--- 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 // 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)? ```