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 5cd01805e..896eee2e1 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/__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 000000000..c5d077f0b --- /dev/null +++ b/apps/api/src/api/services/phases/blocks/__tests__/catalog-routed-offramp.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from "bun:test"; +import { EvmToken, FiatToken, mapFiatToDestination, Networks, RampDirection } from "@vortexfi/shared"; +import { resolveBlockFlow } from "../flows/catalog"; + +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", () => { + // 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 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/helpers.ts b/apps/api/src/api/services/phases/blocks/core/helpers.ts index 06a2ef1d0..5f97350c0 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/phases/blocks/core/io.ts b/apps/api/src/api/services/phases/blocks/core/io.ts index 0e8e58b04..ca584e0dd 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/core/squidrouter.ts b/apps/api/src/api/services/phases/blocks/core/squidrouter.ts index 4c8fdd731..ec33762d4 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,13 @@ import { DestinationType, EvmToken, EvmTokenDetails, + getEvmTokensForNetwork, getNetworkFromDestination, getOnChainTokenDetails, getRoute, isEvmTokenDetails, + isNetworkEVM, + NATIVE_TOKEN_ADDRESS, Networks, OnChainToken, parseContractBalanceResponse, @@ -51,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 */ @@ -107,15 +116,33 @@ 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( 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 { @@ -124,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; @@ -198,7 +230,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/api/services/phases/blocks/flows/catalog.ts b/apps/api/src/api/services/phases/blocks/flows/catalog.ts index e42c6bd25..34ecc5f25 100644 --- a/apps/api/src/api/services/phases/blocks/flows/catalog.ts +++ b/apps/api/src/api/services/phases/blocks/flows/catalog.ts @@ -6,8 +6,8 @@ import { FiatToken, getNetworkFromDestination, isDomesticToken, - isEvmToken, isNetworkEVM, + isOnChainToken, mapFiatToDestination, Networks, RampDirection @@ -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,21 @@ const flowDefinitions: FlowDefinition[] = [ request.to === EPaymentMethod.SEPA && network !== undefined && isNetworkEVM(network) && - isEvmToken(request.inputCurrency) && - evmTokenConfig[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) ); } }, { 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 +122,11 @@ const flowDefinitions: FlowDefinition[] = [ request.outputCurrency === FiatToken.BRL && network !== undefined && isNetworkEVM(network) && - isEvmToken(request.inputCurrency) && - evmTokenConfig[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) ); } }, @@ -141,7 +147,7 @@ const flowDefinitions: FlowDefinition[] = [ request.to === mapFiatToDestination(request.outputCurrency as FiatToken) && network !== undefined && isNetworkEVM(network) && - evmTokenConfig[network][request.inputCurrency as EvmToken] !== undefined + isOnChainToken(request.inputCurrency) ); } }, 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 2f4c4c0b6..067fe4fee 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/api/services/quote/index.ts b/apps/api/src/api/services/quote/index.ts index f4d38c1b6..9dd1b9d39 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 new file mode 100644 index 000000000..07831dc44 --- /dev/null +++ b/apps/api/src/config/cryptocurrencies.config.test.ts @@ -0,0 +1,122 @@ +import { describe, expect, it } from "bun:test"; +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; +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>>; + +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); + 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: BOTH + }); + expect(bySymbol.USDC.rampTypes).toEqual(BOTH); + }); + + 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() + ); + }); + + 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); + expect(result.map(token => token.assetSymbol).sort()).toEqual(["DOT", "USDC", "USDT"]); + expect(result.every(token => token.rampTypes.length === 0)).toBe(true); + }); + + 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 a1da1f31d..0f3829553 100644 --- a/apps/api/src/config/cryptocurrencies.config.ts +++ b/apps/api/src/config/cryptocurrencies.config.ts @@ -1,89 +1,106 @@ import { AssetHubToken, assetHubTokenConfig, + EPaymentMethod, EvmNetworks, - EvmToken, - evmTokenConfig, + EvmTokenDetails, + FiatToken, + getEvmTokenConfig, isNetworkAssetHub, isNetworkEVM, Networks, + RampDirection, 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)) .join("', '"); -const throwInvalidNetworkError = (network: string): never => { +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}'` }); }; -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}'.` - }); +/** + * 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 { - assetContractAddress: details.erc20AddressSourceChain, - assetDecimals: details.decimals, - assetNetwork: details.network, - assetSymbol: details.assetSymbol - }; + return !isRetiredAssetHubCorridor(probe as Parameters[0]); }; -const mapAssetHubTokenToDetails = (token: AssetHubToken): SupportedCryptocurrencyDetails => { - const details = assetHubTokenConfig[token]; - return { - assetDecimals: details.decimals, - assetForeignAssetId: details.foreignAssetId, - assetNetwork: details.network, - assetSymbol: details.assetSymbol - }; -}; +const rampTypesFor = (network: Networks): RampDirection[] => + [RampDirection.BUY, RampDirection.SELL].filter(rampType => isDirectionAvailable(network, rampType)); -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 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] ?? {})) { + 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 + }); } + return [...byAddress.values()]; }; const getAssetHubTokens = (): SupportedCryptocurrencyDetails[] => { - return Object.values(AssetHubToken).map(mapAssetHubTokenToDetails); + 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, + rampTypes + }; + }); }; -const getAllEvmNetworkTokens = (): SupportedCryptocurrencyDetails[] => - Object.values(Networks).filter(isNetworkEVM).flatMap(getEvmNetworkTokens); - -const getAllNetworkTokens = (): SupportedCryptocurrencyDetails[] => [...getAllEvmNetworkTokens(), ...getAssetHubTokens()]; - /** - * 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/test-utils/background-work.test.ts b/apps/api/src/test-utils/background-work.test.ts new file mode 100644 index 000000000..1398c9212 --- /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 000000000..95b947908 --- /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 e74cdf278..4746442ed 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 000000000..406cc85f4 --- /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 4f01d0e76..9b17ef172 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/corridors/brl-offramp-crosschain.scenario.test.ts b/apps/api/src/tests/corridors/brl-offramp-crosschain.scenario.test.ts index 8a34e7c6e..7f6587044 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,20 +1,26 @@ -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, FiatToken, + NATIVE_TOKEN_ADDRESS, Networks, 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"; @@ -479,4 +485,168 @@ describe("BRL offramp cross-chain corridor (USDC on Polygon → Base → pix via }, 30000 ); + + async function requestSellQuote(inputCurrency: string, inputAmount: string, network: Networks = Networks.Ethereum) { + return app.request("/v1/quotes", { + body: JSON.stringify({ + from: network, + inputAmount, + inputCurrency, + network, + 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). + 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 }); + } + }); + + 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/apps/api/src/tests/harness.smoke.test.ts b/apps/api/src/tests/harness.smoke.test.ts index fe7431882..d59ad8d15 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/apps/api/src/tests/http-surface.invariants.test.ts b/apps/api/src/tests/http-surface.invariants.test.ts index 397b41774..a138cc73b 100644 --- a/apps/api/src/tests/http-surface.invariants.test.ts +++ b/apps/api/src/tests/http-surface.invariants.test.ts @@ -351,8 +351,17 @@ 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); + 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); diff --git a/docs/api/apidog/page-manifest.json b/docs/api/apidog/page-manifest.json index 47f48b95f..5e729e9d4 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 with safe quote handling, browser token refresh, wallet checks, resumable payments, start reconciliation, 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/openapi/vortex.openapi.d.ts b/docs/api/openapi/vortex.openapi.d.ts index 2ef3faf89..8988e6c3d 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; `rampTypes` lists the directions at least one corridor supports for the token. */ 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`, `base-sepolia`, `bsc`, `ethereum`, `moonbeam`, `paseo`, `polygon`, `polygonAmoy` + * @example ethereum */ - network?: string; + network: components["schemas"]["SupportedCryptocurrencyNetwork"]; }; header?: never; path?: never; @@ -1692,12 +1692,25 @@ 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"][]; }[]; }; }; }; + /** @description Missing or unsupported `network`. */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + error: string; + }; + }; + }; }; }; put?: never; @@ -3235,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 cb34238b2..ced87f8ec 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" @@ -10378,16 +10395,16 @@ "/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; `rampTypes` lists the directions at least one corridor supports for the token.", "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`, `base-sepolia`, `bsc`, `ethereum`, `moonbeam`, `paseo`, `polygon`, `polygonAmoy`", + "example": "ethereum", "in": "query", "name": "network", - "required": false, + "required": true, "schema": { - "type": "string" + "$ref": "#/components/schemas/SupportedCryptocurrencyNetwork" } } ], @@ -10412,13 +10429,20 @@ "type": ["string", "null"] }, "assetNetwork": { - "$ref": "#/components/schemas/Networks" + "$ref": "#/components/schemas/SupportedCryptocurrencyNetwork" }, "assetSymbol": { "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, for example on networks without ramp support or for the retired AssetHub corridors.", + "items": { + "$ref": "#/components/schemas/RampDirection" + }, + "type": "array" } }, - "required": ["assetDecimals", "assetNetwork", "assetSymbol"], + "required": ["assetDecimals", "assetNetwork", "assetSymbol", "rampTypes"], "type": "object" }, "type": "array" @@ -10431,6 +10455,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/pages/01-overview.md b/docs/api/pages/01-overview.md index 65a66ae9c..5541a0a7c 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 @@ -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/02-quick-start-with-the-sdk.md b/docs/api/pages/02-quick-start-with-the-sdk.md index 59c76a430..325ad2734 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 5b592be9b..fcccd6c74 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 7f014e737..eaf2f1a13 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 e8f113e1f..2459aa228 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 1ad2e3246..7e6454d05 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`) @@ -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 0b236ccb9..225a45469 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 8f04f5cd1..b807514f9 100644 --- a/docs/api/pages/15-custom-ui-integration.md +++ b/docs/api/pages/15-custom-ui-integration.md @@ -4,6 +4,19 @@ 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. + +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. diff --git a/docs/api/scripts/check-openapi.ts b/docs/api/scripts/check-openapi.ts index 402517208..ed6d3ac70 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 || diff --git a/docs/api/wire-contract.snapshot.md b/docs/api/wire-contract.snapshot.md index 670662d6d..07ea7fa42 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: { diff --git a/docs/operations-testing.md b/docs/operations-testing.md index 4ea5fd321..4756b2b9f 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`, diff --git a/docs/security-spec/03-ramp-engine/fee-integrity.md b/docs/security-spec/03-ramp-engine/fee-integrity.md index f7ce48a0d..1cc55c0ba 100644 --- a/docs/security-spec/03-ramp-engine/fee-integrity.md +++ b/docs/security-spec/03-ramp-engine/fee-integrity.md @@ -16,7 +16,12 @@ 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. 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. 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 d8c23ea72..143095438 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 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` 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. diff --git a/packages/shared/src/endpoints/supported-cryptocurrencies.endpoints.ts b/packages/shared/src/endpoints/supported-cryptocurrencies.endpoints.ts index 320e59574..b047d8def 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 at least one corridor supports for this token on its network. + rampTypes: RampDirection[]; } export interface GetSupportedCryptocurrenciesRequest { - network?: Networks; + network: Networks; } export interface GetSupportedCryptocurrenciesResponse {