From 104e49e807581eddc4d81dd596619613cbc8dea7 Mon Sep 17 00:00:00 2001 From: Gianfranco Date: Mon, 6 Apr 2026 18:18:24 -0300 Subject: [PATCH 01/78] 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/78] 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/78] 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/78] 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/78] 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/78] 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/78] 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/78] 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/78] 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/78] 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/78] 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/78] 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. From 142c8496dc3381378162a1857d79a9453e3c1306 Mon Sep 17 00:00:00 2001 From: Gianfranco Date: Mon, 13 Apr 2026 12:34:49 -0300 Subject: [PATCH 13/78] patch more incorrect flows --- .../services/phases/handlers/distribute-fees-handler.ts | 4 +++- .../api/services/phases/handlers/fund-ephemeral-handler.ts | 5 +++-- .../api/services/phases/handlers/nabla-approve-handler.ts | 5 +++-- .../src/api/services/phases/handlers/nabla-swap-handler.ts | 7 ++++--- .../services/phases/handlers/squid-router-phase-handler.ts | 6 +++++- 5 files changed, 18 insertions(+), 9 deletions(-) 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 abec2cdfb..576a55821 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 @@ -61,7 +61,9 @@ export class DistributeFeesHandler extends BasePhaseHandler { } // Determine next phase - const nextPhase = state.type === RampDirection.BUY ? "subsidizePostSwap" : "subsidizePreSwap"; + const isBrlInvolved = quote.inputCurrency === "BRL" || quote.outputCurrency === "BRL"; + const nextPhase = + state.type === RampDirection.BUY ? (isBrlInvolved ? "subsidizePostSwapEvm" : "subsidizePostSwap") : "subsidizePreSwap"; // Check if we already have a hash stored const existingHash = state.state.distributeFeeHash || null; 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 5089e1d28..70c2d8d35 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 @@ -54,7 +54,8 @@ const DESTINATION_EVM_FUNDING_AMOUNTS: Record = { [Networks.BSC]: "0.000115", // ~0.1 USD @ 889 [Networks.Avalanche]: "0.0034", // ~0.1 USD @ 30 [Networks.Moonbeam]: "0.34", // ~0.1 USD @ 0.30 - [Networks.PolygonAmoy]: "0.2" // ~0.1 USD @ 0.50 + [Networks.PolygonAmoy]: "0.2", // ~0.1 USD @ 0.50 + [Networks.BaseSepolia]: "0.000034" // ~0.1 USD @ 3000 }; export class FundEphemeralPhaseHandler extends BasePhaseHandler { @@ -182,7 +183,7 @@ export class FundEphemeralPhaseHandler extends BasePhaseHandler { logger.info("Pendulum ephemeral address already funded."); } - if (isOnramp(state) && !isBaseFunded) { + if (!isBaseFunded) { logger.info(`Funding base ephemeral account ${evmEphemeralAddress}`); await this.fundEvmEphemeralAccount(state, Networks.Base); } 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 46383ce8e..a9293a5a7 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 @@ -35,12 +35,13 @@ export class NablaApprovePhaseHandler extends BasePhaseHandler { const { substrateEphemeralAddress } = state.state as StateMetadata; - if (quote.inputCurrency === FiatToken.BRL) { + // BRL flows, use evm instance of Nabla. + if (quote.inputCurrency === FiatToken.BRL || quote.outputCurrency === 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"); + throw new Error("NablaApprovePhaseHandler: Invalid state. Missing substrate ephemeral address for a non-BRL quote."); } } 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 2ba69a637..ae649c310 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 @@ -33,12 +33,12 @@ export class NablaSwapPhaseHandler extends BasePhaseHandler { const { substrateEphemeralAddress } = state.state as StateMetadata; - if (quote.inputCurrency === FiatToken.BRL) { + if (quote.inputCurrency === FiatToken.BRL || quote.outputCurrency === FiatToken.BRL) { return this.executeEvmSwap(state, quote); } else if (substrateEphemeralAddress) { return this.executeSubstrateSwap(state, quote); } else { - throw new Error("NablaSwapPhaseHandler: Neither EVM nor substrate ephemeral address found in state"); + throw new Error("NablaSwapPhaseHandler: Invalid state. Missing substrate ephemeral address for a non-BRL quote."); } } @@ -160,7 +160,8 @@ export class NablaSwapPhaseHandler extends BasePhaseHandler { logger.info(`NablaSwapPhaseHandler: EVM swap transaction successful: ${txHash}`); } catch (e) { logger.error(`Could not swap token on EVM: ${(e as Error).message}`); - throw e; + // unrecoverable by default. + throw this.createUnrecoverableError(`Could not swap token on EVM: ${(e as Error).message}`); } const isBrlInvolved = quote.inputCurrency === FiatToken.BRL || quote.outputCurrency === FiatToken.BRL; diff --git a/apps/api/src/api/services/phases/handlers/squid-router-phase-handler.ts b/apps/api/src/api/services/phases/handlers/squid-router-phase-handler.ts index 0d43c57c0..a5ccfdc13 100644 --- a/apps/api/src/api/services/phases/handlers/squid-router-phase-handler.ts +++ b/apps/api/src/api/services/phases/handlers/squid-router-phase-handler.ts @@ -53,11 +53,15 @@ export class SquidRouterPhaseHandler extends BasePhaseHandler { return state; } - // handle special "singularity" case: Alfredpay onrapm USDC in Polygon. + // handle special "singularity" cases: Alfredpay onrapm USDC in Polygon, Avania onramp USDC on Base: if (quote.to === Networks.Polygon && quote.outputCurrency === EvmToken.USDC) { return this.transitionToNextPhase(state, "destinationTransfer"); } + if (quote.to === Networks.Base && quote.outputCurrency === EvmToken.USDC) { + return this.transitionToNextPhase(state, "destinationTransfer"); + } + try { // Get the presigned transactions for this phase const approveTransaction = this.getPresignedTransaction(state, "squidRouterApprove"); From 127902a41573b5317dd65d393b258e36ee430fe6 Mon Sep 17 00:00:00 2001 From: Gianfranco Date: Mon, 13 Apr 2026 15:55:59 -0300 Subject: [PATCH 14/78] more patches after onramp testing --- .../handlers/distribute-fees-handler.ts | 4 +- .../phases/handlers/fund-ephemeral-handler.ts | 4 + .../phases/handlers/nabla-approve-handler.ts | 4 +- .../phases/handlers/nabla-swap-handler.ts | 6 +- .../squid-router-pay-phase-handler.ts | 85 +++++++++++-------- .../handlers/squid-router-phase-handler.ts | 6 +- .../subsidize-post-swap-evm-handler.ts | 20 +++-- .../transactions/common/feeDistribution.ts | 2 +- .../api/services/transactions/validation.ts | 1 + apps/api/src/models/subsidy.model.ts | 3 +- .../shared/src/services/squidrouter/config.ts | 1 + 11 files changed, 83 insertions(+), 53 deletions(-) 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 576a55821..aaba9aa2d 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 @@ -76,7 +76,7 @@ export class DistributeFeesHandler extends BasePhaseHandler { if (isEvmTransaction) { const status = await this.checkEvmTransactionStatus(existingHash).catch((_: unknown) => { - throw this.createRecoverableError("Failed to check EVM transaction status"); + throw this.createRecoverableError("Failed to check EVM transaction status from existing hash."); }); if (status === ExtrinsicStatus.Success) { @@ -87,7 +87,7 @@ export class DistributeFeesHandler extends BasePhaseHandler { } } else { const status = await this.checkExtrinsicStatus(existingHash).catch((_: unknown) => { - throw this.createRecoverableError("Failed to check extrinsic status"); + throw this.createRecoverableError("Failed to check extrinsic status from existing hash."); }); if (status === ExtrinsicStatus.Success) { 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 70c2d8d35..4aaf131ec 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 @@ -77,6 +77,10 @@ export class FundEphemeralPhaseHandler extends BasePhaseHandler { if (!isOnramp(state) && outputCurrency === FiatToken.USD) { return false; } + + if (inputCurrency === FiatToken.BRL || outputCurrency === FiatToken.BRL) { + return false; + } return true; } 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 a9293a5a7..904bfc228 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 @@ -29,7 +29,7 @@ export class NablaApprovePhaseHandler extends BasePhaseHandler { throw new Error("Quote not found for the given state"); } - if (!quote.metadata.nablaSwap) { + if (!quote.metadata.nablaSwap && !quote.metadata.nablaSwapEvm) { throw new Error("Missing nablaSwap info in quote metadata"); } @@ -131,7 +131,7 @@ export class NablaApprovePhaseHandler extends BasePhaseHandler { const baseClient = evmClientManager.getClient(Networks.Base); try { - const { txData: nablaApproveTransaction } = this.getPresignedTransaction(state, "nablaApprove"); + const { txData: nablaApproveTransaction } = this.getPresignedTransaction(state, "nablaApproveEvm"); if (typeof nablaApproveTransaction !== "string") { throw new Error("NablaApprovePhaseHandler: Invalid EVM transaction data. This is a bug."); 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 ae649c310..ca830c7c0 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 @@ -139,7 +139,7 @@ export class NablaSwapPhaseHandler extends BasePhaseHandler { const baseClient = evmClientManager.getClient(Networks.Base); try { - const { txData: nablaSwapTransaction } = this.getPresignedTransaction(state, "nablaSwap"); + const { txData: nablaSwapTransaction } = this.getPresignedTransaction(state, "nablaSwapEvm"); if (typeof nablaSwapTransaction !== "string") { throw new Error("NablaSwapPhaseHandler: Invalid EVM transaction data. This is a bug."); @@ -161,6 +161,10 @@ export class NablaSwapPhaseHandler extends BasePhaseHandler { } catch (e) { logger.error(`Could not swap token on EVM: ${(e as Error).message}`); // unrecoverable by default. + // TODO do we want to add automatic recovery? Issue is, invalid swaps now revert. + // We can add a retry with up to 1 or 2 backups. Or try to differentiate based on the revert message. + // Although, this operation should never fail with the right amount of tokens, assuming the minium can be met. + // we could call the quoter to be sure right before, a sort of dry-run. throw this.createUnrecoverableError(`Could not swap token on EVM: ${(e as Error).message}`); } diff --git a/apps/api/src/api/services/phases/handlers/squid-router-pay-phase-handler.ts b/apps/api/src/api/services/phases/handlers/squid-router-pay-phase-handler.ts index f4d9fdf3a..4e5a04bbe 100644 --- a/apps/api/src/api/services/phases/handlers/squid-router-pay-phase-handler.ts +++ b/apps/api/src/api/services/phases/handlers/squid-router-pay-phase-handler.ts @@ -21,7 +21,7 @@ import { import Big from "big.js"; import { createWalletClient, encodeFunctionData, Hash, PublicClient } from "viem"; import { privateKeyToAccount } from "viem/accounts"; -import { moonbeam, polygon } from "viem/chains"; +import { base, moonbeam, polygon } from "viem/chains"; import logger from "../../../../config/logger"; import { MOONBEAM_FUNDING_PRIVATE_KEY } from "../../../../constants/constants"; import { axelarGasServiceAbi } from "../../../../contracts/AxelarGasService"; @@ -47,18 +47,22 @@ const DEFAULT_SQUIDROUTER_GAS_ESTIMATE = "1600000"; // Estimate used to calculat export class SquidRouterPayPhaseHandler extends BasePhaseHandler { private moonbeamPublicClient: PublicClient; private polygonPublicClient: PublicClient; + private basePublicClient: PublicClient; private moonbeamWalletClient: ReturnType; private polygonWalletClient: ReturnType; + private baseWalletClient: ReturnType; constructor() { super(); const evmClientManager = EvmClientManager.getInstance(); this.moonbeamPublicClient = evmClientManager.getClient(Networks.Moonbeam); this.polygonPublicClient = evmClientManager.getClient(Networks.Polygon); + this.basePublicClient = evmClientManager.getClient(Networks.Base); const moonbeamExecutorAccount = privateKeyToAccount(MOONBEAM_FUNDING_PRIVATE_KEY as `0x${string}`); this.moonbeamWalletClient = evmClientManager.getWalletClient(Networks.Moonbeam, moonbeamExecutorAccount); this.polygonWalletClient = evmClientManager.getWalletClient(Networks.Polygon, moonbeamExecutorAccount); + this.baseWalletClient = evmClientManager.getWalletClient(Networks.Base, moonbeamExecutorAccount); } /** @@ -232,12 +236,18 @@ export class SquidRouterPayPhaseHandler extends BasePhaseHandler { payTxHash = await this.executeFundTransaction(nativeToFundRaw, swapHash as `0x${string}`, logIndex, state, quote); - const isPolygon = quote.inputCurrency !== FiatToken.BRL; - const subsidyToken = isPolygon ? SubsidyToken.MATIC : SubsidyToken.GLMR; + let subsidyToken: SubsidyToken; + let payerAccount: `0x${string}` | undefined; + + if (quote.inputCurrency === FiatToken.BRL) { + subsidyToken = SubsidyToken.ETH; + payerAccount = this.baseWalletClient.account?.address; + } else { + subsidyToken = SubsidyToken.MATIC; + payerAccount = this.polygonWalletClient.account?.address; + } + const subsidyAmount = nativeToDecimal(nativeToFundRaw, 18).toNumber(); - const payerAccount = isPolygon - ? this.polygonWalletClient.account?.address - : this.moonbeamWalletClient.account?.address; if (payerAccount) { await this.createSubsidy(state, subsidyAmount, subsidyToken, payerAccount, payTxHash); @@ -277,42 +287,43 @@ export class SquidRouterPayPhaseHandler extends BasePhaseHandler { quote: QuoteTicket ): Promise { if (quote.inputCurrency === FiatToken.BRL) { - return this.executeFundTransactionOnMoonbeam(tokenValueRaw, swapHash, logIndex); + return this.executeFundTransactionOnBase(tokenValueRaw, swapHash, logIndex); } else { return this.executeFundTransactionOnPolygon(tokenValueRaw, swapHash, logIndex); } } /** - * Execute a call to the Axelar gas service on Moonbeam network. - * @param tokenValueRaw The amount of GLMR to fund the transaction with. + * Execute a call to the Axelar gas service on Polygon network. + * @param tokenValueRaw The amount of MATIC to fund the transaction with. * @param swapHash The swap transaction hash. * @param logIndex The log index from Axelar scan. * @returns Hash of the transaction that funds the Axelar gas service. */ - private async executeFundTransactionOnMoonbeam( + private async executeFundTransactionOnPolygon( tokenValueRaw: string, swapHash: `0x${string}`, logIndex: number ): Promise { try { - const walletClientAccount = this.moonbeamWalletClient.account; + const walletClientAccount = this.polygonWalletClient.account; if (!walletClientAccount) { - throw new Error("SquidRouterPayPhaseHandler: Moonbeam wallet client account not found."); + throw new Error("SquidRouterPayPhaseHandler: Polygon wallet client account not found."); } + // Create addNativeGas transaction data const transactionData = encodeFunctionData({ abi: axelarGasServiceAbi, args: [swapHash, logIndex, walletClientAccount.address], functionName: "addNativeGas" }); - const { maxFeePerGas, maxPriorityFeePerGas } = await this.moonbeamPublicClient.estimateFeesPerGas(); + const { maxFeePerGas, maxPriorityFeePerGas } = await this.polygonPublicClient.estimateFeesPerGas(); - const gasPaymentHash = await this.moonbeamWalletClient.sendTransaction({ + const gasPaymentHash = await this.polygonWalletClient.sendTransaction({ account: walletClientAccount, - chain: moonbeam, + chain: polygon, data: transactionData, maxFeePerGas, maxPriorityFeePerGas, @@ -320,31 +331,27 @@ export class SquidRouterPayPhaseHandler extends BasePhaseHandler { value: BigInt(tokenValueRaw) }); - logger.info(`SquidRouterPayPhaseHandler: Moonbeam fund transaction sent with hash: ${gasPaymentHash}`); + logger.info(`SquidRouterPayPhaseHandler: Polygon fund transaction sent with hash: ${gasPaymentHash}`); return gasPaymentHash; } catch (error) { - logger.error("SquidRouterPayPhaseHandler: Error funding gas to Axelar gas service on Moonbeam: ", error); - throw new Error("SquidRouterPayPhaseHandler: Failed to send Moonbeam transaction"); + logger.error("SquidRouterPayPhaseHandler: Error funding gas to Axelar gas service on Polygon: ", error); + throw new Error("SquidRouterPayPhaseHandler: Failed to send Polygon transaction"); } } /** - * Execute a call to the Axelar gas service on Polygon network. - * @param tokenValueRaw The amount of MATIC to fund the transaction with. + * Execute a call to the Axelar gas service on Base network. + * @param tokenValueRaw The amount of ETH to fund the transaction with. * @param swapHash The swap transaction hash. * @param logIndex The log index from Axelar scan. * @returns Hash of the transaction that funds the Axelar gas service. */ - private async executeFundTransactionOnPolygon( - tokenValueRaw: string, - swapHash: `0x${string}`, - logIndex: number - ): Promise { + private async executeFundTransactionOnBase(tokenValueRaw: string, swapHash: `0x${string}`, logIndex: number): Promise { try { - const walletClientAccount = this.polygonWalletClient.account; + const walletClientAccount = this.baseWalletClient.account; if (!walletClientAccount) { - throw new Error("SquidRouterPayPhaseHandler: Polygon wallet client account not found."); + throw new Error("SquidRouterPayPhaseHandler: Base wallet client account not found."); } // Create addNativeGas transaction data @@ -354,31 +361,35 @@ export class SquidRouterPayPhaseHandler extends BasePhaseHandler { functionName: "addNativeGas" }); - const { maxFeePerGas, maxPriorityFeePerGas } = await this.polygonPublicClient.estimateFeesPerGas(); + const { maxFeePerGas, maxPriorityFeePerGas } = await this.basePublicClient.estimateFeesPerGas(); - const gasPaymentHash = await this.polygonWalletClient.sendTransaction({ + const gasPaymentHash = await this.baseWalletClient.sendTransaction({ account: walletClientAccount, - chain: polygon, + chain: base, data: transactionData, - maxFeePerGas, - maxPriorityFeePerGas, + maxFeePerGas: maxFeePerGas * 2n, + maxPriorityFeePerGas: maxPriorityFeePerGas * 2n, to: AXL_GAS_SERVICE_EVM as `0x${string}`, value: BigInt(tokenValueRaw) }); - logger.info(`SquidRouterPayPhaseHandler: Polygon fund transaction sent with hash: ${gasPaymentHash}`); + logger.info(`SquidRouterPayPhaseHandler: Base fund transaction sent with hash: ${gasPaymentHash}`); return gasPaymentHash; } catch (error) { - logger.error("SquidRouterPayPhaseHandler: Error funding gas to Axelar gas service on Polygon: ", error); - throw new Error("SquidRouterPayPhaseHandler: Failed to send Polygon transaction"); + logger.error("SquidRouterPayPhaseHandler: Error funding gas to Axelar gas service on Base: ", error); + throw new Error("SquidRouterPayPhaseHandler: Failed to send Base transaction"); } } private async getSquidrouterStatus(swapHash: string, state: RampState, quote: QuoteTicket): Promise { try { - // Always Polygon for Monerium onramp, Moonbeam for BRL + // Always Polygon for Monerium onramp, Base for BRL const fromChain = - quote.inputCurrency === FiatToken.EURC || quote.inputCurrency === FiatToken.USD ? Networks.Polygon : Networks.Moonbeam; + quote.inputCurrency === FiatToken.EURC || quote.inputCurrency === FiatToken.USD + ? Networks.Polygon + : quote.inputCurrency === FiatToken.BRL + ? Networks.Base + : Networks.Moonbeam; const fromChainId = getNetworkId(fromChain)?.toString(); const toChain = quote.to === Networks.AssetHub ? Networks.Moonbeam : quote.to; const toChainId = getNetworkId(toChain)?.toString(); diff --git a/apps/api/src/api/services/phases/handlers/squid-router-phase-handler.ts b/apps/api/src/api/services/phases/handlers/squid-router-phase-handler.ts index a5ccfdc13..ee8a7f493 100644 --- a/apps/api/src/api/services/phases/handlers/squid-router-phase-handler.ts +++ b/apps/api/src/api/services/phases/handlers/squid-router-phase-handler.ts @@ -20,12 +20,14 @@ import { BasePhaseHandler } from "../base-phase-handler"; export class SquidRouterPhaseHandler extends BasePhaseHandler { private moonbeamClient: PublicClient; private polygonClient: PublicClient; + private baseClient: PublicClient; constructor() { super(); const evmClientManager = EvmClientManager.getInstance(); this.moonbeamClient = evmClientManager.getClient(Networks.Moonbeam); this.polygonClient = evmClientManager.getClient(Networks.Polygon); + this.baseClient = evmClientManager.getClient(Networks.Base); } /** @@ -130,7 +132,7 @@ export class SquidRouterPhaseHandler extends BasePhaseHandler { /** * Get the appropriate public client based on the input token - * Monerium's EUR uses polygon, BRL uses moonbeam + * Monerium's EUR uses polygon, BRL uses Base * @param state The current ramp state * @returns The appropriate public client */ @@ -144,7 +146,7 @@ export class SquidRouterPhaseHandler extends BasePhaseHandler { if (quote.inputCurrency === FiatToken.EURC || quote.inputCurrency === FiatToken.USD) { return this.polygonClient; } else if (quote.inputCurrency === FiatToken.BRL) { - return this.moonbeamClient; + return this.baseClient; } else { logger.info( `SquidRouterPhaseHandler: Using Moonbeam client as default for input currency: ${quote.inputCurrency}. This is a bug.` 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 533540fd7..39ec9ba9d 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 @@ -4,7 +4,7 @@ import { EvmNetworks, EvmToken, EvmTokenDetails, - isNativeEvmToken, + getOnChainTokenDetails, Networks, nativeToDecimal, RampDirection, @@ -53,7 +53,14 @@ export class SubsidizePostSwapEvmPhaseHandler extends BasePhaseHandler { try { // Get token details for the output token - const outputTokenDetails = quote.metadata.nablaSwapEvm.outputToken as unknown as EvmTokenDetails; + const outputToken = quote.metadata.nablaSwapEvm.outputCurrency as EvmToken; + + const outputTokenDetails = getOnChainTokenDetails(Networks.Base, outputToken) as EvmTokenDetails; + if (!outputTokenDetails) { + throw new Error( + `Could not find token details for output token ${outputToken} on network ${Networks.Base}. Invalid quote metadata.` + ); + } // Check current balance on EVM const currentBalance = await checkEvmBalanceForToken({ @@ -74,6 +81,8 @@ export class SubsidizePostSwapEvmPhaseHandler extends BasePhaseHandler { quote.metadata.subsidy.subsidyAmountInOutputTokenRaw ); + console.log("debug: expectedSwapOutputAmountRaw", expectedSwapOutputAmountRaw.toString()); + // 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 @@ -83,6 +92,7 @@ export class SubsidizePostSwapEvmPhaseHandler extends BasePhaseHandler { } const requiredAmount = Big(expectedSwapOutputAmountRaw).sub(currentBalance); + console.log("debug: requiredAmount", requiredAmount.toString()); const didBalanceReachExpected = async () => { const balance = await checkEvmBalanceForToken({ @@ -142,11 +152,7 @@ export class SubsidizePostSwapEvmPhaseHandler extends BasePhaseHandler { } protected nextPhaseSelector(state: RampState, quote: QuoteTicket): RampPhase { - if (state.type === RampDirection.BUY) { - return "squidRouterSwap"; - } else { - return "squidRouterSwap"; - } + return "squidRouterSwap"; } } diff --git a/apps/api/src/api/services/transactions/common/feeDistribution.ts b/apps/api/src/api/services/transactions/common/feeDistribution.ts index f593cc4aa..602f1d18d 100644 --- a/apps/api/src/api/services/transactions/common/feeDistribution.ts +++ b/apps/api/src/api/services/transactions/common/feeDistribution.ts @@ -283,7 +283,7 @@ export async function addEvmFeeDistributionTransaction( meta: {}, network: Networks.Base, nonce: nextNonce, - phase: "distributeFees", + phase: "distributeFeesEvm", signer: account.address, txData: feeDistributionTx }); diff --git a/apps/api/src/api/services/transactions/validation.ts b/apps/api/src/api/services/transactions/validation.ts index 628897700..d5d81324d 100644 --- a/apps/api/src/api/services/transactions/validation.ts +++ b/apps/api/src/api/services/transactions/validation.ts @@ -65,6 +65,7 @@ function getTransactionTypeForPhase(phase: RampPhase | CleanupPhase): EphemeralA case "squidRouterSwap": case "nablaApproveEvm": case "nablaSwapEvm": + case "distributeFeesEvm": return EphemeralAccountType.EVM; default: return EphemeralAccountType.EVM; diff --git a/apps/api/src/models/subsidy.model.ts b/apps/api/src/models/subsidy.model.ts index d8b8a2167..e4bd896e8 100644 --- a/apps/api/src/models/subsidy.model.ts +++ b/apps/api/src/models/subsidy.model.ts @@ -12,7 +12,8 @@ export enum SubsidyToken { EURC = "EURC", USDC = "USDC", MATIC = "MATIC", - BRL = "BRL" + BRL = "BRL", + ETH = "ETH" } export interface SubsidyAttributes { diff --git a/packages/shared/src/services/squidrouter/config.ts b/packages/shared/src/services/squidrouter/config.ts index 91a1d0b2d..bb7260fe5 100644 --- a/packages/shared/src/services/squidrouter/config.ts +++ b/packages/shared/src/services/squidrouter/config.ts @@ -4,6 +4,7 @@ import { AXL_USDC_MOONBEAM } from "../../tokens/moonbeam/config"; export const SQUIDROUTER_FEE_OVERPAY = 0.25; // 25% overpayment export const MOONBEAM_SQUIDROUTER_SWAP_MIN_VALUE_RAW = "10000000000000000"; // 0.01 GLMR in raw units export const POLYGON_SQUIDROUTER_SWAP_MIN_VALUE_RAW = "10000000000000000"; // 0.01 MATIC in raw units +export const BASE_SQUIDROUTER_SWAP_MIN_VALUE_RAW = "10000000000000"; // 0.00001 ETH in raw units interface ConfigBase { toChainId: string; From 688c2e4f8eb131c12a6a52145ffa6e5a5f796ff0 Mon Sep 17 00:00:00 2001 From: Gianfranco Date: Mon, 13 Apr 2026 16:47:12 -0300 Subject: [PATCH 15/78] use proper squidrouter funding value --- .../phases/handlers/fund-ephemeral-handler.ts | 14 +++++++++----- apps/api/src/constants/constants.ts | 5 ++++- packages/shared/src/services/squidrouter/onramp.ts | 8 ++++++-- 3 files changed, 19 insertions(+), 8 deletions(-) diff --git a/apps/api/src/api/services/phases/handlers/fund-ephemeral-handler.ts b/apps/api/src/api/services/phases/handlers/fund-ephemeral-handler.ts index 4aaf131ec..d76213c9c 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 @@ -12,7 +12,11 @@ import { import { NetworkError, Transaction } from "stellar-sdk"; import { privateKeyToAccount } from "viem/accounts"; import logger from "../../../../config/logger"; -import { MOONBEAM_FUNDING_PRIVATE_KEY, POLYGON_EPHEMERAL_STARTING_BALANCE_UNITS } from "../../../../constants/constants"; +import { + BASE_EPHEMERAL_STARTING_BALANCE_UNITS, + MOONBEAM_FUNDING_PRIVATE_KEY, + POLYGON_EPHEMERAL_STARTING_BALANCE_UNITS +} from "../../../../constants/constants"; import QuoteTicket from "../../../../models/quoteTicket.model"; import RampState from "../../../../models/rampState.model"; @@ -317,11 +321,11 @@ export class FundEphemeralPhaseHandler extends BasePhaseHandler { throw new Error(`FundEphemeralPhaseHandler: Could not get chain info for ${network}`); } + const amountToFundUnits = + network === Networks.Polygon ? POLYGON_EPHEMERAL_STARTING_BALANCE_UNITS : BASE_EPHEMERAL_STARTING_BALANCE_UNITS; + const ephmeralAddress = state.state.evmEphemeralAddress; - const fundingAmountRaw = multiplyByPowerOfTen( - POLYGON_EPHEMERAL_STARTING_BALANCE_UNITS, - chain.nativeCurrency.decimals - ).toFixed(); + const fundingAmountRaw = multiplyByPowerOfTen(amountToFundUnits, chain.nativeCurrency.decimals).toFixed(); // We use Moonbeam's funding account to fund the ephemeral account on the network. const fundingAccount = privateKeyToAccount(MOONBEAM_FUNDING_PRIVATE_KEY as `0x${string}`); diff --git a/apps/api/src/constants/constants.ts b/apps/api/src/constants/constants.ts index fa6ef607f..a7fa52aef 100644 --- a/apps/api/src/constants/constants.ts +++ b/apps/api/src/constants/constants.ts @@ -9,6 +9,8 @@ const STELLAR_EPHEMERAL_STARTING_BALANCE_UNITS = "2.5"; // Amount to send to the const PENDULUM_EPHEMERAL_STARTING_BALANCE_UNITS = "0.1"; // Amount to send to the new pendulum ephemeral account created const MOONBEAM_EPHEMERAL_STARTING_BALANCE_UNITS = "1"; // Amount to send to the new moonbeam ephemeral account created const POLYGON_EPHEMERAL_STARTING_BALANCE_UNITS = "1.5"; // Amount to send to the new polygon ephemeral account created +const BASE_EPHEMERAL_STARTING_BALANCE_UNITS = "0.00015"; // Amount to send to the new base ephemeral account created + const DEFAULT_POLLING_INTERVAL = 3000; const GLMR_FUNDING_AMOUNT_RAW = "50000000000000000"; const ASSETHUB_XCM_FEE_USDC_UNITS = 0.013124; @@ -81,5 +83,6 @@ export { DEFAULT_POLLING_INTERVAL, STELLAR_BASE_FEE, SANDBOX_ENABLED, - MAX_FINAL_SETTLEMENT_SUBSIDY_USD + MAX_FINAL_SETTLEMENT_SUBSIDY_USD, + BASE_EPHEMERAL_STARTING_BALANCE_UNITS }; diff --git a/packages/shared/src/services/squidrouter/onramp.ts b/packages/shared/src/services/squidrouter/onramp.ts index 57619d852..db02c1d4d 100644 --- a/packages/shared/src/services/squidrouter/onramp.ts +++ b/packages/shared/src/services/squidrouter/onramp.ts @@ -14,7 +14,11 @@ import { Networks, SquidrouterRoute } from "../.."; -import { MOONBEAM_SQUIDROUTER_SWAP_MIN_VALUE_RAW, POLYGON_SQUIDROUTER_SWAP_MIN_VALUE_RAW } from "./config"; +import { + BASE_SQUIDROUTER_SWAP_MIN_VALUE_RAW, + MOONBEAM_SQUIDROUTER_SWAP_MIN_VALUE_RAW, + POLYGON_SQUIDROUTER_SWAP_MIN_VALUE_RAW +} from "./config"; import { getRoute } from "./route"; import { createGenericRouteParams } from "./route-params"; import { createTransactionDataFromRoute } from "./route-transactions"; @@ -149,7 +153,7 @@ export async function createOnrampSquidrouterTransactionsFromBaseToEvm( publicClient: baseClient, rawAmount: params.rawAmount, route, - swapValue: POLYGON_SQUIDROUTER_SWAP_MIN_VALUE_RAW // TODO do we need a different min value for Base? + swapValue: BASE_SQUIDROUTER_SWAP_MIN_VALUE_RAW }); return { From 0d7b006e3a61f1f01e243f29301990c7f0e44336 Mon Sep 17 00:00:00 2001 From: Gianfranco Date: Wed, 15 Apr 2026 13:33:52 -0300 Subject: [PATCH 16/78] adjust brla offramp on base, new phases --- .../handlers/distribute-fees-handler.ts | 8 +- .../subsidize-post-swap-evm-handler.ts | 8 +- .../subsidize-pre-swap-evm-handler.ts | 134 ++++++++++++++++++ .../api/services/phases/register-handlers.ts | 2 + .../engines/merge-subsidy/offramp-evm.ts | 2 +- .../shared/src/endpoints/ramp.endpoints.ts | 1 + .../src/services/squidrouter/route-cache.ts | 2 +- .../shared/src/services/squidrouter/route.ts | 5 +- packages/shared/src/tokens/evm/config.ts | 4 +- 9 files changed, 155 insertions(+), 11 deletions(-) create mode 100644 apps/api/src/api/services/phases/handlers/subsidize-pre-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 aaba9aa2d..98e6ea1ca 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 @@ -63,7 +63,13 @@ export class DistributeFeesHandler extends BasePhaseHandler { // Determine next phase const isBrlInvolved = quote.inputCurrency === "BRL" || quote.outputCurrency === "BRL"; const nextPhase = - state.type === RampDirection.BUY ? (isBrlInvolved ? "subsidizePostSwapEvm" : "subsidizePostSwap") : "subsidizePreSwap"; + state.type === RampDirection.BUY + ? isBrlInvolved + ? "subsidizePostSwapEvm" + : "subsidizePostSwap" + : isBrlInvolved + ? "subsidizePreSwapEvm" + : "subsidizePreSwap"; // Check if we already have a hash stored const existingHash = state.state.distributeFeeHash || null; 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 39ec9ba9d..433802ed7 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 @@ -88,7 +88,7 @@ export class SubsidizePostSwapEvmPhaseHandler extends BasePhaseHandler { // 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."); + expectedSwapOutputAmountRaw = Big(quote.metadata.nablaSwapEvm.outputAmountRaw); } const requiredAmount = Big(expectedSwapOutputAmountRaw).sub(currentBalance); @@ -152,7 +152,11 @@ export class SubsidizePostSwapEvmPhaseHandler extends BasePhaseHandler { } protected nextPhaseSelector(state: RampState, quote: QuoteTicket): RampPhase { - return "squidRouterSwap"; + if (state.type === RampDirection.BUY) { + return "squidRouterSwap"; + } else { + return "brlaPayoutOnBase"; + } } } diff --git a/apps/api/src/api/services/phases/handlers/subsidize-pre-swap-evm-handler.ts b/apps/api/src/api/services/phases/handlers/subsidize-pre-swap-evm-handler.ts new file mode 100644 index 000000000..9b6ad8c68 --- /dev/null +++ b/apps/api/src/api/services/phases/handlers/subsidize-pre-swap-evm-handler.ts @@ -0,0 +1,134 @@ +import { + checkEvmBalanceForToken, + EvmClientManager, + EvmNetworks, + EvmToken, + EvmTokenDetails, + getOnChainTokenDetails, + 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 SubsidizePreSwapEvmPhaseHandler extends BasePhaseHandler { + public getPhaseName(): RampPhase { + return "subsidizePreSwapEvm"; + } + + 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("SubsidizePreSwapEvmPhaseHandler: State metadata corrupted. This is a bug."); + } + + if (!quote.metadata.nablaSwapEvm) { + throw new Error("Missing nablaSwapEvm information in quote metadata"); + } + + try { + // Get token details for the input token + const inputToken = quote.metadata.nablaSwapEvm.inputCurrency as EvmToken; + + const inputTokenDetails = getOnChainTokenDetails(Networks.Base, inputToken) as EvmTokenDetails; + if (!inputTokenDetails) { + throw new Error( + `Could not find token details for input token ${inputToken} on network ${Networks.Base}. Invalid quote metadata.` + ); + } + + // Check current balance on EVM + const currentBalance = await checkEvmBalanceForToken({ + amountDesiredRaw: "1", + chain: inputTokenDetails.network as EvmNetworks, + intervalMs: 1000, // Just check if there's any balance + ownerAddress: evmEphemeralAddress, + timeoutMs: 5000, + tokenDetails: inputTokenDetails + }); + + if (currentBalance.eq(Big(0))) { + throw new Error("Invalid phase: input token did not arrive yet on EVM"); + } + + const expectedInputAmountForSwapRaw = quote.metadata.nablaSwapEvm.inputAmountForSwapRaw; + + const requiredAmount = Big(expectedInputAmountForSwapRaw).sub(currentBalance); + console.log("debug: requiredAmount", requiredAmount.toString()); + + const didBalanceReachExpected = async () => { + const balance = await checkEvmBalanceForToken({ + amountDesiredRaw: expectedInputAmountForSwapRaw.toString(), + chain: inputTokenDetails.network as EvmNetworks, + intervalMs: 1000, + ownerAddress: evmEphemeralAddress, + timeoutMs: 5000, + tokenDetails: inputTokenDetails + }); + return balance.gte(Big(expectedInputAmountForSwapRaw)); + }; + + if (requiredAmount.gt(Big(0))) { + // Do the actual subsidizing on EVM + logger.info( + `Subsidizing pre-swap EVM with ${requiredAmount.toFixed()} to reach target value of ${expectedInputAmountForSwapRaw}` + ); + + const evmClientManager = EvmClientManager.getInstance(); + const fundingAccount = privateKeyToAccount(MOONBEAM_FUNDING_PRIVATE_KEY as `0x${string}`); + const destinationNetwork = inputTokenDetails.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: inputTokenDetails.erc20AddressSourceChain as `0x${string}`, + value: 0n + }); + + const subsidyAmount = nativeToDecimal(requiredAmount, quote.metadata.nablaSwapEvm.inputDecimals).toNumber(); + const subsidyToken = quote.metadata.nablaSwapEvm.inputCurrency as unknown as SubsidyToken; + + await this.createSubsidy(state, subsidyAmount, subsidyToken, fundingAccount.address, txHash); + + // Wait for the balance to update + await waitUntilTrueWithTimeout(didBalanceReachExpected, 2000); + } + + return this.transitionToNextPhase(state, "nablaApprove"); + } catch (e) { + logger.error("Error in subsidizePreSwapEvm:", e); + throw this.createRecoverableError("SubsidizePreSwapEvmPhaseHandler: Failed to subsidize pre swap on EVM."); + } + } +} + +export default new SubsidizePreSwapEvmPhaseHandler(); diff --git a/apps/api/src/api/services/phases/register-handlers.ts b/apps/api/src/api/services/phases/register-handlers.ts index 31f0afc21..6ea9e788e 100644 --- a/apps/api/src/api/services/phases/register-handlers.ts +++ b/apps/api/src/api/services/phases/register-handlers.ts @@ -26,6 +26,7 @@ import squidRouterPermitExecutionHandler from "./handlers/squidrouter-permit-exe 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 subsidizePreSwapEvmPhaseHandler from "./handlers/subsidize-pre-swap-evm-handler"; import subsidizePreSwapPhaseHandler from "./handlers/subsidize-pre-swap-handler"; import phaseRegistry from "./phase-registry"; @@ -45,6 +46,7 @@ export function registerPhaseHandlers(): void { phaseRegistry.registerHandler(subsidizePostSwapPhaseHandler); phaseRegistry.registerHandler(subsidizePostSwapEvmPhaseHandler); phaseRegistry.registerHandler(subsidizePreSwapPhaseHandler); + phaseRegistry.registerHandler(subsidizePreSwapEvmPhaseHandler); phaseRegistry.registerHandler(moonbeamToPendulumPhaseHandler); phaseRegistry.registerHandler(brlaPayoutBaseHandler); phaseRegistry.registerHandler(fundEphemeralHandler); diff --git a/apps/api/src/api/services/quote/engines/merge-subsidy/offramp-evm.ts b/apps/api/src/api/services/quote/engines/merge-subsidy/offramp-evm.ts index 34ad88614..5ba2a43b1 100644 --- a/apps/api/src/api/services/quote/engines/merge-subsidy/offramp-evm.ts +++ b/apps/api/src/api/services/quote/engines/merge-subsidy/offramp-evm.ts @@ -34,7 +34,7 @@ export class OffRampMergeSubsidyEvmEngine implements Stage { ctx.nablaSwapEvm = { ...ctx.nablaSwapEvm, outputAmountDecimal: ctx.nablaSwapEvm.outputAmountDecimal.plus(ctx.subsidy.subsidyAmountInOutputTokenDecimal), - outputAmountRaw: ctx.nablaSwapEvm.outputAmountDecimal.plus(ctx.subsidy.subsidyAmountInOutputTokenRaw).toFixed(0, 0) + outputAmountRaw: (BigInt(ctx.nablaSwapEvm.outputAmountRaw) + BigInt(ctx.subsidy.subsidyAmountInOutputTokenRaw)).toString() }; ctx.addNote?.( diff --git a/packages/shared/src/endpoints/ramp.endpoints.ts b/packages/shared/src/endpoints/ramp.endpoints.ts index 69722c69c..891a02a75 100644 --- a/packages/shared/src/endpoints/ramp.endpoints.ts +++ b/packages/shared/src/endpoints/ramp.endpoints.ts @@ -37,6 +37,7 @@ export type RampPhase = | "spacewalkRedeem" | "stellarPayment" | "subsidizePreSwap" + | "subsidizePreSwapEvm" | "subsidizePostSwap" | "subsidizePostSwapEvm" | "distributeFees" diff --git a/packages/shared/src/services/squidrouter/route-cache.ts b/packages/shared/src/services/squidrouter/route-cache.ts index 2dfb10728..35d18edc2 100644 --- a/packages/shared/src/services/squidrouter/route-cache.ts +++ b/packages/shared/src/services/squidrouter/route-cache.ts @@ -40,8 +40,8 @@ export function stripRouteForCache(result: SquidrouterRouteResult): SquidrouterC return { data: { route: { - quoteId: result.data.route.quoteId, estimate: result.data.route.estimate, + quoteId: result.data.route.quoteId, transactionRequest: { value: result.data.route.transactionRequest.value } diff --git a/packages/shared/src/services/squidrouter/route.ts b/packages/shared/src/services/squidrouter/route.ts index e8861568c..fae8eeb66 100644 --- a/packages/shared/src/services/squidrouter/route.ts +++ b/packages/shared/src/services/squidrouter/route.ts @@ -118,10 +118,7 @@ const routeQueues = new Map(); * When useCache is true, returns a stripped-down SquidrouterCachedRouteResult without transactionRequest. * When useCache is false or not specified (default), returns the full SquidrouterRouteResult. */ -export async function getRoute( - params: RouteParams, - options: { useCache: true } -): Promise; +export async function getRoute(params: RouteParams, options: { useCache: true }): Promise; export async function getRoute(params: RouteParams, options?: { useCache?: false }): Promise; export async function getRoute( params: RouteParams, diff --git a/packages/shared/src/tokens/evm/config.ts b/packages/shared/src/tokens/evm/config.ts index 45630e27f..f2ea1c83a 100644 --- a/packages/shared/src/tokens/evm/config.ts +++ b/packages/shared/src/tokens/evm/config.ts @@ -212,7 +212,7 @@ export const evmTokenConfig: Record Date: Wed, 15 Apr 2026 14:51:05 -0300 Subject: [PATCH 17/78] add final destination transaction to avenia deposit evm address on base --- .../handlers/brla-payout-base-handler.ts | 99 +++++++++++++++++-- .../api/services/phases/meta-state-types.ts | 1 + .../offramp/routes/evm-to-brl-base.ts | 26 ++++- .../typechain-types/contracts/TokenRelayer.ts | 2 +- 4 files changed, 119 insertions(+), 9 deletions(-) diff --git a/apps/api/src/api/services/phases/handlers/brla-payout-base-handler.ts b/apps/api/src/api/services/phases/handlers/brla-payout-base-handler.ts index 807657d32..46a19af72 100644 --- a/apps/api/src/api/services/phases/handlers/brla-payout-base-handler.ts +++ b/apps/api/src/api/services/phases/handlers/brla-payout-base-handler.ts @@ -1,4 +1,12 @@ -import { AveniaTicketStatus, BrlaApiService, isFiatTokenEnum, PixOutputTicketPayload, RampPhase } from "@vortexfi/shared"; +import { + AveniaTicketStatus, + BrlaApiService, + EvmClientManager, + isFiatTokenEnum, + Networks, + PixOutputTicketPayload, + RampPhase +} from "@vortexfi/shared"; import Big from "big.js"; import logger from "../../../../config/logger"; import QuoteTicket from "../../../../models/quoteTicket.model"; @@ -14,7 +22,7 @@ export class BrlaPayoutOnBasePhaseHandler extends BasePhaseHandler { } protected async executePhase(state: RampState): Promise { - const { taxId, pixDestination, payOutTicketId } = state.state as StateMetadata; + const { taxId, pixDestination, payOutTicketId, brlaPayoutTxHash } = state.state as StateMetadata; if (!taxId || !pixDestination) { throw new Error("BrlaPayoutOnMoonbeamPhaseHandler: State metadata corrupted. This is a bug."); @@ -37,11 +45,11 @@ export class BrlaPayoutOnBasePhaseHandler extends BasePhaseHandler { throw new Error("BrlaPayoutOnMoonbeamPhaseHandler: Invalid token type."); } - if (!quote.metadata.pendulumToMoonbeamXcm?.outputAmountDecimal) { - throw new Error("BrlaPayoutOnMoonbeamPhaseHandler: Missing pendulumToMoonbeamXcm metadata."); + if (!quote.metadata.nablaSwapEvm?.outputAmountDecimal) { + throw new Error("BrlaPayoutOnMoonbeamPhaseHandler: Missing nablaSwapEvm metadata."); } - const amountForPayout = quote.metadata.pendulumToMoonbeamXcm.outputAmountDecimal; + const amountForPayout = quote.metadata.nablaSwapEvm.outputAmountDecimal; const brlaApiService = BrlaApiService.getInstance(); @@ -51,6 +59,9 @@ export class BrlaPayoutOnBasePhaseHandler extends BasePhaseHandler { return this.transitionToNextPhase(state, "complete"); } + // send the "final destination" + await this.sendBrlaPayoutTransaction(state, brlaPayoutTxHash); + const pollForSufficientBalance = async () => { const pollInterval = 5000; // 5 seconds const timeout = 5 * 60 * 1000; // 5 minutes @@ -104,7 +115,6 @@ export class BrlaPayoutOnBasePhaseHandler extends BasePhaseHandler { subAccountId: taxIdRecord.subAccountId }); - logger.debug("Debug: payOutQuote", payOutQuote); const payOutTicketParams: PixOutputTicketPayload = { quoteToken: payOutQuote.quoteToken, ticketBlockchainInput: { @@ -114,8 +124,10 @@ export class BrlaPayoutOnBasePhaseHandler extends BasePhaseHandler { pixKey: pixDestination } }; + console.log("payOutTicketParams: ", payOutTicketParams); + const payOutTicketId = "mocked-ticket-id-for-now"; - const { id: payOutTicketId } = await brlaApiService.createPixOutputTicket(payOutTicketParams, taxIdRecord.subAccountId); + //const { id: payOutTicketId } = await brlaApiService.createPixOutputTicket(payOutTicketParams, taxIdRecord.subAccountId); logger.debug("Debug: payOutTicketId", payOutTicketId); // Update the state with the transaction hashes await state.update({ @@ -133,6 +145,79 @@ export class BrlaPayoutOnBasePhaseHandler extends BasePhaseHandler { } } + private async sendBrlaPayoutTransaction(state: RampState, brlaPayoutTxHash?: `0x${string}`): Promise { + try { + const evmClientManager = EvmClientManager.getInstance(); + const baseClient = evmClientManager.getClient(Networks.Base); + const { txData: brlaPayoutTx } = this.getPresignedTransaction(state, "brlaPayoutOnBase"); + + if (!brlaPayoutTx) { + throw new Error("Missing presigned transaction for brlaPayoutOnBase"); + } + + let txHash: `0x${string}`; + + if (brlaPayoutTxHash) { + // Check existing transaction status + logger.info( + `BrlaPayoutOnBasePhaseHandler: Found existing transaction hash ${brlaPayoutTxHash}. Waiting for receipt...` + ); + const receipt = await baseClient.waitForTransactionReceipt({ hash: brlaPayoutTxHash }); + + if (receipt.status !== "success") { + logger.warn( + `BrlaPayoutOnBasePhaseHandler: Existing transaction ${brlaPayoutTxHash} failed. Sending new transaction...` + ); + + txHash = (await evmClientManager.sendRawTransactionWithRetry( + Networks.Base, + brlaPayoutTx as `0x${string}` + )) as `0x${string}`; + + const newReceipt = await baseClient.waitForTransactionReceipt({ hash: txHash }); + + if (newReceipt.status !== "success") { + throw new Error(`Transaction ${txHash} failed on chain`); + } + logger.info(`BrlaPayoutOnBasePhaseHandler: New transaction ${txHash} succeeded.`); + + await state.update({ + state: { + ...state.state, + brlaPayoutTxHash: txHash + } + }); + } else { + logger.info(`BrlaPayoutOnBasePhaseHandler: Existing transaction ${brlaPayoutTxHash} succeeded.`); + txHash = brlaPayoutTxHash; + } + } else { + txHash = (await evmClientManager.sendRawTransactionWithRetry( + Networks.Base, + brlaPayoutTx as `0x${string}` + )) as `0x${string}`; + logger.info(`BrlaPayoutOnBasePhaseHandler: Transaction sent with hash ${txHash}. Waiting for receipt...`); + const receipt = await baseClient.waitForTransactionReceipt({ hash: txHash }); + + if (receipt.status !== "success") { + throw new Error(`Transaction ${txHash} failed on chain`); + } + logger.info(`BrlaPayoutOnBasePhaseHandler: Transaction ${txHash} succeeded.`); + + // Store hash in state + await state.update({ + state: { + ...state.state, + brlaPayoutTxHash: txHash + } + }); + } + } catch (error) { + logger.error("BrlaPayoutOnBasePhaseHandler: Failed to send BRLA payout transaction.", error); + throw this.createRecoverableError("Failed to send BRLA payout transaction"); + } + } + protected async checkTicketStatusPaid({ ticketId, subAccountId diff --git a/apps/api/src/api/services/phases/meta-state-types.ts b/apps/api/src/api/services/phases/meta-state-types.ts index 1b4b71879..cd2d3d655 100644 --- a/apps/api/src/api/services/phases/meta-state-types.ts +++ b/apps/api/src/api/services/phases/meta-state-types.ts @@ -49,6 +49,7 @@ export interface StateMetadata { unhandledPaymentAlertSent: boolean; depositQrCode: string | undefined; payOutTicketId: string | undefined; + brlaPayoutTxHash?: `0x${string}`; // Only used in onramp, offramp - monerium moneriumOnrampPermit?: PermitSignature; permitTxHash?: string; 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 fd7c64da8..7d092dcee 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 @@ -12,7 +12,7 @@ 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 { addNablaSwapTransactionsOnBase, addOnrampDestinationChainTransactions } from "../../onramp/common/transactions"; import { OfframpTransactionParams, OfframpTransactionsWithMeta } from "../common/types"; import { validateBRLOfframp, validateOfframpQuote } from "../common/validation"; @@ -121,6 +121,30 @@ export async function prepareEvmToBRLOfframpBaseTransactions({ // Fee distribution transaction on EVM baseNonce = await addEvmFeeDistributionTransaction(quote, evmEphemeralEntry, unsignedTxs, baseNonce); + // Output after swap + discount and subsidy + const brlaTransferAmountRaw = quote.metadata.nablaSwapEvm?.outputAmountRaw; + if (!brlaTransferAmountRaw) { + throw new Error("Missing outputAmountRaw in nablaSwapEvm metadata"); + } + + const finalDestinationTransfer = await addOnrampDestinationChainTransactions({ + amountRaw: brlaTransferAmountRaw, + destinationNetwork: Networks.Base, + isNativeToken: false, + toAddress: validatedBrlaEvmAddress, + toToken: baseBrlaAddress as `0x${string}` + }); + + unsignedTxs.push({ + meta: {}, + network: Networks.Base, + nonce: baseNonce, + phase: "brlaPayoutOnBase", + signer: evmEphemeralEntry.address, + txData: encodeEvmTransactionData(finalDestinationTransfer) as EvmTransactionData + }); + baseNonce++; + stateMeta = { ...stateMeta, brlaEvmAddress: validatedBrlaEvmAddress, diff --git a/contracts/relayer/typechain-types/contracts/TokenRelayer.ts b/contracts/relayer/typechain-types/contracts/TokenRelayer.ts index 9d405d34a..c7019692c 100644 --- a/contracts/relayer/typechain-types/contracts/TokenRelayer.ts +++ b/contracts/relayer/typechain-types/contracts/TokenRelayer.ts @@ -124,7 +124,7 @@ export interface TokenRelayerInterface extends Interface { export namespace EIP712DomainChangedEvent { export type InputTuple = []; export type OutputTuple = []; - export interface OutputObject {} + export type OutputObject = {}; export type Event = TypedContractEvent; export type Filter = TypedDeferredTopicFilter; export type Log = TypedEventLog; From 7b2e3391685d42078b935e62f6a240782466b780 Mon Sep 17 00:00:00 2001 From: Gianfranco Date: Wed, 15 Apr 2026 16:07:31 -0300 Subject: [PATCH 18/78] update ramp journey --- docs/architecture/ramp-journey-and-fees.md | 36 ++++++++++------------ 1 file changed, 17 insertions(+), 19 deletions(-) diff --git a/docs/architecture/ramp-journey-and-fees.md b/docs/architecture/ramp-journey-and-fees.md index 1c9987903..7ad49868a 100644 --- a/docs/architecture/ramp-journey-and-fees.md +++ b/docs/architecture/ramp-journey-and-fees.md @@ -253,35 +253,27 @@ graph TD M_Start[moneriumOnrampMint] --> M_Fund[fundEphemeral] --> M_Transfer[moneriumOnrampSelfTransfer] --> M_Dest{Destination?}; end - subgraph BRLA_Flow [BRLA BRL] + subgraph BRLA_Flow [BRLA BRL on Base] direction LR - B_Start[brlaOnrampMint] --> B_FUND[fundEphemeral] --> B_to_P[moonbeamToPendulumXcm]; + B_Mint[brlaOnrampMint] --> B_Fund[fundEphemeral] --> B_Nabla[nablaSwap] --> B_Dist[distributeFees] --> B_PostSub[subsidizePostSwapEvm]; end %% --- All non-AssetHub paths enter the shared EVM settlement subgraph --- AF_Fund --> SES_Swap; M_Dest -->|EVM| SES_Swap; - B_to_M[pendulumToMoonbeamXcm] --> SES_Swap; + B_PostSub --> SES_Swap; %% --- Monerium AssetHub path (dedicated squid nodes, different destination) --- M_Dest -->|AssetHub| M_AH_Swap[squidRouterSwap - Moonbeam] --> M_AH_Pay[squidRouterPay] --> M_to_P[moonbeamToPendulum]; %% --- Connections to/from Common Pendulum Swap Flow --- M_to_P --> PS_Start; - B_to_P --> PS_Start; - PS_post --> Post_Swap_Router{Source Flow?}; - - %% --- Diverging paths after Pendulum Swap --- - Post_Swap_Router -->|From Monerium| AHF_Start; - Post_Swap_Router -->|From BRLA| BRLA_Post_Swap_Dest{Destination?}; - - BRLA_Post_Swap_Dest -->|AssetHub| AHF_Start; - BRLA_Post_Swap_Dest -->|EVM| B_to_M; + PS_post --> AHF_Start; end subgraph "Off-Ramp" direction LR - M_off[Start Off-Ramp] --> N_off{Input Source?}; + M_off[Start Off-Ramp] --> N_off{Flow?}; %% --- Alfredpay Off-Ramp Flow --- N_off -->|Alfredpay| AF_Off_Permit[squidRouterPermitExecute]; @@ -290,7 +282,17 @@ graph TD AF_Off_Subsidy --> AF_Off_Transfer[alfredpayOfframpTransfer]; AF_Off_Transfer --> Y_off[Complete]; - %% --- Standard Off-Ramp Flows --- + %% --- BRLA Off-Ramp Flow on Base --- + N_off -->|BRL| B_Off_Squid[squidRouterSwap_user]; + B_Off_Squid --> B_Off_Fund[fundEphemeral]; + B_Off_Fund --> B_Off_Dist[distributeFees]; + B_Off_Dist --> B_Off_Pre[subsidizePreSwapEvm]; + B_Off_Pre --> B_Off_Nabla[nablaSwap]; + B_Off_Nabla --> B_Off_Post[subsidizePostSwapEvm]; + B_Off_Post --> B_Off_Payout[brlaPayoutBase]; + B_Off_Payout --> Y_off; + + %% --- Standard Off-Ramp Flows (EUR/ARS) --- N_off -->|EVM| O_off[moonbeamToPendulum]; N_off -->|AssetHub| P_off[distributeFees_assethub]; O_off --> Q_off[distributeFees_evm]; @@ -299,11 +301,7 @@ graph TD R_off --> S_off[nablaApprove]; S_off --> T_off[nablaSwap]; T_off --> U_off[subsidizePostSwap]; - U_off --> V_off{Output Fiat?}; - V_off -->|BRL| W_off[pendulumToMoonbeam]; - W_off --> X_off[brlaPayoutOnMoonbeam]; - X_off --> Y_off; - V_off -->|EUR/ARS| Z_off[spacewalkRedeem]; + U_off --> Z_off[spacewalkRedeem]; Z_off --> AA_off[stellarPayment]; AA_off --> Y_off; end From 04bcfd7eb6af2234ac9ca8a1cff42f8877c8e076 Mon Sep 17 00:00:00 2001 From: gianfra-t <96739519+gianfra-t@users.noreply.github.com> Date: Wed, 15 Apr 2026 16:09:11 -0300 Subject: [PATCH 19/78] Potential fix for pull request finding 'Unused variable, import, function or class' Co-authored-by: Copilot Autofix powered by AI <223894421+github-code-quality[bot]@users.noreply.github.com> --- .../services/phases/handlers/squid-router-pay-phase-handler.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/api/src/api/services/phases/handlers/squid-router-pay-phase-handler.ts b/apps/api/src/api/services/phases/handlers/squid-router-pay-phase-handler.ts index 4e5a04bbe..6e6a6e092 100644 --- a/apps/api/src/api/services/phases/handlers/squid-router-pay-phase-handler.ts +++ b/apps/api/src/api/services/phases/handlers/squid-router-pay-phase-handler.ts @@ -21,7 +21,7 @@ import { import Big from "big.js"; import { createWalletClient, encodeFunctionData, Hash, PublicClient } from "viem"; import { privateKeyToAccount } from "viem/accounts"; -import { base, moonbeam, polygon } from "viem/chains"; +import { base, polygon } from "viem/chains"; import logger from "../../../../config/logger"; import { MOONBEAM_FUNDING_PRIVATE_KEY } from "../../../../constants/constants"; import { axelarGasServiceAbi } from "../../../../contracts/AxelarGasService"; From b72857cce02f1963a868c19352ced4847906559e Mon Sep 17 00:00:00 2001 From: gianfra-t <96739519+gianfra-t@users.noreply.github.com> Date: Wed, 15 Apr 2026 16:09:21 -0300 Subject: [PATCH 20/78] Potential fix for pull request finding 'Unused variable, import, function or class' Co-authored-by: Copilot Autofix powered by AI <223894421+github-code-quality[bot]@users.noreply.github.com> --- .../api/src/api/services/quote/engines/nabla-swap/offramp-evm.ts | 1 - 1 file changed, 1 deletion(-) 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 f0db1bba3..c9afd9680 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 @@ -1,7 +1,6 @@ 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; From 6513473731b2356cf13ce95949927e2080947c5b Mon Sep 17 00:00:00 2001 From: gianfra-t <96739519+gianfra-t@users.noreply.github.com> Date: Wed, 15 Apr 2026 16:09:30 -0300 Subject: [PATCH 21/78] Potential fix for pull request finding 'Unused variable, import, function or class' Co-authored-by: Copilot Autofix powered by AI <223894421+github-code-quality[bot]@users.noreply.github.com> --- .../src/api/services/quote/engines/nabla-swap/offramp-evm.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 c9afd9680..9c17b74e4 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 @@ -1,4 +1,4 @@ -import { EvmToken, FiatToken, getPendulumDetails, Networks, PENDULUM_USDC_AXL, RampDirection } from "@vortexfi/shared"; +import { EvmToken, RampDirection } from "@vortexfi/shared"; import { QuoteContext } from "../../core/types"; import { BaseNablaSwapEngineEvm, NablaSwapEvmComputation } from "./base-evm"; From a7cb50f52f9f64cf624f56728d8f3117de9c0987 Mon Sep 17 00:00:00 2001 From: gianfra-t <96739519+gianfra-t@users.noreply.github.com> Date: Wed, 15 Apr 2026 16:09:48 -0300 Subject: [PATCH 22/78] Potential fix for pull request finding 'Unused variable, import, function or class' Co-authored-by: Copilot Autofix powered by AI <223894421+github-code-quality[bot]@users.noreply.github.com> --- .../api/src/api/services/quote/engines/nabla-swap/onramp-evm.ts | 2 -- 1 file changed, 2 deletions(-) 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 4a75060d5..77e7a7825 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 @@ -15,8 +15,6 @@ export class OnRampSwapEngineEvm extends BaseNablaSwapEngineEvm { } 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" From a0232a97665d84c700ccf3ad82ff1d59b6c2d29a Mon Sep 17 00:00:00 2001 From: gianfra-t <96739519+gianfra-t@users.noreply.github.com> Date: Wed, 15 Apr 2026 16:09:58 -0300 Subject: [PATCH 23/78] Potential fix for pull request finding 'Unused variable, import, function or class' Co-authored-by: Copilot Autofix powered by AI <223894421+github-code-quality[bot]@users.noreply.github.com> --- .../services/quote/engines/squidrouter/onramp-base-to-evm.ts | 2 -- 1 file changed, 2 deletions(-) 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 fb61dda41..09af4b06d 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 @@ -1,6 +1,4 @@ import { - ERC20_USDC_POLYGON, - ERC20_USDC_POLYGON_DECIMALS, EvmToken, getNetworkFromDestination, multiplyByPowerOfTen, From 0bdedcbe743d8bacdb88655d6df81ad86782257f Mon Sep 17 00:00:00 2001 From: gianfra-t <96739519+gianfra-t@users.noreply.github.com> Date: Wed, 15 Apr 2026 16:10:09 -0300 Subject: [PATCH 24/78] Potential fix for pull request finding 'Unused variable, import, function or class' Co-authored-by: Copilot Autofix powered by AI <223894421+github-code-quality[bot]@users.noreply.github.com> --- .../api/services/transactions/offramp/routes/evm-to-brl-base.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 7d092dcee..c1f520ead 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 @@ -33,7 +33,7 @@ export async function prepareEvmToBRLOfframpBaseTransactions({ let stateMeta: Partial = {}; // Validate inputs and extract required data - const { fromNetwork, inputTokenDetails, outputTokenDetails } = validateOfframpQuote(quote, signingAccounts); + const { fromNetwork, inputTokenDetails } = validateOfframpQuote(quote, signingAccounts); const evmEphemeralEntry = signingAccounts.find(account => account.type === "EVM"); if (!evmEphemeralEntry) { From dddd1fc90ec71e39814976763d303ccfd1fc19f9 Mon Sep 17 00:00:00 2001 From: gianfra-t <96739519+gianfra-t@users.noreply.github.com> Date: Wed, 15 Apr 2026 16:10:19 -0300 Subject: [PATCH 25/78] Potential fix for pull request finding 'Unused variable, import, function or class' Co-authored-by: Copilot Autofix powered by AI <223894421+github-code-quality[bot]@users.noreply.github.com> --- .../api/services/transactions/offramp/routes/evm-to-brl-base.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 c1f520ead..d8ad0547d 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 @@ -11,7 +11,7 @@ import { import Big from "big.js"; import { StateMetadata } from "../../../phases/meta-state-types"; import { encodeEvmTransactionData } from "../.."; -import { addEvmFeeDistributionTransaction, addFeeDistributionTransaction } from "../../common/feeDistribution"; +import { addEvmFeeDistributionTransaction } from "../../common/feeDistribution"; import { addNablaSwapTransactionsOnBase, addOnrampDestinationChainTransactions } from "../../onramp/common/transactions"; import { OfframpTransactionParams, OfframpTransactionsWithMeta } from "../common/types"; import { validateBRLOfframp, validateOfframpQuote } from "../common/validation"; From 6f4cfca0679e520378156132f749524123268314 Mon Sep 17 00:00:00 2001 From: gianfra-t <96739519+gianfra-t@users.noreply.github.com> Date: Wed, 15 Apr 2026 16:10:42 -0300 Subject: [PATCH 26/78] Potential fix for pull request finding 'Unused variable, import, function or class' Co-authored-by: Copilot Autofix powered by AI <223894421+github-code-quality[bot]@users.noreply.github.com> --- .../services/phases/handlers/subsidize-pre-swap-evm-handler.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/apps/api/src/api/services/phases/handlers/subsidize-pre-swap-evm-handler.ts b/apps/api/src/api/services/phases/handlers/subsidize-pre-swap-evm-handler.ts index 9b6ad8c68..f6848feda 100644 --- a/apps/api/src/api/services/phases/handlers/subsidize-pre-swap-evm-handler.ts +++ b/apps/api/src/api/services/phases/handlers/subsidize-pre-swap-evm-handler.ts @@ -7,7 +7,6 @@ import { getOnChainTokenDetails, Networks, nativeToDecimal, - RampDirection, RampPhase, waitUntilTrueWithTimeout } from "@vortexfi/shared"; From 9e97b6de89cc2f961709b094d7b7dac2fe8d2556 Mon Sep 17 00:00:00 2001 From: Gianfranco Date: Wed, 15 Apr 2026 16:11:38 -0300 Subject: [PATCH 27/78] code quality fix --- .../src/api/services/phases/handlers/brla-payout-base-handler.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/apps/api/src/api/services/phases/handlers/brla-payout-base-handler.ts b/apps/api/src/api/services/phases/handlers/brla-payout-base-handler.ts index 46a19af72..d04d86f33 100644 --- a/apps/api/src/api/services/phases/handlers/brla-payout-base-handler.ts +++ b/apps/api/src/api/services/phases/handlers/brla-payout-base-handler.ts @@ -189,7 +189,6 @@ export class BrlaPayoutOnBasePhaseHandler extends BasePhaseHandler { }); } else { logger.info(`BrlaPayoutOnBasePhaseHandler: Existing transaction ${brlaPayoutTxHash} succeeded.`); - txHash = brlaPayoutTxHash; } } else { txHash = (await evmClientManager.sendRawTransactionWithRetry( From 3d3e2f0a40a6b4e10f9c438cd4fffbe814ce8698 Mon Sep 17 00:00:00 2001 From: Kacper Szarkiewicz Date: Fri, 17 Apr 2026 15:49:20 +0200 Subject: [PATCH 28/78] reset scroll to top on route and widget screen transitions --- .../SummaryStep/BRLOnrampDetails.tsx | 2 +- .../src/hooks/ramp/useRampNavigation.ts | 27 ++++++++++++++++++- apps/frontend/src/main.tsx | 2 +- .../typechain-types/contracts/TokenRelayer.ts | 2 +- 4 files changed, 29 insertions(+), 4 deletions(-) diff --git a/apps/frontend/src/components/widget-steps/SummaryStep/BRLOnrampDetails.tsx b/apps/frontend/src/components/widget-steps/SummaryStep/BRLOnrampDetails.tsx index 0d2e03240..ca609bf61 100644 --- a/apps/frontend/src/components/widget-steps/SummaryStep/BRLOnrampDetails.tsx +++ b/apps/frontend/src/components/widget-steps/SummaryStep/BRLOnrampDetails.tsx @@ -37,7 +37,7 @@ export const BRLOnrampDetails: FC = () => {

{t("components.SummaryPage.BRLOnrampDetails.copyCode")}

- + ); }; diff --git a/apps/frontend/src/hooks/ramp/useRampNavigation.ts b/apps/frontend/src/hooks/ramp/useRampNavigation.ts index dfea88477..d294de281 100644 --- a/apps/frontend/src/hooks/ramp/useRampNavigation.ts +++ b/apps/frontend/src/hooks/ramp/useRampNavigation.ts @@ -1,7 +1,20 @@ -import { ReactNode, useCallback } from "react"; +import { ReactNode, useCallback, useEffect } from "react"; import { useIsQuoteComponentDisplayed } from "./useIsQuoteComponentDisplayed"; import { useRampComponentState } from "./useRampComponentState"; +function getActiveScreen( + currentPhase: string | undefined, + rampStateDefined: boolean, + machineValue: string, + isQuoteDisplayed: boolean +): string { + if (currentPhase === "complete") return "success"; + if (currentPhase === "failed") return "failure"; + if (rampStateDefined && machineValue === "RampFollowUp") return "progress"; + if (isQuoteDisplayed) return "quote"; + return "form"; +} + export const useRampNavigation = ( successComponent: ReactNode, failureComponent: ReactNode, @@ -12,6 +25,18 @@ export const useRampNavigation = ( const { rampState, rampMachineState } = useRampComponentState(); const isQuoteDisplayed = useIsQuoteComponentDisplayed(); + const activeScreen = getActiveScreen( + rampState?.ramp?.currentPhase, + rampState !== undefined, + String(rampMachineState.value), + isQuoteDisplayed + ); + + useEffect(() => { + console.log("activeScreen", activeScreen); + window.scrollTo({ behavior: "instant", top: 0 }); + }, [activeScreen]); + const getCurrentComponent = useCallback(() => { if (rampState?.ramp?.currentPhase === "complete") { return successComponent; diff --git a/apps/frontend/src/main.tsx b/apps/frontend/src/main.tsx index 560f2ba9d..c3e8cd955 100644 --- a/apps/frontend/src/main.tsx +++ b/apps/frontend/src/main.tsx @@ -60,7 +60,7 @@ i18n.use(initReactI18next).init({ } }); -const router = createRouter({ routeTree, scrollRestoration: true }); +const router = createRouter({ routeTree }); declare module "@tanstack/react-router" { interface Register { diff --git a/contracts/relayer/typechain-types/contracts/TokenRelayer.ts b/contracts/relayer/typechain-types/contracts/TokenRelayer.ts index 9d405d34a..c7019692c 100644 --- a/contracts/relayer/typechain-types/contracts/TokenRelayer.ts +++ b/contracts/relayer/typechain-types/contracts/TokenRelayer.ts @@ -124,7 +124,7 @@ export interface TokenRelayerInterface extends Interface { export namespace EIP712DomainChangedEvent { export type InputTuple = []; export type OutputTuple = []; - export interface OutputObject {} + export type OutputObject = {}; export type Event = TypedContractEvent; export type Filter = TypedDeferredTopicFilter; export type Log = TypedEventLog; From 5ffe05b97a068f4a7acc25c731e584608f945599 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Tue, 28 Apr 2026 18:55:37 +0200 Subject: [PATCH 29/78] Replace sepolia with base contracts and address PR review comments --- .../handlers/brla-payout-base-handler.ts | 31 +++++++++---------- .../handlers/distribute-fees-handler.ts | 10 +++--- .../phases/handlers/fund-ephemeral-handler.ts | 3 +- .../api/services/phases/handlers/helpers.ts | 5 +-- .../phases/handlers/initial-phase-handler.ts | 8 ++--- .../subsidize-post-swap-evm-handler.ts | 4 +-- .../subsidize-pre-swap-evm-handler.ts | 2 +- apps/api/src/api/services/quote/core/nabla.ts | 8 ++--- .../quote/engines/fee/offramp-avenia.ts | 1 - .../quote/engines/nabla-swap/base-evm.ts | 4 +-- .../quote/engines/nabla-swap/offramp-evm.ts | 8 ----- .../transactions/common/feeDistribution.ts | 9 ++---- .../onramp/routes/avenia-to-evm-base.ts | 10 ++---- .../api/services/transactions/validation.ts | 4 +-- 14 files changed, 43 insertions(+), 64 deletions(-) diff --git a/apps/api/src/api/services/phases/handlers/brla-payout-base-handler.ts b/apps/api/src/api/services/phases/handlers/brla-payout-base-handler.ts index d04d86f33..7b13b2e8c 100644 --- a/apps/api/src/api/services/phases/handlers/brla-payout-base-handler.ts +++ b/apps/api/src/api/services/phases/handlers/brla-payout-base-handler.ts @@ -25,7 +25,7 @@ export class BrlaPayoutOnBasePhaseHandler extends BasePhaseHandler { const { taxId, pixDestination, payOutTicketId, brlaPayoutTxHash } = state.state as StateMetadata; if (!taxId || !pixDestination) { - throw new Error("BrlaPayoutOnMoonbeamPhaseHandler: State metadata corrupted. This is a bug."); + throw new Error("BrlaPayoutOnBasePhaseHandler: State metadata corrupted. This is a bug."); } const quote = await QuoteTicket.findByPk(state.quoteId); @@ -38,15 +38,15 @@ export class BrlaPayoutOnBasePhaseHandler extends BasePhaseHandler { const taxIdRecord = await TaxId.findByPk(taxId); if (!taxIdRecord) { - throw new Error("BrlaPayoutOnMoonbeamPhaseHandler: SubaccountId must exist at this stage. This is a bug."); + throw new Error("BrlaPayoutOnBasePhaseHandler: SubaccountId must exist at this stage. This is a bug."); } if (!isFiatTokenEnum(outputCurrency)) { - throw new Error("BrlaPayoutOnMoonbeamPhaseHandler: Invalid token type."); + throw new Error("BrlaPayoutOnBasePhaseHandler: Invalid token type."); } if (!quote.metadata.nablaSwapEvm?.outputAmountDecimal) { - throw new Error("BrlaPayoutOnMoonbeamPhaseHandler: Missing nablaSwapEvm metadata."); + throw new Error("BrlaPayoutOnBasePhaseHandler: Missing nablaSwapEvm metadata."); } const amountForPayout = quote.metadata.nablaSwapEvm.outputAmountDecimal; @@ -90,11 +90,11 @@ export class BrlaPayoutOnBasePhaseHandler extends BasePhaseHandler { await new Promise(resolve => setTimeout(resolve, pollInterval)); } if (lastError) { - logger.error("BrlaPayoutOnMoonbeamPhaseHandler: Polling for balance failed: ", lastError); + logger.error("BrlaPayoutOnBasePhaseHandler: Polling for balance failed: ", lastError); throw lastError; } throw new Error( - `BrlaPayoutOnMoonbeamPhaseHandler: Balance check timed out after 5 minutes. Needed ${amountForPayout} units.` + `BrlaPayoutOnBasePhaseHandler: Balance check timed out after 5 minutes. Needed ${amountForPayout} units.` ); }; @@ -104,7 +104,7 @@ export class BrlaPayoutOnBasePhaseHandler extends BasePhaseHandler { const amount = new Big(outputAmount); const subaccount = await brlaApiService.subaccountInfo(taxIdRecord.subAccountId); if (!subaccount) { - throw new Error("BrlaPayoutOnMoonbeamPhaseHandler: Subaccount must exist."); + throw new Error("BrlaPayoutOnBasePhaseHandler: Subaccount must exist."); } const subaccountEvmAddress = subaccount.wallets.filter(wallet => wallet.chain === "EVM")[0]; @@ -124,10 +124,7 @@ export class BrlaPayoutOnBasePhaseHandler extends BasePhaseHandler { pixKey: pixDestination } }; - console.log("payOutTicketParams: ", payOutTicketParams); - const payOutTicketId = "mocked-ticket-id-for-now"; - - //const { id: payOutTicketId } = await brlaApiService.createPixOutputTicket(payOutTicketParams, taxIdRecord.subAccountId); + const { id: payOutTicketId } = await brlaApiService.createPixOutputTicket(payOutTicketParams, taxIdRecord.subAccountId); logger.debug("Debug: payOutTicketId", payOutTicketId); // Update the state with the transaction hashes await state.update({ @@ -140,8 +137,8 @@ export class BrlaPayoutOnBasePhaseHandler extends BasePhaseHandler { await this.checkTicketStatusPaid({ subAccountId: taxIdRecord.subAccountId, ticketId: payOutTicketId }); return this.transitionToNextPhase(state, "complete"); } catch (e) { - logger.error("Error in brlaPayoutOnMoonbeam", e); - throw this.createUnrecoverableError("BrlaPayoutOnMoonbeamPhaseHandler: Failed to trigger BRLA offramp."); + logger.error("Error in brlaPayoutOnBase", e); + throw this.createUnrecoverableError("BrlaPayoutOnBasePhaseHandler: Failed to trigger BRLA offramp."); } } @@ -239,7 +236,7 @@ export class BrlaPayoutOnBasePhaseHandler extends BasePhaseHandler { return AveniaTicketStatus.PAID; } if (ticket.status === AveniaTicketStatus.FAILED) { - throw this.createUnrecoverableError("BrlaPayoutOnMoonbeamPhaseHandler: Ticket status is FAILED"); + throw this.createUnrecoverableError("BrlaPayoutOnBasePhaseHandler: Ticket status is FAILED"); } } } catch (error) { @@ -253,13 +250,13 @@ export class BrlaPayoutOnBasePhaseHandler extends BasePhaseHandler { } if (lastError) { - logger.error("BrlaPayoutOnMoonbeamPhaseHandler: Polling for ticket status timed out with an error: ", lastError); + logger.error("BrlaPayoutOnBasePhaseHandler: Polling for ticket status timed out with an error: ", lastError); throw this.createUnrecoverableError( - `BrlaPayoutOnMoonbeamPhaseHandler: Polling for ticket status timed out with an error: ${lastError.message}` + `BrlaPayoutOnBasePhaseHandler: Polling for ticket status timed out with an error: ${lastError.message}` ); } - throw this.createRecoverableError("BrlaPayoutOnMoonbeamPhaseHandler: Polling for ticket status timed out."); + throw this.createRecoverableError("BrlaPayoutOnBasePhaseHandler: Polling for ticket status timed out."); } } 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 98e6ea1ca..6eeba5502 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 @@ -7,7 +7,7 @@ import { decodeSubmittableExtrinsic, EvmClientManager, EvmNetworks, - isEvmTransactionData, + EvmTransactionData, Networks, RampDirection, RampPhase, @@ -329,7 +329,7 @@ export class DistributeFeesHandler extends BasePhaseHandler { * @param network The EVM network * @returns The transaction hash */ - private async submitEvmTransaction(txData: any, network: EvmNetworks): Promise { + private async submitEvmTransaction(txData: EvmTransactionData, network: EvmNetworks): Promise { logger.debug(`Submitting EVM transaction to ${network} for ${this.getPhaseName()} phase`); const evmClientManager = EvmClientManager.getInstance(); @@ -372,13 +372,13 @@ export class DistributeFeesHandler extends BasePhaseHandler { /** * Check EVM transaction status * @param txHash The transaction hash + * @param network The EVM network where the transaction was submitted * @returns ExtrinsicStatus: Success, Fail, or Undefined */ - private async checkEvmTransactionStatus(txHash: string): Promise { + private async checkEvmTransactionStatus(txHash: string, network: EvmNetworks = Networks.Base): Promise { try { const evmClientManager = EvmClientManager.getInstance(); - // Always on Base for EVM. - const publicClient = evmClientManager.getClient(Networks.Base); + const publicClient = evmClientManager.getClient(network); const receipt = await publicClient.getTransactionReceipt({ hash: txHash as `0x${string}` }); 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 e998d8b8c..9f0bd2661 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 @@ -59,8 +59,7 @@ const DESTINATION_EVM_FUNDING_AMOUNTS: Record = { [Networks.BSC]: "0.000115", // ~0.1 USD @ 889 [Networks.Avalanche]: "0.0034", // ~0.1 USD @ 30 [Networks.Moonbeam]: "0.34", // ~0.1 USD @ 0.30 - [Networks.PolygonAmoy]: "0.2", // ~0.1 USD @ 0.50 - [Networks.BaseSepolia]: "0.000034" // ~0.1 USD @ 3000 + [Networks.PolygonAmoy]: "0.2" // ~0.1 USD @ 0.50 }; export class FundEphemeralPhaseHandler extends BasePhaseHandler { diff --git a/apps/api/src/api/services/phases/handlers/helpers.ts b/apps/api/src/api/services/phases/handlers/helpers.ts index c7a90e97e..ad7767031 100644 --- a/apps/api/src/api/services/phases/handlers/helpers.ts +++ b/apps/api/src/api/services/phases/handlers/helpers.ts @@ -8,9 +8,10 @@ import { } from "@vortexfi/shared"; import Big from "big.js"; import { Horizon, Networks } from "stellar-sdk"; -import { polygon } from "viem/chains"; +import { base, polygon } from "viem/chains"; import logger from "../../../../config/logger"; import { + BASE_EPHEMERAL_STARTING_BALANCE_UNITS, GLMR_FUNDING_AMOUNT_RAW, PENDULUM_EPHEMERAL_STARTING_BALANCE_UNITS, POLYGON_EPHEMERAL_STARTING_BALANCE_UNITS, @@ -67,7 +68,7 @@ export async function isBaseEphemeralFunded(baseEphemeralAddress: string): Promi address: baseEphemeralAddress as `0x${string}` }); const fundingAmountRaw = new Big( - multiplyByPowerOfTen(POLYGON_EPHEMERAL_STARTING_BALANCE_UNITS, polygon.nativeCurrency.decimals).toFixed() + multiplyByPowerOfTen(BASE_EPHEMERAL_STARTING_BALANCE_UNITS, base.nativeCurrency.decimals).toFixed() ); return Big(balance.toString()).gte(fundingAmountRaw); 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 23b7c711d..e2ee9af11 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/phases/handlers/subsidize-post-swap-evm-handler.ts b/apps/api/src/api/services/phases/handlers/subsidize-post-swap-evm-handler.ts index 433802ed7..6d016c06a 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 @@ -81,7 +81,7 @@ export class SubsidizePostSwapEvmPhaseHandler extends BasePhaseHandler { quote.metadata.subsidy.subsidyAmountInOutputTokenRaw ); - console.log("debug: expectedSwapOutputAmountRaw", expectedSwapOutputAmountRaw.toString()); + logger.debug(`SubsidizePostSwapEvmHandler: expectedSwapOutputAmountRaw ${expectedSwapOutputAmountRaw.toString()}`); // Try to find the required amount to subsidize on the quote metadata if (state.type === RampDirection.BUY) { @@ -92,7 +92,7 @@ export class SubsidizePostSwapEvmPhaseHandler extends BasePhaseHandler { } const requiredAmount = Big(expectedSwapOutputAmountRaw).sub(currentBalance); - console.log("debug: requiredAmount", requiredAmount.toString()); + logger.debug(`SubsidizePostSwapEvmHandler: requiredAmount ${requiredAmount.toString()}`); const didBalanceReachExpected = async () => { const balance = await checkEvmBalanceForToken({ diff --git a/apps/api/src/api/services/phases/handlers/subsidize-pre-swap-evm-handler.ts b/apps/api/src/api/services/phases/handlers/subsidize-pre-swap-evm-handler.ts index f6848feda..04f7b4ae4 100644 --- a/apps/api/src/api/services/phases/handlers/subsidize-pre-swap-evm-handler.ts +++ b/apps/api/src/api/services/phases/handlers/subsidize-pre-swap-evm-handler.ts @@ -70,7 +70,7 @@ export class SubsidizePreSwapEvmPhaseHandler extends BasePhaseHandler { const expectedInputAmountForSwapRaw = quote.metadata.nablaSwapEvm.inputAmountForSwapRaw; const requiredAmount = Big(expectedInputAmountForSwapRaw).sub(currentBalance); - console.log("debug: requiredAmount", requiredAmount.toString()); + logger.debug(`SubsidizePreSwapEvmHandler: requiredAmount ${requiredAmount.toString()}`); const didBalanceReachExpected = async () => { const balance = await checkEvmBalanceForToken({ diff --git a/apps/api/src/api/services/quote/core/nabla.ts b/apps/api/src/api/services/quote/core/nabla.ts index eec6ab7a7..eed758467 100644 --- a/apps/api/src/api/services/quote/core/nabla.ts +++ b/apps/api/src/api/services/quote/core/nabla.ts @@ -16,8 +16,8 @@ import httpStatus from "http-status"; import logger from "../../../../config/logger"; import { APIError } from "../../../errors/api-error"; -const NABLA_ROUTER_BASE: `0x${string}` = "0x0e368D4891C4A52b91b4e1Bf3CdEfcdaAFEF4355"; // TODO modify with router on Base after test -const NABLA_QUOTER_BASE: `0x${string}` = "0xf4B0f7c272354d070CC5C8140826b7BBe56953dA"; +const NABLA_ROUTER_BASE: `0x${string}` = "0x58E5Cb2dA15f01CB8FAefef202aa25238efCBdcf"; +const NABLA_QUOTER_BASE: `0x${string}` = "0x94C2F795358170a92271bF2490a56135E3fBA58A"; export interface NablaSwapRequest { inputAmountForSwap: string; @@ -79,7 +79,7 @@ export async function calculateNablaSwapOutput(request: NablaSwapRequest): Promi } ]; - const result = await evmClientManager.readContractWithRetry<[bigint, bigint]>(Networks.BaseSepolia, { + const result = await evmClientManager.readContractWithRetry<[bigint, bigint]>(Networks.Base, { abi: swapAbi, address: NABLA_ROUTER_BASE, args: [ @@ -168,7 +168,7 @@ export async function calculateNablaSwapOutputEvm(request: NablaSwapEvmRequest): } ]; - const result = await evmClientManager.readContractWithRetry(Networks.BaseSepolia, { + const result = await evmClientManager.readContractWithRetry(Networks.Base, { abi: swapAbi, address: NABLA_QUOTER_BASE, args: [ 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 31b2c8a37..153bd98d0 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,7 +10,6 @@ export class OffRampFeeAveniaEngine extends BaseFeeEngine { }; protected validate(ctx: QuoteContext): void { - console.log("Validating OffRampFeeAveniaEngine with context:", ctx); if (!ctx.nablaSwap && !ctx.nablaSwapEvm) { throw new Error("OffRampFeeAveniaEngine requires nablaSwap or nablaSwapEvm in context"); } 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 17f20cda3..550f62588 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.BaseSepolia, inputToken) as EvmTokenDetails; - const outputTokenDetails = getOnChainTokenDetails(Networks.BaseSepolia, outputToken) as EvmTokenDetails; + 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"); 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 9c17b74e4..33825ff27 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 @@ -33,14 +33,6 @@ 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/transactions/common/feeDistribution.ts b/apps/api/src/api/services/transactions/common/feeDistribution.ts index 602f1d18d..e90a2dd58 100644 --- a/apps/api/src/api/services/transactions/common/feeDistribution.ts +++ b/apps/api/src/api/services/transactions/common/feeDistribution.ts @@ -241,13 +241,8 @@ 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 + logger.debug( + `Fee distribution inputs: totalFeeUsdcRaw: ${totalFeeUsdcRaw.toFixed(0)}, maxFeePerGas: ${maxFeePerGas}, maxPriorityFeePerGas: ${maxPriorityFeePerGas}` ); const txData: EvmTransactionData = { data: transferCallData as `0x${string}`, 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 848e270e6..4645d0ee3 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,4 +1,5 @@ import { + createOnrampSquidrouterTransactionsFromBaseToEvm, createOnrampSquidrouterTransactionsOnDestinationChain, EvmNetworks, EvmToken, @@ -14,7 +15,7 @@ import { } from "@vortexfi/shared"; import { isAddress } from "viem"; import { privateKeyToAccount } from "viem/accounts"; -import { createOnrampSquidrouterTransactionsFromBaseToEvm } from "../../../../../../../../packages/shared/src/services"; +import logger from "../../../../../config/logger"; import { MOONBEAM_FUNDING_PRIVATE_KEY } from "../../../../../constants/constants"; import { StateMetadata } from "../../../phases/meta-state-types"; import { addEvmFeeDistributionTransaction } from "../../common/feeDistribution"; @@ -50,12 +51,7 @@ export async function prepareAveniaToEvmOnrampTransactionsOnBase({ quote, signingAccounts ); - console.log( - "starting: prepareAveniaToEvmOnrampTransactionsOnBase with quote:", - quote, - "destinationAddress:", - destinationAddress - ); + logger.debug(`Starting prepareAveniaToEvmOnrampTransactionsOnBase with destinationAddress: ${destinationAddress}`); // Setup state metadata stateMeta = { destinationAddress, diff --git a/apps/api/src/api/services/transactions/validation.ts b/apps/api/src/api/services/transactions/validation.ts index d5d81324d..0e1d44ed9 100644 --- a/apps/api/src/api/services/transactions/validation.ts +++ b/apps/api/src/api/services/transactions/validation.ts @@ -103,7 +103,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); + logger.debug(`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; @@ -155,7 +155,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); + logger.debug(`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}`, From 778fcafea93911814ba708e223426d1bb802177f Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Tue, 28 Apr 2026 18:55:46 +0200 Subject: [PATCH 30/78] Improve error logging by serializing route parameters in error messages --- packages/shared/src/services/squidrouter/onramp.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/shared/src/services/squidrouter/onramp.ts b/packages/shared/src/services/squidrouter/onramp.ts index db02c1d4d..baabbc509 100644 --- a/packages/shared/src/services/squidrouter/onramp.ts +++ b/packages/shared/src/services/squidrouter/onramp.ts @@ -89,7 +89,7 @@ export async function createOnrampSquidrouterTransactionsFromMoonbeamToEvm( swapData }; } catch (e) { - throw new Error(`Error getting route: ${routeParams}. Error: ${e}`); + throw new Error(`Error getting route: ${JSON.stringify(routeParams)}. Error: ${e}`); } } @@ -126,7 +126,7 @@ export async function createOnrampSquidrouterTransactionsFromPolygonToEvm( swapData }; } catch (e) { - throw new Error(`Error getting route: ${routeParams}. Error: ${e}`); + throw new Error(`Error getting route: ${JSON.stringify(routeParams)}. Error: ${e}`); } } @@ -163,7 +163,7 @@ export async function createOnrampSquidrouterTransactionsFromBaseToEvm( swapData }; } catch (e) { - throw new Error(`Error getting route: ${routeParams}. Error: ${e}`); + throw new Error(`Error getting route: ${JSON.stringify(routeParams)}. Error: ${e}`); } } @@ -210,7 +210,7 @@ export async function createOnrampSquidrouterTransactionsFromPolygonToMoonbeamWi swapData }; } catch (e) { - throw new Error(`Error getting route: ${routeParams}. Error: ${e}`); + throw new Error(`Error getting route: ${JSON.stringify(routeParams)}. Error: ${e}`); } } @@ -245,6 +245,6 @@ export async function createOnrampSquidrouterTransactionsOnDestinationChain( swapData }; } catch (e) { - throw new Error(`Error getting route: ${routeParams}. Error: ${e}`); + throw new Error(`Error getting route: ${JSON.stringify(routeParams)}. Error: ${e}`); } } From 66a900b80d0b0324437490f4ab182a3ea2505d46 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Tue, 28 Apr 2026 18:55:54 +0200 Subject: [PATCH 31/78] Update off-ramp flow to use brlaPayoutOnBase instead of brlaPayoutBase --- docs/architecture/ramp-journey-and-fees.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/architecture/ramp-journey-and-fees.md b/docs/architecture/ramp-journey-and-fees.md index 7ad49868a..8b74dfd9a 100644 --- a/docs/architecture/ramp-journey-and-fees.md +++ b/docs/architecture/ramp-journey-and-fees.md @@ -289,7 +289,7 @@ graph TD B_Off_Dist --> B_Off_Pre[subsidizePreSwapEvm]; B_Off_Pre --> B_Off_Nabla[nablaSwap]; B_Off_Nabla --> B_Off_Post[subsidizePostSwapEvm]; - B_Off_Post --> B_Off_Payout[brlaPayoutBase]; + B_Off_Post --> B_Off_Payout[brlaPayoutOnBase]; B_Off_Payout --> Y_off; %% --- Standard Off-Ramp Flows (EUR/ARS) --- From a4a3bc31f6ed05baaa4fa09de6a1d1a6f088c16a Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Tue, 28 Apr 2026 19:00:36 +0200 Subject: [PATCH 32/78] Update phaseMessages to use brlaPayoutOnBase and add EVM-specific messages --- apps/frontend/src/pages/progress/phaseMessages.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/apps/frontend/src/pages/progress/phaseMessages.ts b/apps/frontend/src/pages/progress/phaseMessages.ts index 408916396..4a1aee37e 100644 --- a/apps/frontend/src/pages/progress/phaseMessages.ts +++ b/apps/frontend/src/pages/progress/phaseMessages.ts @@ -67,7 +67,7 @@ export function getMessageForPhase(ramp: RampState | undefined, t: TFunction<"tr backupSquidRouterSwap: "", baseTransfer: getTransferringMessage(), brlaOnrampMint: t("pages.progress.brlaOnrampMint"), // Not relevant for progress page - brlaPayoutOnMoonbeam: getTransferringMessage(), + brlaPayoutOnBase: getTransferringMessage(), complete: "", destinationTransfer: getDestinationTransferMessage(), // Not relevant for progress page distributeFees: getSwappingMessage(), @@ -87,7 +87,9 @@ export function getMessageForPhase(ramp: RampState | undefined, t: TFunction<"tr moonbeamToPendulum: getMoonbeamToPendulumMessage(), moonbeamToPendulumXcm: getMoonbeamToPendulumMessage(), nablaApprove: getSwappingMessage(), + nablaApproveEvm: getSwappingMessage(), nablaSwap: getSwappingMessage(), + nablaSwapEvm: getSwappingMessage(), pendulumToAssethubXcm: t("pages.progress.pendulumToAssethubXcm", { assetSymbol: outputAssetSymbol }), @@ -109,7 +111,9 @@ export function getMessageForPhase(ramp: RampState | undefined, t: TFunction<"tr assetSymbol: outputAssetSymbol }), subsidizePostSwap: getSwappingMessage(), // Not relevant for progress page + subsidizePostSwapEvm: getSwappingMessage(), // Not relevant for progress page subsidizePreSwap: getSwappingMessage(), + subsidizePreSwapEvm: getSwappingMessage(), timedOut: "" }; From b37c397b0ad68433774ea3d8b7e15cfa4e9c4a96 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Tue, 28 Apr 2026 19:00:51 +0200 Subject: [PATCH 33/78] Sort imports --- .../api/services/transactions/onramp/routes/avenia-to-evm.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 83fa1fefc..3a0c79cff 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 @@ -23,7 +23,7 @@ import { isAddress } from "viem"; import { privateKeyToAccount } from "viem/accounts"; import { MOONBEAM_FUNDING_PRIVATE_KEY } from "../../../../../constants/constants"; import { StateMetadata } from "../../../phases/meta-state-types"; -import { addEvmFeeDistributionTransaction, addFeeDistributionTransaction } from "../../common/feeDistribution"; +import { addFeeDistributionTransaction } from "../../common/feeDistribution"; import { encodeEvmTransactionData } from "../../index"; import { addDestinationChainApprovalTransaction, From e4d6fa9f34ad54a0d6ddac18e848b2d14f9a52e6 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Tue, 28 Apr 2026 19:06:50 +0200 Subject: [PATCH 34/78] Fix type issues --- apps/api/src/api/controllers/brla.controller.ts | 3 +-- .../services/phases/handlers/distribute-fees-handler.ts | 2 +- .../services/phases/handlers/final-settlement-subsidy.ts | 3 ++- .../services/phases/handlers/fund-ephemeral-handler.ts | 3 ++- .../phases/handlers/pendulum-to-moonbeam-xcm-handler.ts | 2 +- .../phases/handlers/squid-router-pay-phase-handler.ts | 4 ++-- .../phases/handlers/squid-router-phase-handler.ts | 1 + .../engines/initialize/offramp-from-evm-alfredpay.ts | 2 +- .../api/services/transactions/common/feeDistribution.ts | 2 +- apps/api/src/api/services/transactions/validation.ts | 2 +- apps/frontend/src/pages/progress/index.tsx | 8 ++++++-- 11 files changed, 19 insertions(+), 13 deletions(-) diff --git a/apps/api/src/api/controllers/brla.controller.ts b/apps/api/src/api/controllers/brla.controller.ts index 1ce102b3d..49f35c794 100644 --- a/apps/api/src/api/controllers/brla.controller.ts +++ b/apps/api/src/api/controllers/brla.controller.ts @@ -576,7 +576,6 @@ export const initiateKybLevel1 = async ( ): Promise => { try { const { subAccountId } = req.query; - const { redirectUrl } = req.body as { redirectUrl: string }; if (!subAccountId) { res.status(httpStatus.BAD_REQUEST).json({ error: "Missing subAccountId" }); @@ -597,7 +596,7 @@ export const initiateKybLevel1 = async ( } const brlaApiService = BrlaApiService.getInstance(); - const response = await brlaApiService.initiateKybLevel1(subAccountId, redirectUrl); + const response = await brlaApiService.initiateKybLevel1(subAccountId); res.status(httpStatus.OK).json(response); } catch (error) { 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 6eeba5502..1f5a4611b 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 @@ -118,7 +118,7 @@ export class DistributeFeesHandler extends BasePhaseHandler { if (isEvmTransaction) { logger.info(`Submitting EVM fee distribution transaction for ramp ${state.id}...`); actualTxHash = await this.submitEvmTransaction( - distributeFeeTransaction.txData, + distributeFeeTransaction.txData as EvmTransactionData, distributeFeeTransaction.network as EvmNetworks ); } else { diff --git a/apps/api/src/api/services/phases/handlers/final-settlement-subsidy.ts b/apps/api/src/api/services/phases/handlers/final-settlement-subsidy.ts index 696e7fb17..267a9e5bc 100644 --- a/apps/api/src/api/services/phases/handlers/final-settlement-subsidy.ts +++ b/apps/api/src/api/services/phases/handlers/final-settlement-subsidy.ts @@ -41,7 +41,8 @@ const NATIVE_TOKENS: Record = [Networks.Arbitrum]: { decimals: 18, symbol: "ETH" }, [Networks.Base]: { decimals: 18, symbol: "ETH" }, [Networks.Avalanche]: { decimals: 18, symbol: "AVAX" }, - [Networks.Moonbeam]: { decimals: 18, symbol: "GLMR" } + [Networks.Moonbeam]: { decimals: 18, symbol: "GLMR" }, + [Networks.BaseSepolia]: { decimals: 18, symbol: "ETH" } }; /** 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 9f0bd2661..e998d8b8c 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 @@ -59,7 +59,8 @@ const DESTINATION_EVM_FUNDING_AMOUNTS: Record = { [Networks.BSC]: "0.000115", // ~0.1 USD @ 889 [Networks.Avalanche]: "0.0034", // ~0.1 USD @ 30 [Networks.Moonbeam]: "0.34", // ~0.1 USD @ 0.30 - [Networks.PolygonAmoy]: "0.2" // ~0.1 USD @ 0.50 + [Networks.PolygonAmoy]: "0.2", // ~0.1 USD @ 0.50 + [Networks.BaseSepolia]: "0.000034" // ~0.1 USD @ 3000 }; export class FundEphemeralPhaseHandler extends BasePhaseHandler { diff --git a/apps/api/src/api/services/phases/handlers/pendulum-to-moonbeam-xcm-handler.ts b/apps/api/src/api/services/phases/handlers/pendulum-to-moonbeam-xcm-handler.ts index ea8559c5c..2121d8e62 100644 --- a/apps/api/src/api/services/phases/handlers/pendulum-to-moonbeam-xcm-handler.ts +++ b/apps/api/src/api/services/phases/handlers/pendulum-to-moonbeam-xcm-handler.ts @@ -179,7 +179,7 @@ export class PendulumToMoonbeamXCMPhaseHandler extends BasePhaseHandler { protected nextPhaseSelector(state: RampState): RampPhase { if (state.type === RampDirection.SELL) { - return "brlaPayoutOnMoonbeam"; + return "brlaPayoutOnBase"; } else { return "squidRouterSwap"; } diff --git a/apps/api/src/api/services/phases/handlers/squid-router-pay-phase-handler.ts b/apps/api/src/api/services/phases/handlers/squid-router-pay-phase-handler.ts index 3c148f837..9a72698ea 100644 --- a/apps/api/src/api/services/phases/handlers/squid-router-pay-phase-handler.ts +++ b/apps/api/src/api/services/phases/handlers/squid-router-pay-phase-handler.ts @@ -242,10 +242,10 @@ export class SquidRouterPayPhaseHandler extends BasePhaseHandler { if (quote.inputCurrency === FiatToken.BRL) { subsidyToken = SubsidyToken.ETH; - payerAccount = this.baseWalletClient.account?.address; + payerAccount = this.baseWalletClient.account?.address as `0x${string}` | undefined; } else { subsidyToken = SubsidyToken.MATIC; - payerAccount = this.polygonWalletClient.account?.address; + payerAccount = this.polygonWalletClient.account?.address as `0x${string}` | undefined; } const subsidyAmount = nativeToDecimal(nativeToFundRaw, 18).toNumber(); diff --git a/apps/api/src/api/services/phases/handlers/squid-router-phase-handler.ts b/apps/api/src/api/services/phases/handlers/squid-router-phase-handler.ts index 223c9796d..0f42f440e 100644 --- a/apps/api/src/api/services/phases/handlers/squid-router-phase-handler.ts +++ b/apps/api/src/api/services/phases/handlers/squid-router-phase-handler.ts @@ -1,5 +1,6 @@ import { EvmClientManager, + EvmToken, FiatToken, getNetworkFromDestination, getNetworkId, 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 13ac1e567..0e1fe432e 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 @@ -2,7 +2,7 @@ import { ALFREDPAY_EVM_TOKEN, Networks, OnChainToken, RampDirection } from "@vor import Big from "big.js"; import { EvmBridgeQuoteRequest, getEvmBridgeQuote } from "../../core/squidrouter"; import { QuoteContext } from "../../core/types"; -import { BaseInitializeEngine } from "./index"; +import { assignPreNablaContext, BaseInitializeEngine } from "./index"; export class OffRampFromEvmInitializeEngine extends BaseInitializeEngine { private readonly network: Networks; diff --git a/apps/api/src/api/services/transactions/common/feeDistribution.ts b/apps/api/src/api/services/transactions/common/feeDistribution.ts index e90a2dd58..8dbfb862b 100644 --- a/apps/api/src/api/services/transactions/common/feeDistribution.ts +++ b/apps/api/src/api/services/transactions/common/feeDistribution.ts @@ -278,7 +278,7 @@ export async function addEvmFeeDistributionTransaction( meta: {}, network: Networks.Base, nonce: nextNonce, - phase: "distributeFeesEvm", + phase: "distributeFees", signer: account.address, txData: feeDistributionTx }); diff --git a/apps/api/src/api/services/transactions/validation.ts b/apps/api/src/api/services/transactions/validation.ts index 0e1d44ed9..ce6eca319 100644 --- a/apps/api/src/api/services/transactions/validation.ts +++ b/apps/api/src/api/services/transactions/validation.ts @@ -65,7 +65,7 @@ function getTransactionTypeForPhase(phase: RampPhase | CleanupPhase): EphemeralA case "squidRouterSwap": case "nablaApproveEvm": case "nablaSwapEvm": - case "distributeFeesEvm": + case "distributeFees": return EphemeralAccountType.EVM; default: return EphemeralAccountType.EVM; diff --git a/apps/frontend/src/pages/progress/index.tsx b/apps/frontend/src/pages/progress/index.tsx index 7a138f932..59ded1b27 100644 --- a/apps/frontend/src/pages/progress/index.tsx +++ b/apps/frontend/src/pages/progress/index.tsx @@ -25,7 +25,7 @@ const PHASE_DURATIONS: Record = { backupSquidRouterSwap: 0, baseTransfer: 10, brlaOnrampMint: 5 * 60, - brlaPayoutOnMoonbeam: 30, + brlaPayoutOnBase: 30, complete: 0, destinationTransfer: 12, distributeFees: 24, @@ -40,7 +40,9 @@ const PHASE_DURATIONS: Record = { moonbeamToPendulum: 40, moonbeamToPendulumXcm: 30, nablaApprove: 24, + nablaApproveEvm: 24, nablaSwap: 24, + nablaSwapEvm: 24, pendulumToAssethubXcm: 30, pendulumToHydrationXcm: 30, pendulumToMoonbeamXcm: 40, @@ -52,7 +54,9 @@ const PHASE_DURATIONS: Record = { stellarCreateAccount: 0, stellarPayment: 6, subsidizePostSwap: 24, + subsidizePostSwapEvm: 24, subsidizePreSwap: 24, + subsidizePreSwapEvm: 24, timedOut: 0 }; @@ -193,7 +197,7 @@ function getRampFlow(rampState: RampState | undefined): keyof typeof PHASE_FLOWS return "onramp_eur_evm"; } - if (currentPhase === "brlaPayoutOnMoonbeam" || rampState.quote?.outputCurrency === FiatToken.BRL) { + if (currentPhase === "brlaPayoutOnBase" || rampState.quote?.outputCurrency === FiatToken.BRL) { return "offramp_brl"; } From a9418acdcfde11d6f695cbd01afa6f2eb5a4ab95 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Wed, 29 Apr 2026 09:51:29 +0200 Subject: [PATCH 35/78] Amend type issues --- .../api/src/api/services/transactions/common/feeDistribution.ts | 2 +- apps/api/src/api/services/transactions/validation.ts | 2 +- apps/frontend/src/pages/progress/index.tsx | 1 + apps/frontend/src/pages/progress/phaseMessages.ts | 1 + packages/shared/src/endpoints/ramp.endpoints.ts | 1 + 5 files changed, 5 insertions(+), 2 deletions(-) diff --git a/apps/api/src/api/services/transactions/common/feeDistribution.ts b/apps/api/src/api/services/transactions/common/feeDistribution.ts index 8dbfb862b..e90a2dd58 100644 --- a/apps/api/src/api/services/transactions/common/feeDistribution.ts +++ b/apps/api/src/api/services/transactions/common/feeDistribution.ts @@ -278,7 +278,7 @@ export async function addEvmFeeDistributionTransaction( meta: {}, network: Networks.Base, nonce: nextNonce, - phase: "distributeFees", + phase: "distributeFeesEvm", signer: account.address, txData: feeDistributionTx }); diff --git a/apps/api/src/api/services/transactions/validation.ts b/apps/api/src/api/services/transactions/validation.ts index ce6eca319..0e1d44ed9 100644 --- a/apps/api/src/api/services/transactions/validation.ts +++ b/apps/api/src/api/services/transactions/validation.ts @@ -65,7 +65,7 @@ function getTransactionTypeForPhase(phase: RampPhase | CleanupPhase): EphemeralA case "squidRouterSwap": case "nablaApproveEvm": case "nablaSwapEvm": - case "distributeFees": + case "distributeFeesEvm": return EphemeralAccountType.EVM; default: return EphemeralAccountType.EVM; diff --git a/apps/frontend/src/pages/progress/index.tsx b/apps/frontend/src/pages/progress/index.tsx index 59ded1b27..ce54bf363 100644 --- a/apps/frontend/src/pages/progress/index.tsx +++ b/apps/frontend/src/pages/progress/index.tsx @@ -29,6 +29,7 @@ const PHASE_DURATIONS: Record = { complete: 0, destinationTransfer: 12, distributeFees: 24, + distributeFeesEvm: 24, failed: 0, finalSettlementSubsidy: 30, fundEphemeral: 20, diff --git a/apps/frontend/src/pages/progress/phaseMessages.ts b/apps/frontend/src/pages/progress/phaseMessages.ts index 4a1aee37e..b4717aa32 100644 --- a/apps/frontend/src/pages/progress/phaseMessages.ts +++ b/apps/frontend/src/pages/progress/phaseMessages.ts @@ -71,6 +71,7 @@ export function getMessageForPhase(ramp: RampState | undefined, t: TFunction<"tr complete: "", destinationTransfer: getDestinationTransferMessage(), // Not relevant for progress page distributeFees: getSwappingMessage(), + distributeFeesEvm: getSwappingMessage(), failed: "", finalSettlementSubsidy: getDestinationTransferMessage(), fundEphemeral: t("pages.progress.fundEphemeral"), diff --git a/packages/shared/src/endpoints/ramp.endpoints.ts b/packages/shared/src/endpoints/ramp.endpoints.ts index 9b7820eb9..8b3a00205 100644 --- a/packages/shared/src/endpoints/ramp.endpoints.ts +++ b/packages/shared/src/endpoints/ramp.endpoints.ts @@ -41,6 +41,7 @@ export type RampPhase = | "subsidizePostSwap" | "subsidizePostSwapEvm" | "distributeFees" + | "distributeFeesEvm" | "alfredpayOnrampMint" | "alfredOnrampMintFallback" | "alfredpayOfframpTransfer" From b916eb31d9e47426078a97e596031027d35dac78 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Wed, 29 Apr 2026 10:39:17 +0200 Subject: [PATCH 36/78] Amend PR comments --- .../engines/merge-subsidy/offramp-evm.ts | 1 - .../offramp/routes/evm-to-brl-base.ts | 19 ++++++++++++++----- .../onramp/routes/monerium-to-evm.ts | 5 +++++ packages/shared/src/helpers/networks.ts | 2 +- .../shared/src/services/evm/clientManager.ts | 2 +- .../src/services/nabla/transactions/index.ts | 1 - 6 files changed, 21 insertions(+), 9 deletions(-) diff --git a/apps/api/src/api/services/quote/engines/merge-subsidy/offramp-evm.ts b/apps/api/src/api/services/quote/engines/merge-subsidy/offramp-evm.ts index 5ba2a43b1..10c0317cd 100644 --- a/apps/api/src/api/services/quote/engines/merge-subsidy/offramp-evm.ts +++ b/apps/api/src/api/services/quote/engines/merge-subsidy/offramp-evm.ts @@ -1,5 +1,4 @@ import { RampDirection } from "@vortexfi/shared"; -import Big from "big.js"; import { QuoteContext, Stage, StageKey } from "../../core/types"; interface MergeSubsidyConfig { 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 d8ad0547d..e42d9e6e2 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 @@ -44,8 +44,7 @@ export async function prepareEvmToBRLOfframpBaseTransactions({ brlaEvmAddress: validatedBrlaEvmAddress, pixDestination: validatedPixDestination, taxId: validatedTaxId, - receiverTaxId: validatedReceiverTaxId, - offrampAmountBeforeAnchorFeesRaw + receiverTaxId: validatedReceiverTaxId } = validateBRLOfframp(quote, { brlaEvmAddress, pixDestination, receiverTaxId, taxId }); const inputAmountRaw = multiplyByPowerOfTen(new Big(quote.inputAmount), inputTokenDetails.decimals).toFixed(0, 0); @@ -101,15 +100,25 @@ export async function prepareEvmToBRLOfframpBaseTransactions({ }); } + // TODO isn't this missing the rest of the edge case handling? + let baseNonce = 0; + const baseUSDCTokenAddress = evmTokenConfig[Networks.Base][EvmToken.USDC]?.erc20AddressSourceChain; + if (!baseUSDCTokenAddress) { + throw new Error("Invalid USDC configuration for Base in evmTokenConfig"); + } + const baseBRLATokenAddress = evmTokenConfig[Networks.Base][EvmToken.BRLA]?.erc20AddressSourceChain; + if (!baseBRLATokenAddress) { + throw new Error("Invalid BRLA configuration for Base in evmTokenConfig"); + } + // Add Base Nabla swap transactions (USDC to BRLA on Base) const { nextNonce: nonceAfterNabla, stateMeta: nablaStateMeta } = await addNablaSwapTransactionsOnBase( { account: evmEphemeralEntry, - // 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 + inputTokenAddress: baseUSDCTokenAddress, + outputTokenAddress: baseBRLATokenAddress, quote }, unsignedTxs, 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 35a85dc97..139e238b1 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 @@ -39,6 +39,11 @@ export async function prepareMoneriumToEvmOnrampTransactions({ 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 } = validateMoneriumOnramp(quote, signingAccounts); diff --git a/packages/shared/src/helpers/networks.ts b/packages/shared/src/helpers/networks.ts index 919d336f4..4b8aed162 100644 --- a/packages/shared/src/helpers/networks.ts +++ b/packages/shared/src/helpers/networks.ts @@ -115,7 +115,7 @@ const NETWORK_METADATA: Record = { }, [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 + id: baseSepolia.id, isEVM: true, supportsRamp: false }, diff --git a/packages/shared/src/services/evm/clientManager.ts b/packages/shared/src/services/evm/clientManager.ts index 8843a4c94..a478e2f5a 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, baseSepolia, bsc, mainnet, moonbeam, polygon, polygonAmoy, sepolia } from "viem/chains"; +import { arbitrum, avalanche, base, baseSepolia, bsc, mainnet, moonbeam, polygon, polygonAmoy } from "viem/chains"; import { ALCHEMY_API_KEY, EvmNetworks, Networks } from "../../index"; import logger from "../../logger"; diff --git a/packages/shared/src/services/nabla/transactions/index.ts b/packages/shared/src/services/nabla/transactions/index.ts index 8767899e3..955ca861e 100644 --- a/packages/shared/src/services/nabla/transactions/index.ts +++ b/packages/shared/src/services/nabla/transactions/index.ts @@ -1,6 +1,5 @@ import { CreateExecuteMessageExtrinsicOptions } from "@pendulum-chain/api-solang"; import { encodeFunctionData } from "viem/utils"; -import { routerAbi } from "../../../contracts/Router"; import { AccountMeta, ApiManager, From d5594ce0cd422c97569c93e27fd39b02f3bb10db Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 29 Apr 2026 08:57:42 +0000 Subject: [PATCH 37/78] fix: address PR review comments - presigned tx, nabla addresses, PIX routing, network params MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - distribute-fees-handler: submit presigned raw EVM transaction instead of signing with funding account - distribute-fees-handler: fix presigned tx lookup to use "distributeFeesEvm" for EVM flows - distribute-fees-handler: pass explicit network to checkEvmTransactionStatus - nabla.ts: import NABLA_ROUTER_BASE and NABLA_QUOTER_BASE from shared constants - route-resolver: restore AssetHub→PIX routing using OfframpToPixStrategy Agent-Logs-Url: https://github.com/pendulum-chain/vortex/sessions/9b62fc69-63f0-4f94-8dd5-eb0138bebf28 --- .../handlers/distribute-fees-handler.ts | 46 +++++++++---------- apps/api/src/api/services/quote/core/nabla.ts | 5 +- .../services/quote/routes/route-resolver.ts | 3 +- packages/shared/src/tokens/constants/misc.ts | 1 + 4 files changed, 26 insertions(+), 29 deletions(-) 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 1f5a4611b..c9bd24438 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 @@ -7,16 +7,14 @@ import { decodeSubmittableExtrinsic, EvmClientManager, EvmNetworks, - EvmTransactionData, Networks, RampDirection, RampPhase, TransactionTemporarilyBannedError, waitUntilTrueWithTimeout } from "@vortexfi/shared"; -import { privateKeyToAccount } from "viem/accounts"; import logger from "../../../../config/logger"; -import { MOONBEAM_FUNDING_PRIVATE_KEY, SUBSCAN_API_KEY } from "../../../../constants/constants"; +import { SUBSCAN_API_KEY } from "../../../../constants/constants"; import QuoteTicket from "../../../../models/quoteTicket.model"; import RampState from "../../../../models/rampState.model"; import { PhaseError } from "../../../errors/phase-error"; @@ -74,14 +72,15 @@ 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). + // For BRL flows, distribution happens on EVM (Base). const isEvmTransaction = quote.inputCurrency === "BRL" || quote.outputCurrency === "BRL"; + const evmNetwork = isEvmTransaction ? (Networks.Base as EvmNetworks) : undefined; if (existingHash) { logger.info(`Found existing distribute fee hash for ramp ${state.id}: ${existingHash}`); - if (isEvmTransaction) { - const status = await this.checkEvmTransactionStatus(existingHash).catch((_: unknown) => { + if (isEvmTransaction && evmNetwork) { + const status = await this.checkEvmTransactionStatus(existingHash, evmNetwork).catch((_: unknown) => { throw this.createRecoverableError("Failed to check EVM transaction status from existing hash."); }); @@ -106,8 +105,10 @@ export class DistributeFeesHandler extends BasePhaseHandler { } try { - // Get the pre-signed fee distribution transaction. This can be undefined if no fees are to be distributed. - const distributeFeeTransaction = this.getPresignedTransaction(state, "distributeFees"); + // Get the pre-signed fee distribution transaction. + // Use "distributeFeesEvm" for EVM flows, "distributeFees" for substrate flows. + const presignedPhase = isEvmTransaction ? "distributeFeesEvm" : "distributeFees"; + const distributeFeeTransaction = this.getPresignedTransaction(state, presignedPhase); if (distributeFeeTransaction === undefined) { logger.info("No fee distribution transaction data found. Skipping fee distribution."); return this.transitionToNextPhase(state, nextPhase); @@ -117,8 +118,8 @@ export class DistributeFeesHandler extends BasePhaseHandler { if (isEvmTransaction) { logger.info(`Submitting EVM fee distribution transaction for ramp ${state.id}...`); - actualTxHash = await this.submitEvmTransaction( - distributeFeeTransaction.txData as EvmTransactionData, + actualTxHash = await this.submitEvmRawTransaction( + distributeFeeTransaction.txData as string, distributeFeeTransaction.network as EvmNetworks ); } else { @@ -324,25 +325,20 @@ export class DistributeFeesHandler extends BasePhaseHandler { } /** - * Submit an EVM transaction - * @param txData The EVM transaction data + * Submit a presigned EVM raw transaction + * @param serializedTransaction The signed serialized transaction * @param network The EVM network * @returns The transaction hash */ - private async submitEvmTransaction(txData: EvmTransactionData, network: EvmNetworks): Promise { - logger.debug(`Submitting EVM transaction to ${network} for ${this.getPhaseName()} phase`); + private async submitEvmRawTransaction(serializedTransaction: string, network: EvmNetworks): Promise { + logger.debug(`Broadcasting presigned EVM transaction to ${network} for ${this.getPhaseName()} phase`); + + if (typeof serializedTransaction !== "string" || !serializedTransaction.startsWith("0x")) { + throw new Error(`Invalid presigned EVM transaction data 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") - }); + return await evmClientManager.sendRawTransactionWithRetry(network, serializedTransaction as `0x${string}`); } /** @@ -375,7 +371,7 @@ export class DistributeFeesHandler extends BasePhaseHandler { * @param network The EVM network where the transaction was submitted * @returns ExtrinsicStatus: Success, Fail, or Undefined */ - private async checkEvmTransactionStatus(txHash: string, network: EvmNetworks = Networks.Base): Promise { + private async checkEvmTransactionStatus(txHash: string, network: EvmNetworks): Promise { try { const evmClientManager = EvmClientManager.getInstance(); const publicClient = evmClientManager.getClient(network); diff --git a/apps/api/src/api/services/quote/core/nabla.ts b/apps/api/src/api/services/quote/core/nabla.ts index eed758467..bc7c3edec 100644 --- a/apps/api/src/api/services/quote/core/nabla.ts +++ b/apps/api/src/api/services/quote/core/nabla.ts @@ -4,6 +4,8 @@ import { EvmTokenDetails, getTokenOutAmount, multiplyByPowerOfTen, + NABLA_QUOTER_BASE, + NABLA_ROUTER_BASE, Networks, PendulumTokenDetails, parseContractBalanceResponse, @@ -16,9 +18,6 @@ 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_QUOTER_BASE: `0x${string}` = "0x94C2F795358170a92271bF2490a56135E3fBA58A"; - export interface NablaSwapRequest { inputAmountForSwap: string; rampType: RampDirection; 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 df1f172e1..70831fd15 100644 --- a/apps/api/src/api/services/quote/routes/route-resolver.ts +++ b/apps/api/src/api/services/quote/routes/route-resolver.ts @@ -15,6 +15,7 @@ import { APIError } from "../../../errors/api-error"; 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"; @@ -65,7 +66,7 @@ export class RouteResolver { switch (ctx.to) { case "pix": - return new OfframpToPixEvmStrategy(); + return ctx.from === Networks.AssetHub ? new OfframpToPixStrategy() : new OfframpToPixEvmStrategy(); case "wire": case "ach": case "spei": diff --git a/packages/shared/src/tokens/constants/misc.ts b/packages/shared/src/tokens/constants/misc.ts index fdf9b0eaf..3165bc5fa 100644 --- a/packages/shared/src/tokens/constants/misc.ts +++ b/packages/shared/src/tokens/constants/misc.ts @@ -13,6 +13,7 @@ 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 NABLA_QUOTER_BASE: `0x${string}` = "0x94C2F795358170a92271bF2490a56135E3fBA58A"; export const SPACEWALK_REDEEM_SAFETY_MARGIN = 0.05; export const AMM_MINIMUM_OUTPUT_SOFT_MARGIN = 0.02; From 544f70aee039eb23772c4395c1ee0863bbb37102 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Wed, 29 Apr 2026 11:43:08 +0200 Subject: [PATCH 38/78] Implement EVM fee distribution with Multicall3 support for partner splits --- .../transactions/common/feeDistribution.ts | 96 +++++++++++++++---- apps/api/src/contracts/Multicall3.ts | 30 ++++++ 2 files changed, 109 insertions(+), 17 deletions(-) create mode 100644 apps/api/src/contracts/Multicall3.ts diff --git a/apps/api/src/api/services/transactions/common/feeDistribution.ts b/apps/api/src/api/services/transactions/common/feeDistribution.ts index e90a2dd58..f36a90a41 100644 --- a/apps/api/src/api/services/transactions/common/feeDistribution.ts +++ b/apps/api/src/api/services/transactions/common/feeDistribution.ts @@ -17,6 +17,7 @@ import Big from "big.js"; import { encodeFunctionData } from "viem/utils"; import logger from "../../../../config/logger"; import erc20ABI from "../../../../contracts/ERC20"; +import { MULTICALL3_ADDRESS, multicall3ABI } from "../../../../contracts/Multicall3"; import Partner from "../../../../models/partner.model"; import { QuoteTicketAttributes } from "../../../../models/quoteTicket.model"; import { multiplyByPowerOfTen } from "../../pendulum/helpers"; @@ -182,7 +183,9 @@ export async function addFeeDistributionTransaction( /** * Creates an EVM fee distribution transaction for Base network. - * Transfers total fees (network + vortex + partner) to vortex payout address using USDC. + * Splits fees: network + vortex fees go to vortex EVM payout address, + * partner markup goes to partner EVM payout address (if available). + * Uses Multicall3 to batch multiple ERC20 transfers when needed. * * @param quote The quote ticket * @returns The EVM transaction data or null if no fees to distribute @@ -208,6 +211,17 @@ export async function createEvmFeeDistributionTransaction(quote: QuoteTicketAttr } const vortexPayoutAddress = vortexPartner.payoutAddressEvm; + // Look up partner EVM payout address for markup split + let partnerPayoutAddressEvm: string | null = null; + if (quote.partnerId) { + const quotePartner = await Partner.findOne({ + where: { id: quote.partnerId, isActive: true, rampType: quote.rampType } + }); + if (quotePartner?.payoutAddressEvm) { + partnerPayoutAddressEvm = quotePartner.payoutAddressEvm; + } + } + // Use Base USDC for decimal calculations const baseUsdcConfig = evmTokenConfig[Networks.Base][EvmToken.USDC]; if (!baseUsdcConfig) { @@ -216,44 +230,92 @@ export async function createEvmFeeDistributionTransaction(quote: QuoteTicketAttr } const decimals = baseUsdcConfig.decimals; + const usdcAddress = baseUsdcConfig.erc20AddressSourceChain; // 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); + // Vortex receives network + vortex fees + const vortexTotalUsdcRaw = networkFeeUsdcRaw.plus(vortexFeeUsdcRaw); + const hasVortexFees = vortexTotalUsdcRaw.gt(0); + const hasPartnerFees = partnerMarkupFeeUsdcRaw.gt(0) && partnerPayoutAddressEvm !== null; - if (totalFeeUsdcRaw.lte(0)) { + if (!hasVortexFees && !hasPartnerFees) { logger.warn("No fees to distribute, skipping EVM fee distribution transaction"); return null; } const evmClientManager = EvmClientManager.getInstance(); const publicClient = evmClientManager.getClient(Networks.Base); + const { maxFeePerGas, maxPriorityFeePerGas } = await publicClient.estimateFeesPerGas(); + + // If only vortex fees (no partner split), use a direct ERC20 transfer + if (hasVortexFees && !hasPartnerFees) { + const transferCallData = encodeFunctionData({ + abi: erc20ABI, + args: [vortexPayoutAddress, vortexTotalUsdcRaw.toFixed(0)], + functionName: "transfer" + }); + + logger.debug(`EVM fee distribution (vortex only): ${vortexTotalUsdcRaw.toFixed(0)} to ${vortexPayoutAddress}`); - // Encode USDC transfer to vortex payout address - const transferCallData = encodeFunctionData({ - abi: erc20ABI, - args: [vortexPayoutAddress, totalFeeUsdcRaw.toFixed(0)], - functionName: "transfer" + return { + data: transferCallData as `0x${string}`, + gas: "100000", + maxFeePerGas: String(maxFeePerGas), + maxPriorityFeePerGas: String(maxPriorityFeePerGas), + to: usdcAddress, + value: "0" + }; + } + + // Build Multicall3 calls for split distribution + const calls: { target: `0x${string}`; allowFailure: boolean; callData: `0x${string}` }[] = []; + + if (hasVortexFees) { + calls.push({ + allowFailure: false, + callData: encodeFunctionData({ + abi: erc20ABI, + args: [vortexPayoutAddress, vortexTotalUsdcRaw.toFixed(0)], + functionName: "transfer" + }) as `0x${string}`, + target: usdcAddress as `0x${string}` + }); + } + + if (hasPartnerFees && partnerPayoutAddressEvm) { + calls.push({ + allowFailure: false, + callData: encodeFunctionData({ + abi: erc20ABI, + args: [partnerPayoutAddressEvm, partnerMarkupFeeUsdcRaw.toFixed(0)], + functionName: "transfer" + }) as `0x${string}`, + target: usdcAddress as `0x${string}` + }); + } + + const multicallData = encodeFunctionData({ + abi: multicall3ABI, + args: [calls], + functionName: "aggregate3" }); - const { maxFeePerGas, maxPriorityFeePerGas } = await publicClient.estimateFeesPerGas(); logger.debug( - `Fee distribution inputs: totalFeeUsdcRaw: ${totalFeeUsdcRaw.toFixed(0)}, maxFeePerGas: ${maxFeePerGas}, maxPriorityFeePerGas: ${maxPriorityFeePerGas}` + `EVM fee distribution (split): vortex=${vortexTotalUsdcRaw.toFixed(0)} to ${vortexPayoutAddress}, partner=${partnerMarkupFeeUsdcRaw.toFixed(0)} to ${partnerPayoutAddressEvm}` ); - const txData: EvmTransactionData = { - data: transferCallData as `0x${string}`, - gas: "100000", + + return { + data: multicallData as `0x${string}`, + gas: "150000", maxFeePerGas: String(maxFeePerGas), maxPriorityFeePerGas: String(maxPriorityFeePerGas), - to: baseUsdcConfig.erc20AddressSourceChain, + to: MULTICALL3_ADDRESS, value: "0" }; - - return txData; } /** diff --git a/apps/api/src/contracts/Multicall3.ts b/apps/api/src/contracts/Multicall3.ts new file mode 100644 index 000000000..a89b8b360 --- /dev/null +++ b/apps/api/src/contracts/Multicall3.ts @@ -0,0 +1,30 @@ +export const MULTICALL3_ADDRESS = "0xcA11bde05977b3631167028862bE2a173976CA11" as const; + +export const multicall3ABI = [ + { + inputs: [ + { + components: [ + { name: "target", type: "address" }, + { name: "allowFailure", type: "bool" }, + { name: "callData", type: "bytes" } + ], + name: "calls", + type: "tuple[]" + } + ], + name: "aggregate3", + outputs: [ + { + components: [ + { name: "success", type: "bool" }, + { name: "returnData", type: "bytes" } + ], + name: "returnData", + type: "tuple[]" + } + ], + stateMutability: "payable", + type: "function" + } +] as const; From 86caa8dbb03da377a55a8c8cd72ee4f95c323e93 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Wed, 29 Apr 2026 11:43:20 +0200 Subject: [PATCH 39/78] Rename 026-add-payout-address-evm-to-partners.ts --- ...m-to-partners.ts => 026-add-payout-address-evm-to-partners.ts} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename apps/api/src/database/migrations/{025-add-payout-address-evm-to-partners.ts => 026-add-payout-address-evm-to-partners.ts} (100%) diff --git a/apps/api/src/database/migrations/025-add-payout-address-evm-to-partners.ts b/apps/api/src/database/migrations/026-add-payout-address-evm-to-partners.ts similarity index 100% rename from apps/api/src/database/migrations/025-add-payout-address-evm-to-partners.ts rename to apps/api/src/database/migrations/026-add-payout-address-evm-to-partners.ts From 2d79041eacf6a8b613f6a3df92af77a8225f511d Mon Sep 17 00:00:00 2001 From: Kacper Szarkiewicz Date: Thu, 30 Apr 2026 14:51:32 +0200 Subject: [PATCH 40/78] remove typeDocument from Mexico KYC form --- .../components/Alfredpay/MxnKycFormScreen.tsx | 18 ------------------ .../supported-fiat-currencies.endpoints.ts | 4 ++-- .../shared/src/services/alfredpay/types.ts | 2 +- 3 files changed, 3 insertions(+), 21 deletions(-) diff --git a/apps/frontend/src/components/Alfredpay/MxnKycFormScreen.tsx b/apps/frontend/src/components/Alfredpay/MxnKycFormScreen.tsx index b1ba119f7..dbab1af23 100644 --- a/apps/frontend/src/components/Alfredpay/MxnKycFormScreen.tsx +++ b/apps/frontend/src/components/Alfredpay/MxnKycFormScreen.tsx @@ -14,7 +14,6 @@ const schema = z.object({ firstName: z.string().min(1), lastName: z.string().min(1), state: z.string().min(1), - typeDocument: z.enum(["INE", "Resident card", "passport"]), zipCode: z.string().min(1) }); @@ -100,23 +99,6 @@ export function MxnKycFormScreen({ onSubmit }: MxnKycFormScreenProps) { {errors.email && {errors.email.message}} -
- - - {errors.typeDocument && {errors.typeDocument.message}} -
-
@@ -139,15 +144,20 @@ export function ColKycFormScreen({ onSubmit }: ColKycFormScreenProps) { - +
+ + + (v ? `+${v.replace(/^\+/, "")}` : v) + })} + /> +
{errors.phoneNumber && {errors.phoneNumber.message}} From d4a0f653412979f967a9c38c1c13805dfa309209 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 30 Apr 2026 15:17:03 +0000 Subject: [PATCH 42/78] fix(ColKycFormScreen): use ColKycFormValues type and fix phone normalization Agent-Logs-Url: https://github.com/pendulum-chain/vortex/sessions/b3c8ee52-8e6f-4d2a-9417-4c40ef144510 Co-authored-by: ebma <6690623+ebma@users.noreply.github.com> --- apps/frontend/src/components/Alfredpay/ColKycFormScreen.tsx | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/apps/frontend/src/components/Alfredpay/ColKycFormScreen.tsx b/apps/frontend/src/components/Alfredpay/ColKycFormScreen.tsx index bb48e23c9..49d765e2f 100644 --- a/apps/frontend/src/components/Alfredpay/ColKycFormScreen.tsx +++ b/apps/frontend/src/components/Alfredpay/ColKycFormScreen.tsx @@ -3,7 +3,6 @@ import { AlfredpayColombiaDocumentType } from "@vortexfi/shared"; import { Controller, useForm } from "react-hook-form"; import { useTranslation } from "react-i18next"; import { z } from "zod"; -import type { MxnKycFormData } from "../../machines/alfredpayKyc.machine"; import { MenuButtons } from "../MenuButtons"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "../ui/select"; @@ -29,7 +28,7 @@ const schema = z type ColKycFormValues = z.infer; interface ColKycFormScreenProps { - onSubmit: (data: MxnKycFormData) => void; + onSubmit: (data: ColKycFormValues) => void; } export function ColKycFormScreen({ onSubmit }: ColKycFormScreenProps) { @@ -154,7 +153,7 @@ export function ColKycFormScreen({ onSubmit }: ColKycFormScreenProps) { placeholder="573000000000" type="tel" {...register("phoneNumber", { - setValueAs: (v: string) => (v ? `+${v.replace(/^\+/, "")}` : v) + setValueAs: (v: string) => (v ? `+${v.replace(/^\+/, "").replace(/\D/g, "")}` : v) })} /> From 34df1621b73928bba3990cb6e6de3aaa40351388 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Mon, 4 May 2026 19:40:26 +0200 Subject: [PATCH 43/78] Fix bug with race condition of progress page sometimes rendering old ramp status --- apps/frontend/src/pages/progress/index.tsx | 50 ++++++++++++++++------ 1 file changed, 36 insertions(+), 14 deletions(-) diff --git a/apps/frontend/src/pages/progress/index.tsx b/apps/frontend/src/pages/progress/index.tsx index ce54bf363..aab6e8bdb 100644 --- a/apps/frontend/src/pages/progress/index.tsx +++ b/apps/frontend/src/pages/progress/index.tsx @@ -426,12 +426,12 @@ export const ProgressPage = () => { const { t } = useTranslation(); const { trackEvent } = useEventsContext(); const rampActor = useRampActor(); - const intervalRef = useRef(null); const { rampState } = useSelector(rampActor, state => ({ rampState: state.context.rampState })); + const rampId = rampState?.ramp?.id; const prevPhaseRef = useRef(rampState?.ramp?.currentPhase || "initial"); const [currentPhase, setCurrentPhase] = useState(prevPhaseRef.current); @@ -452,24 +452,42 @@ export const ProgressPage = () => { return false; }, [rampState?.ramp?.currentPhase, rampState?.ramp?.createdAt]); + // Sync displayed phase to the active ramp. This also covers ramp-identity + // changes (e.g. user starts a new ramp): a new ramp's phase resets to + // "initial", so we won't briefly render the previous ramp's phase before + // the next poll lands. useEffect(() => { - if (!rampState?.ramp?.id || !flowType || intervalRef.current) return; + const newPhase = rampState?.ramp?.currentPhase ?? "initial"; + prevPhaseRef.current = newPhase; + setCurrentPhase(newPhase); + }, [rampState?.ramp?.currentPhase]); - const rampId = rampState.ramp.id; + useEffect(() => { + if (!rampId || !flowType) return; + + let cancelled = false; //XSTATE: we could also move this into an internal process inside the FollowUp state. const fetchRampState = async () => { try { const updatedRampProcess = await RampService.getRampStatus(rampId); + if (cancelled) return; - const currentRampState = rampState; - if (currentRampState) { - const updatedRampState = { - ...currentRampState, - ramp: updatedRampProcess - }; - rampActor.send({ rampState: updatedRampState, type: "SET_RAMP_STATE" }); - } + // Defensive: the backend should echo the rampId we asked for, but if it + // doesn't, drop the response rather than overwriting state. + if (updatedRampProcess.id !== rampId) return; + + // Read the latest snapshot from the actor instead of relying on a + // stale closure over `rampState`. If the active ramp has changed (or + // been cleared) since this poll started, ignore this response - it + // belongs to a stale ramp and would otherwise overwrite the new one. + const latest = rampActor.getSnapshot().context.rampState; + if (!latest || latest.ramp?.id !== rampId) return; + + rampActor.send({ + rampState: { ...latest, ramp: updatedRampProcess }, + type: "SET_RAMP_STATE" + }); const maybeNewPhase = updatedRampProcess.currentPhase; if (maybeNewPhase !== prevPhaseRef.current) { @@ -484,14 +502,18 @@ export const ProgressPage = () => { setCurrentPhase(maybeNewPhase); } } catch (error) { - console.error("Failed to fetch ramp state:", error); + if (!cancelled) console.error("Failed to fetch ramp state:", error); } }; fetchRampState(); const intervalId = setInterval(fetchRampState, 5000); - intervalRef.current = intervalId; - }, [rampState?.ramp?.id, phaseSequence, rampState, trackEvent, flowType, rampActor.send]); + + return () => { + cancelled = true; + clearInterval(intervalId); + }; + }, [rampId, flowType, phaseSequence, rampActor, trackEvent]); return (
From 8a78be56e551943af0d23c60eb3be0232263b56c Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Mon, 4 May 2026 20:15:29 +0200 Subject: [PATCH 44/78] Fix bug with invalid nabla contract call name --- packages/shared/src/services/nabla/transactions/index.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/shared/src/services/nabla/transactions/index.ts b/packages/shared/src/services/nabla/transactions/index.ts index 955ca861e..376fb797c 100644 --- a/packages/shared/src/services/nabla/transactions/index.ts +++ b/packages/shared/src/services/nabla/transactions/index.ts @@ -118,7 +118,7 @@ export async function createNablaTransactionsForOnrampOnEVM( { name: "_to", type: "address" }, { name: "_deadline", type: "uint256" } ], - name: "swapExactTokensForTokens", + name: "swapExactTokensForTokensWithoutPriceFeedUpdate", outputs: [{ type: "uint256[]" }], stateMutability: "nonpayable", type: "function" @@ -134,7 +134,7 @@ export async function createNablaTransactionsForOnrampOnEVM( ephemeralAddress, BigInt(deadline) ], - functionName: "swapExactTokensForTokens" + functionName: "swapExactTokensForTokensWithoutPriceFeedUpdate" }); const { maxFeePerGas: swapMaxFee, maxPriorityFeePerGas: swapMaxPriority } = await baseClient.estimateFeesPerGas(); From 6ea53d9d0ad787e929e59d4d79f4437b9fcbf416 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Mon, 4 May 2026 20:15:44 +0200 Subject: [PATCH 45/78] Add check for already funded ephemeral address to optimize mint flow --- .../handlers/brla-onramp-mint-handler.ts | 49 ++++++++++++++++--- 1 file changed, 42 insertions(+), 7 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 a06bcbb12..493e5e8da 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,10 @@ import { BrlaApiService, BrlaCurrency, checkEvmBalancePeriodically, + EvmAddress, EvmToken, evmTokenConfig, + getEvmTokenBalance, Networks, RampPhase, waitUntilTrueWithTimeout @@ -63,6 +65,24 @@ export class BrlaOnrampMintHandler extends BasePhaseHandler { }); } + const tokenDetails = evmTokenConfig[Networks.Base][EvmToken.BRLA]; + if (!tokenDetails) { + throw new Error("BRLA token details not found for Base network"); + } + + const expectedAmountReceived = quote.metadata.aveniaTransfer.outputAmountRaw; + + // Recovery shortcut: a previous run may have already minted on Avenia and + // transferred to the ephemeral. If the ephemeral already holds the expected + // amount, skip the Avenia balance wait and the (idempotent-but-wasteful) + // pay-out ticket creation. + if (await this.ephemeralAlreadyFunded(tokenDetails.erc20AddressSourceChain, evmEphemeralAddress, expectedAmountReceived)) { + logger.info( + `BrlaOnrampMintHandler: Ephemeral ${evmEphemeralAddress} already holds the expected ${expectedAmountReceived} BRLA. Skipping mint flow.` + ); + return this.transitionToNextPhase(state, "fundEphemeral"); + } + const brlaApiService = BrlaApiService.getInstance(); try { logger.info( @@ -124,19 +144,12 @@ export class BrlaOnrampMintHandler extends BasePhaseHandler { taxIdRecord.subAccountId ); - 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 Base address ${state.state.evmEphemeralAddress}` ); try { const pollingTimeMs = 1000; - const tokenDetails = evmTokenConfig[Networks.Base][EvmToken.BRLA]; - - if (!tokenDetails) { - throw new Error("BRLA token details not found for Base network"); - } await checkEvmBalancePeriodically( tokenDetails.erc20AddressSourceChain, @@ -163,6 +176,28 @@ export class BrlaOnrampMintHandler extends BasePhaseHandler { return this.transitionToNextPhase(state, "fundEphemeral"); } + private async ephemeralAlreadyFunded( + tokenAddress: string, + ownerAddress: string, + expectedAmountRaw: string + ): Promise { + try { + const balance = await getEvmTokenBalance({ + chain: Networks.Base, + ownerAddress: ownerAddress as EvmAddress, + tokenAddress: tokenAddress as EvmAddress + }); + return balance.gte(new Big(expectedAmountRaw)); + } catch (error) { + // Treat read failures as "not funded" so we fall through to the regular + // flow rather than aborting the phase on a transient RPC error. + logger.warn( + `BrlaOnrampMintHandler: ephemeral balance pre-check failed for ${ownerAddress}, falling back to Avenia flow: ${error}` + ); + return false; + } + } + protected isPaymentTimeoutReached(state: RampState): boolean { const thisPhaseEntry = state.phaseHistory.find(phaseHistoryEntry => phaseHistoryEntry.phase === this.getPhaseName()); if (!thisPhaseEntry) { From 2c034f95e05bced16a56b7823fa7f42737a14465 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Mon, 4 May 2026 20:15:51 +0200 Subject: [PATCH 46/78] Add debug log --- packages/shared/src/services/evm/balance.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/shared/src/services/evm/balance.ts b/packages/shared/src/services/evm/balance.ts index ec1ee6268..d5c22397d 100644 --- a/packages/shared/src/services/evm/balance.ts +++ b/packages/shared/src/services/evm/balance.ts @@ -105,6 +105,10 @@ export function checkEvmBalancePeriodically( const someBalanceBig = new Big(result); const amountDesiredUnitsBig = new Big(amountDesiredRaw); + logger.current.debug( + `checkEvmBalancePeriodically: Balance of ${brlaEvmAddress} for token ${tokenAddress} on ${chain}: ${someBalanceBig.toString()} (target: ${amountDesiredRaw})` + ); + if (someBalanceBig.gte(amountDesiredUnitsBig)) { resolve(someBalanceBig); } else if (Date.now() - startTime > timeoutMs) { From 1689691ab594b0846ba3d026cf562852c3912345 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Mon, 4 May 2026 20:30:45 +0200 Subject: [PATCH 47/78] Enhance error handling for pixKey and receiverTaxId validation with detailed logging --- .../api/src/api/services/ramp/ramp.service.ts | 46 +++++++++++++++---- 1 file changed, 37 insertions(+), 9 deletions(-) diff --git a/apps/api/src/api/services/ramp/ramp.service.ts b/apps/api/src/api/services/ramp/ramp.service.ts index 988a88402..2ea6a2bbd 100644 --- a/apps/api/src/api/services/ramp/ramp.service.ts +++ b/apps/api/src/api/services/ramp/ramp.service.ts @@ -769,16 +769,44 @@ export class RampService extends BaseRampService { } // To make it harder to extract information, both the pixKey and the receiverTaxId are required to be correct. + // The user-facing error stays generic, but server-side logs differentiate failure modes for diagnosis. + let pixKeyData; try { - const pixKeyData = await brlaApiService.validatePixKey(pixKey); - //validate the recipient's taxId with partial information - if (!validateMaskedNumber(normalizeTaxId(pixKeyData.taxId), normalizeTaxId(receiverTaxId))) { - throw new APIError({ - message: "Invalid pixKey or receiverTaxId.", - status: httpStatus.BAD_REQUEST - }); - } - } catch (_error) { + pixKeyData = await brlaApiService.validatePixKey(pixKey); + } catch (error) { + logger.warn( + `validateBrlaOfframpRequest: pix-info lookup failed for pixKey=${pixKey}: ${ + error instanceof Error ? error.message : String(error) + }` + ); + throw new APIError({ + message: "Invalid pixKey or receiverTaxId.", + status: httpStatus.BAD_REQUEST + }); + } + + let masksMatch: boolean; + try { + // Do NOT pass the masked taxId through normalizeTaxId: that helper strips all + // non-digits, which would also strip the `*` mask characters and break the + // length-aligned comparison done by validateMaskedNumber. + masksMatch = validateMaskedNumber(pixKeyData.taxId, normalizeTaxId(receiverTaxId)); + } catch (error) { + logger.warn( + `validateBrlaOfframpRequest: pix key owner taxId is not comparable to receiverTaxId. masked=${pixKeyData.taxId}, provided=${normalizeTaxId( + receiverTaxId + )}: ${error instanceof Error ? error.message : String(error)}` + ); + throw new APIError({ + message: "Invalid pixKey or receiverTaxId.", + status: httpStatus.BAD_REQUEST + }); + } + + if (!masksMatch) { + logger.warn( + `validateBrlaOfframpRequest: pix key owner taxId does not match receiverTaxId. masked=${pixKeyData.taxId}, provided=${normalizeTaxId(receiverTaxId)}` + ); throw new APIError({ message: "Invalid pixKey or receiverTaxId.", status: httpStatus.BAD_REQUEST From db9f7d77928d54a92fae955ee6187ab9e9418b89 Mon Sep 17 00:00:00 2001 From: Kacper Szarkiewicz Date: Tue, 5 May 2026 20:42:10 +0200 Subject: [PATCH 48/78] Add Mexico KYB Alfredpay types --- .../shared/src/services/alfredpay/types.ts | 46 +++++++++++++++---- 1 file changed, 38 insertions(+), 8 deletions(-) diff --git a/packages/shared/src/services/alfredpay/types.ts b/packages/shared/src/services/alfredpay/types.ts index 93a5a6415..953ee2088 100644 --- a/packages/shared/src/services/alfredpay/types.ts +++ b/packages/shared/src/services/alfredpay/types.ts @@ -411,11 +411,12 @@ export enum AlfredpayKycFileType { // KYB form submission types export enum AlfredpayKybFileType { + TAX_ID_DOCUMENT = "taxIdDocument", ARTICLES_INCORPORATION = "articlesIncorporation", - PROOF_ADDRESS = "proofAddress", - SHAREHOLDER_REGISTRY = "shareholderRegistry" + PROOF_ADDRESS = "proofAddress" } +/** Penny relate-person upload: only docFront + docBack (URI fields are derived server-side). */ export enum AlfredpayKybRelatedPersonFileType { DOC_FRONT = "docFront", DOC_BACK = "docBack" @@ -444,13 +445,42 @@ export interface SubmitKybInformationRequest { relatedPersons: AlfredpayKybRelatedPerson[]; } -export interface AlfredpayKybRelatedPersonResponse { - id: string; - firstName: string; - lastName: string; +export interface SubmitKybInformationResponse { + submissionId: string; } -export interface SubmitKybInformationResponse { +/** Single related-person row from `GET …/customers/{customerId}/kyb/details`. */ +export interface AlfredpayKybRelatedPersonDetails { + idRelatedPerson: string; + firstName?: string; + lastName?: string; + email?: string; + dateOfBirth?: string; + nationalities?: string[]; + docFront?: string | null; + docBack?: string | null; + docFrontUri?: string | null; + docBackUri?: string | null; +} + +/** Response item from `GET …/customers/{customerId}/kyb/details`; the endpoint returns an array. */ +export interface AlfredpayKybCustomerAndBusiness { + customerId: string; + country: string; + businessName: string; + taxId: string; + website?: string; + state: string; + city: string; + address: string; + zipCode: string; submissionId: string; - relatedPersons?: AlfredpayKybRelatedPersonResponse[]; + relatedPersons: AlfredpayKybRelatedPersonDetails[]; + verificationSessionId?: string | null; + articlesIncorporation?: string; + articlesIncorporationUri?: string; + proofAddress?: string; + proofAddressUri?: string; + taxIdDocument?: string; + taxIdDocumentUri?: string; } From 7a9cce2619eded041c61a15a8ab0e3bb1492eb59 Mon Sep 17 00:00:00 2001 From: Kacper Szarkiewicz Date: Tue, 5 May 2026 20:42:41 +0200 Subject: [PATCH 49/78] add Mexico KYB Alfredpay Related Person endpoints --- .../services/alfredpay/alfredpayApiService.ts | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/packages/shared/src/services/alfredpay/alfredpayApiService.ts b/packages/shared/src/services/alfredpay/alfredpayApiService.ts index a5670f263..d01f0cb63 100644 --- a/packages/shared/src/services/alfredpay/alfredpayApiService.ts +++ b/packages/shared/src/services/alfredpay/alfredpayApiService.ts @@ -7,6 +7,7 @@ import { AlfredpayFiatAccountFields, AlfredpayFiatAccountType, AlfredpayFiatCurrency, + AlfredpayKybCustomerAndBusiness, AlfredpayKybFileType, AlfredpayKybRelatedPersonFileType, AlfredpayKycFileType, @@ -263,7 +264,7 @@ export class AlfredpayApiService { file: Blob ): Promise { const formData = new FormData(); - formData.append("fileBody", file); + formData.append("rawBody", file); formData.append("fileType", fileType); const url = `${ALFREDPAY_BASE_URL}/api/v1/third-party-service/penny/customers/${customerId}/kyc/${submissionId}/files`; @@ -295,6 +296,15 @@ export class AlfredpayApiService { return (await this.executeRequest(path, "POST", { kybSubmission: data })) as SubmitKybInformationResponse; } + /** + * Alfredpay: GET …/customers/{customerId}/kyb/details — returns the relate-person ids needed for KYB file uploads. + * Docs: https://alfredpay.readme.io/v2.0/reference/kybcontroller_findcustomerandbusiness-1 + */ + public async getKybBusinessDetails(customerId: string): Promise { + const path = `/api/v1/third-party-service/penny/customers/${customerId}/kyb/details`; + return (await this.executeRequest(path, "GET")) ?? []; + } + public async submitKybFiles( customerId: string, submissionId: string, @@ -321,6 +331,11 @@ export class AlfredpayApiService { } } + /** + * Penny: POST …/customers/{customerId}/kyb/{idRelatedPerson}/files/relate-person + * Path segment = Penny “Related Person ID” (from KYB submit response `relatedPersons[].id`, not the customerId). + * Docs (typo in path name): https://alfredpay.readme.io/v2.0/reference/kybcontroller_submitkybfilerelateperson-3 + */ public async submitKybRelatedPersonFiles( customerId: string, relatedPersonId: string, From 210fff8807c5c472d6927caa6b98ef889c23a9e7 Mon Sep 17 00:00:00 2001 From: Kacper Szarkiewicz Date: Tue, 5 May 2026 20:50:55 +0200 Subject: [PATCH 50/78] refactor Mexico KYB Flow --- .../api/controllers/alfredpay.controller.ts | 33 ++- apps/api/src/api/routes/v1/alfredpay.route.ts | 1 + .../components/Alfredpay/AlfredpayKycFlow.tsx | 2 + .../Alfredpay/KybBusinessDocsScreen.tsx | 32 ++- .../src/machines/alfredpayKyc.machine.ts | 209 ++++++++++++++---- 5 files changed, 227 insertions(+), 50 deletions(-) diff --git a/apps/api/src/api/controllers/alfredpay.controller.ts b/apps/api/src/api/controllers/alfredpay.controller.ts index 1e628b314..7def597d1 100644 --- a/apps/api/src/api/controllers/alfredpay.controller.ts +++ b/apps/api/src/api/controllers/alfredpay.controller.ts @@ -547,6 +547,30 @@ export class AlfredpayController { } } + static async findKybCustomerAndBusiness(req: Request, res: Response) { + try { + const { country } = req.query as { country: string }; + const userId = req.userId!; + + const alfredPayCustomer = await AlfredPayCustomer.findOne({ + where: { country: country as AlfredPayCountry, type: AlfredpayCustomerType.BUSINESS, userId } + }); + + if (!alfredPayCustomer) { + return res.status(404).json({ error: "Alfredpay business customer not found" }); + } + + const alfredpayService = AlfredpayApiService.getInstance(); + const details = await alfredpayService.getKybBusinessDetails(alfredPayCustomer.alfredPayId); + + res.json(details); + } catch (error) { + logger.error("Error finding Alfredpay KYB customer and business:", error); + const message = error instanceof Error ? error.message : "Internal server error"; + res.status(500).json({ error: message }); + } + } + static async submitKybFile(req: Request, res: Response) { try { const { country, submissionId, fileType } = req.body as { country: string; submissionId: string; fileType: string }; @@ -582,6 +606,7 @@ export class AlfredpayController { } static async submitKybRelatedPersonFile(req: Request, res: Response) { + let pennyCustomerId: string | undefined; try { const { country, relatedPersonId, fileType } = req.body as { country: string; @@ -602,6 +627,8 @@ export class AlfredpayController { return res.status(404).json({ error: "Alfredpay business customer not found" }); } + pennyCustomerId = alfredPayCustomer.alfredPayId; + const fileBlob = new File([new Uint8Array(req.file.buffer)], req.file.originalname, { type: req.file.mimetype }); const alfredpayService = AlfredpayApiService.getInstance(); await alfredpayService.submitKybRelatedPersonFiles( @@ -613,7 +640,11 @@ export class AlfredpayController { res.json({ success: true }); } catch (error) { - logger.error("Error submitting KYB related person file:", error); + const body = req.body as { country?: string; relatedPersonId?: string; fileType?: string }; + const errSummary = error instanceof Error ? error.message : String(error); + logger.error( + `[submitKybRelatedPersonFile] ${errSummary} | customerIdPenny=${pennyCustomerId ?? "n/a"} relatedPersonId=${body.relatedPersonId ?? "n/a"} userId=${req.userId ?? "n/a"}` + ); const message = error instanceof Error ? error.message : "Internal server error"; res.status(500).json({ error: message }); } diff --git a/apps/api/src/api/routes/v1/alfredpay.route.ts b/apps/api/src/api/routes/v1/alfredpay.route.ts index 1a464668d..e2009db8e 100644 --- a/apps/api/src/api/routes/v1/alfredpay.route.ts +++ b/apps/api/src/api/routes/v1/alfredpay.route.ts @@ -25,6 +25,7 @@ router.post("/sendKycSubmission", requireAuth, validateResultCountry, AlfredpayC // Business API-based KYB router.post("/submitKybInformation", requireAuth, validateResultCountry, AlfredpayController.submitKybInformation); router.post("/submitKybFile", requireAuth, upload.single("file"), validateResultCountry, AlfredpayController.submitKybFile); +router.get("/findKybCustomerAndBusiness", requireAuth, validateResultCountry, AlfredpayController.findKybCustomerAndBusiness); router.post( "/submitKybRelatedPersonFile", requireAuth, diff --git a/apps/frontend/src/components/Alfredpay/AlfredpayKycFlow.tsx b/apps/frontend/src/components/Alfredpay/AlfredpayKycFlow.tsx index 3b12570ce..0b97500d4 100644 --- a/apps/frontend/src/components/Alfredpay/AlfredpayKycFlow.tsx +++ b/apps/frontend/src/components/Alfredpay/AlfredpayKycFlow.tsx @@ -70,6 +70,8 @@ export const AlfredpayKycFlow = () => { stateValue === "SendingSubmission" || stateValue === "SubmittingKybInfo" || stateValue === "SubmittingKybBusinessFiles" || + stateValue === "FindingKybCustomerAndBusiness" || + stateValue === "SubmittingKybRelatedPersonBundle" || stateValue === "SubmittingKybPersonFiles" || stateValue === "SendingKybSubmission" ) { diff --git a/apps/frontend/src/components/Alfredpay/KybBusinessDocsScreen.tsx b/apps/frontend/src/components/Alfredpay/KybBusinessDocsScreen.tsx index 1b6307cc8..fe2a59975 100644 --- a/apps/frontend/src/components/Alfredpay/KybBusinessDocsScreen.tsx +++ b/apps/frontend/src/components/Alfredpay/KybBusinessDocsScreen.tsx @@ -71,15 +71,18 @@ interface KybBusinessDocsScreenProps { export function KybBusinessDocsScreen({ onBack, onSubmit }: KybBusinessDocsScreenProps) { const { t } = useTranslation(); + const [taxIdDocument, setTaxIdDocument] = useState(null); const [articlesIncorporation, setArticlesIncorporation] = useState(null); const [proofAddress, setProofAddress] = useState(null); - const [shareholderRegistry, setShareholderRegistry] = useState(null); + const [docFront, setDocFront] = useState(null); + const [docBack, setDocBack] = useState(null); - const isValid = articlesIncorporation !== null && proofAddress !== null && shareholderRegistry !== null; + const isValid = + taxIdDocument !== null && articlesIncorporation !== null && proofAddress !== null && docFront !== null && docBack !== null; const handleSubmit = () => { - if (!articlesIncorporation || !proofAddress || !shareholderRegistry) return; - onSubmit({ articlesIncorporation, proofAddress, shareholderRegistry }); + if (!taxIdDocument || !articlesIncorporation || !proofAddress || !docFront || !docBack) return; + onSubmit({ articlesIncorporation, docBack, docFront, proofAddress, taxIdDocument }); }; return ( @@ -89,6 +92,12 @@ export function KybBusinessDocsScreen({ onBack, onSubmit }: KybBusinessDocsScree

{t("components.kybBusinessDocs.subtitle")}

+ + -

{t("components.mxnDocumentUpload.fileHint")}