181 lines
12 KiB
Markdown
181 lines
12 KiB
Markdown
---
|
||
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 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-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)?
|
||
```
|