---
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)

```
<OracleQuotePanel>                     // new — src/sections/request-template/checkout-oracle-quote.tsx
├─ <TokenChainSelector/>              // reuse/extend ProviderPayment's Select; disable disallowed (allowlist)
├─ <QuoteSummaryCard quote=… status=…>// the headline: "You pay 37.00 USDC ≈ ﷼1,500,000"
│   ├─ dual amount (token primary, pricingCurrency secondary)
│   ├─ <DepegBadge bps=…/>            // "+2.99% depeg protection" (Chip)
│   ├─ rounding note ("rounded up 0.44 to 37.00")
│   └─ <QuoteExpiryTimer expiresAt=… onExpire=refetch/>
├─ <DepegWarningBanner code=…/>       // 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).
```
