feat: script deterministic V1 demo state
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 <url>");
|
||||
return finalizeDeployment({ rpc: fetchRpc(rest[0]) });
|
||||
}
|
||||
throw new Error("usage: finalize-manifest.mjs preflight-deploy <anvil|base-sepolia> | deploy --rpc-url <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;
|
||||
});
|
||||
}
|
||||
@@ -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 <anvil|base-sepolia>");
|
||||
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;
|
||||
});
|
||||
}
|
||||
@@ -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"));
|
||||
}
|
||||
Reference in New Issue
Block a user