From c6a2cc6874fb2ce202f386f26097c33d0571f31e Mon Sep 17 00:00:00 2001 From: Gianfranco Date: Mon, 8 Jun 2026 13:11:15 -0300 Subject: [PATCH 01/26] refactor flow routing logic --- .../alfredpay-offramp-transfer-handler.ts | 2 +- .../handlers/alfredpay-onramp-mint-handler.ts | 2 +- .../handlers/brla-onramp-mint-handler.ts | 13 +- .../handlers/brla-payout-base-handler.ts | 4 +- .../handlers/destination-transfer-handler.ts | 4 +- .../handlers/distribute-fees-handler.ts | 11 +- .../handlers/final-settlement-subsidy.ts | 16 +- .../phases/handlers/fund-ephemeral-handler.ts | 39 +--- .../phases/handlers/initial-phase-handler.ts | 18 +- .../handlers/mykobo-onramp-deposit-handler.ts | 4 +- .../phases/handlers/mykobo-payout-handler.ts | 2 +- .../phases/handlers/nabla-approve-handler.ts | 6 +- .../phases/handlers/nabla-swap-handler.ts | 10 +- .../squid-router-pay-phase-handler.ts | 6 +- .../handlers/squid-router-phase-handler.ts | 2 +- .../squidrouter-permit-execution-handler.ts | 6 +- .../handlers/subsidize-post-swap-handler.ts | 45 +---- .../handlers/subsidize-pre-swap-handler.ts | 7 +- .../api/services/phases/meta-state-types.ts | 12 +- .../api/services/phases/phase-processor.ts | 46 ++++- .../services/phases/ramp-flow-definitions.ts | 175 ++++++++++++++++++ .../api/services/phases/register-handlers.ts | 2 + .../offramp/routes/evm-to-alfredpay.ts | 12 +- .../offramp/routes/evm-to-brl-base.ts | 2 + .../offramp/routes/evm-to-mykobo.ts | 2 + .../onramp/routes/alfredpay-to-evm.ts | 5 +- .../onramp/routes/avenia-to-evm-base.ts | 12 +- .../onramp/routes/mykobo-to-evm.ts | 11 +- 28 files changed, 309 insertions(+), 167 deletions(-) create mode 100644 apps/api/src/api/services/phases/ramp-flow-definitions.ts 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..946bf7233 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"; @@ -237,43 +236,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/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..54ca90a46 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 @@ -100,11 +100,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; 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..a57a18aec 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 @@ -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..6ab611814 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."); @@ -288,7 +286,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 +295,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..a8e6c9e53 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 @@ -292,7 +289,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..9ba5b30f0 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, RampPhase } from "@vortexfi/shared"; export interface StateMetadata { nablaSoftMinimumOutputRaw: string; @@ -77,4 +77,14 @@ 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[]; + // Morpho vault deposit + morphoVaultAddress?: string; + morphoShareTokenAddress?: string; + morphoDepositAssetAddress?: string; + morphoDepositAmountRaw?: string; + morphoApproveTxHash?: `0x${string}`; + morphoDepositTxHash?: `0x${string}`; } 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..c4034ce22 --- /dev/null +++ b/apps/api/src/api/services/phases/ramp-flow-definitions.ts @@ -0,0 +1,175 @@ +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" +]; + +// ─── Morpho Vault Deposit On-Ramp (Mykobo SEPA on Base → Morpho Vault) ───── + +/** EUR → USDC on Base via Nabla, then deposit into Morpho vault. No SquidRouter bridge. */ +export const EUR_ONRAMP_BASE_MORPHO: RampPhase[] = [ + "initial", + "mykoboOnrampDeposit", + "fundEphemeral", + "subsidizePreSwap", + "nablaApprove", + "nablaSwap", + "distributeFees", + "subsidizePostSwap", + "morphoApprove", + "morphoDeposit", + "complete" +]; diff --git a/apps/api/src/api/services/phases/register-handlers.ts b/apps/api/src/api/services/phases/register-handlers.ts index eb48061c1..a39ab7986 100644 --- a/apps/api/src/api/services/phases/register-handlers.ts +++ b/apps/api/src/api/services/phases/register-handlers.ts @@ -12,6 +12,7 @@ import hydrationToAssethubXcmPhaseHandler from "./handlers/hydration-to-assethub import initialPhaseHandler from "./handlers/initial-phase-handler"; import moonbeamToPendulumPhaseHandler from "./handlers/moonbeam-to-pendulum-handler"; import moonbeamToPendulumXcmHandler from "./handlers/moonbeam-to-pendulum-xcm-handler"; +import morphoDepositHandler from "./handlers/morpho-deposit-handler"; import mykoboOnrampDepositHandler from "./handlers/mykobo-onramp-deposit-handler"; import mykoboPayoutHandler from "./handlers/mykobo-payout-handler"; import nablaApproveHandler from "./handlers/nabla-approve-handler"; @@ -58,6 +59,7 @@ export function registerPhaseHandlers(): void { phaseRegistry.registerHandler(finalSettlementSubsidy); phaseRegistry.registerHandler(destinationTransferHandler); phaseRegistry.registerHandler(squidRouterPermitExecutionHandler); + phaseRegistry.registerHandler(morphoDepositHandler); logger.info("Phase handlers registered"); } 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..b3b74b9b9 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}` { @@ -322,6 +322,7 @@ export async function prepareEvmToAlfredpayOfframpTransactions({ evmEphemeralAddress: evmEphemeralEntry.address, fiatAccountId, isDirectTransfer: true, + phaseFlow: ALFREDPAY_OFFRAMP, walletAddress: userAddress }; } else { @@ -338,9 +339,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 +413,7 @@ export async function prepareEvmToAlfredpayOfframpTransactions({ alfredpayUserId: customer.alfredPayId, evmEphemeralAddress: evmEphemeralEntry.address, fiatAccountId, + phaseFlow: ALFREDPAY_OFFRAMP, squidRouterPermitExecutionValue: bridgeResult.swapData.value, walletAddress: userAddress }; @@ -449,6 +449,7 @@ export async function prepareEvmToAlfredpayOfframpTransactions({ fiatAccountId, isDirectTransfer: true, isNoPermitFallback: true, + phaseFlow: ALFREDPAY_OFFRAMP, walletAddress: userAddress }; } else { @@ -490,6 +491,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/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 From cc3d30df70432894bd738f903c47e79055db192d Mon Sep 17 00:00:00 2001 From: Gianfranco Date: Mon, 8 Jun 2026 19:56:22 -0300 Subject: [PATCH 02/26] add morpho deposit flow variant --- .../phases/handlers/morpho-deposit-handler.ts | 201 ++++++++++++++++++ .../phases/handlers/morpho-vault-config.ts | 25 +++ .../api/src/api/services/ramp/ramp.service.ts | 33 ++- .../onramp/routes/mykobo-to-evm-morpho.ts | 196 +++++++++++++++++ .../api/services/transactions/validation.ts | 2 + .../frontend/src/pages/progress/phaseFlows.ts | 2 + .../src/pages/progress/phaseMessages.ts | 2 + apps/frontend/src/translations/en.json | 2 + apps/frontend/src/translations/pt.json | 2 + .../shared/src/endpoints/ramp.endpoints.ts | 2 + 10 files changed, 456 insertions(+), 11 deletions(-) create mode 100644 apps/api/src/api/services/phases/handlers/morpho-deposit-handler.ts create mode 100644 apps/api/src/api/services/phases/handlers/morpho-vault-config.ts create mode 100644 apps/api/src/api/services/transactions/onramp/routes/mykobo-to-evm-morpho.ts diff --git a/apps/api/src/api/services/phases/handlers/morpho-deposit-handler.ts b/apps/api/src/api/services/phases/handlers/morpho-deposit-handler.ts new file mode 100644 index 000000000..028036987 --- /dev/null +++ b/apps/api/src/api/services/phases/handlers/morpho-deposit-handler.ts @@ -0,0 +1,201 @@ +import { EvmClientManager, EvmNetworks, Networks, RampPhase } from "@vortexfi/shared"; +import { erc20Abi } from "viem"; +import logger from "../../../../config/logger"; +import RampState from "../../../../models/rampState.model"; +import { BasePhaseHandler } from "../base-phase-handler"; +import { StateMetadata } from "../meta-state-types"; + +const EVM_BALANCE_CHECK_TIMEOUT_MS = 3 * 60 * 1000; +const BALANCE_POLLING_INTERVAL_MS = 5000; + +const morphoVaultAbi = [ + { + inputs: [ + { name: "assets", type: "uint256" }, + { name: "receiver", type: "address" } + ], + name: "deposit", + outputs: [{ name: "shares", type: "uint256" }], + stateMutability: "nonpayable", + type: "function" + }, + { + inputs: [{ name: "owner", type: "address" }], + name: "balanceOf", + outputs: [{ name: "", type: "uint256" }], + stateMutability: "view", + type: "function" + } +] as const; + +const MORPHO_NETWORK: EvmNetworks = Networks.Base; + +async function pollForShareBalance( + evmClientManager: EvmClientManager, + network: EvmNetworks, + vaultAddress: `0x${string}`, + ownerAddress: `0x${string}` +): Promise { + const client = evmClientManager.getClient(network); + const deadline = Date.now() + EVM_BALANCE_CHECK_TIMEOUT_MS; + + while (Date.now() < deadline) { + const balance = await client.readContract({ + abi: morphoVaultAbi, + address: vaultAddress, + args: [ownerAddress], + functionName: "balanceOf" + }); + + if (balance > 0n) { + return balance; + } + + await new Promise(resolve => setTimeout(resolve, BALANCE_POLLING_INTERVAL_MS)); + } + + throw new Error(`MorphoDepositHandler: Timed out waiting for share tokens on ${ownerAddress} at vault ${vaultAddress}`); +} + +export class MorphoDepositHandler extends BasePhaseHandler { + public getPhaseName(): RampPhase { + return "morphoApprove"; + } + + public getMaxRetries(): number { + return 5; + } + + protected async executePhase(state: RampState): Promise { + const meta = state.state as StateMetadata; + const evmClientManager = EvmClientManager.getInstance(); + + if (!meta.morphoVaultAddress) { + throw new Error("MorphoDepositHandler: Missing morphoVaultAddress in state metadata"); + } + if (!meta.morphoDepositAssetAddress) { + throw new Error("MorphoDepositHandler: Missing morphoDepositAssetAddress in state metadata"); + } + if (!meta.evmEphemeralAddress) { + throw new Error("MorphoDepositHandler: Missing evmEphemeralAddress in state metadata"); + } + + const vaultAddress = meta.morphoVaultAddress as `0x${string}`; + const depositAssetAddress = meta.morphoDepositAssetAddress as `0x${string}`; + const ephemeralAddress = meta.evmEphemeralAddress; + + // Phase 1: morphoApprove — broadcast approval, verify on-chain allowance + if (state.currentPhase === "morphoApprove") { + return this.executeApprove(state, evmClientManager, vaultAddress, depositAssetAddress, ephemeralAddress as `0x${string}`); + } + + // Phase 2: morphoDeposit — broadcast deposit, verify share tokens + return this.executeDeposit(state, evmClientManager, vaultAddress, ephemeralAddress as `0x${string}`); + } + + private async executeApprove( + state: RampState, + evmClientManager: EvmClientManager, + vaultAddress: `0x${string}`, + depositAssetAddress: `0x${string}`, + ephemeralAddress: `0x${string}` + ): Promise { + const meta = state.state as StateMetadata; + + // Recovery: if we already broadcast the approval, just verify allowance + if (meta.morphoApproveTxHash) { + logger.info(`MorphoDepositHandler: Approve tx already broadcast (${meta.morphoApproveTxHash}), verifying allowance`); + await this.verifyAllowance(evmClientManager, depositAssetAddress, ephemeralAddress, vaultAddress, meta); + return state; + } + + const { txData: approveTxData } = this.getPresignedTransaction(state, "morphoApprove"); + + const txHash = (await evmClientManager.sendRawTransactionWithRetry( + MORPHO_NETWORK, + approveTxData as `0x${string}` + )) as `0x${string}`; + + logger.info(`MorphoDepositHandler: Approval tx broadcast: ${txHash}`); + + await state.update({ + state: { ...state.state, morphoApproveTxHash: txHash } + }); + + const receipt = await evmClientManager.getClient(MORPHO_NETWORK).waitForTransactionReceipt({ hash: txHash }); + if (!receipt || receipt.status !== "success") { + throw new Error(`MorphoDepositHandler: Approval transaction ${txHash} failed on chain`); + } + + await this.verifyAllowance(evmClientManager, depositAssetAddress, ephemeralAddress, vaultAddress, meta); + + return state; + } + + private async verifyAllowance( + evmClientManager: EvmClientManager, + tokenAddress: `0x${string}`, + ownerAddress: `0x${string}`, + spenderAddress: `0x${string}`, + meta: StateMetadata + ): Promise { + const client = evmClientManager.getClient(MORPHO_NETWORK); + + const allowance = await client.readContract({ + abi: erc20Abi, + address: tokenAddress, + args: [ownerAddress, spenderAddress], + functionName: "allowance" + }); + + const requiredAmount = BigInt(meta.morphoDepositAmountRaw || "0"); + + if (allowance < requiredAmount) { + throw new Error(`MorphoDepositHandler: Insufficient allowance. Have ${allowance}, need ${requiredAmount}`); + } + + logger.info(`MorphoDepositHandler: Allowance verified: ${allowance} >= ${requiredAmount}`); + } + + private async executeDeposit( + state: RampState, + evmClientManager: EvmClientManager, + vaultAddress: `0x${string}`, + ephemeralAddress: `0x${string}` + ): Promise { + const meta = state.state as StateMetadata; + + // Recovery: if we already broadcast the deposit, just verify shares + if (meta.morphoDepositTxHash) { + logger.info(`MorphoDepositHandler: Deposit tx already broadcast (${meta.morphoDepositTxHash}), verifying shares`); + const shares = await pollForShareBalance(evmClientManager, MORPHO_NETWORK, vaultAddress, ephemeralAddress); + logger.info(`MorphoDepositHandler: Share balance verified: ${shares}`); + return state; + } + + const { txData: depositTxData } = this.getPresignedTransaction(state, "morphoDeposit"); + + const txHash = (await evmClientManager.sendRawTransactionWithRetry( + MORPHO_NETWORK, + depositTxData as `0x${string}` + )) as `0x${string}`; + + logger.info(`MorphoDepositHandler: Deposit tx broadcast: ${txHash}`); + + await state.update({ + state: { ...state.state, morphoDepositTxHash: txHash } + }); + + const receipt = await evmClientManager.getClient(MORPHO_NETWORK).waitForTransactionReceipt({ hash: txHash }); + if (!receipt || receipt.status !== "success") { + throw new Error(`MorphoDepositHandler: Deposit transaction ${txHash} failed on chain`); + } + + const shares = await pollForShareBalance(evmClientManager, MORPHO_NETWORK, vaultAddress, ephemeralAddress); + logger.info(`MorphoDepositHandler: Deposit successful. Share balance: ${shares}`); + + return state; + } +} + +export default new MorphoDepositHandler(); diff --git a/apps/api/src/api/services/phases/handlers/morpho-vault-config.ts b/apps/api/src/api/services/phases/handlers/morpho-vault-config.ts new file mode 100644 index 000000000..7f11fb46a --- /dev/null +++ b/apps/api/src/api/services/phases/handlers/morpho-vault-config.ts @@ -0,0 +1,25 @@ +import { Networks } from "@vortexfi/shared"; + +export interface MorphoVaultInfo { + vaultAddress: `0x${string}`; + depositAssetAddress: `0x${string}`; + depositAssetDecimals: number; + network: Networks; +} + +const MORPHO_VAULTS: Record = { + "usdc-base": { + depositAssetAddress: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", + depositAssetDecimals: 6, + network: Networks.Base, + vaultAddress: "0xbeef0e0834849aCC03f0089F01f4F1Eeb06873C9" + } +}; + +export function getMorphoVaultInfo(vaultId: string): MorphoVaultInfo { + const vault = MORPHO_VAULTS[vaultId]; + if (!vault) { + throw new Error(`Morpho vault "${vaultId}" not found. Available: ${Object.keys(MORPHO_VAULTS).join(", ")}`); + } + return vault; +} diff --git a/apps/api/src/api/services/ramp/ramp.service.ts b/apps/api/src/api/services/ramp/ramp.service.ts index 9484a65f0..355bbc5a7 100644 --- a/apps/api/src/api/services/ramp/ramp.service.ts +++ b/apps/api/src/api/services/ramp/ramp.service.ts @@ -60,6 +60,7 @@ import { prepareOfframpTransactions } from "../transactions/offramp"; import { prepareOnrampTransactions } from "../transactions/onramp"; import { AveniaOnrampTransactionParams } from "../transactions/onramp/common/types"; import { prepareMykoboToEvmOnrampTransactions } from "../transactions/onramp/routes/mykobo-to-evm"; +import { prepareMykoboToEvmMorphoOnrampTransactions } from "../transactions/onramp/routes/mykobo-to-evm-morpho"; import { validatePresignedTxs } from "../transactions/validation"; import webhookDeliveryService from "../webhook/webhook-delivery.service"; import { BaseRampService } from "./base.service"; @@ -1140,15 +1141,25 @@ export class RampService extends BaseRampService { } const mykobo = MykoboApiService.getInstance(); - const intent = await mykobo.createTransactionIntent({ - currency: MykoboCurrency.EURC, - email_address: additionalData.email, - ip_address: additionalData.ipAddress, - transaction_type: MykoboTransactionType.DEPOSIT, - value: new Big(quote.inputAmount).toFixed(2, 0), - wallet_address: evmEphemeralEntry.address - }); - + // const intent = await mykobo.createTransactionIntent({ + // currency: MykoboCurrency.EURC, + // email_address: additionalData.email, + // ip_address: "79.224.167.233", + // transaction_type: MykoboTransactionType.DEPOSIT, + // value: new Big(quote.inputAmount).toFixed(2, 0), + // wallet_address: evmEphemeralEntry.address + // }); + + 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,9 +1168,9 @@ export class RampService extends BaseRampService { }); } - const { unsignedTxs, stateMeta } = await prepareMykoboToEvmOnrampTransactions({ + const { unsignedTxs, stateMeta } = await prepareMykoboToEvmMorphoOnrampTransactions({ destinationAddress: additionalData.destinationAddress, - ipAddress: additionalData.ipAddress, + ipAddress: "79.224.167.233", mykoboEmail: additionalData.email, mykoboTransactionId: intent.transaction.id, mykoboTransactionReference: intent.transaction.reference, diff --git a/apps/api/src/api/services/transactions/onramp/routes/mykobo-to-evm-morpho.ts b/apps/api/src/api/services/transactions/onramp/routes/mykobo-to-evm-morpho.ts new file mode 100644 index 000000000..9a07abe7e --- /dev/null +++ b/apps/api/src/api/services/transactions/onramp/routes/mykobo-to-evm-morpho.ts @@ -0,0 +1,196 @@ +import { + EvmClientManager, + EvmNetworks, + EvmToken, + EvmTokenDetails, + EvmTransactionData, + evmTokenConfig, + isEvmTokenDetails, + multiplyByPowerOfTen, + Networks, + UnsignedTx +} from "@vortexfi/shared"; +import Big from "big.js"; +import { encodeFunctionData, erc20Abi } from "viem"; +import logger from "../../../../../config/logger"; +import erc20ABI from "../../../../../contracts/ERC20"; +import { getMorphoVaultInfo } from "../../../phases/handlers/morpho-vault-config"; +import { StateMetadata } from "../../../phases/meta-state-types"; +import { EUR_ONRAMP_BASE_MORPHO } from "../../../phases/ramp-flow-definitions"; +import { prepareBaseCleanupApproval } from "../../base/cleanup"; +import { addEvmFeeDistributionTransaction } from "../../common/feeDistribution"; +import { encodeEvmTransactionData } from "../../index"; +import { addNablaSwapTransactionsOnBase } from "../common/transactions"; +import { MykoboOnrampTransactionParams, OnrampTransactionsWithMeta } from "../common/types"; +import { validateMykoboOnramp } from "../common/validation"; + +const morphoVaultAbi = [ + { + inputs: [ + { name: "assets", type: "uint256" }, + { name: "receiver", type: "address" } + ], + name: "deposit", + outputs: [{ name: "shares", type: "uint256" }], + stateMutability: "nonpayable", + type: "function" + } +] as const; + +/** + * Prepares all transactions for a Mykobo (EUR) onramp that deposits into a Morpho vault on Base. + * + * Flow: user SEPA deposit → EURC on Base ephemeral → Nabla swap EURC→USDC → Morpho vault deposit. + * No SquidRouter bridge — the Morpho vault is on Base and accepts USDC. + */ +export async function prepareMykoboToEvmMorphoOnrampTransactions({ + quote, + signingAccounts, + destinationAddress, + mykoboEmail, + mykoboTransactionId, + mykoboTransactionReference +}: MykoboOnrampTransactionParams & { + mykoboTransactionId: string; + mykoboTransactionReference: string; +}): Promise { + let stateMeta: Partial = {}; + const unsignedTxs: UnsignedTx[] = []; + + const { evmEphemeralEntry, inputTokenDetails } = validateMykoboOnramp(quote, signingAccounts); + logger.debug(`Starting prepareMykoboToEvmMorphoOnrampTransactions with destinationAddress: ${destinationAddress}`); + + if (!isEvmTokenDetails(inputTokenDetails)) { + throw new Error(`Input token must be an EVM token for Morpho onramp, got ${inputTokenDetails.assetSymbol}`); + } + + if (!quote.metadata.nablaSwapEvm?.outputAmountRaw) { + throw new Error("Missing nablaSwapEvm.outputAmountRaw in quote metadata for Morpho onramp"); + } + + const morphoVault = getMorphoVaultInfo("usdc-base"); + const depositAmountRaw = quote.metadata.nablaSwapEvm.outputAmountRaw; + + stateMeta = { + destinationAddress, + evmEphemeralAddress: evmEphemeralEntry.address, + isDirectTransfer: false, + morphoDepositAmountRaw: depositAmountRaw, + morphoDepositAssetAddress: morphoVault.depositAssetAddress, + morphoShareTokenAddress: morphoVault.vaultAddress, + morphoVaultAddress: morphoVault.vaultAddress, + mykoboEmail, + mykoboTransactionId, + mykoboTransactionReference, + walletAddress: destinationAddress + }; + + let baseNonce = 0; + + const nablaSwapOutputTokenAddress = evmTokenConfig[Networks.Base][EvmToken.USDC]?.erc20AddressSourceChain; + if (!nablaSwapOutputTokenAddress) { + throw new Error("Invalid USDC configuration for Base in evmTokenConfig"); + } + const eurcInputTokenAddress = (inputTokenDetails as EvmTokenDetails).erc20AddressSourceChain; + + const { nextNonce: nonceAfterNabla, stateMeta: nablaStateMeta } = await addNablaSwapTransactionsOnBase( + { + account: evmEphemeralEntry, + inputTokenAddress: eurcInputTokenAddress, + outputTokenAddress: nablaSwapOutputTokenAddress, + quote + }, + unsignedTxs, + baseNonce + ); + stateMeta = { ...stateMeta, ...nablaStateMeta }; + baseNonce = nonceAfterNabla; + + baseNonce = await addEvmFeeDistributionTransaction(quote, evmEphemeralEntry, unsignedTxs, baseNonce); + + const baseClient = EvmClientManager.getInstance().getClient(Networks.Base); + const { maxFeePerGas, maxPriorityFeePerGas } = await baseClient.estimateFeesPerGas(); + + // Morpho approval: approve vault to spend the deposit asset (USDC) + const approveCallData = encodeFunctionData({ + abi: erc20Abi, + args: [morphoVault.vaultAddress, BigInt(depositAmountRaw)], + functionName: "approve" + }); + + unsignedTxs.push({ + meta: {}, + network: Networks.Base, + nonce: baseNonce++, + phase: "morphoApprove", + signer: evmEphemeralEntry.address, + txData: { + data: approveCallData as `0x${string}`, + gas: "100000", + maxFeePerGas: String(maxFeePerGas), + maxPriorityFeePerGas: String(maxPriorityFeePerGas), + to: morphoVault.depositAssetAddress as `0x${string}`, + value: "0" + } as EvmTransactionData + }); + + // Morpho deposit: deposit USDC into the vault + const depositCallData = encodeFunctionData({ + abi: morphoVaultAbi, + args: [BigInt(depositAmountRaw), evmEphemeralEntry.address as `0x${string}`], + functionName: "deposit" + }); + + unsignedTxs.push({ + meta: {}, + network: Networks.Base, + nonce: baseNonce++, + phase: "morphoDeposit", + signer: evmEphemeralEntry.address, + txData: { + data: depositCallData as `0x${string}`, + gas: "500000", + maxFeePerGas: String(maxFeePerGas), + maxPriorityFeePerGas: String(maxPriorityFeePerGas), + to: morphoVault.vaultAddress as `0x${string}`, + value: "0" + } as EvmTransactionData + }); + + // Cleanup approvals for post-complete token recovery + const baseFundingAccountAddress = ( + await import("../../../phases/evm-funding").then(m => m.getEvmFundingAccount(Networks.Base)) + ).address; + + const eurcCleanupApproval = await prepareBaseCleanupApproval( + eurcInputTokenAddress as `0x${string}`, + baseFundingAccountAddress, + Networks.Base + ); + unsignedTxs.push({ + meta: {}, + network: Networks.Base, + nonce: baseNonce++, + phase: "baseCleanupEurc", + signer: evmEphemeralEntry.address, + txData: encodeEvmTransactionData(eurcCleanupApproval) as EvmTransactionData + }); + + const usdcCleanupApproval = await prepareBaseCleanupApproval( + nablaSwapOutputTokenAddress as `0x${string}`, + baseFundingAccountAddress, + Networks.Base + ); + unsignedTxs.push({ + meta: {}, + network: Networks.Base, + nonce: baseNonce++, + phase: "baseCleanupUsdc", + signer: evmEphemeralEntry.address, + txData: encodeEvmTransactionData(usdcCleanupApproval) as EvmTransactionData + }); + + stateMeta = { ...stateMeta, phaseFlow: EUR_ONRAMP_BASE_MORPHO }; + + return { stateMeta, unsignedTxs }; +} diff --git a/apps/api/src/api/services/transactions/validation.ts b/apps/api/src/api/services/transactions/validation.ts index 338d00468..bab2c3029 100644 --- a/apps/api/src/api/services/transactions/validation.ts +++ b/apps/api/src/api/services/transactions/validation.ts @@ -231,6 +231,8 @@ function getTransactionTypeForPhase(phase: RampPhase | CleanupPhase, network: Ne case "baseCleanupAxlUsdc": case "alfredOnrampMintFallback": case "alfredpayOfframpTransferFallback": + case "morphoApprove": + case "morphoDeposit": return EphemeralAccountType.EVM; default: throw new APIError({ diff --git a/apps/frontend/src/pages/progress/phaseFlows.ts b/apps/frontend/src/pages/progress/phaseFlows.ts index 717c4461d..c52e4f786 100644 --- a/apps/frontend/src/pages/progress/phaseFlows.ts +++ b/apps/frontend/src/pages/progress/phaseFlows.ts @@ -23,6 +23,8 @@ export const PHASE_DURATIONS: Record = { initial: 0, moonbeamToPendulum: 40, moonbeamToPendulumXcm: 30, + morphoApprove: 24, + morphoDeposit: 30, mykoboOnrampDeposit: 5 * 60, mykoboPayoutOnBase: 60, nablaApprove: 24, diff --git a/apps/frontend/src/pages/progress/phaseMessages.ts b/apps/frontend/src/pages/progress/phaseMessages.ts index 8af888520..9c28bebab 100644 --- a/apps/frontend/src/pages/progress/phaseMessages.ts +++ b/apps/frontend/src/pages/progress/phaseMessages.ts @@ -84,6 +84,8 @@ export function getMessageForPhase(ramp: RampState | undefined, t: TFunction<"tr initial: t("pages.progress.initial"), moonbeamToPendulum: getMoonbeamToPendulumMessage(), moonbeamToPendulumXcm: getMoonbeamToPendulumMessage(), + morphoApprove: t("pages.progress.morphoApprove"), + morphoDeposit: t("pages.progress.morphoDeposit"), mykoboOnrampDeposit: t("pages.progress.mykoboOnrampDeposit"), mykoboPayoutOnBase: getTransferringMessage(), nablaApprove: getSwappingMessage(), diff --git a/apps/frontend/src/translations/en.json b/apps/frontend/src/translations/en.json index dc3e4fe66..0d38d0b36 100644 --- a/apps/frontend/src/translations/en.json +++ b/apps/frontend/src/translations/en.json @@ -1208,6 +1208,8 @@ "hydrationToAssethubXcm": "Transferring {{assetSymbol}} from Hydration --> AssetHub", "initial": "Starting process", "moonbeamToPendulum": "Transferring {{assetSymbol}} from Moonbeam --> Pendulum", + "morphoApprove": "Approving tokens for Morpho vault deposit", + "morphoDeposit": "Depositing into Morpho vault", "mykoboOnrampDeposit": "Waiting to receive payment", "pendulumToAssethubXcm": "Transferring {{assetSymbol}} from Pendulum --> AssetHub", "pendulumToHydrationXcm": "Transferring {{assetSymbol}} from Pendulum --> Hydration", diff --git a/apps/frontend/src/translations/pt.json b/apps/frontend/src/translations/pt.json index ff06500bb..ac92def9d 100644 --- a/apps/frontend/src/translations/pt.json +++ b/apps/frontend/src/translations/pt.json @@ -1213,6 +1213,8 @@ "hydrationToAssethubXcm": "Transferindo {{assetSymbol}} de Hydration --> AssetHub", "initial": "Iniciando processo", "moonbeamToPendulum": "Transferindo {{assetSymbol}} de Moonbeam --> Pendulum", + "morphoApprove": "Aprovando tokens para depósito no Morpho vault", + "morphoDeposit": "Depositando no Morpho vault", "mykoboOnrampDeposit": "Aguardando recebimento do pagamento", "pendulumToAssethubXcm": "Transferindo {{assetSymbol}} de Pendulum --> AssetHub", "pendulumToHydrationXcm": "Transferindo {{assetSymbol}} de Pendulum --> Hydration", diff --git a/packages/shared/src/endpoints/ramp.endpoints.ts b/packages/shared/src/endpoints/ramp.endpoints.ts index 217f0ac09..f191e5af2 100644 --- a/packages/shared/src/endpoints/ramp.endpoints.ts +++ b/packages/shared/src/endpoints/ramp.endpoints.ts @@ -51,6 +51,8 @@ export type RampPhase = | "backupSquidRouterApprove" | "backupSquidRouterSwap" | "backupApprove" + | "morphoApprove" + | "morphoDeposit" | "complete"; export type CleanupPhase = From 6eab26bbca2f6174dbae666503a23f42c621a682 Mon Sep 17 00:00:00 2001 From: Gianfranco Date: Tue, 9 Jun 2026 11:15:45 -0300 Subject: [PATCH 03/26] check for allowance to await for state sync --- .../phases/handlers/morpho-deposit-handler.ts | 30 +++++++++++-------- 1 file changed, 18 insertions(+), 12 deletions(-) diff --git a/apps/api/src/api/services/phases/handlers/morpho-deposit-handler.ts b/apps/api/src/api/services/phases/handlers/morpho-deposit-handler.ts index 028036987..fc0c0b53f 100644 --- a/apps/api/src/api/services/phases/handlers/morpho-deposit-handler.ts +++ b/apps/api/src/api/services/phases/handlers/morpho-deposit-handler.ts @@ -140,21 +140,27 @@ export class MorphoDepositHandler extends BasePhaseHandler { meta: StateMetadata ): Promise { const client = evmClientManager.getClient(MORPHO_NETWORK); - - const allowance = await client.readContract({ - abi: erc20Abi, - address: tokenAddress, - args: [ownerAddress, spenderAddress], - functionName: "allowance" - }); - const requiredAmount = BigInt(meta.morphoDepositAmountRaw || "0"); - - if (allowance < requiredAmount) { - throw new Error(`MorphoDepositHandler: Insufficient allowance. Have ${allowance}, need ${requiredAmount}`); + const deadline = Date.now() + EVM_BALANCE_CHECK_TIMEOUT_MS; + let lastAllowance = 0n; + + while (Date.now() < deadline) { + lastAllowance = await client.readContract({ + abi: erc20Abi, + address: tokenAddress, + args: [ownerAddress, spenderAddress], + functionName: "allowance" + }); + + if (lastAllowance >= requiredAmount) { + logger.info(`MorphoDepositHandler: Allowance verified: ${lastAllowance} >= ${requiredAmount}`); + return; + } + + await new Promise(resolve => setTimeout(resolve, BALANCE_POLLING_INTERVAL_MS)); } - logger.info(`MorphoDepositHandler: Allowance verified: ${allowance} >= ${requiredAmount}`); + throw new Error(`MorphoDepositHandler: Insufficient allowance. Have ${lastAllowance}, need ${requiredAmount}`); } private async executeDeposit( From d3e17f98e394bd37e1098e142d5dcd15e840b174 Mon Sep 17 00:00:00 2001 From: Gianfranco Date: Tue, 9 Jun 2026 12:57:41 -0300 Subject: [PATCH 04/26] Add vault "token" to UI selector --- .../services/quote/engines/discount/onramp.ts | 8 ++-- .../quote/engines/fee/onramp-brl-to-evm.ts | 5 ++- .../quote/engines/fee/onramp-mykobo-to-evm.ts | 5 ++- .../engines/squidrouter/onramp-base-to-evm.ts | 14 ++++--- .../api/src/api/services/ramp/ramp.service.ts | 37 ++++++++++++++----- apps/api/src/api/workers/cleanup.worker.ts | 9 ++--- packages/shared/src/tokens/evm/config.ts | 9 +++++ packages/shared/src/tokens/types/evm.ts | 3 +- 8 files changed, 63 insertions(+), 27 deletions(-) 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..38079c016 100644 --- a/apps/api/src/api/services/quote/engines/discount/onramp.ts +++ b/apps/api/src/api/services/quote/engines/discount/onramp.ts @@ -123,11 +123,9 @@ 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. - if (toNetwork === Networks.Base && req.outputCurrency === EvmToken.USDC) { + // Trivial case: USDC/Morpho Vault on Base is also the requested output. No bridge runs, so the + // conversion rate is exactly 1:1 - skip the Squid call. + if (toNetwork === Networks.Base && (req.outputCurrency === EvmToken.USDC || req.outputCurrency === EvmToken.MORPHO_VAULT)) { 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..f7fc311e0 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 @@ -63,7 +63,10 @@ export class OnRampAveniaToEvmFeeEngine extends BaseFeeEngine { // Same-chain same-token: Nabla swap output already matches the destination token (e.g. BRL → Base USDC). // No bridge needed, so skip the Squid route call (which would fail with "same token same chain") and report zero network fee. - if (swapNetwork === toNetwork && fromTokenDetails.erc20AddressSourceChain.toLowerCase() === toToken.toLowerCase()) { + if ( + (swapNetwork === toNetwork && fromTokenDetails.erc20AddressSourceChain.toLowerCase() === toToken.toLowerCase()) || + request.outputCurrency === EvmToken.MORPHO_VAULT + ) { return { anchor: { amount: computedAnchorFee, currency: anchorFeeCurrency }, network: { amount: "0", currency: "USD" as RampCurrency } 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..dc91ec69c 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 @@ -57,7 +57,10 @@ export class OnRampMykoboToEvmFeeEngine extends BaseFeeEngine { throw new Error(`OnRampMykoboToEvmFeeEngine: invalid token configuration for ${this.fromToken} on ${swapNetwork}`); } - if (swapNetwork === toNetwork && fromTokenDetails.erc20AddressSourceChain.toLowerCase() === toToken.toLowerCase()) { + if ( + (swapNetwork === toNetwork && fromTokenDetails.erc20AddressSourceChain.toLowerCase() === toToken.toLowerCase()) || + request.outputCurrency === EvmToken.MORPHO_VAULT + ) { return { anchor: { amount: computedAnchorFee, currency: anchorFeeCurrency }, network: { amount: "0", currency: "USD" as RampCurrency } 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..6f0d400b8 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 @@ -108,9 +108,13 @@ 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. - if (ctx.to === Networks.Base && ctx.request.outputCurrency === EvmToken.USDC) { + // Trivial case: nabla output (USDC on Base) is already the requested output or is Morpho Vault. + // 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 || ctx.request.outputCurrency === EvmToken.MORPHO_VAULT) + ) { + const targetTokenDetails = getTokenDetailsForEvmDestination(ctx.request.outputCurrency as OnChainToken, Networks.Base); return { data: { amountRaw: inputAmountRaw, @@ -118,10 +122,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" }; diff --git a/apps/api/src/api/services/ramp/ramp.service.ts b/apps/api/src/api/services/ramp/ramp.service.ts index 355bbc5a7..6f8c7645e 100644 --- a/apps/api/src/api/services/ramp/ramp.service.ts +++ b/apps/api/src/api/services/ramp/ramp.service.ts @@ -13,6 +13,7 @@ import { CreateAlfredpayOfframpQuoteRequest, CreateAlfredpayOnrampRequest, EphemeralAccountType, + EvmToken, FiatToken, GetRampHistoryResponse, GetRampStatusResponse, @@ -1168,15 +1169,33 @@ export class RampService extends BaseRampService { }); } - const { unsignedTxs, stateMeta } = await prepareMykoboToEvmMorphoOnrampTransactions({ - destinationAddress: additionalData.destinationAddress, - ipAddress: "79.224.167.233", - mykoboEmail: additionalData.email, - mykoboTransactionId: intent.transaction.id, - mykoboTransactionReference: intent.transaction.reference, - quote, - signingAccounts: normalizedSigningAccounts - }); + let unsignedTxs; + let stateMeta; + if (quote.outputCurrency === EvmToken.MORPHO_VAULT) { + const result = await prepareMykoboToEvmMorphoOnrampTransactions({ + destinationAddress: additionalData.destinationAddress, + ipAddress: "79.224.167.233", + mykoboEmail: additionalData.email, + mykoboTransactionId: intent.transaction.id, + mykoboTransactionReference: intent.transaction.reference, + quote, + signingAccounts: normalizedSigningAccounts + }); + unsignedTxs = result.unsignedTxs; + stateMeta = result.stateMeta; + } else { + const result = await prepareMykoboToEvmOnrampTransactions({ + destinationAddress: additionalData.destinationAddress, + ipAddress: "79.224.167.233", + mykoboEmail: additionalData.email, + mykoboTransactionId: intent.transaction.id, + mykoboTransactionReference: intent.transaction.reference, + quote, + signingAccounts: normalizedSigningAccounts + }); + unsignedTxs = result.unsignedTxs; + stateMeta = result.stateMeta; + } const ibanPaymentData: IbanPaymentData = { bic: "", 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/packages/shared/src/tokens/evm/config.ts b/packages/shared/src/tokens/evm/config.ts index 3b6631e53..1fe3cf276 100644 --- a/packages/shared/src/tokens/evm/config.ts +++ b/packages/shared/src/tokens/evm/config.ts @@ -226,6 +226,15 @@ export const evmTokenConfig: Record Date: Tue, 9 Jun 2026 13:12:53 -0300 Subject: [PATCH 05/26] deposit into destination address, bypass small subsidy cap issue --- .../phases/handlers/morpho-deposit-handler.ts | 17 ++++++++++------- .../handlers/subsidize-post-swap-handler.ts | 5 +++-- .../handlers/subsidize-pre-swap-handler.ts | 5 +++-- .../onramp/routes/mykobo-to-evm-morpho.ts | 2 +- 4 files changed, 17 insertions(+), 12 deletions(-) diff --git a/apps/api/src/api/services/phases/handlers/morpho-deposit-handler.ts b/apps/api/src/api/services/phases/handlers/morpho-deposit-handler.ts index fc0c0b53f..4125d3eee 100644 --- a/apps/api/src/api/services/phases/handlers/morpho-deposit-handler.ts +++ b/apps/api/src/api/services/phases/handlers/morpho-deposit-handler.ts @@ -89,8 +89,9 @@ export class MorphoDepositHandler extends BasePhaseHandler { return this.executeApprove(state, evmClientManager, vaultAddress, depositAssetAddress, ephemeralAddress as `0x${string}`); } - // Phase 2: morphoDeposit — broadcast deposit, verify share tokens - return this.executeDeposit(state, evmClientManager, vaultAddress, ephemeralAddress as `0x${string}`); + // Phase 2: morphoDeposit — broadcast deposit, verify share tokens minted to destination + const destinationAddress = meta.destinationAddress as `0x${string}`; + return this.executeDeposit(state, evmClientManager, vaultAddress, destinationAddress); } private async executeApprove( @@ -167,14 +168,16 @@ export class MorphoDepositHandler extends BasePhaseHandler { state: RampState, evmClientManager: EvmClientManager, vaultAddress: `0x${string}`, - ephemeralAddress: `0x${string}` + shareReceiver: `0x${string}` ): Promise { const meta = state.state as StateMetadata; // Recovery: if we already broadcast the deposit, just verify shares if (meta.morphoDepositTxHash) { - logger.info(`MorphoDepositHandler: Deposit tx already broadcast (${meta.morphoDepositTxHash}), verifying shares`); - const shares = await pollForShareBalance(evmClientManager, MORPHO_NETWORK, vaultAddress, ephemeralAddress); + logger.info( + `MorphoDepositHandler: Deposit tx already broadcast (${meta.morphoDepositTxHash}), verifying shares on ${shareReceiver}` + ); + const shares = await pollForShareBalance(evmClientManager, MORPHO_NETWORK, vaultAddress, shareReceiver); logger.info(`MorphoDepositHandler: Share balance verified: ${shares}`); return state; } @@ -197,8 +200,8 @@ export class MorphoDepositHandler extends BasePhaseHandler { throw new Error(`MorphoDepositHandler: Deposit transaction ${txHash} failed on chain`); } - const shares = await pollForShareBalance(evmClientManager, MORPHO_NETWORK, vaultAddress, ephemeralAddress); - logger.info(`MorphoDepositHandler: Deposit successful. Share balance: ${shares}`); + const shares = await pollForShareBalance(evmClientManager, MORPHO_NETWORK, vaultAddress, shareReceiver); + logger.info(`MorphoDepositHandler: Deposit successful. Share balance on ${shareReceiver}: ${shares}`); return state; } 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 6ab611814..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 @@ -236,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}).` ); } 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 a8e6c9e53..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 @@ -240,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}).` ); } diff --git a/apps/api/src/api/services/transactions/onramp/routes/mykobo-to-evm-morpho.ts b/apps/api/src/api/services/transactions/onramp/routes/mykobo-to-evm-morpho.ts index 9a07abe7e..f8a22535a 100644 --- a/apps/api/src/api/services/transactions/onramp/routes/mykobo-to-evm-morpho.ts +++ b/apps/api/src/api/services/transactions/onramp/routes/mykobo-to-evm-morpho.ts @@ -137,7 +137,7 @@ export async function prepareMykoboToEvmMorphoOnrampTransactions({ // Morpho deposit: deposit USDC into the vault const depositCallData = encodeFunctionData({ abi: morphoVaultAbi, - args: [BigInt(depositAmountRaw), evmEphemeralEntry.address as `0x${string}`], + args: [BigInt(depositAmountRaw), destinationAddress as `0x${string}`], functionName: "deposit" }); From 264bd01e846ef3164ebd3731aa40d69d0ccfdddf Mon Sep 17 00:00:00 2001 From: Gianfranco Date: Tue, 9 Jun 2026 14:52:56 -0300 Subject: [PATCH 06/26] use morphoDeposit as single phase handler --- .../phases/handlers/morpho-deposit-handler.ts | 71 +++++++++++++------ .../api/services/phases/meta-state-types.ts | 1 - .../services/phases/ramp-flow-definitions.ts | 1 - .../api/src/api/services/priceFeed.service.ts | 3 +- .../frontend/src/pages/progress/phaseFlows.ts | 2 +- .../src/pages/progress/phaseMessages.ts | 2 +- 6 files changed, 52 insertions(+), 28 deletions(-) diff --git a/apps/api/src/api/services/phases/handlers/morpho-deposit-handler.ts b/apps/api/src/api/services/phases/handlers/morpho-deposit-handler.ts index 4125d3eee..841a0dae2 100644 --- a/apps/api/src/api/services/phases/handlers/morpho-deposit-handler.ts +++ b/apps/api/src/api/services/phases/handlers/morpho-deposit-handler.ts @@ -59,7 +59,7 @@ async function pollForShareBalance( export class MorphoDepositHandler extends BasePhaseHandler { public getPhaseName(): RampPhase { - return "morphoApprove"; + return "morphoDeposit"; } public getMaxRetries(): number { @@ -82,16 +82,32 @@ export class MorphoDepositHandler extends BasePhaseHandler { const vaultAddress = meta.morphoVaultAddress as `0x${string}`; const depositAssetAddress = meta.morphoDepositAssetAddress as `0x${string}`; - const ephemeralAddress = meta.evmEphemeralAddress; + const ephemeralAddress = meta.evmEphemeralAddress as `0x${string}`; + const destinationAddress = meta.destinationAddress as `0x${string}`; - // Phase 1: morphoApprove — broadcast approval, verify on-chain allowance - if (state.currentPhase === "morphoApprove") { - return this.executeApprove(state, evmClientManager, vaultAddress, depositAssetAddress, ephemeralAddress as `0x${string}`); - } + const { approveTxData, depositTxData } = this.getMorphoPresignedTxs(state); - // Phase 2: morphoDeposit — broadcast deposit, verify share tokens minted to destination - const destinationAddress = meta.destinationAddress as `0x${string}`; - return this.executeDeposit(state, evmClientManager, vaultAddress, destinationAddress); + // Step 1: Approve vault to spend deposit asset + state = await this.executeApprove( + state, + evmClientManager, + vaultAddress, + depositAssetAddress, + ephemeralAddress, + approveTxData + ); + + // Step 2: Deposit into vault, verify share tokens minted to destination + return this.executeDeposit(state, evmClientManager, vaultAddress, destinationAddress, depositTxData); + } + + private getMorphoPresignedTxs(state: RampState): { approveTxData: string; depositTxData: string } { + const approveTx = state.presignedTxs?.find(tx => tx.phase === "morphoApprove"); + const depositTx = state.presignedTxs?.find(tx => tx.phase === "morphoDeposit"); + if (!approveTx || !depositTx) { + throw new Error(`MorphoDepositHandler: Missing presigned txs (approve: ${!!approveTx}, deposit: ${!!depositTx})`); + } + return { approveTxData: approveTx.txData as string, depositTxData: depositTx.txData as string }; } private async executeApprove( @@ -99,19 +115,18 @@ export class MorphoDepositHandler extends BasePhaseHandler { evmClientManager: EvmClientManager, vaultAddress: `0x${string}`, depositAssetAddress: `0x${string}`, - ephemeralAddress: `0x${string}` + ephemeralAddress: `0x${string}`, + approveTxData: string ): Promise { const meta = state.state as StateMetadata; + const requiredAmount = BigInt(meta.morphoDepositAmountRaw || "0"); - // Recovery: if we already broadcast the approval, just verify allowance - if (meta.morphoApproveTxHash) { - logger.info(`MorphoDepositHandler: Approve tx already broadcast (${meta.morphoApproveTxHash}), verifying allowance`); - await this.verifyAllowance(evmClientManager, depositAssetAddress, ephemeralAddress, vaultAddress, meta); + const allowance = await this.readAllowance(evmClientManager, depositAssetAddress, ephemeralAddress, vaultAddress); + if (allowance >= requiredAmount) { + logger.info(`MorphoDepositHandler: Allowance ${allowance} >= ${requiredAmount}, skipping approve`); return state; } - const { txData: approveTxData } = this.getPresignedTransaction(state, "morphoApprove"); - const txHash = (await evmClientManager.sendRawTransactionWithRetry( MORPHO_NETWORK, approveTxData as `0x${string}` @@ -119,10 +134,6 @@ export class MorphoDepositHandler extends BasePhaseHandler { logger.info(`MorphoDepositHandler: Approval tx broadcast: ${txHash}`); - await state.update({ - state: { ...state.state, morphoApproveTxHash: txHash } - }); - const receipt = await evmClientManager.getClient(MORPHO_NETWORK).waitForTransactionReceipt({ hash: txHash }); if (!receipt || receipt.status !== "success") { throw new Error(`MorphoDepositHandler: Approval transaction ${txHash} failed on chain`); @@ -133,6 +144,21 @@ export class MorphoDepositHandler extends BasePhaseHandler { return state; } + private async readAllowance( + evmClientManager: EvmClientManager, + tokenAddress: `0x${string}`, + ownerAddress: `0x${string}`, + spenderAddress: `0x${string}` + ): Promise { + const client = evmClientManager.getClient(MORPHO_NETWORK); + return client.readContract({ + abi: erc20Abi, + address: tokenAddress, + args: [ownerAddress, spenderAddress], + functionName: "allowance" + }); + } + private async verifyAllowance( evmClientManager: EvmClientManager, tokenAddress: `0x${string}`, @@ -168,7 +194,8 @@ export class MorphoDepositHandler extends BasePhaseHandler { state: RampState, evmClientManager: EvmClientManager, vaultAddress: `0x${string}`, - shareReceiver: `0x${string}` + shareReceiver: `0x${string}`, + depositTxData: string ): Promise { const meta = state.state as StateMetadata; @@ -182,8 +209,6 @@ export class MorphoDepositHandler extends BasePhaseHandler { return state; } - const { txData: depositTxData } = this.getPresignedTransaction(state, "morphoDeposit"); - const txHash = (await evmClientManager.sendRawTransactionWithRetry( MORPHO_NETWORK, depositTxData as `0x${string}` 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 9ba5b30f0..4cdaed38d 100644 --- a/apps/api/src/api/services/phases/meta-state-types.ts +++ b/apps/api/src/api/services/phases/meta-state-types.ts @@ -85,6 +85,5 @@ export interface StateMetadata { morphoShareTokenAddress?: string; morphoDepositAssetAddress?: string; morphoDepositAmountRaw?: string; - morphoApproveTxHash?: `0x${string}`; morphoDepositTxHash?: `0x${string}`; } diff --git a/apps/api/src/api/services/phases/ramp-flow-definitions.ts b/apps/api/src/api/services/phases/ramp-flow-definitions.ts index c4034ce22..8f08222be 100644 --- a/apps/api/src/api/services/phases/ramp-flow-definitions.ts +++ b/apps/api/src/api/services/phases/ramp-flow-definitions.ts @@ -169,7 +169,6 @@ export const EUR_ONRAMP_BASE_MORPHO: RampPhase[] = [ "nablaSwap", "distributeFees", "subsidizePostSwap", - "morphoApprove", "morphoDeposit", "complete" ]; diff --git a/apps/api/src/api/services/priceFeed.service.ts b/apps/api/src/api/services/priceFeed.service.ts index 782f2e2f6..a8743310a 100644 --- a/apps/api/src/api/services/priceFeed.service.ts +++ b/apps/api/src/api/services/priceFeed.service.ts @@ -460,7 +460,8 @@ export class PriceFeedService { ETH: "ethereum", GLMR: "moonbeam", HDX: "hydradx", - MATIC: "polygon-ecosystem-token" + MATIC: "polygon-ecosystem-token", + "MORPHO VAULT": "usd-coin" }; return tokenIdMap[currency.toUpperCase()] || null; diff --git a/apps/frontend/src/pages/progress/phaseFlows.ts b/apps/frontend/src/pages/progress/phaseFlows.ts index c52e4f786..ee729bcc8 100644 --- a/apps/frontend/src/pages/progress/phaseFlows.ts +++ b/apps/frontend/src/pages/progress/phaseFlows.ts @@ -23,7 +23,7 @@ export const PHASE_DURATIONS: Record = { initial: 0, moonbeamToPendulum: 40, moonbeamToPendulumXcm: 30, - morphoApprove: 24, + morphoApprove: 0, morphoDeposit: 30, mykoboOnrampDeposit: 5 * 60, mykoboPayoutOnBase: 60, diff --git a/apps/frontend/src/pages/progress/phaseMessages.ts b/apps/frontend/src/pages/progress/phaseMessages.ts index 9c28bebab..d6b1c7131 100644 --- a/apps/frontend/src/pages/progress/phaseMessages.ts +++ b/apps/frontend/src/pages/progress/phaseMessages.ts @@ -84,7 +84,7 @@ export function getMessageForPhase(ramp: RampState | undefined, t: TFunction<"tr initial: t("pages.progress.initial"), moonbeamToPendulum: getMoonbeamToPendulumMessage(), moonbeamToPendulumXcm: getMoonbeamToPendulumMessage(), - morphoApprove: t("pages.progress.morphoApprove"), + morphoApprove: "", morphoDeposit: t("pages.progress.morphoDeposit"), mykoboOnrampDeposit: t("pages.progress.mykoboOnrampDeposit"), mykoboPayoutOnBase: getTransferringMessage(), From 7623656b864164374ecf462f5ce52e1ba7141a0e Mon Sep 17 00:00:00 2001 From: Gianfranco Date: Thu, 11 Jun 2026 16:18:35 -0300 Subject: [PATCH 07/26] adjust dev command --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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", From 9eec84e5026a80b215973400cd26e2a1dab0203d Mon Sep 17 00:00:00 2001 From: Gianfranco Date: Thu, 11 Jun 2026 16:53:02 -0300 Subject: [PATCH 08/26] add bridge step to morpho demo flow --- .../phases/handlers/morpho-deposit-handler.ts | 2 +- .../phases/handlers/morpho-vault-config.ts | 6 +- .../squid-router-pay-phase-handler.ts | 23 ++- .../services/phases/ramp-flow-definitions.ts | 3 + .../quote/engines/fee/onramp-brl-to-evm.ts | 2 +- .../quote/engines/fee/onramp-mykobo-to-evm.ts | 2 +- .../onramp/routes/mykobo-to-evm-morpho.ts | 179 ++++++++++++++---- apps/frontend/src/pages/progress/index.tsx | 3 + .../frontend/src/pages/progress/phaseFlows.ts | 16 ++ packages/shared/src/helpers/signUnsigned.ts | 4 +- packages/shared/src/tokens/evm/config.ts | 27 ++- 11 files changed, 208 insertions(+), 59 deletions(-) diff --git a/apps/api/src/api/services/phases/handlers/morpho-deposit-handler.ts b/apps/api/src/api/services/phases/handlers/morpho-deposit-handler.ts index 841a0dae2..85542dc95 100644 --- a/apps/api/src/api/services/phases/handlers/morpho-deposit-handler.ts +++ b/apps/api/src/api/services/phases/handlers/morpho-deposit-handler.ts @@ -28,7 +28,7 @@ const morphoVaultAbi = [ } ] as const; -const MORPHO_NETWORK: EvmNetworks = Networks.Base; +const MORPHO_NETWORK: EvmNetworks = Networks.Ethereum; async function pollForShareBalance( evmClientManager: EvmClientManager, diff --git a/apps/api/src/api/services/phases/handlers/morpho-vault-config.ts b/apps/api/src/api/services/phases/handlers/morpho-vault-config.ts index 7f11fb46a..23727cd6c 100644 --- a/apps/api/src/api/services/phases/handlers/morpho-vault-config.ts +++ b/apps/api/src/api/services/phases/handlers/morpho-vault-config.ts @@ -8,10 +8,10 @@ export interface MorphoVaultInfo { } const MORPHO_VAULTS: Record = { - "usdc-base": { - depositAssetAddress: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", + "usdc-ethereum": { + depositAssetAddress: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", // USDC on Ethereum depositAssetDecimals: 6, - network: Networks.Base, + network: Networks.Ethereum, vaultAddress: "0xbeef0e0834849aCC03f0089F01f4F1Eeb06873C9" } }; 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 54ca90a46..236f2ffd7 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 @@ -232,10 +232,13 @@ 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 { @@ -282,10 +285,18 @@ 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.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); + } } } @@ -379,13 +390,15 @@ export class SquidRouterPayPhaseHandler extends BasePhaseHandler { 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; const toChainId = getNetworkId(toChain)?.toString(); diff --git a/apps/api/src/api/services/phases/ramp-flow-definitions.ts b/apps/api/src/api/services/phases/ramp-flow-definitions.ts index 8f08222be..b6a0d0e14 100644 --- a/apps/api/src/api/services/phases/ramp-flow-definitions.ts +++ b/apps/api/src/api/services/phases/ramp-flow-definitions.ts @@ -169,6 +169,9 @@ export const EUR_ONRAMP_BASE_MORPHO: RampPhase[] = [ "nablaSwap", "distributeFees", "subsidizePostSwap", + "squidRouterSwap", + "squidRouterPay", + "finalSettlementSubsidy", "morphoDeposit", "complete" ]; 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 f7fc311e0..15fdebae4 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 @@ -65,7 +65,7 @@ export class OnRampAveniaToEvmFeeEngine extends BaseFeeEngine { // No bridge needed, so skip the Squid route call (which would fail with "same token same chain") and report zero network fee. if ( (swapNetwork === toNetwork && fromTokenDetails.erc20AddressSourceChain.toLowerCase() === toToken.toLowerCase()) || - request.outputCurrency === EvmToken.MORPHO_VAULT + (request.outputCurrency === EvmToken.MORPHO_VAULT && swapNetwork === toNetwork) ) { return { anchor: { amount: computedAnchorFee, currency: anchorFeeCurrency }, 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 dc91ec69c..cf13c5216 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 @@ -59,7 +59,7 @@ export class OnRampMykoboToEvmFeeEngine extends BaseFeeEngine { if ( (swapNetwork === toNetwork && fromTokenDetails.erc20AddressSourceChain.toLowerCase() === toToken.toLowerCase()) || - request.outputCurrency === EvmToken.MORPHO_VAULT + (request.outputCurrency === EvmToken.MORPHO_VAULT && swapNetwork === toNetwork) ) { return { anchor: { amount: computedAnchorFee, currency: anchorFeeCurrency }, diff --git a/apps/api/src/api/services/transactions/onramp/routes/mykobo-to-evm-morpho.ts b/apps/api/src/api/services/transactions/onramp/routes/mykobo-to-evm-morpho.ts index f8a22535a..34bde066a 100644 --- a/apps/api/src/api/services/transactions/onramp/routes/mykobo-to-evm-morpho.ts +++ b/apps/api/src/api/services/transactions/onramp/routes/mykobo-to-evm-morpho.ts @@ -1,10 +1,13 @@ import { + createOnrampSquidrouterTransactionsFromBaseToEvm, + createOnrampSquidrouterTransactionsOnDestinationChain, EvmClientManager, EvmNetworks, EvmToken, EvmTokenDetails, EvmTransactionData, evmTokenConfig, + getOnChainTokenDetailsOrDefault, isEvmTokenDetails, multiplyByPowerOfTen, Networks, @@ -13,14 +16,14 @@ import { import Big from "big.js"; import { encodeFunctionData, erc20Abi } from "viem"; import logger from "../../../../../config/logger"; -import erc20ABI from "../../../../../contracts/ERC20"; +import { getEvmFundingAccount } from "../../../phases/evm-funding"; import { getMorphoVaultInfo } from "../../../phases/handlers/morpho-vault-config"; import { StateMetadata } from "../../../phases/meta-state-types"; import { EUR_ONRAMP_BASE_MORPHO } from "../../../phases/ramp-flow-definitions"; import { prepareBaseCleanupApproval } from "../../base/cleanup"; import { addEvmFeeDistributionTransaction } from "../../common/feeDistribution"; import { encodeEvmTransactionData } from "../../index"; -import { addNablaSwapTransactionsOnBase } from "../common/transactions"; +import { addDestinationChainApprovalTransaction, addNablaSwapTransactionsOnBase } from "../common/transactions"; import { MykoboOnrampTransactionParams, OnrampTransactionsWithMeta } from "../common/types"; import { validateMykoboOnramp } from "../common/validation"; @@ -38,10 +41,9 @@ const morphoVaultAbi = [ ] as const; /** - * Prepares all transactions for a Mykobo (EUR) onramp that deposits into a Morpho vault on Base. + * Prepares all transactions for a Mykobo (EUR) onramp that deposits into a Morpho vault on Ethereum. * - * Flow: user SEPA deposit → EURC on Base ephemeral → Nabla swap EURC→USDC → Morpho vault deposit. - * No SquidRouter bridge — the Morpho vault is on Base and accepts USDC. + * Flow: user SEPA deposit → EURC on Base ephemeral → Nabla swap EURC→USDC → SquidRouter to Ethereum (USDC) → Morpho vault deposit on Ethereum. */ export async function prepareMykoboToEvmMorphoOnrampTransactions({ quote, @@ -57,7 +59,7 @@ export async function prepareMykoboToEvmMorphoOnrampTransactions({ let stateMeta: Partial = {}; const unsignedTxs: UnsignedTx[] = []; - const { evmEphemeralEntry, inputTokenDetails } = validateMykoboOnramp(quote, signingAccounts); + const { toNetwork, outputTokenDetails, evmEphemeralEntry, inputTokenDetails } = validateMykoboOnramp(quote, signingAccounts); logger.debug(`Starting prepareMykoboToEvmMorphoOnrampTransactions with destinationAddress: ${destinationAddress}`); if (!isEvmTokenDetails(inputTokenDetails)) { @@ -68,8 +70,17 @@ export async function prepareMykoboToEvmMorphoOnrampTransactions({ throw new Error("Missing nablaSwapEvm.outputAmountRaw in quote metadata for Morpho onramp"); } - const morphoVault = getMorphoVaultInfo("usdc-base"); - const depositAmountRaw = quote.metadata.nablaSwapEvm.outputAmountRaw; + if (!quote.metadata.evmToEvm?.inputAmountRaw) { + throw new Error("Missing evmToEvm.inputAmountRaw in quote metadata for Morpho onramp"); + } + + if (!quote.metadata.evmToEvm?.outputAmountRaw) { + throw new Error("Missing evmToEvm.outputAmountRaw in quote metadata for Morpho onramp"); + } + + const morphoVault = getMorphoVaultInfo("usdc-ethereum"); + const bridgeInputAmountRaw = quote.metadata.evmToEvm.inputAmountRaw; + const depositAmountRaw = quote.metadata.evmToEvm.outputAmountRaw; stateMeta = { destinationAddress, @@ -93,6 +104,7 @@ export async function prepareMykoboToEvmMorphoOnrampTransactions({ } const eurcInputTokenAddress = (inputTokenDetails as EvmTokenDetails).erc20AddressSourceChain; + // 1. Nabla Swap: EURC -> USDC on Base const { nextNonce: nonceAfterNabla, stateMeta: nablaStateMeta } = await addNablaSwapTransactionsOnBase( { account: evmEphemeralEntry, @@ -106,10 +118,75 @@ export async function prepareMykoboToEvmMorphoOnrampTransactions({ stateMeta = { ...stateMeta, ...nablaStateMeta }; baseNonce = nonceAfterNabla; + // 2. Fee Distribution on Base baseNonce = await addEvmFeeDistributionTransaction(quote, evmEphemeralEntry, unsignedTxs, baseNonce); - const baseClient = EvmClientManager.getInstance().getClient(Networks.Base); - const { maxFeePerGas, maxPriorityFeePerGas } = await baseClient.estimateFeesPerGas(); + // 3. SquidRouter Swap: USDC on Base -> USDC on Ethereum + const { approveData, swapData, squidRouterQuoteId, squidRouterReceiverId, squidRouterReceiverHash } = + await createOnrampSquidrouterTransactionsFromBaseToEvm({ + destinationAddress: evmEphemeralEntry.address, + fromAddress: evmEphemeralEntry.address, + fromToken: nablaSwapOutputTokenAddress, + rawAmount: bridgeInputAmountRaw, + toNetwork: Networks.Ethereum, + toToken: morphoVault.depositAssetAddress // USDC on Ethereum + }); + + unsignedTxs.push({ + meta: {}, + network: Networks.Base, + nonce: baseNonce++, + phase: "squidRouterApprove", + signer: evmEphemeralEntry.address, + txData: encodeEvmTransactionData(approveData) as EvmTransactionData + }); + + unsignedTxs.push({ + meta: {}, + network: Networks.Base, + nonce: baseNonce++, + phase: "squidRouterSwap", + signer: evmEphemeralEntry.address, + txData: encodeEvmTransactionData(swapData) as EvmTransactionData + }); + + // 4. Base Cleanups + const baseFundingAccountAddress = getEvmFundingAccount(Networks.Base).address; + + const eurcCleanupApproval = await prepareBaseCleanupApproval( + eurcInputTokenAddress as `0x${string}`, + baseFundingAccountAddress, + Networks.Base + ); + unsignedTxs.push({ + meta: {}, + network: Networks.Base, + nonce: baseNonce++, + phase: "baseCleanupEurc", + signer: evmEphemeralEntry.address, + txData: encodeEvmTransactionData(eurcCleanupApproval) as EvmTransactionData + }); + + const usdcCleanupApproval = await prepareBaseCleanupApproval( + nablaSwapOutputTokenAddress as `0x${string}`, + baseFundingAccountAddress, + Networks.Base + ); + unsignedTxs.push({ + meta: {}, + network: Networks.Base, + nonce: baseNonce++, + phase: "baseCleanupUsdc", + signer: evmEphemeralEntry.address, + txData: encodeEvmTransactionData(usdcCleanupApproval) as EvmTransactionData + }); + + // 5. Destination chain (Ethereum) transactions + let destinationNonce = 0; + const destinationStartingNonce = destinationNonce; + + const ethereumClient = EvmClientManager.getInstance().getClient(Networks.Ethereum); + const { maxFeePerGas, maxPriorityFeePerGas } = await ethereumClient.estimateFeesPerGas(); // Morpho approval: approve vault to spend the deposit asset (USDC) const approveCallData = encodeFunctionData({ @@ -120,8 +197,8 @@ export async function prepareMykoboToEvmMorphoOnrampTransactions({ unsignedTxs.push({ meta: {}, - network: Networks.Base, - nonce: baseNonce++, + network: Networks.Ethereum, + nonce: destinationNonce++, phase: "morphoApprove", signer: evmEphemeralEntry.address, txData: { @@ -134,7 +211,7 @@ export async function prepareMykoboToEvmMorphoOnrampTransactions({ } as EvmTransactionData }); - // Morpho deposit: deposit USDC into the vault + // Morpho deposit: deposit USDC into the vault on Ethereum const depositCallData = encodeFunctionData({ abi: morphoVaultAbi, args: [BigInt(depositAmountRaw), destinationAddress as `0x${string}`], @@ -143,8 +220,8 @@ export async function prepareMykoboToEvmMorphoOnrampTransactions({ unsignedTxs.push({ meta: {}, - network: Networks.Base, - nonce: baseNonce++, + network: Networks.Ethereum, + nonce: destinationNonce++, phase: "morphoDeposit", signer: evmEphemeralEntry.address, txData: { @@ -157,40 +234,66 @@ export async function prepareMykoboToEvmMorphoOnrampTransactions({ } as EvmTransactionData }); - // Cleanup approvals for post-complete token recovery - const baseFundingAccountAddress = ( - await import("../../../phases/evm-funding").then(m => m.getEvmFundingAccount(Networks.Base)) - ).address; + // 6. Backup transactions on Ethereum (in case axlUSDC lands instead of USDC) + const destinationAxlUsdcDetails = getOnChainTokenDetailsOrDefault(Networks.Ethereum, EvmToken.AXLUSDC) as EvmTokenDetails; + const bridgedTokenForFallback = destinationAxlUsdcDetails.erc20AddressSourceChain as `0x${string}`; + // TODO triple-check, if squidrouter fails, does it deposit axlUSDC on destination as well for the Ethereum case? + // or is this destination different ? + + const { approveData: finalApproveData, swapData: finalSwapData } = + await createOnrampSquidrouterTransactionsOnDestinationChain({ + destinationAddress: evmEphemeralEntry.address, + fromAddress: evmEphemeralEntry.address, + fromToken: bridgedTokenForFallback, + network: Networks.Ethereum, + rawAmount: bridgeInputAmountRaw, + toToken: morphoVault.depositAssetAddress // swap to USDC + }); - const eurcCleanupApproval = await prepareBaseCleanupApproval( - eurcInputTokenAddress as `0x${string}`, - baseFundingAccountAddress, - Networks.Base - ); unsignedTxs.push({ meta: {}, - network: Networks.Base, - nonce: baseNonce++, - phase: "baseCleanupEurc", + network: Networks.Ethereum, + nonce: destinationNonce++, + phase: "backupSquidRouterApprove", signer: evmEphemeralEntry.address, - txData: encodeEvmTransactionData(eurcCleanupApproval) as EvmTransactionData + txData: encodeEvmTransactionData(finalApproveData) as EvmTransactionData }); - const usdcCleanupApproval = await prepareBaseCleanupApproval( - nablaSwapOutputTokenAddress as `0x${string}`, - baseFundingAccountAddress, - Networks.Base - ); unsignedTxs.push({ meta: {}, - network: Networks.Base, - nonce: baseNonce++, - phase: "baseCleanupUsdc", + network: Networks.Ethereum, + nonce: destinationNonce++, + phase: "backupSquidRouterSwap", signer: evmEphemeralEntry.address, - txData: encodeEvmTransactionData(usdcCleanupApproval) as EvmTransactionData + txData: encodeEvmTransactionData(finalSwapData) as EvmTransactionData }); - stateMeta = { ...stateMeta, phaseFlow: EUR_ONRAMP_BASE_MORPHO }; + const fundingAccount = getEvmFundingAccount(Networks.Base); + const backupApproveAmountRaw = new Big(bridgeInputAmountRaw).mul("1.05").toFixed(0, 0); + + const backupApproveTransaction = await addDestinationChainApprovalTransaction({ + amountRaw: backupApproveAmountRaw, + destinationNetwork: Networks.Ethereum, + spenderAddress: fundingAccount.address, + tokenAddress: bridgedTokenForFallback + }); + + unsignedTxs.push({ + meta: {}, + network: Networks.Ethereum, + nonce: destinationStartingNonce, + phase: "backupApprove", + signer: evmEphemeralEntry.address, + txData: backupApproveTransaction + }); + + stateMeta = { + ...stateMeta, + phaseFlow: EUR_ONRAMP_BASE_MORPHO, + squidRouterQuoteId, + squidRouterReceiverHash, + squidRouterReceiverId + }; return { stateMeta, unsignedTxs }; } diff --git a/apps/frontend/src/pages/progress/index.tsx b/apps/frontend/src/pages/progress/index.tsx index 9695d33be..c4287539f 100644 --- a/apps/frontend/src/pages/progress/index.tsx +++ b/apps/frontend/src/pages/progress/index.tsx @@ -26,6 +26,9 @@ function getRampFlow(rampState: RampState | undefined): keyof typeof PHASE_FLOWS if (rampState.quote?.inputCurrency === FiatToken.BRL) { return "onramp_brl"; } + if (rampState.quote?.outputCurrency === "MORPHO VAULT") { + return "onramp_eur_morpho"; + } return "onramp_eur_evm"; } diff --git a/apps/frontend/src/pages/progress/phaseFlows.ts b/apps/frontend/src/pages/progress/phaseFlows.ts index ee729bcc8..3a8ea24f3 100644 --- a/apps/frontend/src/pages/progress/phaseFlows.ts +++ b/apps/frontend/src/pages/progress/phaseFlows.ts @@ -100,5 +100,21 @@ export const PHASE_FLOWS = { "distributeFees", "destinationTransfer", "complete" + ] as RampPhase[], + + onramp_eur_morpho: [ + "initial", + "mykoboOnrampDeposit", + "fundEphemeral", + "subsidizePreSwap", + "nablaApprove", + "nablaSwap", + "distributeFees", + "subsidizePostSwap", + "squidRouterSwap", + "squidRouterPay", + "finalSettlementSubsidy", + "morphoDeposit", + "complete" ] as RampPhase[] }; diff --git a/packages/shared/src/helpers/signUnsigned.ts b/packages/shared/src/helpers/signUnsigned.ts index 229e276f1..3e6ec7de9 100644 --- a/packages/shared/src/helpers/signUnsigned.ts +++ b/packages/shared/src/helpers/signUnsigned.ts @@ -216,7 +216,9 @@ export async function signUnsignedTransactions( (tx.phase === "destinationTransfer" || tx.phase === "backupSquidRouterApprove" || tx.phase === "backupSquidRouterSwap" || - tx.phase === "backupApprove") && + tx.phase === "backupApprove" || + tx.phase === "morphoApprove" || + tx.phase === "morphoDeposit") && tx.network !== Networks.Polygon && tx.network !== Networks.PolygonAmoy && tx.network !== Networks.Base diff --git a/packages/shared/src/tokens/evm/config.ts b/packages/shared/src/tokens/evm/config.ts index 1fe3cf276..cd4101024 100644 --- a/packages/shared/src/tokens/evm/config.ts +++ b/packages/shared/src/tokens/evm/config.ts @@ -36,6 +36,24 @@ export const evmTokenConfig: Record Date: Thu, 11 Jun 2026 17:14:37 -0300 Subject: [PATCH 09/26] adjust quote service logic --- apps/api/src/api/services/quote/index.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/apps/api/src/api/services/quote/index.ts b/apps/api/src/api/services/quote/index.ts index 402a6a950..a51c01386 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, @@ -148,7 +149,11 @@ export class QuoteService extends BaseRampService { ): Promise { validateChainSupport(request.rampType, request.from, request.to); - if (request.rampType === RampDirection.BUY && request.to === Networks.Ethereum) { + if ( + request.rampType === RampDirection.BUY && + request.to === Networks.Ethereum && + request.outputCurrency !== EvmToken.MORPHO_VAULT + ) { throw new APIError({ message: QuoteError.FailedToCalculateQuote, status: httpStatus.INTERNAL_SERVER_ERROR }); } From 92ff6d12d94a0fec383daa6939521f861bda7e4a Mon Sep 17 00:00:00 2001 From: Gianfranco Date: Thu, 11 Jun 2026 17:53:05 -0300 Subject: [PATCH 10/26] test flow --- .../onramp/routes/mykobo-to-evm-morpho.ts | 55 +------------------ packages/sdk/src/services/NetworkManager.ts | 2 +- .../src/services/pendulum/apiManager.ts | 2 +- packages/shared/src/tokens/utils/helpers.ts | 8 --- 4 files changed, 5 insertions(+), 62 deletions(-) diff --git a/apps/api/src/api/services/transactions/onramp/routes/mykobo-to-evm-morpho.ts b/apps/api/src/api/services/transactions/onramp/routes/mykobo-to-evm-morpho.ts index 34bde066a..18aa91a7a 100644 --- a/apps/api/src/api/services/transactions/onramp/routes/mykobo-to-evm-morpho.ts +++ b/apps/api/src/api/services/transactions/onramp/routes/mykobo-to-evm-morpho.ts @@ -234,58 +234,9 @@ export async function prepareMykoboToEvmMorphoOnrampTransactions({ } as EvmTransactionData }); - // 6. Backup transactions on Ethereum (in case axlUSDC lands instead of USDC) - const destinationAxlUsdcDetails = getOnChainTokenDetailsOrDefault(Networks.Ethereum, EvmToken.AXLUSDC) as EvmTokenDetails; - const bridgedTokenForFallback = destinationAxlUsdcDetails.erc20AddressSourceChain as `0x${string}`; - // TODO triple-check, if squidrouter fails, does it deposit axlUSDC on destination as well for the Ethereum case? - // or is this destination different ? - - const { approveData: finalApproveData, swapData: finalSwapData } = - await createOnrampSquidrouterTransactionsOnDestinationChain({ - destinationAddress: evmEphemeralEntry.address, - fromAddress: evmEphemeralEntry.address, - fromToken: bridgedTokenForFallback, - network: Networks.Ethereum, - rawAmount: bridgeInputAmountRaw, - toToken: morphoVault.depositAssetAddress // swap to USDC - }); - - unsignedTxs.push({ - meta: {}, - network: Networks.Ethereum, - nonce: destinationNonce++, - phase: "backupSquidRouterApprove", - signer: evmEphemeralEntry.address, - txData: encodeEvmTransactionData(finalApproveData) as EvmTransactionData - }); - - unsignedTxs.push({ - meta: {}, - network: Networks.Ethereum, - nonce: destinationNonce++, - phase: "backupSquidRouterSwap", - signer: evmEphemeralEntry.address, - txData: encodeEvmTransactionData(finalSwapData) as EvmTransactionData - }); - - const fundingAccount = getEvmFundingAccount(Networks.Base); - const backupApproveAmountRaw = new Big(bridgeInputAmountRaw).mul("1.05").toFixed(0, 0); - - const backupApproveTransaction = await addDestinationChainApprovalTransaction({ - amountRaw: backupApproveAmountRaw, - destinationNetwork: Networks.Ethereum, - spenderAddress: fundingAccount.address, - tokenAddress: bridgedTokenForFallback - }); - - unsignedTxs.push({ - meta: {}, - network: Networks.Ethereum, - nonce: destinationStartingNonce, - phase: "backupApprove", - signer: evmEphemeralEntry.address, - txData: backupApproveTransaction - }); + // 6. Backup transactions + // No backup swap transactions are needed on Ethereum because SquidRouter bridges USDC + // directly as native USDC, which is already the deposit asset for the Morpho vault. stateMeta = { ...stateMeta, 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/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/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; From bb2fd7d1443aeb73997e7998c91b89b58c5d6e4d Mon Sep 17 00:00:00 2001 From: Gianfranco Date: Fri, 12 Jun 2026 18:24:03 -0300 Subject: [PATCH 11/26] add morpho offramp changes --- .../api/services/phases/handlers/helpers.ts | 2 +- .../handlers/morpho-permit-execute-handler.ts | 175 +++++++ .../phases/handlers/morpho-redeem-handler.ts | 189 ++++++++ .../phases/handlers/morpho-vault-config.ts | 2 + .../api/services/phases/meta-state-types.ts | 10 + .../services/phases/ramp-flow-definitions.ts | 24 + .../api/services/phases/register-handlers.ts | 4 + apps/api/src/api/services/quote/core/types.ts | 7 + .../initialize/offramp-from-evm-morpho.ts | 84 ++++ .../services/quote/routes/route-resolver.ts | 5 + .../offramp-to-sepa-evm-morpho.strategy.ts | 30 ++ .../services/transactions/offramp/index.ts | 6 + .../offramp/routes/evm-to-alfredpay.ts | 2 +- .../offramp/routes/evm-to-mykobo-morpho.ts | 440 ++++++++++++++++++ .../api/services/transactions/validation.ts | 3 + apps/frontend/src/pages/progress/index.tsx | 3 + .../frontend/src/pages/progress/phaseFlows.ts | 18 + .../src/pages/progress/phaseMessages.ts | 2 + apps/frontend/src/translations/en.json | 2 + .../shared/src/endpoints/ramp.endpoints.ts | 2 + 20 files changed, 1008 insertions(+), 2 deletions(-) create mode 100644 apps/api/src/api/services/phases/handlers/morpho-permit-execute-handler.ts create mode 100644 apps/api/src/api/services/phases/handlers/morpho-redeem-handler.ts create mode 100644 apps/api/src/api/services/quote/engines/initialize/offramp-from-evm-morpho.ts create mode 100644 apps/api/src/api/services/quote/routes/strategies/offramp-to-sepa-evm-morpho.strategy.ts create mode 100644 apps/api/src/api/services/transactions/offramp/routes/evm-to-mykobo-morpho.ts diff --git a/apps/api/src/api/services/phases/handlers/helpers.ts b/apps/api/src/api/services/phases/handlers/helpers.ts index f745eed0d..01344dfc9 100644 --- a/apps/api/src/api/services/phases/handlers/helpers.ts +++ b/apps/api/src/api/services/phases/handlers/helpers.ts @@ -58,7 +58,7 @@ 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.Ethereum]: "0.005", [VortexNetworks.Arbitrum]: "0.000045", [VortexNetworks.Base]: "0.000034", [VortexNetworks.Polygon]: "0.6", diff --git a/apps/api/src/api/services/phases/handlers/morpho-permit-execute-handler.ts b/apps/api/src/api/services/phases/handlers/morpho-permit-execute-handler.ts new file mode 100644 index 000000000..d696170ee --- /dev/null +++ b/apps/api/src/api/services/phases/handlers/morpho-permit-execute-handler.ts @@ -0,0 +1,175 @@ +import { EvmClientManager, EvmNetworks, isSignedTypedData, Networks, RampPhase, SignedTypedData } from "@vortexfi/shared"; +import { privateKeyToAccount } from "viem/accounts"; +import logger from "../../../../config/logger"; +import { config } from "../../../../config/vars"; +import RampState from "../../../../models/rampState.model"; +import { BasePhaseHandler } from "../base-phase-handler"; +import { StateMetadata } from "../meta-state-types"; + +const permitAbi = [ + { + inputs: [ + { name: "owner", type: "address" }, + { name: "spender", type: "address" }, + { name: "value", type: "uint256" }, + { name: "deadline", type: "uint256" }, + { name: "v", type: "uint8" }, + { name: "r", type: "bytes32" }, + { name: "s", type: "bytes32" } + ], + name: "permit", + outputs: [], + stateMutability: "nonpayable", + type: "function" + } +] as const; + +type VrsSignature = { v: number; r: `0x${string}`; s: `0x${string}` }; + +const ETHEREUM_NETWORK: EvmNetworks = Networks.Ethereum; + +/** + * Phase description: + * The user signs a single EIP-2612 Permit typed data on the Morpho vault (spender = ephemeral). + * Two presignedTxs are registered under phase "morphoPermitExecute": + * 1. SignedTypedData entry signed by the user (nonce 0, signer = userAddress) + * 2. Raw ERC-20 transferFrom tx signed by the EVM ephemeral (nonce 0, signer = evmEphemeral) + * This handler: + * a. Reads the SignedTypedData, verifies signature, and broadcasts vault.permit() using + * the executor key. permit() does not require the spender to be the caller, so the + * executor (which has gas at this point) can submit it. + * b. Waits for the permit receipt. + * c. Broadcasts the presigned transferFrom tx (the spender, the ephemeral, is the only + * one who can call transferFrom on the allowance it was just granted). + * d. Waits for the transferFrom receipt. + * After both txs land, the MorphoRedeemHandler can call vault.redeem on the ephemeral. + */ +export class MorphoPermitExecuteHandler extends BasePhaseHandler { + private evmClientManager: EvmClientManager; + + constructor() { + super(); + this.evmClientManager = EvmClientManager.getInstance(); + } + + public getPhaseName(): RampPhase { + return "morphoPermitExecute"; + } + + private getExecutorWallet(network: EvmNetworks) { + const account = privateKeyToAccount(config.secrets.moonbeamExecutorPrivateKey as `0x${string}`); + return { + publicClient: this.evmClientManager.getClient(network), + walletClient: this.evmClientManager.getWalletClient(network, account) + }; + } + + private async waitForReceipt(network: EvmNetworks, hash: `0x${string}`, label: string): Promise { + const { publicClient } = this.getExecutorWallet(network); + const receipt = await publicClient.waitForTransactionReceipt({ hash }); + if (!receipt || receipt.status !== "success") { + throw this.createRecoverableError(`${label} tx failed: ${hash}`); + } + } + + protected async executePhase(state: RampState): Promise { + logger.info(`Executing morphoPermitExecute phase for ramp ${state.id}`); + + const meta = state.state as StateMetadata; + const network = ETHEREUM_NETWORK; + + if (!meta.evmEphemeralAddress) { + throw this.createUnrecoverableError("Missing evmEphemeralAddress in state metadata"); + } + + const ephemeralAddress = meta.evmEphemeralAddress as `0x${string}`; + + if (meta.morphoPermitTxHash && meta.morphoPermitTransferFromTxHash) { + logger.info(`MorphoPermitExecuteHandler: Both permit and transferFrom already broadcast for ramp ${state.id}, verifying`); + const publicClient = this.evmClientManager.getClient(network); + for (const [label, hash] of [ + ["permit", meta.morphoPermitTxHash], + ["transferFrom", meta.morphoPermitTransferFromTxHash] + ] as const) { + const receipt = await publicClient.getTransactionReceipt({ hash }); + if (!receipt || receipt.status !== "success") { + throw this.createRecoverableError(`MorphoPermitExecuteHandler: existing ${label} tx ${hash} is not successful`); + } + } + return state; + } + + // Find both presigned entries for this phase + const allPresigned = state.presignedTxs?.filter(tx => tx.phase === "morphoPermitExecute") ?? []; + const permitPresigned = allPresigned.find(tx => tx.signer.toLowerCase() === state.state.walletAddress?.toLowerCase()); + const transferFromPresigned = allPresigned.find(tx => tx.signer.toLowerCase() === ephemeralAddress.toLowerCase()); + + if (!permitPresigned) { + throw this.createUnrecoverableError("Missing user-signed permit presignedTx for morphoPermitExecute"); + } + if (!transferFromPresigned) { + throw this.createUnrecoverableError("Missing ephemeral-signed transferFrom presignedTx for morphoPermitExecute"); + } + + // ── Step 1: broadcast vault.permit via executor ── + if (!meta.morphoPermitTxHash) { + const permitData = permitPresigned.txData; + if (!isSignedTypedData(permitData)) { + throw this.createUnrecoverableError("morphoPermitExecute: user presignedTx is not a SignedTypedData"); + } + const permitTypedData: SignedTypedData = permitData; + const sig = permitTypedData.signature as VrsSignature | undefined; + if (!sig) { + throw this.createUnrecoverableError("morphoPermitExecute: permit signature missing from user signed typed data"); + } + const token = permitTypedData.domain.verifyingContract as `0x${string}`; + const owner = permitTypedData.message.owner as `0x${string}`; + const value = BigInt(permitTypedData.message.value as string); + const deadline = BigInt(permitTypedData.message.deadline as string); + + if (deadline * 1000n < BigInt(Date.now())) { + throw this.createUnrecoverableError("Permit deadline already passed;"); + } + + const { walletClient } = this.getExecutorWallet(network); + const permitHash = await walletClient.writeContract({ + abi: permitAbi, + address: token, + args: [owner, ephemeralAddress, value, deadline, sig.v, sig.r, sig.s], + functionName: "permit" + }); + logger.info(`MorphoPermitExecuteHandler: Permit tx sent: ${permitHash}`); + + state = await state.update({ state: { ...state.state, morphoPermitTxHash: permitHash } }); + await this.waitForReceipt(network, permitHash, "Permit"); + } else { + logger.info(`MorphoPermitExecuteHandler: Permit already broadcast (${meta.morphoPermitTxHash})`); + } + + // TODO be smart with rpc state sync, read and await for allowance of ephemeral to be updated. + + // ── Step 2: broadcast ephemeral-signed transferFrom ── + if (!meta.morphoPermitTransferFromTxHash) { + const signedTxHex = transferFromPresigned.txData as string; + const txHash = (await this.evmClientManager.sendRawTransactionWithRetry( + network, + signedTxHex as `0x${string}` + )) as `0x${string}`; + logger.info(`MorphoPermitExecuteHandler: transferFrom tx broadcast: ${txHash}`); + + const publicClient = this.evmClientManager.getClient(network); + const receipt = await publicClient.waitForTransactionReceipt({ hash: txHash }); + if (!receipt || receipt.status !== "success") { + throw this.createRecoverableError(`MorphoPermitExecuteHandler: transferFrom tx ${txHash} failed on chain`); + } + state = await state.update({ state: { ...state.state, morphoPermitTransferFromTxHash: txHash } }); + } else { + logger.info(`MorphoPermitExecuteHandler: transferFrom already broadcast (${meta.morphoPermitTransferFromTxHash})`); + } + + logger.info(`MorphoPermitExecuteHandler: Permit + transferFrom confirmed for ramp ${state.id}`); + return state; + } +} + +export default new MorphoPermitExecuteHandler(); diff --git a/apps/api/src/api/services/phases/handlers/morpho-redeem-handler.ts b/apps/api/src/api/services/phases/handlers/morpho-redeem-handler.ts new file mode 100644 index 000000000..9d619e4c2 --- /dev/null +++ b/apps/api/src/api/services/phases/handlers/morpho-redeem-handler.ts @@ -0,0 +1,189 @@ +import { EvmClientManager, EvmNetworks, Networks, RampPhase } from "@vortexfi/shared"; +import { erc20Abi } from "viem"; +import logger from "../../../../config/logger"; +import RampState from "../../../../models/rampState.model"; +import { BasePhaseHandler } from "../base-phase-handler"; +import { StateMetadata } from "../meta-state-types"; + +const EVM_BALANCE_CHECK_TIMEOUT_MS = 3 * 60 * 1000; +const BALANCE_POLLING_INTERVAL_MS = 5000; + +const vaultRedeemAbi = [ + { + inputs: [ + { name: "shares", type: "uint256" }, + { name: "receiver", type: "address" }, + { name: "owner", type: "address" } + ], + name: "redeem", + outputs: [{ name: "assets", type: "uint256" }], + stateMutability: "nonpayable", + type: "function" + }, + { + inputs: [{ name: "shares", type: "uint256" }], + name: "previewRedeem", + outputs: [{ name: "assets", type: "uint256" }], + stateMutability: "view", + type: "function" + } +] as const; + +const ETHEREUM_NETWORK: EvmNetworks = Networks.Ethereum; + +/** + * Phase description: + * Broadcasts the presigned vault.redeem(shares, ephemeral, ephemeral) tx on Ethereum. + * Defends against bad output with three layers: + * 1. Pre-flight: read previewRedeem(shares) before broadcast; abort if below minOutputRaw. + * 2. Event parse: parse the Withdraw event from the receipt; assert event.assets >= minOutputRaw. + * 3. Balance poll: as a final fallback, poll USDC.balanceOf(ephemeral) until >= minOutputRaw. + */ +export class MorphoRedeemHandler extends BasePhaseHandler { + private evmClientManager: EvmClientManager; + + constructor() { + super(); + this.evmClientManager = EvmClientManager.getInstance(); + } + + public getPhaseName(): RampPhase { + return "morphoRedeem"; + } + + public getMaxRetries(): number { + return 5; + } + + protected async executePhase(state: RampState): Promise { + const meta = state.state as StateMetadata; + + if (!meta.morphoRedeemVaultAddress) { + throw this.createUnrecoverableError("Missing morphoRedeemVaultAddress in state metadata"); + } + if (!meta.morphoRedeemAssetAddress) { + throw this.createUnrecoverableError("Missing morphoRedeemAssetAddress in state metadata"); + } + if (!meta.evmEphemeralAddress) { + throw this.createUnrecoverableError("Missing evmEphemeralAddress in state metadata"); + } + if (!meta.morphoRedeemSharesAmountRaw) { + throw this.createUnrecoverableError("Missing morphoRedeemSharesAmountRaw in state metadata"); + } + + const vaultAddress = meta.morphoRedeemVaultAddress as `0x${string}`; + const assetAddress = meta.morphoRedeemAssetAddress as `0x${string}`; + const ephemeralAddress = meta.evmEphemeralAddress as `0x${string}`; + const sharesAmount = BigInt(meta.morphoRedeemSharesAmountRaw); + const minOutputRaw = BigInt(meta.morphoRedeemMinOutputRaw || "0"); + + // Recovery: if we already broadcast the redeem, verify the outcome. + if (meta.morphoRedeemTxHash) { + logger.info(`MorphoRedeemHandler: Redeem tx already broadcast (${meta.morphoRedeemTxHash}), verifying USDC balance`); + await this.verifyUsdcBalance(assetAddress, ephemeralAddress, minOutputRaw); + return state; + } + + // Layer 1: pre-flight previewRedeem. If the on-chain rate has drifted past tolerance before + // we even broadcast, abort without spending gas on a doomed redeem. + const publicClient = this.evmClientManager.getClient(ETHEREUM_NETWORK); + const previewAssets = (await publicClient.readContract({ + abi: vaultRedeemAbi, + address: vaultAddress, + args: [sharesAmount], + functionName: "previewRedeem" + })) as bigint; + + if (previewAssets < minOutputRaw) { + throw this.createRecoverableError( + `MorphoRedeemHandler: previewRedeem=${previewAssets} below minOutputRaw=${minOutputRaw}; aborting before broadcast` + ); + } + + const presigned = this.getPresignedTransaction(state, "morphoRedeem"); + if (!presigned) { + throw this.createUnrecoverableError("Missing presigned transaction for morphoRedeem phase"); + } + + const txHash = (await this.evmClientManager.sendRawTransactionWithRetry( + ETHEREUM_NETWORK, + presigned.txData as `0x${string}` + )) as `0x${string}`; + logger.info(`MorphoRedeemHandler: Redeem tx broadcast: ${txHash}`); + + state = await state.update({ + state: { ...state.state, morphoRedeemTxHash: txHash } + }); + + const receipt = await publicClient.waitForTransactionReceipt({ hash: txHash }); + if (!receipt || receipt.status !== "success") { + throw this.createRecoverableError(`MorphoRedeemHandler: Redeem transaction ${txHash} failed on chain`); + } + + // Layer 2: parse the Withdraw event. ERC-4626 Withdraw event signature is + // Withdraw(address indexed sender, address indexed receiver, address indexed owner, uint256 assets, uint256 shares) + // TODO this is either a good idea, or a bad one if this signature can change. + const withdrawEventSig = "0xfbde797d201c681b91056529119e0b02407c7bb96a4a2c75c01fc9667232c8db"; + let parsedAssets: bigint | null = null; + for (const log of receipt.logs) { + if (log.address.toLowerCase() !== vaultAddress.toLowerCase()) continue; + if (log.topics[0]?.toLowerCase() !== withdrawEventSig) continue; + if (log.data.length >= 66) { + // data = abi.encode(uint256 assets, uint256 shares) => 32 + 32 bytes + parsedAssets = BigInt(log.data.slice(0, 66)); + break; + } + } + if (parsedAssets !== null) { + if (parsedAssets < minOutputRaw) { + throw this.createRecoverableError( + `MorphoRedeemHandler: Withdraw event assets=${parsedAssets} below minOutputRaw=${minOutputRaw}` + ); + } + logger.info(`MorphoRedeemHandler: Withdraw event assets=${parsedAssets} >= minOutputRaw=${minOutputRaw}`); + const updated = await state.update({ + state: { ...state.state, morphoRedeemActualOutputRaw: parsedAssets.toString() } + }); + return updated; + } + logger.info( + "MorphoRedeemHandler: No Withdraw event found in receipt (non-standard vault); falling back to balance polling" + ); + + // Layer 3: balance polling fallback + await this.verifyUsdcBalance(assetAddress, ephemeralAddress, minOutputRaw); + return state; + } + + private async verifyUsdcBalance( + assetAddress: `0x${string}`, + ephemeralAddress: `0x${string}`, + minOutputRaw: bigint + ): Promise { + const client = this.evmClientManager.getClient(ETHEREUM_NETWORK); + const deadline = Date.now() + EVM_BALANCE_CHECK_TIMEOUT_MS; + let lastBalance = 0n; + + while (Date.now() < deadline) { + lastBalance = (await client.readContract({ + abi: erc20Abi, + address: assetAddress, + args: [ephemeralAddress], + functionName: "balanceOf" + })) as bigint; + + if (lastBalance >= minOutputRaw) { + logger.info(`MorphoRedeemHandler: USDC balance verified: ${lastBalance} >= ${minOutputRaw}`); + return; + } + + await new Promise(resolve => setTimeout(resolve, BALANCE_POLLING_INTERVAL_MS)); + } + + throw this.createRecoverableError( + `MorphoRedeemHandler: Timed out waiting for USDC balance >= ${minOutputRaw} on ${ephemeralAddress} (last seen: ${lastBalance})` + ); + } +} + +export default new MorphoRedeemHandler(); diff --git a/apps/api/src/api/services/phases/handlers/morpho-vault-config.ts b/apps/api/src/api/services/phases/handlers/morpho-vault-config.ts index 23727cd6c..3b65b7bba 100644 --- a/apps/api/src/api/services/phases/handlers/morpho-vault-config.ts +++ b/apps/api/src/api/services/phases/handlers/morpho-vault-config.ts @@ -4,6 +4,7 @@ export interface MorphoVaultInfo { vaultAddress: `0x${string}`; depositAssetAddress: `0x${string}`; depositAssetDecimals: number; + shareDecimals: number; network: Networks; } @@ -12,6 +13,7 @@ const MORPHO_VAULTS: Record = { depositAssetAddress: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", // USDC on Ethereum depositAssetDecimals: 6, network: Networks.Ethereum, + shareDecimals: 18, // MetaMorpho default vaultAddress: "0xbeef0e0834849aCC03f0089F01f4F1Eeb06873C9" } }; 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 4cdaed38d..9be7b783d 100644 --- a/apps/api/src/api/services/phases/meta-state-types.ts +++ b/apps/api/src/api/services/phases/meta-state-types.ts @@ -86,4 +86,14 @@ export interface StateMetadata { morphoDepositAssetAddress?: string; morphoDepositAmountRaw?: string; morphoDepositTxHash?: `0x${string}`; + // Morpho vault redeem (offramp) + morphoRedeemVaultAddress?: string; + morphoRedeemShareTokenAddress?: string; + morphoRedeemAssetAddress?: string; + morphoRedeemSharesAmountRaw?: string; + morphoRedeemMinOutputRaw?: string; + morphoRedeemTxHash?: `0x${string}`; + morphoRedeemActualOutputRaw?: string; + morphoPermitTxHash?: `0x${string}`; + morphoPermitTransferFromTxHash?: `0x${string}`; } diff --git a/apps/api/src/api/services/phases/ramp-flow-definitions.ts b/apps/api/src/api/services/phases/ramp-flow-definitions.ts index b6a0d0e14..6a7d266cb 100644 --- a/apps/api/src/api/services/phases/ramp-flow-definitions.ts +++ b/apps/api/src/api/services/phases/ramp-flow-definitions.ts @@ -175,3 +175,27 @@ export const EUR_ONRAMP_BASE_MORPHO: RampPhase[] = [ "morphoDeposit", "complete" ]; + +// ─── Morpho Vault Redeem Off-Ramp (Morpho Vault on Ethereum → Mykobo SEPA on Base) ──── + +/** + * EUR offramp from Morpho vault shares on Ethereum → SEPA payout via Mykobo. + * User signs a single EIP-2612 permit; ephemeral broadcasts permit+transferFrom, + * redeems shares to USDC on Ethereum, bridges USDC to Base, then the existing + * Mykobo payout leg (Nabla USDC→EURC + EURC→Mykobo) executes on Base. + */ +export const EUR_OFFRAMP_MORPHO: RampPhase[] = [ + "initial", + "morphoPermitExecute", + "morphoRedeem", + "squidRouterApprove", + "squidRouterSwap", + "fundEphemeral", + "distributeFees", + "subsidizePreSwap", + "nablaApprove", + "nablaSwap", + "subsidizePostSwap", + "mykoboPayoutOnBase", + "complete" +]; diff --git a/apps/api/src/api/services/phases/register-handlers.ts b/apps/api/src/api/services/phases/register-handlers.ts index a39ab7986..ab6b17a84 100644 --- a/apps/api/src/api/services/phases/register-handlers.ts +++ b/apps/api/src/api/services/phases/register-handlers.ts @@ -13,6 +13,8 @@ import initialPhaseHandler from "./handlers/initial-phase-handler"; import moonbeamToPendulumPhaseHandler from "./handlers/moonbeam-to-pendulum-handler"; import moonbeamToPendulumXcmHandler from "./handlers/moonbeam-to-pendulum-xcm-handler"; import morphoDepositHandler from "./handlers/morpho-deposit-handler"; +import morphoPermitExecuteHandler from "./handlers/morpho-permit-execute-handler"; +import morphoRedeemHandler from "./handlers/morpho-redeem-handler"; import mykoboOnrampDepositHandler from "./handlers/mykobo-onramp-deposit-handler"; import mykoboPayoutHandler from "./handlers/mykobo-payout-handler"; import nablaApproveHandler from "./handlers/nabla-approve-handler"; @@ -60,6 +62,8 @@ export function registerPhaseHandlers(): void { phaseRegistry.registerHandler(destinationTransferHandler); phaseRegistry.registerHandler(squidRouterPermitExecutionHandler); phaseRegistry.registerHandler(morphoDepositHandler); + phaseRegistry.registerHandler(morphoPermitExecuteHandler); + phaseRegistry.registerHandler(morphoRedeemHandler); logger.info("Phase handlers registered"); } diff --git a/apps/api/src/api/services/quote/core/types.ts b/apps/api/src/api/services/quote/core/types.ts index 523ed37d4..597ce7f5e 100644 --- a/apps/api/src/api/services/quote/core/types.ts +++ b/apps/api/src/api/services/quote/core/types.ts @@ -203,6 +203,13 @@ export interface QuoteContext { evmToEvm?: BridgeMeta; + // Morpho vault redeem metadata (offramp only). Set by OffRampFromEvmInitializeMorphoEngine. + redeemMeta?: { + sharesAmountRaw: string; + vaultAddress: string; + expectedUsdcRaw: string; + }; + evmToMoonbeam?: BridgeMeta; hydrationToAssethubXcm?: XcmMeta; diff --git a/apps/api/src/api/services/quote/engines/initialize/offramp-from-evm-morpho.ts b/apps/api/src/api/services/quote/engines/initialize/offramp-from-evm-morpho.ts new file mode 100644 index 000000000..d364316a3 --- /dev/null +++ b/apps/api/src/api/services/quote/engines/initialize/offramp-from-evm-morpho.ts @@ -0,0 +1,84 @@ +import { EvmClientManager, EvmToken, Networks, RampDirection } from "@vortexfi/shared"; +import Big from "big.js"; +import { getMorphoVaultInfo } from "../../../phases/handlers/morpho-vault-config"; +import { EvmBridgeQuoteRequest, getEvmBridgeQuote } from "../../core/squidrouter"; +import { QuoteContext } from "../../core/types"; +import { assignPreNablaContext, BaseInitializeEngine } from "./index"; + +const vaultAbi = [ + { + inputs: [{ name: "shares", type: "uint256" }], + name: "previewRedeem", + outputs: [{ name: "assets", type: "uint256" }], + stateMutability: "view", + type: "function" + } +] as const; + +export class OffRampFromEvmInitializeMorphoEngine extends BaseInitializeEngine { + readonly config = { + direction: RampDirection.SELL, + skipNote: "OffRampFromEvmInitializeMorphoEngine: Skipped because rampType is BUY, this engine handles SELL operations only" + }; + + protected async executeInternal(ctx: QuoteContext): Promise { + const req = ctx.request; + + if (req.from !== Networks.Ethereum) { + throw new Error(`OffRampFromEvmInitializeMorphoEngine: expected from=Ethereum, got ${req.from}`); + } + + // 1. Resolve the Morpho vault and compute shares -> USDC conversion via previewRedeem. + const vault = getMorphoVaultInfo("usdc-ethereum"); + const evmClientManager = EvmClientManager.getInstance(); + const ethereumClient = evmClientManager.getClient(Networks.Ethereum); + + const sharesAmountRaw = new Big(req.inputAmount).mul(new Big(10).pow(vault.shareDecimals)).toFixed(0, 0); + + const assetsFromShares = (await ethereumClient.readContract({ + abi: vaultAbi, + address: vault.vaultAddress, + args: [BigInt(sharesAmountRaw)], + functionName: "previewRedeem" + })) as bigint; + + const expectedUsdcDecimal = new Big(assetsFromShares.toString()).div(new Big(10).pow(vault.depositAssetDecimals)); + + // 2. Set up preNabla context (mirrors the standard offramp EVM flow) + await assignPreNablaContext(ctx); + + // 3. Get bridge quote: USDC on Ethereum -> USDC on Base + const bridgeRequest: EvmBridgeQuoteRequest = { + amountDecimal: expectedUsdcDecimal.toFixed(vault.depositAssetDecimals), + fromNetwork: Networks.Ethereum, + inputCurrency: EvmToken.USDC, + outputCurrency: EvmToken.USDC, + rampType: req.rampType, + toNetwork: Networks.Base + }; + + const bridgeQuote = await getEvmBridgeQuote(bridgeRequest); + + ctx.evmToEvm = { + ...bridgeRequest, + fromToken: bridgeQuote.fromToken, + inputAmountDecimal: new Big(bridgeRequest.amountDecimal), + inputAmountRaw: bridgeQuote.inputAmountRaw, + networkFeeUSD: bridgeQuote.networkFeeUSD, + outputAmountDecimal: bridgeQuote.outputAmountDecimal, + outputAmountRaw: bridgeQuote.outputAmountRaw, + toToken: bridgeQuote.toToken + }; + + // 4. Surface redeem metadata for the route-prep phase (compute share->USDC rate at quote time). + ctx.redeemMeta = { + expectedUsdcRaw: assetsFromShares.toString(), + sharesAmountRaw, + vaultAddress: vault.vaultAddress + }; + + ctx.addNote?.( + `Morpho offramp init: ${sharesAmountRaw} vault shares -> ~${expectedUsdcDecimal.toFixed()} USDC (previewRedeem); bridge to ${bridgeQuote.outputAmountDecimal.toFixed()} USDC on Base` + ); + } +} diff --git a/apps/api/src/api/services/quote/routes/route-resolver.ts b/apps/api/src/api/services/quote/routes/route-resolver.ts index 4d2d71b08..bc259a741 100644 --- a/apps/api/src/api/services/quote/routes/route-resolver.ts +++ b/apps/api/src/api/services/quote/routes/route-resolver.ts @@ -4,6 +4,7 @@ import { AssetHubToken, EPaymentMethod, + EvmToken, FiatToken, isAlfredpayToken, Networks, @@ -18,6 +19,7 @@ import { offrampEvmToAlfredpayStrategy } from "./strategies/offramp-evm-to-alfre import { offrampToPixStrategy } from "./strategies/offramp-to-pix.strategy"; import { offrampToPixEvmStrategy } from "./strategies/offramp-to-pix-base.strategy"; import { offrampToSepaEvmStrategy } from "./strategies/offramp-to-sepa-evm.strategy"; +import { offrampToSepaEvmMorphoStrategy } from "./strategies/offramp-to-sepa-evm-morpho.strategy"; import { onrampAlfredpayToEvmStrategy } from "./strategies/onramp-alfredpay-to-evm.strategy"; import { onrampAveniaToAssethubStrategy } from "./strategies/onramp-avenia-to-assethub.strategy"; import { onrampAveniaToEvmBaseStrategy } from "./strategies/onramp-avenia-to-evm.strategy-base"; @@ -73,6 +75,9 @@ export class RouteResolver { case "spei": return offrampEvmToAlfredpayStrategy; case "sepa": + if (ctx.request.inputCurrency === EvmToken.MORPHO_VAULT) { + return offrampToSepaEvmMorphoStrategy; + } return offrampToSepaEvmStrategy; case "cbu": default: diff --git a/apps/api/src/api/services/quote/routes/strategies/offramp-to-sepa-evm-morpho.strategy.ts b/apps/api/src/api/services/quote/routes/strategies/offramp-to-sepa-evm-morpho.strategy.ts new file mode 100644 index 000000000..60ebf0ecf --- /dev/null +++ b/apps/api/src/api/services/quote/routes/strategies/offramp-to-sepa-evm-morpho.strategy.ts @@ -0,0 +1,30 @@ +import { EvmToken } from "@vortexfi/shared"; +import { StageKey } from "../../core/types"; +import { OffRampDiscountEngine } from "../../engines/discount/offramp"; +import { OffRampFeeMykoboEngine } from "../../engines/fee/offramp-mykobo"; +import { OffRampFinalizeEngine } from "../../engines/finalize/offramp"; +import { OffRampFromEvmInitializeMorphoEngine } from "../../engines/initialize/offramp-from-evm-morpho"; +import { OffRampMergeSubsidyEvmEngine } from "../../engines/merge-subsidy/offramp-evm"; +import { OffRampSwapEngineEvm } from "../../engines/nabla-swap/offramp-evm"; +import { defineRouteStrategy } from "../route-definition"; + +/** + * EUR offramp from Morpho vault shares on Ethereum → SEPA via Mykobo. + * + * The Initialize engine differs from the standard EUR offramp: it must first convert + * share amount → USDC amount via the vault's previewRedeem, then request the + * SquidRouter bridge quote (USDC Ethereum → USDC Base). Downstream stages + * (NablaSwap, Fee, Discount, MergeSubsidy, Finalize) are unchanged. + */ +export const offrampToSepaEvmMorphoStrategy = defineRouteStrategy({ + engines: () => ({ + [StageKey.Initialize]: new OffRampFromEvmInitializeMorphoEngine(), + [StageKey.NablaSwap]: new OffRampSwapEngineEvm(EvmToken.EURC), + [StageKey.Fee]: new OffRampFeeMykoboEngine(), + [StageKey.Discount]: new OffRampDiscountEngine(), + [StageKey.MergeSubsidy]: new OffRampMergeSubsidyEvmEngine(), + [StageKey.Finalize]: new OffRampFinalizeEngine() + }), + name: "OfframpToSepaEvmMorpho", + stages: [StageKey.Initialize, StageKey.NablaSwap, StageKey.Fee, StageKey.Discount, StageKey.MergeSubsidy, StageKey.Finalize] +}); diff --git a/apps/api/src/api/services/transactions/offramp/index.ts b/apps/api/src/api/services/transactions/offramp/index.ts index 1e48a6878..886ffc1ca 100644 --- a/apps/api/src/api/services/transactions/offramp/index.ts +++ b/apps/api/src/api/services/transactions/offramp/index.ts @@ -1,4 +1,5 @@ import { + EvmToken, FiatToken, getNetworkFromDestination, getOnChainTokenDetails, @@ -11,6 +12,7 @@ import { prepareAssethubToBRLOfframpTransactions } from "./routes/assethub-to-br import { prepareEvmToAlfredpayOfframpTransactions } from "./routes/evm-to-alfredpay"; import { prepareEvmToBRLOfframpBaseTransactions } from "./routes/evm-to-brl-base"; import { prepareEvmToMykoboOfframpTransactions } from "./routes/evm-to-mykobo"; +import { prepareEvmToMykoboMorphoOfframpTransactions } from "./routes/evm-to-mykobo-morpho"; export async function prepareOfframpTransactions(params: OfframpTransactionParams): Promise { const { quote } = params; @@ -29,6 +31,10 @@ export async function prepareOfframpTransactions(params: OfframpTransactionParam return prepareAssethubToBRLOfframpTransactions(params); } } else if (quote.outputCurrency === FiatToken.EURC) { + // Morpho vault share source (Ethereum) → redeem + bridge to Base → Mykobo payout + if (quote.inputCurrency === EvmToken.MORPHO_VAULT) { + return prepareEvmToMykoboMorphoOfframpTransactions(params); + } // Mykobo EUR offramp on Base (EVM-only path) const inputTokenDetails = getOnChainTokenDetails(fromNetwork, quote.inputCurrency as OnChainToken); if (!inputTokenDetails || !isEvmTokenDetails(inputTokenDetails)) { 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 b3b74b9b9..be387a8d7 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 @@ -69,7 +69,7 @@ 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. */ -async function resolvePermitDomain( +export async function resolvePermitDomain( publicClient: PublicClient, tokenAddress: `0x${string}`, chainId: number, diff --git a/apps/api/src/api/services/transactions/offramp/routes/evm-to-mykobo-morpho.ts b/apps/api/src/api/services/transactions/offramp/routes/evm-to-mykobo-morpho.ts new file mode 100644 index 000000000..73154f7cd --- /dev/null +++ b/apps/api/src/api/services/transactions/offramp/routes/evm-to-mykobo-morpho.ts @@ -0,0 +1,440 @@ +import { + createOfframpSquidrouterTransactionsToEvm, + EvmClientManager, + EvmToken, + EvmTransactionData, + evmTokenConfig, + getNetworkId, + isWithdrawInstructions, + MykoboApiService, + MykoboCurrency, + MykoboTransactionType, + multiplyByPowerOfTen, + Networks, + SignedTypedData, + UnsignedTx +} from "@vortexfi/shared"; +import Big from "big.js"; +import httpStatus from "http-status"; +import { encodeFunctionData } from "viem"; +import { APIError } from "../../../../errors/api-error"; +import { syncMykoboCustomerKyc } from "../../../mykobo/mykobo-customer.service"; +import { getEvmFundingAccount } from "../../../phases/evm-funding"; +import { getMorphoVaultInfo } from "../../../phases/handlers/morpho-vault-config"; +import { StateMetadata } from "../../../phases/meta-state-types"; +import { EUR_OFFRAMP_MORPHO } from "../../../phases/ramp-flow-definitions"; +import { prepareBaseCleanupApproval } from "../../base/cleanup"; +import { addEvmFeeDistributionTransaction } from "../../common/feeDistribution"; +import { encodeEvmTransactionData } from "../../index"; +import { addNablaSwapTransactionsOnBase, addOnrampDestinationChainTransactions } from "../../onramp/common/transactions"; +import { OfframpTransactionParams, OfframpTransactionsWithMeta } from "../common/types"; +import { resolvePermitDomain } from "./evm-to-alfredpay"; + +const erc20Abi = [ + { + inputs: [{ name: "account", type: "address" }], + name: "balanceOf", + outputs: [{ name: "", type: "uint256" }], + stateMutability: "view", + type: "function" + }, + { + inputs: [{ name: "owner", type: "address" }], + name: "nonces", + outputs: [{ name: "", type: "uint256" }], + stateMutability: "view", + type: "function" + }, + { inputs: [], name: "name", outputs: [{ name: "", type: "string" }], stateMutability: "view", type: "function" } +] as const; + +const REDEEM_SLIPPAGE_BPS = 50; // 0.5% — covers interest accrual between quote-time and redeem-time (0% vault fee assumed) +const APPROVE_BUFFER_BPS = 100; // 1% — exact output + 1% for interest accrual before bridge + +const vaultRedeemAbi = [ + { + inputs: [ + { name: "shares", type: "uint256" }, + { name: "receiver", type: "address" }, + { name: "owner", type: "address" } + ], + name: "redeem", + outputs: [{ name: "assets", type: "uint256" }], + stateMutability: "nonpayable", + type: "function" + } +] as const; + +/** + * Prepares all transactions for an offramp from Morpho vault shares on Ethereum + * → SEPA payout via Mykobo. + * + * Flow: + * 1. User signs a single EIP-2612 Permit typed data on the vault (spender = ephemeral). + * This is submitted as a presignedTx (SignedTypedData) at registration. + * 2. morphoPermitExecute handler broadcasts: + * a) vault.permit(owner, spender, value, deadline, v, r, s) using the executor key — + * permit() does not require the spender to be the caller, so this is fine. + * b) vault.transferFrom(user, ephemeral, shares) signed by the ephemeral (presignedTx, + * nonce 1). Only the spender can call transferFrom on its own allowance. + * 3. morphoRedeem: ephemeral calls vault.redeem(shares, ephemeral, ephemeral) → USDC on Ethereum. + * 4. squidRouterApprove + squidRouterSwap: ephemeral bridges USDC Ethereum → Base. + * 5. ethereumCleanupUsdc: max-approve USDC to funding account for post-ramp sweep. + * 6. On Base: distribute fees, swap USDC→EURC via Nabla, transfer EURC to Mykobo, cleanups. + */ +export async function prepareEvmToMykoboMorphoOfframpTransactions({ + quote, + signingAccounts, + userAddress, + email, + destinationAddress, + ipAddress, + userId +}: OfframpTransactionParams): Promise { + const unsignedTxs: UnsignedTx[] = []; + let stateMeta: Partial = {}; + + if (!email) { + throw new APIError({ + isPublic: true, + message: "email must be provided for Morpho EUR offramp (Mykobo)", + status: httpStatus.BAD_REQUEST + }); + } + if (!ipAddress) { + throw new APIError({ + isPublic: true, + message: "ipAddress must be provided for Morpho EUR offramp (Mykobo)", + status: httpStatus.BAD_REQUEST + }); + } + if (!destinationAddress) { + throw new APIError({ + isPublic: true, + message: "destinationAddress (user receiving wallet) must be provided for Morpho EUR offramp", + status: httpStatus.BAD_REQUEST + }); + } + if (!userAddress) { + throw new Error("User address must be provided for Morpho offramping"); + } + + const evmEphemeralEntry = signingAccounts.find(account => account.type === "EVM"); + if (!evmEphemeralEntry) { + throw new Error("EVM ephemeral account not found for Morpho to Mykobo offramp"); + } + + const fromNetwork = quote.from as Networks; + if (fromNetwork !== Networks.Ethereum) { + throw new Error(`Morpho EUR offramp only supports Ethereum source; got ${fromNetwork}`); + } + + if (!quote.metadata.nablaSwapEvm?.outputAmountDecimal) { + throw new Error("Missing nablaSwapEvm.outputAmountDecimal in quote metadata for Morpho offramp"); + } + if (!quote.metadata.evmToEvm?.inputAmountRaw) { + throw new Error("Missing evmToEvm.inputAmountRaw in quote metadata for Morpho offramp (bridge input)"); + } + + const baseUsdcAddress = evmTokenConfig[Networks.Base][EvmToken.USDC]?.erc20AddressSourceChain; + if (!baseUsdcAddress) { + throw new Error("Invalid USDC configuration for Base in evmTokenConfig"); + } + const baseEurcAddress = evmTokenConfig[Networks.Base][EvmToken.EURC]?.erc20AddressSourceChain; + if (!baseEurcAddress) { + throw new Error("Invalid EURC configuration for Base in evmTokenConfig"); + } + const baseAxlUsdcAddress = evmTokenConfig[Networks.Base][EvmToken.AXLUSDC]?.erc20AddressSourceChain; + if (!baseAxlUsdcAddress) { + throw new Error("Invalid AXLUSDC configuration for Base in evmTokenConfig"); + } + + const morphoVault = getMorphoVaultInfo("usdc-ethereum"); + + const evmClientManager = EvmClientManager.getInstance(); + const ethereumClient = evmClientManager.getClient(Networks.Ethereum); + const ethereumChainId = getNetworkId(Networks.Ethereum); + + if (!ethereumChainId) { + throw new Error("Could not resolve Ethereum chain id"); + } + + const userNonce = (await ethereumClient.readContract({ + abi: erc20Abi, + address: morphoVault.vaultAddress, + args: [userAddress], + functionName: "nonces" + })) as bigint; + + const tokenName = (await ethereumClient.readContract({ + abi: erc20Abi, + address: morphoVault.vaultAddress, + functionName: "name" + })) as string; + + const resolvedDomain = await resolvePermitDomain(ethereumClient, morphoVault.vaultAddress, ethereumChainId, tokenName); + + const sharesAmountRaw = quote.metadata.redeemMeta?.sharesAmountRaw; + if (!sharesAmountRaw) { + throw new Error("Missing quote.metadata.redeemMeta.sharesAmountRaw; was the offramp Morpho initialize engine run?"); + } + + const permitDeadline = BigInt(Math.floor(Date.now() / 1000) + 24 * 60 * 60); + + // 1. User-signed EIP-2612 Permit typed data (spender = ephemeral). + // Submitted as a presignedTx (SignedTypedData) at registration. The handler validates the + // typed data, then uses the embedded v/r/s to call vault.permit via the executor key. + const permitTypedData: SignedTypedData = { + domain: resolvedDomain, + message: { + deadline: permitDeadline.toString(), + nonce: userNonce.toString(), + owner: userAddress, + spender: evmEphemeralEntry.address, + value: sharesAmountRaw + }, + primaryType: "Permit", + types: { + Permit: [ + { name: "owner", type: "address" }, + { name: "spender", type: "address" }, + { name: "value", type: "uint256" }, + { name: "nonce", type: "uint256" }, + { name: "deadline", type: "uint256" } + ] + } + }; + + unsignedTxs.push({ + meta: {}, + network: Networks.Ethereum, + nonce: 0, + phase: "morphoPermitExecute", + signer: userAddress, + txData: permitTypedData + }); + + // 2. Ephemeral-signed transferFrom presigned tx. Only the spender (the ephemeral) can call + // transferFrom on the allowance granted by permit(), so this must be ephemeral-signed. + // Phase + signer match the existing alfredpay direct-transfer pattern; the handler reads + // the user-signed permit entry and the ephemeral-signed transferFrom entry together. + const { maxFeePerGas, maxPriorityFeePerGas } = await ethereumClient.estimateFeesPerGas(); + + const transferFromCallData = encodeFunctionData({ + abi: [ + { + inputs: [ + { name: "from", type: "address" }, + { name: "to", type: "address" }, + { name: "value", type: "uint256" } + ], + name: "transferFrom", + outputs: [{ name: "", type: "bool" }], + stateMutability: "nonpayable", + type: "function" + } + ], + args: [userAddress as `0x${string}`, evmEphemeralEntry.address as `0x${string}`, BigInt(sharesAmountRaw)], + functionName: "transferFrom" + }); + + unsignedTxs.push({ + meta: {}, + network: Networks.Ethereum, + nonce: 0, + phase: "morphoPermitExecute", + signer: evmEphemeralEntry.address, + txData: encodeEvmTransactionData({ + data: transferFromCallData, + gas: "200000", + maxFeePerGas: String(maxFeePerGas), + maxPriorityFeePerGas: String(maxPriorityFeePerGas), + to: morphoVault.vaultAddress, + value: "0" + }) as EvmTransactionData + }); + + // 3. morphoRedeem: ephemeral calls vault.redeem(shares, ephemeral, ephemeral) + const redeemCallData = encodeFunctionData({ + abi: vaultRedeemAbi, + args: [BigInt(sharesAmountRaw), evmEphemeralEntry.address as `0x${string}`, evmEphemeralEntry.address as `0x${string}`], + functionName: "redeem" + }); + + unsignedTxs.push({ + meta: {}, + network: Networks.Ethereum, + nonce: 1, + phase: "morphoRedeem", + signer: evmEphemeralEntry.address, + txData: encodeEvmTransactionData({ + data: redeemCallData, + gas: "500000", + maxFeePerGas: String(maxFeePerGas), + maxPriorityFeePerGas: String(maxPriorityFeePerGas), + to: morphoVault.vaultAddress, + value: "0" + }) as EvmTransactionData + }); + + // 4. SquidRouter approve + swap (Ethereum → Base USDC) + const bridgeInputAmountRaw = quote.metadata.evmToEvm.inputAmountRaw; + // Approve 1% more than expected bridge input to cover interest accrual between redeem and bridge. + const approveAmountRaw = (BigInt(bridgeInputAmountRaw) * BigInt(10000 + APPROVE_BUFFER_BPS)) / BigInt(10000); + + const { approveData, swapData, squidRouterQuoteId } = await createOfframpSquidrouterTransactionsToEvm({ + destinationAddress: evmEphemeralEntry.address, + fromAddress: evmEphemeralEntry.address, + fromNetwork: Networks.Ethereum, + fromToken: morphoVault.depositAssetAddress, + rawAmount: approveAmountRaw.toString(), + toNetwork: Networks.Base, + toToken: baseUsdcAddress + }); + + unsignedTxs.push({ + meta: {}, + network: Networks.Ethereum, + nonce: 2, + phase: "squidRouterApprove", + signer: evmEphemeralEntry.address, + txData: encodeEvmTransactionData(approveData) as EvmTransactionData + }); + + unsignedTxs.push({ + meta: {}, + network: Networks.Ethereum, + nonce: 3, + phase: "squidRouterSwap", + signer: evmEphemeralEntry.address, + txData: encodeEvmTransactionData(swapData) as EvmTransactionData + }); + + // 5. ethereumCleanupUsdc: max-approve USDC to funding account for post-ramp sweep. + const ethereumFundingAccount = getEvmFundingAccount(Networks.Ethereum); + const ethereumUsdcCleanup = await prepareBaseCleanupApproval( + morphoVault.depositAssetAddress as `0x${string}`, + ethereumFundingAccount.address, + Networks.Ethereum + ); + + unsignedTxs.push({ + meta: {}, + network: Networks.Ethereum, + nonce: 4, + phase: "ethereumCleanupUsdc", + signer: evmEphemeralEntry.address, + txData: encodeEvmTransactionData(ethereumUsdcCleanup) as EvmTransactionData + }); + + // 6. Mykobo intent (must precede any user-signed tx so failures abort early). + const mykoboIntentValue = quote.metadata.nablaSwapEvm.outputAmountDecimal; + const mykoboFlooredValue = new Big(mykoboIntentValue).toFixed(2, 0); + const eurcDecimals = evmTokenConfig[Networks.Base][EvmToken.EURC]?.decimals; + if (eurcDecimals === undefined) { + throw new Error("Invalid EURC decimals configuration for Base in evmTokenConfig"); + } + const eurcTransferAmountRaw = multiplyByPowerOfTen(new Big(mykoboFlooredValue), eurcDecimals).toFixed(0, 0); + + const mykobo = MykoboApiService.getInstance(); + const intent = await mykobo.createTransactionIntent({ + currency: MykoboCurrency.EURC, + email_address: email, + ip_address: ipAddress, + transaction_type: MykoboTransactionType.WITHDRAW, + value: mykoboFlooredValue, + wallet_address: evmEphemeralEntry.address + }); + + if (!isWithdrawInstructions(intent.instructions)) { + throw new Error("Mykobo intent did not return withdraw instructions; cannot derive receivables address"); + } + const mykoboReceivablesAddress = intent.instructions.address; + const mykoboTransactionId = intent.transaction.id; + const mykoboTransactionReference = intent.transaction.reference; + + // 7. Base leg (fundEphemeral handled separately; Nabla + payout + cleanups) + let baseNonce = 0; + + baseNonce = await addEvmFeeDistributionTransaction(quote, evmEphemeralEntry, unsignedTxs, baseNonce); + + const { nextNonce: nonceAfterNabla, stateMeta: nablaStateMeta } = await addNablaSwapTransactionsOnBase( + { + account: evmEphemeralEntry, + inputTokenAddress: baseUsdcAddress, + outputTokenAddress: baseEurcAddress, + quote + }, + unsignedTxs, + baseNonce + ); + stateMeta = nablaStateMeta; + baseNonce = nonceAfterNabla; + + const payoutTransfer = await addOnrampDestinationChainTransactions({ + amountRaw: eurcTransferAmountRaw, + destinationNetwork: Networks.Base, + isNativeToken: false, + toAddress: mykoboReceivablesAddress as `0x${string}`, + toToken: baseEurcAddress as `0x${string}` + }); + + unsignedTxs.push({ + meta: {}, + network: Networks.Base, + nonce: baseNonce, + phase: "mykoboPayoutOnBase", + signer: evmEphemeralEntry.address, + txData: encodeEvmTransactionData(payoutTransfer) as EvmTransactionData + }); + baseNonce++; + + const baseFundingAccount = getEvmFundingAccount(Networks.Base); + const cleanupTokens = [ + { address: baseUsdcAddress, phase: "baseCleanupUsdc" as const }, + { address: baseEurcAddress, phase: "baseCleanupEurc" as const }, + { address: baseAxlUsdcAddress, phase: "baseCleanupAxlUsdc" as const } + ]; + + for (const { address, phase } of cleanupTokens) { + const approval = await prepareBaseCleanupApproval(address as `0x${string}`, baseFundingAccount.address, Networks.Base); + unsignedTxs.push({ + meta: {}, + network: Networks.Base, + nonce: baseNonce++, + phase, + signer: evmEphemeralEntry.address, + txData: encodeEvmTransactionData(approval) as EvmTransactionData + }); + } + + const morphoRedeemMinOutputRaw = quote.metadata.redeemMeta?.expectedUsdcRaw + ? (BigInt(quote.metadata.redeemMeta.expectedUsdcRaw) * BigInt(10000 - REDEEM_SLIPPAGE_BPS)) / BigInt(10000) + : "0"; + + stateMeta = { + ...stateMeta, + destinationAddress, + evmEphemeralAddress: evmEphemeralEntry.address, + morphoRedeemAssetAddress: morphoVault.depositAssetAddress, + morphoRedeemMinOutputRaw: morphoRedeemMinOutputRaw.toString(), + morphoRedeemSharesAmountRaw: sharesAmountRaw, + morphoRedeemShareTokenAddress: morphoVault.vaultAddress, + morphoRedeemVaultAddress: morphoVault.vaultAddress, + mykoboEmail: email, + mykoboReceivablesAddress, + mykoboTransactionId, + mykoboTransactionReference, + phaseFlow: EUR_OFFRAMP_MORPHO, + squidRouterQuoteId, + walletAddress: userAddress + }; + + // TODO we're missing squidrouter backup, at least. + + if (userId) { + await syncMykoboCustomerKyc(userId, email); + } + + return { stateMeta, unsignedTxs }; +} diff --git a/apps/api/src/api/services/transactions/validation.ts b/apps/api/src/api/services/transactions/validation.ts index bab2c3029..9ed2baa8e 100644 --- a/apps/api/src/api/services/transactions/validation.ts +++ b/apps/api/src/api/services/transactions/validation.ts @@ -233,6 +233,9 @@ function getTransactionTypeForPhase(phase: RampPhase | CleanupPhase, network: Ne case "alfredpayOfframpTransferFallback": case "morphoApprove": case "morphoDeposit": + case "morphoPermitExecute": + case "morphoRedeem": + case "ethereumCleanupUsdc": return EphemeralAccountType.EVM; default: throw new APIError({ diff --git a/apps/frontend/src/pages/progress/index.tsx b/apps/frontend/src/pages/progress/index.tsx index c4287539f..c18dd7e24 100644 --- a/apps/frontend/src/pages/progress/index.tsx +++ b/apps/frontend/src/pages/progress/index.tsx @@ -37,6 +37,9 @@ function getRampFlow(rampState: RampState | undefined): keyof typeof PHASE_FLOWS } if (rampState.quote?.outputCurrency === FiatToken.EURC) { + if (rampState.quote?.inputCurrency === "MORPHO VAULT") { + return "offramp_eur_morpho"; + } return "offramp_eur_evm"; } diff --git a/apps/frontend/src/pages/progress/phaseFlows.ts b/apps/frontend/src/pages/progress/phaseFlows.ts index 3a8ea24f3..8915c2740 100644 --- a/apps/frontend/src/pages/progress/phaseFlows.ts +++ b/apps/frontend/src/pages/progress/phaseFlows.ts @@ -25,6 +25,8 @@ export const PHASE_DURATIONS: Record = { moonbeamToPendulumXcm: 30, morphoApprove: 0, morphoDeposit: 30, + morphoPermitExecute: 30, + morphoRedeem: 30, mykoboOnrampDeposit: 5 * 60, mykoboPayoutOnBase: 60, nablaApprove: 24, @@ -69,6 +71,22 @@ export const PHASE_FLOWS = { "complete" ] as RampPhase[], + offramp_eur_morpho: [ + "initial", + "morphoPermitExecute", + "morphoRedeem", + "squidRouterApprove", + "squidRouterSwap", + "fundEphemeral", + "distributeFees", + "subsidizePreSwap", + "nablaApprove", + "nablaSwap", + "subsidizePostSwap", + "mykoboPayoutOnBase", + "complete" + ] as RampPhase[], + onramp_brl: [ "initial", "brlaOnrampMint", diff --git a/apps/frontend/src/pages/progress/phaseMessages.ts b/apps/frontend/src/pages/progress/phaseMessages.ts index d6b1c7131..e8603bf72 100644 --- a/apps/frontend/src/pages/progress/phaseMessages.ts +++ b/apps/frontend/src/pages/progress/phaseMessages.ts @@ -86,6 +86,8 @@ export function getMessageForPhase(ramp: RampState | undefined, t: TFunction<"tr moonbeamToPendulumXcm: getMoonbeamToPendulumMessage(), morphoApprove: "", morphoDeposit: t("pages.progress.morphoDeposit"), + morphoPermitExecute: t("pages.progress.morphoPermitExecute"), + morphoRedeem: t("pages.progress.morphoRedeem"), mykoboOnrampDeposit: t("pages.progress.mykoboOnrampDeposit"), mykoboPayoutOnBase: getTransferringMessage(), nablaApprove: getSwappingMessage(), diff --git a/apps/frontend/src/translations/en.json b/apps/frontend/src/translations/en.json index 0d38d0b36..7cd99b027 100644 --- a/apps/frontend/src/translations/en.json +++ b/apps/frontend/src/translations/en.json @@ -1210,6 +1210,8 @@ "moonbeamToPendulum": "Transferring {{assetSymbol}} from Moonbeam --> Pendulum", "morphoApprove": "Approving tokens for Morpho vault deposit", "morphoDeposit": "Depositing into Morpho vault", + "morphoPermitExecute": "Moving Morpho vault shares to Vortex", + "morphoRedeem": "Redeeming Morpho vault shares for USDC", "mykoboOnrampDeposit": "Waiting to receive payment", "pendulumToAssethubXcm": "Transferring {{assetSymbol}} from Pendulum --> AssetHub", "pendulumToHydrationXcm": "Transferring {{assetSymbol}} from Pendulum --> Hydration", diff --git a/packages/shared/src/endpoints/ramp.endpoints.ts b/packages/shared/src/endpoints/ramp.endpoints.ts index f191e5af2..8b7ce2d4a 100644 --- a/packages/shared/src/endpoints/ramp.endpoints.ts +++ b/packages/shared/src/endpoints/ramp.endpoints.ts @@ -53,6 +53,8 @@ export type RampPhase = | "backupApprove" | "morphoApprove" | "morphoDeposit" + | "morphoPermitExecute" + | "morphoRedeem" | "complete"; export type CleanupPhase = From d69f35c37f4a5fe2454bb4360686e690882b5945 Mon Sep 17 00:00:00 2001 From: Gianfranco Date: Mon, 15 Jun 2026 15:06:37 -0300 Subject: [PATCH 12/26] adjust morpho offramp to start from any evm --- .../phases/handlers/fund-ephemeral-handler.ts | 21 ++ .../phases/handlers/morpho-deposit-handler.ts | 32 ++- .../handlers/morpho-permit-execute-handler.ts | 100 ++++++-- .../phases/handlers/morpho-redeem-handler.ts | 16 +- .../phases/handlers/morpho-vault-config.ts | 8 +- .../api/services/phases/meta-state-types.ts | 3 +- .../services/phases/ramp-flow-definitions.ts | 62 ++++- .../initialize/offramp-from-evm-morpho.ts | 77 ++++--- .../offramp/routes/evm-to-alfredpay.ts | 66 +++--- .../offramp/routes/evm-to-mykobo-morpho.ts | 216 ++++++++++-------- .../onramp/routes/mykobo-to-evm-morpho.ts | 120 ++++++---- .../shared/src/endpoints/ramp.endpoints.ts | 7 +- .../shared/src/tokens/freeTokens/config.ts | 2 +- 13 files changed, 472 insertions(+), 258 deletions(-) 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 946bf7233..a755b66e2 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 @@ -85,6 +85,13 @@ export class FundEphemeralPhaseHandler extends BasePhaseHandler { return false; } + protected getRequiresMorphoNetworkFunding(state: RampState): boolean { + const meta = state.state as StateMetadata; + if (!meta.morphoNetwork) return false; + if (!isNetworkEVM(meta.morphoNetwork as EvmNetworks)) return false; + return true; + } + protected getRequiresDestinationEvmFunding(state: RampState): boolean { // Required for onramps where the destination is an EVM network (not AssetHub) if (isOnramp(state) && state.to !== Networks.AssetHub) { @@ -193,6 +200,13 @@ export class FundEphemeralPhaseHandler extends BasePhaseHandler { ? await isDestinationEvmEphemeralFunded(evmEphemeralAddress, destinationNetwork) : true; + const requiresMorphoNetworkFunding = this.getRequiresMorphoNetworkFunding(state); + const morphoNetwork = (state.state as StateMetadata).morphoNetwork as EvmNetworks | undefined; + const isMorphoNetworkFunded = + requiresMorphoNetworkFunding && morphoNetwork + ? await isDestinationEvmEphemeralFunded(evmEphemeralAddress, morphoNetwork) + : true; + if (!isPendulumFunded) { logger.info(`Funding PEN ephemeral account ${substrateEphemeralAddress}`); if (isOnramp(state) && state.to !== Networks.AssetHub) { @@ -218,6 +232,13 @@ export class FundEphemeralPhaseHandler extends BasePhaseHandler { logger.info("Polygon ephemeral address already funded."); } + if (!isMorphoNetworkFunded && morphoNetwork) { + logger.info(`Funding morpho network ephemeral account ${evmEphemeralAddress} on ${morphoNetwork}`); + await this.fundDestinationEvmEphemeralAccount(state, morphoNetwork); + } else if (requiresMorphoNetworkFunding) { + logger.info(`Morpho network ephemeral account already funded on ${morphoNetwork}.`); + } + if (isOnramp(state) && !isDestinationEvmFunded && destinationNetwork && isNetworkEVM(destinationNetwork)) { logger.info(`Funding destination EVM ephemeral account ${evmEphemeralAddress} on ${destinationNetwork}`); await this.fundDestinationEvmEphemeralAccount(state, destinationNetwork); diff --git a/apps/api/src/api/services/phases/handlers/morpho-deposit-handler.ts b/apps/api/src/api/services/phases/handlers/morpho-deposit-handler.ts index 85542dc95..a68b9c05b 100644 --- a/apps/api/src/api/services/phases/handlers/morpho-deposit-handler.ts +++ b/apps/api/src/api/services/phases/handlers/morpho-deposit-handler.ts @@ -30,6 +30,10 @@ const morphoVaultAbi = [ const MORPHO_NETWORK: EvmNetworks = Networks.Ethereum; +function resolveMorphoNetwork(meta: StateMetadata): EvmNetworks { + return (meta.morphoNetwork as EvmNetworks | undefined) ?? MORPHO_NETWORK; +} + async function pollForShareBalance( evmClientManager: EvmClientManager, network: EvmNetworks, @@ -80,6 +84,7 @@ export class MorphoDepositHandler extends BasePhaseHandler { throw new Error("MorphoDepositHandler: Missing evmEphemeralAddress in state metadata"); } + const network = resolveMorphoNetwork(meta); const vaultAddress = meta.morphoVaultAddress as `0x${string}`; const depositAssetAddress = meta.morphoDepositAssetAddress as `0x${string}`; const ephemeralAddress = meta.evmEphemeralAddress as `0x${string}`; @@ -91,6 +96,7 @@ export class MorphoDepositHandler extends BasePhaseHandler { state = await this.executeApprove( state, evmClientManager, + network, vaultAddress, depositAssetAddress, ephemeralAddress, @@ -98,7 +104,7 @@ export class MorphoDepositHandler extends BasePhaseHandler { ); // Step 2: Deposit into vault, verify share tokens minted to destination - return this.executeDeposit(state, evmClientManager, vaultAddress, destinationAddress, depositTxData); + return this.executeDeposit(state, evmClientManager, network, vaultAddress, destinationAddress, depositTxData); } private getMorphoPresignedTxs(state: RampState): { approveTxData: string; depositTxData: string } { @@ -113,6 +119,7 @@ export class MorphoDepositHandler extends BasePhaseHandler { private async executeApprove( state: RampState, evmClientManager: EvmClientManager, + network: EvmNetworks, vaultAddress: `0x${string}`, depositAssetAddress: `0x${string}`, ephemeralAddress: `0x${string}`, @@ -121,36 +128,37 @@ export class MorphoDepositHandler extends BasePhaseHandler { const meta = state.state as StateMetadata; const requiredAmount = BigInt(meta.morphoDepositAmountRaw || "0"); - const allowance = await this.readAllowance(evmClientManager, depositAssetAddress, ephemeralAddress, vaultAddress); + const allowance = await this.readAllowance(evmClientManager, network, depositAssetAddress, ephemeralAddress, vaultAddress); if (allowance >= requiredAmount) { logger.info(`MorphoDepositHandler: Allowance ${allowance} >= ${requiredAmount}, skipping approve`); return state; } const txHash = (await evmClientManager.sendRawTransactionWithRetry( - MORPHO_NETWORK, + network, approveTxData as `0x${string}` )) as `0x${string}`; logger.info(`MorphoDepositHandler: Approval tx broadcast: ${txHash}`); - const receipt = await evmClientManager.getClient(MORPHO_NETWORK).waitForTransactionReceipt({ hash: txHash }); + const receipt = await evmClientManager.getClient(network).waitForTransactionReceipt({ hash: txHash }); if (!receipt || receipt.status !== "success") { throw new Error(`MorphoDepositHandler: Approval transaction ${txHash} failed on chain`); } - await this.verifyAllowance(evmClientManager, depositAssetAddress, ephemeralAddress, vaultAddress, meta); + await this.verifyAllowance(evmClientManager, network, depositAssetAddress, ephemeralAddress, vaultAddress, meta); return state; } private async readAllowance( evmClientManager: EvmClientManager, + network: EvmNetworks, tokenAddress: `0x${string}`, ownerAddress: `0x${string}`, spenderAddress: `0x${string}` ): Promise { - const client = evmClientManager.getClient(MORPHO_NETWORK); + const client = evmClientManager.getClient(network); return client.readContract({ abi: erc20Abi, address: tokenAddress, @@ -161,12 +169,13 @@ export class MorphoDepositHandler extends BasePhaseHandler { private async verifyAllowance( evmClientManager: EvmClientManager, + network: EvmNetworks, tokenAddress: `0x${string}`, ownerAddress: `0x${string}`, spenderAddress: `0x${string}`, meta: StateMetadata ): Promise { - const client = evmClientManager.getClient(MORPHO_NETWORK); + const client = evmClientManager.getClient(network); const requiredAmount = BigInt(meta.morphoDepositAmountRaw || "0"); const deadline = Date.now() + EVM_BALANCE_CHECK_TIMEOUT_MS; let lastAllowance = 0n; @@ -193,6 +202,7 @@ export class MorphoDepositHandler extends BasePhaseHandler { private async executeDeposit( state: RampState, evmClientManager: EvmClientManager, + network: EvmNetworks, vaultAddress: `0x${string}`, shareReceiver: `0x${string}`, depositTxData: string @@ -204,13 +214,13 @@ export class MorphoDepositHandler extends BasePhaseHandler { logger.info( `MorphoDepositHandler: Deposit tx already broadcast (${meta.morphoDepositTxHash}), verifying shares on ${shareReceiver}` ); - const shares = await pollForShareBalance(evmClientManager, MORPHO_NETWORK, vaultAddress, shareReceiver); + const shares = await pollForShareBalance(evmClientManager, network, vaultAddress, shareReceiver); logger.info(`MorphoDepositHandler: Share balance verified: ${shares}`); return state; } const txHash = (await evmClientManager.sendRawTransactionWithRetry( - MORPHO_NETWORK, + network, depositTxData as `0x${string}` )) as `0x${string}`; @@ -220,12 +230,12 @@ export class MorphoDepositHandler extends BasePhaseHandler { state: { ...state.state, morphoDepositTxHash: txHash } }); - const receipt = await evmClientManager.getClient(MORPHO_NETWORK).waitForTransactionReceipt({ hash: txHash }); + const receipt = await evmClientManager.getClient(network).waitForTransactionReceipt({ hash: txHash }); if (!receipt || receipt.status !== "success") { throw new Error(`MorphoDepositHandler: Deposit transaction ${txHash} failed on chain`); } - const shares = await pollForShareBalance(evmClientManager, MORPHO_NETWORK, vaultAddress, shareReceiver); + const shares = await pollForShareBalance(evmClientManager, network, vaultAddress, shareReceiver); logger.info(`MorphoDepositHandler: Deposit successful. Share balance on ${shareReceiver}: ${shares}`); return state; diff --git a/apps/api/src/api/services/phases/handlers/morpho-permit-execute-handler.ts b/apps/api/src/api/services/phases/handlers/morpho-permit-execute-handler.ts index d696170ee..c5a411e94 100644 --- a/apps/api/src/api/services/phases/handlers/morpho-permit-execute-handler.ts +++ b/apps/api/src/api/services/phases/handlers/morpho-permit-execute-handler.ts @@ -24,10 +24,30 @@ const permitAbi = [ } ] as const; +const allowanceAbi = [ + { + inputs: [ + { name: "owner", type: "address" }, + { name: "spender", type: "address" } + ], + name: "allowance", + outputs: [{ name: "", type: "uint256" }], + stateMutability: "view", + type: "function" + } +] as const; + +const ALLOWANCE_POLL_INTERVAL_MS = 2_000; +const ALLOWANCE_POLL_TIMEOUT_MS = 120_000; + type VrsSignature = { v: number; r: `0x${string}`; s: `0x${string}` }; const ETHEREUM_NETWORK: EvmNetworks = Networks.Ethereum; +function resolveMorphoNetwork(meta: StateMetadata): EvmNetworks { + return (meta.morphoNetwork as EvmNetworks | undefined) ?? ETHEREUM_NETWORK; +} + /** * Phase description: * The user signs a single EIP-2612 Permit typed data on the Morpho vault (spender = ephemeral). @@ -72,11 +92,48 @@ export class MorphoPermitExecuteHandler extends BasePhaseHandler { } } + private async awaitAllowance( + network: EvmNetworks, + vaultAddress: `0x${string}`, + owner: `0x${string}`, + spender: `0x${string}`, + expected: bigint + ): Promise { + const { publicClient } = this.getExecutorWallet(network); + const startedAt = Date.now(); + let lastObserved = 0n; + while (Date.now() - startedAt < ALLOWANCE_POLL_TIMEOUT_MS) { + try { + const current = (await publicClient.readContract({ + abi: allowanceAbi, + address: vaultAddress, + args: [owner, spender], + functionName: "allowance" + })) as bigint; + lastObserved = current; + if (current >= expected) { + logger.info( + `MorphoPermitExecuteHandler: allowance synced for ${vaultAddress} (${owner} -> ${spender}): ${current.toString()}` + ); + return; + } + } catch (err) { + logger.warn( + `MorphoPermitExecuteHandler: allowance read failed (${vaultAddress}); will retry: ${(err as Error).message}` + ); + } + await new Promise(resolve => setTimeout(resolve, ALLOWANCE_POLL_INTERVAL_MS)); + } + throw this.createRecoverableError( + `MorphoPermitExecuteHandler: timed out waiting for allowance to reach ${expected.toString()} on ${vaultAddress} (last observed: ${lastObserved.toString()})` + ); + } + protected async executePhase(state: RampState): Promise { logger.info(`Executing morphoPermitExecute phase for ramp ${state.id}`); const meta = state.state as StateMetadata; - const network = ETHEREUM_NETWORK; + const network = resolveMorphoNetwork(meta); if (!meta.evmEphemeralAddress) { throw this.createUnrecoverableError("Missing evmEphemeralAddress in state metadata"); @@ -112,25 +169,25 @@ export class MorphoPermitExecuteHandler extends BasePhaseHandler { } // ── Step 1: broadcast vault.permit via executor ── - if (!meta.morphoPermitTxHash) { - const permitData = permitPresigned.txData; - if (!isSignedTypedData(permitData)) { - throw this.createUnrecoverableError("morphoPermitExecute: user presignedTx is not a SignedTypedData"); - } - const permitTypedData: SignedTypedData = permitData; - const sig = permitTypedData.signature as VrsSignature | undefined; - if (!sig) { - throw this.createUnrecoverableError("morphoPermitExecute: permit signature missing from user signed typed data"); - } - const token = permitTypedData.domain.verifyingContract as `0x${string}`; - const owner = permitTypedData.message.owner as `0x${string}`; - const value = BigInt(permitTypedData.message.value as string); - const deadline = BigInt(permitTypedData.message.deadline as string); + const permitData = permitPresigned.txData; + if (!isSignedTypedData(permitData)) { + throw this.createUnrecoverableError("morphoPermitExecute: user presignedTx is not a SignedTypedData"); + } + const permitTypedData: SignedTypedData = permitData; + const sig = permitTypedData.signature as VrsSignature | undefined; + if (!sig) { + throw this.createUnrecoverableError("morphoPermitExecute: permit signature missing from user signed typed data"); + } + const token = permitTypedData.domain.verifyingContract as `0x${string}`; + const owner = permitTypedData.message.owner as `0x${string}`; + const value = BigInt(permitTypedData.message.value as string); + const deadline = BigInt(permitTypedData.message.deadline as string); - if (deadline * 1000n < BigInt(Date.now())) { - throw this.createUnrecoverableError("Permit deadline already passed;"); - } + if (deadline * 1000n < BigInt(Date.now())) { + throw this.createUnrecoverableError("Permit deadline already passed;"); + } + if (!meta.morphoPermitTxHash) { const { walletClient } = this.getExecutorWallet(network); const permitHash = await walletClient.writeContract({ abi: permitAbi, @@ -146,7 +203,12 @@ export class MorphoPermitExecuteHandler extends BasePhaseHandler { logger.info(`MorphoPermitExecuteHandler: Permit already broadcast (${meta.morphoPermitTxHash})`); } - // TODO be smart with rpc state sync, read and await for allowance of ephemeral to be updated. + // ── Step 1.5: await allowance RPC sync ── + // `waitForTransactionReceipt` only confirms the tx is mined on the source node; + // downstream public RPCs may lag in their state view. Poll the vault's allowance + // until it reflects the post-permit value, otherwise the subsequent transferFrom + // will revert with "insufficient allowance". + await this.awaitAllowance(network, token, owner, ephemeralAddress, value); // ── Step 2: broadcast ephemeral-signed transferFrom ── if (!meta.morphoPermitTransferFromTxHash) { diff --git a/apps/api/src/api/services/phases/handlers/morpho-redeem-handler.ts b/apps/api/src/api/services/phases/handlers/morpho-redeem-handler.ts index 9d619e4c2..7dea9c140 100644 --- a/apps/api/src/api/services/phases/handlers/morpho-redeem-handler.ts +++ b/apps/api/src/api/services/phases/handlers/morpho-redeem-handler.ts @@ -31,6 +31,10 @@ const vaultRedeemAbi = [ const ETHEREUM_NETWORK: EvmNetworks = Networks.Ethereum; +function resolveMorphoNetwork(meta: StateMetadata): EvmNetworks { + return (meta.morphoNetwork as EvmNetworks | undefined) ?? ETHEREUM_NETWORK; +} + /** * Phase description: * Broadcasts the presigned vault.redeem(shares, ephemeral, ephemeral) tx on Ethereum. @@ -71,6 +75,7 @@ export class MorphoRedeemHandler extends BasePhaseHandler { throw this.createUnrecoverableError("Missing morphoRedeemSharesAmountRaw in state metadata"); } + const network = resolveMorphoNetwork(meta); const vaultAddress = meta.morphoRedeemVaultAddress as `0x${string}`; const assetAddress = meta.morphoRedeemAssetAddress as `0x${string}`; const ephemeralAddress = meta.evmEphemeralAddress as `0x${string}`; @@ -80,13 +85,13 @@ export class MorphoRedeemHandler extends BasePhaseHandler { // Recovery: if we already broadcast the redeem, verify the outcome. if (meta.morphoRedeemTxHash) { logger.info(`MorphoRedeemHandler: Redeem tx already broadcast (${meta.morphoRedeemTxHash}), verifying USDC balance`); - await this.verifyUsdcBalance(assetAddress, ephemeralAddress, minOutputRaw); + await this.verifyUsdcBalance(network, assetAddress, ephemeralAddress, minOutputRaw); return state; } // Layer 1: pre-flight previewRedeem. If the on-chain rate has drifted past tolerance before // we even broadcast, abort without spending gas on a doomed redeem. - const publicClient = this.evmClientManager.getClient(ETHEREUM_NETWORK); + const publicClient = this.evmClientManager.getClient(network); const previewAssets = (await publicClient.readContract({ abi: vaultRedeemAbi, address: vaultAddress, @@ -106,7 +111,7 @@ export class MorphoRedeemHandler extends BasePhaseHandler { } const txHash = (await this.evmClientManager.sendRawTransactionWithRetry( - ETHEREUM_NETWORK, + network, presigned.txData as `0x${string}` )) as `0x${string}`; logger.info(`MorphoRedeemHandler: Redeem tx broadcast: ${txHash}`); @@ -151,16 +156,17 @@ export class MorphoRedeemHandler extends BasePhaseHandler { ); // Layer 3: balance polling fallback - await this.verifyUsdcBalance(assetAddress, ephemeralAddress, minOutputRaw); + await this.verifyUsdcBalance(network, assetAddress, ephemeralAddress, minOutputRaw); return state; } private async verifyUsdcBalance( + network: EvmNetworks, assetAddress: `0x${string}`, ephemeralAddress: `0x${string}`, minOutputRaw: bigint ): Promise { - const client = this.evmClientManager.getClient(ETHEREUM_NETWORK); + const client = this.evmClientManager.getClient(network); const deadline = Date.now() + EVM_BALANCE_CHECK_TIMEOUT_MS; let lastBalance = 0n; diff --git a/apps/api/src/api/services/phases/handlers/morpho-vault-config.ts b/apps/api/src/api/services/phases/handlers/morpho-vault-config.ts index 3b65b7bba..d2454cbbf 100644 --- a/apps/api/src/api/services/phases/handlers/morpho-vault-config.ts +++ b/apps/api/src/api/services/phases/handlers/morpho-vault-config.ts @@ -9,12 +9,12 @@ export interface MorphoVaultInfo { } const MORPHO_VAULTS: Record = { - "usdc-ethereum": { - depositAssetAddress: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", // USDC on Ethereum + "usdc-base": { + depositAssetAddress: "0x833589fcd6edb6e08f4c7c32d4f71b54bda02913", // USDC on Base depositAssetDecimals: 6, - network: Networks.Ethereum, + network: Networks.Base, shareDecimals: 18, // MetaMorpho default - vaultAddress: "0xbeef0e0834849aCC03f0089F01f4F1Eeb06873C9" + vaultAddress: "0xbeef0e0834849aCC03f0089F01f4F1Eeb06873C9" // Steakhouse Prime Instant on Base } }; 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 9be7b783d..fe78accf3 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, RampPhase } from "@vortexfi/shared"; +import { AlfredpayFiatPaymentInstructions, ExtrinsicOptions, IbanPaymentData, Networks, RampPhase } from "@vortexfi/shared"; export interface StateMetadata { nablaSoftMinimumOutputRaw: string; @@ -96,4 +96,5 @@ export interface StateMetadata { morphoRedeemActualOutputRaw?: string; morphoPermitTxHash?: `0x${string}`; morphoPermitTransferFromTxHash?: `0x${string}`; + morphoNetwork?: Networks; } diff --git a/apps/api/src/api/services/phases/ramp-flow-definitions.ts b/apps/api/src/api/services/phases/ramp-flow-definitions.ts index 6a7d266cb..0b7676f10 100644 --- a/apps/api/src/api/services/phases/ramp-flow-definitions.ts +++ b/apps/api/src/api/services/phases/ramp-flow-definitions.ts @@ -157,10 +157,14 @@ export const ALFREDPAY_OFFRAMP: RampPhase[] = [ "complete" ]; -// ─── Morpho Vault Deposit On-Ramp (Mykobo SEPA on Base → Morpho Vault) ───── +// ─── Morpho Vault On-Ramp (Mykobo SEPA on Base → Morpho Vault) ────────────────── -/** EUR → USDC on Base via Nabla, then deposit into Morpho vault. No SquidRouter bridge. */ -export const EUR_ONRAMP_BASE_MORPHO: RampPhase[] = [ +/** + * Generic EUR onramp from Mykobo SEPA → deposit into a Morpho vault on any EVM chain. + * EURC on Base via Nabla swap to USDC, then SquidRouter bridge to the vault's chain, + * then deposit into the Morpho vault. Use this for vaults on chains other than Base. + */ +export const EUR_ONRAMP_MORPHO: RampPhase[] = [ "initial", "mykoboOnrampDeposit", "fundEphemeral", @@ -176,21 +180,61 @@ export const EUR_ONRAMP_BASE_MORPHO: RampPhase[] = [ "complete" ]; -// ─── Morpho Vault Redeem Off-Ramp (Morpho Vault on Ethereum → Mykobo SEPA on Base) ──── +/** + * Same-chain variant for vaults on Base. After the Nabla EURC→USDC swap on Base, USDC + * is already on Base, so the deposit into the Base vault does not need a SquidRouter + * bridge. The route-prep code in `mykobo-to-evm-morpho.ts` selects this flow when + * `morphoNetwork === Networks.Base` and skips adding bridge presignedTxs. + */ +export const EUR_ONRAMP_BASE_MORPHO: RampPhase[] = [ + "initial", + "mykoboOnrampDeposit", + "fundEphemeral", + "subsidizePreSwap", + "nablaApprove", + "nablaSwap", + "distributeFees", + "subsidizePostSwap", + "morphoDeposit", + "complete" +]; + +// ─── Morpho Vault Off-Ramp (Morpho Vault → Mykobo SEPA on Base) ──────────────── /** - * EUR offramp from Morpho vault shares on Ethereum → SEPA payout via Mykobo. - * User signs a single EIP-2612 permit; ephemeral broadcasts permit+transferFrom, - * redeems shares to USDC on Ethereum, bridges USDC to Base, then the existing - * Mykobo payout leg (Nabla USDC→EURC + EURC→Mykobo) executes on Base. + * Generic EUR offramp from Morpho vault shares on any EVM chain → SEPA payout via Mykobo. + * User signs a single EIP-2612 Permit typed data on the vault (spender = ephemeral). + * The ephemeral broadcasts permit + transferFrom, redeems shares to USDC on the + * vault's chain, then SquidRouter bridges the USDC to Base, where the Mykobo payout + * leg (Nabla USDC→EURC + EURC→Mykobo) executes. Use this for vaults on chains + * other than Base. */ export const EUR_OFFRAMP_MORPHO: RampPhase[] = [ "initial", + "fundEphemeral", "morphoPermitExecute", "morphoRedeem", - "squidRouterApprove", "squidRouterSwap", + "distributeFees", + "subsidizePreSwap", + "nablaApprove", + "nablaSwap", + "subsidizePostSwap", + "mykoboPayoutOnBase", + "complete" +]; + +/** + * Same-chain variant for vaults on Base. After morphoRedeem, USDC is already on Base, + * so no SquidRouter bridge is needed before the Mykobo payout leg. The route-prep + * code in `evm-to-mykobo-morpho.ts` selects this flow when + * `morphoNetwork === Networks.Base` and skips adding bridge presignedTxs. + */ +export const EUR_OFFRAMP_BASE_MORPHO: RampPhase[] = [ + "initial", "fundEphemeral", + "morphoPermitExecute", + "morphoRedeem", "distributeFees", "subsidizePreSwap", "nablaApprove", diff --git a/apps/api/src/api/services/quote/engines/initialize/offramp-from-evm-morpho.ts b/apps/api/src/api/services/quote/engines/initialize/offramp-from-evm-morpho.ts index d364316a3..8926fbedf 100644 --- a/apps/api/src/api/services/quote/engines/initialize/offramp-from-evm-morpho.ts +++ b/apps/api/src/api/services/quote/engines/initialize/offramp-from-evm-morpho.ts @@ -1,8 +1,8 @@ -import { EvmClientManager, EvmToken, Networks, RampDirection } from "@vortexfi/shared"; +import { EvmClientManager, EvmNetworks, EvmToken, Networks, RampDirection } from "@vortexfi/shared"; import Big from "big.js"; import { getMorphoVaultInfo } from "../../../phases/handlers/morpho-vault-config"; import { EvmBridgeQuoteRequest, getEvmBridgeQuote } from "../../core/squidrouter"; -import { QuoteContext } from "../../core/types"; +import { BridgeMeta, QuoteContext } from "../../core/types"; import { assignPreNablaContext, BaseInitializeEngine } from "./index"; const vaultAbi = [ @@ -24,18 +24,15 @@ export class OffRampFromEvmInitializeMorphoEngine extends BaseInitializeEngine { protected async executeInternal(ctx: QuoteContext): Promise { const req = ctx.request; - if (req.from !== Networks.Ethereum) { - throw new Error(`OffRampFromEvmInitializeMorphoEngine: expected from=Ethereum, got ${req.from}`); - } - // 1. Resolve the Morpho vault and compute shares -> USDC conversion via previewRedeem. - const vault = getMorphoVaultInfo("usdc-ethereum"); + const vault = getMorphoVaultInfo("usdc-base"); const evmClientManager = EvmClientManager.getInstance(); - const ethereumClient = evmClientManager.getClient(Networks.Ethereum); + const vaultNetwork = vault.network as EvmNetworks; + const vaultClient = evmClientManager.getClient(vaultNetwork); const sharesAmountRaw = new Big(req.inputAmount).mul(new Big(10).pow(vault.shareDecimals)).toFixed(0, 0); - const assetsFromShares = (await ethereumClient.readContract({ + const assetsFromShares = (await vaultClient.readContract({ abi: vaultAbi, address: vault.vaultAddress, args: [BigInt(sharesAmountRaw)], @@ -47,28 +44,45 @@ export class OffRampFromEvmInitializeMorphoEngine extends BaseInitializeEngine { // 2. Set up preNabla context (mirrors the standard offramp EVM flow) await assignPreNablaContext(ctx); - // 3. Get bridge quote: USDC on Ethereum -> USDC on Base - const bridgeRequest: EvmBridgeQuoteRequest = { - amountDecimal: expectedUsdcDecimal.toFixed(vault.depositAssetDecimals), - fromNetwork: Networks.Ethereum, - inputCurrency: EvmToken.USDC, - outputCurrency: EvmToken.USDC, - rampType: req.rampType, - toNetwork: Networks.Base - }; + // 3. Bridge context: when the vault is on Base, USDC is already on Base and no + // SquidRouter bridge is required. Surface a same-chain evmToEvm so downstream + // engines (e.g. OffRampSwapEngineEvm) can read outputAmountDecimal without + // conditional code paths. For non-Base vaults, fetch a real bridge quote. + if (vaultNetwork === Networks.Base) { + ctx.evmToEvm = { + fromNetwork: vaultNetwork, + fromToken: vault.depositAssetAddress as `0x${string}`, + inputAmountDecimal: expectedUsdcDecimal, + inputAmountRaw: assetsFromShares.toString(), + networkFeeUSD: "0", + outputAmountDecimal: expectedUsdcDecimal, + outputAmountRaw: assetsFromShares.toString(), + toNetwork: vaultNetwork, + toToken: vault.depositAssetAddress as `0x${string}` + } satisfies BridgeMeta; + } else { + const bridgeRequest: EvmBridgeQuoteRequest = { + amountDecimal: expectedUsdcDecimal.toFixed(vault.depositAssetDecimals), + fromNetwork: vaultNetwork, + inputCurrency: EvmToken.USDC, + outputCurrency: EvmToken.USDC, + rampType: req.rampType, + toNetwork: Networks.Base + }; - const bridgeQuote = await getEvmBridgeQuote(bridgeRequest); + const bridgeQuote = await getEvmBridgeQuote(bridgeRequest); - ctx.evmToEvm = { - ...bridgeRequest, - fromToken: bridgeQuote.fromToken, - inputAmountDecimal: new Big(bridgeRequest.amountDecimal), - inputAmountRaw: bridgeQuote.inputAmountRaw, - networkFeeUSD: bridgeQuote.networkFeeUSD, - outputAmountDecimal: bridgeQuote.outputAmountDecimal, - outputAmountRaw: bridgeQuote.outputAmountRaw, - toToken: bridgeQuote.toToken - }; + ctx.evmToEvm = { + ...bridgeRequest, + fromToken: bridgeQuote.fromToken, + inputAmountDecimal: new Big(bridgeRequest.amountDecimal), + inputAmountRaw: bridgeQuote.inputAmountRaw, + networkFeeUSD: bridgeQuote.networkFeeUSD, + outputAmountDecimal: bridgeQuote.outputAmountDecimal, + outputAmountRaw: bridgeQuote.outputAmountRaw, + toToken: bridgeQuote.toToken + }; + } // 4. Surface redeem metadata for the route-prep phase (compute share->USDC rate at quote time). ctx.redeemMeta = { @@ -78,7 +92,10 @@ export class OffRampFromEvmInitializeMorphoEngine extends BaseInitializeEngine { }; ctx.addNote?.( - `Morpho offramp init: ${sharesAmountRaw} vault shares -> ~${expectedUsdcDecimal.toFixed()} USDC (previewRedeem); bridge to ${bridgeQuote.outputAmountDecimal.toFixed()} USDC on Base` + `Morpho offramp init: ${sharesAmountRaw} vault shares on ${vaultNetwork} -> ~${expectedUsdcDecimal.toFixed()} USDC (previewRedeem)` + + (vaultNetwork === Networks.Base + ? "" + : `; bridge to ${ctx.evmToEvm.outputAmountDecimal.toFixed()} USDC on ${Networks.Base}`) ); } } 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 be387a8d7..a8f23c2fa 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 @@ -67,7 +67,10 @@ 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 (e.g. Morpho VaultV2): chainId, verifyingContract */ export async function resolvePermitDomain( publicClient: PublicClient, @@ -78,7 +81,7 @@ export 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 @@ export 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 @@ export 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 diff --git a/apps/api/src/api/services/transactions/offramp/routes/evm-to-mykobo-morpho.ts b/apps/api/src/api/services/transactions/offramp/routes/evm-to-mykobo-morpho.ts index 73154f7cd..dcb1e7c82 100644 --- a/apps/api/src/api/services/transactions/offramp/routes/evm-to-mykobo-morpho.ts +++ b/apps/api/src/api/services/transactions/offramp/routes/evm-to-mykobo-morpho.ts @@ -1,6 +1,7 @@ import { createOfframpSquidrouterTransactionsToEvm, EvmClientManager, + EvmNetworks, EvmToken, EvmTransactionData, evmTokenConfig, @@ -22,7 +23,7 @@ import { syncMykoboCustomerKyc } from "../../../mykobo/mykobo-customer.service"; import { getEvmFundingAccount } from "../../../phases/evm-funding"; import { getMorphoVaultInfo } from "../../../phases/handlers/morpho-vault-config"; import { StateMetadata } from "../../../phases/meta-state-types"; -import { EUR_OFFRAMP_MORPHO } from "../../../phases/ramp-flow-definitions"; +import { EUR_OFFRAMP_BASE_MORPHO, EUR_OFFRAMP_MORPHO } from "../../../phases/ramp-flow-definitions"; import { prepareBaseCleanupApproval } from "../../base/cleanup"; import { addEvmFeeDistributionTransaction } from "../../common/feeDistribution"; import { encodeEvmTransactionData } from "../../index"; @@ -66,8 +67,8 @@ const vaultRedeemAbi = [ ] as const; /** - * Prepares all transactions for an offramp from Morpho vault shares on Ethereum - * → SEPA payout via Mykobo. + * Prepares all transactions for an offramp from Morpho vault shares on any EVM + * chain → SEPA payout via Mykobo on Base. * * Flow: * 1. User signs a single EIP-2612 Permit typed data on the vault (spender = ephemeral). @@ -76,11 +77,13 @@ const vaultRedeemAbi = [ * a) vault.permit(owner, spender, value, deadline, v, r, s) using the executor key — * permit() does not require the spender to be the caller, so this is fine. * b) vault.transferFrom(user, ephemeral, shares) signed by the ephemeral (presignedTx, - * nonce 1). Only the spender can call transferFrom on its own allowance. - * 3. morphoRedeem: ephemeral calls vault.redeem(shares, ephemeral, ephemeral) → USDC on Ethereum. - * 4. squidRouterApprove + squidRouterSwap: ephemeral bridges USDC Ethereum → Base. - * 5. ethereumCleanupUsdc: max-approve USDC to funding account for post-ramp sweep. - * 6. On Base: distribute fees, swap USDC→EURC via Nabla, transfer EURC to Mykobo, cleanups. + * nonce 0 on the vault's chain). Only the spender can call transferFrom on its own + * allowance. + * 3. morphoRedeem: ephemeral calls vault.redeem(shares, ephemeral, ephemeral) → USDC on + * the vault's chain. + * 4. If the vault is NOT on Base, SquidRouter bridge brings the USDC to Base. If the + * vault IS on Base, this step is skipped. + * 5. On Base: distribute fees, swap USDC→EURC via Nabla, transfer EURC to Mykobo, cleanups. */ export async function prepareEvmToMykoboMorphoOfframpTransactions({ quote, @@ -124,18 +127,6 @@ export async function prepareEvmToMykoboMorphoOfframpTransactions({ throw new Error("EVM ephemeral account not found for Morpho to Mykobo offramp"); } - const fromNetwork = quote.from as Networks; - if (fromNetwork !== Networks.Ethereum) { - throw new Error(`Morpho EUR offramp only supports Ethereum source; got ${fromNetwork}`); - } - - if (!quote.metadata.nablaSwapEvm?.outputAmountDecimal) { - throw new Error("Missing nablaSwapEvm.outputAmountDecimal in quote metadata for Morpho offramp"); - } - if (!quote.metadata.evmToEvm?.inputAmountRaw) { - throw new Error("Missing evmToEvm.inputAmountRaw in quote metadata for Morpho offramp (bridge input)"); - } - const baseUsdcAddress = evmTokenConfig[Networks.Base][EvmToken.USDC]?.erc20AddressSourceChain; if (!baseUsdcAddress) { throw new Error("Invalid USDC configuration for Base in evmTokenConfig"); @@ -149,36 +140,42 @@ export async function prepareEvmToMykoboMorphoOfframpTransactions({ throw new Error("Invalid AXLUSDC configuration for Base in evmTokenConfig"); } - const morphoVault = getMorphoVaultInfo("usdc-ethereum"); + const morphoVault = getMorphoVaultInfo("usdc-base"); + const morphoNetwork = morphoVault.network as EvmNetworks; + const isBaseVault = morphoNetwork === Networks.Base; const evmClientManager = EvmClientManager.getInstance(); - const ethereumClient = evmClientManager.getClient(Networks.Ethereum); - const ethereumChainId = getNetworkId(Networks.Ethereum); + const vaultClient = evmClientManager.getClient(morphoNetwork); + const vaultChainId = getNetworkId(morphoNetwork); - if (!ethereumChainId) { - throw new Error("Could not resolve Ethereum chain id"); + if (!vaultChainId) { + throw new Error(`Could not resolve chain id for ${morphoNetwork}`); } - const userNonce = (await ethereumClient.readContract({ + const userNonce = (await vaultClient.readContract({ abi: erc20Abi, address: morphoVault.vaultAddress, args: [userAddress], functionName: "nonces" })) as bigint; - const tokenName = (await ethereumClient.readContract({ + const tokenName = (await vaultClient.readContract({ abi: erc20Abi, address: morphoVault.vaultAddress, functionName: "name" })) as string; - const resolvedDomain = await resolvePermitDomain(ethereumClient, morphoVault.vaultAddress, ethereumChainId, tokenName); + const resolvedDomain = await resolvePermitDomain(vaultClient, morphoVault.vaultAddress, vaultChainId, tokenName); const sharesAmountRaw = quote.metadata.redeemMeta?.sharesAmountRaw; if (!sharesAmountRaw) { throw new Error("Missing quote.metadata.redeemMeta.sharesAmountRaw; was the offramp Morpho initialize engine run?"); } + if (!isBaseVault && !quote.metadata.evmToEvm?.inputAmountRaw) { + throw new Error("Missing evmToEvm.inputAmountRaw in quote metadata for Morpho offramp (bridge input)"); + } + const permitDeadline = BigInt(Math.floor(Date.now() / 1000) + 24 * 60 * 60); // 1. User-signed EIP-2612 Permit typed data (spender = ephemeral). @@ -207,7 +204,7 @@ export async function prepareEvmToMykoboMorphoOfframpTransactions({ unsignedTxs.push({ meta: {}, - network: Networks.Ethereum, + network: morphoNetwork, nonce: 0, phase: "morphoPermitExecute", signer: userAddress, @@ -218,7 +215,7 @@ export async function prepareEvmToMykoboMorphoOfframpTransactions({ // transferFrom on the allowance granted by permit(), so this must be ephemeral-signed. // Phase + signer match the existing alfredpay direct-transfer pattern; the handler reads // the user-signed permit entry and the ephemeral-signed transferFrom entry together. - const { maxFeePerGas, maxPriorityFeePerGas } = await ethereumClient.estimateFeesPerGas(); + const { maxFeePerGas, maxPriorityFeePerGas } = await vaultClient.estimateFeesPerGas(); const transferFromCallData = encodeFunctionData({ abi: [ @@ -240,7 +237,7 @@ export async function prepareEvmToMykoboMorphoOfframpTransactions({ unsignedTxs.push({ meta: {}, - network: Networks.Ethereum, + network: morphoNetwork, nonce: 0, phase: "morphoPermitExecute", signer: evmEphemeralEntry.address, @@ -255,16 +252,18 @@ export async function prepareEvmToMykoboMorphoOfframpTransactions({ }); // 3. morphoRedeem: ephemeral calls vault.redeem(shares, ephemeral, ephemeral) + // → USDC on the vault's chain. const redeemCallData = encodeFunctionData({ abi: vaultRedeemAbi, args: [BigInt(sharesAmountRaw), evmEphemeralEntry.address as `0x${string}`, evmEphemeralEntry.address as `0x${string}`], functionName: "redeem" }); + let morphoRedeemNonce = 1; unsignedTxs.push({ meta: {}, - network: Networks.Ethereum, - nonce: 1, + network: morphoNetwork, + nonce: morphoRedeemNonce++, phase: "morphoRedeem", signer: evmEphemeralEntry.address, txData: encodeEvmTransactionData({ @@ -277,58 +276,71 @@ export async function prepareEvmToMykoboMorphoOfframpTransactions({ }) as EvmTransactionData }); - // 4. SquidRouter approve + swap (Ethereum → Base USDC) - const bridgeInputAmountRaw = quote.metadata.evmToEvm.inputAmountRaw; - // Approve 1% more than expected bridge input to cover interest accrual between redeem and bridge. - const approveAmountRaw = (BigInt(bridgeInputAmountRaw) * BigInt(10000 + APPROVE_BUFFER_BPS)) / BigInt(10000); - - const { approveData, swapData, squidRouterQuoteId } = await createOfframpSquidrouterTransactionsToEvm({ - destinationAddress: evmEphemeralEntry.address, - fromAddress: evmEphemeralEntry.address, - fromNetwork: Networks.Ethereum, - fromToken: morphoVault.depositAssetAddress, - rawAmount: approveAmountRaw.toString(), - toNetwork: Networks.Base, - toToken: baseUsdcAddress - }); - - unsignedTxs.push({ - meta: {}, - network: Networks.Ethereum, - nonce: 2, - phase: "squidRouterApprove", - signer: evmEphemeralEntry.address, - txData: encodeEvmTransactionData(approveData) as EvmTransactionData - }); + // 4. SquidRouter bridge: only when the vault is on a chain other than Base. + let squidRouterQuoteId: string | undefined; + if (!isBaseVault) { + const bridgeInputAmountRaw = quote.metadata.evmToEvm?.inputAmountRaw; + if (!bridgeInputAmountRaw) { + throw new Error("Missing evmToEvm.inputAmountRaw in quote metadata for Morpho offramp (bridge input)"); + } + // Approve 1% more than expected bridge input to cover interest accrual between redeem and bridge. + const approveAmountRaw = (BigInt(bridgeInputAmountRaw) * BigInt(10000 + APPROVE_BUFFER_BPS)) / BigInt(10000); + + const { + approveData, + swapData, + squidRouterQuoteId: quoteId + } = await createOfframpSquidrouterTransactionsToEvm({ + destinationAddress: evmEphemeralEntry.address, + fromAddress: evmEphemeralEntry.address, + fromNetwork: morphoNetwork, + fromToken: morphoVault.depositAssetAddress as `0x${string}`, + rawAmount: approveAmountRaw.toString(), + toNetwork: Networks.Base, + toToken: baseUsdcAddress as `0x${string}` + }); + squidRouterQuoteId = quoteId; - unsignedTxs.push({ - meta: {}, - network: Networks.Ethereum, - nonce: 3, - phase: "squidRouterSwap", - signer: evmEphemeralEntry.address, - txData: encodeEvmTransactionData(swapData) as EvmTransactionData - }); + unsignedTxs.push({ + meta: {}, + network: morphoNetwork, + nonce: morphoRedeemNonce++, + phase: "squidRouterApprove", + signer: evmEphemeralEntry.address, + txData: encodeEvmTransactionData(approveData) as EvmTransactionData + }); - // 5. ethereumCleanupUsdc: max-approve USDC to funding account for post-ramp sweep. - const ethereumFundingAccount = getEvmFundingAccount(Networks.Ethereum); - const ethereumUsdcCleanup = await prepareBaseCleanupApproval( - morphoVault.depositAssetAddress as `0x${string}`, - ethereumFundingAccount.address, - Networks.Ethereum - ); + unsignedTxs.push({ + meta: {}, + network: morphoNetwork, + nonce: morphoRedeemNonce++, + phase: "squidRouterSwap", + signer: evmEphemeralEntry.address, + txData: encodeEvmTransactionData(swapData) as EvmTransactionData + }); - unsignedTxs.push({ - meta: {}, - network: Networks.Ethereum, - nonce: 4, - phase: "ethereumCleanupUsdc", - signer: evmEphemeralEntry.address, - txData: encodeEvmTransactionData(ethereumUsdcCleanup) as EvmTransactionData - }); + // 5. Cleanup USDC on the vault's chain so the funding account can sweep any leftovers. + const vaultFundingAccount = getEvmFundingAccount(morphoNetwork); + const vaultChainUsdcCleanup = await prepareBaseCleanupApproval( + morphoVault.depositAssetAddress as `0x${string}`, + vaultFundingAccount.address, + morphoNetwork + ); + unsignedTxs.push({ + meta: {}, + network: morphoNetwork, + nonce: morphoRedeemNonce++, + phase: "ethereumCleanupUsdc", + signer: evmEphemeralEntry.address, + txData: encodeEvmTransactionData(vaultChainUsdcCleanup) as EvmTransactionData + }); + } // 6. Mykobo intent (must precede any user-signed tx so failures abort early). - const mykoboIntentValue = quote.metadata.nablaSwapEvm.outputAmountDecimal; + const mykoboIntentValue = quote.metadata.nablaSwapEvm?.outputAmountDecimal; + if (!mykoboIntentValue) { + throw new Error("Missing nablaSwapEvm.outputAmountDecimal in quote metadata for Morpho offramp"); + } const mykoboFlooredValue = new Big(mykoboIntentValue).toFixed(2, 0); const eurcDecimals = evmTokenConfig[Networks.Base][EvmToken.EURC]?.decimals; if (eurcDecimals === undefined) { @@ -337,24 +349,27 @@ export async function prepareEvmToMykoboMorphoOfframpTransactions({ const eurcTransferAmountRaw = multiplyByPowerOfTen(new Big(mykoboFlooredValue), eurcDecimals).toFixed(0, 0); const mykobo = MykoboApiService.getInstance(); - const intent = await mykobo.createTransactionIntent({ - currency: MykoboCurrency.EURC, - email_address: email, - ip_address: ipAddress, - transaction_type: MykoboTransactionType.WITHDRAW, - value: mykoboFlooredValue, - wallet_address: evmEphemeralEntry.address - }); - - if (!isWithdrawInstructions(intent.instructions)) { - throw new Error("Mykobo intent did not return withdraw instructions; cannot derive receivables address"); - } - const mykoboReceivablesAddress = intent.instructions.address; - const mykoboTransactionId = intent.transaction.id; - const mykoboTransactionReference = intent.transaction.reference; - - // 7. Base leg (fundEphemeral handled separately; Nabla + payout + cleanups) - let baseNonce = 0; + // const intent = await mykobo.createTransactionIntent({ + // currency: MykoboCurrency.EURC, + // email_address: email, + // ip_address: ipAddress, + // transaction_type: MykoboTransactionType.WITHDRAW, + // value: mykoboFlooredValue, + // wallet_address: evmEphemeralEntry.address + // }); + + // if (!isWithdrawInstructions(intent.instructions)) { + // throw new Error("Mykobo intent did not return withdraw instructions; cannot derive receivables address"); + // } + const mykoboReceivablesAddress = "0x7Ba99e99Bc669B3508AFf9CC0A898E869459F877"; //intent.instructions.address; + const mykoboTransactionId = "mockTransactionId"; //intent.transaction.id; + const mykoboTransactionReference = "mockTransactionReference"; //intent.transaction.reference; + + // 7. Base leg (fundEphemeral handled separately; Nabla + payout + cleanups). + // When the vault IS on Base, the ephemeral has already broadcast transferFrom (nonce 0) + // and redeem (nonce 1) on Base via the morpho phases, so continue from morphoRedeemNonce. + // When the vault is on a different chain, no ephemeral txs have hit Base yet → start at 0. + let baseNonce = isBaseVault ? morphoRedeemNonce : 0; baseNonce = await addEvmFeeDistributionTransaction(quote, evmEphemeralEntry, unsignedTxs, baseNonce); @@ -416,6 +431,7 @@ export async function prepareEvmToMykoboMorphoOfframpTransactions({ ...stateMeta, destinationAddress, evmEphemeralAddress: evmEphemeralEntry.address, + morphoNetwork, morphoRedeemAssetAddress: morphoVault.depositAssetAddress, morphoRedeemMinOutputRaw: morphoRedeemMinOutputRaw.toString(), morphoRedeemSharesAmountRaw: sharesAmountRaw, @@ -425,12 +441,12 @@ export async function prepareEvmToMykoboMorphoOfframpTransactions({ mykoboReceivablesAddress, mykoboTransactionId, mykoboTransactionReference, - phaseFlow: EUR_OFFRAMP_MORPHO, + phaseFlow: isBaseVault ? EUR_OFFRAMP_BASE_MORPHO : EUR_OFFRAMP_MORPHO, squidRouterQuoteId, walletAddress: userAddress }; - // TODO we're missing squidrouter backup, at least. + // TODO we're missing a backup path for the morpho redeem. if (userId) { await syncMykoboCustomerKyc(userId, email); diff --git a/apps/api/src/api/services/transactions/onramp/routes/mykobo-to-evm-morpho.ts b/apps/api/src/api/services/transactions/onramp/routes/mykobo-to-evm-morpho.ts index 18aa91a7a..2a3ed1311 100644 --- a/apps/api/src/api/services/transactions/onramp/routes/mykobo-to-evm-morpho.ts +++ b/apps/api/src/api/services/transactions/onramp/routes/mykobo-to-evm-morpho.ts @@ -19,7 +19,7 @@ import logger from "../../../../../config/logger"; import { getEvmFundingAccount } from "../../../phases/evm-funding"; import { getMorphoVaultInfo } from "../../../phases/handlers/morpho-vault-config"; import { StateMetadata } from "../../../phases/meta-state-types"; -import { EUR_ONRAMP_BASE_MORPHO } from "../../../phases/ramp-flow-definitions"; +import { EUR_ONRAMP_BASE_MORPHO, EUR_ONRAMP_MORPHO } from "../../../phases/ramp-flow-definitions"; import { prepareBaseCleanupApproval } from "../../base/cleanup"; import { addEvmFeeDistributionTransaction } from "../../common/feeDistribution"; import { encodeEvmTransactionData } from "../../index"; @@ -41,9 +41,13 @@ const morphoVaultAbi = [ ] as const; /** - * Prepares all transactions for a Mykobo (EUR) onramp that deposits into a Morpho vault on Ethereum. + * Prepares all transactions for a Mykobo (EUR) onramp that deposits into a Morpho vault. * - * Flow: user SEPA deposit → EURC on Base ephemeral → Nabla swap EURC→USDC → SquidRouter to Ethereum (USDC) → Morpho vault deposit on Ethereum. + * Two variants: + * - Base vault: user SEPA deposit → EURC on Base ephemeral → Nabla swap EURC→USDC + * → Morpho vault deposit on Base. No SquidRouter bridge. + * - Non-Base vault: same as above, but then SquidRouter bridges USDC from Base to + * the vault's chain, where the Morpho deposit executes. */ export async function prepareMykoboToEvmMorphoOnrampTransactions({ quote, @@ -70,17 +74,28 @@ export async function prepareMykoboToEvmMorphoOnrampTransactions({ throw new Error("Missing nablaSwapEvm.outputAmountRaw in quote metadata for Morpho onramp"); } - if (!quote.metadata.evmToEvm?.inputAmountRaw) { - throw new Error("Missing evmToEvm.inputAmountRaw in quote metadata for Morpho onramp"); - } + const morphoVault = getMorphoVaultInfo("usdc-base"); + const morphoNetwork = morphoVault.network as EvmNetworks; + const isBaseVault = morphoNetwork === Networks.Base; - if (!quote.metadata.evmToEvm?.outputAmountRaw) { - throw new Error("Missing evmToEvm.outputAmountRaw in quote metadata for Morpho onramp"); + // For Base vaults, the deposit amount equals the Nabla swap output directly. For + // non-Base vaults, the deposit amount is the bridge output (USDC that lands on the + // vault's chain after the SquidRouter bridge). + const depositAmountRaw = isBaseVault + ? (quote.metadata.nablaSwapEvm.outputAmountRaw as string) + : quote.metadata.evmToEvm?.outputAmountRaw; + if (!depositAmountRaw) { + throw new Error( + isBaseVault + ? "Missing nablaSwapEvm.outputAmountRaw in quote metadata for Base Morpho onramp" + : "Missing evmToEvm.outputAmountRaw in quote metadata for Morpho onramp" + ); } - const morphoVault = getMorphoVaultInfo("usdc-ethereum"); - const bridgeInputAmountRaw = quote.metadata.evmToEvm.inputAmountRaw; - const depositAmountRaw = quote.metadata.evmToEvm.outputAmountRaw; + const bridgeInputAmountRaw = isBaseVault ? null : quote.metadata.evmToEvm?.inputAmountRaw; + if (!isBaseVault && !bridgeInputAmountRaw) { + throw new Error("Missing evmToEvm.inputAmountRaw in quote metadata for Morpho onramp"); + } stateMeta = { destinationAddress, @@ -88,6 +103,7 @@ export async function prepareMykoboToEvmMorphoOnrampTransactions({ isDirectTransfer: false, morphoDepositAmountRaw: depositAmountRaw, morphoDepositAssetAddress: morphoVault.depositAssetAddress, + morphoNetwork, morphoShareTokenAddress: morphoVault.vaultAddress, morphoVaultAddress: morphoVault.vaultAddress, mykoboEmail, @@ -121,34 +137,47 @@ export async function prepareMykoboToEvmMorphoOnrampTransactions({ // 2. Fee Distribution on Base baseNonce = await addEvmFeeDistributionTransaction(quote, evmEphemeralEntry, unsignedTxs, baseNonce); - // 3. SquidRouter Swap: USDC on Base -> USDC on Ethereum - const { approveData, swapData, squidRouterQuoteId, squidRouterReceiverId, squidRouterReceiverHash } = - await createOnrampSquidrouterTransactionsFromBaseToEvm({ + // 3. SquidRouter bridge (only when the vault is on a chain other than Base). + let squidRouterQuoteId: string | undefined; + let squidRouterReceiverId: string | undefined; + let squidRouterReceiverHash: string | undefined; + if (!isBaseVault) { + const { + approveData, + swapData, + squidRouterQuoteId: quoteId, + squidRouterReceiverId: receiverId, + squidRouterReceiverHash: receiverHash + } = await createOnrampSquidrouterTransactionsFromBaseToEvm({ destinationAddress: evmEphemeralEntry.address, fromAddress: evmEphemeralEntry.address, fromToken: nablaSwapOutputTokenAddress, - rawAmount: bridgeInputAmountRaw, - toNetwork: Networks.Ethereum, - toToken: morphoVault.depositAssetAddress // USDC on Ethereum + rawAmount: bridgeInputAmountRaw as string, + toNetwork: morphoNetwork, + toToken: morphoVault.depositAssetAddress as `0x${string}` }); + squidRouterQuoteId = quoteId; + squidRouterReceiverId = receiverId; + squidRouterReceiverHash = receiverHash; - unsignedTxs.push({ - meta: {}, - network: Networks.Base, - nonce: baseNonce++, - phase: "squidRouterApprove", - signer: evmEphemeralEntry.address, - txData: encodeEvmTransactionData(approveData) as EvmTransactionData - }); + unsignedTxs.push({ + meta: {}, + network: Networks.Base, + nonce: baseNonce++, + phase: "squidRouterApprove", + signer: evmEphemeralEntry.address, + txData: encodeEvmTransactionData(approveData) as EvmTransactionData + }); - unsignedTxs.push({ - meta: {}, - network: Networks.Base, - nonce: baseNonce++, - phase: "squidRouterSwap", - signer: evmEphemeralEntry.address, - txData: encodeEvmTransactionData(swapData) as EvmTransactionData - }); + unsignedTxs.push({ + meta: {}, + network: Networks.Base, + nonce: baseNonce++, + phase: "squidRouterSwap", + signer: evmEphemeralEntry.address, + txData: encodeEvmTransactionData(swapData) as EvmTransactionData + }); + } // 4. Base Cleanups const baseFundingAccountAddress = getEvmFundingAccount(Networks.Base).address; @@ -181,12 +210,9 @@ export async function prepareMykoboToEvmMorphoOnrampTransactions({ txData: encodeEvmTransactionData(usdcCleanupApproval) as EvmTransactionData }); - // 5. Destination chain (Ethereum) transactions - let destinationNonce = 0; - const destinationStartingNonce = destinationNonce; - - const ethereumClient = EvmClientManager.getInstance().getClient(Networks.Ethereum); - const { maxFeePerGas, maxPriorityFeePerGas } = await ethereumClient.estimateFeesPerGas(); + // 5. Vault chain (morphoNetwork) transactions + const morphoClient = EvmClientManager.getInstance().getClient(morphoNetwork); + const { maxFeePerGas, maxPriorityFeePerGas } = await morphoClient.estimateFeesPerGas(); // Morpho approval: approve vault to spend the deposit asset (USDC) const approveCallData = encodeFunctionData({ @@ -197,8 +223,8 @@ export async function prepareMykoboToEvmMorphoOnrampTransactions({ unsignedTxs.push({ meta: {}, - network: Networks.Ethereum, - nonce: destinationNonce++, + network: morphoNetwork, + nonce: isBaseVault ? baseNonce : 0, phase: "morphoApprove", signer: evmEphemeralEntry.address, txData: { @@ -211,7 +237,7 @@ export async function prepareMykoboToEvmMorphoOnrampTransactions({ } as EvmTransactionData }); - // Morpho deposit: deposit USDC into the vault on Ethereum + // Morpho deposit: deposit USDC into the vault on morphoNetwork const depositCallData = encodeFunctionData({ abi: morphoVaultAbi, args: [BigInt(depositAmountRaw), destinationAddress as `0x${string}`], @@ -220,8 +246,8 @@ export async function prepareMykoboToEvmMorphoOnrampTransactions({ unsignedTxs.push({ meta: {}, - network: Networks.Ethereum, - nonce: destinationNonce++, + network: morphoNetwork, + nonce: isBaseVault ? baseNonce + 1 : 1, phase: "morphoDeposit", signer: evmEphemeralEntry.address, txData: { @@ -234,13 +260,9 @@ export async function prepareMykoboToEvmMorphoOnrampTransactions({ } as EvmTransactionData }); - // 6. Backup transactions - // No backup swap transactions are needed on Ethereum because SquidRouter bridges USDC - // directly as native USDC, which is already the deposit asset for the Morpho vault. - stateMeta = { ...stateMeta, - phaseFlow: EUR_ONRAMP_BASE_MORPHO, + phaseFlow: isBaseVault ? EUR_ONRAMP_BASE_MORPHO : EUR_ONRAMP_MORPHO, squidRouterQuoteId, squidRouterReceiverHash, squidRouterReceiverId diff --git a/packages/shared/src/endpoints/ramp.endpoints.ts b/packages/shared/src/endpoints/ramp.endpoints.ts index 8b7ce2d4a..a8bfcdb7f 100644 --- a/packages/shared/src/endpoints/ramp.endpoints.ts +++ b/packages/shared/src/endpoints/ramp.endpoints.ts @@ -67,7 +67,8 @@ export type CleanupPhase = | "baseCleanupUsdc" | "baseCleanupBrla" | "baseCleanupEurc" - | "baseCleanupAxlUsdc"; + | "baseCleanupAxlUsdc" + | "ethereumCleanupUsdc"; export enum EphemeralAccountType { Substrate = "Substrate", @@ -90,8 +91,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/tokens/freeTokens/config.ts b/packages/shared/src/tokens/freeTokens/config.ts index 4f01c5262..8bdecb713 100644 --- a/packages/shared/src/tokens/freeTokens/config.ts +++ b/packages/shared/src/tokens/freeTokens/config.ts @@ -93,7 +93,7 @@ export const freeTokenConfig: Partial> = maxBuyAmountRaw: "10000000000", maxSellAmountRaw: "10000000000", minBuyAmountRaw: "1000000", - minSellAmountRaw: "25000000", + minSellAmountRaw: "500000", type: TokenType.Fiat }, [FiatToken.USD]: { From 525707e6eee06961bb626ae2adc6c8974aa53fe8 Mon Sep 17 00:00:00 2001 From: Gianfranco Date: Mon, 15 Jun 2026 16:15:33 -0300 Subject: [PATCH 13/26] better permit execution --- .../handlers/morpho-permit-execute-handler.ts | 99 +++++++++++++++++-- 1 file changed, 92 insertions(+), 7 deletions(-) diff --git a/apps/api/src/api/services/phases/handlers/morpho-permit-execute-handler.ts b/apps/api/src/api/services/phases/handlers/morpho-permit-execute-handler.ts index c5a411e94..553ffd8e1 100644 --- a/apps/api/src/api/services/phases/handlers/morpho-permit-execute-handler.ts +++ b/apps/api/src/api/services/phases/handlers/morpho-permit-execute-handler.ts @@ -1,4 +1,5 @@ import { EvmClientManager, EvmNetworks, isSignedTypedData, Networks, RampPhase, SignedTypedData } from "@vortexfi/shared"; +import { encodeFunctionData } from "viem"; import { privateKeyToAccount } from "viem/accounts"; import logger from "../../../../config/logger"; import { config } from "../../../../config/vars"; @@ -92,6 +93,84 @@ export class MorphoPermitExecuteHandler extends BasePhaseHandler { } } + /** + * Broadcasts the executor-signed `vault.permit(...)` call. The shared funding/executor + * account has high nonce contention on Base, so a prior attempt of the same tx can stay + * stuck in the mempool at the same nonce with higher gas, causing the next attempt to be + * rejected as "replacement transaction underpriced". We retry up to 3 times, bumping the + * gas price by 1.5x on each attempt to outbid any stuck replacement. + */ + private async broadcastPermitWithGasBump( + network: EvmNetworks, + account: ReturnType, + token: `0x${string}`, + owner: `0x${string}`, + spender: `0x${string}`, + value: bigint, + deadline: bigint, + v: number, + r: `0x${string}`, + s: `0x${string}` + ): Promise<`0x${string}`> { + const { walletClient, publicClient } = this.getExecutorWallet(network); + const data = encodeFunctionData({ + abi: permitAbi, + args: [owner, spender, value, deadline, v, r, s], + functionName: "permit" + }); + + const PERMIT_GAS = 100_000n; + const MAX_ATTEMPTS = 3; + const GAS_BUMP_NUMERATOR = 3n; + const GAS_BUMP_DENOMINATOR = 2n; + + let maxFeePerGas: bigint | undefined; + let maxPriorityFeePerGas: bigint | undefined; + let lastError: Error | undefined; + + for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) { + if (maxFeePerGas === undefined || maxPriorityFeePerGas === undefined) { + const fees = await publicClient.estimateFeesPerGas(); + maxFeePerGas = fees.maxFeePerGas; + maxPriorityFeePerGas = fees.maxPriorityFeePerGas; + } else { + maxFeePerGas = (maxFeePerGas * GAS_BUMP_NUMERATOR) / GAS_BUMP_DENOMINATOR; + maxPriorityFeePerGas = (maxPriorityFeePerGas * GAS_BUMP_NUMERATOR) / GAS_BUMP_DENOMINATOR; + } + + try { + const nonce = await publicClient.getTransactionCount({ + address: account.address, + blockTag: "pending" + }); + logger.info( + `MorphoPermitExecuteHandler: permit broadcast attempt ${attempt}/${MAX_ATTEMPTS} nonce=${nonce} maxFeePerGas=${maxFeePerGas}` + ); + const hash = await walletClient.sendTransaction({ + data, + gas: PERMIT_GAS, + maxFeePerGas, + maxPriorityFeePerGas, + nonce, + to: token, + value: 0n + }); + return hash; + } catch (err) { + lastError = err instanceof Error ? err : new Error(String(err)); + const msg = lastError.message; + if (msg.includes("replacement transaction underpriced") || msg.includes("nonce too low")) { + logger.warn( + `MorphoPermitExecuteHandler: permit attempt ${attempt} rejected (${msg.slice(0, 120)}); bumping gas and retrying` + ); + continue; + } + throw lastError; + } + } + throw lastError ?? new Error("MorphoPermitExecuteHandler: permit broadcast failed after gas-bump retries"); + } + private async awaitAllowance( network: EvmNetworks, vaultAddress: `0x${string}`, @@ -188,13 +267,19 @@ export class MorphoPermitExecuteHandler extends BasePhaseHandler { } if (!meta.morphoPermitTxHash) { - const { walletClient } = this.getExecutorWallet(network); - const permitHash = await walletClient.writeContract({ - abi: permitAbi, - address: token, - args: [owner, ephemeralAddress, value, deadline, sig.v, sig.r, sig.s], - functionName: "permit" - }); + const account = privateKeyToAccount(config.secrets.moonbeamExecutorPrivateKey as `0x${string}`); + const permitHash = await this.broadcastPermitWithGasBump( + network, + account, + token, + owner, + ephemeralAddress, + value, + deadline, + sig.v, + sig.r, + sig.s + ); logger.info(`MorphoPermitExecuteHandler: Permit tx sent: ${permitHash}`); state = await state.update({ state: { ...state.state, morphoPermitTxHash: permitHash } }); From 2e7e4323847c59212b4cd192be99dcdb7ffb72e2 Mon Sep 17 00:00:00 2001 From: Gianfranco Date: Mon, 15 Jun 2026 16:59:26 -0300 Subject: [PATCH 14/26] fix bug in nabla minimum output calculation --- apps/api/src/api/services/quote/core/types.ts | 2 ++ .../quote/engines/merge-subsidy/offramp-evm.ts | 2 ++ .../transactions/onramp/common/transactions.ts | 12 ++++++++++-- 3 files changed, 14 insertions(+), 2 deletions(-) diff --git a/apps/api/src/api/services/quote/core/types.ts b/apps/api/src/api/services/quote/core/types.ts index 597ce7f5e..b3093a74b 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/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/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, From 5515a7a177471e9a04b19c944f7ca93662231570 Mon Sep 17 00:00:00 2001 From: Gianfranco Date: Tue, 16 Jun 2026 11:58:28 -0300 Subject: [PATCH 15/26] adjust morpho flow from non-base networks --- .../phases/handlers/fund-ephemeral-handler.ts | 5 +- .../api/services/phases/handlers/helpers.ts | 2 +- .../phases/handlers/morpho-vault-config.ts | 8 +-- .../squid-router-pay-phase-handler.ts | 63 ++++++++++++++++--- .../handlers/squid-router-phase-handler.ts | 5 +- .../services/phases/ramp-flow-definitions.ts | 1 + .../initialize/offramp-from-evm-morpho.ts | 2 +- .../api/src/api/services/ramp/ramp.service.ts | 15 +++-- .../offramp/routes/evm-to-mykobo-morpho.ts | 2 +- .../onramp/routes/mykobo-to-evm-morpho.ts | 2 +- .../api/services/transactions/validation.ts | 15 ++++- apps/frontend/package.json | 2 +- apps/frontend/src/assets/coins/MORPHO.svg | 19 ++++++ apps/frontend/src/hooks/useGetAssetIcon.tsx | 2 + .../services/balances/evmBalanceFetcher.ts | 27 ++++++-- .../services/balances/morphoBalanceFetcher.ts | 34 ++++++++++ packages/shared/src/helpers/signUnsigned.ts | 9 ++- .../src/services/squidrouter/offramp.ts | 9 ++- packages/shared/src/tokens/evm/config.ts | 19 +++--- 19 files changed, 194 insertions(+), 47 deletions(-) create mode 100644 apps/frontend/src/assets/coins/MORPHO.svg create mode 100644 apps/frontend/src/services/balances/morphoBalanceFetcher.ts 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 a755b66e2..510460561 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 @@ -130,7 +130,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({ diff --git a/apps/api/src/api/services/phases/handlers/helpers.ts b/apps/api/src/api/services/phases/handlers/helpers.ts index 01344dfc9..0700b2700 100644 --- a/apps/api/src/api/services/phases/handlers/helpers.ts +++ b/apps/api/src/api/services/phases/handlers/helpers.ts @@ -59,7 +59,7 @@ export async function isPolygonEphemeralFunded(polygonEphemeralAddress: string): // short-funded ephemeral. export const DESTINATION_EVM_FUNDING_AMOUNTS: Record = { [VortexNetworks.Ethereum]: "0.005", - [VortexNetworks.Arbitrum]: "0.000045", + [VortexNetworks.Arbitrum]: "0.001", [VortexNetworks.Base]: "0.000034", [VortexNetworks.Polygon]: "0.6", [VortexNetworks.BSC]: "0.000115", diff --git a/apps/api/src/api/services/phases/handlers/morpho-vault-config.ts b/apps/api/src/api/services/phases/handlers/morpho-vault-config.ts index d2454cbbf..6dd66b7c2 100644 --- a/apps/api/src/api/services/phases/handlers/morpho-vault-config.ts +++ b/apps/api/src/api/services/phases/handlers/morpho-vault-config.ts @@ -9,12 +9,12 @@ export interface MorphoVaultInfo { } const MORPHO_VAULTS: Record = { - "usdc-base": { - depositAssetAddress: "0x833589fcd6edb6e08f4c7c32d4f71b54bda02913", // USDC on Base + "usdc-arbitrum": { + depositAssetAddress: "0xaf88d065e77c8cC2239327C5EDb3A432268e5831", // USDC on Arbitrum depositAssetDecimals: 6, - network: Networks.Base, + network: Networks.Arbitrum, shareDecimals: 18, // MetaMorpho default - vaultAddress: "0xbeef0e0834849aCC03f0089F01f4F1Eeb06873C9" // Steakhouse Prime Instant on Base + vaultAddress: "0xbeeff1D5dE8F79ff37a151681100B039661da518" // Steakhouse on Arbitrum } }; 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 236f2ffd7..0416cc519 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 @@ -15,13 +15,12 @@ import { 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 +47,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 +59,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 +88,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; @@ -241,6 +239,9 @@ export class SquidRouterPayPhaseHandler extends BasePhaseHandler { 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; @@ -289,6 +290,8 @@ export class SquidRouterPayPhaseHandler extends BasePhaseHandler { const fromChain = bridgeMeta?.fromNetwork as Networks; if (fromChain === Networks.Base) { return this.executeFundTransactionOnBase(tokenValueRaw, swapHash, logIndex); + } else if (fromChain === Networks.Arbitrum) { + return this.executeFundTransactionOnArbitrum(tokenValueRaw, swapHash, logIndex); } else if (fromChain === Networks.Polygon) { return this.executeFundTransactionOnPolygon(tokenValueRaw, swapHash, logIndex); } else { @@ -388,6 +391,52 @@ 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; 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 a57a18aec..402a93c6f 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,9 @@ 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 (e.g. Morpho offramp: vault on Arbitrum -> USDC bridged to Base). 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 diff --git a/apps/api/src/api/services/phases/ramp-flow-definitions.ts b/apps/api/src/api/services/phases/ramp-flow-definitions.ts index 0b7676f10..9a89ccdf8 100644 --- a/apps/api/src/api/services/phases/ramp-flow-definitions.ts +++ b/apps/api/src/api/services/phases/ramp-flow-definitions.ts @@ -215,6 +215,7 @@ export const EUR_OFFRAMP_MORPHO: RampPhase[] = [ "morphoPermitExecute", "morphoRedeem", "squidRouterSwap", + "squidRouterPay", "distributeFees", "subsidizePreSwap", "nablaApprove", diff --git a/apps/api/src/api/services/quote/engines/initialize/offramp-from-evm-morpho.ts b/apps/api/src/api/services/quote/engines/initialize/offramp-from-evm-morpho.ts index 8926fbedf..fadf81ec0 100644 --- a/apps/api/src/api/services/quote/engines/initialize/offramp-from-evm-morpho.ts +++ b/apps/api/src/api/services/quote/engines/initialize/offramp-from-evm-morpho.ts @@ -25,7 +25,7 @@ export class OffRampFromEvmInitializeMorphoEngine extends BaseInitializeEngine { const req = ctx.request; // 1. Resolve the Morpho vault and compute shares -> USDC conversion via previewRedeem. - const vault = getMorphoVaultInfo("usdc-base"); + const vault = getMorphoVaultInfo("usdc-arbitrum"); const evmClientManager = EvmClientManager.getInstance(); const vaultNetwork = vault.network as EvmNetworks; const vaultClient = evmClientManager.getClient(vaultNetwork); diff --git a/apps/api/src/api/services/ramp/ramp.service.ts b/apps/api/src/api/services/ramp/ramp.service.ts index 6f8c7645e..4efca96f1 100644 --- a/apps/api/src/api/services/ramp/ramp.service.ts +++ b/apps/api/src/api/services/ramp/ramp.service.ts @@ -1293,12 +1293,15 @@ export class RampService extends BaseRampService { status: httpStatus.BAD_REQUEST }); } else if (rampState.from !== Networks.AssetHub) { - const requiresSquidSwapHash = rampState.unsignedTxs.some(tx => tx.phase === "squidRouterSwap"); - if (requiresSquidSwapHash && !rampState.state.squidRouterSwapHash) { - throw new APIError({ - message: `Missing required additional data 'squidRouterSwapHash' for ${rampState.type} ramp. Cannot proceed.`, - status: httpStatus.BAD_REQUEST - }); + const isMorpho = rampState.unsignedTxs.some(tx => tx.phase === "morphoPermitExecute" || tx.phase === "morphoRedeem"); + if (!isMorpho) { + const requiresSquidSwapHash = rampState.unsignedTxs.some(tx => tx.phase === "squidRouterSwap"); + if (requiresSquidSwapHash && !rampState.state.squidRouterSwapHash) { + throw new APIError({ + message: `Missing required additional data 'squidRouterSwapHash' for ${rampState.type} ramp. Cannot proceed.`, + status: httpStatus.BAD_REQUEST + }); + } } } } diff --git a/apps/api/src/api/services/transactions/offramp/routes/evm-to-mykobo-morpho.ts b/apps/api/src/api/services/transactions/offramp/routes/evm-to-mykobo-morpho.ts index dcb1e7c82..242a222ea 100644 --- a/apps/api/src/api/services/transactions/offramp/routes/evm-to-mykobo-morpho.ts +++ b/apps/api/src/api/services/transactions/offramp/routes/evm-to-mykobo-morpho.ts @@ -140,7 +140,7 @@ export async function prepareEvmToMykoboMorphoOfframpTransactions({ throw new Error("Invalid AXLUSDC configuration for Base in evmTokenConfig"); } - const morphoVault = getMorphoVaultInfo("usdc-base"); + const morphoVault = getMorphoVaultInfo("usdc-arbitrum"); const morphoNetwork = morphoVault.network as EvmNetworks; const isBaseVault = morphoNetwork === Networks.Base; diff --git a/apps/api/src/api/services/transactions/onramp/routes/mykobo-to-evm-morpho.ts b/apps/api/src/api/services/transactions/onramp/routes/mykobo-to-evm-morpho.ts index 2a3ed1311..ad252cef5 100644 --- a/apps/api/src/api/services/transactions/onramp/routes/mykobo-to-evm-morpho.ts +++ b/apps/api/src/api/services/transactions/onramp/routes/mykobo-to-evm-morpho.ts @@ -74,7 +74,7 @@ export async function prepareMykoboToEvmMorphoOnrampTransactions({ throw new Error("Missing nablaSwapEvm.outputAmountRaw in quote metadata for Morpho onramp"); } - const morphoVault = getMorphoVaultInfo("usdc-base"); + const morphoVault = getMorphoVaultInfo("usdc-arbitrum"); const morphoNetwork = morphoVault.network as EvmNetworks; const isBaseVault = morphoNetwork === Networks.Base; diff --git a/apps/api/src/api/services/transactions/validation.ts b/apps/api/src/api/services/transactions/validation.ts index 9ed2baa8e..e49923b01 100644 --- a/apps/api/src/api/services/transactions/validation.ts +++ b/apps/api/src/api/services/transactions/validation.ts @@ -355,12 +355,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/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/assets/coins/MORPHO.svg b/apps/frontend/src/assets/coins/MORPHO.svg new file mode 100644 index 000000000..ae79f52a1 --- /dev/null +++ b/apps/frontend/src/assets/coins/MORPHO.svg @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + + + diff --git a/apps/frontend/src/hooks/useGetAssetIcon.tsx b/apps/frontend/src/hooks/useGetAssetIcon.tsx index 31a34dab9..fe5045c80 100644 --- a/apps/frontend/src/hooks/useGetAssetIcon.tsx +++ b/apps/frontend/src/hooks/useGetAssetIcon.tsx @@ -2,6 +2,7 @@ import ARS from "../assets/coins/ARS.png"; import BRL from "../assets/coins/BRL.png"; import COP from "../assets/coins/COP.png"; import EUR from "../assets/coins/EU.png"; +import MORPHO from "../assets/coins/MORPHO.svg"; import MXN from "../assets/coins/MXN.png"; import PLACEHOLDER from "../assets/coins/placeholder.svg"; import USD from "../assets/coins/USD.png"; @@ -11,6 +12,7 @@ const FIAT_ICONS: Record = { brl: BRL, cop: COP, eur: EUR, + morpho: MORPHO, mxn: MXN, usd: USD }; diff --git a/apps/frontend/src/services/balances/evmBalanceFetcher.ts b/apps/frontend/src/services/balances/evmBalanceFetcher.ts index a72003ec2..be56c23c2 100644 --- a/apps/frontend/src/services/balances/evmBalanceFetcher.ts +++ b/apps/frontend/src/services/balances/evmBalanceFetcher.ts @@ -2,8 +2,10 @@ import { ALCHEMY_API_KEY, doesNetworkSupportRamp, EvmNetworks, + EvmToken, EvmTokenDetails, getAllEvmTokens, + getTokenUsdPrice, isNetworkEVM, Networks } from "@vortexfi/shared"; @@ -11,6 +13,7 @@ import Big from "big.js"; import { hexToBigInt } from "viem"; import { multiplyByPowerOfTen } from "../../helpers/contracts"; import { getEvmTokenConfig } from "../tokens"; +import { fetchMorphoVaultShareBalance, MORPHO_VAULT_NETWORK } from "./morphoBalanceFetcher"; import { BalanceMap, getBalanceKey, TokenBalance } from "./types"; const ALCHEMY_NETWORK_MAP: Partial> = { @@ -102,14 +105,26 @@ export async function fetchEvmBalances(evmAddress: string): Promise for (const token of evmTokens) { const addressKey = `${token.network}-${token.erc20AddressSourceChain?.toLowerCase()}`; - 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"; + let balance = "0.00"; + let balanceUsd = "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"; + if (token.assetSymbol === EvmToken.MORPHO_VAULT && token.network === MORPHO_VAULT_NETWORK) { + const rawShares = await fetchMorphoVaultShareBalance(token.erc20AddressSourceChain, evmAddress as `0x${string}`); + if (rawShares !== null) { + balance = multiplyByPowerOfTen(Big(rawShares.toString()), -token.decimals).toFixed(6, 0); + const usdcPrice = getTokenUsdPrice(EvmToken.USDC) ?? 0; + balanceUsd = usdcPrice > 0 ? Big(balance).times(usdcPrice).toFixed(2, 0) : "0.00"; + } + } else { + const rawBalance = allEvmBalances.get(addressKey); + const showDecimals = token.assetSymbol.toLowerCase().includes("usd") ? 2 : 6; + balance = rawBalance ? multiplyByPowerOfTen(Big(rawBalance), -token.decimals).toFixed(showDecimals, 0) : "0.00"; + + const matchingToken = evmTokenLookup.get(addressKey); + const usdPrice = matchingToken?.usdPrice ?? 0; + 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/apps/frontend/src/services/balances/morphoBalanceFetcher.ts b/apps/frontend/src/services/balances/morphoBalanceFetcher.ts new file mode 100644 index 000000000..78151c7df --- /dev/null +++ b/apps/frontend/src/services/balances/morphoBalanceFetcher.ts @@ -0,0 +1,34 @@ +import { Networks } from "@vortexfi/shared"; +import { readContract } from "@wagmi/core"; +import { wagmiConfig } from "../../wagmiConfig"; + +const MORPHO_VAULT_ABI = [ + { + inputs: [{ name: "owner", type: "address" }], + name: "balanceOf", + outputs: [{ name: "", type: "uint256" }], + stateMutability: "view", + type: "function" + } +] as const; + +const ARBITRUM_CHAIN_ID = 42161; + +export async function fetchMorphoVaultShareBalance(vaultAddress: `0x${string}`, owner: `0x${string}`): Promise { + try { + const raw = (await readContract(wagmiConfig, { + abi: MORPHO_VAULT_ABI, + address: vaultAddress, + args: [owner], + chainId: ARBITRUM_CHAIN_ID, + functionName: "balanceOf" + })) as bigint; + + return raw; + } catch (error) { + console.error(`Error fetching Morpho vault share balance for ${vaultAddress}:`, error); + return null; + } +} + +export const MORPHO_VAULT_NETWORK = Networks.Arbitrum; diff --git a/packages/shared/src/helpers/signUnsigned.ts b/packages/shared/src/helpers/signUnsigned.ts index 3e6ec7de9..2c040557d 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/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/tokens/evm/config.ts b/packages/shared/src/tokens/evm/config.ts index cd4101024..e80c4fc06 100644 --- a/packages/shared/src/tokens/evm/config.ts +++ b/packages/shared/src/tokens/evm/config.ts @@ -45,15 +45,6 @@ export const evmTokenConfig: Record Date: Tue, 16 Jun 2026 15:50:57 -0300 Subject: [PATCH 16/26] adjust squidrouter pay for Morhpo flow --- .../squid-router-pay-phase-handler.ts | 33 ++++++++++++++++--- .../offramp/routes/evm-to-mykobo-morpho.ts | 6 +--- .../squidrouter/route-transactions.ts | 10 +++--- 3 files changed, 35 insertions(+), 14 deletions(-) diff --git a/apps/api/src/api/services/phases/handlers/squid-router-pay-phase-handler.ts b/apps/api/src/api/services/phases/handlers/squid-router-pay-phase-handler.ts index 0416cc519..a4277b522 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,14 +4,15 @@ import { BalanceCheckErrorType, checkEvmBalanceForToken, EvmClientManager, - EvmNetworks, EvmTokenDetails, FiatToken, + getNetworkFromDestination, getNetworkId, getOnChainTokenDetails, getStatus, getStatusAxelarScan, isAlfredpayToken, + isNetworkEVM, Networks, nativeToDecimal, OnChainToken, @@ -112,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 }); @@ -121,8 +127,6 @@ export class SquidRouterPayPhaseHandler extends BasePhaseHandler { return; } - const toChain = quote.to as EvmNetworks; - let balanceCheckPromise: Promise; try { @@ -449,7 +453,10 @@ export class SquidRouterPayPhaseHandler extends BasePhaseHandler { ? Networks.Base : 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) { @@ -494,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 (e.g. Morpho -> SEPA via Mykobo), `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/transactions/offramp/routes/evm-to-mykobo-morpho.ts b/apps/api/src/api/services/transactions/offramp/routes/evm-to-mykobo-morpho.ts index 242a222ea..e2818058e 100644 --- a/apps/api/src/api/services/transactions/offramp/routes/evm-to-mykobo-morpho.ts +++ b/apps/api/src/api/services/transactions/offramp/routes/evm-to-mykobo-morpho.ts @@ -50,7 +50,6 @@ const erc20Abi = [ ] as const; const REDEEM_SLIPPAGE_BPS = 50; // 0.5% — covers interest accrual between quote-time and redeem-time (0% vault fee assumed) -const APPROVE_BUFFER_BPS = 100; // 1% — exact output + 1% for interest accrual before bridge const vaultRedeemAbi = [ { @@ -283,9 +282,6 @@ export async function prepareEvmToMykoboMorphoOfframpTransactions({ if (!bridgeInputAmountRaw) { throw new Error("Missing evmToEvm.inputAmountRaw in quote metadata for Morpho offramp (bridge input)"); } - // Approve 1% more than expected bridge input to cover interest accrual between redeem and bridge. - const approveAmountRaw = (BigInt(bridgeInputAmountRaw) * BigInt(10000 + APPROVE_BUFFER_BPS)) / BigInt(10000); - const { approveData, swapData, @@ -295,7 +291,7 @@ export async function prepareEvmToMykoboMorphoOfframpTransactions({ fromAddress: evmEphemeralEntry.address, fromNetwork: morphoNetwork, fromToken: morphoVault.depositAssetAddress as `0x${string}`, - rawAmount: approveAmountRaw.toString(), + rawAmount: bridgeInputAmountRaw, toNetwork: Networks.Base, toToken: baseUsdcAddress as `0x${string}` }); 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) }; From 39ee8d24d4a45a88d9dfb7aac04478073254edcd Mon Sep 17 00:00:00 2001 From: Gianfranco Date: Tue, 16 Jun 2026 17:08:49 -0300 Subject: [PATCH 17/26] adjust final settlement phase for morpho onramp outside Base --- .../handlers/final-settlement-subsidy.ts | 15 +++++++++++++- .../api/services/phases/handlers/helpers.ts | 2 +- .../api/services/quote/core/squidrouter.ts | 17 +++++++++++++++- .../quote/engines/fee/onramp-brl-to-evm.ts | 4 ++-- .../quote/engines/fee/onramp-mykobo-to-evm.ts | 4 ++-- .../engines/squidrouter/onramp-base-to-evm.ts | 4 ++-- .../onramp/routes/mykobo-to-evm-morpho.ts | 20 ++++--------------- .../shared/src/tokens/freeTokens/config.ts | 2 +- 8 files changed, 42 insertions(+), 26 deletions(-) diff --git a/apps/api/src/api/services/phases/handlers/final-settlement-subsidy.ts b/apps/api/src/api/services/phases/handlers/final-settlement-subsidy.ts index 9fca87e92..6aa2f8092 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 @@ -89,7 +89,9 @@ export class FinalSettlementSubsidyHandler extends BasePhaseHandler { const outTokenDetails = state.type === RampDirection.BUY - ? (getOnChainTokenDetails(quote.network, quote.outputCurrency) as EvmTokenDetails) + ? quote.outputCurrency === EvmToken.MORPHO_VAULT + ? (getOnChainTokenDetails(quote.network as Networks, EvmToken.USDC) as EvmTokenDetails) + : (getOnChainTokenDetails(quote.network, quote.outputCurrency) as EvmTokenDetails) : isAlfredpaySell ? getOnChainTokenDetails(Networks.Polygon, ALFREDPAY_EVM_TOKEN) : getOnChainTokenDetails(Networks.Polygon, EvmToken.USDC); @@ -104,6 +106,17 @@ export class FinalSettlementSubsidyHandler extends BasePhaseHandler { let expectedAmountRaw: Big | undefined; switch (state.type) { case RampDirection.BUY: + if (quote.outputCurrency === EvmToken.MORPHO_VAULT) { + // MORPHO_VAULT onramp: SquidRouter delivers the deposit asset (USDC) to the ephemeral, not vault shares. + // Vault shares are minted by the later morphoDeposit phase. Use the raw deposit amount from state metadata. + if (!state.state.morphoDepositAmountRaw) { + throw new Error( + "FinalSettlementSubsidyHandler: Missing morphoDepositAmountRaw in state metadata for MORPHO_VAULT onramp" + ); + } + expectedAmountRaw = Big(state.state.morphoDepositAmountRaw); + break; + } expectedAmountRaw = multiplyByPowerOfTen(quote.outputAmount, outTokenDetails.decimals); break; case RampDirection.SELL: diff --git a/apps/api/src/api/services/phases/handlers/helpers.ts b/apps/api/src/api/services/phases/handlers/helpers.ts index 0700b2700..c0fb149e7 100644 --- a/apps/api/src/api/services/phases/handlers/helpers.ts +++ b/apps/api/src/api/services/phases/handlers/helpers.ts @@ -59,7 +59,7 @@ export async function isPolygonEphemeralFunded(polygonEphemeralAddress: string): // short-funded ephemeral. export const DESTINATION_EVM_FUNDING_AMOUNTS: Record = { [VortexNetworks.Ethereum]: "0.005", - [VortexNetworks.Arbitrum]: "0.001", + [VortexNetworks.Arbitrum]: "0.0002", [VortexNetworks.Base]: "0.000034", [VortexNetworks.Polygon]: "0.6", [VortexNetworks.BSC]: "0.000115", diff --git a/apps/api/src/api/services/quote/core/squidrouter.ts b/apps/api/src/api/services/quote/core/squidrouter.ts index b23dfad46..253224552 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,20 @@ export function getTokenDetailsForEvmDestination( return tokenDetails; } +/** + * Returns the token details that SquidRouter should deliver on the destination + * chain for a given (outputCurrency, toNetwork). For MORPHO_VAULT, the bridge + * must land the vault's deposit asset (USDC) - the vault contract itself is an + * ERC-4626 share token that SquidRouter cannot bridge. The actual vault deposit + * runs as a separate on-chain step in route-prep. + */ +export function getBridgeTargetTokenDetails(outputCurrency: OnChainToken, toNetwork: Networks): EvmTokenDetails { + if (outputCurrency === EvmToken.MORPHO_VAULT) { + return getTokenDetailsForEvmDestination(EvmToken.USDC, toNetwork); + } + return getTokenDetailsForEvmDestination(outputCurrency, toNetwork); +} + /** * Helper to prepare route parameters for Squidrouter */ @@ -182,7 +197,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/engines/fee/onramp-brl-to-evm.ts b/apps/api/src/api/services/quote/engines/fee/onramp-brl-to-evm.ts index 15fdebae4..11a829d97 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 cf13c5216..776b9e38f 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/squidrouter/onramp-base-to-evm.ts b/apps/api/src/api/services/quote/engines/squidrouter/onramp-base-to-evm.ts index 6f0d400b8..d625998be 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"; @@ -139,7 +139,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/transactions/onramp/routes/mykobo-to-evm-morpho.ts b/apps/api/src/api/services/transactions/onramp/routes/mykobo-to-evm-morpho.ts index ad252cef5..0bc99f7fd 100644 --- a/apps/api/src/api/services/transactions/onramp/routes/mykobo-to-evm-morpho.ts +++ b/apps/api/src/api/services/transactions/onramp/routes/mykobo-to-evm-morpho.ts @@ -1,19 +1,15 @@ import { createOnrampSquidrouterTransactionsFromBaseToEvm, - createOnrampSquidrouterTransactionsOnDestinationChain, EvmClientManager, EvmNetworks, EvmToken, EvmTokenDetails, EvmTransactionData, evmTokenConfig, - getOnChainTokenDetailsOrDefault, isEvmTokenDetails, - multiplyByPowerOfTen, Networks, UnsignedTx } from "@vortexfi/shared"; -import Big from "big.js"; import { encodeFunctionData, erc20Abi } from "viem"; import logger from "../../../../../config/logger"; import { getEvmFundingAccount } from "../../../phases/evm-funding"; @@ -23,7 +19,7 @@ import { EUR_ONRAMP_BASE_MORPHO, EUR_ONRAMP_MORPHO } from "../../../phases/ramp- import { prepareBaseCleanupApproval } from "../../base/cleanup"; import { addEvmFeeDistributionTransaction } from "../../common/feeDistribution"; import { encodeEvmTransactionData } from "../../index"; -import { addDestinationChainApprovalTransaction, addNablaSwapTransactionsOnBase } from "../common/transactions"; +import { addNablaSwapTransactionsOnBase } from "../common/transactions"; import { MykoboOnrampTransactionParams, OnrampTransactionsWithMeta } from "../common/types"; import { validateMykoboOnramp } from "../common/validation"; @@ -63,7 +59,7 @@ export async function prepareMykoboToEvmMorphoOnrampTransactions({ let stateMeta: Partial = {}; const unsignedTxs: UnsignedTx[] = []; - const { toNetwork, outputTokenDetails, evmEphemeralEntry, inputTokenDetails } = validateMykoboOnramp(quote, signingAccounts); + const { evmEphemeralEntry, inputTokenDetails } = validateMykoboOnramp(quote, signingAccounts); logger.debug(`Starting prepareMykoboToEvmMorphoOnrampTransactions with destinationAddress: ${destinationAddress}`); if (!isEvmTokenDetails(inputTokenDetails)) { @@ -139,15 +135,11 @@ export async function prepareMykoboToEvmMorphoOnrampTransactions({ // 3. SquidRouter bridge (only when the vault is on a chain other than Base). let squidRouterQuoteId: string | undefined; - let squidRouterReceiverId: string | undefined; - let squidRouterReceiverHash: string | undefined; if (!isBaseVault) { const { approveData, swapData, - squidRouterQuoteId: quoteId, - squidRouterReceiverId: receiverId, - squidRouterReceiverHash: receiverHash + squidRouterQuoteId: quoteId } = await createOnrampSquidrouterTransactionsFromBaseToEvm({ destinationAddress: evmEphemeralEntry.address, fromAddress: evmEphemeralEntry.address, @@ -157,8 +149,6 @@ export async function prepareMykoboToEvmMorphoOnrampTransactions({ toToken: morphoVault.depositAssetAddress as `0x${string}` }); squidRouterQuoteId = quoteId; - squidRouterReceiverId = receiverId; - squidRouterReceiverHash = receiverHash; unsignedTxs.push({ meta: {}, @@ -263,9 +253,7 @@ export async function prepareMykoboToEvmMorphoOnrampTransactions({ stateMeta = { ...stateMeta, phaseFlow: isBaseVault ? EUR_ONRAMP_BASE_MORPHO : EUR_ONRAMP_MORPHO, - squidRouterQuoteId, - squidRouterReceiverHash, - squidRouterReceiverId + squidRouterQuoteId }; return { stateMeta, unsignedTxs }; diff --git a/packages/shared/src/tokens/freeTokens/config.ts b/packages/shared/src/tokens/freeTokens/config.ts index 8bdecb713..21314278b 100644 --- a/packages/shared/src/tokens/freeTokens/config.ts +++ b/packages/shared/src/tokens/freeTokens/config.ts @@ -92,7 +92,7 @@ export const freeTokenConfig: Partial> = }, maxBuyAmountRaw: "10000000000", maxSellAmountRaw: "10000000000", - minBuyAmountRaw: "1000000", + minBuyAmountRaw: "500000", minSellAmountRaw: "500000", type: TokenType.Fiat }, From 3ec7c6853fd6e0fab91369e09362fa385ae1c049 Mon Sep 17 00:00:00 2001 From: Gianfranco Date: Tue, 16 Jun 2026 17:24:45 -0300 Subject: [PATCH 18/26] refactor fund ephemeral handler --- .../phases/handlers/fund-ephemeral-handler.ts | 43 +++++++++---------- 1 file changed, 20 insertions(+), 23 deletions(-) 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 510460561..b07b6f711 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 @@ -197,19 +197,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; - - const requiresMorphoNetworkFunding = this.getRequiresMorphoNetworkFunding(state); - const morphoNetwork = (state.state as StateMetadata).morphoNetwork as EvmNetworks | undefined; - const isMorphoNetworkFunded = - requiresMorphoNetworkFunding && morphoNetwork - ? await isDestinationEvmEphemeralFunded(evmEphemeralAddress, morphoNetwork) - : true; - if (!isPendulumFunded) { logger.info(`Funding PEN ephemeral account ${substrateEphemeralAddress}`); if (isOnramp(state) && state.to !== Networks.AssetHub) { @@ -235,18 +222,28 @@ export class FundEphemeralPhaseHandler extends BasePhaseHandler { logger.info("Polygon ephemeral address already funded."); } - if (!isMorphoNetworkFunded && morphoNetwork) { - logger.info(`Funding morpho network ephemeral account ${evmEphemeralAddress} on ${morphoNetwork}`); - await this.fundDestinationEvmEphemeralAccount(state, morphoNetwork); - } else if (requiresMorphoNetworkFunding) { - logger.info(`Morpho network ephemeral account already funded on ${morphoNetwork}.`); + // Deduplicate morpho + destination EVM networks: a single network can appear + // in both sets (e.g. onramp Morpho on Arbitrum, where the destination == + // morpho network). Funding twice for the same address on the same chain + // would over-fund the ephemeral. + const evmNetworksToFund = new Set(); + const morphoNetwork = (state.state as StateMetadata).morphoNetwork as EvmNetworks | undefined; + if (morphoNetwork && this.getRequiresMorphoNetworkFunding(state)) { + evmNetworksToFund.add(morphoNetwork); + } + const destinationNetwork = getNetworkFromDestination(state.to); + if (isOnramp(state) && destinationNetwork && isNetworkEVM(destinationNetwork) && requiresDestinationEvmFunding) { + evmNetworksToFund.add(destinationNetwork); } - 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}.`); + 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); From 5f9fc349b44cd32355960622b9cd3e22677b6a35 Mon Sep 17 00:00:00 2001 From: Gianfranco Date: Wed, 17 Jun 2026 11:37:37 -0300 Subject: [PATCH 19/26] disable intent mocking --- .../api/src/api/services/ramp/ramp.service.ts | 39 ++++++++++--------- .../offramp/routes/evm-to-mykobo-morpho.ts | 31 ++++++++------- 2 files changed, 36 insertions(+), 34 deletions(-) diff --git a/apps/api/src/api/services/ramp/ramp.service.ts b/apps/api/src/api/services/ramp/ramp.service.ts index 4efca96f1..81078c090 100644 --- a/apps/api/src/api/services/ramp/ramp.service.ts +++ b/apps/api/src/api/services/ramp/ramp.service.ts @@ -1142,25 +1142,26 @@ export class RampService extends BaseRampService { } const mykobo = MykoboApiService.getInstance(); - // const intent = await mykobo.createTransactionIntent({ - // currency: MykoboCurrency.EURC, - // email_address: additionalData.email, - // ip_address: "79.224.167.233", - // transaction_type: MykoboTransactionType.DEPOSIT, - // value: new Big(quote.inputAmount).toFixed(2, 0), - // wallet_address: evmEphemeralEntry.address - // }); - - const intent = { - instructions: { - bank_account_name: "Mykobo Test", - iban: "DE89370400440532013000" - }, - transaction: { - id: "mykobo-transaction-id", - reference: "mykobo-transaction-reference" - } - }; + const intent = await mykobo.createTransactionIntent({ + currency: MykoboCurrency.EURC, + email_address: additionalData.email, + ip_address: "79.224.167.233", + transaction_type: MykoboTransactionType.DEPOSIT, + value: new Big(quote.inputAmount).toFixed(2, 0), + 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({ diff --git a/apps/api/src/api/services/transactions/offramp/routes/evm-to-mykobo-morpho.ts b/apps/api/src/api/services/transactions/offramp/routes/evm-to-mykobo-morpho.ts index e2818058e..a8e5e570d 100644 --- a/apps/api/src/api/services/transactions/offramp/routes/evm-to-mykobo-morpho.ts +++ b/apps/api/src/api/services/transactions/offramp/routes/evm-to-mykobo-morpho.ts @@ -345,21 +345,22 @@ export async function prepareEvmToMykoboMorphoOfframpTransactions({ const eurcTransferAmountRaw = multiplyByPowerOfTen(new Big(mykoboFlooredValue), eurcDecimals).toFixed(0, 0); const mykobo = MykoboApiService.getInstance(); - // const intent = await mykobo.createTransactionIntent({ - // currency: MykoboCurrency.EURC, - // email_address: email, - // ip_address: ipAddress, - // transaction_type: MykoboTransactionType.WITHDRAW, - // value: mykoboFlooredValue, - // wallet_address: evmEphemeralEntry.address - // }); - - // if (!isWithdrawInstructions(intent.instructions)) { - // throw new Error("Mykobo intent did not return withdraw instructions; cannot derive receivables address"); - // } - const mykoboReceivablesAddress = "0x7Ba99e99Bc669B3508AFf9CC0A898E869459F877"; //intent.instructions.address; - const mykoboTransactionId = "mockTransactionId"; //intent.transaction.id; - const mykoboTransactionReference = "mockTransactionReference"; //intent.transaction.reference; + const intent = await mykobo.createTransactionIntent({ + currency: MykoboCurrency.EURC, + email_address: email, + ip_address: ipAddress, + transaction_type: MykoboTransactionType.WITHDRAW, + value: mykoboFlooredValue, + wallet_address: evmEphemeralEntry.address + }); + + if (!isWithdrawInstructions(intent.instructions)) { + throw new Error("Mykobo intent did not return withdraw instructions; cannot derive receivables address"); + } + // Mocking Mykobo intent call + // const mykoboReceivablesAddress = "0x7Ba99e99Bc669B3508AFf9CC0A898E869459F877"; //intent.instructions.address; + // const mykoboTransactionId = "mockTransactionId"; //intent.transaction.id; + // const mykoboTransactionReference = "mockTransactionReference"; //intent.transaction.reference; // 7. Base leg (fundEphemeral handled separately; Nabla + payout + cleanups). // When the vault IS on Base, the ephemeral has already broadcast transferFrom (nonce 0) From 3795378df41a9daebbff7b5ba3c501b512a06641 Mon Sep 17 00:00:00 2001 From: Gianfranco Date: Wed, 17 Jun 2026 11:45:16 -0300 Subject: [PATCH 20/26] use real ip for intent calling --- apps/api/src/api/services/ramp/ramp.service.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/api/src/api/services/ramp/ramp.service.ts b/apps/api/src/api/services/ramp/ramp.service.ts index 81078c090..b50bae634 100644 --- a/apps/api/src/api/services/ramp/ramp.service.ts +++ b/apps/api/src/api/services/ramp/ramp.service.ts @@ -1145,7 +1145,7 @@ export class RampService extends BaseRampService { const intent = await mykobo.createTransactionIntent({ currency: MykoboCurrency.EURC, email_address: additionalData.email, - ip_address: "79.224.167.233", + ip_address: additionalData.ipAddress, transaction_type: MykoboTransactionType.DEPOSIT, value: new Big(quote.inputAmount).toFixed(2, 0), wallet_address: evmEphemeralEntry.address @@ -1175,7 +1175,7 @@ export class RampService extends BaseRampService { if (quote.outputCurrency === EvmToken.MORPHO_VAULT) { const result = await prepareMykoboToEvmMorphoOnrampTransactions({ destinationAddress: additionalData.destinationAddress, - ipAddress: "79.224.167.233", + ipAddress: additionalData.ipAddress, mykoboEmail: additionalData.email, mykoboTransactionId: intent.transaction.id, mykoboTransactionReference: intent.transaction.reference, @@ -1187,7 +1187,7 @@ export class RampService extends BaseRampService { } else { const result = await prepareMykoboToEvmOnrampTransactions({ destinationAddress: additionalData.destinationAddress, - ipAddress: "79.224.167.233", + ipAddress: additionalData.ipAddress, mykoboEmail: additionalData.email, mykoboTransactionId: intent.transaction.id, mykoboTransactionReference: intent.transaction.reference, From b32923ecd159c778dd925c78cb57746253847a83 Mon Sep 17 00:00:00 2001 From: Gianfranco Date: Wed, 17 Jun 2026 15:45:08 -0300 Subject: [PATCH 21/26] first proposal and refactor --- .opencode/agents/m3-implementor.md | 41 ++ .../src/api/services/quote/blocks/DESIGN.md | 361 +++++++++++++++++ .../src/api/services/quote/blocks/REPORT.md | 379 ++++++++++++++++++ .../eur-onramp-morpho.parity.test.ts | 117 ++++++ .../services/quote/blocks/core/combinators.ts | 43 ++ .../api/services/quote/blocks/core/fees.ts | 48 +++ .../api/services/quote/blocks/core/flow.ts | 33 ++ .../src/api/services/quote/blocks/core/io.ts | 23 ++ .../services/quote/blocks/core/phase-flow.ts | 25 ++ .../api/services/quote/blocks/core/subsidy.ts | 94 +++++ .../api/services/quote/blocks/core/types.ts | 38 ++ .../quote/blocks/flows/eur-onramp-morpho.ts | 33 ++ .../quote/blocks/phases/morpho-mint.ts | 64 +++ .../quote/blocks/phases/mykobo-mint.ts | 43 ++ .../quote/blocks/phases/nabla-swap.ts | 58 +++ .../quote/blocks/phases/squid-router-swap.ts | 64 +++ 16 files changed, 1464 insertions(+) create mode 100644 .opencode/agents/m3-implementor.md create mode 100644 apps/api/src/api/services/quote/blocks/DESIGN.md create mode 100644 apps/api/src/api/services/quote/blocks/REPORT.md create mode 100644 apps/api/src/api/services/quote/blocks/__tests__/eur-onramp-morpho.parity.test.ts create mode 100644 apps/api/src/api/services/quote/blocks/core/combinators.ts create mode 100644 apps/api/src/api/services/quote/blocks/core/fees.ts create mode 100644 apps/api/src/api/services/quote/blocks/core/flow.ts create mode 100644 apps/api/src/api/services/quote/blocks/core/io.ts create mode 100644 apps/api/src/api/services/quote/blocks/core/phase-flow.ts create mode 100644 apps/api/src/api/services/quote/blocks/core/subsidy.ts create mode 100644 apps/api/src/api/services/quote/blocks/core/types.ts create mode 100644 apps/api/src/api/services/quote/blocks/flows/eur-onramp-morpho.ts create mode 100644 apps/api/src/api/services/quote/blocks/phases/morpho-mint.ts create mode 100644 apps/api/src/api/services/quote/blocks/phases/mykobo-mint.ts create mode 100644 apps/api/src/api/services/quote/blocks/phases/nabla-swap.ts create mode 100644 apps/api/src/api/services/quote/blocks/phases/squid-router-swap.ts diff --git a/.opencode/agents/m3-implementor.md b/.opencode/agents/m3-implementor.md new file mode 100644 index 000000000..b6e710d2a --- /dev/null +++ b/.opencode/agents/m3-implementor.md @@ -0,0 +1,41 @@ +--- +description: MiniMax M3 implementor for the Vortex monorepo. Receives a detailed architecture spec (file paths, function signatures, generics, implementation hints, existing files to port logic from) from the coordinator and produces working TypeScript that passes `bun typecheck` and `bun lint:fix`. Use for parallel implementation of well-scoped code chunks in the Vortex quote-engine block refactor. +mode: subagent +model: opencode-go/MiniMax-M3 +permission: + edit: allow + write: allow + bash: allow + read: allow + glob: allow + grep: allow + list: allow +--- + +You are the MiniMax M3 implementor for the Vortex monorepo. You implement code from a coordinator's spec; you do not design architecture. + +## Input you receive +A chunk spec containing: +- The exact file path(s) to create or modify +- Full function/interface signatures with generics +- Implementation hints (which existing files to read, which logic to port) +- Verification commands to run + +## Workflow +1. Read every existing file the spec references, plus neighboring files for convention. Mimic existing style exactly. +2. Implement exactly what the spec says — no scope creep, no speculative abstractions, no "improvements" to adjacent code. +3. Run `bun lint:fix` then `bun typecheck` (from the repo root) and iterate until both are green. If `packages/shared` was touched, run `bun build:shared` first. +4. Report back: file paths created/modified, a one-paragraph summary of what was implemented, and the final typecheck/lint status. Surface any deviations from the spec and why. + +## Rules (from CLAUDE.md — non-negotiable) +- Use `bun`, never npm/yarn/pnpm. +- Biome: line width 128, 2-space indent, semicolons always, double quotes, no trailing commas. +- DO NOT add comments unless the spec explicitly asks. No docstrings on code you didn't touch. +- Surgical changes: touch only what the spec requires. Don't refactor neighboring code. +- No over-engineering: no abstractions for single-use code, no error handling for impossible scenarios, no input validation for typed internal params. +- Three similar lines is better than a premature abstraction. +- `FiatToken` has 6 values (EURC, ARS, BRL, USD, MXN, COP); any `Record` must include all six. +- After ANY change to `packages/shared`, run `bun build:shared` before typecheck. + +## When to stop +When typecheck and lint pass and the spec is fully implemented. Return the summary. Do not continue into adjacent chunks — the coordinator assigns those. diff --git a/apps/api/src/api/services/quote/blocks/DESIGN.md b/apps/api/src/api/services/quote/blocks/DESIGN.md new file mode 100644 index 000000000..1937e74bd --- /dev/null +++ b/apps/api/src/api/services/quote/blocks/DESIGN.md @@ -0,0 +1,361 @@ +# Block-Based Quote Engine — Proof of Concept + +## Goal + +Introduce a typed, composable "block" model for defining quote flows. Each phase +declares its input/output IO brands (token + chain), and the `flow()` builder +enforces **at compile time** that adjacent phases are compatible — a Base swap +cannot feed a Polygon-only transfer, a Morpho deposit on Arbitrum cannot follow a +passthrough that stayed on Base. + +**Scope of this POC:** the `EUR_ONRAMP_MORPHO` corridor +(Mykobo SEPA → EURC on Base → Nabla EURC→USDC swap → Squid bridge to dest chain → +Morpho vault deposit). The old strategy (`onrampMykoboToEvmStrategy` + morpho +route-prep) and all existing phase handlers **coexist** and are NOT modified. +The POC lives entirely under `blocks/` and is verified by a parity test. + +## File structure + +``` +apps/api/src/api/services/quote/blocks/ + DESIGN.md # this file + core/ + types.ts # PhaseIO, Phase, Flow, PhaseCtx + io.ts # IO helpers + brand type aliases + requestToIO + flow.ts # flow() builder with compile-time adjacency + combinators.ts # branch(), passthrough() + subsidy.ts # WithSubsidy wrapper + phase-flow.ts # derive RampPhase[] from a Flow (+ infra bookends) + fees.ts # computeFees(ctx) helper (ports quote-fees.ts) + phases/ + mykobo-mint.ts # MykoboMint: fiat EUR -> EURC on Base + nabla-swap.ts # NablaSwap + squid-router-swap.ts # SquidRouterSwap + morpho-mint.ts # MorphoMint: USDC -> MORPHO_VAULT + flows/ + eur-onramp-morpho.ts # the assembled flow + __tests__/ + eur-onramp-morpho.parity.test.ts # structural + parity verification +``` + +## Core types (`core/types.ts`) + +```ts +import type { Big } from "big.js"; +import type { + CreateQuoteRequest, + PartnerInfo, + QuoteFeeStructure, + RampPhase, + RampCurrency +} from "@vortexfi/shared"; + +// Compile-time brand unions. Concrete phases instantiate PhaseIO with literal +// types drawn from EvmToken | FiatToken | AssetHubToken | PendulumCurrencyId +// (for Token) and Networks | "Pendulum" | "Stellar" | "fiat" (for Chain). +// Keep these as `extends string` so literal narrowing flows through generics. +export type TokenBrand = string; +export type ChainBrand = string; + +export interface PhaseIO { + amount: Big; // human-readable decimal amount + amountRaw: string; // integer-string raw amount at the token's decimals + token: Token; + chain: Chain; + meta: Record; // phase-specific (route id, oracle price, subsidy, ...) +} + +// Cross-phase context: the genuinely shared state that is NOT the per-phase IO. +// Replaces the cross-phase parts of the current QuoteContext (request, partner, +// fees, notes). Per-phase computed values travel on the PhaseIO.meta, not here. +export interface PhaseCtx { + request: CreateQuoteRequest & { userId?: string }; + partner: PartnerInfo | null; + now: Date; + notes: string[]; + addNote(note: string): void; + // Computed once by the flow runner via computeFees(ctx) before phases run. + fees?: { + usd?: { vortex: string; anchor: string; partnerMarkup: string; network: string; total: string }; + displayFiat?: QuoteFeeStructure; + }; + // Set by the MykoboMint phase so WithSubsidy can read the pre-swap amount. + // (Keep this minimal; prefer carrying values on PhaseIO.meta.) +} + +// A block. Generic over Input and Output IO brands so adjacency is checkable. +export interface Phase { + readonly name: string; + // The RampPhase(s) this block expands to in the execution phaseFlow. + // Example: NablaSwap -> ["nablaApprove", "nablaSwap"]. + readonly phases: RampPhase[]; + simulate(input: I, ctx: PhaseCtx): Promise; +} + +export interface Flow { + readonly name: string; + // Derived: flatMap(p => p.phases) over the assembled phases. + readonly phases: RampPhase[]; + // Runs the chain end to end. The first phase's input is built by requestToIO(ctx). + simulate(ctx: PhaseCtx): Promise; +} +``` + +## `FlowBuilder` (`core/flow.ts`) + +Compile-time adjacency is enforced via a **builder**, not a variadic `flow()` +function. A variadic impl signature (`Phase[]`) acts as a +fallback that bivariance makes permissive, silently bypassing the adjacency +check (verified empirically). The builder's `.pipe(next)` is a single method +signature with no overload fallback, so a brand mismatch is a hard type error. + +A second critical detail: the output brand must be extracted via a conditional +`infer` type, **not** a second generic param. `start

, O1>` widens `O1` to `PhaseIO` (the constraint bound) during inference, +severing the brand chain. `OutputOf

` preserves the literal brand. + +```ts +type OutputOf

= P extends Phase ? O : never; + +export class FlowBuilder { + private constructor(private readonly phaseList: Phase[]) {} + + static start

>(first: P): FlowBuilder>; + pipe

>(next: P): FlowBuilder>; + build(name: string): Flow; +} +``` + +Runtime: `build()` stores the phases array; `Flow.simulate(ctx)` builds the +first input via `requestToIO(ctx)`, then sequentially calls +`phase.simulate(prevOutput, ctx)`, returning the final output. +`Flow.phases` = `phaseList.flatMap(p => p.phases)`. + +Usage: `FlowBuilder.start(phase1).pipe(phase2).pipe(phase3).build("name")`. + +## `branch()` and `passthrough()` (`core/combinators.ts`) + +```ts +// branch: runtime-selects between phases based on ctx. The output type O is +// declared explicitly (the common downstream shape); each branch's Phase +// must produce IO assignable to O. This is the documented escape hatch where +// compile-time adjacency is intentionally relaxed at a runtime decision point. +export function branch( + select: (ctx: PhaseCtx) => Promise | number, + branches: Phase[] +): Phase; + +// passthrough: a no-op phase that forwards input as output (same token, same chain). +// Used when a bridge is skipped because funds are already on the target chain. +// phases: [] (no execution phases — it is a quote-side no-op). +export function passthrough(): Phase, PhaseIO>; +``` + +`branch`'s `phases` = the union (deduped, order-preserved) of all branches' phases. +For the EUR_ONRAMP_MORPHO flow, branches are `passthrough` (phases: []) and +`SquidRouterSwap` (phases: `["squidRouterSwap", "squidRouterPay"]`), so the +branch contributes `["squidRouterSwap", "squidRouterPay"]` to the derived +phaseFlow. `assemblePhaseFlow` (below) handles the conditional inclusion at +runtime — for the static `flow.phases` derivation, include the union. + +## `WithSubsidy` wrapper (`core/subsidy.ts`) + +```ts +export function WithSubsidy

, I extends PhaseIO, O extends PhaseIO>( + inner: P, + opts: { bookend: "subsidizePostSwap" | "finalSettlementSubsidy" } +): Phase; +``` + +- `name`: `WithSubsidy(${inner.name})` +- `phases`: `["subsidizePreSwap", ...inner.phases, opts.bookend]` +- `simulate()`: runs `inner.simulate(input, ctx)`, then computes subsidy metadata + and attaches it to `output.meta.subsidy`. Port the calculation from + `engines/discount/onramp.ts` and `engines/merge-subsidy/offramp-evm.ts`. The + metadata shape mirrors the current `QuoteContext.subsidy` field + (`core/types.ts:245-261` of the existing quote engine): + ```ts + meta.subsidy = { + applied: boolean; + subsidyRate: Big; + partnerId: string | null; + expectedOutputAmountDecimal: Big; + actualOutputAmountDecimal: Big; + subsidyAmountInOutputTokenDecimal: Big; + idealSubsidyAmountInOutputTokenDecimal: Big; + targetOutputAmountDecimal: Big; + // ...raw string counterparts + } + ``` + +## IO helpers (`core/io.ts`) + +```ts +// Build the first phase's input IO from the quote request. +export function requestToIO(ctx: PhaseCtx): PhaseIO< RampCurrency, "fiat">; + +// Convenience constructors for typed EVM / Pendulum IO. +export function evmIO( + token: Token, chain: Chain, amount: Big, amountRaw: string, meta?: Record +): PhaseIO; +``` + +## Fees helper (`core/fees.ts`) + +```ts +// Ports calculateFeeComponents from core/quote-fees.ts. Called once by the flow +// runner before simulate() so ctx.fees is populated for WithSubsidy and phases. +export async function computeFees(ctx: PhaseCtx): Promise; +``` + +## Phase implementations (`phases/*.ts`) + +Each phase ports the `compute()` logic of an existing engine, returning a typed +`PhaseIO` instead of mutating `QuoteContext`. Read the listed existing files +first and mimic their computation exactly (same external calls, same math) so +the parity test can pass. + +### `mykobo-mint.ts` — `MykoboMint` +- Type: `Phase, PhaseIO>` + (use the actual `FiatToken.EURC` and `Networks.Base` literal values) +- Port from: `engines/initialize/onramp-mykobo.ts` + - Call `MykoboApiService.getInstance().defaultDepositFee(inputAmountDecimal.toFixed(2, 0))` + - `deliveredEurc = inputAmount - mykoboFee` + - Use `getOnChainTokenDetails(Networks.Base, EvmToken.EURC)` for decimals/raw conversion +- `phases`: `["mykoboOnrampDeposit"]` +- Output meta: `{ mykoboFee, inputAmountRaw, outputAmountRaw }` + +### `nabla-swap.ts` — `NablaSwap` +- Type: `Phase, PhaseIO>` with `Chain`, `InToken`, `OutToken` as generics +- Port from: `engines/nabla-swap/base-evm.ts` + `core/nabla.ts:calculateNablaSwapOutputEvm` + - Subtract deductible fee (for offramp only; onramp returns 0) — see `BaseNablaSwapEngineEvm.getDeductibleFeeAmount` + - Call `calculateNablaSwapOutputEvm({ inputAmountForSwap, inputTokenDetails, outputTokenDetails, rampType })` + - Optionally fetch oracle price via `priceFeedService.getOnchainOraclePrice` +- `phases`: `["nablaApprove", "nablaSwap"]` +- Output meta: `{ effectiveExchangeRate, oraclePrice, inputAmountForSwapRaw, inputToken, outputToken }` +- Instantiate for the flow as `NablaSwap` + +### `squid-router-swap.ts` — `SquidRouterSwap` +- Type: `Phase, PhaseIO>` +- Port from: `engines/squidrouter/onramp-base-to-evm.ts` + `core/squidrouter.ts:calculateEvmBridgeAndNetworkFee` + - Build `EvmBridgeRequest` from input IO + ctx.request + - Call `calculateEvmBridgeAndNetworkFee(request)` + - Deduct distributed USD fees from the bridged amount (see `OnRampSquidRouterToBaseEngine.compute` lines 101-107) + - Use `getBridgeTargetTokenDetails` for the destination token (handles MORPHO_VAULT -> USDC) +- `phases`: `["squidRouterSwap", "squidRouterPay"]` +- Output meta: `{ effectiveExchangeRate, networkFeeUSD, routeData, fromToken, toToken }` + +### `morpho-mint.ts` — `MorphoMint` +- Type: `Phase, PhaseIO>` +- Port from: `handlers/morpho-deposit-handler.ts` (the deposit/previewDeposit pattern) and + `engines/initialize/offramp-from-evm-morpho.ts` (the previewRedeem pattern — mirror it for previewDeposit) + - For `simulate()`: call the vault's `previewDeposit(assets)` read to convert USDC -> expected shares + - Use the Morpho vault ABI snippet already present in `morpho-deposit-handler.ts` and `morpho-vault-config.ts` + - The vault address comes from `ctx.request` / morpho config; read `handlers/morpho-vault-config.ts` +- `phases`: `["morphoDeposit"]` +- Output meta: `{ vaultAddress, sharesAmountRaw, expectedUsdcRaw }` +- `Chain` is generic so the same block handles Base and Arbitrum vaults; narrow at runtime on `input.chain`. + +## EUR_ONRAMP_MORPHO flow (`flows/eur-onramp-morpho.ts`) + +```ts +import { EvmToken, Networks } from "@vortexfi/shared"; +import { FlowBuilder } from "../core/flow"; +import { branch, passthrough } from "../core/combinators"; +import { WithSubsidy } from "../core/subsidy"; +import { MykoboMint } from "../phases/mykobo-mint"; +import { NablaSwap } from "../phases/nabla-swap"; +import { SquidRouterSwap } from "../phases/squid-router-swap"; +import { MorphoMint } from "../phases/morpho-mint"; + +export const eurOnrampMorphoFlow = FlowBuilder + .start(MykoboMint) + .pipe(WithSubsidy(NablaSwap(Networks.Base, EvmToken.EURC, EvmToken.USDC), { bookend: "subsidizePostSwap" })) + .pipe( + branch( + ctx => (ctx.request.to === Networks.Base ? 0 : 1), + [passthrough(), SquidRouterSwap(Networks.Base, Networks.Arbitrum, EvmToken.USDC)] + ) + ) + .pipe(MorphoMint()) + .build("EurOnrampMorpho"); +``` + +**Note on factory functions:** +- `MykoboMint` is a plain `const` (no generics): `Phase, PhaseIO>`. +- `NablaSwap(chain, inToken, outToken)` and `SquidRouterSwap(fromChain, toChain, token)` are generic **functions taking runtime args** (the enum member values); brand types are inferred from the args. They need runtime values to call `getOnChainTokenDetails` / build the bridge request. +- `MorphoMint()` is a generic function with **type args only** (no runtime chain arg) because the chain is a union (`Networks.Base | Networks.Arbitrum`) at the call site — it reads `input.chain` at runtime instead. +- `passthrough()` is type-args only (pure no-op, no runtime logic). +- **Brands are enum member types** (e.g. `EvmToken.EURC`, `Networks.Base`), never plain string literals — keep this consistent so adjacency matches. Key values: `FiatToken.EURC="EUR"`, `EvmToken.EURC="EURC"`, `EvmToken.USDC="USDC"`, `EvmToken.MORPHO_VAULT="MORPHO VAULT"`, `Networks.Base="base"`, `Networks.Arbitrum="arbitrum"`. + +The `branch` output type is `PhaseIO`. +`MorphoMint` is instantiated with `Chain = Networks.Base | Networks.Arbitrum`. +This is the documented relaxation: at a runtime branch point the chain brand +becomes a union and the downstream phase must be generic over it. The strong +compile-time guarantee still holds for the linear segments (MykoboMint → NablaSwap, +and within each branch). + +## Phase-flow derivation (`core/phase-flow.ts`) + +```ts +import { RampPhase } from "@vortexfi/shared"; +import { Flow } from "./types"; + +export function assemblePhaseFlow( + flow: Flow, + opts: { direction: RampDirection; isBaseVault: boolean } +): RampPhase[]; +``` + +Produce a `RampPhase[]` that deep-equals the existing definitions: +- Cross-chain (`isBaseVault: false`): `EUR_ONRAMP_MORPHO` from `ramp-flow-definitions.ts` +- Base vault (`isBaseVault: true`): `EUR_ONRAMP_BASE_MORPHO` + +The block-derived core phases (from `flow.phases`) are: +`["mykoboOnrampDeposit", "subsidizePreSwap", "nablaApprove", "nablaSwap", "subsidizePostSwap", "squidRouterSwap", "squidRouterPay", "morphoDeposit"]` +(cross-chain) or the same without the squid pair (base). `assemblePhaseFlow` +prepends `["initial"]`, inserts `["fundEphemeral"]` after `mykoboOnrampDeposit`, +inserts `["distributeFees"]` after `nablaSwap` (before `subsidizePostSwap`), and +appends `["complete"]`. Compare carefully against the existing arrays in +`ramp-flow-definitions.ts:167-200` to get the exact ordering and bookends. + +## Verification (`__tests__/eur-onramp-morpho.parity.test.ts`) + +1. **Structural:** `eurOnrampMorphoFlow.phases` equals the expected core phases array. +2. **Phase-flow parity:** `assemblePhaseFlow(eurOnrampMorphoFlow, { isBaseVault: false })` + deep-equals `EUR_ONRAMP_MORPHO`; the base variant deep-equals `EUR_ONRAMP_BASE_MORPHO`. +3. **Compile-time adjacency (type-level):** include a `// @ts-expect-error` block + showing a deliberately mis-ordered flow (e.g. `MorphoMint` before `NablaSwap`) + fails to compile — proving the type guard works. +4. **Simulate smoke test:** with external calls mocked (viem readContract, + MykoboApiService.defaultDepositFee, Squid getRoute, Morpho previewDeposit), + `eurOnrampMorphoFlow.simulate(ctx)` returns a `PhaseIO` whose `amount > 0` and + `token === EvmToken.MORPHO_VAULT`. Full numerical parity vs the old strategy + is a follow-up integration test (out of POC scope). + +## Conventions (non-negotiable) + +- `bun`, never npm/yarn/pnpm. Run `bun lint:fix` then `bun typecheck` from repo root. +- Biome: line width 128, 2-space indent, semicolons always, double quotes, no trailing commas. +- DO NOT add comments unless this DESIGN doc explicitly asks. No docstrings on code you didn't touch. +- Surgical changes: touch only files under `blocks/`. Do NOT modify any existing + file outside `blocks/` (no edits to `quote/core/*`, `engines/*`, `phases/*`, + `ramp-flow-definitions.ts`, etc.). The POC coexists with the old code. +- 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 (e.g. `engines/nabla-swap/base-evm.ts`). + +## Existing files to read before implementing (per phase) + +| Block | Primary port source | +|-------|---------------------| +| MykoboMint | `engines/initialize/onramp-mykobo.ts`, `engines/initialize/index.ts` | +| NablaSwap | `engines/nabla-swap/base-evm.ts`, `engines/nabla-swap/onramp-mykobo-evm.ts`, `core/nabla.ts` | +| SquidRouterSwap | `engines/squidrouter/onramp-base-to-evm.ts`, `engines/squidrouter/index.ts`, `core/squidrouter.ts` | +| MorphoMint | `handlers/morpho-deposit-handler.ts`, `handlers/morpho-vault-config.ts`, `engines/initialize/offramp-from-evm-morpho.ts` | +| WithSubsidy | `engines/discount/onramp.ts`, `engines/merge-subsidy/offramp-evm.ts`, `core/types.ts` (subsidy field) | +| fees | `core/quote-fees.ts`, `core/helpers.ts` | +| phase-flow | `phases/ramp-flow-definitions.ts` (EUR_ONRAMP_MORPHO, EUR_ONRAMP_BASE_MORPHO) | + +All paths are relative to `apps/api/src/api/services/quote/` (or `apps/api/src/api/services/` for `phases/` and `handlers/`). diff --git a/apps/api/src/api/services/quote/blocks/REPORT.md b/apps/api/src/api/services/quote/blocks/REPORT.md new file mode 100644 index 000000000..90ec38757 --- /dev/null +++ b/apps/api/src/api/services/quote/blocks/REPORT.md @@ -0,0 +1,379 @@ +# Block-Based Quote Engine — Implementation Report + +> Companion to [`DESIGN.md`](./DESIGN.md). DESIGN.md is the forward-looking spec +> (signatures, port sources, conventions). This file is the achievement record: +> the journey, the critical technical discoveries, the final state, the gaps, +> and the roadmap. + +## Executive summary + +A typed, composable "block" model for defining Vortex quote flows was prototyped +against the `EUR_ONRAMP_MORPHO` corridor (Mykobo SEPA → EURC on Base → Nabla +EURC→USDC swap → Squid bridge to destination chain → Morpho vault deposit). The +POC lives entirely under `apps/api/src/api/services/quote/blocks/` and coexists +with the old quote engine — **zero edits outside `blocks/`**. + +The headline goal is achieved: **adjacent phases are checked for compatibility +at compile time**. A phase that outputs USDC on Base cannot feed a phase that +expects USDC on Polygon — TypeScript rejects it. A deliberately mis-ordered flow +in the test file is suppressed by `// @ts-expect-error`, proving the guard is +real (an unused directive would fail `bun typecheck`). + +The derived execution `phaseFlow` arrays **deep-equal** the existing +hand-maintained `EUR_ONRAMP_MORPHO` and `EUR_ONRAMP_BASE_MORPHO` arrays in +`ramp-flow-definitions.ts`, so `PhaseProcessor` compatibility is preserved. + +Final verification: `4 pass, 1 skip, 0 fail` (structural parity + compile-time +adjacency proof + an end-to-end `simulate` smoke test that ran, not skipped). + +## M3 implementor agent + +Defined at `.opencode/agents/m3-implementor.md` (41 lines): +- `mode: subagent`, `model: opencode-go/MiniMax-M3` +- Full edit/write/bash/read/glob/grep/list permissions +- Prompt encodes the CLAUDE.md rules (bun, Biome 128/2-space/semis/double-quotes, + no comments, surgical changes, no over-engineering, `FiatToken` 6-value rule) +- Workflow: read referenced files → implement → `bun lint:fix` → `bun typecheck` + → report back + +**Restart opencode to activate it as a `subagent_type`.** In the session that +produced this report, opencode's config is loaded once at startup and not +hot-reloaded, so implementation ran via `general` subagents behaving per the M3 +prompt. After restart, future sessions can spawn real M3 subagents with +`subagent_type: "m3-implementor"`. + +## Execution model (how this was built) + +Coordinator (this agent) defined architecture + specs + reviewed each wave. +Implementor subagents did the typing. Four sequential waves: + +| Wave | Scope | Subagents | Parallel? | +|------|-------|-----------|-----------| +| 1 | Core primitives (7 files under `core/`) | 1 | — | +| 2 | 4 phases (`phases/*.ts`) | 4 | yes | +| 3 | Flow assembly (`flows/eur-onramp-morpho.ts`) | 1 | — | +| 4 | Parity test (`__tests__/*.test.ts`) | 1 | — | + +The coordinator reviewed each wave before launching the next. One wave (Wave 1) +required a coordinator-level fix to `flow.ts` after review — the subagent's +variadic `flow()` compiled but silently bypassed the adjacency check (see +"Critical technical discoveries" below). + +## Critical technical discoveries + +These are the two non-obvious fixes that made the compile-time guarantee +actually work. They are the most valuable knowledge in this POC and are baked +into `core/flow.ts`. + +### Discovery 1: `FlowBuilder` over a variadic `flow()` function + +DESIGN.md originally specified a variadic `flow(name, p1, p2, p3, p4)` builder +with overloaded signatures for 1..6 phases. Wave 1 implemented it. An +empirical probe proved it **did not enforce adjacency**: a deliberately +mis-ordered flow compiled without error. + +Root cause: the variadic implementation signature +`flow(name, ...phases: Phase[])` acts as a fallback when +overload inference fails. Because `Phase`'s `simulate` is declared as a method +shorthand (bivariant under `strictFunctionTypes`), any branded phase is +assignable to `Phase`, so the fallback accepts anything. + +Fix: a `FlowBuilder` class where `.pipe(next)` is a **single method signature** +with no overload fallback to escape to: + +```ts +pipe

>(next: P): FlowBuilder>; +``` + +A brand mismatch at a `.pipe()` call is now a hard type error with no permissive +fallback path. The probe confirmed: the mis-ordered flow now errors (the +`@ts-expect-error` is correctly consumed), the correct flow still compiles. + +### Discovery 2: `OutputOf

` over a second generic parameter + +The first `FlowBuilder` attempt still didn't catch the error. Debug showed the +output brand was **widening to `PhaseIO`** (the constraint bound) during generic +inference, severing the brand chain at the very first `.pipe()`. + +Root cause: `start

, O1>` infers `O1` through the +constraint (`PhaseIO`) rather than from the actual phase type. The literal +brand (`"EURC"`, `"base"`) is lost. + +Fix: infer via a conditional `infer` type on a single generic, which preserves +the literal brand: + +```ts +type OutputOf

= P extends Phase ? O : never; + +static start

>(first: P): FlowBuilder>; +pipe

>(next: P): FlowBuilder>; +``` + +This was verified with a type-equality probe (`Equals>` +returned `true` after the fix, `false` before). + +### Documented relaxation: `branch()` at runtime decision points + +A `branch()` that selects between a `passthrough` (stays on Base) and a +`SquidRouterSwap` (goes Base→Arbitrum) produces a union output brand +`PhaseIO`. TypeScript does not infer this union +automatically from the branches — it picks the first branch's output and +rejects the second. The fix is an **explicit type argument** on `branch`: + +```ts +branch< + PhaseIO, // input + PhaseIO // output (union) +>(select, [passthrough<...>(), SquidRouterSwap(...)]) +``` + +This is the intentional escape hatch: at a runtime branch point the chain brand +becomes a union and the downstream phase (`MorphoMint`) +must be generic over it. The strong compile-time guarantee still holds for the +linear segments (MykoboMint → NablaSwap, and within each branch). + +## Final file inventory + +14 files under `blocks/` (1045 lines incl. DESIGN.md) + 1 agent file (41 lines). + +``` +.opencode/agents/m3-implementor.md 41 + +apps/api/src/api/services/quote/blocks/ + DESIGN.md 361 + core/ + types.ts (PhaseIO, Phase, Flow, PhaseCtx) 38 + io.ts (requestToIO, evmIO) 23 + flow.ts (FlowBuilder, OutputOf) 33 + combinators.ts (branch, passthrough) 43 + subsidy.ts (WithSubsidy) 92 + fees.ts (computeFees) 48 + phase-flow.ts (assemblePhaseFlow) 28 + phases/ + mykobo-mint.ts 43 + nabla-swap.ts 58 + squid-router-swap.ts 64 + morpho-mint.ts 64 + flows/ + eur-onramp-morpho.ts 33 + __tests__/ + eur-onramp-morpho.parity.test.ts 117 +``` + +All `blocks/**/*.ts` files pass `bun typecheck` and `bun lint:fix` cleanly. +Pre-existing repo-wide typecheck/lint errors (4 in +`transactions/offramp/routes/evm-to-mykobo-morpho.ts`, ~193 lint in other +non-test files) are untouched and unrelated. + +## Verification results + +### Test run + +``` +bun test src/api/services/quote/blocks/__tests__/eur-onramp-morpho.parity.test.ts +→ 4 pass, 1 skip, 0 fail, 6 expect() calls, 5 tests across 1 file +``` + +| Part | Test | Result | +|------|------|--------| +| 1 | `derives the core phases from the assembled blocks` | pass | +| 1 | `assembles the cross-chain phaseFlow matching the existing EUR_ONRAMP_MORPHO` | pass | +| 1 | `assembles the base-vault phaseFlow matching the existing EUR_ONRAMP_BASE_MORPHO` | pass | +| 2 | `rejects a mis-ordered flow at compile time` (`.skip` + `@ts-expect-error`) | skip at runtime, **type-checked by tsc** (directive consumed → adjacency proven) | +| 3 | `runs the assembled flow end-to-end with mocked externals and lands on MORPHO_VAULT` | pass (ran, not skipped) | + +The Part 3 smoke test mocks `calculateNablaSwapOutputEvm`, +`calculateEvmBridgeAndNetworkFee`, `MykoboApiService.getInstance`, and +`EvmClientManager.getInstance` (the four real externals). It asserts the final +`PhaseIO` has `amount > 0`, `token === EvmToken.MORPHO_VAULT`, and `ctx.notes` +is non-empty — all three hold. + +### Compile-time adjacency proof + +The `// @ts-expect-error` directive on the mis-ordered flow +(`FlowBuilder.start(MorphoMint<...>()).pipe(MykoboMint)`) is **correctly +consumed** by a real TS2322 ("`Phase,...>` is not +assignable to `Phase`"). There is no "Unused `@ts-expect-error` +directive" error — the guard is real. If someone breaks adjacency in the +future, `bun typecheck` will fail. + +## Parity proof (derived `RampPhase[]` arrays) + +`assemblePhaseFlow` produces arrays that deep-equal the existing +hand-maintained definitions in `phases/ramp-flow-definitions.ts`: + +**Cross-chain** (`isBaseVault: false`) — deep-equals `EUR_ONRAMP_MORPHO` (lines 167-181): +``` +["initial", "mykoboOnrampDeposit", "fundEphemeral", "subsidizePreSwap", + "nablaApprove", "nablaSwap", "distributeFees", "subsidizePostSwap", + "squidRouterSwap", "squidRouterPay", "finalSettlementSubsidy", + "morphoDeposit", "complete"] +``` + +**Base vault** (`isBaseVault: true`) — deep-equals `EUR_ONRAMP_BASE_MORPHO` (lines 189-200): +``` +["initial", "mykoboOnrampDeposit", "fundEphemeral", "subsidizePreSwap", + "nablaApprove", "nablaSwap", "distributeFees", "subsidizePostSwap", + "morphoDeposit", "complete"] +``` + +The block-derived core phases (from `flow.phases`, before bookending) are: +``` +["mykoboOnrampDeposit", "subsidizePreSwap", "nablaApprove", "nablaSwap", + "subsidizePostSwap", "squidRouterSwap", "squidRouterPay", "morphoDeposit"] +``` +`assemblePhaseFlow` prepends `["initial"]`, inserts `["fundEphemeral"]` after +`mykoboOnrampDeposit`, inserts `["distributeFees"]` after `nablaSwap`, inserts +`["finalSettlementSubsidy"]` after `squidRouterPay` (cross-chain only), and +appends `["complete"]`. + +## The assembled flow + +```ts +FlowBuilder.start(MykoboMint) + .pipe(WithSubsidy(NablaSwap(Networks.Base, EvmToken.EURC, EvmToken.USDC), { bookend: "subsidizePostSwap" })) + .pipe( + branch< + PhaseIO, + PhaseIO + >( + ctx => (ctx.request.to === Networks.Base ? 0 : 1), + [passthrough(), SquidRouterSwap(Networks.Base, Networks.Arbitrum, EvmToken.USDC)] + ) + ) + .pipe(MorphoMint()) + .build("EurOnrampMorpho"); +``` + +## Known gaps & POC limitations + +Documented from subagent reports. All are intentional POC scope cuts, not bugs: + +1. **`WithSubsidy` simplified.** Computes subsidy metadata from + `ctx.partner.targetDiscount`/`maxSubsidy` + a single oracle price lookup. Does + NOT port: the DB partner lookup (`resolveDiscountPartner`), the per-engine + SquidRouter conversion-rate adjustment, or post-swap fee deduction from the + "actual" amount. `actualOutputAmountDecimal` = inner output amount directly. + Subsidy metadata shape mirrors the existing `QuoteContext.subsidy` field. +2. **`computeFees` network fee = `"0"`.** `calculateFeeComponents` (reused from + `core/quote-fees.ts`) returns no network component — the Squid network fee is + normally added mid-pipeline by per-engine `compute` (which needs `ctx.mykoboMint` + etc., not available on `PhaseCtx` at pre-`simulate` time). The adapter sets + `network: "0"` in both fee views rather than re-fetching the Squid fee. +3. **`MorphoMint` hardcodes `"usdc-arbitrum"` vault.** Only one vault is + configured in `morpho-vault-config.ts`; `PhaseCtx`/`CreateQuoteRequest` + carries no vault-id field. A multi-vault resolution (chain→vault-id map or a + `ctx.request` field) is deferred. When `branch` selects the `passthrough` + path (`input.chain === Networks.Base`), `previewDeposit` would be read on + Base against an Arbitrum vault address — no Base vault configured yet. +4. **`NablaSwap` SELL deductible = `0`.** The SELL path in the existing engine + reads `ctx.preNabla?.deductibleFeeAmountInSwapCurrency`, which is not on + `PhaseCtx`. Hardcoded to `0` (safe for the POC: `EUR_ONRAMP_MORPHO` is + BUY/onramp, where the deductible is `0` anyway — onramp deducts after swap). +5. **`NablaSwap` runtime is Base-only.** `calculateNablaSwapOutputEvm` hardcodes + `Networks.Base` in its `EvmClientManager.readContractWithRetry` call. The + `chain` arg is used only for branding/IO output. NablaSwap is only ever + instantiated with `chain = Networks.Base` in the POC flow. +6. **`QuoteTicketMetadata` import source.** `PartnerInfo` is not exported from + `@vortexfi/shared`; `core/types.ts` imports it from `../../core/types` + (read-only, no file outside `core/` was modified). +7. **Smoke test mock leakage.** `mock.module("../../../priceFeed.service", ...)` + did not fully intercept the real module — the real `PriceFeedService + initialized` log still appears. Harmless: `getOnchainOraclePrice` is wrapped + in `try/catch` inside both `NablaSwap` and `WithSubsidy`, so the flow + completes regardless. The 4 critical mocks (nabla, squidrouter, Mykobo, + EvmClientManager) all applied. + +## Conventions discovered (for future corridor ports) + +### Brand values (enum member string values — keep adjacency consistent) + +| Enum | Member | Value | +|------|--------|-------| +| `FiatToken` | `EURC` | `"EUR"` | +| `FiatToken` | `ARS` | `"ARS"` | +| `FiatToken` | `BRL` | `"BRL"` | +| `FiatToken` | `USD` | `"USD"` | +| `FiatToken` | `MXN` | `"MXN"` | +| `FiatToken` | `COP` | `"COP"` | +| `EvmToken` | `EURC` | `"EURC"` | +| `EvmToken` | `USDC` | `"USDC"` | +| `EvmToken` | `MORPHO_VAULT` | `"MORPHO VAULT"` | +| `Networks` | `Base` | `"base"` | +| `Networks` | `Arbitrum` | `"arbitrum"` | + +**Gotcha:** `FiatToken.EURC` is `"EUR"` but `EvmToken.EURC` is `"EURC"` — +different strings, so the brands are distinct types. This is what makes the +fiat→EVM boundary in `MykoboMint` (input `PhaseIO` +→ output `PhaseIO`) type-check: the +output brand genuinely differs from the input brand, and only `MykoboMint`'s +declared signature bridges them. + +### Factory function call forms (TS has no generic const values) + +| Export | Form | Why | +|--------|------|-----| +| `MykoboMint` | plain `const` (no generics) | no runtime variability | +| `NablaSwap(chain, inToken, outToken)` | generic **function with runtime args** | needs runtime values for `getOnChainTokenDetails`; brands inferred from args | +| `SquidRouterSwap(fromChain, toChain, token)` | generic **function with runtime args** | needs runtime values for bridge request; brands inferred from args | +| `MorphoMint()` | generic function with **type args only** | chain is a union at the call site; reads `input.chain` at runtime | +| `passthrough()` | generic function, type args only | pure no-op | +| `WithSubsidy(inner, opts)` | generic function wrapping a `Phase` | decorator | +| `branch(select, branches)` | generic function, often with **explicit type args** | needed when branches produce a union output (see "Documented relaxation" above) | + +### Biome rule that shaped the code + +`biome.json` sets `suspicious/noExplicitAny: "error"`, and its override that +sets it to `"off"` has no `includes` field, so Biome v2 does not apply the +override — `any` is genuinely an error everywhere. This is why `core/flow.ts` +uses `PhaseIO` (the base interface) instead of `any` for the loose constraint +slots, and why `Phase`'s `simulate` is a method shorthand (bivariant under +`strictFunctionTypes`, so branded phases remain assignable to `Phase`). + +## Roadmap (next steps, in priority order) + +1. **Port the remaining ~9 corridors.** Each is now small — primitives exist. + Apply the same `FlowBuilder.start(...).pipe(...).build()` pattern. Candidates + by complexity: `EUR_ONRAMP_BASE_DIRECT` (smallest), `BRL_ONRAMP_*`, + `ALFREDPAY_*`, the offramps. Each port replaces one entry in + `ramp-flow-definitions.ts` with a derived array. +2. **Wire the new flow into `QuoteService` behind a flag.** Run real parity + vs the old `onrampMykoboToEvmStrategy` for `EUR_ONRAMP_MORPHO`. Compare + `outputAmount` numerically. +3. **Implement `prepareTxs` and `execute` on `Phase`.** Currently only + `simulate` + `phases` are defined. Adding `prepareTxs(input, output, ctx): + Promise` and `execute(state, io): Promise` to the + `Phase` interface makes the execution side block-defined too — one source of + truth per corridor instead of three (strategy, `RampPhase[]`, route-prep). +4. **Full numerical parity integration test** vs `onrampMykoboToEvmStrategy` + (real externals, no mocks) as a follow-up to the smoke test. +5. **Close the POC gaps** in priority order: (a) `computeFees` network fee + (move the Squid fee fetch into a dedicated phase or a post-`simulate` fixup), + (b) `MorphoMint` multi-vault resolution, (c) `NablaSwap` SELL deductible, + (d) `WithSubsidy` full partner-DB lookup. +6. **Delete the old strategy + `ramp-flow-definitions.ts` entries** for each + ported corridor once parity is proven. The old `StageKey`/`RampPhase` strings + can coexist as aliases during migration. + +## Appendix: the existing code this refactor targets (for context) + +The entanglement this POC begins to unwind (full detail in the original +exploration): + +- **Quote side** (`apps/api/src/api/services/quote/`): `QuoteService` → + `RouteResolver` → `QuoteOrchestrator` walks `stages: StageKey[]` (10 keys) + calling `engine.execute(ctx)`. 10 strategies in `routes/strategies/`. Engines + in `engines/{initialize,nabla-swap,squidrouter,fee,discount,finalize,...}/`. +- **Execution side** (`apps/api/src/api/services/phases/` + + `transactions/`): `PhaseProcessor` walks `state.state.phaseFlow: RampPhase[]` + (28 distinct strings). Handlers in `handlers/*.ts` read `quote.metadata.*` by + string key. Route-prep in `transactions/{onramp,offramp}/routes/*.ts` picks + both the `phaseFlow` array AND builds `unsignedTxs` from the same metadata. +- **The gap:** 10 quote stages ≠ 28 execution phases. The mapping is implicit, + spread across three files. `QuoteContext` is a ~25-field optional bag. + `phaseFlow` is `string[]` — a typo fails only at runtime. Subsidy logic is + smeared across both pipelines. No compile-time guarantee that a Polygon-only + phase doesn't follow a Base swap. + +This POC proves the block model closes that gap, one corridor at a time, without +a big-bang rewrite. diff --git a/apps/api/src/api/services/quote/blocks/__tests__/eur-onramp-morpho.parity.test.ts b/apps/api/src/api/services/quote/blocks/__tests__/eur-onramp-morpho.parity.test.ts new file mode 100644 index 000000000..ab4191853 --- /dev/null +++ b/apps/api/src/api/services/quote/blocks/__tests__/eur-onramp-morpho.parity.test.ts @@ -0,0 +1,117 @@ +import { describe, expect, it, mock } from "bun:test"; +import Big from "big.js"; +import { EvmClientManager, EvmToken, EPaymentMethod, FiatToken, MykoboApiService, Networks, RampDirection } 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: "1.05", + nablaOutputAmountDecimal: new Big(105), + nablaOutputAmountRaw: "105000000" + }) +})); + +mock.module("../../core/squidrouter", () => ({ + calculateEvmBridgeAndNetworkFee: async () => ({ + finalEffectiveExchangeRate: "0.95", + finalGrossOutputAmountDecimal: new Big(95), + networkFeeUSD: "0.1", + outputTokenDecimals: 6 + }), + getBridgeTargetTokenDetails: () => ({ + erc20AddressSourceChain: "0xaf88d065e77c8cC2239327C5EDb3A432268e5831" + }) +})); + +mock.module("../../../priceFeed.service", () => ({ + priceFeedService: { + getOnchainOraclePrice: async () => ({ + lastUpdateTimestamp: 0, + name: "mock", + price: new Big(1) + }) + } +})); + +import { EUR_ONRAMP_BASE_MORPHO, EUR_ONRAMP_MORPHO } from "../../../phases/ramp-flow-definitions"; +import { FlowBuilder } from "../core/flow"; +import type { PhaseCtx, PhaseIO } from "../core/types"; +import { MorphoMint } from "../phases/morpho-mint"; +import { MykoboMint } from "../phases/mykobo-mint"; +import { eurOnrampMorphoBasePhaseFlow, eurOnrampMorphoFlow, eurOnrampMorphoPhaseFlow } from "../flows/eur-onramp-morpho"; + +describe("EUR_ONRAMP_MORPHO block flow — structure", () => { + it("derives the core phases from the assembled blocks", () => { + expect(eurOnrampMorphoFlow.phases).toEqual([ + "mykoboOnrampDeposit", + "subsidizePreSwap", + "nablaApprove", + "nablaSwap", + "subsidizePostSwap", + "squidRouterSwap", + "squidRouterPay", + "morphoDeposit" + ]); + }); + + it("assembles the cross-chain phaseFlow matching the existing EUR_ONRAMP_MORPHO", () => { + expect(eurOnrampMorphoPhaseFlow).toEqual(EUR_ONRAMP_MORPHO); + }); + + it("assembles the base-vault phaseFlow matching the existing EUR_ONRAMP_BASE_MORPHO", () => { + expect(eurOnrampMorphoBasePhaseFlow).toEqual(EUR_ONRAMP_BASE_MORPHO); + }); +}); + +describe("EUR_ONRAMP_MORPHO block flow — compile-time adjacency", () => { + it.skip("rejects a mis-ordered flow at compile time (MorphoMint before MykoboMint)", () => { + // MorphoMint expects USDC input; MykoboMint expects EUR/fiat input. + // @ts-expect-error adjacency: MorphoMint input brand (USDC) != MykoboMint input brand (EUR/fiat) + const _wrong = FlowBuilder.start(MorphoMint()).pipe(MykoboMint).build("wrong"); + void _wrong; + }); +}); + +describe("EUR_ONRAMP_MORPHO block flow — simulate smoke", () => { + it("runs the assembled flow end-to-end with mocked externals and lands on MORPHO_VAULT", async () => { + MykoboApiService.getInstance = mock(() => ({ + defaultDepositFee: mock(async () => ({ total: "0.5" })) + })) as unknown as typeof MykoboApiService.getInstance; + + EvmClientManager.getInstance = mock(() => ({ + getClient: mock(() => ({ + readContract: mock(async () => 1000000000000000000n) + })) + })) as unknown as typeof EvmClientManager.getInstance; + + const notes: string[] = []; + const ctx: PhaseCtx = { + addNote: (note: string) => { + notes.push(note); + }, + fees: { + usd: { anchor: "0.1", network: "0.1", partnerMarkup: "0", total: "0.3", vortex: "0.1" } + }, + now: new Date(), + notes, + partner: null, + request: { + from: EPaymentMethod.SEPA, + inputAmount: "100", + inputCurrency: FiatToken.EURC, + network: Networks.Base, + outputCurrency: EvmToken.MORPHO_VAULT, + rampType: RampDirection.BUY, + to: Networks.Arbitrum + } + }; + + const output: PhaseIO = await eurOnrampMorphoFlow.simulate(ctx); + + expect(output.amount.gt(0)).toBe(true); + expect(output.token).toBe(EvmToken.MORPHO_VAULT); + expect(ctx.notes.length).toBeGreaterThan(0); + }); +}); 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..c8672a494 --- /dev/null +++ b/apps/api/src/api/services/quote/blocks/core/fees.ts @@ -0,0 +1,48 @@ +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 { + 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..a48c7fe6d --- /dev/null +++ b/apps/api/src/api/services/quote/blocks/core/flow.ts @@ -0,0 +1,33 @@ +import type { RampPhase } from "@vortexfi/shared"; +import { requestToIO } from "./io"; +import type { Flow, Phase, PhaseCtx, PhaseIO } from "./types"; + +type OutputOf

= P extends Phase ? O : never; + +export class FlowBuilder { + private constructor(private readonly phaseList: Phase[]) {} + + 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); + return { + name, + phases, + async simulate(ctx: PhaseCtx): Promise { + let current: PhaseIO = requestToIO(ctx); + for (const phase of phaseList) { + current = await phase.simulate(current, 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..704cdd6d0 --- /dev/null +++ b/apps/api/src/api/services/quote/blocks/core/phase-flow.ts @@ -0,0 +1,25 @@ +import type { RampDirection, RampPhase } from "@vortexfi/shared"; +import type { Flow } from "./types"; + +export function assemblePhaseFlow(flow: Flow, opts: { direction: RampDirection; isBaseVault: boolean }): RampPhase[] { + const { isBaseVault } = opts; + const corePhases: RampPhase[] = isBaseVault + ? flow.phases.filter(phase => phase !== "squidRouterSwap" && phase !== "squidRouterPay") + : [...flow.phases]; + + const result: RampPhase[] = ["initial"]; + for (const phase of corePhases) { + result.push(phase); + if (phase === "mykoboOnrampDeposit") { + result.push("fundEphemeral"); + } + if (phase === "nablaSwap") { + result.push("distributeFees"); + } + if (phase === "squidRouterPay" && !isBaseVault) { + result.push("finalSettlementSubsidy"); + } + } + result.push("complete"); + return result; +} diff --git a/apps/api/src/api/services/quote/blocks/core/subsidy.ts b/apps/api/src/api/services/quote/blocks/core/subsidy.ts new file mode 100644 index 000000000..c22836c6d --- /dev/null +++ b/apps/api/src/api/services/quote/blocks/core/subsidy.ts @@ -0,0 +1,94 @@ +import { RampDirection } from "@vortexfi/shared"; +import Big from "big.js"; +import { priceFeedService } from "../../../priceFeed.service"; +import type { Phase, PhaseCtx, PhaseIO } from "./types"; + +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; +} + +export function WithSubsidy

, I extends PhaseIO, O extends PhaseIO>( + inner: P, + opts: { bookend: "subsidizePostSwap" | "finalSettlementSubsidy" } +): Phase { + return { + name: `WithSubsidy(${inner.name})`, + phases: ["subsidizePreSwap", ...inner.phases, opts.bookend], + async simulate(input: I, ctx: PhaseCtx): Promise { + const output = await inner.simulate(input, ctx); + const subsidy = await computeSubsidyMeta(output, ctx); + return { ...output, meta: { ...output.meta, subsidy } } as O; + } + }; +} + +async function computeSubsidyMeta(output: PhaseIO, ctx: PhaseCtx): Promise { + const partner = ctx.partner; + const targetDiscount = partner?.targetDiscount ?? 0; + const maxSubsidy = partner?.maxSubsidy ?? 0; + const isOfframp = ctx.request.rampType === RampDirection.SELL; + + let expectedOutputAmountDecimal = output.amount; + try { + const oracle = await priceFeedService.getOnchainOraclePrice(ctx.request.inputCurrency); + const effectivePrice = isOfframp ? new Big(1).div(oracle.price) : oracle.price; + const discountedRate = effectivePrice.mul(new Big(1).plus(targetDiscount)); + expectedOutputAmountDecimal = new Big(ctx.request.inputAmount).mul(discountedRate); + } catch (error) { + ctx.addNote(`WithSubsidy: oracle price unavailable, falling back to actual output as expected. Error: ${error}`); + } + + const actualOutputAmountDecimal = output.amount; + 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 => + output.amount.gt(0) ? new Big(output.amountRaw).times(decimal).div(output.amount).toFixed(0, 0) : "0"; + + return { + actualOutputAmountDecimal, + actualOutputAmountRaw: output.amountRaw, + applied: subsidyAmountInOutputTokenDecimal.gt(0), + expectedOutputAmountDecimal, + expectedOutputAmountRaw: toRaw(expectedOutputAmountDecimal), + 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; +} 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..10f76c546 --- /dev/null +++ b/apps/api/src/api/services/quote/blocks/core/types.ts @@ -0,0 +1,38 @@ +import type { CreateQuoteRequest, QuoteFeeStructure, RampCurrency, RampPhase } from "@vortexfi/shared"; +import type { Big } from "big.js"; +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[]; + simulate(input: I, ctx: PhaseCtx): Promise; +} + +export interface Flow { + readonly name: string; + readonly phases: RampPhase[]; + simulate(ctx: PhaseCtx): Promise; +} diff --git a/apps/api/src/api/services/quote/blocks/flows/eur-onramp-morpho.ts b/apps/api/src/api/services/quote/blocks/flows/eur-onramp-morpho.ts new file mode 100644 index 000000000..ce55ec4b2 --- /dev/null +++ b/apps/api/src/api/services/quote/blocks/flows/eur-onramp-morpho.ts @@ -0,0 +1,33 @@ +import { EvmToken, Networks, RampDirection } from "@vortexfi/shared"; +import { branch, passthrough } from "../core/combinators"; +import { FlowBuilder } from "../core/flow"; +import { assemblePhaseFlow } from "../core/phase-flow"; +import { WithSubsidy } from "../core/subsidy"; +import type { Flow, PhaseIO } from "../core/types"; +import { MorphoMint } from "../phases/morpho-mint"; +import { MykoboMint } from "../phases/mykobo-mint"; +import { NablaSwap } from "../phases/nabla-swap"; +import { SquidRouterSwap } from "../phases/squid-router-swap"; + +export const eurOnrampMorphoFlow: Flow = FlowBuilder.start(MykoboMint) + .pipe(WithSubsidy(NablaSwap(Networks.Base, EvmToken.EURC, EvmToken.USDC), { bookend: "subsidizePostSwap" })) + .pipe( + branch< + PhaseIO, + PhaseIO + >( + ctx => (ctx.request.to === Networks.Base ? 0 : 1), + [passthrough(), SquidRouterSwap(Networks.Base, Networks.Arbitrum, EvmToken.USDC)] + ) + ) + .pipe(MorphoMint()) + .build("EurOnrampMorpho"); + +export const eurOnrampMorphoPhaseFlow = assemblePhaseFlow(eurOnrampMorphoFlow, { + direction: RampDirection.BUY, + isBaseVault: false +}); +export const eurOnrampMorphoBasePhaseFlow = assemblePhaseFlow(eurOnrampMorphoFlow, { + direction: RampDirection.BUY, + isBaseVault: true +}); diff --git a/apps/api/src/api/services/quote/blocks/phases/morpho-mint.ts b/apps/api/src/api/services/quote/blocks/phases/morpho-mint.ts new file mode 100644 index 000000000..80fae523c --- /dev/null +++ b/apps/api/src/api/services/quote/blocks/phases/morpho-mint.ts @@ -0,0 +1,64 @@ +import { EvmClientManager, EvmNetworks, EvmToken } from "@vortexfi/shared"; +import Big from "big.js"; +import { getMorphoVaultInfo } from "../../../phases/handlers/morpho-vault-config"; +import { evmIO } from "../core/io"; +import type { ChainBrand, Phase, PhaseCtx, PhaseIO } from "../core/types"; + +const morphoVaultAbi = [ + { + inputs: [ + { name: "assets", type: "uint256" }, + { name: "receiver", type: "address" } + ], + name: "deposit", + outputs: [{ name: "shares", type: "uint256" }], + stateMutability: "nonpayable", + type: "function" + }, + { + inputs: [{ name: "owner", type: "address" }], + name: "balanceOf", + outputs: [{ name: "", type: "uint256" }], + stateMutability: "view", + type: "function" + }, + { + inputs: [{ name: "assets", type: "uint256" }], + name: "previewDeposit", + outputs: [{ name: "shares", type: "uint256" }], + stateMutability: "view", + type: "function" + } +] as const; + +export function MorphoMint(): Phase< + PhaseIO, + PhaseIO +> { + return { + name: "MorphoMint", + phases: ["morphoDeposit"], + async simulate(input, ctx) { + const vault = getMorphoVaultInfo("usdc-arbitrum"); + const network = input.chain as EvmNetworks; + const client = EvmClientManager.getInstance().getClient(network); + + const sharesRaw = (await client.readContract({ + abi: morphoVaultAbi, + address: vault.vaultAddress, + args: [BigInt(input.amountRaw)], + functionName: "previewDeposit" + })) as bigint; + + const sharesDecimal = new Big(sharesRaw.toString()).div(new Big(10).pow(vault.shareDecimals)); + + ctx.addNote(`MorphoMint: ${input.amount} USDC -> ${sharesDecimal.toFixed()} vault shares on ${network}`); + + return evmIO(EvmToken.MORPHO_VAULT, input.chain as Chain, sharesDecimal, sharesRaw.toString(), { + depositAssetAddress: vault.depositAssetAddress, + expectedUsdcRaw: input.amountRaw, + vaultAddress: vault.vaultAddress + }); + } + }; +} 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..713522c8a --- /dev/null +++ b/apps/api/src/api/services/quote/blocks/phases/mykobo-mint.ts @@ -0,0 +1,43 @@ +import { + EvmToken, + FiatToken, + getOnChainTokenDetails, + MykoboApiService, + multiplyByPowerOfTen, + Networks +} from "@vortexfi/shared"; +import Big from "big.js"; +import { evmIO } from "../core/io"; +import type { Phase, 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, { + inputAmountRaw: input.amountRaw, + mykoboFee: mykoboFeeDecimal + }); + } +}; 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..e3f8a3a92 --- /dev/null +++ b/apps/api/src/api/services/quote/blocks/phases/nabla-swap.ts @@ -0,0 +1,58 @@ +import { EvmTokenDetails, getOnChainTokenDetails, Networks } from "@vortexfi/shared"; +import { Big } from "big.js"; +import logger from "../../../../../config/logger"; +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"; + +export function NablaSwap( + chain: Chain, + inToken: InToken, + outToken: OutToken +): Phase, PhaseIO> { + return { + 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, { + effectiveExchangeRate: result.effectiveExchangeRate, + inputAmountForSwapRaw, + inputToken: inToken, + oraclePrice: oraclePrice?.price, + outputToken: outToken + }); + } + }; +} 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..60b05144e --- /dev/null +++ b/apps/api/src/api/services/quote/blocks/phases/squid-router-swap.ts @@ -0,0 +1,64 @@ +import { EvmTokenDetails, getOnChainTokenDetails, multiplyByPowerOfTen, Networks, OnChainToken } from "@vortexfi/shared"; +import { Big } from "big.js"; +import { calculateEvmBridgeAndNetworkFee, getBridgeTargetTokenDetails } from "../../core/squidrouter"; +import { evmIO } from "../core/io"; +import type { ChainBrand, Phase, PhaseCtx, PhaseIO, TokenBrand } from "../core/types"; + +export function SquidRouterSwap( + fromChain: FromChain, + toChain: ToChain, + token: Token +): Phase, PhaseIO> { + return { + name: `SquidRouterSwap(${fromChain}->${toChain}/${token})`, + 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 usdFees = ctx.fees.usd; + + const inputTokenDetails = getOnChainTokenDetails(fromChain as Networks, token) as EvmTokenDetails; + + const usdFeesDistributedDecimal = Big(usdFees.network).plus(usdFees.vortex).plus(usdFees.partnerMarkup); + const usdFeesDistributedRaw = multiplyByPowerOfTen(usdFeesDistributedDecimal, inputTokenDetails.decimals); + + const inputAmountDecimal = new Big(input.amount).minus(usdFeesDistributedDecimal); + const inputAmountRaw = new Big(input.amountRaw).minus(usdFeesDistributedRaw).toFixed(0, 0); + + const fromToken = inputTokenDetails.erc20AddressSourceChain; + const toTokenDetails = getBridgeTargetTokenDetails(ctx.request.outputCurrency as OnChainToken, toChain as Networks); + const toToken = toTokenDetails.erc20AddressSourceChain; + + const bridgeRequest = { + amountRaw: inputAmountRaw, + fromNetwork: fromChain as Networks, + fromToken, + originalInputAmountForRateCalc: input.amountRaw, + rampType: ctx.request.rampType, + toNetwork: toChain as Networks, + toToken + }; + + 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} ${token} on ${fromChain} -> ${bridgeResult.finalGrossOutputAmountDecimal.toFixed()} ${token} on ${toChain}` + ); + + return evmIO(token, toChain, bridgeResult.finalGrossOutputAmountDecimal, outputAmountRaw, { + effectiveExchangeRate: bridgeResult.finalEffectiveExchangeRate, + fromToken, + inputAmountDecimal, + inputAmountRaw, + networkFeeUSD: bridgeResult.networkFeeUSD, + toToken + }); + } + }; +} From 049c5a19cf8c08e2c86344ec1a3b8dd37d035808 Mon Sep 17 00:00:00 2001 From: Gianfranco Date: Thu, 18 Jun 2026 14:43:03 -0300 Subject: [PATCH 22/26] refactor proposal --- .../src/api/services/quote/blocks/DESIGN.md | 361 ------------- .../src/api/services/quote/blocks/README.md | 493 ++++++++++++++++++ .../src/api/services/quote/blocks/REPORT.md | 379 -------------- .../eur-onramp-morpho.parity.test.ts | 200 +++++-- .../api/services/quote/blocks/core/flow.ts | 2 + .../services/quote/blocks/core/phase-flow.ts | 25 +- .../quote/blocks/flows/eur-onramp-morpho.ts | 49 +- .../quote/blocks/phases/distribute-fees.ts | 34 ++ .../blocks/phases/final-settlement-subsidy.ts | 18 + .../quote/blocks/phases/fund-ephemeral.ts | 15 + .../quote/blocks/phases/morpho-mint.ts | 10 +- .../quote/blocks/phases/mykobo-mint.ts | 14 +- .../quote/blocks/phases/nabla-swap.ts | 20 +- .../quote/blocks/phases/squid-router-swap.ts | 19 +- .../quote/blocks/phases/subsidize-post.ts | 18 + .../subsidy.ts => phases/subsidize-pre.ts} | 93 ++-- 16 files changed, 876 insertions(+), 874 deletions(-) delete mode 100644 apps/api/src/api/services/quote/blocks/DESIGN.md create mode 100644 apps/api/src/api/services/quote/blocks/README.md delete mode 100644 apps/api/src/api/services/quote/blocks/REPORT.md create mode 100644 apps/api/src/api/services/quote/blocks/phases/distribute-fees.ts create mode 100644 apps/api/src/api/services/quote/blocks/phases/final-settlement-subsidy.ts create mode 100644 apps/api/src/api/services/quote/blocks/phases/fund-ephemeral.ts create mode 100644 apps/api/src/api/services/quote/blocks/phases/subsidize-post.ts rename apps/api/src/api/services/quote/blocks/{core/subsidy.ts => phases/subsidize-pre.ts} (54%) diff --git a/apps/api/src/api/services/quote/blocks/DESIGN.md b/apps/api/src/api/services/quote/blocks/DESIGN.md deleted file mode 100644 index 1937e74bd..000000000 --- a/apps/api/src/api/services/quote/blocks/DESIGN.md +++ /dev/null @@ -1,361 +0,0 @@ -# Block-Based Quote Engine — Proof of Concept - -## Goal - -Introduce a typed, composable "block" model for defining quote flows. Each phase -declares its input/output IO brands (token + chain), and the `flow()` builder -enforces **at compile time** that adjacent phases are compatible — a Base swap -cannot feed a Polygon-only transfer, a Morpho deposit on Arbitrum cannot follow a -passthrough that stayed on Base. - -**Scope of this POC:** the `EUR_ONRAMP_MORPHO` corridor -(Mykobo SEPA → EURC on Base → Nabla EURC→USDC swap → Squid bridge to dest chain → -Morpho vault deposit). The old strategy (`onrampMykoboToEvmStrategy` + morpho -route-prep) and all existing phase handlers **coexist** and are NOT modified. -The POC lives entirely under `blocks/` and is verified by a parity test. - -## File structure - -``` -apps/api/src/api/services/quote/blocks/ - DESIGN.md # this file - core/ - types.ts # PhaseIO, Phase, Flow, PhaseCtx - io.ts # IO helpers + brand type aliases + requestToIO - flow.ts # flow() builder with compile-time adjacency - combinators.ts # branch(), passthrough() - subsidy.ts # WithSubsidy wrapper - phase-flow.ts # derive RampPhase[] from a Flow (+ infra bookends) - fees.ts # computeFees(ctx) helper (ports quote-fees.ts) - phases/ - mykobo-mint.ts # MykoboMint: fiat EUR -> EURC on Base - nabla-swap.ts # NablaSwap - squid-router-swap.ts # SquidRouterSwap - morpho-mint.ts # MorphoMint: USDC -> MORPHO_VAULT - flows/ - eur-onramp-morpho.ts # the assembled flow - __tests__/ - eur-onramp-morpho.parity.test.ts # structural + parity verification -``` - -## Core types (`core/types.ts`) - -```ts -import type { Big } from "big.js"; -import type { - CreateQuoteRequest, - PartnerInfo, - QuoteFeeStructure, - RampPhase, - RampCurrency -} from "@vortexfi/shared"; - -// Compile-time brand unions. Concrete phases instantiate PhaseIO with literal -// types drawn from EvmToken | FiatToken | AssetHubToken | PendulumCurrencyId -// (for Token) and Networks | "Pendulum" | "Stellar" | "fiat" (for Chain). -// Keep these as `extends string` so literal narrowing flows through generics. -export type TokenBrand = string; -export type ChainBrand = string; - -export interface PhaseIO { - amount: Big; // human-readable decimal amount - amountRaw: string; // integer-string raw amount at the token's decimals - token: Token; - chain: Chain; - meta: Record; // phase-specific (route id, oracle price, subsidy, ...) -} - -// Cross-phase context: the genuinely shared state that is NOT the per-phase IO. -// Replaces the cross-phase parts of the current QuoteContext (request, partner, -// fees, notes). Per-phase computed values travel on the PhaseIO.meta, not here. -export interface PhaseCtx { - request: CreateQuoteRequest & { userId?: string }; - partner: PartnerInfo | null; - now: Date; - notes: string[]; - addNote(note: string): void; - // Computed once by the flow runner via computeFees(ctx) before phases run. - fees?: { - usd?: { vortex: string; anchor: string; partnerMarkup: string; network: string; total: string }; - displayFiat?: QuoteFeeStructure; - }; - // Set by the MykoboMint phase so WithSubsidy can read the pre-swap amount. - // (Keep this minimal; prefer carrying values on PhaseIO.meta.) -} - -// A block. Generic over Input and Output IO brands so adjacency is checkable. -export interface Phase { - readonly name: string; - // The RampPhase(s) this block expands to in the execution phaseFlow. - // Example: NablaSwap -> ["nablaApprove", "nablaSwap"]. - readonly phases: RampPhase[]; - simulate(input: I, ctx: PhaseCtx): Promise; -} - -export interface Flow { - readonly name: string; - // Derived: flatMap(p => p.phases) over the assembled phases. - readonly phases: RampPhase[]; - // Runs the chain end to end. The first phase's input is built by requestToIO(ctx). - simulate(ctx: PhaseCtx): Promise; -} -``` - -## `FlowBuilder` (`core/flow.ts`) - -Compile-time adjacency is enforced via a **builder**, not a variadic `flow()` -function. A variadic impl signature (`Phase[]`) acts as a -fallback that bivariance makes permissive, silently bypassing the adjacency -check (verified empirically). The builder's `.pipe(next)` is a single method -signature with no overload fallback, so a brand mismatch is a hard type error. - -A second critical detail: the output brand must be extracted via a conditional -`infer` type, **not** a second generic param. `start

, O1>` widens `O1` to `PhaseIO` (the constraint bound) during inference, -severing the brand chain. `OutputOf

` preserves the literal brand. - -```ts -type OutputOf

= P extends Phase ? O : never; - -export class FlowBuilder { - private constructor(private readonly phaseList: Phase[]) {} - - static start

>(first: P): FlowBuilder>; - pipe

>(next: P): FlowBuilder>; - build(name: string): Flow; -} -``` - -Runtime: `build()` stores the phases array; `Flow.simulate(ctx)` builds the -first input via `requestToIO(ctx)`, then sequentially calls -`phase.simulate(prevOutput, ctx)`, returning the final output. -`Flow.phases` = `phaseList.flatMap(p => p.phases)`. - -Usage: `FlowBuilder.start(phase1).pipe(phase2).pipe(phase3).build("name")`. - -## `branch()` and `passthrough()` (`core/combinators.ts`) - -```ts -// branch: runtime-selects between phases based on ctx. The output type O is -// declared explicitly (the common downstream shape); each branch's Phase -// must produce IO assignable to O. This is the documented escape hatch where -// compile-time adjacency is intentionally relaxed at a runtime decision point. -export function branch( - select: (ctx: PhaseCtx) => Promise | number, - branches: Phase[] -): Phase; - -// passthrough: a no-op phase that forwards input as output (same token, same chain). -// Used when a bridge is skipped because funds are already on the target chain. -// phases: [] (no execution phases — it is a quote-side no-op). -export function passthrough(): Phase, PhaseIO>; -``` - -`branch`'s `phases` = the union (deduped, order-preserved) of all branches' phases. -For the EUR_ONRAMP_MORPHO flow, branches are `passthrough` (phases: []) and -`SquidRouterSwap` (phases: `["squidRouterSwap", "squidRouterPay"]`), so the -branch contributes `["squidRouterSwap", "squidRouterPay"]` to the derived -phaseFlow. `assemblePhaseFlow` (below) handles the conditional inclusion at -runtime — for the static `flow.phases` derivation, include the union. - -## `WithSubsidy` wrapper (`core/subsidy.ts`) - -```ts -export function WithSubsidy

, I extends PhaseIO, O extends PhaseIO>( - inner: P, - opts: { bookend: "subsidizePostSwap" | "finalSettlementSubsidy" } -): Phase; -``` - -- `name`: `WithSubsidy(${inner.name})` -- `phases`: `["subsidizePreSwap", ...inner.phases, opts.bookend]` -- `simulate()`: runs `inner.simulate(input, ctx)`, then computes subsidy metadata - and attaches it to `output.meta.subsidy`. Port the calculation from - `engines/discount/onramp.ts` and `engines/merge-subsidy/offramp-evm.ts`. The - metadata shape mirrors the current `QuoteContext.subsidy` field - (`core/types.ts:245-261` of the existing quote engine): - ```ts - meta.subsidy = { - applied: boolean; - subsidyRate: Big; - partnerId: string | null; - expectedOutputAmountDecimal: Big; - actualOutputAmountDecimal: Big; - subsidyAmountInOutputTokenDecimal: Big; - idealSubsidyAmountInOutputTokenDecimal: Big; - targetOutputAmountDecimal: Big; - // ...raw string counterparts - } - ``` - -## IO helpers (`core/io.ts`) - -```ts -// Build the first phase's input IO from the quote request. -export function requestToIO(ctx: PhaseCtx): PhaseIO< RampCurrency, "fiat">; - -// Convenience constructors for typed EVM / Pendulum IO. -export function evmIO( - token: Token, chain: Chain, amount: Big, amountRaw: string, meta?: Record -): PhaseIO; -``` - -## Fees helper (`core/fees.ts`) - -```ts -// Ports calculateFeeComponents from core/quote-fees.ts. Called once by the flow -// runner before simulate() so ctx.fees is populated for WithSubsidy and phases. -export async function computeFees(ctx: PhaseCtx): Promise; -``` - -## Phase implementations (`phases/*.ts`) - -Each phase ports the `compute()` logic of an existing engine, returning a typed -`PhaseIO` instead of mutating `QuoteContext`. Read the listed existing files -first and mimic their computation exactly (same external calls, same math) so -the parity test can pass. - -### `mykobo-mint.ts` — `MykoboMint` -- Type: `Phase, PhaseIO>` - (use the actual `FiatToken.EURC` and `Networks.Base` literal values) -- Port from: `engines/initialize/onramp-mykobo.ts` - - Call `MykoboApiService.getInstance().defaultDepositFee(inputAmountDecimal.toFixed(2, 0))` - - `deliveredEurc = inputAmount - mykoboFee` - - Use `getOnChainTokenDetails(Networks.Base, EvmToken.EURC)` for decimals/raw conversion -- `phases`: `["mykoboOnrampDeposit"]` -- Output meta: `{ mykoboFee, inputAmountRaw, outputAmountRaw }` - -### `nabla-swap.ts` — `NablaSwap` -- Type: `Phase, PhaseIO>` with `Chain`, `InToken`, `OutToken` as generics -- Port from: `engines/nabla-swap/base-evm.ts` + `core/nabla.ts:calculateNablaSwapOutputEvm` - - Subtract deductible fee (for offramp only; onramp returns 0) — see `BaseNablaSwapEngineEvm.getDeductibleFeeAmount` - - Call `calculateNablaSwapOutputEvm({ inputAmountForSwap, inputTokenDetails, outputTokenDetails, rampType })` - - Optionally fetch oracle price via `priceFeedService.getOnchainOraclePrice` -- `phases`: `["nablaApprove", "nablaSwap"]` -- Output meta: `{ effectiveExchangeRate, oraclePrice, inputAmountForSwapRaw, inputToken, outputToken }` -- Instantiate for the flow as `NablaSwap` - -### `squid-router-swap.ts` — `SquidRouterSwap` -- Type: `Phase, PhaseIO>` -- Port from: `engines/squidrouter/onramp-base-to-evm.ts` + `core/squidrouter.ts:calculateEvmBridgeAndNetworkFee` - - Build `EvmBridgeRequest` from input IO + ctx.request - - Call `calculateEvmBridgeAndNetworkFee(request)` - - Deduct distributed USD fees from the bridged amount (see `OnRampSquidRouterToBaseEngine.compute` lines 101-107) - - Use `getBridgeTargetTokenDetails` for the destination token (handles MORPHO_VAULT -> USDC) -- `phases`: `["squidRouterSwap", "squidRouterPay"]` -- Output meta: `{ effectiveExchangeRate, networkFeeUSD, routeData, fromToken, toToken }` - -### `morpho-mint.ts` — `MorphoMint` -- Type: `Phase, PhaseIO>` -- Port from: `handlers/morpho-deposit-handler.ts` (the deposit/previewDeposit pattern) and - `engines/initialize/offramp-from-evm-morpho.ts` (the previewRedeem pattern — mirror it for previewDeposit) - - For `simulate()`: call the vault's `previewDeposit(assets)` read to convert USDC -> expected shares - - Use the Morpho vault ABI snippet already present in `morpho-deposit-handler.ts` and `morpho-vault-config.ts` - - The vault address comes from `ctx.request` / morpho config; read `handlers/morpho-vault-config.ts` -- `phases`: `["morphoDeposit"]` -- Output meta: `{ vaultAddress, sharesAmountRaw, expectedUsdcRaw }` -- `Chain` is generic so the same block handles Base and Arbitrum vaults; narrow at runtime on `input.chain`. - -## EUR_ONRAMP_MORPHO flow (`flows/eur-onramp-morpho.ts`) - -```ts -import { EvmToken, Networks } from "@vortexfi/shared"; -import { FlowBuilder } from "../core/flow"; -import { branch, passthrough } from "../core/combinators"; -import { WithSubsidy } from "../core/subsidy"; -import { MykoboMint } from "../phases/mykobo-mint"; -import { NablaSwap } from "../phases/nabla-swap"; -import { SquidRouterSwap } from "../phases/squid-router-swap"; -import { MorphoMint } from "../phases/morpho-mint"; - -export const eurOnrampMorphoFlow = FlowBuilder - .start(MykoboMint) - .pipe(WithSubsidy(NablaSwap(Networks.Base, EvmToken.EURC, EvmToken.USDC), { bookend: "subsidizePostSwap" })) - .pipe( - branch( - ctx => (ctx.request.to === Networks.Base ? 0 : 1), - [passthrough(), SquidRouterSwap(Networks.Base, Networks.Arbitrum, EvmToken.USDC)] - ) - ) - .pipe(MorphoMint()) - .build("EurOnrampMorpho"); -``` - -**Note on factory functions:** -- `MykoboMint` is a plain `const` (no generics): `Phase, PhaseIO>`. -- `NablaSwap(chain, inToken, outToken)` and `SquidRouterSwap(fromChain, toChain, token)` are generic **functions taking runtime args** (the enum member values); brand types are inferred from the args. They need runtime values to call `getOnChainTokenDetails` / build the bridge request. -- `MorphoMint()` is a generic function with **type args only** (no runtime chain arg) because the chain is a union (`Networks.Base | Networks.Arbitrum`) at the call site — it reads `input.chain` at runtime instead. -- `passthrough()` is type-args only (pure no-op, no runtime logic). -- **Brands are enum member types** (e.g. `EvmToken.EURC`, `Networks.Base`), never plain string literals — keep this consistent so adjacency matches. Key values: `FiatToken.EURC="EUR"`, `EvmToken.EURC="EURC"`, `EvmToken.USDC="USDC"`, `EvmToken.MORPHO_VAULT="MORPHO VAULT"`, `Networks.Base="base"`, `Networks.Arbitrum="arbitrum"`. - -The `branch` output type is `PhaseIO`. -`MorphoMint` is instantiated with `Chain = Networks.Base | Networks.Arbitrum`. -This is the documented relaxation: at a runtime branch point the chain brand -becomes a union and the downstream phase must be generic over it. The strong -compile-time guarantee still holds for the linear segments (MykoboMint → NablaSwap, -and within each branch). - -## Phase-flow derivation (`core/phase-flow.ts`) - -```ts -import { RampPhase } from "@vortexfi/shared"; -import { Flow } from "./types"; - -export function assemblePhaseFlow( - flow: Flow, - opts: { direction: RampDirection; isBaseVault: boolean } -): RampPhase[]; -``` - -Produce a `RampPhase[]` that deep-equals the existing definitions: -- Cross-chain (`isBaseVault: false`): `EUR_ONRAMP_MORPHO` from `ramp-flow-definitions.ts` -- Base vault (`isBaseVault: true`): `EUR_ONRAMP_BASE_MORPHO` - -The block-derived core phases (from `flow.phases`) are: -`["mykoboOnrampDeposit", "subsidizePreSwap", "nablaApprove", "nablaSwap", "subsidizePostSwap", "squidRouterSwap", "squidRouterPay", "morphoDeposit"]` -(cross-chain) or the same without the squid pair (base). `assemblePhaseFlow` -prepends `["initial"]`, inserts `["fundEphemeral"]` after `mykoboOnrampDeposit`, -inserts `["distributeFees"]` after `nablaSwap` (before `subsidizePostSwap`), and -appends `["complete"]`. Compare carefully against the existing arrays in -`ramp-flow-definitions.ts:167-200` to get the exact ordering and bookends. - -## Verification (`__tests__/eur-onramp-morpho.parity.test.ts`) - -1. **Structural:** `eurOnrampMorphoFlow.phases` equals the expected core phases array. -2. **Phase-flow parity:** `assemblePhaseFlow(eurOnrampMorphoFlow, { isBaseVault: false })` - deep-equals `EUR_ONRAMP_MORPHO`; the base variant deep-equals `EUR_ONRAMP_BASE_MORPHO`. -3. **Compile-time adjacency (type-level):** include a `// @ts-expect-error` block - showing a deliberately mis-ordered flow (e.g. `MorphoMint` before `NablaSwap`) - fails to compile — proving the type guard works. -4. **Simulate smoke test:** with external calls mocked (viem readContract, - MykoboApiService.defaultDepositFee, Squid getRoute, Morpho previewDeposit), - `eurOnrampMorphoFlow.simulate(ctx)` returns a `PhaseIO` whose `amount > 0` and - `token === EvmToken.MORPHO_VAULT`. Full numerical parity vs the old strategy - is a follow-up integration test (out of POC scope). - -## Conventions (non-negotiable) - -- `bun`, never npm/yarn/pnpm. Run `bun lint:fix` then `bun typecheck` from repo root. -- Biome: line width 128, 2-space indent, semicolons always, double quotes, no trailing commas. -- DO NOT add comments unless this DESIGN doc explicitly asks. No docstrings on code you didn't touch. -- Surgical changes: touch only files under `blocks/`. Do NOT modify any existing - file outside `blocks/` (no edits to `quote/core/*`, `engines/*`, `phases/*`, - `ramp-flow-definitions.ts`, etc.). The POC coexists with the old code. -- 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 (e.g. `engines/nabla-swap/base-evm.ts`). - -## Existing files to read before implementing (per phase) - -| Block | Primary port source | -|-------|---------------------| -| MykoboMint | `engines/initialize/onramp-mykobo.ts`, `engines/initialize/index.ts` | -| NablaSwap | `engines/nabla-swap/base-evm.ts`, `engines/nabla-swap/onramp-mykobo-evm.ts`, `core/nabla.ts` | -| SquidRouterSwap | `engines/squidrouter/onramp-base-to-evm.ts`, `engines/squidrouter/index.ts`, `core/squidrouter.ts` | -| MorphoMint | `handlers/morpho-deposit-handler.ts`, `handlers/morpho-vault-config.ts`, `engines/initialize/offramp-from-evm-morpho.ts` | -| WithSubsidy | `engines/discount/onramp.ts`, `engines/merge-subsidy/offramp-evm.ts`, `core/types.ts` (subsidy field) | -| fees | `core/quote-fees.ts`, `core/helpers.ts` | -| phase-flow | `phases/ramp-flow-definitions.ts` (EUR_ONRAMP_MORPHO, EUR_ONRAMP_BASE_MORPHO) | - -All paths are relative to `apps/api/src/api/services/quote/` (or `apps/api/src/api/services/` for `phases/` and `handlers/`). 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..6d3b10f84 --- /dev/null +++ b/apps/api/src/api/services/quote/blocks/README.md @@ -0,0 +1,493 @@ +# 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 Morpho deposit on Arbitrum cannot follow a passthrough +that stayed on Base. The execution `RampPhase[]` is derived from the flow +(`["initial", ...flow.phases, "complete"]`), so the strategy, the execution +sequence, and the brand-correctness check all live in one place. + +**Scope of this POC:** the `EUR_ONRAMP_MORPHO` corridor (Mykobo SEPA → EURC +on Base → Nabla EURC→USDC → Squid bridge → Morpho vault deposit) in two +variants (cross-chain, base-vault). Lives entirely under +`apps/api/src/api/services/quote/blocks/` and coexists with the existing +quote engine — **zero edits outside `blocks/`**. + +**Status:** `8 pass, 1 skip, 0 fail` parity test. Compile-time adjacency +verified via a deliberately mis-ordered `@ts-expect-error` block in the +test. Both `assemblePhaseFlow(flow)` outputs deep-equal the hand-maintained +`EUR_ONRAMP_MORPHO` and `EUR_ONRAMP_BASE_MORPHO` arrays in +`phases/ramp-flow-definitions.ts`. The final `PhaseIO.meta` carries the +full `QuoteTicketMetadata` shape the old engines produced, so the existing +route-prep and handlers read it unchanged. + +--- + +## 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/ + mykobo-mint.ts # fiat EUR -> EURC on Base + fund-ephemeral.ts # FundEphemeral() + subsidize-pre.ts # SubsidizePre() + computeSubsidyMeta + nabla-swap.ts # NablaSwap() + distribute-fees.ts # DistributeFees() + subsidize-post.ts # SubsidizePost() + squid-router-swap.ts # SquidRouterSwap() + final-settlement-subsidy.ts # FinalSettlementSubsidy() + morpho-mint.ts # MorphoMint() + flows/ + eur-onramp-morpho.ts # eurOnrampMorphoFlow + eurOnrampBaseMorphoFlow + __tests__/ + eur-onramp-morpho.parity.test.ts # structural + parity + 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 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[]; // declared execution expansion + simulate(input: I, ctx: PhaseCtx): Promise; +} + +export interface Flow { + readonly name: string; + readonly phases: RampPhase[]; // flatMap(p => p.phases) over assembled phases + simulate(ctx: PhaseCtx): Promise; +} +``` + +`Token` and `Chain` are instantiated with literal types drawn from +`EvmToken`, `FiatToken`, `AssetHubToken`, `PendulumCurrencyId`, and +`Networks` / `"Pendulum"` / `"Stellar"` / `"fiat"`. Brands are kept as +`extends string` so literal narrowing flows through generics. + +### `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; + +export class FlowBuilder { + private constructor(private readonly phaseList: Phase[]) {} + + static start

>(first: P): FlowBuilder>; + pipe

>(next: P): FlowBuilder>; + build(name: string): Flow; +} +``` + +`OutputOf

` uses a conditional `infer` rather than a second generic +parameter — `start

, O1>` widens `O1` to +`PhaseIO` (the constraint bound) during inference and severs the brand +chain at the first `.pipe()`. `OutputOf

` preserves the literal brand. + +Runtime: `build()` stores the phases; `Flow.simulate(ctx)` builds the first +input via `requestToIO(ctx)`, then sequentially calls +`phase.simulate(prevOutput, ctx)`. `Flow.phases` = +`phaseList.flatMap(p => p.phases)`. + +### `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 `isBaseVault` flag, no +`branch` logic. The developer is responsible for piping every step +(including funding, fee distribution, subsidy, final settlement) 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`) + +```ts +export function branch( + select: (ctx: PhaseCtx) => Promise | number, + branches: Phase[] +): Phase; + +export function passthrough(): Phase< + PhaseIO, + PhaseIO +>; +``` + +`branch` runtime-selects between phases; the output `O` is declared +explicitly (the common downstream shape) and each branch's `Phase` +must produce IO assignable to `O`. `branch.phases` = the order-preserving +union of all branches' `phases`. + +These are kept as available primitives but are **not relied upon** in the +POC flows — the cross-chain and base-vault variants are written as two +explicit flows rather than runtime branches. Reach for `branch` only when a +flow genuinely needs to fork at simulate time; prefer two flows otherwise. + +### Phase catalog + +Every step in a corridor — including the "bookend" steps (funding, fee +distribution, subsidy, final settlement) — is a first-class `Phase`. +The flow assembles them linearly. + +| Phase | Type | `phases` | Meta keys written | Notes | +|-------|------|----------|------------------|-------| +| `MykoboMint` | `Phase, PhaseIO>` | `["mykoboOnrampDeposit"]` | `mykoboMint`, `fees` | Plain `const` (no generics). Ports `engines/initialize/onramp-mykobo.ts`. Also copies `ctx.fees` into meta. | +| `FundEphemeral()` | `Phase, PhaseIO>` | `["fundEphemeral"]` | (preserves) | Passthrough. Funding happens execution-side. | +| `SubsidizePre()` | `Phase, PhaseIO>` | `["subsidizePreSwap"]` | `subsidy` (partial: `expectedOutputAmountDecimal`, `expectedOutputAmountRaw`) | Computes expected output via `computeExpectedOutput(ctx)` — no meta read. Exports shared `buildFullSubsidy` + `computeExpectedOutput` helpers. | +| `NablaSwap(chain, in, out)` | `Phase, PhaseIO>` | `["nablaApprove", "nablaSwap"]` | `nablaSwapEvm` | Generic with runtime args. Ports `engines/nabla-swap/base-evm.ts` + `core/nabla.ts:calculateNablaSwapOutputEvm`. | +| `DistributeFees()` | `Phase, PhaseIO>` | `["distributeFees"]` | (preserves) | Reduces amount by `ctx.fees.usd`. | +| `SubsidizePost()` | `Phase, PhaseIO>` | `["subsidizePostSwap"]` | `subsidy` (full) | Independently computes expected via `computeExpectedOutput(ctx)`, builds and writes full subsidy. No meta read. | +| `SquidRouterSwap(from, to, token)` | `Phase, PhaseIO>` | `["squidRouterSwap", "squidRouterPay"]` | `evmToEvm` | Generic with runtime args. Ports `engines/squidrouter/onramp-base-to-evm.ts` + `core/squidrouter.ts:calculateEvmBridgeAndNetworkFee`. | +| `FinalSettlementSubsidy()` | `Phase, PhaseIO>` | `["finalSettlementSubsidy"]` | `subsidy` (full, overrides) | Same as `SubsidizePost` — independently computes expected, builds full subsidy. Overwrites any earlier `meta.subsidy`. | +| `MorphoMint()` | `Phase, PhaseIO>` | `["morphoDeposit"]` | `morphoDeposit` | Type-args only. Reads `input.chain` at runtime; calls `previewDeposit`. | + +### The two flows (`flows/eur-onramp-morpho.ts`) + +```ts +export const eurOnrampMorphoFlow: Flow = FlowBuilder.start(MykoboMint) + .pipe(FundEphemeral()) + .pipe(SubsidizePre()) + .pipe(NablaSwap(Networks.Base, EvmToken.EURC, EvmToken.USDC)) + .pipe(DistributeFees()) + .pipe(SubsidizePost()) + .pipe(SquidRouterSwap(Networks.Base, Networks.Arbitrum, EvmToken.USDC)) + .pipe(FinalSettlementSubsidy()) + .pipe(MorphoMint()) + .build("EurOnrampMorpho"); + +export const eurOnrampBaseMorphoFlow: Flow = FlowBuilder.start(MykoboMint) + .pipe(FundEphemeral()) + .pipe(SubsidizePre()) + .pipe(NablaSwap(Networks.Base, EvmToken.EURC, EvmToken.USDC)) + .pipe(DistributeFees()) + .pipe(SubsidizePost()) + .pipe(MorphoMint()) + .build("EurOnrampBaseMorpho"); + +export const eurOnrampMorphoPhaseFlow = assemblePhaseFlow(eurOnrampMorphoFlow); +export const eurOnrampBaseMorphoPhaseFlow = assemblePhaseFlow(eurOnrampBaseMorphoFlow); +``` + +No `WithSubsidy`, no `branch`, no `passthrough`. The two flows differ only +in whether the Squid bridge and the final settlement subsidy are present. + +### Parity proof (derived `RampPhase[]` arrays) + +`assemblePhaseFlow(flow)` produces arrays that deep-equal the hand-maintained +definitions in `phases/ramp-flow-definitions.ts`. + +**Cross-chain** — deep-equals `EUR_ONRAMP_MORPHO` (lines 167-181): +``` +["initial", "mykoboOnrampDeposit", "fundEphemeral", "subsidizePreSwap", + "nablaApprove", "nablaSwap", "distributeFees", "subsidizePostSwap", + "squidRouterSwap", "squidRouterPay", "finalSettlementSubsidy", + "morphoDeposit", "complete"] +``` + +**Base vault** — deep-equals `EUR_ONRAMP_BASE_MORPHO` (lines 189-200): +``` +["initial", "mykoboOnrampDeposit", "fundEphemeral", "subsidizePreSwap", + "nablaApprove", "nablaSwap", "distributeFees", "subsidizePostSwap", + "morphoDeposit", "complete"] +``` + +The bookend `["initial", ..., "complete"]` is the only thing +`assemblePhaseFlow` adds. Everything else is declared by the flow itself. + +### Verification + +`__tests__/eur-onramp-morpho.parity.test.ts` runs five kinds of checks: + +1. **Structural** — `flow.phases` equals the expected core phases array + (no bookends) for both flows. +2. **Phase-flow parity** — `assemblePhaseFlow(flow)` deep-equals the + existing hand-maintained array for both flows (asserted via both the + pre-derived constant and a direct call). +3. **Compile-time adjacency** — a `// @ts-expect-error` block + (`FlowBuilder.start(MorphoMint<...>()).pipe(MykoboMint)`) is + type-checked by tsc. If the brand guard were broken, the directive + would be unused and `bun typecheck` would fail. +4. **Simulate smoke** — with externals mocked (`calculateNablaSwapOutputEvm`, + `calculateEvmBridgeAndNetworkFee`, `MykoboApiService.defaultDepositFee`, + `EvmClientManager.readContract`), each flow's `simulate(ctx)` returns a + `PhaseIO` with `amount > 0`, `token === EvmToken.MORPHO_VAULT`, and + non-empty `ctx.notes`. +5. **Metadata parity** — the final `PhaseIO.meta` carries every key the old + engines produced (`mykoboMint`, `nablaSwapEvm`, `evmToEvm`, `subsidy`, + `fees`, `morphoDeposit`) with the same field names, so the existing + route-prep and handlers read it unchanged. + +Final test run: `8 pass, 1 skip, 0 fail, 45 expect() calls`. + +### 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 +(`mykoboMint`, `nablaSwapEvm`, `evmToEvm`, `subsidy`, `morphoDeposit`) +and **spreads `input.meta`** so earlier phases' data is preserved: + +```ts +return evmIO(token, chain, amount, amountRaw, { + ...input.meta, // preserve everything from earlier phases + mykoboMint: { ... }, // this phase's data +}); +``` + +The final `PhaseIO.meta` is therefore the union of all phase outputs — +equivalent to the old `QuoteContext` bag, minus the fields the POC doesn't +use (`preNabla`, `*Xcm`, `hydrationSwap`, `alfredpayMint`, `aveniaMint`, +`evmToMoonbeam`, `moonbeamToEvm`, etc.). The `QuoteService` casts it to +`QuoteTicketMetadata` and stores it as `quote.metadata`: + +```ts +const output = await flow.simulate(ctx); +quote.metadata = output.meta as QuoteTicketMetadata; +``` + +The existing route-prep (`transactions/onramp/routes/mykobo-to-evm-morpho.ts`) +already reads `quote.metadata.nablaSwapEvm.outputAmountRaw`, +`quote.metadata.evmToEvm.outputAmountRaw`, and +`quote.metadata.evmToEvm.inputAmountRaw` — these keys exist with the same +shapes as before. No route-prep or handler code needs to change. + +**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)` — where `input` is the typed `PhaseIO` (amount, token, +chain) and `ctx` is the cross-phase context (request, partner, fees, +notes). This means any phase can be removed, reordered, or swapped without +breaking another phase's simulation logic. The only adjacency constraint +is the `Phase` brand check enforced by `FlowBuilder.pipe`. + +The three subsidy phases (`SubsidizePre`, `SubsidizePost`, +`FinalSettlementSubsidy`) each independently call the shared +`computeExpectedOutput(ctx)` helper to derive the oracle-based expected +output from `ctx.request` + `ctx.partner` — they do not read each other's +meta. `SubsidizePre` writes a partial `meta.subsidy` (just +`expectedOutputAmount*`); `SubsidizePost` and `FinalSettlementSubsidy` +each write the full `meta.subsidy` (including `actualOutputAmount*`, +`subsidyAmountInOutputToken*`, `idealSubsidy*`, `targetOutputAmount*`, +`applied`, `subsidyRate`, `partnerId`, `adjustedDifference`, +`adjustedTargetDiscount`). If multiple subsidy phases run, the last one +wins (its full `meta.subsidy` overwrites the prior). Each subsidy phase +computes the same expected output independently — `priceFeedService` +caches the oracle call so the cost is negligible. + +`computeFees(ctx)` (which populates `ctx.fees`) runs before the flow; +`MykoboMint` copies `ctx.fees` into `meta.fees` so the final metadata +includes it. No phase reads `meta.fees` during simulation. + +### 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/`. Do NOT modify any + existing file outside `blocks/` (no edits to `quote/core/*`, `engines/*`, + `phases/*`, `ramp-flow-definitions.ts`, etc.). The POC coexists with the + old code. +- 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 (e.g. + `engines/nabla-swap/base-evm.ts`). + +### Brand values (enum member string values — keep adjacency consistent) + +| Enum | Member | Value | +|------|--------|-------| +| `FiatToken` | `EURC` | `"EUR"` | +| `FiatToken` | `ARS` | `"ARS"` | +| `FiatToken` | `BRL` | `"BRL"` | +| `FiatToken` | `USD` | `"USD"` | +| `FiatToken` | `MXN` | `"MXN"` | +| `FiatToken` | `COP` | `"COP"` | +| `EvmToken` | `EURC` | `"EURC"` | +| `EvmToken` | `USDC` | `"USDC"` | +| `EvmToken` | `MORPHO_VAULT` | `"MORPHO VAULT"` | +| `Networks` | `Base` | `"base"` | +| `Networks` | `Arbitrum` | `"arbitrum"` | + +**Gotcha:** `FiatToken.EURC` is `"EUR"` but `EvmToken.EURC` is `"EURC"` — +different strings, so the brands are distinct types. This is what makes +the fiat→EVM boundary in `MykoboMint` (input +`PhaseIO` → output +`PhaseIO`) type-check: the +output brand genuinely differs from the input brand, and only +`MykoboMint`'s declared signature bridges them. + +### Factory function call forms (TS has no generic const values) + +| Export | Form | Why | +|--------|------|-----| +| `MykoboMint` | plain `const` (no generics) | no runtime variability | +| `NablaSwap(chain, in, out)` | generic **function with runtime args** | needs runtime values for `getOnChainTokenDetails`; brands inferred from args | +| `SquidRouterSwap(from, to, token)` | generic **function with runtime args** | needs runtime values for bridge request; brands inferred from args | +| `MorphoMint()` | generic function with **type args only** | reads `input.chain` at runtime | +| `FundEphemeral()` | type-args only | pure passthrough | +| `DistributeFees()` | type-args only | reads from `ctx.fees` | +| `SubsidizePre()` | type-args only | writes `meta.expectedOutputAmount` | +| `SubsidizePost()` | type-args only | reads from `meta`, writes `meta.subsidy` | +| `FinalSettlementSubsidy()` | type-args only | finalizes subsidy | +| `passthrough()` | type-args only | pure no-op | +| `branch(select, branches)` | generic function | runtime decision point | + +**Brands are always enum member types** (`typeof EvmToken.EURC`, +`typeof Networks.Base`), never plain string literals — keep this consistent +so adjacency matches. + +### Existing files to read before implementing (per phase) + +| Block | Primary port source | +|-------|---------------------| +| `MykoboMint` | `engines/initialize/onramp-mykobo.ts`, `engines/initialize/index.ts` | +| `NablaSwap` | `engines/nabla-swap/base-evm.ts`, `engines/nabla-swap/onramp-mykobo-evm.ts`, `core/nabla.ts` | +| `SquidRouterSwap` | `engines/squidrouter/onramp-base-to-evm.ts`, `engines/squidrouter/index.ts`, `core/squidrouter.ts` | +| `MorphoMint` | `handlers/morpho-deposit-handler.ts`, `handlers/morpho-vault-config.ts`, `engines/initialize/offramp-from-evm-morpho.ts` | +| `SubsidizePre` / `SubsidizePost` / `FinalSettlementSubsidy` | `engines/discount/onramp.ts`, `engines/merge-subsidy/offramp-evm.ts`, `core/types.ts` (subsidy field) | +| `computeFees` | `core/quote-fees.ts`, `core/helpers.ts` | +| `assemblePhaseFlow` | `phases/ramp-flow-definitions.ts` (EUR_ONRAMP_MORPHO, EUR_ONRAMP_BASE_MORPHO) | + +All paths relative to `apps/api/src/api/services/quote/` (or +`apps/api/src/api/services/` for `phases/` and `handlers/`). + +### Known gaps & POC limitations + +All intentional POC scope cuts, not bugs: + +1. **Subsidy simplified.** `SubsidizePre` / `SubsidizePost` / + `FinalSettlementSubsidy` compute subsidy 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. `actualOutputAmountDecimal` = the + actual input amount. Subsidy metadata shape mirrors the existing + `QuoteContext.subsidy` field. +2. **`computeFees` network fee = `"0"`.** `calculateFeeComponents` (reused + from `core/quote-fees.ts`) returns no network component — the Squid + network fee is normally added mid-pipeline by per-engine `compute` + (which needs `ctx.mykoboMint` etc., not available on `PhaseCtx` at + pre-`simulate` time). The adapter sets `network: "0"` in both fee views + rather than re-fetching the Squid fee. +3. **`MorphoMint` hardcodes `"usdc-arbitrum"` vault.** Only one vault is + configured in `morpho-vault-config.ts`; `PhaseCtx` / `CreateQuoteRequest` + carries no vault-id field. A multi-vault resolution (chain→vault-id map + or a `ctx.request` field) is deferred. When the base-vault flow runs, + `previewDeposit` would be read on Base against an Arbitrum vault + address — no Base vault configured yet. +4. **`NablaSwap` SELL deductible = `0`.** The SELL path in the existing + engine reads `ctx.preNabla?.deductibleFeeAmountInSwapCurrency`, which + is not on `PhaseCtx`. Hardcoded to `0` (safe for the POC: + `EUR_ONRAMP_MORPHO` is BUY/onramp, where the deductible is `0` anyway). +5. **`NablaSwap` runtime is Base-only.** `calculateNablaSwapOutputEvm` + hardcodes `Networks.Base` in its `EvmClientManager.readContractWithRetry` + call. The `chain` arg is used only for branding/IO output. NablaSwap is + only ever instantiated with `chain = Networks.Base` in the POC flow. +6. **`PartnerInfo` import source.** Not exported from `@vortexfi/shared`; + `core/types.ts` imports it from `../../core/types` (read-only, no file + outside `core/` was modified). +7. **Smoke test mock leakage.** `mock.module("../../../priceFeed.service", ...)` + does not fully intercept the real module — the real + `PriceFeedService initialized` log still appears. Harmless: + `getOnchainOraclePrice` is wrapped in `try/catch` inside `NablaSwap` and + the subsidy phases, so the flow completes regardless. + +### Appendix: the existing code this refactor targets + +The entanglement this POC begins to unwind: + +- **Quote side** (`apps/api/src/api/services/quote/`): `QuoteService` → + `RouteResolver` → `QuoteOrchestrator` walks `stages: StageKey[]` (10 + keys) calling `engine.execute(ctx)`. 10 strategies in + `routes/strategies/`. Engines in + `engines/{initialize,nabla-swap,squidrouter,fee,discount,finalize,...}/`. +- **Execution side** (`apps/api/src/api/services/phases/` + + `transactions/`): `PhaseProcessor` walks + `state.state.phaseFlow: RampPhase[]` (28 distinct strings). Handlers in + `handlers/*.ts` read `quote.metadata.*` by string key. Route-prep in + `transactions/{onramp,offramp}/routes/*.ts` picks both the `phaseFlow` + array AND builds `unsignedTxs` from the same metadata. +- **The gap:** 10 quote stages ≠ 28 execution phases. The mapping is + implicit, spread across three files. `QuoteContext` is a ~25-field + optional bag. `phaseFlow` is `string[]` — a typo fails only at runtime. + Subsidy logic is smeared across both pipelines. No compile-time + guarantee that a Polygon-only phase doesn't follow a Base swap. + +This POC proves the block model closes that gap, one corridor at a time, +without a big-bang rewrite. + +### Roadmap (next steps, in priority order) + +1. **Port the remaining ~9 corridors.** Each is now small — primitives + exist. Apply the same `FlowBuilder.start(...).pipe(...).build()` + pattern. Candidates by complexity: `EUR_ONRAMP_BASE_DIRECT` (smallest), + `BRL_ONRAMP_*`, `ALFREDPAY_*`, the offramps. Each port replaces one + entry in `ramp-flow-definitions.ts` with a derived array. +2. **Wire the new flow into `QuoteService` behind a flag.** Run real + parity vs the old `onrampMykoboToEvmStrategy` for `EUR_ONRAMP_MORPHO`. + Compare `outputAmount` numerically. +3. **Implement `prepareTxs` and `execute` on `Phase`.** Currently only + `simulate` + `phases` are defined. Adding + `prepareTxs(input, output, ctx): Promise` and + `execute(state, io): Promise` to the `Phase` interface makes + the execution side block-defined too — one source of truth per corridor + instead of three (strategy, `RampPhase[]`, route-prep). +4. **Full numerical parity integration test** vs + `onrampMykoboToEvmStrategy` (real externals, no mocks) as a follow-up + to the smoke test. +5. **Close the POC gaps** in priority order: (a) `computeFees` network + fee (move the Squid fee fetch into a dedicated phase or a post-`simulate` + fixup), (b) `MorphoMint` multi-vault resolution, (c) `NablaSwap` SELL + deductible, (d) `Subsidize*` full partner-DB lookup. +6. **Delete the old strategy + `ramp-flow-definitions.ts` entries** for + each ported corridor once parity is proven. The old `StageKey` / + `RampPhase` strings can coexist as aliases during migration. diff --git a/apps/api/src/api/services/quote/blocks/REPORT.md b/apps/api/src/api/services/quote/blocks/REPORT.md deleted file mode 100644 index 90ec38757..000000000 --- a/apps/api/src/api/services/quote/blocks/REPORT.md +++ /dev/null @@ -1,379 +0,0 @@ -# Block-Based Quote Engine — Implementation Report - -> Companion to [`DESIGN.md`](./DESIGN.md). DESIGN.md is the forward-looking spec -> (signatures, port sources, conventions). This file is the achievement record: -> the journey, the critical technical discoveries, the final state, the gaps, -> and the roadmap. - -## Executive summary - -A typed, composable "block" model for defining Vortex quote flows was prototyped -against the `EUR_ONRAMP_MORPHO` corridor (Mykobo SEPA → EURC on Base → Nabla -EURC→USDC swap → Squid bridge to destination chain → Morpho vault deposit). The -POC lives entirely under `apps/api/src/api/services/quote/blocks/` and coexists -with the old quote engine — **zero edits outside `blocks/`**. - -The headline goal is achieved: **adjacent phases are checked for compatibility -at compile time**. A phase that outputs USDC on Base cannot feed a phase that -expects USDC on Polygon — TypeScript rejects it. A deliberately mis-ordered flow -in the test file is suppressed by `// @ts-expect-error`, proving the guard is -real (an unused directive would fail `bun typecheck`). - -The derived execution `phaseFlow` arrays **deep-equal** the existing -hand-maintained `EUR_ONRAMP_MORPHO` and `EUR_ONRAMP_BASE_MORPHO` arrays in -`ramp-flow-definitions.ts`, so `PhaseProcessor` compatibility is preserved. - -Final verification: `4 pass, 1 skip, 0 fail` (structural parity + compile-time -adjacency proof + an end-to-end `simulate` smoke test that ran, not skipped). - -## M3 implementor agent - -Defined at `.opencode/agents/m3-implementor.md` (41 lines): -- `mode: subagent`, `model: opencode-go/MiniMax-M3` -- Full edit/write/bash/read/glob/grep/list permissions -- Prompt encodes the CLAUDE.md rules (bun, Biome 128/2-space/semis/double-quotes, - no comments, surgical changes, no over-engineering, `FiatToken` 6-value rule) -- Workflow: read referenced files → implement → `bun lint:fix` → `bun typecheck` - → report back - -**Restart opencode to activate it as a `subagent_type`.** In the session that -produced this report, opencode's config is loaded once at startup and not -hot-reloaded, so implementation ran via `general` subagents behaving per the M3 -prompt. After restart, future sessions can spawn real M3 subagents with -`subagent_type: "m3-implementor"`. - -## Execution model (how this was built) - -Coordinator (this agent) defined architecture + specs + reviewed each wave. -Implementor subagents did the typing. Four sequential waves: - -| Wave | Scope | Subagents | Parallel? | -|------|-------|-----------|-----------| -| 1 | Core primitives (7 files under `core/`) | 1 | — | -| 2 | 4 phases (`phases/*.ts`) | 4 | yes | -| 3 | Flow assembly (`flows/eur-onramp-morpho.ts`) | 1 | — | -| 4 | Parity test (`__tests__/*.test.ts`) | 1 | — | - -The coordinator reviewed each wave before launching the next. One wave (Wave 1) -required a coordinator-level fix to `flow.ts` after review — the subagent's -variadic `flow()` compiled but silently bypassed the adjacency check (see -"Critical technical discoveries" below). - -## Critical technical discoveries - -These are the two non-obvious fixes that made the compile-time guarantee -actually work. They are the most valuable knowledge in this POC and are baked -into `core/flow.ts`. - -### Discovery 1: `FlowBuilder` over a variadic `flow()` function - -DESIGN.md originally specified a variadic `flow(name, p1, p2, p3, p4)` builder -with overloaded signatures for 1..6 phases. Wave 1 implemented it. An -empirical probe proved it **did not enforce adjacency**: a deliberately -mis-ordered flow compiled without error. - -Root cause: the variadic implementation signature -`flow(name, ...phases: Phase[])` acts as a fallback when -overload inference fails. Because `Phase`'s `simulate` is declared as a method -shorthand (bivariant under `strictFunctionTypes`), any branded phase is -assignable to `Phase`, so the fallback accepts anything. - -Fix: a `FlowBuilder` class where `.pipe(next)` is a **single method signature** -with no overload fallback to escape to: - -```ts -pipe

>(next: P): FlowBuilder>; -``` - -A brand mismatch at a `.pipe()` call is now a hard type error with no permissive -fallback path. The probe confirmed: the mis-ordered flow now errors (the -`@ts-expect-error` is correctly consumed), the correct flow still compiles. - -### Discovery 2: `OutputOf

` over a second generic parameter - -The first `FlowBuilder` attempt still didn't catch the error. Debug showed the -output brand was **widening to `PhaseIO`** (the constraint bound) during generic -inference, severing the brand chain at the very first `.pipe()`. - -Root cause: `start

, O1>` infers `O1` through the -constraint (`PhaseIO`) rather than from the actual phase type. The literal -brand (`"EURC"`, `"base"`) is lost. - -Fix: infer via a conditional `infer` type on a single generic, which preserves -the literal brand: - -```ts -type OutputOf

= P extends Phase ? O : never; - -static start

>(first: P): FlowBuilder>; -pipe

>(next: P): FlowBuilder>; -``` - -This was verified with a type-equality probe (`Equals>` -returned `true` after the fix, `false` before). - -### Documented relaxation: `branch()` at runtime decision points - -A `branch()` that selects between a `passthrough` (stays on Base) and a -`SquidRouterSwap` (goes Base→Arbitrum) produces a union output brand -`PhaseIO`. TypeScript does not infer this union -automatically from the branches — it picks the first branch's output and -rejects the second. The fix is an **explicit type argument** on `branch`: - -```ts -branch< - PhaseIO, // input - PhaseIO // output (union) ->(select, [passthrough<...>(), SquidRouterSwap(...)]) -``` - -This is the intentional escape hatch: at a runtime branch point the chain brand -becomes a union and the downstream phase (`MorphoMint`) -must be generic over it. The strong compile-time guarantee still holds for the -linear segments (MykoboMint → NablaSwap, and within each branch). - -## Final file inventory - -14 files under `blocks/` (1045 lines incl. DESIGN.md) + 1 agent file (41 lines). - -``` -.opencode/agents/m3-implementor.md 41 - -apps/api/src/api/services/quote/blocks/ - DESIGN.md 361 - core/ - types.ts (PhaseIO, Phase, Flow, PhaseCtx) 38 - io.ts (requestToIO, evmIO) 23 - flow.ts (FlowBuilder, OutputOf) 33 - combinators.ts (branch, passthrough) 43 - subsidy.ts (WithSubsidy) 92 - fees.ts (computeFees) 48 - phase-flow.ts (assemblePhaseFlow) 28 - phases/ - mykobo-mint.ts 43 - nabla-swap.ts 58 - squid-router-swap.ts 64 - morpho-mint.ts 64 - flows/ - eur-onramp-morpho.ts 33 - __tests__/ - eur-onramp-morpho.parity.test.ts 117 -``` - -All `blocks/**/*.ts` files pass `bun typecheck` and `bun lint:fix` cleanly. -Pre-existing repo-wide typecheck/lint errors (4 in -`transactions/offramp/routes/evm-to-mykobo-morpho.ts`, ~193 lint in other -non-test files) are untouched and unrelated. - -## Verification results - -### Test run - -``` -bun test src/api/services/quote/blocks/__tests__/eur-onramp-morpho.parity.test.ts -→ 4 pass, 1 skip, 0 fail, 6 expect() calls, 5 tests across 1 file -``` - -| Part | Test | Result | -|------|------|--------| -| 1 | `derives the core phases from the assembled blocks` | pass | -| 1 | `assembles the cross-chain phaseFlow matching the existing EUR_ONRAMP_MORPHO` | pass | -| 1 | `assembles the base-vault phaseFlow matching the existing EUR_ONRAMP_BASE_MORPHO` | pass | -| 2 | `rejects a mis-ordered flow at compile time` (`.skip` + `@ts-expect-error`) | skip at runtime, **type-checked by tsc** (directive consumed → adjacency proven) | -| 3 | `runs the assembled flow end-to-end with mocked externals and lands on MORPHO_VAULT` | pass (ran, not skipped) | - -The Part 3 smoke test mocks `calculateNablaSwapOutputEvm`, -`calculateEvmBridgeAndNetworkFee`, `MykoboApiService.getInstance`, and -`EvmClientManager.getInstance` (the four real externals). It asserts the final -`PhaseIO` has `amount > 0`, `token === EvmToken.MORPHO_VAULT`, and `ctx.notes` -is non-empty — all three hold. - -### Compile-time adjacency proof - -The `// @ts-expect-error` directive on the mis-ordered flow -(`FlowBuilder.start(MorphoMint<...>()).pipe(MykoboMint)`) is **correctly -consumed** by a real TS2322 ("`Phase,...>` is not -assignable to `Phase`"). There is no "Unused `@ts-expect-error` -directive" error — the guard is real. If someone breaks adjacency in the -future, `bun typecheck` will fail. - -## Parity proof (derived `RampPhase[]` arrays) - -`assemblePhaseFlow` produces arrays that deep-equal the existing -hand-maintained definitions in `phases/ramp-flow-definitions.ts`: - -**Cross-chain** (`isBaseVault: false`) — deep-equals `EUR_ONRAMP_MORPHO` (lines 167-181): -``` -["initial", "mykoboOnrampDeposit", "fundEphemeral", "subsidizePreSwap", - "nablaApprove", "nablaSwap", "distributeFees", "subsidizePostSwap", - "squidRouterSwap", "squidRouterPay", "finalSettlementSubsidy", - "morphoDeposit", "complete"] -``` - -**Base vault** (`isBaseVault: true`) — deep-equals `EUR_ONRAMP_BASE_MORPHO` (lines 189-200): -``` -["initial", "mykoboOnrampDeposit", "fundEphemeral", "subsidizePreSwap", - "nablaApprove", "nablaSwap", "distributeFees", "subsidizePostSwap", - "morphoDeposit", "complete"] -``` - -The block-derived core phases (from `flow.phases`, before bookending) are: -``` -["mykoboOnrampDeposit", "subsidizePreSwap", "nablaApprove", "nablaSwap", - "subsidizePostSwap", "squidRouterSwap", "squidRouterPay", "morphoDeposit"] -``` -`assemblePhaseFlow` prepends `["initial"]`, inserts `["fundEphemeral"]` after -`mykoboOnrampDeposit`, inserts `["distributeFees"]` after `nablaSwap`, inserts -`["finalSettlementSubsidy"]` after `squidRouterPay` (cross-chain only), and -appends `["complete"]`. - -## The assembled flow - -```ts -FlowBuilder.start(MykoboMint) - .pipe(WithSubsidy(NablaSwap(Networks.Base, EvmToken.EURC, EvmToken.USDC), { bookend: "subsidizePostSwap" })) - .pipe( - branch< - PhaseIO, - PhaseIO - >( - ctx => (ctx.request.to === Networks.Base ? 0 : 1), - [passthrough(), SquidRouterSwap(Networks.Base, Networks.Arbitrum, EvmToken.USDC)] - ) - ) - .pipe(MorphoMint()) - .build("EurOnrampMorpho"); -``` - -## Known gaps & POC limitations - -Documented from subagent reports. All are intentional POC scope cuts, not bugs: - -1. **`WithSubsidy` simplified.** Computes subsidy metadata from - `ctx.partner.targetDiscount`/`maxSubsidy` + a single oracle price lookup. Does - NOT port: the DB partner lookup (`resolveDiscountPartner`), the per-engine - SquidRouter conversion-rate adjustment, or post-swap fee deduction from the - "actual" amount. `actualOutputAmountDecimal` = inner output amount directly. - Subsidy metadata shape mirrors the existing `QuoteContext.subsidy` field. -2. **`computeFees` network fee = `"0"`.** `calculateFeeComponents` (reused from - `core/quote-fees.ts`) returns no network component — the Squid network fee is - normally added mid-pipeline by per-engine `compute` (which needs `ctx.mykoboMint` - etc., not available on `PhaseCtx` at pre-`simulate` time). The adapter sets - `network: "0"` in both fee views rather than re-fetching the Squid fee. -3. **`MorphoMint` hardcodes `"usdc-arbitrum"` vault.** Only one vault is - configured in `morpho-vault-config.ts`; `PhaseCtx`/`CreateQuoteRequest` - carries no vault-id field. A multi-vault resolution (chain→vault-id map or a - `ctx.request` field) is deferred. When `branch` selects the `passthrough` - path (`input.chain === Networks.Base`), `previewDeposit` would be read on - Base against an Arbitrum vault address — no Base vault configured yet. -4. **`NablaSwap` SELL deductible = `0`.** The SELL path in the existing engine - reads `ctx.preNabla?.deductibleFeeAmountInSwapCurrency`, which is not on - `PhaseCtx`. Hardcoded to `0` (safe for the POC: `EUR_ONRAMP_MORPHO` is - BUY/onramp, where the deductible is `0` anyway — onramp deducts after swap). -5. **`NablaSwap` runtime is Base-only.** `calculateNablaSwapOutputEvm` hardcodes - `Networks.Base` in its `EvmClientManager.readContractWithRetry` call. The - `chain` arg is used only for branding/IO output. NablaSwap is only ever - instantiated with `chain = Networks.Base` in the POC flow. -6. **`QuoteTicketMetadata` import source.** `PartnerInfo` is not exported from - `@vortexfi/shared`; `core/types.ts` imports it from `../../core/types` - (read-only, no file outside `core/` was modified). -7. **Smoke test mock leakage.** `mock.module("../../../priceFeed.service", ...)` - did not fully intercept the real module — the real `PriceFeedService - initialized` log still appears. Harmless: `getOnchainOraclePrice` is wrapped - in `try/catch` inside both `NablaSwap` and `WithSubsidy`, so the flow - completes regardless. The 4 critical mocks (nabla, squidrouter, Mykobo, - EvmClientManager) all applied. - -## Conventions discovered (for future corridor ports) - -### Brand values (enum member string values — keep adjacency consistent) - -| Enum | Member | Value | -|------|--------|-------| -| `FiatToken` | `EURC` | `"EUR"` | -| `FiatToken` | `ARS` | `"ARS"` | -| `FiatToken` | `BRL` | `"BRL"` | -| `FiatToken` | `USD` | `"USD"` | -| `FiatToken` | `MXN` | `"MXN"` | -| `FiatToken` | `COP` | `"COP"` | -| `EvmToken` | `EURC` | `"EURC"` | -| `EvmToken` | `USDC` | `"USDC"` | -| `EvmToken` | `MORPHO_VAULT` | `"MORPHO VAULT"` | -| `Networks` | `Base` | `"base"` | -| `Networks` | `Arbitrum` | `"arbitrum"` | - -**Gotcha:** `FiatToken.EURC` is `"EUR"` but `EvmToken.EURC` is `"EURC"` — -different strings, so the brands are distinct types. This is what makes the -fiat→EVM boundary in `MykoboMint` (input `PhaseIO` -→ output `PhaseIO`) type-check: the -output brand genuinely differs from the input brand, and only `MykoboMint`'s -declared signature bridges them. - -### Factory function call forms (TS has no generic const values) - -| Export | Form | Why | -|--------|------|-----| -| `MykoboMint` | plain `const` (no generics) | no runtime variability | -| `NablaSwap(chain, inToken, outToken)` | generic **function with runtime args** | needs runtime values for `getOnChainTokenDetails`; brands inferred from args | -| `SquidRouterSwap(fromChain, toChain, token)` | generic **function with runtime args** | needs runtime values for bridge request; brands inferred from args | -| `MorphoMint()` | generic function with **type args only** | chain is a union at the call site; reads `input.chain` at runtime | -| `passthrough()` | generic function, type args only | pure no-op | -| `WithSubsidy(inner, opts)` | generic function wrapping a `Phase` | decorator | -| `branch(select, branches)` | generic function, often with **explicit type args** | needed when branches produce a union output (see "Documented relaxation" above) | - -### Biome rule that shaped the code - -`biome.json` sets `suspicious/noExplicitAny: "error"`, and its override that -sets it to `"off"` has no `includes` field, so Biome v2 does not apply the -override — `any` is genuinely an error everywhere. This is why `core/flow.ts` -uses `PhaseIO` (the base interface) instead of `any` for the loose constraint -slots, and why `Phase`'s `simulate` is a method shorthand (bivariant under -`strictFunctionTypes`, so branded phases remain assignable to `Phase`). - -## Roadmap (next steps, in priority order) - -1. **Port the remaining ~9 corridors.** Each is now small — primitives exist. - Apply the same `FlowBuilder.start(...).pipe(...).build()` pattern. Candidates - by complexity: `EUR_ONRAMP_BASE_DIRECT` (smallest), `BRL_ONRAMP_*`, - `ALFREDPAY_*`, the offramps. Each port replaces one entry in - `ramp-flow-definitions.ts` with a derived array. -2. **Wire the new flow into `QuoteService` behind a flag.** Run real parity - vs the old `onrampMykoboToEvmStrategy` for `EUR_ONRAMP_MORPHO`. Compare - `outputAmount` numerically. -3. **Implement `prepareTxs` and `execute` on `Phase`.** Currently only - `simulate` + `phases` are defined. Adding `prepareTxs(input, output, ctx): - Promise` and `execute(state, io): Promise` to the - `Phase` interface makes the execution side block-defined too — one source of - truth per corridor instead of three (strategy, `RampPhase[]`, route-prep). -4. **Full numerical parity integration test** vs `onrampMykoboToEvmStrategy` - (real externals, no mocks) as a follow-up to the smoke test. -5. **Close the POC gaps** in priority order: (a) `computeFees` network fee - (move the Squid fee fetch into a dedicated phase or a post-`simulate` fixup), - (b) `MorphoMint` multi-vault resolution, (c) `NablaSwap` SELL deductible, - (d) `WithSubsidy` full partner-DB lookup. -6. **Delete the old strategy + `ramp-flow-definitions.ts` entries** for each - ported corridor once parity is proven. The old `StageKey`/`RampPhase` strings - can coexist as aliases during migration. - -## Appendix: the existing code this refactor targets (for context) - -The entanglement this POC begins to unwind (full detail in the original -exploration): - -- **Quote side** (`apps/api/src/api/services/quote/`): `QuoteService` → - `RouteResolver` → `QuoteOrchestrator` walks `stages: StageKey[]` (10 keys) - calling `engine.execute(ctx)`. 10 strategies in `routes/strategies/`. Engines - in `engines/{initialize,nabla-swap,squidrouter,fee,discount,finalize,...}/`. -- **Execution side** (`apps/api/src/api/services/phases/` + - `transactions/`): `PhaseProcessor` walks `state.state.phaseFlow: RampPhase[]` - (28 distinct strings). Handlers in `handlers/*.ts` read `quote.metadata.*` by - string key. Route-prep in `transactions/{onramp,offramp}/routes/*.ts` picks - both the `phaseFlow` array AND builds `unsignedTxs` from the same metadata. -- **The gap:** 10 quote stages ≠ 28 execution phases. The mapping is implicit, - spread across three files. `QuoteContext` is a ~25-field optional bag. - `phaseFlow` is `string[]` — a typo fails only at runtime. Subsidy logic is - smeared across both pipelines. No compile-time guarantee that a Polygon-only - phase doesn't follow a Base swap. - -This POC proves the block model closes that gap, one corridor at a time, without -a big-bang rewrite. diff --git a/apps/api/src/api/services/quote/blocks/__tests__/eur-onramp-morpho.parity.test.ts b/apps/api/src/api/services/quote/blocks/__tests__/eur-onramp-morpho.parity.test.ts index ab4191853..b7524c055 100644 --- a/apps/api/src/api/services/quote/blocks/__tests__/eur-onramp-morpho.parity.test.ts +++ b/apps/api/src/api/services/quote/blocks/__tests__/eur-onramp-morpho.parity.test.ts @@ -37,31 +37,56 @@ mock.module("../../../priceFeed.service", () => ({ import { EUR_ONRAMP_BASE_MORPHO, EUR_ONRAMP_MORPHO } 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 { MorphoMint } from "../phases/morpho-mint"; import { MykoboMint } from "../phases/mykobo-mint"; -import { eurOnrampMorphoBasePhaseFlow, eurOnrampMorphoFlow, eurOnrampMorphoPhaseFlow } from "../flows/eur-onramp-morpho"; +import { + eurOnrampBaseMorphoFlow, + eurOnrampBaseMorphoPhaseFlow, + eurOnrampMorphoFlow, + eurOnrampMorphoPhaseFlow +} from "../flows/eur-onramp-morpho"; describe("EUR_ONRAMP_MORPHO block flow — structure", () => { - it("derives the core phases from the assembled blocks", () => { + it("derives the core phases from the cross-chain assembled blocks", () => { expect(eurOnrampMorphoFlow.phases).toEqual([ "mykoboOnrampDeposit", + "fundEphemeral", "subsidizePreSwap", "nablaApprove", "nablaSwap", + "distributeFees", "subsidizePostSwap", "squidRouterSwap", "squidRouterPay", + "finalSettlementSubsidy", + "morphoDeposit" + ]); + }); + + it("derives the core phases from the base-vault assembled blocks", () => { + expect(eurOnrampBaseMorphoFlow.phases).toEqual([ + "mykoboOnrampDeposit", + "fundEphemeral", + "subsidizePreSwap", + "nablaApprove", + "nablaSwap", + "distributeFees", + "subsidizePostSwap", "morphoDeposit" ]); }); it("assembles the cross-chain phaseFlow matching the existing EUR_ONRAMP_MORPHO", () => { expect(eurOnrampMorphoPhaseFlow).toEqual(EUR_ONRAMP_MORPHO); + expect(assemblePhaseFlow(eurOnrampMorphoFlow)).toEqual(EUR_ONRAMP_MORPHO); }); it("assembles the base-vault phaseFlow matching the existing EUR_ONRAMP_BASE_MORPHO", () => { - expect(eurOnrampMorphoBasePhaseFlow).toEqual(EUR_ONRAMP_BASE_MORPHO); + expect(eurOnrampBaseMorphoPhaseFlow).toEqual(EUR_ONRAMP_BASE_MORPHO); + expect(assemblePhaseFlow(eurOnrampBaseMorphoFlow)).toEqual(EUR_ONRAMP_BASE_MORPHO); }); }); @@ -74,44 +99,143 @@ describe("EUR_ONRAMP_MORPHO block flow — compile-time adjacency", () => { }); }); -describe("EUR_ONRAMP_MORPHO block flow — simulate smoke", () => { - it("runs the assembled flow end-to-end with mocked externals and lands on MORPHO_VAULT", async () => { - MykoboApiService.getInstance = mock(() => ({ - defaultDepositFee: mock(async () => ({ total: "0.5" })) - })) as unknown as typeof MykoboApiService.getInstance; - - EvmClientManager.getInstance = mock(() => ({ - getClient: mock(() => ({ - readContract: mock(async () => 1000000000000000000n) - })) - })) as unknown as typeof EvmClientManager.getInstance; - - const notes: string[] = []; - const ctx: PhaseCtx = { - addNote: (note: string) => { - notes.push(note); - }, - fees: { - usd: { anchor: "0.1", network: "0.1", partnerMarkup: "0", total: "0.3", vortex: "0.1" } - }, - now: new Date(), - notes, - partner: null, - request: { - from: EPaymentMethod.SEPA, - inputAmount: "100", - inputCurrency: FiatToken.EURC, - network: Networks.Base, - outputCurrency: EvmToken.MORPHO_VAULT, - rampType: RampDirection.BUY, - to: Networks.Arbitrum - } - }; +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.SEPA, + inputAmount: "100", + inputCurrency: FiatToken.EURC, + network: Networks.Base, + outputCurrency: EvmToken.MORPHO_VAULT, + rampType: RampDirection.BUY, + to: Networks.Arbitrum + } + }; +} + +async function runFlow(flow: typeof eurOnrampMorphoFlow, to: Networks.Base | Networks.Arbitrum): Promise { + MykoboApiService.getInstance = mock(() => ({ + defaultDepositFee: mock(async () => ({ total: "0.5" })) + })) as unknown as typeof MykoboApiService.getInstance; + + EvmClientManager.getInstance = mock(() => ({ + getClient: mock(() => ({ + readContract: mock(async () => 1000000000000000000n) + })) + })) as unknown as typeof EvmClientManager.getInstance; - const output: PhaseIO = await eurOnrampMorphoFlow.simulate(ctx); + const ctx: PhaseCtx = { ...buildCtx(), request: { ...buildCtx().request, to } }; + return flow.simulate(ctx); +} + +describe("EUR_ONRAMP_MORPHO block flow — simulate smoke", () => { + it("runs the cross-chain flow end-to-end and lands on MORPHO_VAULT", async () => { + const output: PhaseIO = await runFlow(eurOnrampMorphoFlow, Networks.Arbitrum); + expect(output.amount.gt(0)).toBe(true); + expect(output.token).toBe(EvmToken.MORPHO_VAULT); + }); + it("runs the base-vault flow end-to-end and lands on MORPHO_VAULT", async () => { + const output: PhaseIO = await runFlow(eurOnrampBaseMorphoFlow, Networks.Base); expect(output.amount.gt(0)).toBe(true); expect(output.token).toBe(EvmToken.MORPHO_VAULT); - expect(ctx.notes.length).toBeGreaterThan(0); + }); +}); + +describe("EUR_ONRAMP_MORPHO block flow — metadata parity", () => { + it("accumulates mykoboMint, nablaSwapEvm, fees, subsidy, morphoDeposit in the cross-chain flow meta", async () => { + const output: PhaseIO = await runFlow(eurOnrampMorphoFlow, Networks.Arbitrum); + const { meta } = output; + + expect(meta.fees).toBeDefined(); + expect((meta.fees as { usd: { total: string } }).usd.total).toBe("0.3"); + + const mykobo = meta.mykoboMint as { + currency: FiatToken; + fee: Big; + inputAmountDecimal: Big; + inputAmountRaw: string; + outputAmountDecimal: Big; + outputAmountRaw: string; + }; + expect(mykobo).toBeDefined(); + expect(mykobo.currency).toBe(FiatToken.EURC); + expect(mykobo.fee.toFixed()).toBe("0.5"); + expect(mykobo.inputAmountRaw).toBe("100"); + expect(mykobo.outputAmountRaw).toBe("99500000"); + + const nabla = meta.nablaSwapEvm as { + inputAmountForSwapRaw: string; + inputDecimals: number; + inputToken: string; + outputAmountRaw: string; + outputDecimals: number; + outputToken: string; + effectiveExchangeRate?: string; + }; + expect(nabla).toBeDefined(); + expect(nabla.inputAmountForSwapRaw).toBe("99500000"); + expect(nabla.outputAmountRaw).toBe("105000000"); + expect(nabla.effectiveExchangeRate).toBe("1.05"); + + const evmToEvm = meta.evmToEvm as { + effectiveExchangeRate: string; + fromNetwork: string; + fromToken: string; + inputAmountRaw: string; + outputAmountRaw: string; + toNetwork: string; + toToken: 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("95000000"); + expect(evmToEvm.networkFeeUSD).toBe("0.1"); + + const subsidy = meta.subsidy as SubsidyMeta; + expect(subsidy).toBeDefined(); + expect(subsidy.applied).toBe(false); + expect(subsidy.expectedOutputAmountDecimal.toFixed()).toBe("100"); + expect(subsidy.actualOutputAmountDecimal.gt(0)).toBe(true); + expect(subsidy.partnerId).toBeNull(); + expect(subsidy.adjustedDifference).toBeDefined(); + expect(subsidy.adjustedTargetDiscount).toBeDefined(); + + const morpho = meta.morphoDeposit as { + depositAssetAddress: string; + expectedUsdcRaw: string; + sharesAmountRaw: string; + vaultAddress: string; + }; + expect(morpho).toBeDefined(); + expect(morpho.vaultAddress).toBeDefined(); + expect(morpho.depositAssetAddress).toBeDefined(); + expect(morpho.expectedUsdcRaw).toBe("95000000"); + expect(morpho.sharesAmountRaw).toBe("1000000000000000000"); + }); + + it("omits evmToEvm in the base-vault flow meta but keeps all other keys", async () => { + const output: PhaseIO = await runFlow(eurOnrampBaseMorphoFlow, Networks.Base); + const { meta } = output; + + expect(meta.mykoboMint).toBeDefined(); + expect(meta.nablaSwapEvm).toBeDefined(); + expect(meta.fees).toBeDefined(); + expect(meta.subsidy).toBeDefined(); + expect(meta.morphoDeposit).toBeDefined(); + expect(meta.evmToEvm).toBeUndefined(); }); }); diff --git a/apps/api/src/api/services/quote/blocks/core/flow.ts b/apps/api/src/api/services/quote/blocks/core/flow.ts index a48c7fe6d..dc68c1a2c 100644 --- a/apps/api/src/api/services/quote/blocks/core/flow.ts +++ b/apps/api/src/api/services/quote/blocks/core/flow.ts @@ -1,4 +1,5 @@ import type { RampPhase } from "@vortexfi/shared"; +import { computeFees } from "./fees"; import { requestToIO } from "./io"; import type { Flow, Phase, PhaseCtx, PhaseIO } from "./types"; @@ -22,6 +23,7 @@ export class FlowBuilder { 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, ctx); 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 index 704cdd6d0..fa715ccf1 100644 --- a/apps/api/src/api/services/quote/blocks/core/phase-flow.ts +++ b/apps/api/src/api/services/quote/blocks/core/phase-flow.ts @@ -1,25 +1,6 @@ -import type { RampDirection, RampPhase } from "@vortexfi/shared"; +import type { RampPhase } from "@vortexfi/shared"; import type { Flow } from "./types"; -export function assemblePhaseFlow(flow: Flow, opts: { direction: RampDirection; isBaseVault: boolean }): RampPhase[] { - const { isBaseVault } = opts; - const corePhases: RampPhase[] = isBaseVault - ? flow.phases.filter(phase => phase !== "squidRouterSwap" && phase !== "squidRouterPay") - : [...flow.phases]; - - const result: RampPhase[] = ["initial"]; - for (const phase of corePhases) { - result.push(phase); - if (phase === "mykoboOnrampDeposit") { - result.push("fundEphemeral"); - } - if (phase === "nablaSwap") { - result.push("distributeFees"); - } - if (phase === "squidRouterPay" && !isBaseVault) { - result.push("finalSettlementSubsidy"); - } - } - result.push("complete"); - return result; +export function assemblePhaseFlow(flow: Flow): RampPhase[] { + return ["initial", ...flow.phases, "complete"]; } diff --git a/apps/api/src/api/services/quote/blocks/flows/eur-onramp-morpho.ts b/apps/api/src/api/services/quote/blocks/flows/eur-onramp-morpho.ts index ce55ec4b2..95d1f2034 100644 --- a/apps/api/src/api/services/quote/blocks/flows/eur-onramp-morpho.ts +++ b/apps/api/src/api/services/quote/blocks/flows/eur-onramp-morpho.ts @@ -1,33 +1,36 @@ -import { EvmToken, Networks, RampDirection } from "@vortexfi/shared"; -import { branch, passthrough } from "../core/combinators"; +import { EvmToken, Networks } from "@vortexfi/shared"; import { FlowBuilder } from "../core/flow"; import { assemblePhaseFlow } from "../core/phase-flow"; -import { WithSubsidy } from "../core/subsidy"; -import type { Flow, PhaseIO } from "../core/types"; +import type { Flow } from "../core/types"; +import { DistributeFees } from "../phases/distribute-fees"; +import { FinalSettlementSubsidy } from "../phases/final-settlement-subsidy"; +import { FundEphemeral } from "../phases/fund-ephemeral"; import { MorphoMint } from "../phases/morpho-mint"; import { MykoboMint } from "../phases/mykobo-mint"; 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"; export const eurOnrampMorphoFlow: Flow = FlowBuilder.start(MykoboMint) - .pipe(WithSubsidy(NablaSwap(Networks.Base, EvmToken.EURC, EvmToken.USDC), { bookend: "subsidizePostSwap" })) - .pipe( - branch< - PhaseIO, - PhaseIO - >( - ctx => (ctx.request.to === Networks.Base ? 0 : 1), - [passthrough(), SquidRouterSwap(Networks.Base, Networks.Arbitrum, EvmToken.USDC)] - ) - ) - .pipe(MorphoMint()) + .pipe(FundEphemeral()) + .pipe(SubsidizePre()) + .pipe(NablaSwap(Networks.Base, EvmToken.EURC, EvmToken.USDC)) + .pipe(DistributeFees()) + .pipe(SubsidizePost()) + .pipe(SquidRouterSwap(Networks.Base, Networks.Arbitrum, EvmToken.USDC)) + .pipe(FinalSettlementSubsidy()) + .pipe(MorphoMint()) .build("EurOnrampMorpho"); -export const eurOnrampMorphoPhaseFlow = assemblePhaseFlow(eurOnrampMorphoFlow, { - direction: RampDirection.BUY, - isBaseVault: false -}); -export const eurOnrampMorphoBasePhaseFlow = assemblePhaseFlow(eurOnrampMorphoFlow, { - direction: RampDirection.BUY, - isBaseVault: true -}); +export const eurOnrampBaseMorphoFlow: Flow = FlowBuilder.start(MykoboMint) + .pipe(FundEphemeral()) + .pipe(SubsidizePre()) + .pipe(NablaSwap(Networks.Base, EvmToken.EURC, EvmToken.USDC)) + .pipe(DistributeFees()) + .pipe(SubsidizePost()) + .pipe(MorphoMint()) + .build("EurOnrampBaseMorpho"); + +export const eurOnrampMorphoPhaseFlow = assemblePhaseFlow(eurOnrampMorphoFlow); +export const eurOnrampBaseMorphoPhaseFlow = assemblePhaseFlow(eurOnrampBaseMorphoFlow); 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..edbe968ec --- /dev/null +++ b/apps/api/src/api/services/quote/blocks/phases/distribute-fees.ts @@ -0,0 +1,34 @@ +import { multiplyByPowerOfTen } from "@vortexfi/shared"; +import Big from "big.js"; +import { evmIO } from "../core/io"; +import type { ChainBrand, Phase, PhaseCtx, PhaseIO, TokenBrand } from "../core/types"; + +const SIMPLIFIED_TOKEN_DECIMALS = 6; + +export function DistributeFees(): Phase< + PhaseIO, + PhaseIO +> { + return { + 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..3d179cfe6 --- /dev/null +++ b/apps/api/src/api/services/quote/blocks/phases/final-settlement-subsidy.ts @@ -0,0 +1,18 @@ +import type { ChainBrand, Phase, PhaseCtx, PhaseIO, TokenBrand } from "../core/types"; +import { buildFullSubsidy, computeExpectedOutput } from "./subsidize-pre"; + +export function FinalSettlementSubsidy(): Phase< + PhaseIO, + PhaseIO +> { + return { + 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..fa0836e92 --- /dev/null +++ b/apps/api/src/api/services/quote/blocks/phases/fund-ephemeral.ts @@ -0,0 +1,15 @@ +import type { ChainBrand, Phase, PhaseCtx, PhaseIO, TokenBrand } from "../core/types"; + +export function FundEphemeral(): Phase< + PhaseIO, + PhaseIO +> { + return { + 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/morpho-mint.ts b/apps/api/src/api/services/quote/blocks/phases/morpho-mint.ts index 80fae523c..674ccc1ef 100644 --- a/apps/api/src/api/services/quote/blocks/phases/morpho-mint.ts +++ b/apps/api/src/api/services/quote/blocks/phases/morpho-mint.ts @@ -55,9 +55,13 @@ export function MorphoMint(): Phase< ctx.addNote(`MorphoMint: ${input.amount} USDC -> ${sharesDecimal.toFixed()} vault shares on ${network}`); return evmIO(EvmToken.MORPHO_VAULT, input.chain as Chain, sharesDecimal, sharesRaw.toString(), { - depositAssetAddress: vault.depositAssetAddress, - expectedUsdcRaw: input.amountRaw, - vaultAddress: vault.vaultAddress + ...input.meta, + morphoDeposit: { + depositAssetAddress: vault.depositAssetAddress, + expectedUsdcRaw: input.amountRaw, + sharesAmountRaw: sharesRaw.toString(), + vaultAddress: vault.vaultAddress + } }); } }; 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 index 713522c8a..b55f64bb1 100644 --- a/apps/api/src/api/services/quote/blocks/phases/mykobo-mint.ts +++ b/apps/api/src/api/services/quote/blocks/phases/mykobo-mint.ts @@ -8,7 +8,7 @@ import { } from "@vortexfi/shared"; import Big from "big.js"; import { evmIO } from "../core/io"; -import type { Phase, PhaseIO } from "../core/types"; +import type { Phase, PhaseCtx, PhaseIO } from "../core/types"; export const MykoboMint: Phase, PhaseIO> = { name: "MykoboMint", @@ -36,8 +36,16 @@ export const MykoboMint: Phase, PhaseIO(): Phase< + PhaseIO, + PhaseIO +> { + return { + 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); + ctx.addNote(`SubsidizePost: applied=${subsidy.applied}, amount=${subsidy.subsidyAmountInOutputTokenDecimal.toFixed()}`); + return { ...input, meta: { ...input.meta, subsidy } }; + } + }; +} diff --git a/apps/api/src/api/services/quote/blocks/core/subsidy.ts b/apps/api/src/api/services/quote/blocks/phases/subsidize-pre.ts similarity index 54% rename from apps/api/src/api/services/quote/blocks/core/subsidy.ts rename to apps/api/src/api/services/quote/blocks/phases/subsidize-pre.ts index c22836c6d..5c1fd3ea6 100644 --- a/apps/api/src/api/services/quote/blocks/core/subsidy.ts +++ b/apps/api/src/api/services/quote/blocks/phases/subsidize-pre.ts @@ -1,9 +1,9 @@ import { RampDirection } from "@vortexfi/shared"; -import Big from "big.js"; +import { Big } from "big.js"; import { priceFeedService } from "../../../priceFeed.service"; -import type { Phase, PhaseCtx, PhaseIO } from "./types"; +import type { ChainBrand, Phase, PhaseCtx, PhaseIO, TokenBrand } from "../core/types"; -interface SubsidyMeta { +export interface SubsidyMeta { applied: boolean; subsidyRate: Big; partnerId: string | null; @@ -17,40 +17,21 @@ interface SubsidyMeta { idealSubsidyAmountInOutputTokenRaw: string; targetOutputAmountDecimal: Big; targetOutputAmountRaw: string; + adjustedDifference: Big; + adjustedTargetDiscount: Big; } -export function WithSubsidy

, I extends PhaseIO, O extends PhaseIO>( - inner: P, - opts: { bookend: "subsidizePostSwap" | "finalSettlementSubsidy" } -): Phase { - return { - name: `WithSubsidy(${inner.name})`, - phases: ["subsidizePreSwap", ...inner.phases, opts.bookend], - async simulate(input: I, ctx: PhaseCtx): Promise { - const output = await inner.simulate(input, ctx); - const subsidy = await computeSubsidyMeta(output, ctx); - return { ...output, meta: { ...output.meta, subsidy } } as O; - } - }; -} - -async function computeSubsidyMeta(output: PhaseIO, ctx: PhaseCtx): Promise { +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 isOfframp = ctx.request.rampType === RampDirection.SELL; - let expectedOutputAmountDecimal = output.amount; - try { - const oracle = await priceFeedService.getOnchainOraclePrice(ctx.request.inputCurrency); - const effectivePrice = isOfframp ? new Big(1).div(oracle.price) : oracle.price; - const discountedRate = effectivePrice.mul(new Big(1).plus(targetDiscount)); - expectedOutputAmountDecimal = new Big(ctx.request.inputAmount).mul(discountedRate); - } catch (error) { - ctx.addNote(`WithSubsidy: oracle price unavailable, falling back to actual output as expected. Error: ${error}`); - } - - const actualOutputAmountDecimal = output.amount; const idealSubsidyAmountInOutputTokenDecimal = actualOutputAmountDecimal.gte(expectedOutputAmountDecimal) ? new Big(0) : expectedOutputAmountDecimal.minus(actualOutputAmountDecimal); @@ -66,14 +47,18 @@ async function computeSubsidyMeta(output: PhaseIO, ctx: PhaseCtx): Promise - output.amount.gt(0) ? new Big(output.amountRaw).times(decimal).div(output.amount).toFixed(0, 0) : "0"; + actualOutputAmountDecimal.gt(0) + ? new Big(actualOutputAmountRaw).times(decimal).div(actualOutputAmountDecimal).toFixed(0, 0) + : "0"; return { actualOutputAmountDecimal, - actualOutputAmountRaw: output.amountRaw, + actualOutputAmountRaw, + adjustedDifference: new Big(0), + adjustedTargetDiscount: new Big(0), applied: subsidyAmountInOutputTokenDecimal.gt(0), expectedOutputAmountDecimal, - expectedOutputAmountRaw: toRaw(expectedOutputAmountDecimal), + expectedOutputAmountRaw, idealSubsidyAmountInOutputTokenDecimal, idealSubsidyAmountInOutputTokenRaw: toRaw(idealSubsidyAmountInOutputTokenDecimal), partnerId: partner?.id ?? null, @@ -92,3 +77,43 @@ function capSubsidy(idealSubsidy: Big, expectedOutput: Big, maxSubsidy: number): } 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 }; +} + +export function SubsidizePre(): Phase< + PhaseIO, + PhaseIO +> { + return { + 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 + } + } + }; + } + }; +} From 6673c75772bd6326a6f9308995ca0c05c0abb0f5 Mon Sep 17 00:00:00 2001 From: Gianfranco Date: Thu, 18 Jun 2026 15:14:09 -0300 Subject: [PATCH 23/26] update simulation state when subsidizing --- .../src/api/services/quote/blocks/core/fees.ts | 2 ++ .../quote/blocks/phases/squid-router-swap.ts | 16 ++++------------ .../quote/blocks/phases/subsidize-post.ts | 14 ++++++++++++-- 3 files changed, 18 insertions(+), 14 deletions(-) diff --git a/apps/api/src/api/services/quote/blocks/core/fees.ts b/apps/api/src/api/services/quote/blocks/core/fees.ts index c8672a494..444e6e178 100644 --- a/apps/api/src/api/services/quote/blocks/core/fees.ts +++ b/apps/api/src/api/services/quote/blocks/core/fees.ts @@ -5,6 +5,8 @@ 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, 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 index 51361aa1c..2b2c2cc3a 100644 --- 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 @@ -1,4 +1,4 @@ -import { EvmTokenDetails, getOnChainTokenDetails, multiplyByPowerOfTen, Networks, OnChainToken } from "@vortexfi/shared"; +import { EvmTokenDetails, getOnChainTokenDetails, Networks, OnChainToken } from "@vortexfi/shared"; import { Big } from "big.js"; import { calculateEvmBridgeAndNetworkFee, getBridgeTargetTokenDetails } from "../../core/squidrouter"; import { evmIO } from "../core/io"; @@ -17,22 +17,14 @@ export function SquidRouterSwap, ctx: PhaseCtx): Promise> { const expected = await computeExpectedOutput(ctx); const subsidy = buildFullSubsidy(input.amount, input.amountRaw, expected.decimal, expected.raw, ctx); - ctx.addNote(`SubsidizePost: applied=${subsidy.applied}, amount=${subsidy.subsidyAmountInOutputTokenDecimal.toFixed()}`); - return { ...input, meta: { ...input.meta, subsidy } }; + 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 } + }; } }; } From 1d3cbacccbf0ce06baebd63f834667e5ea8fd192 Mon Sep 17 00:00:00 2001 From: Gianfranco Date: Thu, 18 Jun 2026 15:30:08 -0300 Subject: [PATCH 24/26] remove morpho changes --- .../handlers/final-settlement-subsidy.ts | 15 +- .../phases/handlers/fund-ephemeral-handler.ts | 15 - .../phases/handlers/morpho-deposit-handler.ts | 245 ---------- .../handlers/morpho-permit-execute-handler.ts | 322 ------------- .../phases/handlers/morpho-redeem-handler.ts | 195 -------- .../phases/handlers/morpho-vault-config.ts | 27 -- .../squid-router-pay-phase-handler.ts | 4 +- .../handlers/squid-router-phase-handler.ts | 3 +- .../api/services/phases/meta-state-types.ts | 17 - .../services/phases/ramp-flow-definitions.ts | 88 ---- .../api/services/phases/register-handlers.ts | 6 - .../api/src/api/services/priceFeed.service.ts | 3 +- .../api/services/quote/core/squidrouter.ts | 8 +- apps/api/src/api/services/quote/core/types.ts | 7 - .../services/quote/engines/discount/onramp.ts | 4 +- .../quote/engines/fee/onramp-brl-to-evm.ts | 5 +- .../quote/engines/fee/onramp-mykobo-to-evm.ts | 5 +- .../initialize/offramp-from-evm-morpho.ts | 101 ---- .../engines/squidrouter/onramp-base-to-evm.ts | 7 +- apps/api/src/api/services/quote/index.ts | 6 +- .../services/quote/routes/route-resolver.ts | 5 - .../offramp-to-sepa-evm-morpho.strategy.ts | 30 -- .../api/src/api/services/ramp/ramp.service.ts | 53 +- .../services/transactions/offramp/index.ts | 6 - .../offramp/routes/evm-to-alfredpay.ts | 2 +- .../offramp/routes/evm-to-mykobo-morpho.ts | 453 ------------------ .../onramp/routes/mykobo-to-evm-morpho.ts | 260 ---------- .../api/services/transactions/validation.ts | 4 - apps/frontend/src/assets/coins/MORPHO.svg | 19 - apps/frontend/src/hooks/useGetAssetIcon.tsx | 2 - apps/frontend/src/pages/progress/index.tsx | 6 - .../frontend/src/pages/progress/phaseFlows.ts | 37 +- .../src/pages/progress/phaseMessages.ts | 4 - .../services/balances/evmBalanceFetcher.ts | 26 +- .../services/balances/morphoBalanceFetcher.ts | 34 -- apps/frontend/src/translations/en.json | 4 - apps/frontend/src/translations/pt.json | 2 - .../shared/src/endpoints/ramp.endpoints.ts | 4 - packages/shared/src/helpers/signUnsigned.ts | 4 +- packages/shared/src/tokens/evm/config.ts | 10 - packages/shared/src/tokens/types/evm.ts | 3 +- 41 files changed, 41 insertions(+), 2010 deletions(-) delete mode 100644 apps/api/src/api/services/phases/handlers/morpho-deposit-handler.ts delete mode 100644 apps/api/src/api/services/phases/handlers/morpho-permit-execute-handler.ts delete mode 100644 apps/api/src/api/services/phases/handlers/morpho-redeem-handler.ts delete mode 100644 apps/api/src/api/services/phases/handlers/morpho-vault-config.ts delete mode 100644 apps/api/src/api/services/quote/engines/initialize/offramp-from-evm-morpho.ts delete mode 100644 apps/api/src/api/services/quote/routes/strategies/offramp-to-sepa-evm-morpho.strategy.ts delete mode 100644 apps/api/src/api/services/transactions/offramp/routes/evm-to-mykobo-morpho.ts delete mode 100644 apps/api/src/api/services/transactions/onramp/routes/mykobo-to-evm-morpho.ts delete mode 100644 apps/frontend/src/assets/coins/MORPHO.svg delete mode 100644 apps/frontend/src/services/balances/morphoBalanceFetcher.ts 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 6aa2f8092..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 @@ -89,9 +89,7 @@ export class FinalSettlementSubsidyHandler extends BasePhaseHandler { const outTokenDetails = state.type === RampDirection.BUY - ? quote.outputCurrency === EvmToken.MORPHO_VAULT - ? (getOnChainTokenDetails(quote.network as Networks, EvmToken.USDC) as EvmTokenDetails) - : (getOnChainTokenDetails(quote.network, quote.outputCurrency) as EvmTokenDetails) + ? (getOnChainTokenDetails(quote.network, quote.outputCurrency) as EvmTokenDetails) : isAlfredpaySell ? getOnChainTokenDetails(Networks.Polygon, ALFREDPAY_EVM_TOKEN) : getOnChainTokenDetails(Networks.Polygon, EvmToken.USDC); @@ -106,17 +104,6 @@ export class FinalSettlementSubsidyHandler extends BasePhaseHandler { let expectedAmountRaw: Big | undefined; switch (state.type) { case RampDirection.BUY: - if (quote.outputCurrency === EvmToken.MORPHO_VAULT) { - // MORPHO_VAULT onramp: SquidRouter delivers the deposit asset (USDC) to the ephemeral, not vault shares. - // Vault shares are minted by the later morphoDeposit phase. Use the raw deposit amount from state metadata. - if (!state.state.morphoDepositAmountRaw) { - throw new Error( - "FinalSettlementSubsidyHandler: Missing morphoDepositAmountRaw in state metadata for MORPHO_VAULT onramp" - ); - } - expectedAmountRaw = Big(state.state.morphoDepositAmountRaw); - break; - } expectedAmountRaw = multiplyByPowerOfTen(quote.outputAmount, outTokenDetails.decimals); break; case RampDirection.SELL: 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 b07b6f711..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 @@ -85,13 +85,6 @@ export class FundEphemeralPhaseHandler extends BasePhaseHandler { return false; } - protected getRequiresMorphoNetworkFunding(state: RampState): boolean { - const meta = state.state as StateMetadata; - if (!meta.morphoNetwork) return false; - if (!isNetworkEVM(meta.morphoNetwork as EvmNetworks)) return false; - return true; - } - protected getRequiresDestinationEvmFunding(state: RampState): boolean { // Required for onramps where the destination is an EVM network (not AssetHub) if (isOnramp(state) && state.to !== Networks.AssetHub) { @@ -222,15 +215,7 @@ export class FundEphemeralPhaseHandler extends BasePhaseHandler { logger.info("Polygon ephemeral address already funded."); } - // Deduplicate morpho + destination EVM networks: a single network can appear - // in both sets (e.g. onramp Morpho on Arbitrum, where the destination == - // morpho network). Funding twice for the same address on the same chain - // would over-fund the ephemeral. const evmNetworksToFund = new Set(); - const morphoNetwork = (state.state as StateMetadata).morphoNetwork as EvmNetworks | undefined; - if (morphoNetwork && this.getRequiresMorphoNetworkFunding(state)) { - evmNetworksToFund.add(morphoNetwork); - } const destinationNetwork = getNetworkFromDestination(state.to); if (isOnramp(state) && destinationNetwork && isNetworkEVM(destinationNetwork) && requiresDestinationEvmFunding) { evmNetworksToFund.add(destinationNetwork); diff --git a/apps/api/src/api/services/phases/handlers/morpho-deposit-handler.ts b/apps/api/src/api/services/phases/handlers/morpho-deposit-handler.ts deleted file mode 100644 index a68b9c05b..000000000 --- a/apps/api/src/api/services/phases/handlers/morpho-deposit-handler.ts +++ /dev/null @@ -1,245 +0,0 @@ -import { EvmClientManager, EvmNetworks, Networks, RampPhase } from "@vortexfi/shared"; -import { erc20Abi } from "viem"; -import logger from "../../../../config/logger"; -import RampState from "../../../../models/rampState.model"; -import { BasePhaseHandler } from "../base-phase-handler"; -import { StateMetadata } from "../meta-state-types"; - -const EVM_BALANCE_CHECK_TIMEOUT_MS = 3 * 60 * 1000; -const BALANCE_POLLING_INTERVAL_MS = 5000; - -const morphoVaultAbi = [ - { - inputs: [ - { name: "assets", type: "uint256" }, - { name: "receiver", type: "address" } - ], - name: "deposit", - outputs: [{ name: "shares", type: "uint256" }], - stateMutability: "nonpayable", - type: "function" - }, - { - inputs: [{ name: "owner", type: "address" }], - name: "balanceOf", - outputs: [{ name: "", type: "uint256" }], - stateMutability: "view", - type: "function" - } -] as const; - -const MORPHO_NETWORK: EvmNetworks = Networks.Ethereum; - -function resolveMorphoNetwork(meta: StateMetadata): EvmNetworks { - return (meta.morphoNetwork as EvmNetworks | undefined) ?? MORPHO_NETWORK; -} - -async function pollForShareBalance( - evmClientManager: EvmClientManager, - network: EvmNetworks, - vaultAddress: `0x${string}`, - ownerAddress: `0x${string}` -): Promise { - const client = evmClientManager.getClient(network); - const deadline = Date.now() + EVM_BALANCE_CHECK_TIMEOUT_MS; - - while (Date.now() < deadline) { - const balance = await client.readContract({ - abi: morphoVaultAbi, - address: vaultAddress, - args: [ownerAddress], - functionName: "balanceOf" - }); - - if (balance > 0n) { - return balance; - } - - await new Promise(resolve => setTimeout(resolve, BALANCE_POLLING_INTERVAL_MS)); - } - - throw new Error(`MorphoDepositHandler: Timed out waiting for share tokens on ${ownerAddress} at vault ${vaultAddress}`); -} - -export class MorphoDepositHandler extends BasePhaseHandler { - public getPhaseName(): RampPhase { - return "morphoDeposit"; - } - - public getMaxRetries(): number { - return 5; - } - - protected async executePhase(state: RampState): Promise { - const meta = state.state as StateMetadata; - const evmClientManager = EvmClientManager.getInstance(); - - if (!meta.morphoVaultAddress) { - throw new Error("MorphoDepositHandler: Missing morphoVaultAddress in state metadata"); - } - if (!meta.morphoDepositAssetAddress) { - throw new Error("MorphoDepositHandler: Missing morphoDepositAssetAddress in state metadata"); - } - if (!meta.evmEphemeralAddress) { - throw new Error("MorphoDepositHandler: Missing evmEphemeralAddress in state metadata"); - } - - const network = resolveMorphoNetwork(meta); - const vaultAddress = meta.morphoVaultAddress as `0x${string}`; - const depositAssetAddress = meta.morphoDepositAssetAddress as `0x${string}`; - const ephemeralAddress = meta.evmEphemeralAddress as `0x${string}`; - const destinationAddress = meta.destinationAddress as `0x${string}`; - - const { approveTxData, depositTxData } = this.getMorphoPresignedTxs(state); - - // Step 1: Approve vault to spend deposit asset - state = await this.executeApprove( - state, - evmClientManager, - network, - vaultAddress, - depositAssetAddress, - ephemeralAddress, - approveTxData - ); - - // Step 2: Deposit into vault, verify share tokens minted to destination - return this.executeDeposit(state, evmClientManager, network, vaultAddress, destinationAddress, depositTxData); - } - - private getMorphoPresignedTxs(state: RampState): { approveTxData: string; depositTxData: string } { - const approveTx = state.presignedTxs?.find(tx => tx.phase === "morphoApprove"); - const depositTx = state.presignedTxs?.find(tx => tx.phase === "morphoDeposit"); - if (!approveTx || !depositTx) { - throw new Error(`MorphoDepositHandler: Missing presigned txs (approve: ${!!approveTx}, deposit: ${!!depositTx})`); - } - return { approveTxData: approveTx.txData as string, depositTxData: depositTx.txData as string }; - } - - private async executeApprove( - state: RampState, - evmClientManager: EvmClientManager, - network: EvmNetworks, - vaultAddress: `0x${string}`, - depositAssetAddress: `0x${string}`, - ephemeralAddress: `0x${string}`, - approveTxData: string - ): Promise { - const meta = state.state as StateMetadata; - const requiredAmount = BigInt(meta.morphoDepositAmountRaw || "0"); - - const allowance = await this.readAllowance(evmClientManager, network, depositAssetAddress, ephemeralAddress, vaultAddress); - if (allowance >= requiredAmount) { - logger.info(`MorphoDepositHandler: Allowance ${allowance} >= ${requiredAmount}, skipping approve`); - return state; - } - - const txHash = (await evmClientManager.sendRawTransactionWithRetry( - network, - approveTxData as `0x${string}` - )) as `0x${string}`; - - logger.info(`MorphoDepositHandler: Approval tx broadcast: ${txHash}`); - - const receipt = await evmClientManager.getClient(network).waitForTransactionReceipt({ hash: txHash }); - if (!receipt || receipt.status !== "success") { - throw new Error(`MorphoDepositHandler: Approval transaction ${txHash} failed on chain`); - } - - await this.verifyAllowance(evmClientManager, network, depositAssetAddress, ephemeralAddress, vaultAddress, meta); - - return state; - } - - private async readAllowance( - evmClientManager: EvmClientManager, - network: EvmNetworks, - tokenAddress: `0x${string}`, - ownerAddress: `0x${string}`, - spenderAddress: `0x${string}` - ): Promise { - const client = evmClientManager.getClient(network); - return client.readContract({ - abi: erc20Abi, - address: tokenAddress, - args: [ownerAddress, spenderAddress], - functionName: "allowance" - }); - } - - private async verifyAllowance( - evmClientManager: EvmClientManager, - network: EvmNetworks, - tokenAddress: `0x${string}`, - ownerAddress: `0x${string}`, - spenderAddress: `0x${string}`, - meta: StateMetadata - ): Promise { - const client = evmClientManager.getClient(network); - const requiredAmount = BigInt(meta.morphoDepositAmountRaw || "0"); - const deadline = Date.now() + EVM_BALANCE_CHECK_TIMEOUT_MS; - let lastAllowance = 0n; - - while (Date.now() < deadline) { - lastAllowance = await client.readContract({ - abi: erc20Abi, - address: tokenAddress, - args: [ownerAddress, spenderAddress], - functionName: "allowance" - }); - - if (lastAllowance >= requiredAmount) { - logger.info(`MorphoDepositHandler: Allowance verified: ${lastAllowance} >= ${requiredAmount}`); - return; - } - - await new Promise(resolve => setTimeout(resolve, BALANCE_POLLING_INTERVAL_MS)); - } - - throw new Error(`MorphoDepositHandler: Insufficient allowance. Have ${lastAllowance}, need ${requiredAmount}`); - } - - private async executeDeposit( - state: RampState, - evmClientManager: EvmClientManager, - network: EvmNetworks, - vaultAddress: `0x${string}`, - shareReceiver: `0x${string}`, - depositTxData: string - ): Promise { - const meta = state.state as StateMetadata; - - // Recovery: if we already broadcast the deposit, just verify shares - if (meta.morphoDepositTxHash) { - logger.info( - `MorphoDepositHandler: Deposit tx already broadcast (${meta.morphoDepositTxHash}), verifying shares on ${shareReceiver}` - ); - const shares = await pollForShareBalance(evmClientManager, network, vaultAddress, shareReceiver); - logger.info(`MorphoDepositHandler: Share balance verified: ${shares}`); - return state; - } - - const txHash = (await evmClientManager.sendRawTransactionWithRetry( - network, - depositTxData as `0x${string}` - )) as `0x${string}`; - - logger.info(`MorphoDepositHandler: Deposit tx broadcast: ${txHash}`); - - await state.update({ - state: { ...state.state, morphoDepositTxHash: txHash } - }); - - const receipt = await evmClientManager.getClient(network).waitForTransactionReceipt({ hash: txHash }); - if (!receipt || receipt.status !== "success") { - throw new Error(`MorphoDepositHandler: Deposit transaction ${txHash} failed on chain`); - } - - const shares = await pollForShareBalance(evmClientManager, network, vaultAddress, shareReceiver); - logger.info(`MorphoDepositHandler: Deposit successful. Share balance on ${shareReceiver}: ${shares}`); - - return state; - } -} - -export default new MorphoDepositHandler(); diff --git a/apps/api/src/api/services/phases/handlers/morpho-permit-execute-handler.ts b/apps/api/src/api/services/phases/handlers/morpho-permit-execute-handler.ts deleted file mode 100644 index 553ffd8e1..000000000 --- a/apps/api/src/api/services/phases/handlers/morpho-permit-execute-handler.ts +++ /dev/null @@ -1,322 +0,0 @@ -import { EvmClientManager, EvmNetworks, isSignedTypedData, Networks, RampPhase, SignedTypedData } from "@vortexfi/shared"; -import { encodeFunctionData } from "viem"; -import { privateKeyToAccount } from "viem/accounts"; -import logger from "../../../../config/logger"; -import { config } from "../../../../config/vars"; -import RampState from "../../../../models/rampState.model"; -import { BasePhaseHandler } from "../base-phase-handler"; -import { StateMetadata } from "../meta-state-types"; - -const permitAbi = [ - { - inputs: [ - { name: "owner", type: "address" }, - { name: "spender", type: "address" }, - { name: "value", type: "uint256" }, - { name: "deadline", type: "uint256" }, - { name: "v", type: "uint8" }, - { name: "r", type: "bytes32" }, - { name: "s", type: "bytes32" } - ], - name: "permit", - outputs: [], - stateMutability: "nonpayable", - type: "function" - } -] as const; - -const allowanceAbi = [ - { - inputs: [ - { name: "owner", type: "address" }, - { name: "spender", type: "address" } - ], - name: "allowance", - outputs: [{ name: "", type: "uint256" }], - stateMutability: "view", - type: "function" - } -] as const; - -const ALLOWANCE_POLL_INTERVAL_MS = 2_000; -const ALLOWANCE_POLL_TIMEOUT_MS = 120_000; - -type VrsSignature = { v: number; r: `0x${string}`; s: `0x${string}` }; - -const ETHEREUM_NETWORK: EvmNetworks = Networks.Ethereum; - -function resolveMorphoNetwork(meta: StateMetadata): EvmNetworks { - return (meta.morphoNetwork as EvmNetworks | undefined) ?? ETHEREUM_NETWORK; -} - -/** - * Phase description: - * The user signs a single EIP-2612 Permit typed data on the Morpho vault (spender = ephemeral). - * Two presignedTxs are registered under phase "morphoPermitExecute": - * 1. SignedTypedData entry signed by the user (nonce 0, signer = userAddress) - * 2. Raw ERC-20 transferFrom tx signed by the EVM ephemeral (nonce 0, signer = evmEphemeral) - * This handler: - * a. Reads the SignedTypedData, verifies signature, and broadcasts vault.permit() using - * the executor key. permit() does not require the spender to be the caller, so the - * executor (which has gas at this point) can submit it. - * b. Waits for the permit receipt. - * c. Broadcasts the presigned transferFrom tx (the spender, the ephemeral, is the only - * one who can call transferFrom on the allowance it was just granted). - * d. Waits for the transferFrom receipt. - * After both txs land, the MorphoRedeemHandler can call vault.redeem on the ephemeral. - */ -export class MorphoPermitExecuteHandler extends BasePhaseHandler { - private evmClientManager: EvmClientManager; - - constructor() { - super(); - this.evmClientManager = EvmClientManager.getInstance(); - } - - public getPhaseName(): RampPhase { - return "morphoPermitExecute"; - } - - private getExecutorWallet(network: EvmNetworks) { - const account = privateKeyToAccount(config.secrets.moonbeamExecutorPrivateKey as `0x${string}`); - return { - publicClient: this.evmClientManager.getClient(network), - walletClient: this.evmClientManager.getWalletClient(network, account) - }; - } - - private async waitForReceipt(network: EvmNetworks, hash: `0x${string}`, label: string): Promise { - const { publicClient } = this.getExecutorWallet(network); - const receipt = await publicClient.waitForTransactionReceipt({ hash }); - if (!receipt || receipt.status !== "success") { - throw this.createRecoverableError(`${label} tx failed: ${hash}`); - } - } - - /** - * Broadcasts the executor-signed `vault.permit(...)` call. The shared funding/executor - * account has high nonce contention on Base, so a prior attempt of the same tx can stay - * stuck in the mempool at the same nonce with higher gas, causing the next attempt to be - * rejected as "replacement transaction underpriced". We retry up to 3 times, bumping the - * gas price by 1.5x on each attempt to outbid any stuck replacement. - */ - private async broadcastPermitWithGasBump( - network: EvmNetworks, - account: ReturnType, - token: `0x${string}`, - owner: `0x${string}`, - spender: `0x${string}`, - value: bigint, - deadline: bigint, - v: number, - r: `0x${string}`, - s: `0x${string}` - ): Promise<`0x${string}`> { - const { walletClient, publicClient } = this.getExecutorWallet(network); - const data = encodeFunctionData({ - abi: permitAbi, - args: [owner, spender, value, deadline, v, r, s], - functionName: "permit" - }); - - const PERMIT_GAS = 100_000n; - const MAX_ATTEMPTS = 3; - const GAS_BUMP_NUMERATOR = 3n; - const GAS_BUMP_DENOMINATOR = 2n; - - let maxFeePerGas: bigint | undefined; - let maxPriorityFeePerGas: bigint | undefined; - let lastError: Error | undefined; - - for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) { - if (maxFeePerGas === undefined || maxPriorityFeePerGas === undefined) { - const fees = await publicClient.estimateFeesPerGas(); - maxFeePerGas = fees.maxFeePerGas; - maxPriorityFeePerGas = fees.maxPriorityFeePerGas; - } else { - maxFeePerGas = (maxFeePerGas * GAS_BUMP_NUMERATOR) / GAS_BUMP_DENOMINATOR; - maxPriorityFeePerGas = (maxPriorityFeePerGas * GAS_BUMP_NUMERATOR) / GAS_BUMP_DENOMINATOR; - } - - try { - const nonce = await publicClient.getTransactionCount({ - address: account.address, - blockTag: "pending" - }); - logger.info( - `MorphoPermitExecuteHandler: permit broadcast attempt ${attempt}/${MAX_ATTEMPTS} nonce=${nonce} maxFeePerGas=${maxFeePerGas}` - ); - const hash = await walletClient.sendTransaction({ - data, - gas: PERMIT_GAS, - maxFeePerGas, - maxPriorityFeePerGas, - nonce, - to: token, - value: 0n - }); - return hash; - } catch (err) { - lastError = err instanceof Error ? err : new Error(String(err)); - const msg = lastError.message; - if (msg.includes("replacement transaction underpriced") || msg.includes("nonce too low")) { - logger.warn( - `MorphoPermitExecuteHandler: permit attempt ${attempt} rejected (${msg.slice(0, 120)}); bumping gas and retrying` - ); - continue; - } - throw lastError; - } - } - throw lastError ?? new Error("MorphoPermitExecuteHandler: permit broadcast failed after gas-bump retries"); - } - - private async awaitAllowance( - network: EvmNetworks, - vaultAddress: `0x${string}`, - owner: `0x${string}`, - spender: `0x${string}`, - expected: bigint - ): Promise { - const { publicClient } = this.getExecutorWallet(network); - const startedAt = Date.now(); - let lastObserved = 0n; - while (Date.now() - startedAt < ALLOWANCE_POLL_TIMEOUT_MS) { - try { - const current = (await publicClient.readContract({ - abi: allowanceAbi, - address: vaultAddress, - args: [owner, spender], - functionName: "allowance" - })) as bigint; - lastObserved = current; - if (current >= expected) { - logger.info( - `MorphoPermitExecuteHandler: allowance synced for ${vaultAddress} (${owner} -> ${spender}): ${current.toString()}` - ); - return; - } - } catch (err) { - logger.warn( - `MorphoPermitExecuteHandler: allowance read failed (${vaultAddress}); will retry: ${(err as Error).message}` - ); - } - await new Promise(resolve => setTimeout(resolve, ALLOWANCE_POLL_INTERVAL_MS)); - } - throw this.createRecoverableError( - `MorphoPermitExecuteHandler: timed out waiting for allowance to reach ${expected.toString()} on ${vaultAddress} (last observed: ${lastObserved.toString()})` - ); - } - - protected async executePhase(state: RampState): Promise { - logger.info(`Executing morphoPermitExecute phase for ramp ${state.id}`); - - const meta = state.state as StateMetadata; - const network = resolveMorphoNetwork(meta); - - if (!meta.evmEphemeralAddress) { - throw this.createUnrecoverableError("Missing evmEphemeralAddress in state metadata"); - } - - const ephemeralAddress = meta.evmEphemeralAddress as `0x${string}`; - - if (meta.morphoPermitTxHash && meta.morphoPermitTransferFromTxHash) { - logger.info(`MorphoPermitExecuteHandler: Both permit and transferFrom already broadcast for ramp ${state.id}, verifying`); - const publicClient = this.evmClientManager.getClient(network); - for (const [label, hash] of [ - ["permit", meta.morphoPermitTxHash], - ["transferFrom", meta.morphoPermitTransferFromTxHash] - ] as const) { - const receipt = await publicClient.getTransactionReceipt({ hash }); - if (!receipt || receipt.status !== "success") { - throw this.createRecoverableError(`MorphoPermitExecuteHandler: existing ${label} tx ${hash} is not successful`); - } - } - return state; - } - - // Find both presigned entries for this phase - const allPresigned = state.presignedTxs?.filter(tx => tx.phase === "morphoPermitExecute") ?? []; - const permitPresigned = allPresigned.find(tx => tx.signer.toLowerCase() === state.state.walletAddress?.toLowerCase()); - const transferFromPresigned = allPresigned.find(tx => tx.signer.toLowerCase() === ephemeralAddress.toLowerCase()); - - if (!permitPresigned) { - throw this.createUnrecoverableError("Missing user-signed permit presignedTx for morphoPermitExecute"); - } - if (!transferFromPresigned) { - throw this.createUnrecoverableError("Missing ephemeral-signed transferFrom presignedTx for morphoPermitExecute"); - } - - // ── Step 1: broadcast vault.permit via executor ── - const permitData = permitPresigned.txData; - if (!isSignedTypedData(permitData)) { - throw this.createUnrecoverableError("morphoPermitExecute: user presignedTx is not a SignedTypedData"); - } - const permitTypedData: SignedTypedData = permitData; - const sig = permitTypedData.signature as VrsSignature | undefined; - if (!sig) { - throw this.createUnrecoverableError("morphoPermitExecute: permit signature missing from user signed typed data"); - } - const token = permitTypedData.domain.verifyingContract as `0x${string}`; - const owner = permitTypedData.message.owner as `0x${string}`; - const value = BigInt(permitTypedData.message.value as string); - const deadline = BigInt(permitTypedData.message.deadline as string); - - if (deadline * 1000n < BigInt(Date.now())) { - throw this.createUnrecoverableError("Permit deadline already passed;"); - } - - if (!meta.morphoPermitTxHash) { - const account = privateKeyToAccount(config.secrets.moonbeamExecutorPrivateKey as `0x${string}`); - const permitHash = await this.broadcastPermitWithGasBump( - network, - account, - token, - owner, - ephemeralAddress, - value, - deadline, - sig.v, - sig.r, - sig.s - ); - logger.info(`MorphoPermitExecuteHandler: Permit tx sent: ${permitHash}`); - - state = await state.update({ state: { ...state.state, morphoPermitTxHash: permitHash } }); - await this.waitForReceipt(network, permitHash, "Permit"); - } else { - logger.info(`MorphoPermitExecuteHandler: Permit already broadcast (${meta.morphoPermitTxHash})`); - } - - // ── Step 1.5: await allowance RPC sync ── - // `waitForTransactionReceipt` only confirms the tx is mined on the source node; - // downstream public RPCs may lag in their state view. Poll the vault's allowance - // until it reflects the post-permit value, otherwise the subsequent transferFrom - // will revert with "insufficient allowance". - await this.awaitAllowance(network, token, owner, ephemeralAddress, value); - - // ── Step 2: broadcast ephemeral-signed transferFrom ── - if (!meta.morphoPermitTransferFromTxHash) { - const signedTxHex = transferFromPresigned.txData as string; - const txHash = (await this.evmClientManager.sendRawTransactionWithRetry( - network, - signedTxHex as `0x${string}` - )) as `0x${string}`; - logger.info(`MorphoPermitExecuteHandler: transferFrom tx broadcast: ${txHash}`); - - const publicClient = this.evmClientManager.getClient(network); - const receipt = await publicClient.waitForTransactionReceipt({ hash: txHash }); - if (!receipt || receipt.status !== "success") { - throw this.createRecoverableError(`MorphoPermitExecuteHandler: transferFrom tx ${txHash} failed on chain`); - } - state = await state.update({ state: { ...state.state, morphoPermitTransferFromTxHash: txHash } }); - } else { - logger.info(`MorphoPermitExecuteHandler: transferFrom already broadcast (${meta.morphoPermitTransferFromTxHash})`); - } - - logger.info(`MorphoPermitExecuteHandler: Permit + transferFrom confirmed for ramp ${state.id}`); - return state; - } -} - -export default new MorphoPermitExecuteHandler(); diff --git a/apps/api/src/api/services/phases/handlers/morpho-redeem-handler.ts b/apps/api/src/api/services/phases/handlers/morpho-redeem-handler.ts deleted file mode 100644 index 7dea9c140..000000000 --- a/apps/api/src/api/services/phases/handlers/morpho-redeem-handler.ts +++ /dev/null @@ -1,195 +0,0 @@ -import { EvmClientManager, EvmNetworks, Networks, RampPhase } from "@vortexfi/shared"; -import { erc20Abi } from "viem"; -import logger from "../../../../config/logger"; -import RampState from "../../../../models/rampState.model"; -import { BasePhaseHandler } from "../base-phase-handler"; -import { StateMetadata } from "../meta-state-types"; - -const EVM_BALANCE_CHECK_TIMEOUT_MS = 3 * 60 * 1000; -const BALANCE_POLLING_INTERVAL_MS = 5000; - -const vaultRedeemAbi = [ - { - inputs: [ - { name: "shares", type: "uint256" }, - { name: "receiver", type: "address" }, - { name: "owner", type: "address" } - ], - name: "redeem", - outputs: [{ name: "assets", type: "uint256" }], - stateMutability: "nonpayable", - type: "function" - }, - { - inputs: [{ name: "shares", type: "uint256" }], - name: "previewRedeem", - outputs: [{ name: "assets", type: "uint256" }], - stateMutability: "view", - type: "function" - } -] as const; - -const ETHEREUM_NETWORK: EvmNetworks = Networks.Ethereum; - -function resolveMorphoNetwork(meta: StateMetadata): EvmNetworks { - return (meta.morphoNetwork as EvmNetworks | undefined) ?? ETHEREUM_NETWORK; -} - -/** - * Phase description: - * Broadcasts the presigned vault.redeem(shares, ephemeral, ephemeral) tx on Ethereum. - * Defends against bad output with three layers: - * 1. Pre-flight: read previewRedeem(shares) before broadcast; abort if below minOutputRaw. - * 2. Event parse: parse the Withdraw event from the receipt; assert event.assets >= minOutputRaw. - * 3. Balance poll: as a final fallback, poll USDC.balanceOf(ephemeral) until >= minOutputRaw. - */ -export class MorphoRedeemHandler extends BasePhaseHandler { - private evmClientManager: EvmClientManager; - - constructor() { - super(); - this.evmClientManager = EvmClientManager.getInstance(); - } - - public getPhaseName(): RampPhase { - return "morphoRedeem"; - } - - public getMaxRetries(): number { - return 5; - } - - protected async executePhase(state: RampState): Promise { - const meta = state.state as StateMetadata; - - if (!meta.morphoRedeemVaultAddress) { - throw this.createUnrecoverableError("Missing morphoRedeemVaultAddress in state metadata"); - } - if (!meta.morphoRedeemAssetAddress) { - throw this.createUnrecoverableError("Missing morphoRedeemAssetAddress in state metadata"); - } - if (!meta.evmEphemeralAddress) { - throw this.createUnrecoverableError("Missing evmEphemeralAddress in state metadata"); - } - if (!meta.morphoRedeemSharesAmountRaw) { - throw this.createUnrecoverableError("Missing morphoRedeemSharesAmountRaw in state metadata"); - } - - const network = resolveMorphoNetwork(meta); - const vaultAddress = meta.morphoRedeemVaultAddress as `0x${string}`; - const assetAddress = meta.morphoRedeemAssetAddress as `0x${string}`; - const ephemeralAddress = meta.evmEphemeralAddress as `0x${string}`; - const sharesAmount = BigInt(meta.morphoRedeemSharesAmountRaw); - const minOutputRaw = BigInt(meta.morphoRedeemMinOutputRaw || "0"); - - // Recovery: if we already broadcast the redeem, verify the outcome. - if (meta.morphoRedeemTxHash) { - logger.info(`MorphoRedeemHandler: Redeem tx already broadcast (${meta.morphoRedeemTxHash}), verifying USDC balance`); - await this.verifyUsdcBalance(network, assetAddress, ephemeralAddress, minOutputRaw); - return state; - } - - // Layer 1: pre-flight previewRedeem. If the on-chain rate has drifted past tolerance before - // we even broadcast, abort without spending gas on a doomed redeem. - const publicClient = this.evmClientManager.getClient(network); - const previewAssets = (await publicClient.readContract({ - abi: vaultRedeemAbi, - address: vaultAddress, - args: [sharesAmount], - functionName: "previewRedeem" - })) as bigint; - - if (previewAssets < minOutputRaw) { - throw this.createRecoverableError( - `MorphoRedeemHandler: previewRedeem=${previewAssets} below minOutputRaw=${minOutputRaw}; aborting before broadcast` - ); - } - - const presigned = this.getPresignedTransaction(state, "morphoRedeem"); - if (!presigned) { - throw this.createUnrecoverableError("Missing presigned transaction for morphoRedeem phase"); - } - - const txHash = (await this.evmClientManager.sendRawTransactionWithRetry( - network, - presigned.txData as `0x${string}` - )) as `0x${string}`; - logger.info(`MorphoRedeemHandler: Redeem tx broadcast: ${txHash}`); - - state = await state.update({ - state: { ...state.state, morphoRedeemTxHash: txHash } - }); - - const receipt = await publicClient.waitForTransactionReceipt({ hash: txHash }); - if (!receipt || receipt.status !== "success") { - throw this.createRecoverableError(`MorphoRedeemHandler: Redeem transaction ${txHash} failed on chain`); - } - - // Layer 2: parse the Withdraw event. ERC-4626 Withdraw event signature is - // Withdraw(address indexed sender, address indexed receiver, address indexed owner, uint256 assets, uint256 shares) - // TODO this is either a good idea, or a bad one if this signature can change. - const withdrawEventSig = "0xfbde797d201c681b91056529119e0b02407c7bb96a4a2c75c01fc9667232c8db"; - let parsedAssets: bigint | null = null; - for (const log of receipt.logs) { - if (log.address.toLowerCase() !== vaultAddress.toLowerCase()) continue; - if (log.topics[0]?.toLowerCase() !== withdrawEventSig) continue; - if (log.data.length >= 66) { - // data = abi.encode(uint256 assets, uint256 shares) => 32 + 32 bytes - parsedAssets = BigInt(log.data.slice(0, 66)); - break; - } - } - if (parsedAssets !== null) { - if (parsedAssets < minOutputRaw) { - throw this.createRecoverableError( - `MorphoRedeemHandler: Withdraw event assets=${parsedAssets} below minOutputRaw=${minOutputRaw}` - ); - } - logger.info(`MorphoRedeemHandler: Withdraw event assets=${parsedAssets} >= minOutputRaw=${minOutputRaw}`); - const updated = await state.update({ - state: { ...state.state, morphoRedeemActualOutputRaw: parsedAssets.toString() } - }); - return updated; - } - logger.info( - "MorphoRedeemHandler: No Withdraw event found in receipt (non-standard vault); falling back to balance polling" - ); - - // Layer 3: balance polling fallback - await this.verifyUsdcBalance(network, assetAddress, ephemeralAddress, minOutputRaw); - return state; - } - - private async verifyUsdcBalance( - network: EvmNetworks, - assetAddress: `0x${string}`, - ephemeralAddress: `0x${string}`, - minOutputRaw: bigint - ): Promise { - const client = this.evmClientManager.getClient(network); - const deadline = Date.now() + EVM_BALANCE_CHECK_TIMEOUT_MS; - let lastBalance = 0n; - - while (Date.now() < deadline) { - lastBalance = (await client.readContract({ - abi: erc20Abi, - address: assetAddress, - args: [ephemeralAddress], - functionName: "balanceOf" - })) as bigint; - - if (lastBalance >= minOutputRaw) { - logger.info(`MorphoRedeemHandler: USDC balance verified: ${lastBalance} >= ${minOutputRaw}`); - return; - } - - await new Promise(resolve => setTimeout(resolve, BALANCE_POLLING_INTERVAL_MS)); - } - - throw this.createRecoverableError( - `MorphoRedeemHandler: Timed out waiting for USDC balance >= ${minOutputRaw} on ${ephemeralAddress} (last seen: ${lastBalance})` - ); - } -} - -export default new MorphoRedeemHandler(); diff --git a/apps/api/src/api/services/phases/handlers/morpho-vault-config.ts b/apps/api/src/api/services/phases/handlers/morpho-vault-config.ts deleted file mode 100644 index 6dd66b7c2..000000000 --- a/apps/api/src/api/services/phases/handlers/morpho-vault-config.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { Networks } from "@vortexfi/shared"; - -export interface MorphoVaultInfo { - vaultAddress: `0x${string}`; - depositAssetAddress: `0x${string}`; - depositAssetDecimals: number; - shareDecimals: number; - network: Networks; -} - -const MORPHO_VAULTS: Record = { - "usdc-arbitrum": { - depositAssetAddress: "0xaf88d065e77c8cC2239327C5EDb3A432268e5831", // USDC on Arbitrum - depositAssetDecimals: 6, - network: Networks.Arbitrum, - shareDecimals: 18, // MetaMorpho default - vaultAddress: "0xbeeff1D5dE8F79ff37a151681100B039661da518" // Steakhouse on Arbitrum - } -}; - -export function getMorphoVaultInfo(vaultId: string): MorphoVaultInfo { - const vault = MORPHO_VAULTS[vaultId]; - if (!vault) { - throw new Error(`Morpho vault "${vaultId}" not found. Available: ${Object.keys(MORPHO_VAULTS).join(", ")}`); - } - return vault; -} 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 a4277b522..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 @@ -505,8 +505,8 @@ 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 (e.g. Morpho -> SEPA via Mykobo), `quote.to` is a PaymentMethod enum - * value, so we fall back to the bridge metadata recorded at quote time. + * 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); 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 402a93c6f..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 @@ -61,8 +61,7 @@ export class SquidRouterPhaseHandler extends BasePhaseHandler { if (state.type === RampDirection.SELL) { // SELL with a squidRouterSwap unsigned tx means an offramp that bridges between EVM - // chains (e.g. Morpho offramp: vault on Arbitrum -> USDC bridged to Base). The - // ephemeral broadcasts approve+swap; the same bridge logic as onramp applies. + // 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 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 fe78accf3..6dc181db7 100644 --- a/apps/api/src/api/services/phases/meta-state-types.ts +++ b/apps/api/src/api/services/phases/meta-state-types.ts @@ -80,21 +80,4 @@ export interface StateMetadata { // 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[]; - // Morpho vault deposit - morphoVaultAddress?: string; - morphoShareTokenAddress?: string; - morphoDepositAssetAddress?: string; - morphoDepositAmountRaw?: string; - morphoDepositTxHash?: `0x${string}`; - // Morpho vault redeem (offramp) - morphoRedeemVaultAddress?: string; - morphoRedeemShareTokenAddress?: string; - morphoRedeemAssetAddress?: string; - morphoRedeemSharesAmountRaw?: string; - morphoRedeemMinOutputRaw?: string; - morphoRedeemTxHash?: `0x${string}`; - morphoRedeemActualOutputRaw?: string; - morphoPermitTxHash?: `0x${string}`; - morphoPermitTransferFromTxHash?: `0x${string}`; - morphoNetwork?: Networks; } diff --git a/apps/api/src/api/services/phases/ramp-flow-definitions.ts b/apps/api/src/api/services/phases/ramp-flow-definitions.ts index 9a89ccdf8..3fc900676 100644 --- a/apps/api/src/api/services/phases/ramp-flow-definitions.ts +++ b/apps/api/src/api/services/phases/ramp-flow-definitions.ts @@ -156,91 +156,3 @@ export const ALFREDPAY_OFFRAMP: RampPhase[] = [ "alfredpayOfframpTransfer", "complete" ]; - -// ─── Morpho Vault On-Ramp (Mykobo SEPA on Base → Morpho Vault) ────────────────── - -/** - * Generic EUR onramp from Mykobo SEPA → deposit into a Morpho vault on any EVM chain. - * EURC on Base via Nabla swap to USDC, then SquidRouter bridge to the vault's chain, - * then deposit into the Morpho vault. Use this for vaults on chains other than Base. - */ -export const EUR_ONRAMP_MORPHO: RampPhase[] = [ - "initial", - "mykoboOnrampDeposit", - "fundEphemeral", - "subsidizePreSwap", - "nablaApprove", - "nablaSwap", - "distributeFees", - "subsidizePostSwap", - "squidRouterSwap", - "squidRouterPay", - "finalSettlementSubsidy", - "morphoDeposit", - "complete" -]; - -/** - * Same-chain variant for vaults on Base. After the Nabla EURC→USDC swap on Base, USDC - * is already on Base, so the deposit into the Base vault does not need a SquidRouter - * bridge. The route-prep code in `mykobo-to-evm-morpho.ts` selects this flow when - * `morphoNetwork === Networks.Base` and skips adding bridge presignedTxs. - */ -export const EUR_ONRAMP_BASE_MORPHO: RampPhase[] = [ - "initial", - "mykoboOnrampDeposit", - "fundEphemeral", - "subsidizePreSwap", - "nablaApprove", - "nablaSwap", - "distributeFees", - "subsidizePostSwap", - "morphoDeposit", - "complete" -]; - -// ─── Morpho Vault Off-Ramp (Morpho Vault → Mykobo SEPA on Base) ──────────────── - -/** - * Generic EUR offramp from Morpho vault shares on any EVM chain → SEPA payout via Mykobo. - * User signs a single EIP-2612 Permit typed data on the vault (spender = ephemeral). - * The ephemeral broadcasts permit + transferFrom, redeems shares to USDC on the - * vault's chain, then SquidRouter bridges the USDC to Base, where the Mykobo payout - * leg (Nabla USDC→EURC + EURC→Mykobo) executes. Use this for vaults on chains - * other than Base. - */ -export const EUR_OFFRAMP_MORPHO: RampPhase[] = [ - "initial", - "fundEphemeral", - "morphoPermitExecute", - "morphoRedeem", - "squidRouterSwap", - "squidRouterPay", - "distributeFees", - "subsidizePreSwap", - "nablaApprove", - "nablaSwap", - "subsidizePostSwap", - "mykoboPayoutOnBase", - "complete" -]; - -/** - * Same-chain variant for vaults on Base. After morphoRedeem, USDC is already on Base, - * so no SquidRouter bridge is needed before the Mykobo payout leg. The route-prep - * code in `evm-to-mykobo-morpho.ts` selects this flow when - * `morphoNetwork === Networks.Base` and skips adding bridge presignedTxs. - */ -export const EUR_OFFRAMP_BASE_MORPHO: RampPhase[] = [ - "initial", - "fundEphemeral", - "morphoPermitExecute", - "morphoRedeem", - "distributeFees", - "subsidizePreSwap", - "nablaApprove", - "nablaSwap", - "subsidizePostSwap", - "mykoboPayoutOnBase", - "complete" -]; diff --git a/apps/api/src/api/services/phases/register-handlers.ts b/apps/api/src/api/services/phases/register-handlers.ts index ab6b17a84..eb48061c1 100644 --- a/apps/api/src/api/services/phases/register-handlers.ts +++ b/apps/api/src/api/services/phases/register-handlers.ts @@ -12,9 +12,6 @@ import hydrationToAssethubXcmPhaseHandler from "./handlers/hydration-to-assethub import initialPhaseHandler from "./handlers/initial-phase-handler"; import moonbeamToPendulumPhaseHandler from "./handlers/moonbeam-to-pendulum-handler"; import moonbeamToPendulumXcmHandler from "./handlers/moonbeam-to-pendulum-xcm-handler"; -import morphoDepositHandler from "./handlers/morpho-deposit-handler"; -import morphoPermitExecuteHandler from "./handlers/morpho-permit-execute-handler"; -import morphoRedeemHandler from "./handlers/morpho-redeem-handler"; import mykoboOnrampDepositHandler from "./handlers/mykobo-onramp-deposit-handler"; import mykoboPayoutHandler from "./handlers/mykobo-payout-handler"; import nablaApproveHandler from "./handlers/nabla-approve-handler"; @@ -61,9 +58,6 @@ export function registerPhaseHandlers(): void { phaseRegistry.registerHandler(finalSettlementSubsidy); phaseRegistry.registerHandler(destinationTransferHandler); phaseRegistry.registerHandler(squidRouterPermitExecutionHandler); - phaseRegistry.registerHandler(morphoDepositHandler); - phaseRegistry.registerHandler(morphoPermitExecuteHandler); - phaseRegistry.registerHandler(morphoRedeemHandler); logger.info("Phase handlers registered"); } diff --git a/apps/api/src/api/services/priceFeed.service.ts b/apps/api/src/api/services/priceFeed.service.ts index a8743310a..782f2e2f6 100644 --- a/apps/api/src/api/services/priceFeed.service.ts +++ b/apps/api/src/api/services/priceFeed.service.ts @@ -460,8 +460,7 @@ export class PriceFeedService { ETH: "ethereum", GLMR: "moonbeam", HDX: "hydradx", - MATIC: "polygon-ecosystem-token", - "MORPHO VAULT": "usd-coin" + MATIC: "polygon-ecosystem-token" }; return tokenIdMap[currency.toUpperCase()] || null; diff --git a/apps/api/src/api/services/quote/core/squidrouter.ts b/apps/api/src/api/services/quote/core/squidrouter.ts index 253224552..341e06107 100644 --- a/apps/api/src/api/services/quote/core/squidrouter.ts +++ b/apps/api/src/api/services/quote/core/squidrouter.ts @@ -81,15 +81,9 @@ export function getTokenDetailsForEvmDestination( /** * Returns the token details that SquidRouter should deliver on the destination - * chain for a given (outputCurrency, toNetwork). For MORPHO_VAULT, the bridge - * must land the vault's deposit asset (USDC) - the vault contract itself is an - * ERC-4626 share token that SquidRouter cannot bridge. The actual vault deposit - * runs as a separate on-chain step in route-prep. + * chain for a given (outputCurrency, toNetwork). */ export function getBridgeTargetTokenDetails(outputCurrency: OnChainToken, toNetwork: Networks): EvmTokenDetails { - if (outputCurrency === EvmToken.MORPHO_VAULT) { - return getTokenDetailsForEvmDestination(EvmToken.USDC, toNetwork); - } return getTokenDetailsForEvmDestination(outputCurrency, toNetwork); } diff --git a/apps/api/src/api/services/quote/core/types.ts b/apps/api/src/api/services/quote/core/types.ts index b3093a74b..80ff464fc 100644 --- a/apps/api/src/api/services/quote/core/types.ts +++ b/apps/api/src/api/services/quote/core/types.ts @@ -205,13 +205,6 @@ export interface QuoteContext { evmToEvm?: BridgeMeta; - // Morpho vault redeem metadata (offramp only). Set by OffRampFromEvmInitializeMorphoEngine. - redeemMeta?: { - sharesAmountRaw: string; - vaultAddress: string; - expectedUsdcRaw: string; - }; - evmToMoonbeam?: BridgeMeta; hydrationToAssethubXcm?: XcmMeta; 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 38079c016..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,9 +123,9 @@ export class OnRampDiscountEngine extends BaseDiscountEngine { if (!toNetwork) { return null; } - // Trivial case: USDC/Morpho Vault on Base is also the requested output. No bridge runs, so the + // 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. - if (toNetwork === Networks.Base && (req.outputCurrency === EvmToken.USDC || req.outputCurrency === EvmToken.MORPHO_VAULT)) { + 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 11a829d97..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 @@ -63,10 +63,7 @@ export class OnRampAveniaToEvmFeeEngine extends BaseFeeEngine { // Same-chain same-token: Nabla swap output already matches the destination token (e.g. BRL → Base USDC). // No bridge needed, so skip the Squid route call (which would fail with "same token same chain") and report zero network fee. - if ( - (swapNetwork === toNetwork && fromTokenDetails.erc20AddressSourceChain.toLowerCase() === toToken.toLowerCase()) || - (request.outputCurrency === EvmToken.MORPHO_VAULT && swapNetwork === toNetwork) - ) { + if (swapNetwork === toNetwork && fromTokenDetails.erc20AddressSourceChain.toLowerCase() === toToken.toLowerCase()) { return { anchor: { amount: computedAnchorFee, currency: anchorFeeCurrency }, network: { amount: "0", currency: "USD" as RampCurrency } 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 776b9e38f..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 @@ -57,10 +57,7 @@ export class OnRampMykoboToEvmFeeEngine extends BaseFeeEngine { throw new Error(`OnRampMykoboToEvmFeeEngine: invalid token configuration for ${this.fromToken} on ${swapNetwork}`); } - if ( - (swapNetwork === toNetwork && fromTokenDetails.erc20AddressSourceChain.toLowerCase() === toToken.toLowerCase()) || - (request.outputCurrency === EvmToken.MORPHO_VAULT && swapNetwork === toNetwork) - ) { + if (swapNetwork === toNetwork && fromTokenDetails.erc20AddressSourceChain.toLowerCase() === toToken.toLowerCase()) { return { anchor: { amount: computedAnchorFee, currency: anchorFeeCurrency }, network: { amount: "0", currency: "USD" as RampCurrency } diff --git a/apps/api/src/api/services/quote/engines/initialize/offramp-from-evm-morpho.ts b/apps/api/src/api/services/quote/engines/initialize/offramp-from-evm-morpho.ts deleted file mode 100644 index fadf81ec0..000000000 --- a/apps/api/src/api/services/quote/engines/initialize/offramp-from-evm-morpho.ts +++ /dev/null @@ -1,101 +0,0 @@ -import { EvmClientManager, EvmNetworks, EvmToken, Networks, RampDirection } from "@vortexfi/shared"; -import Big from "big.js"; -import { getMorphoVaultInfo } from "../../../phases/handlers/morpho-vault-config"; -import { EvmBridgeQuoteRequest, getEvmBridgeQuote } from "../../core/squidrouter"; -import { BridgeMeta, QuoteContext } from "../../core/types"; -import { assignPreNablaContext, BaseInitializeEngine } from "./index"; - -const vaultAbi = [ - { - inputs: [{ name: "shares", type: "uint256" }], - name: "previewRedeem", - outputs: [{ name: "assets", type: "uint256" }], - stateMutability: "view", - type: "function" - } -] as const; - -export class OffRampFromEvmInitializeMorphoEngine extends BaseInitializeEngine { - readonly config = { - direction: RampDirection.SELL, - skipNote: "OffRampFromEvmInitializeMorphoEngine: Skipped because rampType is BUY, this engine handles SELL operations only" - }; - - protected async executeInternal(ctx: QuoteContext): Promise { - const req = ctx.request; - - // 1. Resolve the Morpho vault and compute shares -> USDC conversion via previewRedeem. - const vault = getMorphoVaultInfo("usdc-arbitrum"); - const evmClientManager = EvmClientManager.getInstance(); - const vaultNetwork = vault.network as EvmNetworks; - const vaultClient = evmClientManager.getClient(vaultNetwork); - - const sharesAmountRaw = new Big(req.inputAmount).mul(new Big(10).pow(vault.shareDecimals)).toFixed(0, 0); - - const assetsFromShares = (await vaultClient.readContract({ - abi: vaultAbi, - address: vault.vaultAddress, - args: [BigInt(sharesAmountRaw)], - functionName: "previewRedeem" - })) as bigint; - - const expectedUsdcDecimal = new Big(assetsFromShares.toString()).div(new Big(10).pow(vault.depositAssetDecimals)); - - // 2. Set up preNabla context (mirrors the standard offramp EVM flow) - await assignPreNablaContext(ctx); - - // 3. Bridge context: when the vault is on Base, USDC is already on Base and no - // SquidRouter bridge is required. Surface a same-chain evmToEvm so downstream - // engines (e.g. OffRampSwapEngineEvm) can read outputAmountDecimal without - // conditional code paths. For non-Base vaults, fetch a real bridge quote. - if (vaultNetwork === Networks.Base) { - ctx.evmToEvm = { - fromNetwork: vaultNetwork, - fromToken: vault.depositAssetAddress as `0x${string}`, - inputAmountDecimal: expectedUsdcDecimal, - inputAmountRaw: assetsFromShares.toString(), - networkFeeUSD: "0", - outputAmountDecimal: expectedUsdcDecimal, - outputAmountRaw: assetsFromShares.toString(), - toNetwork: vaultNetwork, - toToken: vault.depositAssetAddress as `0x${string}` - } satisfies BridgeMeta; - } else { - const bridgeRequest: EvmBridgeQuoteRequest = { - amountDecimal: expectedUsdcDecimal.toFixed(vault.depositAssetDecimals), - fromNetwork: vaultNetwork, - inputCurrency: EvmToken.USDC, - outputCurrency: EvmToken.USDC, - rampType: req.rampType, - toNetwork: Networks.Base - }; - - const bridgeQuote = await getEvmBridgeQuote(bridgeRequest); - - ctx.evmToEvm = { - ...bridgeRequest, - fromToken: bridgeQuote.fromToken, - inputAmountDecimal: new Big(bridgeRequest.amountDecimal), - inputAmountRaw: bridgeQuote.inputAmountRaw, - networkFeeUSD: bridgeQuote.networkFeeUSD, - outputAmountDecimal: bridgeQuote.outputAmountDecimal, - outputAmountRaw: bridgeQuote.outputAmountRaw, - toToken: bridgeQuote.toToken - }; - } - - // 4. Surface redeem metadata for the route-prep phase (compute share->USDC rate at quote time). - ctx.redeemMeta = { - expectedUsdcRaw: assetsFromShares.toString(), - sharesAmountRaw, - vaultAddress: vault.vaultAddress - }; - - ctx.addNote?.( - `Morpho offramp init: ${sharesAmountRaw} vault shares on ${vaultNetwork} -> ~${expectedUsdcDecimal.toFixed()} USDC (previewRedeem)` + - (vaultNetwork === Networks.Base - ? "" - : `; bridge to ${ctx.evmToEvm.outputAmountDecimal.toFixed()} USDC on ${Networks.Base}`) - ); - } -} 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 d625998be..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 @@ -108,12 +108,9 @@ export class OnRampSquidRouterToBaseEngine extends BaseSquidRouterEngine { const usdcBaseTokenDetails = getTokenDetailsForEvmDestination(EvmToken.USDC, Networks.Base); - // Trivial case: nabla output (USDC on Base) is already the requested output or is Morpho Vault. + // 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 || ctx.request.outputCurrency === EvmToken.MORPHO_VAULT) - ) { + if (ctx.to === Networks.Base && ctx.request.outputCurrency === EvmToken.USDC) { const targetTokenDetails = getTokenDetailsForEvmDestination(ctx.request.outputCurrency as OnChainToken, Networks.Base); return { data: { diff --git a/apps/api/src/api/services/quote/index.ts b/apps/api/src/api/services/quote/index.ts index a51c01386..ba4e3a037 100644 --- a/apps/api/src/api/services/quote/index.ts +++ b/apps/api/src/api/services/quote/index.ts @@ -149,11 +149,7 @@ export class QuoteService extends BaseRampService { ): Promise { validateChainSupport(request.rampType, request.from, request.to); - if ( - request.rampType === RampDirection.BUY && - request.to === Networks.Ethereum && - request.outputCurrency !== EvmToken.MORPHO_VAULT - ) { + if (request.rampType === RampDirection.BUY && request.to === Networks.Ethereum) { throw new APIError({ message: QuoteError.FailedToCalculateQuote, status: httpStatus.INTERNAL_SERVER_ERROR }); } diff --git a/apps/api/src/api/services/quote/routes/route-resolver.ts b/apps/api/src/api/services/quote/routes/route-resolver.ts index bc259a741..4d2d71b08 100644 --- a/apps/api/src/api/services/quote/routes/route-resolver.ts +++ b/apps/api/src/api/services/quote/routes/route-resolver.ts @@ -4,7 +4,6 @@ import { AssetHubToken, EPaymentMethod, - EvmToken, FiatToken, isAlfredpayToken, Networks, @@ -19,7 +18,6 @@ import { offrampEvmToAlfredpayStrategy } from "./strategies/offramp-evm-to-alfre import { offrampToPixStrategy } from "./strategies/offramp-to-pix.strategy"; import { offrampToPixEvmStrategy } from "./strategies/offramp-to-pix-base.strategy"; import { offrampToSepaEvmStrategy } from "./strategies/offramp-to-sepa-evm.strategy"; -import { offrampToSepaEvmMorphoStrategy } from "./strategies/offramp-to-sepa-evm-morpho.strategy"; import { onrampAlfredpayToEvmStrategy } from "./strategies/onramp-alfredpay-to-evm.strategy"; import { onrampAveniaToAssethubStrategy } from "./strategies/onramp-avenia-to-assethub.strategy"; import { onrampAveniaToEvmBaseStrategy } from "./strategies/onramp-avenia-to-evm.strategy-base"; @@ -75,9 +73,6 @@ export class RouteResolver { case "spei": return offrampEvmToAlfredpayStrategy; case "sepa": - if (ctx.request.inputCurrency === EvmToken.MORPHO_VAULT) { - return offrampToSepaEvmMorphoStrategy; - } return offrampToSepaEvmStrategy; case "cbu": default: diff --git a/apps/api/src/api/services/quote/routes/strategies/offramp-to-sepa-evm-morpho.strategy.ts b/apps/api/src/api/services/quote/routes/strategies/offramp-to-sepa-evm-morpho.strategy.ts deleted file mode 100644 index 60ebf0ecf..000000000 --- a/apps/api/src/api/services/quote/routes/strategies/offramp-to-sepa-evm-morpho.strategy.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { EvmToken } from "@vortexfi/shared"; -import { StageKey } from "../../core/types"; -import { OffRampDiscountEngine } from "../../engines/discount/offramp"; -import { OffRampFeeMykoboEngine } from "../../engines/fee/offramp-mykobo"; -import { OffRampFinalizeEngine } from "../../engines/finalize/offramp"; -import { OffRampFromEvmInitializeMorphoEngine } from "../../engines/initialize/offramp-from-evm-morpho"; -import { OffRampMergeSubsidyEvmEngine } from "../../engines/merge-subsidy/offramp-evm"; -import { OffRampSwapEngineEvm } from "../../engines/nabla-swap/offramp-evm"; -import { defineRouteStrategy } from "../route-definition"; - -/** - * EUR offramp from Morpho vault shares on Ethereum → SEPA via Mykobo. - * - * The Initialize engine differs from the standard EUR offramp: it must first convert - * share amount → USDC amount via the vault's previewRedeem, then request the - * SquidRouter bridge quote (USDC Ethereum → USDC Base). Downstream stages - * (NablaSwap, Fee, Discount, MergeSubsidy, Finalize) are unchanged. - */ -export const offrampToSepaEvmMorphoStrategy = defineRouteStrategy({ - engines: () => ({ - [StageKey.Initialize]: new OffRampFromEvmInitializeMorphoEngine(), - [StageKey.NablaSwap]: new OffRampSwapEngineEvm(EvmToken.EURC), - [StageKey.Fee]: new OffRampFeeMykoboEngine(), - [StageKey.Discount]: new OffRampDiscountEngine(), - [StageKey.MergeSubsidy]: new OffRampMergeSubsidyEvmEngine(), - [StageKey.Finalize]: new OffRampFinalizeEngine() - }), - name: "OfframpToSepaEvmMorpho", - stages: [StageKey.Initialize, StageKey.NablaSwap, StageKey.Fee, StageKey.Discount, StageKey.MergeSubsidy, StageKey.Finalize] -}); diff --git a/apps/api/src/api/services/ramp/ramp.service.ts b/apps/api/src/api/services/ramp/ramp.service.ts index b50bae634..4df33e8dc 100644 --- a/apps/api/src/api/services/ramp/ramp.service.ts +++ b/apps/api/src/api/services/ramp/ramp.service.ts @@ -13,7 +13,6 @@ import { CreateAlfredpayOfframpQuoteRequest, CreateAlfredpayOnrampRequest, EphemeralAccountType, - EvmToken, FiatToken, GetRampHistoryResponse, GetRampStatusResponse, @@ -61,7 +60,6 @@ import { prepareOfframpTransactions } from "../transactions/offramp"; import { prepareOnrampTransactions } from "../transactions/onramp"; import { AveniaOnrampTransactionParams } from "../transactions/onramp/common/types"; import { prepareMykoboToEvmOnrampTransactions } from "../transactions/onramp/routes/mykobo-to-evm"; -import { prepareMykoboToEvmMorphoOnrampTransactions } from "../transactions/onramp/routes/mykobo-to-evm-morpho"; import { validatePresignedTxs } from "../transactions/validation"; import webhookDeliveryService from "../webhook/webhook-delivery.service"; import { BaseRampService } from "./base.service"; @@ -1172,31 +1170,17 @@ export class RampService extends BaseRampService { let unsignedTxs; let stateMeta; - if (quote.outputCurrency === EvmToken.MORPHO_VAULT) { - const result = await prepareMykoboToEvmMorphoOnrampTransactions({ - destinationAddress: additionalData.destinationAddress, - ipAddress: additionalData.ipAddress, - mykoboEmail: additionalData.email, - mykoboTransactionId: intent.transaction.id, - mykoboTransactionReference: intent.transaction.reference, - quote, - signingAccounts: normalizedSigningAccounts - }); - unsignedTxs = result.unsignedTxs; - stateMeta = result.stateMeta; - } else { - const result = await prepareMykoboToEvmOnrampTransactions({ - destinationAddress: additionalData.destinationAddress, - ipAddress: additionalData.ipAddress, - mykoboEmail: additionalData.email, - mykoboTransactionId: intent.transaction.id, - mykoboTransactionReference: intent.transaction.reference, - quote, - signingAccounts: normalizedSigningAccounts - }); - unsignedTxs = result.unsignedTxs; - stateMeta = result.stateMeta; - } + const result = await prepareMykoboToEvmOnrampTransactions({ + destinationAddress: additionalData.destinationAddress, + ipAddress: additionalData.ipAddress, + mykoboEmail: additionalData.email, + mykoboTransactionId: intent.transaction.id, + mykoboTransactionReference: intent.transaction.reference, + quote, + signingAccounts: normalizedSigningAccounts + }); + unsignedTxs = result.unsignedTxs; + stateMeta = result.stateMeta; const ibanPaymentData: IbanPaymentData = { bic: "", @@ -1294,15 +1278,12 @@ export class RampService extends BaseRampService { status: httpStatus.BAD_REQUEST }); } else if (rampState.from !== Networks.AssetHub) { - const isMorpho = rampState.unsignedTxs.some(tx => tx.phase === "morphoPermitExecute" || tx.phase === "morphoRedeem"); - if (!isMorpho) { - const requiresSquidSwapHash = rampState.unsignedTxs.some(tx => tx.phase === "squidRouterSwap"); - if (requiresSquidSwapHash && !rampState.state.squidRouterSwapHash) { - throw new APIError({ - message: `Missing required additional data 'squidRouterSwapHash' for ${rampState.type} ramp. Cannot proceed.`, - status: httpStatus.BAD_REQUEST - }); - } + const requiresSquidSwapHash = rampState.unsignedTxs.some(tx => tx.phase === "squidRouterSwap"); + if (requiresSquidSwapHash && !rampState.state.squidRouterSwapHash) { + throw new APIError({ + message: `Missing required additional data 'squidRouterSwapHash' for ${rampState.type} ramp. Cannot proceed.`, + status: httpStatus.BAD_REQUEST + }); } } } diff --git a/apps/api/src/api/services/transactions/offramp/index.ts b/apps/api/src/api/services/transactions/offramp/index.ts index 886ffc1ca..1e48a6878 100644 --- a/apps/api/src/api/services/transactions/offramp/index.ts +++ b/apps/api/src/api/services/transactions/offramp/index.ts @@ -1,5 +1,4 @@ import { - EvmToken, FiatToken, getNetworkFromDestination, getOnChainTokenDetails, @@ -12,7 +11,6 @@ import { prepareAssethubToBRLOfframpTransactions } from "./routes/assethub-to-br import { prepareEvmToAlfredpayOfframpTransactions } from "./routes/evm-to-alfredpay"; import { prepareEvmToBRLOfframpBaseTransactions } from "./routes/evm-to-brl-base"; import { prepareEvmToMykoboOfframpTransactions } from "./routes/evm-to-mykobo"; -import { prepareEvmToMykoboMorphoOfframpTransactions } from "./routes/evm-to-mykobo-morpho"; export async function prepareOfframpTransactions(params: OfframpTransactionParams): Promise { const { quote } = params; @@ -31,10 +29,6 @@ export async function prepareOfframpTransactions(params: OfframpTransactionParam return prepareAssethubToBRLOfframpTransactions(params); } } else if (quote.outputCurrency === FiatToken.EURC) { - // Morpho vault share source (Ethereum) → redeem + bridge to Base → Mykobo payout - if (quote.inputCurrency === EvmToken.MORPHO_VAULT) { - return prepareEvmToMykoboMorphoOfframpTransactions(params); - } // Mykobo EUR offramp on Base (EVM-only path) const inputTokenDetails = getOnChainTokenDetails(fromNetwork, quote.inputCurrency as OnChainToken); if (!inputTokenDetails || !isEvmTokenDetails(inputTokenDetails)) { 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 a8f23c2fa..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 @@ -70,7 +70,7 @@ export function getRelayerAddress(network: EvmNetworks): `0x${string}` { * Supports three formats: * - Standard (OZ ERC20Permit): name, version, chainId, verifyingContract * - Salt-based (e.g. USDT on Polygon): name, version, verifyingContract, salt - * - Minimal (e.g. Morpho VaultV2): chainId, verifyingContract + * - Minimal: chainId, verifyingContract */ export async function resolvePermitDomain( publicClient: PublicClient, diff --git a/apps/api/src/api/services/transactions/offramp/routes/evm-to-mykobo-morpho.ts b/apps/api/src/api/services/transactions/offramp/routes/evm-to-mykobo-morpho.ts deleted file mode 100644 index a8e5e570d..000000000 --- a/apps/api/src/api/services/transactions/offramp/routes/evm-to-mykobo-morpho.ts +++ /dev/null @@ -1,453 +0,0 @@ -import { - createOfframpSquidrouterTransactionsToEvm, - EvmClientManager, - EvmNetworks, - EvmToken, - EvmTransactionData, - evmTokenConfig, - getNetworkId, - isWithdrawInstructions, - MykoboApiService, - MykoboCurrency, - MykoboTransactionType, - multiplyByPowerOfTen, - Networks, - SignedTypedData, - UnsignedTx -} from "@vortexfi/shared"; -import Big from "big.js"; -import httpStatus from "http-status"; -import { encodeFunctionData } from "viem"; -import { APIError } from "../../../../errors/api-error"; -import { syncMykoboCustomerKyc } from "../../../mykobo/mykobo-customer.service"; -import { getEvmFundingAccount } from "../../../phases/evm-funding"; -import { getMorphoVaultInfo } from "../../../phases/handlers/morpho-vault-config"; -import { StateMetadata } from "../../../phases/meta-state-types"; -import { EUR_OFFRAMP_BASE_MORPHO, EUR_OFFRAMP_MORPHO } from "../../../phases/ramp-flow-definitions"; -import { prepareBaseCleanupApproval } from "../../base/cleanup"; -import { addEvmFeeDistributionTransaction } from "../../common/feeDistribution"; -import { encodeEvmTransactionData } from "../../index"; -import { addNablaSwapTransactionsOnBase, addOnrampDestinationChainTransactions } from "../../onramp/common/transactions"; -import { OfframpTransactionParams, OfframpTransactionsWithMeta } from "../common/types"; -import { resolvePermitDomain } from "./evm-to-alfredpay"; - -const erc20Abi = [ - { - inputs: [{ name: "account", type: "address" }], - name: "balanceOf", - outputs: [{ name: "", type: "uint256" }], - stateMutability: "view", - type: "function" - }, - { - inputs: [{ name: "owner", type: "address" }], - name: "nonces", - outputs: [{ name: "", type: "uint256" }], - stateMutability: "view", - type: "function" - }, - { inputs: [], name: "name", outputs: [{ name: "", type: "string" }], stateMutability: "view", type: "function" } -] as const; - -const REDEEM_SLIPPAGE_BPS = 50; // 0.5% — covers interest accrual between quote-time and redeem-time (0% vault fee assumed) - -const vaultRedeemAbi = [ - { - inputs: [ - { name: "shares", type: "uint256" }, - { name: "receiver", type: "address" }, - { name: "owner", type: "address" } - ], - name: "redeem", - outputs: [{ name: "assets", type: "uint256" }], - stateMutability: "nonpayable", - type: "function" - } -] as const; - -/** - * Prepares all transactions for an offramp from Morpho vault shares on any EVM - * chain → SEPA payout via Mykobo on Base. - * - * Flow: - * 1. User signs a single EIP-2612 Permit typed data on the vault (spender = ephemeral). - * This is submitted as a presignedTx (SignedTypedData) at registration. - * 2. morphoPermitExecute handler broadcasts: - * a) vault.permit(owner, spender, value, deadline, v, r, s) using the executor key — - * permit() does not require the spender to be the caller, so this is fine. - * b) vault.transferFrom(user, ephemeral, shares) signed by the ephemeral (presignedTx, - * nonce 0 on the vault's chain). Only the spender can call transferFrom on its own - * allowance. - * 3. morphoRedeem: ephemeral calls vault.redeem(shares, ephemeral, ephemeral) → USDC on - * the vault's chain. - * 4. If the vault is NOT on Base, SquidRouter bridge brings the USDC to Base. If the - * vault IS on Base, this step is skipped. - * 5. On Base: distribute fees, swap USDC→EURC via Nabla, transfer EURC to Mykobo, cleanups. - */ -export async function prepareEvmToMykoboMorphoOfframpTransactions({ - quote, - signingAccounts, - userAddress, - email, - destinationAddress, - ipAddress, - userId -}: OfframpTransactionParams): Promise { - const unsignedTxs: UnsignedTx[] = []; - let stateMeta: Partial = {}; - - if (!email) { - throw new APIError({ - isPublic: true, - message: "email must be provided for Morpho EUR offramp (Mykobo)", - status: httpStatus.BAD_REQUEST - }); - } - if (!ipAddress) { - throw new APIError({ - isPublic: true, - message: "ipAddress must be provided for Morpho EUR offramp (Mykobo)", - status: httpStatus.BAD_REQUEST - }); - } - if (!destinationAddress) { - throw new APIError({ - isPublic: true, - message: "destinationAddress (user receiving wallet) must be provided for Morpho EUR offramp", - status: httpStatus.BAD_REQUEST - }); - } - if (!userAddress) { - throw new Error("User address must be provided for Morpho offramping"); - } - - const evmEphemeralEntry = signingAccounts.find(account => account.type === "EVM"); - if (!evmEphemeralEntry) { - throw new Error("EVM ephemeral account not found for Morpho to Mykobo offramp"); - } - - const baseUsdcAddress = evmTokenConfig[Networks.Base][EvmToken.USDC]?.erc20AddressSourceChain; - if (!baseUsdcAddress) { - throw new Error("Invalid USDC configuration for Base in evmTokenConfig"); - } - const baseEurcAddress = evmTokenConfig[Networks.Base][EvmToken.EURC]?.erc20AddressSourceChain; - if (!baseEurcAddress) { - throw new Error("Invalid EURC configuration for Base in evmTokenConfig"); - } - const baseAxlUsdcAddress = evmTokenConfig[Networks.Base][EvmToken.AXLUSDC]?.erc20AddressSourceChain; - if (!baseAxlUsdcAddress) { - throw new Error("Invalid AXLUSDC configuration for Base in evmTokenConfig"); - } - - const morphoVault = getMorphoVaultInfo("usdc-arbitrum"); - const morphoNetwork = morphoVault.network as EvmNetworks; - const isBaseVault = morphoNetwork === Networks.Base; - - const evmClientManager = EvmClientManager.getInstance(); - const vaultClient = evmClientManager.getClient(morphoNetwork); - const vaultChainId = getNetworkId(morphoNetwork); - - if (!vaultChainId) { - throw new Error(`Could not resolve chain id for ${morphoNetwork}`); - } - - const userNonce = (await vaultClient.readContract({ - abi: erc20Abi, - address: morphoVault.vaultAddress, - args: [userAddress], - functionName: "nonces" - })) as bigint; - - const tokenName = (await vaultClient.readContract({ - abi: erc20Abi, - address: morphoVault.vaultAddress, - functionName: "name" - })) as string; - - const resolvedDomain = await resolvePermitDomain(vaultClient, morphoVault.vaultAddress, vaultChainId, tokenName); - - const sharesAmountRaw = quote.metadata.redeemMeta?.sharesAmountRaw; - if (!sharesAmountRaw) { - throw new Error("Missing quote.metadata.redeemMeta.sharesAmountRaw; was the offramp Morpho initialize engine run?"); - } - - if (!isBaseVault && !quote.metadata.evmToEvm?.inputAmountRaw) { - throw new Error("Missing evmToEvm.inputAmountRaw in quote metadata for Morpho offramp (bridge input)"); - } - - const permitDeadline = BigInt(Math.floor(Date.now() / 1000) + 24 * 60 * 60); - - // 1. User-signed EIP-2612 Permit typed data (spender = ephemeral). - // Submitted as a presignedTx (SignedTypedData) at registration. The handler validates the - // typed data, then uses the embedded v/r/s to call vault.permit via the executor key. - const permitTypedData: SignedTypedData = { - domain: resolvedDomain, - message: { - deadline: permitDeadline.toString(), - nonce: userNonce.toString(), - owner: userAddress, - spender: evmEphemeralEntry.address, - value: sharesAmountRaw - }, - primaryType: "Permit", - types: { - Permit: [ - { name: "owner", type: "address" }, - { name: "spender", type: "address" }, - { name: "value", type: "uint256" }, - { name: "nonce", type: "uint256" }, - { name: "deadline", type: "uint256" } - ] - } - }; - - unsignedTxs.push({ - meta: {}, - network: morphoNetwork, - nonce: 0, - phase: "morphoPermitExecute", - signer: userAddress, - txData: permitTypedData - }); - - // 2. Ephemeral-signed transferFrom presigned tx. Only the spender (the ephemeral) can call - // transferFrom on the allowance granted by permit(), so this must be ephemeral-signed. - // Phase + signer match the existing alfredpay direct-transfer pattern; the handler reads - // the user-signed permit entry and the ephemeral-signed transferFrom entry together. - const { maxFeePerGas, maxPriorityFeePerGas } = await vaultClient.estimateFeesPerGas(); - - const transferFromCallData = encodeFunctionData({ - abi: [ - { - inputs: [ - { name: "from", type: "address" }, - { name: "to", type: "address" }, - { name: "value", type: "uint256" } - ], - name: "transferFrom", - outputs: [{ name: "", type: "bool" }], - stateMutability: "nonpayable", - type: "function" - } - ], - args: [userAddress as `0x${string}`, evmEphemeralEntry.address as `0x${string}`, BigInt(sharesAmountRaw)], - functionName: "transferFrom" - }); - - unsignedTxs.push({ - meta: {}, - network: morphoNetwork, - nonce: 0, - phase: "morphoPermitExecute", - signer: evmEphemeralEntry.address, - txData: encodeEvmTransactionData({ - data: transferFromCallData, - gas: "200000", - maxFeePerGas: String(maxFeePerGas), - maxPriorityFeePerGas: String(maxPriorityFeePerGas), - to: morphoVault.vaultAddress, - value: "0" - }) as EvmTransactionData - }); - - // 3. morphoRedeem: ephemeral calls vault.redeem(shares, ephemeral, ephemeral) - // → USDC on the vault's chain. - const redeemCallData = encodeFunctionData({ - abi: vaultRedeemAbi, - args: [BigInt(sharesAmountRaw), evmEphemeralEntry.address as `0x${string}`, evmEphemeralEntry.address as `0x${string}`], - functionName: "redeem" - }); - - let morphoRedeemNonce = 1; - unsignedTxs.push({ - meta: {}, - network: morphoNetwork, - nonce: morphoRedeemNonce++, - phase: "morphoRedeem", - signer: evmEphemeralEntry.address, - txData: encodeEvmTransactionData({ - data: redeemCallData, - gas: "500000", - maxFeePerGas: String(maxFeePerGas), - maxPriorityFeePerGas: String(maxPriorityFeePerGas), - to: morphoVault.vaultAddress, - value: "0" - }) as EvmTransactionData - }); - - // 4. SquidRouter bridge: only when the vault is on a chain other than Base. - let squidRouterQuoteId: string | undefined; - if (!isBaseVault) { - const bridgeInputAmountRaw = quote.metadata.evmToEvm?.inputAmountRaw; - if (!bridgeInputAmountRaw) { - throw new Error("Missing evmToEvm.inputAmountRaw in quote metadata for Morpho offramp (bridge input)"); - } - const { - approveData, - swapData, - squidRouterQuoteId: quoteId - } = await createOfframpSquidrouterTransactionsToEvm({ - destinationAddress: evmEphemeralEntry.address, - fromAddress: evmEphemeralEntry.address, - fromNetwork: morphoNetwork, - fromToken: morphoVault.depositAssetAddress as `0x${string}`, - rawAmount: bridgeInputAmountRaw, - toNetwork: Networks.Base, - toToken: baseUsdcAddress as `0x${string}` - }); - squidRouterQuoteId = quoteId; - - unsignedTxs.push({ - meta: {}, - network: morphoNetwork, - nonce: morphoRedeemNonce++, - phase: "squidRouterApprove", - signer: evmEphemeralEntry.address, - txData: encodeEvmTransactionData(approveData) as EvmTransactionData - }); - - unsignedTxs.push({ - meta: {}, - network: morphoNetwork, - nonce: morphoRedeemNonce++, - phase: "squidRouterSwap", - signer: evmEphemeralEntry.address, - txData: encodeEvmTransactionData(swapData) as EvmTransactionData - }); - - // 5. Cleanup USDC on the vault's chain so the funding account can sweep any leftovers. - const vaultFundingAccount = getEvmFundingAccount(morphoNetwork); - const vaultChainUsdcCleanup = await prepareBaseCleanupApproval( - morphoVault.depositAssetAddress as `0x${string}`, - vaultFundingAccount.address, - morphoNetwork - ); - unsignedTxs.push({ - meta: {}, - network: morphoNetwork, - nonce: morphoRedeemNonce++, - phase: "ethereumCleanupUsdc", - signer: evmEphemeralEntry.address, - txData: encodeEvmTransactionData(vaultChainUsdcCleanup) as EvmTransactionData - }); - } - - // 6. Mykobo intent (must precede any user-signed tx so failures abort early). - const mykoboIntentValue = quote.metadata.nablaSwapEvm?.outputAmountDecimal; - if (!mykoboIntentValue) { - throw new Error("Missing nablaSwapEvm.outputAmountDecimal in quote metadata for Morpho offramp"); - } - const mykoboFlooredValue = new Big(mykoboIntentValue).toFixed(2, 0); - const eurcDecimals = evmTokenConfig[Networks.Base][EvmToken.EURC]?.decimals; - if (eurcDecimals === undefined) { - throw new Error("Invalid EURC decimals configuration for Base in evmTokenConfig"); - } - const eurcTransferAmountRaw = multiplyByPowerOfTen(new Big(mykoboFlooredValue), eurcDecimals).toFixed(0, 0); - - const mykobo = MykoboApiService.getInstance(); - const intent = await mykobo.createTransactionIntent({ - currency: MykoboCurrency.EURC, - email_address: email, - ip_address: ipAddress, - transaction_type: MykoboTransactionType.WITHDRAW, - value: mykoboFlooredValue, - wallet_address: evmEphemeralEntry.address - }); - - if (!isWithdrawInstructions(intent.instructions)) { - throw new Error("Mykobo intent did not return withdraw instructions; cannot derive receivables address"); - } - // Mocking Mykobo intent call - // const mykoboReceivablesAddress = "0x7Ba99e99Bc669B3508AFf9CC0A898E869459F877"; //intent.instructions.address; - // const mykoboTransactionId = "mockTransactionId"; //intent.transaction.id; - // const mykoboTransactionReference = "mockTransactionReference"; //intent.transaction.reference; - - // 7. Base leg (fundEphemeral handled separately; Nabla + payout + cleanups). - // When the vault IS on Base, the ephemeral has already broadcast transferFrom (nonce 0) - // and redeem (nonce 1) on Base via the morpho phases, so continue from morphoRedeemNonce. - // When the vault is on a different chain, no ephemeral txs have hit Base yet → start at 0. - let baseNonce = isBaseVault ? morphoRedeemNonce : 0; - - baseNonce = await addEvmFeeDistributionTransaction(quote, evmEphemeralEntry, unsignedTxs, baseNonce); - - const { nextNonce: nonceAfterNabla, stateMeta: nablaStateMeta } = await addNablaSwapTransactionsOnBase( - { - account: evmEphemeralEntry, - inputTokenAddress: baseUsdcAddress, - outputTokenAddress: baseEurcAddress, - quote - }, - unsignedTxs, - baseNonce - ); - stateMeta = nablaStateMeta; - baseNonce = nonceAfterNabla; - - const payoutTransfer = await addOnrampDestinationChainTransactions({ - amountRaw: eurcTransferAmountRaw, - destinationNetwork: Networks.Base, - isNativeToken: false, - toAddress: mykoboReceivablesAddress as `0x${string}`, - toToken: baseEurcAddress as `0x${string}` - }); - - unsignedTxs.push({ - meta: {}, - network: Networks.Base, - nonce: baseNonce, - phase: "mykoboPayoutOnBase", - signer: evmEphemeralEntry.address, - txData: encodeEvmTransactionData(payoutTransfer) as EvmTransactionData - }); - baseNonce++; - - const baseFundingAccount = getEvmFundingAccount(Networks.Base); - const cleanupTokens = [ - { address: baseUsdcAddress, phase: "baseCleanupUsdc" as const }, - { address: baseEurcAddress, phase: "baseCleanupEurc" as const }, - { address: baseAxlUsdcAddress, phase: "baseCleanupAxlUsdc" as const } - ]; - - for (const { address, phase } of cleanupTokens) { - const approval = await prepareBaseCleanupApproval(address as `0x${string}`, baseFundingAccount.address, Networks.Base); - unsignedTxs.push({ - meta: {}, - network: Networks.Base, - nonce: baseNonce++, - phase, - signer: evmEphemeralEntry.address, - txData: encodeEvmTransactionData(approval) as EvmTransactionData - }); - } - - const morphoRedeemMinOutputRaw = quote.metadata.redeemMeta?.expectedUsdcRaw - ? (BigInt(quote.metadata.redeemMeta.expectedUsdcRaw) * BigInt(10000 - REDEEM_SLIPPAGE_BPS)) / BigInt(10000) - : "0"; - - stateMeta = { - ...stateMeta, - destinationAddress, - evmEphemeralAddress: evmEphemeralEntry.address, - morphoNetwork, - morphoRedeemAssetAddress: morphoVault.depositAssetAddress, - morphoRedeemMinOutputRaw: morphoRedeemMinOutputRaw.toString(), - morphoRedeemSharesAmountRaw: sharesAmountRaw, - morphoRedeemShareTokenAddress: morphoVault.vaultAddress, - morphoRedeemVaultAddress: morphoVault.vaultAddress, - mykoboEmail: email, - mykoboReceivablesAddress, - mykoboTransactionId, - mykoboTransactionReference, - phaseFlow: isBaseVault ? EUR_OFFRAMP_BASE_MORPHO : EUR_OFFRAMP_MORPHO, - squidRouterQuoteId, - walletAddress: userAddress - }; - - // TODO we're missing a backup path for the morpho redeem. - - if (userId) { - await syncMykoboCustomerKyc(userId, email); - } - - return { stateMeta, unsignedTxs }; -} diff --git a/apps/api/src/api/services/transactions/onramp/routes/mykobo-to-evm-morpho.ts b/apps/api/src/api/services/transactions/onramp/routes/mykobo-to-evm-morpho.ts deleted file mode 100644 index 0bc99f7fd..000000000 --- a/apps/api/src/api/services/transactions/onramp/routes/mykobo-to-evm-morpho.ts +++ /dev/null @@ -1,260 +0,0 @@ -import { - createOnrampSquidrouterTransactionsFromBaseToEvm, - EvmClientManager, - EvmNetworks, - EvmToken, - EvmTokenDetails, - EvmTransactionData, - evmTokenConfig, - isEvmTokenDetails, - Networks, - UnsignedTx -} from "@vortexfi/shared"; -import { encodeFunctionData, erc20Abi } from "viem"; -import logger from "../../../../../config/logger"; -import { getEvmFundingAccount } from "../../../phases/evm-funding"; -import { getMorphoVaultInfo } from "../../../phases/handlers/morpho-vault-config"; -import { StateMetadata } from "../../../phases/meta-state-types"; -import { EUR_ONRAMP_BASE_MORPHO, EUR_ONRAMP_MORPHO } from "../../../phases/ramp-flow-definitions"; -import { prepareBaseCleanupApproval } from "../../base/cleanup"; -import { addEvmFeeDistributionTransaction } from "../../common/feeDistribution"; -import { encodeEvmTransactionData } from "../../index"; -import { addNablaSwapTransactionsOnBase } from "../common/transactions"; -import { MykoboOnrampTransactionParams, OnrampTransactionsWithMeta } from "../common/types"; -import { validateMykoboOnramp } from "../common/validation"; - -const morphoVaultAbi = [ - { - inputs: [ - { name: "assets", type: "uint256" }, - { name: "receiver", type: "address" } - ], - name: "deposit", - outputs: [{ name: "shares", type: "uint256" }], - stateMutability: "nonpayable", - type: "function" - } -] as const; - -/** - * Prepares all transactions for a Mykobo (EUR) onramp that deposits into a Morpho vault. - * - * Two variants: - * - Base vault: user SEPA deposit → EURC on Base ephemeral → Nabla swap EURC→USDC - * → Morpho vault deposit on Base. No SquidRouter bridge. - * - Non-Base vault: same as above, but then SquidRouter bridges USDC from Base to - * the vault's chain, where the Morpho deposit executes. - */ -export async function prepareMykoboToEvmMorphoOnrampTransactions({ - quote, - signingAccounts, - destinationAddress, - mykoboEmail, - mykoboTransactionId, - mykoboTransactionReference -}: MykoboOnrampTransactionParams & { - mykoboTransactionId: string; - mykoboTransactionReference: string; -}): Promise { - let stateMeta: Partial = {}; - const unsignedTxs: UnsignedTx[] = []; - - const { evmEphemeralEntry, inputTokenDetails } = validateMykoboOnramp(quote, signingAccounts); - logger.debug(`Starting prepareMykoboToEvmMorphoOnrampTransactions with destinationAddress: ${destinationAddress}`); - - if (!isEvmTokenDetails(inputTokenDetails)) { - throw new Error(`Input token must be an EVM token for Morpho onramp, got ${inputTokenDetails.assetSymbol}`); - } - - if (!quote.metadata.nablaSwapEvm?.outputAmountRaw) { - throw new Error("Missing nablaSwapEvm.outputAmountRaw in quote metadata for Morpho onramp"); - } - - const morphoVault = getMorphoVaultInfo("usdc-arbitrum"); - const morphoNetwork = morphoVault.network as EvmNetworks; - const isBaseVault = morphoNetwork === Networks.Base; - - // For Base vaults, the deposit amount equals the Nabla swap output directly. For - // non-Base vaults, the deposit amount is the bridge output (USDC that lands on the - // vault's chain after the SquidRouter bridge). - const depositAmountRaw = isBaseVault - ? (quote.metadata.nablaSwapEvm.outputAmountRaw as string) - : quote.metadata.evmToEvm?.outputAmountRaw; - if (!depositAmountRaw) { - throw new Error( - isBaseVault - ? "Missing nablaSwapEvm.outputAmountRaw in quote metadata for Base Morpho onramp" - : "Missing evmToEvm.outputAmountRaw in quote metadata for Morpho onramp" - ); - } - - const bridgeInputAmountRaw = isBaseVault ? null : quote.metadata.evmToEvm?.inputAmountRaw; - if (!isBaseVault && !bridgeInputAmountRaw) { - throw new Error("Missing evmToEvm.inputAmountRaw in quote metadata for Morpho onramp"); - } - - stateMeta = { - destinationAddress, - evmEphemeralAddress: evmEphemeralEntry.address, - isDirectTransfer: false, - morphoDepositAmountRaw: depositAmountRaw, - morphoDepositAssetAddress: morphoVault.depositAssetAddress, - morphoNetwork, - morphoShareTokenAddress: morphoVault.vaultAddress, - morphoVaultAddress: morphoVault.vaultAddress, - mykoboEmail, - mykoboTransactionId, - mykoboTransactionReference, - walletAddress: destinationAddress - }; - - let baseNonce = 0; - - const nablaSwapOutputTokenAddress = evmTokenConfig[Networks.Base][EvmToken.USDC]?.erc20AddressSourceChain; - if (!nablaSwapOutputTokenAddress) { - throw new Error("Invalid USDC configuration for Base in evmTokenConfig"); - } - const eurcInputTokenAddress = (inputTokenDetails as EvmTokenDetails).erc20AddressSourceChain; - - // 1. Nabla Swap: EURC -> USDC on Base - const { nextNonce: nonceAfterNabla, stateMeta: nablaStateMeta } = await addNablaSwapTransactionsOnBase( - { - account: evmEphemeralEntry, - inputTokenAddress: eurcInputTokenAddress, - outputTokenAddress: nablaSwapOutputTokenAddress, - quote - }, - unsignedTxs, - baseNonce - ); - stateMeta = { ...stateMeta, ...nablaStateMeta }; - baseNonce = nonceAfterNabla; - - // 2. Fee Distribution on Base - baseNonce = await addEvmFeeDistributionTransaction(quote, evmEphemeralEntry, unsignedTxs, baseNonce); - - // 3. SquidRouter bridge (only when the vault is on a chain other than Base). - let squidRouterQuoteId: string | undefined; - if (!isBaseVault) { - const { - approveData, - swapData, - squidRouterQuoteId: quoteId - } = await createOnrampSquidrouterTransactionsFromBaseToEvm({ - destinationAddress: evmEphemeralEntry.address, - fromAddress: evmEphemeralEntry.address, - fromToken: nablaSwapOutputTokenAddress, - rawAmount: bridgeInputAmountRaw as string, - toNetwork: morphoNetwork, - toToken: morphoVault.depositAssetAddress as `0x${string}` - }); - squidRouterQuoteId = quoteId; - - unsignedTxs.push({ - meta: {}, - network: Networks.Base, - nonce: baseNonce++, - phase: "squidRouterApprove", - signer: evmEphemeralEntry.address, - txData: encodeEvmTransactionData(approveData) as EvmTransactionData - }); - - unsignedTxs.push({ - meta: {}, - network: Networks.Base, - nonce: baseNonce++, - phase: "squidRouterSwap", - signer: evmEphemeralEntry.address, - txData: encodeEvmTransactionData(swapData) as EvmTransactionData - }); - } - - // 4. Base Cleanups - const baseFundingAccountAddress = getEvmFundingAccount(Networks.Base).address; - - const eurcCleanupApproval = await prepareBaseCleanupApproval( - eurcInputTokenAddress as `0x${string}`, - baseFundingAccountAddress, - Networks.Base - ); - unsignedTxs.push({ - meta: {}, - network: Networks.Base, - nonce: baseNonce++, - phase: "baseCleanupEurc", - signer: evmEphemeralEntry.address, - txData: encodeEvmTransactionData(eurcCleanupApproval) as EvmTransactionData - }); - - const usdcCleanupApproval = await prepareBaseCleanupApproval( - nablaSwapOutputTokenAddress as `0x${string}`, - baseFundingAccountAddress, - Networks.Base - ); - unsignedTxs.push({ - meta: {}, - network: Networks.Base, - nonce: baseNonce++, - phase: "baseCleanupUsdc", - signer: evmEphemeralEntry.address, - txData: encodeEvmTransactionData(usdcCleanupApproval) as EvmTransactionData - }); - - // 5. Vault chain (morphoNetwork) transactions - const morphoClient = EvmClientManager.getInstance().getClient(morphoNetwork); - const { maxFeePerGas, maxPriorityFeePerGas } = await morphoClient.estimateFeesPerGas(); - - // Morpho approval: approve vault to spend the deposit asset (USDC) - const approveCallData = encodeFunctionData({ - abi: erc20Abi, - args: [morphoVault.vaultAddress, BigInt(depositAmountRaw)], - functionName: "approve" - }); - - unsignedTxs.push({ - meta: {}, - network: morphoNetwork, - nonce: isBaseVault ? baseNonce : 0, - phase: "morphoApprove", - signer: evmEphemeralEntry.address, - txData: { - data: approveCallData as `0x${string}`, - gas: "100000", - maxFeePerGas: String(maxFeePerGas), - maxPriorityFeePerGas: String(maxPriorityFeePerGas), - to: morphoVault.depositAssetAddress as `0x${string}`, - value: "0" - } as EvmTransactionData - }); - - // Morpho deposit: deposit USDC into the vault on morphoNetwork - const depositCallData = encodeFunctionData({ - abi: morphoVaultAbi, - args: [BigInt(depositAmountRaw), destinationAddress as `0x${string}`], - functionName: "deposit" - }); - - unsignedTxs.push({ - meta: {}, - network: morphoNetwork, - nonce: isBaseVault ? baseNonce + 1 : 1, - phase: "morphoDeposit", - signer: evmEphemeralEntry.address, - txData: { - data: depositCallData as `0x${string}`, - gas: "500000", - maxFeePerGas: String(maxFeePerGas), - maxPriorityFeePerGas: String(maxPriorityFeePerGas), - to: morphoVault.vaultAddress as `0x${string}`, - value: "0" - } as EvmTransactionData - }); - - stateMeta = { - ...stateMeta, - phaseFlow: isBaseVault ? EUR_ONRAMP_BASE_MORPHO : EUR_ONRAMP_MORPHO, - squidRouterQuoteId - }; - - return { stateMeta, unsignedTxs }; -} diff --git a/apps/api/src/api/services/transactions/validation.ts b/apps/api/src/api/services/transactions/validation.ts index e49923b01..e5193b0ac 100644 --- a/apps/api/src/api/services/transactions/validation.ts +++ b/apps/api/src/api/services/transactions/validation.ts @@ -231,10 +231,6 @@ function getTransactionTypeForPhase(phase: RampPhase | CleanupPhase, network: Ne case "baseCleanupAxlUsdc": case "alfredOnrampMintFallback": case "alfredpayOfframpTransferFallback": - case "morphoApprove": - case "morphoDeposit": - case "morphoPermitExecute": - case "morphoRedeem": case "ethereumCleanupUsdc": return EphemeralAccountType.EVM; default: diff --git a/apps/frontend/src/assets/coins/MORPHO.svg b/apps/frontend/src/assets/coins/MORPHO.svg deleted file mode 100644 index ae79f52a1..000000000 --- a/apps/frontend/src/assets/coins/MORPHO.svg +++ /dev/null @@ -1,19 +0,0 @@ - - - - - - - - - - - - - - - - - - - diff --git a/apps/frontend/src/hooks/useGetAssetIcon.tsx b/apps/frontend/src/hooks/useGetAssetIcon.tsx index fe5045c80..31a34dab9 100644 --- a/apps/frontend/src/hooks/useGetAssetIcon.tsx +++ b/apps/frontend/src/hooks/useGetAssetIcon.tsx @@ -2,7 +2,6 @@ import ARS from "../assets/coins/ARS.png"; import BRL from "../assets/coins/BRL.png"; import COP from "../assets/coins/COP.png"; import EUR from "../assets/coins/EU.png"; -import MORPHO from "../assets/coins/MORPHO.svg"; import MXN from "../assets/coins/MXN.png"; import PLACEHOLDER from "../assets/coins/placeholder.svg"; import USD from "../assets/coins/USD.png"; @@ -12,7 +11,6 @@ const FIAT_ICONS: Record = { brl: BRL, cop: COP, eur: EUR, - morpho: MORPHO, mxn: MXN, usd: USD }; diff --git a/apps/frontend/src/pages/progress/index.tsx b/apps/frontend/src/pages/progress/index.tsx index c18dd7e24..9695d33be 100644 --- a/apps/frontend/src/pages/progress/index.tsx +++ b/apps/frontend/src/pages/progress/index.tsx @@ -26,9 +26,6 @@ function getRampFlow(rampState: RampState | undefined): keyof typeof PHASE_FLOWS if (rampState.quote?.inputCurrency === FiatToken.BRL) { return "onramp_brl"; } - if (rampState.quote?.outputCurrency === "MORPHO VAULT") { - return "onramp_eur_morpho"; - } return "onramp_eur_evm"; } @@ -37,9 +34,6 @@ function getRampFlow(rampState: RampState | undefined): keyof typeof PHASE_FLOWS } if (rampState.quote?.outputCurrency === FiatToken.EURC) { - if (rampState.quote?.inputCurrency === "MORPHO VAULT") { - return "offramp_eur_morpho"; - } return "offramp_eur_evm"; } diff --git a/apps/frontend/src/pages/progress/phaseFlows.ts b/apps/frontend/src/pages/progress/phaseFlows.ts index 8915c2740..9538b902d 100644 --- a/apps/frontend/src/pages/progress/phaseFlows.ts +++ b/apps/frontend/src/pages/progress/phaseFlows.ts @@ -23,10 +23,6 @@ export const PHASE_DURATIONS: Record = { initial: 0, moonbeamToPendulum: 40, moonbeamToPendulumXcm: 30, - morphoApprove: 0, - morphoDeposit: 30, - morphoPermitExecute: 30, - morphoRedeem: 30, mykoboOnrampDeposit: 5 * 60, mykoboPayoutOnBase: 60, nablaApprove: 24, @@ -71,22 +67,6 @@ export const PHASE_FLOWS = { "complete" ] as RampPhase[], - offramp_eur_morpho: [ - "initial", - "morphoPermitExecute", - "morphoRedeem", - "squidRouterApprove", - "squidRouterSwap", - "fundEphemeral", - "distributeFees", - "subsidizePreSwap", - "nablaApprove", - "nablaSwap", - "subsidizePostSwap", - "mykoboPayoutOnBase", - "complete" - ] as RampPhase[], - onramp_brl: [ "initial", "brlaOnrampMint", @@ -111,6 +91,7 @@ export const PHASE_FLOWS = { "subsidizePreSwap", "nablaApprove", "nablaSwap", + "distributeFees", "subsidizePostSwap", "squidRouterApprove", "squidRouterSwap", @@ -118,21 +99,5 @@ export const PHASE_FLOWS = { "distributeFees", "destinationTransfer", "complete" - ] as RampPhase[], - - onramp_eur_morpho: [ - "initial", - "mykoboOnrampDeposit", - "fundEphemeral", - "subsidizePreSwap", - "nablaApprove", - "nablaSwap", - "distributeFees", - "subsidizePostSwap", - "squidRouterSwap", - "squidRouterPay", - "finalSettlementSubsidy", - "morphoDeposit", - "complete" ] as RampPhase[] }; diff --git a/apps/frontend/src/pages/progress/phaseMessages.ts b/apps/frontend/src/pages/progress/phaseMessages.ts index e8603bf72..8af888520 100644 --- a/apps/frontend/src/pages/progress/phaseMessages.ts +++ b/apps/frontend/src/pages/progress/phaseMessages.ts @@ -84,10 +84,6 @@ export function getMessageForPhase(ramp: RampState | undefined, t: TFunction<"tr initial: t("pages.progress.initial"), moonbeamToPendulum: getMoonbeamToPendulumMessage(), moonbeamToPendulumXcm: getMoonbeamToPendulumMessage(), - morphoApprove: "", - morphoDeposit: t("pages.progress.morphoDeposit"), - morphoPermitExecute: t("pages.progress.morphoPermitExecute"), - morphoRedeem: t("pages.progress.morphoRedeem"), mykoboOnrampDeposit: t("pages.progress.mykoboOnrampDeposit"), mykoboPayoutOnBase: getTransferringMessage(), nablaApprove: getSwappingMessage(), diff --git a/apps/frontend/src/services/balances/evmBalanceFetcher.ts b/apps/frontend/src/services/balances/evmBalanceFetcher.ts index be56c23c2..564e1b54a 100644 --- a/apps/frontend/src/services/balances/evmBalanceFetcher.ts +++ b/apps/frontend/src/services/balances/evmBalanceFetcher.ts @@ -2,10 +2,8 @@ import { ALCHEMY_API_KEY, doesNetworkSupportRamp, EvmNetworks, - EvmToken, EvmTokenDetails, getAllEvmTokens, - getTokenUsdPrice, isNetworkEVM, Networks } from "@vortexfi/shared"; @@ -13,7 +11,6 @@ import Big from "big.js"; import { hexToBigInt } from "viem"; import { multiplyByPowerOfTen } from "../../helpers/contracts"; import { getEvmTokenConfig } from "../tokens"; -import { fetchMorphoVaultShareBalance, MORPHO_VAULT_NETWORK } from "./morphoBalanceFetcher"; import { BalanceMap, getBalanceKey, TokenBalance } from "./types"; const ALCHEMY_NETWORK_MAP: Partial> = { @@ -109,22 +106,13 @@ export async function fetchEvmBalances(evmAddress: string): Promise let balance = "0.00"; let balanceUsd = "0.00"; - if (token.assetSymbol === EvmToken.MORPHO_VAULT && token.network === MORPHO_VAULT_NETWORK) { - const rawShares = await fetchMorphoVaultShareBalance(token.erc20AddressSourceChain, evmAddress as `0x${string}`); - if (rawShares !== null) { - balance = multiplyByPowerOfTen(Big(rawShares.toString()), -token.decimals).toFixed(6, 0); - const usdcPrice = getTokenUsdPrice(EvmToken.USDC) ?? 0; - balanceUsd = usdcPrice > 0 ? Big(balance).times(usdcPrice).toFixed(2, 0) : "0.00"; - } - } else { - const rawBalance = allEvmBalances.get(addressKey); - const showDecimals = token.assetSymbol.toLowerCase().includes("usd") ? 2 : 6; - balance = rawBalance ? multiplyByPowerOfTen(Big(rawBalance), -token.decimals).toFixed(showDecimals, 0) : "0.00"; - - const matchingToken = evmTokenLookup.get(addressKey); - const usdPrice = matchingToken?.usdPrice ?? 0; - balanceUsd = usdPrice > 0 ? Big(balance).times(usdPrice).toFixed(2, 0) : "0.00"; - } + const rawBalance = allEvmBalances.get(addressKey); + const showDecimals = token.assetSymbol.toLowerCase().includes("usd") ? 2 : 6; + balance = rawBalance ? multiplyByPowerOfTen(Big(rawBalance), -token.decimals).toFixed(showDecimals, 0) : "0.00"; + + const matchingToken = evmTokenLookup.get(addressKey); + const usdPrice = matchingToken?.usdPrice ?? 0; + 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/apps/frontend/src/services/balances/morphoBalanceFetcher.ts b/apps/frontend/src/services/balances/morphoBalanceFetcher.ts deleted file mode 100644 index 78151c7df..000000000 --- a/apps/frontend/src/services/balances/morphoBalanceFetcher.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { Networks } from "@vortexfi/shared"; -import { readContract } from "@wagmi/core"; -import { wagmiConfig } from "../../wagmiConfig"; - -const MORPHO_VAULT_ABI = [ - { - inputs: [{ name: "owner", type: "address" }], - name: "balanceOf", - outputs: [{ name: "", type: "uint256" }], - stateMutability: "view", - type: "function" - } -] as const; - -const ARBITRUM_CHAIN_ID = 42161; - -export async function fetchMorphoVaultShareBalance(vaultAddress: `0x${string}`, owner: `0x${string}`): Promise { - try { - const raw = (await readContract(wagmiConfig, { - abi: MORPHO_VAULT_ABI, - address: vaultAddress, - args: [owner], - chainId: ARBITRUM_CHAIN_ID, - functionName: "balanceOf" - })) as bigint; - - return raw; - } catch (error) { - console.error(`Error fetching Morpho vault share balance for ${vaultAddress}:`, error); - return null; - } -} - -export const MORPHO_VAULT_NETWORK = Networks.Arbitrum; diff --git a/apps/frontend/src/translations/en.json b/apps/frontend/src/translations/en.json index 7cd99b027..dc3e4fe66 100644 --- a/apps/frontend/src/translations/en.json +++ b/apps/frontend/src/translations/en.json @@ -1208,10 +1208,6 @@ "hydrationToAssethubXcm": "Transferring {{assetSymbol}} from Hydration --> AssetHub", "initial": "Starting process", "moonbeamToPendulum": "Transferring {{assetSymbol}} from Moonbeam --> Pendulum", - "morphoApprove": "Approving tokens for Morpho vault deposit", - "morphoDeposit": "Depositing into Morpho vault", - "morphoPermitExecute": "Moving Morpho vault shares to Vortex", - "morphoRedeem": "Redeeming Morpho vault shares for USDC", "mykoboOnrampDeposit": "Waiting to receive payment", "pendulumToAssethubXcm": "Transferring {{assetSymbol}} from Pendulum --> AssetHub", "pendulumToHydrationXcm": "Transferring {{assetSymbol}} from Pendulum --> Hydration", diff --git a/apps/frontend/src/translations/pt.json b/apps/frontend/src/translations/pt.json index ac92def9d..ff06500bb 100644 --- a/apps/frontend/src/translations/pt.json +++ b/apps/frontend/src/translations/pt.json @@ -1213,8 +1213,6 @@ "hydrationToAssethubXcm": "Transferindo {{assetSymbol}} de Hydration --> AssetHub", "initial": "Iniciando processo", "moonbeamToPendulum": "Transferindo {{assetSymbol}} de Moonbeam --> Pendulum", - "morphoApprove": "Aprovando tokens para depósito no Morpho vault", - "morphoDeposit": "Depositando no Morpho vault", "mykoboOnrampDeposit": "Aguardando recebimento do pagamento", "pendulumToAssethubXcm": "Transferindo {{assetSymbol}} de Pendulum --> AssetHub", "pendulumToHydrationXcm": "Transferindo {{assetSymbol}} de Pendulum --> Hydration", diff --git a/packages/shared/src/endpoints/ramp.endpoints.ts b/packages/shared/src/endpoints/ramp.endpoints.ts index a8bfcdb7f..ce680c850 100644 --- a/packages/shared/src/endpoints/ramp.endpoints.ts +++ b/packages/shared/src/endpoints/ramp.endpoints.ts @@ -51,10 +51,6 @@ export type RampPhase = | "backupSquidRouterApprove" | "backupSquidRouterSwap" | "backupApprove" - | "morphoApprove" - | "morphoDeposit" - | "morphoPermitExecute" - | "morphoRedeem" | "complete"; export type CleanupPhase = diff --git a/packages/shared/src/helpers/signUnsigned.ts b/packages/shared/src/helpers/signUnsigned.ts index 2c040557d..eaa1c44fb 100644 --- a/packages/shared/src/helpers/signUnsigned.ts +++ b/packages/shared/src/helpers/signUnsigned.ts @@ -223,9 +223,7 @@ export async function signUnsignedTransactions( (tx.phase === "destinationTransfer" || tx.phase === "backupSquidRouterApprove" || tx.phase === "backupSquidRouterSwap" || - tx.phase === "backupApprove" || - tx.phase === "morphoApprove" || - tx.phase === "morphoDeposit") && + tx.phase === "backupApprove") && tx.network !== Networks.Polygon && tx.network !== Networks.PolygonAmoy && tx.network !== Networks.Base diff --git a/packages/shared/src/tokens/evm/config.ts b/packages/shared/src/tokens/evm/config.ts index e80c4fc06..5bab9db4f 100644 --- a/packages/shared/src/tokens/evm/config.ts +++ b/packages/shared/src/tokens/evm/config.ts @@ -179,16 +179,6 @@ export const evmTokenConfig: Record Date: Thu, 18 Jun 2026 15:33:56 -0300 Subject: [PATCH 25/26] remove opencode config --- .opencode/agents/m3-implementor.md | 41 ------------------------------ 1 file changed, 41 deletions(-) delete mode 100644 .opencode/agents/m3-implementor.md diff --git a/.opencode/agents/m3-implementor.md b/.opencode/agents/m3-implementor.md deleted file mode 100644 index b6e710d2a..000000000 --- a/.opencode/agents/m3-implementor.md +++ /dev/null @@ -1,41 +0,0 @@ ---- -description: MiniMax M3 implementor for the Vortex monorepo. Receives a detailed architecture spec (file paths, function signatures, generics, implementation hints, existing files to port logic from) from the coordinator and produces working TypeScript that passes `bun typecheck` and `bun lint:fix`. Use for parallel implementation of well-scoped code chunks in the Vortex quote-engine block refactor. -mode: subagent -model: opencode-go/MiniMax-M3 -permission: - edit: allow - write: allow - bash: allow - read: allow - glob: allow - grep: allow - list: allow ---- - -You are the MiniMax M3 implementor for the Vortex monorepo. You implement code from a coordinator's spec; you do not design architecture. - -## Input you receive -A chunk spec containing: -- The exact file path(s) to create or modify -- Full function/interface signatures with generics -- Implementation hints (which existing files to read, which logic to port) -- Verification commands to run - -## Workflow -1. Read every existing file the spec references, plus neighboring files for convention. Mimic existing style exactly. -2. Implement exactly what the spec says — no scope creep, no speculative abstractions, no "improvements" to adjacent code. -3. Run `bun lint:fix` then `bun typecheck` (from the repo root) and iterate until both are green. If `packages/shared` was touched, run `bun build:shared` first. -4. Report back: file paths created/modified, a one-paragraph summary of what was implemented, and the final typecheck/lint status. Surface any deviations from the spec and why. - -## Rules (from CLAUDE.md — non-negotiable) -- Use `bun`, never npm/yarn/pnpm. -- Biome: line width 128, 2-space indent, semicolons always, double quotes, no trailing commas. -- DO NOT add comments unless the spec explicitly asks. No docstrings on code you didn't touch. -- Surgical changes: touch only what the spec requires. Don't refactor neighboring code. -- No over-engineering: no abstractions for single-use code, no error handling for impossible scenarios, no input validation for typed internal params. -- Three similar lines is better than a premature abstraction. -- `FiatToken` has 6 values (EURC, ARS, BRL, USD, MXN, COP); any `Record` must include all six. -- After ANY change to `packages/shared`, run `bun build:shared` before typecheck. - -## When to stop -When typecheck and lint pass and the spec is fully implemented. Return the summary. Do not continue into adjacent chunks — the coordinator assigns those. From 374bff3580de795109c75fc7399d1440cffc2087 Mon Sep 17 00:00:00 2001 From: Gianfranco Date: Mon, 6 Jul 2026 17:35:16 -0300 Subject: [PATCH 26/26] types improvements, re-implement example flow for refactor, invariance checks --- .../src/api/services/quote/blocks/README.md | 627 +++++++++--------- ...brl-onramp-base-cross-chain.parity.test.ts | 223 +++++++ .../eur-onramp-morpho.parity.test.ts | 241 ------- .../api/services/quote/blocks/core/flow.ts | 14 +- .../api/services/quote/blocks/core/types.ts | 9 +- .../flows/brl-onramp-base-cross-chain.ts | 35 + .../quote/blocks/flows/eur-onramp-morpho.ts | 36 - .../quote/blocks/phases/avenia-mint.ts | 301 +++++++++ .../blocks/phases/destination-transfer.ts | 186 ++++++ .../quote/blocks/phases/distribute-fees.ts | 164 ++++- .../blocks/phases/final-settlement-subsidy.ts | 311 +++++++++ .../quote/blocks/phases/fund-ephemeral.ts | 214 +++++- .../quote/blocks/phases/morpho-mint.ts | 68 -- .../quote/blocks/phases/nabla-swap.ts | 133 +++- .../quote/blocks/phases/squid-router-swap.ts | 581 +++++++++++++++- .../quote/blocks/phases/subsidize-post.ts | 167 +++++ .../quote/blocks/phases/subsidize-pre.ts | 152 ++++- 17 files changed, 2768 insertions(+), 694 deletions(-) create mode 100644 apps/api/src/api/services/quote/blocks/__tests__/brl-onramp-base-cross-chain.parity.test.ts delete mode 100644 apps/api/src/api/services/quote/blocks/__tests__/eur-onramp-morpho.parity.test.ts create mode 100644 apps/api/src/api/services/quote/blocks/flows/brl-onramp-base-cross-chain.ts delete mode 100644 apps/api/src/api/services/quote/blocks/flows/eur-onramp-morpho.ts create mode 100644 apps/api/src/api/services/quote/blocks/phases/avenia-mint.ts create mode 100644 apps/api/src/api/services/quote/blocks/phases/destination-transfer.ts delete mode 100644 apps/api/src/api/services/quote/blocks/phases/morpho-mint.ts diff --git a/apps/api/src/api/services/quote/blocks/README.md b/apps/api/src/api/services/quote/blocks/README.md index 6d3b10f84..487860bc5 100644 --- a/apps/api/src/api/services/quote/blocks/README.md +++ b/apps/api/src/api/services/quote/blocks/README.md @@ -6,24 +6,55 @@ 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 Morpho deposit on Arbitrum cannot follow a passthrough -that stayed on Base. The execution `RampPhase[]` is derived from the flow -(`["initial", ...flow.phases, "complete"]`), so the strategy, the execution -sequence, and the brand-correctness check all live in one place. - -**Scope of this POC:** the `EUR_ONRAMP_MORPHO` corridor (Mykobo SEPA → EURC -on Base → Nabla EURC→USDC → Squid bridge → Morpho vault deposit) in two -variants (cross-chain, base-vault). Lives entirely under +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/`**. - -**Status:** `8 pass, 1 skip, 0 fail` parity test. Compile-time adjacency -verified via a deliberately mis-ordered `@ts-expect-error` block in the -test. Both `assemblePhaseFlow(flow)` outputs deep-equal the hand-maintained -`EUR_ONRAMP_MORPHO` and `EUR_ONRAMP_BASE_MORPHO` arrays in -`phases/ramp-flow-definitions.ts`. The final `PhaseIO.meta` carries the -full `QuoteTicketMetadata` shape the old engines produced, so the existing -route-prep and handlers read it unchanged. +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. --- @@ -41,20 +72,21 @@ apps/api/src/api/services/quote/blocks/ combinators.ts # branch(), passthrough() fees.ts # computeFees(ctx) phase-flow.ts # assemblePhaseFlow(flow) -> RampPhase[] - phases/ - mykobo-mint.ts # fiat EUR -> EURC on Base - fund-ephemeral.ts # FundEphemeral() - subsidize-pre.ts # SubsidizePre() + computeSubsidyMeta - nabla-swap.ts # NablaSwap() - distribute-fees.ts # DistributeFees() - subsidize-post.ts # SubsidizePost() - squid-router-swap.ts # SquidRouterSwap() - final-settlement-subsidy.ts # FinalSettlementSubsidy() - morpho-mint.ts # MorphoMint() + 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/ - eur-onramp-morpho.ts # eurOnrampMorphoFlow + eurOnrampBaseMorphoFlow + brl-onramp-base-cross-chain.ts # makeBrlOnrampBaseCrossChainFlow(toChain, toToken) __tests__/ - eur-onramp-morpho.parity.test.ts # structural + parity + compile-time + simulate + brl-onramp-base-cross-chain.parity.test.ts # structure + parity + executors + compile-time + simulate ``` ### Core types (`core/types.ts`) @@ -71,35 +103,33 @@ export interface PhaseIO; // phase-specific (route id, oracle price, subsidy, ...) } -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[]; // declared execution expansion - simulate(input: I, ctx: PhaseCtx): Promise; + 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) over assembled phases + 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 -`EvmToken`, `FiatToken`, `AssetHubToken`, `PendulumCurrencyId`, and -`Networks` / `"Pendulum"` / `"Stellar"` / `"fiat"`. Brands are kept as -`extends string` so literal narrowing flows through generics. +`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`) @@ -109,26 +139,64 @@ 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 OutputOf

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

>(first: P): FlowBuilder>; + static start

(first: P): FlowBuilder>; pipe

>(next: P): FlowBuilder>; build(name: string): Flow; } ``` -`OutputOf

` uses a conditional `infer` rather than a second generic -parameter — `start

, O1>` widens `O1` to -`PhaseIO` (the constraint bound) during inference and severs the brand -chain at the first `.pipe()`. `OutputOf

` preserves the literal brand. +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)); +``` -Runtime: `build()` stores the phases; `Flow.simulate(ctx)` builds the first -input via `requestToIO(ctx)`, then sequentially calls -`phase.simulate(prevOutput, ctx)`. `Flow.phases` = -`phaseList.flatMap(p => p.phases)`. +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`) @@ -138,102 +206,80 @@ export function assemblePhaseFlow(flow: Flow): RampPhase[] { } ``` -That's the whole thing. No phase-name knowledge, no `isBaseVault` flag, no -`branch` logic. The developer is responsible for piping every step -(including funding, fee distribution, subsidy, final settlement) 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. +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`) -```ts -export function branch( - select: (ctx: PhaseCtx) => Promise | number, - branches: Phase[] -): Phase; - -export function passthrough(): Phase< - PhaseIO, - PhaseIO ->; -``` - -`branch` runtime-selects between phases; the output `O` is declared -explicitly (the common downstream shape) and each branch's `Phase` -must produce IO assignable to `O`. `branch.phases` = the order-preserving -union of all branches' `phases`. - -These are kept as available primitives but are **not relied upon** in the -POC flows — the cross-chain and base-vault variants are written as two -explicit flows rather than runtime branches. Reach for `branch` only when a -flow genuinely needs to fork at simulate time; prefer two flows otherwise. +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) — is a first-class `Phase`. -The flow assembles them linearly. - -| Phase | Type | `phases` | Meta keys written | Notes | -|-------|------|----------|------------------|-------| -| `MykoboMint` | `Phase, PhaseIO>` | `["mykoboOnrampDeposit"]` | `mykoboMint`, `fees` | Plain `const` (no generics). Ports `engines/initialize/onramp-mykobo.ts`. Also copies `ctx.fees` into meta. | -| `FundEphemeral()` | `Phase, PhaseIO>` | `["fundEphemeral"]` | (preserves) | Passthrough. Funding happens execution-side. | -| `SubsidizePre()` | `Phase, PhaseIO>` | `["subsidizePreSwap"]` | `subsidy` (partial: `expectedOutputAmountDecimal`, `expectedOutputAmountRaw`) | Computes expected output via `computeExpectedOutput(ctx)` — no meta read. Exports shared `buildFullSubsidy` + `computeExpectedOutput` helpers. | -| `NablaSwap(chain, in, out)` | `Phase, PhaseIO>` | `["nablaApprove", "nablaSwap"]` | `nablaSwapEvm` | Generic with runtime args. Ports `engines/nabla-swap/base-evm.ts` + `core/nabla.ts:calculateNablaSwapOutputEvm`. | -| `DistributeFees()` | `Phase, PhaseIO>` | `["distributeFees"]` | (preserves) | Reduces amount by `ctx.fees.usd`. | -| `SubsidizePost()` | `Phase, PhaseIO>` | `["subsidizePostSwap"]` | `subsidy` (full) | Independently computes expected via `computeExpectedOutput(ctx)`, builds and writes full subsidy. No meta read. | -| `SquidRouterSwap(from, to, token)` | `Phase, PhaseIO>` | `["squidRouterSwap", "squidRouterPay"]` | `evmToEvm` | Generic with runtime args. Ports `engines/squidrouter/onramp-base-to-evm.ts` + `core/squidrouter.ts:calculateEvmBridgeAndNetworkFee`. | -| `FinalSettlementSubsidy()` | `Phase, PhaseIO>` | `["finalSettlementSubsidy"]` | `subsidy` (full, overrides) | Same as `SubsidizePost` — independently computes expected, builds full subsidy. Overwrites any earlier `meta.subsidy`. | -| `MorphoMint()` | `Phase, PhaseIO>` | `["morphoDeposit"]` | `morphoDeposit` | Type-args only. Reads `input.chain` at runtime; calls `previewDeposit`. | - -### The two flows (`flows/eur-onramp-morpho.ts`) +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 const eurOnrampMorphoFlow: Flow = FlowBuilder.start(MykoboMint) - .pipe(FundEphemeral()) - .pipe(SubsidizePre()) - .pipe(NablaSwap(Networks.Base, EvmToken.EURC, EvmToken.USDC)) - .pipe(DistributeFees()) - .pipe(SubsidizePost()) - .pipe(SquidRouterSwap(Networks.Base, Networks.Arbitrum, EvmToken.USDC)) - .pipe(FinalSettlementSubsidy()) - .pipe(MorphoMint()) - .build("EurOnrampMorpho"); - -export const eurOnrampBaseMorphoFlow: Flow = FlowBuilder.start(MykoboMint) - .pipe(FundEphemeral()) - .pipe(SubsidizePre()) - .pipe(NablaSwap(Networks.Base, EvmToken.EURC, EvmToken.USDC)) - .pipe(DistributeFees()) - .pipe(SubsidizePost()) - .pipe(MorphoMint()) - .build("EurOnrampBaseMorpho"); - -export const eurOnrampMorphoPhaseFlow = assemblePhaseFlow(eurOnrampMorphoFlow); -export const eurOnrampBaseMorphoPhaseFlow = assemblePhaseFlow(eurOnrampBaseMorphoFlow); +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"); +} ``` -No `WithSubsidy`, no `branch`, no `passthrough`. The two flows differ only -in whether the Squid bridge and the final settlement subsidy are present. - -### Parity proof (derived `RampPhase[]` arrays) +### Parity (derived `RampPhase[]`) -`assemblePhaseFlow(flow)` produces arrays that deep-equal the hand-maintained -definitions in `phases/ramp-flow-definitions.ts`. +`assemblePhaseFlow(flow)` deep-equals the hand-maintained +`BRL_ONRAMP_BASE_CROSS_CHAIN` in `phases/ramp-flow-definitions.ts`, for +every destination instantiation: -**Cross-chain** — deep-equals `EUR_ONRAMP_MORPHO` (lines 167-181): ``` -["initial", "mykoboOnrampDeposit", "fundEphemeral", "subsidizePreSwap", +["initial", "brlaOnrampMint", "fundEphemeral", "subsidizePreSwap", "nablaApprove", "nablaSwap", "distributeFees", "subsidizePostSwap", "squidRouterSwap", "squidRouterPay", "finalSettlementSubsidy", - "morphoDeposit", "complete"] -``` - -**Base vault** — deep-equals `EUR_ONRAMP_BASE_MORPHO` (lines 189-200): -``` -["initial", "mykoboOnrampDeposit", "fundEphemeral", "subsidizePreSwap", - "nablaApprove", "nablaSwap", "distributeFees", "subsidizePostSwap", - "morphoDeposit", "complete"] + "destinationTransfer", "complete"] ``` The bookend `["initial", ..., "complete"]` is the only thing @@ -241,85 +287,47 @@ The bookend `["initial", ..., "complete"]` is the only thing ### Verification -`__tests__/eur-onramp-morpho.parity.test.ts` runs five kinds of checks: +`__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 - (no bookends) for both flows. +1. **Structural** — `flow.phases` equals the expected core phases array. 2. **Phase-flow parity** — `assemblePhaseFlow(flow)` deep-equals the - existing hand-maintained array for both flows (asserted via both the - pre-derived constant and a direct call). -3. **Compile-time adjacency** — a `// @ts-expect-error` block - (`FlowBuilder.start(MorphoMint<...>()).pipe(MykoboMint)`) is - type-checked by tsc. If the brand guard were broken, the directive + 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. -4. **Simulate smoke** — with externals mocked (`calculateNablaSwapOutputEvm`, - `calculateEvmBridgeAndNetworkFee`, `MykoboApiService.defaultDepositFee`, - `EvmClientManager.readContract`), each flow's `simulate(ctx)` returns a - `PhaseIO` with `amount > 0`, `token === EvmToken.MORPHO_VAULT`, and - non-empty `ctx.notes`. -5. **Metadata parity** — the final `PhaseIO.meta` carries every key the old - engines produced (`mykoboMint`, `nablaSwapEvm`, `evmToEvm`, `subsidy`, - `fees`, `morphoDeposit`) with the same field names, so the existing - route-prep and handlers read it unchanged. - -Final test run: `8 pass, 1 skip, 0 fail, 45 expect() calls`. +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 -(`mykoboMint`, `nablaSwapEvm`, `evmToEvm`, `subsidy`, `morphoDeposit`) -and **spreads `input.meta`** so earlier phases' data is preserved: - -```ts -return evmIO(token, chain, amount, amountRaw, { - ...input.meta, // preserve everything from earlier phases - mykoboMint: { ... }, // this phase's data -}); -``` - -The final `PhaseIO.meta` is therefore the union of all phase outputs — -equivalent to the old `QuoteContext` bag, minus the fields the POC doesn't -use (`preNabla`, `*Xcm`, `hydrationSwap`, `alfredpayMint`, `aveniaMint`, -`evmToMoonbeam`, `moonbeamToEvm`, etc.). The `QuoteService` casts it to -`QuoteTicketMetadata` and stores it as `quote.metadata`: - -```ts -const output = await flow.simulate(ctx); -quote.metadata = output.meta as QuoteTicketMetadata; -``` - -The existing route-prep (`transactions/onramp/routes/mykobo-to-evm-morpho.ts`) -already reads `quote.metadata.nablaSwapEvm.outputAmountRaw`, -`quote.metadata.evmToEvm.outputAmountRaw`, and -`quote.metadata.evmToEvm.inputAmountRaw` — these keys exist with the same -shapes as before. No route-prep or handler code needs to change. +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)` — where `input` is the typed `PhaseIO` (amount, token, -chain) and `ctx` is the cross-phase context (request, partner, fees, -notes). This means any phase can be removed, reordered, or swapped without -breaking another phase's simulation logic. The only adjacency constraint -is the `Phase` brand check enforced by `FlowBuilder.pipe`. - -The three subsidy phases (`SubsidizePre`, `SubsidizePost`, -`FinalSettlementSubsidy`) each independently call the shared -`computeExpectedOutput(ctx)` helper to derive the oracle-based expected -output from `ctx.request` + `ctx.partner` — they do not read each other's -meta. `SubsidizePre` writes a partial `meta.subsidy` (just -`expectedOutputAmount*`); `SubsidizePost` and `FinalSettlementSubsidy` -each write the full `meta.subsidy` (including `actualOutputAmount*`, -`subsidyAmountInOutputToken*`, `idealSubsidy*`, `targetOutputAmount*`, -`applied`, `subsidyRate`, `partnerId`, `adjustedDifference`, -`adjustedTargetDiscount`). If multiple subsidy phases run, the last one -wins (its full `meta.subsidy` overwrites the prior). Each subsidy phase -computes the same expected output independently — `priceFeedService` -caches the oracle call so the cost is negligible. - -`computeFees(ctx)` (which populates `ctx.fees`) runs before the flow; -`MykoboMint` copies `ctx.fees` into `meta.fees` so the final metadata -includes it. No phase reads `meta.fees` during simulation. +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) @@ -329,165 +337,142 @@ includes it. No phase reads `meta.fees` during simulation. 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/`. Do NOT modify any - existing file outside `blocks/` (no edits to `quote/core/*`, `engines/*`, - `phases/*`, `ramp-flow-definitions.ts`, etc.). The POC coexists with the - old code. +- 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 (e.g. - `engines/nabla-swap/base-evm.ts`). +- Mimic the import style of neighboring files. ### Brand values (enum member string values — keep adjacency consistent) | Enum | Member | Value | |------|--------|-------| -| `FiatToken` | `EURC` | `"EUR"` | -| `FiatToken` | `ARS` | `"ARS"` | | `FiatToken` | `BRL` | `"BRL"` | -| `FiatToken` | `USD` | `"USD"` | -| `FiatToken` | `MXN` | `"MXN"` | -| `FiatToken` | `COP` | `"COP"` | +| `FiatToken` | `EURC` | `"EUR"` | +| `EvmToken` | `BRLA` | `"BRLA"` | | `EvmToken` | `EURC` | `"EURC"` | | `EvmToken` | `USDC` | `"USDC"` | -| `EvmToken` | `MORPHO_VAULT` | `"MORPHO VAULT"` | +| `EvmToken` | `USDT` | `"USDT"` | | `Networks` | `Base` | `"base"` | | `Networks` | `Arbitrum` | `"arbitrum"` | +| `Networks` | `Polygon` | `"polygon"` | -**Gotcha:** `FiatToken.EURC` is `"EUR"` but `EvmToken.EURC` is `"EURC"` — -different strings, so the brands are distinct types. This is what makes -the fiat→EVM boundary in `MykoboMint` (input -`PhaseIO` → output -`PhaseIO`) type-check: the -output brand genuinely differs from the input brand, and only -`MykoboMint`'s declared signature bridges them. +**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 | -| `NablaSwap(chain, in, out)` | generic **function with runtime args** | needs runtime values for `getOnChainTokenDetails`; brands inferred from args | -| `SquidRouterSwap(from, to, token)` | generic **function with runtime args** | needs runtime values for bridge request; brands inferred from args | -| `MorphoMint()` | generic function with **type args only** | reads `input.chain` at runtime | -| `FundEphemeral()` | type-args only | pure passthrough | +| `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 | writes `meta.expectedOutputAmount` | -| `SubsidizePost()` | type-args only | reads from `meta`, writes `meta.subsidy` | -| `FinalSettlementSubsidy()` | type-args only | finalizes subsidy | +| `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.EURC`, +**Brands are always enum member types** (`typeof EvmToken.BRLA`, `typeof Networks.Base`), never plain string literals — keep this consistent so adjacency matches. -### Existing files to read before implementing (per phase) +### Port sources (per phase: simulate ← quote engine, executor ← handler) -| Block | Primary port source | -|-------|---------------------| -| `MykoboMint` | `engines/initialize/onramp-mykobo.ts`, `engines/initialize/index.ts` | -| `NablaSwap` | `engines/nabla-swap/base-evm.ts`, `engines/nabla-swap/onramp-mykobo-evm.ts`, `core/nabla.ts` | -| `SquidRouterSwap` | `engines/squidrouter/onramp-base-to-evm.ts`, `engines/squidrouter/index.ts`, `core/squidrouter.ts` | -| `MorphoMint` | `handlers/morpho-deposit-handler.ts`, `handlers/morpho-vault-config.ts`, `engines/initialize/offramp-from-evm-morpho.ts` | -| `SubsidizePre` / `SubsidizePost` / `FinalSettlementSubsidy` | `engines/discount/onramp.ts`, `engines/merge-subsidy/offramp-evm.ts`, `core/types.ts` (subsidy field) | -| `computeFees` | `core/quote-fees.ts`, `core/helpers.ts` | -| `assemblePhaseFlow` | `phases/ramp-flow-definitions.ts` (EUR_ONRAMP_MORPHO, EUR_ONRAMP_BASE_MORPHO) | +| 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 paths relative to `apps/api/src/api/services/quote/` (or -`apps/api/src/api/services/` for `phases/` and `handlers/`). +All handler paths relative to `apps/api/src/api/services/phases/`. ### Known gaps & POC limitations -All intentional POC scope cuts, not bugs: +All intentional scope cuts, not bugs: -1. **Subsidy simplified.** `SubsidizePre` / `SubsidizePost` / - `FinalSettlementSubsidy` compute subsidy metadata from +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. `actualOutputAmountDecimal` = the - actual input amount. Subsidy metadata shape mirrors the existing - `QuoteContext.subsidy` field. -2. **`computeFees` network fee = `"0"`.** `calculateFeeComponents` (reused - from `core/quote-fees.ts`) returns no network component — the Squid - network fee is normally added mid-pipeline by per-engine `compute` - (which needs `ctx.mykoboMint` etc., not available on `PhaseCtx` at - pre-`simulate` time). The adapter sets `network: "0"` in both fee views - rather than re-fetching the Squid fee. -3. **`MorphoMint` hardcodes `"usdc-arbitrum"` vault.** Only one vault is - configured in `morpho-vault-config.ts`; `PhaseCtx` / `CreateQuoteRequest` - carries no vault-id field. A multi-vault resolution (chain→vault-id map - or a `ctx.request` field) is deferred. When the base-vault flow runs, - `previewDeposit` would be read on Base against an Arbitrum vault - address — no Base vault configured yet. -4. **`NablaSwap` SELL deductible = `0`.** The SELL path in the existing - engine reads `ctx.preNabla?.deductibleFeeAmountInSwapCurrency`, which - is not on `PhaseCtx`. Hardcoded to `0` (safe for the POC: - `EUR_ONRAMP_MORPHO` is BUY/onramp, where the deductible is `0` anyway). + 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` in its `EvmClientManager.readContractWithRetry` - call. The `chain` arg is used only for branding/IO output. NablaSwap is - only ever instantiated with `chain = Networks.Base` in the POC flow. + 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, no file - outside `core/` was modified). + `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 — the real - `PriceFeedService initialized` log still appears. Harmless: - `getOnchainOraclePrice` is wrapped in `try/catch` inside `NablaSwap` and - the subsidy phases, so the flow completes regardless. + 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 POC begins to unwind: +The entanglement this model unwinds: - **Quote side** (`apps/api/src/api/services/quote/`): `QuoteService` → - `RouteResolver` → `QuoteOrchestrator` walks `stages: StageKey[]` (10 - keys) calling `engine.execute(ctx)`. 10 strategies in - `routes/strategies/`. Engines in - `engines/{initialize,nabla-swap,squidrouter,fee,discount,finalize,...}/`. + `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: RampPhase[]` (28 distinct strings). Handlers in - `handlers/*.ts` read `quote.metadata.*` by string key. Route-prep in - `transactions/{onramp,offramp}/routes/*.ts` picks both the `phaseFlow` - array AND builds `unsignedTxs` from the same metadata. -- **The gap:** 10 quote stages ≠ 28 execution phases. The mapping is - implicit, spread across three files. `QuoteContext` is a ~25-field - optional bag. `phaseFlow` is `string[]` — a typo fails only at runtime. - Subsidy logic is smeared across both pipelines. No compile-time - guarantee that a Polygon-only phase doesn't follow a Base swap. - -This POC proves the block model closes that gap, one corridor at a time, -without a big-bang rewrite. + `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. **Port the remaining ~9 corridors.** Each is now small — primitives - exist. Apply the same `FlowBuilder.start(...).pipe(...).build()` - pattern. Candidates by complexity: `EUR_ONRAMP_BASE_DIRECT` (smallest), - `BRL_ONRAMP_*`, `ALFREDPAY_*`, the offramps. Each port replaces one - entry in `ramp-flow-definitions.ts` with a derived array. -2. **Wire the new flow into `QuoteService` behind a flag.** Run real - parity vs the old `onrampMykoboToEvmStrategy` for `EUR_ONRAMP_MORPHO`. - Compare `outputAmount` numerically. -3. **Implement `prepareTxs` and `execute` on `Phase`.** Currently only - `simulate` + `phases` are defined. Adding - `prepareTxs(input, output, ctx): Promise` and - `execute(state, io): Promise` to the `Phase` interface makes - the execution side block-defined too — one source of truth per corridor - instead of three (strategy, `RampPhase[]`, route-prep). -4. **Full numerical parity integration test** vs - `onrampMykoboToEvmStrategy` (real externals, no mocks) as a follow-up - to the smoke test. -5. **Close the POC gaps** in priority order: (a) `computeFees` network - fee (move the Squid fee fetch into a dedicated phase or a post-`simulate` - fixup), (b) `MorphoMint` multi-vault resolution, (c) `NablaSwap` SELL - deductible, (d) `Subsidize*` full partner-DB lookup. -6. **Delete the old strategy + `ramp-flow-definitions.ts` entries** for - each ported corridor once parity is proven. The old `StageKey` / - `RampPhase` strings can coexist as aliases during migration. +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/__tests__/eur-onramp-morpho.parity.test.ts b/apps/api/src/api/services/quote/blocks/__tests__/eur-onramp-morpho.parity.test.ts deleted file mode 100644 index b7524c055..000000000 --- a/apps/api/src/api/services/quote/blocks/__tests__/eur-onramp-morpho.parity.test.ts +++ /dev/null @@ -1,241 +0,0 @@ -import { describe, expect, it, mock } from "bun:test"; -import Big from "big.js"; -import { EvmClientManager, EvmToken, EPaymentMethod, FiatToken, MykoboApiService, Networks, RampDirection } 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: "1.05", - nablaOutputAmountDecimal: new Big(105), - nablaOutputAmountRaw: "105000000" - }) -})); - -mock.module("../../core/squidrouter", () => ({ - calculateEvmBridgeAndNetworkFee: async () => ({ - finalEffectiveExchangeRate: "0.95", - finalGrossOutputAmountDecimal: new Big(95), - networkFeeUSD: "0.1", - outputTokenDecimals: 6 - }), - getBridgeTargetTokenDetails: () => ({ - erc20AddressSourceChain: "0xaf88d065e77c8cC2239327C5EDb3A432268e5831" - }) -})); - -mock.module("../../../priceFeed.service", () => ({ - priceFeedService: { - getOnchainOraclePrice: async () => ({ - lastUpdateTimestamp: 0, - name: "mock", - price: new Big(1) - }) - } -})); - -import { EUR_ONRAMP_BASE_MORPHO, EUR_ONRAMP_MORPHO } 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 { MorphoMint } from "../phases/morpho-mint"; -import { MykoboMint } from "../phases/mykobo-mint"; -import { - eurOnrampBaseMorphoFlow, - eurOnrampBaseMorphoPhaseFlow, - eurOnrampMorphoFlow, - eurOnrampMorphoPhaseFlow -} from "../flows/eur-onramp-morpho"; - -describe("EUR_ONRAMP_MORPHO block flow — structure", () => { - it("derives the core phases from the cross-chain assembled blocks", () => { - expect(eurOnrampMorphoFlow.phases).toEqual([ - "mykoboOnrampDeposit", - "fundEphemeral", - "subsidizePreSwap", - "nablaApprove", - "nablaSwap", - "distributeFees", - "subsidizePostSwap", - "squidRouterSwap", - "squidRouterPay", - "finalSettlementSubsidy", - "morphoDeposit" - ]); - }); - - it("derives the core phases from the base-vault assembled blocks", () => { - expect(eurOnrampBaseMorphoFlow.phases).toEqual([ - "mykoboOnrampDeposit", - "fundEphemeral", - "subsidizePreSwap", - "nablaApprove", - "nablaSwap", - "distributeFees", - "subsidizePostSwap", - "morphoDeposit" - ]); - }); - - it("assembles the cross-chain phaseFlow matching the existing EUR_ONRAMP_MORPHO", () => { - expect(eurOnrampMorphoPhaseFlow).toEqual(EUR_ONRAMP_MORPHO); - expect(assemblePhaseFlow(eurOnrampMorphoFlow)).toEqual(EUR_ONRAMP_MORPHO); - }); - - it("assembles the base-vault phaseFlow matching the existing EUR_ONRAMP_BASE_MORPHO", () => { - expect(eurOnrampBaseMorphoPhaseFlow).toEqual(EUR_ONRAMP_BASE_MORPHO); - expect(assemblePhaseFlow(eurOnrampBaseMorphoFlow)).toEqual(EUR_ONRAMP_BASE_MORPHO); - }); -}); - -describe("EUR_ONRAMP_MORPHO block flow — compile-time adjacency", () => { - it.skip("rejects a mis-ordered flow at compile time (MorphoMint before MykoboMint)", () => { - // MorphoMint expects USDC input; MykoboMint expects EUR/fiat input. - // @ts-expect-error adjacency: MorphoMint input brand (USDC) != MykoboMint input brand (EUR/fiat) - const _wrong = FlowBuilder.start(MorphoMint()).pipe(MykoboMint).build("wrong"); - void _wrong; - }); -}); - -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.SEPA, - inputAmount: "100", - inputCurrency: FiatToken.EURC, - network: Networks.Base, - outputCurrency: EvmToken.MORPHO_VAULT, - rampType: RampDirection.BUY, - to: Networks.Arbitrum - } - }; -} - -async function runFlow(flow: typeof eurOnrampMorphoFlow, to: Networks.Base | Networks.Arbitrum): Promise { - MykoboApiService.getInstance = mock(() => ({ - defaultDepositFee: mock(async () => ({ total: "0.5" })) - })) as unknown as typeof MykoboApiService.getInstance; - - EvmClientManager.getInstance = mock(() => ({ - getClient: mock(() => ({ - readContract: mock(async () => 1000000000000000000n) - })) - })) as unknown as typeof EvmClientManager.getInstance; - - const ctx: PhaseCtx = { ...buildCtx(), request: { ...buildCtx().request, to } }; - return flow.simulate(ctx); -} - -describe("EUR_ONRAMP_MORPHO block flow — simulate smoke", () => { - it("runs the cross-chain flow end-to-end and lands on MORPHO_VAULT", async () => { - const output: PhaseIO = await runFlow(eurOnrampMorphoFlow, Networks.Arbitrum); - expect(output.amount.gt(0)).toBe(true); - expect(output.token).toBe(EvmToken.MORPHO_VAULT); - }); - - it("runs the base-vault flow end-to-end and lands on MORPHO_VAULT", async () => { - const output: PhaseIO = await runFlow(eurOnrampBaseMorphoFlow, Networks.Base); - expect(output.amount.gt(0)).toBe(true); - expect(output.token).toBe(EvmToken.MORPHO_VAULT); - }); -}); - -describe("EUR_ONRAMP_MORPHO block flow — metadata parity", () => { - it("accumulates mykoboMint, nablaSwapEvm, fees, subsidy, morphoDeposit in the cross-chain flow meta", async () => { - const output: PhaseIO = await runFlow(eurOnrampMorphoFlow, Networks.Arbitrum); - const { meta } = output; - - expect(meta.fees).toBeDefined(); - expect((meta.fees as { usd: { total: string } }).usd.total).toBe("0.3"); - - const mykobo = meta.mykoboMint as { - currency: FiatToken; - fee: Big; - inputAmountDecimal: Big; - inputAmountRaw: string; - outputAmountDecimal: Big; - outputAmountRaw: string; - }; - expect(mykobo).toBeDefined(); - expect(mykobo.currency).toBe(FiatToken.EURC); - expect(mykobo.fee.toFixed()).toBe("0.5"); - expect(mykobo.inputAmountRaw).toBe("100"); - expect(mykobo.outputAmountRaw).toBe("99500000"); - - const nabla = meta.nablaSwapEvm as { - inputAmountForSwapRaw: string; - inputDecimals: number; - inputToken: string; - outputAmountRaw: string; - outputDecimals: number; - outputToken: string; - effectiveExchangeRate?: string; - }; - expect(nabla).toBeDefined(); - expect(nabla.inputAmountForSwapRaw).toBe("99500000"); - expect(nabla.outputAmountRaw).toBe("105000000"); - expect(nabla.effectiveExchangeRate).toBe("1.05"); - - const evmToEvm = meta.evmToEvm as { - effectiveExchangeRate: string; - fromNetwork: string; - fromToken: string; - inputAmountRaw: string; - outputAmountRaw: string; - toNetwork: string; - toToken: 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("95000000"); - expect(evmToEvm.networkFeeUSD).toBe("0.1"); - - const subsidy = meta.subsidy as SubsidyMeta; - expect(subsidy).toBeDefined(); - expect(subsidy.applied).toBe(false); - expect(subsidy.expectedOutputAmountDecimal.toFixed()).toBe("100"); - expect(subsidy.actualOutputAmountDecimal.gt(0)).toBe(true); - expect(subsidy.partnerId).toBeNull(); - expect(subsidy.adjustedDifference).toBeDefined(); - expect(subsidy.adjustedTargetDiscount).toBeDefined(); - - const morpho = meta.morphoDeposit as { - depositAssetAddress: string; - expectedUsdcRaw: string; - sharesAmountRaw: string; - vaultAddress: string; - }; - expect(morpho).toBeDefined(); - expect(morpho.vaultAddress).toBeDefined(); - expect(morpho.depositAssetAddress).toBeDefined(); - expect(morpho.expectedUsdcRaw).toBe("95000000"); - expect(morpho.sharesAmountRaw).toBe("1000000000000000000"); - }); - - it("omits evmToEvm in the base-vault flow meta but keeps all other keys", async () => { - const output: PhaseIO = await runFlow(eurOnrampBaseMorphoFlow, Networks.Base); - const { meta } = output; - - expect(meta.mykoboMint).toBeDefined(); - expect(meta.nablaSwapEvm).toBeDefined(); - expect(meta.fees).toBeDefined(); - expect(meta.subsidy).toBeDefined(); - expect(meta.morphoDeposit).toBeDefined(); - expect(meta.evmToEvm).toBeUndefined(); - }); -}); diff --git a/apps/api/src/api/services/quote/blocks/core/flow.ts b/apps/api/src/api/services/quote/blocks/core/flow.ts index dc68c1a2c..000d66c03 100644 --- a/apps/api/src/api/services/quote/blocks/core/flow.ts +++ b/apps/api/src/api/services/quote/blocks/core/flow.ts @@ -3,12 +3,16 @@ import { computeFees } from "./fees"; import { requestToIO } from "./io"; import type { Flow, Phase, PhaseCtx, PhaseIO } from "./types"; -type OutputOf

= P extends Phase ? O : never; +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: Phase[]) {} + private constructor(private readonly phaseList: AnyPhase[]) {} - static start

>(first: P): FlowBuilder> { + static start

(first: P): FlowBuilder> { return new FlowBuilder>([first]); } @@ -19,14 +23,16 @@ export class FlowBuilder { 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, ctx); + current = await phase.simulate(current as never, ctx); } return current; } diff --git a/apps/api/src/api/services/quote/blocks/core/types.ts b/apps/api/src/api/services/quote/blocks/core/types.ts index 10f76c546..aa4a69366 100644 --- a/apps/api/src/api/services/quote/blocks/core/types.ts +++ b/apps/api/src/api/services/quote/blocks/core/types.ts @@ -1,5 +1,6 @@ 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; @@ -28,11 +29,17 @@ export interface PhaseCtx { export interface Phase { readonly name: string; readonly phases: RampPhase[]; - simulate(input: I, ctx: PhaseCtx): Promise; + // 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/flows/eur-onramp-morpho.ts b/apps/api/src/api/services/quote/blocks/flows/eur-onramp-morpho.ts deleted file mode 100644 index 95d1f2034..000000000 --- a/apps/api/src/api/services/quote/blocks/flows/eur-onramp-morpho.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { EvmToken, Networks } from "@vortexfi/shared"; -import { FlowBuilder } from "../core/flow"; -import { assemblePhaseFlow } from "../core/phase-flow"; -import type { Flow } from "../core/types"; -import { DistributeFees } from "../phases/distribute-fees"; -import { FinalSettlementSubsidy } from "../phases/final-settlement-subsidy"; -import { FundEphemeral } from "../phases/fund-ephemeral"; -import { MorphoMint } from "../phases/morpho-mint"; -import { MykoboMint } from "../phases/mykobo-mint"; -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"; - -export const eurOnrampMorphoFlow: Flow = FlowBuilder.start(MykoboMint) - .pipe(FundEphemeral()) - .pipe(SubsidizePre()) - .pipe(NablaSwap(Networks.Base, EvmToken.EURC, EvmToken.USDC)) - .pipe(DistributeFees()) - .pipe(SubsidizePost()) - .pipe(SquidRouterSwap(Networks.Base, Networks.Arbitrum, EvmToken.USDC)) - .pipe(FinalSettlementSubsidy()) - .pipe(MorphoMint()) - .build("EurOnrampMorpho"); - -export const eurOnrampBaseMorphoFlow: Flow = FlowBuilder.start(MykoboMint) - .pipe(FundEphemeral()) - .pipe(SubsidizePre()) - .pipe(NablaSwap(Networks.Base, EvmToken.EURC, EvmToken.USDC)) - .pipe(DistributeFees()) - .pipe(SubsidizePost()) - .pipe(MorphoMint()) - .build("EurOnrampBaseMorpho"); - -export const eurOnrampMorphoPhaseFlow = assemblePhaseFlow(eurOnrampMorphoFlow); -export const eurOnrampBaseMorphoPhaseFlow = assemblePhaseFlow(eurOnrampBaseMorphoFlow); 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 index edbe968ec..49ae94d25 100644 --- a/apps/api/src/api/services/quote/blocks/phases/distribute-fees.ts +++ b/apps/api/src/api/services/quote/blocks/phases/distribute-fees.ts @@ -1,15 +1,177 @@ -import { multiplyByPowerOfTen } from "@vortexfi/shared"; +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> { 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 index 3d179cfe6..ccccd2c77 100644 --- 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 @@ -1,11 +1,322 @@ +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> { 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 index fa0836e92..e90b1a522 100644 --- a/apps/api/src/api/services/quote/blocks/phases/fund-ephemeral.ts +++ b/apps/api/src/api/services/quote/blocks/phases/fund-ephemeral.ts @@ -1,10 +1,216 @@ +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"; -export function FundEphemeral(): Phase< - PhaseIO, - PhaseIO -> { +// 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> { diff --git a/apps/api/src/api/services/quote/blocks/phases/morpho-mint.ts b/apps/api/src/api/services/quote/blocks/phases/morpho-mint.ts deleted file mode 100644 index 674ccc1ef..000000000 --- a/apps/api/src/api/services/quote/blocks/phases/morpho-mint.ts +++ /dev/null @@ -1,68 +0,0 @@ -import { EvmClientManager, EvmNetworks, EvmToken } from "@vortexfi/shared"; -import Big from "big.js"; -import { getMorphoVaultInfo } from "../../../phases/handlers/morpho-vault-config"; -import { evmIO } from "../core/io"; -import type { ChainBrand, Phase, PhaseCtx, PhaseIO } from "../core/types"; - -const morphoVaultAbi = [ - { - inputs: [ - { name: "assets", type: "uint256" }, - { name: "receiver", type: "address" } - ], - name: "deposit", - outputs: [{ name: "shares", type: "uint256" }], - stateMutability: "nonpayable", - type: "function" - }, - { - inputs: [{ name: "owner", type: "address" }], - name: "balanceOf", - outputs: [{ name: "", type: "uint256" }], - stateMutability: "view", - type: "function" - }, - { - inputs: [{ name: "assets", type: "uint256" }], - name: "previewDeposit", - outputs: [{ name: "shares", type: "uint256" }], - stateMutability: "view", - type: "function" - } -] as const; - -export function MorphoMint(): Phase< - PhaseIO, - PhaseIO -> { - return { - name: "MorphoMint", - phases: ["morphoDeposit"], - async simulate(input, ctx) { - const vault = getMorphoVaultInfo("usdc-arbitrum"); - const network = input.chain as EvmNetworks; - const client = EvmClientManager.getInstance().getClient(network); - - const sharesRaw = (await client.readContract({ - abi: morphoVaultAbi, - address: vault.vaultAddress, - args: [BigInt(input.amountRaw)], - functionName: "previewDeposit" - })) as bigint; - - const sharesDecimal = new Big(sharesRaw.toString()).div(new Big(10).pow(vault.shareDecimals)); - - ctx.addNote(`MorphoMint: ${input.amount} USDC -> ${sharesDecimal.toFixed()} vault shares on ${network}`); - - return evmIO(EvmToken.MORPHO_VAULT, input.chain as Chain, sharesDecimal, sharesRaw.toString(), { - ...input.meta, - morphoDeposit: { - depositAssetAddress: vault.depositAssetAddress, - expectedUsdcRaw: input.amountRaw, - sharesAmountRaw: sharesRaw.toString(), - vaultAddress: vault.vaultAddress - } - }); - } - }; -} 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 index 001d3f79f..acebf65e2 100644 --- a/apps/api/src/api/services/quote/blocks/phases/nabla-swap.ts +++ b/apps/api/src/api/services/quote/blocks/phases/nabla-swap.ts @@ -1,17 +1,148 @@ -import { EvmTokenDetails, getOnChainTokenDetails, Networks } from "@vortexfi/shared"; +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> { 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 index 2b2c2cc3a..f9576c927 100644 --- 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 @@ -1,36 +1,585 @@ -import { EvmTokenDetails, getOnChainTokenDetails, Networks, OnChainToken } from "@vortexfi/shared"; +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"; -export function SquidRouterSwap( +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, - token: Token -): Phase, PhaseIO> { + fromToken: FromToken, + toToken: ToToken +): Phase, PhaseIO> { return { - name: `SquidRouterSwap(${fromChain}->${toChain}/${token})`, + executors: [new SquidRouterSwapExecutor(), new SquidRouterPayExecutor()], + name: `SquidRouterSwap(${fromChain}/${fromToken}->${toChain}/${toToken})`, phases: ["squidRouterSwap", "squidRouterPay"], - async simulate(input: PhaseIO, ctx: PhaseCtx): Promise> { + 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, token) as EvmTokenDetails; + const inputTokenDetails = getOnChainTokenDetails(fromChain as Networks, fromToken) as EvmTokenDetails; - const fromToken = inputTokenDetails.erc20AddressSourceChain; - const toTokenDetails = getBridgeTargetTokenDetails(ctx.request.outputCurrency as OnChainToken, toChain as Networks); - const toToken = toTokenDetails.erc20AddressSourceChain; + 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, + fromToken: bridgeSourceToken, originalInputAmountForRateCalc: input.amountRaw, rampType: ctx.request.rampType, toNetwork: toChain as Networks, - toToken + toToken: bridgeTargetToken }; const bridgeResult = await calculateEvmBridgeAndNetworkFee(bridgeRequest); @@ -40,22 +589,22 @@ export function SquidRouterSwap ${bridgeResult.finalGrossOutputAmountDecimal.toFixed()} ${token} on ${toChain}` + `SquidRouterSwap: ${input.amount} ${fromToken} on ${fromChain} -> ${bridgeResult.finalGrossOutputAmountDecimal.toFixed()} ${toToken} on ${toChain}` ); - return evmIO(token, toChain, bridgeResult.finalGrossOutputAmountDecimal, outputAmountRaw, { + return evmIO(toToken, toChain, bridgeResult.finalGrossOutputAmountDecimal, outputAmountRaw, { ...input.meta, evmToEvm: { effectiveExchangeRate: bridgeResult.finalEffectiveExchangeRate, fromNetwork: fromChain as Networks, - fromToken, + fromToken: bridgeSourceToken, inputAmountDecimal: input.amount, inputAmountRaw: input.amountRaw, networkFeeUSD: bridgeResult.networkFeeUSD, outputAmountDecimal: bridgeResult.finalGrossOutputAmountDecimal, outputAmountRaw, toNetwork: toChain as Networks, - toToken + 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 index 6c8688872..89c68082b 100644 --- a/apps/api/src/api/services/quote/blocks/phases/subsidize-post.ts +++ b/apps/api/src/api/services/quote/blocks/phases/subsidize-post.ts @@ -1,12 +1,179 @@ +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> { 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 index 5c1fd3ea6..d697fc384 100644 --- a/apps/api/src/api/services/quote/blocks/phases/subsidize-pre.ts +++ b/apps/api/src/api/services/quote/blocks/phases/subsidize-pre.ts @@ -1,5 +1,27 @@ -import { RampDirection } from "@vortexfi/shared"; +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"; @@ -94,11 +116,139 @@ export async function computeExpectedOutput(ctx: PhaseCtx): Promise<{ decimal: B 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> {