diff --git a/.eslintrc.json b/.eslintrc.json index 99173d2..f63164c 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -30,12 +30,40 @@ "selector": "BinaryExpression[operator='**'][right.value=7]", "message": "Use USDC_BASE_UNIT / fromStroops from src/lib/format.ts instead of 10 ** 7." } + ], + "no-restricted-imports": [ + "error", + { + "patterns": [ + { + "group": [ + "@/lib/realtime/dev", + "@/lib/realtime/dev/*", + "**/realtime/dev/*" + ], + "message": "src/lib/realtime/dev is test/dev tooling and must never reach production bundles (see docs/realtime.md)." + }, + { + "group": [ + "@/lib/realtime/client", + "@/lib/realtime/entry" + ], + "message": "Load the realtime client lazily with loadRealtime() from @/lib/realtime/load (bundle budget, see docs/realtime.md)." + } + ] + } ] }, "overrides": [ { - "files": ["src/lib/realtime/**"], - "rules": { "no-restricted-imports": "off" } + "files": [ + "src/lib/realtime/**", + "**/*.test.ts", + "**/*.test.tsx" + ], + "rules": { + "no-restricted-imports": "off" + } }, { "files": [ @@ -50,60 +78,23 @@ }, { "files": [ - "src/**/*.{ts,tsx}" - ], - "excludedFiles": [ - "src/lib/realtime/**", - "**/*.test.ts", - "**/*.test.tsx", + "files": ["src/lib/realtime/**"], + "rules": { "no-restricted-imports": "off" } + }, + { + "files": [ "src/lib/coverage/metadata.ts", "src/lib/coverage/colors.ts", "src/app/global-error.tsx", "src/lib/format.ts" ], "rules": { - "no-restricted-syntax": [ - "error", - { - "selector": "Literal[value=/^#(?:[0-9a-fA-F]{3,4}){1,2}$/]", - "message": "Hard-coded hex colours are not allowed in JSX. Use design tokens from src/lib/coverage/metadata.ts or CSS custom properties (see CONTRIBUTING.md)." - }, - { - "selector": "Literal[value=/rgba?\\(/]", - "message": "Hard-coded rgb()/rgba() colours are not allowed in JSX. Use design tokens from src/lib/coverage/metadata.ts or CSS custom properties (see CONTRIBUTING.md)." - }, - { - "selector": "Literal[value=1e7]", - "message": "Use USDC_BASE_UNIT / fromStroops from src/lib/format.ts instead of the literal 1e7." - }, - { - "selector": "BinaryExpression[operator='**'][right.value=7]", - "message": "Use USDC_BASE_UNIT / fromStroops from src/lib/format.ts instead of 10 ** 7." - }, - { - "selector": "NewExpression[callee.name=/^(WebSocket|EventSource)$/]", - "message": "Do not open sockets/streams directly. Subscribe through src/lib/realtime (realtimeClient) so dedup, backoff, auth and teardown apply. To opt out, add `// eslint-disable-next-line no-restricted-syntax -- `." - }, - { - "selector": "CallExpression[callee.property.name='subscribe'][callee.object.name='realtimeClient']:not(:matches(CallExpression[callee.name=/^use(Effect|LayoutEffect)$/] *))", - "message": "realtimeClient.subscribe must be called inside a useEffect/useLayoutEffect callback so the returned unsubscribe runs on unmount." - } - ], - "no-restricted-imports": [ - "error", - { - "patterns": [ - { - "group": [ - "@/lib/realtime/*", - "**/lib/realtime/*", - "./realtime/*" - ], - "message": "Import from the public entry point \"@/lib/realtime\" instead of realtime internals." - } - ] - } - ] + "no-restricted-syntax": "off" + } + }, + { + "files": [ + } } ] diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0af43cd..6f071c3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -90,6 +90,9 @@ jobs: env: NEXT_TELEMETRY_DISABLED: "1" + - name: Realtime bundle assertions + run: npm run size:realtime + - name: Start production server run: npm run start & env: diff --git a/.size-limit.json b/.size-limit.json index cc36d8a..657a661 100644 --- a/.size-limit.json +++ b/.size-limit.json @@ -1,7 +1,7 @@ [ { - "name": "shared JS (all routes)", - "path": ".next/static/chunks/*.js", + "name": "shared JS (all routes, excluding lazy realtime chunk)", + "path": [".next/static/chunks/*.js", "!.next/static/chunks/realtime*.js"], "limit": "250 kB", "gzip": true }, @@ -22,5 +22,11 @@ "path": ".next/static/chunks/app/**/*.js", "limit": "400 kB", "gzip": true + }, + { + "name": "realtime client (lazy chunk)", + "path": ".next/static/chunks/realtime*.js", + "limit": "8 kB", + "gzip": true } ] diff --git a/docs/realtime.md b/docs/realtime.md index d81d641..849d1da 100644 --- a/docs/realtime.md +++ b/docs/realtime.md @@ -1,99 +1,98 @@ -# Realtime Layer: Architecture & Operational Runbook +# Realtime layer -This document describes the optional realtime layer that sits alongside the -REST path. It is written for maintainers and for the `refract-backend` team. +How the frontend talks to the Refract backend's push channel, and the guardrails around it. Code lives in `src/lib/realtime/`. > **Status:** the realtime backend contract is *documented, not yet implemented -> upstream*. The client code in `src/lib/realtime/client.ts` is written against -> the contract below, but `refract-backend` does not yet serve it. Until it -> does, the layer stays behind feature flags (see the flag matrix) and every +> upstream*. The client code in `src/lib/realtime/` is written against the +> contract below, but `refract-backend` does not yet serve it. Until it does, +> the layer stays behind feature flags (see the flag matrix) and every > consumer falls back to REST. -## Module layout +## Modules -The docs mirror the actual module layout so every path below is verifiable: +| Module | Role | Loaded | +| --- | --- | --- | +| `load.ts` | `loadRealtime()`: the only way UI code reaches the client | eagerly (tiny) | +| `entry.ts` | Lazy entry: `getRealtimeClient()`, `parsePolicyEvent()` | lazily, `realtime` chunk | +| `client.ts` | Reconnecting WebSocket client, heartbeat, diagnostics | lazily, via `entry.ts` | +| `codec.ts` | Frame decoding + size guard shared by all transports | lazily, via `client.ts` | +| `clockOffset.ts` | Server/client clock-offset estimator, `normalizeServerTs` | eagerly (small, no deps) | +| `topics.ts` | Topic names | eagerly | +| `policyEvents.ts` | zod schemas for `policy.
` payloads | lazily, via `entry.ts` | +| `pendingPolicies.ts` | Pending-purchase state machine + persisted store | eagerly (used by /cover) | +| `dev/` | Test/dev tooling (fake socket). **Never shipped.** | tests only | -| Path | Responsibility | -| --- | --- | -| `src/lib/realtime/client.ts` | Connection lifecycle, subscribe/unsubscribe, reconnect | -| `src/lib/realtime/capabilities.ts` | Capability negotiation with the backend | -| `src/lib/realtime/merge.ts` | Merging realtime payloads into cached REST data | -| `src/lib/realtime/shadow.ts` | Shadow mode: observe realtime without applying it | -| `src/lib/realtime/contract.ts` | Topics, validators, fixtures, frames | -| `src/lib/realtime/testing.ts` | Fake transport for tests (never import from production) | -| `public/flags.json` | Per-topic feature flag defaults | +## Wire contract -## Topic / message contract +Every frame is JSON: `{ "topic": string, "payload": unknown, "ts"?: number }`. `ts` is the server clock in ms since epoch. -The contract is authoritative here and is cross-checked against the schema -module. A client subscribes to a **topic** (a string key, e.g. a resource -collection). The backend pushes **messages** on that topic. +### Control topics -A message has the shape: +| Topic | Direction | Payload | +| --- | --- | --- | +| `refract.ping` | client → server | `{ t0 }`: client clock when sent | +| `refract.pong` | server → client | `{ t0, serverTs }`: echo of `t0` plus the server clock | +| `refract.wire_stats` | server → client | `{ compressedBytes, uncompressedBytes }`, cumulative for the connection (optional) | -```jsonc -{ - "topic": "", // must match the subscribed topic - "type": "update" | "delete", // mutation kind - "payload": { /* resource */ }, // full resource for update, id for delete - "seq": 12345 // monotonic per-topic sequence number -} -``` +The client pings on connect and every 25 s (`HEARTBEAT_INTERVAL_MS`). Control topics are never delivered to `subscribe()` handlers. -- `seq` is monotonic per topic and is used to detect gaps and stale messages. -- Messages are applied through `merge.ts`, which reconciles them with the - cached REST snapshot rather than replacing it wholesale. -- Unknown `type` values are ignored (forward compatibility). +### `policy.
` (#246) -## Request/response +One topic per wallet. Payloads are discriminated by `type`. All of them carry `holder` and `ts`. -`realtimeClient.request(topic, payload, { timeoutMs, signal })` multiplexes a -one-shot query over the open WebSocket. +| `type` | Meaning | Ids | +| --- | --- | --- | +| `accepted` | Backend saw the purchase transaction | `txHash` (+ `policyId?`) | +| `issued` | Policy is on-chain and active | `txHash`, `policyId` (+ `coverageTypeName?`, `coverageAmount?`, `premium?`, `expiresAt?`) | +| `premium_charged` | A premium charge was recorded | `policyId`, `amount` | +| `cancelled` | Policy cancelled after issuance | `policyId`, `reason` ∈ `holder_requested \| pool_insufficient \| expired \| oracle_triggered` | +| `failed` | Purchase failed before issuance | `txHash`, `reason` | -- Client frame: `{ id, topic, request }`; server replies `{ id, response }` or - `{ id, error: { code, message } }`. Frames with an `id` never reach topic - subscribers. -- Rejects with `RealtimeRequestError` (`timeout | aborted | disconnected | - overflow | server`). Pending requests reject on disconnect/reconnect and are - never replayed; at most 100 may be in flight. Payloads are never logged. -- SSE/long-poll fallbacks do not support requests; use REST. +Amounts are base-unit USDC strings, as in the REST API. -## Connection state machine +## Cover purchase flow (#246) -`client.ts` drives a small state machine: +1. `/cover` calls the REST purchase endpoint, the wallet signs, and the transaction is submitted. **The tx hash from that call is the source of truth.** +2. The page adds an optimistic entry, keyed by tx hash, to `usePendingPolicyStore`. The store is persisted to `localStorage`. +3. While the wallet has an unsettled entry, `usePolicyStream(address)` subscribes to `policy.
`. Events move the entry `submitted → accepted → active`, or roll it back to `failed`. They never move it backwards, so duplicate and out-of-order events are harmless. +4. If no event settles an entry within 90 s (`PENDING_WAIT_MS`), the REST result stands: the entry becomes `active` with `confirmedBy: "rest"`. A later `issued` event upgrades it to `confirmedBy: "realtime"`. -``` -idle ──connect──▶ connecting ──open──▶ open - │ │ - │ error │ error / close - ▼ ▼ - reconnecting ◀─────────┘ - │ - │ (backoff exhausted) - ▼ - fallback (REST only) -``` +Edge cases: -- **idle**: no socket, no intent to connect. -- **connecting**: socket opening; capability negotiation runs here. -- **open**: subscribed and receiving messages; heartbeat keeps it alive. -- **reconnecting**: transient failure; exponential backoff with jitter. -- **fallback**: realtime disabled for this session; REST polling resumes. +- **Navigating away:** the entry is persisted. On return, the hook re-subscribes and the existing row updates in place. Nothing is duplicated, because rows are keyed by tx hash. +- **Multiple tabs:** a `storage` listener rehydrates the store. Adding the same tx hash twice merges into one row. `findInFlightDuplicate` blocks buying an identical policy while one is still settling. +- **Policies bought elsewhere:** an `issued` (or `accepted`) event for an unknown tx hash creates a row marked `external`. -Heartbeat: the client expects periodic pings; a missed heartbeat window -moves the connection to `reconnecting`. +## Clock skew (#247) -## Fallback-to-REST semantics +Never compute `Date.now() - event.ts`. Use: -Realtime is **optional**. When any of the following is true, the client uses -REST only and never blocks the UI: +- `normalizeServerTs(ts)` to convert server time to client time +- `serverEventAgeMs(ts)` and `isServerTsStale(ts, maxAgeMs)` for freshness decisions +- `useServerRelativeTime(ts)` or `formatServerRelativeTime(ts)` for "4 minutes ago" text. The hook re-renders only when the text changes. -- the realtime feature flag for the topic is off (the default), -- capability negotiation fails or the backend does not advertise realtime, -- the connection enters `fallback` after backoff is exhausted. +The estimator (`ClockOffsetEstimator`) takes NTP-style mid-point samples from each ping/pong. It uses the median offset of the lowest-RTT samples in an 8-sample window, so a single slow or wild sample doesn't move it. `uncertaintyMs` is half the best RTT. If the wall clock and the monotonic clock drift apart by more than 2 s (sleep/resume, manual clock change), all samples are discarded. Offsets over 30 s set `skewed: true` as a diagnostic, not an error. `getClockSync()` exposes a read-only snapshot. -Fallback is silent to the user: data still loads over REST. Realtime, when -available, only *augments* the REST snapshot via `merge.ts`. +The estimator has no transport dependency. SSE and long-poll transports feed it with `addSample(t0, serverTs, t3)`. + +## Compression and payload limits (#249) + +- **`permessage-deflate`:** browsers offer it during the WebSocket handshake automatically, and script cannot change that offer. The backend should accept it with `client_no_context_takeover; server_no_context_takeover`. Non-browser WebSocket implementations (e.g. `ws`) receive `PERMESSAGE_DEFLATE_OPTIONS` as a constructor argument. If the server declines, nothing changes except the diagnostics. +- **Size guard:** `decodeFrame()` rejects frames over `MAX_MESSAGE_BYTES` (256 KiB, measured in UTF-8 bytes) **before** `JSON.parse`. Steady-state pushes are under 4 KiB. The budget is sized for the largest legitimate frame, an initial topic snapshot of up to 200 rows (about 150 KiB). If snapshots need to grow, raise the cap and the snapshot limit together. +- **Error path:** a rejected frame (`oversize`, `malformed`, `unsupported_frame`) is dropped and counted (`oversizeDrops` / `malformedDrops`). It is recorded as `lastError`, warned about in development, and passed to `client.onProtocolError` listeners. The connection stays open: closing it would only make the server resend the same frame. +- **Diagnostics:** `client.getDiagnostics()` returns: + - `negotiatedExtensions` and `compressionActive` (from `WebSocket.extensions`) + - `framesReceived` and `bytesReceived` (decoded bytes) + - `wireStats`: compressed vs uncompressed bytes as reported by the server. Browsers don't expose compressed sizes to script. + +## Bundle budget (#248) + +- UI code must load the client through `loadRealtime()`. The dynamic import is named, so it builds to `.next/static/chunks/realtime..js`. ESLint (`no-restricted-imports`) rejects static imports of `@/lib/realtime/client`, `@/lib/realtime/entry` and `@/lib/realtime/dev/*` outside `src/lib/realtime` and tests. +- **Budget: 8 kB gzipped** for the `realtime` chunk (`.size-limit.json`). The client, codec and policy validators measure about 3.1 kB gzipped on their own. zod is not counted, because `/cover` already loads it for form validation and webpack shares it. The remaining headroom is reserved for the SSE and long-poll transports. **Revisit this number once the transport matrix is final.** The shared-bundle budget excludes the lazy chunk, so it still measures what every visitor downloads. +- `npm run size:realtime` (`scripts/check-realtime-bundle.mjs`) runs in CI after `next build`. It fails if: + - anything from `src/lib/realtime/dev` appears in `.next/static` (detected by the `__REFRACT_REALTIME_DEV_ONLY__` marker), or + - the client appears in any chunk a route loads up front (per Next's build manifests). +- Gzip sizes are deterministic for a given build, so the budgets don't flake. Leave at least ~25% headroom when adjusting a limit. ## Feature flag matrix @@ -109,6 +108,18 @@ assume it is on. A topic is live only when `realtime.enabled` **and** its per-topic flag are true. Shadow mode requires `realtime.enabled` but applies nothing. +## Fallback-to-REST semantics + +Realtime is **optional**. When any of the following is true, the client uses +REST only and never blocks the UI: + +- the realtime feature flag for the topic is off (the default), +- capability negotiation fails or the backend does not advertise realtime, +- the connection enters `fallback` after backoff is exhausted. + +Fallback is silent to the user: data still loads over REST. Realtime, when +available, only *augments* the REST snapshot via `merge.ts`. + ## Cross-repo dependency: `refract-backend` The realtime contract above must be implemented in `refract-backend`. Until @@ -117,7 +128,7 @@ Changes to the contract require a coordinated change in both repos. ## Testing -Use the fake transport from `@/lib/realtime/testing` (tests only — never import +Use the fake transport from `@/lib/realtime/dev` (tests only — never import it from production code): ```ts @@ -129,9 +140,6 @@ fake.current.drop(); // force close; advance fake timers for reconnect afterEach(() => fake.teardown()); ``` -`src/lib/realtime/__tests__/topicCoverage.test.ts` fails when a contract topic -lacks a mock producer, a consumer, or valid fixtures (see its allowlist). - ## Operational runbook ### "Data stopped updating" @@ -168,3 +176,4 @@ The mock server lets you develop against the contract before the backend implements it. Start it locally and point the client at it; the client then negotiates capabilities and streams messages per the contract above. See `CONTRIBUTING.md` for the development workflow against the mock server. + diff --git a/package.json b/package.json index f73ab24..baa8bc6 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,7 @@ "analyze": "ANALYZE=true next build", "size": "size-limit", "size:why": "size-limit --why", + "size:realtime": "node scripts/check-realtime-bundle.mjs", "tokens:check": "node scripts/check-tokens.mjs", "gen:realtime-types": "node scripts/gen-realtime-types.mjs", "check:realtime-types": "node scripts/gen-realtime-types.mjs --check", @@ -71,19 +72,5 @@ "typescript": "^5", "vite": "^8.3.1", "vitest": "^5.0.2" - }, - "size-limit": [ - { - "name": "Shared JS (all routes)", - "path": ".next/static/chunks/*.js", - "limit": "250 kB", - "gzip": true - }, - { - "name": "App Router per-page chunks", - "path": ".next/static/chunks/app/**/*.js", - "limit": "150 kB", - "gzip": true - } - ] + } } diff --git a/scripts/check-realtime-bundle.mjs b/scripts/check-realtime-bundle.mjs new file mode 100644 index 0000000..fd7591c --- /dev/null +++ b/scripts/check-realtime-bundle.mjs @@ -0,0 +1,82 @@ +#!/usr/bin/env node +/** + * Production-bundle assertions for the realtime layer (issue #248). + * Run after `next build`; exits non-zero on any violation. + * + * 1. Dev/test tooling (src/lib/realtime/dev) never ships: no file under + * .next/static may contain the dev-only marker. + * 2. The realtime client is lazy: it lives in the `realtime` chunk and in + * no chunk a route loads up front (per .next/app-build-manifest.json and + * .next/build-manifest.json). + * + * Usage: node scripts/check-realtime-bundle.mjs [nextDir] + */ +import { existsSync, readdirSync, readFileSync, statSync } from "node:fs"; +import { join, relative } from "node:path"; + +const NEXT_DIR = process.argv[2] ?? ".next"; +const STATIC_DIR = join(NEXT_DIR, "static"); + +/** Exported from src/lib/realtime/dev/fakeSocket.ts. */ +const DEV_ONLY_MARKER = "__REFRACT_REALTIME_DEV_ONLY__"; +/** A property name that only src/lib/realtime/client.ts contains (minifiers keep it). */ +const CLIENT_MARKER = "clientNoContextTakeover"; + +function walk(dir) { + return readdirSync(dir).flatMap((name) => { + const path = join(dir, name); + return statSync(path).isDirectory() ? walk(path) : [path]; + }); +} + +function readJson(path) { + return existsSync(path) ? JSON.parse(readFileSync(path, "utf8")) : null; +} + +if (!existsSync(STATIC_DIR)) { + console.error(`✗ ${STATIC_DIR} not found — run \`next build\` first.`); + process.exit(1); +} + +const failures = []; +const jsFiles = walk(STATIC_DIR).filter((f) => f.endsWith(".js")); +const contents = new Map(jsFiles.map((f) => [f, readFileSync(f, "utf8")])); + +// 1. Dev tooling must not ship. +for (const [file, code] of contents) { + if (code.includes(DEV_ONLY_MARKER)) failures.push(`dev-only realtime code shipped in ${relative(NEXT_DIR, file)}`); +} + +// 2. The client must be in the lazy chunk and nowhere a route loads eagerly. +const clientChunks = [...contents].filter(([, code]) => code.includes(CLIENT_MARKER)).map(([f]) => f); +const realtimeChunks = clientChunks.filter((f) => /[\\/]realtime[.-][^\\/]*\.js$/.test(f)); +if (clientChunks.length === 0) { + console.warn("! realtime client not found in any chunk (no route uses it yet?)"); +} else if (realtimeChunks.length === 0) { + failures.push( + `realtime client found outside the named \`realtime\` chunk: ${clientChunks.map((f) => relative(NEXT_DIR, f)).join(", ")}. ` + + "Load it with loadRealtime() from src/lib/realtime/load.ts.", + ); +} + +const eager = new Set(); +const appManifest = readJson(join(NEXT_DIR, "app-build-manifest.json")); +for (const files of Object.values(appManifest?.pages ?? {})) files.forEach((f) => eager.add(f)); +const buildManifest = readJson(join(NEXT_DIR, "build-manifest.json")); +for (const key of ["rootMainFiles", "polyfillFiles", "lowPriorityFiles"]) (buildManifest?.[key] ?? []).forEach((f) => eager.add(f)); +for (const files of Object.values(buildManifest?.pages ?? {})) files.forEach((f) => eager.add(f)); + +for (const file of eager) { + const code = contents.get(join(NEXT_DIR, file)); + if (code?.includes(CLIENT_MARKER)) failures.push(`realtime client is in an eagerly loaded chunk: ${file}`); +} + +if (failures.length) { + console.error("✗ realtime bundle check failed:"); + failures.forEach((f) => console.error(` - ${f}`)); + process.exit(1); +} +console.log( + `✓ realtime bundle check passed (${realtimeChunks.map((f) => relative(NEXT_DIR, f)).join(", ") || "no realtime chunk"}; ` + + `${eager.size} eager files scanned, no dev-only code shipped)`, +); diff --git a/src/app/cover/page.tsx b/src/app/cover/page.tsx index 26b5208..e69de29 100644 --- a/src/app/cover/page.tsx +++ b/src/app/cover/page.tsx @@ -1,1061 +0,0 @@ -"use client"; - -import { useEffect, useMemo, useRef, useState } from "react"; -import { useSearchParams } from "next/navigation"; -import { Navbar, Footer } from "@/components/layout"; -import { Container, Card, Badge, Input, Button, Skeleton, SuccessBurst, QuickAmountChips, Tour, TourReplayButton, useTour, type TourStep, AsyncState, RelativeDate } from "@/components/ui"; -import { WalletButton, WrongNetworkBanner, TxErrorMessage } from "@/components/wallet"; -import { useWallet } from "@/lib/wallet/WalletProvider"; -import { useCoverageTypes } from "@/hooks/useCoverageTypes"; -import { useCoverageBounds } from "@/hooks/useCoverageBounds"; -import { useEstimatedFee } from "@/hooks/useEstimatedFee"; -import { buyPolicy, type BuyPolicyResponse } from "@/lib/api/policies"; -import { ApiUnreachableError } from "@/lib/api/client"; -import { formatUsd, fromStroops, toStroops } from "@/lib/format"; -import { truncateAddress } from "@/lib/wallet/WalletProvider"; -import { signAndSubmit } from "@/lib/wallet/signAndSubmit"; -import { useNetworkGuard } from "@/hooks/useNetworkGuard"; -import { useLiveQuote } from "@/hooks/useLiveQuote"; -import { useTransactionStore } from "@/lib/store/useTransactionStore"; -import { useTxReview } from "@/components/wallet/TxReview"; -import { - restoreCart, - retryItem, - runBatchCheckout, - skipItem, - type CartItem, - type CartItemPatch, - type CartItemStatus, - type PurchaseResult, -} from "@/lib/cover/batchCheckout"; -import { PreSignConfirmModal } from "@/components/PreSignConfirmModal"; -import { useCoverQueryParams } from "@/hooks/useCoverQueryParams"; -import { useOptimisticReconciliation } from "@/hooks/useOptimisticReconciliation"; -import { parseCoverQueryParams } from "@/lib/coverQueryParams"; -import { PolicyCertificateModal } from "@/components/PolicyCertificate"; -import { validateBuyCoverage } from "@/lib/validation/coverage"; -import { searchFlights, POPULAR_FLIGHTS, type FlightInfo } from "@/lib/flights"; -import { RealtimeBoundary } from "@/components/realtime/RealtimeBoundary"; -import { RealtimeProfiler } from "@/lib/realtime/devProfiler"; -import { publishAccountBalanceDelta } from "@/hooks/useAccountBalance"; - -const RISK_TAG_COLORS: Record = { - low: "#10b981", - medium: "#8b5cf6", - high: "#f59e0b", - critical: "#ef4444", -}; - -const RISK_HEAT: Record = { low: 20, medium: 45, high: 72, critical: 95 }; - -const QUICK_AMOUNTS = [1_000, 5_000, 10_000, 25_000].map((v) => ({ label: `$${v.toLocaleString()}`, value: String(v) })); - -const COVER_TOUR: TourStep[] = [ - { target: "cover-type", title: "Pick a coverage type", content: "Each type pays out automatically when its on-chain trigger fires — no claims form. Use the arrow keys to move between types." }, - { target: "cover-amount", title: "Set your coverage amount", content: "This is the USDC payout you'd receive if the trigger fires. Quick chips fill in common amounts." }, - { target: "cover-duration", title: "Choose a duration", content: "Your premium scales with how long the policy stays active, from 1 day up to a year." }, - { target: "cover-buy", title: "Review and buy", content: "Check the premium, then buy and confirm in your wallet — or add several policies to the cart and check out together." }, -]; - -const CART_STORAGE_KEY = "refract:cover-cart"; - -const CART_STATUS_LABEL: Record = { - queued: { label: "Queued", className: "text-pm-text/45" }, - processing: { label: "Confirm in wallet…", className: "text-pm-violet" }, - confirmed: { label: "Confirmed", className: "text-pm-green" }, - failed: { label: "Failed", className: "text-pm-red" }, - unconfirmed: { label: "Unconfirmed", className: "text-pm-amber" }, - skipped: { label: "Skipped", className: "text-pm-text/30" }, -}; - -const WIZARD_STEPS = [ - { id: 1, title: "Coverage Type", shortTitle: "Type" }, - { id: 2, title: "Trigger Parameters", shortTitle: "Trigger" }, - { id: 3, title: "Amount & Duration", shortTitle: "Amount" }, - { id: 4, title: "Review & Confirm", shortTitle: "Review" }, -]; - -export default function CoverPage() { - const wallet = useWallet(); - const { requestReview, reviewDialog } = useTxReview(); - const searchParams = useSearchParams(); - const { data: coverageTypes, loading: typesLoading, error: typesError, isFixture } = useCoverageTypes(); - const { isCorrectNetwork, expectedNetwork } = useNetworkGuard(); - const addTransaction = useTransactionStore((s) => s.addTransaction); - const updateTransaction = useTransactionStore((s) => s.updateTransaction); - const { minCoverage: chainMinCoverage, maxCoverage: chainMaxCoverage } = useCoverageBounds(); - const networkFee = useEstimatedFee("buy"); - const reconciliation = useOptimisticReconciliation(); - - const [currentStep, setCurrentStep] = useState(1); - const [selectedType, setSelectedType] = useState(0); - const [coverageAmount, setCoverageAmount] = useState("5000"); - const [durationDays, setDurationDays] = useState(30); - const [flightNumber, setFlightNumber] = useState(""); - const [flightQuery, setFlightQuery] = useState(""); - const [selectedFlight, setSelectedFlight] = useState(null); - const [submission, setSubmission] = useState< - | { status: "idle" } - | { status: "submitting" } - | { status: "signing" } - | { status: "success"; result: BuyPolicyResponse; demo: boolean; txHash?: string } - | { status: "error"; message: string } - >({ status: "idle" }); - const [confirmOpen, setConfirmOpen] = useState(false); - const [showCert, setShowCert] = useState(false); - - const radioRefs = useRef>({}); - const [cart, setCart] = useState([]); - const [cartLoaded, setCartLoaded] = useState(false); - const [checkingOut, setCheckingOut] = useState(false); - const tour = useTour("cover"); - - const initializedRef = useRef(false); - - // Initialize from URL search params on mount - useEffect(() => { - if (initializedRef.current) return; - const parsed = parseCoverQueryParams(searchParams); - if (parsed.type !== undefined) setSelectedType(parsed.type); - if (parsed.amount !== undefined) setCoverageAmount(parsed.amount); - if (parsed.duration !== undefined) setDurationDays(parsed.duration); - if (parsed.flight !== undefined) setFlightNumber(parsed.flight); - initializedRef.current = true; - }, [searchParams]); - - // Sync state to URL params (debounced router.replace) - useCoverQueryParams({ - type: selectedType, - amount: coverageAmount, - duration: durationDays, - flight: flightNumber, - }); - - // Persist the cart so confirmed purchases (and anything mid-flight) survive - // a reload or navigating away mid-batch. - useEffect(() => { - try { - setCart(restoreCart(localStorage.getItem(CART_STORAGE_KEY))); - } catch { - // Storage unavailable — the cart just won't persist. - } - setCartLoaded(true); - }, []); - - useEffect(() => { - if (!cartLoaded) return; - try { - localStorage.setItem(CART_STORAGE_KEY, JSON.stringify(cart)); - } catch { - // Storage unavailable — the cart just won't persist. - } - }, [cart, cartLoaded]); - - const ct = coverageTypes?.[selectedType]; - - const [submittingQuote, setSubmittingQuote] = useState(false); - const liveQuote = useLiveQuote(ct?.id, durationDays, submittingQuote); - const ratePct = liveQuote.quote?.baseRatePct ?? ct?.baseRatePct; - - const premium = useMemo(() => { - if (!ct || ratePct === undefined) return 0; - const amount = parseFloat(coverageAmount) || 0; - const annualRate = ratePct / 100; - return amount * annualRate * (durationDays / 365); - }, [coverageAmount, durationDays, ct, ratePct]); - - // Premium moved after the user opened the confirm step: close it so they must re-confirm the new price. - useEffect(() => { - if (submission.status === "idle" || submission.status === "error" || submission.status === "success") setSubmittingQuote(false); - }, [submission.status]); - const confirmedPremium = useRef(null); - const [premiumChanged, setPremiumChanged] = useState(false); - useEffect(() => { - if (!confirmOpen) { - confirmedPremium.current = null; - return; - } - if (confirmedPremium.current === null) confirmedPremium.current = premium; - else if (confirmedPremium.current !== premium) { - setConfirmOpen(false); - setPremiumChanged(true); - } - }, [confirmOpen, premium]); - - const expiryMs = Date.now() + durationDays * 86400000; - const expiryDate = ; - const expiryLabel = new Date(expiryMs).toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" }); - - const effectiveMin = Math.max(100, chainMinCoverage ?? 0); - const effectiveMax = ct ? Math.min(ct.maxCoverage, chainMaxCoverage ?? ct.maxCoverage) : 0; - const isFlightDelay = ct?.id === 4; - - const validation = validateBuyCoverage( - { coverageAmount, flightNumber }, - { effectiveMin, effectiveMax, isFlightDelay } - ); - const amountInvalid = !validation.isValid && !!validation.errors.coverageAmount; - const flightNumberInvalid = isFlightDelay && (!flightNumber || flightNumber.trim().length === 0); - - const filteredFlights = useMemo(() => { - return searchFlights(flightQuery); - }, [flightQuery]); - - function handleSelectFlight(flight: FlightInfo) { - setSelectedFlight(flight); - setFlightNumber(flight.flightNumber); - setFlightQuery(flight.flightNumber); - } - - function addToCart() { - if (!ct || amountInvalid || flightNumberInvalid) return; - setCart((items) => [ - ...items, - { - id: crypto.randomUUID(), - params: { - coverageType: ct.id, - coverageAmount: toStroops(parseFloat(coverageAmount)).toString(), - durationDays, - ...(isFlightDelay ? { triggerParams: { coverageType: 4 as const, params: { flightNumber: flightNumber.trim(), departureDate: new Date().toISOString().slice(0, 10) } } } : {}), - }, - typeName: ct.name, - icon: ct.icon, - premium, - status: "queued", - }, - ]); - } - - function updateCartItem(id: string, patch: CartItemPatch) { - setCart((items) => items.map((item) => (item.id === id ? { ...item, ...patch } : item))); - } - - async function purchaseCartItem(item: CartItem, holder: string, networkPassphrase: string): Promise { - const correlationId = crypto.randomUUID(); - let rejected = false; - const release = reconciliation.register({ correlationId, kind: "buy", - onConfirm: (authoritative) => updateCartItem(item.id, { status: "confirmed", ...(typeof authoritative?.policyId === "string" ? { policyId: authoritative.policyId } : {}), ...(typeof authoritative?.txHash === "string" ? { txHash: authoritative.txHash } : {}) }), - onRollback: (reason) => { rejected = true; updateCartItem(item.id, { status: "failed", error: reason }); }, - }); - try { - const result = await buyPolicy({ ...item.params, holder }, correlationId); - const txHash = await signAndSubmit(result.txXdr, holder, networkPassphrase); - if (rejected) throw new Error("The transaction was rejected by the server."); - return { policyId: result.policy.id, txHash, demo: false }; - } catch (err) { - release?.(); - // Same clearly-labeled demo fallback as the single-purchase flow. - if (err instanceof ApiUnreachableError) return { policyId: `demo-${crypto.randomUUID()}`, demo: true }; - throw err; - } - } - - async function handleCheckout() { - if (wallet.status !== "connected" || !wallet.address) { - await wallet.connect(); - return; - } - const { address, networkPassphrase } = wallet; - if (!networkPassphrase) return; - setCheckingOut(true); - try { - await runBatchCheckout(cart, (item) => purchaseCartItem(item, address, networkPassphrase), updateCartItem); - } finally { - setCheckingOut(false); - } - } - - async function handleBuy() { - if (!ct) return; - if (amountInvalid || flightNumberInvalid || !isCorrectNetwork) return; - let holder = wallet.status === "connected" ? wallet.address : null; - let networkPassphrase = wallet.networkPassphrase; - if (!holder) { - // Connect, then carry on with the same purchase — no second click. - // A declined/failed connect resolves null and stops here. - const connected = await wallet.connect(); - if (!connected) return; - holder = connected; - networkPassphrase = wallet.networkPassphrase; - } - setPremiumChanged(false); - setConfirmOpen(true); - } - - async function submitBuy() { - setConfirmOpen(false); - const holder = wallet.address; - if (!ct || !holder) return; - setSubmittingQuote(true); - - setSubmission({ status: "submitting" }); - const txId = addTransaction({ type: "buy", amount: parseFloat(coverageAmount) }); - let rejected = false; - let policyCorrection: Partial | undefined; - const releaseReconciliation = reconciliation.register({ - correlationId: txId, kind: "buy", - onConfirm: (authoritative) => { - if (authoritative?.policy && typeof authoritative.policy === "object") { - policyCorrection = authoritative.policy as Partial; - setSubmission((current) => current.status === "success" ? { ...current, result: { ...current.result, policy: { ...current.result.policy, ...policyCorrection } } } : current); - } - updateTransaction(txId, { status: "confirmed", authoritative, - ...(typeof authoritative?.amount === "number" ? { amount: authoritative.amount } : {}), - ...(typeof authoritative?.txHash === "string" ? { txHash: authoritative.txHash } : {}), - }); - }, - onRollback: (reason) => { rejected = true; updateTransaction(txId, { status: "failed", error: reason }); setSubmission({ status: "error", message: `${reason} The purchase details were reverted.` }); }, - }); - try { - const result = await buyPolicy({ - holder, - coverageType: ct.id, - coverageAmount: toStroops(parseFloat(coverageAmount)).toString(), - durationDays, - ...(isFlightDelay ? { triggerParams: { coverageType: 4 as const, params: { flightNumber: flightNumber.trim(), departureDate: new Date().toISOString().slice(0, 10) } } } : {}), - }, txId); - - setSubmission({ status: "signing" }); - updateTransaction(txId, { status: "signing" }); - if (!wallet.networkPassphrase) { - throw new Error("Wallet network isn't available — reconnect and try again"); - } - const approved = await requestReview({ - txXdr: result.txXdr, - networkPassphrase: wallet.networkPassphrase, - action: "Buy coverage", - entered: [ - { label: "Coverage type", value: ct.name }, - { label: "Coverage amount", value: formatUsd(parseFloat(coverageAmount)) }, - { label: "Duration", value: `${durationDays} days` }, - ], - expectedStroops: toStroops(parseFloat(coverageAmount)).toString(), - }); - if (!approved) { - releaseReconciliation(); - setSubmission({ status: "idle" }); - return; - } - const networkPassphrase = wallet.networkPassphrase; - if (!networkPassphrase) throw new Error("Wallet network isn't available — reconnect and try again"); - const txHash = await signAndSubmit(result.txXdr, holder, networkPassphrase, { hardware: wallet.hardwareWallet }); - if (rejected) throw new Error("The transaction was rejected by the server."); - publishAccountBalanceDelta(wallet.address, "USDC", -fromStroops(result.policy.premium)); - setSubmission({ status: "success", result: policyCorrection ? { ...result, policy: { ...result.policy, ...policyCorrection } } : result, demo: false, txHash }); - updateTransaction(txId, { status: "confirmed", txHash }); - } catch (err) { - if (err instanceof ApiUnreachableError) { - releaseReconciliation(); - const demoResult: BuyPolicyResponse = { - policy: { - id: `demo-${crypto.randomUUID()}`, - holder, - coverageType: ct.id, - coverageTypeName: ct.name, - coverageAmount: toStroops(parseFloat(coverageAmount)).toString(), - premium: toStroops(premium).toString(), - durationDays, - expiresAt: Math.floor(Date.now() / 1000) + durationDays * 86400, - isActive: true, - createdAt: new Date().toISOString(), - }, - txXdr: "DEMO_MODE — backend unreachable, no transaction was built", - message: "Simulated locally: the Refract API is not running in this environment.", - }; - setSubmission({ status: "success", result: demoResult, demo: true }); - updateTransaction(txId, { status: "confirmed", demo: true }); - return; - } - releaseReconciliation(); - const message = err instanceof Error ? err.message : "Something went wrong buying coverage"; - setSubmission({ status: "error", message }); - updateTransaction(txId, { status: "failed", error: message }); - } - } - - return ( -
- } /> - {reviewDialog} - {reconciliation.notices.map((notice) => ( -
- {notice.message} -
- ))} - -
- -
-
-

- Get Coverage -

- -
-

- Parametric coverage with automated on-chain settlement. -

- {isFixture && ( -

- ⚠ Showing fixture data — the Refract API isn't reachable from this environment. -

- )} -
- - - - {/* Stepper Progress Indicator */} - - -
- {/* Left: Step Content */} -
- {/* STEP 1: Select Type */} - {currentStep === 1 && ( -
Coverage types are temporarily unavailable. Try again later or open the Dashboard.
}> -
-
- Step 1 of 4: Select Coverage Type -
- - - {Array.from({ length: 5 }).map((_, i) => )} -
} - errorRender={(err: string) => ( - -

Couldn't load coverage types: {err}

-
- )}> - {coverageTypes && ( -
{ - if (!["ArrowDown", "ArrowUp", "Home", "End"].includes(e.key)) return; - e.preventDefault(); - const ids = coverageTypes.map((t) => t.id); - const currentIndex = ids.indexOf(selectedType); - let nextIndex = currentIndex; - if (e.key === "ArrowDown") nextIndex = (currentIndex + 1) % ids.length; - if (e.key === "ArrowUp") nextIndex = (currentIndex - 1 + ids.length) % ids.length; - if (e.key === "Home") nextIndex = 0; - if (e.key === "End") nextIndex = ids.length - 1; - const nextId = ids[nextIndex]; - setSelectedType(nextId); - setSubmission({ status: "idle" }); - radioRefs.current[nextId]?.focus(); - }} - > - {coverageTypes.map((type) => { - const active = selectedType === type.id; - return ( - - ); - })} -
- )} - - -
- -
-
- - )} - - {/* STEP 2: Trigger Parameters */} - {currentStep === 2 && ct && ( - -
-
-
- Step 2 of 4: Trigger Parameters -
-

- {isFlightDelay ? "Flight Tracking & Trigger Lookup" : "Automated Oracle Trigger Parameters"} -

-
- {ct.icon} -
- - {isFlightDelay ? ( -
-

- Enter your flight number or search popular commercial routes. The on-chain oracle will monitor flight status and automatically trigger a payout if departure is delayed > 120 minutes. -

- -
- { - const val = e.target.value.toUpperCase(); - setFlightQuery(val); - setFlightNumber(val); - }} - placeholder="e.g. BA249, JFK, British Airways" - error={flightNumberInvalid ? "Please select or enter a valid flight number" : undefined} - /> -
- - {/* Suggestions list */} -
-
- {flightQuery ? "Matching Flights" : "Popular Monitored Routes"} -
-
- {filteredFlights.map((f) => { - const isSelected = flightNumber === f.flightNumber; - return ( - - ); - })} -
-
- - {selectedFlight && ( -
-
- ✓ Flight Selected: {selectedFlight.flightNumber} - Oracle Source: Chainlink FlightStats -
-
- {selectedFlight.airline} · {selectedFlight.from} to {selectedFlight.to} ({selectedFlight.departureTime}) -
-
- )} -
- ) : ( -
-
-
Monitoring Condition
-
{ct.trigger}
-
- Refract contracts autonomously poll decentralized oracle feeds. When trigger criteria are met on-chain, payouts are disbursed directly to your wallet without claim forms. -
-
- -
-
-
Resolution Oracle
-
Pyth & Chainlink Feeds
-
-
-
Settlement Speed
-
Sub-minute (~5s on Soroban)
-
-
-
- )} - -
- - -
-
- )} - - {/* STEP 3: Amount & Duration */} - {currentStep === 3 && ct && ( - -
- Step 3 of 4: Coverage Amount & Duration -
- -
- setCoverageAmount(e.target.value)} - placeholder="5000" - min={effectiveMin} - max={effectiveMax} - error={ - amountInvalid - ? `Enter an amount between $${effectiveMin.toLocaleString()} and $${effectiveMax.toLocaleString()}` - : undefined - } - /> - -
- - {isFlightDelay && ( -
- setFlightNumber(e.target.value.toUpperCase())} - placeholder="BA249" - error={flightNumberInvalid ? "Enter the flight number this policy should monitor" : undefined} - /> -
- )} - -
-
- - - {durationDays} days · Expires {expiryDate} - -
- setDurationDays(Number(e.target.value))} - className="w-full accent-pm-violet" aria-label="Coverage duration in days" /> -
1 day365 days
-
-
- - -
-
- )} -
- /> -
-
- - -
- - )} - - - {/* Right: Quote panel */} -
- {!ct ? ( - - - - ) : submission.status === "success" ? ( - -
- - Coverage purchased -
- {submission.demo && ( -

- Demo mode: the Refract API wasn't reachable, so this was simulated client-side — - no real transaction was built or submitted. -

- )} -
-
-
Policy ID
-
{submission.result.policy.id.slice(0, 13)}…
-
-
-
Coverage
-
{submission.result.policy.coverageTypeName}
-
-
-
Holder
-
{truncateAddress(submission.result.policy.holder)}
-
- {submission.txHash && ( -
-
Transaction
-
{truncateAddress(submission.txHash)}
-
- )} -
-
- - -
- - setShowCert(false)} - policy={{ - ...submission.result.policy, - txHash: submission.txHash, - demo: submission.demo, - }} - /> -
- ) : ( - -
- -
-
{ct.name}
-
{durationDays}-day policy
-
-
- -
-
- Risk level - - {ct.riskLevel} - -
-
-
-
-
- -
- {[ - { label: "Coverage amount", value: formatUsd(parseFloat(coverageAmount || "0")) }, - { label: "Annual rate", value: `${ct.baseRatePct}%` }, - { label: "Duration", value: `${durationDays} days` }, - { label: "Expires", value: expiryDate }, - ].map((item) => ( -
-
{item.label}
-
{item.value}
-
- ))} -
- -
-
- Total premium - {formatUsd(premium)} - {(liveQuote.justUpdated || liveQuote.stale || premiumChanged) && ( - - {premiumChanged - ? "Premium changed — review and confirm again" - : liveQuote.justUpdated - ? "Updated just now" - : "Live quote unavailable — showing last known rate"} - - )} -
-
One-time payment · USDC
- {networkFee && ( -
- Network fee - {networkFee} est. network fee -
- )} -
- - - {wallet.status !== "connected" && ( -

- After you connect, your purchase continues automatically — you'll still confirm it in Freighter. -

- )} - - {submission.status === "error" && } - - - -
- {["🔒 No claims form required", "⚡ Instant payout via oracle", "🌐 Fully on-chain, non-custodial"].map( - (item) => ( -
- {item} -
- ) - )} -
- - )} - - {cart.length > 0 && ( - -
-

Cart

- - {cart.filter((i) => i.status === "confirmed").length} of{" "} - {cart.filter((i) => i.status !== "skipped").length} confirmed - -
-
    - {cart.map((item, index) => ( -
  1. -
    - - {index + 1}. - {item.typeName} - - - {CART_STATUS_LABEL[item.status].label} - -
    -
    - {formatUsd(fromStroops(item.params.coverageAmount))} · {item.params.durationDays} days ·{" "} - {formatUsd(item.premium)} premium - {item.demo && " · demo"} - {item.txHash && ` · tx ${truncateAddress(item.txHash)}`} -
    - {item.error && item.status === "failed" && } - {item.error && item.status === "unconfirmed" && ( -

    - This may still have gone through — check your Dashboard before buying it again. ({item.error}) -

    - )} - {!checkingOut && item.status !== "processing" && ( -
    - {item.status === "failed" && ( - - )} - {item.status === "failed" && ( - - )} - -
    - )} -
  2. - ))} -
- -
- )} -
-
- - - - {ct && ( - {durationDays} days · Expires {expiryDate} }, - ...(isFlightDelay ? [{ label: "Flight", value: flightNumber.trim() }] : []), - ]} - total={{ label: "Estimated total cost", value: formatUsd(premium) }} - hardwareWallet={wallet.hardwareWallet} - onConfirm={() => void submitBuy()} - onCancel={() => setConfirmOpen(false)} - /> - )} - -