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/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/brla-payout-moonbeam-handler.ts b/apps/api/src/api/services/phases/handlers/brla-payout-base-handler.ts similarity index 63% 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..d04d86f33 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 @@ -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"; @@ -8,13 +16,13 @@ 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 { - 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 BrlaPayoutOnMoonbeamPhaseHandler 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 BrlaPayoutOnMoonbeamPhaseHandler 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 BrlaPayoutOnMoonbeamPhaseHandler extends BasePhaseHandler { subAccountId: taxIdRecord.subAccountId }); - logger.debug("Debug: payOutQuote", payOutQuote); const payOutTicketParams: PixOutputTicketPayload = { quoteToken: payOutQuote.quoteToken, ticketBlockchainInput: { @@ -114,8 +124,10 @@ export class BrlaPayoutOnMoonbeamPhaseHandler 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,78 @@ export class BrlaPayoutOnMoonbeamPhaseHandler 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.`); + } + } 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 @@ -179,4 +263,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 706e36714..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 @@ -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; @@ -55,23 +61,47 @@ 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" + : isBrlInvolved + ? "subsidizePreSwapEvm" + : "subsidizePreSwap"; // 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" || quote.outputCurrency === "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 from existing hash."); + }); - 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 from existing hash."); + }); + + 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 +113,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 +139,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 +322,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/fund-ephemeral-handler.ts b/apps/api/src/api/services/phases/handlers/fund-ephemeral-handler.ts index 3ffc1b0cc..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 @@ -11,23 +11,25 @@ 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"; +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"; 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"; import { horizonServer, + isBaseEphemeralFunded, isDestinationEvmEphemeralFunded, - isMoonbeamEphemeralFunded, isPendulumEphemeralFunded, isPolygonEphemeralFunded, isStellarEphemeralFunded, @@ -56,7 +58,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 { @@ -78,6 +81,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; } @@ -93,9 +100,9 @@ export class FundEphemeralPhaseHandler extends BasePhaseHandler { return false; } - protected getRequiresMoonbeamEphemeralAddress(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 +127,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 +139,7 @@ export class FundEphemeralPhaseHandler extends BasePhaseHandler { quote.inputCurrency, quote.outputCurrency ); - const requiresMoonbeamEphemeralAddress = this.getRequiresMoonbeamEphemeralAddress(state, quote.inputCurrency); + const requiresBaseEphemeralAddress = this.getRequiresBaseEphemeralAddress(quote.inputCurrency, quote.outputCurrency); const requiresDestinationEvmFunding = this.getRequiresDestinationEvmFunding(state); // Ephemeral checks. @@ -152,9 +158,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 +191,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 (!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 +229,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) { @@ -248,6 +245,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 } @@ -312,27 +311,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 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, - polygon.nativeCurrency.decimals - ).toFixed(); + const fundingAmountRaw = multiplyByPowerOfTen(amountToFundUnits, 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 +344,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/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/phases/handlers/nabla-approve-handler.ts b/apps/api/src/api/services/phases/handlers/nabla-approve-handler.ts index c77300379..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 @@ -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,16 +23,33 @@ 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) { throw new Error("Quote not found for the given state"); } + if (!quote.metadata.nablaSwap && !quote.metadata.nablaSwapEvm) { + throw new Error("Missing nablaSwap info in quote metadata"); + } + + const { substrateEphemeralAddress } = state.state as StateMetadata; + + // 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: Invalid state. Missing substrate ephemeral address for a non-BRL quote."); + } + } + + 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"); } @@ -99,6 +125,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, "nablaApproveEvm"); + + 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..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 @@ -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 || quote.outputCurrency === FiatToken.BRL) { + return this.executeEvmSwap(state, quote); + } else if (substrateEphemeralAddress) { + return this.executeSubstrateSwap(state, quote); + } else { + throw new Error("NablaSwapPhaseHandler: Invalid state. Missing substrate ephemeral address for a non-BRL quote."); + } + } + + 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,46 @@ export class NablaSwapPhaseHandler extends BasePhaseHandler { const nextPhase = state.type === RampDirection.BUY ? "distributeFees" : "subsidizePostSwap"; return this.transitionToNextPhase(state, nextPhase); } + + private async executeEvmSwap(state: RampState, quote: QuoteTicket): Promise { + const evmClientManager = EvmClientManager.getInstance(); + const baseClient = evmClientManager.getClient(Networks.Base); + + try { + const { txData: nablaSwapTransaction } = this.getPresignedTransaction(state, "nablaSwapEvm"); + + 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}`); + // 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}`); + } + + 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); + } } export default new NablaSwapPhaseHandler(); 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..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 { 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"; @@ -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 0d43c57c0..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); } /** @@ -53,11 +55,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"); @@ -126,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 */ @@ -140,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 new file mode 100644 index 000000000..433802ed7 --- /dev/null +++ b/apps/api/src/api/services/phases/handlers/subsidize-post-swap-evm-handler.ts @@ -0,0 +1,163 @@ +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 SubsidizePostSwapEvmPhaseHandler extends BasePhaseHandler { + public getPhaseName(): RampPhase { + return "subsidizePostSwapEvm"; + } + + 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 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({ + 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 + ); + + 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 + expectedSwapOutputAmountRaw = Big(quote.metadata.evmToEvm?.inputAmountRaw); + } else { + expectedSwapOutputAmountRaw = Big(quote.metadata.nablaSwapEvm.outputAmountRaw); + } + + const requiredAmount = Big(expectedSwapOutputAmountRaw).sub(currentBalance); + console.log("debug: requiredAmount", requiredAmount.toString()); + + 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 { + if (state.type === RampDirection.BUY) { + return "squidRouterSwap"; + } else { + return "brlaPayoutOnBase"; + } + } +} + +export default new SubsidizePostSwapEvmPhaseHandler(); 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..f6848feda --- /dev/null +++ b/apps/api/src/api/services/phases/handlers/subsidize-pre-swap-evm-handler.ts @@ -0,0 +1,133 @@ +import { + checkEvmBalanceForToken, + EvmClientManager, + EvmNetworks, + EvmToken, + EvmTokenDetails, + getOnChainTokenDetails, + Networks, + nativeToDecimal, + 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/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/phases/register-handlers.ts b/apps/api/src/api/services/phases/register-handlers.ts index 712f9890a..6ea9e788e 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,7 +24,9 @@ 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 subsidizePreSwapEvmPhaseHandler from "./handlers/subsidize-pre-swap-evm-handler"; import subsidizePreSwapPhaseHandler from "./handlers/subsidize-pre-swap-handler"; import phaseRegistry from "./phase-registry"; @@ -42,9 +44,11 @@ export function registerPhaseHandlers(): void { phaseRegistry.registerHandler(stellarPaymentHandler); phaseRegistry.registerHandler(spacewalkRedeemHandler); phaseRegistry.registerHandler(subsidizePostSwapPhaseHandler); + phaseRegistry.registerHandler(subsidizePostSwapEvmPhaseHandler); phaseRegistry.registerHandler(subsidizePreSwapPhaseHandler); + phaseRegistry.registerHandler(subsidizePreSwapEvmPhaseHandler); phaseRegistry.registerHandler(moonbeamToPendulumPhaseHandler); - phaseRegistry.registerHandler(brlaPayoutMoonbeamHandler); + phaseRegistry.registerHandler(brlaPayoutBaseHandler); phaseRegistry.registerHandler(fundEphemeralHandler); phaseRegistry.registerHandler(alfredpayOnrampMintHandler); phaseRegistry.registerHandler(alfredpayOfframpTransferHandler); diff --git a/apps/api/src/api/services/quote/core/nabla.ts b/apps/api/src/api/services/quote/core/nabla.ts index 7d174e607..eec6ab7a7 100644 --- a/apps/api/src/api/services/quote/core/nabla.ts +++ b/apps/api/src/api/services/quote/core/nabla.ts @@ -1,9 +1,24 @@ -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}` = "0x0e368D4891C4A52b91b4e1Bf3CdEfcdaAFEF4355"; // TODO modify with router on Base after test +const NABLA_QUOTER_BASE: `0x${string}` = "0xf4B0f7c272354d070CC5C8140826b7BBe56953dA"; + export interface NablaSwapRequest { inputAmountForSwap: string; rampType: RampDirection; @@ -17,6 +32,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 +49,154 @@ 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.BaseSepolia, { + 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: "_tokenPath", type: "address[]" }, + { name: "_routerPath", type: "address[]" } + ], + name: "quoteSwapExactTokensForTokens", + outputs: [{ name: "amountOut_", type: "uint256" }], + stateMutability: "view", + type: "function" + } + ]; + + const result = await evmClientManager.readContractWithRetry(Networks.BaseSepolia, { + abi: swapAbi, + address: NABLA_QUOTER_BASE, + args: [ + BigInt(amountIn), + [ + inputTokenDetails.erc20AddressSourceChain as `0x${string}`, + outputTokenDetails.erc20AddressSourceChain as `0x${string}` + ], + [NABLA_ROUTER_BASE] + ], + functionName: "quoteSwapExactTokensForTokens" + }); + + const preciseQuotedAmountOut = parseContractBalanceResponse(outputTokenDetails.decimals, result); + 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..bc174bd76 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, @@ -17,6 +18,7 @@ import { Big } from "big.js"; export enum StageKey { Initialize = "Initialize", NablaSwap = "NablaSwap", + MergeSubsidy = "MergeSubsidy", PendulumTransfer = "PendulumTransfer", HydrationSwap = "HydrationSwap", SquidRouter = "SquidRouter", @@ -129,6 +131,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/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/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/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/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/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/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/offramp-from-evm-alfredpay.ts b/apps/api/src/api/services/quote/engines/initialize/offramp-from-evm-alfredpay.ts index e9e3db70c..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 @@ -4,23 +4,31 @@ 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 { const req = ctx.request; + await assignPreNablaContext(ctx); + const quoteRequest: EvmBridgeQuoteRequest = { amountDecimal: req.inputAmount, fromNetwork: req.from as Networks, inputCurrency: req.inputCurrency as OnChainToken, outputCurrency: EvmToken.USDC, rampType: req.rampType, - toNetwork: Networks.Polygon + toNetwork: this.network }; const bridgeQuote = await getEvmBridgeQuote(quoteRequest); @@ -37,7 +45,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/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/merge-subsidy/offramp-evm.ts b/apps/api/src/api/services/quote/engines/merge-subsidy/offramp-evm.ts new file mode 100644 index 000000000..5ba2a43b1 --- /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: (BigInt(ctx.nablaSwapEvm.outputAmountRaw) + BigInt(ctx.subsidy.subsidyAmountInOutputTokenRaw)).toString() + }; + + 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/base-evm.ts b/apps/api/src/api/services/quote/engines/nabla-swap/base-evm.ts new file mode 100644 index 000000000..17f20cda3 --- /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.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"); + } + + 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/offramp-evm.ts b/apps/api/src/api/services/quote/engines/nabla-swap/offramp-evm.ts new file mode 100644 index 000000000..9c17b74e4 --- /dev/null +++ b/apps/api/src/api/services/quote/engines/nabla-swap/offramp-evm.ts @@ -0,0 +1,50 @@ +import { EvmToken, RampDirection } from "@vortexfi/shared"; +import { QuoteContext } from "../../core/types"; +import { BaseNablaSwapEngineEvm, NablaSwapEvmComputation } from "./base-evm"; + +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; + console.log( + "passing through OffRampSwapEngineEvm with inputAmountPreFees:", + inputAmountPreFees.toString(), + "inputToken:", + inputToken, + "outputToken:", + this.outputToken + ); + return { + inputAmountPreFees, + inputToken, + outputToken: this.outputToken + }; + } +} 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..77e7a7825 --- /dev/null +++ b/apps/api/src/api/services/quote/engines/nabla-swap/onramp-evm.ts @@ -0,0 +1,37 @@ +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 { + 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 (BRLA on Base) + // The output token is fixed at USDC. + const inputToken = EvmToken.BRLA; + const outputToken = EvmToken.USDC; + + return { + inputAmountPreFees, + inputToken, + outputToken + }; + } +} 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 new file mode 100644 index 000000000..09af4b06d --- /dev/null +++ b/apps/api/src/api/services/quote/engines/squidrouter/onramp-base-to-evm.ts @@ -0,0 +1,95 @@ +import { + 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"; +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.nablaSwapEvm) { + throw new Error( + "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 { + // 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; + + // 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({ + message: `Invalid network for destination: ${req.to} `, + status: httpStatus.BAD_REQUEST + }); + } + + const toToken = getTokenDetailsForEvmDestination(req.outputCurrency as OnChainToken, req.to).erc20AddressSourceChain; + + const usdcBaseTokenDetails = getTokenDetailsForEvmDestination(EvmToken.USDC, Networks.Base); + + return { + data: { + amountRaw: inputAmountRaw, + fromNetwork: Networks.Base, + fromToken: usdcBaseTokenDetails.erc20AddressSourceChain, + inputAmountDecimal: inputAmountDecimal, + inputAmountRaw: inputAmountRaw, + outputDecimals: usdcBaseTokenDetails.decimals, + toNetwork, + toToken + }, + type: "evm-to-evm" + }; + } +} 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..3ef1566a4 100644 --- a/apps/api/src/api/services/quote/routes/route-resolver.ts +++ b/apps/api/src/api/services/quote/routes/route-resolver.ts @@ -5,11 +5,11 @@ 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"; -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(); } } } @@ -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 503e339a8..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,8 +1,9 @@ +import { Networks } from "@vortexfi/shared"; import { EnginesRegistry, IRouteStrategy, QuoteContext, StageKey } from "../../core/types"; import { OffRampEvmToAlfredpayFeeEngine } from "../../engines/fee/offramp-evm-to-alfredpay"; import { OffRampFinalizeEngine } from "../../engines/finalize/offramp"; -import { AlfredpayOffRampFromEvmInitializeEngine } from "../../engines/initialize/offramp-from-evm-alfredpay"; +import { OffRampFromEvmInitializeEngine } from "../../engines/initialize/offramp-from-evm-alfredpay"; import { OfframpTransactionAlfredpayEngine } from "../../engines/partners/offramp-alfredpay"; export class OfframpEvmToAlfredpayStrategy implements IRouteStrategy { @@ -14,7 +15,7 @@ export class OfframpEvmToAlfredpayStrategy implements IRouteStrategy { getEngines(_ctx: QuoteContext): EnginesRegistry { return { - [StageKey.Initialize]: new AlfredpayOffRampFromEvmInitializeEngine(), + [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 new file mode 100644 index 000000000..e314c45ed --- /dev/null +++ b/apps/api/src/api/services/quote/routes/strategies/offramp-to-pix-base.strategy.ts @@ -0,0 +1,27 @@ +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 { 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.NablaSwap, StageKey.Fee, StageKey.Discount, StageKey.MergeSubsidy, StageKey.Finalize]; + } + + getEngines(_ctx: QuoteContext): EnginesRegistry { + return { + [StageKey.Initialize]: new OffRampFromEvmInitializeEngine(Networks.Base), + [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 181e66f29..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 @@ -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,9 +24,11 @@ export class OfframpToPixStrategy implements IRouteStrategy { getEngines(ctx: QuoteContext): EnginesRegistry { return { [StageKey.Initialize]: - ctx.request.from === "assethub" ? new OffRampFromAssethubInitializeEngine() : new OffRampFromEvmInitializeEngine(), - [StageKey.Fee]: new OffRampFeeAveniaEngine(), + ctx.request.from === "assethub" + ? new OffRampFromAssethubInitializeEngine() + : new OffRampFromEvmInitializeEngineMoonbeam(), [StageKey.NablaSwap]: new OffRampSwapEngine(), + [StageKey.Fee]: new OffRampFeeAveniaEngine(), [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-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(), 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..5a7dcf501 --- /dev/null +++ b/apps/api/src/api/services/quote/routes/strategies/onramp-avenia-to-evm.strategy-base.ts @@ -0,0 +1,27 @@ +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 OnrampAveniaToEvmBaseStrategy implements IRouteStrategy { + readonly name = "OnRampAveniaToEvmBase"; + + 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.Fee]: new OnRampAveniaToEvmFeeEngine(Networks.Base, EvmToken.USDC), + [StageKey.NablaSwap]: new OnRampSwapEngineEvm(), + [StageKey.Discount]: new OnRampDiscountEngine(), + [StageKey.SquidRouter]: new OnRampSquidRouterBrlToEvmEngineBase(), + [StageKey.Finalize]: new OnRampFinalizeEngine() + }; + } +} 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 a586193aa..602f1d18d 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,116 @@ 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 (EVM) + const vortexPartner = await Partner.findOne({ + where: { isActive: true, name: "vortex", rampType: quote.rampType } + }); + if (!vortexPartner || !vortexPartner.payoutAddressEvm) { + logger.warn("Vortex partner or EVM payout address not found, skipping EVM fee distribution transaction"); + return null; + } + const vortexPayoutAddress = vortexPartner.payoutAddressEvm; + + // 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(); + console.log( + "fee distr inputs: totalFeeUsdcRaw:", + totalFeeUsdcRaw.toFixed(0), + "maxFeePerGas:", + maxFeePerGas, + "maxPriorityFeePerGas:", + maxPriorityFeePerGas + ); + 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: "distributeFeesEvm", + signer: account.address, + txData: feeDistributionTx + }); + nextNonce++; + } + + return nextNonce; +} 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/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 new file mode 100644 index 000000000..d8ad0547d --- /dev/null +++ b/apps/api/src/api/services/transactions/offramp/routes/evm-to-brl-base.ts @@ -0,0 +1,157 @@ +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 } from "../../common/feeDistribution"; +import { addNablaSwapTransactionsOnBase, addOnrampDestinationChainTransactions } 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 prepareEvmToBRLOfframpBaseTransactions({ + quote, + signingAccounts, + userAddress, + pixDestination, + taxId, + receiverTaxId, + brlaEvmAddress +}: OfframpTransactionParams): Promise { + const unsignedTxs: UnsignedTx[] = []; + let stateMeta: Partial = {}; + + // Validate inputs and extract required data + const { fromNetwork, inputTokenDetails } = 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, + // 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, + baseNonce + ); + stateMeta = { ...stateMeta, ...nablaStateMeta }; + baseNonce = nonceAfterNabla; + + // 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, + pixDestination: validatedPixDestination, + receiverTaxId: validatedReceiverTaxId, + taxId: validatedTaxId + }; + + return { stateMeta, 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 4749f8c78..0d6277d5b 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, @@ -205,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 @@ -224,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" @@ -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.nablaSwapEvm?.inputAmountForSwapRaw) { + throw new Error("Missing nablaSwapEvm input amount in quote metadata"); + } + + // The input amount for the swap was already calculated in the quote. + 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); + + const { approve, swap } = await createNablaTransactionsForOnrampOnEVM( + inputAmountForNablaSwapRaw, + account, + inputTokenAddress, + outputTokenAddress, + nablaHardMinimumOutputRaw + ); + + unsignedTxs.push({ + meta: {}, + network: Networks.Base, + nonce: nextNonce, + phase: "nablaApproveEvm", + signer: account.address, + txData: approve + }); + nextNonce++; + + unsignedTxs.push({ + meta: {}, + network: Networks.Base, + nonce: nextNonce, + phase: "nablaSwapEvm", + 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/index.ts b/apps/api/src/api/services/transactions/onramp/index.ts index 4227e3184..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 { prepareAveniaToEvmOnrampTransactions } 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"; @@ -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)) { 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 new file mode 100644 index 000000000..848e270e6 --- /dev/null +++ b/apps/api/src/api/services/transactions/onramp/routes/avenia-to-evm-base.ts @@ -0,0 +1,246 @@ +import { + createOnrampSquidrouterTransactionsOnDestinationChain, + EvmNetworks, + EvmToken, + EvmTokenDetails, + EvmTransactionData, + evmTokenConfig, + getOnChainTokenDetailsOrDefault, + isEvmTokenDetails, + isNativeEvmToken, + multiplyByPowerOfTen, + 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"; +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 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, + evmEphemeralAddress: evmEphemeralEntry.address, + taxId + }; + + let baseNonce = 0; + + if (!quote.metadata.aveniaTransfer?.outputAmountRaw) { + throw new Error("Missing aveniaTransfer amountOutRaw in quote metadata"); + } + + 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}`); + } + + // 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 }; + } + + const { approveData, swapData, squidRouterQuoteId, squidRouterReceiverId, squidRouterReceiverHash } = + await createOnrampSquidrouterTransactionsFromBaseToEvm({ + destinationAddress: evmEphemeralEntry.address, + fromAddress: evmEphemeralEntry.address, + 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({ + 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 + }); + + // 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({ + 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 + }); + + 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 c90f0824b..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,10 +18,11 @@ 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"; -import { addFeeDistributionTransaction } from "../../common/feeDistribution"; +import { addEvmFeeDistributionTransaction, addFeeDistributionTransaction } from "../../common/feeDistribution"; import { encodeEvmTransactionData } from "../../index"; import { addDestinationChainApprovalTransaction, @@ -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, @@ -193,6 +199,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, 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..d5d81324d 100644 --- a/apps/api/src/api/services/transactions/validation.ts +++ b/apps/api/src/api/services/transactions/validation.ts @@ -63,6 +63,9 @@ function getTransactionTypeForPhase(phase: RampPhase | CleanupPhase): EphemeralA return EphemeralAccountType.Stellar; case "squidRouterApprove": case "squidRouterSwap": + case "nablaApproveEvm": + case "nablaSwapEvm": + case "distributeFeesEvm": return EphemeralAccountType.EVM; default: return EphemeralAccountType.EVM; @@ -100,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); // do not validate typed data if (isSignedTypedData(txData) || isSignedTypedDataArray(txData)) { return; @@ -152,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); 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/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/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/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/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" } 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/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; 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 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..891a02a75 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" @@ -35,12 +37,15 @@ export type RampPhase = | "spacewalkRedeem" | "stellarPayment" | "subsidizePreSwap" + | "subsidizePreSwapEvm" | "subsidizePostSwap" + | "subsidizePostSwapEvm" | "distributeFees" | "alfredpayOnrampMint" | "alfredpayOfframpTransfer" | "brlaOnrampMint" - | "brlaPayoutOnMoonbeam" + | "brlaPayoutOnBase" + | "baseTransfer" | "failed" | "timedOut" | "finalSettlementSubsidy" 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/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/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; diff --git a/packages/shared/src/services/squidrouter/onramp.ts b/packages/shared/src/services/squidrouter/onramp.ts index 99f49c3a0..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"; @@ -29,7 +33,7 @@ export interface OnrampSquidrouterParamsFromMoonbeam { moonbeamEphemeralStartingNonce: number; } -export interface OnrampSquidrouterParamsFromPolygon { +export interface OnrampSquidrouterParamsFromEvm { fromAddress: string; rawAmount: string; fromToken: `0x${string}`; @@ -91,7 +95,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 +130,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: BASE_SQUIDROUTER_SWAP_MIN_VALUE_RAW + }); + + 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/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/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..f2ea1c83a 100644 --- a/packages/shared/src/tokens/evm/config.ts +++ b/packages/shared/src/tokens/evm/config.ts @@ -207,6 +207,62 @@ export const evmTokenConfig: Record