diff --git a/apps/api/src/api/services/quote/engines/discount/helpers.test.ts b/apps/api/src/api/services/quote/engines/discount/helpers.test.ts index 4ed4c4397..66c844db0 100644 --- a/apps/api/src/api/services/quote/engines/discount/helpers.test.ts +++ b/apps/api/src/api/services/quote/engines/discount/helpers.test.ts @@ -1,6 +1,8 @@ -import { describe, expect, it } from "bun:test"; +import { afterEach, describe, expect, it, spyOn } from "bun:test"; import Big from "big.js"; -import { calculateExpectedOutput, calculateSubsidyAmount } from "./helpers"; +import { priceFeedService } from "../../../priceFeed.service"; +import { QuoteContext } from "../../core/types"; +import { calculateExpectedOutput, calculateSubsidyAmount, getUsdDenominatedInputAmount } from "./helpers"; describe("calculateSubsidyAmount", () => { it("returns 0 when actual output meets expected output", () => { @@ -53,3 +55,84 @@ describe("calculateExpectedOutput with negative targetDiscount (rate floor)", () expect(expectedOutput.toString()).toBe("102"); }); }); + +describe("getUsdDenominatedInputAmount", () => { + const brlToUsdRate = new Big("0.19475713784910216959"); + const eurToUsdRate = new Big("1.16"); + let rateSpy: ReturnType | undefined; + + afterEach(() => { + rateSpy?.mockRestore(); + rateSpy = undefined; + }); + + const makeCtx = (inputCurrency: string, inputAmount: string, bridgedUsdcAmount?: string) => + ({ + evmToEvm: bridgedUsdcAmount ? { outputAmountDecimal: new Big(bridgedUsdcAmount) } : undefined, + request: { inputAmount, inputCurrency } + }) as unknown as QuoteContext; + + it("returns the request amount unchanged for USD-like inputs", async () => { + const usd = await getUsdDenominatedInputAmount(makeCtx("USDC", "1000")); + expect(usd.toString()).toBe("1000"); + }); + + it("values a BRLA input at the BRL-USD oracle rate", async () => { + rateSpy = spyOn(priceFeedService, "getFiatToUsdExchangeRate").mockResolvedValue(brlToUsdRate); + + const usd = await getUsdDenominatedInputAmount(makeCtx("BRLA", "1000")); + expect(usd.toFixed(6)).toBe("194.757138"); + }); + + it("regression: a 1000 BRLA offramp to BRL targets ~1000 BRL, not inputAmount x inverted rate", async () => { + rateSpy = spyOn(priceFeedService, "getFiatToUsdExchangeRate").mockResolvedValue(brlToUsdRate); + + // Before the fix the engine passed the raw 1000 (BRLA) into calculateExpectedOutput as if + // it were USD, yielding an expected output of ~5134 BRL and a massively inflated subsidy. + const usd = await getUsdDenominatedInputAmount(makeCtx("BRLA", "1000")); + const { expectedOutput } = calculateExpectedOutput(usd.toString(), brlToUsdRate, 0, true, null); + expect(expectedOutput.toFixed(4)).toBe("1000.0000"); + }); + + it("values a EURC input at the EUR-USD oracle rate", async () => { + rateSpy = spyOn(priceFeedService, "getFiatToUsdExchangeRate").mockResolvedValue(eurToUsdRate); + + const usd = await getUsdDenominatedInputAmount(makeCtx("EURC", "1000")); + expect(usd.toFixed(2)).toBe("1160.00"); + }); + + it("regression: a 1000 EURC offramp to EUR targets ~1000 EUR, not a zero-subsidy undershoot", async () => { + rateSpy = spyOn(priceFeedService, "getFiatToUsdExchangeRate").mockResolvedValue(eurToUsdRate); + + // Before the fix the engine passed the raw 1000 (EURC) into calculateExpectedOutput as if + // it were USD, yielding an expected output of ~862 EUR — below the actual output, so the + // EURC→SEPA subsidy was silently never paid. + const usd = await getUsdDenominatedInputAmount(makeCtx("EURC", "1000")); + const { expectedOutput } = calculateExpectedOutput(usd.toString(), eurToUsdRate, 0, true, null); + expect(expectedOutput.toFixed(4)).toBe("1000.0000"); + }); + + it("falls back to the bridged USDC amount when the fiat-peg rate lookup fails", async () => { + rateSpy = spyOn(priceFeedService, "getFiatToUsdExchangeRate").mockRejectedValue(new Error("feed down")); + + const usd = await getUsdDenominatedInputAmount(makeCtx("BRLA", "1000", "194.757138")); + expect(usd.toString()).toBe("194.757138"); + }); + + it("falls back to the raw input when the fiat-peg rate lookup fails and no bridged amount exists", async () => { + rateSpy = spyOn(priceFeedService, "getFiatToUsdExchangeRate").mockRejectedValue(new Error("feed down")); + + const usd = await getUsdDenominatedInputAmount(makeCtx("BRLA", "1000")); + expect(usd.toString()).toBe("1000"); + }); + + it("falls back to the bridged USDC amount for tokens without a fiat peg", async () => { + const usd = await getUsdDenominatedInputAmount(makeCtx("ETH", "0.5", "1834.201")); + expect(usd.toString()).toBe("1834.201"); + }); + + it("falls back to the request amount when no bridged amount is available", async () => { + const usd = await getUsdDenominatedInputAmount(makeCtx("ETH", "0.5")); + expect(usd.toString()).toBe("0.5"); + }); +}); diff --git a/apps/api/src/api/services/quote/engines/discount/helpers.ts b/apps/api/src/api/services/quote/engines/discount/helpers.ts index 7cbb7c11d..f46864a8a 100644 --- a/apps/api/src/api/services/quote/engines/discount/helpers.ts +++ b/apps/api/src/api/services/quote/engines/discount/helpers.ts @@ -1,7 +1,9 @@ -import { RampDirection } from "@vortexfi/shared"; +import { EvmToken, FiatToken, normalizeTokenSymbol, RampDirection } from "@vortexfi/shared"; import Big from "big.js"; +import logger from "../../../../../config/logger"; import { config } from "../../../../../config/vars"; import { findPartnerWithPricing, PartnerWithPricing } from "../../../partners/partner-pricing.service"; +import { priceFeedService } from "../../../priceFeed.service"; import { QuoteContext } from "../../core/types"; import { DiscountComputation } from "./index"; @@ -72,6 +74,63 @@ export async function resolveDiscountPartner(ctx: QuoteContext, rampType: RampDi return vortexPricing ? toActivePartner(vortexPricing) : null; } +const USD_LIKE_INPUT_CURRENCIES: ReadonlySet = new Set([ + "USD", + EvmToken.USDC, + EvmToken.USDT, + EvmToken.USDCE, + EvmToken.AXLUSDC +]); + +const FIAT_PEG_BY_STABLECOIN: Record = { + [EvmToken.BRLA]: FiatToken.BRL, + [EvmToken.EURC]: FiatToken.EURC +}; + +/** + * Value the offramp request input in USD. The offramp expected-output math multiplies a + * USD amount by the inverted FIAT-USD oracle rate, but request.inputAmount is denominated + * in the input token: USD-like stables pass through unchanged, fiat-pegged stables + * (BRLA, EURC) are valued at their peg's FIAT-USD oracle rate, and any other token falls + * back to the bridged USDC amount when available. + * + * A rate-feed failure while valuing a fiat-pegged stable MUST NOT fail the quote: the + * engine already holds the bridged USDC amount, a good USD-denominated proxy, so we fall + * back to it (or the raw input as a last resort) rather than throwing from discount math. + */ +export async function getUsdDenominatedInputAmount(ctx: QuoteContext): Promise { + const { inputAmount, inputCurrency } = ctx.request; + const normalized = normalizeTokenSymbol(inputCurrency); + + if (USD_LIKE_INPUT_CURRENCIES.has(normalized)) { + return new Big(inputAmount); + } + + const pegFiat = FIAT_PEG_BY_STABLECOIN[normalized]; + if (pegFiat) { + try { + const fiatToUsdRate = await priceFeedService.getFiatToUsdExchangeRate(pegFiat); + return new Big(inputAmount).mul(fiatToUsdRate); + } catch (error) { + const fallback = usdFallbackFromContext(ctx); + logger.warn( + `getUsdDenominatedInputAmount: ${pegFiat}-USD rate lookup failed for ${inputCurrency} input, ` + + `falling back to ${fallback.toString()} USD. Error: ${error instanceof Error ? error.message : error}` + ); + return fallback; + } + } + + return usdFallbackFromContext(ctx); +} + +function usdFallbackFromContext(ctx: QuoteContext): Big { + if (ctx.evmToEvm?.outputAmountDecimal) { + return ctx.evmToEvm.outputAmountDecimal; + } + return new Big(ctx.request.inputAmount); +} + /** * Calculate expected output amount based on oracle price and target discount * @param inputAmount - The input amount from the request diff --git a/apps/api/src/api/services/quote/engines/discount/offramp-alfredpay.ts b/apps/api/src/api/services/quote/engines/discount/offramp-alfredpay.ts index b1ac53a0e..ca57d2fca 100644 --- a/apps/api/src/api/services/quote/engines/discount/offramp-alfredpay.ts +++ b/apps/api/src/api/services/quote/engines/discount/offramp-alfredpay.ts @@ -9,7 +9,12 @@ import Big from "big.js"; import { priceFeedService } from "../../../priceFeed.service"; import { QuoteContext } from "../../core/types"; import { BaseDiscountEngine, DiscountComputation } from "."; -import { calculateExpectedOutput, calculateSubsidyAmount, resolveDiscountPartner } from "./helpers"; +import { + calculateExpectedOutput, + calculateSubsidyAmount, + getUsdDenominatedInputAmount, + resolveDiscountPartner +} from "./helpers"; export class OffRampAlfredpayDiscountEngine extends BaseDiscountEngine { readonly config = { @@ -59,11 +64,19 @@ export class OffRampAlfredpayDiscountEngine extends BaseDiscountEngine { const usdToFiatRate = new Big(1).div(effectiveRate); const finalOutput = usdOnPolygon.mul(usdToFiatRate); + // The inverted rate converts USD -> fiat, so a non-USD input (e.g. BRLA) must be valued in USD first. + const inputAmountUsd = await getUsdDenominatedInputAmount(ctx); + if (!inputAmountUsd.eq(inputAmount)) { + ctx.addNote?.( + `OffRampAlfredpayDiscountEngine: valued input ${inputAmount} ${ctx.request.inputCurrency} at ${inputAmountUsd.toFixed(6)} USD for discount calculation` + ); + } + const { expectedOutput: expectedOutputDecimal, adjustedDifference, adjustedTargetDiscount - } = calculateExpectedOutput(inputAmount, effectiveRate, targetDiscount, this.config.isOfframp, partner); + } = calculateExpectedOutput(inputAmountUsd.toString(), effectiveRate, targetDiscount, this.config.isOfframp, partner); const idealSubsidyDecimal = expectedOutputDecimal.gt(finalOutput) ? expectedOutputDecimal.minus(finalOutput) : new Big(0); 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 aa6e7b547..2d4ccaa40 100644 --- a/apps/api/src/api/services/quote/engines/discount/offramp.ts +++ b/apps/api/src/api/services/quote/engines/discount/offramp.ts @@ -3,7 +3,12 @@ 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"; +import { + calculateExpectedOutput, + calculateSubsidyAmount, + getUsdDenominatedInputAmount, + resolveDiscountPartner +} from "./helpers"; export class OffRampDiscountEngine extends BaseDiscountEngine { readonly config = { @@ -32,18 +37,27 @@ export class OffRampDiscountEngine extends BaseDiscountEngine { // biome-ignore lint/style/noNonNullAssertion: Context is validated in validate const oraclePrice = nablaSwap.oraclePrice!; - const { inputAmount, rampType } = ctx.request; + const { inputAmount, inputCurrency, rampType } = ctx.request; const partner = await resolveDiscountPartner(ctx, rampType); const targetDiscount = partner?.targetDiscount ?? 0; const maxSubsidy = partner?.maxSubsidy ?? 0; - // Calculate the oracle-based expected output in BRL. + // The expected-output math multiplies a USD amount by the inverted FIAT-USD oracle rate, + // so a non-USD input (e.g. BRLA) must be valued in USD first. + const inputAmountUsd = await getUsdDenominatedInputAmount(ctx); + if (!inputAmountUsd.eq(inputAmount)) { + ctx.addNote?.( + `OffRampDiscountEngine: valued input ${inputAmount} ${inputCurrency} at ${inputAmountUsd.toFixed(6)} USD for discount calculation` + ); + } + + // Calculate the oracle-based expected output in the target fiat currency. const { expectedOutput: oracleExpectedOutputDecimal, adjustedDifference, adjustedTargetDiscount - } = calculateExpectedOutput(inputAmount, oraclePrice, targetDiscount, this.config.isOfframp, partner); + } = calculateExpectedOutput(inputAmountUsd.toString(), oraclePrice, targetDiscount, this.config.isOfframp, partner); // 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. diff --git a/apps/api/src/tests/quote-pricing.golden.test.ts b/apps/api/src/tests/quote-pricing.golden.test.ts index 3b63e66c9..55bcb4fc3 100644 --- a/apps/api/src/tests/quote-pricing.golden.test.ts +++ b/apps/api/src/tests/quote-pricing.golden.test.ts @@ -180,7 +180,10 @@ describe("quote pricing goldens (fixed input matrix)", () => { network: "base", networkFeeFiat: "0", networkFeeUsd: "0", - outputAmount: "500.00", + // 100 BRLA is worth ~100 BRL (1:1 peg), not 500: the discount engine now values the + // BRLA input in USD (~20 USD at 5 BRL/USD) before applying the inverted oracle rate, + // instead of treating 100 BRLA as 100 USD → 500 BRL. + outputAmount: "100.00", outputCurrency: "BRL", partnerFeeFiat: "0", partnerFeeUsd: "0", diff --git a/docs/security-spec/03-ramp-engine/discount-mechanism.md b/docs/security-spec/03-ramp-engine/discount-mechanism.md index 9ba604935..b31d11718 100644 --- a/docs/security-spec/03-ramp-engine/discount-mechanism.md +++ b/docs/security-spec/03-ramp-engine/discount-mechanism.md @@ -11,7 +11,7 @@ For each quote, the discount engine: - `targetDiscount` — the discount to advertise. A positive `targetDiscount` means the user receives **more** than the oracle implies (e.g. `targetDiscount=0.005` means the rate offered is 0.5% better than the oracle rate). - `maxSubsidy` — a fractional cap on the subsidy as a share of expected output. 3. Reads dynamic state `partnerDiscountState[partner.id]`, keyed by the pricing partner, which holds a `difference` value that drifts up while no quote is consumed and back down once a quote is consumed, bounded by `[minDynamicDifference, maxDynamicDifference]`. -4. Calculates `expectedOutput = inputAmount × oraclePrice × (1 + targetDiscount + adjustedDifference)`. For offramps the oracle price is inverted first. +4. Calculates `expectedOutput = inputAmount × oraclePrice × (1 + targetDiscount + adjustedDifference)`. For offramps the oracle price is inverted first (USD → fiat), so the input amount MUST be USD-denominated: the engine first values the request input in USD via `getUsdDenominatedInputAmount` — USD-like stables (USD, USDC, USDT, USDC.e, axlUSDC) pass through unchanged, fiat-pegged stables (BRLA → BRL, EURC → EUR) are valued at their peg's FIAT-USD oracle rate, and any other input token falls back to the bridged USDC amount (`evmToEvm.outputAmountDecimal`) when available. A rate-feed failure while valuing a fiat-pegged stable does not fail the quote — it falls back to the bridged USDC amount (or the raw input as a last resort), so a transient price-feed outage cannot throw from discount math. 5. Calculates `actualOutput` as what the user would receive without subsidy (Nabla output minus post-swap fees on onramp, anchor fee added back on offramp). 6. Calculates `idealSubsidy = max(0, expectedOutput − actualOutput)` and `actualSubsidy = min(idealSubsidy, maxSubsidy × expectedOutput)` (only when `targetDiscount > 0`). 7. Writes a `ctx.subsidy` record consumed by downstream merge-subsidy and finalize stages and ultimately by the subsidy phase handlers. On EVM post-swap routes this record represents the discount-derived subsidy component only; the runtime handler may additionally cover actual-vs-quoted swap-output discrepancy, which is capped separately. The finalize stage snapshots the quote-time discount component into public display fields (`discountFiat`, `discountUsd`, `discountCurrency`) when the subsidy is applied, allowing the UI to show the user-facing discount separately from fees. @@ -39,7 +39,8 @@ For onramps to EVM destinations other than AssetHub, the engine also probes Squi 10. **EVM off-ramp Nabla minimums MUST use AMM-only output, not subsidy-merged output.** EVM Nabla quote producers write the AMM-only result to `nablaSwapEvm.ammOutputAmount*`. On Base EVM offramps, `MergeSubsidy` may then merge the quote-time discount subsidy into `nablaSwapEvm.outputAmount*` so downstream payout/finalization targets reflect the subsidized amount. The on-chain Nabla swap minimums MUST be derived from the preserved AMM-only amount (`ammOutputAmountRaw`, falling back to `outputAmountRaw` only for legacy quotes without the snapshot), otherwise the minimum can exceed what the AMM can deliver and cause deterministic swap reverts. 11. **The dynamic difference MUST NOT be incremented within `discountStateTimeoutMinutes` of the last quote** — `getAdjustedDifference` only adds `deltaD` when `isWithinStateTimeout` is **false**. Otherwise repeated quotes from the same partner would inflate the difference faster than intended. 12. **Squid Router probe failures MUST fall back to a 1:1 assumption, never block the quote.** Both `getSquidRouterUSDCConversionRate` and `getSquidRouterAxlUSDCConversionRate` return `null` on error and the engine proceeds with `adjustedExpectedOutputDecimal = oracleExpectedOutputDecimal`. A network failure on the probe MUST NOT cause the entire quote stage to throw. -13. **Public discount display MUST only expose applied quote-time discount subsidy.** `discountFiat` / `discountUsd` MUST be present only when `ctx.subsidy.applied` is true, the subsidy amount is positive, the subsidy currency can be inferred, and display conversion succeeds without fallback. Runtime swap-discrepancy top-ups MUST NOT be folded into this display field because they are execution-time protection, not a promotional rate improvement. +13. **Offramp `expectedOutput` MUST be computed from the USD value of the input, never the raw input amount.** The inverted oracle rate converts USD → fiat; feeding it a non-USD input amount misdenominates the target. Before this was enforced, a 1000 BRLA → PIX offramp was treated as 1000 USD, inflating `expectedOutput` (and the `maxSubsidy × expectedOutput` cap) by the BRL-USD rate (~5×) and over-paying the subsidy on every such quote; EURC → SEPA offramps were symmetrically under-subsidized. Enforced by `getUsdDenominatedInputAmount` in both `OffRampDiscountEngine` and `OffRampAlfredpayDiscountEngine`. +14. **Public discount display MUST only expose applied quote-time discount subsidy.** `discountFiat` / `discountUsd` MUST be present only when `ctx.subsidy.applied` is true, the subsidy amount is positive, the subsidy currency can be inferred, and display conversion succeeds without fallback. Runtime swap-discrepancy top-ups MUST NOT be folded into this display field because they are execution-time protection, not a promotional rate improvement. ## Threat Vectors & Mitigations