feat: demonstrate state-preserving V2 upgrade
This commit is contained in:
+201
-1
@@ -4,6 +4,7 @@ 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 = {
|
||||
@@ -88,6 +89,201 @@ export async function finalizeDeployment({ root = process.cwd(), rpc }) {
|
||||
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");
|
||||
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 };
|
||||
}
|
||||
|
||||
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 });
|
||||
@@ -283,7 +479,11 @@ export async function runFinalizeCli(argv, { root = process.cwd(), log = console
|
||||
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 (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) {
|
||||
|
||||
@@ -18,12 +18,19 @@ const bankRequirements = [
|
||||
eventSignature("Upgraded", [parameter("implementation", "address", true)]),
|
||||
];
|
||||
const tokenRequirements = [functionSignature("balanceOf", [parameter("account", "address")], ["uint256"], "view")];
|
||||
const bankV2Requirements = [
|
||||
...bankRequirements,
|
||||
functionSignature("transferBalance", [parameter("recipient", "address"), parameter("amount", "uint256")], [], "nonpayable"),
|
||||
eventSignature("BalanceTransferred", [parameter("from", "address", true), parameter("to", "address", true), parameter("amount", "uint256", false)]),
|
||||
];
|
||||
|
||||
export function extractAbi(artifact, contractName) {
|
||||
if (!artifact || typeof artifact !== "object" || !Array.isArray(artifact.abi)) {
|
||||
throw new Error(`${contractName} artifact must contain an ABI`);
|
||||
}
|
||||
const requirements = contractName === "BankV1" ? bankRequirements : contractName === "MockUSDC" ? tokenRequirements : null;
|
||||
const requirements = contractName === "BankV1" ? bankRequirements
|
||||
: contractName === "BankV2" ? bankV2Requirements
|
||||
: contractName === "MockUSDC" ? tokenRequirements : null;
|
||||
if (!requirements) throw new Error(`unsupported contract artifact ${contractName}`);
|
||||
for (const requirement of requirements) {
|
||||
const entries = artifact.abi.filter((entry) => entry && entry.type === requirement.type && entry.name === requirement.name);
|
||||
@@ -53,21 +60,27 @@ function matchesParameters(actual, expected) {
|
||||
});
|
||||
}
|
||||
|
||||
export function renderContractsModule(bankV1Abi, mockUsdcAbi) {
|
||||
return `/* This file is generated by tools/sync-web-artifacts.mjs. Do not edit. */\n\nexport const bankV1Abi = ${JSON.stringify(bankV1Abi, null, 2)} as const;\n\nexport const mockUsdcAbi = ${JSON.stringify(mockUsdcAbi, null, 2)} as const;\n`;
|
||||
export function renderContractsModule(bankV1Abi, bankV2Abi, mockUsdcAbi) {
|
||||
return `/* This file is generated by tools/sync-web-artifacts.mjs. Do not edit. */\n\nexport const bankV1Abi = ${JSON.stringify(bankV1Abi, null, 2)} as const;\n\nexport const bankV2Abi = ${JSON.stringify(bankV2Abi, null, 2)} as const;\n\nexport const mockUsdcAbi = ${JSON.stringify(mockUsdcAbi, null, 2)} as const;\n`;
|
||||
}
|
||||
|
||||
export async function syncArtifacts({
|
||||
bankArtifactPath = resolve(repositoryRoot, "out/BankV1.sol/BankV1.json"),
|
||||
bankV2ArtifactPath = resolve(repositoryRoot, "out/BankV2.sol/BankV2.json"),
|
||||
tokenArtifactPath = resolve(repositoryRoot, "out/MockUSDC.sol/MockUSDC.json"),
|
||||
outputPath = resolve(repositoryRoot, "web/src/generated/contracts.ts"),
|
||||
check = false,
|
||||
} = {}) {
|
||||
const [bankArtifact, tokenArtifact] = await Promise.all([
|
||||
const [bankArtifact, bankV2Artifact, tokenArtifact] = await Promise.all([
|
||||
readArtifact(bankArtifactPath, "BankV1"),
|
||||
readArtifact(bankV2ArtifactPath, "BankV2"),
|
||||
readArtifact(tokenArtifactPath, "MockUSDC"),
|
||||
]);
|
||||
const contents = renderContractsModule(extractAbi(bankArtifact, "BankV1"), extractAbi(tokenArtifact, "MockUSDC"));
|
||||
const contents = renderContractsModule(
|
||||
extractAbi(bankArtifact, "BankV1"),
|
||||
extractAbi(bankV2Artifact, "BankV2"),
|
||||
extractAbi(tokenArtifact, "MockUSDC")
|
||||
);
|
||||
if (check) {
|
||||
let existing;
|
||||
try { existing = await readFile(outputPath, "utf8"); } catch { throw new Error("generated contracts module is stale or missing"); }
|
||||
|
||||
@@ -4,15 +4,18 @@ import { tmpdir } from "node:os";
|
||||
import { dirname, join } from "node:path";
|
||||
import test from "node:test";
|
||||
|
||||
import { atomicWrite, finalizeDeployment, preflightDeploy, runFinalizeCli, validateManifest } from "./finalize-manifest.mjs";
|
||||
import { UPGRADED_TOPIC, atomicWrite, finalizeDeployment, finalizeUpgrade, preflightDeploy, runFinalizeCli, validateManifest } from "./finalize-manifest.mjs";
|
||||
import { runSelectCli, selectManifest } from "./select-manifest.mjs";
|
||||
|
||||
const TOKEN = "0x1000000000000000000000000000000000000001";
|
||||
const PROXY = "0x2000000000000000000000000000000000000002";
|
||||
const IMPLEMENTATION = "0x3000000000000000000000000000000000000003";
|
||||
const V2_IMPLEMENTATION = "0x4000000000000000000000000000000000000004";
|
||||
const OWNER = "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266";
|
||||
const IMPLEMENTATION_SLOT = "0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc";
|
||||
const EDUCATIONAL_WARNING = "Educational demo — mock token — never use real funds.";
|
||||
const DECLARED_CREATE_HASH = "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
|
||||
const DECLARED_CALL_HASH = "0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb";
|
||||
|
||||
test("preflight rejects an existing target canonical manifest with the safe recovery command", async () => {
|
||||
await withFixture(async (root) => {
|
||||
@@ -136,6 +139,106 @@ test("finalizer failure leaves an initially absent canonical manifest absent", a
|
||||
});
|
||||
});
|
||||
|
||||
test("upgrade finalizer verifies receipt, event, slot, artifact-driven state and changes only implementation", async () => {
|
||||
await withUpgradeFixture(async (root, active) => {
|
||||
const before = structuredClone(active);
|
||||
const output = await finalizeUpgrade({ root, rpc: fakeUpgradeRpc() });
|
||||
|
||||
assert.equal(output.mode, "upgrade");
|
||||
assert.deepEqual(output.manifest, { ...before, implementation: V2_IMPLEMENTATION });
|
||||
for (const name of ["anvil.json", "active.json"]) {
|
||||
const confirmed = await readJson(join(root, "deployments", name));
|
||||
assert.deepEqual(confirmed, { ...before, implementation: V2_IMPLEMENTATION });
|
||||
assert.deepEqual({ ...confirmed, implementation: before.implementation }, before);
|
||||
}
|
||||
await assert.rejects(() => access(join(root, "deployments", "upgrade-pending.json")));
|
||||
});
|
||||
});
|
||||
|
||||
test("upgrade finalizer rejects proxy, deployment-block, and actor identity mutation without changing confirmed files", async () => {
|
||||
for (const [name, mutate] of [
|
||||
["proxy", (pending) => { pending.proxy = TOKEN; }],
|
||||
["deployment block", (pending) => { pending.deploymentBlock += 1; }],
|
||||
["actor", (pending) => { pending.snapshot.balances[1].address = OWNER; }],
|
||||
]) {
|
||||
await withUpgradeFixture(async (root) => {
|
||||
const pendingPath = join(root, "deployments", "upgrade-pending.json");
|
||||
const pending = await readJson(pendingPath); mutate(pending); await writeJson(pendingPath, pending);
|
||||
const beforeCanonical = await readFile(join(root, "deployments", "anvil.json"));
|
||||
const beforeActive = await readFile(join(root, "deployments", "active.json"));
|
||||
await assert.rejects(() => finalizeUpgrade({ root, rpc: fakeUpgradeRpc() }), /proxy|deployment|actor|identity/i, name);
|
||||
assert.deepEqual(await readFile(join(root, "deployments", "anvil.json")), beforeCanonical);
|
||||
assert.deepEqual(await readFile(join(root, "deployments", "active.json")), beforeActive);
|
||||
await access(pendingPath);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
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/],
|
||||
["missing Upgraded event", fakeUpgradeRpc({ omitUpgradeLog: true }), null, /Upgraded/],
|
||||
["wrong live version", fakeUpgradeRpc({ version: 1n }), null, /version/],
|
||||
["slot mismatch", fakeUpgradeRpc({ slot: IMPLEMENTATION }), null, /slot/],
|
||||
["owner changed", fakeUpgradeRpc({ owner: TOKEN }), null, /owner/],
|
||||
["unknown mode", fakeUpgradeRpc(), (pending) => { pending.mode = "mystery"; }, /mode/],
|
||||
];
|
||||
for (const [name, rpc, mutate, expected] of cases) {
|
||||
await withUpgradeFixture(async (root) => {
|
||||
const pendingPath = join(root, "deployments", "upgrade-pending.json");
|
||||
if (mutate) { const pending = await readJson(pendingPath); mutate(pending); await writeJson(pendingPath, pending); }
|
||||
const beforeCanonical = await readFile(join(root, "deployments", "anvil.json"));
|
||||
const beforeActive = await readFile(join(root, "deployments", "active.json"));
|
||||
await assert.rejects(() => finalizeUpgrade({ root, rpc }), expected, name);
|
||||
assert.deepEqual(await readFile(join(root, "deployments", "anvil.json")), beforeCanonical);
|
||||
assert.deepEqual(await readFile(join(root, "deployments", "active.json")), beforeActive);
|
||||
await access(pendingPath);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test("noop finalizer ignores stale broadcast history and leaves confirmed manifests byte-for-byte unchanged", async () => {
|
||||
await withUpgradeFixture(async (root, active) => {
|
||||
active.implementation = V2_IMPLEMENTATION;
|
||||
await writeJson(join(root, "deployments", "anvil.json"), active);
|
||||
await writeJson(join(root, "deployments", "active.json"), active);
|
||||
await writeJson(join(root, "deployments", "upgrade-pending.json"), {
|
||||
mode: "noop", chainId: 31337, observedBlock: 80, ownerNonce: 5,
|
||||
proxy: PROXY, implementation: V2_IMPLEMENTATION,
|
||||
});
|
||||
await writeJson(join(root, "broadcast", "UpgradeV2.s.sol", "31337", "run-latest.json"), {
|
||||
transactions: [{ hash: "0xstale", transaction: { to: TOKEN } }], receipts: [{ status: "0x0" }],
|
||||
});
|
||||
const beforeCanonical = await readFile(join(root, "deployments", "anvil.json"));
|
||||
const beforeActive = await readFile(join(root, "deployments", "active.json"));
|
||||
|
||||
const output = await finalizeUpgrade({ root, rpc: fakeUpgradeRpc() });
|
||||
|
||||
assert.equal(output.mode, "noop");
|
||||
assert.deepEqual(await readFile(join(root, "deployments", "anvil.json")), beforeCanonical);
|
||||
assert.deepEqual(await readFile(join(root, "deployments", "active.json")), beforeActive);
|
||||
await assert.rejects(() => access(join(root, "deployments", "upgrade-pending.json")));
|
||||
});
|
||||
});
|
||||
|
||||
test("noop finalizer rejects a regressed block or changed owner nonce without touching confirmed state", async () => {
|
||||
for (const [name, rpc] of [["block", fakeUpgradeRpc({ blockNumber: 79 })], ["nonce", fakeUpgradeRpc({ nonce: 6 })]]) {
|
||||
await withUpgradeFixture(async (root, active) => {
|
||||
active.implementation = V2_IMPLEMENTATION;
|
||||
await writeJson(join(root, "deployments", "anvil.json"), active);
|
||||
await writeJson(join(root, "deployments", "active.json"), active);
|
||||
await writeJson(join(root, "deployments", "upgrade-pending.json"), {
|
||||
mode: "noop", chainId: 31337, observedBlock: 80, ownerNonce: 5,
|
||||
proxy: PROXY, implementation: V2_IMPLEMENTATION,
|
||||
});
|
||||
const before = await readFile(join(root, "deployments", "active.json"));
|
||||
await assert.rejects(() => finalizeUpgrade({ root, rpc }), /block|nonce/i, name);
|
||||
assert.deepEqual(await readFile(join(root, "deployments", "active.json")), before);
|
||||
await access(join(root, "deployments", "upgrade-pending.json"));
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test("selection atomically replaces active with only a valid named canonical manifest", async () => {
|
||||
await withFixture(async (root) => {
|
||||
const anvil = manifest({ deploymentBlock: 31 });
|
||||
@@ -266,6 +369,118 @@ function fakeRpc(overrides = {}) {
|
||||
};
|
||||
}
|
||||
|
||||
const selectors = {
|
||||
"owner()": "11111111",
|
||||
"asset()": "22222222",
|
||||
"paused()": "33333333",
|
||||
"balanceOf(address)": "44444444",
|
||||
"totalLiabilities()": "55555555",
|
||||
"contractVersion()": "66666666",
|
||||
};
|
||||
|
||||
async function withUpgradeFixture(fn) {
|
||||
await withFixture(async (root) => {
|
||||
const active = manifest();
|
||||
await writeJson(join(root, "deployments", "anvil.json"), active);
|
||||
await writeJson(join(root, "deployments", "active.json"), active);
|
||||
await writeJson(join(root, "deployments", "upgrade-pending.json"), upgradePending(active));
|
||||
await writeUpgradeArtifacts(root);
|
||||
await writeUpgradeBroadcast(root);
|
||||
await fn(root, active);
|
||||
});
|
||||
}
|
||||
|
||||
function upgradePending(active) {
|
||||
return {
|
||||
mode: "upgrade",
|
||||
network: active.network,
|
||||
chainId: active.chainId,
|
||||
token: active.token,
|
||||
proxy: active.proxy,
|
||||
previousImplementation: active.implementation,
|
||||
implementation: V2_IMPLEMENTATION,
|
||||
owner: active.owner,
|
||||
deploymentBlock: active.deploymentBlock,
|
||||
snapshot: {
|
||||
proxy: active.proxy,
|
||||
implementation: active.implementation,
|
||||
owner: active.owner,
|
||||
asset: active.token,
|
||||
paused: false,
|
||||
balances: active.actors.map((actor, index) => ({ address: actor.address, balance: index === 1 ? 900_000_000 : index === 2 ? 500_000_000 : 0 })),
|
||||
liabilities: 1_400_000_000,
|
||||
reserves: 1_400_000_000,
|
||||
surplus: 0,
|
||||
deploymentBlock: active.deploymentBlock,
|
||||
version: 1,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function writeUpgradeArtifacts(root) {
|
||||
const bankDirectory = join(root, "out", "BankV2.sol");
|
||||
const tokenDirectory = join(root, "out", "MockUSDC.sol");
|
||||
const { mkdir } = await import("node:fs/promises");
|
||||
await mkdir(bankDirectory, { recursive: true });
|
||||
await mkdir(tokenDirectory, { recursive: true });
|
||||
await writeJson(join(bankDirectory, "BankV2.json"), { methodIdentifiers: selectors });
|
||||
await writeJson(join(tokenDirectory, "MockUSDC.json"), { methodIdentifiers: { "balanceOf(address)": selectors["balanceOf(address)"] } });
|
||||
}
|
||||
|
||||
async function writeUpgradeBroadcast(root) {
|
||||
const directory = join(root, "broadcast", "UpgradeV2.s.sol", "31337");
|
||||
const { mkdir } = await import("node:fs/promises");
|
||||
await mkdir(directory, { recursive: true });
|
||||
await writeJson(join(directory, "run-latest.json"), {
|
||||
transactions: [
|
||||
{ hash: DECLARED_CREATE_HASH, transactionType: "CREATE", transaction: { to: null } },
|
||||
{ hash: DECLARED_CALL_HASH, transactionType: "CALL", transaction: { to: PROXY } },
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
function fakeUpgradeRpc(overrides = {}) {
|
||||
const balanceByAddress = new Map([
|
||||
[OWNER.toLowerCase(), 0n],
|
||||
["0x70997970c51812dc3a010c7d01b50e0d17dc79c8", 900_000_000n],
|
||||
["0x3c44cdddb6a900fa2b585dd299e03d12fa4293bc", 500_000_000n],
|
||||
]);
|
||||
return async (method, params) => {
|
||||
if (method === "eth_chainId") return "0x7a69";
|
||||
if (method === "eth_blockNumber") return hexQuantity(overrides.blockNumber ?? 100);
|
||||
if (method === "eth_getTransactionCount") return hexQuantity(overrides.nonce ?? 5);
|
||||
if (method === "eth_getCode") return "0x6000";
|
||||
if (method === "eth_getStorageAt") return wordForRpc(overrides.slot ?? V2_IMPLEMENTATION);
|
||||
if (method === "eth_getTransactionByHash") {
|
||||
return { hash: params[0], to: params[0] === DECLARED_CREATE_HASH ? PROXY : null };
|
||||
}
|
||||
if (method === "eth_getTransactionReceipt") return {
|
||||
status: overrides.receiptStatus ?? "0x1",
|
||||
blockNumber: "0x5a",
|
||||
logs: overrides.omitUpgradeLog || params[0] === DECLARED_CALL_HASH
|
||||
? [] : [{ address: PROXY, topics: [UPGRADED_TOPIC, wordForRpc(V2_IMPLEMENTATION)], data: "0x" }],
|
||||
};
|
||||
if (method === "eth_call") {
|
||||
const call = params[0];
|
||||
const selector = call.data.slice(2, 10);
|
||||
if (selector === selectors["owner()"]) return wordForRpc(overrides.owner ?? OWNER);
|
||||
if (selector === selectors["asset()"]) return wordForRpc(TOKEN);
|
||||
if (selector === selectors["paused()"]) return uintWord(0n);
|
||||
if (selector === selectors["totalLiabilities()"]) return uintWord(1_400_000_000n);
|
||||
if (selector === selectors["contractVersion()"]) return uintWord(overrides.version ?? 2n);
|
||||
if (selector === selectors["balanceOf(address)"]) {
|
||||
if (call.to.toLowerCase() === TOKEN.toLowerCase()) return uintWord(1_400_000_000n);
|
||||
return uintWord(balanceByAddress.get(`0x${call.data.slice(-40)}`.toLowerCase()) ?? 0n);
|
||||
}
|
||||
}
|
||||
throw new Error(`unexpected upgrade RPC method: ${method}`);
|
||||
};
|
||||
}
|
||||
|
||||
function wordForRpc(address) { return `0x${"0".repeat(24)}${address.slice(2).toLowerCase()}`; }
|
||||
function uintWord(value) { return `0x${BigInt(value).toString(16).padStart(64, "0")}`; }
|
||||
function hexQuantity(value) { return `0x${Number(value).toString(16)}`; }
|
||||
|
||||
async function writeJson(path, value) {
|
||||
await writeFile(path, `${JSON.stringify(value, null, 2)}\n`);
|
||||
}
|
||||
|
||||
@@ -22,6 +22,11 @@ const bankAbi = [
|
||||
{ type: "event", name: "Upgraded", inputs: [{ name: "implementation", type: "address", indexed: true }], anonymous: false },
|
||||
];
|
||||
const tokenAbi = [{ type: "function", name: "balanceOf", inputs: [{ name: "account", type: "address" }], outputs: [{ name: "", type: "uint256" }], stateMutability: "view" }];
|
||||
const bankV2Abi = [
|
||||
...bankAbi,
|
||||
{ type: "function", name: "transferBalance", inputs: [{ name: "recipient", type: "address" }, { name: "amount", type: "uint256" }], outputs: [], stateMutability: "nonpayable" },
|
||||
{ type: "event", name: "BalanceTransferred", inputs: [{ name: "from", type: "address", indexed: true }, { name: "to", type: "address", indexed: true }, { name: "amount", type: "uint256", indexed: false }], anonymous: false },
|
||||
];
|
||||
const manifest = {
|
||||
schemaVersion: 1, network: "anvil", chainId: 31337, deploymentBlock: 3,
|
||||
rpcUrl: "http://127.0.0.1:8545", token: address, proxy: address,
|
||||
@@ -40,13 +45,18 @@ async function expectRejects(action, pattern) {
|
||||
|
||||
await withFixture(async (root) => {
|
||||
const bank = join(root, "BankV1.json");
|
||||
const bankV2 = join(root, "BankV2.json");
|
||||
const token = join(root, "MockUSDC.json");
|
||||
const output = join(root, "contracts.ts");
|
||||
|
||||
// Catches a production bridge that silently produces an ABI module from incomplete artifacts.
|
||||
await expectRejects(() => syncArtifacts({ bankArtifactPath: bank, tokenArtifactPath: token, outputPath: output }), /BankV1 artifact/i);
|
||||
await expectRejects(() => syncArtifacts({ bankArtifactPath: bank, bankV2ArtifactPath: bankV2, tokenArtifactPath: token, outputPath: output }), /BankV1 artifact/i);
|
||||
await writeFile(bank, JSON.stringify({ abi: bankAbi }));
|
||||
await expectRejects(() => syncArtifacts({ bankArtifactPath: bank, tokenArtifactPath: token, outputPath: output }), /MockUSDC artifact/i);
|
||||
await writeFile(token, JSON.stringify({ abi: tokenAbi }));
|
||||
await expectRejects(() => syncArtifacts({ bankArtifactPath: bank, bankV2ArtifactPath: bankV2, tokenArtifactPath: token, outputPath: output }), /BankV2 artifact/i);
|
||||
await writeFile(bankV2, JSON.stringify({ abi: bankV2Abi }));
|
||||
await rm(token);
|
||||
await expectRejects(() => syncArtifacts({ bankArtifactPath: bank, bankV2ArtifactPath: bankV2, tokenArtifactPath: token, outputPath: output }), /MockUSDC artifact/i);
|
||||
await writeFile(token, JSON.stringify({ abi: tokenAbi }));
|
||||
|
||||
// Catches a bridge that exports an ABI missing a V1 contract function or event.
|
||||
@@ -58,16 +68,20 @@ await withFixture(async (root) => {
|
||||
const wrongEvent = structuredClone(bankAbi);
|
||||
wrongEvent.find((entry) => entry.name === "Deposited").inputs[0].indexed = false;
|
||||
assert.throws(() => extractAbi({ abi: wrongEvent }, "BankV1"), /signature.*Deposited/i);
|
||||
const rendered = renderContractsModule(extractAbi({ abi: bankAbi }, "BankV1"), extractAbi({ abi: tokenAbi }, "MockUSDC"));
|
||||
assert.throws(() => extractAbi({ abi: bankV2Abi.filter((entry) => entry.name !== "transferBalance") }, "BankV2"), /transferBalance/);
|
||||
assert.throws(() => extractAbi({ abi: bankV2Abi.filter((entry) => entry.name !== "BalanceTransferred") }, "BankV2"), /BalanceTransferred/);
|
||||
const rendered = renderContractsModule(extractAbi({ abi: bankAbi }, "BankV1"), extractAbi({ abi: bankV2Abi }, "BankV2"), extractAbi({ abi: tokenAbi }, "MockUSDC"));
|
||||
assert.match(rendered, /export const bankV1Abi = .* as const;/s);
|
||||
assert.match(rendered, /export const bankV2Abi = .* as const;/s);
|
||||
assert.match(rendered, /export const mockUsdcAbi = .* as const;/s);
|
||||
assert.doesNotMatch(rendered, /bankV2Abi|BankV2/);
|
||||
assert.match(rendered, /transferBalance/);
|
||||
assert.match(rendered, /BalanceTransferred/);
|
||||
|
||||
// Catches a bridge that requires an active manifest or reads V2 as part of ABI generation.
|
||||
await syncArtifacts({ bankArtifactPath: bank, tokenArtifactPath: token, outputPath: output });
|
||||
await syncArtifacts({ bankArtifactPath: bank, bankV2ArtifactPath: bankV2, tokenArtifactPath: token, outputPath: output });
|
||||
assert.equal(await readFile(output, "utf8"), rendered);
|
||||
await writeFile(output, "stale\n");
|
||||
await expectRejects(() => syncArtifacts({ bankArtifactPath: bank, tokenArtifactPath: token, outputPath: output, check: true }), /stale/i);
|
||||
await expectRejects(() => syncArtifacts({ bankArtifactPath: bank, bankV2ArtifactPath: bankV2, tokenArtifactPath: token, outputPath: output, check: true }), /stale/i);
|
||||
});
|
||||
|
||||
await withFixture(async (root) => {
|
||||
|
||||
Reference in New Issue
Block a user