Files
uupl-smart-contract/tools/finalize-manifest.mjs
T

295 lines
13 KiB
JavaScript

import { randomUUID } from "node:crypto";
import { readFile, rename, rm, writeFile } from "node:fs/promises";
import { dirname, join, resolve } from "node:path";
import { pathToFileURL } from "node:url";
export const IMPLEMENTATION_SLOT = "0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc";
export const EDUCATIONAL_WARNING = "Educational demo — mock token — never use real funds.";
const NETWORKS = {
anvil: {
name: "anvil", chainId: 31337, canonical: "anvil.json", recovery: "make reset-local", rpcUrl: "http://127.0.0.1:8545",
},
baseSepolia: {
name: "baseSepolia", chainId: 84532, canonical: "base-sepolia.json", recovery: "make archive-base-manifest",
},
};
const REQUIRED_MANIFEST_FIELDS = ["schemaVersion", "network", "chainId", "deploymentBlock", "token", "proxy", "implementation", "owner", "actors"];
const OPTIONAL_MANIFEST_FIELDS = ["rpcUrl", "explorerBaseUrl"];
const ANVIL_TEST_PHRASE = "test test test test test test test test test test test junk";
const PROHIBITED_STRING_VALUE = /(?:private[_ -]?key|mnemonic|secret|password|credential|api[_ -]?key)|0x[a-fA-F0-9]{64}/i;
export function networkSpec(network) {
const spec = NETWORKS[network];
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 = publicManifest(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");
assertExactSchema(manifest);
rejectProhibitedStringValues(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"}`);
}
assertManifestUrls(manifest, spec);
for (const field of ["token", "proxy", "implementation", "owner"]) assertAddress(manifest[field], field);
if (!Array.isArray(manifest.actors) || manifest.actors.length === 0) throw new Error("manifest actors must be a nonempty array");
const labels = new Set();
const actors = new Set();
for (const [index, actorRecord] of manifest.actors.entries()) {
if (!actorRecord || typeof actorRecord !== "object" || Array.isArray(actorRecord)) throw new Error(`actors[${index}] must be an object`);
const keys = Object.keys(actorRecord);
if (keys.length !== 2 || !Object.hasOwn(actorRecord, "label") || !Object.hasOwn(actorRecord, "address")) {
throw new Error(`actors[${index}] must contain exactly label and address`);
}
const { label, address } = actorRecord;
if (typeof label !== "string" || label.trim() === "" || labels.has(label)) throw new Error("manifest actor labels must be unique nonempty strings");
labels.add(label);
assertAddress(address, `actors[${index}].address`);
const actor = address.toLowerCase();
if (actors.has(actor)) throw new Error("manifest actors must be unique");
actors.add(actor);
}
assertActorConfiguration(manifest);
}
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()}.json`);
const operations = { writeFile, rename, rm, ...io };
try {
await operations.writeFile(temporary, contents, { mode: 0o600 });
await operations.rename(temporary, path);
} catch (error) {
await operations.rm(temporary, { force: true }).catch(() => {});
throw error;
}
}
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 assertExactSchema(manifest) {
for (const field of REQUIRED_MANIFEST_FIELDS) {
if (!Object.hasOwn(manifest, field)) throw new Error(`manifest is missing required field ${field}`);
}
for (const field of Object.keys(manifest)) {
if (![...REQUIRED_MANIFEST_FIELDS, ...OPTIONAL_MANIFEST_FIELDS].includes(field)) throw new Error(`manifest contains unknown field ${field}`);
}
}
function assertManifestUrls(manifest, spec) {
if (manifest.network === "anvil") {
if (Object.hasOwn(manifest, "rpcUrl") && manifest.rpcUrl !== spec.rpcUrl) {
throw new Error("manifest anvil rpcUrl must use the local public endpoint");
}
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);
}
}
function assertPublicUrl(value, field) {
if (typeof value !== "string") throw new Error(`manifest ${field} must be a URL string`);
let url;
try {
url = new URL(value);
} catch {
throw new Error(`manifest ${field} must be a public URL`);
}
if ((url.protocol !== "https:" && url.protocol !== "http:") || url.username || url.password) {
throw new Error(`manifest ${field} must be a public URL without credentials`);
}
}
function assertActorConfiguration(manifest) {
const { actors, network, owner } = manifest;
if (network === "anvil") {
const expected = [
["owner", "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266"],
["Alice", "0x70997970C51812dc3A010C7d01b50e0d17dc79C8"],
["Bob", "0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC"],
];
if (actors.length !== expected.length || actors.some((actor, index) => actor.label !== expected[index][0] || actor.address.toLowerCase() !== expected[index][1].toLowerCase())) {
throw new Error("manifest anvil actors must match the documented local actor configuration");
}
} else if (actors.length !== 1 || actors[0].label !== "owner") {
throw new Error("manifest baseSepolia must contain only the owner actor");
}
if (actors[0].address.toLowerCase() !== owner.toLowerCase()) throw new Error("manifest owner must be actor zero");
}
function rejectProhibitedStringValues(value, path = "") {
if (typeof value === "string") {
if (value.includes(ANVIL_TEST_PHRASE) || PROHIBITED_STRING_VALUE.test(value)) {
throw new Error(`manifest contains prohibited secret material at ${path}`);
}
return;
}
if (Array.isArray(value)) {
value.forEach((item, index) => rejectProhibitedStringValues(item, `${path}[${index}]`));
return;
}
if (!value || typeof value !== "object") return;
for (const [key, nested] of Object.entries(value)) {
const nestedPath = path ? `${path}.${key}` : key;
rejectProhibitedStringValues(key, nestedPath);
rejectProhibitedStringValues(nested, nestedPath);
}
}
function publicManifest(manifest, deploymentBlock) {
return {
schemaVersion: manifest.schemaVersion,
network: manifest.network,
chainId: manifest.chainId,
deploymentBlock,
...(Object.hasOwn(manifest, "rpcUrl") ? { rpcUrl: manifest.rpcUrl } : {}),
...(Object.hasOwn(manifest, "explorerBaseUrl") ? { explorerBaseUrl: manifest.explorerBaseUrl } : {}),
token: manifest.token,
proxy: manifest.proxy,
implementation: manifest.implementation,
owner: manifest.owner,
actors: manifest.actors.map(({ label, address }) => ({ label, address })),
};
}
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;
};
}
export async function runFinalizeCli(argv, { root = process.cwd(), log = console.log } = {}) {
log(EDUCATIONAL_WARNING);
const [command, network, ...rest] = argv;
if (command === "preflight-deploy" && network && rest.length === 0) return preflightDeploy({ root, 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 <url>");
return finalizeDeployment({ root, rpc: fetchRpc(rest[0]) });
}
throw new Error("usage: finalize-manifest.mjs preflight-deploy <anvil|baseSepolia> | deploy --rpc-url <url>");
}
if (process.argv[1] && pathToFileURL(resolve(process.argv[1])).href === import.meta.url) {
runFinalizeCli(process.argv.slice(2)).catch((error) => {
console.error(error.message);
process.exitCode = 1;
});
}