fix: harden Base manifest recovery
This commit is contained in:
@@ -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 = "") {
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 } = {}) {
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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" }],
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user