diff --git a/apps/api/src/api/services/phases/handlers/alfredpay-offramp-transfer-handler.ts b/apps/api/src/api/services/phases/handlers/alfredpay-offramp-transfer-handler.ts index d8830da97..728e926a4 100644 --- a/apps/api/src/api/services/phases/handlers/alfredpay-offramp-transfer-handler.ts +++ b/apps/api/src/api/services/phases/handlers/alfredpay-offramp-transfer-handler.ts @@ -105,7 +105,7 @@ export class AlfredpayOfframpTransferHandler extends BasePhaseHandler { ); } - return this.transitionToNextPhase(state, "complete"); + return state; } private async recreateAlfredpayOfframp( diff --git a/apps/api/src/api/services/phases/handlers/alfredpay-onramp-mint-handler.ts b/apps/api/src/api/services/phases/handlers/alfredpay-onramp-mint-handler.ts index de4cc7bd0..b91e7aafd 100644 --- a/apps/api/src/api/services/phases/handlers/alfredpay-onramp-mint-handler.ts +++ b/apps/api/src/api/services/phases/handlers/alfredpay-onramp-mint-handler.ts @@ -98,7 +98,7 @@ export class AlfredpayOnrampMintHandler extends BasePhaseHandler { `AlfredpayOnrampMintHandler: Balance reached on Polygon ephemeral ${evmEphemeralAddress}. Proceeding to fundEphemeral.` ); - return this.transitionToNextPhase(state, "fundEphemeral"); + return state; } private async pollAlfredpayOnrampStatus( diff --git a/apps/api/src/api/services/phases/handlers/brla-onramp-mint-handler.ts b/apps/api/src/api/services/phases/handlers/brla-onramp-mint-handler.ts index 016ae5846..de289653b 100644 --- a/apps/api/src/api/services/phases/handlers/brla-onramp-mint-handler.ts +++ b/apps/api/src/api/services/phases/handlers/brla-onramp-mint-handler.ts @@ -85,15 +85,13 @@ export class BrlaOnrampMintHandler extends BasePhaseHandler { // transferred to the ephemeral. We accept a balance of at least 95% of the // pre-computed expected amount to account for fee differences between quote // creation time and execution time. - const recoveryThresholdRaw = new Big(preComputedExpectedAmountRaw) - .times(EPHEMERAL_FUNDED_TOLERANCE_FACTOR) - .toFixed(0, 0); + const recoveryThresholdRaw = new Big(preComputedExpectedAmountRaw).times(EPHEMERAL_FUNDED_TOLERANCE_FACTOR).toFixed(0, 0); if (await this.ephemeralAlreadyFunded(tokenDetails.erc20AddressSourceChain, evmEphemeralAddress, recoveryThresholdRaw)) { logger.info( `BrlaOnrampMintHandler: Ephemeral ${evmEphemeralAddress} already holds at least 95% of the expected ${preComputedExpectedAmountRaw} BRLA (threshold: ${recoveryThresholdRaw}). Skipping mint flow.` ); - return this.transitionToNextPhase(state, "fundEphemeral"); + return state; } const brlaApiService = BrlaApiService.getInstance(); @@ -149,10 +147,7 @@ export class BrlaOnrampMintHandler extends BasePhaseHandler { // Derive the expected on-chain amount from the live quote's outputAmount rather than // the stale pre-computed metadata value. The live quote accounts for the actual fees // applied at execution time, so this is the amount that will truly arrive on Base. - const expectedAmountReceived = multiplyByPowerOfTen( - new Big(aveniaQuote.outputAmount), - tokenDetails.decimals - ).toFixed(0, 0); + const expectedAmountReceived = multiplyByPowerOfTen(new Big(aveniaQuote.outputAmount), tokenDetails.decimals).toFixed(0, 0); logger.info( `BrlaOnrampMintHandler: Live Avenia quote output is ${aveniaQuote.outputAmount} BRLA (raw: ${expectedAmountReceived}). Pre-computed metadata value was ${preComputedExpectedAmountRaw}.` @@ -198,7 +193,7 @@ export class BrlaOnrampMintHandler extends BasePhaseHandler { : new Error(`Error checking Base balance: ${error}`); } - return this.transitionToNextPhase(state, "fundEphemeral"); + return state; } private async ephemeralAlreadyFunded( diff --git a/apps/api/src/api/services/phases/handlers/brla-payout-base-handler.ts b/apps/api/src/api/services/phases/handlers/brla-payout-base-handler.ts index 7b13b2e8c..58fb948e5 100644 --- a/apps/api/src/api/services/phases/handlers/brla-payout-base-handler.ts +++ b/apps/api/src/api/services/phases/handlers/brla-payout-base-handler.ts @@ -56,7 +56,7 @@ export class BrlaPayoutOnBasePhaseHandler extends BasePhaseHandler { // We need to check for existing ticket, recovery scenario if (payOutTicketId) { await this.checkTicketStatusPaid({ subAccountId: taxIdRecord.subAccountId, ticketId: payOutTicketId }); - return this.transitionToNextPhase(state, "complete"); + return state; } // send the "final destination" @@ -135,7 +135,7 @@ export class BrlaPayoutOnBasePhaseHandler extends BasePhaseHandler { }); await this.checkTicketStatusPaid({ subAccountId: taxIdRecord.subAccountId, ticketId: payOutTicketId }); - return this.transitionToNextPhase(state, "complete"); + return state; } catch (e) { logger.error("Error in brlaPayoutOnBase", e); throw this.createUnrecoverableError("BrlaPayoutOnBasePhaseHandler: Failed to trigger BRLA offramp."); diff --git a/apps/api/src/api/services/phases/handlers/destination-transfer-handler.ts b/apps/api/src/api/services/phases/handlers/destination-transfer-handler.ts index 634ac6de9..d40ec4827 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 @@ -93,7 +93,7 @@ export class DestinationTransferHandler extends BasePhaseHandler { const receipt = await client.getTransactionReceipt({ hash: destinationTransferTxHash as `0x${string}` }); if (receipt.status === "success") { - return this.transitionToNextPhase(state, "complete"); + return state; } else { throw new Error(`Transaction ${destinationTransferTxHash} failed on chain.`); } @@ -167,7 +167,7 @@ export class DestinationTransferHandler extends BasePhaseHandler { }); // (optional) wait for balance to be updated on user - destination - return this.transitionToNextPhase(state, "complete"); + return state; } catch (error) { throw this.createRecoverableError( `DestinationTransferHandler: Error during phase execution - ${(error as Error).message}` diff --git a/apps/api/src/api/services/phases/handlers/distribute-fees-handler.ts b/apps/api/src/api/services/phases/handlers/distribute-fees-handler.ts index b88456f7f..0766d6644 100644 --- a/apps/api/src/api/services/phases/handlers/distribute-fees-handler.ts +++ b/apps/api/src/api/services/phases/handlers/distribute-fees-handler.ts @@ -72,9 +72,6 @@ export class DistributeFeesHandler extends BasePhaseHandler { throw this.createUnrecoverableError(`Quote ticket not found for ID: ${state.quoteId}`); } - // Determine next phase - const nextPhase = state.type === RampDirection.BUY ? "subsidizePostSwap" : "subsidizePreSwap"; - // Check if we already have a hash stored const existingHash = state.state.distributeFeeHash || null; @@ -92,7 +89,7 @@ export class DistributeFeesHandler extends BasePhaseHandler { if (status === ExtrinsicStatus.Success) { logger.info(`Existing distribute fee EVM transaction was successful for ramp ${state.id}`); - return this.transitionToNextPhase(state, nextPhase); + return state; } else { logger.info(`Existing distribute fee EVM transaction was not successful (status: ${status}), will retry`); } @@ -103,7 +100,7 @@ export class DistributeFeesHandler extends BasePhaseHandler { if (status === ExtrinsicStatus.Success) { logger.info(`Existing distribute fee transaction was successful for ramp ${state.id}`); - return this.transitionToNextPhase(state, nextPhase); + return state; } else { logger.info(`Existing distribute fee transaction was not successful (status: ${status}), will retry`); } @@ -115,7 +112,7 @@ export class DistributeFeesHandler extends BasePhaseHandler { const distributeFeeTransaction = this.getPresignedTransaction(state, "distributeFees"); if (distributeFeeTransaction === undefined) { logger.info("No fee distribution transaction data found. Skipping fee distribution."); - return this.transitionToNextPhase(state, nextPhase); + return state; } // The funding token (USDC) may not yet be on the ephemeral when we reach this phase @@ -157,7 +154,7 @@ export class DistributeFeesHandler extends BasePhaseHandler { } logger.info(`Successfully verified fee distribution transaction for ramp ${state.id}: ${actualTxHash}`); - return this.transitionToNextPhase(updatedState, nextPhase); + return state; } catch (e: unknown) { logger.error(`Error distributing fees for ramp ${state.id}:`, e); 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 8ebe2b380..9fca87e92 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,12 +57,6 @@ export class FinalSettlementSubsidyHandler extends BasePhaseHandler { return "finalSettlementSubsidy"; } - private getNextPhase(state: RampState, quote: QuoteTicket): RampPhase { - return state.type === RampDirection.SELL && isAlfredpayToken(quote.outputCurrency as FiatToken) - ? "alfredpayOfframpTransfer" - : "destinationTransfer"; - } - protected async executePhase(state: RampState): Promise { logger.debug(`FinalSettlementSubsidyHandler: Starting phase execution for ramp ${state.id}, type=${state.type}`); @@ -76,7 +70,7 @@ export class FinalSettlementSubsidyHandler extends BasePhaseHandler { !(state.type === RampDirection.SELL && isAlfredpayToken(quote.outputCurrency as FiatToken)) ) { logger.info(`FinalSettlementSubsidyHandler: Skipping subsidy for direct-transfer ramp ${state.id}`); - return this.transitionToNextPhase(state, this.getNextPhase(state, quote)); + return state; } const evmClientManager = EvmClientManager.getInstance(); @@ -88,7 +82,7 @@ export class FinalSettlementSubsidyHandler extends BasePhaseHandler { if (isFiatToOwnStablecoinBaseDirect(quote.inputCurrency, quote.outputCurrency, quote.network)) { logger.info(`FinalSettlementSubsidyHandler: Skipping subsidy for Base direct-transfer route (ramp ${state.id})`); - return this.transitionToNextPhase(state, this.getNextPhase(state, quote)); + return state; } const isAlfredpaySell = state.type === RampDirection.SELL && isAlfredpayToken(quote.outputCurrency as FiatToken); @@ -147,7 +141,7 @@ export class FinalSettlementSubsidyHandler extends BasePhaseHandler { logger.info( `FinalSettlementSubsidyHandler: Transaction ${state.state.finalSettlementSubsidyTxHash} already successful. Skipping.` ); - return this.transitionToNextPhase(state, this.getNextPhase(state, quote)); + return state; } } @@ -187,7 +181,7 @@ export class FinalSettlementSubsidyHandler extends BasePhaseHandler { logger.info( `FinalSettlementSubsidyHandler: Delivered amount (${delivered.toString()}) meets expected amount with actualBalance=${actualBalance.toString()} and preSettlementBalance=${preBalance.toString()}. No subsidy needed.` ); - return this.transitionToNextPhase(state, this.getNextPhase(state, quote)); + return state; } logger.info( @@ -364,7 +358,7 @@ export class FinalSettlementSubsidyHandler extends BasePhaseHandler { } }); - return this.transitionToNextPhase(state, this.getNextPhase(state, quote)); + return state; } catch (error) { throw this.createRecoverableError( `FinalSettlementSubsidyHandler: Error during phase execution - ${(error as Error).message}` diff --git a/apps/api/src/api/services/phases/handlers/fund-ephemeral-handler.ts b/apps/api/src/api/services/phases/handlers/fund-ephemeral-handler.ts index 8cc7737aa..8d4e30f07 100644 --- a/apps/api/src/api/services/phases/handlers/fund-ephemeral-handler.ts +++ b/apps/api/src/api/services/phases/handlers/fund-ephemeral-handler.ts @@ -23,7 +23,6 @@ import RampState from "../../../../models/rampState.model"; import { UnrecoverablePhaseError } from "../../../errors/phase-error"; import { multiplyByPowerOfTen } from "../../pendulum/helpers"; import { fundEphemeralAccount } from "../../pendulum/pendulum.service"; -import { isFiatToOwnStablecoinBaseDirect } from "../../quote/utils"; import { BasePhaseHandler } from "../base-phase-handler"; import { getEvmFundingAccount } from "../evm-funding"; import { verifyUserSubmittedTxByHash } from "../helpers/user-tx-verifier"; @@ -124,7 +123,10 @@ export class FundEphemeralPhaseHandler extends BasePhaseHandler { return; } - const hasSquidApproveBlueprint = state.unsignedTxs.some(tx => tx.phase === "squidRouterApprove"); + const hasSquidApproveBlueprint = state.unsignedTxs.some( + tx => + tx.phase === "squidRouterApprove" && tx.signer.toLowerCase() !== (state.state.evmEphemeralAddress ?? "").toLowerCase() + ); if (!hasSquidApproveBlueprint) return; await verifyUserSubmittedTxByHash({ @@ -188,12 +190,6 @@ export class FundEphemeralPhaseHandler extends BasePhaseHandler { const isPolygonFunded = requiresPolygonEphemeralAddress ? await isPolygonEphemeralFunded(evmEphemeralAddress) : true; - const destinationNetwork = getNetworkFromDestination(state.to); - const isDestinationEvmFunded = - requiresDestinationEvmFunding && destinationNetwork && isNetworkEVM(destinationNetwork) // for type safety - ? await isDestinationEvmEphemeralFunded(evmEphemeralAddress, destinationNetwork) - : true; - if (!isPendulumFunded) { logger.info(`Funding PEN ephemeral account ${substrateEphemeralAddress}`); if (isOnramp(state) && state.to !== Networks.AssetHub) { @@ -219,11 +215,20 @@ export class FundEphemeralPhaseHandler extends BasePhaseHandler { logger.info("Polygon ephemeral address already funded."); } - if (isOnramp(state) && !isDestinationEvmFunded && destinationNetwork && isNetworkEVM(destinationNetwork)) { - logger.info(`Funding destination EVM ephemeral account ${evmEphemeralAddress} on ${destinationNetwork}`); - await this.fundDestinationEvmEphemeralAccount(state, destinationNetwork); - } else if (requiresDestinationEvmFunding) { - logger.info(`Destination EVM ephemeral address already funded on ${destinationNetwork}.`); + const evmNetworksToFund = new Set(); + const destinationNetwork = getNetworkFromDestination(state.to); + if (isOnramp(state) && destinationNetwork && isNetworkEVM(destinationNetwork) && requiresDestinationEvmFunding) { + evmNetworksToFund.add(destinationNetwork); + } + + for (const network of evmNetworksToFund) { + const isFunded = await isDestinationEvmEphemeralFunded(evmEphemeralAddress, network); + if (!isFunded) { + logger.info(`Funding EVM ephemeral account ${evmEphemeralAddress} on ${network}`); + await this.fundDestinationEvmEphemeralAccount(state, network); + } else { + logger.info(`EVM ephemeral account already funded on ${network}.`); + } } } catch (e) { logger.error("Error in FundEphemeralPhaseHandler:", e); @@ -237,43 +242,7 @@ export class FundEphemeralPhaseHandler extends BasePhaseHandler { throw recoverableError; } - return this.transitionToNextPhase(state, this.nextPhaseSelector(state, quote)); - } - - protected nextPhaseSelector(state: RampState, quote: QuoteTicket): RampPhase { - if ( - (state.state.isDirectTransfer === true && - !(state.type === RampDirection.SELL && isAlfredpayToken(quote.outputCurrency as FiatToken))) || - (isOnramp(state) && isFiatToOwnStablecoinBaseDirect(quote.inputCurrency, quote.outputCurrency, quote.network)) - ) { - return "destinationTransfer"; - } - - // brla onramp case - if (isOnramp(state) && quote.inputCurrency === FiatToken.BRL) { - return "subsidizePreSwap"; - } - // mykobo (EURC) onramp case - if (isOnramp(state) && quote.inputCurrency === FiatToken.EURC) { - return "subsidizePreSwap"; - } - // alfredpay onramp case - if (isOnramp(state) && isAlfredpayToken(quote.inputCurrency as FiatToken)) { - return "subsidizePreSwap"; - } - - // off ramp cases - if (state.type === RampDirection.SELL && state.from === Networks.AssetHub) { - return "distributeFees"; - } else if (state.type === RampDirection.SELL && isAlfredpayToken(quote.outputCurrency as FiatToken)) { - return "finalSettlementSubsidy"; - } else if (state.type === RampDirection.SELL && quote.outputCurrency === FiatToken.BRL) { - return "distributeFees"; - } else if (state.type === RampDirection.SELL && quote.outputCurrency === FiatToken.EURC) { - return "distributeFees"; - } else { - return "moonbeamToPendulum"; // Via contract.subsidizePreSwap - } + return state; } protected async fundEvmEphemeralAccount(state: RampState, network: EvmNetworks): Promise { diff --git a/apps/api/src/api/services/phases/handlers/helpers.ts b/apps/api/src/api/services/phases/handlers/helpers.ts index f745eed0d..c0fb149e7 100644 --- a/apps/api/src/api/services/phases/handlers/helpers.ts +++ b/apps/api/src/api/services/phases/handlers/helpers.ts @@ -58,8 +58,8 @@ export async function isPolygonEphemeralFunded(polygonEphemeralAddress: string): // will spuriously succeed (or fail) and downstream phases may run with a // short-funded ephemeral. export const DESTINATION_EVM_FUNDING_AMOUNTS: Record = { - [VortexNetworks.Ethereum]: "0.00016", - [VortexNetworks.Arbitrum]: "0.000045", + [VortexNetworks.Ethereum]: "0.005", + [VortexNetworks.Arbitrum]: "0.0002", [VortexNetworks.Base]: "0.000034", [VortexNetworks.Polygon]: "0.6", [VortexNetworks.BSC]: "0.000115", diff --git a/apps/api/src/api/services/phases/handlers/initial-phase-handler.ts b/apps/api/src/api/services/phases/handlers/initial-phase-handler.ts index d3ccd8d34..4e3dca43e 100644 --- a/apps/api/src/api/services/phases/handlers/initial-phase-handler.ts +++ b/apps/api/src/api/services/phases/handlers/initial-phase-handler.ts @@ -1,4 +1,4 @@ -import { FiatToken, isAlfredpayToken, RampDirection, RampPhase } from "@vortexfi/shared"; +import { RampPhase } from "@vortexfi/shared"; import logger from "../../../../config/logger"; import { config } from "../../../../config/vars"; import QuoteTicket from "../../../../models/quoteTicket.model"; @@ -6,7 +6,9 @@ import RampState from "../../../../models/rampState.model"; import { BasePhaseHandler } from "../base-phase-handler"; /** - * Handler for the initial phase + * Handler for the initial phase. + * Routing is handled by the PhaseProcessor via phaseFlow. + * This handler only performs setup and sandbox short-circuiting. */ export class InitialPhaseHandler extends BasePhaseHandler { /** @@ -34,17 +36,7 @@ export class InitialPhaseHandler extends BasePhaseHandler { return this.transitionToNextPhase(state, "complete"); } - if (state.type === RampDirection.BUY && quote.inputCurrency === FiatToken.BRL) { - return this.transitionToNextPhase(state, "brlaOnrampMint"); - } else if (state.type === RampDirection.BUY && quote.inputCurrency === FiatToken.EURC) { - return this.transitionToNextPhase(state, "mykoboOnrampDeposit"); - } else if (state.type === RampDirection.BUY && isAlfredpayToken(quote.inputCurrency as FiatToken)) { - return this.transitionToNextPhase(state, "alfredpayOnrampMint"); - } else if (state.type === RampDirection.SELL && isAlfredpayToken(quote.outputCurrency as FiatToken)) { - return this.transitionToNextPhase(state, "squidRouterPermitExecute"); - } - - return this.transitionToNextPhase(state, "fundEphemeral"); + return state; } } diff --git a/apps/api/src/api/services/phases/handlers/mykobo-onramp-deposit-handler.ts b/apps/api/src/api/services/phases/handlers/mykobo-onramp-deposit-handler.ts index ade234875..cf3abe3ab 100644 --- a/apps/api/src/api/services/phases/handlers/mykobo-onramp-deposit-handler.ts +++ b/apps/api/src/api/services/phases/handlers/mykobo-onramp-deposit-handler.ts @@ -67,7 +67,7 @@ export class MykoboOnrampDepositHandler extends BasePhaseHandler { logger.info( `MykoboOnrampDepositHandler: Ephemeral ${evmEphemeralAddress} already holds at least 95% of the expected ${expectedAmountRaw} EURC (threshold: ${recoveryThresholdRaw}). Skipping deposit wait.` ); - return this.transitionToNextPhase(state, "fundEphemeral"); + return state; } logger.info( @@ -106,7 +106,7 @@ export class MykoboOnrampDepositHandler extends BasePhaseHandler { `MykoboOnrampDepositHandler: EURC deposit received on Base ephemeral ${evmEphemeralAddress}. Proceeding to fundEphemeral.` ); - return this.transitionToNextPhase(state, "fundEphemeral"); + return state; } private async ephemeralAlreadyFunded( diff --git a/apps/api/src/api/services/phases/handlers/mykobo-payout-handler.ts b/apps/api/src/api/services/phases/handlers/mykobo-payout-handler.ts index ad07596eb..0cdea986c 100644 --- a/apps/api/src/api/services/phases/handlers/mykobo-payout-handler.ts +++ b/apps/api/src/api/services/phases/handlers/mykobo-payout-handler.ts @@ -23,7 +23,7 @@ export class MykoboPayoutOnBasePhaseHandler extends BasePhaseHandler { await this.sendMykoboPayoutTransaction(state, mykoboPayoutTxHash); await this.pollMykoboUntilCompleted(mykoboTransactionId); - return this.transitionToNextPhase(state, "complete"); + return state; } private async sendMykoboPayoutTransaction(state: RampState, mykoboPayoutTxHash?: `0x${string}`): Promise { diff --git a/apps/api/src/api/services/phases/handlers/nabla-approve-handler.ts b/apps/api/src/api/services/phases/handlers/nabla-approve-handler.ts index 052dcb785..1b0076aa4 100644 --- a/apps/api/src/api/services/phases/handlers/nabla-approve-handler.ts +++ b/apps/api/src/api/services/phases/handlers/nabla-approve-handler.ts @@ -58,7 +58,7 @@ export class NablaApprovePhaseHandler extends BasePhaseHandler { const approvedAmount = approval.toString() !== "" ? Big(approval.toString()) : Big(0); if (approvedAmount.gte(requiredAmount)) { logger.info("NablaApprovePhaseHandler: Amount already approved. Skipping approval."); - return this.transitionToNextPhase(state, "nablaSwap"); + return state; } } catch (e) { throw this.createRecoverableError( @@ -103,7 +103,7 @@ export class NablaApprovePhaseHandler extends BasePhaseHandler { throw new Error("Could not approve token"); } - return this.transitionToNextPhase(state, "nablaSwap"); + return state; } catch (e) { let errorMessage = ""; const { result } = e as ExecuteMessageResult; @@ -145,7 +145,7 @@ export class NablaApprovePhaseHandler extends BasePhaseHandler { logger.info(`NablaApprovePhaseHandler: EVM approve transaction successful: ${txHash}`); - return this.transitionToNextPhase(state, "nablaSwap"); + return state; } catch (e) { logger.error(`Could not approve token on EVM: ${(e as Error).message}`); throw e; diff --git a/apps/api/src/api/services/phases/handlers/nabla-swap-handler.ts b/apps/api/src/api/services/phases/handlers/nabla-swap-handler.ts index d2b616138..f125c6ebb 100644 --- a/apps/api/src/api/services/phases/handlers/nabla-swap-handler.ts +++ b/apps/api/src/api/services/phases/handlers/nabla-swap-handler.ts @@ -10,7 +10,6 @@ import { evmTokenConfig, NABLA_ROUTER, Networks, - RampDirection, RampPhase } from "@vortexfi/shared"; import Big from "big.js"; @@ -59,8 +58,7 @@ export class NablaSwapPhaseHandler extends BasePhaseHandler { if (nablaSwapTxHash) { logger.info(`NablaSwapPhaseHandler: Transaction already submitted (${nablaSwapTxHash}), skipping to next phase`); - const nextPhase = state.type === RampDirection.BUY ? "distributeFees" : "subsidizePostSwap"; - return this.transitionToNextPhase(state, nextPhase); + return state; } if (!quote.metadata.nablaSwap?.inputAmountForSwapRaw) { @@ -146,8 +144,7 @@ export class NablaSwapPhaseHandler extends BasePhaseHandler { throw new Error(`Could not swap the required amount of token: ${errorMessage}`); } - const nextPhase = state.type === RampDirection.BUY ? "distributeFees" : "subsidizePostSwap"; - return this.transitionToNextPhase(state, nextPhase); + return state; } private async executeEvmSwap(state: RampState, quote: QuoteTicket): Promise { @@ -216,8 +213,7 @@ export class NablaSwapPhaseHandler extends BasePhaseHandler { throw this.createUnrecoverableError(`Could not swap token on EVM: ${(e as Error).message}`); } - const nextPhase = state.type === RampDirection.BUY ? "distributeFees" : "subsidizePostSwap"; - return this.transitionToNextPhase(state, nextPhase); + return state; } } 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 3973a004c..b68b06b24 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 @@ -4,24 +4,24 @@ import { BalanceCheckErrorType, checkEvmBalanceForToken, EvmClientManager, - EvmNetworks, EvmTokenDetails, FiatToken, + getNetworkFromDestination, getNetworkId, getOnChainTokenDetails, getStatus, getStatusAxelarScan, isAlfredpayToken, + isNetworkEVM, Networks, nativeToDecimal, OnChainToken, - RampDirection, RampPhase, SquidRouterPayResponse } from "@vortexfi/shared"; import Big from "big.js"; import { createWalletClient, encodeFunctionData, Hash, PublicClient } from "viem"; -import { base, polygon } from "viem/chains"; +import { arbitrum, base, polygon } from "viem/chains"; import logger from "../../../../config/logger"; import { axelarGasServiceAbi } from "../../../../contracts/AxelarGasService"; import QuoteTicket from "../../../../models/quoteTicket.model"; @@ -48,9 +48,11 @@ export class SquidRouterPayPhaseHandler extends BasePhaseHandler { private moonbeamPublicClient: PublicClient; private polygonPublicClient: PublicClient; private basePublicClient: PublicClient; + private arbitrumPublicClient: PublicClient; private moonbeamWalletClient: ReturnType; private polygonWalletClient: ReturnType; private baseWalletClient: ReturnType; + private arbitrumWalletClient: ReturnType; constructor() { super(); @@ -58,11 +60,13 @@ export class SquidRouterPayPhaseHandler extends BasePhaseHandler { this.moonbeamPublicClient = evmClientManager.getClient(Networks.Moonbeam); this.polygonPublicClient = evmClientManager.getClient(Networks.Polygon); this.basePublicClient = evmClientManager.getClient(Networks.Base); + this.arbitrumPublicClient = evmClientManager.getClient(Networks.Arbitrum); const moonbeamExecutorAccount = getEvmFundingAccount(Networks.Moonbeam); this.moonbeamWalletClient = evmClientManager.getWalletClient(Networks.Moonbeam, moonbeamExecutorAccount); this.polygonWalletClient = evmClientManager.getWalletClient(Networks.Polygon, moonbeamExecutorAccount); this.baseWalletClient = evmClientManager.getWalletClient(Networks.Base, moonbeamExecutorAccount); + this.arbitrumWalletClient = evmClientManager.getWalletClient(Networks.Arbitrum, moonbeamExecutorAccount); } /** @@ -85,11 +89,6 @@ export class SquidRouterPayPhaseHandler extends BasePhaseHandler { logger.info(`Executing squidRouterPay phase for ramp ${state.id}`); - if (state.type === RampDirection.SELL) { - logger.info("squidRouterPay phase is not supported for off-ramp"); - return state; - } - try { // Get the bridge hash const bridgeCallHash = state.state.squidRouterSwapHash; @@ -100,11 +99,7 @@ export class SquidRouterPayPhaseHandler extends BasePhaseHandler { // Enter check status loop await this.checkStatus(state, bridgeCallHash, quote); - if (state.to === Networks.AssetHub) { - return this.transitionToNextPhase(state, "moonbeamToPendulum"); - } else { - return this.transitionToNextPhase(state, "finalSettlementSubsidy"); - } + return state; } catch (error: unknown) { logger.error(`SquidRouterPayPhaseHandler: Error in squidRouterPay phase for ramp ${state.id}:`, error); throw error; @@ -118,8 +113,13 @@ export class SquidRouterPayPhaseHandler extends BasePhaseHandler { * Only if both fail (timeout) we throw. */ private async checkStatus(state: RampState, swapHash: string, quote: QuoteTicket): Promise { + // Resolve the actual EVM destination of the Squid bridge. For onramps, quote.to is the + // EVM network directly. For offramps to a payment method (e.g. SEPA via Mykobo), the + // bridge lands on the EVM leg of the ramp, recorded in quote.metadata.evmToEvm.toNetwork. + const toChain = this.resolveBridgeToChain(quote); + // If the destination is not an EVM network, skip the EVM balance optimization and rely on bridge status only. - if (quote.to === Networks.AssetHub) { + if (!toChain || !isNetworkEVM(toChain)) { logger.info("SquidRouterPayPhaseHandler: Destination network is non-EVM; skipping EVM balance check optimization.", { toNetwork: quote.to }); @@ -127,8 +127,6 @@ export class SquidRouterPayPhaseHandler extends BasePhaseHandler { return; } - const toChain = quote.to as EvmNetworks; - let balanceCheckPromise: Promise; try { @@ -236,12 +234,18 @@ export class SquidRouterPayPhaseHandler extends BasePhaseHandler { payTxHash = await this.executeFundTransaction(nativeToFundRaw, swapHash as `0x${string}`, logIndex, state, quote); + const bridgeMeta = quote.metadata.evmToEvm || quote.metadata.moonbeamToEvm; + const fromChain = bridgeMeta?.fromNetwork as Networks; + let subsidyToken: SubsidyToken; let payerAccount: `0x${string}` | undefined; - if (quote.inputCurrency === FiatToken.BRL) { + if (fromChain === Networks.Base || (!fromChain && quote.inputCurrency === FiatToken.BRL)) { subsidyToken = SubsidyToken.ETH; payerAccount = this.baseWalletClient.account?.address as `0x${string}` | undefined; + } else if (fromChain === Networks.Arbitrum) { + subsidyToken = SubsidyToken.ETH; + payerAccount = this.arbitrumWalletClient.account?.address as `0x${string}` | undefined; } else { subsidyToken = SubsidyToken.MATIC; payerAccount = this.polygonWalletClient.account?.address as `0x${string}` | undefined; @@ -286,10 +290,20 @@ export class SquidRouterPayPhaseHandler extends BasePhaseHandler { state: RampState, quote: QuoteTicket ): Promise { - if (quote.inputCurrency === FiatToken.BRL) { + const bridgeMeta = quote.metadata.evmToEvm || quote.metadata.moonbeamToEvm; + const fromChain = bridgeMeta?.fromNetwork as Networks; + if (fromChain === Networks.Base) { return this.executeFundTransactionOnBase(tokenValueRaw, swapHash, logIndex); - } else { + } else if (fromChain === Networks.Arbitrum) { + return this.executeFundTransactionOnArbitrum(tokenValueRaw, swapHash, logIndex); + } else if (fromChain === Networks.Polygon) { return this.executeFundTransactionOnPolygon(tokenValueRaw, swapHash, logIndex); + } else { + if (quote.inputCurrency === FiatToken.BRL) { + return this.executeFundTransactionOnBase(tokenValueRaw, swapHash, logIndex); + } else { + return this.executeFundTransactionOnPolygon(tokenValueRaw, swapHash, logIndex); + } } } @@ -381,17 +395,68 @@ export class SquidRouterPayPhaseHandler extends BasePhaseHandler { } } + /** + * Execute a call to the Axelar gas service on Arbitrum network. + * @param tokenValueRaw The amount of ETH to fund the transaction with. + * @param swapHash The swap transaction hash. + * @param logIndex The log index from Axelar scan. + * @returns Hash of the transaction that funds the Axelar gas service. + */ + private async executeFundTransactionOnArbitrum( + tokenValueRaw: string, + swapHash: `0x${string}`, + logIndex: number + ): Promise { + try { + const walletClientAccount = this.arbitrumWalletClient.account; + + if (!walletClientAccount) { + throw new Error("SquidRouterPayPhaseHandler: Arbitrum wallet client account not found."); + } + + // Create addNativeGas transaction data + const transactionData = encodeFunctionData({ + abi: axelarGasServiceAbi, + args: [swapHash, logIndex, walletClientAccount.address], + functionName: "addNativeGas" + }); + + const { maxFeePerGas, maxPriorityFeePerGas } = await this.arbitrumPublicClient.estimateFeesPerGas(); + + const gasPaymentHash = await this.arbitrumWalletClient.sendTransaction({ + account: walletClientAccount, + chain: arbitrum, + data: transactionData, + maxFeePerGas: maxFeePerGas * 2n, + maxPriorityFeePerGas: maxPriorityFeePerGas * 2n, + to: AXL_GAS_SERVICE_EVM as `0x${string}`, + value: BigInt(tokenValueRaw) + }); + + logger.info(`SquidRouterPayPhaseHandler: Arbitrum fund transaction sent with hash: ${gasPaymentHash}`); + return gasPaymentHash; + } catch (error) { + logger.error("SquidRouterPayPhaseHandler: Error funding gas to Axelar gas service on Arbitrum: ", error); + throw new Error("SquidRouterPayPhaseHandler: Failed to send Arbitrum transaction"); + } + } + private async getSquidrouterStatus(swapHash: string, state: RampState, quote: QuoteTicket): Promise { try { + const bridgeMeta = quote.metadata.evmToEvm || quote.metadata.moonbeamToEvm; // Always Polygon for Monerium/Alfredpay onramp, Base for BRL const fromChain = - quote.inputCurrency === FiatToken.EURC || isAlfredpayToken(quote.inputCurrency as FiatToken) + (bridgeMeta?.fromNetwork as Networks) || + (quote.inputCurrency === FiatToken.EURC || isAlfredpayToken(quote.inputCurrency as FiatToken) ? Networks.Polygon : quote.inputCurrency === FiatToken.BRL ? Networks.Base - : Networks.Moonbeam; + : Networks.Moonbeam); const fromChainId = getNetworkId(fromChain)?.toString(); - const toChain = quote.to === Networks.AssetHub ? Networks.Moonbeam : quote.to; + // Axelar routes through Moonbeam for AssetHub destinations, so the Squid status API + // expects Moonbeam's chain id when quote.to is AssetHub. + const resolvedToChain = this.resolveBridgeToChain(quote); + const toChain = resolvedToChain === Networks.AssetHub ? Networks.Moonbeam : resolvedToChain; const toChainId = getNetworkId(toChain)?.toString(); if (!fromChainId || !toChainId) { @@ -436,6 +501,22 @@ export class SquidRouterPayPhaseHandler extends BasePhaseHandler { } } + /** + * Resolve the actual destination network of the Squid bridge. + * + * For onramps, `quote.to` is the EVM network the bridge delivers to. For offramps to a + * payment method, `quote.to` is a PaymentMethod enum value, so we fall back to the bridge + * metadata recorded at quote time. + */ + private resolveBridgeToChain(quote: QuoteTicket): Networks | undefined { + const directNetwork = getNetworkFromDestination(quote.to); + if (directNetwork) { + return directNetwork; + } + const bridgeMeta = quote.metadata.evmToEvm || quote.metadata.moonbeamToEvm; + return bridgeMeta?.toNetwork as Networks | undefined; + } + private calculateGasFeeInUnits(feeResponse: AxelarScanStatusFees, estimatedGas: string | number): string { const baseFeeInUnitsBig = Big(feeResponse.source_base_fee); diff --git a/apps/api/src/api/services/phases/handlers/squid-router-phase-handler.ts b/apps/api/src/api/services/phases/handlers/squid-router-phase-handler.ts index a36b98fd0..7d22914a6 100644 --- a/apps/api/src/api/services/phases/handlers/squid-router-phase-handler.ts +++ b/apps/api/src/api/services/phases/handlers/squid-router-phase-handler.ts @@ -60,8 +60,8 @@ export class SquidRouterPhaseHandler extends BasePhaseHandler { } if (state.type === RampDirection.SELL) { - logger.info("SquidRouter phase is not supported for off-ramp"); - return state; + // SELL with a squidRouterSwap unsigned tx means an offramp that bridges between EVM + // chains. The ephemeral broadcasts approve+swap; the same bridge logic as onramp applies. } // Alfredpay mints USDT directly on Polygon. Skip the swap ONLY when the requested @@ -207,7 +207,7 @@ export class SquidRouterPhaseHandler extends BasePhaseHandler { }); // Transition to the next phase - return this.transitionToNextPhase(updatedState, "squidRouterPay"); + return updatedState; } catch (error) { logger.error(`Error in squidRouter phase for ramp ${state.id}:`, error); throw error; diff --git a/apps/api/src/api/services/phases/handlers/squidrouter-permit-execution-handler.ts b/apps/api/src/api/services/phases/handlers/squidrouter-permit-execution-handler.ts index 96f951d06..6c1fbc040 100644 --- a/apps/api/src/api/services/phases/handlers/squidrouter-permit-execution-handler.ts +++ b/apps/api/src/api/services/phases/handlers/squidrouter-permit-execution-handler.ts @@ -112,7 +112,7 @@ export class SquidrouterPermitExecuteHandler extends BasePhaseHandler { } logger.info(`${label} tx confirmed: ${hash}`); - return this.transitionToNextPhase(updatedState, "fundEphemeral"); + return updatedState; } private async waitForUserHash( @@ -152,7 +152,7 @@ export class SquidrouterPermitExecuteHandler extends BasePhaseHandler { ); } - return this.transitionToNextPhase(state, "fundEphemeral"); + return state; } private async executeDirectTransfer( @@ -276,7 +276,7 @@ export class SquidrouterPermitExecuteHandler extends BasePhaseHandler { if (receipt && receipt.status === "success") { logger.info(`Existing squidRouter permit execution transaction was successful for ramp ${state.id}`); - return this.transitionToNextPhase(state, "fundEphemeral"); + return state; } else { logger.info( `Existing squidRouter permit execution transaction was not successful (status: ${receipt?.status}), will retry` diff --git a/apps/api/src/api/services/phases/handlers/subsidize-post-swap-handler.ts b/apps/api/src/api/services/phases/handlers/subsidize-post-swap-handler.ts index 08928a49d..de1440f38 100644 --- a/apps/api/src/api/services/phases/handlers/subsidize-post-swap-handler.ts +++ b/apps/api/src/api/services/phases/handlers/subsidize-post-swap-handler.ts @@ -1,12 +1,10 @@ import { ApiManager, - AssetHubToken, checkEvmBalanceForToken, EvmClientManager, EvmNetworks, EvmToken, EvmTokenDetails, - FiatToken, getOnChainTokenDetails, Networks, nativeToDecimal, @@ -154,7 +152,7 @@ export class SubsidizePostSwapPhaseHandler extends BasePhaseHandler { await waitUntilTrueWithTimeout(didBalanceReachExpected, 2000); } - return this.transitionToNextPhase(state, this.substrateNextPhaseSelector(state, quote)); + return state; } catch (e) { logger.error("Error in subsidizePostSwap (substrate):", e); throw this.createRecoverableError("SubsidizePostSwapPhaseHandler: Failed to subsidize post swap."); @@ -238,11 +236,12 @@ export class SubsidizePostSwapPhaseHandler extends BasePhaseHandler { quote.outputCurrency as RampCurrency, EvmToken.USDC as RampCurrency ); - const subsidyCapUsd = Big(quoteOutputUsd).mul(MAX_EVM_SWAP_SUBSIDY_QUOTE_FRACTION); + const percentageCap = Big(quoteOutputUsd).mul(MAX_EVM_SWAP_SUBSIDY_QUOTE_FRACTION); + const subsidyCapUsd = percentageCap.gt("1") ? percentageCap : Big("1"); if (Big(subsidyUsd).gt(subsidyCapUsd)) { // Pause for operator intervention without moving the ramp to failed. throw this.createRecoverableError( - `SubsidizePostSwapPhaseHandler: Required subsidy $${subsidyUsd} exceeds cap $${subsidyCapUsd.toFixed(2)} (${MAX_EVM_SWAP_SUBSIDY_QUOTE_FRACTION} of quote output $${quoteOutputUsd}).` + `SubsidizePostSwapPhaseHandler: Required subsidy $${subsidyUsd} exceeds cap $${subsidyCapUsd.toFixed(2)} (max of $1.00 and ${MAX_EVM_SWAP_SUBSIDY_QUOTE_FRACTION} of quote output $${quoteOutputUsd}).` ); } @@ -288,7 +287,7 @@ export class SubsidizePostSwapPhaseHandler extends BasePhaseHandler { } } - return this.transitionToNextPhase(state, this.evmNextPhaseSelector(state, quote)); + return state; } catch (e) { logger.error("Error in subsidizePostSwap (EVM):", e); if (e instanceof PhaseError) { @@ -297,45 +296,6 @@ export class SubsidizePostSwapPhaseHandler extends BasePhaseHandler { throw this.createRecoverableError("SubsidizePostSwapPhaseHandler: Failed to subsidize post swap on EVM."); } } - - protected substrateNextPhaseSelector(state: RampState, quote: QuoteTicket): RampPhase { - // onramp cases - if (state.type === RampDirection.BUY) { - if (state.to === "assethub") { - if (quote.outputCurrency === AssetHubToken.USDC) { - // USDC can directly go to AssetHub - return "pendulumToAssethubXcm"; - } else { - // USDT and DOT need to go via Hydration - return "pendulumToHydrationXcm"; - } - } - return "pendulumToMoonbeamXcm"; - } - - // off ramp cases - if (quote.outputCurrency === FiatToken.BRL) { - return "pendulumToMoonbeamXcm"; - } - - if (state.type === RampDirection.SELL) { - throw new Error("SubsidizePostSwapPhaseHandler: Unsupported non-BRL offramp route after Stellar deprecation"); - } - - throw new Error( - `SubsidizePostSwapPhaseHandler: Unrecognized routing combination: direction=${state.type}, to=${state.to}, output=${quote.outputCurrency}` - ); - } - - protected evmNextPhaseSelector(state: RampState, quote: QuoteTicket): RampPhase { - if (state.type === RampDirection.BUY) { - return "squidRouterSwap"; - } - if (quote.outputCurrency === FiatToken.EURC) { - return "mykoboPayoutOnBase"; - } - return "brlaPayoutOnBase"; - } } export default new SubsidizePostSwapPhaseHandler(); diff --git a/apps/api/src/api/services/phases/handlers/subsidize-pre-swap-handler.ts b/apps/api/src/api/services/phases/handlers/subsidize-pre-swap-handler.ts index 3d7ff6875..18ca97098 100644 --- a/apps/api/src/api/services/phases/handlers/subsidize-pre-swap-handler.ts +++ b/apps/api/src/api/services/phases/handlers/subsidize-pre-swap-handler.ts @@ -75,7 +75,6 @@ export class SubsidizePreSwapPhaseHandler extends BasePhaseHandler { inputToken: ALFREDPAY_EVM_TOKEN, inputTokenDetails, logLabel: "Alfredpay", - nextPhase: "squidRouterSwap" as RampPhase, subsidyToken: ALFREDPAY_EVM_TOKEN as unknown as SubsidyToken, tokenContract: ALFREDPAY_ERC20_TOKEN }; @@ -99,7 +98,6 @@ export class SubsidizePreSwapPhaseHandler extends BasePhaseHandler { inputToken, inputTokenDetails, logLabel: "EVM", - nextPhase: "nablaApprove" as RampPhase, subsidyToken: quote.metadata.nablaSwapEvm.inputCurrency as unknown as SubsidyToken, tokenContract: inputTokenDetails.erc20AddressSourceChain as `0x${string}` }; @@ -185,7 +183,7 @@ export class SubsidizePreSwapPhaseHandler extends BasePhaseHandler { await waitUntilTrueWithTimeout(didBalanceReachExpected, 5000); } - return this.transitionToNextPhase(state, "nablaApprove"); + return state; } catch (e) { logger.error("Error in subsidizePreSwap (substrate):", e); throw this.createRecoverableError("SubsidizePreSwapPhaseHandler: Failed to subsidize pre swap."); @@ -205,7 +203,6 @@ export class SubsidizePreSwapPhaseHandler extends BasePhaseHandler { inputToken, inputTokenDetails, logLabel, - nextPhase, expectedInputAmountForSwapRaw, subsidyToken, tokenContract @@ -243,11 +240,12 @@ export class SubsidizePreSwapPhaseHandler extends BasePhaseHandler { quote.outputCurrency as RampCurrency, EvmToken.USDC as RampCurrency ); - const subsidyCapUsd = Big(quoteOutputUsd).mul(MAX_EVM_SWAP_SUBSIDY_QUOTE_FRACTION); + const percentageCap = Big(quoteOutputUsd).mul(MAX_EVM_SWAP_SUBSIDY_QUOTE_FRACTION); + const subsidyCapUsd = percentageCap.gt("1") ? percentageCap : Big("1"); if (Big(subsidyUsd).gt(subsidyCapUsd)) { // Pause for operator intervention without moving the ramp to failed. throw this.createRecoverableError( - `SubsidizePreSwapPhaseHandler: Required subsidy $${subsidyUsd} exceeds cap $${subsidyCapUsd.toFixed(2)} (${MAX_EVM_SWAP_SUBSIDY_QUOTE_FRACTION} of quote output $${quoteOutputUsd}).` + `SubsidizePreSwapPhaseHandler: Required subsidy $${subsidyUsd} exceeds cap $${subsidyCapUsd.toFixed(2)} (max of $1.00 and ${MAX_EVM_SWAP_SUBSIDY_QUOTE_FRACTION} of quote output $${quoteOutputUsd}).` ); } @@ -292,7 +290,7 @@ export class SubsidizePreSwapPhaseHandler extends BasePhaseHandler { } } - return this.transitionToNextPhase(state, nextPhase); + return state; } catch (e) { logger.error("Error in subsidizePreSwap (EVM):", e); if (e instanceof PhaseError) { diff --git a/apps/api/src/api/services/phases/meta-state-types.ts b/apps/api/src/api/services/phases/meta-state-types.ts index c7509915a..6dc181db7 100644 --- a/apps/api/src/api/services/phases/meta-state-types.ts +++ b/apps/api/src/api/services/phases/meta-state-types.ts @@ -1,4 +1,4 @@ -import { AlfredpayFiatPaymentInstructions, ExtrinsicOptions, IbanPaymentData } from "@vortexfi/shared"; +import { AlfredpayFiatPaymentInstructions, ExtrinsicOptions, IbanPaymentData, Networks, RampPhase } from "@vortexfi/shared"; export interface StateMetadata { nablaSoftMinimumOutputRaw: string; @@ -77,4 +77,7 @@ export interface StateMetadata { mykoboReceivablesAddress?: string; mykoboPayoutTxHash?: `0x${string}`; mykoboTransactionReference?: string; + // Explicit phase flow for this ramp (set at registration by route builder). + // When present, the PhaseProcessor follows this sequence instead of handler-driven routing. + phaseFlow?: RampPhase[]; } diff --git a/apps/api/src/api/services/phases/phase-processor.ts b/apps/api/src/api/services/phases/phase-processor.ts index fad4828e0..dfb4fe256 100644 --- a/apps/api/src/api/services/phases/phase-processor.ts +++ b/apps/api/src/api/services/phases/phase-processor.ts @@ -1,3 +1,4 @@ +import { RampPhase } from "@vortexfi/shared"; import httpStatus from "http-status"; import logger from "../../../config/logger"; import { runWithRampContext } from "../../../config/ramp-context"; @@ -5,6 +6,7 @@ import { config } from "../../../config/vars"; import RampState from "../../../models/rampState.model"; import { APIError } from "../../errors/api-error"; import { PhaseError, RecoverablePhaseError } from "../../errors/phase-error"; +import { StateMetadata } from "./meta-state-types"; import phaseRegistry from "./phase-registry"; /** @@ -153,6 +155,40 @@ export class PhaseProcessor { return now.getTime() - lockTime.getTime() > lockDuration; } + /** + * Resolve the next phase for a ramp. + * + * If the handler explicitly changed the phase (short-circuit override), honor it. + * Otherwise, if a phaseFlow is defined, advance to the next phase in the sequence. + * If no phaseFlow exists (legacy ramp), return the handler's result as-is. + */ + private resolveNextPhase(originalPhase: RampPhase, handlerResult: RampState, state: RampState): RampPhase { + // Handler explicitly changed the phase (short-circuit override) — honor it + if (handlerResult.currentPhase !== originalPhase) { + return handlerResult.currentPhase; + } + + const phaseFlow = (state.state as StateMetadata).phaseFlow; + + // Legacy ramp without phaseFlow — handler must set the next phase + if (!phaseFlow) { + return handlerResult.currentPhase; + } + + // Flow-driven routing — advance to the next phase in the sequence + const currentIndex = phaseFlow.indexOf(originalPhase); + if (currentIndex === -1) { + throw new Error(`PhaseProcessor: Phase "${originalPhase}" not found in phaseFlow for ramp ${state.id}`); + } + if (currentIndex >= phaseFlow.length - 1) { + throw new Error( + `PhaseProcessor: Phase "${originalPhase}" is the last phase in phaseFlow but not terminal for ramp ${state.id}` + ); + } + + return phaseFlow[currentIndex + 1]; + } + /** * Process a phase * @param state The current ramp state @@ -183,11 +219,19 @@ export class PhaseProcessor { clearTimeout(timeoutId); }); + // Resolve the next phase: handler short-circuit > explicit flow > handler-driven (legacy) + const nextPhase = this.resolveNextPhase(currentPhase, pendingState, state); + + const phaseHistory = + nextPhase !== pendingState.currentPhase + ? [...pendingState.phaseHistory, { phase: nextPhase, timestamp: new Date() }] + : pendingState.phaseHistory; + // Single source of authority for phase transitions. // Persist only the phase-related fields on the original persisted instance // to avoid inserting new records or clobbering unrelated columns. const updatedState = await state.update( - { currentPhase: pendingState.currentPhase, phaseHistory: pendingState.phaseHistory }, + { currentPhase: nextPhase, phaseHistory }, { fields: ["currentPhase", "phaseHistory"] } ); diff --git a/apps/api/src/api/services/phases/ramp-flow-definitions.ts b/apps/api/src/api/services/phases/ramp-flow-definitions.ts new file mode 100644 index 000000000..3fc900676 --- /dev/null +++ b/apps/api/src/api/services/phases/ramp-flow-definitions.ts @@ -0,0 +1,158 @@ +import { RampPhase } from "@vortexfi/shared"; + +// ─── BRL On-Ramp (Avenia/BRLA on Base) ─────────────────────────────────────── + +/** BRL → BRLA direct on Base. No Nabla swap, no Squid. */ +export const BRL_ONRAMP_BASE_DIRECT: RampPhase[] = [ + "initial", + "brlaOnrampMint", + "fundEphemeral", + "destinationTransfer", + "complete" +]; + +/** BRL → USDC same-chain on Base. Nabla swap but no SquidRouter bridge. */ +export const BRL_ONRAMP_BASE_SAME_CHAIN: RampPhase[] = [ + "initial", + "brlaOnrampMint", + "fundEphemeral", + "subsidizePreSwap", + "nablaApprove", + "nablaSwap", + "distributeFees", + "subsidizePostSwap", + "destinationTransfer", + "complete" +]; + +/** BRL → USDC cross-chain (EVM destination). Full Nabla + SquidRouter bridge. */ +export const BRL_ONRAMP_BASE_CROSS_CHAIN: RampPhase[] = [ + "initial", + "brlaOnrampMint", + "fundEphemeral", + "subsidizePreSwap", + "nablaApprove", + "nablaSwap", + "distributeFees", + "subsidizePostSwap", + "squidRouterSwap", + "squidRouterPay", + "finalSettlementSubsidy", + "destinationTransfer", + "complete" +]; + +// ─── EUR On-Ramp (Mykobo SEPA on Base) ─────────────────────────────────────── + +/** EUR → EURC direct on Base. No Nabla swap, no Squid. */ +export const EUR_ONRAMP_BASE_DIRECT: RampPhase[] = [ + "initial", + "mykoboOnrampDeposit", + "fundEphemeral", + "destinationTransfer", + "complete" +]; + +/** EUR → USDC same-chain on Base. Nabla swap but no SquidRouter bridge. */ +export const EUR_ONRAMP_BASE_SAME_CHAIN: RampPhase[] = [ + "initial", + "mykoboOnrampDeposit", + "fundEphemeral", + "subsidizePreSwap", + "nablaApprove", + "nablaSwap", + "distributeFees", + "subsidizePostSwap", + "destinationTransfer", + "complete" +]; + +/** EUR → USDC cross-chain (EVM destination). Full Nabla + SquidRouter bridge. */ +export const EUR_ONRAMP_BASE_CROSS_CHAIN: RampPhase[] = [ + "initial", + "mykoboOnrampDeposit", + "fundEphemeral", + "subsidizePreSwap", + "nablaApprove", + "nablaSwap", + "distributeFees", + "subsidizePostSwap", + "squidRouterSwap", + "squidRouterPay", + "finalSettlementSubsidy", + "destinationTransfer", + "complete" +]; + +// ─── Alfredpay On-Ramp (USD/MXN/COP via Polygon) ───────────────────────────── + +/** Alfredpay same-token direct on Polygon. No SquidRouter swap needed. */ +export const ALFREDPAY_ONRAMP_DIRECT: RampPhase[] = [ + "initial", + "alfredpayOnrampMint", + "fundEphemeral", + "subsidizePreSwap", + "squidRouterSwap", + "finalSettlementSubsidy", + "destinationTransfer", + "complete" +]; + +/** Alfredpay cross-chain (EVM destination). SquidRouter bridge required. */ +export const ALFREDPAY_ONRAMP_CROSS_CHAIN: RampPhase[] = [ + "initial", + "alfredpayOnrampMint", + "fundEphemeral", + "subsidizePreSwap", + "squidRouterSwap", + "squidRouterPay", + "finalSettlementSubsidy", + "destinationTransfer", + "complete" +]; + +// ─── BRL Off-Ramp (Avenia/BRLA on Base) ────────────────────────────────────── + +/** All BRL offramps. USDC → BRLA via Nabla on Base → PIX payout. */ +export const BRL_OFFRAMP_BASE: RampPhase[] = [ + "initial", + "fundEphemeral", + "distributeFees", + "subsidizePreSwap", + "nablaApprove", + "nablaSwap", + "subsidizePostSwap", + "brlaPayoutOnBase", + "complete" +]; + +// ─── EUR Off-Ramp (Mykobo on Base) ─────────────────────────────────────────── + +/** All EUR offramps. USDC → EURC via Nabla on Base → SEPA payout. */ +export const EUR_OFFRAMP_BASE: RampPhase[] = [ + "initial", + "fundEphemeral", + "distributeFees", + "subsidizePreSwap", + "nablaApprove", + "nablaSwap", + "subsidizePostSwap", + "mykoboPayoutOnBase", + "complete" +]; + +// ─── Alfredpay Off-Ramp (USD/MXN/COP via Polygon) ──────────────────────────── + +/** + * All Alfredpay offramps. Permit/transfer on source chain → fund ephemeral → + * subsidy → fiat payout. Direct and cross-chain share the same phase sequence; + * the handler differentiates via isDirectTransfer / isNoPermitFallback. + */ +export const ALFREDPAY_OFFRAMP: RampPhase[] = [ + "initial", + "squidRouterPermitExecute", + "fundEphemeral", + "finalSettlementSubsidy", + "alfredpayOfframpTransfer", + "complete" +]; diff --git a/apps/api/src/api/services/quote/blocks/README.md b/apps/api/src/api/services/quote/blocks/README.md new file mode 100644 index 000000000..487860bc5 --- /dev/null +++ b/apps/api/src/api/services/quote/blocks/README.md @@ -0,0 +1,478 @@ +# Block-Based Quote Engine + +## Abstract + +A typed, composable **block** model for defining Vortex quote flows. Each +phase declares its input and output `PhaseIO` brands; a +`FlowBuilder.start(...).pipe(...).build(...)` chain enforces **at compile +time** that adjacent phases are compatible — a Base swap cannot feed a +Polygon-only transfer, a phase that bridges to Arbitrum cannot be followed +by a Base-only step. The execution `RampPhase[]` is derived from the flow +(`["initial", ...flow.phases, "complete"]`), and each phase now also carries +its **executors** — the execution-side handlers for its `RampPhase`s — so +the strategy, the execution sequence, the brand-correctness check, and the +execution logic all live in one place. + +### Design invariants (the ethos) + +1. **Self-contained phases.** A phase owns the complete logic for its step + on both sides of the system: quote simulation (`simulate`) and execution + (`executors`, one per declared `RampPhase`). One corridor is ultimately + defined once, as one flow, instead of three times (quote strategy, + `RampPhase[]` array, route-prep). Transaction *preparation* + (`prepareTxs`) is the remaining third leg — see roadmap. +2. **Composition only through the typed IO boundary.** A phase sees its + input `PhaseIO` — funds of token `T` on chain `C`, plus signature or + contract-execution data accumulated in `meta` — and produces an output + `PhaseIO`. It knows nothing else: not which phases surround it, not its + position in the flow, and never another phase's `meta`. Anything a + phase needs beyond its input must be derivable from the shared + read-only `PhaseCtx` (request, partner, fees, notes). This is what + makes phases removable, reorderable, and swappable. +3. **Adjacency mismatches fail at compile time — where the types can carry + it.** `FlowBuilder.pipe` rejects a token- or chain-brand mismatch as a + hard type error. `Phase.simulate` is declared as a *property function* + (not a method) so the check is contravariant under `strictFunctionTypes` + — a union or unbranded output cannot silently disable checking + downstream. This is a strong preference, not an absolute: where full + type-level enforcement would wreck readability, runtime checks and + parity tests are the accepted fallback. + +**Scope:** the `BRL_ONRAMP_BASE_CROSS_CHAIN` corridor (Avenia PIX → BRLA on +Base → Nabla BRLA→USDC → fee distribution → Squid bridge → destination +transfer) — a **real production corridor**, expressed as a flow *family* +parameterized by destination chain and token. Lives entirely under +`apps/api/src/api/services/quote/blocks/` and coexists with the existing +quote engine — **zero edits outside `blocks/`** (production files are +imported read-only). + +**Status:** `7 pass, 1 skip, 0 fail` parity test; `bun typecheck` clean for +`blocks/`. The derived `RampPhase[]` deep-equals the production +`BRL_ONRAMP_BASE_CROSS_CHAIN` array. Every phase in the flow carries an +executor ported from the corresponding production handler (EVM/Base BUY +slice), registry-compatible via `BasePhaseHandler` — but **nothing is wired +into the PhaseProcessor or QuoteService yet**: the production handlers in +`phases/handlers/` remain the ones that run. The earlier Morpho example +flow was removed together with the Morpho production code; `MykoboMint` +remains in the catalog for the EUR corridors. + +--- + +## Detail + +### File layout + +``` +apps/api/src/api/services/quote/blocks/ + README.md # this file + core/ + types.ts # PhaseIO, Phase, Flow, PhaseCtx + io.ts # requestToIO, evmIO + flow.ts # FlowBuilder + OutputOf

+ combinators.ts # branch(), passthrough() + fees.ts # computeFees(ctx) + phase-flow.ts # assemblePhaseFlow(flow) -> RampPhase[] + phases/ # each file: simulate + executor(s) + avenia-mint.ts # fiat BRL -> BRLA on Base + brlaOnrampMint executor + mykobo-mint.ts # fiat EUR -> EURC on Base (simulate only, for EUR corridors) + fund-ephemeral.ts # FundEphemeral(token, chain) + fundEphemeral executor + subsidize-pre.ts # SubsidizePre() + subsidizePreSwap executor + nabla-swap.ts # NablaSwap(chain, in, out) + nablaApprove/nablaSwap executors + distribute-fees.ts # DistributeFees() + distributeFees executor + subsidize-post.ts # SubsidizePost() + subsidizePostSwap executor + squid-router-swap.ts # SquidRouterSwap(from, to, fromToken, toToken) + swap/pay executors + final-settlement-subsidy.ts # FinalSettlementSubsidy() + executor + destination-transfer.ts # DestinationTransfer() + executor + flows/ + brl-onramp-base-cross-chain.ts # makeBrlOnrampBaseCrossChainFlow(toChain, toToken) + __tests__/ + brl-onramp-base-cross-chain.parity.test.ts # structure + parity + executors + compile-time + simulate +``` + +### Core types (`core/types.ts`) + +```ts +export type TokenBrand = string; +export type ChainBrand = string; + +export interface PhaseIO { + amount: Big; // human-readable decimal + amountRaw: string; // integer-string raw at token's decimals + token: Token; + chain: Chain; + meta: Record; // phase-specific (route id, oracle price, subsidy, ...) +} + +export interface Phase { + readonly name: string; + readonly phases: RampPhase[]; // declared execution expansion + readonly simulate: (input: I, ctx: PhaseCtx) => Promise; + readonly executors?: PhaseHandler[]; // one per entry in `phases`, same order +} + +export interface Flow { + readonly name: string; + readonly phases: RampPhase[]; // flatMap(p => p.phases) + readonly executors: PhaseHandler[];// flatMap(p => p.executors ?? []) + simulate(ctx: PhaseCtx): Promise; +} +``` + +`Token` and `Chain` are instantiated with literal types drawn from the +string enums (`EvmToken`, `FiatToken`, `AssetHubToken`) and `Networks` / +`"fiat"`. Brands are kept as `extends string` so literal narrowing flows +through generics. Note the brands are structural (string values), not +nominal: `EvmToken.USDC` and `AssetHubToken.USDC` are the same literal +type, so the **chain** brand carries the discrimination between +ecosystems. + +`simulate` is a **property function type**, not a method — under +`strictFunctionTypes` this makes `pipe`'s input check contravariant, so a +phase whose declared output degrades to unbranded `PhaseIO` (or a union) +cannot be followed by a narrower-input phase without a compile error. + +### `FlowBuilder` (`core/flow.ts`) + +Compile-time adjacency is enforced via a **builder**, not a variadic +`flow()` function. The builder's `.pipe(next)` is a single method signature +with no overload fallback to escape to, so a brand mismatch is a hard type +error. + +```ts +type OutputOf

= P extends Phase ? O : never; +type AnyPhase = Phase; // internal type-erased storage + +export class FlowBuilder { + private constructor(private readonly phaseList: AnyPhase[]) {} + + static start

(first: P): FlowBuilder>; + pipe

>(next: P): FlowBuilder>; + build(name: string): Flow; +} +``` + +The internal list is stored as `Phase` — the honest +existential under contravariance (any phase is assignable to it, and it +cannot be *called* without a cast). The single `input as never` in +`build()`'s simulate loop is the one place the type system hands over to +the adjacency guarantee that `pipe` already enforced. + +Runtime: `build()` stores the phases; `Flow.simulate(ctx)` runs +`computeFees(ctx)`, builds the first input via `requestToIO(ctx)`, then +sequentially calls `phase.simulate(prevOutput, ctx)`. `Flow.phases` = +`flatMap(p => p.phases)`; `Flow.executors` = `flatMap(p => p.executors)`. + +### Executors (the execution side) + +Each phase file defines executor class(es) extending the production +`BasePhaseHandler` (imported read-only from `services/phases/`), one per +`RampPhase` the phase declares. `Flow.executors` therefore lines up 1:1 +with `Flow.phases` — asserted in the parity test. Wiring a corridor later +is one loop: + +```ts +flow.executors.forEach(executor => phaseRegistry.registerHandler(executor)); +``` + +Nothing calls this yet. The executors are faithful ports of the production +handlers, **sliced to the path this corridor exercises** (EVM ephemeral, +Base source chain, BUY direction): + +| Executor | `RampPhase` | Ported from | Slice notes | +|----------|-------------|-------------|-------------| +| `BrlaOnrampMintExecutor` | `brlaOnrampMint` | `handlers/brla-onramp-mint-handler.ts` | Full port (already Base-specific). Waits for the Avenia subaccount balance, creates the live transfer ticket to the Base ephemeral, 95% recovery shortcut, 30-min payment timeout → `failed`. | +| `FundEphemeralExecutor` | `fundEphemeral` | `handlers/fund-ephemeral-handler.ts` | Source-chain gas funding (incl. presigned squid swap `value` extraction) + destination EVM funding for BUY. Pendulum/Polygon-Alfredpay branches and SELL user-tx verification not ported. | +| `SubsidizePreSwapExecutor` | `subsidizePreSwap` | `handlers/subsidize-pre-swap-handler.ts` | EVM branch only (`nablaSwapEvm` config). USD cap check + funding-account ERC-20 top-up + Subsidy record. | +| `NablaApproveExecutor` | `nablaApprove` | `handlers/nabla-approve-handler.ts` | EVM branch: broadcast presigned approve on Base. | +| `NablaSwapExecutor` | `nablaSwap` | `handlers/nabla-swap-handler.ts` | EVM branch: input-balance validation + broadcast presigned swap. Substrate soft-minimum dry-run not ported. | +| `DistributeFeesExecutor` | `distributeFees` | `handlers/distribute-fees-handler.ts` | EVM branch: `distributeFeeHash` idempotency, USDC fee-balance precondition, presigned broadcast. Substrate/Subscan branch not ported. | +| `SubsidizePostSwapExecutor` | `subsidizePostSwap` | `handlers/subsidize-post-swap-handler.ts` | EVM branch: tops up to the simulated Squid input (`evmToEvm.inputAmountRaw`) for BUY. | +| `SquidRouterSwapExecutor` | `squidRouterSwap` | `handlers/squid-router-phase-handler.ts` | Route short-circuits (direct transfer, Alfredpay, same-chain passthrough) dropped — a flow piping this block always bridges. Keeps approve+swap broadcast, hash persistence, `preSettlementBalance` snapshot. | +| `SquidRouterPayExecutor` | `squidRouterPay` | `handlers/squid-router-pay-phase-handler.ts` | One network-generic Axelar `addNativeGas` method replaces the three per-chain copies; clients created lazily. Status/balance `Promise.any` race kept. | +| `FinalSettlementSubsidyExecutor` | `finalSettlementSubsidy` | `handlers/final-settlement-subsidy.ts` | BUY branch: ≥90% bridge-delivery wait, `preSettlementBalance` delta, native→token SquidRouter top-up swap with USD cap, 5-attempt transfer loop. SELL/Alfredpay branches not ported. | +| `DestinationTransferExecutor` | `destinationTransfer` | `handlers/destination-transfer-handler.ts` | Full port: recipient validation of the presigned tx, nonce-gap guard, idempotency, broadcast. | + +Executors intentionally read the **same `quote.metadata` keys and +`state.state` fields** as the production handlers (including the +cross-phase reads production performs, e.g. `subsidizePostSwap` reading +`evmToEvm.inputAmountRaw`) so a block-driven ramp is bit-compatible with a +handler-driven one during migration. + +### `assemblePhaseFlow` (`core/phase-flow.ts`) + +```ts +export function assemblePhaseFlow(flow: Flow): RampPhase[] { + return ["initial", ...flow.phases, "complete"]; +} +``` + +That's the whole thing. No phase-name knowledge, no route flags, no +`branch` logic. The developer pipes every step (funding, fee distribution, +subsidy, settlement, delivery) into the flow explicitly. Verbosity in flow +definitions is the deliberate tradeoff: a corridor's full execution shape +is readable top-to-bottom in one file. + +### `branch()` and `passthrough()` (`core/combinators.ts`) + +Kept as available primitives but **not relied upon**: destination variants +are expressed as a flow *family* (a factory over brands) rather than +runtime branches. Reach for `branch` only when a flow genuinely needs to +fork at simulate time; prefer separate flows otherwise. Note `branch`'s +static `phases` union is only valid when all branches expand to the same +`RampPhase` list. + +### Phase catalog + +Every step in a corridor — including the "bookend" steps (funding, fee +distribution, subsidy, final settlement, delivery) — is a first-class +`Phase` carrying both `simulate` and its executors. The flow +assembles them linearly. + +| Phase | Type | `phases` | Meta keys written | +|-------|------|----------|------------------| +| `AveniaMint` | `Phase, PhaseIO>` | `["brlaOnrampMint"]` | `aveniaMint`, `aveniaTransfer`, `fees` | +| `MykoboMint` | `Phase, PhaseIO>` | `["mykoboOnrampDeposit"]` | `mykoboMint`, `fees` | +| `FundEphemeral(token, chain)` | `Phase, PhaseIO>` | `["fundEphemeral"]` | (preserves) | +| `SubsidizePre()` | `Phase, PhaseIO>` | `["subsidizePreSwap"]` | `subsidy` (partial) | +| `NablaSwap(chain, in, out)` | `Phase, PhaseIO>` | `["nablaApprove", "nablaSwap"]` | `nablaSwapEvm` | +| `DistributeFees()` | `Phase, PhaseIO>` | `["distributeFees"]` | (preserves) | +| `SubsidizePost()` | `Phase, PhaseIO>` | `["subsidizePostSwap"]` | `subsidy` (full) | +| `SquidRouterSwap(from, to, fromToken, toToken)` | `Phase, PhaseIO>` | `["squidRouterSwap", "squidRouterPay"]` | `evmToEvm` | +| `FinalSettlementSubsidy()` | `Phase, PhaseIO>` | `["finalSettlementSubsidy"]` | `subsidy` (full, overrides) | +| `DestinationTransfer()` | `Phase, PhaseIO>` | `["destinationTransfer"]` | (preserves) | + +`SquidRouterSwap` derives the bridge target from its **own** `toToken` / +`toChain` args (not from `ctx.request.outputCurrency`) — the phase carries +its complete contract in its signature. + +### The flow family (`flows/brl-onramp-base-cross-chain.ts`) + +The destination chain/token vary per request (`quote.to` / +`quote.outputCurrency`), so the corridor is a factory; the derived +`RampPhase[]` is identical for every destination: + +```ts +export function makeBrlOnrampBaseCrossChainFlow( + toChain: ToChain, + toToken: ToToken +): Flow { + return FlowBuilder.start(AveniaMint) + .pipe(FundEphemeral(EvmToken.BRLA, Networks.Base)) + .pipe(SubsidizePre()) + .pipe(NablaSwap(Networks.Base, EvmToken.BRLA, EvmToken.USDC)) + .pipe(DistributeFees()) + .pipe(SubsidizePost()) + .pipe(SquidRouterSwap(Networks.Base, toChain, EvmToken.USDC, toToken)) + .pipe(FinalSettlementSubsidy()) + .pipe(DestinationTransfer()) + .build("BrlOnrampBaseCrossChain"); +} +``` + +### Parity (derived `RampPhase[]`) + +`assemblePhaseFlow(flow)` deep-equals the hand-maintained +`BRL_ONRAMP_BASE_CROSS_CHAIN` in `phases/ramp-flow-definitions.ts`, for +every destination instantiation: + +``` +["initial", "brlaOnrampMint", "fundEphemeral", "subsidizePreSwap", + "nablaApprove", "nablaSwap", "distributeFees", "subsidizePostSwap", + "squidRouterSwap", "squidRouterPay", "finalSettlementSubsidy", + "destinationTransfer", "complete"] +``` + +The bookend `["initial", ..., "complete"]` is the only thing +`assemblePhaseFlow` adds. Everything else is declared by the flow itself. + +### Verification + +`__tests__/brl-onramp-base-cross-chain.parity.test.ts` encodes the checks +every ported corridor must pass: + +1. **Structural** — `flow.phases` equals the expected core phases array. +2. **Phase-flow parity** — `assemblePhaseFlow(flow)` deep-equals the + production `BRL_ONRAMP_BASE_CROSS_CHAIN`, including for other + destinations of the flow family. +3. **Executor coverage** — `flow.executors.map(e => e.getPhaseName())` + deep-equals `flow.phases`: every execution phase has exactly one + executor, in order. +4. **Compile-time adjacency** — a `// @ts-expect-error` block (wrong token + after `AveniaMint`; Base-only phase after an Arbitrum bridge) is + type-checked by tsc. If the brand guard were broken, the directives + would be unused and `bun typecheck` would fail. +5. **Simulate smoke** — with externals mocked (`BrlaApiService`, + `calculateNablaSwapOutputEvm`, `calculateEvmBridgeAndNetworkFee`, + `priceFeedService`), `simulate(ctx)` lands on the destination + token/chain with `amount > 0`. +6. **Metadata parity** — the final `PhaseIO.meta` carries the keys the old + engines produce (`aveniaMint`, `aveniaTransfer`, `nablaSwapEvm`, + `evmToEvm`, `subsidy`, `fees`) with the same field names, so existing + route-prep and handlers read them unchanged. + +### Meta accumulation and compatibility + +`PhaseIO.meta` is a `Record` bag that flows forward +through the pipeline. Each phase writes its data to a named key and +**spreads `input.meta`** so earlier phases' data is preserved. The final +meta is the union of all phase outputs — the subset of the old +`QuoteContext` bag this corridor uses. + +**Invariant: meta is write-only during simulation.** No phase reads +`input.meta.` to compute its output. Every phase is a pure function +of `(input, ctx)`. The only adjacency constraint is the `Phase` +brand check enforced by `FlowBuilder.pipe`. + +The three subsidy phases each independently call the shared +`computeExpectedOutput(ctx)` helper — they do not read each other's meta. +If multiple subsidy phases run, the last full `meta.subsidy` wins. +`computeFees(ctx)` runs before the flow; the first phase copies `ctx.fees` +into `meta.fees`. + +### Conventions (non-negotiable) + +- `bun`, never npm/yarn/pnpm. Run `bun lint:fix` then `bun typecheck` from + the repo root. +- Biome: line width 128, 2-space indent, semicolons always, double quotes, + no trailing commas. +- DO NOT add comments unless this doc explicitly asks. No docstrings on + code you didn't touch. +- Surgical changes: touch only files under `blocks/`. Production files + (`quote/core/*`, `engines/*`, `phases/*`, models, constants) are + imported read-only, never edited. +- No over-engineering: no abstractions for single-use code, no error + handling for impossible scenarios, no input validation for typed internal + params. +- `FiatToken` has 6 values (EURC, ARS, BRL, USD, MXN, COP); any + `Record` must include all six. +- Mimic the import style of neighboring files. + +### Brand values (enum member string values — keep adjacency consistent) + +| Enum | Member | Value | +|------|--------|-------| +| `FiatToken` | `BRL` | `"BRL"` | +| `FiatToken` | `EURC` | `"EUR"` | +| `EvmToken` | `BRLA` | `"BRLA"` | +| `EvmToken` | `EURC` | `"EURC"` | +| `EvmToken` | `USDC` | `"USDC"` | +| `EvmToken` | `USDT` | `"USDT"` | +| `Networks` | `Base` | `"base"` | +| `Networks` | `Arbitrum` | `"arbitrum"` | +| `Networks` | `Polygon` | `"polygon"` | + +**Gotcha:** `FiatToken.BRL` is `"BRL"` but `EvmToken.BRLA` is `"BRLA"` +(and `FiatToken.EURC` is `"EUR"` vs `EvmToken.EURC` `"EURC"`) — different +strings, so the brands are distinct types. This is what makes the +fiat→EVM boundary in `AveniaMint` / `MykoboMint` type-check: the output +brand genuinely differs from the input brand, and only the mint phase's +declared signature bridges them. + +### Factory function call forms (TS has no generic const values) + +| Export | Form | Why | +|--------|------|-----| +| `AveniaMint` | plain `const` (no generics) | no runtime variability | +| `MykoboMint` | plain `const` (no generics) | no runtime variability | +| `FundEphemeral(token, chain)` | generic **function with runtime args** | executor needs the runtime chain | +| `NablaSwap(chain, in, out)` | generic function with runtime args | needs runtime values for `getOnChainTokenDetails` | +| `SquidRouterSwap(from, to, fromToken, toToken)` | generic function with runtime args | needs runtime values for the bridge request; target token is the phase's own arg | +| `DistributeFees()` | type-args only | reads from `ctx.fees` | +| `SubsidizePre()` | type-args only | ctx-derived | +| `SubsidizePost()` | type-args only | ctx-derived | +| `FinalSettlementSubsidy()` | type-args only | ctx-derived | +| `DestinationTransfer()` | type-args only | pure passthrough in simulation | +| `passthrough()` | type-args only | pure no-op | +| `branch(select, branches)` | generic function | runtime decision point | + +**Brands are always enum member types** (`typeof EvmToken.BRLA`, +`typeof Networks.Base`), never plain string literals — keep this consistent +so adjacency matches. + +### Port sources (per phase: simulate ← quote engine, executor ← handler) + +| Block | Simulate ported from | Executor(s) ported from | +|-------|---------------------|-------------------------| +| `AveniaMint` | `engines/initialize/onramp-avenia.ts` | `handlers/brla-onramp-mint-handler.ts` | +| `FundEphemeral` | (passthrough) | `handlers/fund-ephemeral-handler.ts` | +| `SubsidizePre` / `SubsidizePost` / `FinalSettlementSubsidy` | `engines/discount/onramp.ts` (simplified) | `handlers/subsidize-pre-swap-handler.ts`, `handlers/subsidize-post-swap-handler.ts`, `handlers/final-settlement-subsidy.ts` | +| `NablaSwap` | `engines/nabla-swap/base-evm.ts`, `core/nabla.ts` | `handlers/nabla-approve-handler.ts`, `handlers/nabla-swap-handler.ts` | +| `DistributeFees` | (deducts `ctx.fees.usd`) | `handlers/distribute-fees-handler.ts` | +| `SquidRouterSwap` | `engines/squidrouter/onramp-base-to-evm.ts`, `core/squidrouter.ts` | `handlers/squid-router-phase-handler.ts`, `handlers/squid-router-pay-phase-handler.ts` | +| `DestinationTransfer` | (passthrough) | `handlers/destination-transfer-handler.ts` | +| `computeFees` | `core/quote-fees.ts` | — | + +All handler paths relative to `apps/api/src/api/services/phases/`. + +### Known gaps & POC limitations + +All intentional scope cuts, not bugs: + +1. **Subsidy simplified.** The subsidy phases compute metadata from + `ctx.partner.targetDiscount` / `maxSubsidy` + a single oracle price + lookup. They do NOT port: the DB partner lookup + (`resolveDiscountPartner`), the per-engine SquidRouter conversion-rate + adjustment, or post-swap fee deduction from the "actual" amount. +2. **`computeFees` network fee = `"0"`, anchor fee from DB.** The + production Avenia fee engine sets the anchor fee from the live Avenia + mint/transfer fees and the network fee from a Squid quote; the blocks + adapter reuses `calculateFeeComponents` only. Simulated amounts + therefore drift from the old engine until fee parity (roadmap) is done. +3. **Executors are corridor slices.** EVM ephemeral, Base source, BUY + direction. Substrate (Pendulum), Polygon/Alfredpay, and SELL branches + of the production handlers are not ported — they belong to the + corridors that exercise them. +4. **Executors keep production's cross-phase metadata reads** (e.g. + `subsidizePostSwap` topping up to `evmToEvm.inputAmountRaw`) for + bit-compatibility during migration. Persisting per-phase IO boundaries + would remove these reads — deferred. +5. **`NablaSwap` runtime is Base-only.** `calculateNablaSwapOutputEvm` + hardcodes `Networks.Base`; the `chain` arg is used for branding/IO. +6. **`PartnerInfo` import source.** Not exported from `@vortexfi/shared`; + `core/types.ts` imports it from `../../core/types` (read-only). +7. **Smoke test mock leakage.** `mock.module("../../../priceFeed.service", ...)` + does not fully intercept the real module. Harmless: oracle calls are + wrapped in `try/catch` inside the phases, so the flow completes. + +### Appendix: the existing code this refactor targets + +The entanglement this model unwinds: + +- **Quote side** (`apps/api/src/api/services/quote/`): `QuoteService` → + `RouteResolver` → `QuoteOrchestrator` walks `stages: StageKey[]` calling + `engine.execute(ctx)`. Strategies in `routes/strategies/`, engines in + `engines/*`, all communicating through the ~25-field optional + `QuoteContext` bag. +- **Execution side** (`apps/api/src/api/services/phases/` + + `transactions/`): `PhaseProcessor` walks `state.state.phaseFlow`. + Handlers read `quote.metadata.*` by string key. Route-prep picks the + `phaseFlow` array AND builds `unsignedTxs` from the same metadata — + re-deriving the corridor decision the quote strategy already made. +- **The gap:** quote stages ≠ execution phases; the mapping is implicit, + spread across three files; `phaseFlow` correctness is runtime-only. + +The block model closes that gap one corridor at a time, without a +big-bang rewrite: this corridor's quote simulation, phase sequence, and +execution handlers are now defined by a single flow. + +### Roadmap (next steps, in priority order) + +1. **`prepareTxs` on `Phase`.** The remaining third leg: each phase + declares the unsigned transactions its executors expect + (`nablaApprove`/`nablaSwap` txs, squid approve/swap, destination + transfer), replacing the corridor's route-prep in + `transactions/onramp/routes/avenia-to-evm-base.ts`. Needs a flow-level + prepare context (ephemeral addresses, per-signer nonce allocation in + flow order). +2. **Wire the flow behind a flag.** Register `flow.executors` into the + phase registry and route `QuoteService` through `flow.simulate` for + this corridor; run real numerical parity vs `onrampAveniaToEvmBaseStrategy` + (requires closing gap #2 first). +3. **Port the remaining corridors.** `EUR_ONRAMP_BASE_*` next (MykoboMint + exists; executors for `mykoboOnrampDeposit` pending), then + `BRL_ONRAMP_BASE_{DIRECT,SAME_CHAIN}` (subsets of this flow family), + `ALFREDPAY_*`, the offramps. +4. **Persist per-phase IO boundaries** into metadata so executors read + their own phase's simulated output instead of downstream keys (gap #4). +5. **Delete the old strategy + `ramp-flow-definitions.ts` entry + handlers** + for each ported corridor once parity is proven. diff --git a/apps/api/src/api/services/quote/blocks/__tests__/brl-onramp-base-cross-chain.parity.test.ts b/apps/api/src/api/services/quote/blocks/__tests__/brl-onramp-base-cross-chain.parity.test.ts new file mode 100644 index 000000000..b49db9817 --- /dev/null +++ b/apps/api/src/api/services/quote/blocks/__tests__/brl-onramp-base-cross-chain.parity.test.ts @@ -0,0 +1,223 @@ +import { describe, expect, it, mock } from "bun:test"; +import Big from "big.js"; +import { BrlaApiService, EPaymentMethod, EvmToken, FiatToken, Networks, RampDirection, RampPhase } from "@vortexfi/shared"; + +mock.module("../../core/nabla", () => ({ + calculateNablaSwapOutput: async () => { + throw new Error("calculateNablaSwapOutput should not be called in EVM-only smoke test"); + }, + calculateNablaSwapOutputEvm: async () => ({ + effectiveExchangeRate: "0.18", + nablaOutputAmountDecimal: new Big(18), + nablaOutputAmountRaw: "18000000" + }) +})); + +mock.module("../../core/squidrouter", () => ({ + calculateEvmBridgeAndNetworkFee: async () => ({ + finalEffectiveExchangeRate: "0.99", + finalGrossOutputAmountDecimal: new Big(17.5), + networkFeeUSD: "0.1", + outputTokenDecimals: 6 + }), + getBridgeTargetTokenDetails: () => ({ + erc20AddressSourceChain: "0xaf88d065e77c8cC2239327C5EDb3A432268e5831" + }) +})); + +mock.module("../../../priceFeed.service", () => ({ + priceFeedService: { + getOnchainOraclePrice: async () => ({ + lastUpdateTimestamp: 0, + name: "mock", + price: new Big(0.18) + }) + } +})); + +import { BRL_ONRAMP_BASE_CROSS_CHAIN } from "../../../phases/ramp-flow-definitions"; +import { FlowBuilder } from "../core/flow"; +import { assemblePhaseFlow } from "../core/phase-flow"; +import type { PhaseCtx, PhaseIO } from "../core/types"; +import type { SubsidyMeta } from "../phases/subsidize-pre"; +import { AveniaMint } from "../phases/avenia-mint"; +import { DistributeFees } from "../phases/distribute-fees"; +import { NablaSwap } from "../phases/nabla-swap"; +import { SquidRouterSwap } from "../phases/squid-router-swap"; +import { + brlOnrampBaseCrossChainFlow, + brlOnrampBaseCrossChainPhaseFlow, + makeBrlOnrampBaseCrossChainFlow +} from "../flows/brl-onramp-base-cross-chain"; + +const CORE_PHASES: RampPhase[] = [ + "brlaOnrampMint", + "fundEphemeral", + "subsidizePreSwap", + "nablaApprove", + "nablaSwap", + "distributeFees", + "subsidizePostSwap", + "squidRouterSwap", + "squidRouterPay", + "finalSettlementSubsidy", + "destinationTransfer" +]; + +describe("BRL_ONRAMP_BASE_CROSS_CHAIN block flow — structure", () => { + it("derives the core phases from the assembled blocks", () => { + expect(brlOnrampBaseCrossChainFlow.phases).toEqual(CORE_PHASES); + }); + + it("assembles the phaseFlow matching the existing BRL_ONRAMP_BASE_CROSS_CHAIN", () => { + expect(brlOnrampBaseCrossChainPhaseFlow).toEqual(BRL_ONRAMP_BASE_CROSS_CHAIN); + expect(assemblePhaseFlow(brlOnrampBaseCrossChainFlow)).toEqual(BRL_ONRAMP_BASE_CROSS_CHAIN); + }); + + it("derives the same phaseFlow for every destination in the flow family", () => { + expect(assemblePhaseFlow(makeBrlOnrampBaseCrossChainFlow(Networks.Polygon, EvmToken.USDT))).toEqual( + BRL_ONRAMP_BASE_CROSS_CHAIN + ); + expect(assemblePhaseFlow(makeBrlOnrampBaseCrossChainFlow(Networks.Ethereum, EvmToken.USDC))).toEqual( + BRL_ONRAMP_BASE_CROSS_CHAIN + ); + }); +}); + +describe("BRL_ONRAMP_BASE_CROSS_CHAIN block flow — executors", () => { + it("provides exactly one executor per phase, in flow order", () => { + expect(brlOnrampBaseCrossChainFlow.executors.map(executor => executor.getPhaseName())).toEqual(CORE_PHASES); + }); + + it("provides executors for every destination in the flow family", () => { + const flow = makeBrlOnrampBaseCrossChainFlow(Networks.Polygon, EvmToken.USDT); + expect(flow.executors.map(executor => executor.getPhaseName())).toEqual(CORE_PHASES); + }); +}); + +describe("BRL_ONRAMP_BASE_CROSS_CHAIN block flow — compile-time adjacency", () => { + it.skip("rejects brand mismatches at compile time", () => { + // AveniaMint outputs BRLA on Base; a EURC-input swap cannot follow. + // @ts-expect-error adjacency: NablaSwap input brand (EURC) != AveniaMint output brand (BRLA) + const _wrongToken = FlowBuilder.start(AveniaMint).pipe(NablaSwap(Networks.Base, EvmToken.EURC, EvmToken.USDC)); + void _wrongToken; + + // The bridge lands on Arbitrum; a Base-only phase cannot follow. + const bridged = FlowBuilder.start(SquidRouterSwap(Networks.Base, Networks.Arbitrum, EvmToken.USDC, EvmToken.USDC)); + // @ts-expect-error adjacency: DistributeFees chain brand (base) != bridge output chain (arbitrum) + const _wrongChain = bridged.pipe(DistributeFees()); + void _wrongChain; + }); +}); + +function buildCtx(): PhaseCtx { + const notes: string[] = []; + return { + addNote: (note: string) => { + notes.push(note); + }, + fees: { + usd: { anchor: "0.1", network: "0.1", partnerMarkup: "0", total: "0.3", vortex: "0.1" } + }, + notes, + now: new Date(), + partner: null, + request: { + from: EPaymentMethod.PIX, + inputAmount: "100", + inputCurrency: FiatToken.BRL, + network: Networks.Base, + outputCurrency: EvmToken.USDC, + rampType: RampDirection.BUY, + to: Networks.Arbitrum + } + }; +} + +async function runFlow(flow: typeof brlOnrampBaseCrossChainFlow): Promise { + BrlaApiService.getInstance = mock(() => ({ + createPayInQuote: mock(async (request: { inputCurrency: string }) => ({ + appliedFees: [{ amount: "0.2", type: "Gas Fee" }], + outputAmount: request.inputCurrency === "BRL" ? "99" : "98.5", + quoteToken: "mock-quote-token" + })) + })) as unknown as typeof BrlaApiService.getInstance; + + return flow.simulate(buildCtx()); +} + +describe("BRL_ONRAMP_BASE_CROSS_CHAIN block flow — simulate smoke", () => { + it("runs the flow end-to-end and lands on the destination token", async () => { + const output: PhaseIO = await runFlow(brlOnrampBaseCrossChainFlow); + expect(output.amount.gt(0)).toBe(true); + expect(output.token).toBe(EvmToken.USDC); + expect(output.chain).toBe(Networks.Arbitrum); + }); +}); + +describe("BRL_ONRAMP_BASE_CROSS_CHAIN block flow — metadata parity", () => { + it("accumulates aveniaMint, aveniaTransfer, fees, nablaSwapEvm, evmToEvm, subsidy in the flow meta", async () => { + const output: PhaseIO = await runFlow(brlOnrampBaseCrossChainFlow); + const { meta } = output; + + expect(meta.fees).toBeDefined(); + expect((meta.fees as { usd: { total: string } }).usd.total).toBe("0.3"); + + const aveniaMint = meta.aveniaMint as { + currency: FiatToken; + fee: Big; + inputAmountDecimal: Big; + outputAmountDecimal: Big; + outputAmountRaw: string; + }; + expect(aveniaMint).toBeDefined(); + expect(aveniaMint.currency).toBe(FiatToken.BRL); + // 100 BRL in, 99 BRLA quoted -> 1 BRL mint fee, 0.2 gas fee deducted from delivery + expect(aveniaMint.fee.toFixed()).toBe("1"); + expect(aveniaMint.inputAmountDecimal.toFixed()).toBe("100"); + expect(aveniaMint.outputAmountDecimal.toFixed()).toBe("98.8"); + + const aveniaTransfer = meta.aveniaTransfer as { + fee: Big; + inputAmountDecimal: Big; + outputAmountDecimal: Big; + outputAmountRaw: string; + }; + expect(aveniaTransfer).toBeDefined(); + expect(aveniaTransfer.inputAmountDecimal.toFixed()).toBe("98.8"); + // transfer quote outputs 98.5, minus the 0.2 gas-fee buffer ((0.2 + 0.2) * 0.5) + expect(aveniaTransfer.outputAmountDecimal.toFixed()).toBe("98.3"); + + const nabla = meta.nablaSwapEvm as { + inputCurrency: string; + outputAmountRaw: string; + outputCurrency: string; + effectiveExchangeRate?: string; + }; + expect(nabla).toBeDefined(); + expect(nabla.inputCurrency).toBe(EvmToken.BRLA); + expect(nabla.outputCurrency).toBe(EvmToken.USDC); + expect(nabla.outputAmountRaw).toBe("18000000"); + expect(nabla.effectiveExchangeRate).toBe("0.18"); + + const evmToEvm = meta.evmToEvm as { + fromNetwork: string; + inputAmountRaw: string; + outputAmountRaw: string; + toNetwork: string; + networkFeeUSD: string; + }; + expect(evmToEvm).toBeDefined(); + expect(evmToEvm.fromNetwork).toBe(Networks.Base); + expect(evmToEvm.toNetwork).toBe(Networks.Arbitrum); + expect(evmToEvm.inputAmountRaw).toBeDefined(); + expect(evmToEvm.outputAmountRaw).toBe("17500000"); + expect(evmToEvm.networkFeeUSD).toBe("0.1"); + + const subsidy = meta.subsidy as SubsidyMeta; + expect(subsidy).toBeDefined(); + expect(subsidy.applied).toBe(false); + expect(subsidy.actualOutputAmountDecimal.gt(0)).toBe(true); + expect(subsidy.partnerId).toBeNull(); + }); +}); diff --git a/apps/api/src/api/services/quote/blocks/core/combinators.ts b/apps/api/src/api/services/quote/blocks/core/combinators.ts new file mode 100644 index 000000000..8fe609e5a --- /dev/null +++ b/apps/api/src/api/services/quote/blocks/core/combinators.ts @@ -0,0 +1,43 @@ +import type { RampPhase } from "@vortexfi/shared"; +import type { ChainBrand, Phase, PhaseCtx, PhaseIO, TokenBrand } from "./types"; + +export function branch( + select: (ctx: PhaseCtx) => Promise | number, + branches: Phase[] +): Phase { + const unionPhases: RampPhase[] = []; + const seen = new Set(); + for (const branchPhase of branches) { + for (const phase of branchPhase.phases) { + if (!seen.has(phase)) { + seen.add(phase); + unionPhases.push(phase); + } + } + } + return { + name: "branch", + phases: unionPhases, + async simulate(input: I, ctx: PhaseCtx): Promise { + const index = await select(ctx); + const chosen = branches[index]; + if (!chosen) { + throw new Error(`branch: select returned ${index}, no branch at that index`); + } + return chosen.simulate(input, ctx); + } + }; +} + +export function passthrough(): Phase< + PhaseIO, + PhaseIO +> { + return { + name: "passthrough", + phases: [], + async simulate(input: PhaseIO): Promise> { + return input; + } + }; +} diff --git a/apps/api/src/api/services/quote/blocks/core/fees.ts b/apps/api/src/api/services/quote/blocks/core/fees.ts new file mode 100644 index 000000000..444e6e178 --- /dev/null +++ b/apps/api/src/api/services/quote/blocks/core/fees.ts @@ -0,0 +1,50 @@ +import { EvmToken, RampCurrency } from "@vortexfi/shared"; +import Big from "big.js"; +import { priceFeedService } from "../../../priceFeed.service"; +import { calculateFeeComponents } from "../../core/quote-fees"; +import type { PhaseCtx } from "./types"; + +export async function computeFees(ctx: PhaseCtx): Promise { + if (ctx.fees) return; + + const { vortexFee, anchorFee, partnerMarkupFee, feeCurrency } = await calculateFeeComponents({ + from: ctx.request.from, + inputAmount: ctx.request.inputAmount, + inputCurrency: ctx.request.inputCurrency, + outputAmountOfframp: "0", + outputCurrency: ctx.request.outputCurrency, + partnerName: ctx.partner?.id || undefined, + rampType: ctx.request.rampType, + to: ctx.request.to + }); + + const USD = EvmToken.USDC as RampCurrency; + const [vortexUsd, anchorUsd, partnerUsd] = await Promise.all([ + priceFeedService.convertCurrency(vortexFee, feeCurrency, USD), + priceFeedService.convertCurrency(anchorFee, feeCurrency, USD), + priceFeedService.convertCurrency(partnerMarkupFee, feeCurrency, USD) + ]); + + const networkUsd = "0"; + const networkDisplay = "0"; + const totalUsd = new Big(vortexUsd).plus(anchorUsd).plus(partnerUsd).plus(networkUsd).toFixed(6); + const totalDisplay = new Big(vortexFee).plus(anchorFee).plus(partnerMarkupFee).toFixed(2); + + ctx.fees = { + displayFiat: { + anchor: anchorFee, + currency: feeCurrency, + network: networkDisplay, + partnerMarkup: partnerMarkupFee, + total: totalDisplay, + vortex: vortexFee + }, + usd: { + anchor: anchorUsd, + network: networkUsd, + partnerMarkup: partnerUsd, + total: totalUsd, + vortex: vortexUsd + } + }; +} diff --git a/apps/api/src/api/services/quote/blocks/core/flow.ts b/apps/api/src/api/services/quote/blocks/core/flow.ts new file mode 100644 index 000000000..000d66c03 --- /dev/null +++ b/apps/api/src/api/services/quote/blocks/core/flow.ts @@ -0,0 +1,41 @@ +import type { RampPhase } from "@vortexfi/shared"; +import { computeFees } from "./fees"; +import { requestToIO } from "./io"; +import type { Flow, Phase, PhaseCtx, PhaseIO } from "./types"; + +type OutputOf

= P extends Phase ? O : never; + +// Internal type-erased phase storage. `never` input makes any Phase assignable under +// contravariance; the builder's pipe() adjacency check is what guarantees the runtime inputs line up. +type AnyPhase = Phase; + +export class FlowBuilder { + private constructor(private readonly phaseList: AnyPhase[]) {} + + static start

(first: P): FlowBuilder> { + return new FlowBuilder>([first]); + } + + pipe

>(next: P): FlowBuilder> { + return new FlowBuilder>([...this.phaseList, next]); + } + + build(name: string): Flow { + const phaseList = this.phaseList; + const phases: RampPhase[] = phaseList.flatMap(phase => phase.phases); + const executors = phaseList.flatMap(phase => phase.executors ?? []); + return { + executors, + name, + phases, + async simulate(ctx: PhaseCtx): Promise { + await computeFees(ctx); + let current: PhaseIO = requestToIO(ctx); + for (const phase of phaseList) { + current = await phase.simulate(current as never, ctx); + } + return current; + } + }; + } +} diff --git a/apps/api/src/api/services/quote/blocks/core/io.ts b/apps/api/src/api/services/quote/blocks/core/io.ts new file mode 100644 index 000000000..9b0242b60 --- /dev/null +++ b/apps/api/src/api/services/quote/blocks/core/io.ts @@ -0,0 +1,23 @@ +import type { RampCurrency } from "@vortexfi/shared"; +import Big from "big.js"; +import type { ChainBrand, PhaseCtx, PhaseIO, TokenBrand } from "./types"; + +export function requestToIO(ctx: PhaseCtx): PhaseIO { + return { + amount: new Big(ctx.request.inputAmount), + amountRaw: ctx.request.inputAmount, + chain: "fiat", + meta: {}, + token: ctx.request.inputCurrency + }; +} + +export function evmIO( + token: Token, + chain: Chain, + amount: Big, + amountRaw: string, + meta?: Record +): PhaseIO { + return { amount, amountRaw, chain, meta: meta ?? {}, token }; +} diff --git a/apps/api/src/api/services/quote/blocks/core/phase-flow.ts b/apps/api/src/api/services/quote/blocks/core/phase-flow.ts new file mode 100644 index 000000000..fa715ccf1 --- /dev/null +++ b/apps/api/src/api/services/quote/blocks/core/phase-flow.ts @@ -0,0 +1,6 @@ +import type { RampPhase } from "@vortexfi/shared"; +import type { Flow } from "./types"; + +export function assemblePhaseFlow(flow: Flow): RampPhase[] { + return ["initial", ...flow.phases, "complete"]; +} diff --git a/apps/api/src/api/services/quote/blocks/core/types.ts b/apps/api/src/api/services/quote/blocks/core/types.ts new file mode 100644 index 000000000..aa4a69366 --- /dev/null +++ b/apps/api/src/api/services/quote/blocks/core/types.ts @@ -0,0 +1,45 @@ +import type { CreateQuoteRequest, QuoteFeeStructure, RampCurrency, RampPhase } from "@vortexfi/shared"; +import type { Big } from "big.js"; +import type { PhaseHandler } from "../../../phases/base-phase-handler"; +import type { PartnerInfo } from "../../core/types"; + +export type TokenBrand = string; +export type ChainBrand = string; + +export interface PhaseIO { + amount: Big; + amountRaw: string; + token: Token; + chain: Chain; + meta: Record; +} + +export interface PhaseCtx { + request: CreateQuoteRequest & { userId?: string }; + partner: PartnerInfo | null; + now: Date; + notes: string[]; + addNote(note: string): void; + fees?: { + usd?: { vortex: string; anchor: string; partnerMarkup: string; network: string; total: string }; + displayFiat?: QuoteFeeStructure; + }; +} + +export interface Phase { + readonly name: string; + readonly phases: RampPhase[]; + // Property (not method) so pipe's brand check stays contravariant under strictFunctionTypes. + readonly simulate: (input: I, ctx: PhaseCtx) => Promise; + // One executor per entry in `phases`, in the same order. Optional while corridors + // are ported incrementally; a flow whose phases all carry executors is fully + // execution-ready (registerable into the phase registry, unwired for now). + readonly executors?: PhaseHandler[]; +} + +export interface Flow { + readonly name: string; + readonly phases: RampPhase[]; + readonly executors: PhaseHandler[]; + simulate(ctx: PhaseCtx): Promise; +} diff --git a/apps/api/src/api/services/quote/blocks/flows/brl-onramp-base-cross-chain.ts b/apps/api/src/api/services/quote/blocks/flows/brl-onramp-base-cross-chain.ts new file mode 100644 index 000000000..2192aa2f1 --- /dev/null +++ b/apps/api/src/api/services/quote/blocks/flows/brl-onramp-base-cross-chain.ts @@ -0,0 +1,35 @@ +import { EvmToken, Networks } from "@vortexfi/shared"; +import { FlowBuilder } from "../core/flow"; +import { assemblePhaseFlow } from "../core/phase-flow"; +import type { ChainBrand, Flow, TokenBrand } from "../core/types"; +import { AveniaMint } from "../phases/avenia-mint"; +import { DestinationTransfer } from "../phases/destination-transfer"; +import { DistributeFees } from "../phases/distribute-fees"; +import { FinalSettlementSubsidy } from "../phases/final-settlement-subsidy"; +import { FundEphemeral } from "../phases/fund-ephemeral"; +import { NablaSwap } from "../phases/nabla-swap"; +import { SquidRouterSwap } from "../phases/squid-router-swap"; +import { SubsidizePost } from "../phases/subsidize-post"; +import { SubsidizePre } from "../phases/subsidize-pre"; + +// The destination chain/token vary per request (quote.to / quote.outputCurrency), so the corridor +// is a flow family: one factory, one flow instance per destination. The RampPhase[] shape is +// identical for every destination. +export function makeBrlOnrampBaseCrossChainFlow( + toChain: ToChain, + toToken: ToToken +): Flow { + return FlowBuilder.start(AveniaMint) + .pipe(FundEphemeral(EvmToken.BRLA, Networks.Base)) + .pipe(SubsidizePre()) + .pipe(NablaSwap(Networks.Base, EvmToken.BRLA, EvmToken.USDC)) + .pipe(DistributeFees()) + .pipe(SubsidizePost()) + .pipe(SquidRouterSwap(Networks.Base, toChain, EvmToken.USDC, toToken)) + .pipe(FinalSettlementSubsidy()) + .pipe(DestinationTransfer()) + .build("BrlOnrampBaseCrossChain"); +} + +export const brlOnrampBaseCrossChainFlow: Flow = makeBrlOnrampBaseCrossChainFlow(Networks.Arbitrum, EvmToken.USDC); +export const brlOnrampBaseCrossChainPhaseFlow = assemblePhaseFlow(brlOnrampBaseCrossChainFlow); diff --git a/apps/api/src/api/services/quote/blocks/phases/avenia-mint.ts b/apps/api/src/api/services/quote/blocks/phases/avenia-mint.ts new file mode 100644 index 000000000..5d362af4e --- /dev/null +++ b/apps/api/src/api/services/quote/blocks/phases/avenia-mint.ts @@ -0,0 +1,301 @@ +import { + AveniaPaymentMethod, + BalanceCheckError, + BalanceCheckErrorType, + BlockchainSendMethod, + BrlaApiService, + BrlaCurrency, + checkEvmBalancePeriodically, + EvmAddress, + EvmToken, + evmTokenConfig, + FiatToken, + getAnyFiatTokenDetailsMoonbeam, + getEvmTokenBalance, + multiplyByPowerOfTen, + Networks, + RampPhase, + waitUntilTrueWithTimeout +} from "@vortexfi/shared"; +import Big from "big.js"; +import httpStatus from "http-status"; +import logger from "../../../../../config/logger"; +import QuoteTicket from "../../../../../models/quoteTicket.model"; +import RampState from "../../../../../models/rampState.model"; +import TaxId from "../../../../../models/taxId.model"; +import { APIError } from "../../../../errors/api-error"; +import { BasePhaseHandler } from "../../../phases/base-phase-handler"; +import { StateMetadata } from "../../../phases/meta-state-types"; +import { evmIO } from "../core/io"; +import type { Phase, PhaseIO } from "../core/types"; + +const PAYMENT_TIMEOUT_MS = 30 * 60 * 1000; // 30 minutes +const EVM_BALANCE_CHECK_TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes + +// The pre-computed expected amount stored at quote-creation time can be slightly higher than the +// amount actually transferred due to fee differences at execution time. We allow a 5% tolerance +// in the recovery shortcut so that an already-funded ephemeral is not missed. +const EPHEMERAL_FUNDED_TOLERANCE_FACTOR = 0.95; + +class BrlaOnrampMintExecutor extends BasePhaseHandler { + public getPhaseName(): RampPhase { + return "brlaOnrampMint"; + } + + protected async executePhase(state: RampState): Promise { + const { evmEphemeralAddress } = state.state as StateMetadata; + + if (!evmEphemeralAddress) { + throw new Error("BrlaOnrampMintExecutor: State metadata corrupted. This is a bug."); + } + + const quote = await QuoteTicket.findByPk(state.quoteId); + if (!quote) { + throw new Error("Quote not found for the given state"); + } + + if (!quote.metadata.aveniaMint) { + throw new Error("Missing 'aveniaMint' in quote metadata"); + } + + if (!quote.metadata.aveniaTransfer) { + throw new Error("Missing 'aveniaTransfer' in quote metadata"); + } + + const taxIdRecord = await TaxId.findByPk(state.state.taxId); + if (!taxIdRecord) { + throw new APIError({ + message: "Subaccount not found", + status: httpStatus.BAD_REQUEST + }); + } + + const tokenDetails = evmTokenConfig[Networks.Base][EvmToken.BRLA]; + if (!tokenDetails) { + throw new Error("BRLA token details not found for Base network"); + } + + const preComputedExpectedAmountRaw = quote.metadata.aveniaTransfer.outputAmountRaw; + + // Recovery shortcut: a previous run may have already minted on Avenia and transferred to the + // ephemeral. Accept a balance of at least 95% of the pre-computed expected amount. + const recoveryThresholdRaw = new Big(preComputedExpectedAmountRaw).times(EPHEMERAL_FUNDED_TOLERANCE_FACTOR).toFixed(0, 0); + + if (await this.ephemeralAlreadyFunded(tokenDetails.erc20AddressSourceChain, evmEphemeralAddress, recoveryThresholdRaw)) { + logger.info( + `BrlaOnrampMintExecutor: Ephemeral ${evmEphemeralAddress} already holds at least 95% of the expected ${preComputedExpectedAmountRaw} BRLA (threshold: ${recoveryThresholdRaw}). Skipping mint flow.` + ); + return state; + } + + const brlaApiService = BrlaApiService.getInstance(); + try { + logger.info( + `BrlaOnrampMintExecutor: Waiting for Avenia balance to have at least ${quote.metadata.aveniaMint.outputAmountDecimal} BRL` + ); + await waitUntilTrueWithTimeout( + async () => { + if (!quote.metadata.aveniaMint) { + return false; + } + + const { balances } = await brlaApiService.getAccountBalance(taxIdRecord.subAccountId); + if (!balances || balances.BRLA === undefined || balances.BRLA === null) { + return false; + } + return Number(balances.BRLA) >= Number(Big(quote.metadata.aveniaMint.outputAmountDecimal).toFixed(2, 0)); + }, + 5000, + PAYMENT_TIMEOUT_MS + ); + } catch (error) { + const isCheckTimeout = error instanceof Error && error.message.includes("Timeout"); + if (isCheckTimeout && this.isPaymentTimeoutReached(state)) { + logger.error("Payment timeout. Cancelling ramp."); + return this.transitionToNextPhase(state, "failed"); + } + + throw isCheckTimeout + ? this.createRecoverableError( + `BrlaOnrampMintExecutor: phase timeout reached waiting for Avenia balance with error: ${error}` + ) + : new Error(`Error checking Avenia balance: ${error}`); + } + + // Transfer the funds from the subaccount to the ephemeral address + const aveniaQuote = await brlaApiService.createPayInQuote({ + blockchainSendMethod: BlockchainSendMethod.PERMIT, + inputAmount: Big(quote.metadata.aveniaMint.outputAmountDecimal).toFixed(2, 0), + inputCurrency: BrlaCurrency.BRLA, + inputPaymentMethod: AveniaPaymentMethod.INTERNAL, + inputThirdParty: false, + outputCurrency: BrlaCurrency.BRLA, + outputPaymentMethod: AveniaPaymentMethod.BASE, + outputThirdParty: false, + subAccountId: taxIdRecord.subAccountId + }); + + logger.info("BrlaOnrampMintExecutor: Created Avenia pay-out quote for mint transfer."); + + // Derive the expected on-chain amount from the live quote rather than the stale pre-computed + // metadata value: the live quote accounts for the fees actually applied at execution time. + const expectedAmountReceived = multiplyByPowerOfTen(new Big(aveniaQuote.outputAmount), tokenDetails.decimals).toFixed(0, 0); + + logger.info( + `BrlaOnrampMintExecutor: Live Avenia quote output is ${aveniaQuote.outputAmount} BRLA (raw: ${expectedAmountReceived}). Pre-computed metadata value was ${preComputedExpectedAmountRaw}.` + ); + + const aveniaTicket = await brlaApiService.createPixOutputTicket( + { + quoteToken: aveniaQuote.quoteToken, + ticketBlockchainOutput: { + walletAddress: state.state.evmEphemeralAddress, + walletChain: AveniaPaymentMethod.BASE + } + }, + taxIdRecord.subAccountId + ); + + logger.info( + `BrlaOnrampMintExecutor: Created Avenia transfer ticket with id ${aveniaTicket.id} to transfer ${aveniaQuote.outputAmount} BRLA to Base address ${state.state.evmEphemeralAddress}` + ); + + try { + const pollingTimeMs = 1000; + + await checkEvmBalancePeriodically( + tokenDetails.erc20AddressSourceChain, + evmEphemeralAddress, + expectedAmountReceived, + pollingTimeMs, + EVM_BALANCE_CHECK_TIMEOUT_MS, + Networks.Base + ); + } catch (error) { + if (!(error instanceof BalanceCheckError)) throw error; + + const isCheckTimeout = error.type === BalanceCheckErrorType.Timeout; + if (isCheckTimeout && this.isPaymentTimeoutReached(state)) { + logger.error("Payment timeout. Cancelling ramp."); + return this.transitionToNextPhase(state, "failed"); + } + + throw isCheckTimeout + ? this.createRecoverableError(`BrlaOnrampMintExecutor: phase timeout reached with error: ${error}`) + : new Error(`Error checking Base balance: ${error}`); + } + + return state; + } + + private async ephemeralAlreadyFunded( + tokenAddress: string, + ownerAddress: string, + expectedAmountRaw: string + ): Promise { + try { + const balance = await getEvmTokenBalance({ + chain: Networks.Base, + ownerAddress: ownerAddress as EvmAddress, + tokenAddress: tokenAddress as EvmAddress + }); + return balance.gte(new Big(expectedAmountRaw)); + } catch (error) { + // Treat read failures as "not funded" so we fall through to the regular flow rather than + // aborting the phase on a transient RPC error. + logger.warn( + `BrlaOnrampMintExecutor: ephemeral balance pre-check failed for ${ownerAddress}, falling back to Avenia flow: ${error}` + ); + return false; + } + } + + protected isPaymentTimeoutReached(state: RampState): boolean { + const thisPhaseEntry = state.phaseHistory.find(phaseHistoryEntry => phaseHistoryEntry.phase === this.getPhaseName()); + if (!thisPhaseEntry) { + throw new Error("BrlaOnrampMintExecutor: Phase not found in history. This is a bug."); + } + + const initialTimestamp = new Date(thisPhaseEntry.timestamp); + return initialTimestamp.getTime() + PAYMENT_TIMEOUT_MS < Date.now(); + } +} + +export const AveniaMint: Phase, PhaseIO> = { + executors: [new BrlaOnrampMintExecutor()], + name: "AveniaMint", + phases: ["brlaOnrampMint"], + async simulate(input, ctx) { + const brlaTokenDetails = getAnyFiatTokenDetailsMoonbeam(FiatToken.BRL); + const inputAmountDecimal = new Big(input.amount); + const inputAmountRaw = multiplyByPowerOfTen(inputAmountDecimal, brlaTokenDetails.decimals).toFixed(0, 0); + + const brlaApiService = BrlaApiService.getInstance(); + const aveniaPayInToInternalQuote = await brlaApiService.createPayInQuote( + { + inputAmount: inputAmountDecimal.toString(), + inputCurrency: BrlaCurrency.BRL, + inputPaymentMethod: AveniaPaymentMethod.PIX, + inputThirdParty: false, + outputCurrency: BrlaCurrency.BRLA, + outputPaymentMethod: AveniaPaymentMethod.INTERNAL, + outputThirdParty: false + }, + { useCache: true } + ); + + const aveniaTransferQuote = await brlaApiService.createPayInQuote( + { + blockchainSendMethod: BlockchainSendMethod.PERMIT, + inputAmount: aveniaPayInToInternalQuote.outputAmount.toString(), + inputCurrency: BrlaCurrency.BRLA, + inputPaymentMethod: AveniaPaymentMethod.INTERNAL, + inputThirdParty: false, + outputCurrency: BrlaCurrency.BRLA, + outputPaymentMethod: AveniaPaymentMethod.MOONBEAM, + outputThirdParty: false + }, + { useCache: true } + ); + + // We add a small buffer for the gas fees + const gasFeePayIn = aveniaPayInToInternalQuote.appliedFees.find(fee => fee.type === "Gas Fee"); + const receivedBrlaDecimal = new Big(aveniaPayInToInternalQuote.outputAmount).minus(gasFeePayIn?.amount || 0); + const receivedBrlaRaw = multiplyByPowerOfTen(receivedBrlaDecimal, brlaTokenDetails.decimals).toFixed(0, 0); + + const gasFeeTransfer = aveniaTransferQuote.appliedFees.find(fee => fee.type === "Gas Fee"); + let gasFeeBuffer = new Big(0.1); // Default to 0.1 BRL if we can't find the gas fee + if (gasFeePayIn || gasFeeTransfer) { + const gasFeeAmount = new Big(gasFeePayIn?.amount || 0).plus(gasFeeTransfer?.amount || 0); + // We add a 50% buffer to the applied gas fee + gasFeeBuffer = gasFeeAmount.mul(0.5); + } + + const mintedBrlaDecimal = new Big(aveniaTransferQuote.outputAmount).minus(gasFeeBuffer); + const mintedBrlaRaw = multiplyByPowerOfTen(mintedBrlaDecimal, brlaTokenDetails.decimals).toFixed(0, 0); + const transferFee = receivedBrlaDecimal.minus(mintedBrlaDecimal); + + ctx.addNote(`AveniaMint: assuming ${mintedBrlaDecimal.toFixed()} BRLA minted on the Base ephemeral account`); + + return evmIO(EvmToken.BRLA, Networks.Base, mintedBrlaDecimal, mintedBrlaRaw, { + ...input.meta, + aveniaMint: { + currency: FiatToken.BRL, + fee: inputAmountDecimal.minus(aveniaPayInToInternalQuote.outputAmount), + inputAmountDecimal, + inputAmountRaw, + outputAmountDecimal: receivedBrlaDecimal, + outputAmountRaw: receivedBrlaRaw + }, + aveniaTransfer: { + currency: FiatToken.BRL, + fee: transferFee, + inputAmountDecimal: receivedBrlaDecimal, + inputAmountRaw: receivedBrlaRaw, + outputAmountDecimal: mintedBrlaDecimal, + outputAmountRaw: mintedBrlaRaw + }, + fees: ctx.fees + }); + } +}; diff --git a/apps/api/src/api/services/quote/blocks/phases/destination-transfer.ts b/apps/api/src/api/services/quote/blocks/phases/destination-transfer.ts new file mode 100644 index 000000000..d464f6cf6 --- /dev/null +++ b/apps/api/src/api/services/quote/blocks/phases/destination-transfer.ts @@ -0,0 +1,186 @@ +import { + checkEvmBalanceForToken, + EvmClientManager, + EvmNetworks, + EvmTokenDetails, + getOnChainTokenDetails, + multiplyByPowerOfTen, + RampPhase +} from "@vortexfi/shared"; +import { decodeFunctionData, erc20Abi, parseTransaction } from "viem"; +import logger from "../../../../../config/logger"; +import QuoteTicket from "../../../../../models/quoteTicket.model"; +import RampState from "../../../../../models/rampState.model"; +import { UnrecoverablePhaseError } from "../../../../errors/phase-error"; +import { BasePhaseHandler } from "../../../phases/base-phase-handler"; +import { StateMetadata } from "../../../phases/meta-state-types"; +import type { ChainBrand, Phase, PhaseCtx, PhaseIO, TokenBrand } from "../core/types"; + +const BALANCE_POLLING_TIME_MS = 5000; +const EVM_BALANCE_CHECK_TIMEOUT_MS = 3 * 60 * 1000; // 3 minutes + +function validateDestinationTransferRecipient(rawTx: `0x${string}`, expectedDestination: string): void { + const decoded = parseTransaction(rawTx); + + if (!decoded.to) { + throw new Error("DestinationTransferExecutor: Presigned transaction has no 'to' address"); + } + + const isNativeTransfer = !decoded.data || decoded.data === "0x"; + + if (isNativeTransfer) { + if (decoded.to.toLowerCase() !== expectedDestination.toLowerCase()) { + throw new Error( + "DestinationTransferExecutor: Native transfer recipient mismatch. " + + `Expected ${expectedDestination}, got ${decoded.to}` + ); + } + return; + } + + // ERC-20 transfer: `to` is the token contract, recipient is in calldata + if (!decoded.data) { + throw new Error("DestinationTransferExecutor: ERC-20 transfer missing calldata"); + } + const { functionName, args } = decodeFunctionData({ abi: erc20Abi, data: decoded.data }); + if (functionName !== "transfer") { + throw new Error(`DestinationTransferExecutor: Expected ERC-20 'transfer' call, got '${functionName}'`); + } + + const [recipient] = args as [string, bigint]; + if (recipient.toLowerCase() !== expectedDestination.toLowerCase()) { + throw new Error( + "DestinationTransferExecutor: ERC-20 transfer recipient mismatch. " + `Expected ${expectedDestination}, got ${recipient}` + ); + } +} + +class DestinationTransferExecutor extends BasePhaseHandler { + public getPhaseName(): RampPhase { + return "destinationTransfer"; + } + + protected async executePhase(state: RampState): Promise { + const evmClientManager = EvmClientManager.getInstance(); + + const quote = await QuoteTicket.findByPk(state.quoteId); + if (!quote) { + throw new Error("Quote not found for the given state"); + } + + const outTokenDetails = getOnChainTokenDetails(quote.network, quote.outputCurrency) as EvmTokenDetails; + if (!outTokenDetails) { + throw new Error( + `DestinationTransferExecutor: Unsupported output token ${quote.outputCurrency} for network ${quote.network}` + ); + } + + const { txData: destinationTransfer } = this.getPresignedTransaction(state, "destinationTransfer"); + const expectedAmountRaw = multiplyByPowerOfTen(quote.outputAmount, outTokenDetails.decimals).toString(); + const destinationNetwork = quote.network as EvmNetworks; + const { destinationTransferTxHash, destinationAddress } = state.state as StateMetadata; + + if (destinationAddress) { + validateDestinationTransferRecipient(destinationTransfer as `0x${string}`, destinationAddress); + } else { + logger.warn("DestinationTransferExecutor: No destinationAddress in state metadata, skipping recipient validation"); + } + if (destinationTransferTxHash) { + try { + const client = evmClientManager.getClient(destinationNetwork); + const receipt = await client.getTransactionReceipt({ hash: destinationTransferTxHash as `0x${string}` }); + + if (receipt.status === "success") { + return state; + } else { + throw new Error(`Transaction ${destinationTransferTxHash} failed on chain.`); + } + } catch (error) { + if (error instanceof Error && error.name !== "TransactionReceiptNotFoundError") { + throw error; + } + // If receipt not found, proceed to normal flow + } + } + + // Nonce-gap guard: a presigned nonce ahead of the live ephemeral nonce can never be mined and + // would silently retry until the processor gives up, stranding user funds. Raise it for manual + // review. Reading the live nonce is best-effort: an RPC failure must not block the happy path. + if (!destinationTransferTxHash && state.state.evmEphemeralAddress) { + try { + const presignedNonce = parseTransaction(destinationTransfer as `0x${string}`).nonce; + if (presignedNonce !== undefined) { + try { + const liveNonce = await evmClientManager.getClient(destinationNetwork).getTransactionCount({ + address: state.state.evmEphemeralAddress as `0x${string}`, + blockTag: "pending" + }); + if (presignedNonce > liveNonce) { + throw this.createUnrecoverableError( + `DestinationTransferExecutor: presigned nonce ${presignedNonce} is ahead of the ephemeral live nonce ${liveNonce}. ` + + "The transfer can never broadcast (nonce gap); manual review required." + ); + } + } catch (error) { + if (error instanceof UnrecoverablePhaseError) { + throw error; + } + logger.warn( + `DestinationTransferExecutor: could not verify ephemeral nonce before broadcast - ${(error as Error).message}` + ); + } + } + } catch (error) { + if (error instanceof UnrecoverablePhaseError) { + throw error; + } + logger.warn( + `DestinationTransferExecutor: could not parse presigned destination transfer for nonce check - ${(error as Error).message}` + ); + } + } + + try { + await checkEvmBalanceForToken({ + amountDesiredRaw: expectedAmountRaw, + chain: destinationNetwork, + intervalMs: BALANCE_POLLING_TIME_MS, + ownerAddress: state.state.evmEphemeralAddress, + timeoutMs: EVM_BALANCE_CHECK_TIMEOUT_MS, + tokenDetails: outTokenDetails + }); + + const txHash = await evmClientManager.sendRawTransactionWithRetry( + destinationNetwork, + destinationTransfer as `0x${string}` + ); + await state.update({ + state: { + ...state.state, + destinationTransferTxHash: txHash + } + }); + + return state; + } catch (error) { + throw this.createRecoverableError( + `DestinationTransferExecutor: Error during phase execution - ${(error as Error).message}` + ); + } + } +} + +export function DestinationTransfer(): Phase< + PhaseIO, + PhaseIO +> { + return { + executors: [new DestinationTransferExecutor()], + name: "DestinationTransfer", + phases: ["destinationTransfer"], + async simulate(input: PhaseIO, ctx: PhaseCtx): Promise> { + ctx.addNote(`DestinationTransfer: delivering ${input.amount.toFixed()} ${input.token} on ${input.chain} to the user`); + return input; + } + }; +} diff --git a/apps/api/src/api/services/quote/blocks/phases/distribute-fees.ts b/apps/api/src/api/services/quote/blocks/phases/distribute-fees.ts new file mode 100644 index 000000000..49ae94d25 --- /dev/null +++ b/apps/api/src/api/services/quote/blocks/phases/distribute-fees.ts @@ -0,0 +1,196 @@ +import { + checkEvmBalanceForToken, + EvmClientManager, + EvmNetworks, + EvmToken, + EvmTokenDetails, + evmTokenConfig, + multiplyByPowerOfTen, + Networks, + RampPhase, + waitUntilTrueWithTimeout +} from "@vortexfi/shared"; +import Big from "big.js"; +import logger from "../../../../../config/logger"; +import QuoteTicket from "../../../../../models/quoteTicket.model"; +import RampState from "../../../../../models/rampState.model"; +import { PhaseError } from "../../../../errors/phase-error"; +import { BasePhaseHandler } from "../../../phases/base-phase-handler"; +import { evmIO } from "../core/io"; +import type { ChainBrand, Phase, PhaseCtx, PhaseIO, TokenBrand } from "../core/types"; + +const SIMPLIFIED_TOKEN_DECIMALS = 6; + +const FEE_BALANCE_POLL_INTERVAL_MS = 5_000; +const FEE_BALANCE_POLL_TIMEOUT_MS = 60_000; + +// EVM slice of the production DistributeFeesHandler: verifies the ephemeral holds enough USDC on +// Base to cover the USD fees, then broadcasts the presigned fee-distribution transaction. The +// substrate (Pendulum/Subscan) branch is not ported. +class DistributeFeesExecutor extends BasePhaseHandler { + public getPhaseName(): RampPhase { + return "distributeFees"; + } + + protected async executePhase(state: RampState): Promise { + const quote = await QuoteTicket.findOne({ where: { id: state.quoteId } }); + if (!quote) { + throw this.createUnrecoverableError(`Quote ticket not found for ID: ${state.quoteId}`); + } + + const existingHash = state.state.distributeFeeHash || null; + if (existingHash) { + logger.info(`Found existing distribute fee hash for ramp ${state.id}: ${existingHash}`); + + const isSuccessful = await this.isEvmTransactionSuccessful(existingHash, Networks.Base).catch((_: unknown) => { + throw this.createRecoverableError("Failed to check EVM transaction status from existing hash."); + }); + + if (isSuccessful) { + logger.info(`Existing distribute fee EVM transaction was successful for ramp ${state.id}`); + return state; + } + logger.info("Existing distribute fee EVM transaction was not successful, will retry"); + } + + try { + const distributeFeeTransaction = this.getPresignedTransaction(state, "distributeFees"); + if (distributeFeeTransaction === undefined) { + logger.info("No fee distribution transaction data found. Skipping fee distribution."); + return state; + } + + // The funding token (USDC) may not yet be on the ephemeral when we reach this phase. + // Poll for it before submitting; if it never arrives within the timeout, throw a + // recoverable error so we retry the phase. + await this.ensureEvmFeeTokenBalance(quote, distributeFeeTransaction.signer); + + logger.info(`Submitting EVM fee distribution transaction for ramp ${state.id}...`); + const txData = distributeFeeTransaction.txData; + if (typeof txData !== "string" || !txData.startsWith("0x")) { + throw new Error("DistributeFeesExecutor: Invalid presigned EVM transaction data"); + } + const evmClientManager = EvmClientManager.getInstance(); + const network = distributeFeeTransaction.network as EvmNetworks; + const actualTxHash = await evmClientManager.sendRawTransactionWithRetry(network, txData as `0x${string}`); + + logger.info(`Transaction broadcast with hash ${actualTxHash}. Persisting hash...`); + await state.update({ + state: { + ...state.state, + distributeFeeHash: actualTxHash + } + }); + + await this.waitForEvmTransactionSuccess(actualTxHash, network); + + logger.info(`Successfully verified fee distribution transaction for ramp ${state.id}: ${actualTxHash}`); + return state; + } catch (e: unknown) { + logger.error(`Error distributing fees for ramp ${state.id}:`, e); + + if (e instanceof PhaseError) { + throw e; + } + + const error = e instanceof Error ? e : new Error(String(e)); + throw this.createRecoverableError(`Failed to distribute fees: ${error.message || "Unknown error"}`); + } + } + + private computeRequiredFeeRaw(quote: QuoteTicket, decimals: number): Big | null { + const usdFeeStructure = quote.metadata.fees?.usd; + if (!usdFeeStructure) { + return null; + } + + const totalUsd = new Big(usdFeeStructure.network).plus(usdFeeStructure.vortex).plus(usdFeeStructure.partnerMarkup); + if (totalUsd.lte(0)) { + return null; + } + + return multiplyByPowerOfTen(totalUsd, decimals); + } + + private async ensureEvmFeeTokenBalance(quote: QuoteTicket, signerAddress: string): Promise { + const baseUsdcConfig = evmTokenConfig[Networks.Base][EvmToken.USDC] as EvmTokenDetails | undefined; + if (!baseUsdcConfig) { + throw this.createUnrecoverableError("Base USDC configuration not found; cannot verify fee balance."); + } + + const requiredRaw = this.computeRequiredFeeRaw(quote, baseUsdcConfig.decimals); + if (!requiredRaw) { + logger.info("No positive USD fees configured; skipping fee balance precondition check."); + return; + } + + logger.info( + `Checking EVM fee balance: signer=${signerAddress} requires >= ${requiredRaw.toFixed(0)} USDC raw on Base before submitting fee distribution.` + ); + + try { + const balance = await checkEvmBalanceForToken({ + amountDesiredRaw: requiredRaw.toFixed(0), + chain: Networks.Base as EvmNetworks, + intervalMs: FEE_BALANCE_POLL_INTERVAL_MS, + ownerAddress: signerAddress, + timeoutMs: FEE_BALANCE_POLL_TIMEOUT_MS, + tokenDetails: baseUsdcConfig + }); + logger.info(`EVM fee balance precondition met: balance=${balance.toFixed(0)} >= required=${requiredRaw.toFixed(0)}`); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + throw this.createRecoverableError( + `Fee distribution precondition failed: USDC balance not available on ${signerAddress} within ${FEE_BALANCE_POLL_TIMEOUT_MS}ms. ${message}` + ); + } + } + + private async isEvmTransactionSuccessful(txHash: string, network: EvmNetworks): Promise { + try { + const publicClient = EvmClientManager.getInstance().getClient(network); + const receipt = await publicClient.getTransactionReceipt({ hash: txHash as `0x${string}` }); + return receipt?.status === "success"; + } catch (error) { + logger.debug(`Error checking EVM transaction receipt: ${error}`); + return false; + } + } + + private async waitForEvmTransactionSuccess(txHash: string, network: EvmNetworks): Promise { + await waitUntilTrueWithTimeout( + () => this.isEvmTransactionSuccessful(txHash, network), + 2000, // check every 2 seconds + 180000 // timeout after 3 minutes + ); + } +} + +export function DistributeFees(): Phase< + PhaseIO, + PhaseIO +> { + return { + executors: [new DistributeFeesExecutor()], + name: "DistributeFees", + phases: ["distributeFees"], + async simulate(input: PhaseIO, ctx: PhaseCtx): Promise> { + if (!ctx.fees?.usd) { + ctx.addNote("DistributeFees: no fees in ctx, skipping"); + return input; + } + const usdFees = ctx.fees.usd; + const totalFeesUsd = new Big(usdFees.network).plus(usdFees.vortex).plus(usdFees.partnerMarkup); + const newAmount = new Big(input.amount).minus(totalFeesUsd); + if (newAmount.lt(0)) { + ctx.addNote(`DistributeFees: fees ${totalFeesUsd.toFixed()} USD exceed amount ${input.amount.toFixed()}, setting to 0`); + return evmIO(input.token, input.chain, new Big(0), "0", input.meta) as PhaseIO; + } + const newAmountRaw = newAmount.times(new Big(10).pow(SIMPLIFIED_TOKEN_DECIMALS)).toFixed(0, 0); + ctx.addNote( + `DistributeFees: ${input.amount.toFixed()} ${input.token} -> ${newAmount.toFixed()} ${input.token} after ${totalFeesUsd.toFixed()} USD fees` + ); + return evmIO(input.token, input.chain, newAmount, newAmountRaw, input.meta) as PhaseIO; + } + }; +} diff --git a/apps/api/src/api/services/quote/blocks/phases/final-settlement-subsidy.ts b/apps/api/src/api/services/quote/blocks/phases/final-settlement-subsidy.ts new file mode 100644 index 000000000..ccccd2c77 --- /dev/null +++ b/apps/api/src/api/services/quote/blocks/phases/final-settlement-subsidy.ts @@ -0,0 +1,329 @@ +import { + checkEvmBalanceForToken, + EvmClientManager, + EvmNetworks, + EvmTokenDetails, + getEvmBalance, + getNetworkId, + getOnChainTokenDetails, + getRoute, + isNativeEvmToken, + multiplyByPowerOfTen, + NATIVE_TOKEN_ADDRESS, + Networks, + RampCurrency, + RampPhase, + TokenType +} from "@vortexfi/shared"; +import Big from "big.js"; +import { encodeFunctionData, erc20Abi, TransactionReceipt } from "viem"; +import { generatePrivateKey, privateKeyToAddress } from "viem/accounts"; +import logger from "../../../../../config/logger"; +import { MAX_FINAL_SETTLEMENT_SUBSIDY_USD } from "../../../../../constants/constants"; +import QuoteTicket from "../../../../../models/quoteTicket.model"; +import RampState from "../../../../../models/rampState.model"; +import { BasePhaseHandler } from "../../../phases/base-phase-handler"; +import { getEvmFundingAccount } from "../../../phases/evm-funding"; +import { priceFeedService } from "../../../priceFeed.service"; +import type { ChainBrand, Phase, PhaseCtx, PhaseIO, TokenBrand } from "../core/types"; +import { buildFullSubsidy, computeExpectedOutput } from "./subsidize-pre"; + +const BALANCE_POLLING_TIME_MS = 5000; +const EVM_BALANCE_CHECK_TIMEOUT_MS = 3 * 60 * 1000; // 3 minutes +// Wait for >=90% of expected bridge delivery to absorb slippage while still waiting for actual bridge arrival. +const MIN_BRIDGE_DELIVERY_RATIO = 0.9; + +const NATIVE_TOKENS: Record = { + [Networks.Ethereum]: { decimals: 18, symbol: "ETH" }, + [Networks.Polygon]: { decimals: 18, symbol: "MATIC" }, + [Networks.PolygonAmoy]: { decimals: 18, symbol: "MATIC" }, + [Networks.BSC]: { decimals: 18, symbol: "BNB" }, + [Networks.Arbitrum]: { decimals: 18, symbol: "ETH" }, + [Networks.Base]: { decimals: 18, symbol: "ETH" }, + [Networks.Avalanche]: { decimals: 18, symbol: "AVAX" }, + [Networks.Moonbeam]: { decimals: 18, symbol: "GLMR" }, + [Networks.BaseSepolia]: { decimals: 18, symbol: "ETH" } +}; + +// BUY slice of the production FinalSettlementSubsidyHandler: waits for the bridge to deliver on +// the destination chain, then tops the ephemeral up to exactly quote.outputAmount (swapping the +// funding account's native token to the output token via SquidRouter when needed). SELL/Alfredpay +// and direct-transfer branches are not ported. +class FinalSettlementSubsidyExecutor extends BasePhaseHandler { + public getPhaseName(): RampPhase { + return "finalSettlementSubsidy"; + } + + protected async executePhase(state: RampState): Promise { + logger.debug(`FinalSettlementSubsidyExecutor: Starting phase execution for ramp ${state.id}, type=${state.type}`); + + const quote = await QuoteTicket.findByPk(state.quoteId); + if (!quote) { + throw new Error("FinalSettlementSubsidyExecutor: Quote not found for the given state"); + } + + const evmClientManager = EvmClientManager.getInstance(); + const fundingAccount = getEvmFundingAccount(Networks.Moonbeam); + + const outTokenDetailsRaw = getOnChainTokenDetails(quote.network, quote.outputCurrency); + if (!outTokenDetailsRaw || outTokenDetailsRaw.type === TokenType.AssetHub) { + throw new Error("FinalSettlementSubsidyExecutor: Output currency is not an EVM token"); + } + const outTokenDetails = outTokenDetailsRaw as EvmTokenDetails; + + const isNative = isNativeEvmToken(outTokenDetails); + const expectedAmountRaw = multiplyByPowerOfTen(quote.outputAmount, outTokenDetails.decimals); + const destinationNetwork = quote.network as EvmNetworks; + const publicClient = evmClientManager.getClient(destinationNetwork); + const ephemeralAddress = state.state.evmEphemeralAddress as `0x${string}`; + + logger.debug( + `FinalSettlementSubsidyExecutor: expectedAmountRaw=${expectedAmountRaw.toString()}, destinationNetwork=${destinationNetwork}, ephemeralAddress=${ephemeralAddress}, isNative=${isNative}` + ); + + // 1. Idempotency check + if (state.state.finalSettlementSubsidyTxHash) { + const receipt = await publicClient + .getTransactionReceipt({ + hash: state.state.finalSettlementSubsidyTxHash as `0x${string}` + }) + .catch(() => null); + + if (receipt && receipt.status === "success") { + logger.info( + `FinalSettlementSubsidyExecutor: Transaction ${state.state.finalSettlementSubsidyTxHash} already successful. Skipping.` + ); + return state; + } + } + + // 2. Wait for the bridge to deliver on the destination chain + const actualBalance = await checkEvmBalanceForToken({ + amountDesiredRaw: expectedAmountRaw.mul(MIN_BRIDGE_DELIVERY_RATIO).toFixed(0, 0), + chain: destinationNetwork, + intervalMs: BALANCE_POLLING_TIME_MS, + ownerAddress: ephemeralAddress, + timeoutMs: EVM_BALANCE_CHECK_TIMEOUT_MS, + tokenDetails: outTokenDetails + }); + logger.debug(`FinalSettlementSubsidyExecutor: Ephemeral balance=${actualBalance.toString()}`); + + const preBalance = new Big(state.state.preSettlementBalance ?? "0"); + const deliveredRaw = actualBalance.minus(preBalance); + const delivered = deliveredRaw.gte(0) ? deliveredRaw : new Big(0); + + // 3. Check funding account balance + const actualBalanceFundingAccount = await getEvmBalance({ + chain: destinationNetwork, + ownerAddress: fundingAccount.address as `0x${string}`, + tokenDetails: outTokenDetails + }); + + const subsidyAmountRaw = expectedAmountRaw.minus(delivered); + logger.debug( + `FinalSettlementSubsidyExecutor: subsidyAmountRaw=${subsidyAmountRaw.toString()} (expected=${expectedAmountRaw.toString()} - delivered=${delivered.toString()}, actualBalance=${actualBalance.toString()}, preSettlementBalance=${preBalance.toString()})` + ); + + if (subsidyAmountRaw.lte(0)) { + logger.info( + `FinalSettlementSubsidyExecutor: Delivered amount (${delivered.toString()}) meets expected amount with actualBalance=${actualBalance.toString()} and preSettlementBalance=${preBalance.toString()}. No subsidy needed.` + ); + return state; + } + + logger.info( + `FinalSettlementSubsidyExecutor: Subsidizing ${subsidyAmountRaw.toString()} raw units of ${isNative ? "native token" : outTokenDetails.assetSymbol} to ${ephemeralAddress}` + ); + + // 4. Top up funding account if insufficient balance (ERC-20 only; native tokens transfer directly) + if (!isNative && actualBalanceFundingAccount.lt(subsidyAmountRaw)) { + logger.info( + `FinalSettlementSubsidyExecutor: Funding account has insufficient balance. Swapping native token to ${outTokenDetails.assetSymbol}` + ); + + const nativeToken = NATIVE_TOKENS[destinationNetwork]; + const oneUsdInNative = await priceFeedService.convertCurrency( + "1", + "USD" as RampCurrency, + nativeToken.symbol as RampCurrency + ); + const oneUsdInNativeRaw = multiplyByPowerOfTen(oneUsdInNative, nativeToken.decimals).toFixed(0); + + const chainId = getNetworkId(destinationNetwork).toString(); + + // Use a placeholder address for this query to prevent rate limiting issues + const placeholderAddress = privateKeyToAddress(generatePrivateKey()); + const testRouteResult = await getRoute( + { + bypassGuardrails: true, + enableExpress: true, + fromAddress: placeholderAddress, + fromAmount: oneUsdInNativeRaw, + fromChain: chainId, + fromToken: NATIVE_TOKEN_ADDRESS, + slippageConfig: { + autoMode: 1 + }, + toAddress: placeholderAddress, + toChain: chainId, + toToken: outTokenDetails.erc20AddressSourceChain + }, + { useCache: true } + ); + + const { route: testRoute } = testRouteResult.data; + const rate = new Big(testRoute.estimate.toAmount).div(new Big(oneUsdInNativeRaw)); + const requiredNativeRaw = subsidyAmountRaw.div(rate).mul(1.1).toFixed(0); + + logger.info( + `FinalSettlementSubsidyExecutor: Swapping ${requiredNativeRaw} native units (approx. rate ${rate}) to get required subsidy.` + ); + + // Check the amount of native is not higher than cap, cap specified in units of usd. + const requiredNative = new Big(requiredNativeRaw).div(new Big(10).pow(nativeToken.decimals)); + const requiredNativeInUsd = await priceFeedService.convertCurrency( + requiredNative.toString(), + nativeToken.symbol as RampCurrency, + "USD" as RampCurrency + ); + + if (new Big(requiredNativeInUsd).gt(MAX_FINAL_SETTLEMENT_SUBSIDY_USD)) { + throw this.createUnrecoverableError( + `FinalSettlementSubsidyExecutor: Required subsidy swap amount $${requiredNativeInUsd} exceeds maximum allowed $${MAX_FINAL_SETTLEMENT_SUBSIDY_USD}` + ); + } + + const swapRouteResult = await getRoute({ + bypassGuardrails: true, + enableExpress: true, + fromAddress: fundingAccount.address, + fromAmount: requiredNativeRaw, + fromChain: chainId, + fromToken: NATIVE_TOKEN_ADDRESS, + slippageConfig: { + autoMode: 1 + }, + toAddress: fundingAccount.address, + toChain: chainId, + toToken: outTokenDetails.erc20AddressSourceChain + }); + + const { route: swapRoute } = swapRouteResult.data; + + // Validate swap route output is within acceptable range (>=80% of required subsidy) + const estimatedOutput = new Big(swapRoute.estimate.toAmount); + const minimumAcceptableOutput = subsidyAmountRaw.mul(0.8); + if (estimatedOutput.lt(minimumAcceptableOutput)) { + throw this.createUnrecoverableError( + `FinalSettlementSubsidyExecutor: SquidRouter swap output ${estimatedOutput.toString()} is below 80% of required subsidy ${subsidyAmountRaw.toString()}` + ); + } + + const { maxFeePerGas, maxPriorityFeePerGas } = await publicClient.estimateFeesPerGas(); + const txHashIdx = await evmClientManager.sendTransactionWithBlindRetry(destinationNetwork, fundingAccount, { + data: swapRoute.transactionRequest.data as `0x${string}`, + gas: BigInt(swapRoute.transactionRequest.gasLimit), + maxFeePerGas, + maxPriorityFeePerGas, + to: swapRoute.transactionRequest.target as `0x${string}`, + value: BigInt(swapRoute.transactionRequest.value) + }); + + logger.info(`FinalSettlementSubsidyExecutor: Swap transaction sent: ${txHashIdx}. Waiting for receipt...`); + const receipt = await publicClient.waitForTransactionReceipt({ hash: txHashIdx }); + + if (receipt.status !== "success") { + throw new Error(`Swap transaction ${txHashIdx} failed`); + } + + logger.info("FinalSettlementSubsidyExecutor: Swap successful. Waiting for balance update..."); + + 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) + let txHash: `0x${string}` | undefined = state.state.finalSettlementSubsidyTxHash as `0x${string}` | undefined; + + try { + const { maxFeePerGas, maxPriorityFeePerGas } = await publicClient.estimateFeesPerGas(); + + let receipt: TransactionReceipt | undefined = undefined; + let attempt = 0; + + while (attempt < 5 && (!receipt || receipt.status !== "success")) { + logger.debug(`FinalSettlementSubsidyExecutor: Subsidy transfer attempt ${attempt + 1}/5, isNative=${isNative}`); + if (isNative) { + txHash = await evmClientManager.sendTransactionWithBlindRetry(destinationNetwork, fundingAccount, { + maxFeePerGas, + maxPriorityFeePerGas, + to: ephemeralAddress, + value: BigInt(subsidyAmountRaw.toFixed(0)) + }); + } else { + 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 }); + + if (!receipt || receipt.status !== "success") { + logger.error(`FinalSettlementSubsidyExecutor: Transaction ${txHash} failed or was not found. Retrying...`); + attempt++; + await new Promise(resolve => setTimeout(resolve, 20000)); + } + } + + if (!receipt || receipt.status !== "success") { + throw new Error(`Failed to confirm subsidy transaction after ${attempt} attempts`); + } + + await state.update({ + state: { + ...state.state, + finalSettlementSubsidyTxHash: txHash + } + }); + + return state; + } catch (error) { + throw this.createRecoverableError( + `FinalSettlementSubsidyExecutor: Error during phase execution - ${(error as Error).message}` + ); + } + } +} + +export function FinalSettlementSubsidy(): Phase< + PhaseIO, + PhaseIO +> { + return { + executors: [new FinalSettlementSubsidyExecutor()], + name: "FinalSettlementSubsidy", + phases: ["finalSettlementSubsidy"], + async simulate(input: PhaseIO, ctx: PhaseCtx): Promise> { + const expected = await computeExpectedOutput(ctx); + const subsidy = buildFullSubsidy(input.amount, input.amountRaw, expected.decimal, expected.raw, ctx); + ctx.addNote(`FinalSettlementSubsidy: finalized, amount=${subsidy.subsidyAmountInOutputTokenDecimal.toFixed()}`); + return { ...input, meta: { ...input.meta, subsidy } }; + } + }; +} diff --git a/apps/api/src/api/services/quote/blocks/phases/fund-ephemeral.ts b/apps/api/src/api/services/quote/blocks/phases/fund-ephemeral.ts new file mode 100644 index 000000000..e90b1a522 --- /dev/null +++ b/apps/api/src/api/services/quote/blocks/phases/fund-ephemeral.ts @@ -0,0 +1,221 @@ +import { + EvmClientManager, + EvmNetworks, + getNetworkFromDestination, + isNetworkEVM, + multiplyByPowerOfTen, + Networks, + RampDirection, + RampPhase, + waitUntilTrueWithTimeout +} from "@vortexfi/shared"; +import { type Hex, parseTransaction } from "viem"; +import logger from "../../../../../config/logger"; +import { + BASE_EPHEMERAL_STARTING_BALANCE_UNITS, + POLYGON_EPHEMERAL_STARTING_BALANCE_UNITS +} from "../../../../../constants/constants"; +import RampState from "../../../../../models/rampState.model"; +import { UnrecoverablePhaseError } from "../../../../errors/phase-error"; +import { BasePhaseHandler } from "../../../phases/base-phase-handler"; +import { getEvmFundingAccount } from "../../../phases/evm-funding"; +import { + DESTINATION_EVM_FUNDING_AMOUNTS, + isBaseEphemeralFunded, + isDestinationEvmEphemeralFunded, + isPolygonEphemeralFunded +} from "../../../phases/handlers/helpers"; +import { StateMetadata } from "../../../phases/meta-state-types"; +import type { ChainBrand, Phase, PhaseCtx, PhaseIO, TokenBrand } from "../core/types"; + +// EVM-ephemeral onramp slice of the production FundEphemeralPhaseHandler: funds the source-chain +// ephemeral (native gas + the presigned squidRouter swap value) and, for BUY ramps to an EVM +// destination, the destination-chain ephemeral. Substrate/Polygon-Alfredpay branches are not ported. +class FundEphemeralExecutor extends BasePhaseHandler { + constructor(private readonly chain: EvmNetworks) { + super(); + } + + public getPhaseName(): RampPhase { + return "fundEphemeral"; + } + + protected async executePhase(state: RampState): Promise { + const { evmEphemeralAddress } = state.state as StateMetadata; + if (!evmEphemeralAddress) { + throw new Error("FundEphemeralExecutor: State metadata corrupted, missing evmEphemeralAddress. This is a bug."); + } + + try { + const isSourceFunded = + this.chain === Networks.Polygon + ? await isPolygonEphemeralFunded(evmEphemeralAddress) + : await isBaseEphemeralFunded(evmEphemeralAddress); + + if (!isSourceFunded) { + logger.info(`Funding ${this.chain} ephemeral account ${evmEphemeralAddress}`); + await this.fundEvmEphemeralAccount(state, this.chain); + } else { + logger.info(`${this.chain} ephemeral address already funded.`); + } + + const destinationNetwork = getNetworkFromDestination(state.to); + if ( + state.type === RampDirection.BUY && + state.to !== Networks.AssetHub && + destinationNetwork && + isNetworkEVM(destinationNetwork) + ) { + const isFunded = await isDestinationEvmEphemeralFunded(evmEphemeralAddress, destinationNetwork); + if (!isFunded) { + logger.info(`Funding EVM ephemeral account ${evmEphemeralAddress} on ${destinationNetwork}`); + await this.fundDestinationEvmEphemeralAccount(state, destinationNetwork); + } else { + logger.info(`EVM ephemeral account already funded on ${destinationNetwork}.`); + } + } + } catch (e) { + logger.error("Error in FundEphemeralExecutor:", e); + + if (e instanceof UnrecoverablePhaseError) { + throw e; + } + + throw this.createRecoverableError("Error funding ephemeral account"); + } + + return state; + } + + protected async fundEvmEphemeralAccount(state: RampState, network: EvmNetworks): Promise { + try { + const evmClientManager = EvmClientManager.getInstance(); + const networkClient = evmClientManager.getClient(network); + const chain = networkClient.chain; + + if (!chain) { + throw new Error(`FundEphemeralExecutor: Could not get chain info for ${network}`); + } + + const amountToFundUnits = + network === Networks.Polygon ? POLYGON_EPHEMERAL_STARTING_BALANCE_UNITS : BASE_EPHEMERAL_STARTING_BALANCE_UNITS; + + const ephemeralAddress = state.state.evmEphemeralAddress; + const baseFundingRaw = BigInt(multiplyByPowerOfTen(amountToFundUnits, chain.nativeCurrency.decimals).toFixed()); + + // Cover the exact native value the presigned squidRouter swap will send (bridge gas etc.). + // The value already includes a safety margin from computeSwapValueWithSafetyMargin. + // squidRouterPay remains as a top-up safety net if the route value still falls short. + const swapTx = this.getPresignedTransaction(state, "squidRouterSwap"); + let swapValueRaw = 0n; + if (swapTx?.txData && typeof swapTx.txData === "string") { + try { + swapValueRaw = parseTransaction(swapTx.txData as Hex).value ?? 0n; + } catch (decodeError) { + logger.warn( + `FundEphemeralExecutor: Could not decode squidRouterSwap presigned tx for value extraction on ${network}: ${decodeError}` + ); + } + } + + const fundingAmountRaw = (baseFundingRaw + swapValueRaw).toString(); + + const fundingAccount = getEvmFundingAccount(network); + const walletClient = evmClientManager.getWalletClient(network, fundingAccount); + + const txHash = await walletClient.sendTransaction({ + to: ephemeralAddress as `0x${string}`, + value: BigInt(fundingAmountRaw) + }); + + const receipt = await networkClient.waitForTransactionReceipt({ + hash: txHash as `0x${string}` + }); + + if (!receipt || receipt.status !== "success") { + throw new Error(`FundEphemeralExecutor: Transaction ${txHash} failed or was not found`); + } + + // The receipt confirms inclusion, but downstream phases use a different RPC client which + // may briefly lag behind. Poll the balance until it reflects the funded amount so that + // subsequent phases (nablaApprove etc.) don't read a stale balance. + const isFundedCheck = + network === Networks.Polygon + ? () => isPolygonEphemeralFunded(ephemeralAddress) + : () => isBaseEphemeralFunded(ephemeralAddress); + + try { + await waitUntilTrueWithTimeout(isFundedCheck, 1000, 30000); + } catch (pollError) { + throw new Error( + `FundEphemeralExecutor: Funded ${ephemeralAddress} on ${network} but balance not reflected on RPC within timeout: ${pollError}` + ); + } + } catch (error) { + logger.error(`FundEphemeralExecutor: Error during funding ${network} ephemeral:`, error); + throw new Error(`FundEphemeralExecutor: Error during funding ${network} ephemeral: ` + error); + } + } + + protected async fundDestinationEvmEphemeralAccount(state: RampState, destinationNetwork: EvmNetworks): Promise { + try { + const evmClientManager = EvmClientManager.getInstance(); + const destinationClient = evmClientManager.getClient(destinationNetwork); + const chain = destinationClient.chain; + + if (!chain) { + throw new Error(`FundEphemeralExecutor: Could not get chain info for ${destinationNetwork}`); + } + + const ephemeralAddress = state.state.evmEphemeralAddress; + const fundingAmountUnits = DESTINATION_EVM_FUNDING_AMOUNTS[destinationNetwork]; + const fundingAmountRaw = multiplyByPowerOfTen(fundingAmountUnits, chain.nativeCurrency.decimals).toFixed(); + + const fundingAccount = getEvmFundingAccount(destinationNetwork); + const walletClient = evmClientManager.getWalletClient(destinationNetwork, fundingAccount); + + const txHash = await walletClient.sendTransaction({ + to: ephemeralAddress as `0x${string}`, + value: BigInt(fundingAmountRaw) + }); + + const receipt = await destinationClient.waitForTransactionReceipt({ + hash: txHash as `0x${string}` + }); + + if (!receipt || receipt.status !== "success") { + throw new Error(`FundEphemeralExecutor: Transaction ${txHash} failed or was not found on ${destinationNetwork}`); + } + + try { + await waitUntilTrueWithTimeout( + () => isDestinationEvmEphemeralFunded(ephemeralAddress, destinationNetwork), + 1000, + 30000 + ); + } catch (pollError) { + throw new Error( + `FundEphemeralExecutor: Funded ${ephemeralAddress} on ${destinationNetwork} but balance not reflected on RPC within timeout: ${pollError}` + ); + } + } catch (error) { + logger.error(`FundEphemeralExecutor: Error during funding ${destinationNetwork} ephemeral:`, error); + throw new Error(`FundEphemeralExecutor: Error during funding ${destinationNetwork} ephemeral: ` + error); + } + } +} + +export function FundEphemeral( + _token: Token, + chain: Chain +): Phase, PhaseIO> { + return { + executors: [new FundEphemeralExecutor(chain as EvmNetworks)], + name: "FundEphemeral", + phases: ["fundEphemeral"], + async simulate(input: PhaseIO, ctx: PhaseCtx): Promise> { + ctx.addNote(`FundEphemeral: funding ephemeral on ${input.chain} for ${input.amount.toFixed()} ${input.token}`); + return input; + } + }; +} diff --git a/apps/api/src/api/services/quote/blocks/phases/mykobo-mint.ts b/apps/api/src/api/services/quote/blocks/phases/mykobo-mint.ts new file mode 100644 index 000000000..b55f64bb1 --- /dev/null +++ b/apps/api/src/api/services/quote/blocks/phases/mykobo-mint.ts @@ -0,0 +1,51 @@ +import { + EvmToken, + FiatToken, + getOnChainTokenDetails, + MykoboApiService, + multiplyByPowerOfTen, + Networks +} from "@vortexfi/shared"; +import Big from "big.js"; +import { evmIO } from "../core/io"; +import type { Phase, PhaseCtx, PhaseIO } from "../core/types"; + +export const MykoboMint: Phase, PhaseIO> = { + name: "MykoboMint", + phases: ["mykoboOnrampDeposit"], + async simulate(input, ctx) { + const eurcBaseDetails = getOnChainTokenDetails(Networks.Base, EvmToken.EURC); + if (!eurcBaseDetails) { + throw new Error("MykoboMint: EURC token details not found for Base"); + } + + const inputAmountDecimal = new Big(input.amount); + const mykoboFeeResponse = await MykoboApiService.getInstance().defaultDepositFee(inputAmountDecimal.toFixed(2, 0)); + const mykoboFeeDecimal = new Big(mykoboFeeResponse.total); + + const deliveredEurcDecimal = inputAmountDecimal.minus(mykoboFeeDecimal); + if (deliveredEurcDecimal.lte(0)) { + throw new Error( + `MykoboMint: Mykobo deposit fee ${mykoboFeeDecimal.toFixed()} EUR is greater than or equal to input amount ${inputAmountDecimal.toFixed()} EUR` + ); + } + const deliveredEurcRaw = multiplyByPowerOfTen(deliveredEurcDecimal, eurcBaseDetails.decimals).toFixed(0, 0); + + ctx.addNote( + `MykoboMint: ${deliveredEurcDecimal.toFixed()} EURC delivered on Base after ${mykoboFeeDecimal.toFixed()} EUR fee` + ); + + return evmIO(EvmToken.EURC, Networks.Base, deliveredEurcDecimal, deliveredEurcRaw, { + ...input.meta, + fees: ctx.fees, + mykoboMint: { + currency: FiatToken.EURC, + fee: mykoboFeeDecimal, + inputAmountDecimal, + inputAmountRaw: input.amountRaw, + outputAmountDecimal: deliveredEurcDecimal, + outputAmountRaw: deliveredEurcRaw + } + }); + } +}; diff --git a/apps/api/src/api/services/quote/blocks/phases/nabla-swap.ts b/apps/api/src/api/services/quote/blocks/phases/nabla-swap.ts new file mode 100644 index 000000000..acebf65e2 --- /dev/null +++ b/apps/api/src/api/services/quote/blocks/phases/nabla-swap.ts @@ -0,0 +1,199 @@ +import { + checkEvmBalanceForToken, + EvmClientManager, + EvmToken, + EvmTokenDetails, + evmTokenConfig, + getOnChainTokenDetails, + Networks, + RampPhase +} from "@vortexfi/shared"; +import { Big } from "big.js"; +import logger from "../../../../../config/logger"; +import QuoteTicket from "../../../../../models/quoteTicket.model"; +import RampState from "../../../../../models/rampState.model"; +import { BasePhaseHandler } from "../../../phases/base-phase-handler"; +import { priceFeedService } from "../../../priceFeed.service"; +import { calculateNablaSwapOutputEvm } from "../../core/nabla"; +import { evmIO } from "../core/io"; +import type { ChainBrand, Phase, PhaseCtx, PhaseIO, TokenBrand } from "../core/types"; + +// EVM slice of the production NablaApprovePhaseHandler: broadcasts the presigned ERC-20 approve +// for the Nabla router on Base. The substrate (Pendulum) branch is not ported. +class NablaApproveExecutor extends BasePhaseHandler { + public getPhaseName(): RampPhase { + return "nablaApprove"; + } + + protected async executePhase(state: RampState): Promise { + const evmClientManager = EvmClientManager.getInstance(); + const baseClient = evmClientManager.getClient(Networks.Base); + + try { + const { txData: nablaApproveTransaction } = this.getPresignedTransaction(state, "nablaApprove"); + + if (typeof nablaApproveTransaction !== "string") { + throw new Error("NablaApproveExecutor: Invalid EVM transaction data. This is a bug."); + } + + const txHash = await baseClient.sendRawTransaction({ + serializedTransaction: nablaApproveTransaction as `0x${string}` + }); + + const receipt = await baseClient.waitForTransactionReceipt({ + hash: txHash + }); + + if (!receipt || receipt.status !== "success") { + throw new Error(`NablaApproveExecutor: EVM approve transaction ${txHash} failed`); + } + + logger.info(`NablaApproveExecutor: EVM approve transaction successful: ${txHash}`); + + return state; + } catch (e) { + logger.error(`Could not approve token on EVM: ${(e as Error).message}`); + throw e; + } + } +} + +// EVM slice of the production NablaSwapPhaseHandler: validates the ephemeral holds the simulated +// swap input on Base, then broadcasts the presigned swap. The substrate branch (soft-minimum +// dry-run via getAmountOut) is not ported. +class NablaSwapExecutor extends BasePhaseHandler { + public getPhaseName(): RampPhase { + return "nablaSwap"; + } + + protected async executePhase(state: RampState): Promise { + const quote = await QuoteTicket.findByPk(state.quoteId); + if (!quote) { + throw new Error("Quote not found for the given state"); + } + + if (!quote.metadata.nablaSwapEvm?.inputAmountForSwapRaw || !quote.metadata.nablaSwapEvm.inputCurrency) { + throw new Error("Missing nablaSwapEvm input metadata required to validate pre-swap balance"); + } + + const evmEphemeralAddress = state.state.evmEphemeralAddress; + if (!evmEphemeralAddress) { + throw new Error("Missing EVM ephemeral address to validate nabla swap input balance"); + } + + const inputTokenDetails = evmTokenConfig[Networks.Base]?.[quote.metadata.nablaSwapEvm.inputCurrency as EvmToken] as + | EvmTokenDetails + | undefined; + if (!inputTokenDetails) { + throw new Error(`Invalid input token ${quote.metadata.nablaSwapEvm.inputCurrency} for Base nabla swap`); + } + + try { + await checkEvmBalanceForToken({ + amountDesiredRaw: quote.metadata.nablaSwapEvm.inputAmountForSwapRaw, + chain: Networks.Base, + intervalMs: 1000, + ownerAddress: evmEphemeralAddress, + timeoutMs: 5000, + tokenDetails: inputTokenDetails + }); + } catch (e) { + const errorMessage = e instanceof Error ? e.message : String(e); + logger.error(`Could not validate EVM input balance before swap: ${errorMessage}`); + + throw this.createUnrecoverableError(`Could not validate EVM input balance before swap: ${errorMessage}`); + } + + const evmClientManager = EvmClientManager.getInstance(); + const baseClient = evmClientManager.getClient(Networks.Base); + + try { + const { txData: nablaSwapTransaction } = this.getPresignedTransaction(state, "nablaSwap"); + + if (typeof nablaSwapTransaction !== "string") { + throw new Error("NablaSwapExecutor: Invalid EVM transaction data. This is a bug."); + } + + const txHash = await baseClient.sendRawTransaction({ + serializedTransaction: nablaSwapTransaction as `0x${string}` + }); + + const receipt = await baseClient.waitForTransactionReceipt({ + hash: txHash + }); + + if (!receipt || receipt.status !== "success") { + throw new Error(`NablaSwapExecutor: EVM swap transaction ${txHash} failed`); + } + + logger.info(`NablaSwapExecutor: EVM swap transaction successful: ${txHash}`); + } catch (e) { + logger.error(`Could not swap token on EVM: ${(e as Error).message}`); + throw this.createUnrecoverableError(`Could not swap token on EVM: ${(e as Error).message}`); + } + + return state; + } +} + +export function NablaSwap( + chain: Chain, + inToken: InToken, + outToken: OutToken +): Phase, PhaseIO> { + return { + executors: [new NablaApproveExecutor(), new NablaSwapExecutor()], + name: `NablaSwap(${chain}/${inToken}->${outToken})`, + phases: ["nablaApprove", "nablaSwap"], + async simulate(input: PhaseIO, ctx: PhaseCtx): Promise> { + const inputTokenDetails = getOnChainTokenDetails(Networks.Base, inToken) as EvmTokenDetails; + const outputTokenDetails = getOnChainTokenDetails(Networks.Base, outToken) as EvmTokenDetails; + + if (!inputTokenDetails || !outputTokenDetails) { + throw new Error("NablaSwap: Could not find EVM token details for the requested tokens"); + } + + const deductibleFee = new Big(0); + const inputAmountForSwap = new Big(input.amount).minus(deductibleFee).toString(); + const inputAmountForSwapRaw = new Big(inputAmountForSwap).times(new Big(10).pow(inputTokenDetails.decimals)).toFixed(0); + + const result = await calculateNablaSwapOutputEvm({ + inputAmountForSwap, + inputTokenDetails, + outputTokenDetails, + rampType: ctx.request.rampType + }); + + let oraclePrice: Awaited> | undefined; + try { + oraclePrice = await priceFeedService.getOnchainOraclePrice(ctx.request.outputCurrency); + } catch (error) { + logger.warn( + `NablaSwap: Unable to fetch on-chain oracle price for ${ctx.request.outputCurrency}, proceeding without it. Error: ${error}` + ); + } + + ctx.addNote( + `NablaSwap: ${inputAmountForSwap} ${inToken} -> ${result.nablaOutputAmountDecimal.toFixed()} ${outToken} on ${chain}` + ); + + return evmIO(outToken, chain, result.nablaOutputAmountDecimal, result.nablaOutputAmountRaw, { + ...input.meta, + nablaSwapEvm: { + effectiveExchangeRate: result.effectiveExchangeRate, + inputAmountForSwapDecimal: inputAmountForSwap, + inputAmountForSwapRaw, + inputCurrency: inToken, + inputDecimals: inputTokenDetails.decimals, + inputToken: inputTokenDetails.erc20AddressSourceChain, + oraclePrice: oraclePrice?.price, + outputAmountDecimal: result.nablaOutputAmountDecimal, + outputAmountRaw: result.nablaOutputAmountRaw, + outputCurrency: outToken, + outputDecimals: outputTokenDetails.decimals, + outputToken: outputTokenDetails.erc20AddressSourceChain + } + }); + } + }; +} diff --git a/apps/api/src/api/services/quote/blocks/phases/squid-router-swap.ts b/apps/api/src/api/services/quote/blocks/phases/squid-router-swap.ts new file mode 100644 index 000000000..f9576c927 --- /dev/null +++ b/apps/api/src/api/services/quote/blocks/phases/squid-router-swap.ts @@ -0,0 +1,612 @@ +import { + AxelarScanStatusFees, + BalanceCheckError, + BalanceCheckErrorType, + checkEvmBalanceForToken, + EvmClientManager, + EvmNetworks, + EvmTokenDetails, + evmTokenConfig, + getEvmBalance, + getNetworkFromDestination, + getNetworkId, + getOnChainTokenDetails, + getStatus, + getStatusAxelarScan, + isEvmTokenDetails, + isNetworkEVM, + Networks, + nativeToDecimal, + OnChainToken, + RampPhase, + SquidRouterPayResponse +} from "@vortexfi/shared"; +import { Big } from "big.js"; +import { encodeFunctionData, Hash } from "viem"; +import logger from "../../../../../config/logger"; +import { axelarGasServiceAbi } from "../../../../../contracts/AxelarGasService"; +import QuoteTicket from "../../../../../models/quoteTicket.model"; +import RampState from "../../../../../models/rampState.model"; +import { SubsidyToken } from "../../../../../models/subsidy.model"; +import { BasePhaseHandler } from "../../../phases/base-phase-handler"; +import { getEvmFundingAccount } from "../../../phases/evm-funding"; +import { calculateEvmBridgeAndNetworkFee, getBridgeTargetTokenDetails } from "../../core/squidrouter"; +import { evmIO } from "../core/io"; +import type { ChainBrand, Phase, PhaseCtx, PhaseIO, TokenBrand } from "../core/types"; + +const AXELAR_POLLING_INTERVAL_MS = 10000; // 10 seconds +const SQUIDROUTER_INITIAL_DELAY_MS = 60000; // 60 seconds +const AXL_GAS_SERVICE_EVM = "0x2d5d7d31F671F86C782533cc367F14109a082712"; +const BALANCE_POLLING_TIME_MS = 10000; +// Intentionally longer (15 minutes) than the balance checks in other executors: cross-chain +// settlement and destination gas payment can legitimately take longer under congestion. +const EVM_BALANCE_CHECK_TIMEOUT_MS = 15 * 60 * 1000; +const DEFAULT_SQUIDROUTER_GAS_ESTIMATE = "1600000"; + +// Port of the production SquidRouterPhaseHandler without the route short-circuits +// (direct-transfer, Alfredpay, same-chain passthrough): a flow that pipes this block always +// bridges, so the skips are dead branches here. +class SquidRouterSwapExecutor extends BasePhaseHandler { + public getPhaseName(): RampPhase { + return "squidRouterSwap"; + } + + protected async executePhase(state: RampState): Promise { + logger.info(`Executing squidRouter phase for ramp ${state.id}`); + + const quote = await QuoteTicket.findByPk(state.quoteId); + if (!quote) { + throw new Error("Quote not found for the given state"); + } + + const bridgeMeta = quote.metadata.evmToEvm; + if ( + !bridgeMeta?.inputAmountRaw || + !bridgeMeta.fromNetwork || + !bridgeMeta.fromToken || + !bridgeMeta.toNetwork || + !bridgeMeta.toToken + ) { + throw new Error("Missing bridge metadata required to validate squidRouter input balance"); + } + + const evmEphemeralAddress = state.state.evmEphemeralAddress; + if (!evmEphemeralAddress) { + throw new Error("Missing EVM ephemeral address to validate squidRouter input balance"); + } + + const sourceNetwork = bridgeMeta.fromNetwork as EvmNetworks; + const sourceTokenDetails = Object.values(evmTokenConfig[sourceNetwork] || {}).find( + token => token.erc20AddressSourceChain.toLowerCase() === bridgeMeta.fromToken.toLowerCase() + ) as EvmTokenDetails | undefined; + + if (!sourceTokenDetails) { + throw new Error( + `Could not resolve source token details on ${bridgeMeta.fromNetwork} for token ${bridgeMeta.fromToken} in squidRouter phase` + ); + } + + try { + try { + await checkEvmBalanceForToken({ + amountDesiredRaw: bridgeMeta.inputAmountRaw, + chain: sourceNetwork, + intervalMs: 1000, + ownerAddress: evmEphemeralAddress, + timeoutMs: 15000, + tokenDetails: sourceTokenDetails + }); + } catch (_error) { + throw this.createRecoverableError( + `Unable to verify squidRouter input balance for ${evmEphemeralAddress} on ${sourceNetwork}; balance may not be settled yet` + ); + } + + const approveTransaction = this.getPresignedTransaction(state, "squidRouterApprove"); + const swapTransaction = this.getPresignedTransaction(state, "squidRouterSwap"); + + if (!approveTransaction || !swapTransaction) { + throw new Error("Missing presigned transactions for squidRouter phase"); + } + + let approveHash = state.state.squidRouterApproveHash; + if (!approveHash) { + const accountNonce = await this.getNonce(sourceNetwork, approveTransaction.signer as `0x${string}`); + if (approveTransaction.nonce && approveTransaction.nonce !== accountNonce) { + logger.warn( + `Nonce mismatch for approve transaction of account ${approveTransaction.signer}: expected ${accountNonce}, got ${approveTransaction.nonce}` + ); + } + + approveHash = await this.executeTransaction(sourceNetwork, approveTransaction.txData as string); + logger.info(`Approve transaction executed with hash: ${approveHash}`); + + await state.update({ + state: { + ...state.state, + squidRouterApproveHash: approveHash + } + }); + } + + await this.waitForTransactionConfirmation(sourceNetwork, approveHash); + logger.info(`Approve transaction confirmed: ${approveHash}`); + + const swapHash = await this.executeTransaction(sourceNetwork, swapTransaction.txData as string); + logger.info(`Swap transaction executed with hash: ${swapHash}`); + + let updatedState = await state.update({ + state: { + ...state.state, + squidRouterSwapHash: swapHash + } + }); + + await this.waitForTransactionConfirmation(sourceNetwork, swapHash); + logger.info(`Swap transaction confirmed: ${swapHash}`); + + // Snapshot the destination-token balance so finalSettlementSubsidy can compute the actual + // bridge delivery rather than the total balance (which may include leftover dust). + let preSettlementBalance = "0"; + try { + const destinationNetwork = quote.network as EvmNetworks; + const outTokenDetails = getOnChainTokenDetails(quote.network, quote.outputCurrency); + + if (!outTokenDetails || !isEvmTokenDetails(outTokenDetails)) { + throw new Error(`Could not resolve destination token details for ${quote.outputCurrency} on ${destinationNetwork}`); + } + + preSettlementBalance = ( + await getEvmBalance({ + chain: destinationNetwork, + ownerAddress: state.state.evmEphemeralAddress as `0x${string}`, + tokenDetails: outTokenDetails + }) + ).toString(); + } catch (error) { + logger.warn( + `SquidRouterSwapExecutor: Failed to snapshot pre-settlement balance for ramp ${state.id}; storing 0. Error: ${error}` + ); + } + + updatedState = await updatedState.update({ + state: { + ...updatedState.state, + preSettlementBalance + } + }); + + return updatedState; + } catch (error) { + logger.error(`Error in squidRouter phase for ramp ${state.id}:`, error); + throw error; + } + } + + private async executeTransaction(network: EvmNetworks, txData: string): Promise { + try { + const publicClient = EvmClientManager.getInstance().getClient(network); + const txHash = await publicClient.sendRawTransaction({ + serializedTransaction: txData as `0x${string}` + }); + return txHash; + } catch (error) { + logger.error("Error sending raw transaction", error); + throw new Error("Failed to send transaction"); + } + } + + private async waitForTransactionConfirmation(network: EvmNetworks, txHash: string): Promise { + const maxRetries = 3; + const baseDelay = 5000; // 5 seconds + const maxDelay = 30000; // 30 seconds + + for (let attempt = 0; attempt <= maxRetries; attempt++) { + try { + const publicClient = EvmClientManager.getInstance().getClient(network); + const receipt = await publicClient.waitForTransactionReceipt({ + hash: txHash as `0x${string}` + }); + + if (!receipt || receipt.status !== "success") { + throw new Error(`SquidRouterSwapExecutor: Transaction ${txHash} failed or was not found`); + } + + return; + } catch (error) { + const isLastAttempt = attempt === maxRetries; + const isTransactionNotFoundError = + error instanceof Error && + (error.message.includes("TransactionReceiptNotFoundError") || + error.message.includes("could not be found") || + error.message.includes("Transaction may not be processed")); + + if (isLastAttempt) { + throw new Error( + `SquidRouterSwapExecutor: Error waiting for transaction confirmation after ${maxRetries + 1} attempts: ${error}` + ); + } + + if (isTransactionNotFoundError) { + const delay = Math.min(baseDelay * Math.pow(2, attempt), maxDelay); + + logger.info( + `SquidRouterSwapExecutor: Transaction ${txHash} not found on attempt ${attempt + 1}/${maxRetries + 1}. Retrying in ${delay}ms...` + ); + + await new Promise(resolve => setTimeout(resolve, delay)); + } else { + throw this.createRecoverableError(`SquidRouterSwapExecutor: Error waiting for transaction confirmation: ${error}`); + } + } + } + } + + private async getNonce(network: EvmNetworks, address: `0x${string}`): Promise { + try { + const publicClient = EvmClientManager.getInstance().getClient(network); + return await publicClient.getTransactionCount({ address }); + } catch (error) { + logger.error("Error getting nonce", error); + throw this.createRecoverableError("Failed to get transaction nonce"); + } + } +} + +// Port of the production SquidRouterPayPhaseHandler with a single network-generic Axelar gas +// funding method instead of one per chain. Clients are created lazily (no work at import time). +class SquidRouterPayExecutor extends BasePhaseHandler { + public getPhaseName(): RampPhase { + return "squidRouterPay"; + } + + protected async executePhase(state: RampState): Promise { + const quote = await QuoteTicket.findByPk(state.quoteId); + if (!quote) { + throw new Error("Quote not found for the given state"); + } + + logger.info(`Executing squidRouterPay phase for ramp ${state.id}`); + + try { + const bridgeCallHash = state.state.squidRouterSwapHash; + if (!bridgeCallHash) { + throw new Error("SquidRouterPayExecutor: Missing bridge hash in state for squidRouterPay phase. State corrupted."); + } + + await this.checkStatus(state, bridgeCallHash, quote); + + return state; + } catch (error: unknown) { + logger.error(`SquidRouterPayExecutor: Error in squidRouterPay phase for ramp ${state.id}:`, error); + throw error; + } + } + + // Checks the Axelar bridge status and the destination balance in parallel; either one + // succeeding is a success. Only if both fail (timeout) we throw. + private async checkStatus(state: RampState, swapHash: string, quote: QuoteTicket): Promise { + const toChain = this.resolveBridgeToChain(quote); + + if (!toChain || !isNetworkEVM(toChain)) { + logger.info("SquidRouterPayExecutor: Destination network is non-EVM; skipping EVM balance check optimization.", { + toNetwork: quote.to + }); + await this.checkBridgeStatus(state, swapHash, quote); + return; + } + + let balanceCheckPromise: Promise; + + try { + const outTokenDetails = getOnChainTokenDetails(toChain, quote.outputCurrency as OnChainToken) as EvmTokenDetails; + const ephemeralAddress = state.state.evmEphemeralAddress; + + if (outTokenDetails && ephemeralAddress) { + balanceCheckPromise = checkEvmBalanceForToken({ + amountDesiredRaw: "1", // A stricter target might time out 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( + "SquidRouterPayExecutor: Cannot perform balance check optimization (missing expected token details or address)." + ); + balanceCheckPromise = Promise.reject(new Error("Skipped balance check")); + } + } catch (err) { + logger.warn(`SquidRouterPayExecutor: Error preparing balance check: ${err}`); + balanceCheckPromise = Promise.reject(err); + } + + const bridgeCheckPromise = this.checkBridgeStatus(state, swapHash, quote).catch(err => { + throw err; + }); + + const balanceCheckWithErrorHandling = balanceCheckPromise.catch(err => { + throw err; + }); + + try { + await Promise.any([bridgeCheckPromise, balanceCheckWithErrorHandling]); + } catch (error) { + if (error instanceof AggregateError) { + const balanceError = error.errors.find(e => e instanceof BalanceCheckError); + const bridgeError = error.errors.find(e => !(e instanceof BalanceCheckError)); + + let errorMessage = "SquidRouterPayExecutor: Both bridge status check and balance check failed."; + + if (balanceError instanceof BalanceCheckError) { + if (balanceError.type === BalanceCheckErrorType.Timeout) { + errorMessage += ` Balance check timed out after ${EVM_BALANCE_CHECK_TIMEOUT_MS}ms.`; + } else if (balanceError.type === BalanceCheckErrorType.ReadFailure) { + errorMessage += ` Balance check read failure (unexpected infrastructure issue): ${balanceError.message}.`; + } + } + + if (bridgeError) { + errorMessage += ` Bridge check error: ${bridgeError instanceof Error ? bridgeError.message : String(bridgeError)}.`; + } + + throw new Error(errorMessage); + } + throw error; + } + } + + private async checkBridgeStatus(state: RampState, swapHash: string, quote: QuoteTicket): Promise { + let isExecuted = false; + let payTxHash: string | undefined = state.state.squidRouterPayTxHash; + + await new Promise(resolve => setTimeout(resolve, SQUIDROUTER_INITIAL_DELAY_MS)); + + while (!isExecuted) { + try { + const squidRouterStatus = await this.getSquidrouterStatus(swapHash, state, quote); + + if (!squidRouterStatus) { + logger.warn(`SquidRouterPayExecutor: No squidRouter status found for swap hash ${swapHash}.`); + } else if (squidRouterStatus.status === "success") { + logger.info(`SquidRouterPayExecutor: Transaction ${swapHash} successfully executed on Squidrouter.`); + isExecuted = true; + break; + } + + const isGmp = squidRouterStatus ? squidRouterStatus.isGMPTransaction : true; + + if (isGmp) { + const axelarScanStatus = await getStatusAxelarScan(swapHash); + + if (!axelarScanStatus) { + logger.info(`SquidRouterPayExecutor: Axelar status not found yet for hash ${swapHash}.`); + } else if (axelarScanStatus.status === "executed" || axelarScanStatus.status === "express_executed") { + logger.info(`SquidRouterPayExecutor: Transaction ${swapHash} successfully executed on Axelar.`); + isExecuted = true; + break; + } else if (!payTxHash) { + logger.info("SquidRouterPayExecutor: Bridge transaction detected on Axelar. Proceeding to fund gas."); + + const nativeToFundRaw = this.calculateGasFeeInUnits(axelarScanStatus.fees, DEFAULT_SQUIDROUTER_GAS_ESTIMATE); + const logIndex = Number(axelarScanStatus.id.split("_")[2]); + + const fromChain = (quote.metadata.evmToEvm?.fromNetwork as EvmNetworks) ?? Networks.Base; + + payTxHash = await this.executeFundTransaction(fromChain, nativeToFundRaw, swapHash as `0x${string}`, logIndex); + + const subsidyToken = fromChain === Networks.Polygon ? SubsidyToken.MATIC : SubsidyToken.ETH; + const payerAccount = getEvmFundingAccount(fromChain).address; + const subsidyAmount = nativeToDecimal(nativeToFundRaw, 18).toNumber(); + + await this.createSubsidy(state, subsidyAmount, subsidyToken, payerAccount, payTxHash); + + await state.update({ + state: { ...state.state, squidRouterPayTxHash: payTxHash } + }); + } + } else { + logger.info("SquidRouterPayExecutor: Same-chain transaction detected. Skipping Axelar check."); + } + } catch (error) { + throw this.createRecoverableError( + `SquidRouterPayExecutor: 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)); + } + } + + private async executeFundTransaction( + fromChain: EvmNetworks, + tokenValueRaw: string, + swapHash: `0x${string}`, + logIndex: number + ): Promise { + try { + const evmClientManager = EvmClientManager.getInstance(); + const fundingAccount = getEvmFundingAccount(fromChain); + const walletClient = evmClientManager.getWalletClient(fromChain, fundingAccount); + const publicClient = evmClientManager.getClient(fromChain); + + const walletClientAccount = walletClient.account; + if (!walletClientAccount) { + throw new Error(`SquidRouterPayExecutor: ${fromChain} wallet client account not found.`); + } + + const transactionData = encodeFunctionData({ + abi: axelarGasServiceAbi, + args: [swapHash, logIndex, walletClientAccount.address], + functionName: "addNativeGas" + }); + + const { maxFeePerGas, maxPriorityFeePerGas } = await publicClient.estimateFeesPerGas(); + + const gasPaymentHash = await walletClient.sendTransaction({ + account: walletClientAccount, + chain: publicClient.chain, + data: transactionData, + maxFeePerGas: maxFeePerGas * 2n, + maxPriorityFeePerGas: maxPriorityFeePerGas * 2n, + to: AXL_GAS_SERVICE_EVM as `0x${string}`, + value: BigInt(tokenValueRaw) + }); + + logger.info(`SquidRouterPayExecutor: ${fromChain} fund transaction sent with hash: ${gasPaymentHash}`); + return gasPaymentHash; + } catch (error) { + logger.error(`SquidRouterPayExecutor: Error funding gas to Axelar gas service on ${fromChain}: `, error); + throw new Error(`SquidRouterPayExecutor: Failed to send ${fromChain} transaction`); + } + } + + private async getSquidrouterStatus(swapHash: string, state: RampState, quote: QuoteTicket): Promise { + try { + const fromChain = quote.metadata.evmToEvm?.fromNetwork as Networks | undefined; + if (!fromChain) { + throw new Error("SquidRouterPayExecutor: Missing evmToEvm bridge metadata for status check"); + } + const fromChainId = getNetworkId(fromChain)?.toString(); + // Axelar routes through Moonbeam for AssetHub destinations, so the Squid status API + // expects Moonbeam's chain id when the destination is AssetHub. + const resolvedToChain = this.resolveBridgeToChain(quote); + const toChain = resolvedToChain === Networks.AssetHub ? Networks.Moonbeam : resolvedToChain; + const toChainId = toChain ? getNetworkId(toChain)?.toString() : undefined; + + if (!fromChainId || !toChainId) { + throw new Error("SquidRouterPayExecutor: Invalid from or to network for Squidrouter status check"); + } + + const squidRouterStatus = await getStatus(swapHash, fromChainId, toChainId, state.state.squidRouterQuoteId); + return squidRouterStatus; + } catch (squidRouterError) { + logger.warn( + `SquidRouterPayExecutor: 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( + `SquidRouterPayExecutor: Axelar scan status not found for swap hash ${swapHash} during fallback attempt.` + ); + } + + 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( + `SquidRouterPayExecutor: Both SquidRouter and Axelar fallback failed for swap hash ${swapHash}. Axelar fallback error: ${axelarError instanceof Error ? axelarError.message : String(axelarError)}` + ); + throw new Error(`SquidRouterPayExecutor: Failed to fetch Squidrouter status for swap hash ${swapHash}`); + } + } + } + + // For onramps, quote.to is the EVM network the bridge delivers to. For offramps to a payment + // method, fall back to the bridge metadata recorded at quote time. + private resolveBridgeToChain(quote: QuoteTicket): Networks | undefined { + const directNetwork = getNetworkFromDestination(quote.to); + if (directNetwork) { + return directNetwork; + } + return quote.metadata.evmToEvm?.toNetwork as Networks | undefined; + } + + private calculateGasFeeInUnits(feeResponse: AxelarScanStatusFees, estimatedGas: string | number): string { + const baseFeeInUnitsBig = Big(feeResponse.source_base_fee); + + // Execution fee: cost to execute the transaction on the destination chain. + const estimatedGasBig = Big(estimatedGas); + const sourceGasPriceBig = Big(feeResponse.source_token.gas_price); + + const executionFeeUnits = estimatedGasBig.mul(sourceGasPriceBig); + const multiplier = feeResponse.execute_gas_multiplier; + const executionFeeWithMultiplier = executionFeeUnits.mul(multiplier); + + const totalGasFee = baseFeeInUnitsBig.add(executionFeeWithMultiplier); + + const sourceDecimals = feeResponse.source_token.gas_price_in_units.decimals; + const totalGasFeeRaw = totalGasFee.mul(Big(10).pow(sourceDecimals)); + + return totalGasFeeRaw.lt(0) ? "0" : totalGasFeeRaw.toFixed(0, 0); + } +} + +export function SquidRouterSwap< + FromChain extends ChainBrand, + ToChain extends ChainBrand, + FromToken extends TokenBrand, + ToToken extends TokenBrand +>( + fromChain: FromChain, + toChain: ToChain, + fromToken: FromToken, + toToken: ToToken +): Phase, PhaseIO> { + return { + executors: [new SquidRouterSwapExecutor(), new SquidRouterPayExecutor()], + name: `SquidRouterSwap(${fromChain}/${fromToken}->${toChain}/${toToken})`, + phases: ["squidRouterSwap", "squidRouterPay"], + async simulate(input: PhaseIO, ctx: PhaseCtx): Promise> { + if (!ctx.fees?.usd) { + throw new Error("SquidRouterSwap: Missing ctx.fees.usd - ensure computeFees ran successfully"); + } + + const inputTokenDetails = getOnChainTokenDetails(fromChain as Networks, fromToken) as EvmTokenDetails; + + const bridgeSourceToken = inputTokenDetails.erc20AddressSourceChain; + const toTokenDetails = getBridgeTargetTokenDetails(toToken as OnChainToken, toChain as Networks); + const bridgeTargetToken = toTokenDetails.erc20AddressSourceChain; + + const bridgeRequest = { + amountRaw: input.amountRaw, + fromNetwork: fromChain as Networks, + fromToken: bridgeSourceToken, + originalInputAmountForRateCalc: input.amountRaw, + rampType: ctx.request.rampType, + toNetwork: toChain as Networks, + toToken: bridgeTargetToken + }; + + const bridgeResult = await calculateEvmBridgeAndNetworkFee(bridgeRequest); + + const outputAmountRaw = new Big(bridgeResult.finalGrossOutputAmountDecimal) + .times(new Big(10).pow(bridgeResult.outputTokenDecimals)) + .toFixed(0, 0); + + ctx.addNote( + `SquidRouterSwap: ${input.amount} ${fromToken} on ${fromChain} -> ${bridgeResult.finalGrossOutputAmountDecimal.toFixed()} ${toToken} on ${toChain}` + ); + + return evmIO(toToken, toChain, bridgeResult.finalGrossOutputAmountDecimal, outputAmountRaw, { + ...input.meta, + evmToEvm: { + effectiveExchangeRate: bridgeResult.finalEffectiveExchangeRate, + fromNetwork: fromChain as Networks, + fromToken: bridgeSourceToken, + inputAmountDecimal: input.amount, + inputAmountRaw: input.amountRaw, + networkFeeUSD: bridgeResult.networkFeeUSD, + outputAmountDecimal: bridgeResult.finalGrossOutputAmountDecimal, + outputAmountRaw, + toNetwork: toChain as Networks, + toToken: bridgeTargetToken + } + }); + } + }; +} diff --git a/apps/api/src/api/services/quote/blocks/phases/subsidize-post.ts b/apps/api/src/api/services/quote/blocks/phases/subsidize-post.ts new file mode 100644 index 000000000..89c68082b --- /dev/null +++ b/apps/api/src/api/services/quote/blocks/phases/subsidize-post.ts @@ -0,0 +1,195 @@ +import { + checkEvmBalanceForToken, + EvmClientManager, + EvmNetworks, + EvmToken, + EvmTokenDetails, + getOnChainTokenDetails, + Networks, + nativeToDecimal, + RampCurrency, + RampDirection, + RampPhase +} from "@vortexfi/shared"; +import Big from "big.js"; +import { encodeFunctionData, erc20Abi } from "viem"; +import logger from "../../../../../config/logger"; +import { MAX_EVM_SWAP_SUBSIDY_QUOTE_FRACTION } from "../../../../../constants/constants"; +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 "../../../phases/base-phase-handler"; +import { getEvmFundingAccount } from "../../../phases/evm-funding"; +import { StateMetadata } from "../../../phases/meta-state-types"; +import { priceFeedService } from "../../../priceFeed.service"; +import type { ChainBrand, Phase, PhaseCtx, PhaseIO, TokenBrand } from "../core/types"; +import { buildFullSubsidy, computeExpectedOutput } from "./subsidize-pre"; + +// EVM slice of the production SubsidizePostSwapPhaseHandler: tops up the ephemeral's Nabla output +// token on Base until it matches the amount the next phase expects (the simulated Squid bridge +// input for BUY ramps). The substrate branch is not ported. +class SubsidizePostSwapExecutor extends BasePhaseHandler { + public getPhaseName(): RampPhase { + return "subsidizePostSwap"; + } + + public getMaxRetries(): number { + return 200; + } + + protected async executePhase(state: RampState): Promise { + const quote = await QuoteTicket.findByPk(state.quoteId); + if (!quote) { + throw new Error("Quote not found for the given state"); + } + + const { evmEphemeralAddress } = state.state as StateMetadata; + if (!evmEphemeralAddress) { + throw new Error("SubsidizePostSwapExecutor: State metadata corrupted. This is a bug."); + } + + if (!quote.metadata.evmToEvm) { + throw new Error("Missing evmToEvm information in quote metadata"); + } + + if (!quote.metadata.nablaSwapEvm) { + throw new Error("Missing nablaSwapEvm information in quote metadata"); + } + + if (!quote.metadata.subsidy) { + throw new Error("Missing subsidy information in quote metadata"); + } + + try { + const outputToken = quote.metadata.nablaSwapEvm.outputCurrency as EvmToken; + + const outputTokenDetails = getOnChainTokenDetails(Networks.Base, outputToken) as EvmTokenDetails; + if (!outputTokenDetails) { + throw new Error( + `Could not find token details for output token ${outputToken} on network ${Networks.Base}. Invalid quote metadata.` + ); + } + + // Wait for token settlement before checking balance + await new Promise(resolve => setTimeout(resolve, 15000)); + + const currentBalance = await checkEvmBalanceForToken({ + amountDesiredRaw: "1", + chain: outputTokenDetails.network as EvmNetworks, + intervalMs: 1000, + ownerAddress: evmEphemeralAddress, + timeoutMs: 5000, + tokenDetails: outputTokenDetails + }); + + if (currentBalance.eq(Big(0))) { + throw new Error("Invalid phase: input token did not arrive yet on EVM"); + } + + // For BUY operations, top up to the simulated Squid bridge input; for SELL, to the + // simulated Nabla output. + const expectedSwapOutputAmountRaw = + state.type === RampDirection.BUY + ? Big(quote.metadata.evmToEvm.inputAmountRaw) + : Big(quote.metadata.nablaSwapEvm.outputAmountRaw); + + const requiredAmount = Big(expectedSwapOutputAmountRaw).sub(currentBalance); + logger.debug(`SubsidizePostSwapExecutor: requiredAmount ${requiredAmount.toString()}`); + + if (requiredAmount.gt(Big(0))) { + const subsidyDecimal = nativeToDecimal(requiredAmount, quote.metadata.nablaSwapEvm.outputDecimals).toString(); + const subsidyUsd = await priceFeedService.convertCurrency( + subsidyDecimal, + outputToken as RampCurrency, + EvmToken.USDC as RampCurrency + ); + const quoteOutputUsd = await priceFeedService.convertCurrency( + quote.outputAmount, + quote.outputCurrency as RampCurrency, + EvmToken.USDC as RampCurrency + ); + const percentageCap = Big(quoteOutputUsd).mul(MAX_EVM_SWAP_SUBSIDY_QUOTE_FRACTION); + const subsidyCapUsd = percentageCap.gt("1") ? percentageCap : Big("1"); + if (Big(subsidyUsd).gt(subsidyCapUsd)) { + // Pause for operator intervention without moving the ramp to failed. + throw this.createRecoverableError( + `SubsidizePostSwapExecutor: Required subsidy $${subsidyUsd} exceeds cap $${subsidyCapUsd.toFixed(2)} (max of $1.00 and ${MAX_EVM_SWAP_SUBSIDY_QUOTE_FRACTION} of quote output $${quoteOutputUsd}).` + ); + } + + logger.info( + `Subsidizing post-swap EVM with ${requiredAmount.toFixed()} to reach target value of ${expectedSwapOutputAmountRaw}` + ); + + const evmClientManager = EvmClientManager.getInstance(); + const destinationNetwork = outputTokenDetails.network as EvmNetworks; + const fundingAccount = getEvmFundingAccount(destinationNetwork); + + const publicClient = evmClientManager.getClient(destinationNetwork); + const { maxFeePerGas, maxPriorityFeePerGas } = await publicClient.estimateFeesPerGas(); + + const data = encodeFunctionData({ + abi: erc20Abi, + args: [evmEphemeralAddress as `0x${string}`, BigInt(requiredAmount.toFixed(0))], + functionName: "transfer" + }); + + const txHash = await evmClientManager.sendTransactionWithBlindRetry(destinationNetwork, fundingAccount, { + data, + maxFeePerGas, + maxPriorityFeePerGas, + to: outputTokenDetails.erc20AddressSourceChain as `0x${string}`, + value: 0n + }); + + const subsidyAmount = nativeToDecimal(requiredAmount, quote.metadata.nablaSwapEvm.outputDecimals).toNumber(); + const subsidyToken = quote.metadata.nablaSwapEvm.outputCurrency as unknown as SubsidyToken; + + await this.createSubsidy(state, subsidyAmount, subsidyToken, fundingAccount.address, txHash); + + const receipt = await publicClient.waitForTransactionReceipt({ + hash: txHash as `0x${string}` + }); + + if (!receipt || receipt.status !== "success") { + throw new Error(`SubsidizePostSwapExecutor: Subsidy transaction ${txHash} failed or was not found`); + } + } + + return state; + } catch (e) { + logger.error("Error in subsidizePostSwap (EVM):", e); + if (e instanceof PhaseError) { + throw e; + } + throw this.createRecoverableError("SubsidizePostSwapExecutor: Failed to subsidize post swap on EVM."); + } + } +} + +export function SubsidizePost(): Phase< + PhaseIO, + PhaseIO +> { + return { + executors: [new SubsidizePostSwapExecutor()], + name: "SubsidizePost", + phases: ["subsidizePostSwap"], + async simulate(input: PhaseIO, ctx: PhaseCtx): Promise> { + const expected = await computeExpectedOutput(ctx); + const subsidy = buildFullSubsidy(input.amount, input.amountRaw, expected.decimal, expected.raw, ctx); + const newAmount = input.amount.plus(subsidy.subsidyAmountInOutputTokenDecimal); + const newAmountRaw = new Big(input.amountRaw).plus(subsidy.subsidyAmountInOutputTokenRaw).toFixed(0, 0); + ctx.addNote( + `SubsidizePost: applied=${subsidy.applied}, subsidy=${subsidy.subsidyAmountInOutputTokenDecimal.toFixed()}, newAmount=${newAmount.toFixed()}` + ); + return { + ...input, + amount: newAmount, + amountRaw: newAmountRaw, + meta: { ...input.meta, subsidy } + }; + } + }; +} diff --git a/apps/api/src/api/services/quote/blocks/phases/subsidize-pre.ts b/apps/api/src/api/services/quote/blocks/phases/subsidize-pre.ts new file mode 100644 index 000000000..d697fc384 --- /dev/null +++ b/apps/api/src/api/services/quote/blocks/phases/subsidize-pre.ts @@ -0,0 +1,269 @@ +import { + checkEvmBalanceForToken, + EvmClientManager, + EvmNetworks, + EvmToken, + EvmTokenDetails, + getOnChainTokenDetails, + Networks, + nativeToDecimal, + RampCurrency, + RampDirection, + RampPhase +} from "@vortexfi/shared"; +import { Big } from "big.js"; +import { encodeFunctionData, erc20Abi } from "viem"; +import logger from "../../../../../config/logger"; +import { MAX_EVM_SWAP_SUBSIDY_QUOTE_FRACTION } from "../../../../../constants/constants"; +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 "../../../phases/base-phase-handler"; +import { getEvmFundingAccount } from "../../../phases/evm-funding"; +import { StateMetadata } from "../../../phases/meta-state-types"; +import { priceFeedService } from "../../../priceFeed.service"; +import type { ChainBrand, Phase, PhaseCtx, PhaseIO, TokenBrand } from "../core/types"; + +export interface SubsidyMeta { + applied: boolean; + subsidyRate: Big; + partnerId: string | null; + expectedOutputAmountDecimal: Big; + expectedOutputAmountRaw: string; + actualOutputAmountDecimal: Big; + actualOutputAmountRaw: string; + subsidyAmountInOutputTokenDecimal: Big; + subsidyAmountInOutputTokenRaw: string; + idealSubsidyAmountInOutputTokenDecimal: Big; + idealSubsidyAmountInOutputTokenRaw: string; + targetOutputAmountDecimal: Big; + targetOutputAmountRaw: string; + adjustedDifference: Big; + adjustedTargetDiscount: Big; +} + +export function buildFullSubsidy( + actualOutputAmountDecimal: Big, + actualOutputAmountRaw: string, + expectedOutputAmountDecimal: Big, + expectedOutputAmountRaw: string, + ctx: PhaseCtx +): SubsidyMeta { + const partner = ctx.partner; + const targetDiscount = partner?.targetDiscount ?? 0; + const maxSubsidy = partner?.maxSubsidy ?? 0; + + const idealSubsidyAmountInOutputTokenDecimal = actualOutputAmountDecimal.gte(expectedOutputAmountDecimal) + ? new Big(0) + : expectedOutputAmountDecimal.minus(actualOutputAmountDecimal); + + const subsidyAmountInOutputTokenDecimal = + targetDiscount !== 0 + ? capSubsidy(idealSubsidyAmountInOutputTokenDecimal, expectedOutputAmountDecimal, maxSubsidy) + : new Big(0); + + const targetOutputAmountDecimal = actualOutputAmountDecimal.plus(subsidyAmountInOutputTokenDecimal); + const subsidyRate = expectedOutputAmountDecimal.gt(0) + ? subsidyAmountInOutputTokenDecimal.div(expectedOutputAmountDecimal) + : new Big(0); + + const toRaw = (decimal: Big): string => + actualOutputAmountDecimal.gt(0) + ? new Big(actualOutputAmountRaw).times(decimal).div(actualOutputAmountDecimal).toFixed(0, 0) + : "0"; + + return { + actualOutputAmountDecimal, + actualOutputAmountRaw, + adjustedDifference: new Big(0), + adjustedTargetDiscount: new Big(0), + applied: subsidyAmountInOutputTokenDecimal.gt(0), + expectedOutputAmountDecimal, + expectedOutputAmountRaw, + idealSubsidyAmountInOutputTokenDecimal, + idealSubsidyAmountInOutputTokenRaw: toRaw(idealSubsidyAmountInOutputTokenDecimal), + partnerId: partner?.id ?? null, + subsidyAmountInOutputTokenDecimal, + subsidyAmountInOutputTokenRaw: toRaw(subsidyAmountInOutputTokenDecimal), + subsidyRate, + targetOutputAmountDecimal, + targetOutputAmountRaw: toRaw(targetOutputAmountDecimal) + }; +} + +function capSubsidy(idealSubsidy: Big, expectedOutput: Big, maxSubsidy: number): Big { + if (maxSubsidy > 0) { + const maxAllowed = expectedOutput.mul(maxSubsidy); + return idealSubsidy.gt(maxAllowed) ? maxAllowed : idealSubsidy; + } + return idealSubsidy; +} + +export async function computeExpectedOutput(ctx: PhaseCtx): Promise<{ decimal: Big; raw: string }> { + let expectedOutputAmount = new Big(ctx.request.inputAmount); + try { + const oracle = await priceFeedService.getOnchainOraclePrice(ctx.request.inputCurrency); + const isOfframp = ctx.request.rampType === RampDirection.SELL; + const effectivePrice = isOfframp ? new Big(1).div(oracle.price) : oracle.price; + const targetDiscount = ctx.partner?.targetDiscount ?? 0; + const discountedRate = effectivePrice.mul(new Big(1).plus(targetDiscount)); + expectedOutputAmount = new Big(ctx.request.inputAmount).mul(discountedRate); + } catch (error) { + ctx.addNote(`computeExpectedOutput: oracle price unavailable, using input amount. Error: ${error}`); + } + const expectedOutputAmountRaw = expectedOutputAmount.times(new Big(10).pow(6)).toFixed(0, 0); + return { decimal: expectedOutputAmount, raw: expectedOutputAmountRaw }; +} + +// EVM slice of the production SubsidizePreSwapPhaseHandler: tops up the ephemeral's Nabla input +// token on Base until it matches the simulated swap input. Substrate and Alfredpay branches are +// not ported. +class SubsidizePreSwapExecutor extends BasePhaseHandler { + public getPhaseName(): RampPhase { + return "subsidizePreSwap"; + } + + public getMaxRetries(): number { + return 200; + } + + protected async executePhase(state: RampState): Promise { + const quote = await QuoteTicket.findByPk(state.quoteId); + if (!quote) { + throw new Error("Quote not found for the given state"); + } + + const { evmEphemeralAddress } = state.state as StateMetadata; + if (!evmEphemeralAddress) { + throw new Error("SubsidizePreSwapExecutor: State metadata corrupted. This is a bug."); + } + + if (!quote.metadata.nablaSwapEvm) { + throw new Error("Missing nablaSwapEvm information in quote metadata"); + } + + try { + const inputToken = quote.metadata.nablaSwapEvm.inputCurrency as EvmToken; + const inputTokenDetails = getOnChainTokenDetails(Networks.Base, inputToken) as EvmTokenDetails; + if (!inputTokenDetails) { + throw new Error( + `Could not find token details for input token ${inputToken} on network ${Networks.Base}. Invalid quote metadata.` + ); + } + const expectedInputAmountForSwapRaw = quote.metadata.nablaSwapEvm.inputAmountForSwapRaw; + + // Wait for token settlement before checking balance + await new Promise(resolve => setTimeout(resolve, 15000)); + + const currentBalance = await checkEvmBalanceForToken({ + amountDesiredRaw: "1", + chain: inputTokenDetails.network as EvmNetworks, + intervalMs: 1000, + ownerAddress: evmEphemeralAddress, + timeoutMs: 5000, + tokenDetails: inputTokenDetails + }); + + if (currentBalance.eq(Big(0))) { + throw new Error("Invalid phase: input token did not arrive yet on EVM"); + } + + const requiredAmount = Big(expectedInputAmountForSwapRaw).sub(currentBalance); + logger.debug(`SubsidizePreSwapExecutor: requiredAmount ${requiredAmount.toString()}`); + + if (requiredAmount.gt(Big(0))) { + const subsidyDecimal = nativeToDecimal(requiredAmount, quote.metadata.nablaSwapEvm.inputDecimals).toString(); + const subsidyUsd = await priceFeedService.convertCurrency( + subsidyDecimal, + inputToken as RampCurrency, + EvmToken.USDC as RampCurrency + ); + const quoteOutputUsd = await priceFeedService.convertCurrency( + quote.outputAmount, + quote.outputCurrency as RampCurrency, + EvmToken.USDC as RampCurrency + ); + const percentageCap = Big(quoteOutputUsd).mul(MAX_EVM_SWAP_SUBSIDY_QUOTE_FRACTION); + const subsidyCapUsd = percentageCap.gt("1") ? percentageCap : Big("1"); + if (Big(subsidyUsd).gt(subsidyCapUsd)) { + // Pause for operator intervention without moving the ramp to failed. + throw this.createRecoverableError( + `SubsidizePreSwapExecutor: Required subsidy $${subsidyUsd} exceeds cap $${subsidyCapUsd.toFixed(2)} (max of $1.00 and ${MAX_EVM_SWAP_SUBSIDY_QUOTE_FRACTION} of quote output $${quoteOutputUsd}).` + ); + } + + logger.info( + `Subsidizing pre-swap EVM with ${requiredAmount.toFixed()} to reach target value of ${expectedInputAmountForSwapRaw}` + ); + + const evmClientManager = EvmClientManager.getInstance(); + const destinationNetwork = inputTokenDetails.network as EvmNetworks; + const fundingAccount = getEvmFundingAccount(destinationNetwork); + + const publicClient = evmClientManager.getClient(destinationNetwork); + const { maxFeePerGas, maxPriorityFeePerGas } = await publicClient.estimateFeesPerGas(); + + const data = encodeFunctionData({ + abi: erc20Abi, + args: [evmEphemeralAddress as `0x${string}`, BigInt(requiredAmount.toFixed(0))], + functionName: "transfer" + }); + + const txHash = await evmClientManager.sendTransactionWithBlindRetry(destinationNetwork, fundingAccount, { + data, + maxFeePerGas, + maxPriorityFeePerGas, + to: inputTokenDetails.erc20AddressSourceChain as `0x${string}`, + value: 0n + }); + + const subsidyAmount = nativeToDecimal(requiredAmount, quote.metadata.nablaSwapEvm.inputDecimals).toNumber(); + const subsidyToken = quote.metadata.nablaSwapEvm.inputCurrency as unknown as SubsidyToken; + + await this.createSubsidy(state, subsidyAmount, subsidyToken, fundingAccount.address, txHash); + + const receipt = await publicClient.waitForTransactionReceipt({ + hash: txHash as `0x${string}` + }); + + if (!receipt || receipt.status !== "success") { + throw new Error(`SubsidizePreSwapExecutor: Subsidy transaction ${txHash} failed or was not found`); + } + } + + return state; + } catch (e) { + logger.error("Error in subsidizePreSwap (EVM):", e); + if (e instanceof PhaseError) { + throw e; + } + throw this.createRecoverableError("SubsidizePreSwapExecutor: Failed to subsidize pre swap on EVM."); + } + } +} + +export function SubsidizePre(): Phase< + PhaseIO, + PhaseIO +> { + return { + executors: [new SubsidizePreSwapExecutor()], + name: "SubsidizePre", + phases: ["subsidizePreSwap"], + async simulate(input: PhaseIO, ctx: PhaseCtx): Promise> { + const expected = await computeExpectedOutput(ctx); + ctx.addNote(`SubsidizePre: expected output ${expected.decimal.toFixed()} ${input.token}`); + return { + ...input, + meta: { + ...input.meta, + subsidy: { + expectedOutputAmountDecimal: expected.decimal, + expectedOutputAmountRaw: expected.raw + } + } + }; + } + }; +} diff --git a/apps/api/src/api/services/quote/core/squidrouter.ts b/apps/api/src/api/services/quote/core/squidrouter.ts index b23dfad46..341e06107 100644 --- a/apps/api/src/api/services/quote/core/squidrouter.ts +++ b/apps/api/src/api/services/quote/core/squidrouter.ts @@ -2,6 +2,7 @@ import { createGenericRouteParams, createRouteParamsWithMoonbeamPostHook, DestinationType, + EvmToken, EvmTokenDetails, getNetworkFromDestination, getOnChainTokenDetails, @@ -78,6 +79,14 @@ export function getTokenDetailsForEvmDestination( return tokenDetails; } +/** + * Returns the token details that SquidRouter should deliver on the destination + * chain for a given (outputCurrency, toNetwork). + */ +export function getBridgeTargetTokenDetails(outputCurrency: OnChainToken, toNetwork: Networks): EvmTokenDetails { + return getTokenDetailsForEvmDestination(outputCurrency, toNetwork); +} + /** * Helper to prepare route parameters for Squidrouter */ @@ -182,7 +191,7 @@ function calculateFinalExchangeRate( function buildRouteRequest(request: EvmBridgeQuoteRequest) { const inputTokenDetails = getTokenDetailsForEvmDestination(request.inputCurrency, request.fromNetwork); - const outputTokenDetails = getTokenDetailsForEvmDestination(request.outputCurrency, request.toNetwork); + const outputTokenDetails = getBridgeTargetTokenDetails(request.outputCurrency, request.toNetwork); const amountRaw = multiplyByPowerOfTen(request.amountDecimal, inputTokenDetails.decimals).toFixed(0, 0); return prepareSquidrouterRouteParams({ diff --git a/apps/api/src/api/services/quote/core/types.ts b/apps/api/src/api/services/quote/core/types.ts index 523ed37d4..80ff464fc 100644 --- a/apps/api/src/api/services/quote/core/types.ts +++ b/apps/api/src/api/services/quote/core/types.ts @@ -131,6 +131,8 @@ export interface QuoteContext { inputDecimals: number; outputAmountRaw: string; outputAmountDecimal: Big; + ammOutputAmountRaw?: string; + ammOutputAmountDecimal?: Big; outputCurrency: EvmToken; outputDecimals: number; outputToken: string; // ERC20 address diff --git a/apps/api/src/api/services/quote/engines/discount/onramp.ts b/apps/api/src/api/services/quote/engines/discount/onramp.ts index 8b0d5b8a3..f66c08f91 100644 --- a/apps/api/src/api/services/quote/engines/discount/onramp.ts +++ b/apps/api/src/api/services/quote/engines/discount/onramp.ts @@ -123,10 +123,8 @@ export class OnRampDiscountEngine extends BaseDiscountEngine { if (!toNetwork) { return null; } - // Trivial case: USDC on Base is also the requested output. No bridge runs, so the - // conversion rate is exactly 1:1 - skip the Squid call (which would fail with same-chain - // same-token) and avoid the misleading 1:1 fallback log. + // conversion rate is exactly 1:1 - skip the Squid call. if (toNetwork === Networks.Base && req.outputCurrency === EvmToken.USDC) { return new Big(1); } diff --git a/apps/api/src/api/services/quote/engines/fee/onramp-brl-to-evm.ts b/apps/api/src/api/services/quote/engines/fee/onramp-brl-to-evm.ts index 5f69075c4..20d22568e 100644 --- a/apps/api/src/api/services/quote/engines/fee/onramp-brl-to-evm.ts +++ b/apps/api/src/api/services/quote/engines/fee/onramp-brl-to-evm.ts @@ -10,7 +10,7 @@ import { RampCurrency, RampDirection } from "@vortexfi/shared"; -import { calculateEvmBridgeAndNetworkFee, getTokenDetailsForEvmDestination } from "../../core/squidrouter"; +import { calculateEvmBridgeAndNetworkFee, getBridgeTargetTokenDetails } from "../../core/squidrouter"; import { QuoteContext } from "../../core/types"; import { BaseFeeEngine, FeeComputation, FeeConfig } from "./index"; @@ -52,7 +52,7 @@ export class OnRampAveniaToEvmFeeEngine extends BaseFeeEngine { throw new Error(`OnRampAveniaToEvmFeeEngine: invalid network for destination: ${request.to}`); } - const toToken = getTokenDetailsForEvmDestination(request.outputCurrency as OnChainToken, toNetwork).erc20AddressSourceChain; + const toToken = getBridgeTargetTokenDetails(request.outputCurrency as OnChainToken, toNetwork).erc20AddressSourceChain; const swapNetwork = this.fromNetwork as EvmNetworks; // Get token details from evmTokenConfig diff --git a/apps/api/src/api/services/quote/engines/fee/onramp-mykobo-to-evm.ts b/apps/api/src/api/services/quote/engines/fee/onramp-mykobo-to-evm.ts index f0cc3d81a..e5338dec2 100644 --- a/apps/api/src/api/services/quote/engines/fee/onramp-mykobo-to-evm.ts +++ b/apps/api/src/api/services/quote/engines/fee/onramp-mykobo-to-evm.ts @@ -10,7 +10,7 @@ import { RampCurrency, RampDirection } from "@vortexfi/shared"; -import { calculateEvmBridgeAndNetworkFee, getTokenDetailsForEvmDestination } from "../../core/squidrouter"; +import { calculateEvmBridgeAndNetworkFee, getBridgeTargetTokenDetails } from "../../core/squidrouter"; import { QuoteContext } from "../../core/types"; import { BaseFeeEngine, FeeComputation, FeeConfig } from "./index"; @@ -49,7 +49,7 @@ export class OnRampMykoboToEvmFeeEngine extends BaseFeeEngine { throw new Error(`OnRampMykoboToEvmFeeEngine: invalid network for destination: ${request.to}`); } - const toToken = getTokenDetailsForEvmDestination(request.outputCurrency as OnChainToken, toNetwork).erc20AddressSourceChain; + const toToken = getBridgeTargetTokenDetails(request.outputCurrency as OnChainToken, toNetwork).erc20AddressSourceChain; const swapNetwork = this.fromNetwork as EvmNetworks; const fromTokenDetails = evmTokenConfig[swapNetwork]?.[this.fromToken]; diff --git a/apps/api/src/api/services/quote/engines/merge-subsidy/offramp-evm.ts b/apps/api/src/api/services/quote/engines/merge-subsidy/offramp-evm.ts index 10c0317cd..5509a53ce 100644 --- a/apps/api/src/api/services/quote/engines/merge-subsidy/offramp-evm.ts +++ b/apps/api/src/api/services/quote/engines/merge-subsidy/offramp-evm.ts @@ -32,6 +32,8 @@ export class OffRampMergeSubsidyEvmEngine implements Stage { ctx.nablaSwapEvm = { ...ctx.nablaSwapEvm, + ammOutputAmountDecimal: ctx.nablaSwapEvm.ammOutputAmountDecimal ?? ctx.nablaSwapEvm.outputAmountDecimal, + ammOutputAmountRaw: ctx.nablaSwapEvm.ammOutputAmountRaw ?? ctx.nablaSwapEvm.outputAmountRaw, outputAmountDecimal: ctx.nablaSwapEvm.outputAmountDecimal.plus(ctx.subsidy.subsidyAmountInOutputTokenDecimal), outputAmountRaw: (BigInt(ctx.nablaSwapEvm.outputAmountRaw) + BigInt(ctx.subsidy.subsidyAmountInOutputTokenRaw)).toString() }; diff --git a/apps/api/src/api/services/quote/engines/squidrouter/onramp-base-to-evm.ts b/apps/api/src/api/services/quote/engines/squidrouter/onramp-base-to-evm.ts index cd5e2af8a..4787fe49d 100644 --- a/apps/api/src/api/services/quote/engines/squidrouter/onramp-base-to-evm.ts +++ b/apps/api/src/api/services/quote/engines/squidrouter/onramp-base-to-evm.ts @@ -10,7 +10,7 @@ import { import Big from "big.js"; import httpStatus from "http-status"; import { APIError } from "../../../../errors/api-error"; -import { getTokenDetailsForEvmDestination } from "../../core/squidrouter"; +import { getBridgeTargetTokenDetails, getTokenDetailsForEvmDestination } from "../../core/squidrouter"; import { QuoteContext } from "../../core/types"; import { isBrlToBrlaBaseDirect, isEurToEurcBaseDirect } from "../../utils"; import { BaseSquidRouterEngine, SquidRouterComputation, SquidRouterConfig } from "./index"; @@ -108,9 +108,10 @@ export class OnRampSquidRouterToBaseEngine extends BaseSquidRouterEngine { const usdcBaseTokenDetails = getTokenDetailsForEvmDestination(EvmToken.USDC, Networks.Base); - // Trivial case: nabla output (USDC on Base) is already the requested output. Skip the Squid - // route fetch but still emit bridge meta so downstream stages have a 1:1 passthrough record. + // Trivial case: nabla output (USDC on Base) is already the requested output. + // Skip the Squid route fetch but still emit bridge meta so downstream stages have a 1:1 passthrough record. if (ctx.to === Networks.Base && ctx.request.outputCurrency === EvmToken.USDC) { + const targetTokenDetails = getTokenDetailsForEvmDestination(ctx.request.outputCurrency as OnChainToken, Networks.Base); return { data: { amountRaw: inputAmountRaw, @@ -118,10 +119,10 @@ export class OnRampSquidRouterToBaseEngine extends BaseSquidRouterEngine { fromToken: usdcBaseTokenDetails.erc20AddressSourceChain, inputAmountDecimal, inputAmountRaw, - outputDecimals: usdcBaseTokenDetails.decimals, + outputDecimals: targetTokenDetails.decimals, skipRouteCalculation: true, toNetwork: Networks.Base, - toToken: usdcBaseTokenDetails.erc20AddressSourceChain + toToken: targetTokenDetails.erc20AddressSourceChain }, type: "evm-to-evm" }; @@ -135,7 +136,7 @@ export class OnRampSquidRouterToBaseEngine extends BaseSquidRouterEngine { }); } - const toToken = getTokenDetailsForEvmDestination(req.outputCurrency as OnChainToken, req.to).erc20AddressSourceChain; + const toToken = getBridgeTargetTokenDetails(req.outputCurrency as OnChainToken, toNetwork).erc20AddressSourceChain; return { data: { diff --git a/apps/api/src/api/services/quote/index.ts b/apps/api/src/api/services/quote/index.ts index 402a6a950..ba4e3a037 100644 --- a/apps/api/src/api/services/quote/index.ts +++ b/apps/api/src/api/services/quote/index.ts @@ -3,6 +3,7 @@ import { CreateBestQuoteRequest, CreateQuoteRequest, DestinationType, + EvmToken, FiatToken, getNetworkFromDestination, isNetworkEVM, diff --git a/apps/api/src/api/services/ramp/ramp.service.ts b/apps/api/src/api/services/ramp/ramp.service.ts index 9484a65f0..4df33e8dc 100644 --- a/apps/api/src/api/services/ramp/ramp.service.ts +++ b/apps/api/src/api/services/ramp/ramp.service.ts @@ -1149,6 +1149,17 @@ export class RampService extends BaseRampService { wallet_address: evmEphemeralEntry.address }); + // Mocking mykobo intent call + // const intent = { + // instructions: { + // bank_account_name: "Mykobo Test", + // iban: "DE89370400440532013000" + // }, + // transaction: { + // id: "mykobo-transaction-id", + // reference: "mykobo-transaction-reference" + // } + // }; const instructions = intent.instructions; if (!instructions || !("iban" in instructions)) { throw new APIError({ @@ -1157,7 +1168,9 @@ export class RampService extends BaseRampService { }); } - const { unsignedTxs, stateMeta } = await prepareMykoboToEvmOnrampTransactions({ + let unsignedTxs; + let stateMeta; + const result = await prepareMykoboToEvmOnrampTransactions({ destinationAddress: additionalData.destinationAddress, ipAddress: additionalData.ipAddress, mykoboEmail: additionalData.email, @@ -1166,6 +1179,8 @@ export class RampService extends BaseRampService { quote, signingAccounts: normalizedSigningAccounts }); + unsignedTxs = result.unsignedTxs; + stateMeta = result.stateMeta; const ibanPaymentData: IbanPaymentData = { bic: "", diff --git a/apps/api/src/api/services/transactions/offramp/routes/evm-to-alfredpay.ts b/apps/api/src/api/services/transactions/offramp/routes/evm-to-alfredpay.ts index bc6ba3bbe..174d66c28 100644 --- a/apps/api/src/api/services/transactions/offramp/routes/evm-to-alfredpay.ts +++ b/apps/api/src/api/services/transactions/offramp/routes/evm-to-alfredpay.ts @@ -41,12 +41,13 @@ import erc20ABI from "../../../../../contracts/ERC20"; import AlfredPayCustomer from "../../../../../models/alfredPayCustomer.model"; import { getEvmFundingAccount } from "../../../phases/evm-funding"; import { StateMetadata } from "../../../phases/meta-state-types"; +import { ALFREDPAY_OFFRAMP } from "../../../phases/ramp-flow-definitions"; import { encodeEvmTransactionData } from "../../index"; import { addOnrampDestinationChainTransactions } from "../../onramp/common/transactions"; import { preparePolygonCleanupApproval } from "../../polygon/cleanup"; import { OfframpTransactionParams, OfframpTransactionsWithMeta } from "../common/types"; -// TokenRelayer deployments. Address may differ per chain +// TokenRelayer deployments. Address may differ per chain export const RELAYER_ADDRESSES: Partial> = { [Networks.Arbitrum]: "0xC9ECD03c89349B3EAe4613c7091c6c3029413785", [Networks.Base]: "0xDbece5cE27984FC64688bcC57f75b96a28e8c68c", @@ -54,7 +55,6 @@ export const RELAYER_ADDRESSES: Partial> = { [Networks.Avalanche]: "0x11871C77Aa0170ae13864E4E82cFa471720e045e", [Networks.Ethereum]: "0x522A51f9c5B1683F0F15910075487c4D162A8b83", [Networks.BSC]: "0x2d657ac14088fED401b58FEd377988ed3F875220" - }; export function getRelayerAddress(network: EvmNetworks): `0x${string}` { @@ -67,9 +67,12 @@ export function getRelayerAddress(network: EvmNetworks): `0x${string}` { /** * Resolves the EIP-712 domain for a token's permit signature. - * Some tokens (like USDT in polygon) use salt-based domain separation instead of chainId. + * Supports three formats: + * - Standard (OZ ERC20Permit): name, version, chainId, verifyingContract + * - Salt-based (e.g. USDT on Polygon): name, version, verifyingContract, salt + * - Minimal: chainId, verifyingContract */ -async function resolvePermitDomain( +export async function resolvePermitDomain( publicClient: PublicClient, tokenAddress: `0x${string}`, chainId: number, @@ -78,7 +81,7 @@ async function resolvePermitDomain( let version = "1"; try { version = (await publicClient.readContract({ - abi: [{ inputs: [], name: "version", outputs: [{ type: "string" }], type: "function" }], + abi: [{ inputs: [], name: "version", outputs: [{ type: "string" }], stateMutability: "view", type: "function" }], address: tokenAddress, functionName: "version" })) as string; @@ -96,10 +99,20 @@ async function resolvePermitDomain( ]) ); + const minimalHash = keccak256( + encodeAbiParameters(parseAbiParameters("bytes32, uint256, address"), [ + keccak256(toHex("EIP712Domain(uint256 chainId,address verifyingContract)")), + BigInt(chainId), + tokenAddress + ]) + ); + let onChainSeparator: `0x${string}` | undefined; try { onChainSeparator = (await publicClient.readContract({ - abi: [{ inputs: [], name: "DOMAIN_SEPARATOR", outputs: [{ type: "bytes32" }], type: "function" }], + abi: [ + { inputs: [], name: "DOMAIN_SEPARATOR", outputs: [{ type: "bytes32" }], stateMutability: "view", type: "function" } + ], address: tokenAddress, functionName: "DOMAIN_SEPARATOR" })) as `0x${string}`; @@ -108,30 +121,31 @@ async function resolvePermitDomain( } if (onChainSeparator !== undefined) { - if (onChainSeparator !== standardHash) { - // On-chain separator exists but doesn't match standard - compute salt hash for comparison - const salt = pad(toHex(chainId), { size: 32 }); - const saltHash = keccak256( - encodeAbiParameters(parseAbiParameters("bytes32, bytes32, bytes32, address, bytes32"), [ - keccak256(toHex("EIP712Domain(string name,string version,address verifyingContract,bytes32 salt)")), - keccak256(toHex(tokenName)), - keccak256(toHex(version)), - tokenAddress, - salt - ]) - ); - - if (onChainSeparator === saltHash) { - return { name: tokenName, salt, verifyingContract: tokenAddress, version }; - } - - // Neither matches - this is an error - throw new Error( - `Token ${tokenName} has unexpected DOMAIN_SEPARATOR. Expected standard: ${standardHash} or salt: ${saltHash}, got: ${onChainSeparator}` - ); + if (onChainSeparator === standardHash) { + return { chainId, name: tokenName, verifyingContract: tokenAddress, version }; + } + if (onChainSeparator === minimalHash) { + return { chainId, verifyingContract: tokenAddress }; } - // use standard domain - return { chainId, name: tokenName, verifyingContract: tokenAddress, version }; + // On-chain separator exists but doesn't match standard or minimal - try salt hash. + const salt = pad(toHex(chainId), { size: 32 }); + const saltHash = keccak256( + encodeAbiParameters(parseAbiParameters("bytes32, bytes32, bytes32, address, bytes32"), [ + keccak256(toHex("EIP712Domain(string name,string version,address verifyingContract,bytes32 salt)")), + keccak256(toHex(tokenName)), + keccak256(toHex(version)), + tokenAddress, + salt + ]) + ); + + if (onChainSeparator === saltHash) { + return { name: tokenName, salt, verifyingContract: tokenAddress, version }; + } + + throw new Error( + `Token ${tokenName} has unexpected DOMAIN_SEPARATOR. Expected standard: ${standardHash}, minimal: ${minimalHash} or salt: ${saltHash}, got: ${onChainSeparator}` + ); } // No on-chain separator available - default to standard @@ -322,6 +336,7 @@ export async function prepareEvmToAlfredpayOfframpTransactions({ evmEphemeralAddress: evmEphemeralEntry.address, fiatAccountId, isDirectTransfer: true, + phaseFlow: ALFREDPAY_OFFRAMP, walletAddress: userAddress }; } else { @@ -338,9 +353,7 @@ export async function prepareEvmToAlfredpayOfframpTransactions({ const relayerAddress = RELAYER_ADDRESSES[fromNetwork]; if (!relayerAddress) { - throw new Error( - `Alfredpay offramp permit flow is not supported on ${fromNetwork}: no relayer deployment configured` - ); + throw new Error(`Alfredpay offramp permit flow is not supported on ${fromNetwork}: no relayer deployment configured`); } const permitTypedData: SignedTypedData = { @@ -414,6 +427,7 @@ export async function prepareEvmToAlfredpayOfframpTransactions({ alfredpayUserId: customer.alfredPayId, evmEphemeralAddress: evmEphemeralEntry.address, fiatAccountId, + phaseFlow: ALFREDPAY_OFFRAMP, squidRouterPermitExecutionValue: bridgeResult.swapData.value, walletAddress: userAddress }; @@ -449,6 +463,7 @@ export async function prepareEvmToAlfredpayOfframpTransactions({ fiatAccountId, isDirectTransfer: true, isNoPermitFallback: true, + phaseFlow: ALFREDPAY_OFFRAMP, walletAddress: userAddress }; } else { @@ -490,6 +505,7 @@ export async function prepareEvmToAlfredpayOfframpTransactions({ evmEphemeralAddress: evmEphemeralEntry.address, fiatAccountId, isNoPermitFallback: true, + phaseFlow: ALFREDPAY_OFFRAMP, squidRouterPermitExecutionValue: bridgeResult.swapData.value, walletAddress: userAddress }; diff --git a/apps/api/src/api/services/transactions/offramp/routes/evm-to-brl-base.ts b/apps/api/src/api/services/transactions/offramp/routes/evm-to-brl-base.ts index 6bfa4e181..203e0b673 100644 --- a/apps/api/src/api/services/transactions/offramp/routes/evm-to-brl-base.ts +++ b/apps/api/src/api/services/transactions/offramp/routes/evm-to-brl-base.ts @@ -13,6 +13,7 @@ import { encodeFunctionData } from "viem"; import erc20ABI from "../../../../../contracts/ERC20"; import { getEvmFundingAccount } from "../../../phases/evm-funding"; import { StateMetadata } from "../../../phases/meta-state-types"; +import { BRL_OFFRAMP_BASE } from "../../../phases/ramp-flow-definitions"; import { encodeEvmTransactionData } from "../.."; import { prepareBaseCleanupApproval } from "../../base/cleanup"; import { addEvmFeeDistributionTransaction } from "../../common/feeDistribution"; @@ -233,6 +234,7 @@ export async function prepareEvmToBRLOfframpBaseTransactions({ ...stateMeta, brlaEvmAddress: validatedBrlaEvmAddress, evmEphemeralAddress: evmEphemeralEntry.address, + phaseFlow: BRL_OFFRAMP_BASE, pixDestination: validatedPixDestination, receiverTaxId: validatedReceiverTaxId, taxId: validatedTaxId diff --git a/apps/api/src/api/services/transactions/offramp/routes/evm-to-mykobo.ts b/apps/api/src/api/services/transactions/offramp/routes/evm-to-mykobo.ts index f47e2c3a8..720eb3dd9 100644 --- a/apps/api/src/api/services/transactions/offramp/routes/evm-to-mykobo.ts +++ b/apps/api/src/api/services/transactions/offramp/routes/evm-to-mykobo.ts @@ -20,6 +20,7 @@ import { APIError } from "../../../../errors/api-error"; import { syncMykoboCustomerKyc } from "../../../mykobo/mykobo-customer.service"; import { getEvmFundingAccount } from "../../../phases/evm-funding"; import { StateMetadata } from "../../../phases/meta-state-types"; +import { EUR_OFFRAMP_BASE } from "../../../phases/ramp-flow-definitions"; import { encodeEvmTransactionData } from "../.."; import { prepareBaseCleanupApproval } from "../../base/cleanup"; import { addEvmFeeDistributionTransaction } from "../../common/feeDistribution"; @@ -243,6 +244,7 @@ export async function prepareEvmToMykoboOfframpTransactions({ mykoboReceivablesAddress, mykoboTransactionId, mykoboTransactionReference, + phaseFlow: EUR_OFFRAMP_BASE, walletAddress: userAddress }; 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 b3cfec69e..a1136c75c 100644 --- a/apps/api/src/api/services/transactions/onramp/common/transactions.ts +++ b/apps/api/src/api/services/transactions/onramp/common/transactions.ts @@ -261,10 +261,18 @@ export async function addNablaSwapTransactionsOnBase( // The input amount for the swap was already calculated in the quote. const inputAmountForNablaSwapRaw = quote.metadata.nablaSwapEvm.inputAmountForSwapRaw; + // For offramps, outputAmountRaw may include a partner subsidy (merged in + // OffRampMergeSubsidyEvmEngine). Use the AMM-only amount when available so + // the on-chain minimum reflects what the AMM can actually deliver. + const minOutputBaseRaw = quote.metadata.nablaSwapEvm.ammOutputAmountRaw ?? quote.metadata.nablaSwapEvm.outputAmountRaw; const outputAmountRaw = Big(quote.metadata.nablaSwapEvm.outputAmountRaw); - const nablaSoftMinimumOutputRaw = outputAmountRaw.mul(1 - AMM_MINIMUM_OUTPUT_SOFT_MARGIN).toFixed(0, 0); - const nablaHardMinimumOutputRaw = outputAmountRaw.mul(1 - AMM_MINIMUM_OUTPUT_HARD_MARGIN).toFixed(0, 0); + const nablaSoftMinimumOutputRaw = Big(minOutputBaseRaw) + .mul(1 - AMM_MINIMUM_OUTPUT_SOFT_MARGIN) + .toFixed(0, 0); + const nablaHardMinimumOutputRaw = Big(minOutputBaseRaw) + .mul(1 - AMM_MINIMUM_OUTPUT_HARD_MARGIN) + .toFixed(0, 0); const { approve, swap } = await createNablaTransactionsForOnrampOnEVM( inputAmountForNablaSwapRaw, diff --git a/apps/api/src/api/services/transactions/onramp/routes/alfredpay-to-evm.ts b/apps/api/src/api/services/transactions/onramp/routes/alfredpay-to-evm.ts index e2f298d60..cebf2c234 100644 --- a/apps/api/src/api/services/transactions/onramp/routes/alfredpay-to-evm.ts +++ b/apps/api/src/api/services/transactions/onramp/routes/alfredpay-to-evm.ts @@ -23,6 +23,7 @@ import { isAddress } from "viem"; import AlfredPayCustomer from "../../../../../models/alfredPayCustomer.model"; import { getEvmFundingAccount } from "../../../phases/evm-funding"; import { StateMetadata } from "../../../phases/meta-state-types"; +import { ALFREDPAY_ONRAMP_CROSS_CHAIN, ALFREDPAY_ONRAMP_DIRECT } from "../../../phases/ramp-flow-definitions"; import { encodeEvmTransactionData } from "../../index"; import { preparePolygonCleanupApproval } from "../../polygon/cleanup"; import { addDestinationChainApprovalTransaction, addOnrampDestinationChainTransactions } from "../common/transactions"; @@ -136,7 +137,7 @@ export async function prepareAlfredpayToEvmOnrampTransactions({ txData: encodeEvmTransactionData(polygonCleanupApproval) as EvmTransactionData }); - return { stateMeta, unsignedTxs }; + return { stateMeta: { ...stateMeta, phaseFlow: ALFREDPAY_ONRAMP_DIRECT }, unsignedTxs }; } const { approveData, swapData, squidRouterQuoteId, squidRouterReceiverId, squidRouterReceiverHash } = @@ -202,6 +203,7 @@ export async function prepareAlfredpayToEvmOnrampTransactions({ stateMeta = { ...stateMeta, + phaseFlow: ALFREDPAY_ONRAMP_DIRECT, squidRouterQuoteId, squidRouterReceiverHash, squidRouterReceiverId @@ -317,6 +319,7 @@ export async function prepareAlfredpayToEvmOnrampTransactions({ stateMeta = { ...stateMeta, + phaseFlow: ALFREDPAY_ONRAMP_CROSS_CHAIN, squidRouterQuoteId, squidRouterReceiverHash, squidRouterReceiverId diff --git a/apps/api/src/api/services/transactions/onramp/routes/avenia-to-evm-base.ts b/apps/api/src/api/services/transactions/onramp/routes/avenia-to-evm-base.ts index 466ca0d89..79d2a78ac 100644 --- a/apps/api/src/api/services/transactions/onramp/routes/avenia-to-evm-base.ts +++ b/apps/api/src/api/services/transactions/onramp/routes/avenia-to-evm-base.ts @@ -18,6 +18,11 @@ import { isAddress } from "viem"; import logger from "../../../../../config/logger"; import { getEvmFundingAccount } from "../../../phases/evm-funding"; import { StateMetadata } from "../../../phases/meta-state-types"; +import { + BRL_ONRAMP_BASE_CROSS_CHAIN, + BRL_ONRAMP_BASE_DIRECT, + BRL_ONRAMP_BASE_SAME_CHAIN +} from "../../../phases/ramp-flow-definitions"; import { isBrlToBrlaBaseDirect } from "../../../quote/utils"; import { prepareBaseCleanupApproval } from "../../base/cleanup"; import { addEvmFeeDistributionTransaction } from "../../common/feeDistribution"; @@ -90,7 +95,7 @@ export async function prepareAveniaToEvmOnrampTransactionsOnBase({ txData: finalDestinationTransfer }); - return { stateMeta, unsignedTxs }; + return { stateMeta: { ...stateMeta, phaseFlow: BRL_ONRAMP_BASE_DIRECT }, unsignedTxs }; } if (!quote.metadata.aveniaTransfer?.outputAmountRaw) { @@ -170,7 +175,7 @@ export async function prepareAveniaToEvmOnrampTransactionsOnBase({ txData: encodeEvmTransactionData(usdcCleanupApproval) as EvmTransactionData }); - return { stateMeta, unsignedTxs }; + return { stateMeta: { ...stateMeta, phaseFlow: BRL_ONRAMP_BASE_SAME_CHAIN }, unsignedTxs }; } const { approveData, swapData, squidRouterQuoteId, squidRouterReceiverId, squidRouterReceiverHash } = @@ -255,7 +260,7 @@ export async function prepareAveniaToEvmOnrampTransactionsOnBase({ squidRouterReceiverId }; - return { stateMeta, unsignedTxs }; + return { stateMeta: { ...stateMeta, phaseFlow: BRL_ONRAMP_BASE_SAME_CHAIN }, unsignedTxs }; } const baseFundingAccountAddress = getEvmFundingAccount(Networks.Base).address; @@ -375,6 +380,7 @@ export async function prepareAveniaToEvmOnrampTransactionsOnBase({ stateMeta = { ...stateMeta, + phaseFlow: BRL_ONRAMP_BASE_CROSS_CHAIN, squidRouterQuoteId, squidRouterReceiverHash, squidRouterReceiverId diff --git a/apps/api/src/api/services/transactions/onramp/routes/mykobo-to-evm.ts b/apps/api/src/api/services/transactions/onramp/routes/mykobo-to-evm.ts index d5131e37a..5fa0707fd 100644 --- a/apps/api/src/api/services/transactions/onramp/routes/mykobo-to-evm.ts +++ b/apps/api/src/api/services/transactions/onramp/routes/mykobo-to-evm.ts @@ -18,6 +18,11 @@ import { isAddress } from "viem"; import logger from "../../../../../config/logger"; import { getEvmFundingAccount } from "../../../phases/evm-funding"; import { StateMetadata } from "../../../phases/meta-state-types"; +import { + EUR_ONRAMP_BASE_CROSS_CHAIN, + EUR_ONRAMP_BASE_DIRECT, + EUR_ONRAMP_BASE_SAME_CHAIN +} from "../../../phases/ramp-flow-definitions"; import { isEurToEurcBaseDirect } from "../../../quote/utils"; import { prepareBaseCleanupApproval } from "../../base/cleanup"; import { addEvmFeeDistributionTransaction } from "../../common/feeDistribution"; @@ -105,7 +110,7 @@ export async function prepareMykoboToEvmOnrampTransactions({ txData: finalDestinationTransfer }); - return { stateMeta, unsignedTxs }; + return { stateMeta: { ...stateMeta, phaseFlow: EUR_ONRAMP_BASE_DIRECT }, unsignedTxs }; } const nablaSwapOutputTokenAddress = evmTokenConfig[Networks.Base][EvmToken.USDC]?.erc20AddressSourceChain; @@ -180,7 +185,7 @@ export async function prepareMykoboToEvmOnrampTransactions({ txData: encodeEvmTransactionData(usdcCleanupApproval) as EvmTransactionData }); - return { stateMeta, unsignedTxs }; + return { stateMeta: { ...stateMeta, phaseFlow: EUR_ONRAMP_BASE_SAME_CHAIN }, unsignedTxs }; } const { approveData, swapData, squidRouterQuoteId, squidRouterReceiverId, squidRouterReceiverHash } = @@ -263,6 +268,7 @@ export async function prepareMykoboToEvmOnrampTransactions({ stateMeta = { ...stateMeta, + phaseFlow: EUR_ONRAMP_BASE_SAME_CHAIN, squidRouterQuoteId, squidRouterReceiverHash, squidRouterReceiverId @@ -395,6 +401,7 @@ export async function prepareMykoboToEvmOnrampTransactions({ stateMeta = { ...stateMeta, + phaseFlow: EUR_ONRAMP_BASE_CROSS_CHAIN, squidRouterQuoteId, squidRouterReceiverHash, squidRouterReceiverId diff --git a/apps/api/src/api/services/transactions/validation.ts b/apps/api/src/api/services/transactions/validation.ts index 338d00468..e5193b0ac 100644 --- a/apps/api/src/api/services/transactions/validation.ts +++ b/apps/api/src/api/services/transactions/validation.ts @@ -231,6 +231,7 @@ function getTransactionTypeForPhase(phase: RampPhase | CleanupPhase, network: Ne case "baseCleanupAxlUsdc": case "alfredOnrampMintFallback": case "alfredpayOfframpTransferFallback": + case "ethereumCleanupUsdc": return EphemeralAccountType.EVM; default: throw new APIError({ @@ -350,12 +351,21 @@ export async function validatePresignedTxs( // them — only the resulting on-chain tx hash via /v1/ramp/update additionalData. The receipt // is then verified against the unsigned blueprint by user-tx-verifier at phase execution time. // Accepting a presignedTx here would create a fake authority surface that bypasses that check. - const isUserWalletPhase = + // The signer is the source of truth: an unsigned entry whose signer is an ephemeral address + // is broadcast by the ephemeral (and requires a presignedTx); an entry signed by the user is + // broadcast by the user (and must NOT have a presignedTx). + const ephemeralSigners = new Set( + Object.values(ephemerals) + .filter((v): v is string => Boolean(v)) + .map(s => s.toLowerCase()) + ); + const isSquidBridgePhase = tx.phase === "squidRouterSwap" || tx.phase === "squidRouterApprove"; + const isAlwaysUserWalletPhase = tx.phase === "squidRouterNoPermitTransfer" || tx.phase === "squidRouterNoPermitApprove" || tx.phase === "squidRouterNoPermitSwap" || - (direction === RampDirection.SELL && (tx.phase === "squidRouterSwap" || tx.phase === "squidRouterApprove")); - if (isUserWalletPhase) { + (isSquidBridgePhase && direction === RampDirection.SELL && !ephemeralSigners.has(tx.signer.toLowerCase())); + if (isAlwaysUserWalletPhase) { throw new APIError({ message: `Phase ${tx.phase} is broadcast by the user wallet; do not submit a presigned transaction for it. Submit only the on-chain tx hash via additionalData.`, status: httpStatus.BAD_REQUEST diff --git a/apps/api/src/api/workers/cleanup.worker.ts b/apps/api/src/api/workers/cleanup.worker.ts index 33360e6a5..ea491bac3 100644 --- a/apps/api/src/api/workers/cleanup.worker.ts +++ b/apps/api/src/api/workers/cleanup.worker.ts @@ -154,11 +154,10 @@ class CleanupWorker { where: { currentPhase: { [Op.in]: ["complete", "failed", "timedOut"] }, flowVariant: config.flowVariant, - postCompleteState: { - cleanup: { - [Op.or]: [{ cleanupCompleted: false }, { cleanupCompleted: { [Op.is]: null } }] - } - } + [Op.or]: [ + { "postCompleteState.cleanup.cleanupCompleted": false }, + { "postCompleteState.cleanup.cleanupCompleted": null } + ] } }); diff --git a/apps/frontend/package.json b/apps/frontend/package.json index 3711431ac..9a5e2ac1c 100644 --- a/apps/frontend/package.json +++ b/apps/frontend/package.json @@ -122,7 +122,7 @@ "scripts": { "build": "bun x --bun vite build && cp -R src/assets/coins dist/assets/coins && cp _redirects dist/_redirects", "build-storybook": "storybook build", - "dev": "bun x --bun vite --host", + "dev": "rm -rf node_modules/.vite && bun x --bun vite --host", "preview": "bun x --bun vite preview", "storybook": "storybook dev -p 6006", "test": "vitest", diff --git a/apps/frontend/src/pages/progress/phaseFlows.ts b/apps/frontend/src/pages/progress/phaseFlows.ts index 717c4461d..9538b902d 100644 --- a/apps/frontend/src/pages/progress/phaseFlows.ts +++ b/apps/frontend/src/pages/progress/phaseFlows.ts @@ -91,6 +91,7 @@ export const PHASE_FLOWS = { "subsidizePreSwap", "nablaApprove", "nablaSwap", + "distributeFees", "subsidizePostSwap", "squidRouterApprove", "squidRouterSwap", diff --git a/apps/frontend/src/services/balances/evmBalanceFetcher.ts b/apps/frontend/src/services/balances/evmBalanceFetcher.ts index a72003ec2..564e1b54a 100644 --- a/apps/frontend/src/services/balances/evmBalanceFetcher.ts +++ b/apps/frontend/src/services/balances/evmBalanceFetcher.ts @@ -102,14 +102,17 @@ export async function fetchEvmBalances(evmAddress: string): Promise for (const token of evmTokens) { const addressKey = `${token.network}-${token.erc20AddressSourceChain?.toLowerCase()}`; - const rawBalance = allEvmBalances.get(addressKey); + let balance = "0.00"; + let balanceUsd = "0.00"; + + const rawBalance = allEvmBalances.get(addressKey); const showDecimals = token.assetSymbol.toLowerCase().includes("usd") ? 2 : 6; - const balance = rawBalance ? multiplyByPowerOfTen(Big(rawBalance), -token.decimals).toFixed(showDecimals, 0) : "0.00"; + balance = rawBalance ? multiplyByPowerOfTen(Big(rawBalance), -token.decimals).toFixed(showDecimals, 0) : "0.00"; const matchingToken = evmTokenLookup.get(addressKey); const usdPrice = matchingToken?.usdPrice ?? 0; - const balanceUsd = usdPrice > 0 ? Big(balance).times(usdPrice).toFixed(2, 0) : "0.00"; + balanceUsd = usdPrice > 0 ? Big(balance).times(usdPrice).toFixed(2, 0) : "0.00"; const balanceKey = getBalanceKey(token.network, token.assetSymbol); newBalances.set(balanceKey, { balance, balanceUsd }); diff --git a/package.json b/package.json index 26da763f9..52225a659 100644 --- a/package.json +++ b/package.json @@ -108,7 +108,7 @@ "build:sdk": "bun run --cwd packages/sdk build", "build:shared": "bun run --cwd packages/shared build", "compile:contracts:relayer": "bun run --cwd contracts/relayer compile", - "dev": "concurrently -n 'shared,backend,frontend,dashboard' -c '#ffa500,#007755,#2f6da3,#24c989' 'cd packages/shared && bun run dev' 'cd apps/api && bun dev' 'cd apps/frontend && bun dev' 'cd apps/dashboard && bun dev'", + "dev": "bun run --cwd packages/shared dev & bun run --cwd apps/api dev & bun run --cwd apps/frontend dev & wait", "dev:backend": "bun run --cwd apps/api dev", "dev:contracts:relayer": "bun run --cwd contracts/relayer node", "dev:dashboard": "bun run --cwd apps/dashboard dev", diff --git a/packages/sdk/src/services/NetworkManager.ts b/packages/sdk/src/services/NetworkManager.ts index 09dd56efc..11828f0ba 100644 --- a/packages/sdk/src/services/NetworkManager.ts +++ b/packages/sdk/src/services/NetworkManager.ts @@ -17,7 +17,7 @@ const DEFAULT_NETWORKS: NetworkConfig[] = [ }, { name: "hydration", - wsUrl: "wss://hydration.ibp.network" + wsUrl: "wss://hydration.dotters.network" } ]; diff --git a/packages/shared/src/endpoints/ramp.endpoints.ts b/packages/shared/src/endpoints/ramp.endpoints.ts index 217f0ac09..ce680c850 100644 --- a/packages/shared/src/endpoints/ramp.endpoints.ts +++ b/packages/shared/src/endpoints/ramp.endpoints.ts @@ -63,7 +63,8 @@ export type CleanupPhase = | "baseCleanupUsdc" | "baseCleanupBrla" | "baseCleanupEurc" - | "baseCleanupAxlUsdc"; + | "baseCleanupAxlUsdc" + | "ethereumCleanupUsdc"; export enum EphemeralAccountType { Substrate = "Substrate", @@ -86,8 +87,8 @@ export interface EvmTransactionData { } export interface TypedDataDomain { - name: string; - version: string; + name?: string; + version?: string; salt?: `0x${string}`; chainId?: number; verifyingContract: EvmAddress; diff --git a/packages/shared/src/helpers/signUnsigned.ts b/packages/shared/src/helpers/signUnsigned.ts index 229e276f1..eaa1c44fb 100644 --- a/packages/shared/src/helpers/signUnsigned.ts +++ b/packages/shared/src/helpers/signUnsigned.ts @@ -208,7 +208,14 @@ export async function signUnsignedTransactions( // Group transactions const moonbeamTxs = unsignedTxs.filter(tx => tx.network === Networks.Moonbeam); const evmTxs = unsignedTxs.filter( - tx => tx.network === Networks.Polygon || tx.network === Networks.PolygonAmoy || tx.network === Networks.Base + tx => + tx.network === Networks.Polygon || + tx.network === Networks.PolygonAmoy || + tx.network === Networks.Base || + tx.network === Networks.Arbitrum || + tx.network === Networks.Avalanche || + tx.network === Networks.BSC || + tx.network === Networks.Ethereum ); const hydrationTxs = unsignedTxs.filter(tx => tx.network === Networks.Hydration); const destinationNetworkTxs = unsignedTxs.filter( diff --git a/packages/shared/src/services/pendulum/apiManager.ts b/packages/shared/src/services/pendulum/apiManager.ts index c5d27972e..cce7c7eb9 100644 --- a/packages/shared/src/services/pendulum/apiManager.ts +++ b/packages/shared/src/services/pendulum/apiManager.ts @@ -18,7 +18,7 @@ const NETWORKS: NetworkConfig[] = [ }, { name: "hydration", - wsUrls: ["wss://hydration.ibp.network"] + wsUrls: ["wss://hydration.dotters.network"] }, { name: "moonbeam", diff --git a/packages/shared/src/services/squidrouter/offramp.ts b/packages/shared/src/services/squidrouter/offramp.ts index 271f810cd..30e45719b 100644 --- a/packages/shared/src/services/squidrouter/offramp.ts +++ b/packages/shared/src/services/squidrouter/offramp.ts @@ -1,7 +1,7 @@ import { u8aToHex } from "@polkadot/util"; import { decodeAddress } from "@polkadot/util-crypto"; import { createRandomString, createSquidRouterHash } from "../../helpers/squidrouter"; -import { EvmTransactionData, Networks, SquidrouterRoute } from "../../index"; +import { EvmNetworks, EvmTransactionData, isNetworkEVM, Networks, SquidrouterRoute } from "../../index"; import { EvmClientManager } from "../evm/clientManager"; import { getSquidRouterConfig } from "./config"; import { encodePayload } from "./payload"; @@ -92,9 +92,12 @@ export async function createOfframpSquidrouterTransactionsToEvm( if (params.fromNetwork === Networks.AssetHub) { throw new Error("AssetHub is not supported for Squidrouter offramp"); } + if (!isNetworkEVM(params.fromNetwork)) { + throw new Error(`createOfframpSquidrouterTransactionsToEvm: fromNetwork ${params.fromNetwork} is not an EVM network`); + } const evmClientManager = EvmClientManager.getInstance(); - const moonbeamClient = evmClientManager.getClient(Networks.Moonbeam); + const fromNetworkClient = evmClientManager.getClient(params.fromNetwork as EvmNetworks); const routeParams = createGenericRouteParams({ amount: params.rawAmount, ...params }); @@ -103,7 +106,7 @@ export async function createOfframpSquidrouterTransactionsToEvm( return createTransactionDataFromRoute({ inputTokenErc20Address: params.fromToken, - publicClient: moonbeamClient, + publicClient: fromNetworkClient, rawAmount: params.rawAmount, route }); diff --git a/packages/shared/src/services/squidrouter/route-transactions.ts b/packages/shared/src/services/squidrouter/route-transactions.ts index 2d1a8fbde..61025c5a8 100644 --- a/packages/shared/src/services/squidrouter/route-transactions.ts +++ b/packages/shared/src/services/squidrouter/route-transactions.ts @@ -79,12 +79,14 @@ export async function createTransactionDataFromRoute({ }); const { maxFeePerGas, maxPriorityFeePerGas } = await publicClient.estimateFeesPerGas(); + const bumpedMaxFeePerGas = (maxFeePerGas * 2n).toString(); + const bumpedMaxPriorityFeePerGas = ((maxPriorityFeePerGas ?? maxFeePerGas) * 2n).toString(); const approveData: EvmTransactionData = { data: approveTransactionData as `0x${string}`, gas: "150000", - maxFeePerGas: maxFeePerGas.toString(), - maxPriorityFeePerGas: (maxPriorityFeePerGas ?? maxFeePerGas).toString(), + maxFeePerGas: bumpedMaxFeePerGas, + maxPriorityFeePerGas: bumpedMaxPriorityFeePerGas, to: inputTokenErc20Address as `0x${string}`, value: "0" }; @@ -96,8 +98,8 @@ export async function createTransactionDataFromRoute({ const swapData: EvmTransactionData = { data: transactionRequest.data as `0x${string}`, gas: normalizeBigIntString(transactionRequest.gasLimit), - maxFeePerGas: maxFeePerGas.toString(), - maxPriorityFeePerGas: (maxPriorityFeePerGas ?? maxFeePerGas).toString(), + maxFeePerGas: bumpedMaxFeePerGas, + maxPriorityFeePerGas: bumpedMaxPriorityFeePerGas, to: transactionRequest.target as `0x${string}`, value: normalizeBigIntString(swapValue ?? transactionRequest.value) }; diff --git a/packages/shared/src/tokens/evm/config.ts b/packages/shared/src/tokens/evm/config.ts index 3b6631e53..5bab9db4f 100644 --- a/packages/shared/src/tokens/evm/config.ts +++ b/packages/shared/src/tokens/evm/config.ts @@ -36,6 +36,15 @@ export const evmTokenConfig: Record> = }, maxBuyAmountRaw: "10000000000", maxSellAmountRaw: "10000000000", - minBuyAmountRaw: "1000000", - minSellAmountRaw: "25000000", + minBuyAmountRaw: "500000", + minSellAmountRaw: "500000", type: TokenType.Fiat }, [FiatToken.USD]: { diff --git a/packages/shared/src/tokens/utils/helpers.ts b/packages/shared/src/tokens/utils/helpers.ts index 8cf75c120..8cfe69f69 100644 --- a/packages/shared/src/tokens/utils/helpers.ts +++ b/packages/shared/src/tokens/utils/helpers.ts @@ -53,14 +53,6 @@ export function getOnChainTokenDetailsOrDefault( onChainToken: OnChainTokenSymbol, dynamicEvmTokenConfig?: Record>> ): OnChainTokenDetails { - // AXLUSDC doesn't exist Ethereum - if (onChainToken === EvmToken.AXLUSDC && network === Networks.Ethereum) { - const usdcDetails = getOnChainTokenDetails(network, EvmToken.USDC, dynamicEvmTokenConfig); - if (usdcDetails) { - return usdcDetails; - } - } - const maybeOnChainTokenDetails = getOnChainTokenDetails(network, onChainToken, dynamicEvmTokenConfig); if (maybeOnChainTokenDetails) { return maybeOnChainTokenDetails;