12 KiB
title, status, owner, created, branch, storage
| title | status | owner | created | branch | storage |
|---|---|---|---|---|---|
| Oracle Pricing & Stablecoin Depeg Protection | implemented on backend integrate-main-into-development | backend | 2026-05-31 | backend integrate-main-into-development at 3a50dc4 | 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 thanORACLE_MAX_STALENESS_S. - Circuit breaker: if
|1 − tokenPriceUSD| > DEPEG_HARD_CAP_BPS(e.g. 500 bps), do not auto-quote — return aDEPEG_LIMIT_EXCEEDEDerror; 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
// 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, liketokens.json). CoversUSDC/USD,USDT/USD,EUR/USD, etc.OffchainFxProvider— pluggable HTTP/TS snippet provider for fiat without on-chain feeds (IRR, TRY). Config-driven endpoint(s).PriceOracleaggregator — routes each pair to the best provider, applies fallback order, staleness + cross-source agreement, returns a single trustedRate(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
- At
POST …/intents, ignore any clientamount; load theSellerOfferprice (server-authoritative). - Validate
(token, chain)against the seller allowlist (assertPaymentChoiceAllowed). PriceOracle→fxRate,tokenPriceUSD; run guardrails.- Compute
rawSettle→settle(rounding) →onChainUnits. - Persist a locked quote in Postgres and mirror it on the Mongo Payment, then use
settleas 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_quoteschild table keyed bypayment_id -> payments.id— decimal columns (numeric(38,18)) foroffer_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 migration0008, preserving every0005/0006money-safety object. - Pricing-currency enum: extend
budget_currency/ the offer currency enum to addTRY(and any others) — additive. - Mongoose
Payment: mirror thequotesub-document so dual-write stays consistent during migration. - The quote write goes through
quoteRepo.persistQuoteForMongoPayment(), resolving the PG parent throughpayments.legacy_object_idand thenid_map. If the PG payment row is not present yet, the backend still mirrors the quote to Mongo and records apg_dualwrite_gapsrow 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
amountis 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 ≥ invoiceUSDalways (seller made whole); rounding error ≤ 3%. - Integration: both
/intentspaths produce a stored quote; client-sent amount is ignored.
13. Implementation status
- Oracle core —
PriceProviderinterface, registry,PriceOracleaggregator, off-chain FX provider, Chainlink provider, and env-driven provider order are implemented. - Quote engine — decimal math, depeg policy, nice rounding, guardrails,
Payment.quotemirror,payment_quotes, andTRYpricing support are implemented. - Seam wiring —
/intentscomputes the server-side amount for both provider paths whenORACLE_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_URLto 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)?