docs: sync from backend 3a50dc4 - promote postgres integration
This commit is contained in:
@@ -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 60–120 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)?
|
||||
```
|
||||
Reference in New Issue
Block a user