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..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 @@ -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,14 +82,16 @@ 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); 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); @@ -110,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: @@ -147,7 +154,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 +194,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 +371,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..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 @@ -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"; @@ -86,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) { @@ -124,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({ @@ -188,12 +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; - if (!isPendulumFunded) { logger.info(`Funding PEN ephemeral account ${substrateEphemeralAddress}`); if (isOnramp(state) && state.to !== Networks.AssetHub) { @@ -219,11 +222,28 @@ export class FundEphemeralPhaseHandler extends BasePhaseHandler { logger.info("Polygon ephemeral address already funded."); } - if (isOnramp(state) && !isDestinationEvmFunded && destinationNetwork && isNetworkEVM(destinationNetwork)) { - logger.info(`Funding destination EVM ephemeral account ${evmEphemeralAddress} on ${destinationNetwork}`); - await this.fundDestinationEvmEphemeralAccount(state, destinationNetwork); - } else if (requiresDestinationEvmFunding) { - logger.info(`Destination EVM ephemeral address already funded on ${destinationNetwork}.`); + // 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); + } + + for (const network of evmNetworksToFund) { + const isFunded = await isDestinationEvmEphemeralFunded(evmEphemeralAddress, network); + if (!isFunded) { + logger.info(`Funding EVM ephemeral account ${evmEphemeralAddress} on ${network}`); + await this.fundDestinationEvmEphemeralAccount(state, network); + } else { + logger.info(`EVM ephemeral account already funded on ${network}.`); + } } } catch (e) { logger.error("Error in FundEphemeralPhaseHandler:", e); @@ -237,43 +257,7 @@ export class FundEphemeralPhaseHandler extends BasePhaseHandler { throw recoverableError; } - return this.transitionToNextPhase(state, this.nextPhaseSelector(state, quote)); - } - - protected nextPhaseSelector(state: RampState, quote: QuoteTicket): RampPhase { - if ( - (state.state.isDirectTransfer === true && - !(state.type === RampDirection.SELL && isAlfredpayToken(quote.outputCurrency as FiatToken))) || - (isOnramp(state) && isFiatToOwnStablecoinBaseDirect(quote.inputCurrency, quote.outputCurrency, quote.network)) - ) { - return "destinationTransfer"; - } - - // brla onramp case - if (isOnramp(state) && quote.inputCurrency === FiatToken.BRL) { - return "subsidizePreSwap"; - } - // mykobo (EURC) onramp case - if (isOnramp(state) && quote.inputCurrency === FiatToken.EURC) { - return "subsidizePreSwap"; - } - // alfredpay onramp case - if (isOnramp(state) && isAlfredpayToken(quote.inputCurrency as FiatToken)) { - return "subsidizePreSwap"; - } - - // off ramp cases - if (state.type === RampDirection.SELL && state.from === Networks.AssetHub) { - return "distributeFees"; - } else if (state.type === RampDirection.SELL && isAlfredpayToken(quote.outputCurrency as FiatToken)) { - return "finalSettlementSubsidy"; - } else if (state.type === RampDirection.SELL && quote.outputCurrency === FiatToken.BRL) { - return "distributeFees"; - } else if (state.type === RampDirection.SELL && quote.outputCurrency === FiatToken.EURC) { - return "distributeFees"; - } else { - return "moonbeamToPendulum"; // Via contract.subsidizePreSwap - } + return state; } protected async fundEvmEphemeralAccount(state: RampState, network: EvmNetworks): Promise { diff --git a/apps/api/src/api/services/phases/handlers/helpers.ts b/apps/api/src/api/services/phases/handlers/helpers.ts index f745eed0d..245b3984f 100644 --- a/apps/api/src/api/services/phases/handlers/helpers.ts +++ b/apps/api/src/api/services/phases/handlers/helpers.ts @@ -58,8 +58,8 @@ export async function isPolygonEphemeralFunded(polygonEphemeralAddress: string): // will spuriously succeed (or fail) and downstream phases may run with a // short-funded ephemeral. export const DESTINATION_EVM_FUNDING_AMOUNTS: Record = { - [VortexNetworks.Ethereum]: "0.00016", - [VortexNetworks.Arbitrum]: "0.000045", + [VortexNetworks.Ethereum]: "0.005", + [VortexNetworks.Arbitrum]: "0.0001", [VortexNetworks.Base]: "0.000034", [VortexNetworks.Polygon]: "0.6", [VortexNetworks.BSC]: "0.000115", diff --git a/apps/api/src/api/services/phases/handlers/initial-phase-handler.ts b/apps/api/src/api/services/phases/handlers/initial-phase-handler.ts index d3ccd8d34..4e3dca43e 100644 --- a/apps/api/src/api/services/phases/handlers/initial-phase-handler.ts +++ b/apps/api/src/api/services/phases/handlers/initial-phase-handler.ts @@ -1,4 +1,4 @@ -import { FiatToken, isAlfredpayToken, RampDirection, RampPhase } from "@vortexfi/shared"; +import { RampPhase } from "@vortexfi/shared"; import logger from "../../../../config/logger"; import { config } from "../../../../config/vars"; import QuoteTicket from "../../../../models/quoteTicket.model"; @@ -6,7 +6,9 @@ import RampState from "../../../../models/rampState.model"; import { BasePhaseHandler } from "../base-phase-handler"; /** - * Handler for the initial phase + * Handler for the initial phase. + * Routing is handled by the PhaseProcessor via phaseFlow. + * This handler only performs setup and sandbox short-circuiting. */ export class InitialPhaseHandler extends BasePhaseHandler { /** @@ -34,17 +36,7 @@ export class InitialPhaseHandler extends BasePhaseHandler { return this.transitionToNextPhase(state, "complete"); } - if (state.type === RampDirection.BUY && quote.inputCurrency === FiatToken.BRL) { - return this.transitionToNextPhase(state, "brlaOnrampMint"); - } else if (state.type === RampDirection.BUY && quote.inputCurrency === FiatToken.EURC) { - return this.transitionToNextPhase(state, "mykoboOnrampDeposit"); - } else if (state.type === RampDirection.BUY && isAlfredpayToken(quote.inputCurrency as FiatToken)) { - return this.transitionToNextPhase(state, "alfredpayOnrampMint"); - } else if (state.type === RampDirection.SELL && isAlfredpayToken(quote.outputCurrency as FiatToken)) { - return this.transitionToNextPhase(state, "squidRouterPermitExecute"); - } - - return this.transitionToNextPhase(state, "fundEphemeral"); + return state; } } diff --git a/apps/api/src/api/services/phases/handlers/morpho-deposit-handler.ts b/apps/api/src/api/services/phases/handlers/morpho-deposit-handler.ts new file mode 100644 index 000000000..a68b9c05b --- /dev/null +++ b/apps/api/src/api/services/phases/handlers/morpho-deposit-handler.ts @@ -0,0 +1,245 @@ +import { EvmClientManager, EvmNetworks, Networks, RampPhase } from "@vortexfi/shared"; +import { erc20Abi } from "viem"; +import logger from "../../../../config/logger"; +import RampState from "../../../../models/rampState.model"; +import { BasePhaseHandler } from "../base-phase-handler"; +import { StateMetadata } from "../meta-state-types"; + +const EVM_BALANCE_CHECK_TIMEOUT_MS = 3 * 60 * 1000; +const BALANCE_POLLING_INTERVAL_MS = 5000; + +const morphoVaultAbi = [ + { + inputs: [ + { name: "assets", type: "uint256" }, + { name: "receiver", type: "address" } + ], + name: "deposit", + outputs: [{ name: "shares", type: "uint256" }], + stateMutability: "nonpayable", + type: "function" + }, + { + inputs: [{ name: "owner", type: "address" }], + name: "balanceOf", + outputs: [{ name: "", type: "uint256" }], + stateMutability: "view", + type: "function" + } +] as const; + +const MORPHO_NETWORK: EvmNetworks = Networks.Ethereum; + +function resolveMorphoNetwork(meta: StateMetadata): EvmNetworks { + return (meta.morphoNetwork as EvmNetworks | undefined) ?? MORPHO_NETWORK; +} + +async function pollForShareBalance( + evmClientManager: EvmClientManager, + network: EvmNetworks, + vaultAddress: `0x${string}`, + ownerAddress: `0x${string}` +): Promise { + const client = evmClientManager.getClient(network); + const deadline = Date.now() + EVM_BALANCE_CHECK_TIMEOUT_MS; + + while (Date.now() < deadline) { + const balance = await client.readContract({ + abi: morphoVaultAbi, + address: vaultAddress, + args: [ownerAddress], + functionName: "balanceOf" + }); + + if (balance > 0n) { + return balance; + } + + await new Promise(resolve => setTimeout(resolve, BALANCE_POLLING_INTERVAL_MS)); + } + + throw new Error(`MorphoDepositHandler: Timed out waiting for share tokens on ${ownerAddress} at vault ${vaultAddress}`); +} + +export class MorphoDepositHandler extends BasePhaseHandler { + public getPhaseName(): RampPhase { + return "morphoDeposit"; + } + + public getMaxRetries(): number { + return 5; + } + + protected async executePhase(state: RampState): Promise { + const meta = state.state as StateMetadata; + const evmClientManager = EvmClientManager.getInstance(); + + if (!meta.morphoVaultAddress) { + throw new Error("MorphoDepositHandler: Missing morphoVaultAddress in state metadata"); + } + if (!meta.morphoDepositAssetAddress) { + throw new Error("MorphoDepositHandler: Missing morphoDepositAssetAddress in state metadata"); + } + if (!meta.evmEphemeralAddress) { + throw new Error("MorphoDepositHandler: Missing evmEphemeralAddress in state metadata"); + } + + const network = resolveMorphoNetwork(meta); + const vaultAddress = meta.morphoVaultAddress as `0x${string}`; + const depositAssetAddress = meta.morphoDepositAssetAddress as `0x${string}`; + const ephemeralAddress = meta.evmEphemeralAddress as `0x${string}`; + const destinationAddress = meta.destinationAddress as `0x${string}`; + + const { approveTxData, depositTxData } = this.getMorphoPresignedTxs(state); + + // Step 1: Approve vault to spend deposit asset + state = await this.executeApprove( + state, + evmClientManager, + network, + vaultAddress, + depositAssetAddress, + ephemeralAddress, + approveTxData + ); + + // Step 2: Deposit into vault, verify share tokens minted to destination + return this.executeDeposit(state, evmClientManager, network, vaultAddress, destinationAddress, depositTxData); + } + + private getMorphoPresignedTxs(state: RampState): { approveTxData: string; depositTxData: string } { + const approveTx = state.presignedTxs?.find(tx => tx.phase === "morphoApprove"); + const depositTx = state.presignedTxs?.find(tx => tx.phase === "morphoDeposit"); + if (!approveTx || !depositTx) { + throw new Error(`MorphoDepositHandler: Missing presigned txs (approve: ${!!approveTx}, deposit: ${!!depositTx})`); + } + return { approveTxData: approveTx.txData as string, depositTxData: depositTx.txData as string }; + } + + private async executeApprove( + state: RampState, + evmClientManager: EvmClientManager, + network: EvmNetworks, + vaultAddress: `0x${string}`, + depositAssetAddress: `0x${string}`, + ephemeralAddress: `0x${string}`, + approveTxData: string + ): Promise { + const meta = state.state as StateMetadata; + const requiredAmount = BigInt(meta.morphoDepositAmountRaw || "0"); + + const allowance = await this.readAllowance(evmClientManager, network, depositAssetAddress, ephemeralAddress, vaultAddress); + if (allowance >= requiredAmount) { + logger.info(`MorphoDepositHandler: Allowance ${allowance} >= ${requiredAmount}, skipping approve`); + return state; + } + + const txHash = (await evmClientManager.sendRawTransactionWithRetry( + network, + approveTxData as `0x${string}` + )) as `0x${string}`; + + logger.info(`MorphoDepositHandler: Approval tx broadcast: ${txHash}`); + + const receipt = await evmClientManager.getClient(network).waitForTransactionReceipt({ hash: txHash }); + if (!receipt || receipt.status !== "success") { + throw new Error(`MorphoDepositHandler: Approval transaction ${txHash} failed on chain`); + } + + await this.verifyAllowance(evmClientManager, network, depositAssetAddress, ephemeralAddress, vaultAddress, meta); + + return state; + } + + private async readAllowance( + evmClientManager: EvmClientManager, + network: EvmNetworks, + tokenAddress: `0x${string}`, + ownerAddress: `0x${string}`, + spenderAddress: `0x${string}` + ): Promise { + const client = evmClientManager.getClient(network); + return client.readContract({ + abi: erc20Abi, + address: tokenAddress, + args: [ownerAddress, spenderAddress], + functionName: "allowance" + }); + } + + private async verifyAllowance( + evmClientManager: EvmClientManager, + network: EvmNetworks, + tokenAddress: `0x${string}`, + ownerAddress: `0x${string}`, + spenderAddress: `0x${string}`, + meta: StateMetadata + ): Promise { + const client = evmClientManager.getClient(network); + const requiredAmount = BigInt(meta.morphoDepositAmountRaw || "0"); + const deadline = Date.now() + EVM_BALANCE_CHECK_TIMEOUT_MS; + let lastAllowance = 0n; + + while (Date.now() < deadline) { + lastAllowance = await client.readContract({ + abi: erc20Abi, + address: tokenAddress, + args: [ownerAddress, spenderAddress], + functionName: "allowance" + }); + + if (lastAllowance >= requiredAmount) { + logger.info(`MorphoDepositHandler: Allowance verified: ${lastAllowance} >= ${requiredAmount}`); + return; + } + + await new Promise(resolve => setTimeout(resolve, BALANCE_POLLING_INTERVAL_MS)); + } + + throw new Error(`MorphoDepositHandler: Insufficient allowance. Have ${lastAllowance}, need ${requiredAmount}`); + } + + private async executeDeposit( + state: RampState, + evmClientManager: EvmClientManager, + network: EvmNetworks, + vaultAddress: `0x${string}`, + shareReceiver: `0x${string}`, + depositTxData: string + ): Promise { + const meta = state.state as StateMetadata; + + // Recovery: if we already broadcast the deposit, just verify shares + if (meta.morphoDepositTxHash) { + logger.info( + `MorphoDepositHandler: Deposit tx already broadcast (${meta.morphoDepositTxHash}), verifying shares on ${shareReceiver}` + ); + const shares = await pollForShareBalance(evmClientManager, network, vaultAddress, shareReceiver); + logger.info(`MorphoDepositHandler: Share balance verified: ${shares}`); + return state; + } + + const txHash = (await evmClientManager.sendRawTransactionWithRetry( + network, + depositTxData as `0x${string}` + )) as `0x${string}`; + + logger.info(`MorphoDepositHandler: Deposit tx broadcast: ${txHash}`); + + await state.update({ + state: { ...state.state, morphoDepositTxHash: txHash } + }); + + const receipt = await evmClientManager.getClient(network).waitForTransactionReceipt({ hash: txHash }); + if (!receipt || receipt.status !== "success") { + throw new Error(`MorphoDepositHandler: Deposit transaction ${txHash} failed on chain`); + } + + const shares = await pollForShareBalance(evmClientManager, network, vaultAddress, shareReceiver); + logger.info(`MorphoDepositHandler: Deposit successful. Share balance on ${shareReceiver}: ${shares}`); + + return state; + } +} + +export default new MorphoDepositHandler(); diff --git a/apps/api/src/api/services/phases/handlers/morpho-permit-execute-handler.ts b/apps/api/src/api/services/phases/handlers/morpho-permit-execute-handler.ts new file mode 100644 index 000000000..553ffd8e1 --- /dev/null +++ b/apps/api/src/api/services/phases/handlers/morpho-permit-execute-handler.ts @@ -0,0 +1,322 @@ +import { EvmClientManager, EvmNetworks, isSignedTypedData, Networks, RampPhase, SignedTypedData } from "@vortexfi/shared"; +import { encodeFunctionData } from "viem"; +import { privateKeyToAccount } from "viem/accounts"; +import logger from "../../../../config/logger"; +import { config } from "../../../../config/vars"; +import RampState from "../../../../models/rampState.model"; +import { BasePhaseHandler } from "../base-phase-handler"; +import { StateMetadata } from "../meta-state-types"; + +const permitAbi = [ + { + inputs: [ + { name: "owner", type: "address" }, + { name: "spender", type: "address" }, + { name: "value", type: "uint256" }, + { name: "deadline", type: "uint256" }, + { name: "v", type: "uint8" }, + { name: "r", type: "bytes32" }, + { name: "s", type: "bytes32" } + ], + name: "permit", + outputs: [], + stateMutability: "nonpayable", + type: "function" + } +] as const; + +const allowanceAbi = [ + { + inputs: [ + { name: "owner", type: "address" }, + { name: "spender", type: "address" } + ], + name: "allowance", + outputs: [{ name: "", type: "uint256" }], + stateMutability: "view", + type: "function" + } +] as const; + +const ALLOWANCE_POLL_INTERVAL_MS = 2_000; +const ALLOWANCE_POLL_TIMEOUT_MS = 120_000; + +type VrsSignature = { v: number; r: `0x${string}`; s: `0x${string}` }; + +const ETHEREUM_NETWORK: EvmNetworks = Networks.Ethereum; + +function resolveMorphoNetwork(meta: StateMetadata): EvmNetworks { + return (meta.morphoNetwork as EvmNetworks | undefined) ?? ETHEREUM_NETWORK; +} + +/** + * Phase description: + * The user signs a single EIP-2612 Permit typed data on the Morpho vault (spender = ephemeral). + * Two presignedTxs are registered under phase "morphoPermitExecute": + * 1. SignedTypedData entry signed by the user (nonce 0, signer = userAddress) + * 2. Raw ERC-20 transferFrom tx signed by the EVM ephemeral (nonce 0, signer = evmEphemeral) + * This handler: + * a. Reads the SignedTypedData, verifies signature, and broadcasts vault.permit() using + * the executor key. permit() does not require the spender to be the caller, so the + * executor (which has gas at this point) can submit it. + * b. Waits for the permit receipt. + * c. Broadcasts the presigned transferFrom tx (the spender, the ephemeral, is the only + * one who can call transferFrom on the allowance it was just granted). + * d. Waits for the transferFrom receipt. + * After both txs land, the MorphoRedeemHandler can call vault.redeem on the ephemeral. + */ +export class MorphoPermitExecuteHandler extends BasePhaseHandler { + private evmClientManager: EvmClientManager; + + constructor() { + super(); + this.evmClientManager = EvmClientManager.getInstance(); + } + + public getPhaseName(): RampPhase { + return "morphoPermitExecute"; + } + + private getExecutorWallet(network: EvmNetworks) { + const account = privateKeyToAccount(config.secrets.moonbeamExecutorPrivateKey as `0x${string}`); + return { + publicClient: this.evmClientManager.getClient(network), + walletClient: this.evmClientManager.getWalletClient(network, account) + }; + } + + private async waitForReceipt(network: EvmNetworks, hash: `0x${string}`, label: string): Promise { + const { publicClient } = this.getExecutorWallet(network); + const receipt = await publicClient.waitForTransactionReceipt({ hash }); + if (!receipt || receipt.status !== "success") { + throw this.createRecoverableError(`${label} tx failed: ${hash}`); + } + } + + /** + * Broadcasts the executor-signed `vault.permit(...)` call. The shared funding/executor + * account has high nonce contention on Base, so a prior attempt of the same tx can stay + * stuck in the mempool at the same nonce with higher gas, causing the next attempt to be + * rejected as "replacement transaction underpriced". We retry up to 3 times, bumping the + * gas price by 1.5x on each attempt to outbid any stuck replacement. + */ + private async broadcastPermitWithGasBump( + network: EvmNetworks, + account: ReturnType, + token: `0x${string}`, + owner: `0x${string}`, + spender: `0x${string}`, + value: bigint, + deadline: bigint, + v: number, + r: `0x${string}`, + s: `0x${string}` + ): Promise<`0x${string}`> { + const { walletClient, publicClient } = this.getExecutorWallet(network); + const data = encodeFunctionData({ + abi: permitAbi, + args: [owner, spender, value, deadline, v, r, s], + functionName: "permit" + }); + + const PERMIT_GAS = 100_000n; + const MAX_ATTEMPTS = 3; + const GAS_BUMP_NUMERATOR = 3n; + const GAS_BUMP_DENOMINATOR = 2n; + + let maxFeePerGas: bigint | undefined; + let maxPriorityFeePerGas: bigint | undefined; + let lastError: Error | undefined; + + for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) { + if (maxFeePerGas === undefined || maxPriorityFeePerGas === undefined) { + const fees = await publicClient.estimateFeesPerGas(); + maxFeePerGas = fees.maxFeePerGas; + maxPriorityFeePerGas = fees.maxPriorityFeePerGas; + } else { + maxFeePerGas = (maxFeePerGas * GAS_BUMP_NUMERATOR) / GAS_BUMP_DENOMINATOR; + maxPriorityFeePerGas = (maxPriorityFeePerGas * GAS_BUMP_NUMERATOR) / GAS_BUMP_DENOMINATOR; + } + + try { + const nonce = await publicClient.getTransactionCount({ + address: account.address, + blockTag: "pending" + }); + logger.info( + `MorphoPermitExecuteHandler: permit broadcast attempt ${attempt}/${MAX_ATTEMPTS} nonce=${nonce} maxFeePerGas=${maxFeePerGas}` + ); + const hash = await walletClient.sendTransaction({ + data, + gas: PERMIT_GAS, + maxFeePerGas, + maxPriorityFeePerGas, + nonce, + to: token, + value: 0n + }); + return hash; + } catch (err) { + lastError = err instanceof Error ? err : new Error(String(err)); + const msg = lastError.message; + if (msg.includes("replacement transaction underpriced") || msg.includes("nonce too low")) { + logger.warn( + `MorphoPermitExecuteHandler: permit attempt ${attempt} rejected (${msg.slice(0, 120)}); bumping gas and retrying` + ); + continue; + } + throw lastError; + } + } + throw lastError ?? new Error("MorphoPermitExecuteHandler: permit broadcast failed after gas-bump retries"); + } + + private async awaitAllowance( + network: EvmNetworks, + vaultAddress: `0x${string}`, + owner: `0x${string}`, + spender: `0x${string}`, + expected: bigint + ): Promise { + const { publicClient } = this.getExecutorWallet(network); + const startedAt = Date.now(); + let lastObserved = 0n; + while (Date.now() - startedAt < ALLOWANCE_POLL_TIMEOUT_MS) { + try { + const current = (await publicClient.readContract({ + abi: allowanceAbi, + address: vaultAddress, + args: [owner, spender], + functionName: "allowance" + })) as bigint; + lastObserved = current; + if (current >= expected) { + logger.info( + `MorphoPermitExecuteHandler: allowance synced for ${vaultAddress} (${owner} -> ${spender}): ${current.toString()}` + ); + return; + } + } catch (err) { + logger.warn( + `MorphoPermitExecuteHandler: allowance read failed (${vaultAddress}); will retry: ${(err as Error).message}` + ); + } + await new Promise(resolve => setTimeout(resolve, ALLOWANCE_POLL_INTERVAL_MS)); + } + throw this.createRecoverableError( + `MorphoPermitExecuteHandler: timed out waiting for allowance to reach ${expected.toString()} on ${vaultAddress} (last observed: ${lastObserved.toString()})` + ); + } + + protected async executePhase(state: RampState): Promise { + logger.info(`Executing morphoPermitExecute phase for ramp ${state.id}`); + + const meta = state.state as StateMetadata; + const network = resolveMorphoNetwork(meta); + + if (!meta.evmEphemeralAddress) { + throw this.createUnrecoverableError("Missing evmEphemeralAddress in state metadata"); + } + + const ephemeralAddress = meta.evmEphemeralAddress as `0x${string}`; + + if (meta.morphoPermitTxHash && meta.morphoPermitTransferFromTxHash) { + logger.info(`MorphoPermitExecuteHandler: Both permit and transferFrom already broadcast for ramp ${state.id}, verifying`); + const publicClient = this.evmClientManager.getClient(network); + for (const [label, hash] of [ + ["permit", meta.morphoPermitTxHash], + ["transferFrom", meta.morphoPermitTransferFromTxHash] + ] as const) { + const receipt = await publicClient.getTransactionReceipt({ hash }); + if (!receipt || receipt.status !== "success") { + throw this.createRecoverableError(`MorphoPermitExecuteHandler: existing ${label} tx ${hash} is not successful`); + } + } + return state; + } + + // Find both presigned entries for this phase + const allPresigned = state.presignedTxs?.filter(tx => tx.phase === "morphoPermitExecute") ?? []; + const permitPresigned = allPresigned.find(tx => tx.signer.toLowerCase() === state.state.walletAddress?.toLowerCase()); + const transferFromPresigned = allPresigned.find(tx => tx.signer.toLowerCase() === ephemeralAddress.toLowerCase()); + + if (!permitPresigned) { + throw this.createUnrecoverableError("Missing user-signed permit presignedTx for morphoPermitExecute"); + } + if (!transferFromPresigned) { + throw this.createUnrecoverableError("Missing ephemeral-signed transferFrom presignedTx for morphoPermitExecute"); + } + + // ── Step 1: broadcast vault.permit via executor ── + const permitData = permitPresigned.txData; + if (!isSignedTypedData(permitData)) { + throw this.createUnrecoverableError("morphoPermitExecute: user presignedTx is not a SignedTypedData"); + } + const permitTypedData: SignedTypedData = permitData; + const sig = permitTypedData.signature as VrsSignature | undefined; + if (!sig) { + throw this.createUnrecoverableError("morphoPermitExecute: permit signature missing from user signed typed data"); + } + const token = permitTypedData.domain.verifyingContract as `0x${string}`; + const owner = permitTypedData.message.owner as `0x${string}`; + const value = BigInt(permitTypedData.message.value as string); + const deadline = BigInt(permitTypedData.message.deadline as string); + + if (deadline * 1000n < BigInt(Date.now())) { + throw this.createUnrecoverableError("Permit deadline already passed;"); + } + + if (!meta.morphoPermitTxHash) { + const account = privateKeyToAccount(config.secrets.moonbeamExecutorPrivateKey as `0x${string}`); + const permitHash = await this.broadcastPermitWithGasBump( + network, + account, + token, + owner, + ephemeralAddress, + value, + deadline, + sig.v, + sig.r, + sig.s + ); + logger.info(`MorphoPermitExecuteHandler: Permit tx sent: ${permitHash}`); + + state = await state.update({ state: { ...state.state, morphoPermitTxHash: permitHash } }); + await this.waitForReceipt(network, permitHash, "Permit"); + } else { + logger.info(`MorphoPermitExecuteHandler: Permit already broadcast (${meta.morphoPermitTxHash})`); + } + + // ── Step 1.5: await allowance RPC sync ── + // `waitForTransactionReceipt` only confirms the tx is mined on the source node; + // downstream public RPCs may lag in their state view. Poll the vault's allowance + // until it reflects the post-permit value, otherwise the subsequent transferFrom + // will revert with "insufficient allowance". + await this.awaitAllowance(network, token, owner, ephemeralAddress, value); + + // ── Step 2: broadcast ephemeral-signed transferFrom ── + if (!meta.morphoPermitTransferFromTxHash) { + const signedTxHex = transferFromPresigned.txData as string; + const txHash = (await this.evmClientManager.sendRawTransactionWithRetry( + network, + signedTxHex as `0x${string}` + )) as `0x${string}`; + logger.info(`MorphoPermitExecuteHandler: transferFrom tx broadcast: ${txHash}`); + + const publicClient = this.evmClientManager.getClient(network); + const receipt = await publicClient.waitForTransactionReceipt({ hash: txHash }); + if (!receipt || receipt.status !== "success") { + throw this.createRecoverableError(`MorphoPermitExecuteHandler: transferFrom tx ${txHash} failed on chain`); + } + state = await state.update({ state: { ...state.state, morphoPermitTransferFromTxHash: txHash } }); + } else { + logger.info(`MorphoPermitExecuteHandler: transferFrom already broadcast (${meta.morphoPermitTransferFromTxHash})`); + } + + logger.info(`MorphoPermitExecuteHandler: Permit + transferFrom confirmed for ramp ${state.id}`); + return state; + } +} + +export default new MorphoPermitExecuteHandler(); diff --git a/apps/api/src/api/services/phases/handlers/morpho-redeem-handler.ts b/apps/api/src/api/services/phases/handlers/morpho-redeem-handler.ts new file mode 100644 index 000000000..7dea9c140 --- /dev/null +++ b/apps/api/src/api/services/phases/handlers/morpho-redeem-handler.ts @@ -0,0 +1,195 @@ +import { EvmClientManager, EvmNetworks, Networks, RampPhase } from "@vortexfi/shared"; +import { erc20Abi } from "viem"; +import logger from "../../../../config/logger"; +import RampState from "../../../../models/rampState.model"; +import { BasePhaseHandler } from "../base-phase-handler"; +import { StateMetadata } from "../meta-state-types"; + +const EVM_BALANCE_CHECK_TIMEOUT_MS = 3 * 60 * 1000; +const BALANCE_POLLING_INTERVAL_MS = 5000; + +const vaultRedeemAbi = [ + { + inputs: [ + { name: "shares", type: "uint256" }, + { name: "receiver", type: "address" }, + { name: "owner", type: "address" } + ], + name: "redeem", + outputs: [{ name: "assets", type: "uint256" }], + stateMutability: "nonpayable", + type: "function" + }, + { + inputs: [{ name: "shares", type: "uint256" }], + name: "previewRedeem", + outputs: [{ name: "assets", type: "uint256" }], + stateMutability: "view", + type: "function" + } +] as const; + +const ETHEREUM_NETWORK: EvmNetworks = Networks.Ethereum; + +function resolveMorphoNetwork(meta: StateMetadata): EvmNetworks { + return (meta.morphoNetwork as EvmNetworks | undefined) ?? ETHEREUM_NETWORK; +} + +/** + * Phase description: + * Broadcasts the presigned vault.redeem(shares, ephemeral, ephemeral) tx on Ethereum. + * Defends against bad output with three layers: + * 1. Pre-flight: read previewRedeem(shares) before broadcast; abort if below minOutputRaw. + * 2. Event parse: parse the Withdraw event from the receipt; assert event.assets >= minOutputRaw. + * 3. Balance poll: as a final fallback, poll USDC.balanceOf(ephemeral) until >= minOutputRaw. + */ +export class MorphoRedeemHandler extends BasePhaseHandler { + private evmClientManager: EvmClientManager; + + constructor() { + super(); + this.evmClientManager = EvmClientManager.getInstance(); + } + + public getPhaseName(): RampPhase { + return "morphoRedeem"; + } + + public getMaxRetries(): number { + return 5; + } + + protected async executePhase(state: RampState): Promise { + const meta = state.state as StateMetadata; + + if (!meta.morphoRedeemVaultAddress) { + throw this.createUnrecoverableError("Missing morphoRedeemVaultAddress in state metadata"); + } + if (!meta.morphoRedeemAssetAddress) { + throw this.createUnrecoverableError("Missing morphoRedeemAssetAddress in state metadata"); + } + if (!meta.evmEphemeralAddress) { + throw this.createUnrecoverableError("Missing evmEphemeralAddress in state metadata"); + } + if (!meta.morphoRedeemSharesAmountRaw) { + throw this.createUnrecoverableError("Missing morphoRedeemSharesAmountRaw in state metadata"); + } + + const network = resolveMorphoNetwork(meta); + const vaultAddress = meta.morphoRedeemVaultAddress as `0x${string}`; + const assetAddress = meta.morphoRedeemAssetAddress as `0x${string}`; + const ephemeralAddress = meta.evmEphemeralAddress as `0x${string}`; + const sharesAmount = BigInt(meta.morphoRedeemSharesAmountRaw); + const minOutputRaw = BigInt(meta.morphoRedeemMinOutputRaw || "0"); + + // Recovery: if we already broadcast the redeem, verify the outcome. + if (meta.morphoRedeemTxHash) { + logger.info(`MorphoRedeemHandler: Redeem tx already broadcast (${meta.morphoRedeemTxHash}), verifying USDC balance`); + await this.verifyUsdcBalance(network, assetAddress, ephemeralAddress, minOutputRaw); + return state; + } + + // Layer 1: pre-flight previewRedeem. If the on-chain rate has drifted past tolerance before + // we even broadcast, abort without spending gas on a doomed redeem. + const publicClient = this.evmClientManager.getClient(network); + const previewAssets = (await publicClient.readContract({ + abi: vaultRedeemAbi, + address: vaultAddress, + args: [sharesAmount], + functionName: "previewRedeem" + })) as bigint; + + if (previewAssets < minOutputRaw) { + throw this.createRecoverableError( + `MorphoRedeemHandler: previewRedeem=${previewAssets} below minOutputRaw=${minOutputRaw}; aborting before broadcast` + ); + } + + const presigned = this.getPresignedTransaction(state, "morphoRedeem"); + if (!presigned) { + throw this.createUnrecoverableError("Missing presigned transaction for morphoRedeem phase"); + } + + const txHash = (await this.evmClientManager.sendRawTransactionWithRetry( + network, + presigned.txData as `0x${string}` + )) as `0x${string}`; + logger.info(`MorphoRedeemHandler: Redeem tx broadcast: ${txHash}`); + + state = await state.update({ + state: { ...state.state, morphoRedeemTxHash: txHash } + }); + + const receipt = await publicClient.waitForTransactionReceipt({ hash: txHash }); + if (!receipt || receipt.status !== "success") { + throw this.createRecoverableError(`MorphoRedeemHandler: Redeem transaction ${txHash} failed on chain`); + } + + // Layer 2: parse the Withdraw event. ERC-4626 Withdraw event signature is + // Withdraw(address indexed sender, address indexed receiver, address indexed owner, uint256 assets, uint256 shares) + // TODO this is either a good idea, or a bad one if this signature can change. + const withdrawEventSig = "0xfbde797d201c681b91056529119e0b02407c7bb96a4a2c75c01fc9667232c8db"; + let parsedAssets: bigint | null = null; + for (const log of receipt.logs) { + if (log.address.toLowerCase() !== vaultAddress.toLowerCase()) continue; + if (log.topics[0]?.toLowerCase() !== withdrawEventSig) continue; + if (log.data.length >= 66) { + // data = abi.encode(uint256 assets, uint256 shares) => 32 + 32 bytes + parsedAssets = BigInt(log.data.slice(0, 66)); + break; + } + } + if (parsedAssets !== null) { + if (parsedAssets < minOutputRaw) { + throw this.createRecoverableError( + `MorphoRedeemHandler: Withdraw event assets=${parsedAssets} below minOutputRaw=${minOutputRaw}` + ); + } + logger.info(`MorphoRedeemHandler: Withdraw event assets=${parsedAssets} >= minOutputRaw=${minOutputRaw}`); + const updated = await state.update({ + state: { ...state.state, morphoRedeemActualOutputRaw: parsedAssets.toString() } + }); + return updated; + } + logger.info( + "MorphoRedeemHandler: No Withdraw event found in receipt (non-standard vault); falling back to balance polling" + ); + + // Layer 3: balance polling fallback + await this.verifyUsdcBalance(network, assetAddress, ephemeralAddress, minOutputRaw); + return state; + } + + private async verifyUsdcBalance( + network: EvmNetworks, + assetAddress: `0x${string}`, + ephemeralAddress: `0x${string}`, + minOutputRaw: bigint + ): Promise { + const client = this.evmClientManager.getClient(network); + const deadline = Date.now() + EVM_BALANCE_CHECK_TIMEOUT_MS; + let lastBalance = 0n; + + while (Date.now() < deadline) { + lastBalance = (await client.readContract({ + abi: erc20Abi, + address: assetAddress, + args: [ephemeralAddress], + functionName: "balanceOf" + })) as bigint; + + if (lastBalance >= minOutputRaw) { + logger.info(`MorphoRedeemHandler: USDC balance verified: ${lastBalance} >= ${minOutputRaw}`); + return; + } + + await new Promise(resolve => setTimeout(resolve, BALANCE_POLLING_INTERVAL_MS)); + } + + throw this.createRecoverableError( + `MorphoRedeemHandler: Timed out waiting for USDC balance >= ${minOutputRaw} on ${ephemeralAddress} (last seen: ${lastBalance})` + ); + } +} + +export default new MorphoRedeemHandler(); diff --git a/apps/api/src/api/services/phases/handlers/morpho-vault-config.ts b/apps/api/src/api/services/phases/handlers/morpho-vault-config.ts new file mode 100644 index 000000000..bda842ded --- /dev/null +++ b/apps/api/src/api/services/phases/handlers/morpho-vault-config.ts @@ -0,0 +1,27 @@ +import { Networks } from "@vortexfi/shared"; + +export interface MorphoVaultInfo { + vaultAddress: `0x${string}`; + depositAssetAddress: `0x${string}`; + depositAssetDecimals: number; + shareDecimals: number; + network: Networks; +} + +const MORPHO_VAULTS: Record = { + "usdc-arbitrum": { + depositAssetAddress: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", // USDC on Ethereum + depositAssetDecimals: 6, + network: Networks.Ethereum, + shareDecimals: 18, // MetaMorpho default + vaultAddress: "0x0bF0164D17469241B6E086dA4016DCc54FEAA334" // Alphaping on Ethereum + } +}; + +export function getMorphoVaultInfo(vaultId: string): MorphoVaultInfo { + const vault = MORPHO_VAULTS[vaultId]; + if (!vault) { + throw new Error(`Morpho vault "${vaultId}" not found. Available: ${Object.keys(MORPHO_VAULTS).join(", ")}`); + } + return vault; +} diff --git a/apps/api/src/api/services/phases/handlers/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..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,24 +4,24 @@ import { BalanceCheckErrorType, checkEvmBalanceForToken, EvmClientManager, - EvmNetworks, EvmTokenDetails, FiatToken, + getNetworkFromDestination, getNetworkId, getOnChainTokenDetails, getStatus, getStatusAxelarScan, isAlfredpayToken, + isNetworkEVM, Networks, nativeToDecimal, OnChainToken, - RampDirection, RampPhase, SquidRouterPayResponse } from "@vortexfi/shared"; import Big from "big.js"; import { createWalletClient, encodeFunctionData, Hash, PublicClient } from "viem"; -import { base, polygon } from "viem/chains"; +import { arbitrum, base, polygon } from "viem/chains"; import logger from "../../../../config/logger"; import { axelarGasServiceAbi } from "../../../../contracts/AxelarGasService"; import QuoteTicket from "../../../../models/quoteTicket.model"; @@ -48,9 +48,11 @@ export class SquidRouterPayPhaseHandler extends BasePhaseHandler { private moonbeamPublicClient: PublicClient; private polygonPublicClient: PublicClient; private basePublicClient: PublicClient; + private arbitrumPublicClient: PublicClient; private moonbeamWalletClient: ReturnType; private polygonWalletClient: ReturnType; private baseWalletClient: ReturnType; + private arbitrumWalletClient: ReturnType; constructor() { super(); @@ -58,11 +60,13 @@ export class SquidRouterPayPhaseHandler extends BasePhaseHandler { this.moonbeamPublicClient = evmClientManager.getClient(Networks.Moonbeam); this.polygonPublicClient = evmClientManager.getClient(Networks.Polygon); this.basePublicClient = evmClientManager.getClient(Networks.Base); + this.arbitrumPublicClient = evmClientManager.getClient(Networks.Arbitrum); const moonbeamExecutorAccount = getEvmFundingAccount(Networks.Moonbeam); this.moonbeamWalletClient = evmClientManager.getWalletClient(Networks.Moonbeam, moonbeamExecutorAccount); this.polygonWalletClient = evmClientManager.getWalletClient(Networks.Polygon, moonbeamExecutorAccount); this.baseWalletClient = evmClientManager.getWalletClient(Networks.Base, moonbeamExecutorAccount); + this.arbitrumWalletClient = evmClientManager.getWalletClient(Networks.Arbitrum, moonbeamExecutorAccount); } /** @@ -85,11 +89,6 @@ export class SquidRouterPayPhaseHandler extends BasePhaseHandler { logger.info(`Executing squidRouterPay phase for ramp ${state.id}`); - if (state.type === RampDirection.SELL) { - logger.info("squidRouterPay phase is not supported for off-ramp"); - return state; - } - try { // Get the bridge hash const bridgeCallHash = state.state.squidRouterSwapHash; @@ -100,11 +99,7 @@ export class SquidRouterPayPhaseHandler extends BasePhaseHandler { // Enter check status loop await this.checkStatus(state, bridgeCallHash, quote); - if (state.to === Networks.AssetHub) { - return this.transitionToNextPhase(state, "moonbeamToPendulum"); - } else { - return this.transitionToNextPhase(state, "finalSettlementSubsidy"); - } + return state; } catch (error: unknown) { logger.error(`SquidRouterPayPhaseHandler: Error in squidRouterPay phase for ramp ${state.id}:`, error); throw error; @@ -118,8 +113,13 @@ export class SquidRouterPayPhaseHandler extends BasePhaseHandler { * Only if both fail (timeout) we throw. */ private async checkStatus(state: RampState, swapHash: string, quote: QuoteTicket): Promise { + // Resolve the actual EVM destination of the Squid bridge. For onramps, quote.to is the + // EVM network directly. For offramps to a payment method (e.g. SEPA via Mykobo), the + // bridge lands on the EVM leg of the ramp, recorded in quote.metadata.evmToEvm.toNetwork. + const toChain = this.resolveBridgeToChain(quote); + // If the destination is not an EVM network, skip the EVM balance optimization and rely on bridge status only. - if (quote.to === Networks.AssetHub) { + if (!toChain || !isNetworkEVM(toChain)) { logger.info("SquidRouterPayPhaseHandler: Destination network is non-EVM; skipping EVM balance check optimization.", { toNetwork: quote.to }); @@ -127,8 +127,6 @@ export class SquidRouterPayPhaseHandler extends BasePhaseHandler { return; } - const toChain = quote.to as EvmNetworks; - let balanceCheckPromise: Promise; try { @@ -236,12 +234,18 @@ export class SquidRouterPayPhaseHandler extends BasePhaseHandler { payTxHash = await this.executeFundTransaction(nativeToFundRaw, swapHash as `0x${string}`, logIndex, state, quote); + const bridgeMeta = quote.metadata.evmToEvm || quote.metadata.moonbeamToEvm; + const fromChain = bridgeMeta?.fromNetwork as Networks; + let subsidyToken: SubsidyToken; let payerAccount: `0x${string}` | undefined; - if (quote.inputCurrency === FiatToken.BRL) { + if (fromChain === Networks.Base || (!fromChain && quote.inputCurrency === FiatToken.BRL)) { subsidyToken = SubsidyToken.ETH; payerAccount = this.baseWalletClient.account?.address as `0x${string}` | undefined; + } else if (fromChain === Networks.Arbitrum) { + subsidyToken = SubsidyToken.ETH; + payerAccount = this.arbitrumWalletClient.account?.address as `0x${string}` | undefined; } else { subsidyToken = SubsidyToken.MATIC; payerAccount = this.polygonWalletClient.account?.address as `0x${string}` | undefined; @@ -286,10 +290,20 @@ export class SquidRouterPayPhaseHandler extends BasePhaseHandler { state: RampState, quote: QuoteTicket ): Promise { - if (quote.inputCurrency === FiatToken.BRL) { + const bridgeMeta = quote.metadata.evmToEvm || quote.metadata.moonbeamToEvm; + const fromChain = bridgeMeta?.fromNetwork as Networks; + if (fromChain === Networks.Base) { return this.executeFundTransactionOnBase(tokenValueRaw, swapHash, logIndex); - } else { + } else if (fromChain === Networks.Arbitrum) { + return this.executeFundTransactionOnArbitrum(tokenValueRaw, swapHash, logIndex); + } else if (fromChain === Networks.Polygon) { return this.executeFundTransactionOnPolygon(tokenValueRaw, swapHash, logIndex); + } else { + if (quote.inputCurrency === FiatToken.BRL) { + return this.executeFundTransactionOnBase(tokenValueRaw, swapHash, logIndex); + } else { + return this.executeFundTransactionOnPolygon(tokenValueRaw, swapHash, logIndex); + } } } @@ -381,17 +395,68 @@ export class SquidRouterPayPhaseHandler extends BasePhaseHandler { } } + /** + * Execute a call to the Axelar gas service on Arbitrum network. + * @param tokenValueRaw The amount of ETH to fund the transaction with. + * @param swapHash The swap transaction hash. + * @param logIndex The log index from Axelar scan. + * @returns Hash of the transaction that funds the Axelar gas service. + */ + private async executeFundTransactionOnArbitrum( + tokenValueRaw: string, + swapHash: `0x${string}`, + logIndex: number + ): Promise { + try { + const walletClientAccount = this.arbitrumWalletClient.account; + + if (!walletClientAccount) { + throw new Error("SquidRouterPayPhaseHandler: Arbitrum wallet client account not found."); + } + + // Create addNativeGas transaction data + const transactionData = encodeFunctionData({ + abi: axelarGasServiceAbi, + args: [swapHash, logIndex, walletClientAccount.address], + functionName: "addNativeGas" + }); + + const { maxFeePerGas, maxPriorityFeePerGas } = await this.arbitrumPublicClient.estimateFeesPerGas(); + + const gasPaymentHash = await this.arbitrumWalletClient.sendTransaction({ + account: walletClientAccount, + chain: arbitrum, + data: transactionData, + maxFeePerGas: maxFeePerGas * 2n, + maxPriorityFeePerGas: maxPriorityFeePerGas * 2n, + to: AXL_GAS_SERVICE_EVM as `0x${string}`, + value: BigInt(tokenValueRaw) + }); + + logger.info(`SquidRouterPayPhaseHandler: Arbitrum fund transaction sent with hash: ${gasPaymentHash}`); + return gasPaymentHash; + } catch (error) { + logger.error("SquidRouterPayPhaseHandler: Error funding gas to Axelar gas service on Arbitrum: ", error); + throw new Error("SquidRouterPayPhaseHandler: Failed to send Arbitrum transaction"); + } + } + private async getSquidrouterStatus(swapHash: string, state: RampState, quote: QuoteTicket): Promise { try { + const bridgeMeta = quote.metadata.evmToEvm || quote.metadata.moonbeamToEvm; // Always Polygon for Monerium/Alfredpay onramp, Base for BRL const fromChain = - quote.inputCurrency === FiatToken.EURC || isAlfredpayToken(quote.inputCurrency as FiatToken) + (bridgeMeta?.fromNetwork as Networks) || + (quote.inputCurrency === FiatToken.EURC || isAlfredpayToken(quote.inputCurrency as FiatToken) ? Networks.Polygon : quote.inputCurrency === FiatToken.BRL ? Networks.Base - : Networks.Moonbeam; + : Networks.Moonbeam); const fromChainId = getNetworkId(fromChain)?.toString(); - const toChain = quote.to === Networks.AssetHub ? Networks.Moonbeam : quote.to; + // Axelar routes through Moonbeam for AssetHub destinations, so the Squid status API + // expects Moonbeam's chain id when quote.to is AssetHub. + const resolvedToChain = this.resolveBridgeToChain(quote); + const toChain = resolvedToChain === Networks.AssetHub ? Networks.Moonbeam : resolvedToChain; const toChainId = getNetworkId(toChain)?.toString(); if (!fromChainId || !toChainId) { @@ -436,6 +501,22 @@ export class SquidRouterPayPhaseHandler extends BasePhaseHandler { } } + /** + * Resolve the actual destination network of the Squid bridge. + * + * For onramps, `quote.to` is the EVM network the bridge delivers to. For offramps to a + * payment method (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/phases/handlers/squid-router-phase-handler.ts b/apps/api/src/api/services/phases/handlers/squid-router-phase-handler.ts index a36b98fd0..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 @@ -207,7 +208,7 @@ export class SquidRouterPhaseHandler extends BasePhaseHandler { }); // Transition to the next phase - return this.transitionToNextPhase(updatedState, "squidRouterPay"); + return updatedState; } catch (error) { logger.error(`Error in squidRouter phase for ramp ${state.id}:`, error); throw error; diff --git a/apps/api/src/api/services/phases/handlers/squidrouter-permit-execution-handler.ts b/apps/api/src/api/services/phases/handlers/squidrouter-permit-execution-handler.ts index 96f951d06..6c1fbc040 100644 --- a/apps/api/src/api/services/phases/handlers/squidrouter-permit-execution-handler.ts +++ b/apps/api/src/api/services/phases/handlers/squidrouter-permit-execution-handler.ts @@ -112,7 +112,7 @@ export class SquidrouterPermitExecuteHandler extends BasePhaseHandler { } logger.info(`${label} tx confirmed: ${hash}`); - return this.transitionToNextPhase(updatedState, "fundEphemeral"); + return updatedState; } private async waitForUserHash( @@ -152,7 +152,7 @@ export class SquidrouterPermitExecuteHandler extends BasePhaseHandler { ); } - return this.transitionToNextPhase(state, "fundEphemeral"); + return state; } private async executeDirectTransfer( @@ -276,7 +276,7 @@ export class SquidrouterPermitExecuteHandler extends BasePhaseHandler { if (receipt && receipt.status === "success") { logger.info(`Existing squidRouter permit execution transaction was successful for ramp ${state.id}`); - return this.transitionToNextPhase(state, "fundEphemeral"); + return state; } else { logger.info( `Existing squidRouter permit execution transaction was not successful (status: ${receipt?.status}), will retry` diff --git a/apps/api/src/api/services/phases/handlers/subsidize-post-swap-handler.ts b/apps/api/src/api/services/phases/handlers/subsidize-post-swap-handler.ts index 08928a49d..de1440f38 100644 --- a/apps/api/src/api/services/phases/handlers/subsidize-post-swap-handler.ts +++ b/apps/api/src/api/services/phases/handlers/subsidize-post-swap-handler.ts @@ -1,12 +1,10 @@ import { ApiManager, - AssetHubToken, checkEvmBalanceForToken, EvmClientManager, EvmNetworks, EvmToken, EvmTokenDetails, - FiatToken, getOnChainTokenDetails, Networks, nativeToDecimal, @@ -154,7 +152,7 @@ export class SubsidizePostSwapPhaseHandler extends BasePhaseHandler { await waitUntilTrueWithTimeout(didBalanceReachExpected, 2000); } - return this.transitionToNextPhase(state, this.substrateNextPhaseSelector(state, quote)); + return state; } catch (e) { logger.error("Error in subsidizePostSwap (substrate):", e); throw this.createRecoverableError("SubsidizePostSwapPhaseHandler: Failed to subsidize post swap."); @@ -238,11 +236,12 @@ export class SubsidizePostSwapPhaseHandler extends BasePhaseHandler { quote.outputCurrency as RampCurrency, EvmToken.USDC as RampCurrency ); - const subsidyCapUsd = Big(quoteOutputUsd).mul(MAX_EVM_SWAP_SUBSIDY_QUOTE_FRACTION); + const percentageCap = Big(quoteOutputUsd).mul(MAX_EVM_SWAP_SUBSIDY_QUOTE_FRACTION); + const subsidyCapUsd = percentageCap.gt("1") ? percentageCap : Big("1"); if (Big(subsidyUsd).gt(subsidyCapUsd)) { // Pause for operator intervention without moving the ramp to failed. throw this.createRecoverableError( - `SubsidizePostSwapPhaseHandler: Required subsidy $${subsidyUsd} exceeds cap $${subsidyCapUsd.toFixed(2)} (${MAX_EVM_SWAP_SUBSIDY_QUOTE_FRACTION} of quote output $${quoteOutputUsd}).` + `SubsidizePostSwapPhaseHandler: Required subsidy $${subsidyUsd} exceeds cap $${subsidyCapUsd.toFixed(2)} (max of $1.00 and ${MAX_EVM_SWAP_SUBSIDY_QUOTE_FRACTION} of quote output $${quoteOutputUsd}).` ); } @@ -288,7 +287,7 @@ export class SubsidizePostSwapPhaseHandler extends BasePhaseHandler { } } - return this.transitionToNextPhase(state, this.evmNextPhaseSelector(state, quote)); + return state; } catch (e) { logger.error("Error in subsidizePostSwap (EVM):", e); if (e instanceof PhaseError) { @@ -297,45 +296,6 @@ export class SubsidizePostSwapPhaseHandler extends BasePhaseHandler { throw this.createRecoverableError("SubsidizePostSwapPhaseHandler: Failed to subsidize post swap on EVM."); } } - - protected substrateNextPhaseSelector(state: RampState, quote: QuoteTicket): RampPhase { - // onramp cases - if (state.type === RampDirection.BUY) { - if (state.to === "assethub") { - if (quote.outputCurrency === AssetHubToken.USDC) { - // USDC can directly go to AssetHub - return "pendulumToAssethubXcm"; - } else { - // USDT and DOT need to go via Hydration - return "pendulumToHydrationXcm"; - } - } - return "pendulumToMoonbeamXcm"; - } - - // off ramp cases - if (quote.outputCurrency === FiatToken.BRL) { - return "pendulumToMoonbeamXcm"; - } - - if (state.type === RampDirection.SELL) { - throw new Error("SubsidizePostSwapPhaseHandler: Unsupported non-BRL offramp route after Stellar deprecation"); - } - - throw new Error( - `SubsidizePostSwapPhaseHandler: Unrecognized routing combination: direction=${state.type}, to=${state.to}, output=${quote.outputCurrency}` - ); - } - - protected evmNextPhaseSelector(state: RampState, quote: QuoteTicket): RampPhase { - if (state.type === RampDirection.BUY) { - return "squidRouterSwap"; - } - if (quote.outputCurrency === FiatToken.EURC) { - return "mykoboPayoutOnBase"; - } - return "brlaPayoutOnBase"; - } } export default new SubsidizePostSwapPhaseHandler(); diff --git a/apps/api/src/api/services/phases/handlers/subsidize-pre-swap-handler.ts b/apps/api/src/api/services/phases/handlers/subsidize-pre-swap-handler.ts index 3d7ff6875..18ca97098 100644 --- a/apps/api/src/api/services/phases/handlers/subsidize-pre-swap-handler.ts +++ b/apps/api/src/api/services/phases/handlers/subsidize-pre-swap-handler.ts @@ -75,7 +75,6 @@ export class SubsidizePreSwapPhaseHandler extends BasePhaseHandler { inputToken: ALFREDPAY_EVM_TOKEN, inputTokenDetails, logLabel: "Alfredpay", - nextPhase: "squidRouterSwap" as RampPhase, subsidyToken: ALFREDPAY_EVM_TOKEN as unknown as SubsidyToken, tokenContract: ALFREDPAY_ERC20_TOKEN }; @@ -99,7 +98,6 @@ export class SubsidizePreSwapPhaseHandler extends BasePhaseHandler { inputToken, inputTokenDetails, logLabel: "EVM", - nextPhase: "nablaApprove" as RampPhase, subsidyToken: quote.metadata.nablaSwapEvm.inputCurrency as unknown as SubsidyToken, tokenContract: inputTokenDetails.erc20AddressSourceChain as `0x${string}` }; @@ -185,7 +183,7 @@ export class SubsidizePreSwapPhaseHandler extends BasePhaseHandler { await waitUntilTrueWithTimeout(didBalanceReachExpected, 5000); } - return this.transitionToNextPhase(state, "nablaApprove"); + return state; } catch (e) { logger.error("Error in subsidizePreSwap (substrate):", e); throw this.createRecoverableError("SubsidizePreSwapPhaseHandler: Failed to subsidize pre swap."); @@ -205,7 +203,6 @@ export class SubsidizePreSwapPhaseHandler extends BasePhaseHandler { inputToken, inputTokenDetails, logLabel, - nextPhase, expectedInputAmountForSwapRaw, subsidyToken, tokenContract @@ -243,11 +240,12 @@ export class SubsidizePreSwapPhaseHandler extends BasePhaseHandler { quote.outputCurrency as RampCurrency, EvmToken.USDC as RampCurrency ); - const subsidyCapUsd = Big(quoteOutputUsd).mul(MAX_EVM_SWAP_SUBSIDY_QUOTE_FRACTION); + const percentageCap = Big(quoteOutputUsd).mul(MAX_EVM_SWAP_SUBSIDY_QUOTE_FRACTION); + const subsidyCapUsd = percentageCap.gt("1") ? percentageCap : Big("1"); if (Big(subsidyUsd).gt(subsidyCapUsd)) { // Pause for operator intervention without moving the ramp to failed. throw this.createRecoverableError( - `SubsidizePreSwapPhaseHandler: Required subsidy $${subsidyUsd} exceeds cap $${subsidyCapUsd.toFixed(2)} (${MAX_EVM_SWAP_SUBSIDY_QUOTE_FRACTION} of quote output $${quoteOutputUsd}).` + `SubsidizePreSwapPhaseHandler: Required subsidy $${subsidyUsd} exceeds cap $${subsidyCapUsd.toFixed(2)} (max of $1.00 and ${MAX_EVM_SWAP_SUBSIDY_QUOTE_FRACTION} of quote output $${quoteOutputUsd}).` ); } @@ -292,7 +290,7 @@ export class SubsidizePreSwapPhaseHandler extends BasePhaseHandler { } } - return this.transitionToNextPhase(state, nextPhase); + return state; } catch (e) { logger.error("Error in subsidizePreSwap (EVM):", e); if (e instanceof PhaseError) { diff --git a/apps/api/src/api/services/phases/meta-state-types.ts b/apps/api/src/api/services/phases/meta-state-types.ts index c7509915a..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 } from "@vortexfi/shared"; +import { AlfredpayFiatPaymentInstructions, ExtrinsicOptions, IbanPaymentData, Networks, RampPhase } from "@vortexfi/shared"; export interface StateMetadata { nablaSoftMinimumOutputRaw: string; @@ -77,4 +77,24 @@ 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; + morphoDepositTxHash?: `0x${string}`; + // Morpho vault redeem (offramp) + morphoRedeemVaultAddress?: string; + morphoRedeemShareTokenAddress?: string; + morphoRedeemAssetAddress?: string; + morphoRedeemSharesAmountRaw?: string; + morphoRedeemMinOutputRaw?: string; + morphoRedeemTxHash?: `0x${string}`; + morphoRedeemActualOutputRaw?: string; + morphoPermitTxHash?: `0x${string}`; + morphoPermitTransferFromTxHash?: `0x${string}`; + morphoNetwork?: Networks; } diff --git a/apps/api/src/api/services/phases/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..9a89ccdf8 --- /dev/null +++ b/apps/api/src/api/services/phases/ramp-flow-definitions.ts @@ -0,0 +1,246 @@ +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 On-Ramp (Mykobo SEPA on Base → Morpho Vault) ────────────────── + +/** + * Generic EUR onramp from Mykobo SEPA → deposit into a Morpho vault on any EVM chain. + * EURC on Base via Nabla swap to USDC, then SquidRouter bridge to the vault's chain, + * then deposit into the Morpho vault. Use this for vaults on chains other than Base. + */ +export const EUR_ONRAMP_MORPHO: RampPhase[] = [ + "initial", + "mykoboOnrampDeposit", + "fundEphemeral", + "subsidizePreSwap", + "nablaApprove", + "nablaSwap", + "distributeFees", + "subsidizePostSwap", + "squidRouterSwap", + "squidRouterPay", + "finalSettlementSubsidy", + "morphoDeposit", + "complete" +]; + +/** + * Same-chain variant for vaults on Base. After the Nabla EURC→USDC swap on Base, USDC + * is already on Base, so the deposit into the Base vault does not need a SquidRouter + * bridge. The route-prep code in `mykobo-to-evm-morpho.ts` selects this flow when + * `morphoNetwork === Networks.Base` and skips adding bridge presignedTxs. + */ +export const EUR_ONRAMP_BASE_MORPHO: RampPhase[] = [ + "initial", + "mykoboOnrampDeposit", + "fundEphemeral", + "subsidizePreSwap", + "nablaApprove", + "nablaSwap", + "distributeFees", + "subsidizePostSwap", + "morphoDeposit", + "complete" +]; + +// ─── Morpho Vault Off-Ramp (Morpho Vault → Mykobo SEPA on Base) ──────────────── + +/** + * Generic EUR offramp from Morpho vault shares on any EVM chain → SEPA payout via Mykobo. + * User signs a single EIP-2612 Permit typed data on the vault (spender = ephemeral). + * The ephemeral broadcasts permit + transferFrom, redeems shares to USDC on the + * vault's chain, then SquidRouter bridges the USDC to Base, where the Mykobo payout + * leg (Nabla USDC→EURC + EURC→Mykobo) executes. Use this for vaults on chains + * other than Base. + */ +export const EUR_OFFRAMP_MORPHO: RampPhase[] = [ + "initial", + "fundEphemeral", + "morphoPermitExecute", + "morphoRedeem", + "squidRouterSwap", + "squidRouterPay", + "distributeFees", + "subsidizePreSwap", + "nablaApprove", + "nablaSwap", + "subsidizePostSwap", + "mykoboPayoutOnBase", + "complete" +]; + +/** + * Same-chain variant for vaults on Base. After morphoRedeem, USDC is already on Base, + * so no SquidRouter bridge is needed before the Mykobo payout leg. The route-prep + * code in `evm-to-mykobo-morpho.ts` selects this flow when + * `morphoNetwork === Networks.Base` and skips adding bridge presignedTxs. + */ +export const EUR_OFFRAMP_BASE_MORPHO: RampPhase[] = [ + "initial", + "fundEphemeral", + "morphoPermitExecute", + "morphoRedeem", + "distributeFees", + "subsidizePreSwap", + "nablaApprove", + "nablaSwap", + "subsidizePostSwap", + "mykoboPayoutOnBase", + "complete" +]; diff --git a/apps/api/src/api/services/phases/register-handlers.ts b/apps/api/src/api/services/phases/register-handlers.ts index eb48061c1..ab6b17a84 100644 --- a/apps/api/src/api/services/phases/register-handlers.ts +++ b/apps/api/src/api/services/phases/register-handlers.ts @@ -12,6 +12,9 @@ import hydrationToAssethubXcmPhaseHandler from "./handlers/hydration-to-assethub import initialPhaseHandler from "./handlers/initial-phase-handler"; import moonbeamToPendulumPhaseHandler from "./handlers/moonbeam-to-pendulum-handler"; import moonbeamToPendulumXcmHandler from "./handlers/moonbeam-to-pendulum-xcm-handler"; +import morphoDepositHandler from "./handlers/morpho-deposit-handler"; +import morphoPermitExecuteHandler from "./handlers/morpho-permit-execute-handler"; +import morphoRedeemHandler from "./handlers/morpho-redeem-handler"; import mykoboOnrampDepositHandler from "./handlers/mykobo-onramp-deposit-handler"; import mykoboPayoutHandler from "./handlers/mykobo-payout-handler"; import nablaApproveHandler from "./handlers/nabla-approve-handler"; @@ -58,6 +61,9 @@ export function registerPhaseHandlers(): void { phaseRegistry.registerHandler(finalSettlementSubsidy); phaseRegistry.registerHandler(destinationTransferHandler); phaseRegistry.registerHandler(squidRouterPermitExecutionHandler); + phaseRegistry.registerHandler(morphoDepositHandler); + phaseRegistry.registerHandler(morphoPermitExecuteHandler); + phaseRegistry.registerHandler(morphoRedeemHandler); logger.info("Phase handlers registered"); } diff --git a/apps/api/src/api/services/priceFeed.service.ts b/apps/api/src/api/services/priceFeed.service.ts index 782f2e2f6..c22aa2af5 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", + VAULT: "usd-coin" }; return tokenIdMap[currency.toUpperCase()] || null; diff --git a/apps/api/src/api/services/quote/core/squidrouter.ts b/apps/api/src/api/services/quote/core/squidrouter.ts index 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/core/types.ts b/apps/api/src/api/services/quote/core/types.ts index 523ed37d4..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 @@ -203,6 +205,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/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..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 @@ -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 && swapNetwork === toNetwork) + ) { 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..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]; @@ -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 && swapNetwork === toNetwork) + ) { return { anchor: { amount: computedAnchorFee, currency: anchorFeeCurrency }, network: { amount: "0", currency: "USD" as RampCurrency } diff --git a/apps/api/src/api/services/quote/engines/initialize/offramp-from-evm-morpho.ts b/apps/api/src/api/services/quote/engines/initialize/offramp-from-evm-morpho.ts new file mode 100644 index 000000000..fadf81ec0 --- /dev/null +++ b/apps/api/src/api/services/quote/engines/initialize/offramp-from-evm-morpho.ts @@ -0,0 +1,101 @@ +import { EvmClientManager, EvmNetworks, EvmToken, Networks, RampDirection } from "@vortexfi/shared"; +import Big from "big.js"; +import { getMorphoVaultInfo } from "../../../phases/handlers/morpho-vault-config"; +import { EvmBridgeQuoteRequest, getEvmBridgeQuote } from "../../core/squidrouter"; +import { BridgeMeta, QuoteContext } from "../../core/types"; +import { assignPreNablaContext, BaseInitializeEngine } from "./index"; + +const vaultAbi = [ + { + inputs: [{ name: "shares", type: "uint256" }], + name: "previewRedeem", + outputs: [{ name: "assets", type: "uint256" }], + stateMutability: "view", + type: "function" + } +] as const; + +export class OffRampFromEvmInitializeMorphoEngine extends BaseInitializeEngine { + readonly config = { + direction: RampDirection.SELL, + skipNote: "OffRampFromEvmInitializeMorphoEngine: Skipped because rampType is BUY, this engine handles SELL operations only" + }; + + protected async executeInternal(ctx: QuoteContext): Promise { + const req = ctx.request; + + // 1. Resolve the Morpho vault and compute shares -> USDC conversion via previewRedeem. + const vault = getMorphoVaultInfo("usdc-arbitrum"); + const evmClientManager = EvmClientManager.getInstance(); + const vaultNetwork = vault.network as EvmNetworks; + const vaultClient = evmClientManager.getClient(vaultNetwork); + + const sharesAmountRaw = new Big(req.inputAmount).mul(new Big(10).pow(vault.shareDecimals)).toFixed(0, 0); + + const assetsFromShares = (await vaultClient.readContract({ + abi: vaultAbi, + address: vault.vaultAddress, + args: [BigInt(sharesAmountRaw)], + functionName: "previewRedeem" + })) as bigint; + + const expectedUsdcDecimal = new Big(assetsFromShares.toString()).div(new Big(10).pow(vault.depositAssetDecimals)); + + // 2. Set up preNabla context (mirrors the standard offramp EVM flow) + await assignPreNablaContext(ctx); + + // 3. Bridge context: when the vault is on Base, USDC is already on Base and no + // SquidRouter bridge is required. Surface a same-chain evmToEvm so downstream + // engines (e.g. OffRampSwapEngineEvm) can read outputAmountDecimal without + // conditional code paths. For non-Base vaults, fetch a real bridge quote. + if (vaultNetwork === Networks.Base) { + ctx.evmToEvm = { + fromNetwork: vaultNetwork, + fromToken: vault.depositAssetAddress as `0x${string}`, + inputAmountDecimal: expectedUsdcDecimal, + inputAmountRaw: assetsFromShares.toString(), + networkFeeUSD: "0", + outputAmountDecimal: expectedUsdcDecimal, + outputAmountRaw: assetsFromShares.toString(), + toNetwork: vaultNetwork, + toToken: vault.depositAssetAddress as `0x${string}` + } satisfies BridgeMeta; + } else { + const bridgeRequest: EvmBridgeQuoteRequest = { + amountDecimal: expectedUsdcDecimal.toFixed(vault.depositAssetDecimals), + fromNetwork: vaultNetwork, + inputCurrency: EvmToken.USDC, + outputCurrency: EvmToken.USDC, + rampType: req.rampType, + toNetwork: Networks.Base + }; + + const bridgeQuote = await getEvmBridgeQuote(bridgeRequest); + + ctx.evmToEvm = { + ...bridgeRequest, + fromToken: bridgeQuote.fromToken, + inputAmountDecimal: new Big(bridgeRequest.amountDecimal), + inputAmountRaw: bridgeQuote.inputAmountRaw, + networkFeeUSD: bridgeQuote.networkFeeUSD, + outputAmountDecimal: bridgeQuote.outputAmountDecimal, + outputAmountRaw: bridgeQuote.outputAmountRaw, + toToken: bridgeQuote.toToken + }; + } + + // 4. Surface redeem metadata for the route-prep phase (compute share->USDC rate at quote time). + ctx.redeemMeta = { + expectedUsdcRaw: assetsFromShares.toString(), + sharesAmountRaw, + vaultAddress: vault.vaultAddress + }; + + ctx.addNote?.( + `Morpho offramp init: ${sharesAmountRaw} vault shares on ${vaultNetwork} -> ~${expectedUsdcDecimal.toFixed()} USDC (previewRedeem)` + + (vaultNetwork === Networks.Base + ? "" + : `; bridge to ${ctx.evmToEvm.outputAmountDecimal.toFixed()} USDC on ${Networks.Base}`) + ); + } +} diff --git a/apps/api/src/api/services/quote/engines/merge-subsidy/offramp-evm.ts b/apps/api/src/api/services/quote/engines/merge-subsidy/offramp-evm.ts index 10c0317cd..5509a53ce 100644 --- a/apps/api/src/api/services/quote/engines/merge-subsidy/offramp-evm.ts +++ b/apps/api/src/api/services/quote/engines/merge-subsidy/offramp-evm.ts @@ -32,6 +32,8 @@ export class OffRampMergeSubsidyEvmEngine implements Stage { ctx.nablaSwapEvm = { ...ctx.nablaSwapEvm, + ammOutputAmountDecimal: ctx.nablaSwapEvm.ammOutputAmountDecimal ?? ctx.nablaSwapEvm.outputAmountDecimal, + ammOutputAmountRaw: ctx.nablaSwapEvm.ammOutputAmountRaw ?? ctx.nablaSwapEvm.outputAmountRaw, outputAmountDecimal: ctx.nablaSwapEvm.outputAmountDecimal.plus(ctx.subsidy.subsidyAmountInOutputTokenDecimal), outputAmountRaw: (BigInt(ctx.nablaSwapEvm.outputAmountRaw) + BigInt(ctx.subsidy.subsidyAmountInOutputTokenRaw)).toString() }; diff --git a/apps/api/src/api/services/quote/engines/squidrouter/onramp-base-to-evm.ts b/apps/api/src/api/services/quote/engines/squidrouter/onramp-base-to-evm.ts index cd5e2af8a..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"; @@ -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" }; @@ -135,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/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 }); } 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/ramp/ramp.service.ts b/apps/api/src/api/services/ramp/ramp.service.ts index 9484a65f0..b50bae634 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, @@ -60,6 +61,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"; @@ -1149,6 +1151,17 @@ export class RampService extends BaseRampService { wallet_address: evmEphemeralEntry.address }); + // Mocking mykobo intent call + // const intent = { + // instructions: { + // bank_account_name: "Mykobo Test", + // iban: "DE89370400440532013000" + // }, + // transaction: { + // id: "mykobo-transaction-id", + // reference: "mykobo-transaction-reference" + // } + // }; const instructions = intent.instructions; if (!instructions || !("iban" in instructions)) { throw new APIError({ @@ -1157,15 +1170,33 @@ export class RampService extends BaseRampService { }); } - const { unsignedTxs, stateMeta } = await prepareMykoboToEvmOnrampTransactions({ - destinationAddress: additionalData.destinationAddress, - ipAddress: additionalData.ipAddress, - 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: additionalData.ipAddress, + mykoboEmail: additionalData.email, + mykoboTransactionId: intent.transaction.id, + mykoboTransactionReference: intent.transaction.reference, + quote, + signingAccounts: normalizedSigningAccounts + }); + unsignedTxs = result.unsignedTxs; + stateMeta = result.stateMeta; + } else { + const result = await prepareMykoboToEvmOnrampTransactions({ + destinationAddress: additionalData.destinationAddress, + ipAddress: additionalData.ipAddress, + mykoboEmail: additionalData.email, + mykoboTransactionId: intent.transaction.id, + mykoboTransactionReference: intent.transaction.reference, + quote, + signingAccounts: normalizedSigningAccounts + }); + unsignedTxs = result.unsignedTxs; + stateMeta = result.stateMeta; + } const ibanPaymentData: IbanPaymentData = { bic: "", @@ -1263,12 +1294,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/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 bc6ba3bbe..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 @@ -41,12 +41,13 @@ import erc20ABI from "../../../../../contracts/ERC20"; import AlfredPayCustomer from "../../../../../models/alfredPayCustomer.model"; import { getEvmFundingAccount } from "../../../phases/evm-funding"; import { StateMetadata } from "../../../phases/meta-state-types"; +import { ALFREDPAY_OFFRAMP } from "../../../phases/ramp-flow-definitions"; import { encodeEvmTransactionData } from "../../index"; import { addOnrampDestinationChainTransactions } from "../../onramp/common/transactions"; import { preparePolygonCleanupApproval } from "../../polygon/cleanup"; import { OfframpTransactionParams, OfframpTransactionsWithMeta } from "../common/types"; -// TokenRelayer deployments. Address may differ per chain +// TokenRelayer deployments. Address may differ per chain export const RELAYER_ADDRESSES: Partial> = { [Networks.Arbitrum]: "0xC9ECD03c89349B3EAe4613c7091c6c3029413785", [Networks.Base]: "0xDbece5cE27984FC64688bcC57f75b96a28e8c68c", @@ -54,7 +55,6 @@ export const RELAYER_ADDRESSES: Partial> = { [Networks.Avalanche]: "0x11871C77Aa0170ae13864E4E82cFa471720e045e", [Networks.Ethereum]: "0x522A51f9c5B1683F0F15910075487c4D162A8b83", [Networks.BSC]: "0x2d657ac14088fED401b58FEd377988ed3F875220" - }; export function getRelayerAddress(network: EvmNetworks): `0x${string}` { @@ -67,9 +67,12 @@ export function getRelayerAddress(network: EvmNetworks): `0x${string}` { /** * Resolves the EIP-712 domain for a token's permit signature. - * Some tokens (like USDT in polygon) use salt-based domain separation instead of chainId. + * Supports three formats: + * - Standard (OZ ERC20Permit): name, version, chainId, verifyingContract + * - Salt-based (e.g. USDT on Polygon): name, version, verifyingContract, salt + * - Minimal (e.g. Morpho VaultV2): chainId, verifyingContract */ -async function resolvePermitDomain( +export async function resolvePermitDomain( publicClient: PublicClient, tokenAddress: `0x${string}`, chainId: number, @@ -78,7 +81,7 @@ async function resolvePermitDomain( let version = "1"; try { version = (await publicClient.readContract({ - abi: [{ inputs: [], name: "version", outputs: [{ type: "string" }], type: "function" }], + abi: [{ inputs: [], name: "version", outputs: [{ type: "string" }], stateMutability: "view", type: "function" }], address: tokenAddress, functionName: "version" })) as string; @@ -96,10 +99,20 @@ async function resolvePermitDomain( ]) ); + const minimalHash = keccak256( + encodeAbiParameters(parseAbiParameters("bytes32, uint256, address"), [ + keccak256(toHex("EIP712Domain(uint256 chainId,address verifyingContract)")), + BigInt(chainId), + tokenAddress + ]) + ); + let onChainSeparator: `0x${string}` | undefined; try { onChainSeparator = (await publicClient.readContract({ - abi: [{ inputs: [], name: "DOMAIN_SEPARATOR", outputs: [{ type: "bytes32" }], type: "function" }], + abi: [ + { inputs: [], name: "DOMAIN_SEPARATOR", outputs: [{ type: "bytes32" }], stateMutability: "view", type: "function" } + ], address: tokenAddress, functionName: "DOMAIN_SEPARATOR" })) as `0x${string}`; @@ -108,30 +121,31 @@ async function resolvePermitDomain( } if (onChainSeparator !== undefined) { - if (onChainSeparator !== standardHash) { - // On-chain separator exists but doesn't match standard - compute salt hash for comparison - const salt = pad(toHex(chainId), { size: 32 }); - const saltHash = keccak256( - encodeAbiParameters(parseAbiParameters("bytes32, bytes32, bytes32, address, bytes32"), [ - keccak256(toHex("EIP712Domain(string name,string version,address verifyingContract,bytes32 salt)")), - keccak256(toHex(tokenName)), - keccak256(toHex(version)), - tokenAddress, - salt - ]) - ); - - if (onChainSeparator === saltHash) { - return { name: tokenName, salt, verifyingContract: tokenAddress, version }; - } - - // Neither matches - this is an error - throw new Error( - `Token ${tokenName} has unexpected DOMAIN_SEPARATOR. Expected standard: ${standardHash} or salt: ${saltHash}, got: ${onChainSeparator}` - ); + if (onChainSeparator === standardHash) { + return { chainId, name: tokenName, verifyingContract: tokenAddress, version }; + } + if (onChainSeparator === minimalHash) { + return { chainId, verifyingContract: tokenAddress }; } - // use standard domain - return { chainId, name: tokenName, verifyingContract: tokenAddress, version }; + // On-chain separator exists but doesn't match standard or minimal - try salt hash. + const salt = pad(toHex(chainId), { size: 32 }); + const saltHash = keccak256( + encodeAbiParameters(parseAbiParameters("bytes32, bytes32, bytes32, address, bytes32"), [ + keccak256(toHex("EIP712Domain(string name,string version,address verifyingContract,bytes32 salt)")), + keccak256(toHex(tokenName)), + keccak256(toHex(version)), + tokenAddress, + salt + ]) + ); + + if (onChainSeparator === saltHash) { + return { name: tokenName, salt, verifyingContract: tokenAddress, version }; + } + + throw new Error( + `Token ${tokenName} has unexpected DOMAIN_SEPARATOR. Expected standard: ${standardHash}, minimal: ${minimalHash} or salt: ${saltHash}, got: ${onChainSeparator}` + ); } // No on-chain separator available - default to standard @@ -322,6 +336,7 @@ export async function prepareEvmToAlfredpayOfframpTransactions({ evmEphemeralAddress: evmEphemeralEntry.address, fiatAccountId, isDirectTransfer: true, + phaseFlow: ALFREDPAY_OFFRAMP, walletAddress: userAddress }; } else { @@ -338,9 +353,7 @@ export async function prepareEvmToAlfredpayOfframpTransactions({ const relayerAddress = RELAYER_ADDRESSES[fromNetwork]; if (!relayerAddress) { - throw new Error( - `Alfredpay offramp permit flow is not supported on ${fromNetwork}: no relayer deployment configured` - ); + throw new Error(`Alfredpay offramp permit flow is not supported on ${fromNetwork}: no relayer deployment configured`); } const permitTypedData: SignedTypedData = { @@ -414,6 +427,7 @@ export async function prepareEvmToAlfredpayOfframpTransactions({ alfredpayUserId: customer.alfredPayId, evmEphemeralAddress: evmEphemeralEntry.address, fiatAccountId, + phaseFlow: ALFREDPAY_OFFRAMP, squidRouterPermitExecutionValue: bridgeResult.swapData.value, walletAddress: userAddress }; @@ -449,6 +463,7 @@ export async function prepareEvmToAlfredpayOfframpTransactions({ fiatAccountId, isDirectTransfer: true, isNoPermitFallback: true, + phaseFlow: ALFREDPAY_OFFRAMP, walletAddress: userAddress }; } else { @@ -490,6 +505,7 @@ export async function prepareEvmToAlfredpayOfframpTransactions({ evmEphemeralAddress: evmEphemeralEntry.address, fiatAccountId, isNoPermitFallback: true, + phaseFlow: ALFREDPAY_OFFRAMP, squidRouterPermitExecutionValue: bridgeResult.swapData.value, walletAddress: userAddress }; diff --git a/apps/api/src/api/services/transactions/offramp/routes/evm-to-brl-base.ts b/apps/api/src/api/services/transactions/offramp/routes/evm-to-brl-base.ts index 6bfa4e181..203e0b673 100644 --- a/apps/api/src/api/services/transactions/offramp/routes/evm-to-brl-base.ts +++ b/apps/api/src/api/services/transactions/offramp/routes/evm-to-brl-base.ts @@ -13,6 +13,7 @@ import { encodeFunctionData } from "viem"; import erc20ABI from "../../../../../contracts/ERC20"; import { getEvmFundingAccount } from "../../../phases/evm-funding"; import { StateMetadata } from "../../../phases/meta-state-types"; +import { BRL_OFFRAMP_BASE } from "../../../phases/ramp-flow-definitions"; import { encodeEvmTransactionData } from "../.."; import { prepareBaseCleanupApproval } from "../../base/cleanup"; import { addEvmFeeDistributionTransaction } from "../../common/feeDistribution"; @@ -233,6 +234,7 @@ export async function prepareEvmToBRLOfframpBaseTransactions({ ...stateMeta, brlaEvmAddress: validatedBrlaEvmAddress, evmEphemeralAddress: evmEphemeralEntry.address, + phaseFlow: BRL_OFFRAMP_BASE, pixDestination: validatedPixDestination, receiverTaxId: validatedReceiverTaxId, taxId: validatedTaxId diff --git a/apps/api/src/api/services/transactions/offramp/routes/evm-to-mykobo-morpho.ts b/apps/api/src/api/services/transactions/offramp/routes/evm-to-mykobo-morpho.ts new file mode 100644 index 000000000..a8e5e570d --- /dev/null +++ b/apps/api/src/api/services/transactions/offramp/routes/evm-to-mykobo-morpho.ts @@ -0,0 +1,453 @@ +import { + createOfframpSquidrouterTransactionsToEvm, + EvmClientManager, + EvmNetworks, + EvmToken, + EvmTransactionData, + evmTokenConfig, + getNetworkId, + isWithdrawInstructions, + MykoboApiService, + MykoboCurrency, + MykoboTransactionType, + multiplyByPowerOfTen, + Networks, + SignedTypedData, + UnsignedTx +} from "@vortexfi/shared"; +import Big from "big.js"; +import httpStatus from "http-status"; +import { encodeFunctionData } from "viem"; +import { APIError } from "../../../../errors/api-error"; +import { syncMykoboCustomerKyc } from "../../../mykobo/mykobo-customer.service"; +import { getEvmFundingAccount } from "../../../phases/evm-funding"; +import { getMorphoVaultInfo } from "../../../phases/handlers/morpho-vault-config"; +import { StateMetadata } from "../../../phases/meta-state-types"; +import { EUR_OFFRAMP_BASE_MORPHO, EUR_OFFRAMP_MORPHO } from "../../../phases/ramp-flow-definitions"; +import { prepareBaseCleanupApproval } from "../../base/cleanup"; +import { addEvmFeeDistributionTransaction } from "../../common/feeDistribution"; +import { encodeEvmTransactionData } from "../../index"; +import { addNablaSwapTransactionsOnBase, addOnrampDestinationChainTransactions } from "../../onramp/common/transactions"; +import { OfframpTransactionParams, OfframpTransactionsWithMeta } from "../common/types"; +import { resolvePermitDomain } from "./evm-to-alfredpay"; + +const erc20Abi = [ + { + inputs: [{ name: "account", type: "address" }], + name: "balanceOf", + outputs: [{ name: "", type: "uint256" }], + stateMutability: "view", + type: "function" + }, + { + inputs: [{ name: "owner", type: "address" }], + name: "nonces", + outputs: [{ name: "", type: "uint256" }], + stateMutability: "view", + type: "function" + }, + { inputs: [], name: "name", outputs: [{ name: "", type: "string" }], stateMutability: "view", type: "function" } +] as const; + +const REDEEM_SLIPPAGE_BPS = 50; // 0.5% — covers interest accrual between quote-time and redeem-time (0% vault fee assumed) + +const vaultRedeemAbi = [ + { + inputs: [ + { name: "shares", type: "uint256" }, + { name: "receiver", type: "address" }, + { name: "owner", type: "address" } + ], + name: "redeem", + outputs: [{ name: "assets", type: "uint256" }], + stateMutability: "nonpayable", + type: "function" + } +] as const; + +/** + * Prepares all transactions for an offramp from Morpho vault shares on any EVM + * chain → SEPA payout via Mykobo on Base. + * + * Flow: + * 1. User signs a single EIP-2612 Permit typed data on the vault (spender = ephemeral). + * This is submitted as a presignedTx (SignedTypedData) at registration. + * 2. morphoPermitExecute handler broadcasts: + * a) vault.permit(owner, spender, value, deadline, v, r, s) using the executor key — + * permit() does not require the spender to be the caller, so this is fine. + * b) vault.transferFrom(user, ephemeral, shares) signed by the ephemeral (presignedTx, + * nonce 0 on the vault's chain). Only the spender can call transferFrom on its own + * allowance. + * 3. morphoRedeem: ephemeral calls vault.redeem(shares, ephemeral, ephemeral) → USDC on + * the vault's chain. + * 4. If the vault is NOT on Base, SquidRouter bridge brings the USDC to Base. If the + * vault IS on Base, this step is skipped. + * 5. On Base: distribute fees, swap USDC→EURC via Nabla, transfer EURC to Mykobo, cleanups. + */ +export async function prepareEvmToMykoboMorphoOfframpTransactions({ + quote, + signingAccounts, + userAddress, + email, + destinationAddress, + ipAddress, + userId +}: OfframpTransactionParams): Promise { + const unsignedTxs: UnsignedTx[] = []; + let stateMeta: Partial = {}; + + if (!email) { + throw new APIError({ + isPublic: true, + message: "email must be provided for Morpho EUR offramp (Mykobo)", + status: httpStatus.BAD_REQUEST + }); + } + if (!ipAddress) { + throw new APIError({ + isPublic: true, + message: "ipAddress must be provided for Morpho EUR offramp (Mykobo)", + status: httpStatus.BAD_REQUEST + }); + } + if (!destinationAddress) { + throw new APIError({ + isPublic: true, + message: "destinationAddress (user receiving wallet) must be provided for Morpho EUR offramp", + status: httpStatus.BAD_REQUEST + }); + } + if (!userAddress) { + throw new Error("User address must be provided for Morpho offramping"); + } + + const evmEphemeralEntry = signingAccounts.find(account => account.type === "EVM"); + if (!evmEphemeralEntry) { + throw new Error("EVM ephemeral account not found for Morpho to Mykobo offramp"); + } + + const baseUsdcAddress = evmTokenConfig[Networks.Base][EvmToken.USDC]?.erc20AddressSourceChain; + if (!baseUsdcAddress) { + throw new Error("Invalid USDC configuration for Base in evmTokenConfig"); + } + const baseEurcAddress = evmTokenConfig[Networks.Base][EvmToken.EURC]?.erc20AddressSourceChain; + if (!baseEurcAddress) { + throw new Error("Invalid EURC configuration for Base in evmTokenConfig"); + } + const baseAxlUsdcAddress = evmTokenConfig[Networks.Base][EvmToken.AXLUSDC]?.erc20AddressSourceChain; + if (!baseAxlUsdcAddress) { + throw new Error("Invalid AXLUSDC configuration for Base in evmTokenConfig"); + } + + const morphoVault = getMorphoVaultInfo("usdc-arbitrum"); + const morphoNetwork = morphoVault.network as EvmNetworks; + const isBaseVault = morphoNetwork === Networks.Base; + + const evmClientManager = EvmClientManager.getInstance(); + const vaultClient = evmClientManager.getClient(morphoNetwork); + const vaultChainId = getNetworkId(morphoNetwork); + + if (!vaultChainId) { + throw new Error(`Could not resolve chain id for ${morphoNetwork}`); + } + + const userNonce = (await vaultClient.readContract({ + abi: erc20Abi, + address: morphoVault.vaultAddress, + args: [userAddress], + functionName: "nonces" + })) as bigint; + + const tokenName = (await vaultClient.readContract({ + abi: erc20Abi, + address: morphoVault.vaultAddress, + functionName: "name" + })) as string; + + const resolvedDomain = await resolvePermitDomain(vaultClient, morphoVault.vaultAddress, vaultChainId, tokenName); + + const sharesAmountRaw = quote.metadata.redeemMeta?.sharesAmountRaw; + if (!sharesAmountRaw) { + throw new Error("Missing quote.metadata.redeemMeta.sharesAmountRaw; was the offramp Morpho initialize engine run?"); + } + + if (!isBaseVault && !quote.metadata.evmToEvm?.inputAmountRaw) { + throw new Error("Missing evmToEvm.inputAmountRaw in quote metadata for Morpho offramp (bridge input)"); + } + + const permitDeadline = BigInt(Math.floor(Date.now() / 1000) + 24 * 60 * 60); + + // 1. User-signed EIP-2612 Permit typed data (spender = ephemeral). + // Submitted as a presignedTx (SignedTypedData) at registration. The handler validates the + // typed data, then uses the embedded v/r/s to call vault.permit via the executor key. + const permitTypedData: SignedTypedData = { + domain: resolvedDomain, + message: { + deadline: permitDeadline.toString(), + nonce: userNonce.toString(), + owner: userAddress, + spender: evmEphemeralEntry.address, + value: sharesAmountRaw + }, + primaryType: "Permit", + types: { + Permit: [ + { name: "owner", type: "address" }, + { name: "spender", type: "address" }, + { name: "value", type: "uint256" }, + { name: "nonce", type: "uint256" }, + { name: "deadline", type: "uint256" } + ] + } + }; + + unsignedTxs.push({ + meta: {}, + network: morphoNetwork, + nonce: 0, + phase: "morphoPermitExecute", + signer: userAddress, + txData: permitTypedData + }); + + // 2. Ephemeral-signed transferFrom presigned tx. Only the spender (the ephemeral) can call + // transferFrom on the allowance granted by permit(), so this must be ephemeral-signed. + // Phase + signer match the existing alfredpay direct-transfer pattern; the handler reads + // the user-signed permit entry and the ephemeral-signed transferFrom entry together. + const { maxFeePerGas, maxPriorityFeePerGas } = await vaultClient.estimateFeesPerGas(); + + const transferFromCallData = encodeFunctionData({ + abi: [ + { + inputs: [ + { name: "from", type: "address" }, + { name: "to", type: "address" }, + { name: "value", type: "uint256" } + ], + name: "transferFrom", + outputs: [{ name: "", type: "bool" }], + stateMutability: "nonpayable", + type: "function" + } + ], + args: [userAddress as `0x${string}`, evmEphemeralEntry.address as `0x${string}`, BigInt(sharesAmountRaw)], + functionName: "transferFrom" + }); + + unsignedTxs.push({ + meta: {}, + network: morphoNetwork, + nonce: 0, + phase: "morphoPermitExecute", + signer: evmEphemeralEntry.address, + txData: encodeEvmTransactionData({ + data: transferFromCallData, + gas: "200000", + maxFeePerGas: String(maxFeePerGas), + maxPriorityFeePerGas: String(maxPriorityFeePerGas), + to: morphoVault.vaultAddress, + value: "0" + }) as EvmTransactionData + }); + + // 3. morphoRedeem: ephemeral calls vault.redeem(shares, ephemeral, ephemeral) + // → USDC on the vault's chain. + const redeemCallData = encodeFunctionData({ + abi: vaultRedeemAbi, + args: [BigInt(sharesAmountRaw), evmEphemeralEntry.address as `0x${string}`, evmEphemeralEntry.address as `0x${string}`], + functionName: "redeem" + }); + + let morphoRedeemNonce = 1; + unsignedTxs.push({ + meta: {}, + network: morphoNetwork, + nonce: morphoRedeemNonce++, + phase: "morphoRedeem", + signer: evmEphemeralEntry.address, + txData: encodeEvmTransactionData({ + data: redeemCallData, + gas: "500000", + maxFeePerGas: String(maxFeePerGas), + maxPriorityFeePerGas: String(maxPriorityFeePerGas), + to: morphoVault.vaultAddress, + value: "0" + }) as EvmTransactionData + }); + + // 4. SquidRouter bridge: only when the vault is on a chain other than Base. + let squidRouterQuoteId: string | undefined; + if (!isBaseVault) { + const bridgeInputAmountRaw = quote.metadata.evmToEvm?.inputAmountRaw; + if (!bridgeInputAmountRaw) { + throw new Error("Missing evmToEvm.inputAmountRaw in quote metadata for Morpho offramp (bridge input)"); + } + const { + approveData, + swapData, + squidRouterQuoteId: quoteId + } = await createOfframpSquidrouterTransactionsToEvm({ + destinationAddress: evmEphemeralEntry.address, + fromAddress: evmEphemeralEntry.address, + fromNetwork: morphoNetwork, + fromToken: morphoVault.depositAssetAddress as `0x${string}`, + rawAmount: bridgeInputAmountRaw, + toNetwork: Networks.Base, + toToken: baseUsdcAddress as `0x${string}` + }); + squidRouterQuoteId = quoteId; + + unsignedTxs.push({ + meta: {}, + network: morphoNetwork, + nonce: morphoRedeemNonce++, + phase: "squidRouterApprove", + signer: evmEphemeralEntry.address, + txData: encodeEvmTransactionData(approveData) as EvmTransactionData + }); + + unsignedTxs.push({ + meta: {}, + network: morphoNetwork, + nonce: morphoRedeemNonce++, + phase: "squidRouterSwap", + signer: evmEphemeralEntry.address, + txData: encodeEvmTransactionData(swapData) as EvmTransactionData + }); + + // 5. Cleanup USDC on the vault's chain so the funding account can sweep any leftovers. + const vaultFundingAccount = getEvmFundingAccount(morphoNetwork); + const vaultChainUsdcCleanup = await prepareBaseCleanupApproval( + morphoVault.depositAssetAddress as `0x${string}`, + vaultFundingAccount.address, + morphoNetwork + ); + unsignedTxs.push({ + meta: {}, + network: morphoNetwork, + nonce: morphoRedeemNonce++, + phase: "ethereumCleanupUsdc", + signer: evmEphemeralEntry.address, + txData: encodeEvmTransactionData(vaultChainUsdcCleanup) as EvmTransactionData + }); + } + + // 6. Mykobo intent (must precede any user-signed tx so failures abort early). + const mykoboIntentValue = quote.metadata.nablaSwapEvm?.outputAmountDecimal; + if (!mykoboIntentValue) { + throw new Error("Missing nablaSwapEvm.outputAmountDecimal in quote metadata for Morpho offramp"); + } + const mykoboFlooredValue = new Big(mykoboIntentValue).toFixed(2, 0); + const eurcDecimals = evmTokenConfig[Networks.Base][EvmToken.EURC]?.decimals; + if (eurcDecimals === undefined) { + throw new Error("Invalid EURC decimals configuration for Base in evmTokenConfig"); + } + const eurcTransferAmountRaw = multiplyByPowerOfTen(new Big(mykoboFlooredValue), eurcDecimals).toFixed(0, 0); + + const mykobo = MykoboApiService.getInstance(); + const intent = await mykobo.createTransactionIntent({ + currency: MykoboCurrency.EURC, + email_address: email, + ip_address: ipAddress, + transaction_type: MykoboTransactionType.WITHDRAW, + value: mykoboFlooredValue, + wallet_address: evmEphemeralEntry.address + }); + + if (!isWithdrawInstructions(intent.instructions)) { + throw new Error("Mykobo intent did not return withdraw instructions; cannot derive receivables address"); + } + // Mocking Mykobo intent call + // const mykoboReceivablesAddress = "0x7Ba99e99Bc669B3508AFf9CC0A898E869459F877"; //intent.instructions.address; + // const mykoboTransactionId = "mockTransactionId"; //intent.transaction.id; + // const mykoboTransactionReference = "mockTransactionReference"; //intent.transaction.reference; + + // 7. Base leg (fundEphemeral handled separately; Nabla + payout + cleanups). + // When the vault IS on Base, the ephemeral has already broadcast transferFrom (nonce 0) + // and redeem (nonce 1) on Base via the morpho phases, so continue from morphoRedeemNonce. + // When the vault is on a different chain, no ephemeral txs have hit Base yet → start at 0. + let baseNonce = isBaseVault ? morphoRedeemNonce : 0; + + baseNonce = await addEvmFeeDistributionTransaction(quote, evmEphemeralEntry, unsignedTxs, baseNonce); + + const { nextNonce: nonceAfterNabla, stateMeta: nablaStateMeta } = await addNablaSwapTransactionsOnBase( + { + account: evmEphemeralEntry, + inputTokenAddress: baseUsdcAddress, + outputTokenAddress: baseEurcAddress, + quote + }, + unsignedTxs, + baseNonce + ); + stateMeta = nablaStateMeta; + baseNonce = nonceAfterNabla; + + const payoutTransfer = await addOnrampDestinationChainTransactions({ + amountRaw: eurcTransferAmountRaw, + destinationNetwork: Networks.Base, + isNativeToken: false, + toAddress: mykoboReceivablesAddress as `0x${string}`, + toToken: baseEurcAddress as `0x${string}` + }); + + unsignedTxs.push({ + meta: {}, + network: Networks.Base, + nonce: baseNonce, + phase: "mykoboPayoutOnBase", + signer: evmEphemeralEntry.address, + txData: encodeEvmTransactionData(payoutTransfer) as EvmTransactionData + }); + baseNonce++; + + const baseFundingAccount = getEvmFundingAccount(Networks.Base); + const cleanupTokens = [ + { address: baseUsdcAddress, phase: "baseCleanupUsdc" as const }, + { address: baseEurcAddress, phase: "baseCleanupEurc" as const }, + { address: baseAxlUsdcAddress, phase: "baseCleanupAxlUsdc" as const } + ]; + + for (const { address, phase } of cleanupTokens) { + const approval = await prepareBaseCleanupApproval(address as `0x${string}`, baseFundingAccount.address, Networks.Base); + unsignedTxs.push({ + meta: {}, + network: Networks.Base, + nonce: baseNonce++, + phase, + signer: evmEphemeralEntry.address, + txData: encodeEvmTransactionData(approval) as EvmTransactionData + }); + } + + const morphoRedeemMinOutputRaw = quote.metadata.redeemMeta?.expectedUsdcRaw + ? (BigInt(quote.metadata.redeemMeta.expectedUsdcRaw) * BigInt(10000 - REDEEM_SLIPPAGE_BPS)) / BigInt(10000) + : "0"; + + stateMeta = { + ...stateMeta, + destinationAddress, + evmEphemeralAddress: evmEphemeralEntry.address, + morphoNetwork, + morphoRedeemAssetAddress: morphoVault.depositAssetAddress, + morphoRedeemMinOutputRaw: morphoRedeemMinOutputRaw.toString(), + morphoRedeemSharesAmountRaw: sharesAmountRaw, + morphoRedeemShareTokenAddress: morphoVault.vaultAddress, + morphoRedeemVaultAddress: morphoVault.vaultAddress, + mykoboEmail: email, + mykoboReceivablesAddress, + mykoboTransactionId, + mykoboTransactionReference, + phaseFlow: isBaseVault ? EUR_OFFRAMP_BASE_MORPHO : EUR_OFFRAMP_MORPHO, + squidRouterQuoteId, + walletAddress: userAddress + }; + + // TODO we're missing a backup path for the morpho redeem. + + if (userId) { + await syncMykoboCustomerKyc(userId, email); + } + + return { stateMeta, unsignedTxs }; +} diff --git a/apps/api/src/api/services/transactions/offramp/routes/evm-to-mykobo.ts b/apps/api/src/api/services/transactions/offramp/routes/evm-to-mykobo.ts index f47e2c3a8..720eb3dd9 100644 --- a/apps/api/src/api/services/transactions/offramp/routes/evm-to-mykobo.ts +++ b/apps/api/src/api/services/transactions/offramp/routes/evm-to-mykobo.ts @@ -20,6 +20,7 @@ import { APIError } from "../../../../errors/api-error"; import { syncMykoboCustomerKyc } from "../../../mykobo/mykobo-customer.service"; import { getEvmFundingAccount } from "../../../phases/evm-funding"; import { StateMetadata } from "../../../phases/meta-state-types"; +import { EUR_OFFRAMP_BASE } from "../../../phases/ramp-flow-definitions"; import { encodeEvmTransactionData } from "../.."; import { prepareBaseCleanupApproval } from "../../base/cleanup"; import { addEvmFeeDistributionTransaction } from "../../common/feeDistribution"; @@ -243,6 +244,7 @@ export async function prepareEvmToMykoboOfframpTransactions({ mykoboReceivablesAddress, mykoboTransactionId, mykoboTransactionReference, + phaseFlow: EUR_OFFRAMP_BASE, walletAddress: userAddress }; diff --git a/apps/api/src/api/services/transactions/onramp/common/transactions.ts b/apps/api/src/api/services/transactions/onramp/common/transactions.ts index b3cfec69e..a1136c75c 100644 --- a/apps/api/src/api/services/transactions/onramp/common/transactions.ts +++ b/apps/api/src/api/services/transactions/onramp/common/transactions.ts @@ -261,10 +261,18 @@ export async function addNablaSwapTransactionsOnBase( // The input amount for the swap was already calculated in the quote. const inputAmountForNablaSwapRaw = quote.metadata.nablaSwapEvm.inputAmountForSwapRaw; + // For offramps, outputAmountRaw may include a partner subsidy (merged in + // OffRampMergeSubsidyEvmEngine). Use the AMM-only amount when available so + // the on-chain minimum reflects what the AMM can actually deliver. + const minOutputBaseRaw = quote.metadata.nablaSwapEvm.ammOutputAmountRaw ?? quote.metadata.nablaSwapEvm.outputAmountRaw; const outputAmountRaw = Big(quote.metadata.nablaSwapEvm.outputAmountRaw); - const nablaSoftMinimumOutputRaw = outputAmountRaw.mul(1 - AMM_MINIMUM_OUTPUT_SOFT_MARGIN).toFixed(0, 0); - const nablaHardMinimumOutputRaw = outputAmountRaw.mul(1 - AMM_MINIMUM_OUTPUT_HARD_MARGIN).toFixed(0, 0); + const nablaSoftMinimumOutputRaw = Big(minOutputBaseRaw) + .mul(1 - AMM_MINIMUM_OUTPUT_SOFT_MARGIN) + .toFixed(0, 0); + const nablaHardMinimumOutputRaw = Big(minOutputBaseRaw) + .mul(1 - AMM_MINIMUM_OUTPUT_HARD_MARGIN) + .toFixed(0, 0); const { approve, swap } = await createNablaTransactionsForOnrampOnEVM( inputAmountForNablaSwapRaw, diff --git a/apps/api/src/api/services/transactions/onramp/routes/alfredpay-to-evm.ts b/apps/api/src/api/services/transactions/onramp/routes/alfredpay-to-evm.ts index e2f298d60..cebf2c234 100644 --- a/apps/api/src/api/services/transactions/onramp/routes/alfredpay-to-evm.ts +++ b/apps/api/src/api/services/transactions/onramp/routes/alfredpay-to-evm.ts @@ -23,6 +23,7 @@ import { isAddress } from "viem"; import AlfredPayCustomer from "../../../../../models/alfredPayCustomer.model"; import { getEvmFundingAccount } from "../../../phases/evm-funding"; import { StateMetadata } from "../../../phases/meta-state-types"; +import { ALFREDPAY_ONRAMP_CROSS_CHAIN, ALFREDPAY_ONRAMP_DIRECT } from "../../../phases/ramp-flow-definitions"; import { encodeEvmTransactionData } from "../../index"; import { preparePolygonCleanupApproval } from "../../polygon/cleanup"; import { addDestinationChainApprovalTransaction, addOnrampDestinationChainTransactions } from "../common/transactions"; @@ -136,7 +137,7 @@ export async function prepareAlfredpayToEvmOnrampTransactions({ txData: encodeEvmTransactionData(polygonCleanupApproval) as EvmTransactionData }); - return { stateMeta, unsignedTxs }; + return { stateMeta: { ...stateMeta, phaseFlow: ALFREDPAY_ONRAMP_DIRECT }, unsignedTxs }; } const { approveData, swapData, squidRouterQuoteId, squidRouterReceiverId, squidRouterReceiverHash } = @@ -202,6 +203,7 @@ export async function prepareAlfredpayToEvmOnrampTransactions({ stateMeta = { ...stateMeta, + phaseFlow: ALFREDPAY_ONRAMP_DIRECT, squidRouterQuoteId, squidRouterReceiverHash, squidRouterReceiverId @@ -317,6 +319,7 @@ export async function prepareAlfredpayToEvmOnrampTransactions({ stateMeta = { ...stateMeta, + phaseFlow: ALFREDPAY_ONRAMP_CROSS_CHAIN, squidRouterQuoteId, squidRouterReceiverHash, squidRouterReceiverId diff --git a/apps/api/src/api/services/transactions/onramp/routes/avenia-to-evm-base.ts b/apps/api/src/api/services/transactions/onramp/routes/avenia-to-evm-base.ts index 466ca0d89..79d2a78ac 100644 --- a/apps/api/src/api/services/transactions/onramp/routes/avenia-to-evm-base.ts +++ b/apps/api/src/api/services/transactions/onramp/routes/avenia-to-evm-base.ts @@ -18,6 +18,11 @@ import { isAddress } from "viem"; import logger from "../../../../../config/logger"; import { getEvmFundingAccount } from "../../../phases/evm-funding"; import { StateMetadata } from "../../../phases/meta-state-types"; +import { + BRL_ONRAMP_BASE_CROSS_CHAIN, + BRL_ONRAMP_BASE_DIRECT, + BRL_ONRAMP_BASE_SAME_CHAIN +} from "../../../phases/ramp-flow-definitions"; import { isBrlToBrlaBaseDirect } from "../../../quote/utils"; import { prepareBaseCleanupApproval } from "../../base/cleanup"; import { addEvmFeeDistributionTransaction } from "../../common/feeDistribution"; @@ -90,7 +95,7 @@ export async function prepareAveniaToEvmOnrampTransactionsOnBase({ txData: finalDestinationTransfer }); - return { stateMeta, unsignedTxs }; + return { stateMeta: { ...stateMeta, phaseFlow: BRL_ONRAMP_BASE_DIRECT }, unsignedTxs }; } if (!quote.metadata.aveniaTransfer?.outputAmountRaw) { @@ -170,7 +175,7 @@ export async function prepareAveniaToEvmOnrampTransactionsOnBase({ txData: encodeEvmTransactionData(usdcCleanupApproval) as EvmTransactionData }); - return { stateMeta, unsignedTxs }; + return { stateMeta: { ...stateMeta, phaseFlow: BRL_ONRAMP_BASE_SAME_CHAIN }, unsignedTxs }; } const { approveData, swapData, squidRouterQuoteId, squidRouterReceiverId, squidRouterReceiverHash } = @@ -255,7 +260,7 @@ export async function prepareAveniaToEvmOnrampTransactionsOnBase({ squidRouterReceiverId }; - return { stateMeta, unsignedTxs }; + return { stateMeta: { ...stateMeta, phaseFlow: BRL_ONRAMP_BASE_SAME_CHAIN }, unsignedTxs }; } const baseFundingAccountAddress = getEvmFundingAccount(Networks.Base).address; @@ -375,6 +380,7 @@ export async function prepareAveniaToEvmOnrampTransactionsOnBase({ stateMeta = { ...stateMeta, + phaseFlow: BRL_ONRAMP_BASE_CROSS_CHAIN, squidRouterQuoteId, squidRouterReceiverHash, squidRouterReceiverId diff --git a/apps/api/src/api/services/transactions/onramp/routes/mykobo-to-evm-morpho.ts b/apps/api/src/api/services/transactions/onramp/routes/mykobo-to-evm-morpho.ts new file mode 100644 index 000000000..0bc99f7fd --- /dev/null +++ b/apps/api/src/api/services/transactions/onramp/routes/mykobo-to-evm-morpho.ts @@ -0,0 +1,260 @@ +import { + createOnrampSquidrouterTransactionsFromBaseToEvm, + EvmClientManager, + EvmNetworks, + EvmToken, + EvmTokenDetails, + EvmTransactionData, + evmTokenConfig, + isEvmTokenDetails, + Networks, + UnsignedTx +} from "@vortexfi/shared"; +import { encodeFunctionData, erc20Abi } from "viem"; +import logger from "../../../../../config/logger"; +import { getEvmFundingAccount } from "../../../phases/evm-funding"; +import { getMorphoVaultInfo } from "../../../phases/handlers/morpho-vault-config"; +import { StateMetadata } from "../../../phases/meta-state-types"; +import { EUR_ONRAMP_BASE_MORPHO, EUR_ONRAMP_MORPHO } from "../../../phases/ramp-flow-definitions"; +import { prepareBaseCleanupApproval } from "../../base/cleanup"; +import { addEvmFeeDistributionTransaction } from "../../common/feeDistribution"; +import { encodeEvmTransactionData } from "../../index"; +import { addNablaSwapTransactionsOnBase } from "../common/transactions"; +import { MykoboOnrampTransactionParams, OnrampTransactionsWithMeta } from "../common/types"; +import { validateMykoboOnramp } from "../common/validation"; + +const morphoVaultAbi = [ + { + inputs: [ + { name: "assets", type: "uint256" }, + { name: "receiver", type: "address" } + ], + name: "deposit", + outputs: [{ name: "shares", type: "uint256" }], + stateMutability: "nonpayable", + type: "function" + } +] as const; + +/** + * Prepares all transactions for a Mykobo (EUR) onramp that deposits into a Morpho vault. + * + * Two variants: + * - Base vault: user SEPA deposit → EURC on Base ephemeral → Nabla swap EURC→USDC + * → Morpho vault deposit on Base. No SquidRouter bridge. + * - Non-Base vault: same as above, but then SquidRouter bridges USDC from Base to + * the vault's chain, where the Morpho deposit executes. + */ +export async function prepareMykoboToEvmMorphoOnrampTransactions({ + quote, + signingAccounts, + destinationAddress, + mykoboEmail, + mykoboTransactionId, + mykoboTransactionReference +}: MykoboOnrampTransactionParams & { + mykoboTransactionId: string; + mykoboTransactionReference: string; +}): Promise { + let stateMeta: Partial = {}; + const unsignedTxs: UnsignedTx[] = []; + + const { evmEphemeralEntry, inputTokenDetails } = validateMykoboOnramp(quote, signingAccounts); + logger.debug(`Starting prepareMykoboToEvmMorphoOnrampTransactions with destinationAddress: ${destinationAddress}`); + + if (!isEvmTokenDetails(inputTokenDetails)) { + throw new Error(`Input token must be an EVM token for Morpho onramp, got ${inputTokenDetails.assetSymbol}`); + } + + if (!quote.metadata.nablaSwapEvm?.outputAmountRaw) { + throw new Error("Missing nablaSwapEvm.outputAmountRaw in quote metadata for Morpho onramp"); + } + + const morphoVault = getMorphoVaultInfo("usdc-arbitrum"); + const morphoNetwork = morphoVault.network as EvmNetworks; + const isBaseVault = morphoNetwork === Networks.Base; + + // For Base vaults, the deposit amount equals the Nabla swap output directly. For + // non-Base vaults, the deposit amount is the bridge output (USDC that lands on the + // vault's chain after the SquidRouter bridge). + const depositAmountRaw = isBaseVault + ? (quote.metadata.nablaSwapEvm.outputAmountRaw as string) + : quote.metadata.evmToEvm?.outputAmountRaw; + if (!depositAmountRaw) { + throw new Error( + isBaseVault + ? "Missing nablaSwapEvm.outputAmountRaw in quote metadata for Base Morpho onramp" + : "Missing evmToEvm.outputAmountRaw in quote metadata for Morpho onramp" + ); + } + + const bridgeInputAmountRaw = isBaseVault ? null : quote.metadata.evmToEvm?.inputAmountRaw; + if (!isBaseVault && !bridgeInputAmountRaw) { + throw new Error("Missing evmToEvm.inputAmountRaw in quote metadata for Morpho onramp"); + } + + stateMeta = { + destinationAddress, + evmEphemeralAddress: evmEphemeralEntry.address, + isDirectTransfer: false, + morphoDepositAmountRaw: depositAmountRaw, + morphoDepositAssetAddress: morphoVault.depositAssetAddress, + morphoNetwork, + morphoShareTokenAddress: morphoVault.vaultAddress, + morphoVaultAddress: morphoVault.vaultAddress, + mykoboEmail, + mykoboTransactionId, + mykoboTransactionReference, + walletAddress: destinationAddress + }; + + let baseNonce = 0; + + const nablaSwapOutputTokenAddress = evmTokenConfig[Networks.Base][EvmToken.USDC]?.erc20AddressSourceChain; + if (!nablaSwapOutputTokenAddress) { + throw new Error("Invalid USDC configuration for Base in evmTokenConfig"); + } + const eurcInputTokenAddress = (inputTokenDetails as EvmTokenDetails).erc20AddressSourceChain; + + // 1. Nabla Swap: EURC -> USDC on Base + const { nextNonce: nonceAfterNabla, stateMeta: nablaStateMeta } = await addNablaSwapTransactionsOnBase( + { + account: evmEphemeralEntry, + inputTokenAddress: eurcInputTokenAddress, + outputTokenAddress: nablaSwapOutputTokenAddress, + quote + }, + unsignedTxs, + baseNonce + ); + stateMeta = { ...stateMeta, ...nablaStateMeta }; + baseNonce = nonceAfterNabla; + + // 2. Fee Distribution on Base + baseNonce = await addEvmFeeDistributionTransaction(quote, evmEphemeralEntry, unsignedTxs, baseNonce); + + // 3. SquidRouter bridge (only when the vault is on a chain other than Base). + let squidRouterQuoteId: string | undefined; + if (!isBaseVault) { + const { + approveData, + swapData, + squidRouterQuoteId: quoteId + } = await createOnrampSquidrouterTransactionsFromBaseToEvm({ + destinationAddress: evmEphemeralEntry.address, + fromAddress: evmEphemeralEntry.address, + fromToken: nablaSwapOutputTokenAddress, + rawAmount: bridgeInputAmountRaw as string, + toNetwork: morphoNetwork, + toToken: morphoVault.depositAssetAddress as `0x${string}` + }); + squidRouterQuoteId = quoteId; + + unsignedTxs.push({ + meta: {}, + network: Networks.Base, + nonce: baseNonce++, + phase: "squidRouterApprove", + signer: evmEphemeralEntry.address, + txData: encodeEvmTransactionData(approveData) as EvmTransactionData + }); + + unsignedTxs.push({ + meta: {}, + network: Networks.Base, + nonce: baseNonce++, + phase: "squidRouterSwap", + signer: evmEphemeralEntry.address, + txData: encodeEvmTransactionData(swapData) as EvmTransactionData + }); + } + + // 4. Base Cleanups + const baseFundingAccountAddress = getEvmFundingAccount(Networks.Base).address; + + const eurcCleanupApproval = await prepareBaseCleanupApproval( + eurcInputTokenAddress as `0x${string}`, + baseFundingAccountAddress, + Networks.Base + ); + unsignedTxs.push({ + meta: {}, + network: Networks.Base, + nonce: baseNonce++, + phase: "baseCleanupEurc", + signer: evmEphemeralEntry.address, + txData: encodeEvmTransactionData(eurcCleanupApproval) as EvmTransactionData + }); + + const usdcCleanupApproval = await prepareBaseCleanupApproval( + nablaSwapOutputTokenAddress as `0x${string}`, + baseFundingAccountAddress, + Networks.Base + ); + unsignedTxs.push({ + meta: {}, + network: Networks.Base, + nonce: baseNonce++, + phase: "baseCleanupUsdc", + signer: evmEphemeralEntry.address, + txData: encodeEvmTransactionData(usdcCleanupApproval) as EvmTransactionData + }); + + // 5. Vault chain (morphoNetwork) transactions + const morphoClient = EvmClientManager.getInstance().getClient(morphoNetwork); + const { maxFeePerGas, maxPriorityFeePerGas } = await morphoClient.estimateFeesPerGas(); + + // Morpho approval: approve vault to spend the deposit asset (USDC) + const approveCallData = encodeFunctionData({ + abi: erc20Abi, + args: [morphoVault.vaultAddress, BigInt(depositAmountRaw)], + functionName: "approve" + }); + + unsignedTxs.push({ + meta: {}, + network: morphoNetwork, + nonce: isBaseVault ? baseNonce : 0, + phase: "morphoApprove", + signer: evmEphemeralEntry.address, + txData: { + data: approveCallData as `0x${string}`, + gas: "100000", + maxFeePerGas: String(maxFeePerGas), + maxPriorityFeePerGas: String(maxPriorityFeePerGas), + to: morphoVault.depositAssetAddress as `0x${string}`, + value: "0" + } as EvmTransactionData + }); + + // Morpho deposit: deposit USDC into the vault on morphoNetwork + const depositCallData = encodeFunctionData({ + abi: morphoVaultAbi, + args: [BigInt(depositAmountRaw), destinationAddress as `0x${string}`], + functionName: "deposit" + }); + + unsignedTxs.push({ + meta: {}, + network: morphoNetwork, + nonce: isBaseVault ? baseNonce + 1 : 1, + phase: "morphoDeposit", + signer: evmEphemeralEntry.address, + txData: { + data: depositCallData as `0x${string}`, + gas: "500000", + maxFeePerGas: String(maxFeePerGas), + maxPriorityFeePerGas: String(maxPriorityFeePerGas), + to: morphoVault.vaultAddress as `0x${string}`, + value: "0" + } as EvmTransactionData + }); + + stateMeta = { + ...stateMeta, + phaseFlow: isBaseVault ? EUR_ONRAMP_BASE_MORPHO : EUR_ONRAMP_MORPHO, + squidRouterQuoteId + }; + + return { stateMeta, unsignedTxs }; +} diff --git a/apps/api/src/api/services/transactions/onramp/routes/mykobo-to-evm.ts b/apps/api/src/api/services/transactions/onramp/routes/mykobo-to-evm.ts index d5131e37a..5fa0707fd 100644 --- a/apps/api/src/api/services/transactions/onramp/routes/mykobo-to-evm.ts +++ b/apps/api/src/api/services/transactions/onramp/routes/mykobo-to-evm.ts @@ -18,6 +18,11 @@ import { isAddress } from "viem"; import logger from "../../../../../config/logger"; import { getEvmFundingAccount } from "../../../phases/evm-funding"; import { StateMetadata } from "../../../phases/meta-state-types"; +import { + EUR_ONRAMP_BASE_CROSS_CHAIN, + EUR_ONRAMP_BASE_DIRECT, + EUR_ONRAMP_BASE_SAME_CHAIN +} from "../../../phases/ramp-flow-definitions"; import { isEurToEurcBaseDirect } from "../../../quote/utils"; import { prepareBaseCleanupApproval } from "../../base/cleanup"; import { addEvmFeeDistributionTransaction } from "../../common/feeDistribution"; @@ -105,7 +110,7 @@ export async function prepareMykoboToEvmOnrampTransactions({ txData: finalDestinationTransfer }); - return { stateMeta, unsignedTxs }; + return { stateMeta: { ...stateMeta, phaseFlow: EUR_ONRAMP_BASE_DIRECT }, unsignedTxs }; } const nablaSwapOutputTokenAddress = evmTokenConfig[Networks.Base][EvmToken.USDC]?.erc20AddressSourceChain; @@ -180,7 +185,7 @@ export async function prepareMykoboToEvmOnrampTransactions({ txData: encodeEvmTransactionData(usdcCleanupApproval) as EvmTransactionData }); - return { stateMeta, unsignedTxs }; + return { stateMeta: { ...stateMeta, phaseFlow: EUR_ONRAMP_BASE_SAME_CHAIN }, unsignedTxs }; } const { approveData, swapData, squidRouterQuoteId, squidRouterReceiverId, squidRouterReceiverHash } = @@ -263,6 +268,7 @@ export async function prepareMykoboToEvmOnrampTransactions({ stateMeta = { ...stateMeta, + phaseFlow: EUR_ONRAMP_BASE_SAME_CHAIN, squidRouterQuoteId, squidRouterReceiverHash, squidRouterReceiverId @@ -395,6 +401,7 @@ export async function prepareMykoboToEvmOnrampTransactions({ stateMeta = { ...stateMeta, + phaseFlow: EUR_ONRAMP_BASE_CROSS_CHAIN, squidRouterQuoteId, squidRouterReceiverHash, squidRouterReceiverId diff --git a/apps/api/src/api/services/transactions/validation.ts b/apps/api/src/api/services/transactions/validation.ts index 338d00468..e49923b01 100644 --- a/apps/api/src/api/services/transactions/validation.ts +++ b/apps/api/src/api/services/transactions/validation.ts @@ -231,6 +231,11 @@ function getTransactionTypeForPhase(phase: RampPhase | CleanupPhase, network: Ne case "baseCleanupAxlUsdc": case "alfredOnrampMintFallback": case "alfredpayOfframpTransferFallback": + case "morphoApprove": + case "morphoDeposit": + case "morphoPermitExecute": + case "morphoRedeem": + case "ethereumCleanupUsdc": return EphemeralAccountType.EVM; default: throw new APIError({ @@ -350,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/api/src/api/workers/cleanup.worker.ts b/apps/api/src/api/workers/cleanup.worker.ts index 33360e6a5..ea491bac3 100644 --- a/apps/api/src/api/workers/cleanup.worker.ts +++ b/apps/api/src/api/workers/cleanup.worker.ts @@ -154,11 +154,10 @@ class CleanupWorker { where: { currentPhase: { [Op.in]: ["complete", "failed", "timedOut"] }, flowVariant: config.flowVariant, - postCompleteState: { - cleanup: { - [Op.or]: [{ cleanupCompleted: false }, { cleanupCompleted: { [Op.is]: null } }] - } - } + [Op.or]: [ + { "postCompleteState.cleanup.cleanupCompleted": false }, + { "postCompleteState.cleanup.cleanupCompleted": null } + ] } }); diff --git a/apps/frontend/package.json b/apps/frontend/package.json index 3711431ac..9a5e2ac1c 100644 --- a/apps/frontend/package.json +++ b/apps/frontend/package.json @@ -122,7 +122,7 @@ "scripts": { "build": "bun x --bun vite build && cp -R src/assets/coins dist/assets/coins && cp _redirects dist/_redirects", "build-storybook": "storybook build", - "dev": "bun x --bun vite --host", + "dev": "rm -rf node_modules/.vite && bun x --bun vite --host", "preview": "bun x --bun vite preview", "storybook": "storybook dev -p 6006", "test": "vitest", diff --git a/apps/frontend/src/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/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/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/pages/progress/index.tsx b/apps/frontend/src/pages/progress/index.tsx index 9695d33be..3676492f0 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 === "VAULT") { + return "onramp_eur_morpho"; + } return "onramp_eur_evm"; } @@ -34,6 +37,9 @@ function getRampFlow(rampState: RampState | undefined): keyof typeof PHASE_FLOWS } if (rampState.quote?.outputCurrency === FiatToken.EURC) { + if (rampState.quote?.inputCurrency === "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 717c4461d..8915c2740 100644 --- a/apps/frontend/src/pages/progress/phaseFlows.ts +++ b/apps/frontend/src/pages/progress/phaseFlows.ts @@ -23,6 +23,10 @@ export const PHASE_DURATIONS: Record = { initial: 0, moonbeamToPendulum: 40, moonbeamToPendulumXcm: 30, + morphoApprove: 0, + morphoDeposit: 30, + morphoPermitExecute: 30, + morphoRedeem: 30, mykoboOnrampDeposit: 5 * 60, mykoboPayoutOnBase: 60, nablaApprove: 24, @@ -67,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", @@ -98,5 +118,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/apps/frontend/src/pages/progress/phaseMessages.ts b/apps/frontend/src/pages/progress/phaseMessages.ts index 8af888520..e8603bf72 100644 --- a/apps/frontend/src/pages/progress/phaseMessages.ts +++ b/apps/frontend/src/pages/progress/phaseMessages.ts @@ -84,6 +84,10 @@ export function getMessageForPhase(ramp: RampState | undefined, t: TFunction<"tr initial: t("pages.progress.initial"), moonbeamToPendulum: getMoonbeamToPendulumMessage(), moonbeamToPendulumXcm: getMoonbeamToPendulumMessage(), + morphoApprove: "", + morphoDeposit: t("pages.progress.morphoDeposit"), + morphoPermitExecute: t("pages.progress.morphoPermitExecute"), + morphoRedeem: t("pages.progress.morphoRedeem"), mykoboOnrampDeposit: t("pages.progress.mykoboOnrampDeposit"), mykoboPayoutOnBase: getTransferringMessage(), nablaApprove: getSwappingMessage(), diff --git a/apps/frontend/src/services/balances/evmBalanceFetcher.ts b/apps/frontend/src/services/balances/evmBalanceFetcher.ts index 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..ddb105767 --- /dev/null +++ b/apps/frontend/src/services/balances/morphoBalanceFetcher.ts @@ -0,0 +1,33 @@ +import { mainnet } from "@reown/appkit/networks"; +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; + +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: mainnet.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.Ethereum; diff --git a/apps/frontend/src/translations/en.json b/apps/frontend/src/translations/en.json index dc3e4fe66..7cd99b027 100644 --- a/apps/frontend/src/translations/en.json +++ b/apps/frontend/src/translations/en.json @@ -1208,6 +1208,10 @@ "hydrationToAssethubXcm": "Transferring {{assetSymbol}} from Hydration --> AssetHub", "initial": "Starting process", "moonbeamToPendulum": "Transferring {{assetSymbol}} from Moonbeam --> Pendulum", + "morphoApprove": "Approving tokens for Morpho vault deposit", + "morphoDeposit": "Depositing into Morpho vault", + "morphoPermitExecute": "Moving Morpho vault shares to Vortex", + "morphoRedeem": "Redeeming Morpho vault shares for USDC", "mykoboOnrampDeposit": "Waiting to receive payment", "pendulumToAssethubXcm": "Transferring {{assetSymbol}} from Pendulum --> AssetHub", "pendulumToHydrationXcm": "Transferring {{assetSymbol}} from Pendulum --> Hydration", diff --git a/apps/frontend/src/translations/pt.json b/apps/frontend/src/translations/pt.json index 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/package.json b/package.json index 26da763f9..52225a659 100644 --- a/package.json +++ b/package.json @@ -108,7 +108,7 @@ "build:sdk": "bun run --cwd packages/sdk build", "build:shared": "bun run --cwd packages/shared build", "compile:contracts:relayer": "bun run --cwd contracts/relayer compile", - "dev": "concurrently -n 'shared,backend,frontend,dashboard' -c '#ffa500,#007755,#2f6da3,#24c989' 'cd packages/shared && bun run dev' 'cd apps/api && bun dev' 'cd apps/frontend && bun dev' 'cd apps/dashboard && bun dev'", + "dev": "bun run --cwd packages/shared dev & bun run --cwd apps/api dev & bun run --cwd apps/frontend dev & wait", "dev:backend": "bun run --cwd apps/api dev", "dev:contracts:relayer": "bun run --cwd contracts/relayer node", "dev:dashboard": "bun run --cwd apps/dashboard dev", diff --git a/packages/sdk/src/services/NetworkManager.ts b/packages/sdk/src/services/NetworkManager.ts index 09dd56efc..11828f0ba 100644 --- a/packages/sdk/src/services/NetworkManager.ts +++ b/packages/sdk/src/services/NetworkManager.ts @@ -17,7 +17,7 @@ const DEFAULT_NETWORKS: NetworkConfig[] = [ }, { name: "hydration", - wsUrl: "wss://hydration.ibp.network" + wsUrl: "wss://hydration.dotters.network" } ]; diff --git a/packages/shared/src/endpoints/ramp.endpoints.ts b/packages/shared/src/endpoints/ramp.endpoints.ts index 217f0ac09..a8bfcdb7f 100644 --- a/packages/shared/src/endpoints/ramp.endpoints.ts +++ b/packages/shared/src/endpoints/ramp.endpoints.ts @@ -51,6 +51,10 @@ export type RampPhase = | "backupSquidRouterApprove" | "backupSquidRouterSwap" | "backupApprove" + | "morphoApprove" + | "morphoDeposit" + | "morphoPermitExecute" + | "morphoRedeem" | "complete"; export type CleanupPhase = @@ -63,7 +67,8 @@ export type CleanupPhase = | "baseCleanupUsdc" | "baseCleanupBrla" | "baseCleanupEurc" - | "baseCleanupAxlUsdc"; + | "baseCleanupAxlUsdc" + | "ethereumCleanupUsdc"; export enum EphemeralAccountType { Substrate = "Substrate", @@ -86,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/helpers/signUnsigned.ts b/packages/shared/src/helpers/signUnsigned.ts index 229e276f1..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( @@ -216,7 +223,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/services/pendulum/apiManager.ts b/packages/shared/src/services/pendulum/apiManager.ts index c5d27972e..cce7c7eb9 100644 --- a/packages/shared/src/services/pendulum/apiManager.ts +++ b/packages/shared/src/services/pendulum/apiManager.ts @@ -18,7 +18,7 @@ const NETWORKS: NetworkConfig[] = [ }, { name: "hydration", - wsUrls: ["wss://hydration.ibp.network"] + wsUrls: ["wss://hydration.dotters.network"] }, { name: "moonbeam", diff --git a/packages/shared/src/services/squidrouter/offramp.ts b/packages/shared/src/services/squidrouter/offramp.ts index 271f810cd..30e45719b 100644 --- a/packages/shared/src/services/squidrouter/offramp.ts +++ b/packages/shared/src/services/squidrouter/offramp.ts @@ -1,7 +1,7 @@ import { u8aToHex } from "@polkadot/util"; import { decodeAddress } from "@polkadot/util-crypto"; import { createRandomString, createSquidRouterHash } from "../../helpers/squidrouter"; -import { EvmTransactionData, Networks, SquidrouterRoute } from "../../index"; +import { EvmNetworks, EvmTransactionData, isNetworkEVM, Networks, SquidrouterRoute } from "../../index"; import { EvmClientManager } from "../evm/clientManager"; import { getSquidRouterConfig } from "./config"; import { encodePayload } from "./payload"; @@ -92,9 +92,12 @@ export async function createOfframpSquidrouterTransactionsToEvm( if (params.fromNetwork === Networks.AssetHub) { throw new Error("AssetHub is not supported for Squidrouter offramp"); } + if (!isNetworkEVM(params.fromNetwork)) { + throw new Error(`createOfframpSquidrouterTransactionsToEvm: fromNetwork ${params.fromNetwork} is not an EVM network`); + } const evmClientManager = EvmClientManager.getInstance(); - const moonbeamClient = evmClientManager.getClient(Networks.Moonbeam); + const fromNetworkClient = evmClientManager.getClient(params.fromNetwork as EvmNetworks); const routeParams = createGenericRouteParams({ amount: params.rawAmount, ...params }); @@ -103,7 +106,7 @@ export async function createOfframpSquidrouterTransactionsToEvm( return createTransactionDataFromRoute({ inputTokenErc20Address: params.fromToken, - publicClient: moonbeamClient, + publicClient: fromNetworkClient, rawAmount: params.rawAmount, route }); diff --git a/packages/shared/src/services/squidrouter/route-transactions.ts b/packages/shared/src/services/squidrouter/route-transactions.ts index 2d1a8fbde..61025c5a8 100644 --- a/packages/shared/src/services/squidrouter/route-transactions.ts +++ b/packages/shared/src/services/squidrouter/route-transactions.ts @@ -79,12 +79,14 @@ export async function createTransactionDataFromRoute({ }); const { maxFeePerGas, maxPriorityFeePerGas } = await publicClient.estimateFeesPerGas(); + const bumpedMaxFeePerGas = (maxFeePerGas * 2n).toString(); + const bumpedMaxPriorityFeePerGas = ((maxPriorityFeePerGas ?? maxFeePerGas) * 2n).toString(); const approveData: EvmTransactionData = { data: approveTransactionData as `0x${string}`, gas: "150000", - maxFeePerGas: maxFeePerGas.toString(), - maxPriorityFeePerGas: (maxPriorityFeePerGas ?? maxFeePerGas).toString(), + maxFeePerGas: bumpedMaxFeePerGas, + maxPriorityFeePerGas: bumpedMaxPriorityFeePerGas, to: inputTokenErc20Address as `0x${string}`, value: "0" }; @@ -96,8 +98,8 @@ export async function createTransactionDataFromRoute({ const swapData: EvmTransactionData = { data: transactionRequest.data as `0x${string}`, gas: normalizeBigIntString(transactionRequest.gasLimit), - maxFeePerGas: maxFeePerGas.toString(), - maxPriorityFeePerGas: (maxPriorityFeePerGas ?? maxFeePerGas).toString(), + maxFeePerGas: bumpedMaxFeePerGas, + maxPriorityFeePerGas: bumpedMaxPriorityFeePerGas, to: transactionRequest.target as `0x${string}`, value: normalizeBigIntString(swapValue ?? transactionRequest.value) }; diff --git a/packages/shared/src/tokens/evm/config.ts b/packages/shared/src/tokens/evm/config.ts index 3b6631e53..3d35d3d42 100644 --- a/packages/shared/src/tokens/evm/config.ts +++ b/packages/shared/src/tokens/evm/config.ts @@ -36,6 +36,15 @@ export const evmTokenConfig: Record> = }, maxBuyAmountRaw: "10000000000", maxSellAmountRaw: "10000000000", - minBuyAmountRaw: "1000000", - minSellAmountRaw: "25000000", + minBuyAmountRaw: "500000", + minSellAmountRaw: "500000", type: TokenType.Fiat }, [FiatToken.USD]: { diff --git a/packages/shared/src/tokens/types/evm.ts b/packages/shared/src/tokens/types/evm.ts index 9218ca8b6..85371889e 100644 --- a/packages/shared/src/tokens/types/evm.ts +++ b/packages/shared/src/tokens/types/evm.ts @@ -13,7 +13,8 @@ export enum EvmToken { USDCE = "USDC.E", ETH = "ETH", BRLA = "BRLA", - EURC = "EURC" + EURC = "EURC", + MORPHO_VAULT = "VAULT" } export enum UsdLikeEvmToken { 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;