docs: sync from backend cab0719 - align request budget validation

This commit is contained in:
Siavash Sameni
2026-05-31 14:46:59 +04:00
parent 773f5db454
commit 0bd3fe5598
25 changed files with 5976 additions and 48 deletions

View File

@@ -0,0 +1,286 @@
---
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).
```