From 104e49e807581eddc4d81dd596619613cbc8dea7 Mon Sep 17 00:00:00 2001 From: Gianfranco Date: Mon, 6 Apr 2026 18:18:24 -0300 Subject: [PATCH 01/12] create transactions for BRL onramp on base --- .../src/api/controllers/brla.controller.ts | 5 +- .../transactions/common/feeDistribution.ts | 112 ++++++++++++ .../onramp/common/transactions.ts | 67 +++++++ .../transactions/onramp/common/validation.ts | 45 +++++ .../onramp/routes/avenia-to-evm.ts | 173 +++++++++++++++++- apps/frontend/src/pages/progress/index.tsx | 1 + .../src/pages/progress/phaseMessages.ts | 1 + bun.lock | 3 +- packages/shared/src/contracts/index.ts | 5 + .../shared/src/endpoints/ramp.endpoints.ts | 1 + packages/shared/src/services/index.ts | 1 + .../src/services/nabla/transactions/index.ts | 107 ++++++++++- packages/shared/src/tokens/constants/misc.ts | 1 + packages/shared/src/tokens/evm/config.ts | 9 + packages/shared/src/tokens/types/evm.ts | 3 +- 15 files changed, 527 insertions(+), 7 deletions(-) create mode 100644 packages/shared/src/contracts/index.ts diff --git a/apps/api/src/api/controllers/brla.controller.ts b/apps/api/src/api/controllers/brla.controller.ts index 5639ca038..893b7a404 100644 --- a/apps/api/src/api/controllers/brla.controller.ts +++ b/apps/api/src/api/controllers/brla.controller.ts @@ -568,11 +568,12 @@ export const newKyc = async ( * @throws 500 - For any server-side errors during processing */ export const initiateKybLevel1 = async ( - req: Request, + req: Request, res: Response ): Promise => { try { const { subAccountId } = req.query; + const { redirectUrl } = req.body as { redirectUrl: string }; if (!subAccountId) { res.status(httpStatus.BAD_REQUEST).json({ error: "Missing subAccountId" }); @@ -593,7 +594,7 @@ export const initiateKybLevel1 = async ( } const brlaApiService = BrlaApiService.getInstance(); - const response = await brlaApiService.initiateKybLevel1(subAccountId); + const response = await brlaApiService.initiateKybLevel1(subAccountId, redirectUrl); res.status(httpStatus.OK).json(response); } catch (error) { diff --git a/apps/api/src/api/services/transactions/common/feeDistribution.ts b/apps/api/src/api/services/transactions/common/feeDistribution.ts index a586193aa..1d8dffa95 100644 --- a/apps/api/src/api/services/transactions/common/feeDistribution.ts +++ b/apps/api/src/api/services/transactions/common/feeDistribution.ts @@ -1,7 +1,11 @@ import { AccountMeta, ApiManager, + EvmClientManager, + EvmToken, + EvmTransactionData, encodeSubmittableExtrinsic, + evmTokenConfig, getNetworkFromDestination, Networks, PENDULUM_USDC_ASSETHUB, @@ -10,7 +14,9 @@ import { UnsignedTx } from "@vortexfi/shared"; import Big from "big.js"; +import { encodeFunctionData } from "viem/utils"; import logger from "../../../../config/logger"; +import erc20ABI from "../../../../contracts/ERC20"; import Partner from "../../../../models/partner.model"; import { QuoteTicketAttributes } from "../../../../models/quoteTicket.model"; import { multiplyByPowerOfTen } from "../../pendulum/helpers"; @@ -173,3 +179,109 @@ export async function addFeeDistributionTransaction( return nextNonce; } + +/** + * Creates an EVM fee distribution transaction for Base network. + * Transfers total fees (network + vortex + partner) to vortex payout address using USDC. + * + * @param quote The quote ticket + * @returns The EVM transaction data or null if no fees to distribute + */ +export async function createEvmFeeDistributionTransaction(quote: QuoteTicketAttributes): Promise { + const usdFeeStructure = quote.metadata.fees?.usd; + if (!usdFeeStructure) { + logger.warn("No USD fee structure found in quote metadata, skipping EVM fee distribution transaction"); + return null; + } + + const networkFeeUSD = usdFeeStructure.network; + const vortexFeeUSD = usdFeeStructure.vortex; + const partnerMarkupFeeUSD = usdFeeStructure.partnerMarkup; + + // Get vortex payout address + const vortexPartner = await Partner.findOne({ + where: { isActive: true, name: "vortex", rampType: quote.rampType } + }); + if (!vortexPartner || !vortexPartner.payoutAddress) { + logger.warn("Vortex partner or payout address not found, skipping EVM fee distribution transaction"); + return null; + } + const vortexPayoutAddress = vortexPartner.payoutAddress; + + // Use Base USDC for decimal calculations + const baseUsdcConfig = evmTokenConfig[Networks.Base][EvmToken.USDC]; + if (!baseUsdcConfig) { + logger.warn("Base USDC configuration not found, skipping EVM fee distribution transaction"); + return null; + } + + const decimals = baseUsdcConfig.decimals; + + // Convert USD fees to USDC raw units + const networkFeeUsdcRaw = multiplyByPowerOfTen(networkFeeUSD, decimals); + const vortexFeeUsdcRaw = multiplyByPowerOfTen(vortexFeeUSD, decimals); + const partnerMarkupFeeUsdcRaw = multiplyByPowerOfTen(partnerMarkupFeeUSD, decimals); + + // Calculate total fee amount + const totalFeeUsdcRaw = networkFeeUsdcRaw.plus(vortexFeeUsdcRaw).plus(partnerMarkupFeeUsdcRaw); + + if (totalFeeUsdcRaw.lte(0)) { + logger.warn("No fees to distribute, skipping EVM fee distribution transaction"); + return null; + } + + const evmClientManager = EvmClientManager.getInstance(); + const publicClient = evmClientManager.getClient(Networks.Base); + + // Encode USDC transfer to vortex payout address + const transferCallData = encodeFunctionData({ + abi: erc20ABI, + args: [vortexPayoutAddress, totalFeeUsdcRaw.toFixed(0)], + functionName: "transfer" + }); + + const { maxFeePerGas, maxPriorityFeePerGas } = await publicClient.estimateFeesPerGas(); + + const txData: EvmTransactionData = { + data: transferCallData as `0x${string}`, + gas: "100000", + maxFeePerGas: String(maxFeePerGas), + maxPriorityFeePerGas: String(maxPriorityFeePerGas), + to: baseUsdcConfig.erc20AddressSourceChain, + value: "0" + }; + + return txData; +} + +/** + * Adds EVM fee distribution transaction for Base network if available. + * + * @param quote Quote ticket + * @param account Account metadata + * @param unsignedTxs Array to add transactions to + * @param nextNonce Next available nonce + * @returns Updated nonce + */ +export async function addEvmFeeDistributionTransaction( + quote: QuoteTicketAttributes, + account: AccountMeta, + unsignedTxs: UnsignedTx[], + nextNonce: number +): Promise { + const feeDistributionTx = await createEvmFeeDistributionTransaction(quote); + + if (feeDistributionTx) { + unsignedTxs.push({ + meta: {}, + network: Networks.Base, + nonce: nextNonce, + phase: "distributeFees", + signer: account.address, + txData: feeDistributionTx + }); + nextNonce++; + } + + return nextNonce; +} 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 4749f8c78..c5e6295d9 100644 --- a/apps/api/src/api/services/transactions/onramp/common/transactions.ts +++ b/apps/api/src/api/services/transactions/onramp/common/transactions.ts @@ -4,6 +4,7 @@ import { AMM_MINIMUM_OUTPUT_SOFT_MARGIN, createMoonbeamToPendulumXCM, createNablaTransactionsForOnramp, + createNablaTransactionsForOnrampOnEVM, EvmClientManager, EvmNetworks, EvmTransactionData, @@ -233,6 +234,72 @@ export async function addOnrampDestinationChainTransactions(params: { return txData; } +/** + * Creates Nabla swap transactions for Base + * @param params Transaction parameters + * @param unsignedTxs Array to add transactions to + * @param nextNonce Next available nonce + * @returns Updated nonce and state metadata + */ +export async function addNablaSwapTransactionsOnBase( + params: { + quote: QuoteTicketAttributes; + account: AccountMeta; + inputTokenAddress: `0x${string}`; + outputTokenAddress: `0x${string}`; + }, + unsignedTxs: UnsignedTx[], + nextNonce: number +): Promise<{ nextNonce: number; stateMeta: Partial }> { + const { quote, account, inputTokenAddress, outputTokenAddress } = params; + + if (!quote.metadata.nablaSwap?.inputAmountForSwapRaw) { + throw new Error("Missing nablaSwap input amount in quote metadata"); + } + + // The input amount for the swap was already calculated in the quote. + const inputAmountForNablaSwapRaw = quote.metadata.nablaSwap.inputAmountForSwapRaw; + const outputAmountRaw = Big(quote.metadata.nablaSwap.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 { approve, swap } = await createNablaTransactionsForOnrampOnEVM( + inputAmountForNablaSwapRaw, + account, + inputTokenAddress, + outputTokenAddress, + nablaHardMinimumOutputRaw + ); + + unsignedTxs.push({ + meta: {}, + network: Networks.Base, + nonce: nextNonce, + phase: "nablaApprove", + signer: account.address, + txData: approve + }); + nextNonce++; + + unsignedTxs.push({ + meta: {}, + network: Networks.Base, + nonce: nextNonce, + phase: "nablaSwap", + signer: account.address, + txData: swap + }); + nextNonce++; + + return { + nextNonce, + stateMeta: { + nablaSoftMinimumOutputRaw + } + }; +} + /** * Creates an approval transaction on the destination chain * @param params Transaction parameters diff --git a/apps/api/src/api/services/transactions/onramp/common/validation.ts b/apps/api/src/api/services/transactions/onramp/common/validation.ts index 19ac4ea20..3cbc29381 100644 --- a/apps/api/src/api/services/transactions/onramp/common/validation.ts +++ b/apps/api/src/api/services/transactions/onramp/common/validation.ts @@ -1,9 +1,13 @@ import { AccountMeta, + EvmToken, + evmTokenConfig, FiatToken, getAnyFiatTokenDetails, + getEvmTokenConfig, getNetworkFromDestination, getOnChainTokenDetails, + getOnChainTokenDetailsOrDefault, isFiatToken, isMoonbeamTokenDetails, isOnChainToken, @@ -60,6 +64,47 @@ export function validateAveniaOnramp( return { evmEphemeralEntry, inputTokenDetails, outputTokenDetails, substrateEphemeralEntry, toNetwork }; } +export function validateAveniaOnrampOnBase( + quote: QuoteTicketAttributes, + signingAccounts: AccountMeta[] +): { + toNetwork: Networks; + outputTokenDetails: OnChainTokenDetails; + evmEphemeralEntry: AccountMeta; + inputTokenDetails: OnChainTokenDetails; +} { + const toNetwork = getNetworkFromDestination(quote.to); + if (!toNetwork) { + throw new Error(`Invalid network for destination ${quote.to}`); + } + + const evmEphemeralEntry = signingAccounts.find(ephemeral => ephemeral.type === "EVM"); + if (!evmEphemeralEntry) { + throw new Error("Base ephemeral not found"); + } + + if (!isFiatToken(quote.inputCurrency)) { + throw new Error(`Input currency must be fiat token for onramp, got ${quote.inputCurrency}`); + } + + // For Base, we use BRLA's native minted token + const inputTokenDetails = getEvmTokenConfig().base[EvmToken.BRLA]; + if (!inputTokenDetails) { + throw new Error("BRLA token details not found for Base"); + } + + if (!isOnChainToken(quote.outputCurrency)) { + throw new Error(`Output currency cannot be fiat token ${quote.outputCurrency} for onramp.`); + } + const outputTokenDetails = getOnChainTokenDetails(toNetwork, quote.outputCurrency); + + if (!outputTokenDetails || !isOnChainTokenDetails(outputTokenDetails)) { + throw new Error(`Output token must be on-chain token for onramp, got ${quote.outputCurrency}`); + } + + return { evmEphemeralEntry, inputTokenDetails, outputTokenDetails, toNetwork }; +} + export function validateMoneriumOnramp( quote: QuoteTicketAttributes, signingAccounts: AccountMeta[] diff --git a/apps/api/src/api/services/transactions/onramp/routes/avenia-to-evm.ts b/apps/api/src/api/services/transactions/onramp/routes/avenia-to-evm.ts index c90f0824b..45142aa47 100644 --- a/apps/api/src/api/services/transactions/onramp/routes/avenia-to-evm.ts +++ b/apps/api/src/api/services/transactions/onramp/routes/avenia-to-evm.ts @@ -21,17 +21,18 @@ import { import { privateKeyToAccount } from "viem/accounts"; import { MOONBEAM_FUNDING_PRIVATE_KEY } from "../../../../../constants/constants"; import { StateMetadata } from "../../../phases/meta-state-types"; -import { addFeeDistributionTransaction } from "../../common/feeDistribution"; +import { addEvmFeeDistributionTransaction, addFeeDistributionTransaction } from "../../common/feeDistribution"; import { encodeEvmTransactionData } from "../../index"; import { addDestinationChainApprovalTransaction, addMoonbeamTransactions, addNablaSwapTransactions, + addNablaSwapTransactionsOnBase, addOnrampDestinationChainTransactions, addPendulumCleanupTx } from "../common/transactions"; import { AveniaOnrampTransactionParams, OnrampTransactionsWithMeta } from "../common/types"; -import { validateAveniaOnramp } from "../common/validation"; +import { validateAveniaOnramp, validateAveniaOnrampOnBase } from "../common/validation"; /** * Prepares all transactions for an Avenia (BRL) onramp to EVM chain. @@ -193,6 +194,7 @@ export async function prepareAveniaToEvmOnrampTransactions({ let destinationNonce = 0; const finalAmountRaw = multiplyByPowerOfTen(quote.outputAmount, outputTokenDetails.decimals); + const finalDestinationTransfer = await addOnrampDestinationChainTransactions({ amountRaw: finalAmountRaw.toString(), destinationNetwork: toNetwork as EvmNetworks, @@ -255,3 +257,170 @@ export async function prepareAveniaToEvmOnrampTransactions({ return { stateMeta, unsignedTxs }; } + +/** + * Prepares all transactions for an Avenia (BRL) onramp to EVM chain via Base. + * This route handles: BRL → Base (BRLA) -> Swap (to USDC) → EVM (final transfer) + */ +export async function prepareAveniaToEvmOnrampTransactionsOnBase({ + quote, + signingAccounts, + destinationAddress, + taxId +}: AveniaOnrampTransactionParams): Promise { + let stateMeta: Partial = {}; + const unsignedTxs: UnsignedTx[] = []; + + // Validate inputs and extract required data + const { toNetwork, outputTokenDetails, evmEphemeralEntry, inputTokenDetails } = validateAveniaOnrampOnBase( + quote, + signingAccounts + ); + + // Setup state metadata + stateMeta = { + destinationAddress, + evmEphemeralAddress: evmEphemeralEntry.address, + taxId + }; + + let baseNonce = 0; + + if (!quote.metadata.aveniaTransfer?.outputAmountRaw) { + throw new Error("Missing aveniaTransfer amountOutRaw in quote metadata"); + } + const inputAmountPostAnchorFeeRaw = quote.metadata.aveniaTransfer.outputAmountRaw; + + if (!isEvmTokenDetails(outputTokenDetails)) { + throw new Error(`Output token must be an EVM token for onramp to any EVM chain, got ${outputTokenDetails.assetSymbol}`); + } + + const destinationAxlUsdcDetails = getOnChainTokenDetailsOrDefault(toNetwork as Networks, EvmToken.AXLUSDC) as EvmTokenDetails; + + // Output for BRLA onramp will always go through USDC. + // TODO. Unless the actual BRLA token ) + const nablaSwapOutputTokenAddress = evmTokenConfig[Networks.Base][EvmToken.USDC]?.erc20AddressSourceChain; + if (!nablaSwapOutputTokenAddress) { + throw new Error("Invalid USDC configuration for Base in evmTokenConfig"); + } + + const { nextNonce: nonceAfterNabla, stateMeta: nablaStateMeta } = await addNablaSwapTransactionsOnBase( + { + account: evmEphemeralEntry, + inputTokenAddress: (inputTokenDetails as EvmTokenDetails).erc20AddressSourceChain, + outputTokenAddress: nablaSwapOutputTokenAddress, + quote + }, + unsignedTxs, + baseNonce + ); + stateMeta = { ...stateMeta, ...nablaStateMeta }; + baseNonce = nonceAfterNabla; + + baseNonce = await addEvmFeeDistributionTransaction(quote, evmEphemeralEntry, unsignedTxs, baseNonce); + + const finalAmountRaw = multiplyByPowerOfTen(quote.outputAmount, outputTokenDetails.decimals); + + // Special case, onramping USDC on Base. We need to skip the SquidRouter step and go directly to the destination transfer. + if (toNetwork === Networks.Base && outputTokenDetails.erc20AddressSourceChain === nablaSwapOutputTokenAddress) { + const finalDestinationTransfer = await addOnrampDestinationChainTransactions({ + amountRaw: finalAmountRaw.toString(), + destinationNetwork: Networks.Base, + isNativeToken: isNativeEvmToken(outputTokenDetails), + toAddress: destinationAddress, + toToken: outputTokenDetails.erc20AddressSourceChain + }); + + unsignedTxs.push({ + meta: {}, + network: Networks.Base, + nonce: baseNonce, + phase: "destinationTransfer", + signer: evmEphemeralEntry.address, + txData: finalDestinationTransfer + }); + + return { stateMeta, unsignedTxs }; + } + + // Fallback swap depends on the EVM chain. For Ethereum, the bridged token is USDC. For the rest, it is axlUSDC. + const bridgedTokenForFallback = + toNetwork === Networks.Ethereum + ? evmTokenConfig.ethereum.USDC!.erc20AddressSourceChain + : destinationAxlUsdcDetails.erc20AddressSourceChain; + + // Destination chain: Squidrouter swap to final token + const { approveData: finalApproveData, swapData: finalSwapData } = + await createOnrampSquidrouterTransactionsOnDestinationChain({ + destinationAddress: evmEphemeralEntry.address, + fromAddress: evmEphemeralEntry.address, + fromToken: bridgedTokenForFallback, + network: toNetwork as EvmNetworks, + rawAmount: inputAmountPostAnchorFeeRaw, + toToken: outputTokenDetails.erc20AddressSourceChain + }); + + let destinationNonce = 0; + + const finalDestinationTransfer = await addOnrampDestinationChainTransactions({ + amountRaw: finalAmountRaw.toString(), + destinationNetwork: toNetwork as EvmNetworks, + isNativeToken: isNativeEvmToken(outputTokenDetails), + toAddress: destinationAddress, + toToken: outputTokenDetails.erc20AddressSourceChain + }); + + unsignedTxs.push({ + meta: {}, + network: toNetwork, + nonce: destinationNonce, + phase: "destinationTransfer", + signer: evmEphemeralEntry.address, + txData: finalDestinationTransfer + }); + + destinationNonce++; + + unsignedTxs.push({ + meta: {}, + network: toNetwork, + nonce: destinationNonce, + phase: "backupSquidRouterApprove", + signer: evmEphemeralEntry.address, + txData: encodeEvmTransactionData(finalApproveData) as EvmTransactionData + }); + destinationNonce++; + + unsignedTxs.push({ + meta: {}, + network: toNetwork, + nonce: destinationNonce, + phase: "backupSquidRouterSwap", + signer: evmEphemeralEntry.address, + txData: encodeEvmTransactionData(finalSwapData) as EvmTransactionData + }); + destinationNonce++; + + const maxUint256 = 2n ** 256n - 1n; + const fundingAccount = privateKeyToAccount(MOONBEAM_FUNDING_PRIVATE_KEY as `0x${string}`); + + const backupApproveTransaction = await addDestinationChainApprovalTransaction({ + amountRaw: maxUint256.toString(), + destinationNetwork: toNetwork as EvmNetworks, + spenderAddress: fundingAccount.address, + tokenAddress: bridgedTokenForFallback + }); + + // We set this to 0 on purpose because we don't want to risk that the required nonce is never reached + const backupApproveNonce = 0; + unsignedTxs.push({ + meta: {}, + network: toNetwork, + nonce: backupApproveNonce, + phase: "backupApprove", + signer: evmEphemeralEntry.address, + txData: backupApproveTransaction + }); + + return { stateMeta, unsignedTxs }; +} diff --git a/apps/frontend/src/pages/progress/index.tsx b/apps/frontend/src/pages/progress/index.tsx index 44410713b..8cb8093d2 100644 --- a/apps/frontend/src/pages/progress/index.tsx +++ b/apps/frontend/src/pages/progress/index.tsx @@ -21,6 +21,7 @@ const PHASE_DURATIONS: Record = { backupApprove: 0, backupSquidRouterApprove: 0, backupSquidRouterSwap: 0, + baseTransfer: 10, brlaOnrampMint: 5 * 60, brlaPayoutOnMoonbeam: 30, complete: 0, diff --git a/apps/frontend/src/pages/progress/phaseMessages.ts b/apps/frontend/src/pages/progress/phaseMessages.ts index d389e9b21..0c58cabc9 100644 --- a/apps/frontend/src/pages/progress/phaseMessages.ts +++ b/apps/frontend/src/pages/progress/phaseMessages.ts @@ -58,6 +58,7 @@ export function getMessageForPhase(ramp: RampState | undefined, t: TFunction<"tr backupApprove: "", // Not relevant for progress page backupSquidRouterApprove: "", backupSquidRouterSwap: "", + baseTransfer: getTransferringMessage(), brlaOnrampMint: t("pages.progress.brlaOnrampMint"), // Not relevant for progress page brlaPayoutOnMoonbeam: getTransferringMessage(), complete: "", diff --git a/bun.lock b/bun.lock index fc0c10029..b0827d5f8 100644 --- a/bun.lock +++ b/bun.lock @@ -5,7 +5,6 @@ "name": "vortex-monorepo", "dependencies": { "big.js": "^7.0.1", - "cobe": "^2.0.1", "husky": "^9.1.7", "lint-staged": "^16.1.0", "numora-react": "^3.0.3", @@ -152,6 +151,7 @@ "buffer": "^6.0.3", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "cobe": "catalog:", "crypto-js": "^4.2.0", "i18next": "^24.2.3", "input-otp": "^1.4.2", @@ -371,6 +371,7 @@ "bcrypt": "5.1.1", "big.js": "^7.0.1", "clsx": "^1.2.1", + "cobe": "^2.0.1", "concurrently": "^9.1.2", "prettier": "^2.8.4", "stellar-sdk": "^13.1.0", diff --git a/packages/shared/src/contracts/index.ts b/packages/shared/src/contracts/index.ts new file mode 100644 index 000000000..55d91319e --- /dev/null +++ b/packages/shared/src/contracts/index.ts @@ -0,0 +1,5 @@ +export * from "./AxelarGasService"; +export * from "./ERC20"; +export * from "./ERC20Wrapper"; +export * from "./Router"; +export * from "./SquidReceiver"; diff --git a/packages/shared/src/endpoints/ramp.endpoints.ts b/packages/shared/src/endpoints/ramp.endpoints.ts index 1afdeacf0..c0c6eb141 100644 --- a/packages/shared/src/endpoints/ramp.endpoints.ts +++ b/packages/shared/src/endpoints/ramp.endpoints.ts @@ -41,6 +41,7 @@ export type RampPhase = | "alfredpayOfframpTransfer" | "brlaOnrampMint" | "brlaPayoutOnMoonbeam" + | "baseTransfer" | "failed" | "timedOut" | "finalSettlementSubsidy" diff --git a/packages/shared/src/services/index.ts b/packages/shared/src/services/index.ts index 9caa39bb4..e3eb64dca 100644 --- a/packages/shared/src/services/index.ts +++ b/packages/shared/src/services/index.ts @@ -1,3 +1,4 @@ +export * from "../contracts"; export * from "./alfredpay"; export * from "./brla"; export * from "./evm"; diff --git a/packages/shared/src/services/nabla/transactions/index.ts b/packages/shared/src/services/nabla/transactions/index.ts index 0221c0128..8767899e3 100644 --- a/packages/shared/src/services/nabla/transactions/index.ts +++ b/packages/shared/src/services/nabla/transactions/index.ts @@ -1,5 +1,16 @@ import { CreateExecuteMessageExtrinsicOptions } from "@pendulum-chain/api-solang"; -import { AccountMeta, ApiManager, encodeSubmittableExtrinsic, PendulumTokenDetails } from "../../../index"; +import { encodeFunctionData } from "viem/utils"; +import { routerAbi } from "../../../contracts/Router"; +import { + AccountMeta, + ApiManager, + EvmClientManager, + EvmTransactionData, + encodeSubmittableExtrinsic, + Networks, + PendulumTokenDetails +} from "../../../index"; +import { NABLA_ROUTER_BASE } from "../../../tokens/constants/misc"; import { prepareNablaApproveTransaction } from "./approve"; import { prepareNablaSwapTransaction } from "./swap"; @@ -50,6 +61,100 @@ export async function createNablaTransactionsForOfframp( }; } +export async function createNablaTransactionsForOnrampOnEVM( + amountRaw: string, + ephemeral: AccountMeta, + inputTokenAddress: `0x${string}`, + outputTokenAddress: `0x${string}`, + nablaHardMinimumOutputRaw: string +) { + if (ephemeral.type !== "EVM") { + throw new Error(`Can't create Nabla EVM transactions for ${ephemeral.type}`); + } + + const evmClientManager = EvmClientManager.getInstance(); + const baseClient = evmClientManager.getClient(Networks.Base); + + const ephemeralAddress = ephemeral.address; + + // Create approve transaction for the input token + const approveCallData = encodeFunctionData({ + abi: [ + { + inputs: [ + { name: "spender", type: "address" }, + { name: "amount", type: "uint256" } + ], + name: "approve", + outputs: [{ type: "bool" }], + stateMutability: "nonpayable", + type: "function" + } + ], + args: [NABLA_ROUTER_BASE, BigInt(amountRaw)], + functionName: "approve" + }); + + const { maxFeePerGas: approveMaxFee, maxPriorityFeePerGas: approveMaxPriority } = await baseClient.estimateFeesPerGas(); + + const approveTransaction: EvmTransactionData = { + data: approveCallData as `0x${string}`, + gas: "100000", + maxFeePerGas: approveMaxFee.toString(), + maxPriorityFeePerGas: approveMaxPriority.toString(), + to: inputTokenAddress, + value: "0" + }; + + // Create swap transaction + const deadline = Math.floor(Date.now() / 1000) + 3600; // 1 hour from now + + // Standard ABI for the swap function + const swapAbi = [ + { + inputs: [ + { name: "_amountIn", type: "uint256" }, + { name: "_amountOutMin", type: "uint256" }, + { name: "_tokenInOut", type: "address[]" }, + { name: "_to", type: "address" }, + { name: "_deadline", type: "uint256" } + ], + name: "swapExactTokensForTokens", + outputs: [{ type: "uint256[]" }], + stateMutability: "nonpayable", + type: "function" + } + ]; + + const swapCallData = encodeFunctionData({ + abi: swapAbi, + args: [ + BigInt(amountRaw), + BigInt(nablaHardMinimumOutputRaw), + [inputTokenAddress, outputTokenAddress], + ephemeralAddress, + BigInt(deadline) + ], + functionName: "swapExactTokensForTokens" + }); + + const { maxFeePerGas: swapMaxFee, maxPriorityFeePerGas: swapMaxPriority } = await baseClient.estimateFeesPerGas(); + + const swapTransaction: EvmTransactionData = { + data: swapCallData as `0x${string}`, + gas: "500000", // Higher gas limit for swap + maxFeePerGas: swapMaxFee.toString(), + maxPriorityFeePerGas: swapMaxPriority.toString(), + to: NABLA_ROUTER_BASE, + value: "0" + }; + + return { + approve: approveTransaction, + swap: swapTransaction + }; +} + export async function createNablaTransactionsForOnramp( amountRaw: string, ephemeral: AccountMeta, diff --git a/packages/shared/src/tokens/constants/misc.ts b/packages/shared/src/tokens/constants/misc.ts index 9d4f1e81c..0553a4493 100644 --- a/packages/shared/src/tokens/constants/misc.ts +++ b/packages/shared/src/tokens/constants/misc.ts @@ -9,6 +9,7 @@ export const ASSETHUB_WSS = "wss://dot-rpc.stakeworld.io/assethub"; export const MOONBEAM_WSS = "wss://wss.api.moonbeam.network"; export const WALLETCONNECT_ASSETHUB_ID = "polkadot:68d56f15f85d3136970ec16946040bc1"; export const NABLA_ROUTER = "6gAVVw13mQgzzKk4yEwScMmWiCNyMAunXFJUZonbgKrym81N"; // AssetHub USDC instance +export const NABLA_ROUTER_BASE: `0x${string}` = "0x58E5Cb2dA15f01CB8FAefef202aa25238efCBdcf"; export const SPACEWALK_REDEEM_SAFETY_MARGIN = 0.05; export const AMM_MINIMUM_OUTPUT_SOFT_MARGIN = 0.02; diff --git a/packages/shared/src/tokens/evm/config.ts b/packages/shared/src/tokens/evm/config.ts index d45aec70b..ef5ee138d 100644 --- a/packages/shared/src/tokens/evm/config.ts +++ b/packages/shared/src/tokens/evm/config.ts @@ -207,6 +207,15 @@ export const evmTokenConfig: Record Date: Tue, 7 Apr 2026 13:01:26 -0300 Subject: [PATCH 02/12] add quote engine for brl onramp on base --- apps/api/src/api/services/quote/core/nabla.ts | 183 ++++++++++++++++-- apps/api/src/api/services/quote/core/types.ts | 16 ++ .../quote/engines/nabla-swap/base-evm.ts | 139 +++++++++++++ .../quote/engines/nabla-swap/onramp-evm.ts | 39 ++++ .../engines/squidrouter/onramp-base-to-evm.ts | 76 ++++++++ .../onramp-avenia-to-evm.strategy-base.ts | 25 +++ 6 files changed, 459 insertions(+), 19 deletions(-) create mode 100644 apps/api/src/api/services/quote/engines/nabla-swap/base-evm.ts create mode 100644 apps/api/src/api/services/quote/engines/nabla-swap/onramp-evm.ts create mode 100644 apps/api/src/api/services/quote/engines/squidrouter/onramp-base-to-evm.ts create mode 100644 apps/api/src/api/services/quote/routes/strategies/onramp-avenia-to-evm.strategy-base.ts diff --git a/apps/api/src/api/services/quote/core/nabla.ts b/apps/api/src/api/services/quote/core/nabla.ts index 7d174e607..3161fe569 100644 --- a/apps/api/src/api/services/quote/core/nabla.ts +++ b/apps/api/src/api/services/quote/core/nabla.ts @@ -1,9 +1,23 @@ -import { ApiManager, getTokenOutAmount, PendulumTokenDetails, QuoteError, RampDirection } from "@vortexfi/shared"; +import { + ApiManager, + EvmClientManager, + EvmTokenDetails, + getTokenOutAmount, + multiplyByPowerOfTen, + Networks, + PendulumTokenDetails, + parseContractBalanceResponse, + QuoteError, + RampDirection, + stringifyBigWithSignificantDecimals +} from "@vortexfi/shared"; import { Big } from "big.js"; import httpStatus from "http-status"; import logger from "../../../../config/logger"; import { APIError } from "../../../errors/api-error"; +const NABLA_ROUTER_BASE: `0x${string}` = "0x58E5Cb2dA15f01CB8FAefef202aa25238efCBdcf"; + export interface NablaSwapRequest { inputAmountForSwap: string; rampType: RampDirection; @@ -17,6 +31,13 @@ export interface NablaSwapResult { effectiveExchangeRate?: string; } +export interface NablaSwapEvmRequest { + inputAmountForSwap: string; + rampType: RampDirection; + inputTokenDetails: EvmTokenDetails; + outputTokenDetails: EvmTokenDetails; +} + export async function calculateNablaSwapOutput(request: NablaSwapRequest): Promise { const { inputAmountForSwap, inputTokenPendulumDetails, outputTokenPendulumDetails } = request; // Validate input amount @@ -27,32 +48,156 @@ export async function calculateNablaSwapOutput(request: NablaSwapRequest): Promi }); } + if (!inputTokenPendulumDetails || !outputTokenPendulumDetails) { + throw new APIError({ + message: QuoteError.UnableToGetPendulumTokenDetails, + status: httpStatus.BAD_REQUEST + }); + } + + const isEVM = inputTokenPendulumDetails.erc20WrapperAddress.startsWith("0x"); + try { - // Get API manager and Pendulum API - const apiManager = ApiManager.getInstance(); - const pendulumApi = await apiManager.getApi("pendulum"); - - if (!inputTokenPendulumDetails || !outputTokenPendulumDetails) { - throw new APIError({ - message: QuoteError.UnableToGetPendulumTokenDetails, - status: httpStatus.BAD_REQUEST + if (isEVM) { + const evmClientManager = EvmClientManager.getInstance(); + const amountIn = multiplyByPowerOfTen(new Big(inputAmountForSwap), inputTokenPendulumDetails.decimals).toFixed(0, 0); + + const swapAbi = [ + { + inputs: [ + { name: "_amountIn", type: "uint256" }, + { name: "_tokenInOut", type: "address[]" } + ], + name: "getAmountOut", + outputs: [ + { name: "amountOut", type: "uint256" }, + { name: "feeAmount", type: "uint256" } + ], + stateMutability: "view", + type: "function" + } + ]; + + const result = await evmClientManager.readContractWithRetry<[bigint, bigint]>(Networks.Base, { + abi: swapAbi, + address: NABLA_ROUTER_BASE, + args: [ + BigInt(amountIn), + [ + inputTokenPendulumDetails.erc20WrapperAddress as `0x${string}`, + outputTokenPendulumDetails.erc20WrapperAddress as `0x${string}` + ] + ], + functionName: "getAmountOut" + }); + + const preciseQuotedAmountOut = parseContractBalanceResponse(outputTokenPendulumDetails.decimals, result[0]); + if (!preciseQuotedAmountOut) { + throw new Error("Failed to parse quoted amount out"); + } + + return { + effectiveExchangeRate: stringifyBigWithSignificantDecimals( + preciseQuotedAmountOut.preciseBigDecimal.div(new Big(inputAmountForSwap)), + 4 + ), + nablaOutputAmountDecimal: preciseQuotedAmountOut.preciseBigDecimal, + nablaOutputAmountRaw: preciseQuotedAmountOut.rawBalance.toFixed() + }; + } else { + // Get API manager and Pendulum API + const apiManager = ApiManager.getInstance(); + const pendulumApi = await apiManager.getApi("pendulum"); + + // Perform the Nabla swap + const swapResult = await getTokenOutAmount({ + api: pendulumApi.api, + fromAmountString: inputAmountForSwap, + inputTokenPendulumDetails, + outputTokenPendulumDetails }); + + return { + effectiveExchangeRate: swapResult.effectiveExchangeRate, + nablaOutputAmountDecimal: swapResult.preciseQuotedAmountOut.preciseBigDecimal, + nablaOutputAmountRaw: swapResult.preciseQuotedAmountOut.rawBalance.toFixed() + }; } - // Perform the Nabla swap - const swapResult = await getTokenOutAmount({ - api: pendulumApi.api, - fromAmountString: inputAmountForSwap, - inputTokenPendulumDetails, - outputTokenPendulumDetails + } catch (error) { + logger.error("Error calculating Nabla swap output:", error); + throw new APIError({ + message: QuoteError.FailedToCalculateQuote, + status: httpStatus.INTERNAL_SERVER_ERROR }); + } +} + +export async function calculateNablaSwapOutputEvm(request: NablaSwapEvmRequest): Promise { + const { inputAmountForSwap, inputTokenDetails, outputTokenDetails } = request; + + // Validate input amount + if (!inputAmountForSwap || Big(inputAmountForSwap).lte(0)) { + throw new APIError({ + message: QuoteError.InputAmountForSwapMustBeGreaterThanZero, + status: httpStatus.BAD_REQUEST + }); + } + + if (!inputTokenDetails || !outputTokenDetails) { + throw new APIError({ + message: QuoteError.UnableToGetPendulumTokenDetails, + status: httpStatus.BAD_REQUEST + }); + } + + try { + const evmClientManager = EvmClientManager.getInstance(); + const amountIn = multiplyByPowerOfTen(new Big(inputAmountForSwap), inputTokenDetails.decimals).toFixed(0, 0); + + const swapAbi = [ + { + inputs: [ + { name: "_amountIn", type: "uint256" }, + { name: "_tokenInOut", type: "address[]" } + ], + name: "getAmountOut", + outputs: [ + { name: "amountOut", type: "uint256" }, + { name: "feeAmount", type: "uint256" } + ], + stateMutability: "view", + type: "function" + } + ]; + + const result = await evmClientManager.readContractWithRetry<[bigint, bigint]>(Networks.Base, { + abi: swapAbi, + address: NABLA_ROUTER_BASE, + args: [ + BigInt(amountIn), + [ + inputTokenDetails.erc20AddressSourceChain as `0x${string}`, + outputTokenDetails.erc20AddressSourceChain as `0x${string}` + ] + ], + functionName: "getAmountOut" + }); + + const preciseQuotedAmountOut = parseContractBalanceResponse(outputTokenDetails.decimals, result[0]); + if (!preciseQuotedAmountOut) { + throw new Error("Failed to parse quoted amount out"); + } return { - effectiveExchangeRate: swapResult.effectiveExchangeRate, - nablaOutputAmountDecimal: swapResult.preciseQuotedAmountOut.preciseBigDecimal, - nablaOutputAmountRaw: swapResult.preciseQuotedAmountOut.rawBalance.toFixed() + effectiveExchangeRate: stringifyBigWithSignificantDecimals( + preciseQuotedAmountOut.preciseBigDecimal.div(new Big(inputAmountForSwap)), + 4 + ), + nablaOutputAmountDecimal: preciseQuotedAmountOut.preciseBigDecimal, + nablaOutputAmountRaw: preciseQuotedAmountOut.rawBalance.toFixed() }; } catch (error) { - logger.error("Error calculating Nabla swap output:", error); + logger.error("Error calculating EVM Nabla swap output:", error); throw new APIError({ message: QuoteError.FailedToCalculateQuote, status: httpStatus.INTERNAL_SERVER_ERROR diff --git a/apps/api/src/api/services/quote/core/types.ts b/apps/api/src/api/services/quote/core/types.ts index c7609e1b0..2abd193ad 100644 --- a/apps/api/src/api/services/quote/core/types.ts +++ b/apps/api/src/api/services/quote/core/types.ts @@ -4,6 +4,7 @@ import { CreateQuoteRequest, DestinationType, + EvmToken, PendulumCurrencyId, QuoteFeeStructure, QuoteResponse, @@ -129,6 +130,21 @@ export interface QuoteContext { oraclePrice?: Big; }; + nablaSwapEvm?: { + inputAmountForSwapDecimal: string; + inputAmountForSwapRaw: string; + inputCurrency: EvmToken; + inputToken: string; // ERC20 address + inputDecimals: number; + outputAmountRaw: string; + outputAmountDecimal: Big; + outputCurrency: EvmToken; + outputDecimals: number; + outputToken: string; // ERC20 address + effectiveExchangeRate?: string; + oraclePrice?: Big; + }; + hydrationSwap?: { inputAmountRaw: string; inputAmountDecimal: string; diff --git a/apps/api/src/api/services/quote/engines/nabla-swap/base-evm.ts b/apps/api/src/api/services/quote/engines/nabla-swap/base-evm.ts new file mode 100644 index 000000000..550f62588 --- /dev/null +++ b/apps/api/src/api/services/quote/engines/nabla-swap/base-evm.ts @@ -0,0 +1,139 @@ +import { EvmToken, EvmTokenDetails, getOnChainTokenDetails, Networks, RampDirection } from "@vortexfi/shared"; +import { Big } from "big.js"; +import logger from "../../../../../config/logger"; +import { priceFeedService } from "../../../priceFeed.service"; +import { calculateNablaSwapOutputEvm } from "../../core/nabla"; +import { QuoteContext, Stage, StageKey } from "../../core/types"; + +export interface NablaSwapEvmConfig { + direction: RampDirection; + skipNote: string; +} + +export interface NablaSwapEvmComputation { + oraclePrice?: Big; + inputAmountPreFees: Big; + inputToken: EvmToken; + outputToken: EvmToken; +} + +export abstract class BaseNablaSwapEngineEvm implements Stage { + abstract readonly config: NablaSwapEvmConfig; + + readonly key = StageKey.NablaSwap; + + async execute(ctx: QuoteContext): Promise { + const { request } = ctx; + const { direction, skipNote } = this.config; + + if (request.rampType !== direction) { + ctx.addNote?.(skipNote); + return; + } + + this.validate(ctx); + + const { inputAmountPreFees, inputToken, outputToken } = this.compute(ctx); + + // Get token details for Base network + const inputTokenDetails = getOnChainTokenDetails(Networks.Base, inputToken) as EvmTokenDetails; + const outputTokenDetails = getOnChainTokenDetails(Networks.Base, outputToken) as EvmTokenDetails; + + if (!inputTokenDetails || !outputTokenDetails) { + throw new Error("BaseNablaSwapEngineEvm: Could not find EVM token details for the requested tokens"); + } + + const deductibleFeeAmount = this.getDeductibleFeeAmount(ctx); + const inputAmountForSwap = inputAmountPreFees.minus(deductibleFeeAmount).toString(); + const inputAmountForSwapRaw = this.calculateInputAmountForSwapRaw(inputAmountForSwap, inputTokenDetails); + + const result = await calculateNablaSwapOutputEvm({ + inputAmountForSwap, + inputTokenDetails, + outputTokenDetails, + rampType: request.rampType + }); + + let oraclePrice; + try { + oraclePrice = await priceFeedService.getOnchainOraclePrice( + request.rampType === RampDirection.BUY ? request.inputCurrency : request.outputCurrency + ); + } catch (error) { + logger.warn( + `BaseNablaSwapEngineEvm: Unable to fetch on-chain oracle price for ${request.outputCurrency}, proceeding without it. Error: ${error}` + ); + } + + this.assignNablaSwapContext( + ctx, + result, + inputAmountForSwap, + inputAmountForSwapRaw, + inputToken, + outputToken, + inputTokenDetails, + outputTokenDetails, + oraclePrice?.price + ); + + this.addNote(ctx, inputTokenDetails, outputTokenDetails, inputAmountForSwap, result); + } + + protected abstract validate(ctx: QuoteContext): void; + + protected abstract compute(ctx: QuoteContext): NablaSwapEvmComputation; + + protected getDeductibleFeeAmount(ctx: QuoteContext): Big { + if (ctx.request.rampType === RampDirection.SELL) { + return ctx.preNabla?.deductibleFeeAmountInSwapCurrency || new Big(0); + } else { + // For onramps, the fees are deducted after the nabla swap, so no deductible fee before the swap + return new Big(0); + } + } + + protected calculateInputAmountForSwapRaw(inputAmountForSwap: string, inputToken: EvmTokenDetails): string { + return new Big(inputAmountForSwap).times(new Big(10).pow(inputToken.decimals)).toFixed(0); + } + + private assignNablaSwapContext( + ctx: QuoteContext, + result: { effectiveExchangeRate?: string; nablaOutputAmountDecimal: Big; nablaOutputAmountRaw: string }, + inputAmountForSwapDecimal: string, + inputAmountForSwapRaw: string, + inputToken: EvmToken, + outputToken: EvmToken, + inputTokenDetails: EvmTokenDetails, + outputTokenDetails: EvmTokenDetails, + oraclePrice?: Big + ): void { + ctx.nablaSwapEvm = { + ...ctx.nablaSwapEvm, + effectiveExchangeRate: result.effectiveExchangeRate, + inputAmountForSwapDecimal, + inputAmountForSwapRaw, + inputCurrency: inputToken, + inputDecimals: inputTokenDetails.decimals, + inputToken: inputTokenDetails.erc20AddressSourceChain, + oraclePrice, + outputAmountDecimal: result.nablaOutputAmountDecimal, + outputAmountRaw: result.nablaOutputAmountRaw, + outputCurrency: outputToken, + outputDecimals: outputTokenDetails.decimals, + outputToken: outputTokenDetails.erc20AddressSourceChain + }; + } + + private addNote( + ctx: QuoteContext, + inputToken: EvmTokenDetails, + outputToken: EvmTokenDetails, + inputAmountForSwap: string, + result: { nablaOutputAmountDecimal: Big } + ): void { + ctx.addNote?.( + `Nabla swap from ${inputToken.assetSymbol} to ${outputToken.assetSymbol}, input amount ${inputAmountForSwap}, output amount ${result.nablaOutputAmountDecimal.toFixed()}` + ); + } +} diff --git a/apps/api/src/api/services/quote/engines/nabla-swap/onramp-evm.ts b/apps/api/src/api/services/quote/engines/nabla-swap/onramp-evm.ts new file mode 100644 index 000000000..e947c7377 --- /dev/null +++ b/apps/api/src/api/services/quote/engines/nabla-swap/onramp-evm.ts @@ -0,0 +1,39 @@ +import { EvmToken, RampDirection } from "@vortexfi/shared"; +import { QuoteContext } from "../../core/types"; +import { BaseNablaSwapEngineEvm, NablaSwapEvmComputation } from "./base-evm"; + +export class OnRampSwapEngineEVM extends BaseNablaSwapEngineEvm { + readonly config = { + direction: RampDirection.BUY, + skipNote: "OnRampSwapEngineEVM: Skipped because rampType is SELL, this engine handles BUY operations only" + } as const; + + protected validate(ctx: QuoteContext): void { + if (!ctx.fees?.usd) { + throw new Error("OnRampSwapEngineEVM: Fees in USD must be calculated first - ensure fee stage ran successfully"); + } + } + + protected compute(ctx: QuoteContext): NablaSwapEvmComputation { + const { request } = ctx; + + if (!ctx.aveniaTransfer) { + throw new Error( + "OnRampSwapEngineEVM: Missing aveniaTransfer quote data from previous stage - ensure initialize stage ran successfully" + ); + } + + const inputAmountPreFees = ctx.aveniaTransfer.outputAmountDecimal; + + // For Onramp EVM, the input token for Nabla is the output of Avenia transfer (BRL on Base) + // The output token is fixed at USDC. + const inputToken = ctx.aveniaTransfer.currency as EvmToken; + const outputToken = EvmToken.USDC; + + return { + inputAmountPreFees, + inputToken, + outputToken + }; + } +} 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 new file mode 100644 index 000000000..b0494edab --- /dev/null +++ b/apps/api/src/api/services/quote/engines/squidrouter/onramp-base-to-evm.ts @@ -0,0 +1,76 @@ +import { + ERC20_USDC_POLYGON, + ERC20_USDC_POLYGON_DECIMALS, + EvmToken, + getNetworkFromDestination, + Networks, + OnChainToken, + RampDirection +} from "@vortexfi/shared"; +import httpStatus from "http-status"; +import { APIError } from "../../../../errors/api-error"; +import { getTokenDetailsForEvmDestination } from "../../core/squidrouter"; +import { QuoteContext } from "../../core/types"; +import { BaseSquidRouterEngine, SquidRouterComputation, SquidRouterConfig, SquidRouterData } from "./index"; + +export class OnRampSquidRouterBrlToEvmEngineBase extends BaseSquidRouterEngine { + readonly config: SquidRouterConfig = { + direction: RampDirection.BUY, + skipNote: "OnRampSquidRouterBrlToEvmEngine: Skipped because rampType is SELL, this engine handles BUY operations only" + }; + + protected validate(ctx: QuoteContext): void { + if (ctx.request.to === "assethub") { + throw new Error( + "OnRampSquidRouterBrlToEvmEngine: Skipped because destination is assethub, this engine handles EVM destinations only" + ); + } + + if (!ctx.aveniaMint?.outputAmountDecimal) { + throw new Error( + "OnRampSquidRouterBrlToEvmEngine: Missing aveniaMint.outputAmountDecimal in context - ensure initialize stage ran successfully" + ); + } + } + + protected compute(ctx: QuoteContext): SquidRouterComputation { + // skip for the trivial case scenario. + if (ctx.to === Networks.Base && ctx.request.outputCurrency === EvmToken.USDC) { + return { + data: { + skipRouteCalculation: true + } as SquidRouterData, + type: "evm-to-evm" + }; + } + + const req = ctx.request; + const toNetwork = getNetworkFromDestination(req.to); + if (!toNetwork) { + throw new APIError({ + message: `Invalid network for destination: ${req.to} `, + status: httpStatus.BAD_REQUEST + }); + } + + const toToken = getTokenDetailsForEvmDestination(req.outputCurrency as OnChainToken, req.to).erc20AddressSourceChain; + // biome-ignore lint/style/noNonNullAssertion: Context is validated in validate + const aveniaMint = ctx.aveniaMint!; + + const usdcBaseTokenDetails = getTokenDetailsForEvmDestination(EvmToken.USDC, Networks.Base); + + return { + data: { + amountRaw: aveniaMint.outputAmountRaw, + fromNetwork: Networks.Base, + fromToken: usdcBaseTokenDetails.erc20AddressSourceChain, + inputAmountDecimal: aveniaMint.outputAmountDecimal, + inputAmountRaw: aveniaMint.outputAmountRaw, + outputDecimals: usdcBaseTokenDetails.decimals, + toNetwork, + toToken + }, + type: "evm-to-evm" + }; + } +} diff --git a/apps/api/src/api/services/quote/routes/strategies/onramp-avenia-to-evm.strategy-base.ts b/apps/api/src/api/services/quote/routes/strategies/onramp-avenia-to-evm.strategy-base.ts new file mode 100644 index 000000000..3f0dcb3bf --- /dev/null +++ b/apps/api/src/api/services/quote/routes/strategies/onramp-avenia-to-evm.strategy-base.ts @@ -0,0 +1,25 @@ +import { EnginesRegistry, IRouteStrategy, QuoteContext, StageKey } from "../../core/types"; +import { OnRampDiscountEngine } from "../../engines/discount/onramp"; +import { OnRampAveniaToEvmFeeEngine } from "../../engines/fee/onramp-brl-to-evm"; +import { OnRampFinalizeEngine } from "../../engines/finalize/onramp"; +import { OnRampInitializeAveniaEngine } from "../../engines/initialize/onramp-avenia"; +import { OnRampSwapEngine } from "../../engines/nabla-swap/onramp"; +import { OnRampPendulumTransferEngine } from "../../engines/pendulum-transfers/onramp"; +import { OnRampSquidRouterBrlToEvmEngineBase } from "../../engines/squidrouter/onramp-base-to-evm"; + +export class OnrampAveniaToEvmStrategy implements IRouteStrategy { + readonly name = "OnRampAveniaToEvm"; + + getStages(_ctx: QuoteContext): StageKey[] { + return [StageKey.Initialize, StageKey.Fee, StageKey.NablaSwap, StageKey.Discount, StageKey.SquidRouter, StageKey.Finalize]; + } + + getEngines(_ctx: QuoteContext): EnginesRegistry { + return { + [StageKey.Initialize]: new OnRampInitializeAveniaEngine(), + [StageKey.NablaSwap]: new OnRampSquidRouterBrlToEvmEngineBase(), // TODO check. Can we use the same as we use on Pendulum? + [StageKey.Discount]: new OnRampDiscountEngine(), + [StageKey.Finalize]: new OnRampFinalizeEngine() + }; + } +} From fb397cc4bdd1848b33ccfd8d6b085a9b739bce8a Mon Sep 17 00:00:00 2001 From: Gianfranco Date: Tue, 7 Apr 2026 15:28:58 -0300 Subject: [PATCH 03/12] adjust phases for base brl flow --- .../handlers/brla-onramp-mint-handler.ts | 24 ++++--- .../phases/handlers/fund-ephemeral-handler.ts | 48 ++++++------- .../api/services/phases/handlers/helpers.ts | 18 ++++- .../phases/handlers/nabla-approve-handler.ts | 67 +++++++++++++++++-- .../phases/handlers/nabla-swap-handler.ts | 56 ++++++++++++++-- .../onramp-avenia-to-evm.strategy-base.ts | 5 +- .../api/services/transactions/onramp/index.ts | 4 +- 7 files changed, 169 insertions(+), 53 deletions(-) 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 90b2ff838..a06bcbb12 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 @@ -6,8 +6,8 @@ import { BrlaApiService, BrlaCurrency, checkEvmBalancePeriodically, - FiatToken, - getAnyFiatTokenDetailsMoonbeam, + EvmToken, + evmTokenConfig, Networks, RampPhase, waitUntilTrueWithTimeout @@ -28,7 +28,7 @@ import { StateMetadata } from "../meta-state-types"; const PAYMENT_TIMEOUT_MS = 30 * 60 * 1000; // 30 minutes const EVM_BALANCE_CHECK_TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes -// Phase description: wait for the tokens to arrive at the Moonbeam ephemeral address. +// Phase description: wait for the tokens to arrive at the Base ephemeral address. // If the timeout is reached, we assume the user has NOT made the payment and we cancel the ramp. export class BrlaOnrampMintHandler extends BasePhaseHandler { public getPhaseName(): RampPhase { @@ -106,7 +106,7 @@ export class BrlaOnrampMintHandler extends BasePhaseHandler { inputPaymentMethod: AveniaPaymentMethod.INTERNAL, inputThirdParty: false, outputCurrency: BrlaCurrency.BRLA, - outputPaymentMethod: AveniaPaymentMethod.MOONBEAM, + outputPaymentMethod: AveniaPaymentMethod.BASE, outputThirdParty: false, subAccountId: taxIdRecord.subAccountId }); @@ -118,7 +118,7 @@ export class BrlaOnrampMintHandler extends BasePhaseHandler { quoteToken: aveniaQuote.quoteToken, ticketBlockchainOutput: { walletAddress: state.state.evmEphemeralAddress, - walletChain: AveniaPaymentMethod.MOONBEAM + walletChain: AveniaPaymentMethod.BASE } }, taxIdRecord.subAccountId @@ -127,20 +127,24 @@ export class BrlaOnrampMintHandler extends BasePhaseHandler { const expectedAmountReceived = quote.metadata.aveniaTransfer?.outputAmountRaw; logger.info( - `BrlaOnrampMintHandler: Created Avenia transfer ticket with id ${aveniaTicket.id} to transfer ${quote.metadata.aveniaTransfer.outputAmountDecimal} BRLA to Moonbeam address ${state.state.evmEphemeralAddress}` + `BrlaOnrampMintHandler: Created Avenia transfer ticket with id ${aveniaTicket.id} to transfer ${quote.metadata.aveniaTransfer.outputAmountDecimal} BRLA to Base address ${state.state.evmEphemeralAddress}` ); try { const pollingTimeMs = 1000; - const tokenDetails = getAnyFiatTokenDetailsMoonbeam(FiatToken.BRL); + const tokenDetails = evmTokenConfig[Networks.Base][EvmToken.BRLA]; + + if (!tokenDetails) { + throw new Error("BRLA token details not found for Base network"); + } await checkEvmBalancePeriodically( - tokenDetails.moonbeamErc20Address, + tokenDetails.erc20AddressSourceChain, evmEphemeralAddress, expectedAmountReceived, pollingTimeMs, EVM_BALANCE_CHECK_TIMEOUT_MS, - Networks.Moonbeam + Networks.Base ); } catch (error) { if (!(error instanceof BalanceCheckError)) throw error; @@ -153,7 +157,7 @@ export class BrlaOnrampMintHandler extends BasePhaseHandler { throw isCheckTimeout ? this.createRecoverableError(`BrlaOnrampMintHandler: phase timeout reached with error: ${error}`) - : new Error(`Error checking Moonbeam balance: ${error}`); + : new Error(`Error checking Base balance: ${error}`); } return this.transitionToNextPhase(state, "fundEphemeral"); 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 3ffc1b0cc..3c4d3153c 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 @@ -26,8 +26,8 @@ import { validateStellarPaymentSequenceNumber } from "../helpers/stellar-sequenc import { StateMetadata } from "../meta-state-types"; import { horizonServer, + isBaseEphemeralFunded, isDestinationEvmEphemeralFunded, - isMoonbeamEphemeralFunded, isPendulumEphemeralFunded, isPolygonEphemeralFunded, isStellarEphemeralFunded, @@ -93,7 +93,7 @@ export class FundEphemeralPhaseHandler extends BasePhaseHandler { return false; } - protected getRequiresMoonbeamEphemeralAddress(state: RampState, inputCurrency?: string): boolean { + protected getRequiresBaseEphemeralAddress(state: RampState, inputCurrency?: string): boolean { // Only required for BRLA onramps. if (isOnramp(state) && inputCurrency === FiatToken.BRL) { return true; @@ -133,7 +133,7 @@ export class FundEphemeralPhaseHandler extends BasePhaseHandler { quote.inputCurrency, quote.outputCurrency ); - const requiresMoonbeamEphemeralAddress = this.getRequiresMoonbeamEphemeralAddress(state, quote.inputCurrency); + const requiresBaseEphemeralAddress = this.getRequiresBaseEphemeralAddress(state, quote.inputCurrency); const requiresDestinationEvmFunding = this.getRequiresDestinationEvmFunding(state); // Ephemeral checks. @@ -152,9 +152,7 @@ export class FundEphemeralPhaseHandler extends BasePhaseHandler { ? await isPendulumEphemeralFunded(substrateEphemeralAddress, pendulumNode) : true; - const isMoonbeamFunded = requiresMoonbeamEphemeralAddress - ? await isMoonbeamEphemeralFunded(evmEphemeralAddress, moonbeamNode) - : true; + const isBaseFunded = requiresBaseEphemeralAddress ? await isBaseEphemeralFunded(evmEphemeralAddress) : true; const isPolygonFunded = requiresPolygonEphemeralAddress ? await isPolygonEphemeralFunded(evmEphemeralAddress) : true; @@ -187,21 +185,14 @@ export class FundEphemeralPhaseHandler extends BasePhaseHandler { logger.info("Pendulum ephemeral address already funded."); } - if (isOnramp(state) && !isMoonbeamFunded) { - logger.info(`Funding moonbeam ephemeral account ${evmEphemeralAddress}`); - - const destinationNetwork = getNetworkFromDestination(state.to); - // For onramp case, "to" is always a network. - if (!destinationNetwork) { - throw new Error("FundEphemeralPhaseHandler: Invalid destination network."); - } - - await fundMoonbeamEphemeralAccount(evmEphemeralAddress); + if (isOnramp(state) && !isBaseFunded) { + logger.info(`Funding base ephemeral account ${evmEphemeralAddress}`); + await this.fundEvmEphemeralAccount(state, Networks.Base); } if (!isPolygonFunded) { logger.info(`Funding polygon ephemeral account ${evmEphemeralAddress}`); - await this.fundPolygonEphemeralAccount(state); + await this.fundEvmEphemeralAccount(state, Networks.Polygon); } else if (requiresPolygonEphemeralAddress) { logger.info("Polygon ephemeral address already funded."); } @@ -232,7 +223,7 @@ export class FundEphemeralPhaseHandler extends BasePhaseHandler { protected nextPhaseSelector(state: RampState, quote: QuoteTicket): RampPhase { // brla onramp case if (isOnramp(state) && quote.inputCurrency === FiatToken.BRL) { - return "moonbeamToPendulumXcm"; + return "nablaApprove"; } // alfredpay onramp case if (isOnramp(state) && quote.inputCurrency === FiatToken.USD) { @@ -312,27 +303,32 @@ export class FundEphemeralPhaseHandler extends BasePhaseHandler { } } - protected async fundPolygonEphemeralAccount(state: RampState): Promise { + protected async fundEvmEphemeralAccount(state: RampState, network: EvmNetworks): Promise { try { const evmClientManager = EvmClientManager.getInstance(); - const polygonClient = evmClientManager.getClient(Networks.Polygon); + const networkClient = evmClientManager.getClient(network); + const chain = networkClient.chain; + + if (!chain) { + throw new Error(`FundEphemeralPhaseHandler: Could not get chain info for ${network}`); + } const ephmeralAddress = state.state.evmEphemeralAddress; const fundingAmountRaw = multiplyByPowerOfTen( POLYGON_EPHEMERAL_STARTING_BALANCE_UNITS, - polygon.nativeCurrency.decimals + chain.nativeCurrency.decimals ).toFixed(); - // We use Moonbeam's funding account to fund the ephemeral account on Polygon. + // We use Moonbeam's funding account to fund the ephemeral account on the network. const fundingAccount = privateKeyToAccount(MOONBEAM_FUNDING_PRIVATE_KEY as `0x${string}`); - const walletClient = evmClientManager.getWalletClient(Networks.Polygon, fundingAccount); + const walletClient = evmClientManager.getWalletClient(network, fundingAccount); const txHash = await walletClient.sendTransaction({ to: ephmeralAddress as `0x${string}`, value: BigInt(fundingAmountRaw) }); - const receipt = await polygonClient.waitForTransactionReceipt({ + const receipt = await networkClient.waitForTransactionReceipt({ hash: txHash as `0x${string}` }); @@ -340,8 +336,8 @@ export class FundEphemeralPhaseHandler extends BasePhaseHandler { throw new Error(`FundEphemeralPhaseHandler: Transaction ${txHash} failed or was not found`); } } catch (error) { - logger.error("FundEphemeralPhaseHandler: Error during funding Polygon ephemeral:", error); - throw new Error("FundEphemeralPhaseHandler: Error during funding Polygon ephemeral: " + error); + logger.error(`FundEphemeralPhaseHandler: Error during funding ${network} ephemeral:`, error); + throw new Error(`FundEphemeralPhaseHandler: Error during funding ${network} ephemeral: ` + error); } } diff --git a/apps/api/src/api/services/phases/handlers/helpers.ts b/apps/api/src/api/services/phases/handlers/helpers.ts index e3578e43f..c7a90e97e 100644 --- a/apps/api/src/api/services/phases/handlers/helpers.ts +++ b/apps/api/src/api/services/phases/handlers/helpers.ts @@ -53,12 +53,26 @@ export async function isPendulumEphemeralFunded(pendulumEphemeralAddress: string return Big(balance.free.toString()).gte(fundingAmountRaw); } -export async function isMoonbeamEphemeralFunded(moonbeamEphemeralAddress: string, moonebamNode: API): Promise { +export async function isMoonbeamEphemeralFunded(moonbeamEphemeralAddress: string, moonbeamNode: API): Promise { //@ts-ignore - const { data: balance } = await moonebamNode.api.query.system.account(moonbeamEphemeralAddress); + const { data: balance } = await moonbeamNode.api.query.system.account(moonbeamEphemeralAddress); return Big(balance.free.toString()).gte(GLMR_FUNDING_AMOUNT_RAW); } +export async function isBaseEphemeralFunded(baseEphemeralAddress: string): Promise { + const evmClientManager = EvmClientManager.getInstance(); + const baseClient = evmClientManager.getClient(VortexNetworks.Base); + + const balance = await baseClient.getBalance({ + address: baseEphemeralAddress as `0x${string}` + }); + const fundingAmountRaw = new Big( + multiplyByPowerOfTen(POLYGON_EPHEMERAL_STARTING_BALANCE_UNITS, polygon.nativeCurrency.decimals).toFixed() + ); + + return Big(balance.toString()).gte(fundingAmountRaw); +} + export async function isPolygonEphemeralFunded(polygonEphemeralAddress: string): Promise { const evmClientManager = EvmClientManager.getInstance(); const polygonClient = evmClientManager.getClient(VortexNetworks.Polygon); 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 c77300379..46383ce8e 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 @@ -1,12 +1,21 @@ import { createExecuteMessageExtrinsic, ExecuteMessageResult, submitExtrinsic } from "@pendulum-chain/api-solang"; import { Abi } from "@polkadot/api-contract"; -import { ApiManager, decodeSubmittableExtrinsic, NABLA_ROUTER, RampPhase } from "@vortexfi/shared"; +import { + ApiManager, + decodeSubmittableExtrinsic, + EvmClientManager, + FiatToken, + NABLA_ROUTER, + Networks, + RampPhase +} from "@vortexfi/shared"; import Big from "big.js"; import logger from "../../../../config/logger"; import { erc20WrapperAbi } from "../../../../contracts/ERC20Wrapper"; import QuoteTicket from "../../../../models/quoteTicket.model"; import RampState from "../../../../models/rampState.model"; import { BasePhaseHandler } from "../base-phase-handler"; +import { StateMetadata } from "../meta-state-types"; export class NablaApprovePhaseHandler extends BasePhaseHandler { public getPhaseName(): RampPhase { @@ -14,10 +23,6 @@ export class NablaApprovePhaseHandler extends BasePhaseHandler { } protected async executePhase(state: RampState): Promise { - const apiManager = ApiManager.getInstance(); - const networkName = "pendulum"; - const pendulumNode = await apiManager.getApi(networkName); - const quote = await QuoteTicket.findByPk(state.quoteId); if (!quote) { @@ -28,6 +33,26 @@ export class NablaApprovePhaseHandler extends BasePhaseHandler { throw new Error("Missing nablaSwap info in quote metadata"); } + const { substrateEphemeralAddress } = state.state as StateMetadata; + + if (quote.inputCurrency === FiatToken.BRL) { + return this.executeEvmApprove(state); + } else if (substrateEphemeralAddress) { + return this.executeSubstrateApprove(state, quote); + } else { + throw new Error("NablaApprovePhaseHandler: Neither EVM nor substrate ephemeral address found in state"); + } + } + + private async executeSubstrateApprove(state: RampState, quote: QuoteTicket): Promise { + const apiManager = ApiManager.getInstance(); + const networkName = "pendulum"; + const pendulumNode = await apiManager.getApi(networkName); + + if (!quote.metadata.nablaSwap) { + throw new Error("Missing nablaSwap info in quote metadata"); + } + try { const approval = await pendulumNode.api.query.tokenAllowance.approvals( quote.metadata.nablaSwap.inputCurrencyId, @@ -99,6 +124,38 @@ export class NablaApprovePhaseHandler extends BasePhaseHandler { throw e; } } + + private async executeEvmApprove(state: RampState): Promise { + const evmClientManager = EvmClientManager.getInstance(); + const baseClient = evmClientManager.getClient(Networks.Base); + + try { + const { txData: nablaApproveTransaction } = this.getPresignedTransaction(state, "nablaApprove"); + + if (typeof nablaApproveTransaction !== "string") { + throw new Error("NablaApprovePhaseHandler: Invalid EVM transaction data. This is a bug."); + } + + const txHash = await baseClient.sendRawTransaction({ + serializedTransaction: nablaApproveTransaction as `0x${string}` + }); + + const receipt = await baseClient.waitForTransactionReceipt({ + hash: txHash + }); + + if (!receipt || receipt.status !== "success") { + throw new Error(`NablaApprovePhaseHandler: EVM approve transaction ${txHash} failed`); + } + + logger.info(`NablaApprovePhaseHandler: EVM approve transaction successful: ${txHash}`); + + return this.transitionToNextPhase(state, "nablaSwap"); + } catch (e) { + logger.error(`Could not approve token on EVM: ${(e as Error).message}`); + throw e; + } + } } export default new NablaApprovePhaseHandler(); 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 64a360eeb..1f22f38a0 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 @@ -4,7 +4,10 @@ import { ApiManager, decodeSubmittableExtrinsic, defaultReadLimits, + EvmClientManager, + FiatToken, NABLA_ROUTER, + Networks, RampDirection, RampPhase } from "@vortexfi/shared"; @@ -22,16 +25,28 @@ export class NablaSwapPhaseHandler extends BasePhaseHandler { } protected async executePhase(state: RampState): Promise { - const apiManager = ApiManager.getInstance(); - const networkName = "pendulum"; - const pendulumNode = await apiManager.getApi(networkName); - const quote = await QuoteTicket.findByPk(state.quoteId); if (!quote) { throw new Error("Quote not found for the given state"); } + const { substrateEphemeralAddress } = state.state as StateMetadata; + + if (quote.inputCurrency === FiatToken.BRL) { + return this.executeEvmSwap(state); + } else if (substrateEphemeralAddress) { + return this.executeSubstrateSwap(state, quote); + } else { + throw new Error("NablaSwapPhaseHandler: Neither EVM nor substrate ephemeral address found in state"); + } + } + + private async executeSubstrateSwap(state: RampState, quote: QuoteTicket): Promise { + const apiManager = ApiManager.getInstance(); + const networkName = "pendulum"; + const pendulumNode = await apiManager.getApi(networkName); + const { nablaSoftMinimumOutputRaw, substrateEphemeralAddress } = state.state as StateMetadata; if (!nablaSoftMinimumOutputRaw || !substrateEphemeralAddress) { @@ -118,6 +133,39 @@ export class NablaSwapPhaseHandler extends BasePhaseHandler { const nextPhase = state.type === RampDirection.BUY ? "distributeFees" : "subsidizePostSwap"; return this.transitionToNextPhase(state, nextPhase); } + + private async executeEvmSwap(state: RampState): Promise { + const evmClientManager = EvmClientManager.getInstance(); + const baseClient = evmClientManager.getClient(Networks.Base); + + try { + const { txData: nablaSwapTransaction } = this.getPresignedTransaction(state, "nablaSwap"); + + if (typeof nablaSwapTransaction !== "string") { + throw new Error("NablaSwapPhaseHandler: Invalid EVM transaction data. This is a bug."); + } + + const txHash = await baseClient.sendRawTransaction({ + serializedTransaction: nablaSwapTransaction as `0x${string}` + }); + + const receipt = await baseClient.waitForTransactionReceipt({ + hash: txHash + }); + + if (!receipt || receipt.status !== "success") { + throw new Error(`NablaSwapPhaseHandler: EVM swap transaction ${txHash} failed`); + } + + logger.info(`NablaSwapPhaseHandler: EVM swap transaction successful: ${txHash}`); + } catch (e) { + logger.error(`Could not swap token on EVM: ${(e as Error).message}`); + throw e; + } + + const nextPhase = state.type === RampDirection.BUY ? "distributeFees" : "subsidizePostSwap"; + return this.transitionToNextPhase(state, nextPhase); + } } export default new NablaSwapPhaseHandler(); diff --git a/apps/api/src/api/services/quote/routes/strategies/onramp-avenia-to-evm.strategy-base.ts b/apps/api/src/api/services/quote/routes/strategies/onramp-avenia-to-evm.strategy-base.ts index 3f0dcb3bf..99b1d2013 100644 --- a/apps/api/src/api/services/quote/routes/strategies/onramp-avenia-to-evm.strategy-base.ts +++ b/apps/api/src/api/services/quote/routes/strategies/onramp-avenia-to-evm.strategy-base.ts @@ -1,10 +1,7 @@ import { EnginesRegistry, IRouteStrategy, QuoteContext, StageKey } from "../../core/types"; import { OnRampDiscountEngine } from "../../engines/discount/onramp"; -import { OnRampAveniaToEvmFeeEngine } from "../../engines/fee/onramp-brl-to-evm"; import { OnRampFinalizeEngine } from "../../engines/finalize/onramp"; import { OnRampInitializeAveniaEngine } from "../../engines/initialize/onramp-avenia"; -import { OnRampSwapEngine } from "../../engines/nabla-swap/onramp"; -import { OnRampPendulumTransferEngine } from "../../engines/pendulum-transfers/onramp"; import { OnRampSquidRouterBrlToEvmEngineBase } from "../../engines/squidrouter/onramp-base-to-evm"; export class OnrampAveniaToEvmStrategy implements IRouteStrategy { @@ -17,7 +14,7 @@ export class OnrampAveniaToEvmStrategy implements IRouteStrategy { getEngines(_ctx: QuoteContext): EnginesRegistry { return { [StageKey.Initialize]: new OnRampInitializeAveniaEngine(), - [StageKey.NablaSwap]: new OnRampSquidRouterBrlToEvmEngineBase(), // TODO check. Can we use the same as we use on Pendulum? + [StageKey.NablaSwap]: new OnRampSquidRouterBrlToEvmEngineBase(), [StageKey.Discount]: new OnRampDiscountEngine(), [StageKey.Finalize]: new OnRampFinalizeEngine() }; diff --git a/apps/api/src/api/services/transactions/onramp/index.ts b/apps/api/src/api/services/transactions/onramp/index.ts index 4227e3184..47622a10d 100644 --- a/apps/api/src/api/services/transactions/onramp/index.ts +++ b/apps/api/src/api/services/transactions/onramp/index.ts @@ -8,7 +8,7 @@ import { } from "./common/types"; import { prepareAlfredpayToEvmOnrampTransactions } from "./routes/alfredpay-to-evm"; import { prepareAveniaToAssethubOnrampTransactions } from "./routes/avenia-to-assethub"; -import { prepareAveniaToEvmOnrampTransactions } from "./routes/avenia-to-evm"; +import { prepareAveniaToEvmOnrampTransactionsOnBase } from "./routes/avenia-to-evm"; import { prepareMoneriumToAssethubOnrampTransactions } from "./routes/monerium-to-assethub"; import { prepareMoneriumToEvmOnrampTransactions } from "./routes/monerium-to-evm"; @@ -32,7 +32,7 @@ export async function prepareOnrampTransactions( if (quote.to === Networks.AssetHub) { return prepareAveniaToAssethubOnrampTransactions(aveniaParams); } else { - return prepareAveniaToEvmOnrampTransactions(aveniaParams); + return prepareAveniaToEvmOnrampTransactionsOnBase(aveniaParams); } } else if (quote.inputCurrency === FiatToken.EURC) { if (!("moneriumWalletAddress" in params)) { From cb54d5e10721c548bda1fda0d5a8fcd996c7f812 Mon Sep 17 00:00:00 2001 From: Gianfranco Date: Tue, 7 Apr 2026 17:19:29 -0300 Subject: [PATCH 04/12] handle subsidy, fee distribution on BRL Base --- .../handlers/distribute-fees-handler.ts | 141 ++++++++++++++-- .../subsidize-post-swap-evm-handler.ts | 157 ++++++++++++++++++ .../services/quote/engines/discount/onramp.ts | 80 +++++++-- .../quote/engines/nabla-swap/onramp-evm.ts | 8 +- .../quote/engines/squidrouter/index.ts | 10 ++ .../engines/squidrouter/onramp-base-to-evm.ts | 35 +++- .../onramp-avenia-to-evm.strategy-base.ts | 4 +- 7 files changed, 396 insertions(+), 39 deletions(-) create mode 100644 apps/api/src/api/services/phases/handlers/subsidize-post-swap-evm-handler.ts 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 706e36714..e1f530cbb 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 @@ -5,12 +5,18 @@ import { ISubmittableResult } from "@polkadot/types/types"; import { ApiManager, decodeSubmittableExtrinsic, + EvmClientManager, + EvmNetworks, + isEvmTransactionData, + Networks, RampDirection, RampPhase, - TransactionTemporarilyBannedError + TransactionTemporarilyBannedError, + waitUntilTrueWithTimeout } from "@vortexfi/shared"; +import { privateKeyToAccount } from "viem/accounts"; import logger from "../../../../config/logger"; -import { SUBSCAN_API_KEY } from "../../../../constants/constants"; +import { MOONBEAM_FUNDING_PRIVATE_KEY, SUBSCAN_API_KEY } from "../../../../constants/constants"; import QuoteTicket from "../../../../models/quoteTicket.model"; import RampState from "../../../../models/rampState.model"; import { PhaseError } from "../../../errors/phase-error"; @@ -26,7 +32,7 @@ enum ExtrinsicStatus { } /** - * Handler for distributing Network, Vortex, and Partner fees using a stablecoin on Pendulum + * Handler for distributing Network, Vortex, and Partner fees using a stablecoin on Pendulum or EVM chains */ export class DistributeFeesHandler extends BasePhaseHandler { private apiManager: ApiManager; @@ -60,18 +66,34 @@ export class DistributeFeesHandler extends BasePhaseHandler { // Check if we already have a hash stored const existingHash = state.state.distributeFeeHash || null; + // For BRL onramp flows, distributio happens on EVM (Base). + const isEvmTransaction = quote.inputCurrency === "BRL"; + if (existingHash) { logger.info(`Found existing distribute fee hash for ramp ${state.id}: ${existingHash}`); - const status = await this.checkExtrinsicStatus(existingHash).catch((_: unknown) => { - throw this.createRecoverableError("Failed to check extrinsic status"); - }); + if (isEvmTransaction) { + const status = await this.checkEvmTransactionStatus(existingHash).catch((_: unknown) => { + throw this.createRecoverableError("Failed to check EVM transaction status"); + }); - if (status === ExtrinsicStatus.Success) { - logger.info(`Existing distribute fee transaction was successful for ramp ${state.id}`); - return this.transitionToNextPhase(state, nextPhase); + if (status === ExtrinsicStatus.Success) { + logger.info(`Existing distribute fee EVM transaction was successful for ramp ${state.id}`); + return this.transitionToNextPhase(state, nextPhase); + } else { + logger.info(`Existing distribute fee EVM transaction was not successful (status: ${status}), will retry`); + } } else { - logger.info(`Existing distribute fee transaction was not successful (status: ${status}), will retry`); + const status = await this.checkExtrinsicStatus(existingHash).catch((_: unknown) => { + throw this.createRecoverableError("Failed to check extrinsic status"); + }); + + if (status === ExtrinsicStatus.Success) { + logger.info(`Existing distribute fee transaction was successful for ramp ${state.id}`); + return this.transitionToNextPhase(state, nextPhase); + } else { + logger.info(`Existing distribute fee transaction was not successful (status: ${status}), will retry`); + } } } @@ -83,12 +105,21 @@ export class DistributeFeesHandler extends BasePhaseHandler { return this.transitionToNextPhase(state, nextPhase); } - const { api } = await this.apiManager.getApi("pendulum"); + let actualTxHash: string; - const decodedTx = decodeSubmittableExtrinsic(distributeFeeTransaction.txData as string, api); + if (isEvmTransaction) { + logger.info(`Submitting EVM fee distribution transaction for ramp ${state.id}...`); + actualTxHash = await this.submitEvmTransaction( + distributeFeeTransaction.txData, + distributeFeeTransaction.network as EvmNetworks + ); + } else { + const { api } = await this.apiManager.getApi("pendulum"); + const decodedTx = decodeSubmittableExtrinsic(distributeFeeTransaction.txData as string, api); - logger.info(`Submitting fee distribution transaction for ramp ${state.id}...`); - const actualTxHash = await this.submitTransaction(decodedTx, api); + logger.info(`Submitting substrate fee distribution transaction for ramp ${state.id}...`); + actualTxHash = await this.submitTransaction(decodedTx, api); + } logger.info(`Transaction broadcast with hash ${actualTxHash}. Persisting hash...`); @@ -100,8 +131,12 @@ export class DistributeFeesHandler extends BasePhaseHandler { } }); - // Wait for extrinsic success using Subscan API - await this.waitForExtrinsicSuccess(actualTxHash); + // Wait for transaction success + if (isEvmTransaction) { + await this.waitForEvmTransactionSuccess(actualTxHash, distributeFeeTransaction.network as EvmNetworks); + } else { + await this.waitForExtrinsicSuccess(actualTxHash); + } logger.info(`Successfully verified fee distribution transaction for ramp ${state.id}: ${actualTxHash}`); return this.transitionToNextPhase(updatedState, nextPhase); @@ -279,6 +314,80 @@ export class DistributeFeesHandler extends BasePhaseHandler { throw error; } } + + /** + * Submit an EVM transaction + * @param txData The EVM transaction data + * @param network The EVM network + * @returns The transaction hash + */ + private async submitEvmTransaction(txData: any, network: EvmNetworks): Promise { + logger.debug(`Submitting EVM transaction to ${network} for ${this.getPhaseName()} phase`); + + const evmClientManager = EvmClientManager.getInstance(); + const fundingAccount = privateKeyToAccount(MOONBEAM_FUNDING_PRIVATE_KEY as `0x${string}`); + + return await evmClientManager.sendTransactionWithBlindRetry(network, fundingAccount, { + data: txData.data as `0x${string}`, + gas: BigInt(txData.gas || "100000"), + maxFeePerGas: txData.maxFeePerGas ? BigInt(txData.maxFeePerGas) : undefined, + maxPriorityFeePerGas: txData.maxPriorityFeePerGas ? BigInt(txData.maxPriorityFeePerGas) : undefined, + to: txData.to as `0x${string}`, + value: BigInt(txData.value || "0") + }); + } + + /** + * Wait for EVM transaction success + * @param txHash The transaction hash + * @param network The EVM network + */ + private async waitForEvmTransactionSuccess(txHash: string, network: EvmNetworks): Promise { + const evmClientManager = EvmClientManager.getInstance(); + const publicClient = evmClientManager.getClient(network); + + await waitUntilTrueWithTimeout( + async () => { + try { + const receipt = await publicClient.getTransactionReceipt({ hash: txHash as `0x${string}` }); + return receipt?.status === "success"; + } catch (error) { + logger.debug(`Error checking EVM transaction receipt: ${error}`); + return false; + } + }, + 2000, // check every 2 seconds + 180000 // timeout after 3 minutes + ); + } + + /** + * Check EVM transaction status + * @param txHash The transaction hash + * @returns ExtrinsicStatus: Success, Fail, or Undefined + */ + private async checkEvmTransactionStatus(txHash: string): Promise { + try { + const evmClientManager = EvmClientManager.getInstance(); + // Always on Base for EVM. + const publicClient = evmClientManager.getClient(Networks.Base); + + const receipt = await publicClient.getTransactionReceipt({ hash: txHash as `0x${string}` }); + + if (receipt) { + if (receipt.status === "success") { + return ExtrinsicStatus.Success; + } else { + return ExtrinsicStatus.Fail; + } + } + + return ExtrinsicStatus.Undefined; + } catch (error: unknown) { + logger.error(`Error checking EVM transaction status: ${error}`); + return ExtrinsicStatus.Undefined; + } + } } export default new DistributeFeesHandler(); diff --git a/apps/api/src/api/services/phases/handlers/subsidize-post-swap-evm-handler.ts b/apps/api/src/api/services/phases/handlers/subsidize-post-swap-evm-handler.ts new file mode 100644 index 000000000..a076d19a5 --- /dev/null +++ b/apps/api/src/api/services/phases/handlers/subsidize-post-swap-evm-handler.ts @@ -0,0 +1,157 @@ +import { + checkEvmBalanceForToken, + EvmClientManager, + EvmNetworks, + EvmToken, + EvmTokenDetails, + isNativeEvmToken, + Networks, + nativeToDecimal, + RampDirection, + RampPhase, + waitUntilTrueWithTimeout +} from "@vortexfi/shared"; +import Big from "big.js"; +import { encodeFunctionData, erc20Abi } from "viem"; +import { privateKeyToAccount } from "viem/accounts"; +import logger from "../../../../config/logger"; +import { MOONBEAM_FUNDING_PRIVATE_KEY } from "../../../../constants/constants"; +import QuoteTicket from "../../../../models/quoteTicket.model"; +import RampState from "../../../../models/rampState.model"; +import { SubsidyToken } from "../../../../models/subsidy.model"; +import { BasePhaseHandler } from "../base-phase-handler"; +import { StateMetadata } from "../meta-state-types"; + +export class SubsidizePostSwapEvmPhaseHandler extends BasePhaseHandler { + public getPhaseName(): RampPhase { + return "subsidizePostSwap"; + } + + protected async executePhase(state: RampState): Promise { + const quote = await QuoteTicket.findByPk(state.quoteId); + if (!quote) { + throw new Error("Quote not found for the given state"); + } + + const { evmEphemeralAddress } = state.state as StateMetadata; + + if (!evmEphemeralAddress) { + throw new Error("SubsidizePostSwapEvmPhaseHandler: State metadata corrupted. This is a bug."); + } + + if (!quote.metadata.evmToEvm) { + throw new Error("Missing evmToEvm information in quote metadata"); + } + + if (!quote.metadata.nablaSwapEvm) { + throw new Error("Missing nablaSwapEvm information in quote metadata"); + } + + if (!quote.metadata.subsidy) { + throw new Error("Missing subsidy information in quote metadata"); + } + + try { + // Get token details for the output token + const outputTokenDetails = quote.metadata.nablaSwapEvm.outputToken as unknown as EvmTokenDetails; + + // Check current balance on EVM + const currentBalance = await checkEvmBalanceForToken({ + amountDesiredRaw: "1", + chain: outputTokenDetails.network as EvmNetworks, + intervalMs: 1000, // Just check if there's any balance + ownerAddress: evmEphemeralAddress, + timeoutMs: 5000, + tokenDetails: outputTokenDetails + }); + + if (currentBalance.eq(Big(0))) { + throw new Error("Invalid phase: input token did not arrive yet on EVM"); + } + + // Add a default/base expected output amount from the swap + let expectedSwapOutputAmountRaw = Big(quote.metadata.nablaSwapEvm.outputAmountRaw).plus( + quote.metadata.subsidy.subsidyAmountInOutputTokenRaw + ); + + // Try to find the required amount to subsidize on the quote metadata + if (state.type === RampDirection.BUY) { + // For BUY operations, use the evmToEvm inputAmountRaw as the expected amount + expectedSwapOutputAmountRaw = Big(quote.metadata.evmToEvm?.inputAmountRaw); + } else { + throw new Error("SubsidizePostSwapEvmPhaseHandler: SELL operations are not supported in this handler yet."); + } + + const requiredAmount = Big(expectedSwapOutputAmountRaw).sub(currentBalance); + + const didBalanceReachExpected = async () => { + const balance = await checkEvmBalanceForToken({ + amountDesiredRaw: expectedSwapOutputAmountRaw.toString(), + chain: outputTokenDetails.network as EvmNetworks, + intervalMs: 1000, + ownerAddress: evmEphemeralAddress, + timeoutMs: 5000, + tokenDetails: outputTokenDetails + }); + return balance.gte(expectedSwapOutputAmountRaw); + }; + + if (requiredAmount.gt(Big(0))) { + // Do the actual subsidizing on EVM + logger.info( + `Subsidizing post-swap EVM with ${requiredAmount.toFixed()} to reach target value of ${expectedSwapOutputAmountRaw}` + ); + + const evmClientManager = EvmClientManager.getInstance(); + const fundingAccount = privateKeyToAccount(MOONBEAM_FUNDING_PRIVATE_KEY as `0x${string}`); + const destinationNetwork = outputTokenDetails.network as EvmNetworks; + + // Get gas estimates + const publicClient = evmClientManager.getClient(destinationNetwork); + const { maxFeePerGas, maxPriorityFeePerGas } = await publicClient.estimateFeesPerGas(); + + // ERC-20 transfer. + const data = encodeFunctionData({ + abi: erc20Abi, + args: [evmEphemeralAddress as `0x${string}`, BigInt(requiredAmount.toFixed(0))], + functionName: "transfer" + }); + + const txHash = await evmClientManager.sendTransactionWithBlindRetry(destinationNetwork, fundingAccount, { + data, + maxFeePerGas, + maxPriorityFeePerGas, + to: outputTokenDetails.erc20AddressSourceChain as `0x${string}`, + value: 0n + }); + + const subsidyAmount = nativeToDecimal(requiredAmount, quote.metadata.nablaSwapEvm.outputDecimals).toNumber(); + const subsidyToken = quote.metadata.nablaSwapEvm.outputCurrency as unknown as SubsidyToken; + + await this.createSubsidy(state, subsidyAmount, subsidyToken, fundingAccount.address, txHash); + + // Wait for the balance to update + await waitUntilTrueWithTimeout(didBalanceReachExpected, 2000); + } + + return this.transitionToNextPhase(state, this.nextPhaseSelector(state, quote)); + } catch (e) { + logger.error("Error in subsidizePostSwapEvm:", e); + throw this.createRecoverableError("SubsidizePostSwapEvmPhaseHandler: Failed to subsidize post swap on EVM."); + } + } + + protected nextPhaseSelector(state: RampState, quote: QuoteTicket): RampPhase { + // For EVM, the next phases would be EVM-specific + // This needs to be adapted based on the specific EVM chain and flow + // For now, keeping similar logic but this should be reviewed + if (state.type === RampDirection.BUY) { + // For BUY operations on EVM, typically go to final settlement or similar + return "squidRouterSwap"; + } else { + throw new Error("SubsidizePostSwapEvmPhaseHandler: SELL operations are not supported in this handler."); + } + } +} + +export default new SubsidizePostSwapEvmPhaseHandler(); 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 cf851b175..15f4dca8f 100644 --- a/apps/api/src/api/services/quote/engines/discount/onramp.ts +++ b/apps/api/src/api/services/quote/engines/discount/onramp.ts @@ -21,11 +21,13 @@ export class OnRampDiscountEngine extends BaseDiscountEngine { } as const; protected validate(ctx: QuoteContext): void { - if (!ctx.nablaSwap) { - throw new Error("OnRampDiscountEngine requires nablaSwap to be defined"); + // Handle both Base USDC flows and Moonbeam axlUSDC flows + if (!ctx.nablaSwap && !ctx.nablaSwapEvm) { + throw new Error("OnRampDiscountEngine requires either nablaSwap or nablaSwapEvm to be defined"); } - if (!ctx.nablaSwap.oraclePrice) { + const nablaSwap = ctx.nablaSwap || ctx.nablaSwapEvm; + if (!nablaSwap?.oraclePrice) { throw new Error("OnRampDiscountEngine requires nablaSwap.oraclePrice to be defined"); } @@ -91,9 +93,63 @@ export class OnRampDiscountEngine extends BaseDiscountEngine { } } + /** + * Queries squidrouter to determine the actual conversion rate from USDC on Base + * to the final destination token on the target EVM chain. + * + * The oracle price is based on the Binance USDT-BRL rate, but the Nabla swap on Base + * outputs USDC (not USDT). Since USDC may trade at a discount to USDT via + * squidrouter, using the oracle USDT rate as the USDC subsidy target means the user + * may receive slightly less than the oracle-promised amount after the squidrouter step. + * + * This method fetches the actual USDC → destination token rate so the discount engine + * can back-calculate the precise USDC amount required on Base. + * + * @param ctx - The quote context (must have request.outputCurrency and request.to set) + * @param expectedUSDCDecimal - The oracle-based expected USDC amount used as probe input + * @returns The conversion rate (destination token units per USDC) or null on failure + */ + private async getSquidRouterUSDCConversionRate(ctx: QuoteContext, expectedUSDCDecimal: Big): Promise { + const req = ctx.request; + const toNetwork = getNetworkFromDestination(req.to); + + if (!toNetwork) { + return null; + } + + try { + const bridgeQuote = await getEvmBridgeQuote({ + amountDecimal: expectedUSDCDecimal.toString(), + fromNetwork: Networks.Base, + inputCurrency: EvmToken.USDC as unknown as OnChainToken, + outputCurrency: req.outputCurrency as OnChainToken, + rampType: req.rampType, + toNetwork + }); + + if (expectedUSDCDecimal.lte(0) || bridgeQuote.outputAmountDecimal.lte(0)) { + return null; + } + + const conversionRate = bridgeQuote.outputAmountDecimal.div(expectedUSDCDecimal); + logger.info( + `OnRampDiscountEngine: SquidRouter USDC→${req.outputCurrency} rate: ${conversionRate.toFixed(6)} ` + + `(input: ${expectedUSDCDecimal.toFixed(6)} USDC, output: ${bridgeQuote.outputAmountDecimal.toFixed(6)} ${req.outputCurrency})` + ); + return conversionRate; + } catch (error) { + logger.warn( + `OnRampDiscountEngine: Could not fetch SquidRouter USDC→${req.outputCurrency} conversion rate, ` + + `falling back to 1:1 assumption. Error: ${error}` + ); + return null; + } + } + protected async compute(ctx: QuoteContext): Promise { - // biome-ignore lint/style/noNonNullAssertion: Context is validated in validate - const nablaSwap = ctx.nablaSwap!; + // Determine which nabla swap we're using (Base EVM or Pendulum) + const isBaseFlow = !!ctx.nablaSwapEvm; + const nablaSwap = ctx.nablaSwapEvm || ctx.nablaSwap!; // biome-ignore lint/style/noNonNullAssertion: Context is validated in validate const oraclePrice = nablaSwap.oraclePrice!; // biome-ignore lint/style/noNonNullAssertion: Context is validated in validate @@ -105,25 +161,25 @@ export class OnRampDiscountEngine extends BaseDiscountEngine { const targetDiscount = partner?.targetDiscount ?? 0; const maxSubsidy = partner?.maxSubsidy ?? 0; - // Calculate the oracle-based expected output in USDT-equivalent axlUSDC terms. + // Calculate the oracle-based expected output const { expectedOutput: oracleExpectedOutputDecimal, adjustedDifference, adjustedTargetDiscount } = calculateExpectedOutput(inputAmount, oraclePrice, targetDiscount, this.config.isOfframp, partner); - // For onramps to EVM chains (not AssetHub), the Nabla output token (axlUSDC on - // Pendulum) is subsequently bridged via squidrouter (Moonbeam → EVM destination). The - // oracle gives a USDT-BRL rate, but axlUSDC may not trade 1:1 with USDT on squidrouter. - // So we use the actual squidrouter route to determine the required axlUSDC amount + // For onramps to EVM chains (not AssetHub), adjust for the actual bridge conversion rate let adjustedExpectedOutputDecimal = oracleExpectedOutputDecimal; if (ctx.request.to !== "assethub") { - const squidRouterRate = await this.getSquidRouterAxlUSDCConversionRate(ctx, oracleExpectedOutputDecimal); + const squidRouterRate = isBaseFlow + ? await this.getSquidRouterUSDCConversionRate(ctx, oracleExpectedOutputDecimal) + : await this.getSquidRouterAxlUSDCConversionRate(ctx, oracleExpectedOutputDecimal); if (squidRouterRate !== null && squidRouterRate.gt(0)) { adjustedExpectedOutputDecimal = oracleExpectedOutputDecimal.div(squidRouterRate); + const tokenName = isBaseFlow ? "USDC" : "axlUSDC"; ctx.addNote?.( - `OnRampDiscountEngine: Adjusted expected axlUSDC from ${oracleExpectedOutputDecimal.toFixed(6)} ` + + `OnRampDiscountEngine: Adjusted expected ${tokenName} from ${oracleExpectedOutputDecimal.toFixed(6)} ` + `to ${adjustedExpectedOutputDecimal.toFixed(6)} (squidRouter rate: ${squidRouterRate.toFixed(6)})` ); } diff --git a/apps/api/src/api/services/quote/engines/nabla-swap/onramp-evm.ts b/apps/api/src/api/services/quote/engines/nabla-swap/onramp-evm.ts index e947c7377..75cdff6c2 100644 --- a/apps/api/src/api/services/quote/engines/nabla-swap/onramp-evm.ts +++ b/apps/api/src/api/services/quote/engines/nabla-swap/onramp-evm.ts @@ -2,15 +2,15 @@ import { EvmToken, RampDirection } from "@vortexfi/shared"; import { QuoteContext } from "../../core/types"; import { BaseNablaSwapEngineEvm, NablaSwapEvmComputation } from "./base-evm"; -export class OnRampSwapEngineEVM extends BaseNablaSwapEngineEvm { +export class OnRampSwapEngineEvm extends BaseNablaSwapEngineEvm { readonly config = { direction: RampDirection.BUY, - skipNote: "OnRampSwapEngineEVM: Skipped because rampType is SELL, this engine handles BUY operations only" + skipNote: "OnRampSwapEngineEvm: Skipped because rampType is SELL, this engine handles BUY operations only" } as const; protected validate(ctx: QuoteContext): void { if (!ctx.fees?.usd) { - throw new Error("OnRampSwapEngineEVM: Fees in USD must be calculated first - ensure fee stage ran successfully"); + throw new Error("OnRampSwapEngineEvm: Fees in USD must be calculated first - ensure fee stage ran successfully"); } } @@ -19,7 +19,7 @@ export class OnRampSwapEngineEVM extends BaseNablaSwapEngineEvm { if (!ctx.aveniaTransfer) { throw new Error( - "OnRampSwapEngineEVM: Missing aveniaTransfer quote data from previous stage - ensure initialize stage ran successfully" + "OnRampSwapEngineEvm: Missing aveniaTransfer quote data from previous stage - ensure initialize stage ran successfully" ); } diff --git a/apps/api/src/api/services/quote/engines/squidrouter/index.ts b/apps/api/src/api/services/quote/engines/squidrouter/index.ts index 1fa398fff..542bb2215 100644 --- a/apps/api/src/api/services/quote/engines/squidrouter/index.ts +++ b/apps/api/src/api/services/quote/engines/squidrouter/index.ts @@ -60,6 +60,16 @@ export abstract class BaseSquidRouterEngine implements Stage { protected abstract compute(ctx: QuoteContext): SquidRouterComputation; + protected mergeSubsidy(ctx: QuoteContext, outputAmountDecimal: Big): Big { + // biome-ignore lint/style/noNonNullAssertion: Context is validated in validate + return outputAmountDecimal.plus(ctx.subsidy!.subsidyAmountInOutputTokenDecimal); + } + + protected mergeSubsidyRaw(ctx: QuoteContext, outputAmountRaw: Big): Big { + // biome-ignore lint/style/noNonNullAssertion: Context is validated in validate + return outputAmountRaw.plus(ctx.subsidy!.subsidyAmountInOutputTokenRaw); + } + private buildBridgeRequest(data: SquidRouterData, req: CreateQuoteRequest): EvmBridgeRequest { return { amountRaw: data.amountRaw, 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 b0494edab..a24951444 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 @@ -3,10 +3,12 @@ import { ERC20_USDC_POLYGON_DECIMALS, EvmToken, getNetworkFromDestination, + multiplyByPowerOfTen, Networks, OnChainToken, RampDirection } from "@vortexfi/shared"; +import Big from "big.js"; import httpStatus from "http-status"; import { APIError } from "../../../../errors/api-error"; import { getTokenDetailsForEvmDestination } from "../../core/squidrouter"; @@ -26,11 +28,15 @@ export class OnRampSquidRouterBrlToEvmEngineBase extends BaseSquidRouterEngine { ); } - if (!ctx.aveniaMint?.outputAmountDecimal) { + if (!ctx.nablaSwapEvm) { throw new Error( - "OnRampSquidRouterBrlToEvmEngine: Missing aveniaMint.outputAmountDecimal in context - ensure initialize stage ran successfully" + "OnRampSquidRouterBrlToEvmEngine: Missing nablaSwapEvm.outputAmountDecimal in context - ensure initialize stage ran successfully" ); } + + if (!ctx.fees?.usd || !ctx.fees?.displayFiat) { + throw new Error("OnRampPendulumTransferEngine: Missing fees in context - ensure fee calculation ran successfully"); + } } protected compute(ctx: QuoteContext): SquidRouterComputation { @@ -45,6 +51,23 @@ export class OnRampSquidRouterBrlToEvmEngineBase extends BaseSquidRouterEngine { } const req = ctx.request; + + // biome-ignore lint/style/noNonNullAssertion: Context is validated in validate + const usdFees = ctx.fees!.usd!; + + // biome-ignore lint/style/noNonNullAssertion: Context is validated in validate + const nablaSwap = ctx.nablaSwapEvm!; + + // Deduce fees distributed after Nabla swap and before transfer to next destination + // Onramps always have a USD-stablecoin as output, so we can use the USD fee structure + const usdFeesDistributedDecimal = Big(usdFees.network).plus(usdFees.vortex).plus(usdFees.partnerMarkup); + const usdFeesDistributedRaw = multiplyByPowerOfTen(usdFeesDistributedDecimal, nablaSwap.outputDecimals); + + const inputAmountDecimal = this.mergeSubsidy(ctx, new Big(nablaSwap.outputAmountDecimal)).minus(usdFeesDistributedDecimal); + const inputAmountRaw = this.mergeSubsidyRaw(ctx, new Big(nablaSwap.outputAmountRaw)) + .minus(usdFeesDistributedRaw) + .toFixed(0, 0); + const toNetwork = getNetworkFromDestination(req.to); if (!toNetwork) { throw new APIError({ @@ -55,17 +78,17 @@ export class OnRampSquidRouterBrlToEvmEngineBase extends BaseSquidRouterEngine { const toToken = getTokenDetailsForEvmDestination(req.outputCurrency as OnChainToken, req.to).erc20AddressSourceChain; // biome-ignore lint/style/noNonNullAssertion: Context is validated in validate - const aveniaMint = ctx.aveniaMint!; + const nablaSwapEvm = ctx.nablaSwapEvm!; const usdcBaseTokenDetails = getTokenDetailsForEvmDestination(EvmToken.USDC, Networks.Base); return { data: { - amountRaw: aveniaMint.outputAmountRaw, + amountRaw: nablaSwapEvm.outputAmountRaw, fromNetwork: Networks.Base, fromToken: usdcBaseTokenDetails.erc20AddressSourceChain, - inputAmountDecimal: aveniaMint.outputAmountDecimal, - inputAmountRaw: aveniaMint.outputAmountRaw, + inputAmountDecimal: inputAmountDecimal, + inputAmountRaw: inputAmountRaw, outputDecimals: usdcBaseTokenDetails.decimals, toNetwork, toToken diff --git a/apps/api/src/api/services/quote/routes/strategies/onramp-avenia-to-evm.strategy-base.ts b/apps/api/src/api/services/quote/routes/strategies/onramp-avenia-to-evm.strategy-base.ts index 99b1d2013..b9c37f03d 100644 --- a/apps/api/src/api/services/quote/routes/strategies/onramp-avenia-to-evm.strategy-base.ts +++ b/apps/api/src/api/services/quote/routes/strategies/onramp-avenia-to-evm.strategy-base.ts @@ -2,6 +2,7 @@ import { EnginesRegistry, IRouteStrategy, QuoteContext, StageKey } from "../../c import { OnRampDiscountEngine } from "../../engines/discount/onramp"; import { OnRampFinalizeEngine } from "../../engines/finalize/onramp"; import { OnRampInitializeAveniaEngine } from "../../engines/initialize/onramp-avenia"; +import { OnRampSwapEngineEvm } from "../../engines/nabla-swap/onramp-evm"; import { OnRampSquidRouterBrlToEvmEngineBase } from "../../engines/squidrouter/onramp-base-to-evm"; export class OnrampAveniaToEvmStrategy implements IRouteStrategy { @@ -14,8 +15,9 @@ export class OnrampAveniaToEvmStrategy implements IRouteStrategy { getEngines(_ctx: QuoteContext): EnginesRegistry { return { [StageKey.Initialize]: new OnRampInitializeAveniaEngine(), - [StageKey.NablaSwap]: new OnRampSquidRouterBrlToEvmEngineBase(), + [StageKey.NablaSwap]: new OnRampSwapEngineEvm(), [StageKey.Discount]: new OnRampDiscountEngine(), + [StageKey.SquidRouter]: new OnRampSquidRouterBrlToEvmEngineBase(), [StageKey.Finalize]: new OnRampFinalizeEngine() }; } From 0bebbfc8660510f57dcb0d262c61c04c964aaaa1 Mon Sep 17 00:00:00 2001 From: Gianfranco Date: Wed, 8 Apr 2026 10:58:19 -0300 Subject: [PATCH 05/12] create offramp transactions for brla base flow, first iteration. --- .../engines/squidrouter/onramp-base-to-evm.ts | 4 +- .../transactions/offramp/common/validation.ts | 10 +- .../offramp/routes/evm-to-brl-base.ts | 132 ++++++++++++ .../api/services/transactions/onramp/index.ts | 2 +- .../onramp/routes/avenia-to-evm-base.ts | 201 ++++++++++++++++++ .../onramp/routes/avenia-to-evm.ts | 170 +-------------- 6 files changed, 342 insertions(+), 177 deletions(-) create mode 100644 apps/api/src/api/services/transactions/offramp/routes/evm-to-brl-base.ts create mode 100644 apps/api/src/api/services/transactions/onramp/routes/avenia-to-evm-base.ts 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 a24951444..fb61dda41 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 @@ -77,14 +77,12 @@ export class OnRampSquidRouterBrlToEvmEngineBase extends BaseSquidRouterEngine { } const toToken = getTokenDetailsForEvmDestination(req.outputCurrency as OnChainToken, req.to).erc20AddressSourceChain; - // biome-ignore lint/style/noNonNullAssertion: Context is validated in validate - const nablaSwapEvm = ctx.nablaSwapEvm!; const usdcBaseTokenDetails = getTokenDetailsForEvmDestination(EvmToken.USDC, Networks.Base); return { data: { - amountRaw: nablaSwapEvm.outputAmountRaw, + amountRaw: inputAmountRaw, fromNetwork: Networks.Base, fromToken: usdcBaseTokenDetails.erc20AddressSourceChain, inputAmountDecimal: inputAmountDecimal, diff --git a/apps/api/src/api/services/transactions/offramp/common/validation.ts b/apps/api/src/api/services/transactions/offramp/common/validation.ts index 67a1b8af9..d660de916 100644 --- a/apps/api/src/api/services/transactions/offramp/common/validation.ts +++ b/apps/api/src/api/services/transactions/offramp/common/validation.ts @@ -86,13 +86,15 @@ export function validateBRLOfframp( throw new Error("brlaEvmAddress, pixDestination, receiverTaxId and taxId parameters must be provided for offramp to BRL"); } - if (!quote.metadata.pendulumToMoonbeamXcm?.outputAmountRaw) { - throw new Error("Quote metadata is missing pendulumToMoonbeamXcm information"); - } + // TODO add validation relevant to EVM flow, after quote context is known. + // if (!quote.metadata.pendulumToMoonbeamXcm?.outputAmountRaw) { + // throw new Error("Quote metadata is missing pendulumToMoonbeamXcm information"); + // } + // TODO still don't know which field will be return { brlaEvmAddress, - offrampAmountBeforeAnchorFeesRaw: quote.metadata.pendulumToMoonbeamXcm.outputAmountRaw, + offrampAmountBeforeAnchorFeesRaw: "200", //quote.metadata.pendulumToMoonbeamXcm.outputAmountRaw, pixDestination, receiverTaxId, taxId 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 new file mode 100644 index 000000000..1eb55e4b4 --- /dev/null +++ b/apps/api/src/api/services/transactions/offramp/routes/evm-to-brl-base.ts @@ -0,0 +1,132 @@ +import { + createOfframpSquidrouterTransactionsToEvm, + EvmToken, + EvmTransactionData, + evmTokenConfig, + isEvmTokenDetails, + multiplyByPowerOfTen, + Networks, + UnsignedTx +} from "@vortexfi/shared"; +import Big from "big.js"; +import { StateMetadata } from "../../../phases/meta-state-types"; +import { encodeEvmTransactionData } from "../.."; +import { addEvmFeeDistributionTransaction, addFeeDistributionTransaction } from "../../common/feeDistribution"; +import { addNablaSwapTransactionsOnBase } from "../../onramp/common/transactions"; +import { OfframpTransactionParams, OfframpTransactionsWithMeta } from "../common/types"; +import { validateBRLOfframp, validateOfframpQuote } from "../common/validation"; + +/** + * Prepares all transactions for an EVM to BRL offramp. + * This route handles: EVM → Base (swap) → Avenia Offramp. + */ +export async function prepareEvmToBRLOfframpTransactions({ + quote, + signingAccounts, + userAddress, + pixDestination, + taxId, + receiverTaxId, + brlaEvmAddress +}: OfframpTransactionParams): Promise { + const unsignedTxs: UnsignedTx[] = []; + let stateMeta: Partial = {}; + + // Validate inputs and extract required data + const { fromNetwork, inputTokenDetails, outputTokenDetails } = validateOfframpQuote(quote, signingAccounts); + + const evmEphemeralEntry = signingAccounts.find(account => account.type === "EVM"); + if (!evmEphemeralEntry) { + throw new Error("EVM account not found. An EVM ephemeral account is required for EVM to BRL offramp."); + } + + const { + brlaEvmAddress: validatedBrlaEvmAddress, + pixDestination: validatedPixDestination, + taxId: validatedTaxId, + receiverTaxId: validatedReceiverTaxId, + offrampAmountBeforeAnchorFeesRaw + } = validateBRLOfframp(quote, { brlaEvmAddress, pixDestination, receiverTaxId, taxId }); + + const inputAmountRaw = multiplyByPowerOfTen(new Big(quote.inputAmount), inputTokenDetails.decimals).toFixed(0, 0); + + if (!userAddress) { + throw new Error("User address must be provided for offramping."); + } + + if (!isEvmTokenDetails(inputTokenDetails)) { + throw new Error("EVM to BRL route requires EVM input token"); + } + + const baseUsdcAddress = evmTokenConfig[Networks.Base][EvmToken.USDC]?.erc20AddressSourceChain; + if (!baseUsdcAddress) { + throw new Error("Invalid USDC configuration for Base in evmTokenConfig"); + } + + const baseBrlaAddress = evmTokenConfig[Networks.Base][EvmToken.BRLA]?.erc20AddressSourceChain; + if (!baseBrlaAddress) { + throw new Error("Invalid BRLA configuration for Base in evmTokenConfig"); + } + + // Special case: if user is already on Base with USDC, skip squidrouter transactions + if (!(fromNetwork === Networks.Base && inputTokenDetails.erc20AddressSourceChain === baseUsdcAddress)) { + // TODO Maybe, move to contract-base squid swap. + // Otherwise use the same approach as previously + const { approveData, swapData } = await createOfframpSquidrouterTransactionsToEvm({ + destinationAddress: evmEphemeralEntry.address, + fromAddress: userAddress, + fromNetwork, + fromToken: inputTokenDetails.erc20AddressSourceChain, + rawAmount: inputAmountRaw, + toNetwork: Networks.Base, + toToken: baseUsdcAddress + }); + + unsignedTxs.push({ + meta: {}, + network: fromNetwork, + nonce: 0, + phase: "squidRouterApprove", + signer: userAddress, + txData: encodeEvmTransactionData(approveData) as EvmTransactionData + }); + + unsignedTxs.push({ + meta: {}, + network: fromNetwork, + nonce: 1, + phase: "squidRouterSwap", + signer: userAddress, + txData: encodeEvmTransactionData(swapData) as EvmTransactionData + }); + } + + let baseNonce = 0; + + // Add Base Nabla swap transactions (USDC to BRLA on Base) + const { nextNonce: nonceAfterNabla, stateMeta: nablaStateMeta } = await addNablaSwapTransactionsOnBase( + { + account: evmEphemeralEntry, + inputTokenAddress: baseUsdcAddress, // Swap from USDC to BRLA on Base + outputTokenAddress: baseBrlaAddress, // BRLA address on Base + quote + }, + unsignedTxs, + baseNonce + ); + stateMeta = { ...stateMeta, ...nablaStateMeta }; + baseNonce = nonceAfterNabla; + + // Fee distribution transaction on EVM + baseNonce = await addEvmFeeDistributionTransaction(quote, evmEphemeralEntry, unsignedTxs, baseNonce); + + stateMeta = { + ...stateMeta, + brlaEvmAddress: validatedBrlaEvmAddress, + pixDestination: validatedPixDestination, + receiverTaxId: validatedReceiverTaxId, + taxId: validatedTaxId + }; + + return { stateMeta, unsignedTxs }; +} diff --git a/apps/api/src/api/services/transactions/onramp/index.ts b/apps/api/src/api/services/transactions/onramp/index.ts index 47622a10d..505711d98 100644 --- a/apps/api/src/api/services/transactions/onramp/index.ts +++ b/apps/api/src/api/services/transactions/onramp/index.ts @@ -8,7 +8,7 @@ import { } from "./common/types"; import { prepareAlfredpayToEvmOnrampTransactions } from "./routes/alfredpay-to-evm"; import { prepareAveniaToAssethubOnrampTransactions } from "./routes/avenia-to-assethub"; -import { prepareAveniaToEvmOnrampTransactionsOnBase } from "./routes/avenia-to-evm"; +import { prepareAveniaToEvmOnrampTransactionsOnBase } from "./routes/avenia-to-evm-base"; import { prepareMoneriumToAssethubOnrampTransactions } from "./routes/monerium-to-assethub"; import { prepareMoneriumToEvmOnrampTransactions } from "./routes/monerium-to-evm"; 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 new file mode 100644 index 000000000..16171e211 --- /dev/null +++ b/apps/api/src/api/services/transactions/onramp/routes/avenia-to-evm-base.ts @@ -0,0 +1,201 @@ +import { + AXL_USDC_MOONBEAM_DETAILS, + createOnrampSquidrouterTransactionsOnDestinationChain, + EvmNetworks, + EvmToken, + EvmTokenDetails, + EvmTransactionData, + encodeSubmittableExtrinsic, + evmTokenConfig, + getNetworkId, + getOnChainTokenDetailsOrDefault, + isEvmTokenDetails, + isNativeEvmToken, + multiplyByPowerOfTen, + Networks, + UnsignedTx +} from "@vortexfi/shared"; +import { privateKeyToAccount } from "viem/accounts"; +import { MOONBEAM_FUNDING_PRIVATE_KEY } from "../../../../../constants/constants"; +import { StateMetadata } from "../../../phases/meta-state-types"; +import { addEvmFeeDistributionTransaction } from "../../common/feeDistribution"; +import { encodeEvmTransactionData } from "../../index"; +import { + addDestinationChainApprovalTransaction, + addNablaSwapTransactionsOnBase, + addOnrampDestinationChainTransactions +} from "../common/transactions"; +import { AveniaOnrampTransactionParams, OnrampTransactionsWithMeta } from "../common/types"; +import { validateAveniaOnrampOnBase } from "../common/validation"; + +/** + * Prepares all transactions for an Avenia (BRL) onramp to EVM chain via Base. + * This route handles: BRL → Base (BRLA) -> Swap (to USDC) → EVM (final transfer) + */ +export async function prepareAveniaToEvmOnrampTransactionsOnBase({ + quote, + signingAccounts, + destinationAddress, + taxId +}: AveniaOnrampTransactionParams): Promise { + let stateMeta: Partial = {}; + const unsignedTxs: UnsignedTx[] = []; + + // Validate inputs and extract required data + const { toNetwork, outputTokenDetails, evmEphemeralEntry, inputTokenDetails } = validateAveniaOnrampOnBase( + quote, + signingAccounts + ); + + // Setup state metadata + stateMeta = { + destinationAddress, + evmEphemeralAddress: evmEphemeralEntry.address, + taxId + }; + + let baseNonce = 0; + + if (!quote.metadata.aveniaTransfer?.outputAmountRaw) { + throw new Error("Missing aveniaTransfer amountOutRaw in quote metadata"); + } + const inputAmountPostAnchorFeeRaw = quote.metadata.aveniaTransfer.outputAmountRaw; + + if (!isEvmTokenDetails(outputTokenDetails)) { + throw new Error(`Output token must be an EVM token for onramp to any EVM chain, got ${outputTokenDetails.assetSymbol}`); + } + + const destinationAxlUsdcDetails = getOnChainTokenDetailsOrDefault(toNetwork as Networks, EvmToken.AXLUSDC) as EvmTokenDetails; + + // Output for BRLA onramp will always go through USDC. + // TODO. Unless the actual BRLA token wants to be onramped. + const nablaSwapOutputTokenAddress = evmTokenConfig[Networks.Base][EvmToken.USDC]?.erc20AddressSourceChain; + if (!nablaSwapOutputTokenAddress) { + throw new Error("Invalid USDC configuration for Base in evmTokenConfig"); + } + + const { nextNonce: nonceAfterNabla, stateMeta: nablaStateMeta } = await addNablaSwapTransactionsOnBase( + { + account: evmEphemeralEntry, + inputTokenAddress: (inputTokenDetails as EvmTokenDetails).erc20AddressSourceChain, + outputTokenAddress: nablaSwapOutputTokenAddress, + quote + }, + unsignedTxs, + baseNonce + ); + stateMeta = { ...stateMeta, ...nablaStateMeta }; + baseNonce = nonceAfterNabla; + + baseNonce = await addEvmFeeDistributionTransaction(quote, evmEphemeralEntry, unsignedTxs, baseNonce); + + const finalAmountRaw = multiplyByPowerOfTen(quote.outputAmount, outputTokenDetails.decimals); + + // Special case, onramping USDC on Base. We need to skip the SquidRouter step and go directly to the destination transfer. + if (toNetwork === Networks.Base && outputTokenDetails.erc20AddressSourceChain === nablaSwapOutputTokenAddress) { + const finalDestinationTransfer = await addOnrampDestinationChainTransactions({ + amountRaw: finalAmountRaw.toString(), + destinationNetwork: Networks.Base, + isNativeToken: isNativeEvmToken(outputTokenDetails), + toAddress: destinationAddress, + toToken: outputTokenDetails.erc20AddressSourceChain + }); + + unsignedTxs.push({ + meta: {}, + network: Networks.Base, + nonce: baseNonce, + phase: "destinationTransfer", + signer: evmEphemeralEntry.address, + txData: finalDestinationTransfer + }); + + return { stateMeta, unsignedTxs }; + } + + // Fallback swap depends on the EVM chain. For Ethereum, the bridged token is USDC. For the rest, it is axlUSDC. + const bridgedTokenForFallback = + toNetwork === Networks.Ethereum + ? evmTokenConfig.ethereum.USDC!.erc20AddressSourceChain + : destinationAxlUsdcDetails.erc20AddressSourceChain; + + const inputAmountRawFinalBridge = quote.metadata.evmToEvm?.inputAmountRaw; + if (!inputAmountRawFinalBridge) { + throw new Error("Missing input amount for final bridge in quote metadata"); + } + + // Destination chain: Squidrouter swap to final token + const { approveData: finalApproveData, swapData: finalSwapData } = + await createOnrampSquidrouterTransactionsOnDestinationChain({ + destinationAddress: evmEphemeralEntry.address, + fromAddress: evmEphemeralEntry.address, + fromToken: outputTokenDetails.erc20AddressSourceChain, + network: toNetwork as EvmNetworks, + rawAmount: inputAmountRawFinalBridge, + toToken: outputTokenDetails.erc20AddressSourceChain + }); + + let destinationNonce = 0; + + const finalDestinationTransfer = await addOnrampDestinationChainTransactions({ + amountRaw: finalAmountRaw.toString(), + destinationNetwork: toNetwork as EvmNetworks, + isNativeToken: isNativeEvmToken(outputTokenDetails), + toAddress: destinationAddress, + toToken: outputTokenDetails.erc20AddressSourceChain + }); + + unsignedTxs.push({ + meta: {}, + network: toNetwork, + nonce: destinationNonce, + phase: "destinationTransfer", + signer: evmEphemeralEntry.address, + txData: finalDestinationTransfer + }); + + destinationNonce++; + + unsignedTxs.push({ + meta: {}, + network: toNetwork, + nonce: destinationNonce, + phase: "backupSquidRouterApprove", + signer: evmEphemeralEntry.address, + txData: encodeEvmTransactionData(finalApproveData) as EvmTransactionData + }); + destinationNonce++; + + unsignedTxs.push({ + meta: {}, + network: toNetwork, + nonce: destinationNonce, + phase: "backupSquidRouterSwap", + signer: evmEphemeralEntry.address, + txData: encodeEvmTransactionData(finalSwapData) as EvmTransactionData + }); + destinationNonce++; + + const maxUint256 = 2n ** 256n - 1n; + const fundingAccount = privateKeyToAccount(MOONBEAM_FUNDING_PRIVATE_KEY as `0x${string}`); + + const backupApproveTransaction = await addDestinationChainApprovalTransaction({ + amountRaw: maxUint256.toString(), + destinationNetwork: toNetwork as EvmNetworks, + spenderAddress: fundingAccount.address, + tokenAddress: bridgedTokenForFallback + }); + + // We set this to 0 on purpose because we don't want to risk that the required nonce is never reached + const backupApproveNonce = 0; + unsignedTxs.push({ + meta: {}, + network: toNetwork, + nonce: backupApproveNonce, + phase: "backupApprove", + signer: evmEphemeralEntry.address, + txData: backupApproveTransaction + }); + + return { stateMeta, unsignedTxs }; +} diff --git a/apps/api/src/api/services/transactions/onramp/routes/avenia-to-evm.ts b/apps/api/src/api/services/transactions/onramp/routes/avenia-to-evm.ts index 45142aa47..bb03432c7 100644 --- a/apps/api/src/api/services/transactions/onramp/routes/avenia-to-evm.ts +++ b/apps/api/src/api/services/transactions/onramp/routes/avenia-to-evm.ts @@ -27,12 +27,11 @@ import { addDestinationChainApprovalTransaction, addMoonbeamTransactions, addNablaSwapTransactions, - addNablaSwapTransactionsOnBase, addOnrampDestinationChainTransactions, addPendulumCleanupTx } from "../common/transactions"; import { AveniaOnrampTransactionParams, OnrampTransactionsWithMeta } from "../common/types"; -import { validateAveniaOnramp, validateAveniaOnrampOnBase } from "../common/validation"; +import { validateAveniaOnramp } from "../common/validation"; /** * Prepares all transactions for an Avenia (BRL) onramp to EVM chain. @@ -257,170 +256,3 @@ export async function prepareAveniaToEvmOnrampTransactions({ return { stateMeta, unsignedTxs }; } - -/** - * Prepares all transactions for an Avenia (BRL) onramp to EVM chain via Base. - * This route handles: BRL → Base (BRLA) -> Swap (to USDC) → EVM (final transfer) - */ -export async function prepareAveniaToEvmOnrampTransactionsOnBase({ - quote, - signingAccounts, - destinationAddress, - taxId -}: AveniaOnrampTransactionParams): Promise { - let stateMeta: Partial = {}; - const unsignedTxs: UnsignedTx[] = []; - - // Validate inputs and extract required data - const { toNetwork, outputTokenDetails, evmEphemeralEntry, inputTokenDetails } = validateAveniaOnrampOnBase( - quote, - signingAccounts - ); - - // Setup state metadata - stateMeta = { - destinationAddress, - evmEphemeralAddress: evmEphemeralEntry.address, - taxId - }; - - let baseNonce = 0; - - if (!quote.metadata.aveniaTransfer?.outputAmountRaw) { - throw new Error("Missing aveniaTransfer amountOutRaw in quote metadata"); - } - const inputAmountPostAnchorFeeRaw = quote.metadata.aveniaTransfer.outputAmountRaw; - - if (!isEvmTokenDetails(outputTokenDetails)) { - throw new Error(`Output token must be an EVM token for onramp to any EVM chain, got ${outputTokenDetails.assetSymbol}`); - } - - const destinationAxlUsdcDetails = getOnChainTokenDetailsOrDefault(toNetwork as Networks, EvmToken.AXLUSDC) as EvmTokenDetails; - - // Output for BRLA onramp will always go through USDC. - // TODO. Unless the actual BRLA token ) - const nablaSwapOutputTokenAddress = evmTokenConfig[Networks.Base][EvmToken.USDC]?.erc20AddressSourceChain; - if (!nablaSwapOutputTokenAddress) { - throw new Error("Invalid USDC configuration for Base in evmTokenConfig"); - } - - const { nextNonce: nonceAfterNabla, stateMeta: nablaStateMeta } = await addNablaSwapTransactionsOnBase( - { - account: evmEphemeralEntry, - inputTokenAddress: (inputTokenDetails as EvmTokenDetails).erc20AddressSourceChain, - outputTokenAddress: nablaSwapOutputTokenAddress, - quote - }, - unsignedTxs, - baseNonce - ); - stateMeta = { ...stateMeta, ...nablaStateMeta }; - baseNonce = nonceAfterNabla; - - baseNonce = await addEvmFeeDistributionTransaction(quote, evmEphemeralEntry, unsignedTxs, baseNonce); - - const finalAmountRaw = multiplyByPowerOfTen(quote.outputAmount, outputTokenDetails.decimals); - - // Special case, onramping USDC on Base. We need to skip the SquidRouter step and go directly to the destination transfer. - if (toNetwork === Networks.Base && outputTokenDetails.erc20AddressSourceChain === nablaSwapOutputTokenAddress) { - const finalDestinationTransfer = await addOnrampDestinationChainTransactions({ - amountRaw: finalAmountRaw.toString(), - destinationNetwork: Networks.Base, - isNativeToken: isNativeEvmToken(outputTokenDetails), - toAddress: destinationAddress, - toToken: outputTokenDetails.erc20AddressSourceChain - }); - - unsignedTxs.push({ - meta: {}, - network: Networks.Base, - nonce: baseNonce, - phase: "destinationTransfer", - signer: evmEphemeralEntry.address, - txData: finalDestinationTransfer - }); - - return { stateMeta, unsignedTxs }; - } - - // Fallback swap depends on the EVM chain. For Ethereum, the bridged token is USDC. For the rest, it is axlUSDC. - const bridgedTokenForFallback = - toNetwork === Networks.Ethereum - ? evmTokenConfig.ethereum.USDC!.erc20AddressSourceChain - : destinationAxlUsdcDetails.erc20AddressSourceChain; - - // Destination chain: Squidrouter swap to final token - const { approveData: finalApproveData, swapData: finalSwapData } = - await createOnrampSquidrouterTransactionsOnDestinationChain({ - destinationAddress: evmEphemeralEntry.address, - fromAddress: evmEphemeralEntry.address, - fromToken: bridgedTokenForFallback, - network: toNetwork as EvmNetworks, - rawAmount: inputAmountPostAnchorFeeRaw, - toToken: outputTokenDetails.erc20AddressSourceChain - }); - - let destinationNonce = 0; - - const finalDestinationTransfer = await addOnrampDestinationChainTransactions({ - amountRaw: finalAmountRaw.toString(), - destinationNetwork: toNetwork as EvmNetworks, - isNativeToken: isNativeEvmToken(outputTokenDetails), - toAddress: destinationAddress, - toToken: outputTokenDetails.erc20AddressSourceChain - }); - - unsignedTxs.push({ - meta: {}, - network: toNetwork, - nonce: destinationNonce, - phase: "destinationTransfer", - signer: evmEphemeralEntry.address, - txData: finalDestinationTransfer - }); - - destinationNonce++; - - unsignedTxs.push({ - meta: {}, - network: toNetwork, - nonce: destinationNonce, - phase: "backupSquidRouterApprove", - signer: evmEphemeralEntry.address, - txData: encodeEvmTransactionData(finalApproveData) as EvmTransactionData - }); - destinationNonce++; - - unsignedTxs.push({ - meta: {}, - network: toNetwork, - nonce: destinationNonce, - phase: "backupSquidRouterSwap", - signer: evmEphemeralEntry.address, - txData: encodeEvmTransactionData(finalSwapData) as EvmTransactionData - }); - destinationNonce++; - - const maxUint256 = 2n ** 256n - 1n; - const fundingAccount = privateKeyToAccount(MOONBEAM_FUNDING_PRIVATE_KEY as `0x${string}`); - - const backupApproveTransaction = await addDestinationChainApprovalTransaction({ - amountRaw: maxUint256.toString(), - destinationNetwork: toNetwork as EvmNetworks, - spenderAddress: fundingAccount.address, - tokenAddress: bridgedTokenForFallback - }); - - // We set this to 0 on purpose because we don't want to risk that the required nonce is never reached - const backupApproveNonce = 0; - unsignedTxs.push({ - meta: {}, - network: toNetwork, - nonce: backupApproveNonce, - phase: "backupApprove", - signer: evmEphemeralEntry.address, - txData: backupApproveTransaction - }); - - return { stateMeta, unsignedTxs }; -} From fac170f44e88791713fdabe4811d1d9cadb75b7d Mon Sep 17 00:00:00 2001 From: Gianfranco Date: Wed, 8 Apr 2026 15:44:55 -0300 Subject: [PATCH 06/12] implement quote logic for brla on base. --- .../initialize/offramp-from-evm-alfredpay.ts | 16 ++++--- .../engines/initialize/offramp-from-evm.ts | 2 +- .../quote/engines/nabla-swap/offramp-evm.ts | 44 +++++++++++++++++++ .../offramp-evm-to-alfredpay.strategy.ts | 33 +++++++++----- .../offramp-to-pix-base.strategy.ts | 25 +++++++++++ .../strategies/offramp-to-pix.strategy.ts | 6 ++- .../strategies/offramp-to-stellar.strategy.ts | 6 ++- 7 files changed, 111 insertions(+), 21 deletions(-) create mode 100644 apps/api/src/api/services/quote/engines/nabla-swap/offramp-evm.ts create mode 100644 apps/api/src/api/services/quote/routes/strategies/offramp-to-pix-base.strategy.ts diff --git a/apps/api/src/api/services/quote/engines/initialize/offramp-from-evm-alfredpay.ts b/apps/api/src/api/services/quote/engines/initialize/offramp-from-evm-alfredpay.ts index e9e3db70c..519efef75 100644 --- a/apps/api/src/api/services/quote/engines/initialize/offramp-from-evm-alfredpay.ts +++ b/apps/api/src/api/services/quote/engines/initialize/offramp-from-evm-alfredpay.ts @@ -4,11 +4,17 @@ import { EvmBridgeQuoteRequest, getEvmBridgeQuote } from "../../core/squidrouter import { QuoteContext } from "../../core/types"; import { assignPreNablaContext, BaseInitializeEngine } from "./index"; -export class AlfredpayOffRampFromEvmInitializeEngine extends BaseInitializeEngine { +export class OffRampFromEvmInitializeEngine extends BaseInitializeEngine { + private readonly network: Networks; + + constructor(network: Networks) { + super(); + this.network = network; + } + readonly config = { direction: RampDirection.SELL, - skipNote: - "AlfredpayOffRampFromEvmInitializeEngine: Skipped because rampType is BUY, this engine handles SELL operations only" + skipNote: "OffRampFromEvmInitializeEngine: Skipped because rampType is BUY, this engine handles SELL operations only" }; protected async executeInternal(ctx: QuoteContext): Promise { @@ -20,7 +26,7 @@ export class AlfredpayOffRampFromEvmInitializeEngine extends BaseInitializeEngin inputCurrency: req.inputCurrency as OnChainToken, outputCurrency: EvmToken.USDC, rampType: req.rampType, - toNetwork: Networks.Polygon + toNetwork: this.network }; const bridgeQuote = await getEvmBridgeQuote(quoteRequest); @@ -37,7 +43,7 @@ export class AlfredpayOffRampFromEvmInitializeEngine extends BaseInitializeEngin }; ctx.addNote?.( - `Initialized: input=${req.inputAmount} ${req.inputCurrency}, raw=${ctx.evmToPendulum?.inputAmountRaw}, output=${ctx.evmToPendulum?.outputAmountDecimal.toString()} ${ctx.evmToPendulum?.toToken}, raw=${ctx.evmToPendulum?.outputAmountRaw}` + `Initialized: input=${req.inputAmount} ${req.inputCurrency}, raw=${ctx.evmToEvm?.inputAmountRaw}, output=${ctx.evmToEvm?.outputAmountDecimal.toString()} ${ctx.evmToEvm?.toToken}, raw=${ctx.evmToEvm?.outputAmountRaw}` ); } } diff --git a/apps/api/src/api/services/quote/engines/initialize/offramp-from-evm.ts b/apps/api/src/api/services/quote/engines/initialize/offramp-from-evm.ts index 3fd253c04..54f254ed6 100644 --- a/apps/api/src/api/services/quote/engines/initialize/offramp-from-evm.ts +++ b/apps/api/src/api/services/quote/engines/initialize/offramp-from-evm.ts @@ -4,7 +4,7 @@ import { EvmBridgeQuoteRequest, getEvmBridgeQuote } from "../../core/squidrouter import { QuoteContext } from "../../core/types"; import { assignPreNablaContext, BaseInitializeEngine } from "./index"; -export class OffRampFromEvmInitializeEngine extends BaseInitializeEngine { +export class OffRampFromEvmInitializeEngineMoonbeam extends BaseInitializeEngine { readonly config = { direction: RampDirection.SELL, skipNote: "OffRampFromEvmInitializeEngine: Skipped because rampType is BUY, this engine handles SELL operations only" diff --git a/apps/api/src/api/services/quote/engines/nabla-swap/offramp-evm.ts b/apps/api/src/api/services/quote/engines/nabla-swap/offramp-evm.ts new file mode 100644 index 000000000..dce1f7dad --- /dev/null +++ b/apps/api/src/api/services/quote/engines/nabla-swap/offramp-evm.ts @@ -0,0 +1,44 @@ +import { EvmToken, FiatToken, getPendulumDetails, Networks, PENDULUM_USDC_AXL, RampDirection } from "@vortexfi/shared"; +import { QuoteContext } from "../../core/types"; +import { BaseNablaSwapEngineEvm, NablaSwapEvmComputation } from "./base-evm"; +import { NablaSwapComputation } from "./index"; + +export class OffRampSwapEngineEvm extends BaseNablaSwapEngineEvm { + readonly outputToken: EvmToken; + + constructor(outputToken: EvmToken) { + super(); + this.outputToken = outputToken; + } + + readonly config = { + direction: RampDirection.SELL, + skipNote: "OffRampSwapEngineEvm: Skipped because rampType is BUY, this engine handles SELL operations only" + } as const; + + protected validate(ctx: QuoteContext): void { + if (!ctx.preNabla?.deductibleFeeAmountInSwapCurrency) { + throw new Error( + "OffRampSwapEngineEvm: Missing deductibleFeeAmountInSwapCurrency in preNabla context - ensure initialize stage ran successfully" + ); + } + } + + protected compute(ctx: QuoteContext): NablaSwapEvmComputation { + const inputAmountPreFees = ctx.evmToEvm?.outputAmountDecimal; + if (!inputAmountPreFees) { + throw new Error( + "OffRampSwapEngineEvm: Missing input amount from previous stage - ensure initialize stage ran successfully" + ); + } + + // We receive USDC on Base. + const inputToken = EvmToken.USDC; + + return { + inputAmountPreFees, + inputToken, + outputToken: this.outputToken + }; + } +} diff --git a/apps/api/src/api/services/quote/routes/strategies/offramp-evm-to-alfredpay.strategy.ts b/apps/api/src/api/services/quote/routes/strategies/offramp-evm-to-alfredpay.strategy.ts index 503e339a8..1169ae8b1 100644 --- a/apps/api/src/api/services/quote/routes/strategies/offramp-evm-to-alfredpay.strategy.ts +++ b/apps/api/src/api/services/quote/routes/strategies/offramp-evm-to-alfredpay.strategy.ts @@ -1,22 +1,33 @@ +import { Networks } from "@vortexfi/shared"; import { EnginesRegistry, IRouteStrategy, QuoteContext, StageKey } from "../../core/types"; -import { OffRampEvmToAlfredpayFeeEngine } from "../../engines/fee/offramp-evm-to-alfredpay"; +import { OffRampDiscountEngine } from "../../engines/discount/offramp"; +import { OffRampFeeAveniaEngine } from "../../engines/fee/offramp-avenia"; import { OffRampFinalizeEngine } from "../../engines/finalize/offramp"; +import { OffRampFromEvmInitializeEngine } from "../../engines/initialize/offramp-from-evm-alfredpay"; +import { OffRampSwapEngine } from "../../engines/nabla-swap/offramp"; +import { OffRampToAveniaPendulumTransferEngine } from "../../engines/pendulum-transfers/offramp-avenia"; -import { AlfredpayOffRampFromEvmInitializeEngine } from "../../engines/initialize/offramp-from-evm-alfredpay"; -import { OfframpTransactionAlfredpayEngine } from "../../engines/partners/offramp-alfredpay"; - -export class OfframpEvmToAlfredpayStrategy implements IRouteStrategy { - readonly name = "OfframpEvmToAlfredpay"; +export class OfframpToPixStrategy implements IRouteStrategy { + readonly name = "OffRampPix"; getStages(_ctx: QuoteContext): StageKey[] { - return [StageKey.Initialize, StageKey.Fee, StageKey.PartnerOperation, StageKey.Finalize]; + return [ + StageKey.Initialize, + StageKey.NablaSwap, + StageKey.Fee, + StageKey.Discount, + StageKey.PendulumTransfer, + StageKey.Finalize + ]; } - getEngines(_ctx: QuoteContext): EnginesRegistry { + getEngines(ctx: QuoteContext): EnginesRegistry { return { - [StageKey.Initialize]: new AlfredpayOffRampFromEvmInitializeEngine(), - [StageKey.Fee]: new OffRampEvmToAlfredpayFeeEngine(), - [StageKey.PartnerOperation]: new OfframpTransactionAlfredpayEngine(), + [StageKey.Initialize]: new OffRampFromEvmInitializeEngine(Networks.Base), + [StageKey.Fee]: new OffRampFeeAveniaEngine(), + [StageKey.NablaSwap]: new OffRampSwapEngine(), + [StageKey.Discount]: new OffRampDiscountEngine(), + [StageKey.PendulumTransfer]: new OffRampToAveniaPendulumTransferEngine(), [StageKey.Finalize]: new OffRampFinalizeEngine() }; } diff --git a/apps/api/src/api/services/quote/routes/strategies/offramp-to-pix-base.strategy.ts b/apps/api/src/api/services/quote/routes/strategies/offramp-to-pix-base.strategy.ts new file mode 100644 index 000000000..00340507b --- /dev/null +++ b/apps/api/src/api/services/quote/routes/strategies/offramp-to-pix-base.strategy.ts @@ -0,0 +1,25 @@ +import { EvmToken, Networks } from "@vortexfi/shared"; +import { EnginesRegistry, IRouteStrategy, QuoteContext, StageKey } from "../../core/types"; +import { OffRampDiscountEngine } from "../../engines/discount/offramp"; +import { OffRampFeeAveniaEngine } from "../../engines/fee/offramp-avenia"; +import { OffRampFinalizeEngine } from "../../engines/finalize/offramp"; +import { OffRampFromEvmInitializeEngine } from "../../engines/initialize/offramp-from-evm-alfredpay"; +import { OffRampSwapEngineEvm } from "../../engines/nabla-swap/offramp-evm"; + +export class OfframpEvmToAlfredpayStrategy implements IRouteStrategy { + readonly name = "OfframpEvmToAlfredpay"; + + getStages(_ctx: QuoteContext): StageKey[] { + return [StageKey.Initialize, StageKey.Fee, StageKey.PartnerOperation, StageKey.Finalize]; + } + + getEngines(_ctx: QuoteContext): EnginesRegistry { + return { + [StageKey.Initialize]: new OffRampFromEvmInitializeEngine(Networks.Polygon), + [StageKey.Fee]: new OffRampFeeAveniaEngine(), + [StageKey.NablaSwap]: new OffRampSwapEngineEvm(EvmToken.BRLA), + [StageKey.Discount]: new OffRampDiscountEngine(), + [StageKey.Finalize]: new OffRampFinalizeEngine() + }; + } +} diff --git a/apps/api/src/api/services/quote/routes/strategies/offramp-to-pix.strategy.ts b/apps/api/src/api/services/quote/routes/strategies/offramp-to-pix.strategy.ts index 181e66f29..f8067112c 100644 --- a/apps/api/src/api/services/quote/routes/strategies/offramp-to-pix.strategy.ts +++ b/apps/api/src/api/services/quote/routes/strategies/offramp-to-pix.strategy.ts @@ -3,7 +3,7 @@ import { OffRampDiscountEngine } from "../../engines/discount/offramp"; import { OffRampFeeAveniaEngine } from "../../engines/fee/offramp-avenia"; import { OffRampFinalizeEngine } from "../../engines/finalize/offramp"; import { OffRampFromAssethubInitializeEngine } from "../../engines/initialize/offramp-from-assethub"; -import { OffRampFromEvmInitializeEngine } from "../../engines/initialize/offramp-from-evm"; +import { OffRampFromEvmInitializeEngineMoonbeam } from "../../engines/initialize/offramp-from-evm"; import { OffRampSwapEngine } from "../../engines/nabla-swap/offramp"; import { OffRampToAveniaPendulumTransferEngine } from "../../engines/pendulum-transfers/offramp-avenia"; @@ -24,7 +24,9 @@ export class OfframpToPixStrategy implements IRouteStrategy { getEngines(ctx: QuoteContext): EnginesRegistry { return { [StageKey.Initialize]: - ctx.request.from === "assethub" ? new OffRampFromAssethubInitializeEngine() : new OffRampFromEvmInitializeEngine(), + ctx.request.from === "assethub" + ? new OffRampFromAssethubInitializeEngine() + : new OffRampFromEvmInitializeEngineMoonbeam(), [StageKey.Fee]: new OffRampFeeAveniaEngine(), [StageKey.NablaSwap]: new OffRampSwapEngine(), [StageKey.Discount]: new OffRampDiscountEngine(), diff --git a/apps/api/src/api/services/quote/routes/strategies/offramp-to-stellar.strategy.ts b/apps/api/src/api/services/quote/routes/strategies/offramp-to-stellar.strategy.ts index 6a2f3fb64..823e73a57 100644 --- a/apps/api/src/api/services/quote/routes/strategies/offramp-to-stellar.strategy.ts +++ b/apps/api/src/api/services/quote/routes/strategies/offramp-to-stellar.strategy.ts @@ -3,7 +3,7 @@ import { OffRampDiscountEngine } from "../../engines/discount/offramp"; import { OffRampFeeStellarEngine } from "../../engines/fee/offramp-stellar"; import { OffRampFinalizeEngine } from "../../engines/finalize/offramp"; import { OffRampFromAssethubInitializeEngine } from "../../engines/initialize/offramp-from-assethub"; -import { OffRampFromEvmInitializeEngine } from "../../engines/initialize/offramp-from-evm"; +import { OffRampFromEvmInitializeEngineMoonbeam } from "../../engines/initialize/offramp-from-evm"; import { OffRampSwapEngine } from "../../engines/nabla-swap/offramp"; import { OffRampToStellarPendulumTransferEngine } from "../../engines/pendulum-transfers/offramp-stellar"; @@ -24,7 +24,9 @@ export class OfframpToStellarStrategy implements IRouteStrategy { getEngines(ctx: QuoteContext): EnginesRegistry { return { [StageKey.Initialize]: - ctx.request.from === "assethub" ? new OffRampFromAssethubInitializeEngine() : new OffRampFromEvmInitializeEngine(), + ctx.request.from === "assethub" + ? new OffRampFromAssethubInitializeEngine() + : new OffRampFromEvmInitializeEngineMoonbeam(), [StageKey.NablaSwap]: new OffRampSwapEngine(), [StageKey.Fee]: new OffRampFeeStellarEngine(), [StageKey.Discount]: new OffRampDiscountEngine(), From d66365aac54f732caedfdefbfc447be94f1dd21e Mon Sep 17 00:00:00 2001 From: Gianfranco Date: Wed, 8 Apr 2026 16:31:49 -0300 Subject: [PATCH 07/12] route new brla base offramp flow --- ...oonbeam-handler.ts => brla-payout-base-handler.ts} | 6 +++--- .../phases/handlers/distribute-fees-handler.ts | 2 +- .../phases/handlers/fund-ephemeral-handler.ts | 11 +++++------ .../services/phases/handlers/nabla-swap-handler.ts | 8 +++++--- .../handlers/subsidize-post-swap-evm-handler.ts | 8 ++------ apps/api/src/api/services/phases/register-handlers.ts | 6 ++++-- packages/shared/src/endpoints/ramp.endpoints.ts | 3 ++- 7 files changed, 22 insertions(+), 22 deletions(-) rename apps/api/src/api/services/phases/handlers/{brla-payout-moonbeam-handler.ts => brla-payout-base-handler.ts} (97%) diff --git a/apps/api/src/api/services/phases/handlers/brla-payout-moonbeam-handler.ts b/apps/api/src/api/services/phases/handlers/brla-payout-base-handler.ts similarity index 97% rename from apps/api/src/api/services/phases/handlers/brla-payout-moonbeam-handler.ts rename to apps/api/src/api/services/phases/handlers/brla-payout-base-handler.ts index effedf8ad..807657d32 100644 --- a/apps/api/src/api/services/phases/handlers/brla-payout-moonbeam-handler.ts +++ b/apps/api/src/api/services/phases/handlers/brla-payout-base-handler.ts @@ -8,9 +8,9 @@ import { PhaseError } from "../../../errors/phase-error"; import { BasePhaseHandler } from "../base-phase-handler"; import { StateMetadata } from "../meta-state-types"; -export class BrlaPayoutOnMoonbeamPhaseHandler extends BasePhaseHandler { +export class BrlaPayoutOnBasePhaseHandler extends BasePhaseHandler { public getPhaseName(): RampPhase { - return "brlaPayoutOnMoonbeam"; + return "brlaPayoutOnBase"; } protected async executePhase(state: RampState): Promise { @@ -179,4 +179,4 @@ export class BrlaPayoutOnMoonbeamPhaseHandler extends BasePhaseHandler { } } -export default new BrlaPayoutOnMoonbeamPhaseHandler(); +export default new BrlaPayoutOnBasePhaseHandler(); 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 e1f530cbb..abec2cdfb 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 @@ -67,7 +67,7 @@ export class DistributeFeesHandler extends BasePhaseHandler { const existingHash = state.state.distributeFeeHash || null; // For BRL onramp flows, distributio happens on EVM (Base). - const isEvmTransaction = quote.inputCurrency === "BRL"; + const isEvmTransaction = quote.inputCurrency === "BRL" || quote.outputCurrency === "BRL"; if (existingHash) { logger.info(`Found existing distribute fee hash for ramp ${state.id}: ${existingHash}`); 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 3c4d3153c..5089e1d28 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 @@ -11,7 +11,6 @@ import { } from "@vortexfi/shared"; import { NetworkError, Transaction } from "stellar-sdk"; import { privateKeyToAccount } from "viem/accounts"; -import { polygon } from "viem/chains"; import logger from "../../../../config/logger"; import { MOONBEAM_FUNDING_PRIVATE_KEY, POLYGON_EPHEMERAL_STARTING_BALANCE_UNITS } from "../../../../constants/constants"; @@ -20,7 +19,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 { fundMoonbeamEphemeralAccount } from "../../transactions/moonbeam/balance"; import { BasePhaseHandler } from "../base-phase-handler"; import { validateStellarPaymentSequenceNumber } from "../helpers/stellar-sequence-validator"; import { StateMetadata } from "../meta-state-types"; @@ -93,9 +91,9 @@ export class FundEphemeralPhaseHandler extends BasePhaseHandler { return false; } - protected getRequiresBaseEphemeralAddress(state: RampState, inputCurrency?: string): boolean { + protected getRequiresBaseEphemeralAddress(inputCurrency?: string, outputCurrency?: string): boolean { // Only required for BRLA onramps. - if (isOnramp(state) && inputCurrency === FiatToken.BRL) { + if (inputCurrency === FiatToken.BRL || outputCurrency === FiatToken.BRL) { return true; } return false; @@ -120,7 +118,6 @@ export class FundEphemeralPhaseHandler extends BasePhaseHandler { const apiManager = ApiManager.getInstance(); const pendulumNode = await apiManager.getApi("pendulum"); - const moonbeamNode = await apiManager.getApi("moonbeam"); const { evmEphemeralAddress, substrateEphemeralAddress } = state.state as StateMetadata; const requiresPendulumEphemeralAddress = this.getRequiresPendulumEphemeralAddress( @@ -133,7 +130,7 @@ export class FundEphemeralPhaseHandler extends BasePhaseHandler { quote.inputCurrency, quote.outputCurrency ); - const requiresBaseEphemeralAddress = this.getRequiresBaseEphemeralAddress(state, quote.inputCurrency); + const requiresBaseEphemeralAddress = this.getRequiresBaseEphemeralAddress(quote.inputCurrency, quote.outputCurrency); const requiresDestinationEvmFunding = this.getRequiresDestinationEvmFunding(state); // Ephemeral checks. @@ -239,6 +236,8 @@ export class FundEphemeralPhaseHandler extends BasePhaseHandler { return "distributeFees"; } else if (state.type === RampDirection.SELL && quote.outputCurrency === FiatToken.USD) { return "finalSettlementSubsidy"; + } else if (state.type === RampDirection.SELL && quote.outputCurrency === FiatToken.BRL) { + return "distributeFees"; } else { return "moonbeamToPendulum"; // Via contract.subsidizePreSwap } 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 1f22f38a0..2ba69a637 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 @@ -34,7 +34,7 @@ export class NablaSwapPhaseHandler extends BasePhaseHandler { const { substrateEphemeralAddress } = state.state as StateMetadata; if (quote.inputCurrency === FiatToken.BRL) { - return this.executeEvmSwap(state); + return this.executeEvmSwap(state, quote); } else if (substrateEphemeralAddress) { return this.executeSubstrateSwap(state, quote); } else { @@ -134,7 +134,7 @@ export class NablaSwapPhaseHandler extends BasePhaseHandler { return this.transitionToNextPhase(state, nextPhase); } - private async executeEvmSwap(state: RampState): Promise { + private async executeEvmSwap(state: RampState, quote: QuoteTicket): Promise { const evmClientManager = EvmClientManager.getInstance(); const baseClient = evmClientManager.getClient(Networks.Base); @@ -163,7 +163,9 @@ export class NablaSwapPhaseHandler extends BasePhaseHandler { throw e; } - const nextPhase = state.type === RampDirection.BUY ? "distributeFees" : "subsidizePostSwap"; + const isBrlInvolved = quote.inputCurrency === FiatToken.BRL || quote.outputCurrency === FiatToken.BRL; + const nextPhase = + state.type === RampDirection.BUY ? "distributeFees" : isBrlInvolved ? "subsidizePostSwapEvm" : "subsidizePostSwap"; return this.transitionToNextPhase(state, nextPhase); } } diff --git a/apps/api/src/api/services/phases/handlers/subsidize-post-swap-evm-handler.ts b/apps/api/src/api/services/phases/handlers/subsidize-post-swap-evm-handler.ts index a076d19a5..533540fd7 100644 --- a/apps/api/src/api/services/phases/handlers/subsidize-post-swap-evm-handler.ts +++ b/apps/api/src/api/services/phases/handlers/subsidize-post-swap-evm-handler.ts @@ -24,7 +24,7 @@ import { StateMetadata } from "../meta-state-types"; export class SubsidizePostSwapEvmPhaseHandler extends BasePhaseHandler { public getPhaseName(): RampPhase { - return "subsidizePostSwap"; + return "subsidizePostSwapEvm"; } protected async executePhase(state: RampState): Promise { @@ -142,14 +142,10 @@ export class SubsidizePostSwapEvmPhaseHandler extends BasePhaseHandler { } protected nextPhaseSelector(state: RampState, quote: QuoteTicket): RampPhase { - // For EVM, the next phases would be EVM-specific - // This needs to be adapted based on the specific EVM chain and flow - // For now, keeping similar logic but this should be reviewed if (state.type === RampDirection.BUY) { - // For BUY operations on EVM, typically go to final settlement or similar return "squidRouterSwap"; } else { - throw new Error("SubsidizePostSwapEvmPhaseHandler: SELL operations are not supported in this handler."); + return "squidRouterSwap"; } } } diff --git a/apps/api/src/api/services/phases/register-handlers.ts b/apps/api/src/api/services/phases/register-handlers.ts index 712f9890a..31f0afc21 100644 --- a/apps/api/src/api/services/phases/register-handlers.ts +++ b/apps/api/src/api/services/phases/register-handlers.ts @@ -2,7 +2,7 @@ import logger from "../../../config/logger"; import alfredpayOfframpTransferHandler from "./handlers/alfredpay-offramp-transfer-handler"; import alfredpayOnrampMintHandler from "./handlers/alfredpay-onramp-mint-handler"; import brlaOnrampMintHandler from "./handlers/brla-onramp-mint-handler"; -import brlaPayoutMoonbeamHandler from "./handlers/brla-payout-moonbeam-handler"; +import brlaPayoutBaseHandler from "./handlers/brla-payout-base-handler"; import destinationTransferHandler from "./handlers/destination-transfer-handler"; import distributeFeesHandler from "./handlers/distribute-fees-handler"; import finalSettlementSubsidy from "./handlers/final-settlement-subsidy"; @@ -24,6 +24,7 @@ import squidRouterPayPhaseHandler from "./handlers/squid-router-pay-phase-handle import squidRouterPhaseHandler from "./handlers/squid-router-phase-handler"; import squidRouterPermitExecutionHandler from "./handlers/squidrouter-permit-execution-handler"; import stellarPaymentHandler from "./handlers/stellar-payment-handler"; +import subsidizePostSwapEvmPhaseHandler from "./handlers/subsidize-post-swap-evm-handler"; import subsidizePostSwapPhaseHandler from "./handlers/subsidize-post-swap-handler"; import subsidizePreSwapPhaseHandler from "./handlers/subsidize-pre-swap-handler"; import phaseRegistry from "./phase-registry"; @@ -42,9 +43,10 @@ export function registerPhaseHandlers(): void { phaseRegistry.registerHandler(stellarPaymentHandler); phaseRegistry.registerHandler(spacewalkRedeemHandler); phaseRegistry.registerHandler(subsidizePostSwapPhaseHandler); + phaseRegistry.registerHandler(subsidizePostSwapEvmPhaseHandler); phaseRegistry.registerHandler(subsidizePreSwapPhaseHandler); phaseRegistry.registerHandler(moonbeamToPendulumPhaseHandler); - phaseRegistry.registerHandler(brlaPayoutMoonbeamHandler); + phaseRegistry.registerHandler(brlaPayoutBaseHandler); phaseRegistry.registerHandler(fundEphemeralHandler); phaseRegistry.registerHandler(alfredpayOnrampMintHandler); phaseRegistry.registerHandler(alfredpayOfframpTransferHandler); diff --git a/packages/shared/src/endpoints/ramp.endpoints.ts b/packages/shared/src/endpoints/ramp.endpoints.ts index c0c6eb141..3f1287a49 100644 --- a/packages/shared/src/endpoints/ramp.endpoints.ts +++ b/packages/shared/src/endpoints/ramp.endpoints.ts @@ -36,11 +36,12 @@ export type RampPhase = | "stellarPayment" | "subsidizePreSwap" | "subsidizePostSwap" + | "subsidizePostSwapEvm" | "distributeFees" | "alfredpayOnrampMint" | "alfredpayOfframpTransfer" | "brlaOnrampMint" - | "brlaPayoutOnMoonbeam" + | "brlaPayoutOnBase" | "baseTransfer" | "failed" | "timedOut" From b95e42ebdba3ccfb9ec2de0ef6c94e04df09f836 Mon Sep 17 00:00:00 2001 From: Gianfranco Date: Thu, 9 Apr 2026 10:57:28 -0300 Subject: [PATCH 08/12] fix avenia kyc machine guard bug --- apps/frontend/src/machines/brlaKyc.machine.ts | 6 ++---- apps/frontend/src/machines/kyc.states.ts | 8 ++++---- 2 files changed, 6 insertions(+), 8 deletions(-) diff --git a/apps/frontend/src/machines/brlaKyc.machine.ts b/apps/frontend/src/machines/brlaKyc.machine.ts index 56239b40d..f30d8559d 100644 --- a/apps/frontend/src/machines/brlaKyc.machine.ts +++ b/apps/frontend/src/machines/brlaKyc.machine.ts @@ -64,15 +64,13 @@ export const aveniaKycMachine = setup({ | { type: "COMPANY_VERIFICATION_STARTED" } | { type: "REPRESENTATIVE_VERIFICATION_STARTED" }, input: {} as RampContext, - output: {} as { error?: AveniaKycMachineError } + output: {} as AveniaKycContext } }).createMachine({ context: ({ input }) => ({ ...input }) as AveniaKycContext, id: "brlaKyc", initial: "FormFilling", - output: ({ context }) => ({ - error: context.error - }), + output: ({ context }) => context, states: { DocumentUpload: { on: { diff --git a/apps/frontend/src/machines/kyc.states.ts b/apps/frontend/src/machines/kyc.states.ts index e0ab63b17..fe9cd1760 100644 --- a/apps/frontend/src/machines/kyc.states.ts +++ b/apps/frontend/src/machines/kyc.states.ts @@ -139,15 +139,15 @@ export const kycStateNode = { onDone: [ { actions: assign({ - kycFormData: ({ event }: { event: DoneActorEvent }) => event.output.context.kycFormData + kycFormData: ({ event }: { event: DoneActorEvent }) => event.output.kycFormData }), - guard: ({ event }: { event: DoneActorEvent }) => !event.output.context.error, + guard: ({ event }: { event: DoneActorEvent }) => !event.output.error, target: "VerificationComplete" }, { actions: assign({ - initializeFailedMessage: ({ event }: { event: DoneActorEvent }) => - (event.output.context.error as AveniaKycMachineError).message + initializeFailedMessage: ({ event }: { event: DoneActorEvent }) => + (event.output.error as AveniaKycMachineError).message }), target: "#ramp.KycFailure" } From 25f862759fe961d8a96b3d1d0d8f92d63ee5704c Mon Sep 17 00:00:00 2001 From: Gianfranco Date: Thu, 9 Apr 2026 11:00:40 -0300 Subject: [PATCH 09/12] use new pix evm quote strategy --- .../services/quote/routes/route-resolver.ts | 4 +-- .../offramp-evm-to-alfredpay.strategy.ts | 30 +++++++------------ .../offramp-to-pix-base.strategy.ts | 6 ++-- 3 files changed, 15 insertions(+), 25 deletions(-) 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 9012fc271..99e2c2df5 100644 --- a/apps/api/src/api/services/quote/routes/route-resolver.ts +++ b/apps/api/src/api/services/quote/routes/route-resolver.ts @@ -5,7 +5,7 @@ import { AssetHubToken, FiatToken, Networks, RampDirection } from "@vortexfi/sha import type { QuoteContext } from "../core/types"; import { IRouteStrategy } from "../core/types"; import { OfframpEvmToAlfredpayStrategy } from "./strategies/offramp-evm-to-alfredpay.strategy"; -import { OfframpToPixStrategy } from "./strategies/offramp-to-pix.strategy"; +import { OfframpToPixEvmStrategy } from "./strategies/offramp-to-pix-base.strategy"; import { OfframpToStellarStrategy } from "./strategies/offramp-to-stellar.strategy"; import { OnrampAlfredpayToEvmStrategy } from "./strategies/onramp-alfredpay-to-evm.strategy"; import { OnrampAveniaToAssethubStrategy } from "./strategies/onramp-avenia-to-assethub.strategy"; @@ -47,7 +47,7 @@ export class RouteResolver { switch (ctx.to) { case "pix": - return new OfframpToPixStrategy(); + return new OfframpToPixEvmStrategy(); case "ach": return new OfframpEvmToAlfredpayStrategy(); case "sepa": diff --git a/apps/api/src/api/services/quote/routes/strategies/offramp-evm-to-alfredpay.strategy.ts b/apps/api/src/api/services/quote/routes/strategies/offramp-evm-to-alfredpay.strategy.ts index 1169ae8b1..3469df32a 100644 --- a/apps/api/src/api/services/quote/routes/strategies/offramp-evm-to-alfredpay.strategy.ts +++ b/apps/api/src/api/services/quote/routes/strategies/offramp-evm-to-alfredpay.strategy.ts @@ -1,33 +1,23 @@ import { Networks } from "@vortexfi/shared"; import { EnginesRegistry, IRouteStrategy, QuoteContext, StageKey } from "../../core/types"; -import { OffRampDiscountEngine } from "../../engines/discount/offramp"; -import { OffRampFeeAveniaEngine } from "../../engines/fee/offramp-avenia"; +import { OffRampEvmToAlfredpayFeeEngine } from "../../engines/fee/offramp-evm-to-alfredpay"; import { OffRampFinalizeEngine } from "../../engines/finalize/offramp"; + import { OffRampFromEvmInitializeEngine } from "../../engines/initialize/offramp-from-evm-alfredpay"; -import { OffRampSwapEngine } from "../../engines/nabla-swap/offramp"; -import { OffRampToAveniaPendulumTransferEngine } from "../../engines/pendulum-transfers/offramp-avenia"; +import { OfframpTransactionAlfredpayEngine } from "../../engines/partners/offramp-alfredpay"; -export class OfframpToPixStrategy implements IRouteStrategy { - readonly name = "OffRampPix"; +export class OfframpEvmToAlfredpayStrategy implements IRouteStrategy { + readonly name = "OfframpEvmToAlfredpay"; getStages(_ctx: QuoteContext): StageKey[] { - return [ - StageKey.Initialize, - StageKey.NablaSwap, - StageKey.Fee, - StageKey.Discount, - StageKey.PendulumTransfer, - StageKey.Finalize - ]; + return [StageKey.Initialize, StageKey.Fee, StageKey.PartnerOperation, StageKey.Finalize]; } - getEngines(ctx: QuoteContext): EnginesRegistry { + getEngines(_ctx: QuoteContext): EnginesRegistry { return { - [StageKey.Initialize]: new OffRampFromEvmInitializeEngine(Networks.Base), - [StageKey.Fee]: new OffRampFeeAveniaEngine(), - [StageKey.NablaSwap]: new OffRampSwapEngine(), - [StageKey.Discount]: new OffRampDiscountEngine(), - [StageKey.PendulumTransfer]: new OffRampToAveniaPendulumTransferEngine(), + [StageKey.Initialize]: new OffRampFromEvmInitializeEngine(Networks.Polygon), + [StageKey.Fee]: new OffRampEvmToAlfredpayFeeEngine(), + [StageKey.PartnerOperation]: new OfframpTransactionAlfredpayEngine(), [StageKey.Finalize]: new OffRampFinalizeEngine() }; } diff --git a/apps/api/src/api/services/quote/routes/strategies/offramp-to-pix-base.strategy.ts b/apps/api/src/api/services/quote/routes/strategies/offramp-to-pix-base.strategy.ts index 00340507b..25f51d32b 100644 --- a/apps/api/src/api/services/quote/routes/strategies/offramp-to-pix-base.strategy.ts +++ b/apps/api/src/api/services/quote/routes/strategies/offramp-to-pix-base.strategy.ts @@ -6,8 +6,8 @@ import { OffRampFinalizeEngine } from "../../engines/finalize/offramp"; import { OffRampFromEvmInitializeEngine } from "../../engines/initialize/offramp-from-evm-alfredpay"; import { OffRampSwapEngineEvm } from "../../engines/nabla-swap/offramp-evm"; -export class OfframpEvmToAlfredpayStrategy implements IRouteStrategy { - readonly name = "OfframpEvmToAlfredpay"; +export class OfframpToPixEvmStrategy implements IRouteStrategy { + readonly name = "OfframpToPixEvm"; getStages(_ctx: QuoteContext): StageKey[] { return [StageKey.Initialize, StageKey.Fee, StageKey.PartnerOperation, StageKey.Finalize]; @@ -15,7 +15,7 @@ export class OfframpEvmToAlfredpayStrategy implements IRouteStrategy { getEngines(_ctx: QuoteContext): EnginesRegistry { return { - [StageKey.Initialize]: new OffRampFromEvmInitializeEngine(Networks.Polygon), + [StageKey.Initialize]: new OffRampFromEvmInitializeEngine(Networks.Base), [StageKey.Fee]: new OffRampFeeAveniaEngine(), [StageKey.NablaSwap]: new OffRampSwapEngineEvm(EvmToken.BRLA), [StageKey.Discount]: new OffRampDiscountEngine(), From e71a10bd0b2b90c3ba3d44a412e5cb53aea744bd Mon Sep 17 00:00:00 2001 From: Gianfranco Date: Fri, 10 Apr 2026 10:59:14 -0300 Subject: [PATCH 10/12] extra validations, fixing gas issues --- .../phases/handlers/initial-phase-handler.ts | 8 +- apps/api/src/api/services/quote/core/nabla.ts | 29 +++--- .../quote/engines/fee/onramp-brl-to-evm.ts | 29 +++++- .../services/quote/engines/finalize/onramp.ts | 2 +- .../quote/engines/initialize/onramp-avenia.ts | 5 +- .../quote/engines/nabla-swap/base-evm.ts | 4 +- .../quote/engines/nabla-swap/onramp-evm.ts | 4 +- .../services/quote/routes/route-resolver.ts | 4 +- .../onramp-avenia-to-evm.strategy-base.ts | 7 +- .../onramp-avenia-to-evm.strategy.ts | 3 +- .../api/src/api/services/ramp/ramp.service.ts | 2 +- .../transactions/common/feeDistribution.ts | 17 +++- .../offramp/routes/evm-to-brl-base.ts | 5 +- .../onramp/common/transactions.ts | 14 +-- .../onramp/routes/alfredpay-to-evm.ts | 6 ++ .../onramp/routes/avenia-to-evm-base.ts | 97 ++++++++++++++----- .../onramp/routes/avenia-to-evm.ts | 6 ++ .../onramp/routes/monerium-to-evm.ts | 1 + .../api/services/transactions/validation.ts | 6 +- .../025-add-payout-address-evm-to-partners.ts | 16 +++ apps/api/src/models/partner.model.ts | 8 ++ .../shared/src/endpoints/ramp.endpoints.ts | 2 + packages/shared/src/helpers/networks.ts | 14 ++- packages/shared/src/helpers/signUnsigned.ts | 8 +- .../shared/src/services/evm/clientManager.ts | 7 +- .../shared/src/services/squidrouter/onramp.ts | 43 +++++++- packages/shared/src/tokens/evm/config.ts | 47 +++++++++ 27 files changed, 308 insertions(+), 86 deletions(-) create mode 100644 apps/api/src/database/migrations/025-add-payout-address-evm-to-partners.ts 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 a3c3c0507..368e51cc6 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 @@ -29,10 +29,10 @@ export class InitialPhaseHandler extends BasePhaseHandler { logger.info(`Executing initial phase for ramp ${state.id}`); - if (SANDBOX_ENABLED) { - await new Promise(resolve => setTimeout(resolve, 10000)); - return this.transitionToNextPhase(state, "complete"); - } + // if (SANDBOX_ENABLED) { + // await new Promise(resolve => setTimeout(resolve, 10000)); + // return this.transitionToNextPhase(state, "complete"); + // } if (state.type === RampDirection.BUY && quote.inputCurrency === FiatToken.BRL) { return this.transitionToNextPhase(state, "brlaOnrampMint"); diff --git a/apps/api/src/api/services/quote/core/nabla.ts b/apps/api/src/api/services/quote/core/nabla.ts index 3161fe569..4fe771628 100644 --- a/apps/api/src/api/services/quote/core/nabla.ts +++ b/apps/api/src/api/services/quote/core/nabla.ts @@ -16,7 +16,8 @@ import httpStatus from "http-status"; import logger from "../../../../config/logger"; import { APIError } from "../../../errors/api-error"; -const NABLA_ROUTER_BASE: `0x${string}` = "0x58E5Cb2dA15f01CB8FAefef202aa25238efCBdcf"; +const NABLA_ROUTER_BASE: `0x${string}` = "0x0e368D4891C4A52b91b4e1Bf3CdEfcdaAFEF4355"; // TODO modify with router on Base after test +const NABLA_QUOTER_BASE: `0x${string}` = "0xf4B0f7c272354d070CC5C8140826b7BBe56953dA"; export interface NablaSwapRequest { inputAmountForSwap: string; @@ -78,7 +79,7 @@ export async function calculateNablaSwapOutput(request: NablaSwapRequest): Promi } ]; - const result = await evmClientManager.readContractWithRetry<[bigint, bigint]>(Networks.Base, { + const result = await evmClientManager.readContractWithRetry<[bigint, bigint]>(Networks.BaseSepolia, { abi: swapAbi, address: NABLA_ROUTER_BASE, args: [ @@ -134,6 +135,9 @@ export async function calculateNablaSwapOutput(request: NablaSwapRequest): Promi export async function calculateNablaSwapOutputEvm(request: NablaSwapEvmRequest): Promise { const { inputAmountForSwap, inputTokenDetails, outputTokenDetails } = request; + console.log("Calculating Nabla swap output with input amount:", inputAmountForSwap); + console.log("Input token details:", inputTokenDetails); + console.log("Output token details:", outputTokenDetails); // Validate input amount if (!inputAmountForSwap || Big(inputAmountForSwap).lte(0)) { @@ -158,32 +162,31 @@ export async function calculateNablaSwapOutputEvm(request: NablaSwapEvmRequest): { inputs: [ { name: "_amountIn", type: "uint256" }, - { name: "_tokenInOut", type: "address[]" } - ], - name: "getAmountOut", - outputs: [ - { name: "amountOut", type: "uint256" }, - { name: "feeAmount", type: "uint256" } + { name: "_tokenPath", type: "address[]" }, + { name: "_routerPath", type: "address[]" } ], + name: "quoteSwapExactTokensForTokens", + outputs: [{ name: "amountOut_", type: "uint256" }], stateMutability: "view", type: "function" } ]; - const result = await evmClientManager.readContractWithRetry<[bigint, bigint]>(Networks.Base, { + const result = await evmClientManager.readContractWithRetry(Networks.BaseSepolia, { abi: swapAbi, - address: NABLA_ROUTER_BASE, + address: NABLA_QUOTER_BASE, args: [ BigInt(amountIn), [ inputTokenDetails.erc20AddressSourceChain as `0x${string}`, outputTokenDetails.erc20AddressSourceChain as `0x${string}` - ] + ], + [NABLA_ROUTER_BASE] ], - functionName: "getAmountOut" + functionName: "quoteSwapExactTokensForTokens" }); - const preciseQuotedAmountOut = parseContractBalanceResponse(outputTokenDetails.decimals, result[0]); + const preciseQuotedAmountOut = parseContractBalanceResponse(outputTokenDetails.decimals, result); if (!preciseQuotedAmountOut) { throw new Error("Failed to parse quoted amount out"); } 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 e2d946622..47c750546 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 @@ -1,7 +1,9 @@ import { - AXL_USDC_MOONBEAM, - AXL_USDC_MOONBEAM_DETAILS, + EvmNetworks, + EvmToken, + evmTokenConfig, getNetworkFromDestination, + isNetworkEVM, multiplyByPowerOfTen, Networks, OnChainToken, @@ -18,6 +20,16 @@ export class OnRampAveniaToEvmFeeEngine extends BaseFeeEngine { skipNote: "Skipped for off-ramp request" }; + constructor( + private readonly fromNetwork: Networks, + private readonly fromToken: EvmToken + ) { + super(); + if (!isNetworkEVM(fromNetwork)) { + throw new Error(`OnRampAveniaToEvmFeeEngine: ${fromNetwork} is not an EVM network`); + } + } + protected validate(ctx: QuoteContext): void { if (!ctx.aveniaMint) { throw new Error("OnRampAveniaToEvmFeeEngine requires aveniaMint in context"); @@ -42,14 +54,21 @@ export class OnRampAveniaToEvmFeeEngine extends BaseFeeEngine { const toToken = getTokenDetailsForEvmDestination(request.outputCurrency as OnChainToken, toNetwork).erc20AddressSourceChain; + const swapNetwork = this.fromNetwork as EvmNetworks; + // Get token details from evmTokenConfig + const fromTokenDetails = evmTokenConfig[swapNetwork]?.[this.fromToken]; + if (!fromTokenDetails) { + throw new Error(`OnRampAveniaToEvmFeeEngine: invalid token configuration for ${this.fromToken} on ${swapNetwork}`); + } + // For simplicity, we just use the input amount and convert it to the raw amount here // It's not the actual amount that will be bridged but it doesn't matter for the network fee calculation - const amountRaw = multiplyByPowerOfTen(request.inputAmount, AXL_USDC_MOONBEAM_DETAILS.decimals).toFixed(0, 0); + const amountRaw = multiplyByPowerOfTen(request.inputAmount, fromTokenDetails.decimals).toFixed(0, 0); const bridgeResult = await calculateEvmBridgeAndNetworkFee({ amountRaw, - fromNetwork: Networks.Moonbeam, - fromToken: AXL_USDC_MOONBEAM, + fromNetwork: swapNetwork, + fromToken: fromTokenDetails.erc20AddressSourceChain, originalInputAmountForRateCalc: request.inputAmount, rampType: request.rampType, toNetwork, diff --git a/apps/api/src/api/services/quote/engines/finalize/onramp.ts b/apps/api/src/api/services/quote/engines/finalize/onramp.ts index 418d04881..e3f7ada02 100644 --- a/apps/api/src/api/services/quote/engines/finalize/onramp.ts +++ b/apps/api/src/api/services/quote/engines/finalize/onramp.ts @@ -37,7 +37,7 @@ export class OnRampFinalizeEngine extends BaseFinalizeEngine { } finalOutputAmountDecimal = output; } - } else if (request.inputCurrency === FiatToken.EURC) { + } else if (request.inputCurrency === FiatToken.EURC || request.inputCurrency === FiatToken.BRL) { const output = ctx.evmToEvm?.outputAmountDecimal; if (!output) { throw new APIError({ diff --git a/apps/api/src/api/services/quote/engines/initialize/onramp-avenia.ts b/apps/api/src/api/services/quote/engines/initialize/onramp-avenia.ts index 3249e24ea..0bff9b50a 100644 --- a/apps/api/src/api/services/quote/engines/initialize/onramp-avenia.ts +++ b/apps/api/src/api/services/quote/engines/initialize/onramp-avenia.ts @@ -6,6 +6,7 @@ import { FiatToken, getAnyFiatTokenDetailsMoonbeam, multiplyByPowerOfTen, + Networks, RampDirection } from "@vortexfi/shared"; import Big from "big.js"; @@ -90,7 +91,9 @@ export class OnRampInitializeAveniaEngine extends BaseInitializeEngine { }; const xcmFees = buildXcmMeta(); - await assignMoonbeamToPendulumXcm(ctx, xcmFees, mintedBrlaDecimal, mintedBrlaRaw); + if (ctx.to === Networks.AssetHub) { + await assignMoonbeamToPendulumXcm(ctx, xcmFees, mintedBrlaDecimal, mintedBrlaRaw); + } ctx.addNote?.(`Assuming ${mintedBrlaDecimal.toFixed()} BRLA minted on ephemeral account`); } diff --git a/apps/api/src/api/services/quote/engines/nabla-swap/base-evm.ts b/apps/api/src/api/services/quote/engines/nabla-swap/base-evm.ts index 550f62588..17f20cda3 100644 --- a/apps/api/src/api/services/quote/engines/nabla-swap/base-evm.ts +++ b/apps/api/src/api/services/quote/engines/nabla-swap/base-evm.ts @@ -36,8 +36,8 @@ export abstract class BaseNablaSwapEngineEvm implements Stage { const { inputAmountPreFees, inputToken, outputToken } = this.compute(ctx); // Get token details for Base network - const inputTokenDetails = getOnChainTokenDetails(Networks.Base, inputToken) as EvmTokenDetails; - const outputTokenDetails = getOnChainTokenDetails(Networks.Base, outputToken) as EvmTokenDetails; + const inputTokenDetails = getOnChainTokenDetails(Networks.BaseSepolia, inputToken) as EvmTokenDetails; + const outputTokenDetails = getOnChainTokenDetails(Networks.BaseSepolia, outputToken) as EvmTokenDetails; if (!inputTokenDetails || !outputTokenDetails) { throw new Error("BaseNablaSwapEngineEvm: Could not find EVM token details for the requested tokens"); diff --git a/apps/api/src/api/services/quote/engines/nabla-swap/onramp-evm.ts b/apps/api/src/api/services/quote/engines/nabla-swap/onramp-evm.ts index 75cdff6c2..4a75060d5 100644 --- a/apps/api/src/api/services/quote/engines/nabla-swap/onramp-evm.ts +++ b/apps/api/src/api/services/quote/engines/nabla-swap/onramp-evm.ts @@ -25,9 +25,9 @@ export class OnRampSwapEngineEvm extends BaseNablaSwapEngineEvm { const inputAmountPreFees = ctx.aveniaTransfer.outputAmountDecimal; - // For Onramp EVM, the input token for Nabla is the output of Avenia transfer (BRL on Base) + // For Onramp EVM, the input token for Nabla is the output of Avenia transfer (BRLA on Base) // The output token is fixed at USDC. - const inputToken = ctx.aveniaTransfer.currency as EvmToken; + const inputToken = EvmToken.BRLA; const outputToken = EvmToken.USDC; return { 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 99e2c2df5..3ef1566a4 100644 --- a/apps/api/src/api/services/quote/routes/route-resolver.ts +++ b/apps/api/src/api/services/quote/routes/route-resolver.ts @@ -9,7 +9,7 @@ import { OfframpToPixEvmStrategy } from "./strategies/offramp-to-pix-base.strate import { OfframpToStellarStrategy } from "./strategies/offramp-to-stellar.strategy"; import { OnrampAlfredpayToEvmStrategy } from "./strategies/onramp-alfredpay-to-evm.strategy"; import { OnrampAveniaToAssethubStrategy } from "./strategies/onramp-avenia-to-assethub.strategy"; -import { OnrampAveniaToEvmStrategy } from "./strategies/onramp-avenia-to-evm.strategy"; +import { OnrampAveniaToEvmBaseStrategy } from "./strategies/onramp-avenia-to-evm.strategy-base"; import { OnrampMoneriumToAssethubStrategy } from "./strategies/onramp-monerium-to-assethub.strategy"; import { OnrampMoneriumToEvmStrategy } from "./strategies/onramp-monerium-to-evm.strategy"; @@ -29,7 +29,7 @@ export class RouteResolver { } else if (ctx.request.inputCurrency === FiatToken.USD) { return new OnrampAlfredpayToEvmStrategy(); } else { - return new OnrampAveniaToEvmStrategy(); + return new OnrampAveniaToEvmBaseStrategy(); } } } diff --git a/apps/api/src/api/services/quote/routes/strategies/onramp-avenia-to-evm.strategy-base.ts b/apps/api/src/api/services/quote/routes/strategies/onramp-avenia-to-evm.strategy-base.ts index b9c37f03d..5a7dcf501 100644 --- a/apps/api/src/api/services/quote/routes/strategies/onramp-avenia-to-evm.strategy-base.ts +++ b/apps/api/src/api/services/quote/routes/strategies/onramp-avenia-to-evm.strategy-base.ts @@ -1,12 +1,14 @@ +import { EvmToken, Networks } from "@vortexfi/shared"; import { EnginesRegistry, IRouteStrategy, QuoteContext, StageKey } from "../../core/types"; import { OnRampDiscountEngine } from "../../engines/discount/onramp"; +import { OnRampAveniaToEvmFeeEngine } from "../../engines/fee/onramp-brl-to-evm"; import { OnRampFinalizeEngine } from "../../engines/finalize/onramp"; import { OnRampInitializeAveniaEngine } from "../../engines/initialize/onramp-avenia"; import { OnRampSwapEngineEvm } from "../../engines/nabla-swap/onramp-evm"; import { OnRampSquidRouterBrlToEvmEngineBase } from "../../engines/squidrouter/onramp-base-to-evm"; -export class OnrampAveniaToEvmStrategy implements IRouteStrategy { - readonly name = "OnRampAveniaToEvm"; +export class OnrampAveniaToEvmBaseStrategy implements IRouteStrategy { + readonly name = "OnRampAveniaToEvmBase"; getStages(_ctx: QuoteContext): StageKey[] { return [StageKey.Initialize, StageKey.Fee, StageKey.NablaSwap, StageKey.Discount, StageKey.SquidRouter, StageKey.Finalize]; @@ -15,6 +17,7 @@ export class OnrampAveniaToEvmStrategy implements IRouteStrategy { getEngines(_ctx: QuoteContext): EnginesRegistry { return { [StageKey.Initialize]: new OnRampInitializeAveniaEngine(), + [StageKey.Fee]: new OnRampAveniaToEvmFeeEngine(Networks.Base, EvmToken.USDC), [StageKey.NablaSwap]: new OnRampSwapEngineEvm(), [StageKey.Discount]: new OnRampDiscountEngine(), [StageKey.SquidRouter]: new OnRampSquidRouterBrlToEvmEngineBase(), diff --git a/apps/api/src/api/services/quote/routes/strategies/onramp-avenia-to-evm.strategy.ts b/apps/api/src/api/services/quote/routes/strategies/onramp-avenia-to-evm.strategy.ts index 3f065362a..49b5c39f0 100644 --- a/apps/api/src/api/services/quote/routes/strategies/onramp-avenia-to-evm.strategy.ts +++ b/apps/api/src/api/services/quote/routes/strategies/onramp-avenia-to-evm.strategy.ts @@ -1,3 +1,4 @@ +import { EvmToken, Networks } from "@vortexfi/shared"; import { EnginesRegistry, IRouteStrategy, QuoteContext, StageKey } from "../../core/types"; import { OnRampDiscountEngine } from "../../engines/discount/onramp"; import { OnRampAveniaToEvmFeeEngine } from "../../engines/fee/onramp-brl-to-evm"; @@ -25,7 +26,7 @@ export class OnrampAveniaToEvmStrategy implements IRouteStrategy { getEngines(_ctx: QuoteContext): EnginesRegistry { return { [StageKey.Initialize]: new OnRampInitializeAveniaEngine(), - [StageKey.Fee]: new OnRampAveniaToEvmFeeEngine(), + [StageKey.Fee]: new OnRampAveniaToEvmFeeEngine(Networks.Moonbeam, EvmToken.AXLUSDC), [StageKey.NablaSwap]: new OnRampSwapEngine(), [StageKey.Discount]: new OnRampDiscountEngine(), [StageKey.PendulumTransfer]: new OnRampPendulumTransferEngine(), diff --git a/apps/api/src/api/services/ramp/ramp.service.ts b/apps/api/src/api/services/ramp/ramp.service.ts index c37db9cea..6a6360d06 100644 --- a/apps/api/src/api/services/ramp/ramp.service.ts +++ b/apps/api/src/api/services/ramp/ramp.service.ts @@ -853,7 +853,7 @@ export class RampService extends BaseRampService { const evmEphemeralEntry = signingAccounts.find(ephemeral => ephemeral.type === "EVM"); if (!evmEphemeralEntry) { throw new APIError({ - message: "Moonbeam ephemeral not found", + message: "Base ephemeral not found", status: httpStatus.BAD_REQUEST }); } diff --git a/apps/api/src/api/services/transactions/common/feeDistribution.ts b/apps/api/src/api/services/transactions/common/feeDistribution.ts index 1d8dffa95..f593cc4aa 100644 --- a/apps/api/src/api/services/transactions/common/feeDistribution.ts +++ b/apps/api/src/api/services/transactions/common/feeDistribution.ts @@ -198,15 +198,15 @@ export async function createEvmFeeDistributionTransaction(quote: QuoteTicketAttr const vortexFeeUSD = usdFeeStructure.vortex; const partnerMarkupFeeUSD = usdFeeStructure.partnerMarkup; - // Get vortex payout address + // Get vortex payout address (EVM) const vortexPartner = await Partner.findOne({ where: { isActive: true, name: "vortex", rampType: quote.rampType } }); - if (!vortexPartner || !vortexPartner.payoutAddress) { - logger.warn("Vortex partner or payout address not found, skipping EVM fee distribution transaction"); + if (!vortexPartner || !vortexPartner.payoutAddressEvm) { + logger.warn("Vortex partner or EVM payout address not found, skipping EVM fee distribution transaction"); return null; } - const vortexPayoutAddress = vortexPartner.payoutAddress; + const vortexPayoutAddress = vortexPartner.payoutAddressEvm; // Use Base USDC for decimal calculations const baseUsdcConfig = evmTokenConfig[Networks.Base][EvmToken.USDC]; @@ -241,7 +241,14 @@ export async function createEvmFeeDistributionTransaction(quote: QuoteTicketAttr }); const { maxFeePerGas, maxPriorityFeePerGas } = await publicClient.estimateFeesPerGas(); - + console.log( + "fee distr inputs: totalFeeUsdcRaw:", + totalFeeUsdcRaw.toFixed(0), + "maxFeePerGas:", + maxFeePerGas, + "maxPriorityFeePerGas:", + maxPriorityFeePerGas + ); const txData: EvmTransactionData = { data: transferCallData as `0x${string}`, gas: "100000", 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 1eb55e4b4..35b28f510 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 @@ -107,8 +107,9 @@ export async function prepareEvmToBRLOfframpTransactions({ const { nextNonce: nonceAfterNabla, stateMeta: nablaStateMeta } = await addNablaSwapTransactionsOnBase( { account: evmEphemeralEntry, - inputTokenAddress: baseUsdcAddress, // Swap from USDC to BRLA on Base - outputTokenAddress: baseBrlaAddress, // BRLA address on Base + // TODO remove before release, using mock base tokens. + inputTokenAddress: "0x1b888723fb7699f9dF0a99443107E8A888A67e11", // baseUsdcAddress, // Swap from USDC to BRLA on Base + outputTokenAddress: "0x57180796D4082Ba903d86c4eA3C86490fA10512c", //baseBrlaAddress, // BRLA address on Base quote }, unsignedTxs, 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 c5e6295d9..c4ca61e17 100644 --- a/apps/api/src/api/services/transactions/onramp/common/transactions.ts +++ b/apps/api/src/api/services/transactions/onramp/common/transactions.ts @@ -206,7 +206,7 @@ export async function addOnrampDestinationChainTransactions(params: { const txData: EvmTransactionData = { data: "0x" as `0x${string}`, gas: "21000", // Standard gas limit for native transfers - maxFeePerGas: String(maxFeePerGas), + maxFeePerGas: String(maxFeePerGas * 3n), maxPriorityFeePerGas: String(maxPriorityFeePerGas * 3n), to: toAddress as `0x${string}`, value: amountRaw @@ -225,7 +225,7 @@ export async function addOnrampDestinationChainTransactions(params: { const txData: EvmTransactionData = { data: transferCallData as `0x${string}`, gas: "100000", - maxFeePerGas: String(maxFeePerGas), + maxFeePerGas: String(maxFeePerGas * 3n), maxPriorityFeePerGas: String(maxPriorityFeePerGas * 3n), to: toToken, value: "0" @@ -253,13 +253,13 @@ export async function addNablaSwapTransactionsOnBase( ): Promise<{ nextNonce: number; stateMeta: Partial }> { const { quote, account, inputTokenAddress, outputTokenAddress } = params; - if (!quote.metadata.nablaSwap?.inputAmountForSwapRaw) { + if (!quote.metadata.nablaSwapEvm?.inputAmountForSwapRaw) { throw new Error("Missing nablaSwap input amount in quote metadata"); } // The input amount for the swap was already calculated in the quote. - const inputAmountForNablaSwapRaw = quote.metadata.nablaSwap.inputAmountForSwapRaw; - const outputAmountRaw = Big(quote.metadata.nablaSwap.outputAmountRaw); + const inputAmountForNablaSwapRaw = quote.metadata.nablaSwapEvm.inputAmountForSwapRaw; + 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); @@ -276,7 +276,7 @@ export async function addNablaSwapTransactionsOnBase( meta: {}, network: Networks.Base, nonce: nextNonce, - phase: "nablaApprove", + phase: "nablaApproveEvm", signer: account.address, txData: approve }); @@ -286,7 +286,7 @@ export async function addNablaSwapTransactionsOnBase( meta: {}, network: Networks.Base, nonce: nextNonce, - phase: "nablaSwap", + phase: "nablaSwapEvm", signer: account.address, txData: swap }); 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 3eac4077d..2f72aa914 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 @@ -16,6 +16,7 @@ import { Networks, UnsignedTx } from "@vortexfi/shared"; +import { isAddress } from "viem"; import { privateKeyToAccount } from "viem/accounts"; import { MOONBEAM_FUNDING_PRIVATE_KEY } from "../../../../../constants/constants"; import AlfredPayCustomer from "../../../../../models/alfredPayCustomer.model"; @@ -37,6 +38,11 @@ export async function prepareAlfredpayToEvmOnrampTransactions({ let stateMeta: Partial = {}; const unsignedTxs: UnsignedTx[] = []; + // Validate that destinationAddress is a valid EVM address for EVM routes + if (!isAddress(destinationAddress)) { + throw new Error(`Invalid destination address for EVM route: ${destinationAddress}. Must be a valid EVM address.`); + } + const evmEphemeralEntry = signingAccounts.find(ephemeral => ephemeral.type === "EVM"); if (!evmEphemeralEntry) { throw new Error("EVM ephemeral entry not found"); 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 16171e211..848e270e6 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 @@ -1,13 +1,10 @@ import { - AXL_USDC_MOONBEAM_DETAILS, createOnrampSquidrouterTransactionsOnDestinationChain, EvmNetworks, EvmToken, EvmTokenDetails, EvmTransactionData, - encodeSubmittableExtrinsic, evmTokenConfig, - getNetworkId, getOnChainTokenDetailsOrDefault, isEvmTokenDetails, isNativeEvmToken, @@ -15,7 +12,9 @@ import { Networks, UnsignedTx } from "@vortexfi/shared"; +import { isAddress } from "viem"; import { privateKeyToAccount } from "viem/accounts"; +import { createOnrampSquidrouterTransactionsFromBaseToEvm } from "../../../../../../../../packages/shared/src/services"; import { MOONBEAM_FUNDING_PRIVATE_KEY } from "../../../../../constants/constants"; import { StateMetadata } from "../../../phases/meta-state-types"; import { addEvmFeeDistributionTransaction } from "../../common/feeDistribution"; @@ -41,12 +40,22 @@ export async function prepareAveniaToEvmOnrampTransactionsOnBase({ let stateMeta: Partial = {}; const unsignedTxs: UnsignedTx[] = []; + // Validate that destinationAddress is a valid EVM address for EVM routes + if (!isAddress(destinationAddress)) { + throw new Error(`Invalid destination address for EVM route: ${destinationAddress}. Must be a valid EVM address.`); + } + // Validate inputs and extract required data const { toNetwork, outputTokenDetails, evmEphemeralEntry, inputTokenDetails } = validateAveniaOnrampOnBase( quote, signingAccounts ); - + console.log( + "starting: prepareAveniaToEvmOnrampTransactionsOnBase with quote:", + quote, + "destinationAddress:", + destinationAddress + ); // Setup state metadata stateMeta = { destinationAddress, @@ -59,21 +68,21 @@ export async function prepareAveniaToEvmOnrampTransactionsOnBase({ if (!quote.metadata.aveniaTransfer?.outputAmountRaw) { throw new Error("Missing aveniaTransfer amountOutRaw in quote metadata"); } - const inputAmountPostAnchorFeeRaw = quote.metadata.aveniaTransfer.outputAmountRaw; + + if (!quote.metadata.evmToEvm?.inputAmountRaw) { + throw new Error("Missing evmToEvm inputAmountRaw in quote metadata"); + } if (!isEvmTokenDetails(outputTokenDetails)) { throw new Error(`Output token must be an EVM token for onramp to any EVM chain, got ${outputTokenDetails.assetSymbol}`); } - const destinationAxlUsdcDetails = getOnChainTokenDetailsOrDefault(toNetwork as Networks, EvmToken.AXLUSDC) as EvmTokenDetails; - // Output for BRLA onramp will always go through USDC. // TODO. Unless the actual BRLA token wants to be onramped. const nablaSwapOutputTokenAddress = evmTokenConfig[Networks.Base][EvmToken.USDC]?.erc20AddressSourceChain; if (!nablaSwapOutputTokenAddress) { throw new Error("Invalid USDC configuration for Base in evmTokenConfig"); } - const { nextNonce: nonceAfterNabla, stateMeta: nablaStateMeta } = await addNablaSwapTransactionsOnBase( { account: evmEphemeralEntry, @@ -113,28 +122,34 @@ export async function prepareAveniaToEvmOnrampTransactionsOnBase({ return { stateMeta, unsignedTxs }; } - // Fallback swap depends on the EVM chain. For Ethereum, the bridged token is USDC. For the rest, it is axlUSDC. - const bridgedTokenForFallback = - toNetwork === Networks.Ethereum - ? evmTokenConfig.ethereum.USDC!.erc20AddressSourceChain - : destinationAxlUsdcDetails.erc20AddressSourceChain; - - const inputAmountRawFinalBridge = quote.metadata.evmToEvm?.inputAmountRaw; - if (!inputAmountRawFinalBridge) { - throw new Error("Missing input amount for final bridge in quote metadata"); - } - - // Destination chain: Squidrouter swap to final token - const { approveData: finalApproveData, swapData: finalSwapData } = - await createOnrampSquidrouterTransactionsOnDestinationChain({ + const { approveData, swapData, squidRouterQuoteId, squidRouterReceiverId, squidRouterReceiverHash } = + await createOnrampSquidrouterTransactionsFromBaseToEvm({ destinationAddress: evmEphemeralEntry.address, fromAddress: evmEphemeralEntry.address, - fromToken: outputTokenDetails.erc20AddressSourceChain, - network: toNetwork as EvmNetworks, - rawAmount: inputAmountRawFinalBridge, - toToken: outputTokenDetails.erc20AddressSourceChain + fromToken: nablaSwapOutputTokenAddress, + rawAmount: quote.metadata.evmToEvm?.inputAmountRaw, + toNetwork, + toToken: (outputTokenDetails as EvmTokenDetails).erc20AddressSourceChain }); + 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 + }); + let destinationNonce = 0; const finalDestinationTransfer = await addOnrampDestinationChainTransactions({ @@ -154,6 +169,29 @@ export async function prepareAveniaToEvmOnrampTransactionsOnBase({ txData: finalDestinationTransfer }); + // Fallback swap depends on the EVM chain. For Ethereum, the bridged token is USDC. For the rest, it is axlUSDC. + const destinationAxlUsdcDetails = getOnChainTokenDetailsOrDefault(toNetwork as Networks, EvmToken.AXLUSDC) as EvmTokenDetails; + const bridgedTokenForFallback = + toNetwork === Networks.Ethereum + ? evmTokenConfig.ethereum.USDC!.erc20AddressSourceChain + : destinationAxlUsdcDetails.erc20AddressSourceChain; + + const inputAmountRawFinalBridge = quote.metadata.evmToEvm?.inputAmountRaw; + if (!inputAmountRawFinalBridge) { + throw new Error("Missing input amount for final bridge in quote metadata"); + } + + // Destination chain: Squidrouter swap to final token + const { approveData: finalApproveData, swapData: finalSwapData } = + await createOnrampSquidrouterTransactionsOnDestinationChain({ + destinationAddress: evmEphemeralEntry.address, + fromAddress: evmEphemeralEntry.address, + fromToken: bridgedTokenForFallback, + network: toNetwork as EvmNetworks, + rawAmount: inputAmountRawFinalBridge, + toToken: outputTokenDetails.erc20AddressSourceChain + }); + destinationNonce++; unsignedTxs.push({ @@ -197,5 +235,12 @@ export async function prepareAveniaToEvmOnrampTransactionsOnBase({ txData: backupApproveTransaction }); + stateMeta = { + ...stateMeta, + squidRouterQuoteId, + squidRouterReceiverHash, + squidRouterReceiverId + }; + return { stateMeta, unsignedTxs }; } diff --git a/apps/api/src/api/services/transactions/onramp/routes/avenia-to-evm.ts b/apps/api/src/api/services/transactions/onramp/routes/avenia-to-evm.ts index bb03432c7..046ee3bb3 100644 --- a/apps/api/src/api/services/transactions/onramp/routes/avenia-to-evm.ts +++ b/apps/api/src/api/services/transactions/onramp/routes/avenia-to-evm.ts @@ -18,6 +18,7 @@ import { Networks, UnsignedTx } from "@vortexfi/shared"; +import { isAddress } from "viem"; import { privateKeyToAccount } from "viem/accounts"; import { MOONBEAM_FUNDING_PRIVATE_KEY } from "../../../../../constants/constants"; import { StateMetadata } from "../../../phases/meta-state-types"; @@ -46,6 +47,11 @@ export async function prepareAveniaToEvmOnrampTransactions({ let stateMeta: Partial = {}; const unsignedTxs: UnsignedTx[] = []; + // Validate that destinationAddress is a valid EVM address for EVM routes + if (!isAddress(destinationAddress)) { + throw new Error(`Invalid destination address for EVM route: ${destinationAddress}. Must be a valid EVM address.`); + } + // Validate inputs and extract required data const { toNetwork, outputTokenDetails, substrateEphemeralEntry, evmEphemeralEntry, inputTokenDetails } = validateAveniaOnramp( quote, diff --git a/apps/api/src/api/services/transactions/onramp/routes/monerium-to-evm.ts b/apps/api/src/api/services/transactions/onramp/routes/monerium-to-evm.ts index 0e2a3c5fb..35a85dc97 100644 --- a/apps/api/src/api/services/transactions/onramp/routes/monerium-to-evm.ts +++ b/apps/api/src/api/services/transactions/onramp/routes/monerium-to-evm.ts @@ -15,6 +15,7 @@ import { UnsignedTx } from "@vortexfi/shared"; import Big from "big.js"; +import { isAddress } from "viem"; import { privateKeyToAccount } from "viem/accounts"; import { MOONBEAM_FUNDING_PRIVATE_KEY, SANDBOX_ENABLED } from "../../../../../constants/constants"; import { StateMetadata } from "../../../phases/meta-state-types"; diff --git a/apps/api/src/api/services/transactions/validation.ts b/apps/api/src/api/services/transactions/validation.ts index 95b6cd055..628897700 100644 --- a/apps/api/src/api/services/transactions/validation.ts +++ b/apps/api/src/api/services/transactions/validation.ts @@ -63,6 +63,8 @@ function getTransactionTypeForPhase(phase: RampPhase | CleanupPhase): EphemeralA return EphemeralAccountType.Stellar; case "squidRouterApprove": case "squidRouterSwap": + case "nablaApproveEvm": + case "nablaSwapEvm": return EphemeralAccountType.EVM; default: return EphemeralAccountType.EVM; @@ -100,7 +102,7 @@ export async function validatePresignedTxs( function validateEvmTransaction(tx: PresignedTx, expectedSigner: string) { const { txData, signer } = tx; - + console.log("Validating EVM transaction with signer:", signer, "on network:", tx.network, "for phase:", tx.phase); // do not validate typed data if (isSignedTypedData(txData) || isSignedTypedDataArray(txData)) { return; @@ -152,7 +154,7 @@ function validateEvmTransaction(tx: PresignedTx, expectedSigner: string) { async function validateSubstrateTransaction(tx: PresignedTx, expectedSignerSubstrate: string, expectedSignerEvm: string) { const { txData, signer, network } = tx; - + console.log("Validating Substrate transaction with signer:", signer, "on network:", network, "for phase:", tx.phase); if (!expectedSignerSubstrate && !expectedSignerEvm) { throw new APIError({ message: `Expected signer for Substrate transaction is not provided for phase ${tx.phase}`, diff --git a/apps/api/src/database/migrations/025-add-payout-address-evm-to-partners.ts b/apps/api/src/database/migrations/025-add-payout-address-evm-to-partners.ts new file mode 100644 index 000000000..5db2616fe --- /dev/null +++ b/apps/api/src/database/migrations/025-add-payout-address-evm-to-partners.ts @@ -0,0 +1,16 @@ +import { DataTypes, QueryInterface } from "sequelize"; + +export async function up(queryInterface: QueryInterface): Promise { + // Add payout_address_evm column to partners table + await queryInterface.addColumn("partners", "payout_address_evm", { + allowNull: true, + comment: "EVM-specific payout address for fee distribution on EVM chains (Base, Ethereum, etc.)", + field: "payout_address_evm", + type: DataTypes.STRING(255) + }); +} + +export async function down(queryInterface: QueryInterface): Promise { + // Remove payout_address_evm column + await queryInterface.removeColumn("partners", "payout_address_evm"); +} diff --git a/apps/api/src/models/partner.model.ts b/apps/api/src/models/partner.model.ts index 4c5138b6b..5ada4e601 100644 --- a/apps/api/src/models/partner.model.ts +++ b/apps/api/src/models/partner.model.ts @@ -12,6 +12,7 @@ export interface PartnerAttributes { markupValue: number; markupCurrency: RampCurrency; payoutAddress: string; + payoutAddressEvm: string | null; rampType: RampDirection; vortexFeeType: "absolute" | "relative" | "none"; vortexFeeValue: number; @@ -45,6 +46,8 @@ class Partner extends Model implem declare payoutAddress: string; + declare payoutAddressEvm: string | null; + declare rampType: RampDirection; declare vortexFeeType: "absolute" | "relative" | "none"; @@ -140,6 +143,11 @@ Partner.init( field: "payout_address", type: DataTypes.STRING(255) }, + payoutAddressEvm: { + allowNull: true, + field: "payout_address_evm", + type: DataTypes.STRING(255) + }, rampType: { allowNull: false, field: "ramp_type", diff --git a/packages/shared/src/endpoints/ramp.endpoints.ts b/packages/shared/src/endpoints/ramp.endpoints.ts index 3f1287a49..69722c69c 100644 --- a/packages/shared/src/endpoints/ramp.endpoints.ts +++ b/packages/shared/src/endpoints/ramp.endpoints.ts @@ -23,7 +23,9 @@ export type RampPhase = | "fundEphemeral" | "destinationTransfer" | "nablaApprove" + | "nablaApproveEvm" | "nablaSwap" + | "nablaSwapEvm" | "hydrationSwap" | "hydrationToAssethubXcm" | "moonbeamToPendulum" diff --git a/packages/shared/src/helpers/networks.ts b/packages/shared/src/helpers/networks.ts index 81c8800ff..919d336f4 100644 --- a/packages/shared/src/helpers/networks.ts +++ b/packages/shared/src/helpers/networks.ts @@ -1,4 +1,4 @@ -import { arbitrum, avalanche, base, bsc, mainnet as ethereum, moonbeam, polygon, polygonAmoy } from "viem/chains"; +import { arbitrum, avalanche, base, baseSepolia, bsc, mainnet as ethereum, moonbeam, polygon, polygonAmoy } from "viem/chains"; import { PaymentMethod } from "../endpoints/payment-methods.endpoints"; export type DestinationType = Networks | PaymentMethod; @@ -16,7 +16,8 @@ export enum Networks { Moonbeam = "moonbeam", Pendulum = "pendulum", Stellar = "stellar", - PolygonAmoy = "polygonAmoy" + PolygonAmoy = "polygonAmoy", + BaseSepolia = "base-sepolia" } // This type is used to represent all networks that can be used as a source or destination in the system. @@ -28,7 +29,8 @@ export type EvmNetworks = | Networks.Ethereum | Networks.Moonbeam | Networks.Polygon - | Networks.PolygonAmoy; + | Networks.PolygonAmoy + | Networks.BaseSepolia; /** * Checks if a destination is a network and returns the network if it is. @@ -111,6 +113,12 @@ const NETWORK_METADATA: Record = { isEVM: true, supportsRamp: true }, + [Networks.BaseSepolia]: { + displayName: "Base Sepolia", + id: baseSepolia.id, // Using the same chain ID as Base since Sepolia is a testnet for Base --- TODO: update if Base Sepolia has a different chain ID after launch + isEVM: true, + supportsRamp: false + }, [Networks.Avalanche]: { displayName: "Avalanche", id: avalanche.id, diff --git a/packages/shared/src/helpers/signUnsigned.ts b/packages/shared/src/helpers/signUnsigned.ts index 045889ebf..4bb57aae0 100644 --- a/packages/shared/src/helpers/signUnsigned.ts +++ b/packages/shared/src/helpers/signUnsigned.ts @@ -195,7 +195,7 @@ async function signMultipleEvmTransactions( chain: walletClient.chain, data: tx.txData.data, gas: BigInt(tx.txData.gas), - maxFeePerGas: tx.txData.maxFeePerGas ? BigInt(tx.txData.maxFeePerGas) * 1n : BigInt(187500000000), + maxFeePerGas: tx.txData.maxFeePerGas ? BigInt(tx.txData.maxFeePerGas) * 2n : BigInt(187500000000), maxPriorityFeePerGas: tx.txData.maxPriorityFeePerGas ? BigInt(tx.txData.maxPriorityFeePerGas) * 3n : BigInt(187500000000), nonce: Number(currentNonce), to: tx.txData.to, @@ -235,7 +235,9 @@ export async function signUnsignedTransactions( // Group transactions const moonbeamTxs = unsignedTxs.filter(tx => tx.network === Networks.Moonbeam); - const polygonTxs = unsignedTxs.filter(tx => tx.network === Networks.Polygon || tx.network === Networks.PolygonAmoy); + const evmTxs = unsignedTxs.filter( + tx => tx.network === Networks.Polygon || tx.network === Networks.PolygonAmoy || tx.network === Networks.Base + ); const hydrationTxs = unsignedTxs.filter(tx => tx.network === Networks.Hydration); const destinationNetworkTxs = unsignedTxs.filter( tx => @@ -344,7 +346,7 @@ export async function signUnsignedTransactions( } // Process Polygon transactions - for (const tx of polygonTxs) { + for (const tx of evmTxs) { if (!ephemerals.evmEphemeral) { throw new Error("Missing EVM ephemeral account"); } diff --git a/packages/shared/src/services/evm/clientManager.ts b/packages/shared/src/services/evm/clientManager.ts index 9e8fb02b6..8843a4c94 100644 --- a/packages/shared/src/services/evm/clientManager.ts +++ b/packages/shared/src/services/evm/clientManager.ts @@ -1,5 +1,5 @@ import { Account, Chain, createPublicClient, createWalletClient, http, PublicClient, Transport, WalletClient } from "viem"; -import { arbitrum, avalanche, base, bsc, mainnet, moonbeam, polygon, polygonAmoy } from "viem/chains"; +import { arbitrum, avalanche, base, baseSepolia, bsc, mainnet, moonbeam, polygon, polygonAmoy, sepolia } from "viem/chains"; import { ALCHEMY_API_KEY, EvmNetworks, Networks } from "../../index"; import logger from "../../logger"; @@ -42,6 +42,11 @@ function getEvmNetworks(apiKey?: string): EvmNetworkConfig[] { name: Networks.Base, rpcUrls: apiKey ? [`https://base-mainnet.g.alchemy.com/v2/${apiKey}`, ""] : [""] }, + { + chain: baseSepolia, + name: Networks.BaseSepolia, + rpcUrls: apiKey ? [`https://base-sepolia.g.alchemy.com/v2/${apiKey}`, ""] : [""] + }, { chain: bsc, name: Networks.BSC, diff --git a/packages/shared/src/services/squidrouter/onramp.ts b/packages/shared/src/services/squidrouter/onramp.ts index 99f49c3a0..57619d852 100644 --- a/packages/shared/src/services/squidrouter/onramp.ts +++ b/packages/shared/src/services/squidrouter/onramp.ts @@ -29,7 +29,7 @@ export interface OnrampSquidrouterParamsFromMoonbeam { moonbeamEphemeralStartingNonce: number; } -export interface OnrampSquidrouterParamsFromPolygon { +export interface OnrampSquidrouterParamsFromEvm { fromAddress: string; rawAmount: string; fromToken: `0x${string}`; @@ -91,7 +91,7 @@ export async function createOnrampSquidrouterTransactionsFromMoonbeamToEvm( // Onramp from Polygon directly to any token on any EVM chain. export async function createOnrampSquidrouterTransactionsFromPolygonToEvm( - params: OnrampSquidrouterParamsFromPolygon + params: OnrampSquidrouterParamsFromEvm ): Promise { if (params.toNetwork === Networks.AssetHub) { // This error indicates a bug in our code, as AssetHub onramps should be handled differently. @@ -126,9 +126,46 @@ export async function createOnrampSquidrouterTransactionsFromPolygonToEvm( } } +export async function createOnrampSquidrouterTransactionsFromBaseToEvm( + params: OnrampSquidrouterParamsFromEvm +): Promise { + if (params.toNetwork === Networks.AssetHub) { + // This error indicates a bug in our code, as AssetHub onramps should be handled differently. + throw new Error("AssetHub is not supported for this flow. Use a different function."); + } + + const evmClientManager = EvmClientManager.getInstance(); + const baseClient = evmClientManager.getClient(Networks.Base); + const fromNetwork = Networks.Base; + + const routeParams = createGenericRouteParams({ ...params, amount: params.rawAmount, fromNetwork }); + + try { + const routeResult = await getRoute(routeParams); + const { route } = routeResult.data; + + const { approveData, swapData, squidRouterQuoteId } = await createTransactionDataFromRoute({ + inputTokenErc20Address: params.fromToken, + publicClient: baseClient, + rawAmount: params.rawAmount, + route, + swapValue: POLYGON_SQUIDROUTER_SWAP_MIN_VALUE_RAW // TODO do we need a different min value for Base? + }); + + return { + approveData, + route, + squidRouterQuoteId, + swapData + }; + } catch (e) { + throw new Error(`Error getting route: ${routeParams}. Error: ${e}`); + } +} + // Onramp from Polygon directly to any token on any EVM chain. export async function createOnrampSquidrouterTransactionsFromPolygonToMoonbeamWithPendulumPosthook( - params: Omit + params: Omit ): Promise { const evmClientManager = EvmClientManager.getInstance(); const polygonClient = evmClientManager.getClient(Networks.Polygon); diff --git a/packages/shared/src/tokens/evm/config.ts b/packages/shared/src/tokens/evm/config.ts index ef5ee138d..45630e27f 100644 --- a/packages/shared/src/tokens/evm/config.ts +++ b/packages/shared/src/tokens/evm/config.ts @@ -218,6 +218,53 @@ export const evmTokenConfig: Record Date: Fri, 10 Apr 2026 12:28:39 -0300 Subject: [PATCH 11/12] add quote engine phase for subsidy merge on evm --- apps/api/src/api/services/quote/core/nabla.ts | 4 -- apps/api/src/api/services/quote/core/types.ts | 1 + .../quote/engines/discount/offramp.ts | 10 ++--- .../quote/engines/fee/offramp-avenia.ts | 8 ++-- .../quote/engines/finalize/offramp.ts | 5 ++- .../initialize/offramp-from-evm-alfredpay.ts | 2 + .../engines/merge-subsidy/offramp-evm.ts | 44 +++++++++++++++++++ .../quote/engines/nabla-swap/offramp-evm.ts | 9 +++- .../offramp-to-pix-base.strategy.ts | 6 ++- .../strategies/offramp-to-pix.strategy.ts | 2 +- 10 files changed, 73 insertions(+), 18 deletions(-) create mode 100644 apps/api/src/api/services/quote/engines/merge-subsidy/offramp-evm.ts diff --git a/apps/api/src/api/services/quote/core/nabla.ts b/apps/api/src/api/services/quote/core/nabla.ts index 4fe771628..eec6ab7a7 100644 --- a/apps/api/src/api/services/quote/core/nabla.ts +++ b/apps/api/src/api/services/quote/core/nabla.ts @@ -135,10 +135,6 @@ export async function calculateNablaSwapOutput(request: NablaSwapRequest): Promi export async function calculateNablaSwapOutputEvm(request: NablaSwapEvmRequest): Promise { const { inputAmountForSwap, inputTokenDetails, outputTokenDetails } = request; - console.log("Calculating Nabla swap output with input amount:", inputAmountForSwap); - console.log("Input token details:", inputTokenDetails); - console.log("Output token details:", outputTokenDetails); - // Validate input amount if (!inputAmountForSwap || Big(inputAmountForSwap).lte(0)) { throw new APIError({ diff --git a/apps/api/src/api/services/quote/core/types.ts b/apps/api/src/api/services/quote/core/types.ts index 2abd193ad..bc174bd76 100644 --- a/apps/api/src/api/services/quote/core/types.ts +++ b/apps/api/src/api/services/quote/core/types.ts @@ -18,6 +18,7 @@ import { Big } from "big.js"; export enum StageKey { Initialize = "Initialize", NablaSwap = "NablaSwap", + MergeSubsidy = "MergeSubsidy", PendulumTransfer = "PendulumTransfer", HydrationSwap = "HydrationSwap", SquidRouter = "SquidRouter", diff --git a/apps/api/src/api/services/quote/engines/discount/offramp.ts b/apps/api/src/api/services/quote/engines/discount/offramp.ts index 53d151506..5f3eedb4f 100644 --- a/apps/api/src/api/services/quote/engines/discount/offramp.ts +++ b/apps/api/src/api/services/quote/engines/discount/offramp.ts @@ -13,12 +13,12 @@ export class OffRampDiscountEngine extends BaseDiscountEngine { } as const; protected validate(ctx: QuoteContext): void { - if (!ctx.nablaSwap) { - throw new Error("OffRampDiscountEngine requires nablaSwap to be defined"); + if (!ctx.nablaSwap && !ctx.nablaSwapEvm) { + throw new Error("OffRampDiscountEngine requires nablaSwap or nablaSwapEvm to be defined"); } - if (!ctx.nablaSwap.oraclePrice) { - throw new Error("OffRampDiscountEngine requires nablaSwap.oraclePrice to be defined"); + if (!ctx.nablaSwap?.oraclePrice && !ctx.nablaSwapEvm?.oraclePrice) { + throw new Error("OffRampDiscountEngine requires nablaSwap.oraclePrice or nablaSwapEvm.oraclePrice to be defined"); } if (!ctx.request.inputAmount) { @@ -28,7 +28,7 @@ export class OffRampDiscountEngine extends BaseDiscountEngine { protected async compute(ctx: QuoteContext): Promise { // biome-ignore lint/style/noNonNullAssertion: Context is validated in validate - const nablaSwap = ctx.nablaSwap!; + const nablaSwap = ctx.nablaSwap! || ctx.nablaSwapEvm!; // biome-ignore lint/style/noNonNullAssertion: Context is validated in validate const oraclePrice = nablaSwap.oraclePrice!; diff --git a/apps/api/src/api/services/quote/engines/fee/offramp-avenia.ts b/apps/api/src/api/services/quote/engines/fee/offramp-avenia.ts index 852fb097c..31b2c8a37 100644 --- a/apps/api/src/api/services/quote/engines/fee/offramp-avenia.ts +++ b/apps/api/src/api/services/quote/engines/fee/offramp-avenia.ts @@ -10,14 +10,16 @@ export class OffRampFeeAveniaEngine extends BaseFeeEngine { }; protected validate(ctx: QuoteContext): void { - if (!ctx.nablaSwap) { - throw new Error("OffRampFeeAveniaEngine requires nablaSwap in context"); + console.log("Validating OffRampFeeAveniaEngine with context:", ctx); + if (!ctx.nablaSwap && !ctx.nablaSwapEvm) { + throw new Error("OffRampFeeAveniaEngine requires nablaSwap or nablaSwapEvm in context"); } } protected async compute(ctx: QuoteContext, anchorFee: string, feeCurrency: RampCurrency): Promise { // biome-ignore lint/style/noNonNullAssertion: Context is validated in `validate` - const outputAmountOfframp = ctx.nablaSwap!.outputAmountDecimal.toFixed(2, 0); + const outputAmountOfframp = + ctx.nablaSwap?.outputAmountDecimal.toFixed(2, 0) ?? ctx.nablaSwapEvm!.outputAmountDecimal.toFixed(2, 0); const brlaApiService = BrlaApiService.getInstance(); const aveniaQuote = await brlaApiService.createPayOutQuote( diff --git a/apps/api/src/api/services/quote/engines/finalize/offramp.ts b/apps/api/src/api/services/quote/engines/finalize/offramp.ts index c7f93577e..f80526def 100644 --- a/apps/api/src/api/services/quote/engines/finalize/offramp.ts +++ b/apps/api/src/api/services/quote/engines/finalize/offramp.ts @@ -24,14 +24,15 @@ export class OffRampFinalizeEngine extends BaseFinalizeEngine { const offrampAmountBeforeAnchorFees = ctx.request.to === "pix" - ? ctx.pendulumToMoonbeamXcm?.outputAmountDecimal + ? (ctx.nablaSwapEvm?.outputAmountDecimal ?? ctx.pendulumToMoonbeamXcm?.outputAmountDecimal) : ctx.alfredpayOfframp ? ctx.alfredpayOfframp.inputAmountDecimal : ctx.pendulumToStellar?.outputAmountDecimal; if (!offrampAmountBeforeAnchorFees) { throw new APIError({ - message: "OffRampFinalizeEngine requires pendulumToMoonbeamXcm, alfredpayOfframp or pendulumToStellar output", + message: + "OffRampFinalizeEngine requires nablaSwapEvm, pendulumToMoonbeamXcm, alfredpayOfframp or pendulumToStellar output", status: httpStatus.INTERNAL_SERVER_ERROR }); } diff --git a/apps/api/src/api/services/quote/engines/initialize/offramp-from-evm-alfredpay.ts b/apps/api/src/api/services/quote/engines/initialize/offramp-from-evm-alfredpay.ts index 519efef75..fc5822b7a 100644 --- a/apps/api/src/api/services/quote/engines/initialize/offramp-from-evm-alfredpay.ts +++ b/apps/api/src/api/services/quote/engines/initialize/offramp-from-evm-alfredpay.ts @@ -20,6 +20,8 @@ export class OffRampFromEvmInitializeEngine extends BaseInitializeEngine { protected async executeInternal(ctx: QuoteContext): Promise { const req = ctx.request; + await assignPreNablaContext(ctx); + const quoteRequest: EvmBridgeQuoteRequest = { amountDecimal: req.inputAmount, fromNetwork: req.from as Networks, 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 new file mode 100644 index 000000000..34ad88614 --- /dev/null +++ b/apps/api/src/api/services/quote/engines/merge-subsidy/offramp-evm.ts @@ -0,0 +1,44 @@ +import { RampDirection } from "@vortexfi/shared"; +import Big from "big.js"; +import { QuoteContext, Stage, StageKey } from "../../core/types"; + +interface MergeSubsidyConfig { + direction: RampDirection; + skipNote: string; +} + +export class OffRampMergeSubsidyEvmEngine implements Stage { + readonly key = StageKey.MergeSubsidy; + + readonly config: MergeSubsidyConfig = { + direction: RampDirection.SELL, + skipNote: "OffRampMergeSubsidyEvmEngine: Skipped because rampType is BUY, this engine handles SELL operations only" + }; + + async execute(ctx: QuoteContext): Promise { + const { direction, skipNote } = this.config; + + if (ctx.request.rampType !== direction) { + ctx.addNote?.(skipNote); + return; + } + + if (!ctx.nablaSwapEvm) { + throw new Error("OffRampMergeSubsidyEvmEngine requires nablaSwapEvm in context"); + } + + if (!ctx.subsidy) { + throw new Error("OffRampMergeSubsidyEvmEngine requires subsidy in context"); + } + + ctx.nablaSwapEvm = { + ...ctx.nablaSwapEvm, + outputAmountDecimal: ctx.nablaSwapEvm.outputAmountDecimal.plus(ctx.subsidy.subsidyAmountInOutputTokenDecimal), + outputAmountRaw: ctx.nablaSwapEvm.outputAmountDecimal.plus(ctx.subsidy.subsidyAmountInOutputTokenRaw).toFixed(0, 0) + }; + + ctx.addNote?.( + `OffRampMergeSubsidyEvmEngine: merged subsidy ${ctx.subsidy.subsidyAmountInOutputTokenDecimal.toFixed(6)} into nablaSwapEvm output` + ); + } +} diff --git a/apps/api/src/api/services/quote/engines/nabla-swap/offramp-evm.ts b/apps/api/src/api/services/quote/engines/nabla-swap/offramp-evm.ts index dce1f7dad..f0db1bba3 100644 --- a/apps/api/src/api/services/quote/engines/nabla-swap/offramp-evm.ts +++ b/apps/api/src/api/services/quote/engines/nabla-swap/offramp-evm.ts @@ -34,7 +34,14 @@ export class OffRampSwapEngineEvm extends BaseNablaSwapEngineEvm { // We receive USDC on Base. const inputToken = EvmToken.USDC; - + console.log( + "passing through OffRampSwapEngineEvm with inputAmountPreFees:", + inputAmountPreFees.toString(), + "inputToken:", + inputToken, + "outputToken:", + this.outputToken + ); return { inputAmountPreFees, inputToken, diff --git a/apps/api/src/api/services/quote/routes/strategies/offramp-to-pix-base.strategy.ts b/apps/api/src/api/services/quote/routes/strategies/offramp-to-pix-base.strategy.ts index 25f51d32b..e314c45ed 100644 --- a/apps/api/src/api/services/quote/routes/strategies/offramp-to-pix-base.strategy.ts +++ b/apps/api/src/api/services/quote/routes/strategies/offramp-to-pix-base.strategy.ts @@ -4,21 +4,23 @@ import { OffRampDiscountEngine } from "../../engines/discount/offramp"; import { OffRampFeeAveniaEngine } from "../../engines/fee/offramp-avenia"; import { OffRampFinalizeEngine } from "../../engines/finalize/offramp"; import { OffRampFromEvmInitializeEngine } from "../../engines/initialize/offramp-from-evm-alfredpay"; +import { OffRampMergeSubsidyEvmEngine } from "../../engines/merge-subsidy/offramp-evm"; import { OffRampSwapEngineEvm } from "../../engines/nabla-swap/offramp-evm"; export class OfframpToPixEvmStrategy implements IRouteStrategy { readonly name = "OfframpToPixEvm"; getStages(_ctx: QuoteContext): StageKey[] { - return [StageKey.Initialize, StageKey.Fee, StageKey.PartnerOperation, StageKey.Finalize]; + return [StageKey.Initialize, StageKey.NablaSwap, StageKey.Fee, StageKey.Discount, StageKey.MergeSubsidy, StageKey.Finalize]; } getEngines(_ctx: QuoteContext): EnginesRegistry { return { [StageKey.Initialize]: new OffRampFromEvmInitializeEngine(Networks.Base), - [StageKey.Fee]: new OffRampFeeAveniaEngine(), [StageKey.NablaSwap]: new OffRampSwapEngineEvm(EvmToken.BRLA), + [StageKey.Fee]: new OffRampFeeAveniaEngine(), [StageKey.Discount]: new OffRampDiscountEngine(), + [StageKey.MergeSubsidy]: new OffRampMergeSubsidyEvmEngine(), [StageKey.Finalize]: new OffRampFinalizeEngine() }; } diff --git a/apps/api/src/api/services/quote/routes/strategies/offramp-to-pix.strategy.ts b/apps/api/src/api/services/quote/routes/strategies/offramp-to-pix.strategy.ts index f8067112c..2f4c825fd 100644 --- a/apps/api/src/api/services/quote/routes/strategies/offramp-to-pix.strategy.ts +++ b/apps/api/src/api/services/quote/routes/strategies/offramp-to-pix.strategy.ts @@ -27,8 +27,8 @@ export class OfframpToPixStrategy implements IRouteStrategy { ctx.request.from === "assethub" ? new OffRampFromAssethubInitializeEngine() : new OffRampFromEvmInitializeEngineMoonbeam(), - [StageKey.Fee]: new OffRampFeeAveniaEngine(), [StageKey.NablaSwap]: new OffRampSwapEngine(), + [StageKey.Fee]: new OffRampFeeAveniaEngine(), [StageKey.Discount]: new OffRampDiscountEngine(), [StageKey.PendulumTransfer]: new OffRampToAveniaPendulumTransferEngine(), [StageKey.Finalize]: new OffRampFinalizeEngine() From bfd2f8e03212bc1caec6009844b0884c974074c0 Mon Sep 17 00:00:00 2001 From: Gianfranco Date: Fri, 10 Apr 2026 12:40:59 -0300 Subject: [PATCH 12/12] patch on offramp brla flow on base --- apps/api/src/api/services/transactions/offramp/index.ts | 4 ++-- .../services/transactions/offramp/routes/evm-to-brl-base.ts | 2 +- .../api/services/transactions/onramp/common/transactions.ts | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/apps/api/src/api/services/transactions/offramp/index.ts b/apps/api/src/api/services/transactions/offramp/index.ts index 4793d7b6f..552c1786e 100644 --- a/apps/api/src/api/services/transactions/offramp/index.ts +++ b/apps/api/src/api/services/transactions/offramp/index.ts @@ -9,7 +9,7 @@ import { OfframpTransactionParams, OfframpTransactionsWithMeta } from "./common/ import { prepareAssethubToBRLOfframpTransactions } from "./routes/assethub-to-brl"; import { prepareAssethubToStellarOfframpTransactions } from "./routes/assethub-to-stellar"; import { prepareEvmToAlfredpayOfframpTransactions } from "./routes/evm-to-alfredpay"; -import { prepareEvmToBRLOfframpTransactions } from "./routes/evm-to-brl"; +import { prepareEvmToBRLOfframpBaseTransactions } from "./routes/evm-to-brl-base"; import { prepareEvmToMoneriumEvmOfframpTransactions } from "./routes/evm-to-monerium-evm"; import { prepareEvmToStellarOfframpTransactions } from "./routes/evm-to-stellar"; @@ -25,7 +25,7 @@ export async function prepareOfframpTransactions(params: OfframpTransactionParam if (quote.outputCurrency === FiatToken.BRL) { const inputTokenDetails = getOnChainTokenDetails(fromNetwork, quote.inputCurrency as OnChainToken); if (inputTokenDetails && isEvmTokenDetails(inputTokenDetails)) { - return prepareEvmToBRLOfframpTransactions(params); + return prepareEvmToBRLOfframpBaseTransactions(params); } else { return prepareAssethubToBRLOfframpTransactions(params); } 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 35b28f510..fd7c64da8 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 @@ -20,7 +20,7 @@ import { validateBRLOfframp, validateOfframpQuote } from "../common/validation"; * Prepares all transactions for an EVM to BRL offramp. * This route handles: EVM → Base (swap) → Avenia Offramp. */ -export async function prepareEvmToBRLOfframpTransactions({ +export async function prepareEvmToBRLOfframpBaseTransactions({ quote, signingAccounts, 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 c4ca61e17..0d6277d5b 100644 --- a/apps/api/src/api/services/transactions/onramp/common/transactions.ts +++ b/apps/api/src/api/services/transactions/onramp/common/transactions.ts @@ -254,7 +254,7 @@ export async function addNablaSwapTransactionsOnBase( const { quote, account, inputTokenAddress, outputTokenAddress } = params; if (!quote.metadata.nablaSwapEvm?.inputAmountForSwapRaw) { - throw new Error("Missing nablaSwap input amount in quote metadata"); + throw new Error("Missing nablaSwapEvm input amount in quote metadata"); } // The input amount for the swap was already calculated in the quote.