diff --git a/apps/api/.env.example b/apps/api/.env.example index abb307a9f..e6f56f676 100644 --- a/apps/api/.env.example +++ b/apps/api/.env.example @@ -112,6 +112,16 @@ RAMP_WIDGET_URL=https://www.vortexfinance.co/widget # Vortex fee config VORTEX_FEE_PEN_PERCENTAGE=0.0 +# Fee-collecting EVM BUY destination execution pricing. Exact provider-token +# direct payouts remain covered by their existing source reserve. Other quotes +# are unavailable when the expected funding plus payout fee exceeds the USD +# ceiling, and registration is rejected when fees move beyond the quote margin +# (12000 = 20%). Keep dynamic funding disabled for the first deployment, then +# enable it only after every API and worker replica runs the new executor. +EVM_DYNAMIC_DESTINATION_FUNDING_ENABLED=false +EVM_DESTINATION_MAX_EXECUTION_FEE_USD=5 +EVM_DESTINATION_NETWORK_FEE_MARGIN_BPS=12000 + # Rate Limiting RATE_LIMIT_MAX_REQUESTS=100 RATE_LIMIT_WINDOW_MINUTES=1 diff --git a/apps/api/src/api/services/phases/blocks/__tests__/alfredpay-onramp-cross-chain.flow.test.ts b/apps/api/src/api/services/phases/blocks/__tests__/alfredpay-onramp-cross-chain.flow.test.ts index f292e3cdf..c114a5e41 100644 --- a/apps/api/src/api/services/phases/blocks/__tests__/alfredpay-onramp-cross-chain.flow.test.ts +++ b/apps/api/src/api/services/phases/blocks/__tests__/alfredpay-onramp-cross-chain.flow.test.ts @@ -98,6 +98,15 @@ const CORE_PHASES: RampPhase[] = [ function buildCtx(): PhaseCtx { return { addNote: () => undefined, + evmDestinationGas: { + executionFeeUsd: "0.01", + fundingGasLimit: "21000", + isNativeTransfer: false, + maximumFeePerGas: "1", + network: Networks.Arbitrum, + programVersion: 2, + transferGasLimit: "100000" + }, notes: [], now: new Date(), partner: { id: null }, @@ -150,12 +159,12 @@ describe("Alfredpay cross-chain onramp flow", () => { expect(output.token).toBe(EvmToken.USDC); expect(output.chain).toBe(Networks.Arbitrum); - expect(output.amount.toFixed()).toBe("95"); + expect(output.amount.toFixed()).toBe("94.99"); expect(metadata.globals.fees.usd).toEqual({ anchor: "2", - network: "0", + network: "0.01", partnerMarkup: "1", - total: "4.000000", + total: "4.010000", vortex: "1" }); expect(Object.keys(metadata.blocks)).toEqual([ @@ -168,7 +177,7 @@ describe("Alfredpay cross-chain onramp flow", () => { "distributeFees" ]); expect(getBlockMetadata(metadata, AlfredpayMintContext).outputAmountRaw).toBe("98000000"); - expect(getBlockMetadata(metadata, SubsidizePreContext).targetInputAmountRaw).toBe("96000000"); - expect(getBlockMetadata(metadata, SquidRouterSwapContext).inputAmountRaw).toBe("96000000"); + expect(getBlockMetadata(metadata, SubsidizePreContext).targetInputAmountRaw).toBe("95990000"); + expect(getBlockMetadata(metadata, SquidRouterSwapContext).inputAmountRaw).toBe("95990000"); }); }); diff --git a/apps/api/src/api/services/phases/blocks/__tests__/alfredpay-onramp-direct.flow.test.ts b/apps/api/src/api/services/phases/blocks/__tests__/alfredpay-onramp-direct.flow.test.ts index c67552e1d..59d2174bd 100644 --- a/apps/api/src/api/services/phases/blocks/__tests__/alfredpay-onramp-direct.flow.test.ts +++ b/apps/api/src/api/services/phases/blocks/__tests__/alfredpay-onramp-direct.flow.test.ts @@ -85,6 +85,15 @@ const ALFREDPAY_ONRAMP_DIRECT: RampPhase[] = ["initial", ...CORE_PHASES, "comple function buildCtx(outputCurrency: EvmToken): PhaseCtx { return { addNote: () => undefined, + evmDestinationGas: { + executionFeeUsd: "0.01", + fundingGasLimit: "21000", + isNativeTransfer: false, + maximumFeePerGas: "1", + network: Networks.Polygon, + programVersion: 2, + transferGasLimit: "100000" + }, notes: [], now: new Date(), partner: { id: null }, @@ -135,12 +144,12 @@ describe("Alfredpay direct onramp flow", () => { expect(squidCalculations).toBe(0); expect(capturedProviderRequests[0]?.metadata.customerId).toBe("anonymous"); - expect(output).toMatchObject({ amountRaw: "96000000", chain: Networks.Polygon, token: ALFREDPAY_EVM_TOKEN }); + expect(output).toMatchObject({ amountRaw: "95990000", chain: Networks.Polygon, token: ALFREDPAY_EVM_TOKEN }); expect(metadata.blocks.squidRouterSwap).toMatchObject({ effectiveExchangeRate: "1", - inputAmountRaw: "96000000", + inputAmountRaw: "95990000", networkFeeUSD: "0", - outputAmountRaw: "96000000" + outputAmountRaw: "95990000" }); }); @@ -149,10 +158,10 @@ describe("Alfredpay direct onramp flow", () => { const { metadata, output } = await makeAlfredpayOnrampDirectFlow(EvmToken.USDC).simulate(buildCtx(EvmToken.USDC)); expect(squidCalculations).toBe(1); - expect(output).toMatchObject({ amountRaw: "95000000", chain: Networks.Polygon, token: EvmToken.USDC }); + expect(output).toMatchObject({ amountRaw: "94990000", chain: Networks.Polygon, token: EvmToken.USDC }); expect(metadata.blocks.squidRouterSwap).toMatchObject({ - inputAmountRaw: "96000000", - outputAmountRaw: "95000000" + inputAmountRaw: "95990000", + outputAmountRaw: "94990000" }); }); }); diff --git a/apps/api/src/api/services/phases/blocks/__tests__/brl-onramp-base-cross-chain.flow.test.ts b/apps/api/src/api/services/phases/blocks/__tests__/brl-onramp-base-cross-chain.flow.test.ts index e630b0a3e..650973ded 100644 --- a/apps/api/src/api/services/phases/blocks/__tests__/brl-onramp-base-cross-chain.flow.test.ts +++ b/apps/api/src/api/services/phases/blocks/__tests__/brl-onramp-base-cross-chain.flow.test.ts @@ -1,6 +1,7 @@ import { afterAll, describe, expect, it, mock } from "bun:test"; import Big from "big.js"; import { BrlaApiService, EPaymentMethod, EvmToken, FiatToken, Networks, RampDirection, RampPhase } from "@vortexfi/shared"; +import { config } from "../../../../../config/vars"; import * as partnerPricingNamespace from "../../../partners/partner-pricing.service"; const partnerPricingReal = { ...partnerPricingNamespace }; @@ -166,7 +167,7 @@ describe("BRL cross-chain onramp flow compile-time adjacency", () => { }); }); -function buildCtx(): PhaseCtx { +function buildCtx(includeDynamicFunding = true): PhaseCtx { const notes: string[] = []; return { addNote: (note: string) => { @@ -176,6 +177,19 @@ function buildCtx(): PhaseCtx { displayFiat: { anchor: "0.1", currency: FiatToken.BRL, network: "0", partnerMarkup: "0", total: "0.2", vortex: "0.1" }, usd: { anchor: "0.1", network: "0", partnerMarkup: "0", total: "0.2", vortex: "0.1" } }, + ...(includeDynamicFunding + ? { + evmDestinationGas: { + executionFeeUsd: "0.01", + fundingGasLimit: "21000", + isNativeTransfer: false, + maximumFeePerGas: "1", + network: Networks.Arbitrum, + programVersion: 2 as const, + transferGasLimit: "100000" + } + } + : {}), notes, now: new Date(), partner: null, @@ -191,7 +205,7 @@ function buildCtx(): PhaseCtx { }; } -async function runFlow(flow: typeof brlOnrampBaseCrossChainFlow) { +async function runFlow(flow: typeof brlOnrampBaseCrossChainFlow, includeDynamicFunding = true) { BrlaApiService.getInstance = mock(() => ({ createPayInQuote: mock(async (request: { inputCurrency: string }) => ({ appliedFees: [{ amount: "0.2", type: "Gas Fee" }], @@ -200,7 +214,7 @@ async function runFlow(flow: typeof brlOnrampBaseCrossChainFlow) { })) })) as unknown as typeof BrlaApiService.getInstance; - return flow.simulate(buildCtx()); + return flow.simulate(buildCtx(includeDynamicFunding)); } describe("BRL cross-chain onramp flow simulation", () => { @@ -210,6 +224,17 @@ describe("BRL cross-chain onramp flow simulation", () => { expect(output.token).toBe(EvmToken.USDC); expect(output.chain).toBe(Networks.Arbitrum); }); + + it("keeps producing legacy-compatible metadata until the rollout flag is enabled", async () => { + const originalEnabled = config.evmDestinationGas.dynamicFundingEnabled; + config.evmDestinationGas.dynamicFundingEnabled = false; + try { + const { metadata } = await runFlow(brlOnrampBaseCrossChainFlow, false); + expect(metadata.globals.evmDestinationGas).toBeUndefined(); + } finally { + config.evmDestinationGas.dynamicFundingEnabled = originalEnabled; + } + }); }); describe("BRL cross-chain onramp flow metadata ownership", () => { @@ -217,7 +242,7 @@ describe("BRL cross-chain onramp flow metadata ownership", () => { const { metadata } = await runFlow(brlOnrampBaseCrossChainFlow); const { blocks, globals } = metadata; - expect(globals.fees.usd).toMatchObject({ anchor: "1.5", network: "0.1", total: "1.700000", vortex: "0.1" }); + expect(globals.fees.usd).toMatchObject({ anchor: "1.5", network: "0.11", total: "1.710000", vortex: "0.1" }); expect(Object.keys(blocks)).toEqual([ "aveniaMint", "fundEphemeral", @@ -233,7 +258,7 @@ describe("BRL cross-chain onramp flow metadata ownership", () => { const aveniaMint = getBlockMetadata(metadata, AveniaMintContext).mint; expect(aveniaMint).toBeDefined(); expect(aveniaMint.currency).toBe(FiatToken.BRL); - // 100 BRL in, 99 BRLA quoted -> 1 BRL mint fee, 0.2 gas fee deducted from delivery + // 100 BRL in, 99 BRLA quoted -> 1 BRL mint fee, 0.2 provider gas fee deducted from delivery expect(Big(aveniaMint.fee).toFixed()).toBe("1"); expect(Big(aveniaMint.inputAmountDecimal).toFixed()).toBe("100"); expect(Big(aveniaMint.outputAmountDecimal).toFixed()).toBe("98.8"); @@ -260,8 +285,8 @@ describe("BRL cross-chain onramp flow metadata ownership", () => { expect(evmToEvm.networkFeeUSD).toBe("0.1"); const distributeFees = getBlockMetadata(metadata, DistributeFeesContext); - expect(distributeFees.networkFeeUsd).toBe("0.1"); - expect(distributeFees.totalFeesUsd).toBe("0.2"); + expect(distributeFees.networkFeeUsd).toBe("0.11"); + expect(distributeFees.totalFeesUsd).toBe("0.21"); const subsidy = getBlockMetadata(metadata, FinalSettlementSubsidyContext); expect(subsidy).toBeDefined(); @@ -271,7 +296,7 @@ describe("BRL cross-chain onramp flow metadata ownership", () => { expect(getBlockMetadata(metadata, SubsidizePreContext).inputCurrency).toBe(EvmToken.BRLA); const subsidizePost = getBlockMetadata(metadata, SubsidizePostContext); expect(subsidizePost.outputCurrency).toBe(EvmToken.USDC); - expect(Big(subsidizePost.actualOutputAmountDecimal).toFixed()).toBe("17.8"); + expect(Big(subsidizePost.actualOutputAmountDecimal).toFixed()).toBe("17.79"); expect(getBlockMetadata(metadata, DestinationTransferContext).amountRaw).toBe("17500000"); }); }); diff --git a/apps/api/src/api/services/phases/blocks/__tests__/brl-onramp-base-cross-chain.transactions.test.ts b/apps/api/src/api/services/phases/blocks/__tests__/brl-onramp-base-cross-chain.transactions.test.ts index ee69dff6d..abb8c1b6a 100644 --- a/apps/api/src/api/services/phases/blocks/__tests__/brl-onramp-base-cross-chain.transactions.test.ts +++ b/apps/api/src/api/services/phases/blocks/__tests__/brl-onramp-base-cross-chain.transactions.test.ts @@ -16,7 +16,7 @@ import * as evmFundingNamespace from "../core/evm-funding"; import * as partnerPricingNamespace from "../../../partners/partner-pricing.service"; import type { QuoteTicketAttributes } from "../../../../../models/quoteTicket.model"; import Big from "big.js"; -import { decodeFunctionData, erc20Abi } from "viem"; +import { decodeFunctionData, erc20Abi, parseTransaction } from "viem"; import type { FlowMetadata } from "../core/metadata"; import type { SubsidyMetadata } from "../phases/subsidize-pre/simulation"; @@ -203,6 +203,15 @@ function buildMetadata(): FlowMetadata { } }, globals: { + evmDestinationGas: { + executionFeeUsd: "0.363", + fundingGasLimit: "21000", + isNativeTransfer: false, + maximumFeePerGas: "1000000000", + network: Networks.Arbitrum, + programVersion: 2, + transferGasLimit: "100000" + }, fees: { usd: { anchor: "0.1", network: "0.1", partnerMarkup: "0", total: "0.3", vortex: "0.1" } }, partner: null, request: REQUEST @@ -263,6 +272,10 @@ describe("BRL onramp Base cross-chain transactions", () => { expect(blocks.unsignedTxs.find(tx => tx.phase === "squidRouterSwap")?.txData).toMatchObject({ data: "0xa2" }); expect(blocks.unsignedTxs.find(tx => tx.phase === "backupSquidRouterApprove")?.txData).toMatchObject({ data: "0xb1" }); expect(blocks.unsignedTxs.find(tx => tx.phase === "backupSquidRouterSwap")?.txData).toMatchObject({ data: "0xb2" }); + expect(blocks.unsignedTxs.find(tx => tx.phase === "destinationTransfer")?.txData).toMatchObject({ + maxFeePerGas: "1000000000", + maxPriorityFeePerGas: "1000000" + }); }); it("allocates the production nonce lanes per network", async () => { @@ -300,6 +313,9 @@ describe("BRL onramp Base cross-chain transactions", () => { const presignedTxs = await signUnsignedTransactions(blocks.unsignedTxs, { evmEphemeral }); expect(presignedTxs.length).toBeGreaterThanOrEqual(blocks.unsignedTxs.length); expect(presignedTxs.every(tx => typeof tx.txData === "string" && tx.txData.startsWith("0x"))).toBe(true); + const destinationTransfer = presignedTxs.find(tx => tx.phase === "destinationTransfer"); + expect(destinationTransfer).toBeDefined(); + expect(parseTransaction(destinationTransfer?.txData as `0x${string}`).maxFeePerGas).toBe(3_000_000_000n); }, 60_000); it("preserves 18-decimal BSC USDT precision in the destination transfer", async () => { diff --git a/apps/api/src/api/services/phases/blocks/__tests__/brl-onramp-base-same-chain.flow.test.ts b/apps/api/src/api/services/phases/blocks/__tests__/brl-onramp-base-same-chain.flow.test.ts index 1a5c7826f..8a8f3dea7 100644 --- a/apps/api/src/api/services/phases/blocks/__tests__/brl-onramp-base-same-chain.flow.test.ts +++ b/apps/api/src/api/services/phases/blocks/__tests__/brl-onramp-base-same-chain.flow.test.ts @@ -100,6 +100,17 @@ function buildCtx(outputCurrency: EvmToken): PhaseCtx { displayFiat: { anchor: "0.1", currency: FiatToken.BRL, network: "0", partnerMarkup: "0", total: "0.2", vortex: "0.1" }, usd: { anchor: "0.1", network: "0", partnerMarkup: "0", total: "0.2", vortex: "0.1" } }, + evmDestinationGas: { + executionFeeUsd: "0.01", + fundingGasLimit: "21000", + isNativeTransfer: false, + maximumFeePerGas: "1", + maximumFundingL1FeeRaw: "1", + maximumPayoutL1FeeRaw: "1", + network: Networks.Base, + programVersion: 2, + transferGasLimit: "100000" + }, notes: [], now: new Date(), partner: null, @@ -166,15 +177,17 @@ describe("BRL Base same-chain block flows", () => { expect(destinationTransfer.network).toBe(Networks.Base); expect(destinationTransfer.token).toBe(outputCurrency); expect(metadata.globals.fees.usd.anchor).toBe("1.5"); - expect(metadata.globals.fees.usd.network).toBe(outputCurrency === EvmToken.USDC ? "0" : "0.1"); - expect(metadata.globals.fees.usd.total).toBe(outputCurrency === EvmToken.USDC ? "1.600000" : "1.700000"); - expect(metadata.globals.fees.displayFiat?.network).toBe(outputCurrency === EvmToken.USDC ? "0" : "0.1"); - expect(metadata.globals.fees.displayFiat?.total).toBe(outputCurrency === EvmToken.USDC ? "1.60" : "1.70"); + expect(metadata.globals.fees.usd.network).toBe(outputCurrency === EvmToken.USDC ? "0.01" : "0.11"); + expect(metadata.globals.fees.usd.total).toBe(outputCurrency === EvmToken.USDC ? "1.610000" : "1.710000"); + expect(metadata.globals.fees.displayFiat?.network).toBe(outputCurrency === EvmToken.USDC ? "0.01" : "0.11"); + expect(metadata.globals.fees.displayFiat?.total).toBe(outputCurrency === EvmToken.USDC ? "1.61" : "1.71"); expect(getBlockMetadata(metadata, DistributeFeesContext).networkFeeUsd).toBe( - outputCurrency === EvmToken.USDC ? "0" : "0.1" + outputCurrency === EvmToken.USDC ? "0.01" : "0.11" ); const subsidizePost = getBlockMetadata(metadata, SubsidizePostContext); - expect(Big(subsidizePost.actualOutputAmountDecimal).toFixed()).toBe(outputCurrency === EvmToken.USDC ? "17.9" : "17.8"); + expect(Big(subsidizePost.actualOutputAmountDecimal).toFixed()).toBe( + outputCurrency === EvmToken.USDC ? "17.89" : "17.79" + ); expect(subsidizePost.applied).toBe(false); }); } diff --git a/apps/api/src/api/services/phases/blocks/__tests__/fund-ephemeral-user-hashes.test.ts b/apps/api/src/api/services/phases/blocks/__tests__/fund-ephemeral-user-hashes.test.ts index 74518c162..425732660 100644 --- a/apps/api/src/api/services/phases/blocks/__tests__/fund-ephemeral-user-hashes.test.ts +++ b/apps/api/src/api/services/phases/blocks/__tests__/fund-ephemeral-user-hashes.test.ts @@ -3,6 +3,7 @@ import { EvmToken, FiatToken, Networks, RampDirection } from "@vortexfi/shared"; import type QuoteTicket from "../../../../../models/quoteTicket.model"; import type RampState from "../../../../../models/rampState.model"; import * as userTxVerifier from "../../../phases/helpers/user-tx-verifier"; +import { privateKeyToAccount } from "viem/accounts"; // Snapshot before mocking: mock.module mutates the imported namespace in place, so // spreading `userTxVerifier` at restore time would copy the stub back. @@ -108,3 +109,113 @@ describe("FundEphemeralExecutor user hash verification", () => { expect(verifyUserSubmittedTxByHash).not.toHaveBeenCalled(); }); }); + +describe("FundEphemeralExecutor destination gas funding", () => { + it("uses the signed payout liability for non-Ethereum EVM destinations", async () => { + const account = privateKeyToAccount("0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d"); + const rawTx = await account.signTransaction({ + chainId: 137, + gas: 100_000n, + maxFeePerGas: 30_000_000_000n, + maxPriorityFeePerGas: 1_000_000_000n, + nonce: 0, + to: "0x0000000000000000000000000000000000000001", + type: "eip1559", + value: 0n + }); + const handler = Object.create(FundEphemeralExecutor.prototype) as any; + handler.getPresignedTransaction = () => ({ + meta: {}, + network: Networks.Polygon, + nonce: 0, + phase: "destinationTransfer", + signer: account.address, + txData: rawTx + }); + const state = { + unsignedTxs: [ + { + network: Networks.Polygon, + nonce: 0, + phase: "destinationTransfer", + signer: account.address, + txData: { + data: "0x", + gas: "100000", + maxFeePerGas: "10000000000", + maxPriorityFeePerGas: "1000000000", + to: "0x0000000000000000000000000000000000000001", + value: "0" + } + } + ] + } as unknown as RampState; + + expect( + await handler.getDestinationEvmFundingRequirementRaw(state, Networks.Polygon, { + executionFeeUsd: "0.20", + fundingGasLimit: "21000", + isNativeTransfer: false, + maximumFeePerGas: "12000000000", + network: Networks.Polygon, + programVersion: 2, + transferGasLimit: "100000" + }) + ).toBe(3_000_000_000_000_000n); + }); + + it("reserves the persisted Base payout L1 envelope instead of a live early fee", async () => { + const account = privateKeyToAccount("0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d"); + const rawTx = await account.signTransaction({ + chainId: 8453, + gas: 100_000n, + maxFeePerGas: 3_000_000_000n, + maxPriorityFeePerGas: 1_000_000_000n, + nonce: 0, + to: "0x0000000000000000000000000000000000000001", + type: "eip1559", + value: 0n + }); + const handler = Object.create(FundEphemeralExecutor.prototype) as any; + handler.getPresignedTransaction = () => ({ + meta: {}, + network: Networks.Base, + nonce: 0, + phase: "destinationTransfer", + signer: account.address, + txData: rawTx + }); + const state = { + unsignedTxs: [ + { + network: Networks.Base, + nonce: 0, + phase: "destinationTransfer", + signer: account.address, + txData: { + data: "0x", + gas: "100000", + maxFeePerGas: "1000000000", + maxPriorityFeePerGas: "1000000000", + to: "0x0000000000000000000000000000000000000001", + value: "0" + } + } + ] + } as unknown as RampState; + + expect( + await handler.getDestinationEvmFundingRequirementRaw(state, Networks.Base, { + executionFeeUsd: "0.20", + fundingGasLimit: "21000", + isNativeTransfer: false, + maximumFeePerGas: "1200000000", + maximumFundingL1FeeRaw: "12000000000000", + maximumPayoutL1FeeRaw: "15000000000000", + network: Networks.Base, + programVersion: 2, + transferGasLimit: "100000" + }) + ).toBe(315_000_000_000_000n); + }); +}); diff --git a/apps/api/src/api/services/phases/blocks/core/destination-funding.test.ts b/apps/api/src/api/services/phases/blocks/core/destination-funding.test.ts index a4efdf9d7..c68358c93 100644 --- a/apps/api/src/api/services/phases/blocks/core/destination-funding.test.ts +++ b/apps/api/src/api/services/phases/blocks/core/destination-funding.test.ts @@ -1,9 +1,14 @@ import { describe, expect, it } from "bun:test"; import { Networks } from "@vortexfi/shared"; import { privateKeyToAccount } from "viem/accounts"; -import { ETHEREUM_EPHEMERAL_STARTING_BALANCE_UNITS } from "../../../../../constants/constants"; import { UnrecoverablePhaseError } from "../../../../errors/phase-error"; -import { DESTINATION_EVM_FUNDING_AMOUNTS, ensurePresignedTransferFunded } from "./destination-funding"; +import { + calculateDestinationFundingShortfallRaw, + calculateSourceEvmFundingRequirementRaw, + ensurePresignedTransferFunded, + getDynamicDestinationEvmFundingNetwork +} from "./destination-funding"; +import { calculatePresignedGasBudgetRaw } from "./evm-destination-gas"; const account = privateKeyToAccount("0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d"); const recipient = "0x0000000000000000000000000000000000000001"; @@ -31,8 +36,34 @@ describe("ensurePresignedTransferFunded", () => { }); }); -describe("destination EVM funding amounts", () => { - it("uses the static Ethereum ephemeral funding constant", () => { - expect(DESTINATION_EVM_FUNDING_AMOUNTS[Networks.Ethereum]).toBe(ETHEREUM_EPHEMERAL_STARTING_BALANCE_UNITS); +describe("EVM destination gas funding", () => { + it("derives the funding requirement from the signed transaction fee cap", async () => { + const rawTx = await account.signTransaction({ + chainId: 1, + gas: 100_000n, + maxFeePerGas: 3_000_000_000n, + maxPriorityFeePerGas: 1_000_000_000n, + nonce: 0, + to: recipient, + type: "eip1559", + value: 0n + }); + + expect(calculatePresignedGasBudgetRaw(rawTx)).toBe(300_000_000_000_000n); + }); + + it("funds only the shortfall below the signed gas requirement", () => { + expect(calculateDestinationFundingShortfallRaw(300n, 125n)).toBe(175n); + expect(calculateDestinationFundingShortfallRaw(300n, 300n)).toBe(0n); + expect(calculateDestinationFundingShortfallRaw(300n, 400n)).toBe(0n); + }); + + it("keeps a same-network payout liability additive to source reserves", () => { + expect(calculateSourceEvmFundingRequirementRaw(100n, 25n, 300n)).toBe(425n); + }); + + it("does not dynamically fund destination gas for direct-transfer flows", () => { + expect(getDynamicDestinationEvmFundingNetwork(Networks.Base, true, true)).toBeUndefined(); + expect(getDynamicDestinationEvmFundingNetwork(Networks.Base, true, false)).toBe(Networks.Base); }); }); diff --git a/apps/api/src/api/services/phases/blocks/core/destination-funding.ts b/apps/api/src/api/services/phases/blocks/core/destination-funding.ts index 1558ffe39..8d6717356 100644 --- a/apps/api/src/api/services/phases/blocks/core/destination-funding.ts +++ b/apps/api/src/api/services/phases/blocks/core/destination-funding.ts @@ -4,6 +4,7 @@ import { checkEvmNativeBalancePeriodically, EvmClientManager, EvmNetworks, + isNetworkEVM, Networks } from "@vortexfi/shared"; import Big from "big.js"; @@ -12,7 +13,6 @@ import { base, polygon } from "viem/chains"; import logger from "../../../../../config/logger"; import { BASE_EPHEMERAL_STARTING_BALANCE_UNITS, - ETHEREUM_EPHEMERAL_STARTING_BALANCE_UNITS, GLMR_FUNDING_AMOUNT_RAW, PENDULUM_EPHEMERAL_STARTING_BALANCE_UNITS, POLYGON_EPHEMERAL_STARTING_BALANCE_UNITS @@ -20,6 +20,21 @@ import { import { UnrecoverablePhaseError } from "../../../../errors/phase-error"; import { multiplyByPowerOfTen } from "../../../pendulum/helpers"; +// Compatibility program for quotes created before dynamic destination funding +// metadata existed. Keep these values and operation identities stable until all +// such quotes/ramps have expired or completed. +export const LEGACY_DESTINATION_EVM_FUNDING_AMOUNTS: Record = { + [Networks.Arbitrum]: "0.0002", + [Networks.Avalanche]: "0.0034", + [Networks.Base]: "0.000034", + [Networks.BaseSepolia]: "0.000034", + [Networks.BSC]: "0.000115", + [Networks.Ethereum]: "0.005", + [Networks.Moonbeam]: "0.34", + [Networks.Polygon]: "0.6", + [Networks.PolygonAmoy]: "0.2" +}; + export async function isPendulumEphemeralFunded(pendulumEphemeralAddress: string, pendulumNode: API): Promise { const fundingAmountUnits = Big(PENDULUM_EPHEMERAL_STARTING_BALANCE_UNITS); const fundingAmountRaw = multiplyByPowerOfTen(fundingAmountUnits, pendulumNode.decimals).toFixed(); @@ -55,21 +70,39 @@ export async function isPolygonEphemeralFunded(polygonEphemeralAddress: string): return Big(balance.toString()).gte(fundingAmountRaw); } -export const DESTINATION_EVM_FUNDING_AMOUNTS: Record = { - [Networks.Ethereum]: ETHEREUM_EPHEMERAL_STARTING_BALANCE_UNITS, - [Networks.Arbitrum]: "0.0002", - [Networks.Base]: "0.000034", - [Networks.Polygon]: "0.6", - [Networks.BSC]: "0.000115", - [Networks.Avalanche]: "0.0034", - [Networks.Moonbeam]: "0.34", - [Networks.PolygonAmoy]: "0.2", - [Networks.BaseSepolia]: "0.000034" -}; +export function calculateDestinationFundingShortfallRaw(requiredFundingRaw: bigint, currentBalanceRaw: bigint): bigint { + return requiredFundingRaw > currentBalanceRaw ? requiredFundingRaw - currentBalanceRaw : 0n; +} + +export function calculateSourceEvmFundingRequirementRaw( + fixedFundingRaw: bigint, + plannedNativeValueRaw: bigint, + sameNetworkDestinationLiabilityRaw = 0n +): bigint { + // A same-chain payout spends from the same balance as the source phases, so its signed + // fee liability must remain additive instead of reusing the fixed source reserve. + return fixedFundingRaw + plannedNativeValueRaw + sameNetworkDestinationLiabilityRaw; +} + +export function getDynamicDestinationEvmFundingNetwork( + destinationNetwork: Networks | undefined, + isBuy: boolean, + isDirectTransfer: boolean | undefined +): EvmNetworks | undefined { + // Direct provider-mint flows intentionally have no destination fee envelope. Their + // execution therefore keeps the existing fixed source reserve rather than adding an + // unquoted dynamic treasury transfer. + if (!isBuy || isDirectTransfer === true || !destinationNetwork || !isNetworkEVM(destinationNetwork)) { + return undefined; + } + + return destinationNetwork; +} export async function isDestinationEvmEphemeralFunded( evmEphemeralAddress: string, - destinationNetwork: EvmNetworks + destinationNetwork: EvmNetworks, + requiredFundingRaw: bigint ): Promise { const destinationClient = EvmClientManager.getInstance().getClient(destinationNetwork); const chain = destinationClient.chain; @@ -78,11 +111,7 @@ export async function isDestinationEvmEphemeralFunded( } const balance = await destinationClient.getBalance({ address: evmEphemeralAddress as `0x${string}` }); - const fundingAmountRaw = new Big( - multiplyByPowerOfTen(DESTINATION_EVM_FUNDING_AMOUNTS[destinationNetwork], chain.nativeCurrency.decimals).toFixed() - ); - - return Big(balance.toString()).gte(fundingAmountRaw); + return Big(balance.toString()).gte(requiredFundingRaw.toString()); } const PRESIGNED_TRANSFER_BALANCE_POLL_MS = 5000; diff --git a/apps/api/src/api/services/phases/blocks/core/evm-destination-gas.test.ts b/apps/api/src/api/services/phases/blocks/core/evm-destination-gas.test.ts new file mode 100644 index 000000000..4bd167746 --- /dev/null +++ b/apps/api/src/api/services/phases/blocks/core/evm-destination-gas.test.ts @@ -0,0 +1,370 @@ +import { describe, expect, it } from "bun:test"; +import { + type EvmNetworks, + type EvmTransactionData, + Networks, + type PresignedTx, + QuoteError, + RampDirection +} from "@vortexfi/shared"; +import { APIError } from "../../../../errors/api-error"; +import { config } from "../../../../../config/vars"; +import { + assertPreparedEvmDestinationFeeWithinQuote, + assertEvmTreasuryFundingFeeWithinQuote, + calculateBoundedPresignedGasBudgetRaw, + calculateExpectedExecutionFeeRaw, + calculateQuotedPresignedExecutionBudgetRaw, + EVM_ERC20_TRANSFER_GAS_LIMIT, + EVM_ERC20_UNSIGNED_TRANSACTION_SIZE_BYTES, + EVM_NATIVE_UNSIGNED_TRANSACTION_SIZE_BYTES, + getBaseL1FeeUpperBoundRaw, + getEvmDestinationExecutionFeeUsd, + getEvmNativeFeeCurrency +} from "./evm-destination-gas"; +import type { PhaseCtx } from "./types"; +import { privateKeyToAccount } from "viem/accounts"; +import { installFakeEvm } from "../../../../../test-utils/fake-world/fake-evm"; + +describe("EVM destination gas policy", () => { + it("prices the persisted funding and ERC-20 payout gas envelopes", () => { + expect( + calculateExpectedExecutionFeeRaw( + 1_200_000_000n, + 21_000n, + EVM_ERC20_TRANSFER_GAS_LIMIT + ) + ).toBe(145_200_000_000_000n); + }); + + it("adds the persisted Base L1 security-fee envelopes", () => { + expect( + calculateExpectedExecutionFeeRaw( + 1_200_000_000n, + 21_000n, + EVM_ERC20_TRANSFER_GAS_LIMIT, + 24_000n + ) + ).toBe(145_200_000_024_000n); + }); + + it("re-binds every signed payout field before deriving treasury liability", async () => { + const account = privateKeyToAccount("0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d"); + const unsignedTransaction = destinationTransfer( + transaction("1000000000", "1000000000"), + account.address, + Networks.Polygon + ); + const overrides = [ + { expected: "'to'", gas: 100_000n, to: "0x0000000000000000000000000000000000000002" }, + { data: "0x12345678" as const, expected: "data", gas: 100_000n }, + { expected: "value", gas: 100_000n, value: 1n }, + { expected: "gas limit", gas: 100_001n }, + { + expected: "below expected minimum", + gas: 100_000n, + maxFeePerGas: 999_999_999n, + maxPriorityFeePerGas: 999_999_999n + }, + { expected: "nonce", gas: 100_000n, nonce: 1 } + ]; + + for (const override of overrides) { + const rawTransaction = await account.signTransaction({ + chainId: 137, + gas: override.gas, + maxFeePerGas: override.maxFeePerGas ?? 3_000_000_000n, + maxPriorityFeePerGas: override.maxPriorityFeePerGas ?? 1_000_000_000n, + nonce: override.nonce ?? 0, + to: (override.to ?? "0x0000000000000000000000000000000000000001") as `0x${string}`, + type: "eip1559", + value: override.value ?? 0n, + ...(override.data ? { data: override.data } : {}) + }); + + await expect( + calculateBoundedPresignedGasBudgetRaw( + destinationTransfer(rawTransaction, account.address, Networks.Polygon), + unsignedTransaction + ) + ).rejects.toThrow(override.expected); + } + + const foreignAccount = privateKeyToAccount( + "0x8b3a350cf5c34c9194ca3a545d44d3b6739b0eeb2e0ec143f7fb926a4f9f9f0d" + ); + const foreignRawTransaction = await foreignAccount.signTransaction({ + chainId: 137, + gas: 100_000n, + maxFeePerGas: 3_000_000_000n, + maxPriorityFeePerGas: 1_000_000_000n, + nonce: 0, + to: "0x0000000000000000000000000000000000000001", + type: "eip1559", + value: 0n + }); + await expect( + calculateBoundedPresignedGasBudgetRaw( + destinationTransfer(foreignRawTransaction, account.address, Networks.Polygon), + unsignedTransaction + ) + ).rejects.toThrow("Recovered signer"); + }); + + it("adds the persisted Base L1 payout envelope to the presigned liability", async () => { + const { fakeEvm, restore } = installFakeEvm(); + try { + const account = privateKeyToAccount("0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d"); + const rawTransaction = await account.signTransaction({ + chainId: 8453, + gas: 100_000n, + maxFeePerGas: 3_000_000_000n, + maxPriorityFeePerGas: 1_000_000_000n, + nonce: 0, + to: "0x0000000000000000000000000000000000000001", + type: "eip1559", + value: 0n + }); + const presignedTransfer = destinationTransfer(rawTransaction, account.address, Networks.Base); + const unsignedTransfer = destinationTransfer( + transaction("1000000000", "1000000000"), + account.address, + Networks.Base + ); + + expect( + await calculateQuotedPresignedExecutionBudgetRaw(presignedTransfer, unsignedTransfer, { + executionFeeUsd: "0.20", + fundingGasLimit: "21000", + isNativeTransfer: false, + maximumFeePerGas: "1200000000", + maximumFundingL1FeeRaw: "12000000000000", + maximumPayoutL1FeeRaw: "13000000000000", + network: Networks.Base, + programVersion: 2, + transferGasLimit: "100000" + }) + ).toBe(300_000_000_000_000n + 13_000_000_000_000n); + fakeEvm.onReadContract = () => { + throw new Error("late Base oracle unavailable"); + }; + expect( + await calculateQuotedPresignedExecutionBudgetRaw(presignedTransfer, unsignedTransfer, { + executionFeeUsd: "0.20", + fundingGasLimit: "21000", + isNativeTransfer: false, + maximumFeePerGas: "1200000000", + maximumFundingL1FeeRaw: "12000000000000", + maximumPayoutL1FeeRaw: "13000000000000", + network: Networks.Base, + programVersion: 2, + transferGasLimit: "100000" + }) + ).toBe(313_000_000_000_000n); + fakeEvm.onReadContract = undefined; + expect(await getBaseL1FeeUpperBoundRaw(Networks.Base, EVM_NATIVE_UNSIGNED_TRANSACTION_SIZE_BYTES)).toBe( + fakeEvm.baseL1FeeUpperBoundRaw + ); + expect(await getBaseL1FeeUpperBoundRaw(Networks.BaseSepolia, EVM_NATIVE_UNSIGNED_TRANSACTION_SIZE_BYTES)).toBe( + fakeEvm.baseL1FeeUpperBoundRaw + ); + expect(await getBaseL1FeeUpperBoundRaw(Networks.Arbitrum, EVM_NATIVE_UNSIGNED_TRANSACTION_SIZE_BYTES)).toBe(0n); + } finally { + restore(); + } + }); + + it("rejects a Base treasury transfer when its current L1 fee exceeds the quote envelope", async () => { + const { fakeEvm, restore } = installFakeEvm(); + try { + const quote = { + executionFeeUsd: "0.20", + fundingGasLimit: "21000", + isNativeTransfer: false, + maximumFeePerGas: "1200000000", + maximumFundingL1FeeRaw: "12000000000000", + maximumPayoutL1FeeRaw: "12000000000000", + network: Networks.Base as EvmNetworks, + programVersion: 2 as const, + transferGasLimit: "100000" + }; + fakeEvm.baseL1FeeUpperBoundRaw = 12_000_000_000_001n; + + await expect(assertEvmTreasuryFundingFeeWithinQuote(quote, Networks.Base, 1_000_000_000n)).rejects.toThrow( + QuoteError.NetworkFeesTooHigh + ); + } finally { + restore(); + } + }); + + it("rejects a Base treasury transfer when the payout L1 fee exceeds the quote envelope", async () => { + const { fakeEvm, restore } = installFakeEvm(); + try { + const quote = { + executionFeeUsd: "0.20", + fundingGasLimit: "21000", + isNativeTransfer: false, + maximumFeePerGas: "1200000000", + maximumFundingL1FeeRaw: "12000000000000", + maximumPayoutL1FeeRaw: "12000000000000", + network: Networks.Base as EvmNetworks, + programVersion: 2 as const, + transferGasLimit: EVM_ERC20_TRANSFER_GAS_LIMIT.toString() + }; + fakeEvm.onReadContract = (_network, params) => { + if (params.functionName !== "getL1FeeUpperBound") return undefined; + return params.args?.[0] === EVM_ERC20_UNSIGNED_TRANSACTION_SIZE_BYTES + ? 12_000_000_000_001n + : fakeEvm.baseL1FeeUpperBoundRaw; + }; + + await expect(assertEvmTreasuryFundingFeeWithinQuote(quote, Networks.Base, 1_000_000_000n)).rejects.toThrow( + QuoteError.NetworkFeesTooHigh + ); + } finally { + restore(); + } + }); + + it("requires Arbitrum funding gas to include the parent-chain poster component", async () => { + const { fakeEvm, restore } = installFakeEvm(); + try { + fakeEvm.arbitrumL1GasComponent = 520n; + const quote = { + executionFeeUsd: "0.20", + fundingGasLimit: "21000", + isNativeTransfer: false, + maximumFeePerGas: "1200000000", + network: Networks.Arbitrum as EvmNetworks, + programVersion: 2 as const, + transferGasLimit: "100000" + }; + + await expect(assertEvmTreasuryFundingFeeWithinQuote(quote, Networks.Arbitrum, 1_000_000_000n)).rejects.toThrow( + QuoteError.NetworkFeesTooHigh + ); + + quote.fundingGasLimit = "21520"; + quote.transferGasLimit = "100520"; + await expect( + assertEvmTreasuryFundingFeeWithinQuote(quote, Networks.Arbitrum, 1_000_000_000n) + ).resolves.toBeUndefined(); + } finally { + restore(); + } + }); + + it("maps every EVM network to the native currency used to price its gas", () => { + const expected: Record = { + [Networks.Arbitrum]: "ETH", + [Networks.Avalanche]: "AVAX", + [Networks.Base]: "ETH", + [Networks.BaseSepolia]: "ETH", + [Networks.BSC]: "BNB", + [Networks.Ethereum]: "ETH", + [Networks.Moonbeam]: "GLMR", + [Networks.Polygon]: "MATIC", + [Networks.PolygonAmoy]: "MATIC" + }; + + for (const [network, currency] of Object.entries(expected)) { + expect(String(getEvmNativeFeeCurrency(network as EvmNetworks))).toBe(currency); + } + }); + + it("does not price destination gas for exact provider-direct payouts", async () => { + const ctx = { + priceEvmDestinationGas: false, + request: { rampType: RampDirection.BUY, to: Networks.Base } + } as PhaseCtx; + + expect(await getEvmDestinationExecutionFeeUsd(ctx)).toBe("0"); + expect(ctx.evmDestinationGas).toBeUndefined(); + }); + + it("allows registration-time fee movement inside the quote margin", () => { + expect(() => + assertPreparedEvmDestinationFeeWithinQuote( + { + executionFeeUsd: "0.20", + fundingGasLimit: "21000", + isNativeTransfer: false, + maximumFeePerGas: "120", + network: Networks.Arbitrum, + programVersion: 2, + transferGasLimit: "100000" + }, + Networks.Arbitrum, + transaction("120") + ) + ).not.toThrow(); + }); + + it("uses the persisted absolute ceiling after deployment margin configuration changes", () => { + const originalMargin = config.evmDestinationGas.networkFeeMarginBps; + config.evmDestinationGas.networkFeeMarginBps = 30_000; + try { + expect(() => + assertPreparedEvmDestinationFeeWithinQuote( + { + executionFeeUsd: "0.20", + fundingGasLimit: "21000", + isNativeTransfer: false, + maximumFeePerGas: "120", + network: Networks.BSC, + programVersion: 2, + transferGasLimit: "100000" + }, + Networks.BSC, + transaction("121") + ) + ).toThrow(QuoteError.NetworkFeesTooHigh); + } finally { + config.evmDestinationGas.networkFeeMarginBps = originalMargin; + } + }); + + it("rejects registration when the destination fee moved beyond the quote margin", () => { + let thrown: unknown; + try { + assertPreparedEvmDestinationFeeWithinQuote( + { + executionFeeUsd: "0.20", + fundingGasLimit: "21000", + isNativeTransfer: false, + maximumFeePerGas: "120", + network: Networks.BSC, + programVersion: 2, + transferGasLimit: "100000" + }, + Networks.BSC, + transaction("121") + ); + } catch (error) { + thrown = error; + } + + expect(thrown).toBeInstanceOf(APIError); + expect((thrown as APIError).message).toBe(QuoteError.NetworkFeesTooHigh); + }); +}); + +function transaction(maxFeePerGas: string, maxPriorityFeePerGas = "1"): EvmTransactionData { + return { + data: "0x", + gas: "100000", + maxFeePerGas, + maxPriorityFeePerGas, + to: "0x0000000000000000000000000000000000000001", + value: "0" + }; +} + +function destinationTransfer( + txData: PresignedTx["txData"], + signer: string, + network: EvmNetworks +): PresignedTx { + return { meta: {}, network, nonce: 0, phase: "destinationTransfer", signer, txData }; +} diff --git a/apps/api/src/api/services/phases/blocks/core/evm-destination-gas.ts b/apps/api/src/api/services/phases/blocks/core/evm-destination-gas.ts new file mode 100644 index 000000000..f2741a432 --- /dev/null +++ b/apps/api/src/api/services/phases/blocks/core/evm-destination-gas.ts @@ -0,0 +1,315 @@ +import { + EvmClientManager, + type EvmNetworks, + EvmToken, + type EvmTransactionData, + getNetworkFromDestination, + getOnChainTokenDetails, + isEvmTokenDetails, + isNativeEvmToken, + isNetworkEVM, + Networks, + type OnChainToken, + type PresignedTx, + QuoteError, + type RampCurrency, + RampDirection +} from "@vortexfi/shared"; +import Big from "big.js"; +import httpStatus from "http-status"; +import { encodeFunctionData, erc20Abi, formatUnits, parseAbi, parseTransaction, type TransactionSerialized } from "viem"; +import { config } from "../../../../../config/vars"; +import { APIError } from "../../../../errors/api-error"; +import { priceFeedService } from "../../../priceFeed.service"; +import { validatePresignedEvmTransactionAgainstUnsigned } from "../../../transactions/validation"; +import type { EvmDestinationGasQuote } from "./metadata"; +import type { PhaseCtx } from "./types"; + +export const EVM_NATIVE_TRANSFER_GAS_LIMIT = 21_000n; +export const EVM_ERC20_TRANSFER_GAS_LIMIT = 100_000n; +export const EVM_DESTINATION_FUNDING_PROGRAM_VERSION = 2 as const; + +// Base's GasPriceOracle adds the signed fields itself. These conservative unsigned +// EIP-1559 sizes cover a native transfer and an ERC-20 transfer respectively. +export const EVM_NATIVE_UNSIGNED_TRANSACTION_SIZE_BYTES = 128n; +export const EVM_ERC20_UNSIGNED_TRANSACTION_SIZE_BYTES = 256n; + +const BASE_GAS_PRICE_ORACLE_ADDRESS = "0x420000000000000000000000000000000000000F"; +const BASE_GAS_PRICE_ORACLE_ABI = parseAbi(["function getL1FeeUpperBound(uint256 unsignedTxSize) view returns (uint256)"]); + +const ARBITRUM_NODE_INTERFACE_ADDRESS = "0x00000000000000000000000000000000000000C8"; +const ARBITRUM_NODE_INTERFACE_ABI = parseAbi([ + "function gasEstimateL1Component(address to, bool contractCreation, bytes data) view returns (uint64 gasEstimateForL1, uint256 baseFee, uint256 l1BaseFeeEstimate)" +]); +const WORST_CASE_EVM_ADDRESS = "0xffffffffffffffffffffffffffffffffffffffff"; +const WORST_CASE_ERC20_TRANSFER_DATA = encodeFunctionData({ + abi: erc20Abi, + args: [WORST_CASE_EVM_ADDRESS, 2n ** 256n - 1n], + functionName: "transfer" +}); + +const EVM_NATIVE_FEE_CURRENCIES: Record = { + [Networks.Arbitrum]: "ETH" as RampCurrency, + [Networks.Avalanche]: "AVAX" as RampCurrency, + [Networks.Base]: "ETH" as RampCurrency, + [Networks.BaseSepolia]: "ETH" as RampCurrency, + [Networks.BSC]: "BNB" as RampCurrency, + [Networks.Ethereum]: "ETH" as RampCurrency, + [Networks.Moonbeam]: "GLMR" as RampCurrency, + [Networks.Polygon]: "MATIC" as RampCurrency, + [Networks.PolygonAmoy]: "MATIC" as RampCurrency +}; + +export function getEvmNativeFeeCurrency(network: EvmNetworks): RampCurrency { + return EVM_NATIVE_FEE_CURRENCIES[network]; +} + +function throwNetworkFeesTooHigh(): never { + throw new APIError({ message: QuoteError.NetworkFeesTooHigh, status: httpStatus.SERVICE_UNAVAILABLE }); +} + +function applyMarginBps(value: bigint, marginBps: number): bigint { + return (value * BigInt(marginBps) + 9_999n) / 10_000n; +} + +async function getArbitrumL1GasComponent(network: EvmNetworks, to: `0x${string}`, data: `0x${string}`): Promise { + if (network !== Networks.Arbitrum) return 0n; + + const client = EvmClientManager.getInstance().getClient(network); + const result = (await client.readContract({ + abi: ARBITRUM_NODE_INTERFACE_ABI, + account: WORST_CASE_EVM_ADDRESS, + address: ARBITRUM_NODE_INTERFACE_ADDRESS, + args: [to, false, data], + functionName: "gasEstimateL1Component" + })) as readonly [bigint, bigint, bigint]; + return result[0]; +} + +async function getArbitrumExecutionGasLimits( + network: EvmNetworks, + isNativeTransfer: boolean, + marginBps = 10_000 +): Promise<{ fundingGasLimit: bigint; transferGasLimit: bigint }> { + const [fundingL1Gas, payoutL1Gas] = await Promise.all([ + getArbitrumL1GasComponent(network, WORST_CASE_EVM_ADDRESS, "0x"), + getArbitrumL1GasComponent(network, WORST_CASE_EVM_ADDRESS, isNativeTransfer ? "0x" : WORST_CASE_ERC20_TRANSFER_DATA) + ]); + return { + fundingGasLimit: EVM_NATIVE_TRANSFER_GAS_LIMIT + applyMarginBps(fundingL1Gas, marginBps), + transferGasLimit: + (isNativeTransfer ? EVM_NATIVE_TRANSFER_GAS_LIMIT : EVM_ERC20_TRANSFER_GAS_LIMIT) + applyMarginBps(payoutL1Gas, marginBps) + }; +} + +export function calculatePresignedGasBudgetRaw(rawTransaction: `0x${string}`): bigint { + const transaction = parseTransaction(rawTransaction as TransactionSerialized); + const feePerGas = transaction.maxFeePerGas ?? transaction.gasPrice; + if (transaction.gas === undefined || feePerGas === undefined) { + throw new Error("EVM destination transaction is missing gas or fee data"); + } + return transaction.gas * feePerGas; +} + +export async function calculateBoundedPresignedGasBudgetRaw( + presignedTransaction: PresignedTx, + unsignedTransaction: PresignedTx +): Promise { + await validatePresignedEvmTransactionAgainstUnsigned(presignedTransaction, unsignedTransaction); + if (typeof presignedTransaction.txData !== "string") { + throw new Error("EVM destination transaction is not a signed transaction"); + } + return calculatePresignedGasBudgetRaw(presignedTransaction.txData as `0x${string}`); +} + +function isBaseNetwork(network: EvmNetworks): boolean { + return network === Networks.Base || network === Networks.BaseSepolia; +} + +export async function getBaseL1FeeUpperBoundRaw(network: EvmNetworks, unsignedTxSize: bigint): Promise { + if (!isBaseNetwork(network)) return 0n; + + const client = EvmClientManager.getInstance().getClient(network); + return (await client.readContract({ + abi: BASE_GAS_PRICE_ORACLE_ABI, + address: BASE_GAS_PRICE_ORACLE_ADDRESS, + args: [unsignedTxSize], + functionName: "getL1FeeUpperBound" + })) as bigint; +} + +export async function calculateQuotedPresignedExecutionBudgetRaw( + presignedTransaction: PresignedTx, + unsignedTransaction: PresignedTx, + quote: EvmDestinationGasQuote +): Promise { + if (quote.programVersion !== EVM_DESTINATION_FUNDING_PROGRAM_VERSION || quote.network !== presignedTransaction.network) { + throw new Error(`EVM destination funding quote does not support ${presignedTransaction.network}`); + } + if (isBaseNetwork(presignedTransaction.network) && quote.maximumPayoutL1FeeRaw === undefined) { + throw new Error("Base destination gas quote is missing its payout L1 fee envelope"); + } + const l1ReserveRaw = isBaseNetwork(presignedTransaction.network) ? BigInt(quote.maximumPayoutL1FeeRaw as string) : 0n; + return (await calculateBoundedPresignedGasBudgetRaw(presignedTransaction, unsignedTransaction)) + l1ReserveRaw; +} + +export function calculateExpectedExecutionFeeRaw( + maximumFeePerGas: bigint, + fundingGasLimit: bigint, + transferGasLimit: bigint, + maximumL1FeeRaw = 0n +): bigint { + return (fundingGasLimit + transferGasLimit) * maximumFeePerGas + maximumL1FeeRaw; +} + +export function assertPreparedEvmDestinationFeeWithinQuote( + quote: EvmDestinationGasQuote, + network: EvmNetworks, + transaction: EvmTransactionData +): void { + if (quote.network !== network) { + throw new Error(`EVM destination gas quote is for ${quote.network}, not ${network}`); + } + if (transaction.gas !== quote.transferGasLimit) { + throw new Error(`EVM destination gas limit changed from ${quote.transferGasLimit} to ${transaction.gas}`); + } + if (!transaction.maxFeePerGas) { + throw new Error("Prepared EVM destination transaction is missing maxFeePerGas"); + } + + if (BigInt(transaction.maxFeePerGas) > BigInt(quote.maximumFeePerGas)) { + throwNetworkFeesTooHigh(); + } +} + +export async function assertEvmTreasuryFundingFeeWithinQuote( + quote: EvmDestinationGasQuote, + network: EvmNetworks, + maxFeePerGas: bigint +): Promise { + if (quote.network !== network) { + throw new Error(`EVM destination gas quote is for ${quote.network}, not ${network}`); + } + if (quote.programVersion !== EVM_DESTINATION_FUNDING_PROGRAM_VERSION) { + throw new Error(`Unsupported EVM destination funding program ${String(quote.programVersion)}`); + } + if (maxFeePerGas > BigInt(quote.maximumFeePerGas)) { + throwNetworkFeesTooHigh(); + } + + if (network === Networks.Arbitrum) { + const currentGasLimits = await getArbitrumExecutionGasLimits(network, quote.isNativeTransfer); + if ( + currentGasLimits.fundingGasLimit > BigInt(quote.fundingGasLimit) || + currentGasLimits.transferGasLimit > BigInt(quote.transferGasLimit) + ) { + throwNetworkFeesTooHigh(); + } + } + + if (!isBaseNetwork(network)) return; + if (quote.maximumFundingL1FeeRaw === undefined || quote.maximumPayoutL1FeeRaw === undefined) { + throw new Error("Base destination gas quote is missing its L1 fee envelope"); + } + + const payoutTransactionSize = + BigInt(quote.transferGasLimit) === EVM_NATIVE_TRANSFER_GAS_LIMIT + ? EVM_NATIVE_UNSIGNED_TRANSACTION_SIZE_BYTES + : EVM_ERC20_UNSIGNED_TRANSACTION_SIZE_BYTES; + const [currentFundingL1FeeUpperBound, currentPayoutL1FeeUpperBound] = await Promise.all([ + getBaseL1FeeUpperBoundRaw(network, EVM_NATIVE_UNSIGNED_TRANSACTION_SIZE_BYTES), + getBaseL1FeeUpperBoundRaw(network, payoutTransactionSize) + ]); + if ( + currentFundingL1FeeUpperBound > BigInt(quote.maximumFundingL1FeeRaw) || + currentPayoutL1FeeUpperBound > BigInt(quote.maximumPayoutL1FeeRaw) + ) { + throwNetworkFeesTooHigh(); + } +} + +export async function preflightEvmDestinationFeeWithinQuote(quote: EvmDestinationGasQuote): Promise { + const client = EvmClientManager.getInstance().getClient(quote.network); + const { maxFeePerGas } = await client.estimateFeesPerGas(); + await assertEvmTreasuryFundingFeeWithinQuote(quote, quote.network, maxFeePerGas); +} + +export async function getEvmDestinationExecutionFeeUsd(ctx: PhaseCtx): Promise { + const destinationNetwork = getNetworkFromDestination(ctx.request.to); + if ( + ctx.request.rampType !== RampDirection.BUY || + ctx.priceEvmDestinationGas === false || + !destinationNetwork || + !isNetworkEVM(destinationNetwork) + ) { + return "0"; + } + if (ctx.evmDestinationGas !== undefined) { + return ctx.evmDestinationGas.executionFeeUsd; + } + + const tokenDetails = getOnChainTokenDetails(destinationNetwork, ctx.request.outputCurrency as OnChainToken); + if (!tokenDetails || !isEvmTokenDetails(tokenDetails)) { + throw new Error(`${destinationNetwork} output token ${ctx.request.outputCurrency} is not configured`); + } + + const isNativeTransfer = isNativeEvmToken(tokenDetails); + const destinationClient = EvmClientManager.getInstance().getClient(destinationNetwork); + const chain = destinationClient.chain; + if (!chain) { + throw new Error(`Could not get chain info for EVM destination ${destinationNetwork}`); + } + const { maxFeePerGas } = await destinationClient.estimateFeesPerGas(); + const payoutTransactionSize = isNativeEvmToken(tokenDetails) + ? EVM_NATIVE_UNSIGNED_TRANSACTION_SIZE_BYTES + : EVM_ERC20_UNSIGNED_TRANSACTION_SIZE_BYTES; + const [fundingL1FeeUpperBoundRaw, payoutL1FeeUpperBoundRaw] = await Promise.all([ + getBaseL1FeeUpperBoundRaw(destinationNetwork, EVM_NATIVE_UNSIGNED_TRANSACTION_SIZE_BYTES), + getBaseL1FeeUpperBoundRaw(destinationNetwork, payoutTransactionSize) + ]); + const marginBps = config.evmDestinationGas.networkFeeMarginBps; + const { fundingGasLimit, transferGasLimit } = await getArbitrumExecutionGasLimits( + destinationNetwork, + isNativeTransfer, + marginBps + ); + const maximumFeePerGas = applyMarginBps(maxFeePerGas, marginBps); + const maximumFundingL1FeeRaw = applyMarginBps(fundingL1FeeUpperBoundRaw, marginBps); + const maximumPayoutL1FeeRaw = applyMarginBps(payoutL1FeeUpperBoundRaw, marginBps); + const expectedFeeRaw = calculateExpectedExecutionFeeRaw( + maximumFeePerGas, + fundingGasLimit, + transferGasLimit, + maximumFundingL1FeeRaw + maximumPayoutL1FeeRaw + ); + const expectedFeeUnits = formatUnits(expectedFeeRaw, chain.nativeCurrency.decimals); + const expectedFeeUsd = new Big( + await priceFeedService.convertCurrency( + expectedFeeUnits, + getEvmNativeFeeCurrency(destinationNetwork), + EvmToken.USDC as RampCurrency + ) + ).toFixed(6); + + if (new Big(expectedFeeUsd).gt(config.evmDestinationGas.maxExecutionFeeUsd)) { + throwNetworkFeesTooHigh(); + } + + ctx.evmDestinationGas = { + executionFeeUsd: expectedFeeUsd, + fundingGasLimit: fundingGasLimit.toString(), + isNativeTransfer, + maximumFeePerGas: maximumFeePerGas.toString(), + ...(isBaseNetwork(destinationNetwork) + ? { + maximumFundingL1FeeRaw: maximumFundingL1FeeRaw.toString(), + maximumPayoutL1FeeRaw: maximumPayoutL1FeeRaw.toString() + } + : {}), + network: destinationNetwork, + programVersion: EVM_DESTINATION_FUNDING_PROGRAM_VERSION, + transferGasLimit: transferGasLimit.toString() + }; + ctx.addNote(`${destinationNetwork} destination execution fee: ${expectedFeeUsd} USD`); + return expectedFeeUsd; +} diff --git a/apps/api/src/api/services/phases/blocks/core/evm-transactions.ts b/apps/api/src/api/services/phases/blocks/core/evm-transactions.ts index 743396625..91ae5fb63 100644 --- a/apps/api/src/api/services/phases/blocks/core/evm-transactions.ts +++ b/apps/api/src/api/services/phases/blocks/core/evm-transactions.ts @@ -1,6 +1,7 @@ import { EvmClientManager, type EvmNetworks, type EvmTransactionData } from "@vortexfi/shared"; import { encodeFunctionData } from "viem/utils"; import erc20ABI from "../../../../../contracts/ERC20"; +import { EVM_ERC20_TRANSFER_GAS_LIMIT, EVM_NATIVE_TRANSFER_GAS_LIMIT } from "./evm-destination-gas"; export function encodeEvmTransactionData(data: unknown) { return data; @@ -34,18 +35,19 @@ export async function createDestinationTransferTransaction(params: { toToken: `0x${string}`; amountRaw: string; destinationNetwork: EvmNetworks; + gasLimit?: string; isNativeToken?: boolean; }): Promise { - const { toAddress, amountRaw, destinationNetwork, toToken, isNativeToken } = params; + const { toAddress, amountRaw, destinationNetwork, gasLimit, toToken, isNativeToken } = params; const publicClient = EvmClientManager.getInstance().getClient(destinationNetwork); const { maxFeePerGas, maxPriorityFeePerGas } = await publicClient.estimateFeesPerGas(); if (isNativeToken) { return { data: "0x", - gas: "21000", - maxFeePerGas: String(maxFeePerGas * 3n), - maxPriorityFeePerGas: String(maxPriorityFeePerGas * 3n), + gas: gasLimit ?? EVM_NATIVE_TRANSFER_GAS_LIMIT.toString(), + maxFeePerGas: String(maxFeePerGas), + maxPriorityFeePerGas: String(maxPriorityFeePerGas), to: toAddress as `0x${string}`, value: amountRaw }; @@ -53,9 +55,9 @@ export async function createDestinationTransferTransaction(params: { return { data: encodeFunctionData({ abi: erc20ABI, args: [toAddress, amountRaw], functionName: "transfer" }), - gas: "100000", - maxFeePerGas: String(maxFeePerGas * 3n), - maxPriorityFeePerGas: String(maxPriorityFeePerGas * 3n), + gas: gasLimit ?? EVM_ERC20_TRANSFER_GAS_LIMIT.toString(), + maxFeePerGas: String(maxFeePerGas), + maxPriorityFeePerGas: String(maxPriorityFeePerGas), to: toToken, value: "0" }; diff --git a/apps/api/src/api/services/phases/blocks/core/fees.ts b/apps/api/src/api/services/phases/blocks/core/fees.ts index 83e3be4df..b2a57bcc2 100644 --- a/apps/api/src/api/services/phases/blocks/core/fees.ts +++ b/apps/api/src/api/services/phases/blocks/core/fees.ts @@ -1,6 +1,7 @@ import { EvmToken, RampCurrency } from "@vortexfi/shared"; import Big from "big.js"; import { priceFeedService } from "../../../priceFeed.service"; +import { getEvmDestinationExecutionFeeUsd } from "./evm-destination-gas"; import { calculateFeeComponents } from "./quote-fees"; import type { PhaseCtx } from "./types"; @@ -14,7 +15,8 @@ export async function overrideFees(ctx: PhaseCtx, override: FeeOverride): Promis throw new Error("Cannot override an incomplete fee snapshot"); } const displayCurrency = ctx.fees.displayFiat.currency; - const [anchorUsd, anchorDisplay, networkUsd, networkDisplay] = await Promise.all([ + const destinationExecutionFeeUsd = await getEvmDestinationExecutionFeeUsd(ctx); + const [anchorUsd, anchorDisplay, baseNetworkUsd, baseNetworkDisplay, destinationExecutionFeeDisplay] = await Promise.all([ priceFeedService.convertCurrency(override.anchor.amount, override.anchor.currency, EvmToken.USDC), priceFeedService.convertCurrency(override.anchor.amount, override.anchor.currency, displayCurrency), override.network @@ -22,8 +24,11 @@ export async function overrideFees(ctx: PhaseCtx, override: FeeOverride): Promis : ctx.fees.usd.network, override.network ? priceFeedService.convertCurrency(override.network.amount, override.network.currency, displayCurrency) - : ctx.fees.displayFiat.network + : ctx.fees.displayFiat.network, + override.network ? priceFeedService.convertCurrency(destinationExecutionFeeUsd, EvmToken.USDC, displayCurrency) : "0" ]); + const networkUsd = new Big(baseNetworkUsd).plus(override.network ? destinationExecutionFeeUsd : "0").toString(); + const networkDisplay = new Big(baseNetworkDisplay).plus(destinationExecutionFeeDisplay).toString(); return { displayFiat: { ...ctx.fees.displayFiat, @@ -60,17 +65,30 @@ export async function calculateFees(ctx: PhaseCtx, override?: FeeOverride): Prom const displayCurrency = ctx.targetFeeFiatCurrency ?? feeCurrency; const anchor = override?.anchor ?? { amount: anchorFee, currency: feeCurrency }; const network = override?.network ?? { amount: "0", currency: USD }; - const [vortexUsd, anchorUsd, partnerUsd, networkUsd, vortexDisplay, anchorDisplay, partnerDisplay, networkDisplay] = - await Promise.all([ - priceFeedService.convertCurrency(vortexFee, feeCurrency, USD), - priceFeedService.convertCurrency(anchor.amount, anchor.currency, USD), - priceFeedService.convertCurrency(partnerMarkupFee, feeCurrency, USD), - priceFeedService.convertCurrency(network.amount, network.currency, USD), - priceFeedService.convertCurrency(vortexFee, feeCurrency, displayCurrency), - priceFeedService.convertCurrency(anchor.amount, anchor.currency, displayCurrency), - priceFeedService.convertCurrency(partnerMarkupFee, feeCurrency, displayCurrency), - priceFeedService.convertCurrency(network.amount, network.currency, displayCurrency) - ]); + const destinationExecutionFeeUsd = await getEvmDestinationExecutionFeeUsd(ctx); + const [ + vortexUsd, + anchorUsd, + partnerUsd, + baseNetworkUsd, + vortexDisplay, + anchorDisplay, + partnerDisplay, + baseNetworkDisplay, + destinationExecutionFeeDisplay + ] = await Promise.all([ + priceFeedService.convertCurrency(vortexFee, feeCurrency, USD), + priceFeedService.convertCurrency(anchor.amount, anchor.currency, USD), + priceFeedService.convertCurrency(partnerMarkupFee, feeCurrency, USD), + priceFeedService.convertCurrency(network.amount, network.currency, USD), + priceFeedService.convertCurrency(vortexFee, feeCurrency, displayCurrency), + priceFeedService.convertCurrency(anchor.amount, anchor.currency, displayCurrency), + priceFeedService.convertCurrency(partnerMarkupFee, feeCurrency, displayCurrency), + priceFeedService.convertCurrency(network.amount, network.currency, displayCurrency), + priceFeedService.convertCurrency(destinationExecutionFeeUsd, USD, displayCurrency) + ]); + const networkUsd = new Big(baseNetworkUsd).plus(destinationExecutionFeeUsd).toString(); + const networkDisplay = new Big(baseNetworkDisplay).plus(destinationExecutionFeeDisplay).toString(); const totalUsd = new Big(vortexUsd).plus(anchorUsd).plus(partnerUsd).plus(networkUsd).toFixed(6); const totalDisplay = new Big(vortexDisplay).plus(anchorDisplay).plus(partnerDisplay).plus(networkDisplay).toFixed(2); diff --git a/apps/api/src/api/services/phases/blocks/core/financial-operation.test.ts b/apps/api/src/api/services/phases/blocks/core/financial-operation.test.ts index 88b3dfc92..547b47e8b 100644 --- a/apps/api/src/api/services/phases/blocks/core/financial-operation.test.ts +++ b/apps/api/src/api/services/phases/blocks/core/financial-operation.test.ts @@ -57,6 +57,58 @@ describe("runFinancialOperation", () => { }); }); + it("replays a confirmed target-balance operation when the observed shortfall changes", async () => { + let observedShortfallRaw = "100"; + const perform = mock(async () => ({ amountRaw: observedShortfallRaw, id: "funding-1" })); + const operation = { + ...baseOperation, + attemptClass: "destination-evm-native-funding-v2", + request: { destination: "ephemeral-1", network: "base", targetBalanceRaw: "1000" } + }; + + const first = await runFinancialOperation({ ...operation, perform }); + observedShortfallRaw = "20"; + const replayed = await runFinancialOperation({ ...operation, perform }); + + expect(perform).toHaveBeenCalledTimes(1); + expect(replayed).toEqual(first); + expect(replayed.amountRaw).toBe("100"); + }); + + it("replays a confirmed operation before running a new-side-effect preflight", async () => { + let feesInsideEnvelope = true; + const beforePerform = mock(async () => { + if (!feesInsideEnvelope) throw new Error("network fees too high"); + }); + const perform = mock(async () => ({ id: "funding-1" })); + + const first = await runFinancialOperation({ ...baseOperation, beforePerform, perform }); + feesInsideEnvelope = false; + const replayed = await runFinancialOperation({ ...baseOperation, beforePerform, perform }); + + expect(replayed).toEqual(first); + expect(beforePerform).toHaveBeenCalledTimes(1); + expect(perform).toHaveBeenCalledTimes(1); + }); + + it("leaves an operation unclaimed when its preflight rejects a new side effect", async () => { + let feesInsideEnvelope = false; + const beforePerform = mock(async () => { + if (!feesInsideEnvelope) throw new Error("network fees too high"); + }); + const perform = mock(async () => ({ id: "funding-1" })); + + await expect(runFinancialOperation({ ...baseOperation, beforePerform, perform })).rejects.toThrow( + "network fees too high" + ); + expect(await FinancialOperation.findOne()).toMatchObject({ status: "not_started" }); + + feesInsideEnvelope = true; + await expect(runFinancialOperation({ ...baseOperation, beforePerform, perform })).resolves.toEqual({ id: "funding-1" }); + expect(beforePerform).toHaveBeenCalledTimes(2); + expect(perform).toHaveBeenCalledTimes(1); + }); + it("halts retries after an ambiguous provider failure", async () => { const perform = mock(async () => { throw new Error("connection reset after submission"); diff --git a/apps/api/src/api/services/phases/blocks/core/financial-operation.ts b/apps/api/src/api/services/phases/blocks/core/financial-operation.ts index 7b67f6bed..ef4f8647a 100644 --- a/apps/api/src/api/services/phases/blocks/core/financial-operation.ts +++ b/apps/api/src/api/services/phases/blocks/core/financial-operation.ts @@ -16,6 +16,8 @@ export interface RunFinancialOperationArgs { request: unknown; retryFailed?: boolean; signal?: AbortSignal; + /** Runs only after replay/reconciliation is exhausted and immediately before claiming a new side effect. */ + beforePerform?(): Promise; perform(idempotencyKey: string): Promise; reconcile?: (operation: FinancialOperation) => Promise; externalId?: (result: Result) => string | undefined; @@ -76,6 +78,7 @@ export async function runFinancialOperation({ attemptClass, provider, request, + beforePerform, perform, reconcile, externalId, @@ -153,6 +156,8 @@ export async function runFinancialOperation({ } } + await beforePerform?.(); + const [claimed] = await FinancialOperation.update( { errorMessage: null, status: "submitted" }, { where: { id: operation.id, status: "not_started" } } diff --git a/apps/api/src/api/services/phases/blocks/core/flow.ts b/apps/api/src/api/services/phases/blocks/core/flow.ts index 7e3c3404f..6f6fb11fa 100644 --- a/apps/api/src/api/services/phases/blocks/core/flow.ts +++ b/apps/api/src/api/services/phases/blocks/core/flow.ts @@ -1,4 +1,5 @@ import { EphemeralAccountType, type RampPhase } from "@vortexfi/shared"; +import { config } from "../../../../../config/vars"; import type { PhaseHandler } from "../../../phases/base-phase-handler"; import type { StateMetadata } from "../../../phases/meta-state-types"; import { computeFees } from "./fees"; @@ -296,6 +297,10 @@ export class FlowBuilder { }; }, async simulate(ctx: PhaseCtx) { + // Exact provider-token payouts do not have a fee-distribution phase; + // their same-chain gas remains part of the existing source reserve. + ctx.priceEvmDestinationGas = + config.evmDestinationGas.dynamicFundingEnabled && staticStateMeta.isDirectTransfer !== true; await computeFees(ctx); if (!ctx.fees?.usd) { throw new Error("Flow simulation requires computed USD fees"); @@ -319,7 +324,12 @@ export class FlowBuilder { metadata: { blocks, flow: identity, - globals: { fees: ctx.fees as never, partner: ctx.partner, request: ctx.request } + globals: { + ...(ctx.evmDestinationGas ? { evmDestinationGas: ctx.evmDestinationGas } : {}), + fees: ctx.fees as never, + partner: ctx.partner, + request: ctx.request + } }, output: current as O }; diff --git a/apps/api/src/api/services/phases/blocks/core/metadata.test.ts b/apps/api/src/api/services/phases/blocks/core/metadata.test.ts new file mode 100644 index 000000000..6c0d9234f --- /dev/null +++ b/apps/api/src/api/services/phases/blocks/core/metadata.test.ts @@ -0,0 +1,82 @@ +import { describe, expect, it } from "bun:test"; +import { Networks } from "@vortexfi/shared"; +import type { EvmDestinationGasQuote } from "./metadata"; +import { getFlowMetadata } from "./metadata"; + +const validQuote: EvmDestinationGasQuote = { + executionFeeUsd: "0.20", + fundingGasLimit: "21000", + isNativeTransfer: false, + maximumFeePerGas: "1200000000", + network: Networks.Arbitrum, + programVersion: 2, + transferGasLimit: "100000" +}; + +function metadata(evmDestinationGas?: unknown): unknown { + return { + blocks: {}, + globals: { + ...(evmDestinationGas === undefined ? {} : { evmDestinationGas }), + fees: { usd: { anchor: "0", network: "0", partnerMarkup: "0", total: "0", vortex: "0" } }, + partner: null, + request: {} + } + }; +} + +describe("getFlowMetadata EVM destination gas validation", () => { + it("accepts absence as the legacy funding program", () => { + expect(getFlowMetadata(metadata()).globals.evmDestinationGas).toBeUndefined(); + }); + + it("accepts a complete v2 envelope including required Base L1 maxima", () => { + const baseQuote: EvmDestinationGasQuote = { + ...validQuote, + maximumFundingL1FeeRaw: "12000000000000", + maximumPayoutL1FeeRaw: "13000000000000", + network: Networks.Base + }; + + expect(getFlowMetadata(metadata(baseQuote)).globals.evmDestinationGas).toEqual(baseQuote); + }); + + it("rejects malformed or unbounded v2 fields before they reach treasury arithmetic", () => { + const invalidQuotes: unknown[] = [ + { ...validQuote, executionFeeUsd: "0" }, + { ...validQuote, fundingGasLimit: "1e5" }, + { ...validQuote, isNativeTransfer: "false" }, + { ...validQuote, maximumFeePerGas: (2n ** 256n).toString() }, + { ...validQuote, network: "not-a-network" }, + { ...validQuote, programVersion: 3 }, + { ...validQuote, transferGasLimit: "0" } + ]; + + for (const quote of invalidQuotes) { + expect(() => getFlowMetadata(metadata(quote))).toThrow("EVM destination"); + } + }); + + it("requires a paired positive L1 envelope on Base-family quotes", () => { + expect(() => getFlowMetadata(metadata({ ...validQuote, network: Networks.Base }))).toThrow("L1 fee envelope"); + expect(() => + getFlowMetadata( + metadata({ + ...validQuote, + maximumFundingL1FeeRaw: "1", + network: Networks.Base + }) + ) + ).toThrow("L1 fee envelope"); + expect(() => + getFlowMetadata( + metadata({ + ...validQuote, + maximumFundingL1FeeRaw: "0", + maximumPayoutL1FeeRaw: "1", + network: Networks.BaseSepolia + }) + ) + ).toThrow("maximumFundingL1FeeRaw"); + }); +}); diff --git a/apps/api/src/api/services/phases/blocks/core/metadata.ts b/apps/api/src/api/services/phases/blocks/core/metadata.ts index 8125ca687..dd5ad1a8c 100644 --- a/apps/api/src/api/services/phases/blocks/core/metadata.ts +++ b/apps/api/src/api/services/phases/blocks/core/metadata.ts @@ -1,5 +1,12 @@ -import type { CreateQuoteRequest, QuoteFeeStructure, RampCurrency } from "@vortexfi/shared"; -import type { Big } from "big.js"; +import { + type CreateQuoteRequest, + type EvmNetworks, + isNetworkEVM, + Networks, + type QuoteFeeStructure, + type RampCurrency +} from "@vortexfi/shared"; +import Big from "big.js"; import type { StateMetadata } from "../../../phases/meta-state-types"; import type { PartnerInfo } from "../../../quote/core/types"; import type { FlowIdentity } from "./identity"; @@ -23,7 +30,20 @@ export function defineContext() { ({ key, schemaVersion }) as ContextMetadata; } +export interface EvmDestinationGasQuote { + executionFeeUsd: string; + fundingGasLimit: string; + isNativeTransfer: boolean; + maximumFeePerGas: string; + maximumFundingL1FeeRaw?: string; + maximumPayoutL1FeeRaw?: string; + network: EvmNetworks; + programVersion: 2; + transferGasLimit: string; +} + export interface FlowGlobals { + evmDestinationGas?: EvmDestinationGasQuote; fees: { displayFiat?: QuoteFeeStructure; usd: { anchor: string; network: string; partnerMarkup: string; total: string; vortex: string }; @@ -40,6 +60,68 @@ export interface FlowMetadata = Record, + field: keyof EvmDestinationGasQuote, + maximum: bigint +): void { + const fieldValue = value[field]; + if ( + typeof fieldValue !== "string" || + !POSITIVE_INTEGER_PATTERN.test(fieldValue) || + fieldValue.length > maximum.toString().length || + BigInt(fieldValue) > maximum + ) { + throw new Error(`Invalid EVM destination gas quote ${field}`); + } +} + +function assertEvmDestinationGasQuote(value: unknown): asserts value is EvmDestinationGasQuote { + if (!isRecord(value)) { + throw new Error("Invalid EVM destination gas quote envelope"); + } + if (value.programVersion !== 2) { + throw new Error(`Unsupported EVM destination funding program ${String(value.programVersion)}`); + } + if (typeof value.network !== "string" || !isNetworkEVM(value.network as Networks)) { + throw new Error("Invalid EVM destination gas quote network"); + } + if (typeof value.isNativeTransfer !== "boolean") { + throw new Error("Invalid EVM destination gas quote transfer type"); + } + if ( + typeof value.executionFeeUsd !== "string" || + value.executionFeeUsd.length > 128 || + !POSITIVE_DECIMAL_PATTERN.test(value.executionFeeUsd) || + !new Big(value.executionFeeUsd).gt(0) + ) { + throw new Error("Invalid EVM destination gas quote executionFeeUsd"); + } + + assertPositiveIntegerField(value, "fundingGasLimit", MAX_UINT64); + assertPositiveIntegerField(value, "transferGasLimit", MAX_UINT64); + assertPositiveIntegerField(value, "maximumFeePerGas", MAX_UINT256); + + const hasFundingL1Maximum = value.maximumFundingL1FeeRaw !== undefined; + const hasPayoutL1Maximum = value.maximumPayoutL1FeeRaw !== undefined; + if (hasFundingL1Maximum !== hasPayoutL1Maximum) { + throw new Error("Incomplete EVM destination gas quote L1 fee envelope"); + } + const isBase = value.network === Networks.Base || value.network === Networks.BaseSepolia; + if (isBase && !hasFundingL1Maximum) { + throw new Error("Base destination gas quote is missing its L1 fee envelope"); + } + if (hasFundingL1Maximum) { + assertPositiveIntegerField(value, "maximumFundingL1FeeRaw", MAX_UINT256); + assertPositiveIntegerField(value, "maximumPayoutL1FeeRaw", MAX_UINT256); + } +} + export function getFlowMetadata(metadata: unknown): FlowMetadata { const value = metadata as Partial | null; if ( @@ -52,6 +134,9 @@ export function getFlowMetadata(metadata: unknown): FlowMetadata { ) { throw new Error("Quote does not contain block flow metadata"); } + if (value.globals.evmDestinationGas !== undefined) { + assertEvmDestinationGasQuote(value.globals.evmDestinationGas); + } return value as FlowMetadata; } diff --git a/apps/api/src/api/services/phases/blocks/core/types.ts b/apps/api/src/api/services/phases/blocks/core/types.ts index 06ed4e715..56b6a31a0 100644 --- a/apps/api/src/api/services/phases/blocks/core/types.ts +++ b/apps/api/src/api/services/phases/blocks/core/types.ts @@ -41,6 +41,8 @@ export interface PhaseCtx { vortexFeePenPercentage?: number; }; targetFeeFiatCurrency?: RampCurrency; + evmDestinationGas?: FlowMetadata["globals"]["evmDestinationGas"]; + priceEvmDestinationGas?: boolean; } export type FlowInputResolver = (ctx: PhaseCtx) => O | Promise; diff --git a/apps/api/src/api/services/phases/blocks/phases/destination-transfer/transactions.ts b/apps/api/src/api/services/phases/blocks/phases/destination-transfer/transactions.ts index 765782256..f7c581951 100644 --- a/apps/api/src/api/services/phases/blocks/phases/destination-transfer/transactions.ts +++ b/apps/api/src/api/services/phases/blocks/phases/destination-transfer/transactions.ts @@ -5,9 +5,11 @@ import { isEvmTokenDetails, isNativeEvmToken, Networks, - OnChainToken + OnChainToken, + RampDirection } from "@vortexfi/shared"; import { requireAccount } from "../../core/accounts"; +import { assertPreparedEvmDestinationFeeWithinQuote } from "../../core/evm-destination-gas"; import { createDestinationTransferTransaction } from "../../core/evm-transactions"; import type { PrepareCtx, PreparedPhaseTxs } from "../../core/types"; import type { DestinationTransferMetadata } from "./simulation"; @@ -31,10 +33,21 @@ export async function prepareDestinationTransferTxs(ctx: PrepareCtx + transaction.phase === "destinationTransfer" && + transaction.network === destinationNetwork && + transaction.nonce === presignedTransfer.nonce && + transaction.signer.toLowerCase() === presignedTransfer.signer.toLowerCase() + ); + if (!unsignedTransfer || !isEvmTransactionData(unsignedTransfer.txData)) { + throw this.createUnrecoverableError( + `FinalSettlementSubsidyExecutor: missing ${destinationNetwork} destination transfer blueprint` + ); + } + destinationGasReserveRaw = new Big( + ( + await calculateQuotedPresignedExecutionBudgetRaw(presignedTransfer, unsignedTransfer, destinationGasQuote) + ).toString() + ); + } else { + destinationGasReserveRaw = multiplyByPowerOfTen( + LEGACY_DESTINATION_EVM_FUNDING_AMOUNTS[destinationNetwork], + outTokenDetails.decimals + ); + } + } const requiredBalanceRaw = expectedAmountRaw.plus(destinationGasReserveRaw); const subsidyAmountRaw = calculateSettlementSubsidyRaw( expectedAmountRaw, diff --git a/apps/api/src/api/services/phases/blocks/phases/fund-ephemeral/execution.ts b/apps/api/src/api/services/phases/blocks/phases/fund-ephemeral/execution.ts index 705dddf6a..d5f6f0535 100644 --- a/apps/api/src/api/services/phases/blocks/phases/fund-ephemeral/execution.ts +++ b/apps/api/src/api/services/phases/blocks/phases/fund-ephemeral/execution.ts @@ -5,9 +5,10 @@ import { FiatToken, getNetworkFromDestination, isAlfredpayToken, - isNetworkEVM, + isEvmTransactionData, multiplyByPowerOfTen, Networks, + QuoteError, RampDirection, RampPhase, waitUntilTrueWithTimeout @@ -16,23 +17,34 @@ import logger from "../../../../../../config/logger"; import { config } from "../../../../../../config/vars"; import { BASE_EPHEMERAL_STARTING_BALANCE_UNITS, + MOONBEAM_EVM_SOURCE_STARTING_BALANCE_UNITS, POLYGON_EPHEMERAL_STARTING_BALANCE_UNITS } from "../../../../../../constants/constants"; import QuoteTicket from "../../../../../../models/quoteTicket.model"; import RampState from "../../../../../../models/rampState.model"; +import { APIError } from "../../../../../errors/api-error"; import { PhaseError } from "../../../../../errors/phase-error"; import { fundEphemeralAccount } from "../../../../pendulum/pendulum.service"; import { BasePhaseHandler } from "../../../../phases/base-phase-handler"; import { verifyUserSubmittedTxByHash } from "../../../../phases/helpers/user-tx-verifier"; import { StateMetadata } from "../../../../phases/meta-state-types"; +import { PresignedEvmTransactionRebindError } from "../../../../transactions/validation"; import { abortableCall, throwIfAborted } from "../../core/cancellation"; import { - DESTINATION_EVM_FUNDING_AMOUNTS, + calculateDestinationFundingShortfallRaw, + calculateSourceEvmFundingRequirementRaw, + getDynamicDestinationEvmFundingNetwork, isDestinationEvmEphemeralFunded, - isPendulumEphemeralFunded + isPendulumEphemeralFunded, + LEGACY_DESTINATION_EVM_FUNDING_AMOUNTS } from "../../core/destination-funding"; +import { + assertEvmTreasuryFundingFeeWithinQuote, + calculateQuotedPresignedExecutionBudgetRaw, + EVM_DESTINATION_FUNDING_PROGRAM_VERSION +} from "../../core/evm-destination-gas"; import { getEvmFundingAccount } from "../../core/evm-funding"; -import { getBlockMetadata, getBlockState, getFlowMetadata } from "../../core/metadata"; +import { type EvmDestinationGasQuote, getBlockMetadata, getBlockState, getFlowMetadata } from "../../core/metadata"; import { getNativePrefunding } from "../../core/prepare"; import { AssethubOfframpSourceContext, type AssethubOfframpSourceRegistrationFacts } from "../assethub-offramp-source"; import { EvmOfframpSourceContext, EvmOfframpSourceMetadata } from "../evm-offramp-source/simulation"; @@ -48,7 +60,12 @@ export class FundEphemeralExecutor extends BasePhaseHandler { if (!quote) { throw new Error("Quote not found for the given state"); } - const blocks = getFlowMetadata(quote.metadata).blocks; + const flowMetadata = getFlowMetadata(quote.metadata); + const blocks = flowMetadata.blocks; + const destinationGasQuote = flowMetadata.globals.evmDestinationGas; + if (destinationGasQuote && destinationGasQuote.programVersion !== EVM_DESTINATION_FUNDING_PROGRAM_VERSION) { + throw new Error(`Unsupported EVM destination funding program ${String(destinationGasQuote.programVersion)}`); + } if (blocks[AssethubOfframpSourceContext.key]) { await this.verifyAssethubSourceTransaction(state); const substrateAddress = state.state.substrateEphemeralAddress; @@ -90,11 +107,37 @@ export class FundEphemeralExecutor extends BasePhaseHandler { sourceNetwork === Networks.Polygon ? POLYGON_EPHEMERAL_STARTING_BALANCE_UNITS : sourceNetwork === Networks.Moonbeam - ? DESTINATION_EVM_FUNDING_AMOUNTS[Networks.Moonbeam] + ? destinationGasQuote + ? MOONBEAM_EVM_SOURCE_STARTING_BALANCE_UNITS + : LEGACY_DESTINATION_EVM_FUNDING_AMOUNTS[Networks.Moonbeam] : BASE_EPHEMERAL_STARTING_BALANCE_UNITS; const fixedFundingRaw = BigInt(multiplyByPowerOfTen(fixedFundingUnits, chain.nativeCurrency.decimals).toFixed()); const plannedNativeValueRaw = getNativePrefunding(state.state.transactionPlan, sourceNetwork, evmEphemeralAddress); - const requiredFundingRaw = fixedFundingRaw + plannedNativeValueRaw; + const destinationNetwork = getNetworkFromDestination(state.to); + const dynamicDestinationNetwork = destinationGasQuote + ? getDynamicDestinationEvmFundingNetwork( + destinationNetwork, + state.type === RampDirection.BUY, + state.state.isDirectTransfer + ) + : undefined; + let destinationFundingRaw = 0n; + if (dynamicDestinationNetwork) { + if (!destinationGasQuote) { + throw new Error(`FundEphemeralExecutor: missing ${dynamicDestinationNetwork} destination gas quote`); + } + destinationFundingRaw = await this.getDestinationEvmFundingRequirementRaw( + state, + dynamicDestinationNetwork, + destinationGasQuote + ); + } + const sameNetworkDestinationLiabilityRaw = dynamicDestinationNetwork === sourceNetwork ? destinationFundingRaw : 0n; + const requiredFundingRaw = calculateSourceEvmFundingRequirementRaw( + fixedFundingRaw, + plannedNativeValueRaw, + sameNetworkDestinationLiabilityRaw + ); const currentBalanceRaw = await sourceClient.getBalance({ address: evmEphemeralAddress as `0x${string}` }); if (currentBalanceRaw < requiredFundingRaw) { @@ -104,25 +147,55 @@ export class FundEphemeralExecutor extends BasePhaseHandler { sourceNetwork, requiredFundingRaw - currentBalanceRaw, requiredFundingRaw, + dynamicDestinationNetwork === sourceNetwork ? destinationGasQuote : undefined, signal ); } else { logger.info(`${sourceNetwork} ephemeral address already funded.`); } - const destinationNetwork = getNetworkFromDestination(state.to); - if ( - state.type === RampDirection.BUY && - state.to !== Networks.AssetHub && - destinationNetwork && - isNetworkEVM(destinationNetwork) - ) { - const isFunded = await isDestinationEvmEphemeralFunded(evmEphemeralAddress, destinationNetwork); + if (dynamicDestinationNetwork && dynamicDestinationNetwork !== sourceNetwork) { + const isFunded = await isDestinationEvmEphemeralFunded( + evmEphemeralAddress, + dynamicDestinationNetwork, + destinationFundingRaw + ); if (!isFunded) { - logger.info(`Funding EVM ephemeral account ${evmEphemeralAddress} on ${destinationNetwork}`); - await this.fundDestinationEvmEphemeralAccount(state, destinationNetwork, signal); + logger.info(`Funding EVM ephemeral account ${evmEphemeralAddress} on ${dynamicDestinationNetwork}`); + await this.fundDestinationEvmEphemeralAccount( + state, + dynamicDestinationNetwork, + destinationFundingRaw, + destinationGasQuote, + signal + ); } else { - logger.info(`EVM ephemeral account already funded on ${destinationNetwork}.`); + logger.info(`EVM ephemeral account already funded on ${dynamicDestinationNetwork}.`); + } + } + + const legacyDestinationNetwork = destinationGasQuote + ? undefined + : getDynamicDestinationEvmFundingNetwork( + destinationNetwork, + state.type === RampDirection.BUY, + state.state.isDirectTransfer + ); + if (legacyDestinationNetwork) { + const legacyClient = EvmClientManager.getInstance().getClient(legacyDestinationNetwork); + const legacyChain = legacyClient.chain; + if (!legacyChain) { + throw new Error(`FundEphemeralExecutor: Could not get chain info for ${legacyDestinationNetwork}`); + } + const legacyRequiredRaw = BigInt( + multiplyByPowerOfTen( + LEGACY_DESTINATION_EVM_FUNDING_AMOUNTS[legacyDestinationNetwork], + legacyChain.nativeCurrency.decimals + ).toFixed() + ); + if (!(await isDestinationEvmEphemeralFunded(evmEphemeralAddress, legacyDestinationNetwork, legacyRequiredRaw))) { + logger.info(`Legacy-funding EVM ephemeral account ${evmEphemeralAddress} on ${legacyDestinationNetwork}`); + await this.fundLegacyDestinationEvmEphemeralAccount(state, legacyDestinationNetwork, legacyRequiredRaw, signal); } } } catch (e) { @@ -132,6 +205,14 @@ export class FundEphemeralExecutor extends BasePhaseHandler { throw e; } + if (e instanceof APIError && e.message === QuoteError.NetworkFeesTooHigh) { + throw this.createRecoverableError(QuoteError.NetworkFeesTooHigh); + } + + if (e instanceof PresignedEvmTransactionRebindError) { + throw this.createUnrecoverableError(e.message); + } + throw this.createRecoverableError("Error funding ephemeral account"); } @@ -206,6 +287,7 @@ export class FundEphemeralExecutor extends BasePhaseHandler { network: EvmNetworks, fundingAmountRaw: bigint, requiredFundingRaw: bigint, + destinationGasQuote?: EvmDestinationGasQuote, signal?: AbortSignal ): Promise { try { @@ -221,14 +303,33 @@ export class FundEphemeralExecutor extends BasePhaseHandler { const fundingAccount = getEvmFundingAccount(network); const walletClient = evmClientManager.getWalletClient(network, fundingAccount); + let checkedFees: { maxFeePerGas: bigint; maxPriorityFeePerGas: bigint } | undefined; await this.runFinancialOperation(state, { - attemptClass: "source-evm-native-funding", + attemptClass: destinationGasQuote ? "source-evm-native-funding-v2" : "source-evm-native-funding", + beforePerform: destinationGasQuote + ? async () => { + const fees = await networkClient.estimateFeesPerGas(); + await assertEvmTreasuryFundingFeeWithinQuote(destinationGasQuote, network, fees.maxFeePerGas); + checkedFees = fees; + } + : undefined, externalId: result => result.hash, perform: async () => { throwIfAborted(signal); + const fees = checkedFees; + if (destinationGasQuote && !fees) { + throw new Error(`FundEphemeralExecutor: missing checked ${network} funding fees`); + } const hash = await abortableCall(signal, () => walletClient.sendTransaction({ + ...(fees && destinationGasQuote + ? { + gas: BigInt(destinationGasQuote.fundingGasLimit), + maxFeePerGas: fees.maxFeePerGas, + maxPriorityFeePerGas: fees.maxPriorityFeePerGas + } + : {}), to: ephemeralAddress as `0x${string}`, value: fundingAmountRaw }) @@ -245,7 +346,9 @@ export class FundEphemeralExecutor extends BasePhaseHandler { }, provider: network, request: { - amountRaw: fundingAmountRaw.toString(), + ...(destinationGasQuote + ? { targetBalanceRaw: requiredFundingRaw.toString() } + : { amountRaw: fundingAmountRaw.toString() }), destination: ephemeralAddress, network, source: fundingAccount.address @@ -270,7 +373,7 @@ export class FundEphemeralExecutor extends BasePhaseHandler { } } catch (error) { logger.error(`FundEphemeralExecutor: Error during funding ${network} ephemeral:`, error); - if (error instanceof PhaseError) throw error; + if (error instanceof PhaseError || error instanceof APIError) throw error; throw new Error(`FundEphemeralExecutor: Error during funding ${network} ephemeral: ` + error); } } @@ -278,6 +381,8 @@ export class FundEphemeralExecutor extends BasePhaseHandler { protected async fundDestinationEvmEphemeralAccount( state: RampState, destinationNetwork: EvmNetworks, + requiredFundingRaw: bigint, + destinationGasQuote: EvmDestinationGasQuote | undefined, signal?: AbortSignal ): Promise { try { @@ -290,21 +395,40 @@ export class FundEphemeralExecutor extends BasePhaseHandler { } const ephemeralAddress = state.state.evmEphemeralAddress; - const fundingAmountUnits = DESTINATION_EVM_FUNDING_AMOUNTS[destinationNetwork]; - const fundingAmountRaw = multiplyByPowerOfTen(fundingAmountUnits, chain.nativeCurrency.decimals).toFixed(); + const currentBalanceRaw = await destinationClient.getBalance({ address: ephemeralAddress as `0x${string}` }); + const fundingAmountRaw = calculateDestinationFundingShortfallRaw(requiredFundingRaw, currentBalanceRaw); + if (fundingAmountRaw === 0n) { + return; + } const fundingAccount = getEvmFundingAccount(destinationNetwork); const walletClient = evmClientManager.getWalletClient(destinationNetwork, fundingAccount); + if (!destinationGasQuote) { + throw new Error(`FundEphemeralExecutor: missing ${destinationNetwork} destination gas quote`); + } + let checkedFees: { maxFeePerGas: bigint; maxPriorityFeePerGas: bigint } | undefined; await this.runFinancialOperation(state, { - attemptClass: "destination-evm-native-funding", + attemptClass: "destination-evm-native-funding-v2", + beforePerform: async () => { + const fees = await destinationClient.estimateFeesPerGas(); + await assertEvmTreasuryFundingFeeWithinQuote(destinationGasQuote, destinationNetwork, fees.maxFeePerGas); + checkedFees = fees; + }, externalId: result => result.hash, perform: async () => { throwIfAborted(signal); + const fees = checkedFees; + if (!fees) { + throw new Error(`FundEphemeralExecutor: missing checked ${destinationNetwork} funding fees`); + } const hash = await abortableCall(signal, () => walletClient.sendTransaction({ + gas: BigInt(destinationGasQuote.fundingGasLimit), + maxFeePerGas: fees.maxFeePerGas, + maxPriorityFeePerGas: fees.maxPriorityFeePerGas, to: ephemeralAddress as `0x${string}`, - value: BigInt(fundingAmountRaw) + value: fundingAmountRaw }) ); const receipt = await abortableCall(signal, () => @@ -319,17 +443,17 @@ export class FundEphemeralExecutor extends BasePhaseHandler { }, provider: destinationNetwork, request: { - amountRaw: fundingAmountRaw, destination: ephemeralAddress, network: destinationNetwork, - source: fundingAccount.address + source: fundingAccount.address, + targetBalanceRaw: requiredFundingRaw.toString() }, signal }); try { await waitUntilTrueWithTimeout( - () => isDestinationEvmEphemeralFunded(ephemeralAddress, destinationNetwork), + () => isDestinationEvmEphemeralFunded(ephemeralAddress, destinationNetwork, requiredFundingRaw), 1000, 30000, signal @@ -341,11 +465,82 @@ export class FundEphemeralExecutor extends BasePhaseHandler { } } catch (error) { logger.error(`FundEphemeralExecutor: Error during funding ${destinationNetwork} ephemeral:`, error); - if (error instanceof PhaseError) throw error; + if (error instanceof PhaseError || error instanceof APIError) throw error; throw new Error(`FundEphemeralExecutor: Error during funding ${destinationNetwork} ephemeral: ` + error); } } + private async getDestinationEvmFundingRequirementRaw( + state: RampState, + destinationNetwork: EvmNetworks, + destinationGasQuote: EvmDestinationGasQuote + ): Promise { + const presignedTransfer = this.getPresignedTransaction(state, "destinationTransfer"); + if (!presignedTransfer?.txData || presignedTransfer.network !== destinationNetwork) { + throw new Error(`FundEphemeralExecutor: missing ${destinationNetwork} destination transfer`); + } + const unsignedTransfer = state.unsignedTxs.find( + transaction => + transaction.phase === "destinationTransfer" && + transaction.network === destinationNetwork && + transaction.nonce === presignedTransfer.nonce && + transaction.signer.toLowerCase() === presignedTransfer.signer.toLowerCase() + ); + if (!unsignedTransfer || !isEvmTransactionData(unsignedTransfer.txData)) { + throw new Error(`FundEphemeralExecutor: missing ${destinationNetwork} destination transfer blueprint`); + } + return calculateQuotedPresignedExecutionBudgetRaw(presignedTransfer, unsignedTransfer, destinationGasQuote); + } + + private async fundLegacyDestinationEvmEphemeralAccount( + state: RampState, + destinationNetwork: EvmNetworks, + requiredFundingRaw: bigint, + signal?: AbortSignal + ): Promise { + const evmClientManager = EvmClientManager.getInstance(); + const destinationClient = evmClientManager.getClient(destinationNetwork); + const ephemeralAddress = state.state.evmEphemeralAddress as `0x${string}`; + const currentBalanceRaw = await destinationClient.getBalance({ address: ephemeralAddress }); + const fundingAmountRaw = calculateDestinationFundingShortfallRaw(requiredFundingRaw, currentBalanceRaw); + if (fundingAmountRaw === 0n) return; + + const fundingAccount = getEvmFundingAccount(destinationNetwork); + const walletClient = evmClientManager.getWalletClient(destinationNetwork, fundingAccount); + await this.runFinancialOperation(state, { + attemptClass: "destination-evm-native-funding", + externalId: result => result.hash, + perform: async () => { + throwIfAborted(signal); + const hash = await abortableCall(signal, () => + walletClient.sendTransaction({ to: ephemeralAddress, value: fundingAmountRaw }) + ); + const receipt = await abortableCall(signal, () => + destinationClient.waitForTransactionReceipt({ hash: hash as `0x${string}` }) + ); + if (!receipt || receipt.status !== "success") { + throw new Error(`FundEphemeralExecutor: Transaction ${hash} failed or was not found on ${destinationNetwork}`); + } + return { hash }; + }, + provider: destinationNetwork, + request: { + amountRaw: fundingAmountRaw.toString(), + destination: ephemeralAddress, + network: destinationNetwork, + source: fundingAccount.address + }, + signal + }); + + await waitUntilTrueWithTimeout( + () => isDestinationEvmEphemeralFunded(ephemeralAddress, destinationNetwork, requiredFundingRaw), + 1000, + 30000, + signal + ); + } + private async fundSubstrateEphemeralAccount( state: RampState, substrateAddress: string, diff --git a/apps/api/src/api/services/phases/blocks/phases/subsidize-pre/simulation.ts b/apps/api/src/api/services/phases/blocks/phases/subsidize-pre/simulation.ts index eb361f4d5..4527d269c 100644 --- a/apps/api/src/api/services/phases/blocks/phases/subsidize-pre/simulation.ts +++ b/apps/api/src/api/services/phases/blocks/phases/subsidize-pre/simulation.ts @@ -165,7 +165,7 @@ export async function simulateAlfredpaySubsidizePre 0 && failures.every(failure => isLowLiquidityQuoteError(failure.error))) { throw createLowLiquidityQuoteError(); } + if (failures.length > 0 && failures.every(failure => isNetworkFeesTooHighError(failure.error))) { + throw new APIError({ message: QuoteError.NetworkFeesTooHigh, status: httpStatus.SERVICE_UNAVAILABLE }); + } throw new APIError({ message: QuoteError.FailedToCalculateQuote, @@ -224,6 +235,10 @@ export class QuoteService extends BaseRampService { throw error; } + if (isNetworkFeesTooHighError(error)) { + throw error; + } + if (isLowLiquidityQuoteError(error)) { throw createLowLiquidityQuoteError(); } diff --git a/apps/api/src/api/services/ramp/ramp.service.ts b/apps/api/src/api/services/ramp/ramp.service.ts index 46aa26edd..e1080c672 100644 --- a/apps/api/src/api/services/ramp/ramp.service.ts +++ b/apps/api/src/api/services/ramp/ramp.service.ts @@ -44,6 +44,7 @@ import { } from "../../services/phases/blocks/core/discount"; import { getTargetFiatCurrency } from "../../services/phases/blocks/core/helpers"; import { accountCapabilities } from "../phases/blocks/core/accounts"; +import { preflightEvmDestinationFeeWithinQuote } from "../phases/blocks/core/evm-destination-gas"; import { getFlowMetadata } from "../phases/blocks/core/metadata"; import { resolvePersistedBlockFlow } from "../phases/blocks/flows/catalog"; import { StateMetadata } from "../phases/meta-state-types"; @@ -936,6 +937,11 @@ export class RampService extends BaseRampService { const metadata = getFlowMetadata(quote.metadata); const flow = resolvePersistedBlockFlow(metadata); const quoteFields = quote.get({ plain: true }); + if (metadata.globals.evmDestinationGas) { + // Run the same persisted-envelope guard before provider registration can + // create an independently durable ticket. prepareTxs keeps its exact check. + await preflightEvmDestinationFeeWithinQuote(metadata.globals.evmDestinationGas); + } const registered = await flow.register({ authenticatedUser: { id: userId }, input: additionalData ?? {}, diff --git a/apps/api/src/api/services/transactions/validation.test.ts b/apps/api/src/api/services/transactions/validation.test.ts index cb892db89..c3749a421 100644 --- a/apps/api/src/api/services/transactions/validation.test.ts +++ b/apps/api/src/api/services/transactions/validation.test.ts @@ -5,6 +5,7 @@ import { EvmTransactionData, Networks, NUMBER_OF_PRESIGNED_TXS, + PRESIGNED_EVM_FEE_MULTIPLIER, PresignedTx, RampDirection, SignedTypedData @@ -88,8 +89,7 @@ async function makeSignedEvmTxWithBackups(overrides: { } // Helper for legacy (type 0) EVM transactions which use `gasPrice` and omit -// maxFeePerGas / maxPriorityFeePerGas entirely. Used to test the zero-minimum branch -// of assertSignedEvmMinimum, since some chains/SDKs sign legacy-style. +// maxFeePerGas / maxPriorityFeePerGas entirely. async function makeLegacySignedEvmTxWithBackups(overrides: { nonce: number; phase: PresignedTx["phase"]; @@ -792,7 +792,7 @@ describe("Presigned Transaction validation", () => { ).rejects.toThrow("maxPriorityFeePerGas"); }); - it("accepts legacy signed EVM tx without maxPriorityFeePerGas when server unsigned minimum is 0", async () => { + it("rejects a nonzero legacy gas price when the server-issued fee envelope is zero", async () => { const unsignedTxData: EvmTransactionData = { data: "0x12345678", gas: "21000", @@ -816,12 +816,104 @@ describe("Presigned Transaction validation", () => { network: Networks.Polygon }); + await expect( + validatePresignedTxs(RampDirection.BUY, [presignedTx], { Substrate: "", EVM: EVM_SIGNER }, [unsignedTx]) + ).rejects.toThrow("exceeds expected maximum 0"); + }); + + it("accepts the production signing multiplier while keeping the server gas limit", async () => { + const unsignedTxData: EvmTransactionData = { + data: "0x12345678", + gas: "21000", + maxFeePerGas: "1000000000", + maxPriorityFeePerGas: "500000000", + to: "0x000000000000000000000000000000000000dEaD", + value: "0" + }; + const unsignedTx: PresignedTx = { + meta: {}, + network: Networks.Polygon, + nonce: 5, + phase: "fundEphemeral", + signer: EVM_SIGNER, + txData: unsignedTxData + }; + const presignedTx = await makeSignedEvmTxWithBackups({ + gasLimit: 21000n, + maxFeePerGas: 1000000000n * PRESIGNED_EVM_FEE_MULTIPLIER, + maxPriorityFeePerGas: 500000000n * PRESIGNED_EVM_FEE_MULTIPLIER, + nonce: 5, + phase: "fundEphemeral", + network: Networks.Polygon + }); + await expect( validatePresignedTxs(RampDirection.BUY, [presignedTx], { Substrate: "", EVM: EVM_SIGNER }, [unsignedTx]) ).resolves.toBeUndefined(); }); - it("accepts signed EVM hex blob when gas and fee caps exceed server unsigned values", async () => { + it("rejects a signed EVM gas limit above the server-issued value", async () => { + const unsignedTxData: EvmTransactionData = { + data: "0x12345678", + gas: "21000", + maxFeePerGas: "1000000000", + maxPriorityFeePerGas: "500000000", + to: "0x000000000000000000000000000000000000dEaD", + value: "0" + }; + const unsignedTx: PresignedTx = { + meta: {}, + network: Networks.Polygon, + nonce: 5, + phase: "fundEphemeral", + signer: EVM_SIGNER, + txData: unsignedTxData + }; + const presignedTx = await makeSignedEvmTxWithBackups({ + gasLimit: 21001n, + maxFeePerGas: 1000000000n, + maxPriorityFeePerGas: 500000000n, + nonce: 5, + phase: "fundEphemeral", + network: Networks.Polygon + }); + + await expect( + validatePresignedTxs(RampDirection.BUY, [presignedTx], { Substrate: "", EVM: EVM_SIGNER }, [unsignedTx]) + ).rejects.toThrow("gas limit"); + }); + + it("rejects a signed EVM maxFeePerGas above the production signing multiplier", async () => { + const unsignedTxData: EvmTransactionData = { + data: "0x12345678", + gas: "21000", + maxFeePerGas: "1000000000", + maxPriorityFeePerGas: "500000000", + to: "0x000000000000000000000000000000000000dEaD", + value: "0" + }; + const unsignedTx: PresignedTx = { + meta: {}, + network: Networks.Polygon, + nonce: 5, + phase: "fundEphemeral", + signer: EVM_SIGNER, + txData: unsignedTxData + }; + const presignedTx = await makeSignedEvmTxWithBackups({ + maxFeePerGas: 1000000000n * PRESIGNED_EVM_FEE_MULTIPLIER + 1n, + maxPriorityFeePerGas: 500000000n, + nonce: 5, + phase: "fundEphemeral", + network: Networks.Polygon + }); + + await expect( + validatePresignedTxs(RampDirection.BUY, [presignedTx], { Substrate: "", EVM: EVM_SIGNER }, [unsignedTx]) + ).rejects.toThrow("maxFeePerGas"); + }); + + it("rejects a signed EVM priority fee above the production signing multiplier", async () => { const unsignedTxData: EvmTransactionData = { data: "0x12345678", gas: "21000", @@ -839,9 +931,8 @@ describe("Presigned Transaction validation", () => { txData: unsignedTxData }; const presignedTx = await makeSignedEvmTxWithBackups({ - gasLimit: 30000n, maxFeePerGas: 2000000000n, - maxPriorityFeePerGas: 1000000000n, + maxPriorityFeePerGas: 500000000n * PRESIGNED_EVM_FEE_MULTIPLIER + 1n, nonce: 5, phase: "fundEphemeral", network: Networks.Polygon @@ -849,7 +940,7 @@ describe("Presigned Transaction validation", () => { await expect( validatePresignedTxs(RampDirection.BUY, [presignedTx], { Substrate: "", EVM: EVM_SIGNER }, [unsignedTx]) - ).resolves.toBeUndefined(); + ).rejects.toThrow("maxPriorityFeePerGas"); }); it("should throw error when transaction is missing required properties", async () => { diff --git a/apps/api/src/api/services/transactions/validation.ts b/apps/api/src/api/services/transactions/validation.ts index bb4c4b72d..c5ea164a2 100644 --- a/apps/api/src/api/services/transactions/validation.ts +++ b/apps/api/src/api/services/transactions/validation.ts @@ -11,6 +11,7 @@ import { isSignedTypedDataArray, Networks, NUMBER_OF_PRESIGNED_TXS, + PRESIGNED_EVM_FEE_MULTIPLIER, PresignedTx, RampDirection, RampPhase, @@ -34,28 +35,43 @@ interface VerifiedEvmTransaction { chainId: number; } -function assertSignedEvmMinimum(fieldName: string, actual: bigint | undefined, expectedMinimumRaw: string | undefined) { +export class PresignedEvmTransactionRebindError extends APIError { + constructor(message: string) { + super({ message, status: httpStatus.BAD_REQUEST }); + } +} + +function assertSignedEvmFeeWithinBounds(fieldName: string, actual: bigint | undefined, expectedMinimumRaw: string | undefined) { if (expectedMinimumRaw === undefined) { return; } const expectedMinimum = BigInt(expectedMinimumRaw); - // When the server-issued minimum is 0, a missing field is equivalent to "≥ 0" (e.g., legacy txs that - // use gasPrice instead of maxPriorityFeePerGas, or chains that accept zero priority fee). Reject only - // if a concrete value is present and is strictly below the minimum. - if (expectedMinimum === 0n) { - if (actual !== undefined && actual < expectedMinimum) { - throw new APIError({ - message: `Signed EVM transaction ${fieldName} ${actual.toString()} is below expected minimum ${expectedMinimum.toString()}`, - status: httpStatus.BAD_REQUEST - }); - } + if (actual === undefined || actual < expectedMinimum) { + throw new APIError({ + message: `Signed EVM transaction ${fieldName} ${actual?.toString() ?? "missing"} is below expected minimum ${expectedMinimum.toString()}`, + status: httpStatus.BAD_REQUEST + }); + } + + const expectedMaximum = expectedMinimum * PRESIGNED_EVM_FEE_MULTIPLIER; + if (actual > expectedMaximum) { + throw new APIError({ + message: `Signed EVM transaction ${fieldName} ${actual.toString()} exceeds expected maximum ${expectedMaximum.toString()}`, + status: httpStatus.BAD_REQUEST + }); + } +} + +function assertSignedEvmGasLimit(actual: bigint | undefined, expectedRaw: string | undefined) { + if (expectedRaw === undefined) { return; } - if (actual === undefined || actual < expectedMinimum) { + const expected = BigInt(expectedRaw); + if (actual !== expected) { throw new APIError({ - message: `Signed EVM transaction ${fieldName} ${actual?.toString() ?? "missing"} is below expected minimum ${expectedMinimum.toString()}`, + message: `Signed EVM transaction gas limit ${actual?.toString() ?? "missing"} does not match expected ${expected.toString()}`, status: httpStatus.BAD_REQUEST }); } @@ -139,9 +155,9 @@ async function verifySignedEvmTransaction( }); } - assertSignedEvmMinimum("gas limit", parsed.gas, unsignedTxData.gas); - assertSignedEvmMinimum("maxFeePerGas", parsed.maxFeePerGas ?? parsed.gasPrice, unsignedTxData.maxFeePerGas); - assertSignedEvmMinimum( + assertSignedEvmGasLimit(parsed.gas, unsignedTxData.gas); + assertSignedEvmFeeWithinBounds("maxFeePerGas", parsed.maxFeePerGas ?? parsed.gasPrice, unsignedTxData.maxFeePerGas); + assertSignedEvmFeeWithinBounds( "maxPriorityFeePerGas", parsed.maxPriorityFeePerGas ?? parsed.gasPrice, unsignedTxData.maxPriorityFeePerGas @@ -474,6 +490,28 @@ async function validateEvmTransaction( await verifySignedEvmTransaction(txData, signer, tx.nonce, tx.network, evmUnsigned); } +export async function validatePresignedEvmTransactionAgainstUnsigned(tx: PresignedTx, unsignedTx: PresignedTx): Promise { + try { + if ( + tx.phase !== unsignedTx.phase || + tx.network !== unsignedTx.network || + tx.nonce !== unsignedTx.nonce || + tx.signer.toLowerCase() !== unsignedTx.signer.toLowerCase() + ) { + throw new Error("Presigned EVM transaction identity does not match its server-issued unsigned transaction"); + } + if (!isEvmTransactionData(unsignedTx.txData)) { + throw new Error("Server-issued unsigned EVM transaction has invalid transaction data"); + } + await validateEvmTransaction(tx, unsignedTx.signer, unsignedTx.txData); + } catch (error) { + if (error instanceof PresignedEvmTransactionRebindError) throw error; + throw new PresignedEvmTransactionRebindError( + error instanceof Error ? error.message : "Presigned EVM transaction does not match its server-issued transaction" + ); + } +} + function validateSignedTypedData( tx: PresignedTx, expectedSigner: string, diff --git a/apps/api/src/config/vars.test.ts b/apps/api/src/config/vars.test.ts index 2549967ec..2d42c2957 100644 --- a/apps/api/src/config/vars.test.ts +++ b/apps/api/src/config/vars.test.ts @@ -140,4 +140,52 @@ describe("vars deployment environment validation", () => { expect(result.exitCode).toBe(1); expect(result.stderr).toContain("RECIPIENT_INVITE_MAX_DISCOUNT_BPS must be an integer between 0 and 300"); }); + + it("rejects an EVM destination network-fee margin below 100 percent", async () => { + const result = await importVarsWithEnv({ + DEPLOYMENT_ENV: "production", + EVM_DESTINATION_NETWORK_FEE_MARGIN_BPS: "9999", + NODE_ENV: "production" + }); + + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain("EVM_DESTINATION_NETWORK_FEE_MARGIN_BPS must be an integer between 10000 and 30000"); + }); + + it("rejects a non-positive EVM destination execution-fee ceiling", async () => { + const result = await importVarsWithEnv({ + DEPLOYMENT_ENV: "production", + EVM_DESTINATION_MAX_EXECUTION_FEE_USD: "0", + NODE_ENV: "production" + }); + + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain("EVM_DESTINATION_MAX_EXECUTION_FEE_USD must be a positive number"); + }); + + it("rejects non-decimal EVM destination execution-fee ceilings during startup", async () => { + for (const invalidValue of ["0x10", "1e1"]) { + const result = await importVarsWithEnv({ + DEPLOYMENT_ENV: "production", + EVM_DESTINATION_MAX_EXECUTION_FEE_USD: invalidValue, + NODE_ENV: "production" + }); + + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain("EVM_DESTINATION_MAX_EXECUTION_FEE_USD must be a positive number"); + } + }); + + it("rejects non-decimal Mykobo fallback fees before returning strings to fee arithmetic", async () => { + const result = await importVarsWithEnv({ + DEPLOYMENT_ENV: "production", + MYKOBO_FALLBACK_DEPOSIT_FEE: "0x10", + MYKOBO_FALLBACK_WITHDRAW_FEE: "1", + MYKOBO_FEE_FALLBACK_ENABLED: "true", + NODE_ENV: "production" + }); + + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain("MYKOBO_FALLBACK_DEPOSIT_FEE must be a non-negative number"); + }); }); diff --git a/apps/api/src/config/vars.ts b/apps/api/src/config/vars.ts index 49f427e50..870049ab6 100644 --- a/apps/api/src/config/vars.ts +++ b/apps/api/src/config/vars.ts @@ -23,6 +23,7 @@ interface SpreadsheetConfig { } type DeploymentEnv = "development" | "production" | "sandbox" | "staging" | "test"; +const DECIMAL_STRING_PATTERN = /^(?:\d+(?:\.\d+)?|\.\d+)$/; // Identifies which onramp flow this backend instance serves. Two backends // share one database; each ignores ramps/quotes belonging to the other flow. @@ -82,7 +83,7 @@ function readNonNegativeDecimalEnv(name: string): string { throw new Error(`${name} is required when MYKOBO_FEE_FALLBACK_ENABLED=true`); } const value = Number(rawValue); - if (!Number.isFinite(value) || value < 0) { + if (!DECIMAL_STRING_PATTERN.test(rawValue) || !Number.isFinite(value) || value < 0) { throw new Error(`${name} must be a non-negative number (got '${rawValue}')`); } return rawValue; @@ -104,6 +105,26 @@ function readFractionEnv(name: string, defaultValue: string): number { return value; } +function readPositiveDecimalEnv(name: string, defaultValue: string): string { + const rawValue = process.env[name] ?? defaultValue; + const trimmedValue = rawValue.trim(); + const value = Number(trimmedValue); + if (!DECIMAL_STRING_PATTERN.test(trimmedValue) || !Number.isFinite(value) || value <= 0) { + throw new Error(`${name} must be a positive number`); + } + return trimmedValue; +} + +function readEvmDestinationNetworkFeeMarginBps(): number { + const name = "EVM_DESTINATION_NETWORK_FEE_MARGIN_BPS"; + const rawValue = process.env[name] ?? "12000"; + const value = Number(rawValue.trim()); + if (!Number.isInteger(value) || value < 10_000 || value > 30_000 || rawValue.trim() === "") { + throw new Error(`${name} must be an integer between 10000 and 30000`); + } + return value; +} + function readEmailAllowlist(): string[] { return (process.env.EMAIL_RECIPIENT_ALLOWLIST || "") .split(",") @@ -227,6 +248,11 @@ interface Config { defaults: { vortexEvmPayoutAddress: string | undefined; }; + evmDestinationGas: { + dynamicFundingEnabled: boolean; + maxExecutionFeeUsd: string; + networkFeeMarginBps: number; + }; } export const config: Config = { @@ -247,6 +273,13 @@ export const config: Config = { }, deploymentEnv: readDeploymentEnv(), env: nodeEnv, + evmDestinationGas: { + // Two-phase rollout guard: deploy readers/executors first, then enable quote + // production only after every worker understands funding program v2. + dynamicFundingEnabled: process.env.EVM_DYNAMIC_DESTINATION_FUNDING_ENABLED === "true", + maxExecutionFeeUsd: readPositiveDecimalEnv("EVM_DESTINATION_MAX_EXECUTION_FEE_USD", "5"), + networkFeeMarginBps: readEvmDestinationNetworkFeeMarginBps() + }, flowVariant: readFlowVariant(), integrations: { diff --git a/apps/api/src/constants/constants.ts b/apps/api/src/constants/constants.ts index fe0ded7f4..91da296ce 100644 --- a/apps/api/src/constants/constants.ts +++ b/apps/api/src/constants/constants.ts @@ -7,9 +7,9 @@ const SUBSIDY_MINIMUM_RATIO_FUND_UNITS = "5"; // 5 Subsidies considering maximum const MOONBEAM_RECEIVER_CONTRACT_ADDRESS = "0x2AB52086e8edaB28193172209407FF9df1103CDc"; const PENDULUM_EPHEMERAL_STARTING_BALANCE_UNITS = "0.1"; // Amount to send to the new pendulum ephemeral account created const MOONBEAM_EPHEMERAL_STARTING_BALANCE_UNITS = "1"; // Amount to send to the new moonbeam ephemeral account created +const MOONBEAM_EVM_SOURCE_STARTING_BALANCE_UNITS = "0.34"; // GLMR reserve for source-chain EVM transactions const POLYGON_EPHEMERAL_STARTING_BALANCE_UNITS = "1.5"; // Amount to send to the new polygon ephemeral account created const BASE_EPHEMERAL_STARTING_BALANCE_UNITS = "0.00015"; // Amount to send to the new base ephemeral account created -const ETHEREUM_EPHEMERAL_STARTING_BALANCE_UNITS = "0.005"; // Conservative reserve for destination transfer and fallback gas const DEFAULT_POLLING_INTERVAL = 3000; const GLMR_FUNDING_AMOUNT_RAW = "50000000000000000"; @@ -40,9 +40,9 @@ export { BASE_EPHEMERAL_STARTING_BALANCE_UNITS, DEFAULT_LOGIN_EXPIRATION_TIME_HOURS, DEFAULT_POLLING_INTERVAL, - ETHEREUM_EPHEMERAL_STARTING_BALANCE_UNITS, GLMR_FUNDING_AMOUNT_RAW, MAX_FINAL_SETTLEMENT_SUBSIDY_USD, + MOONBEAM_EVM_SOURCE_STARTING_BALANCE_UNITS, MOONBEAM_EPHEMERAL_STARTING_BALANCE_UNITS, MOONBEAM_FUNDING_AMOUNT_UNITS, MOONBEAM_RECEIVER_CONTRACT_ADDRESS, diff --git a/apps/api/src/test-utils/factories.ts b/apps/api/src/test-utils/factories.ts index c032e476f..56b4e1de2 100644 --- a/apps/api/src/test-utils/factories.ts +++ b/apps/api/src/test-utils/factories.ts @@ -26,6 +26,8 @@ import RampState, { type RampStateAttributes } from "../models/rampState.model"; import User from "../models/user.model"; let sequence = 0; +const TEST_VORTEX_EVM_PAYOUT_ADDRESS = "0x000000000000000000000000000000000000fee5"; + function nextSeq(): number { return ++sequence; } @@ -162,11 +164,17 @@ export async function createTestQuote(overrides: Partial /** * Baseline configuration the quote pipeline expects in every environment: * the "vortex" partner rows carrying the default platform fee (zero here; - * tests that assert fee math override via createTestPartner). + * tests that assert fee math override via createTestPartner). The payout + * address is required whenever a corridor prices a positive network fee. */ export async function seedVortexPartners(): Promise { for (const rampType of [RampDirection.BUY, RampDirection.SELL]) { - await createTestPartner({ displayName: "Vortex", name: "vortex", rampType }); + await createTestPartner({ + displayName: "Vortex", + name: "vortex", + payoutAddressEvm: TEST_VORTEX_EVM_PAYOUT_ADDRESS, + rampType + }); } } diff --git a/apps/api/src/test-utils/fake-world/fake-evm.ts b/apps/api/src/test-utils/fake-world/fake-evm.ts index 4f4c6c426..0a2e457e0 100644 --- a/apps/api/src/test-utils/fake-world/fake-evm.ts +++ b/apps/api/src/test-utils/fake-world/fake-evm.ts @@ -5,6 +5,9 @@ export interface RecordedEvmTx { from?: string; to?: string; data?: string; + gas?: bigint; + maxFeePerGas?: bigint; + maxPriorityFeePerGas?: bigint; value?: bigint; serialized?: string; hash: `0x${string}`; @@ -40,6 +43,7 @@ const MAX_UINT256 = 2n ** 256n - 1n; */ export class FakeEvm { private balances = new Map(); + private feeEstimates = new Map(); private nonces = new Map(); private txCounter = 0; readonly sentTransactions: RecordedEvmTx[] = []; @@ -57,6 +61,9 @@ export class FakeEvm { sendFailureMessage = "FakeEvm: scripted transaction failure"; /** Hashes whose receipts report a mined-but-reverted transaction. */ readonly revertedReceiptHashes = new Set(); + baseL1FeeRaw = 8_000_000_000_000n; + baseL1FeeUpperBoundRaw = 10_000_000_000_000n; + arbitrumL1GasComponent = 520n; private key(network: string, token: string, holder: string): string { return `${network}:${token.toLowerCase()}:${holder.toLowerCase()}`; @@ -78,6 +85,10 @@ export class FakeEvm { return this.balances.get(this.key(network, "native", holder)) ?? 0n; } + setFeeEstimate(network: string, maxFeePerGas: bigint, maxPriorityFeePerGas = maxFeePerGas): void { + this.feeEstimates.set(network, { maxFeePerGas, maxPriorityFeePerGas }); + } + /** * Records a transaction as if a user wallet had broadcast it (outside the * EvmClientManager seam) and returns its hash — for corridors where the @@ -116,6 +127,12 @@ export class FakeEvm { return MAX_UINT256; case "getAmountOut": return this.onGetAmountOut(network, params.address, params.args?.[0] as bigint); + case "getL1Fee": + return this.baseL1FeeRaw; + case "getL1FeeUpperBound": + return this.baseL1FeeUpperBoundRaw; + case "gasEstimateL1Component": + return [this.arbitrumL1GasComponent, 1_000_000_000n, 1_000_000_000n] as const; default: throw new Error( `FakeEvm: readContract '${params.functionName}' on ${network} is not implemented — ` + @@ -162,7 +179,8 @@ export class FakeEvm { // Dry-runs (eth_call) succeed generically; scripted failures go through failNextSends instead. call: async () => ({ data: "0x" as `0x${string}` }), chain: { id: CHAIN_IDS[network] ?? 0, name: network, nativeCurrency: { decimals: 18, name: "Ether", symbol: "ETH" } }, - estimateFeesPerGas: async () => ({ maxFeePerGas: 1_000_000_000n, maxPriorityFeePerGas: 1_000_000_000n }), + estimateFeesPerGas: async () => + this.feeEstimates.get(network) ?? { maxFeePerGas: 1_000_000_000n, maxPriorityFeePerGas: 1_000_000_000n }, estimateGas: async () => 21_000n, getBalance: async ({ address }: { address: string }) => this.nativeBalance(network, address), getGasPrice: async () => 1_000_000_000n, @@ -195,8 +213,24 @@ export class FakeEvm { return this.makeUnimplementedProxy( { account, - sendTransaction: async (params: { to?: string; data?: string; value?: bigint }) => - this.recordTransaction({ data: params.data, from: account.address, network, to: params.to, value: params.value }), + sendTransaction: async (params: { + to?: string; + data?: string; + gas?: bigint; + maxFeePerGas?: bigint; + maxPriorityFeePerGas?: bigint; + value?: bigint; + }) => + this.recordTransaction({ + data: params.data, + from: account.address, + gas: params.gas, + maxFeePerGas: params.maxFeePerGas, + maxPriorityFeePerGas: params.maxPriorityFeePerGas, + network, + to: params.to, + value: params.value + }), writeContract: async (params: { address: string; functionName: string }) => this.recordTransaction({ data: params.functionName, from: account.address, network, to: params.address }) }, diff --git a/apps/api/src/test-utils/fake-world/fake-prices.ts b/apps/api/src/test-utils/fake-world/fake-prices.ts index 8a8af91cb..dee740281 100644 --- a/apps/api/src/test-utils/fake-world/fake-prices.ts +++ b/apps/api/src/test-utils/fake-world/fake-prices.ts @@ -10,6 +10,8 @@ import { priceFeedService } from "../../api/services/priceFeed.service"; export class FakePrices { /** CoinGecko-style token id → USD price. */ cryptoUsd: Record = { + "avalanche-2": 25, + binancecoin: 600, ethereum: 2500, moonbeam: 0.08, "polygon-ecosystem-token": 0.5, @@ -18,14 +20,24 @@ export class FakePrices { /** Fiat/RampCurrency code (lowercased) → units of that currency per 1 USD. */ perUsd: Record = { ars: 1000, + // Consistent with cryptoUsd["avalanche-2"] = 25. + avax: 0.04, + // Consistent with cryptoUsd["binancecoin"] = 600. + bnb: 1 / 600, brl: 5, // BRLA is the on-chain twin of BRL and shares its peg. brla: 5, cop: 4000, + // Consistent with cryptoUsd["ethereum"] = 2500. + eth: 1 / 2500, eur: 0.9, + // Consistent with cryptoUsd["moonbeam"] = 0.08. + glmr: 12.5, // Consistent with cryptoUsd["polygon-ecosystem-token"] = 0.5. matic: 2, mxn: 17, + // Alias used by newer Polygon clients for the same native asset. + pol: 2, usd: 1, usdc: 1, "usdc.e": 1, diff --git a/apps/api/src/test-utils/preload.ts b/apps/api/src/test-utils/preload.ts index 21fefb637..32546be4d 100644 --- a/apps/api/src/test-utils/preload.ts +++ b/apps/api/src/test-utils/preload.ts @@ -8,6 +8,7 @@ */ if (!process.env.RUN_LIVE_TESTS) { process.env.NODE_ENV = "test"; + process.env.EVM_DYNAMIC_DESTINATION_FUNDING_ENABLED ??= "true"; process.env.DEPLOYMENT_ENV = "test"; process.env.FLOW_VARIANT = process.env.FLOW_VARIANT || "mykobo"; diff --git a/apps/api/src/tests/corridors/alfredpay-currencies.scenario.test.ts b/apps/api/src/tests/corridors/alfredpay-currencies.scenario.test.ts index 6c88ff574..cfaab204d 100644 --- a/apps/api/src/tests/corridors/alfredpay-currencies.scenario.test.ts +++ b/apps/api/src/tests/corridors/alfredpay-currencies.scenario.test.ts @@ -5,12 +5,14 @@ import { AlfredPayCountry, AlfredpayOfframpStatus, AlfredpayOnrampStatus, + type EvmTransactionData, EvmToken, evmTokenConfig, FiatToken, getAnyFiatTokenDetails, multiplyByPowerOfTen, Networks, + PRESIGNED_EVM_FEE_MULTIPLIER, RampDirection, type RampPhase, type UnsignedTx @@ -263,20 +265,19 @@ describe("Alfredpay currency corridors (USD/COP/ARS, on- and offramp)", () => { /** Signs a blueprint exactly as issued; the nonce may be overridden for backups. */ async function signBlueprint(ephemeral: PrivateKeyAccount, blueprint: UnsignedTx, nonce?: number): Promise<`0x${string}`> { - const txData = blueprint.txData as unknown as { to: `0x${string}`; data: `0x${string}`; value?: string }; + const txData = blueprint.txData as EvmTransactionData; const chainId = CHAIN_IDS[blueprint.network]; if (!chainId) { throw new Error(`No chain id mapped for ${blueprint.network}`); } return ephemeral.signTransaction({ chainId, - data: txData.data, - gas: 600_000n, - // validatePresignedTxs enforces the blueprint's fee minimums (3 gwei floor on Polygon). - maxFeePerGas: 5_000_000_000n, - maxPriorityFeePerGas: 5_000_000_000n, + data: txData.data as `0x${string}`, + gas: BigInt(txData.gas), + maxFeePerGas: BigInt(txData.maxFeePerGas ?? "0") * PRESIGNED_EVM_FEE_MULTIPLIER, + maxPriorityFeePerGas: BigInt(txData.maxPriorityFeePerGas ?? "0") * PRESIGNED_EVM_FEE_MULTIPLIER, nonce: nonce ?? blueprint.nonce, - to: txData.to, + to: txData.to as `0x${string}`, type: "eip1559", value: BigInt(txData.value ?? "0") }); @@ -363,14 +364,19 @@ describe("Alfredpay currency corridors (USD/COP/ARS, on- and offramp)", () => { const mintAmountRaw = BigInt(metadata?.blocks.alfredpayMint?.outputAmountRaw ?? "0"); expect(mintAmountRaw).toBeGreaterThan(0n); const amountRaw = parseUnits(quote.outputAmount, ALFREDPAY_ERC20_DECIMALS); + const registered = await RampState.findByPk(ramp.id); + const transferBlueprint = registered?.unsignedTxs.find(tx => tx.phase === "destinationTransfer"); + if (!transferBlueprint) throw new Error("destinationTransfer blueprint missing"); + const transferTxData = transferBlueprint.txData as EvmTransactionData; const signTransfer = (nonce: number) => ephemeral.signTransaction({ chainId: 137, data: encodeFunctionData({ abi: erc20Abi, args: [destination, amountRaw], functionName: "transfer" }), - gas: 100_000n, - maxFeePerGas: 5_000_000_000n, - maxPriorityFeePerGas: 5_000_000_000n, + gas: BigInt(transferTxData.gas), + maxFeePerGas: BigInt(transferTxData.maxFeePerGas ?? "0") * PRESIGNED_EVM_FEE_MULTIPLIER, + maxPriorityFeePerGas: + BigInt(transferTxData.maxPriorityFeePerGas ?? "0") * PRESIGNED_EVM_FEE_MULTIPLIER, nonce, to: ALFREDPAY_ERC20_TOKEN, type: "eip1559" @@ -453,17 +459,18 @@ describe("Alfredpay currency corridors (USD/COP/ARS, on- and offramp)", () => { expect(userTransferBlueprint).toBeDefined(); expect(offrampTransferBlueprint).toBeDefined(); const userTxData = userTransferBlueprint?.txData as unknown as { to: `0x${string}`; data: `0x${string}` }; - const offrampTxData = offrampTransferBlueprint?.txData as unknown as { to: `0x${string}`; data: `0x${string}` }; + const offrampTxData = offrampTransferBlueprint?.txData as EvmTransactionData; const signOfframpTransfer = (nonce: number) => ephemeral.signTransaction({ chainId: 137, - data: offrampTxData.data, - gas: 100_000n, - maxFeePerGas: 5_000_000_000n, - maxPriorityFeePerGas: 5_000_000_000n, + data: offrampTxData.data as `0x${string}`, + gas: BigInt(offrampTxData.gas), + maxFeePerGas: BigInt(offrampTxData.maxFeePerGas ?? "0") * PRESIGNED_EVM_FEE_MULTIPLIER, + maxPriorityFeePerGas: + BigInt(offrampTxData.maxPriorityFeePerGas ?? "0") * PRESIGNED_EVM_FEE_MULTIPLIER, nonce, - to: offrampTxData.to, + to: offrampTxData.to as `0x${string}`, type: "eip1559" }); const backups: Record = {}; diff --git a/apps/api/src/tests/corridors/brl-offramp-crosschain.scenario.test.ts b/apps/api/src/tests/corridors/brl-offramp-crosschain.scenario.test.ts index e015fd72f..8a34e7c6e 100644 --- a/apps/api/src/tests/corridors/brl-offramp-crosschain.scenario.test.ts +++ b/apps/api/src/tests/corridors/brl-offramp-crosschain.scenario.test.ts @@ -1,10 +1,12 @@ import { afterAll, beforeAll, beforeEach, describe, expect, it } from "bun:test"; import { AveniaTicketStatus, + type EvmTransactionData, EvmToken, evmTokenConfig, FiatToken, Networks, + PRESIGNED_EVM_FEE_MULTIPLIER, RampDirection, type RampPhase, type UnsignedTx @@ -179,15 +181,15 @@ describe("BRL offramp cross-chain corridor (USDC on Polygon → Base → pix via } async function signBlueprint(ephemeral: PrivateKeyAccount, blueprint: UnsignedTx): Promise<`0x${string}`> { - const txData = blueprint.txData as unknown as { to: `0x${string}`; data: `0x${string}`; value?: string }; + const txData = blueprint.txData as EvmTransactionData; return ephemeral.signTransaction({ chainId: 8453, - data: txData.data, - gas: 600_000n, - maxFeePerGas: 5_000_000_000n, - maxPriorityFeePerGas: 5_000_000_000n, + data: txData.data as `0x${string}`, + gas: BigInt(txData.gas), + maxFeePerGas: BigInt(txData.maxFeePerGas ?? "0") * PRESIGNED_EVM_FEE_MULTIPLIER, + maxPriorityFeePerGas: BigInt(txData.maxPriorityFeePerGas ?? "0") * PRESIGNED_EVM_FEE_MULTIPLIER, nonce: blueprint.nonce, - to: txData.to, + to: txData.to as `0x${string}`, type: "eip1559", value: BigInt(txData.value ?? "0") }); diff --git a/apps/api/src/tests/corridors/brl-offramp.scenario.test.ts b/apps/api/src/tests/corridors/brl-offramp.scenario.test.ts index 33782af01..153e7f64f 100644 --- a/apps/api/src/tests/corridors/brl-offramp.scenario.test.ts +++ b/apps/api/src/tests/corridors/brl-offramp.scenario.test.ts @@ -2,10 +2,12 @@ import { afterAll, beforeAll, beforeEach, describe, expect, it } from "bun:test" import { AveniaTicketStatus, type CleanupPhase, + type EvmTransactionData, EvmToken, evmTokenConfig, FiatToken, Networks, + PRESIGNED_EVM_FEE_MULTIPLIER, RampDirection, type RampPhase, type UnsignedTx @@ -181,15 +183,15 @@ describe("BRL offramp swap corridor (USDC on Base → pix via Avenia)", () => { } async function signBlueprint(ephemeral: PrivateKeyAccount, blueprint: UnsignedTx, nonce?: number): Promise<`0x${string}`> { - const txData = blueprint.txData as unknown as { to: `0x${string}`; data: `0x${string}`; value?: string }; + const txData = blueprint.txData as EvmTransactionData; return ephemeral.signTransaction({ chainId: 8453, - data: txData.data, - gas: 600_000n, - maxFeePerGas: 5_000_000_000n, - maxPriorityFeePerGas: 5_000_000_000n, + data: txData.data as `0x${string}`, + gas: BigInt(txData.gas), + maxFeePerGas: BigInt(txData.maxFeePerGas ?? "0") * PRESIGNED_EVM_FEE_MULTIPLIER, + maxPriorityFeePerGas: BigInt(txData.maxPriorityFeePerGas ?? "0") * PRESIGNED_EVM_FEE_MULTIPLIER, nonce: nonce ?? blueprint.nonce, - to: txData.to, + to: txData.to as `0x${string}`, type: "eip1559", value: BigInt(txData.value ?? "0") }); @@ -200,27 +202,16 @@ describe("BRL offramp swap corridor (USDC on Base → pix via Avenia)", () => { * nonces, honoring the blueprint's fee/gas minimums, shaped for /v1/ramp/update. */ async function signBlueprintWithBackups(ephemeral: PrivateKeyAccount, blueprint: UnsignedTx) { - const txData = blueprint.txData as unknown as { - to: `0x${string}`; - data: `0x${string}`; - value?: string; - gas?: string; - maxFeePerGas?: string; - maxPriorityFeePerGas?: string; - }; - const atLeast = (raw: string | undefined, floor: bigint) => { - const value = BigInt(raw ?? "0"); - return value > floor ? value : floor; - }; + const txData = blueprint.txData as EvmTransactionData; const sign = (nonce: number) => ephemeral.signTransaction({ chainId: 8453, - data: txData.data, - gas: atLeast(txData.gas, 600_000n), - maxFeePerGas: atLeast(txData.maxFeePerGas, 5_000_000_000n), - maxPriorityFeePerGas: atLeast(txData.maxPriorityFeePerGas, 5_000_000_000n), + data: txData.data as `0x${string}`, + gas: BigInt(txData.gas), + maxFeePerGas: BigInt(txData.maxFeePerGas ?? "0") * PRESIGNED_EVM_FEE_MULTIPLIER, + maxPriorityFeePerGas: BigInt(txData.maxPriorityFeePerGas ?? "0") * PRESIGNED_EVM_FEE_MULTIPLIER, nonce, - to: txData.to, + to: txData.to as `0x${string}`, type: "eip1559", value: BigInt(txData.value ?? "0") }); @@ -577,7 +568,7 @@ describe("BRL offramp swap corridor (USDC on Base → pix via Avenia)", () => { const rampState = await RampState.findByPk(ramp.id); const payoutBlueprint = blueprintOf(rampState?.unsignedTxs ?? [], "brlaPayoutOnBase"); - const blueprintData = payoutBlueprint.txData as unknown as { to: `0x${string}`; data: `0x${string}` }; + const blueprintData = payoutBlueprint.txData as EvmTransactionData; const { args } = decodeFunctionData({ abi: erc20Abi, data: blueprintData.data }); const amount = (args as [string, bigint])[1]; @@ -587,11 +578,12 @@ describe("BRL offramp swap corridor (USDC on Base → pix via Avenia)", () => { ephemeral.signTransaction({ chainId: 8453, data: encodeFunctionData({ abi: erc20Abi, args: [attacker, amount], functionName: "transfer" }), - gas: 600_000n, - maxFeePerGas: 5_000_000_000n, - maxPriorityFeePerGas: 5_000_000_000n, + gas: BigInt(blueprintData.gas), + maxFeePerGas: BigInt(blueprintData.maxFeePerGas ?? "0") * PRESIGNED_EVM_FEE_MULTIPLIER, + maxPriorityFeePerGas: + BigInt(blueprintData.maxPriorityFeePerGas ?? "0") * PRESIGNED_EVM_FEE_MULTIPLIER, nonce, - to: blueprintData.to, + to: blueprintData.to as `0x${string}`, type: "eip1559" }); const tamperedPayout = await tamper(payoutBlueprint.nonce); diff --git a/apps/api/src/tests/corridors/brl-onramp-crosschain.scenario.test.ts b/apps/api/src/tests/corridors/brl-onramp-crosschain.scenario.test.ts index 150c4da8a..ecf87ec94 100644 --- a/apps/api/src/tests/corridors/brl-onramp-crosschain.scenario.test.ts +++ b/apps/api/src/tests/corridors/brl-onramp-crosschain.scenario.test.ts @@ -1,17 +1,22 @@ import { afterAll, beforeAll, beforeEach, describe, expect, it } from "bun:test"; import { EvmToken, + type EvmNetworks, evmTokenConfig, FiatToken, Networks, + QuoteError, RampDirection, type RampPhase, + signUnsignedTransactions, type UnsignedTx } from "@vortexfi/shared"; +import Big from "big.js"; import { decodeFunctionData, erc20Abi, parseTransaction, parseUnits } from "viem"; import { generatePrivateKey, privateKeyToAccount, type PrivateKeyAccount } from "viem/accounts"; import phaseProcessor from "../../api/services/phases/phase-processor"; -import { getBlockMetadata } from "../../api/services/phases/blocks/core/metadata"; +import { config } from "../../config/vars"; +import { getBlockMetadata, getFlowMetadata } from "../../api/services/phases/blocks/core/metadata"; import { NablaSwapContext } from "../../api/services/phases/blocks/phases/nabla-swap/simulation"; import { SquidRouterSwapContext } from "../../api/services/phases/blocks/phases/squid-router-swap/simulation"; import QuoteTicket from "../../models/quoteTicket.model"; @@ -34,11 +39,33 @@ const USDC_ON_ARBITRUM = requireToken(Networks.Arbitrum, EvmToken.USDC).erc20Add const BRLA_ON_BASE = requireToken(Networks.Base, EvmToken.BRLA).erc20AddressSourceChain as `0x${string}`; const TAX_ID = "12345678901"; +const BASE_CHAIN_ID_HEX = "0x2105"; +const ARBITRUM_CHAIN_ID_HEX = "0xa4b1"; -const CHAIN_IDS: Partial> = { - [Networks.Arbitrum]: 42161, - [Networks.Base]: 8453 -}; +function installChainIdShim(): { restore: () => void } { + const guardedFetch = globalThis.fetch; + const shim = (async (input: Parameters[0], init?: Parameters[1]) => { + if (typeof init?.body === "string") { + try { + const payload = JSON.parse(init.body) as { id?: number; method?: string }; + if (payload.method === "eth_chainId") { + const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; + const chainId = url.includes("base") ? BASE_CHAIN_ID_HEX : ARBITRUM_CHAIN_ID_HEX; + return Response.json({ id: payload.id ?? 1, jsonrpc: "2.0", result: chainId }); + } + } catch { + // Not a JSON-RPC request; retain the hermetic fetch guard below. + } + } + return guardedFetch(input, init); + }) as typeof fetch; + globalThis.fetch = Object.assign(shim, guardedFetch); + return { + restore: () => { + globalThis.fetch = guardedFetch; + } + }; +} // Unlike the direct pix→BRLA-on-Base corridor, the full swap-and-bridge chain // executes here: Nabla swaps the minted BRLA into USDC on Base, the squid @@ -79,6 +106,12 @@ interface CorridorSetup { destination: `0x${string}`; } +interface DestinationFundingExpectation { + initialBalanceRaw: bigint; + liabilityRaw: bigint; + shortfallRaw: bigint; +} + /** * Corridor scenario tests for the CROSS-CHAIN BRL onramp (pix → BRLA minted on * Base → Nabla swap to USDC → SquidRouter bridge → USDC on Arbitrum). This is @@ -93,10 +126,12 @@ interface CorridorSetup { describe("BRL onramp cross-chain corridor (pix → Base mint+swap → USDC on Arbitrum)", () => { let world: FakeWorld; let auth: { restore: () => void }; + let chainIdShim: { restore: () => void }; let app: TestApp; beforeAll(async () => { world = installFakeWorld(); + chainIdShim = installChainIdShim(); auth = installFakeSupabaseAuth(); await setupTestDatabase(); app = await startTestApp(); @@ -105,6 +140,7 @@ describe("BRL onramp cross-chain corridor (pix → Base mint+swap → USDC on Ar afterAll(async () => { await app?.close(); auth?.restore(); + chainIdShim?.restore(); world?.restore(); }); @@ -114,6 +150,8 @@ describe("BRL onramp cross-chain corridor (pix → Base mint+swap → USDC on Ar // partner's EVM payout address even when the resulting fees are zero. await updatePartnerPricing("vortex", RampDirection.BUY, { payoutAddressEvm: "0x000000000000000000000000000000000000fee5" }); world.evm.failNextSends = 0; + world.evm.setFeeEstimate(Networks.Arbitrum, 1_000_000_000n); + world.evm.setFeeEstimate(Networks.Base, 1_000_000_000n); world.evm.onTransaction = undefined; world.brla.onPixOutputTicket = undefined; world.brla.accountBalances = { BRLA: 1_000_000, USDC: 0, USDM: 0, USDT: 0 }; @@ -133,8 +171,8 @@ describe("BRL onramp cross-chain corridor (pix → Base mint+swap → USDC on Ar }); async function createQuoteViaApi( - destinationNetwork: Networks.Arbitrum | Networks.Ethereum = Networks.Arbitrum - ): Promise<{ id: string; outputAmount: string }> { + destinationNetwork: EvmNetworks = Networks.Arbitrum + ): Promise<{ id: string; networkFeeUsd: string; outputAmount: string }> { const response = await app.request("/v1/quotes", { body: JSON.stringify({ from: "pix", @@ -149,7 +187,7 @@ describe("BRL onramp cross-chain corridor (pix → Base mint+swap → USDC on Ar method: "POST" }); expect(response.status, `quote creation failed: ${await response.clone().text()}`).toBe(201); - return (await response.json()) as { id: string; outputAmount: string }; + return (await response.json()) as { id: string; networkFeeUsd: string; outputAmount: string }; } async function registerViaApi( @@ -180,38 +218,31 @@ describe("BRL onramp cross-chain corridor (pix → Base mint+swap → USDC on Ar return blueprint as UnsignedTx; } - async function signBlueprint(ephemeral: PrivateKeyAccount, blueprint: UnsignedTx): Promise<`0x${string}`> { - const txData = blueprint.txData as unknown as { to: `0x${string}`; data: `0x${string}`; value?: string }; - const chainId = CHAIN_IDS[blueprint.network]; - if (!chainId) { - throw new Error(`No chain id mapped for ${blueprint.network}`); - } - return ephemeral.signTransaction({ - chainId, - data: txData.data, - gas: 600_000n, - maxFeePerGas: 5_000_000_000n, - maxPriorityFeePerGas: 5_000_000_000n, - nonce: blueprint.nonce, - to: txData.to, - type: "eip1559", - value: BigInt(txData.value ?? "0") - }); - } - /** - * Creates quote + registration through the HTTP API, then signs the - * ephemeral phase blueprints exactly as issued — the Nabla pair and squid - * pair on Base plus the destination transfer on Arbitrum — and stores them - * as presigned transactions the way /v1/ramp/update would. + * Creates quote + registration through the HTTP API, signs every ephemeral + * blueprint with the shared production signer (including backups), and + * submits the result through the real /v1/ramp/update validation path. */ - async function setUpRegisteredRamp(): Promise { - const ephemeral = privateKeyToAccount(generatePrivateKey()); + async function setUpRegisteredRamp(options: { legacyDestinationFunding?: boolean } = {}): Promise { + const ephemeralSecret = generatePrivateKey(); + const ephemeral = privateKeyToAccount(ephemeralSecret); const destination = privateKeyToAccount(generatePrivateKey()).address as `0x${string}`; const user = await createTestUser(); await createTestTaxId(user.id, { taxId: TAX_ID }); const quote = await createQuoteViaApi(); + expect(new Big(quote.networkFeeUsd).gt("2.5")).toBe(true); + if (options.legacyDestinationFunding) { + const legacyQuote = await QuoteTicket.findByPk(quote.id); + if (!legacyQuote) throw new Error("Quote not found before legacy compatibility setup"); + const legacyMetadata = getFlowMetadata(legacyQuote.metadata); + const { evmDestinationGas: _dynamicFunding, ...legacyGlobals } = legacyMetadata.globals; + await legacyQuote.update({ + metadata: { ...legacyMetadata, globals: legacyGlobals } as unknown as QuoteTicket["metadata"] + }); + // The historical 0.0002 ETH reserve covers this signed 100k-gas payout. + world.evm.setFeeEstimate(Networks.Arbitrum, 500_000_000n); + } const ramp = await registerViaApi(quote.id, user.id, ephemeral, destination); const persistedQuote = await QuoteTicket.findByPk(quote.id); @@ -238,34 +269,37 @@ describe("BRL onramp cross-chain corridor (pix → Base mint+swap → USDC on Ar const squidApproveBlueprint = blueprintOf(unsignedTxs, "squidRouterApprove"); const squidSwapBlueprint = blueprintOf(unsignedTxs, "squidRouterSwap"); const transferBlueprint = blueprintOf(unsignedTxs, "destinationTransfer"); + expect(nablaApproveBlueprint.network).toBe(Networks.Base); + expect(nablaSwapBlueprint.network).toBe(Networks.Base); expect(squidApproveBlueprint.network).toBe(Networks.Base); expect(squidSwapBlueprint.network).toBe(Networks.Base); expect(transferBlueprint.network).toBe(Networks.Arbitrum); - const signedNablaApprove = await signBlueprint(ephemeral, nablaApproveBlueprint); - const signedNablaSwap = await signBlueprint(ephemeral, nablaSwapBlueprint); - const signedSquidApprove = await signBlueprint(ephemeral, squidApproveBlueprint); - const signedSquidSwap = await signBlueprint(ephemeral, squidSwapBlueprint); - const signedTransfer = await signBlueprint(ephemeral, transferBlueprint); - - const presign = (blueprint: UnsignedTx, txData: `0x${string}`) => ({ - meta: {}, - network: blueprint.network, - nonce: blueprint.nonce, - phase: blueprint.phase, - signer: ephemeral.address, - txData + const presignedTxs = await signUnsignedTransactions(unsignedTxs, { + evmEphemeral: { + address: ephemeral.address, + secret: ephemeralSecret + } }); + const signedFor = (phase: RampPhase) => { + const transaction = presignedTxs.find(tx => tx.phase === phase); + expect(transaction, `production signer omitted ${phase}`).toBeDefined(); + return transaction?.txData as `0x${string}`; + }; + const signedNablaSwap = signedFor("nablaSwap"); + const signedSquidApprove = signedFor("squidRouterApprove"); + const signedSquidSwap = signedFor("squidRouterSwap"); + const signedTransfer = signedFor("destinationTransfer"); - await rampState.update({ - presignedTxs: [ - presign(nablaApproveBlueprint, signedNablaApprove), - presign(nablaSwapBlueprint, signedNablaSwap), - presign(squidApproveBlueprint, signedSquidApprove), - presign(squidSwapBlueprint, signedSquidSwap), - presign(transferBlueprint, signedTransfer) - ] + const updateResponse = await app.request("/v1/ramp/update", { + body: JSON.stringify({ presignedTxs, rampId: ramp.id }), + headers: { + Authorization: `Bearer ${testUserToken(user.id)}`, + "Content-Type": "application/json" + }, + method: "POST" }); + expect(updateResponse.status, `ramp update failed: ${await updateResponse.clone().text()}`).toBe(200); const transferTxData = transferBlueprint.txData as unknown as { data: `0x${string}` }; const { args } = decodeFunctionData({ abi: erc20Abi, data: transferTxData.data }); @@ -291,14 +325,25 @@ describe("BRL onramp cross-chain corridor (pix → Base mint+swap → USDC on Ar * Scripts the fake world so every polling loop succeeds on its first check: * - the Avenia subaccount holds the minted BRL and the mint ticket credits * the ephemeral's BRLA on Base instantly, - * - the ephemeral has gas on Base AND Arbitrum (destination funding), + * - the ephemeral has source gas on Base but only a partial destination gas + * balance on Arbitrum, so fundEphemeral must supply the exact shortfall, * - the broadcast Nabla swap credits the ephemeral's Base USDC, * - the broadcast squid swap credits the bridged USDC on Arbitrum, - * - raw ERC-20 transfers are applied to the in-memory ledger. + * - the destination payout is accepted only if the funded native balance can + * cover its full signed fee cap, then raw ERC-20 transfers are applied to + * the in-memory ledger. */ - function scriptHappyWorld(setup: CorridorSetup): void { + function scriptHappyWorld(setup: CorridorSetup): DestinationFundingExpectation { + const parsedTransfer = parseTransaction(setup.signedTransfer); + if (parsedTransfer.gas === undefined || parsedTransfer.maxFeePerGas === undefined) { + throw new Error("Signed destination transfer is missing its gas fee cap"); + } + const liabilityRaw = parsedTransfer.gas * parsedTransfer.maxFeePerGas; + const initialBalanceRaw = liabilityRaw / 4n; + const shortfallRaw = liabilityRaw - initialBalanceRaw; + world.evm.setNativeBalance(Networks.Base, setup.ephemeral.address, parseUnits("2", 18)); - world.evm.setNativeBalance(Networks.Arbitrum, setup.ephemeral.address, parseUnits("2", 18)); + world.evm.setNativeBalance(Networks.Arbitrum, setup.ephemeral.address, initialBalanceRaw); world.brla.onPixOutputTicket = ({ walletAddress }) => { if (walletAddress) { // Generous credit (same as the direct corridor): the mint handler @@ -308,6 +353,14 @@ describe("BRL onramp cross-chain corridor (pix → Base mint+swap → USDC on Ar } }; world.evm.onTransaction = tx => { + if (!tx.serialized && tx.to?.toLowerCase() === setup.ephemeral.address.toLowerCase() && tx.value !== undefined) { + world.evm.setNativeBalance( + tx.network, + setup.ephemeral.address, + world.evm.nativeBalance(tx.network, setup.ephemeral.address) + tx.value + ); + return; + } if (tx.serialized === setup.signedNablaSwap) { world.evm.setErc20Balance(Networks.Base, USDC_ON_BASE, setup.ephemeral.address, setup.swapOutputRaw); return; @@ -321,6 +374,18 @@ describe("BRL onramp cross-chain corridor (pix → Base mint+swap → USDC on Ar ); return; } + if (tx.serialized === setup.signedTransfer) { + const fundedBalanceRaw = world.evm.nativeBalance(Networks.Arbitrum, setup.ephemeral.address); + if (fundedBalanceRaw < liabilityRaw) { + throw new Error( + `FakeEvm: destination payout needs ${liabilityRaw} native units but ephemeral holds ${fundedBalanceRaw}` + ); + } + // Charge the full signed fee cap. Real execution normally spends less, but + // this proves the selected funding survives the worst case authorized by + // the transaction before the fake RPC accepts the submission. + world.evm.setNativeBalance(Networks.Arbitrum, setup.ephemeral.address, fundedBalanceRaw - liabilityRaw); + } const parsed = tx.serialized ? parseTransaction(tx.serialized as `0x${string}`) : { data: tx.data, to: tx.to }; if (!parsed.to || !parsed.data) { return; @@ -342,25 +407,95 @@ describe("BRL onramp cross-chain corridor (pix → Base mint+swap → USDC on Ar world.evm.erc20Balance(tx.network, parsed.to, recipient) + amount ); }; + + return { initialBalanceRaw, liabilityRaw, shortfallRaw }; } function submissionsOf(signedTx: `0x${string}`): number { return world.evm.sentTransactions.filter(tx => tx.serialized === signedTx).length; } - it("creates BRL onramp quotes for Ethereum destinations", async () => { - const quote = await createQuoteViaApi(Networks.Ethereum); - const persistedQuote = await QuoteTicket.findByPk(quote.id); + it("prices destination execution for ETH, MATIC, BNB, and AVAX gas chains", async () => { + for (const network of [ + Networks.Ethereum, + Networks.Arbitrum, + Networks.Polygon, + Networks.BSC, + Networks.Avalanche + ] as const) { + const quote = await createQuoteViaApi(network); + const persistedQuote = await QuoteTicket.findByPk(quote.id); + + expect(persistedQuote?.network).toBe(network); + expect(persistedQuote?.to).toBe(network); + expect(new Big(quote.networkFeeUsd).gt("2.5")).toBe(true); + expect(getFlowMetadata(persistedQuote?.metadata).globals.evmDestinationGas?.network).toBe(network); + } + }); + + it("returns the typed 503 for normal and all-high best-quote requests", async () => { + const originalCeiling = config.evmDestinationGas.maxExecutionFeeUsd; + config.evmDestinationGas.maxExecutionFeeUsd = "0.000001"; + try { + const request = { + from: "pix", + inputAmount: "500", + inputCurrency: FiatToken.BRL, + outputCurrency: EvmToken.USDC, + rampType: RampDirection.BUY + }; + const quoteResponse = await app.request("/v1/quotes", { + body: JSON.stringify({ ...request, network: Networks.Arbitrum, to: Networks.Arbitrum }), + headers: { "Content-Type": "application/json" }, + method: "POST" + }); + expect(quoteResponse.status).toBe(503); + expect(await quoteResponse.json()).toMatchObject({ message: QuoteError.NetworkFeesTooHigh }); + + const bestResponse = await app.request("/v1/quotes/best", { + body: JSON.stringify({ ...request, networks: [Networks.Arbitrum] }), + headers: { "Content-Type": "application/json" }, + method: "POST" + }); + expect(bestResponse.status).toBe(503); + expect(await bestResponse.json()).toMatchObject({ message: QuoteError.NetworkFeesTooHigh }); + } finally { + config.evmDestinationGas.maxExecutionFeeUsd = originalCeiling; + } + }); + + it("rejects moved fees before creating an Avenia registration ticket", async () => { + const ephemeral = privateKeyToAccount(generatePrivateKey()); + const destination = privateKeyToAccount(generatePrivateKey()).address as `0x${string}`; + const user = await createTestUser(); + await createTestTaxId(user.id, { taxId: TAX_ID }); + const quote = await createQuoteViaApi(); + const ticketsBefore = world.brla.pixInputTickets.length; + world.evm.setFeeEstimate(Networks.Arbitrum, 1_200_000_001n); - expect(persistedQuote?.network).toBe(Networks.Ethereum); - expect(persistedQuote?.to).toBe(Networks.Ethereum); + const response = await app.request("/v1/ramp/register", { + body: JSON.stringify({ + additionalData: { destinationAddress: destination, taxId: TAX_ID }, + quoteId: quote.id, + signingAccounts: [{ address: ephemeral.address, type: "EVM" }] + }), + headers: { + Authorization: `Bearer ${testUserToken(user.id)}`, + "Content-Type": "application/json" + }, + method: "POST" + }); + + expect(response.status).toBe(503); + expect(await response.json()).toMatchObject({ message: QuoteError.NetworkFeesTooHigh }); + expect(world.brla.pixInputTickets).toHaveLength(ticketsBefore); }); it( - "happy path: mints on Base, swaps BRLA to USDC via Nabla, bridges via squid, and pays the destination on Arbitrum", + "dynamically funds the signed payout shortfall, then submits the full cross-chain payout", async () => { const setup = await setUpRegisteredRamp(); - scriptHappyWorld(setup); + const destinationFunding = scriptHappyWorld(setup); const pixOutBefore = world.brla.pixOutputTickets.length; // Registration requested a Base USDC → Arbitrum USDC squid route. @@ -393,8 +528,78 @@ describe("BRL onramp cross-chain corridor (pix → Base mint+swap → USDC on Ar expect(submissionsOf(setup.signedSquidApprove)).toBe(1); expect(submissionsOf(setup.signedSquidSwap)).toBe(1); expect(submissionsOf(setup.signedTransfer)).toBe(1); + const destinationFundingTxs = world.evm.sentTransactions.filter( + tx => + !tx.serialized && + tx.network === Networks.Arbitrum && + tx.to?.toLowerCase() === setup.ephemeral.address.toLowerCase() && + tx.value !== undefined + ); + expect(destinationFundingTxs).toHaveLength(1); + expect(destinationFundingTxs[0].value).toBe(destinationFunding.shortfallRaw); + const gasQuote = getFlowMetadata(quote?.metadata).globals.evmDestinationGas; + expect(gasQuote?.fundingGasLimit).toBe("21624"); + expect(gasQuote?.transferGasLimit).toBe("100624"); + expect(destinationFundingTxs[0].gas).toBe(BigInt(gasQuote?.fundingGasLimit ?? "0")); + expect(destinationFundingTxs[0].maxFeePerGas).toBe(1_000_000_000n); + expect(destinationFundingTxs[0].maxPriorityFeePerGas).toBe(1_000_000_000n); + expect(destinationFunding.initialBalanceRaw + (destinationFundingTxs[0].value ?? 0n)).toBe( + destinationFunding.liabilityRaw + ); + expect(world.evm.nativeBalance(Networks.Arbitrum, setup.ephemeral.address)).toBe(0n); expect(world.evm.erc20Balance(Networks.Arbitrum, USDC_ON_ARBITRUM, setup.destination)).toBe(setup.amountRaw); }, 30000 ); + + it( + "completes an in-flight legacy cross-chain quote without dynamic funding metadata", + async () => { + const setup = await setUpRegisteredRamp({ legacyDestinationFunding: true }); + const destinationFunding = scriptHappyWorld(setup); + + await phaseProcessor.processRamp(setup.rampId); + + const final = await RampState.findByPk(setup.rampId); + expect(final?.currentPhase).toBe("complete"); + const destinationFundingTxs = world.evm.sentTransactions.filter( + tx => + !tx.serialized && + tx.network === Networks.Arbitrum && + tx.to?.toLowerCase() === setup.ephemeral.address.toLowerCase() && + tx.value !== undefined + ); + expect(destinationFundingTxs).toHaveLength(1); + expect(destinationFundingTxs[0].gas).toBeUndefined(); + expect(destinationFunding.initialBalanceRaw + (destinationFundingTxs[0].value ?? 0n)).toBe(parseUnits("0.0002", 18)); + expect(submissionsOf(setup.signedTransfer)).toBe(1); + }, + 30000 + ); + + it( + "pauses without treasury spend when live destination fees exceed the quote envelope", + async () => { + const setup = await setUpRegisteredRamp(); + scriptHappyWorld(setup); + world.evm.setFeeEstimate(Networks.Arbitrum, 1_200_000_001n, 1_000_000_000n); + + await phaseProcessor.processRamp(setup.rampId); + + const paused = await RampState.findByPk(setup.rampId); + expect(paused?.currentPhase).toBe("fundEphemeral"); + expect( + world.evm.sentTransactions.filter( + tx => + !tx.serialized && + tx.network === Networks.Arbitrum && + tx.to?.toLowerCase() === setup.ephemeral.address.toLowerCase() + ) + ).toHaveLength(0); + expect(submissionsOf(setup.signedTransfer)).toBe(0); + expect(paused?.errorLogs.some(log => log.phase === "fundEphemeral" && log.recoverable)).toBe(true); + expect(paused?.errorLogs.at(-1)?.error).toBe(QuoteError.NetworkFeesTooHigh); + }, + 30000 + ); }); diff --git a/apps/api/src/tests/corridors/eur-offramp.scenario.test.ts b/apps/api/src/tests/corridors/eur-offramp.scenario.test.ts index d5cb42110..8d844d5b6 100644 --- a/apps/api/src/tests/corridors/eur-offramp.scenario.test.ts +++ b/apps/api/src/tests/corridors/eur-offramp.scenario.test.ts @@ -1,12 +1,14 @@ import { afterAll, beforeAll, beforeEach, describe, expect, it } from "bun:test"; import { EphemeralAccountType, + type EvmTransactionData, EvmToken, evmTokenConfig, FiatToken, MykoboTransactionStatus, MykoboTransactionType, Networks, + PRESIGNED_EVM_FEE_MULTIPLIER, RampDirection, type RampPhase, type UnsignedTx @@ -185,15 +187,15 @@ describe("EUR offramp corridor (USDC on Base → SEPA via Mykobo)", () => { } async function signBlueprint(ephemeral: PrivateKeyAccount, blueprint: UnsignedTx): Promise<`0x${string}`> { - const txData = blueprint.txData as unknown as { to: `0x${string}`; data: `0x${string}`; value?: string }; + const txData = blueprint.txData as EvmTransactionData; return ephemeral.signTransaction({ chainId: 8453, - data: txData.data, - gas: 600_000n, - maxFeePerGas: 5_000_000_000n, - maxPriorityFeePerGas: 5_000_000_000n, + data: txData.data as `0x${string}`, + gas: BigInt(txData.gas), + maxFeePerGas: BigInt(txData.maxFeePerGas ?? "0") * PRESIGNED_EVM_FEE_MULTIPLIER, + maxPriorityFeePerGas: BigInt(txData.maxPriorityFeePerGas ?? "0") * PRESIGNED_EVM_FEE_MULTIPLIER, nonce: blueprint.nonce, - to: txData.to, + to: txData.to as `0x${string}`, type: "eip1559", value: BigInt(txData.value ?? "0") }); diff --git a/apps/api/src/tests/corridors/mxn-offramp.scenario.test.ts b/apps/api/src/tests/corridors/mxn-offramp.scenario.test.ts index 12b5e5124..fb1fb4bc7 100644 --- a/apps/api/src/tests/corridors/mxn-offramp.scenario.test.ts +++ b/apps/api/src/tests/corridors/mxn-offramp.scenario.test.ts @@ -3,9 +3,11 @@ import { ALFREDPAY_ERC20_DECIMALS, ALFREDPAY_ERC20_TOKEN, AlfredpayOfframpStatus, + type EvmTransactionData, EvmToken, FiatToken, Networks, + PRESIGNED_EVM_FEE_MULTIPLIER, RampDirection, type RampPhase, type UnsignedTx @@ -41,10 +43,9 @@ const HAPPY_PATH_PHASES: RampPhase[] = [ const ALFREDPAY_OFFRAMP_RATE = 20; const FIAT_ACCOUNT_ID = "test-fiat-account-1"; -interface EvmTxBlueprint { +interface EvmTxBlueprint extends EvmTransactionData { to: `0x${string}`; data: `0x${string}`; - value?: string; } interface CorridorSetup { @@ -192,10 +193,11 @@ describe("MXN offramp direct corridor (USDT on Polygon → spei, no-permit)", () return ephemeral.signTransaction({ chainId: 137, data: offrampTransferBlueprint.data, - gas: 100_000n, - // validatePresignedTxs enforces a 3 gwei floor on Polygon fees. - maxFeePerGas: 5_000_000_000n, - maxPriorityFeePerGas: 5_000_000_000n, + gas: BigInt(offrampTransferBlueprint.gas), + maxFeePerGas: + BigInt(offrampTransferBlueprint.maxFeePerGas ?? "0") * PRESIGNED_EVM_FEE_MULTIPLIER, + maxPriorityFeePerGas: + BigInt(offrampTransferBlueprint.maxPriorityFeePerGas ?? "0") * PRESIGNED_EVM_FEE_MULTIPLIER, nonce, to: offrampTransferBlueprint.to, type: "eip1559" @@ -422,16 +424,17 @@ describe("MXN offramp direct corridor (USDT on Polygon → spei, no-permit)", () // Presign the single distributeFees transfer (vortex only) as blueprinted. const feeBlueprint = allUnsignedTxs.find(tx => tx.phase === "distributeFees"); expect(feeBlueprint).toBeDefined(); - const feeData = feeBlueprint?.txData as unknown as { to: `0x${string}`; data: `0x${string}` }; + const feeData = feeBlueprint?.txData as EvmTransactionData; const signFee = (nonce: number) => setup.ephemeral.signTransaction({ chainId: 137, - data: feeData.data, - gas: 100_000n, - maxFeePerGas: 5_000_000_000n, - maxPriorityFeePerGas: 5_000_000_000n, + data: feeData.data as `0x${string}`, + gas: BigInt(feeData.gas), + maxFeePerGas: BigInt(feeData.maxFeePerGas ?? "0") * PRESIGNED_EVM_FEE_MULTIPLIER, + maxPriorityFeePerGas: + BigInt(feeData.maxPriorityFeePerGas ?? "0") * PRESIGNED_EVM_FEE_MULTIPLIER, nonce, - to: feeData.to, + to: feeData.to as `0x${string}`, type: "eip1559" }); const feeBackups: Record = {}; @@ -519,16 +522,17 @@ describe("MXN offramp direct corridor (USDT on Polygon → spei, no-permit)", () const feeBlueprint = allUnsignedTxs.find(tx => tx.phase === "distributeFees"); expect(feeBlueprint).toBeDefined(); - const feeData = feeBlueprint?.txData as unknown as { to: `0x${string}`; data: `0x${string}` }; + const feeData = feeBlueprint?.txData as EvmTransactionData; const signFee = (nonce: number) => setup.ephemeral.signTransaction({ chainId: 137, - data: feeData.data, - gas: 100_000n, - maxFeePerGas: 5_000_000_000n, - maxPriorityFeePerGas: 5_000_000_000n, + data: feeData.data as `0x${string}`, + gas: BigInt(feeData.gas), + maxFeePerGas: BigInt(feeData.maxFeePerGas ?? "0") * PRESIGNED_EVM_FEE_MULTIPLIER, + maxPriorityFeePerGas: + BigInt(feeData.maxPriorityFeePerGas ?? "0") * PRESIGNED_EVM_FEE_MULTIPLIER, nonce, - to: feeData.to, + to: feeData.to as `0x${string}`, type: "eip1559" }); const feeBackups: Record = {}; diff --git a/apps/api/src/tests/corridors/mxn-onramp-crosschain.scenario.test.ts b/apps/api/src/tests/corridors/mxn-onramp-crosschain.scenario.test.ts index 4dc3d55ba..4594d8326 100644 --- a/apps/api/src/tests/corridors/mxn-onramp-crosschain.scenario.test.ts +++ b/apps/api/src/tests/corridors/mxn-onramp-crosschain.scenario.test.ts @@ -2,10 +2,12 @@ import { afterAll, beforeAll, beforeEach, describe, expect, it } from "bun:test" import { ALFREDPAY_ERC20_TOKEN, AlfredpayOnrampStatus, + type EvmTransactionData, EvmToken, evmTokenConfig, FiatToken, Networks, + PRESIGNED_EVM_FEE_MULTIPLIER, RampDirection, type RampPhase, type UnsignedTx @@ -161,20 +163,19 @@ describe("MXN onramp cross-chain corridor (spei → Polygon mint → USDT on Arb /** Signs a blueprint exactly as issued; the nonce may be overridden for backups. */ async function signBlueprint(ephemeral: PrivateKeyAccount, blueprint: UnsignedTx, nonce?: number): Promise<`0x${string}`> { - const txData = blueprint.txData as unknown as { to: `0x${string}`; data: `0x${string}`; value?: string }; + const txData = blueprint.txData as EvmTransactionData; const chainId = CHAIN_IDS[blueprint.network]; if (!chainId) { throw new Error(`No chain id mapped for ${blueprint.network}`); } return ephemeral.signTransaction({ chainId, - data: txData.data, - gas: 600_000n, - // validatePresignedTxs enforces the blueprint's fee minimums (3 gwei floor on Polygon). - maxFeePerGas: 5_000_000_000n, - maxPriorityFeePerGas: 5_000_000_000n, + data: txData.data as `0x${string}`, + gas: BigInt(txData.gas), + maxFeePerGas: BigInt(txData.maxFeePerGas ?? "0") * PRESIGNED_EVM_FEE_MULTIPLIER, + maxPriorityFeePerGas: BigInt(txData.maxPriorityFeePerGas ?? "0") * PRESIGNED_EVM_FEE_MULTIPLIER, nonce: nonce ?? blueprint.nonce, - to: txData.to, + to: txData.to as `0x${string}`, type: "eip1559", value: BigInt(txData.value ?? "0") }); diff --git a/apps/api/src/tests/corridors/mxn-onramp.scenario.test.ts b/apps/api/src/tests/corridors/mxn-onramp.scenario.test.ts index c941c3eac..037229980 100644 --- a/apps/api/src/tests/corridors/mxn-onramp.scenario.test.ts +++ b/apps/api/src/tests/corridors/mxn-onramp.scenario.test.ts @@ -3,12 +3,15 @@ import { ALFREDPAY_ERC20_DECIMALS, ALFREDPAY_ERC20_TOKEN, AlfredpayOnrampStatus, + type EvmTransactionData, EvmToken, FiatToken, Networks, + PRESIGNED_EVM_FEE_MULTIPLIER, RampDirection, type RampPhase } from "@vortexfi/shared"; +import Big from "big.js"; import { decodeFunctionData, encodeFunctionData, erc20Abi, parseTransaction, parseUnits } from "viem"; import { generatePrivateKey, privateKeyToAccount, type PrivateKeyAccount } from "viem/accounts"; import phaseProcessor from "../../api/services/phases/phase-processor"; @@ -195,6 +198,10 @@ describe("MXN onramp direct corridor (spei → USDT on Polygon)", () => { expect(mintAmountRaw).toBeGreaterThan(0n); const amountRaw = parseUnits(quote.outputAmount, ALFREDPAY_ERC20_DECIMALS); + const registered = await RampState.findByPk(ramp.id); + const transferBlueprint = registered?.unsignedTxs.find(tx => tx.phase === "destinationTransfer"); + if (!transferBlueprint) throw new Error("destinationTransfer blueprint missing"); + const transferTxData = transferBlueprint.txData as EvmTransactionData; async function signTransfer(recipient: `0x${string}`, nonce: number): Promise<`0x${string}`> { return ephemeral.signTransaction({ chainId: 137, @@ -203,10 +210,10 @@ describe("MXN onramp direct corridor (spei → USDT on Polygon)", () => { args: [recipient, amountRaw], functionName: "transfer" }), - gas: 100_000n, - // validatePresignedTxs enforces a 3 gwei floor on Polygon fees. - maxFeePerGas: 5_000_000_000n, - maxPriorityFeePerGas: 5_000_000_000n, + gas: BigInt(transferTxData.gas), + maxFeePerGas: BigInt(transferTxData.maxFeePerGas ?? "0") * PRESIGNED_EVM_FEE_MULTIPLIER, + maxPriorityFeePerGas: + BigInt(transferTxData.maxPriorityFeePerGas ?? "0") * PRESIGNED_EVM_FEE_MULTIPLIER, nonce, to: ALFREDPAY_ERC20_TOKEN, type: "eip1559" @@ -244,16 +251,17 @@ describe("MXN onramp direct corridor (spei → USDT on Polygon)", () => { const feeBlueprints = (rampState.unsignedTxs ?? []).filter(tx => tx.phase === "distributeFees"); const signedFeeTransfers: `0x${string}`[] = []; for (const blueprint of feeBlueprints) { - const blueprintData = blueprint.txData as unknown as { to: `0x${string}`; data: `0x${string}` }; + const blueprintData = blueprint.txData as EvmTransactionData; const signFee = (nonce: number) => ephemeral.signTransaction({ chainId: 137, - data: blueprintData.data, - gas: 100_000n, - maxFeePerGas: 5_000_000_000n, - maxPriorityFeePerGas: 5_000_000_000n, + data: blueprintData.data as `0x${string}`, + gas: BigInt(blueprintData.gas), + maxFeePerGas: BigInt(blueprintData.maxFeePerGas ?? "0") * PRESIGNED_EVM_FEE_MULTIPLIER, + maxPriorityFeePerGas: + BigInt(blueprintData.maxPriorityFeePerGas ?? "0") * PRESIGNED_EVM_FEE_MULTIPLIER, nonce, - to: blueprintData.to, + to: blueprintData.to as `0x${string}`, type: "eip1559" }); const feeBackups: Record = {}; @@ -386,18 +394,20 @@ describe("MXN onramp direct corridor (spei → USDT on Polygon)", () => { const setup = await setUpRegisteredRamp(); // Quote: 2000 MXN mints 100 USDT. A 1% target promises the user 101 USDT - // after fees, so Vortex contributes 2 USDT: 1 for the rate improvement and - // 1 that economically offsets the separately collected fee. + // after fees, so Vortex contributes 2 USDT plus the dynamic destination + // network fee: 1 for the rate improvement and the rest to offset fees. const quote = await QuoteTicket.findByPk(setup.quoteId); const metadata = getFlowMetadata(quote?.metadata); + const networkFeeUsd = new Big(metadata.globals.fees.usd.network); expect(Number(quote?.outputAmount)).toBe(101); + expect(networkFeeUsd.gt(0)).toBe(true); expect(Number(metadata.globals.fees?.usd?.vortex)).toBe(1); const preSwap = metadata.blocks.subsidizePreSwap as { subsidyAmountInOutputTokenDecimal: string; feeReserveRaw: string }; - expect(Number(preSwap.subsidyAmountInOutputTokenDecimal)).toBe(2); - expect(preSwap.feeReserveRaw).toBe(parseUnits("1", 6).toString()); + expect(new Big(preSwap.subsidyAmountInOutputTokenDecimal).toFixed(6)).toBe(networkFeeUsd.plus(2).toFixed(6)); + expect(preSwap.feeReserveRaw).toBe(parseUnits(networkFeeUsd.plus(1).toFixed(6), 6).toString()); // Registration prepared ONE Polygon distributeFees transfer (vortex only) - // paying the 1 USDT residual; setUpRegisteredRamp presigned it. + // paying the 1 USDT fee plus the network fee; setUpRegisteredRamp presigned it. expect(setup.signedFeeTransfers).toHaveLength(1); scriptHappyWorld(setup); @@ -407,10 +417,12 @@ describe("MXN onramp direct corridor (spei → USDT on Polygon)", () => { expect(final?.currentPhase).toBe("complete"); expect(final?.phaseHistory.map(entry => entry.phase)).toEqual(HAPPY_PATH_PHASES); - // Destination received the promised net 101 USDT; the fee metadata and - // on-chain collection remain the full 1 USDT. + // Destination received the promised net 101 USDT; Vortex receives its + // full 1 USDT fee plus the priced destination network fee. expect(world.evm.erc20Balance(Networks.Polygon, ALFREDPAY_ERC20_TOKEN, setup.destination)).toBe(parseUnits("101", 6)); - expect(world.evm.erc20Balance(Networks.Polygon, ALFREDPAY_ERC20_TOKEN, vortexPayout)).toBe(parseUnits("1", 6)); + expect(world.evm.erc20Balance(Networks.Polygon, ALFREDPAY_ERC20_TOKEN, vortexPayout)).toBe( + parseUnits(networkFeeUsd.plus(1).toFixed(6), 6) + ); expect(submissionsOf(setup.signedFeeTransfers[0])).toBe(1); }, 30000 @@ -439,7 +451,9 @@ describe("MXN onramp direct corridor (spei → USDT on Polygon)", () => { // used to collapse the two distributeFees transfers into one. const setup = await setUpRegisteredRamp({ pricingPartner: partner }); const quote = await QuoteTicket.findByPk(setup.quoteId); - expect(Number(quote?.outputAmount)).toBe(98); + const networkFeeUsd = new Big(getFlowMetadata(quote?.metadata).globals.fees.usd.network); + expect(networkFeeUsd.gt(0)).toBe(true); + expect(new Big(quote?.outputAmount ?? 0).toFixed(6)).toBe(new Big(98).minus(networkFeeUsd).toFixed(6)); expect(setup.signedFeeTransfers).toHaveLength(2); const merged = await RampState.findByPk(setup.rampId); expect(merged?.presignedTxs?.filter(tx => tx.phase === "distributeFees")).toHaveLength(2); @@ -463,7 +477,9 @@ describe("MXN onramp direct corridor (spei → USDT on Polygon)", () => { expect(afterFirstRun?.errorLogs.some(log => log.error.includes("requires reconciliation"))).toBe(true); // The first transfer was paid exactly once; the second never credited anyone. expect(submissionsOf(setup.signedFeeTransfers[0])).toBe(1); - expect(world.evm.erc20Balance(Networks.Polygon, ALFREDPAY_ERC20_TOKEN, vortexPayout)).toBe(parseUnits("1", 6)); + expect(world.evm.erc20Balance(Networks.Polygon, ALFREDPAY_ERC20_TOKEN, vortexPayout)).toBe( + parseUnits(networkFeeUsd.plus(1).toFixed(6), 6) + ); expect(world.evm.erc20Balance(Networks.Polygon, ALFREDPAY_ERC20_TOKEN, partnerPayout)).toBe(0n); const operations = await FinancialOperation.findAll({ where: { phase: "distributeFees", scopeId: setup.rampId, scopeType: "ramp" } @@ -477,7 +493,9 @@ describe("MXN onramp direct corridor (spei → USDT on Polygon)", () => { // halts on the ambiguous one again — no recipient is ever double-paid. await phaseProcessor.processRamp(setup.rampId); expect(submissionsOf(setup.signedFeeTransfers[0])).toBe(1); - expect(world.evm.erc20Balance(Networks.Polygon, ALFREDPAY_ERC20_TOKEN, vortexPayout)).toBe(parseUnits("1", 6)); + expect(world.evm.erc20Balance(Networks.Polygon, ALFREDPAY_ERC20_TOKEN, vortexPayout)).toBe( + parseUnits(networkFeeUsd.plus(1).toFixed(6), 6) + ); }, 30000 ); @@ -612,7 +630,7 @@ describe("MXN onramp direct corridor (spei → USDT on Polygon)", () => { expect(final?.currentPhase).toBe("failed"); expect(final?.phaseHistory.map(entry => entry.phase)).not.toContain("complete"); expect(final?.processingLock).toEqual({ locked: false, lockedAt: null }); - expect(final?.errorLogs.some(log => log.error.includes("recipient mismatch"))).toBe(true); + expect(final?.errorLogs.some(log => log.error.includes("does not match expected data"))).toBe(true); // The mismatching transfer must never reach the chain, and nobody gets paid. expect(submissionsOf(setup.signedTransfer)).toBe(0); diff --git a/apps/api/src/tests/quote-consumption.invariants.test.ts b/apps/api/src/tests/quote-consumption.invariants.test.ts index 3d9aad3ba..6ad58e593 100644 --- a/apps/api/src/tests/quote-consumption.invariants.test.ts +++ b/apps/api/src/tests/quote-consumption.invariants.test.ts @@ -1,5 +1,14 @@ import { afterAll, beforeAll, beforeEach, describe, expect, it } from "bun:test"; -import { EvmToken, FiatToken, Networks, RampDirection, type PresignedTx, type UnsignedTx } from "@vortexfi/shared"; +import { + type EvmTransactionData, + EvmToken, + FiatToken, + Networks, + PRESIGNED_EVM_FEE_MULTIPLIER, + RampDirection, + type PresignedTx, + type UnsignedTx +} from "@vortexfi/shared"; import { generatePrivateKey, privateKeyToAccount, type PrivateKeyAccount } from "viem/accounts"; import QuoteTicket from "../models/quoteTicket.model"; import RampState from "../models/rampState.model"; @@ -76,15 +85,15 @@ describe("quote consumption invariants (BRL onramp)", () => { } async function signBlueprint(account: PrivateKeyAccount, blueprint: UnsignedTx, nonce: number): Promise<`0x${string}`> { - const txData = blueprint.txData as { data: `0x${string}`; to: `0x${string}`; value?: string }; + const txData = blueprint.txData as EvmTransactionData; return account.signTransaction({ chainId: 8453, - data: txData.data, - gas: 600_000n, - maxFeePerGas: 10_000_000_000n, - maxPriorityFeePerGas: 10_000_000_000n, + data: txData.data as `0x${string}`, + gas: BigInt(txData.gas), + maxFeePerGas: BigInt(txData.maxFeePerGas ?? "0") * PRESIGNED_EVM_FEE_MULTIPLIER, + maxPriorityFeePerGas: BigInt(txData.maxPriorityFeePerGas ?? "0") * PRESIGNED_EVM_FEE_MULTIPLIER, nonce, - to: txData.to, + to: txData.to as `0x${string}`, type: "eip1559", value: BigInt(txData.value ?? "0") }); diff --git a/apps/api/src/tests/quote-pricing.golden.test.ts b/apps/api/src/tests/quote-pricing.golden.test.ts index ea18baf8c..b8c882661 100644 --- a/apps/api/src/tests/quote-pricing.golden.test.ts +++ b/apps/api/src/tests/quote-pricing.golden.test.ts @@ -6,9 +6,10 @@ import { startTestApp, type TestApp } from "../test-utils/test-app"; /** * Golden tests for the quote pricing math. Every external input is pinned: - * FakePrices rates (BRL 5/USD, USDC 1/USD), FakeBrla pay-in/pay-out rate 1, - * and a scripted Nabla quoter at 0.18 USDC per BRLA. Under those inputs the - * fee/output values below are pure functions of the pricing engines. + * FakePrices rates (BRL 5/USD, ETH 2,500/USD, USDC 1/USD), FakeEvm fees + * (1 gwei), FakeBrla pay-in/pay-out rate 1, and a scripted Nabla quoter at + * 0.18 USDC per BRLA. Under those inputs the fee/output values below are pure + * functions of the pricing engines. * * A diff here means the pricing math changed. If that is intentional, update * the goldens consciously and call out the fee impact in the PR description — @@ -139,9 +140,9 @@ describe("quote pricing goldens (fixed input matrix)", () => { inputAmount: "100.00", inputCurrency: "BRL", network: "base", - networkFeeFiat: "0", - networkFeeUsd: "0", - outputAmount: "17.982", + networkFeeFiat: "2.115", + networkFeeUsd: "0.423", + outputAmount: "17.559", outputCurrency: "USDC", partnerFeeFiat: "0", partnerFeeUsd: "0", @@ -150,8 +151,8 @@ describe("quote pricing goldens (fixed input matrix)", () => { processingFeeUsd: "0.02", rampType: "BUY", to: "base", - totalFeeFiat: "0.10", - totalFeeUsd: "0.020000", + totalFeeFiat: "2.22", + totalFeeUsd: "0.443000", vortexFeeFiat: "0", vortexFeeUsd: "0" }, diff --git a/apps/api/src/tests/sdk-contract.alfredpay-onramp.test.ts b/apps/api/src/tests/sdk-contract.alfredpay-onramp.test.ts index c86b9d836..490a237a7 100644 --- a/apps/api/src/tests/sdk-contract.alfredpay-onramp.test.ts +++ b/apps/api/src/tests/sdk-contract.alfredpay-onramp.test.ts @@ -278,6 +278,7 @@ describe("SDK ↔ API contract (Alfredpay onramps, fiat → USDT on Polygon)", ( for (const fee of feeFields) { expect(Number.isFinite(Number(fee))).toBe(true); } + expect(Number(quote.networkFeeUsd)).toBeGreaterThan(0); expect(quote.feeCurrency).toBeTruthy(); // registerRamp runs the SDK's full internal Alfredpay BUY flow: ephemeral @@ -303,9 +304,9 @@ describe("SDK ↔ API contract (Alfredpay onramps, fiat → USDT on Polygon)", ( expect(rampProcess.achPaymentData?.reference).toBeTruthy(); // The ephemeral surface of the direct Alfredpay BUY route: the - // destination transfer plus the Polygon dust cleanup. + // destination and network-fee transfers plus Polygon dust cleanup. const unsigned = rampProcess.unsignedTxs ?? []; - expect(unsigned.map(tx => tx.phase).sort()).toEqual(["destinationTransfer", "polygonCleanup"]); + expect(unsigned.map(tx => tx.phase).sort()).toEqual(["destinationTransfer", "distributeFees", "polygonCleanup"]); expect(unsigned.every(tx => tx.network === Networks.Polygon)).toBe(true); const destinationTransferTx = unsigned.find(tx => tx.phase === "destinationTransfer"); if (!destinationTransferTx) { @@ -320,7 +321,7 @@ describe("SDK ↔ API contract (Alfredpay onramps, fiat → USDT on Polygon)", ( expect(stored?.userId).toBe(userId); expect(stored?.state.alfredpayTransactionId).toBeTruthy(); const presigned = stored?.presignedTxs ?? []; - expect(presigned.map(tx => tx.phase).sort()).toEqual(["destinationTransfer", "polygonCleanup"]); + expect(presigned.map(tx => tx.phase).sort()).toEqual(["destinationTransfer", "distributeFees", "polygonCleanup"]); const presignedTransfer = presigned.find(tx => tx.phase === "destinationTransfer"); if (!presignedTransfer) { throw new Error("No presigned destinationTransfer"); diff --git a/apps/frontend/src/stores/quote/useQuoteStore.ts b/apps/frontend/src/stores/quote/useQuoteStore.ts index 09362dc72..30f2f27cb 100644 --- a/apps/frontend/src/stores/quote/useQuoteStore.ts +++ b/apps/frontend/src/stores/quote/useQuoteStore.ts @@ -75,7 +75,8 @@ const friendlyErrorMessages: Record = { [QuoteError.FailedToCalculatePreNablaDeductibleFees]: "pages.swap.error.tryDifferentAmount", [QuoteError.FailedToCalculateFeeComponents]: "pages.swap.error.tryDifferentAmount", [QuoteError.UnsupportedCurrency]: "pages.swap.error.unsupportedCurrency", - [QuoteError.AnchorTemporarilyUnavailable]: "pages.swap.error.anchorUnavailable" + [QuoteError.AnchorTemporarilyUnavailable]: "pages.swap.error.anchorUnavailable", + [QuoteError.NetworkFeesTooHigh]: "pages.swap.error.networkFeesTooHigh" }; function getFriendlyErrorMessage(error: unknown) { diff --git a/apps/frontend/src/translations/en.json b/apps/frontend/src/translations/en.json index 0ba943eff..52c7f0a81 100644 --- a/apps/frontend/src/translations/en.json +++ b/apps/frontend/src/translations/en.json @@ -1582,6 +1582,7 @@ "buy": "Maximum buy amount is {{maxAmountUnits}} {{assetSymbol}}.", "sell": "Maximum sell amount is {{maxAmountUnits}} {{assetSymbol}}." }, + "networkFeesTooHigh": "Destination network fees are temporarily too high. Please try again later.", "preNablaDeductibleFees": "Failed to get quote. Please try again with a different amount.", "quoteNotFound": "Quote not found", "tryDifferentAmount": "Failed to calculate quote. Please try a different amount.", diff --git a/apps/frontend/src/translations/pt.json b/apps/frontend/src/translations/pt.json index 1a29211ad..94628c322 100644 --- a/apps/frontend/src/translations/pt.json +++ b/apps/frontend/src/translations/pt.json @@ -1586,6 +1586,7 @@ "buy": "O valor máximo de compra é {{maxAmountUnits}} {{assetSymbol}}.", "sell": "O valor máximo de venda é {{maxAmountUnits}} {{assetSymbol}}." }, + "networkFeesTooHigh": "As taxas da rede de destino estão temporariamente muito altas. Tente novamente mais tarde.", "preNablaDeductibleFees": "Falha ao obter cotação. Por favor, tente novamente com um valor diferente.", "quoteNotFound": "Cotação não encontrada", "tryDifferentAmount": "Falha ao calcular cotação. Tente um valor diferente.", diff --git a/docs/api/wire-contract.snapshot.md b/docs/api/wire-contract.snapshot.md index 951851537..1bbf78c03 100644 --- a/docs/api/wire-contract.snapshot.md +++ b/docs/api/wire-contract.snapshot.md @@ -1176,7 +1176,7 @@ PriceResponseBase: { totalFee: number; } -QuoteError: enum QuoteError { AboveUpperLimitBuy = "Input amount exceeds maximum BUY limit of", AboveUpperLimitSell = "Output amount exceeds maximum SELL limit of", AnchorTemporarilyUnavailable = "This payment provider is temporarily unavailable. Please try again in a few minutes.", AssetHubNotSupportedForAlfredPay = "AssetHub is not supported for this currency. Please select a different network.", BelowLowerLimitBuy = "Input amount below minimum BUY limit of", BelowLowerLimitSell = "Output amount below minimum SELL limit of", FailedToCalculateFeeComponents = "Failed to calculate fee components", FailedToCalculatePreNablaDeductibleFees = "Failed to calculate pre-Nabla deductible fees", FailedToCalculateQuote = "Failed to calculate the quote. Please try a lower amount.", InputAmountForSwapMustBeGreaterThanZero = "Input amount for swap must be greater than 0", InputAmountTooLow = "Input amount too low. Please try a larger amount.", InputAmountTooLowToCoverCalculatedFees = "Input amount too low to cover calculated fees.", InputAmountTooLowToCoverFees = "Input amount too low to cover fees", InvalidNetworks = "Invalid 'networks' value: must be an array of valid network identifiers", InvalidRampType = "Invalid ramp type, must be \"BUY\" or \"SELL\"", LowLiquidity = "This route is temporarily unavailable due to low liquidity. Please try a smaller amount or check back soon.", MissingFromField = "BUY rampType requires 'from' parameter", MissingRequiredFields = "Missing required fields", MissingToField = "SELL rampType requires 'to' parameter", QuoteNotFound = "Quote not found", UnableToGetPendulumTokenDetails = "Unable to get Pendulum token details", UnsupportedCurrency = "Currency not supported" } +QuoteError: enum QuoteError { AboveUpperLimitBuy = "Input amount exceeds maximum BUY limit of", AboveUpperLimitSell = "Output amount exceeds maximum SELL limit of", AnchorTemporarilyUnavailable = "This payment provider is temporarily unavailable. Please try again in a few minutes.", AssetHubNotSupportedForAlfredPay = "AssetHub is not supported for this currency. Please select a different network.", BelowLowerLimitBuy = "Input amount below minimum BUY limit of", BelowLowerLimitSell = "Output amount below minimum SELL limit of", FailedToCalculateFeeComponents = "Failed to calculate fee components", FailedToCalculatePreNablaDeductibleFees = "Failed to calculate pre-Nabla deductible fees", FailedToCalculateQuote = "Failed to calculate the quote. Please try a lower amount.", InputAmountForSwapMustBeGreaterThanZero = "Input amount for swap must be greater than 0", InputAmountTooLow = "Input amount too low. Please try a larger amount.", InputAmountTooLowToCoverCalculatedFees = "Input amount too low to cover calculated fees.", InputAmountTooLowToCoverFees = "Input amount too low to cover fees", InvalidNetworks = "Invalid 'networks' value: must be an array of valid network identifiers", InvalidRampType = "Invalid ramp type, must be \"BUY\" or \"SELL\"", LowLiquidity = "This route is temporarily unavailable due to low liquidity. Please try a smaller amount or check back soon.", MissingFromField = "BUY rampType requires 'from' parameter", MissingRequiredFields = "Missing required fields", MissingToField = "SELL rampType requires 'to' parameter", NetworkFeesTooHigh = "Destination network fees are temporarily too high. Please try again later.", QuoteNotFound = "Quote not found", UnableToGetPendulumTokenDetails = "Unable to get Pendulum token details", UnsupportedCurrency = "Currency not supported" } QuoteFeeStructure: { anchor: string; diff --git a/docs/security-spec/03-ramp-engine/block-flow-architecture.md b/docs/security-spec/03-ramp-engine/block-flow-architecture.md index e2eecb522..1a39a0a51 100644 --- a/docs/security-spec/03-ramp-engine/block-flow-architecture.md +++ b/docs/security-spec/03-ramp-engine/block-flow-architecture.md @@ -62,6 +62,8 @@ runtime validation, and startup wiring checks therefore remain mandatory. 11. **Schema evolution.** A change that reinterprets a persisted field MUST increment its schema version and either retain its old reader or provide an explicit, one-directional migration. Missing fields MUST NOT silently acquire a new meaning. + Executor-relevant optional globals MUST additionally carry their own program + version when absence remains a supported legacy program. 12. **Namespaced ownership.** Blocks MUST read their own metadata and state by context key. Compatibility projection into legacy top-level `StateMetadata` or API response fields MUST reject conflicting destinations. New executor dependencies MUST use @@ -105,9 +107,11 @@ runtime validation, and startup wiring checks therefore remain mandatory. | Old or manually edited JSONB is cast into a new TypeScript type | Versioned envelope validation before registration, start, or recovery | | Two blocks flatten different values into one legacy field | Compatibility merge rejects conflicting values | | An old flow implementation is removed too early | Per-variant deployment/removal check against unexpired pending quotes and resumable ramps; update and start reject expired initial ramps before lifecycle hooks | +| An old worker consumes newly introduced executor metadata during a rolling deploy | Keep new quote production behind a default-off activation flag; deploy the dual legacy/v2 executor everywhere before enabling the new program | | A provider accepts an order and the database transaction later rolls back | Independent durable financial-operation claim; retry reuses the confirmed response or halts on ambiguity | | Two workers attempt the same external side effect | Unique operation key and atomic `not_started` → `submitted` claim | | A provider has no idempotency-key API | Unknown outcomes require reconciliation; automatic repetition is forbidden | +| A live balance changes between funding retries | Dynamic funding request hashes bind the stable target balance, not the observed shortfall; program-specific attempt classes prevent collision with historical operations | ## Audit Checklist diff --git a/docs/security-spec/03-ramp-engine/ephemeral-accounts.md b/docs/security-spec/03-ramp-engine/ephemeral-accounts.md index 4720701c5..a17963b14 100644 --- a/docs/security-spec/03-ramp-engine/ephemeral-accounts.md +++ b/docs/security-spec/03-ramp-engine/ephemeral-accounts.md @@ -15,7 +15,7 @@ Ephemeral accounts may be created on: - **AssetHub** — For XCM transfers to/from Pendulum and Hydration - **Hydration** — For Hydration DEX swaps and XCM transfers - **Base** — Hub for all BRL **and EUR** on/off-ramp flows. Hosts BRLA mint/burn (via Avenia), Mykobo EUR settlement (EURC on Base), Nabla-on-EVM swap (USDC↔BRLA, USDC↔EURC), and EVM fee distribution via sequential ERC-20 transfers. -- **Cross-chain EVM destinations** — BUY ramps may bridge to supported destination networks, including Ethereum. The destination ephemeral is prefunded with a static per-network native-token reserve before its presigned payout transaction is submitted; Ethereum uses `ETHEREUM_EPHEMERAL_STARTING_BALANCE_UNITS`. There is no generic destination-chain native-dust sweep, so unused gas reserve can remain on the ephemeral account. +- **EVM destinations** — BUY ramps may pay out on any supported EVM destination. Every destination reserve is derived from a validated presigned payout whose signer, chain, nonce, target, calldata, value, gas limit, and fee bounds are rebound to the matching server blueprint at execution; both early funding and native final-settlement reserve calculation use the same validator. Only the native-balance shortfall is funded. On fee-collecting flows, the quote's network fee includes the destination funding transaction plus payout, priced in the chain's native currency with `EVM_DESTINATION_NETWORK_FEE_MARGIN_BPS`; quotes above `EVM_DESTINATION_MAX_EXECUTION_FEE_USD` are rejected. The quote persists the resulting absolute fee and gas ceilings. Arbitrum gas limits include NodeInterface parent-chain poster gas. For Base and Base Sepolia, both transactions' L1 security fees are included through GasPriceOracle `getL1FeeUpperBound`; the payout reserve holds the persisted maximum rather than calling the oracle again after bridge delivery. Registration preflights the envelope before provider ticket creation, and execution re-estimates immediately before the treasury send, applies explicit EIP-1559 caps, and pauses recoverably without sending if live fees exceed it. Quotes without funding-program metadata retain the historical static-funding path for in-flight compatibility. Exact provider-token direct payouts have no fee-distribution phase, so they do not advertise an uncollectible destination fee and remain covered by their existing source reserve. Other source-chain reserves remain separate because they cover the full upstream phase plan rather than only the final payout. There is no generic destination-chain native-dust sweep, so the difference between the signed fee cap and actual gas consumption can remain on the ephemeral account. ### Cleanup Architecture @@ -40,6 +40,14 @@ The cleanup worker (`cleanup.worker.ts`) selects ramps where `currentPhase ∈ { 5. **Cleanup transactions MUST be submitted with the server's cosigner authority** — The ephemeral account's keypair is generated client-side and may not be available post-ramp. Cleanup relies on the server's cosigner (multisig on Substrate) to authorize the sweep. 6. **The Moonbeam 3-hour delay MUST be enforced before cleanup** — SquidRouter cross-chain swaps can trigger refunds via Axelar. Cleaning up before refunds land means the refunded tokens are sent to an account nobody controls. 7. **Cleanup failures MUST be logged and retried** — A single cleanup failure should not cause permanent fund loss. The worker should re-attempt on subsequent cycles. +8. **Destination funding MUST NOT trust unbounded client gas fields** — Presigned gas limits and fee caps must remain within the server-issued signing envelope at API validation and again when execution calculates the native reserve. +9. **Base-family destination fees MUST include the L1 security component** — Quotes for Base/Base Sepolia must price both L2 execution and L1 data publication for the treasury funding transfer and payout. The ephemeral reserve for a signed payout must include the persisted accepted payout L1 maximum. +10. **Treasury funding MUST remain inside the persisted quote envelope** — Immediately before a dynamic funding transfer, the backend must re-estimate fees, compare L2 `maxFeePerGas`, chain-specific gas requirements, and Base L1 upper bounds to the absolute persisted maxima, and perform no send when any limit is exceeded. +11. **Dynamic treasury transfers MUST carry explicit fee caps** — The funding transaction must use the checked gas limit, `maxFeePerGas`, and `maxPriorityFeePerGas`; relying on wallet defaults after performing the quote-envelope check would create a time-of-check/time-of-use mismatch. +12. **Funding retries MUST bind a stable target** — A dynamic funding operation's request hash must bind the required target balance and v2 program identity, not a live balance-derived shortfall that can change after a confirmed send. +13. **Funding-program rollout MUST be two phase** — New quote production stays disabled until every API and worker replica supports both legacy static metadata and program v2. +14. **A confirmed funding operation MUST replay before live-fee preflight** — Fee movement after a receipt-confirmed send must not prevent the journal from returning that persisted result; the live envelope guard applies only before a genuinely new treasury broadcast. +15. **Persisted funding envelopes MUST be validated at runtime** — Presence selects program v2 only after all version, network, transfer-kind, bounded positive fee/gas, and Base-family L1 fields pass structural validation. Absence alone selects the legacy program. ## Threat Vectors & Mitigations @@ -47,7 +55,11 @@ The cleanup worker (`cleanup.worker.ts`) selects ramps where `currentPhase ∈ { |---|---|---| | **Stuck funds on failed ramp** | Ramp fails after `fundEphemeral` but before any swap executes. Tokens sit on ephemeral Pendulum account. | The cleanup worker selects on `currentPhase ∈ {"complete", "failed", "timedOut"}`, so failed/timed-out ramps with funded ephemerals on Pendulum/Moonbeam/Polygon/Hydration are picked up by their respective post-process handlers. F-044 is therefore largely addressed at the worker-selection level. Remaining per-chain gaps: `BaseChainPostProcessHandler` is registered but its `shouldProcess` returns `false` unless `currentPhase === "complete"`, so **failed/timed-out Base ramps are never swept** despite the worker selecting them; AssetHub is a no-op stub. | | **Stuck ERC-20 dust on Base** | BRL on/off-ramps could leave BRLA/USDC residuals on the Base ephemeral. | **Mitigated.** `BaseChainPostProcessHandler` sweeps both BRLA and USDC after `currentPhase === "complete"` via presigned `approve` + funding-key `transferFrom`. ETH gas dust is not swept. | -| **Native gas dust on cross-chain EVM destinations** | A static native-token reserve funds a destination ephemeral for its payout and possible fallback transactions. Any unused reserve remains after the ramp. | **Known gap.** Funding is deliberately conservative to avoid stranding the user's bridged tokens, but there is no generic destination-chain native-dust sweep. Prefer deriving the reserve from the signed transactions' fee envelopes and add a recovery path before lowering the static amount. | +| **Native gas dust on cross-chain EVM destinations** | A native-token reserve funds a destination ephemeral for its payout. Any unused reserve remains after the ramp. | **Known gap.** All EVM destination funding is derived from the signed transaction fee cap and only the balance shortfall is sent. Quotes charge the estimated funding-plus-payout execution cost rather than the maximum signed reserve. The remaining `maxFeePerGas` versus effective-gas-price difference is intentionally accepted until the flow moves to a smart-contract or paymaster model. | +| **Client-inflated destination reserve** | The client signs the expected payout call with an excessive gas limit or fee cap, causing Vortex to transfer a large native balance to an ephemeral whose key the client controls. | **Mitigated.** API validation requires exact server gas and bounds both fee fields by the production 3× multiplier for primaries and backups. `fundEphemeral` re-binds the signed transaction to the unsigned blueprint before computing its reserve. | +| **Base L1 fee omitted or stale** | A Base/Base Sepolia payout is funded only for L2 execution, or uses an exact fee read long before payout, even though it later pays for Ethereum data publication. | **Mitigated.** Quote pricing persists conservative funding and payout GasPriceOracle upper bounds after margin. The signed payout funding requirement reserves the persisted payout maximum and final settlement performs no late oracle call. | +| **Arbitrum poster gas omitted** | A funding or payout transaction is capped at 21,000/100,000 although Arbitrum accounts for parent-chain calldata posting in its gas limit. | **Mitigated.** Quote-time NodeInterface estimates are added to both persisted gas limits and checked again before funding. | +| **Execution-time fee spike** | Fees rise after quote/registration and wallet defaults submit a treasury funding transaction whose cost no longer matches what the user was quoted. | **Mitigated.** The live L2 estimate and both Base L1 upper bounds (funding and payout) are checked against the persisted envelope before the financial operation is claimed; excessive drift produces a recoverable pause and no send. Accepted transactions use those exact checked EIP-1559 caps. | | **No-op AssetHub cleanup** | An AssetHub ephemeral holds residual tokens after an AssetHub-routed ramp. The registered `AssetHubPostProcessHandler` always returns `shouldProcess=false`. | **Known gap.** The handler is a placeholder. If AssetHub ephemerals can hold residual tokens, this needs to be implemented; otherwise the handler can be removed and the gap accepted. | | **SEPA ramp exclusion (historical)** | An older revision of the worker excluded `from: "sepa"` from cleanup. If still in place, residual Monerium EURe on the Polygon ephemeral from a failed SEPA onramp would be unrecoverable. | **No longer exclusionary.** The `cleanup.worker.ts` query no longer filters on `from`; SEPA ramps are now eligible. The PolygonPostProcessHandler runs against them and sweeps any user-approved residual via `transferFrom`. F-046 is therefore resolved by the worker change. | | **Premature Moonbeam cleanup** | Cleanup runs before the 3-hour SquidRouter refund window expires. Refunded tokens land on an already-swept ephemeral account. | MoonbeamPostProcessHandler enforces `MOONBEAM_CLEANUP_DELAY_MS` (3 hours). Verify this delay is checked before every Moonbeam cleanup, not just on first attempt. | @@ -66,6 +78,9 @@ The cleanup worker (`cleanup.worker.ts`) selects ramps where `currentPhase ∈ { - [x] HydrationPostProcessHandler submits `hydrationCleanup` extrinsic from ramp state — verified - [ ] **AssetHubPostProcessHandler is a no-op stub** (`shouldProcess` always returns `false`). Either implement an AssetHub cleanup or remove the handler from the registry. - [x] **Base post-process handler implemented** (`BaseChainPostProcessHandler`). Sweeps residual BRLA/USDC/EURC/AxlUSDC on Base ephemerals to the funding account via presigned `approve` + funding-key `transferFrom`. ETH gas dust is not swept (accepted residual). +- [x] Dynamic EVM destination presigns cannot expand treasury liability beyond exact server gas and the shared 3× fee multiplier; backups and execution-time blueprint binding are covered. +- [x] Base/Base Sepolia quote pricing and payout reserve include GasPriceOracle L1 security fees. +- [x] Dynamic treasury funding rechecks the persisted L2/Base-L1 quote envelope immediately before broadcast and sends with explicit EIP-1559 caps; excessive drift pauses without treasury spend. - [x] Cleanup worker runs every 5 minutes via `node-cron` — verified - [x] Cleanup worker processes at most 5 ramps per cycle — verified - [x] Cleanup worker marks ramps as cleaned (`postProcessDone: true` via `postCompleteState.cleanup.cleanupCompleted`) to prevent re-processing — verified diff --git a/docs/security-spec/03-ramp-engine/fee-integrity.md b/docs/security-spec/03-ramp-engine/fee-integrity.md index e14c331e8..b642cddad 100644 --- a/docs/security-spec/03-ramp-engine/fee-integrity.md +++ b/docs/security-spec/03-ramp-engine/fee-integrity.md @@ -85,6 +85,51 @@ always occur only after all user-facing phases is incorrect. - Distributed fees are final. The current implementation has no automatic clawback if a later delivery phase fails. +### Dynamic EVM destination execution fees + +- BUY flows with a non-direct EVM payout quote the native execution cost of both the + treasury-to-ephemeral funding transfer and the presigned payout. Base and Base Sepolia + additionally query the GasPriceOracle for each transaction's L1 security fee upper + bound; omitting this component underprices a fee charged on every normal Base-family + transaction. +- The quote applies `EVM_DESTINATION_NETWORK_FEE_MARGIN_BPS` once and persists the + resulting absolute `maximumFeePerGas`, funding/payout gas limits, and Base L1 + maxima. Runtime acceptance never reconstructs a ceiling from current deployment + configuration. Arbitrum gas limits add the NodeInterface parent-chain poster-gas + component; a plain transfer is not assumed to fit in 21,000 gas there. +- The persisted-metadata read boundary validates every funding-program-v2 field before + treasury arithmetic: the version and EVM network, transfer kind, positive bounded + decimal-integer fee/gas fields, positive execution-fee decimal, and paired Base-family + L1 maxima. An absent envelope remains the legacy static program. +- Registration preflights the persisted envelope before provider registration hooks + can create an independently durable payment ticket, then checks the exact prepared + payout against the same absolute limits. +- Immediately before the treasury funding transfer, execution re-estimates the L2 fee + and both Base L1 upper bounds (funding and payout). If any exceeds the persisted absolute envelope, + the phase pauses recoverably before claiming or broadcasting a new financial operation. + The journal resolves an already-confirmed operation before this preflight, so a + receipt-confirmed send remains replayable after a balance-poll timeout even if fees + subsequently rise. An accepted new transfer carries the checked gas and EIP-1559 fee + caps explicitly. +- The native amount delivered to the ephemeral is based on the bounded signed payout + liability, not arbitrary client fields. Before both funding-time and native-settlement + reserve calculations, the signed payout's identity, signer, chain, nonce, target, + calldata, value, exact gas limit, and lower/upper fee bounds must match the server + blueprint through the production EVM validator. + Base-family payouts reserve the persisted maximum payout L1 fee, rather than an + early exact oracle value that can become stale before settlement. +- Funding metadata carries program version 2. Quotes without that metadata execute + the historical static-funding program and operation identities. Dynamic financial + operations use v2 attempt classes and bind their request hash to the stable target + balance, so a confirmed send can be replayed after an RPC-balance polling timeout. +- Dynamic quote production is opt-in through + `EVM_DYNAMIC_DESTINATION_FUNDING_ENABLED`. Deploy the dual-reader/dual-executor code + to every API and worker replica while disabled, then enable quote production. This + prevents an old worker from consuming v2 metadata during a rolling deployment. +- The residual between a signed/quoted cap and the effective fee is accepted native + dust for now. It is not solved by this policy and remains documented in + `ephemeral-accounts.md`; a future smart-contract or paymaster flow can eliminate it. + ### Alfredpay corridors: solvency and failure safety - **Charging** — the onramp deducts vortex/partner components from the provider mint diff --git a/docs/security-spec/03-ramp-engine/transaction-validation.md b/docs/security-spec/03-ramp-engine/transaction-validation.md index 2d9dd9014..49df9a2aa 100644 --- a/docs/security-spec/03-ramp-engine/transaction-validation.md +++ b/docs/security-spec/03-ramp-engine/transaction-validation.md @@ -49,6 +49,7 @@ The two layers together guarantee that the client cannot (a) sneak a malicious p 7. **Validation MUST occur before any presigned transaction is persisted or executed** — The `updateRamp` and `startRamp` flows must reject invalid transactions before merging them into ramp state. 8. **Ephemeral addresses submitted at `registerRamp` MUST be proven fresh on every chain their route signs on, before transactions are built** — Address format validation is insufficient. For each ephemeral type the client submits, the server MUST query the chains the quote's route actually signs on (`quoteToSigningNetworks`) and reject the registration if any check finds a non-fresh account. Freshness covers nonce **and** balance: Substrate `nonce === 0 && free === 0`; EVM `nonce === 0 && native balance === 0` (a nonce-0 EVM account can still hold funds). The route-derived set MUST be kept in sync with the route builders — under-listing a chain the ephemeral signs on silently reopens the freshness gap. Fail-closed on RPC errors. Without this, the server builds presigned transactions with assumed-fresh nonces, and execution halts mid-ramp on the first chain where the assumption breaks. See `02-signing-keys/ephemeral-accounts.md` invariant 7. 9. **Multi-account block preparation MUST preserve signer capabilities and nonce domains** — EVM and Substrate account metadata are supplied as typed capabilities to each phase. Nonces are allocated independently per `(network, signer)`. A transaction that consumes more than one nonce declares a positive `nonceSpan`. +10. **Client signatures MUST NOT expand platform gas liability beyond the server-issued envelope** — A raw EVM signature must preserve the unsigned gas limit exactly. `maxFeePerGas` and `maxPriorityFeePerGas` must be at least the server estimate but no greater than `PRESIGNED_EVM_FEE_MULTIPLIER` times that estimate (currently 3×, matching the shared production signer). The same checks apply independently to every backup transaction. Any execution-time treasury calculation derived from a presign must re-bind it to the matching unsigned blueprint as defense in depth. ## Threat Vectors & Mitigations @@ -59,7 +60,7 @@ The two layers together guarantee that the client cannot (a) sneak a malicious p | **Off-ramp SquidRouter bypass** | SELL-direction ramps previously skipped SquidRouter swap/approve validation entirely. Client could submit a swap routing funds to an attacker's EVM address. | **MITIGATED (F-041)**: SELL-direction `squidRouterApprove`/`squidRouterSwap` are now (a) rejected by `validatePresignedTxs` if a presigned tx is submitted for them, and (b) verified by-hash at the top of `FundEphemeralPhaseHandler.executePhase` via `verifyUserSubmittedSquidHashes` against the server-issued `to`/`data`/`value`/`signer`. The swap hash is mandatory; the approve hash is verified only when reported (pre-existing allowances make the approve tx optional — an unapproved swap fails its own on-chain receipt check). | | **User-wallet phase presigned-tx smuggling** | Client submits an unrelated EVM/Substrate presigned tx labeled with a user-wallet phase name (`moneriumOnrampMint`, `squidRouterApprove`/`Swap` for SELL, `squidRouterNoPermit*`). Previously `validatePresignedTxs` `continue`d on these phases, letting the tx through without content validation. | **MITIGATED**: `validatePresignedTxs` now throws `APIError(BAD_REQUEST)` for any presigned tx whose phase is in the user-wallet set. User-wallet phases are verified by on-chain hash + receipt + calldata only. | | **Transaction data substitution via metadata matching** | Client submits transactions with correct phase/network/nonce/signer metadata but different txData content. | **MITIGATED (F-043)**: `validatePresignedTxs` resolves the matching unsigned transaction by the same identity keys and performs content validation before `areAllTxsIncluded` is used as the final inclusion guard. | -| **EVM contract target or execution-parameter substitution** | Client signs a raw EVM transaction to an attacker-controlled contract, or signs the expected transaction with gas/fee parameters too low to execute reliably. | **MITIGATED (F-050)**: Raw signed EVM transactions are recovered and compared to the server-issued unsigned `to`, `data`, `value`, and `nonce`; gas limit and fee caps must be at least the server-issued values, and contract-creation transactions are rejected. | +| **EVM contract target or execution-parameter substitution** | Client signs a raw EVM transaction to an attacker-controlled contract, signs below the executable gas policy, or inflates gas/fee fields so a treasury prefund sends excessive native currency to the client-controlled ephemeral. | **MITIGATED (F-050)**: Raw signed EVM transactions are recovered and compared to the server-issued unsigned `to`, `data`, `value`, and `nonce`; the gas limit must match exactly, fee caps must remain between the server estimate and the shared production signer's 3× multiplier, every backup is checked, and contract-creation transactions are rejected. Both funding-time and native final-settlement treasury-liability calculations call that same complete validator again before deriving a reserve. | | **New phase/format added without validation** | A developer adds a new phase and the validator silently treats it as EVM because the phase type falls through to a default. | **MITIGATED (F-047)**: `getTransactionTypeForPhase` now throws for unknown phases instead of defaulting to EVM. | | **Non-fresh ephemeral submitted at registration** | Client submits an ephemeral address that already has on-chain history — non-zero nonce, or a funded native balance on a nonce-0 account. Backend builds presigned transactions assuming a clean account; execution halts mid-ramp on the first signed broadcast after subsidies/funding have already been committed. | **MITIGATED (F-072)**: `registerRamp` invokes `validateEphemeralAccountsFresh(ephemerals, quote)` after `normalizeAndValidateSigningAccounts`. For each ephemeral type the client provides, it checks the chains the quote's route signs on (`quoteToSigningNetworks`). Substrate: `nonce === 0 && free === 0`. EVM: `nonce === 0 && native balance === 0`. Fail-closed on RPC errors. | @@ -70,10 +71,10 @@ The two layers together guarantee that the client cannot (a) sneak a malicious p - [ ] **F-042**: Substrate transaction validation checks signer and decodable method, but NOT expected method, parameters, amounts, or destinations. - [x] **F-043**: `areAllTxsIncluded` remains metadata-only, but content substitution is blocked earlier by identity-keyed unsigned transaction lookup plus per-format content validation. - [x] **F-047**: `getTransactionTypeForPhase` throws on unknown phases instead of defaulting to EVM. -- [x] **F-050**: EVM validation checks raw transaction `to`, `data`, `value`, `nonce`, signer, chain ID, gas limit, and fee caps against the server-issued unsigned transaction; contract creation is rejected. Native-token destination transfers (where viem's `parseTransaction` returns `data: undefined`) are normalized: both sides of the calldata equality check coerce empty/undefined calldata to `"0x"` so legitimate native transfers are not rejected (`apps/api/src/api/services/transactions/validation.ts:126`). +- [x] **F-050**: EVM validation checks raw transaction `to`, `data`, `value`, `nonce`, signer, chain ID, gas limit, and fee caps against the server-issued unsigned transaction; gas is exact and fee caps are bounded by the shared 3× signing multiplier. Contract creation is rejected. Native-token destination transfers (where viem's `parseTransaction` returns `data: undefined`) are normalized: both sides of the calldata equality check coerce empty/undefined calldata to `"0x"` so legitimate native transfers are not rejected. `FundEphemeral` and native `FinalSettlementSubsidy` reuse this validator before deriving destination gas liability. - [x] `validatePresignedTxs` is called in both `updateRamp` and `startRamp` — dual validation confirmed - [x] `validateAllPresignedTransactionsSigned` checks every expected transaction has a corresponding signed entry -- [x] EVM raw transaction validation (`validateEvmTransaction`) checks `from`, `chainId`, `nonce`, `to`, `data`, `value`, gas limit, and fee caps against expected signer, chain, and server-issued unsigned payload +- [x] EVM raw transaction validation checks `from`, `chainId`, `nonce`, `to`, `data`, `value`, exact gas limit, and bounded fee caps against expected signer, chain, and server-issued unsigned payload - [x] Onramp-specific validation (`validateAveniaOnramp`, `validateMoneriumOnramp`) checks quote amounts and integration-specific fields - [x] Offramp-specific validation (`validateOfframpQuote`, `validateBRLOfframp`) checks quote consistency - [x] `RAMP_START_EXPIRATION_TIME_SECONDS` enforces a time window between registration and start — prevents stale presigned transactions from being executed @@ -84,7 +85,7 @@ The two layers together guarantee that the client cannot (a) sneak a malicious p - [ ] **F-058**: No per-presigned-transaction TTL after ramp starts — `getPresignedTransaction` performs no age check, presigned txs remain valid indefinitely through recovery retries. - [x] Presigned-tx partitioning via `partitionUnsignedTxs` + `filterUnsignedTxsForResponse`. **PASS** — ephemeral txs hidden from SDK response until `ephemeralPresignChecksPass` flips true. - [x] Deposit QR code (BRL onramp) gated on `ephemeralPresignChecksPass`. **PASS** — verified in `meta-state-types.ts`. -- [x] Signed presigned transaction matching accepts normal signed payload mutations while still binding EVM raw transactions to the unsigned server-built `to`/`data`/`value`/`nonce` and minimum gas/fee parameters, and typed-data payloads to the unsigned typed-data content with signatures stripped for comparison. +- [x] Signed presigned transaction matching accepts the production signer's bounded fee multiplier while still binding EVM raw transactions to the unsigned server-built `to`/`data`/`value`/`nonce`/gas envelope, and typed-data payloads to the unsigned typed-data content with signatures stripped for comparison. - [x] **No-permit fallback receipt validation hardened**: `waitForUserHash` verifies receipt `from`, receipt `to`, and transaction `input` against the expected user address and presigned EVM transaction payload before advancing. - [x] User-submitted phase types (`moneriumOnrampMint`, SELL `squidRouterApprove`/`squidRouterSwap`, `squidRouterNoPermit*`) are **rejected** by `validatePresignedTxs` if presigned and **verified by on-chain hash + receipt + calldata** at runtime via `verifyUserSubmittedTxByHash` in `apps/api/src/api/services/phases/helpers/user-tx-verifier.ts`. - [x] **Typed-data full-field binding (F-038 hardening)**: `validateSignedTypedData` deep-compares the signed typed data against the server-issued unsigned typed data (`domain`, `primaryType`, `types`, `message`) before recovering the signature, so the user cannot substitute spender/token/value/deadline/nonce/verifyingContract while still producing a valid signature over a tampered struct. diff --git a/packages/shared/src/endpoints/quote.endpoints.ts b/packages/shared/src/endpoints/quote.endpoints.ts index 0c9e21cb8..34269b4b7 100644 --- a/packages/shared/src/endpoints/quote.endpoints.ts +++ b/packages/shared/src/endpoints/quote.endpoints.ts @@ -116,6 +116,7 @@ export enum QuoteError { // Availability errors UnsupportedCurrency = "Currency not supported", AnchorTemporarilyUnavailable = "This payment provider is temporarily unavailable. Please try again in a few minutes.", + NetworkFeesTooHigh = "Destination network fees are temporarily too high. Please try again later.", // Compatibility errors AssetHubNotSupportedForAlfredPay = "AssetHub is not supported for this currency. Please select a different network.", diff --git a/packages/shared/src/helpers/signUnsigned.test.ts b/packages/shared/src/helpers/signUnsigned.test.ts index f7af85cdd..0a5250b4a 100644 --- a/packages/shared/src/helpers/signUnsigned.test.ts +++ b/packages/shared/src/helpers/signUnsigned.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "bun:test"; -import type { WalletClient } from "viem"; -import { polygonAmoy } from "viem/chains"; +import { parseTransaction, type WalletClient } from "viem"; +import { baseSepolia, polygonAmoy } from "viem/chains"; import type { UnsignedTx } from "../endpoints/ramp.endpoints"; // Importing ./signUnsigned pulls in the package barrel, which freezes src/constants.ts from @@ -10,7 +10,7 @@ process.env.ALFREDPAY_API_KEY ||= "test-key"; process.env.ALFREDPAY_API_SECRET ||= "test-secret"; const { Networks } = await import("./networks"); -const { createEvmClient, groupUnsignedTxsForSigning } = await import("./signUnsigned"); +const { createEvmClient, groupUnsignedTxsForSigning, signUnsignedTransactions } = await import("./signUnsigned"); const EPHEMERAL = { address: "0x0000000000000000000000000000000000000000", @@ -67,13 +67,13 @@ describe("groupUnsignedTxsForSigning", () => { expect(groups.destinationNetworkTxs).toEqual([]); }); - it("keeps destination-phase transactions on other networks in the destination group", () => { + it("assigns Base Sepolia destination transactions to the EVM group", () => { const tx = makeTx(Networks.BaseSepolia, "destinationTransfer"); const groups = groupUnsignedTxsForSigning([tx]); - expect(groups.destinationNetworkTxs).toEqual([tx]); - expect(groups.evmTxs).toEqual([]); + expect(groups.evmTxs).toEqual([tx]); + expect(groups.destinationNetworkTxs).toEqual([]); }); it("never assigns a transaction to both the EVM and destination groups", () => { @@ -92,3 +92,31 @@ describe("groupUnsignedTxsForSigning", () => { } }); }); + +describe("Base Sepolia signing", () => { + it("signs the primary transaction and all nonce backups for Base Sepolia", async () => { + const tx = makeTx(Networks.BaseSepolia, "destinationTransfer"); + const originalFetch = globalThis.fetch; + globalThis.fetch = (async (_input, init) => { + const request = JSON.parse(String(init?.body)) as { id: number; method: string }; + expect(request.method).toBe("eth_chainId"); + return new Response(JSON.stringify({ id: request.id, jsonrpc: "2.0", result: `0x${baseSepolia.id.toString(16)}` }), { + headers: { "Content-Type": "application/json" } + }); + }) as typeof fetch; + + try { + const [primaryTx] = await signUnsignedTransactions([tx], { evmEphemeral: EPHEMERAL }); + const signedVariants = [primaryTx, ...Object.values(primaryTx.meta.additionalTxs ?? {})]; + const parsedVariants = signedVariants.map(variant => parseTransaction(variant.txData as `0x${string}`)); + + expect(signedVariants).toHaveLength(5); + expect(parsedVariants.map(parsed => parsed.chainId)).toEqual(Array(5).fill(baseSepolia.id)); + expect(parsedVariants.map(parsed => parsed.nonce)).toEqual([0, 1, 2, 3, 4]); + expect(parsedVariants.map(parsed => parsed.maxFeePerGas)).toEqual(Array(5).fill(3n)); + expect(parsedVariants.map(parsed => parsed.maxPriorityFeePerGas)).toEqual(Array(5).fill(3n)); + } finally { + globalThis.fetch = originalFetch; + } + }); +}); diff --git a/packages/shared/src/helpers/signUnsigned.ts b/packages/shared/src/helpers/signUnsigned.ts index 869265a51..58dc24cae 100644 --- a/packages/shared/src/helpers/signUnsigned.ts +++ b/packages/shared/src/helpers/signUnsigned.ts @@ -4,7 +4,7 @@ import { hexToU8a } from "@polkadot/util"; import { cryptoWaitReady } from "@polkadot/util-crypto"; import { createWalletClient, fallback, http, WalletClient } from "viem"; import { privateKeyToAccount } from "viem/accounts"; -import { arbitrum, avalanche, base, bsc, mainnet, moonbeam, polygon, polygonAmoy } from "viem/chains"; +import { arbitrum, avalanche, base, baseSepolia, bsc, mainnet, moonbeam, polygon, polygonAmoy } from "viem/chains"; import { decodeSubmittableExtrinsic, EphemeralAccount, @@ -23,6 +23,7 @@ const EVM_EPHEMERAL_SIGNING_NETWORKS: Networks[] = [ Networks.Polygon, Networks.PolygonAmoy, Networks.Base, + Networks.BaseSepolia, Networks.Arbitrum, Networks.Avalanche, Networks.BSC, @@ -36,6 +37,11 @@ const DESTINATION_NETWORK_PHASES = [ "backupApprove" ]; +// Applied once, when the client signs a prepared EVM transaction. Backend +// transaction builders must leave fee estimates unscaled so safety margins do +// not compound across preparation and signing. +export const PRESIGNED_EVM_FEE_MULTIPLIER = 3n; + /** * Groups transactions by the signing flow that handles them. The destination group must * exclude every network the EVM group selects, or the same transaction would be signed @@ -154,6 +160,10 @@ export function createEvmClient( chain = base; rpcUrls = apiKey ? [`https://base-mainnet.g.alchemy.com/v2/${apiKey}`] : []; break; + case Networks.BaseSepolia: + chain = baseSepolia; + rpcUrls = apiKey ? [`https://base-sepolia.g.alchemy.com/v2/${apiKey}`] : []; + break; case Networks.BSC: chain = bsc; rpcUrls = apiKey ? [`https://bnb-mainnet.g.alchemy.com/v2/${apiKey}`] : []; @@ -197,10 +207,10 @@ async function signMultipleEvmTransactions( throw new Error("Wallet client account is undefined"); } const maxPriorityFeePerGas = tx.txData.maxPriorityFeePerGas - ? BigInt(tx.txData.maxPriorityFeePerGas) * 3n + ? BigInt(tx.txData.maxPriorityFeePerGas) * PRESIGNED_EVM_FEE_MULTIPLIER : BigInt(187500000000); const maxFeePerGas = (() => { - const fee = tx.txData.maxFeePerGas ? BigInt(tx.txData.maxFeePerGas) * 3n : BigInt(187500000000); + const fee = tx.txData.maxFeePerGas ? BigInt(tx.txData.maxFeePerGas) * PRESIGNED_EVM_FEE_MULTIPLIER : BigInt(187500000000); return fee > maxPriorityFeePerGas ? fee : maxPriorityFeePerGas; })();