From c6a2cc6874fb2ce202f386f26097c33d0571f31e Mon Sep 17 00:00:00 2001 From: Gianfranco Date: Mon, 8 Jun 2026 13:11:15 -0300 Subject: [PATCH 01/22] 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/22] 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/22] 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/22] 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/22] 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/22] 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/22] 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/22] 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/22] 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/22] 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/22] 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/22] 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/22] 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/22] 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/22] 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/22] 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/22] 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/22] 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/22] 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/22] 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 44d5b4c2a0b154a5ab5f5fc6ddc54284f4cd4937 Mon Sep 17 00:00:00 2001 From: Gianfranco Date: Wed, 17 Jun 2026 16:03:56 -0300 Subject: [PATCH 21/22] fix display of payment instructions for morpho flow --- apps/api/src/api/services/priceFeed.service.ts | 2 +- .../widget-steps/SummaryStep/EUROnrampDetails.tsx | 6 ++---- apps/frontend/src/pages/progress/index.tsx | 4 ++-- packages/shared/src/tokens/evm/config.ts | 2 +- packages/shared/src/tokens/types/evm.ts | 2 +- 5 files changed, 7 insertions(+), 9 deletions(-) diff --git a/apps/api/src/api/services/priceFeed.service.ts b/apps/api/src/api/services/priceFeed.service.ts index a8743310a..c22aa2af5 100644 --- a/apps/api/src/api/services/priceFeed.service.ts +++ b/apps/api/src/api/services/priceFeed.service.ts @@ -461,7 +461,7 @@ export class PriceFeedService { GLMR: "moonbeam", HDX: "hydradx", MATIC: "polygon-ecosystem-token", - "MORPHO VAULT": "usd-coin" + VAULT: "usd-coin" }; return tokenIdMap[currency.toUpperCase()] || null; diff --git a/apps/frontend/src/components/widget-steps/SummaryStep/EUROnrampDetails.tsx b/apps/frontend/src/components/widget-steps/SummaryStep/EUROnrampDetails.tsx index c3b4a9fd0..593310bb2 100644 --- a/apps/frontend/src/components/widget-steps/SummaryStep/EUROnrampDetails.tsx +++ b/apps/frontend/src/components/widget-steps/SummaryStep/EUROnrampDetails.tsx @@ -12,16 +12,14 @@ import { InfoBox } from "../../InfoBox"; export const EUROnrampDetails: FC = () => { const { t } = useTranslation(); const rampActor = useRampActor(); - const { isQuoteExpired, quote, rampState, signingPhase } = useSelector(rampActor, state => ({ + const { isQuoteExpired, quote, rampState } = useSelector(rampActor, state => ({ isQuoteExpired: state.context.isQuoteExpired, quote: state.context.quote, - rampState: state.context.rampState, - signingPhase: state.context.rampSigningPhase + rampState: state.context.rampState })); if (!rampState?.ramp?.ibanPaymentData) return null; if (rampState.quote.id !== quote?.id) return null; - if (signingPhase !== "finished") return null; // Only show details if the ramp is finished if (isQuoteExpired) return null; const { iban, bic, receiverName, reference } = rampState.ramp.ibanPaymentData; diff --git a/apps/frontend/src/pages/progress/index.tsx b/apps/frontend/src/pages/progress/index.tsx index c18dd7e24..3676492f0 100644 --- a/apps/frontend/src/pages/progress/index.tsx +++ b/apps/frontend/src/pages/progress/index.tsx @@ -26,7 +26,7 @@ function getRampFlow(rampState: RampState | undefined): keyof typeof PHASE_FLOWS if (rampState.quote?.inputCurrency === FiatToken.BRL) { return "onramp_brl"; } - if (rampState.quote?.outputCurrency === "MORPHO VAULT") { + if (rampState.quote?.outputCurrency === "VAULT") { return "onramp_eur_morpho"; } return "onramp_eur_evm"; @@ -37,7 +37,7 @@ function getRampFlow(rampState: RampState | undefined): keyof typeof PHASE_FLOWS } if (rampState.quote?.outputCurrency === FiatToken.EURC) { - if (rampState.quote?.inputCurrency === "MORPHO VAULT") { + if (rampState.quote?.inputCurrency === "VAULT") { return "offramp_eur_morpho"; } return "offramp_eur_evm"; diff --git a/packages/shared/src/tokens/evm/config.ts b/packages/shared/src/tokens/evm/config.ts index e80c4fc06..c983887ef 100644 --- a/packages/shared/src/tokens/evm/config.ts +++ b/packages/shared/src/tokens/evm/config.ts @@ -181,7 +181,7 @@ export const evmTokenConfig: Record Date: Wed, 17 Jun 2026 19:26:33 -0300 Subject: [PATCH 22/22] config Morpho ethereum vault --- apps/api/src/api/services/phases/handlers/helpers.ts | 2 +- .../api/services/phases/handlers/morpho-vault-config.ts | 6 +++--- .../frontend/src/services/balances/morphoBalanceFetcher.ts | 7 +++---- packages/shared/src/tokens/evm/config.ts | 4 ++-- 4 files changed, 9 insertions(+), 10 deletions(-) diff --git a/apps/api/src/api/services/phases/handlers/helpers.ts b/apps/api/src/api/services/phases/handlers/helpers.ts index c0fb149e7..245b3984f 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.0002", + [VortexNetworks.Arbitrum]: "0.0001", [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 6dd66b7c2..bda842ded 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 @@ -10,11 +10,11 @@ export interface MorphoVaultInfo { const MORPHO_VAULTS: Record = { "usdc-arbitrum": { - depositAssetAddress: "0xaf88d065e77c8cC2239327C5EDb3A432268e5831", // USDC on Arbitrum + depositAssetAddress: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", // USDC on Ethereum depositAssetDecimals: 6, - network: Networks.Arbitrum, + network: Networks.Ethereum, shareDecimals: 18, // MetaMorpho default - vaultAddress: "0xbeeff1D5dE8F79ff37a151681100B039661da518" // Steakhouse on Arbitrum + vaultAddress: "0x0bF0164D17469241B6E086dA4016DCc54FEAA334" // Alphaping on Ethereum } }; diff --git a/apps/frontend/src/services/balances/morphoBalanceFetcher.ts b/apps/frontend/src/services/balances/morphoBalanceFetcher.ts index 78151c7df..ddb105767 100644 --- a/apps/frontend/src/services/balances/morphoBalanceFetcher.ts +++ b/apps/frontend/src/services/balances/morphoBalanceFetcher.ts @@ -1,3 +1,4 @@ +import { mainnet } from "@reown/appkit/networks"; import { Networks } from "@vortexfi/shared"; import { readContract } from "@wagmi/core"; import { wagmiConfig } from "../../wagmiConfig"; @@ -12,15 +13,13 @@ const MORPHO_VAULT_ABI = [ } ] 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, + chainId: mainnet.id, functionName: "balanceOf" })) as bigint; @@ -31,4 +30,4 @@ export async function fetchMorphoVaultShareBalance(vaultAddress: `0x${string}`, } } -export const MORPHO_VAULT_NETWORK = Networks.Arbitrum; +export const MORPHO_VAULT_NETWORK = Networks.Ethereum; diff --git a/packages/shared/src/tokens/evm/config.ts b/packages/shared/src/tokens/evm/config.ts index c983887ef..3d35d3d42 100644 --- a/packages/shared/src/tokens/evm/config.ts +++ b/packages/shared/src/tokens/evm/config.ts @@ -183,10 +183,10 @@ export const evmTokenConfig: Record