--- title: Oracle Depeg Checkout — UI Implementation Guide status: implementation-ready with backend-contract caveats (reconciled with backend integrate-main-into-development@3a50dc4) audience: frontend stack: Next.js 16 (App Router) · React 19 · TypeScript · MUI 7 + Emotion · SWR · axios · Socket.io · i18next (fa default, RTL) related: ../01 - Architecture/Oracle Pricing & Stablecoin Depeg Protection.md --- # Oracle Depeg Checkout — UI Implementation Guide > Goal: a frontend dev can implement the depeg-protected checkout step **straight from this doc**. It maps every piece to the real codebase (file:line), gives the API contract + TypeScript types, the component tree (MUI), the state machine, display/formatting/RTL rules, error UX with copy (en + fa), wireframes, and acceptance criteria. > > **Backend reality 2026-05-31:** there is no separate read-only quote preview endpoint yet. The committed backend computes and returns a quote from `POST /api/payment/request-network/intents` when `ORACLE_QUOTING_ENABLED=true`; it also writes `payment_quotes` only if a PG parent payment row can be resolved. Mongo remains the runtime source for the Payment itself. See [[Postgres Runtime Cutover Status]]. ## 0. What the buyer experiences (plain English) The seller priced the item in some currency (e.g. **IRR / TRY / USD**). The buyer picks a **settlement stablecoin + chain** (USDC/USDT on an allow-listed chain). The app fetches a **live quote**: it converts the invoice to the token at the oracle rate, **adds depeg protection** (if USDC trades at \$0.97 the buyer pays ~3% more so the seller is made whole), and **snaps to a human-readable amount** when one is within 3%. The buyer sees exactly **what they pay, in what token, ≈ the invoice currency**, the depeg adjustment, and a short **expiry countdown**. On confirm, the payment intent is created against that locked quote. ## 1. Where it slots into the existing checkout Existing flow (`src/sections/request-template/`): **cart → billing → payment → complete** (`view/request-template-checkout-view.tsx:20-77`, provider `context/request-template-checkout-provider.tsx`). **Recommended:** insert a **Quote** sub-step at the top of the existing **payment** step (Option B from the explore — least restructuring). The token/chain selector already lives in the payment step (`request-template-checkout-payment.tsx` → `ProviderPayment`); we add the quote panel directly above the pay button and **block payment until a valid (non-expired, non-blocked) quote exists**. | Touch point | File | Change | |---|---|---| | Token/chain selection | `request-template-checkout-payment.tsx` (ProviderPayment child) | On (token, chain) change → fetch quote | | Order summary | `request-template-checkout-payment.tsx:985-1050` | Add quote card + depeg banner + expiry timer above pay button | | Checkout state | `context/types.ts` | Add `oracleQuote` + `quoteStatus` fields | | API endpoints | `src/lib/axios.ts` (`endpoints` object, ~188-511) | Add `endpoints.payments.quote` | | Types | `src/types/payment.ts` | Add `IPaymentQuote` (below) | | Currency format | `src/utils/currencyUtils.ts` | Add fiat formatting (IRR/TRY) + dual-amount helper | ## 2. API contract > The amount is computed **server-side**; the UI never sends or trusts a money amount. Field names below include the target preview contract plus the currently committed `/intents` behavior. ### 2.1 Future `POST /payment/quote` — preview a quote (read-only, no Payment created) > [!warning] Not implemented in backend `3a50dc4` > Build the first frontend integration against `POST /api/payment/request-network/intents` for now. Add this preview endpoint later if the UX needs live quotes before creating/updating a pending Payment. Request: ```jsonc { "purchaseRequestId": "…", // or sellerOfferId / template session id, matching current /intents inputs "sellerOfferId": "…", "token": "USDC", // buyer's chosen settlement token "network": "bsc" // chain key (must be in the seller allowlist) } ``` Success `200`: ```jsonc { "quote": { "quoteId": "q_…", "pricingCurrency": "IRR", // the invoice/obligation currency "offerAmount": "1500000.00", // decimal STRING, in pricingCurrency "invoiceUsd": "35.50", // decimal string "token": "USDC", "chainId": 56, "tokenPriceUsd": "0.971", // depeg oracle price (decimal string) "fxRate": "0.0000236", // pricingCurrency → USD (decimal string) "rawSettleAmount": "36.56", // exact depeg-protected token amount (decimal string) "settleAmount": "37.00", // amount the buyer pays (after snap-up rounding) — decimal string "settleAmountOnChain": "37000000000000000000", // base units (per-chain decimals) — string "depegAdjustmentBps": 299, // +2.99% vs par (number) "roundingBps": 120, // rounding delta vs rawSettle (number, >=0) "fxSource": "offchain_fx", "depegSource": "chainlink", "fetchedAt": "2026-05-31T12:00:00.000Z", "expiresAt": "2026-05-31T12:01:30.000Z" // QUOTE_VALIDITY_S after fetchedAt } } ``` Error `4xx` (typed `code`): ```jsonc { "error": { "code": "DEPEG_LIMIT_EXCEEDED", "message": "…", "details": { "tokenPriceUsd": "0.93", "capBps": 500 } } } ``` ### 2.2 Intent creation (committed route, now quote-capable) `POST /api/payment/request-network/intents` and the amn.scanner path (`src/lib/axios.ts` → `endpoints.payments.requestNetwork.intents`). Current committed behavior: - The server **recomputes/validates the amount** from the offer + a fresh quote; any client `amount` is ignored. - The response can include `quote` fields when `ORACLE_QUOTING_ENABLED=true`. - Quote persistence is best-effort PG + Mongo mirror. If the PG payment row is not present yet, the quote is mirrored to Mongo and a `pg_dualwrite_gaps` row is recorded. - Binding a separately previewed `quoteId` is future work, because the read-only preview endpoint is not committed yet. ### 2.3 Error codes the UI must handle | `code` | Meaning | UI behavior | |---|---|---| | `DEPEG_LIMIT_EXCEEDED` | Stablecoin off peg beyond hard cap | Block pay; show warning banner; offer "try another token/chain" or "retry later" | | `ORACLE_UNAVAILABLE` | No provider could price the pair | Block pay; "pricing temporarily unavailable, retry"; auto-retry w/ backoff | | `ORACLE_STALE` | Rate too old | Same as unavailable; auto-refetch | | `QUOTE_EXPIRED` / `QUOTE_MOVED` | Locked quote no longer valid at submit | Re-quote; if amount moved > threshold, require explicit re-confirm | | `PAYMENT_CHOICE_NOT_ALLOWED` | token/chain not in seller allowlist | Disable that option in the selector | ## 3. TypeScript types (add to `src/types/payment.ts`) ```ts export type QuoteErrorCode = | 'DEPEG_LIMIT_EXCEEDED' | 'ORACLE_UNAVAILABLE' | 'ORACLE_STALE' | 'QUOTE_EXPIRED' | 'QUOTE_MOVED' | 'PAYMENT_CHOICE_NOT_ALLOWED'; export interface IPaymentQuote { quoteId: string; pricingCurrency: string; // 'IRR' | 'TRY' | 'USD' | 'EUR' | 'USDT' | 'USDC' offerAmount: string; // decimal string, in pricingCurrency invoiceUsd: string; token: 'USDC' | 'USDT'; chainId: number; tokenPriceUsd: string; fxRate: string; rawSettleAmount: string; settleAmount: string; // what the buyer pays (display this) settleAmountOnChain: string; // base units depegAdjustmentBps: number; // + = buyer pays more (depeg), - = buyer pays less (premium) roundingBps: number; fxSource: string; depegSource: string; fetchedAt: string; // ISO expiresAt: string; // ISO } export type QuoteStatus = | 'idle' | 'loading' | 'quoted' | 'expired' | 'requoting' | 'blocked' | 'unavailable'; ``` > **All money/rate fields are decimal strings.** Never `parseFloat` them for math — only for display formatting. If you must do arithmetic (you shouldn't on the client), use a decimal lib; the authoritative amount is always `settleAmount` from the server. ## 4. Data layer (SWR + axios) Add to the `endpoints` object in `src/lib/axios.ts`: ```ts payments: { // …existing… quote: '/payment/quote', } ``` Hook (`src/actions/payment-quote.ts`), mirroring the existing SWR convention: ```ts import useSWR from 'swr'; import { axiosInstance, endpoints, fetcher } from 'src/lib/axios'; import type { IPaymentQuote } from 'src/types/payment'; export function usePaymentQuote(args: { purchaseRequestId?: string; sellerOfferId?: string; token?: string; network?: string } | null) { // POST-based quote: use a tuple key + a custom fetcher (SWR mutate on (token,network) change) const key = args && args.token && args.network ? ['payment-quote', args] as const : null; const { data, error, isLoading, mutate } = useSWR(key, async ([, body]) => { const res = await axiosInstance.post(endpoints.payments.quote, body); return res.data.quote as IPaymentQuote; }, { refreshInterval: 0, // we drive refresh off the expiry timer, not polling revalidateOnFocus: false, shouldRetryOnError: false, // typed errors are handled by the caller, not retried blindly }); return { quote: data, error, isLoading, refetch: mutate }; } ``` - **Refetch on:** (token, chain) change, manual "refresh rate", and on expiry (timer hits 0 → `refetch()` → `requoting`). - **Map axios errors** to `QuoteErrorCode` via `err.response?.data?.error?.code`. ## 5. Component tree (MUI) ``` // new — src/sections/request-template/checkout-oracle-quote.tsx ├─ // reuse/extend ProviderPayment's Select; disable disallowed (allowlist) ├─ // the headline: "You pay 37.00 USDC ≈ ﷼1,500,000" │ ├─ dual amount (token primary, pricingCurrency secondary) │ ├─ // "+2.99% depeg protection" (Chip) │ ├─ rounding note ("rounded up 0.44 to 37.00") │ └─ ├─ // Alert when blocked/unavailable └─ used by the pay button: disabled unless status==='quoted' ``` ### 5.1 `QuoteSummaryCard` (MUI `Card`) Props: `{ quote: IPaymentQuote; status: QuoteStatus }`. - Primary line (`Typography variant="h5"`, `dir="ltr"`): **`{settleAmount} {token}`**. - Secondary (`body2`, muted): **`≈ {offerAmount} {pricingCurrency}`** (formatted, RTL-aware — see §6). - Row of `Chip`s: depeg badge, network, "rate locked · {countdown}". - If `status==='loading'|'requoting'` → MUI `Skeleton` rows. - If `roundingBps>0` → small caption: "Rounded up to a round number (within 3%)". ### 5.2 `DepegBadge` (MUI `Chip`) - `depegAdjustmentBps > 0` → color `warning`, label "Depeg protection +{bps/100}%", tooltip "Your stablecoin trades below \$1; you pay slightly more so the seller receives the full {pricingCurrency} value." - `depegAdjustmentBps < 0` → color `success`, label "Premium −{|bps|/100}%", tooltip "Your stablecoin trades above \$1; you pay slightly less." - `=== 0` → hide or neutral "At peg". ### 5.3 `QuoteExpiryTimer` - Counts down to `expiresAt`. At `T-15s` turn amber; at `0` call `onExpire()` (sets `requoting`, refetches). Show "Refresh rate" button always. ### 5.4 `DepegWarningBanner` (MUI `Alert`) - `DEPEG_LIMIT_EXCEEDED` → severity `error`, "We can't price {token} safely right now (it's {x}% off peg). Try another token/chain or retry shortly." + retry button. - `ORACLE_UNAVAILABLE`/`ORACLE_STALE` → severity `warning` + auto-retry spinner. ## 6. Formatting, RTL & i18n - **Amounts are LTR even in RTL layouts** — wrap every number/token/hash in `dir="ltr"` (project convention, see explore §4 + CLAUDE.md). The card layout flips with the theme (`stylis-plugin-rtl`), but the numerals don't. - Extend `src/utils/currencyUtils.ts`: ```ts // fiat display: IRR/TRY have no decimals typically; group thousands per locale export function formatFiat(amount: string, currency: string, locale?: string): string; // dual display helper used by the card export function formatPayLine(quote: IPaymentQuote): { primary: string; secondary: string }; ``` - IRR: symbol ﷼, 0 decimals, fa-IR grouping. TRY: ₺, 2 decimals. USDT/USDC: 2 decimals. - **i18n keys** (add to `src/locales/langs/{en,fa}/messages.json`), e.g.: ``` checkout.quote.youPay = "You pay {{amount}} {{token}}" checkout.quote.approx = "≈ {{amount}} {{currency}}" checkout.quote.depegProtection = "Depeg protection +{{pct}}%" checkout.quote.premium = "Premium −{{pct}}%" checkout.quote.roundedUp = "Rounded up to {{amount}} {{token}}" checkout.quote.expiresIn = "Rate locked · {{seconds}}s" checkout.quote.refresh = "Refresh rate" checkout.quote.err.depegCap = "Can't price {{token}} safely ({{pct}}% off peg). Try another token or retry." checkout.quote.err.unavailable = "Pricing temporarily unavailable. Retrying…" checkout.quote.err.expired = "Rate updated — please review the new amount." ``` Persian (`fa`) is the default locale — provide fa strings too. ## 7. State machine ``` idle ──(token+chain chosen)──► loading ──ok──► quoted ──(timer 0)──► requoting ──ok──► quoted │ │ │ └─err─► unavailable/blocked └─err─► unavailable/blocked quoted ──(buyer confirms)──► [POST intents] ──QUOTE_EXPIRED/MOVED──► requoting (then re-confirm if moved > 50 bps) └─ok──► existing payment-pending flow (Socket.io) ``` - **Pay button** enabled **only** in `quoted`. In `blocked`/`unavailable` it's disabled with the banner explaining why. - After intent creation succeeds, hand off to the **existing** Socket.io payment-status flow (`request-template-checkout-payment.tsx:457-736`) — unchanged. ## 8. Wireframe (quoted, depeg case) ``` ┌─────────────────────────────────────────────┐ │ Pay with: [ USDC ▾ ] on [ BSC ▾ ] │ ├─────────────────────────────────────────────┤ │ You pay │ │ 37.00 USDC ← h5, dir=ltr │ │ ≈ ﷼1,500,000 ← muted, RTL-aware │ │ │ │ [ +2.99% depeg protection ] [ BSC ] │ │ Rounded up to a round number (within 3%) │ │ Rate locked · 01:18 [ Refresh rate ] │ ├─────────────────────────────────────────────┤ │ [ Pay 37.00 USDC ] │ └─────────────────────────────────────────────┘ Blocked (DEPEG_LIMIT_EXCEEDED): ┌─────────────────────────────────────────────┐ │ ⚠ Can't price USDC safely (7% off peg). │ │ Try another token/chain or retry shortly. │ │ [ Retry ] [ Change token ] │ └─────────────────────────────────────────────┘ ``` ## 9. Edge cases - **Token/chain not allowed** → disable the option (`PAYMENT_CHOICE_NOT_ALLOWED`), don't even quote. - **Quote expires while buyer idles** → auto-`requoting`; if `settleAmount` moves > 50 bps, surface "Rate updated — review new amount" before re-enabling pay. - **Network flip mid-quote** → cancel in-flight quote (SWR key change handles it), show skeleton. - **Premium (token > \$1)** → show green "Premium −x%", buyer pays less; never below the obligation. - **Decimal precision** → display rounds for humans, but submit/track uses the server `settleAmount`/`settleAmountOnChain` strings verbatim. - **Slow oracle** → skeleton + "fetching live rate"; don't show a stale amount. ## 10. Acceptance criteria 1. Changing token or chain refetches a quote; pay is disabled until `quoted`. 2. Displayed amount always equals server `settleAmount`; the client never computes or sends an amount. 3. Depeg up shows a warning-colored badge and a higher token amount; premium shows a success badge and a lower amount; seller is never shorted. 4. Beyond the hard cap, pay is blocked with a clear banner (no silent overcharge). 5. Expiry countdown works; on expiry it re-quotes; a > 50 bps move forces re-confirm. 6. Amounts render `dir="ltr"` inside the RTL (fa) layout; fa + en strings present. 7. Successful confirm transitions into the existing Socket.io payment-status UI unchanged. ## 11. Backend dependencies to confirm (with backend `integrate-main-into-development@3a50dc4`) - [ ] `POST /payment/quote` preview endpoint exists and returns the shape in §2.1 (add it if the build only wired the `/intents` seam). - [ ] Intent route accepts/validates `quoteId` and returns `QUOTE_EXPIRED`/`QUOTE_MOVED`. - [ ] Typed error `code`s in §2.3 are emitted. - [ ] `TRY` (and any other) pricing currencies enabled. - [ ] Final field names match §3 (this doc will be reconciled to the committed code). ```