From 7b99fd1b8b584c60279dd6fb40634394b0480772 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Wed, 11 Mar 2026 17:03:21 +0000 Subject: [PATCH 1/4] Implement subsidy adjustment for axlUSDC conversion rate in onramp --- .../services/quote/engines/discount/onramp.ts | 117 ++++++++++++++++-- 1 file changed, 105 insertions(+), 12 deletions(-) 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 dd8cf1ebc..db96dfe37 100644 --- a/apps/api/src/api/services/quote/engines/discount/onramp.ts +++ b/apps/api/src/api/services/quote/engines/discount/onramp.ts @@ -1,5 +1,14 @@ -import { multiplyByPowerOfTen, RampDirection } from "@vortexfi/shared"; +import { + EvmToken, + getNetworkFromDestination, + multiplyByPowerOfTen, + Networks, + OnChainToken, + RampDirection +} from "@vortexfi/shared"; import Big from "big.js"; +import logger from "../../../../../config/logger"; +import { getEvmBridgeQuote } from "../../core/squidrouter"; import { QuoteContext } from "../../core/types"; import { BaseDiscountEngine, DiscountComputation } from "."; import { calculateExpectedOutput, calculateSubsidyAmount, resolveDiscountPartner } from "./helpers"; @@ -29,6 +38,59 @@ export class OnRampDiscountEngine extends BaseDiscountEngine { } } + /** + * Queries squidrouter to determine the actual conversion rate from axlUSDC on Moonbeam + * 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 Pendulum + * outputs axlUSDC (not USDT). Since axlUSDC may trade at a discount to USDT via + * squidrouter, using the oracle USDT rate as the axlUSDC subsidy target means the user + * would receive slightly less than the oracle-promised amount after the squidrouter step. + * + * This method fetches the actual axlUSDC → destination token rate so the discount engine + * can back-calculate the precise axlUSDC amount required on Pendulum. + * + * @param ctx - The quote context (must have request.outputCurrency and request.to set) + * @param expectedAxlUSDCDecimal - The oracle-based expected axlUSDC amount used as probe input + * @returns The conversion rate (destination token units per axlUSDC) or null on failure + */ + private async getSquidRouterAxlUSDCConversionRate(ctx: QuoteContext, expectedAxlUSDCDecimal: Big): Promise { + const req = ctx.request; + const toNetwork = getNetworkFromDestination(req.to); + + if (!toNetwork) { + return null; + } + + try { + const bridgeQuote = await getEvmBridgeQuote({ + amountDecimal: expectedAxlUSDCDecimal.toString(), + fromNetwork: Networks.Moonbeam, + inputCurrency: EvmToken.AXLUSDC as unknown as OnChainToken, + outputCurrency: req.outputCurrency as OnChainToken, + rampType: req.rampType, + toNetwork + }); + + if (expectedAxlUSDCDecimal.lte(0) || bridgeQuote.outputAmountDecimal.lte(0)) { + return null; + } + + const conversionRate = bridgeQuote.outputAmountDecimal.div(expectedAxlUSDCDecimal); + logger.info( + `OnRampDiscountEngine: SquidRouter axlUSDC→${req.outputCurrency} rate: ${conversionRate.toFixed(6)} ` + + `(input: ${expectedAxlUSDCDecimal.toFixed(6)} axlUSDC, output: ${bridgeQuote.outputAmountDecimal.toFixed(6)} ${req.outputCurrency})` + ); + return conversionRate; + } catch (error) { + logger.warn( + `OnRampDiscountEngine: Could not fetch SquidRouter axlUSDC→${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!; @@ -43,35 +105,66 @@ export class OnRampDiscountEngine extends BaseDiscountEngine { const targetDiscount = partner?.targetDiscount ?? 0; const maxSubsidy = partner?.maxSubsidy ?? 0; - // Calculate expected output amount based on oracle price + target discount + // Step 1: Calculate the oracle-based expected output in USDT-equivalent axlUSDC terms. + // For onramps (isOfframp=false): expectedOutput = inputAmount * oraclePrice * (1 + discount) + // The oracle is the Binance USDT-BRL rate expressed as FIAT-USD (e.g., 0.175 USD per 1 BRL). const { - expectedOutput: expectedOutputAmountDecimal, + expectedOutput: oracleExpectedOutputDecimal, adjustedDifference, adjustedTargetDiscount } = calculateExpectedOutput(inputAmount, oraclePrice, targetDiscount, this.config.isOfframp, partner); - const expectedOutputAmountRaw = multiplyByPowerOfTen(expectedOutputAmountDecimal, nablaSwap.outputDecimals).toFixed(0, 0); - // For onramps, we have to deduct the fees from the output amount of the nabla swap + // Step 2: 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. + // + // Without this adjustment the subsidy would ensure the user has exactly + // `oracleExpectedOutputDecimal` axlUSDC on Pendulum. After squidrouter converts + // axlUSDC → destination token at rate r < 1, the user would receive + // `oracleExpectedOutputDecimal * r` instead of the oracle-promised + // `oracleExpectedOutputDecimal` — a systematic short-pay. + // + // Fix: use the actual squidrouter route to determine the required axlUSDC amount: + // required_axlUSDC = oracle_promised_dest_amount / squidrouter_rate + // After squidrouter: required_axlUSDC * squidrouter_rate = oracle_promised_dest_amount ✓ + let adjustedExpectedOutputDecimal = oracleExpectedOutputDecimal; + if (ctx.request.to !== "assethub") { + const squidRouterRate = await this.getSquidRouterAxlUSDCConversionRate(ctx, oracleExpectedOutputDecimal); + + if (squidRouterRate !== null && squidRouterRate.gt(0)) { + adjustedExpectedOutputDecimal = oracleExpectedOutputDecimal.div(squidRouterRate); + ctx.addNote?.( + `OnRampDiscountEngine: Adjusted expected axlUSDC from ${oracleExpectedOutputDecimal.toFixed(6)} ` + + `to ${adjustedExpectedOutputDecimal.toFixed(6)} (squidRouter rate: ${squidRouterRate.toFixed(6)})` + ); + } + } + + const expectedOutputAmountRaw = multiplyByPowerOfTen(adjustedExpectedOutputDecimal, nablaSwap.outputDecimals).toFixed(0, 0); + + // For onramps, fees are deducted from the nabla output (not before the swap) const deductedFeesAfterSwap = Big(usdFees.network).plus(usdFees.vortex).plus(usdFees.partnerMarkup); const actualOutputAmountDecimal = nablaSwap.outputAmountDecimal.minus(deductedFeesAfterSwap); const actualOutputAmountRaw = multiplyByPowerOfTen(actualOutputAmountDecimal, nablaSwap.outputDecimals).toFixed(0, 0); - // Calculate ideal subsidy (uncapped - the full shortfall needed to reach expected output) - const idealSubsidyAmountDecimal = actualOutputAmountDecimal.gte(expectedOutputAmountDecimal) + // Calculate ideal subsidy (uncapped - the full shortfall needed to reach adjusted expected output) + const idealSubsidyAmountDecimal = actualOutputAmountDecimal.gte(adjustedExpectedOutputDecimal) ? new Big(0) - : expectedOutputAmountDecimal.minus(actualOutputAmountDecimal); + : adjustedExpectedOutputDecimal.minus(actualOutputAmountDecimal); const idealSubsidyAmountRaw = multiplyByPowerOfTen(idealSubsidyAmountDecimal, nablaSwap.outputDecimals).toFixed(0, 0); // Calculate actual subsidy (capped by maxSubsidy) const actualSubsidyAmountDecimal = - targetDiscount > 0 ? calculateSubsidyAmount(expectedOutputAmountDecimal, actualOutputAmountDecimal, maxSubsidy) : Big(0); + targetDiscount > 0 + ? calculateSubsidyAmount(adjustedExpectedOutputDecimal, actualOutputAmountDecimal, maxSubsidy) + : Big(0); const actualSubsidyAmountRaw = multiplyByPowerOfTen(actualSubsidyAmountDecimal, nablaSwap.outputDecimals).toFixed(0, 0); const targetOutputAmountDecimal = actualOutputAmountDecimal.plus(actualSubsidyAmountDecimal); const targetOutputAmountRaw = Big(actualOutputAmountRaw).plus(actualSubsidyAmountRaw).toFixed(0, 0); - const subsidyRate = expectedOutputAmountDecimal.gt(0) - ? actualSubsidyAmountDecimal.div(expectedOutputAmountDecimal) + const subsidyRate = adjustedExpectedOutputDecimal.gt(0) + ? actualSubsidyAmountDecimal.div(adjustedExpectedOutputDecimal) : new Big(0); return { @@ -79,7 +172,7 @@ export class OnRampDiscountEngine extends BaseDiscountEngine { actualOutputAmountRaw, adjustedDifference, adjustedTargetDiscount, - expectedOutputAmountDecimal, + expectedOutputAmountDecimal: adjustedExpectedOutputDecimal, expectedOutputAmountRaw, idealSubsidyAmountInOutputTokenDecimal: idealSubsidyAmountDecimal, idealSubsidyAmountInOutputTokenRaw: idealSubsidyAmountRaw, From a2b9ce1246403c04e976a904465ea5e6848727a1 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Thu, 12 Mar 2026 09:18:10 +0000 Subject: [PATCH 2/4] Take anchor fee into account for offramp subsidy --- .../quote/engines/discount/offramp.ts | 57 +++++++++++++++---- 1 file changed, 47 insertions(+), 10 deletions(-) diff --git a/apps/api/src/api/services/quote/engines/discount/offramp.ts b/apps/api/src/api/services/quote/engines/discount/offramp.ts index 420673a6d..e62bd9184 100644 --- a/apps/api/src/api/services/quote/engines/discount/offramp.ts +++ b/apps/api/src/api/services/quote/engines/discount/offramp.ts @@ -1,5 +1,6 @@ import { multiplyByPowerOfTen, RampDirection } from "@vortexfi/shared"; import Big from "big.js"; +import logger from "../../../../../config/logger"; import { QuoteContext } from "../../core/types"; import { BaseDiscountEngine, DiscountComputation } from "."; import { calculateExpectedOutput, calculateSubsidyAmount, resolveDiscountPartner } from "./helpers"; @@ -37,33 +38,69 @@ export class OffRampDiscountEngine extends BaseDiscountEngine { const targetDiscount = partner?.targetDiscount ?? 0; const maxSubsidy = partner?.maxSubsidy ?? 0; - // Calculate expected output amount based on oracle price + target discount + // Step 1: Calculate the oracle-based expected output in BRL. + // For offramps (isOfframp=true): expectedOutput = inputAmount * (1/oraclePrice) * (1 + discount) + // The oracle is the Binance USDT-BRL rate expressed as FIAT-USD (e.g., 0.175 USD per 1 BRL), + // inverted to give BRL per USD (e.g., 5.7 BRL per USDC input). const { - expectedOutput: expectedOutputAmountDecimal, + expectedOutput: oracleExpectedOutputDecimal, adjustedDifference, adjustedTargetDiscount } = calculateExpectedOutput(inputAmount, oraclePrice, targetDiscount, this.config.isOfframp, partner); - const expectedOutputAmountRaw = multiplyByPowerOfTen(expectedOutputAmountDecimal, nablaSwap.outputDecimals).toFixed(0, 0); + + // Step 2: Account for the anchor fee deducted in the Finalize stage. + // + // The Finalize stage computes the BRL the user receives as: + // final_BRL = pendulumToMoonbeamXcm.outputAmountDecimal - anchorFee + // + // The PendulumTransfer stage sets pendulumToMoonbeamXcm.outputAmountDecimal to: + // nablaOutput + subsidyAmount + // + // Without this adjustment the subsidy targets `oracleExpectedOutputDecimal` BRLA + // on Pendulum, but after the anchor fee deduction the user receives + // `oracleExpectedOutputDecimal - anchorFee` BRL — systematically less than the + // oracle-promised rate. + // + // Fix: add the anchor fee on top of the oracle-promised BRL so that: + // pendulumToMoonbeamXcm = oracle_promised + anchorFee + // final_BRL = (oracle_promised + anchorFee) - anchorFee = oracle_promised ✓ + const anchorFeeInBrl = ctx.fees?.displayFiat?.anchor ? new Big(ctx.fees.displayFiat.anchor) : new Big(0); + const adjustedExpectedOutputDecimal = oracleExpectedOutputDecimal.plus(anchorFeeInBrl); + + if (anchorFeeInBrl.gt(0)) { + logger.info( + `OffRampDiscountEngine: Adjusted expected BRL from ${oracleExpectedOutputDecimal.toFixed(6)} ` + + `to ${adjustedExpectedOutputDecimal.toFixed(6)} (anchor fee: ${anchorFeeInBrl.toFixed(6)} BRL)` + ); + ctx.addNote?.( + `OffRampDiscountEngine: Adjusted expected BRL output from ${oracleExpectedOutputDecimal.toFixed(4)} ` + + `to ${adjustedExpectedOutputDecimal.toFixed(4)} BRL to account for anchor fee of ${anchorFeeInBrl.toFixed(4)} BRL` + ); + } + + const expectedOutputAmountRaw = multiplyByPowerOfTen(adjustedExpectedOutputDecimal, nablaSwap.outputDecimals).toFixed(0, 0); const actualOutputAmountDecimal = nablaSwap.outputAmountDecimal; const actualOutputAmountRaw = multiplyByPowerOfTen(actualOutputAmountDecimal, nablaSwap.outputDecimals).toFixed(0, 0); - // Calculate ideal subsidy (uncapped - the full shortfall needed to reach expected output) - const idealSubsidyAmountDecimal = actualOutputAmountDecimal.gte(expectedOutputAmountDecimal) + // Calculate ideal subsidy (uncapped - the full shortfall needed to reach adjusted expected output) + const idealSubsidyAmountDecimal = actualOutputAmountDecimal.gte(adjustedExpectedOutputDecimal) ? new Big(0) - : expectedOutputAmountDecimal.minus(actualOutputAmountDecimal); + : adjustedExpectedOutputDecimal.minus(actualOutputAmountDecimal); const idealSubsidyAmountRaw = multiplyByPowerOfTen(idealSubsidyAmountDecimal, nablaSwap.outputDecimals).toFixed(0, 0); // Calculate actual subsidy (capped by maxSubsidy) const actualSubsidyAmountDecimal = - targetDiscount > 0 ? calculateSubsidyAmount(expectedOutputAmountDecimal, actualOutputAmountDecimal, maxSubsidy) : Big(0); + targetDiscount > 0 + ? calculateSubsidyAmount(adjustedExpectedOutputDecimal, actualOutputAmountDecimal, maxSubsidy) + : Big(0); const actualSubsidyAmountRaw = multiplyByPowerOfTen(actualSubsidyAmountDecimal, nablaSwap.outputDecimals).toFixed(0, 0); const targetOutputAmountDecimal = actualOutputAmountDecimal.plus(actualSubsidyAmountDecimal); const targetOutputAmountRaw = Big(actualOutputAmountRaw).plus(actualSubsidyAmountRaw).toFixed(0, 0); - const subsidyRate = expectedOutputAmountDecimal.gt(0) - ? actualSubsidyAmountDecimal.div(expectedOutputAmountDecimal) + const subsidyRate = adjustedExpectedOutputDecimal.gt(0) + ? actualSubsidyAmountDecimal.div(adjustedExpectedOutputDecimal) : new Big(0); return { @@ -71,7 +108,7 @@ export class OffRampDiscountEngine extends BaseDiscountEngine { actualOutputAmountRaw, adjustedDifference, adjustedTargetDiscount, - expectedOutputAmountDecimal, + expectedOutputAmountDecimal: adjustedExpectedOutputDecimal, expectedOutputAmountRaw, idealSubsidyAmountInOutputTokenDecimal: idealSubsidyAmountDecimal, idealSubsidyAmountInOutputTokenRaw: idealSubsidyAmountRaw, From 9901e8e4c015745155c1feb749fdac9324140314 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Thu, 12 Mar 2026 09:18:18 +0000 Subject: [PATCH 3/4] Adjust .gitignore --- .gitignore | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 714c0a222..3f633518c 100644 --- a/.gitignore +++ b/.gitignore @@ -41,4 +41,7 @@ storybook-static CLAUDE.local.md -.claude \ No newline at end of file +.claude +/contracts/relayer/artifacts/* +/contracts/relayer/cache/* +/.roo/* From a31b5d05e352201ff84b1119bfbfc85ae6c7ca70 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Fri, 13 Mar 2026 18:53:20 +0000 Subject: [PATCH 4/4] Adjust comments --- .../quote/engines/discount/offramp.ts | 23 +++---------------- .../services/quote/engines/discount/onramp.ts | 17 +++----------- 2 files changed, 6 insertions(+), 34 deletions(-) diff --git a/apps/api/src/api/services/quote/engines/discount/offramp.ts b/apps/api/src/api/services/quote/engines/discount/offramp.ts index e62bd9184..53d151506 100644 --- a/apps/api/src/api/services/quote/engines/discount/offramp.ts +++ b/apps/api/src/api/services/quote/engines/discount/offramp.ts @@ -38,32 +38,15 @@ export class OffRampDiscountEngine extends BaseDiscountEngine { const targetDiscount = partner?.targetDiscount ?? 0; const maxSubsidy = partner?.maxSubsidy ?? 0; - // Step 1: Calculate the oracle-based expected output in BRL. - // For offramps (isOfframp=true): expectedOutput = inputAmount * (1/oraclePrice) * (1 + discount) - // The oracle is the Binance USDT-BRL rate expressed as FIAT-USD (e.g., 0.175 USD per 1 BRL), - // inverted to give BRL per USD (e.g., 5.7 BRL per USDC input). + // Calculate the oracle-based expected output in BRL. const { expectedOutput: oracleExpectedOutputDecimal, adjustedDifference, adjustedTargetDiscount } = calculateExpectedOutput(inputAmount, oraclePrice, targetDiscount, this.config.isOfframp, partner); - // Step 2: Account for the anchor fee deducted in the Finalize stage. - // - // The Finalize stage computes the BRL the user receives as: - // final_BRL = pendulumToMoonbeamXcm.outputAmountDecimal - anchorFee - // - // The PendulumTransfer stage sets pendulumToMoonbeamXcm.outputAmountDecimal to: - // nablaOutput + subsidyAmount - // - // Without this adjustment the subsidy targets `oracleExpectedOutputDecimal` BRLA - // on Pendulum, but after the anchor fee deduction the user receives - // `oracleExpectedOutputDecimal - anchorFee` BRL — systematically less than the - // oracle-promised rate. - // - // Fix: add the anchor fee on top of the oracle-promised BRL so that: - // pendulumToMoonbeamXcm = oracle_promised + anchorFee - // final_BRL = (oracle_promised + anchorFee) - anchorFee = oracle_promised ✓ + // Account for the anchor fee deducted in the Finalize stage, which reduces the user's received amount. + // We need to add it back to the expected output to calculate the subsidy correctly. const anchorFeeInBrl = ctx.fees?.displayFiat?.anchor ? new Big(ctx.fees.displayFiat.anchor) : new Big(0); const adjustedExpectedOutputDecimal = oracleExpectedOutputDecimal.plus(anchorFeeInBrl); 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 db96dfe37..cf851b175 100644 --- a/apps/api/src/api/services/quote/engines/discount/onramp.ts +++ b/apps/api/src/api/services/quote/engines/discount/onramp.ts @@ -105,28 +105,17 @@ export class OnRampDiscountEngine extends BaseDiscountEngine { const targetDiscount = partner?.targetDiscount ?? 0; const maxSubsidy = partner?.maxSubsidy ?? 0; - // Step 1: Calculate the oracle-based expected output in USDT-equivalent axlUSDC terms. - // For onramps (isOfframp=false): expectedOutput = inputAmount * oraclePrice * (1 + discount) - // The oracle is the Binance USDT-BRL rate expressed as FIAT-USD (e.g., 0.175 USD per 1 BRL). + // Calculate the oracle-based expected output in USDT-equivalent axlUSDC terms. const { expectedOutput: oracleExpectedOutputDecimal, adjustedDifference, adjustedTargetDiscount } = calculateExpectedOutput(inputAmount, oraclePrice, targetDiscount, this.config.isOfframp, partner); - // Step 2: For onramps to EVM chains (not AssetHub), the Nabla output token (axlUSDC on + // 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. - // - // Without this adjustment the subsidy would ensure the user has exactly - // `oracleExpectedOutputDecimal` axlUSDC on Pendulum. After squidrouter converts - // axlUSDC → destination token at rate r < 1, the user would receive - // `oracleExpectedOutputDecimal * r` instead of the oracle-promised - // `oracleExpectedOutputDecimal` — a systematic short-pay. - // - // Fix: use the actual squidrouter route to determine the required axlUSDC amount: - // required_axlUSDC = oracle_promised_dest_amount / squidrouter_rate - // After squidrouter: required_axlUSDC * squidrouter_rate = oracle_promised_dest_amount ✓ + // So we use the actual squidrouter route to determine the required axlUSDC amount let adjustedExpectedOutputDecimal = oracleExpectedOutputDecimal; if (ctx.request.to !== "assethub") { const squidRouterRate = await this.getSquidRouterAxlUSDCConversionRate(ctx, oracleExpectedOutputDecimal);