From 52935acf5e908570246ba52bb0d497775891b516 Mon Sep 17 00:00:00 2001 From: golem Date: Fri, 21 Aug 2026 02:17:18 -0600 Subject: [PATCH] feat: script deterministic V1 demo state --- Makefile | 18 +- deployments/.gitkeep | 1 + script/CheckState.s.sol | 59 +++++++ script/DeployV1.s.sol | 70 ++++++++ script/SeedV1Demo.s.sol | 45 +++++ script/lib/DemoScript.sol | 124 ++++++++++++++ test/ScriptPreflight.t.sol | 278 +++++++++++++++++++++++++++++++ tools/finalize-manifest.mjs | 220 ++++++++++++++++++++++++ tools/select-manifest.mjs | 27 +++ tools/test-finalize-manifest.mjs | 169 +++++++++++++++++++ 10 files changed, 1010 insertions(+), 1 deletion(-) create mode 100644 deployments/.gitkeep create mode 100644 script/CheckState.s.sol create mode 100644 script/DeployV1.s.sol create mode 100644 script/SeedV1Demo.s.sol create mode 100644 script/lib/DemoScript.sol create mode 100644 test/ScriptPreflight.t.sol create mode 100644 tools/finalize-manifest.mjs create mode 100644 tools/select-manifest.mjs create mode 100644 tools/test-finalize-manifest.mjs diff --git a/Makefile b/Makefile index 00edc2c..1cbaa49 100644 --- a/Makefile +++ b/Makefile @@ -1,7 +1,10 @@ SHELL := /bin/bash .SHELLFLAGS := -euo pipefail -c -.PHONY: doctor setup verify +RPC_LOCAL := http://127.0.0.1:8545 +ANVIL_OWNER := 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266 + +.PHONY: doctor setup verify deploy-v1 seed-v1 check-state test-finalize-manifest doctor: @./tools/doctor.sh setup: @@ -13,7 +16,20 @@ verify: @forge clean @npm_config_offline=true forge build --force @npm_config_offline=true forge test --force + @node tools/test-finalize-manifest.mjs @npm --prefix web run lint @npm --prefix web run typecheck @npm --prefix web test @npm --prefix web run build +test-finalize-manifest: + @node tools/test-finalize-manifest.mjs +deploy-v1: + @node tools/finalize-manifest.mjs preflight-deploy anvil + @SCRIPT_SENDER=$(ANVIL_OWNER) DEPLOYMENT_MANIFEST_PATH=deployments/pending.json npm_config_offline=true forge script script/DeployV1.s.sol:DeployV1 --rpc-url $(RPC_LOCAL) --sender $(ANVIL_OWNER) --broadcast --force + @DEPLOYMENT_MANIFEST_PATH=deployments/pending.json DEMO_EXPECTED_STAGE=deployed forge script script/CheckState.s.sol:CheckState --rpc-url $(RPC_LOCAL) --force + @node tools/finalize-manifest.mjs deploy --rpc-url $(RPC_LOCAL) + @node tools/select-manifest.mjs anvil +seed-v1: + @forge script script/SeedV1Demo.s.sol:SeedV1Demo --rpc-url $(RPC_LOCAL) --broadcast --force +check-state: + @DEMO_EXPECTED_STAGE=$${DEMO_EXPECTED_STAGE:-v1} forge script script/CheckState.s.sol:CheckState --rpc-url $(RPC_LOCAL) --force diff --git a/deployments/.gitkeep b/deployments/.gitkeep new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/deployments/.gitkeep @@ -0,0 +1 @@ + diff --git a/script/CheckState.s.sol b/script/CheckState.s.sol new file mode 100644 index 0000000..b2aef5a --- /dev/null +++ b/script/CheckState.s.sol @@ -0,0 +1,59 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.35; + +import {console2} from "forge-std/console2.sol"; +import {Upgrades} from "openzeppelin-foundry-upgrades/Upgrades.sol"; +import {BankV1} from "../src/BankV1.sol"; +import {MockUSDC} from "../src/MockUSDC.sol"; +import {DemoScript} from "./lib/DemoScript.sol"; + +contract CheckState is DemoScript { + error Insolvent(uint256 reserves, uint256 liabilities); + error UnknownStage(string stage); + + function run() external view { + _requireSupportedChain(block.chainid); + 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); + BankV1 bank = BankV1(manifest.proxy); + MockUSDC token = MockUSDC(manifest.token); + + address actualImplementation = Upgrades.getImplementationAddress(manifest.proxy); + _assertAddress("implementation", manifest.implementation, actualImplementation); + _assertAddress("owner", manifest.owner, bank.owner()); + _assertAddress("asset", manifest.token, address(bank.asset())); + + uint256 reserves = token.balanceOf(manifest.proxy); + uint256 liabilities = bank.totalLiabilities(); + if (reserves < liabilities) revert Insolvent(reserves, liabilities); + uint256 surplus = reserves - liabilities; + + console2.log("network", manifest.network); + console2.log("block", block.number); + console2.log("token", manifest.token); + console2.log("proxy", manifest.proxy); + console2.log("implementation", actualImplementation); + console2.log("owner", bank.owner()); + 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("reserves", reserves); + console2.log("liabilities", liabilities); + console2.log("surplus", surplus); + + bytes32 stageHash = keccak256(bytes(stage)); + if (deployedStage) { + _assertDeployedState(manifest); + } else if (stageHash == keccak256("v1")) { + _assertV1State(manifest); + _assertUint("surplus", 0, surplus); + } else if (stageHash != keccak256("invariants")) { + revert UnknownStage(stage); + } + } +} diff --git a/script/DeployV1.s.sol b/script/DeployV1.s.sol new file mode 100644 index 0000000..871ffda --- /dev/null +++ b/script/DeployV1.s.sol @@ -0,0 +1,70 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.35; + +import {Upgrades} from "openzeppelin-foundry-upgrades/Upgrades.sol"; +import {BankV1} from "../src/BankV1.sol"; +import {MockUSDC} from "../src/MockUSDC.sol"; +import {DemoScript} from "./lib/DemoScript.sol"; + +contract DeployV1 is DemoScript { + function run() external returns (address tokenAddress, address proxy, address implementation) { + _requireSupportedChain(block.chainid); + address sender = vm.envAddress("SCRIPT_SENDER"); + + if (block.chainid == ANVIL_CHAIN_ID) { + (uint256 ownerKey, address derivedOwner) = _deriveLocalActor(block.chainid, 0); + _assertAddress("SCRIPT_SENDER", derivedOwner, sender); + vm.startBroadcast(ownerKey); + } else { + vm.startBroadcast(sender); + } + + MockUSDC token = new MockUSDC(sender); + proxy = + Upgrades.deployUUPSProxy("BankV1.sol:BankV1", abi.encodeCall(BankV1.initialize, (address(token), sender))); + vm.stopBroadcast(); + + tokenAddress = address(token); + implementation = Upgrades.getImplementationAddress(proxy); + _requireCode("token", tokenAddress); + _requireCode("proxy", proxy); + _requireCode("implementation", implementation); + _assertAddress("owner", sender, BankV1(proxy).owner()); + _assertAddress("asset", tokenAddress, address(BankV1(proxy).asset())); + _assertUint("version", 1, BankV1(proxy).contractVersion()); + _assertAddress("implementation", implementation, Upgrades.getImplementationAddress(proxy)); + + Manifest memory manifest; + manifest.schemaVersion = 1; + manifest.network = block.chainid == ANVIL_CHAIN_ID ? "anvil" : "base-sepolia"; + 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.token = tokenAddress; + manifest.proxy = proxy; + manifest.implementation = implementation; + manifest.owner = sender; + (manifest.actorLabels, manifest.actors) = _actors(sender); + + _writeManifest(_manifestPath(PENDING_MANIFEST_PATH), manifest); + } + + function _actors(address sender) private returns (string[] memory labels, address[] 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); + } else { + labels = new string[](1); + actors = new address[](1); + labels[0] = "owner"; + actors[0] = sender; + } + } +} diff --git a/script/SeedV1Demo.s.sol b/script/SeedV1Demo.s.sol new file mode 100644 index 0000000..d57aef7 --- /dev/null +++ b/script/SeedV1Demo.s.sol @@ -0,0 +1,45 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.35; + +import {BankV1} from "../src/BankV1.sol"; +import {MockUSDC} from "../src/MockUSDC.sol"; +import {DemoScript} from "./lib/DemoScript.sol"; + +contract SeedV1Demo is DemoScript { + function run() external { + if (block.chainid != ANVIL_CHAIN_ID) revert UnsupportedChain(block.chainid); + 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); + (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); + + MockUSDC token = MockUSDC(manifest.token); + BankV1 bank = BankV1(manifest.proxy); + + vm.startBroadcast(ownerKey); + token.mint(alice, 2_000e6); + token.mint(bob, 1_000e6); + vm.stopBroadcast(); + + vm.startBroadcast(aliceKey); + token.approve(manifest.proxy, 1_000e6); + bank.deposit(1_000e6); + vm.stopBroadcast(); + + vm.startBroadcast(bobKey); + token.approve(manifest.proxy, 500e6); + bank.deposit(500e6); + vm.stopBroadcast(); + + vm.startBroadcast(aliceKey); + bank.withdraw(100e6); + vm.stopBroadcast(); + + _assertV1State(manifest); + } +} diff --git a/script/lib/DemoScript.sol b/script/lib/DemoScript.sol new file mode 100644 index 0000000..28f3d7f --- /dev/null +++ b/script/lib/DemoScript.sol @@ -0,0 +1,124 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.35; + +import {Script} from "forge-std/Script.sol"; +import {BankV1} from "../../src/BankV1.sol"; +import {MockUSDC} from "../../src/MockUSDC.sol"; + +abstract contract DemoScript is Script { + uint256 internal constant ANVIL_CHAIN_ID = 31337; + uint256 internal constant BASE_SEPOLIA_CHAIN_ID = 84532; + 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"; + + error UnsupportedChain(uint256 chainId); + error ManifestChainMismatch(uint256 expected, uint256 actual); + error MissingCode(string label, address target); + error InvalidDeploymentBlock(); + error InvalidManifestSchema(uint256 schemaVersion); + error UnexpectedState(string label, uint256 expected, uint256 actual); + error UnexpectedAddress(string label, address expected, address actual); + + struct Manifest { + uint256 schemaVersion; + string network; + uint256 chainId; + uint256 deploymentBlock; + string rpcUrl; + string explorerUrl; + address token; + address proxy; + address implementation; + address owner; + string[] actorLabels; + address[] actors; + } + + function _requireSupportedChain(uint256 chainId) internal pure { + 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) { + if (chainId != ANVIL_CHAIN_ID) revert UnsupportedChain(chainId); + privateKey = vm.deriveKey(ANVIL_TEST_PHRASE, index); + actor = vm.addr(privateKey); + } + + function _manifestPath(string memory defaultPath) internal view returns (string memory) { + return vm.envOr("DEPLOYMENT_MANIFEST_PATH", defaultPath); + } + + function _readManifest(string memory path, bool active) internal view returns (Manifest memory manifest) { + string memory json = vm.readFile(path); + 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"); + 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(); + _requireCode("token", manifest.token); + _requireCode("proxy", manifest.proxy); + _requireCode("implementation", manifest.implementation); + } + + function _requireCode(string memory label, address target) internal view { + if (target == address(0) || target.code.length == 0) revert MissingCode(label, target); + } + + 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 _writeManifest(string memory path, Manifest memory manifest) internal { + vm.writeJson(_serializeManifest(manifest), path); + } + + function _assertDeployedState(Manifest memory manifest) internal view { + BankV1 bank = BankV1(manifest.proxy); + _assertAddress("owner", manifest.owner, bank.owner()); + _assertAddress("asset", manifest.token, address(bank.asset())); + _assertUint("version", 1, bank.contractVersion()); + _assertUint("liabilities", 0, bank.totalLiabilities()); + _assertUint("reserves", 0, MockUSDC(manifest.token).balanceOf(manifest.proxy)); + } + + 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("liabilities", 1_400e6, bank.totalLiabilities()); + _assertUint("reserves", 1_400e6, MockUSDC(manifest.token).balanceOf(manifest.proxy)); + _assertUint("version", 1, bank.contractVersion()); + } + + function _assertUint(string memory label, uint256 expected, uint256 actual) internal pure { + if (actual != expected) revert UnexpectedState(label, expected, actual); + } + + function _assertAddress(string memory label, address expected, address actual) internal pure { + if (actual != expected) revert UnexpectedAddress(label, expected, actual); + } +} diff --git a/test/ScriptPreflight.t.sol b/test/ScriptPreflight.t.sol new file mode 100644 index 0000000..2e1f25e --- /dev/null +++ b/test/ScriptPreflight.t.sol @@ -0,0 +1,278 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.35; + +import {Test} from "forge-std/Test.sol"; +import {DemoScript} from "../script/lib/DemoScript.sol"; +import {DeployV1} from "../script/DeployV1.s.sol"; +import {SeedV1Demo} from "../script/SeedV1Demo.s.sol"; +import {CheckState} from "../script/CheckState.s.sol"; +import {BankV1} from "../src/BankV1.sol"; +import {MockUSDC} from "../src/MockUSDC.sol"; + +contract ScriptPreflightHarness is DemoScript { + function requireSupportedChain(uint256 chainId) external pure { + _requireSupportedChain(chainId); + } + + function deriveLocalActor(uint256 chainId, uint32 index) external returns (address actor) { + (, actor) = _deriveLocalActor(chainId, index); + } + + function readManifest(string calldata path, bool active) external view returns (Manifest memory) { + return _readManifest(path, active); + } + + function requireCode(string calldata label, address target) external view { + _requireCode(label, target); + } + + function serializeManifest(Manifest calldata manifest) external returns (string memory) { + return _serializeManifest(manifest); + } +} + +contract ScriptPreflightTest is Test { + ScriptPreflightHarness internal harness; + string internal fixtureDir; + + address internal constant OWNER = 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266; + address internal constant ALICE = 0x70997970C51812dc3A010C7d01b50e0d17dc79C8; + address internal constant TOKEN = 0x1000000000000000000000000000000000000001; + address internal constant PROXY = 0x2000000000000000000000000000000000000002; + address internal constant IMPLEMENTATION = 0x3000000000000000000000000000000000000003; + + function setUp() public { + harness = new ScriptPreflightHarness(); + fixtureDir = string.concat(vm.projectRoot(), "/deployments/test-script-preflight"); + vm.createDir(fixtureDir, true); + } + + function testSupportedChainsAreAccepted() public view { + harness.requireSupportedChain(31337); + harness.requireSupportedChain(84532); + } + + function testUnsupportedChainsAreRejectedBeforeBroadcast() public { + uint256[3] memory rejected = [uint256(1), uint256(8453), uint256(7777777)]; + for (uint256 i; i < rejected.length; ++i) { + vm.expectRevert(abi.encodeWithSelector(DemoScript.UnsupportedChain.selector, rejected[i])); + harness.requireSupportedChain(rejected[i]); + } + } + + function testLocalActorsDeriveOnlyOnAnvil() public { + assertEq(harness.deriveLocalActor(31337, 0), OWNER); + assertEq(harness.deriveLocalActor(31337, 1), ALICE); + + vm.expectRevert(abi.encodeWithSelector(DemoScript.UnsupportedChain.selector, uint256(84532))); + harness.deriveLocalActor(84532, 0); + } + + function testMissingManifestIsRejected() public { + vm.expectRevert(); + harness.readManifest(string.concat(fixtureDir, "/missing.json"), false); + } + + function testInvalidManifestIsRejected() public { + string memory path = string.concat(fixtureDir, "/invalid.json"); + vm.writeFile(path, "not-json"); + vm.expectRevert(); + harness.readManifest(path, false); + } + + function testWrongManifestChainIsRejected() public { + string memory path = _writeManifest(84532, 1, TOKEN, PROXY, IMPLEMENTATION); + vm.chainId(31337); + vm.expectRevert( + abi.encodeWithSelector(DemoScript.ManifestChainMismatch.selector, uint256(31337), uint256(84532)) + ); + harness.readManifest(path, true); + } + + function testZeroManifestAddressIsRejected() public { + string memory path = _writeManifest(31337, 1, address(0), PROXY, IMPLEMENTATION); + vm.chainId(31337); + vm.expectRevert(abi.encodeWithSelector(DemoScript.MissingCode.selector, "token", address(0))); + harness.readManifest(path, true); + } + + function testAddressWithoutCodeIsRejected() public { + vm.expectRevert(abi.encodeWithSelector(DemoScript.MissingCode.selector, "token", TOKEN)); + harness.requireCode("token", TOKEN); + } + + function testPendingManifestMayUseDeploymentBlockZero() public { + string memory path = _writeManifest(31337, 0, TOKEN, PROXY, IMPLEMENTATION); + vm.chainId(31337); + vm.etch(TOKEN, hex"00"); + vm.etch(PROXY, hex"00"); + vm.etch(IMPLEMENTATION, hex"00"); + DemoScript.Manifest memory manifest = harness.readManifest(path, false); + assertEq(manifest.deploymentBlock, 0); + } + + function testActiveManifestRejectsDeploymentBlockZero() public { + string memory path = _writeManifest(31337, 0, TOKEN, PROXY, IMPLEMENTATION); + vm.chainId(31337); + vm.etch(TOKEN, hex"00"); + vm.etch(PROXY, hex"00"); + vm.etch(IMPLEMENTATION, hex"00"); + vm.expectRevert(DemoScript.InvalidDeploymentBlock.selector); + harness.readManifest(path, true); + } + + function testSerializedManifestContainsPublicAddressesAndNoSecrets() public { + DemoScript.Manifest memory manifest = DemoScript.Manifest({ + schemaVersion: 1, + network: "anvil", + chainId: 31337, + deploymentBlock: 0, + rpcUrl: "http://127.0.0.1:8545", + explorerUrl: "", + token: TOKEN, + proxy: PROXY, + implementation: IMPLEMENTATION, + owner: OWNER, + actorLabels: _labels(), + actors: _actors() + }); + + string memory json = harness.serializeManifest(manifest); + assertTrue(_contains(json, vm.toString(TOKEN))); + assertTrue(_contains(json, vm.toString(PROXY))); + assertTrue(_contains(json, vm.toString(IMPLEMENTATION))); + assertTrue(_contains(json, vm.toString(OWNER))); + assertFalse(_contains(json, "test test test test test test test test test test test junk")); + assertFalse(_contains(json, "PRIVATE_KEY")); + assertFalse(_contains(json, "MNEMONIC")); + assertFalse(_contains(json, "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80")); + } + + function testDeployV1CreatesInitializedProxyAndPublicPendingManifest() public { + string memory path = string.concat(fixtureDir, "/deployed.json"); + vm.chainId(31337); + vm.setEnv("SCRIPT_SENDER", vm.toString(OWNER)); + vm.setEnv("DEPLOYMENT_MANIFEST_PATH", path); + + DeployV1 deployer = new DeployV1(); + (address token, address proxy, address implementation) = deployer.run(); + + DemoScript.Manifest memory manifest = harness.readManifest(path, false); + assertEq(manifest.token, token); + assertEq(manifest.proxy, proxy); + assertEq(manifest.implementation, implementation); + 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); + } + + function testSeedV1DemoExecutesExactActOneStateAndCheckStateAcceptsIt() public { + (string memory path, DemoScript.Manifest memory manifest) = _deployFixture(); + vm.setEnv("DEPLOYMENT_MANIFEST_PATH", path); + + new SeedV1Demo().run(); + + BankV1 bank = BankV1(manifest.proxy); + assertEq(bank.balanceOf(manifest.actors[1]), 900e6); + assertEq(bank.balanceOf(manifest.actors[2]), 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); + + vm.setEnv("DEMO_EXPECTED_STAGE", "v1"); + new CheckState().run(); + } + + function testCheckStateRejectsManifestImplementationMismatch() public { + (string memory path, DemoScript.Manifest memory manifest) = _deployFixture(); + vm.writeJson(string.concat('"', vm.toString(manifest.token), '"'), path, ".implementation"); + vm.setEnv("DEPLOYMENT_MANIFEST_PATH", path); + vm.setEnv("DEMO_EXPECTED_STAGE", "deployed"); + CheckState checker = new CheckState(); + + vm.expectRevert( + abi.encodeWithSelector( + DemoScript.UnexpectedAddress.selector, "implementation", manifest.token, manifest.implementation + ) + ); + checker.run(); + } + + function _writeManifest( + uint256 chainId, + uint256 deploymentBlock, + address token, + address proxy, + address implementation + ) internal returns (string memory path) { + path = string.concat(fixtureDir, "/manifest.json"); + string memory json = string.concat( + '{"schemaVersion":1,"network":"anvil","chainId":', + vm.toString(chainId), + ',"deploymentBlock":', + vm.toString(deploymentBlock), + ',"rpcUrl":"http://127.0.0.1:8545","explorerUrl":"","token":"', + vm.toString(token), + '","proxy":"', + vm.toString(proxy), + '","implementation":"', + vm.toString(implementation), + '","owner":"', + vm.toString(OWNER), + '","actorLabels":["owner","Alice","Bob"],"actors":["', + vm.toString(OWNER), + '","', + vm.toString(ALICE), + '","0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC"]}' + ); + vm.writeFile(path, json); + } + + function _deployFixture() internal returns (string memory path, DemoScript.Manifest memory manifest) { + path = string.concat(fixtureDir, "/deployed.json"); + vm.chainId(31337); + vm.setEnv("SCRIPT_SENDER", vm.toString(OWNER)); + vm.setEnv("DEPLOYMENT_MANIFEST_PATH", path); + new DeployV1().run(); + vm.writeJson("1", path, ".deploymentBlock"); + manifest = harness.readManifest(path, true); + } + + 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 _contains(string memory haystack, string memory needle) internal pure returns (bool) { + bytes memory h = bytes(haystack); + bytes memory n = bytes(needle); + if (n.length > h.length) return false; + for (uint256 i; i + n.length <= h.length; ++i) { + bool match_ = true; + for (uint256 j; j < n.length; ++j) { + if (h[i + j] != n[j]) { + match_ = false; + break; + } + } + if (match_) return true; + } + return false; + } +} diff --git a/tools/finalize-manifest.mjs b/tools/finalize-manifest.mjs new file mode 100644 index 0000000..90d4c9b --- /dev/null +++ b/tools/finalize-manifest.mjs @@ -0,0 +1,220 @@ +import { randomUUID } from "node:crypto"; +import { readFile, rename, writeFile } from "node:fs/promises"; +import { dirname, join, resolve } from "node:path"; +import { pathToFileURL } from "node:url"; + +export const IMPLEMENTATION_SLOT = "0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc"; + +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" }, +}; + +export function networkSpec(network) { + const normalized = network === "baseSepolia" ? "base-sepolia" : network; + const spec = NETWORKS[normalized]; + if (!spec) throw new Error(`unsupported deployment network: ${network}`); + return spec; +} + +export async function preflightDeploy({ root = process.cwd(), network }) { + const spec = networkSpec(network); + const target = join(root, "deployments", spec.canonical); + try { + await readFile(target); + } catch (error) { + if (error.code === "ENOENT") return; + throw error; + } + throw new Error(`refusing to overwrite ${target}; recover safely with: ${spec.recovery}`); +} + +export async function finalizeDeployment({ root = process.cwd(), rpc }) { + if (typeof rpc !== "function") throw new Error("finalizer requires an RPC function"); + const pendingPath = join(root, "deployments", "pending.json"); + const pending = await readManifest(pendingPath, { pending: true }); + const spec = networkSpec(pending.network); + if (pending.chainId !== spec.chainId) throw new Error(`pending manifest chain ID does not match ${pending.network}`); + if (pending.deploymentBlock !== 0) throw new Error("pending manifest deploymentBlock must be 0"); + + const broadcastPath = join(root, "broadcast", "DeployV1.s.sol", String(pending.chainId), "run-latest.json"); + const broadcast = await readJson(broadcastPath); + if (!Array.isArray(broadcast.transactions)) throw new Error("broadcast is partial: transactions are missing"); + const proxyTransactions = broadcast.transactions.filter( + (transaction) => typeof transaction?.contractAddress === "string" + && transaction.contractAddress.toLowerCase() === pending.proxy.toLowerCase() + && transaction.transactionType === "CREATE" + && typeof transaction.hash === "string" + ); + if (proxyTransactions.length !== 1) { + throw new Error(`expected exactly one proxy creation transaction, found ${proxyTransactions.length}`); + } + + const proxyTransaction = proxyTransactions[0]; + const receipt = await rpc("eth_getTransactionReceipt", [proxyTransaction.hash]); + if (!receipt) throw new Error(`missing receipt for proxy transaction ${proxyTransaction.hash}`); + if (!isSuccessfulReceipt(receipt.status)) throw new Error(`proxy receipt ${proxyTransaction.hash} was not successful`); + const deploymentBlock = parseRpcQuantity(receipt.blockNumber, "receipt block number"); + if (deploymentBlock === 0) throw new Error("receipt block number must be nonzero"); + + const actualChainId = parseRpcQuantity(await rpc("eth_chainId", []), "RPC chain ID"); + if (actualChainId !== pending.chainId) { + throw new Error(`RPC chain ID ${actualChainId} does not match pending manifest chain ID ${pending.chainId}`); + } + for (const [label, address] of [["token", pending.token], ["proxy", pending.proxy], ["implementation", pending.implementation]]) { + const code = await rpc("eth_getCode", [address, "latest"]); + if (typeof code !== "string" || !/^0x[0-9a-fA-F]+$/.test(code) || code.length <= 2) { + throw new Error(`${label} ${address} has no code`); + } + } + const storage = await rpc("eth_getStorageAt", [pending.proxy, IMPLEMENTATION_SLOT, "latest"]); + if (slotAddress(storage) !== pending.implementation.toLowerCase()) { + throw new Error("proxy implementation slot does not match pending manifest implementation"); + } + + const confirmed = { ...pending, deploymentBlock }; + validateManifest(confirmed); + const path = join(root, "deployments", spec.canonical); + await atomicWriteJson(path, confirmed); + return { path, manifest: confirmed }; +} + +export async function readManifest(path, { pending = false } = {}) { + const manifest = await readJson(path); + validateManifest(manifest, { pending }); + return manifest; +} + +export function validateManifest(manifest, { pending = false } = {}) { + if (!manifest || typeof manifest !== "object" || Array.isArray(manifest)) throw new Error("manifest must be a JSON object"); + rejectSecretBearingContent(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); + 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"); + } + const labels = new Set(); + const actors = new Set(); + for (let index = 0; index < manifest.actors.length; index += 1) { + const label = manifest.actorLabels[index]; + 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(); + if (actors.has(actor)) throw new Error("manifest actors must be unique"); + actors.add(actor); + } +} + +export async function atomicWriteJson(path, value) { + await atomicWrite(path, `${JSON.stringify(value, null, 2)}\n`); +} + +export async function atomicWrite(path, contents, io = { writeFile, rename }) { + const temporary = join(dirname(path), `.${randomUUID()}.tmp`); + await io.writeFile(temporary, contents, { mode: 0o600 }); + await io.rename(temporary, path); +} + +function assertAddress(value, label) { + if (typeof value !== "string" || !/^0x[0-9a-fA-F]{40}$/.test(value) || /^0x0{40}$/i.test(value)) { + throw new Error(`manifest ${label} must be a nonzero address`); + } +} + +function 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`); + } + 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`); + } + } +} + +function rejectSecretBearingContent(value, path = "") { + if (Array.isArray(value)) { + value.forEach((item, index) => rejectSecretBearingContent(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); + } +} + +function isSuccessfulReceipt(status) { + return status === "0x1" || status === 1 || status === "1"; +} + +function parseRpcQuantity(value, label) { + if (typeof value !== "string" || !/^0x[0-9a-fA-F]+$/.test(value)) throw new Error(`${label} is not a hexadecimal RPC quantity`); + const parsed = Number.parseInt(value, 16); + if (!Number.isSafeInteger(parsed)) throw new Error(`${label} exceeds JavaScript safe integer range`); + return parsed; +} + +function slotAddress(value) { + if (typeof value !== "string" || !/^0x[0-9a-fA-F]{64}$/.test(value)) throw new Error("proxy implementation slot response is invalid"); + return `0x${value.slice(-40)}`.toLowerCase(); +} + +async function readJson(path) { + try { + return JSON.parse(await readFile(path, "utf8")); + } catch (error) { + if (error instanceof SyntaxError) throw new Error(`invalid JSON at ${path}`); + throw error; + } +} + +function fetchRpc(rpcUrl) { + let nextId = 1; + return async (method, params) => { + const response = await fetch(rpcUrl, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ jsonrpc: "2.0", id: nextId++, method, params }), + }); + if (!response.ok) throw new Error(`RPC ${method} returned HTTP ${response.status}`); + const body = await response.json(); + if (body.error) throw new Error(`RPC ${method} failed: ${body.error.message ?? "unknown error"}`); + return body.result; + }; +} + +async function main(argv) { + const [command, network, ...rest] = argv; + if (command === "preflight-deploy" && network && rest.length === 0) return preflightDeploy({ 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]) }); + } + 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) => { + console.error(error.message); + process.exitCode = 1; + }); +} diff --git a/tools/select-manifest.mjs b/tools/select-manifest.mjs new file mode 100644 index 0000000..36dd327 --- /dev/null +++ b/tools/select-manifest.mjs @@ -0,0 +1,27 @@ +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"; + +export async function selectManifest({ root = process.cwd(), network }) { + const spec = networkSpec(network); + const source = join(root, "deployments", spec.canonical); + await readManifest(source); + const contents = await readFile(source); + const active = join(root, "deployments", "active.json"); + await atomicWrite(active, contents); + return { source, active }; +} + +async function main(argv) { + if (argv.length !== 1) throw new Error("usage: select-manifest.mjs "); + return selectManifest({ network: argv[0] }); +} + +if (process.argv[1] && pathToFileURL(resolve(process.argv[1])).href === import.meta.url) { + main(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 new file mode 100644 index 0000000..e795c3c --- /dev/null +++ b/tools/test-finalize-manifest.mjs @@ -0,0 +1,169 @@ +import assert from "node:assert/strict"; +import { 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"; + +const TOKEN = "0x1000000000000000000000000000000000000001"; +const PROXY = "0x2000000000000000000000000000000000000002"; +const IMPLEMENTATION = "0x3000000000000000000000000000000000000003"; +const OWNER = "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266"; +const IMPLEMENTATION_SLOT = "0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc"; + +test("preflight rejects an existing target canonical manifest with the safe recovery command", async () => { + await withFixture(async (root) => { + await writeJson(join(root, "deployments", "anvil.json"), manifest()); + await assert.rejects( + () => preflightDeploy({ root, network: "anvil" }), + /make reset-local/ + ); + await writeJson(join(root, "deployments", "base-sepolia.json"), manifest({ network: "base-sepolia", chainId: 84532 })); + await assert.rejects( + () => preflightDeploy({ root, network: "base-sepolia" }), + /make archive-base-manifest/ + ); + }); +}); + +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 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 })); + }); +}); + +test("finalizer rejects invalid receipt, transaction, RPC, code, slot, and secret data without touching confirmed files", async () => { + const cases = [ + ["failed receipt", { rpc: fakeRpc({ receipt: { status: "0x0", blockNumber: "0x2a" } }) }, /not successful/], + ["missing receipt", { rpc: fakeRpc({ receipt: null }) }, /missing receipt/], + ["ambiguous proxy transaction", { broadcast: { extraProxy: true } }, /exactly one/], + ["partial broadcast", { broadcast: { omitProxy: true } }, /exactly one/], + ["non-creation proxy transaction", { broadcast: { transactionType: "CALL" } }, /exactly one/], + ["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], + ]; + + for (const [name, options, expected] of cases) { + await withFixture(async (root) => { + const pending = options.pending ?? manifest({ deploymentBlock: 0 }); + await writeJson(join(root, "deployments", "pending.json"), pending); + await writeJson(join(root, "deployments", "anvil.json"), manifest({ deploymentBlock: 7 })); + await writeJson(join(root, "deployments", "active.json"), manifest({ deploymentBlock: 8 })); + await writeBroadcast(root, pending, options.broadcast); + const beforeCanonical = await readFile(join(root, "deployments", "anvil.json")); + const beforeActive = await readFile(join(root, "deployments", "active.json")); + + await assert.rejects(() => finalizeDeployment({ root, rpc: options.rpc ?? fakeRpc() }), expected, name); + assert.deepEqual(await readFile(join(root, "deployments", "anvil.json")), beforeCanonical, name); + assert.deepEqual(await readFile(join(root, "deployments", "active.json")), beforeActive, name); + }); + } +}); + +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 }); + 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" }); + assert.deepEqual(await readFile(join(root, "deployments", "anvil.json")), anvilBytes); + assert.deepEqual(await readJson(join(root, "deployments", "active.json")), base); + + 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")); + await assert.rejects(() => selectManifest({ root, network: "base-sepolia" }), /deploymentBlock/); + assert.deepEqual(await readFile(join(root, "deployments", "active.json")), beforeActive); + }); +}); + +test("atomic writes stage a same-directory temporary file before renaming it into place", async () => { + const target = "/tmp/deployments/active.json"; + const calls = []; + const io = { + writeFile: async (path, contents) => calls.push(["write", path, contents]), + rename: async (source, destination) => calls.push(["rename", source, destination]), + }; + + await atomicWrite(target, "confirmed", io); + + assert.equal(calls[0][0], "write"); + assert.equal(dirname(calls[0][1]), dirname(target)); + assert.notEqual(calls[0][1], target); + assert.deepEqual(calls[1], ["rename", calls[0][1], target]); +}); + +function manifest(overrides = {}) { + return { + schemaVersion: 1, + network: "anvil", + chainId: 31337, + deploymentBlock: 1, + rpcUrl: "http://127.0.0.1:8545", + explorerUrl: "", + token: TOKEN, + proxy: PROXY, + implementation: IMPLEMENTATION, + owner: OWNER, + actorLabels: ["owner", "Alice", "Bob"], + actors: [OWNER, "0x70997970C51812dc3A010C7d01b50e0d17dc79C8", "0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC"], + ...overrides, + }; +} + +async function withFixture(fn) { + const root = await mkdtemp(join(tmpdir(), "uups-finalizer-")); + try { + await import("node:fs/promises").then(({ mkdir }) => mkdir(join(root, "deployments"), { recursive: true })); + await fn(root); + } finally { + await rm(root, { recursive: true, force: true }); + } +} + +async function writeBroadcast(root, pending, options = {}) { + const directory = join(root, "broadcast", "DeployV1.s.sol", String(pending.chainId)); + await import("node:fs/promises").then(({ mkdir }) => mkdir(directory, { recursive: true })); + const transactions = options.omitProxy + ? [{ hash: "0xbbb", transactionType: "CREATE", contractAddress: pending.token }] + : [{ hash: "0xaaa", transactionType: options.transactionType ?? "CREATE", contractAddress: pending.proxy }]; + if (options.extraProxy) transactions.push({ hash: "0xccc", transactionType: "CREATE", contractAddress: pending.proxy }); + await writeJson(join(directory, "run-latest.json"), { transactions }); +} + +function fakeRpc(overrides = {}) { + return async (method, params) => { + if (method === "eth_chainId") return overrides.chainId ?? "0x7a69"; + if (method === "eth_getTransactionReceipt") return Object.hasOwn(overrides, "receipt") ? overrides.receipt : { status: "0x1", blockNumber: "0x2a" }; + if (method === "eth_getCode") return params[0].toLowerCase() === overrides.missingCode?.toLowerCase() ? "0x" : "0x6000"; + if (method === "eth_getStorageAt") { + assert.equal(params[1], IMPLEMENTATION_SLOT); + return `0x000000000000000000000000${(overrides.slot ?? IMPLEMENTATION).slice(2)}`; + } + throw new Error(`unexpected RPC method: ${method}`); + }; +} + +async function writeJson(path, value) { + await writeFile(path, `${JSON.stringify(value, null, 2)}\n`); +} + +async function readJson(path) { + return JSON.parse(await readFile(path, "utf8")); +}