Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
87 changes: 85 additions & 2 deletions apps/api/src/api/services/quote/engines/discount/helpers.test.ts
Original file line number Diff line number Diff line change
@@ -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", () => {
Expand Down Expand Up @@ -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<typeof spyOn> | 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");
});
Comment thread
ebma marked this conversation as resolved.

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");
});
});
61 changes: 60 additions & 1 deletion apps/api/src/api/services/quote/engines/discount/helpers.ts
Original file line number Diff line number Diff line change
@@ -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";

Expand Down Expand Up @@ -72,6 +74,63 @@ export async function resolveDiscountPartner(ctx: QuoteContext, rampType: RampDi
return vortexPricing ? toActivePartner(vortexPricing) : null;
}

const USD_LIKE_INPUT_CURRENCIES: ReadonlySet<string> = new Set([
"USD",
EvmToken.USDC,
EvmToken.USDT,
EvmToken.USDCE,
EvmToken.AXLUSDC
]);

const FIAT_PEG_BY_STABLECOIN: Record<string, FiatToken> = {
[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<Big> {
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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -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);

Expand Down
22 changes: 18 additions & 4 deletions apps/api/src/api/services/quote/engines/discount/offramp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -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.
Expand Down
5 changes: 4 additions & 1 deletion apps/api/src/tests/quote-pricing.golden.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
5 changes: 3 additions & 2 deletions docs/security-spec/03-ramp-engine/discount-mechanism.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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

Expand Down
Loading