From c6bf8a683fd97130d9217346f4077569df9c94a7 Mon Sep 17 00:00:00 2001 From: golem Date: Fri, 21 Aug 2026 02:55:41 -0600 Subject: [PATCH] fix: align deployment manifest schema --- script/CheckState.s.sol | 6 +- script/DeployV1.s.sol | 29 ++--- script/SeedV1Demo.s.sol | 4 +- script/lib/DemoScript.sol | 131 ++++++++++++++++------ test/ScriptPreflight.t.sol | 186 ++++++++++++++++++++++++------- tools/finalize-manifest.mjs | 91 ++++++++++----- tools/select-manifest.mjs | 2 +- tools/test-finalize-manifest.mjs | 79 ++++++++++--- 8 files changed, 389 insertions(+), 139 deletions(-) diff --git a/script/CheckState.s.sol b/script/CheckState.s.sol index 19804e9..1f6c712 100644 --- a/script/CheckState.s.sol +++ b/script/CheckState.s.sol @@ -39,9 +39,9 @@ contract CheckState is DemoScript { console2.log("paused", bank.paused()); console2.log("version", bank.contractVersion()); for (uint256 i; i < manifest.actors.length; ++i) { - console2.log(manifest.actorLabels[i], manifest.actors[i]); - console2.log(" bank balance", bank.balanceOf(manifest.actors[i])); - console2.log(" token balance", token.balanceOf(manifest.actors[i])); + console2.log(manifest.actors[i].label, manifest.actors[i].address_); + console2.log(" bank balance", bank.balanceOf(manifest.actors[i].address_)); + console2.log(" token balance", token.balanceOf(manifest.actors[i].address_)); } console2.log("reserves", reserves); console2.log("liabilities", liabilities); diff --git a/script/DeployV1.s.sol b/script/DeployV1.s.sol index 54a0b59..1fa06b9 100644 --- a/script/DeployV1.s.sol +++ b/script/DeployV1.s.sol @@ -37,35 +37,30 @@ contract DeployV1 is DemoScript { Manifest memory manifest; manifest.schemaVersion = 1; - manifest.network = block.chainid == ANVIL_CHAIN_ID ? "anvil" : "base-sepolia"; + manifest.network = block.chainid == ANVIL_CHAIN_ID ? "anvil" : "baseSepolia"; manifest.chainId = block.chainid; manifest.deploymentBlock = 0; - manifest.rpcUrl = block.chainid == ANVIL_CHAIN_ID ? "http://127.0.0.1:8545" : "https://sepolia.base.org"; - manifest.explorerUrl = block.chainid == ANVIL_CHAIN_ID ? "" : "https://sepolia.basescan.org"; + manifest.rpcUrl = block.chainid == ANVIL_CHAIN_ID ? "http://127.0.0.1:8545" : ""; manifest.token = tokenAddress; manifest.proxy = proxy; manifest.implementation = implementation; manifest.owner = sender; - (manifest.actorLabels, manifest.actors) = _actors(sender); + manifest.actors = _actors(sender); _writeManifest(_manifestPath(PENDING_MANIFEST_PATH), manifest); } - function _actors(address sender) private view returns (string[] memory labels, address[] memory actors) { + function _actors(address sender) private view returns (Actor[] memory actors) { if (block.chainid == ANVIL_CHAIN_ID) { - labels = new string[](3); - actors = new address[](3); - labels[0] = "owner"; - labels[1] = "Alice"; - labels[2] = "Bob"; - actors[0] = sender; - (, actors[1]) = _deriveLocalActor(block.chainid, 1); - (, actors[2]) = _deriveLocalActor(block.chainid, 2); + actors = new Actor[](3); + actors[0] = Actor({label: "owner", address_: sender}); + (, actors[1].address_) = _deriveLocalActor(block.chainid, 1); + (, actors[2].address_) = _deriveLocalActor(block.chainid, 2); + actors[1].label = "Alice"; + actors[2].label = "Bob"; } else { - labels = new string[](1); - actors = new address[](1); - labels[0] = "owner"; - actors[0] = sender; + actors = new Actor[](1); + actors[0] = Actor({label: "owner", address_: sender}); } } } diff --git a/script/SeedV1Demo.s.sol b/script/SeedV1Demo.s.sol index 6bc27f5..1724916 100644 --- a/script/SeedV1Demo.s.sol +++ b/script/SeedV1Demo.s.sol @@ -15,8 +15,8 @@ contract SeedV1Demo is DemoScript { (uint256 aliceKey, address alice) = _deriveLocalActor(block.chainid, 1); (uint256 bobKey, address bob) = _deriveLocalActor(block.chainid, 2); _assertAddress("owner actor", manifest.owner, owner); - _assertAddress("Alice actor", manifest.actors[1], alice); - _assertAddress("Bob actor", manifest.actors[2], bob); + _assertAddress("Alice actor", manifest.actors[1].address_, alice); + _assertAddress("Bob actor", manifest.actors[2].address_, bob); MockUSDC token = MockUSDC(manifest.token); BankV1 bank = BankV1(manifest.proxy); diff --git a/script/lib/DemoScript.sol b/script/lib/DemoScript.sol index ffe1d63..1cc026c 100644 --- a/script/lib/DemoScript.sol +++ b/script/lib/DemoScript.sol @@ -23,19 +23,23 @@ abstract contract DemoScript is Script { error UnexpectedState(string label, uint256 expected, uint256 actual); error UnexpectedAddress(string label, address expected, address actual); + struct Actor { + string label; + address address_; + } + struct Manifest { uint256 schemaVersion; string network; uint256 chainId; uint256 deploymentBlock; string rpcUrl; - string explorerUrl; + string explorerBaseUrl; address token; address proxy; address implementation; address owner; - string[] actorLabels; - address[] actors; + Actor[] actors; } function _requireSupportedChain(uint256 chainId) internal pure { @@ -58,22 +62,23 @@ abstract contract DemoScript is Script { function _readManifest(string memory path, bool active) internal view returns (Manifest memory manifest) { string memory json = vm.readFile(path); + _assertExactManifestSchema(json); manifest.schemaVersion = vm.parseJsonUint(json, ".schemaVersion"); manifest.network = vm.parseJsonString(json, ".network"); manifest.chainId = vm.parseJsonUint(json, ".chainId"); manifest.deploymentBlock = vm.parseJsonUint(json, ".deploymentBlock"); - manifest.rpcUrl = vm.parseJsonString(json, ".rpcUrl"); - manifest.explorerUrl = vm.parseJsonString(json, ".explorerUrl"); + if (vm.keyExistsJson(json, ".rpcUrl")) manifest.rpcUrl = vm.parseJsonString(json, ".rpcUrl"); + if (vm.keyExistsJson(json, ".explorerBaseUrl")) { + manifest.explorerBaseUrl = vm.parseJsonString(json, ".explorerBaseUrl"); + } manifest.token = vm.parseJsonAddress(json, ".token"); manifest.proxy = vm.parseJsonAddress(json, ".proxy"); manifest.implementation = vm.parseJsonAddress(json, ".implementation"); manifest.owner = vm.parseJsonAddress(json, ".owner"); - manifest.actorLabels = vm.parseJsonStringArray(json, ".actorLabels"); - manifest.actors = vm.parseJsonAddressArray(json, ".actors"); - 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(); + manifest.actors = _parseActors(json, manifest.chainId); _validateActorConfiguration(manifest); _requireCode("token", manifest.token); _requireCode("proxy", manifest.proxy); @@ -84,29 +89,63 @@ abstract contract DemoScript is Script { if (target == address(0) || target.code.length == 0) revert MissingCode(label, target); } + function _assertExactManifestSchema(string memory json) private view { + string[] memory keys = vm.parseJsonKeys(json, "."); + if (keys.length < 9 || keys.length > 11) revert InvalidManifestSchema(0); + bool[9] memory required; + for (uint256 i; i < keys.length; ++i) { + string memory key = keys[i]; + if (_equals(key, "schemaVersion")) required[0] = true; + else if (_equals(key, "network")) required[1] = true; + else if (_equals(key, "chainId")) required[2] = true; + else if (_equals(key, "deploymentBlock")) required[3] = true; + else if (_equals(key, "token")) required[4] = true; + else if (_equals(key, "proxy")) required[5] = true; + else if (_equals(key, "implementation")) required[6] = true; + else if (_equals(key, "owner")) required[7] = true; + else if (_equals(key, "actors")) required[8] = true; + else if (!_equals(key, "rpcUrl") && !_equals(key, "explorerBaseUrl")) revert InvalidManifestSchema(0); + } + for (uint256 i; i < required.length; ++i) { + if (!required[i]) revert InvalidManifestSchema(0); + } + } + + function _parseActors(string memory json, uint256 chainId) private view returns (Actor[] memory actors) { + uint256 actorCount = chainId == ANVIL_CHAIN_ID ? 3 : 1; + actors = new Actor[](actorCount); + for (uint256 i; i < actorCount; ++i) { + string memory index = vm.toString(i); + actors[i].label = vm.parseJsonString(json, string.concat(".actors[", index, "].label")); + actors[i].address_ = vm.parseJsonAddress(json, string.concat(".actors[", index, "].address")); + } + if (vm.keyExistsJson(json, string.concat(".actors[", vm.toString(actorCount), "]"))) { + revert InvalidActorConfiguration(); + } + } + 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") + manifest.actors.length != 3 || !_equals(manifest.network, "anvil") + || !_equals(manifest.actors[0].label, "owner") || !_equals(manifest.actors[1].label, "Alice") + || !_equals(manifest.actors[2].label, "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 + manifest.actors[0].address_ != manifest.owner || manifest.actors[0].address_ != owner + || manifest.actors[1].address_ != alice || manifest.actors[2].address_ != 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 + manifest.chainId != BASE_SEPOLIA_CHAIN_ID || manifest.actors.length != 1 + || !_equals(manifest.network, "baseSepolia") || !_equals(manifest.actors[0].label, "owner") + || manifest.actors[0].address_ != manifest.owner ) revert InvalidActorConfiguration(); } @@ -122,20 +161,46 @@ abstract contract DemoScript is Script { 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); - vm.serializeString(objectKey, "network", manifest.network); - vm.serializeUint(objectKey, "chainId", manifest.chainId); - vm.serializeUint(objectKey, "deploymentBlock", manifest.deploymentBlock); - vm.serializeString(objectKey, "rpcUrl", manifest.rpcUrl); - vm.serializeString(objectKey, "explorerUrl", manifest.explorerUrl); - vm.serializeAddress(objectKey, "token", manifest.token); - vm.serializeAddress(objectKey, "proxy", manifest.proxy); - vm.serializeAddress(objectKey, "implementation", manifest.implementation); - vm.serializeAddress(objectKey, "owner", manifest.owner); - vm.serializeString(objectKey, "actorLabels", manifest.actorLabels); - json = vm.serializeAddress(objectKey, "actors", manifest.actors); + function _serializeManifest(Manifest memory manifest) internal view returns (string memory json) { + json = string.concat( + '{"schemaVersion":', + vm.toString(manifest.schemaVersion), + ',"network":"', + manifest.network, + '","chainId":', + vm.toString(manifest.chainId), + ',"deploymentBlock":', + vm.toString(manifest.deploymentBlock) + ); + if (bytes(manifest.rpcUrl).length != 0) json = string.concat(json, ',"rpcUrl":"', manifest.rpcUrl, '"'); + if (bytes(manifest.explorerBaseUrl).length != 0) { + json = string.concat(json, ',"explorerBaseUrl":"', manifest.explorerBaseUrl, '"'); + } + json = string.concat( + json, + ',"token":"', + vm.toString(manifest.token), + '","proxy":"', + vm.toString(manifest.proxy), + '","implementation":"', + vm.toString(manifest.implementation), + '","owner":"', + vm.toString(manifest.owner), + '","actors":', + _serializeActors(manifest.actors), + "}" + ); + } + + function _serializeActors(Actor[] memory actors) private view returns (string memory json) { + json = "["; + for (uint256 i; i < actors.length; ++i) { + if (i != 0) json = string.concat(json, ","); + json = string.concat( + json, '{"label":"', actors[i].label, '","address":"', vm.toString(actors[i].address_), '"}' + ); + } + json = string.concat(json, "]"); } function _writeManifest(string memory path, Manifest memory manifest) internal { @@ -153,8 +218,8 @@ abstract contract DemoScript is Script { function _assertV1State(Manifest memory manifest) internal view { BankV1 bank = BankV1(manifest.proxy); - _assertUint("Alice internal balance", 900e6, bank.balanceOf(manifest.actors[1])); - _assertUint("Bob internal balance", 500e6, bank.balanceOf(manifest.actors[2])); + _assertUint("Alice internal balance", 900e6, bank.balanceOf(manifest.actors[1].address_)); + _assertUint("Bob internal balance", 500e6, bank.balanceOf(manifest.actors[2].address_)); _assertUint("liabilities", 1_400e6, bank.totalLiabilities()); _assertUint("reserves", 1_400e6, MockUSDC(manifest.token).balanceOf(manifest.proxy)); _assertUint("version", 1, bank.contractVersion()); diff --git a/test/ScriptPreflight.t.sol b/test/ScriptPreflight.t.sol index 30e4a23..a9a381a 100644 --- a/test/ScriptPreflight.t.sol +++ b/test/ScriptPreflight.t.sol @@ -125,18 +125,38 @@ contract ScriptPreflightTest is Test { harness.readManifest(path, true); } - function testManifestRejectsNonParallelActorArrays() public { + function testAnvilManifestRejectsUnexpectedActorObjectCount() public { string memory path = _writeManifest(31337, 1, TOKEN, PROXY, IMPLEMENTATION); _etchManifestContracts(); - vm.writeJson('["owner","Alice"]', path, ".actorLabels"); - vm.expectRevert(DemoScript.InvalidActorConfiguration.selector); + vm.writeJson( + string.concat( + '[{"label":"owner","address":"', + vm.toString(OWNER), + '"},{"label":"Alice","address":"', + vm.toString(ALICE), + '"}]' + ), + path, + ".actors" + ); + vm.expectRevert(); 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.writeJson( + string.concat( + '[{"label":"owner","address":"', + vm.toString(OWNER), + '"},{"label":"Mallory","address":"', + vm.toString(ALICE), + '"},{"label":"Bob","address":"0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC"}]' + ), + path, + ".actors" + ); vm.expectRevert(DemoScript.InvalidActorConfiguration.selector); harness.readManifest(path, true); } @@ -146,7 +166,11 @@ contract ScriptPreflightTest is Test { _etchManifestContracts(); vm.writeJson( string.concat( - '["', vm.toString(ALICE), '","', vm.toString(OWNER), '","0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC"]' + '[{"label":"owner","address":"', + vm.toString(ALICE), + '"},{"label":"Alice","address":"', + vm.toString(OWNER), + '"},{"label":"Bob","address":"0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC"}]' ), path, ".actors" @@ -156,11 +180,20 @@ contract ScriptPreflightTest is Test { } function testBaseManifestRequiresOnlyOwnerActor() public { - string memory path = _writeBaseManifest(); + string memory path = _writePublicBaseManifest(); vm.chainId(84532); _etchManifestContracts(); - vm.writeJson('["owner","Alice"]', path, ".actorLabels"); - vm.writeJson(string.concat('["', vm.toString(OWNER), '","', vm.toString(ALICE), '"]'), path, ".actors"); + vm.writeJson( + string.concat( + '[{"label":"owner","address":"', + vm.toString(OWNER), + '"},{"label":"Alice","address":"', + vm.toString(ALICE), + '"}]' + ), + path, + ".actors" + ); vm.expectRevert(DemoScript.InvalidActorConfiguration.selector); harness.readManifest(path, true); } @@ -169,6 +202,41 @@ contract ScriptPreflightTest is Test { assertEq(harness.educationalWarning(), unicode"Educational demo — mock token — never use real funds."); } + function testNestedActorsRoundTripThroughManifestSerialization() public { + string memory path = _writePublicAnvilManifest(); + _etchManifestContracts(); + DemoScript.Manifest memory manifest = harness.readManifest(path, true); + assertEq(manifest.actors[0].label, "owner"); + assertEq(manifest.actors[1].address_, ALICE); + + string memory serialized = harness.serializeManifest(manifest); + assertFalse(_contains(serialized, "actorLabels")); + assertFalse(_contains(serialized, "explorerUrl")); + vm.writeFile(path, serialized); + DemoScript.Manifest memory roundTrip = harness.readManifest(path, true); + assertEq(roundTrip.actors[2].label, "Bob"); + assertEq(roundTrip.actors[2].address_, 0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC); + } + + function testLegacyParallelActorManifestIsRejected() public { + string memory path = _writeLegacyParallelActorManifest(); + _etchManifestContracts(); + vm.expectRevert(abi.encodeWithSelector(DemoScript.InvalidManifestSchema.selector, uint256(0))); + harness.readManifest(path, true); + } + + function testBaseSepoliaManifestUsesCamelCaseAndOmitsUnavailableUrls() public { + string memory path = _writePublicBaseManifest(); + vm.chainId(84532); + _etchManifestContracts(); + DemoScript.Manifest memory manifest = harness.readManifest(path, true); + assertEq(manifest.network, "baseSepolia"); + assertEq(manifest.rpcUrl, ""); + assertEq(manifest.explorerBaseUrl, ""); + assertEq(manifest.actors[0].label, "owner"); + assertEq(manifest.actors[0].address_, OWNER); + } + function testSerializedManifestContainsPublicAddressesAndNoSecrets() public { DemoScript.Manifest memory manifest = DemoScript.Manifest({ schemaVersion: 1, @@ -176,12 +244,11 @@ contract ScriptPreflightTest is Test { chainId: 31337, deploymentBlock: 0, rpcUrl: "http://127.0.0.1:8545", - explorerUrl: "", + explorerBaseUrl: "", token: TOKEN, proxy: PROXY, implementation: IMPLEMENTATION, owner: OWNER, - actorLabels: _labels(), actors: _actors() }); @@ -212,12 +279,12 @@ contract ScriptPreflightTest is Test { assertEq(BankV1(proxy).owner(), OWNER); assertEq(address(BankV1(proxy).asset()), token); assertEq(BankV1(proxy).contractVersion(), 1); - assertEq(manifest.actorLabels[0], "owner"); - assertEq(manifest.actorLabels[1], "Alice"); - assertEq(manifest.actorLabels[2], "Bob"); - assertEq(manifest.actors[0], OWNER); - assertEq(manifest.actors[1], ALICE); - assertEq(manifest.actors[2], 0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC); + assertEq(manifest.actors[0].label, "owner"); + assertEq(manifest.actors[1].label, "Alice"); + assertEq(manifest.actors[2].label, "Bob"); + assertEq(manifest.actors[0].address_, OWNER); + assertEq(manifest.actors[1].address_, ALICE); + assertEq(manifest.actors[2].address_, 0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC); } function testSeedV1DemoExecutesExactActOneStateAndCheckStateAcceptsIt() public { @@ -227,12 +294,12 @@ contract ScriptPreflightTest is Test { new SeedV1Demo().run(); BankV1 bank = BankV1(manifest.proxy); - assertEq(bank.balanceOf(manifest.actors[1]), 900e6); - assertEq(bank.balanceOf(manifest.actors[2]), 500e6); + assertEq(bank.balanceOf(manifest.actors[1].address_), 900e6); + assertEq(bank.balanceOf(manifest.actors[2].address_), 500e6); assertEq(bank.totalLiabilities(), 1_400e6); assertEq(MockUSDC(manifest.token).balanceOf(manifest.proxy), 1_400e6); - assertEq(MockUSDC(manifest.token).balanceOf(manifest.actors[1]), 1_100e6); - assertEq(MockUSDC(manifest.token).balanceOf(manifest.actors[2]), 500e6); + assertEq(MockUSDC(manifest.token).balanceOf(manifest.actors[1].address_), 1_100e6); + assertEq(MockUSDC(manifest.token).balanceOf(manifest.actors[2].address_), 500e6); vm.setEnv("DEMO_EXPECTED_STAGE", "v1"); new CheckState().run(); @@ -266,7 +333,7 @@ contract ScriptPreflightTest is Test { vm.toString(chainId), ',"deploymentBlock":', vm.toString(deploymentBlock), - ',"rpcUrl":"http://127.0.0.1:8545","explorerUrl":"","token":"', + ',"rpcUrl":"http://127.0.0.1:8545","token":"', vm.toString(token), '","proxy":"', vm.toString(proxy), @@ -274,11 +341,11 @@ contract ScriptPreflightTest is Test { vm.toString(implementation), '","owner":"', vm.toString(OWNER), - '","actorLabels":["owner","Alice","Bob"],"actors":["', + '","actors":[{"label":"owner","address":"', vm.toString(OWNER), - '","', + '"},{"label":"Alice","address":"', vm.toString(ALICE), - '","0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC"]}' + '"},{"label":"Bob","address":"0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC"}]}' ); vm.writeFile(path, json); } @@ -293,12 +360,12 @@ contract ScriptPreflightTest is Test { manifest = harness.readManifest(path, true); } - function _writeBaseManifest() internal returns (string memory path) { - path = string.concat(fixtureDir, "/base-manifest.json"); + function _writePublicAnvilManifest() internal returns (string memory path) { + path = string.concat(fixtureDir, "/public-anvil-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":"', + '{"schemaVersion":1,"network":"anvil","chainId":31337,"deploymentBlock":1,"rpcUrl":"http://127.0.0.1:8545","token":"', vm.toString(TOKEN), '","proxy":"', vm.toString(PROXY), @@ -306,9 +373,53 @@ contract ScriptPreflightTest is Test { vm.toString(IMPLEMENTATION), '","owner":"', vm.toString(OWNER), - '","actorLabels":["owner"],"actors":["', + '","actors":[{"label":"owner","address":"', vm.toString(OWNER), - '"]}' + '"},{"label":"Alice","address":"', + vm.toString(ALICE), + '"},{"label":"Bob","address":"0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC"}]}' + ) + ); + } + + function _writeLegacyParallelActorManifest() internal returns (string memory path) { + path = string.concat(fixtureDir, "/legacy-parallel-actors.json"); + vm.writeFile( + path, + string.concat( + '{"schemaVersion":1,"network":"anvil","chainId":31337,"deploymentBlock":1,"rpcUrl":"http://127.0.0.1:8545","token":"', + vm.toString(TOKEN), + '","proxy":"', + vm.toString(PROXY), + '","implementation":"', + vm.toString(IMPLEMENTATION), + '","owner":"', + vm.toString(OWNER), + '","actorLabels":["owner","Alice","Bob"],"actors":[{"label":"owner","address":"', + vm.toString(OWNER), + '"},{"label":"Alice","address":"', + vm.toString(ALICE), + '"},{"label":"Bob","address":"0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC"}]}' + ) + ); + } + + function _writePublicBaseManifest() internal returns (string memory path) { + path = string.concat(fixtureDir, "/public-base-manifest.json"); + vm.writeFile( + path, + string.concat( + '{"schemaVersion":1,"network":"baseSepolia","chainId":84532,"deploymentBlock":1,"token":"', + vm.toString(TOKEN), + '","proxy":"', + vm.toString(PROXY), + '","implementation":"', + vm.toString(IMPLEMENTATION), + '","owner":"', + vm.toString(OWNER), + '","actors":[{"label":"owner","address":"', + vm.toString(OWNER), + '"}]}' ) ); } @@ -319,18 +430,11 @@ contract ScriptPreflightTest is Test { vm.etch(IMPLEMENTATION, hex"00"); } - function _labels() internal pure returns (string[] memory labels) { - labels = new string[](3); - labels[0] = "owner"; - labels[1] = "Alice"; - labels[2] = "Bob"; - } - - function _actors() internal pure returns (address[] memory actors) { - actors = new address[](3); - actors[0] = OWNER; - actors[1] = ALICE; - actors[2] = 0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC; + function _actors() internal pure returns (DemoScript.Actor[] memory actors) { + actors = new DemoScript.Actor[](3); + actors[0] = DemoScript.Actor({label: "owner", address_: OWNER}); + actors[1] = DemoScript.Actor({label: "Alice", address_: ALICE}); + actors[2] = DemoScript.Actor({label: "Bob", address_: 0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC}); } function _contains(string memory haystack, string memory needle) internal pure returns (bool) { diff --git a/tools/finalize-manifest.mjs b/tools/finalize-manifest.mjs index 01309d2..4aa4c1d 100644 --- a/tools/finalize-manifest.mjs +++ b/tools/finalize-manifest.mjs @@ -8,22 +8,20 @@ export const EDUCATIONAL_WARNING = "Educational demo — mock token — never us const NETWORKS = { anvil: { - name: "anvil", chainId: 31337, canonical: "anvil.json", recovery: "make reset-local", rpcUrl: "http://127.0.0.1:8545", explorerUrl: "", + name: "anvil", chainId: 31337, canonical: "anvil.json", recovery: "make reset-local", rpcUrl: "http://127.0.0.1:8545", }, - "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", + baseSepolia: { + name: "baseSepolia", chainId: 84532, canonical: "base-sepolia.json", recovery: "make archive-base-manifest", }, }; -const MANIFEST_FIELDS = [ - "schemaVersion", "network", "chainId", "deploymentBlock", "rpcUrl", "explorerUrl", "token", "proxy", "implementation", "owner", "actorLabels", "actors", -]; +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 normalized = network === "baseSepolia" ? "base-sepolia" : network; - const spec = NETWORKS[normalized]; + const spec = NETWORKS[network]; if (!spec) throw new Error(`unsupported deployment network: ${network}`); return spec; } @@ -106,24 +104,26 @@ export function validateManifest(manifest, { pending = false } = {}) { if (!Number.isSafeInteger(manifest.deploymentBlock) || manifest.deploymentBlock < (pending ? 0 : 1)) { throw new Error(`manifest deploymentBlock must be ${pending ? "a nonnegative integer" : "at least 1"}`); } - if (manifest.rpcUrl !== spec.rpcUrl || manifest.explorerUrl !== spec.explorerUrl) { - throw new Error(`manifest display URLs must use the public ${manifest.network} endpoints`); - } + assertManifestUrls(manifest, spec); 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"); - } + 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 (let index = 0; index < manifest.actors.length; index += 1) { - const label = manifest.actorLabels[index]; + 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(manifest.actors[index], `actors[${index}]`); - const actor = manifest.actors[index].toLowerCase(); + 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) { @@ -149,14 +149,55 @@ function assertAddress(value, label) { } function assertExactSchema(manifest) { - for (const field of MANIFEST_FIELDS) { + 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 (!MANIFEST_FIELDS.includes(field)) throw new Error(`manifest contains unknown field ${field}`); + 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 (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)) { @@ -171,6 +212,7 @@ function rejectProhibitedStringValues(value, path = "") { 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); } } @@ -181,14 +223,13 @@ function publicManifest(manifest, deploymentBlock) { network: manifest.network, chainId: manifest.chainId, deploymentBlock, - rpcUrl: manifest.rpcUrl, - explorerUrl: manifest.explorerUrl, + ...(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, - actorLabels: [...manifest.actorLabels], - actors: [...manifest.actors], + actors: manifest.actors.map(({ label, address }) => ({ label, address })), }; } @@ -240,7 +281,7 @@ 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 "); return finalizeDeployment({ root, rpc: fetchRpc(rest[0]) }); } - throw new Error("usage: finalize-manifest.mjs preflight-deploy | deploy --rpc-url "); + 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) { diff --git a/tools/select-manifest.mjs b/tools/select-manifest.mjs index a17974b..f89cb55 100644 --- a/tools/select-manifest.mjs +++ b/tools/select-manifest.mjs @@ -16,7 +16,7 @@ export async function selectManifest({ root = process.cwd(), network }) { 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 "); + if (argv.length !== 1) throw new Error("usage: select-manifest.mjs "); return selectManifest({ root, network: argv[0] }); } diff --git a/tools/test-finalize-manifest.mjs b/tools/test-finalize-manifest.mjs index f571757..d1840fb 100644 --- a/tools/test-finalize-manifest.mjs +++ b/tools/test-finalize-manifest.mjs @@ -4,7 +4,7 @@ import { tmpdir } from "node:os"; import { dirname, join } from "node:path"; import test from "node:test"; -import { atomicWrite, finalizeDeployment, preflightDeploy, runFinalizeCli } from "./finalize-manifest.mjs"; +import { atomicWrite, finalizeDeployment, preflightDeploy, runFinalizeCli, validateManifest } from "./finalize-manifest.mjs"; import { runSelectCli, selectManifest } from "./select-manifest.mjs"; const TOKEN = "0x1000000000000000000000000000000000000001"; @@ -21,9 +21,9 @@ test("preflight rejects an existing target canonical manifest with the safe reco () => preflightDeploy({ root, network: "anvil" }), /make reset-local/ ); - await writeJson(join(root, "deployments", "base-sepolia.json"), manifest({ network: "base-sepolia", chainId: 84532 })); + await writeJson(join(root, "deployments", "base-sepolia.json"), manifest({ network: "baseSepolia", chainId: 84532 })); await assert.rejects( - () => preflightDeploy({ root, network: "base-sepolia" }), + () => preflightDeploy({ root, network: "baseSepolia" }), /make archive-base-manifest/ ); }); @@ -42,17 +42,38 @@ test("direct manifest CLIs print the exact educational warning", async () => { }); }); +test("finalizer confirms a nested public actor manifest and preserves omitted Base URLs", async () => { + await withFixture(async (root) => { + const pending = manifest({ network: "baseSepolia", chainId: 84532, deploymentBlock: 0 }); + await writeJson(join(root, "deployments", "pending.json"), pending); + await writeBroadcast(root, pending); + + const output = await finalizeDeployment({ root, rpc: fakeRpc({ chainId: "0x14a34" }) }); + assert.deepEqual(output.manifest, { ...pending, deploymentBlock: 42 }); + assert.equal(Object.hasOwn(output.manifest, "rpcUrl"), false); + assert.equal(Object.hasOwn(output.manifest, "explorerBaseUrl"), false); + assert.deepEqual(output.manifest.actors, [{ label: "owner", address: OWNER }]); + }); +}); + +test("validator rejects the legacy parallel actor and explorer schema", () => { + assert.throws( + () => validateManifest(legacyManifest()), + /unknown field|missing required field/ + ); +}); + test("finalizer writes only a receipt-confirmed manifest and preserves active until selection", async () => { await withFixture(async (root) => { const pending = manifest({ deploymentBlock: 0 }); await writeJson(join(root, "deployments", "pending.json"), pending); - await writeJson(join(root, "deployments", "active.json"), manifest({ network: "base-sepolia", chainId: 84532, deploymentBlock: 88 })); + await writeJson(join(root, "deployments", "active.json"), manifest({ network: "baseSepolia", chainId: 84532, deploymentBlock: 88 })); await writeBroadcast(root, pending, { hash: "0xaaa", contractAddress: PROXY }); const output = await finalizeDeployment({ root, rpc: fakeRpc() }); assert.equal(output.path, join(root, "deployments", "anvil.json")); assert.deepEqual(await readJson(output.path), { ...pending, deploymentBlock: 42 }); - assert.deepEqual(await readJson(join(root, "deployments", "active.json")), manifest({ network: "base-sepolia", chainId: 84532, deploymentBlock: 88 })); + assert.deepEqual(await readJson(join(root, "deployments", "active.json")), manifest({ network: "baseSepolia", chainId: 84532, deploymentBlock: 88 })); }); }); @@ -67,9 +88,9 @@ test("finalizer rejects invalid receipt, transaction, RPC, code, slot, and secre ["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|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], + ["mnemonic in actor label", { pending: manifest({ deploymentBlock: 0, actors: localActors("test test test test test test test test test test test junk") }) }, /prohibited/i], + ["private key in actor label", { pending: manifest({ deploymentBlock: 0, actors: localActors("0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80") }) }, /prohibited/i], + ["credential in RPC path", { pending: manifest({ network: "baseSepolia", chainId: 84532, deploymentBlock: 0, rpcUrl: "https://sepolia.base.org/v1/secret-token" }) }, /public URL|prohibited/i], ["unknown manifest field", { pending: manifest({ deploymentBlock: 0, harmlessLookingField: "not allowed" }) }, /unknown field/i], ]; @@ -92,7 +113,7 @@ 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"] }); + const pending = manifest({ deploymentBlock: 0, actors: localActors("MNEMONIC") }); await writeJson(join(root, "deployments", "pending.json"), pending); await writeJson(join(root, "deployments", "active.json"), manifest({ deploymentBlock: 8 })); await writeBroadcast(root, pending); @@ -105,23 +126,23 @@ test("finalizer failure leaves an initially absent canonical manifest absent", a test("selection atomically replaces active with only a valid named canonical manifest", async () => { await withFixture(async (root) => { const anvil = manifest({ deploymentBlock: 31 }); - const base = manifest({ network: "base-sepolia", chainId: 84532, deploymentBlock: 32 }); + const base = manifest({ network: "baseSepolia", chainId: 84532, deploymentBlock: 32 }); await writeJson(join(root, "deployments", "anvil.json"), anvil); await writeJson(join(root, "deployments", "base-sepolia.json"), base); await selectManifest({ root, network: "anvil" }); const anvilBytes = await readFile(join(root, "deployments", "anvil.json")); assert.deepEqual(await readFile(join(root, "deployments", "active.json")), anvilBytes); - await selectManifest({ root, network: "base-sepolia" }); + await selectManifest({ root, network: "baseSepolia" }); 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 })); + await writeJson(join(root, "deployments", "base-sepolia.json"), manifest({ network: "baseSepolia", chainId: 84532, deploymentBlock: 0 })); const beforeActive = await readFile(join(root, "deployments", "active.json")); - await assert.rejects(() => selectManifest({ root, network: "base-sepolia" }), /deploymentBlock/); + await assert.rejects(() => selectManifest({ root, network: "baseSepolia" }), /deploymentBlock/); assert.deepEqual(await readFile(join(root, "deployments", "active.json")), beforeActive); }); }); @@ -157,14 +178,30 @@ test("atomic writes remove the ignored staging file when rename fails", async () }); function manifest(overrides = {}) { - const baseSepolia = overrides.network === "base-sepolia"; + const baseSepolia = overrides.network === "baseSepolia"; return { schemaVersion: 1, - network: baseSepolia ? "base-sepolia" : "anvil", + network: baseSepolia ? "baseSepolia" : "anvil", chainId: baseSepolia ? 84532 : 31337, deploymentBlock: 1, - rpcUrl: baseSepolia ? "https://sepolia.base.org" : "http://127.0.0.1:8545", - explorerUrl: baseSepolia ? "https://sepolia.basescan.org" : "", + ...(baseSepolia ? {} : { rpcUrl: "http://127.0.0.1:8545" }), + token: TOKEN, + proxy: PROXY, + implementation: IMPLEMENTATION, + owner: OWNER, + actors: baseSepolia ? [{ label: "owner", address: OWNER }] : localActors(), + ...overrides, + }; +} + +function legacyManifest(overrides = {}) { + return { + schemaVersion: 1, + network: "anvil", + chainId: 31337, + deploymentBlock: 1, + rpcUrl: "http://127.0.0.1:8545", + explorerUrl: "", token: TOKEN, proxy: PROXY, implementation: IMPLEMENTATION, @@ -175,6 +212,14 @@ function manifest(overrides = {}) { }; } +function localActors(aliceLabel = "Alice") { + return [ + { label: "owner", address: OWNER }, + { label: aliceLabel, address: "0x70997970C51812dc3A010C7d01b50e0d17dc79C8" }, + { label: "Bob", address: "0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC" }, + ]; +} + async function withFixture(fn) { const root = await mkdtemp(join(tmpdir(), "uups-finalizer-")); try {