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 97% rename from apps/api/src/api/services/phases/handlers/brla-payout-moonbeam-handler.ts rename to apps/api/src/api/services/phases/handlers/brla-payout-base-handler.ts index effedf8ad..807657d32 100644 --- a/apps/api/src/api/services/phases/handlers/brla-payout-moonbeam-handler.ts +++ b/apps/api/src/api/services/phases/handlers/brla-payout-base-handler.ts @@ -8,9 +8,9 @@ import { PhaseError } from "../../../errors/phase-error"; import { BasePhaseHandler } from "../base-phase-handler"; import { StateMetadata } from "../meta-state-types"; -export class BrlaPayoutOnMoonbeamPhaseHandler extends BasePhaseHandler { +export class BrlaPayoutOnBasePhaseHandler extends BasePhaseHandler { public getPhaseName(): RampPhase { - return "brlaPayoutOnMoonbeam"; + return "brlaPayoutOnBase"; } protected async executePhase(state: RampState): Promise { @@ -179,4 +179,4 @@ export class BrlaPayoutOnMoonbeamPhaseHandler extends BasePhaseHandler { } } -export default new BrlaPayoutOnMoonbeamPhaseHandler(); +export default new BrlaPayoutOnBasePhaseHandler(); diff --git a/apps/api/src/api/services/phases/handlers/distribute-fees-handler.ts b/apps/api/src/api/services/phases/handlers/distribute-fees-handler.ts index 706e36714..abec2cdfb 100644 --- a/apps/api/src/api/services/phases/handlers/distribute-fees-handler.ts +++ b/apps/api/src/api/services/phases/handlers/distribute-fees-handler.ts @@ -5,12 +5,18 @@ import { ISubmittableResult } from "@polkadot/types/types"; import { ApiManager, decodeSubmittableExtrinsic, + EvmClientManager, + EvmNetworks, + isEvmTransactionData, + Networks, RampDirection, RampPhase, - TransactionTemporarilyBannedError + TransactionTemporarilyBannedError, + waitUntilTrueWithTimeout } from "@vortexfi/shared"; +import { privateKeyToAccount } from "viem/accounts"; import logger from "../../../../config/logger"; -import { SUBSCAN_API_KEY } from "../../../../constants/constants"; +import { MOONBEAM_FUNDING_PRIVATE_KEY, SUBSCAN_API_KEY } from "../../../../constants/constants"; import QuoteTicket from "../../../../models/quoteTicket.model"; import RampState from "../../../../models/rampState.model"; import { PhaseError } from "../../../errors/phase-error"; @@ -26,7 +32,7 @@ enum ExtrinsicStatus { } /** - * Handler for distributing Network, Vortex, and Partner fees using a stablecoin on Pendulum + * Handler for distributing Network, Vortex, and Partner fees using a stablecoin on Pendulum or EVM chains */ export class DistributeFeesHandler extends BasePhaseHandler { private apiManager: ApiManager; @@ -60,18 +66,34 @@ export class DistributeFeesHandler extends BasePhaseHandler { // Check if we already have a hash stored const existingHash = state.state.distributeFeeHash || null; + // For BRL onramp flows, distributio happens on EVM (Base). + const isEvmTransaction = quote.inputCurrency === "BRL" || 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"); + }); - if (status === ExtrinsicStatus.Success) { - logger.info(`Existing distribute fee transaction was successful for ramp ${state.id}`); - return this.transitionToNextPhase(state, nextPhase); + if (status === ExtrinsicStatus.Success) { + logger.info(`Existing distribute fee EVM transaction was successful for ramp ${state.id}`); + return this.transitionToNextPhase(state, nextPhase); + } else { + logger.info(`Existing distribute fee EVM transaction was not successful (status: ${status}), will retry`); + } } else { - logger.info(`Existing distribute fee transaction was not successful (status: ${status}), will retry`); + const status = await this.checkExtrinsicStatus(existingHash).catch((_: unknown) => { + throw this.createRecoverableError("Failed to check extrinsic status"); + }); + + if (status === ExtrinsicStatus.Success) { + logger.info(`Existing distribute fee transaction was successful for ramp ${state.id}`); + return this.transitionToNextPhase(state, nextPhase); + } else { + logger.info(`Existing distribute fee transaction was not successful (status: ${status}), will retry`); + } } } @@ -83,12 +105,21 @@ export class DistributeFeesHandler extends BasePhaseHandler { return this.transitionToNextPhase(state, nextPhase); } - const { api } = await this.apiManager.getApi("pendulum"); + let actualTxHash: string; - const decodedTx = decodeSubmittableExtrinsic(distributeFeeTransaction.txData as string, api); + if (isEvmTransaction) { + logger.info(`Submitting EVM fee distribution transaction for ramp ${state.id}...`); + actualTxHash = await this.submitEvmTransaction( + distributeFeeTransaction.txData, + distributeFeeTransaction.network as EvmNetworks + ); + } else { + const { api } = await this.apiManager.getApi("pendulum"); + const decodedTx = decodeSubmittableExtrinsic(distributeFeeTransaction.txData as string, api); - logger.info(`Submitting fee distribution transaction for ramp ${state.id}...`); - const actualTxHash = await this.submitTransaction(decodedTx, api); + logger.info(`Submitting substrate fee distribution transaction for ramp ${state.id}...`); + actualTxHash = await this.submitTransaction(decodedTx, api); + } logger.info(`Transaction broadcast with hash ${actualTxHash}. Persisting hash...`); @@ -100,8 +131,12 @@ export class DistributeFeesHandler extends BasePhaseHandler { } }); - // Wait for extrinsic success using Subscan API - await this.waitForExtrinsicSuccess(actualTxHash); + // Wait for transaction success + if (isEvmTransaction) { + await this.waitForEvmTransactionSuccess(actualTxHash, distributeFeeTransaction.network as EvmNetworks); + } else { + await this.waitForExtrinsicSuccess(actualTxHash); + } logger.info(`Successfully verified fee distribution transaction for ramp ${state.id}: ${actualTxHash}`); return this.transitionToNextPhase(updatedState, nextPhase); @@ -279,6 +314,80 @@ export class DistributeFeesHandler extends BasePhaseHandler { throw error; } } + + /** + * Submit an EVM transaction + * @param txData The EVM transaction data + * @param network The EVM network + * @returns The transaction hash + */ + private async submitEvmTransaction(txData: any, network: EvmNetworks): Promise { + logger.debug(`Submitting EVM transaction to ${network} for ${this.getPhaseName()} phase`); + + const evmClientManager = EvmClientManager.getInstance(); + const fundingAccount = privateKeyToAccount(MOONBEAM_FUNDING_PRIVATE_KEY as `0x${string}`); + + return await evmClientManager.sendTransactionWithBlindRetry(network, fundingAccount, { + data: txData.data as `0x${string}`, + gas: BigInt(txData.gas || "100000"), + maxFeePerGas: txData.maxFeePerGas ? BigInt(txData.maxFeePerGas) : undefined, + maxPriorityFeePerGas: txData.maxPriorityFeePerGas ? BigInt(txData.maxPriorityFeePerGas) : undefined, + to: txData.to as `0x${string}`, + value: BigInt(txData.value || "0") + }); + } + + /** + * Wait for EVM transaction success + * @param txHash The transaction hash + * @param network The EVM network + */ + private async waitForEvmTransactionSuccess(txHash: string, network: EvmNetworks): Promise { + const evmClientManager = EvmClientManager.getInstance(); + const publicClient = evmClientManager.getClient(network); + + await waitUntilTrueWithTimeout( + async () => { + try { + const receipt = await publicClient.getTransactionReceipt({ hash: txHash as `0x${string}` }); + return receipt?.status === "success"; + } catch (error) { + logger.debug(`Error checking EVM transaction receipt: ${error}`); + return false; + } + }, + 2000, // check every 2 seconds + 180000 // timeout after 3 minutes + ); + } + + /** + * Check EVM transaction status + * @param txHash The transaction hash + * @returns ExtrinsicStatus: Success, Fail, or Undefined + */ + private async checkEvmTransactionStatus(txHash: string): Promise { + try { + const evmClientManager = EvmClientManager.getInstance(); + // Always on Base for EVM. + const publicClient = evmClientManager.getClient(Networks.Base); + + const receipt = await publicClient.getTransactionReceipt({ hash: txHash as `0x${string}` }); + + if (receipt) { + if (receipt.status === "success") { + return ExtrinsicStatus.Success; + } else { + return ExtrinsicStatus.Fail; + } + } + + return ExtrinsicStatus.Undefined; + } catch (error: unknown) { + logger.error(`Error checking EVM transaction status: ${error}`); + return ExtrinsicStatus.Undefined; + } + } } export default new DistributeFeesHandler(); diff --git a/apps/api/src/api/services/phases/handlers/fund-ephemeral-handler.ts b/apps/api/src/api/services/phases/handlers/fund-ephemeral-handler.ts index 3ffc1b0cc..5089e1d28 100644 --- a/apps/api/src/api/services/phases/handlers/fund-ephemeral-handler.ts +++ b/apps/api/src/api/services/phases/handlers/fund-ephemeral-handler.ts @@ -11,7 +11,6 @@ import { } from "@vortexfi/shared"; import { NetworkError, Transaction } from "stellar-sdk"; import { privateKeyToAccount } from "viem/accounts"; -import { polygon } from "viem/chains"; import logger from "../../../../config/logger"; import { MOONBEAM_FUNDING_PRIVATE_KEY, POLYGON_EPHEMERAL_STARTING_BALANCE_UNITS } from "../../../../constants/constants"; @@ -20,14 +19,13 @@ 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, @@ -93,9 +91,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 +118,6 @@ export class FundEphemeralPhaseHandler extends BasePhaseHandler { const apiManager = ApiManager.getInstance(); const pendulumNode = await apiManager.getApi("pendulum"); - const moonbeamNode = await apiManager.getApi("moonbeam"); const { evmEphemeralAddress, substrateEphemeralAddress } = state.state as StateMetadata; const requiresPendulumEphemeralAddress = this.getRequiresPendulumEphemeralAddress( @@ -133,7 +130,7 @@ export class FundEphemeralPhaseHandler extends BasePhaseHandler { quote.inputCurrency, quote.outputCurrency ); - const requiresMoonbeamEphemeralAddress = this.getRequiresMoonbeamEphemeralAddress(state, quote.inputCurrency); + const requiresBaseEphemeralAddress = this.getRequiresBaseEphemeralAddress(quote.inputCurrency, quote.outputCurrency); const requiresDestinationEvmFunding = this.getRequiresDestinationEvmFunding(state); // Ephemeral checks. @@ -152,9 +149,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 +182,14 @@ export class FundEphemeralPhaseHandler extends BasePhaseHandler { logger.info("Pendulum ephemeral address already funded."); } - if (isOnramp(state) && !isMoonbeamFunded) { - logger.info(`Funding moonbeam ephemeral account ${evmEphemeralAddress}`); - - const destinationNetwork = getNetworkFromDestination(state.to); - // For onramp case, "to" is always a network. - if (!destinationNetwork) { - throw new Error("FundEphemeralPhaseHandler: Invalid destination network."); - } - - await fundMoonbeamEphemeralAccount(evmEphemeralAddress); + if (isOnramp(state) && !isBaseFunded) { + logger.info(`Funding base ephemeral account ${evmEphemeralAddress}`); + await this.fundEvmEphemeralAccount(state, Networks.Base); } if (!isPolygonFunded) { logger.info(`Funding polygon ephemeral account ${evmEphemeralAddress}`); - await this.fundPolygonEphemeralAccount(state); + await this.fundEvmEphemeralAccount(state, Networks.Polygon); } else if (requiresPolygonEphemeralAddress) { logger.info("Polygon ephemeral address already funded."); } @@ -232,7 +220,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 +236,8 @@ export class FundEphemeralPhaseHandler extends BasePhaseHandler { return "distributeFees"; } else if (state.type === RampDirection.SELL && quote.outputCurrency === FiatToken.USD) { return "finalSettlementSubsidy"; + } else if (state.type === RampDirection.SELL && quote.outputCurrency === FiatToken.BRL) { + return "distributeFees"; } else { return "moonbeamToPendulum"; // Via contract.subsidizePreSwap } @@ -312,27 +302,32 @@ export class FundEphemeralPhaseHandler extends BasePhaseHandler { } } - protected async fundPolygonEphemeralAccount(state: RampState): Promise { + protected async fundEvmEphemeralAccount(state: RampState, network: EvmNetworks): Promise { try { const evmClientManager = EvmClientManager.getInstance(); - const polygonClient = evmClientManager.getClient(Networks.Polygon); + const networkClient = evmClientManager.getClient(network); + const chain = networkClient.chain; + + if (!chain) { + throw new Error(`FundEphemeralPhaseHandler: Could not get chain info for ${network}`); + } const ephmeralAddress = state.state.evmEphemeralAddress; const fundingAmountRaw = multiplyByPowerOfTen( POLYGON_EPHEMERAL_STARTING_BALANCE_UNITS, - polygon.nativeCurrency.decimals + chain.nativeCurrency.decimals ).toFixed(); - // We use Moonbeam's funding account to fund the ephemeral account on Polygon. + // We use Moonbeam's funding account to fund the ephemeral account on the network. const fundingAccount = privateKeyToAccount(MOONBEAM_FUNDING_PRIVATE_KEY as `0x${string}`); - const walletClient = evmClientManager.getWalletClient(Networks.Polygon, fundingAccount); + const walletClient = evmClientManager.getWalletClient(network, fundingAccount); const txHash = await walletClient.sendTransaction({ to: ephmeralAddress as `0x${string}`, value: BigInt(fundingAmountRaw) }); - const receipt = await polygonClient.waitForTransactionReceipt({ + const receipt = await networkClient.waitForTransactionReceipt({ hash: txHash as `0x${string}` }); @@ -340,8 +335,8 @@ export class FundEphemeralPhaseHandler extends BasePhaseHandler { throw new Error(`FundEphemeralPhaseHandler: Transaction ${txHash} failed or was not found`); } } catch (error) { - logger.error("FundEphemeralPhaseHandler: Error during funding Polygon ephemeral:", error); - throw new Error("FundEphemeralPhaseHandler: Error during funding Polygon ephemeral: " + error); + logger.error(`FundEphemeralPhaseHandler: Error during funding ${network} ephemeral:`, error); + throw new Error(`FundEphemeralPhaseHandler: Error during funding ${network} ephemeral: ` + error); } } diff --git a/apps/api/src/api/services/phases/handlers/helpers.ts b/apps/api/src/api/services/phases/handlers/helpers.ts index e3578e43f..c7a90e97e 100644 --- a/apps/api/src/api/services/phases/handlers/helpers.ts +++ b/apps/api/src/api/services/phases/handlers/helpers.ts @@ -53,12 +53,26 @@ export async function isPendulumEphemeralFunded(pendulumEphemeralAddress: string return Big(balance.free.toString()).gte(fundingAmountRaw); } -export async function isMoonbeamEphemeralFunded(moonbeamEphemeralAddress: string, moonebamNode: API): Promise { +export async function isMoonbeamEphemeralFunded(moonbeamEphemeralAddress: string, moonbeamNode: API): Promise { //@ts-ignore - const { data: balance } = await moonebamNode.api.query.system.account(moonbeamEphemeralAddress); + const { data: balance } = await moonbeamNode.api.query.system.account(moonbeamEphemeralAddress); return Big(balance.free.toString()).gte(GLMR_FUNDING_AMOUNT_RAW); } +export async function isBaseEphemeralFunded(baseEphemeralAddress: string): Promise { + const evmClientManager = EvmClientManager.getInstance(); + const baseClient = evmClientManager.getClient(VortexNetworks.Base); + + const balance = await baseClient.getBalance({ + address: baseEphemeralAddress as `0x${string}` + }); + const fundingAmountRaw = new Big( + multiplyByPowerOfTen(POLYGON_EPHEMERAL_STARTING_BALANCE_UNITS, polygon.nativeCurrency.decimals).toFixed() + ); + + return Big(balance.toString()).gte(fundingAmountRaw); +} + export async function isPolygonEphemeralFunded(polygonEphemeralAddress: string): Promise { const evmClientManager = EvmClientManager.getInstance(); const polygonClient = evmClientManager.getClient(VortexNetworks.Polygon); diff --git a/apps/api/src/api/services/phases/handlers/nabla-approve-handler.ts b/apps/api/src/api/services/phases/handlers/nabla-approve-handler.ts index c77300379..46383ce8e 100644 --- a/apps/api/src/api/services/phases/handlers/nabla-approve-handler.ts +++ b/apps/api/src/api/services/phases/handlers/nabla-approve-handler.ts @@ -1,12 +1,21 @@ import { createExecuteMessageExtrinsic, ExecuteMessageResult, submitExtrinsic } from "@pendulum-chain/api-solang"; import { Abi } from "@polkadot/api-contract"; -import { ApiManager, decodeSubmittableExtrinsic, NABLA_ROUTER, RampPhase } from "@vortexfi/shared"; +import { + ApiManager, + decodeSubmittableExtrinsic, + EvmClientManager, + FiatToken, + NABLA_ROUTER, + Networks, + RampPhase +} from "@vortexfi/shared"; import Big from "big.js"; import logger from "../../../../config/logger"; import { erc20WrapperAbi } from "../../../../contracts/ERC20Wrapper"; import QuoteTicket from "../../../../models/quoteTicket.model"; import RampState from "../../../../models/rampState.model"; import { BasePhaseHandler } from "../base-phase-handler"; +import { StateMetadata } from "../meta-state-types"; export class NablaApprovePhaseHandler extends BasePhaseHandler { public getPhaseName(): RampPhase { @@ -14,10 +23,6 @@ export class NablaApprovePhaseHandler extends BasePhaseHandler { } protected async executePhase(state: RampState): Promise { - const apiManager = ApiManager.getInstance(); - const networkName = "pendulum"; - const pendulumNode = await apiManager.getApi(networkName); - const quote = await QuoteTicket.findByPk(state.quoteId); if (!quote) { @@ -28,6 +33,26 @@ export class NablaApprovePhaseHandler extends BasePhaseHandler { throw new Error("Missing nablaSwap info in quote metadata"); } + const { substrateEphemeralAddress } = state.state as StateMetadata; + + if (quote.inputCurrency === FiatToken.BRL) { + return this.executeEvmApprove(state); + } else if (substrateEphemeralAddress) { + return this.executeSubstrateApprove(state, quote); + } else { + throw new Error("NablaApprovePhaseHandler: Neither EVM nor substrate ephemeral address found in state"); + } + } + + private async executeSubstrateApprove(state: RampState, quote: QuoteTicket): Promise { + const apiManager = ApiManager.getInstance(); + const networkName = "pendulum"; + const pendulumNode = await apiManager.getApi(networkName); + + if (!quote.metadata.nablaSwap) { + throw new Error("Missing nablaSwap info in quote metadata"); + } + try { const approval = await pendulumNode.api.query.tokenAllowance.approvals( quote.metadata.nablaSwap.inputCurrencyId, @@ -99,6 +124,38 @@ export class NablaApprovePhaseHandler extends BasePhaseHandler { throw e; } } + + private async executeEvmApprove(state: RampState): Promise { + const evmClientManager = EvmClientManager.getInstance(); + const baseClient = evmClientManager.getClient(Networks.Base); + + try { + const { txData: nablaApproveTransaction } = this.getPresignedTransaction(state, "nablaApprove"); + + if (typeof nablaApproveTransaction !== "string") { + throw new Error("NablaApprovePhaseHandler: Invalid EVM transaction data. This is a bug."); + } + + const txHash = await baseClient.sendRawTransaction({ + serializedTransaction: nablaApproveTransaction as `0x${string}` + }); + + const receipt = await baseClient.waitForTransactionReceipt({ + hash: txHash + }); + + if (!receipt || receipt.status !== "success") { + throw new Error(`NablaApprovePhaseHandler: EVM approve transaction ${txHash} failed`); + } + + logger.info(`NablaApprovePhaseHandler: EVM approve transaction successful: ${txHash}`); + + return this.transitionToNextPhase(state, "nablaSwap"); + } catch (e) { + logger.error(`Could not approve token on EVM: ${(e as Error).message}`); + throw e; + } + } } export default new NablaApprovePhaseHandler(); diff --git a/apps/api/src/api/services/phases/handlers/nabla-swap-handler.ts b/apps/api/src/api/services/phases/handlers/nabla-swap-handler.ts index 64a360eeb..2ba69a637 100644 --- a/apps/api/src/api/services/phases/handlers/nabla-swap-handler.ts +++ b/apps/api/src/api/services/phases/handlers/nabla-swap-handler.ts @@ -4,7 +4,10 @@ import { ApiManager, decodeSubmittableExtrinsic, defaultReadLimits, + EvmClientManager, + FiatToken, NABLA_ROUTER, + Networks, RampDirection, RampPhase } from "@vortexfi/shared"; @@ -22,16 +25,28 @@ export class NablaSwapPhaseHandler extends BasePhaseHandler { } protected async executePhase(state: RampState): Promise { - const apiManager = ApiManager.getInstance(); - const networkName = "pendulum"; - const pendulumNode = await apiManager.getApi(networkName); - const quote = await QuoteTicket.findByPk(state.quoteId); if (!quote) { throw new Error("Quote not found for the given state"); } + const { substrateEphemeralAddress } = state.state as StateMetadata; + + if (quote.inputCurrency === FiatToken.BRL) { + return this.executeEvmSwap(state, quote); + } else if (substrateEphemeralAddress) { + return this.executeSubstrateSwap(state, quote); + } else { + throw new Error("NablaSwapPhaseHandler: Neither EVM nor substrate ephemeral address found in state"); + } + } + + private async executeSubstrateSwap(state: RampState, quote: QuoteTicket): Promise { + const apiManager = ApiManager.getInstance(); + const networkName = "pendulum"; + const pendulumNode = await apiManager.getApi(networkName); + const { nablaSoftMinimumOutputRaw, substrateEphemeralAddress } = state.state as StateMetadata; if (!nablaSoftMinimumOutputRaw || !substrateEphemeralAddress) { @@ -118,6 +133,41 @@ 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, "nablaSwap"); + + if (typeof nablaSwapTransaction !== "string") { + throw new Error("NablaSwapPhaseHandler: Invalid EVM transaction data. This is a bug."); + } + + const txHash = await baseClient.sendRawTransaction({ + serializedTransaction: nablaSwapTransaction as `0x${string}` + }); + + const receipt = await baseClient.waitForTransactionReceipt({ + hash: txHash + }); + + if (!receipt || receipt.status !== "success") { + throw new Error(`NablaSwapPhaseHandler: EVM swap transaction ${txHash} failed`); + } + + logger.info(`NablaSwapPhaseHandler: EVM swap transaction successful: ${txHash}`); + } catch (e) { + logger.error(`Could not swap token on EVM: ${(e as Error).message}`); + throw e; + } + + const 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/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..533540fd7 --- /dev/null +++ b/apps/api/src/api/services/phases/handlers/subsidize-post-swap-evm-handler.ts @@ -0,0 +1,153 @@ +import { + checkEvmBalanceForToken, + EvmClientManager, + EvmNetworks, + EvmToken, + EvmTokenDetails, + isNativeEvmToken, + Networks, + nativeToDecimal, + RampDirection, + RampPhase, + waitUntilTrueWithTimeout +} from "@vortexfi/shared"; +import Big from "big.js"; +import { encodeFunctionData, erc20Abi } from "viem"; +import { privateKeyToAccount } from "viem/accounts"; +import logger from "../../../../config/logger"; +import { MOONBEAM_FUNDING_PRIVATE_KEY } from "../../../../constants/constants"; +import QuoteTicket from "../../../../models/quoteTicket.model"; +import RampState from "../../../../models/rampState.model"; +import { SubsidyToken } from "../../../../models/subsidy.model"; +import { BasePhaseHandler } from "../base-phase-handler"; +import { StateMetadata } from "../meta-state-types"; + +export class SubsidizePostSwapEvmPhaseHandler extends BasePhaseHandler { + public getPhaseName(): RampPhase { + return "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 outputTokenDetails = quote.metadata.nablaSwapEvm.outputToken as unknown as EvmTokenDetails; + + // Check current balance on EVM + const currentBalance = await checkEvmBalanceForToken({ + amountDesiredRaw: "1", + chain: outputTokenDetails.network as EvmNetworks, + intervalMs: 1000, // Just check if there's any balance + ownerAddress: evmEphemeralAddress, + timeoutMs: 5000, + tokenDetails: outputTokenDetails + }); + + if (currentBalance.eq(Big(0))) { + throw new Error("Invalid phase: input token did not arrive yet on EVM"); + } + + // Add a default/base expected output amount from the swap + let expectedSwapOutputAmountRaw = Big(quote.metadata.nablaSwapEvm.outputAmountRaw).plus( + quote.metadata.subsidy.subsidyAmountInOutputTokenRaw + ); + + // Try to find the required amount to subsidize on the quote metadata + if (state.type === RampDirection.BUY) { + // For BUY operations, use the evmToEvm inputAmountRaw as the expected amount + expectedSwapOutputAmountRaw = Big(quote.metadata.evmToEvm?.inputAmountRaw); + } else { + throw new Error("SubsidizePostSwapEvmPhaseHandler: SELL operations are not supported in this handler yet."); + } + + const requiredAmount = Big(expectedSwapOutputAmountRaw).sub(currentBalance); + + const didBalanceReachExpected = async () => { + const balance = await checkEvmBalanceForToken({ + amountDesiredRaw: expectedSwapOutputAmountRaw.toString(), + chain: outputTokenDetails.network as EvmNetworks, + intervalMs: 1000, + ownerAddress: evmEphemeralAddress, + timeoutMs: 5000, + tokenDetails: outputTokenDetails + }); + return balance.gte(expectedSwapOutputAmountRaw); + }; + + if (requiredAmount.gt(Big(0))) { + // Do the actual subsidizing on EVM + logger.info( + `Subsidizing post-swap EVM with ${requiredAmount.toFixed()} to reach target value of ${expectedSwapOutputAmountRaw}` + ); + + const evmClientManager = EvmClientManager.getInstance(); + const fundingAccount = privateKeyToAccount(MOONBEAM_FUNDING_PRIVATE_KEY as `0x${string}`); + const destinationNetwork = outputTokenDetails.network as EvmNetworks; + + // Get gas estimates + const publicClient = evmClientManager.getClient(destinationNetwork); + const { maxFeePerGas, maxPriorityFeePerGas } = await publicClient.estimateFeesPerGas(); + + // ERC-20 transfer. + const data = encodeFunctionData({ + abi: erc20Abi, + args: [evmEphemeralAddress as `0x${string}`, BigInt(requiredAmount.toFixed(0))], + functionName: "transfer" + }); + + const txHash = await evmClientManager.sendTransactionWithBlindRetry(destinationNetwork, fundingAccount, { + data, + maxFeePerGas, + maxPriorityFeePerGas, + to: outputTokenDetails.erc20AddressSourceChain as `0x${string}`, + value: 0n + }); + + const subsidyAmount = nativeToDecimal(requiredAmount, quote.metadata.nablaSwapEvm.outputDecimals).toNumber(); + const subsidyToken = quote.metadata.nablaSwapEvm.outputCurrency as unknown as SubsidyToken; + + await this.createSubsidy(state, subsidyAmount, subsidyToken, fundingAccount.address, txHash); + + // Wait for the balance to update + await waitUntilTrueWithTimeout(didBalanceReachExpected, 2000); + } + + return this.transitionToNextPhase(state, this.nextPhaseSelector(state, quote)); + } catch (e) { + logger.error("Error in subsidizePostSwapEvm:", e); + throw this.createRecoverableError("SubsidizePostSwapEvmPhaseHandler: Failed to subsidize post swap on EVM."); + } + } + + protected nextPhaseSelector(state: RampState, quote: QuoteTicket): RampPhase { + if (state.type === RampDirection.BUY) { + return "squidRouterSwap"; + } else { + return "squidRouterSwap"; + } + } +} + +export default new SubsidizePostSwapEvmPhaseHandler(); diff --git a/apps/api/src/api/services/phases/register-handlers.ts b/apps/api/src/api/services/phases/register-handlers.ts index 712f9890a..31f0afc21 100644 --- a/apps/api/src/api/services/phases/register-handlers.ts +++ b/apps/api/src/api/services/phases/register-handlers.ts @@ -2,7 +2,7 @@ import logger from "../../../config/logger"; import alfredpayOfframpTransferHandler from "./handlers/alfredpay-offramp-transfer-handler"; import alfredpayOnrampMintHandler from "./handlers/alfredpay-onramp-mint-handler"; import brlaOnrampMintHandler from "./handlers/brla-onramp-mint-handler"; -import brlaPayoutMoonbeamHandler from "./handlers/brla-payout-moonbeam-handler"; +import brlaPayoutBaseHandler from "./handlers/brla-payout-base-handler"; import destinationTransferHandler from "./handlers/destination-transfer-handler"; import distributeFeesHandler from "./handlers/distribute-fees-handler"; import finalSettlementSubsidy from "./handlers/final-settlement-subsidy"; @@ -24,6 +24,7 @@ import squidRouterPayPhaseHandler from "./handlers/squid-router-pay-phase-handle import squidRouterPhaseHandler from "./handlers/squid-router-phase-handler"; import squidRouterPermitExecutionHandler from "./handlers/squidrouter-permit-execution-handler"; import stellarPaymentHandler from "./handlers/stellar-payment-handler"; +import subsidizePostSwapEvmPhaseHandler from "./handlers/subsidize-post-swap-evm-handler"; import subsidizePostSwapPhaseHandler from "./handlers/subsidize-post-swap-handler"; import subsidizePreSwapPhaseHandler from "./handlers/subsidize-pre-swap-handler"; import phaseRegistry from "./phase-registry"; @@ -42,9 +43,10 @@ export function registerPhaseHandlers(): void { phaseRegistry.registerHandler(stellarPaymentHandler); phaseRegistry.registerHandler(spacewalkRedeemHandler); phaseRegistry.registerHandler(subsidizePostSwapPhaseHandler); + phaseRegistry.registerHandler(subsidizePostSwapEvmPhaseHandler); phaseRegistry.registerHandler(subsidizePreSwapPhaseHandler); phaseRegistry.registerHandler(moonbeamToPendulumPhaseHandler); - phaseRegistry.registerHandler(brlaPayoutMoonbeamHandler); + phaseRegistry.registerHandler(brlaPayoutBaseHandler); phaseRegistry.registerHandler(fundEphemeralHandler); phaseRegistry.registerHandler(alfredpayOnrampMintHandler); phaseRegistry.registerHandler(alfredpayOfframpTransferHandler); diff --git a/apps/api/src/api/services/quote/core/nabla.ts b/apps/api/src/api/services/quote/core/nabla.ts index 7d174e607..3161fe569 100644 --- a/apps/api/src/api/services/quote/core/nabla.ts +++ b/apps/api/src/api/services/quote/core/nabla.ts @@ -1,9 +1,23 @@ -import { ApiManager, getTokenOutAmount, PendulumTokenDetails, QuoteError, RampDirection } from "@vortexfi/shared"; +import { + ApiManager, + EvmClientManager, + EvmTokenDetails, + getTokenOutAmount, + multiplyByPowerOfTen, + Networks, + PendulumTokenDetails, + parseContractBalanceResponse, + QuoteError, + RampDirection, + stringifyBigWithSignificantDecimals +} from "@vortexfi/shared"; import { Big } from "big.js"; import httpStatus from "http-status"; import logger from "../../../../config/logger"; import { APIError } from "../../../errors/api-error"; +const NABLA_ROUTER_BASE: `0x${string}` = "0x58E5Cb2dA15f01CB8FAefef202aa25238efCBdcf"; + export interface NablaSwapRequest { inputAmountForSwap: string; rampType: RampDirection; @@ -17,6 +31,13 @@ export interface NablaSwapResult { effectiveExchangeRate?: string; } +export interface NablaSwapEvmRequest { + inputAmountForSwap: string; + rampType: RampDirection; + inputTokenDetails: EvmTokenDetails; + outputTokenDetails: EvmTokenDetails; +} + export async function calculateNablaSwapOutput(request: NablaSwapRequest): Promise { const { inputAmountForSwap, inputTokenPendulumDetails, outputTokenPendulumDetails } = request; // Validate input amount @@ -27,32 +48,156 @@ export async function calculateNablaSwapOutput(request: NablaSwapRequest): Promi }); } + if (!inputTokenPendulumDetails || !outputTokenPendulumDetails) { + throw new APIError({ + message: QuoteError.UnableToGetPendulumTokenDetails, + status: httpStatus.BAD_REQUEST + }); + } + + const isEVM = inputTokenPendulumDetails.erc20WrapperAddress.startsWith("0x"); + try { - // Get API manager and Pendulum API - const apiManager = ApiManager.getInstance(); - const pendulumApi = await apiManager.getApi("pendulum"); - - if (!inputTokenPendulumDetails || !outputTokenPendulumDetails) { - throw new APIError({ - message: QuoteError.UnableToGetPendulumTokenDetails, - status: httpStatus.BAD_REQUEST + if (isEVM) { + const evmClientManager = EvmClientManager.getInstance(); + const amountIn = multiplyByPowerOfTen(new Big(inputAmountForSwap), inputTokenPendulumDetails.decimals).toFixed(0, 0); + + const swapAbi = [ + { + inputs: [ + { name: "_amountIn", type: "uint256" }, + { name: "_tokenInOut", type: "address[]" } + ], + name: "getAmountOut", + outputs: [ + { name: "amountOut", type: "uint256" }, + { name: "feeAmount", type: "uint256" } + ], + stateMutability: "view", + type: "function" + } + ]; + + const result = await evmClientManager.readContractWithRetry<[bigint, bigint]>(Networks.Base, { + abi: swapAbi, + address: NABLA_ROUTER_BASE, + args: [ + BigInt(amountIn), + [ + inputTokenPendulumDetails.erc20WrapperAddress as `0x${string}`, + outputTokenPendulumDetails.erc20WrapperAddress as `0x${string}` + ] + ], + functionName: "getAmountOut" + }); + + const preciseQuotedAmountOut = parseContractBalanceResponse(outputTokenPendulumDetails.decimals, result[0]); + if (!preciseQuotedAmountOut) { + throw new Error("Failed to parse quoted amount out"); + } + + return { + effectiveExchangeRate: stringifyBigWithSignificantDecimals( + preciseQuotedAmountOut.preciseBigDecimal.div(new Big(inputAmountForSwap)), + 4 + ), + nablaOutputAmountDecimal: preciseQuotedAmountOut.preciseBigDecimal, + nablaOutputAmountRaw: preciseQuotedAmountOut.rawBalance.toFixed() + }; + } else { + // Get API manager and Pendulum API + const apiManager = ApiManager.getInstance(); + const pendulumApi = await apiManager.getApi("pendulum"); + + // Perform the Nabla swap + const swapResult = await getTokenOutAmount({ + api: pendulumApi.api, + fromAmountString: inputAmountForSwap, + inputTokenPendulumDetails, + outputTokenPendulumDetails }); + + return { + effectiveExchangeRate: swapResult.effectiveExchangeRate, + nablaOutputAmountDecimal: swapResult.preciseQuotedAmountOut.preciseBigDecimal, + nablaOutputAmountRaw: swapResult.preciseQuotedAmountOut.rawBalance.toFixed() + }; } - // Perform the Nabla swap - const swapResult = await getTokenOutAmount({ - api: pendulumApi.api, - fromAmountString: inputAmountForSwap, - inputTokenPendulumDetails, - outputTokenPendulumDetails + } catch (error) { + logger.error("Error calculating Nabla swap output:", error); + throw new APIError({ + message: QuoteError.FailedToCalculateQuote, + status: httpStatus.INTERNAL_SERVER_ERROR }); + } +} + +export async function calculateNablaSwapOutputEvm(request: NablaSwapEvmRequest): Promise { + const { inputAmountForSwap, inputTokenDetails, outputTokenDetails } = request; + + // Validate input amount + if (!inputAmountForSwap || Big(inputAmountForSwap).lte(0)) { + throw new APIError({ + message: QuoteError.InputAmountForSwapMustBeGreaterThanZero, + status: httpStatus.BAD_REQUEST + }); + } + + if (!inputTokenDetails || !outputTokenDetails) { + throw new APIError({ + message: QuoteError.UnableToGetPendulumTokenDetails, + status: httpStatus.BAD_REQUEST + }); + } + + try { + const evmClientManager = EvmClientManager.getInstance(); + const amountIn = multiplyByPowerOfTen(new Big(inputAmountForSwap), inputTokenDetails.decimals).toFixed(0, 0); + + const swapAbi = [ + { + inputs: [ + { name: "_amountIn", type: "uint256" }, + { name: "_tokenInOut", type: "address[]" } + ], + name: "getAmountOut", + outputs: [ + { name: "amountOut", type: "uint256" }, + { name: "feeAmount", type: "uint256" } + ], + stateMutability: "view", + type: "function" + } + ]; + + const result = await evmClientManager.readContractWithRetry<[bigint, bigint]>(Networks.Base, { + abi: swapAbi, + address: NABLA_ROUTER_BASE, + args: [ + BigInt(amountIn), + [ + inputTokenDetails.erc20AddressSourceChain as `0x${string}`, + outputTokenDetails.erc20AddressSourceChain as `0x${string}` + ] + ], + functionName: "getAmountOut" + }); + + const preciseQuotedAmountOut = parseContractBalanceResponse(outputTokenDetails.decimals, result[0]); + if (!preciseQuotedAmountOut) { + throw new Error("Failed to parse quoted amount out"); + } return { - effectiveExchangeRate: swapResult.effectiveExchangeRate, - nablaOutputAmountDecimal: swapResult.preciseQuotedAmountOut.preciseBigDecimal, - nablaOutputAmountRaw: swapResult.preciseQuotedAmountOut.rawBalance.toFixed() + effectiveExchangeRate: stringifyBigWithSignificantDecimals( + preciseQuotedAmountOut.preciseBigDecimal.div(new Big(inputAmountForSwap)), + 4 + ), + nablaOutputAmountDecimal: preciseQuotedAmountOut.preciseBigDecimal, + nablaOutputAmountRaw: preciseQuotedAmountOut.rawBalance.toFixed() }; } catch (error) { - logger.error("Error calculating Nabla swap output:", error); + logger.error("Error calculating EVM Nabla swap output:", error); throw new APIError({ message: QuoteError.FailedToCalculateQuote, status: httpStatus.INTERNAL_SERVER_ERROR diff --git a/apps/api/src/api/services/quote/core/types.ts b/apps/api/src/api/services/quote/core/types.ts index c7609e1b0..2abd193ad 100644 --- a/apps/api/src/api/services/quote/core/types.ts +++ b/apps/api/src/api/services/quote/core/types.ts @@ -4,6 +4,7 @@ import { CreateQuoteRequest, DestinationType, + EvmToken, PendulumCurrencyId, QuoteFeeStructure, QuoteResponse, @@ -129,6 +130,21 @@ export interface QuoteContext { oraclePrice?: Big; }; + nablaSwapEvm?: { + inputAmountForSwapDecimal: string; + inputAmountForSwapRaw: string; + inputCurrency: EvmToken; + inputToken: string; // ERC20 address + inputDecimals: number; + outputAmountRaw: string; + outputAmountDecimal: Big; + outputCurrency: EvmToken; + outputDecimals: number; + outputToken: string; // ERC20 address + effectiveExchangeRate?: string; + oraclePrice?: Big; + }; + hydrationSwap?: { inputAmountRaw: string; inputAmountDecimal: string; diff --git a/apps/api/src/api/services/quote/engines/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/initialize/offramp-from-evm-alfredpay.ts b/apps/api/src/api/services/quote/engines/initialize/offramp-from-evm-alfredpay.ts index e9e3db70c..519efef75 100644 --- a/apps/api/src/api/services/quote/engines/initialize/offramp-from-evm-alfredpay.ts +++ b/apps/api/src/api/services/quote/engines/initialize/offramp-from-evm-alfredpay.ts @@ -4,11 +4,17 @@ import { EvmBridgeQuoteRequest, getEvmBridgeQuote } from "../../core/squidrouter import { QuoteContext } from "../../core/types"; import { assignPreNablaContext, BaseInitializeEngine } from "./index"; -export class AlfredpayOffRampFromEvmInitializeEngine extends BaseInitializeEngine { +export class OffRampFromEvmInitializeEngine extends BaseInitializeEngine { + private readonly network: Networks; + + constructor(network: Networks) { + super(); + this.network = network; + } + readonly config = { direction: RampDirection.SELL, - skipNote: - "AlfredpayOffRampFromEvmInitializeEngine: Skipped because rampType is BUY, this engine handles SELL operations only" + skipNote: "OffRampFromEvmInitializeEngine: Skipped because rampType is BUY, this engine handles SELL operations only" }; protected async executeInternal(ctx: QuoteContext): Promise { @@ -20,7 +26,7 @@ export class AlfredpayOffRampFromEvmInitializeEngine extends BaseInitializeEngin inputCurrency: req.inputCurrency as OnChainToken, outputCurrency: EvmToken.USDC, rampType: req.rampType, - toNetwork: Networks.Polygon + toNetwork: this.network }; const bridgeQuote = await getEvmBridgeQuote(quoteRequest); @@ -37,7 +43,7 @@ export class AlfredpayOffRampFromEvmInitializeEngine extends BaseInitializeEngin }; ctx.addNote?.( - `Initialized: input=${req.inputAmount} ${req.inputCurrency}, raw=${ctx.evmToPendulum?.inputAmountRaw}, output=${ctx.evmToPendulum?.outputAmountDecimal.toString()} ${ctx.evmToPendulum?.toToken}, raw=${ctx.evmToPendulum?.outputAmountRaw}` + `Initialized: input=${req.inputAmount} ${req.inputCurrency}, raw=${ctx.evmToEvm?.inputAmountRaw}, output=${ctx.evmToEvm?.outputAmountDecimal.toString()} ${ctx.evmToEvm?.toToken}, raw=${ctx.evmToEvm?.outputAmountRaw}` ); } } diff --git a/apps/api/src/api/services/quote/engines/initialize/offramp-from-evm.ts b/apps/api/src/api/services/quote/engines/initialize/offramp-from-evm.ts index 3fd253c04..54f254ed6 100644 --- a/apps/api/src/api/services/quote/engines/initialize/offramp-from-evm.ts +++ b/apps/api/src/api/services/quote/engines/initialize/offramp-from-evm.ts @@ -4,7 +4,7 @@ import { EvmBridgeQuoteRequest, getEvmBridgeQuote } from "../../core/squidrouter import { QuoteContext } from "../../core/types"; import { assignPreNablaContext, BaseInitializeEngine } from "./index"; -export class OffRampFromEvmInitializeEngine extends BaseInitializeEngine { +export class OffRampFromEvmInitializeEngineMoonbeam extends BaseInitializeEngine { readonly config = { direction: RampDirection.SELL, skipNote: "OffRampFromEvmInitializeEngine: Skipped because rampType is BUY, this engine handles SELL operations only" diff --git a/apps/api/src/api/services/quote/engines/nabla-swap/base-evm.ts b/apps/api/src/api/services/quote/engines/nabla-swap/base-evm.ts new file mode 100644 index 000000000..550f62588 --- /dev/null +++ b/apps/api/src/api/services/quote/engines/nabla-swap/base-evm.ts @@ -0,0 +1,139 @@ +import { EvmToken, EvmTokenDetails, getOnChainTokenDetails, Networks, RampDirection } from "@vortexfi/shared"; +import { Big } from "big.js"; +import logger from "../../../../../config/logger"; +import { priceFeedService } from "../../../priceFeed.service"; +import { calculateNablaSwapOutputEvm } from "../../core/nabla"; +import { QuoteContext, Stage, StageKey } from "../../core/types"; + +export interface NablaSwapEvmConfig { + direction: RampDirection; + skipNote: string; +} + +export interface NablaSwapEvmComputation { + oraclePrice?: Big; + inputAmountPreFees: Big; + inputToken: EvmToken; + outputToken: EvmToken; +} + +export abstract class BaseNablaSwapEngineEvm implements Stage { + abstract readonly config: NablaSwapEvmConfig; + + readonly key = StageKey.NablaSwap; + + async execute(ctx: QuoteContext): Promise { + const { request } = ctx; + const { direction, skipNote } = this.config; + + if (request.rampType !== direction) { + ctx.addNote?.(skipNote); + return; + } + + this.validate(ctx); + + const { inputAmountPreFees, inputToken, outputToken } = this.compute(ctx); + + // Get token details for Base network + const inputTokenDetails = getOnChainTokenDetails(Networks.Base, inputToken) as EvmTokenDetails; + const outputTokenDetails = getOnChainTokenDetails(Networks.Base, outputToken) as EvmTokenDetails; + + if (!inputTokenDetails || !outputTokenDetails) { + throw new Error("BaseNablaSwapEngineEvm: Could not find EVM token details for the requested tokens"); + } + + const deductibleFeeAmount = this.getDeductibleFeeAmount(ctx); + const inputAmountForSwap = inputAmountPreFees.minus(deductibleFeeAmount).toString(); + const inputAmountForSwapRaw = this.calculateInputAmountForSwapRaw(inputAmountForSwap, inputTokenDetails); + + const result = await calculateNablaSwapOutputEvm({ + inputAmountForSwap, + inputTokenDetails, + outputTokenDetails, + rampType: request.rampType + }); + + let oraclePrice; + try { + oraclePrice = await priceFeedService.getOnchainOraclePrice( + request.rampType === RampDirection.BUY ? request.inputCurrency : request.outputCurrency + ); + } catch (error) { + logger.warn( + `BaseNablaSwapEngineEvm: Unable to fetch on-chain oracle price for ${request.outputCurrency}, proceeding without it. Error: ${error}` + ); + } + + this.assignNablaSwapContext( + ctx, + result, + inputAmountForSwap, + inputAmountForSwapRaw, + inputToken, + outputToken, + inputTokenDetails, + outputTokenDetails, + oraclePrice?.price + ); + + this.addNote(ctx, inputTokenDetails, outputTokenDetails, inputAmountForSwap, result); + } + + protected abstract validate(ctx: QuoteContext): void; + + protected abstract compute(ctx: QuoteContext): NablaSwapEvmComputation; + + protected getDeductibleFeeAmount(ctx: QuoteContext): Big { + if (ctx.request.rampType === RampDirection.SELL) { + return ctx.preNabla?.deductibleFeeAmountInSwapCurrency || new Big(0); + } else { + // For onramps, the fees are deducted after the nabla swap, so no deductible fee before the swap + return new Big(0); + } + } + + protected calculateInputAmountForSwapRaw(inputAmountForSwap: string, inputToken: EvmTokenDetails): string { + return new Big(inputAmountForSwap).times(new Big(10).pow(inputToken.decimals)).toFixed(0); + } + + private assignNablaSwapContext( + ctx: QuoteContext, + result: { effectiveExchangeRate?: string; nablaOutputAmountDecimal: Big; nablaOutputAmountRaw: string }, + inputAmountForSwapDecimal: string, + inputAmountForSwapRaw: string, + inputToken: EvmToken, + outputToken: EvmToken, + inputTokenDetails: EvmTokenDetails, + outputTokenDetails: EvmTokenDetails, + oraclePrice?: Big + ): void { + ctx.nablaSwapEvm = { + ...ctx.nablaSwapEvm, + effectiveExchangeRate: result.effectiveExchangeRate, + inputAmountForSwapDecimal, + inputAmountForSwapRaw, + inputCurrency: inputToken, + inputDecimals: inputTokenDetails.decimals, + inputToken: inputTokenDetails.erc20AddressSourceChain, + oraclePrice, + outputAmountDecimal: result.nablaOutputAmountDecimal, + outputAmountRaw: result.nablaOutputAmountRaw, + outputCurrency: outputToken, + outputDecimals: outputTokenDetails.decimals, + outputToken: outputTokenDetails.erc20AddressSourceChain + }; + } + + private addNote( + ctx: QuoteContext, + inputToken: EvmTokenDetails, + outputToken: EvmTokenDetails, + inputAmountForSwap: string, + result: { nablaOutputAmountDecimal: Big } + ): void { + ctx.addNote?.( + `Nabla swap from ${inputToken.assetSymbol} to ${outputToken.assetSymbol}, input amount ${inputAmountForSwap}, output amount ${result.nablaOutputAmountDecimal.toFixed()}` + ); + } +} diff --git a/apps/api/src/api/services/quote/engines/nabla-swap/offramp-evm.ts b/apps/api/src/api/services/quote/engines/nabla-swap/offramp-evm.ts new file mode 100644 index 000000000..dce1f7dad --- /dev/null +++ b/apps/api/src/api/services/quote/engines/nabla-swap/offramp-evm.ts @@ -0,0 +1,44 @@ +import { EvmToken, FiatToken, getPendulumDetails, Networks, PENDULUM_USDC_AXL, RampDirection } from "@vortexfi/shared"; +import { QuoteContext } from "../../core/types"; +import { BaseNablaSwapEngineEvm, NablaSwapEvmComputation } from "./base-evm"; +import { NablaSwapComputation } from "./index"; + +export class OffRampSwapEngineEvm extends BaseNablaSwapEngineEvm { + readonly outputToken: EvmToken; + + constructor(outputToken: EvmToken) { + super(); + this.outputToken = outputToken; + } + + readonly config = { + direction: RampDirection.SELL, + skipNote: "OffRampSwapEngineEvm: Skipped because rampType is BUY, this engine handles SELL operations only" + } as const; + + protected validate(ctx: QuoteContext): void { + if (!ctx.preNabla?.deductibleFeeAmountInSwapCurrency) { + throw new Error( + "OffRampSwapEngineEvm: Missing deductibleFeeAmountInSwapCurrency in preNabla context - ensure initialize stage ran successfully" + ); + } + } + + protected compute(ctx: QuoteContext): NablaSwapEvmComputation { + const inputAmountPreFees = ctx.evmToEvm?.outputAmountDecimal; + if (!inputAmountPreFees) { + throw new Error( + "OffRampSwapEngineEvm: Missing input amount from previous stage - ensure initialize stage ran successfully" + ); + } + + // We receive USDC on Base. + const inputToken = EvmToken.USDC; + + return { + inputAmountPreFees, + inputToken, + outputToken: this.outputToken + }; + } +} diff --git a/apps/api/src/api/services/quote/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..75cdff6c2 --- /dev/null +++ b/apps/api/src/api/services/quote/engines/nabla-swap/onramp-evm.ts @@ -0,0 +1,39 @@ +import { EvmToken, RampDirection } from "@vortexfi/shared"; +import { QuoteContext } from "../../core/types"; +import { BaseNablaSwapEngineEvm, NablaSwapEvmComputation } from "./base-evm"; + +export class OnRampSwapEngineEvm extends BaseNablaSwapEngineEvm { + readonly config = { + direction: RampDirection.BUY, + skipNote: "OnRampSwapEngineEvm: Skipped because rampType is SELL, this engine handles BUY operations only" + } as const; + + protected validate(ctx: QuoteContext): void { + if (!ctx.fees?.usd) { + throw new Error("OnRampSwapEngineEvm: Fees in USD must be calculated first - ensure fee stage ran successfully"); + } + } + + protected compute(ctx: QuoteContext): NablaSwapEvmComputation { + const { request } = ctx; + + if (!ctx.aveniaTransfer) { + throw new Error( + "OnRampSwapEngineEvm: Missing aveniaTransfer quote data from previous stage - ensure initialize stage ran successfully" + ); + } + + const inputAmountPreFees = ctx.aveniaTransfer.outputAmountDecimal; + + // For Onramp EVM, the input token for Nabla is the output of Avenia transfer (BRL on Base) + // The output token is fixed at USDC. + const inputToken = ctx.aveniaTransfer.currency as EvmToken; + const outputToken = EvmToken.USDC; + + return { + inputAmountPreFees, + inputToken, + outputToken + }; + } +} diff --git a/apps/api/src/api/services/quote/engines/squidrouter/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..fb61dda41 --- /dev/null +++ b/apps/api/src/api/services/quote/engines/squidrouter/onramp-base-to-evm.ts @@ -0,0 +1,97 @@ +import { + ERC20_USDC_POLYGON, + ERC20_USDC_POLYGON_DECIMALS, + EvmToken, + getNetworkFromDestination, + multiplyByPowerOfTen, + Networks, + OnChainToken, + RampDirection +} from "@vortexfi/shared"; +import Big from "big.js"; +import httpStatus from "http-status"; +import { APIError } from "../../../../errors/api-error"; +import { getTokenDetailsForEvmDestination } from "../../core/squidrouter"; +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/strategies/offramp-evm-to-alfredpay.strategy.ts b/apps/api/src/api/services/quote/routes/strategies/offramp-evm-to-alfredpay.strategy.ts index 503e339a8..1169ae8b1 100644 --- a/apps/api/src/api/services/quote/routes/strategies/offramp-evm-to-alfredpay.strategy.ts +++ b/apps/api/src/api/services/quote/routes/strategies/offramp-evm-to-alfredpay.strategy.ts @@ -1,22 +1,33 @@ +import { Networks } from "@vortexfi/shared"; import { EnginesRegistry, IRouteStrategy, QuoteContext, StageKey } from "../../core/types"; -import { OffRampEvmToAlfredpayFeeEngine } from "../../engines/fee/offramp-evm-to-alfredpay"; +import { OffRampDiscountEngine } from "../../engines/discount/offramp"; +import { OffRampFeeAveniaEngine } from "../../engines/fee/offramp-avenia"; import { OffRampFinalizeEngine } from "../../engines/finalize/offramp"; +import { OffRampFromEvmInitializeEngine } from "../../engines/initialize/offramp-from-evm-alfredpay"; +import { OffRampSwapEngine } from "../../engines/nabla-swap/offramp"; +import { OffRampToAveniaPendulumTransferEngine } from "../../engines/pendulum-transfers/offramp-avenia"; -import { AlfredpayOffRampFromEvmInitializeEngine } from "../../engines/initialize/offramp-from-evm-alfredpay"; -import { OfframpTransactionAlfredpayEngine } from "../../engines/partners/offramp-alfredpay"; - -export class OfframpEvmToAlfredpayStrategy implements IRouteStrategy { - readonly name = "OfframpEvmToAlfredpay"; +export class OfframpToPixStrategy implements IRouteStrategy { + readonly name = "OffRampPix"; getStages(_ctx: QuoteContext): StageKey[] { - return [StageKey.Initialize, StageKey.Fee, StageKey.PartnerOperation, StageKey.Finalize]; + return [ + StageKey.Initialize, + StageKey.NablaSwap, + StageKey.Fee, + StageKey.Discount, + StageKey.PendulumTransfer, + StageKey.Finalize + ]; } - getEngines(_ctx: QuoteContext): EnginesRegistry { + getEngines(ctx: QuoteContext): EnginesRegistry { return { - [StageKey.Initialize]: new AlfredpayOffRampFromEvmInitializeEngine(), - [StageKey.Fee]: new OffRampEvmToAlfredpayFeeEngine(), - [StageKey.PartnerOperation]: new OfframpTransactionAlfredpayEngine(), + [StageKey.Initialize]: new OffRampFromEvmInitializeEngine(Networks.Base), + [StageKey.Fee]: new OffRampFeeAveniaEngine(), + [StageKey.NablaSwap]: new OffRampSwapEngine(), + [StageKey.Discount]: new OffRampDiscountEngine(), + [StageKey.PendulumTransfer]: new OffRampToAveniaPendulumTransferEngine(), [StageKey.Finalize]: new OffRampFinalizeEngine() }; } diff --git a/apps/api/src/api/services/quote/routes/strategies/offramp-to-pix-base.strategy.ts b/apps/api/src/api/services/quote/routes/strategies/offramp-to-pix-base.strategy.ts new file mode 100644 index 000000000..00340507b --- /dev/null +++ b/apps/api/src/api/services/quote/routes/strategies/offramp-to-pix-base.strategy.ts @@ -0,0 +1,25 @@ +import { EvmToken, Networks } from "@vortexfi/shared"; +import { EnginesRegistry, IRouteStrategy, QuoteContext, StageKey } from "../../core/types"; +import { OffRampDiscountEngine } from "../../engines/discount/offramp"; +import { OffRampFeeAveniaEngine } from "../../engines/fee/offramp-avenia"; +import { OffRampFinalizeEngine } from "../../engines/finalize/offramp"; +import { OffRampFromEvmInitializeEngine } from "../../engines/initialize/offramp-from-evm-alfredpay"; +import { OffRampSwapEngineEvm } from "../../engines/nabla-swap/offramp-evm"; + +export class OfframpEvmToAlfredpayStrategy implements IRouteStrategy { + readonly name = "OfframpEvmToAlfredpay"; + + getStages(_ctx: QuoteContext): StageKey[] { + return [StageKey.Initialize, StageKey.Fee, StageKey.PartnerOperation, StageKey.Finalize]; + } + + getEngines(_ctx: QuoteContext): EnginesRegistry { + return { + [StageKey.Initialize]: new OffRampFromEvmInitializeEngine(Networks.Polygon), + [StageKey.Fee]: new OffRampFeeAveniaEngine(), + [StageKey.NablaSwap]: new OffRampSwapEngineEvm(EvmToken.BRLA), + [StageKey.Discount]: new OffRampDiscountEngine(), + [StageKey.Finalize]: new OffRampFinalizeEngine() + }; + } +} diff --git a/apps/api/src/api/services/quote/routes/strategies/offramp-to-pix.strategy.ts b/apps/api/src/api/services/quote/routes/strategies/offramp-to-pix.strategy.ts index 181e66f29..f8067112c 100644 --- a/apps/api/src/api/services/quote/routes/strategies/offramp-to-pix.strategy.ts +++ b/apps/api/src/api/services/quote/routes/strategies/offramp-to-pix.strategy.ts @@ -3,7 +3,7 @@ import { OffRampDiscountEngine } from "../../engines/discount/offramp"; import { OffRampFeeAveniaEngine } from "../../engines/fee/offramp-avenia"; import { OffRampFinalizeEngine } from "../../engines/finalize/offramp"; import { OffRampFromAssethubInitializeEngine } from "../../engines/initialize/offramp-from-assethub"; -import { OffRampFromEvmInitializeEngine } from "../../engines/initialize/offramp-from-evm"; +import { OffRampFromEvmInitializeEngineMoonbeam } from "../../engines/initialize/offramp-from-evm"; import { OffRampSwapEngine } from "../../engines/nabla-swap/offramp"; import { OffRampToAveniaPendulumTransferEngine } from "../../engines/pendulum-transfers/offramp-avenia"; @@ -24,7 +24,9 @@ export class OfframpToPixStrategy implements IRouteStrategy { getEngines(ctx: QuoteContext): EnginesRegistry { return { [StageKey.Initialize]: - ctx.request.from === "assethub" ? new OffRampFromAssethubInitializeEngine() : new OffRampFromEvmInitializeEngine(), + ctx.request.from === "assethub" + ? new OffRampFromAssethubInitializeEngine() + : new OffRampFromEvmInitializeEngineMoonbeam(), [StageKey.Fee]: new OffRampFeeAveniaEngine(), [StageKey.NablaSwap]: new OffRampSwapEngine(), [StageKey.Discount]: new OffRampDiscountEngine(), diff --git a/apps/api/src/api/services/quote/routes/strategies/offramp-to-stellar.strategy.ts b/apps/api/src/api/services/quote/routes/strategies/offramp-to-stellar.strategy.ts index 6a2f3fb64..823e73a57 100644 --- a/apps/api/src/api/services/quote/routes/strategies/offramp-to-stellar.strategy.ts +++ b/apps/api/src/api/services/quote/routes/strategies/offramp-to-stellar.strategy.ts @@ -3,7 +3,7 @@ import { OffRampDiscountEngine } from "../../engines/discount/offramp"; import { OffRampFeeStellarEngine } from "../../engines/fee/offramp-stellar"; import { OffRampFinalizeEngine } from "../../engines/finalize/offramp"; import { OffRampFromAssethubInitializeEngine } from "../../engines/initialize/offramp-from-assethub"; -import { OffRampFromEvmInitializeEngine } from "../../engines/initialize/offramp-from-evm"; +import { OffRampFromEvmInitializeEngineMoonbeam } from "../../engines/initialize/offramp-from-evm"; import { OffRampSwapEngine } from "../../engines/nabla-swap/offramp"; import { OffRampToStellarPendulumTransferEngine } from "../../engines/pendulum-transfers/offramp-stellar"; @@ -24,7 +24,9 @@ export class OfframpToStellarStrategy implements IRouteStrategy { getEngines(ctx: QuoteContext): EnginesRegistry { return { [StageKey.Initialize]: - ctx.request.from === "assethub" ? new OffRampFromAssethubInitializeEngine() : new OffRampFromEvmInitializeEngine(), + ctx.request.from === "assethub" + ? new OffRampFromAssethubInitializeEngine() + : new OffRampFromEvmInitializeEngineMoonbeam(), [StageKey.NablaSwap]: new OffRampSwapEngine(), [StageKey.Fee]: new OffRampFeeStellarEngine(), [StageKey.Discount]: new OffRampDiscountEngine(), 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..b9c37f03d --- /dev/null +++ b/apps/api/src/api/services/quote/routes/strategies/onramp-avenia-to-evm.strategy-base.ts @@ -0,0 +1,24 @@ +import { EnginesRegistry, IRouteStrategy, QuoteContext, StageKey } from "../../core/types"; +import { OnRampDiscountEngine } from "../../engines/discount/onramp"; +import { OnRampFinalizeEngine } from "../../engines/finalize/onramp"; +import { OnRampInitializeAveniaEngine } from "../../engines/initialize/onramp-avenia"; +import { OnRampSwapEngineEvm } from "../../engines/nabla-swap/onramp-evm"; +import { OnRampSquidRouterBrlToEvmEngineBase } from "../../engines/squidrouter/onramp-base-to-evm"; + +export class OnrampAveniaToEvmStrategy implements IRouteStrategy { + readonly name = "OnRampAveniaToEvm"; + + getStages(_ctx: QuoteContext): StageKey[] { + return [StageKey.Initialize, StageKey.Fee, StageKey.NablaSwap, StageKey.Discount, StageKey.SquidRouter, StageKey.Finalize]; + } + + getEngines(_ctx: QuoteContext): EnginesRegistry { + return { + [StageKey.Initialize]: new OnRampInitializeAveniaEngine(), + [StageKey.NablaSwap]: new OnRampSwapEngineEvm(), + [StageKey.Discount]: new OnRampDiscountEngine(), + [StageKey.SquidRouter]: new OnRampSquidRouterBrlToEvmEngineBase(), + [StageKey.Finalize]: new OnRampFinalizeEngine() + }; + } +} diff --git a/apps/api/src/api/services/transactions/common/feeDistribution.ts b/apps/api/src/api/services/transactions/common/feeDistribution.ts index a586193aa..1d8dffa95 100644 --- a/apps/api/src/api/services/transactions/common/feeDistribution.ts +++ b/apps/api/src/api/services/transactions/common/feeDistribution.ts @@ -1,7 +1,11 @@ import { AccountMeta, ApiManager, + EvmClientManager, + EvmToken, + EvmTransactionData, encodeSubmittableExtrinsic, + evmTokenConfig, getNetworkFromDestination, Networks, PENDULUM_USDC_ASSETHUB, @@ -10,7 +14,9 @@ import { UnsignedTx } from "@vortexfi/shared"; import Big from "big.js"; +import { encodeFunctionData } from "viem/utils"; import logger from "../../../../config/logger"; +import erc20ABI from "../../../../contracts/ERC20"; import Partner from "../../../../models/partner.model"; import { QuoteTicketAttributes } from "../../../../models/quoteTicket.model"; import { multiplyByPowerOfTen } from "../../pendulum/helpers"; @@ -173,3 +179,109 @@ export async function addFeeDistributionTransaction( return nextNonce; } + +/** + * Creates an EVM fee distribution transaction for Base network. + * Transfers total fees (network + vortex + partner) to vortex payout address using USDC. + * + * @param quote The quote ticket + * @returns The EVM transaction data or null if no fees to distribute + */ +export async function createEvmFeeDistributionTransaction(quote: QuoteTicketAttributes): Promise { + const usdFeeStructure = quote.metadata.fees?.usd; + if (!usdFeeStructure) { + logger.warn("No USD fee structure found in quote metadata, skipping EVM fee distribution transaction"); + return null; + } + + const networkFeeUSD = usdFeeStructure.network; + const vortexFeeUSD = usdFeeStructure.vortex; + const partnerMarkupFeeUSD = usdFeeStructure.partnerMarkup; + + // Get vortex payout address + const vortexPartner = await Partner.findOne({ + where: { isActive: true, name: "vortex", rampType: quote.rampType } + }); + if (!vortexPartner || !vortexPartner.payoutAddress) { + logger.warn("Vortex partner or payout address not found, skipping EVM fee distribution transaction"); + return null; + } + const vortexPayoutAddress = vortexPartner.payoutAddress; + + // Use Base USDC for decimal calculations + const baseUsdcConfig = evmTokenConfig[Networks.Base][EvmToken.USDC]; + if (!baseUsdcConfig) { + logger.warn("Base USDC configuration not found, skipping EVM fee distribution transaction"); + return null; + } + + const decimals = baseUsdcConfig.decimals; + + // Convert USD fees to USDC raw units + const networkFeeUsdcRaw = multiplyByPowerOfTen(networkFeeUSD, decimals); + const vortexFeeUsdcRaw = multiplyByPowerOfTen(vortexFeeUSD, decimals); + const partnerMarkupFeeUsdcRaw = multiplyByPowerOfTen(partnerMarkupFeeUSD, decimals); + + // Calculate total fee amount + const totalFeeUsdcRaw = networkFeeUsdcRaw.plus(vortexFeeUsdcRaw).plus(partnerMarkupFeeUsdcRaw); + + if (totalFeeUsdcRaw.lte(0)) { + logger.warn("No fees to distribute, skipping EVM fee distribution transaction"); + return null; + } + + const evmClientManager = EvmClientManager.getInstance(); + const publicClient = evmClientManager.getClient(Networks.Base); + + // Encode USDC transfer to vortex payout address + const transferCallData = encodeFunctionData({ + abi: erc20ABI, + args: [vortexPayoutAddress, totalFeeUsdcRaw.toFixed(0)], + functionName: "transfer" + }); + + const { maxFeePerGas, maxPriorityFeePerGas } = await publicClient.estimateFeesPerGas(); + + const txData: EvmTransactionData = { + data: transferCallData as `0x${string}`, + gas: "100000", + maxFeePerGas: String(maxFeePerGas), + maxPriorityFeePerGas: String(maxPriorityFeePerGas), + to: baseUsdcConfig.erc20AddressSourceChain, + value: "0" + }; + + return txData; +} + +/** + * Adds EVM fee distribution transaction for Base network if available. + * + * @param quote Quote ticket + * @param account Account metadata + * @param unsignedTxs Array to add transactions to + * @param nextNonce Next available nonce + * @returns Updated nonce + */ +export async function addEvmFeeDistributionTransaction( + quote: QuoteTicketAttributes, + account: AccountMeta, + unsignedTxs: UnsignedTx[], + nextNonce: number +): Promise { + const feeDistributionTx = await createEvmFeeDistributionTransaction(quote); + + if (feeDistributionTx) { + unsignedTxs.push({ + meta: {}, + network: Networks.Base, + nonce: nextNonce, + phase: "distributeFees", + signer: account.address, + txData: feeDistributionTx + }); + nextNonce++; + } + + return nextNonce; +} diff --git a/apps/api/src/api/services/transactions/offramp/common/validation.ts b/apps/api/src/api/services/transactions/offramp/common/validation.ts index 67a1b8af9..d660de916 100644 --- a/apps/api/src/api/services/transactions/offramp/common/validation.ts +++ b/apps/api/src/api/services/transactions/offramp/common/validation.ts @@ -86,13 +86,15 @@ export function validateBRLOfframp( throw new Error("brlaEvmAddress, pixDestination, receiverTaxId and taxId parameters must be provided for offramp to BRL"); } - if (!quote.metadata.pendulumToMoonbeamXcm?.outputAmountRaw) { - throw new Error("Quote metadata is missing pendulumToMoonbeamXcm information"); - } + // TODO add validation relevant to EVM flow, after quote context is known. + // if (!quote.metadata.pendulumToMoonbeamXcm?.outputAmountRaw) { + // throw new Error("Quote metadata is missing pendulumToMoonbeamXcm information"); + // } + // TODO still don't know which field will be return { brlaEvmAddress, - offrampAmountBeforeAnchorFeesRaw: quote.metadata.pendulumToMoonbeamXcm.outputAmountRaw, + offrampAmountBeforeAnchorFeesRaw: "200", //quote.metadata.pendulumToMoonbeamXcm.outputAmountRaw, pixDestination, receiverTaxId, taxId diff --git a/apps/api/src/api/services/transactions/offramp/routes/evm-to-brl-base.ts b/apps/api/src/api/services/transactions/offramp/routes/evm-to-brl-base.ts new file mode 100644 index 000000000..1eb55e4b4 --- /dev/null +++ b/apps/api/src/api/services/transactions/offramp/routes/evm-to-brl-base.ts @@ -0,0 +1,132 @@ +import { + createOfframpSquidrouterTransactionsToEvm, + EvmToken, + EvmTransactionData, + evmTokenConfig, + isEvmTokenDetails, + multiplyByPowerOfTen, + Networks, + UnsignedTx +} from "@vortexfi/shared"; +import Big from "big.js"; +import { StateMetadata } from "../../../phases/meta-state-types"; +import { encodeEvmTransactionData } from "../.."; +import { addEvmFeeDistributionTransaction, addFeeDistributionTransaction } from "../../common/feeDistribution"; +import { addNablaSwapTransactionsOnBase } from "../../onramp/common/transactions"; +import { OfframpTransactionParams, OfframpTransactionsWithMeta } from "../common/types"; +import { validateBRLOfframp, validateOfframpQuote } from "../common/validation"; + +/** + * Prepares all transactions for an EVM to BRL offramp. + * This route handles: EVM → Base (swap) → Avenia Offramp. + */ +export async function prepareEvmToBRLOfframpTransactions({ + quote, + signingAccounts, + userAddress, + pixDestination, + taxId, + receiverTaxId, + brlaEvmAddress +}: OfframpTransactionParams): Promise { + const unsignedTxs: UnsignedTx[] = []; + let stateMeta: Partial = {}; + + // Validate inputs and extract required data + const { fromNetwork, inputTokenDetails, outputTokenDetails } = validateOfframpQuote(quote, signingAccounts); + + const evmEphemeralEntry = signingAccounts.find(account => account.type === "EVM"); + if (!evmEphemeralEntry) { + throw new Error("EVM account not found. An EVM ephemeral account is required for EVM to BRL offramp."); + } + + const { + brlaEvmAddress: validatedBrlaEvmAddress, + pixDestination: validatedPixDestination, + taxId: validatedTaxId, + receiverTaxId: validatedReceiverTaxId, + offrampAmountBeforeAnchorFeesRaw + } = validateBRLOfframp(quote, { brlaEvmAddress, pixDestination, receiverTaxId, taxId }); + + const inputAmountRaw = multiplyByPowerOfTen(new Big(quote.inputAmount), inputTokenDetails.decimals).toFixed(0, 0); + + if (!userAddress) { + throw new Error("User address must be provided for offramping."); + } + + if (!isEvmTokenDetails(inputTokenDetails)) { + throw new Error("EVM to BRL route requires EVM input token"); + } + + const baseUsdcAddress = evmTokenConfig[Networks.Base][EvmToken.USDC]?.erc20AddressSourceChain; + if (!baseUsdcAddress) { + throw new Error("Invalid USDC configuration for Base in evmTokenConfig"); + } + + const baseBrlaAddress = evmTokenConfig[Networks.Base][EvmToken.BRLA]?.erc20AddressSourceChain; + if (!baseBrlaAddress) { + throw new Error("Invalid BRLA configuration for Base in evmTokenConfig"); + } + + // Special case: if user is already on Base with USDC, skip squidrouter transactions + if (!(fromNetwork === Networks.Base && inputTokenDetails.erc20AddressSourceChain === baseUsdcAddress)) { + // TODO Maybe, move to contract-base squid swap. + // Otherwise use the same approach as previously + const { approveData, swapData } = await createOfframpSquidrouterTransactionsToEvm({ + destinationAddress: evmEphemeralEntry.address, + fromAddress: userAddress, + fromNetwork, + fromToken: inputTokenDetails.erc20AddressSourceChain, + rawAmount: inputAmountRaw, + toNetwork: Networks.Base, + toToken: baseUsdcAddress + }); + + unsignedTxs.push({ + meta: {}, + network: fromNetwork, + nonce: 0, + phase: "squidRouterApprove", + signer: userAddress, + txData: encodeEvmTransactionData(approveData) as EvmTransactionData + }); + + unsignedTxs.push({ + meta: {}, + network: fromNetwork, + nonce: 1, + phase: "squidRouterSwap", + signer: userAddress, + txData: encodeEvmTransactionData(swapData) as EvmTransactionData + }); + } + + let baseNonce = 0; + + // Add Base Nabla swap transactions (USDC to BRLA on Base) + const { nextNonce: nonceAfterNabla, stateMeta: nablaStateMeta } = await addNablaSwapTransactionsOnBase( + { + account: evmEphemeralEntry, + inputTokenAddress: baseUsdcAddress, // Swap from USDC to BRLA on Base + outputTokenAddress: baseBrlaAddress, // BRLA address on Base + quote + }, + unsignedTxs, + baseNonce + ); + stateMeta = { ...stateMeta, ...nablaStateMeta }; + baseNonce = nonceAfterNabla; + + // Fee distribution transaction on EVM + baseNonce = await addEvmFeeDistributionTransaction(quote, evmEphemeralEntry, unsignedTxs, baseNonce); + + stateMeta = { + ...stateMeta, + brlaEvmAddress: validatedBrlaEvmAddress, + pixDestination: validatedPixDestination, + receiverTaxId: validatedReceiverTaxId, + taxId: validatedTaxId + }; + + return { stateMeta, unsignedTxs }; +} diff --git a/apps/api/src/api/services/transactions/onramp/common/transactions.ts b/apps/api/src/api/services/transactions/onramp/common/transactions.ts index 4749f8c78..c5e6295d9 100644 --- a/apps/api/src/api/services/transactions/onramp/common/transactions.ts +++ b/apps/api/src/api/services/transactions/onramp/common/transactions.ts @@ -4,6 +4,7 @@ import { AMM_MINIMUM_OUTPUT_SOFT_MARGIN, createMoonbeamToPendulumXCM, createNablaTransactionsForOnramp, + createNablaTransactionsForOnrampOnEVM, EvmClientManager, EvmNetworks, EvmTransactionData, @@ -233,6 +234,72 @@ export async function addOnrampDestinationChainTransactions(params: { return txData; } +/** + * Creates Nabla swap transactions for Base + * @param params Transaction parameters + * @param unsignedTxs Array to add transactions to + * @param nextNonce Next available nonce + * @returns Updated nonce and state metadata + */ +export async function addNablaSwapTransactionsOnBase( + params: { + quote: QuoteTicketAttributes; + account: AccountMeta; + inputTokenAddress: `0x${string}`; + outputTokenAddress: `0x${string}`; + }, + unsignedTxs: UnsignedTx[], + nextNonce: number +): Promise<{ nextNonce: number; stateMeta: Partial }> { + const { quote, account, inputTokenAddress, outputTokenAddress } = params; + + if (!quote.metadata.nablaSwap?.inputAmountForSwapRaw) { + throw new Error("Missing nablaSwap input amount in quote metadata"); + } + + // The input amount for the swap was already calculated in the quote. + const inputAmountForNablaSwapRaw = quote.metadata.nablaSwap.inputAmountForSwapRaw; + const outputAmountRaw = Big(quote.metadata.nablaSwap.outputAmountRaw); + + const nablaSoftMinimumOutputRaw = outputAmountRaw.mul(1 - AMM_MINIMUM_OUTPUT_SOFT_MARGIN).toFixed(0, 0); + const nablaHardMinimumOutputRaw = outputAmountRaw.mul(1 - AMM_MINIMUM_OUTPUT_HARD_MARGIN).toFixed(0, 0); + + const { approve, swap } = await createNablaTransactionsForOnrampOnEVM( + inputAmountForNablaSwapRaw, + account, + inputTokenAddress, + outputTokenAddress, + nablaHardMinimumOutputRaw + ); + + unsignedTxs.push({ + meta: {}, + network: Networks.Base, + nonce: nextNonce, + phase: "nablaApprove", + signer: account.address, + txData: approve + }); + nextNonce++; + + unsignedTxs.push({ + meta: {}, + network: Networks.Base, + nonce: nextNonce, + phase: "nablaSwap", + signer: account.address, + txData: swap + }); + nextNonce++; + + return { + nextNonce, + stateMeta: { + nablaSoftMinimumOutputRaw + } + }; +} + /** * Creates an approval transaction on the destination chain * @param params Transaction parameters diff --git a/apps/api/src/api/services/transactions/onramp/common/validation.ts b/apps/api/src/api/services/transactions/onramp/common/validation.ts index 19ac4ea20..3cbc29381 100644 --- a/apps/api/src/api/services/transactions/onramp/common/validation.ts +++ b/apps/api/src/api/services/transactions/onramp/common/validation.ts @@ -1,9 +1,13 @@ import { AccountMeta, + EvmToken, + evmTokenConfig, FiatToken, getAnyFiatTokenDetails, + getEvmTokenConfig, getNetworkFromDestination, getOnChainTokenDetails, + getOnChainTokenDetailsOrDefault, isFiatToken, isMoonbeamTokenDetails, isOnChainToken, @@ -60,6 +64,47 @@ export function validateAveniaOnramp( return { evmEphemeralEntry, inputTokenDetails, outputTokenDetails, substrateEphemeralEntry, toNetwork }; } +export function validateAveniaOnrampOnBase( + quote: QuoteTicketAttributes, + signingAccounts: AccountMeta[] +): { + toNetwork: Networks; + outputTokenDetails: OnChainTokenDetails; + evmEphemeralEntry: AccountMeta; + inputTokenDetails: OnChainTokenDetails; +} { + const toNetwork = getNetworkFromDestination(quote.to); + if (!toNetwork) { + throw new Error(`Invalid network for destination ${quote.to}`); + } + + const evmEphemeralEntry = signingAccounts.find(ephemeral => ephemeral.type === "EVM"); + if (!evmEphemeralEntry) { + throw new Error("Base ephemeral not found"); + } + + if (!isFiatToken(quote.inputCurrency)) { + throw new Error(`Input currency must be fiat token for onramp, got ${quote.inputCurrency}`); + } + + // For Base, we use BRLA's native minted token + const inputTokenDetails = getEvmTokenConfig().base[EvmToken.BRLA]; + if (!inputTokenDetails) { + throw new Error("BRLA token details not found for Base"); + } + + if (!isOnChainToken(quote.outputCurrency)) { + throw new Error(`Output currency cannot be fiat token ${quote.outputCurrency} for onramp.`); + } + const outputTokenDetails = getOnChainTokenDetails(toNetwork, quote.outputCurrency); + + if (!outputTokenDetails || !isOnChainTokenDetails(outputTokenDetails)) { + throw new Error(`Output token must be on-chain token for onramp, got ${quote.outputCurrency}`); + } + + return { evmEphemeralEntry, inputTokenDetails, outputTokenDetails, toNetwork }; +} + export function validateMoneriumOnramp( quote: QuoteTicketAttributes, signingAccounts: AccountMeta[] diff --git a/apps/api/src/api/services/transactions/onramp/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/avenia-to-evm-base.ts b/apps/api/src/api/services/transactions/onramp/routes/avenia-to-evm-base.ts new file mode 100644 index 000000000..16171e211 --- /dev/null +++ b/apps/api/src/api/services/transactions/onramp/routes/avenia-to-evm-base.ts @@ -0,0 +1,201 @@ +import { + AXL_USDC_MOONBEAM_DETAILS, + createOnrampSquidrouterTransactionsOnDestinationChain, + EvmNetworks, + EvmToken, + EvmTokenDetails, + EvmTransactionData, + encodeSubmittableExtrinsic, + evmTokenConfig, + getNetworkId, + getOnChainTokenDetailsOrDefault, + isEvmTokenDetails, + isNativeEvmToken, + multiplyByPowerOfTen, + Networks, + UnsignedTx +} from "@vortexfi/shared"; +import { privateKeyToAccount } from "viem/accounts"; +import { MOONBEAM_FUNDING_PRIVATE_KEY } from "../../../../../constants/constants"; +import { StateMetadata } from "../../../phases/meta-state-types"; +import { addEvmFeeDistributionTransaction } from "../../common/feeDistribution"; +import { encodeEvmTransactionData } from "../../index"; +import { + addDestinationChainApprovalTransaction, + addNablaSwapTransactionsOnBase, + addOnrampDestinationChainTransactions +} from "../common/transactions"; +import { AveniaOnrampTransactionParams, OnrampTransactionsWithMeta } from "../common/types"; +import { validateAveniaOnrampOnBase } from "../common/validation"; + +/** + * Prepares all transactions for an Avenia (BRL) onramp to EVM chain via Base. + * This route handles: BRL → Base (BRLA) -> Swap (to USDC) → EVM (final transfer) + */ +export async function prepareAveniaToEvmOnrampTransactionsOnBase({ + quote, + signingAccounts, + destinationAddress, + taxId +}: AveniaOnrampTransactionParams): Promise { + let stateMeta: Partial = {}; + const unsignedTxs: UnsignedTx[] = []; + + // Validate inputs and extract required data + const { toNetwork, outputTokenDetails, evmEphemeralEntry, inputTokenDetails } = validateAveniaOnrampOnBase( + quote, + signingAccounts + ); + + // Setup state metadata + stateMeta = { + destinationAddress, + evmEphemeralAddress: evmEphemeralEntry.address, + taxId + }; + + let baseNonce = 0; + + if (!quote.metadata.aveniaTransfer?.outputAmountRaw) { + throw new Error("Missing aveniaTransfer amountOutRaw in quote metadata"); + } + const inputAmountPostAnchorFeeRaw = quote.metadata.aveniaTransfer.outputAmountRaw; + + if (!isEvmTokenDetails(outputTokenDetails)) { + throw new Error(`Output token must be an EVM token for onramp to any EVM chain, got ${outputTokenDetails.assetSymbol}`); + } + + const destinationAxlUsdcDetails = getOnChainTokenDetailsOrDefault(toNetwork as Networks, EvmToken.AXLUSDC) as EvmTokenDetails; + + // Output for BRLA onramp will always go through USDC. + // TODO. Unless the actual BRLA token wants to be onramped. + const nablaSwapOutputTokenAddress = evmTokenConfig[Networks.Base][EvmToken.USDC]?.erc20AddressSourceChain; + if (!nablaSwapOutputTokenAddress) { + throw new Error("Invalid USDC configuration for Base in evmTokenConfig"); + } + + const { nextNonce: nonceAfterNabla, stateMeta: nablaStateMeta } = await addNablaSwapTransactionsOnBase( + { + account: evmEphemeralEntry, + inputTokenAddress: (inputTokenDetails as EvmTokenDetails).erc20AddressSourceChain, + outputTokenAddress: nablaSwapOutputTokenAddress, + quote + }, + unsignedTxs, + baseNonce + ); + stateMeta = { ...stateMeta, ...nablaStateMeta }; + baseNonce = nonceAfterNabla; + + baseNonce = await addEvmFeeDistributionTransaction(quote, evmEphemeralEntry, unsignedTxs, baseNonce); + + const finalAmountRaw = multiplyByPowerOfTen(quote.outputAmount, outputTokenDetails.decimals); + + // Special case, onramping USDC on Base. We need to skip the SquidRouter step and go directly to the destination transfer. + if (toNetwork === Networks.Base && outputTokenDetails.erc20AddressSourceChain === nablaSwapOutputTokenAddress) { + const finalDestinationTransfer = await addOnrampDestinationChainTransactions({ + amountRaw: finalAmountRaw.toString(), + destinationNetwork: Networks.Base, + isNativeToken: isNativeEvmToken(outputTokenDetails), + toAddress: destinationAddress, + toToken: outputTokenDetails.erc20AddressSourceChain + }); + + unsignedTxs.push({ + meta: {}, + network: Networks.Base, + nonce: baseNonce, + phase: "destinationTransfer", + signer: evmEphemeralEntry.address, + txData: finalDestinationTransfer + }); + + return { stateMeta, unsignedTxs }; + } + + // Fallback swap depends on the EVM chain. For Ethereum, the bridged token is USDC. For the rest, it is axlUSDC. + const bridgedTokenForFallback = + toNetwork === Networks.Ethereum + ? evmTokenConfig.ethereum.USDC!.erc20AddressSourceChain + : destinationAxlUsdcDetails.erc20AddressSourceChain; + + const inputAmountRawFinalBridge = quote.metadata.evmToEvm?.inputAmountRaw; + if (!inputAmountRawFinalBridge) { + throw new Error("Missing input amount for final bridge in quote metadata"); + } + + // Destination chain: Squidrouter swap to final token + const { approveData: finalApproveData, swapData: finalSwapData } = + await createOnrampSquidrouterTransactionsOnDestinationChain({ + destinationAddress: evmEphemeralEntry.address, + fromAddress: evmEphemeralEntry.address, + fromToken: outputTokenDetails.erc20AddressSourceChain, + network: toNetwork as EvmNetworks, + rawAmount: inputAmountRawFinalBridge, + toToken: outputTokenDetails.erc20AddressSourceChain + }); + + let destinationNonce = 0; + + const finalDestinationTransfer = await addOnrampDestinationChainTransactions({ + amountRaw: finalAmountRaw.toString(), + destinationNetwork: toNetwork as EvmNetworks, + isNativeToken: isNativeEvmToken(outputTokenDetails), + toAddress: destinationAddress, + toToken: outputTokenDetails.erc20AddressSourceChain + }); + + unsignedTxs.push({ + meta: {}, + network: toNetwork, + nonce: destinationNonce, + phase: "destinationTransfer", + signer: evmEphemeralEntry.address, + txData: finalDestinationTransfer + }); + + destinationNonce++; + + unsignedTxs.push({ + meta: {}, + network: toNetwork, + nonce: destinationNonce, + phase: "backupSquidRouterApprove", + signer: evmEphemeralEntry.address, + txData: encodeEvmTransactionData(finalApproveData) as EvmTransactionData + }); + destinationNonce++; + + unsignedTxs.push({ + meta: {}, + network: toNetwork, + nonce: destinationNonce, + phase: "backupSquidRouterSwap", + signer: evmEphemeralEntry.address, + txData: encodeEvmTransactionData(finalSwapData) as EvmTransactionData + }); + destinationNonce++; + + const maxUint256 = 2n ** 256n - 1n; + const fundingAccount = privateKeyToAccount(MOONBEAM_FUNDING_PRIVATE_KEY as `0x${string}`); + + const backupApproveTransaction = await addDestinationChainApprovalTransaction({ + amountRaw: maxUint256.toString(), + destinationNetwork: toNetwork as EvmNetworks, + spenderAddress: fundingAccount.address, + tokenAddress: bridgedTokenForFallback + }); + + // We set this to 0 on purpose because we don't want to risk that the required nonce is never reached + const backupApproveNonce = 0; + unsignedTxs.push({ + meta: {}, + network: toNetwork, + nonce: backupApproveNonce, + phase: "backupApprove", + signer: evmEphemeralEntry.address, + txData: backupApproveTransaction + }); + + return { stateMeta, unsignedTxs }; +} diff --git a/apps/api/src/api/services/transactions/onramp/routes/avenia-to-evm.ts b/apps/api/src/api/services/transactions/onramp/routes/avenia-to-evm.ts index c90f0824b..bb03432c7 100644 --- a/apps/api/src/api/services/transactions/onramp/routes/avenia-to-evm.ts +++ b/apps/api/src/api/services/transactions/onramp/routes/avenia-to-evm.ts @@ -21,7 +21,7 @@ import { import { privateKeyToAccount } from "viem/accounts"; import { MOONBEAM_FUNDING_PRIVATE_KEY } from "../../../../../constants/constants"; import { StateMetadata } from "../../../phases/meta-state-types"; -import { addFeeDistributionTransaction } from "../../common/feeDistribution"; +import { addEvmFeeDistributionTransaction, addFeeDistributionTransaction } from "../../common/feeDistribution"; import { encodeEvmTransactionData } from "../../index"; import { addDestinationChainApprovalTransaction, @@ -193,6 +193,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/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/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..3f1287a49 100644 --- a/packages/shared/src/endpoints/ramp.endpoints.ts +++ b/packages/shared/src/endpoints/ramp.endpoints.ts @@ -36,11 +36,13 @@ export type RampPhase = | "stellarPayment" | "subsidizePreSwap" | "subsidizePostSwap" + | "subsidizePostSwapEvm" | "distributeFees" | "alfredpayOnrampMint" | "alfredpayOfframpTransfer" | "brlaOnrampMint" - | "brlaPayoutOnMoonbeam" + | "brlaPayoutOnBase" + | "baseTransfer" | "failed" | "timedOut" | "finalSettlementSubsidy" diff --git a/packages/shared/src/services/index.ts b/packages/shared/src/services/index.ts index 9caa39bb4..e3eb64dca 100644 --- a/packages/shared/src/services/index.ts +++ b/packages/shared/src/services/index.ts @@ -1,3 +1,4 @@ +export * from "../contracts"; export * from "./alfredpay"; export * from "./brla"; export * from "./evm"; diff --git a/packages/shared/src/services/nabla/transactions/index.ts b/packages/shared/src/services/nabla/transactions/index.ts index 0221c0128..8767899e3 100644 --- a/packages/shared/src/services/nabla/transactions/index.ts +++ b/packages/shared/src/services/nabla/transactions/index.ts @@ -1,5 +1,16 @@ import { CreateExecuteMessageExtrinsicOptions } from "@pendulum-chain/api-solang"; -import { AccountMeta, ApiManager, encodeSubmittableExtrinsic, PendulumTokenDetails } from "../../../index"; +import { encodeFunctionData } from "viem/utils"; +import { routerAbi } from "../../../contracts/Router"; +import { + AccountMeta, + ApiManager, + EvmClientManager, + EvmTransactionData, + encodeSubmittableExtrinsic, + Networks, + PendulumTokenDetails +} from "../../../index"; +import { NABLA_ROUTER_BASE } from "../../../tokens/constants/misc"; import { prepareNablaApproveTransaction } from "./approve"; import { prepareNablaSwapTransaction } from "./swap"; @@ -50,6 +61,100 @@ export async function createNablaTransactionsForOfframp( }; } +export async function createNablaTransactionsForOnrampOnEVM( + amountRaw: string, + ephemeral: AccountMeta, + inputTokenAddress: `0x${string}`, + outputTokenAddress: `0x${string}`, + nablaHardMinimumOutputRaw: string +) { + if (ephemeral.type !== "EVM") { + throw new Error(`Can't create Nabla EVM transactions for ${ephemeral.type}`); + } + + const evmClientManager = EvmClientManager.getInstance(); + const baseClient = evmClientManager.getClient(Networks.Base); + + const ephemeralAddress = ephemeral.address; + + // Create approve transaction for the input token + const approveCallData = encodeFunctionData({ + abi: [ + { + inputs: [ + { name: "spender", type: "address" }, + { name: "amount", type: "uint256" } + ], + name: "approve", + outputs: [{ type: "bool" }], + stateMutability: "nonpayable", + type: "function" + } + ], + args: [NABLA_ROUTER_BASE, BigInt(amountRaw)], + functionName: "approve" + }); + + const { maxFeePerGas: approveMaxFee, maxPriorityFeePerGas: approveMaxPriority } = await baseClient.estimateFeesPerGas(); + + const approveTransaction: EvmTransactionData = { + data: approveCallData as `0x${string}`, + gas: "100000", + maxFeePerGas: approveMaxFee.toString(), + maxPriorityFeePerGas: approveMaxPriority.toString(), + to: inputTokenAddress, + value: "0" + }; + + // Create swap transaction + const deadline = Math.floor(Date.now() / 1000) + 3600; // 1 hour from now + + // Standard ABI for the swap function + const swapAbi = [ + { + inputs: [ + { name: "_amountIn", type: "uint256" }, + { name: "_amountOutMin", type: "uint256" }, + { name: "_tokenInOut", type: "address[]" }, + { name: "_to", type: "address" }, + { name: "_deadline", type: "uint256" } + ], + name: "swapExactTokensForTokens", + outputs: [{ type: "uint256[]" }], + stateMutability: "nonpayable", + type: "function" + } + ]; + + const swapCallData = encodeFunctionData({ + abi: swapAbi, + args: [ + BigInt(amountRaw), + BigInt(nablaHardMinimumOutputRaw), + [inputTokenAddress, outputTokenAddress], + ephemeralAddress, + BigInt(deadline) + ], + functionName: "swapExactTokensForTokens" + }); + + const { maxFeePerGas: swapMaxFee, maxPriorityFeePerGas: swapMaxPriority } = await baseClient.estimateFeesPerGas(); + + const swapTransaction: EvmTransactionData = { + data: swapCallData as `0x${string}`, + gas: "500000", // Higher gas limit for swap + maxFeePerGas: swapMaxFee.toString(), + maxPriorityFeePerGas: swapMaxPriority.toString(), + to: NABLA_ROUTER_BASE, + value: "0" + }; + + return { + approve: approveTransaction, + swap: swapTransaction + }; +} + export async function createNablaTransactionsForOnramp( amountRaw: string, ephemeral: AccountMeta, diff --git a/packages/shared/src/tokens/constants/misc.ts b/packages/shared/src/tokens/constants/misc.ts index 9d4f1e81c..0553a4493 100644 --- a/packages/shared/src/tokens/constants/misc.ts +++ b/packages/shared/src/tokens/constants/misc.ts @@ -9,6 +9,7 @@ export const ASSETHUB_WSS = "wss://dot-rpc.stakeworld.io/assethub"; export const MOONBEAM_WSS = "wss://wss.api.moonbeam.network"; export const WALLETCONNECT_ASSETHUB_ID = "polkadot:68d56f15f85d3136970ec16946040bc1"; export const NABLA_ROUTER = "6gAVVw13mQgzzKk4yEwScMmWiCNyMAunXFJUZonbgKrym81N"; // AssetHub USDC instance +export const NABLA_ROUTER_BASE: `0x${string}` = "0x58E5Cb2dA15f01CB8FAefef202aa25238efCBdcf"; export const SPACEWALK_REDEEM_SAFETY_MARGIN = 0.05; export const AMM_MINIMUM_OUTPUT_SOFT_MARGIN = 0.02; diff --git a/packages/shared/src/tokens/evm/config.ts b/packages/shared/src/tokens/evm/config.ts index d45aec70b..ef5ee138d 100644 --- a/packages/shared/src/tokens/evm/config.ts +++ b/packages/shared/src/tokens/evm/config.ts @@ -207,6 +207,15 @@ export const evmTokenConfig: Record