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
+216 -1
View File
@@ -4,15 +4,18 @@ import { tmpdir } from "node:os";
import { dirname, join } from "node:path";
import test from "node:test";
import { atomicWrite, finalizeDeployment, preflightDeploy, runFinalizeCli, validateManifest } from "./finalize-manifest.mjs";
import { UPGRADED_TOPIC, atomicWrite, finalizeDeployment, finalizeUpgrade, preflightDeploy, runFinalizeCli, validateManifest } from "./finalize-manifest.mjs";
import { runSelectCli, selectManifest } from "./select-manifest.mjs";
const TOKEN = "0x1000000000000000000000000000000000000001";
const PROXY = "0x2000000000000000000000000000000000000002";
const IMPLEMENTATION = "0x3000000000000000000000000000000000000003";
const V2_IMPLEMENTATION = "0x4000000000000000000000000000000000000004";
const OWNER = "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266";
const IMPLEMENTATION_SLOT = "0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc";
const EDUCATIONAL_WARNING = "Educational demo — mock token — never use real funds.";
const DECLARED_CREATE_HASH = "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
const DECLARED_CALL_HASH = "0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb";
test("preflight rejects an existing target canonical manifest with the safe recovery command", async () => {
await withFixture(async (root) => {
@@ -136,6 +139,106 @@ test("finalizer failure leaves an initially absent canonical manifest absent", a
});
});
test("upgrade finalizer verifies receipt, event, slot, artifact-driven state and changes only implementation", async () => {
await withUpgradeFixture(async (root, active) => {
const before = structuredClone(active);
const output = await finalizeUpgrade({ root, rpc: fakeUpgradeRpc() });
assert.equal(output.mode, "upgrade");
assert.deepEqual(output.manifest, { ...before, implementation: V2_IMPLEMENTATION });
for (const name of ["anvil.json", "active.json"]) {
const confirmed = await readJson(join(root, "deployments", name));
assert.deepEqual(confirmed, { ...before, implementation: V2_IMPLEMENTATION });
assert.deepEqual({ ...confirmed, implementation: before.implementation }, before);
}
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; }],
["deployment block", (pending) => { pending.deploymentBlock += 1; }],
["actor", (pending) => { pending.snapshot.balances[1].address = OWNER; }],
]) {
await withUpgradeFixture(async (root) => {
const pendingPath = join(root, "deployments", "upgrade-pending.json");
const pending = await readJson(pendingPath); mutate(pending); await writeJson(pendingPath, pending);
const beforeCanonical = await readFile(join(root, "deployments", "anvil.json"));
const beforeActive = await readFile(join(root, "deployments", "active.json"));
await assert.rejects(() => finalizeUpgrade({ root, rpc: fakeUpgradeRpc() }), /proxy|deployment|actor|identity/i, name);
assert.deepEqual(await readFile(join(root, "deployments", "anvil.json")), beforeCanonical);
assert.deepEqual(await readFile(join(root, "deployments", "active.json")), beforeActive);
await access(pendingPath);
});
}
});
test("upgrade finalizer leaves confirmed files untouched for failed receipt, missing event, live mismatch, and unknown mode", async () => {
const cases = [
["failed receipt", fakeUpgradeRpc({ receiptStatus: "0x0" }), null, /successful/],
["missing Upgraded event", fakeUpgradeRpc({ omitUpgradeLog: true }), null, /Upgraded/],
["wrong live version", fakeUpgradeRpc({ version: 1n }), null, /version/],
["slot mismatch", fakeUpgradeRpc({ slot: IMPLEMENTATION }), null, /slot/],
["owner changed", fakeUpgradeRpc({ owner: TOKEN }), null, /owner/],
["unknown mode", fakeUpgradeRpc(), (pending) => { pending.mode = "mystery"; }, /mode/],
];
for (const [name, rpc, mutate, expected] of cases) {
await withUpgradeFixture(async (root) => {
const pendingPath = join(root, "deployments", "upgrade-pending.json");
if (mutate) { const pending = await readJson(pendingPath); mutate(pending); await writeJson(pendingPath, pending); }
const beforeCanonical = await readFile(join(root, "deployments", "anvil.json"));
const beforeActive = await readFile(join(root, "deployments", "active.json"));
await assert.rejects(() => finalizeUpgrade({ root, rpc }), expected, name);
assert.deepEqual(await readFile(join(root, "deployments", "anvil.json")), beforeCanonical);
assert.deepEqual(await readFile(join(root, "deployments", "active.json")), beforeActive);
await access(pendingPath);
});
}
});
test("noop finalizer ignores stale broadcast history and leaves confirmed manifests byte-for-byte unchanged", async () => {
await withUpgradeFixture(async (root, active) => {
active.implementation = V2_IMPLEMENTATION;
await writeJson(join(root, "deployments", "anvil.json"), active);
await writeJson(join(root, "deployments", "active.json"), active);
await writeJson(join(root, "deployments", "upgrade-pending.json"), {
mode: "noop", chainId: 31337, observedBlock: 80, ownerNonce: 5,
proxy: PROXY, implementation: V2_IMPLEMENTATION,
});
await writeJson(join(root, "broadcast", "UpgradeV2.s.sol", "31337", "run-latest.json"), {
transactions: [{ hash: "0xstale", transaction: { to: TOKEN } }], receipts: [{ status: "0x0" }],
});
const beforeCanonical = await readFile(join(root, "deployments", "anvil.json"));
const beforeActive = await readFile(join(root, "deployments", "active.json"));
const output = await finalizeUpgrade({ root, rpc: fakeUpgradeRpc() });
assert.equal(output.mode, "noop");
assert.deepEqual(await readFile(join(root, "deployments", "anvil.json")), beforeCanonical);
assert.deepEqual(await readFile(join(root, "deployments", "active.json")), beforeActive);
await assert.rejects(() => access(join(root, "deployments", "upgrade-pending.json")));
});
});
test("noop finalizer rejects a regressed block or changed owner nonce without touching confirmed state", async () => {
for (const [name, rpc] of [["block", fakeUpgradeRpc({ blockNumber: 79 })], ["nonce", fakeUpgradeRpc({ nonce: 6 })]]) {
await withUpgradeFixture(async (root, active) => {
active.implementation = V2_IMPLEMENTATION;
await writeJson(join(root, "deployments", "anvil.json"), active);
await writeJson(join(root, "deployments", "active.json"), active);
await writeJson(join(root, "deployments", "upgrade-pending.json"), {
mode: "noop", chainId: 31337, observedBlock: 80, ownerNonce: 5,
proxy: PROXY, implementation: V2_IMPLEMENTATION,
});
const before = await readFile(join(root, "deployments", "active.json"));
await assert.rejects(() => finalizeUpgrade({ root, rpc }), /block|nonce/i, name);
assert.deepEqual(await readFile(join(root, "deployments", "active.json")), before);
await access(join(root, "deployments", "upgrade-pending.json"));
});
}
});
test("selection atomically replaces active with only a valid named canonical manifest", async () => {
await withFixture(async (root) => {
const anvil = manifest({ deploymentBlock: 31 });
@@ -266,6 +369,118 @@ function fakeRpc(overrides = {}) {
};
}
const selectors = {
"owner()": "11111111",
"asset()": "22222222",
"paused()": "33333333",
"balanceOf(address)": "44444444",
"totalLiabilities()": "55555555",
"contractVersion()": "66666666",
};
async function withUpgradeFixture(fn) {
await withFixture(async (root) => {
const active = manifest();
await writeJson(join(root, "deployments", "anvil.json"), active);
await writeJson(join(root, "deployments", "active.json"), active);
await writeJson(join(root, "deployments", "upgrade-pending.json"), upgradePending(active));
await writeUpgradeArtifacts(root);
await writeUpgradeBroadcast(root);
await fn(root, active);
});
}
function upgradePending(active) {
return {
mode: "upgrade",
network: active.network,
chainId: active.chainId,
token: active.token,
proxy: active.proxy,
previousImplementation: active.implementation,
implementation: V2_IMPLEMENTATION,
owner: active.owner,
deploymentBlock: active.deploymentBlock,
snapshot: {
proxy: active.proxy,
implementation: active.implementation,
owner: active.owner,
asset: active.token,
paused: false,
balances: active.actors.map((actor, index) => ({ address: actor.address, balance: index === 1 ? 900_000_000 : index === 2 ? 500_000_000 : 0 })),
liabilities: 1_400_000_000,
reserves: 1_400_000_000,
surplus: 0,
deploymentBlock: active.deploymentBlock,
version: 1,
},
};
}
async function writeUpgradeArtifacts(root) {
const bankDirectory = join(root, "out", "BankV2.sol");
const tokenDirectory = join(root, "out", "MockUSDC.sol");
const { mkdir } = await import("node:fs/promises");
await mkdir(bankDirectory, { recursive: true });
await mkdir(tokenDirectory, { recursive: true });
await writeJson(join(bankDirectory, "BankV2.json"), { methodIdentifiers: selectors });
await writeJson(join(tokenDirectory, "MockUSDC.json"), { methodIdentifiers: { "balanceOf(address)": selectors["balanceOf(address)"] } });
}
async function writeUpgradeBroadcast(root) {
const directory = join(root, "broadcast", "UpgradeV2.s.sol", "31337");
const { mkdir } = await import("node:fs/promises");
await mkdir(directory, { recursive: true });
await writeJson(join(directory, "run-latest.json"), {
transactions: [
{ hash: DECLARED_CREATE_HASH, transactionType: "CREATE", transaction: { to: null } },
{ hash: DECLARED_CALL_HASH, transactionType: "CALL", transaction: { to: PROXY } },
],
});
}
function fakeUpgradeRpc(overrides = {}) {
const balanceByAddress = new Map([
[OWNER.toLowerCase(), 0n],
["0x70997970c51812dc3a010c7d01b50e0d17dc79c8", 900_000_000n],
["0x3c44cdddb6a900fa2b585dd299e03d12fa4293bc", 500_000_000n],
]);
return async (method, params) => {
if (method === "eth_chainId") return "0x7a69";
if (method === "eth_blockNumber") return hexQuantity(overrides.blockNumber ?? 100);
if (method === "eth_getTransactionCount") return hexQuantity(overrides.nonce ?? 5);
if (method === "eth_getCode") return "0x6000";
if (method === "eth_getStorageAt") return wordForRpc(overrides.slot ?? V2_IMPLEMENTATION);
if (method === "eth_getTransactionByHash") {
return { hash: params[0], to: params[0] === DECLARED_CREATE_HASH ? PROXY : null };
}
if (method === "eth_getTransactionReceipt") return {
status: overrides.receiptStatus ?? "0x1",
blockNumber: "0x5a",
logs: overrides.omitUpgradeLog || params[0] === DECLARED_CALL_HASH
? [] : [{ address: PROXY, topics: [UPGRADED_TOPIC, wordForRpc(V2_IMPLEMENTATION)], data: "0x" }],
};
if (method === "eth_call") {
const call = params[0];
const selector = call.data.slice(2, 10);
if (selector === selectors["owner()"]) return wordForRpc(overrides.owner ?? OWNER);
if (selector === selectors["asset()"]) return wordForRpc(TOKEN);
if (selector === selectors["paused()"]) return uintWord(0n);
if (selector === selectors["totalLiabilities()"]) return uintWord(1_400_000_000n);
if (selector === selectors["contractVersion()"]) return uintWord(overrides.version ?? 2n);
if (selector === selectors["balanceOf(address)"]) {
if (call.to.toLowerCase() === TOKEN.toLowerCase()) return uintWord(1_400_000_000n);
return uintWord(balanceByAddress.get(`0x${call.data.slice(-40)}`.toLowerCase()) ?? 0n);
}
}
throw new Error(`unexpected upgrade RPC method: ${method}`);
};
}
function wordForRpc(address) { return `0x${"0".repeat(24)}${address.slice(2).toLowerCase()}`; }
function uintWord(value) { return `0x${BigInt(value).toString(16).padStart(64, "0")}`; }
function hexQuantity(value) { return `0x${Number(value).toString(16)}`; }
async function writeJson(path, value) {
await writeFile(path, `${JSON.stringify(value, null, 2)}\n`);
}