287 lines
17 KiB
Markdown
287 lines
17 KiB
Markdown
---
|
||
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).
|
||
```
|