diff --git a/docs/openapi.yaml b/docs/openapi.yaml index 5319dd5..a7034a0 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -2625,16 +2625,76 @@ paths: '502': description: Provider unavailable or returned an error + /api/v1/fiat/quotes: + get: + tags: [fiat] + operationId: getBestExecutionFiatQuotes + summary: Get best-execution quotes across all healthy providers + description: > + Queries every healthy fiat provider in parallel (per-provider timeout; + one slow/unavailable provider never blocks the others) and returns a + ranked list plus the single best executable quote. Each quote carries + a structured fee breakdown — or `fees: null` with `unpriced: true` + when a provider cannot itemize its fee, never an assumed zero — and a + `quoteId` that can be passed to `POST /fiat/orders` to lock in that + exact rate for a bounded validity window (`expiresAt`). Providers that + errored or timed out are reported in `excluded` with a reason rather + than silently dropped. + security: + - BearerAuth: [] + parameters: + - in: query + name: direction + required: true + schema: + $ref: '#/components/schemas/FiatDirection' + - in: query + name: fiatAmount + required: true + schema: + type: number + minimum: 0 + - in: query + name: fiatCurrency + required: true + schema: + type: string + description: 3-letter ISO 4217 currency code + - in: query + name: assetSymbol + required: true + schema: + type: string + responses: + '200': + description: Ranked quotes + content: + application/json: + schema: + $ref: '#/components/schemas/BestExecutionQuoteResult' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '503': + description: > + No healthy fiat providers are available. Response body includes + `code: "no_healthy_providers"` and a per-provider failure reason. + /api/v1/fiat/orders: post: tags: [fiat] operationId: createFiatOrder summary: Create a fiat order description: > - Creates an on-ramp or off-ramp order with the active provider and - returns the order along with a provider checkout URL (and KYC URL if - the provider requires identity verification). The order settles only - after the on-chain crypto leg is independently confirmed. + Creates an on-ramp or off-ramp order and returns the order along with + a provider checkout URL (and KYC URL if the provider requires identity + verification). The order settles only after the on-chain crypto leg is + independently confirmed. The provider is resolved, in order of + precedence, from a locked `quoteId` (see `GET /fiat/quotes`), an + explicit `provider` preference, or the registry's default selection + policy — and is pinned to the order permanently: failover only ever + affects which provider a *new* order goes to. security: - BearerAuth: [] requestBody: @@ -2654,8 +2714,20 @@ paths: $ref: '#/components/responses/BadRequest' '401': $ref: '#/components/responses/Unauthorized' + '404': + description: quoteId does not reference a quote owned by the caller + '409': + description: > + `code: "quote_expired"` (locked quote's validity window has + passed — request a fresh quote), `code: "quote_already_used"`, or + `code: "quote_mismatch"` (order parameters don't match the locked + quote). '502': description: Provider unavailable or returned an error + '503': + description: > + No healthy fiat providers are available (`code: + "no_healthy_providers"`) get: tags: [fiat] operationId: listFiatOrders @@ -3897,6 +3969,24 @@ components: assetSymbol: type: string example: USDC + FeeBreakdown: + type: object + nullable: true + description: > + Structured fee breakdown in fiatCurrency. Any component the provider + does not report is `null` — never assumed to be zero. The whole object + is `null` when the provider cannot itemize its fee at all (see + `FiatQuote.unpriced`). + properties: + providerFee: + type: number + nullable: true + networkFee: + type: number + nullable: true + fxSpread: + type: number + nullable: true FiatQuote: type: object properties: @@ -3916,16 +4006,67 @@ components: example: USDC feeAmount: type: number + description: Deprecated — sum of fee components when known. Prefer `fees`. example: 1.5 - exchangeRate: + rate: type: number + description: Exchange rate used (crypto units per 1 fiat unit). example: 1.0 + rateSource: + type: string + enum: [PROVIDER, FX_FEED] + description: Where the exchange rate came from — never silently assumed. + fees: + $ref: '#/components/schemas/FeeBreakdown' + unpriced: + type: boolean + description: True when `fees` is null because the provider gave no breakdown. + requiresKyc: + type: boolean + description: True when this provider requires additional KYC for this pair. + providerQuoteId: + type: string + nullable: true provider: type: string example: moonpay expiresAt: type: string format: date-time + ExcludedProviderQuote: + type: object + properties: + provider: + type: string + reason: + type: string + description: Why this provider's quote could not be included (timeout, error, unsupported pair). + RankedQuote: + allOf: + - $ref: '#/components/schemas/FiatQuote' + - type: object + properties: + quoteId: + type: string + format: uuid + description: Pass as `quoteId` to POST /fiat/orders to lock this exact rate. + rank: + type: integer + description: 1 = best executable price among the providers that responded. + BestExecutionQuoteResult: + type: object + properties: + best: + $ref: '#/components/schemas/RankedQuote' + description: The best executable quote, or omitted/null when every provider failed. + quotes: + type: array + items: + $ref: '#/components/schemas/RankedQuote' + excluded: + type: array + items: + $ref: '#/components/schemas/ExcludedProviderQuote' CreateFiatOrderRequest: type: object required: [userId, direction, fiatAmount, fiatCurrency, assetSymbol] @@ -3945,6 +4086,19 @@ components: assetSymbol: type: string example: USDC + provider: + type: string + description: > + Preferred provider key. Ignored when `quoteId` is set (the quote + already pins a provider). Falls back to the registry's default + selection policy if the preferred provider is unhealthy. + example: moonpay + quoteId: + type: string + format: uuid + description: > + A `quoteId` from `GET /fiat/quotes` to lock in that exact rate. + Rejected with `quote_expired` past its validity window. FiatOrder: type: object properties: @@ -3994,6 +4148,35 @@ components: type: string format: date-time nullable: true + quoteRate: + type: number + nullable: true + description: Exchange rate captured from the quote at order-creation time. + quotedCryptoAmount: + type: number + nullable: true + description: Crypto amount promised by the quote at order-creation time. + fees: + $ref: '#/components/schemas/FeeBreakdown' + providerQuoteId: + type: string + nullable: true + rateLockExpiresAt: + type: string + format: date-time + nullable: true + settledRate: + type: number + nullable: true + description: Exchange rate realized at on-chain settlement. + settledCryptoAmount: + type: number + nullable: true + description: > + Crypto amount actually confirmed on-chain. Compare against + `quotedCryptoAmount` for the quoted-vs-settled delta; drift beyond + tolerance triggers an operational alert and a + `fiat.order.rate_mismatch` webhook. createdAt: type: string format: date-time diff --git a/package-lock.json b/package-lock.json index 1605470..9600bd5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1096,7 +1096,9 @@ } }, "node_modules/@eslint/eslintrc/node_modules/js-yaml": { - "version": "4.3.0", + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.1.tgz", + "integrity": "sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==", "dev": true, "funding": [ { @@ -1322,14 +1324,6 @@ "node": ">=8" } }, - "node_modules/@istanbuljs/load-nyc-config/node_modules/argparse": { - "version": "1.0.10", - "dev": true, - "license": "MIT", - "dependencies": { - "sprintf-js": "~1.0.2" - } - }, "node_modules/@istanbuljs/load-nyc-config/node_modules/find-up": { "version": "4.1.0", "dev": true, @@ -1343,12 +1337,23 @@ } }, "node_modules/@istanbuljs/load-nyc-config/node_modules/js-yaml": { - "version": "3.15.0", + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.1.tgz", + "integrity": "sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==", "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], "license": "MIT", "dependencies": { - "argparse": "^1.0.7", - "esprima": "^4.0.0" + "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" @@ -4726,9 +4731,9 @@ "license": "MIT" }, "node_modules/brace-expansion": { - "version": "5.0.8", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.8.tgz", - "integrity": "sha512-JZyDyq3D4AUifKTPOB7DELf6XsB3WdPuNxCtob1vFXPsSXhdAiHBWJ/tJ8HAc9aH84BK+5JFZLNkJKx3G9kzQg==", + "version": "5.0.9", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", + "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", "license": "MIT", "dependencies": { "balanced-match": "^4.0.2" @@ -5561,7 +5566,9 @@ } }, "node_modules/eslint/node_modules/js-yaml": { - "version": "4.3.0", + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.1.tgz", + "integrity": "sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==", "dev": true, "funding": [ { @@ -5608,18 +5615,6 @@ "url": "https://opencollective.com/eslint" } }, - "node_modules/esprima": { - "version": "4.0.1", - "dev": true, - "license": "BSD-2-Clause", - "bin": { - "esparse": "bin/esparse.js", - "esvalidate": "bin/esvalidate.js" - }, - "engines": { - "node": ">=4" - } - }, "node_modules/esquery": { "version": "1.7.0", "license": "BSD-3-Clause", @@ -6083,7 +6078,6 @@ }, "node_modules/fsevents": { "version": "2.3.3", - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -6614,9 +6608,9 @@ } }, "node_modules/ip-address": { - "version": "10.2.0", - "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz", - "integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==", + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.5.0.tgz", + "integrity": "sha512-R5SnVLJmgYYvf2F2ZgwSBnelz5G4q5AxIC277GDfUaNbrZKNANcBC7RHqYYePlszf4kBolVkJauG0ZjHHFh55g==", "license": "MIT", "engines": { "node": ">= 12" @@ -8932,11 +8926,6 @@ "source-map": "^0.6.0" } }, - "node_modules/sprintf-js": { - "version": "1.0.3", - "dev": true, - "license": "BSD-3-Clause" - }, "node_modules/stack-trace": { "version": "0.0.10", "resolved": "https://registry.npmjs.org/stack-trace/-/stack-trace-0.0.10.tgz", diff --git a/package.json b/package.json index e498c45..32b0f95 100644 --- a/package.json +++ b/package.json @@ -113,6 +113,16 @@ }, "overrides": { "axios": "^1.18.0", - "brace-expansion": "5.0.8" + "brace-expansion": "5.0.9", + "ip-address": "^10.5.0", + "eslint": { + "js-yaml": "^4.3.1" + }, + "@eslint/eslintrc": { + "js-yaml": "^4.3.1" + }, + "@istanbuljs/load-nyc-config": { + "js-yaml": "^4.3.1" + } } } diff --git a/prisma/migrations/20260728000000_add_sub_accounts/rollback.sql b/prisma/migrations/20260728000000_add_sub_accounts/rollback.sql new file mode 100644 index 0000000..6e10ed5 --- /dev/null +++ b/prisma/migrations/20260728000000_add_sub_accounts/rollback.sql @@ -0,0 +1,19 @@ +-- Rollback for 20260728000000_add_sub_accounts + +ALTER TABLE "sub_accounts" DROP CONSTRAINT IF EXISTS "sub_accounts_childUserId_fkey"; +ALTER TABLE "sub_accounts" DROP CONSTRAINT IF EXISTS "sub_accounts_parentUserId_fkey"; + +DROP INDEX IF EXISTS "agent_logs_actingAsUserId_idx"; +DROP INDEX IF EXISTS "transactions_actingAsUserId_idx"; +DROP INDEX IF EXISTS "sub_accounts_status_idx"; +DROP INDEX IF EXISTS "sub_accounts_childUserId_idx"; +DROP INDEX IF EXISTS "sub_accounts_parentUserId_idx"; +DROP INDEX IF EXISTS "sub_accounts_parentUserId_childUserId_key"; + +DROP TABLE IF EXISTS "sub_accounts"; + +ALTER TABLE "agent_logs" DROP COLUMN IF EXISTS "actingAsUserId"; +ALTER TABLE "transactions" DROP COLUMN IF EXISTS "actingAsUserId"; + +DROP TYPE IF EXISTS "SubAccountStatus"; +DROP TYPE IF EXISTS "SubAccountPermission"; diff --git a/prisma/migrations/20260817000000_add_fiat_multi_provider/migration.sql b/prisma/migrations/20260817000000_add_fiat_multi_provider/migration.sql new file mode 100644 index 0000000..c88877f --- /dev/null +++ b/prisma/migrations/20260817000000_add_fiat_multi_provider/migration.sql @@ -0,0 +1,35 @@ +-- AlterTable: rate-drift protection fields on fiat_orders (#313) +ALTER TABLE "fiat_orders" ADD COLUMN "quoteRate" DECIMAL(36,18); +ALTER TABLE "fiat_orders" ADD COLUMN "quotedCryptoAmount" DECIMAL(36,18); +ALTER TABLE "fiat_orders" ADD COLUMN "fees" JSONB; +ALTER TABLE "fiat_orders" ADD COLUMN "providerQuoteId" TEXT; +ALTER TABLE "fiat_orders" ADD COLUMN "rateLockExpiresAt" TIMESTAMP(3); +ALTER TABLE "fiat_orders" ADD COLUMN "settledRate" DECIMAL(36,18); +ALTER TABLE "fiat_orders" ADD COLUMN "settledCryptoAmount" DECIMAL(36,18); + +-- CreateTable: time-boxed provider-pinned quotes (#313) +CREATE TABLE "fiat_quote_locks" ( + "id" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "provider" TEXT NOT NULL, + "direction" "FiatDirection" NOT NULL, + "fiatAmount" DECIMAL(36,18) NOT NULL, + "fiatCurrency" TEXT NOT NULL, + "assetSymbol" TEXT NOT NULL, + "cryptoAmount" DECIMAL(36,18) NOT NULL, + "rate" DECIMAL(36,18), + "fees" JSONB, + "providerQuoteId" TEXT, + "expiresAt" TIMESTAMP(3) NOT NULL, + "consumedAt" TIMESTAMP(3), + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "fiat_quote_locks_pkey" PRIMARY KEY ("id") +); + +CREATE INDEX "fiat_quote_locks_userId_idx" ON "fiat_quote_locks"("userId"); +CREATE INDEX "fiat_quote_locks_expiresAt_idx" ON "fiat_quote_locks"("expiresAt"); +CREATE INDEX "fiat_quote_locks_provider_idx" ON "fiat_quote_locks"("provider"); + +ALTER TABLE "fiat_quote_locks" ADD CONSTRAINT "fiat_quote_locks_userId_fkey" + FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/migrations/20260817000000_add_fiat_multi_provider/rollback.sql b/prisma/migrations/20260817000000_add_fiat_multi_provider/rollback.sql new file mode 100644 index 0000000..b0a04ed --- /dev/null +++ b/prisma/migrations/20260817000000_add_fiat_multi_provider/rollback.sql @@ -0,0 +1,12 @@ +-- Rollback for 20260817000000_add_fiat_multi_provider + +ALTER TABLE "fiat_quote_locks" DROP CONSTRAINT IF EXISTS "fiat_quote_locks_userId_fkey"; +DROP TABLE IF EXISTS "fiat_quote_locks"; + +ALTER TABLE "fiat_orders" DROP COLUMN IF EXISTS "settledCryptoAmount"; +ALTER TABLE "fiat_orders" DROP COLUMN IF EXISTS "settledRate"; +ALTER TABLE "fiat_orders" DROP COLUMN IF EXISTS "rateLockExpiresAt"; +ALTER TABLE "fiat_orders" DROP COLUMN IF EXISTS "providerQuoteId"; +ALTER TABLE "fiat_orders" DROP COLUMN IF EXISTS "fees"; +ALTER TABLE "fiat_orders" DROP COLUMN IF EXISTS "quotedCryptoAmount"; +ALTER TABLE "fiat_orders" DROP COLUMN IF EXISTS "quoteRate"; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 7893ca1..7e31507 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -172,6 +172,7 @@ model User { agentLogs AgentLog[] webhookSubscriptions WebhookSubscription[] fiatOrders FiatOrder[] + fiatQuoteLocks FiatQuoteLock[] referralCode ReferralCode? referralConversion ReferralConversion? recurringDepositPlans RecurringDepositPlan[] @@ -603,6 +604,18 @@ model FiatOrder { kycUrl String? // provider KYC next-step link, surfaced to the user when required failureReason String? // populated on FAILED/REFUNDED for operator/user visibility settledAt DateTime? + // Best-execution / rate-drift protection (#313). quoteRate/quotedCryptoAmount + // and fees are captured from the locked quote at order-creation time so + // cryptoAmount is never an unexplained number; settledRate/settledCryptoAmount + // are populated at on-chain settlement so the quoted-vs-settled delta is + // always inspectable on the order itself. + quoteRate Decimal? @db.Decimal(36, 18) + quotedCryptoAmount Decimal? @db.Decimal(36, 18) + fees Json? // structured FeeBreakdown; null means the provider could not price it (unpriced, never assumed 0) + providerQuoteId String? + rateLockExpiresAt DateTime? + settledRate Decimal? @db.Decimal(36, 18) + settledCryptoAmount Decimal? @db.Decimal(36, 18) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@ -617,6 +630,34 @@ model FiatOrder { @@map("fiat_orders") } +/// A time-boxed, provider-pinned quote returned by the best-execution quote +/// flow (#313). Order creation may reference a lock's id (`quoteId`) instead of +/// re-quoting; the lock is consumed exactly once and rejected past `expiresAt` +/// with `quote_expired` so a stale rate is never silently honored. +model FiatQuoteLock { + id String @id @default(uuid()) + userId String + provider String + direction FiatDirection + fiatAmount Decimal @db.Decimal(36, 18) + fiatCurrency String + assetSymbol String + cryptoAmount Decimal @db.Decimal(36, 18) + rate Decimal? @db.Decimal(36, 18) + fees Json? + providerQuoteId String? + expiresAt DateTime + consumedAt DateTime? + createdAt DateTime @default(now()) + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@index([userId]) + @@index([expiresAt]) + @@index([provider]) + @@map("fiat_quote_locks") +} + /// A user's own single-level referral code (#growth). One code per user; the /// same code is returned on repeated requests. Sharing this code lets a new /// user attribute their signup to the owner. diff --git a/src/fiat/providers/moonpay.ts b/src/fiat/providers/moonpay.ts index 9685c81..20a8924 100644 --- a/src/fiat/providers/moonpay.ts +++ b/src/fiat/providers/moonpay.ts @@ -131,8 +131,21 @@ export class MoonPayProvider implements FiatRampProvider { data.quoteCurrencyAmount ?? data.cryptoAmount ?? 0 ) const feeAmount = Number(data.feeAmount ?? 0) || undefined + const networkFeeAmount = Number(data.networkFeeAmount ?? 0) || undefined const rate = Number(data.exchangeRate ?? data.rate ?? 0) || undefined + // MoonPay reports a total fee but doesn't itemize FX spread. We only + // populate the fields it actually gives us — never assume a zero for the + // rest — and label the quote unpriced when it gives us nothing at all. + const hasFeeData = feeAmount !== undefined || networkFeeAmount !== undefined + const fees = hasFeeData + ? { + providerFee: feeAmount ?? null, + networkFee: networkFeeAmount ?? null, + fxSpread: null, + } + : null + return { provider: this.name, direction: req.direction, @@ -142,6 +155,9 @@ export class MoonPayProvider implements FiatRampProvider { cryptoAmount, feeAmount, rate, + rateSource: 'PROVIDER', + fees, + unpriced: fees === null, } } diff --git a/src/fiat/providers/sandbox.ts b/src/fiat/providers/sandbox.ts new file mode 100644 index 0000000..07494c1 --- /dev/null +++ b/src/fiat/providers/sandbox.ts @@ -0,0 +1,161 @@ +/** + * Sandbox fiat ramp provider (#313). + * + * A second, fully self-contained {@link FiatRampProvider} implementation that + * exists to prove the multi-provider abstraction actually works: it can be + * quoted, ordered, and reconciled through exactly the same code paths as + * MoonPay, with no provider-specific branches anywhere outside this file. + * + * It makes no network calls — quotes are computed deterministically from a + * small illustrative rate table — so it's useful in tests, local development, + * and as a template for wiring a second real vendor. It is registered + * automatically outside production (see `registry.ts`); set + * FIAT_ENABLE_SANDBOX_PROVIDER=true|false to control it explicitly. + */ +import { createHmac, randomUUID, timingSafeEqual } from 'crypto' +import { logger } from '../../utils/logger' +import { + CreateOrderRequest, + CreateOrderResult, + FeeBreakdown, + FiatRampProvider, + ParsedWebhook, + QuoteRequest, + QuoteResult, +} from '../types' + +const PROVIDER_NAME = 'sandbox' + +/** Illustrative crypto-units-per-1-fiat-unit rates. Not sourced from a live feed. */ +const ASSET_RATES: Record = { + USDC: 1, + USDT: 1, + XLM: 4, +} + +function baseRateFor(assetSymbol: string): number { + return ASSET_RATES[assetSymbol.toUpperCase()] ?? 1 +} + +function computeFees(fiatAmount: number): FeeBreakdown { + const providerFeePct = Number(process.env.SANDBOX_PROVIDER_FEE_PCT || 0.005) + const networkFeeFlat = Number(process.env.SANDBOX_NETWORK_FEE_FLAT || 0.1) + return { + providerFee: Number((fiatAmount * providerFeePct).toFixed(6)), + networkFee: networkFeeFlat, + fxSpread: 0, + } +} + +function kycThreshold(): number { + return Number(process.env.SANDBOX_KYC_THRESHOLD || 1000) +} + +function timingSafeEqualHex(a: string, b: string): boolean { + if (a.length !== b.length) return false + try { + return timingSafeEqual(Buffer.from(a, 'hex'), Buffer.from(b, 'hex')) + } catch { + return false + } +} + +export class SandboxProvider implements FiatRampProvider { + readonly name = PROVIDER_NAME + + private readonly webhookKey: string + + constructor(opts?: { webhookKey?: string }) { + this.webhookKey = + opts?.webhookKey ?? + process.env.SANDBOX_WEBHOOK_KEY ?? + (process.env.NODE_ENV === 'production' + ? '' + : 'sandbox-dev-webhook-secret') + } + + async getQuote(req: QuoteRequest): Promise { + const rate = baseRateFor(req.assetSymbol) + const fees = computeFees(req.fiatAmount) + const feeAmount = (fees.providerFee ?? 0) + (fees.networkFee ?? 0) + + const cryptoAmount = + req.direction === 'ON_RAMP' + ? Math.max(0, req.fiatAmount - feeAmount) * rate + : (req.fiatAmount + feeAmount) * rate + + return { + provider: this.name, + direction: req.direction, + fiatAmount: req.fiatAmount, + fiatCurrency: req.fiatCurrency, + assetSymbol: req.assetSymbol, + cryptoAmount, + feeAmount, + rate, + rateSource: 'PROVIDER', + fees, + unpriced: false, + requiresKyc: req.fiatAmount > kycThreshold(), + providerQuoteId: `sandbox_q_${randomUUID()}`, + expiresAt: new Date(Date.now() + 60_000).toISOString(), + } + } + + async createOrder(req: CreateOrderRequest): Promise { + const providerOrderId = `sandbox_${randomUUID()}` + const requiresKyc = req.fiatAmount > kycThreshold() + const rate = baseRateFor(req.assetSymbol) + const fees = computeFees(req.fiatAmount) + const feeAmount = (fees.providerFee ?? 0) + (fees.networkFee ?? 0) + const cryptoAmount = + req.direction === 'ON_RAMP' + ? Math.max(0, req.fiatAmount - feeAmount) * rate + : (req.fiatAmount + feeAmount) * rate + + return { + providerOrderId, + checkoutUrl: `https://sandbox.fiat.local/checkout/${providerOrderId}`, + kycUrl: requiresKyc + ? `https://sandbox.fiat.local/kyc/${providerOrderId}` + : undefined, + status: requiresKyc ? 'KYC_REQUIRED' : 'PENDING', + cryptoAmount, + } + } + + verifyWebhookSignature( + rawBody: string, + headers: Record + ): boolean { + if (!this.webhookKey) { + logger.error( + '[Sandbox] SANDBOX_WEBHOOK_KEY not configured — rejecting webhook' + ) + return false + } + + const header = headers['x-sandbox-signature'] + if (!header || !header.startsWith('sha256=')) return false + const signature = header.slice('sha256='.length) + + const expected = createHmac('sha256', this.webhookKey) + .update(rawBody) + .digest('hex') + + return timingSafeEqualHex(expected, signature) + } + + parseWebhookPayload(rawBody: string): ParsedWebhook { + const data = JSON.parse(rawBody) as Record + return { + providerOrderId: String(data.providerOrderId ?? data.id ?? ''), + status: data.status ?? 'PENDING', + txHash: data.txHash ?? undefined, + cryptoAmount: + data.cryptoAmount != null ? Number(data.cryptoAmount) : undefined, + kycUrl: data.kycUrl ?? undefined, + reason: data.reason ?? undefined, + } + } +} diff --git a/src/fiat/registry.ts b/src/fiat/registry.ts index d116c9a..54b1313 100644 --- a/src/fiat/registry.ts +++ b/src/fiat/registry.ts @@ -1,28 +1,187 @@ /** - * Fiat provider registry (#290). + * Fiat provider registry (#290, extended #313 for multi-provider resilience). * * The single lookup point that maps a provider key to a {@link FiatRampProvider} * implementation. Route handlers and the reconciliation service resolve - * providers exclusively through here, so adding a second vendor is a one-line + * providers exclusively through here, so adding a vendor is a one-line * registry change with no edits to call sites. * - * `getDefaultProvider()` returns the configured active provider for new orders; - * `getProvider(name)` resolves the provider a stored order was created with, so - * webhooks/reconciliation always use the same vendor that opened the order. + * On top of the plain lookup, the registry now keeps a per-provider health + * ledger with circuit-breaker semantics matching {@link + * ../utils/http-client.ts} (closed → open after N consecutive failures → + * half-open after a reset window), and a selection policy so callers can ask + * for "the default provider", "the best-quoting provider", "the next healthy + * provider in rotation", or "my preferred provider, if it's up". + * + * `getDefaultProvider()` still returns the configured active provider for new + * orders; `getProvider(name)` resolves the provider a stored order was created + * with, so webhooks/reconciliation always use the same vendor that opened the + * order — failover only ever applies to picking a provider for a *new* order, + * never to an order already in flight. */ -import { FiatRampProvider } from './types' +import { setFiatProviderCircuitState } from '../utils/metrics' +import { + CircuitState, + FiatRampProvider, + NoHealthyProvidersError, + ProviderHealthSnapshot, + ProviderSelectionPolicy, +} from './types' import { MoonPayProvider } from './providers/moonpay' +import { SandboxProvider } from './providers/sandbox' const registry = new Map() +// ── Health ledger ───────────────────────────────────────────────────────────── + +interface HealthState { + state: CircuitState + consecutiveFailures: number + totalSuccess: number + totalFailure: number + lastFailureAt: number | null + lastSuccessAt: number | null +} + +const CIRCUIT_BREAKER_THRESHOLD = Number( + process.env.FIAT_PROVIDER_CIRCUIT_BREAKER_THRESHOLD || 5 +) +const CIRCUIT_BREAKER_RESET_MS = Number( + process.env.FIAT_PROVIDER_CIRCUIT_BREAKER_RESET_MS || 30_000 +) + +const health = new Map() +let roundRobinCursor = 0 + +function freshHealth(): HealthState { + return { + state: 'closed', + consecutiveFailures: 0, + totalSuccess: 0, + totalFailure: 0, + lastFailureAt: null, + lastSuccessAt: null, + } +} + +function healthFor(name: string): HealthState { + let h = health.get(name) + if (!h) { + h = freshHealth() + health.set(name, h) + } + return h +} + +/** Resolve a half-open transition lazily, mirroring HttpClientAdapter. */ +function refreshCircuitState(name: string): HealthState { + const h = healthFor(name) + if ( + h.state === 'open' && + h.lastFailureAt !== null && + Date.now() - h.lastFailureAt >= CIRCUIT_BREAKER_RESET_MS + ) { + h.state = 'half-open' + setFiatProviderCircuitState(name, 'half-open') + } + return h +} + +/** Record a successful provider call (quote or order creation). */ +export function recordProviderSuccess(name: string): void { + const h = healthFor(name) + h.totalSuccess++ + h.consecutiveFailures = 0 + h.lastSuccessAt = Date.now() + h.state = 'closed' + setFiatProviderCircuitState(name, 'closed') +} + +/** Record a failed provider call (quote or order creation). */ +export function recordProviderFailure(name: string): void { + const h = healthFor(name) + h.totalFailure++ + h.consecutiveFailures++ + h.lastFailureAt = Date.now() + if (h.consecutiveFailures >= CIRCUIT_BREAKER_THRESHOLD) { + h.state = 'open' + setFiatProviderCircuitState(name, 'open') + } +} + +/** Whether a provider is currently eligible for new quote/order traffic. */ +export function isProviderHealthy(name: string): boolean { + return refreshCircuitState(name).state !== 'open' +} + +export function getProviderHealth(name: string): ProviderHealthSnapshot { + const h = refreshCircuitState(name) + return { + provider: name, + state: h.state, + consecutiveFailures: h.consecutiveFailures, + totalSuccess: h.totalSuccess, + totalFailure: h.totalFailure, + lastFailureAt: h.lastFailureAt + ? new Date(h.lastFailureAt).toISOString() + : null, + lastSuccessAt: h.lastSuccessAt + ? new Date(h.lastSuccessAt).toISOString() + : null, + healthy: h.state !== 'open', + } +} + +export function getAllProviderHealth(): ProviderHealthSnapshot[] { + return Array.from(registry.keys()).map(getProviderHealth) +} + +/** + * Admin/operator override (#313): force a provider's circuit open (manual + * failover) or closed (manual recovery). Bypasses the failure-count threshold + * so operators can react before the automatic breaker would trip/reset. + */ +export function adminSetProviderCircuit( + name: string, + state: 'open' | 'closed' +): ProviderHealthSnapshot { + if (!registry.has(name)) { + throw new Error(`Unknown fiat provider: "${name}"`) + } + const h = healthFor(name) + h.state = state + if (state === 'closed') { + h.consecutiveFailures = 0 + } + setFiatProviderCircuitState(name, state) + return getProviderHealth(name) +} + +// ── Registration ──────────────────────────────────────────────────────────── + function register(provider: FiatRampProvider): void { registry.set(provider.name, provider) + if (!health.has(provider.name)) { + health.set(provider.name, freshHealth()) + } } -// v1 ships a single provider. Add further vendors here — nothing else changes. register(new MoonPayProvider()) -/** The provider key used for newly created orders. */ +// The sandbox provider is a documented, deterministic second implementation +// proving the multi-provider abstraction actually works end-to-end (#313). It +// is registered by default outside production; set FIAT_ENABLE_SANDBOX_PROVIDER +// explicitly to control it in any environment. +const sandboxFlag = process.env.FIAT_ENABLE_SANDBOX_PROVIDER +const sandboxEnabled = + sandboxFlag != null + ? sandboxFlag === 'true' + : process.env.NODE_ENV !== 'production' +if (sandboxEnabled) { + register(new SandboxProvider()) +} + +/** The provider key used for newly created orders absent any other signal. */ export function defaultProviderName(): string { return process.env.FIAT_DEFAULT_PROVIDER || 'moonpay' } @@ -36,11 +195,73 @@ export function getProvider(name: string): FiatRampProvider { return provider } -/** Resolve the active default provider for new orders. */ +/** Resolve the active default provider for new orders (ignores health). */ export function getDefaultProvider(): FiatRampProvider { return getProvider(defaultProviderName()) } +/** All registered providers, healthy or not. */ +export function getAllProviders(): FiatRampProvider[] { + return Array.from(registry.values()) +} + +/** Providers currently eligible for new traffic (circuit not open). */ +export function getHealthyProviders(): FiatRampProvider[] { + return getAllProviders().filter((p) => isProviderHealthy(p.name)) +} + +export interface SelectProviderOptions { + policy?: ProviderSelectionPolicy + /** Required for PREFER_PROVIDER; used as a tiebreak hint for BEST_QUOTE callers that already resolved one. */ + preferredProvider?: string +} + +/** + * Resolve a single provider for a *new* order/quote per the requested + * selection policy. Never returns an unhealthy provider unless it is the last + * one left in the registry (better to try and surface the provider's own + * error than to hard-fail before attempting anything). + * + * BEST_QUOTE cannot be resolved here in isolation — it requires comparing + * live quotes — so callers using that policy should run the best-execution + * quote flow (see `fiat/service.ts`) and pass the winning provider name back + * in as PREFER_PROVIDER, or rely on a `quoteId` which already pins a provider. + */ +export function selectProviderForOrder( + opts: SelectProviderOptions = {} +): FiatRampProvider { + const policy = opts.policy ?? 'DEFAULT' + const healthy = getHealthyProviders() + + if (healthy.length === 0) { + throw new NoHealthyProvidersError( + getAllProviderHealth().map((h) => ({ + provider: h.provider, + reason: `circuit ${h.state} after ${h.consecutiveFailures} consecutive failures`, + })) + ) + } + + if (policy === 'PREFER_PROVIDER' && opts.preferredProvider) { + const preferred = healthy.find((p) => p.name === opts.preferredProvider) + if (preferred) return preferred + // Preferred provider is unhealthy/unknown among healthy ones — fall through + // to DEFAULT semantics rather than failing the whole request. + } + + if (policy === 'ROUND_ROBIN_HEALTHY') { + const provider = healthy[roundRobinCursor % healthy.length] + roundRobinCursor = (roundRobinCursor + 1) % healthy.length + return provider + } + + // DEFAULT and BEST_QUOTE (as a fallback when the caller hasn't already + // resolved a winner) both prefer the configured default provider when it's + // healthy, and otherwise fail over to the first healthy alternative. + const preferredDefault = healthy.find((p) => p.name === defaultProviderName()) + return preferredDefault ?? healthy[0] +} + /** * Test/bootstrap seam: replace or add a provider implementation. Used by unit * tests to inject a stub without going through env configuration. @@ -48,3 +269,12 @@ export function getDefaultProvider(): FiatRampProvider { export function registerProvider(provider: FiatRampProvider): void { register(provider) } + +/** Test seam: reset all provider health state back to closed/zeroed. */ +export function resetProviderHealth(): void { + health.clear() + roundRobinCursor = 0 + for (const name of registry.keys()) { + health.set(name, freshHealth()) + } +} diff --git a/src/fiat/service.ts b/src/fiat/service.ts index 75230b9..1742578 100644 --- a/src/fiat/service.ts +++ b/src/fiat/service.ts @@ -1,5 +1,6 @@ /** - * Fiat on-ramp / off-ramp service (#290). + * Fiat on-ramp / off-ramp service (#290, extended #313 for multi-provider + * best-execution and rate-drift protection). * * Design constraints baked in here: * @@ -20,37 +21,260 @@ * * 4. Refund/failed handling. FAILED/REFUNDED are terminal; we persist the * reason for user + operator visibility and emit an outbound webhook event. + * + * 5. Best execution (#313). GET-quote callers see every healthy provider's + * price in parallel, ranked. Order creation either pins a provider + * directly, references a time-boxed FiatQuoteLock from that ranked list, + * or falls back to the registry's selection policy — but once an order + * exists it is pinned to the provider that created it forever; failover + * only ever changes which provider a *new* order/quote goes to. + * + * 6. Rate-drift protection (#313). The quote captured at order creation + * (quoteRate/quotedCryptoAmount/fees) and the amount actually confirmed + * on-chain (settledRate/settledCryptoAmount) are both persisted, so a + * worse-than-quoted settlement is never silently absorbed — drift beyond + * tolerance raises an operational alert and a `fiat.order.rate_mismatch` + * webhook. Over-delivery (a better-than-quoted settlement) is credited to + * the user, not capped — it's still reported for audit visibility. */ import db from '../db' import { logger } from '../utils/logger' import { dispatchWebhookEvent } from '../services/webhookDispatcher' import { alertingService } from '../services/alerting' -import { getDefaultProvider, getProvider } from './registry' +import { + getDefaultProvider, + getProvider, + getHealthyProviders, + getAllProviderHealth, + recordProviderSuccess, + recordProviderFailure, + selectProviderForOrder, +} from './registry' +import { + recordFiatQuoteLatency, + recordFiatQuoteFailure, + recordFiatOrder, + recordFiatRateDrift, +} from '../utils/metrics' import type { CreateFiatOrderInput, FiatQuoteInput, } from '../validators/fiat-validators' -import type { NormalizedWebhookStatus, ParsedWebhook } from './types' +import type { + BestExecutionQuoteResult, + ExcludedProviderQuote, + FiatDirection, + NormalizedWebhookStatus, + ParsedWebhook, + ProviderSelectionPolicy, + QuoteResult, + RankedQuote, +} from './types' +import { NoHealthyProvidersError, FiatOrderError } from './types' /** How long a PENDING/PROCESSING order may sit before the age-out job fails it. */ export const STALE_ORDER_MAX_AGE_MS = Number( process.env.FIAT_STALE_ORDER_MAX_AGE_MS || 24 * 60 * 60 * 1000 ) +/** How long a quote returned by getBestExecutionQuote stays honorable (#313). */ +export const QUOTE_LOCK_TTL_MS = Number( + process.env.FIAT_QUOTE_LOCK_TTL_MS || 60_000 +) + +/** Per-provider timeout for a single quote request in the parallel fan-out. */ +const QUOTE_PROVIDER_TIMEOUT_MS = Number( + process.env.FIAT_QUOTE_PROVIDER_TIMEOUT_MS || 8_000 +) + +/** Beyond this |settled - quoted| / quoted, emit a rate-drift alert + webhook. */ +export const RATE_DRIFT_TOLERANCE_PCT = Number( + process.env.FIAT_RATE_DRIFT_TOLERANCE_PCT || 0.02 +) + +/** Beyond this drift, escalate to critical — flags the order for manual re-quote/refund review. */ +export const RATE_DRIFT_CRITICAL_PCT = Number( + process.env.FIAT_RATE_DRIFT_CRITICAL_PCT || 0.1 +) + +/** + * How close a candidate on-chain transaction's amount must be to an order's + * quoted crypto amount to be considered a match during reconciliation. + * Deliberately looser than RATE_DRIFT_TOLERANCE_PCT (a legitimate settlement + * may itself drift a little) — this exists to stop the "any unlinked + * confirmed transaction for this user+asset" heuristic from cross-linking two + * different providers' concurrent orders for the same user/asset (#313). + */ +export const RECONCILE_AMOUNT_TOLERANCE_PCT = Number( + process.env.FIAT_RECONCILE_AMOUNT_TOLERANCE_PCT || 0.05 +) + type Db = typeof db +export { FiatOrderError } + // ── Quotes ──────────────────────────────────────────────────────────────────── export async function getFiatQuote(input: FiatQuoteInput) { - const provider = getDefaultProvider() - return provider.getQuote({ - direction: input.direction, - fiatAmount: input.fiatAmount, - fiatCurrency: input.fiatCurrency, - assetSymbol: input.assetSymbol, + const provider = selectProviderForOrder({ policy: 'DEFAULT' }) + try { + const quote = await provider.getQuote({ + direction: input.direction, + fiatAmount: input.fiatAmount, + fiatCurrency: input.fiatCurrency, + assetSymbol: input.assetSymbol, + }) + recordProviderSuccess(provider.name) + return quote + } catch (err) { + recordProviderFailure(provider.name) + throw err + } +} + +/** Race a provider quote against a timeout so one slow vendor never blocks the fan-out. */ +async function quoteWithTimeout( + providerName: string, + fn: () => Promise, + timeoutMs: number +): Promise { + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + reject(new Error(`Quote request timed out after ${timeoutMs}ms`)) + }, timeoutMs) + fn() + .then((r) => { + clearTimeout(timer) + resolve(r) + }) + .catch((err) => { + clearTimeout(timer) + reject(err) + }) }) } +/** Best executable quote for the given direction: higher cryptoAmount is better on-ramp, lower is better off-ramp. */ +function isBetterQuote( + a: QuoteResult, + b: QuoteResult, + direction: FiatDirection +): boolean { + if (direction === 'ON_RAMP') return a.cryptoAmount > b.cryptoAmount + return a.cryptoAmount < b.cryptoAmount +} + +/** + * Query every healthy provider in parallel, normalize + rank the results, and + * persist a time-boxed {@link FiatQuoteLock} per successful quote so the + * caller (or a subsequent order-creation call) can reference one by id. + * + * Never throws for partial failure — a provider that times out or errors is + * moved to `excluded` with a reason and the rest of the ranking proceeds. + * Throws {@link NoHealthyProvidersError} only when no provider could be + * reached at all. + */ +export async function getBestExecutionQuote( + input: FiatQuoteInput, + ctx: { userId: string }, + database: Db = db +): Promise { + const providers = getHealthyProviders() + + if (providers.length === 0) { + throw new NoHealthyProvidersError( + getAllProviderHealth().map((h) => ({ + provider: h.provider, + reason: `circuit ${h.state} after ${h.consecutiveFailures} consecutive failures`, + })) + ) + } + + const settled = await Promise.allSettled( + providers.map(async (provider) => { + const start = Date.now() + try { + const quote = await quoteWithTimeout( + provider.name, + () => + provider.getQuote({ + direction: input.direction, + fiatAmount: input.fiatAmount, + fiatCurrency: input.fiatCurrency, + assetSymbol: input.assetSymbol, + }), + QUOTE_PROVIDER_TIMEOUT_MS + ) + recordProviderSuccess(provider.name) + recordFiatQuoteLatency( + provider.name, + input.direction, + (Date.now() - start) / 1000 + ) + return quote + } catch (err) { + recordProviderFailure(provider.name) + recordFiatQuoteLatency( + provider.name, + input.direction, + (Date.now() - start) / 1000 + ) + const reason = err instanceof Error ? err.message : String(err) + recordFiatQuoteFailure(provider.name, reason) + throw new Error(`${provider.name}: ${reason}`) + } + }) + ) + + const successes: QuoteResult[] = [] + const excluded: ExcludedProviderQuote[] = [] + + settled.forEach((result, i) => { + if (result.status === 'fulfilled') { + successes.push(result.value) + } else { + const providerName = providers[i].name + const reason = + result.reason instanceof Error + ? result.reason.message + : String(result.reason) + excluded.push({ provider: providerName, reason }) + } + }) + + successes.sort((a, b) => (isBetterQuote(a, b, input.direction) ? -1 : 1)) + + const expiresAt = new Date(Date.now() + QUOTE_LOCK_TTL_MS) + const ranked: RankedQuote[] = [] + + for (let i = 0; i < successes.length; i++) { + const q = successes[i] + const lock = await (database as any).fiatQuoteLock.create({ + data: { + userId: ctx.userId, + provider: q.provider, + direction: q.direction, + fiatAmount: q.fiatAmount, + fiatCurrency: q.fiatCurrency, + assetSymbol: q.assetSymbol, + cryptoAmount: q.cryptoAmount, + rate: q.rate ?? null, + fees: q.fees as any, + providerQuoteId: q.providerQuoteId ?? null, + expiresAt, + }, + }) + ranked.push({ + ...q, + quoteId: lock.id, + expiresAt: expiresAt.toISOString(), + rank: i + 1, + }) + } + + return { best: ranked[0] ?? null, quotes: ranked, excluded } +} + // ── Order creation ────────────────────────────────────────────────────────── export interface CreateOrderContext { @@ -58,21 +282,119 @@ export interface CreateOrderContext { walletAddress: string } +async function resolveOrderProvider( + input: CreateFiatOrderInput, + database: Db +): Promise<{ + providerName: string + quoteRate: number | null + quotedCryptoAmount: number | null + fees: unknown + providerQuoteId: string | null + rateLockExpiresAt: Date | null + lockId: string | null +}> { + if (input.quoteId) { + const lock = await (database as any).fiatQuoteLock.findUnique({ + where: { id: input.quoteId }, + }) + + if (!lock || lock.userId !== input.userId) { + throw new FiatOrderError('quote_not_found', 404, 'Quote not found') + } + if (lock.consumedAt) { + throw new FiatOrderError( + 'quote_already_used', + 409, + 'This quote has already been used to create an order' + ) + } + if (new Date(lock.expiresAt).getTime() < Date.now()) { + throw new FiatOrderError( + 'quote_expired', + 409, + 'Quote is no longer valid — request a fresh quote', + { freshQuoteUrl: '/api/v1/fiat/quotes' } + ) + } + if ( + lock.direction !== input.direction || + lock.fiatCurrency !== input.fiatCurrency || + lock.assetSymbol !== input.assetSymbol || + Number(lock.fiatAmount) !== input.fiatAmount + ) { + throw new FiatOrderError( + 'quote_mismatch', + 409, + 'Order parameters do not match the locked quote' + ) + } + + return { + providerName: lock.provider, + quoteRate: lock.rate != null ? Number(lock.rate) : null, + quotedCryptoAmount: Number(lock.cryptoAmount), + fees: lock.fees, + providerQuoteId: lock.providerQuoteId, + rateLockExpiresAt: lock.expiresAt, + lockId: lock.id, + } + } + + const policy = (process.env.FIAT_PROVIDER_SELECTION_POLICY || + 'DEFAULT') as ProviderSelectionPolicy + const provider = selectProviderForOrder({ + policy: input.provider ? 'PREFER_PROVIDER' : policy, + preferredProvider: input.provider, + }) + + try { + const quote = await provider.getQuote({ + direction: input.direction, + fiatAmount: input.fiatAmount, + fiatCurrency: input.fiatCurrency, + assetSymbol: input.assetSymbol, + }) + recordProviderSuccess(provider.name) + return { + providerName: provider.name, + quoteRate: quote.rate ?? null, + quotedCryptoAmount: quote.cryptoAmount, + fees: quote.fees as any, + providerQuoteId: quote.providerQuoteId ?? null, + rateLockExpiresAt: new Date(Date.now() + QUOTE_LOCK_TTL_MS), + lockId: null, + } + } catch (err) { + recordProviderFailure(provider.name) + throw err + } +} + export async function createFiatOrder( input: CreateFiatOrderInput, ctx: CreateOrderContext, database: Db = db ) { - const provider = getDefaultProvider() + const resolved = await resolveOrderProvider(input, database) + const provider = getProvider(resolved.providerName) - const created = await provider.createOrder({ - userId: input.userId, - direction: input.direction, - fiatAmount: input.fiatAmount, - fiatCurrency: input.fiatCurrency, - assetSymbol: input.assetSymbol, - walletAddress: ctx.walletAddress, - }) + let created + try { + created = await provider.createOrder({ + userId: input.userId, + direction: input.direction, + fiatAmount: input.fiatAmount, + fiatCurrency: input.fiatCurrency, + assetSymbol: input.assetSymbol, + walletAddress: ctx.walletAddress, + }) + recordProviderSuccess(provider.name) + } catch (err) { + recordProviderFailure(provider.name) + recordFiatOrder(provider.name, 'CREATE_FAILED') + throw err + } const initialStatus = mapToOrderStatus(created.status) @@ -84,14 +406,38 @@ export async function createFiatOrder( direction: input.direction, fiatAmount: input.fiatAmount, fiatCurrency: input.fiatCurrency, - cryptoAmount: created.cryptoAmount ?? null, + cryptoAmount: created.cryptoAmount ?? resolved.quotedCryptoAmount ?? null, assetSymbol: input.assetSymbol, status: initialStatus, checkoutUrl: created.checkoutUrl ?? null, kycUrl: created.kycUrl ?? null, + quoteRate: resolved.quoteRate, + quotedCryptoAmount: resolved.quotedCryptoAmount, + fees: resolved.fees as any, + providerQuoteId: resolved.providerQuoteId, + rateLockExpiresAt: resolved.rateLockExpiresAt, }, }) + if (resolved.lockId) { + await (database as any).fiatQuoteLock + .update({ + where: { id: resolved.lockId }, + data: { consumedAt: new Date() }, + }) + .catch((err: unknown) => { + // Non-fatal: the order is already created. Worst case the same quote + // could be raced onto a second order — logged for investigation. + logger.error('[Fiat] Failed to mark quote lock consumed', { + quoteId: resolved.lockId, + orderId: order.id, + error: err instanceof Error ? err.message : String(err), + }) + }) + } + + recordFiatOrder(provider.name, initialStatus) + logger.info('[Fiat] Order created', { orderId: order.id, provider: provider.name, @@ -119,6 +465,10 @@ export interface ProcessWebhookResult { * (SETTLED/FAILED/REFUNDED) are never overwritten, and a provider "completed" * callback only advances the order to PROCESSING — never SETTLED — because * on-chain confirmation is authoritative (see reconcileFiatOrders). + * + * Keyed on (provider, providerOrderId) — a webhook from provider A can never + * mutate an order created by provider B, even if the providerOrderId strings + * happened to collide, because the unique constraint is on the pair (#313). */ export async function processProviderWebhook( providerName: string, @@ -199,6 +549,7 @@ export async function processProviderWebhook( // Emit outbound webhook for terminal failure/refund so subscribers react. if (updated.status === 'FAILED' || updated.status === 'REFUNDED') { + recordFiatOrder(providerName, updated.status) dispatchWebhookEvent('fiat.order.failed', { orderId: updated.id, provider: providerName, @@ -231,6 +582,14 @@ export async function processProviderWebhook( * Try to settle one order against a specific claimed on-chain tx hash. * Settles only when a CONFIRMED Transaction row exists for that hash — i.e. * the Stellar event listener has independently observed the crypto leg. + * + * Also computes and persists the quoted-vs-settled delta (#313): when the + * order carries a quotedCryptoAmount, the realized settledCryptoAmount/ + * settledRate are compared against it, and drift beyond + * RATE_DRIFT_TOLERANCE_PCT raises an operational alert plus a + * `fiat.order.rate_mismatch` webhook. Over-delivery is credited (the order's + * original `cryptoAmount` — what the user was promised — is never reduced); + * this only ever adds visibility, never claws back a better-than-quoted fill. */ export async function reconcileSingleOrder( orderId: string, @@ -257,6 +616,22 @@ export async function reconcileSingleOrder( return false } + const settledCryptoAmount = Number(tx.amount) + const quotedCryptoAmount = + order.quotedCryptoAmount != null + ? Number(order.quotedCryptoAmount) + : order.cryptoAmount != null + ? Number(order.cryptoAmount) + : null + const fiatAmount = Number(order.fiatAmount) + + let settledRate: number | null = null + let driftPct: number | null = null + if (quotedCryptoAmount && quotedCryptoAmount > 0) { + driftPct = (settledCryptoAmount - quotedCryptoAmount) / quotedCryptoAmount + settledRate = fiatAmount > 0 ? settledCryptoAmount / fiatAmount : null + } + const settled = await (database as any).fiatOrder.update({ where: { id: order.id }, data: { @@ -264,14 +639,19 @@ export async function reconcileSingleOrder( transactionId: tx.id, settledAt: new Date(), cryptoAmount: order.cryptoAmount ?? tx.amount, + settledCryptoAmount, + settledRate, }, }) logger.info('[Fiat] Order settled via on-chain confirmation', { orderId: settled.id, txHash, + driftPct, }) + recordFiatOrder(order.provider, 'SETTLED') + dispatchWebhookEvent('fiat.order.settled', { orderId: settled.id, provider: settled.provider, @@ -281,6 +661,46 @@ export async function reconcileSingleOrder( userId: settled.userId, }).catch(() => {}) + if (driftPct !== null) { + recordFiatRateDrift(order.provider, order.direction, Math.abs(driftPct)) + + if (Math.abs(driftPct) > RATE_DRIFT_TOLERANCE_PCT) { + const critical = Math.abs(driftPct) > RATE_DRIFT_CRITICAL_PCT + alertingService + .emit( + { + title: 'Fiat order settled with rate drift beyond tolerance', + description: + `Order ${order.id} (${order.provider}) settled ${(driftPct * 100).toFixed(2)}% ` + + `${driftPct < 0 ? 'below' : 'above'} the quoted crypto amount.` + + (critical + ? ' Drift exceeds the critical threshold — review for re-quote/refund.' + : ''), + severity: critical ? 'critical' : 'warning', + component: 'fiat-settlement', + metadata: { + orderId: order.id, + provider: order.provider, + direction: order.direction, + driftPct, + }, + }, + `fiat:drift:${order.id}` + ) + .catch(() => {}) + + dispatchWebhookEvent('fiat.order.rate_mismatch', { + orderId: order.id, + provider: order.provider, + direction: order.direction, + quotedCryptoAmount, + settledCryptoAmount, + driftPct, + userId: order.userId, + }).catch(() => {}) + } + } + return true } @@ -302,10 +722,16 @@ export async function reconcileFiatOrders(database: Db = db): Promise<{ let settled = 0 for (const order of processing) { - // Match on any CONFIRMED transaction for this user + asset that isn't - // already linked to another fiat order. Provider-claimed hashes are handled - // inline at webhook time; here we catch lost-webhook / async-settlement. - const candidate = await (database as any).transaction.findFirst({ + // Match against unlinked CONFIRMED transactions for this user + asset. + // Provider-claimed hashes are handled inline at webhook time; here we + // catch lost-webhook / async-settlement. With multiple providers able to + // have concurrent PROCESSING orders for the same user + asset, matching + // on "most recent unlinked" alone can cross-link an order to a + // transaction that actually belongs to a *different* provider's order — + // so once an order has a quoted crypto amount on record, only a + // transaction whose amount falls within RECONCILE_AMOUNT_TOLERANCE_PCT of + // that quote is eligible (#313). + const candidates = await (database as any).transaction.findMany({ where: { userId: order.userId, assetSymbol: order.assetSymbol, @@ -313,8 +739,44 @@ export async function reconcileFiatOrders(database: Db = db): Promise<{ fiatOrders: { none: {} }, }, orderBy: { confirmedAt: 'desc' }, + take: 20, }) + const expectedAmount = + order.quotedCryptoAmount != null + ? Number(order.quotedCryptoAmount) + : order.cryptoAmount != null + ? Number(order.cryptoAmount) + : null + + let candidate: any = null + if (expectedAmount != null && expectedAmount > 0) { + candidate = + candidates.find((c: any) => { + const amt = Number(c.amount) + return ( + Math.abs(amt - expectedAmount) / expectedAmount <= + RECONCILE_AMOUNT_TOLERANCE_PCT + ) + }) ?? null + + if (!candidate && candidates.length > 0) { + logger.warn( + '[Fiat] Confirmed transactions exist for user+asset but none match this order within tolerance — refusing to cross-link', + { + orderId: order.id, + provider: order.provider, + expectedAmount, + candidateCount: candidates.length, + } + ) + } + } else { + // Legacy order with no recorded quote amount — degraded match against + // the most recent unlinked transaction, same as pre-#313 behavior. + candidate = candidates[0] ?? null + } + if (candidate) { const ok = await reconcileSingleOrder( order.id, @@ -335,7 +797,8 @@ export async function reconcileFiatOrders(database: Db = db): Promise<{ title: 'Fiat order stuck in PROCESSING without on-chain settlement', description: `Order ${order.id} (${order.provider}/${order.providerOrderId}) has been ` + - `PROCESSING for ${Math.round(ageMs / 3_600_000)}h with no confirmed on-chain transaction.`, + `PROCESSING for ${Math.round(ageMs / 3_600_000)}h with no confirmed on-chain transaction. ` + + `Quoted crypto amount: ${expectedAmount ?? 'unknown'}.`, severity: 'critical', component: 'fiat-reconciliation', metadata: { @@ -343,6 +806,7 @@ export async function reconcileFiatOrders(database: Db = db): Promise<{ provider: order.provider, providerOrderId: order.providerOrderId, userId: order.userId, + quotedCryptoAmount: expectedAmount, }, }, `fiat:stuck:${order.id}` @@ -413,4 +877,4 @@ function isTerminal(status: string): boolean { return status === 'SETTLED' || status === 'FAILED' || status === 'REFUNDED' } -export { getProvider } +export { getProvider, getDefaultProvider } diff --git a/src/fiat/types.ts b/src/fiat/types.ts index 05dc509..f089785 100644 --- a/src/fiat/types.ts +++ b/src/fiat/types.ts @@ -1,5 +1,5 @@ /** - * Fiat on-ramp / off-ramp provider abstraction (#290). + * Fiat on-ramp / off-ramp provider abstraction (#290, extended #313). * * All provider-specific logic MUST live behind the {@link FiatRampProvider} * interface so a second vendor can be added without touching route handlers or @@ -16,6 +16,20 @@ export interface QuoteRequest { assetSymbol: string } +/** + * Structured fee breakdown (#313). A provider that cannot itemize its fee MUST + * leave the whole {@link QuoteResult.fees} field `null` (see `unpriced`) rather + * than reporting zeros — a null fee is "we don't know", a zero fee is a claim. + */ +export interface FeeBreakdown { + /** Provider's own commission, in fiatCurrency. */ + providerFee: number | null + /** Estimated on-chain/network cost of settling the crypto leg, in fiatCurrency. */ + networkFee: number | null + /** FX spread baked into the quoted rate vs. a reference mid-market rate, in fiatCurrency. */ + fxSpread: number | null +} + export interface QuoteResult { /** Provider key this quote came from (e.g. "moonpay"). */ provider: string @@ -25,11 +39,25 @@ export interface QuoteResult { assetSymbol: string /** Estimated crypto amount the user receives (on-ramp) or must send (off-ramp). */ cryptoAmount: number - /** Provider fee expressed in fiatCurrency, when the provider reports it. */ + /** Provider fee expressed in fiatCurrency, when the provider reports it. Deprecated: prefer `fees`. */ feeAmount?: number /** Exchange rate used (crypto units per 1 fiat unit), when reported. */ rate?: number - /** When the quote stops being valid, if the provider pins one. */ + /** + * Where the rate came from. Never assume 1.0 / same-currency: a provider + * that quotes cross-currency without disclosing its source is a bug in that + * provider adapter, not a default to fall back on here. + */ + rateSource?: 'PROVIDER' | 'FX_FEED' + /** Structured fee breakdown, or null when the provider cannot itemize fees. */ + fees: FeeBreakdown | null + /** True when `fees` is null because the provider does not expose a breakdown. */ + unpriced: boolean + /** True when the provider requires additional KYC for this currency/asset pair. */ + requiresKyc?: boolean + /** Provider's own quote/session identifier, when it issues one. */ + providerQuoteId?: string + /** When the provider stops honoring this quote, if it commits to a window. */ expiresAt?: string } @@ -108,3 +136,83 @@ export interface FiatRampProvider { /** Parse a verified webhook body into the normalized shape. */ parseWebhookPayload(rawBody: string): ParsedWebhook } + +// ── Multi-provider registry / best-execution types (#313) ───────────────────── + +/** + * Provider selection policy used to pick a provider when the caller doesn't + * pin one via an explicit `provider` field or a locked `quoteId`: + * + * - DEFAULT: the configured FIAT_DEFAULT_PROVIDER, falling back to + * any healthy provider if the default is unhealthy. + * - BEST_QUOTE: run the best-execution quote flow and use whichever + * provider came out on top. + * - ROUND_ROBIN_HEALTHY: rotate across currently-healthy providers. + * - PREFER_PROVIDER: use a caller-supplied preferred provider when + * healthy, otherwise fall back to DEFAULT semantics. + */ +export type ProviderSelectionPolicy = + 'DEFAULT' | 'BEST_QUOTE' | 'ROUND_ROBIN_HEALTHY' | 'PREFER_PROVIDER' + +export type CircuitState = 'closed' | 'open' | 'half-open' + +export interface ProviderHealthSnapshot { + provider: string + state: CircuitState + consecutiveFailures: number + totalSuccess: number + totalFailure: number + lastFailureAt: string | null + lastSuccessAt: string | null + /** Convenience: state !== 'open'. */ + healthy: boolean +} + +/** One provider's quote, ranked against its peers for a single request. */ +export interface RankedQuote extends QuoteResult { + /** Id of the persisted {@link FiatQuoteLock} row backing this quote. */ + quoteId: string + /** ISO timestamp — order creation referencing this quoteId fails past this. */ + expiresAt: string + /** 1 = best executable price among healthy providers that returned a quote. */ + rank: number +} + +/** A provider that could not be quoted, with a structured reason. */ +export interface ExcludedProviderQuote { + provider: string + reason: string +} + +export interface BestExecutionQuoteResult { + best: RankedQuote | null + quotes: RankedQuote[] + excluded: ExcludedProviderQuote[] +} + +/** Thrown when no healthy provider could be queried for a quote/order. */ +export class NoHealthyProvidersError extends Error { + readonly code = 'no_healthy_providers' + constructor(public readonly failures: ExcludedProviderQuote[]) { + super('No healthy fiat providers are available') + this.name = 'NoHealthyProvidersError' + } +} + +/** + * Structured error surfaced to routes for quote-locking / order-creation + * failures (#313). Lives here rather than in service.ts so it survives + * `jest.mock('../fiat/service')` in route-level tests that stub the service + * layer but still need `instanceof` checks on thrown errors to work. + */ +export class FiatOrderError extends Error { + constructor( + public readonly code: string, + public readonly status: number, + message: string, + public readonly details?: Record + ) { + super(message) + this.name = 'FiatOrderError' + } +} diff --git a/src/middleware/adminAuth.ts b/src/middleware/adminAuth.ts index af508d2..b07d7ad 100644 --- a/src/middleware/adminAuth.ts +++ b/src/middleware/adminAuth.ts @@ -26,6 +26,8 @@ export const ADMIN_SCOPES = [ 'backfill:write', 'keys:read', 'keys:write', + 'fiat:read', + 'fiat:write', 'super', ] as const export type AdminScope = (typeof ADMIN_SCOPES)[number] diff --git a/src/routes/admin.ts b/src/routes/admin.ts index c4d4cf9..13bc071 100644 --- a/src/routes/admin.ts +++ b/src/routes/admin.ts @@ -14,6 +14,7 @@ import { getEventMetrics } from '../stellar/events' import { DeadLetterQueue } from '../stellar/dlq' import { logger } from '../utils/logger' import { requireAdminAuth, requireAdminScope } from '../middleware/adminAuth' +import { getAllProviderHealth, adminSetProviderCircuit } from '../fiat/registry' import db from '../db' const router = Router() @@ -840,4 +841,73 @@ router.get( } ) +/** + * GET /api/admin/fiat/providers + * Reports per-provider health (circuit state, success/failure counts) for + * every registered fiat ramp provider (#313). + * Required scope: fiat:read + */ +router.get( + '/fiat/providers', + requireAdminScope('fiat:read'), + (req: Request, res: Response) => { + try { + const providers = getAllProviderHealth() + auditLog(req, res, 'GET_FIAT_PROVIDER_HEALTH', 'success') + res.status(200).json({ success: true, data: { providers } }) + } catch (error) { + logger.error('[Admin] Failed to get fiat provider health', { + error: error instanceof Error ? error.message : 'Unknown error', + }) + auditLog(req, res, 'GET_FIAT_PROVIDER_HEALTH', 'failure', { + error: error instanceof Error ? error.message : 'Unknown error', + }) + res + .status(500) + .json({ success: false, error: 'Failed to get fiat provider health' }) + } + } +) + +/** + * POST /api/admin/fiat/providers/:name/failover + * Manually force a fiat provider's circuit open (stop routing new + * quotes/orders to it) or closed (resume routing). Bypasses the automatic + * failure-count threshold so operators can react ahead of it (#313). + * Body: { "state": "open" | "closed" } + * Required scope: fiat:write + */ +router.post( + '/fiat/providers/:name/failover', + requireAdminScope('fiat:write'), + (req: Request, res: Response) => { + const { name } = req.params + const { state } = req.body ?? {} + + if (state !== 'open' && state !== 'closed') { + return res.status(400).json({ + success: false, + error: 'Body must include state: "open" | "closed"', + }) + } + + try { + const health = adminSetProviderCircuit(name, state) + auditLog(req, res, 'FIAT_PROVIDER_FAILOVER', 'success', { + provider: name, + state, + }) + res.status(200).json({ success: true, data: health }) + } catch (error) { + const message = error instanceof Error ? error.message : 'Unknown error' + auditLog(req, res, 'FIAT_PROVIDER_FAILOVER', 'failure', { + provider: name, + state, + error: message, + }) + res.status(404).json({ success: false, error: message }) + } + } +) + export default router diff --git a/src/routes/fiat.ts b/src/routes/fiat.ts index b26fbe9..27f81bc 100644 --- a/src/routes/fiat.ts +++ b/src/routes/fiat.ts @@ -1,7 +1,8 @@ /** - * Fiat on-ramp / off-ramp routes (#290). + * Fiat on-ramp / off-ramp routes (#290, extended #313). * - * POST /api/fiat/quote — auth; get a buy/sell quote + * POST /api/fiat/quote — auth; single default-provider quote (legacy) + * GET /api/fiat/quotes — auth; best-execution: ranked, parallel, multi-provider * POST /api/fiat/orders — auth; create an order, returns checkout URL * GET /api/fiat/orders — auth; caller's order history * GET /api/fiat/orders/:id — auth; single order (owner-scoped) @@ -19,19 +20,45 @@ import { logger } from '../utils/logger' import { sendError } from '../utils/errors' import { fiatQuoteSchema, + bestExecutionQuoteQuerySchema, createFiatOrderSchema, } from '../validators/fiat-validators' import { getFiatQuote, + getBestExecutionQuote, createFiatOrder, processProviderWebhook, } from '../fiat/service' import { getProvider } from '../fiat/registry' +import { NoHealthyProvidersError, FiatOrderError } from '../fiat/types' import db from '../db' const router = Router() -// ── Quote ───────────────────────────────────────────────────────────────────── +function respondToFiatError( + res: Response, + err: unknown, + fallbackMessage: string +) { + if (err instanceof NoHealthyProvidersError) { + return sendError(res, 503, 'No healthy fiat providers are available', { + code: err.code, + failures: err.failures, + }) + } + if (err instanceof FiatOrderError) { + return sendError(res, err.status, err.message, { + code: err.code, + ...err.details, + }) + } + logger.error(`[Fiat] ${fallbackMessage}`, { + error: err instanceof Error ? err.message : String(err), + }) + return sendError(res, 502, fallbackMessage) +} + +// ── Quote (legacy: single default-provider quote) ────────────────────────── router.post( '/quote', requireAuth, @@ -41,10 +68,32 @@ router.post( const quote = await getFiatQuote(req.body) return res.json(quote) } catch (err) { - logger.error('[Fiat] Quote failed', { - error: err instanceof Error ? err.message : String(err), - }) - return sendError(res, 502, 'Failed to fetch quote from provider') + return respondToFiatError(res, err, 'Failed to fetch quote from provider') + } + } +) + +// ── Best-execution quote: ranked, parallel, multi-provider (#313) ────────── +router.get( + '/quotes', + requireAuth, + validate({ + query: bestExecutionQuoteQuerySchema, + errorMessage: 'Validation error', + }), + async (req: Request, res: Response) => { + const userId = req.userId + if (!userId) return sendError(res, 401, 'Unauthorized') + + try { + const result = await getBestExecutionQuote(req.query as any, { userId }) + return res.json(result) + } catch (err) { + return respondToFiatError( + res, + err, + 'Failed to fetch quotes from providers' + ) } } ) @@ -65,10 +114,11 @@ router.post( const order = await createFiatOrder(req.body, { walletAddress }) return res.status(201).json(order) } catch (err) { - logger.error('[Fiat] Order creation failed', { - error: err instanceof Error ? err.message : String(err), - }) - return sendError(res, 502, 'Failed to create order with provider') + return respondToFiatError( + res, + err, + 'Failed to create order with provider' + ) } } ) diff --git a/src/utils/metrics.ts b/src/utils/metrics.ts index 73ee9fc..d796411 100644 --- a/src/utils/metrics.ts +++ b/src/utils/metrics.ts @@ -298,6 +298,45 @@ export const rateLimitActiveViolations = new client.Gauge({ registers: [register], }) +// ── Fiat Provider Metrics (#313) ───────────────────────────────────────────── + +export const fiatQuoteLatency = new client.Histogram({ + name: 'fiat_quote_latency_seconds', + help: 'Latency of a single fiat provider quote request', + labelNames: ['provider', 'direction'] as const, + buckets: [0.05, 0.1, 0.25, 0.5, 1, 2, 5, 10], + registers: [register], +}) + +export const fiatQuoteFailuresTotal = new client.Counter({ + name: 'fiat_quote_failures_total', + help: 'Total number of failed fiat provider quote requests', + labelNames: ['provider', 'reason'] as const, + registers: [register], +}) + +export const fiatOrdersTotal = new client.Counter({ + name: 'fiat_orders_total', + help: 'Total number of fiat orders by provider and outcome', + labelNames: ['provider', 'status'] as const, + registers: [register], +}) + +export const fiatProviderCircuitState = new client.Gauge({ + name: 'fiat_provider_circuit_state', + help: 'Current circuit breaker state per fiat provider (0=closed, 1=half-open, 2=open)', + labelNames: ['provider'] as const, + registers: [register], +}) + +export const fiatRateDriftPct = new client.Histogram({ + name: 'fiat_rate_drift_pct', + help: 'Absolute percentage drift between quoted and settled crypto amount', + labelNames: ['provider', 'direction'] as const, + buckets: [0.001, 0.005, 0.01, 0.02, 0.05, 0.1, 0.25], + registers: [register], +}) + // ── Helper Functions ───────────────────────────────────────────────────────────── /** @@ -488,6 +527,53 @@ export function recordRejectedRequest( rejectedRequestsTotal.inc({ reason }) } +/** + * Record a fiat provider quote attempt's latency (success or failure). + */ +export function recordFiatQuoteLatency( + provider: string, + direction: string, + durationSeconds: number +): void { + fiatQuoteLatency.observe({ provider, direction }, durationSeconds) +} + +/** + * Record a failed fiat provider quote request. + */ +export function recordFiatQuoteFailure(provider: string, reason: string): void { + fiatQuoteFailuresTotal.inc({ provider, reason }) +} + +/** + * Record a fiat order outcome for a provider. + */ +export function recordFiatOrder(provider: string, status: string): void { + fiatOrdersTotal.inc({ provider, status }) +} + +/** + * Reflect a fiat provider's circuit breaker state in a gauge for dashboards. + */ +export function setFiatProviderCircuitState( + provider: string, + state: 'closed' | 'half-open' | 'open' +): void { + const value = state === 'closed' ? 0 : state === 'half-open' ? 1 : 2 + fiatProviderCircuitState.set({ provider }, value) +} + +/** + * Record the absolute percentage drift between a quoted and settled amount. + */ +export function recordFiatRateDrift( + provider: string, + direction: string, + absDriftPct: number +): void { + fiatRateDriftPct.observe({ provider, direction }, absDriftPct) +} + /** * Get metrics for Prometheus scraping */ diff --git a/src/validators/fiat-validators.ts b/src/validators/fiat-validators.ts index 5001d54..630b635 100644 --- a/src/validators/fiat-validators.ts +++ b/src/validators/fiat-validators.ts @@ -21,12 +21,26 @@ export const fiatQuoteSchema = z.object({ assetSymbol: assetSymbolSchema, }) +/** GET /api/v1/fiat/quotes — same shape, read as query params (#313). */ +export const bestExecutionQuoteQuerySchema = z.object({ + direction: fiatDirectionSchema, + fiatAmount: z.coerce.number().positive(), + fiatCurrency: fiatCurrencySchema, + assetSymbol: assetSymbolSchema, +}) + export const createFiatOrderSchema = z.object({ userId: z.string().uuid(), direction: fiatDirectionSchema, fiatAmount: z.number().positive(), fiatCurrency: fiatCurrencySchema, assetSymbol: assetSymbolSchema, + // Best-execution (#313): either pin a specific provider, reference a + // locked quote from GET /fiat/quotes, or supply neither and let the + // registry's default selection policy choose. quoteId takes precedence + // when both are present since it carries an already-priced, time-boxed rate. + provider: z.string().trim().min(1).max(50).optional(), + quoteId: z.string().uuid().optional(), }) export const fiatOrderHistoryParamsSchema = z.object({ @@ -34,4 +48,7 @@ export const fiatOrderHistoryParamsSchema = z.object({ }) export type FiatQuoteInput = z.infer +export type BestExecutionQuoteQuery = z.infer< + typeof bestExecutionQuoteQuerySchema +> export type CreateFiatOrderInput = z.infer diff --git a/src/validators/webhook-validators.ts b/src/validators/webhook-validators.ts index de9f6b1..58a09c0 100644 --- a/src/validators/webhook-validators.ts +++ b/src/validators/webhook-validators.ts @@ -33,6 +33,7 @@ const WEBHOOK_EVENTS = [ 'withdraw.completed', 'fiat.order.settled', 'fiat.order.failed', + 'fiat.order.rate_mismatch', 'recurring_deposit.executed', 'recurring_deposit.failed', 'alert_rule.triggered', diff --git a/tests/integration/fiat.integration.test.ts b/tests/integration/fiat.integration.test.ts index 1e25fd6..cbe3f4a 100644 --- a/tests/integration/fiat.integration.test.ts +++ b/tests/integration/fiat.integration.test.ts @@ -35,10 +35,12 @@ jest.mock('../../src/utils/logger', () => ({ // --- Service layer: mock so no DB / provider network is touched --------------- const mockGetFiatQuote = jest.fn() +const mockGetBestExecutionQuote = jest.fn() const mockCreateFiatOrder = jest.fn() const mockProcessProviderWebhook = jest.fn() jest.mock('../../src/fiat/service', () => ({ getFiatQuote: (...a: unknown[]) => mockGetFiatQuote(...a), + getBestExecutionQuote: (...a: unknown[]) => mockGetBestExecutionQuote(...a), createFiatOrder: (...a: unknown[]) => mockCreateFiatOrder(...a), processProviderWebhook: (...a: unknown[]) => mockProcessProviderWebhook(...a), })) @@ -124,6 +126,69 @@ describe('POST /api/fiat/quote', () => { }) }) +describe('GET /api/fiat/quotes', () => { + it('returns the ranked best-execution result for a valid request', async () => { + mockGetBestExecutionQuote.mockResolvedValue({ + best: { + provider: 'sandbox', + cryptoAmount: 99, + quoteId: 'lock-1', + rank: 1, + }, + quotes: [ + { provider: 'sandbox', cryptoAmount: 99, quoteId: 'lock-1', rank: 1 }, + { provider: 'moonpay', cryptoAmount: 98, quoteId: 'lock-2', rank: 2 }, + ], + excluded: [], + }) + const res = await request(app).get('/api/fiat/quotes').query({ + direction: 'ON_RAMP', + fiatAmount: 100, + fiatCurrency: 'USD', + assetSymbol: 'USDC', + }) + expect(res.status).toBe(200) + expect(res.body.best.provider).toBe('sandbox') + expect(res.body.quotes).toHaveLength(2) + expect(mockGetBestExecutionQuote).toHaveBeenCalledWith( + expect.objectContaining({ fiatAmount: 100 }), + { userId: mockUserId } + ) + }) + + it('rejects an invalid query with 400', async () => { + const res = await request(app).get('/api/fiat/quotes').query({ + direction: 'SIDEWAYS', + fiatAmount: -5, + fiatCurrency: 'usd', + assetSymbol: 'USDC', + }) + expect(res.status).toBe(400) + expect(mockGetBestExecutionQuote).not.toHaveBeenCalled() + }) + + it('returns 503 with a structured error when no provider is healthy', async () => { + const { NoHealthyProvidersError } = jest.requireActual( + '../../src/fiat/types' + ) + mockGetBestExecutionQuote.mockRejectedValue( + new NoHealthyProvidersError([ + { provider: 'moonpay', reason: 'circuit open' }, + { provider: 'sandbox', reason: 'circuit open' }, + ]) + ) + const res = await request(app).get('/api/fiat/quotes').query({ + direction: 'ON_RAMP', + fiatAmount: 100, + fiatCurrency: 'USD', + assetSymbol: 'USDC', + }) + expect(res.status).toBe(503) + expect(res.body.details.code).toBe('no_healthy_providers') + expect(res.body.details.failures).toHaveLength(2) + }) +}) + describe('POST /api/fiat/orders', () => { it('creates an order for the authenticated user', async () => { mockCreateFiatOrder.mockResolvedValue({ @@ -158,6 +223,65 @@ describe('POST /api/fiat/orders', () => { expect(res.status).toBe(403) expect(mockCreateFiatOrder).not.toHaveBeenCalled() }) + + it('rejects an expired locked quote with 409 quote_expired (#313)', async () => { + const { FiatOrderError } = jest.requireActual('../../src/fiat/types') + mockCreateFiatOrder.mockRejectedValue( + new FiatOrderError( + 'quote_expired', + 409, + 'Quote is no longer valid — request a fresh quote', + { freshQuoteUrl: '/api/v1/fiat/quotes' } + ) + ) + const res = await request(app).post('/api/fiat/orders').send({ + userId: mockUserId, + direction: 'ON_RAMP', + fiatAmount: 100, + fiatCurrency: 'USD', + assetSymbol: 'USDC', + quoteId: '11111111-1111-4111-8111-111111111112', + }) + expect(res.status).toBe(409) + expect(res.body.details.code).toBe('quote_expired') + expect(res.body.details.freshQuoteUrl).toBe('/api/v1/fiat/quotes') + }) + + it('returns 503 with a structured error when no provider is healthy', async () => { + const { NoHealthyProvidersError } = jest.requireActual( + '../../src/fiat/types' + ) + mockCreateFiatOrder.mockRejectedValue( + new NoHealthyProvidersError([ + { provider: 'moonpay', reason: 'circuit open' }, + ]) + ) + const res = await request(app).post('/api/fiat/orders').send({ + userId: mockUserId, + direction: 'ON_RAMP', + fiatAmount: 100, + fiatCurrency: 'USD', + assetSymbol: 'USDC', + }) + expect(res.status).toBe(503) + expect(res.body.details.code).toBe('no_healthy_providers') + }) + + it('forwards an explicit provider preference to the service layer', async () => { + mockCreateFiatOrder.mockResolvedValue({ id: 'order-2', status: 'PENDING' }) + await request(app).post('/api/fiat/orders').send({ + userId: mockUserId, + direction: 'ON_RAMP', + fiatAmount: 100, + fiatCurrency: 'USD', + assetSymbol: 'USDC', + provider: 'sandbox', + }) + expect(mockCreateFiatOrder).toHaveBeenCalledWith( + expect.objectContaining({ provider: 'sandbox' }), + expect.objectContaining({ walletAddress: 'GWALLET_USER_1' }) + ) + }) }) describe('GET /api/fiat/orders', () => { diff --git a/tests/unit/fiat/bestExecutionQuote.test.ts b/tests/unit/fiat/bestExecutionQuote.test.ts new file mode 100644 index 0000000..3477bb3 --- /dev/null +++ b/tests/unit/fiat/bestExecutionQuote.test.ts @@ -0,0 +1,229 @@ +// #313 — Best-execution quote flow: parallel multi-provider fan-out, ranking, +// per-quote locking, and graceful partial failure. +import db from '../../../src/db' +import { + getHealthyProviders, + getAllProviderHealth, + recordProviderSuccess, + recordProviderFailure, +} from '../../../src/fiat/registry' +import { getBestExecutionQuote } from '../../../src/fiat/service' +import { NoHealthyProvidersError } from '../../../src/fiat/types' +import type { QuoteResult } from '../../../src/fiat/types' + +jest.mock('../../../src/db', () => ({ __esModule: true, default: {} })) +jest.mock('../../../src/utils/logger', () => ({ + logger: { + warn: jest.fn(), + error: jest.fn(), + info: jest.fn(), + debug: jest.fn(), + }, +})) +jest.mock('../../../src/fiat/registry', () => ({ + getHealthyProviders: jest.fn(), + getAllProviderHealth: jest.fn().mockReturnValue([]), + recordProviderSuccess: jest.fn(), + recordProviderFailure: jest.fn(), +})) +jest.mock('../../../src/utils/metrics', () => ({ + recordFiatQuoteLatency: jest.fn(), + recordFiatQuoteFailure: jest.fn(), + recordFiatOrder: jest.fn(), + recordFiatRateDrift: jest.fn(), + setFiatProviderCircuitState: jest.fn(), +})) + +const mockDb = db as any +const mockGetHealthyProviders = getHealthyProviders as jest.Mock +const mockGetAllProviderHealth = getAllProviderHealth as jest.Mock + +function makeQuote(provider: string, cryptoAmount: number): QuoteResult { + return { + provider, + direction: 'ON_RAMP', + fiatAmount: 100, + fiatCurrency: 'USD', + assetSymbol: 'USDC', + cryptoAmount, + fees: { providerFee: 1, networkFee: 0.1, fxSpread: 0 }, + unpriced: false, + } +} + +function fakeProvider( + name: string, + impl: () => Promise +): { name: string; getQuote: () => Promise } { + return { name, getQuote: impl } +} + +beforeEach(() => { + jest.clearAllMocks() + mockDb.fiatQuoteLock = { + create: jest.fn().mockImplementation(async ({ data }: any) => ({ + id: `lock-${data.provider}`, + ...data, + })), + } +}) + +afterEach(() => { + jest.useRealTimers() +}) + +describe('getBestExecutionQuote', () => { + it('ranks ON_RAMP quotes with the highest crypto amount first', async () => { + mockGetHealthyProviders.mockReturnValue([ + fakeProvider('alpha', async () => makeQuote('alpha', 100)), + fakeProvider('beta', async () => makeQuote('beta', 105)), + ]) + + const result = await getBestExecutionQuote( + { + direction: 'ON_RAMP', + fiatAmount: 100, + fiatCurrency: 'USD', + assetSymbol: 'USDC', + }, + { userId: 'user-1' } + ) + + expect(result.best?.provider).toBe('beta') + expect(result.quotes.map((q) => q.provider)).toEqual(['beta', 'alpha']) + expect(result.quotes[0].rank).toBe(1) + expect(result.quotes[1].rank).toBe(2) + expect(result.quotes.every((q) => typeof q.quoteId === 'string')).toBe(true) + expect(result.excluded).toEqual([]) + expect(recordProviderSuccess).toHaveBeenCalledWith('alpha') + expect(recordProviderSuccess).toHaveBeenCalledWith('beta') + }) + + it('ranks OFF_RAMP quotes with the lowest crypto amount first (least crypto given up is best)', async () => { + mockGetHealthyProviders.mockReturnValue([ + fakeProvider('alpha', async () => ({ + ...makeQuote('alpha', 102), + direction: 'OFF_RAMP', + })), + fakeProvider('beta', async () => ({ + ...makeQuote('beta', 98), + direction: 'OFF_RAMP', + })), + ]) + + const result = await getBestExecutionQuote( + { + direction: 'OFF_RAMP', + fiatAmount: 100, + fiatCurrency: 'USD', + assetSymbol: 'USDC', + }, + { userId: 'user-1' } + ) + + expect(result.best?.provider).toBe('beta') + expect(result.quotes.map((q) => q.provider)).toEqual(['beta', 'alpha']) + }) + + it('excludes a provider that errors, with a reason, and still ranks the healthy ones', async () => { + mockGetHealthyProviders.mockReturnValue([ + fakeProvider('broken', async () => { + throw new Error('vendor 500') + }), + fakeProvider('good', async () => makeQuote('good', 100)), + ]) + + const result = await getBestExecutionQuote( + { + direction: 'ON_RAMP', + fiatAmount: 100, + fiatCurrency: 'USD', + assetSymbol: 'USDC', + }, + { userId: 'user-1' } + ) + + expect(result.best?.provider).toBe('good') + expect(result.quotes).toHaveLength(1) + expect(result.excluded).toEqual([ + expect.objectContaining({ + provider: 'broken', + reason: expect.stringContaining('vendor 500'), + }), + ]) + expect(recordProviderFailure).toHaveBeenCalledWith('broken') + }) + + it('excludes a provider that times out without blocking the others', async () => { + jest.useFakeTimers() + mockGetHealthyProviders.mockReturnValue([ + fakeProvider('slow', () => new Promise(() => {})), + fakeProvider('fast', async () => makeQuote('fast', 100)), + ]) + + const promise = getBestExecutionQuote( + { + direction: 'ON_RAMP', + fiatAmount: 100, + fiatCurrency: 'USD', + assetSymbol: 'USDC', + }, + { userId: 'user-1' } + ) + + await jest.advanceTimersByTimeAsync(10_000) + const result = await promise + + expect(result.best?.provider).toBe('fast') + expect(result.excluded).toEqual([ + expect.objectContaining({ + provider: 'slow', + reason: expect.stringContaining('timed out'), + }), + ]) + }) + + it('throws NoHealthyProvidersError with per-provider reasons when nothing is healthy', async () => { + mockGetHealthyProviders.mockReturnValue([]) + mockGetAllProviderHealth.mockReturnValue([ + { provider: 'alpha', state: 'open', consecutiveFailures: 5 }, + { provider: 'beta', state: 'open', consecutiveFailures: 7 }, + ]) + + await expect( + getBestExecutionQuote( + { + direction: 'ON_RAMP', + fiatAmount: 100, + fiatCurrency: 'USD', + assetSymbol: 'USDC', + }, + { userId: 'user-1' } + ) + ).rejects.toBeInstanceOf(NoHealthyProvidersError) + expect(mockDb.fiatQuoteLock.create).not.toHaveBeenCalled() + }) + + it('labels a fee-less quote as unpriced rather than assuming zero', async () => { + mockGetHealthyProviders.mockReturnValue([ + fakeProvider('nobreakdown', async () => ({ + ...makeQuote('nobreakdown', 100), + fees: null, + unpriced: true, + })), + ]) + + const result = await getBestExecutionQuote( + { + direction: 'ON_RAMP', + fiatAmount: 100, + fiatCurrency: 'USD', + assetSymbol: 'USDC', + }, + { userId: 'user-1' } + ) + + expect(result.best?.fees).toBeNull() + expect(result.best?.unpriced).toBe(true) + }) +}) diff --git a/tests/unit/fiat/createOrderQuoteLock.test.ts b/tests/unit/fiat/createOrderQuoteLock.test.ts new file mode 100644 index 0000000..c1c7536 --- /dev/null +++ b/tests/unit/fiat/createOrderQuoteLock.test.ts @@ -0,0 +1,263 @@ +// #313 — createFiatOrder: quote-lock consumption, expiry rejection, and +// provider-preference / default-policy fallback when no quoteId is supplied. +import db from '../../../src/db' +import { + selectProviderForOrder, + getProvider, + recordProviderSuccess, + recordProviderFailure, +} from '../../../src/fiat/registry' +import { createFiatOrder } from '../../../src/fiat/service' +import { FiatOrderError } from '../../../src/fiat/types' + +jest.mock('../../../src/db', () => ({ __esModule: true, default: {} })) +jest.mock('../../../src/utils/logger', () => ({ + logger: { + warn: jest.fn(), + error: jest.fn(), + info: jest.fn(), + debug: jest.fn(), + }, +})) +jest.mock('../../../src/fiat/registry', () => ({ + selectProviderForOrder: jest.fn(), + getProvider: jest.fn(), + recordProviderSuccess: jest.fn(), + recordProviderFailure: jest.fn(), +})) +jest.mock('../../../src/utils/metrics', () => ({ + recordFiatQuoteLatency: jest.fn(), + recordFiatQuoteFailure: jest.fn(), + recordFiatOrder: jest.fn(), + recordFiatRateDrift: jest.fn(), + setFiatProviderCircuitState: jest.fn(), +})) + +const mockDb = db as any +const mockSelectProvider = selectProviderForOrder as jest.Mock +const mockGetProvider = getProvider as jest.Mock + +const baseInput = { + userId: 'user-1', + direction: 'ON_RAMP' as const, + fiatAmount: 100, + fiatCurrency: 'USD', + assetSymbol: 'USDC', +} + +function baseLock(overrides: Record = {}) { + return { + id: 'lock-1', + userId: 'user-1', + provider: 'moonpay', + direction: 'ON_RAMP', + fiatAmount: 100, + fiatCurrency: 'USD', + assetSymbol: 'USDC', + cryptoAmount: 98.5, + rate: 0.985, + fees: { providerFee: 1.5, networkFee: 0, fxSpread: 0 }, + providerQuoteId: 'mp_q_1', + expiresAt: new Date(Date.now() + 60_000), + consumedAt: null, + ...overrides, + } +} + +function fakeMoonpay(overrides: Record = {}) { + return { + name: 'moonpay', + createOrder: jest.fn().mockResolvedValue({ + providerOrderId: 'mp_order_1', + checkoutUrl: 'https://pay', + status: 'PENDING', + ...overrides, + }), + getQuote: jest.fn(), + } +} + +beforeEach(() => { + jest.clearAllMocks() + mockDb.fiatQuoteLock = { + findUnique: jest.fn(), + update: jest.fn().mockResolvedValue({}), + } + mockDb.fiatOrder = { + create: jest.fn().mockImplementation(async ({ data }: any) => ({ + id: 'order-1', + ...data, + })), + } +}) + +describe('createFiatOrder with quoteId', () => { + it('consumes the lock and persists the locked quote fields on the order', async () => { + const lock = baseLock() + mockDb.fiatQuoteLock.findUnique.mockResolvedValue(lock) + const provider = fakeMoonpay() + mockGetProvider.mockReturnValue(provider) + + const order = await createFiatOrder( + { ...baseInput, quoteId: lock.id }, + { walletAddress: 'GWALLET' } + ) + + expect(mockSelectProvider).not.toHaveBeenCalled() + expect(mockGetProvider).toHaveBeenCalledWith('moonpay') + expect(mockDb.fiatOrder.create).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + provider: 'moonpay', + quoteRate: 0.985, + quotedCryptoAmount: 98.5, + providerQuoteId: 'mp_q_1', + fees: lock.fees, + }), + }) + ) + expect(mockDb.fiatQuoteLock.update).toHaveBeenCalledWith({ + where: { id: lock.id }, + data: { consumedAt: expect.any(Date) }, + }) + expect(order.id).toBe('order-1') + }) + + it('rejects an expired lock with quote_expired and does not create an order', async () => { + mockDb.fiatQuoteLock.findUnique.mockResolvedValue( + baseLock({ expiresAt: new Date(Date.now() - 1000) }) + ) + + await expect( + createFiatOrder( + { ...baseInput, quoteId: 'lock-1' }, + { walletAddress: 'GWALLET' } + ) + ).rejects.toMatchObject({ code: 'quote_expired', status: 409 }) + + expect(mockDb.fiatOrder.create).not.toHaveBeenCalled() + }) + + it('rejects an already-consumed lock with quote_already_used', async () => { + mockDb.fiatQuoteLock.findUnique.mockResolvedValue( + baseLock({ consumedAt: new Date() }) + ) + + await expect( + createFiatOrder( + { ...baseInput, quoteId: 'lock-1' }, + { walletAddress: 'GWALLET' } + ) + ).rejects.toMatchObject({ code: 'quote_already_used', status: 409 }) + + expect(mockDb.fiatOrder.create).not.toHaveBeenCalled() + }) + + it('rejects a quote owned by another user with quote_not_found (no existence leak)', async () => { + mockDb.fiatQuoteLock.findUnique.mockResolvedValue( + baseLock({ userId: 'someone-else' }) + ) + + await expect( + createFiatOrder( + { ...baseInput, quoteId: 'lock-1' }, + { walletAddress: 'GWALLET' } + ) + ).rejects.toMatchObject({ code: 'quote_not_found', status: 404 }) + }) + + it('rejects when order parameters do not match the locked quote', async () => { + mockDb.fiatQuoteLock.findUnique.mockResolvedValue( + baseLock({ assetSymbol: 'XLM' }) + ) + + await expect( + createFiatOrder( + { ...baseInput, quoteId: 'lock-1' }, + { walletAddress: 'GWALLET' } + ) + ).rejects.toMatchObject({ code: 'quote_mismatch', status: 409 }) + }) + + it('propagates FiatOrderError instances correctly typed', async () => { + mockDb.fiatQuoteLock.findUnique.mockResolvedValue(null) + try { + await createFiatOrder( + { ...baseInput, quoteId: 'nope' }, + { walletAddress: 'GWALLET' } + ) + throw new Error('should have thrown') + } catch (err) { + expect(err).toBeInstanceOf(FiatOrderError) + } + }) +}) + +describe('createFiatOrder without quoteId', () => { + it('honors an explicit provider preference via PREFER_PROVIDER', async () => { + const provider = fakeMoonpay() + provider.getQuote.mockResolvedValue({ + provider: 'moonpay', + direction: 'ON_RAMP', + fiatAmount: 100, + fiatCurrency: 'USD', + assetSymbol: 'USDC', + cryptoAmount: 97, + rate: 0.97, + fees: null, + unpriced: true, + }) + mockSelectProvider.mockReturnValue(provider) + + await createFiatOrder( + { ...baseInput, provider: 'moonpay' }, + { walletAddress: 'GWALLET' } + ) + + expect(mockSelectProvider).toHaveBeenCalledWith({ + policy: 'PREFER_PROVIDER', + preferredProvider: 'moonpay', + }) + expect(mockDb.fiatQuoteLock.update).not.toHaveBeenCalled() + expect(mockDb.fiatOrder.create).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ quotedCryptoAmount: 97, fees: null }), + }) + ) + }) + + it('falls back to the registry default policy when neither quoteId nor provider is given', async () => { + const provider = fakeMoonpay() + provider.getQuote.mockResolvedValue({ + provider: 'moonpay', + direction: 'ON_RAMP', + fiatAmount: 100, + fiatCurrency: 'USD', + assetSymbol: 'USDC', + cryptoAmount: 97, + fees: null, + unpriced: true, + }) + mockSelectProvider.mockReturnValue(provider) + + await createFiatOrder(baseInput, { walletAddress: 'GWALLET' }) + + expect(mockSelectProvider).toHaveBeenCalledWith({ + policy: 'DEFAULT', + preferredProvider: undefined, + }) + }) + + it('records provider failure and rethrows when the just-in-time quote fails', async () => { + const provider = fakeMoonpay() + provider.getQuote.mockRejectedValue(new Error('provider down')) + mockSelectProvider.mockReturnValue(provider) + + await expect( + createFiatOrder(baseInput, { walletAddress: 'GWALLET' }) + ).rejects.toThrow('provider down') + + expect(recordProviderFailure).toHaveBeenCalledWith('moonpay') + expect(mockDb.fiatOrder.create).not.toHaveBeenCalled() + }) +}) diff --git a/tests/unit/fiat/registry.test.ts b/tests/unit/fiat/registry.test.ts new file mode 100644 index 0000000..7deaf2e --- /dev/null +++ b/tests/unit/fiat/registry.test.ts @@ -0,0 +1,190 @@ +// #313 — Provider registry: per-provider health ledger (circuit breaker +// semantics matching utils/http-client.ts), selection policies, and manual +// admin failover. Each test loads a fresh module instance with controlled env +// vars so the circuit-breaker threshold/reset window are deterministic. +jest.mock('../../../src/utils/metrics', () => ({ + setFiatProviderCircuitState: jest.fn(), +})) + +function loadRegistry(env: Record) { + jest.resetModules() + const prevEnv = { ...process.env } + Object.assign(process.env, env) + // eslint-disable-next-line @typescript-eslint/no-var-requires + const registry = require('../../../src/fiat/registry') + process.env = prevEnv + return registry as typeof import('../../../src/fiat/registry') +} + +describe('fiat provider registry — health tracking + circuit breaker', () => { + it('registers moonpay and sandbox by default outside production', () => { + const registry = loadRegistry({ + NODE_ENV: 'test', + FIAT_ENABLE_SANDBOX_PROVIDER: 'true', + }) + const names = registry.getAllProviders().map((p: any) => p.name) + expect(names).toEqual(expect.arrayContaining(['moonpay', 'sandbox'])) + }) + + it('opens the circuit after the configured consecutive-failure threshold', () => { + const registry = loadRegistry({ + FIAT_ENABLE_SANDBOX_PROVIDER: 'true', + FIAT_PROVIDER_CIRCUIT_BREAKER_THRESHOLD: '3', + FIAT_PROVIDER_CIRCUIT_BREAKER_RESET_MS: '60000', + }) + + expect(registry.isProviderHealthy('moonpay')).toBe(true) + registry.recordProviderFailure('moonpay') + registry.recordProviderFailure('moonpay') + expect(registry.isProviderHealthy('moonpay')).toBe(true) + registry.recordProviderFailure('moonpay') + expect(registry.isProviderHealthy('moonpay')).toBe(false) + expect(registry.getProviderHealth('moonpay').state).toBe('open') + }) + + it('a success resets the consecutive-failure count and closes the circuit', () => { + const registry = loadRegistry({ + FIAT_ENABLE_SANDBOX_PROVIDER: 'true', + FIAT_PROVIDER_CIRCUIT_BREAKER_THRESHOLD: '3', + }) + registry.recordProviderFailure('moonpay') + registry.recordProviderFailure('moonpay') + registry.recordProviderSuccess('moonpay') + registry.recordProviderFailure('moonpay') + registry.recordProviderFailure('moonpay') + // Only 2 consecutive failures since the success reset the counter. + expect(registry.isProviderHealthy('moonpay')).toBe(true) + }) + + it('transitions an open circuit to half-open (healthy) after the reset window elapses', async () => { + const registry = loadRegistry({ + FIAT_ENABLE_SANDBOX_PROVIDER: 'true', + FIAT_PROVIDER_CIRCUIT_BREAKER_THRESHOLD: '1', + FIAT_PROVIDER_CIRCUIT_BREAKER_RESET_MS: '10', + }) + registry.recordProviderFailure('moonpay') + expect(registry.isProviderHealthy('moonpay')).toBe(false) + + await new Promise((resolve) => setTimeout(resolve, 25)) + + expect(registry.isProviderHealthy('moonpay')).toBe(true) + expect(registry.getProviderHealth('moonpay').state).toBe('half-open') + }) + + it('excludes an open-circuit provider from getHealthyProviders', () => { + const registry = loadRegistry({ + FIAT_ENABLE_SANDBOX_PROVIDER: 'true', + FIAT_PROVIDER_CIRCUIT_BREAKER_THRESHOLD: '1', + FIAT_PROVIDER_CIRCUIT_BREAKER_RESET_MS: '60000', + }) + registry.recordProviderFailure('moonpay') + const healthy = registry.getHealthyProviders().map((p: any) => p.name) + expect(healthy).toEqual(['sandbox']) + }) +}) + +describe('fiat provider registry — selection policy', () => { + it('DEFAULT fails over to the next healthy provider when the configured default is unhealthy', () => { + const registry = loadRegistry({ + FIAT_ENABLE_SANDBOX_PROVIDER: 'true', + FIAT_DEFAULT_PROVIDER: 'moonpay', + FIAT_PROVIDER_CIRCUIT_BREAKER_THRESHOLD: '1', + FIAT_PROVIDER_CIRCUIT_BREAKER_RESET_MS: '60000', + }) + registry.recordProviderFailure('moonpay') + const provider = registry.selectProviderForOrder({ policy: 'DEFAULT' }) + expect(provider.name).toBe('sandbox') + }) + + it('PREFER_PROVIDER uses the preferred provider when healthy', () => { + const registry = loadRegistry({ FIAT_ENABLE_SANDBOX_PROVIDER: 'true' }) + const provider = registry.selectProviderForOrder({ + policy: 'PREFER_PROVIDER', + preferredProvider: 'sandbox', + }) + expect(provider.name).toBe('sandbox') + }) + + it('PREFER_PROVIDER falls back to DEFAULT semantics when the preferred provider is unhealthy', () => { + const registry = loadRegistry({ + FIAT_ENABLE_SANDBOX_PROVIDER: 'true', + FIAT_DEFAULT_PROVIDER: 'moonpay', + FIAT_PROVIDER_CIRCUIT_BREAKER_THRESHOLD: '1', + FIAT_PROVIDER_CIRCUIT_BREAKER_RESET_MS: '60000', + }) + registry.recordProviderFailure('sandbox') + const provider = registry.selectProviderForOrder({ + policy: 'PREFER_PROVIDER', + preferredProvider: 'sandbox', + }) + expect(provider.name).toBe('moonpay') + }) + + it('ROUND_ROBIN_HEALTHY rotates across the healthy set', () => { + const registry = loadRegistry({ FIAT_ENABLE_SANDBOX_PROVIDER: 'true' }) + const first = registry.selectProviderForOrder({ + policy: 'ROUND_ROBIN_HEALTHY', + }) + const second = registry.selectProviderForOrder({ + policy: 'ROUND_ROBIN_HEALTHY', + }) + expect(first.name).not.toBe(second.name) + }) + + it('throws NoHealthyProvidersError with per-provider reasons when every provider is unhealthy', () => { + const registry = loadRegistry({ + FIAT_ENABLE_SANDBOX_PROVIDER: 'true', + FIAT_PROVIDER_CIRCUIT_BREAKER_THRESHOLD: '1', + FIAT_PROVIDER_CIRCUIT_BREAKER_RESET_MS: '60000', + }) + registry.recordProviderFailure('moonpay') + registry.recordProviderFailure('sandbox') + + expect(() => + registry.selectProviderForOrder({ policy: 'DEFAULT' }) + ).toThrow('No healthy fiat providers are available') + try { + registry.selectProviderForOrder({ policy: 'DEFAULT' }) + } catch (err: any) { + expect(err.code).toBe('no_healthy_providers') + expect(err.failures).toEqual( + expect.arrayContaining([ + expect.objectContaining({ provider: 'moonpay' }), + expect.objectContaining({ provider: 'sandbox' }), + ]) + ) + } + }) +}) + +describe('fiat provider registry — admin manual failover', () => { + it('adminSetProviderCircuit forces a provider open/closed, bypassing the failure threshold', () => { + const registry = loadRegistry({ FIAT_ENABLE_SANDBOX_PROVIDER: 'true' }) + + expect(registry.isProviderHealthy('moonpay')).toBe(true) + const opened = registry.adminSetProviderCircuit('moonpay', 'open') + expect(opened.state).toBe('open') + expect(registry.isProviderHealthy('moonpay')).toBe(false) + + const closed = registry.adminSetProviderCircuit('moonpay', 'closed') + expect(closed.state).toBe('closed') + expect(registry.isProviderHealthy('moonpay')).toBe(true) + }) + + it('throws for an unknown provider name', () => { + const registry = loadRegistry({ FIAT_ENABLE_SANDBOX_PROVIDER: 'true' }) + expect(() => registry.adminSetProviderCircuit('nope', 'open')).toThrow( + 'Unknown fiat provider' + ) + }) + + it('getAllProviderHealth reports a snapshot for every registered provider', () => { + const registry = loadRegistry({ FIAT_ENABLE_SANDBOX_PROVIDER: 'true' }) + const snapshots = registry.getAllProviderHealth() + expect(snapshots.map((s: any) => s.provider).sort()).toEqual([ + 'moonpay', + 'sandbox', + ]) + expect(snapshots.every((s: any) => s.healthy)).toBe(true) + }) +}) diff --git a/tests/unit/fiat/sandbox.test.ts b/tests/unit/fiat/sandbox.test.ts new file mode 100644 index 0000000..4bcfe67 --- /dev/null +++ b/tests/unit/fiat/sandbox.test.ts @@ -0,0 +1,161 @@ +// #313 — Sandbox provider: the second concrete FiatRampProvider implementation +// proving the multi-provider abstraction works end-to-end. Deterministic, no +// network calls. +import { createHmac } from 'crypto' +import { SandboxProvider } from '../../../src/fiat/providers/sandbox' + +jest.mock('../../../src/utils/logger', () => ({ + logger: { + warn: jest.fn(), + error: jest.fn(), + info: jest.fn(), + debug: jest.fn(), + }, +})) + +const WEBHOOK_KEY = 'sandbox_test_key' + +function sign(rawBody: string, key = WEBHOOK_KEY): string { + return `sha256=${createHmac('sha256', key).update(rawBody).digest('hex')}` +} + +describe('SandboxProvider.getQuote', () => { + const provider = new SandboxProvider({ webhookKey: WEBHOOK_KEY }) + + it('returns a structured fee breakdown, never assuming zero', async () => { + const quote = await provider.getQuote({ + direction: 'ON_RAMP', + fiatAmount: 100, + fiatCurrency: 'USD', + assetSymbol: 'USDC', + }) + + expect(quote.provider).toBe('sandbox') + expect(quote.unpriced).toBe(false) + expect(quote.fees).not.toBeNull() + expect(quote.fees!.providerFee).toBeGreaterThan(0) + expect(quote.cryptoAmount).toBeGreaterThan(0) + }) + + it('quotes less crypto for ON_RAMP than the gross fiat amount would imply (fees deducted)', async () => { + const quote = await provider.getQuote({ + direction: 'ON_RAMP', + fiatAmount: 100, + fiatCurrency: 'USD', + assetSymbol: 'USDC', + }) + // USDC rate is 1:1 before fees, so cryptoAmount should be < 100. + expect(quote.cryptoAmount).toBeLessThan(100) + }) + + it('flags requiresKyc above the configured threshold, not below it', async () => { + const small = await provider.getQuote({ + direction: 'ON_RAMP', + fiatAmount: 500, + fiatCurrency: 'USD', + assetSymbol: 'USDC', + }) + const large = await provider.getQuote({ + direction: 'ON_RAMP', + fiatAmount: 5000, + fiatCurrency: 'USD', + assetSymbol: 'USDC', + }) + expect(small.requiresKyc).toBe(false) + expect(large.requiresKyc).toBe(true) + }) +}) + +describe('SandboxProvider.createOrder', () => { + const provider = new SandboxProvider({ webhookKey: WEBHOOK_KEY }) + + it('returns a checkout URL and PENDING status below the KYC threshold', async () => { + const result = await provider.createOrder({ + userId: 'user-1', + direction: 'ON_RAMP', + fiatAmount: 100, + fiatCurrency: 'USD', + assetSymbol: 'USDC', + walletAddress: 'GWALLET', + }) + expect(result.status).toBe('PENDING') + expect(result.checkoutUrl).toContain(result.providerOrderId) + expect(result.kycUrl).toBeUndefined() + }) + + it('gates large orders behind KYC_REQUIRED with a kycUrl', async () => { + const result = await provider.createOrder({ + userId: 'user-1', + direction: 'ON_RAMP', + fiatAmount: 5000, + fiatCurrency: 'USD', + assetSymbol: 'USDC', + walletAddress: 'GWALLET', + }) + expect(result.status).toBe('KYC_REQUIRED') + expect(result.kycUrl).toBeDefined() + }) +}) + +describe('SandboxProvider webhook verification + parsing', () => { + const provider = new SandboxProvider({ webhookKey: WEBHOOK_KEY }) + + it('accepts a correctly signed payload', () => { + const body = JSON.stringify({ + providerOrderId: 'sandbox_1', + status: 'SETTLED', + }) + const header = sign(body) + expect( + provider.verifyWebhookSignature(body, { 'x-sandbox-signature': header }) + ).toBe(true) + }) + + it('rejects a tampered body', () => { + const body = JSON.stringify({ + providerOrderId: 'sandbox_1', + status: 'SETTLED', + }) + const header = sign(body) + const tampered = JSON.stringify({ + providerOrderId: 'sandbox_1', + status: 'FAILED', + }) + expect( + provider.verifyWebhookSignature(tampered, { + 'x-sandbox-signature': header, + }) + ).toBe(false) + }) + + it('rejects when no webhook key is configured', () => { + const noKeyProvider = new SandboxProvider({ webhookKey: '' }) + const body = JSON.stringify({ + providerOrderId: 'sandbox_1', + status: 'SETTLED', + }) + expect( + noKeyProvider.verifyWebhookSignature(body, { + 'x-sandbox-signature': sign(body), + }) + ).toBe(false) + }) + + it('parses a webhook payload into the normalized shape', () => { + const body = JSON.stringify({ + providerOrderId: 'sandbox_1', + status: 'PROCESSING', + txHash: '0xabc', + cryptoAmount: 98.5, + }) + const parsed = provider.parseWebhookPayload(body) + expect(parsed).toEqual({ + providerOrderId: 'sandbox_1', + status: 'PROCESSING', + txHash: '0xabc', + cryptoAmount: 98.5, + kycUrl: undefined, + reason: undefined, + }) + }) +}) diff --git a/tests/unit/fiat/service.test.ts b/tests/unit/fiat/service.test.ts index 98aa689..06ebc33 100644 --- a/tests/unit/fiat/service.test.ts +++ b/tests/unit/fiat/service.test.ts @@ -62,6 +62,7 @@ beforeEach(() => { mockDb.transaction = { findUnique: jest.fn(), findFirst: jest.fn(), + findMany: jest.fn().mockResolvedValue([]), } }) @@ -252,18 +253,125 @@ describe('reconcileSingleOrder', () => { }) }) -describe('reconcileFiatOrders', () => { - it('settles PROCESSING orders that now have a confirmed on-chain match', async () => { - mockDb.fiatOrder.findMany.mockResolvedValue([ - baseOrder({ status: 'PROCESSING' }), - ]) - mockDb.transaction.findFirst.mockResolvedValue({ +describe('reconcileSingleOrder — quoted-vs-settled drift (#313)', () => { + it('persists settledCryptoAmount/settledRate and emits rate_mismatch when drift exceeds tolerance', async () => { + const order = baseOrder({ + status: 'PROCESSING', + fiatAmount: 100, + quotedCryptoAmount: 100, + provider: 'moonpay', + }) + mockDb.fiatOrder.findUnique.mockResolvedValue(order) + // 10% short of quote — well beyond the 2% default tolerance. + mockDb.transaction.findUnique.mockResolvedValue({ id: 'tx-1', txHash: '0xabc', status: 'CONFIRMED', userId: 'user-1', - amount: 100, + amount: 90, + }) + mockDb.fiatOrder.update.mockImplementation(({ data }: any) => ({ + ...baseOrder(), + ...data, + })) + + const ok = await reconcileSingleOrder('order-1', '0xabc') + + expect(ok).toBe(true) + const updateArg = mockDb.fiatOrder.update.mock.calls[0][0] + expect(updateArg.data.settledCryptoAmount).toBe(90) + expect(updateArg.data.settledRate).toBeCloseTo(0.9) + expect(mockDispatch).toHaveBeenCalledWith( + 'fiat.order.rate_mismatch', + expect.objectContaining({ + orderId: 'order-1', + quotedCryptoAmount: 100, + settledCryptoAmount: 90, + }) + ) + expect(mockEmit).toHaveBeenCalledWith( + expect.objectContaining({ component: 'fiat-settlement' }), + expect.stringContaining('fiat:drift:') + ) + }) + + it('does not alert when settlement drift is within tolerance', async () => { + const order = baseOrder({ + status: 'PROCESSING', + fiatAmount: 100, + quotedCryptoAmount: 100, + }) + mockDb.fiatOrder.findUnique.mockResolvedValue(order) + // 1% short — within the 2% default tolerance. + mockDb.transaction.findUnique.mockResolvedValue({ + id: 'tx-1', + txHash: '0xabc', + status: 'CONFIRMED', + userId: 'user-1', + amount: 99, + }) + mockDb.fiatOrder.update.mockImplementation(({ data }: any) => ({ + ...baseOrder(), + ...data, + })) + + await reconcileSingleOrder('order-1', '0xabc') + + expect(mockDispatch).not.toHaveBeenCalledWith( + 'fiat.order.rate_mismatch', + expect.anything() + ) + }) + + it('still alerts on over-delivery (credited, not capped) for audit visibility', async () => { + const order = baseOrder({ + status: 'PROCESSING', + fiatAmount: 100, + cryptoAmount: 100, + quotedCryptoAmount: 100, }) + mockDb.fiatOrder.findUnique.mockResolvedValue(order) + // 15% more than quoted. + mockDb.transaction.findUnique.mockResolvedValue({ + id: 'tx-1', + txHash: '0xabc', + status: 'CONFIRMED', + userId: 'user-1', + amount: 115, + }) + mockDb.fiatOrder.update.mockImplementation(({ data }: any) => ({ + ...baseOrder(), + ...data, + })) + + await reconcileSingleOrder('order-1', '0xabc') + + const updateArg = mockDb.fiatOrder.update.mock.calls[0][0] + // The original quoted cryptoAmount promised to the user is never reduced, + // but the realized (better) amount is still captured for audit visibility. + expect(updateArg.data.cryptoAmount).toBe(100) + expect(updateArg.data.settledCryptoAmount).toBe(115) + expect(mockDispatch).toHaveBeenCalledWith( + 'fiat.order.rate_mismatch', + expect.objectContaining({ driftPct: expect.any(Number) }) + ) + }) +}) + +describe('reconcileFiatOrders', () => { + it('settles PROCESSING orders that now have a confirmed on-chain match', async () => { + mockDb.fiatOrder.findMany.mockResolvedValue([ + baseOrder({ status: 'PROCESSING' }), + ]) + mockDb.transaction.findMany.mockResolvedValue([ + { + id: 'tx-1', + txHash: '0xabc', + status: 'CONFIRMED', + userId: 'user-1', + amount: 100, + }, + ]) // reconcileSingleOrder re-reads the order + tx. mockDb.fiatOrder.findUnique.mockResolvedValue( baseOrder({ status: 'PROCESSING' }) @@ -292,7 +400,7 @@ describe('reconcileFiatOrders', () => { createdAt: new Date(Date.now() - STALE_ORDER_MAX_AGE_MS - 1000), }) mockDb.fiatOrder.findMany.mockResolvedValue([stale]) - mockDb.transaction.findFirst.mockResolvedValue(null) + mockDb.transaction.findMany.mockResolvedValue([]) const res = await reconcileFiatOrders() @@ -305,6 +413,55 @@ describe('reconcileFiatOrders', () => { expect.stringContaining('fiat:stuck:') ) }) + + it('does not cross-link two providers concurrent orders for the same user + asset (#313)', async () => { + // Same user, same asset, two different providers, both PROCESSING with + // different quoted crypto amounts. Only one on-chain confirmed + // transaction exists, matching order A's quote — order B must NOT be + // settled against it even though the old "most recent unlinked" heuristic + // would have grabbed it. + const orderA = baseOrder({ + id: 'order-A', + provider: 'moonpay', + providerOrderId: 'mp_1', + status: 'PROCESSING', + quotedCryptoAmount: 100, + }) + const orderB = baseOrder({ + id: 'order-B', + provider: 'sandbox', + providerOrderId: 'sb_1', + status: 'PROCESSING', + quotedCryptoAmount: 50, + }) + mockDb.fiatOrder.findMany.mockResolvedValue([orderA, orderB]) + + const tx = { + id: 'tx-1', + txHash: '0xabc', + status: 'CONFIRMED', + userId: 'user-1', + amount: 100, // matches order A within tolerance; way off from order B + } + mockDb.transaction.findMany.mockResolvedValue([tx]) + mockDb.fiatOrder.findUnique.mockImplementation(({ where }: any) => + where.id === 'order-A' ? orderA : orderB + ) + mockDb.transaction.findUnique.mockResolvedValue(tx) + mockDb.fiatOrder.update.mockImplementation(({ where, data }: any) => ({ + id: where.id, + ...data, + })) + + const res = await reconcileFiatOrders() + + expect(res.settled).toBe(1) + const settleCalls = mockDb.fiatOrder.update.mock.calls.filter( + (c: any) => c[0].data.status === 'SETTLED' + ) + expect(settleCalls).toHaveLength(1) + expect(settleCalls[0][0].where.id).toBe('order-A') + }) }) describe('ageOutStaleFiatOrders', () => {