diff --git a/apps/api/src/api/services/phases/handlers/nabla-swap-handler.ts b/apps/api/src/api/services/phases/handlers/nabla-swap-handler.ts index ca830c7c0..8909296ca 100644 --- a/apps/api/src/api/services/phases/handlers/nabla-swap-handler.ts +++ b/apps/api/src/api/services/phases/handlers/nabla-swap-handler.ts @@ -2,9 +2,12 @@ import { createExecuteMessageExtrinsic, ExecuteMessageResult, readMessage, submi import { Abi } from "@polkadot/api-contract"; import { ApiManager, + checkEvmBalanceForToken, decodeSubmittableExtrinsic, defaultReadLimits, EvmClientManager, + EvmTokenDetails, + evmTokenConfig, FiatToken, NABLA_ROUTER, Networks, @@ -138,6 +141,60 @@ export class NablaSwapPhaseHandler extends BasePhaseHandler { const evmClientManager = EvmClientManager.getInstance(); const baseClient = evmClientManager.getClient(Networks.Base); + if (!quote.metadata.nablaSwapEvm?.inputAmountForSwapRaw || !quote.metadata.nablaSwapEvm.inputCurrency) { + throw new Error("Missing nablaSwapEvm input metadata required to validate pre-swap balance"); + } + + const evmEphemeralAddress = state.state.evmEphemeralAddress; + if (!evmEphemeralAddress) { + throw new Error("Missing EVM ephemeral address to validate nabla swap input balance"); + } + + const inputTokenDetails = evmTokenConfig[Networks.Base]?.[quote.metadata.nablaSwapEvm.inputCurrency] as + | EvmTokenDetails + | undefined; + if (!inputTokenDetails) { + throw new Error(`Invalid input token ${quote.metadata.nablaSwapEvm.inputCurrency} for Base nabla swap`); + } + + const isRecoverableBalanceCheckFailure = (error: unknown): boolean => { + if (!(error instanceof Error)) { + return false; + } + + const normalizedMessage = error.message.toLowerCase(); + return ( + error.name === "BalanceCheckError" || + normalizedMessage.includes("timeout") || + normalizedMessage.includes("timed out") || + normalizedMessage.includes("read failure") || + normalizedMessage.includes("failed to read") || + normalizedMessage.includes("network") || + normalizedMessage.includes("rpc") || + normalizedMessage.includes("fetch") + ); + }; + + try { + await checkEvmBalanceForToken({ + amountDesiredRaw: quote.metadata.nablaSwapEvm.inputAmountForSwapRaw, + chain: Networks.Base, + intervalMs: 1000, + ownerAddress: evmEphemeralAddress, + timeoutMs: 5000, + tokenDetails: inputTokenDetails + }); + } catch (e) { + const errorMessage = e instanceof Error ? e.message : String(e); + logger.error(`Could not validate EVM input balance before swap: ${errorMessage}`); + + if (isRecoverableBalanceCheckFailure(e)) { + throw this.createRecoverableError(`Could not validate EVM input balance before swap: ${errorMessage}`); + } + + throw this.createUnrecoverableError(`Could not validate EVM input balance before swap: ${errorMessage}`); + } + try { const { txData: nablaSwapTransaction } = this.getPresignedTransaction(state, "nablaSwapEvm"); diff --git a/apps/api/src/api/services/phases/handlers/squid-router-phase-handler.ts b/apps/api/src/api/services/phases/handlers/squid-router-phase-handler.ts index 0f42f440e..74c4ca951 100644 --- a/apps/api/src/api/services/phases/handlers/squid-router-phase-handler.ts +++ b/apps/api/src/api/services/phases/handlers/squid-router-phase-handler.ts @@ -1,6 +1,10 @@ import { + checkEvmBalanceForToken, EvmClientManager, + EvmNetworks, EvmToken, + EvmTokenDetails, + evmTokenConfig, FiatToken, getNetworkFromDestination, getNetworkId, @@ -72,7 +76,43 @@ export class SquidRouterPhaseHandler extends BasePhaseHandler { return this.transitionToNextPhase(state, "destinationTransfer"); } + const bridgeMeta = quote.metadata.evmToEvm || quote.metadata.moonbeamToEvm; + if (!bridgeMeta?.inputAmountRaw || !bridgeMeta.fromNetwork || !bridgeMeta.fromToken) { + throw new Error("Missing bridge metadata required to validate squidRouter input balance"); + } + + const evmEphemeralAddress = state.state.evmEphemeralAddress; + if (!evmEphemeralAddress) { + throw new Error("Missing EVM ephemeral address to validate squidRouter input balance"); + } + + const sourceNetwork = bridgeMeta.fromNetwork as EvmNetworks; + const sourceTokenDetails = Object.values(evmTokenConfig[sourceNetwork] || {}).find( + token => token.erc20AddressSourceChain.toLowerCase() === bridgeMeta.fromToken.toLowerCase() + ) as EvmTokenDetails | undefined; + + if (!sourceTokenDetails) { + throw new Error( + `Could not resolve source token details on ${bridgeMeta.fromNetwork} for token ${bridgeMeta.fromToken} in squidRouter phase` + ); + } + try { + try { + await checkEvmBalanceForToken({ + amountDesiredRaw: bridgeMeta.inputAmountRaw, + chain: sourceNetwork, + intervalMs: 1000, + ownerAddress: evmEphemeralAddress, + timeoutMs: 15000, + tokenDetails: sourceTokenDetails + }); + } catch (error) { + throw this.createRecoverableError( + `Unable to verify squidRouter input balance for ${evmEphemeralAddress} on ${sourceNetwork}; balance may not be settled yet` + ); + } + // Get the presigned transactions for this phase const approveTransaction = this.getPresignedTransaction(state, "squidRouterApprove"); const swapTransaction = this.getPresignedTransaction(state, "squidRouterSwap"); diff --git a/apps/api/src/api/services/phases/handlers/subsidize-post-swap-evm-handler.ts b/apps/api/src/api/services/phases/handlers/subsidize-post-swap-evm-handler.ts index ea3778073..19d4d9ffa 100644 --- a/apps/api/src/api/services/phases/handlers/subsidize-post-swap-evm-handler.ts +++ b/apps/api/src/api/services/phases/handlers/subsidize-post-swap-evm-handler.ts @@ -61,6 +61,8 @@ export class SubsidizePostSwapEvmPhaseHandler extends BasePhaseHandler { ); } + await new Promise(resolve => setTimeout(resolve, 15000)); + // Check current balance on EVM const currentBalance = await checkEvmBalanceForToken({ amountDesiredRaw: "1", diff --git a/apps/api/src/api/services/transactions/stellar/offrampTransaction.ts b/apps/api/src/api/services/transactions/stellar/offrampTransaction.ts index ed4e308ae..e8359c662 100644 --- a/apps/api/src/api/services/transactions/stellar/offrampTransaction.ts +++ b/apps/api/src/api/services/transactions/stellar/offrampTransaction.ts @@ -1,4 +1,10 @@ -import { HORIZON_URL, PaymentData, STELLAR_EPHEMERAL_STARTING_BALANCE_UNITS, StellarTokenDetails } from "@vortexfi/shared"; +import { + HORIZON_URL, + NUMBER_OF_PRESIGNED_TXS, + PaymentData, + STELLAR_EPHEMERAL_STARTING_BALANCE_UNITS, + StellarTokenDetails +} from "@vortexfi/shared"; import Big from "big.js"; import { Account, Asset, Horizon, Keypair, Memo, Networks, Operation, TransactionBuilder } from "stellar-sdk"; import logger from "../../../../config/logger"; @@ -39,8 +45,6 @@ export async function buildPaymentAndMergeTx({ createAccountTransactions: Array<{ sequence: string; tx: string }>; }> { const baseFee = STELLAR_BASE_FEE; - const NUMBER_OF_PRESIGNED_TXS = 5; - if (!FUNDING_SECRET) { logger.error("Stellar funding secret not defined"); throw new Error("Stellar funding secret not defined"); diff --git a/apps/api/src/api/services/transactions/validation.test.ts b/apps/api/src/api/services/transactions/validation.test.ts index 2b842f50c..a67d310f6 100644 --- a/apps/api/src/api/services/transactions/validation.test.ts +++ b/apps/api/src/api/services/transactions/validation.test.ts @@ -1,170 +1,178 @@ import {describe, expect, it} from "bun:test"; import {EphemeralAccountType, Networks, PresignedTx, RampDirection} from "@vortexfi/shared"; import {validatePresignedTxs} from "./validation"; -import QuoteTicket from "../../../models/quoteTicket.model"; +import { NUMBER_OF_PRESIGNED_TXS } from "@vortexfi/shared"; + +function withBackups(tx: PresignedTx): PresignedTx { + const additionalTxs: Record = {}; + for (let i = 1; i <= NUMBER_OF_PRESIGNED_TXS - 1; i++) { + additionalTxs[`backup${i}`] = { ...tx, nonce: tx.nonce + i, meta: {} }; + } + return { ...tx, meta: { additionalTxs } }; +} // @ts-ignore const VALID_EXAMPLE_PRESIGNED_TX_EUR_ONRAMP: PresignedTx[] = [ - { + withBackups({ "nonce": 0, "phase": "moneriumOnrampSelfTransfer", "signer": "0x441D7df1551e3750AD2B5629A5DB2c316e7e0f89", "txData": "0x02f8d38189808522ef8a61de8522ef8a61de830186a09418ec0a6e18e5bc3784fdd3a3634b31245ab704f680b86423b872dd000000000000000000000000976ff31a56daf5a0e09f411950311f5877ff00d5000000000000000000000000441d7df1551e3750ad2b5629a5db2c316e7e0f89000000000000000000000000000000000000000000000000a688906bd8b00000c080a07724aeb861281600a776570db236f60ac3762afecb021c4291d11d16a9443849a021bf29fe0aeea6f4d2ad321f1f8ab53998a4779a2ebf3bc29c3e60287e3016b4", "network": Networks.Polygon, "meta": {} - }, - { + }), + withBackups({ "nonce": 1, "phase": "squidRouterApprove", "signer": "0x441D7df1551e3750AD2B5629A5DB2c316e7e0f89", "txData": "0x02f8b38189018522ecb25c008522ef8a61de830249f09418ec0a6e18e5bc3784fdd3a3634b31245ab704f680b844095ea7b3000000000000000000000000ce16f69375520ab01377ce7b88f5ba8c48f8d666000000000000000000000000000000000000000000000000a688906bd8b00000c080a027303cbee431c59d8122dc70f4179bd82be7251ebbf7b057b22c671c0fc78721a04e0004c85b181f0af98935241910f675b44d4c3d0c2459393c37813120a49dab", "network": Networks.Polygon, "meta": {} - }, - { + }), + withBackups({ "nonce": 2, "phase": "squidRouterSwap", "signer": "0x441D7df1551e3750AD2B5629A5DB2c316e7e0f89", "txData": "0x02f904eb8189028522ecb25c008522ef8a61de83058b8894ce16f69375520ab01377ce7b88f5ba8c48f8d666872386f26fc10000b9047458181a8000000000000000000000000018ec0a6e18e5bc3784fdd3a3634b31245ab704f6000000000000000000000000000000000000000000000000a688906bd8b0000000000000000000000000000000000000000000000000000000000000000000600000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000004000000000000000000000000000000000000000000000000000000000000001c0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000018ec0a6e18e5bc3784fdd3a3634b31245ab704f6000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000a000000000000000000000000000000000000000000000000000000000000001200000000000000000000000000000000000000000000000000000000000000044095ea7b300000000000000000000000068b3465833fb72a70ecdf485e0e4c7bd8665fc45ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000004000000000000000000000000018ec0a6e18e5bc3784fdd3a3634b31245ab704f60000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000000000000000000000000000000068b3465833fb72a70ecdf485e0e4c7bd8665fc45000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000a000000000000000000000000000000000000000000000000000000000000001c000000000000000000000000000000000000000000000000000000000000000e404e45aaf00000000000000000000000018ec0a6e18e5bc3784fdd3a3634b31245ab704f60000000000000000000000003c499c542cef5e3811e1192ce70d8cc03d5c335900000000000000000000000000000000000000000000000000000000000001f4000000000000000000000000976ff31a56daf5a0e09f411950311f5877ff00d5000000000000000000000000000000000000000000000000a688906bd8b000000000000000000000000000000000000000000000000000000000000000d3f913000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000004000000000000000000000000018ec0a6e18e5bc3784fdd3a3634b31245ab704f600000000000000000000000000000000000000000000000000000000000000044528e10e2137d5bf5d2940727fcc9007c080a0c699dbbd1d28d5fe95e013f950f6050adf99622fbaf71d5db6dace36646ee0eaa073e405accd62d5d7d7dbc535a69d10a140872b58715bcacae6e9187c8db24c7e", "network": Networks.Polygon, "meta": {} - } + }) ] const VALID_EXAMPLE_PRESIGNED_TX_BRL_ONRAMP: PresignedTx[] = [ - { + withBackups({ "meta": {}, "nonce": 0, "phase": "nablaApprove", "signer": "5FxM3dFCnXJXEbMozuVbhEUQuQK1gmquFpUJ577HebqBc7pz", "txData": "0x71038400ac1767af9bf4282c0f268b5ce1797db8f19eaf9a748f2d9519905654b9c292370162bd23e90ef57a53ec3360bba0b3c1a735dfa22251bd5800105241d94006cd2f9484ba4494f57fb6b00aba9fb6b8a11effb73a22fda6223eb4abe5169ab30d880000000038060093dfde426795690be15b2071741d6538cd265eb673a9e9a1ae4e4389fda96a620007cdd55d7302ce800200001101095ea7b3e0a5f34199e165cbd3f9b0eba1f5e15d5018a7ffe8b76a3693ba5b317efb09d800005dccfd995e80000000000000000000000000000000000000000000000000", "network": Networks.Pendulum - }, - { + }), + withBackups({ "meta": {}, "nonce": 1, "phase": "nablaSwap", "signer": "5FxM3dFCnXJXEbMozuVbhEUQuQK1gmquFpUJ577HebqBc7pz", "txData": "0x75058400ac1767af9bf4282c0f268b5ce1797db8f19eaf9a748f2d9519905654b9c292370158d8c585d9a217389d99709447f8f5777781b979de8eebc194b7dbb7bfd22344b1914b97d2527d0eabf1bb4a68739e6a4d0766ed783f2541ec46fbec5a62d38f00040000380600e0a5f34199e165cbd3f9b0eba1f5e15d5018a7ffe8b76a3693ba5b317efb09d80007003a9a535082584f0000150338ed173900005dccfd995e8000000000000000000000000000000000000000000000000016d21800000000000000000000000000000000000000000000000000000000000893dfde426795690be15b2071741d6538cd265eb673a9e9a1ae4e4389fda96a6290573e0b663336bc844ddd1293af95b0b1872f2677f93e11cc658fafddc58db9ac1767af9bf4282c0f268b5ce1797db8f19eaf9a748f2d9519905654b9c292375883036900000000000000000000000000000000000000000000000000000000", "network": Networks.Pendulum - }, - { + }), + withBackups({ "meta": {}, "nonce": 2, "phase": "distributeFees", "signer": "5FxM3dFCnXJXEbMozuVbhEUQuQK1gmquFpUJ577HebqBc7pz", "txData": "0x4d028400ac1767af9bf4282c0f268b5ce1797db8f19eaf9a748f2d9519905654b9c2923701bc556326e0a028968b7e79fdc2ca473c8ab25f1118c02cd438c5b6ce9eac9b7056ba09c15c7c93455b10e17f5234c61dd13e3562a74012f1b65a7f8d5dbc298300080000330204350200a2b2a8753c39705138998ee3285ab982e1d4f87ff90e626d46938b3e995e2cbd010c4a0c0400", "network": Networks.Pendulum - }, - { + }), + withBackups({ "meta": {}, "nonce": 3, "phase": "pendulumToMoonbeamXcm", "signer": "5FxM3dFCnXJXEbMozuVbhEUQuQK1gmquFpUJ577HebqBc7pz", "txData": "0xbd028400ac1767af9bf4282c0f268b5ce1797db8f19eaf9a748f2d9519905654b9c292370122251f038f2b4eaebdcc58e59b539624cdbc8704d8d07fc9af0f799b8336515d595ec3de29f488c64b07212868acd68bb0a039e0fffbf4c2e8abd72d188cf687000c0000360408010cb61c190000000000000000000000000001060000c52ebca2b10000000000000000000100000003010200511f0300876452cc7a2280560d39e7e8aebc9d1baabd4fea00", "network": Networks.Pendulum - }, - { + }), + withBackups({ "meta": {}, "nonce": 4, "phase": "pendulumCleanup", "signer": "5FxM3dFCnXJXEbMozuVbhEUQuQK1gmquFpUJ577HebqBc7pz", "txData": "0x69038400ac1767af9bf4282c0f268b5ce1797db8f19eaf9a748f2d9519905654b9c2923701e657e664e59ebdc9eac968d4377c7c98465c67eeed99a2b17c3c5009b457d43568e763954af6bed513f25f08be04aeaea98d6edde2cf8640eb2d931a5fe0578f0010000033020c35010056d9583bf0369fff4a35d997b2b5f5997843311823b1aa88fe9661874984e647010d0035010056d9583bf0369fff4a35d997b2b5f5997843311823b1aa88fe9661874984e647010c000a040056d9583bf0369fff4a35d997b2b5f5997843311823b1aa88fe9661874984e64700", "network": Networks.Pendulum - }, - { + }), + withBackups({ "meta": {}, "nonce": 0, "phase": "moonbeamToPendulumXcm", "signer": "0x876452cC7a2280560d39e7E8aEBc9d1bAAbd4fEa", "txData": "0xcd0284876452cc7a2280560d39e7e8aebc9d1baabd4feafbb8da8be045e7e9147ac1feca313fbdc7b7e9ea9511db7186085f9ecee8c2f64710838739a3c97d150137403bf268db57f3750fb2f3a1f7a5d82d6bfa1f99820000000000670b03010100b9200300010100ac1767af9bf4282c0f268b5ce1797db8f19eaf9a748f2d9519905654b9c292370304000002046e0300feb25f3fddad13f82c4d6dbc1481516f62236429001300005dccfd995e800000000000", "network": Networks.Moonbeam - }, - { + }), + withBackups({ "meta": {}, "nonce": 4, "phase": "moonbeamCleanup", "signer": "0x876452cC7a2280560d39e7E8aEBc9d1bAAbd4fEa", "txData": "0xc50184876452cc7a2280560d39e7e8aebc9d1baabd4feabc040cd9dab01c885530675a6b90135220dd1957ede426c13b400424f9448f667d9803e61fadc0c2f0bd1365abee003e4cbf9109b1773c35bd50c3a2c5f9a91d00001000000a04ec733ccc573cbb46211876149e1830c58c6133e200", "network": Networks.Moonbeam - }, - { + }), + withBackups({ "meta": {}, "nonce": 2, "phase": "squidRouterApprove", "signer": "0x876452cC7a2280560d39e7E8aEBc9d1bAAbd4fEa", "txData": "0x02f8af8205040280852ba7def300830249f094ca01a1d0993565291051daff390892518acfad3a80b844095ea7b3000000000000000000000000ce16f69375520ab01377ce7b88f5ba8c48f8d6660000000000000000000000000000000000000000000000000000000000191cb6c080a0d872d6eb940960b00300d45ed0a1ace3914d52e1a77b2db6545d662db8b47f73a06ca7c96230e2743bf8783eb1e39daea3fc3a94895d70d05ee675084d217441e5", "network": Networks.Moonbeam - }, - { + }), + withBackups({ "meta": {}, "nonce": 3, "phase": "squidRouterSwap", "signer": "0x876452cC7a2280560d39e7E8aEBc9d1bAAbd4fEa", "txData": "0x02f907e78205040380852ba7def3008311652094ce16f69375520ab01377ce7b88f5ba8c48f8d666872386f26fc10000b907742147796000000000000000000000000000000000000000000000000000000000000000e00000000000000000000000000000000000000000000000000000000000191cb60000000000000000000000000000000000000000000000000000000000000120000000000000000000000000000000000000000000000000000000000000016000000000000000000000000000000000000000000000000000000000000001c0000000000000000000000000876452cc7a2280560d39e7e8aebc9d1baabd4fea0000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000000761786c55534443000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000007506f6c79676f6e00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000002a3078636531364636393337353532306162303133373763653742383866354241384334384638443636360000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000005700000000000000000000000000000000000000000000000000000000000000040000000000000000000000000123456789012345678901234567890123456789000000000000000000000000000000000000000000000000000000000000000030000000000000000000000000000000000000000000000000000000000000060000000000000000000000000000000000000000000000000000000000000016000000000000000000000000000000000000000000000000000000000000002e000000000000000000000000000000000000000000000000000000000000000030000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000a000000000000000000000000000000000000000000000000000000000000000c000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000020000000000000000000000000750e4c4984a9e0f12978ea6742bc1c5d248f40ed0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000750e4c4984a9e0f12978ea6742bc1c5d248f40ed000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000a000000000000000000000000000000000000000000000000000000000000001200000000000000000000000000000000000000000000000000000000000000044095ea7b300000000000000000000000068b3465833fb72a70ecdf485e0e4c7bd8665fc45ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000040000000000000000000000000750e4c4984a9e0f12978ea6742bc1c5d248f40ed0000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000000100000000000000000000000068b3465833fb72a70ecdf485e0e4c7bd8665fc45000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000a000000000000000000000000000000000000000000000000000000000000001c000000000000000000000000000000000000000000000000000000000000000e404e45aaf000000000000000000000000750e4c4984a9e0f12978ea6742bc1c5d248f40ed0000000000000000000000003c499c542cef5e3811e1192ce70d8cc03d5c3359000000000000000000000000000000000000000000000000000000000000006400000000000000000000000012345678901234567890123456789012345678900000000000000000000000000000000000000000000000000000000000191cb6000000000000000000000000000000000000000000000000000000000019109a0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000040000000000000000000000000750e4c4984a9e0f12978ea6742bc1c5d248f40ed0000000000000000000000000000000000000000000000000000000000000004537e1325f7cf4e57dba060fefb1d7dae00000000000000000000000000000000537e1325f7cf4e57dba060fefb1d7daec080a0f467192c77c1ef20a0c402b4418ced16da1662059a92e311142ce216b84009d3a043f4ef95c3f4ba46c7971dd2455182cf6b25b3b9de58018016beb1496e5df2d8", "network": Networks.Moonbeam - } + }) ] const VALID_EXAMPLE_PRESIGNED_TX_EUR_OFFRAMP: PresignedTx[] = [ - { + withBackups({ "meta": {}, "nonce": 0, "phase": "stellarCreateAccount", "signer": "GBN7PT6ODKPA5LHDAXT4AA4DAMDYAEJPXJVPQFDEIN6L3GMCBIUCQSAJ", "txData": "AAAAAgAAAADkOCw1GPsc4U0bNLBfqRtbB05ZcogqYJfDKZYB95sHRAAtxsADWqM3AAAArQAAAAIAAAABAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAMAAAAAAAAAAAAAAABb98/OGp4OrOMF58ADgwMHgBEvumr4FGRDfL2ZggooKAAAAAABfXhAAAAAAQAAAABb98/OGp4OrOMF58ADgwMHgBEvumr4FGRDfL2ZggooKAAAAAUAAAAAAAAAAAAAAAAAAAAAAAAAAQAAAAIAAAABAAAAAgAAAAEAAAACAAAAAAAAAAEAAAAA5DgsNRj7HOFNGzSwX6kbWwdOWXKIKmCXwymWAfebB0QAAAABAAAAAQAAAABb98/OGp4OrOMF58ADgwMHgBEvumr4FGRDfL2ZggooKAAAAAYAAAABRVVSQwAAAADPT1om4gkLs63PAsep1z2/5mWcxpBGFHW4ZDf6SccRNn//////////AAAAAAAAAAL3mwdEAAAAQCsExvxklazpsIDVJtyQU8Ou969v8j1NeM/MDMATo0UlUifWtbb218kd+ql6i21PQbD7ibxm6M4Zp1zflDIRMwOCCigoAAAAQF1MLyxdcdQ9lMYiR8iHye4TIKoP9zOimi4AKCL87rgDeXbEazuVR0GS0ILjnsc3NLFySKtAWcUFX20XXp7v5Aw=", "network": Networks.Stellar - }, - { + }), + withBackups({ "meta": {}, "nonce": 1, "phase": "stellarPayment", "signer": "GBN7PT6ODKPA5LHDAXT4AA4DAMDYAEJPXJVPQFDEIN6L3GMCBIUCQSAJ", "txData": "AAAAAgAAAABb98/OGp4OrOMF58ADgwMHgBEvumr4FGRDfL2ZggooKAAPQkADjNQFAAAAAQAAAAIAAAABAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAQAAAA1NWUsxNzYxNDkyNzc2AAAAAAAAAQAAAAAAAAABAAAAAMwmH81TyAdqCkge7nLAJdasnz/JchoiBMyDM9Io97NEAAAAAUVVUkMAAAAAz09aJuIJC7OtzwLHqdc9v+ZlnMaQRhR1uGQ3+knHETYAAAAABh8T4AAAAAAAAAAC95sHRAAAAEB4aZkEhfZ98f+FbQSEj0wFNirD7fe2HiWLM9jIuvkoQ9ruzSxycCK+NMiIgppZnNSNnibw10BseXsG9kjK1u0KggooKAAAAED8tHWEfIKPzeuHVBnMy9x+ireQ6kepvWCLq/ZRyXWN8m+lcE0r60HwjD25xJovaY9hyVh9X50o/xm0dM6DlIsF", "network": Networks.Stellar - }, - { + }), + withBackups({ "meta": {}, "nonce": 2, "phase": "stellarCleanup", "signer": "GBN7PT6ODKPA5LHDAXT4AA4DAMDYAEJPXJVPQFDEIN6L3GMCBIUCQSAJ", "txData": "AAAAAgAAAABb98/OGp4OrOMF58ADgwMHgBEvumr4FGRDfL2ZggooKAAehIADjNQFAAAAAgAAAAIAAAABAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABAAAAAAAAAAEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAIAAAAAAAAABgAAAAFFVVJDAAAAAM9PWibiCQuzrc8Cx6nXPb/mZZzGkEYUdbhkN/pJxxE2AAAAAAAAAAAAAAAAAAAACAAAAADkOCw1GPsc4U0bNLBfqRtbB05ZcogqYJfDKZYB95sHRAAAAAAAAAAC95sHRAAAAEB8+udS9KiWj8JjxxPB3HSMC0EkRvggU2hOP9IoHF8+T7VzqZiPzzwuothCSKwaOgaVvG/SSPUIKJQkpVYhjqwJggooKAAAAECSKoTeRu3ttJ9G3Cj6a79Yv6ZQTguCIlGo2tlJltKvQex7SQys69T93BeoG+XALB8I8MvSiQoEXE7unZYpmL0A", "network": Networks.Stellar - }, - { + }), + withBackups({ "meta": {}, "nonce": 0, "phase": "nablaApprove", "signer": "5GBVPRfgZYjDMqQSACxzfrPeKxnsKGyinwwGRFpcacaAzDov", "txData": "0x71038400b61dacc574163a9e8da2aca2b29090c610e61b21de9adbafb699156b6b6d9465019c7a2a23097caa54fcdd14432c731bd7c7c82a5648ee6d9a12378af3e241b435f2675ee515c50edfdf9098318c8a31abcc511d52a76133ad9e6b35cf5209bb8d000000003806005c1026460683b902672db0bbf65df0c021f5c9f844663e4dd1fcb13935ac6ba600072a494093029e820200001101095ea7b3e0a5f34199e165cbd3f9b0eba1f5e15d5018a7ffe8b76a3693ba5b317efb09d8e0ccb60000000000000000000000000000000000000000000000000000000000", "network": Networks.Pendulum - }, - { + }), + withBackups({ "meta": {}, "nonce": 1, "phase": "nablaSwap", "signer": "5GBVPRfgZYjDMqQSACxzfrPeKxnsKGyinwwGRFpcacaAzDov", "txData": "0x75058400b61dacc574163a9e8da2aca2b29090c610e61b21de9adbafb699156b6b6d946501563086b97dda162ab053773820a71edb2bb21e5715eaa961b293e04eaa8a9762e9a43194f41b6ee69728dd47024155045ab556a0de92b1bde305c142a9f1a48100040000380600e0a5f34199e165cbd3f9b0eba1f5e15d5018a7ffe8b76a3693ba5b317efb09d80007003a9a535082584f0000150338ed1739e0ccb60000000000000000000000000000000000000000000000000000000000502ee5a6df080000000000000000000000000000000000000000000000000000085c1026460683b902672db0bbf65df0c021f5c9f844663e4dd1fcb13935ac6ba691527bbc28ccc6504c707183ed37ace959618cc2d7311afc7fe368060fd31181b61dacc574163a9e8da2aca2b29090c610e61b21de9adbafb699156b6b6d9465b479076900000000000000000000000000000000000000000000000000000000", "network": Networks.Pendulum - }, - { + }), + withBackups({ "meta": {}, "nonce": 2, "phase": "spacewalkRedeem", "signer": "5GBVPRfgZYjDMqQSACxzfrPeKxnsKGyinwwGRFpcacaAzDov", "txData": "0x61038400b61dacc574163a9e8da2aca2b29090c610e61b21de9adbafb699156b6b6d946501a8f6a94c137102b940a122e2e57ddcc0fe3bd87ebb6e0973c544ec8f4c3f1870557147cc1d2947e5057d345059d4d36bb7a3c84ca133e9e9fbf04b83c883ea810008000041000b00acb32b57095bf7cfce1a9e0eace305e7c00383030780112fba6af81464437cbd99820a282872ad10a7827be5155531de3c5e805c5f640fd335b491701ac2f4ed6aedbf7961010a020145555243cf4f5a26e2090bb3adcf02c7a9d73dbfe6659cc690461475b86437fa49c71136", "network": Networks.Pendulum, - }, - { + }), + withBackups({ "meta": {}, "nonce": 3, "phase": "pendulumCleanup", "signer": "5GBVPRfgZYjDMqQSACxzfrPeKxnsKGyinwwGRFpcacaAzDov", "txData": "0xf9038400b61dacc574163a9e8da2aca2b29090c610e61b21de9adbafb699156b6b6d94650118426ae3182f3d5fd4d5c023fd9f51b8500d474c5d72222a41cb83bf1c69a25c9bdd8e63ce4a13af23ef0a05c9d3c55ac679c82a9fa38981f7b89352e1eb6089000c000033020c35010056d9583bf0369fff4a35d997b2b5f5997843311823b1aa88fe9661874984e64701020035010056d9583bf0369fff4a35d997b2b5f5997843311823b1aa88fe9661874984e647020145555243cf4f5a26e2090bb3adcf02c7a9d73dbfe6659cc690461475b86437fa49c71136000a040056d9583bf0369fff4a35d997b2b5f5997843311823b1aa88fe9661874984e64700", "network": Networks.Pendulum - } + }) ]; describe("Presigned Transaction validation", () => { @@ -259,5 +267,39 @@ describe("Presigned Transaction validation", () => { } expect(() => validatePresignedTxs(RampDirection.BUY, invalidTxs, ephemerals)).toThrow("presignedTxs must be an array with 1-100 elements"); }) -}); + it("should throw when an ephemeral transaction is missing backup transactions", async () => { + const invalidTxs: PresignedTx[] = JSON.parse(JSON.stringify(VALID_EXAMPLE_PRESIGNED_TX_EUR_ONRAMP)); + invalidTxs[2].meta = {}; + + const ephemerals: {[key in EphemeralAccountType]: string } = { + Substrate: "", + EVM: "0x441D7df1551e3750AD2B5629A5DB2c316e7e0f89", + Stellar: "" + } + + await expect(validatePresignedTxs(RampDirection.BUY, invalidTxs, ephemerals)).rejects.toThrow( + "Transaction for phase squidRouterSwap must include at least 4 backup transactions in meta.additionalTxs" + ); + }); + + it("should throw when backup transaction nonces are not sequential", async () => { + const invalidTxs: PresignedTx[] = JSON.parse(JSON.stringify(VALID_EXAMPLE_PRESIGNED_TX_EUR_ONRAMP)); + // Replace proper nonce with an invalid one to simulate wrong use + const backupTx = invalidTxs[2]?.meta?.additionalTxs?.backup2; + if (!backupTx) { + throw new Error("Missing backup transaction for test setup"); + } + backupTx.nonce = 9; + + const ephemerals: {[key in EphemeralAccountType]: string } = { + Substrate: "", + EVM: "0x441D7df1551e3750AD2B5629A5DB2c316e7e0f89", + Stellar: "" + } + + await expect(validatePresignedTxs(RampDirection.BUY, invalidTxs, ephemerals)).rejects.toThrow( + "Transaction for phase squidRouterSwap has invalid backup nonce sequence. Expected 4, got 5" + ); + }); +}); diff --git a/apps/api/src/api/services/transactions/validation.ts b/apps/api/src/api/services/transactions/validation.ts index 06047db22..6cf860e78 100644 --- a/apps/api/src/api/services/transactions/validation.ts +++ b/apps/api/src/api/services/transactions/validation.ts @@ -7,6 +7,7 @@ import { getNetworkId, isSignedTypedData, isSignedTypedDataArray, + NUMBER_OF_PRESIGNED_TXS, PresignedTx, RampDirection, RampPhase, @@ -72,6 +73,37 @@ function getTransactionTypeForPhase(phase: RampPhase | CleanupPhase): EphemeralA } } +function validateBackupTransactions(tx: PresignedTx, ephemerals: { [key in EphemeralAccountType]: string }) { + const signer = tx.signer.toLowerCase(); + const isEphemeralSigner = Object.values(ephemerals).some(addr => addr && addr.toLowerCase() === signer); + + if (!isEphemeralSigner) { + return; + } + + const additionalTxs = tx.meta?.additionalTxs; + if (!additionalTxs || Object.keys(additionalTxs).length < NUMBER_OF_PRESIGNED_TXS - 1) { + throw new APIError({ + message: `Transaction for phase ${tx.phase} must include at least ${NUMBER_OF_PRESIGNED_TXS - 1} backup transactions in meta.additionalTxs`, + status: httpStatus.BAD_REQUEST + }); + } + + const backupNonces = Object.values(additionalTxs) + .map(backup => backup.nonce) + .sort((a, b) => a - b); + + for (let i = 0; i < NUMBER_OF_PRESIGNED_TXS - 1; i++) { + const expectedNonce = tx.nonce + 1 + i; + if (backupNonces[i] !== expectedNonce) { + throw new APIError({ + message: `Transaction for phase ${tx.phase} has invalid backup nonce sequence. Expected ${expectedNonce}, got ${backupNonces[i]}`, + status: httpStatus.BAD_REQUEST + }); + } + } +} + export async function validatePresignedTxs( direction: RampDirection, presignedTxs: PresignedTx[], @@ -104,6 +136,8 @@ export async function validatePresignedTxs( if (txType === EphemeralAccountType.EVM) validateEvmTransaction(tx, ephemerals.EVM); if (txType === EphemeralAccountType.Substrate) await validateSubstrateTransaction(tx, ephemerals.Substrate, ephemerals.EVM); if (txType === EphemeralAccountType.Stellar) await validateStellarTransaction(tx, ephemerals.Stellar); + + validateBackupTransactions(tx, ephemerals); } } diff --git a/packages/shared/src/constants.ts b/packages/shared/src/constants.ts index c5fa26d21..252799120 100644 --- a/packages/shared/src/constants.ts +++ b/packages/shared/src/constants.ts @@ -1,6 +1,7 @@ import { getEnvVar } from "./helpers/environment"; export const SANDBOX_ENABLED = getEnvVar("SANDBOX_ENABLED"); +export const NUMBER_OF_PRESIGNED_TXS = 5; export const MOONBEAM_RECEIVER_CONTRACT_ADDRESS = "0x2AB52086e8edaB28193172209407FF9df1103CDc"; diff --git a/packages/shared/src/helpers/signUnsigned.ts b/packages/shared/src/helpers/signUnsigned.ts index 858d9d8c3..cc1536489 100644 --- a/packages/shared/src/helpers/signUnsigned.ts +++ b/packages/shared/src/helpers/signUnsigned.ts @@ -13,15 +13,13 @@ import { isSignedTypedData, isSignedTypedDataArray, Networks, + NUMBER_OF_PRESIGNED_TXS, PresignedTx, SANDBOX_ENABLED, UnsignedTx } from "../index"; import logger from "../logger"; -// Number of transactions to pre-sign for each transaction -const NUMBER_OF_PRESIGNED_TXS = 5; - export function addAdditionalTransactionsToMeta(primaryTx: PresignedTx, multiSignedTxs: PresignedTx[]): PresignedTx { if (multiSignedTxs.length <= 1) { return primaryTx;