diff --git a/script/CheckState.s.sol b/script/CheckState.s.sol index b2aef5a..19804e9 100644 --- a/script/CheckState.s.sol +++ b/script/CheckState.s.sol @@ -13,6 +13,7 @@ contract CheckState is DemoScript { function run() external view { _requireSupportedChain(block.chainid); + _printEducationalWarning(); string memory stage = vm.envOr("DEMO_EXPECTED_STAGE", string("v1")); bool deployedStage = keccak256(bytes(stage)) == keccak256("deployed"); Manifest memory manifest = _readManifest(_manifestPath(ACTIVE_MANIFEST_PATH), !deployedStage); diff --git a/script/DeployV1.s.sol b/script/DeployV1.s.sol index 871ffda..54a0b59 100644 --- a/script/DeployV1.s.sol +++ b/script/DeployV1.s.sol @@ -9,6 +9,7 @@ import {DemoScript} from "./lib/DemoScript.sol"; contract DeployV1 is DemoScript { function run() external returns (address tokenAddress, address proxy, address implementation) { _requireSupportedChain(block.chainid); + _printEducationalWarning(); address sender = vm.envAddress("SCRIPT_SENDER"); if (block.chainid == ANVIL_CHAIN_ID) { @@ -50,7 +51,7 @@ contract DeployV1 is DemoScript { _writeManifest(_manifestPath(PENDING_MANIFEST_PATH), manifest); } - function _actors(address sender) private returns (string[] memory labels, address[] memory actors) { + function _actors(address sender) private view returns (string[] memory labels, address[] memory actors) { if (block.chainid == ANVIL_CHAIN_ID) { labels = new string[](3); actors = new address[](3); diff --git a/script/SeedV1Demo.s.sol b/script/SeedV1Demo.s.sol index d57aef7..6bc27f5 100644 --- a/script/SeedV1Demo.s.sol +++ b/script/SeedV1Demo.s.sol @@ -8,8 +8,8 @@ import {DemoScript} from "./lib/DemoScript.sol"; contract SeedV1Demo is DemoScript { function run() external { if (block.chainid != ANVIL_CHAIN_ID) revert UnsupportedChain(block.chainid); + _printEducationalWarning(); Manifest memory manifest = _readManifest(_manifestPath(ACTIVE_MANIFEST_PATH), true); - if (manifest.actors.length != 3 || manifest.actorLabels.length != 3) revert InvalidManifestSchema(1); (uint256 ownerKey, address owner) = _deriveLocalActor(block.chainid, 0); (uint256 aliceKey, address alice) = _deriveLocalActor(block.chainid, 1); diff --git a/script/lib/DemoScript.sol b/script/lib/DemoScript.sol index 28f3d7f..ffe1d63 100644 --- a/script/lib/DemoScript.sol +++ b/script/lib/DemoScript.sol @@ -2,6 +2,7 @@ pragma solidity 0.8.35; import {Script} from "forge-std/Script.sol"; +import {console2} from "forge-std/console2.sol"; import {BankV1} from "../../src/BankV1.sol"; import {MockUSDC} from "../../src/MockUSDC.sol"; @@ -11,12 +12,14 @@ abstract contract DemoScript is Script { string internal constant ANVIL_TEST_PHRASE = "test test test test test test test test test test test junk"; string internal constant PENDING_MANIFEST_PATH = "deployments/pending.json"; string internal constant ACTIVE_MANIFEST_PATH = "deployments/active.json"; + string internal constant EDUCATIONAL_WARNING = unicode"Educational demo — mock token — never use real funds."; error UnsupportedChain(uint256 chainId); error ManifestChainMismatch(uint256 expected, uint256 actual); error MissingCode(string label, address target); error InvalidDeploymentBlock(); error InvalidManifestSchema(uint256 schemaVersion); + error InvalidActorConfiguration(); error UnexpectedState(string label, uint256 expected, uint256 actual); error UnexpectedAddress(string label, address expected, address actual); @@ -39,7 +42,11 @@ abstract contract DemoScript is Script { if (chainId != ANVIL_CHAIN_ID && chainId != BASE_SEPOLIA_CHAIN_ID) revert UnsupportedChain(chainId); } - function _deriveLocalActor(uint256 chainId, uint32 index) internal returns (uint256 privateKey, address actor) { + function _deriveLocalActor(uint256 chainId, uint32 index) + internal + pure + returns (uint256 privateKey, address actor) + { if (chainId != ANVIL_CHAIN_ID) revert UnsupportedChain(chainId); privateKey = vm.deriveKey(ANVIL_TEST_PHRASE, index); actor = vm.addr(privateKey); @@ -67,6 +74,7 @@ abstract contract DemoScript is Script { if (manifest.schemaVersion != 1) revert InvalidManifestSchema(manifest.schemaVersion); if (manifest.chainId != block.chainid) revert ManifestChainMismatch(block.chainid, manifest.chainId); if (active && manifest.deploymentBlock == 0) revert InvalidDeploymentBlock(); + _validateActorConfiguration(manifest); _requireCode("token", manifest.token); _requireCode("proxy", manifest.proxy); _requireCode("implementation", manifest.implementation); @@ -76,6 +84,44 @@ abstract contract DemoScript is Script { if (target == address(0) || target.code.length == 0) revert MissingCode(label, target); } + function _validateActorConfiguration(Manifest memory manifest) internal pure { + if (manifest.actorLabels.length != manifest.actors.length) revert InvalidActorConfiguration(); + if (manifest.chainId == ANVIL_CHAIN_ID) { + if ( + manifest.actorLabels.length != 3 || !_equals(manifest.network, "anvil") + || !_equals(manifest.actorLabels[0], "owner") || !_equals(manifest.actorLabels[1], "Alice") + || !_equals(manifest.actorLabels[2], "Bob") + ) revert InvalidActorConfiguration(); + + (, address owner) = _deriveLocalActor(ANVIL_CHAIN_ID, 0); + (, address alice) = _deriveLocalActor(ANVIL_CHAIN_ID, 1); + (, address bob) = _deriveLocalActor(ANVIL_CHAIN_ID, 2); + if ( + manifest.actors[0] != manifest.owner || manifest.actors[0] != owner || manifest.actors[1] != alice + || manifest.actors[2] != bob + ) revert InvalidActorConfiguration(); + return; + } + + if ( + manifest.chainId != BASE_SEPOLIA_CHAIN_ID || manifest.actorLabels.length != 1 + || !_equals(manifest.network, "base-sepolia") || !_equals(manifest.actorLabels[0], "owner") + || manifest.actors[0] != manifest.owner + ) revert InvalidActorConfiguration(); + } + + function _educationalWarning() internal pure returns (string memory) { + return EDUCATIONAL_WARNING; + } + + function _printEducationalWarning() internal pure { + console2.log(EDUCATIONAL_WARNING); + } + + function _equals(string memory left, string memory right) private pure returns (bool) { + return keccak256(bytes(left)) == keccak256(bytes(right)); + } + function _serializeManifest(Manifest memory manifest) internal returns (string memory json) { string memory objectKey = "demo-manifest"; vm.serializeUint(objectKey, "schemaVersion", manifest.schemaVersion); diff --git a/test/ScriptPreflight.t.sol b/test/ScriptPreflight.t.sol index 2e1f25e..30e4a23 100644 --- a/test/ScriptPreflight.t.sol +++ b/test/ScriptPreflight.t.sol @@ -14,7 +14,7 @@ contract ScriptPreflightHarness is DemoScript { _requireSupportedChain(chainId); } - function deriveLocalActor(uint256 chainId, uint32 index) external returns (address actor) { + function deriveLocalActor(uint256 chainId, uint32 index) external pure returns (address actor) { (, actor) = _deriveLocalActor(chainId, index); } @@ -29,6 +29,10 @@ contract ScriptPreflightHarness is DemoScript { function serializeManifest(Manifest calldata manifest) external returns (string memory) { return _serializeManifest(manifest); } + + function educationalWarning() external pure returns (string memory) { + return _educationalWarning(); + } } contract ScriptPreflightTest is Test { @@ -121,6 +125,50 @@ contract ScriptPreflightTest is Test { harness.readManifest(path, true); } + function testManifestRejectsNonParallelActorArrays() public { + string memory path = _writeManifest(31337, 1, TOKEN, PROXY, IMPLEMENTATION); + _etchManifestContracts(); + vm.writeJson('["owner","Alice"]', path, ".actorLabels"); + vm.expectRevert(DemoScript.InvalidActorConfiguration.selector); + harness.readManifest(path, true); + } + + function testAnvilManifestRejectsUnexpectedActorLabels() public { + string memory path = _writeManifest(31337, 1, TOKEN, PROXY, IMPLEMENTATION); + _etchManifestContracts(); + vm.writeJson('["owner","Mallory","Bob"]', path, ".actorLabels"); + vm.expectRevert(DemoScript.InvalidActorConfiguration.selector); + harness.readManifest(path, true); + } + + function testAnvilManifestRequiresOwnerAtActorZero() public { + string memory path = _writeManifest(31337, 1, TOKEN, PROXY, IMPLEMENTATION); + _etchManifestContracts(); + vm.writeJson( + string.concat( + '["', vm.toString(ALICE), '","', vm.toString(OWNER), '","0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC"]' + ), + path, + ".actors" + ); + vm.expectRevert(DemoScript.InvalidActorConfiguration.selector); + harness.readManifest(path, true); + } + + function testBaseManifestRequiresOnlyOwnerActor() public { + string memory path = _writeBaseManifest(); + vm.chainId(84532); + _etchManifestContracts(); + vm.writeJson('["owner","Alice"]', path, ".actorLabels"); + vm.writeJson(string.concat('["', vm.toString(OWNER), '","', vm.toString(ALICE), '"]'), path, ".actors"); + vm.expectRevert(DemoScript.InvalidActorConfiguration.selector); + harness.readManifest(path, true); + } + + function testEducationalWarningIsExact() public view { + assertEq(harness.educationalWarning(), unicode"Educational demo — mock token — never use real funds."); + } + function testSerializedManifestContainsPublicAddressesAndNoSecrets() public { DemoScript.Manifest memory manifest = DemoScript.Manifest({ schemaVersion: 1, @@ -245,6 +293,32 @@ contract ScriptPreflightTest is Test { manifest = harness.readManifest(path, true); } + function _writeBaseManifest() internal returns (string memory path) { + path = string.concat(fixtureDir, "/base-manifest.json"); + vm.writeFile( + path, + string.concat( + '{"schemaVersion":1,"network":"base-sepolia","chainId":84532,"deploymentBlock":1,"rpcUrl":"https://sepolia.base.org","explorerUrl":"https://sepolia.basescan.org","token":"', + vm.toString(TOKEN), + '","proxy":"', + vm.toString(PROXY), + '","implementation":"', + vm.toString(IMPLEMENTATION), + '","owner":"', + vm.toString(OWNER), + '","actorLabels":["owner"],"actors":["', + vm.toString(OWNER), + '"]}' + ) + ); + } + + function _etchManifestContracts() internal { + vm.etch(TOKEN, hex"00"); + vm.etch(PROXY, hex"00"); + vm.etch(IMPLEMENTATION, hex"00"); + } + function _labels() internal pure returns (string[] memory labels) { labels = new string[](3); labels[0] = "owner"; diff --git a/tools/finalize-manifest.mjs b/tools/finalize-manifest.mjs index 90d4c9b..01309d2 100644 --- a/tools/finalize-manifest.mjs +++ b/tools/finalize-manifest.mjs @@ -1,15 +1,26 @@ import { randomUUID } from "node:crypto"; -import { readFile, rename, writeFile } from "node:fs/promises"; +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" }, - "base-sepolia": { name: "base-sepolia", chainId: 84532, canonical: "base-sepolia.json", recovery: "make archive-base-manifest" }, + anvil: { + name: "anvil", chainId: 31337, canonical: "anvil.json", recovery: "make reset-local", rpcUrl: "http://127.0.0.1:8545", explorerUrl: "", + }, + "base-sepolia": { + name: "base-sepolia", chainId: 84532, canonical: "base-sepolia.json", recovery: "make archive-base-manifest", rpcUrl: "https://sepolia.base.org", explorerUrl: "https://sepolia.basescan.org", + }, }; +const MANIFEST_FIELDS = [ + "schemaVersion", "network", "chainId", "deploymentBlock", "rpcUrl", "explorerUrl", "token", "proxy", "implementation", "owner", "actorLabels", "actors", +]; +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 normalized = network === "baseSepolia" ? "base-sepolia" : network; const spec = NETWORKS[normalized]; @@ -72,7 +83,7 @@ export async function finalizeDeployment({ root = process.cwd(), rpc }) { throw new Error("proxy implementation slot does not match pending manifest implementation"); } - const confirmed = { ...pending, deploymentBlock }; + const confirmed = publicManifest(pending, deploymentBlock); validateManifest(confirmed); const path = join(root, "deployments", spec.canonical); await atomicWriteJson(path, confirmed); @@ -87,15 +98,17 @@ export async function readManifest(path, { pending = false } = {}) { 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); + 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"}`); } - assertPublicUrl(manifest.rpcUrl, "rpcUrl", false); - assertPublicUrl(manifest.explorerUrl, "explorerUrl", true); + if (manifest.rpcUrl !== spec.rpcUrl || manifest.explorerUrl !== spec.explorerUrl) { + throw new Error(`manifest display URLs must use the public ${manifest.network} endpoints`); + } 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"); @@ -118,9 +131,15 @@ export async function atomicWriteJson(path, value) { } 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); + 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) { @@ -129,39 +148,50 @@ function assertAddress(value, label) { } } -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`); +function assertExactSchema(manifest) { + for (const field of MANIFEST_FIELDS) { + if (!Object.hasOwn(manifest, field)) throw new Error(`manifest is missing required field ${field}`); } - 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`); - } + for (const field of Object.keys(manifest)) { + if (!MANIFEST_FIELDS.includes(field)) throw new Error(`manifest contains unknown field ${field}`); } } -function rejectSecretBearingContent(value, path = "") { +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) => rejectSecretBearingContent(item, `${path}[${index}]`)); + 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; - if (/(?:private[_ -]?key|mnemonic|secret|password|credential|api[_ -]?key)/i.test(key)) { - throw new Error(`manifest contains secret-bearing field ${nestedPath}`); - } - rejectSecretBearingContent(nested, nestedPath); + rejectProhibitedStringValues(nested, nestedPath); } } +function publicManifest(manifest, deploymentBlock) { + return { + schemaVersion: manifest.schemaVersion, + network: manifest.network, + chainId: manifest.chainId, + deploymentBlock, + rpcUrl: manifest.rpcUrl, + explorerUrl: manifest.explorerUrl, + token: manifest.token, + proxy: manifest.proxy, + implementation: manifest.implementation, + owner: manifest.owner, + actorLabels: [...manifest.actorLabels], + actors: [...manifest.actors], + }; +} + function isSuccessfulReceipt(status) { return status === "0x1" || status === 1 || status === "1"; } @@ -202,18 +232,19 @@ function fetchRpc(rpcUrl) { }; } -async function main(argv) { +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({ network }); + 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 "); - return finalizeDeployment({ rpc: fetchRpc(rest[0]) }); + return finalizeDeployment({ root, 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) => { + runFinalizeCli(process.argv.slice(2)).catch((error) => { console.error(error.message); process.exitCode = 1; }); diff --git a/tools/select-manifest.mjs b/tools/select-manifest.mjs index 36dd327..a17974b 100644 --- a/tools/select-manifest.mjs +++ b/tools/select-manifest.mjs @@ -2,7 +2,7 @@ import { readFile } from "node:fs/promises"; import { join, resolve } from "node:path"; import { pathToFileURL } from "node:url"; -import { atomicWrite, networkSpec, readManifest } from "./finalize-manifest.mjs"; +import { atomicWrite, EDUCATIONAL_WARNING, networkSpec, readManifest } from "./finalize-manifest.mjs"; export async function selectManifest({ root = process.cwd(), network }) { const spec = networkSpec(network); @@ -14,13 +14,14 @@ export async function selectManifest({ root = process.cwd(), network }) { return { source, active }; } -async function main(argv) { +export async function runSelectCli(argv, { root = process.cwd(), log = console.log } = {}) { + log(EDUCATIONAL_WARNING); if (argv.length !== 1) throw new Error("usage: select-manifest.mjs "); - return selectManifest({ network: argv[0] }); + return selectManifest({ root, network: argv[0] }); } if (process.argv[1] && pathToFileURL(resolve(process.argv[1])).href === import.meta.url) { - main(process.argv.slice(2)).catch((error) => { + runSelectCli(process.argv.slice(2)).catch((error) => { console.error(error.message); process.exitCode = 1; }); diff --git a/tools/test-finalize-manifest.mjs b/tools/test-finalize-manifest.mjs index e795c3c..f571757 100644 --- a/tools/test-finalize-manifest.mjs +++ b/tools/test-finalize-manifest.mjs @@ -1,17 +1,18 @@ import assert from "node:assert/strict"; -import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { access, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { dirname, join } from "node:path"; import test from "node:test"; -import { atomicWrite, finalizeDeployment, preflightDeploy } from "./finalize-manifest.mjs"; -import { selectManifest } from "./select-manifest.mjs"; +import { atomicWrite, finalizeDeployment, preflightDeploy, runFinalizeCli } from "./finalize-manifest.mjs"; +import { runSelectCli, selectManifest } from "./select-manifest.mjs"; const TOKEN = "0x1000000000000000000000000000000000000001"; const PROXY = "0x2000000000000000000000000000000000000002"; const IMPLEMENTATION = "0x3000000000000000000000000000000000000003"; const OWNER = "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266"; const IMPLEMENTATION_SLOT = "0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc"; +const EDUCATIONAL_WARNING = "Educational demo — mock token — never use real funds."; test("preflight rejects an existing target canonical manifest with the safe recovery command", async () => { await withFixture(async (root) => { @@ -28,6 +29,19 @@ test("preflight rejects an existing target canonical manifest with the safe reco }); }); +test("direct manifest CLIs print the exact educational warning", async () => { + await withFixture(async (root) => { + const finalizeLogs = []; + await runFinalizeCli(["preflight-deploy", "anvil"], { root, log: (line) => finalizeLogs.push(line) }); + assert.deepEqual(finalizeLogs, [EDUCATIONAL_WARNING]); + + await writeJson(join(root, "deployments", "anvil.json"), manifest()); + const selectLogs = []; + await runSelectCli(["anvil"], { root, log: (line) => selectLogs.push(line) }); + assert.deepEqual(selectLogs, [EDUCATIONAL_WARNING]); + }); +}); + test("finalizer writes only a receipt-confirmed manifest and preserves active until selection", async () => { await withFixture(async (root) => { const pending = manifest({ deploymentBlock: 0 }); @@ -52,7 +66,11 @@ test("finalizer rejects invalid receipt, transaction, RPC, code, slot, and secre ["wrong chain", { rpc: fakeRpc({ chainId: "0x14a34" }) }, /chain ID/], ["missing code", { rpc: fakeRpc({ missingCode: TOKEN }) }, /has no code/], ["implementation slot mismatch", { rpc: fakeRpc({ slot: TOKEN }) }, /implementation slot/], - ["secret-bearing pending manifest", { pending: manifest({ rpcUrl: "https://user:password@example.invalid" }) }, /credential|secret/i], + ["secret-bearing pending manifest", { pending: manifest({ rpcUrl: "https://user:password@example.invalid" }) }, /credential|secret|endpoints/i], + ["mnemonic in actor label", { pending: manifest({ deploymentBlock: 0, actorLabels: ["owner", "test test test test test test test test test test test junk", "Bob"] }) }, /prohibited/i], + ["private key in actor label", { pending: manifest({ deploymentBlock: 0, actorLabels: ["owner", "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80", "Bob"] }) }, /prohibited/i], + ["credential in RPC path", { pending: manifest({ deploymentBlock: 0, rpcUrl: "https://sepolia.base.org/v1/secret-token" }) }, /public endpoint|prohibited/i], + ["unknown manifest field", { pending: manifest({ deploymentBlock: 0, harmlessLookingField: "not allowed" }) }, /unknown field/i], ]; for (const [name, options, expected] of cases) { @@ -72,6 +90,18 @@ test("finalizer rejects invalid receipt, transaction, RPC, code, slot, and secre } }); +test("finalizer failure leaves an initially absent canonical manifest absent", async () => { + await withFixture(async (root) => { + const pending = manifest({ deploymentBlock: 0, actorLabels: ["owner", "MNEMONIC", "Bob"] }); + await writeJson(join(root, "deployments", "pending.json"), pending); + await writeJson(join(root, "deployments", "active.json"), manifest({ deploymentBlock: 8 })); + await writeBroadcast(root, pending); + + await assert.rejects(() => finalizeDeployment({ root, rpc: fakeRpc() }), /prohibited/i); + await assert.rejects(() => access(join(root, "deployments", "anvil.json"))); + }); +}); + test("selection atomically replaces active with only a valid named canonical manifest", async () => { await withFixture(async (root) => { const anvil = manifest({ deploymentBlock: 31 }); @@ -85,6 +115,9 @@ test("selection atomically replaces active with only a valid named canonical man await selectManifest({ root, network: "base-sepolia" }); assert.deepEqual(await readFile(join(root, "deployments", "anvil.json")), anvilBytes); assert.deepEqual(await readJson(join(root, "deployments", "active.json")), base); + const baseBytes = await readFile(join(root, "deployments", "base-sepolia.json")); + await selectManifest({ root, network: "anvil" }); + assert.deepEqual(await readFile(join(root, "deployments", "base-sepolia.json")), baseBytes); await writeJson(join(root, "deployments", "base-sepolia.json"), manifest({ network: "base-sepolia", chainId: 84532, deploymentBlock: 0 })); const beforeActive = await readFile(join(root, "deployments", "active.json")); @@ -106,17 +139,32 @@ test("atomic writes stage a same-directory temporary file before renaming it int assert.equal(calls[0][0], "write"); assert.equal(dirname(calls[0][1]), dirname(target)); assert.notEqual(calls[0][1], target); + assert.match(calls[0][1], /\.json$/); assert.deepEqual(calls[1], ["rename", calls[0][1], target]); }); +test("atomic writes remove the ignored staging file when rename fails", async () => { + const target = "/tmp/deployments/active.json"; + const calls = []; + const io = { + writeFile: async (path) => calls.push(["write", path]), + rename: async () => { throw new Error("rename failed"); }, + rm: async (path) => calls.push(["rm", path]), + }; + + await assert.rejects(() => atomicWrite(target, "confirmed", io), /rename failed/); + assert.deepEqual(calls[1], ["rm", calls[0][1]]); +}); + function manifest(overrides = {}) { + const baseSepolia = overrides.network === "base-sepolia"; return { schemaVersion: 1, - network: "anvil", - chainId: 31337, + network: baseSepolia ? "base-sepolia" : "anvil", + chainId: baseSepolia ? 84532 : 31337, deploymentBlock: 1, - rpcUrl: "http://127.0.0.1:8545", - explorerUrl: "", + rpcUrl: baseSepolia ? "https://sepolia.base.org" : "http://127.0.0.1:8545", + explorerUrl: baseSepolia ? "https://sepolia.basescan.org" : "", token: TOKEN, proxy: PROXY, implementation: IMPLEMENTATION,