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/* 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..53d151506 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,52 @@ 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 + // Calculate the oracle-based expected output in 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); + + // 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); + + 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 +91,7 @@ export class OffRampDiscountEngine extends BaseDiscountEngine { actualOutputAmountRaw, adjustedDifference, adjustedTargetDiscount, - expectedOutputAmountDecimal, + expectedOutputAmountDecimal: adjustedExpectedOutputDecimal, expectedOutputAmountRaw, idealSubsidyAmountInOutputTokenDecimal: idealSubsidyAmountDecimal, idealSubsidyAmountInOutputTokenRaw: idealSubsidyAmountRaw, 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..cf851b175 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,55 @@ 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 + // Calculate the oracle-based expected output in USDT-equivalent axlUSDC terms. 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 + // 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 + 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 +161,7 @@ export class OnRampDiscountEngine extends BaseDiscountEngine { actualOutputAmountRaw, adjustedDifference, adjustedTargetDiscount, - expectedOutputAmountDecimal, + expectedOutputAmountDecimal: adjustedExpectedOutputDecimal, expectedOutputAmountRaw, idealSubsidyAmountInOutputTokenDecimal: idealSubsidyAmountDecimal, idealSubsidyAmountInOutputTokenRaw: idealSubsidyAmountRaw,