import { randomUUID } from "node:crypto"; import { readFile, rename, writeFile } from "node:fs/promises"; import { dirname, join, resolve } from "node:path"; import { pathToFileURL } from "node:url"; export const IMPLEMENTATION_SLOT = "0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc"; const NETWORKS = { anvil: { name: "anvil", chainId: 31337, canonical: "anvil.json", recovery: "make reset-local" }, "base-sepolia": { name: "base-sepolia", chainId: 84532, canonical: "base-sepolia.json", recovery: "make archive-base-manifest" }, }; export function networkSpec(network) { const normalized = network === "baseSepolia" ? "base-sepolia" : network; const spec = NETWORKS[normalized]; if (!spec) throw new Error(`unsupported deployment network: ${network}`); return spec; } export async function preflightDeploy({ root = process.cwd(), network }) { const spec = networkSpec(network); const target = join(root, "deployments", spec.canonical); try { await readFile(target); } catch (error) { if (error.code === "ENOENT") return; throw error; } throw new Error(`refusing to overwrite ${target}; recover safely with: ${spec.recovery}`); } export async function finalizeDeployment({ root = process.cwd(), rpc }) { if (typeof rpc !== "function") throw new Error("finalizer requires an RPC function"); const pendingPath = join(root, "deployments", "pending.json"); const pending = await readManifest(pendingPath, { pending: true }); const spec = networkSpec(pending.network); if (pending.chainId !== spec.chainId) throw new Error(`pending manifest chain ID does not match ${pending.network}`); if (pending.deploymentBlock !== 0) throw new Error("pending manifest deploymentBlock must be 0"); const broadcastPath = join(root, "broadcast", "DeployV1.s.sol", String(pending.chainId), "run-latest.json"); const broadcast = await readJson(broadcastPath); if (!Array.isArray(broadcast.transactions)) throw new Error("broadcast is partial: transactions are missing"); const proxyTransactions = broadcast.transactions.filter( (transaction) => typeof transaction?.contractAddress === "string" && transaction.contractAddress.toLowerCase() === pending.proxy.toLowerCase() && transaction.transactionType === "CREATE" && typeof transaction.hash === "string" ); if (proxyTransactions.length !== 1) { throw new Error(`expected exactly one proxy creation transaction, found ${proxyTransactions.length}`); } const proxyTransaction = proxyTransactions[0]; const receipt = await rpc("eth_getTransactionReceipt", [proxyTransaction.hash]); if (!receipt) throw new Error(`missing receipt for proxy transaction ${proxyTransaction.hash}`); if (!isSuccessfulReceipt(receipt.status)) throw new Error(`proxy receipt ${proxyTransaction.hash} was not successful`); const deploymentBlock = parseRpcQuantity(receipt.blockNumber, "receipt block number"); if (deploymentBlock === 0) throw new Error("receipt block number must be nonzero"); const actualChainId = parseRpcQuantity(await rpc("eth_chainId", []), "RPC chain ID"); if (actualChainId !== pending.chainId) { throw new Error(`RPC chain ID ${actualChainId} does not match pending manifest chain ID ${pending.chainId}`); } for (const [label, address] of [["token", pending.token], ["proxy", pending.proxy], ["implementation", pending.implementation]]) { const code = await rpc("eth_getCode", [address, "latest"]); if (typeof code !== "string" || !/^0x[0-9a-fA-F]+$/.test(code) || code.length <= 2) { throw new Error(`${label} ${address} has no code`); } } const storage = await rpc("eth_getStorageAt", [pending.proxy, IMPLEMENTATION_SLOT, "latest"]); if (slotAddress(storage) !== pending.implementation.toLowerCase()) { throw new Error("proxy implementation slot does not match pending manifest implementation"); } const confirmed = { ...pending, deploymentBlock }; validateManifest(confirmed); const path = join(root, "deployments", spec.canonical); await atomicWriteJson(path, confirmed); return { path, manifest: confirmed }; } export async function readManifest(path, { pending = false } = {}) { const manifest = await readJson(path); validateManifest(manifest, { pending }); return manifest; } export function validateManifest(manifest, { pending = false } = {}) { if (!manifest || typeof manifest !== "object" || Array.isArray(manifest)) throw new Error("manifest must be a JSON object"); rejectSecretBearingContent(manifest); if (manifest.schemaVersion !== 1) throw new Error("manifest schemaVersion must be 1"); const spec = networkSpec(manifest.network); if (manifest.chainId !== spec.chainId) throw new Error(`manifest chain ID does not match ${manifest.network}`); if (!Number.isSafeInteger(manifest.deploymentBlock) || manifest.deploymentBlock < (pending ? 0 : 1)) { throw new Error(`manifest deploymentBlock must be ${pending ? "a nonnegative integer" : "at least 1"}`); } assertPublicUrl(manifest.rpcUrl, "rpcUrl", false); assertPublicUrl(manifest.explorerUrl, "explorerUrl", true); for (const field of ["token", "proxy", "implementation", "owner"]) assertAddress(manifest[field], field); if (!Array.isArray(manifest.actorLabels) || !Array.isArray(manifest.actors) || manifest.actorLabels.length !== manifest.actors.length || manifest.actors.length === 0) { throw new Error("manifest actors and actorLabels must be nonempty parallel arrays"); } const labels = new Set(); const actors = new Set(); for (let index = 0; index < manifest.actors.length; index += 1) { const label = manifest.actorLabels[index]; if (typeof label !== "string" || label.trim() === "" || labels.has(label)) throw new Error("manifest actor labels must be unique nonempty strings"); labels.add(label); assertAddress(manifest.actors[index], `actors[${index}]`); const actor = manifest.actors[index].toLowerCase(); if (actors.has(actor)) throw new Error("manifest actors must be unique"); actors.add(actor); } } export async function atomicWriteJson(path, value) { await atomicWrite(path, `${JSON.stringify(value, null, 2)}\n`); } export async function atomicWrite(path, contents, io = { writeFile, rename }) { const temporary = join(dirname(path), `.${randomUUID()}.tmp`); await io.writeFile(temporary, contents, { mode: 0o600 }); await io.rename(temporary, path); } function assertAddress(value, label) { if (typeof value !== "string" || !/^0x[0-9a-fA-F]{40}$/.test(value) || /^0x0{40}$/i.test(value)) { throw new Error(`manifest ${label} must be a nonzero address`); } } function assertPublicUrl(value, label, allowEmpty) { if (allowEmpty && value === "") return; if (typeof value !== "string") throw new Error(`manifest ${label} must be a URL`); let parsed; try { parsed = new URL(value); } catch { throw new Error(`manifest ${label} must be a URL`); } if (!/^https?:$/.test(parsed.protocol)) throw new Error(`manifest ${label} must use http or https`); if (parsed.username || parsed.password) throw new Error(`manifest ${label} contains credentials`); for (const key of parsed.searchParams.keys()) { if (/(?:key|token|secret|password|credential|private)/i.test(key)) { throw new Error(`manifest ${label} contains a secret-bearing query parameter`); } } } function rejectSecretBearingContent(value, path = "") { if (Array.isArray(value)) { value.forEach((item, index) => rejectSecretBearingContent(item, `${path}[${index}]`)); return; } if (!value || typeof value !== "object") return; for (const [key, nested] of Object.entries(value)) { const nestedPath = path ? `${path}.${key}` : key; if (/(?:private[_ -]?key|mnemonic|secret|password|credential|api[_ -]?key)/i.test(key)) { throw new Error(`manifest contains secret-bearing field ${nestedPath}`); } rejectSecretBearingContent(nested, nestedPath); } } function isSuccessfulReceipt(status) { return status === "0x1" || status === 1 || status === "1"; } function parseRpcQuantity(value, label) { if (typeof value !== "string" || !/^0x[0-9a-fA-F]+$/.test(value)) throw new Error(`${label} is not a hexadecimal RPC quantity`); const parsed = Number.parseInt(value, 16); if (!Number.isSafeInteger(parsed)) throw new Error(`${label} exceeds JavaScript safe integer range`); return parsed; } function slotAddress(value) { if (typeof value !== "string" || !/^0x[0-9a-fA-F]{64}$/.test(value)) throw new Error("proxy implementation slot response is invalid"); return `0x${value.slice(-40)}`.toLowerCase(); } async function readJson(path) { try { return JSON.parse(await readFile(path, "utf8")); } catch (error) { if (error instanceof SyntaxError) throw new Error(`invalid JSON at ${path}`); throw error; } } function fetchRpc(rpcUrl) { let nextId = 1; return async (method, params) => { const response = await fetch(rpcUrl, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ jsonrpc: "2.0", id: nextId++, method, params }), }); if (!response.ok) throw new Error(`RPC ${method} returned HTTP ${response.status}`); const body = await response.json(); if (body.error) throw new Error(`RPC ${method} failed: ${body.error.message ?? "unknown error"}`); return body.result; }; } async function main(argv) { const [command, network, ...rest] = argv; if (command === "preflight-deploy" && network && rest.length === 0) return preflightDeploy({ network }); if (command === "deploy") { if (network !== "--rpc-url" || typeof rest[0] !== "string" || rest.length !== 1) throw new Error("usage: finalize-manifest.mjs deploy --rpc-url "); return finalizeDeployment({ rpc: fetchRpc(rest[0]) }); } throw new Error("usage: finalize-manifest.mjs preflight-deploy | deploy --rpc-url "); } if (process.argv[1] && pathToFileURL(resolve(process.argv[1])).href === import.meta.url) { main(process.argv.slice(2)).catch((error) => { console.error(error.message); process.exitCode = 1; }); }