From 4e1049fa1667a4f20d30ee25dca739e5e4c37c38 Mon Sep 17 00:00:00 2001 From: Gianfranco Date: Mon, 11 May 2026 16:13:03 -0300 Subject: [PATCH 1/6] patch onramp mint phase --- .../services/phases/handlers/brla-onramp-mint-handler.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/api/src/api/services/phases/handlers/brla-onramp-mint-handler.ts b/apps/api/src/api/services/phases/handlers/brla-onramp-mint-handler.ts index 493e5e8da..9dc8f8247 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 @@ -86,11 +86,11 @@ export class BrlaOnrampMintHandler extends BasePhaseHandler { const brlaApiService = BrlaApiService.getInstance(); try { logger.info( - `BrlaOnrampMintHandler: Waiting for Avenia balance to have at least ${quote.metadata.aveniaMint.outputAmountDecimal} BRL` + `BrlaOnrampMintHandler: Waiting for Avenia balance to have at least ${quote.metadata.aveniaTransfer.outputAmountDecimal} BRL` ); await waitUntilTrueWithTimeout( async () => { - if (!quote.metadata.aveniaMint) { + if (!quote.metadata.aveniaTransfer) { return false; } @@ -99,7 +99,7 @@ export class BrlaOnrampMintHandler extends BasePhaseHandler { if (!balances || balances.BRLA === undefined || balances.BRLA === null) { return false; } - return Number(balances.BRLA) >= Number(Big(quote.metadata.aveniaMint.outputAmountDecimal).toFixed(2, 0)); + return Number(balances.BRLA) >= Number(Big(quote.metadata.aveniaTransfer.outputAmountDecimal).toFixed(2, 0)); }, 5000, PAYMENT_TIMEOUT_MS From 94652dc2850fd4063376d55369fc882478868db1 Mon Sep 17 00:00:00 2001 From: Gianfranco Date: Mon, 11 May 2026 16:20:54 -0300 Subject: [PATCH 2/6] patch brla onramp mint handler 2 --- .../services/phases/handlers/brla-onramp-mint-handler.ts | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/apps/api/src/api/services/phases/handlers/brla-onramp-mint-handler.ts b/apps/api/src/api/services/phases/handlers/brla-onramp-mint-handler.ts index 9dc8f8247..84779dce4 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 @@ -49,10 +49,6 @@ export class BrlaOnrampMintHandler extends BasePhaseHandler { throw new Error("Quote not found for the given state"); } - if (!quote.metadata.aveniaMint) { - throw new Error("Missing 'aveniaMint' in quote metadata"); - } - if (!quote.metadata.aveniaTransfer) { throw new Error("Missing 'aveniaTransfer' in quote metadata"); } @@ -121,7 +117,7 @@ export class BrlaOnrampMintHandler extends BasePhaseHandler { // Transfer the funds from the subaccount to the ephemeral address const aveniaQuote = await brlaApiService.createPayInQuote({ blockchainSendMethod: BlockchainSendMethod.PERMIT, - inputAmount: Big(quote.metadata.aveniaMint.outputAmountDecimal).toFixed(2, 0), + inputAmount: Big(quote.metadata.aveniaTransfer.outputAmountDecimal).toFixed(2, 0), inputCurrency: BrlaCurrency.BRLA, inputPaymentMethod: AveniaPaymentMethod.INTERNAL, inputThirdParty: false, From f8b935a3bf99453929089107e96644f5e3e9be7d Mon Sep 17 00:00:00 2001 From: Gianfranco Date: Mon, 11 May 2026 17:03:38 -0300 Subject: [PATCH 3/6] patch 3 brla onramp mint phase --- .../phases/handlers/brla-onramp-mint-handler.ts | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/apps/api/src/api/services/phases/handlers/brla-onramp-mint-handler.ts b/apps/api/src/api/services/phases/handlers/brla-onramp-mint-handler.ts index 84779dce4..d6f405a44 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 @@ -49,6 +49,10 @@ export class BrlaOnrampMintHandler extends BasePhaseHandler { throw new Error("Quote not found for the given state"); } + if (!quote.metadata.aveniaMint) { + throw new Error("Missing 'aveniaMint' in quote metadata"); + } + if (!quote.metadata.aveniaTransfer) { throw new Error("Missing 'aveniaTransfer' in quote metadata"); } @@ -82,11 +86,11 @@ export class BrlaOnrampMintHandler extends BasePhaseHandler { const brlaApiService = BrlaApiService.getInstance(); try { logger.info( - `BrlaOnrampMintHandler: Waiting for Avenia balance to have at least ${quote.metadata.aveniaTransfer.outputAmountDecimal} BRL` + `BrlaOnrampMintHandler: Waiting for Avenia balance to have at least ${quote.metadata.aveniaMint.outputAmountDecimal} BRL` ); await waitUntilTrueWithTimeout( async () => { - if (!quote.metadata.aveniaTransfer) { + if (!quote.metadata.aveniaMint) { return false; } @@ -95,7 +99,7 @@ export class BrlaOnrampMintHandler extends BasePhaseHandler { if (!balances || balances.BRLA === undefined || balances.BRLA === null) { return false; } - return Number(balances.BRLA) >= Number(Big(quote.metadata.aveniaTransfer.outputAmountDecimal).toFixed(2, 0)); + return Number(balances.BRLA) >= Number(Big(quote.metadata.aveniaMint.outputAmountDecimal).toFixed(2, 0)); }, 5000, PAYMENT_TIMEOUT_MS @@ -117,7 +121,7 @@ export class BrlaOnrampMintHandler extends BasePhaseHandler { // Transfer the funds from the subaccount to the ephemeral address const aveniaQuote = await brlaApiService.createPayInQuote({ blockchainSendMethod: BlockchainSendMethod.PERMIT, - inputAmount: Big(quote.metadata.aveniaTransfer.outputAmountDecimal).toFixed(2, 0), + inputAmount: Big(quote.metadata.aveniaMint.outputAmountDecimal).toFixed(2, 0), inputCurrency: BrlaCurrency.BRLA, inputPaymentMethod: AveniaPaymentMethod.INTERNAL, inputThirdParty: false, @@ -141,7 +145,7 @@ export class BrlaOnrampMintHandler extends BasePhaseHandler { ); logger.info( - `BrlaOnrampMintHandler: Created Avenia transfer ticket with id ${aveniaTicket.id} to transfer ${quote.metadata.aveniaTransfer.outputAmountDecimal} BRLA to Base address ${state.state.evmEphemeralAddress}` + `BrlaOnrampMintHandler: Created Avenia transfer ticket with id ${aveniaTicket.id} to transfer ${quote.metadata.aveniaMint.outputAmountDecimal} BRLA to Base address ${state.state.evmEphemeralAddress}` ); try { From 49f1d077245cff19eb82b21d745b32a0def5a7bc Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Tue, 12 May 2026 09:50:24 +0200 Subject: [PATCH 4/6] fix: derive expectedAmountReceived from live aveniaQuote output The stale `aveniaTransfer.outputAmountRaw` stored at quote-creation time did not match the actual amount dispatched by the runtime `createPayInQuote` call because fees can differ at execution time. This caused `checkEvmBalancePeriodically` to wait for an amount that was never sent, leaving the phase permanently stuck retrying. Fix: compute `expectedAmountReceived` from `aveniaQuote.outputAmount` (the live Avenia response) after `createPayInQuote` resolves, converting the decimal string to a raw integer using the token's on-chain decimals. The recovery shortcut retains the pre-computed metadata value as a best-effort upper-bound check. --- .../handlers/brla-onramp-mint-handler.ts | 26 +++++++++++++++---- 1 file changed, 21 insertions(+), 5 deletions(-) diff --git a/apps/api/src/api/services/phases/handlers/brla-onramp-mint-handler.ts b/apps/api/src/api/services/phases/handlers/brla-onramp-mint-handler.ts index d6f405a44..038da4eab 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 @@ -10,6 +10,7 @@ import { EvmToken, evmTokenConfig, getEvmTokenBalance, + multiplyByPowerOfTen, Networks, RampPhase, waitUntilTrueWithTimeout @@ -70,15 +71,18 @@ export class BrlaOnrampMintHandler extends BasePhaseHandler { throw new Error("BRLA token details not found for Base network"); } - const expectedAmountReceived = quote.metadata.aveniaTransfer.outputAmountRaw; + // Used only for the recovery shortcut below: the pre-computed metadata value is a + // reasonable upper-bound estimate of what should arrive at the ephemeral. The actual + // amount is determined by the live Avenia quote created later in this phase. + const preComputedExpectedAmountRaw = quote.metadata.aveniaTransfer.outputAmountRaw; // Recovery shortcut: a previous run may have already minted on Avenia and // transferred to the ephemeral. If the ephemeral already holds the expected // amount, skip the Avenia balance wait and the (idempotent-but-wasteful) // pay-out ticket creation. - if (await this.ephemeralAlreadyFunded(tokenDetails.erc20AddressSourceChain, evmEphemeralAddress, expectedAmountReceived)) { + if (await this.ephemeralAlreadyFunded(tokenDetails.erc20AddressSourceChain, evmEphemeralAddress, preComputedExpectedAmountRaw)) { logger.info( - `BrlaOnrampMintHandler: Ephemeral ${evmEphemeralAddress} already holds the expected ${expectedAmountReceived} BRLA. Skipping mint flow.` + `BrlaOnrampMintHandler: Ephemeral ${evmEphemeralAddress} already holds the expected ${preComputedExpectedAmountRaw} BRLA. Skipping mint flow.` ); return this.transitionToNextPhase(state, "fundEphemeral"); } @@ -133,6 +137,18 @@ export class BrlaOnrampMintHandler extends BasePhaseHandler { logger.info("BrlaOnrampMintHandler: Created Avenia pay-out quote for mint transfer."); + // Derive the expected on-chain amount from the live quote's outputAmount rather than + // the stale pre-computed metadata value. The live quote accounts for the actual fees + // applied at execution time, so this is the amount that will truly arrive on Base. + const expectedAmountReceived = multiplyByPowerOfTen( + new Big(aveniaQuote.outputAmount), + tokenDetails.decimals + ).toFixed(0, 0); + + logger.info( + `BrlaOnrampMintHandler: Live Avenia quote output is ${aveniaQuote.outputAmount} BRLA (raw: ${expectedAmountReceived}). Pre-computed metadata value was ${preComputedExpectedAmountRaw}.` + ); + const aveniaTicket = await brlaApiService.createPixOutputTicket( { quoteToken: aveniaQuote.quoteToken, @@ -145,7 +161,7 @@ export class BrlaOnrampMintHandler extends BasePhaseHandler { ); logger.info( - `BrlaOnrampMintHandler: Created Avenia transfer ticket with id ${aveniaTicket.id} to transfer ${quote.metadata.aveniaMint.outputAmountDecimal} BRLA to Base address ${state.state.evmEphemeralAddress}` + `BrlaOnrampMintHandler: Created Avenia transfer ticket with id ${aveniaTicket.id} to transfer ${aveniaQuote.outputAmount} BRLA to Base address ${state.state.evmEphemeralAddress}` ); try { @@ -201,7 +217,7 @@ export class BrlaOnrampMintHandler extends BasePhaseHandler { protected isPaymentTimeoutReached(state: RampState): boolean { const thisPhaseEntry = state.phaseHistory.find(phaseHistoryEntry => phaseHistoryEntry.phase === this.getPhaseName()); if (!thisPhaseEntry) { - throw new Error("BrlaOnrampMintHandler: Phase not found in history. State corrupted."); + throw new Error("BrlaOnrampMintHandler: Phase not found in history. This is a bug."); } const initialTimestamp = new Date(thisPhaseEntry.timestamp); From 03e1130c5fe66c4035dde8a1301a357fc1648a8f Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Tue, 12 May 2026 09:56:46 +0200 Subject: [PATCH 5/6] fix: loosen recovery shortcut to accept 95% of pre-computed expected amount The pre-computed `aveniaTransfer.outputAmountRaw` stored at quote-creation time is often slightly higher than what Avenia actually transfers at execution time due to fee differences. This caused the recovery shortcut to miss an already-funded ephemeral and unnecessarily re-run the full mint flow. Allow up to a 5% tolerance: if the ephemeral holds at least 95% of the pre-computed expected amount we treat it as funded and skip to the next phase. --- .../handlers/brla-onramp-mint-handler.ts | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/apps/api/src/api/services/phases/handlers/brla-onramp-mint-handler.ts b/apps/api/src/api/services/phases/handlers/brla-onramp-mint-handler.ts index 038da4eab..016ae5846 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 @@ -31,6 +31,11 @@ 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 +// The pre-computed expected amount stored at quote-creation time can be slightly higher than the +// amount actually transferred due to fee differences at execution time. We allow a 5% tolerance +// in the recovery shortcut so that an already-funded ephemeral is not missed. +const EPHEMERAL_FUNDED_TOLERANCE_FACTOR = 0.95; + // 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 { @@ -77,12 +82,16 @@ export class BrlaOnrampMintHandler extends BasePhaseHandler { const preComputedExpectedAmountRaw = quote.metadata.aveniaTransfer.outputAmountRaw; // Recovery shortcut: a previous run may have already minted on Avenia and - // transferred to the ephemeral. If the ephemeral already holds the expected - // amount, skip the Avenia balance wait and the (idempotent-but-wasteful) - // pay-out ticket creation. - if (await this.ephemeralAlreadyFunded(tokenDetails.erc20AddressSourceChain, evmEphemeralAddress, preComputedExpectedAmountRaw)) { + // transferred to the ephemeral. We accept a balance of at least 95% of the + // pre-computed expected amount to account for fee differences between quote + // creation time and execution time. + const recoveryThresholdRaw = new Big(preComputedExpectedAmountRaw) + .times(EPHEMERAL_FUNDED_TOLERANCE_FACTOR) + .toFixed(0, 0); + + if (await this.ephemeralAlreadyFunded(tokenDetails.erc20AddressSourceChain, evmEphemeralAddress, recoveryThresholdRaw)) { logger.info( - `BrlaOnrampMintHandler: Ephemeral ${evmEphemeralAddress} already holds the expected ${preComputedExpectedAmountRaw} BRLA. Skipping mint flow.` + `BrlaOnrampMintHandler: Ephemeral ${evmEphemeralAddress} already holds at least 95% of the expected ${preComputedExpectedAmountRaw} BRLA (threshold: ${recoveryThresholdRaw}). Skipping mint flow.` ); return this.transitionToNextPhase(state, "fundEphemeral"); } From 5d98ce9bce5cb07c761f504baf97a57f68cac742 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Tue, 12 May 2026 10:47:29 +0200 Subject: [PATCH 6/6] Implement subsidy cap for pre-swap EVM phase to limit excess funding --- .../phases/handlers/fund-ephemeral-handler.ts | 2 +- .../subsidize-pre-swap-evm-handler.ts | 22 +++++++++++++++++++ 2 files changed, 23 insertions(+), 1 deletion(-) 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 7a456ad8b..37b9954b9 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 @@ -219,7 +219,7 @@ export class FundEphemeralPhaseHandler extends BasePhaseHandler { protected nextPhaseSelector(state: RampState, quote: QuoteTicket): RampPhase { // brla onramp case if (isOnramp(state) && quote.inputCurrency === FiatToken.BRL) { - return "nablaApprove"; + return "subsidizePreSwapEvm"; } // alfredpay onramp case if (isOnramp(state) && isAlfredpayToken(quote.inputCurrency as FiatToken)) { diff --git a/apps/api/src/api/services/phases/handlers/subsidize-pre-swap-evm-handler.ts b/apps/api/src/api/services/phases/handlers/subsidize-pre-swap-evm-handler.ts index e3f51e915..ce04a3189 100644 --- a/apps/api/src/api/services/phases/handlers/subsidize-pre-swap-evm-handler.ts +++ b/apps/api/src/api/services/phases/handlers/subsidize-pre-swap-evm-handler.ts @@ -7,6 +7,7 @@ import { getOnChainTokenDetails, Networks, nativeToDecimal, + RampCurrency, RampPhase } from "@vortexfi/shared"; import Big from "big.js"; @@ -17,9 +18,12 @@ 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 { priceFeedService } from "../../priceFeed.service"; import { BasePhaseHandler } from "../base-phase-handler"; import { StateMetadata } from "../meta-state-types"; +const MAX_EVM_SWAP_SUBSIDY_QUOTE_FRACTION = "0.05"; // 5% of quote.outputAmount in USD + export class SubsidizePreSwapEvmPhaseHandler extends BasePhaseHandler { public getPhaseName(): RampPhase { return "subsidizePreSwapEvm"; @@ -72,6 +76,24 @@ export class SubsidizePreSwapEvmPhaseHandler extends BasePhaseHandler { logger.debug(`SubsidizePreSwapEvmHandler: requiredAmount ${requiredAmount.toString()}`); if (requiredAmount.gt(Big(0))) { + const subsidyDecimal = nativeToDecimal(requiredAmount, quote.metadata.nablaSwapEvm.inputDecimals).toString(); + const subsidyUsd = await priceFeedService.convertCurrency( + subsidyDecimal, + inputToken as RampCurrency, + EvmToken.USDC as RampCurrency + ); + const quoteOutputUsd = await priceFeedService.convertCurrency( + quote.outputAmount, + quote.outputCurrency as RampCurrency, + EvmToken.USDC as RampCurrency + ); + const subsidyCapUsd = Big(quoteOutputUsd).mul(MAX_EVM_SWAP_SUBSIDY_QUOTE_FRACTION); + if (Big(subsidyUsd).gt(subsidyCapUsd)) { + throw this.createUnrecoverableError( + `SubsidizePreSwapEvmPhaseHandler: Required subsidy $${subsidyUsd} exceeds cap $${subsidyCapUsd.toFixed(2)} (${MAX_EVM_SWAP_SUBSIDY_QUOTE_FRACTION} of quote output $${quoteOutputUsd}).` + ); + } + // Do the actual subsidizing on EVM logger.info( `Subsidizing pre-swap EVM with ${requiredAmount.toFixed()} to reach target value of ${expectedInputAmountForSwapRaw}`