feat: demonstrate state-preserving V2 upgrade

This commit is contained in:
golem
2026-08-21 15:31:40 -06:00
parent ca6a99b913
commit 94f2ad6e09
16 changed files with 1082 additions and 36 deletions
+201 -1
View File
@@ -4,6 +4,7 @@ import { dirname, join, resolve } from "node:path";
import { pathToFileURL } from "node:url";
export const IMPLEMENTATION_SLOT = "0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc";
export const UPGRADED_TOPIC = "0xbc7cd75a20ee27fd9adebab32041f755214dbc6bffa90cc0225b39da2e5c2d3b";
export const EDUCATIONAL_WARNING = "Educational demo — mock token — never use real funds.";
const NETWORKS = {
@@ -88,6 +89,201 @@ export async function finalizeDeployment({ root = process.cwd(), rpc }) {
return { path, manifest: confirmed };
}
export async function finalizeUpgrade({ root = process.cwd(), rpc }) {
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);
if (pending?.mode !== "upgrade" && pending?.mode !== "noop") throw new Error("unknown upgrade staging mode");
const activePath = join(root, "deployments", "active.json");
const active = await readManifest(activePath);
const spec = networkSpec(active.network);
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") {
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");
const nonce = parseRpcQuantity(
await rpc("eth_getTransactionCount", [active.owner, "latest"]), "owner nonce"
);
if (nonce !== pending.ownerNonce) throw new Error("owner nonce changed after no-op observation");
await assertLiveVersionAndImplementation({ rpc, active, implementation: pending.implementation, methods });
await rm(pendingPath);
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 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)
.filter((hash) => typeof hash === "string"))];
const resolvedTransactions = await Promise.all(hashes.map(async (hash) => ({
hash,
transaction: await rpc("eth_getTransactionByHash", [hash]),
})));
const matching = resolvedTransactions.filter(({ transaction }) =>
typeof transaction?.to === "string" && sameAddress(transaction.to, active.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");
if (!Array.isArray(receipt.logs) || !receipt.logs.some((log) => isUpgradeLog(log, active.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 };
validateManifest(updated);
await atomicWriteJson(canonicalPath, updated);
await atomicWriteJson(activePath, updated);
await rm(pendingPath);
return { mode: "upgrade", path: canonicalPath, manifest: updated };
}
function validateNoopMarker(marker, active) {
assertExactKeys(marker, ["mode", "chainId", "observedBlock", "ownerNonce", "proxy", "implementation"], "no-op marker");
for (const field of ["chainId", "observedBlock", "ownerNonce"]) {
if (!Number.isSafeInteger(marker[field]) || marker[field] < 0) throw new Error(`no-op marker ${field} is invalid`);
}
if (marker.chainId !== active.chainId || !sameAddress(marker.proxy, active.proxy)
|| !sameAddress(marker.implementation, active.implementation)) {
throw new Error("no-op marker chain/proxy/implementation identity does not match active manifest");
}
}
function validateUpgradeMarker(marker, active) {
assertExactKeys(marker, ["mode", "network", "chainId", "token", "proxy", "previousImplementation", "implementation", "owner", "deploymentBlock", "snapshot"], "upgrade marker");
for (const field of ["network", "chainId", "deploymentBlock"]) {
if (marker[field] !== active[field]) throw new Error(`upgrade marker ${field} identity does not match active manifest`);
}
for (const field of ["token", "proxy", "previousImplementation", "owner"]) {
const activeField = field === "previousImplementation" ? "implementation" : field;
if (!sameAddress(marker[field], active[activeField])) throw new Error(`upgrade marker ${field} identity does not match active manifest`);
}
assertAddress(marker.implementation, "upgrade implementation");
if (sameAddress(marker.implementation, active.implementation)) throw new Error("upgrade implementation did not change");
const snapshot = marker.snapshot;
assertExactKeys(snapshot, ["proxy", "implementation", "owner", "asset", "paused", "balances", "liabilities", "reserves", "surplus", "deploymentBlock", "version"], "upgrade snapshot");
if (!sameAddress(snapshot.proxy, active.proxy) || snapshot.deploymentBlock !== active.deploymentBlock) {
throw new Error("upgrade snapshot proxy or deployment identity changed");
}
if (!sameAddress(snapshot.implementation, active.implementation) || !sameAddress(snapshot.owner, active.owner)
|| !sameAddress(snapshot.asset, active.token) || snapshot.version !== 1 || typeof snapshot.paused !== "boolean") {
throw new Error("upgrade snapshot identity does not match active manifest");
}
if (!Array.isArray(snapshot.balances) || snapshot.balances.length !== active.actors.length) {
throw new Error("upgrade snapshot actor count does not match active manifest");
}
snapshot.balances.forEach((record, index) => {
assertExactKeys(record, ["address", "balance"], `upgrade snapshot actor ${index}`);
if (!sameAddress(record.address, active.actors[index].address)) throw new Error("upgrade snapshot actor identity changed");
assertNonnegativeInteger(record.balance, "upgrade snapshot actor balance");
});
for (const field of ["liabilities", "reserves", "surplus"]) assertNonnegativeInteger(snapshot[field], `upgrade snapshot ${field}`);
if (snapshot.reserves < snapshot.liabilities || snapshot.reserves - snapshot.liabilities !== snapshot.surplus) {
throw new Error("upgrade snapshot accounting is inconsistent");
}
}
async function readUpgradeMethods(root) {
const bank = await readJson(join(root, "out", "BankV2.sol", "BankV2.json"));
const token = await readJson(join(root, "out", "MockUSDC.sol", "MockUSDC.json"));
const requiredBank = ["owner()", "asset()", "paused()", "balanceOf(address)", "totalLiabilities()", "contractVersion()"];
const result = { bank: {}, token: {} };
for (const signature of requiredBank) result.bank[signature] = methodSelector(bank, signature, "BankV2");
result.token["balanceOf(address)"] = methodSelector(token, "balanceOf(address)", "MockUSDC");
return result;
}
function methodSelector(artifact, signature, contractName) {
const selector = artifact?.methodIdentifiers?.[signature];
if (typeof selector !== "string" || !/^[0-9a-fA-F]{8}$/.test(selector)) {
throw new Error(`${contractName} artifact is missing method identifier ${signature}`);
}
return `0x${selector.toLowerCase()}`;
}
async function assertLiveVersionAndImplementation({ rpc, active, implementation, methods }) {
const code = await rpc("eth_getCode", [implementation, "latest"]);
if (typeof code !== "string" || code.length <= 2) throw new Error("upgrade implementation has no live code");
const version = decodeUint(await rpcCall(rpc, active.proxy, methods.bank["contractVersion()"]), "contract version");
if (version !== 2) throw new Error("live contract version is not 2");
const slot = await rpc("eth_getStorageAt", [active.proxy, IMPLEMENTATION_SLOT, "latest"]);
if (slotAddress(slot) !== implementation.toLowerCase()) throw new Error("live proxy implementation slot does not match upgrade marker");
}
async function assertUpgradeSnapshot({ rpc, active, pending, methods }) {
const snapshot = pending.snapshot;
const owner = decodeAddress(await rpcCall(rpc, active.proxy, methods.bank["owner()"]), "owner");
const asset = decodeAddress(await rpcCall(rpc, active.proxy, methods.bank["asset()"]), "asset");
const paused = decodeBool(await rpcCall(rpc, active.proxy, methods.bank["paused()"]), "paused");
if (!sameAddress(owner, snapshot.owner)) throw new Error("live owner changed during upgrade");
if (!sameAddress(asset, snapshot.asset)) throw new Error("live asset changed during upgrade");
if (paused !== snapshot.paused) throw new Error("live pause state changed during upgrade");
for (const [index, actor] of snapshot.balances.entries()) {
const balance = decodeUint(
await rpcCall(rpc, active.proxy, `${methods.bank["balanceOf(address)"]}${encodeAddressWord(actor.address)}`),
`actor ${index} balance`
);
if (balance !== actor.balance) throw new Error(`live actor ${index} balance changed during upgrade`);
}
const liabilities = decodeUint(await rpcCall(rpc, active.proxy, methods.bank["totalLiabilities()"]), "liabilities");
const reserves = decodeUint(
await rpcCall(rpc, active.token, `${methods.token["balanceOf(address)"]}${encodeAddressWord(active.proxy)}`),
"reserves"
);
if (liabilities !== snapshot.liabilities) throw new Error("live liabilities changed during upgrade");
if (reserves !== snapshot.reserves) throw new Error("live reserves changed during upgrade");
if (reserves - liabilities !== snapshot.surplus) throw new Error("live surplus changed during upgrade");
}
function rpcCall(rpc, to, data) { return rpc("eth_call", [{ to, data }, "latest"]); }
function encodeAddressWord(address) { assertAddress(address, "call address"); return address.slice(2).toLowerCase().padStart(64, "0"); }
function decodeUint(value, label) {
if (typeof value !== "string" || !/^0x[0-9a-fA-F]{64}$/.test(value)) throw new Error(`${label} RPC result is invalid`);
const parsed = BigInt(value);
if (parsed > BigInt(Number.MAX_SAFE_INTEGER)) throw new Error(`${label} exceeds JavaScript safe integer range`);
return Number(parsed);
}
function decodeAddress(value, label) {
if (typeof value !== "string" || !/^0x0{24}[0-9a-fA-F]{40}$/.test(value)) throw new Error(`${label} RPC result is invalid`);
return `0x${value.slice(-40)}`;
}
function decodeBool(value, label) {
const decoded = decodeUint(value, label);
if (decoded !== 0 && decoded !== 1) throw new Error(`${label} RPC result is not boolean`);
return decoded === 1;
}
function isUpgradeLog(log, proxy, implementation) {
return typeof log?.address === "string" && sameAddress(log.address, proxy) && Array.isArray(log.topics)
&& log.topics.length >= 2 && log.topics[0]?.toLowerCase() === UPGRADED_TOPIC
&& slotAddress(log.topics[1]) === implementation.toLowerCase();
}
function sameAddress(first, second) {
return typeof first === "string" && typeof second === "string" && /^0x[0-9a-fA-F]{40}$/.test(first)
&& /^0x[0-9a-fA-F]{40}$/.test(second) && first.toLowerCase() === second.toLowerCase();
}
function assertExactKeys(value, expected, label) {
if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error(`${label} must be an object`);
const actual = Object.keys(value).sort();
const wanted = [...expected].sort();
if (actual.length !== wanted.length || actual.some((key, index) => key !== wanted[index])) throw new Error(`${label} schema is invalid`);
}
function assertNonnegativeInteger(value, label) {
if (!Number.isSafeInteger(value) || value < 0) throw new Error(`${label} must be a nonnegative integer`);
}
export async function readManifest(path, { pending = false } = {}) {
const manifest = await readJson(path);
validateManifest(manifest, { pending });
@@ -283,7 +479,11 @@ export async function runFinalizeCli(argv, { root = process.cwd(), log = console
if (network !== "--rpc-url" || typeof rest[0] !== "string" || rest.length !== 1) throw new Error("usage: finalize-manifest.mjs deploy --rpc-url <url>");
return finalizeDeployment({ root, rpc: fetchRpc(rest[0]) });
}
throw new Error("usage: finalize-manifest.mjs preflight-deploy <anvil|baseSepolia> | deploy --rpc-url <url>");
if (command === "upgrade") {
if (network !== "--rpc-url" || typeof rest[0] !== "string" || rest.length !== 1) throw new Error("usage: finalize-manifest.mjs upgrade --rpc-url <url>");
return finalizeUpgrade({ root, rpc: fetchRpc(rest[0]) });
}
throw new Error("usage: finalize-manifest.mjs preflight-deploy <anvil|baseSepolia> | deploy --rpc-url <url> | upgrade --rpc-url <url>");
}
if (process.argv[1] && pathToFileURL(resolve(process.argv[1])).href === import.meta.url) {