diff --git a/tools/finalize-manifest.mjs b/tools/finalize-manifest.mjs index b1ab29f..9410ce9 100644 --- a/tools/finalize-manifest.mjs +++ b/tools/finalize-manifest.mjs @@ -89,7 +89,7 @@ export async function finalizeDeployment({ root = process.cwd(), rpc }) { return { path, manifest: confirmed }; } -export async function finalizeUpgrade({ root = process.cwd(), rpc }) { +export async function finalizeUpgrade({ root = process.cwd(), rpc, writeManifest = atomicWriteJson }) { if (typeof rpc !== "function") throw new Error("finalizer requires an RPC function"); const pendingPath = join(root, "deployments", "upgrade-pending.json"); const pending = await readJson(pendingPath); @@ -101,15 +101,12 @@ export async function finalizeUpgrade({ root = process.cwd(), rpc }) { const canonicalPath = join(root, "deployments", spec.canonical); const canonical = await readManifest(canonicalPath); const [activeBytes, canonicalBytes] = await Promise.all([readFile(activePath), readFile(canonicalPath)]); - if (!activeBytes.equals(canonicalBytes) || JSON.stringify(active) !== JSON.stringify(canonical)) { - throw new Error("active and canonical manifest identity must match before upgrade finalization"); - } - - const chainId = parseRpcQuantity(await rpc("eth_chainId", []), "RPC chain ID"); - if (chainId !== active.chainId) throw new Error("RPC chain ID does not match active manifest"); - const methods = await readUpgradeMethods(root); if (pending.mode === "noop") { + assertConfirmedManifestPair(active, canonical, activeBytes, canonicalBytes); + const chainId = parseRpcQuantity(await rpc("eth_chainId", []), "RPC chain ID"); + if (chainId !== active.chainId) throw new Error("RPC chain ID does not match active manifest"); + const methods = await readUpgradeMethods(root); validateNoopMarker(pending, active); const latestBlock = parseRpcQuantity(await rpc("eth_blockNumber", []), "latest block number"); if (latestBlock < pending.observedBlock) throw new Error("live block precedes no-op observation block"); @@ -122,8 +119,11 @@ export async function finalizeUpgrade({ root = process.cwd(), rpc }) { return { mode: "noop", path: canonicalPath, manifest: active }; } - validateUpgradeMarker(pending, active); - const broadcastPath = join(root, "broadcast", "UpgradeV2.s.sol", String(active.chainId), "run-latest.json"); + const previous = recoverableUpgradeBaseline(pending, active, canonical, activeBytes, canonicalBytes); + const chainId = parseRpcQuantity(await rpc("eth_chainId", []), "RPC chain ID"); + if (chainId !== previous.chainId) throw new Error("RPC chain ID does not match active manifest"); + const methods = await readUpgradeMethods(root); + const broadcastPath = join(root, "broadcast", "UpgradeV2.s.sol", String(previous.chainId), "run-latest.json"); const broadcast = await readJson(broadcastPath); if (!Array.isArray(broadcast.transactions)) throw new Error("upgrade broadcast is partial: transactions are missing"); const hashes = [...new Set(broadcast.transactions.map((transaction) => transaction?.hash) @@ -133,26 +133,60 @@ export async function finalizeUpgrade({ root = process.cwd(), rpc }) { transaction: await rpc("eth_getTransactionByHash", [hash]), }))); const matching = resolvedTransactions.filter(({ transaction }) => - typeof transaction?.to === "string" && sameAddress(transaction.to, active.proxy)); + typeof transaction?.to === "string" && sameAddress(transaction.to, previous.proxy)); if (matching.length !== 1) throw new Error(`expected exactly one live upgrade transaction to proxy, found ${matching.length}`); const receipt = await rpc("eth_getTransactionReceipt", [matching[0].hash]); if (!receipt || !isSuccessfulReceipt(receipt.status)) throw new Error("upgrade receipt was not successful"); const upgradeBlock = parseRpcQuantity(receipt.blockNumber, "upgrade receipt block number"); if (upgradeBlock === 0) throw new Error("upgrade receipt block number must be nonzero"); - if (!Array.isArray(receipt.logs) || !receipt.logs.some((log) => isUpgradeLog(log, active.proxy, pending.implementation))) { + if (!Array.isArray(receipt.logs) || !receipt.logs.some((log) => isUpgradeLog(log, previous.proxy, pending.implementation))) { throw new Error("successful upgrade receipt is missing the expected Upgraded event"); } - await assertLiveVersionAndImplementation({ rpc, active, implementation: pending.implementation, methods }); - await assertUpgradeSnapshot({ rpc, active, pending, methods }); - const updated = { ...active, implementation: pending.implementation }; + await assertLiveVersionAndImplementation({ rpc, active: previous, implementation: pending.implementation, methods }); + await assertUpgradeSnapshot({ rpc, active: previous, pending, methods }); + const updated = { ...previous, implementation: pending.implementation }; validateManifest(updated); - await atomicWriteJson(canonicalPath, updated); - await atomicWriteJson(activePath, updated); + const updatedBytes = Buffer.from(`${JSON.stringify(updated, null, 2)}\n`); + if (!canonicalBytes.equals(updatedBytes)) await writeManifest(canonicalPath, updated); + if (!activeBytes.equals(updatedBytes)) await writeManifest(activePath, updated); + const [confirmedCanonical, confirmedActive] = await Promise.all([ + readFile(canonicalPath), + readFile(activePath), + ]); + if (!confirmedCanonical.equals(updatedBytes) || !confirmedActive.equals(updatedBytes)) { + throw new Error("active and canonical manifests did not converge after upgrade finalization"); + } await rm(pendingPath); return { mode: "upgrade", path: canonicalPath, manifest: updated, upgradeBlock }; } +function assertConfirmedManifestPair(active, canonical, activeBytes, canonicalBytes) { + if (!activeBytes.equals(canonicalBytes) || JSON.stringify(active) !== JSON.stringify(canonical)) { + throw new Error("active and canonical manifest identity must match before upgrade finalization"); + } +} + +function recoverableUpgradeBaseline(pending, active, canonical, activeBytes, canonicalBytes) { + assertAddress(pending?.previousImplementation, "upgrade previous implementation"); + assertAddress(pending?.implementation, "upgrade implementation"); + const allowedImplementation = (manifest) => sameAddress(manifest.implementation, pending.previousImplementation) + || sameAddress(manifest.implementation, pending.implementation); + if (!allowedImplementation(active) || !allowedImplementation(canonical)) { + throw new Error("active and canonical manifest divergence is not justified by the upgrade marker"); + } + if (sameAddress(active.implementation, canonical.implementation)) { + assertConfirmedManifestPair(active, canonical, activeBytes, canonicalBytes); + } + const previousActive = { ...active, implementation: pending.previousImplementation }; + const previousCanonical = { ...canonical, implementation: pending.previousImplementation }; + if (JSON.stringify(previousActive) !== JSON.stringify(previousCanonical)) { + throw new Error("active and canonical manifest divergence is not justified by the upgrade marker"); + } + validateUpgradeMarker(pending, previousActive); + return previousActive; +} + function validateNoopMarker(marker, active) { assertExactKeys(marker, ["mode", "chainId", "observedBlock", "ownerNonce", "proxy", "implementation"], "no-op marker"); for (const field of ["chainId", "observedBlock", "ownerNonce"]) { diff --git a/tools/publish-web-manifest.mjs b/tools/publish-web-manifest.mjs index 2256297..1858793 100644 --- a/tools/publish-web-manifest.mjs +++ b/tools/publish-web-manifest.mjs @@ -1,5 +1,6 @@ -import { mkdir, readFile, writeFile } from "node:fs/promises"; -import { dirname, resolve } from "node:path"; +import { randomUUID } from "node:crypto"; +import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises"; +import { dirname, join, resolve } from "node:path"; import { fileURLToPath, pathToFileURL } from "node:url"; const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), ".."); @@ -11,6 +12,7 @@ const secretMarker = /(?:private[_ -]?key|mnemonic|secret|password|credential|ap export async function publishManifest({ activePath = resolve(repositoryRoot, "deployments/active.json"), outputPath = resolve(repositoryRoot, "web/public/deployment.json"), + io, } = {}) { let contents; try { contents = await readFile(activePath, "utf8"); } catch { throw new Error(`active manifest is missing at ${activePath}`); } @@ -18,10 +20,22 @@ export async function publishManifest({ try { manifest = JSON.parse(contents); } catch { throw new Error("active manifest contains invalid JSON"); } validatePublicManifest(manifest); await mkdir(dirname(outputPath), { recursive: true }); - await writeFile(outputPath, `${JSON.stringify(manifest, null, 2)}\n`); + await atomicPublish(outputPath, `${JSON.stringify(manifest, null, 2)}\n`, io); return manifest; } +async function atomicPublish(path, contents, io = {}) { + const temporary = join(dirname(path), `.${randomUUID()}.json`); + const operations = { writeFile, rename, rm, ...io }; + try { + await operations.writeFile(temporary, contents, { mode: 0o600 }); + await operations.rename(temporary, path); + } catch (error) { + await operations.rm(temporary, { force: true }).catch(() => {}); + throw error; + } +} + export function validatePublicManifest(manifest) { if (!isRecord(manifest)) throw new Error("manifest must be an object"); for (const field of requiredFields) if (!Object.hasOwn(manifest, field)) throw new Error(`manifest is missing required field ${field}`); diff --git a/tools/reset-local.sh b/tools/reset-local.sh index e782dc0..44f8b4b 100755 --- a/tools/reset-local.sh +++ b/tools/reset-local.sh @@ -23,6 +23,49 @@ remove_local_manifest() { fi } +remove_local_upgrade_marker() { + local path=$1 + [[ -e "$path" ]] || return 0 + if node -e ' +const fs = require("fs"); +const marker = JSON.parse(fs.readFileSync(process.argv[1], "utf8")); +const record = (value) => value && typeof value === "object" && !Array.isArray(value); +const exact = (value, keys) => record(value) + && Object.keys(value).sort().join("\0") === [...keys].sort().join("\0"); +const uint = (value) => Number.isSafeInteger(value) && value >= 0; +const address = (value) => typeof value === "string" + && /^0x[0-9a-fA-F]{40}$/.test(value) && !/^0x0{40}$/i.test(value); +const same = (first, second) => address(first) && address(second) && first.toLowerCase() === second.toLowerCase(); +const noop = exact(marker, ["mode", "chainId", "observedBlock", "ownerNonce", "proxy", "implementation"]) + && marker.mode === "noop" && marker.chainId === 31337 + && uint(marker.observedBlock) && uint(marker.ownerNonce) + && address(marker.proxy) && address(marker.implementation); +const upgradeKeys = ["mode", "network", "chainId", "token", "proxy", "previousImplementation", "implementation", "owner", "deploymentBlock", "snapshot"]; +const snapshotKeys = ["proxy", "implementation", "owner", "asset", "paused", "balances", "liabilities", "reserves", "surplus", "deploymentBlock", "version"]; +const snapshot = marker?.snapshot; +const balances = Array.isArray(snapshot?.balances) && snapshot.balances.length > 0 + && snapshot.balances.every((balance) => exact(balance, ["address", "balance"]) + && address(balance.address) && uint(balance.balance)); +const upgrade = exact(marker, upgradeKeys) + && marker.mode === "upgrade" && marker.network === "anvil" && marker.chainId === 31337 + && uint(marker.deploymentBlock) && marker.deploymentBlock >= 1 + && [marker.token, marker.proxy, marker.previousImplementation, marker.implementation, marker.owner].every(address) + && !same(marker.previousImplementation, marker.implementation) + && exact(snapshot, snapshotKeys) && typeof snapshot.paused === "boolean" && snapshot.version === 1 + && same(snapshot.proxy, marker.proxy) && same(snapshot.implementation, marker.previousImplementation) + && same(snapshot.owner, marker.owner) && same(snapshot.asset, marker.token) + && snapshot.deploymentBlock === marker.deploymentBlock && balances + && uint(snapshot.liabilities) && uint(snapshot.reserves) && uint(snapshot.surplus) + && snapshot.reserves >= snapshot.liabilities + && snapshot.reserves - snapshot.liabilities === snapshot.surplus; +process.exit(noop || upgrade ? 0 : 1); +' "$path" 2>/dev/null; then + remove_exact "$path" + else + printf 'Preserved non-local or invalid upgrade marker %s\n' "${path#"$ROOT/"}" + fi +} + demo_stop_recorded "$ROOT" vite demo_stop_recorded "$ROOT" anvil @@ -36,6 +79,7 @@ remove_exact "$ROOT/.demo/vite.pgid" remove_exact "$ROOT/.demo/vite.log" remove_local_manifest "$ROOT/deployments/pending.json" +remove_local_upgrade_marker "$ROOT/deployments/upgrade-pending.json" remove_local_manifest "$ROOT/deployments/anvil.json" remove_local_manifest "$ROOT/deployments/active.json" remove_local_manifest "$ROOT/web/public/deployment.json" diff --git a/tools/test-finalize-manifest.mjs b/tools/test-finalize-manifest.mjs index bd2c97a..aafdfd7 100644 --- a/tools/test-finalize-manifest.mjs +++ b/tools/test-finalize-manifest.mjs @@ -159,6 +159,34 @@ test("upgrade finalizer verifies receipt, event, slot, artifact-driven state and }); }); +test("upgrade finalizer retains staging after the second manifest write fails and safely converges on retry", async () => { + await withUpgradeFixture(async (root, active) => { + let writes = 0; + const writeManifest = async (path, value) => { + writes += 1; + if (writes === 2) throw new Error("injected active replacement failure"); + await atomicWrite(path, `${JSON.stringify(value, null, 2)}\n`); + }; + + await assert.rejects( + () => finalizeUpgrade({ root, rpc: fakeUpgradeRpc(), writeManifest }), + /injected active replacement failure/, + ); + assert.equal((await readJson(join(root, "deployments", "anvil.json"))).implementation, V2_IMPLEMENTATION); + assert.equal((await readJson(join(root, "deployments", "active.json"))).implementation, active.implementation); + await access(join(root, "deployments", "upgrade-pending.json")); + + const recovered = await finalizeUpgrade({ root, rpc: fakeUpgradeRpc() }); + + assert.equal(recovered.manifest.implementation, V2_IMPLEMENTATION); + assert.deepEqual( + await readFile(join(root, "deployments", "active.json")), + await readFile(join(root, "deployments", "anvil.json")), + ); + await assert.rejects(() => access(join(root, "deployments", "upgrade-pending.json"))); + }); +}); + test("upgrade finalizer rejects proxy, deployment-block, and actor identity mutation without changing confirmed files", async () => { for (const [name, mutate] of [ ["proxy", (pending) => { pending.proxy = TOKEN; }], diff --git a/tools/test-process-safety.sh b/tools/test-process-safety.sh index 8cc9d9d..45a0c96 100755 --- a/tools/test-process-safety.sh +++ b/tools/test-process-safety.sh @@ -19,12 +19,12 @@ STARTED_PID= cleanup() { local identity root relative for identity in "${TEST_IDENTITIES[@]}"; do test_safe_stop "$identity"; done - for root in absent stale nonnumeric wrong-command false-anvil false-vite exact-anvil raw-capture reused atomic matching group term-refusal anchor-race mutated partial local-reset base-canonical base-active sentinel; do + for root in absent stale nonnumeric wrong-command false-anvil false-vite exact-anvil raw-capture reused atomic matching group term-refusal anchor-race mutated partial local-reset local-noop base-canonical base-active base-upgrade uncertain-upgrade sentinel; do for relative in \ .demo/anvil.pid .demo/anvil.start .demo/anvil.pgid .demo/anvil.log \ .demo/vite.pid .demo/vite.start .demo/vite.pgid .demo/vite.log \ .demo/sentinel .demo/adjacent.keep tools/process-lib.sh tools/reset-local.sh \ - deployments/pending.json deployments/anvil.json deployments/active.json deployments/base-sepolia.json \ + deployments/pending.json deployments/upgrade-pending.json deployments/anvil.json deployments/active.json deployments/base-sepolia.json \ web/public/deployment.json web/src/generated/contracts.ts web/node_modules/.bin/vite; do rm -f -- "$TEST_ROOT/$root/$relative" done @@ -171,7 +171,7 @@ assert_anvil_args_rejected() { demo_stop_raw_launch "$root" anvil "$pid" "$start" "$pgid" } -printf '1..26\n' +printf '1..29\n' # Catches cleanup treating a missing record as an error or signaling an inferred PID. root=$(make_root absent) @@ -573,14 +573,22 @@ printf 'sentinel\n' >"$root/.demo/sentinel" for path in deployments/pending.json deployments/anvil.json deployments/active.json web/public/deployment.json; do printf '{"network":"anvil","chainId":31337}\n' >"$root/$path" done +printf '%s\n' '{"mode":"upgrade","network":"anvil","chainId":31337,"token":"0x1111111111111111111111111111111111111111","proxy":"0x2222222222222222222222222222222222222222","previousImplementation":"0x3333333333333333333333333333333333333333","implementation":"0x4444444444444444444444444444444444444444","owner":"0x5555555555555555555555555555555555555555","deploymentBlock":1,"snapshot":{"proxy":"0x2222222222222222222222222222222222222222","implementation":"0x3333333333333333333333333333333333333333","owner":"0x5555555555555555555555555555555555555555","asset":"0x1111111111111111111111111111111111111111","paused":false,"balances":[{"address":"0x5555555555555555555555555555555555555555","balance":0}],"liabilities":0,"reserves":0,"surplus":0,"deploymentBlock":1,"version":1}}' >"$root/deployments/upgrade-pending.json" printf 'generated ABI\n' >"$root/web/src/generated/contracts.ts" (cd "$root" && bash tools/reset-local.sh >/dev/null) -for path in .demo/anvil.pid .demo/anvil.start .demo/anvil.pgid .demo/anvil.log .demo/vite.pid .demo/vite.start .demo/vite.pgid .demo/vite.log deployments/pending.json deployments/anvil.json deployments/active.json web/public/deployment.json web/src/generated/contracts.ts; do +for path in .demo/anvil.pid .demo/anvil.start .demo/anvil.pgid .demo/anvil.log .demo/vite.pid .demo/vite.start .demo/vite.pgid .demo/vite.log deployments/pending.json deployments/upgrade-pending.json deployments/anvil.json deployments/active.json web/public/deployment.json web/src/generated/contracts.ts; do assert_absent "$root/$path" done assert_exists "$root/.demo/sentinel" pass 'reset removes only explicitly known local runtime and generated files' +# Catches reset leaving a strictly validated local no-op upgrade marker behind. +root=$(make_root local-noop) +printf '%s\n' '{"mode":"noop","chainId":31337,"observedBlock":9,"ownerNonce":3,"proxy":"0x2222222222222222222222222222222222222222","implementation":"0x4444444444444444444444444444444444444444"}' >"$root/deployments/upgrade-pending.json" +(cd "$root" && bash tools/reset-local.sh >/dev/null) +assert_absent "$root/deployments/upgrade-pending.json" +pass 'reset removes a strictly validated local no-op upgrade marker' + # Catches a local reset corrupting a canonical Base Sepolia deployment. root=$(make_root base-canonical) printf '{"network":"baseSepolia","chainId":84532,"marker":"canonical"}\n' >"$root/deployments/base-sepolia.json" @@ -602,6 +610,22 @@ browser_before=$(sha256sum "$root/web/public/deployment.json") [[ "$browser_before" == "$(sha256sum "$root/web/public/deployment.json")" ]] || fail 'Base browser manifest changed' pass 'Base active and browser manifests survive reset byte-for-byte' +# Catches reset deleting a valid Base-shaped upgrade marker that may require public recovery. +root=$(make_root base-upgrade) +printf '%s\n' '{"mode":"upgrade","network":"baseSepolia","chainId":84532,"token":"0x1111111111111111111111111111111111111111","proxy":"0x2222222222222222222222222222222222222222","previousImplementation":"0x3333333333333333333333333333333333333333","implementation":"0x4444444444444444444444444444444444444444","owner":"0x5555555555555555555555555555555555555555","deploymentBlock":1,"snapshot":{"proxy":"0x2222222222222222222222222222222222222222","implementation":"0x3333333333333333333333333333333333333333","owner":"0x5555555555555555555555555555555555555555","asset":"0x1111111111111111111111111111111111111111","paused":false,"balances":[{"address":"0x5555555555555555555555555555555555555555","balance":0}],"liabilities":0,"reserves":0,"surplus":0,"deploymentBlock":1,"version":1}}' >"$root/deployments/upgrade-pending.json" +base_upgrade_before=$(sha256sum "$root/deployments/upgrade-pending.json") +(cd "$root" && bash tools/reset-local.sh >/dev/null) +[[ "$base_upgrade_before" == "$(sha256sum "$root/deployments/upgrade-pending.json")" ]] || fail 'Base upgrade marker changed' +pass 'a Base-shaped upgrade marker survives reset byte-for-byte' + +# Catches reset guessing that malformed or incomplete upgrade staging is safe to delete. +root=$(make_root uncertain-upgrade) +printf '%s\n' '{"mode":"upgrade","chainId":31337}' >"$root/deployments/upgrade-pending.json" +uncertain_before=$(sha256sum "$root/deployments/upgrade-pending.json") +(cd "$root" && bash tools/reset-local.sh >/dev/null) +[[ "$uncertain_before" == "$(sha256sum "$root/deployments/upgrade-pending.json")" ]] || fail 'uncertain upgrade marker changed' +pass 'a malformed or uncertain upgrade marker survives reset byte-for-byte' + # Catches cleanup broadening from exact files to recursive .demo deletion. root=$(make_root sentinel) printf 'keep me\n' >"$root/.demo/adjacent.keep" diff --git a/tools/test-sync-web-artifacts.mjs b/tools/test-sync-web-artifacts.mjs index d40471d..d3bc3a7 100644 --- a/tools/test-sync-web-artifacts.mjs +++ b/tools/test-sync-web-artifacts.mjs @@ -1,7 +1,7 @@ import assert from "node:assert/strict"; -import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { mkdtemp, readFile, readdir, rename, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; -import { join } from "node:path"; +import { basename, dirname, join } from "node:path"; import { extractAbi, renderContractsModule, syncArtifacts } from "./sync-web-artifacts.mjs"; import { publishManifest } from "./publish-web-manifest.mjs"; @@ -89,9 +89,44 @@ await withFixture(async (root) => { const outputPath = join(root, "deployment.json"); await writeFile(activePath, JSON.stringify(manifest)); - // Catches a publisher that copies pending, secret-bearing, or malformed live state into the browser bundle. - await publishManifest({ activePath, outputPath }); + // Catches a publisher that writes the browser destination directly instead of replacing a complete same-directory file. + const operations = []; + await publishManifest({ + activePath, + outputPath, + io: { + writeFile: async (...args) => { operations.push(["write", args[0]]); await writeFile(...args); }, + rename: async (...args) => { operations.push(["rename", ...args]); await rename(...args); }, + rm, + }, + }); assert.deepEqual(JSON.parse(await readFile(outputPath, "utf8")), manifest); + assert.equal(operations.length, 2); + assert.equal(operations[0][0], "write"); + assert.equal(dirname(operations[0][1]), dirname(outputPath)); + assert.notEqual(operations[0][1], outputPath); + assert.deepEqual(operations[1], ["rename", operations[0][1], outputPath]); + + // Catches a failed replacement truncating the published manifest or leaking its staging file. + const preserved = Buffer.from("preserved browser manifest\n"); + await writeFile(outputPath, preserved); + let failedStage; + await assert.rejects( + () => publishManifest({ + activePath, + outputPath, + io: { + writeFile: async (...args) => { failedStage = args[0]; await writeFile(...args); }, + rename: async () => { throw new Error("injected browser replacement failure"); }, + rm, + }, + }), + /injected browser replacement failure/, + ); + assert.deepEqual(await readFile(outputPath), preserved); + assert.equal((await readdir(root)).includes(basename(failedStage)), false); + + // Catches a publisher that copies pending, secret-bearing, or malformed live state into the browser bundle. for (const invalid of [ { ...manifest, deploymentBlock: 0 }, { ...manifest, rpcUrl: "https://token@example.test" }, diff --git a/web/src/hooks/useBankDashboard.test.tsx b/web/src/hooks/useBankDashboard.test.tsx index f72a586..6a62d03 100644 --- a/web/src/hooks/useBankDashboard.test.tsx +++ b/web/src/hooks/useBankDashboard.test.tsx @@ -222,4 +222,39 @@ describe("useBankDashboard", () => { expect(result.current.status === "ready" && result.current.snapshot.blockNumber).toBe(13n); }); }); + + it("refetches a changed manifest on a watched block and isolates its implementation snapshot without remounting", async () => { + const v2ManifestJson = { ...manifestJson, implementation: address("6") }; + const v2Snapshot = { + ...snapshot, + blockNumber: 13n, + version: 2 as const, + implementation: address("6"), + synchronizedAt: new Date("2026-08-21T10:01:00.000Z"), + }; + const pendingV2Snapshot = deferred(); + const loadManifest = vi.fn() + .mockResolvedValueOnce(manifestJson) + .mockResolvedValueOnce(v2ManifestJson); + const loader = vi.fn((current: DeploymentManifest) => current.implementation === manifest.implementation + ? Promise.resolve(snapshot) + : pendingV2Snapshot.promise); + const { result } = renderHook(() => useBankDashboard({ loadManifest, loader }), { + wrapper: createWrapper(), + }); + await waitFor(() => expect(result.current.status).toBe("ready")); + + act(() => notifyBlock?.(13n)); + + await waitFor(() => { + expect(loadManifest).toHaveBeenCalledTimes(2); + expect(loader).toHaveBeenCalledTimes(2); + }); + expect(result.current.status).toBe("loading"); + + pendingV2Snapshot.resolve(v2Snapshot); + await waitFor(() => expect(result.current.status).toBe("ready")); + expect(result.current.status === "ready" && result.current.manifest.implementation).toBe(address("6")); + expect(result.current.status === "ready" && result.current.snapshot).toBe(v2Snapshot); + }); }); diff --git a/web/src/hooks/useBankDashboard.ts b/web/src/hooks/useBankDashboard.ts index 11ee88c..74b2c2e 100644 --- a/web/src/hooks/useBankDashboard.ts +++ b/web/src/hooks/useBankDashboard.ts @@ -43,6 +43,15 @@ function isChainMismatch(error: unknown): boolean { return /endpoint chain ID \d+ does not match manifest chain ID \d+/i.test(message(error)); } +function manifestIdentity(manifest: DeploymentManifest | undefined): string | undefined { + if (!manifest) return undefined; + return JSON.stringify({ + ...manifest, + deploymentBlock: manifest.deploymentBlock.toString(), + actors: manifest.actors.map(({ label, address }) => ({ label, address })), + }); +} + export function useBankDashboard(options: BankDashboardOptions = {}): DashboardState { const loadManifest = options.loadManifest ?? fetchManifest; const loader = options.loader ?? fetchSnapshot; @@ -70,9 +79,10 @@ export function useBankDashboard(options: BankDashboardOptions = {}): DashboardS } }, [manifestQuery.data]); const manifest = parsedManifest && "manifest" in parsedManifest ? parsedManifest.manifest : undefined; + const snapshotIdentity = useMemo(() => manifestIdentity(manifest), [manifest]); const snapshotQueryKey = useMemo( - () => ["bank-dashboard", manifest?.chainId, manifest?.proxy] as const, - [manifest?.chainId, manifest?.proxy], + () => ["bank-dashboard", snapshotIdentity] as const, + [snapshotIdentity], ); const snapshotQuery = useQuery({ @@ -97,8 +107,19 @@ export function useBankDashboard(options: BankDashboardOptions = {}): DashboardS 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]); + void manifestQuery.refetch().then((result) => { + if (result.error !== null || result.data === undefined) return; + let refreshedManifest: DeploymentManifest; + try { + refreshedManifest = parseDeploymentManifest(result.data); + } catch { + return; + } + if (manifestIdentity(refreshedManifest) === snapshotIdentity) { + void queryClient.invalidateQueries({ queryKey: snapshotQueryKey, exact: true }); + } + }); + }, [manifest, manifestQuery, mismatch, queryClient, snapshotIdentity, snapshotQueryKey]); useWatchBlockNumber({ chainId: manifest?.chainId,