Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,10 @@ Runtime errors are reported to Sentry when `NEXT_PUBLIC_SENTRY_DSN` is set. See

[MIT](./LICENSE)

## Data freshness

Landing protocol statistics, oracle status, and payout lists seed client state from REST before showing headline values, then accept feature-flagged realtime updates with a single REST fallback and explicit refresh where provided. Realtime topic flags default off and remain kill switches during the production observation window. There is no interval polling in these data hooks; the landing page's seconds-ago display timer only updates text. See [docs/realtime-freshness.md](docs/realtime-freshness.md) for the polling inventory, topic rollout gates, ordering rule, and safe debug logging switch.

## Realtime layer (dev)

Realtime is off by default (`public/flags.json`; see `docs/realtime-protocol.md` and `docs/realtime-rollout.md`).
Expand Down
24 changes: 24 additions & 0 deletions docs/realtime-freshness.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
# Realtime freshness and polling retirement

Realtime is opt-in per topic through the remotely supplied feature flags. Each enabled topic seeds client state from one REST request, applies ordered realtime events through `useRealtimeResource`, and keeps an explicit refresh action where the UI exposes one. REST is the bounded fallback when the transport is unavailable; there are no interval polls in the data hooks.

## Legacy path inventory and disposition

| Path | Current state | Disposition |
| -------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------- |
| `useOracleStatus` interval/backoff/manual refresh | The hook was empty on the upstream baseline; no data interval or polling backoff existed. The landing page's one-second interval only updates the “seconds ago” label. | Keep manual refresh; use realtime topic `oracle.status` with one REST seed/fallback. Retain the display clock because it does not fetch data. |
| `useRecentClaims` polling constants/controls | The hook was empty; no polling constants existed. | Use `claims.recent`, one REST seed/fallback, and the landing payouts refresh action. |
| `useClaims` holder polling | The hook was empty; no polling constants existed. | Use `claims.holder.<address>` when its kill switch is enabled, one REST seed/fallback, and an explicit dashboard refresh. |
| `useNotifications` interval/backoff/manual refresh | No notification-specific polling existed; it derives from shared hooks. | Keep derived notifications and inherit each source's freshness policy. |
| Shared interval/backoff helpers in `src/lib/` | No data polling helper was present. WebSocket reconnect backoff remains transport recovery, not data polling. | Keep reconnect backoff; do not hand it to adaptive polling. |
| `/` seconds-ago clock | A one-second UI timer updates elapsed-time text only. | Keep; it does not request data or write cache state. |

Topic flags default off and act as kill switches. The retirement is staged: no legacy poll was deleted because none existed in the upstream baseline. For each topic, keep the flag off until production has met the team's agreed observation window; then enable the topic, observe fallback/error rates, and remove any subsequently introduced legacy interval only after the window. The observation-window duration is an operational decision and should be recorded with the rollout.

## Ordering rule

The transport accepts an optional numeric `sequence` and numeric or ISO `timestamp` on each envelope. Higher sequence wins when both updates carry one; otherwise a strictly newer timestamp wins when both carry one. Equal or missing ordering keys favor the already-applied realtime update. REST responses started before a realtime event are aborted and also rejected by a generation guard if cancellation races completion. Comparable newer REST snapshots may advance state during a degraded connection. Cancellation is an optimization; the freshness checks remain authoritative.

## Logging

Realtime diagnostics pass through `src/lib/realtime/redact.ts`. Sensitive keys (auth tokens, wallet/address/holder identifiers, signatures, session credentials) are redacted recursively, arrays and objects are bounded, error messages are omitted, and unknown strings are truncated. Verbose message summaries are off in production and can be temporarily enabled in a development tab with `sessionStorage.setItem("refract:realtime-debug", "1")`; the switch expires when the tab's session ends. Only topic-safe summaries, byte counts, and error names are logged.
49 changes: 49 additions & 0 deletions e2e/realtime-stats.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { expect, test } from "@playwright/test";

test("landing protocol statistics update after a protocol.stats push", async ({ page }) => {
const sockets: Array<{ send: (message: string) => void }> = [];
await page.route("**/flags.json", (route) =>
route.fulfill({ json: { flags: { "realtime-protocol-stats": true } } })
);
await page.route("**/api/v1/pool/stats", (route) =>
route.fulfill({
json: {
totalUsdc: (2_500_000 * 1e7).toString(),
totalShares: "0",
lockedUsdc: "0",
premiumAccrued: "0",
availableUsdc: "0",
utilizationBps: 0,
apyBps: 0,
sharePrice: 1,
maxUtilizationBps: 8000,
},
})
);
await page.route("**/api/v1/claims/stats", (route) =>
route.fulfill({
json: {
activePolicies: 11,
processedClaims: 0,
totalPayout: "0",
settlementConfigured: true,
},
})
);
await page.routeWebSocket("**/ws", (webSocket) => {
sockets.push(webSocket);
});

await page.goto("/");
await expect(page.getByText("Policies Active")).toBeVisible();
expect(sockets).toHaveLength(1);
sockets[0].send(
JSON.stringify({
topic: "protocol.stats",
sequence: 1,
timestamp: Date.now(),
payload: { tvl: (3_200_000 * 1e7).toString(), activePolicies: 31, cumulativePayouts: "0" },
})
);
await expect(page.getByText("31", { exact: true })).toBeVisible();
});
6 changes: 5 additions & 1 deletion src/app/dashboard/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,8 @@ export default function DashboardPage() {
const wallet = useWallet();
const address = wallet.status === "connected" ? wallet.address : null;
const { data: policies, loading, error, isFixture, isStale } = useHolderPolicies(address);
const claims = useClaims(address, policies);
const claimsResource = useClaims(address, policies);
const claims = claimsResource.data;
const policiesById = normalizePolicies(policies ?? []);
const claimsByPolicyId = normalizeClaims(claims);
const [exporting, setExporting] = useState(false);
Expand Down Expand Up @@ -96,6 +97,9 @@ export default function DashboardPage() {
</div>
{canExport && (
<div className="flex shrink-0 items-center gap-2">
<Button type="button" variant="outline" onClick={() => void claimsResource.refresh()} disabled={claimsResource.isRefreshing}>
{claimsResource.isRefreshing ? "Refreshing claims…" : "Refresh claims"}
</Button>
<Button
type="button"
variant="outline"
Expand Down
110 changes: 57 additions & 53 deletions src/app/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,7 @@ import { Container, Button, Badge, Card, Skeleton } from "@/components/ui";
import { usePrefersReducedMotion } from "@/hooks/usePrefersReducedMotion";
import { useOracleStatus } from "@/hooks/useOracleStatus";
import { useRecentClaims } from "@/hooks/useRecentClaims";
import { usePoolStats } from "@/hooks/usePoolStats";
import { useClaimStats } from "@/hooks/useClaimStats";
import { useProtocolStats } from "@/hooks/useProtocolStats";
import type { OracleReading } from "@/lib/api/oracle";
import type { BadgeTone } from "@/components/ui/Badge";
import { formatUsd, formatRelativeTime, fromStroops } from "@/lib/format";
Expand All @@ -19,27 +18,25 @@ import { RealtimeProfiler } from "@/lib/realtime/devProfiler";

function Counter({ to, prefix = "", suffix = "", decimals = 0 }: { to: number; prefix?: string; suffix?: string; decimals?: number }) {
const [val, setVal] = useState(0);
const ref = useRef(false);
const reducedMotion = usePrefersReducedMotion();

useEffect(() => {
if (ref.current) return;
ref.current = true;

if (reducedMotion) {
setVal(to);
return;
}

const duration = 2000;
const start = performance.now();
const from = val;
let frame = 0;
const tick = (now: number) => {
const t = Math.min((now - start) / duration, 1);
const ease = 1 - Math.pow(1 - t, 3);
setVal(ease * to);
if (t < 1) requestAnimationFrame(tick);
setVal(from + (to - from) * ease);
if (t < 1) frame = requestAnimationFrame(tick);
};
requestAnimationFrame(tick);
frame = requestAnimationFrame(tick);
return () => cancelAnimationFrame(frame);
}, [to, reducedMotion]);
return <>{prefix}{val.toLocaleString("en-US", { minimumFractionDigits: decimals, maximumFractionDigits: decimals })}{suffix}</>;
}
Expand Down Expand Up @@ -81,9 +78,8 @@ export default function Home() {
const ct = COVERAGE_TYPES[activeType];
const oracleStatus = useOracleStatus();
const recentClaims = useRecentClaims();
const poolStats = usePoolStats();
const claimStats = useClaimStats();
const statsLoading = poolStats.loading || claimStats.loading;
const protocolStats = useProtocolStats();
const statsLoading = protocolStats.loading;
const anyTriggered = oracleStatus.data?.some((r) => r.severity === "triggered") ?? false;
const [secondsAgo, setSecondsAgo] = useState(0);

Expand Down Expand Up @@ -135,44 +131,51 @@ export default function Home() {
</div>

{/* Stats */}
<div className="mt-12 grid grid-cols-1 gap-8 border-t border-pm-border pt-8 xs:grid-cols-3">
{[
{
val: poolStats.data ? fromStroops(poolStats.data.totalUsdc) : 0,
prefix: "$",
suffix: "",
label: "Total Value Protected",
dec: 0,
},
{
val: poolStats.data ? poolStats.data.apyBps / 100 : 0,
prefix: "",
suffix: "% APY",
label: "Capital Provider Returns",
dec: 1,
},
{
val: claimStats.data?.activePolicies ?? 0,
prefix: "",
suffix: "",
label: "Policies Active",
dec: 0,
},
].map((s) => (
<div key={s.label}>
<div className="font-display text-[26px] font-extrabold tracking-tight text-pm-text sm:text-[28px]">
{statsLoading ? (
<Skeleton height={26} width={90} />
) : (
// Counter only animates once on mount, so it's keyed on
// loaded-state to remount (and pick up the real value)
// the moment the data actually arrives.
<Counter key="loaded" to={s.val} prefix={s.prefix} suffix={s.suffix} decimals={s.dec} />
)}
<div className="mt-12 border-t border-pm-border pt-4">
<div className="mb-2 flex justify-end">
<button type="button" onClick={() => void protocolStats.refresh()} disabled={protocolStats.isRefreshing} aria-label="Refresh protocol statistics" className="text-[11px] text-pm-text/40 hover:text-pm-text disabled:opacity-50">
{protocolStats.isRefreshing ? "Refreshing…" : "Refresh statistics ↻"}
</button>
</div>
<div className="grid grid-cols-1 gap-8 xs:grid-cols-3">
{[
{
val: protocolStats.data ? fromStroops(protocolStats.data.tvl) : 0,
prefix: "$",
suffix: "",
label: "Total Value Protected",
dec: 0,
payout: false,
},
{
val: 0,
prefix: "",
suffix: "",
label: "Cumulative Payouts",
dec: 0,
payout: true,
},
{
val: protocolStats.data?.activePolicies ?? 0,
prefix: "",
suffix: "",
label: "Policies Active",
dec: 0,
payout: false,
},
].map((s) => (
<div key={s.label}>
<div className="min-h-[31px] font-display tabular-nums text-[26px] font-extrabold tracking-tight text-pm-text sm:text-[28px]">
{statsLoading ? (
<Skeleton height={26} width={90} />
) : (
<Counter key="loaded" to={s.payout ? fromStroops(protocolStats.data?.cumulativePayouts ?? "0") : s.val} prefix={s.payout ? "$" : s.prefix} suffix={s.suffix} decimals={s.dec} />
)}
</div>
<div className="mt-0.5 text-xs text-pm-text/40">{s.label}</div>
</div>
<div className="mt-0.5 text-xs text-pm-text/40">{s.label}</div>
</div>
))}
))}
</div>
</div>
</div>

Expand Down Expand Up @@ -356,9 +359,10 @@ export default function Home() {
<RealtimeBoundary topic="claim_settlement" route="/" fallback={() => <section aria-labelledby="payouts-heading" className="py-16"><Container><h2 id="payouts-heading" className="font-display text-2xl font-extrabold text-pm-text">Recent payouts</h2><p role="status" className="mt-3 text-sm text-pm-text/50">Payout updates are temporarily unavailable.</p></Container></section>}>
<RealtimeProfiler topic="claim_settlement"><section aria-labelledby="payouts-heading" className="py-16">
<Container>
<h2 id="payouts-heading" className="mb-7 font-display text-2xl font-extrabold tracking-tight text-pm-text sm:text-[26px]">
Recent payouts
</h2>
<div className="mb-7 flex items-center justify-between">
<h2 id="payouts-heading" className="font-display text-2xl font-extrabold tracking-tight text-pm-text sm:text-[26px]">Recent payouts</h2>
<button type="button" onClick={() => void recentClaims.refresh()} disabled={recentClaims.isRefreshing} className="text-[11px] text-pm-text/40 hover:text-pm-text disabled:opacity-50">Refresh ↻</button>
</div>
{recentClaims.isFixture && (
<p className="mb-4 inline-flex items-center gap-1.5 text-[11px] text-pm-amber">
⚠ Showing example data — the Refract API isn&apos;t reachable from this environment.
Expand Down
2 changes: 1 addition & 1 deletion src/components/GlobalSearch.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ function SearchDialog({ onClose }: { onClose: () => void }) {
const wallet = useWallet();
const address = wallet.status === "connected" ? wallet.address : null;
const { data: policies } = useHolderPolicies(address);
const claims = useClaims(address, policies);
const { data: claims } = useClaims(address, policies);
const { data: coverageTypes } = useCoverageTypes();
const [query, setQuery] = useState("");
const [active, setActive] = useState(0);
Expand Down
45 changes: 45 additions & 0 deletions src/hooks/useOracleStatus.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
"use client";
import { useCallback } from "react";
import { fetchOracleStatus, type OracleReading } from "@/lib/api/oracle";
import { FIXTURE_ORACLE_READINGS } from "@/lib/fixtures/oracle";
import { useRealtimeResource } from "@/lib/realtime/useRealtimeResource";

export function useOracleStatus() {
const fetcher = useCallback(
async (signal: AbortSignal) => (await fetchOracleStatus(signal)).readings,
[]
);
const resource = useRealtimeResource<OracleReading[]>({
topic: "oracle.status",
flag: "realtime-oracle",
fetcher,
fixture: () => FIXTURE_ORACLE_READINGS,
mergeEvent: (payload, current) => {
const reading =
payload && typeof payload === "object" && "coverageType" in payload && "value" in payload
? (payload as OracleReading)
: null;
if (Array.isArray(payload)) return payload as OracleReading[];
if (
payload &&
typeof payload === "object" &&
Array.isArray((payload as { readings?: unknown }).readings)
)
return (payload as { readings: OracleReading[] }).readings;
if (!reading) return null;
return [
...(current ?? []).filter((item) => item.coverageType !== reading.coverageType),
reading,
];
},
});
return {
data: resource.data,
loading: resource.loading,
error: resource.error,
isFixture: resource.isFixture,
lastUpdated: resource.lastUpdated,
isRefreshing: resource.isRefreshing,
refresh: resource.refresh,
};
}
Loading