docs: sync from backend cab0719 - align request budget validation
This commit is contained in:
@@ -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).
|
||||
```
|
||||
Reference in New Issue
Block a user