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
7 changes: 7 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
# Repository agent instructions

- Read and follow the root `CLAUDE.md` before doing any work in this repository.
- When work touches an app or package, also read and follow the closest scoped `CLAUDE.md`.
- Commit messages use Conventional Commits: `<type>(<scope>): <summary>`.
- Pull request titles use plain, human-friendly sentence case. Never prefix a PR title with `<type>(<scope>):`.
- Before creating or renaming a pull request, reread the `Commit Messages & PR Titles` section in `CLAUDE.md`.
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,7 @@ update the existing canonical document when one owns the topic.

## Commit Messages & PR Titles

Every commit message and PR title follows [Conventional Commits](https://www.conventionalcommits.org/):
Every commit message follows [Conventional Commits](https://www.conventionalcommits.org/):

```
<type>(<scope>): <summary>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,13 +30,11 @@ const CORE_PHASES: RampPhase[] = [
];

describe("Alfredpay offramp flow", () => {
it("fails closed for persisted v1 identities (drain-then-deploy contract)", () => {
// Version 2 added the fee-collection phase. v1 is deliberately NOT kept
// dispatchable: deploys are gated on draining v1 quotes and in-flight ramps,
// and anything that slips through must fail closed for manual recovery.
expect(alfredpayOfframpFlow.identity.version).toBe(2);
expect(() => getBlockFlowByIdentity({ ...alfredpayOfframpFlow.identity, version: 1 })).toThrow(
/Unsupported persisted flow AlfredpayOfframp@1/
it("fails closed for persisted pre-v3 identities (drain-then-deploy contract)", () => {
expect(alfredpayOfframpFlow.identity.version).toBe(3);
expect(alfredpayOfframpFlow.identity.blockSchemaVersions.alfredpayOfframp).toBe(2);
expect(() => getBlockFlowByIdentity({ ...alfredpayOfframpFlow.identity, version: 2 })).toThrow(
/Unsupported persisted flow AlfredpayOfframp@2/
);
});

Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,12 @@
import { describe, expect, it, mock } from "bun:test";
import { type EvmNetworks, EvmToken, FiatToken, Networks } from "@vortexfi/shared";
import {
AlfredpayFeeType,
AlfredpayOnChainCurrency,
type EvmNetworks,
EvmToken,
FiatToken,
Networks
} from "@vortexfi/shared";
import { registerAlfredpayOfframp } from "../phases/alfredpay-offramp/registration";
import type { AlfredpayOfframpMetadata } from "../phases/alfredpay-offramp/simulation";

Expand All @@ -19,6 +26,28 @@ const metadata: AlfredpayOfframpMetadata = {
network: Networks.Polygon,
outputAmountDecimal: "1980",
outputAmountRaw: "198000",
pricing: {
customer: { allInRate: "19.8", inputAmountUsd: "100", referenceDifferenceBps: "-100" },
provider: {
baseCurrency: AlfredpayOnChainCurrency.USDT,
feeAmount: "1",
fees: [{ amount: "1", currency: "MXN", type: AlfredpayFeeType.PROCESSING_FEE }],
grossRate: "20",
grossReferenceDifferenceBps: "0",
netRate: "20",
netReferenceDifferenceBps: "0",
quoteCurrency: FiatToken.MXN,
quotedAt: new Date("2026-01-01T00:00:00Z"),
source: "alfredpay"
},
reference: {
baseCurrency: "USD",
observedAt: new Date("2026-01-01T00:00:00Z"),
quoteCurrency: FiatToken.MXN,
rate: "20",
source: "fastforex"
}
},
quoteId: "quote-old",
subsidyAmountDecimal: "0",
subsidyAmountRaw: "0",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import { DistributeFees } from "../phases/distribute-fees";
// Version 2 appends the Polygon fee-collection phase: the vortex/partner fee residual
// that AlfredpayOfframp's pricing reserves on the Polygon ephemeral is paid out after
// the Alfredpay deposit succeeded. Deploys are gated on draining v1 quotes/ramps.
export const ALFREDPAY_OFFRAMP_FLOW_VERSION = 2;
export const ALFREDPAY_OFFRAMP_FLOW_VERSION = 3;
Comment thread
ebma marked this conversation as resolved.

export function makeAlfredpayOfframpFlow(fromToken: EvmToken, fromNetwork: EvmNetworks) {
return FlowBuilder.start(evmRequestIO(fromToken, fromNetwork), AlfredpayOfframp(fromToken, fromNetwork))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
ALFREDPAY_ONCHAIN_CURRENCY,
AlfredpayApiService,
AlfredpayChain,
type AlfredpayFeeType,
type AlfredpayFiatCurrency,
AlfredpayPaymentMethodType,
type EvmNetworks,
Expand All @@ -17,7 +18,7 @@ import {
RampDirection
} from "@vortexfi/shared";
import Big from "big.js";
import { priceFeedService } from "../../../../priceFeed.service";
import { type FiatExchangeRateSource, priceFeedService } from "../../../../priceFeed.service";
import { resolveAlfredpayQuoteCustomerId } from "../../../../quote/alfredpay-customer";
import {
calculateExpectedOutput,
Expand Down Expand Up @@ -48,14 +49,40 @@ export interface AlfredpayOfframpMetadata {
network: typeof Networks.Polygon;
outputAmountDecimal: SerializableBig;
outputAmountRaw: string;
pricing: {
customer: {
allInRate: SerializableBig;
inputAmountUsd: SerializableBig;
referenceDifferenceBps: SerializableBig;
};
provider: {
baseCurrency: typeof ALFREDPAY_ONCHAIN_CURRENCY;
feeAmount: SerializableBig;
fees: Array<{ amount: string; currency: string; type: AlfredpayFeeType }>;
grossRate: SerializableBig;
grossReferenceDifferenceBps: SerializableBig;
netRate: SerializableBig;
netReferenceDifferenceBps: SerializableBig;
quoteCurrency: FiatToken;
quotedAt: Date;
source: "alfredpay";
};
reference: {
baseCurrency: "USD";
observedAt: Date;
quoteCurrency: FiatToken;
rate: SerializableBig;
source: FiatExchangeRateSource;
};
};
quoteId: string;
subsidyAmountDecimal: SerializableBig;
subsidyAmountRaw: string;
token: typeof ALFREDPAY_EVM_TOKEN;
toToken: `0x${string}`;
}

export const AlfredpayOfframpContext = defineContext<AlfredpayOfframpMetadata>()("alfredpayOfframp");
export const AlfredpayOfframpContext = defineContext<AlfredpayOfframpMetadata>()("alfredpayOfframp", 2);

function directAlfredpaySettlementQuote(amountDecimal: string) {
const outputAmountDecimal = new Big(amountDecimal);
Expand Down Expand Up @@ -112,6 +139,10 @@ export function simulateAlfredpayOfframp<FromToken extends EvmToken, FromNetwork
)
);
const fiatToUsd = new Big(1).div(oneUnitInFiat);
const referenceRateSnapshot = await priceFeedService.getUsdToFiatExchangeRateSnapshot(
ctx.request.outputCurrency as RampCurrency
);
const referenceRate = new Big(referenceRateSnapshot.rate);
const partner = await resolveDiscountPartner(ctx as never, RampDirection.SELL);
const targetDiscount = partner?.targetDiscount ?? 0;
const maxSubsidy = partner?.maxSubsidy ?? 0;
Expand Down Expand Up @@ -150,6 +181,9 @@ export function simulateAlfredpayOfframp<FromToken extends EvmToken, FromNetwork
toCurrency: ctx.request.outputCurrency as unknown as AlfredpayFiatCurrency
});
const outputAmount = new Big(providerQuote.toAmount);
const providerGrossRate = new Big(providerQuote.rate);
const providerNetRate = outputAmount.div(providerInput);
const customerAllInRate = outputAmount.div(inputAmountUsd);
const providerFee = AlfredpayApiService.sumFeesByCurrency(
providerQuote.fees,
ctx.request.outputCurrency as unknown as AlfredpayFiatCurrency
Expand Down Expand Up @@ -182,6 +216,32 @@ export function simulateAlfredpayOfframp<FromToken extends EvmToken, FromNetwork
network: Networks.Polygon,
outputAmountDecimal: outputAmount,
outputAmountRaw: multiplyByPowerOfTen(outputAmount, 2).toFixed(0, 0),
pricing: {
customer: {
allInRate: customerAllInRate,
inputAmountUsd,
referenceDifferenceBps: customerAllInRate.div(referenceRate).minus(1).mul(10_000)
},
provider: {
baseCurrency: ALFREDPAY_ONCHAIN_CURRENCY,
feeAmount: providerFee,
fees: providerQuote.fees.map(({ amount, currency, type }) => ({ amount, currency, type })),
grossRate: providerGrossRate,
grossReferenceDifferenceBps: providerGrossRate.div(referenceRate).minus(1).mul(10_000),
netRate: providerNetRate,
netReferenceDifferenceBps: providerNetRate.div(referenceRate).minus(1).mul(10_000),
quoteCurrency: ctx.request.outputCurrency as FiatToken,
quotedAt: ctx.now,
source: "alfredpay"
},
reference: {
baseCurrency: "USD",
observedAt: referenceRateSnapshot.observedAt,
quoteCurrency: ctx.request.outputCurrency as FiatToken,
rate: referenceRate,
source: referenceRateSnapshot.source
}
},
quoteId: providerQuote.quoteId,
subsidyAmountDecimal: subsidyFiat.div(oneUnitInFiat),
subsidyAmountRaw: multiplyByPowerOfTen(subsidyFiat.div(oneUnitInFiat), ALFREDPAY_ERC20_DECIMALS).toFixed(0, 0),
Expand Down
37 changes: 37 additions & 0 deletions apps/api/src/api/services/priceFeed.service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -210,6 +210,43 @@ describe("PriceFeedService", () => {
});

describe("getUsdToFiatExchangeRate", () => {
it("returns the selected provider and observation time with the reference rate", async () => {
const instance = PriceFeedService.getInstance();
const observedAt = 1_000_000;
Date.now = () => observedAt;
fetchMock = mock(async () => mockFastforexResponse(18.5, MXN));
global.fetch = fetchMock as unknown as typeof fetch;

const snapshot = await instance.getUsdToFiatExchangeRateSnapshot(MXN);

expect(snapshot).toEqual({ observedAt: new Date(observedAt), rate: 18.5, source: "fastforex" });
});

it("preserves the original source and observation time on cache hits", async () => {
const instance = PriceFeedService.getInstance();
const observedAt = 1_000_000;
Date.now = () => observedAt;
fetchMock = mock(async () => mockFastforexResponse(18.5, MXN));
global.fetch = fetchMock as unknown as typeof fetch;
const first = await instance.getUsdToFiatExchangeRateSnapshot(MXN);
fetchMock.mockClear();
Date.now = () => observedAt + 1_000;

const cached = await instance.getUsdToFiatExchangeRateSnapshot(MXN);

expect(cached).toEqual(first);
expect(fetchMock).not.toHaveBeenCalled();
});

it("identifies the fixed USD reference without calling an external provider", async () => {
const instance = PriceFeedService.getInstance();

const snapshot = await instance.getUsdToFiatExchangeRateSnapshot(USD);

expect(snapshot).toMatchObject({ rate: 1, source: "identity" });
expect(fetchMock).not.toHaveBeenCalled();
});

it("should use Binance spot as the primary source for BRL", async () => {
const instance = PriceFeedService.getInstance();
instance.getCryptoPrice = mock(async () => 5.86);
Expand Down
33 changes: 24 additions & 9 deletions apps/api/src/api/services/priceFeed.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,14 @@ interface CacheEntry<T> {
expiresAt: number;
}

export type FiatExchangeRateSource = "binance" | "coingecko" | "fastforex" | "identity";

export interface FiatExchangeRateSnapshot {
observedAt: Date;
rate: number;
source: FiatExchangeRateSource;
}

const FIAT_SANITY_SPREAD_LIMITS: Record<string, number> = {
ARS: 0.25,
BRL: 0.02,
Expand Down Expand Up @@ -61,7 +69,7 @@ export class PriceFeedService {
// Cache storage
private cryptoPriceCache: Map<string, CacheEntry<number>> = new Map();

private fiatExchangeRateCache: Map<string, CacheEntry<number>> = new Map();
private fiatExchangeRateCache: Map<string, CacheEntry<FiatExchangeRateSnapshot>> = new Map();

/**
* Private constructor to enforce singleton pattern
Expand Down Expand Up @@ -205,6 +213,10 @@ export class PriceFeedService {
* @returns The exchange rate (how much of toCurrency equals 1 unit of fromCurrency)
*/
public async getUsdToFiatExchangeRate(toCurrency: RampCurrency): Promise<number> {
return (await this.getUsdToFiatExchangeRateSnapshot(toCurrency)).rate;
}

public async getUsdToFiatExchangeRateSnapshot(toCurrency: RampCurrency): Promise<FiatExchangeRateSnapshot> {
const fromCurrency = "USD";
const targetCurrency = toCurrency.toUpperCase() as RampCurrency;

Expand All @@ -213,7 +225,7 @@ export class PriceFeedService {
}

if (targetCurrency === "USD") {
return 1;
return { observedAt: new Date(), rate: 1, source: "identity" };
}

const cacheKey = `fiat:${fromCurrency}:${targetCurrency}`;
Expand All @@ -222,7 +234,7 @@ export class PriceFeedService {
const hasCoinGeckoFallback = !COINGECKO_UNSUPPORTED_FIAT_CURRENCIES.has(targetCurrency);

if (cachedEntry && cachedEntry.expiresAt > now) {
logger.debug(`Cache hit for ${cacheKey}. Using cached exchange rate: ${cachedEntry.value}`);
logger.debug(`Cache hit for ${cacheKey}. Using cached exchange rate: ${cachedEntry.value.rate}`);
return cachedEntry.value;
}

Expand All @@ -232,8 +244,9 @@ export class PriceFeedService {
try {
const rate = await this.getBinanceUsdtToFiatRate(targetCurrency);
await this.assertRateWithinSanityBand("Binance", targetCurrency, rate);
this.fiatExchangeRateCache.set(cacheKey, { expiresAt: now + this.fiatCacheTtlMs, value: rate });
return rate;
const snapshot = { observedAt: new Date(now), rate, source: "binance" } as const;
this.fiatExchangeRateCache.set(cacheKey, { expiresAt: now + this.fiatCacheTtlMs, value: snapshot });
return snapshot;
} catch (binanceError) {
logger.warn(
`Binance failed for ${fromCurrency}-${targetCurrency}, falling back to fastforex: ${binanceError instanceof Error ? binanceError.message : binanceError}`
Expand All @@ -247,8 +260,9 @@ export class PriceFeedService {
try {
const rate = await this.getFastforexRate(fromCurrency, targetCurrency);
await this.assertRateWithinSanityBand("fastforex", targetCurrency, rate);
this.fiatExchangeRateCache.set(cacheKey, { expiresAt: now + this.fiatCacheTtlMs, value: rate });
return rate;
const snapshot = { observedAt: new Date(now), rate, source: "fastforex" } as const;
this.fiatExchangeRateCache.set(cacheKey, { expiresAt: now + this.fiatCacheTtlMs, value: snapshot });
return snapshot;
} catch (ffError) {
logger.warn(
`fastforex failed for ${fromCurrency}-${targetCurrency}, ${
Expand All @@ -274,8 +288,9 @@ export class PriceFeedService {
try {
const rate = await this.getCryptoPrice("usd-coin", targetCurrency.toLowerCase());
this.assertValidFiatRate("CoinGecko", fromCurrency, targetCurrency, rate);
this.fiatExchangeRateCache.set(cacheKey, { expiresAt: now + this.fiatCacheTtlMs, value: rate });
return rate;
const snapshot = { observedAt: new Date(now), rate, source: "coingecko" } as const;
this.fiatExchangeRateCache.set(cacheKey, { expiresAt: now + this.fiatCacheTtlMs, value: snapshot });
return snapshot;
} catch (cgError) {
if (cgError instanceof Error) {
logger.error(`Error fetching fiat exchange rate from ${fromCurrency} to ${targetCurrency}: ${cgError.message}`);
Expand Down
18 changes: 16 additions & 2 deletions apps/api/src/test-utils/fake-world/fake-prices.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,21 +49,35 @@ export class FakePrices {
}
}

type PatchedMethods = "getCryptoPrice" | "getFiatToUsdExchangeRate" | "getUsdToFiatExchangeRate" | "convertCurrency";
type PatchedMethods =
| "getCryptoPrice"
| "getFiatToUsdExchangeRate"
| "getUsdToFiatExchangeRate"
| "getUsdToFiatExchangeRateSnapshot"
| "convertCurrency";

export function installFakePrices(): { fakePrices: FakePrices; restore: () => void } {
const fakePrices = new FakePrices();
const originals: Partial<Record<PatchedMethods, unknown>> = {
convertCurrency: priceFeedService.convertCurrency,
getCryptoPrice: priceFeedService.getCryptoPrice,
getFiatToUsdExchangeRate: priceFeedService.getFiatToUsdExchangeRate,
getUsdToFiatExchangeRate: priceFeedService.getUsdToFiatExchangeRate
getUsdToFiatExchangeRate: priceFeedService.getUsdToFiatExchangeRate,
getUsdToFiatExchangeRateSnapshot: priceFeedService.getUsdToFiatExchangeRateSnapshot
};

priceFeedService.getCryptoPrice = async (tokenId: string) => fakePrices.getCryptoUsd(tokenId);
priceFeedService.getFiatToUsdExchangeRate = async (fromCurrency: RampCurrency) =>
new Big(1).div(fakePrices.getPerUsd(fromCurrency as string));
priceFeedService.getUsdToFiatExchangeRate = async (toCurrency: RampCurrency) => fakePrices.getPerUsd(toCurrency as string);
priceFeedService.getUsdToFiatExchangeRateSnapshot = async (toCurrency: RampCurrency) => {
const normalizedCurrency = toCurrency.toLowerCase();
return {
observedAt: new Date(0),
rate: fakePrices.getPerUsd(normalizedCurrency),
source: normalizedCurrency === "usd" ? "identity" : ["brl", "cop"].includes(normalizedCurrency) ? "binance" : "fastforex"
};
};
priceFeedService.convertCurrency = async (
amount: string,
fromCurrency: RampCurrency,
Expand Down
Loading
Loading