Skip to content

Multi-Provider Fiat Aggregation with Best-Execution Quoting & Fee Transparency #313

Description

@robertocarlous

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

  1. Extend FiatQuote/FiatOrder types + FiatOrder schema (quote fields, deltas, fees, quoteId) + migration.
  2. Registry health ledger + selection policy + failover; metrics.
  3. Parallel best-execution quote flow with locking and expiry.
  4. Settlement delta computation + rate_mismatch alert/webhook; partial-fill handling.
  5. Reconciliation cross-provider hardening.
  6. API routes, admin health/failover, docs/openapi.yaml.

Acceptance Criteria

  • Registry supports multiple providers with health tracking and a documented selection policy; failover is observable (metrics)
  • GET /api/v1/fiat/quotes returns a ranked, parallel-fetched list with structured fee breakdowns and a bounded quote validity window
  • Orders pin the provider that created them; a provider's webhook cannot mutate another provider's order
  • Expired quote locks are rejected with quote_expired and a fresh quote
  • Quoted-vs-settled deltas persisted and surfaced on the order; drift beyond tolerance alerts + emits fiat.order.rate_mismatch
  • Partial fills are reconciled to realized amounts with the delta recorded
  • Reconciliation across multiple providers cannot cross-link orders (test with two providers, same user, same asset)
  • docs/openapi.yaml updated; unit + integration tests (including a mock provider pair) green

Metadata

Metadata

Assignees

Labels

GrantFox OSSIssue tracked in GrantFox OSSMaybe RewardedIssue may be eligible for a GrantFox rewardOfficial Campaign | FWC26Campaign: Official Campaign | FWC26Third CampaignCampaign: Third CampaignenhancementNew feature or request

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions