import assert from "node:assert/strict"; import { access, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { dirname, join } from "node:path"; import test from "node:test"; 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) => { await writeJson(join(root, "deployments", "anvil.json"), manifest()); await assert.rejects( () => preflightDeploy({ root, network: "anvil" }), /make reset-local/ ); await writeJson(join(root, "deployments", "base-sepolia.json"), manifest({ network: "baseSepolia", chainId: 84532 })); await assert.rejects( () => preflightDeploy({ root, network: "baseSepolia" }), /make archive-base-manifest/ ); }); }); test("direct manifest CLIs print the exact educational warning", async () => { await withFixture(async (root) => { const finalizeLogs = []; await runFinalizeCli(["preflight-deploy", "anvil"], { root, log: (line) => finalizeLogs.push(line) }); assert.deepEqual(finalizeLogs, [EDUCATIONAL_WARNING]); await writeJson(join(root, "deployments", "anvil.json"), manifest()); const selectLogs = []; await runSelectCli(["anvil"], { root, log: (line) => selectLogs.push(line) }); assert.deepEqual(selectLogs, [EDUCATIONAL_WARNING]); }); }); test("finalizer confirms a nested public actor manifest and preserves omitted Base URLs", async () => { await withFixture(async (root) => { const pending = manifest({ network: "baseSepolia", chainId: 84532, deploymentBlock: 0 }); await writeJson(join(root, "deployments", "pending.json"), pending); await writeBroadcast(root, pending); const output = await finalizeDeployment({ root, rpc: fakeRpc({ chainId: "0x14a34" }) }); assert.deepEqual(output.manifest, { ...pending, deploymentBlock: 42 }); assert.equal(Object.hasOwn(output.manifest, "rpcUrl"), false); assert.equal(Object.hasOwn(output.manifest, "explorerBaseUrl"), false); assert.deepEqual(output.manifest.actors, [{ label: "owner", address: OWNER }]); }); }); test("finalizer accepts an Anvil manifest with omitted optional rpcUrl and preserves its absence", async () => { await withFixture(async (root) => { const pending = manifest({ deploymentBlock: 0 }); delete pending.rpcUrl; await writeJson(join(root, "deployments", "pending.json"), pending); await writeBroadcast(root, pending); const output = await finalizeDeployment({ root, rpc: fakeRpc() }); assert.equal(Object.hasOwn(output.manifest, "rpcUrl"), false); assert.equal(Object.hasOwn(await readJson(output.path), "rpcUrl"), false); }); }); test("validator rejects the legacy parallel actor and explorer schema", () => { assert.throws( () => validateManifest(legacyManifest()), /unknown field|missing required field/ ); }); test("finalizer writes only a receipt-confirmed manifest and preserves active until selection", async () => { await withFixture(async (root) => { const pending = manifest({ deploymentBlock: 0 }); await writeJson(join(root, "deployments", "pending.json"), pending); await writeJson(join(root, "deployments", "active.json"), manifest({ network: "baseSepolia", chainId: 84532, deploymentBlock: 88 })); await writeBroadcast(root, pending, { hash: "0xaaa", contractAddress: PROXY }); const output = await finalizeDeployment({ root, rpc: fakeRpc() }); assert.equal(output.path, join(root, "deployments", "anvil.json")); assert.deepEqual(await readJson(output.path), { ...pending, deploymentBlock: 42 }); assert.deepEqual(await readJson(join(root, "deployments", "active.json")), manifest({ network: "baseSepolia", chainId: 84532, deploymentBlock: 88 })); }); }); test("finalizer rejects invalid receipt, transaction, RPC, code, slot, and secret data without touching confirmed files", async () => { const cases = [ ["failed receipt", { rpc: fakeRpc({ receipt: { status: "0x0", blockNumber: "0x2a" } }) }, /not successful/], ["missing receipt", { rpc: fakeRpc({ receipt: null }) }, /missing receipt/], ["ambiguous proxy transaction", { broadcast: { extraProxy: true } }, /exactly one/], ["partial broadcast", { broadcast: { omitProxy: true } }, /exactly one/], ["non-creation proxy transaction", { broadcast: { transactionType: "CALL" } }, /exactly one/], ["wrong chain", { rpc: fakeRpc({ chainId: "0x14a34" }) }, /chain ID/], ["missing code", { rpc: fakeRpc({ missingCode: TOKEN }) }, /has no code/], ["implementation slot mismatch", { rpc: fakeRpc({ slot: TOKEN }) }, /implementation slot/], ["secret-bearing pending manifest", { pending: manifest({ rpcUrl: "https://user:password@example.invalid" }) }, /credential|secret|endpoints/i], ["mnemonic in actor label", { pending: manifest({ deploymentBlock: 0, actors: localActors("test test test test test test test test test test test junk") }) }, /prohibited/i], ["private key in actor label", { pending: manifest({ deploymentBlock: 0, actors: localActors("0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80") }) }, /prohibited/i], ["credential in RPC path", { pending: manifest({ network: "baseSepolia", chainId: 84532, deploymentBlock: 0, rpcUrl: "https://sepolia.base.org/v1/secret-token" }) }, /public URL|prohibited/i], ["unknown manifest field", { pending: manifest({ deploymentBlock: 0, harmlessLookingField: "not allowed" }) }, /unknown field/i], ]; for (const [name, options, expected] of cases) { await withFixture(async (root) => { const pending = options.pending ?? manifest({ deploymentBlock: 0 }); await writeJson(join(root, "deployments", "pending.json"), pending); await writeJson(join(root, "deployments", "anvil.json"), manifest({ deploymentBlock: 7 })); await writeJson(join(root, "deployments", "active.json"), manifest({ deploymentBlock: 8 })); await writeBroadcast(root, pending, options.broadcast); const beforeCanonical = await readFile(join(root, "deployments", "anvil.json")); const beforeActive = await readFile(join(root, "deployments", "active.json")); await assert.rejects(() => finalizeDeployment({ root, rpc: options.rpc ?? fakeRpc() }), expected, name); assert.deepEqual(await readFile(join(root, "deployments", "anvil.json")), beforeCanonical, name); assert.deepEqual(await readFile(join(root, "deployments", "active.json")), beforeActive, name); }); } }); test("finalizer failure leaves an initially absent canonical manifest absent", async () => { await withFixture(async (root) => { const pending = manifest({ deploymentBlock: 0, actors: localActors("MNEMONIC") }); await writeJson(join(root, "deployments", "pending.json"), pending); await writeJson(join(root, "deployments", "active.json"), manifest({ deploymentBlock: 8 })); await writeBroadcast(root, pending); await assert.rejects(() => finalizeDeployment({ root, rpc: fakeRpc() }), /prohibited/i); await assert.rejects(() => access(join(root, "deployments", "anvil.json"))); }); }); 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 }); const base = manifest({ network: "baseSepolia", chainId: 84532, deploymentBlock: 32 }); await writeJson(join(root, "deployments", "anvil.json"), anvil); await writeJson(join(root, "deployments", "base-sepolia.json"), base); await selectManifest({ root, network: "anvil" }); const anvilBytes = await readFile(join(root, "deployments", "anvil.json")); assert.deepEqual(await readFile(join(root, "deployments", "active.json")), anvilBytes); await selectManifest({ root, network: "baseSepolia" }); assert.deepEqual(await readFile(join(root, "deployments", "anvil.json")), anvilBytes); assert.deepEqual(await readJson(join(root, "deployments", "active.json")), base); const baseBytes = await readFile(join(root, "deployments", "base-sepolia.json")); await selectManifest({ root, network: "anvil" }); assert.deepEqual(await readFile(join(root, "deployments", "base-sepolia.json")), baseBytes); await writeJson(join(root, "deployments", "base-sepolia.json"), manifest({ network: "baseSepolia", chainId: 84532, deploymentBlock: 0 })); const beforeActive = await readFile(join(root, "deployments", "active.json")); await assert.rejects(() => selectManifest({ root, network: "baseSepolia" }), /deploymentBlock/); assert.deepEqual(await readFile(join(root, "deployments", "active.json")), beforeActive); }); }); test("atomic writes stage a same-directory temporary file before renaming it into place", async () => { const target = "/tmp/deployments/active.json"; const calls = []; const io = { writeFile: async (path, contents) => calls.push(["write", path, contents]), rename: async (source, destination) => calls.push(["rename", source, destination]), }; await atomicWrite(target, "confirmed", io); assert.equal(calls[0][0], "write"); assert.equal(dirname(calls[0][1]), dirname(target)); assert.notEqual(calls[0][1], target); assert.match(calls[0][1], /\.json$/); assert.deepEqual(calls[1], ["rename", calls[0][1], target]); }); test("atomic writes remove the ignored staging file when rename fails", async () => { const target = "/tmp/deployments/active.json"; const calls = []; const io = { writeFile: async (path) => calls.push(["write", path]), rename: async () => { throw new Error("rename failed"); }, rm: async (path) => calls.push(["rm", path]), }; await assert.rejects(() => atomicWrite(target, "confirmed", io), /rename failed/); assert.deepEqual(calls[1], ["rm", calls[0][1]]); }); function manifest(overrides = {}) { const baseSepolia = overrides.network === "baseSepolia"; return { schemaVersion: 1, network: baseSepolia ? "baseSepolia" : "anvil", chainId: baseSepolia ? 84532 : 31337, deploymentBlock: 1, ...(baseSepolia ? {} : { rpcUrl: "http://127.0.0.1:8545" }), token: TOKEN, proxy: PROXY, implementation: IMPLEMENTATION, owner: OWNER, actors: baseSepolia ? [{ label: "owner", address: OWNER }] : localActors(), ...overrides, }; } function legacyManifest(overrides = {}) { return { schemaVersion: 1, network: "anvil", chainId: 31337, deploymentBlock: 1, rpcUrl: "http://127.0.0.1:8545", explorerUrl: "", token: TOKEN, proxy: PROXY, implementation: IMPLEMENTATION, owner: OWNER, actorLabels: ["owner", "Alice", "Bob"], actors: [OWNER, "0x70997970C51812dc3A010C7d01b50e0d17dc79C8", "0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC"], ...overrides, }; } function localActors(aliceLabel = "Alice") { return [ { label: "owner", address: OWNER }, { label: aliceLabel, address: "0x70997970C51812dc3A010C7d01b50e0d17dc79C8" }, { label: "Bob", address: "0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC" }, ]; } async function withFixture(fn) { const root = await mkdtemp(join(tmpdir(), "uups-finalizer-")); try { await import("node:fs/promises").then(({ mkdir }) => mkdir(join(root, "deployments"), { recursive: true })); await fn(root); } finally { await rm(root, { recursive: true, force: true }); } } async function writeBroadcast(root, pending, options = {}) { const directory = join(root, "broadcast", "DeployV1.s.sol", String(pending.chainId)); await import("node:fs/promises").then(({ mkdir }) => mkdir(directory, { recursive: true })); const transactions = options.omitProxy ? [{ hash: "0xbbb", transactionType: "CREATE", contractAddress: pending.token }] : [{ hash: "0xaaa", transactionType: options.transactionType ?? "CREATE", contractAddress: pending.proxy }]; if (options.extraProxy) transactions.push({ hash: "0xccc", transactionType: "CREATE", contractAddress: pending.proxy }); await writeJson(join(directory, "run-latest.json"), { transactions }); } function fakeRpc(overrides = {}) { return async (method, params) => { if (method === "eth_chainId") return overrides.chainId ?? "0x7a69"; if (method === "eth_getTransactionReceipt") return Object.hasOwn(overrides, "receipt") ? overrides.receipt : { status: "0x1", blockNumber: "0x2a" }; if (method === "eth_getCode") return params[0].toLowerCase() === overrides.missingCode?.toLowerCase() ? "0x" : "0x6000"; if (method === "eth_getStorageAt") { assert.equal(params[1], IMPLEMENTATION_SLOT); return `0x000000000000000000000000${(overrides.slot ?? IMPLEMENTATION).slice(2)}`; } throw new Error(`unexpected RPC method: ${method}`); }; } 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`); } async function readJson(path) { return JSON.parse(await readFile(path, "utf8")); }