diff --git a/apps/api/.env.example b/apps/api/.env.example index 33f745d6c..0fc472eb0 100644 --- a/apps/api/.env.example +++ b/apps/api/.env.example @@ -59,6 +59,8 @@ 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 +# Binance public spot ticker (no API key required); primary source for USD<>BRL rates +BINANCE_API_URL=https://api.binance.com SUBSCAN_API_KEY=your-subscan-api-key # Price Feed Cache Configuration diff --git a/apps/api/src/api/controllers/ramp.controller.test.ts b/apps/api/src/api/controllers/ramp.controller.test.ts index 14f51358b..f78d4cfa9 100644 --- a/apps/api/src/api/controllers/ramp.controller.test.ts +++ b/apps/api/src/api/controllers/ramp.controller.test.ts @@ -3,7 +3,7 @@ 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"; +import { formatProviderContext, mapProviderFailure } from "./ramp.controller"; describe("mapProviderFailure", () => { it("maps a 4xx Avenia rejection (e.g. blocked user) to a 422 with a sanitized public message", () => { @@ -133,3 +133,48 @@ describe("mapProviderFailure", () => { expect(logContext).toEqual({}); }); }); + +describe("formatProviderContext", () => { + it("embeds provider/call/status/body in the message suffix (logger drops metadata objects)", () => { + const { logContext } = mapProviderFailure( + new BrlaApiError({ + endpoint: "/v2/account/tickets", + method: "POST", + responseBody: JSON.stringify({ error: "user is blocked" }), + status: 400 + }) + ); + + const suffix = formatProviderContext(logContext); + + // The whole point of the fix: the failing Avenia call and reason must be in the message. + expect(suffix).toContain("provider=avenia"); + expect(suffix).toContain("call=POST /v2/account/tickets"); + expect(suffix).toContain("status=400"); + expect(suffix).toContain('body={"error":"user is blocked"}'); + }); + + it("returns an empty string for non-provider failures so their log line is unchanged", () => { + expect(formatProviderContext({})).toBe(""); + }); + + it("strips control characters from the provider body so it cannot forge/split log lines", () => { + const { logContext } = mapProviderFailure( + new BrlaApiError({ + endpoint: "/v2/account/tickets", + method: "POST", + // Attacker-influenced provider echo trying to inject a fake log line + ANSI escape. + responseBody: 'oops\r\n[fatal] forged line\x1b[31m', + status: 400 + }) + ); + + const body = logContext.providerResponseBody as string; + expect(body).not.toMatch(/[\r\n]/); + expect(body).not.toContain("\x1b"); + + const suffix = formatProviderContext(logContext); + // The whole suffix (and therefore the log line) stays single-line. + expect(suffix).not.toMatch(/[\r\n]/); + }); +}); diff --git a/apps/api/src/api/controllers/ramp.controller.ts b/apps/api/src/api/controllers/ramp.controller.ts index 88839548c..2093de67c 100644 --- a/apps/api/src/api/controllers/ramp.controller.ts +++ b/apps/api/src/api/controllers/ramp.controller.ts @@ -44,6 +44,17 @@ import rampService from "../services/ramp/ramp.service"; */ const MAX_LOGGED_PROVIDER_BODY_LENGTH = 300; +/** + * Collapse control characters (CR/LF, ESC, etc.) in the untrusted provider response body to a + * single space before it is embedded in a log line. The body is external input echoed from the + * provider's HTTP response, so an embedded newline could split the line or forge a fake log + * entry (CWE-117), and an ESC could inject terminal color codes. The other fields we log + * (provider, endpoint, method, numeric status) are code-defined constants and need no escaping. + */ +function sanitizeProviderBody(body: string): string { + return body.replace(/[\x00-\x1F\x7F]+/g, " "); +} + export function mapProviderFailure(error: unknown): { error: unknown; logContext: Record } { if (!(error instanceof ProviderHttpError)) { return { error, logContext: {} }; @@ -64,12 +75,31 @@ export function mapProviderFailure(error: unknown): { error: unknown; logContext provider: error.provider, providerEndpoint: error.endpoint, providerMethod: error.method, - providerResponseBody: error.responseBody.slice(0, MAX_LOGGED_PROVIDER_BODY_LENGTH), + providerResponseBody: sanitizeProviderBody(error.responseBody).slice(0, MAX_LOGGED_PROVIDER_BODY_LENGTH), providerStatus: error.status } }; } +/** + * Render the provider log context as a message suffix. + * + * The app logger (`config/logger.ts`) formats only `{ timestamp, level, message, label }` and + * drops any metadata object passed as the second argument. Provider context therefore has to + * live in the message string itself to reach the logs — passing it as metadata (as we did + * before) silently discarded it. Server-side only; the body is already truncated. Returns an + * empty string for non-provider failures so their log line is unchanged. + */ +export function formatProviderContext(logContext: Record): string { + if (!logContext.provider) { + return ""; + } + return ( + ` — provider=${logContext.provider} call=${logContext.providerMethod} ${logContext.providerEndpoint}` + + ` status=${logContext.providerStatus} body=${logContext.providerResponseBody}` + ); +} + /** * Register a new ramping process * @public @@ -109,10 +139,9 @@ export const registerRamp = async (req: Request, res: Response, nex res.status(httpStatus.CREATED).json(ramp); } catch (error) { const { error: mappedError, logContext } = mapProviderFailure(error); - logger.error("Error registering ramp", { + logger.error(`Error registering ramp${formatProviderContext(logContext)}`, { errorType: classifyApiClientError(mappedError), - requestId: req.requestId, - ...logContext + requestId: req.requestId }); observeRampFailure(req, "ramp_register", mappedError, { quoteId: req.body?.quoteId || null }); next(mappedError); @@ -166,10 +195,9 @@ export const updateRamp = async ( res.status(httpStatus.OK).json(ramp); } catch (error) { const { error: mappedError, logContext } = mapProviderFailure(error); - logger.error("Error updating ramp", { + logger.error(`Error updating ramp${formatProviderContext(logContext)}`, { errorType: classifyApiClientError(mappedError), - requestId: req.requestId, - ...logContext + requestId: req.requestId }); observeRampFailure(req, "ramp_update", mappedError, { rampId: req.body?.rampId || null }); next(mappedError); @@ -213,10 +241,9 @@ export const startRamp = async ( res.status(httpStatus.OK).json(ramp); } catch (error) { const { error: mappedError, logContext } = mapProviderFailure(error); - logger.error("Error starting ramp", { + logger.error(`Error starting ramp${formatProviderContext(logContext)}`, { errorType: classifyApiClientError(mappedError), - requestId: req.requestId, - ...logContext + requestId: req.requestId }); observeRampFailure(req, "ramp_start", mappedError, { rampId: req.body?.rampId || null }); next(mappedError); diff --git a/apps/api/src/api/services/priceFeed.service.test.ts b/apps/api/src/api/services/priceFeed.service.test.ts index 9319d41af..a41f12660 100644 --- a/apps/api/src/api/services/priceFeed.service.test.ts +++ b/apps/api/src/api/services/priceFeed.service.test.ts @@ -29,6 +29,7 @@ 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 = { + binanceApiBaseUrl: "https://api.binance.com", coingeckoApiBaseUrl: "https://api.coingecko.com/api/v3", coingeckoApiKey: "test-api-key", cryptoCacheTtlMs: 300000, @@ -76,9 +77,18 @@ describe("PriceFeedService", () => { status: 200 }); + const mockBinanceResponse = (price: number, symbol = "USDTBRL") => + new Response(JSON.stringify({ price: String(price), symbol }), { + headers: { "content-type": "application/json" }, + status: 200 + }); + + const isBinanceUrl = (url: string) => url.includes("/api/v3/ticker/price"); + beforeEach(() => { originalDateNow = Date.now; - fetchMock = mock(async () => mockFastforexResponse(5.85)); + // Route by URL so BRL exercises the Binance-first path while other fiats hit fastforex. + fetchMock = mock(async (url: string) => (isBinanceUrl(url) ? mockBinanceResponse(5.85) : mockFastforexResponse(5.85))); global.fetch = fetchMock as unknown as typeof fetch; Object.values(loggerMock).forEach(logger => logger.mockClear()); Reflect.set(PriceFeedService, "instance", undefined); @@ -200,26 +210,118 @@ describe("PriceFeedService", () => { }); describe("getUsdToFiatExchangeRate", () => { - it("should use fastforex as primary source", async () => { + it("should use Binance spot as the primary source for BRL", async () => { const instance = PriceFeedService.getInstance(); instance.getCryptoPrice = mock(async () => 5.86); const rate = await instance.getUsdToFiatExchangeRate(BRL); expect(rate).toBe(5.85); - expect(fetchMock).toHaveBeenCalledWith( - "https://api.fastforex.io/fetch-one?from=USD&to=BRL", - expect.anything() - ); + expect(fetchMock).toHaveBeenCalledWith("https://api.binance.com/api/v3/ticker/price?symbol=USDTBRL", expect.anything()); + // fastforex must not be reached when Binance succeeds. + expect(fetchMock).not.toHaveBeenCalledWith("https://api.fastforex.io/fetch-one?from=USD&to=BRL", expect.anything()); + // The Binance rate is still sanity-checked against the CoinGecko reference. 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"); + }); + + it("should fall back to fastforex when Binance is unavailable", async () => { + const instance = PriceFeedService.getInstance(); + fetchMock = mock(async (url: string) => + isBinanceUrl(url) ? new Response("binance down", { status: 500 }) : mockFastforexResponse(5.85) + ); + global.fetch = fetchMock as unknown as typeof fetch; + instance.getCryptoPrice = mock(async () => 5.86); + + const rate = await instance.getUsdToFiatExchangeRate(BRL); + + expect(rate).toBe(5.85); + expect(fetchMock).toHaveBeenCalledWith("https://api.fastforex.io/fetch-one?from=USD&to=BRL", expect.anything()); + const [, options] = fetchMock.mock.calls.find(([url]) => !isBinanceUrl(url as string)) as [string, { headers: Headers }]; expect(options.headers.get("X-API-Key")).toBe("test-fastforex-key"); + expect(loggerMock.warn).toHaveBeenCalledWith(expect.stringContaining("Binance failed for USD-BRL")); + }); + + it("should fall back to fastforex when Binance returns a mismatched symbol", async () => { + const instance = PriceFeedService.getInstance(); + fetchMock = mock(async (url: string) => + isBinanceUrl(url) ? mockBinanceResponse(5.85, "USDTARS") : mockFastforexResponse(5.85) + ); + global.fetch = fetchMock as unknown as typeof fetch; + instance.getCryptoPrice = mock(async () => 5.86); + + const rate = await instance.getUsdToFiatExchangeRate(BRL); + + expect(rate).toBe(5.85); + expect(fetchMock).toHaveBeenCalledWith("https://api.fastforex.io/fetch-one?from=USD&to=BRL", expect.anything()); + expect(loggerMock.warn).toHaveBeenCalledWith(expect.stringContaining("Binance returned unexpected symbol for USDTBRL: USDTARS")); + }); + + it("should fall back to fastforex when the Binance rate is outside the CoinGecko sanity band", async () => { + const instance = PriceFeedService.getInstance(); + fetchMock = mock(async (url: string) => (isBinanceUrl(url) ? mockBinanceResponse(6.2) : mockFastforexResponse(5.85))); + global.fetch = fetchMock as unknown as typeof fetch; + instance.getCryptoPrice = mock(async () => 5.85); + + const rate = await instance.getUsdToFiatExchangeRate(BRL); + + expect(rate).toBe(5.85); + expect(loggerMock.warn).toHaveBeenCalledWith(expect.stringContaining("Binance USD-BRL rate 6.2")); + expect(loggerMock.warn).toHaveBeenCalledWith(expect.stringContaining("above 2.00% limit")); + }); + + it("should accept the Binance rate when the 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(5.85); + expect(loggerMock.warn).toHaveBeenCalledWith(expect.stringContaining("Unable to sanity-check Binance USD-BRL")); + }); + + it("should cache an accepted rate even when the CoinGecko sanity check was unavailable", async () => { + const instance = PriceFeedService.getInstance(); + instance.getCryptoPrice = mock(async () => 0); + + const rate = await instance.getUsdToFiatExchangeRate(BRL); + expect(rate).toBe(5.85); + + 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 skip Binance for fiats without a Binance USDT market and use fastforex", async () => { + const instance = PriceFeedService.getInstance(); + fetchMock = mock(async (url: string) => { + if (isBinanceUrl(url)) { + throw new Error("Binance must not be called for EUR"); + } + return mockFastforexResponse(0.86, EUR); + }); + global.fetch = fetchMock as unknown as typeof fetch; + instance.getCryptoPrice = mock(async () => 0.861); + + const rate = await instance.getUsdToFiatExchangeRate(EUR); + + expect(rate).toBe(0.86); + expect(fetchMock).toHaveBeenCalledWith("https://api.fastforex.io/fetch-one?from=USD&to=EUR", expect.anything()); }); it("should preserve path components in configured fastforex base URL", async () => { const instance = PriceFeedService.getInstance(); Reflect.set(instance, "fastforexApiBaseUrl", "https://api.fastforex.io/v1"); + // Disable Binance so the fastforex path is exercised. + fetchMock = mock(async (url: string) => + isBinanceUrl(url) ? new Response("binance down", { status: 500 }) : mockFastforexResponse(5.85) + ); + global.fetch = fetchMock as unknown as typeof fetch; instance.getCryptoPrice = mock(async () => 5.86); await instance.getUsdToFiatExchangeRate(BRL); @@ -245,7 +347,9 @@ describe("PriceFeedService", () => { it("should refetch after cache expires", async () => { const instance = PriceFeedService.getInstance(); let callCount = 0; - fetchMock = mock(async () => mockFastforexResponse(++callCount === 1 ? 5.85 : 5.9)); + fetchMock = mock(async (url: string) => + isBinanceUrl(url) ? mockBinanceResponse(++callCount === 1 ? 5.85 : 5.9) : mockFastforexResponse(5.85) + ); global.fetch = fetchMock as unknown as typeof fetch; instance.getCryptoPrice = mock(async () => (callCount === 1 ? 5.86 : 5.91)); @@ -259,9 +363,9 @@ describe("PriceFeedService", () => { expect(fetchMock).toHaveBeenCalledTimes(2); }); - it("should fall back to CoinGecko when fastforex fails", async () => { + it("should fall back to CoinGecko when Binance and fastforex both fail", async () => { const instance = PriceFeedService.getInstance(); - fetchMock = mock(async () => new Response("fastforex down", { status: 500 })); + fetchMock = mock(async () => new Response("providers down", { status: 500 })); global.fetch = fetchMock as unknown as typeof fetch; instance.getCryptoPrice = mock(async () => 5.92); @@ -274,18 +378,23 @@ describe("PriceFeedService", () => { it("should skip fastforex and fall back to CoinGecko when fastforex key is missing", async () => { const instance = PriceFeedService.getInstance(); Reflect.set(instance, "fastforexApiKey", undefined); + // Binance must also be unavailable to reach the CoinGecko fallback. + fetchMock = mock(async (url: string) => + isBinanceUrl(url) ? new Response("binance down", { status: 500 }) : mockFastforexResponse(5.85) + ); + global.fetch = fetchMock as unknown as typeof fetch; instance.getCryptoPrice = mock(async () => 5.92); const rate = await instance.getUsdToFiatExchangeRate(BRL); expect(rate).toBe(5.92); - expect(fetchMock).not.toHaveBeenCalled(); + expect(fetchMock).not.toHaveBeenCalledWith("https://api.fastforex.io/fetch-one?from=USD&to=BRL", expect.anything()); expect(instance.getCryptoPrice).toHaveBeenCalledWith("usd-coin", "brl"); }); - it("should throw when both fastforex and CoinGecko fail", async () => { + it("should throw when Binance, fastforex and CoinGecko all fail", async () => { const instance = PriceFeedService.getInstance(); - fetchMock = mock(async () => new Response("fastforex down", { status: 500 })); + fetchMock = mock(async () => new Response("providers down", { status: 500 })); global.fetch = fetchMock as unknown as typeof fetch; instance.getCryptoPrice = mock(async () => { throw new Error("cg down"); @@ -294,26 +403,16 @@ describe("PriceFeedService", () => { await expect(instance.getUsdToFiatExchangeRate(BRL)).rejects.toThrow("cg down"); }); - 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(5.85); - expect(loggerMock.warn).toHaveBeenCalledWith(expect.stringContaining("Unable to sanity-check fastforex USD-BRL")); - }); - 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 - }) + // Binance down, fastforex returns a zero rate, CoinGecko throws. + fetchMock = mock(async (url: string) => + isBinanceUrl(url) + ? new Response("binance down", { status: 500 }) + : 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 () => { @@ -325,42 +424,20 @@ describe("PriceFeedService", () => { 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)); + // Binance down so the fastforex sanity band is exercised. + fetchMock = mock(async (url: string) => + isBinanceUrl(url) ? new Response("binance down", { status: 500 }) : mockFastforexResponse(6.2) + ); global.fetch = fetchMock as unknown as typeof fetch; instance.getCryptoPrice = mock(async () => 5.85); const rate = await instance.getUsdToFiatExchangeRate(BRL); expect(rate).toBe(5.85); + expect(loggerMock.warn).toHaveBeenCalledWith(expect.stringContaining("fastforex USD-BRL rate 6.2")); expect(loggerMock.warn).toHaveBeenCalledWith(expect.stringContaining("above 2.00% limit")); }); - it("should accept fastforex when the CoinGecko sanity-check rate is invalid", async () => { - const instance = PriceFeedService.getInstance(); - instance.getCryptoPrice = mock(async () => 0); - - const rate = await instance.getUsdToFiatExchangeRate(BRL); - - expect(rate).toBe(5.85); - expect(loggerMock.warn).toHaveBeenCalledWith(expect.stringContaining("Unable to sanity-check fastforex USD-BRL")); - }); - - it("should cache accepted FastForex rates when CoinGecko sanity check is unavailable", async () => { - const instance = PriceFeedService.getInstance(); - instance.getCryptoPrice = mock(async () => 0); - - const rate = await instance.getUsdToFiatExchangeRate(BRL); - expect(rate).toBe(5.85); - - 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 return one for USD without calling external providers", async () => { const instance = PriceFeedService.getInstance(); @@ -370,7 +447,7 @@ describe("PriceFeedService", () => { expect(fetchMock).not.toHaveBeenCalled(); }); - it("should fetch FastForex rates for every non-USD Vortex fiat currency", async () => { + it("should price every non-USD Vortex fiat currency, using Binance for BRL and fastforex otherwise", async () => { const instance = PriceFeedService.getInstance(); const fastforexRates: Record = { ARS: 1200, @@ -388,6 +465,9 @@ describe("PriceFeedService", () => { }; fetchMock = mock(async (url: string) => { + if (isBinanceUrl(url)) { + return mockBinanceResponse(fastforexRates.BRL); + } const currency = new URL(url).searchParams.get("to"); if (!currency) { throw new Error("Missing FastForex target currency in test request"); @@ -409,10 +489,14 @@ describe("PriceFeedService", () => { 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() - ); + if (currency === BRL) { + expect(fetchMock).toHaveBeenCalledWith("https://api.binance.com/api/v3/ticker/price?symbol=USDTBRL", expect.anything()); + } else { + expect(fetchMock).toHaveBeenCalledWith( + `https://api.fastforex.io/fetch-one?from=USD&to=${currency}`, + expect.anything() + ); + } expect(instance.getCryptoPrice).toHaveBeenCalledWith("usd-coin", currency.toLowerCase()); } }); @@ -427,6 +511,40 @@ describe("PriceFeedService", () => { }); }); + describe("verifyBinanceReachability", () => { + it("should log an info line when Binance is reachable", async () => { + const instance = PriceFeedService.getInstance(); + fetchMock = mock(async () => mockBinanceResponse(5.2)); + global.fetch = fetchMock as unknown as typeof fetch; + + await instance.verifyBinanceReachability(); + + expect(fetchMock).toHaveBeenCalledWith("https://api.binance.com/api/v3/ticker/price?symbol=USDTBRL", expect.anything()); + expect(loggerMock.info).toHaveBeenCalledWith(expect.stringContaining("Binance price feed reachable: USD-BRL spot rate 5.2")); + expect(loggerMock.error).not.toHaveBeenCalled(); + }); + + it("should log an error line when Binance is unreachable", async () => { + const instance = PriceFeedService.getInstance(); + fetchMock = mock(async () => new Response("blocked", { status: 451 })); + global.fetch = fetchMock as unknown as typeof fetch; + + await instance.verifyBinanceReachability(); + + expect(loggerMock.error).toHaveBeenCalledWith(expect.stringContaining("Binance price feed UNREACHABLE for USD-BRL")); + }); + + it("should not throw when Binance is unreachable", async () => { + const instance = PriceFeedService.getInstance(); + fetchMock = mock(async () => { + throw new Error("network down"); + }); + global.fetch = fetchMock as unknown as typeof fetch; + + expect(instance.verifyBinanceReachability()).resolves.toBeUndefined(); + }); + }); + describe("convertCurrency", () => { it("should return the same amount when currencies match", async () => { const result = await PriceFeedService.getInstance().convertCurrency("100", BRL, BRL); @@ -551,6 +669,12 @@ describe("PriceFeedService", () => { expect(Reflect.get(instance, "fastforexApiKey")).toBe(config.priceProviders.fastforex.apiKey); }); + it("should read Binance config from config/vars", () => { + Reflect.set(PriceFeedService, "instance", undefined); + const instance = PriceFeedService.getInstance(); + expect(Reflect.get(instance, "binanceApiBaseUrl")).toBe(config.priceProviders.binance.baseUrl); + }); + it("should read CoinGecko config from config/vars", () => { Reflect.set(PriceFeedService, "instance", undefined); const instance = PriceFeedService.getInstance(); diff --git a/apps/api/src/api/services/priceFeed.service.ts b/apps/api/src/api/services/priceFeed.service.ts index 1bd2a1f2b..fd8bb9e9d 100644 --- a/apps/api/src/api/services/priceFeed.service.ts +++ b/apps/api/src/api/services/priceFeed.service.ts @@ -10,7 +10,7 @@ interface CacheEntry { expiresAt: number; } -const FASTFOREX_SANITY_SPREAD_LIMITS: Record = { +const FIAT_SANITY_SPREAD_LIMITS: Record = { ARS: 0.25, BRL: 0.02, COP: 0.03, @@ -18,6 +18,13 @@ const FASTFOREX_SANITY_SPREAD_LIMITS: Record = { MXN: 0.03 }; +// Binance spot symbols quoted against USDT (treated as USD) and priced in fiat. +// Only currencies with a liquid Binance USDT market are listed; any fiat not +// present here skips Binance and falls straight through to fastforex. +const BINANCE_USDT_FIAT_SYMBOLS: Record = { + BRL: "USDTBRL" +}; + /** * PriceFeedService * @@ -37,6 +44,8 @@ export class PriceFeedService { private fastforexApiBaseUrl: string; + private binanceApiBaseUrl: string; + // Cache configuration private cryptoCacheTtlMs: number; @@ -57,6 +66,8 @@ export class PriceFeedService { this.fastforexApiKey = config.priceProviders.fastforex.apiKey; this.fastforexApiBaseUrl = config.priceProviders.fastforex.baseUrl; + this.binanceApiBaseUrl = config.priceProviders.binance.baseUrl; + this.cryptoCacheTtlMs = config.priceProviders.coingecko.cryptoCacheTtlMs; this.fiatCacheTtlMs = config.priceProviders.coingecko.fiatCacheTtlMs; @@ -70,6 +81,7 @@ export class PriceFeedService { logger.info(`PriceFeedService initialized with CoinGecko API URL: ${this.coingeckoApiBaseUrl}`); logger.info(`PriceFeedService initialized with fastforex API URL: ${this.fastforexApiBaseUrl}`); + logger.info(`PriceFeedService initialized with Binance API URL: ${this.binanceApiBaseUrl}`); logger.info(`Cache TTLs configured - Crypto: ${this.cryptoCacheTtlMs}ms, Fiat: ${this.fiatCacheTtlMs}ms`); } @@ -178,6 +190,10 @@ export class PriceFeedService { /** * Get the exchange rate from USD to another fiat currency. The source currency is always USD. * + * Provider priority: Binance USDT spot (for currencies with a liquid market, e.g. BRL) is + * tried first, then fastforex, then CoinGecko. The Binance USDT/fiat price reflects the crypto + * spot rate local users transact at, which can diverge from the official fiat FX mid-market rate. + * * @param toCurrency - The target currency code (e.g., 'BRL', 'ARS') * @returns The exchange rate (how much of toCurrency equals 1 unit of fromCurrency) */ @@ -202,12 +218,27 @@ export class PriceFeedService { return cachedEntry.value; } + if (BINANCE_USDT_FIAT_SYMBOLS[targetCurrency]) { + logger.debug(`Cache miss for ${cacheKey}. Fetching from Binance spot.`); + + try { + const rate = await this.getBinanceUsdtToFiatRate(targetCurrency); + await this.assertRateWithinSanityBand("Binance", targetCurrency, rate); + this.fiatExchangeRateCache.set(cacheKey, { expiresAt: now + this.fiatCacheTtlMs, value: rate }); + return rate; + } catch (binanceError) { + logger.warn( + `Binance failed for ${fromCurrency}-${targetCurrency}, falling back to fastforex: ${binanceError instanceof Error ? binanceError.message : binanceError}` + ); + } + } + if (this.fastforexApiKey) { logger.debug(`Cache miss for ${cacheKey}. Fetching from fastforex.`); try { const rate = await this.getFastforexRate(fromCurrency, targetCurrency); - await this.assertFastforexRateWithinSanityBand(targetCurrency, rate); + await this.assertRateWithinSanityBand("fastforex", targetCurrency, rate); this.fiatExchangeRateCache.set(cacheKey, { expiresAt: now + this.fiatCacheTtlMs, value: rate }); return rate; } catch (ffError) { @@ -395,8 +426,62 @@ export class PriceFeedService { return rate; } - private async assertFastforexRateWithinSanityBand(targetCurrency: RampCurrency, fastforexRate: number): Promise { - this.assertValidFiatRate("fastforex", "USD", targetCurrency, fastforexRate); + /** + * Probe every mapped Binance market at startup so an unreachable or geo-blocked + * endpoint (e.g. HTTP 451) surfaces loudly in the logs instead of silently + * degrading to the fastforex/CoinGecko fallback. Never throws — this is a + * diagnostic only, and Binance failures are already handled at request time. + */ + public async verifyBinanceReachability(): Promise { + for (const [currency, symbol] of Object.entries(BINANCE_USDT_FIAT_SYMBOLS)) { + try { + const rate = await this.getBinanceUsdtToFiatRate(currency as RampCurrency); + logger.info(`Binance price feed reachable: USD-${currency} spot rate ${rate} (${symbol})`); + } catch (error) { + logger.error( + `Binance price feed UNREACHABLE for USD-${currency} (${symbol}); USD-${currency} rates will silently fall back to fastforex/CoinGecko. If the egress IP is geo-blocked Binance returns HTTP 451. Error: ${ + error instanceof Error ? error.message : error + }` + ); + } + } + } + + private async getBinanceUsdtToFiatRate(targetCurrency: RampCurrency): Promise { + const symbol = BINANCE_USDT_FIAT_SYMBOLS[targetCurrency]; + if (!symbol) { + throw new Error(`No Binance USDT symbol mapping for ${targetCurrency}`); + } + + const normalizedBaseUrl = this.binanceApiBaseUrl.endsWith("/") ? this.binanceApiBaseUrl : `${this.binanceApiBaseUrl}/`; + const url = new URL("api/v3/ticker/price", normalizedBaseUrl); + url.searchParams.append("symbol", symbol); + + const response = await fetchWithTimeout(url.toString(), { headers: new Headers({ Accept: "application/json" }) }); + + if (!response.ok) { + const errorText = await response.text(); + throw new Error(`Binance API error (${response.status}): ${errorText}`); + } + + const data = (await response.json()) as { symbol: string; price: string }; + + if (data.symbol !== symbol) { + throw new Error(`Binance returned unexpected symbol for ${symbol}: ${data.symbol}`); + } + + const rate = Number(data.price); + + if (!Number.isFinite(rate) || rate <= 0) { + throw new Error(`Binance returned invalid rate for ${symbol}: ${data.price}`); + } + + logger.debug(`Binance spot rate ${symbol}: ${rate}`); + return rate; + } + + private async assertRateWithinSanityBand(provider: string, targetCurrency: RampCurrency, rate: number): Promise { + this.assertValidFiatRate(provider, "USD", targetCurrency, rate); let referenceRate: number; try { @@ -404,19 +489,19 @@ export class PriceFeedService { this.assertValidFiatRate("CoinGecko", "USD", targetCurrency, referenceRate); } catch (error) { logger.warn( - `Unable to sanity-check fastforex USD-${targetCurrency} rate against CoinGecko; accepting fastforex rate: ${ + `Unable to sanity-check ${provider} USD-${targetCurrency} rate against CoinGecko; accepting ${provider} rate: ${ error instanceof Error ? error.message : error }` ); return; } - const spread = Big(fastforexRate).minus(referenceRate).abs().div(referenceRate).toNumber(); - const limit = FASTFOREX_SANITY_SPREAD_LIMITS[targetCurrency] ?? 0.03; + const spread = Big(rate).minus(referenceRate).abs().div(referenceRate).toNumber(); + const limit = FIAT_SANITY_SPREAD_LIMITS[targetCurrency] ?? 0.03; if (spread > limit) { throw new Error( - `fastforex USD-${targetCurrency} rate ${fastforexRate} differs from CoinGecko reference ${referenceRate} by ${( + `${provider} USD-${targetCurrency} rate ${rate} differs from CoinGecko reference ${referenceRate} by ${( spread * 100 ).toFixed(2)}%, above ${(limit * 100).toFixed(2)}% limit` ); diff --git a/apps/api/src/config/vars.ts b/apps/api/src/config/vars.ts index e36e10d96..6512010f2 100644 --- a/apps/api/src/config/vars.ts +++ b/apps/api/src/config/vars.ts @@ -124,6 +124,7 @@ interface Config { }; priceProviders: { alchemyPay: PriceProvider; + binance: PriceProvider; transak: PriceProvider; moonpay: PriceProvider; coingecko: { @@ -231,6 +232,9 @@ export const config: Config = { baseUrl: process.env.ALCHEMYPAY_PROD_URL || "https://openapi.alchemypay.org", secretKey: process.env.ALCHEMYPAY_SECRET_KEY }, + binance: { + baseUrl: process.env.BINANCE_API_URL || "https://api.binance.com" + }, coingecko: { apiKey: process.env.COINGECKO_API_KEY, baseUrl: process.env.COINGECKO_API_URL || "https://pro-api.coingecko.com/api/v3", diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index 7936ef671..38a89a788 100755 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -11,6 +11,7 @@ import { runMigrations } from "./database/migrator"; import "./models"; // Initialize models import { AlfredpayLimitsService } from "./api/services/alfredpay/alfredpay-limits.service"; import registerPhaseHandlers from "./api/services/phases/register-handlers"; +import { priceFeedService } from "./api/services/priceFeed.service"; import ApiClientEventsRetentionWorker from "./api/workers/api-client-events-retention.worker"; import CleanupWorker from "./api/workers/cleanup.worker"; import RampRecoveryWorker from "./api/workers/ramp-recovery.worker"; @@ -73,6 +74,12 @@ const initializeApp = async () => { // Register phase handlers registerPhaseHandlers(); + // Probe the Binance price feed so a geo-block (HTTP 451) or outage surfaces + // loudly in the logs instead of silently degrading to the fiat fallback. + // Fire-and-forget: a blocked/hanging call must not delay startup, and the + // probe never throws while request-time pricing already fails over safely. + void priceFeedService.verifyBinanceReachability(); + // Start the server app.listen(port, () => logger.info(`server started on port ${port} (${env})`)); } catch (error) { diff --git a/docs/security-spec/05-integrations/binance.md b/docs/security-spec/05-integrations/binance.md new file mode 100644 index 000000000..d7fc40b03 --- /dev/null +++ b/docs/security-spec/05-integrations/binance.md @@ -0,0 +1,45 @@ +# Binance Integration + +## What This Does + +Binance's public spot ticker is the **primary** USD-to-fiat rate source in `PriceFeedService` for fiat currencies that have a liquid Binance USDT market. Currently only BRL is mapped, via the `USDTBRL` symbol. The USDT price is treated as USD (consistent with the platform's USD-like stablecoin 1:1 model), so `USDTBRL` yields the USD→BRL rate. This rate reflects the crypto spot price Brazilian users actually transact at, which can diverge from the official fiat FX mid-market rate returned by FastForex. + +**Provider type:** Price provider (crypto spot exchange) +**Fiat currencies:** BRL (only currency with a mapped Binance symbol) +**Chains involved:** None directly — the rate feeds quote/conversion math before on-chain transactions are built or subsidy caps are evaluated. +**Phase handlers:** No phase handler calls Binance directly; handlers call `PriceFeedService.convertCurrency()` when they need USD-denominated caps or conversions. +**API auth method:** None — the public `GET {BINANCE_API_URL}/api/v3/ticker/price?symbol=` endpoint requires no key. + +Provider priority for `getUsdToFiatExchangeRate()` is Binance USDT spot (mapped currencies) → FastForex → CoinGecko. The response is accepted only when `price` parses to a positive finite number. The Binance rate is sanity-checked against CoinGecko's USDC-to-fiat reference with the same per-currency spread band used for FastForex. If Binance is unavailable, invalid, or outside the sanity band, Vortex logs a warning and falls back to FastForex, then CoinGecko. If no valid provider remains, the conversion fails closed. + +## Security Invariants + +1. **Binance endpoint configuration MUST be treated as integrity-sensitive** — `BINANCE_API_URL` is not a secret, but a malicious or mistaken value can redirect quote-time rate lookups. Production deployments should pin it to the expected HTTPS Binance API origin. No API key is sent, so there is no Binance credential to leak. +2. **Binance MUST only be used where a symbol is explicitly mapped** — Only currencies in `BINANCE_USDT_FIAT_SYMBOLS` (currently `BRL: "USDTBRL"`) query Binance. Any other fiat skips Binance and uses FastForex, so an unmapped or illiquid market is never silently priced. +3. **Binance responses MUST be validated before use** — The response must be `2xx`, the returned `symbol` must match the requested symbol (guards against a misconfigured `BINANCE_API_URL` or upstream proxy returning a different, valid-but-wrong market), and `Number(price)` must be finite and greater than zero; otherwise the rate is rejected and the next provider is tried. +4. **Binance rates MUST be sanity-checked against an independent reference when available** — The rate is compared with CoinGecko's USDC-to-fiat reference via `assertRateWithinSanityBand("Binance", ...)` and rejected when the spread exceeds the configured per-currency limit (`FIAT_SANITY_SPREAD_LIMITS`, 2% for BRL). CoinGecko outages or invalid reference rates must not make CoinGecko a hard dependency for an otherwise valid Binance rate. +5. **Provider failures MUST fail over safely** — Binance HTTP errors, invalid responses, or sanity-band failures fall through to FastForex and then CoinGecko. If no valid provider remains, the quote/conversion path must fail closed rather than returning the original amount or proceeding with an invalid rate. +6. **Binance rates MUST be cached only briefly** — Accepted rates share the fiat cache keyed by `USD:` and expire after `FIAT_CACHE_TTL_MS`; stale entries must not be used past their expiry. +7. **Binance MUST NOT be treated as an executable settlement authority** — It only informs pricing math. Actual swap outputs still come from Nabla or Squid, and stored quote amounts remain immutable once created. + +## Threat Vectors & Mitigations + +| Threat | Attack Scenario | Mitigation | +|---|---|---| +| **Endpoint tampering** | `BINANCE_API_URL` is pointed at an attacker-controlled endpoint returning favorable rates. | Treat URL config as integrity-sensitive production configuration; when CoinGecko is available, the response must pass sanity-band validation before use, and out-of-band values fall back to FastForex. | +| **Manipulated / thin-market spot price** | The `USDTBRL` book is briefly manipulated or the ticker returns an anomalous price that would distort quote output, fee conversion, or subsidy cap checks. | Rate must be positive and, when CoinGecko is available, within the configured spread limit; out-of-band values fall back to FastForex then CoinGecko. | +| **USDC depeg distorts reference** | CoinGecko's `usd-coin` fiat price diverges from true USD, causing a healthy Binance rate to fail the sanity band or vice versa. | Treat the reference as an emergency USD proxy; monitor spread warnings and missing-sanity-check warnings. | +| **Binance outage** | Binance is down or returns non-2xx responses during quote creation. | Vortex logs a warning and falls back to FastForex, then CoinGecko. If all providers fail, quote/conversion fails closed. | +| **Stale rates** | A cached rate persists long enough to misprice a volatile corridor. | Fiat cache uses `FIAT_CACHE_TTL_MS`; expired entries trigger a fresh provider lookup. | + +## Audit Checklist + +- [x] Binance URL is configurable but defaults to HTTPS. **PASS** — `BINANCE_API_URL` defaults to `https://api.binance.com`. +- [x] No credential is sent to Binance. **PASS** — `getBinanceUsdtToFiatRate()` sends only an `Accept` header on a public endpoint. +- [x] Only mapped symbols query Binance. **PASS** — `getUsdToFiatExchangeRate()` calls Binance only when `BINANCE_USDT_FIAT_SYMBOLS[targetCurrency]` is set; `getBinanceUsdtToFiatRate()` throws for unmapped currencies. +- [x] Binance response status, symbol, and price are validated. **PASS** — non-OK responses throw; a returned `symbol` that differs from the requested one throws; a non-finite or non-positive `price` throws. +- [x] Binance rates are sanity-checked against CoinGecko when the reference is available. **PASS** — `assertRateWithinSanityBand("Binance", ...)` compares the spread with per-currency limits; otherwise it warns and accepts the valid Binance rate. +- [x] Binance failures fall back to FastForex then CoinGecko. **PASS** — failures are caught and logged before the FastForex/CoinGecko path runs. +- [x] All-provider failure fails closed. **PASS** — `convertCurrency()` rethrows provider failures instead of returning the original amount. +- [x] Accepted rates use the configured short cache TTL. **PASS** — `fiatExchangeRateCache` entries expire after `FIAT_CACHE_TTL_MS`. +- [x] A geo-block or outage is observable, not silent. **PASS** — `verifyBinanceReachability()` runs at startup (`index.ts`) and logs an error for each unreachable mapped market, so a silent fallback to the fiat rate can be detected operationally. The probe is fire-and-forget so a blocked/hanging call cannot delay server startup. diff --git a/docs/security-spec/05-integrations/fastforex.md b/docs/security-spec/05-integrations/fastforex.md index a075bb50b..ac45d1207 100644 --- a/docs/security-spec/05-integrations/fastforex.md +++ b/docs/security-spec/05-integrations/fastforex.md @@ -2,7 +2,7 @@ ## What This Does -FastForex is the primary fiat exchange-rate provider used by `PriceFeedService` for USD-to-fiat conversion in quote, fee, and subsidy math. Vortex calls FastForex for a single forex pair at a time and validates the returned rate before it can affect a quote or conversion. +FastForex is a fiat exchange-rate provider used by `PriceFeedService` for USD-to-fiat conversion in quote, fee, and subsidy math. It is the primary source for fiat currencies without a liquid Binance USDT market, and the secondary source (after Binance) for currencies that have one — currently BRL (see `05-integrations/binance.md`). Vortex calls FastForex for a single forex pair at a time and validates the returned rate before it can affect a quote or conversion. **Provider type:** Price provider **Fiat currencies:** ARS, BRL, COP, EUR, MXN, USD @@ -10,7 +10,7 @@ FastForex is the primary fiat exchange-rate provider used by `PriceFeedService` **Phase handlers:** No phase handler calls FastForex directly; handlers call `PriceFeedService.convertCurrency()` when they need USD-denominated caps or conversions. **API auth method:** `X-API-Key` header from `FASTFOREX_API_KEY`. -The API request shape is `GET {FASTFOREX_API_URL}/fetch-one?from=USD&to=`. The response is accepted only when `result[]` exists and is a positive finite rate. FastForex rates are sanity-checked against CoinGecko's USDC-to-fiat price when that reference is available. If FastForex is unavailable, missing, invalid, or outside the configured per-currency sanity band, Vortex falls back to CoinGecko. If FastForex returns a valid rate but CoinGecko is unavailable or invalid, Vortex logs the missing sanity check and accepts FastForex rather than making the fallback provider a hard dependency. If no valid provider remains, the conversion fails closed. +The full provider priority for `getUsdToFiatExchangeRate()` is Binance USDT spot (for currencies with a mapped symbol, currently BRL) → FastForex → CoinGecko. The API request shape is `GET {FASTFOREX_API_URL}/fetch-one?from=USD&to=`. The response is accepted only when `result[]` exists and is a positive finite rate. FastForex rates are sanity-checked against CoinGecko's USDC-to-fiat price when that reference is available. If FastForex is unavailable, missing, invalid, or outside the configured per-currency sanity band, Vortex falls back to CoinGecko. If FastForex returns a valid rate but CoinGecko is unavailable or invalid, Vortex logs the missing sanity check and accepts FastForex rather than making the fallback provider a hard dependency. If no valid provider remains, the conversion fails closed. ## Security Invariants @@ -44,7 +44,7 @@ The API request shape is `GET {FASTFOREX_API_URL}/fetch-one?from=USD&to=`. - [x] Non-fiat targets are rejected before fetching. **PASS** — `getUsdToFiatExchangeRate()` checks `isFiatToken(targetCurrency)`. - [x] USD target returns `1` without calling external providers. **PASS** — `getUsdToFiatExchangeRate("USD")` short-circuits. - [x] FastForex response status and rate are validated. **PASS** — non-OK responses throw; missing, zero, or negative rates throw. -- [x] FastForex rates are sanity-checked against CoinGecko when the reference is available. **PASS** — `assertFastforexRateWithinSanityBand()` compares the spread with per-currency limits when CoinGecko returns a valid reference; otherwise it warns and accepts the valid FastForex rate. +- [x] FastForex rates are sanity-checked against CoinGecko when the reference is available. **PASS** — `assertRateWithinSanityBand("fastforex", ...)` compares the spread with per-currency limits when CoinGecko returns a valid reference; otherwise it warns and accepts the valid FastForex rate. The same helper guards Binance rates. - [x] FastForex failures fall back to CoinGecko. **PASS** — failures are caught and logged before requesting the CoinGecko fallback. - [x] CoinGecko fallback/reference uses USDC as the USD proxy. **PASS / OPERATIONAL RISK** — accepted by current code, but operators should monitor depeg conditions because this is not a pure fiat FX reference. - [x] Both-provider failure fails closed. **PASS** — `convertCurrency()` rethrows provider failures instead of returning the original amount. diff --git a/docs/security-spec/07-operations/api-surface.md b/docs/security-spec/07-operations/api-surface.md index 73b0593ae..d5ad22167 100644 --- a/docs/security-spec/07-operations/api-surface.md +++ b/docs/security-spec/07-operations/api-surface.md @@ -21,7 +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. +- 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. This context is embedded in the error log message itself (`formatProviderContext`) because the app logger (`config/logger.ts`) formats only `{ timestamp, level, message, label }` and drops metadata objects. 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/README.md b/docs/security-spec/README.md index e3c11c711..659ca8730 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 | +| Binance | `05-integrations/binance.md` | Binance USDT spot price used as the primary USD<>BRL rate source | | 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 | @@ -73,7 +74,8 @@ 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 | +| **Binance** | Crypto exchange whose USDT/fiat spot ticker is the primary USD-to-fiat rate source for currencies with a liquid market (currently BRL via `USDTBRL`) | +| **FastForex** | Fiat exchange-rate provider used as the USD-to-fiat rate source for currencies without a Binance market, and the fallback after Binance for those that have one | | **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 |