diff --git a/apps/api/.env.example b/apps/api/.env.example index aefc6a09f..33f745d6c 100644 --- a/apps/api/.env.example +++ b/apps/api/.env.example @@ -57,6 +57,8 @@ MOONPAY_PROD_URL=https://api.moonpay.com MOONPAY_API_KEY=your-moonpay-api-key COINGECKO_API_KEY=your-coingecko-api-key COINGECKO_API_URL=https://pro-api.coingecko.com/api/v3 +FASTFOREX_API_KEY=your-fastforex-api-key +FASTFOREX_API_URL=https://api.fastforex.io SUBSCAN_API_KEY=your-subscan-api-key # Price Feed Cache Configuration diff --git a/apps/api/src/api/controllers/brla.controller.test.ts b/apps/api/src/api/controllers/brla.controller.test.ts index 66a4d5743..f0a14aa00 100644 --- a/apps/api/src/api/controllers/brla.controller.test.ts +++ b/apps/api/src/api/controllers/brla.controller.test.ts @@ -1,4 +1,4 @@ -import {AveniaAccountType, BrlaApiService} from "@vortexfi/shared"; +import {AveniaAccountType, BrlaApiError, BrlaApiService} from "@vortexfi/shared"; import {afterEach, beforeEach, describe, expect, it, mock} from "bun:test"; import httpStatus from "http-status"; import logger from "../../config/logger"; @@ -139,6 +139,35 @@ describe("getAveniaUser", () => { expect(res.statusCode).toBe(httpStatus.FORBIDDEN); expect(res.body).toEqual({ error: "This tax ID is not linked to your user profile and cannot be used." }); }); + + it("still parses a BrlaApiError 400 into a 400 'Invalid request' with details (message-format invariant)", async () => { + TaxId.findOne = mock(async () => ({ subAccountId: "subaccount-1", userId: "user-1" })) as typeof TaxId.findOne; + BrlaApiService.getInstance = mock( + () => + ({ + subaccountInfo: mock(async () => { + throw new BrlaApiError({ + endpoint: "/v2/account/account-info", + method: "GET", + responseBody: JSON.stringify({ error: "user is blocked" }), + status: 400 + }); + }) + }) as unknown as BrlaApiService + ); + + const res = createResponse(); + await getAveniaUser( + { + query: { taxId: "08786985906" }, + userId: "user-1" + } as any, + res as any + ); + + expect(res.statusCode).toBe(httpStatus.BAD_REQUEST); + expect(res.body).toEqual({ details: { error: "user is blocked" }, error: "Invalid request" }); + }); }); describe("createSubaccount", () => { diff --git a/apps/api/src/api/controllers/ramp.controller.test.ts b/apps/api/src/api/controllers/ramp.controller.test.ts new file mode 100644 index 000000000..14f51358b --- /dev/null +++ b/apps/api/src/api/controllers/ramp.controller.test.ts @@ -0,0 +1,135 @@ +import { AlfredpayApiError, BrlaApiError } from "@vortexfi/shared"; +import { describe, expect, it } from "bun:test"; +import httpStatus from "http-status"; +import { APIError } from "../errors/api-error"; +import { classifyApiClientError } from "../observability/errorClassifier"; +import { mapProviderFailure } from "./ramp.controller"; + +describe("mapProviderFailure", () => { + it("maps a 4xx Avenia rejection (e.g. blocked user) to a 422 with a sanitized public message", () => { + const providerError = new BrlaApiError({ + endpoint: "/v2/account/tickets", + method: "POST", + responseBody: JSON.stringify({ error: "user is blocked" }), + status: 400 + }); + + const { error, logContext } = mapProviderFailure(providerError); + + expect(error).toBeInstanceOf(APIError); + const apiError = error as APIError; + expect(apiError.status).toBe(httpStatus.UNPROCESSABLE_ENTITY); + expect(apiError.isPublic).toBe(true); + // The caller learns which stage failed (the payment provider) but never the raw body. + expect(apiError.message).toContain("payment provider"); + expect(apiError.message.toLowerCase()).not.toContain("blocked"); + expect(apiError.message).not.toContain("user is blocked"); + + // Logging pinpoints exactly which provider call failed, and why (server-side only). + expect(logContext).toEqual({ + provider: "avenia", + providerEndpoint: "/v2/account/tickets", + providerMethod: "POST", + providerResponseBody: JSON.stringify({ error: "user is blocked" }), + providerStatus: 400 + }); + }); + + it("classifies the mapped 4xx error as a provider_error for telemetry", () => { + const { error } = mapProviderFailure( + new BrlaApiError({ + endpoint: "/v2/account/tickets", + method: "POST", + responseBody: JSON.stringify({ error: "user is blocked" }), + status: 400 + }) + ); + + expect(classifyApiClientError(error)).toBe("provider_error"); + }); + + it("maps a 5xx / transport Avenia failure to a 502 provider-unavailable message", () => { + const providerError = new BrlaApiError({ + endpoint: "/v2/account/quote/fixed-rate", + method: "GET", + responseBody: "upstream exploded", + status: 503 + }); + + const { error, logContext } = mapProviderFailure(providerError); + + const apiError = error as APIError; + expect(apiError.status).toBe(httpStatus.BAD_GATEWAY); + expect(apiError.isPublic).toBe(true); + expect(apiError.message).toContain("payment provider"); + expect(apiError.message).not.toContain("upstream exploded"); + expect(logContext.providerStatus).toBe(503); + expect(logContext.providerEndpoint).toBe("/v2/account/quote/fixed-rate"); + }); + + it("also generalizes to Alfredpay provider failures and tags the correct provider", () => { + const providerError = new AlfredpayApiError({ + endpoint: "/customers", + method: "POST", + responseBody: JSON.stringify({ error: "customer rejected" }), + status: 422 + }); + + const { error, logContext } = mapProviderFailure(providerError); + + const apiError = error as APIError; + expect(apiError.status).toBe(httpStatus.UNPROCESSABLE_ENTITY); + expect(apiError.message).toContain("payment provider"); + expect(apiError.message).not.toContain("customer rejected"); + expect(logContext).toEqual({ + provider: "alfredpay", + providerEndpoint: "/customers", + providerMethod: "POST", + providerResponseBody: JSON.stringify({ error: "customer rejected" }), + providerStatus: 422 + }); + expect(classifyApiClientError(error)).toBe("provider_error"); + }); + + it("maps a transport failure (status 0, no HTTP response) to a 502", () => { + const providerError = new BrlaApiError({ + endpoint: "/v2/account/tickets", + method: "POST", + responseBody: "fetch failed: ECONNRESET", + status: 0 + }); + + const { error, logContext } = mapProviderFailure(providerError); + + expect((error as APIError).status).toBe(httpStatus.BAD_GATEWAY); + expect(logContext.providerStatus).toBe(0); + }); + + it("truncates the logged provider response body to bound size/PII", () => { + const providerError = new BrlaApiError({ + endpoint: "/v2/account/tickets", + method: "POST", + responseBody: "x".repeat(5000), + status: 400 + }); + + const { logContext } = mapProviderFailure(providerError); + + expect((logContext.providerResponseBody as string).length).toBe(300); + }); + + it("returns non-provider errors unchanged with empty log context", () => { + const original = new APIError({ message: "Quote has expired", status: httpStatus.BAD_REQUEST }); + + const { error, logContext } = mapProviderFailure(original); + + expect(error).toBe(original); + expect(logContext).toEqual({}); + }); + + it("passes through an unknown non-Error value unchanged", () => { + const { error, logContext } = mapProviderFailure("boom"); + expect(error).toBe("boom"); + expect(logContext).toEqual({}); + }); +}); diff --git a/apps/api/src/api/controllers/ramp.controller.ts b/apps/api/src/api/controllers/ramp.controller.ts index 95cf087c9..88839548c 100644 --- a/apps/api/src/api/controllers/ramp.controller.ts +++ b/apps/api/src/api/controllers/ramp.controller.ts @@ -5,6 +5,7 @@ import { GetRampHistoryResponse, GetRampStatusRequest, GetRampStatusResponse, + ProviderHttpError, RampProcess, StartRampRequest, StartRampResponse, @@ -24,6 +25,51 @@ import { getRequestDurationMs } from "../observability/requestContext"; import { ApiClientOperation } from "../observability/types"; import rampService from "../services/ramp/ramp.service"; +/** + * Translate a raw fiat-provider (Avenia/BRLA, Alfredpay) failure raised while handling a ramp + * request into a caller-facing `APIError` plus structured log context. + * + * - Logging: for a `ProviderHttpError` we surface the exact `provider`/`endpoint`/`method`/ + * `status` of the failing call plus a truncated response body (the reason, e.g. "user is + * blocked") so operators can tell *where* and *why* it failed without guessing. The body is + * logged server-side only and never returned to the caller. A transport failure carries + * `status: 0`, which maps to the same 502 as an upstream `5xx`. + * - Caller message: the provider's raw body (e.g. `{"error":"user is blocked"}`) is never + * forwarded. A `4xx` means the account/request was rejected (unprocessable), a `5xx`/ + * transport failure means the provider is unavailable (bad gateway). Both messages indicate + * the failing stage — the payment provider — without leaking internal detail. The word + * "provider" keeps `classifyApiClientError` reporting this as `provider_error`. + * + * Non-provider errors (validation, ownership, quote state, …) are returned unchanged. + */ +const MAX_LOGGED_PROVIDER_BODY_LENGTH = 300; + +export function mapProviderFailure(error: unknown): { error: unknown; logContext: Record } { + if (!(error instanceof ProviderHttpError)) { + return { error, logContext: {} }; + } + + const isClientRejection = error.status >= 400 && error.status < 500; + const mapped = new APIError({ + isPublic: true, + message: isClientRejection + ? "The payment provider could not process this request for the associated account. Please verify the account status or contact support." + : "The payment provider is temporarily unavailable. Please try again shortly.", + status: isClientRejection ? httpStatus.UNPROCESSABLE_ENTITY : httpStatus.BAD_GATEWAY + }); + + return { + error: mapped, + logContext: { + provider: error.provider, + providerEndpoint: error.endpoint, + providerMethod: error.method, + providerResponseBody: error.responseBody.slice(0, MAX_LOGGED_PROVIDER_BODY_LENGTH), + providerStatus: error.status + } + }; +} + /** * Register a new ramping process * @public @@ -62,9 +108,14 @@ export const registerRamp = async (req: Request, res: Response, nex res.status(httpStatus.CREATED).json(ramp); } catch (error) { - logger.error("Error registering ramp", { errorType: classifyApiClientError(error), requestId: req.requestId }); - observeRampFailure(req, "ramp_register", error, { quoteId: req.body?.quoteId || null }); - next(error); + const { error: mappedError, logContext } = mapProviderFailure(error); + logger.error("Error registering ramp", { + errorType: classifyApiClientError(mappedError), + requestId: req.requestId, + ...logContext + }); + observeRampFailure(req, "ramp_register", mappedError, { quoteId: req.body?.quoteId || null }); + next(mappedError); } }; @@ -114,9 +165,14 @@ export const updateRamp = async ( res.status(httpStatus.OK).json(ramp); } catch (error) { - logger.error("Error updating ramp", { errorType: classifyApiClientError(error), requestId: req.requestId }); - observeRampFailure(req, "ramp_update", error, { rampId: req.body?.rampId || null }); - next(error); + const { error: mappedError, logContext } = mapProviderFailure(error); + logger.error("Error updating ramp", { + errorType: classifyApiClientError(mappedError), + requestId: req.requestId, + ...logContext + }); + observeRampFailure(req, "ramp_update", mappedError, { rampId: req.body?.rampId || null }); + next(mappedError); } }; @@ -156,9 +212,14 @@ export const startRamp = async ( res.status(httpStatus.OK).json(ramp); } catch (error) { - logger.error("Error starting ramp", { errorType: classifyApiClientError(error), requestId: req.requestId }); - observeRampFailure(req, "ramp_start", error, { rampId: req.body?.rampId || null }); - next(error); + const { error: mappedError, logContext } = mapProviderFailure(error); + logger.error("Error starting ramp", { + errorType: classifyApiClientError(mappedError), + requestId: req.requestId, + ...logContext + }); + observeRampFailure(req, "ramp_start", mappedError, { rampId: req.body?.rampId || null }); + next(mappedError); } }; diff --git a/apps/api/src/api/services/phases/base-phase-handler.ts b/apps/api/src/api/services/phases/base-phase-handler.ts index 6670eaa79..49e1ad2e2 100644 --- a/apps/api/src/api/services/phases/base-phase-handler.ts +++ b/apps/api/src/api/services/phases/base-phase-handler.ts @@ -16,9 +16,11 @@ export interface PhaseHandler { /** * Execute the phase * @param state The current ramp state + * @param signal Aborted when the processor gives up on this execution; long-running + * waits must stop when it fires so abandoned executions don't keep running * @returns The updated ramp state */ - execute(state: RampState): Promise; + execute(state: RampState, signal?: AbortSignal): Promise; /** * Get the phase name @@ -41,7 +43,7 @@ export abstract class BasePhaseHandler implements PhaseHandler { * @param state The current ramp state * @returns The updated ramp state */ - public async execute(state: RampState): Promise { + public async execute(state: RampState, signal?: AbortSignal): Promise { try { logger.info(`Executing phase ${this.getPhaseName()} for ramp ${state.id}`); @@ -54,7 +56,7 @@ export abstract class BasePhaseHandler implements PhaseHandler { } // Execute the phase - const updatedState = await this.executePhase(state); + const updatedState = await this.executePhase(state, signal); // Log the phase execution logger.info(`Phase ${this.getPhaseName()} executed successfully for ramp ${state.id}`); @@ -90,9 +92,11 @@ export abstract class BasePhaseHandler implements PhaseHandler { /** * Execute the phase implementation * @param state The current ramp state + * @param signal Aborted when the processor abandons this execution; implementations + * with long-running waits should pass it to their polling helpers * @returns The updated ramp state */ - protected abstract executePhase(state: RampState): Promise; + protected abstract executePhase(state: RampState, signal?: AbortSignal): Promise; /** * Transition to the next phase diff --git a/apps/api/src/api/services/phases/handlers/brla-onramp-mint-handler.ts b/apps/api/src/api/services/phases/handlers/brla-onramp-mint-handler.ts index a82dfd06b..86eeb9db1 100644 --- a/apps/api/src/api/services/phases/handlers/brla-onramp-mint-handler.ts +++ b/apps/api/src/api/services/phases/handlers/brla-onramp-mint-handler.ts @@ -26,10 +26,13 @@ import { BasePhaseHandler } from "../base-phase-handler"; import { syncAveniaOnHoldState } from "../helpers/brla-onramp-hold"; import { StateMetadata } from "../meta-state-types"; -// The rationale for these difference is that it allows for a finer check over the payment timeout in -// case of service restart. A smaller timeout for the balance check loop allows to get out to the outer -// process loop and check for the operation timestamp. +// The check loops use a smaller timeout than the overall payment timeout so each execution +// returns to the outer process loop well below the processor's execution timeout and checks +// the operation timestamp there. The previous 30-minute Avenia wait always outlived the +// processor's 10-minute race, so the payment-timeout cancellation below never ran for +// recovered ramps and abandoned executions piled up. const PAYMENT_TIMEOUT_MS = 30 * 60 * 1000; // 30 minutes +const AVENIA_BALANCE_CHECK_TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes const EVM_BALANCE_CHECK_TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes const AVENIA_HOLD_STATUS_CHECK_INTERVAL_MS = 60 * 1000; // 1 minute @@ -45,7 +48,7 @@ export class BrlaOnrampMintHandler extends BasePhaseHandler { return "brlaOnrampMint"; } - protected async executePhase(state: RampState): Promise { + protected async executePhase(state: RampState, signal?: AbortSignal): Promise { const { evmEphemeralAddress } = state.state as StateMetadata; if (!evmEphemeralAddress) { @@ -138,7 +141,8 @@ export class BrlaOnrampMintHandler extends BasePhaseHandler { return Number(balances.BRLA) >= Number(Big(quote.metadata.aveniaMint.outputAmountDecimal).toFixed(2, 0)); }, 5000, - PAYMENT_TIMEOUT_MS + AVENIA_BALANCE_CHECK_TIMEOUT_MS, + signal ); } catch (error) { const isCheckTimeout = error instanceof Error && error.message.includes("Timeout"); @@ -202,7 +206,8 @@ export class BrlaOnrampMintHandler extends BasePhaseHandler { expectedAmountReceived, pollingTimeMs, EVM_BALANCE_CHECK_TIMEOUT_MS, - Networks.Base + Networks.Base, + signal ); } catch (error) { if (!(error instanceof BalanceCheckError)) throw error; diff --git a/apps/api/src/api/services/phases/phase-processor.cancellation.integration.test.ts b/apps/api/src/api/services/phases/phase-processor.cancellation.integration.test.ts new file mode 100644 index 000000000..53f2c1f22 --- /dev/null +++ b/apps/api/src/api/services/phases/phase-processor.cancellation.integration.test.ts @@ -0,0 +1,151 @@ +import { afterAll, beforeAll, describe, expect, it } from "bun:test"; +import { waitUntilTrue } from "@vortexfi/shared"; +import RampState from "../../../models/rampState.model"; +import { resetTestDatabase, setupTestDatabase } from "../../../test-utils/db"; +import { createTestRampState } from "../../../test-utils/factories"; +import type { PhaseHandler } from "./base-phase-handler"; +import phaseRegistry from "./phase-registry"; + +/** + * Regression test for the 2026-07 production CPU leak: the processor's execution + * timeout raced handler.execute without cancelling it, so every timed-out + * execution (e.g. a BRL onramp waiting for a payment that never arrives) left + * its polling loop running forever. Leaked loops accumulated with each retry + * and each recovery-worker pass until the CPU pegged. + * + * The processor must now hand each execution an AbortSignal, abort it on + * timeout, and signal-aware polling helpers must stop. + */ + +// Shrink the processor's timeouts before the class is instantiated. Originals are +// snapshotted and restored in afterAll so the overrides can't leak into other tests. +const OVERRIDDEN_ENV_VARS = ["PHASE_PROCESSOR_MAX_EXECUTION_TIME_MS", "PHASE_PROCESSOR_RETRY_DELAY_MS"]; +const originalEnv = new Map(OVERRIDDEN_ENV_VARS.map(name => [name, process.env[name]])); +process.env.PHASE_PROCESSOR_MAX_EXECUTION_TIME_MS = "150"; +process.env.PHASE_PROCESSOR_RETRY_DELAY_MS = "10"; + +const TEST_PHASE = "nablaSwap"; + +describe("PhaseProcessor execution cancellation", () => { + let polls = 0; + const receivedSignals: (AbortSignal | undefined)[] = []; + + const hangingHandler: PhaseHandler = { + execute: async (_state: RampState, signal?: AbortSignal) => { + receivedSignals.push(signal); + // Poll forever, like a phase waiting for a payment that never arrives. + await waitUntilTrue( + async () => { + polls++; + return false; + }, + 5, + signal + ); + throw new Error("unreachable"); + }, + getMaxRetries: () => 2, + getPhaseName: () => TEST_PHASE + }; + + // phaseRegistry is a process-wide singleton: shadow the real handler for the + // duration of this file only, and restore (or remove) it in afterAll. + const originalHandler = phaseRegistry.getHandler(TEST_PHASE); + + beforeAll(async () => { + await setupTestDatabase(); + await resetTestDatabase(); + phaseRegistry.registerHandler(hangingHandler); + }); + + afterAll(() => { + if (originalHandler) { + phaseRegistry.registerHandler(originalHandler); + } else { + // The registry has no unregister API; drop the shadow entry directly. + (phaseRegistry as unknown as { handlers: Map }).handlers.delete(TEST_PHASE); + } + for (const name of OVERRIDDEN_ENV_VARS) { + const originalValue = originalEnv.get(name); + if (originalValue === undefined) { + delete process.env[name]; + } else { + process.env[name] = originalValue; + } + } + }); + + it("aborts abandoned executions so their polling loops stop", async () => { + const state = await createTestRampState({ currentPhase: TEST_PHASE }); + + const { PhaseProcessor } = await import("./phase-processor"); + const processor = new PhaseProcessor(); + await processor.processRamp(state.id); + + // The handler timed out, was retried up to getMaxRetries, then given up on. + expect(receivedSignals.length).toBeGreaterThanOrEqual(2); + expect(receivedSignals.every(signal => signal instanceof AbortSignal)).toBe(true); + expect(receivedSignals.every(signal => signal?.aborted)).toBe(true); + + // Regression: without cancellation the abandoned loops keep polling forever. + const pollsAfterGivingUp = polls; + await new Promise(resolve => setTimeout(resolve, 100)); + expect(polls).toBe(pollsAfterGivingUp); + + // The processor released the ramp for future processing. + const reloaded = await RampState.findByPk(state.id); + expect(reloaded?.processingLock.locked).toBe(false); + }); + + it("cleans up the timeout timer when a handler throws synchronously", async () => { + const unhandled: unknown[] = []; + const onUnhandled = (reason: unknown) => { + unhandled.push(reason); + }; + process.on("unhandledRejection", onUnhandled); + + const syncThrowHandler: PhaseHandler = { + // Deliberately not async: thrown before Promise.race is entered, this used to + // leak the timeout timer, whose later rejection nobody handled. + execute: () => { + throw new Error("sync boom"); + }, + getPhaseName: () => TEST_PHASE + }; + phaseRegistry.registerHandler(syncThrowHandler); + + try { + const state = await createTestRampState({ currentPhase: TEST_PHASE }); + const { PhaseProcessor } = await import("./phase-processor"); + const processor = new PhaseProcessor(); + await processor.processRamp(state.id); + + // Wait past MAX_EXECUTION_TIME_MS (150ms): a leaked timer would reject the + // never-awaited timeout promise as an unhandled rejection. + await new Promise(resolve => setTimeout(resolve, 300)); + expect(unhandled).toEqual([]); + + // The sync throw still goes through the normal error path and releases the lock. + const reloaded = await RampState.findByPk(state.id); + expect(reloaded?.processingLock.locked).toBe(false); + } finally { + process.off("unhandledRejection", onUnhandled); + phaseRegistry.registerHandler(hangingHandler); + } + }); + + it("falls back to default timeouts when env overrides are malformed", async () => { + process.env.PHASE_PROCESSOR_MAX_EXECUTION_TIME_MS = "banana"; + process.env.PHASE_PROCESSOR_RETRY_DELAY_MS = "-5"; + try { + const { PhaseProcessor } = await import("./phase-processor"); + const processor = new PhaseProcessor() as unknown as { MAX_EXECUTION_TIME_MS: number; DEFAULT_RETRY_DELAY_MS: number }; + // A NaN here would make setTimeout fire immediately and time out every phase instantly. + expect(processor.MAX_EXECUTION_TIME_MS).toBe(600000); + expect(processor.DEFAULT_RETRY_DELAY_MS).toBe(30000); + } finally { + process.env.PHASE_PROCESSOR_MAX_EXECUTION_TIME_MS = "150"; + process.env.PHASE_PROCESSOR_RETRY_DELAY_MS = "10"; + } + }); +}); diff --git a/apps/api/src/api/services/phases/phase-processor.ts b/apps/api/src/api/services/phases/phase-processor.ts index 72d111047..37b791c75 100644 --- a/apps/api/src/api/services/phases/phase-processor.ts +++ b/apps/api/src/api/services/phases/phase-processor.ts @@ -7,6 +7,13 @@ import { APIError } from "../../errors/api-error"; import { PhaseError, RecoverablePhaseError } from "../../errors/phase-error"; import phaseRegistry from "./phase-registry"; +// A malformed env override must fall back to the default: parseInt would yield NaN +// and setTimeout(..., NaN) fires immediately, which would time out every phase instantly. +function positiveIntFromEnv(value: string | undefined, fallback: number): number { + const parsed = value ? parseInt(value, 10) : Number.NaN; + return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback; +} + /** * Process phases for a ramping process */ @@ -14,9 +21,10 @@ export class PhaseProcessor { private static instance: PhaseProcessor; private retriesMap = new Map(); private readonly MAX_RETRIES = 8; - private readonly MAX_EXECUTION_TIME_MS = 10 * 60 * 1000; // 10 minutes + // Overridable so tests don't wait 10 minutes for the execution timeout. + private readonly MAX_EXECUTION_TIME_MS = positiveIntFromEnv(process.env.PHASE_PROCESSOR_MAX_EXECUTION_TIME_MS, 600000); // 10 minutes // Overridable so tests don't wait 30s between recoverable-error retries. - private readonly DEFAULT_RETRY_DELAY_MS = parseInt(process.env.PHASE_PROCESSOR_RETRY_DELAY_MS || "30000", 10); + private readonly DEFAULT_RETRY_DELAY_MS = positiveIntFromEnv(process.env.PHASE_PROCESSOR_RETRY_DELAY_MS, 30000); private lockedRamps = new Set(); /** @@ -176,15 +184,26 @@ export class PhaseProcessor { // Execute the phase with a maximum waiting time // If the phase execution exceeds this time, we consider it a timeout and handle it as a recoverable error. + // The abort signal tells the (otherwise unstoppable) execution to wind down: without it, every + // timed-out execution kept its polling loops running forever and they piled up until the CPU pegged. const maxExecuteTime = this.MAX_EXECUTION_TIME_MS; + const abortController = new AbortController(); let timeoutId: NodeJS.Timeout; const timeoutPromise = new Promise((_, reject) => { timeoutId = setTimeout(() => { - reject(new RecoverablePhaseError("Phase execution timed out")); + const timeoutError = new RecoverablePhaseError("Phase execution timed out"); + abortController.abort(timeoutError); + reject(timeoutError); }, maxExecuteTime); }); - const pendingState = await Promise.race([handler.execute(state), timeoutPromise]).finally(() => { + // Wrap the handler call so a synchronous throw becomes a rejection of the race: + // thrown eagerly inside the array literal, it would skip the .finally and leak + // the timeout timer (whose later rejection nobody handles). + const pendingState = await Promise.race([ + Promise.resolve().then(() => handler.execute(state, abortController.signal)), + timeoutPromise + ]).finally(() => { clearTimeout(timeoutId); }); diff --git a/apps/api/src/api/services/priceFeed.service.test.ts b/apps/api/src/api/services/priceFeed.service.test.ts index 2fe57980b..9319d41af 100644 --- a/apps/api/src/api/services/priceFeed.service.test.ts +++ b/apps/api/src/api/services/priceFeed.service.test.ts @@ -1,226 +1,115 @@ // eslint-disable-next-line import/no-unresolved import {afterAll, afterEach, beforeEach, describe, expect, it, mock} from "bun:test"; -// Import the mocked function to check calls -import {getTokenOutAmount as getTokenOutAmountMock, type RampCurrency} from "@vortexfi/shared"; +import type {RampCurrency} from "@vortexfi/shared"; +// Captured before the mock.module calls below so afterAll can restore the real +// modules. bun module mocks are process-wide: leaving "@vortexfi/shared" stubbed +// poisons later test files that resolve real token configs (e.g. the corridor +// integration tests, whose top-level helpers throw "token config missing"). import * as sharedNamespace from "@vortexfi/shared"; import * as loggerNamespace from "../../config/logger"; -import {PriceFeedService, priceFeedService} from "./priceFeed.service"; -// Value copies taken before mock.module runs — bun module mocks are -// process-wide, so afterAll below restores the real modules for later files. const sharedReal = { ...sharedNamespace }; const loggerReal = { ...loggerNamespace }; -afterAll(() => { - mock.module("@vortexfi/shared", () => ({ ...sharedReal })); - mock.module("../../config/logger", () => ({ ...loggerReal })); -}); +const ARS = "ARS" as RampCurrency; +const BRL = "BRL" as RampCurrency; +const COP = "COP" as RampCurrency; +const ETH = "ETH" as RampCurrency; +const EUR = "EUR" as RampCurrency; +const MXN = "MXN" as RampCurrency; +const USD = "USD" as RampCurrency; +const USDC = "USDC" as RampCurrency; +const USDT = "USDT" as RampCurrency; + +const FASTFOREX_TEST_FIATS = [EUR, ARS, BRL, MXN, COP] as const; + +const originalEnv = { ...process.env }; +const originalFetch = global.fetch; + +// config/vars snapshots the environment at first import (which may happen in an +// earlier test file), so deterministic values are set on the instance instead. +const testInstanceConfig = { + coingeckoApiBaseUrl: "https://api.coingecko.com/api/v3", + coingeckoApiKey: "test-api-key", + cryptoCacheTtlMs: 300000, + fastforexApiBaseUrl: "https://api.fastforex.io", + fastforexApiKey: "test-fastforex-key", + fiatCacheTtlMs: 300000 +}; + +const loggerMock = { + debug: mock(() => {}), + error: mock(() => {}), + info: mock(() => {}), + warn: mock(() => {}) +}; -// Mock all external dependencies mock.module("@vortexfi/shared", () => ({ - ...sharedReal, - ApiManager: { - getInstance: mock(() => ({ - getApi: mock(async () => ({ - api: {}, - decimals: 12, - ss58Format: 42 - })) - })) - }, - EvmToken: { - USDC: "USDC", - USDCE: "USDC.e", - USDT: "USDT" - }, - getPendulumDetails: mock((currency: string) => { - // Provide slightly different mock details based on currency for realism - if (currency === "USD") { - return { - pendulumAssetSymbol: "USD", - pendulumCurrencyId: { Token: "USD" }, - pendulumDecimals: 6, - pendulumErc20WrapperAddress: "0xUSD" - }; - } - if (currency === "BRL") { - return { - pendulumAssetSymbol: "BRL", - pendulumCurrencyId: { Token: "BRL" }, - pendulumDecimals: 6, - pendulumErc20WrapperAddress: "0xBRL" - }; - } - return { - pendulumAssetSymbol: "TEST", - pendulumCurrencyId: { XCM: 1 }, - pendulumDecimals: 12, - // Default fallback - pendulumErc20WrapperAddress: "0x123" - }; - }), - getTokenOutAmount: mock(async () => ({ - effectiveExchangeRate: "1.25", - preciseQuotedAmountOut: { - preciseBigDecimal: { - toString: () => "1.25" - } - }, - roundedDownQuotedAmountOut: { - toString: () => "1.25" - }, - swapFee: { - toString: () => "0.01" - } - })), - getTokenUsdPrice: mock(() => undefined), - isFiatToken: mock((currency: string) => ["BRL", "EUR", "ARS"].includes(currency)), - normalizeTokenSymbol: mock((currency: string) => currency), - PENDULUM_USDC_AXL: { - pendulumAssetSymbol: "USDC", - pendulumCurrencyId: { Token: "USDC" }, - pendulumDecimals: 6, - pendulumErc20WrapperAddress: "0xUSDC" - }, - RampCurrency: { - ARS: "ARS", - AVAX: "AVAX", - BNB: "BNB", - BRL: "BRL", - ETH: "ETH", - EUR: "EUR", - GLMR: "GLMR", - MATIC: "MATIC", - USD: "USD", - USDC: "USDC", - USDCE: "USDC.e", - USDT: "USDT" - }, - UsdLikeEvmToken: { - USDC: "USDC", - USDCE: "USDC.e", - USDT: "USDT" - } + EvmToken: { USDC: "USDC", USDCE: "USDC.e", USDT: "USDT" }, + getTokenUsdPrice: () => undefined, + isFiatToken: (currency: string) => ["BRL", "EUR", "ARS", "MXN", "COP", "USD"].includes(currency), + normalizeTokenSymbol: (symbol: string) => symbol, + RampCurrency: { ARS: "ARS", BRL: "BRL", COP: "COP", EUR: "EUR", MXN: "MXN", USD: "USD" }, + UsdLikeEvmToken: { USDC: "USDC", USDCE: "USDC.e", USDT: "USDT" } })); mock.module("../../config/logger", () => ({ - default: { - debug: mock(() => { - /* logger mock */ - }), - error: mock(() => { - /* logger mock */ - }), - info: mock(() => { - /* logger mock */ - }), - warn: mock(() => { - /* logger mock */ - }) - } + default: loggerMock })); -// Mock the app initialization -mock.module("../../../index", () => ({})); +const { PriceFeedService, priceFeedService } = await import("./priceFeed.service"); +const { config } = await import("../../config/vars"); describe("PriceFeedService", () => { - // Mock data - const mockCoinGeckoResponse = { - bitcoin: { - usd: 50000 - }, - ethereum: { - usd: 3000 - }, - moonbeam: { - usd: 100 - } - }; - - // Original fetch and env for restoration - const originalFetch = global.fetch; let originalDateNow: () => number; - let originalEnv: NodeJS.ProcessEnv; let fetchMock: ReturnType; - // Setup and teardown + const mockFastforexResponse = (rate: number, currency: RampCurrency = BRL) => + new Response(JSON.stringify({ base: "USD", result: { [currency]: rate }, updated: "2026-06-03T00:00:00Z", ms: 4 }), { + headers: { "content-type": "application/json" }, + status: 200 + }); + + const mockCoinGeckoResponse = (data: unknown) => + new Response(JSON.stringify(data), { + headers: { "content-type": "application/json" }, + status: 200 + }); + beforeEach(() => { - // Store original env and Date.now - originalEnv = { ...process.env }; originalDateNow = Date.now; - - // Mock environment variables for each test - process.env = { - ...originalEnv, // Start with original to avoid missing Node internal vars - COINGECKO_API_KEY: "test-api-key", - COINGECKO_API_URL: "https://api.coingecko.com/api/v3", - CRYPTO_CACHE_TTL_MS: "300000", // 5 minutes - FIAT_CACHE_TTL_MS: "300000" // 5 minutes - } as any; - - // Create a fresh fetch mock for each test - fetchMock = mock(() => - Promise.resolve({ - clone() { - return this; - }, - headers: new Headers(), - json: () => Promise.resolve(mockCoinGeckoResponse), - ok: true, - redirected: false, - status: 200, - statusText: "OK", - text: () => Promise.resolve(JSON.stringify(mockCoinGeckoResponse)), - type: "basic", - url: "" - } as Response) - ); - - // Mock fetch - global.fetch = fetchMock as any; - - // Reset mocks before each test - (getTokenOutAmountMock as any).mockClear(); - // Reset Nabla mock to default implementation if needed (if tests modify its behavior) - (getTokenOutAmountMock as any).mockImplementation(async () => ({ - effectiveExchangeRate: "1.25", - preciseQuotedAmountOut: { preciseBigDecimal: { toString: () => "1.25" } }, - roundedDownQuotedAmountOut: { toString: () => "1.25" }, - swapFee: { toString: () => "0.01" } - })); - - // Ensure singleton is reset *before* each test to pick up fresh env vars/mocks - // @ts-expect-error - accessing private property for testing - PriceFeedService.instance = undefined; - - // The exported module-level singleton keeps its caches across tests; without - // clearing them, cache-hit/miss and fetch call-count assertions depend on test order. - (priceFeedService as any).cryptoPriceCache.clear(); - (priceFeedService as any).fiatExchangeRateCache.clear(); + fetchMock = mock(async () => mockFastforexResponse(5.85)); + global.fetch = fetchMock as unknown as typeof fetch; + Object.values(loggerMock).forEach(logger => logger.mockClear()); + Reflect.set(PriceFeedService, "instance", undefined); + Object.assign(PriceFeedService.getInstance(), testInstanceConfig); }); afterEach(() => { - // Restore fetch + Date.now = originalDateNow; global.fetch = originalFetch; + Reflect.set(PriceFeedService, "instance", undefined); + }); - // Restore Date.now if it was mocked - if (originalDateNow) { - Date.now = originalDateNow; + afterAll(() => { + for (const key of ["COINGECKO_API_URL", "CRYPTO_CACHE_TTL_MS", "FIAT_CACHE_TTL_MS"]) { + const originalValue = originalEnv[key]; + if (originalValue === undefined) { + delete process.env[key]; + } else { + process.env[key] = originalValue; + } } - - // Restore environment variables - process.env = originalEnv; - - // Reset singleton *after* restoring env, ready for next test's beforeEach - // @ts-expect-error - accessing private property for testing - PriceFeedService.instance = undefined; + global.fetch = originalFetch; + Reflect.set(PriceFeedService, "instance", undefined); + // Restore the real modules so this file's stubs don't leak into later files. + mock.module("@vortexfi/shared", () => ({ ...sharedReal })); + mock.module("../../config/logger", () => ({ ...loggerReal })); }); describe("Singleton Pattern", () => { - it("should return the same instance when getInstance is called multiple times", () => { - const instance1 = PriceFeedService.getInstance(); - const instance2 = PriceFeedService.getInstance(); - expect(instance1).toBe(instance2); + it("should return the same instance", () => { + expect(PriceFeedService.getInstance()).toBe(PriceFeedService.getInstance()); }); it("should export a singleton instance", () => { @@ -230,385 +119,457 @@ describe("PriceFeedService", () => { describe("getCryptoPrice", () => { it("should fetch price from CoinGecko API when cache is empty", async () => { - const price = await priceFeedService.getCryptoPrice("bitcoin", "usd"); + const instance = PriceFeedService.getInstance(); + fetchMock = mock(async () => mockCoinGeckoResponse({ bitcoin: { usd: 50000 } })); + global.fetch = fetchMock as unknown as typeof fetch; + + const price = await instance.getCryptoPrice("bitcoin", "usd"); expect(price).toBe(50000); - expect(fetchMock).toHaveBeenCalledTimes(1); - // Use a more flexible expectation for the URL - // Don't check the exact URL, just verify it was called - expect(fetchMock).toHaveBeenCalledTimes(1); + expect(fetchMock).toHaveBeenCalledWith( + "https://api.coingecko.com/api/v3/simple/price?ids=bitcoin&vs_currencies=usd", + expect.objectContaining({ + headers: expect.objectContaining({ + Accept: "application/json", + "x-cg-pro-api-key": "test-api-key" + }) + }) + ); }); - it("should return cached price without API call when cache is valid", async () => { - // First call to populate cache - await priceFeedService.getCryptoPrice("bitcoin", "usd"); - - // Reset mock to verify it's not called again - fetchMock.mockClear(); + it("should work without API key", async () => { + const instance = PriceFeedService.getInstance(); + Reflect.set(instance, "coingeckoApiKey", undefined); + fetchMock = mock(async () => mockCoinGeckoResponse({ bitcoin: { usd: 50000 } })); + global.fetch = fetchMock as unknown as typeof fetch; - // Second call should use cache - const price = await priceFeedService.getCryptoPrice("bitcoin", "usd"); + const price = await instance.getCryptoPrice("bitcoin", "usd"); expect(price).toBe(50000); - expect(fetchMock).not.toHaveBeenCalled(); + expect(fetchMock).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ + headers: expect.not.objectContaining({ + "x-cg-pro-api-key": expect.any(String) + }) + }) + ); }); - it("should make a new API call when cache expires", async () => { - // Override TTL to a small value for testing - process.env.CRYPTO_CACHE_TTL_MS = "100"; - - // Reset singleton to apply new TTL - // @ts-expect-error - accessing private property for testing - PriceFeedService.instance = undefined; - const serviceInstance = PriceFeedService.getInstance(); // Get the new instance - Object.assign(serviceInstance, { cryptoCacheTtlMs: 100 }); - - // Mock Date.now to return a fixed timestamp - const startTime = 1000000; - Date.now = () => startTime; + it("should return cached crypto price without a second API call", async () => { + const instance = PriceFeedService.getInstance(); + fetchMock = mock(async () => mockCoinGeckoResponse({ bitcoin: { usd: 50000 } })); + global.fetch = fetchMock as unknown as typeof fetch; - // First call to populate cache - await serviceInstance.getCryptoPrice("bitcoin", "usd"); - expect(fetchMock).toHaveBeenCalledTimes(1); + await instance.getCryptoPrice("bitcoin", "usd"); fetchMock.mockClear(); - // Advance time beyond the cache TTL by changing Date.now - Date.now = () => startTime + 150; // 150ms later + const price = await instance.getCryptoPrice("bitcoin", "usd"); - // Second call should make a new API call - await serviceInstance.getCryptoPrice("bitcoin", "usd"); - expect(fetchMock).toHaveBeenCalledTimes(1); // Verify the second call happened + expect(price).toBe(50000); + expect(fetchMock).not.toHaveBeenCalled(); }); - it("should throw an error when token ID is not provided", async () => { - await expect(priceFeedService.getCryptoPrice("", "usd")).rejects.toThrow("Token ID and currency are required"); + it("should reject missing token or currency", async () => { + const instance = PriceFeedService.getInstance(); + + await expect(instance.getCryptoPrice("", "usd")).rejects.toThrow("Token ID and currency are required"); + await expect(instance.getCryptoPrice("bitcoin", "")).rejects.toThrow("Token ID and currency are required"); + expect(fetchMock).not.toHaveBeenCalled(); }); - it("should throw an error when currency is not provided", async () => { - await expect(priceFeedService.getCryptoPrice("bitcoin", "")).rejects.toThrow("Token ID and currency are required"); + it("should throw when CoinGecko returns a non-OK response", async () => { + const instance = PriceFeedService.getInstance(); + fetchMock = mock(async () => new Response("Rate limit exceeded", { status: 429, statusText: "Too Many Requests" })); + global.fetch = fetchMock as unknown as typeof fetch; + + await expect(instance.getCryptoPrice("bitcoin", "usd")).rejects.toThrow("CoinGecko API error: 429 Too Many Requests"); }); - it("should throw an error when CoinGecko API returns non-OK response", async () => { - // Create a new instance to avoid cache issues - // @ts-expect-error - accessing private property for testing - PriceFeedService.instance = undefined; - const freshInstance = PriceFeedService.getInstance(); + it("should throw when CoinGecko response does not contain the requested token or currency", async () => { + const instance = PriceFeedService.getInstance(); + fetchMock = mock(async () => mockCoinGeckoResponse({})); + global.fetch = fetchMock as unknown as typeof fetch; + + await expect(instance.getCryptoPrice("bitcoin", "usd")).rejects.toThrow("Token 'bitcoin' not found in CoinGecko response"); + + fetchMock = mock(async () => mockCoinGeckoResponse({ bitcoin: {} })); + global.fetch = fetchMock as unknown as typeof fetch; + await expect(instance.getCryptoPrice("bitcoin", "eur")).rejects.toThrow("Currency 'eur' not found for token 'bitcoin'"); + }); + }); - // Override fetch mock for this test with a non-OK response - const errorResponse = { - clone() { - return this; - }, - headers: new Headers(), - json: () => Promise.resolve({}), - ok: false, - redirected: false, // Return empty object instead of rejecting - status: 429, - statusText: "Too Many Requests", - text: () => Promise.resolve("Rate limit exceeded"), - type: "basic", - url: "" - } as Response; + describe("getUsdToFiatExchangeRate", () => { + it("should use fastforex as primary source", async () => { + const instance = PriceFeedService.getInstance(); + instance.getCryptoPrice = mock(async () => 5.86); - // Use any type assertion to bypass TypeScript errors - global.fetch = (() => Promise.resolve(errorResponse)) as any; + const rate = await instance.getUsdToFiatExchangeRate(BRL); - await expect(freshInstance.getCryptoPrice("bitcoin", "usd")).rejects.toThrow( - "CoinGecko API error: 429 Too Many Requests" + expect(rate).toBe(5.85); + expect(fetchMock).toHaveBeenCalledWith( + "https://api.fastforex.io/fetch-one?from=USD&to=BRL", + expect.anything() ); + expect(instance.getCryptoPrice).toHaveBeenCalledWith("usd-coin", "brl"); + const [, options] = fetchMock.mock.calls[0] as [string, { headers: Headers }]; + expect(options.headers.get("Accept")).toBe("application/json"); + expect(options.headers.get("X-API-Key")).toBe("test-fastforex-key"); }); - it("should throw an error when token is not found in CoinGecko response", async () => { - // Override fetch mock for this test with an empty response - const emptyResponseMock = mock(() => - Promise.resolve({ - clone() { - return this; - }, - headers: new Headers(), // Empty data - json: () => Promise.resolve({}), - ok: true, - redirected: false, - status: 200, - statusText: "OK", - text: () => Promise.resolve("{}"), - type: "basic", - url: "" - } as Response) - ); + it("should preserve path components in configured fastforex base URL", async () => { + const instance = PriceFeedService.getInstance(); + Reflect.set(instance, "fastforexApiBaseUrl", "https://api.fastforex.io/v1"); + instance.getCryptoPrice = mock(async () => 5.86); - global.fetch = emptyResponseMock as any; + await instance.getUsdToFiatExchangeRate(BRL); - await expect(priceFeedService.getCryptoPrice("unknown-token", "usd")).rejects.toThrow( - "Token 'unknown-token' not found in CoinGecko response" - ); + expect(fetchMock).toHaveBeenCalledWith("https://api.fastforex.io/v1/fetch-one?from=USD&to=BRL", expect.anything()); }); - it("should throw an error when currency is not found for token", async () => { - // Override fetch mock for this test with a partial response - const partialResponseMock = mock(() => - Promise.resolve({ - clone() { - return this; - }, - headers: new Headers(), // Token exists, currency doesn't - json: () => Promise.resolve({ bitcoin: {} }), - ok: true, - redirected: false, - status: 200, - statusText: "OK", - text: () => Promise.resolve('{ "bitcoin": {} }'), - type: "basic", - url: "" - } as Response) - ); + it("should return cached rate on second call", async () => { + const instance = PriceFeedService.getInstance(); + const getCryptoPriceMock = mock(async () => 5.86); + instance.getCryptoPrice = getCryptoPriceMock; - global.fetch = partialResponseMock as any; + await instance.getUsdToFiatExchangeRate(BRL); + fetchMock.mockClear(); + getCryptoPriceMock.mockClear(); - await expect(priceFeedService.getCryptoPrice("bitcoin", "unknown-currency")).rejects.toThrow( - "Currency 'unknown-currency' not found for token 'bitcoin'" - ); + const rate = await instance.getUsdToFiatExchangeRate(BRL); + expect(rate).toBe(5.85); + expect(fetchMock).not.toHaveBeenCalled(); + expect(instance.getCryptoPrice).not.toHaveBeenCalled(); }); - it("should handle network errors during fetch", async () => { - // Create a new instance to avoid cache issues - // @ts-expect-error - accessing private property for testing - PriceFeedService.instance = undefined; - const freshInstance = PriceFeedService.getInstance(); + it("should refetch after cache expires", async () => { + const instance = PriceFeedService.getInstance(); + let callCount = 0; + fetchMock = mock(async () => mockFastforexResponse(++callCount === 1 ? 5.85 : 5.9)); + global.fetch = fetchMock as unknown as typeof fetch; + instance.getCryptoPrice = mock(async () => (callCount === 1 ? 5.86 : 5.91)); - // Override fetch with a function that rejects - global.fetch = (() => Promise.reject(new Error("Network error"))) as any; + const startTime = 1000000; + Date.now = () => startTime; + await instance.getUsdToFiatExchangeRate(BRL); - await expect(freshInstance.getCryptoPrice("bitcoin", "usd")).rejects.toThrow("Network error"); + Date.now = () => startTime + 400_000; + const rate = await instance.getUsdToFiatExchangeRate(BRL); + expect(rate).toBe(5.9); + expect(fetchMock).toHaveBeenCalledTimes(2); }); - it("should attach the CoinGecko pro API key header when a key is configured", async () => { - // @ts-expect-error - accessing private property for testing - PriceFeedService.instance = undefined; - const serviceInstance = PriceFeedService.getInstance(); - // The constructor reads config/vars (snapshotted at import), so the key is - // controlled on the instance rather than via process.env. - Object.assign(serviceInstance, { coingeckoApiKey: "test-api-key" }); + it("should fall back to CoinGecko when fastforex fails", async () => { + const instance = PriceFeedService.getInstance(); + fetchMock = mock(async () => new Response("fastforex down", { status: 500 })); + global.fetch = fetchMock as unknown as typeof fetch; + instance.getCryptoPrice = mock(async () => 5.92); - await serviceInstance.getCryptoPrice("bitcoin", "usd"); + const rate = await instance.getUsdToFiatExchangeRate(BRL); - expect(fetchMock).toHaveBeenCalledWith( - expect.any(String), - expect.objectContaining({ - headers: expect.objectContaining({ "x-cg-pro-api-key": "test-api-key" }) - }) - ); + expect(rate).toBe(5.92); + expect(instance.getCryptoPrice).toHaveBeenCalledWith("usd-coin", "brl"); }); - it("should work without API key", async () => { - // @ts-expect-error - accessing private property for testing - PriceFeedService.instance = undefined; - const serviceInstance = PriceFeedService.getInstance(); - Object.assign(serviceInstance, { coingeckoApiKey: undefined }); + it("should skip fastforex and fall back to CoinGecko when fastforex key is missing", async () => { + const instance = PriceFeedService.getInstance(); + Reflect.set(instance, "fastforexApiKey", undefined); + instance.getCryptoPrice = mock(async () => 5.92); - await serviceInstance.getCryptoPrice("bitcoin", "usd"); + const rate = await instance.getUsdToFiatExchangeRate(BRL); - expect(fetchMock).toHaveBeenCalledWith( - expect.any(String), - expect.objectContaining({ - headers: expect.not.objectContaining({ - "x-cg-pro-api-key": expect.any(String) // The header production actually sets - }) - }) - ); + expect(rate).toBe(5.92); + expect(fetchMock).not.toHaveBeenCalled(); + expect(instance.getCryptoPrice).toHaveBeenCalledWith("usd-coin", "brl"); }); - }); - describe("getFiatExchangeRate", () => { - // Add validation to the service mock for these tests - beforeEach(() => { - // Add validation to the mock implementation - (getTokenOutAmountMock as any).mockImplementation(async (params: any) => { - if (!params.fromAmountString || !params.inputTokenPendulumDetails || !params.outputTokenPendulumDetails) { - throw new Error("Missing required parameters"); - } - return { - effectiveExchangeRate: "1.25", - preciseQuotedAmountOut: { preciseBigDecimal: { toString: () => "1.25" } }, - roundedDownQuotedAmountOut: { toString: () => "1.25" }, - swapFee: { toString: () => "0.01" } - }; + it("should throw when both fastforex and CoinGecko fail", async () => { + const instance = PriceFeedService.getInstance(); + fetchMock = mock(async () => new Response("fastforex down", { status: 500 })); + global.fetch = fetchMock as unknown as typeof fetch; + instance.getCryptoPrice = mock(async () => { + throw new Error("cg down"); }); + + await expect(instance.getUsdToFiatExchangeRate(BRL)).rejects.toThrow("cg down"); }); - it("should fetch exchange rate from Nabla when cache is empty", async () => { - // Use type assertion to bypass TypeScript's type checking - const rate = await priceFeedService.getUsdToFiatExchangeRate("BRL" as any); + it("should accept fastforex when CoinGecko sanity check is unavailable", async () => { + const instance = PriceFeedService.getInstance(); + instance.getCryptoPrice = mock(async () => { + throw new Error("cg down"); + }); + + const rate = await instance.getUsdToFiatExchangeRate(BRL); - expect(rate).toBe(1.25); - expect(getTokenOutAmountMock).toHaveBeenCalledTimes(1); + expect(rate).toBe(5.85); + expect(loggerMock.warn).toHaveBeenCalledWith(expect.stringContaining("Unable to sanity-check fastforex USD-BRL")); }); - it("should return cached exchange rate without Nabla call when cache is valid", async () => { - // First call to populate cache - await priceFeedService.getUsdToFiatExchangeRate("BRL" as any); + it("should throw when fastforex returns invalid rate and CoinGecko also fails", async () => { + const instance = PriceFeedService.getInstance(); + fetchMock = mock( + async () => + new Response(JSON.stringify({ base: "USD", result: { BRL: 0 } }), { + headers: { "content-type": "application/json" }, + status: 200 + }) + ); + global.fetch = fetchMock as unknown as typeof fetch; + instance.getCryptoPrice = mock(async () => { + throw new Error("cg down"); + }); + + await expect(instance.getUsdToFiatExchangeRate(BRL)).rejects.toThrow("cg down"); + }); - // Reset mock to verify it's not called again - (getTokenOutAmountMock as any).mockClear(); + it("should reject and fall back when fastforex is outside the CoinGecko sanity band", async () => { + const instance = PriceFeedService.getInstance(); + fetchMock = mock(async () => mockFastforexResponse(6.2)); + global.fetch = fetchMock as unknown as typeof fetch; + instance.getCryptoPrice = mock(async () => 5.85); - // Second call should use cache - const rate = await priceFeedService.getUsdToFiatExchangeRate("BRL" as any); + const rate = await instance.getUsdToFiatExchangeRate(BRL); - expect(rate).toBe(1.25); - expect(getTokenOutAmountMock).not.toHaveBeenCalled(); + expect(rate).toBe(5.85); + expect(loggerMock.warn).toHaveBeenCalledWith(expect.stringContaining("above 2.00% limit")); }); - it("should make a new Nabla call when cache expires", async () => { - // Override TTL to a small value for testing - process.env.FIAT_CACHE_TTL_MS = "100"; + it("should accept fastforex when the CoinGecko sanity-check rate is invalid", async () => { + const instance = PriceFeedService.getInstance(); + instance.getCryptoPrice = mock(async () => 0); - // Reset singleton to apply new TTL - // @ts-expect-error - accessing private property for testing - PriceFeedService.instance = undefined; - const serviceInstance = PriceFeedService.getInstance(); // Get new instance - Object.assign(serviceInstance, { fiatCacheTtlMs: 100 }); + const rate = await instance.getUsdToFiatExchangeRate(BRL); - // Mock Date.now to return a fixed timestamp - const startTime = 1000000; - Date.now = () => startTime; + expect(rate).toBe(5.85); + expect(loggerMock.warn).toHaveBeenCalledWith(expect.stringContaining("Unable to sanity-check fastforex USD-BRL")); + }); - // First call to populate cache - await serviceInstance.getUsdToFiatExchangeRate("BRL" as any); - expect(getTokenOutAmountMock).toHaveBeenCalledTimes(1); - (getTokenOutAmountMock as any).mockClear(); + it("should cache accepted FastForex rates when CoinGecko sanity check is unavailable", async () => { + const instance = PriceFeedService.getInstance(); + instance.getCryptoPrice = mock(async () => 0); - // Advance time beyond the cache TTL by changing Date.now - Date.now = () => startTime + 150; // 150ms later + const rate = await instance.getUsdToFiatExchangeRate(BRL); + expect(rate).toBe(5.85); - // Second call should make a new Nabla call - await serviceInstance.getUsdToFiatExchangeRate("BRL" as any); - expect(getTokenOutAmountMock).toHaveBeenCalledTimes(1); // Verify the second call happened + instance.getCryptoPrice = mock(async () => 5.85); + fetchMock.mockClear(); + + const cachedRate = await instance.getUsdToFiatExchangeRate(BRL); + + expect(cachedRate).toBe(5.85); + expect(fetchMock).not.toHaveBeenCalled(); }); - it("should throw an error when Nabla call fails", async () => { - const nablaError = new Error("Nabla API Error"); - // Configure the mock to throw an error for this specific test - (getTokenOutAmountMock as any).mockRejectedValueOnce(nablaError); + it("should return one for USD without calling external providers", async () => { + const instance = PriceFeedService.getInstance(); - await expect(priceFeedService.getUsdToFiatExchangeRate("EUR" as any)).rejects.toThrow("Nabla API Error"); - expect(getTokenOutAmountMock).toHaveBeenCalledTimes(1); // Verify it was called + const rate = await instance.getUsdToFiatExchangeRate(USD); + + expect(rate).toBe(1); + expect(fetchMock).not.toHaveBeenCalled(); }); - it("should accept a custom input amount", async () => { - // Reset singleton to ensure a fresh instance - // @ts-expect-error - accessing private property for testing - PriceFeedService.instance = undefined; - const freshInstance = PriceFeedService.getInstance(); + it("should fetch FastForex rates for every non-USD Vortex fiat currency", async () => { + const instance = PriceFeedService.getInstance(); + const fastforexRates: Record = { + ARS: 1200, + BRL: 5.85, + COP: 4100, + EUR: 0.86, + MXN: 18.5 + }; + const coingeckoReferenceRates: Record = { + ARS: 1199, + BRL: 5.86, + COP: 4095, + EUR: 0.861, + MXN: 18.49 + }; - // Clear mock before this specific test - (getTokenOutAmountMock as any).mockClear(); + fetchMock = mock(async (url: string) => { + const currency = new URL(url).searchParams.get("to"); + if (!currency) { + throw new Error("Missing FastForex target currency in test request"); + } - await freshInstance.getUsdToFiatExchangeRate("BRL" as any, "10.0"); + return mockFastforexResponse(fastforexRates[currency], currency as RampCurrency); + }); + global.fetch = fetchMock as unknown as typeof fetch; + instance.getCryptoPrice = mock(async (_tokenId: string, vsCurrency: string) => { + const referenceRate = coingeckoReferenceRates[vsCurrency.toUpperCase()]; + if (referenceRate === undefined) { + throw new Error(`Missing CoinGecko reference rate for ${vsCurrency}`); + } - expect(getTokenOutAmountMock).toHaveBeenCalledWith( - expect.objectContaining({ - fromAmountString: "10.0" - }) + return referenceRate; + }); + + for (const currency of FASTFOREX_TEST_FIATS) { + const rate = await instance.getUsdToFiatExchangeRate(currency); + + expect(rate).toBe(fastforexRates[currency]); + expect(fetchMock).toHaveBeenCalledWith( + `https://api.fastforex.io/fetch-one?from=USD&to=${currency}`, + expect.anything() + ); + expect(instance.getCryptoPrice).toHaveBeenCalledWith("usd-coin", currency.toLowerCase()); + } + }); + + it("should reject non-fiat target currencies before fetching", async () => { + const instance = PriceFeedService.getInstance(); + + await expect(instance.getUsdToFiatExchangeRate(ETH)).rejects.toThrow( + "USD-to-fiat exchange rate requires a fiat currency, got ETH" ); + expect(fetchMock).not.toHaveBeenCalled(); }); }); describe("convertCurrency", () => { - it("should return the original amount when currencies are the same", async () => { - const result = await priceFeedService.convertCurrency("100", "USDC" as any, "USDC" as any); - expect(result).toBe("100.00000000"); + it("should return the same amount when currencies match", async () => { + const result = await PriceFeedService.getInstance().convertCurrency("100", BRL, BRL); + expect(result).toBe("100.00"); }); it("should perform 1:1 conversion between USD-like stablecoins", async () => { - const result = await priceFeedService.convertCurrency("100", "USDC" as any, "USDT" as any); + const result = await PriceFeedService.getInstance().convertCurrency("100", USDC, USDT); expect(result).toBe("100"); }); - it("should convert USD to fiat using getFiatExchangeRate", async () => { - // Reset singleton to ensure a fresh instance - // @ts-expect-error - accessing private property for testing - PriceFeedService.instance = undefined; - const freshInstance = PriceFeedService.getInstance(); + it("should convert USD to fiat using fastforex rate", async () => { + const instance = PriceFeedService.getInstance(); + instance.getCryptoPrice = mock(async () => 5.86); - // Clear mock before this specific test - (getTokenOutAmountMock as any).mockClear(); + const result = await instance.convertCurrency("100", USDC, BRL); - const result = await freshInstance.convertCurrency("100", "USDC" as any, "BRL" as any); - expect(result).toBe("125.00"); // 100 * 1.25 = 125 - expect(getTokenOutAmountMock).toHaveBeenCalledTimes(1); + expect(result).toBe("585.00"); }); - it("should convert fiat to USD using inverse of getFiatExchangeRate", async () => { - // Reset singleton to ensure a fresh instance - // @ts-expect-error - accessing private property for testing - PriceFeedService.instance = undefined; - const freshInstance = PriceFeedService.getInstance(); + it("should convert fiat to USD using inverse fastforex rate", async () => { + const instance = PriceFeedService.getInstance(); + instance.getCryptoPrice = mock(async () => 5.86); - // Clear mock before this specific test - (getTokenOutAmountMock as any).mockClear(); + const result = await instance.convertCurrency("585", BRL, USDC); - const result = await freshInstance.convertCurrency("125", "BRL" as any, "USDC" as any); - expect(result).toBe("100.00000000"); // 125 / 1.25 = 100 - expect(getTokenOutAmountMock).toHaveBeenCalledTimes(1); + expect(result).toBe("100.00000000"); }); it("should convert USD to crypto using getCryptoPrice", async () => { - const result = await priceFeedService.convertCurrency("300", "USDC" as any, "ETH" as any); - expect(result).toBe("0.10000000"); // 300 / 3000 = 0.1 - expect(fetchMock).toHaveBeenCalledTimes(1); + const instance = PriceFeedService.getInstance(); + instance.getCryptoPrice = mock(async () => 3000); + + const result = await instance.convertCurrency("300", USDC, ETH); + + expect(result).toBe("0.10000000"); + expect(instance.getCryptoPrice).toHaveBeenCalledWith("ethereum", "usd"); }); it("should convert crypto to USD using getCryptoPrice", async () => { - // Reset singleton to ensure a fresh instance - // @ts-expect-error - accessing private property for testing - PriceFeedService.instance = undefined; - const freshInstance = PriceFeedService.getInstance(); + const instance = PriceFeedService.getInstance(); + instance.getCryptoPrice = mock(async () => 3000); - // Clear fetch mock before this specific test - fetchMock.mockClear(); + const result = await instance.convertCurrency("0.1", ETH, USDC); - const result = await freshInstance.convertCurrency("0.1", "ETH" as any, "USDC" as any); - expect(result).toBe("300.00000000"); // 0.1 * 3000 = 300 - expect(fetchMock).toHaveBeenCalledTimes(1); + expect(result).toBe("300.00000000"); + expect(instance.getCryptoPrice).toHaveBeenCalledWith("ethereum", "usd"); }); - it("should fail closed on conversion errors", async () => { - await expect(priceFeedService.convertCurrency("100", "USDC" as RampCurrency, "UNKNOWN" as RampCurrency)).rejects.toThrow( - "No CoinGecko token ID mapping for UNKNOWN" - ); + it("should respect custom decimal precision", async () => { + const instance = PriceFeedService.getInstance(); + instance.getCryptoPrice = mock(async () => 5.86); + + const result = await instance.convertCurrency("100", USDC, BRL, 2); + + expect(result).toBe("585.00"); + }); + + it("should throw instead of returning the original amount when conversion providers fail", async () => { + const instance = PriceFeedService.getInstance(); + fetchMock = mock(async () => new Response("fastforex down", { status: 500 })); + global.fetch = fetchMock as unknown as typeof fetch; + instance.getCryptoPrice = mock(async () => { + throw new Error("cg down"); + }); + + await expect(instance.convertCurrency("100", USDC, BRL)).rejects.toThrow("cg down"); }); it("should return the original amount only through the explicit fallback helper", async () => { - const result = await priceFeedService.convertCurrencyOrOriginal("100", "USDC" as RampCurrency, "UNKNOWN" as RampCurrency); + const instance = PriceFeedService.getInstance(); + fetchMock = mock(async () => new Response("fastforex down", { status: 500 })); + global.fetch = fetchMock as unknown as typeof fetch; + instance.getCryptoPrice = mock(async () => { + throw new Error("cg down"); + }); + + const result = await instance.convertCurrencyOrOriginal("100", USDC, BRL); + expect(result).toBe("100"); }); - it("should use specified decimal precision", async () => { - const result = await priceFeedService.convertCurrency("100", "USDC" as any, "BRL" as any, 2); - expect(result).toBe("125.00"); // 100 * 1.25 = 125, with 2 decimal places + it("should return null through the nullable helper when conversion fails", async () => { + const instance = PriceFeedService.getInstance(); + fetchMock = mock(async () => new Response("fastforex down", { status: 500 })); + global.fetch = fetchMock as unknown as typeof fetch; + instance.getCryptoPrice = mock(async () => { + throw new Error("cg down"); + }); + + const result = await instance.convertCurrencyOrNull("100", USDC, BRL); + + expect(result).toBeNull(); + }); + }); + + describe("getFiatToUsdExchangeRate", () => { + it("should return the inverse of the guarded USD-to-fiat rate", async () => { + const instance = PriceFeedService.getInstance(); + instance.getCryptoPrice = mock(async () => 5.86); + + const rate = await instance.getFiatToUsdExchangeRate(BRL); + + expect(rate.toFixed(8)).toBe("0.17094017"); + }); + + it("should return one for USD without calling external providers", async () => { + const instance = PriceFeedService.getInstance(); + + const rate = await instance.getFiatToUsdExchangeRate(USD); + + expect(rate.toString()).toBe("1"); + expect(fetchMock).not.toHaveBeenCalled(); }); }); describe("Configuration", () => { + it("should read fastforex config from config/vars", () => { + Reflect.set(PriceFeedService, "instance", undefined); + const instance = PriceFeedService.getInstance(); + expect(Reflect.get(instance, "fastforexApiBaseUrl")).toBe(config.priceProviders.fastforex.baseUrl); + expect(Reflect.get(instance, "fastforexApiKey")).toBe(config.priceProviders.fastforex.apiKey); + }); + + it("should read CoinGecko config from config/vars", () => { + Reflect.set(PriceFeedService, "instance", undefined); + const instance = PriceFeedService.getInstance(); + expect(Reflect.get(instance, "coingeckoApiBaseUrl")).toBe(config.priceProviders.coingecko.baseUrl); + expect(Reflect.get(instance, "cryptoCacheTtlMs")).toBe(config.priceProviders.coingecko.cryptoCacheTtlMs); + expect(Reflect.get(instance, "fiatCacheTtlMs")).toBe(config.priceProviders.coingecko.fiatCacheTtlMs); + }); + it("should keep loaded configuration values when environment variables change after import", () => { - // Set specific values after the config module has already been imported process.env.COINGECKO_API_URL = "https://custom-api.example.com"; process.env.CRYPTO_CACHE_TTL_MS = "60000"; process.env.FIAT_CACHE_TTL_MS = "120000"; - // Reset singleton to apply changes - // @ts-expect-error - accessing private property for testing - PriceFeedService.instance = undefined; - - // Create new instance + Reflect.set(PriceFeedService, "instance", undefined); const instance = PriceFeedService.getInstance(); - // @ts-expect-error - accessing private properties for testing - expect(instance.coingeckoApiBaseUrl).toBe("https://pro-api.coingecko.com/api/v3"); - // @ts-expect-error - accessing private properties for testing - expect(instance.cryptoCacheTtlMs).toBe(300000); - // @ts-expect-error - accessing private properties for testing - expect(instance.fiatCacheTtlMs).toBe(300000); + expect(Reflect.get(instance, "coingeckoApiBaseUrl")).toBe(config.priceProviders.coingecko.baseUrl); + expect(Reflect.get(instance, "cryptoCacheTtlMs")).toBe(config.priceProviders.coingecko.cryptoCacheTtlMs); + expect(Reflect.get(instance, "fiatCacheTtlMs")).toBe(config.priceProviders.coingecko.fiatCacheTtlMs); }); }); }); diff --git a/apps/api/src/api/services/priceFeed.service.ts b/apps/api/src/api/services/priceFeed.service.ts index 5248759ad..1bd2a1f2b 100644 --- a/apps/api/src/api/services/priceFeed.service.ts +++ b/apps/api/src/api/services/priceFeed.service.ts @@ -1,20 +1,8 @@ -import { - ApiManager, - EvmToken, - getPendulumDetails, - getTokenOutAmount, - getTokenUsdPrice, - isFiatToken, - normalizeTokenSymbol, - PENDULUM_USDC_AXL, - RampCurrency, - UsdLikeEvmToken -} from "@vortexfi/shared"; +import { EvmToken, getTokenUsdPrice, isFiatToken, normalizeTokenSymbol, RampCurrency, UsdLikeEvmToken } from "@vortexfi/shared"; import Big from "big.js"; import logger from "../../config/logger"; import { config } from "../../config/vars"; import { fetchWithTimeout } from "../helpers/fetchWithTimeout"; -import { SlackNotifier } from "./slack.service"; // Cache entry interface interface CacheEntry { @@ -22,10 +10,18 @@ interface CacheEntry { expiresAt: number; } +const FASTFOREX_SANITY_SPREAD_LIMITS: Record = { + ARS: 0.25, + BRL: 0.02, + COP: 0.03, + EUR: 0.02, + MXN: 0.03 +}; + /** * PriceFeedService * - * A singleton service that centralizes price lookups for crypto (CoinGecko) and fiat (Nabla) currencies. + * A singleton service that centralizes price lookups for crypto (CoinGecko) and fiat (fastforex) currencies. * This service is part of the fee-handling refactor to provide consistent price data across the application. * Includes in-memory caching with configurable TTLs to reduce API calls and improve performance. */ @@ -37,6 +33,10 @@ export class PriceFeedService { private coingeckoApiBaseUrl: string; + private fastforexApiKey: string | undefined; + + private fastforexApiBaseUrl: string; + // Cache configuration private cryptoCacheTtlMs: number; @@ -54,6 +54,9 @@ export class PriceFeedService { this.coingeckoApiKey = config.priceProviders.coingecko.apiKey; this.coingeckoApiBaseUrl = config.priceProviders.coingecko.baseUrl; + this.fastforexApiKey = config.priceProviders.fastforex.apiKey; + this.fastforexApiBaseUrl = config.priceProviders.fastforex.baseUrl; + this.cryptoCacheTtlMs = config.priceProviders.coingecko.cryptoCacheTtlMs; this.fiatCacheTtlMs = config.priceProviders.coingecko.fiatCacheTtlMs; @@ -61,7 +64,12 @@ export class PriceFeedService { logger.warn("COINGECKO_API_KEY environment variable is not set. CoinGecko API calls may be rate-limited."); } + if (!this.fastforexApiKey) { + logger.warn("FASTFOREX_API_KEY environment variable is not set. Fiat rates will fall back to CoinGecko."); + } + logger.info(`PriceFeedService initialized with CoinGecko API URL: ${this.coingeckoApiBaseUrl}`); + logger.info(`PriceFeedService initialized with fastforex API URL: ${this.fastforexApiBaseUrl}`); logger.info(`Cache TTLs configured - Crypto: ${this.cryptoCacheTtlMs}ms, Fiat: ${this.fiatCacheTtlMs}ms`); } @@ -171,13 +179,21 @@ export class PriceFeedService { * Get the exchange rate from USD to another fiat currency. The source currency is always USD. * * @param toCurrency - The target currency code (e.g., 'BRL', 'ARS') - * @param inputAmount - The amount to convert (default is '1.0') * @returns The exchange rate (how much of toCurrency equals 1 unit of fromCurrency) */ - public async getUsdToFiatExchangeRate(toCurrency: RampCurrency, inputAmount = "1.0"): Promise { + public async getUsdToFiatExchangeRate(toCurrency: RampCurrency): Promise { const fromCurrency = "USD"; + const targetCurrency = toCurrency.toUpperCase() as RampCurrency; + + if (!isFiatToken(targetCurrency)) { + throw new Error(`USD-to-fiat exchange rate requires a fiat currency, got ${toCurrency}`); + } - const cacheKey = `fiat:${fromCurrency}:${toCurrency}`; + if (targetCurrency === "USD") { + return 1; + } + + const cacheKey = `fiat:${fromCurrency}:${targetCurrency}`; const cachedEntry = this.fiatExchangeRateCache.get(cacheKey); const now = Date.now(); @@ -186,67 +202,50 @@ export class PriceFeedService { return cachedEntry.value; } - // Check if the currency has a Pendulum representative (Nabla pool). - // Currencies like MXN, COP, and ARS are TokenType.Fiat with no Pendulum pool — use CoinGecko for those. - let outputTokenPendulumDetails; - try { - outputTokenPendulumDetails = getPendulumDetails(toCurrency); - } catch { - // No Pendulum representative — fall back to CoinGecko using USDC as a USD proxy. - logger.debug(`Cache miss for ${cacheKey}. No Pendulum pool for ${toCurrency}, fetching from CoinGecko.`); + if (this.fastforexApiKey) { + logger.debug(`Cache miss for ${cacheKey}. Fetching from fastforex.`); + try { - const rate = await this.getCryptoPrice("usd-coin", toCurrency.toLowerCase()); + const rate = await this.getFastforexRate(fromCurrency, targetCurrency); + await this.assertFastforexRateWithinSanityBand(targetCurrency, rate); this.fiatExchangeRateCache.set(cacheKey, { expiresAt: now + this.fiatCacheTtlMs, value: rate }); return rate; - } catch (cgError) { - if (cgError instanceof Error) { - logger.error(`Error fetching fiat exchange rate from ${fromCurrency} to ${toCurrency}: ${cgError.message}`); - } - throw cgError; + } catch (ffError) { + logger.warn( + `fastforex failed for ${fromCurrency}-${targetCurrency}, falling back to CoinGecko: ${ffError instanceof Error ? ffError.message : ffError}` + ); } + } else { + logger.debug(`Cache miss for ${cacheKey}. FASTFOREX_API_KEY is not set, fetching from CoinGecko fallback.`); } - logger.debug(`Cache miss for ${cacheKey}. Fetching from Nabla.`); - + logger.debug(`Fetching ${fromCurrency}-${targetCurrency} rate from CoinGecko as fallback.`); try { - logger.debug(`Using ${this.constructor.name} instance to fetch exchange rate from ${fromCurrency} to ${toCurrency}`); - - const apiManager = ApiManager.getInstance(); - const networkName = "pendulum"; - const apiInstance = await apiManager.getApi(networkName); - - // We assume that the exchange rate from axlUSDC to the target currency in the Forex AMM - // resemble the real fiat exchange rate. - const inputTokenPendulumDetails = PENDULUM_USDC_AXL; - - // Call getTokenOutAmount to get the exchange rate - const amountOut = await getTokenOutAmount({ - api: apiInstance.api, - fromAmountString: inputAmount, - inputTokenPendulumDetails, - outputTokenPendulumDetails - }); - - const exchangeRate = parseFloat(amountOut.effectiveExchangeRate); - - logger.debug(`Exchange rate from ${fromCurrency} to ${toCurrency}: ${exchangeRate}`); - - this.fiatExchangeRateCache.set(cacheKey, { - expiresAt: now + this.fiatCacheTtlMs, - value: exchangeRate - }); - - return exchangeRate; - } catch (error) { - if (error instanceof Error) { - logger.error(`Error fetching fiat exchange rate from ${fromCurrency} to ${toCurrency}: ${error.message}`); - } else { - logger.error(`Unknown error fetching fiat exchange rate from ${fromCurrency} to ${toCurrency}`); + const rate = await this.getCryptoPrice("usd-coin", targetCurrency.toLowerCase()); + this.assertValidFiatRate("CoinGecko", fromCurrency, targetCurrency, rate); + this.fiatExchangeRateCache.set(cacheKey, { expiresAt: now + this.fiatCacheTtlMs, value: rate }); + return rate; + } catch (cgError) { + if (cgError instanceof Error) { + logger.error(`Error fetching fiat exchange rate from ${fromCurrency} to ${targetCurrency}: ${cgError.message}`); } + throw cgError; + } + } - // Re-throw the error to be handled by the caller - throw error; + /** + * Get the fiat-to-USD exchange rate expected by quote discount math. + * FastForex returns USD-to-fiat, so this is the inverse of that rate. + */ + public async getFiatToUsdExchangeRate(currency: RampCurrency): Promise { + const usdToFiatRate = await this.getUsdToFiatExchangeRate(currency); + const rate = new Big(usdToFiatRate); + + if (rate.lte(0)) { + throw new Error(`Invalid USD-to-fiat exchange rate for ${currency}: ${usdToFiatRate}`); } + + return new Big(1).div(rate); } /** @@ -365,119 +364,68 @@ export class PriceFeedService { return this.convertCurrencyStrict(amountInUSD, EvmToken.USDC, toCurrency, null); } - // Checks if the onchain oracle prices are up to date. Sends a warning to Slack if not. - async checkOnchainOraclePricesUpToDate(): Promise { - logger.info("Performing onchain oracle prices check..."); - - const apiManager = ApiManager.getInstance(); - const pendulumApi = await apiManager.getApi("pendulum"); - const pendulumApiInstance = pendulumApi.api; - - try { - // Check if the oracle prices are up to date - const allPricesEncoded = await pendulumApiInstance.query.diaOracleModule.coinInfosMap.entries(); - - const prices = allPricesEncoded.map(([_, priceData]) => { - const price = priceData.toHuman() as { name: string; lastUpdateTimestamp: string }; - return { - lastUpdateTimestamp: price.lastUpdateTimestamp.replaceAll(",", ""), - name: price.name - }; - }); + private async getFastforexRate(fromCurrency: string, toCurrency: string): Promise { + const normalizedBaseUrl = this.fastforexApiBaseUrl.endsWith("/") + ? this.fastforexApiBaseUrl + : `${this.fastforexApiBaseUrl}/`; + const url = new URL("fetch-one", normalizedBaseUrl); + url.searchParams.append("from", fromCurrency); + url.searchParams.append("to", toCurrency); + + const headers = new Headers({ Accept: "application/json" }); + if (this.fastforexApiKey) { + headers.set("X-API-Key", this.fastforexApiKey); + } - const outdatedPrices = []; - for (const price of prices) { - const lastUpdateTimestamp = parseInt(price.lastUpdateTimestamp, 10); - const currentTime = Math.floor(Date.now() / 1000); // Current time in seconds - const isPriceUpToDate = currentTime - lastUpdateTimestamp < 3600; // Check if updated within the last hour + const response = await fetchWithTimeout(url.toString(), { headers }); - if (!isPriceUpToDate) { - logger.warn( - `Onchain oracle price for ${price.name} is not up to date. Last update: ${lastUpdateTimestamp}, Current time: ${currentTime}` - ); + if (!response.ok) { + const errorText = await response.text(); + throw new Error(`fastforex API error (${response.status}): ${errorText}`); + } - outdatedPrices.push(price); - } - } + const data = (await response.json()) as { base: string; result: Record }; + const rate = data.result[toCurrency]; - if (outdatedPrices.length > 0) { - const slackNotifier = new SlackNotifier(); - await slackNotifier.sendMessage({ - text: `⚠️ Onchain oracle prices are not up to date! The following prices are outdated:\n${outdatedPrices.map(price => price.name).join(", ")}` - }); - } else { - logger.info("All onchain oracle prices are up to date."); - } - } catch (error) { - logger.error(`Error checking onchain oracle prices: ${error instanceof Error ? error.message : "Unknown error"}`); + if (rate === undefined || rate <= 0) { + throw new Error(`fastforex returned invalid rate for ${fromCurrency}-${toCurrency}: ${rate}`); } - } - /** - * Get the onchain oracle price for a specific currency - * - * @param currency - The RampCurrency to get the oracle price for - * @returns The oracle price data including price value and last update timestamp - * @throws Error if the price cannot be fetched or currency is not found - */ - public async getOnchainOraclePrice(currency: RampCurrency): Promise<{ - price: Big; - lastUpdateTimestamp: number; - name: string; - }> { - logger.debug(`Fetching onchain oracle price for ${currency}`); + logger.debug(`fastforex rate ${fromCurrency}-${toCurrency}: ${rate}`); + return rate; + } - const apiManager = ApiManager.getInstance(); - const pendulumApi = await apiManager.getApi("pendulum"); - const pendulumApiInstance = pendulumApi.api; + private async assertFastforexRateWithinSanityBand(targetCurrency: RampCurrency, fastforexRate: number): Promise { + this.assertValidFiatRate("fastforex", "USD", targetCurrency, fastforexRate); + let referenceRate: number; try { - // Construct the query parameters - const blockchain = "FIAT"; - const symbol = `${currency}-USD`; - - logger.debug(`Querying oracle with blockchain: ${blockchain}, symbol: ${symbol}`); - - // Query the oracle for the specific currency - const priceDataEncoded = await pendulumApiInstance.query.diaOracleModule.coinInfosMap({ - blockchain, - symbol - }); - - // Check if price data exists - if (priceDataEncoded.isEmpty) { - throw new Error(`No oracle price found for currency ${currency} (${blockchain}/${symbol})`); - } - - // Parse the price data - const priceData = priceDataEncoded.toHuman() as { - name: string; - price: string; - lastUpdateTimestamp: string; - }; - - // Remove commas from numeric strings and parse - const priceRaw = parseFloat(priceData.price.replaceAll(",", "")); - const lastUpdateTimestamp = parseInt(priceData.lastUpdateTimestamp.replaceAll(",", ""), 10); - - // Convert price from raw to decimal number by dividing by 10^12 - const price = Big(priceRaw).div(1_000_000_000_000); + referenceRate = await this.getCryptoPrice("usd-coin", targetCurrency.toLowerCase()); + this.assertValidFiatRate("CoinGecko", "USD", targetCurrency, referenceRate); + } catch (error) { + logger.warn( + `Unable to sanity-check fastforex USD-${targetCurrency} rate against CoinGecko; accepting fastforex rate: ${ + error instanceof Error ? error.message : error + }` + ); + return; + } - logger.debug(`Oracle price for ${currency}: ${price}, Last update: ${lastUpdateTimestamp}, Name: ${priceData.name}`); + const spread = Big(fastforexRate).minus(referenceRate).abs().div(referenceRate).toNumber(); + const limit = FASTFOREX_SANITY_SPREAD_LIMITS[targetCurrency] ?? 0.03; - return { - lastUpdateTimestamp, - name: priceData.name, - price - }; - } catch (error) { - if (error instanceof Error) { - logger.error(`Error fetching onchain oracle price for ${currency}: ${error.message}`); - } else { - logger.error(`Unknown error fetching onchain oracle price for ${currency}`); - } + if (spread > limit) { + throw new Error( + `fastforex USD-${targetCurrency} rate ${fastforexRate} differs from CoinGecko reference ${referenceRate} by ${( + spread * 100 + ).toFixed(2)}%, above ${(limit * 100).toFixed(2)}% limit` + ); + } + } - throw error; + private assertValidFiatRate(provider: string, fromCurrency: string, toCurrency: string, rate: number): void { + if (!Number.isFinite(rate) || rate <= 0) { + throw new Error(`${provider} returned invalid rate for ${fromCurrency}-${toCurrency}: ${rate}`); } } diff --git a/apps/api/src/api/services/quote/engines/discount/onramp.ts b/apps/api/src/api/services/quote/engines/discount/onramp.ts index cdd2ef00d..1b7e0f66c 100644 --- a/apps/api/src/api/services/quote/engines/discount/onramp.ts +++ b/apps/api/src/api/services/quote/engines/discount/onramp.ts @@ -51,9 +51,9 @@ export class OnRampDiscountEngine extends BaseDiscountEngine { * Queries squidrouter to determine the actual conversion rate from axlUSDC on Moonbeam * to the final destination token on the target EVM chain. * - * The oracle price is based on the Binance USDT-BRL rate, but the Nabla swap on Pendulum - * outputs axlUSDC (not USDT). Since axlUSDC may trade at a discount to USDT via - * squidrouter, using the oracle USDT rate as the axlUSDC subsidy target means the user + * The oracle price is the fastforex USD mid-market fiat rate, but the Nabla swap on Pendulum + * outputs axlUSDC (not USD). Since axlUSDC may trade at a discount to USD via + * squidrouter, using the oracle USD rate as the axlUSDC subsidy target means the user * would receive slightly less than the oracle-promised amount after the squidrouter step. * * This method fetches the actual axlUSDC → destination token rate so the discount engine @@ -104,9 +104,9 @@ export class OnRampDiscountEngine extends BaseDiscountEngine { * Queries squidrouter to determine the actual conversion rate from USDC on Base * to the final destination token on the target EVM chain. * - * The oracle price is based on the Binance USDT-BRL rate, but the Nabla swap on Base - * outputs USDC (not USDT). Since USDC may trade at a discount to USDT via - * squidrouter, using the oracle USDT rate as the USDC subsidy target means the user + * The oracle price is the fastforex USD mid-market fiat rate, but the Nabla swap on Base + * outputs USDC (not USD). Since USDC may trade at a discount to USD via + * squidrouter, using the oracle USD rate as the USDC subsidy target means the user * may receive slightly less than the oracle-promised amount after the squidrouter step. * * This method fetches the actual USDC → destination token rate so the discount engine diff --git a/apps/api/src/api/services/quote/engines/nabla-swap/base-evm.test.ts b/apps/api/src/api/services/quote/engines/nabla-swap/base-evm.test.ts index b4ddea348..44ced4cf2 100644 --- a/apps/api/src/api/services/quote/engines/nabla-swap/base-evm.test.ts +++ b/apps/api/src/api/services/quote/engines/nabla-swap/base-evm.test.ts @@ -61,7 +61,7 @@ mock.module("../../core/nabla", () => ({ mock.module("../../../priceFeed.service", () => ({ priceFeedService: { - getOnchainOraclePrice: mock(async () => ({ price: new Big("1") })) + getFiatToUsdExchangeRate: mock(async () => new Big("1")) } })); diff --git a/apps/api/src/api/services/quote/engines/nabla-swap/base-evm.ts b/apps/api/src/api/services/quote/engines/nabla-swap/base-evm.ts index f566695a8..bd709f949 100644 --- a/apps/api/src/api/services/quote/engines/nabla-swap/base-evm.ts +++ b/apps/api/src/api/services/quote/engines/nabla-swap/base-evm.ts @@ -1,6 +1,5 @@ import { EvmToken, EvmTokenDetails, getOnChainTokenDetails, Networks, RampDirection } from "@vortexfi/shared"; import { Big } from "big.js"; -import logger from "../../../../../config/logger"; import { priceFeedService } from "../../../priceFeed.service"; import { calculateNablaSwapOutputEvm } from "../../core/nabla"; import { QuoteContext, Stage, StageKey } from "../../core/types"; @@ -54,16 +53,9 @@ export abstract class BaseNablaSwapEngineEvm implements Stage { rampType: request.rampType }); - let oraclePrice; - try { - oraclePrice = await priceFeedService.getOnchainOraclePrice( - request.rampType === RampDirection.BUY ? request.inputCurrency : request.outputCurrency - ); - } catch (error) { - logger.warn( - `BaseNablaSwapEngineEvm: Unable to fetch on-chain oracle price for ${request.outputCurrency}, proceeding without it. Error: ${error}` - ); - } + const oraclePrice = await priceFeedService.getFiatToUsdExchangeRate( + request.rampType === RampDirection.BUY ? request.inputCurrency : request.outputCurrency + ); this.assignNablaSwapContext( ctx, @@ -74,7 +66,7 @@ export abstract class BaseNablaSwapEngineEvm implements Stage { outputToken, inputTokenDetails, outputTokenDetails, - oraclePrice?.price + oraclePrice ); this.addNote(ctx, inputTokenDetails, outputTokenDetails, inputAmountForSwap, result); diff --git a/apps/api/src/api/services/quote/engines/nabla-swap/index.ts b/apps/api/src/api/services/quote/engines/nabla-swap/index.ts index 88d9fbfb6..372eed801 100644 --- a/apps/api/src/api/services/quote/engines/nabla-swap/index.ts +++ b/apps/api/src/api/services/quote/engines/nabla-swap/index.ts @@ -1,6 +1,5 @@ import { PendulumTokenDetails, RampDirection } from "@vortexfi/shared"; import { Big } from "big.js"; -import logger from "../../../../../config/logger"; import { priceFeedService } from "../../../priceFeed.service"; import { calculateNablaSwapOutput } from "../../core/nabla"; import { QuoteContext, Stage, StageKey } from "../../core/types"; @@ -46,16 +45,9 @@ export abstract class BaseNablaSwapEngine implements Stage { rampType: request.rampType }); - let oraclePrice; - try { - oraclePrice = await priceFeedService.getOnchainOraclePrice( - request.rampType === RampDirection.BUY ? request.inputCurrency : request.outputCurrency - ); - } catch (error) { - logger.warn( - `OffRampSwapEngine: Unable to fetch on-chain oracle price for ${request.outputCurrency}, proceeding without it. Error: ${error}` - ); - } + const oraclePrice = await priceFeedService.getFiatToUsdExchangeRate( + request.rampType === RampDirection.BUY ? request.inputCurrency : request.outputCurrency + ); this.assignNablaSwapContext( ctx, @@ -64,7 +56,7 @@ export abstract class BaseNablaSwapEngine implements Stage { inputAmountForSwapRaw, inputTokenPendulumDetails, outputTokenPendulumDetails, - oraclePrice?.price + oraclePrice ); this.addNote(ctx, inputTokenPendulumDetails, outputTokenPendulumDetails, inputAmountForSwap, result); diff --git a/apps/api/src/api/workers/cleanup.worker.ts b/apps/api/src/api/workers/cleanup.worker.ts index 33360e6a5..a4afb4f87 100644 --- a/apps/api/src/api/workers/cleanup.worker.ts +++ b/apps/api/src/api/workers/cleanup.worker.ts @@ -152,13 +152,15 @@ class CleanupWorker { limit: 5, order: [["updatedAt", "DESC"]], where: { + // Op.or nested inside the JSON path object is rejected by Sequelize's + // query builder ("Invalid value"); the dotted-path form is the + // documented way to query JSONB attributes. + [Op.or]: [ + { "postCompleteState.cleanup.cleanupCompleted": false }, + { "postCompleteState.cleanup.cleanupCompleted": { [Op.is]: null } } + ], currentPhase: { [Op.in]: ["complete", "failed", "timedOut"] }, - flowVariant: config.flowVariant, - postCompleteState: { - cleanup: { - [Op.or]: [{ cleanupCompleted: false }, { cleanupCompleted: { [Op.is]: null } }] - } - } + flowVariant: config.flowVariant } }); diff --git a/apps/api/src/config/database.ts b/apps/api/src/config/database.ts index 269acc78e..a3e94aee4 100644 --- a/apps/api/src/config/database.ts +++ b/apps/api/src/config/database.ts @@ -40,11 +40,15 @@ const sequelize = new Sequelize(config.database.database, config.database.userna dialectOptions: getDialectOptions(), host: config.database.host, logging: config.database.logging ? msg => logger.debug(msg) : false, + // Keep a couple of warm connections: with min 0 / idle 10s every quiet period dropped + // all connections, so each request burst re-ran the full SCRAM handshake through the + // Supabase pooler (Supavisor) — which times out with EAUTHTIMEOUT when the event loop + // is busy, failing quotes and API-key validation. pool: { acquire: 30000, - idle: 10000, + idle: 60000, max: 10, - min: 0 + min: 2 }, port: config.database.port }); diff --git a/apps/api/src/config/vars.ts b/apps/api/src/config/vars.ts index 0cb4eb31c..e36e10d96 100644 --- a/apps/api/src/config/vars.ts +++ b/apps/api/src/config/vars.ts @@ -132,6 +132,10 @@ interface Config { cryptoCacheTtlMs: number; fiatCacheTtlMs: number; }; + fastforex: { + apiKey: string | undefined; + baseUrl: string; + }; }; spreadsheet: SpreadsheetConfig; database: { @@ -233,6 +237,10 @@ export const config: Config = { cryptoCacheTtlMs: parseInt(process.env.CRYPTO_CACHE_TTL_MS || "300000", 10), fiatCacheTtlMs: parseInt(process.env.FIAT_CACHE_TTL_MS || "300000", 10) }, + fastforex: { + apiKey: process.env.FASTFOREX_API_KEY, + baseUrl: process.env.FASTFOREX_API_URL || "https://api.fastforex.io" + }, moonpay: { apiKey: process.env.MOONPAY_API_KEY, baseUrl: process.env.MOONPAY_PROD_URL || "https://api.moonpay.com" diff --git a/apps/api/src/test-utils/fake-world/fake-prices.ts b/apps/api/src/test-utils/fake-world/fake-prices.ts index 3825b5fba..c225e9f78 100644 --- a/apps/api/src/test-utils/fake-world/fake-prices.ts +++ b/apps/api/src/test-utils/fake-world/fake-prices.ts @@ -49,14 +49,13 @@ export class FakePrices { } } -type PatchedMethods = "getCryptoPrice" | "getUsdToFiatExchangeRate" | "convertCurrency" | "getOnchainOraclePrice"; +type PatchedMethods = "getCryptoPrice" | "getUsdToFiatExchangeRate" | "convertCurrency"; export function installFakePrices(): { fakePrices: FakePrices; restore: () => void } { const fakePrices = new FakePrices(); const originals: Partial> = { convertCurrency: priceFeedService.convertCurrency, getCryptoPrice: priceFeedService.getCryptoPrice, - getOnchainOraclePrice: priceFeedService.getOnchainOraclePrice, getUsdToFiatExchangeRate: priceFeedService.getUsdToFiatExchangeRate }; @@ -72,12 +71,6 @@ export function installFakePrices(): { fakePrices: FakePrices; restore: () => vo const converted = usd.times(fakePrices.getPerUsd(toCurrency as string)); return decimals != null ? converted.toFixed(decimals, 0) : converted.toString(); }; - priceFeedService.getOnchainOraclePrice = async (currency: RampCurrency) => ({ - lastUpdateTimestamp: Date.now(), - name: currency as string, - price: new Big(1).div(fakePrices.getPerUsd(currency as string)) - }); - return { fakePrices, restore: () => { diff --git a/apps/api/src/tests/cleanup-worker-postprocess.integration.test.ts b/apps/api/src/tests/cleanup-worker-postprocess.integration.test.ts new file mode 100644 index 000000000..d3264bbba --- /dev/null +++ b/apps/api/src/tests/cleanup-worker-postprocess.integration.test.ts @@ -0,0 +1,79 @@ +import { afterAll, beforeAll, beforeEach, describe, expect, it } from "bun:test"; +import type RampState from "../models/rampState.model"; +import CleanupWorker from "../api/workers/cleanup.worker"; +import { resetTestDatabase, setupTestDatabase } from "../test-utils/db"; +import { createTestRampState } from "../test-utils/factories"; + +// CleanupWorker's CronJob is created with runOnInit=true, so merely +// constructing the worker fires a real cleanup cycle in the background. +// Neutralize the tick target for the duration of this file. +const workerPrototype = CleanupWorker.prototype as unknown as { cleanup: () => Promise }; +const realCleanupCycle = workerPrototype.cleanup; +workerPrototype.cleanup = async () => {}; + +afterAll(() => { + workerPrototype.cleanup = realCleanupCycle; +}); + +/** + * Regression test for the postProcessCompletedStates query: it used to nest + * Op.or inside the postCompleteState JSON path object, which Sequelize's + * query builder rejects with `Invalid value { cleanupCompleted: false }` — + * the error was caught and logged, so post-processing of completed ramps + * silently never ran. + */ +describe("CleanupWorker.postProcessCompletedStates", () => { + let worker: CleanupWorker; + let processedStateIds: string[]; + + beforeAll(async () => { + await setupTestDatabase(); + }); + + beforeEach(async () => { + await resetTestDatabase(); + processedStateIds = []; + worker = new CleanupWorker(); + // Stub the per-state cleanup handlers; this test only covers the query. + // biome-ignore lint/suspicious/noExplicitAny: overriding a protected method for testing + (worker as any).processCleanup = async (state: RampState) => { + processedStateIds.push(state.id); + }; + }); + + async function runPostProcess(): Promise { + // biome-ignore lint/suspicious/noExplicitAny: calling the private method under test + await (worker as any).postProcessCompletedStates(); + } + + it("finds completed states whose cleanup is pending or missing", async () => { + const pendingCleanup = await createTestRampState({ + currentPhase: "complete", + postCompleteState: { cleanup: { cleanupAt: null, cleanupCompleted: false, errors: null } } + }); + const missingCleanupFlag = await createTestRampState({ + currentPhase: "failed", + // biome-ignore lint/suspicious/noExplicitAny: simulating legacy rows without the cleanupCompleted flag + postCompleteState: { cleanup: {} } as any + }); + + await runPostProcess(); + + expect(processedStateIds.sort()).toEqual([pendingCleanup.id, missingCleanupFlag.id].sort()); + }); + + it("skips states that are already cleaned up or not in a terminal phase", async () => { + await createTestRampState({ + currentPhase: "complete", + postCompleteState: { cleanup: { cleanupAt: new Date(), cleanupCompleted: true, errors: null } } + }); + await createTestRampState({ + currentPhase: "initial", + postCompleteState: { cleanup: { cleanupAt: null, cleanupCompleted: false, errors: null } } + }); + + await runPostProcess(); + + expect(processedStateIds).toEqual([]); + }); +}); diff --git a/apps/frontend/src/components/Navbar/DesktopNavbar.tsx b/apps/frontend/src/components/Navbar/DesktopNavbar.tsx index d616a634e..5607f3481 100644 --- a/apps/frontend/src/components/Navbar/DesktopNavbar.tsx +++ b/apps/frontend/src/components/Navbar/DesktopNavbar.tsx @@ -76,7 +76,7 @@ export const DesktopNavbar = () => {
- Open App + {t("components.navbar.openApp")}
diff --git a/apps/frontend/src/components/Navbar/MobileMenu.tsx b/apps/frontend/src/components/Navbar/MobileMenu.tsx index d78ea4b16..780b2980f 100644 --- a/apps/frontend/src/components/Navbar/MobileMenu.tsx +++ b/apps/frontend/src/components/Navbar/MobileMenu.tsx @@ -120,7 +120,7 @@ export const MobileMenu = ({ onMenuItemClick }: MobileMenuProps) => { - Buy & Sell + {t("components.navbar.openApp")} diff --git a/apps/frontend/src/translations/en.json b/apps/frontend/src/translations/en.json index f8aeac252..6c549c632 100644 --- a/apps/frontend/src/translations/en.json +++ b/apps/frontend/src/translations/en.json @@ -646,6 +646,7 @@ "docs": "Docs", "howItWorks": "How it works", "individuals": "Individuals", + "openApp": "Open App", "payments": "Payments", "sellCrypto": "Sell Crypto", "solutions": "Solutions", diff --git a/apps/frontend/src/translations/pt.json b/apps/frontend/src/translations/pt.json index 529ff70a9..b2787839d 100644 --- a/apps/frontend/src/translations/pt.json +++ b/apps/frontend/src/translations/pt.json @@ -650,6 +650,7 @@ "docs": "Documentação", "howItWorks": "Como funciona", "individuals": "Indivíduos", + "openApp": "Abrir App", "payments": "Pagamentos", "sellCrypto": "Vender Cripto", "solutions": "Soluções", diff --git a/apps/rebalancer/.env.example b/apps/rebalancer/.env.example index 9c79b66f1..b49ad7a8c 100644 --- a/apps/rebalancer/.env.example +++ b/apps/rebalancer/.env.example @@ -41,3 +41,14 @@ REBALANCING_DAILY_BRIDGE_LIMIT_USD=10000 # Main Nabla instance on Base (BRL→USDC route). Leave empty to disable this route. MAIN_NABLA_ROUTER=0x... MAIN_NABLA_QUOTER=0x... + +# BlindPay (optional): logs an observational "shadow" quote (fiat BRL -> stablecoin) during +# the usdc-brla-usdc-base route comparison, to evaluate BlindPay as a cheaper stablecoin +# source over time. Never routed. When BLINDPAY_API_KEY/BLINDPAY_INSTANCE_ID are unset, the +# shadow quote is skipped. +# BLINDPAY_API_KEY=your_blindpay_api_key +# BLINDPAY_INSTANCE_ID=in_000000000000 +# Optional, defaults to https://api.blindpay.com/v1 +# BLINDPAY_BASE_URL=https://api.blindpay.com/v1 +# Target stablecoin. Sandbox/dev instances only support "USDB"; production uses "USDC"/"USDT". +# BLINDPAY_TOKEN=USDT diff --git a/apps/rebalancer/src/index.ts b/apps/rebalancer/src/index.ts index 5ad243391..aed0a1fdf 100644 --- a/apps/rebalancer/src/index.ts +++ b/apps/rebalancer/src/index.ts @@ -204,6 +204,7 @@ async function evaluateUsdcToBrlaPolicy( profitable: boolean; routeQuotes?: { aveniaQuoteUsdc: string | null; + blindpayShadowQuoteUsdc: string | null; mainNablaQuoteUsdc: string | null; squidRouterQuoteUsdc: string | null; }; @@ -243,6 +244,7 @@ async function evaluateUsdcToBrlaPolicy( profitable: isProjectedProfit(Big(amountUsdcRaw), Big(projectedOutputRaw)), routeQuotes: { aveniaQuoteUsdc: comparison.aveniaQuoteUsdc, + blindpayShadowQuoteUsdc: comparison.blindpayShadowQuoteUsdc, mainNablaQuoteUsdc: comparison.mainNablaQuoteUsdc, squidRouterQuoteUsdc: comparison.squidRouterQuoteUsdc }, diff --git a/apps/rebalancer/src/rebalance/usdc-brla-usdc-base/index.ts b/apps/rebalancer/src/rebalance/usdc-brla-usdc-base/index.ts index 5ce2630ef..d6f966801 100644 --- a/apps/rebalancer/src/rebalance/usdc-brla-usdc-base/index.ts +++ b/apps/rebalancer/src/rebalance/usdc-brla-usdc-base/index.ts @@ -144,12 +144,14 @@ export async function rebalanceUsdcBrlaUsdcBase( state.squidRouterQuoteUsdc = policy?.preflightQuotes?.squidRouterQuoteUsdc ?? null; state.aveniaQuoteUsdc = policy?.preflightQuotes?.aveniaQuoteUsdc ?? null; state.mainNablaQuoteUsdc = policy?.preflightQuotes?.mainNablaQuoteUsdc ?? null; + state.blindpayShadowQuoteUsdc = policy?.preflightQuotes?.blindpayShadowQuoteUsdc ?? null; } else { const comparison = await compareRoutesUpfront(state.usdcAmountRaw); state.winningRoute = comparison.winningRoute; state.squidRouterQuoteUsdc = comparison.squidRouterQuoteUsdc; state.aveniaQuoteUsdc = comparison.aveniaQuoteUsdc; state.mainNablaQuoteUsdc = comparison.mainNablaQuoteUsdc; + state.blindpayShadowQuoteUsdc = comparison.blindpayShadowQuoteUsdc; } console.log(`Route selected: ${state.winningRoute}`); @@ -476,9 +478,19 @@ export async function rebalanceUsdcBrlaUsdcBase( startingTime: state.startingTime }); + const winnerQuoteUsdcRaw = + state.winningRoute === "squidrouter" + ? state.squidRouterQuoteUsdc + : state.winningRoute === "avenia" + ? state.aveniaQuoteUsdc + : state.winningRoute === "nabla-main" + ? state.mainNablaQuoteUsdc + : null; + const slackNotifier = new SlackNotifier(process.env.SLACK_WEB_HOOK_TOKEN); await slackNotifier.sendMessage({ text: formatBaseRebalanceCompletionMessage({ + blindpayShadowQuoteUsdcRaw: state.blindpayShadowQuoteUsdc, brlaReceived: Big(state.brlaAmountDecimal), cost, edgeCaseFlags, @@ -486,7 +498,8 @@ export async function rebalanceUsdcBrlaUsdcBase( initialUsdcBalance: initialUsdcDecimal, policy: policy ?? { config: getConfig().rebalancingCostPolicy }, requestedUsdc: usdcRebalanced, - route: state.winningRoute + route: state.winningRoute, + winnerQuoteUsdcRaw }) }); } diff --git a/apps/rebalancer/src/rebalance/usdc-brla-usdc-base/notifications.ts b/apps/rebalancer/src/rebalance/usdc-brla-usdc-base/notifications.ts index cd0521825..12cf057f0 100644 --- a/apps/rebalancer/src/rebalance/usdc-brla-usdc-base/notifications.ts +++ b/apps/rebalancer/src/rebalance/usdc-brla-usdc-base/notifications.ts @@ -17,6 +17,7 @@ export interface RebalancePolicySummary { opportunistic?: boolean; preflightQuotes?: { aveniaQuoteUsdc: string | null; + blindpayShadowQuoteUsdc: string | null; mainNablaQuoteUsdc: string | null; squidRouterQuoteUsdc: string | null; }; @@ -32,6 +33,9 @@ interface BaseRebalanceCompletionMessageParams { policy?: RebalancePolicySummary; requestedUsdc: Big; route: WinningRoute; + // Observational-only BlindPay shadow quote and the executed route's USDC, both raw (6 decimals). + blindpayShadowQuoteUsdcRaw?: string | null; + winnerQuoteUsdcRaw?: string | null; } export function formatBaseRebalanceCompletionMessage(params: BaseRebalanceCompletionMessageParams): string { @@ -54,8 +58,29 @@ export function formatBaseRebalanceCompletionMessage(params: BaseRebalanceComple ] ] ), + formatBlindpayShadowLine(params.blindpayShadowQuoteUsdcRaw, params.winnerQuoteUsdcRaw, params.route), formatPolicySummary(params.policy, params.edgeCaseFlags) - ].join("\n"); + ] + .filter(section => section !== null) + .join("\n"); +} + +// Observational only: shows what BlindPay would have quoted for the same BRLA amount, alongside +// the executed route, to evaluate BlindPay as a cheaper stablecoin source. Never routed. +function formatBlindpayShadowLine( + blindpayShadowUsdcRaw: string | null | undefined, + winnerQuoteUsdcRaw: string | null | undefined, + route: WinningRoute +): string | null { + if (!blindpayShadowUsdcRaw) return null; + + const blindpayUsdc = Big(blindpayShadowUsdcRaw).div(1e6); + let comparison = ""; + if (winnerQuoteUsdcRaw) { + const delta = blindpayUsdc.minus(Big(winnerQuoteUsdcRaw).div(1e6)); + comparison = ` | vs ${formatRoute(route)} ${Big(winnerQuoteUsdcRaw).div(1e6).toFixed(6)}: ${delta.gte(0) ? "+" : ""}${delta.toFixed(6)}`; + } + return `*BlindPay shadow* (not routed): ${blindpayUsdc.toFixed(6)} USDC-eq${comparison}`; } export function formatPolicySummary(policy: RebalancePolicySummary | undefined, edgeCaseFlags: string[] = []): string { diff --git a/apps/rebalancer/src/rebalance/usdc-brla-usdc-base/steps.ts b/apps/rebalancer/src/rebalance/usdc-brla-usdc-base/steps.ts index 9a9a504ff..1f59b268d 100644 --- a/apps/rebalancer/src/rebalance/usdc-brla-usdc-base/steps.ts +++ b/apps/rebalancer/src/rebalance/usdc-brla-usdc-base/steps.ts @@ -19,6 +19,8 @@ import Big from "big.js"; import { encodeFunctionData, erc20Abi, type PublicClient } from "viem"; import { base, polygon } from "viem/chains"; import { brlaMoonbeamTokenDetails } from "../../constants.ts"; +import { BlindpayApiService } from "../../services/blindpay/blindpayApiService.ts"; +import type { BlindpayStablecoin } from "../../services/blindpay/types.ts"; import { UsdcBaseRebalanceState, UsdcBaseStateManager, type WinningRoute } from "../../services/stateManager.ts"; import { getBaseEvmClients, getConfig, getPolygonEvmClients } from "../../utils/config.ts"; import { NonceManager } from "../../utils/nonce.ts"; @@ -533,6 +535,45 @@ export async function fetchAveniaQuote(brlaAmountDecimal: Big): Promise return outputUsdcRaw; } +// BlindPay rejects payin quotes below this amount (in cents of the sender currency). +const BLINDPAY_MIN_REQUEST_AMOUNT_CENTS = 500; + +/** + * Observational-only quote: what BlindPay would give for converting the same BRLA amount + * into stablecoin (payin BRL -> USDT/USDB). Returned as a USDC-equivalent raw amount + * (6 decimals) so it is directly comparable to the executed-route quotes. Never routed. + * Returns null when BlindPay is unconfigured or the amount is below its minimum. + */ +export async function fetchBlindpayShadowQuoteUsdc(brlaAmountDecimal: Big): Promise { + if (!BlindpayApiService.isConfigured()) { + console.log("BlindPay shadow quote skipped: not configured (set BLINDPAY_API_KEY and BLINDPAY_INSTANCE_ID)."); + return null; + } + + const requestAmountCents = brlaAmountDecimal.mul(100).round(0, 0).toNumber(); + if (requestAmountCents < BLINDPAY_MIN_REQUEST_AMOUNT_CENTS) { + const minBrl = Big(BLINDPAY_MIN_REQUEST_AMOUNT_CENTS).div(100).toString(); + console.log( + `BlindPay shadow quote skipped: ${brlaAmountDecimal.toFixed(4)} BRL is below BlindPay's minimum of ${minBrl} BRL.` + ); + return null; + } + + const { blindpayToken } = getConfig(); + const quote = await BlindpayApiService.getInstance().getPayinFxRate({ + currency_type: "sender", + from: "BRL", + request_amount: requestAmountCents, + to: blindpayToken as BlindpayStablecoin + }); + + // `result_amount` is in cents of the target stablecoin (USD-pegged). Convert to a + // USDC-equivalent raw amount (6 decimals) for an apples-to-apples comparison. + const outputUsdcRaw = multiplyByPowerOfTen(Big(quote.result_amount).div(100), 6).toFixed(0, 0); + console.log(`BlindPay shadow quote: ${outputUsdcRaw} ${blindpayToken} (raw, 6 decimals)`); + return outputUsdcRaw; +} + export async function compareRates(brlaAmountDecimal: Big): Promise<{ winningRoute: "squidrouter" | "avenia"; squidRouterQuoteUsdc: string | null; @@ -970,6 +1011,7 @@ export async function compareRoutesUpfront(usdcAmountRaw: string): Promise<{ squidRouterQuoteUsdc: string | null; aveniaQuoteUsdc: string | null; mainNablaQuoteUsdc: string | null; + blindpayShadowQuoteUsdc: string | null; }> { console.log("Quoting first Nabla (USDC→BRLA) to estimate BRLA output for route comparison..."); @@ -1007,10 +1049,15 @@ export async function compareRoutesUpfront(usdcAmountRaw: string): Promise<{ const config = getConfig(); const mainNablaAvailable = !!(config.mainNablaRouter && config.mainNablaQuoter); + // BlindPay is an observational shadow quote only: fetched alongside the real routes for + // comparison, but never added to `candidates` (never actually routed through). + let blindpayShadowQuoteUsdc: string | null = null; + const results = await Promise.allSettled([ fetchSquidRouterQuote(estimatedBrlaDecimal), fetchAveniaQuote(estimatedBrlaDecimal), - mainNablaAvailable ? fetchMainNablaQuote(estimatedBrlaRaw.toString()) : Promise.reject("not configured") + mainNablaAvailable ? fetchMainNablaQuote(estimatedBrlaRaw.toString()) : Promise.reject("not configured"), + fetchBlindpayShadowQuoteUsdc(estimatedBrlaDecimal) ]); if (results[0].status === "fulfilled") { @@ -1031,6 +1078,12 @@ export async function compareRoutesUpfront(usdcAmountRaw: string): Promise<{ console.warn("Main Nabla quote failed:", results[2].reason); } + if (results[3].status === "fulfilled") { + blindpayShadowQuoteUsdc = results[3].value; + } else { + console.warn("BlindPay shadow quote failed:", results[3].reason); + } + // Normalize all quotes to decimal USDC for comparison const candidates: { route: WinningRoute; usdcDecimal: Big }[] = []; @@ -1056,8 +1109,20 @@ export async function compareRoutesUpfront(usdcAmountRaw: string): Promise<{ console.log(` ${c.route}: ${c.usdcDecimal.toFixed(6)} USDC ${c.route === winner.route ? "(WINNER)" : ""}`); } + // Observational only: log what BlindPay would have quoted for the same BRLA amount, + // alongside the executed routes, to evaluate it as a cheaper stablecoin source over time. + if (blindpayShadowQuoteUsdc) { + const blindpayUsdcDecimal = multiplyByPowerOfTen(Big(blindpayShadowQuoteUsdc), -6); + const deltaVsWinner = blindpayUsdcDecimal.minus(winner.usdcDecimal); + console.log( + ` blindpay (shadow, not routed): ${blindpayUsdcDecimal.toFixed(6)} USDC-eq ` + + `| vs ${winner.route} (winner): ${deltaVsWinner.gte(0) ? "+" : ""}${deltaVsWinner.toFixed(6)} USDC` + ); + } + return { aveniaQuoteUsdc, + blindpayShadowQuoteUsdc, estimatedBrlaRaw: estimatedBrlaRaw.toString(), mainNablaQuoteUsdc, squidRouterQuoteUsdc, diff --git a/apps/rebalancer/src/services/blindpay/blindpayApiService.test.ts b/apps/rebalancer/src/services/blindpay/blindpayApiService.test.ts new file mode 100644 index 000000000..e79551018 --- /dev/null +++ b/apps/rebalancer/src/services/blindpay/blindpayApiService.test.ts @@ -0,0 +1,102 @@ +import { afterAll, beforeEach, describe, expect, test } from "bun:test"; +import { BlindpayApiService } from "./blindpayApiService.ts"; +import type { PayinFxRateInput, PayinFxRateResponse } from "./types.ts"; + +const ENV_VARS = ["EVM_ACCOUNT_SECRET", "BLINDPAY_API_KEY", "BLINDPAY_BASE_URL", "BLINDPAY_INSTANCE_ID"]; +const originalEnv = new Map(ENV_VARS.map(name => [name, process.env[name]])); +const originalFetch = globalThis.fetch; + +function restoreEnv() { + for (const name of ENV_VARS) { + const originalValue = originalEnv.get(name); + if (originalValue === undefined) { + delete process.env[name]; + } else { + process.env[name] = originalValue; + } + } +} + +const FX_INPUT: PayinFxRateInput = { + currency_type: "sender", + from: "BRL", + request_amount: 100_000, + to: "USDC" +}; + +const FX_RESPONSE: PayinFxRateResponse = { + blindpay_quotation: 5.61, + commercial_quotation: 5.58, + instance_flat_fee: 0, + instance_percentage_fee: 25, + result_amount: 17_825, +}; + +describe("BlindpayApiService", () => { + beforeEach(() => { + restoreEnv(); + process.env.EVM_ACCOUNT_SECRET = "0xtest-secret"; + globalThis.fetch = originalFetch; + }); + + afterAll(() => { + restoreEnv(); + globalThis.fetch = originalFetch; + }); + + test("isConfigured is false unless both API key and instance id are set", () => { + delete process.env.BLINDPAY_API_KEY; + delete process.env.BLINDPAY_INSTANCE_ID; + expect(BlindpayApiService.isConfigured()).toBe(false); + + process.env.BLINDPAY_API_KEY = "key"; + expect(BlindpayApiService.isConfigured()).toBe(false); + + process.env.BLINDPAY_INSTANCE_ID = "in_123"; + expect(BlindpayApiService.isConfigured()).toBe(true); + }); + + test("getPayinFxRate throws when BlindPay is not configured", async () => { + delete process.env.BLINDPAY_API_KEY; + delete process.env.BLINDPAY_INSTANCE_ID; + + await expect(BlindpayApiService.getInstance().getPayinFxRate(FX_INPUT)).rejects.toThrow("not configured"); + }); + + test("getPayinFxRate posts to the instance fx endpoint and returns the parsed quote", async () => { + process.env.BLINDPAY_API_KEY = "secret-key"; + process.env.BLINDPAY_BASE_URL = "https://blindpay.test/v1"; + process.env.BLINDPAY_INSTANCE_ID = "in_123"; + + const requests: { url: string; init: RequestInit }[] = []; + globalThis.fetch = (async (url: string | URL | Request, init?: RequestInit) => { + requests.push({ init: init as RequestInit, url: String(url) }); + return new Response(JSON.stringify(FX_RESPONSE), { status: 200 }); + }) as unknown as typeof fetch; + + const quote = await BlindpayApiService.getInstance().getPayinFxRate(FX_INPUT); + + expect(quote).toEqual(FX_RESPONSE); + expect(requests).toHaveLength(1); + const request = requests[0]; + if (!request) throw new Error("fetch was not called"); + expect(request.url).toBe("https://blindpay.test/v1/instances/in_123/payin-quotes/fx"); + expect(request.init.method).toBe("POST"); + expect(JSON.parse(request.init.body as string)).toEqual(FX_INPUT); + const headers = request.init.headers as Record; + expect(headers.Authorization).toBe("Bearer secret-key"); + // Requests must carry a timeout signal so a stalled BlindPay call can't hang the rebalance run. + expect(request.init.signal).toBeInstanceOf(AbortSignal); + }); + + test("getPayinFxRate surfaces non-OK responses with status and body", async () => { + process.env.BLINDPAY_API_KEY = "secret-key"; + process.env.BLINDPAY_INSTANCE_ID = "in_123"; + + globalThis.fetch = (async () => new Response("quota exceeded", { status: 429 })) as unknown as typeof fetch; + + await expect(BlindpayApiService.getInstance().getPayinFxRate(FX_INPUT)).rejects.toThrow( + "BlindPay request failed with status '429'. Error: quota exceeded" + ); + }); +}); diff --git a/apps/rebalancer/src/services/blindpay/blindpayApiService.ts b/apps/rebalancer/src/services/blindpay/blindpayApiService.ts new file mode 100644 index 000000000..19bb59081 --- /dev/null +++ b/apps/rebalancer/src/services/blindpay/blindpayApiService.ts @@ -0,0 +1,58 @@ +import { getConfig } from "../../utils/config.ts"; +import type { PayinFxRateInput, PayinFxRateResponse } from "./types.ts"; + +// A stalled BlindPay request must not hang the surrounding Promise.allSettled and +// stall the whole rebalance run — the quote is observational only. +const REQUEST_TIMEOUT_MS = 30_000; + +/// Minimal client for the BlindPay API. Uses a static Bearer API key (no login flow). +/// We only need the payin FX-rate endpoint to obtain an indicative fiat -> stablecoin +/// price, used purely as an observational comparison against the executed routes. +export class BlindpayApiService { + private static instance: BlindpayApiService; + + private constructor() {} + + public static getInstance(): BlindpayApiService { + if (!BlindpayApiService.instance) { + BlindpayApiService.instance = new BlindpayApiService(); + } + return BlindpayApiService.instance; + } + + /// Whether BlindPay is configured. When false, callers should skip gracefully. + public static isConfigured(): boolean { + const { blindpayApiKey, blindpayInstanceId } = getConfig(); + return Boolean(blindpayApiKey && blindpayInstanceId); + } + + /// Returns an indicative payin quote (fiat -> stablecoin) without creating a receiver + /// or blockchain wallet. See POST /instances/{instanceId}/payin-quotes/fx. + public async getPayinFxRate(input: PayinFxRateInput): Promise { + const { blindpayApiKey, blindpayBaseUrl, blindpayInstanceId } = getConfig(); + if (!blindpayApiKey || !blindpayInstanceId) { + throw new Error("BlindPay is not configured (BLINDPAY_API_KEY / BLINDPAY_INSTANCE_ID missing)."); + } + + const url = `${blindpayBaseUrl}/instances/${blindpayInstanceId}/payin-quotes/fx`; + + console.log(`BlindPay API Request: POST ${url}`, input); + + const response = await fetch(url, { + body: JSON.stringify(input), + headers: { + Accept: "application/json", + Authorization: `Bearer ${blindpayApiKey}`, + "Content-Type": "application/json" + }, + method: "POST", + signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS) + }); + + if (!response.ok) { + throw new Error(`BlindPay request failed with status '${response.status}'. Error: ${await response.text()}`); + } + + return (await response.json()) as PayinFxRateResponse; + } +} diff --git a/apps/rebalancer/src/services/blindpay/types.ts b/apps/rebalancer/src/services/blindpay/types.ts new file mode 100644 index 000000000..3fd0c2a63 --- /dev/null +++ b/apps/rebalancer/src/services/blindpay/types.ts @@ -0,0 +1,33 @@ +// Types for the BlindPay API (https://api.blindpay.com/reference). +// We only use the lightweight payin FX-rate endpoint to obtain an indicative +// price for converting fiat (BRL) into a stablecoin, for rate-tracking purposes. + +// Fiat currency the sender pays in. +export type BlindpayFiatCurrency = "BRL" | "USD" | "MXN" | "COP" | "ARS"; + +// Stablecoin the receiver would get out. "USDB" is only available on sandbox/dev instances. +export type BlindpayStablecoin = "USDC" | "USDT" | "USDB"; + +// Which side of the conversion `request_amount` is denominated in. +// "sender" => request_amount is in the fiat currency (what we want here). +export type BlindpayCurrencyType = "sender" | "receiver"; + +// POST /instances/{instanceId}/payin-quotes/fx +export interface PayinFxRateInput { + currency_type: BlindpayCurrencyType; + from: BlindpayFiatCurrency; + to: BlindpayStablecoin; + // Integer amount in cents of the `currency_type` currency. No float values allowed. + request_amount: number; +} + +export interface PayinFxRateResponse { + // Market/reference quotation. + commercial_quotation: number; + // The quotation BlindPay actually applies (includes their spread). + blindpay_quotation: number; + // Resulting amount on the other side of the conversion, in cents. + result_amount: number; + instance_flat_fee: number; + instance_percentage_fee: number; +} diff --git a/apps/rebalancer/src/services/stateManager.ts b/apps/rebalancer/src/services/stateManager.ts index 510d1b972..215c63e3a 100644 --- a/apps/rebalancer/src/services/stateManager.ts +++ b/apps/rebalancer/src/services/stateManager.ts @@ -203,6 +203,8 @@ export interface UsdcBaseRebalanceState { squidRouterQuoteUsdc: string | null; aveniaQuoteUsdc: string | null; mainNablaQuoteUsdc: string | null; + // Observational-only BlindPay shadow quote (USDC-equivalent raw, 6 decimals); never routed. + blindpayShadowQuoteUsdc: string | null; mainNablaApproveHash: string | null; mainNablaSwapHash: string | null; mainNablaUsdcBalanceBeforeRaw: string | null; @@ -251,6 +253,7 @@ export function createUsdcBaseRebalanceState( aveniaTicketId: null, baseUsdcBalanceBeforeAveniaSwapRaw: null, baseUsdcBalanceBeforeSquidSwapRaw: null, + blindpayShadowQuoteUsdc: null, brlaAmountDecimal: null, brlaAmountRaw: null, brlaBalanceBeforeNablaRaw: null, diff --git a/apps/rebalancer/src/utils/config.ts b/apps/rebalancer/src/utils/config.ts index 32fb80822..f61d8127b 100644 --- a/apps/rebalancer/src/utils/config.ts +++ b/apps/rebalancer/src/utils/config.ts @@ -83,6 +83,16 @@ export function getConfig() { return { alchemyApiKey: process.env.ALCHEMY_API_KEY, + + // BlindPay is used purely to log an observational comparison price (fiat BRL -> stablecoin) + // against the executed routes. Optional: when apiKey/instanceId are missing, the comparison + // is skipped and the rebalancer keeps working as before. + blindpayApiKey: process.env.BLINDPAY_API_KEY, + blindpayBaseUrl: process.env.BLINDPAY_BASE_URL || "https://api.blindpay.com/v1", + blindpayInstanceId: process.env.BLINDPAY_INSTANCE_ID, + // Sandbox/dev instances only support the "USDB" token; production uses "USDC"/"USDT". + blindpayToken: process.env.BLINDPAY_TOKEN || "USDT", + brlaBaseUrl: BRLA_BASE_URL, brlaBusinessAccountAddress: process.env.BRLA_BUSINESS_ACCOUNT_ADDRESS || "0xDF5Fb34B90e5FDF612372dA0c774A516bF5F08b2", diff --git a/docs/security-spec/03-ramp-engine/quote-lifecycle.md b/docs/security-spec/03-ramp-engine/quote-lifecycle.md index a7a2ce535..899b039b2 100644 --- a/docs/security-spec/03-ramp-engine/quote-lifecycle.md +++ b/docs/security-spec/03-ramp-engine/quote-lifecycle.md @@ -4,7 +4,7 @@ Quotes are the entry point for every ramp. A quote calculates the expected output amount for a given input, factoring in exchange rates, fees, and dynamic pricing adjustments. The lifecycle: -1. **Creation** — Client requests a quote via `POST /v1/quotes` with input currency, output currency, amount, and ramp direction (`BUY` for on-ramp or `SELL` for off-ramp). If an active maintenance window exists, the backend rejects quote creation with `503 Service Unavailable`, `Retry-After`, and downtime start/end metadata before fetching rates or writing a quote. Otherwise, the API calculates fees, fetches live exchange rates (Nabla DEX, price providers), applies the dynamic pricing adjustment, and returns a `QuoteResponse` including the expected output amount, fee breakdown, optional quote-time subsidy display fields, and a quote ID. +1. **Creation** — Client requests a quote via `POST /v1/quotes` with input currency, output currency, amount, and ramp direction (`BUY` for on-ramp or `SELL` for off-ramp). If an active maintenance window exists, the backend rejects quote creation with `503 Service Unavailable`, `Retry-After`, and downtime start/end metadata before fetching rates or writing a quote. Otherwise, the API calculates fees, fetches live exchange rates (fiat forex from fastforex.io with best-effort CoinGecko sanity checks, swap rates from Nabla DEX and SquidRouter), applies the dynamic pricing adjustment, and returns a `QuoteResponse` including the expected output amount, fee breakdown, optional quote-time subsidy display fields, and a quote ID. - If live route/pool liquidity cannot serve the quote at the requested amount, the API returns a user-facing `500` quote error (`This route is temporarily unavailable due to low liquidity. Please try a smaller amount or check back soon.`). Clients should treat it as a user-correctable liquidity failure and ask for a smaller amount or to check back soon. This applies to Nabla pool coverage failures, Squid route low-liquidity responses, and `/v1/quotes/best` when every candidate route fails for liquidity. Unexpected provider or calculation failures still follow the global production error policy and are masked as internal errors. 2. **Expiry** — Quotes expire **10 minutes** after creation (hardcoded in `QuoteTicket.create()` and the model default: `new Date(Date.now() + 10 * 60 * 1000)`). After expiry, the quote cannot be used to start a ramp. Note: this is a separate timeout from `discountStateTimeoutMinutes` (see Dynamic Pricing below). 3. **Binding** — When a ramp is registered (`POST /v1/ramp/register`), it binds to a specific quote ID. The quote's amounts become the committed values for the ramp. @@ -65,7 +65,7 @@ The refresh policy is intentionally strict (byte-identical `toAmount` and `fee` 6. **Quote validation MUST occur at ramp registration time** — When binding a quote to a ramp, the API MUST verify: quote exists, quote is not expired, quote is not already consumed, and the requesting user/partner is authorized to use it. 7. **Dynamic pricing `difference` MUST be clamped to partner bounds** — The `difference` value must never exceed `maxDynamicDifference` or fall below `minDynamicDifference`. Both bounds are enforced in `getAdjustedDifference` and `handleQuoteConsumptionForDiscountState`. 8. **Dynamic pricing state MUST NOT be externally modifiable** — The `partnerDiscountState` Map is in-memory and module-private. No API endpoint should expose or allow modification of discount state. -9. **Exchange rates MUST be sourced from authoritative on-chain data** — Swap rates should come from the actual DEX (Nabla) or routing protocol (Squid), not from stale caches or third-party price feeds that could be manipulated. +9. **Exchange rates MUST be sourced from authoritative sources** — Swap rates must come from the actual DEX (Nabla) or routing protocol (Squid). Fiat forex rates are sourced from fastforex.io and, when CoinGecko is available, sanity-checked against CoinGecko's `usd-coin` fiat price. If fastforex is missing, unavailable, invalid, or outside the configured per-currency sanity band, CoinGecko is used as fallback. If fastforex returns a valid rate but CoinGecko is unavailable or invalid, the API logs the missing sanity check and accepts fastforex rather than making CoinGecko a hard dependency. Cached forex rates must stay within the configured short TTL. If no valid fiat rate provider remains, quote/conversion paths must fail closed rather than reusing the input amount or proceeding with an unverified rate. Operators must treat the CoinGecko fallback/reference as a USDC-as-USD proxy, not as pure fiat FX, during USDC depeg conditions. 10. **Subsidy MUST only be applied when `targetDiscount > 0`** — If a partner has no target discount configured, the subsidy amount is always `0`, regardless of the shortfall. 11. **Quote output precision MUST match the final settlement token** — For EVM onramps whose final output comes from Squid, the stored `quote.outputAmount` must retain the destination token's precision, not a fixed source-token precision. This includes BRL/EURC Base→EVM routes and routed Alfredpay USD/MXN/COP/ARS Polygon→EVM routes. Direct same-chain same-token passthrough keeps the minted/source token's precision. 12. **Quote creation MUST honor active maintenance windows server-side** — `POST /v1/quotes` and `POST /v1/quotes/best` must reject during active maintenance before quote calculation/persistence, including enough downtime metadata for direct API clients to retry after the window. @@ -92,6 +92,7 @@ The refresh policy is intentionally strict (byte-identical `toAmount` and `fee` | **Concurrent quote and consumption** | Two simultaneous requests — one quoting, one consuming — for the same partner could read stale `difference` values from the in-memory Map | JavaScript's single-threaded event loop prevents true concurrency for synchronous Map operations. However, the `async` functions in `compute()` could interleave if there are `await` points between reading and writing the Map. In practice, the read and write of `partnerDiscountState` in `getAdjustedDifference` are synchronous, so this is safe within a single process. | | **Quote-output precision loss** | A quote targets an 18-decimal destination token but stores only 6 decimal places. The user-visible amount looks close, but final raw transfer construction under-delivers by the truncated dust amount. | Finalize EVM onramp quotes with destination token decimals when the final amount comes from Squid; tests should cover 6-decimal source → 18-decimal destination routes. | | **Direct API quote creation during planned downtime** | A partner bypasses the UI maintenance banner and requests quotes directly while operators expect Vortex services to be unavailable. | Quote creation routes run the backend maintenance guard and return `503` with `Retry-After`, `maintenance_start`, and `maintenance_end` before any quote is persisted. | +| **USDC-as-USD fallback divergence** | CoinGecko's `usd-coin` fiat price diverges from true USD fiat FX during a USDC depeg. Healthy FastForex quotes may fail the sanity band when CoinGecko is available, or CoinGecko fallback may misprice quote/fee/subsidy math when FastForex fails. | FastForex remains primary when valid; CoinGecko sanity-check outages do not block FastForex. Operators should monitor spread warnings, missing-sanity-check warnings, and quote availability during stablecoin stress. See `05-integrations/fastforex.md`. | ## Audit Checklist @@ -104,7 +105,7 @@ The refresh policy is intentionally strict (byte-identical `toAmount` and `fee` - [EXISTING FINDING] **FINDING F-012**: Dynamic pricing state is in-memory only (`partnerDiscountState` Map) — lost on server restart. Verify this is acceptable or if persistence is needed. **EXISTING FINDING** — documented as F-012. - [N/A] Verify `minDynamicDifference` cannot be set to a dangerously negative value in the partners table — no DB CHECK constraint exists. **N/A** — requires database schema review, not a code audit item. - [N/A] Verify `maxDynamicDifference` cannot be set to an unreasonably high value that would cause excessive subsidization. **N/A** — requires database schema review, not a code audit item. -- [x] Exchange rates used in quote calculation come from live on-chain sources (Nabla, Squid), not stale caches. **PASS** — verified: rates fetched from Nabla DEX and SquidRouter API at quote time. +- [x] Exchange rates used in quote calculation come from live sources: fiat forex from fastforex.io (with best-effort CoinGecko `usd-coin` sanity check/fallback and the configured short cache TTL), swap rates from Nabla DEX and SquidRouter API. **PASS** — verified: forex rates are resolved through `PriceFeedService`; swap rates come from Nabla/Squid; failed fiat conversion providers now fail closed instead of returning the unconverted input amount. **Operational risk:** CoinGecko fallback/reference is a USDC-as-USD proxy during depeg conditions. - [x] Quote response does not include internal implementation details (e.g., the `adjustedDifference` or `adjustedTargetDiscount` values). **PASS** — verified: response includes only user-facing fields (amounts, fees, expiry). - [x] Quote amounts (input, output, fees) are immutable once stored — no UPDATE endpoint modifies them. **PASS** — no quote mutation endpoints exist. - [x] EVM onramp output precision follows destination token decimals where the quote output comes from Squid. **PASS** — BRL/EURC Base→EVM and routed Alfredpay USD/MXN/COP/ARS Polygon→EVM finalization preserve destination token precision before downstream raw transfer construction. Direct same-chain same-token passthrough remains at minted/source-token precision. diff --git a/docs/security-spec/03-ramp-engine/state-machine.md b/docs/security-spec/03-ramp-engine/state-machine.md index 610392074..983a8e40d 100644 --- a/docs/security-spec/03-ramp-engine/state-machine.md +++ b/docs/security-spec/03-ramp-engine/state-machine.md @@ -30,7 +30,7 @@ Lock expiry is set to 15 minutes. If a lock is older than 15 minutes, it's consi 4. **Lock acquisition MUST be atomic** — **KNOWN ISSUE**: The current implementation reads `state.processingLock.locked` from a potentially stale DB read, then sets it in a separate UPDATE. Between the read and write, another process could also acquire the lock. There is no `SELECT FOR UPDATE`, advisory lock, or atomic compare-and-swap. 5. **Lock expiry MUST prevent indefinite stalls** — If a process crashes while holding a lock, the 15-minute expiry ensures another process can eventually take over. The `isLockExpired()` check validates the timestamp. **FIXED (2026-07-05)**: the takeover previously never succeeded — after force-releasing the expired DB lock, `acquireLock` re-read the stale in-memory `state.processingLock.locked` and gave up. `processRamp` now reloads the state after the release. Regression-tested in `apps/api/src/tests/corridors/brl-onramp.scenario.test.ts` ("lock takeover"), alongside a companion test that a *fresh* foreign lock is neither processed past nor clobbered. 6. **Retries MUST be bounded** — Maximum 8 retries (`MAX_RETRIES`). After exhaustion, the processor stops retrying (but does not automatically transition to `failed` — this is a gap). -7. **Phase execution MUST be time-bounded** — The 10-minute timeout (`MAX_EXECUTION_TIME_MS`) prevents handlers from hanging indefinitely. Timeouts are treated as recoverable errors. +7. **Phase execution MUST be time-bounded** — The 10-minute timeout (`MAX_EXECUTION_TIME_MS`, env-overridable via `PHASE_PROCESSOR_MAX_EXECUTION_TIME_MS` for tests) prevents handlers from hanging indefinitely. Timeouts are treated as recoverable errors. **FIXED (2026-07-08)**: the timeout previously only abandoned the execution (`Promise.race`) without stopping it — abandoned polling loops kept running forever, accumulated across retries and recovery-worker passes until the CPU pegged (production incidents Jun 26–Jul 8), and could later perform real side effects (e.g. Avenia ticket creation) concurrently with the live retry. The processor now aborts each timed-out execution via an `AbortSignal` passed to `handler.execute`, and the shared polling helpers (`waitUntilTrue*`, `checkEvmBalance*`) stop when it fires. Regression-tested in `apps/api/src/api/services/phases/phase-processor.cancellation.integration.test.ts`. 8. **The retry counter MUST be reset on successful phase advancement** — When the phase changes, `retriesMap.delete(state.id)` clears the counter, giving the next phase a fresh retry budget. 9. **Error logs MUST be appended, never overwritten** — Each error is pushed to the `errorLogs` array with timestamp, phase, recoverability flag, and stack trace. 10. **Phase handlers MUST NOT directly mutate the database** — Only the processor should call `state.update()` for phase transitions. Handlers return a pending state object. @@ -42,7 +42,7 @@ Lock expiry is set to 15 minutes. If a lock is older than 15 minutes, it's consi | **Race condition on locking** | Two API instances process the same ramp simultaneously due to non-atomic lock acquisition | **KNOWN VULNERABILITY**: No database-level atomic lock. Mitigation: in-memory lock helps for single-instance deployments; multi-instance requires `SELECT FOR UPDATE` or advisory locks | | **Stale state execution** | Handler reads stale data from DB cache, executes with wrong balances/amounts | Phase processor calls `findByPk` before each ramp processing; handlers should re-read state from DB as needed | | **Infinite retry loop** | A recoverable error keeps retrying forever | Bounded at 8 retries; after exhaustion, processing stops | -| **Phase handler timeout** | A handler hangs (e.g., waiting for an RPC response that never comes), blocking the ramp | 10-minute timeout per phase; timeout throws `RecoverablePhaseError` which triggers retry | +| **Phase handler timeout** | A handler hangs (e.g., waiting for an RPC response that never comes), blocking the ramp | 10-minute timeout per phase; timeout throws `RecoverablePhaseError` which triggers retry, and the abandoned execution is aborted via `AbortSignal` so it cannot keep polling or perform late side effects | | **Lock starvation** | Process acquires lock, crashes, lock persists for 15 minutes | Lock expiry mechanism detects stale locks; force-releases and reacquires | | **Retry counter memory leak** | `retriesMap` (in-memory `Map`) grows unbounded for many ramps | Counter is deleted on terminal state, successful phase change, or max retries reached. Long-running ramps with many retries could accumulate entries, but each entry is just an integer. | | **Phase skip attack** | Attacker manipulates DB to skip phases (e.g., jump from `initial` to `complete`) | Phase transitions are controlled by handler return values, not external input. However, if an attacker has DB access, they could modify `currentPhase` directly — no DB-level constraints prevent invalid transitions. | @@ -54,7 +54,7 @@ Lock expiry is set to 15 minutes. If a lock is older than 15 minutes, it's consi - [EXISTING FINDING] **F-004**: After max retries exhausted for a recoverable error, the ramp stays in its current phase (not transitioned to `failed`). Retry counter resets across processing cycles, creating an infinite soft loop. - [x] `state.update()` in the processor uses `{ fields: ["currentPhase", "phaseHistory"] }` — enforced and not bypassed - [x] Terminal states `complete` and `failed` both trigger `retriesMap.delete()` and halt recursion -- [x] `MAX_EXECUTION_TIME_MS` (10 minutes) is enforced via `Promise.race` with a timeout promise +- [x] `MAX_EXECUTION_TIME_MS` (10 minutes) is enforced via `Promise.race` with a timeout promise, and the losing execution is aborted via `AbortSignal` (not merely abandoned) - [x] `MAX_RETRIES` (8) is the hard limit — no code path bypasses this (caveat: resets across cycles per F-004) - [x] `RecoverablePhaseError.minimumWaitSeconds` is respected when provided; fallback is 30 seconds - [x] `phaseHistory` is append-only — phase transitions add to the array, never truncate it @@ -62,5 +62,5 @@ Lock expiry is set to 15 minutes. If a lock is older than 15 minutes, it's consi - [x] No phase handler directly calls `RampState.update()` for `currentPhase` — only the processor does this - [x] The `lockedRamps` Set is cleaned up in the `finally` block (`this.lockedRamps.delete(state.id)`) - [x] Lock expiry handles edge cases: missing timestamp → expired, invalid date → expired, NaN → expired -- [x] Phase processor is a singleton — `PhaseProcessor.getInstance()` pattern, default export is singleton instance, no other file creates `new PhaseProcessor()` +- [x] Phase processor is a singleton — `PhaseProcessor.getInstance()` pattern, default export is singleton instance, no production file creates `new PhaseProcessor()` (tests instantiate the class directly) - [EXISTING FINDING] **F-056**: `sandboxEnabled` causes `initial-phase-handler` to skip the entire state machine (transitions directly `initial` → `complete` after a 10-second sleep) — no production guard prevents this. diff --git a/docs/security-spec/05-integrations/brla.md b/docs/security-spec/05-integrations/brla.md index f5b5dd0d8..05bedac10 100644 --- a/docs/security-spec/05-integrations/brla.md +++ b/docs/security-spec/05-integrations/brla.md @@ -17,7 +17,7 @@ BRLA is the Brazilian Real stablecoin used for BRL on/off-ramp operations, acces 1. User receives PIX deposit details (QR code) during ramp registration. The deposit QR code is gated behind successful presigned-tx validation (see `transaction-validation.md`). 2. User makes PIX payment to the Avenia-managed account. -3. `brlaOnrampMint`: Avenia mints BRLA on Base directly to the user's Base ephemeral. Handler polls `evmEphemeralAddress` balance every 5s for up to **30 minutes** (`PAYMENT_TIMEOUT_MS`) using `checkEvmBalancePeriodically` against a 5-minute inner balance-arrival timeout (`EVM_BALANCE_CHECK_TIMEOUT_MS`). +3. `brlaOnrampMint`: Avenia mints BRLA on Base directly to the user's Base ephemeral. The handler first polls the Avenia subaccount balance every 5s (`waitUntilTrueWithTimeout`, 5-minute chunks — `AVENIA_BALANCE_CHECK_TIMEOUT_MS`), then the `evmEphemeralAddress` balance every 1s (`checkEvmBalancePeriodically`, 5-minute chunks — `EVM_BALANCE_CHECK_TIMEOUT_MS`). Each chunk timeout is a recoverable error; the overall payment window of **30 minutes** (`PAYMENT_TIMEOUT_MS`, wall clock since phase entry via `phaseHistory`) is re-checked on every chunk timeout and cancels the ramp (`failed`) when exceeded. Both waits accept the processor's `AbortSignal` so abandoned executions stop polling. (Before 2026-07-08 the Avenia wait ran 30 minutes per execution, which always outlived the processor's 10-minute execution timeout — so the payment-window cancellation never ran for recovered ramps and never-paid onramps churned indefinitely.) 4. `subsidizePreSwap` (if needed) → `nablaApprove` → `nablaSwap`: Nabla DEX **on Base** swaps BRLA → USDC. 5. `subsidizePostSwap` (if needed) → `distributeFees` (Multicall3 batch on Base, see `fee-integrity.md`). 6. If destination is Base + USDC → direct `destinationTransfer` (Squid skipped — see `squid-router.md`). Otherwise → `squidRouterApprove` / `squidRouterSwap` → bridge to user's supported destination EVM chain → optional fallback `backupSquidRouter*` swap on the destination chain → `destinationTransfer`. BRL→AssetHub is temporarily disabled at quote eligibility and should not reach registration. diff --git a/docs/security-spec/06-cross-chain/fund-routing.md b/docs/security-spec/06-cross-chain/fund-routing.md index 281d167e1..d051312c9 100644 --- a/docs/security-spec/06-cross-chain/fund-routing.md +++ b/docs/security-spec/06-cross-chain/fund-routing.md @@ -44,6 +44,7 @@ The current code resolves this through `EVM_FUNDING_PRIVATE_KEY` and the `getEvm 9. **EVM subsidy caps MUST stop transfers without forcing manual phase repair** — If an EVM pre-swap subsidy exceeds its configured quote-relative cap, the handler must not submit a transfer. For EVM post-swap subsidy, the handler must split the top-up into (a) actual-vs-quoted swap-output discrepancy and (b) discount-derived subsidy, then enforce each component's configured cap independently before submitting a single transfer. Both post-swap cap fractions are env-overridable and default to `0.05`. A cap breach is intentionally recoverable so operators can investigate, top up, or cancel the ramp without repairing an unrecoverably failed phase. 10. **`finalSettlementSubsidy` MUST subsidize the gap to *actual bridge delivery*, not to the ephemeral's total balance** — The subsidy is `expectedAmountRaw - delivered`, where `delivered = actualBalance - preSettlementBalance` and `preSettlementBalance` is the destination-token balance snapshotted before `squidRouterSwap` is broadcast. The snapshot is written only once, so a retry after a same-chain synchronous swap cannot overwrite the true pre-delivery baseline with a post-delivery balance. Computing the subsidy from total balance is unsafe: leftover Nabla-swap dust in the destination token would make the handler return early and over-subsidize before the Squid bridge output has landed. To avoid racing the bridge, the balance poll waits for ≥90% of `expectedAmountRaw` to arrive (the 90% floor absorbs bridge slippage while still confirming the bridge actually delivered) rather than returning on any non-zero balance. The final subsidy is additionally clamped to `expectedAmountRaw - actualBalance`, so a bad or stale baseline cannot top up more than the on-chain shortfall. (Incident: a EUR→EURC Base ramp was over-funded ~29.36 EURC and stranded ~59 EURC because the pre-fix handler returned on dust and subtracted total balance.) 11. **Degenerate same-token settlement routes MUST skip `finalSettlementSubsidy` entirely** — When the ramp is a direct transfer (`state.state.isDirectTransfer === true`), a EUR→EURC-on-Base route (`isEurToEurcBaseDirect`), or a BRL→BRLA-on-Base route (`isBrlToBrlaBaseDirect`), the handler short-circuits to `destinationTransfer` without subsidizing. There is no Squid bridge to settle, so any subsidy computation would be against a balance the funder never needs to top up. +12. **Subsidy cap currency conversions MUST fail closed** — Any USD-denominated subsidy cap check that depends on `PriceFeedService.convertCurrency()` must stop the phase if fiat/crypto price providers fail or return invalid rates. It must not continue with the original unconverted amount, because that can understate the USD value of a funding-account transfer. ## Threat Vectors & Mitigations @@ -58,6 +59,7 @@ The current code resolves this through `EVM_FUNDING_PRIVATE_KEY` and the `getEvm | **Balance check race condition in destination transfer** — Balance changes between the check and the transaction submission | Possible but unlikely for ephemeral accounts (no other senders). If balance drops between check and submission, the EVM transaction reverts (no fund loss, just a failed phase that retries). | | **Post-swap routing logic inconsistency** — The next-phase selection in `subsidize-post-swap-handler.ts` routes to a phase that doesn't match the ramp's intended flow | Routing logic uses `direction`, `toChain`, and `outputTokenType` from ramp state. A mismatch would cause the ramp to enter an unexpected phase. Since phases are handler-specific, executing the wrong phase could fail or produce incorrect results. | | **Final settlement over-subsidy race** — `finalSettlementSubsidy` returns as soon as *any* destination-token balance appears (e.g. Nabla-swap dust) and computes `subsidy = expected − totalBalance` before the Squid bridge output lands. The funder then tops up the full expected amount on top of the later bridge delivery, double-paying the ephemeral and stranding the excess. | **Mitigated.** The handler snapshots `preSettlementBalance` before `squidRouterSwap` is broadcast, never overwrites that baseline on retry, waits for ≥90% of `expectedAmountRaw` to arrive, subsidizes only `expected − (actualBalance − preSettlementBalance)`, and clamps the transfer to the on-chain shortfall. Direct-transfer / EUR→EURC-Base routes skip the phase outright. Regression-test this: the failure mode silently over-pays from the funding key. | +| **Price-provider failure during cap conversion** — A subsidy handler cannot convert native-token requirements into USD and falls back to the unconverted amount, allowing a cap check to pass with the wrong unit. | **Mitigated.** `PriceFeedService.convertCurrency()` throws on provider failure; handlers must fail the phase rather than continue with unchecked unit assumptions. | ## Audit Checklist @@ -80,4 +82,5 @@ The current code resolves this through `EVM_FUNDING_PRIVATE_KEY` and the `getEvm - [x] **`MOONBEAM_FUNDING_PRIVATE_KEY` rename/refactor**: EVM funding now uses the `EVM_FUNDING_PRIVATE_KEY` / `getEvmFundingAccount(network)` path, with the old env name retained only as backward-compatible fallback. - [x] **`finalSettlementSubsidy` subsidizes against actual bridge delivery, not total balance.** **PASS** — snapshots `preSettlementBalance` before broadcasting `squidRouterSwap` (`squid-router-phase-handler.ts`, stored in `meta-state-types.ts`), does not overwrite it on retry, waits for ≥90% of `expectedAmountRaw`, computes `subsidy = expected − (actualBalance − preSettlementBalance)`, and clamps the result to the on-chain shortfall. Prevents both the dust-triggered over-subsidy race and same-chain post-delivery baseline overwrite. - [x] **`finalSettlementSubsidy` short-circuits degenerate same-token routes.** **PASS** — returns to `destinationTransfer` when `state.state.isDirectTransfer === true`, `isEurToEurcBaseDirect(...)`, or `isBrlToBrlaBaseDirect(...)`, so no subsidy is computed for routes that have no Squid bridge to settle. +- [x] Subsidy cap currency conversion fails closed on price-provider errors. **PASS** — `PriceFeedService.convertCurrency()` rethrows provider failures, so handlers do not continue with original unconverted amounts when cap conversions fail. - [x] **Subsidy bookkeeping cannot silently drop rows for unknown token symbols.** **PASS (FIXED)** — `subsidies.token` was a Postgres enum, but `finalSettlementSubsidy` records the `assetSymbol` from the dynamic SquidRouter token registry (open-ended: `WETH`, `USDC.e`, ...) plus per-network native symbols (`BNB`, `AVAX`), and `BasePhaseHandler.createSubsidy` deliberately swallows insert errors so bookkeeping never blocks a phase. Any symbol outside the enum meant the subsidy was paid on-chain but never recorded. Migration 037 widens the column to `VARCHAR(32)` (the enum patched piecemeal before — see migration 036), and the swallowed-error path now logs an alertable `SUBSIDY_RECORDING_FAILED` line with ramp, phase, token, amount, and tx hash. Regression: `src/tests/subsidy-recording.invariants.test.ts`. diff --git a/docs/security-spec/07-operations/api-surface.md b/docs/security-spec/07-operations/api-surface.md index 92616f636..73b0593ae 100644 --- a/docs/security-spec/07-operations/api-surface.md +++ b/docs/security-spec/07-operations/api-surface.md @@ -21,6 +21,7 @@ This spec covers the external-facing attack surface of the Vortex API (`apps/api - Stack traces stripped in non-development environments - 404 handler for unmatched routes - Error responses include an `errors` array with validation details +- Fiat-provider failures raised while handling the mutating ramp endpoints (`POST /v1/ramp/register`, `POST /v1/ramp/update`, `POST /v1/ramp/start`) are normalized before they reach the caller (`mapProviderFailure` in `controllers/ramp.controller.ts`). Both providers throw a `ProviderHttpError` (`BrlaApiError` for Avenia/BRLA, `AlfredpayApiError` for Alfredpay; base class in `packages/shared/src/services/providerHttpError.ts` — named to avoid colliding with the price-layer `ProviderApiError` in `api/errors/providerErrors.ts`), covering both non-ok HTTP responses and transport failures (DNS/timeout/connection reset, carried as `status: 0`). The handler maps these to a `422` (upstream `4xx` — account/request rejected) or `502` (upstream `5xx`/transport — provider unavailable) with a generic "payment provider" message. The raw upstream body (e.g. `{"error":"user is blocked"}`) is **never** forwarded to the caller; it is logged server-side only, **truncated** to 300 chars, alongside the failing `provider`/`endpoint`/`method`/`status` (never query parameters, which may carry a PIX key or other PII) so operators can pinpoint which provider call failed and why. The Avenia and Alfredpay controllers under `controllers/` handle their own errors inline and do not route through this path. **Request correlation and client observability** (`api/observability/`): - Incoming requests receive or propagate a non-secret request ID. diff --git a/docs/security-spec/07-operations/secret-management.md b/docs/security-spec/07-operations/secret-management.md index ded1e1e03..88b047f98 100644 --- a/docs/security-spec/07-operations/secret-management.md +++ b/docs/security-spec/07-operations/secret-management.md @@ -27,6 +27,8 @@ This spec catalogs every secret, its purpose, its blast radius if compromised, a | `MYKOBO_BASE_URL` | Mykobo API endpoint (`/v1` suffix normalized by client) | Not a secret; misconfiguration could route requests to an attacker-controlled host if env is tampered with | | `MYKOBO_CLIENT_DOMAIN` | Vortex's registered client identifier with Mykobo; sent as `client_domain` on every Mykobo API call and selects the negotiated fee tier | Not a secret. **Operationally critical:** loaded via `getEnvVar` with no default — if unset, Mykobo silently falls back to its default fee tier (~5x higher than the negotiated rate, observed: ~0.31 EUR vs ~0.06 EUR fixed deposit fee). Quote engine fee defaults (`defaultDepositFee` / `defaultWithdrawFee`) will not match what Mykobo actually charges, corrupting fee accounting. Treat as required in production. | | `ALCHEMYPAY_APP_ID` / `ALCHEMYPAY_SECRET_KEY` | AlchemyPay price provider | Access to AlchemyPay API — price manipulation, data access | +| `FASTFOREX_API_KEY` | FastForex fiat exchange-rate provider used as the primary fiat forex source for quote/conversion math | Access to FastForex API. Limited direct blast radius — no fund movement authority — but outage, revocation, or provider-side manipulation can affect quote availability or fiat pricing until CoinGecko fallback takes over. | +| `FASTFOREX_API_URL` | FastForex API endpoint | Not a secret; integrity-sensitive because tampering can redirect fiat-rate lookups. Rates still require validation and best-effort CoinGecko sanity checks before use. | | `TRANSAK_API_KEY` | Transak price provider | Access to Transak API | | `MOONPAY_API_KEY` | MoonPay price provider | Access to MoonPay API | | `GOOGLE_SERVICE_ACCOUNT_EMAIL` / `GOOGLE_PRIVATE_KEY` | Google Sheets integration (fee logging) | Access to Google Sheets — data exposure, fee log manipulation | @@ -56,6 +58,7 @@ This spec catalogs every secret, its purpose, its blast radius if compromised, a 8. **No secret MUST be passed as a URL query parameter** — Query parameters are logged by proxies, CDNs, and web servers. Secrets must only travel in headers or request bodies. 9. **`MYKOBO_CLIENT_DOMAIN` MUST be set in production** — Not a secret, but operationally critical: when unset, Mykobo silently applies its default fee tier (~5x worse than the negotiated rate). Quote-engine fee defaults will then diverge from what Mykobo actually charges. Deployment automation MUST treat a missing `MYKOBO_CLIENT_DOMAIN` as a hard failure rather than letting it fall through to default-tier fees. 10. **Observability MUST follow the same no-secret rule as logs** — API client events, request correlation logs, metrics, and observability data must not contain full API keys, bearer tokens, provider credentials, private keys, seeds, raw request headers, or raw request bodies. Sanitized request summaries may be stored only when they are allowlisted, scalar, and stripped of secrets or sensitive payment/user data. See `07-operations/client-observability.md`. +11. **Provider endpoint URLs MUST be treated as integrity-sensitive configuration** — Non-secret provider URL env vars such as `FASTFOREX_API_URL` and `MYKOBO_BASE_URL` must not be user-controllable or mutable at runtime by untrusted actors. A malicious URL can redirect outbound provider calls even when no secret is leaked. ## Threat Vectors & Mitigations @@ -66,6 +69,7 @@ This spec catalogs every secret, its purpose, its blast radius if compromised, a | **Ephemeral webhook keys after restart** — Without `WEBHOOK_PRIVATE_KEY`, webhook signatures change on every restart | Webhook consumers lose the ability to verify signatures from the previous instance. This is a reliability issue, not a direct security vulnerability, but it could cause consumers to reject legitimate webhooks or accept unverified ones (if they fall back to no-verification). | | **Credential rotation requires redeployment** — No runtime rotation mechanism | To rotate any secret, the environment variable must be updated and the service restarted. During the rotation window, the old secret may still be valid (e.g., API keys at third parties). There is no way to do zero-downtime rotation. | | **Lateral movement from price provider keys** — Compromise of AlchemyPay/Transak/MoonPay keys | Limited blast radius — these keys access price data, not funds. However, an attacker could manipulate prices shown to users (if the provider API allows it) or access transaction data. | +| **FastForex endpoint tampering** — `FASTFOREX_API_URL` points to an attacker-controlled host | Quote and conversion math could consume manipulated fiat rates if the forged response also passes validation. The FastForex path is constrained by best-effort CoinGecko sanity-band validation and fails over/fails closed on invalid rates, but deployment configuration should still pin the endpoint to the expected HTTPS origin. | | **Google Sheets credentials** — Access to fee logging spreadsheet | Could expose fee data and ramp metadata. Could manipulate fee records. Lower severity than financial keys but still a data leak. | | **`SUPABASE_SERVICE_KEY` used for all database operations** — No principle of least privilege | The service key bypasses all RLS. If any code path leaks this key, the attacker has unrestricted database access. A more secure approach would use the anon key with RLS for read operations and the service key only for privileged writes. | | **Observability event leak** — Operational telemetry captures secret values or payment/KYC data | Client observability uses a sanitized event schema, 16-character key prefixes only, allowlisted scalar request summaries, scalar metadata filtering, and explicit exclusion of raw headers/bodies, tax IDs, PIX data, KYC data, and private material. | diff --git a/docs/security-spec/README.md b/docs/security-spec/README.md index 9bafe35bb..e3c11c711 100644 --- a/docs/security-spec/README.md +++ b/docs/security-spec/README.md @@ -39,6 +39,7 @@ This directory contains the security specification for the Vortex cross-border p | Mykobo | `05-integrations/mykobo.md` | Mykobo EUR on/off-ramp on Base (currently registration-gated) | | Monerium | `05-integrations/monerium.md` | (Deprecated) Monerium EUR on-ramp — replaced by Mykobo | | Alfredpay | `05-integrations/alfredpay.md` | Alfredpay on/off-ramp | +| FastForex | `05-integrations/fastforex.md` | Fiat forex price provider used by quote/conversion math | | Stellar Anchors | `05-integrations/stellar-anchors.md` | SEP-24, Spacewalk, Stellar payment (fully deprecated; EUR migrated to Mykobo, ARS removed) | | Squid Router | `05-integrations/squid-router.md` | Cross-chain EVM routing | | XCM Transfers | `06-cross-chain/xcm-transfers.md` | Pendulum↔Moonbeam↔AssetHub↔Hydration | @@ -72,6 +73,7 @@ Every spec file uses exactly four sections: | **Mykobo** | EUR fiat anchor for SEPA on/off-ramp on Base (settles EURC on Base; currently registration-gated) | | **Monerium** | (Deprecated) EUR stablecoin issuer; previously used for EUR on-ramp via SEPA. Replaced by Mykobo. | | **Alfredpay** | Fiat payment provider supporting multiple currencies | +| **FastForex** | Fiat exchange-rate provider used as the primary source for USD-to-fiat quote/conversion rates | | **Squid Router** | Cross-chain swap/routing protocol for EVM chains | | **Axelar** | Cross-chain messaging protocol used by SquidRouter for EVM-to-EVM bridging | | **Avenia** | BRLA's internal settlement platform; handles BRLA transfers, swaps, and PIX payouts | diff --git a/packages/shared/src/helpers/functions.test.ts b/packages/shared/src/helpers/functions.test.ts new file mode 100644 index 000000000..75fe04923 --- /dev/null +++ b/packages/shared/src/helpers/functions.test.ts @@ -0,0 +1,91 @@ +import { describe, expect, it } from "bun:test"; +import { sleep, waitUntilTrue, waitUntilTrueWithTimeout } from "./functions"; + +describe("waitUntilTrueWithTimeout", () => { + it("resolves once the condition becomes true", async () => { + let calls = 0; + await waitUntilTrueWithTimeout(async () => ++calls >= 3, 5, 1000); + expect(calls).toBe(3); + }); + + it("rejects on timeout with the legacy 'Timeout ...' message", async () => { + await expect(waitUntilTrueWithTimeout(async () => false, 5, 30)).rejects.toThrow(/Timeout waiting for condition/); + }); + + // Regression: the old implementation raced the poll loop against a timeout without + // cancelling it, so every timed-out call leaked a poll loop that ran forever + // (the production CPU leak of 2026-07). The loop must stop polling after timeout. + it("stops polling after the timeout fires", async () => { + let calls = 0; + await expect( + waitUntilTrueWithTimeout( + async () => { + calls++; + return false; + }, + 5, + 30 + ) + ).rejects.toThrow(/Timeout/); + + const callsAtTimeout = calls; + await sleep(60); + expect(calls).toBe(callsAtTimeout); + }); + + it("stops polling when the caller aborts", async () => { + let calls = 0; + const controller = new AbortController(); + const pending = waitUntilTrueWithTimeout( + async () => { + calls++; + return false; + }, + 5, + 10_000, + controller.signal + ); + + await sleep(20); + controller.abort(new Error("caller aborted")); + await expect(pending).rejects.toThrow("caller aborted"); + + const callsAtAbort = calls; + await sleep(60); + expect(calls).toBe(callsAtAbort); + }); +}); + +describe("sleep", () => { + it("rejects immediately when the signal is already aborted", async () => { + const controller = new AbortController(); + controller.abort(new Error("pre-aborted")); + + const start = Date.now(); + await expect(sleep(10_000, controller.signal)).rejects.toThrow("pre-aborted"); + expect(Date.now() - start).toBeLessThan(1000); + }); +}); + +describe("waitUntilTrue", () => { + it("stops polling when the signal aborts", async () => { + let calls = 0; + const controller = new AbortController(); + const pending = waitUntilTrue( + async () => { + calls++; + return false; + }, + 5, + controller.signal + ); + + await sleep(20); + controller.abort(new Error("stop")); + await expect(pending).rejects.toThrow("stop"); + + const callsAtAbort = calls; + await sleep(60); + expect(calls).toBe(callsAtAbort); + }); +}); diff --git a/packages/shared/src/helpers/functions.ts b/packages/shared/src/helpers/functions.ts index c94d3af7b..a2f390f25 100644 --- a/packages/shared/src/helpers/functions.ts +++ b/packages/shared/src/helpers/functions.ts @@ -1,22 +1,73 @@ -export async function waitUntilTrue(test: () => Promise, periodMs = 1000) { +function abortReason(signal: AbortSignal): Error { + return signal.reason instanceof Error ? signal.reason : new Error("Aborted"); +} + +/** + * Sleep for `ms` milliseconds, rejecting early if `signal` aborts. + */ +export function sleep(ms: number, signal?: AbortSignal): Promise { + return new Promise((resolve, reject) => { + const onAbort = () => { + clearTimeout(timer); + reject(abortReason(signal as AbortSignal)); + }; + const timer = setTimeout(() => { + signal?.removeEventListener("abort", onAbort); + resolve(); + }, ms); + signal?.addEventListener("abort", onAbort, { once: true }); + // A listener added to an already-aborted signal never fires, so re-check + // after registration — this leaves no ordering in which an abort is missed. + if (signal?.aborted) { + signal.removeEventListener("abort", onAbort); + clearTimeout(timer); + reject(abortReason(signal)); + } + }); +} + +export async function waitUntilTrue(test: () => Promise, periodMs = 1000, signal?: AbortSignal) { // eslint-disable-next-line no-constant-condition while (true) { + if (signal?.aborted) { + throw abortReason(signal); + } if (await test()) { return true; } - await new Promise(resolve => setTimeout(resolve, periodMs)); + await sleep(periodMs, signal); } } export async function waitUntilTrueWithTimeout( test: () => Promise, periodMs = 1000, - timeoutMs = 180000 + timeoutMs = 180000, + signal?: AbortSignal ): Promise { + if (signal?.aborted) { + throw abortReason(signal); + } + + // The polling loop must be aborted when the timeout fires (or the caller aborts): + // a plain Promise.race would leave it polling forever after the race settles. + const controller = new AbortController(); + const onCallerAbort = () => controller.abort(abortReason(signal as AbortSignal)); + signal?.addEventListener("abort", onCallerAbort, { once: true }); + + let timer: ReturnType | undefined; const timeoutPromise = new Promise((_, reject) => { - setTimeout(() => reject(new Error(`Timeout waiting for condition after ${timeoutMs} ms`)), timeoutMs); + timer = setTimeout(() => { + const timeoutError = new Error(`Timeout waiting for condition after ${timeoutMs} ms`); + controller.abort(timeoutError); + reject(timeoutError); + }, timeoutMs); }); - const waitPromise = waitUntilTrue(test, periodMs); - await Promise.race([waitPromise, timeoutPromise]); + try { + await Promise.race([waitUntilTrue(test, periodMs, controller.signal), timeoutPromise]); + } finally { + clearTimeout(timer); + signal?.removeEventListener("abort", onCallerAbort); + } } diff --git a/packages/shared/src/services/alfredpay/alfredpayApiService.ts b/packages/shared/src/services/alfredpay/alfredpayApiService.ts index 26f7157bc..8008bcc91 100644 --- a/packages/shared/src/services/alfredpay/alfredpayApiService.ts +++ b/packages/shared/src/services/alfredpay/alfredpayApiService.ts @@ -1,6 +1,7 @@ import Big from "big.js"; import { ALFREDPAY_API_KEY, ALFREDPAY_API_SECRET, ALFREDPAY_BASE_URL } from "../.."; import logger from "../../logger"; +import { ProviderHttpError } from "../providerHttpError"; import { AlfredpayCustomerType, AlfredpayFee, @@ -41,6 +42,16 @@ import { SubmitKycInformationResponse } from "./types"; +/** + * Error thrown when an Alfredpay HTTP request fails. See {@link ProviderHttpError} for the + * carried fields and the message-format invariant. + */ +export class AlfredpayApiError extends ProviderHttpError { + constructor(params: { status: number; endpoint: string; method: string; responseBody: string }) { + super({ ...params, provider: "alfredpay" }); + } +} + export class AlfredpayApiService { private static instance: AlfredpayApiService; @@ -97,7 +108,19 @@ export class AlfredpayApiService { const fullUrl = `${ALFREDPAY_BASE_URL}${url}`; logger.current.debug(`Sending request to ${fullUrl} with method ${method} and payload:`, payload); - const response = await fetch(fullUrl, options); + let response: Response; + try { + response = await fetch(fullUrl, options); + } catch (error) { + // Transport failure (DNS/timeout/connection reset) — no HTTP response. Surface it as a + // provider error with status 0 so callers can normalize it to a 502 instead of a 500. + throw new AlfredpayApiError({ + endpoint: path, + method, + responseBody: error instanceof Error ? error.message : String(error), + status: 0 + }); + } if (response.status === 401) { throw new Error("Authorization error."); @@ -123,7 +146,14 @@ export class AlfredpayApiService { } } } - throw new Error(`Request failed with status '${response.status}'. Error: ${errorText}`); + // AlfredpayApiError keeps the "status ''. Error: " message shape that this + // controller's callers match on, and exposes endpoint/method/status for structured logging. + throw new AlfredpayApiError({ + endpoint: path, + method, + responseBody: errorText, + status: response.status + }); } try { return await response.json(); diff --git a/packages/shared/src/services/brla/brlaApiService.ts b/packages/shared/src/services/brla/brlaApiService.ts index ef8837170..d44b359a2 100644 --- a/packages/shared/src/services/brla/brlaApiService.ts +++ b/packages/shared/src/services/brla/brlaApiService.ts @@ -1,6 +1,7 @@ import * as forge from "node-forge"; import { BRLA_API_KEY, BRLA_BASE_URL, BRLA_PRIVATE_KEY, DocumentUploadRequest, DocumentUploadResponse } from "../.."; import logger from "../../logger"; +import { ProviderHttpError } from "../providerHttpError"; import { Endpoint, EndpointMapping, Endpoints, Methods } from "./mappings"; import { AccountLimitsResponse, @@ -39,6 +40,16 @@ interface CachedQuote { const QUOTE_CACHE_TTL_MS = 3 * 60 * 1000; // 3 minutes const QUOTE_CACHE_MAX_SIZE = 100; // Maximum number of cached entries +/** + * Error thrown when an Avenia/BRLA HTTP request fails. See {@link ProviderHttpError} for the + * carried fields and the message-format invariant. + */ +export class BrlaApiError extends ProviderHttpError { + constructor(params: { status: number; endpoint: string; method: string; responseBody: string }) { + super({ ...params, provider: "avenia" }); + } +} + export class BrlaApiService { private static instance: BrlaApiService; @@ -145,15 +156,34 @@ export class BrlaApiService { const fullUrl = `${BRLA_BASE_URL}${requestUri}`; logger.current.debug(`Sending request to ${fullUrl} with method ${method} and payload:`, payload); - const response = await fetch(fullUrl, options); + let response: Response; + try { + response = await fetch(fullUrl, options); + } catch (error) { + // Transport failure (DNS/timeout/connection reset) — no HTTP response. Surface it as a + // provider error with status 0 so callers can normalize it to a 502 instead of a 500. + throw new BrlaApiError({ + endpoint: endpoint as string, + method: method as string, + responseBody: error instanceof Error ? error.message : String(error), + status: 0 + }); + } if (response.status === 401) { throw new Error("Authorization error."); } if (!response.ok) { - // This format matters and is used in the BRLA controller. - throw new Error(`Request failed with status '${response.status}'. Error: ${await response.text()}`); + // BrlaApiError keeps the "status ''. Error: " message shape that the BRLA + // controller parses, and additionally exposes the endpoint/method/status so the caller + // can log precisely which Avenia call failed. + throw new BrlaApiError({ + endpoint: endpoint as string, + method: method as string, + responseBody: await response.text(), + status: response.status + }); } try { return await response.json(); @@ -305,7 +335,6 @@ export class BrlaApiService { public async createPixInputTicket(payload: PixInputTicketPayload, subAccountId: string): Promise { const query = `subAccountId=${encodeURIComponent(subAccountId)}`; const response = await this.sendRequest(Endpoint.Tickets, "POST", query, payload); - console.log("createPixInputTicket response", response); if ("brCode" in response) { return response; diff --git a/packages/shared/src/services/evm/balance.ts b/packages/shared/src/services/evm/balance.ts index d5c22397d..d5f8eebc0 100644 --- a/packages/shared/src/services/evm/balance.ts +++ b/packages/shared/src/services/evm/balance.ts @@ -1,5 +1,6 @@ import Big from "big.js"; import erc20ABI from "../../contracts/ERC20"; +import { sleep } from "../../helpers/functions"; import { EvmAddress, EvmNetworks, EvmTokenDetails } from "../../index"; import logger from "../../logger"; import { EvmClientManager } from "../evm/clientManager"; @@ -80,106 +81,104 @@ export async function getEvmBalance(params: { }); } -export function checkEvmBalancePeriodically( +export async function checkEvmBalancePeriodically( tokenAddress: string, brlaEvmAddress: string, amountDesiredRaw: string, intervalMs: number, timeoutMs: number, - chain: EvmNetworks + chain: EvmNetworks, + signal?: AbortSignal ): Promise { const evmClientManager = EvmClientManager.getInstance(); - - return new Promise((resolve, reject) => { - const startTime = Date.now(); - - const checkBalance = async () => { - try { - const result = await evmClientManager.readContractWithRetry(chain, { - abi: erc20ABI, - address: tokenAddress as EvmAddress, - args: [brlaEvmAddress], - functionName: "balanceOf" - }); - - const someBalanceBig = new Big(result); - const amountDesiredUnitsBig = new Big(amountDesiredRaw); - - logger.current.debug( - `checkEvmBalancePeriodically: Balance of ${brlaEvmAddress} for token ${tokenAddress} on ${chain}: ${someBalanceBig.toString()} (target: ${amountDesiredRaw})` - ); - - if (someBalanceBig.gte(amountDesiredUnitsBig)) { - resolve(someBalanceBig); - } else if (Date.now() - startTime > timeoutMs) { - reject(new BalanceCheckError(BalanceCheckErrorType.Timeout, `Balance did not meet the limit within ${timeoutMs}ms`)); - } else { - // Schedule next check AFTER this one completes to prevent overlapping calls - setTimeout(checkBalance, intervalMs); - } - } catch (err: unknown) { - reject( - new BalanceCheckError( - BalanceCheckErrorType.ReadFailure, - `Error checking balance: ${err instanceof Error ? err.message : String(err)}` - ) - ); - } - }; - - // Start the first check immediately - checkBalance(); - }); + const startTime = Date.now(); + + // eslint-disable-next-line no-constant-condition + while (true) { + if (signal?.aborted) { + throw signal.reason instanceof Error ? signal.reason : new Error("Balance check aborted"); + } + + let someBalanceBig: Big; + let amountDesiredUnitsBig: Big; + try { + const result = await evmClientManager.readContractWithRetry(chain, { + abi: erc20ABI, + address: tokenAddress as EvmAddress, + args: [brlaEvmAddress], + functionName: "balanceOf" + }); + + someBalanceBig = new Big(result); + amountDesiredUnitsBig = new Big(amountDesiredRaw); + } catch (err: unknown) { + throw new BalanceCheckError( + BalanceCheckErrorType.ReadFailure, + `Error checking balance: ${err instanceof Error ? err.message : String(err)}` + ); + } + + logger.current.debug( + `checkEvmBalancePeriodically: Balance of ${brlaEvmAddress} for token ${tokenAddress} on ${chain}: ${someBalanceBig.toString()} (target: ${amountDesiredRaw})` + ); + + if (someBalanceBig.gte(amountDesiredUnitsBig)) { + return someBalanceBig; + } + if (Date.now() - startTime > timeoutMs) { + throw new BalanceCheckError(BalanceCheckErrorType.Timeout, `Balance did not meet the limit within ${timeoutMs}ms`); + } + // Sleep AFTER each check completes to prevent overlapping calls + await sleep(intervalMs, signal); + } } /** * Periodically checks the native token balance of an address until the desired amount is met or the timeout is reached. */ -export function checkEvmNativeBalancePeriodically( +export async function checkEvmNativeBalancePeriodically( ownerAddress: string, amountDesiredRaw: string, intervalMs: number, timeoutMs: number, - chain: EvmNetworks + chain: EvmNetworks, + signal?: AbortSignal ): Promise { const evmClientManager = EvmClientManager.getInstance(); - - return new Promise((resolve, reject) => { - const startTime = Date.now(); - - const checkBalance = async () => { - try { - const balance = await evmClientManager.getBalanceWithRetry(chain, ownerAddress as EvmAddress); - const balanceBig = new Big(balance.toString()); - const amountDesiredUnitsBig = new Big(amountDesiredRaw); - - logger.current.debug( - `checkEvmNativeBalancePeriodically: Native balance of ${ownerAddress} on ${chain}: ${balanceBig.toString()} (target: ${amountDesiredRaw})` - ); - - if (balanceBig.gte(amountDesiredUnitsBig)) { - resolve(balanceBig); - } else if (Date.now() - startTime > timeoutMs) { - reject( - new BalanceCheckError(BalanceCheckErrorType.Timeout, `Native balance did not meet the limit within ${timeoutMs}ms`) - ); - } else { - // Schedule next check AFTER this one completes to prevent overlapping calls - setTimeout(checkBalance, intervalMs); - } - } catch (err: unknown) { - reject( - new BalanceCheckError( - BalanceCheckErrorType.ReadFailure, - `Error checking native balance: ${err instanceof Error ? err.message : String(err)}` - ) - ); - } - }; - - // Start the first check immediately - checkBalance(); - }); + const startTime = Date.now(); + + // eslint-disable-next-line no-constant-condition + while (true) { + if (signal?.aborted) { + throw signal.reason instanceof Error ? signal.reason : new Error("Balance check aborted"); + } + + let balanceBig: Big; + let amountDesiredUnitsBig: Big; + try { + const balance = await evmClientManager.getBalanceWithRetry(chain, ownerAddress as EvmAddress); + balanceBig = new Big(balance.toString()); + amountDesiredUnitsBig = new Big(amountDesiredRaw); + } catch (err: unknown) { + throw new BalanceCheckError( + BalanceCheckErrorType.ReadFailure, + `Error checking native balance: ${err instanceof Error ? err.message : String(err)}` + ); + } + + logger.current.debug( + `checkEvmNativeBalancePeriodically: Native balance of ${ownerAddress} on ${chain}: ${balanceBig.toString()} (target: ${amountDesiredRaw})` + ); + + if (balanceBig.gte(amountDesiredUnitsBig)) { + return balanceBig; + } + if (Date.now() - startTime > timeoutMs) { + throw new BalanceCheckError(BalanceCheckErrorType.Timeout, `Native balance did not meet the limit within ${timeoutMs}ms`); + } + // Sleep AFTER each check completes to prevent overlapping calls + await sleep(intervalMs, signal); + } } /** @@ -193,11 +192,12 @@ export function checkEvmBalanceForToken(params: { intervalMs: number; timeoutMs: number; chain: EvmNetworks; + signal?: AbortSignal; }): Promise { - const { tokenDetails, ownerAddress, amountDesiredRaw, intervalMs, timeoutMs, chain } = params; + const { tokenDetails, ownerAddress, amountDesiredRaw, intervalMs, timeoutMs, chain, signal } = params; if (isNativeEvmToken(tokenDetails)) { - return checkEvmNativeBalancePeriodically(ownerAddress, amountDesiredRaw, intervalMs, timeoutMs, chain); + return checkEvmNativeBalancePeriodically(ownerAddress, amountDesiredRaw, intervalMs, timeoutMs, chain, signal); } return checkEvmBalancePeriodically( @@ -206,6 +206,7 @@ export function checkEvmBalanceForToken(params: { amountDesiredRaw, intervalMs, timeoutMs, - chain + chain, + signal ); } diff --git a/packages/shared/src/services/index.ts b/packages/shared/src/services/index.ts index 17c170987..58fa879ed 100644 --- a/packages/shared/src/services/index.ts +++ b/packages/shared/src/services/index.ts @@ -5,7 +5,7 @@ export * from "./evm"; export * from "./mykobo"; export * from "./nabla"; export * from "./pendulum"; +export * from "./providerHttpError"; export * from "./slack.service"; export * from "./squidrouter"; -export * from "./squidrouter"; export * from "./xcm"; diff --git a/packages/shared/src/services/pendulum/apiManager.test.ts b/packages/shared/src/services/pendulum/apiManager.test.ts index 191362bf7..cbcfbc7f5 100644 --- a/packages/shared/src/services/pendulum/apiManager.test.ts +++ b/packages/shared/src/services/pendulum/apiManager.test.ts @@ -1,5 +1,6 @@ -import { afterEach, describe, expect, it } from "bun:test"; -import type { NetworkConfig } from "./apiManager"; +import { afterEach, describe, expect, it, mock } from "bun:test"; +import type { ApiPromise } from "@polkadot/api"; +import { type API, ApiManager, type NetworkConfig, type SubstrateApiNetwork } from "./apiManager"; const ORIGINAL_ENV = { ...process.env }; @@ -53,3 +54,103 @@ describe("ApiManager network configuration", () => { ]); }); }); + +// Exposes the private members needed to drive the manager without real WS connections. +type TestableApiManager = { + apiInstances: Map; + previousSpecVersions: Map; + connectApi: (networkName: SubstrateApiNetwork, wsUrlIndex?: number) => Promise; + populateApi: ApiManager["populateApi"]; + getApi: ApiManager["getApi"]; +}; + +function createManager(): TestableApiManager { + return new (ApiManager as unknown as new () => TestableApiManager)(); +} + +function createFakeApi(getSpecVersion: () => number): { instance: API; disconnect: ReturnType } { + const disconnect = mock(() => Promise.resolve()); + const api = { + call: { + core: { + version: () => Promise.resolve({ toHuman: () => ({ specVersion: getSpecVersion() }) }) + } + }, + disconnect, + isConnected: true, + isReady: Promise.resolve() + }; + return { disconnect, instance: { api: api as unknown as ApiPromise, decimals: 12, ss58Format: 42 } }; +} + +describe("ApiManager api refresh", () => { + function setupManager() { + const manager = createManager(); + let onChainSpecVersion = 1; + const fakes: ReturnType[] = []; + + // Mimics the real connectApi: returns a fresh instance reading the current on-chain + // spec version and records that version as the previous one. + const connectApiMock = mock((networkName: SubstrateApiNetwork) => { + const fake = createFakeApi(() => onChainSpecVersion); + fakes.push(fake); + manager.previousSpecVersions.set(networkName, onChainSpecVersion); + return Promise.resolve(fake.instance); + }); + manager.connectApi = connectApiMock; + + return { connectApiMock, fakes, manager, setSpecVersion: (version: number) => (onChainSpecVersion = version) }; + } + + it("populateApi is idempotent and reuses the cached instance", async () => { + const { fakes, manager } = setupManager(); + + const first = await manager.populateApi("pendulum"); + const second = await manager.populateApi("pendulum"); + + expect(second).toBe(first); + expect(manager.connectApi).toHaveBeenCalledTimes(1); + expect(fakes[0].disconnect).not.toHaveBeenCalled(); + }); + + it("getApi replaces and disconnects the cached instance when the spec version changes", async () => { + const { fakes, manager, setSpecVersion } = setupManager(); + + const initial = await manager.populateApi("pendulum"); + + // Simulate a runtime upgrade on chain + setSpecVersion(2); + + const refreshed = await manager.getApi("pendulum"); + + expect(refreshed).not.toBe(initial); + expect(manager.connectApi).toHaveBeenCalledTimes(2); + expect(fakes[0].disconnect).toHaveBeenCalledTimes(1); + expect(manager.apiInstances.get("pendulum-0")).toBe(refreshed); + }); + + it("keeps the cached instance when the refresh reconnect fails", async () => { + const { connectApiMock, fakes, manager, setSpecVersion } = setupManager(); + + const initial = await manager.populateApi("pendulum"); + + setSpecVersion(2); + connectApiMock.mockImplementationOnce(() => Promise.reject(new Error("reconnect failed"))); + + await expect(manager.getApi("pendulum")).rejects.toThrow("reconnect failed"); + + expect(manager.apiInstances.get("pendulum-0")).toBe(initial); + expect(fakes[0].disconnect).not.toHaveBeenCalled(); + }); + + it("getApi with forceRefresh replaces and disconnects the cached instance", async () => { + const { fakes, manager } = setupManager(); + + const initial = await manager.populateApi("pendulum"); + const refreshed = await manager.getApi("pendulum", true); + + expect(refreshed).not.toBe(initial); + expect(fakes[0].disconnect).toHaveBeenCalledTimes(1); + expect(manager.apiInstances.get("pendulum-0")).toBe(refreshed); + }); +}); diff --git a/packages/shared/src/services/pendulum/apiManager.ts b/packages/shared/src/services/pendulum/apiManager.ts index 23e2b6ca6..ff09bcc76 100644 --- a/packages/shared/src/services/pendulum/apiManager.ts +++ b/packages/shared/src/services/pendulum/apiManager.ts @@ -107,12 +107,42 @@ export class ApiManager { if (existingInstance) { return existingInstance; } + return await this.createAndCacheApi(networkName, index); + } + + /** + * Discards the cached API instance (disconnecting it) and creates a fresh one. + * Unlike populateApi, this always replaces the cached instance. + */ + public async refreshApi(networkName: SubstrateApiNetwork, wsUrlIndex?: number): Promise { + const index = wsUrlIndex ?? 0; + const instanceKey = this.generateInstanceKey(networkName, index); + const staleInstance = this.apiInstances.get(instanceKey); + + // Create the replacement before dropping the stale instance so a failed reconnect + // doesn't leave the network without a cached api. + const newApi = await this.createAndCacheApi(networkName, index); + + if (staleInstance) { + try { + await staleInstance.api.disconnect(); + } catch (error) { + logger.current.warn(`Failed to disconnect stale API instance for ${instanceKey}: ${error}`); + } + } + + return newApi; + } + + private async createAndCacheApi(networkName: SubstrateApiNetwork, index: number): Promise { const newApi = await this.connectApi(networkName, index); - this.apiInstances.set(instanceKey, newApi); if (!newApi.api.isConnected) await newApi.api.connect(); await newApi.api.isReady; + // Cache only once the api is ready so concurrent readers never observe a connecting instance. + this.apiInstances.set(this.generateInstanceKey(networkName, index), newApi); + return newApi; } @@ -127,16 +157,20 @@ export class ApiManager { const instanceKey = this.generateInstanceKey(networkName, 0); const apiInstance = this.apiInstances.get(instanceKey); - if (!apiInstance || forceRefresh) { + if (!apiInstance) { return await this.populateApi(networkName); } + if (forceRefresh) { + return await this.refreshApi(networkName); + } + const currentSpecVersion = await this.getSpecVersion(apiInstance.api); const previousSpecVersion = this.previousSpecVersions.get(networkName) ?? 0; if (currentSpecVersion !== previousSpecVersion) { logger.current.info(`Spec version changed for ${networkName}, refreshing the api...`); - return await this.populateApi(networkName); + return await this.refreshApi(networkName); } return apiInstance; @@ -235,10 +269,12 @@ export class ApiManager { ); try { - await this.populateApi(networkName); + const refreshedApiInstance = await this.refreshApi(networkName); + // Rebuild the extrinsic against the refreshed api; the original call is bound to the stale registry. + const retryCall = createCall(refreshedApiInstance.api); const nonce = await this.getNonce(senderKeypair, networkName); return new Promise((resolve, reject) => { - call.signAndSend(senderKeypair, { nonce }, (submissionResult: ISubmittableResult) => { + retryCall.signAndSend(senderKeypair, { nonce }, (submissionResult: ISubmittableResult) => { const { status, dispatchError } = submissionResult; if (dispatchError) { diff --git a/packages/shared/src/services/providerHttpError.ts b/packages/shared/src/services/providerHttpError.ts new file mode 100644 index 000000000..7226e653f --- /dev/null +++ b/packages/shared/src/services/providerHttpError.ts @@ -0,0 +1,42 @@ +/** + * Base class for errors raised when a fiat provider (Avenia/BRLA, Alfredpay) HTTP request + * fails — either a non-ok response or a transport failure (DNS/timeout/connection reset). + * + * Carries the failing `endpoint`, `method`, upstream `status` (0 for transport failures with + * no HTTP response), and raw `responseBody` so callers can log exactly which provider call + * failed and map it to a sanitized caller-facing error without forwarding the raw upstream body. + * + * Named `ProviderHttpError` to avoid colliding with the price-layer `ProviderApiError` in + * `apps/api/src/api/errors/providerErrors.ts`. + * + * The `message` shape is intentionally identical to the plain Error these services threw + * before (`Request failed with status ''. Error: `): existing consumers parse it + * — `handleApiError` in the BRLA controller matches `status ''` and splits on `Error: `, + * and the Alfredpay controller matches substrings like `"404"`/`"409"` in the message. Do not + * change the shape without updating those consumers. + */ +export class ProviderHttpError extends Error { + public readonly provider: string; + + public readonly status: number; + + public readonly endpoint: string; + + public readonly method: string; + + public readonly responseBody: string; + + constructor(params: { provider: string; status: number; endpoint: string; method: string; responseBody: string }) { + super(`Request failed with status '${params.status}'. Error: ${params.responseBody}`); + this.name = this.constructor.name; + // Restore the prototype chain so `instanceof ProviderHttpError` (and the subclasses) stays + // reliable after transpilation/bundling — mapProviderFailure depends on it. Matches the + // convention in apps/api/src/api/errors/providerErrors.ts and services/xcm/send.ts. + Object.setPrototypeOf(this, new.target.prototype); + this.provider = params.provider; + this.status = params.status; + this.endpoint = params.endpoint; + this.method = params.method; + this.responseBody = params.responseBody; + } +}