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

181 lines
12 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
title: Oracle Pricing & Stablecoin Depeg Protection
status: implemented on backend integrate-main-into-development
owner: backend
created: 2026-05-31
branch: backend integrate-main-into-development at 3a50dc4
storage: 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 cap****block + 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
```ts
// 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. `PriceOracle``fxRate`, `tokenPriceUSD`; run guardrails.
4. Compute `rawSettle``settle` (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 seller** — `settle ≥ 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 core** — `PriceProvider` 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)?
```