513 lines
26 KiB
JavaScript
513 lines
26 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 UPGRADED_TOPIC = "0xbc7cd75a20ee27fd9adebab32041f755214dbc6bffa90cc0225b39da2e5c2d3b";
|
|
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_WORDS = [...Array(11).fill("test"), "junk"].join(" ");
|
|
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 finalizeUpgrade({ root = process.cwd(), rpc }) {
|
|
if (typeof rpc !== "function") throw new Error("finalizer requires an RPC function");
|
|
const pendingPath = join(root, "deployments", "upgrade-pending.json");
|
|
const pending = await readJson(pendingPath);
|
|
if (pending?.mode !== "upgrade" && pending?.mode !== "noop") throw new Error("unknown upgrade staging mode");
|
|
|
|
const activePath = join(root, "deployments", "active.json");
|
|
const active = await readManifest(activePath);
|
|
const spec = networkSpec(active.network);
|
|
const canonicalPath = join(root, "deployments", spec.canonical);
|
|
const canonical = await readManifest(canonicalPath);
|
|
const [activeBytes, canonicalBytes] = await Promise.all([readFile(activePath), readFile(canonicalPath)]);
|
|
if (!activeBytes.equals(canonicalBytes) || JSON.stringify(active) !== JSON.stringify(canonical)) {
|
|
throw new Error("active and canonical manifest identity must match before upgrade finalization");
|
|
}
|
|
|
|
const chainId = parseRpcQuantity(await rpc("eth_chainId", []), "RPC chain ID");
|
|
if (chainId !== active.chainId) throw new Error("RPC chain ID does not match active manifest");
|
|
const methods = await readUpgradeMethods(root);
|
|
|
|
if (pending.mode === "noop") {
|
|
validateNoopMarker(pending, active);
|
|
const latestBlock = parseRpcQuantity(await rpc("eth_blockNumber", []), "latest block number");
|
|
if (latestBlock < pending.observedBlock) throw new Error("live block precedes no-op observation block");
|
|
const nonce = parseRpcQuantity(
|
|
await rpc("eth_getTransactionCount", [active.owner, "latest"]), "owner nonce"
|
|
);
|
|
if (nonce !== pending.ownerNonce) throw new Error("owner nonce changed after no-op observation");
|
|
await assertLiveVersionAndImplementation({ rpc, active, implementation: pending.implementation, methods });
|
|
await rm(pendingPath);
|
|
return { mode: "noop", path: canonicalPath, manifest: active };
|
|
}
|
|
|
|
validateUpgradeMarker(pending, active);
|
|
const broadcastPath = join(root, "broadcast", "UpgradeV2.s.sol", String(active.chainId), "run-latest.json");
|
|
const broadcast = await readJson(broadcastPath);
|
|
if (!Array.isArray(broadcast.transactions)) throw new Error("upgrade broadcast is partial: transactions are missing");
|
|
const hashes = [...new Set(broadcast.transactions.map((transaction) => transaction?.hash)
|
|
.filter((hash) => typeof hash === "string"))];
|
|
const resolvedTransactions = await Promise.all(hashes.map(async (hash) => ({
|
|
hash,
|
|
transaction: await rpc("eth_getTransactionByHash", [hash]),
|
|
})));
|
|
const matching = resolvedTransactions.filter(({ transaction }) =>
|
|
typeof transaction?.to === "string" && sameAddress(transaction.to, active.proxy));
|
|
if (matching.length !== 1) throw new Error(`expected exactly one live upgrade transaction to proxy, found ${matching.length}`);
|
|
const receipt = await rpc("eth_getTransactionReceipt", [matching[0].hash]);
|
|
if (!receipt || !isSuccessfulReceipt(receipt.status)) throw new Error("upgrade receipt was not successful");
|
|
const upgradeBlock = parseRpcQuantity(receipt.blockNumber, "upgrade receipt block number");
|
|
if (upgradeBlock === 0) throw new Error("upgrade receipt block number must be nonzero");
|
|
if (!Array.isArray(receipt.logs) || !receipt.logs.some((log) => isUpgradeLog(log, active.proxy, pending.implementation))) {
|
|
throw new Error("successful upgrade receipt is missing the expected Upgraded event");
|
|
}
|
|
|
|
await assertLiveVersionAndImplementation({ rpc, active, implementation: pending.implementation, methods });
|
|
await assertUpgradeSnapshot({ rpc, active, pending, methods });
|
|
const updated = { ...active, implementation: pending.implementation };
|
|
validateManifest(updated);
|
|
await atomicWriteJson(canonicalPath, updated);
|
|
await atomicWriteJson(activePath, updated);
|
|
await rm(pendingPath);
|
|
return { mode: "upgrade", path: canonicalPath, manifest: updated, upgradeBlock };
|
|
}
|
|
|
|
function validateNoopMarker(marker, active) {
|
|
assertExactKeys(marker, ["mode", "chainId", "observedBlock", "ownerNonce", "proxy", "implementation"], "no-op marker");
|
|
for (const field of ["chainId", "observedBlock", "ownerNonce"]) {
|
|
if (!Number.isSafeInteger(marker[field]) || marker[field] < 0) throw new Error(`no-op marker ${field} is invalid`);
|
|
}
|
|
if (marker.chainId !== active.chainId || !sameAddress(marker.proxy, active.proxy)
|
|
|| !sameAddress(marker.implementation, active.implementation)) {
|
|
throw new Error("no-op marker chain/proxy/implementation identity does not match active manifest");
|
|
}
|
|
}
|
|
|
|
function validateUpgradeMarker(marker, active) {
|
|
assertExactKeys(marker, ["mode", "network", "chainId", "token", "proxy", "previousImplementation", "implementation", "owner", "deploymentBlock", "snapshot"], "upgrade marker");
|
|
for (const field of ["network", "chainId", "deploymentBlock"]) {
|
|
if (marker[field] !== active[field]) throw new Error(`upgrade marker ${field} identity does not match active manifest`);
|
|
}
|
|
for (const field of ["token", "proxy", "previousImplementation", "owner"]) {
|
|
const activeField = field === "previousImplementation" ? "implementation" : field;
|
|
if (!sameAddress(marker[field], active[activeField])) throw new Error(`upgrade marker ${field} identity does not match active manifest`);
|
|
}
|
|
assertAddress(marker.implementation, "upgrade implementation");
|
|
if (sameAddress(marker.implementation, active.implementation)) throw new Error("upgrade implementation did not change");
|
|
const snapshot = marker.snapshot;
|
|
assertExactKeys(snapshot, ["proxy", "implementation", "owner", "asset", "paused", "balances", "liabilities", "reserves", "surplus", "deploymentBlock", "version"], "upgrade snapshot");
|
|
if (!sameAddress(snapshot.proxy, active.proxy) || snapshot.deploymentBlock !== active.deploymentBlock) {
|
|
throw new Error("upgrade snapshot proxy or deployment identity changed");
|
|
}
|
|
if (!sameAddress(snapshot.implementation, active.implementation) || !sameAddress(snapshot.owner, active.owner)
|
|
|| !sameAddress(snapshot.asset, active.token) || snapshot.version !== 1 || typeof snapshot.paused !== "boolean") {
|
|
throw new Error("upgrade snapshot identity does not match active manifest");
|
|
}
|
|
if (!Array.isArray(snapshot.balances) || snapshot.balances.length !== active.actors.length) {
|
|
throw new Error("upgrade snapshot actor count does not match active manifest");
|
|
}
|
|
snapshot.balances.forEach((record, index) => {
|
|
assertExactKeys(record, ["address", "balance"], `upgrade snapshot actor ${index}`);
|
|
if (!sameAddress(record.address, active.actors[index].address)) throw new Error("upgrade snapshot actor identity changed");
|
|
assertNonnegativeInteger(record.balance, "upgrade snapshot actor balance");
|
|
});
|
|
for (const field of ["liabilities", "reserves", "surplus"]) assertNonnegativeInteger(snapshot[field], `upgrade snapshot ${field}`);
|
|
if (snapshot.reserves < snapshot.liabilities || snapshot.reserves - snapshot.liabilities !== snapshot.surplus) {
|
|
throw new Error("upgrade snapshot accounting is inconsistent");
|
|
}
|
|
}
|
|
|
|
async function readUpgradeMethods(root) {
|
|
const bank = await readJson(join(root, "out", "BankV2.sol", "BankV2.json"));
|
|
const token = await readJson(join(root, "out", "MockUSDC.sol", "MockUSDC.json"));
|
|
const requiredBank = ["owner()", "asset()", "paused()", "balanceOf(address)", "totalLiabilities()", "contractVersion()"];
|
|
const result = { bank: {}, token: {} };
|
|
for (const signature of requiredBank) result.bank[signature] = methodSelector(bank, signature, "BankV2");
|
|
result.token["balanceOf(address)"] = methodSelector(token, "balanceOf(address)", "MockUSDC");
|
|
return result;
|
|
}
|
|
|
|
function methodSelector(artifact, signature, contractName) {
|
|
const selector = artifact?.methodIdentifiers?.[signature];
|
|
if (typeof selector !== "string" || !/^[0-9a-fA-F]{8}$/.test(selector)) {
|
|
throw new Error(`${contractName} artifact is missing method identifier ${signature}`);
|
|
}
|
|
return `0x${selector.toLowerCase()}`;
|
|
}
|
|
|
|
async function assertLiveVersionAndImplementation({ rpc, active, implementation, methods }) {
|
|
const code = await rpc("eth_getCode", [implementation, "latest"]);
|
|
if (typeof code !== "string" || code.length <= 2) throw new Error("upgrade implementation has no live code");
|
|
const version = decodeUint(await rpcCall(rpc, active.proxy, methods.bank["contractVersion()"]), "contract version");
|
|
if (version !== 2) throw new Error("live contract version is not 2");
|
|
const slot = await rpc("eth_getStorageAt", [active.proxy, IMPLEMENTATION_SLOT, "latest"]);
|
|
if (slotAddress(slot) !== implementation.toLowerCase()) throw new Error("live proxy implementation slot does not match upgrade marker");
|
|
}
|
|
|
|
async function assertUpgradeSnapshot({ rpc, active, pending, methods }) {
|
|
const snapshot = pending.snapshot;
|
|
const owner = decodeAddress(await rpcCall(rpc, active.proxy, methods.bank["owner()"]), "owner");
|
|
const asset = decodeAddress(await rpcCall(rpc, active.proxy, methods.bank["asset()"]), "asset");
|
|
const paused = decodeBool(await rpcCall(rpc, active.proxy, methods.bank["paused()"]), "paused");
|
|
if (!sameAddress(owner, snapshot.owner)) throw new Error("live owner changed during upgrade");
|
|
if (!sameAddress(asset, snapshot.asset)) throw new Error("live asset changed during upgrade");
|
|
if (paused !== snapshot.paused) throw new Error("live pause state changed during upgrade");
|
|
for (const [index, actor] of snapshot.balances.entries()) {
|
|
const balance = decodeUint(
|
|
await rpcCall(rpc, active.proxy, `${methods.bank["balanceOf(address)"]}${encodeAddressWord(actor.address)}`),
|
|
`actor ${index} balance`
|
|
);
|
|
if (balance !== actor.balance) throw new Error(`live actor ${index} balance changed during upgrade`);
|
|
}
|
|
const liabilities = decodeUint(await rpcCall(rpc, active.proxy, methods.bank["totalLiabilities()"]), "liabilities");
|
|
const reserves = decodeUint(
|
|
await rpcCall(rpc, active.token, `${methods.token["balanceOf(address)"]}${encodeAddressWord(active.proxy)}`),
|
|
"reserves"
|
|
);
|
|
if (liabilities !== snapshot.liabilities) throw new Error("live liabilities changed during upgrade");
|
|
if (reserves !== snapshot.reserves) throw new Error("live reserves changed during upgrade");
|
|
if (reserves - liabilities !== snapshot.surplus) throw new Error("live surplus changed during upgrade");
|
|
}
|
|
|
|
function rpcCall(rpc, to, data) { return rpc("eth_call", [{ to, data }, "latest"]); }
|
|
function encodeAddressWord(address) { assertAddress(address, "call address"); return address.slice(2).toLowerCase().padStart(64, "0"); }
|
|
function decodeUint(value, label) {
|
|
if (typeof value !== "string" || !/^0x[0-9a-fA-F]{64}$/.test(value)) throw new Error(`${label} RPC result is invalid`);
|
|
const parsed = BigInt(value);
|
|
if (parsed > BigInt(Number.MAX_SAFE_INTEGER)) throw new Error(`${label} exceeds JavaScript safe integer range`);
|
|
return Number(parsed);
|
|
}
|
|
function decodeAddress(value, label) {
|
|
if (typeof value !== "string" || !/^0x0{24}[0-9a-fA-F]{40}$/.test(value)) throw new Error(`${label} RPC result is invalid`);
|
|
return `0x${value.slice(-40)}`;
|
|
}
|
|
function decodeBool(value, label) {
|
|
const decoded = decodeUint(value, label);
|
|
if (decoded !== 0 && decoded !== 1) throw new Error(`${label} RPC result is not boolean`);
|
|
return decoded === 1;
|
|
}
|
|
function isUpgradeLog(log, proxy, implementation) {
|
|
return typeof log?.address === "string" && sameAddress(log.address, proxy) && Array.isArray(log.topics)
|
|
&& log.topics.length >= 2 && log.topics[0]?.toLowerCase() === UPGRADED_TOPIC
|
|
&& slotAddress(log.topics[1]) === implementation.toLowerCase();
|
|
}
|
|
function sameAddress(first, second) {
|
|
return typeof first === "string" && typeof second === "string" && /^0x[0-9a-fA-F]{40}$/.test(first)
|
|
&& /^0x[0-9a-fA-F]{40}$/.test(second) && first.toLowerCase() === second.toLowerCase();
|
|
}
|
|
function assertExactKeys(value, expected, label) {
|
|
if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error(`${label} must be an object`);
|
|
const actual = Object.keys(value).sort();
|
|
const wanted = [...expected].sort();
|
|
if (actual.length !== wanted.length || actual.some((key, index) => key !== wanted[index])) throw new Error(`${label} schema is invalid`);
|
|
}
|
|
function assertNonnegativeInteger(value, label) {
|
|
if (!Number.isSafeInteger(value) || value < 0) throw new Error(`${label} must be a nonnegative integer`);
|
|
}
|
|
|
|
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 ["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");
|
|
}
|
|
}
|
|
|
|
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`);
|
|
}
|
|
for (const key of url.searchParams.keys()) {
|
|
if (/(key|token|secret|password|credential)/i.test(key)) {
|
|
throw new Error(`manifest ${field} must not contain credential query parameters`);
|
|
}
|
|
}
|
|
}
|
|
|
|
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 !== 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 the ${network === "baseSepolia" ? "Presenter" : "first actor"}`);
|
|
}
|
|
}
|
|
|
|
function rejectProhibitedStringValues(value, path = "") {
|
|
if (typeof value === "string") {
|
|
if (value.includes(ANVIL_TEST_WORDS) || 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]) });
|
|
}
|
|
if (command === "upgrade") {
|
|
if (network !== "--rpc-url" || typeof rest[0] !== "string" || rest.length !== 1) throw new Error("usage: finalize-manifest.mjs upgrade --rpc-url <url>");
|
|
return finalizeUpgrade({ root, rpc: fetchRpc(rest[0]) });
|
|
}
|
|
throw new Error("usage: finalize-manifest.mjs preflight-deploy <anvil|baseSepolia> | deploy --rpc-url <url> | upgrade --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;
|
|
});
|
|
}
|