From a62b8c3009f6ea148e9fb0166a6a34d8bafafc06 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Mon, 7 Sep 2026 17:21:50 +0200 Subject: [PATCH 01/13] feat(api): list routed evm tokens in supported cryptocurrencies The endpoint only read the static token config, so tokens the quote engine accepts via the dynamic Squid Router list (e.g. PAXG) were never advertised. Read the merged dynamic config instead, dedupe the alias keys, and expose rampTypes so integrators can see that routed tokens are BUY-only. The network filter is now required to keep responses per-network. --- .../config/cryptocurrencies.config.test.ts | 70 ++++++++++++ .../api/src/config/cryptocurrencies.config.ts | 101 ++++++++---------- .../src/tests/http-surface.invariants.test.ts | 8 +- .../supported-cryptocurrencies.endpoints.ts | 5 +- 4 files changed, 127 insertions(+), 57 deletions(-) create mode 100644 apps/api/src/config/cryptocurrencies.config.test.ts diff --git a/apps/api/src/config/cryptocurrencies.config.test.ts b/apps/api/src/config/cryptocurrencies.config.test.ts new file mode 100644 index 0000000000..1e8bff5b5b --- /dev/null +++ b/apps/api/src/config/cryptocurrencies.config.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, it } from "bun:test"; +import { EvmNetworks, EvmToken, EvmTokenDetails, evmTokenConfig, Networks, RampDirection, TokenType } from "@vortexfi/shared"; +import { APIError } from "../api/errors/api-error"; +import { getSupportedCryptocurrencies } from "./cryptocurrencies.config"; + +const staticUsdc = evmTokenConfig[Networks.Ethereum][EvmToken.USDC] as EvmTokenDetails; +const staticAxlUsdc = evmTokenConfig[Networks.Ethereum][EvmToken.AXLUSDC] as EvmTokenDetails; + +const routedPaxg: EvmTokenDetails = { + assetSymbol: "PAXG", + decimals: 18, + erc20AddressSourceChain: "0x45804880de22913dafe09f4980848ece6ecbaf78", + isNative: false, + network: Networks.Ethereum, + pendulumRepresentative: staticUsdc.pendulumRepresentative, + type: TokenType.Evm +}; + +// Mirrors the shape produced by mergeWithStaticConfig: static tokens appear under enum key and symbol alias. +const mergedConfig = { + [Networks.Ethereum]: { + [EvmToken.AXLUSDC]: { ...staticAxlUsdc, isFromStaticConfig: true }, + [EvmToken.USDC]: { ...staticUsdc, isFromStaticConfig: true }, + PAXG: routedPaxg, + "USDC.AXL": { ...staticAxlUsdc, isFromStaticConfig: true } + } +} as unknown as Record>>; + +describe("getSupportedCryptocurrencies", () => { + it("lists routed tokens as BUY-only and static tokens as BUY and SELL", () => { + const result = getSupportedCryptocurrencies(Networks.Ethereum, mergedConfig); + const bySymbol = Object.fromEntries(result.map(token => [token.assetSymbol, token])); + + expect(bySymbol.PAXG).toEqual({ + assetContractAddress: routedPaxg.erc20AddressSourceChain, + assetDecimals: 18, + assetNetwork: Networks.Ethereum, + assetSymbol: "PAXG", + rampTypes: [RampDirection.BUY] + }); + expect(bySymbol.USDC.rampTypes).toEqual([RampDirection.BUY, RampDirection.SELL]); + }); + + it("dedupes static tokens stored under enum key and symbol alias", () => { + const result = getSupportedCryptocurrencies(Networks.Ethereum, mergedConfig); + expect(result.map(token => token.assetSymbol).sort()).toEqual(["PAXG", "USDC", staticAxlUsdc.assetSymbol].sort()); + }); + + it("falls back to the static config before the dynamic token list is loaded", () => { + const result = getSupportedCryptocurrencies(Networks.Ethereum); + expect(result.map(token => token.assetSymbol).sort()).toEqual( + Object.values(evmTokenConfig[Networks.Ethereum]) + .map(token => token.assetSymbol) + .sort() + ); + expect(result.every(token => token.rampTypes.includes(RampDirection.SELL))).toBe(true); + }); + + it("marks AssetHub USDC as rampable and other AssetHub tokens as not", () => { + const result = getSupportedCryptocurrencies(Networks.AssetHub); + const bySymbol = Object.fromEntries(result.map(token => [token.assetSymbol, token.rampTypes])); + expect(bySymbol.USDC).toEqual([RampDirection.BUY, RampDirection.SELL]); + expect(bySymbol.DOT).toEqual([]); + }); + + it("rejects a missing or unsupported network", () => { + expect(() => getSupportedCryptocurrencies(undefined)).toThrow(APIError); + expect(() => getSupportedCryptocurrencies(Networks.Pendulum)).toThrow(APIError); + }); +}); diff --git a/apps/api/src/config/cryptocurrencies.config.ts b/apps/api/src/config/cryptocurrencies.config.ts index a1da1f31d5..1abff20ee2 100644 --- a/apps/api/src/config/cryptocurrencies.config.ts +++ b/apps/api/src/config/cryptocurrencies.config.ts @@ -2,11 +2,13 @@ import { AssetHubToken, assetHubTokenConfig, EvmNetworks, - EvmToken, + EvmTokenDetails, evmTokenConfig, + getEvmTokenConfig, isNetworkAssetHub, isNetworkEVM, Networks, + RampDirection, SupportedCryptocurrencyDetails } from "@vortexfi/shared"; import { APIError } from "../api/errors/api-error"; @@ -15,75 +17,64 @@ const supportedNetworks = Object.values(Networks) .filter(network => isNetworkEVM(network) || isNetworkAssetHub(network)) .join("', '"); -const throwInvalidNetworkError = (network: string): never => { +const throwInvalidNetworkError = (network: string | undefined): never => { throw new APIError({ message: `Invalid network: '${network}'. Supported networks are: '${supportedNetworks}'` }); }; -const mapEvmTokenToDetails = (network: EvmNetworks, token: EvmToken): SupportedCryptocurrencyDetails => { - const details = evmTokenConfig[network][token]; - if (!details) { - throw new APIError({ - message: `Token '${token}' is not supported on network '${network}'.` +const getEvmNetworkTokens = ( + network: EvmNetworks, + tokensByNetwork: Record>> +): SupportedCryptocurrencyDetails[] => { + // The offramp flow catalog only matches tokens from the static config; routed (Squid-discovered) tokens are BUY-only. + const sellableAddresses = new Set( + Object.values(evmTokenConfig[network] ?? {}).map(token => token.erc20AddressSourceChain.toLowerCase()) + ); + // The merged config stores static tokens under both their enum key and their symbol; dedupe by contract address. + const byAddress = new Map(); + for (const details of Object.values(tokensByNetwork[network] ?? {})) { + if (!details) continue; + const address = details.erc20AddressSourceChain.toLowerCase(); + if (byAddress.has(address)) continue; + byAddress.set(address, { + assetContractAddress: details.erc20AddressSourceChain, + assetDecimals: details.decimals, + assetNetwork: details.network, + assetSymbol: details.assetSymbol, + rampTypes: sellableAddresses.has(address) ? [RampDirection.BUY, RampDirection.SELL] : [RampDirection.BUY] }); } - - return { - assetContractAddress: details.erc20AddressSourceChain, - assetDecimals: details.decimals, - assetNetwork: details.network, - assetSymbol: details.assetSymbol - }; -}; - -const mapAssetHubTokenToDetails = (token: AssetHubToken): SupportedCryptocurrencyDetails => { - const details = assetHubTokenConfig[token]; - return { - assetDecimals: details.decimals, - assetForeignAssetId: details.foreignAssetId, - assetNetwork: details.network, - assetSymbol: details.assetSymbol - }; -}; - -const getEvmNetworkTokens = (network: Networks): SupportedCryptocurrencyDetails[] => { - if (isNetworkEVM(network)) { - const availableTokens = Object.keys(evmTokenConfig[network]) as EvmToken[]; - return availableTokens.map(token => mapEvmTokenToDetails(network, token)); - } else { - return throwInvalidNetworkError(network); - } -}; - -const getAssetHubTokens = (): SupportedCryptocurrencyDetails[] => { - return Object.values(AssetHubToken).map(mapAssetHubTokenToDetails); + return [...byAddress.values()]; }; -const getAllEvmNetworkTokens = (): SupportedCryptocurrencyDetails[] => - Object.values(Networks).filter(isNetworkEVM).flatMap(getEvmNetworkTokens); - -const getAllNetworkTokens = (): SupportedCryptocurrencyDetails[] => [...getAllEvmNetworkTokens(), ...getAssetHubTokens()]; +const getAssetHubTokens = (): SupportedCryptocurrencyDetails[] => + Object.values(AssetHubToken).map(token => { + const details = assetHubTokenConfig[token]; + return { + assetDecimals: details.decimals, + assetForeignAssetId: details.foreignAssetId, + assetNetwork: details.network, + assetSymbol: details.assetSymbol, + // Only AssetHub USDC has ramp flows (BRL on/offramp). + rampTypes: token === AssetHubToken.USDC ? [RampDirection.BUY, RampDirection.SELL] : [] + }; + }); /** - * Function to get supported cryptocurrencies with details based on network - * @param network Optional network filter - * @returns Array of enhanced token details + * Supported cryptocurrencies for a network, including routed EVM tokens discovered from Squid Router. + * @param network Network filter (required) + * @param tokensByNetwork EVM token config to read from; defaults to the live dynamic config */ -export function getSupportedCryptocurrencies(network?: Networks): SupportedCryptocurrencyDetails[] { +export function getSupportedCryptocurrencies( + network: Networks | undefined, + tokensByNetwork: Record>> = getEvmTokenConfig() +): SupportedCryptocurrencyDetails[] { if (network && isNetworkEVM(network)) { - return getEvmNetworkTokens(network); + return getEvmNetworkTokens(network as EvmNetworks, tokensByNetwork); } - if (network && isNetworkAssetHub(network)) { return getAssetHubTokens(); } - - if (!network) { - return getAllNetworkTokens(); - } - - throwInvalidNetworkError(network); - - return []; + return throwInvalidNetworkError(network); } diff --git a/apps/api/src/tests/http-surface.invariants.test.ts b/apps/api/src/tests/http-surface.invariants.test.ts index 397b41774c..72c007ade6 100644 --- a/apps/api/src/tests/http-surface.invariants.test.ts +++ b/apps/api/src/tests/http-surface.invariants.test.ts @@ -351,8 +351,14 @@ describe("HTTP surface: auth flow, webhooks, history, public routes", () => { // (FiatToken.EURC's wire value is "EUR".) expect(currencies.map(currency => currency.symbol).sort()).toEqual(["ARS", "BRL", "COP", "EUR", "MXN", "USD"]); - const crypto = await requestJson("/v1/supported-cryptocurrencies"); + const crypto = await requestJson("/v1/supported-cryptocurrencies?network=ethereum"); expect(crypto.status).toBe(200); + const cryptocurrencies = crypto.body.cryptocurrencies as Array<{ assetSymbol: string; rampTypes: string[] }>; + expect(cryptocurrencies.find(token => token.assetSymbol === "USDC")?.rampTypes).toEqual(["BUY", "SELL"]); + + // The network filter is required: routed token lists are per-network. + const cryptoUnfiltered = await requestJson("/v1/supported-cryptocurrencies"); + expect(cryptoUnfiltered.status).toBe(400); const countries = await requestJson("/v1/supported-countries"); expect(countries.status).toBe(200); diff --git a/packages/shared/src/endpoints/supported-cryptocurrencies.endpoints.ts b/packages/shared/src/endpoints/supported-cryptocurrencies.endpoints.ts index 320e595742..7399ab0632 100644 --- a/packages/shared/src/endpoints/supported-cryptocurrencies.endpoints.ts +++ b/packages/shared/src/endpoints/supported-cryptocurrencies.endpoints.ts @@ -1,5 +1,6 @@ import { Networks } from "../helpers"; import { OnChainToken } from "../tokens/types/base"; +import { RampDirection } from "../types/rampDirection"; export type SupportedCryptocurrency = OnChainToken; @@ -17,10 +18,12 @@ export interface SupportedCryptocurrencyDetailsBase { assetSymbol: string; assetNetwork: Networks; assetDecimals: number; + /// Ramp directions the quote engine accepts for this token. Routed (dynamically discovered) EVM tokens are BUY-only. + rampTypes: RampDirection[]; } export interface GetSupportedCryptocurrenciesRequest { - network?: Networks; + network: Networks; } export interface GetSupportedCryptocurrenciesResponse { From 3623d4e6c95dd78c11960d38c493e5de522dced3 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Mon, 7 Sep 2026 17:21:51 +0200 Subject: [PATCH 02/13] docs(api): document routed tokens and ramp directions --- docs/api/openapi/vortex.openapi.d.ts | 23 +++++++++++++++---- docs/api/openapi/vortex.openapi.json | 34 ++++++++++++++++++++++++---- docs/api/wire-contract.snapshot.md | 9 +++++++- 3 files changed, 55 insertions(+), 11 deletions(-) diff --git a/docs/api/openapi/vortex.openapi.d.ts b/docs/api/openapi/vortex.openapi.d.ts index 2ef3faf89a..04bf7855a2 100644 --- a/docs/api/openapi/vortex.openapi.d.ts +++ b/docs/api/openapi/vortex.openapi.d.ts @@ -1663,16 +1663,16 @@ export interface paths { }; /** * Supported Cryptocurrencies - * @description Retrieve all supported cryptocurrencies, filtered by network. + * @description Retrieve the cryptocurrencies the quote engine accepts on a network. EVM networks include routed tokens discovered from Squid Router in addition to the static token set; use `rampTypes` to see whether a token can be bought, sold, or both. */ get: { parameters: { - query?: { + query: { /** - * @description Filter supported cryptocurrencies by network. Allowed values: `assethub`, `avalanche`, `base`, `bsc`, `ethereum`, `polygon` - * @example + * @description Network to list cryptocurrencies for (required). Allowed values: `arbitrum`, `assethub`, `avalanche`, `base`, `bsc`, `ethereum`, `moonbeam`, `polygon` + * @example ethereum */ - network?: string; + network: string; }; header?: never; path?: never; @@ -1694,10 +1694,23 @@ export interface paths { assetForeignAssetId?: string | null; assetNetwork: components["schemas"]["Networks"]; assetSymbol: string; + /** @description Ramp directions the quote engine accepts for this token. Routed EVM tokens are `BUY`-only; an empty list means the token is listed but not currently rampable. */ + rampTypes: components["schemas"]["RampDirection"][]; }[]; }; }; }; + /** @description Missing or unsupported `network`. */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + error: string; + }; + }; + }; }; }; put?: never; diff --git a/docs/api/openapi/vortex.openapi.json b/docs/api/openapi/vortex.openapi.json index cb34238b29..3aa674f411 100644 --- a/docs/api/openapi/vortex.openapi.json +++ b/docs/api/openapi/vortex.openapi.json @@ -10378,14 +10378,14 @@ "/v1/supported-cryptocurrencies": { "get": { "deprecated": false, - "description": "Retrieve all supported cryptocurrencies, filtered by network.", + "description": "Retrieve the cryptocurrencies the quote engine accepts on a network. EVM networks include routed tokens discovered from Squid Router in addition to the static token set; use `rampTypes` to see whether a token can be bought, sold, or both.", "parameters": [ { - "description": "Filter supported cryptocurrencies by network. Allowed values: `assethub`, `avalanche`, `base`, `bsc`, `ethereum`, `polygon`", - "example": "", + "description": "Network to list cryptocurrencies for (required). Allowed values: `arbitrum`, `assethub`, `avalanche`, `base`, `bsc`, `ethereum`, `moonbeam`, `polygon`", + "example": "ethereum", "in": "query", "name": "network", - "required": false, + "required": true, "schema": { "type": "string" } @@ -10416,9 +10416,16 @@ }, "assetSymbol": { "type": "string" + }, + "rampTypes": { + "description": "Ramp directions the quote engine accepts for this token. Routed EVM tokens are `BUY`-only; an empty list means the token is listed but not currently rampable.", + "items": { + "$ref": "#/components/schemas/RampDirection" + }, + "type": "array" } }, - "required": ["assetDecimals", "assetNetwork", "assetSymbol"], + "required": ["assetDecimals", "assetNetwork", "assetSymbol", "rampTypes"], "type": "object" }, "type": "array" @@ -10431,6 +10438,23 @@ }, "description": "", "headers": {} + }, + "400": { + "content": { + "application/json": { + "schema": { + "properties": { + "error": { + "type": "string" + } + }, + "required": ["error"], + "type": "object" + } + } + }, + "description": "Missing or unsupported `network`.", + "headers": {} } }, "security": [], diff --git a/docs/api/wire-contract.snapshot.md b/docs/api/wire-contract.snapshot.md index 670662d6d8..07ea7fa426 100644 --- a/docs/api/wire-contract.snapshot.md +++ b/docs/api/wire-contract.snapshot.md @@ -922,7 +922,7 @@ GetSupportedCountryResponse: { } GetSupportedCryptocurrenciesRequest: { - network?: Networks.Arbitrum | Networks.AssetHub | Networks.Avalanche | Networks.BSC | Networks.Base | Networks.BaseSepolia | Networks.Ethereum | Networks.Hydration | Networks.Moonbeam | Networks.Paseo | Networks.Pendulum | Networks.Polygon | Networks.PolygonAmoy; + network: enum Networks { Arbitrum = "arbitrum", AssetHub = "assethub", Avalanche = "avalanche", BSC = "bsc", Base = "base", BaseSepolia = "base-sepolia", Ethereum = "ethereum", Hydration = "hydration", Moonbeam = "moonbeam", Paseo = "paseo", Pendulum = "pendulum", Polygon = "polygon", PolygonAmoy = "polygonAmoy" }; } GetSupportedCryptocurrenciesResponse: { @@ -931,11 +931,13 @@ GetSupportedCryptocurrenciesResponse: { assetDecimals: number; assetNetwork: enum Networks { Arbitrum = "arbitrum", AssetHub = "assethub", Avalanche = "avalanche", BSC = "bsc", Base = "base", BaseSepolia = "base-sepolia", Ethereum = "ethereum", Hydration = "hydration", Moonbeam = "moonbeam", Paseo = "paseo", Pendulum = "pendulum", Polygon = "polygon", PolygonAmoy = "polygonAmoy" }; assetSymbol: string; + rampTypes: Array; } | { assetDecimals: number; assetForeignAssetId?: number; assetNetwork: enum Networks { Arbitrum = "arbitrum", AssetHub = "assethub", Avalanche = "avalanche", BSC = "bsc", Base = "base", BaseSepolia = "base-sepolia", Ethereum = "ethereum", Hydration = "hydration", Moonbeam = "moonbeam", Paseo = "paseo", Pendulum = "pendulum", Polygon = "polygon", PolygonAmoy = "polygonAmoy" }; assetSymbol: string; + rampTypes: Array; }>; } @@ -1964,6 +1966,7 @@ SupportedAssetHubCryptocurrencyDetails: { assetForeignAssetId?: number; assetNetwork: enum Networks { Arbitrum = "arbitrum", AssetHub = "assethub", Avalanche = "avalanche", BSC = "bsc", Base = "base", BaseSepolia = "base-sepolia", Ethereum = "ethereum", Hydration = "hydration", Moonbeam = "moonbeam", Paseo = "paseo", Pendulum = "pendulum", Polygon = "polygon", PolygonAmoy = "polygonAmoy" }; assetSymbol: string; + rampTypes: Array; } SupportedCountry: { @@ -1984,17 +1987,20 @@ SupportedCryptocurrencyDetails: { assetDecimals: number; assetNetwork: enum Networks { Arbitrum = "arbitrum", AssetHub = "assethub", Avalanche = "avalanche", BSC = "bsc", Base = "base", BaseSepolia = "base-sepolia", Ethereum = "ethereum", Hydration = "hydration", Moonbeam = "moonbeam", Paseo = "paseo", Pendulum = "pendulum", Polygon = "polygon", PolygonAmoy = "polygonAmoy" }; assetSymbol: string; + rampTypes: Array; } | { assetDecimals: number; assetForeignAssetId?: number; assetNetwork: enum Networks { Arbitrum = "arbitrum", AssetHub = "assethub", Avalanche = "avalanche", BSC = "bsc", Base = "base", BaseSepolia = "base-sepolia", Ethereum = "ethereum", Hydration = "hydration", Moonbeam = "moonbeam", Paseo = "paseo", Pendulum = "pendulum", Polygon = "polygon", PolygonAmoy = "polygonAmoy" }; assetSymbol: string; + rampTypes: Array; } SupportedCryptocurrencyDetailsBase: { assetDecimals: number; assetNetwork: enum Networks { Arbitrum = "arbitrum", AssetHub = "assethub", Avalanche = "avalanche", BSC = "bsc", Base = "base", BaseSepolia = "base-sepolia", Ethereum = "ethereum", Hydration = "hydration", Moonbeam = "moonbeam", Paseo = "paseo", Pendulum = "pendulum", Polygon = "polygon", PolygonAmoy = "polygonAmoy" }; assetSymbol: string; + rampTypes: Array; } SupportedEVMCryptocurrencyDetails: { @@ -2002,6 +2008,7 @@ SupportedEVMCryptocurrencyDetails: { assetDecimals: number; assetNetwork: enum Networks { Arbitrum = "arbitrum", AssetHub = "assethub", Avalanche = "avalanche", BSC = "bsc", Base = "base", BaseSepolia = "base-sepolia", Ethereum = "ethereum", Hydration = "hydration", Moonbeam = "moonbeam", Paseo = "paseo", Pendulum = "pendulum", Polygon = "polygon", PolygonAmoy = "polygonAmoy" }; assetSymbol: string; + rampTypes: Array; } SupportedFiatCurrency: { From 8a1f1a85744bdaf24de14e69bd1c94891e981bad Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Tue, 8 Sep 2026 10:11:48 +0200 Subject: [PATCH 03/13] fix(api): price native-source squid routes by router fee only Squid sends a native source token (ETH, POL) as msg.value, so the route's transaction value carries the swapped principal on top of the router fee. Pricing the whole value as network fee exceeded the bridged amount, the fee distribution zeroed the swap input, and every native-token SELL quote failed with "Input amount for swap must be greater than 0". Subtract the principal for native sources so only the router fee is charged. --- .../phases/blocks/core/squidrouter.ts | 14 ++++++-- .../brl-offramp-crosschain.scenario.test.ts | 32 +++++++++++++++++++ .../03-ramp-engine/fee-integrity.md | 4 ++- 3 files changed, 46 insertions(+), 4 deletions(-) diff --git a/apps/api/src/api/services/phases/blocks/core/squidrouter.ts b/apps/api/src/api/services/phases/blocks/core/squidrouter.ts index 4c8fdd731d..dd3cfbb803 100644 --- a/apps/api/src/api/services/phases/blocks/core/squidrouter.ts +++ b/apps/api/src/api/services/phases/blocks/core/squidrouter.ts @@ -6,6 +6,7 @@ import { getOnChainTokenDetails, getRoute, isEvmTokenDetails, + NATIVE_TOKEN_ADDRESS, Networks, OnChainToken, parseContractBalanceResponse, @@ -113,9 +114,16 @@ function getNativeTokenCoingeckoId(network: Networks): string { async function calculateSquidrouterNetworkFee( route: SquidrouterRoute | SquidrouterCachedRoute, - fromNetwork: Networks + fromNetwork: Networks, + routeParams: RouteParams ): Promise { - const squidRouterSwapValue = multiplyByPowerOfTen(Big(route.transactionRequest.value), -18); + // A native source token (ETH, POL, ...) is sent as msg.value, so the route's value carries the + // swapped principal on top of the router fee. Only the fee part is a network cost. + const isNativeSource = routeParams.fromToken.toLowerCase() === NATIVE_TOKEN_ADDRESS.toLowerCase(); + const nativeFeeWei = isNativeSource + ? Big(route.transactionRequest.value).minus(routeParams.fromAmount) + : Big(route.transactionRequest.value); + const squidRouterSwapValue = multiplyByPowerOfTen(nativeFeeWei.lt(0) ? Big(0) : nativeFeeWei, -18); const nativeTokenId = getNativeTokenCoingeckoId(fromNetwork); try { @@ -198,7 +206,7 @@ async function getSquidrouterRouteData(routeParams: RouteParams, fromNetwork: Ne } catch { outputAmountUsd = null; } - const networkFeeUSD = await calculateSquidrouterNetworkFee(routeData.route, fromNetwork); + const networkFeeUSD = await calculateSquidrouterNetworkFee(routeData.route, fromNetwork, routeParams); return { fromToken: routeParams.fromToken, diff --git a/apps/api/src/tests/corridors/brl-offramp-crosschain.scenario.test.ts b/apps/api/src/tests/corridors/brl-offramp-crosschain.scenario.test.ts index 8a34e7c6e1..31b2f7baf2 100644 --- a/apps/api/src/tests/corridors/brl-offramp-crosschain.scenario.test.ts +++ b/apps/api/src/tests/corridors/brl-offramp-crosschain.scenario.test.ts @@ -479,4 +479,36 @@ describe("BRL offramp cross-chain corridor (USDC on Polygon → Base → pix via }, 30000 ); + + it("native ETH source: the quote prices only the router fee as network fee, not the swapped principal", async () => { + // Squid sends a native input as msg.value, so the route's value is the principal plus the + // router fee. Pricing the whole value as network fee zeroed the swap input (regression). + const routerFeeWei = 13_400_376_419_807n; + const { transactionValueWei, computeToAmount, computeToAmountUsd } = world.squidRouter; + world.squidRouter.transactionValueWei = (parseUnits("1", 18) + routerFeeWei).toString(); + world.squidRouter.computeToAmount = () => "2500000000"; // 2,500 USDC on Base + world.squidRouter.computeToAmountUsd = () => "2500"; + try { + const response = await app.request("/v1/quotes", { + body: JSON.stringify({ + from: Networks.Ethereum, + inputAmount: "1", + inputCurrency: EvmToken.ETH, + network: Networks.Ethereum, + outputCurrency: FiatToken.BRL, + rampType: RampDirection.SELL, + to: "pix" + }), + headers: { "Content-Type": "application/json" }, + method: "POST" + }); + expect(response.status).toBe(201); + const quote = (await response.json()) as { networkFeeUsd: string; outputAmount: string }; + // 13,400,376,419,807 wei at the FakePrices 2,500 USD/ETH feed. + expect(Number(quote.networkFeeUsd)).toBeCloseTo(0.0335, 3); + expect(Number(quote.outputAmount)).toBeGreaterThan(0); + } finally { + Object.assign(world.squidRouter, { computeToAmount, computeToAmountUsd, transactionValueWei }); + } + }); }); diff --git a/docs/security-spec/03-ramp-engine/fee-integrity.md b/docs/security-spec/03-ramp-engine/fee-integrity.md index f7ce48a0d4..a6ee343771 100644 --- a/docs/security-spec/03-ramp-engine/fee-integrity.md +++ b/docs/security-spec/03-ramp-engine/fee-integrity.md @@ -16,7 +16,9 @@ architecture and MUST NOT be used as evidence for current behavior. currency. A block that obtains a live provider or bridge price may replace only the component it owns: - Mykobo and Avenia fee blocks install their live provider fee; - - routed blocks install the Squid network fee; + - routed blocks install the Squid network fee, priced from the route's native `value`; + for a native source token (ETH, POL) the swapped principal is subtracted first, so + only the router fee is charged; - direct/no-bridge routes preserve a zero network fee. 3. Quote finalization persists the resulting snapshot in `quote_tickets.metadata.fees`. After quote creation, fee amounts are immutable. From a964a6582223a5a2507319f3f44c54686e014eef Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Tue, 8 Sep 2026 10:11:49 +0200 Subject: [PATCH 04/13] fix(api): match routed evm tokens in sell flow catalog The block-flow port gated every EVM SELL definition on the static token enum and config, while the legacy route resolver accepted any token the Squid-discovered catalog knew. Resolve SELL sources through the same merged token catalog BUY destinations use, restoring sells of routed tokens such as PAXG. --- .../__tests__/catalog-routed-offramp.test.ts | 63 +++++++++++++++++++ .../services/phases/blocks/flows/catalog.ts | 22 ++++--- .../03-ramp-engine/ramp-phase-flows.md | 4 +- 3 files changed, 77 insertions(+), 12 deletions(-) create mode 100644 apps/api/src/api/services/phases/blocks/__tests__/catalog-routed-offramp.test.ts diff --git a/apps/api/src/api/services/phases/blocks/__tests__/catalog-routed-offramp.test.ts b/apps/api/src/api/services/phases/blocks/__tests__/catalog-routed-offramp.test.ts new file mode 100644 index 0000000000..73f236bed7 --- /dev/null +++ b/apps/api/src/api/services/phases/blocks/__tests__/catalog-routed-offramp.test.ts @@ -0,0 +1,63 @@ +import { afterAll, describe, expect, it, mock } from "bun:test"; +import * as shared from "@vortexfi/shared"; +import { + EvmToken, + type EvmTokenDetails, + evmTokenConfig, + FiatToken, + mapFiatToDestination, + Networks, + RampDirection, + TokenType +} from "@vortexfi/shared"; +import { resolveBlockFlow } from "../flows/catalog"; + +// Snapshot before mock.module: bun mutates the imported namespace in place. +const sharedReal = { ...shared }; + +// A token that only exists in the Squid-discovered (dynamic) part of the token catalog. +const ROUTED_PAXG: EvmTokenDetails = { + assetSymbol: "PAXG", + decimals: 18, + erc20AddressSourceChain: "0x45804880de22913dafe09f4980848ece6ecbaf78", + isNative: false, + network: Networks.Ethereum, + pendulumRepresentative: (evmTokenConfig[Networks.Ethereum][EvmToken.USDC] as EvmTokenDetails).pendulumRepresentative, + type: TokenType.Evm +}; + +mock.module("@vortexfi/shared", () => ({ + ...sharedReal, + getOnChainTokenDetails: (network: Networks, token: string, ...rest: unknown[]) => + network === Networks.Ethereum && token === "PAXG" + ? ROUTED_PAXG + : (sharedReal.getOnChainTokenDetails as (...args: unknown[]) => unknown)(network, token, ...rest) +})); + +afterAll(() => { + mock.module("@vortexfi/shared", () => sharedReal); +}); + +function sellRequest(inputCurrency: string, outputCurrency: FiatToken) { + return { + from: Networks.Ethereum, + inputAmount: "1", + inputCurrency: inputCurrency as EvmToken, + network: Networks.Ethereum, + outputCurrency, + rampType: RampDirection.SELL, + to: mapFiatToDestination(outputCurrency) + }; +} + +describe("SELL flow catalog with routed (Squid-discovered) source tokens", () => { + it("maps a routed EVM source token to the BRL, EUR, and Alfredpay offramp flows", () => { + expect(resolveBlockFlow(sellRequest("PAXG", FiatToken.BRL)).name).toBe("BrlOfframpBase"); + expect(resolveBlockFlow(sellRequest("PAXG", FiatToken.EURC)).name).toBe("EurOfframpBase"); + expect(resolveBlockFlow(sellRequest("PAXG", FiatToken.USD)).name).toBe("AlfredpayOfframp"); + }); + + it("still rejects a source token unknown to the token catalog", () => { + expect(() => resolveBlockFlow(sellRequest("NOPE", FiatToken.BRL))).toThrow(/No block flow mapped/); + }); +}); diff --git a/apps/api/src/api/services/phases/blocks/flows/catalog.ts b/apps/api/src/api/services/phases/blocks/flows/catalog.ts index e42c6bd25b..14e71b63be 100644 --- a/apps/api/src/api/services/phases/blocks/flows/catalog.ts +++ b/apps/api/src/api/services/phases/blocks/flows/catalog.ts @@ -5,8 +5,8 @@ import { evmTokenConfig, FiatToken, getNetworkFromDestination, + getOnChainTokenDetails, isDomesticToken, - isEvmToken, isNetworkEVM, mapFiatToDestination, Networks, @@ -84,10 +84,10 @@ const flowDefinitions: FlowDefinition[] = [ { create(request) { const network = getNetworkFromDestination(request.from); - if (!network || !isNetworkEVM(network) || !isEvmToken(request.inputCurrency)) { + if (!network || !isNetworkEVM(network)) { throw new APIError({ message: "Unsupported EVM source for EUR offramp", status: httpStatus.BAD_REQUEST }); } - return makeEurOfframpBaseFlow(request.inputCurrency, network); + return makeEurOfframpBaseFlow(request.inputCurrency as EvmToken, network); }, executorFlow: eurOfframpBaseFlow, matches(request) { @@ -98,18 +98,19 @@ const flowDefinitions: FlowDefinition[] = [ request.to === EPaymentMethod.SEPA && network !== undefined && isNetworkEVM(network) && - isEvmToken(request.inputCurrency) && - evmTokenConfig[network][request.inputCurrency] !== undefined + // Source tokens come from the merged token catalog (static config plus Squid-discovered + // tokens), the same catalog BUY destinations resolve against. + getOnChainTokenDetails(network, request.inputCurrency) !== undefined ); } }, { create(request) { const network = getNetworkFromDestination(request.from); - if (!network || !isNetworkEVM(network) || !isEvmToken(request.inputCurrency)) { + if (!network || !isNetworkEVM(network)) { throw new APIError({ message: "Unsupported EVM source for BRL offramp", status: httpStatus.BAD_REQUEST }); } - return makeBrlOfframpBaseFlow(request.inputCurrency, network); + return makeBrlOfframpBaseFlow(request.inputCurrency as EvmToken, network); }, executorFlow: brlOfframpBaseFlow, matches(request) { @@ -119,8 +120,9 @@ const flowDefinitions: FlowDefinition[] = [ request.outputCurrency === FiatToken.BRL && network !== undefined && isNetworkEVM(network) && - isEvmToken(request.inputCurrency) && - evmTokenConfig[network][request.inputCurrency] !== undefined + // Source tokens come from the merged token catalog (static config plus Squid-discovered + // tokens), the same catalog BUY destinations resolve against. + getOnChainTokenDetails(network, request.inputCurrency) !== undefined ); } }, @@ -141,7 +143,7 @@ const flowDefinitions: FlowDefinition[] = [ request.to === mapFiatToDestination(request.outputCurrency as FiatToken) && network !== undefined && isNetworkEVM(network) && - evmTokenConfig[network][request.inputCurrency as EvmToken] !== undefined + getOnChainTokenDetails(network, request.inputCurrency) !== undefined ); } }, diff --git a/docs/security-spec/03-ramp-engine/ramp-phase-flows.md b/docs/security-spec/03-ramp-engine/ramp-phase-flows.md index d8c23ea725..ee8aae0d09 100644 --- a/docs/security-spec/03-ramp-engine/ramp-phase-flows.md +++ b/docs/security-spec/03-ramp-engine/ramp-phase-flows.md @@ -17,7 +17,7 @@ The phase processor in `state-machine.md` orchestrates execution. The authoritat **EUR Off-ramp (Mykobo on Base):** User's crypto on source EVM → Squid bridge to Base USDC (user-signed, client-side) → Nabla-on-Base swap (USDC→EURC) → Mykobo SEPA payout - Runtime backend phases: `initial` → `fundEphemeral` → `distributeFees` (on Base, USDC) → `subsidizePreSwap` → `nablaApprove` → `nablaSwap` → `subsidizePostSwap` → `mykoboPayoutOnBase` → `complete` -- `EurOfframpBase` statically resolves the source token/network. Base USDC emits one user-wallet transfer; another Base token emits same-chain user-wallet Squid approve/swap; another EVM source emits cross-chain user-wallet Squid approve/swap. `fundEphemeral` verifies reported hashes against the issued payloads before platform funds move, using the same validation applied to BRL EVM offramps. +- `EurOfframpBase` binds the source token/network at quote time; the catalog accepts any token from the merged EVM token catalog (static config plus Squid-discovered tokens), the same catalog BUY destinations resolve against. Base USDC emits one user-wallet transfer; another Base token emits same-chain user-wallet Squid approve/swap; another EVM source emits cross-chain user-wallet Squid approve/swap. `fundEphemeral` verifies reported hashes against the issued payloads before platform funds move, using the same validation applied to BRL EVM offramps. - `MykoboOfframpPayout.register` derives the approved customer email from the authenticated user, treats the supplied email only as a consistency check, sends the effective IP to the withdrawal intent, and accepts the payout address only from validated provider instructions. Intent facts are phase-owned and feed payout preparation/execution. - Note: `distributeFees` runs **before** `nablaSwap` on offramp because fees are denominated in USDC and must be deducted before swapping to EURC. Mirrors the BRL-on-Base off-ramp. - **Removed:** the previous Stellar-based EUR off-ramp (Pendulum → Spacewalk → Stellar anchor) is no longer active — Stellar/Spacewalk support was fully removed (migration 028). @@ -37,7 +37,7 @@ The phase processor in `state-machine.md` orchestrates execution. The authoritat - A Base BRLA source requires no Squid source route or network fee. Quote simulation values the BRLA at the BRL/USD oracle rate before entering the common Base offramp pricing pipeline, preserving the fiat peg rather than treating one BRLA as one USD. - Runtime backend phases: `initial` → `fundEphemeral` → `distributeFees` (on Base, USDC) → `subsidizePreSwap` → `nablaApprove` → `nablaSwap` → `subsidizePostSwap` → `brlaPayoutOnBase` → `complete` - The Squid bridge from the source EVM chain to Base is executed by the user's wallet (presigned `squidRouterApprove` + `squidRouterSwap` are submitted client-side); there is no runtime `squidRouterPay` phase in the BRL off-ramp. -- `BrlOfframpBase` covers three source variants while preserving one runtime phase family: Base USDC emits one user-wallet `squidRouterNoPermitTransfer`; another Base token emits same-chain user-wallet Squid approve/swap; another EVM source emits cross-chain user-wallet Squid approve/swap into Base USDC. `fundEphemeral` verifies the reported hashes against those server-issued payloads before funding or executing Base phases. +- `BrlOfframpBase` accepts any source token from the merged EVM token catalog (static config plus Squid-discovered tokens) and covers three source variants while preserving one runtime phase family: Base USDC emits one user-wallet `squidRouterNoPermitTransfer`; another Base token emits same-chain user-wallet Squid approve/swap; another EVM source emits cross-chain user-wallet Squid approve/swap into Base USDC. `fundEphemeral` verifies the reported hashes against those server-issued payloads before funding or executing Base phases. - `AveniaOfframpPayout.register` derives the sender's Avenia identity from the authenticated user and calls `blocks/core/avenia-registration.ts` directly. That block-owned module validates the PIX key against the receiver's normalized tax ID (without stripping Avenia's mask), includes pending SELL volume in BRL/global limits, and returns the trusted Avenia EVM wallet. `AveniaMint.register` uses the same module for pending BUY limits and provider ticket creation. The payout transaction preparer cannot consume client-supplied payout-recipient facts, and `RampService` has no Avenia validation/ticket methods. - **Runtime retirement:** AssetHub→BRL quotes are not returned. `BrlOfframpAssethubUsdc` remains cataloged only so persisted records can be decoded and inspected; registration, presign updates, start, phase execution, and automatic recovery are blocked or skipped before any Moonbeam RPC or transaction. Its historical phase and transaction definitions are dormant compatibility data. - Note: `distributeFees` runs **before** `nablaSwap` on offramp because fees are denominated in USDC and must be deducted before swapping to BRLA. From fb633927ca44c4a11109c8cf0d6e2facc596792a Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Tue, 8 Sep 2026 10:11:49 +0200 Subject: [PATCH 05/13] feat(api): mark routed tokens sellable in supported cryptocurrencies With the SELL catalog resolving routed tokens, rampTypes no longer depends on whether an EVM token comes from the static config. --- apps/api/src/config/cryptocurrencies.config.test.ts | 5 ++--- apps/api/src/config/cryptocurrencies.config.ts | 8 ++------ docs/api/openapi/vortex.openapi.d.ts | 4 ++-- docs/api/openapi/vortex.openapi.json | 4 ++-- .../src/endpoints/supported-cryptocurrencies.endpoints.ts | 2 +- 5 files changed, 9 insertions(+), 14 deletions(-) diff --git a/apps/api/src/config/cryptocurrencies.config.test.ts b/apps/api/src/config/cryptocurrencies.config.test.ts index 1e8bff5b5b..496e4168a7 100644 --- a/apps/api/src/config/cryptocurrencies.config.test.ts +++ b/apps/api/src/config/cryptocurrencies.config.test.ts @@ -27,7 +27,7 @@ const mergedConfig = { } as unknown as Record>>; describe("getSupportedCryptocurrencies", () => { - it("lists routed tokens as BUY-only and static tokens as BUY and SELL", () => { + it("lists routed tokens alongside static ones, both buyable and sellable", () => { const result = getSupportedCryptocurrencies(Networks.Ethereum, mergedConfig); const bySymbol = Object.fromEntries(result.map(token => [token.assetSymbol, token])); @@ -36,7 +36,7 @@ describe("getSupportedCryptocurrencies", () => { assetDecimals: 18, assetNetwork: Networks.Ethereum, assetSymbol: "PAXG", - rampTypes: [RampDirection.BUY] + rampTypes: [RampDirection.BUY, RampDirection.SELL] }); expect(bySymbol.USDC.rampTypes).toEqual([RampDirection.BUY, RampDirection.SELL]); }); @@ -53,7 +53,6 @@ describe("getSupportedCryptocurrencies", () => { .map(token => token.assetSymbol) .sort() ); - expect(result.every(token => token.rampTypes.includes(RampDirection.SELL))).toBe(true); }); it("marks AssetHub USDC as rampable and other AssetHub tokens as not", () => { diff --git a/apps/api/src/config/cryptocurrencies.config.ts b/apps/api/src/config/cryptocurrencies.config.ts index 1abff20ee2..f6f8deabff 100644 --- a/apps/api/src/config/cryptocurrencies.config.ts +++ b/apps/api/src/config/cryptocurrencies.config.ts @@ -3,7 +3,6 @@ import { assetHubTokenConfig, EvmNetworks, EvmTokenDetails, - evmTokenConfig, getEvmTokenConfig, isNetworkAssetHub, isNetworkEVM, @@ -27,10 +26,6 @@ const getEvmNetworkTokens = ( network: EvmNetworks, tokensByNetwork: Record>> ): SupportedCryptocurrencyDetails[] => { - // The offramp flow catalog only matches tokens from the static config; routed (Squid-discovered) tokens are BUY-only. - const sellableAddresses = new Set( - Object.values(evmTokenConfig[network] ?? {}).map(token => token.erc20AddressSourceChain.toLowerCase()) - ); // The merged config stores static tokens under both their enum key and their symbol; dedupe by contract address. const byAddress = new Map(); for (const details of Object.values(tokensByNetwork[network] ?? {})) { @@ -42,7 +37,8 @@ const getEvmNetworkTokens = ( assetDecimals: details.decimals, assetNetwork: details.network, assetSymbol: details.assetSymbol, - rampTypes: sellableAddresses.has(address) ? [RampDirection.BUY, RampDirection.SELL] : [RampDirection.BUY] + // The flow catalog matches EVM sources and destinations against this same merged token catalog. + rampTypes: [RampDirection.BUY, RampDirection.SELL] }); } return [...byAddress.values()]; diff --git a/docs/api/openapi/vortex.openapi.d.ts b/docs/api/openapi/vortex.openapi.d.ts index 04bf7855a2..c8fa86fa58 100644 --- a/docs/api/openapi/vortex.openapi.d.ts +++ b/docs/api/openapi/vortex.openapi.d.ts @@ -1663,7 +1663,7 @@ export interface paths { }; /** * Supported Cryptocurrencies - * @description Retrieve the cryptocurrencies the quote engine accepts on a network. EVM networks include routed tokens discovered from Squid Router in addition to the static token set; use `rampTypes` to see whether a token can be bought, sold, or both. + * @description Retrieve the cryptocurrencies the quote engine accepts on a network. EVM networks include routed tokens discovered from Squid Router in addition to the static token set; `rampTypes` lists the directions at least one corridor supports for the token. */ get: { parameters: { @@ -1694,7 +1694,7 @@ export interface paths { assetForeignAssetId?: string | null; assetNetwork: components["schemas"]["Networks"]; assetSymbol: string; - /** @description Ramp directions the quote engine accepts for this token. Routed EVM tokens are `BUY`-only; an empty list means the token is listed but not currently rampable. */ + /** @description Ramp directions at least one corridor supports for this token on its network. An empty list means the token is listed but not currently rampable. */ rampTypes: components["schemas"]["RampDirection"][]; }[]; }; diff --git a/docs/api/openapi/vortex.openapi.json b/docs/api/openapi/vortex.openapi.json index 3aa674f411..30ad9fdb80 100644 --- a/docs/api/openapi/vortex.openapi.json +++ b/docs/api/openapi/vortex.openapi.json @@ -10378,7 +10378,7 @@ "/v1/supported-cryptocurrencies": { "get": { "deprecated": false, - "description": "Retrieve the cryptocurrencies the quote engine accepts on a network. EVM networks include routed tokens discovered from Squid Router in addition to the static token set; use `rampTypes` to see whether a token can be bought, sold, or both.", + "description": "Retrieve the cryptocurrencies the quote engine accepts on a network. EVM networks include routed tokens discovered from Squid Router in addition to the static token set; `rampTypes` lists the directions at least one corridor supports for the token.", "parameters": [ { "description": "Network to list cryptocurrencies for (required). Allowed values: `arbitrum`, `assethub`, `avalanche`, `base`, `bsc`, `ethereum`, `moonbeam`, `polygon`", @@ -10418,7 +10418,7 @@ "type": "string" }, "rampTypes": { - "description": "Ramp directions the quote engine accepts for this token. Routed EVM tokens are `BUY`-only; an empty list means the token is listed but not currently rampable.", + "description": "Ramp directions at least one corridor supports for this token on its network. An empty list means the token is listed but not currently rampable.", "items": { "$ref": "#/components/schemas/RampDirection" }, diff --git a/packages/shared/src/endpoints/supported-cryptocurrencies.endpoints.ts b/packages/shared/src/endpoints/supported-cryptocurrencies.endpoints.ts index 7399ab0632..b047d8defd 100644 --- a/packages/shared/src/endpoints/supported-cryptocurrencies.endpoints.ts +++ b/packages/shared/src/endpoints/supported-cryptocurrencies.endpoints.ts @@ -18,7 +18,7 @@ export interface SupportedCryptocurrencyDetailsBase { assetSymbol: string; assetNetwork: Networks; assetDecimals: number; - /// Ramp directions the quote engine accepts for this token. Routed (dynamically discovered) EVM tokens are BUY-only. + /// Ramp directions at least one corridor supports for this token on its network. rampTypes: RampDirection[]; } From a2c246bde244348a13b669af747df43e1d67225f Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Tue, 8 Sep 2026 10:35:25 +0200 Subject: [PATCH 06/13] fix(api): keep sell flow matching independent of token discovery Startup re-resolves every pending quote and resumable ramp through the catalog's match predicates and exits the process on failure. Matching SELL sources against live Squid discovery meant one routed-token ramp plus a failed token fetch at boot would crash-loop the API. Match SELL sources structurally, as BUY destinations already are, and let the flow input resolver reject unknown symbols with a 400 at quote time. --- .../__tests__/catalog-routed-offramp.test.ts | 49 ++--------- .../src/api/services/phases/blocks/core/io.ts | 4 +- .../services/phases/blocks/flows/catalog.ts | 20 +++-- .../brl-offramp-crosschain.scenario.test.ts | 82 ++++++++++++++++++- .../03-ramp-engine/ramp-phase-flows.md | 4 +- 5 files changed, 106 insertions(+), 53 deletions(-) diff --git a/apps/api/src/api/services/phases/blocks/__tests__/catalog-routed-offramp.test.ts b/apps/api/src/api/services/phases/blocks/__tests__/catalog-routed-offramp.test.ts index 73f236bed7..c5d077f0b0 100644 --- a/apps/api/src/api/services/phases/blocks/__tests__/catalog-routed-offramp.test.ts +++ b/apps/api/src/api/services/phases/blocks/__tests__/catalog-routed-offramp.test.ts @@ -1,43 +1,7 @@ -import { afterAll, describe, expect, it, mock } from "bun:test"; -import * as shared from "@vortexfi/shared"; -import { - EvmToken, - type EvmTokenDetails, - evmTokenConfig, - FiatToken, - mapFiatToDestination, - Networks, - RampDirection, - TokenType -} from "@vortexfi/shared"; +import { describe, expect, it } from "bun:test"; +import { EvmToken, FiatToken, mapFiatToDestination, Networks, RampDirection } from "@vortexfi/shared"; import { resolveBlockFlow } from "../flows/catalog"; -// Snapshot before mock.module: bun mutates the imported namespace in place. -const sharedReal = { ...shared }; - -// A token that only exists in the Squid-discovered (dynamic) part of the token catalog. -const ROUTED_PAXG: EvmTokenDetails = { - assetSymbol: "PAXG", - decimals: 18, - erc20AddressSourceChain: "0x45804880de22913dafe09f4980848ece6ecbaf78", - isNative: false, - network: Networks.Ethereum, - pendulumRepresentative: (evmTokenConfig[Networks.Ethereum][EvmToken.USDC] as EvmTokenDetails).pendulumRepresentative, - type: TokenType.Evm -}; - -mock.module("@vortexfi/shared", () => ({ - ...sharedReal, - getOnChainTokenDetails: (network: Networks, token: string, ...rest: unknown[]) => - network === Networks.Ethereum && token === "PAXG" - ? ROUTED_PAXG - : (sharedReal.getOnChainTokenDetails as (...args: unknown[]) => unknown)(network, token, ...rest) -})); - -afterAll(() => { - mock.module("@vortexfi/shared", () => sharedReal); -}); - function sellRequest(inputCurrency: string, outputCurrency: FiatToken) { return { from: Networks.Ethereum, @@ -51,13 +15,16 @@ function sellRequest(inputCurrency: string, outputCurrency: FiatToken) { } describe("SELL flow catalog with routed (Squid-discovered) source tokens", () => { - it("maps a routed EVM source token to the BRL, EUR, and Alfredpay offramp flows", () => { + // PAXG is absent from the static token config, and no dynamic token list is loaded in tests, so a + // match here proves the catalog does not consult live token discovery. Persisted flows are + // re-resolved at startup, when discovery may have fallen back to the static config. + it("maps a routed EVM source token to the BRL, EUR, and Alfredpay offramp flows without live token discovery", () => { expect(resolveBlockFlow(sellRequest("PAXG", FiatToken.BRL)).name).toBe("BrlOfframpBase"); expect(resolveBlockFlow(sellRequest("PAXG", FiatToken.EURC)).name).toBe("EurOfframpBase"); expect(resolveBlockFlow(sellRequest("PAXG", FiatToken.USD)).name).toBe("AlfredpayOfframp"); }); - it("still rejects a source token unknown to the token catalog", () => { - expect(() => resolveBlockFlow(sellRequest("NOPE", FiatToken.BRL))).toThrow(/No block flow mapped/); + it("still rejects a fiat symbol as an on-chain SELL source", () => { + expect(() => resolveBlockFlow(sellRequest(FiatToken.BRL, FiatToken.BRL))).toThrow(/No block flow mapped/); }); }); diff --git a/apps/api/src/api/services/phases/blocks/core/io.ts b/apps/api/src/api/services/phases/blocks/core/io.ts index 0e8e58b046..ca584e0dd6 100644 --- a/apps/api/src/api/services/phases/blocks/core/io.ts +++ b/apps/api/src/api/services/phases/blocks/core/io.ts @@ -9,6 +9,8 @@ import { type OnChainToken } from "@vortexfi/shared"; import Big from "big.js"; +import httpStatus from "http-status"; +import { APIError } from "../../../../errors/api-error"; import type { ChainBrand, FlowInputResolver, PhaseCtx, PhaseIO, TokenBrand } from "./types"; export function fiatRequestIO(...tokens: Token[]): FlowInputResolver> { @@ -38,7 +40,7 @@ function onChainRequestIO( } const tokenDetails = getOnChainTokenDetails(chain, token); if (!tokenDetails) { - throw new Error(`Token ${token} is not configured on ${chain}`); + throw new APIError({ message: `Token ${token} is not configured on ${chain}`, status: httpStatus.BAD_REQUEST }); } const amount = new Big(ctx.request.inputAmount); return { diff --git a/apps/api/src/api/services/phases/blocks/flows/catalog.ts b/apps/api/src/api/services/phases/blocks/flows/catalog.ts index 14e71b63be..34ecc5f25e 100644 --- a/apps/api/src/api/services/phases/blocks/flows/catalog.ts +++ b/apps/api/src/api/services/phases/blocks/flows/catalog.ts @@ -5,9 +5,9 @@ import { evmTokenConfig, FiatToken, getNetworkFromDestination, - getOnChainTokenDetails, isDomesticToken, isNetworkEVM, + isOnChainToken, mapFiatToDestination, Networks, RampDirection @@ -98,9 +98,11 @@ const flowDefinitions: FlowDefinition[] = [ request.to === EPaymentMethod.SEPA && network !== undefined && isNetworkEVM(network) && - // Source tokens come from the merged token catalog (static config plus Squid-discovered - // tokens), the same catalog BUY destinations resolve against. - getOnChainTokenDetails(network, request.inputCurrency) !== undefined + // Structural only: the flow input resolver rejects symbols unknown to the merged token + // catalog at quote time. Matching must not depend on live token discovery, because + // persisted flows are re-resolved at startup, when discovery may have fallen back to + // the static config. + isOnChainToken(request.inputCurrency) ); } }, @@ -120,9 +122,11 @@ const flowDefinitions: FlowDefinition[] = [ request.outputCurrency === FiatToken.BRL && network !== undefined && isNetworkEVM(network) && - // Source tokens come from the merged token catalog (static config plus Squid-discovered - // tokens), the same catalog BUY destinations resolve against. - getOnChainTokenDetails(network, request.inputCurrency) !== undefined + // Structural only: the flow input resolver rejects symbols unknown to the merged token + // catalog at quote time. Matching must not depend on live token discovery, because + // persisted flows are re-resolved at startup, when discovery may have fallen back to + // the static config. + isOnChainToken(request.inputCurrency) ); } }, @@ -143,7 +147,7 @@ const flowDefinitions: FlowDefinition[] = [ request.to === mapFiatToDestination(request.outputCurrency as FiatToken) && network !== undefined && isNetworkEVM(network) && - getOnChainTokenDetails(network, request.inputCurrency) !== undefined + isOnChainToken(request.inputCurrency) ); } }, diff --git a/apps/api/src/tests/corridors/brl-offramp-crosschain.scenario.test.ts b/apps/api/src/tests/corridors/brl-offramp-crosschain.scenario.test.ts index 31b2f7baf2..e2581d1cb3 100644 --- a/apps/api/src/tests/corridors/brl-offramp-crosschain.scenario.test.ts +++ b/apps/api/src/tests/corridors/brl-offramp-crosschain.scenario.test.ts @@ -1,6 +1,8 @@ -import { afterAll, beforeAll, beforeEach, describe, expect, it } from "bun:test"; +import { afterAll, beforeAll, beforeEach, describe, expect, it, mock } from "bun:test"; +import * as shared from "@vortexfi/shared"; import { AveniaTicketStatus, + type EvmTokenDetails, type EvmTransactionData, EvmToken, evmTokenConfig, @@ -9,12 +11,15 @@ import { PRESIGNED_EVM_FEE_MULTIPLIER, RampDirection, type RampPhase, + TokenType, type UnsignedTx } from "@vortexfi/shared"; import { parseUnits } from "viem"; import { generatePrivateKey, privateKeyToAccount, type PrivateKeyAccount } from "viem/accounts"; import phaseProcessor from "../../api/services/phases/phase-processor"; import { getFlowMetadata } from "../../api/services/phases/blocks/core/metadata"; +import { resolvePersistedBlockFlow } from "../../api/services/phases/blocks/flows/catalog"; +import { assertPersistedBlockFlowVersionsSupported } from "../../api/services/phases/blocks/register-handlers"; import QuoteTicket from "../../models/quoteTicket.model"; import RampState from "../../models/rampState.model"; import { resetTestDatabase, setupTestDatabase } from "../../test-utils/db"; @@ -480,6 +485,81 @@ describe("BRL offramp cross-chain corridor (USDC on Polygon → Base → pix via 30000 ); + async function requestSellQuote(inputCurrency: string, inputAmount: string) { + return app.request("/v1/quotes", { + body: JSON.stringify({ + from: Networks.Ethereum, + inputAmount, + inputCurrency, + network: Networks.Ethereum, + outputCurrency: FiatToken.BRL, + rampType: RampDirection.SELL, + to: "pix" + }), + headers: { "Content-Type": "application/json" }, + method: "POST" + }); + } + + it("unknown SELL source token: the catalog maps the request and the flow input rejects it with 400", async () => { + const response = await requestSellQuote("NOPE", "1"); + expect(response.status).toBe(400); + const body = (await response.json()) as { message: string }; + expect(body.message).toContain("Token NOPE is not configured on ethereum"); + }); + + it("restart compatibility: an active routed-token SELL ramp resolves when token discovery is static-only", async () => { + // PAXG only exists in the Squid-discovered part of the token catalog. Discovery knows it while + // the quote and ramp are created ... + const realGetOnChainTokenDetails = shared.getOnChainTokenDetails; + const routedPaxg: EvmTokenDetails = { + assetSymbol: "PAXG", + decimals: 18, + erc20AddressSourceChain: "0x45804880de22913dafe09f4980848ece6ecbaf78", + isNative: false, + network: Networks.Ethereum, + pendulumRepresentative: requireToken(Networks.Base, EvmToken.USDC).pendulumRepresentative, + type: TokenType.Evm + }; + mock.module("@vortexfi/shared", () => ({ + ...shared, + getOnChainTokenDetails: (network: Networks, token: string, ...rest: unknown[]) => + network === Networks.Ethereum && token === "PAXG" + ? routedPaxg + : (realGetOnChainTokenDetails as (...args: unknown[]) => unknown)(network, token, ...rest) + })); + const { computeToAmount, computeToAmountUsd } = world.squidRouter; + world.squidRouter.computeToAmount = () => "50000000"; // 50 USDC on Base + world.squidRouter.computeToAmountUsd = () => "50"; + try { + const user = await createTestUser(); + await createTestTaxId(user.id, { taxId: TAX_ID }); + const response = await requestSellQuote("PAXG", "0.02"); + expect(response.status).toBe(201); + const quote = (await response.json()) as { id: string }; + const ramp = await registerViaApi( + quote.id, + user.id, + privateKeyToAccount(generatePrivateKey()), + privateKeyToAccount(generatePrivateKey()) + ); + + // ... then the API restarts while Squid's token list is unavailable, so discovery falls back + // to the static config, which has no PAXG. Startup must still resolve the persisted flow. + mock.module("@vortexfi/shared", () => ({ ...shared, getOnChainTokenDetails: realGetOnChainTokenDetails })); + expect(realGetOnChainTokenDetails(Networks.Ethereum, "PAXG")).toBeUndefined(); + + await assertPersistedBlockFlowVersionsSupported(); + const persistedQuote = await QuoteTicket.findByPk(quote.id); + expect(resolvePersistedBlockFlow(persistedQuote?.metadata).name).toBe("BrlOfframpBase"); + const rampState = await RampState.findByPk(ramp.id); + expect(rampState?.currentPhase).toBe("initial"); + } finally { + mock.module("@vortexfi/shared", () => ({ ...shared, getOnChainTokenDetails: realGetOnChainTokenDetails })); + Object.assign(world.squidRouter, { computeToAmount, computeToAmountUsd }); + } + }); + it("native ETH source: the quote prices only the router fee as network fee, not the swapped principal", async () => { // Squid sends a native input as msg.value, so the route's value is the principal plus the // router fee. Pricing the whole value as network fee zeroed the swap input (regression). diff --git a/docs/security-spec/03-ramp-engine/ramp-phase-flows.md b/docs/security-spec/03-ramp-engine/ramp-phase-flows.md index ee8aae0d09..1430954381 100644 --- a/docs/security-spec/03-ramp-engine/ramp-phase-flows.md +++ b/docs/security-spec/03-ramp-engine/ramp-phase-flows.md @@ -17,7 +17,7 @@ The phase processor in `state-machine.md` orchestrates execution. The authoritat **EUR Off-ramp (Mykobo on Base):** User's crypto on source EVM → Squid bridge to Base USDC (user-signed, client-side) → Nabla-on-Base swap (USDC→EURC) → Mykobo SEPA payout - Runtime backend phases: `initial` → `fundEphemeral` → `distributeFees` (on Base, USDC) → `subsidizePreSwap` → `nablaApprove` → `nablaSwap` → `subsidizePostSwap` → `mykoboPayoutOnBase` → `complete` -- `EurOfframpBase` binds the source token/network at quote time; the catalog accepts any token from the merged EVM token catalog (static config plus Squid-discovered tokens), the same catalog BUY destinations resolve against. Base USDC emits one user-wallet transfer; another Base token emits same-chain user-wallet Squid approve/swap; another EVM source emits cross-chain user-wallet Squid approve/swap. `fundEphemeral` verifies reported hashes against the issued payloads before platform funds move, using the same validation applied to BRL EVM offramps. +- `EurOfframpBase` binds the source token/network at quote time. The catalog matches any on-chain source symbol structurally; the flow input resolver rejects symbols unknown to the merged EVM token catalog (static config plus Squid-discovered tokens) at quote time, so persisted flows re-resolve at startup independently of live token discovery. Base USDC emits one user-wallet transfer; another Base token emits same-chain user-wallet Squid approve/swap; another EVM source emits cross-chain user-wallet Squid approve/swap. `fundEphemeral` verifies reported hashes against the issued payloads before platform funds move, using the same validation applied to BRL EVM offramps. - `MykoboOfframpPayout.register` derives the approved customer email from the authenticated user, treats the supplied email only as a consistency check, sends the effective IP to the withdrawal intent, and accepts the payout address only from validated provider instructions. Intent facts are phase-owned and feed payout preparation/execution. - Note: `distributeFees` runs **before** `nablaSwap` on offramp because fees are denominated in USDC and must be deducted before swapping to EURC. Mirrors the BRL-on-Base off-ramp. - **Removed:** the previous Stellar-based EUR off-ramp (Pendulum → Spacewalk → Stellar anchor) is no longer active — Stellar/Spacewalk support was fully removed (migration 028). @@ -37,7 +37,7 @@ The phase processor in `state-machine.md` orchestrates execution. The authoritat - A Base BRLA source requires no Squid source route or network fee. Quote simulation values the BRLA at the BRL/USD oracle rate before entering the common Base offramp pricing pipeline, preserving the fiat peg rather than treating one BRLA as one USD. - Runtime backend phases: `initial` → `fundEphemeral` → `distributeFees` (on Base, USDC) → `subsidizePreSwap` → `nablaApprove` → `nablaSwap` → `subsidizePostSwap` → `brlaPayoutOnBase` → `complete` - The Squid bridge from the source EVM chain to Base is executed by the user's wallet (presigned `squidRouterApprove` + `squidRouterSwap` are submitted client-side); there is no runtime `squidRouterPay` phase in the BRL off-ramp. -- `BrlOfframpBase` accepts any source token from the merged EVM token catalog (static config plus Squid-discovered tokens) and covers three source variants while preserving one runtime phase family: Base USDC emits one user-wallet `squidRouterNoPermitTransfer`; another Base token emits same-chain user-wallet Squid approve/swap; another EVM source emits cross-chain user-wallet Squid approve/swap into Base USDC. `fundEphemeral` verifies the reported hashes against those server-issued payloads before funding or executing Base phases. +- `BrlOfframpBase` matches any on-chain source symbol structurally (unknown symbols are rejected by the flow input resolver at quote time, keeping persisted-flow resolution independent of live token discovery) and covers three source variants while preserving one runtime phase family: Base USDC emits one user-wallet `squidRouterNoPermitTransfer`; another Base token emits same-chain user-wallet Squid approve/swap; another EVM source emits cross-chain user-wallet Squid approve/swap into Base USDC. `fundEphemeral` verifies the reported hashes against those server-issued payloads before funding or executing Base phases. - `AveniaOfframpPayout.register` derives the sender's Avenia identity from the authenticated user and calls `blocks/core/avenia-registration.ts` directly. That block-owned module validates the PIX key against the receiver's normalized tax ID (without stripping Avenia's mask), includes pending SELL volume in BRL/global limits, and returns the trusted Avenia EVM wallet. `AveniaMint.register` uses the same module for pending BUY limits and provider ticket creation. The payout transaction preparer cannot consume client-supplied payout-recipient facts, and `RampService` has no Avenia validation/ticket methods. - **Runtime retirement:** AssetHub→BRL quotes are not returned. `BrlOfframpAssethubUsdc` remains cataloged only so persisted records can be decoded and inspected; registration, presign updates, start, phase execution, and automatic recovery are blocked or skipped before any Moonbeam RPC or transaction. Its historical phase and transaction definitions are dormant compatibility data. - Note: `distributeFees` runs **before** `nablaSwap` on offramp because fees are denominated in USDC and must be deducted before swapping to BRLA. From 984966e759276c04e0e03f480da39d2d16fa6a1d Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Tue, 8 Sep 2026 10:35:26 +0200 Subject: [PATCH 07/13] fix(api): advertise no ramp directions on disabled networks rampTypes claimed BUY and SELL for Moonbeam, testnets, and AssetHub, although quote creation rejects those networks or has retired their only corridors. Derive the directions from the same guards the quote service applies, naming the AssetHub retirement so both share it. --- .../services/phases/blocks/core/helpers.ts | 17 ++++++ apps/api/src/api/services/quote/index.ts | 16 +++-- .../config/cryptocurrencies.config.test.ts | 59 ++++++++++++++++--- .../api/src/config/cryptocurrencies.config.ts | 39 ++++++++++-- docs/api/openapi/vortex.openapi.d.ts | 2 +- docs/api/openapi/vortex.openapi.json | 2 +- 6 files changed, 111 insertions(+), 24 deletions(-) diff --git a/apps/api/src/api/services/phases/blocks/core/helpers.ts b/apps/api/src/api/services/phases/blocks/core/helpers.ts index 06a2ef1d01..5f97350c08 100644 --- a/apps/api/src/api/services/phases/blocks/core/helpers.ts +++ b/apps/api/src/api/services/phases/blocks/core/helpers.ts @@ -96,6 +96,23 @@ export function validateChainSupport(rampType: RampDirection, from: DestinationT } } +/** + * The AssetHub BRL corridors are retired at runtime: their flows stay cataloged only so persisted + * records can be decoded, and new quotes for them are rejected. + */ +export function isRetiredAssetHubCorridor( + request: Pick +): boolean { + return ( + (request.rampType === RampDirection.BUY && + request.inputCurrency === FiatToken.BRL && + getNetworkFromDestination(request.to) === Networks.AssetHub) || + (request.rampType === RampDirection.SELL && + getNetworkFromDestination(request.from) === Networks.AssetHub && + request.outputCurrency === FiatToken.BRL) + ); +} + /** * Corridors whose partner-markup component is collected via EVM fee transfers: the * BRL Base routes (USDC on Base) and every Alfredpay corridor (USDT on Polygon). A diff --git a/apps/api/src/api/services/quote/index.ts b/apps/api/src/api/services/quote/index.ts index f4d38c1b63..9dd1b9d396 100644 --- a/apps/api/src/api/services/quote/index.ts +++ b/apps/api/src/api/services/quote/index.ts @@ -18,7 +18,12 @@ import pLimit from "p-limit"; import logger from "../../../config/logger"; import { config } from "../../../config/vars"; import { APIError } from "../../errors/api-error"; -import { getTargetFiatCurrency, SUPPORTED_CHAINS, validateChainSupport } from "../phases/blocks/core/helpers"; +import { + getTargetFiatCurrency, + isRetiredAssetHubCorridor, + SUPPORTED_CHAINS, + validateChainSupport +} from "../phases/blocks/core/helpers"; import { MykoboFeeUnavailableError } from "../phases/blocks/core/mykobo-fee"; import { runBlockQuoteFlow } from "../phases/blocks/core/quote"; import { buildBlockQuoteResponse } from "../phases/blocks/core/quote-response"; @@ -188,14 +193,7 @@ export class QuoteService extends BaseRampService { ): Promise { validateChainSupport(request.rampType, request.from, request.to); - if ( - (request.rampType === RampDirection.BUY && - request.inputCurrency === FiatToken.BRL && - getNetworkFromDestination(request.to) === Networks.AssetHub) || - (request.rampType === RampDirection.SELL && - getNetworkFromDestination(request.from) === Networks.AssetHub && - request.outputCurrency === FiatToken.BRL) - ) { + if (isRetiredAssetHubCorridor(request)) { throw new APIError({ message: QuoteError.FailedToCalculateQuote, status: httpStatus.BAD_REQUEST }); } diff --git a/apps/api/src/config/cryptocurrencies.config.test.ts b/apps/api/src/config/cryptocurrencies.config.test.ts index 496e4168a7..426581d22b 100644 --- a/apps/api/src/config/cryptocurrencies.config.test.ts +++ b/apps/api/src/config/cryptocurrencies.config.test.ts @@ -1,6 +1,17 @@ import { describe, expect, it } from "bun:test"; -import { EvmNetworks, EvmToken, EvmTokenDetails, evmTokenConfig, Networks, RampDirection, TokenType } from "@vortexfi/shared"; +import { + EPaymentMethod, + EvmNetworks, + EvmToken, + EvmTokenDetails, + evmTokenConfig, + FiatToken, + Networks, + RampDirection, + TokenType +} from "@vortexfi/shared"; import { APIError } from "../api/errors/api-error"; +import { isRetiredAssetHubCorridor, validateChainSupport } from "../api/services/phases/blocks/core/helpers"; import { getSupportedCryptocurrencies } from "./cryptocurrencies.config"; const staticUsdc = evmTokenConfig[Networks.Ethereum][EvmToken.USDC] as EvmTokenDetails; @@ -26,6 +37,8 @@ const mergedConfig = { } } as unknown as Record>>; +const BOTH = [RampDirection.BUY, RampDirection.SELL]; + describe("getSupportedCryptocurrencies", () => { it("lists routed tokens alongside static ones, both buyable and sellable", () => { const result = getSupportedCryptocurrencies(Networks.Ethereum, mergedConfig); @@ -36,9 +49,9 @@ describe("getSupportedCryptocurrencies", () => { assetDecimals: 18, assetNetwork: Networks.Ethereum, assetSymbol: "PAXG", - rampTypes: [RampDirection.BUY, RampDirection.SELL] + rampTypes: BOTH }); - expect(bySymbol.USDC.rampTypes).toEqual([RampDirection.BUY, RampDirection.SELL]); + expect(bySymbol.USDC.rampTypes).toEqual(BOTH); }); it("dedupes static tokens stored under enum key and symbol alias", () => { @@ -55,11 +68,43 @@ describe("getSupportedCryptocurrencies", () => { ); }); - it("marks AssetHub USDC as rampable and other AssetHub tokens as not", () => { + it("advertises no directions on EVM networks the quote service rejects", () => { + for (const network of [Networks.Moonbeam, Networks.BaseSepolia, Networks.PolygonAmoy]) { + // The runtime guard behind the metadata: quote creation rejects both directions. + expect(() => validateChainSupport(RampDirection.BUY, EPaymentMethod.PIX, network)).toThrow(APIError); + expect(() => validateChainSupport(RampDirection.SELL, network, EPaymentMethod.PIX)).toThrow(APIError); + + const result = getSupportedCryptocurrencies(network); + expect(result.length).toBeGreaterThan(0); + expect(result.every(token => token.rampTypes.length === 0)).toBe(true); + } + }); + + it("advertises no directions for AssetHub, whose only corridors are retired", () => { + // Chain support alone would allow AssetHub; the retirement guard is what closes it. + expect(() => validateChainSupport(RampDirection.BUY, EPaymentMethod.PIX, Networks.AssetHub)).not.toThrow(); + expect( + isRetiredAssetHubCorridor({ + from: EPaymentMethod.PIX, + inputCurrency: FiatToken.BRL, + outputCurrency: "USDC" as EvmToken, + rampType: RampDirection.BUY, + to: Networks.AssetHub + }) + ).toBe(true); + expect( + isRetiredAssetHubCorridor({ + from: Networks.AssetHub, + inputCurrency: "USDC" as EvmToken, + outputCurrency: FiatToken.BRL, + rampType: RampDirection.SELL, + to: EPaymentMethod.PIX + }) + ).toBe(true); + const result = getSupportedCryptocurrencies(Networks.AssetHub); - const bySymbol = Object.fromEntries(result.map(token => [token.assetSymbol, token.rampTypes])); - expect(bySymbol.USDC).toEqual([RampDirection.BUY, RampDirection.SELL]); - expect(bySymbol.DOT).toEqual([]); + expect(result.map(token => token.assetSymbol).sort()).toEqual(["DOT", "USDC", "USDT"]); + expect(result.every(token => token.rampTypes.length === 0)).toBe(true); }); it("rejects a missing or unsupported network", () => { diff --git a/apps/api/src/config/cryptocurrencies.config.ts b/apps/api/src/config/cryptocurrencies.config.ts index f6f8deabff..409b9085a7 100644 --- a/apps/api/src/config/cryptocurrencies.config.ts +++ b/apps/api/src/config/cryptocurrencies.config.ts @@ -1,8 +1,10 @@ import { AssetHubToken, assetHubTokenConfig, + EPaymentMethod, EvmNetworks, EvmTokenDetails, + FiatToken, getEvmTokenConfig, isNetworkAssetHub, isNetworkEVM, @@ -11,6 +13,7 @@ import { SupportedCryptocurrencyDetails } from "@vortexfi/shared"; import { APIError } from "../api/errors/api-error"; +import { isRetiredAssetHubCorridor, validateChainSupport } from "../api/services/phases/blocks/core/helpers"; const supportedNetworks = Object.values(Networks) .filter(network => isNetworkEVM(network) || isNetworkAssetHub(network)) @@ -22,10 +25,34 @@ const throwInvalidNetworkError = (network: string | undefined): never => { }); }; +/** + * Whether quote creation lets a ramp in this direction touch the network at all, probed through the + * same guards the quote service applies. The BRL/PIX corridor stands in for the fiat side: chain + * support is per network, and BRL is the only corridor the AssetHub flows ever had. + */ +const isDirectionAvailable = (network: Networks, rampType: RampDirection): boolean => { + const probe = + rampType === RampDirection.BUY + ? { from: EPaymentMethod.PIX, inputCurrency: FiatToken.BRL, outputCurrency: "", rampType, to: network } + : { from: network, inputCurrency: "", outputCurrency: FiatToken.BRL, rampType, to: EPaymentMethod.PIX }; + try { + validateChainSupport(rampType, probe.from, probe.to); + } catch { + return false; + } + return !isRetiredAssetHubCorridor(probe as Parameters[0]); +}; + +const rampTypesFor = (network: Networks): RampDirection[] => + [RampDirection.BUY, RampDirection.SELL].filter(rampType => isDirectionAvailable(network, rampType)); + const getEvmNetworkTokens = ( network: EvmNetworks, tokensByNetwork: Record>> ): SupportedCryptocurrencyDetails[] => { + // The flow catalog matches EVM sources and destinations structurally, so every listed token shares + // the network's directions. + const rampTypes = rampTypesFor(network); // The merged config stores static tokens under both their enum key and their symbol; dedupe by contract address. const byAddress = new Map(); for (const details of Object.values(tokensByNetwork[network] ?? {})) { @@ -37,25 +64,25 @@ const getEvmNetworkTokens = ( assetDecimals: details.decimals, assetNetwork: details.network, assetSymbol: details.assetSymbol, - // The flow catalog matches EVM sources and destinations against this same merged token catalog. - rampTypes: [RampDirection.BUY, RampDirection.SELL] + rampTypes }); } return [...byAddress.values()]; }; -const getAssetHubTokens = (): SupportedCryptocurrencyDetails[] => - Object.values(AssetHubToken).map(token => { +const getAssetHubTokens = (): SupportedCryptocurrencyDetails[] => { + const rampTypes = rampTypesFor(Networks.AssetHub); + return Object.values(AssetHubToken).map(token => { const details = assetHubTokenConfig[token]; return { assetDecimals: details.decimals, assetForeignAssetId: details.foreignAssetId, assetNetwork: details.network, assetSymbol: details.assetSymbol, - // Only AssetHub USDC has ramp flows (BRL on/offramp). - rampTypes: token === AssetHubToken.USDC ? [RampDirection.BUY, RampDirection.SELL] : [] + rampTypes }; }); +}; /** * Supported cryptocurrencies for a network, including routed EVM tokens discovered from Squid Router. diff --git a/docs/api/openapi/vortex.openapi.d.ts b/docs/api/openapi/vortex.openapi.d.ts index c8fa86fa58..00e35bed50 100644 --- a/docs/api/openapi/vortex.openapi.d.ts +++ b/docs/api/openapi/vortex.openapi.d.ts @@ -1694,7 +1694,7 @@ export interface paths { assetForeignAssetId?: string | null; assetNetwork: components["schemas"]["Networks"]; assetSymbol: string; - /** @description Ramp directions at least one corridor supports for this token on its network. An empty list means the token is listed but not currently rampable. */ + /** @description Ramp directions at least one corridor supports for this token on its network. An empty list means the token is listed but not currently rampable, for example on networks without ramp support or for the retired AssetHub corridors. */ rampTypes: components["schemas"]["RampDirection"][]; }[]; }; diff --git a/docs/api/openapi/vortex.openapi.json b/docs/api/openapi/vortex.openapi.json index 30ad9fdb80..58ca344ca6 100644 --- a/docs/api/openapi/vortex.openapi.json +++ b/docs/api/openapi/vortex.openapi.json @@ -10418,7 +10418,7 @@ "type": "string" }, "rampTypes": { - "description": "Ramp directions at least one corridor supports for this token on its network. An empty list means the token is listed but not currently rampable.", + "description": "Ramp directions at least one corridor supports for this token on its network. An empty list means the token is listed but not currently rampable, for example on networks without ramp support or for the retired AssetHub corridors.", "items": { "$ref": "#/components/schemas/RampDirection" }, From b1932fabae6201dda2c08184528fa90055d4bec4 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Tue, 8 Sep 2026 10:45:53 +0200 Subject: [PATCH 08/13] test(api): drain fire-and-forget work before truncating test tables The phase processor enqueues the ramp-completion email without awaiting it, so the INSERT can still be in flight when the next test's beforeEach truncates every table. Postgres then deadlocks the two (the INSERT's FK check waits for a table the TRUNCATE holds, and vice versa) and the next test fails at random. Route the enqueue through a background-work registry from the fake world and have truncateAllTables wait for it. --- .../src/test-utils/background-work.test.ts | 52 +++++++++++++++++++ apps/api/src/test-utils/background-work.ts | 33 ++++++++++++ apps/api/src/test-utils/db.ts | 4 ++ .../fake-world/fake-background-work.ts | 27 ++++++++++ apps/api/src/test-utils/fake-world/index.ts | 5 ++ apps/api/src/tests/harness.smoke.test.ts | 23 ++++++++ docs/operations-testing.md | 5 ++ 7 files changed, 149 insertions(+) create mode 100644 apps/api/src/test-utils/background-work.test.ts create mode 100644 apps/api/src/test-utils/background-work.ts create mode 100644 apps/api/src/test-utils/fake-world/fake-background-work.ts diff --git a/apps/api/src/test-utils/background-work.test.ts b/apps/api/src/test-utils/background-work.test.ts new file mode 100644 index 0000000000..1398c92125 --- /dev/null +++ b/apps/api/src/test-utils/background-work.test.ts @@ -0,0 +1,52 @@ +import { afterAll, beforeAll, describe, expect, it } from "bun:test"; +import { enqueueRampCompletedEmail } from "../api/services/email/ramp-completion"; +import type RampState from "../models/rampState.model"; +import { pendingBackgroundWorkCount, settleBackgroundWork, trackBackgroundWork } from "./background-work"; +import { installBackgroundWorkTracking } from "./fake-world/fake-background-work"; + +describe("background work registry", () => { + let tracking: { restore: () => void }; + + beforeAll(() => { + tracking = installBackgroundWorkTracking(); + }); + + afterAll(() => { + tracking.restore(); + }); + + it("settles after tracked work finishes, whether it resolved or rejected", async () => { + let finish: () => void = () => undefined; + const slow = new Promise(resolve => { + finish = resolve; + }); + const failing = Promise.reject(new Error("enqueue failed")); + + // The caller keeps the original promise, so its own error handling still runs. + expect(trackBackgroundWork(slow)).toBe(slow); + await expect(trackBackgroundWork(failing)).rejects.toThrow("enqueue failed"); + expect(pendingBackgroundWorkCount()).toBeGreaterThan(0); + + let settled = false; + const settling = settleBackgroundWork().then(() => { + settled = true; + }); + await Promise.resolve(); + expect(settled).toBe(false); + + finish(); + await settling; + expect(pendingBackgroundWorkCount()).toBe(0); + }); + + it("tracks the phase processor's ramp-completion enqueue through the fake world wrapper", async () => { + // A ramp without a user returns before touching the database, which is enough to show + // the call is routed through the registry. + const enqueue = enqueueRampCompletedEmail({ userId: null } as unknown as RampState); + expect(pendingBackgroundWorkCount()).toBe(1); + + await enqueue; + await settleBackgroundWork(); + expect(pendingBackgroundWorkCount()).toBe(0); + }); +}); diff --git a/apps/api/src/test-utils/background-work.ts b/apps/api/src/test-utils/background-work.ts new file mode 100644 index 0000000000..95b9479084 --- /dev/null +++ b/apps/api/src/test-utils/background-work.ts @@ -0,0 +1,33 @@ +/** + * Registry for fire-and-forget work the app starts after a response or phase transition + * (today: the ramp-completion email enqueue in the phase processor). Such work can still be + * writing after the test that triggered it has finished, and a TRUNCATE issued while that + * INSERT is in flight deadlocks in Postgres: the INSERT's foreign-key check waits for a table + * the TRUNCATE already locked, while the TRUNCATE waits for the table the INSERT holds. The + * fake world routes these entry points through trackBackgroundWork, and truncateAllTables + * waits for everything tracked to settle first. + */ +const pending = new Set>(); + +/** Records a fire-and-forget promise; returns the original so the caller's handling is unchanged. */ +export function trackBackgroundWork(promise: Promise): Promise { + const settled: Promise = promise + .then( + () => undefined, + () => undefined + ) + .finally(() => pending.delete(settled)); + pending.add(settled); + return promise; +} + +export function pendingBackgroundWorkCount(): number { + return pending.size; +} + +/** Resolves once no tracked work is in flight, including work started while waiting. */ +export async function settleBackgroundWork(): Promise { + while (pending.size > 0) { + await Promise.all([...pending]); + } +} diff --git a/apps/api/src/test-utils/db.ts b/apps/api/src/test-utils/db.ts index e74cdf278c..4746442ed0 100644 --- a/apps/api/src/test-utils/db.ts +++ b/apps/api/src/test-utils/db.ts @@ -1,5 +1,6 @@ import sequelize from "../config/database"; import { runMigrations } from "../database/migrator"; +import { settleBackgroundWork } from "./background-work"; // Importing the models index registers every model and association on the sequelize instance. import "../models"; @@ -51,6 +52,9 @@ export async function resetTestDatabase(): Promise { * migration bookkeeping intact. */ export async function truncateAllTables(): Promise { + // Fire-and-forget work from the previous test (e.g. the ramp-completion email enqueue) + // may still be inserting; truncating underneath it deadlocks in Postgres. + await settleBackgroundWork(); const tables = Object.values(sequelize.models) // Umzug's SequelizeStorage registers SequelizeMeta as a model; wiping it // would make every migration re-run on the next setup. diff --git a/apps/api/src/test-utils/fake-world/fake-background-work.ts b/apps/api/src/test-utils/fake-world/fake-background-work.ts new file mode 100644 index 0000000000..406cc85f41 --- /dev/null +++ b/apps/api/src/test-utils/fake-world/fake-background-work.ts @@ -0,0 +1,27 @@ +import { mock } from "bun:test"; +import * as rampCompletion from "../../api/services/email/ramp-completion"; +import { trackBackgroundWork } from "../background-work"; + +// Snapshot before any mock.module call: bun mutates the imported namespace in place, +// so restore() spreading `rampCompletion` afterwards would reinstall the wrapper. +const rampCompletionReal = { ...rampCompletion }; + +/** + * Routes the app's fire-and-forget entry points through the background-work registry so + * truncateAllTables can wait for them. enqueueRampCompletedEmail is a plain function export + * the phase processor calls without awaiting, so it is wrapped via mock.module with the rest + * of the module passed through untouched; behaviour is unchanged. + */ +export function installBackgroundWorkTracking(): { restore: () => void } { + mock.module("../../api/services/email/ramp-completion", () => ({ + ...rampCompletionReal, + enqueueRampCompletedEmail: (...args: Parameters) => + trackBackgroundWork(rampCompletionReal.enqueueRampCompletedEmail(...args)) + })); + + return { + restore: () => { + mock.module("../../api/services/email/ramp-completion", () => rampCompletionReal); + } + }; +} diff --git a/apps/api/src/test-utils/fake-world/index.ts b/apps/api/src/test-utils/fake-world/index.ts index 4f01d0e760..9b17ef172b 100644 --- a/apps/api/src/test-utils/fake-world/index.ts +++ b/apps/api/src/test-utils/fake-world/index.ts @@ -1,5 +1,6 @@ import { ApiManager } from "@vortexfi/shared"; import { type FakeAlfredpay, type FakeBrla, type FakeMykobo, installFakeAnchors } from "./fake-anchors"; +import { installBackgroundWorkTracking } from "./fake-background-work"; import { type FakeEvm, installFakeEvm } from "./fake-evm"; import { type FakePrices, installFakePrices } from "./fake-prices"; import { type FakeSquidRouter, installFakeSquidRouter } from "./fake-squidrouter"; @@ -30,6 +31,9 @@ export function installFakeWorld(): FakeWorld { const { fakeAlfredpay, fakeBrla, fakeMykobo, restore: restoreAnchors } = installFakeAnchors(); const { fakePrices, restore: restorePrices } = installFakePrices(); const { fakeSquidRouter, restore: restoreSquidRouter } = installFakeSquidRouter(); + // Not an external boundary, but fire-and-forget app work (the ramp-completion email + // enqueue) must be trackable so truncateAllTables can wait for it between tests. + const { restore: restoreBackgroundWorkTracking } = installBackgroundWorkTracking(); // Substrate/Pendulum flows are not faked yet; fail loudly if a code path // unexpectedly needs them so the gap is explicit rather than a hang. @@ -102,6 +106,7 @@ export function installFakeWorld(): FakeWorld { prices: fakePrices, restore: () => { ApiManager.getInstance = originalGetApiManager; + restoreBackgroundWorkTracking(); restoreSquidRouter(); restorePrices(); restoreAnchors(); diff --git a/apps/api/src/tests/harness.smoke.test.ts b/apps/api/src/tests/harness.smoke.test.ts index fe74318826..d59ad8d154 100644 --- a/apps/api/src/tests/harness.smoke.test.ts +++ b/apps/api/src/tests/harness.smoke.test.ts @@ -1,5 +1,6 @@ import { afterAll, beforeAll, describe, expect, it } from "bun:test"; import { assertApiCredentialSchemaReady } from "../api/services/apiCredential.service"; +import { trackBackgroundWork } from "../test-utils/background-work"; import { installFakeWorld, type FakeWorld } from "../test-utils/fake-world"; import { setupTestDatabase, truncateAllTables } from "../test-utils/db"; import { createTestApiKey, createTestPartner, createTestQuote, createTestRampState, createTestUser } from "../test-utils/factories"; @@ -63,4 +64,26 @@ describe("test harness smoke test", () => { }); expect(balance).toBe(123n); }); + + it("truncateAllTables waits for tracked fire-and-forget work before touching the tables", async () => { + // Regression: the phase processor's completion-email enqueue outlived its test and + // deadlocked against the next test's TRUNCATE. + let finish: () => void = () => undefined; + trackBackgroundWork( + new Promise(resolve => { + finish = resolve; + }) + ); + + let truncated = false; + const truncating = truncateAllTables().then(() => { + truncated = true; + }); + await new Promise(resolve => setTimeout(resolve, 50)); + expect(truncated).toBe(false); + + finish(); + await truncating; + expect(truncated).toBe(true); + }); }); diff --git a/docs/operations-testing.md b/docs/operations-testing.md index 4ea5fd321e..4756b2b9fd 100644 --- a/docs/operations-testing.md +++ b/docs/operations-testing.md @@ -134,6 +134,11 @@ migrated with the production Umzug migrations and truncated between tests. Seque mocked in integration tests — transactionality (quote consumption, processing locks) is part of what we test. Unit tests may still mock models where the DB is incidental. +Fire-and-forget app work that outlives a test (today: the phase processor's ramp-completion +email enqueue) is routed through `test-utils/background-work.ts` by the fake world, and +`truncateAllTables` waits for it to settle first; a TRUNCATE issued under an in-flight INSERT +deadlocks in Postgres and fails the next test at random. + ### Factories `apps/api/src/test-utils/factories.ts` builds `User`, `Partner`, `ApiCredential`, `QuoteTicket`, From 794eaff2326a05fc367941d38d7e1e299b82818c Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Tue, 8 Sep 2026 11:11:27 +0200 Subject: [PATCH 09/13] fix(api): explain required network query parameter --- apps/api/src/config/cryptocurrencies.config.test.ts | 12 ++++++++++-- apps/api/src/config/cryptocurrencies.config.ts | 5 ++++- apps/api/src/tests/http-surface.invariants.test.ts | 3 +++ 3 files changed, 17 insertions(+), 3 deletions(-) diff --git a/apps/api/src/config/cryptocurrencies.config.test.ts b/apps/api/src/config/cryptocurrencies.config.test.ts index 426581d22b..07831dc44f 100644 --- a/apps/api/src/config/cryptocurrencies.config.test.ts +++ b/apps/api/src/config/cryptocurrencies.config.test.ts @@ -107,8 +107,16 @@ describe("getSupportedCryptocurrencies", () => { expect(result.every(token => token.rampTypes.length === 0)).toBe(true); }); - it("rejects a missing or unsupported network", () => { - expect(() => getSupportedCryptocurrencies(undefined)).toThrow(APIError); + it("explains how to pass the required network query parameter", () => { + expect(() => getSupportedCryptocurrencies(undefined)).toThrow( + "Missing required query parameter 'network'. Example: /v1/supported-cryptocurrencies?network=ethereum" + ); + }); + + it("lists supported networks when an invalid network is supplied", () => { expect(() => getSupportedCryptocurrencies(Networks.Pendulum)).toThrow(APIError); + expect(() => getSupportedCryptocurrencies(Networks.Pendulum)).toThrow( + "Invalid network: 'pendulum'. Supported networks are:" + ); }); }); diff --git a/apps/api/src/config/cryptocurrencies.config.ts b/apps/api/src/config/cryptocurrencies.config.ts index 409b9085a7..0f38295532 100644 --- a/apps/api/src/config/cryptocurrencies.config.ts +++ b/apps/api/src/config/cryptocurrencies.config.ts @@ -21,7 +21,10 @@ const supportedNetworks = Object.values(Networks) const throwInvalidNetworkError = (network: string | undefined): never => { throw new APIError({ - message: `Invalid network: '${network}'. Supported networks are: '${supportedNetworks}'` + message: + network === undefined + ? "Missing required query parameter 'network'. Example: /v1/supported-cryptocurrencies?network=ethereum" + : `Invalid network: '${network}'. Supported networks are: '${supportedNetworks}'` }); }; diff --git a/apps/api/src/tests/http-surface.invariants.test.ts b/apps/api/src/tests/http-surface.invariants.test.ts index 72c007ade6..a138cc73b9 100644 --- a/apps/api/src/tests/http-surface.invariants.test.ts +++ b/apps/api/src/tests/http-surface.invariants.test.ts @@ -359,6 +359,9 @@ describe("HTTP surface: auth flow, webhooks, history, public routes", () => { // The network filter is required: routed token lists are per-network. const cryptoUnfiltered = await requestJson("/v1/supported-cryptocurrencies"); expect(cryptoUnfiltered.status).toBe(400); + expect(cryptoUnfiltered.body.error).toBe( + "Missing required query parameter 'network'. Example: /v1/supported-cryptocurrencies?network=ethereum" + ); const countries = await requestJson("/v1/supported-countries"); expect(countries.status).toBe(200); From 4ec55eb0360ee1fd3c68bfd8dbea3b193de6e2bb Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Tue, 8 Sep 2026 11:31:35 +0200 Subject: [PATCH 10/13] docs(api): make managed profiles an explicit opt-in branch Agents reading the docs inferred that a custom UI or API-driven onboarding requires managed profiles. State the standalone-profile alternative where that inference happens. --- docs/api/apidog/page-manifest.json | 2 +- docs/api/pages/01-overview.md | 2 +- docs/api/pages/12-ai-agent-integration.md | 13 ++++++++++--- docs/api/pages/14-managed-profiles.md | 2 ++ docs/api/pages/15-custom-ui-integration.md | 11 +++++++++++ 5 files changed, 25 insertions(+), 5 deletions(-) diff --git a/docs/api/apidog/page-manifest.json b/docs/api/apidog/page-manifest.json index 47f48b95f5..216226e6f8 100644 --- a/docs/api/apidog/page-manifest.json +++ b/docs/api/apidog/page-manifest.json @@ -342,7 +342,7 @@ "wallet network validation", "ramp status polling" ], - "metaDescription": "Build a resilient custom Vortex UI with safe quote handling, browser token refresh, wallet checks, resumable payments, start reconciliation, and status polling.", + "metaDescription": "Build a resilient custom Vortex UI: standalone or managed profiles, safe quote handling, browser token refresh, wallet checks, resumable payments, and status polling.", "metaTitle": "Custom UI Integration Best Practices — Vortex" }, "slug": "custom-ui-integration", diff --git a/docs/api/pages/01-overview.md b/docs/api/pages/01-overview.md index 65a66ae9c6..4fd801d70a 100644 --- a/docs/api/pages/01-overview.md +++ b/docs/api/pages/01-overview.md @@ -46,7 +46,7 @@ This boundary is non-negotiable: if ephemeral secrets are lost while a ramp is i - Building your own interface: [Custom UI Integration](https://api-docs.vortexfinance.co/custom-ui-integration). - Building for a non-Node stack: [AI Agent Integration](https://api-docs.vortexfinance.co/ai-agent-integration). - Hosted checkout: [Widget Integration](https://api-docs.vortexfinance.co/widget-integration). -- Onboarding and ramping for your own customers: [Managed Profiles](https://api-docs.vortexfinance.co/managed-profiles). +- Platform-controlled headless customer profiles: [Managed Profiles](https://api-docs.vortexfinance.co/managed-profiles). ## Terms diff --git a/docs/api/pages/12-ai-agent-integration.md b/docs/api/pages/12-ai-agent-integration.md index 1ad2e32468..81e2ce65fc 100644 --- a/docs/api/pages/12-ai-agent-integration.md +++ b/docs/api/pages/12-ai-agent-integration.md @@ -213,7 +213,14 @@ See also [Production Checklist](https://api-docs.vortexfinance.co/production-che ## H. API-Driven KYC And KYB Onboarding -Where a corridor supports it, onboarding runs through the API without any Vortex UI. The contract has three parts: +Where a corridor supports it, onboarding runs through the API without any Vortex UI. + +Two profile models can run these flows, and the choice is independent of how you build your UI: + +- **Standalone profile (default).** The customer owns a normal Vortex profile and authenticates with their own Supabase session or API credential. No manager status is required, and this is the only model the EUR corridor supports. +- **Managed profile (optional).** Your platform creates and controls a headless child profile that has no Vortex login, OTP, or claiming lifecycle. Vortex must enable your profile as a manager first — see H.1. + +Neither model changes the three-part contract below: 1. **Discover the flow.** @@ -234,8 +241,8 @@ Non-negotiable rules for an agent implementing these flows: - **BR individuals: the verification method locks permanently.** The first standard document, liveness artifact, submission, or status read commits the account to the `standard` method; a Sumsub token import commits it to `sumsub_share_token` and blocks the standard path. Decide the method before touching either flow. - **Pin `requirementsVersion`** alongside the docs commit and SDK version you already record (Section A), and re-run discovery when it changes. -### H.1 Onboarding For Your Own Customers (Managed Profiles) +### H.1 Optional: Platform-Controlled Onboarding With Managed Profiles -Platforms that onboard their own users headlessly — no Vortex login or UI for the end customer — create **managed child profiles** and run every onboarding and ramp operation on the child's behalf, either with the manager credential plus `X-Managed-Profile-Id` or with child-owned credentials. All discovery-published onboarding steps and the full ramp lifecycle accept this delegation, subject to the manager's corridor policy; webhooks do not (poll instead). The walkthrough with examples is [Managed Profiles](https://api-docs.vortexfinance.co/managed-profiles); the authoritative contract is in [Authentication And API Keys](https://api-docs.vortexfinance.co/authentication-and-partner-keys). Agents implementing this pattern must key their idempotency and state on the manager-scoped `externalSubjectId` → `profileId` mapping, and must complete a BR child's Sumsub token import **before** any status read for that child (the method-lock rule above). +If your platform must create and control headless customers — no Vortex login or UI for the end customer, and no later claiming flow — use **managed child profiles** and run every onboarding and ramp operation on the child's behalf, either with the manager credential plus `X-Managed-Profile-Id` or with child-owned credentials. All discovery-published onboarding steps and the full ramp lifecycle accept this delegation, subject to the manager's corridor policy; webhooks do not (poll instead). The walkthrough with examples is [Managed Profiles](https://api-docs.vortexfinance.co/managed-profiles); the authoritative contract is in [Authentication And API Keys](https://api-docs.vortexfinance.co/authentication-and-partner-keys). Agents implementing this pattern must key their idempotency and state on the manager-scoped `externalSubjectId` → `profileId` mapping, and must complete a BR child's Sumsub token import **before** any status read for that child (the method-lock rule above). --- diff --git a/docs/api/pages/14-managed-profiles.md b/docs/api/pages/14-managed-profiles.md index 0b236ccb92..225a45469f 100644 --- a/docs/api/pages/14-managed-profiles.md +++ b/docs/api/pages/14-managed-profiles.md @@ -4,6 +4,8 @@ Managed profiles let a platform onboard and operate Vortex accounts for its own Use managed profiles when interactive signup is unavailable or undesirable — a B2B platform embedding cross-border payouts, a fintech onboarding its verified user base, or an operations backend running ramps for corporate sub-accounts. Provision one genuine child per real individual or business; never share one child between customers. +Managed profiles are optional. If each customer can own a normal Vortex profile and authenticate with their own session or API credential, use a standalone profile instead: API-driven onboarding, a custom UI, and the full ramp lifecycle all work without manager status, and the EUR corridor requires a standalone profile. Manager status is only for platforms that must own the customer's Vortex identity. + This page is the integration walkthrough. The exact authorization contract — every check Vortex performs, edge-case semantics, and error codes — lives in [Authentication And API Keys](https://api-docs.vortexfinance.co/authentication-and-partner-keys) and is authoritative where the two overlap. ## Prerequisites diff --git a/docs/api/pages/15-custom-ui-integration.md b/docs/api/pages/15-custom-ui-integration.md index 8f04f5cd14..22cb65baaa 100644 --- a/docs/api/pages/15-custom-ui-integration.md +++ b/docs/api/pages/15-custom-ui-integration.md @@ -4,6 +4,17 @@ A custom Vortex UI must coordinate asynchronous quotes, authentication, wallets, The practices below complement the [SDK Quick Start](https://api-docs.vortexfinance.co/quick-start-with-the-sdk) and [Ramp Lifecycle](https://api-docs.vortexfinance.co/ramp-lifecycle). They apply to any custom UI even when the framework, wallet library, or fiat corridor differs. +## Decide Your Profile Model First + +A custom UI and API-driven onboarding do **not** require managed profiles. The interface you build and the profile-ownership model are independent choices. + +| Your customers | Model | +|---|---| +| Own their Vortex identity and authenticate with their own Supabase session or API credential | **Standalone profile** — one normal Vortex profile per customer. No manager status required. | +| Must never have a Vortex login, because your platform creates and controls them | **[Managed Profiles](https://api-docs.vortexfinance.co/managed-profiles)** — headless child profiles with no login, OTP, or later claiming lifecycle. Vortex must enable your profile as a manager first. | + +Both models support the same custom UI, the same API-driven onboarding, and the same ramp lifecycle. Choose managed profiles only when your platform must own the customer's Vortex identity; the EUR corridor is bound to a verified login email and supports standalone profiles only. + ## Keep Quotes Bound To Current Input Amount fields often request a new quote while the user is still typing. Network responses can arrive out of order, so a slower response for an old amount must not replace the current quote. From 252cebf7a88ee55e7df17f75417c6882917d8321 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Tue, 8 Sep 2026 11:31:42 +0200 Subject: [PATCH 11/13] docs(api): document the browser origin allowlist and how to request it Browser integrations fail at the CORS preflight until Vortex adds their exact origin, and the docs never said how to get one added. --- docs/api/apidog/page-manifest.json | 4 ++-- docs/api/pages/01-overview.md | 2 +- docs/api/pages/02-quick-start-with-the-sdk.md | 2 +- docs/api/pages/03-authentication-and-partner-keys.md | 11 +++++++++++ docs/api/pages/08-widget-integration.md | 2 +- docs/api/pages/11-production-checklist.md | 1 + docs/api/pages/12-ai-agent-integration.md | 2 +- docs/api/pages/15-custom-ui-integration.md | 2 ++ 8 files changed, 20 insertions(+), 6 deletions(-) diff --git a/docs/api/apidog/page-manifest.json b/docs/api/apidog/page-manifest.json index 216226e6f8..5e729e9d4c 100644 --- a/docs/api/apidog/page-manifest.json +++ b/docs/api/apidog/page-manifest.json @@ -138,7 +138,7 @@ "OTP sign-in", "crypto ramp authentication" ], - "metaDescription": "How Vortex authenticates clients with pk_*/sk_* keys or Supabase sessions, including managed-child delegation and secure BRL KYC token import.", + "metaDescription": "How Vortex authenticates clients with pk_*/sk_* keys or Supabase sessions, including browser origin approval, managed-child delegation, and BRL KYC token import.", "metaTitle": "Authentication And API Keys — Vortex API" }, "slug": "authentication-and-partner-keys", @@ -342,7 +342,7 @@ "wallet network validation", "ramp status polling" ], - "metaDescription": "Build a resilient custom Vortex UI: standalone or managed profiles, safe quote handling, browser token refresh, wallet checks, resumable payments, and status polling.", + "metaDescription": "Build a resilient custom Vortex UI: standalone vs managed profiles, browser origin approval, safe quote handling, token refresh, resumable payments, and status polling.", "metaTitle": "Custom UI Integration Best Practices — Vortex" }, "slug": "custom-ui-integration", diff --git a/docs/api/pages/01-overview.md b/docs/api/pages/01-overview.md index 4fd801d70a..5541a0a7ce 100644 --- a/docs/api/pages/01-overview.md +++ b/docs/api/pages/01-overview.md @@ -32,7 +32,7 @@ The SDK wraps steps 2, 3, and parts of 5 for supported flows. Direct API integra | Browser / mobile / hosted checkout | Vortex Widget | | Any other language or runtime | Direct API integration following the SDK's behavior | -The SDK publishes Node.js and modern-browser builds. Server integrations authenticate with a user-linked `sk_*`; browser integrations must use a renewable Supabase Bearer session through `accessTokenProvider` and must never embed an `sk_*`. Browser origins must be explicitly approved in the API's `BROWSER_SDK_ORIGINS` configuration. The hosted Widget remains the lowest-effort production UI. +The SDK publishes Node.js and modern-browser builds. Server integrations authenticate with a user-linked `sk_*`; browser integrations must use a renewable Supabase Bearer session through `accessTokenProvider` and must never embed an `sk_*`. Browser origins must be approved by Vortex before any browser request reaches the API; email to have yours added (see [Authentication And API Keys](https://api-docs.vortexfinance.co/authentication-and-partner-keys)). The hosted Widget remains the lowest-effort production UI. ## Custody Model diff --git a/docs/api/pages/02-quick-start-with-the-sdk.md b/docs/api/pages/02-quick-start-with-the-sdk.md index 59c76a430d..325ad2734d 100644 --- a/docs/api/pages/02-quick-start-with-the-sdk.md +++ b/docs/api/pages/02-quick-start-with-the-sdk.md @@ -46,7 +46,7 @@ const sdk = new VortexSdk({ }); ``` -The configured browser origin must be present in the API deployment's `BROWSER_SDK_ORIGINS` allowlist. The browser build rejects `secretKey` at construction. +Your browser origin must be approved by Vortex before any request from it reaches the API; email to have it added, and see [Authentication And API Keys](https://api-docs.vortexfinance.co/authentication-and-partner-keys). The browser build rejects `secretKey` at construction. You can check the authenticated subject's sanitized corridor readiness without exposing exact limits or profile data: diff --git a/docs/api/pages/03-authentication-and-partner-keys.md b/docs/api/pages/03-authentication-and-partner-keys.md index 5b592be9b0..fcccd6c748 100644 --- a/docs/api/pages/03-authentication-and-partner-keys.md +++ b/docs/api/pages/03-authentication-and-partner-keys.md @@ -23,6 +23,17 @@ Both values share one immutable credential ID, subject profile, optional partner `GET /v1/ramp-info` requires `X-Public-Key` or `X-API-Key`; a Supabase Bearer session does not authorize this endpoint. It returns only per-corridor `kycStatus`, `canBuy`, and `canSell`. A manager secret may supply `X-Managed-Profile-Id`; public keys may not. It accepts no body/query profile or user selector and does not expose PII, provider identifiers, KYC failure reasons, account details, ramp history, or exact limits. +## Browser Origin Approval + +Vortex accepts browser requests only from origins it has explicitly approved. Every request a browser makes to the API — the browser build of `@vortexfi/sdk`, a `fetch` from your own front end, or `POST /v1/session/create` called from page code — is refused by CORS unless your exact origin is on the allowlist. Server-to-server calls are unaffected. + +To have an origin approved, email with: + +- each origin exactly as the browser sends it, including scheme and any non-default port (for example `https://app.example.com` or `https://checkout.example.com:8443`); +- whether it is for sandbox, production, or both. + +Entries are exact-match and wildcards are never accepted, so every subdomain, preview domain, and development host that calls the API must be listed individually. Request approval before you integrate: without it every browser call fails at the CORS preflight, which surfaces as a browser network error rather than a Vortex error code. + ## Subject And Partner Binding Every credential authenticates exactly one Vortex profile. A profile-managed credential has no partner and is managed by its signed-in subject. A partner-managed credential has an optional partner attribution but still authenticates only its bound profile. diff --git a/docs/api/pages/08-widget-integration.md b/docs/api/pages/08-widget-integration.md index 7f014e7372..eaf2f1a130 100644 --- a/docs/api/pages/08-widget-integration.md +++ b/docs/api/pages/08-widget-integration.md @@ -153,7 +153,7 @@ Webhook management uses the corresponding server-side secret through `X-API-Key` | Scenario | Use | |---|---| | Browser / mobile app, hosted UX | Widget | -| Custom browser UX on an approved origin, accepting prototype localStorage custody | `@vortexfi/sdk` with Bearer auth | +| Custom browser UX on a [Vortex-approved origin](https://api-docs.vortexfinance.co/authentication-and-partner-keys), accepting prototype localStorage custody | `@vortexfi/sdk` with Bearer auth | | Custom Node.js UX | `@vortexfi/sdk` with a secret credential | | Trusted Python backend | `vortex-sdk-python` | | Other backend stacks | Direct API ([AI Agent Integration](https://api-docs.vortexfinance.co/ai-agent-integration)) | diff --git a/docs/api/pages/11-production-checklist.md b/docs/api/pages/11-production-checklist.md index e8f113e1f4..2459aa2281 100644 --- a/docs/api/pages/11-production-checklist.md +++ b/docs/api/pages/11-production-checklist.md @@ -15,6 +15,7 @@ Before going live, verify the following: - Test failed, delayed, and retried ramp states in sandbox. - Define a support process for users who close the app before a ramp finishes. - Rotate partner keys if they are exposed or no longer needed. +- Confirm every browser origin that calls the API, in sandbox and production, is on the Vortex origin allowlist; request additions at . - For BRL flows, confirm that your onboarding path produces an eligible user before starting the ramp. - Confirm your integration complies with the Vortex [Terms and Conditions](https://www.vortexfinance.co/en/terms-and-conditions) and [Privacy Policy](https://www.vortexfinance.co/en/privacy-policy). diff --git a/docs/api/pages/12-ai-agent-integration.md b/docs/api/pages/12-ai-agent-integration.md index 81e2ce65fc..7e6454d05b 100644 --- a/docs/api/pages/12-ai-agent-integration.md +++ b/docs/api/pages/12-ai-agent-integration.md @@ -26,7 +26,7 @@ Every path supports all live fiat corridors: BRL (PIX), EUR (SEPA), USD (ACH), M Ramping requires an onboarded (KYC/KYB-approved) user. Onboarding is a separate, corridor-specific flow that most corridors also expose through the API — see Section H before assuming the app or Widget is required. -Do not expose an `sk_*` or reimplement signing against the raw ramp API in a browser. Use the browser build of `@vortexfi/sdk` with Bearer authentication on an approved origin, or use the Widget. Browser SDK users explicitly accept that ephemeral secrets are generated in browser memory and backed up to plaintext same-origin localStorage by default. +Do not expose an `sk_*` or reimplement signing against the raw ramp API in a browser. An approved origin means Vortex has added your exact browser origin to its allowlist; request it at before you integrate, because unapproved origins fail at the CORS preflight. Use the browser build of `@vortexfi/sdk` with Bearer authentication on an approved origin, or use the Widget. Browser SDK users explicitly accept that ephemeral secrets are generated in browser memory and backed up to plaintext same-origin localStorage by default. ## C. Python (`vortex-sdk-python`) diff --git a/docs/api/pages/15-custom-ui-integration.md b/docs/api/pages/15-custom-ui-integration.md index 22cb65baaa..b807514f98 100644 --- a/docs/api/pages/15-custom-ui-integration.md +++ b/docs/api/pages/15-custom-ui-integration.md @@ -15,6 +15,8 @@ A custom UI and API-driven onboarding do **not** require managed profiles. The i Both models support the same custom UI, the same API-driven onboarding, and the same ramp lifecycle. Choose managed profiles only when your platform must own the customer's Vortex identity; the EUR corridor is bound to a verified login email and supports standalone profiles only. +Browser-based UIs also need their origin approved by Vortex before any request reaches the API — see [Authentication And API Keys](https://api-docs.vortexfinance.co/authentication-and-partner-keys). + ## Keep Quotes Bound To Current Input Amount fields often request a new quote while the user is still typing. Network responses can arrive out of order, so a slower response for an old amount must not replace the current quote. From 2523e718299070c7b5f7af25a867dcd32a023b1f Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Tue, 8 Sep 2026 12:37:39 +0200 Subject: [PATCH 12/13] fix(api): harden native offramp routing --- .../brl-offramp-base.source-txs.test.ts | 23 ++++++- .../phases/blocks/core/squidrouter.ts | 36 +++++++++-- .../phases/evm-offramp-source/transactions.ts | 37 ++++++----- .../brl-offramp-crosschain.scenario.test.ts | 64 ++++++++++++++++++- .../03-ramp-engine/fee-integrity.md | 5 +- 5 files changed, 137 insertions(+), 28 deletions(-) diff --git a/apps/api/src/api/services/phases/blocks/__tests__/brl-offramp-base.source-txs.test.ts b/apps/api/src/api/services/phases/blocks/__tests__/brl-offramp-base.source-txs.test.ts index 5cd01805e9..896eee2e10 100644 --- a/apps/api/src/api/services/phases/blocks/__tests__/brl-offramp-base.source-txs.test.ts +++ b/apps/api/src/api/services/phases/blocks/__tests__/brl-offramp-base.source-txs.test.ts @@ -1,6 +1,13 @@ import { afterAll, describe, expect, it, mock } from "bun:test"; import * as sharedNamespace from "@vortexfi/shared"; -import { EphemeralAccountType, type EvmNetworks, EvmToken, evmTokenConfig, Networks } from "@vortexfi/shared"; +import { + EphemeralAccountType, + type EvmNetworks, + EvmToken, + evmTokenConfig, + NATIVE_TOKEN_ADDRESS, + Networks +} from "@vortexfi/shared"; import type { PrepareCtx } from "../core/types"; import type { EvmOfframpSourceRegistrationFacts } from "../phases/evm-offramp-source/registration"; import type { EvmOfframpSourceMetadata } from "../phases/evm-offramp-source/simulation"; @@ -83,4 +90,18 @@ describe("EVM offramp source transaction variants", () => { toNetwork: Networks.Base }); }); + + it("uses only the Squid swap for a native source token", async () => { + const prepared = await prepareEvmOfframpSourceTxs(context(Networks.Ethereum, EvmToken.ETH)); + expect(prepared.intents.map(intent => intent.phase)).toEqual(["squidRouterSwap"]); + expect(prepared.intents[0]?.signer).toBe(USER); + expect(prepared.intents[0]?.network).toBe(Networks.Ethereum); + expect(routeRequests.at(-1)).toMatchObject({ + destinationAddress: EPHEMERAL, + fromAddress: USER, + fromNetwork: Networks.Ethereum, + toNetwork: Networks.Base + }); + expect(String(routeRequests.at(-1)?.fromToken).toLowerCase()).toBe(NATIVE_TOKEN_ADDRESS.toLowerCase()); + }); }); diff --git a/apps/api/src/api/services/phases/blocks/core/squidrouter.ts b/apps/api/src/api/services/phases/blocks/core/squidrouter.ts index dd3cfbb803..ec33762d47 100644 --- a/apps/api/src/api/services/phases/blocks/core/squidrouter.ts +++ b/apps/api/src/api/services/phases/blocks/core/squidrouter.ts @@ -2,10 +2,12 @@ import { DestinationType, EvmToken, EvmTokenDetails, + getEvmTokensForNetwork, getNetworkFromDestination, getOnChainTokenDetails, getRoute, isEvmTokenDetails, + isNetworkEVM, NATIVE_TOKEN_ADDRESS, Networks, OnChainToken, @@ -52,6 +54,12 @@ export interface EvmBridgeResult { outputTokenDecimals: number; } +const STATIC_NATIVE_TOKEN_PRICE_FALLBACKS_USD: Readonly>> = { + ethereum: 2500, + moonbeam: 0.08, + "polygon-ecosystem-token": 0.5 +}; + /** * Helper to get token details for final output currency on EVM destination */ @@ -108,8 +116,19 @@ function getNativeTokenCoingeckoId(network: Networks): string { case Networks.Moonbeam: return "moonbeam"; default: - return "moonbeam"; + throw new Error(`Unsupported Squid Router source network: ${network}`); + } +} + +function getDiscoveredNativeTokenPriceUSD(network: Networks): number | undefined { + if (!isNetworkEVM(network)) { + return undefined; } + const nativeToken = getEvmTokensForNetwork(network).find( + token => token.isNative || token.erc20AddressSourceChain.toLowerCase() === NATIVE_TOKEN_ADDRESS.toLowerCase() + ); + const priceUSD = nativeToken?.usdPrice; + return priceUSD !== undefined && Number.isFinite(priceUSD) && priceUSD > 0 ? priceUSD : undefined; } async function calculateSquidrouterNetworkFee( @@ -132,11 +151,16 @@ async function calculateSquidrouterNetworkFee( logger.debug(`Network fee calculated using ${nativeTokenId} price: $${nativePriceUSD}, fee: $${squidFeeUSD}`); return squidFeeUSD; } catch (error) { - logger.error( - `Failed to get ${nativeTokenId} price, using fallback: ${error instanceof Error ? error.message : "Unknown error"}` - ); - // Conservative per-chain fallback so we never silently report ~$0 for ETH-priced chains. - const fallbackPriceUSD = nativeTokenId === "ethereum" ? 2500 : nativeTokenId === "polygon-ecosystem-token" ? 0.5 : 0.08; + logger.error(`Failed to get ${nativeTokenId} price: ${error instanceof Error ? error.message : "Unknown error"}`); + const discoveredPriceUSD = getDiscoveredNativeTokenPriceUSD(fromNetwork); + // Static fallbacks cover the established chains when token discovery is unavailable. Newly + // discovered chains must bring their own validated native-token price or fail the quote. + const staticFallbackPriceUSD = STATIC_NATIVE_TOKEN_PRICE_FALLBACKS_USD[nativeTokenId]; + const fallbackPriceUSD = discoveredPriceUSD ?? staticFallbackPriceUSD; + if (fallbackPriceUSD === undefined) { + logger.error(`No validated ${nativeTokenId} fallback price is available`); + throw error; + } const squidFeeUSD = squidRouterSwapValue.mul(fallbackPriceUSD).toFixed(6); logger.warn(`Using fallback ${nativeTokenId} price: $${fallbackPriceUSD}, fee: $${squidFeeUSD}`); return squidFeeUSD; diff --git a/apps/api/src/api/services/phases/blocks/phases/evm-offramp-source/transactions.ts b/apps/api/src/api/services/phases/blocks/phases/evm-offramp-source/transactions.ts index 2f4c4c0b61..067fe4fee1 100644 --- a/apps/api/src/api/services/phases/blocks/phases/evm-offramp-source/transactions.ts +++ b/apps/api/src/api/services/phases/blocks/phases/evm-offramp-source/transactions.ts @@ -4,12 +4,13 @@ import { EvmToken, EvmTransactionData, evmTokenConfig, + NATIVE_TOKEN_ADDRESS, Networks } from "@vortexfi/shared"; import { encodeFunctionData, erc20Abi } from "viem"; import { requireAccount } from "../../core/accounts"; import { encodeEvmTransactionData } from "../../core/evm-transactions"; -import type { PrepareCtx, PreparedPhaseTxs } from "../../core/types"; +import type { PrepareCtx, PreparedPhaseTxs, TxIntent } from "../../core/types"; import type { EvmOfframpSourceRegistrationFacts } from "./registration"; import type { EvmOfframpSourceMetadata } from "./simulation"; @@ -58,23 +59,25 @@ export async function prepareEvmOfframpSourceTxs( toNetwork: Networks.Base, toToken: baseUsdc }); + const swapIntent: TxIntent = { + lane: "main", + network: metadata.fromNetwork, + phase: "squidRouterSwap", + signer: facts.userAddress, + txData: encodeEvmTransactionData(swapData) as EvmTransactionData + }; + const intents: TxIntent[] = [swapIntent]; + if (metadata.fromToken.toLowerCase() !== NATIVE_TOKEN_ADDRESS.toLowerCase()) { + intents.unshift({ + lane: "main", + network: metadata.fromNetwork, + phase: "squidRouterApprove", + signer: facts.userAddress, + txData: encodeEvmTransactionData(approveData) as EvmTransactionData + }); + } return { - intents: [ - { - lane: "main", - network: metadata.fromNetwork, - phase: "squidRouterApprove", - signer: facts.userAddress, - txData: encodeEvmTransactionData(approveData) as EvmTransactionData - }, - { - lane: "main", - network: metadata.fromNetwork, - phase: "squidRouterSwap", - signer: facts.userAddress, - txData: encodeEvmTransactionData(swapData) as EvmTransactionData - } - ], + intents, state: { userAddress: facts.userAddress } }; } diff --git a/apps/api/src/tests/corridors/brl-offramp-crosschain.scenario.test.ts b/apps/api/src/tests/corridors/brl-offramp-crosschain.scenario.test.ts index e2581d1cb3..7f65870441 100644 --- a/apps/api/src/tests/corridors/brl-offramp-crosschain.scenario.test.ts +++ b/apps/api/src/tests/corridors/brl-offramp-crosschain.scenario.test.ts @@ -7,6 +7,7 @@ import { EvmToken, evmTokenConfig, FiatToken, + NATIVE_TOKEN_ADDRESS, Networks, PRESIGNED_EVM_FEE_MULTIPLIER, RampDirection, @@ -485,13 +486,13 @@ describe("BRL offramp cross-chain corridor (USDC on Polygon → Base → pix via 30000 ); - async function requestSellQuote(inputCurrency: string, inputAmount: string) { + async function requestSellQuote(inputCurrency: string, inputAmount: string, network: Networks = Networks.Ethereum) { return app.request("/v1/quotes", { body: JSON.stringify({ - from: Networks.Ethereum, + from: network, inputAmount, inputCurrency, - network: Networks.Ethereum, + network, outputCurrency: FiatToken.BRL, rampType: RampDirection.SELL, to: "pix" @@ -591,4 +592,61 @@ describe("BRL offramp cross-chain corridor (USDC on Polygon → Base → pix via Object.assign(world.squidRouter, { computeToAmount, computeToAmountUsd, transactionValueWei }); } }); + + for (const { network, priceUsd, symbol, tokenId } of [ + { network: Networks.Avalanche, priceUsd: 25, symbol: "AVAX", tokenId: "avalanche-2" }, + { network: Networks.BSC, priceUsd: 600, symbol: "BNB", tokenId: "binancecoin" } + ]) { + it(`native ${symbol} source: a price-feed outage uses the Squid-discovered native-token price`, async () => { + const realGetEvmTokensForNetwork = shared.getEvmTokensForNetwork; + const realGetOnChainTokenDetails = shared.getOnChainTokenDetails; + const nativeToken: EvmTokenDetails = { + assetSymbol: symbol, + decimals: 18, + erc20AddressSourceChain: NATIVE_TOKEN_ADDRESS, + isNative: true, + network, + pendulumRepresentative: requireToken(Networks.Base, EvmToken.USDC).pendulumRepresentative, + type: TokenType.Evm, + usdPrice: priceUsd + }; + mock.module("@vortexfi/shared", () => ({ + ...shared, + getEvmTokensForNetwork: (candidateNetwork: Networks, ...rest: unknown[]) => + candidateNetwork === network + ? [nativeToken] + : (realGetEvmTokensForNetwork as (...args: unknown[]) => unknown)(candidateNetwork, ...rest), + getOnChainTokenDetails: (candidateNetwork: Networks, token: string, ...rest: unknown[]) => + candidateNetwork === network && token === symbol + ? nativeToken + : (realGetOnChainTokenDetails as (...args: unknown[]) => unknown)(candidateNetwork, token, ...rest) + })); + + const routerFeeWei = 10_000_000_000_000_000n; + const savedPrice = world.prices.cryptoUsd[tokenId]; + const { transactionValueWei, computeToAmount, computeToAmountUsd } = world.squidRouter; + delete world.prices.cryptoUsd[tokenId]; + world.squidRouter.transactionValueWei = (parseUnits("1", 18) + routerFeeWei).toString(); + world.squidRouter.computeToAmount = () => "2500000000"; + world.squidRouter.computeToAmountUsd = () => "2500"; + + try { + const response = await requestSellQuote(symbol, "1", network); + expect(response.status).toBe(201); + const quote = (await response.json()) as { networkFeeUsd: string; outputAmount: string }; + expect(Number(quote.networkFeeUsd)).toBeCloseTo(Number(routerFeeWei) * 1e-18 * priceUsd, 6); + expect(Number(quote.outputAmount)).toBeGreaterThan(0); + } finally { + mock.module("@vortexfi/shared", () => ({ + ...shared, + getEvmTokensForNetwork: realGetEvmTokensForNetwork, + getOnChainTokenDetails: realGetOnChainTokenDetails + })); + if (savedPrice !== undefined) { + world.prices.cryptoUsd[tokenId] = savedPrice; + } + Object.assign(world.squidRouter, { computeToAmount, computeToAmountUsd, transactionValueWei }); + } + }); + } }); diff --git a/docs/security-spec/03-ramp-engine/fee-integrity.md b/docs/security-spec/03-ramp-engine/fee-integrity.md index a6ee343771..1cc55c0ba5 100644 --- a/docs/security-spec/03-ramp-engine/fee-integrity.md +++ b/docs/security-spec/03-ramp-engine/fee-integrity.md @@ -18,7 +18,10 @@ architecture and MUST NOT be used as evidence for current behavior. - Mykobo and Avenia fee blocks install their live provider fee; - routed blocks install the Squid network fee, priced from the route's native `value`; for a native source token (ETH, POL) the swapped principal is subtracted first, so - only the router fee is charged; + only the router fee is charged. If the primary price feed is unavailable, a + positive native-token USD price from Squid discovery may be used; a newly enabled + chain without a validated fallback price fails quote creation instead of borrowing + another chain's fallback; - direct/no-bridge routes preserve a zero network fee. 3. Quote finalization persists the resulting snapshot in `quote_tickets.metadata.fees`. After quote creation, fee amounts are immutable. From 125017682d164f8895ff21509e4381a4be64ba36 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Tue, 8 Sep 2026 12:37:48 +0200 Subject: [PATCH 13/13] docs(api): align cryptocurrency network schema --- docs/api/openapi/vortex.openapi.d.ts | 11 +++++++--- docs/api/openapi/vortex.openapi.json | 23 ++++++++++++++++--- docs/api/scripts/check-openapi.ts | 33 ++++++++++++++++++++++++++++ 3 files changed, 61 insertions(+), 6 deletions(-) diff --git a/docs/api/openapi/vortex.openapi.d.ts b/docs/api/openapi/vortex.openapi.d.ts index 00e35bed50..8988e6c3d1 100644 --- a/docs/api/openapi/vortex.openapi.d.ts +++ b/docs/api/openapi/vortex.openapi.d.ts @@ -1669,10 +1669,10 @@ export interface paths { parameters: { query: { /** - * @description Network to list cryptocurrencies for (required). Allowed values: `arbitrum`, `assethub`, `avalanche`, `base`, `bsc`, `ethereum`, `moonbeam`, `polygon` + * @description Network to list cryptocurrencies for (required). Allowed values: `arbitrum`, `assethub`, `avalanche`, `base`, `base-sepolia`, `bsc`, `ethereum`, `moonbeam`, `paseo`, `polygon`, `polygonAmoy` * @example ethereum */ - network: string; + network: components["schemas"]["SupportedCryptocurrencyNetwork"]; }; header?: never; path?: never; @@ -1692,7 +1692,7 @@ export interface paths { assetDecimals: number; /** @description Defined if network is Assethub. */ assetForeignAssetId?: string | null; - assetNetwork: components["schemas"]["Networks"]; + assetNetwork: components["schemas"]["SupportedCryptocurrencyNetwork"]; assetSymbol: string; /** @description Ramp directions at least one corridor supports for this token on its network. An empty list means the token is listed but not currently rampable, for example on networks without ramp support or for the retired AssetHub corridors. */ rampTypes: components["schemas"]["RampDirection"][]; @@ -3248,6 +3248,11 @@ export interface components { /** @constant */ success: true; }; + /** + * @description Networks accepted by the supported-cryptocurrencies endpoint. + * @enum {string} + */ + SupportedCryptocurrencyNetwork: "assethub" | "arbitrum" | "avalanche" | "base" | "base-sepolia" | "bsc" | "ethereum" | "moonbeam" | "paseo" | "polygon" | "polygonAmoy"; /** @enum {string} */ TaxIdType: "CPF" | "CNPJ"; TriggerOfframpRequest: { diff --git a/docs/api/openapi/vortex.openapi.json b/docs/api/openapi/vortex.openapi.json index 58ca344ca6..ced87f8ecb 100644 --- a/docs/api/openapi/vortex.openapi.json +++ b/docs/api/openapi/vortex.openapi.json @@ -3613,6 +3613,23 @@ "required": ["success"], "type": "object" }, + "SupportedCryptocurrencyNetwork": { + "description": "Networks accepted by the supported-cryptocurrencies endpoint.", + "enum": [ + "assethub", + "arbitrum", + "avalanche", + "base", + "base-sepolia", + "bsc", + "ethereum", + "moonbeam", + "paseo", + "polygon", + "polygonAmoy" + ], + "type": "string" + }, "TaxIdType": { "enum": ["CPF", "CNPJ"], "type": "string" @@ -10381,13 +10398,13 @@ "description": "Retrieve the cryptocurrencies the quote engine accepts on a network. EVM networks include routed tokens discovered from Squid Router in addition to the static token set; `rampTypes` lists the directions at least one corridor supports for the token.", "parameters": [ { - "description": "Network to list cryptocurrencies for (required). Allowed values: `arbitrum`, `assethub`, `avalanche`, `base`, `bsc`, `ethereum`, `moonbeam`, `polygon`", + "description": "Network to list cryptocurrencies for (required). Allowed values: `arbitrum`, `assethub`, `avalanche`, `base`, `base-sepolia`, `bsc`, `ethereum`, `moonbeam`, `paseo`, `polygon`, `polygonAmoy`", "example": "ethereum", "in": "query", "name": "network", "required": true, "schema": { - "type": "string" + "$ref": "#/components/schemas/SupportedCryptocurrencyNetwork" } } ], @@ -10412,7 +10429,7 @@ "type": ["string", "null"] }, "assetNetwork": { - "$ref": "#/components/schemas/Networks" + "$ref": "#/components/schemas/SupportedCryptocurrencyNetwork" }, "assetSymbol": { "type": "string" diff --git a/docs/api/scripts/check-openapi.ts b/docs/api/scripts/check-openapi.ts index 4025172083..ed6d3ac702 100644 --- a/docs/api/scripts/check-openapi.ts +++ b/docs/api/scripts/check-openapi.ts @@ -1,5 +1,6 @@ import { existsSync, readFileSync, writeFileSync } from "node:fs"; import { ONBOARDING_REQUIREMENTS } from "../../../packages/shared/src/endpoints/onboarding-requirements.endpoints"; +import { isNetworkAssetHub, isNetworkEVM, Networks } from "../../../packages/shared/src/helpers/networks"; const OPENAPI_FILE = "docs/api/openapi/vortex.openapi.json"; const GENERATED_TYPES_FILE = "docs/api/openapi/vortex.openapi.d.ts"; @@ -493,6 +494,38 @@ for (const [path, method, requiredStatuses] of MANAGED_PROFILE_OPERATIONS) { const createManagedProfile = operationAt("/v1/managed-profiles", "post"); const createManagedProfileResponses = createManagedProfile.responses as JsonObject; const schemas = ((openapi.components as JsonObject).schemas ?? {}) as JsonObject; +const supportedCryptocurrencyNetworkRef = "#/components/schemas/SupportedCryptocurrencyNetwork"; +const expectedSupportedCryptocurrencyNetworks = Object.values(Networks) + .filter(network => isNetworkEVM(network) || isNetworkAssetHub(network)) + .sort(); +const supportedCryptocurrencyNetworkSchema = schemas.SupportedCryptocurrencyNetwork as JsonObject; +const documentedSupportedCryptocurrencyNetworks = Array.isArray(supportedCryptocurrencyNetworkSchema?.enum) + ? [...supportedCryptocurrencyNetworkSchema.enum].sort() + : []; +const supportedCryptocurrencies = operationAt("/v1/supported-cryptocurrencies", "get"); +const supportedCryptocurrencyParameters = Array.isArray(supportedCryptocurrencies.parameters) + ? supportedCryptocurrencies.parameters + : []; +const supportedCryptocurrencyNetworkParameter = supportedCryptocurrencyParameters.find( + parameter => + parameter && + typeof parameter === "object" && + (parameter as JsonObject).in === "query" && + (parameter as JsonObject).name === "network" +) as JsonObject | undefined; +const supportedCryptocurrencyResponseNetworkSchema = valueAtPointer( + openapi, + "#/paths/~1v1~1supported-cryptocurrencies/get/responses/200/content/application~1json/schema/properties/cryptocurrencies/items/properties/assetNetwork" +) as JsonObject | undefined; +if ( + JSON.stringify(documentedSupportedCryptocurrencyNetworks) !== JSON.stringify(expectedSupportedCryptocurrencyNetworks) || + (supportedCryptocurrencyNetworkParameter?.schema as JsonObject | undefined)?.$ref !== supportedCryptocurrencyNetworkRef || + supportedCryptocurrencyResponseNetworkSchema?.$ref !== supportedCryptocurrencyNetworkRef +) { + throw new Error( + "GET /v1/supported-cryptocurrencies must document every runtime EVM/AssetHub network through its endpoint-specific schema." + ); +} if ( JSON.stringify(createManagedProfile.requestBody).includes("#/components/schemas/CreateManagedProfileRequest") === false || JSON.stringify(createManagedProfileResponses["200"]).includes("#/components/schemas/ManagedProfileResponse") === false ||