127 lines
5.4 KiB
TypeScript
127 lines
5.4 KiB
TypeScript
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
|
import { useCallback, useEffect, useMemo, useRef } from "react";
|
|
import type { PublicClient } from "viem";
|
|
import { useWatchBlockNumber } from "wagmi";
|
|
import { createManifestPublicClient } from "../config/chains";
|
|
import { parseDeploymentManifest } from "../config/manifest";
|
|
import { loadDashboardSnapshot, ViemBankReader } from "../data/bankClient";
|
|
import type { DashboardSnapshot, DeploymentManifest } from "../types/dashboard";
|
|
|
|
export type DashboardState =
|
|
| Readonly<{ status: "loading" }>
|
|
| Readonly<{ status: "ready"; manifest: DeploymentManifest; snapshot: DashboardSnapshot }>
|
|
| Readonly<{ status: "stale"; manifest: DeploymentManifest; snapshot: DashboardSnapshot; failedAt: Date; error: string }>
|
|
| Readonly<{ status: "disconnected"; manifest: DeploymentManifest; snapshot?: undefined; failedAt: Date; error: string }>
|
|
| Readonly<{ status: "invalid-manifest"; error: string }>
|
|
| Readonly<{ status: "chain-mismatch"; manifest: DeploymentManifest; error: string }>;
|
|
|
|
export type BankDashboardOptions = Readonly<{
|
|
loadManifest?: () => Promise<unknown>;
|
|
loader?: (manifest: DeploymentManifest) => Promise<DashboardSnapshot>;
|
|
now?: () => Date;
|
|
}>;
|
|
|
|
const manifestQueryKey = ["deployment-manifest"] as const;
|
|
const defaultNow = () => new Date();
|
|
|
|
async function fetchManifest(): Promise<unknown> {
|
|
const response = await fetch("/deployment.json", { headers: { Accept: "application/json" } });
|
|
if (!response.ok) throw new Error(`deployment manifest request failed with HTTP ${response.status}`);
|
|
return response.json() as Promise<unknown>;
|
|
}
|
|
|
|
async function fetchSnapshot(manifest: DeploymentManifest): Promise<DashboardSnapshot> {
|
|
const client = createManifestPublicClient(manifest);
|
|
return loadDashboardSnapshot(new ViemBankReader(client as PublicClient), manifest);
|
|
}
|
|
|
|
function message(error: unknown): string {
|
|
return error instanceof Error ? error.message : "Unknown dashboard error";
|
|
}
|
|
|
|
function isChainMismatch(error: unknown): boolean {
|
|
return /endpoint chain ID \d+ does not match manifest chain ID \d+/i.test(message(error));
|
|
}
|
|
|
|
export function useBankDashboard(options: BankDashboardOptions = {}): DashboardState {
|
|
const loadManifest = options.loadManifest ?? fetchManifest;
|
|
const loader = options.loader ?? fetchSnapshot;
|
|
const now = options.now ?? defaultNow;
|
|
const queryClient = useQueryClient();
|
|
const lastWatchedBlock = useRef<bigint | undefined>(undefined);
|
|
|
|
const manifestQuery = useQuery({
|
|
queryKey: manifestQueryKey,
|
|
queryFn: loadManifest,
|
|
retry: false,
|
|
retryOnMount: false,
|
|
staleTime: Number.POSITIVE_INFINITY,
|
|
refetchOnMount: false,
|
|
refetchOnReconnect: false,
|
|
refetchOnWindowFocus: false,
|
|
});
|
|
|
|
const parsedManifest = useMemo(() => {
|
|
if (manifestQuery.data === undefined) return undefined;
|
|
try {
|
|
return { manifest: parseDeploymentManifest(manifestQuery.data) } as const;
|
|
} catch (error) {
|
|
return { error: message(error) } as const;
|
|
}
|
|
}, [manifestQuery.data]);
|
|
const manifest = parsedManifest && "manifest" in parsedManifest ? parsedManifest.manifest : undefined;
|
|
const snapshotQueryKey = useMemo(
|
|
() => ["bank-dashboard", manifest?.chainId, manifest?.proxy] as const,
|
|
[manifest?.chainId, manifest?.proxy],
|
|
);
|
|
|
|
const snapshotQuery = useQuery({
|
|
queryKey: snapshotQueryKey,
|
|
queryFn: () => {
|
|
if (!manifest) throw new Error("deployment manifest is unavailable");
|
|
return loader(manifest);
|
|
},
|
|
enabled: manifest !== undefined,
|
|
retry: false,
|
|
retryOnMount: false,
|
|
refetchOnMount: false,
|
|
refetchOnReconnect: false,
|
|
refetchOnWindowFocus: false,
|
|
});
|
|
const mismatch = snapshotQuery.error !== null && isChainMismatch(snapshotQuery.error);
|
|
const latestSnapshot = useRef<DashboardSnapshot | undefined>(undefined);
|
|
useEffect(() => {
|
|
latestSnapshot.current = snapshotQuery.data;
|
|
}, [snapshotQuery.data]);
|
|
|
|
const reconcileBlock = useCallback((blockNumber: bigint) => {
|
|
if (!manifest || mismatch || latestSnapshot.current?.blockNumber === blockNumber || lastWatchedBlock.current === blockNumber) return;
|
|
lastWatchedBlock.current = blockNumber;
|
|
void queryClient.invalidateQueries({ queryKey: snapshotQueryKey, exact: true });
|
|
}, [manifest, mismatch, queryClient, snapshotQueryKey]);
|
|
|
|
useWatchBlockNumber({
|
|
chainId: manifest?.chainId,
|
|
enabled: manifest !== undefined && !mismatch,
|
|
onBlockNumber: reconcileBlock,
|
|
});
|
|
|
|
const failedAt = useMemo(
|
|
() => snapshotQuery.error === null ? undefined : now(),
|
|
[now, snapshotQuery.error],
|
|
);
|
|
|
|
if (manifestQuery.isPending) return { status: "loading" };
|
|
if (manifestQuery.error !== null) return { status: "invalid-manifest", error: message(manifestQuery.error) };
|
|
if (parsedManifest && "error" in parsedManifest) return { status: "invalid-manifest", error: parsedManifest.error ?? "Deployment manifest is invalid" };
|
|
if (!manifest) return { status: "loading" };
|
|
if (mismatch) return { status: "chain-mismatch", manifest, error: message(snapshotQuery.error) };
|
|
if (snapshotQuery.error !== null) {
|
|
const error = message(snapshotQuery.error);
|
|
if (snapshotQuery.data) return { status: "stale", manifest, snapshot: snapshotQuery.data, failedAt: failedAt ?? now(), error };
|
|
return { status: "disconnected", manifest, failedAt: failedAt ?? now(), error };
|
|
}
|
|
if (snapshotQuery.data) return { status: "ready", manifest, snapshot: snapshotQuery.data };
|
|
return { status: "loading" };
|
|
}
|