fix: harden Base manifest recovery

This commit is contained in:
golem
2026-08-21 16:16:12 -06:00
parent 90b0a11b8a
commit 6dee960d15
7 changed files with 119 additions and 32 deletions
+2
View File
@@ -1,5 +1,7 @@
# UUPS Bank V1 Demo # 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. 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. > **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.
+2
View File
@@ -1,5 +1,7 @@
# Presenter Runbook: Prepared V1 and Live UUPS Upgrade # Presenter Runbook: Prepared V1 and Live UUPS Upgrade
> Educational demo — mock token — never use real funds.
## Preflight and rehearsal ## 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. 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.
+14 -8
View File
@@ -363,8 +363,15 @@ function assertManifestUrls(manifest, spec) {
if (Object.hasOwn(manifest, "explorerBaseUrl")) throw new Error("manifest anvil must omit explorerBaseUrl"); if (Object.hasOwn(manifest, "explorerBaseUrl")) throw new Error("manifest anvil must omit explorerBaseUrl");
return; return;
} }
for (const field of OPTIONAL_MANIFEST_FIELDS) { for (const field of ["rpcUrl", "explorerBaseUrl"]) {
if (Object.hasOwn(manifest, field)) assertPublicUrl(manifest[field], field); 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"); throw new Error("manifest anvil actors must match the documented local actor configuration");
} }
} else { } else {
const configured = actors.length === 2 && actors[0].label === "Presenter" && actors[1].label === "Recipient"; if (actors.length !== 2 || actors[0].label !== "Presenter" || actors[1].label !== "Recipient") {
const legacy = actors.length === 1 && actors[0].label === "owner" throw new Error("manifest baseSepolia actors must contain exactly Presenter and Recipient");
&& !Object.hasOwn(manifest, "rpcUrl") && !Object.hasOwn(manifest, "explorerBaseUrl");
if (!configured && !legacy) {
throw new Error("manifest baseSepolia actors must be 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 = "") { function rejectProhibitedStringValues(value, path = "") {
+12 -3
View File
@@ -35,11 +35,20 @@ export function validatePublicManifest(manifest) {
assertActors(manifest.actors); assertActors(manifest.actors);
for (const field of optionalFields) if (Object.hasOwn(manifest, field)) assertPublicUrl(manifest[field], field); for (const field of optionalFields) if (Object.hasOwn(manifest, field)) assertPublicUrl(manifest[field], field);
if (manifest.network === "baseSepolia") { 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"); throw new Error("manifest Base Sepolia rpcUrl must use HTTPS");
} }
if (Object.hasOwn(manifest, "explorerBaseUrl") && manifest.explorerBaseUrl !== "https://sepolia.basescan.org") { if (manifest.explorerBaseUrl !== "https://sepolia.basescan.org") {
throw new Error("manifest Base Sepolia explorerBaseUrl must use BaseScan"); 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");
} }
} }
} }
+12 -8
View File
@@ -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 { join, resolve } from "node:path";
import { pathToFileURL } from "node:url"; import { pathToFileURL } from "node:url";
@@ -14,7 +14,7 @@ export async function selectManifest({ root = process.cwd(), network }) {
return { source, active }; 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"); if (network !== "baseSepolia") throw new Error("only baseSepolia may be archived");
const spec = networkSpec(network); const spec = networkSpec(network);
const source = join(root, "deployments", spec.canonical); 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, ""); const timestamp = now.toISOString().replace(/[-:.]/g, "");
if (!/^\d{8}T\d{9}Z$/.test(timestamp)) throw new Error("archive timestamp is invalid"); 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 target = join(root, "deployments", `base-sepolia.${timestamp}.json`);
const operations = { link, rm, ...io };
try { try {
await access(target); await operations.link(source, target);
} catch (error) { } catch (error) {
if (error.code === "ENOENT") { if (error.code === "EEXIST") throw new Error("refusing to overwrite an existing Base Sepolia archive");
await rename(source, target);
return { source, path: target };
}
throw error; 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 } = {}) { export async function runSelectCli(argv, { root = process.cwd(), log = console.log } = {}) {
+58 -6
View File
@@ -106,7 +106,8 @@ import { pathToFileURL } from "node:url";
const repositoryRoot = process.argv[2]; const repositoryRoot = process.argv[2];
const { archiveManifest, selectManifest } = await import(pathToFileURL(join(repositoryRoot, "tools/select-manifest.mjs"))); 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 root = await mkdtemp(join(tmpdir(), "uups-base-config-"));
const deployments = join(root, "deployments"); const deployments = join(root, "deployments");
const webManifest = join(root, "web/public/deployment.json"); const webManifest = join(root, "web/public/deployment.json");
@@ -157,14 +158,29 @@ try {
assert.equal(published.rpcUrl, "https://public.invalid/rpc"); assert.equal(published.rpcUrl, "https://public.invalid/rpc");
assert.equal(published.explorerBaseUrl, "https://sepolia.basescan.org"); assert.equal(published.explorerBaseUrl, "https://sepolia.basescan.org");
assert.equal((await readFile(webManifest, "utf8")).includes("terminal.invalid"), false); assert.equal((await readFile(webManifest, "utf8")).includes("terminal.invalid"), false);
for (const invalid of [ const without = (field) => {
{ ...base, rpcUrl: "http://public.invalid/rpc" }, const manifest = { ...base };
{ ...base, explorerBaseUrl: "https://example.invalid" }, 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 writeFile(join(deployments, "active.json"), `${JSON.stringify(invalid)}\n`);
await assert.rejects( await assert.rejects(
() => publishManifest({ activePath: join(deployments, "active.json"), outputPath: webManifest }), () => publishManifest({ activePath: join(deployments, "active.json"), outputPath: webManifest }),
/HTTPS|BaseScan/, rejection,
`publication accepted ${label}`,
); );
} }
await writeFile(join(deployments, "active.json"), activeBefore); await writeFile(join(deployments, "active.json"), activeBefore);
@@ -179,6 +195,42 @@ try {
await assert.rejects(() => access(join(deployments, "base-sepolia.json"))); await assert.rejects(() => access(join(deployments, "base-sepolia.json")));
assert.deepEqual(await readFile(join(deployments, "active.json")), activeBefore); assert.deepEqual(await readFile(join(deployments, "active.json")), activeBefore);
assert.deepEqual(await readFile(join(deployments, "anvil.json")), anvilBefore); 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/); await assert.rejects(() => archiveManifest({ root, network: "anvil" }), /baseSepolia/);
} finally { } finally {
await rm(root, { recursive: true, force: true }); await rm(root, { recursive: true, force: true });
+19 -7
View File
@@ -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) => { await withFixture(async (root) => {
const pending = manifest({ network: "baseSepolia", chainId: 84532, deploymentBlock: 0 }); const pending = manifest({ network: "baseSepolia", chainId: 84532, deploymentBlock: 0 });
await writeJson(join(root, "deployments", "pending.json"), pending); 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" }) }); const output = await finalizeDeployment({ root, rpc: fakeRpc({ chainId: "0x14a34" }) });
assert.deepEqual(output.manifest, { ...pending, deploymentBlock: 42 }); assert.deepEqual(output.manifest, { ...pending, deploymentBlock: 42 });
assert.equal(Object.hasOwn(output.manifest, "rpcUrl"), false); assert.equal(output.manifest.rpcUrl, "https://sepolia.base.org");
assert.equal(Object.hasOwn(output.manifest, "explorerBaseUrl"), false); assert.equal(output.manifest.explorerBaseUrl, "https://sepolia.basescan.org");
assert.deepEqual(output.manifest.actors, [{ label: "owner", address: OWNER }]); 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() }); const output = await finalizeUpgrade({ root, rpc: fakeUpgradeRpc() });
assert.equal(output.mode, "upgrade"); assert.equal(output.mode, "upgrade");
assert.equal(output.upgradeBlock, 90);
assert.deepEqual(output.manifest, { ...before, implementation: V2_IMPLEMENTATION }); assert.deepEqual(output.manifest, { ...before, implementation: V2_IMPLEMENTATION });
for (const name of ["anvil.json", "active.json"]) { for (const name of ["anvil.json", "active.json"]) {
const confirmed = await readJson(join(root, "deployments", name)); 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 () => { test("upgrade finalizer leaves confirmed files untouched for failed receipt, missing event, live mismatch, and unknown mode", async () => {
const cases = [ const cases = [
["failed receipt", fakeUpgradeRpc({ receiptStatus: "0x0" }), null, /successful/], ["failed receipt", fakeUpgradeRpc({ receiptStatus: "0x0" }), null, /successful/],
["zero receipt block", fakeUpgradeRpc({ receiptBlock: "0x0" }), null, /nonzero/],
["missing Upgraded event", fakeUpgradeRpc({ omitUpgradeLog: true }), null, /Upgraded/], ["missing Upgraded event", fakeUpgradeRpc({ omitUpgradeLog: true }), null, /Upgraded/],
["wrong live version", fakeUpgradeRpc({ version: 1n }), null, /version/], ["wrong live version", fakeUpgradeRpc({ version: 1n }), null, /version/],
["slot mismatch", fakeUpgradeRpc({ slot: IMPLEMENTATION }), null, /slot/], ["slot mismatch", fakeUpgradeRpc({ slot: IMPLEMENTATION }), null, /slot/],
@@ -300,12 +305,19 @@ function manifest(overrides = {}) {
network: baseSepolia ? "baseSepolia" : "anvil", network: baseSepolia ? "baseSepolia" : "anvil",
chainId: baseSepolia ? 84532 : 31337, chainId: baseSepolia ? 84532 : 31337,
deploymentBlock: 1, 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, token: TOKEN,
proxy: PROXY, proxy: PROXY,
implementation: IMPLEMENTATION, implementation: IMPLEMENTATION,
owner: OWNER, owner: OWNER,
actors: baseSepolia ? [{ label: "owner", address: OWNER }] : localActors(), actors: baseSepolia
? [
{ label: "Presenter", address: OWNER },
{ label: "Recipient", address: "0x70997970C51812dc3A010C7d01b50e0d17dc79C8" },
]
: localActors(),
...overrides, ...overrides,
}; };
} }
@@ -456,7 +468,7 @@ function fakeUpgradeRpc(overrides = {}) {
} }
if (method === "eth_getTransactionReceipt") return { if (method === "eth_getTransactionReceipt") return {
status: overrides.receiptStatus ?? "0x1", status: overrides.receiptStatus ?? "0x1",
blockNumber: "0x5a", blockNumber: overrides.receiptBlock ?? "0x5a",
logs: overrides.omitUpgradeLog || params[0] === DECLARED_CALL_HASH logs: overrides.omitUpgradeLog || params[0] === DECLARED_CALL_HASH
? [] : [{ address: PROXY, topics: [UPGRADED_TOPIC, wordForRpc(V2_IMPLEMENTATION)], data: "0x" }], ? [] : [{ address: PROXY, topics: [UPGRADED_TOPIC, wordForRpc(V2_IMPLEMENTATION)], data: "0x" }],
}; };