diff --git a/README.md b/README.md index c796461..6c97164 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,7 @@ # UUPS Bank V1 Demo +> Educational demo — mock token — never use real funds. + This repository is the prepared starting point for a live upgradeability lesson. It deploys a local V1 bank that custodies a six-decimal mock ERC-20, records customer balances behind an ERC-1967 proxy, and presents the result in a read-only operations console. > **Trust boundary:** MockUSDC has no value. These contracts are educational and unaudited; real deposits must never be sent here. The owner can pause customer actions and install arbitrary future logic. UUPS mistakes can corrupt state or permanently brick upgradeability. A real custody product requires professional audits, operational key controls, multisig or timelocked governance, incident procedures, legal advice, and jurisdiction-specific compliance work. diff --git a/docs/PRESENTER_RUNBOOK.md b/docs/PRESENTER_RUNBOOK.md index a3761b0..9ac08e0 100644 --- a/docs/PRESENTER_RUNBOOK.md +++ b/docs/PRESENTER_RUNBOOK.md @@ -1,5 +1,7 @@ # Presenter Runbook: Prepared V1 and Live UUPS Upgrade +> Educational demo — mock token — never use real funds. + ## Preflight and rehearsal Rehearse once from a clean disposable branch created at the `demo-start` tag. Allow 25 minutes: 3 minutes for preflight, 5 for Act 1, 10 for the Codex change and verification, 4 for Act 3, and 3 for questions. Keep two terminals visible and open the browser only after Vite reports ready. diff --git a/tools/finalize-manifest.mjs b/tools/finalize-manifest.mjs index 18879f0..b1ab29f 100644 --- a/tools/finalize-manifest.mjs +++ b/tools/finalize-manifest.mjs @@ -363,8 +363,15 @@ function assertManifestUrls(manifest, spec) { if (Object.hasOwn(manifest, "explorerBaseUrl")) throw new Error("manifest anvil must omit explorerBaseUrl"); return; } - for (const field of OPTIONAL_MANIFEST_FIELDS) { - if (Object.hasOwn(manifest, field)) assertPublicUrl(manifest[field], field); + for (const field of ["rpcUrl", "explorerBaseUrl"]) { + if (!Object.hasOwn(manifest, field)) throw new Error(`manifest baseSepolia is missing ${field}`); + assertPublicUrl(manifest[field], field); + } + if (new URL(manifest.rpcUrl).protocol !== "https:") { + throw new Error("manifest Base Sepolia rpcUrl must use HTTPS"); + } + if (manifest.explorerBaseUrl !== "https://sepolia.basescan.org") { + throw new Error("manifest Base Sepolia explorerBaseUrl must use the BaseScan root"); } } @@ -398,14 +405,13 @@ function assertActorConfiguration(manifest) { throw new Error("manifest anvil actors must match the documented local actor configuration"); } } else { - const configured = actors.length === 2 && actors[0].label === "Presenter" && actors[1].label === "Recipient"; - const legacy = actors.length === 1 && actors[0].label === "owner" - && !Object.hasOwn(manifest, "rpcUrl") && !Object.hasOwn(manifest, "explorerBaseUrl"); - if (!configured && !legacy) { - throw new Error("manifest baseSepolia actors must be Presenter and Recipient"); + if (actors.length !== 2 || actors[0].label !== "Presenter" || actors[1].label !== "Recipient") { + throw new Error("manifest baseSepolia actors must contain exactly Presenter and Recipient"); } } - if (actors[0].address.toLowerCase() !== owner.toLowerCase()) throw new Error("manifest owner must be actor zero"); + if (actors[0].address.toLowerCase() !== owner.toLowerCase()) { + throw new Error(`manifest owner must be the ${network === "baseSepolia" ? "Presenter" : "first actor"}`); + } } function rejectProhibitedStringValues(value, path = "") { diff --git a/tools/publish-web-manifest.mjs b/tools/publish-web-manifest.mjs index d323761..2256297 100644 --- a/tools/publish-web-manifest.mjs +++ b/tools/publish-web-manifest.mjs @@ -35,11 +35,20 @@ export function validatePublicManifest(manifest) { assertActors(manifest.actors); for (const field of optionalFields) if (Object.hasOwn(manifest, field)) assertPublicUrl(manifest[field], field); if (manifest.network === "baseSepolia") { - if (Object.hasOwn(manifest, "rpcUrl") && new URL(manifest.rpcUrl).protocol !== "https:") { + for (const field of ["rpcUrl", "explorerBaseUrl"]) { + if (!Object.hasOwn(manifest, field)) throw new Error(`manifest baseSepolia is missing ${field}`); + } + if (new URL(manifest.rpcUrl).protocol !== "https:") { throw new Error("manifest Base Sepolia rpcUrl must use HTTPS"); } - if (Object.hasOwn(manifest, "explorerBaseUrl") && manifest.explorerBaseUrl !== "https://sepolia.basescan.org") { - throw new Error("manifest Base Sepolia explorerBaseUrl must use BaseScan"); + if (manifest.explorerBaseUrl !== "https://sepolia.basescan.org") { + throw new Error("manifest Base Sepolia explorerBaseUrl must use the BaseScan root"); + } + if (manifest.actors.length !== 2 || manifest.actors[0].label !== "Presenter" || manifest.actors[1].label !== "Recipient") { + throw new Error("manifest baseSepolia actors must contain exactly Presenter and Recipient"); + } + if (manifest.owner.toLowerCase() !== manifest.actors[0].address.toLowerCase()) { + throw new Error("manifest owner must be the Presenter"); } } } diff --git a/tools/select-manifest.mjs b/tools/select-manifest.mjs index bfe7f94..62036ad 100644 --- a/tools/select-manifest.mjs +++ b/tools/select-manifest.mjs @@ -1,4 +1,4 @@ -import { access, readFile, rename } from "node:fs/promises"; +import { link, readFile, rm } from "node:fs/promises"; import { join, resolve } from "node:path"; import { pathToFileURL } from "node:url"; @@ -14,7 +14,7 @@ export async function selectManifest({ root = process.cwd(), network }) { return { source, active }; } -export async function archiveManifest({ root = process.cwd(), network, now = new Date() }) { +export async function archiveManifest({ root = process.cwd(), network, now = new Date(), io = {} }) { if (network !== "baseSepolia") throw new Error("only baseSepolia may be archived"); const spec = networkSpec(network); const source = join(root, "deployments", spec.canonical); @@ -23,16 +23,20 @@ export async function archiveManifest({ root = process.cwd(), network, now = new const timestamp = now.toISOString().replace(/[-:.]/g, ""); if (!/^\d{8}T\d{9}Z$/.test(timestamp)) throw new Error("archive timestamp is invalid"); const target = join(root, "deployments", `base-sepolia.${timestamp}.json`); + const operations = { link, rm, ...io }; try { - await access(target); + await operations.link(source, target); } catch (error) { - if (error.code === "ENOENT") { - await rename(source, target); - return { source, path: target }; - } + if (error.code === "EEXIST") throw new Error("refusing to overwrite an existing Base Sepolia archive"); throw error; } - throw new Error("refusing to overwrite an existing Base Sepolia archive"); + try { + await operations.rm(source); + } catch (error) { + await operations.rm(target, { force: true }).catch(() => {}); + throw error; + } + return { source, path: target }; } export async function runSelectCli(argv, { root = process.cwd(), log = console.log } = {}) { diff --git a/tools/test-base-config.sh b/tools/test-base-config.sh index 1a11a31..f8a940a 100755 --- a/tools/test-base-config.sh +++ b/tools/test-base-config.sh @@ -106,7 +106,8 @@ import { pathToFileURL } from "node:url"; const repositoryRoot = process.argv[2]; const { archiveManifest, selectManifest } = await import(pathToFileURL(join(repositoryRoot, "tools/select-manifest.mjs"))); -const { publishManifest } = await import(pathToFileURL(join(repositoryRoot, "tools/publish-web-manifest.mjs"))); +const { validateManifest } = await import(pathToFileURL(join(repositoryRoot, "tools/finalize-manifest.mjs"))); +const { publishManifest, validatePublicManifest } = await import(pathToFileURL(join(repositoryRoot, "tools/publish-web-manifest.mjs"))); const root = await mkdtemp(join(tmpdir(), "uups-base-config-")); const deployments = join(root, "deployments"); const webManifest = join(root, "web/public/deployment.json"); @@ -157,14 +158,29 @@ try { assert.equal(published.rpcUrl, "https://public.invalid/rpc"); assert.equal(published.explorerBaseUrl, "https://sepolia.basescan.org"); assert.equal((await readFile(webManifest, "utf8")).includes("terminal.invalid"), false); - for (const invalid of [ - { ...base, rpcUrl: "http://public.invalid/rpc" }, - { ...base, explorerBaseUrl: "https://example.invalid" }, - ]) { + const without = (field) => { + const manifest = { ...base }; + delete manifest[field]; + return manifest; + }; + const invalidCases = [ + ["missing rpcUrl", without("rpcUrl"), /rpcUrl/], + ["missing explorerBaseUrl", without("explorerBaseUrl"), /explorerBaseUrl/], + ["wrong actor count", { ...base, actors: [base.actors[0]] }, /exactly Presenter and Recipient|actor configuration/], + ["wrong actor labels", { ...base, actors: [{ ...base.actors[0], label: "Owner" }, base.actors[1]] }, /Presenter and Recipient|actor configuration/], + ["owner is not Presenter", { ...base, owner: recipient }, /owner.*Presenter|first actor/], + ["HTTP public RPC", { ...base, rpcUrl: "http://public.invalid/rpc" }, /HTTPS/], + ["credential-bearing public RPC", { ...base, rpcUrl: "https://user@public.invalid/rpc" }, /credentials|prohibited secret/], + ["wrong BaseScan root", { ...base, explorerBaseUrl: "https://example.invalid" }, /BaseScan/], + ]; + for (const [label, invalid, rejection] of invalidCases) { + assert.throws(() => validateManifest(invalid), rejection, `confirmation accepted ${label}`); + assert.throws(() => validatePublicManifest(invalid), rejection, `publication validation accepted ${label}`); await writeFile(join(deployments, "active.json"), `${JSON.stringify(invalid)}\n`); await assert.rejects( () => publishManifest({ activePath: join(deployments, "active.json"), outputPath: webManifest }), - /HTTPS|BaseScan/, + rejection, + `publication accepted ${label}`, ); } await writeFile(join(deployments, "active.json"), activeBefore); @@ -179,6 +195,42 @@ try { await assert.rejects(() => access(join(deployments, "base-sepolia.json"))); assert.deepEqual(await readFile(join(deployments, "active.json")), activeBefore); assert.deepEqual(await readFile(join(deployments, "anvil.json")), anvilBefore); + + const canonicalPath = join(deployments, "base-sepolia.json"); + const canonicalBytes = Buffer.from(`${JSON.stringify(base)}\n`); + const existingBytes = Buffer.from("existing archive bytes\n"); + const collisionDate = new Date("2026-08-21T12:35:56.789Z"); + const collisionPath = join(deployments, "base-sepolia.20260821T123556789Z.json"); + await writeFile(canonicalPath, canonicalBytes); + await writeFile(collisionPath, existingBytes); + await assert.rejects( + () => archiveManifest({ root, network: "baseSepolia", now: collisionDate }), + /existing Base Sepolia archive/, + ); + assert.deepEqual(await readFile(canonicalPath), canonicalBytes); + assert.deepEqual(await readFile(collisionPath), existingBytes); + + const boundaryDate = new Date("2026-08-21T12:36:56.789Z"); + const boundaryPath = join(deployments, "base-sepolia.20260821T123656789Z.json"); + const boundaryBytes = Buffer.from("archive created by racing process\n"); + await assert.rejects( + () => archiveManifest({ + root, + network: "baseSepolia", + now: boundaryDate, + io: { + link: async (_source, target) => { + await writeFile(target, boundaryBytes); + const error = new Error("collision at link boundary"); + error.code = "EEXIST"; + throw error; + }, + }, + }), + /existing Base Sepolia archive/, + ); + assert.deepEqual(await readFile(canonicalPath), canonicalBytes); + assert.deepEqual(await readFile(boundaryPath), boundaryBytes); await assert.rejects(() => archiveManifest({ root, network: "anvil" }), /baseSepolia/); } finally { await rm(root, { recursive: true, force: true }); diff --git a/tools/test-finalize-manifest.mjs b/tools/test-finalize-manifest.mjs index 762a028..bd2c97a 100644 --- a/tools/test-finalize-manifest.mjs +++ b/tools/test-finalize-manifest.mjs @@ -45,7 +45,7 @@ test("direct manifest CLIs print the exact educational warning", async () => { }); }); -test("finalizer confirms a nested public actor manifest and preserves omitted Base URLs", async () => { +test("finalizer confirms the exact Base public URLs and Presenter/Recipient actors", async () => { await withFixture(async (root) => { const pending = manifest({ network: "baseSepolia", chainId: 84532, deploymentBlock: 0 }); await writeJson(join(root, "deployments", "pending.json"), pending); @@ -53,9 +53,12 @@ test("finalizer confirms a nested public actor manifest and preserves omitted Ba 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 }]); + assert.equal(output.manifest.rpcUrl, "https://sepolia.base.org"); + assert.equal(output.manifest.explorerBaseUrl, "https://sepolia.basescan.org"); + assert.deepEqual(output.manifest.actors, [ + { label: "Presenter", address: OWNER }, + { label: "Recipient", address: "0x70997970C51812dc3A010C7d01b50e0d17dc79C8" }, + ]); }); }); @@ -145,6 +148,7 @@ test("upgrade finalizer verifies receipt, event, slot, artifact-driven state and const output = await finalizeUpgrade({ root, rpc: fakeUpgradeRpc() }); assert.equal(output.mode, "upgrade"); + assert.equal(output.upgradeBlock, 90); assert.deepEqual(output.manifest, { ...before, implementation: V2_IMPLEMENTATION }); for (const name of ["anvil.json", "active.json"]) { const confirmed = await readJson(join(root, "deployments", name)); @@ -177,6 +181,7 @@ test("upgrade finalizer rejects proxy, deployment-block, and actor identity muta 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/], + ["zero receipt block", fakeUpgradeRpc({ receiptBlock: "0x0" }), null, /nonzero/], ["missing Upgraded event", fakeUpgradeRpc({ omitUpgradeLog: true }), null, /Upgraded/], ["wrong live version", fakeUpgradeRpc({ version: 1n }), null, /version/], ["slot mismatch", fakeUpgradeRpc({ slot: IMPLEMENTATION }), null, /slot/], @@ -300,12 +305,19 @@ function manifest(overrides = {}) { network: baseSepolia ? "baseSepolia" : "anvil", chainId: baseSepolia ? 84532 : 31337, deploymentBlock: 1, - ...(baseSepolia ? {} : { rpcUrl: "http://127.0.0.1:8545" }), + ...(baseSepolia + ? { rpcUrl: "https://sepolia.base.org", explorerBaseUrl: "https://sepolia.basescan.org" } + : { rpcUrl: "http://127.0.0.1:8545" }), token: TOKEN, proxy: PROXY, implementation: IMPLEMENTATION, owner: OWNER, - actors: baseSepolia ? [{ label: "owner", address: OWNER }] : localActors(), + actors: baseSepolia + ? [ + { label: "Presenter", address: OWNER }, + { label: "Recipient", address: "0x70997970C51812dc3A010C7d01b50e0d17dc79C8" }, + ] + : localActors(), ...overrides, }; } @@ -456,7 +468,7 @@ function fakeUpgradeRpc(overrides = {}) { } if (method === "eth_getTransactionReceipt") return { status: overrides.receiptStatus ?? "0x1", - blockNumber: "0x5a", + blockNumber: overrides.receiptBlock ?? "0x5a", logs: overrides.omitUpgradeLog || params[0] === DECLARED_CALL_HASH ? [] : [{ address: PROXY, topics: [UPGRADED_TOPIC, wordForRpc(V2_IMPLEMENTATION)], data: "0x" }], };