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/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/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/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/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/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 |