Problem Statement
Fiat rails are a single point of failure and a single point of cost. src/fiat/registry.ts resolves a default provider and src/fiat/service.ts calls it unconditionally — a MoonPay outage bricks on-ramp/off-ramp entirely, and users have no way to compare what a conversion actually costs across providers. There is also no best-execution guarantee: the crypto amount quoted at order creation is whatever one provider happened to offer, with no fee breakdown and no protection against a quote that drifts before settlement. This issue turns fiat into a resilient, price-transparent, multi-provider layer with best-execution quoting, provider failover, and auditable fee/rate exposure.
Current State
src/fiat/service.ts is provider-agnostic by design: getQuote / createOrder / processProviderWebhook / reconcileSingleOrder call into a FiatRampProvider resolved from src/fiat/registry.ts. The abstraction is already there — but there is exactly one provider (src/fiat/providers/moonpay.ts), the registry returns the default, and there is no multi-quote comparison, no failover, and no per-provider pricing transparency.
FiatOrder (prisma/schema.prisma) records cryptoAmount, fiatAmount, fiatCurrency, assetSymbol, status, failureReason — but no quoted-vs-settled delta, no fee line items, no rate captured at quote time, no provider_quote_id.
- Settlement correctness is already strong: on-chain confirmation is authoritative (
reconcileSingleOrder only settles against a CONFIRMED Transaction row owned by the same user), and reconciliation/age-out jobs exist. This issue must not weaken those invariants.
Proposed Solution
1. Multi-provider registry with health + failover (src/fiat/registry.ts)
- Registry becomes order-aware: maintain a provider health ledger (success/failure counts, consecutive-error state, circuit-breaker semantics consistent with
src/utils/http-client.ts's circuit breaker), and a selection policy — e.g. DEFAULT, BEST_QUOTE, ROUND_ROBIN_HEALTHY, PREFER_PROVIDER.
createOrder and getQuote must be able to target a specific provider (from the registry, a user preference, or an explicit request field) in addition to the default path, so best-execution and failover are both expressible.
- On order creation, pin the provider: a
PENDING order is processed and reconciled only against the provider that created it. Failover applies to new orders, never mid-flight ones.
- Provider health must be observable: expose metrics (via
src/utils/metrics.ts/Prometheus) for quote latency, quote failure rate, order success/failure per provider.
2. Best-execution quoting (src/fiat/service.ts + src/fiat/types.ts)
- New quote flow that queries all healthy providers in parallel (
Promise.allSettled, per-provider timeout), normalizes each into a comparable quote (crypto amount for a given fiat amount, and the reverse), and:
- Returns the best executable quote plus the full ranked list to the caller (so the client can show "3 providers, best is X").
- Persists the quoted rate, fee breakdown, and quote validity when an order is created, so
cryptoAmount is never an unexplained number.
- Quote locking: a quote is valid for a bounded window (configurable, default e.g. 60s). Order creation from an expired quote is rejected with a clear
quote_expired error and a fresh quote link — the user must see the new rate, not silently get a different cryptoAmount at settlement.
- Fee transparency contract: each provider must return a structured fee breakdown (provider fee, network/gas estimate, any FX spread) via an extended
FiatQuote type; a provider that cannot supply a breakdown must return fees: null and be explicitly labeled unpriced rather than assumed 0.
3. Rate-drift protection and settlement deltas
- Extend
FiatOrder with quoteRate, quotedCryptoAmount, fees Json?, settledRate, settledCryptoAmount, rateLockExpiresAt, providerQuoteId String?.
- On settlement (on-chain confirmation), compute and persist the quoted-vs-settled delta in fiat terms. A drift beyond a configurable tolerance (e.g. 2%) must emit an operational alert and a
fiat.order.rate_mismatch webhook event — the user is entitled to know they got a worse deal than quoted.
- Partial fills: on-chain confirmation may deliver a different
cryptoAmount than quoted (fees eaten by network, provider rounding). Persist the realized amount and the delta; document and handle the case where the delivered amount differs enough that the provider should be re-quoted or refunded.
- Multi-currency:
fiatCurrency already exists but the quote path and rate capture must be exercised for every currency the providers support, with conversion-rate sourcing made explicit (provider-provided vs. an FX feed) and never silently assumed 1.0.
4. Reconciliation hardening across providers
src/jobs/fiatReconciliation.ts must remain authoritative: with multiple providers, ensure the "match any unlinked CONFIRMED transaction" heuristic in reconcileFiatOrders cannot cross-link an order to a transaction belonging to a different provider's order (it currently keys on user + asset + unlinked; add provider-claim cross-checks and keep the strict userId guard).
- New reconciliation case: provider reports
PROCESSING/paid, on-chain never arrives, order ages past threshold — the existing alert already exists; extend it to include which provider and the quoted-vs-settled expectation so operators can run provider-specific investigations.
5. API surface (src/routes/fiat.ts + docs/openapi.yaml)
GET /api/v1/fiat/quotes — parallel best-execution quote with ranked provider list, fee breakdowns, quote expiry.
POST /api/v1/fiat/orders — accepts either a provider preference or a quoteId (locked quote); rejects expired locks.
GET /api/v1/fiat/orders/:id — includes quote vs. settled deltas and fee line items.
- Admin endpoint to inspect provider health and manually fail over (behind
X-Admin-Token / scoped admin keys, audited via AdminAuditLog).
Edge Cases & Failure Modes
- All providers down: quote returns a structured
no_healthy_providers error with per-provider failure reasons — never a silent hang or a 500 masquerading as an outage.
- One provider down mid-request:
allSettled partial results; the healthy providers' quotes still rank.
- Provider deprecation (withdraws support for an asset/currency): quote must exclude it and surface the reason; in-flight
PENDING orders must still be reconcilable.
- Webhook from a provider for an order it didn't create: must be rejected (registry keying on
(provider, providerOrderId) unique constraint already does this — keep it).
- Quote drift vs. settlement: the delta alert path above; decide and document whether over-delivery (better rate) is credited or capped.
- KYC gating differences across providers: a provider that requires KYC for a currency the others don't must be flagged in the ranked list, not silently excluded.
Security & Privacy Considerations
- Never store provider secrets/PII beyond what exists today (provider-hosted checkout; no card/bank data). Per-provider API keys stay in env/secret-manager (
src/config/secrets.ts).
- Provider webhooks remain HMAC-verified and idempotent (
processProviderWebhook); the multi-provider change must not weaken the raw-body capture in src/index.ts (express.raw before the JSON parser).
- Ranked quote lists expose no PII; ensure per-user provider preferences (if added) cannot leak another user's orders.
- Rate-drift alerts must not leak customer PII into operator channels — reference order ids, not personal data.
Out of Scope
- Adding a second concrete provider implementation is in scope only as proof the abstraction works (a documented mock/sandbox provider is acceptable; wiring a real second vendor's API is desirable but not mandatory for acceptance).
- Liquidity aggregation / internal order matching — this is provider passthrough.
- FX hedging instruments — this issue makes the rate exposure visible, it does not hedge it.
Suggested Implementation Plan
- Extend
FiatQuote/FiatOrder types + FiatOrder schema (quote fields, deltas, fees, quoteId) + migration.
- Registry health ledger + selection policy + failover; metrics.
- Parallel best-execution quote flow with locking and expiry.
- Settlement delta computation +
rate_mismatch alert/webhook; partial-fill handling.
- Reconciliation cross-provider hardening.
- API routes, admin health/failover,
docs/openapi.yaml.
Acceptance Criteria
Problem Statement
Fiat rails are a single point of failure and a single point of cost.
src/fiat/registry.tsresolves a default provider andsrc/fiat/service.tscalls it unconditionally — a MoonPay outage bricks on-ramp/off-ramp entirely, and users have no way to compare what a conversion actually costs across providers. There is also no best-execution guarantee: the crypto amount quoted at order creation is whatever one provider happened to offer, with no fee breakdown and no protection against a quote that drifts before settlement. This issue turns fiat into a resilient, price-transparent, multi-provider layer with best-execution quoting, provider failover, and auditable fee/rate exposure.Current State
src/fiat/service.tsis provider-agnostic by design:getQuote/createOrder/processProviderWebhook/reconcileSingleOrdercall into aFiatRampProviderresolved fromsrc/fiat/registry.ts. The abstraction is already there — but there is exactly one provider (src/fiat/providers/moonpay.ts), the registry returns the default, and there is no multi-quote comparison, no failover, and no per-provider pricing transparency.FiatOrder(prisma/schema.prisma) recordscryptoAmount,fiatAmount,fiatCurrency,assetSymbol,status,failureReason— but no quoted-vs-settled delta, no fee line items, no rate captured at quote time, no provider_quote_id.reconcileSingleOrderonly settles against aCONFIRMEDTransactionrow owned by the same user), and reconciliation/age-out jobs exist. This issue must not weaken those invariants.Proposed Solution
1. Multi-provider registry with health + failover (
src/fiat/registry.ts)src/utils/http-client.ts's circuit breaker), and a selection policy — e.g.DEFAULT,BEST_QUOTE,ROUND_ROBIN_HEALTHY,PREFER_PROVIDER.createOrderandgetQuotemust be able to target a specific provider (from the registry, a user preference, or an explicit request field) in addition to the default path, so best-execution and failover are both expressible.PENDINGorder is processed and reconciled only against the provider that created it. Failover applies to new orders, never mid-flight ones.src/utils/metrics.ts/Prometheus) for quote latency, quote failure rate, order success/failure per provider.2. Best-execution quoting (
src/fiat/service.ts+src/fiat/types.ts)Promise.allSettled, per-provider timeout), normalizes each into a comparable quote (crypto amount for a given fiat amount, and the reverse), and:cryptoAmountis never an unexplained number.quote_expirederror and a fresh quote link — the user must see the new rate, not silently get a differentcryptoAmountat settlement.FiatQuotetype; a provider that cannot supply a breakdown must returnfees: nulland be explicitly labeled unpriced rather than assumed0.3. Rate-drift protection and settlement deltas
FiatOrderwithquoteRate,quotedCryptoAmount,fees Json?,settledRate,settledCryptoAmount,rateLockExpiresAt,providerQuoteId String?.fiat.order.rate_mismatchwebhook event — the user is entitled to know they got a worse deal than quoted.cryptoAmountthan quoted (fees eaten by network, provider rounding). Persist the realized amount and the delta; document and handle the case where the delivered amount differs enough that the provider should be re-quoted or refunded.fiatCurrencyalready exists but the quote path and rate capture must be exercised for every currency the providers support, with conversion-rate sourcing made explicit (provider-provided vs. an FX feed) and never silently assumed1.0.4. Reconciliation hardening across providers
src/jobs/fiatReconciliation.tsmust remain authoritative: with multiple providers, ensure the "match any unlinked CONFIRMED transaction" heuristic inreconcileFiatOrderscannot cross-link an order to a transaction belonging to a different provider's order (it currently keys on user + asset + unlinked; add provider-claim cross-checks and keep the strictuserIdguard).PROCESSING/paid, on-chain never arrives, order ages past threshold — the existing alert already exists; extend it to include which provider and the quoted-vs-settled expectation so operators can run provider-specific investigations.5. API surface (
src/routes/fiat.ts+docs/openapi.yaml)GET /api/v1/fiat/quotes— parallel best-execution quote with ranked provider list, fee breakdowns, quote expiry.POST /api/v1/fiat/orders— accepts either aproviderpreference or aquoteId(locked quote); rejects expired locks.GET /api/v1/fiat/orders/:id— includes quote vs. settled deltas and fee line items.X-Admin-Token/ scoped admin keys, audited viaAdminAuditLog).Edge Cases & Failure Modes
no_healthy_providerserror with per-provider failure reasons — never a silent hang or a 500 masquerading as an outage.allSettledpartial results; the healthy providers' quotes still rank.PENDINGorders must still be reconcilable.(provider, providerOrderId)unique constraint already does this — keep it).Security & Privacy Considerations
src/config/secrets.ts).processProviderWebhook); the multi-provider change must not weaken the raw-body capture insrc/index.ts(express.rawbefore the JSON parser).Out of Scope
Suggested Implementation Plan
FiatQuote/FiatOrdertypes +FiatOrderschema (quote fields, deltas, fees, quoteId) + migration.rate_mismatchalert/webhook; partial-fill handling.docs/openapi.yaml.Acceptance Criteria
GET /api/v1/fiat/quotesreturns a ranked, parallel-fetched list with structured fee breakdowns and a bounded quote validity windowquote_expiredand a fresh quotefiat.order.rate_mismatchdocs/openapi.yamlupdated; unit + integration tests (including a mock provider pair) green