Files
nick-doc/01 - Architecture/Oracle Pricing & Stablecoin Depeg Protection.md

12 KiB
Raw Blame History

title, status, owner, created, branch, storage
title status owner created branch storage
Oracle Pricing & Stablecoin Depeg Protection implemented on backend integrate-main-into-development backend 2026-05-31 backend integrate-main-into-development at 3a50dc4 Postgres `payment_quotes` plus Mongo `Payment.quote` mirror during dual-write

Oracle Pricing & Stablecoin Depeg Protection

1. Goal

Let sellers price in any supported currency (USD, EUR, IRR, TRY, … plus the stablecoins themselves), let the buyer settle in their chosen stablecoin + chain (USDC/USDT on the allow-listed chains), and compute the on-chain amount server-side from a live price quote so that:

  • Primary: depeg protection. An invoice is a value obligation in the pricing currency. If the buyer's chosen stablecoin is off its peg (e.g. USDC @ $0.97), the buyer pays proportionally more of that token so the seller still receives the full value.
  • Buyers see a human-readable amount when one is within 3% of the exact figure.
  • The amount is never trusted from the client — it is derived from the seller's offer price + oracle rates on the server.

2. Current state (as built)

From the promoted backend branch (integrate-main-into-development at 3a50dc4):

Concern Where Note
Seller price (invoice) src/models/SellerOffer.ts:9-12,53-64 price.amount + price.currency enum ['USD','EUR','IRR','TRY','USDT','USDC']source of truth
Buyer budget src/models/PurchaseRequest.ts:169-182 now ['USDT','USDC'] only
Payment amount src/models/Payment.ts:31-40 amount.amount + amount.currency; set at intent creation
Intent route src/services/payment/requestNetwork/requestNetworkRoutes.ts /api/payment/request-network/intents ignores client amount when ORACLE_QUOTING_ENABLED=true, loads the seller offer price, computes the quote, and uses quote.settleAmount
Provider dispatch same, :410-413 amn.scanner vs Request Network
Token decimals src/services/payment/requestNetwork/tokens.ts (lookupTokenBySymbol) per-chain decimals (BSC 18, ETH 6, …)
Unit conversion src/utils/currencyUtils.ts:75,94 tokenToBlockchainUnits / blockchainUnitsToToken; stablecoins assumed 1:1 USD (:48)
Seller allowlist src/services/payment/sellerPaymentConfig.ts:44-125 resolveSellerPaymentConfig + assertPaymentChoiceAllowed

Two gaps this feature closes: (a) no FX/oracle layer — stablecoins were hard-assumed 1:1 with USD; (b) the provider-selection settlement amount was client-supplied (a fund-safety hole independent of depeg).

3. Locked design decisions

# Decision Choice
Depeg policy Protect seller, cap downside. Token < $1 ⇒ buyer pays more (seller made whole). Token > $1 ⇒ par-neutral pass-through (buyer pays less). Depeg beyond a hard capblock + require re-confirm, never silently overcharge.
Rounding Snap up within 3%, or down only if the result still fully covers the depeg-protected obligation. Rounding may never leave the seller short.
Oracle Chainlink on-chain for stablecoin/USD + major FX, with a pluggable off-chain TS provider fallback for exotic fiat (IRR, TRY). Cross-source agreement + staleness guard.
Scope/branch Originally built on feat/oracle-depeg-protection, then backported onto the Postgres branch and promoted to backend integrate-main-into-development at 3a50dc4.

4. The quote math

All arithmetic in decimal (decimal.js / PG numeric) — never JS float (consistent with the money-core migration's decimal-string contract).

invoiceUSD     = offer.amount × fxRate(offer.currency → USD)      # FX oracle; for USDT/USDC pricing, ≈1 but still depeg-checked
tokenPriceUSD  = depegRate(settlementToken → USD)                 # depeg oracle (Chainlink T/USD)
rawSettle      = invoiceUSD / tokenPriceUSD                       # depeg protection
# premium pass-through is par-neutral: if tokenPriceUSD > 1, rawSettle < invoice (buyer benefits)
settle         = niceRound(rawSettle)   if niceRound(rawSettle) ≥ rawSettle*(1)  AND  |nice  raw|/raw ≤ 0.03
               = rawSettle               otherwise
onChainUnits   = tokenToBlockchainUnits(settle, token, chainId)   # per-chain decimals

niceRound ladder (scaled to magnitude of rawSettle): candidates from {1, 2, 5}×10^k and the nearest 10^k / 100|10|1 step; pick the smallest nice number ≥ rawSettle within 3%, else the nearest nice number ≥ obligation. Never returns a value < rawSettle's underlying fiat obligation.

Guardrails:

  • Staleness: each rate has fetchedAt; reject/refresh if older than ORACLE_MAX_STALENESS_S.
  • Circuit breaker: if |1 tokenPriceUSD| > DEPEG_HARD_CAP_BPS (e.g. 500 bps), do not auto-quote — return a DEPEG_LIMIT_EXCEEDED error; checkout must re-confirm or wait.
  • Cross-source agreement: if two providers disagree by more than ORACLE_DISAGREE_BPS, treat as untrusted (block or fall back to the more authoritative source).
  • FX sanity bounds per fiat (esp. IRR free-market vs official): configurable min/max plausible band.

5. Oracle abstraction

// src/services/payment/priceOracle/types.ts
export interface Rate { base: string; quote: string; price: string; decimals: number; fetchedAt: number; source: string }
export interface PriceProvider {
  id: string
  supports(base: string, quote: string): boolean
  getRate(base: string, quote: string): Promise<Rate>   // throws on unsupported / stale / unreachable
}
  • ChainlinkProvider — reads on-chain aggregator feeds per chain (feed address registry, like tokens.json). Covers USDC/USD, USDT/USD, EUR/USD, etc.
  • OffchainFxProvider — pluggable HTTP/TS snippet provider for fiat without on-chain feeds (IRR, TRY). Config-driven endpoint(s).
  • PriceOracle aggregator — routes each pair to the best provider, applies fallback order, staleness + cross-source agreement, returns a single trusted Rate (or throws).

Providers are registered in a small registry (same pattern as the payment-provider registry) so adding a source = adding a file, no core changes — satisfies "oracle can be a TypeScript snippet."

6. Quote lifecycle & storage

  1. At POST …/intents, ignore any client amount; load the SellerOffer price (server-authoritative).
  2. Validate (token, chain) against the seller allowlist (assertPaymentChoiceAllowed).
  3. PriceOraclefxRate, tokenPriceUSD; run guardrails.
  4. Compute rawSettlesettle (rounding) → onChainUnits.
  5. Persist a locked quote in Postgres and mirror it on the Mongo Payment, then use settle as the intent amount.

Payment quote fields (Mongo mirror plus Postgres payment_quotes row):

quote: {
  quoteId, pricingCurrency, offerAmount, invoiceUSD,
  fxRate, fxSource, tokenPriceUSD, depegSource,
  rawSettleAmount, settleAmount, roundingBps, depegAdjustmentBps,
  token, chainId, fetchedAt, expiresAt
}
  • Validity window QUOTE_VALIDITY_S (default 60120 s). On expiry → re-quote before submit; never settle against a stale quote.
  • The quote is immutable once a payment is detected (audit trail of exactly what rate the buyer agreed to).

7. Data-model changes (Postgres-native)

Because the feature was promoted through the money-core migration branch, the quote is stored natively in Postgres via the Drizzle schema/repos:

  • Drizzle schema: payment_quotes child table keyed by payment_id -> payments.id — decimal columns (numeric(38,18)) for offer_amount, invoice_usd, fx_rate, token_price_usd, raw_settle_amount, settle_amount; text for currencies/sources; rounding_bps, depeg_adjustment_bps, fetched_at, expires_at. Additive migration 0008, preserving every 0005/0006 money-safety object.
  • Pricing-currency enum: extend budget_currency / the offer currency enum to add TRY (and any others) — additive.
  • Mongoose Payment: mirror the quote sub-document so dual-write stays consistent during migration.
  • The quote write goes through quoteRepo.persistQuoteForMongoPayment(), resolving the PG parent through payments.legacy_object_id and then id_map. If the PG payment row is not present yet, the backend still mirrors the quote to Mongo and records a pg_dualwrite_gaps row for reconciliation.

8. Integration seam

src/services/payment/requestNetwork/requestNetworkRoutes.ts, before provider dispatch — for both the amn.scanner and Request Network paths when ORACLE_QUOTING_ENABLED=true:

- amount: Number(amount)                      // REMOVE: client-trusted
+ const quote = await priceOracle.quote({ offer, token, network, sellerConfig })
+ // amount := quote.settleAmount ; attach quote to the Payment + intent input

Request Network already takes invoiceCurrency + paymentCurrency (requestNetworkService.ts:140-149) — we still compute and store our own quote for depeg auditing and to keep AMN/RN consistent.

9. Config (env)

ORACLE_QUOTING_ENABLED=false
PRICE_ORACLE_PROVIDERS=chainlink,offchain_fx     # order = fallback order
ORACLE_MAX_STALENESS_S=120
ORACLE_DISAGREE_BPS=100
DEPEG_HARD_CAP_BPS=500            # block beyond 5% depeg
QUOTE_VALIDITY_S=90
REQUOTE_RECONFIRM_BPS=50
OFFCHAIN_FX_URL=                 # IRR/TRY source
OFFCHAIN_FX_REQUEST_TIMEOUT_MS=8000
CHAINLINK_RPC_1=                 # Ethereum Chainlink reads
CHAINLINK_RPC_56=                # BSC Chainlink reads

10. Fund-safety considerations

  • Server-authoritative amount — client amount is ignored; derived from offer + oracle (closes the existing trust hole at :404).
  • Decimal-exact end-to-end (no float), matching the money-core contract.
  • Quote tamper / replay — quote is server-computed, stored, time-boxed, and frozen once payment detected.
  • Oracle manipulation — cross-source agreement, staleness, hard caps, FX sanity bands; prefer Chainlink (manipulation-resistant) for stablecoins.
  • Rounding never shorts the sellersettle ≥ obligation.
  • Premium handling is par-neutral — buyer benefits when token > $1, seller never overcharged.

11. Failure modes

Failure Behavior
All providers down / stale Block checkout with a clear retry error (no guessed rate)
Depeg > hard cap DEPEG_LIMIT_EXCEEDED — require explicit re-confirm
Provider disagreement Use authoritative source or block
Quote expired at submit Re-quote; if materially changed, re-confirm with buyer

12. Testing

  • Unit: quote math (depeg up/down, premium par-neutral, rounding-up-only, never-below-obligation), niceRound ladder across magnitudes (IRR millions → USDC cents).
  • Oracle: provider fallback, staleness, disagreement, hard-cap circuit breaker.
  • Property: settle × tokenPriceUSD ≥ invoiceUSD always (seller made whole); rounding error ≤ 3%.
  • Integration: both /intents paths produce a stored quote; client-sent amount is ignored.

13. Implementation status

  • Oracle corePriceProvider interface, registry, PriceOracle aggregator, off-chain FX provider, Chainlink provider, and env-driven provider order are implemented.
  • Quote engine — decimal math, depeg policy, nice rounding, guardrails, Payment.quote mirror, payment_quotes, and TRY pricing support are implemented.
  • Seam wiring/intents computes the server-side amount for both provider paths when ORACLE_QUOTING_ENABLED=true.
  • Tests — oracle/depeg, request-network pay-in, adapter, webhook, and sweep service suites passed during promotion. PG decimal integration cases require local PG_URL / MIGRATION_PG_URL to run.

14. Open questions

  • IRR: official vs free-market rate source (and which is "truth" for invoicing)?
  • Do we expose the live quote (rate, depeg %, expiry) to the buyer UI before they confirm?
  • Re-confirm threshold when a re-quote moves the amount (e.g. > X bps)?