From 6f24fc0cbea9e1ad5c0de30f08ee2928db173179 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Tue, 9 Jun 2026 10:46:25 +0200 Subject: [PATCH 01/11] Fix handling of final amount raw for onramping transactions and ensure USDC config is present for Ethereum --- .../onramp/routes/avenia-to-evm-base.ts | 26 ++++++++++++------- .../onramp/routes/avenia-to-evm.ts | 18 ++++++++----- .../onramp/routes/mykobo-to-evm.ts | 12 ++++----- 3 files changed, 34 insertions(+), 22 deletions(-) diff --git a/apps/api/src/api/services/transactions/onramp/routes/avenia-to-evm-base.ts b/apps/api/src/api/services/transactions/onramp/routes/avenia-to-evm-base.ts index 466ca0d89..de0394e55 100644 --- a/apps/api/src/api/services/transactions/onramp/routes/avenia-to-evm-base.ts +++ b/apps/api/src/api/services/transactions/onramp/routes/avenia-to-evm-base.ts @@ -72,9 +72,9 @@ export async function prepareAveniaToEvmOnrampTransactionsOnBase({ // BRL→BRLA on Base: Avenia already minted the requested BRLA, so no Nabla swap, fee-token // conversion, or SquidRouter step is needed — transfer the minted BRLA straight to the user. if (isDirectTransfer) { - const finalAmountRaw = multiplyByPowerOfTen(quote.outputAmount, outputTokenDetails.decimals); + const finalAmountRaw = multiplyByPowerOfTen(quote.outputAmount, outputTokenDetails.decimals).toFixed(0, 0); const finalDestinationTransfer = await addOnrampDestinationChainTransactions({ - amountRaw: finalAmountRaw.toString(), + amountRaw: finalAmountRaw, destinationNetwork: Networks.Base, isNativeToken: isNativeEvmToken(outputTokenDetails), toAddress: destinationAddress, @@ -122,12 +122,12 @@ export async function prepareAveniaToEvmOnrampTransactionsOnBase({ baseNonce = await addEvmFeeDistributionTransaction(quote, evmEphemeralEntry, unsignedTxs, baseNonce); - const finalAmountRaw = multiplyByPowerOfTen(quote.outputAmount, outputTokenDetails.decimals); + const finalAmountRaw = multiplyByPowerOfTen(quote.outputAmount, outputTokenDetails.decimals).toFixed(0, 0); // Special case, onramping USDC on Base. We need to skip the SquidRouter step and go directly to the destination transfer. if (toNetwork === Networks.Base && outputTokenDetails.erc20AddressSourceChain === nablaSwapOutputTokenAddress) { const finalDestinationTransfer = await addOnrampDestinationChainTransactions({ - amountRaw: finalAmountRaw.toString(), + amountRaw: finalAmountRaw, destinationNetwork: Networks.Base, isNativeToken: isNativeEvmToken(outputTokenDetails), toAddress: destinationAddress, @@ -206,7 +206,7 @@ export async function prepareAveniaToEvmOnrampTransactionsOnBase({ // them, and on a shared nonce sequence they would push destinationTransfer beyond the live nonce). if (toNetwork === Networks.Base) { const sameChainDestinationTransfer = await addOnrampDestinationChainTransactions({ - amountRaw: finalAmountRaw.toString(), + amountRaw: finalAmountRaw, destinationNetwork: Networks.Base, isNativeToken: isNativeEvmToken(outputTokenDetails), toAddress: destinationAddress, @@ -289,7 +289,7 @@ export async function prepareAveniaToEvmOnrampTransactionsOnBase({ const destinationStartingNonce = destinationNonce; const finalDestinationTransfer = await addOnrampDestinationChainTransactions({ - amountRaw: finalAmountRaw.toString(), + amountRaw: finalAmountRaw, destinationNetwork: toNetwork as EvmNetworks, isNativeToken: isNativeEvmToken(outputTokenDetails), toAddress: destinationAddress, @@ -307,10 +307,16 @@ export async function prepareAveniaToEvmOnrampTransactionsOnBase({ // Fallback swap depends on the EVM chain. For Ethereum, the bridged token is USDC. For the rest, it is axlUSDC. const destinationAxlUsdcDetails = getOnChainTokenDetailsOrDefault(toNetwork as Networks, EvmToken.AXLUSDC) as EvmTokenDetails; - const bridgedTokenForFallback = - toNetwork === Networks.Ethereum - ? evmTokenConfig.ethereum.USDC!.erc20AddressSourceChain - : destinationAxlUsdcDetails.erc20AddressSourceChain; + let bridgedTokenForFallback: `0x${string}`; + if (toNetwork === Networks.Ethereum) { + const ethereumUsdc = evmTokenConfig.ethereum.USDC; + if (!ethereumUsdc) { + throw new Error("USDC config missing for Ethereum"); + } + bridgedTokenForFallback = ethereumUsdc.erc20AddressSourceChain as `0x${string}`; + } else { + bridgedTokenForFallback = destinationAxlUsdcDetails.erc20AddressSourceChain as `0x${string}`; + } const inputAmountRawFinalBridge = quote.metadata.evmToEvm?.inputAmountRaw; if (!inputAmountRawFinalBridge) { diff --git a/apps/api/src/api/services/transactions/onramp/routes/avenia-to-evm.ts b/apps/api/src/api/services/transactions/onramp/routes/avenia-to-evm.ts index 19f7f3dbd..a6791729d 100644 --- a/apps/api/src/api/services/transactions/onramp/routes/avenia-to-evm.ts +++ b/apps/api/src/api/services/transactions/onramp/routes/avenia-to-evm.ts @@ -182,10 +182,16 @@ export async function prepareAveniaToEvmOnrampTransactions({ moonbeamNonce++; // Fallback swap depends on the EVM chain. For Ethereum, the bridged token is USDC. For the rest, it is axlUSDC. - const bridgedTokenForFallback = - toNetwork === Networks.Ethereum - ? evmTokenConfig.ethereum.USDC!.erc20AddressSourceChain - : destinationAxlUsdcDetails.erc20AddressSourceChain; + let bridgedTokenForFallback: `0x${string}`; + if (toNetwork === Networks.Ethereum) { + const ethereumUsdc = evmTokenConfig.ethereum.USDC; + if (!ethereumUsdc) { + throw new Error("USDC config missing for Ethereum"); + } + bridgedTokenForFallback = ethereumUsdc.erc20AddressSourceChain as `0x${string}`; + } else { + bridgedTokenForFallback = destinationAxlUsdcDetails.erc20AddressSourceChain as `0x${string}`; + } const { approveData: destApproveData, swapData: destSwapData } = await createOnrampSquidrouterTransactionsOnDestinationChain({ destinationAddress: evmEphemeralEntry.address, @@ -198,10 +204,10 @@ export async function prepareAveniaToEvmOnrampTransactions({ let destinationNonce = 0; - const finalAmountRaw = multiplyByPowerOfTen(quote.outputAmount, outputTokenDetails.decimals); + const finalAmountRaw = multiplyByPowerOfTen(quote.outputAmount, outputTokenDetails.decimals).toFixed(0, 0); const finalDestinationTransfer = await addOnrampDestinationChainTransactions({ - amountRaw: finalAmountRaw.toString(), + amountRaw: finalAmountRaw, destinationNetwork: toNetwork as EvmNetworks, isNativeToken: isNativeEvmToken(outputTokenDetails), toAddress: destinationAddress, diff --git a/apps/api/src/api/services/transactions/onramp/routes/mykobo-to-evm.ts b/apps/api/src/api/services/transactions/onramp/routes/mykobo-to-evm.ts index d5131e37a..02a0a1454 100644 --- a/apps/api/src/api/services/transactions/onramp/routes/mykobo-to-evm.ts +++ b/apps/api/src/api/services/transactions/onramp/routes/mykobo-to-evm.ts @@ -87,9 +87,9 @@ export async function prepareMykoboToEvmOnrampTransactions({ let baseNonce = 0; if (isDirectTransfer) { - const finalAmountRaw = multiplyByPowerOfTen(quote.outputAmount, outputTokenDetails.decimals); + const finalAmountRaw = multiplyByPowerOfTen(quote.outputAmount, outputTokenDetails.decimals).toFixed(0, 0); const finalDestinationTransfer = await addOnrampDestinationChainTransactions({ - amountRaw: finalAmountRaw.toString(), + amountRaw: finalAmountRaw, destinationNetwork: Networks.Base, isNativeToken: isNativeEvmToken(outputTokenDetails), toAddress: destinationAddress, @@ -129,12 +129,12 @@ export async function prepareMykoboToEvmOnrampTransactions({ baseNonce = await addEvmFeeDistributionTransaction(quote, evmEphemeralEntry, unsignedTxs, baseNonce); - const finalAmountRaw = multiplyByPowerOfTen(quote.outputAmount, outputTokenDetails.decimals); + const finalAmountRaw = multiplyByPowerOfTen(quote.outputAmount, outputTokenDetails.decimals).toFixed(0, 0); // Special case: onramping USDC on Base. Skip SquidRouter and transfer directly to destination. if (toNetwork === Networks.Base && outputTokenDetails.erc20AddressSourceChain === nablaSwapOutputTokenAddress) { const finalDestinationTransfer = await addOnrampDestinationChainTransactions({ - amountRaw: finalAmountRaw.toString(), + amountRaw: finalAmountRaw, destinationNetwork: Networks.Base, isNativeToken: isNativeEvmToken(outputTokenDetails), toAddress: destinationAddress, @@ -216,7 +216,7 @@ export async function prepareMykoboToEvmOnrampTransactions({ // them, and on a shared nonce sequence they would push destinationTransfer beyond the live nonce). if (toNetwork === Networks.Base) { const sameChainDestinationTransfer = await addOnrampDestinationChainTransactions({ - amountRaw: finalAmountRaw.toString(), + amountRaw: finalAmountRaw, destinationNetwork: Networks.Base, isNativeToken: isNativeEvmToken(outputTokenDetails), toAddress: destinationAddress, @@ -305,7 +305,7 @@ export async function prepareMykoboToEvmOnrampTransactions({ const destinationStartingNonce = destinationNonce; const finalDestinationTransfer = await addOnrampDestinationChainTransactions({ - amountRaw: finalAmountRaw.toString(), + amountRaw: finalAmountRaw, destinationNetwork: toNetwork as EvmNetworks, isNativeToken: isNativeEvmToken(outputTokenDetails), toAddress: destinationAddress, From 99e128f8cd594314dc5a532f3da06740255976e8 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Tue, 9 Jun 2026 10:46:54 +0200 Subject: [PATCH 02/11] Don't retry contract calls for EXCEEDS_MAX_COVERAGE_RATIO error --- .../src/services/evm/clientManager.test.ts | 41 +++++++++++++++++-- .../shared/src/services/evm/clientManager.ts | 15 ++++++- 2 files changed, 51 insertions(+), 5 deletions(-) diff --git a/packages/shared/src/services/evm/clientManager.test.ts b/packages/shared/src/services/evm/clientManager.test.ts index 86479862c..69c027090 100644 --- a/packages/shared/src/services/evm/clientManager.test.ts +++ b/packages/shared/src/services/evm/clientManager.test.ts @@ -1,6 +1,6 @@ -import { describe, expect, it } from "bun:test"; -import { Networks } from "../../helpers"; -import { EvmClientManager, redactRpcUrlForLogs, sanitizeRpcErrorMessage } from "./clientManager"; +import {describe, expect, it} from "bun:test"; +import {Networks} from "../../helpers"; +import {EvmClientManager, redactRpcUrlForLogs, sanitizeRpcErrorMessage} from "./clientManager"; describe("redactRpcUrlForLogs", () => { it("redacts provider API keys from RPC URLs", () => { @@ -29,3 +29,38 @@ describe("EvmClientManager RPC cache keys", () => { expect(defaultRpcClient).not.toBe(explicitRpcClient); }); }); + +describe("EvmClientManager read contract retries", () => { + it("does not retry deterministic Nabla coverage-ratio reverts", async () => { + const manager = EvmClientManager.getInstance(); + const managerWithMockedClient = manager as EvmClientManager & { getClient: EvmClientManager["getClient"] }; + const originalGetClient = managerWithMockedClient.getClient; + let attempts = 0; + + managerWithMockedClient.getClient = (() => + ({ + readContract: async () => { + attempts += 1; + throw new Error("execution reverted: SP:quoteSwapInto:EXCEEDS_MAX_COVERAGE_RATIO"); + } + }) as unknown as ReturnType) as EvmClientManager["getClient"]; + + try { + await expect( + manager.readContractWithRetry( + Networks.Base, + { + abi: [], + address: "0x2A7989993335b31A3133CDA93bc1a095e7b178Ff", + functionName: "quoteSwapExactTokensForTokens" + }, + 3, + 0 + ) + ).rejects.toThrow("EXCEEDS_MAX_COVERAGE_RATIO"); + expect(attempts).toBe(1); + } finally { + managerWithMockedClient.getClient = originalGetClient; + } + }); +}); diff --git a/packages/shared/src/services/evm/clientManager.ts b/packages/shared/src/services/evm/clientManager.ts index 6313197b7..abd39993e 100644 --- a/packages/shared/src/services/evm/clientManager.ts +++ b/packages/shared/src/services/evm/clientManager.ts @@ -10,6 +10,7 @@ export interface EvmNetworkConfig { } const VIEM_DEFAULT_TRANSPORT_CACHE_KEY = ""; +const NON_RETRYABLE_READ_CONTRACT_REVERTS = ["EXCEEDS_MAX_COVERAGE_RATIO"]; export function redactRpcUrlForLogs(rpcUrl: string): string { if (!rpcUrl) { @@ -51,6 +52,10 @@ function createRpcTransport(network: EvmNetworkConfig, rpcUrl?: string): Transpo return targetRpcUrl === "" ? http() : http(targetRpcUrl); } +function isNonRetryableReadContractError(error: Error): boolean { + return NON_RETRYABLE_READ_CONTRACT_REVERTS.some(revertReason => error.message.includes(revertReason)); +} + function getEvmNetworks(apiKey?: string): EvmNetworkConfig[] { // Note on defining RPC URLs: '' is equal to viem's default RPC for that chain: http(). return [ @@ -161,7 +166,8 @@ export class EvmClientManager { operation: (rpcUrl: string) => Promise, operationName: string, maxRetries = 3, - initialDelayMs = 1000 + initialDelayMs = 1000, + shouldRetry: (error: Error) => boolean = () => true ): Promise { const network = this.getNetworkConfig(networkName); const rpcUrls = network.rpcUrls; @@ -187,6 +193,10 @@ export class EvmClientManager { `${operationName} attempt ${attempt + 1}/${maxRetries + 1} failed on ${networkName} with RPC ${redactRpcUrlForLogs(rpcUrl)}: ${sanitizeRpcErrorMessage(lastError.message)}` ); + if (!shouldRetry(lastError)) { + throw lastError; + } + if (attempt < maxRetries) { const delayMs = initialDelayMs * Math.pow(2, attempt); // Exponential backoff await new Promise(resolve => setTimeout(resolve, delayMs)); @@ -327,7 +337,8 @@ export class EvmClientManager { }, "read contract", maxRetries, - initialDelayMs + initialDelayMs, + error => !isNonRetryableReadContractError(error) ); } From dcbfae3b4e6fddae1dadf735dd17664ec5efb533 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Tue, 9 Jun 2026 11:02:01 +0200 Subject: [PATCH 03/11] Fix EVM onramp raw amount formatting coverage --- .../handlers/destination-transfer-handler.ts | 2 +- .../onramp/routes/avenia-to-evm-base.test.ts | 230 ++++++++++++++++++ 2 files changed, 231 insertions(+), 1 deletion(-) create mode 100644 apps/api/src/api/services/transactions/onramp/routes/avenia-to-evm-base.test.ts diff --git a/apps/api/src/api/services/phases/handlers/destination-transfer-handler.ts b/apps/api/src/api/services/phases/handlers/destination-transfer-handler.ts index 634ac6de9..eeb09cf77 100644 --- a/apps/api/src/api/services/phases/handlers/destination-transfer-handler.ts +++ b/apps/api/src/api/services/phases/handlers/destination-transfer-handler.ts @@ -78,7 +78,7 @@ export class DestinationTransferHandler extends BasePhaseHandler { } const { txData: destinationTransfer } = this.getPresignedTransaction(state, "destinationTransfer"); - const expectedAmountRaw = multiplyByPowerOfTen(quote.outputAmount, outTokenDetails.decimals).toString(); + const expectedAmountRaw = multiplyByPowerOfTen(quote.outputAmount, outTokenDetails.decimals).toFixed(0, 0); const destinationNetwork = quote.network as EvmNetworks; // We can assert this type due to checks before const { destinationTransferTxHash, destinationAddress } = state.state as StateMetadata; diff --git a/apps/api/src/api/services/transactions/onramp/routes/avenia-to-evm-base.test.ts b/apps/api/src/api/services/transactions/onramp/routes/avenia-to-evm-base.test.ts new file mode 100644 index 000000000..1dc02a6a1 --- /dev/null +++ b/apps/api/src/api/services/transactions/onramp/routes/avenia-to-evm-base.test.ts @@ -0,0 +1,230 @@ +import { beforeEach, describe, expect, it, mock } from "bun:test"; +import Big from "big.js"; + +const EVM_EPHEMERAL_ADDRESS = "0x1111111111111111111111111111111111111111"; +const DESTINATION_ADDRESS = "0x2222222222222222222222222222222222222222"; +const FUNDING_ADDRESS = "0x3333333333333333333333333333333333333333"; +const BRLA_BASE = "0xfCB34c47f850f452C15EA1B84d51231C38A61783"; +const USDC_BASE = "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913"; +const USDT_BSC = "0x55d398326f99059fF775485246999027B3197955"; +const AXL_USDC_BSC = "0x4268B8F0B87b6Eae5d897996E6b845ddbD99Adf3"; + +const Networks = { + BSC: "bsc", + Base: "base", + Ethereum: "ethereum" +} as const; + +const EvmToken = { + AXLUSDC: "AXLUSDC", + USDC: "USDC", + USDT: "USDT" +} as const; + +const FiatToken = { + BRL: "BRL", + EURC: "EUR" +} as const; + +const destinationTransferCalls: Array<{ amountRaw: string; destinationNetwork: string }> = []; + +const addOnrampDestinationChainTransactions = mock(async (params: { amountRaw: string; destinationNetwork: string }) => { + destinationTransferCalls.push(params); + if (!/^\d+$/.test(params.amountRaw)) { + throw new Error(`expected integer raw amount, got ${params.amountRaw}`); + } + return { + data: "0xdestination", + gas: "100000", + maxFeePerGas: "1", + maxPriorityFeePerGas: "1", + to: DESTINATION_ADDRESS, + value: "0" + }; +}); + +mock.module("@vortexfi/shared", () => ({ + createOnrampSquidrouterTransactionsFromBaseToEvm: mock(async () => ({ + approveData: { data: "0xapprove", gas: "100000", to: USDC_BASE, value: "0" }, + squidRouterQuoteId: "quote", + squidRouterReceiverHash: "0xreceiverhash", + squidRouterReceiverId: "receiver", + swapData: { data: "0xswap", gas: "200000", to: USDC_BASE, value: "0" } + })), + createOnrampSquidrouterTransactionsOnDestinationChain: mock(async () => ({ + approveData: { data: "0xbackupapprove", gas: "100000", to: AXL_USDC_BSC, value: "0" }, + swapData: { data: "0xbackupswap", gas: "200000", to: AXL_USDC_BSC, value: "0" } + })), + EvmToken, + FiatToken, + evmTokenConfig: { + [Networks.Base]: { + [EvmToken.USDC]: { + assetSymbol: "USDC", + decimals: 6, + erc20AddressSourceChain: USDC_BASE, + isNative: false, + network: Networks.Base, + type: "evm" + } + }, + [Networks.BSC]: { + [EvmToken.AXLUSDC]: { + assetSymbol: "axlUSDC", + decimals: 6, + erc20AddressSourceChain: AXL_USDC_BSC, + isNative: false, + network: Networks.BSC, + type: "evm" + }, + [EvmToken.USDT]: { + assetSymbol: "USDT", + decimals: 18, + erc20AddressSourceChain: USDT_BSC, + isNative: false, + network: Networks.BSC, + type: "evm" + } + }, + [Networks.Ethereum]: { + [EvmToken.USDC]: { + assetSymbol: "USDC", + decimals: 6, + erc20AddressSourceChain: USDC_BASE, + isNative: false, + network: Networks.Ethereum, + type: "evm" + } + } + }, + getOnChainTokenDetailsOrDefault: mock(() => ({ + assetSymbol: "axlUSDC", + decimals: 6, + erc20AddressSourceChain: AXL_USDC_BSC, + isNative: false, + network: Networks.BSC, + type: "evm" + })), + isEvmTokenDetails: () => true, + isNativeEvmToken: (details: { isNative?: boolean }) => details.isNative === true, + multiplyByPowerOfTen: (value: Big.BigSource, power: number) => { + const result = new Big(value); + if (result.c[0] !== 0) result.e += power; + return result; + }, + Networks +})); + +mock.module("../common/validation", () => ({ + validateAveniaOnrampOnBase: mock(() => ({ + evmEphemeralEntry: { + address: EVM_EPHEMERAL_ADDRESS, + type: "EVM" + }, + inputTokenDetails: { + assetSymbol: "BRLA", + decimals: 18, + erc20AddressSourceChain: BRLA_BASE, + isNative: false, + network: Networks.Base, + type: "evm" + }, + outputTokenDetails: { + assetSymbol: "USDT", + decimals: 18, + erc20AddressSourceChain: USDT_BSC, + isNative: false, + network: Networks.BSC, + type: "evm" + }, + toNetwork: Networks.BSC + })) +})); + +mock.module("../common/transactions", () => ({ + addDestinationChainApprovalTransaction: mock(async () => ({ + data: "0xbackupapprove", + gas: "100000", + maxFeePerGas: "1", + maxPriorityFeePerGas: "1", + to: AXL_USDC_BSC, + value: "0" + })), + addNablaSwapTransactionsOnBase: mock(async (_params, _unsignedTxs, nextNonce: number) => ({ + nextNonce: nextNonce + 2, + stateMeta: { nablaSoftMinimumOutputRaw: "4818988497" } + })), + addOnrampDestinationChainTransactions +})); + +mock.module("../../common/feeDistribution", () => ({ + addEvmFeeDistributionTransaction: mock(async (_quote, _account, _unsignedTxs, nextNonce: number) => nextNonce) +})); + +mock.module("../../base/cleanup", () => ({ + prepareBaseCleanupApproval: mock(async () => ({ + data: "0xcleanup", + gas: "100000", + maxFeePerGas: "1", + maxPriorityFeePerGas: "1", + to: USDC_BASE, + value: "0" + })) +})); + +mock.module("../../index", () => ({ + encodeEvmTransactionData: (data: unknown) => data +})); + +mock.module("../../../phases/evm-funding", () => ({ + getEvmFundingAccount: () => ({ address: FUNDING_ADDRESS }) +})); + +mock.module("../../../../../config/logger", () => ({ + default: { + debug: mock(() => undefined) + } +})); + +const { prepareAveniaToEvmOnrampTransactionsOnBase } = await import("./avenia-to-evm-base"); + +describe("prepareAveniaToEvmOnrampTransactionsOnBase", () => { + beforeEach(() => { + destinationTransferCalls.length = 0; + addOnrampDestinationChainTransactions.mockClear(); + }); + + it("uses fixed integer notation for large final destination raw amounts", async () => { + await prepareAveniaToEvmOnrampTransactionsOnBase({ + destinationAddress: DESTINATION_ADDRESS, + quote: { + inputCurrency: "BRL", + metadata: { + aveniaTransfer: { + outputAmountRaw: "25002249808000000000000" + }, + evmToEvm: { + inputAmountRaw: "4818926798" + }, + nablaSwapEvm: { + inputAmountForSwapRaw: "25002249808000000000000", + outputAmountRaw: "4818988497" + } + }, + network: Networks.BSC, + outputAmount: "4818.903696", + outputCurrency: EvmToken.USDT, + to: Networks.BSC + } as never, + signingAccounts: [{ address: EVM_EPHEMERAL_ADDRESS, type: "EVM" }] as never, + taxId: "12345678901" + }); + + expect(destinationTransferCalls).toContainEqual( + expect.objectContaining({ + amountRaw: "4818903696000000000000", + destinationNetwork: Networks.BSC + }) + ); + }); +}); From 1030b6e36ba5d264ec5f090f19a27e8264f5134f Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Tue, 9 Jun 2026 11:10:03 +0200 Subject: [PATCH 04/11] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- packages/shared/src/services/evm/clientManager.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/shared/src/services/evm/clientManager.ts b/packages/shared/src/services/evm/clientManager.ts index abd39993e..9018bbcc1 100644 --- a/packages/shared/src/services/evm/clientManager.ts +++ b/packages/shared/src/services/evm/clientManager.ts @@ -194,7 +194,9 @@ export class EvmClientManager { ); if (!shouldRetry(lastError)) { - throw lastError; + throw new Error(`${operationName} failed on ${networkName}: ${sanitizeRpcErrorMessage(lastError.message)}`, { + cause: lastError + }); } if (attempt < maxRetries) { From 4e3e4b8961dc5c3ac17562a06fbcbe733cb5ed9a Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Tue, 9 Jun 2026 11:25:41 +0200 Subject: [PATCH 05/11] Add api-client-events-retention.service.ts --- ...pi-client-events-retention.service.test.ts | 94 +++++++++++++++++++ .../api-client-events-retention.service.ts | 83 ++++++++++++++++ .../api-client-events-retention.worker.ts | 41 ++++++++ apps/api/src/index.ts | 2 + .../07-operations/client-observability.md | 6 +- 5 files changed, 223 insertions(+), 3 deletions(-) create mode 100644 apps/api/src/api/services/api-client-events-retention.service.test.ts create mode 100644 apps/api/src/api/services/api-client-events-retention.service.ts create mode 100644 apps/api/src/api/workers/api-client-events-retention.worker.ts diff --git a/apps/api/src/api/services/api-client-events-retention.service.test.ts b/apps/api/src/api/services/api-client-events-retention.service.test.ts new file mode 100644 index 000000000..aae9ef6ac --- /dev/null +++ b/apps/api/src/api/services/api-client-events-retention.service.test.ts @@ -0,0 +1,94 @@ +import { afterEach, describe, expect, it, mock } from "bun:test"; +import { Transaction } from "sequelize"; +import sequelize from "../../config/database"; +import ApiClientEvent from "../../models/apiClientEvent.model"; +import { + API_CLIENT_EVENT_RETENTION_DAYS, + ApiClientEventsRetentionService, + getApiClientEventRetentionCutoff +} from "./api-client-events-retention.service"; + +describe("getApiClientEventRetentionCutoff", () => { + it("keeps the current UTC calendar day and the previous six days", () => { + const cutoff = getApiClientEventRetentionCutoff(new Date("2026-06-09T18:30:00.000Z")); + + expect(API_CLIENT_EVENT_RETENTION_DAYS).toBe(7); + expect(cutoff.toISOString()).toBe("2026-06-03T00:00:00.000Z"); + }); + + it("uses the UTC calendar day instead of the local clock time", () => { + const cutoff = getApiClientEventRetentionCutoff(new Date("2026-01-01T00:30:00.000Z")); + + expect(cutoff.toISOString()).toBe("2025-12-26T00:00:00.000Z"); + }); +}); + +describe("ApiClientEventsRetentionService", () => { + const originalTransaction = sequelize.transaction; + const originalQuery = sequelize.query; + const originalFindAll = ApiClientEvent.findAll; + const originalDestroy = ApiClientEvent.destroy; + + afterEach(() => { + sequelize.transaction = originalTransaction; + sequelize.query = originalQuery; + ApiClientEvent.findAll = originalFindAll; + ApiClientEvent.destroy = originalDestroy; + }); + + it("deletes expired events until all batches are drained", async () => { + const transaction = {} as Transaction; + const transactionMock = mock(async (callback: (transaction: Transaction) => Promise) => callback(transaction)); + const queryMock = mock(async () => [{ acquired: true }]); + let findAllCallCount = 0; + let destroyCallCount = 0; + const findAllMock = mock(async () => { + findAllCallCount += 1; + if (findAllCallCount === 1) { + return Array.from({ length: 10000 }, (_, index) => ({ id: `first-${index}` })); + } + + return [{ id: "second-1" }, { id: "second-2" }]; + }); + const destroyMock = mock(async () => { + destroyCallCount += 1; + return destroyCallCount === 1 ? 10000 : 2; + }); + + sequelize.transaction = transactionMock as typeof sequelize.transaction; + sequelize.query = queryMock as unknown as typeof sequelize.query; + ApiClientEvent.findAll = findAllMock as unknown as typeof ApiClientEvent.findAll; + ApiClientEvent.destroy = destroyMock as typeof ApiClientEvent.destroy; + + const deletedEventsCount = await new ApiClientEventsRetentionService().cleanupExpiredEvents( + new Date("2026-06-09T18:30:00.000Z") + ); + + expect(deletedEventsCount).toBe(10002); + expect(transactionMock).toHaveBeenCalledTimes(2); + expect(queryMock).toHaveBeenCalledTimes(2); + expect(findAllMock).toHaveBeenCalledTimes(2); + expect(destroyMock).toHaveBeenCalledTimes(2); + }); + + it("stops without deleting when another worker holds the cleanup lock", async () => { + const transaction = {} as Transaction; + const transactionMock = mock(async (callback: (transaction: Transaction) => Promise) => callback(transaction)); + const queryMock = mock(async () => [{ acquired: false }]); + const findAllMock = mock(async () => [{ id: "expired-event" }]); + const destroyMock = mock(async () => 1); + + sequelize.transaction = transactionMock as typeof sequelize.transaction; + sequelize.query = queryMock as unknown as typeof sequelize.query; + ApiClientEvent.findAll = findAllMock as unknown as typeof ApiClientEvent.findAll; + ApiClientEvent.destroy = destroyMock as typeof ApiClientEvent.destroy; + + const deletedEventsCount = await new ApiClientEventsRetentionService().cleanupExpiredEvents(); + + expect(deletedEventsCount).toBe(0); + expect(transactionMock).toHaveBeenCalledTimes(1); + expect(queryMock).toHaveBeenCalledTimes(1); + expect(findAllMock).not.toHaveBeenCalled(); + expect(destroyMock).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/api/src/api/services/api-client-events-retention.service.ts b/apps/api/src/api/services/api-client-events-retention.service.ts new file mode 100644 index 000000000..e7cce15d3 --- /dev/null +++ b/apps/api/src/api/services/api-client-events-retention.service.ts @@ -0,0 +1,83 @@ +import { Op, QueryTypes, Transaction } from "sequelize"; +import sequelize from "../../config/database"; +import logger from "../../config/logger"; +import ApiClientEvent from "../../models/apiClientEvent.model"; + +export const API_CLIENT_EVENT_RETENTION_DAYS = 7; +const API_CLIENT_EVENT_DELETE_BATCH_SIZE = 10000; +const API_CLIENT_EVENT_CLEANUP_ADVISORY_LOCK_NAMESPACE = 918522; +const API_CLIENT_EVENT_CLEANUP_ADVISORY_LOCK_KEY = 1; + +export function getApiClientEventRetentionCutoff(now = new Date()): Date { + return new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate() - (API_CLIENT_EVENT_RETENTION_DAYS - 1))); +} + +export class ApiClientEventsRetentionService { + public async cleanupExpiredEvents(now = new Date()): Promise { + const cutoff = getApiClientEventRetentionCutoff(now); + let deletedEventsCount = 0; + + while (true) { + const deletedBatchCount = await this.cleanupExpiredEventsBatch(cutoff); + deletedEventsCount += deletedBatchCount; + + if (deletedBatchCount < API_CLIENT_EVENT_DELETE_BATCH_SIZE) { + return deletedEventsCount; + } + } + } + + private async cleanupExpiredEventsBatch(cutoff: Date): Promise { + return sequelize.transaction(async transaction => { + const [lock] = await sequelize.query<{ acquired: boolean }>( + "SELECT pg_try_advisory_xact_lock(:namespace, :key) AS acquired", + { + replacements: { + key: API_CLIENT_EVENT_CLEANUP_ADVISORY_LOCK_KEY, + namespace: API_CLIENT_EVENT_CLEANUP_ADVISORY_LOCK_NAMESPACE + }, + transaction, + type: QueryTypes.SELECT + } + ); + + if (!lock?.acquired) { + logger.info("Skipping API client events cleanup because another worker holds the cleanup lock"); + return 0; + } + + return this.cleanupExpiredEventsWithLock(cutoff, transaction); + }); + } + + private async cleanupExpiredEventsWithLock(cutoff: Date, transaction: Transaction): Promise { + const expiredEventBatch = await ApiClientEvent.findAll({ + attributes: ["id"], + limit: API_CLIENT_EVENT_DELETE_BATCH_SIZE, + order: [["createdAt", "ASC"]], + transaction, + where: { + createdAt: { + [Op.lt]: cutoff + } + } + }); + + const expiredEventIds = expiredEventBatch.map(event => event.id); + if (expiredEventIds.length === 0) { + return 0; + } + + return ApiClientEvent.destroy({ + transaction, + where: { + createdAt: { + [Op.lt]: cutoff + }, + id: { + [Op.in]: expiredEventIds + } + } + }); + } +} diff --git a/apps/api/src/api/workers/api-client-events-retention.worker.ts b/apps/api/src/api/workers/api-client-events-retention.worker.ts new file mode 100644 index 000000000..84a5a11f6 --- /dev/null +++ b/apps/api/src/api/workers/api-client-events-retention.worker.ts @@ -0,0 +1,41 @@ +import { CronJob } from "cron"; +import logger from "../../config/logger"; +import { ApiClientEventsRetentionService } from "../services/api-client-events-retention.service"; + +class ApiClientEventsRetentionWorker { + private job: CronJob; + + private readonly retentionService: ApiClientEventsRetentionService; + + constructor(cronTime = "5 0 * * *") { + this.retentionService = new ApiClientEventsRetentionService(); + this.job = new CronJob(cronTime, this.cleanup.bind(this), null, false, "UTC", null, true); + } + + public start(): void { + logger.info("Starting API client events retention worker"); + this.job.start(); + } + + public stop(): void { + logger.info("Stopping API client events retention worker"); + this.job.stop(); + } + + private async cleanup(): Promise { + logger.info("Running API client events retention worker cycle"); + + try { + const deletedEventsCount = await this.retentionService.cleanupExpiredEvents(); + if (deletedEventsCount > 0) { + logger.info(`Deleted ${deletedEventsCount} expired API client events`); + } + + logger.info("API client events retention worker cycle completed"); + } catch (error) { + logger.error("Error during API client events retention worker cycle:", error); + } + } +} + +export default ApiClientEventsRetentionWorker; diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index c2c734c57..7936ef671 100755 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -11,6 +11,7 @@ import { runMigrations } from "./database/migrator"; import "./models"; // Initialize models import { AlfredpayLimitsService } from "./api/services/alfredpay/alfredpay-limits.service"; import registerPhaseHandlers from "./api/services/phases/register-handlers"; +import ApiClientEventsRetentionWorker from "./api/workers/api-client-events-retention.worker"; import CleanupWorker from "./api/workers/cleanup.worker"; import RampRecoveryWorker from "./api/workers/ramp-recovery.worker"; import UnhandledPaymentWorker from "./api/workers/unhandled-payment.worker"; @@ -62,6 +63,7 @@ const initializeApp = async () => { // Start background workers new CleanupWorker().start(); + new ApiClientEventsRetentionWorker().start(); new RampRecoveryWorker().start(); new UnhandledPaymentWorker().start(); diff --git a/docs/security-spec/07-operations/client-observability.md b/docs/security-spec/07-operations/client-observability.md index 06aaaae70..2313c1eda 100644 --- a/docs/security-spec/07-operations/client-observability.md +++ b/docs/security-spec/07-operations/client-observability.md @@ -24,7 +24,7 @@ Internal operators can inspect these events through `GET /v1/admin/api-client-ev 5. **Request correlation MUST be non-secret** — `requestId`, `quoteId`, and `rampId` may be stored for debugging, but they must not be used as high-cardinality metric labels. They are correlation identifiers, not authentication material. 6. **Partner attribution MUST use safe identifiers** — Events may store `partnerId`, `partnerName`, and short API key prefixes. Full secret keys and raw auth headers are forbidden. 7. **Operational metrics MUST remain low-cardinality** — Future metric exporters must group by bounded labels such as operation, partner, status, HTTP status, and error type. They must not label by user ID, wallet address, request ID, quote ID, ramp ID, tax ID, PIX key, or free-form request values. -8. **Event persistence SHOULD have automated retention before production alerting/dashboard rollout** — Raw operational events are useful for investigation but should not be retained indefinitely without aggregation or cleanup. Until that follow-up exists, operators should treat retention as a known operational gap. +8. **Event persistence SHOULD have automated retention before production alerting/dashboard rollout** — Raw operational events are useful for investigation but must not be retained indefinitely without aggregation or cleanup. The backend retention worker keeps the current UTC calendar day plus the previous six full UTC calendar days and removes older `api_client_events` rows on startup and daily. 9. **Dashboard access MUST go through metrics-dashboard-authenticated backend APIs** — Browser dashboards must call protected backend endpoints and must not ship database credentials, Supabase service-role keys, Metabase embed secrets, or other server-only credentials to Netlify/frontend code. ## Threat Vectors & Mitigations @@ -37,7 +37,7 @@ Internal operators can inspect these events through `GET /v1/admin/api-client-ev | **Business flow disruption** — Database/logging outage causes quote/ramp requests to fail | Observability writes are fire-and-forget/best-effort and catch their own errors. The request path must proceed exactly as it would without observability. | | **Missing correlation during incidents** — Operators cannot connect a partner report to backend logs | Generate or propagate `requestId` for all requests and return it via `X-Request-ID`. Persist request IDs alongside quote/ramp IDs when available. | | **High-cardinality metric explosion** — Future dashboard metrics use ramp IDs or user IDs as labels | Keep high-cardinality identifiers in logs/event rows only. Export aggregate metrics using bounded labels. | -| **Unbounded telemetry retention** — Raw event rows grow indefinitely | Known follow-up: add retention or aggregation before long-term production alerting/dashboard operation. Initial raw retention should be time-bounded, ideally 30-90 days. | +| **Unbounded telemetry retention** — Raw event rows grow indefinitely | Use the backend retention worker to delete `api_client_events` older than the 7-day UTC calendar retention window. The cleanup runs on startup and daily, uses advisory locking, and deletes in bounded batches. | | **Public dashboard exposure** — A static dashboard URL is discovered by outsiders | Require the dedicated backend metrics dashboard bearer token for all event data. Do not rely on obscurity of Netlify URLs. Keep frontend tokens in operator-controlled browser session storage only. | | **BI embed secret leak** — A future Metabase embed is generated in browser code | Generate signed embed URLs only from the backend. Do not place Metabase signing secrets in Netlify environment variables exposed to Vite. | @@ -53,4 +53,4 @@ Internal operators can inspect these events through `GET /v1/admin/api-client-ev - [ ] Verify future metric exporters do not use request ID, quote ID, ramp ID, user ID, wallet address, tax ID, or PIX key as metric labels. - [ ] Verify `GET /v1/admin/api-client-events` uses `metricsDashboardAuth` and returns only sanitized event fields. - [ ] Verify `apps/dashboard` has no direct database connection and no server-only credentials in Vite-exposed env vars. -- [ ] Add and verify an automated retention or aggregation mechanism before retaining high-volume production telemetry long-term. +- [ ] Verify the API client events retention worker runs on backend startup and daily, and deletes `api_client_events` older than the 7-day UTC calendar retention window in bounded batches. From 0c5c3bbd81fc732af97966a69466422e4f494074 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Tue, 9 Jun 2026 11:55:22 +0200 Subject: [PATCH 06/11] Add raw output amount handling for EVM onramping transactions --- .../api/services/quote/core/squidrouter.ts | 2 + .../quote/engines/finalize/onramp.test.ts | 29 +++++++++ .../services/quote/engines/finalize/onramp.ts | 7 ++- .../quote/engines/squidrouter/index.test.ts | 60 +++++++++++++++++++ .../quote/engines/squidrouter/index.ts | 5 +- .../onramp/routes/avenia-to-evm-base.test.ts | 8 +-- 6 files changed, 102 insertions(+), 9 deletions(-) create mode 100644 apps/api/src/api/services/quote/engines/finalize/onramp.test.ts create mode 100644 apps/api/src/api/services/quote/engines/squidrouter/index.test.ts diff --git a/apps/api/src/api/services/quote/core/squidrouter.ts b/apps/api/src/api/services/quote/core/squidrouter.ts index b23dfad46..02f0ac88b 100644 --- a/apps/api/src/api/services/quote/core/squidrouter.ts +++ b/apps/api/src/api/services/quote/core/squidrouter.ts @@ -46,6 +46,7 @@ export interface EvmBridgeQuoteRequest { export interface EvmBridgeResult { finalGrossOutputAmountDecimal: Big; // Final amount after Squidrouter + finalGrossOutputAmountRaw: string; // Final amount in destination token raw units networkFeeUSD: string; // Squidrouter specific fee finalEffectiveExchangeRate?: string; outputTokenDecimals: number; @@ -261,6 +262,7 @@ export async function calculateEvmBridgeAndNetworkFee(request: EvmBridgeRequest) return { finalEffectiveExchangeRate, finalGrossOutputAmountDecimal, + finalGrossOutputAmountRaw: finalGrossOutputAmount, networkFeeUSD, outputTokenDecimals }; diff --git a/apps/api/src/api/services/quote/engines/finalize/onramp.test.ts b/apps/api/src/api/services/quote/engines/finalize/onramp.test.ts new file mode 100644 index 000000000..da9b5c815 --- /dev/null +++ b/apps/api/src/api/services/quote/engines/finalize/onramp.test.ts @@ -0,0 +1,29 @@ +import {describe, expect, it} from "bun:test"; +import {EvmToken, FiatToken, Networks, RampDirection} from "@vortexfi/shared"; +import Big from "big.js"; +import {OnRampFinalizeEngine} from "./onramp"; + +class TestOnRampFinalizeEngine extends OnRampFinalizeEngine { + compute(ctx: Parameters[0]) { + return this.computeOutput(ctx); + } +} + +describe("OnRampFinalizeEngine", () => { + it("uses destination EVM token decimals for BRL onramp output precision", async () => { + const result = await new TestOnRampFinalizeEngine().compute({ + evmToEvm: { + outputAmountDecimal: new Big("4817.805726163073314321") + }, + request: { + inputCurrency: FiatToken.BRL, + outputCurrency: EvmToken.USDT, + rampType: RampDirection.BUY, + to: Networks.BSC + } + } as never); + + expect(result.decimals).toBe(18); + expect(result.amount.toFixed(result.decimals, 0)).toBe("4817.805726163073314321"); + }); +}); diff --git a/apps/api/src/api/services/quote/engines/finalize/onramp.ts b/apps/api/src/api/services/quote/engines/finalize/onramp.ts index 081043479..4fa2b28a6 100644 --- a/apps/api/src/api/services/quote/engines/finalize/onramp.ts +++ b/apps/api/src/api/services/quote/engines/finalize/onramp.ts @@ -1,7 +1,8 @@ -import { AssetHubToken, FiatToken, RampDirection } from "@vortexfi/shared"; +import { AssetHubToken, FiatToken, OnChainToken, RampDirection } from "@vortexfi/shared"; import Big from "big.js"; import httpStatus from "http-status"; import { APIError } from "../../../../errors/api-error"; +import { getTokenDetailsForEvmDestination } from "../../core/squidrouter"; import { QuoteContext } from "../../core/types"; import { applyAlfredpayLimits, validateAmountLimits } from "../../core/validation-helpers"; import { BaseFinalizeEngine, FinalizeComputation } from "."; @@ -17,6 +18,7 @@ export class OnRampFinalizeEngine extends BaseFinalizeEngine { const { request } = ctx; let finalOutputAmountDecimal: Big; + let finalOutputDecimals = 6; if (request.to === "assethub") { if (request.outputCurrency === AssetHubToken.USDC) { const output = ctx.pendulumToAssethubXcm?.outputAmountDecimal; @@ -46,6 +48,7 @@ export class OnRampFinalizeEngine extends BaseFinalizeEngine { }); } finalOutputAmountDecimal = new Big(output); + finalOutputDecimals = getTokenDetailsForEvmDestination(request.outputCurrency as OnChainToken, request.to).decimals; } else if ( request.inputCurrency === FiatToken.USD || request.inputCurrency === FiatToken.MXN || @@ -88,7 +91,7 @@ export class OnRampFinalizeEngine extends BaseFinalizeEngine { return { amount: finalOutputAmountDecimal, - decimals: 6 + decimals: finalOutputDecimals }; } diff --git a/apps/api/src/api/services/quote/engines/squidrouter/index.test.ts b/apps/api/src/api/services/quote/engines/squidrouter/index.test.ts new file mode 100644 index 000000000..659fbf89d --- /dev/null +++ b/apps/api/src/api/services/quote/engines/squidrouter/index.test.ts @@ -0,0 +1,60 @@ +import {describe, expect, it, mock} from "bun:test"; +import {EvmToken, Networks, RampDirection} from "@vortexfi/shared"; +import Big from "big.js"; +import {QuoteContext} from "../../core/types"; + +const BSC_USDT_OUTPUT_RAW = "4817805726163073314321"; + +mock.module("../../core/squidrouter", () => ({ + calculateEvmBridgeAndNetworkFee: mock(async () => ({ + finalEffectiveExchangeRate: "1", + finalGrossOutputAmountDecimal: new Big("4817.805726163073314321"), + finalGrossOutputAmountRaw: BSC_USDT_OUTPUT_RAW, + networkFeeUSD: "0.061741", + outputTokenDecimals: 18 + })) +})); + +const { BaseSquidRouterEngine } = await import("./index"); + +class TestSquidRouterEngine extends BaseSquidRouterEngine { + readonly config = { + direction: RampDirection.BUY, + skipNote: "skip" + } as const; + + protected validate(): void {} + + protected compute() { + return { + data: { + amountRaw: "4817744605", + fromNetwork: Networks.Base, + fromToken: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913" as const, + inputAmountDecimal: new Big("4817.744605"), + inputAmountRaw: "4817744605", + outputDecimals: 6, + toNetwork: Networks.BSC, + toToken: "0x55d398326f99059fF775485246999027B3197955" as const + }, + type: "evm-to-evm" as const + }; + } +} + +describe("BaseSquidRouterEngine", () => { + it("stores Squid destination raw output instead of rebuilding it with source decimals", async () => { + const ctx = { + addNote: mock(() => undefined), + request: { + outputCurrency: EvmToken.USDT, + rampType: RampDirection.BUY + } + } as unknown as QuoteContext; + + await new TestSquidRouterEngine().execute(ctx); + + expect(ctx.evmToEvm?.outputAmountDecimal.toFixed()).toBe("4817.805726163073314321"); + expect(ctx.evmToEvm?.outputAmountRaw).toBe(BSC_USDT_OUTPUT_RAW); + }); +}); diff --git a/apps/api/src/api/services/quote/engines/squidrouter/index.ts b/apps/api/src/api/services/quote/engines/squidrouter/index.ts index 4c0dd63e2..c6885283e 100644 --- a/apps/api/src/api/services/quote/engines/squidrouter/index.ts +++ b/apps/api/src/api/services/quote/engines/squidrouter/index.ts @@ -50,6 +50,7 @@ export abstract class BaseSquidRouterEngine implements Stage { const passthroughResult: EvmBridgeResult = { finalEffectiveExchangeRate: "1", finalGrossOutputAmountDecimal: computation.data.inputAmountDecimal, + finalGrossOutputAmountRaw: computation.data.inputAmountRaw, networkFeeUSD: "0", outputTokenDecimals: computation.data.outputDecimals }; @@ -110,9 +111,7 @@ export abstract class BaseSquidRouterEngine implements Stage { inputAmountRaw: data.inputAmountRaw, networkFeeUSD: bridgeResult.networkFeeUSD, outputAmountDecimal: bridgeResult.finalGrossOutputAmountDecimal, - outputAmountRaw: new Big(bridgeResult.finalGrossOutputAmountDecimal) - .times(new Big(10).pow(data.outputDecimals)) - .toFixed(0, 0), + outputAmountRaw: bridgeResult.finalGrossOutputAmountRaw, toNetwork: data.toNetwork, toToken: data.toToken }; diff --git a/apps/api/src/api/services/transactions/onramp/routes/avenia-to-evm-base.test.ts b/apps/api/src/api/services/transactions/onramp/routes/avenia-to-evm-base.test.ts index 1dc02a6a1..337a89134 100644 --- a/apps/api/src/api/services/transactions/onramp/routes/avenia-to-evm-base.test.ts +++ b/apps/api/src/api/services/transactions/onramp/routes/avenia-to-evm-base.test.ts @@ -1,4 +1,4 @@ -import { beforeEach, describe, expect, it, mock } from "bun:test"; +import {beforeEach, describe, expect, it, mock} from "bun:test"; import Big from "big.js"; const EVM_EPHEMERAL_ADDRESS = "0x1111111111111111111111111111111111111111"; @@ -194,7 +194,7 @@ describe("prepareAveniaToEvmOnrampTransactionsOnBase", () => { addOnrampDestinationChainTransactions.mockClear(); }); - it("uses fixed integer notation for large final destination raw amounts", async () => { + it("preserves 18-decimal BSC USDT precision for the final destination raw amount", async () => { await prepareAveniaToEvmOnrampTransactionsOnBase({ destinationAddress: DESTINATION_ADDRESS, quote: { @@ -212,7 +212,7 @@ describe("prepareAveniaToEvmOnrampTransactionsOnBase", () => { } }, network: Networks.BSC, - outputAmount: "4818.903696", + outputAmount: "4817.805726163073314321", outputCurrency: EvmToken.USDT, to: Networks.BSC } as never, @@ -222,7 +222,7 @@ describe("prepareAveniaToEvmOnrampTransactionsOnBase", () => { expect(destinationTransferCalls).toContainEqual( expect.objectContaining({ - amountRaw: "4818903696000000000000", + amountRaw: "4817805726163073314321", destinationNetwork: Networks.BSC }) ); From ef4353b73a0cde8290b66c44adc0acaee7cf7336 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Tue, 9 Jun 2026 12:00:44 +0200 Subject: [PATCH 07/11] Adjust security spec files --- docs/security-spec/03-ramp-engine/quote-lifecycle.md | 3 +++ docs/security-spec/03-ramp-engine/ramp-phase-flows.md | 1 + docs/security-spec/05-integrations/brla.md | 5 +++++ docs/security-spec/05-integrations/squid-router.md | 5 +++++ 4 files changed, 14 insertions(+) diff --git a/docs/security-spec/03-ramp-engine/quote-lifecycle.md b/docs/security-spec/03-ramp-engine/quote-lifecycle.md index 76fb47a9d..58b974a00 100644 --- a/docs/security-spec/03-ramp-engine/quote-lifecycle.md +++ b/docs/security-spec/03-ramp-engine/quote-lifecycle.md @@ -66,6 +66,7 @@ The refresh policy is intentionally strict (byte-identical `toAmount` and `fee` 8. **Dynamic pricing state MUST NOT be externally modifiable** — The `partnerDiscountState` Map is in-memory and module-private. No API endpoint should expose or allow modification of discount state. 9. **Exchange rates MUST be sourced from authoritative on-chain data** — Swap rates should come from the actual DEX (Nabla) or routing protocol (Squid), not from stale caches or third-party price feeds that could be manipulated. 10. **Subsidy MUST only be applied when `targetDiscount > 0`** — If a partner has no target discount configured, the subsidy amount is always `0`, regardless of the shortfall. +11. **Quote output precision MUST match the final settlement token** — For EVM onramps whose final output comes from Squid, the stored `quote.outputAmount` must retain the destination token's precision, not a fixed source-token precision. Downstream transaction builders convert this immutable quote amount back to raw units using destination token decimals. ## Threat Vectors & Mitigations @@ -81,6 +82,7 @@ The refresh policy is intentionally strict (byte-identical `toAmount` and `fee` | **Unauthorized quote consumption** | Attacker binds someone else's quote to their own ramp | Quotes carrying an owner (`partnerId` or `userId`) are bound to that owner; ownership is verified at ramp registration via `assertQuoteOwnership`. Fully-anonymous quotes (no `partnerId` and no `userId`) are intentionally consumable by any caller — they cannot leak privileged data because they were created without a principal in the first place. | | **Negative `minDynamicDifference`** | If `minDynamicDifference` is set to a large negative value in the partner DB record, consuming quotes could push the rate below the base `targetDiscount`, potentially making the effective discount negative (user receives less than the oracle rate) | DB constraint: `minDynamicDifference` defaults to `0`. However, there is no DB-level CHECK constraint preventing negative values. If set manually, the clamping logic would allow `difference` to go negative. | | **Concurrent quote and consumption** | Two simultaneous requests — one quoting, one consuming — for the same partner could read stale `difference` values from the in-memory Map | JavaScript's single-threaded event loop prevents true concurrency for synchronous Map operations. However, the `async` functions in `compute()` could interleave if there are `await` points between reading and writing the Map. In practice, the read and write of `partnerDiscountState` in `getAdjustedDifference` are synchronous, so this is safe within a single process. | +| **Quote-output precision loss** | A quote targets an 18-decimal destination token but stores only 6 decimal places. The user-visible amount looks close, but final raw transfer construction under-delivers by the truncated dust amount. | Finalize EVM onramp quotes with destination token decimals when the final amount comes from Squid; tests should cover 6-decimal source → 18-decimal destination routes. | ## Audit Checklist @@ -96,6 +98,7 @@ The refresh policy is intentionally strict (byte-identical `toAmount` and `fee` - [x] Exchange rates used in quote calculation come from live on-chain sources (Nabla, Squid), not stale caches. **PASS** — verified: rates fetched from Nabla DEX and SquidRouter API at quote time. - [x] Quote response does not include internal implementation details (e.g., the `adjustedDifference` or `adjustedTargetDiscount` values). **PASS** — verified: response includes only user-facing fields (amounts, fees, expiry). - [x] Quote amounts (input, output, fees) are immutable once stored — no UPDATE endpoint modifies them. **PASS** — no quote mutation endpoints exist. +- [x] EVM onramp output precision follows destination token decimals where the quote output comes from Squid. **PASS** — BRL/EURC Base→EVM finalization preserves destination token precision before downstream raw transfer construction. - [PARTIAL] Authentication is enforced on quote creation (verify which auth mechanisms protect `POST /v1/ramp/quotes`). **PARTIAL** — quote creation is accessible via API key auth or Supabase auth; the endpoint is optional-auth by design (public quotes allowed for some partners). - [PARTIAL] Quote ownership is verified at ramp registration — the user/partner creating the ramp must match the quote creator. **PARTIAL** — no strict user-to-quote binding; mitigated by UUID unpredictability and 10-minute expiry. - [x] Subsidy is only calculated when `targetDiscount > 0` — partners with no discount get `0` subsidy regardless of shortfall. **PASS** — verified in `calculateSubsidyAmount()`. 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 2130532b0..9b734af57 100644 --- a/docs/security-spec/03-ramp-engine/ramp-phase-flows.md +++ b/docs/security-spec/03-ramp-engine/ramp-phase-flows.md @@ -39,6 +39,7 @@ There are 29+ phase handlers in `apps/api/src/api/services/phases/handlers/`. Th - Runtime backend phases: `initial` → `brlaOnrampMint` (poll Base RPC, 30min outer / 5min inner) → `fundEphemeral` → `subsidizePreSwap` → `nablaApprove` → `nablaSwap` → `distributeFees` → `subsidizePostSwap` → `squidRouterSwap` → `destinationTransfer` → `complete` - Skip-Squid case (destination = Base USDC): the `squidRouterSwap` handler short-circuits directly to `destinationTransfer`. - Cross-chain case (destination ≠ Base USDC): `squidRouterSwap` → `squidRouterPay` → `finalSettlementSubsidy` → `destinationTransfer` for supported EVM destinations. **BRL→AssetHub quotes are temporarily disabled** and should not enter this phase chain. +- Amount precision: the Squid quote output is stored as the final EVM destination amount. `evmToEvm.inputAmountRaw` remains Base USDC raw and drives the Squid source-chain swap, while `evmToEvm.outputAmountRaw` and `quote.outputAmount` must use the destination token's raw/decimal precision before `destinationTransfer` is built. - **Degenerate BRL→BRLA-on-Base case:** `isBrlToBrlaBaseDirect` short-circuits the entire pipeline to a single `destinationTransfer` (no Nabla, no `distributeFees`, no Squid, no `finalSettlementSubsidy`, no cleanup), because Avenia already mints BRLA on the Base ephemeral and the generic path would otherwise swap BRLA→USDC→BRLA for itself. Mirrors the EUR→EURC-on-Base bypass. See `05-integrations/brla.md`. - Base ephemeral cleanup (`baseCleanupUsdc`, `baseCleanupBrla`) is performed out-of-flow by a separate sweeper after `complete`; cleanup approvals are presigned but not part of the runtime nextPhase chain. diff --git a/docs/security-spec/05-integrations/brla.md b/docs/security-spec/05-integrations/brla.md index a771bfa8e..e356d48e8 100644 --- a/docs/security-spec/05-integrations/brla.md +++ b/docs/security-spec/05-integrations/brla.md @@ -22,6 +22,8 @@ BRLA is the Brazilian Real stablecoin used for BRL on/off-ramp operations, acces 5. `subsidizePostSwap` (if needed) → `distributeFees` (Multicall3 batch on Base, see `fee-integrity.md`). 6. If destination is Base + USDC → direct `destinationTransfer` (Squid skipped — see `squid-router.md`). Otherwise → `squidRouterApprove` / `squidRouterSwap` → bridge to user's supported destination EVM chain → optional fallback `backupSquidRouter*` swap on the destination chain → `destinationTransfer`. BRL→AssetHub is temporarily disabled at quote eligibility and should not reach registration. +For non-Base EVM destinations, the final quote output is the Squid destination-token amount. `quote.outputAmount` MUST be stored with the destination token's decimals (for example, 18 decimals for BSC USDT) because `avenia-to-evm-base.ts` derives the final `destinationTransfer` raw amount by multiplying the stored quote output by the destination token decimals. Truncating all BRL on-ramp outputs to 6 decimals would create tiny but real under-delivery for 18-decimal destination tokens and would make `evmToEvm.outputAmountRaw` disagree with the destination-token raw amount returned by Squid. + #### Degenerate BRL→BRLA-on-Base route (direct bypass) When the user on-ramps BRL and asks for **BRLA delivered on Base** (input BRL, output BRLA, network Base), the generic pipeline would pointlessly swap BRLA→USDC on Nabla and then bridge/swap USDC→BRLA back to itself — burning two swaps of slippage and fees, and inviting the over-subsidy race documented in `06-cross-chain/fund-routing.md`. Avenia already mints BRLA directly on the Base ephemeral, so the route builder detects this case via `isBrlToBrlaBaseDirect(quote.inputCurrency, quote.outputCurrency, quote.network)` (`api/services/quote/utils.ts`) and emits a **single** `destinationTransfer` of the quoted output amount straight from the ephemeral to the user — no `nablaApprove`/`nablaSwap`, no `distributeFees`, no `squidRouter*`, no `finalSettlementSubsidy`, no Base cleanup. `stateMeta.isDirectTransfer = true` is set so the downstream `squidRouterSwap` and `finalSettlementSubsidy` handlers also short-circuit defensively if ever reached (`avenia-to-evm-base.ts`). The destination-transfer nonce is `0` (the ephemeral has signed nothing else on this corridor). This mirrors the EUR→EURC-on-Base bypass (`mykobo.md`). @@ -70,6 +72,7 @@ The invariant `transferAmount ≥ payoutAmount` must hold (transfer covers payou 12. **PIX deposit details (QR code) MUST be generated server-side** — Returned via API response only after presigned transactions are validated, never client-modifiable. 13. **BRL↔AssetHub MUST stay disabled while the Base BRL rail is active-only** — The quote engine should return no quote for BRL→AssetHub or AssetHub→BRL, preventing users from registering legacy Moonbeam/Pendulum BRL routes. 14. **The BRL→BRLA-on-Base on-ramp MUST take the direct-transfer bypass** — When `inputCurrency === BRL`, `outputCurrency === BRLA`, and `network === Base`, `isBrlToBrlaBaseDirect` MUST short-circuit the route to a single `destinationTransfer` from the ephemeral to the user, with `stateMeta.isDirectTransfer = true`. The Nabla swap, `distributeFees`, SquidRouter, `finalSettlementSubsidy`, and Base cleanup phases MUST NOT run — routing BRLA through USDC and back would burn double-swap slippage/fees against the user and expose the over-subsidy race (`06-cross-chain/fund-routing.md`). The `squidRouterSwap` and `finalSettlementSubsidy` handlers MUST also honor `isDirectTransfer`/`isBrlToBrlaBaseDirect` defensively and skip to `destinationTransfer` if reached. +15. **BRL→EVM quote output precision MUST match the destination token** — For supported EVM destinations, `quote.outputAmount` MUST preserve the destination token's decimal precision, and `evmToEvm.outputAmountRaw` MUST represent the destination token's raw units. The Squid bridge input remains Base USDC raw (`evmToEvm.inputAmountRaw`), but final delivery uses destination-token decimals. ## Threat Vectors & Mitigations @@ -86,6 +89,7 @@ The invariant `transferAmount ≥ payoutAmount` must hold (transfer covers payou | **Underdelivery from Nabla** | Nabla swap returns less BRLA than quoted, balance poll times out, ramp stuck | Balance-poll timeout (5min) fails the phase as recoverable; `subsidizePostSwap` (EVM branch) tops up shortfalls subject to the quote-relative EVM subsidy cap documented in `fund-routing.md`. | | **Disabled AssetHub corridor accidentally re-enabled** | Legacy BRL↔AssetHub route files are selected and a user registers a route that the Base BRL rail no longer supports | Quote eligibility must return no quote for BRL→AssetHub and AssetHub→BRL. Treat any successful quote for those corridors as a regression until the corridor is intentionally re-enabled. | | **BRL→BRLA-Base self-swap drain** | The generic pipeline swaps the user's already-minted BRLA to USDC and back, charging two swaps of slippage/fees and triggering `finalSettlementSubsidy` against bridge-less dust (over-subsidy + strand) | `isBrlToBrlaBaseDirect` collapses the corridor to a single `destinationTransfer` with `isDirectTransfer = true`; Nabla/distributeFees/Squid/finalSettlementSubsidy/cleanup are skipped at both route-build and handler level. | +| **Destination-token decimal under-delivery** | A BRL on-ramp targets an 18-decimal token such as BSC USDT, but the quote output is truncated to 6 decimals before `destinationTransfer` raw amount construction. | On-ramp finalization uses destination-token decimals for BRL EVM outputs; Squid metadata preserves destination raw output from `route.estimate.toAmount`. | ## Audit Checklist @@ -109,6 +113,7 @@ The invariant `transferAmount ≥ payoutAmount` must hold (transfer covers payou - [x] **FINDING F-064 (MEDIUM)**: BRLA KYC callback endpoint requires authentication. **PASS (FIXED)** — `/kyc/record-attempt` uses `requireAuth`. - [x] BRL→BRLA-on-Base on-ramps (`isBrlToBrlaBaseDirect`) emit a single `destinationTransfer` with `isDirectTransfer = true` — no Nabla swap, `distributeFees`, SquidRouter, `finalSettlementSubsidy`, or Base cleanup phases. **PASS** — `avenia-to-evm-base.ts` early direct branch (single tx at nonce 0). - [x] `squidRouterSwap` and `finalSettlementSubsidy` honor `isDirectTransfer` / `isBrlToBrlaBaseDirect` and short-circuit to `destinationTransfer` if ever reached on a direct route. **PASS** — `squid-router-phase-handler.ts`, `final-settlement-subsidy.ts`. +- [x] BRL→EVM destination-token precision preserved. **PASS** — `OnRampFinalizeEngine` stores BRL/EVM `quote.outputAmount` using destination token decimals, and `BaseSquidRouterEngine` preserves Squid's destination raw output in `evmToEvm.outputAmountRaw`. ## Remediation Notes diff --git a/docs/security-spec/05-integrations/squid-router.md b/docs/security-spec/05-integrations/squid-router.md index 7c3317a42..737d8fe20 100644 --- a/docs/security-spec/05-integrations/squid-router.md +++ b/docs/security-spec/05-integrations/squid-router.md @@ -29,6 +29,8 @@ It handles cross-chain swap execution, Axelar bridge status monitoring, and gas 5. Optional `backupSquidRouterApprove` / `backupSquidRouterSwap` on the destination chain if the bridged token (axlUSDC / USDC) needs further conversion to the user's requested output token. **F-054: these `backup*` presigned txs have no registered phase handler.** 6. `destinationTransfer` to the user. +For quote metadata, Squid's `route.estimate.toAmount` is already denominated in the **destination token's raw units**. The bridge metadata (`evmToEvm.outputAmountRaw`, `moonbeamToEvm.outputAmountRaw`, etc.) MUST preserve that raw value directly instead of rebuilding it from the human-readable decimal amount with source-token decimals. This matters for routes like Base USDC (6 decimals) → BSC USDT (18 decimals), where using the source decimals would under-scale the metadata by 12 decimal places. + ### Off-ramp flow (user EVM source → Base USDC) 1. User signs one of three paths (depending on source ERC-20 capabilities and direction): @@ -58,6 +60,7 @@ When the BRL on-ramp's destination is **Base + USDC**, the Nabla swap output is 10. **No-permit fallback MUST NOT advance to `fundEphemeral` until BOTH approve and swap (or the direct transfer) have confirmed** — Sequential `waitForUserHash` calls in `executeNoPermitFallback` enforce this. 11. **Transaction hashes MUST be persisted to state before waiting** — `squidRouterApproveHash`, `squidRouterSwapHash`, `squidRouterPayTxHash`, `squidRouterPermitExecutionHash`, `squidRouterNoPermitApproveHash`, `squidRouterNoPermitSwapHash`, `squidRouterNoPermitTransferHash` all enable crash recovery. 12. **Skip-Squid path MUST NOT lose destination validation** — Quote engine `validate()` runs regardless of `skipRouteCalculation`; `destinationTransfer` is the only on-chain step that fires. +13. **Squid output raw metadata MUST use destination-token raw units** — `route.estimate.toAmount` is the authoritative destination raw output; `evmToEvm.outputAmountRaw` MUST NOT be recomputed with the source token's decimals. For same-chain same-token passthrough, `inputAmountRaw` is also the destination raw amount and is safe to mirror. ## Threat Vectors & Mitigations @@ -74,6 +77,7 @@ When the BRL on-ramp's destination is **Base + USDC**, the Nabla swap output is | **No-permit fallback hash spoofing** | User reports tx hash → backend calls `waitForTransactionReceipt(hash)`. Hash is verified against actual chain state, not trusted blindly. The worst the user can do is report a hash that doesn't exist (handler errors recoverably) or a hash for a different transaction (receipt's `to`/`value` are not currently re-checked — see open question below). | | **No-permit allowance window attack** | The `squidRouterNoPermitApprove` grants Squid an allowance from the user's wallet; if the swap hash never confirms, the allowance lingers. The user wallet, not Vortex, retains the risk. UX should remind the user to revoke unused allowances; backend cannot revoke on the user's behalf. | | **Skip-Squid trivial-case manipulation** | The skip path triggers only when destination is Base+USDC, validated server-side by the quote engine before any presigned tx is generated. Attacker cannot force the skip path on non-Base/non-USDC routes. | +| **Destination decimal under-scaling** | A quote route bridges from a 6-decimal source token to an 18-decimal destination token (for example Base USDC → BSC USDT), but metadata reconstructs the destination raw output using source decimals. Displayed decimals look correct while raw metadata is under-scaled. | Preserve Squid's `route.estimate.toAmount` directly as destination-token raw metadata, and persist `quote.outputAmount` with destination-token precision before building the final transfer. | **⚠️ FINDING F-CARRIED**: In `squid-router-phase-handler.ts` line 147, `getPublicClient()` defaults to Moonbeam if `inputCurrency` doesn't match any known case and logs "This is a bug." Same handler also catches errors and silently defaults to Moonbeam (line 151-152). This fallback could cause transactions to be submitted to the wrong network. @@ -95,6 +99,7 @@ When the BRL on-ramp's destination is **Base + USDC**, the Nabla swap output is - [x] **FINDING F-063 (MEDIUM)**: SquidRouter slippage rejection (>2.5%) enforced. **PASS (FIXED)**. - [x] **No-permit fallback receipt validation**: `waitForUserHash` verifies receipt `from`, receipt `to`, and transaction `input` against the expected user address and presigned EVM transaction payload before advancing. - [x] **Skip-Squid trivial path**: emits passthrough bridge meta in `BaseSquidRouterEngine` and short-circuits discount/fee engines. Destination address validated by quote engine `validate()`. **PASS** — no security checks bypassed. +- [x] **Destination-token raw output metadata**: `evmToEvm.outputAmountRaw` preserves Squid's `route.estimate.toAmount` in destination raw units. **PASS** — prevents Base USDC → BSC USDT-style 6-vs-18 decimal under-scaling. - [x] **Squid 429 rate-limit retry**: exponential backoff. **PASS — verify backoff cap.** - [x] **Arrival timeout**: `waitUntilTrue` accepts a timeout argument. **PASS** — verify all callers pass a finite value. - [EXISTING FINDING F-054]: `backupSquidRouterApprove`/`backupSquidRouterSwap`/`backupApprove` presigned txs have no registered phase handler. Either dead code or missing implementation. From 49db1c2a03f062617e66944758ac79855a0156de Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Tue, 9 Jun 2026 12:03:20 +0200 Subject: [PATCH 08/11] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- apps/api/src/api/workers/api-client-events-retention.worker.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/api/src/api/workers/api-client-events-retention.worker.ts b/apps/api/src/api/workers/api-client-events-retention.worker.ts index 84a5a11f6..89076c1da 100644 --- a/apps/api/src/api/workers/api-client-events-retention.worker.ts +++ b/apps/api/src/api/workers/api-client-events-retention.worker.ts @@ -33,7 +33,8 @@ class ApiClientEventsRetentionWorker { logger.info("API client events retention worker cycle completed"); } catch (error) { - logger.error("Error during API client events retention worker cycle:", error); + const errorDetails = error instanceof Error ? error.stack ?? error.message : String(error); + logger.error(`Error during API client events retention worker cycle: ${errorDetails}`); } } } From 1caf0d44fef02c2265b456bd65462d18f570849b Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Tue, 9 Jun 2026 12:24:38 +0200 Subject: [PATCH 09/11] Remove api client event metrics dashboard again from the project --- README.md | 28 -- apps/api/src/config/express.ts | 2 - apps/dashboard/.env.example | 2 - apps/dashboard/App.css | 47 --- apps/dashboard/_redirects | 9 - apps/dashboard/index.html | 13 - apps/dashboard/package.json | 29 -- apps/dashboard/public/404.html | 25 -- apps/dashboard/src/App.tsx | 329 ------------------ .../dashboard/src/api/apiClientEvents.test.ts | 76 ---- apps/dashboard/src/api/apiClientEvents.ts | 92 ----- apps/dashboard/src/config/index.ts | 19 - apps/dashboard/src/helpers/cn.ts | 6 - apps/dashboard/src/main.tsx | 27 -- apps/dashboard/tsconfig.json | 27 -- apps/dashboard/tsconfig.node.json | 9 - apps/dashboard/vite.config.ts | 19 - bun.lock | 23 -- docs/security-spec/01-auth/admin-auth.md | 2 +- .../07-operations/client-observability.md | 17 +- .../07-operations/secret-management.md | 4 +- package.json | 7 +- 22 files changed, 13 insertions(+), 799 deletions(-) delete mode 100644 apps/dashboard/.env.example delete mode 100644 apps/dashboard/App.css delete mode 100644 apps/dashboard/_redirects delete mode 100644 apps/dashboard/index.html delete mode 100644 apps/dashboard/package.json delete mode 100644 apps/dashboard/public/404.html delete mode 100644 apps/dashboard/src/App.tsx delete mode 100644 apps/dashboard/src/api/apiClientEvents.test.ts delete mode 100644 apps/dashboard/src/api/apiClientEvents.ts delete mode 100644 apps/dashboard/src/config/index.ts delete mode 100644 apps/dashboard/src/helpers/cn.ts delete mode 100644 apps/dashboard/src/main.tsx delete mode 100644 apps/dashboard/tsconfig.json delete mode 100644 apps/dashboard/tsconfig.node.json delete mode 100644 apps/dashboard/vite.config.ts diff --git a/README.md b/README.md index 6f46858c1..303ba342e 100644 --- a/README.md +++ b/README.md @@ -32,7 +32,6 @@ This is a **Bun monorepo** containing multiple sub-projects organized into apps, ### Apps - **[apps/api](apps/api)** - Backend API service providing signature services, on/off-ramping flows, quote generation, and transaction state management -- **[apps/dashboard](apps/dashboard)** - Internal React dashboard for protected API client observability data - **[apps/frontend](apps/frontend)** - React-based web application built with Vite for the Vortex user interface - **[apps/rebalancer](apps/rebalancer)** - Service for automated liquidity rebalancing across chains ### Contracts @@ -68,7 +67,6 @@ bun dev This will start: - **Frontend**: [http://127.0.0.1:5173/](http://127.0.0.1:5173) -- **Dashboard**: [http://127.0.0.1:5175/](http://127.0.0.1:5175) - **Backend API**: [http://localhost:3000](http://localhost:3000) #### Run Individual Projects @@ -78,11 +76,6 @@ This will start: bun dev:frontend ``` -**Internal dashboard only:** -```bash -bun dev:dashboard -``` - **Backend API only:** ```bash bun dev:backend @@ -110,9 +103,6 @@ bun build # Build frontend bun build:frontend -# Build internal dashboard -bun build:dashboard - # Build backend API bun build:backend @@ -188,24 +178,6 @@ bun start See [apps/api/README.md](apps/api/README.md) for detailed API documentation. -### Internal Dashboard (apps/dashboard) - -The internal dashboard displays sanitized API client observability events from `GET /v1/admin/api-client-events`. The backend endpoint requires `Authorization: Bearer `, and the dashboard stores the entered token in browser session storage only. Use a different value than `ADMIN_SECRET` so a dashboard token compromise cannot access broader admin operations. - -**Development:** -```bash -cd apps/dashboard -bun dev -``` - -**Build:** -```bash -cd apps/dashboard -bun build -``` - -For Netlify, set the build command to `bun build` from `apps/dashboard` or use the root script `bun build:dashboard`. `VITE_DASHBOARD_API_BASE` can override the API base URL; otherwise the app uses `http://localhost:3000` in development and the `/api/` Netlify redirects in deployed environments. - ### Rebalancer (apps/rebalancer) Service for automated liquidity rebalancing across chains. diff --git a/apps/api/src/config/express.ts b/apps/api/src/config/express.ts index f8c7aebfa..89028bffe 100644 --- a/apps/api/src/config/express.ts +++ b/apps/api/src/config/express.ts @@ -37,8 +37,6 @@ app.use( config.env !== "production" ? "https://staging--vortexfi.netlify.app" : null, config.env === "development" ? "http://localhost:5173" : null, config.env === "development" ? "http://127.0.0.1:5173" : null, - config.env === "development" ? "http://localhost:5175" : null, - config.env === "development" ? "http://127.0.0.1:5175" : null, config.env === "development" ? "http://localhost:6006" : null ].filter(Boolean) as string[] }) diff --git a/apps/dashboard/.env.example b/apps/dashboard/.env.example deleted file mode 100644 index 6f77fe506..000000000 --- a/apps/dashboard/.env.example +++ /dev/null @@ -1,2 +0,0 @@ -VITE_DASHBOARD_API_BASE=http://localhost:3000 -VITE_ENVIRONMENT=development diff --git a/apps/dashboard/App.css b/apps/dashboard/App.css deleted file mode 100644 index 30e018e16..000000000 --- a/apps/dashboard/App.css +++ /dev/null @@ -1,47 +0,0 @@ -@import "tailwindcss"; - -:root { - color: #e7f7ee; - background: #06100d; - font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; - font-synthesis: none; - text-rendering: optimizeLegibility; - -webkit-font-smoothing: antialiased; - -moz-osx-font-smoothing: grayscale; -} - -body { - margin: 0; - min-width: 320px; - min-height: 100vh; -} - -button, -input, -select { - font: inherit; -} - -button { - transition: - background-color 120ms ease, - border-color 120ms ease, - color 120ms ease, - opacity 120ms ease, - transform 80ms ease, - box-shadow 120ms ease; -} - -button:not(:disabled):active { - transform: translateY(1px); -} - -#app { - min-height: 100vh; -} - -.dashboard-shell { - min-height: 100vh; - background: radial-gradient(circle at top left, rgba(36, 201, 137, 0.2), transparent 32rem), - linear-gradient(135deg, #06100d 0%, #0c1714 48%, #09110f 100%); -} diff --git a/apps/dashboard/_redirects b/apps/dashboard/_redirects deleted file mode 100644 index ade7e4cba..000000000 --- a/apps/dashboard/_redirects +++ /dev/null @@ -1,9 +0,0 @@ -/api/production/* https://api.vortexfinance.co/:splat 200 -/api/staging/* https://api-staging.vortexfinance.co/:splat 200 -/api/sandbox/* https://api-sandbox.vortexfinance.co/:splat 200 - -# Known dashboard routes — serve SPA shell with real 200 -/ /index.html 200 - -# Everything else is a real 404 -/* /404.html 404 diff --git a/apps/dashboard/index.html b/apps/dashboard/index.html deleted file mode 100644 index 1d0cf4ce7..000000000 --- a/apps/dashboard/index.html +++ /dev/null @@ -1,13 +0,0 @@ - - - - - - - Vortex Internal Dashboard - - -
- - - diff --git a/apps/dashboard/package.json b/apps/dashboard/package.json deleted file mode 100644 index f10ef5a31..000000000 --- a/apps/dashboard/package.json +++ /dev/null @@ -1,29 +0,0 @@ -{ - "dependencies": { - "@tailwindcss/vite": "^4.0.3", - "@tanstack/react-query": "^5.64.2", - "@vitejs/plugin-react": "^4.3.4", - "clsx": "^2.1.1", - "react": "=19.2.0", - "react-dom": "=19.2.0", - "tailwind-merge": "^3.4.0", - "tailwindcss": "^4.0.3" - }, - "devDependencies": { - "@types/node": "catalog:", - "@types/react": "^19.0.8", - "@types/react-dom": "^19.0.3", - "typescript": "catalog:", - "vite": "^6.2.6" - }, - "name": "vortex-dashboard", - "private": true, - "scripts": { - "build": "bun x --bun vite build && cp _redirects dist/_redirects", - "dev": "bun x --bun vite --host", - "preview": "bun x --bun vite preview", - "test": "bun test" - }, - "type": "module", - "version": "1.0.0" -} diff --git a/apps/dashboard/public/404.html b/apps/dashboard/public/404.html deleted file mode 100644 index 00bb59dc9..000000000 --- a/apps/dashboard/public/404.html +++ /dev/null @@ -1,25 +0,0 @@ - - - - - - Not found - - - -
-

404

-

This internal dashboard route does not exist.

-
- - diff --git a/apps/dashboard/src/App.tsx b/apps/dashboard/src/App.tsx deleted file mode 100644 index 149dfd7e3..000000000 --- a/apps/dashboard/src/App.tsx +++ /dev/null @@ -1,329 +0,0 @@ -import { useQuery, useQueryClient } from "@tanstack/react-query"; -import { type FormEvent, type ReactNode, useMemo, useState } from "react"; -import { ApiClientEventsFilters, fetchApiClientEvents, normalizeMetricsDashboardToken } from "./api/apiClientEvents"; -import { cn } from "./helpers/cn"; - -const tokenStorageKey = "vortex-dashboard-metrics-token"; - -const defaultFilters: ApiClientEventsFilters = { - limit: 50, - offset: 0 -}; - -const operations = [ - "auth_api_key", - "auth_public_key", - "auth_dual", - "auth_ownership", - "quote_create", - "quote_create_best", - "quote_get", - "ramp_register", - "ramp_update", - "ramp_start", - "ramp_status", - "ramp_errors" -]; - -function readStoredToken(): string { - return window.sessionStorage.getItem(tokenStorageKey) ?? ""; -} - -export function App() { - const queryClient = useQueryClient(); - const [metricsToken, setMetricsToken] = useState(readStoredToken); - const [tokenDraft, setTokenDraft] = useState(metricsToken); - const [filters, setFilters] = useState(defaultFilters); - - const eventsQuery = useQuery({ - enabled: metricsToken.length > 0, - queryFn: () => fetchApiClientEvents(filters, metricsToken), - queryKey: ["api-client-events", filters, metricsToken] - }); - - const failureRate = useMemo(() => { - const summary = eventsQuery.data?.summary; - if (!summary || summary.sampleSize === 0) return "0.0%"; - - const failures = summary.byStatus.failure ?? 0; - return `${((failures / summary.sampleSize) * 100).toFixed(1)}%`; - }, [eventsQuery.data]); - - function handleTokenSubmit(event: FormEvent) { - event.preventDefault(); - const normalizedToken = normalizeMetricsDashboardToken(tokenDraft); - window.sessionStorage.setItem(tokenStorageKey, normalizedToken); - setMetricsToken(normalizedToken); - setTokenDraft(normalizedToken); - } - - function handleFiltersSubmit(event: FormEvent) { - event.preventDefault(); - const formData = new FormData(event.currentTarget); - setFilters({ - apiKeyPrefix: stringValue(formData, "apiKeyPrefix"), - endDate: stringValue(formData, "endDate"), - errorType: stringValue(formData, "errorType"), - limit: numberValue(formData, "limit", 50), - offset: 0, - operation: stringValue(formData, "operation"), - partnerName: stringValue(formData, "partnerName"), - quoteId: stringValue(formData, "quoteId"), - rampId: stringValue(formData, "rampId"), - requestId: stringValue(formData, "requestId"), - startDate: stringValue(formData, "startDate"), - status: stringValue(formData, "status") - }); - } - - function handleClearToken() { - window.sessionStorage.removeItem(tokenStorageKey); - setMetricsToken(""); - setTokenDraft(""); - } - - function movePage(direction: -1 | 1) { - setFilters(currentFilters => ({ - ...currentFilters, - offset: Math.max(0, currentFilters.offset + direction * currentFilters.limit) - })); - } - - return ( -
-
-
-
-

Internal observability

-

API client events

-

- Sanitized partner API activity, protected by a dedicated metrics dashboard bearer token. Tokens are kept in - session storage only. -

-
-
- -
- setTokenDraft(event.target.value)} - placeholder="Bearer token value" - type="password" - value={tokenDraft} - /> - - -
-
-
- -
- - - - - - - - - - - - - - -
- - - - -
- -
-
-
-

Recent events

-

- Showing {eventsQuery.data?.events.length ?? 0} rows from offset {filters.offset}. -

-
-
- - - -
-
- - {eventsQuery.isError ? : null} - {eventsQuery.isLoading ? : null} - {!metricsToken ? : null} - {eventsQuery.data ? : null} -
-
-
- ); -} - -function stringValue(formData: FormData, key: string): string | undefined { - const value = formData.get(key); - if (typeof value !== "string") return undefined; - - const trimmedValue = value.trim(); - return trimmedValue.length > 0 ? trimmedValue : undefined; -} - -function numberValue(formData: FormData, key: string, fallback: number): number { - const value = stringValue(formData, key); - if (!value) return fallback; - - const parsed = Number.parseInt(value, 10); - return Number.isFinite(parsed) ? parsed : fallback; -} - -function FilterInput({ name, placeholder, type = "text" }: { name: string; placeholder?: string; type?: string }) { - return ( - - ); -} - -function FilterSelect({ name, options, placeholder }: { name: string; options: string[]; placeholder: string }) { - return ( - - ); -} - -function MetricCard({ label, value }: { label: string; value: number | string }) { - return ( -
-

{label}

-

{value}

-
- ); -} - -function EventsTable({ events }: { events: Awaited>["events"] }) { - if (events.length === 0) { - return ; - } - - return ( -
- - - - Created - Status - Operation - Partner - HTTP - Error - Duration - Ramp / Quote - - - - {events.map(event => ( - - {new Date(event.createdAt).toLocaleString()} - - - {event.status} - - - {event.operation} - {event.partnerName ?? "—"} - {event.httpStatus ?? "—"} - -
{event.errorType ?? "—"}
- {event.errorMessage ?
{event.errorMessage}
: null} -
- {event.durationMs ? `${event.durationMs}ms` : "—"} - -
{event.rampId ?? "—"}
-
{event.quoteId ?? "—"}
-
- - ))} - -
-
- ); -} - -function HeaderCell({ children }: { children: ReactNode }) { - return {children}; -} - -function BodyCell({ children }: { children: ReactNode }) { - return {children}; -} - -function EmptyState({ message }: { message: string }) { - return
{message}
; -} - -function ErrorState({ message }: { message: string }) { - return
{message}
; -} diff --git a/apps/dashboard/src/api/apiClientEvents.test.ts b/apps/dashboard/src/api/apiClientEvents.test.ts deleted file mode 100644 index 80232ef42..000000000 --- a/apps/dashboard/src/api/apiClientEvents.test.ts +++ /dev/null @@ -1,76 +0,0 @@ -import { afterEach, describe, expect, it, mock } from "bun:test"; -import { buildApiClientEventsUrl, fetchApiClientEvents, normalizeMetricsDashboardToken } from "./apiClientEvents"; - -describe("buildApiClientEventsUrl", () => { - it("builds relative Netlify proxy URLs with query filters", () => { - const url = buildApiClientEventsUrl({ limit: 50, offset: 0, partnerName: "Partner" }, "/api/staging"); - - expect(url).toBe("/api/staging/v1/admin/api-client-events?limit=50&offset=0&partnerName=Partner"); - }); -}); - -describe("fetchApiClientEvents", () => { - const originalFetch = globalThis.fetch; - - afterEach(() => { - globalThis.fetch = originalFetch; - }); - - it("sends the metrics dashboard bearer token to the protected endpoint", async () => { - const fetchMock = mock(async (_input: string | URL | Request, _init?: RequestInit) => - Response.json({ - events: [], - limit: 50, - offset: 0, - summary: { byErrorType: {}, byOperation: {}, byStatus: {}, sampleSize: 0, total: 0 }, - total: 0 - }) - ); - globalThis.fetch = fetchMock as unknown as typeof fetch; - - await fetchApiClientEvents({ limit: 50, offset: 0 }, "metrics-dashboard-secret"); - - const firstFetchCall = fetchMock.mock.calls[0]; - expect(firstFetchCall).toBeDefined(); - - const init = firstFetchCall?.[1]; - expect(init).toEqual({ - headers: { - Authorization: "Bearer metrics-dashboard-secret" - } - }); - }); - - it("normalizes a pasted bearer token before sending the auth header", async () => { - const fetchMock = mock(async (_input: string | URL | Request, _init?: RequestInit) => - Response.json({ - events: [], - limit: 50, - offset: 0, - summary: { byErrorType: {}, byOperation: {}, byStatus: {}, sampleSize: 0, total: 0 }, - total: 0 - }) - ); - globalThis.fetch = fetchMock as unknown as typeof fetch; - - await fetchApiClientEvents({ limit: 50, offset: 0 }, "Bearer metrics-dashboard-secret"); - - const firstFetchCall = fetchMock.mock.calls[0]; - expect(firstFetchCall).toBeDefined(); - - const init = firstFetchCall?.[1]; - expect(init).toEqual({ - headers: { - Authorization: "Bearer metrics-dashboard-secret" - } - }); - }); -}); - -describe("normalizeMetricsDashboardToken", () => { - it("accepts raw token values and pasted bearer header values", () => { - expect(normalizeMetricsDashboardToken("metrics-dashboard-secret")).toBe("metrics-dashboard-secret"); - expect(normalizeMetricsDashboardToken("Bearer metrics-dashboard-secret")).toBe("metrics-dashboard-secret"); - expect(normalizeMetricsDashboardToken(" bearer metrics-dashboard-secret ")).toBe("metrics-dashboard-secret"); - }); -}); diff --git a/apps/dashboard/src/api/apiClientEvents.ts b/apps/dashboard/src/api/apiClientEvents.ts deleted file mode 100644 index fff598ed3..000000000 --- a/apps/dashboard/src/api/apiClientEvents.ts +++ /dev/null @@ -1,92 +0,0 @@ -import { config } from "../config"; - -export type ApiClientEvent = { - apiKeyPrefix: string | null; - createdAt: string; - durationMs: number | null; - errorMessage: string | null; - errorType: string | null; - httpStatus: number | null; - id: string; - metadata: Record | null; - network: string | null; - operation: string; - partnerName: string | null; - paymentMethod: string | null; - quoteId: string | null; - rampId: string | null; - rampType: string | null; - requestId: string | null; - status: "success" | "failure"; -}; - -export type ApiClientEventsFilters = { - apiKeyPrefix?: string; - endDate?: string; - errorType?: string; - limit: number; - offset: number; - operation?: string; - partnerName?: string; - quoteId?: string; - rampId?: string; - requestId?: string; - startDate?: string; - status?: string; -}; - -export type ApiClientEventsResponse = { - events: ApiClientEvent[]; - limit: number; - offset: number; - summary: { - byErrorType: Record; - byOperation: Record; - byStatus: Record; - sampleSize: number; - total: number; - }; - total: number; -}; - -export function normalizeMetricsDashboardToken(token: string): string { - return token - .trim() - .replace(/^Bearer\s+/i, "") - .trim(); -} - -export function buildApiClientEventsUrl(filters: ApiClientEventsFilters, apiBaseUrl = config.apiBaseUrl): string { - const params = new URLSearchParams(); - - for (const [key, value] of Object.entries(filters)) { - if (value !== undefined && value !== "") { - params.set(key, String(value)); - } - } - - const queryString = params.toString(); - const baseUrl = apiBaseUrl.replace(/\/$/, ""); - const url = `${baseUrl}/v1/admin/api-client-events`; - return queryString ? `${url}?${queryString}` : url; -} - -export async function fetchApiClientEvents( - filters: ApiClientEventsFilters, - metricsDashboardToken: string -): Promise { - const normalizedToken = normalizeMetricsDashboardToken(metricsDashboardToken); - - const response = await fetch(buildApiClientEventsUrl(filters), { - headers: { - Authorization: `Bearer ${normalizedToken}` - } - }); - - if (!response.ok) { - const body = await response.text(); - throw new Error(body || `Request failed with status ${response.status}`); - } - - return response.json(); -} diff --git a/apps/dashboard/src/config/index.ts b/apps/dashboard/src/config/index.ts deleted file mode 100644 index 88b0ef3bc..000000000 --- a/apps/dashboard/src/config/index.ts +++ /dev/null @@ -1,19 +0,0 @@ -type Environment = "development" | "staging" | "production" | "sandbox"; - -const nodeEnv = process.env.NODE_ENV as Environment; -const env = (import.meta.env.VITE_ENVIRONMENT || nodeEnv || "development") as Environment; -const explicitApiBase = import.meta.env.VITE_DASHBOARD_API_BASE; - -function getDefaultApiBase(environment: Environment): string { - if (environment === "production") return "/api/production"; - if (environment === "staging") return "/api/staging"; - if (environment === "sandbox") return "/api/sandbox"; - return "http://localhost:3000"; -} - -export const config = { - apiBaseUrl: explicitApiBase || getDefaultApiBase(env), - env, - isDev: env === "development", - isProd: env === "production" -}; diff --git a/apps/dashboard/src/helpers/cn.ts b/apps/dashboard/src/helpers/cn.ts deleted file mode 100644 index 365058ceb..000000000 --- a/apps/dashboard/src/helpers/cn.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { type ClassValue, clsx } from "clsx"; -import { twMerge } from "tailwind-merge"; - -export function cn(...inputs: ClassValue[]) { - return twMerge(clsx(inputs)); -} diff --git a/apps/dashboard/src/main.tsx b/apps/dashboard/src/main.tsx deleted file mode 100644 index 78bb9715b..000000000 --- a/apps/dashboard/src/main.tsx +++ /dev/null @@ -1,27 +0,0 @@ -import "../App.css"; - -import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import { createRoot } from "react-dom/client"; -import { App } from "./App"; - -const queryClient = new QueryClient({ - defaultOptions: { - queries: { - refetchOnWindowFocus: false, - retry: 1, - staleTime: 30_000 - } - } -}); - -const root = document.getElementById("app"); - -if (!root) { - throw new Error("Root element not found"); -} - -createRoot(root).render( - - - -); diff --git a/apps/dashboard/tsconfig.json b/apps/dashboard/tsconfig.json deleted file mode 100644 index 8347c8c73..000000000 --- a/apps/dashboard/tsconfig.json +++ /dev/null @@ -1,27 +0,0 @@ -{ - "compilerOptions": { - "allowJs": false, - "allowSyntheticDefaultImports": true, - "esModuleInterop": false, - "forceConsistentCasingInFileNames": true, - "isolatedModules": true, - "jsx": "react-jsx", - "lib": ["DOM", "DOM.Iterable", "ESNext"], - "module": "ESNext", - "moduleResolution": "bundler", - "noEmit": true, - "resolveJsonModule": true, - "skipLibCheck": true, - "strict": true, - "target": "ESNext", - "types": ["node", "bun"], - "useDefineForClassFields": true - }, - "exclude": ["node_modules"], - "include": ["src"], - "references": [ - { - "path": "./tsconfig.node.json" - } - ] -} diff --git a/apps/dashboard/tsconfig.node.json b/apps/dashboard/tsconfig.node.json deleted file mode 100644 index 413a56833..000000000 --- a/apps/dashboard/tsconfig.node.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "compilerOptions": { - "allowSyntheticDefaultImports": true, - "composite": true, - "module": "ESNext", - "moduleResolution": "Node" - }, - "include": ["vite.config.ts"] -} diff --git a/apps/dashboard/vite.config.ts b/apps/dashboard/vite.config.ts deleted file mode 100644 index 134c849f1..000000000 --- a/apps/dashboard/vite.config.ts +++ /dev/null @@ -1,19 +0,0 @@ -import tailwindcss from "@tailwindcss/vite"; -import react from "@vitejs/plugin-react"; -import { defineConfig } from "vite"; - -export default defineConfig({ - build: { - sourcemap: true, - target: "esnext" - }, - define: { - "process.env": {} - }, - plugins: [react(), tailwindcss()], - server: { - host: true, - port: 5175, - strictPort: true - } -}); diff --git a/bun.lock b/bun.lock index 5892cf2b8..89826af61 100644 --- a/bun.lock +++ b/bun.lock @@ -98,27 +98,6 @@ "typescript": "catalog:", }, }, - "apps/dashboard": { - "name": "vortex-dashboard", - "version": "1.0.0", - "dependencies": { - "@tailwindcss/vite": "^4.0.3", - "@tanstack/react-query": "^5.64.2", - "@vitejs/plugin-react": "^4.3.4", - "clsx": "^2.1.1", - "react": "=19.2.0", - "react-dom": "=19.2.0", - "tailwind-merge": "^3.4.0", - "tailwindcss": "^4.0.3", - }, - "devDependencies": { - "@types/node": "catalog:", - "@types/react": "^19.0.8", - "@types/react-dom": "^19.0.3", - "typescript": "catalog:", - "vite": "^6.2.6", - }, - }, "apps/frontend": { "name": "vortex-frontend", "version": "1.0.0", @@ -4411,8 +4390,6 @@ "vortex-backend": ["vortex-backend@workspace:apps/api"], - "vortex-dashboard": ["vortex-dashboard@workspace:apps/dashboard"], - "vortex-frontend": ["vortex-frontend@workspace:apps/frontend"], "vortex-rebalancer": ["vortex-rebalancer@workspace:apps/rebalancer"], diff --git a/docs/security-spec/01-auth/admin-auth.md b/docs/security-spec/01-auth/admin-auth.md index 82cd463de..ec30bfc49 100644 --- a/docs/security-spec/01-auth/admin-auth.md +++ b/docs/security-spec/01-auth/admin-auth.md @@ -2,7 +2,7 @@ ## What This Does -Admin authentication protects internal/operational endpoints that can mutate system state or manage partners. It uses a single shared secret (`ADMIN_SECRET` env var) compared via Bearer token. Read-only client observability dashboards use a separate `METRICS_DASHBOARD_SECRET` so a dashboard token compromise does not grant broader admin access. +Admin authentication protects internal/operational endpoints that can mutate system state or manage partners. It uses a single shared secret (`ADMIN_SECRET` env var) compared via Bearer token. Read-only access to client observability endpoints uses a separate `METRICS_DASHBOARD_SECRET` so a metrics token compromise does not grant broader admin access. The flow: 1. Admin includes `Authorization: Bearer ` header diff --git a/docs/security-spec/07-operations/client-observability.md b/docs/security-spec/07-operations/client-observability.md index 2313c1eda..939826179 100644 --- a/docs/security-spec/07-operations/client-observability.md +++ b/docs/security-spec/07-operations/client-observability.md @@ -11,9 +11,9 @@ The observed surface includes: - Ramp register, update, start, status, and error-log retrieval. - Request correlation through `X-Request-ID` / `X-Correlation-ID` and response `X-Request-ID`. -Events are persisted in `api_client_events` and structured logs are emitted through the existing backend logger. The event table is an operational telemetry store, not a source of truth for ramp state. Ramp execution failures remain in `RampState.errorLogs`; client observability events are request-level records used for dashboards, alerting, and incident investigation. +Events are persisted in `api_client_events` and structured logs are emitted through the existing backend logger. The event table is an operational telemetry store, not a source of truth for ramp state. Ramp execution failures remain in `RampState.errorLogs`; client observability events are request-level records used for alerting and incident investigation. -Internal operators can inspect these events through `GET /v1/admin/api-client-events`, which is protected by the dedicated `Authorization: Bearer ` middleware. The Netlify-deployable dashboard in `apps/dashboard` calls this endpoint; it does not connect directly to the database and does not contain any server-side secrets. `METRICS_DASHBOARD_SECRET` must be different from `ADMIN_SECRET` to reduce blast radius. +Internal operators can inspect these events through `GET /v1/admin/api-client-events`, which is protected by the dedicated `Authorization: Bearer ` middleware. `METRICS_DASHBOARD_SECRET` must be different from `ADMIN_SECRET` to reduce blast radius. ## Security Invariants @@ -24,8 +24,8 @@ Internal operators can inspect these events through `GET /v1/admin/api-client-ev 5. **Request correlation MUST be non-secret** — `requestId`, `quoteId`, and `rampId` may be stored for debugging, but they must not be used as high-cardinality metric labels. They are correlation identifiers, not authentication material. 6. **Partner attribution MUST use safe identifiers** — Events may store `partnerId`, `partnerName`, and short API key prefixes. Full secret keys and raw auth headers are forbidden. 7. **Operational metrics MUST remain low-cardinality** — Future metric exporters must group by bounded labels such as operation, partner, status, HTTP status, and error type. They must not label by user ID, wallet address, request ID, quote ID, ramp ID, tax ID, PIX key, or free-form request values. -8. **Event persistence SHOULD have automated retention before production alerting/dashboard rollout** — Raw operational events are useful for investigation but must not be retained indefinitely without aggregation or cleanup. The backend retention worker keeps the current UTC calendar day plus the previous six full UTC calendar days and removes older `api_client_events` rows on startup and daily. -9. **Dashboard access MUST go through metrics-dashboard-authenticated backend APIs** — Browser dashboards must call protected backend endpoints and must not ship database credentials, Supabase service-role keys, Metabase embed secrets, or other server-only credentials to Netlify/frontend code. +8. **Event persistence SHOULD have automated retention before production operational use** — Raw operational events are useful for investigation but must not be retained indefinitely without aggregation or cleanup. The backend retention worker keeps the current UTC calendar day plus the previous six full UTC calendar days and removes older `api_client_events` rows on startup and daily. +9. **Client observability access MUST go through metrics-dashboard-authenticated backend APIs** — Internal consumers must call protected backend endpoints and must not ship database credentials, Supabase service-role keys, Metabase embed secrets, or other server-only credentials to client-side code. ## Threat Vectors & Mitigations @@ -36,10 +36,10 @@ Internal operators can inspect these events through `GET /v1/admin/api-client-ev | **PII leakage through metadata** — Client-provided `additionalData` or error messages include tax IDs, PIX keys, or bank details | Do not persist nested metadata objects. Keep metadata scalar-only and sanitized. Avoid passing request bodies to observability helpers. Truncate error messages and prefer stable `errorType` categories. | | **Business flow disruption** — Database/logging outage causes quote/ramp requests to fail | Observability writes are fire-and-forget/best-effort and catch their own errors. The request path must proceed exactly as it would without observability. | | **Missing correlation during incidents** — Operators cannot connect a partner report to backend logs | Generate or propagate `requestId` for all requests and return it via `X-Request-ID`. Persist request IDs alongside quote/ramp IDs when available. | -| **High-cardinality metric explosion** — Future dashboard metrics use ramp IDs or user IDs as labels | Keep high-cardinality identifiers in logs/event rows only. Export aggregate metrics using bounded labels. | +| **High-cardinality metric explosion** — Future observability metrics use ramp IDs or user IDs as labels | Keep high-cardinality identifiers in logs/event rows only. Export aggregate metrics using bounded labels. | | **Unbounded telemetry retention** — Raw event rows grow indefinitely | Use the backend retention worker to delete `api_client_events` older than the 7-day UTC calendar retention window. The cleanup runs on startup and daily, uses advisory locking, and deletes in bounded batches. | -| **Public dashboard exposure** — A static dashboard URL is discovered by outsiders | Require the dedicated backend metrics dashboard bearer token for all event data. Do not rely on obscurity of Netlify URLs. Keep frontend tokens in operator-controlled browser session storage only. | -| **BI embed secret leak** — A future Metabase embed is generated in browser code | Generate signed embed URLs only from the backend. Do not place Metabase signing secrets in Netlify environment variables exposed to Vite. | +| **Internal metrics client exposure** — An internal metrics consumer is reachable by outsiders | Require the dedicated backend metrics dashboard bearer token for all event data. Do not rely on obscurity of client URLs. | +| **BI embed secret leak** — A future Metabase embed is generated in client-side code | Generate signed embed URLs only from the backend. Do not place Metabase signing secrets in publicly exposed environment variables. | ## Audit Checklist @@ -49,8 +49,7 @@ Internal operators can inspect these events through `GET /v1/admin/api-client-ev - [ ] Verify event persistence helpers catch their own errors and cannot throw into controller or middleware responses. - [ ] Verify auth, quote, and ramp request instrumentation does not alter existing response bodies or HTTP status codes. - [ ] Verify no observability event stores `X-API-Key`, bearer tokens, raw headers, raw request bodies, tax IDs, PIX destinations, QR codes, KYC data, private keys, seeds, ephemeral secrets, or signed transaction payloads. -- [ ] Verify error messages are truncated and dashboards/alerts use stable `errorType` categories rather than raw messages. +- [ ] Verify error messages are truncated and alerts/consumers use stable `errorType` categories rather than raw messages. - [ ] Verify future metric exporters do not use request ID, quote ID, ramp ID, user ID, wallet address, tax ID, or PIX key as metric labels. - [ ] Verify `GET /v1/admin/api-client-events` uses `metricsDashboardAuth` and returns only sanitized event fields. -- [ ] Verify `apps/dashboard` has no direct database connection and no server-only credentials in Vite-exposed env vars. - [ ] Verify the API client events retention worker runs on backend startup and daily, and deletes `api_client_events` older than the 7-day UTC calendar retention window in bounded batches. diff --git a/docs/security-spec/07-operations/secret-management.md b/docs/security-spec/07-operations/secret-management.md index e361eeceb..c5fedc4f3 100644 --- a/docs/security-spec/07-operations/secret-management.md +++ b/docs/security-spec/07-operations/secret-management.md @@ -18,7 +18,7 @@ This spec catalogs every secret, its purpose, its blast radius if compromised, a | `MOONBEAM_FUNDING_PRIVATE_KEY` | EVM subsidization transfers across all EVM chains in scope (Moonbeam, Base, Polygon, etc.); BRLA payouts on Base; EVM fee distribution on Base | Drain of EVM funding pool on every supported EVM chain — including BRLA payout path on Base | | `CLIENT_DOMAIN_SECRET` | SEP-10 domain signing for Stellar anchors | Impersonation of Vortex in Stellar anchor authentication | | `ADMIN_SECRET` | Admin endpoint bearer token | Full admin access — can modify ramps, trigger operations | -| `METRICS_DASHBOARD_SECRET` | Internal observability dashboard bearer token | Read-only access to sanitized API client event data | +| `METRICS_DASHBOARD_SECRET` | Read-only observability API bearer token | Read-only access to sanitized API client event data | | `WEBHOOK_PRIVATE_KEY` | RSA key for webhook signatures | Forge webhook signatures — could trick consumers into accepting fake events. **If missing, ephemeral RSA keys are generated at startup (non-persistent across restarts).** | | `SUPABASE_SERVICE_KEY` | Supabase admin access (bypasses RLS) | Full database read/write — all ramp data, user data, keys | | `SUPABASE_ANON_KEY` | Supabase public access (subject to RLS) | Limited by RLS policies — lower blast radius than service key | @@ -56,7 +56,7 @@ This spec catalogs every secret, its purpose, its blast radius if compromised, a 7. **Database credentials (`DB_*`) MUST NOT be accessible from the public internet** — Direct PostgreSQL access should be restricted to the application server's network. 8. **No secret MUST be passed as a URL query parameter** — Query parameters are logged by proxies, CDNs, and web servers. Secrets must only travel in headers or request bodies. 9. **`MYKOBO_CLIENT_DOMAIN` MUST be set in production** — Not a secret, but operationally critical: when unset, Mykobo silently applies its default fee tier (~5x worse than the negotiated rate). Quote-engine fee defaults will then diverge from what Mykobo actually charges. Deployment automation MUST treat a missing `MYKOBO_CLIENT_DOMAIN` as a hard failure rather than letting it fall through to default-tier fees. -10. **Observability MUST follow the same no-secret rule as logs** — API client events, request correlation logs, metrics, and dashboard data must not contain full API keys, bearer tokens, provider credentials, private keys, seeds, raw request headers, or raw request bodies. See `07-operations/client-observability.md`. +10. **Observability MUST follow the same no-secret rule as logs** — API client events, request correlation logs, metrics, and observability data must not contain full API keys, bearer tokens, provider credentials, private keys, seeds, raw request headers, or raw request bodies. See `07-operations/client-observability.md`. ## Threat Vectors & Mitigations diff --git a/package.json b/package.json index 26da763f9..977cf7875 100644 --- a/package.json +++ b/package.json @@ -101,17 +101,15 @@ "packageManager": "bun@1.3.1", "private": true, "scripts": { - "build": "bun run build:shared && bun run build:sdk && bun run build:frontend && bun run build:dashboard && bun run build:backend", + "build": "bun run build:shared && bun run build:sdk && bun run build:frontend && bun run build:backend", "build:backend": "bun run --cwd apps/api build", - "build:dashboard": "bun run --cwd apps/dashboard build", "build:frontend": "bun run --cwd apps/frontend build", "build:sdk": "bun run --cwd packages/sdk build", "build:shared": "bun run --cwd packages/shared build", "compile:contracts:relayer": "bun run --cwd contracts/relayer compile", - "dev": "concurrently -n 'shared,backend,frontend,dashboard' -c '#ffa500,#007755,#2f6da3,#24c989' 'cd packages/shared && bun run dev' 'cd apps/api && bun dev' 'cd apps/frontend && bun dev' 'cd apps/dashboard && bun dev'", + "dev": "concurrently -n 'shared,backend,frontend' -c '#ffa500,#007755,#2f6da3' 'cd packages/shared && bun run dev' 'cd apps/api && bun dev' 'cd apps/frontend && bun dev'", "dev:backend": "bun run --cwd apps/api dev", "dev:contracts:relayer": "bun run --cwd contracts/relayer node", - "dev:dashboard": "bun run --cwd apps/dashboard dev", "dev:frontend": "bun run --cwd apps/frontend dev", "dev:rebalancer": "bun run --cwd apps/rebalancer dev", "docs:api:check": "bun docs/api/scripts/check-openapi.ts", @@ -122,7 +120,6 @@ "lint:fix": "biome lint --write .", "prepare": "husky", "serve:backend": "bun run --cwd apps/api serve", - "serve:dashboard": "bun run --cwd apps/dashboard preview", "serve:frontend": "bun run --cwd apps/frontend preview", "test:contracts:relayer": "bun run --cwd contracts/relayer test", "typecheck": "bunx tsc", From b88120d91a632f7531a9f47ae954516ae2cb1425 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Tue, 9 Jun 2026 12:51:40 +0200 Subject: [PATCH 10/11] Preserve Alfredpay routed output decimals --- .../quote/engines/finalize/onramp.test.ts | 17 +++++++++++++++++ .../services/quote/engines/finalize/onramp.ts | 3 +++ 2 files changed, 20 insertions(+) diff --git a/apps/api/src/api/services/quote/engines/finalize/onramp.test.ts b/apps/api/src/api/services/quote/engines/finalize/onramp.test.ts index da9b5c815..ed6c96078 100644 --- a/apps/api/src/api/services/quote/engines/finalize/onramp.test.ts +++ b/apps/api/src/api/services/quote/engines/finalize/onramp.test.ts @@ -26,4 +26,21 @@ describe("OnRampFinalizeEngine", () => { expect(result.decimals).toBe(18); expect(result.amount.toFixed(result.decimals, 0)).toBe("4817.805726163073314321"); }); + + it("uses destination EVM token decimals for Alfredpay routed onramp output precision", async () => { + const result = await new TestOnRampFinalizeEngine().compute({ + evmToEvm: { + outputAmountDecimal: new Big("4817.805726163073314321") + }, + request: { + inputCurrency: FiatToken.USD, + outputCurrency: EvmToken.USDT, + rampType: RampDirection.BUY, + to: Networks.BSC + } + } as never); + + expect(result.decimals).toBe(18); + expect(result.amount.toFixed(result.decimals, 0)).toBe("4817.805726163073314321"); + }); }); diff --git a/apps/api/src/api/services/quote/engines/finalize/onramp.ts b/apps/api/src/api/services/quote/engines/finalize/onramp.ts index 4fa2b28a6..6adbcc0dc 100644 --- a/apps/api/src/api/services/quote/engines/finalize/onramp.ts +++ b/apps/api/src/api/services/quote/engines/finalize/onramp.ts @@ -65,6 +65,9 @@ export class OnRampFinalizeEngine extends BaseFinalizeEngine { }); } let amount = new Big(output); + if (ctx.evmToEvm) { + finalOutputDecimals = getTokenDetailsForEvmDestination(request.outputCurrency as OnChainToken, request.to).decimals; + } if (!ctx.evmToEvm && ctx.alfredpayMint) { const usdFees = ctx.fees?.usd; const feesToDeduct = usdFees ? new Big(usdFees.vortex).plus(usdFees.partnerMarkup) : new Big(0); From b9775304dab0cb91ff00e30d515b61dd9d36fb46 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Tue, 9 Jun 2026 12:53:23 +0200 Subject: [PATCH 11/11] Document Alfredpay routed output precision --- .../03-ramp-engine/quote-lifecycle.md | 4 +-- .../03-ramp-engine/ramp-phase-flows.md | 1 + .../05-integrations/alfredpay.md | 25 +++++++++++-------- .../05-integrations/squid-router.md | 7 +++--- 4 files changed, 22 insertions(+), 15 deletions(-) diff --git a/docs/security-spec/03-ramp-engine/quote-lifecycle.md b/docs/security-spec/03-ramp-engine/quote-lifecycle.md index 58b974a00..067d739bd 100644 --- a/docs/security-spec/03-ramp-engine/quote-lifecycle.md +++ b/docs/security-spec/03-ramp-engine/quote-lifecycle.md @@ -66,7 +66,7 @@ The refresh policy is intentionally strict (byte-identical `toAmount` and `fee` 8. **Dynamic pricing state MUST NOT be externally modifiable** — The `partnerDiscountState` Map is in-memory and module-private. No API endpoint should expose or allow modification of discount state. 9. **Exchange rates MUST be sourced from authoritative on-chain data** — Swap rates should come from the actual DEX (Nabla) or routing protocol (Squid), not from stale caches or third-party price feeds that could be manipulated. 10. **Subsidy MUST only be applied when `targetDiscount > 0`** — If a partner has no target discount configured, the subsidy amount is always `0`, regardless of the shortfall. -11. **Quote output precision MUST match the final settlement token** — For EVM onramps whose final output comes from Squid, the stored `quote.outputAmount` must retain the destination token's precision, not a fixed source-token precision. Downstream transaction builders convert this immutable quote amount back to raw units using destination token decimals. +11. **Quote output precision MUST match the final settlement token** — For EVM onramps whose final output comes from Squid, the stored `quote.outputAmount` must retain the destination token's precision, not a fixed source-token precision. This includes BRL/EURC Base→EVM routes and routed Alfredpay USD/MXN/COP Polygon→EVM routes. Direct same-chain same-token passthrough keeps the minted/source token's precision. ## Threat Vectors & Mitigations @@ -98,7 +98,7 @@ The refresh policy is intentionally strict (byte-identical `toAmount` and `fee` - [x] Exchange rates used in quote calculation come from live on-chain sources (Nabla, Squid), not stale caches. **PASS** — verified: rates fetched from Nabla DEX and SquidRouter API at quote time. - [x] Quote response does not include internal implementation details (e.g., the `adjustedDifference` or `adjustedTargetDiscount` values). **PASS** — verified: response includes only user-facing fields (amounts, fees, expiry). - [x] Quote amounts (input, output, fees) are immutable once stored — no UPDATE endpoint modifies them. **PASS** — no quote mutation endpoints exist. -- [x] EVM onramp output precision follows destination token decimals where the quote output comes from Squid. **PASS** — BRL/EURC Base→EVM finalization preserves destination token precision before downstream raw transfer construction. +- [x] EVM onramp output precision follows destination token decimals where the quote output comes from Squid. **PASS** — BRL/EURC Base→EVM and routed Alfredpay USD/MXN/COP Polygon→EVM finalization preserve destination token precision before downstream raw transfer construction. Direct same-chain same-token passthrough remains at minted/source-token precision. - [PARTIAL] Authentication is enforced on quote creation (verify which auth mechanisms protect `POST /v1/ramp/quotes`). **PARTIAL** — quote creation is accessible via API key auth or Supabase auth; the endpoint is optional-auth by design (public quotes allowed for some partners). - [PARTIAL] Quote ownership is verified at ramp registration — the user/partner creating the ramp must match the quote creator. **PARTIAL** — no strict user-to-quote binding; mitigated by UUID unpredictability and 10-minute expiry. - [x] Subsidy is only calculated when `targetDiscount > 0` — partners with no discount get `0` subsidy regardless of shortfall. **PASS** — verified in `calculateSubsidyAmount()`. 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 9b734af57..ec3a62f09 100644 --- a/docs/security-spec/03-ramp-engine/ramp-phase-flows.md +++ b/docs/security-spec/03-ramp-engine/ramp-phase-flows.md @@ -45,6 +45,7 @@ There are 29+ phase handlers in `apps/api/src/api/services/phases/handlers/`. Th **Alfredpay corridors:** Similar structure with `alfredpayOfframpTransfer` / `alfredpayOnrampMint` replacing the fiat provider phases. - **Degenerate Polygon same-token onramp case:** Alfredpay mints `ALFREDPAY_EVM_TOKEN` (USDT) on Polygon. When the user requests that same token on Polygon (`quote.metadata.request.to === Networks.Polygon` **and** `quote.outputCurrency === ALFREDPAY_EVM_TOKEN`), the `squidRouterSwap` handler short-circuits to `finalSettlementSubsidy` with no swap. Any **other** Polygon output (e.g. USDC) still runs the real USDT→output swap — `quote.metadata.request.to` is the destination network, not the output token, so the short-circuit MUST also check `outputCurrency`. See `05-integrations/alfredpay.md`. +- **Amount precision on routed Alfredpay onramps:** when Alfredpay mints on Polygon and the user requests a different EVM output token, the routed Squid output is the final settlement amount. `evmToEvm.inputAmountRaw` remains the Polygon source-token raw amount, while `evmToEvm.outputAmountRaw` and `quote.outputAmount` MUST use the final destination token's raw/decimal precision. The direct Polygon same-token case remains at the minted token's precision. - **Alfredpay offramp always runs `finalSettlementSubsidy`:** The `FinalSettlementSubsidyHandler` direct-transfer skip explicitly excludes `SELL && isAlfredpayToken(outputCurrency)`. This ensures the Polygon ephemeral is always subsidized to the expected amount before `alfredpayOfframpTransfer`, regardless of the `isDirectTransfer` flag. - **`fund-ephemeral-handler` direct-transfer skip excludes Alfredpay offramps:** The `nextPhaseSelector` in `fund-ephemeral-handler.ts` skips to `destinationTransfer` for any ramp with `isDirectTransfer === true` **except** `SELL && isAlfredpayToken(outputCurrency)`. This preserves the existing skip for all other corridors while ensuring Alfredpay offramps proceed through `finalSettlementSubsidy` → `alfredpayOfframpTransfer`. diff --git a/docs/security-spec/05-integrations/alfredpay.md b/docs/security-spec/05-integrations/alfredpay.md index deb2a3cd5..79d79d96e 100644 --- a/docs/security-spec/05-integrations/alfredpay.md +++ b/docs/security-spec/05-integrations/alfredpay.md @@ -6,29 +6,31 @@ Alfredpay is a fiat payment provider supporting on-ramp and off-ramp operations **Provider type:** Both (on-ramp and off-ramp) **Fiat currencies:** Multiple (varies by country, validated via `AlfredPayCountry` enum) -**Chains involved:** Polygon (Alfredpay-side, USDC), EVM destinations via SquidRouter (Polygon → Base/other) +**Chains involved:** Polygon (Alfredpay-side, USDT / `ALFREDPAY_EVM_TOKEN`), EVM destinations via SquidRouter (Polygon → Base/other) **Customer types:** Individual (KYC) and Business (KYB) — selected via `AlfredpayCustomerType`. The controller maps Alfredpay's KYB status to the platform's `AlfredPayStatus` via `mapKybStatus`; KYC is handled by `mapKycStatus`. Branch in `alfredpay.controller.ts` on `AlfredpayCustomerType.BUSINESS`. **Phase handlers:** -- `alfredpay-onramp-mint-handler.ts` — On-ramp: waits for Alfredpay payment confirmation and credits USDC to the ephemeral on Polygon. -- `alfredpay-offramp-transfer-handler.ts` — Off-ramp: transfers USDC to Alfredpay's settlement address for fiat payout. Recovers from expired upstream quotes by re-quoting at execute time (see [Quote Lifecycle — AlfredPay Provider Quote TTL](../03-ramp-engine/quote-lifecycle.md)). -- `subsidize-pre-swap-handler.ts` — Subsidy: tops up the ephemeral's USDC balance on Polygon to the discount-engine's `targetOutputAmountRaw` before the next stage. Uses `getEvmSubsidyConfig` to pick the Alfredpay-specific funding account and token (`ALFREDPAY_EVM_TOKEN`). -- `squid-router-phase-handler.ts` — Cross-chain bridge for non-Polygon EVM destinations. Same-chain same-token routes short-circuit via `isSameChainSameTokenPassthrough` (no SquidRouter call when source and destination are both Polygon USDC). +- `alfredpay-onramp-mint-handler.ts` — On-ramp: waits for Alfredpay payment confirmation and credits the Alfredpay on-chain token (`ALFREDPAY_EVM_TOKEN`) to the ephemeral on Polygon. +- `alfredpay-offramp-transfer-handler.ts` — Off-ramp: transfers the Alfredpay on-chain token to Alfredpay's settlement address for fiat payout. Recovers from expired upstream quotes by re-quoting at execute time (see [Quote Lifecycle — AlfredPay Provider Quote TTL](../03-ramp-engine/quote-lifecycle.md)). +- `subsidize-pre-swap-handler.ts` — Subsidy: tops up the ephemeral's Alfredpay on-chain token balance on Polygon to the discount-engine's `targetOutputAmountRaw` before the next stage. Uses `getEvmSubsidyConfig` to pick the Alfredpay-specific funding account and token (`ALFREDPAY_EVM_TOKEN`). +- `squid-router-phase-handler.ts` — Cross-chain bridge for non-Polygon EVM destinations. Same-chain same-token routes short-circuit via `isSameChainSameTokenPassthrough` (no SquidRouter call when source and destination are both Polygon `ALFREDPAY_EVM_TOKEN`). **On-ramp flow:** 1. Quote stage emits `ctx.alfredpayOnramp` with provider `quoteId` (30s upstream TTL) and `ctx.subsidy` with the discount-engine target. 2. User initiates on-ramp → receives Alfredpay payment instructions. 3. User makes fiat payment. -4. `alfredpayOnrampMint` phase: confirms Alfredpay payment, credits USDC to the ephemeral on Polygon. If the provider quote is degraded or expired and the discount engine's `expectedOutput` exceeds the provider's, the phase emits `alfredOnrampMintFallback` to record the substitution. -5. `subsidizePreSwap` phase: tops up ephemeral USDC balance to the subsidy target (Polygon, `ALFREDPAY_EVM_TOKEN`). -6. `squidRouterSwap` phase: bridges USDC to the destination EVM chain. For same-chain same-token (Polygon USDC → Polygon USDC), the passthrough shortcut sends the funds directly without invoking SquidRouter. +4. `alfredpayOnrampMint` phase: confirms Alfredpay payment, credits the Alfredpay on-chain token to the ephemeral on Polygon. If the provider quote is degraded or expired and the discount engine's `expectedOutput` exceeds the provider's, the phase emits `alfredOnrampMintFallback` to record the substitution. +5. `subsidizePreSwap` phase: tops up the ephemeral's Alfredpay on-chain token balance to the subsidy target (Polygon, `ALFREDPAY_EVM_TOKEN`). +6. `squidRouterSwap` phase: routes the Polygon Alfredpay token to the destination EVM chain/token. For same-chain same-token (Polygon `ALFREDPAY_EVM_TOKEN` → Polygon `ALFREDPAY_EVM_TOKEN`), the passthrough shortcut sends the funds directly without invoking SquidRouter. 7. `destinationTransfer` → `polygonCleanup` → `complete`. +For routed Alfredpay onramps (any non-passthrough output), the final quote output is the Squid destination-token amount. `quote.outputAmount` MUST be stored with the destination token's decimals, and `evmToEvm.outputAmountRaw` MUST preserve Squid's destination-token raw output. The Polygon-minted Alfredpay token remains the Squid source amount; the spec must not treat Polygon source-token decimals as final settlement precision. + **Off-ramp flow:** 1. Quote stage emits `ctx.alfredpayOfframp` with provider `quoteId` and the AlfredPay fiat order is created during `prepareOfframpEvmToAlfredpay...` (see `transactions/offramp/routes/evm-to-alfredpay.ts:229`). At prep time, if the quote carries `metadata.alfredpayOfframp`, the service calls `refreshAlfredpayOfframpQuoteIfMatching` to swap in a fresh provider `quoteId` (strict: `toAmount` and `fee` must be identical; drift throws an `INTERNAL_SERVER_ERROR`). The order is authoritative from prep time; `processAlfredpayOfframpStart` only re-validates state before phase execution. -2. `squidRouterPermitExecute` or `squidRouterNoPermitTransfer/Approve/Swap` phase: executes the user-signed permit (or the no-permit equivalent) and lands USDC on Polygon. +2. `squidRouterPermitExecute` or `squidRouterNoPermitTransfer/Approve/Swap` phase: executes the user-signed permit (or the no-permit equivalent) and lands the Alfredpay on-chain token on Polygon. 3. `finalSettlementSubsidy` phase: always runs for Alfredpay offramps — the direct-transfer skip in `FinalSettlementSubsidyHandler` explicitly excludes `SELL && isAlfredpayToken(outputCurrency)` so the subsidy top-up is never bypassed. -4. `alfredpayOfframpTransfer` phase: transfers USDC to Alfredpay's settlement address for fiat payout. If Alfredpay rejects the stored `quoteId` as expired, the handler requests a fresh provider quote at execute time and re-attempts (`alfredpayOfframpTransferFallback` phase records the re-attempt). +4. `alfredpayOfframpTransfer` phase: transfers the Alfredpay on-chain token to Alfredpay's settlement address for fiat payout. If Alfredpay rejects the stored `quoteId` as expired, the handler requests a fresh provider quote at execute time and re-attempts (`alfredpayOfframpTransferFallback` phase records the re-attempt). 5. `polygonCleanupAxlUsdc` → `complete`. **Request validation:** Alfredpay middleware (`alfredpay.middleware.ts`) validates the `country` parameter against the `AlfredPayCountry` enum for all Alfredpay-related requests. @@ -49,6 +51,7 @@ Alfredpay is a fiat payment provider supporting on-ramp and off-ramp operations 12. **The Polygon swap short-circuit MUST be gated on the output token, not on the destination network alone** — Alfredpay mints `ALFREDPAY_EVM_TOKEN` (USDT) directly on Polygon, so the `squidRouterSwap` handler short-circuits straight to `finalSettlementSubsidy` (no swap) only when `quote.metadata.request.to === Networks.Polygon` **and** `quote.outputCurrency === ALFREDPAY_EVM_TOKEN`. `quote.metadata.request.to` is the destination *network*, not the output token; gating on the network alone would mis-deliver — a user requesting any other Polygon output (e.g. USDC) would silently receive the minted USDT instead of their swapped asset. The quote engine (`onramp-polygon-to-evm-alfredpay.ts`) mirrors this: it emits `skipRouteCalculation: true` only for the same-token (`outputCurrency === ALFREDPAY_EVM_TOKEN`) case and otherwise produces a real USDT→output swap. 13. **Offramp quote refresh at prep time MUST be strict and transactional** — `refreshAlfredpayOfframpQuoteIfMatching` (called during `prepareOfframpNonBrlTransactions`) re-fetches a provider quote and compares `toAmount` and `fee`. Any drift throws `INTERNAL_SERVER_ERROR`, aborting ramp registration. The quote metadata update (new `quoteId` + `expirationDate`) runs within the registration transaction, so a partial update cannot persist. 14. **`finalSettlementSubsidy` MUST NOT be skipped for Alfredpay offramps** — The `FinalSettlementSubsidyHandler` direct-transfer skip explicitly excludes `SELL && isAlfredpayToken(outputCurrency)`. This ensures the ephemeral on Polygon is always topped up to the expected amount before `alfredpayOfframpTransfer`, preventing under-funded settlements. +15. **Routed Alfredpay onramp quote output precision MUST match the destination token** — For Alfredpay USD/MXN/COP onramps that route through Squid, `quote.outputAmount` MUST preserve the final destination token's decimal precision, and `evmToEvm.outputAmountRaw` MUST represent the destination token's raw units. The Polygon-minted Alfredpay token is only the Squid source-side input. Direct Polygon same-token passthrough remains at the minted token's 6-decimal precision. ## Threat Vectors & Mitigations @@ -66,6 +69,7 @@ Alfredpay is a fiat payment provider supporting on-ramp and off-ramp operations | **Alfredpay offramp skipping subsidy via direct-transfer flag** | An Alfredpay offramp ramp with `isDirectTransfer === true` skips `finalSettlementSubsidy`, under-funding the settlement | `FinalSettlementSubsidyHandler` explicitly excludes `SELL && isAlfredpayToken(outputCurrency)` from the direct-transfer skip; subsidy always runs for Alfredpay offramps | | **Polygon passthrough rounding** | Same-chain same-token shortcut rounds the bridge output incorrectly, leaking dust or under-funding the destination | `toFixed(0, 0)` round-down in the squid-router finalize; downstream subsidy ensures the destination receives the quoted amount | | **Polygon wrong-token delivery** | A user on-ramps via Alfredpay and requests a non-USDT Polygon output (e.g. USDC); the handler skips the swap on destination-network alone and transfers the minted USDT, delivering the wrong asset | The `squidRouterSwap` short-circuit is gated on `quote.outputCurrency === ALFREDPAY_EVM_TOKEN` (not just `quote.metadata.request.to === Networks.Polygon`); non-USDT Polygon outputs run the real USDT→output swap. Matched in the quote engine's `skipRouteCalculation` branch. | +| **Routed destination precision loss** | A USD/MXN/COP Alfredpay onramp mints a 6-decimal Polygon source token, routes to an 18-decimal destination token, and stores the final quote output with source precision. The final amount is truncated before destination-transfer expectations are calculated. | Finalize routed Alfredpay EVM quotes with the destination token's decimals when `evmToEvm` metadata exists; keep direct Polygon same-token passthrough at minted-token precision. | ## Audit Checklist @@ -89,3 +93,4 @@ Alfredpay is a fiat payment provider supporting on-ramp and off-ramp operations - [x] `refreshAlfredpayOfframpQuoteIfMatching` re-fetches a fresh provider quote at prep time, compares `toAmount` and `fee` exactly, and throws on drift. Quote metadata update (new `quoteId` + `expirationDate`) runs within the registration transaction. **PASS** — `ramp.service.ts`. - [x] `FinalSettlementSubsidyHandler` does NOT skip subsidy for Alfredpay offramps (`SELL && isAlfredpayToken`). **PASS** — explicit exclusion in the direct-transfer skip condition. - [x] AlfredPay offramp order is created at prep time (`evm-to-alfredpay.ts:229`); `processAlfredpayOfframpStart` is a defensive validation-only no-op. **PASS** — verified. +- [x] Routed Alfredpay onramp quote output precision follows destination token decimals when `evmToEvm` metadata exists; direct Polygon same-token passthrough remains at minted-token precision. **PASS** — verified in `finalize/onramp.ts`. diff --git a/docs/security-spec/05-integrations/squid-router.md b/docs/security-spec/05-integrations/squid-router.md index 737d8fe20..c5abb76db 100644 --- a/docs/security-spec/05-integrations/squid-router.md +++ b/docs/security-spec/05-integrations/squid-router.md @@ -7,6 +7,7 @@ Squid Router is a cross-chain swap/routing protocol built on Axelar's General Me - **BRL off-ramp**: User's source EVM chain → Base USDC. - **EUR on-ramp (Mykobo on Base)**: Base USDC → user's destination EVM chain (after EURC→USDC Nabla swap). - **EUR off-ramp (Mykobo on Base)**: User's source EVM chain → Base USDC (client-side user-signed). +- **Alfredpay on-ramp**: Polygon Alfredpay token → user's destination EVM chain/token, except for Polygon same-token passthrough. - **Off-ramp permit acquisition (Alfredpay)**: User EVM → Moonbeam via `TokenRelayer.execute()` with EIP-2612 permit. > **Removed:** the previous Monerium-EUR Squid usage (Polygon EURe → Moonbeam) is no longer active; Monerium is deprecated (see `monerium.md`). @@ -29,7 +30,7 @@ It handles cross-chain swap execution, Axelar bridge status monitoring, and gas 5. Optional `backupSquidRouterApprove` / `backupSquidRouterSwap` on the destination chain if the bridged token (axlUSDC / USDC) needs further conversion to the user's requested output token. **F-054: these `backup*` presigned txs have no registered phase handler.** 6. `destinationTransfer` to the user. -For quote metadata, Squid's `route.estimate.toAmount` is already denominated in the **destination token's raw units**. The bridge metadata (`evmToEvm.outputAmountRaw`, `moonbeamToEvm.outputAmountRaw`, etc.) MUST preserve that raw value directly instead of rebuilding it from the human-readable decimal amount with source-token decimals. This matters for routes like Base USDC (6 decimals) → BSC USDT (18 decimals), where using the source decimals would under-scale the metadata by 12 decimal places. +For quote metadata, Squid's `route.estimate.toAmount` is already denominated in the **destination token's raw units**. The bridge metadata (`evmToEvm.outputAmountRaw`, `moonbeamToEvm.outputAmountRaw`, etc.) MUST preserve that raw value directly instead of rebuilding it from the human-readable decimal amount with source-token decimals. This matters for routes like Base USDC (6 decimals) → BSC USDT (18 decimals), where using the source decimals would under-scale the metadata by 12 decimal places. The same invariant applies to routed Alfredpay onramps: even when the Squid source is the Polygon-minted Alfredpay token, `route.estimate.toAmount` remains authoritative for the destination token's raw units and `quote.outputAmount` must retain destination-token precision. ### Off-ramp flow (user EVM source → Base USDC) @@ -60,7 +61,7 @@ When the BRL on-ramp's destination is **Base + USDC**, the Nabla swap output is 10. **No-permit fallback MUST NOT advance to `fundEphemeral` until BOTH approve and swap (or the direct transfer) have confirmed** — Sequential `waitForUserHash` calls in `executeNoPermitFallback` enforce this. 11. **Transaction hashes MUST be persisted to state before waiting** — `squidRouterApproveHash`, `squidRouterSwapHash`, `squidRouterPayTxHash`, `squidRouterPermitExecutionHash`, `squidRouterNoPermitApproveHash`, `squidRouterNoPermitSwapHash`, `squidRouterNoPermitTransferHash` all enable crash recovery. 12. **Skip-Squid path MUST NOT lose destination validation** — Quote engine `validate()` runs regardless of `skipRouteCalculation`; `destinationTransfer` is the only on-chain step that fires. -13. **Squid output raw metadata MUST use destination-token raw units** — `route.estimate.toAmount` is the authoritative destination raw output; `evmToEvm.outputAmountRaw` MUST NOT be recomputed with the source token's decimals. For same-chain same-token passthrough, `inputAmountRaw` is also the destination raw amount and is safe to mirror. +13. **Squid output raw metadata MUST use destination-token raw units** — `route.estimate.toAmount` is the authoritative destination raw output; `evmToEvm.outputAmountRaw` MUST NOT be recomputed with the source token's decimals. For same-chain same-token passthrough, `inputAmountRaw` is also the destination raw amount and is safe to mirror. Routed Alfredpay onramps follow the same rule; only direct Polygon same-token passthrough keeps the minted source-token precision. ## Threat Vectors & Mitigations @@ -99,7 +100,7 @@ When the BRL on-ramp's destination is **Base + USDC**, the Nabla swap output is - [x] **FINDING F-063 (MEDIUM)**: SquidRouter slippage rejection (>2.5%) enforced. **PASS (FIXED)**. - [x] **No-permit fallback receipt validation**: `waitForUserHash` verifies receipt `from`, receipt `to`, and transaction `input` against the expected user address and presigned EVM transaction payload before advancing. - [x] **Skip-Squid trivial path**: emits passthrough bridge meta in `BaseSquidRouterEngine` and short-circuits discount/fee engines. Destination address validated by quote engine `validate()`. **PASS** — no security checks bypassed. -- [x] **Destination-token raw output metadata**: `evmToEvm.outputAmountRaw` preserves Squid's `route.estimate.toAmount` in destination raw units. **PASS** — prevents Base USDC → BSC USDT-style 6-vs-18 decimal under-scaling. +- [x] **Destination-token raw output metadata**: `evmToEvm.outputAmountRaw` preserves Squid's `route.estimate.toAmount` in destination raw units, including routed Alfredpay onramps. **PASS** — prevents Base/Polygon 6-decimal source → BSC USDT-style 18-decimal destination under-scaling. - [x] **Squid 429 rate-limit retry**: exponential backoff. **PASS — verify backoff cap.** - [x] **Arrival timeout**: `waitUntilTrue` accepts a timeout argument. **PASS** — verify all callers pass a finite value. - [EXISTING FINDING F-054]: `backupSquidRouterApprove`/`backupSquidRouterSwap`/`backupApprove` presigned txs have no registered phase handler. Either dead code or missing implementation.