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

12 KiB
Raw Permalink 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 conditional 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. When ORACLE_QUOTING_ENABLED=true, persist a locked quote in Postgres if the PG parent payment row exists, 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-capable, not full cutover)

Because the feature was promoted through the money-core migration branch, the quote can be stored natively in Postgres via the Drizzle schema/repos. The live payment record remains Mongo-backed until the payment service itself is wired through the PG repository path:

  • 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)?