diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 000000000..4054f133d --- /dev/null +++ b/AGENTS.md @@ -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: `(): `. +- Pull request titles use plain, human-friendly sentence case. Never prefix a PR title with `():`. +- Before creating or renaming a pull request, reread the `Commit Messages & PR Titles` section in `CLAUDE.md`. diff --git a/CLAUDE.md b/CLAUDE.md index e44d49221..877bbca75 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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/): ``` (): diff --git a/apps/api/src/api/services/phases/blocks/__tests__/alfredpay-offramp.flow.test.ts b/apps/api/src/api/services/phases/blocks/__tests__/alfredpay-offramp.flow.test.ts index 416366c27..424065ace 100644 --- a/apps/api/src/api/services/phases/blocks/__tests__/alfredpay-offramp.flow.test.ts +++ b/apps/api/src/api/services/phases/blocks/__tests__/alfredpay-offramp.flow.test.ts @@ -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/ ); }); diff --git a/apps/api/src/api/services/phases/blocks/__tests__/alfredpay-offramp.registration.test.ts b/apps/api/src/api/services/phases/blocks/__tests__/alfredpay-offramp.registration.test.ts index 04be6c2ae..d38c0351a 100644 --- a/apps/api/src/api/services/phases/blocks/__tests__/alfredpay-offramp.registration.test.ts +++ b/apps/api/src/api/services/phases/blocks/__tests__/alfredpay-offramp.registration.test.ts @@ -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"; @@ -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", diff --git a/apps/api/src/api/services/phases/blocks/flows/alfredpay-offramp.ts b/apps/api/src/api/services/phases/blocks/flows/alfredpay-offramp.ts index 4a837fa5d..9324d6c12 100644 --- a/apps/api/src/api/services/phases/blocks/flows/alfredpay-offramp.ts +++ b/apps/api/src/api/services/phases/blocks/flows/alfredpay-offramp.ts @@ -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; export function makeAlfredpayOfframpFlow(fromToken: EvmToken, fromNetwork: EvmNetworks) { return FlowBuilder.start(evmRequestIO(fromToken, fromNetwork), AlfredpayOfframp(fromToken, fromNetwork)) diff --git a/apps/api/src/api/services/phases/blocks/phases/alfredpay-offramp/simulation.ts b/apps/api/src/api/services/phases/blocks/phases/alfredpay-offramp/simulation.ts index 974df19b5..12c4d22e4 100644 --- a/apps/api/src/api/services/phases/blocks/phases/alfredpay-offramp/simulation.ts +++ b/apps/api/src/api/services/phases/blocks/phases/alfredpay-offramp/simulation.ts @@ -5,6 +5,7 @@ import { ALFREDPAY_ONCHAIN_CURRENCY, AlfredpayApiService, AlfredpayChain, + type AlfredpayFeeType, type AlfredpayFiatCurrency, AlfredpayPaymentMethodType, type EvmNetworks, @@ -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, @@ -48,6 +49,32 @@ 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; @@ -55,7 +82,7 @@ export interface AlfredpayOfframpMetadata { toToken: `0x${string}`; } -export const AlfredpayOfframpContext = defineContext()("alfredpayOfframp"); +export const AlfredpayOfframpContext = defineContext()("alfredpayOfframp", 2); function directAlfredpaySettlementQuote(amountDecimal: string) { const outputAmountDecimal = new Big(amountDecimal); @@ -112,6 +139,10 @@ export function simulateAlfredpayOfframp ({ 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), diff --git a/apps/api/src/api/services/priceFeed.service.test.ts b/apps/api/src/api/services/priceFeed.service.test.ts index 2e27bb4d0..5e14f2560 100644 --- a/apps/api/src/api/services/priceFeed.service.test.ts +++ b/apps/api/src/api/services/priceFeed.service.test.ts @@ -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); diff --git a/apps/api/src/api/services/priceFeed.service.ts b/apps/api/src/api/services/priceFeed.service.ts index 87e950d7d..199322375 100644 --- a/apps/api/src/api/services/priceFeed.service.ts +++ b/apps/api/src/api/services/priceFeed.service.ts @@ -10,6 +10,14 @@ interface CacheEntry { expiresAt: number; } +export type FiatExchangeRateSource = "binance" | "coingecko" | "fastforex" | "identity"; + +export interface FiatExchangeRateSnapshot { + observedAt: Date; + rate: number; + source: FiatExchangeRateSource; +} + const FIAT_SANITY_SPREAD_LIMITS: Record = { ARS: 0.25, BRL: 0.02, @@ -61,7 +69,7 @@ export class PriceFeedService { // Cache storage private cryptoPriceCache: Map> = new Map(); - private fiatExchangeRateCache: Map> = new Map(); + private fiatExchangeRateCache: Map> = new Map(); /** * Private constructor to enforce singleton pattern @@ -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 { + return (await this.getUsdToFiatExchangeRateSnapshot(toCurrency)).rate; + } + + public async getUsdToFiatExchangeRateSnapshot(toCurrency: RampCurrency): Promise { const fromCurrency = "USD"; const targetCurrency = toCurrency.toUpperCase() as RampCurrency; @@ -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}`; @@ -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; } @@ -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}` @@ -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}, ${ @@ -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}`); diff --git a/apps/api/src/test-utils/fake-world/fake-prices.ts b/apps/api/src/test-utils/fake-world/fake-prices.ts index ef4157ebb..8a8af91cb 100644 --- a/apps/api/src/test-utils/fake-world/fake-prices.ts +++ b/apps/api/src/test-utils/fake-world/fake-prices.ts @@ -49,7 +49,12 @@ 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(); @@ -57,13 +62,22 @@ export function installFakePrices(): { fakePrices: FakePrices; restore: () => vo 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, diff --git a/apps/api/src/tests/corridors/mxn-offramp.scenario.test.ts b/apps/api/src/tests/corridors/mxn-offramp.scenario.test.ts index bf488f39a..12b5e5124 100644 --- a/apps/api/src/tests/corridors/mxn-offramp.scenario.test.ts +++ b/apps/api/src/tests/corridors/mxn-offramp.scenario.test.ts @@ -302,6 +302,32 @@ describe("MXN offramp direct corridor (USDT on Polygon → spei, no-permit)", () alfredpayOfframp?: { bridgeInputAmountRaw?: string; bridgeOutputAmountRaw?: string; + pricing?: { + customer: { + allInRate: string; + inputAmountUsd: string; + referenceDifferenceBps: string; + }; + provider: { + baseCurrency: string; + feeAmount: string; + fees: Array<{ amount: string; currency: string; type: string }>; + grossRate: string; + grossReferenceDifferenceBps: string; + netRate: string; + netReferenceDifferenceBps: string; + quoteCurrency: string; + quotedAt: string; + source: string; + }; + reference: { + baseCurrency: string; + observedAt: string; + quoteCurrency: string; + rate: string; + source: string; + }; + }; }; }; } @@ -310,6 +336,31 @@ describe("MXN offramp direct corridor (USDT on Polygon → spei, no-permit)", () expect(metadata?.blocks.alfredpayOfframp?.bridgeInputAmountRaw).toBe(expectedRaw); expect(metadata?.blocks.alfredpayOfframp?.bridgeOutputAmountRaw).toBe(expectedRaw); + + const pricing = metadata?.blocks.alfredpayOfframp?.pricing; + expect(pricing?.reference).toEqual({ + baseCurrency: "USD", + observedAt: "1970-01-01T00:00:00.000Z", + quoteCurrency: FiatToken.MXN, + rate: "17", + source: "fastforex" + }); + expect(pricing?.provider).toMatchObject({ + baseCurrency: EvmToken.USDT, + feeAmount: "0", + fees: [], + grossRate: "20", + netRate: "20", + quoteCurrency: FiatToken.MXN, + source: "alfredpay" + }); + expect(Number(pricing?.provider.grossReferenceDifferenceBps)).toBeCloseTo((20 / 17 - 1) * 10_000); + expect(Number(pricing?.provider.netReferenceDifferenceBps)).toBeCloseTo((20 / 17 - 1) * 10_000); + expect(Number(pricing?.customer.inputAmountUsd)).toBe(Number(quote.inputAmount)); + expect(Number(pricing?.customer.allInRate)).toBeCloseTo(Number(quote.outputAmount) / Number(quote.inputAmount)); + expect(Number(pricing?.customer.referenceDifferenceBps)).toBeCloseTo( + (Number(pricing?.customer.allInRate) / Number(pricing?.reference.rate) - 1) * 10_000 + ); }); it( diff --git a/docs/security-spec/03-ramp-engine/ramp-phase-flows.md b/docs/security-spec/03-ramp-engine/ramp-phase-flows.md index c423ea3c9..3ae28e65e 100644 --- a/docs/security-spec/03-ramp-engine/ramp-phase-flows.md +++ b/docs/security-spec/03-ramp-engine/ramp-phase-flows.md @@ -60,7 +60,7 @@ offramp block executors raise a recoverable, zero-retry pause at `brlaPayoutOnBa before reading partner state or broadcasting the anchor-bound transfer. The ramp remains in the payout phase and is not cleanup-eligible, leaving the client-custodied ephemeral key available for fund recovery. The switch is active only when `NODE_ENV=development`. -- **Catalog-backed Alfredpay offramp family:** USD/ACH, MXN/SPEI, COP/ACH, and ARS/CBU use `initial` → `squidRouterPermitExecute` → `fundEphemeral` → `finalSettlementSubsidy` → `alfredpayOfframpTransfer` → `distributeFees` → `complete` (flow version 2). The source preparer statically selects direct Polygon USDT, Polygon same-chain Squid, or cross-chain Squid. EIP-2612 sources emit permit/relayer typed data; unsupported tokens emit user-wallet transfer or approve/swap blueprints whose reported hashes are content-verified before funding. Final transfer and recovery fallback share Polygon nonce 0, fee-charging quotes place one `distributeFees` transfer per recipient at the following main-lane nonces, and `polygonCleanupAxlUsdc` comes last. +- **Catalog-backed Alfredpay offramp family:** USD/ACH, MXN/SPEI, COP/ACH, and ARS/CBU use `initial` → `squidRouterPermitExecute` → `fundEphemeral` → `finalSettlementSubsidy` → `alfredpayOfframpTransfer` → `distributeFees` → `complete` (flow version 3). The source preparer statically selects direct Polygon USDT, Polygon same-chain Squid, or cross-chain Squid. EIP-2612 sources emit permit/relayer typed data; unsupported tokens emit user-wallet transfer or approve/swap blueprints whose reported hashes are content-verified before funding. Final transfer and recovery fallback share Polygon nonce 0, fee-charging quotes place one `distributeFees` transfer per recipient at the following main-lane nonces, and `polygonCleanupAxlUsdc` comes last. Version 3 adds source-labelled reference, provider, and customer all-in pricing observations to the persisted block metadata without changing quote arithmetic or making the provider rate a global price source. - **Degenerate Polygon same-token onramp case:** Alfredpay mints `ALFREDPAY_EVM_TOKEN` (USDT) on Polygon. `AlfredpayOnrampDirect` composes a Squid passthrough block when the requested output is that same token and a same-chain Squid block for every other Polygon output. Both continue through `finalSettlementSubsidy`, `destinationTransfer`, and `distributeFees` (flow version 2). See `05-integrations/alfredpay.md`. - **Amount precision on routed Alfredpay onramps:** when Alfredpay mints on Polygon and the user requests a different EVM output token, the routed Squid output is the final settlement amount. `evmToEvm.inputAmountRaw` remains the Polygon source-token raw amount, while `evmToEvm.outputAmountRaw` and `quote.outputAmount` MUST use the final destination token's raw/decimal precision. The direct Polygon same-token case remains at the minted token's precision. - **Alfredpay offramp always runs `finalSettlementSubsidy`:** `phases/blocks/phases/alfredpay-offramp/index.ts` declares `fundEphemeral` → `finalSettlementSubsidy` → `alfredpayOfframpTransfer` for every source variant. No executor short-circuits this sequence. diff --git a/docs/security-spec/05-integrations/alfredpay.md b/docs/security-spec/05-integrations/alfredpay.md index 74509fb1f..7b76113f2 100644 --- a/docs/security-spec/05-integrations/alfredpay.md +++ b/docs/security-spec/05-integrations/alfredpay.md @@ -37,7 +37,7 @@ Alfredpay is a fiat payment provider supporting on-ramp and off-ramp operations For routed Alfredpay onramps (any non-passthrough output), the final quote output is the Squid destination-token amount. `quote.outputAmount` MUST be stored with the destination token's decimals, and `evmToEvm.outputAmountRaw` MUST preserve Squid's destination-token raw output. The Polygon-minted Alfredpay token remains the Squid source amount; the spec must not treat Polygon source-token decimals as final settlement precision. **Off-ramp flow:** -1. The catalog `AlfredpayOfframp` block stores provider quote facts under `metadata.blocks.alfredpayOfframp` and returns the provider expiration as the Vortex quote TTL. Its registration hook validates `fiatAccountId` and wallet address, resolves the authenticated KYC-approved Alfredpay customer, refreshes the provider quote with exact `toAmount` and fee equality, updates only that block's `quoteId`/expiration, and creates the order transactionally. Drift hard-fails registration. +1. The catalog `AlfredpayOfframp` block stores provider quote facts under `metadata.blocks.alfredpayOfframp` and returns the provider expiration as the Vortex quote TTL. Its `pricing` metadata records three separate observations: the source-labelled Vortex USD/fiat reference, Alfredpay's gross rate and fee-adjusted net rate, and the final customer all-in rate after Vortex pricing. These values are diagnostic; Alfredpay's rate does not replace the general Vortex conversion source. Its registration hook validates `fiatAccountId` and wallet address, resolves the authenticated KYC-approved Alfredpay customer, refreshes the provider quote with exact `toAmount` and fee equality, updates only that block's `quoteId`/expiration, and creates the order transactionally. Drift hard-fails registration. 2. `squidRouterPermitExecute` or `squidRouterNoPermitTransfer/Approve/Swap` phase: executes the user-signed permit (or the no-permit equivalent) and lands the Alfredpay on-chain token on Polygon. 3. `finalSettlementSubsidy` phase: always runs for Alfredpay offramps because `AlfredpayOfframp` declares it between funding and provider transfer for every source variant; its target is the Alfredpay deposit PLUS the charged vortex/partner fees so the later fee transfers stay funded. 4. `alfredpayOfframpTransfer` phase: transfers the Alfredpay on-chain token to Alfredpay's settlement address for fiat payout. If Alfredpay rejects the stored `quoteId` as expired, the handler requests a fresh provider quote at execute time and re-attempts (`alfredpayOfframpTransferFallback` phase records the re-attempt). @@ -76,6 +76,7 @@ For routed Alfredpay onramps (any non-passthrough output), the final quote outpu 24. **Reported Alfredpay usage MUST be user-scoped and provider-leg denominated** — `POST /v1/limits` derives the effective user from authentication and counts only that user's ramps whose `complete` phase-history timestamp falls in the current UTC calendar month. Routed BUY usage is the Alfredpay fiat input; routed SELL usage is `metadata.blocks.alfredpayOfframp.inputAmountDecimal` in `ALFREDPAY_EVM_TOKEN`, not the public source-token amount. This informational aggregate is cached in memory for 60 seconds; quote-time limit enforcement never reads that cache. Alfredpay does not document whether its cumulative quota resets by calendar month or uses a rolling window, so the calendar-month period is an explicit Vortex assumption rather than provider-confirmed semantics. 25. **A terminal verification outcome MUST be queued for notification before it is persisted** — Alfredpay publishes no verification webhook, so every observer that can make the customer terminal — the dashboard's shared refresh, `AlfredpayStatusWorker`, `/alfredpayStatus`, and `/getKycStatus` — MUST enqueue before its status write. An account written terminal while its enqueue failed could be excluded from every subsequent poll and never notified. A failure must leave the account non-terminal so a later poll retries both. The notification key is `(alfredpay, verification_*, submissionId)`, which makes retries and racing observers idempotent. See `resend.md` invariant 13. 26. **The background verification sweep MUST be bounded, fair, and MUST NOT poll accounts it cannot notify** — `AlfredpayStatusWorker` costs two to three Alfredpay calls per account (submission-id resolution, then status). It MUST bound the sweep by account age (60 days on `provider_customers.updatedAt`, since an account abandoned mid-wizard never reaches a terminal status) and by batch size. A stable keyset cursor advances after every full page and wraps at the end; repeatedly selecting only the newest page would starve older eligible accounts. Entities with a null `profile_id` are partner-owned and have no profile to email; they MUST be excluded in the query so they never consume provider requests. Only the `mykobo` flow-variant backend owns the provider status workers, and each cron uses `waitForCompletion`, preventing duplicate cross-backend polls and overlapping same-process cycles. +27. **Alfredpay offramp pricing observations MUST remain source-labelled and descriptive** — The persisted block metadata records the actual Vortex reference-feed source and observation time, Alfredpay's returned gross rate and fee breakdown, the provider net rate derived from `toAmount ÷ fromAmount`, and the customer all-in rate derived from final fiat output divided by the USD-valued quote input. These observations MUST NOT replace the normal Vortex price-conversion source or alter quote amounts, discounts, fees, or subsidies. ## Threat Vectors & Mitigations @@ -90,6 +91,7 @@ For routed Alfredpay onramps (any non-passthrough output), the final quote outpu | **Provider quote-quote-fall fallback abuse** | Attacker times provider quote drift between Vortex quote and ramp start to maximise the discount-engine fallback subsidy | Provider quote TTL is ~30s; `refreshAlfredpayOnrampQuoteIfMatching` only re-binds on byte-identical `toAmount`/`fee`; otherwise the fallback path is bounded by `maxSubsidy × expectedOutput` and only fires when `targetDiscount > 0` | | **Expired provider quote on offramp transfer** | Provider rejects the stored `quoteId` at transfer time, blocking settlement | `phases/blocks/phases/alfredpay-offramp/execution.ts` re-quotes at execute time and emits `alfredpayOfframpTransferFallback`; the Vortex `QuoteTicket` is untouched | | **Offramp quote drift at prep time** | Market moves between quote creation and ramp registration; the refreshed Alfredpay offramp quote has different `toAmount`/`fee` | `refreshAlfredpayOfframpQuoteIfMatching` compares `toAmount` and `fee` exactly; any drift throws `INTERNAL_SERVER_ERROR`, aborting registration. The user must re-quote. | +| **Offramp pricing source confusion** | Diagnostics present Alfredpay's executable rate as the general market reference, obscuring whether a difference comes from the reference feed, provider fees, or Vortex pricing | Persist separate source-labelled reference, provider gross/net, and customer all-in observations. Quote arithmetic continues to use the configured Vortex price feed. | | **Alfredpay offramp skipping subsidy** | An Alfredpay offramp reaches provider transfer without `finalSettlementSubsidy`, under-funding the settlement | The `AlfredpayOfframp` block declares subsidy before transfer for every source variant; flow tests pin the sequence | | **Polygon passthrough rounding** | Same-chain same-token shortcut rounds the bridge output incorrectly, leaking dust or under-funding the destination | `toFixed(0, 0)` round-down in the squid-router finalize; downstream subsidy ensures the destination receives the quoted amount | | **Polygon wrong-token delivery** | A user on-ramps via Alfredpay and requests a non-USDT Polygon output (e.g. USDC); the flow skips the swap on destination-network alone and transfers the minted USDT | `AlfredpayOnrampDirect` selects passthrough only for `ALFREDPAY_EVM_TOKEN`; non-USDT Polygon outputs compose `SameChainSquidRouterSwap` | @@ -118,6 +120,7 @@ For routed Alfredpay onramps (any non-passthrough output), the final quote outpu - [x] `AlfredpayMint.start` only re-binds the provider `quoteId` when `toAmount` and `fee` match byte-identically, creates the order once, and returns/persists the provider payment instructions through the generic flow lifecycle. **PASS** — block lifecycle tests. - [x] `AlfredpayOfframp.register` re-fetches a fresh provider quote, compares `toAmount` and `fee` exactly, updates only its own metadata identity/expiry, and throws on drift before order creation. **PASS** — block registration tests. - [x] `AlfredpayOfframp` always includes `finalSettlementSubsidy` before provider transfer. **PASS** — explicit phase list and flow tests. +- [x] Alfredpay offramp metadata separates the source-labelled Vortex reference, provider gross/net, and customer all-in rates without changing quote arithmetic. **PASS** — flow version 3 block metadata and MXN corridor coverage. - [x] AlfredPay offramp order is created by the block phase registration hook; `AlfredpayOfframp.start` retains the defensive validation-only no-op and is idempotent after registration. **PASS** — block lifecycle tests. - [x] Routed Alfredpay onramp quote output precision follows destination token decimals; direct Polygon same-token passthrough remains at minted-token precision. **PASS** — Alfredpay flow and transaction tests. - [x] Alfredpay onramp registration rejects missing customer context before customer lookup and requires a `Success` Alfredpay customer status. **PASS** — `phases/blocks/phases/alfredpay-mint/registration.ts`. diff --git a/packages/shared/src/helpers/ephemerals.ts b/packages/shared/src/helpers/ephemerals.ts index e6cdf95a4..34805193a 100644 --- a/packages/shared/src/helpers/ephemerals.ts +++ b/packages/shared/src/helpers/ephemerals.ts @@ -22,10 +22,10 @@ export function createMoonbeamEphemeral(): EphemeralAccount { } export async function createPendulumEphemeral(): Promise { + await cryptoWaitReady(); const seedPhrase = mnemonicGenerate(); const keyring = new Keyring({ type: "sr25519" }); - await cryptoWaitReady(); const ephemeralAccountKeypair = keyring.addFromUri(seedPhrase); return { address: ephemeralAccountKeypair.address, secret: seedPhrase }; diff --git a/packages/shared/src/services/alfredpay/schemas.test.ts b/packages/shared/src/services/alfredpay/schemas.test.ts index 9e1da1fe0..8cd414b5d 100644 --- a/packages/shared/src/services/alfredpay/schemas.test.ts +++ b/packages/shared/src/services/alfredpay/schemas.test.ts @@ -86,6 +86,16 @@ describe("alfredpayQuoteResponseSchema", () => { expect(() => alfredpayQuoteResponseSchema.parse(body)).toThrow(); }); + test("rejects missing pricing fields consumed by quote metadata", () => { + const missingRate = validQuoteBody(); + delete (missingRate as Record).rate; + expect(() => alfredpayQuoteResponseSchema.parse(missingRate)).toThrow(); + + const missingFeeType = validQuoteBody(); + delete (missingFeeType.fees[0] as Record).type; + expect(() => alfredpayQuoteResponseSchema.parse(missingFeeType)).toThrow(); + }); + test("rejects a non-decimal toAmount", () => { const body = validQuoteBody(); body.toAmount = "28,75"; diff --git a/packages/shared/src/services/alfredpay/schemas.ts b/packages/shared/src/services/alfredpay/schemas.ts index 85ec02509..9ea5aaa44 100644 --- a/packages/shared/src/services/alfredpay/schemas.ts +++ b/packages/shared/src/services/alfredpay/schemas.ts @@ -3,6 +3,7 @@ import { AlfredpayCustomerType } from "../../tokens/types/base"; import { AlfredpayConfigPair, AlfredpayFee, + AlfredpayFeeType, AlfredpayFiatAccount, AlfredpayFiatAccountType, AlfredpayFiatPaymentInstructions, @@ -33,8 +34,8 @@ type ConsumedConfigPair = Pick< AlfredpayConfigPair, "fromCurrency" | "toCurrency" | "minQuantity" | "maxQuantity" | "decimals" | "typeCustomer" >; -type ConsumedFee = Pick; -type ConsumedQuote = Pick & { +type ConsumedFee = Pick; +type ConsumedQuote = Pick & { fees: ConsumedFee[]; }; type ConsumedOnrampTransaction = Pick & { @@ -84,19 +85,21 @@ export const alfredpayConfigsResponseSchema = z.looseObject({ /** * The body of a POST …/quotes response, BUY and SELL alike — the consumed fields are - * direction-independent (`fromCurrency`/`toCurrency`/`rate` are never read back; Vortex - * trusts its own request there). + * direction-independent (`fromCurrency`/`toCurrency` are never read back; Vortex trusts + * its own request there). */ export const alfredpayQuoteResponseSchema = z.looseObject({ expiration: parseableTimestamp, fees: z.array( z.looseObject({ amount: z.string().regex(DECIMAL_STRING), - currency: z.string().min(1) + currency: z.string().min(1), + type: z.enum(AlfredpayFeeType) }) ), fromAmount: z.string().regex(DECIMAL_STRING), quoteId: z.string().min(1), + rate: z.string().regex(DECIMAL_STRING), toAmount: z.string().regex(DECIMAL_STRING) }) satisfies z.ZodType;