docs: sync from backend 3a50dc4 - promote postgres integration

This commit is contained in:
Siavash Sameni
2026-05-31 14:20:11 +04:00
parent 622dbe4dcb
commit 773f5db454
7 changed files with 329 additions and 13 deletions

View File

@@ -0,0 +1,180 @@
---
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: 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. 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 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)?
```