Skip to content
Open
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
46 changes: 41 additions & 5 deletions ts/apps/admin/components/settlement-card.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import { useSupportedUsdcMint } from "@/hooks/useSupportedUsdcMint";
const USDC_DECIMALS = 6;
const CLAIMS_PER_TX = 6;
const MIGRATIONS_PER_TX = 8;
const EPOCH_FRESHNESS_MS = 30 * 1000;

interface AccountMetaLike {
pubkey: PublicKey;
Expand Down Expand Up @@ -158,6 +159,7 @@ export function SettlementCard() {
const [minSettlementInput, setMinSettlementInput] = useState("");
const [savingMinSettlement, setSavingMinSettlement] = useState(false);
const [epochOpen, setEpochOpen] = useState(false);
const [epochFetchedAt, setEpochFetchedAt] = useState<number | null>(null);
const [epochLoading, setEpochLoading] = useState(false);
const [migrating, setMigrating] = useState(false);
const [migrationProgress, setMigrationProgress] = useState({ current: 0, total: 0 });
Expand Down Expand Up @@ -224,6 +226,20 @@ export function SettlementCard() {
() => claims.filter((claim) => claim.needsMigration).length,
[claims]
);
const [epochStaleFlag, setEpochStaleFlag] = useState(0);
const epochStateFresh = epochFetchedAt !== null && (Date.now() - epochFetchedAt) < EPOCH_FRESHNESS_MS;

useEffect(() => {
if (epochFetchedAt === null) return;
const remaining = EPOCH_FRESHNESS_MS - (Date.now() - epochFetchedAt);
if (remaining <= 0) {
setEpochStaleFlag((n) => n + 1);
return;
}
const timer = setTimeout(() => setEpochStaleFlag((n) => n + 1), remaining);
return () => clearTimeout(timer);
}, [epochFetchedAt, epochStaleFlag]);
Comment on lines +232 to +241

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Infinite re-render loop once epoch becomes stale

When the timeout fires it calls setEpochStaleFlag(n => n + 1), which schedules a re-render. The effect then re-runs (because epochStaleFlag is in the deps array). At that point remaining ≤ 0, so the remaining <= 0 branch immediately calls setEpochStaleFlag(n => n + 1) again — which triggers another render, another effect run, another state update, and so on indefinitely. In practice this starts ~30 s after any epoch state is fetched and will saturate the main thread until the admin tab is closed.

The fix is to drop the setEpochStaleFlag call in the remaining <= 0 branch. By the time that branch runs, the previous timer-fired setEpochStaleFlag already caused the re-render that set epochStateFresh = false; there is nothing left to trigger.

Suggested change
useEffect(() => {
if (epochFetchedAt === null) return;
const remaining = EPOCH_FRESHNESS_MS - (Date.now() - epochFetchedAt);
if (remaining <= 0) {
setEpochStaleFlag((n) => n + 1);
return;
}
const timer = setTimeout(() => setEpochStaleFlag((n) => n + 1), remaining);
return () => clearTimeout(timer);
}, [epochFetchedAt, epochStaleFlag]);
useEffect(() => {
if (epochFetchedAt === null) return;
const remaining = EPOCH_FRESHNESS_MS - (Date.now() - epochFetchedAt);
if (remaining <= 0) return; // already stale — previous setEpochStaleFlag already re-rendered
const timer = setTimeout(() => setEpochStaleFlag((n) => n + 1), remaining);
return () => clearTimeout(timer);
}, [epochFetchedAt, epochStaleFlag]);


const pendingClaimsMismatch = poolPendingClaims !== null && poolPendingClaims !== totalRequested;
const pendingClaimsSyncRequired =
poolPendingClaims !== null && totalRequested > poolPendingClaims;
Expand Down Expand Up @@ -278,6 +294,7 @@ export function SettlementCard() {
const state = await accountApi.settlementState.fetch(settlementStatePda);
if (signal?.aborted) return;
setEpochOpen(true);
setEpochFetchedAt(Date.now());
setEpochError(null);
setEpochVaultSnapshot(BigInt(state.vaultSnapshot.toString()));
setEpochPendingSnapshot(BigInt(state.pendingSnapshot.toString()));
Expand All @@ -298,7 +315,10 @@ export function SettlementCard() {
setEpochCoveredUsdc(null);
const isAccountNotFound =
e instanceof Error && e.message.includes("could not find account");
if (!isAccountNotFound) {
if (isAccountNotFound) {
setEpochFetchedAt(Date.now());
} else {
setEpochFetchedAt(null);
setEpochError(getErrorMessage(e, "Failed to fetch settlement epoch"));
}
}
Expand Down Expand Up @@ -368,6 +388,10 @@ export function SettlementCard() {

const handleOpenSettlement = useCallback(async () => {
if (!program || !publicKey || !usdcMint || !usdcTokenProgram || !payoutVault) return;
if (!epochFetchedAt || (Date.now() - epochFetchedAt) >= EPOCH_FRESHNESS_MS) {
setEpochError("Epoch state is stale. Refresh before opening a settlement epoch.");
return;
}
if (needsMigrationCount > 0) {
setEpochError(
`${needsMigrationCount} legacy claim${needsMigrationCount === 1 ? "" : "s"} must be migrated before a settlement epoch can open.`
Expand Down Expand Up @@ -406,10 +430,14 @@ export function SettlementCard() {
} finally {
setEpochLoading(false);
}
}, [fetchPoolPendingClaims, fetchSettlementEpoch, minSettlementConfigPda, needsMigrationCount, payoutVault, poolPda, program, publicKey, refreshVault, settlementStatePda, supportedUsdcConfigPda, usdcMint, usdcTokenProgram]);
}, [epochFetchedAt, fetchPoolPendingClaims, fetchSettlementEpoch, minSettlementConfigPda, needsMigrationCount, payoutVault, poolPda, program, publicKey, refreshVault, settlementStatePda, supportedUsdcConfigPda, usdcMint, usdcTokenProgram]);

const handleCloseSettlement = useCallback(async () => {
if (!program || !publicKey) return;
if (!epochFetchedAt || (Date.now() - epochFetchedAt) >= EPOCH_FRESHNESS_MS) {
setEpochError("Epoch state is stale. Refresh before closing the settlement epoch.");
return;
}
setEpochLoading(true);
setEpochError(null);
setTxError(null);
Expand Down Expand Up @@ -441,7 +469,7 @@ export function SettlementCard() {
} finally {
setEpochLoading(false);
}
}, [fetchPoolPendingClaims, poolPda, program, publicKey, refreshClaims, refreshVault, settlementStatePda]);
}, [epochFetchedAt, fetchPoolPendingClaims, poolPda, program, publicKey, refreshClaims, refreshVault, settlementStatePda]);

// migrate_claim is permissionless on-chain (the connected wallet only pays
// the realloc rent), so this works for any operator wallet. It reverts if a
Expand Down Expand Up @@ -720,6 +748,7 @@ export function SettlementCard() {
!!program &&
!!usdcMint &&
epochOpen &&
epochStateFresh &&
settlementPlan.length > 0 &&
!settling &&
!pendingClaimsSyncRequired &&
Expand Down Expand Up @@ -858,7 +887,7 @@ export function SettlementCard() {
{!epochOpen ? (
<button
onClick={() => void handleOpenSettlement()}
disabled={!wallet.publicKey || !program || epochLoading || migrating || claims.length === 0 || needsMigrationCount > 0 || vaultBelowMinSettlement}
disabled={!wallet.publicKey || !program || epochLoading || migrating || claims.length === 0 || needsMigrationCount > 0 || vaultBelowMinSettlement || !epochStateFresh}
className="inline-flex items-center gap-2 rounded-lg bg-[#00FFB2] px-4 py-2 text-sm font-medium text-black transition hover:bg-[#33FFC1] disabled:cursor-not-allowed disabled:bg-neutral-800 disabled:text-neutral-500"
>
{epochLoading ? <Loader2 className="h-4 w-4 animate-spin" /> : null}
Expand All @@ -867,7 +896,7 @@ export function SettlementCard() {
) : (
<button
onClick={() => void handleCloseSettlement()}
disabled={!wallet.publicKey || !program || epochLoading || settling || epochCoverageComplete === false}
disabled={!wallet.publicKey || !program || epochLoading || settling || epochCoverageComplete === false || !epochStateFresh}
title={epochCoverageComplete === false ? "Every claim in the epoch snapshot must be settled or cancelled before the epoch can close." : undefined}
className="inline-flex items-center gap-2 rounded-lg border border-rose-500/40 bg-rose-500/10 px-4 py-2 text-sm font-medium text-rose-300 transition hover:bg-rose-500/20 disabled:cursor-not-allowed disabled:opacity-50"
>
Expand All @@ -878,6 +907,13 @@ export function SettlementCard() {
</div>
</div>

{!epochStateFresh && epochFetchedAt !== null && (
<div className="mt-4 flex items-start gap-3 rounded-xl border border-amber-500/20 bg-amber-500/5 px-4 py-3 text-sm text-amber-300">
<AlertCircle className="mt-0.5 h-4 w-4 shrink-0" />
Epoch state is stale. Refresh before taking any settlement action.
</div>
)}

{epochOpen && (
<div className="mt-4 grid grid-cols-1 gap-4 md:grid-cols-4">
<div className="rounded-lg border border-neutral-800/60 bg-neutral-950/40 p-4">
Expand Down
172 changes: 70 additions & 102 deletions ts/apps/admin/components/support-requests-list.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
"use client";

import { useCallback, useEffect, useRef, useState } from "react";
import { useCallback, useEffect, useState } from "react";
import { useWallet } from "@solana/wallet-adapter-react";
import {
AlertCircle,
Expand All @@ -14,7 +14,6 @@ import { buildAdminAccessMessage } from "@/lib/admin-auth-message";
import type { SupportRequestRecord } from "@/lib/support-requests";

const PAGE_SIZE = 25;
const ACCESS_HEADER_TTL_MS = 4 * 60 * 1000;

function getErrorMessage(value: unknown, fallback: string): string {
if (
Expand Down Expand Up @@ -46,28 +45,12 @@ export function SupportRequestsList() {
const [loadingMore, setLoadingMore] = useState(false);
const [error, setError] = useState<string | null>(null);
const [nextCursor, setNextCursor] = useState<string | null>(null);
const accessHeadersRef = useRef<{
wallet: string;
issuedAtMs: number;
headers: Record<string, string>;
} | null>(null);

const buildAccessHeaders = useCallback(async (forceRefresh = false) => {
const buildAccessHeaders = useCallback(async () => {
if (!publicKey || !signMessage) {
throw new Error("Connect an admin wallet that supports message signing");
}

const wallet = publicKey.toBase58();
const cachedHeaders = accessHeadersRef.current;
if (
!forceRefresh &&
cachedHeaders &&
cachedHeaders.wallet === wallet &&
Date.now() - cachedHeaders.issuedAtMs < ACCESS_HEADER_TTL_MS
) {
return cachedHeaders.headers;
}

const issuedAt = new Date().toISOString();
const signatureBytes = await signMessage(
new TextEncoder().encode(buildAdminAccessMessage(issuedAt)),
Expand All @@ -79,103 +62,87 @@ export function SupportRequestsList() {
"x-admin-issued-at": issuedAt,
"x-admin-signature": signature,
};
accessHeadersRef.current = {
wallet,
issuedAtMs: Date.now(),
headers,
};

return headers;
}, [publicKey, signMessage]);

useEffect(() => {
accessHeadersRef.current = null;
}, [publicKey]);

const fetchRequests = useCallback(async (options?: {
cursor?: string | null;
append?: boolean;
}) => {
const cursor = options?.cursor ?? null;
const append = options?.append ?? false;
setError(null);

if (append) {
setLoadingMore(true);
} else {
setLoading(true);
}
const fetchRequests = useCallback(
async (options?: { cursor?: string | null; append?: boolean }) => {
const cursor = options?.cursor ?? null;
const append = options?.append ?? false;
setError(null);

try {
const searchParams = new URLSearchParams({
limit: PAGE_SIZE.toString(),
});
if (cursor) {
searchParams.set("cursor", cursor);
if (append) {
setLoadingMore(true);
} else {
setLoading(true);
}

const requestUrl = `/api/support-requests?${searchParams.toString()}`;
let response = await fetch(requestUrl, {
headers: await buildAccessHeaders(),
});
try {
const searchParams = new URLSearchParams({
limit: PAGE_SIZE.toString(),
});
if (cursor) {
searchParams.set("cursor", cursor);
}

if (response.status === 401) {
accessHeadersRef.current = null;
response = await fetch(requestUrl, {
headers: await buildAccessHeaders(true),
const requestUrl = `/api/support-requests?${searchParams.toString()}`;
const response = await fetch(requestUrl, {
headers: await buildAccessHeaders(),
});
}

const data = await response.json();
const data = await response.json();

if (!response.ok) {
throw new Error(
getErrorMessage(data, "Failed to load support requests"),
);
}
if (!response.ok) {
throw new Error(
getErrorMessage(data, "Failed to load support requests"),
);
}

const nextRequests =
data &&
typeof data === "object" &&
"requests" in data &&
Array.isArray((data as { requests?: unknown }).requests)
? ((data as { requests: SupportRequestRecord[] }).requests ?? [])
: [];
const nextPageCursor =
data &&
typeof data === "object" &&
"nextCursor" in data &&
(typeof (data as { nextCursor?: unknown }).nextCursor === "string" ||
(data as { nextCursor?: unknown }).nextCursor === null)
? ((data as { nextCursor: string | null }).nextCursor ?? null)
: null;
const nextRequests =
data &&
typeof data === "object" &&
"requests" in data &&
Array.isArray((data as { requests?: unknown }).requests)
? ((data as { requests: SupportRequestRecord[] }).requests ?? [])
: [];
const nextPageCursor =
data &&
typeof data === "object" &&
"nextCursor" in data &&
(typeof (data as { nextCursor?: unknown }).nextCursor === "string" ||
(data as { nextCursor?: unknown }).nextCursor === null)
? ((data as { nextCursor: string | null }).nextCursor ?? null)
: null;

setRequests((current) => {
if (!append) {
return nextRequests;
}
setRequests((current) => {
if (!append) {
return nextRequests;
}

const existingIds = new Set(current.map((request) => request.id));
return [
...current,
...nextRequests.filter((request) => !existingIds.has(request.id)),
];
});
setNextCursor(nextPageCursor);
} catch (requestError: unknown) {
setError(
requestError instanceof Error
? requestError.message
: "Failed to load support requests",
);
} finally {
if (append) {
setLoadingMore(false);
} else {
setLoading(false);
const existingIds = new Set(current.map((request) => request.id));
return [
...current,
...nextRequests.filter((request) => !existingIds.has(request.id)),
];
});
setNextCursor(nextPageCursor);
} catch (requestError: unknown) {
setError(
requestError instanceof Error
? requestError.message
: "Failed to load support requests",
);
} finally {
if (append) {
setLoadingMore(false);
} else {
setLoading(false);
}
}
}
}, [buildAccessHeaders]);
},
[buildAccessHeaders],
);

useEffect(() => {
void fetchRequests();
Expand All @@ -191,7 +158,8 @@ export function SupportRequestsList() {
</p>
{requests.length > 0 ? (
<p className="mt-2 text-xs text-neutral-600">
Showing {requests.length} request{requests.length === 1 ? "" : "s"}
Showing {requests.length} request
{requests.length === 1 ? "" : "s"}
{nextCursor ? " with more available" : ""}
</p>
) : null}
Expand Down
Loading