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

17 KiB
Raw Blame History

title, status, audience, stack, related
title status audience stack related
Oracle Depeg Checkout — UI Implementation Guide implementation-ready with backend-contract caveats (reconciled with backend integrate-main-into-development@3a50dc4) frontend Next.js 16 (App Router) · React 19 · TypeScript · MUI 7 + Emotion · SWR · axios · Socket.io · i18next (fa default, RTL) ../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.tsxProviderPayment); 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:

{
  "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:

{
  "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):

{ "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.tsendpoints.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)

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:

payments: {
  // …existing…
  quote: '/payment/quote',
}

Hook (src/actions/payment-quote.ts), mirroring the existing SWR convention:

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 Chips: 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:
    // 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 codes 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).