diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 60f2d13..0af43cd 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -23,6 +23,9 @@ jobs: - name: Install dependencies run: npm ci + - name: Check realtime generated types + run: npm run check:realtime-types + - name: Run ESLint run: npm run lint diff --git a/openapi/refract-api.yaml b/openapi/refract-api.yaml index 1740062..b9713ae 100644 --- a/openapi/refract-api.yaml +++ b/openapi/refract-api.yaml @@ -131,6 +131,18 @@ components: type: string enum: [low, medium, high, triggered] message: { type: string } + RealtimeEnvelope: + type: object + required: [topic, payload] + properties: + topic: { type: string } + payload: + type: object + additionalProperties: true + metadata: + type: object + nullable: true + additionalProperties: true ClaimRecord: type: object required: [policyId, holder, coverageType, triggered, payout, reason, processedAt] diff --git a/package.json b/package.json index 9c38a16..f73ab24 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,8 @@ "size": "size-limit", "size:why": "size-limit --why", "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", "realtime:benchmark": "node scripts/realtime-benchmark.mjs", "mock:realtime": "node scripts/mock-realtime-server.mjs" }, diff --git a/scripts/fixtures/malformed-openapi.yaml b/scripts/fixtures/malformed-openapi.yaml new file mode 100644 index 0000000..1ffb19b --- /dev/null +++ b/scripts/fixtures/malformed-openapi.yaml @@ -0,0 +1,5 @@ +openapi: 3.0.3 +components: + schemas: + Unrelated: + type: object diff --git a/scripts/gen-realtime-types.mjs b/scripts/gen-realtime-types.mjs new file mode 100644 index 0000000..4ea62fa --- /dev/null +++ b/scripts/gen-realtime-types.mjs @@ -0,0 +1,128 @@ +import { readFileSync, writeFileSync } from "node:fs"; +import { resolve } from "node:path"; + +const root = process.cwd(); +const specPath = resolve(root, process.env.REALTIME_OPENAPI_SPEC ?? "openapi/refract-api.yaml"); +const outPath = resolve(root, "src/lib/realtime/generated/schemas.ts"); +const indexPath = resolve(root, "src/lib/realtime/generated/index.ts"); +const check = process.argv.includes("--check"); +const spec = readFileSync(specPath, "utf8"); +if ( + !/^openapi:\s*3\./m.test(spec) || + !/^components:\s*$/m.test(spec) || + !/^ schemas:\s*$/m.test(spec) +) + throw new Error("Invalid OpenAPI document: expected OpenAPI 3 and components.schemas"); +const wanted = ["OracleReading", "ClaimRecord", "PoolStats", "RealtimeEnvelope"]; +const schemas = {}; +for (const name of wanted) { + const start = spec.indexOf(` ${name}:`); + if (start < 0) throw new Error(`Required realtime schema '${name}' is missing from ${specPath}`); + const boundary = spec.slice(start + 5).search(/\n [A-Z][A-Za-z0-9]*:/); + const next = boundary < 0 ? -1 : start + 5 + boundary; + const block = spec.slice(start, next < 0 ? undefined : next); + const requiredLine = block.match(/^ required:\s*\[([^\]]*)\]/m)?.[1]; + if (!requiredLine) throw new Error(`Schema '${name}' has no required field declaration`); + const required = new Set( + requiredLine + .split(",") + .map((v) => v.trim()) + .filter(Boolean) + ); + const propsStart = block.indexOf(" properties:"); + if (propsStart < 0) throw new Error(`Schema '${name}' has no properties`); + const propsText = block.slice(propsStart + 7); + const props = []; + const lines = propsText.split("\n"); + for (let i = 1; i < lines.length; i++) { + const line = lines[i]; + const field = line.match(/^ ([A-Za-z][\w]*):(?:\s*\{\s*(.*?)\s*\})?\s*$/); + if (!field) continue; + let descriptor = field[2] ?? ""; + if (!descriptor) { + const nested = lines.slice(i + 1, i + 5).join(" "); + descriptor = nested; + } + const type = descriptor.match( + /(?:^|[,\s])type:\s*(string|number|integer|boolean|object|array)/ + )?.[1]; + const enumBody = descriptor.match(/enum:\s*\[([^\]]*)\]/)?.[1]; + const nullable = /nullable:\s*true/.test(descriptor); + if (!type && !enumBody) throw new Error(`Unsupported or malformed field '${name}.${field[1]}'`); + let tsType = + type === "integer" || type === "number" + ? "number" + : type === "boolean" + ? "boolean" + : type === "object" + ? "Record" + : type === "array" + ? "unknown[]" + : "string"; + let zod = + type === "number" || type === "integer" + ? "z.number()" + : type === "boolean" + ? "z.boolean()" + : type === "object" + ? "z.record(z.string(), z.unknown())" + : "z.string()"; + if (enumBody) { + const vals = enumBody + .split(",") + .map((v) => v.trim()) + .filter(Boolean); + tsType = vals + .map((v) => v.replace(/^['"]|['"]$/g, "")) + .map((v) => JSON.stringify(v)) + .join(" | "); + zod = `z.enum([${vals.map((v) => JSON.stringify(v.replace(/^['"]|['"]$/g, ""))).join(", ")}])`; + } + if (nullable) { + tsType += " | null"; + zod += ".nullable()"; + } + const optional = !required.has(field[1]); + props.push({ key: field[1], tsType, zod: optional ? `${zod}.optional()` : zod, optional }); + } + props.sort((a, b) => (a.key < b.key ? -1 : a.key > b.key ? 1 : 0)); + for (const field of required) + if (!props.some((p) => p.key === field)) + throw new Error(`Required field '${name}.${field}' is missing from properties`); + schemas[name] = props; +} +const lines = [ + "// Generated by scripts/gen-realtime-types.mjs. Do not edit.", + 'import { z } from "zod";', + "", +]; +for (const [name, props] of Object.entries(schemas)) { + lines.push(`export interface ${name} {`); + for (const p of props) lines.push(` ${p.key}${p.optional ? "?" : ""}: ${p.tsType};`); + lines.push("}", `export const ${name}Schema = z.object({`); + for (const p of props) lines.push(` ${p.key}: ${p.zod},`); + lines.push("});", ""); +} +const output = `${lines.join("\n").trimEnd()}\n`; +const indexOutput = `// Generated by scripts/gen-realtime-types.mjs. Do not edit.\nexport { ${wanted.map((n) => `${n}Schema`).join(", ")} } from "./schemas";\nexport type { ${wanted.join(", ")} } from "./schemas";\n`; +if (check) { + let current = ""; + try { + current = readFileSync(outPath, "utf8"); + } catch { + /* drift */ + } + let currentIndex = ""; + try { + currentIndex = readFileSync(indexPath, "utf8"); + } catch { + /* drift */ + } + if (current !== output || currentIndex !== indexOutput) { + console.error("Realtime generated files are stale. Run npm run gen:realtime-types."); + process.exitCode = 1; + } +} else { + writeFileSync(outPath, output); + writeFileSync(indexPath, indexOutput); +} diff --git a/scripts/gen-realtime-types.test.ts b/scripts/gen-realtime-types.test.ts new file mode 100644 index 0000000..e8ed41c --- /dev/null +++ b/scripts/gen-realtime-types.test.ts @@ -0,0 +1,16 @@ +import { spawnSync } from "node:child_process"; +import { resolve } from "node:path"; +import { describe, expect, it } from "vitest"; + +describe("realtime type generator", () => { + it("fails loudly when the OpenAPI document omits a realtime schema", () => { + const script = resolve(process.cwd(), "scripts/gen-realtime-types.mjs"); + const fixture = resolve(process.cwd(), "scripts/fixtures/malformed-openapi.yaml"); + const result = spawnSync(process.execPath, [script], { + env: { ...process.env, REALTIME_OPENAPI_SPEC: fixture }, + encoding: "utf8", + }); + expect(result.status).not.toBe(0); + expect(result.stderr).toMatch(/Required realtime schema/); + }); +}); diff --git a/src/app/globals.css b/src/app/globals.css index 2ddc5f6..8400277 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -1,3 +1,13 @@ +/* Realtime freshness status stays static when reduced motion is enabled. */ +.data-freshness { display: inline-flex; align-items: center; gap: .4rem; min-width: 8rem; font-size: .75rem; line-height: 1.25rem; color: var(--pm-text-muted, #a1a1aa); } +.data-freshness__dot { width: .4rem; height: .4rem; flex: 0 0 .4rem; border-radius: 50%; background: #a1a1aa; } +.data-freshness--live { color: #34d399; } +.data-freshness--live .data-freshness__dot { background: #34d399; } +.data-freshness--muted .data-freshness__dot { background: #fbbf24; } +.data-freshness__dot--pulse { animation: freshness-pulse 1.8s ease-in-out infinite; } +@keyframes freshness-pulse { 50% { opacity: .4; transform: scale(.8); } } +@media (prefers-reduced-motion: reduce) { .data-freshness__dot--pulse { animation: none; } } + @import url("https://fonts.googleapis.com/css2?family=Syne:wght@400;500;600;700;800&family=Inter:wght@300;400;500;600&family=JetBrains+Mono:wght@400;500&display=swap"); @tailwind base; @@ -327,28 +337,6 @@ input:focus-visible, /* ── Loading skeleton ────────────────────────────────────────────────────── */ -x 18px; - border-radius: var(--pm-r); - font-size: 13px; - font-weight: 600; - text-decoration: none; - transition: top 0.15s ease; -} -.pm-skip-link:focus { - top: 16px; -} - -@media (prefers-reduced-motion: reduce) { - *, *::before, *::after { - animation-duration: 0.01ms !important; - animation-iteration-count: 1 !important; - transition-duration: 0.01ms !important; - scroll-behavior: auto !important; - } -} - -/* ── Loading skeleton ────────────────────────────────────────────────────── */ - .pm-skeleton { background: linear-gradient(90deg, var(--pm-surface-2) 25%, var(--pm-surface-3) 50%, var(--pm-surface-2) 75%); background-size: 200% 100%; diff --git a/src/app/page.tsx b/src/app/page.tsx index 3b1709f..c3a7341 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -15,6 +15,7 @@ import { formatUsd, formatRelativeTime, fromStroops } from "@/lib/format"; import { truncateAddress } from "@/lib/wallet/WalletProvider"; import { ACTIVE_NETWORK } from "@/lib/network"; import { HowItWorks } from "@/components/HowItWorks"; +import { DataFreshness } from "@/components/DataFreshness"; import { RealtimeBoundary } from "@/components/realtime/RealtimeBoundary"; import { RealtimeProfiler } from "@/lib/realtime/devProfiler"; @@ -188,6 +189,7 @@ export default function Home() {
Live Oracle Status +
+
{recentClaims.isFixture && (

⚠ Showing example data — the Refract API isn't reachable from this environment. diff --git a/src/app/provide/page.tsx b/src/app/provide/page.tsx index 46d76a9..e69de29 100644 --- a/src/app/provide/page.tsx +++ b/src/app/provide/page.tsx @@ -1,496 +0,0 @@ -"use client"; - -import { useEffect, useMemo, useRef, useState } from "react"; -import { Navbar, Footer } from "@/components/layout"; -import { Container, Card, Badge, Input, Button, Skeleton, LiveCountdown, RelativeDate } from "@/components/ui"; -import { WalletButton, WrongNetworkBanner } from "@/components/wallet"; -import { useWallet } from "@/lib/wallet/WalletProvider"; -import { usePoolStats } from "@/hooks/usePoolStats"; -import { useUserPoolPosition } from "@/hooks/useUserPoolPosition"; -import { useLockupStatus } from "@/hooks/useLockupStatus"; -import { provideCapital, withdrawCapital, type ProvideCapitalResponse, type WithdrawCapitalResponse } from "@/lib/api/pool"; -import { ApiUnreachableError } from "@/lib/api/client"; -import { formatUsd, fromStroops, toStroops } from "@/lib/format"; -import { signAndSubmit } from "@/lib/wallet/signAndSubmit"; -import { truncateAddress } from "@/lib/wallet/WalletProvider"; -import { useNetworkGuard } from "@/hooks/useNetworkGuard"; -import { PoolActivityFeed } from "@/components/pool/PoolActivityFeed"; -import { useTransactionStore } from "@/lib/store/useTransactionStore"; -import { RealtimeBoundary } from "@/components/realtime/RealtimeBoundary"; -import { RealtimeProfiler } from "@/lib/realtime/devProfiler"; -import { publishAccountBalanceDelta } from "@/hooks/useAccountBalance"; - -// Illustrative allocation breakdown by coverage category — the backend -// doesn't currently expose a per-category pool split, so this is presented -// as UI context rather than fetched data. -const RISK_BREAKDOWN = [ - { type: "Stablecoin Depeg", color: "#8b5cf6", pct: 22 }, - { type: "Market Crash", color: "#f59e0b", pct: 18 }, - { type: "Liquidation Shield", color: "#10b981", pct: 35 }, - { type: "Smart Contract Risk", color: "#ef4444", pct: 15 }, - { type: "Flight Delay", color: "#06b6d4", pct: 10 }, -]; - -type SubmissionState = - | { status: "idle" } - | { status: "submitting" } - | { status: "signing" } - | { status: "success"; kind: "deposit"; result: ProvideCapitalResponse; demo: boolean; txHash?: string } - | { status: "success"; kind: "withdraw"; result: WithdrawCapitalResponse; demo: boolean; txHash?: string } - | { status: "error"; message: string }; - -export default function ProvidePage() { - const wallet = useWallet(); - const { isCorrectNetwork, expectedNetwork } = useNetworkGuard(); - const addTransaction = useTransactionStore((s) => s.addTransaction); - const updateTransaction = useTransactionStore((s) => s.updateTransaction); - const { data: pool, loading: poolLoading, isFixture: poolIsFixture, dataSource: poolSource } = usePoolStats(); - const { data: position } = useUserPoolPosition(wallet.status === "connected" ? wallet.address : null); - const { lockupExpiresAt } = useLockupStatus(wallet.status === "connected" ? wallet.address : null); - - const [tab, setTab] = useState<"deposit" | "withdraw">("deposit"); - const [amount, setAmount] = useState(""); - const [submission, setSubmission] = useState({ status: "idle" }); - const canvasRef = useRef(null); - - const sharePrice = pool?.sharePrice ?? 1; - const userShares = position ? fromStroops(position.shares) : 0; - const availableToWithdraw = userShares * sharePrice; - - const sharesOut = amount ? (parseFloat(amount) / sharePrice).toFixed(4) : "—"; - const usdcOut = amount ? (parseFloat(amount) * sharePrice).toFixed(2) : "—"; - - const isLocked = lockupExpiresAt !== null && lockupExpiresAt * 1000 > Date.now(); - const withdrawInvalid = - tab === "withdraw" && wallet.status === "connected" && parseFloat(amount || "0") > availableToWithdraw; - - const utilizationPct = pool ? pool.utilizationBps / 100 : 0; - const maxUtilizationPct = pool ? pool.maxUtilizationBps / 100 : 80; - - // Donut chart for the illustrative risk breakdown - useEffect(() => { - const canvas = canvasRef.current; - if (!canvas) return; - const ctx = canvas.getContext("2d"); - if (!ctx) return; - const size = 160; - canvas.width = canvas.height = size * devicePixelRatio; - ctx.scale(devicePixelRatio, devicePixelRatio); - const cx = size / 2, cy = size / 2, r = 62, innerR = 42; - let startAngle = -Math.PI / 2; - - RISK_BREAKDOWN.forEach((seg) => { - const angle = (seg.pct / 100) * Math.PI * 2; - ctx.beginPath(); - ctx.arc(cx, cy, r, startAngle, startAngle + angle); - ctx.arc(cx, cy, innerR, startAngle + angle, startAngle, true); - ctx.fillStyle = seg.color; - ctx.fill(); - ctx.beginPath(); - ctx.arc(cx, cy, r + 1, startAngle, startAngle + angle); - ctx.arc(cx, cy, innerR - 1, startAngle + angle, startAngle, true); - ctx.fillStyle = "rgba(7,5,15,0.5)"; - ctx.lineWidth = 2; - ctx.fill(); - startAngle += angle; - }); - }, []); - - const withdrawQuickPct = useMemo( - () => [ - { label: "25%", value: userShares * 0.25 * sharePrice }, - { label: "50%", value: userShares * 0.5 * sharePrice }, - { label: "75%", value: userShares * 0.75 * sharePrice }, - { label: "MAX", value: userShares * sharePrice }, - ], - [userShares, sharePrice] - ); - - async function handleSubmit() { - if (wallet.status !== "connected" || !wallet.address) { - await wallet.connect(); - return; - } - const parsed = parseFloat(amount || "0"); - if (parsed <= 0) return; - if (tab === "withdraw" && parsed > availableToWithdraw) return; - if (tab === "withdraw" && isLocked) return; - if (!isCorrectNetwork) return; - if (!(await wallet.ensureActive())) return; - - setSubmission({ status: "submitting" }); - const txId = addTransaction({ type: tab === "deposit" ? "provide" : "withdraw", amount: parsed }); - try { - if (tab === "deposit") { - const result = await provideCapital(wallet.address, toStroops(parsed)); - setSubmission({ status: "signing" }); - updateTransaction(txId, { status: "signing" }); - if (!wallet.networkPassphrase) { - throw new Error("Wallet network isn't available — reconnect and try again"); - } - const txHash = await signAndSubmit(result.txXdr, wallet.address, wallet.networkPassphrase, "provide"); - publishAccountBalanceDelta(wallet.address, "USDC", -parsed); - setSubmission({ status: "success", kind: "deposit", result, demo: false, txHash }); - updateTransaction(txId, { status: "confirmed", txHash }); - } else { - const result = await withdrawCapital(wallet.address, toStroops(parsed / sharePrice)); - setSubmission({ status: "signing" }); - updateTransaction(txId, { status: "signing" }); - if (!wallet.networkPassphrase) { - throw new Error("Wallet network isn't available — reconnect and try again"); - } - const txHash = await signAndSubmit(result.txXdr, wallet.address, wallet.networkPassphrase, "withdraw"); - publishAccountBalanceDelta(wallet.address, "USDC", parsed); - setSubmission({ status: "success", kind: "withdraw", result, demo: false, txHash }); - updateTransaction(txId, { status: "confirmed", txHash }); - } - } catch (err) { - if (err instanceof ApiUnreachableError) { - // Backend unreachable in this environment — simulate locally, - // clearly labeled, so the flow can still be exercised end-to-end. - if (tab === "deposit") { - const demoResult: ProvideCapitalResponse = { - provider: wallet.address, - amountUsdc: toStroops(parsed), - sharesOut: toStroops(parsed / sharePrice), - sharePrice, - txXdr: "DEMO_MODE — backend unreachable, no transaction was built", - message: "Simulated locally: the Refract API is not running in this environment.", - }; - setSubmission({ status: "success", kind: "deposit", result: demoResult, demo: true }); - } else { - const demoResult: WithdrawCapitalResponse = { - provider: wallet.address, - sharesIn: toStroops(parsed / sharePrice), - usdcOut: toStroops(parsed), - sharePrice, - txXdr: "DEMO_MODE — backend unreachable, no transaction was built", - }; - setSubmission({ status: "success", kind: "withdraw", result: demoResult, demo: true }); - } - updateTransaction(txId, { status: "confirmed", demo: true }); - return; - } - const message = err instanceof Error ? err.message : "Something went wrong"; - setSubmission({ status: "error", message }); - updateTransaction(txId, { status: "failed", error: message }); - } - } - - return ( -

- } /> - -

Pool data is temporarily unavailable. You can still browse other Refract pages.

}> -
- -
-

- Provide Capital -

-

- Underwrite Refract policies. Earn premiums when no triggers fire. Pool capital backs all coverage - categories. -

- {poolSource === "chain" && ( -

- ⛓ Reading directly from chain — the Refract API isn't reachable, so APY isn't available. -

- )} - {poolIsFixture && ( -

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

- )} -
- - - - {/* Stats */} -
- {poolLoading || !pool - ? Array.from({ length: 4 }).map((_, i) => ( - - - - - )) - : [ - { label: "Pool TVL", value: `$${(Number(pool.totalUsdc) / 1e7 / 1e6).toFixed(1)}M` }, - { label: "30d APY", value: poolSource === "chain" ? "—" : `${(pool.apyBps / 100).toFixed(1)}%`, accent: true }, - { label: "Share Price", value: `$${pool.sharePrice}` }, - { label: "Utilization", value: `${utilizationPct.toFixed(2)}%` }, - ].map((s) => ( - -
{s.label}
-
- {s.value} -
-
- ))} -
- -
- {/* Left: Pool info */} -
- - - -

Capital Allocation

-
-
-
- - -

- How capital provision works -

-
    - {[ - { n: "01", title: "Deposit USDC", desc: "Receive Refract pool shares (PPS) proportional to your deposit." }, - { n: "02", title: "Underwrite policies", desc: "Your capital backs coverage sold to policy buyers. You collect premiums upfront." }, - { n: "03", title: "Earn continuously", desc: "Premium yield accrues to your PPS shares, increasing their USDC value over time." }, - { n: "04", title: "Shared risk", desc: "If a payout fires, it's split proportionally across all capital providers — not concentrated on any one LP." }, - ].map((item) => ( -
  1. -
    - {item.n} -
    -
    -
    {item.title}
    -
    {item.desc}
    -
    -
  2. - ))} -
-
- - -
-

Pool Capacity

- 70 ? "text-pm-amber" : "text-pm-green"}`}> - {utilizationPct.toFixed(2)}% utilized - -
-
-
-
-
- {pool ? `$${(Number(pool.lockedUsdc) / 1e7 / 1e6).toFixed(1)}M locked in policies` : "—"} - {pool ? `$${(Number(pool.totalUsdc) / 1e7 / 1e6).toFixed(1)}M total / ${maxUtilizationPct}% max` : "—"} -
- -
- - {/* Right: Deposit/withdraw form */} -
- {submission.status === "success" ? ( - -
- - - {submission.kind === "deposit" ? "Capital provided" : "Withdrawal submitted"} - -
- {submission.demo && ( -

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

- )} -
-
-
{submission.kind === "deposit" ? "Shares received" : "USDC received"}
-
- {submission.kind === "deposit" - ? fromStroops(submission.result.sharesOut).toFixed(4) - : formatUsd(fromStroops(submission.result.usdcOut))} -
-
-
-
Share price
-
${submission.result.sharePrice}
-
- {submission.txHash && ( -
-
Transaction
-
{truncateAddress(submission.txHash)}
-
- )} -
- -
- ) : ( - -
{ - if (!["ArrowLeft", "ArrowRight"].includes(e.key)) return; - e.preventDefault(); - setTab(tab === "deposit" ? "withdraw" : "deposit"); - setAmount(""); - setSubmission({ status: "idle" }); - }} - > - {(["deposit", "withdraw"] as const).map((t) => ( - - ))} -
- -
- PPS share price - ${sharePrice} -
- - {tab === "withdraw" && wallet.status === "connected" && ( -
- Your position: {userShares.toFixed(4)} shares ·{" "} - {formatUsd(userShares * sharePrice)} -
- )} - - {tab === "withdraw" && isLocked && lockupExpiresAt && ( -
- 🔒 Withdrawals unlock on ·{" "} - -
- )} - -
- setAmount(e.target.value)} - error={withdrawInvalid ? `You only have ${formatUsd(availableToWithdraw)} available to withdraw` : undefined} - /> -
- {tab === "deposit" - ? ["1,000", "5,000", "10,000", "25,000"].map((v) => ( - - )) - : withdrawQuickPct.map((p) => ( - - ))} -
-
- - {amount && ( -
-
- {tab === "deposit" ? "PPS shares received" : "USDC received"} - {tab === "deposit" ? sharesOut : usdcOut} -
-
- Estimated 30d yield - - +{formatUsd(((parseFloat(amount || "0") * (pool?.apyBps ?? 890)) / 10000 / 12))} - -
-
- )} - - - - {submission.status === "error" && ( -

- {submission.message} -

- )} - -
-

- Risk - Capital providers share in payout risk. If oracle triggers fire, pool capital covers claims - proportionally. -

-
-
- )} -
-
- -
-
- -
-
- ); -} diff --git a/src/components/DataFreshness.tsx b/src/components/DataFreshness.tsx new file mode 100644 index 0000000..863956a --- /dev/null +++ b/src/components/DataFreshness.tsx @@ -0,0 +1,86 @@ +"use client"; + +import { useEffect, useRef, useState } from "react"; +import { usePrefersReducedMotion } from "@/hooks/usePrefersReducedMotion"; +import { getFreshness, type DataSource, type Freshness } from "@/lib/realtime/freshness"; +import { realtimeClient } from "@/lib/realtime/client"; + +export function DataFreshness({ + updatedAt, + source = "api", + reconnecting = false, + staleAfterMs = 30_000, +}: { + updatedAt?: number | null; + source?: DataSource; + reconnecting?: boolean; + staleAfterMs?: number; +}) { + const reduced = usePrefersReducedMotion(); + const [now, setNow] = useState(null); + const [connection, setConnection] = useState(realtimeClient.getConnectionState()); + const [everConnected, setEverConnected] = useState(false); + const reconnectStarted = useRef(null); + useEffect(() => { + const unsubscribe = realtimeClient.onConnectionState((state) => { + setConnection(state); + if (state === "connected") { + setEverConnected(true); + reconnectStarted.current = null; + } else if (reconnectStarted.current === null) reconnectStarted.current = Date.now(); + }); + setNow(Date.now()); + const timer = window.setInterval(() => setNow(Date.now()), 2_000); + return () => { + unsubscribe(); + window.clearInterval(timer); + }; + }, []); + const graceStarted = + everConnected && connection !== "connected" ? reconnectStarted.current : null; + const freshness: Freshness = + now === null ? "unknown" : getFreshness(updatedAt ?? null, now, staleAfterMs, graceStarted); + const withinReconnectGrace = + everConnected && + connection !== "connected" && + reconnectStarted.current !== null && + now !== null && + now - reconnectStarted.current < 5_000; + const displayedFreshness = freshness; + const age = updatedAt && now !== null ? Math.max(0, Math.floor((now - updatedAt) / 1000)) : null; + const label = reconnecting + ? "Reconnecting" + : source === "fixture" + ? "Offline data" + : source !== "realtime" + ? source === "chain" + ? "On-chain data" + : "REST data" + : displayedFreshness === "fresh" + ? "Live" + : displayedFreshness === "aging" + ? "Updating" + : displayedFreshness === "stale" + ? `Updated ${age}s ago` + : "Waiting for data"; + const tone = + reconnecting || + (everConnected && connection !== "connected" && !withinReconnectGrace) || + displayedFreshness === "stale" + ? "muted" + : displayedFreshness === "fresh" && source === "realtime" + ? "live" + : "quiet"; + return ( + + + ); +} diff --git a/src/hooks/useOracleStatus.ts b/src/hooks/useOracleStatus.ts index e69de29..b54837b 100644 --- a/src/hooks/useOracleStatus.ts +++ b/src/hooks/useOracleStatus.ts @@ -0,0 +1,70 @@ +"use client"; + +import { useEffect } from "react"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { fetchOracleStatus, type OracleReading } from "@/lib/api/oracle"; +import { FIXTURE_ORACLE_READINGS } from "@/lib/fixtures/oracle"; +import { queryFnWithFixtureFallback, type FixtureResult } from "@/lib/query/fixtureFallback"; +import { realtimeClient } from "@/lib/realtime/client"; + +export function useOracleStatus() { + const client = useQueryClient(); + const query = useQuery({ + queryKey: ["oracleStatus"], + queryFn: async ({ + signal, + }): Promise< + FixtureResult<{ readings: OracleReading[] }> & { source?: "api" | "fixture" | "realtime" } + > => { + const result = await queryFnWithFixtureFallback( + () => fetchOracleStatus(signal), + () => ({ readings: FIXTURE_ORACLE_READINGS }), + { signal, fallbackOnAnyError: true } + ); + return { ...result, source: result.isFixture ? "fixture" : "api" }; + }, + refetchInterval: 60_000, + }); + useEffect( + () => + realtimeClient.subscribe("oracle_update", (payload) => { + const reading = payload as OracleReading; + if (!reading || typeof reading !== "object" || typeof reading.coverageType !== "string") + return; + client.setQueryData( + ["oracleStatus"], + ( + current: + | { data: { readings: OracleReading[] }; isFixture: boolean; source?: string } + | undefined + ) => ({ + data: { + readings: [ + ...(current?.data.readings ?? []).filter( + (item) => item.coverageType !== reading.coverageType + ), + reading, + ], + }, + isFixture: false, + source: "realtime", + }) + ); + }), + [client] + ); + const result = query.data?.data; + return { + data: result?.readings ?? null, + loading: query.isLoading, + isFixture: query.data?.isFixture ?? false, + lastUpdated: query.dataUpdatedAt ? new Date(query.dataUpdatedAt) : undefined, + dataSource: query.data?.isFixture + ? ("fixture" as const) + : query.data?.source === "realtime" + ? ("realtime" as const) + : ("api" as const), + isRefreshing: query.isFetching, + refresh: query.refetch, + }; +} diff --git a/src/hooks/usePoolStats.ts b/src/hooks/usePoolStats.ts index e69de29..b98bc21 100644 --- a/src/hooks/usePoolStats.ts +++ b/src/hooks/usePoolStats.ts @@ -0,0 +1,72 @@ +"use client"; + +import { useEffect } from "react"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { fetchPoolStats, type PoolStats } from "@/lib/api/pool"; +import { FIXTURE_POOL_STATS } from "@/lib/fixtures/poolStats"; +import { ApiUnreachableError } from "@/lib/api/client"; +import { readPoolStats } from "@/lib/stellar/rpc"; +import { realtimeClient } from "@/lib/realtime/client"; + +export function usePoolStats() { + const client = useQueryClient(); + const query = useQuery({ + queryKey: ["poolStats"], + queryFn: async ({ signal }) => { + try { + return { + data: await fetchPoolStats(signal), + dataSource: "api" as const, + isFixture: false, + error: undefined, + }; + } catch (apiError) { + if (!(apiError instanceof ApiUnreachableError)) + return { + data: null, + dataSource: null, + isFixture: false, + error: apiError instanceof Error ? apiError.message : "Failed to load pool stats", + }; + try { + return { + data: await readPoolStats(), + dataSource: "chain" as const, + isFixture: false, + error: undefined, + }; + } catch { + return { + data: FIXTURE_POOL_STATS, + dataSource: "fixture" as const, + isFixture: true, + error: undefined, + }; + } + } + }, + refetchInterval: 60_000, + }); + useEffect( + () => + realtimeClient.subscribe("pool_update", (payload) => { + if (!payload || typeof payload !== "object") return; + client.setQueryData(["poolStats"], { + data: payload as PoolStats, + dataSource: "realtime", + isFixture: false, + }); + }), + [client] + ); + const fixture = query.data?.isFixture ?? false; + return { + data: query.data?.data ?? null, + loading: query.isLoading, + isFixture: fixture, + dataSource: query.data?.dataSource ?? null, + lastUpdated: query.dataUpdatedAt ? new Date(query.dataUpdatedAt) : undefined, + error: query.error?.message, + refreshFromChain: readPoolStats, + }; +} diff --git a/src/hooks/useRecentClaims.ts b/src/hooks/useRecentClaims.ts index e69de29..f6fdd6d 100644 --- a/src/hooks/useRecentClaims.ts +++ b/src/hooks/useRecentClaims.ts @@ -0,0 +1,53 @@ +"use client"; + +import { useEffect } from "react"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { fetchRecentClaims, type ClaimRecord } from "@/lib/api/claims"; +import { FIXTURE_RECENT_CLAIMS } from "@/lib/fixtures/recentClaims"; +import { queryFnWithFixtureFallback } from "@/lib/query/fixtureFallback"; +import { realtimeClient } from "@/lib/realtime/client"; + +export function useRecentClaims() { + const client = useQueryClient(); + const query = useQuery({ + queryKey: ["recentClaims"], + queryFn: ({ signal }) => + queryFnWithFixtureFallback( + () => fetchRecentClaims({ limit: 10 }, signal), + () => ({ items: FIXTURE_RECENT_CLAIMS, hasMore: false }), + { signal, fallbackOnAnyError: true } + ), + refetchInterval: 60_000, + }); + useEffect( + () => + realtimeClient.subscribe("claim_update", (payload) => { + const claim = payload as ClaimRecord; + if (!claim || typeof claim !== "object" || typeof claim.policyId !== "string") return; + client.setQueryData( + ["recentClaims"], + ( + current: + { data: { items: ClaimRecord[]; hasMore: boolean }; isFixture: boolean } | undefined + ) => ({ + data: { + items: [ + claim, + ...(current?.data.items ?? []).filter((item) => item.policyId !== claim.policyId), + ].slice(0, 10), + hasMore: current?.data.hasMore ?? false, + }, + isFixture: false, + }) + ); + }), + [client] + ); + return { + data: query.data?.data?.items ?? [], + loading: query.isLoading, + isFixture: query.data?.isFixture ?? false, + lastUpdated: query.dataUpdatedAt ? new Date(query.dataUpdatedAt) : undefined, + dataSource: query.data?.isFixture ? ("fixture" as const) : ("api" as const), + }; +} diff --git a/src/lib/api/oracle.ts b/src/lib/api/oracle.ts index ba680c3..e69de29 100644 --- a/src/lib/api/oracle.ts +++ b/src/lib/api/oracle.ts @@ -1,9 +0,0 @@ -import { apiRequest } from "./client"; -import type { OracleReading } from "@/lib/domain/realtime"; - -/** Types are generated from openapi/refract-api.yaml, which mirrors OracleReading from refract-backend/src/oracle/oracle-reading.ts. */ -export type { OracleReading } from "@/lib/domain/realtime"; - -export function fetchOracleStatus(signal?: AbortSignal): Promise<{ readings: OracleReading[] }> { - return apiRequest("/oracle/status", { signal }); -} diff --git a/src/lib/realtime/client.test.ts b/src/lib/realtime/client.test.ts index e69de29..c776105 100644 --- a/src/lib/realtime/client.test.ts +++ b/src/lib/realtime/client.test.ts @@ -0,0 +1,32 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { RealtimeClient } from "./client"; + +describe("RealtimeClient outbound batching", () => { + afterEach(() => vi.unstubAllGlobals()); + + it("writes twenty topic operations as one frame", () => { + const socket = { readyState: 1, send: vi.fn() }; + vi.stubGlobal("WebSocket", { OPEN: 1 }); + const client = new RealtimeClient() as any; + client.socket = socket; + for (let i = 0; i < 20; i++) client.enqueue(`topic-${i}`, "subscribe"); + client.flush(); + expect(socket.send).toHaveBeenCalledTimes(1); + expect(JSON.parse(socket.send.mock.calls[0]![0]).operations).toHaveLength(20); + }); + + it("elides subscribe then unsubscribe before the queued flush", () => { + const client = new RealtimeClient() as any; + client.enqueue("oracle_update", "subscribe"); + client.enqueue("oracle_update", "unsubscribe"); + expect(client.pending.size).toBe(0); + }); + + it("bounds queued topic operations and warns on overflow", () => { + const client = new RealtimeClient() as any; + const warning = vi.spyOn(console, "warn").mockImplementation(() => {}); + for (let i = 0; i < 501; i++) client.enqueue(`topic-${i}`, "subscribe"); + expect(client.pending.size).toBe(500); + expect(warning).toHaveBeenCalled(); + }); +}); diff --git a/src/lib/realtime/freshness.test.ts b/src/lib/realtime/freshness.test.ts new file mode 100644 index 0000000..12ce94b --- /dev/null +++ b/src/lib/realtime/freshness.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from "vitest"; +import { getFreshness } from "./freshness"; + +describe("realtime freshness thresholds", () => { + it("uses fresh, aging, and stale boundaries consistently", () => { + expect(getFreshness(70_000, 100_000, 30_000)).toBe("fresh"); + expect(getFreshness(69_999, 100_000, 30_000)).toBe("aging"); + expect(getFreshness(40_000, 100_000, 30_000)).toBe("aging"); + expect(getFreshness(39_999, 100_000, 30_000)).toBe("stale"); + }); + + it("handles absent timestamps and future clock skew", () => { + expect(getFreshness(null, 100_000)).toBe("unknown"); + expect(getFreshness(101_000, 100_000)).toBe("fresh"); + }); + + it("holds the previous non-stale state through brief reconnect churn", () => { + expect(getFreshness(0, 65_000, 30_000, 62_000)).toBe("aging"); + expect(getFreshness(0, 67_000, 30_000, 62_000)).toBe("stale"); + }); +}); diff --git a/src/lib/realtime/freshness.ts b/src/lib/realtime/freshness.ts index b225ed3..e69de29 100644 --- a/src/lib/realtime/freshness.ts +++ b/src/lib/realtime/freshness.ts @@ -1,15 +0,0 @@ -export interface FreshnessKey { - sequence?: number; - timestamp?: number; - source: "rest" | "realtime"; -} - -/** `true` only when candidate is demonstrably newer; unorderable ties favor the current realtime value. */ -export function isNewerUpdate(current: FreshnessKey | null, candidate: FreshnessKey): boolean { - if (!current) return true; - if (current.sequence !== undefined && candidate.sequence !== undefined) - return candidate.sequence > current.sequence; - if (current.timestamp !== undefined && candidate.timestamp !== undefined) - return candidate.timestamp > current.timestamp; - return current.source !== "realtime"; -} diff --git a/src/lib/realtime/generated/index.ts b/src/lib/realtime/generated/index.ts new file mode 100644 index 0000000..7024eb2 --- /dev/null +++ b/src/lib/realtime/generated/index.ts @@ -0,0 +1,3 @@ +// Generated by scripts/gen-realtime-types.mjs. Do not edit. +export { OracleReadingSchema, ClaimRecordSchema, PoolStatsSchema, RealtimeEnvelopeSchema } from "./schemas"; +export type { OracleReading, ClaimRecord, PoolStats, RealtimeEnvelope } from "./schemas"; diff --git a/src/lib/realtime/generated/schemas.test.ts b/src/lib/realtime/generated/schemas.test.ts new file mode 100644 index 0000000..fa9df21 --- /dev/null +++ b/src/lib/realtime/generated/schemas.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from "vitest"; +import { ClaimRecordSchema, OracleReadingSchema, RealtimeEnvelopeSchema } from "./schemas"; + +describe("generated realtime validators", () => { + it("accepts the optional settlement hash and rejects malformed readings", () => { + expect( + ClaimRecordSchema.safeParse({ + policyId: "p1", + holder: "G...", + coverageType: 1, + triggered: true, + payout: "100", + reason: "triggered", + processedAt: 1, + }).success + ).toBe(true); + expect( + OracleReadingSchema.safeParse({ + coverageType: "StablecoinDepeg", + type: "oracle_update", + value: 1, + threshold: 0.95, + severity: "invalid", + message: "bad", + }).success + ).toBe(false); + }); + + it("preserves optional, nullable, and nested envelope fields", () => { + expect( + RealtimeEnvelopeSchema.safeParse({ + topic: "oracle_update", + payload: { value: 1 }, + metadata: null, + }).success + ).toBe(true); + expect( + RealtimeEnvelopeSchema.safeParse({ + topic: "oracle_update", + payload: { nested: { value: 1 } }, + }).success + ).toBe(true); + }); +}); diff --git a/src/lib/realtime/generated/schemas.ts b/src/lib/realtime/generated/schemas.ts new file mode 100644 index 0000000..4a765fc --- /dev/null +++ b/src/lib/realtime/generated/schemas.ts @@ -0,0 +1,74 @@ +// Generated by scripts/gen-realtime-types.mjs. Do not edit. +import { z } from "zod"; + +export interface OracleReading { + coverageType: string; + message: string; + severity: "low" | "medium" | "high" | "triggered"; + threshold: number; + type: "oracle_update"; + value: number; +} +export const OracleReadingSchema = z.object({ + coverageType: z.string(), + message: z.string(), + severity: z.enum(["low", "medium", "high", "triggered"]), + threshold: z.number(), + type: z.enum(["oracle_update"]), + value: z.number(), +}); + +export interface ClaimRecord { + coverageType: number; + holder: string; + payout: string; + policyId: string; + processedAt: number; + reason: string; + settlementTxHash?: string; + triggered: boolean; +} +export const ClaimRecordSchema = z.object({ + coverageType: z.number(), + holder: z.string(), + payout: z.string(), + policyId: z.string(), + processedAt: z.number(), + reason: z.string(), + settlementTxHash: z.string().optional(), + triggered: z.boolean(), +}); + +export interface PoolStats { + apyBps: number; + availableUsdc: string; + lockedUsdc: string; + maxUtilizationBps: number; + premiumAccrued: string; + sharePrice: number; + totalShares: string; + totalUsdc: string; + utilizationBps: number; +} +export const PoolStatsSchema = z.object({ + apyBps: z.number(), + availableUsdc: z.string(), + lockedUsdc: z.string(), + maxUtilizationBps: z.number(), + premiumAccrued: z.string(), + sharePrice: z.number(), + totalShares: z.string(), + totalUsdc: z.string(), + utilizationBps: z.number(), +}); + +export interface RealtimeEnvelope { + metadata?: Record | null; + payload: Record; + topic: string; +} +export const RealtimeEnvelopeSchema = z.object({ + metadata: z.record(z.string(), z.unknown()).nullable().optional(), + payload: z.record(z.string(), z.unknown()), + topic: z.string(), +}); diff --git a/src/lib/realtime/types.ts b/src/lib/realtime/types.ts index 8ebbbd5..e69de29 100644 --- a/src/lib/realtime/types.ts +++ b/src/lib/realtime/types.ts @@ -1,72 +0,0 @@ -/** - * Shared types for the realtime transport layer. - */ - -export type RealtimeConnectionState = - | 'idle' - | 'connecting' - | 'connected' - | 'reconnecting' - | 'degraded' - | 'closed'; - -export type RealtimeCloseCode = - | 1000 // normal closure - | 1001 // going away - | 1006 // abnormal closure - | 4001 // auth expired - | 4003 // auth forbidden - | 4401 // unauthorized - | 4403 // forbidden - | number; - -/** - * Provider used by the transport to obtain and refresh auth tokens. - * Kept wallet-agnostic so the transport stays testable and decoupled - * from any specific wallet/auth implementation. - */ -export interface AuthTokenProvider { - /** Returns the current auth token, or null if none is available. */ - getAuthToken(): string | null | Promise; - /** - * Requests a fresh auth token via the existing wallet auth flow. - * Implementations MUST dedupe concurrent calls and avoid refresh loops. - * Resolves to the new token, or null if refresh failed. - */ - refreshAuthToken(): Promise; -} - -/** - * A subscription that is scoped to an authenticated wallet and therefore - * must be replayed after a successful re-authentication. - */ -export interface AuthenticatedSubscription { - topic: string; - /** Wallet address the topic is scoped to, if applicable. */ - wallet?: string; - /** Arbitrary payload replayed on resubscribe. */ - params?: Record; -} - -/** - * Reasons a realtime connection may enter a degraded state. - */ -export type RealtimeDegradedReason = - | 'auth-refresh-failed' - | 'auth-refresh-loop' - | 'auth-required'; - -export interface RealtimeDegradedState { - reason: RealtimeDegradedReason; - /** Whether the consumer should fall back to REST polling. */ - fallbackToRest: boolean; - /** Human-readable message safe to surface in the UI (never contains tokens). */ - message: string; -} - -export interface RealtimeAuthOptions { - /** Injected token provider; required for wallet-scoped subscriptions. */ - auth?: AuthTokenProvider; - /** Max consecutive refresh attempts before entering degraded state. */ - maxRefreshAttempts?: number; -}