Files
nick-doc/05 - Design System/Oracle Depeg Checkout — UI Implementation Guide.md

287 lines
17 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
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).
```