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..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,14 +1,10 @@ import { - checkEvmBalancePeriodically, + checkEvmBalanceForToken, EvmClientManager, EvmNetworks, EvmTokenDetails, - FiatToken, - getAnyFiatTokenDetailsMoonbeam, getOnChainTokenDetails, - isEvmToken, multiplyByPowerOfTen, - Networks, RampDirection, RampPhase } from "@vortexfi/shared"; @@ -38,11 +34,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( + `DestinationTransferHandler: 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; @@ -66,14 +65,14 @@ 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 - ); + 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 83f45bc7e..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 @@ -1,13 +1,15 @@ import { - checkEvmBalancePeriodically, + checkEvmBalanceForToken, EvmClientManager, EvmNetworks, EvmTokenDetails, + getEvmBalance, getNetworkId, getOnChainTokenDetails, getRoute, - isEvmToken, + isNativeEvmToken, multiplyByPowerOfTen, + NATIVE_TOKEN_ADDRESS, Networks, RampCurrency, RampDirection, @@ -25,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" }, @@ -57,14 +58,18 @@ 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 isNative = isNativeEvmToken(outTokenDetails); + const expectedAmountRaw = multiplyByPowerOfTen(quote.outputAmount, outTokenDetails.decimals); const destinationNetwork = quote.network as EvmNetworks; const publicClient = evmClientManager.getClient(destinationNetwork); @@ -86,20 +91,21 @@ 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 (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 + }); - const actualBalanceFundingAccount = await publicClient.readContract({ - abi: erc20Abi, - address: outTokenDetails.erc20AddressSourceChain as `0x${string}`, - args: [fundingAccount.address], - functionName: "balanceOf" + // 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); @@ -111,10 +117,12 @@ 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 ${isNative ? "native token" : outTokenDetails.assetSymbol} to ${ephemeralAddress}` + ); - // Check if funding account has enough balance - if (new Big(actualBalanceFundingAccount.toString()).lt(subsidyAmountRaw)) { + // 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}` ); @@ -203,40 +211,50 @@ 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 + }); } - // 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 (isNative) { + // 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/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..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,7 +2,7 @@ import { AxelarScanStatusFees, BalanceCheckError, BalanceCheckErrorType, - checkEvmBalancePeriodically, + checkEvmBalanceForToken, EvmClientManager, EvmNetworks, EvmTokenDetails, @@ -28,7 +28,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 @@ -133,14 +132,14 @@ 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 - ); + 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)." @@ -252,7 +251,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 +388,39 @@ 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 { + id: "", + isGMPTransaction: true, + routeStatus: [], + squidTransactionStatus: "", + 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}`); + } } } 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/common/transactions.ts b/apps/api/src/api/services/transactions/onramp/common/transactions.ts index 262562e73..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,25 +191,41 @@ 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 } = 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/avenia-to-evm.ts b/apps/api/src/api/services/transactions/onramp/routes/avenia-to-evm.ts index a8aafebef..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 @@ -192,9 +193,10 @@ 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, + isNativeToken: isNativeEvmToken(outputTokenDetails), toAddress: destinationAddress, toToken: outputTokenDetails.erc20AddressSourceChain }); @@ -205,7 +207,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 d3abf5372..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 @@ -1,16 +1,27 @@ import { createOnrampSquidrouterTransactionsFromPolygonToEvm, + createOnrampSquidrouterTransactionsOnDestinationChain, ERC20_EURE_POLYGON_V1, + EvmNetworks, + EvmToken, + EvmTokenDetails, EvmTransactionData, + evmTokenConfig, + getOnChainTokenDetailsOrDefault, isAssetHubTokenDetails, + isNativeEvmToken, + 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 { priceFeedService } from "../../../priceFeed.service"; 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"; @@ -37,6 +48,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 @@ -68,7 +84,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, @@ -94,6 +110,97 @@ export async function prepareMoneriumToEvmOnrampTransactions({ txData: encodeEvmTransactionData(swapData) as EvmTransactionData }); + let destinationNonce = toNetwork === Networks.Polygon ? polygonAccountNonce : 0; + + const finalAmountRaw = multiplyByPowerOfTen(quote.outputAmount, outputTokenDetails.decimals); + const finalDestinationTransfer = await addOnrampDestinationChainTransactions({ + amountRaw: finalAmountRaw.toString(), + destinationNetwork: toNetwork as EvmNetworks, + isNativeToken: isNativeEvmToken(outputTokenDetails), + toAddress: destinationAddress, + toToken: outputTokenDetails.erc20AddressSourceChain + }); + + unsignedTxs.push({ + meta: {}, + network: toNetwork, + nonce: destinationNonce, + phase: "destinationTransfer", + signer: evmEphemeralEntry.address, + txData: finalDestinationTransfer + }); + + // 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 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: intermediateUsdAmountForFallbackRaw, + 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 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, + nonce: backupApproveNonce, + phase: "backupApprove", + signer: evmEphemeralEntry.address, + txData: backupApproveTransaction + }); + stateMeta = { ...stateMeta, squidRouterQuoteId, 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/evm/balance.ts b/packages/shared/src/services/evm/balance.ts index 875aa1b27..ec1ee6268 100644 --- a/packages/shared/src/services/evm/balance.ts +++ b/packages/shared/src/services/evm/balance.ts @@ -1,8 +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" @@ -18,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; @@ -41,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, @@ -88,3 +127,81 @@ export function checkEvmBalancePeriodically( checkBalance(); }); } + +/** + * 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, + 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(); + }); +} + +/** + * 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 + ); +} 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. 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`;