fix: harden upgrade publication and refresh
This commit is contained in:
+49
-15
@@ -89,7 +89,7 @@ export async function finalizeDeployment({ root = process.cwd(), rpc }) {
|
|||||||
return { path, manifest: confirmed };
|
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");
|
if (typeof rpc !== "function") throw new Error("finalizer requires an RPC function");
|
||||||
const pendingPath = join(root, "deployments", "upgrade-pending.json");
|
const pendingPath = join(root, "deployments", "upgrade-pending.json");
|
||||||
const pending = await readJson(pendingPath);
|
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 canonicalPath = join(root, "deployments", spec.canonical);
|
||||||
const canonical = await readManifest(canonicalPath);
|
const canonical = await readManifest(canonicalPath);
|
||||||
const [activeBytes, canonicalBytes] = await Promise.all([readFile(activePath), readFile(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");
|
|
||||||
}
|
|
||||||
|
|
||||||
|
if (pending.mode === "noop") {
|
||||||
|
assertConfirmedManifestPair(active, canonical, activeBytes, canonicalBytes);
|
||||||
const chainId = parseRpcQuantity(await rpc("eth_chainId", []), "RPC chain ID");
|
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");
|
if (chainId !== active.chainId) throw new Error("RPC chain ID does not match active manifest");
|
||||||
const methods = await readUpgradeMethods(root);
|
const methods = await readUpgradeMethods(root);
|
||||||
|
|
||||||
if (pending.mode === "noop") {
|
|
||||||
validateNoopMarker(pending, active);
|
validateNoopMarker(pending, active);
|
||||||
const latestBlock = parseRpcQuantity(await rpc("eth_blockNumber", []), "latest block number");
|
const latestBlock = parseRpcQuantity(await rpc("eth_blockNumber", []), "latest block number");
|
||||||
if (latestBlock < pending.observedBlock) throw new Error("live block precedes no-op observation block");
|
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 };
|
return { mode: "noop", path: canonicalPath, manifest: active };
|
||||||
}
|
}
|
||||||
|
|
||||||
validateUpgradeMarker(pending, active);
|
const previous = recoverableUpgradeBaseline(pending, active, canonical, activeBytes, canonicalBytes);
|
||||||
const broadcastPath = join(root, "broadcast", "UpgradeV2.s.sol", String(active.chainId), "run-latest.json");
|
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);
|
const broadcast = await readJson(broadcastPath);
|
||||||
if (!Array.isArray(broadcast.transactions)) throw new Error("upgrade broadcast is partial: transactions are missing");
|
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)
|
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]),
|
transaction: await rpc("eth_getTransactionByHash", [hash]),
|
||||||
})));
|
})));
|
||||||
const matching = resolvedTransactions.filter(({ transaction }) =>
|
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}`);
|
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]);
|
const receipt = await rpc("eth_getTransactionReceipt", [matching[0].hash]);
|
||||||
if (!receipt || !isSuccessfulReceipt(receipt.status)) throw new Error("upgrade receipt was not successful");
|
if (!receipt || !isSuccessfulReceipt(receipt.status)) throw new Error("upgrade receipt was not successful");
|
||||||
const upgradeBlock = parseRpcQuantity(receipt.blockNumber, "upgrade receipt block number");
|
const upgradeBlock = parseRpcQuantity(receipt.blockNumber, "upgrade receipt block number");
|
||||||
if (upgradeBlock === 0) throw new Error("upgrade receipt block number must be nonzero");
|
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");
|
throw new Error("successful upgrade receipt is missing the expected Upgraded event");
|
||||||
}
|
}
|
||||||
|
|
||||||
await assertLiveVersionAndImplementation({ rpc, active, implementation: pending.implementation, methods });
|
await assertLiveVersionAndImplementation({ rpc, active: previous, implementation: pending.implementation, methods });
|
||||||
await assertUpgradeSnapshot({ rpc, active, pending, methods });
|
await assertUpgradeSnapshot({ rpc, active: previous, pending, methods });
|
||||||
const updated = { ...active, implementation: pending.implementation };
|
const updated = { ...previous, implementation: pending.implementation };
|
||||||
validateManifest(updated);
|
validateManifest(updated);
|
||||||
await atomicWriteJson(canonicalPath, updated);
|
const updatedBytes = Buffer.from(`${JSON.stringify(updated, null, 2)}\n`);
|
||||||
await atomicWriteJson(activePath, updated);
|
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);
|
await rm(pendingPath);
|
||||||
return { mode: "upgrade", path: canonicalPath, manifest: updated, upgradeBlock };
|
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) {
|
function validateNoopMarker(marker, active) {
|
||||||
assertExactKeys(marker, ["mode", "chainId", "observedBlock", "ownerNonce", "proxy", "implementation"], "no-op marker");
|
assertExactKeys(marker, ["mode", "chainId", "observedBlock", "ownerNonce", "proxy", "implementation"], "no-op marker");
|
||||||
for (const field of ["chainId", "observedBlock", "ownerNonce"]) {
|
for (const field of ["chainId", "observedBlock", "ownerNonce"]) {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
import { randomUUID } from "node:crypto";
|
||||||
import { dirname, resolve } from "node:path";
|
import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
|
||||||
|
import { dirname, join, resolve } from "node:path";
|
||||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||||
|
|
||||||
const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.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({
|
export async function publishManifest({
|
||||||
activePath = resolve(repositoryRoot, "deployments/active.json"),
|
activePath = resolve(repositoryRoot, "deployments/active.json"),
|
||||||
outputPath = resolve(repositoryRoot, "web/public/deployment.json"),
|
outputPath = resolve(repositoryRoot, "web/public/deployment.json"),
|
||||||
|
io,
|
||||||
} = {}) {
|
} = {}) {
|
||||||
let contents;
|
let contents;
|
||||||
try { contents = await readFile(activePath, "utf8"); } catch { throw new Error(`active manifest is missing at ${activePath}`); }
|
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"); }
|
try { manifest = JSON.parse(contents); } catch { throw new Error("active manifest contains invalid JSON"); }
|
||||||
validatePublicManifest(manifest);
|
validatePublicManifest(manifest);
|
||||||
await mkdir(dirname(outputPath), { recursive: true });
|
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;
|
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) {
|
export function validatePublicManifest(manifest) {
|
||||||
if (!isRecord(manifest)) throw new Error("manifest must be an object");
|
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}`);
|
for (const field of requiredFields) if (!Object.hasOwn(manifest, field)) throw new Error(`manifest is missing required field ${field}`);
|
||||||
|
|||||||
@@ -23,6 +23,49 @@ remove_local_manifest() {
|
|||||||
fi
|
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" vite
|
||||||
demo_stop_recorded "$ROOT" anvil
|
demo_stop_recorded "$ROOT" anvil
|
||||||
|
|
||||||
@@ -36,6 +79,7 @@ remove_exact "$ROOT/.demo/vite.pgid"
|
|||||||
remove_exact "$ROOT/.demo/vite.log"
|
remove_exact "$ROOT/.demo/vite.log"
|
||||||
|
|
||||||
remove_local_manifest "$ROOT/deployments/pending.json"
|
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/anvil.json"
|
||||||
remove_local_manifest "$ROOT/deployments/active.json"
|
remove_local_manifest "$ROOT/deployments/active.json"
|
||||||
remove_local_manifest "$ROOT/web/public/deployment.json"
|
remove_local_manifest "$ROOT/web/public/deployment.json"
|
||||||
|
|||||||
@@ -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 () => {
|
test("upgrade finalizer rejects proxy, deployment-block, and actor identity mutation without changing confirmed files", async () => {
|
||||||
for (const [name, mutate] of [
|
for (const [name, mutate] of [
|
||||||
["proxy", (pending) => { pending.proxy = TOKEN; }],
|
["proxy", (pending) => { pending.proxy = TOKEN; }],
|
||||||
|
|||||||
@@ -19,12 +19,12 @@ STARTED_PID=
|
|||||||
cleanup() {
|
cleanup() {
|
||||||
local identity root relative
|
local identity root relative
|
||||||
for identity in "${TEST_IDENTITIES[@]}"; do test_safe_stop "$identity"; done
|
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 \
|
for relative in \
|
||||||
.demo/anvil.pid .demo/anvil.start .demo/anvil.pgid .demo/anvil.log \
|
.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/vite.pid .demo/vite.start .demo/vite.pgid .demo/vite.log \
|
||||||
.demo/sentinel .demo/adjacent.keep tools/process-lib.sh tools/reset-local.sh \
|
.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
|
web/public/deployment.json web/src/generated/contracts.ts web/node_modules/.bin/vite; do
|
||||||
rm -f -- "$TEST_ROOT/$root/$relative"
|
rm -f -- "$TEST_ROOT/$root/$relative"
|
||||||
done
|
done
|
||||||
@@ -171,7 +171,7 @@ assert_anvil_args_rejected() {
|
|||||||
demo_stop_raw_launch "$root" anvil "$pid" "$start" "$pgid"
|
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.
|
# Catches cleanup treating a missing record as an error or signaling an inferred PID.
|
||||||
root=$(make_root absent)
|
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
|
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"
|
printf '{"network":"anvil","chainId":31337}\n' >"$root/$path"
|
||||||
done
|
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"
|
printf 'generated ABI\n' >"$root/web/src/generated/contracts.ts"
|
||||||
(cd "$root" && bash tools/reset-local.sh >/dev/null)
|
(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"
|
assert_absent "$root/$path"
|
||||||
done
|
done
|
||||||
assert_exists "$root/.demo/sentinel"
|
assert_exists "$root/.demo/sentinel"
|
||||||
pass 'reset removes only explicitly known local runtime and generated files'
|
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.
|
# Catches a local reset corrupting a canonical Base Sepolia deployment.
|
||||||
root=$(make_root base-canonical)
|
root=$(make_root base-canonical)
|
||||||
printf '{"network":"baseSepolia","chainId":84532,"marker":"canonical"}\n' >"$root/deployments/base-sepolia.json"
|
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'
|
[[ "$browser_before" == "$(sha256sum "$root/web/public/deployment.json")" ]] || fail 'Base browser manifest changed'
|
||||||
pass 'Base active and browser manifests survive reset byte-for-byte'
|
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.
|
# Catches cleanup broadening from exact files to recursive .demo deletion.
|
||||||
root=$(make_root sentinel)
|
root=$(make_root sentinel)
|
||||||
printf 'keep me\n' >"$root/.demo/adjacent.keep"
|
printf 'keep me\n' >"$root/.demo/adjacent.keep"
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import assert from "node:assert/strict";
|
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 { 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 { extractAbi, renderContractsModule, syncArtifacts } from "./sync-web-artifacts.mjs";
|
||||||
import { publishManifest } from "./publish-web-manifest.mjs";
|
import { publishManifest } from "./publish-web-manifest.mjs";
|
||||||
|
|
||||||
@@ -89,9 +89,44 @@ await withFixture(async (root) => {
|
|||||||
const outputPath = join(root, "deployment.json");
|
const outputPath = join(root, "deployment.json");
|
||||||
await writeFile(activePath, JSON.stringify(manifest));
|
await writeFile(activePath, JSON.stringify(manifest));
|
||||||
|
|
||||||
// Catches a publisher that copies pending, secret-bearing, or malformed live state into the browser bundle.
|
// Catches a publisher that writes the browser destination directly instead of replacing a complete same-directory file.
|
||||||
await publishManifest({ activePath, outputPath });
|
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.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 [
|
for (const invalid of [
|
||||||
{ ...manifest, deploymentBlock: 0 },
|
{ ...manifest, deploymentBlock: 0 },
|
||||||
{ ...manifest, rpcUrl: "https://token@example.test" },
|
{ ...manifest, rpcUrl: "https://token@example.test" },
|
||||||
|
|||||||
@@ -222,4 +222,39 @@ describe("useBankDashboard", () => {
|
|||||||
expect(result.current.status === "ready" && result.current.snapshot.blockNumber).toBe(13n);
|
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<DashboardSnapshot>();
|
||||||
|
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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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));
|
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 {
|
export function useBankDashboard(options: BankDashboardOptions = {}): DashboardState {
|
||||||
const loadManifest = options.loadManifest ?? fetchManifest;
|
const loadManifest = options.loadManifest ?? fetchManifest;
|
||||||
const loader = options.loader ?? fetchSnapshot;
|
const loader = options.loader ?? fetchSnapshot;
|
||||||
@@ -70,9 +79,10 @@ export function useBankDashboard(options: BankDashboardOptions = {}): DashboardS
|
|||||||
}
|
}
|
||||||
}, [manifestQuery.data]);
|
}, [manifestQuery.data]);
|
||||||
const manifest = parsedManifest && "manifest" in parsedManifest ? parsedManifest.manifest : undefined;
|
const manifest = parsedManifest && "manifest" in parsedManifest ? parsedManifest.manifest : undefined;
|
||||||
|
const snapshotIdentity = useMemo(() => manifestIdentity(manifest), [manifest]);
|
||||||
const snapshotQueryKey = useMemo(
|
const snapshotQueryKey = useMemo(
|
||||||
() => ["bank-dashboard", manifest?.chainId, manifest?.proxy] as const,
|
() => ["bank-dashboard", snapshotIdentity] as const,
|
||||||
[manifest?.chainId, manifest?.proxy],
|
[snapshotIdentity],
|
||||||
);
|
);
|
||||||
|
|
||||||
const snapshotQuery = useQuery({
|
const snapshotQuery = useQuery({
|
||||||
@@ -97,8 +107,19 @@ export function useBankDashboard(options: BankDashboardOptions = {}): DashboardS
|
|||||||
const reconcileBlock = useCallback((blockNumber: bigint) => {
|
const reconcileBlock = useCallback((blockNumber: bigint) => {
|
||||||
if (!manifest || mismatch || latestSnapshot.current?.blockNumber === blockNumber || lastWatchedBlock.current === blockNumber) return;
|
if (!manifest || mismatch || latestSnapshot.current?.blockNumber === blockNumber || lastWatchedBlock.current === blockNumber) return;
|
||||||
lastWatchedBlock.current = blockNumber;
|
lastWatchedBlock.current = blockNumber;
|
||||||
|
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 });
|
void queryClient.invalidateQueries({ queryKey: snapshotQueryKey, exact: true });
|
||||||
}, [manifest, mismatch, queryClient, snapshotQueryKey]);
|
}
|
||||||
|
});
|
||||||
|
}, [manifest, manifestQuery, mismatch, queryClient, snapshotIdentity, snapshotQueryKey]);
|
||||||
|
|
||||||
useWatchBlockNumber({
|
useWatchBlockNumber({
|
||||||
chainId: manifest?.chainId,
|
chainId: manifest?.chainId,
|
||||||
|
|||||||
Reference in New Issue
Block a user