From 456b529c5411b78401ad1d22c098b7dcd5368eb4 Mon Sep 17 00:00:00 2001 From: Gianfranco Date: Wed, 4 Mar 2026 16:10:58 -0300 Subject: [PATCH 01/17] attmpet fetching axelar status when squidrouter status fails --- .../squid-router-pay-phase-handler.ts | 37 ++++++++++++++++--- 1 file changed, 32 insertions(+), 5 deletions(-) diff --git a/apps/api/src/api/services/phases/handlers/squid-router-pay-phase-handler.ts b/apps/api/src/api/services/phases/handlers/squid-router-pay-phase-handler.ts index 41aec032b..b65c3c728 100644 --- a/apps/api/src/api/services/phases/handlers/squid-router-pay-phase-handler.ts +++ b/apps/api/src/api/services/phases/handlers/squid-router-pay-phase-handler.ts @@ -252,7 +252,9 @@ export class SquidRouterPayPhaseHandler extends BasePhaseHandler { logger.info("SquidRouterPayPhaseHandler: Same-chain transaction detected. Skipping Axelar check."); } } catch (error) { - logger.error(`SquidRouterPayPhaseHandler: Error in bridge status loop for ${swapHash}:`, error); + throw this.createRecoverableError( + `SquidRouterPayPhaseHandler: Failed to check bridge status for ${swapHash}, error: ${error instanceof Error ? error.message : String(error)}` + ); } await new Promise(resolve => setTimeout(resolve, AXELAR_POLLING_INTERVAL_MS)); @@ -387,11 +389,36 @@ export class SquidRouterPayPhaseHandler extends BasePhaseHandler { const squidRouterStatus = await getStatus(swapHash, fromChainId, toChainId, state.state.squidRouterQuoteId); return squidRouterStatus; - } catch (error) { - logger.error(`SquidRouterPayPhaseHandler: Error fetching Squidrouter status for swap hash ${swapHash}:`, error); - throw this.createRecoverableError( - `SquidRouterPayPhaseHandler: Failed to fetch Squidrouter status for swap hash ${swapHash}` + } catch (squidRouterError) { + logger.warn( + `SquidRouterPayPhaseHandler: SquidRouter status check failed for swap hash ${swapHash}, attempting Axelar fallback: ${squidRouterError instanceof Error ? squidRouterError.message : String(squidRouterError)}` ); + + try { + const axelarScanStatus = await getStatusAxelarScan(swapHash); + + if (!axelarScanStatus) { + throw new Error( + `SquidRouterPayPhaseHandler: Axelar scan status not found for swap hash ${swapHash} during fallback attempt.` + ); + } + + // Map Axelar status to SquidRouter format, assuming GMP transaction. + const mappedStatus = + axelarScanStatus.status === "executed" || axelarScanStatus.status === "express_executed" + ? "success" + : axelarScanStatus.status; + + return { + isGMPTransaction: true, + status: mappedStatus + } as SquidRouterPayResponse; + } catch (axelarError) { + logger.error( + `SquidRouterPayPhaseHandler: Both SquidRouter and Axelar fallback failed for swap hash ${swapHash}. Axelar fallback error: ${axelarError instanceof Error ? axelarError.message : String(axelarError)}` + ); + throw new Error(`SquidRouterPayPhaseHandler: Failed to fetch Squidrouter status for swap hash ${swapHash}`); + } } } From e744d2a2cdae557bcc2f5d48ea0e89b98be756ba Mon Sep 17 00:00:00 2001 From: Gianfranco Date: Thu, 5 Mar 2026 12:23:50 -0300 Subject: [PATCH 02/17] make monerium onramp flow route first to destination ephemeral --- .../phases/handlers/final-settlement-subsidy.ts | 10 ++++++---- .../transactions/onramp/routes/monerium-to-evm.ts | 2 +- tsconfig.json | 3 ++- 3 files changed, 9 insertions(+), 6 deletions(-) diff --git a/apps/api/src/api/services/phases/handlers/final-settlement-subsidy.ts b/apps/api/src/api/services/phases/handlers/final-settlement-subsidy.ts index 83f45bc7e..523a7fe20 100644 --- a/apps/api/src/api/services/phases/handlers/final-settlement-subsidy.ts +++ b/apps/api/src/api/services/phases/handlers/final-settlement-subsidy.ts @@ -57,14 +57,16 @@ export class FinalSettlementSubsidyHandler extends BasePhaseHandler { const quote = await QuoteTicket.findByPk(state.quoteId); if (!quote) { - throw new Error("Quote not found for the given state"); + throw new Error("FinalSettlementSubsidyHandler: Quote not found for the given state"); } - if (!isEvmToken(quote.outputCurrency)) { - throw new Error("FinalSettlementSubsidyHandler: Output currency is not an EVM token"); + const outTokenDetails = getOnChainTokenDetails(quote.network, quote.outputCurrency) as EvmTokenDetails; + if (!outTokenDetails) { + throw new Error( + `FinalSettlementSubsidyHandler: Unsupported output token ${quote.outputCurrency} for network ${quote.network}` + ); } - const outTokenDetails = getOnChainTokenDetails(quote.network, quote.outputCurrency) as EvmTokenDetails; const expectedAmountRaw = multiplyByPowerOfTen(quote.outputAmount, outTokenDetails.decimals); const destinationNetwork = quote.network as EvmNetworks; const publicClient = evmClientManager.getClient(destinationNetwork); diff --git a/apps/api/src/api/services/transactions/onramp/routes/monerium-to-evm.ts b/apps/api/src/api/services/transactions/onramp/routes/monerium-to-evm.ts index d3abf5372..a1c6134a1 100644 --- a/apps/api/src/api/services/transactions/onramp/routes/monerium-to-evm.ts +++ b/apps/api/src/api/services/transactions/onramp/routes/monerium-to-evm.ts @@ -68,7 +68,7 @@ export async function prepareMoneriumToEvmOnrampTransactions({ const { approveData, swapData, squidRouterQuoteId, squidRouterReceiverId, squidRouterReceiverHash } = await createOnrampSquidrouterTransactionsFromPolygonToEvm({ - destinationAddress: moneriumWalletAddress, + destinationAddress: evmEphemeralEntry.address, fromAddress: evmEphemeralEntry.address, fromToken: ERC20_EURE_POLYGON_V1, rawAmount: inputAmountPostAnchorFeeRaw, diff --git a/tsconfig.json b/tsconfig.json index 071c97d1f..094f34d73 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -24,5 +24,6 @@ "strict": true, "target": "ESNext", "verbatimModuleSyntax": false - } + }, + "exclude": ["node_modules", "**/dist", "**/out", "**/.build", "**/.cache"] } From f9fc861cd1264b24116b822c4a757a786ce05e5e Mon Sep 17 00:00:00 2001 From: Gianfranco Date: Thu, 5 Mar 2026 15:10:53 -0300 Subject: [PATCH 03/17] add missing fallback transactions for monerium -> evm flow --- .../handlers/destination-transfer-handler.ts | 9 +- .../onramp/common/transactions.ts | 4 +- .../onramp/routes/monerium-to-evm.ts | 90 ++++++++++++++++++- 3 files changed, 97 insertions(+), 6 deletions(-) diff --git a/apps/api/src/api/services/phases/handlers/destination-transfer-handler.ts b/apps/api/src/api/services/phases/handlers/destination-transfer-handler.ts index bcdcb8726..445f621f7 100644 --- a/apps/api/src/api/services/phases/handlers/destination-transfer-handler.ts +++ b/apps/api/src/api/services/phases/handlers/destination-transfer-handler.ts @@ -38,11 +38,14 @@ export class DestinationTransferHandler extends BasePhaseHandler { throw new Error("Quote not found for the given state"); } - if (!isEvmToken(quote.outputCurrency)) { - throw new Error("DestinationTransferHandler: Output currency is not an EVM token"); + const outTokenDetails = getOnChainTokenDetails(quote.network, quote.outputCurrency) as EvmTokenDetails; + if (!outTokenDetails) { + throw new Error( + `FinalSettlementSubsidyHandler: Unsupported output token ${quote.outputCurrency} for network ${quote.network}` + ); } + const { txData: destinationTransfer } = this.getPresignedTransaction(state, "destinationTransfer"); - const outTokenDetails = getOnChainTokenDetails(quote.network, quote.outputCurrency) as EvmTokenDetails; const expectedAmountRaw = multiplyByPowerOfTen(quote.outputAmount, outTokenDetails.decimals).toString(); const destinationNetwork = quote.network as EvmNetworks; // We can assert this type due to checks before const { destinationTransferTxHash } = state.state; diff --git a/apps/api/src/api/services/transactions/onramp/common/transactions.ts b/apps/api/src/api/services/transactions/onramp/common/transactions.ts index 262562e73..366b04732 100644 --- a/apps/api/src/api/services/transactions/onramp/common/transactions.ts +++ b/apps/api/src/api/services/transactions/onramp/common/transactions.ts @@ -203,13 +203,13 @@ export async function addOnrampDestinationChainTransactions(params: { functionName: "transfer" }); - const { maxFeePerGas } = await publicClient.estimateFeesPerGas(); + const { maxFeePerGas, maxPriorityFeePerGas } = await publicClient.estimateFeesPerGas(); const txData: EvmTransactionData = { data: transferCallData as `0x${string}`, gas: "100000", maxFeePerGas: String(maxFeePerGas), - maxPriorityFeePerGas: String(maxFeePerGas), + maxPriorityFeePerGas: String(maxPriorityFeePerGas * 3n), to: toToken, value: "0" }; diff --git a/apps/api/src/api/services/transactions/onramp/routes/monerium-to-evm.ts b/apps/api/src/api/services/transactions/onramp/routes/monerium-to-evm.ts index a1c6134a1..bff4bcffe 100644 --- a/apps/api/src/api/services/transactions/onramp/routes/monerium-to-evm.ts +++ b/apps/api/src/api/services/transactions/onramp/routes/monerium-to-evm.ts @@ -1,16 +1,25 @@ import { createOnrampSquidrouterTransactionsFromPolygonToEvm, + createOnrampSquidrouterTransactionsOnDestinationChain, ERC20_EURE_POLYGON_V1, + EvmNetworks, + EvmToken, + EvmTokenDetails, EvmTransactionData, + evmTokenConfig, + getOnChainTokenDetailsOrDefault, isAssetHubTokenDetails, + multiplyByPowerOfTen, Networks, UnsignedTx } from "@vortexfi/shared"; import Big from "big.js"; -import { SANDBOX_ENABLED } from "../../../../../constants/constants"; +import { privateKeyToAccount } from "viem/accounts"; +import { MOONBEAM_FUNDING_PRIVATE_KEY, SANDBOX_ENABLED } from "../../../../../constants/constants"; import { StateMetadata } from "../../../phases/meta-state-types"; import { encodeEvmTransactionData } from "../../index"; import { createOnrampEphemeralSelfTransfer } from "../common/monerium"; +import { addDestinationChainApprovalTransaction, addOnrampDestinationChainTransactions } from "../common/transactions"; import { MoneriumOnrampTransactionParams, OnrampTransactionsWithMeta } from "../common/types"; import { validateMoneriumOnramp } from "../common/validation"; @@ -94,6 +103,85 @@ export async function prepareMoneriumToEvmOnrampTransactions({ txData: encodeEvmTransactionData(swapData) as EvmTransactionData }); + let destinationNonce = 0; + + const finalAmountRaw = multiplyByPowerOfTen(quote.outputAmount, outputTokenDetails.decimals); + const finalSettlementTransaction = await addOnrampDestinationChainTransactions({ + amountRaw: finalAmountRaw.toString(), + destinationNetwork: toNetwork as EvmNetworks, + toAddress: destinationAddress, + toToken: outputTokenDetails.erc20AddressSourceChain + }); + + unsignedTxs.push({ + meta: {}, + network: toNetwork, + nonce: destinationNonce, + phase: "destinationTransfer", + signer: evmEphemeralEntry.address, + txData: finalSettlementTransaction + }); + + // Fallback swap depends on the EVM chain. For Ethereum, the bridged token is USDC. For the rest, it is axlUSDC. + const destinationAxlUsdcDetails = getOnChainTokenDetailsOrDefault(toNetwork as Networks, EvmToken.AXLUSDC) as EvmTokenDetails; + + const bridgedTokenForFallback = + toNetwork === Networks.Ethereum + ? evmTokenConfig.ethereum.USDC!.erc20AddressSourceChain + : destinationAxlUsdcDetails.erc20AddressSourceChain; + + // const { approveData: destApproveData, swapData: destSwapData } = await createOnrampSquidrouterTransactionsOnDestinationChain({ + // destinationAddress: evmEphemeralEntry.address, + // fromAddress: evmEphemeralEntry.address, + // fromToken: bridgedTokenForFallback, + // network: toNetwork as EvmNetworks, + // rawAmount: quote.metadata.evmToEvm!.inputAmountRaw, + // toToken: outputTokenDetails.erc20AddressSourceChain + // }); + + // destinationNonce++; + + // unsignedTxs.push({ + // meta: {}, + // network: toNetwork, + // nonce: destinationNonce, + // phase: "backupSquidRouterApprove", + // signer: evmEphemeralEntry.address, + // txData: encodeEvmTransactionData(destApproveData) as EvmTransactionData + // }); + // destinationNonce++; + + // unsignedTxs.push({ + // meta: {}, + // network: toNetwork, + // nonce: destinationNonce, + // phase: "backupSquidRouterSwap", + // signer: evmEphemeralEntry.address, + // txData: encodeEvmTransactionData(destSwapData) as EvmTransactionData + // }); + // destinationNonce++; + + const maxUint256 = 2n ** 256n - 1n; + const fundingAccount = privateKeyToAccount(MOONBEAM_FUNDING_PRIVATE_KEY as `0x${string}`); + + const backupApproveTransaction = await addDestinationChainApprovalTransaction({ + amountRaw: maxUint256.toString(), + destinationNetwork: toNetwork as EvmNetworks, + spenderAddress: fundingAccount.address, + tokenAddress: bridgedTokenForFallback + }); + + // We set this to 0 on purpose because we don't want to risk that the required nonce is never reached + const backupApproveNonce = 0; + unsignedTxs.push({ + meta: {}, + network: toNetwork, + nonce: backupApproveNonce, + phase: "backupApprove", + signer: evmEphemeralEntry.address, + txData: backupApproveTransaction + }); + stateMeta = { ...stateMeta, squidRouterQuoteId, From f3c05eac2feb0440b56ef0cb7fb453c4778ec2ec Mon Sep 17 00:00:00 2001 From: Gianfranco Date: Thu, 5 Mar 2026 19:06:24 -0300 Subject: [PATCH 04/17] complete default values for type --- .../services/phases/handlers/squid-router-pay-phase-handler.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/apps/api/src/api/services/phases/handlers/squid-router-pay-phase-handler.ts b/apps/api/src/api/services/phases/handlers/squid-router-pay-phase-handler.ts index b65c3c728..b5b3bda87 100644 --- a/apps/api/src/api/services/phases/handlers/squid-router-pay-phase-handler.ts +++ b/apps/api/src/api/services/phases/handlers/squid-router-pay-phase-handler.ts @@ -410,7 +410,10 @@ export class SquidRouterPayPhaseHandler extends BasePhaseHandler { : axelarScanStatus.status; return { + id: "", isGMPTransaction: true, + routeStatus: [], + squidTransactionStatus: "", status: mappedStatus } as SquidRouterPayResponse; } catch (axelarError) { From 6594a8fc69fa74e03e14b08deb08601e0446df41 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Mon, 9 Mar 2026 13:30:10 +0000 Subject: [PATCH 05/17] Rename variable --- .../api/services/transactions/onramp/routes/avenia-to-evm.ts | 4 ++-- .../services/transactions/onramp/routes/monerium-to-evm.ts | 5 ++--- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/apps/api/src/api/services/transactions/onramp/routes/avenia-to-evm.ts b/apps/api/src/api/services/transactions/onramp/routes/avenia-to-evm.ts index a8aafebef..7e8ea23a0 100644 --- a/apps/api/src/api/services/transactions/onramp/routes/avenia-to-evm.ts +++ b/apps/api/src/api/services/transactions/onramp/routes/avenia-to-evm.ts @@ -192,7 +192,7 @@ export async function prepareAveniaToEvmOnrampTransactions({ let destinationNonce = 0; const finalAmountRaw = multiplyByPowerOfTen(quote.outputAmount, outputTokenDetails.decimals); - const finalSettlementTransaction = await addOnrampDestinationChainTransactions({ + const finalDestinationTransfer = await addOnrampDestinationChainTransactions({ amountRaw: finalAmountRaw.toString(), destinationNetwork: toNetwork as EvmNetworks, toAddress: destinationAddress, @@ -205,7 +205,7 @@ export async function prepareAveniaToEvmOnrampTransactions({ nonce: destinationNonce, phase: "destinationTransfer", signer: evmEphemeralEntry.address, - txData: finalSettlementTransaction + txData: finalDestinationTransfer }); destinationNonce++; diff --git a/apps/api/src/api/services/transactions/onramp/routes/monerium-to-evm.ts b/apps/api/src/api/services/transactions/onramp/routes/monerium-to-evm.ts index bff4bcffe..f4f479271 100644 --- a/apps/api/src/api/services/transactions/onramp/routes/monerium-to-evm.ts +++ b/apps/api/src/api/services/transactions/onramp/routes/monerium-to-evm.ts @@ -1,6 +1,5 @@ import { createOnrampSquidrouterTransactionsFromPolygonToEvm, - createOnrampSquidrouterTransactionsOnDestinationChain, ERC20_EURE_POLYGON_V1, EvmNetworks, EvmToken, @@ -106,7 +105,7 @@ export async function prepareMoneriumToEvmOnrampTransactions({ let destinationNonce = 0; const finalAmountRaw = multiplyByPowerOfTen(quote.outputAmount, outputTokenDetails.decimals); - const finalSettlementTransaction = await addOnrampDestinationChainTransactions({ + const finalDestinationTransfer = await addOnrampDestinationChainTransactions({ amountRaw: finalAmountRaw.toString(), destinationNetwork: toNetwork as EvmNetworks, toAddress: destinationAddress, @@ -119,7 +118,7 @@ export async function prepareMoneriumToEvmOnrampTransactions({ nonce: destinationNonce, phase: "destinationTransfer", signer: evmEphemeralEntry.address, - txData: finalSettlementTransaction + txData: finalDestinationTransfer }); // Fallback swap depends on the EVM chain. For Ethereum, the bridged token is USDC. For the rest, it is axlUSDC. From 8fd2d776581bf8ab0ad1f40cf6c7d6e3b8bb587a Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Mon, 9 Mar 2026 13:35:34 +0000 Subject: [PATCH 06/17] Adjust nonce --- .../services/transactions/onramp/routes/monerium-to-evm.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/apps/api/src/api/services/transactions/onramp/routes/monerium-to-evm.ts b/apps/api/src/api/services/transactions/onramp/routes/monerium-to-evm.ts index f4f479271..2ae6d3e48 100644 --- a/apps/api/src/api/services/transactions/onramp/routes/monerium-to-evm.ts +++ b/apps/api/src/api/services/transactions/onramp/routes/monerium-to-evm.ts @@ -170,8 +170,9 @@ export async function prepareMoneriumToEvmOnrampTransactions({ tokenAddress: bridgedTokenForFallback }); - // We set this to 0 on purpose because we don't want to risk that the required nonce is never reached - const backupApproveNonce = 0; + // We set this to 0 for non-polygon networks on purpose because we don't want to risk that the required nonce + // is never reached + const backupApproveNonce = toNetwork === Networks.Polygon ? polygonAccountNonce : 0; unsignedTxs.push({ meta: {}, network: toNetwork, From 6cc786ed3213cb5e4e4e10945275df4fe5f59948 Mon Sep 17 00:00:00 2001 From: Gianfranco Date: Mon, 9 Mar 2026 12:44:06 -0300 Subject: [PATCH 07/17] fix monerium to evm squidrouter backups --- .../onramp/routes/monerium-to-evm.ts | 73 +++++++++++-------- tsconfig.json | 3 +- 2 files changed, 44 insertions(+), 32 deletions(-) diff --git a/apps/api/src/api/services/transactions/onramp/routes/monerium-to-evm.ts b/apps/api/src/api/services/transactions/onramp/routes/monerium-to-evm.ts index 2ae6d3e48..b7d1c8994 100644 --- a/apps/api/src/api/services/transactions/onramp/routes/monerium-to-evm.ts +++ b/apps/api/src/api/services/transactions/onramp/routes/monerium-to-evm.ts @@ -1,3 +1,4 @@ +import { createOnrampSquidrouterTransactionsOnDestinationChain } from "@packages/shared"; import { createOnrampSquidrouterTransactionsFromPolygonToEvm, ERC20_EURE_POLYGON_V1, @@ -16,6 +17,7 @@ import Big from "big.js"; import { privateKeyToAccount } from "viem/accounts"; import { MOONBEAM_FUNDING_PRIVATE_KEY, SANDBOX_ENABLED } from "../../../../../constants/constants"; import { StateMetadata } from "../../../phases/meta-state-types"; +import { priceFeedService } from "../../../priceFeed.service"; import { encodeEvmTransactionData } from "../../index"; import { createOnrampEphemeralSelfTransfer } from "../common/monerium"; import { addDestinationChainApprovalTransaction, addOnrampDestinationChainTransactions } from "../common/transactions"; @@ -45,6 +47,11 @@ export async function prepareMoneriumToEvmOnrampTransactions({ if (!quote.metadata.moneriumMint?.outputAmountRaw) { throw new Error("Missing moonbeamToEvm output amount in quote metadata"); } + + if (!quote.metadata.evmToEvm?.inputAmountDecimal) { + throw new Error("Missing evmToEvm input amount in quote metadata"); + } + const inputAmountPostAnchorFeeRaw = new Big(quote.metadata.moneriumMint.outputAmountRaw).toFixed(0, 0); // Setup state metadata @@ -129,36 +136,42 @@ export async function prepareMoneriumToEvmOnrampTransactions({ ? evmTokenConfig.ethereum.USDC!.erc20AddressSourceChain : destinationAxlUsdcDetails.erc20AddressSourceChain; - // const { approveData: destApproveData, swapData: destSwapData } = await createOnrampSquidrouterTransactionsOnDestinationChain({ - // destinationAddress: evmEphemeralEntry.address, - // fromAddress: evmEphemeralEntry.address, - // fromToken: bridgedTokenForFallback, - // network: toNetwork as EvmNetworks, - // rawAmount: quote.metadata.evmToEvm!.inputAmountRaw, - // toToken: outputTokenDetails.erc20AddressSourceChain - // }); - - // destinationNonce++; - - // unsignedTxs.push({ - // meta: {}, - // network: toNetwork, - // nonce: destinationNonce, - // phase: "backupSquidRouterApprove", - // signer: evmEphemeralEntry.address, - // txData: encodeEvmTransactionData(destApproveData) as EvmTransactionData - // }); - // destinationNonce++; - - // unsignedTxs.push({ - // meta: {}, - // network: toNetwork, - // nonce: destinationNonce, - // phase: "backupSquidRouterSwap", - // signer: evmEphemeralEntry.address, - // txData: encodeEvmTransactionData(destSwapData) as EvmTransactionData - // }); - // destinationNonce++; + const intermediateUsdAmount = await priceFeedService.convertCurrency( + Big(quote!.metadata.evmToEvm?.inputAmountDecimal).toFixed(2, 0), + quote.inputCurrency, + EvmToken.USDC + ); + + const { approveData: destApproveData, swapData: destSwapData } = await createOnrampSquidrouterTransactionsOnDestinationChain({ + destinationAddress: evmEphemeralEntry.address, + fromAddress: evmEphemeralEntry.address, + fromToken: bridgedTokenForFallback, + network: toNetwork as EvmNetworks, + rawAmount: multiplyByPowerOfTen(intermediateUsdAmount, destinationAxlUsdcDetails.decimals).toFixed(0, 0), + toToken: outputTokenDetails.erc20AddressSourceChain + }); + + destinationNonce++; + + unsignedTxs.push({ + meta: {}, + network: toNetwork, + nonce: destinationNonce, + phase: "backupSquidRouterApprove", + signer: evmEphemeralEntry.address, + txData: encodeEvmTransactionData(destApproveData) as EvmTransactionData + }); + destinationNonce++; + + unsignedTxs.push({ + meta: {}, + network: toNetwork, + nonce: destinationNonce, + phase: "backupSquidRouterSwap", + signer: evmEphemeralEntry.address, + txData: encodeEvmTransactionData(destSwapData) as EvmTransactionData + }); + destinationNonce++; const maxUint256 = 2n ** 256n - 1n; const fundingAccount = privateKeyToAccount(MOONBEAM_FUNDING_PRIVATE_KEY as `0x${string}`); diff --git a/tsconfig.json b/tsconfig.json index 094f34d73..071c97d1f 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -24,6 +24,5 @@ "strict": true, "target": "ESNext", "verbatimModuleSyntax": false - }, - "exclude": ["node_modules", "**/dist", "**/out", "**/.build", "**/.cache"] + } } From 74e3c29a22a47ff52995b867b7a47eaea0095379 Mon Sep 17 00:00:00 2001 From: Gianfranco Date: Mon, 9 Mar 2026 19:59:46 -0300 Subject: [PATCH 08/17] fix invalid import --- .../api/services/transactions/onramp/routes/monerium-to-evm.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/api/src/api/services/transactions/onramp/routes/monerium-to-evm.ts b/apps/api/src/api/services/transactions/onramp/routes/monerium-to-evm.ts index b7d1c8994..05db98f14 100644 --- a/apps/api/src/api/services/transactions/onramp/routes/monerium-to-evm.ts +++ b/apps/api/src/api/services/transactions/onramp/routes/monerium-to-evm.ts @@ -1,6 +1,6 @@ -import { createOnrampSquidrouterTransactionsOnDestinationChain } from "@packages/shared"; import { createOnrampSquidrouterTransactionsFromPolygonToEvm, + createOnrampSquidrouterTransactionsOnDestinationChain, ERC20_EURE_POLYGON_V1, EvmNetworks, EvmToken, From 4b06c9b340f2f7cba9db9ea4b89329e760e3e20e Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Tue, 10 Mar 2026 10:09:21 +0000 Subject: [PATCH 09/17] Fix issues with wrong output decimals for onramps --- .../squidrouter/onramp-moonbeam-to-evm.ts | 7 ++++--- .../engines/squidrouter/onramp-polygon-to-evm.ts | 16 +++++----------- .../squidrouter/onramp-polygon-to-moonbeam.ts | 15 ++++++--------- .../onramp/routes/monerium-to-evm.ts | 10 +++++++--- 4 files changed, 22 insertions(+), 26 deletions(-) diff --git a/apps/api/src/api/services/quote/engines/squidrouter/onramp-moonbeam-to-evm.ts b/apps/api/src/api/services/quote/engines/squidrouter/onramp-moonbeam-to-evm.ts index 305f796fa..909b8209f 100644 --- a/apps/api/src/api/services/quote/engines/squidrouter/onramp-moonbeam-to-evm.ts +++ b/apps/api/src/api/services/quote/engines/squidrouter/onramp-moonbeam-to-evm.ts @@ -32,7 +32,8 @@ export class OnRampSquidRouterBrlToEvmEngine extends BaseSquidRouterEngine { ); } - const toToken = getTokenDetailsForEvmDestination(req.outputCurrency as OnChainToken, toNetwork).erc20AddressSourceChain; + const toToken = getTokenDetailsForEvmDestination(req.outputCurrency as OnChainToken, toNetwork); + const toTokenAddress = toToken.erc20AddressSourceChain; // biome-ignore lint/style/noNonNullAssertion: Context is validated in validate const pendulumToMoonbeamXcm = ctx.pendulumToMoonbeamXcm!; @@ -43,9 +44,9 @@ export class OnRampSquidRouterBrlToEvmEngine extends BaseSquidRouterEngine { fromToken: AXL_USDC_MOONBEAM, inputAmountDecimal: pendulumToMoonbeamXcm.outputAmountDecimal, inputAmountRaw: pendulumToMoonbeamXcm.outputAmountRaw, - outputDecimals: getTokenDetailsForEvmDestination(req.outputCurrency as OnChainToken, toNetwork).decimals, + outputDecimals: toToken.decimals, toNetwork, - toToken + toToken: toTokenAddress }, type: "moonbeam-to-evm" }; diff --git a/apps/api/src/api/services/quote/engines/squidrouter/onramp-polygon-to-evm.ts b/apps/api/src/api/services/quote/engines/squidrouter/onramp-polygon-to-evm.ts index 89b3c09f5..8764edc9a 100644 --- a/apps/api/src/api/services/quote/engines/squidrouter/onramp-polygon-to-evm.ts +++ b/apps/api/src/api/services/quote/engines/squidrouter/onramp-polygon-to-evm.ts @@ -1,11 +1,4 @@ -import { - ERC20_EURE_POLYGON_DECIMALS, - ERC20_EURE_POLYGON_V1, - getNetworkFromDestination, - Networks, - OnChainToken, - RampDirection -} from "@vortexfi/shared"; +import { ERC20_EURE_POLYGON_V1, getNetworkFromDestination, Networks, OnChainToken, RampDirection } from "@vortexfi/shared"; import httpStatus from "http-status"; import { APIError } from "../../../../errors/api-error"; import { getTokenDetailsForEvmDestination } from "../../core/squidrouter"; @@ -42,7 +35,8 @@ export class OnRampSquidRouterEurToEvmEngine extends BaseSquidRouterEngine { }); } - const toToken = getTokenDetailsForEvmDestination(req.outputCurrency as OnChainToken, req.to).erc20AddressSourceChain; + const toToken = getTokenDetailsForEvmDestination(req.outputCurrency as OnChainToken, req.to); + const toTokenAddress = toToken.erc20AddressSourceChain; // biome-ignore lint/style/noNonNullAssertion: Context is validated in validate const moneriumMint = ctx.moneriumMint!; @@ -53,9 +47,9 @@ export class OnRampSquidRouterEurToEvmEngine extends BaseSquidRouterEngine { fromToken: ERC20_EURE_POLYGON_V1, inputAmountDecimal: moneriumMint.outputAmountDecimal, inputAmountRaw: moneriumMint.outputAmountRaw, - outputDecimals: ERC20_EURE_POLYGON_DECIMALS, + outputDecimals: toToken.decimals, toNetwork, - toToken + toToken: toTokenAddress }, type: "evm-to-evm" }; diff --git a/apps/api/src/api/services/quote/engines/squidrouter/onramp-polygon-to-moonbeam.ts b/apps/api/src/api/services/quote/engines/squidrouter/onramp-polygon-to-moonbeam.ts index d2ea2929f..3c87d24ea 100644 --- a/apps/api/src/api/services/quote/engines/squidrouter/onramp-polygon-to-moonbeam.ts +++ b/apps/api/src/api/services/quote/engines/squidrouter/onramp-polygon-to-moonbeam.ts @@ -1,10 +1,4 @@ -import { - AXL_USDC_MOONBEAM, - ERC20_EURE_POLYGON_DECIMALS, - ERC20_EURE_POLYGON_V1, - Networks, - RampDirection -} from "@vortexfi/shared"; +import { AXL_USDC_MOONBEAM_DETAILS, ERC20_EURE_POLYGON_V1, Networks, RampDirection } from "@vortexfi/shared"; import { QuoteContext } from "../../core/types"; import { BaseSquidRouterEngine, SquidRouterComputation, SquidRouterConfig } from "./index"; @@ -32,6 +26,9 @@ export class OnRampSquidRouterEurToAssetHubEngine extends BaseSquidRouterEngine // biome-ignore lint/style/noNonNullAssertion: Context is validated in validate const moneriumMint = ctx.moneriumMint!; + const toToken = AXL_USDC_MOONBEAM_DETAILS; + const toTokenAddress = toToken.erc20AddressSourceChain; + return { data: { amountRaw: moneriumMint.outputAmountRaw, @@ -39,9 +36,9 @@ export class OnRampSquidRouterEurToAssetHubEngine extends BaseSquidRouterEngine fromToken: ERC20_EURE_POLYGON_V1, inputAmountDecimal: moneriumMint.outputAmountDecimal, inputAmountRaw: moneriumMint.outputAmountRaw, - outputDecimals: ERC20_EURE_POLYGON_DECIMALS, + outputDecimals: toToken.decimals, toNetwork: Networks.Moonbeam, - toToken: AXL_USDC_MOONBEAM + toToken: toTokenAddress }, type: "evm-to-moonbeam" }; diff --git a/apps/api/src/api/services/transactions/onramp/routes/monerium-to-evm.ts b/apps/api/src/api/services/transactions/onramp/routes/monerium-to-evm.ts index 05db98f14..ee41077ba 100644 --- a/apps/api/src/api/services/transactions/onramp/routes/monerium-to-evm.ts +++ b/apps/api/src/api/services/transactions/onramp/routes/monerium-to-evm.ts @@ -136,18 +136,22 @@ export async function prepareMoneriumToEvmOnrampTransactions({ ? evmTokenConfig.ethereum.USDC!.erc20AddressSourceChain : destinationAxlUsdcDetails.erc20AddressSourceChain; - const intermediateUsdAmount = await priceFeedService.convertCurrency( - Big(quote!.metadata.evmToEvm?.inputAmountDecimal).toFixed(2, 0), + const intermediateUsdAmountForFallback = await priceFeedService.convertCurrency( + Big(quote.metadata.evmToEvm?.inputAmountDecimal).toFixed(2, 0), quote.inputCurrency, EvmToken.USDC ); + const intermediateUsdAmountForFallbackRaw = multiplyByPowerOfTen( + intermediateUsdAmountForFallback, + destinationAxlUsdcDetails.decimals + ).toFixed(0, 0); const { approveData: destApproveData, swapData: destSwapData } = await createOnrampSquidrouterTransactionsOnDestinationChain({ destinationAddress: evmEphemeralEntry.address, fromAddress: evmEphemeralEntry.address, fromToken: bridgedTokenForFallback, network: toNetwork as EvmNetworks, - rawAmount: multiplyByPowerOfTen(intermediateUsdAmount, destinationAxlUsdcDetails.decimals).toFixed(0, 0), + rawAmount: intermediateUsdAmountForFallbackRaw, toToken: outputTokenDetails.erc20AddressSourceChain }); From ce851a11f4e41831913b65a453f4ab9206c996d2 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Tue, 10 Mar 2026 10:20:35 +0000 Subject: [PATCH 10/17] Add concurrency/rate limit for squidrouter route query --- bun.lock | 5 +++++ packages/shared/package.json | 1 + packages/shared/src/services/squidrouter/route.ts | 10 ++++++++++ 3 files changed, 16 insertions(+) diff --git a/bun.lock b/bun.lock index b21883d30..f423a726e 100644 --- a/bun.lock +++ b/bun.lock @@ -275,6 +275,7 @@ "axios": "catalog:", "big.js": "catalog:", "node-forge": "^1.3.1", + "p-queue": "^9.1.0", "stellar-sdk": "catalog:", "web3": "catalog:", "winston": "catalog:", @@ -3258,6 +3259,10 @@ "p-map": ["p-map@4.0.0", "", { "dependencies": { "aggregate-error": "^3.0.0" } }, "sha512-/bjOqmgETBYB5BoEeGVea8dmvHb2m9GLy1E9W43yeyfP6QQCZGFNa+XRceJEuDB6zqr+gKpIAmlLebMpykw/MQ=="], + "p-queue": ["p-queue@9.1.0", "", { "dependencies": { "eventemitter3": "^5.0.1", "p-timeout": "^7.0.0" } }, "sha512-O/ZPaXuQV29uSLbxWBGGZO1mCQXV2BLIwUr59JUU9SoH76mnYvtms7aafH/isNSNGwuEfP6W/4xD0/TJXxrizw=="], + + "p-timeout": ["p-timeout@7.0.1", "", {}, "sha512-AxTM2wDGORHGEkPCt8yqxOTMgpfbEHqF51f/5fJCmwFC3C/zNcGT63SymH2ttOAaiIws2zVg4+izQCjrakcwHg=="], + "p-try": ["p-try@2.2.0", "", {}, "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ=="], "package-json-from-dist": ["package-json-from-dist@1.0.1", "", {}, "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw=="], diff --git a/packages/shared/package.json b/packages/shared/package.json index dbf3aaf73..9dd8dcf41 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -16,6 +16,7 @@ "axios": "catalog:", "big.js": "catalog:", "node-forge": "^1.3.1", + "p-queue": "^9.1.0", "stellar-sdk": "catalog:", "web3": "catalog:", "winston": "catalog:" diff --git a/packages/shared/src/services/squidrouter/route.ts b/packages/shared/src/services/squidrouter/route.ts index c5ef315c3..152e0a835 100644 --- a/packages/shared/src/services/squidrouter/route.ts +++ b/packages/shared/src/services/squidrouter/route.ts @@ -1,4 +1,5 @@ import axios, { AxiosError } from "axios"; +import PQueue from "p-queue"; import { encodeFunctionData, PublicClient } from "viem"; import erc20ABI from "../../contracts/ERC20"; import splitReceiverABI from "../../contracts/moonbeam/splitReceiverABI.json"; @@ -121,7 +122,16 @@ export interface SquidrouterRouteResult { requestId: string; } +// Rate-limited queue: at most 1 concurrent request, with a minimum 500ms gap between calls. +// This prevents hitting SquidRouter API rate limits when multiple getRoute() calls happen in quick succession. +const routeQueue = new PQueue({ concurrency: 1, interval: 1000, intervalCap: 1 }); + export async function getRoute(params: RouteParams): Promise { + const result = (await routeQueue.add(() => getRouteInternal(params))) as SquidrouterRouteResult; + return result; +} + +async function getRouteInternal(params: RouteParams): Promise { // This is the integrator ID for the Squidrouter API const { integratorId } = squidRouterConfigBase; const url = `${SQUIDROUTER_BASE_URL}/route`; From 1cf87607842b37fbb73536adb76e369f520d493e Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Tue, 10 Mar 2026 10:39:28 +0000 Subject: [PATCH 11/17] Add handling for native token to final-settlement-subsidy.ts --- .../handlers/final-settlement-subsidy.ts | 108 ++++++++++++------ packages/shared/src/services/evm/balance.ts | 65 +++++++++++ .../shared/src/services/evm/clientManager.ts | 28 +++++ 3 files changed, 168 insertions(+), 33 deletions(-) diff --git a/apps/api/src/api/services/phases/handlers/final-settlement-subsidy.ts b/apps/api/src/api/services/phases/handlers/final-settlement-subsidy.ts index 523a7fe20..76a1045c6 100644 --- a/apps/api/src/api/services/phases/handlers/final-settlement-subsidy.ts +++ b/apps/api/src/api/services/phases/handlers/final-settlement-subsidy.ts @@ -1,12 +1,13 @@ import { checkEvmBalancePeriodically, + checkEvmNativeBalancePeriodically, EvmClientManager, EvmNetworks, EvmTokenDetails, + getEvmNativeBalance, getNetworkId, getOnChainTokenDetails, getRoute, - isEvmToken, multiplyByPowerOfTen, Networks, RampCurrency, @@ -67,6 +68,9 @@ export class FinalSettlementSubsidyHandler extends BasePhaseHandler { ); } + const isNativeToken = + outTokenDetails.isNative || outTokenDetails.erc20AddressSourceChain.toLowerCase() === NATIVE_TOKEN_ADDRESS.toLowerCase(); + const expectedAmountRaw = multiplyByPowerOfTen(quote.outputAmount, outTokenDetails.decimals); const destinationNetwork = quote.network as EvmNetworks; const publicClient = evmClientManager.getClient(destinationNetwork); @@ -88,21 +92,37 @@ export class FinalSettlementSubsidyHandler extends BasePhaseHandler { } } - const actualBalance = await checkEvmBalancePeriodically( - outTokenDetails.erc20AddressSourceChain, - ephemeralAddress, - "1", // If we passed expectedAmountRaw, we might timeout if the bridge slipped and delivered slightly less. - BALANCE_POLLING_TIME_MS, - EVM_BALANCE_CHECK_TIMEOUT_MS, - destinationNetwork - ); + // 2. Check ephemeral address balance (native vs ERC-20) + const actualBalance = isNativeToken + ? await checkEvmNativeBalancePeriodically( + ephemeralAddress, + "1", // If we passed expectedAmountRaw, we might timeout if the bridge slipped and delivered slightly less. + BALANCE_POLLING_TIME_MS, + EVM_BALANCE_CHECK_TIMEOUT_MS, + destinationNetwork + ) + : await checkEvmBalancePeriodically( + outTokenDetails.erc20AddressSourceChain, + ephemeralAddress, + "1", // If we passed expectedAmountRaw, we might timeout if the bridge slipped and delivered slightly less. + BALANCE_POLLING_TIME_MS, + EVM_BALANCE_CHECK_TIMEOUT_MS, + destinationNetwork + ); - const actualBalanceFundingAccount = await publicClient.readContract({ - abi: erc20Abi, - address: outTokenDetails.erc20AddressSourceChain as `0x${string}`, - args: [fundingAccount.address], - functionName: "balanceOf" - }); + // 3. Check funding account balance (native vs ERC-20) + const actualBalanceFundingAccount = isNativeToken + ? await getEvmNativeBalance(fundingAccount.address, destinationNetwork) + : new Big( + ( + await publicClient.readContract({ + abi: erc20Abi, + address: outTokenDetails.erc20AddressSourceChain as `0x${string}`, + args: [fundingAccount.address], + functionName: "balanceOf" + }) + ).toString() + ); const subsidyAmountRaw = expectedAmountRaw.minus(actualBalance); @@ -113,10 +133,22 @@ export class FinalSettlementSubsidyHandler extends BasePhaseHandler { return this.transitionToNextPhase(state, "destinationTransfer"); } - logger.info(`FinalSettlementSubsidyHandler: Subsidizing ${subsidyAmountRaw.toString()} units to ${ephemeralAddress}`); + logger.info( + `FinalSettlementSubsidyHandler: Subsidizing ${subsidyAmountRaw.toString()} units of ${isNativeToken ? "native token" : outTokenDetails.assetSymbol} to ${ephemeralAddress}` + ); + + // 4. Top up funding account if insufficient balance + if (actualBalanceFundingAccount.lt(subsidyAmountRaw)) { + if (isNativeToken) { + // For native tokens, we cannot easily swap into the native token on-chain. + // The funding account must be pre-funded with sufficient native balance. + throw this.createUnrecoverableError( + `FinalSettlementSubsidyHandler: Funding account has insufficient native token balance ` + + `(${actualBalanceFundingAccount.toString()}) to cover subsidy of ${subsidyAmountRaw.toString()} on ${destinationNetwork}. ` + + `Please top up the funding account with native tokens manually.` + ); + } - // Check if funding account has enough balance - if (new Big(actualBalanceFundingAccount.toString()).lt(subsidyAmountRaw)) { logger.info( `FinalSettlementSubsidyHandler: Funding account has insufficient balance. Swapping native token to ${outTokenDetails.assetSymbol}` ); @@ -215,30 +247,40 @@ export class FinalSettlementSubsidyHandler extends BasePhaseHandler { ); } - // Execution Loop + // 5. Execute the subsidy transfer (native value transfer vs ERC-20 transfer) let txHash: `0x${string}` | undefined = state.state.finalSettlementSubsidyTxHash as `0x${string}` | undefined; try { - const data = encodeFunctionData({ - abi: erc20Abi, - args: [ephemeralAddress, BigInt(subsidyAmountRaw.toFixed(0))], - functionName: "transfer" - }); - const { maxFeePerGas, maxPriorityFeePerGas } = await publicClient.estimateFeesPerGas(); let receipt: TransactionReceipt | undefined = undefined; let attempt = 0; while (attempt < 5 && (!receipt || receipt.status !== "success")) { - // Blind retry for transaction submission - txHash = await evmClientManager.sendTransactionWithBlindRetry(destinationNetwork, fundingAccount, { - data, - maxFeePerGas, - maxPriorityFeePerGas, - to: outTokenDetails.erc20AddressSourceChain as `0x${string}`, - value: 0n - }); + if (isNativeToken) { + // Native token: simple value transfer, no contract interaction + txHash = await evmClientManager.sendTransactionWithBlindRetry(destinationNetwork, fundingAccount, { + maxFeePerGas, + maxPriorityFeePerGas, + to: ephemeralAddress, + value: BigInt(subsidyAmountRaw.toFixed(0)) + }); + } else { + // ERC-20: encode transfer call + const data = encodeFunctionData({ + abi: erc20Abi, + args: [ephemeralAddress, BigInt(subsidyAmountRaw.toFixed(0))], + functionName: "transfer" + }); + + txHash = await evmClientManager.sendTransactionWithBlindRetry(destinationNetwork, fundingAccount, { + data, + maxFeePerGas, + maxPriorityFeePerGas, + to: outTokenDetails.erc20AddressSourceChain as `0x${string}`, + value: 0n + }); + } receipt = await publicClient.waitForTransactionReceipt({ hash: txHash }); diff --git a/packages/shared/src/services/evm/balance.ts b/packages/shared/src/services/evm/balance.ts index 875aa1b27..0358593a0 100644 --- a/packages/shared/src/services/evm/balance.ts +++ b/packages/shared/src/services/evm/balance.ts @@ -1,6 +1,7 @@ import Big from "big.js"; import erc20ABI from "../../contracts/ERC20"; import { EvmAddress, EvmNetworks } from "../../index"; +import logger from "../../logger"; import { EvmClientManager } from "../evm/clientManager"; export enum BalanceCheckErrorType { @@ -88,3 +89,67 @@ export function checkEvmBalancePeriodically( checkBalance(); }); } + +/** + * Gets the native token balance for a given address on the specified chain, using RPC retry logic. + */ +export async function getEvmNativeBalance(ownerAddress: EvmAddress, chain: EvmNetworks): Promise { + try { + const evmClientManager = EvmClientManager.getInstance(); + const balance = await evmClientManager.getBalanceWithRetry(chain, ownerAddress); + return new Big(balance.toString()); + } catch (err) { + throw new Error(`Failed to read native balance: ${err instanceof Error ? err.message : String(err)}`); + } +} + +/** + * Periodically checks the native token balance of an address on the specified chain + * until the desired amount is met or the timeout is reached. + */ +export function checkEvmNativeBalancePeriodically( + ownerAddress: string, + amountDesiredRaw: string, + intervalMs: number, + timeoutMs: number, + chain: EvmNetworks +): Promise { + const evmClientManager = EvmClientManager.getInstance(); + + return new Promise((resolve, reject) => { + const startTime = Date.now(); + + const checkBalance = async () => { + try { + const balance = await evmClientManager.getBalanceWithRetry(chain, ownerAddress as EvmAddress); + const balanceBig = new Big(balance.toString()); + const amountDesiredUnitsBig = new Big(amountDesiredRaw); + + logger.current.debug( + `checkEvmNativeBalancePeriodically: Native balance of ${ownerAddress} on ${chain}: ${balanceBig.toString()} (target: ${amountDesiredRaw})` + ); + + if (balanceBig.gte(amountDesiredUnitsBig)) { + resolve(balanceBig); + } else if (Date.now() - startTime > timeoutMs) { + reject( + new BalanceCheckError(BalanceCheckErrorType.Timeout, `Native balance did not meet the limit within ${timeoutMs}ms`) + ); + } else { + // Schedule next check AFTER this one completes to prevent overlapping calls + setTimeout(checkBalance, intervalMs); + } + } catch (err: unknown) { + reject( + new BalanceCheckError( + BalanceCheckErrorType.ReadFailure, + `Error checking native balance: ${err instanceof Error ? err.message : String(err)}` + ) + ); + } + }; + + // Start the first check immediately + checkBalance(); + }); +} diff --git a/packages/shared/src/services/evm/clientManager.ts b/packages/shared/src/services/evm/clientManager.ts index 3843e9ebf..9e8fb02b6 100644 --- a/packages/shared/src/services/evm/clientManager.ts +++ b/packages/shared/src/services/evm/clientManager.ts @@ -327,6 +327,34 @@ export class EvmClientManager { ); } + /** + * Gets the native balance of an address with smart retry logic using exponential backoff and RPC switching. + * Exhausts all available RPCs before repeating selections. + * + * @param networkName - The EVM network to query + * @param address - The address to get the native balance of + * @param maxRetries - Maximum number of retry attempts (default: 3) + * @param initialDelayMs - Initial delay in milliseconds before first retry (default: 1000) + * @returns Native balance as bigint + */ + public async getBalanceWithRetry( + networkName: EvmNetworks, + address: `0x${string}`, + maxRetries = 3, + initialDelayMs = 1000 + ): Promise { + return this.executeWithRetry( + networkName, + async rpcUrl => { + const publicClient = this.getClient(networkName, rpcUrl); + return await publicClient.getBalance({ address }); + }, + "get native balance", + maxRetries, + initialDelayMs + ); + } + /** * Sends a raw transaction with smart retry logic using exponential backoff and RPC switching. * Exhausts all available RPCs before repeating selections. From 54a6c7af739113c8a85aab6971f303d3dfa2beab Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Tue, 10 Mar 2026 11:52:43 +0000 Subject: [PATCH 12/17] Add handling for native token to destination transfer handler --- .../handlers/destination-transfer-handler.ts | 37 ++++++++++++------- .../onramp/common/transactions.ts | 22 +++++++++-- .../onramp/routes/avenia-to-evm.ts | 1 + .../onramp/routes/monerium-to-evm.ts | 1 + 4 files changed, 45 insertions(+), 16 deletions(-) diff --git a/apps/api/src/api/services/phases/handlers/destination-transfer-handler.ts b/apps/api/src/api/services/phases/handlers/destination-transfer-handler.ts index 445f621f7..a06df7c26 100644 --- a/apps/api/src/api/services/phases/handlers/destination-transfer-handler.ts +++ b/apps/api/src/api/services/phases/handlers/destination-transfer-handler.ts @@ -1,14 +1,11 @@ import { checkEvmBalancePeriodically, + checkEvmNativeBalancePeriodically, EvmClientManager, EvmNetworks, EvmTokenDetails, - FiatToken, - getAnyFiatTokenDetailsMoonbeam, getOnChainTokenDetails, - isEvmToken, multiplyByPowerOfTen, - Networks, RampDirection, RampPhase } from "@vortexfi/shared"; @@ -18,6 +15,7 @@ import { BasePhaseHandler } from "../base-phase-handler"; const BALANCE_POLLING_TIME_MS = 5000; const EVM_BALANCE_CHECK_TIMEOUT_MS = 3 * 60 * 1000; // 3 minutes +const NATIVE_TOKEN_ADDRESS = "0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE"; /** * Handler for transferring funds to the destination address on EVM networks (onramp only) */ @@ -41,10 +39,13 @@ export class DestinationTransferHandler extends BasePhaseHandler { const outTokenDetails = getOnChainTokenDetails(quote.network, quote.outputCurrency) as EvmTokenDetails; if (!outTokenDetails) { throw new Error( - `FinalSettlementSubsidyHandler: Unsupported output token ${quote.outputCurrency} for network ${quote.network}` + `DestinationTransferHandler: Unsupported output token ${quote.outputCurrency} for network ${quote.network}` ); } + const isNativeToken = + outTokenDetails.isNative || outTokenDetails.erc20AddressSourceChain.toLowerCase() === NATIVE_TOKEN_ADDRESS.toLowerCase(); + const { txData: destinationTransfer } = this.getPresignedTransaction(state, "destinationTransfer"); const expectedAmountRaw = multiplyByPowerOfTen(quote.outputAmount, outTokenDetails.decimals).toString(); const destinationNetwork = quote.network as EvmNetworks; // We can assert this type due to checks before @@ -69,14 +70,24 @@ export class DestinationTransferHandler extends BasePhaseHandler { // main phase execution loop: try { - await checkEvmBalancePeriodically( - outTokenDetails.erc20AddressSourceChain, - state.state.evmEphemeralAddress, - expectedAmountRaw, - BALANCE_POLLING_TIME_MS, - EVM_BALANCE_CHECK_TIMEOUT_MS, - destinationNetwork - ); + if (isNativeToken) { + await checkEvmNativeBalancePeriodically( + state.state.evmEphemeralAddress, + expectedAmountRaw, + BALANCE_POLLING_TIME_MS, + EVM_BALANCE_CHECK_TIMEOUT_MS, + destinationNetwork + ); + } else { + await checkEvmBalancePeriodically( + outTokenDetails.erc20AddressSourceChain, + state.state.evmEphemeralAddress, + expectedAmountRaw, + BALANCE_POLLING_TIME_MS, + EVM_BALANCE_CHECK_TIMEOUT_MS, + destinationNetwork + ); + } // send the transaction, log hash in the state for recovery. const txHash = await evmClientManager.sendRawTransactionWithRetry( diff --git a/apps/api/src/api/services/transactions/onramp/common/transactions.ts b/apps/api/src/api/services/transactions/onramp/common/transactions.ts index 366b04732..49c183668 100644 --- a/apps/api/src/api/services/transactions/onramp/common/transactions.ts +++ b/apps/api/src/api/services/transactions/onramp/common/transactions.ts @@ -191,20 +191,36 @@ export async function addOnrampDestinationChainTransactions(params: { toToken: `0x${string}`; amountRaw: string; destinationNetwork: EvmNetworks; + isNativeToken?: boolean; }): Promise { - const { toAddress, amountRaw, destinationNetwork, toToken } = params; + const { toAddress, amountRaw, destinationNetwork, toToken, isNativeToken } = params; const evmClientManager = EvmClientManager.getInstance(); const publicClient = evmClientManager.getClient(destinationNetwork); + const { maxFeePerGas, maxPriorityFeePerGas } = await publicClient.estimateFeesPerGas(); + + if (isNativeToken) { + // Native token: simple value transfer to the recipient address + const txData: EvmTransactionData = { + data: "0x" as `0x${string}`, + gas: "21000", // Standard gas limit for native transfers + maxFeePerGas: String(maxFeePerGas), + maxPriorityFeePerGas: String(maxPriorityFeePerGas * 3n), + to: toAddress as `0x${string}`, + value: amountRaw + }; + + return txData; + } + + // ERC-20 token: encode transfer call targeting the token contract const transferCallData = encodeFunctionData({ abi: erc20ABI, args: [toAddress, amountRaw], functionName: "transfer" }); - const { maxFeePerGas, maxPriorityFeePerGas } = await publicClient.estimateFeesPerGas(); - const txData: EvmTransactionData = { data: transferCallData as `0x${string}`, gas: "100000", diff --git a/apps/api/src/api/services/transactions/onramp/routes/avenia-to-evm.ts b/apps/api/src/api/services/transactions/onramp/routes/avenia-to-evm.ts index 7e8ea23a0..1215c7a85 100644 --- a/apps/api/src/api/services/transactions/onramp/routes/avenia-to-evm.ts +++ b/apps/api/src/api/services/transactions/onramp/routes/avenia-to-evm.ts @@ -195,6 +195,7 @@ export async function prepareAveniaToEvmOnrampTransactions({ const finalDestinationTransfer = await addOnrampDestinationChainTransactions({ amountRaw: finalAmountRaw.toString(), destinationNetwork: toNetwork as EvmNetworks, + isNativeToken: outputTokenDetails.isNative, toAddress: destinationAddress, toToken: outputTokenDetails.erc20AddressSourceChain }); diff --git a/apps/api/src/api/services/transactions/onramp/routes/monerium-to-evm.ts b/apps/api/src/api/services/transactions/onramp/routes/monerium-to-evm.ts index ee41077ba..d394dcd7d 100644 --- a/apps/api/src/api/services/transactions/onramp/routes/monerium-to-evm.ts +++ b/apps/api/src/api/services/transactions/onramp/routes/monerium-to-evm.ts @@ -115,6 +115,7 @@ export async function prepareMoneriumToEvmOnrampTransactions({ const finalDestinationTransfer = await addOnrampDestinationChainTransactions({ amountRaw: finalAmountRaw.toString(), destinationNetwork: toNetwork as EvmNetworks, + isNativeToken: outputTokenDetails.isNative, toAddress: destinationAddress, toToken: outputTokenDetails.erc20AddressSourceChain }); From 7bd1305dee635ae50b15508610948f408a73145d Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Tue, 10 Mar 2026 19:14:42 +0000 Subject: [PATCH 13/17] Fix type issue --- .../api/services/phases/handlers/final-settlement-subsidy.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/api/src/api/services/phases/handlers/final-settlement-subsidy.ts b/apps/api/src/api/services/phases/handlers/final-settlement-subsidy.ts index 76a1045c6..b004406e1 100644 --- a/apps/api/src/api/services/phases/handlers/final-settlement-subsidy.ts +++ b/apps/api/src/api/services/phases/handlers/final-settlement-subsidy.ts @@ -1,6 +1,7 @@ import { checkEvmBalancePeriodically, checkEvmNativeBalancePeriodically, + EvmAddress, EvmClientManager, EvmNetworks, EvmTokenDetails, @@ -112,7 +113,7 @@ export class FinalSettlementSubsidyHandler extends BasePhaseHandler { // 3. Check funding account balance (native vs ERC-20) const actualBalanceFundingAccount = isNativeToken - ? await getEvmNativeBalance(fundingAccount.address, destinationNetwork) + ? await getEvmNativeBalance(fundingAccount.address as EvmAddress, destinationNetwork) : new Big( ( await publicClient.readContract({ From 1bd28a3710dc143448a3de1686a0b05d6f5083f9 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Tue, 10 Mar 2026 19:34:32 +0000 Subject: [PATCH 14/17] Fix wrong nonce --- .../api/services/transactions/onramp/routes/monerium-to-evm.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/api/src/api/services/transactions/onramp/routes/monerium-to-evm.ts b/apps/api/src/api/services/transactions/onramp/routes/monerium-to-evm.ts index d394dcd7d..b8e839a10 100644 --- a/apps/api/src/api/services/transactions/onramp/routes/monerium-to-evm.ts +++ b/apps/api/src/api/services/transactions/onramp/routes/monerium-to-evm.ts @@ -109,7 +109,7 @@ export async function prepareMoneriumToEvmOnrampTransactions({ txData: encodeEvmTransactionData(swapData) as EvmTransactionData }); - let destinationNonce = 0; + let destinationNonce = toNetwork === Networks.Polygon ? polygonAccountNonce : 0; const finalAmountRaw = multiplyByPowerOfTen(quote.outputAmount, outputTokenDetails.decimals); const finalDestinationTransfer = await addOnrampDestinationChainTransactions({ From f7020f0818210cb4ca7c264426c744857862e963 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Tue, 10 Mar 2026 19:34:48 +0000 Subject: [PATCH 15/17] Add support for native token balance check in squid-router-pay-phase-handler.ts --- .../squid-router-pay-phase-handler.ts | 31 +++++++++++++------ 1 file changed, 22 insertions(+), 9 deletions(-) diff --git a/apps/api/src/api/services/phases/handlers/squid-router-pay-phase-handler.ts b/apps/api/src/api/services/phases/handlers/squid-router-pay-phase-handler.ts index b5b3bda87..831f47f35 100644 --- a/apps/api/src/api/services/phases/handlers/squid-router-pay-phase-handler.ts +++ b/apps/api/src/api/services/phases/handlers/squid-router-pay-phase-handler.ts @@ -3,6 +3,7 @@ import { BalanceCheckError, BalanceCheckErrorType, checkEvmBalancePeriodically, + checkEvmNativeBalancePeriodically, EvmClientManager, EvmNetworks, EvmTokenDetails, @@ -28,7 +29,6 @@ import { axelarGasServiceAbi } from "../../../../contracts/AxelarGasService"; import QuoteTicket from "../../../../models/quoteTicket.model"; import RampState from "../../../../models/rampState.model"; import { SubsidyToken } from "../../../../models/subsidy.model"; -import { PhaseError } from "../../../errors/phase-error"; import { BasePhaseHandler } from "../base-phase-handler"; const AXELAR_POLLING_INTERVAL_MS = 10000; // 10 seconds @@ -42,6 +42,7 @@ const BALANCE_POLLING_TIME_MS = 10000; // of otherwise successful bridge operations. const EVM_BALANCE_CHECK_TIMEOUT_MS = 15 * 60 * 1000; // 15 minutes const DEFAULT_SQUIDROUTER_GAS_ESTIMATE = "1600000"; // Estimate used to calculate part of the gas fee for SquidRouter transactions. +const NATIVE_TOKEN_ADDRESS = "0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE"; /** * Handler for the squidRouter pay phase. Checks the status of the Axelar bridge and pays on native GLMR fee. */ @@ -133,14 +134,26 @@ export class SquidRouterPayPhaseHandler extends BasePhaseHandler { const ephemeralAddress = state.state.evmEphemeralAddress; if (outTokenDetails && ephemeralAddress) { - balanceCheckPromise = checkEvmBalancePeriodically( - outTokenDetails.erc20AddressSourceChain, - ephemeralAddress, - "1", // If we passed expectedAmountRaw, we might timeout if the bridge slipped and delivered slightly less. - BALANCE_POLLING_TIME_MS, - EVM_BALANCE_CHECK_TIMEOUT_MS, - toChain - ); + const isNativeToken = + outTokenDetails.isNative || + outTokenDetails.erc20AddressSourceChain.toLowerCase() === NATIVE_TOKEN_ADDRESS.toLowerCase(); + + balanceCheckPromise = isNativeToken + ? checkEvmNativeBalancePeriodically( + ephemeralAddress, + "1", // If we passed expectedAmountRaw, we might timeout if the bridge slipped and delivered slightly less. + BALANCE_POLLING_TIME_MS, + EVM_BALANCE_CHECK_TIMEOUT_MS, + toChain + ) + : checkEvmBalancePeriodically( + outTokenDetails.erc20AddressSourceChain, + ephemeralAddress, + "1", // If we passed expectedAmountRaw, we might timeout if the bridge slipped and delivered slightly less. + BALANCE_POLLING_TIME_MS, + EVM_BALANCE_CHECK_TIMEOUT_MS, + toChain + ); } else { logger.warn( "SquidRouterPayPhaseHandler: Cannot perform balance check optimization (missing expected token details or address)." From cc69d279c1ddb0afa13023f9119ba6f2d3898f3c Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Tue, 10 Mar 2026 20:56:42 +0000 Subject: [PATCH 16/17] Refactor and simplify code --- .../handlers/destination-transfer-handler.ts | 33 ++------ .../handlers/final-settlement-subsidy.ts | 81 +++++++----------- .../squid-router-pay-phase-handler.ts | 32 ++----- .../onramp/routes/avenia-to-evm.ts | 3 +- .../onramp/routes/monerium-to-evm.ts | 3 +- packages/shared/src/services/evm/balance.ts | 84 +++++++++++++++---- 6 files changed, 122 insertions(+), 114 deletions(-) diff --git a/apps/api/src/api/services/phases/handlers/destination-transfer-handler.ts b/apps/api/src/api/services/phases/handlers/destination-transfer-handler.ts index a06df7c26..29c05cf2f 100644 --- a/apps/api/src/api/services/phases/handlers/destination-transfer-handler.ts +++ b/apps/api/src/api/services/phases/handlers/destination-transfer-handler.ts @@ -1,6 +1,5 @@ import { - checkEvmBalancePeriodically, - checkEvmNativeBalancePeriodically, + checkEvmBalanceForToken, EvmClientManager, EvmNetworks, EvmTokenDetails, @@ -15,7 +14,6 @@ import { BasePhaseHandler } from "../base-phase-handler"; const BALANCE_POLLING_TIME_MS = 5000; const EVM_BALANCE_CHECK_TIMEOUT_MS = 3 * 60 * 1000; // 3 minutes -const NATIVE_TOKEN_ADDRESS = "0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE"; /** * Handler for transferring funds to the destination address on EVM networks (onramp only) */ @@ -43,9 +41,6 @@ export class DestinationTransferHandler extends BasePhaseHandler { ); } - const isNativeToken = - outTokenDetails.isNative || outTokenDetails.erc20AddressSourceChain.toLowerCase() === NATIVE_TOKEN_ADDRESS.toLowerCase(); - const { txData: destinationTransfer } = this.getPresignedTransaction(state, "destinationTransfer"); const expectedAmountRaw = multiplyByPowerOfTen(quote.outputAmount, outTokenDetails.decimals).toString(); const destinationNetwork = quote.network as EvmNetworks; // We can assert this type due to checks before @@ -70,24 +65,14 @@ export class DestinationTransferHandler extends BasePhaseHandler { // main phase execution loop: try { - if (isNativeToken) { - await checkEvmNativeBalancePeriodically( - state.state.evmEphemeralAddress, - expectedAmountRaw, - BALANCE_POLLING_TIME_MS, - EVM_BALANCE_CHECK_TIMEOUT_MS, - destinationNetwork - ); - } else { - await checkEvmBalancePeriodically( - outTokenDetails.erc20AddressSourceChain, - state.state.evmEphemeralAddress, - expectedAmountRaw, - BALANCE_POLLING_TIME_MS, - EVM_BALANCE_CHECK_TIMEOUT_MS, - destinationNetwork - ); - } + await checkEvmBalanceForToken({ + amountDesiredRaw: expectedAmountRaw, + chain: destinationNetwork, + intervalMs: BALANCE_POLLING_TIME_MS, + ownerAddress: state.state.evmEphemeralAddress, + timeoutMs: EVM_BALANCE_CHECK_TIMEOUT_MS, + tokenDetails: outTokenDetails + }); // send the transaction, log hash in the state for recovery. const txHash = await evmClientManager.sendRawTransactionWithRetry( diff --git a/apps/api/src/api/services/phases/handlers/final-settlement-subsidy.ts b/apps/api/src/api/services/phases/handlers/final-settlement-subsidy.ts index b004406e1..6aaabde05 100644 --- a/apps/api/src/api/services/phases/handlers/final-settlement-subsidy.ts +++ b/apps/api/src/api/services/phases/handlers/final-settlement-subsidy.ts @@ -1,15 +1,15 @@ import { - checkEvmBalancePeriodically, - checkEvmNativeBalancePeriodically, - EvmAddress, + checkEvmBalanceForToken, EvmClientManager, EvmNetworks, EvmTokenDetails, - getEvmNativeBalance, + getEvmBalance, getNetworkId, getOnChainTokenDetails, getRoute, + isNativeEvmToken, multiplyByPowerOfTen, + NATIVE_TOKEN_ADDRESS, Networks, RampCurrency, RampDirection, @@ -27,7 +27,6 @@ import { BasePhaseHandler } from "../base-phase-handler"; const BALANCE_POLLING_TIME_MS = 5000; const EVM_BALANCE_CHECK_TIMEOUT_MS = 3 * 60 * 1000; // 3 minutes -const NATIVE_TOKEN_ADDRESS = "0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE"; const NATIVE_TOKENS: Record = { [Networks.Ethereum]: { decimals: 18, symbol: "ETH" }, @@ -69,8 +68,7 @@ export class FinalSettlementSubsidyHandler extends BasePhaseHandler { ); } - const isNativeToken = - outTokenDetails.isNative || outTokenDetails.erc20AddressSourceChain.toLowerCase() === NATIVE_TOKEN_ADDRESS.toLowerCase(); + const isNative = isNativeEvmToken(outTokenDetails); const expectedAmountRaw = multiplyByPowerOfTen(quote.outputAmount, outTokenDetails.decimals); const destinationNetwork = quote.network as EvmNetworks; @@ -93,37 +91,22 @@ export class FinalSettlementSubsidyHandler extends BasePhaseHandler { } } - // 2. Check ephemeral address balance (native vs ERC-20) - const actualBalance = isNativeToken - ? await checkEvmNativeBalancePeriodically( - ephemeralAddress, - "1", // If we passed expectedAmountRaw, we might timeout if the bridge slipped and delivered slightly less. - BALANCE_POLLING_TIME_MS, - EVM_BALANCE_CHECK_TIMEOUT_MS, - destinationNetwork - ) - : await checkEvmBalancePeriodically( - outTokenDetails.erc20AddressSourceChain, - ephemeralAddress, - "1", // If we passed expectedAmountRaw, we might timeout if the bridge slipped and delivered slightly less. - BALANCE_POLLING_TIME_MS, - EVM_BALANCE_CHECK_TIMEOUT_MS, - destinationNetwork - ); - - // 3. Check funding account balance (native vs ERC-20) - const actualBalanceFundingAccount = isNativeToken - ? await getEvmNativeBalance(fundingAccount.address as EvmAddress, destinationNetwork) - : new Big( - ( - await publicClient.readContract({ - abi: erc20Abi, - address: outTokenDetails.erc20AddressSourceChain as `0x${string}`, - args: [fundingAccount.address], - functionName: "balanceOf" - }) - ).toString() - ); + // 2. Check ephemeral address balance (handles both native and ERC-20 automatically) + const actualBalance = await checkEvmBalanceForToken({ + amountDesiredRaw: "1", // If we passed expectedAmountRaw, we might timeout if the bridge slipped and delivered slightly less. + chain: destinationNetwork, + intervalMs: BALANCE_POLLING_TIME_MS, + ownerAddress: ephemeralAddress, + timeoutMs: EVM_BALANCE_CHECK_TIMEOUT_MS, + tokenDetails: outTokenDetails + }); + + // 3. Check funding account balance (handles both native and ERC-20 automatically) + const actualBalanceFundingAccount = await getEvmBalance({ + chain: destinationNetwork, + ownerAddress: fundingAccount.address as `0x${string}`, + tokenDetails: outTokenDetails + }); const subsidyAmountRaw = expectedAmountRaw.minus(actualBalance); @@ -135,12 +118,12 @@ export class FinalSettlementSubsidyHandler extends BasePhaseHandler { } logger.info( - `FinalSettlementSubsidyHandler: Subsidizing ${subsidyAmountRaw.toString()} units of ${isNativeToken ? "native token" : outTokenDetails.assetSymbol} to ${ephemeralAddress}` + `FinalSettlementSubsidyHandler: Subsidizing ${subsidyAmountRaw.toString()} units of ${isNative ? "native token" : outTokenDetails.assetSymbol} to ${ephemeralAddress}` ); // 4. Top up funding account if insufficient balance if (actualBalanceFundingAccount.lt(subsidyAmountRaw)) { - if (isNativeToken) { + if (isNative) { // For native tokens, we cannot easily swap into the native token on-chain. // The funding account must be pre-funded with sufficient native balance. throw this.createUnrecoverableError( @@ -238,14 +221,14 @@ export class FinalSettlementSubsidyHandler extends BasePhaseHandler { logger.info("FinalSettlementSubsidyHandler: Swap successful. Waiting for balance update..."); // Wait for balance checks to pass - await checkEvmBalancePeriodically( - outTokenDetails.erc20AddressSourceChain, - fundingAccount.address, - subsidyAmountRaw.toString(), - BALANCE_POLLING_TIME_MS, - EVM_BALANCE_CHECK_TIMEOUT_MS, - destinationNetwork - ); + await checkEvmBalanceForToken({ + amountDesiredRaw: subsidyAmountRaw.toString(), + chain: destinationNetwork, + intervalMs: BALANCE_POLLING_TIME_MS, + ownerAddress: fundingAccount.address, + timeoutMs: EVM_BALANCE_CHECK_TIMEOUT_MS, + tokenDetails: outTokenDetails + }); } // 5. Execute the subsidy transfer (native value transfer vs ERC-20 transfer) @@ -258,7 +241,7 @@ export class FinalSettlementSubsidyHandler extends BasePhaseHandler { let attempt = 0; while (attempt < 5 && (!receipt || receipt.status !== "success")) { - if (isNativeToken) { + if (isNative) { // Native token: simple value transfer, no contract interaction txHash = await evmClientManager.sendTransactionWithBlindRetry(destinationNetwork, fundingAccount, { maxFeePerGas, diff --git a/apps/api/src/api/services/phases/handlers/squid-router-pay-phase-handler.ts b/apps/api/src/api/services/phases/handlers/squid-router-pay-phase-handler.ts index 831f47f35..46b514f4f 100644 --- a/apps/api/src/api/services/phases/handlers/squid-router-pay-phase-handler.ts +++ b/apps/api/src/api/services/phases/handlers/squid-router-pay-phase-handler.ts @@ -2,8 +2,7 @@ import { AxelarScanStatusFees, BalanceCheckError, BalanceCheckErrorType, - checkEvmBalancePeriodically, - checkEvmNativeBalancePeriodically, + checkEvmBalanceForToken, EvmClientManager, EvmNetworks, EvmTokenDetails, @@ -42,7 +41,6 @@ const BALANCE_POLLING_TIME_MS = 10000; // of otherwise successful bridge operations. const EVM_BALANCE_CHECK_TIMEOUT_MS = 15 * 60 * 1000; // 15 minutes const DEFAULT_SQUIDROUTER_GAS_ESTIMATE = "1600000"; // Estimate used to calculate part of the gas fee for SquidRouter transactions. -const NATIVE_TOKEN_ADDRESS = "0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE"; /** * Handler for the squidRouter pay phase. Checks the status of the Axelar bridge and pays on native GLMR fee. */ @@ -134,26 +132,14 @@ export class SquidRouterPayPhaseHandler extends BasePhaseHandler { const ephemeralAddress = state.state.evmEphemeralAddress; if (outTokenDetails && ephemeralAddress) { - const isNativeToken = - outTokenDetails.isNative || - outTokenDetails.erc20AddressSourceChain.toLowerCase() === NATIVE_TOKEN_ADDRESS.toLowerCase(); - - balanceCheckPromise = isNativeToken - ? checkEvmNativeBalancePeriodically( - ephemeralAddress, - "1", // If we passed expectedAmountRaw, we might timeout if the bridge slipped and delivered slightly less. - BALANCE_POLLING_TIME_MS, - EVM_BALANCE_CHECK_TIMEOUT_MS, - toChain - ) - : checkEvmBalancePeriodically( - outTokenDetails.erc20AddressSourceChain, - ephemeralAddress, - "1", // If we passed expectedAmountRaw, we might timeout if the bridge slipped and delivered slightly less. - BALANCE_POLLING_TIME_MS, - EVM_BALANCE_CHECK_TIMEOUT_MS, - toChain - ); + balanceCheckPromise = checkEvmBalanceForToken({ + amountDesiredRaw: "1", // If we passed expectedAmountRaw, we might timeout if the bridge slipped and delivered slightly less. + chain: toChain, + intervalMs: BALANCE_POLLING_TIME_MS, + ownerAddress: ephemeralAddress, + timeoutMs: EVM_BALANCE_CHECK_TIMEOUT_MS, + tokenDetails: outTokenDetails + }); } else { logger.warn( "SquidRouterPayPhaseHandler: Cannot perform balance check optimization (missing expected token details or address)." diff --git a/apps/api/src/api/services/transactions/onramp/routes/avenia-to-evm.ts b/apps/api/src/api/services/transactions/onramp/routes/avenia-to-evm.ts index 1215c7a85..c90f0824b 100644 --- a/apps/api/src/api/services/transactions/onramp/routes/avenia-to-evm.ts +++ b/apps/api/src/api/services/transactions/onramp/routes/avenia-to-evm.ts @@ -13,6 +13,7 @@ import { getOnChainTokenDetailsOrDefault, getPendulumDetails, isEvmTokenDetails, + isNativeEvmToken, multiplyByPowerOfTen, Networks, UnsignedTx @@ -195,7 +196,7 @@ export async function prepareAveniaToEvmOnrampTransactions({ const finalDestinationTransfer = await addOnrampDestinationChainTransactions({ amountRaw: finalAmountRaw.toString(), destinationNetwork: toNetwork as EvmNetworks, - isNativeToken: outputTokenDetails.isNative, + isNativeToken: isNativeEvmToken(outputTokenDetails), toAddress: destinationAddress, toToken: outputTokenDetails.erc20AddressSourceChain }); diff --git a/apps/api/src/api/services/transactions/onramp/routes/monerium-to-evm.ts b/apps/api/src/api/services/transactions/onramp/routes/monerium-to-evm.ts index b8e839a10..0e2a3c5fb 100644 --- a/apps/api/src/api/services/transactions/onramp/routes/monerium-to-evm.ts +++ b/apps/api/src/api/services/transactions/onramp/routes/monerium-to-evm.ts @@ -9,6 +9,7 @@ import { evmTokenConfig, getOnChainTokenDetailsOrDefault, isAssetHubTokenDetails, + isNativeEvmToken, multiplyByPowerOfTen, Networks, UnsignedTx @@ -115,7 +116,7 @@ export async function prepareMoneriumToEvmOnrampTransactions({ const finalDestinationTransfer = await addOnrampDestinationChainTransactions({ amountRaw: finalAmountRaw.toString(), destinationNetwork: toNetwork as EvmNetworks, - isNativeToken: outputTokenDetails.isNative, + isNativeToken: isNativeEvmToken(outputTokenDetails), toAddress: destinationAddress, toToken: outputTokenDetails.erc20AddressSourceChain }); diff --git a/packages/shared/src/services/evm/balance.ts b/packages/shared/src/services/evm/balance.ts index 0358593a0..ec1ee6268 100644 --- a/packages/shared/src/services/evm/balance.ts +++ b/packages/shared/src/services/evm/balance.ts @@ -1,9 +1,11 @@ import Big from "big.js"; import erc20ABI from "../../contracts/ERC20"; -import { EvmAddress, EvmNetworks } from "../../index"; +import { EvmAddress, EvmNetworks, EvmTokenDetails } from "../../index"; import logger from "../../logger"; import { EvmClientManager } from "../evm/clientManager"; +export const NATIVE_TOKEN_ADDRESS = "0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE"; + export enum BalanceCheckErrorType { Timeout = "BALANCE_CHECK_TIMEOUT", ReadFailure = "BALANCE_CHECK_READ_FAILURE" @@ -19,6 +21,14 @@ export class BalanceCheckError extends Error { } } +/** + * Determines whether an EVM token is a native token (e.g. ETH, MATIC, AVAX) + * by checking both the `isNative` flag and the well-known sentinel address. + */ +export function isNativeEvmToken(tokenDetails: EvmTokenDetails): boolean { + return tokenDetails.isNative || tokenDetails.erc20AddressSourceChain.toLowerCase() === NATIVE_TOKEN_ADDRESS.toLowerCase(); +} + interface GetBalanceParams { tokenAddress: EvmAddress; ownerAddress: EvmAddress; @@ -42,6 +52,34 @@ export async function getEvmTokenBalance({ tokenAddress, ownerAddress, chain }: } } +export async function getEvmNativeBalance(ownerAddress: EvmAddress, chain: EvmNetworks): Promise { + try { + const evmClientManager = EvmClientManager.getInstance(); + const balance = await evmClientManager.getBalanceWithRetry(chain, ownerAddress); + return new Big(balance.toString()); + } catch (err) { + throw new Error(`Failed to read native balance: ${err instanceof Error ? err.message : String(err)}`); + } +} + +export async function getEvmBalance(params: { + tokenDetails: EvmTokenDetails; + ownerAddress: EvmAddress; + chain: EvmNetworks; +}): Promise { + const { tokenDetails, ownerAddress, chain } = params; + + if (isNativeEvmToken(tokenDetails)) { + return getEvmNativeBalance(ownerAddress, chain); + } + + return getEvmTokenBalance({ + chain, + ownerAddress, + tokenAddress: tokenDetails.erc20AddressSourceChain as EvmAddress + }); +} + export function checkEvmBalancePeriodically( tokenAddress: string, brlaEvmAddress: string, @@ -91,21 +129,7 @@ export function checkEvmBalancePeriodically( } /** - * Gets the native token balance for a given address on the specified chain, using RPC retry logic. - */ -export async function getEvmNativeBalance(ownerAddress: EvmAddress, chain: EvmNetworks): Promise { - try { - const evmClientManager = EvmClientManager.getInstance(); - const balance = await evmClientManager.getBalanceWithRetry(chain, ownerAddress); - return new Big(balance.toString()); - } catch (err) { - throw new Error(`Failed to read native balance: ${err instanceof Error ? err.message : String(err)}`); - } -} - -/** - * Periodically checks the native token balance of an address on the specified chain - * until the desired amount is met or the timeout is reached. + * Periodically checks the native token balance of an address until the desired amount is met or the timeout is reached. */ export function checkEvmNativeBalancePeriodically( ownerAddress: string, @@ -153,3 +177,31 @@ export function checkEvmNativeBalancePeriodically( checkBalance(); }); } + +/** + * Unified periodic balance check that automatically handles both native and ERC-20 tokens + * based on the token details. Callers don't need to know the token type. + */ +export function checkEvmBalanceForToken(params: { + tokenDetails: EvmTokenDetails; + ownerAddress: string; + amountDesiredRaw: string; + intervalMs: number; + timeoutMs: number; + chain: EvmNetworks; +}): Promise { + const { tokenDetails, ownerAddress, amountDesiredRaw, intervalMs, timeoutMs, chain } = params; + + if (isNativeEvmToken(tokenDetails)) { + return checkEvmNativeBalancePeriodically(ownerAddress, amountDesiredRaw, intervalMs, timeoutMs, chain); + } + + return checkEvmBalancePeriodically( + tokenDetails.erc20AddressSourceChain, + ownerAddress, + amountDesiredRaw, + intervalMs, + timeoutMs, + chain + ); +} From 4dbdfad4882f1d8355f510e3caf678034cd40c07 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Tue, 10 Mar 2026 21:02:47 +0000 Subject: [PATCH 17/17] Update funding account balance check to handle native tokens directly --- .../phases/handlers/final-settlement-subsidy.ts | 14 ++------------ 1 file changed, 2 insertions(+), 12 deletions(-) diff --git a/apps/api/src/api/services/phases/handlers/final-settlement-subsidy.ts b/apps/api/src/api/services/phases/handlers/final-settlement-subsidy.ts index 6aaabde05..43d7b3a17 100644 --- a/apps/api/src/api/services/phases/handlers/final-settlement-subsidy.ts +++ b/apps/api/src/api/services/phases/handlers/final-settlement-subsidy.ts @@ -121,18 +121,8 @@ export class FinalSettlementSubsidyHandler extends BasePhaseHandler { `FinalSettlementSubsidyHandler: Subsidizing ${subsidyAmountRaw.toString()} units of ${isNative ? "native token" : outTokenDetails.assetSymbol} to ${ephemeralAddress}` ); - // 4. Top up funding account if insufficient balance - if (actualBalanceFundingAccount.lt(subsidyAmountRaw)) { - if (isNative) { - // For native tokens, we cannot easily swap into the native token on-chain. - // The funding account must be pre-funded with sufficient native balance. - throw this.createUnrecoverableError( - `FinalSettlementSubsidyHandler: Funding account has insufficient native token balance ` + - `(${actualBalanceFundingAccount.toString()}) to cover subsidy of ${subsidyAmountRaw.toString()} on ${destinationNetwork}. ` + - `Please top up the funding account with native tokens manually.` - ); - } - + // 4. Top up funding account if insufficient balance (ERC-20 only; native tokens are transferred directly) + if (!isNative && actualBalanceFundingAccount.lt(subsidyAmountRaw)) { logger.info( `FinalSettlementSubsidyHandler: Funding account has insufficient balance. Swapping native token to ${outTokenDetails.assetSymbol}` );