feat: demonstrate state-preserving V2 upgrade
This commit is contained in:
@@ -3,8 +3,9 @@ SHELL := /bin/bash
|
|||||||
|
|
||||||
RPC_LOCAL := http://127.0.0.1:8545
|
RPC_LOCAL := http://127.0.0.1:8545
|
||||||
ANVIL_OWNER := 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266
|
ANVIL_OWNER := 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266
|
||||||
|
ANVIL_ALICE := 0x70997970C51812dc3A010C7d01b50e0d17dc79C8
|
||||||
|
|
||||||
.PHONY: doctor setup demo-local verify check-state reset-local deploy-v1 seed-v1 sync-artifacts sync-artifacts-check sync-abis publish-web-manifest test-finalize-manifest
|
.PHONY: doctor setup demo-local verify check-state reset-local deploy-v1 seed-v1 upgrade-v2 demo-transfer sync-artifacts sync-artifacts-check sync-abis publish-web-manifest test-finalize-manifest
|
||||||
doctor:
|
doctor:
|
||||||
@bash tools/doctor.sh
|
@bash tools/doctor.sh
|
||||||
setup:
|
setup:
|
||||||
@@ -51,5 +52,14 @@ deploy-v1:
|
|||||||
@node tools/select-manifest.mjs anvil
|
@node tools/select-manifest.mjs anvil
|
||||||
seed-v1:
|
seed-v1:
|
||||||
@forge script script/SeedV1Demo.s.sol:SeedV1Demo --rpc-url $(RPC_LOCAL) --broadcast --force
|
@forge script script/SeedV1Demo.s.sol:SeedV1Demo --rpc-url $(RPC_LOCAL) --broadcast --force
|
||||||
|
upgrade-v2:
|
||||||
|
@SCRIPT_SENDER=$(ANVIL_OWNER) DEPLOYMENT_MANIFEST_PATH=deployments/upgrade-pending.json npm_config_offline=true forge script script/UpgradeV2.s.sol:UpgradeV2 --rpc-url $(RPC_LOCAL) --sender $(ANVIL_OWNER) --broadcast --force
|
||||||
|
@node tools/finalize-manifest.mjs upgrade --rpc-url $(RPC_LOCAL)
|
||||||
|
@DEMO_EXPECTED_STAGE=invariants forge script script/CheckState.s.sol:CheckState --rpc-url $(RPC_LOCAL) --force
|
||||||
|
@node tools/sync-web-artifacts.mjs
|
||||||
|
@node tools/publish-web-manifest.mjs
|
||||||
|
demo-transfer:
|
||||||
|
@forge script script/TransferV2Demo.s.sol:TransferV2Demo --rpc-url $(RPC_LOCAL) --sender $(ANVIL_ALICE) --broadcast --force
|
||||||
|
@DEMO_EXPECTED_STAGE=v2 $(MAKE) check-state
|
||||||
check-state:
|
check-state:
|
||||||
@forge script script/CheckState.s.sol:CheckState --rpc-url $(RPC_LOCAL) --force
|
@forge script script/CheckState.s.sol:CheckState --rpc-url $(RPC_LOCAL) --force
|
||||||
|
|||||||
+19
-2
@@ -12,11 +12,14 @@ contract CheckState is DemoScript {
|
|||||||
error UnknownStage(string stage);
|
error UnknownStage(string stage);
|
||||||
|
|
||||||
function run() external view {
|
function run() external view {
|
||||||
|
_run(_manifestPath(ACTIVE_MANIFEST_PATH), vm.envOr("DEMO_EXPECTED_STAGE", string("v1")));
|
||||||
|
}
|
||||||
|
|
||||||
|
function _run(string memory manifestPath, string memory stage) internal view {
|
||||||
_requireSupportedChain(block.chainid);
|
_requireSupportedChain(block.chainid);
|
||||||
_printEducationalWarning();
|
_printEducationalWarning();
|
||||||
string memory stage = vm.envOr("DEMO_EXPECTED_STAGE", string("v1"));
|
|
||||||
bool deployedStage = keccak256(bytes(stage)) == keccak256("deployed");
|
bool deployedStage = keccak256(bytes(stage)) == keccak256("deployed");
|
||||||
Manifest memory manifest = _readManifest(_manifestPath(ACTIVE_MANIFEST_PATH), !deployedStage);
|
Manifest memory manifest = _readManifest(manifestPath, !deployedStage);
|
||||||
BankV1 bank = BankV1(manifest.proxy);
|
BankV1 bank = BankV1(manifest.proxy);
|
||||||
MockUSDC token = MockUSDC(manifest.token);
|
MockUSDC token = MockUSDC(manifest.token);
|
||||||
|
|
||||||
@@ -53,6 +56,20 @@ contract CheckState is DemoScript {
|
|||||||
} else if (stageHash == keccak256("v1")) {
|
} else if (stageHash == keccak256("v1")) {
|
||||||
_assertV1State(manifest);
|
_assertV1State(manifest);
|
||||||
_assertUint("surplus", 0, surplus);
|
_assertUint("surplus", 0, surplus);
|
||||||
|
} else if (stageHash == keccak256("upgraded")) {
|
||||||
|
_assertUint("Alice internal balance", 900e6, bank.balanceOf(manifest.actors[1].address_));
|
||||||
|
_assertUint("Bob internal balance", 500e6, bank.balanceOf(manifest.actors[2].address_));
|
||||||
|
_assertUint("liabilities", 1_400e6, liabilities);
|
||||||
|
_assertUint("reserves", 1_400e6, reserves);
|
||||||
|
_assertUint("surplus", 0, surplus);
|
||||||
|
_assertUint("version", 2, bank.contractVersion());
|
||||||
|
} else if (stageHash == keccak256("v2")) {
|
||||||
|
_assertUint("Alice internal balance", 650e6, bank.balanceOf(manifest.actors[1].address_));
|
||||||
|
_assertUint("Bob internal balance", 750e6, bank.balanceOf(manifest.actors[2].address_));
|
||||||
|
_assertUint("liabilities", 1_400e6, liabilities);
|
||||||
|
_assertUint("reserves", 1_400e6, reserves);
|
||||||
|
_assertUint("surplus", 0, surplus);
|
||||||
|
_assertUint("version", 2, bank.contractVersion());
|
||||||
} else if (stageHash != keccak256("invariants")) {
|
} else if (stageHash != keccak256("invariants")) {
|
||||||
revert UnknownStage(stage);
|
revert UnknownStage(stage);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
pragma solidity 0.8.35;
|
||||||
|
|
||||||
|
import {BankV2} from "../src/BankV2.sol";
|
||||||
|
import {MockUSDC} from "../src/MockUSDC.sol";
|
||||||
|
import {DemoScript} from "./lib/DemoScript.sol";
|
||||||
|
|
||||||
|
contract TransferV2Demo is DemoScript {
|
||||||
|
function run() external {
|
||||||
|
_run(_manifestPath(ACTIVE_MANIFEST_PATH));
|
||||||
|
}
|
||||||
|
|
||||||
|
function _run(string memory manifestPath) internal {
|
||||||
|
if (block.chainid != ANVIL_CHAIN_ID) revert UnsupportedChain(block.chainid);
|
||||||
|
_printEducationalWarning();
|
||||||
|
Manifest memory manifest = _readManifest(manifestPath, true);
|
||||||
|
(uint256 aliceKey, address alice) = _deriveLocalActor(block.chainid, 1);
|
||||||
|
address bob = manifest.actors[2].address_;
|
||||||
|
_assertAddress("Alice actor", manifest.actors[1].address_, alice);
|
||||||
|
|
||||||
|
BankV2 bank = BankV2(manifest.proxy);
|
||||||
|
MockUSDC token = MockUSDC(manifest.token);
|
||||||
|
_assertUint("version", 2, bank.contractVersion());
|
||||||
|
_assertUint("Alice internal balance", 900e6, bank.balanceOf(alice));
|
||||||
|
_assertUint("Bob internal balance", 500e6, bank.balanceOf(bob));
|
||||||
|
uint256 liabilities = bank.totalLiabilities();
|
||||||
|
uint256 reserves = token.balanceOf(manifest.proxy);
|
||||||
|
_assertUint("liabilities", 1_400e6, liabilities);
|
||||||
|
_assertUint("reserves", 1_400e6, reserves);
|
||||||
|
|
||||||
|
vm.startBroadcast(aliceKey);
|
||||||
|
bank.transferBalance(bob, 250e6);
|
||||||
|
vm.stopBroadcast();
|
||||||
|
|
||||||
|
_assertUint("Alice internal balance", 650e6, bank.balanceOf(alice));
|
||||||
|
_assertUint("Bob internal balance", 750e6, bank.balanceOf(bob));
|
||||||
|
_assertUint("liabilities", liabilities, bank.totalLiabilities());
|
||||||
|
_assertUint("reserves", reserves, token.balanceOf(manifest.proxy));
|
||||||
|
_assertUint("surplus", 0, token.balanceOf(manifest.proxy) - bank.totalLiabilities());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,183 @@
|
|||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
pragma solidity 0.8.35;
|
||||||
|
|
||||||
|
import {Options, Upgrades} from "openzeppelin-foundry-upgrades/Upgrades.sol";
|
||||||
|
import {console2} from "forge-std/console2.sol";
|
||||||
|
import {BankV1} from "../src/BankV1.sol";
|
||||||
|
import {MockUSDC} from "../src/MockUSDC.sol";
|
||||||
|
import {DemoScript} from "./lib/DemoScript.sol";
|
||||||
|
|
||||||
|
contract UpgradeV2 is DemoScript {
|
||||||
|
string internal constant UPGRADE_PENDING_MANIFEST_PATH = "deployments/upgrade-pending.json";
|
||||||
|
|
||||||
|
error UnexpectedVersion(uint256 version);
|
||||||
|
|
||||||
|
struct Snapshot {
|
||||||
|
address proxy;
|
||||||
|
address implementation;
|
||||||
|
address owner;
|
||||||
|
address asset;
|
||||||
|
bool paused;
|
||||||
|
uint256[] balances;
|
||||||
|
uint256 liabilities;
|
||||||
|
uint256 reserves;
|
||||||
|
uint256 surplus;
|
||||||
|
uint256 deploymentBlock;
|
||||||
|
uint256 version;
|
||||||
|
}
|
||||||
|
|
||||||
|
function run() external returns (bool upgraded, address implementation) {
|
||||||
|
_requireSupportedChain(block.chainid);
|
||||||
|
_printEducationalWarning();
|
||||||
|
string memory activePath = vm.envOr("UPGRADE_ACTIVE_MANIFEST_PATH", ACTIVE_MANIFEST_PATH);
|
||||||
|
return _run(activePath, _manifestPath(UPGRADE_PENDING_MANIFEST_PATH), vm.envAddress("SCRIPT_SENDER"));
|
||||||
|
}
|
||||||
|
|
||||||
|
function _run(string memory activePath, string memory pendingPath, address sender)
|
||||||
|
internal
|
||||||
|
returns (bool upgraded, address implementation)
|
||||||
|
{
|
||||||
|
Manifest memory manifest = _readManifest(activePath, true);
|
||||||
|
BankV1 bank = BankV1(manifest.proxy);
|
||||||
|
|
||||||
|
address actualImplementation = Upgrades.getImplementationAddress(manifest.proxy);
|
||||||
|
_assertAddress("implementation", manifest.implementation, actualImplementation);
|
||||||
|
_assertAddress("owner", manifest.owner, bank.owner());
|
||||||
|
_assertAddress("asset", manifest.token, address(bank.asset()));
|
||||||
|
_assertAddress("SCRIPT_SENDER", bank.owner(), sender);
|
||||||
|
|
||||||
|
uint256 version = bank.contractVersion();
|
||||||
|
if (version == 2) {
|
||||||
|
implementation = actualImplementation;
|
||||||
|
_writeNoopMarker(pendingPath, manifest, vm.getNonce(bank.owner()), block.number);
|
||||||
|
console2.log("BankV2 already active; no upgrade broadcast.");
|
||||||
|
return (false, implementation);
|
||||||
|
}
|
||||||
|
if (version != 1) revert UnexpectedVersion(version);
|
||||||
|
|
||||||
|
Snapshot memory before_ = _snapshot(manifest);
|
||||||
|
Options memory opts;
|
||||||
|
opts.referenceContract = "BankV1.sol:BankV1";
|
||||||
|
Upgrades.validateUpgrade("BankV2.sol:BankV2", opts);
|
||||||
|
|
||||||
|
if (block.chainid == ANVIL_CHAIN_ID) {
|
||||||
|
(uint256 ownerKey, address derivedOwner) = _deriveLocalActor(block.chainid, 0);
|
||||||
|
_assertAddress("local owner", sender, derivedOwner);
|
||||||
|
vm.startBroadcast(ownerKey);
|
||||||
|
} else {
|
||||||
|
vm.startBroadcast(sender);
|
||||||
|
}
|
||||||
|
Upgrades.upgradeProxy(manifest.proxy, "BankV2.sol:BankV2", "", opts);
|
||||||
|
vm.stopBroadcast();
|
||||||
|
|
||||||
|
Snapshot memory after_ = _snapshot(manifest);
|
||||||
|
implementation = after_.implementation;
|
||||||
|
_requireCode("implementation", implementation);
|
||||||
|
if (implementation == before_.implementation) {
|
||||||
|
revert UnexpectedAddress("implementation changed", before_.implementation, implementation);
|
||||||
|
}
|
||||||
|
_assertUint("version", 2, after_.version);
|
||||||
|
_assertSnapshotUnchanged(before_, after_);
|
||||||
|
_writeUpgradeMarker(pendingPath, manifest, before_, implementation);
|
||||||
|
return (true, implementation);
|
||||||
|
}
|
||||||
|
|
||||||
|
function _snapshot(Manifest memory manifest) internal view returns (Snapshot memory snapshot) {
|
||||||
|
BankV1 bank = BankV1(manifest.proxy);
|
||||||
|
uint256 reserves = MockUSDC(manifest.token).balanceOf(manifest.proxy);
|
||||||
|
uint256 liabilities = bank.totalLiabilities();
|
||||||
|
snapshot.proxy = manifest.proxy;
|
||||||
|
snapshot.implementation = Upgrades.getImplementationAddress(manifest.proxy);
|
||||||
|
snapshot.owner = bank.owner();
|
||||||
|
snapshot.asset = address(bank.asset());
|
||||||
|
snapshot.paused = bank.paused();
|
||||||
|
snapshot.balances = new uint256[](manifest.actors.length);
|
||||||
|
for (uint256 i; i < manifest.actors.length; ++i) {
|
||||||
|
snapshot.balances[i] = bank.balanceOf(manifest.actors[i].address_);
|
||||||
|
}
|
||||||
|
snapshot.liabilities = liabilities;
|
||||||
|
snapshot.reserves = reserves;
|
||||||
|
snapshot.surplus = reserves - liabilities;
|
||||||
|
snapshot.deploymentBlock = manifest.deploymentBlock;
|
||||||
|
snapshot.version = bank.contractVersion();
|
||||||
|
}
|
||||||
|
|
||||||
|
function _assertSnapshotUnchanged(Snapshot memory before_, Snapshot memory after_) internal pure {
|
||||||
|
_assertAddress("proxy", before_.proxy, after_.proxy);
|
||||||
|
_assertAddress("owner", before_.owner, after_.owner);
|
||||||
|
_assertAddress("asset", before_.asset, after_.asset);
|
||||||
|
_assertUint("paused", before_.paused ? 1 : 0, after_.paused ? 1 : 0);
|
||||||
|
_assertUint("actor count", before_.balances.length, after_.balances.length);
|
||||||
|
for (uint256 i; i < before_.balances.length; ++i) {
|
||||||
|
_assertUint("actor balance", before_.balances[i], after_.balances[i]);
|
||||||
|
}
|
||||||
|
_assertUint("liabilities", before_.liabilities, after_.liabilities);
|
||||||
|
_assertUint("reserves", before_.reserves, after_.reserves);
|
||||||
|
_assertUint("surplus", before_.surplus, after_.surplus);
|
||||||
|
_assertUint("deployment block", before_.deploymentBlock, after_.deploymentBlock);
|
||||||
|
}
|
||||||
|
|
||||||
|
function _writeNoopMarker(string memory path, Manifest memory manifest, uint256 ownerNonce, uint256 observedBlock)
|
||||||
|
private
|
||||||
|
{
|
||||||
|
vm.writeJson(
|
||||||
|
string.concat(
|
||||||
|
'{"mode":"noop","chainId":',
|
||||||
|
vm.toString(manifest.chainId),
|
||||||
|
',"observedBlock":',
|
||||||
|
vm.toString(observedBlock),
|
||||||
|
',"ownerNonce":',
|
||||||
|
vm.toString(ownerNonce),
|
||||||
|
',"proxy":"',
|
||||||
|
vm.toString(manifest.proxy),
|
||||||
|
'","implementation":"',
|
||||||
|
vm.toString(manifest.implementation),
|
||||||
|
'"}'
|
||||||
|
),
|
||||||
|
path
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function _writeUpgradeMarker(
|
||||||
|
string memory path,
|
||||||
|
Manifest memory manifest,
|
||||||
|
Snapshot memory before_,
|
||||||
|
address implementation
|
||||||
|
) private {
|
||||||
|
string memory balances = "[";
|
||||||
|
for (uint256 i; i < manifest.actors.length; ++i) {
|
||||||
|
if (i != 0) balances = string.concat(balances, ",");
|
||||||
|
balances = string.concat(
|
||||||
|
balances,
|
||||||
|
'{"address":"',
|
||||||
|
vm.toString(manifest.actors[i].address_),
|
||||||
|
'","balance":',
|
||||||
|
vm.toString(before_.balances[i]),
|
||||||
|
"}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
balances = string.concat(balances, "]");
|
||||||
|
string memory snapshot = string.concat('{"proxy":"', vm.toString(before_.proxy), '"');
|
||||||
|
snapshot = string.concat(snapshot, ',"implementation":"', vm.toString(before_.implementation), '"');
|
||||||
|
snapshot = string.concat(snapshot, ',"owner":"', vm.toString(before_.owner), '"');
|
||||||
|
snapshot = string.concat(snapshot, ',"asset":"', vm.toString(before_.asset), '"');
|
||||||
|
snapshot = string.concat(snapshot, ',"paused":', vm.toString(before_.paused));
|
||||||
|
snapshot = string.concat(snapshot, ',"balances":', balances);
|
||||||
|
snapshot = string.concat(snapshot, ',"liabilities":', vm.toString(before_.liabilities));
|
||||||
|
snapshot = string.concat(snapshot, ',"reserves":', vm.toString(before_.reserves));
|
||||||
|
snapshot = string.concat(snapshot, ',"surplus":', vm.toString(before_.surplus));
|
||||||
|
snapshot = string.concat(snapshot, ',"deploymentBlock":', vm.toString(before_.deploymentBlock));
|
||||||
|
snapshot = string.concat(snapshot, ',"version":', vm.toString(before_.version), "}");
|
||||||
|
|
||||||
|
string memory json = string.concat('{"mode":"upgrade","network":"', manifest.network, '"');
|
||||||
|
json = string.concat(json, ',"chainId":', vm.toString(manifest.chainId));
|
||||||
|
json = string.concat(json, ',"token":"', vm.toString(manifest.token), '"');
|
||||||
|
json = string.concat(json, ',"proxy":"', vm.toString(manifest.proxy), '"');
|
||||||
|
json = string.concat(json, ',"previousImplementation":"', vm.toString(manifest.implementation), '"');
|
||||||
|
json = string.concat(json, ',"implementation":"', vm.toString(implementation), '"');
|
||||||
|
json = string.concat(json, ',"owner":"', vm.toString(manifest.owner), '"');
|
||||||
|
json = string.concat(json, ',"deploymentBlock":', vm.toString(manifest.deploymentBlock));
|
||||||
|
json = string.concat(json, ',"snapshot":', snapshot, "}");
|
||||||
|
vm.writeJson(json, path);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,26 +2,45 @@
|
|||||||
pragma solidity 0.8.35;
|
pragma solidity 0.8.35;
|
||||||
|
|
||||||
import {BankTestBase} from "./helpers/BankTestBase.sol";
|
import {BankTestBase} from "./helpers/BankTestBase.sol";
|
||||||
import {BankHandler} from "./helpers/BankHandler.sol";
|
import {BankV2} from "../src/BankV2.sol";
|
||||||
|
import {Options, Upgrades} from "openzeppelin-foundry-upgrades/Upgrades.sol";
|
||||||
|
import {BankV2Handler} from "./helpers/BankV2Handler.sol";
|
||||||
|
|
||||||
contract BankInvariantTest is BankTestBase {
|
contract BankInvariantTest is BankTestBase {
|
||||||
BankHandler internal handler;
|
BankV2Handler internal handler;
|
||||||
|
|
||||||
function setUp() public override {
|
function setUp() public override {
|
||||||
super.setUp();
|
super.setUp();
|
||||||
|
Options memory opts;
|
||||||
|
opts.referenceContract = "BankV1.sol:BankV1";
|
||||||
|
Upgrades.upgradeProxy(proxy, "BankV2.sol:BankV2", "", opts, owner);
|
||||||
|
BankV2 bankV2 = BankV2(proxy);
|
||||||
|
|
||||||
handler = new BankHandler(token, bank);
|
handler = new BankV2Handler(token, bankV2);
|
||||||
vm.prank(owner);
|
vm.prank(owner);
|
||||||
token.transferOwnership(address(handler));
|
token.transferOwnership(address(handler));
|
||||||
|
|
||||||
targetContract(address(handler));
|
targetContract(address(handler));
|
||||||
bytes4[] memory selectors = new bytes4[](3);
|
bytes4[] memory selectors = new bytes4[](4);
|
||||||
selectors[0] = handler.deposit.selector;
|
selectors[0] = handler.deposit.selector;
|
||||||
selectors[1] = handler.withdraw.selector;
|
selectors[1] = handler.withdraw.selector;
|
||||||
selectors[2] = handler.donate.selector;
|
selectors[2] = handler.donate.selector;
|
||||||
|
selectors[3] = handler.transfer.selector;
|
||||||
targetSelector(FuzzSelector({addr: address(handler), selectors: selectors}));
|
targetSelector(FuzzSelector({addr: address(handler), selectors: selectors}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function testHandlerExecutesDeterministicTrackedTransfer() public {
|
||||||
|
handler.deposit(0, 40e6);
|
||||||
|
|
||||||
|
handler.transfer(0, 0, 15e6);
|
||||||
|
|
||||||
|
assertEq(bank.balanceOf(handler.actorAt(0)), 25e6);
|
||||||
|
assertEq(bank.balanceOf(handler.actorAt(1)), 15e6);
|
||||||
|
assertEq(handler.ghostTransferred(), 15e6);
|
||||||
|
assertEq(bank.totalLiabilities(), 40e6);
|
||||||
|
assertEq(token.balanceOf(address(bank)), 40e6);
|
||||||
|
}
|
||||||
|
|
||||||
function invariant_liabilitiesEqualTrackedBalances() public view {
|
function invariant_liabilitiesEqualTrackedBalances() public view {
|
||||||
uint256 sum;
|
uint256 sum;
|
||||||
for (uint256 i; i < handler.actorCount(); ++i) {
|
for (uint256 i; i < handler.actorCount(); ++i) {
|
||||||
|
|||||||
+244
-7
@@ -2,11 +2,15 @@
|
|||||||
pragma solidity 0.8.35;
|
pragma solidity 0.8.35;
|
||||||
|
|
||||||
import {Test} from "forge-std/Test.sol";
|
import {Test} from "forge-std/Test.sol";
|
||||||
|
import {Upgrades} from "openzeppelin-foundry-upgrades/Upgrades.sol";
|
||||||
import {DemoScript} from "../script/lib/DemoScript.sol";
|
import {DemoScript} from "../script/lib/DemoScript.sol";
|
||||||
import {DeployV1} from "../script/DeployV1.s.sol";
|
import {DeployV1} from "../script/DeployV1.s.sol";
|
||||||
import {SeedV1Demo} from "../script/SeedV1Demo.s.sol";
|
import {SeedV1Demo} from "../script/SeedV1Demo.s.sol";
|
||||||
import {CheckState} from "../script/CheckState.s.sol";
|
import {CheckState} from "../script/CheckState.s.sol";
|
||||||
|
import {UpgradeV2} from "../script/UpgradeV2.s.sol";
|
||||||
|
import {TransferV2Demo} from "../script/TransferV2Demo.s.sol";
|
||||||
import {BankV1} from "../src/BankV1.sol";
|
import {BankV1} from "../src/BankV1.sol";
|
||||||
|
import {BankV2} from "../src/BankV2.sol";
|
||||||
import {MockUSDC} from "../src/MockUSDC.sol";
|
import {MockUSDC} from "../src/MockUSDC.sol";
|
||||||
|
|
||||||
contract ScriptPreflightHarness is DemoScript {
|
contract ScriptPreflightHarness is DemoScript {
|
||||||
@@ -35,12 +39,39 @@ contract ScriptPreflightHarness is DemoScript {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
contract UpgradeV2Harness is UpgradeV2 {
|
||||||
|
function assertSnapshotUnchanged(Snapshot calldata before_, Snapshot calldata after_) external pure {
|
||||||
|
_assertSnapshotUnchanged(before_, after_);
|
||||||
|
}
|
||||||
|
|
||||||
|
function runWithPaths(string calldata activePath, string calldata pendingPath, address sender)
|
||||||
|
external
|
||||||
|
returns (bool upgraded, address implementation)
|
||||||
|
{
|
||||||
|
_requireSupportedChain(block.chainid);
|
||||||
|
return _run(activePath, pendingPath, sender);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
contract TransferV2DemoHarness is TransferV2Demo {
|
||||||
|
function runWithPath(string calldata manifestPath) external {
|
||||||
|
_run(manifestPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
contract CheckStateHarness is CheckState {
|
||||||
|
function runWithPath(string calldata manifestPath, string calldata stage) external view {
|
||||||
|
_run(manifestPath, stage);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
contract ScriptPreflightTest is Test {
|
contract ScriptPreflightTest is Test {
|
||||||
ScriptPreflightHarness internal harness;
|
ScriptPreflightHarness internal harness;
|
||||||
string internal fixtureDir;
|
string internal fixtureDir;
|
||||||
|
|
||||||
address internal constant OWNER = 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266;
|
address internal constant OWNER = 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266;
|
||||||
address internal constant ALICE = 0x70997970C51812dc3A010C7d01b50e0d17dc79C8;
|
address internal constant ALICE = 0x70997970C51812dc3A010C7d01b50e0d17dc79C8;
|
||||||
|
address internal constant BOB = 0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC;
|
||||||
address internal constant TOKEN = 0x1000000000000000000000000000000000000001;
|
address internal constant TOKEN = 0x1000000000000000000000000000000000000001;
|
||||||
address internal constant PROXY = 0x2000000000000000000000000000000000000002;
|
address internal constant PROXY = 0x2000000000000000000000000000000000000002;
|
||||||
address internal constant IMPLEMENTATION = 0x3000000000000000000000000000000000000003;
|
address internal constant IMPLEMENTATION = 0x3000000000000000000000000000000000000003;
|
||||||
@@ -302,7 +333,7 @@ contract ScriptPreflightTest is Test {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function testSeedV1DemoExecutesExactActOneStateAndCheckStateAcceptsIt() public {
|
function testSeedV1DemoExecutesExactActOneStateAndCheckStateAcceptsIt() public {
|
||||||
(string memory path, DemoScript.Manifest memory manifest) = _deployFixture();
|
(string memory path, DemoScript.Manifest memory manifest) = _deployFixtureNamed("seed-v1");
|
||||||
vm.setEnv("DEPLOYMENT_MANIFEST_PATH", path);
|
vm.setEnv("DEPLOYMENT_MANIFEST_PATH", path);
|
||||||
|
|
||||||
new SeedV1Demo().run();
|
new SeedV1Demo().run();
|
||||||
@@ -320,18 +351,176 @@ contract ScriptPreflightTest is Test {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function testCheckStateRejectsManifestImplementationMismatch() public {
|
function testCheckStateRejectsManifestImplementationMismatch() public {
|
||||||
(string memory path, DemoScript.Manifest memory manifest) = _deployFixture();
|
(string memory path, DemoScript.Manifest memory manifest) = _deployUpgradeFixtureNamed("check-mismatch");
|
||||||
vm.writeJson(string.concat('"', vm.toString(manifest.token), '"'), path, ".implementation");
|
vm.writeJson(string.concat('"', vm.toString(manifest.token), '"'), path, ".implementation");
|
||||||
vm.setEnv("DEPLOYMENT_MANIFEST_PATH", path);
|
CheckStateHarness checker = new CheckStateHarness();
|
||||||
vm.setEnv("DEMO_EXPECTED_STAGE", "deployed");
|
|
||||||
CheckState checker = new CheckState();
|
|
||||||
|
|
||||||
vm.expectRevert(
|
vm.expectRevert(
|
||||||
abi.encodeWithSelector(
|
abi.encodeWithSelector(
|
||||||
DemoScript.UnexpectedAddress.selector, "implementation", manifest.token, manifest.implementation
|
DemoScript.UnexpectedAddress.selector, "implementation", manifest.token, manifest.implementation
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
checker.run();
|
checker.runWithPath(path, "deployed");
|
||||||
|
}
|
||||||
|
|
||||||
|
function testUpgradeV2RequiresManifestIdentityCodeOwnerSenderAndVersionOne() public {
|
||||||
|
(string memory path, DemoScript.Manifest memory manifest) = _deployUpgradeFixtureNamed("upgrade-preflight");
|
||||||
|
string memory pending = string.concat(fixtureDir, "/upgrade-pending.json");
|
||||||
|
UpgradeV2Harness upgrader = new UpgradeV2Harness();
|
||||||
|
|
||||||
|
vm.expectRevert(
|
||||||
|
abi.encodeWithSelector(DemoScript.UnexpectedAddress.selector, "SCRIPT_SENDER", manifest.owner, ALICE)
|
||||||
|
);
|
||||||
|
upgrader.runWithPaths(path, pending, ALICE);
|
||||||
|
|
||||||
|
vm.writeJson(string.concat('"', vm.toString(manifest.token), '"'), path, ".implementation");
|
||||||
|
vm.expectRevert(
|
||||||
|
abi.encodeWithSelector(
|
||||||
|
DemoScript.UnexpectedAddress.selector, "implementation", manifest.token, manifest.implementation
|
||||||
|
)
|
||||||
|
);
|
||||||
|
upgrader.runWithPaths(path, pending, OWNER);
|
||||||
|
}
|
||||||
|
|
||||||
|
function testUpgradeV2RejectsUnsupportedChainMissingProxyCodeWrongOwnerAndUnexpectedVersion() public {
|
||||||
|
UpgradeV2Harness upgrader = new UpgradeV2Harness();
|
||||||
|
vm.chainId(1);
|
||||||
|
vm.expectRevert(abi.encodeWithSelector(DemoScript.UnsupportedChain.selector, uint256(1)));
|
||||||
|
upgrader.runWithPaths("unused", "unused", OWNER);
|
||||||
|
|
||||||
|
vm.chainId(31337);
|
||||||
|
(string memory missingPath,) = _deployUpgradeFixtureNamed("upgrade-missing-proxy");
|
||||||
|
vm.writeJson('"0x4000000000000000000000000000000000000004"', missingPath, ".proxy");
|
||||||
|
vm.expectRevert(
|
||||||
|
abi.encodeWithSelector(DemoScript.MissingCode.selector, "proxy", 0x4000000000000000000000000000000000000004)
|
||||||
|
);
|
||||||
|
upgrader.runWithPaths(missingPath, string.concat(fixtureDir, "/missing-pending.json"), OWNER);
|
||||||
|
|
||||||
|
(string memory ownerPath, DemoScript.Manifest memory ownerManifest) =
|
||||||
|
_deployUpgradeFixtureNamed("upgrade-owner");
|
||||||
|
vm.prank(OWNER);
|
||||||
|
BankV1(ownerManifest.proxy).transferOwnership(ALICE);
|
||||||
|
vm.expectRevert(abi.encodeWithSelector(DemoScript.UnexpectedAddress.selector, "owner", OWNER, ALICE));
|
||||||
|
upgrader.runWithPaths(ownerPath, string.concat(fixtureDir, "/owner-pending.json"), ALICE);
|
||||||
|
|
||||||
|
(string memory versionPath, DemoScript.Manifest memory versionManifest) =
|
||||||
|
_deployUpgradeFixtureNamed("upgrade-version");
|
||||||
|
vm.mockCall(
|
||||||
|
versionManifest.proxy, abi.encodeWithSelector(BankV1.contractVersion.selector), abi.encode(uint256(3))
|
||||||
|
);
|
||||||
|
vm.expectRevert(abi.encodeWithSelector(UpgradeV2.UnexpectedVersion.selector, uint256(3)));
|
||||||
|
upgrader.runWithPaths(versionPath, string.concat(fixtureDir, "/version-pending.json"), OWNER);
|
||||||
|
}
|
||||||
|
|
||||||
|
function testUpgradeV2PreservesCompleteSnapshotAndWritesOnlyStagingRecord() public {
|
||||||
|
(string memory path, DemoScript.Manifest memory manifest) = _deployUpgradeFixtureNamed("upgrade-preserve");
|
||||||
|
_seedState(manifest);
|
||||||
|
string memory beforeManifest = vm.readFile(path);
|
||||||
|
string memory pending = string.concat(fixtureDir, "/upgrade-pending.json");
|
||||||
|
|
||||||
|
(bool upgraded, address implementation) = new UpgradeV2Harness().runWithPaths(path, pending, OWNER);
|
||||||
|
|
||||||
|
assertTrue(upgraded);
|
||||||
|
assertNotEq(implementation, manifest.implementation);
|
||||||
|
assertEq(vm.readFile(path), beforeManifest);
|
||||||
|
BankV2 bankV2 = BankV2(manifest.proxy);
|
||||||
|
assertEq(bankV2.contractVersion(), 2);
|
||||||
|
assertEq(bankV2.owner(), manifest.owner);
|
||||||
|
assertEq(address(bankV2.asset()), manifest.token);
|
||||||
|
assertFalse(bankV2.paused());
|
||||||
|
assertEq(bankV2.balanceOf(ALICE), 900e6);
|
||||||
|
assertEq(bankV2.balanceOf(BOB), 500e6);
|
||||||
|
assertEq(bankV2.totalLiabilities(), 1_400e6);
|
||||||
|
assertEq(MockUSDC(manifest.token).balanceOf(manifest.proxy), 1_400e6);
|
||||||
|
string memory marker = vm.readFile(pending);
|
||||||
|
assertEq(vm.parseJsonString(marker, ".mode"), "upgrade");
|
||||||
|
assertEq(vm.parseJsonAddress(marker, ".implementation"), implementation);
|
||||||
|
assertEq(vm.parseJsonAddress(marker, ".snapshot.proxy"), manifest.proxy);
|
||||||
|
assertEq(vm.parseJsonUint(marker, ".snapshot.balances[1].balance"), 900e6);
|
||||||
|
assertEq(vm.parseJsonUint(marker, ".snapshot.balances[2].balance"), 500e6);
|
||||||
|
}
|
||||||
|
|
||||||
|
function testUpgradeV2AlreadyActiveWritesVerifiableNoopWithoutChangingImplementation() public {
|
||||||
|
(string memory path, DemoScript.Manifest memory manifest) = _deployUpgradeFixtureNamed("upgrade-noop");
|
||||||
|
string memory pending = string.concat(fixtureDir, "/upgrade-pending.json");
|
||||||
|
UpgradeV2Harness upgrader = new UpgradeV2Harness();
|
||||||
|
(, address implementation) = upgrader.runWithPaths(path, pending, OWNER);
|
||||||
|
vm.writeJson(string.concat('"', vm.toString(implementation), '"'), path, ".implementation");
|
||||||
|
uint256 nonceBefore = vm.getNonce(OWNER);
|
||||||
|
|
||||||
|
(bool upgraded, address observedImplementation) = upgrader.runWithPaths(path, pending, OWNER);
|
||||||
|
|
||||||
|
assertFalse(upgraded);
|
||||||
|
assertEq(observedImplementation, implementation);
|
||||||
|
assertEq(vm.getNonce(OWNER), nonceBefore);
|
||||||
|
string memory marker = vm.readFile(pending);
|
||||||
|
assertEq(vm.parseJsonString(marker, ".mode"), "noop");
|
||||||
|
assertEq(vm.parseJsonUint(marker, ".chainId"), 31337);
|
||||||
|
assertEq(vm.parseJsonUint(marker, ".observedBlock"), block.number);
|
||||||
|
assertEq(vm.parseJsonUint(marker, ".ownerNonce"), nonceBefore);
|
||||||
|
assertEq(vm.parseJsonAddress(marker, ".proxy"), manifest.proxy);
|
||||||
|
assertEq(vm.parseJsonAddress(marker, ".implementation"), implementation);
|
||||||
|
}
|
||||||
|
|
||||||
|
function testSnapshotComparisonRejectsApplicationMutationButAllowsImplementationAndVersionChange() public {
|
||||||
|
UpgradeV2Harness upgradeHarness = new UpgradeV2Harness();
|
||||||
|
UpgradeV2.Snapshot memory before_ = _snapshot();
|
||||||
|
UpgradeV2.Snapshot memory after_ = _snapshot();
|
||||||
|
after_.implementation = address(0x9999);
|
||||||
|
after_.version = 2;
|
||||||
|
upgradeHarness.assertSnapshotUnchanged(before_, after_);
|
||||||
|
|
||||||
|
for (uint256 mutation; mutation < 10; ++mutation) {
|
||||||
|
before_ = _snapshot();
|
||||||
|
after_ = _snapshot();
|
||||||
|
if (mutation == 0) after_.proxy = address(0x9999);
|
||||||
|
else if (mutation == 1) after_.owner = address(0x9999);
|
||||||
|
else if (mutation == 2) after_.asset = address(0x9999);
|
||||||
|
else if (mutation == 3) after_.paused = true;
|
||||||
|
else if (mutation == 4) after_.balances = new uint256[](2);
|
||||||
|
else if (mutation == 5) after_.balances[1] += 1;
|
||||||
|
else if (mutation == 6) after_.liabilities += 1;
|
||||||
|
else if (mutation == 7) after_.reserves += 1;
|
||||||
|
else if (mutation == 8) after_.surplus += 1;
|
||||||
|
else after_.deploymentBlock += 1;
|
||||||
|
vm.expectRevert();
|
||||||
|
upgradeHarness.assertSnapshotUnchanged(before_, after_);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function testTransferV2DemoExecutesExactActThreeAndCheckStateAcceptsBothV2Stages() public {
|
||||||
|
(string memory path, DemoScript.Manifest memory manifest) = _deployUpgradeFixtureNamed("transfer-v2");
|
||||||
|
_seedState(manifest);
|
||||||
|
string memory pending = string.concat(fixtureDir, "/upgrade-pending.json");
|
||||||
|
(, address implementation) = new UpgradeV2Harness().runWithPaths(path, pending, OWNER);
|
||||||
|
vm.writeJson(string.concat('"', vm.toString(implementation), '"'), path, ".implementation");
|
||||||
|
|
||||||
|
new CheckStateHarness().runWithPath(path, "upgraded");
|
||||||
|
new TransferV2DemoHarness().runWithPath(path);
|
||||||
|
new CheckStateHarness().runWithPath(path, "v2");
|
||||||
|
|
||||||
|
BankV2 bankV2 = BankV2(manifest.proxy);
|
||||||
|
assertEq(bankV2.balanceOf(ALICE), 650e6);
|
||||||
|
assertEq(bankV2.balanceOf(BOB), 750e6);
|
||||||
|
assertEq(bankV2.totalLiabilities(), 1_400e6);
|
||||||
|
assertEq(MockUSDC(manifest.token).balanceOf(manifest.proxy), 1_400e6);
|
||||||
|
}
|
||||||
|
|
||||||
|
function _snapshot() internal pure returns (UpgradeV2.Snapshot memory snapshot) {
|
||||||
|
snapshot.proxy = PROXY;
|
||||||
|
snapshot.implementation = IMPLEMENTATION;
|
||||||
|
snapshot.owner = OWNER;
|
||||||
|
snapshot.asset = TOKEN;
|
||||||
|
snapshot.paused = false;
|
||||||
|
snapshot.balances = new uint256[](3);
|
||||||
|
snapshot.balances[0] = 10;
|
||||||
|
snapshot.balances[1] = 20;
|
||||||
|
snapshot.balances[2] = 30;
|
||||||
|
snapshot.liabilities = 60;
|
||||||
|
snapshot.reserves = 70;
|
||||||
|
snapshot.surplus = 10;
|
||||||
|
snapshot.deploymentBlock = 1;
|
||||||
|
snapshot.version = 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
function _writeManifest(
|
function _writeManifest(
|
||||||
@@ -365,7 +554,14 @@ contract ScriptPreflightTest is Test {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function _deployFixture() internal returns (string memory path, DemoScript.Manifest memory manifest) {
|
function _deployFixture() internal returns (string memory path, DemoScript.Manifest memory manifest) {
|
||||||
path = string.concat(fixtureDir, "/deployed.json");
|
return _deployFixtureNamed("deployed");
|
||||||
|
}
|
||||||
|
|
||||||
|
function _deployFixtureNamed(string memory name)
|
||||||
|
internal
|
||||||
|
returns (string memory path, DemoScript.Manifest memory manifest)
|
||||||
|
{
|
||||||
|
path = string.concat(fixtureDir, "/", name, ".json");
|
||||||
vm.chainId(31337);
|
vm.chainId(31337);
|
||||||
vm.setEnv("SCRIPT_SENDER", vm.toString(OWNER));
|
vm.setEnv("SCRIPT_SENDER", vm.toString(OWNER));
|
||||||
vm.setEnv("DEPLOYMENT_MANIFEST_PATH", path);
|
vm.setEnv("DEPLOYMENT_MANIFEST_PATH", path);
|
||||||
@@ -374,6 +570,47 @@ contract ScriptPreflightTest is Test {
|
|||||||
manifest = harness.readManifest(path, true);
|
manifest = harness.readManifest(path, true);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function _deployUpgradeFixtureNamed(string memory name)
|
||||||
|
internal
|
||||||
|
returns (string memory path, DemoScript.Manifest memory manifest)
|
||||||
|
{
|
||||||
|
vm.chainId(31337);
|
||||||
|
MockUSDC deployedToken = new MockUSDC(OWNER);
|
||||||
|
address deployedProxy = Upgrades.deployUUPSProxy(
|
||||||
|
"BankV1.sol:BankV1", abi.encodeCall(BankV1.initialize, (address(deployedToken), OWNER))
|
||||||
|
);
|
||||||
|
manifest.schemaVersion = 1;
|
||||||
|
manifest.network = "anvil";
|
||||||
|
manifest.chainId = 31337;
|
||||||
|
manifest.deploymentBlock = 1;
|
||||||
|
manifest.rpcUrl = "http://127.0.0.1:8545";
|
||||||
|
manifest.token = address(deployedToken);
|
||||||
|
manifest.proxy = deployedProxy;
|
||||||
|
manifest.implementation = Upgrades.getImplementationAddress(deployedProxy);
|
||||||
|
manifest.owner = OWNER;
|
||||||
|
manifest.actors = _actors();
|
||||||
|
path = string.concat(fixtureDir, "/", name, ".json");
|
||||||
|
vm.writeFile(path, harness.serializeManifest(manifest));
|
||||||
|
}
|
||||||
|
|
||||||
|
function _seedState(DemoScript.Manifest memory manifest) internal {
|
||||||
|
MockUSDC deployedToken = MockUSDC(manifest.token);
|
||||||
|
BankV1 deployedBank = BankV1(manifest.proxy);
|
||||||
|
vm.startPrank(OWNER);
|
||||||
|
deployedToken.mint(ALICE, 2_000e6);
|
||||||
|
deployedToken.mint(BOB, 1_000e6);
|
||||||
|
vm.stopPrank();
|
||||||
|
vm.startPrank(ALICE);
|
||||||
|
deployedToken.approve(manifest.proxy, 1_000e6);
|
||||||
|
deployedBank.deposit(1_000e6);
|
||||||
|
deployedBank.withdraw(100e6);
|
||||||
|
vm.stopPrank();
|
||||||
|
vm.startPrank(BOB);
|
||||||
|
deployedToken.approve(manifest.proxy, 500e6);
|
||||||
|
deployedBank.deposit(500e6);
|
||||||
|
vm.stopPrank();
|
||||||
|
}
|
||||||
|
|
||||||
function _writePublicAnvilManifest() internal returns (string memory path) {
|
function _writePublicAnvilManifest() internal returns (string memory path) {
|
||||||
path = string.concat(fixtureDir, "/public-anvil-manifest.json");
|
path = string.concat(fixtureDir, "/public-anvil-manifest.json");
|
||||||
vm.writeFile(
|
vm.writeFile(
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
pragma solidity 0.8.35;
|
||||||
|
|
||||||
|
import {BankV2} from "../../src/BankV2.sol";
|
||||||
|
import {MockUSDC} from "../../src/MockUSDC.sol";
|
||||||
|
import {BankHandler} from "./BankHandler.sol";
|
||||||
|
|
||||||
|
contract BankV2Handler is BankHandler {
|
||||||
|
BankV2 internal immutable bankV2;
|
||||||
|
|
||||||
|
uint256 public ghostTransferred;
|
||||||
|
|
||||||
|
constructor(MockUSDC token_, BankV2 bank_) BankHandler(token_, bank_) {
|
||||||
|
bankV2 = bank_;
|
||||||
|
}
|
||||||
|
|
||||||
|
function transfer(uint256 fromSeed, uint256 toSeed, uint256 amount) external {
|
||||||
|
uint256 count = actorCount();
|
||||||
|
uint256 fromIndex = fromSeed % count;
|
||||||
|
address from = actorAt(fromIndex);
|
||||||
|
uint256 balance = bankV2.balanceOf(from);
|
||||||
|
if (balance == 0) return;
|
||||||
|
|
||||||
|
uint256 toIndex = (fromIndex + 1 + (toSeed % (count - 1))) % count;
|
||||||
|
address to = actorAt(toIndex);
|
||||||
|
amount = bound(amount, 1, balance);
|
||||||
|
|
||||||
|
vm.prank(from);
|
||||||
|
bankV2.transferBalance(to, amount);
|
||||||
|
ghostTransferred += amount;
|
||||||
|
}
|
||||||
|
}
|
||||||
+201
-1
@@ -4,6 +4,7 @@ import { dirname, join, resolve } from "node:path";
|
|||||||
import { pathToFileURL } from "node:url";
|
import { pathToFileURL } from "node:url";
|
||||||
|
|
||||||
export const IMPLEMENTATION_SLOT = "0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc";
|
export const IMPLEMENTATION_SLOT = "0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc";
|
||||||
|
export const UPGRADED_TOPIC = "0xbc7cd75a20ee27fd9adebab32041f755214dbc6bffa90cc0225b39da2e5c2d3b";
|
||||||
export const EDUCATIONAL_WARNING = "Educational demo — mock token — never use real funds.";
|
export const EDUCATIONAL_WARNING = "Educational demo — mock token — never use real funds.";
|
||||||
|
|
||||||
const NETWORKS = {
|
const NETWORKS = {
|
||||||
@@ -88,6 +89,201 @@ export async function finalizeDeployment({ root = process.cwd(), rpc }) {
|
|||||||
return { path, manifest: confirmed };
|
return { path, manifest: confirmed };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function finalizeUpgrade({ root = process.cwd(), rpc }) {
|
||||||
|
if (typeof rpc !== "function") throw new Error("finalizer requires an RPC function");
|
||||||
|
const pendingPath = join(root, "deployments", "upgrade-pending.json");
|
||||||
|
const pending = await readJson(pendingPath);
|
||||||
|
if (pending?.mode !== "upgrade" && pending?.mode !== "noop") throw new Error("unknown upgrade staging mode");
|
||||||
|
|
||||||
|
const activePath = join(root, "deployments", "active.json");
|
||||||
|
const active = await readManifest(activePath);
|
||||||
|
const spec = networkSpec(active.network);
|
||||||
|
const canonicalPath = join(root, "deployments", spec.canonical);
|
||||||
|
const canonical = await readManifest(canonicalPath);
|
||||||
|
const [activeBytes, canonicalBytes] = await Promise.all([readFile(activePath), readFile(canonicalPath)]);
|
||||||
|
if (!activeBytes.equals(canonicalBytes) || JSON.stringify(active) !== JSON.stringify(canonical)) {
|
||||||
|
throw new Error("active and canonical manifest identity must match before upgrade finalization");
|
||||||
|
}
|
||||||
|
|
||||||
|
const chainId = parseRpcQuantity(await rpc("eth_chainId", []), "RPC chain ID");
|
||||||
|
if (chainId !== active.chainId) throw new Error("RPC chain ID does not match active manifest");
|
||||||
|
const methods = await readUpgradeMethods(root);
|
||||||
|
|
||||||
|
if (pending.mode === "noop") {
|
||||||
|
validateNoopMarker(pending, active);
|
||||||
|
const latestBlock = parseRpcQuantity(await rpc("eth_blockNumber", []), "latest block number");
|
||||||
|
if (latestBlock < pending.observedBlock) throw new Error("live block precedes no-op observation block");
|
||||||
|
const nonce = parseRpcQuantity(
|
||||||
|
await rpc("eth_getTransactionCount", [active.owner, "latest"]), "owner nonce"
|
||||||
|
);
|
||||||
|
if (nonce !== pending.ownerNonce) throw new Error("owner nonce changed after no-op observation");
|
||||||
|
await assertLiveVersionAndImplementation({ rpc, active, implementation: pending.implementation, methods });
|
||||||
|
await rm(pendingPath);
|
||||||
|
return { mode: "noop", path: canonicalPath, manifest: active };
|
||||||
|
}
|
||||||
|
|
||||||
|
validateUpgradeMarker(pending, active);
|
||||||
|
const broadcastPath = join(root, "broadcast", "UpgradeV2.s.sol", String(active.chainId), "run-latest.json");
|
||||||
|
const broadcast = await readJson(broadcastPath);
|
||||||
|
if (!Array.isArray(broadcast.transactions)) throw new Error("upgrade broadcast is partial: transactions are missing");
|
||||||
|
const hashes = [...new Set(broadcast.transactions.map((transaction) => transaction?.hash)
|
||||||
|
.filter((hash) => typeof hash === "string"))];
|
||||||
|
const resolvedTransactions = await Promise.all(hashes.map(async (hash) => ({
|
||||||
|
hash,
|
||||||
|
transaction: await rpc("eth_getTransactionByHash", [hash]),
|
||||||
|
})));
|
||||||
|
const matching = resolvedTransactions.filter(({ transaction }) =>
|
||||||
|
typeof transaction?.to === "string" && sameAddress(transaction.to, active.proxy));
|
||||||
|
if (matching.length !== 1) throw new Error(`expected exactly one live upgrade transaction to proxy, found ${matching.length}`);
|
||||||
|
const receipt = await rpc("eth_getTransactionReceipt", [matching[0].hash]);
|
||||||
|
if (!receipt || !isSuccessfulReceipt(receipt.status)) throw new Error("upgrade receipt was not successful");
|
||||||
|
if (!Array.isArray(receipt.logs) || !receipt.logs.some((log) => isUpgradeLog(log, active.proxy, pending.implementation))) {
|
||||||
|
throw new Error("successful upgrade receipt is missing the expected Upgraded event");
|
||||||
|
}
|
||||||
|
|
||||||
|
await assertLiveVersionAndImplementation({ rpc, active, implementation: pending.implementation, methods });
|
||||||
|
await assertUpgradeSnapshot({ rpc, active, pending, methods });
|
||||||
|
const updated = { ...active, implementation: pending.implementation };
|
||||||
|
validateManifest(updated);
|
||||||
|
await atomicWriteJson(canonicalPath, updated);
|
||||||
|
await atomicWriteJson(activePath, updated);
|
||||||
|
await rm(pendingPath);
|
||||||
|
return { mode: "upgrade", path: canonicalPath, manifest: updated };
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateNoopMarker(marker, active) {
|
||||||
|
assertExactKeys(marker, ["mode", "chainId", "observedBlock", "ownerNonce", "proxy", "implementation"], "no-op marker");
|
||||||
|
for (const field of ["chainId", "observedBlock", "ownerNonce"]) {
|
||||||
|
if (!Number.isSafeInteger(marker[field]) || marker[field] < 0) throw new Error(`no-op marker ${field} is invalid`);
|
||||||
|
}
|
||||||
|
if (marker.chainId !== active.chainId || !sameAddress(marker.proxy, active.proxy)
|
||||||
|
|| !sameAddress(marker.implementation, active.implementation)) {
|
||||||
|
throw new Error("no-op marker chain/proxy/implementation identity does not match active manifest");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateUpgradeMarker(marker, active) {
|
||||||
|
assertExactKeys(marker, ["mode", "network", "chainId", "token", "proxy", "previousImplementation", "implementation", "owner", "deploymentBlock", "snapshot"], "upgrade marker");
|
||||||
|
for (const field of ["network", "chainId", "deploymentBlock"]) {
|
||||||
|
if (marker[field] !== active[field]) throw new Error(`upgrade marker ${field} identity does not match active manifest`);
|
||||||
|
}
|
||||||
|
for (const field of ["token", "proxy", "previousImplementation", "owner"]) {
|
||||||
|
const activeField = field === "previousImplementation" ? "implementation" : field;
|
||||||
|
if (!sameAddress(marker[field], active[activeField])) throw new Error(`upgrade marker ${field} identity does not match active manifest`);
|
||||||
|
}
|
||||||
|
assertAddress(marker.implementation, "upgrade implementation");
|
||||||
|
if (sameAddress(marker.implementation, active.implementation)) throw new Error("upgrade implementation did not change");
|
||||||
|
const snapshot = marker.snapshot;
|
||||||
|
assertExactKeys(snapshot, ["proxy", "implementation", "owner", "asset", "paused", "balances", "liabilities", "reserves", "surplus", "deploymentBlock", "version"], "upgrade snapshot");
|
||||||
|
if (!sameAddress(snapshot.proxy, active.proxy) || snapshot.deploymentBlock !== active.deploymentBlock) {
|
||||||
|
throw new Error("upgrade snapshot proxy or deployment identity changed");
|
||||||
|
}
|
||||||
|
if (!sameAddress(snapshot.implementation, active.implementation) || !sameAddress(snapshot.owner, active.owner)
|
||||||
|
|| !sameAddress(snapshot.asset, active.token) || snapshot.version !== 1 || typeof snapshot.paused !== "boolean") {
|
||||||
|
throw new Error("upgrade snapshot identity does not match active manifest");
|
||||||
|
}
|
||||||
|
if (!Array.isArray(snapshot.balances) || snapshot.balances.length !== active.actors.length) {
|
||||||
|
throw new Error("upgrade snapshot actor count does not match active manifest");
|
||||||
|
}
|
||||||
|
snapshot.balances.forEach((record, index) => {
|
||||||
|
assertExactKeys(record, ["address", "balance"], `upgrade snapshot actor ${index}`);
|
||||||
|
if (!sameAddress(record.address, active.actors[index].address)) throw new Error("upgrade snapshot actor identity changed");
|
||||||
|
assertNonnegativeInteger(record.balance, "upgrade snapshot actor balance");
|
||||||
|
});
|
||||||
|
for (const field of ["liabilities", "reserves", "surplus"]) assertNonnegativeInteger(snapshot[field], `upgrade snapshot ${field}`);
|
||||||
|
if (snapshot.reserves < snapshot.liabilities || snapshot.reserves - snapshot.liabilities !== snapshot.surplus) {
|
||||||
|
throw new Error("upgrade snapshot accounting is inconsistent");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readUpgradeMethods(root) {
|
||||||
|
const bank = await readJson(join(root, "out", "BankV2.sol", "BankV2.json"));
|
||||||
|
const token = await readJson(join(root, "out", "MockUSDC.sol", "MockUSDC.json"));
|
||||||
|
const requiredBank = ["owner()", "asset()", "paused()", "balanceOf(address)", "totalLiabilities()", "contractVersion()"];
|
||||||
|
const result = { bank: {}, token: {} };
|
||||||
|
for (const signature of requiredBank) result.bank[signature] = methodSelector(bank, signature, "BankV2");
|
||||||
|
result.token["balanceOf(address)"] = methodSelector(token, "balanceOf(address)", "MockUSDC");
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
function methodSelector(artifact, signature, contractName) {
|
||||||
|
const selector = artifact?.methodIdentifiers?.[signature];
|
||||||
|
if (typeof selector !== "string" || !/^[0-9a-fA-F]{8}$/.test(selector)) {
|
||||||
|
throw new Error(`${contractName} artifact is missing method identifier ${signature}`);
|
||||||
|
}
|
||||||
|
return `0x${selector.toLowerCase()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function assertLiveVersionAndImplementation({ rpc, active, implementation, methods }) {
|
||||||
|
const code = await rpc("eth_getCode", [implementation, "latest"]);
|
||||||
|
if (typeof code !== "string" || code.length <= 2) throw new Error("upgrade implementation has no live code");
|
||||||
|
const version = decodeUint(await rpcCall(rpc, active.proxy, methods.bank["contractVersion()"]), "contract version");
|
||||||
|
if (version !== 2) throw new Error("live contract version is not 2");
|
||||||
|
const slot = await rpc("eth_getStorageAt", [active.proxy, IMPLEMENTATION_SLOT, "latest"]);
|
||||||
|
if (slotAddress(slot) !== implementation.toLowerCase()) throw new Error("live proxy implementation slot does not match upgrade marker");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function assertUpgradeSnapshot({ rpc, active, pending, methods }) {
|
||||||
|
const snapshot = pending.snapshot;
|
||||||
|
const owner = decodeAddress(await rpcCall(rpc, active.proxy, methods.bank["owner()"]), "owner");
|
||||||
|
const asset = decodeAddress(await rpcCall(rpc, active.proxy, methods.bank["asset()"]), "asset");
|
||||||
|
const paused = decodeBool(await rpcCall(rpc, active.proxy, methods.bank["paused()"]), "paused");
|
||||||
|
if (!sameAddress(owner, snapshot.owner)) throw new Error("live owner changed during upgrade");
|
||||||
|
if (!sameAddress(asset, snapshot.asset)) throw new Error("live asset changed during upgrade");
|
||||||
|
if (paused !== snapshot.paused) throw new Error("live pause state changed during upgrade");
|
||||||
|
for (const [index, actor] of snapshot.balances.entries()) {
|
||||||
|
const balance = decodeUint(
|
||||||
|
await rpcCall(rpc, active.proxy, `${methods.bank["balanceOf(address)"]}${encodeAddressWord(actor.address)}`),
|
||||||
|
`actor ${index} balance`
|
||||||
|
);
|
||||||
|
if (balance !== actor.balance) throw new Error(`live actor ${index} balance changed during upgrade`);
|
||||||
|
}
|
||||||
|
const liabilities = decodeUint(await rpcCall(rpc, active.proxy, methods.bank["totalLiabilities()"]), "liabilities");
|
||||||
|
const reserves = decodeUint(
|
||||||
|
await rpcCall(rpc, active.token, `${methods.token["balanceOf(address)"]}${encodeAddressWord(active.proxy)}`),
|
||||||
|
"reserves"
|
||||||
|
);
|
||||||
|
if (liabilities !== snapshot.liabilities) throw new Error("live liabilities changed during upgrade");
|
||||||
|
if (reserves !== snapshot.reserves) throw new Error("live reserves changed during upgrade");
|
||||||
|
if (reserves - liabilities !== snapshot.surplus) throw new Error("live surplus changed during upgrade");
|
||||||
|
}
|
||||||
|
|
||||||
|
function rpcCall(rpc, to, data) { return rpc("eth_call", [{ to, data }, "latest"]); }
|
||||||
|
function encodeAddressWord(address) { assertAddress(address, "call address"); return address.slice(2).toLowerCase().padStart(64, "0"); }
|
||||||
|
function decodeUint(value, label) {
|
||||||
|
if (typeof value !== "string" || !/^0x[0-9a-fA-F]{64}$/.test(value)) throw new Error(`${label} RPC result is invalid`);
|
||||||
|
const parsed = BigInt(value);
|
||||||
|
if (parsed > BigInt(Number.MAX_SAFE_INTEGER)) throw new Error(`${label} exceeds JavaScript safe integer range`);
|
||||||
|
return Number(parsed);
|
||||||
|
}
|
||||||
|
function decodeAddress(value, label) {
|
||||||
|
if (typeof value !== "string" || !/^0x0{24}[0-9a-fA-F]{40}$/.test(value)) throw new Error(`${label} RPC result is invalid`);
|
||||||
|
return `0x${value.slice(-40)}`;
|
||||||
|
}
|
||||||
|
function decodeBool(value, label) {
|
||||||
|
const decoded = decodeUint(value, label);
|
||||||
|
if (decoded !== 0 && decoded !== 1) throw new Error(`${label} RPC result is not boolean`);
|
||||||
|
return decoded === 1;
|
||||||
|
}
|
||||||
|
function isUpgradeLog(log, proxy, implementation) {
|
||||||
|
return typeof log?.address === "string" && sameAddress(log.address, proxy) && Array.isArray(log.topics)
|
||||||
|
&& log.topics.length >= 2 && log.topics[0]?.toLowerCase() === UPGRADED_TOPIC
|
||||||
|
&& slotAddress(log.topics[1]) === implementation.toLowerCase();
|
||||||
|
}
|
||||||
|
function sameAddress(first, second) {
|
||||||
|
return typeof first === "string" && typeof second === "string" && /^0x[0-9a-fA-F]{40}$/.test(first)
|
||||||
|
&& /^0x[0-9a-fA-F]{40}$/.test(second) && first.toLowerCase() === second.toLowerCase();
|
||||||
|
}
|
||||||
|
function assertExactKeys(value, expected, label) {
|
||||||
|
if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error(`${label} must be an object`);
|
||||||
|
const actual = Object.keys(value).sort();
|
||||||
|
const wanted = [...expected].sort();
|
||||||
|
if (actual.length !== wanted.length || actual.some((key, index) => key !== wanted[index])) throw new Error(`${label} schema is invalid`);
|
||||||
|
}
|
||||||
|
function assertNonnegativeInteger(value, label) {
|
||||||
|
if (!Number.isSafeInteger(value) || value < 0) throw new Error(`${label} must be a nonnegative integer`);
|
||||||
|
}
|
||||||
|
|
||||||
export async function readManifest(path, { pending = false } = {}) {
|
export async function readManifest(path, { pending = false } = {}) {
|
||||||
const manifest = await readJson(path);
|
const manifest = await readJson(path);
|
||||||
validateManifest(manifest, { pending });
|
validateManifest(manifest, { pending });
|
||||||
@@ -283,7 +479,11 @@ export async function runFinalizeCli(argv, { root = process.cwd(), log = console
|
|||||||
if (network !== "--rpc-url" || typeof rest[0] !== "string" || rest.length !== 1) throw new Error("usage: finalize-manifest.mjs deploy --rpc-url <url>");
|
if (network !== "--rpc-url" || typeof rest[0] !== "string" || rest.length !== 1) throw new Error("usage: finalize-manifest.mjs deploy --rpc-url <url>");
|
||||||
return finalizeDeployment({ root, rpc: fetchRpc(rest[0]) });
|
return finalizeDeployment({ root, rpc: fetchRpc(rest[0]) });
|
||||||
}
|
}
|
||||||
throw new Error("usage: finalize-manifest.mjs preflight-deploy <anvil|baseSepolia> | deploy --rpc-url <url>");
|
if (command === "upgrade") {
|
||||||
|
if (network !== "--rpc-url" || typeof rest[0] !== "string" || rest.length !== 1) throw new Error("usage: finalize-manifest.mjs upgrade --rpc-url <url>");
|
||||||
|
return finalizeUpgrade({ root, rpc: fetchRpc(rest[0]) });
|
||||||
|
}
|
||||||
|
throw new Error("usage: finalize-manifest.mjs preflight-deploy <anvil|baseSepolia> | deploy --rpc-url <url> | upgrade --rpc-url <url>");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (process.argv[1] && pathToFileURL(resolve(process.argv[1])).href === import.meta.url) {
|
if (process.argv[1] && pathToFileURL(resolve(process.argv[1])).href === import.meta.url) {
|
||||||
|
|||||||
@@ -18,12 +18,19 @@ const bankRequirements = [
|
|||||||
eventSignature("Upgraded", [parameter("implementation", "address", true)]),
|
eventSignature("Upgraded", [parameter("implementation", "address", true)]),
|
||||||
];
|
];
|
||||||
const tokenRequirements = [functionSignature("balanceOf", [parameter("account", "address")], ["uint256"], "view")];
|
const tokenRequirements = [functionSignature("balanceOf", [parameter("account", "address")], ["uint256"], "view")];
|
||||||
|
const bankV2Requirements = [
|
||||||
|
...bankRequirements,
|
||||||
|
functionSignature("transferBalance", [parameter("recipient", "address"), parameter("amount", "uint256")], [], "nonpayable"),
|
||||||
|
eventSignature("BalanceTransferred", [parameter("from", "address", true), parameter("to", "address", true), parameter("amount", "uint256", false)]),
|
||||||
|
];
|
||||||
|
|
||||||
export function extractAbi(artifact, contractName) {
|
export function extractAbi(artifact, contractName) {
|
||||||
if (!artifact || typeof artifact !== "object" || !Array.isArray(artifact.abi)) {
|
if (!artifact || typeof artifact !== "object" || !Array.isArray(artifact.abi)) {
|
||||||
throw new Error(`${contractName} artifact must contain an ABI`);
|
throw new Error(`${contractName} artifact must contain an ABI`);
|
||||||
}
|
}
|
||||||
const requirements = contractName === "BankV1" ? bankRequirements : contractName === "MockUSDC" ? tokenRequirements : null;
|
const requirements = contractName === "BankV1" ? bankRequirements
|
||||||
|
: contractName === "BankV2" ? bankV2Requirements
|
||||||
|
: contractName === "MockUSDC" ? tokenRequirements : null;
|
||||||
if (!requirements) throw new Error(`unsupported contract artifact ${contractName}`);
|
if (!requirements) throw new Error(`unsupported contract artifact ${contractName}`);
|
||||||
for (const requirement of requirements) {
|
for (const requirement of requirements) {
|
||||||
const entries = artifact.abi.filter((entry) => entry && entry.type === requirement.type && entry.name === requirement.name);
|
const entries = artifact.abi.filter((entry) => entry && entry.type === requirement.type && entry.name === requirement.name);
|
||||||
@@ -53,21 +60,27 @@ function matchesParameters(actual, expected) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function renderContractsModule(bankV1Abi, mockUsdcAbi) {
|
export function renderContractsModule(bankV1Abi, bankV2Abi, mockUsdcAbi) {
|
||||||
return `/* This file is generated by tools/sync-web-artifacts.mjs. Do not edit. */\n\nexport const bankV1Abi = ${JSON.stringify(bankV1Abi, null, 2)} as const;\n\nexport const mockUsdcAbi = ${JSON.stringify(mockUsdcAbi, null, 2)} as const;\n`;
|
return `/* This file is generated by tools/sync-web-artifacts.mjs. Do not edit. */\n\nexport const bankV1Abi = ${JSON.stringify(bankV1Abi, null, 2)} as const;\n\nexport const bankV2Abi = ${JSON.stringify(bankV2Abi, null, 2)} as const;\n\nexport const mockUsdcAbi = ${JSON.stringify(mockUsdcAbi, null, 2)} as const;\n`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function syncArtifacts({
|
export async function syncArtifacts({
|
||||||
bankArtifactPath = resolve(repositoryRoot, "out/BankV1.sol/BankV1.json"),
|
bankArtifactPath = resolve(repositoryRoot, "out/BankV1.sol/BankV1.json"),
|
||||||
|
bankV2ArtifactPath = resolve(repositoryRoot, "out/BankV2.sol/BankV2.json"),
|
||||||
tokenArtifactPath = resolve(repositoryRoot, "out/MockUSDC.sol/MockUSDC.json"),
|
tokenArtifactPath = resolve(repositoryRoot, "out/MockUSDC.sol/MockUSDC.json"),
|
||||||
outputPath = resolve(repositoryRoot, "web/src/generated/contracts.ts"),
|
outputPath = resolve(repositoryRoot, "web/src/generated/contracts.ts"),
|
||||||
check = false,
|
check = false,
|
||||||
} = {}) {
|
} = {}) {
|
||||||
const [bankArtifact, tokenArtifact] = await Promise.all([
|
const [bankArtifact, bankV2Artifact, tokenArtifact] = await Promise.all([
|
||||||
readArtifact(bankArtifactPath, "BankV1"),
|
readArtifact(bankArtifactPath, "BankV1"),
|
||||||
|
readArtifact(bankV2ArtifactPath, "BankV2"),
|
||||||
readArtifact(tokenArtifactPath, "MockUSDC"),
|
readArtifact(tokenArtifactPath, "MockUSDC"),
|
||||||
]);
|
]);
|
||||||
const contents = renderContractsModule(extractAbi(bankArtifact, "BankV1"), extractAbi(tokenArtifact, "MockUSDC"));
|
const contents = renderContractsModule(
|
||||||
|
extractAbi(bankArtifact, "BankV1"),
|
||||||
|
extractAbi(bankV2Artifact, "BankV2"),
|
||||||
|
extractAbi(tokenArtifact, "MockUSDC")
|
||||||
|
);
|
||||||
if (check) {
|
if (check) {
|
||||||
let existing;
|
let existing;
|
||||||
try { existing = await readFile(outputPath, "utf8"); } catch { throw new Error("generated contracts module is stale or missing"); }
|
try { existing = await readFile(outputPath, "utf8"); } catch { throw new Error("generated contracts module is stale or missing"); }
|
||||||
|
|||||||
@@ -4,15 +4,18 @@ import { tmpdir } from "node:os";
|
|||||||
import { dirname, join } from "node:path";
|
import { dirname, join } from "node:path";
|
||||||
import test from "node:test";
|
import test from "node:test";
|
||||||
|
|
||||||
import { atomicWrite, finalizeDeployment, preflightDeploy, runFinalizeCli, validateManifest } from "./finalize-manifest.mjs";
|
import { UPGRADED_TOPIC, atomicWrite, finalizeDeployment, finalizeUpgrade, preflightDeploy, runFinalizeCli, validateManifest } from "./finalize-manifest.mjs";
|
||||||
import { runSelectCli, selectManifest } from "./select-manifest.mjs";
|
import { runSelectCli, selectManifest } from "./select-manifest.mjs";
|
||||||
|
|
||||||
const TOKEN = "0x1000000000000000000000000000000000000001";
|
const TOKEN = "0x1000000000000000000000000000000000000001";
|
||||||
const PROXY = "0x2000000000000000000000000000000000000002";
|
const PROXY = "0x2000000000000000000000000000000000000002";
|
||||||
const IMPLEMENTATION = "0x3000000000000000000000000000000000000003";
|
const IMPLEMENTATION = "0x3000000000000000000000000000000000000003";
|
||||||
|
const V2_IMPLEMENTATION = "0x4000000000000000000000000000000000000004";
|
||||||
const OWNER = "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266";
|
const OWNER = "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266";
|
||||||
const IMPLEMENTATION_SLOT = "0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc";
|
const IMPLEMENTATION_SLOT = "0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc";
|
||||||
const EDUCATIONAL_WARNING = "Educational demo — mock token — never use real funds.";
|
const EDUCATIONAL_WARNING = "Educational demo — mock token — never use real funds.";
|
||||||
|
const DECLARED_CREATE_HASH = "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
|
||||||
|
const DECLARED_CALL_HASH = "0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb";
|
||||||
|
|
||||||
test("preflight rejects an existing target canonical manifest with the safe recovery command", async () => {
|
test("preflight rejects an existing target canonical manifest with the safe recovery command", async () => {
|
||||||
await withFixture(async (root) => {
|
await withFixture(async (root) => {
|
||||||
@@ -136,6 +139,106 @@ test("finalizer failure leaves an initially absent canonical manifest absent", a
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("upgrade finalizer verifies receipt, event, slot, artifact-driven state and changes only implementation", async () => {
|
||||||
|
await withUpgradeFixture(async (root, active) => {
|
||||||
|
const before = structuredClone(active);
|
||||||
|
const output = await finalizeUpgrade({ root, rpc: fakeUpgradeRpc() });
|
||||||
|
|
||||||
|
assert.equal(output.mode, "upgrade");
|
||||||
|
assert.deepEqual(output.manifest, { ...before, implementation: V2_IMPLEMENTATION });
|
||||||
|
for (const name of ["anvil.json", "active.json"]) {
|
||||||
|
const confirmed = await readJson(join(root, "deployments", name));
|
||||||
|
assert.deepEqual(confirmed, { ...before, implementation: V2_IMPLEMENTATION });
|
||||||
|
assert.deepEqual({ ...confirmed, implementation: before.implementation }, before);
|
||||||
|
}
|
||||||
|
await assert.rejects(() => access(join(root, "deployments", "upgrade-pending.json")));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("upgrade finalizer rejects proxy, deployment-block, and actor identity mutation without changing confirmed files", async () => {
|
||||||
|
for (const [name, mutate] of [
|
||||||
|
["proxy", (pending) => { pending.proxy = TOKEN; }],
|
||||||
|
["deployment block", (pending) => { pending.deploymentBlock += 1; }],
|
||||||
|
["actor", (pending) => { pending.snapshot.balances[1].address = OWNER; }],
|
||||||
|
]) {
|
||||||
|
await withUpgradeFixture(async (root) => {
|
||||||
|
const pendingPath = join(root, "deployments", "upgrade-pending.json");
|
||||||
|
const pending = await readJson(pendingPath); mutate(pending); await writeJson(pendingPath, pending);
|
||||||
|
const beforeCanonical = await readFile(join(root, "deployments", "anvil.json"));
|
||||||
|
const beforeActive = await readFile(join(root, "deployments", "active.json"));
|
||||||
|
await assert.rejects(() => finalizeUpgrade({ root, rpc: fakeUpgradeRpc() }), /proxy|deployment|actor|identity/i, name);
|
||||||
|
assert.deepEqual(await readFile(join(root, "deployments", "anvil.json")), beforeCanonical);
|
||||||
|
assert.deepEqual(await readFile(join(root, "deployments", "active.json")), beforeActive);
|
||||||
|
await access(pendingPath);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("upgrade finalizer leaves confirmed files untouched for failed receipt, missing event, live mismatch, and unknown mode", async () => {
|
||||||
|
const cases = [
|
||||||
|
["failed receipt", fakeUpgradeRpc({ receiptStatus: "0x0" }), null, /successful/],
|
||||||
|
["missing Upgraded event", fakeUpgradeRpc({ omitUpgradeLog: true }), null, /Upgraded/],
|
||||||
|
["wrong live version", fakeUpgradeRpc({ version: 1n }), null, /version/],
|
||||||
|
["slot mismatch", fakeUpgradeRpc({ slot: IMPLEMENTATION }), null, /slot/],
|
||||||
|
["owner changed", fakeUpgradeRpc({ owner: TOKEN }), null, /owner/],
|
||||||
|
["unknown mode", fakeUpgradeRpc(), (pending) => { pending.mode = "mystery"; }, /mode/],
|
||||||
|
];
|
||||||
|
for (const [name, rpc, mutate, expected] of cases) {
|
||||||
|
await withUpgradeFixture(async (root) => {
|
||||||
|
const pendingPath = join(root, "deployments", "upgrade-pending.json");
|
||||||
|
if (mutate) { const pending = await readJson(pendingPath); mutate(pending); await writeJson(pendingPath, pending); }
|
||||||
|
const beforeCanonical = await readFile(join(root, "deployments", "anvil.json"));
|
||||||
|
const beforeActive = await readFile(join(root, "deployments", "active.json"));
|
||||||
|
await assert.rejects(() => finalizeUpgrade({ root, rpc }), expected, name);
|
||||||
|
assert.deepEqual(await readFile(join(root, "deployments", "anvil.json")), beforeCanonical);
|
||||||
|
assert.deepEqual(await readFile(join(root, "deployments", "active.json")), beforeActive);
|
||||||
|
await access(pendingPath);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("noop finalizer ignores stale broadcast history and leaves confirmed manifests byte-for-byte unchanged", async () => {
|
||||||
|
await withUpgradeFixture(async (root, active) => {
|
||||||
|
active.implementation = V2_IMPLEMENTATION;
|
||||||
|
await writeJson(join(root, "deployments", "anvil.json"), active);
|
||||||
|
await writeJson(join(root, "deployments", "active.json"), active);
|
||||||
|
await writeJson(join(root, "deployments", "upgrade-pending.json"), {
|
||||||
|
mode: "noop", chainId: 31337, observedBlock: 80, ownerNonce: 5,
|
||||||
|
proxy: PROXY, implementation: V2_IMPLEMENTATION,
|
||||||
|
});
|
||||||
|
await writeJson(join(root, "broadcast", "UpgradeV2.s.sol", "31337", "run-latest.json"), {
|
||||||
|
transactions: [{ hash: "0xstale", transaction: { to: TOKEN } }], receipts: [{ status: "0x0" }],
|
||||||
|
});
|
||||||
|
const beforeCanonical = await readFile(join(root, "deployments", "anvil.json"));
|
||||||
|
const beforeActive = await readFile(join(root, "deployments", "active.json"));
|
||||||
|
|
||||||
|
const output = await finalizeUpgrade({ root, rpc: fakeUpgradeRpc() });
|
||||||
|
|
||||||
|
assert.equal(output.mode, "noop");
|
||||||
|
assert.deepEqual(await readFile(join(root, "deployments", "anvil.json")), beforeCanonical);
|
||||||
|
assert.deepEqual(await readFile(join(root, "deployments", "active.json")), beforeActive);
|
||||||
|
await assert.rejects(() => access(join(root, "deployments", "upgrade-pending.json")));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("noop finalizer rejects a regressed block or changed owner nonce without touching confirmed state", async () => {
|
||||||
|
for (const [name, rpc] of [["block", fakeUpgradeRpc({ blockNumber: 79 })], ["nonce", fakeUpgradeRpc({ nonce: 6 })]]) {
|
||||||
|
await withUpgradeFixture(async (root, active) => {
|
||||||
|
active.implementation = V2_IMPLEMENTATION;
|
||||||
|
await writeJson(join(root, "deployments", "anvil.json"), active);
|
||||||
|
await writeJson(join(root, "deployments", "active.json"), active);
|
||||||
|
await writeJson(join(root, "deployments", "upgrade-pending.json"), {
|
||||||
|
mode: "noop", chainId: 31337, observedBlock: 80, ownerNonce: 5,
|
||||||
|
proxy: PROXY, implementation: V2_IMPLEMENTATION,
|
||||||
|
});
|
||||||
|
const before = await readFile(join(root, "deployments", "active.json"));
|
||||||
|
await assert.rejects(() => finalizeUpgrade({ root, rpc }), /block|nonce/i, name);
|
||||||
|
assert.deepEqual(await readFile(join(root, "deployments", "active.json")), before);
|
||||||
|
await access(join(root, "deployments", "upgrade-pending.json"));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
test("selection atomically replaces active with only a valid named canonical manifest", async () => {
|
test("selection atomically replaces active with only a valid named canonical manifest", async () => {
|
||||||
await withFixture(async (root) => {
|
await withFixture(async (root) => {
|
||||||
const anvil = manifest({ deploymentBlock: 31 });
|
const anvil = manifest({ deploymentBlock: 31 });
|
||||||
@@ -266,6 +369,118 @@ function fakeRpc(overrides = {}) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const selectors = {
|
||||||
|
"owner()": "11111111",
|
||||||
|
"asset()": "22222222",
|
||||||
|
"paused()": "33333333",
|
||||||
|
"balanceOf(address)": "44444444",
|
||||||
|
"totalLiabilities()": "55555555",
|
||||||
|
"contractVersion()": "66666666",
|
||||||
|
};
|
||||||
|
|
||||||
|
async function withUpgradeFixture(fn) {
|
||||||
|
await withFixture(async (root) => {
|
||||||
|
const active = manifest();
|
||||||
|
await writeJson(join(root, "deployments", "anvil.json"), active);
|
||||||
|
await writeJson(join(root, "deployments", "active.json"), active);
|
||||||
|
await writeJson(join(root, "deployments", "upgrade-pending.json"), upgradePending(active));
|
||||||
|
await writeUpgradeArtifacts(root);
|
||||||
|
await writeUpgradeBroadcast(root);
|
||||||
|
await fn(root, active);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function upgradePending(active) {
|
||||||
|
return {
|
||||||
|
mode: "upgrade",
|
||||||
|
network: active.network,
|
||||||
|
chainId: active.chainId,
|
||||||
|
token: active.token,
|
||||||
|
proxy: active.proxy,
|
||||||
|
previousImplementation: active.implementation,
|
||||||
|
implementation: V2_IMPLEMENTATION,
|
||||||
|
owner: active.owner,
|
||||||
|
deploymentBlock: active.deploymentBlock,
|
||||||
|
snapshot: {
|
||||||
|
proxy: active.proxy,
|
||||||
|
implementation: active.implementation,
|
||||||
|
owner: active.owner,
|
||||||
|
asset: active.token,
|
||||||
|
paused: false,
|
||||||
|
balances: active.actors.map((actor, index) => ({ address: actor.address, balance: index === 1 ? 900_000_000 : index === 2 ? 500_000_000 : 0 })),
|
||||||
|
liabilities: 1_400_000_000,
|
||||||
|
reserves: 1_400_000_000,
|
||||||
|
surplus: 0,
|
||||||
|
deploymentBlock: active.deploymentBlock,
|
||||||
|
version: 1,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function writeUpgradeArtifacts(root) {
|
||||||
|
const bankDirectory = join(root, "out", "BankV2.sol");
|
||||||
|
const tokenDirectory = join(root, "out", "MockUSDC.sol");
|
||||||
|
const { mkdir } = await import("node:fs/promises");
|
||||||
|
await mkdir(bankDirectory, { recursive: true });
|
||||||
|
await mkdir(tokenDirectory, { recursive: true });
|
||||||
|
await writeJson(join(bankDirectory, "BankV2.json"), { methodIdentifiers: selectors });
|
||||||
|
await writeJson(join(tokenDirectory, "MockUSDC.json"), { methodIdentifiers: { "balanceOf(address)": selectors["balanceOf(address)"] } });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function writeUpgradeBroadcast(root) {
|
||||||
|
const directory = join(root, "broadcast", "UpgradeV2.s.sol", "31337");
|
||||||
|
const { mkdir } = await import("node:fs/promises");
|
||||||
|
await mkdir(directory, { recursive: true });
|
||||||
|
await writeJson(join(directory, "run-latest.json"), {
|
||||||
|
transactions: [
|
||||||
|
{ hash: DECLARED_CREATE_HASH, transactionType: "CREATE", transaction: { to: null } },
|
||||||
|
{ hash: DECLARED_CALL_HASH, transactionType: "CALL", transaction: { to: PROXY } },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function fakeUpgradeRpc(overrides = {}) {
|
||||||
|
const balanceByAddress = new Map([
|
||||||
|
[OWNER.toLowerCase(), 0n],
|
||||||
|
["0x70997970c51812dc3a010c7d01b50e0d17dc79c8", 900_000_000n],
|
||||||
|
["0x3c44cdddb6a900fa2b585dd299e03d12fa4293bc", 500_000_000n],
|
||||||
|
]);
|
||||||
|
return async (method, params) => {
|
||||||
|
if (method === "eth_chainId") return "0x7a69";
|
||||||
|
if (method === "eth_blockNumber") return hexQuantity(overrides.blockNumber ?? 100);
|
||||||
|
if (method === "eth_getTransactionCount") return hexQuantity(overrides.nonce ?? 5);
|
||||||
|
if (method === "eth_getCode") return "0x6000";
|
||||||
|
if (method === "eth_getStorageAt") return wordForRpc(overrides.slot ?? V2_IMPLEMENTATION);
|
||||||
|
if (method === "eth_getTransactionByHash") {
|
||||||
|
return { hash: params[0], to: params[0] === DECLARED_CREATE_HASH ? PROXY : null };
|
||||||
|
}
|
||||||
|
if (method === "eth_getTransactionReceipt") return {
|
||||||
|
status: overrides.receiptStatus ?? "0x1",
|
||||||
|
blockNumber: "0x5a",
|
||||||
|
logs: overrides.omitUpgradeLog || params[0] === DECLARED_CALL_HASH
|
||||||
|
? [] : [{ address: PROXY, topics: [UPGRADED_TOPIC, wordForRpc(V2_IMPLEMENTATION)], data: "0x" }],
|
||||||
|
};
|
||||||
|
if (method === "eth_call") {
|
||||||
|
const call = params[0];
|
||||||
|
const selector = call.data.slice(2, 10);
|
||||||
|
if (selector === selectors["owner()"]) return wordForRpc(overrides.owner ?? OWNER);
|
||||||
|
if (selector === selectors["asset()"]) return wordForRpc(TOKEN);
|
||||||
|
if (selector === selectors["paused()"]) return uintWord(0n);
|
||||||
|
if (selector === selectors["totalLiabilities()"]) return uintWord(1_400_000_000n);
|
||||||
|
if (selector === selectors["contractVersion()"]) return uintWord(overrides.version ?? 2n);
|
||||||
|
if (selector === selectors["balanceOf(address)"]) {
|
||||||
|
if (call.to.toLowerCase() === TOKEN.toLowerCase()) return uintWord(1_400_000_000n);
|
||||||
|
return uintWord(balanceByAddress.get(`0x${call.data.slice(-40)}`.toLowerCase()) ?? 0n);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw new Error(`unexpected upgrade RPC method: ${method}`);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function wordForRpc(address) { return `0x${"0".repeat(24)}${address.slice(2).toLowerCase()}`; }
|
||||||
|
function uintWord(value) { return `0x${BigInt(value).toString(16).padStart(64, "0")}`; }
|
||||||
|
function hexQuantity(value) { return `0x${Number(value).toString(16)}`; }
|
||||||
|
|
||||||
async function writeJson(path, value) {
|
async function writeJson(path, value) {
|
||||||
await writeFile(path, `${JSON.stringify(value, null, 2)}\n`);
|
await writeFile(path, `${JSON.stringify(value, null, 2)}\n`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,6 +22,11 @@ const bankAbi = [
|
|||||||
{ type: "event", name: "Upgraded", inputs: [{ name: "implementation", type: "address", indexed: true }], anonymous: false },
|
{ type: "event", name: "Upgraded", inputs: [{ name: "implementation", type: "address", indexed: true }], anonymous: false },
|
||||||
];
|
];
|
||||||
const tokenAbi = [{ type: "function", name: "balanceOf", inputs: [{ name: "account", type: "address" }], outputs: [{ name: "", type: "uint256" }], stateMutability: "view" }];
|
const tokenAbi = [{ type: "function", name: "balanceOf", inputs: [{ name: "account", type: "address" }], outputs: [{ name: "", type: "uint256" }], stateMutability: "view" }];
|
||||||
|
const bankV2Abi = [
|
||||||
|
...bankAbi,
|
||||||
|
{ type: "function", name: "transferBalance", inputs: [{ name: "recipient", type: "address" }, { name: "amount", type: "uint256" }], outputs: [], stateMutability: "nonpayable" },
|
||||||
|
{ type: "event", name: "BalanceTransferred", inputs: [{ name: "from", type: "address", indexed: true }, { name: "to", type: "address", indexed: true }, { name: "amount", type: "uint256", indexed: false }], anonymous: false },
|
||||||
|
];
|
||||||
const manifest = {
|
const manifest = {
|
||||||
schemaVersion: 1, network: "anvil", chainId: 31337, deploymentBlock: 3,
|
schemaVersion: 1, network: "anvil", chainId: 31337, deploymentBlock: 3,
|
||||||
rpcUrl: "http://127.0.0.1:8545", token: address, proxy: address,
|
rpcUrl: "http://127.0.0.1:8545", token: address, proxy: address,
|
||||||
@@ -40,13 +45,18 @@ async function expectRejects(action, pattern) {
|
|||||||
|
|
||||||
await withFixture(async (root) => {
|
await withFixture(async (root) => {
|
||||||
const bank = join(root, "BankV1.json");
|
const bank = join(root, "BankV1.json");
|
||||||
|
const bankV2 = join(root, "BankV2.json");
|
||||||
const token = join(root, "MockUSDC.json");
|
const token = join(root, "MockUSDC.json");
|
||||||
const output = join(root, "contracts.ts");
|
const output = join(root, "contracts.ts");
|
||||||
|
|
||||||
// Catches a production bridge that silently produces an ABI module from incomplete artifacts.
|
// Catches a production bridge that silently produces an ABI module from incomplete artifacts.
|
||||||
await expectRejects(() => syncArtifacts({ bankArtifactPath: bank, tokenArtifactPath: token, outputPath: output }), /BankV1 artifact/i);
|
await expectRejects(() => syncArtifacts({ bankArtifactPath: bank, bankV2ArtifactPath: bankV2, tokenArtifactPath: token, outputPath: output }), /BankV1 artifact/i);
|
||||||
await writeFile(bank, JSON.stringify({ abi: bankAbi }));
|
await writeFile(bank, JSON.stringify({ abi: bankAbi }));
|
||||||
await expectRejects(() => syncArtifacts({ bankArtifactPath: bank, tokenArtifactPath: token, outputPath: output }), /MockUSDC artifact/i);
|
await writeFile(token, JSON.stringify({ abi: tokenAbi }));
|
||||||
|
await expectRejects(() => syncArtifacts({ bankArtifactPath: bank, bankV2ArtifactPath: bankV2, tokenArtifactPath: token, outputPath: output }), /BankV2 artifact/i);
|
||||||
|
await writeFile(bankV2, JSON.stringify({ abi: bankV2Abi }));
|
||||||
|
await rm(token);
|
||||||
|
await expectRejects(() => syncArtifacts({ bankArtifactPath: bank, bankV2ArtifactPath: bankV2, tokenArtifactPath: token, outputPath: output }), /MockUSDC artifact/i);
|
||||||
await writeFile(token, JSON.stringify({ abi: tokenAbi }));
|
await writeFile(token, JSON.stringify({ abi: tokenAbi }));
|
||||||
|
|
||||||
// Catches a bridge that exports an ABI missing a V1 contract function or event.
|
// Catches a bridge that exports an ABI missing a V1 contract function or event.
|
||||||
@@ -58,16 +68,20 @@ await withFixture(async (root) => {
|
|||||||
const wrongEvent = structuredClone(bankAbi);
|
const wrongEvent = structuredClone(bankAbi);
|
||||||
wrongEvent.find((entry) => entry.name === "Deposited").inputs[0].indexed = false;
|
wrongEvent.find((entry) => entry.name === "Deposited").inputs[0].indexed = false;
|
||||||
assert.throws(() => extractAbi({ abi: wrongEvent }, "BankV1"), /signature.*Deposited/i);
|
assert.throws(() => extractAbi({ abi: wrongEvent }, "BankV1"), /signature.*Deposited/i);
|
||||||
const rendered = renderContractsModule(extractAbi({ abi: bankAbi }, "BankV1"), extractAbi({ abi: tokenAbi }, "MockUSDC"));
|
assert.throws(() => extractAbi({ abi: bankV2Abi.filter((entry) => entry.name !== "transferBalance") }, "BankV2"), /transferBalance/);
|
||||||
|
assert.throws(() => extractAbi({ abi: bankV2Abi.filter((entry) => entry.name !== "BalanceTransferred") }, "BankV2"), /BalanceTransferred/);
|
||||||
|
const rendered = renderContractsModule(extractAbi({ abi: bankAbi }, "BankV1"), extractAbi({ abi: bankV2Abi }, "BankV2"), extractAbi({ abi: tokenAbi }, "MockUSDC"));
|
||||||
assert.match(rendered, /export const bankV1Abi = .* as const;/s);
|
assert.match(rendered, /export const bankV1Abi = .* as const;/s);
|
||||||
|
assert.match(rendered, /export const bankV2Abi = .* as const;/s);
|
||||||
assert.match(rendered, /export const mockUsdcAbi = .* as const;/s);
|
assert.match(rendered, /export const mockUsdcAbi = .* as const;/s);
|
||||||
assert.doesNotMatch(rendered, /bankV2Abi|BankV2/);
|
assert.match(rendered, /transferBalance/);
|
||||||
|
assert.match(rendered, /BalanceTransferred/);
|
||||||
|
|
||||||
// Catches a bridge that requires an active manifest or reads V2 as part of ABI generation.
|
// Catches a bridge that requires an active manifest or reads V2 as part of ABI generation.
|
||||||
await syncArtifacts({ bankArtifactPath: bank, tokenArtifactPath: token, outputPath: output });
|
await syncArtifacts({ bankArtifactPath: bank, bankV2ArtifactPath: bankV2, tokenArtifactPath: token, outputPath: output });
|
||||||
assert.equal(await readFile(output, "utf8"), rendered);
|
assert.equal(await readFile(output, "utf8"), rendered);
|
||||||
await writeFile(output, "stale\n");
|
await writeFile(output, "stale\n");
|
||||||
await expectRejects(() => syncArtifacts({ bankArtifactPath: bank, tokenArtifactPath: token, outputPath: output, check: true }), /stale/i);
|
await expectRejects(() => syncArtifacts({ bankArtifactPath: bank, bankV2ArtifactPath: bankV2, tokenArtifactPath: token, outputPath: output, check: true }), /stale/i);
|
||||||
});
|
});
|
||||||
|
|
||||||
await withFixture(async (root) => {
|
await withFixture(async (root) => {
|
||||||
|
|||||||
@@ -128,6 +128,34 @@ describe("read-only operations console", () => {
|
|||||||
expect(timeline.textContent).toContain("0xcccc…cccc");
|
expect(timeline.textContent).toContain("0xcccc…cccc");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("renders the V2 version and exact internal transfer activity without transaction controls", () => {
|
||||||
|
const v2Snapshot: DashboardSnapshot = {
|
||||||
|
...snapshot,
|
||||||
|
version: 2,
|
||||||
|
paused: false,
|
||||||
|
actors: [
|
||||||
|
{ label: "Alice", address: address("5"), balance: 650_000_000n },
|
||||||
|
{ label: "Bob", address: address("6"), balance: 750_000_000n },
|
||||||
|
],
|
||||||
|
activity: [{
|
||||||
|
kind: "transfer",
|
||||||
|
from: address("5"),
|
||||||
|
to: address("6"),
|
||||||
|
amount: 250_000_000n,
|
||||||
|
blockNumber: 19n,
|
||||||
|
logIndex: 0,
|
||||||
|
transactionHash: transactionHash("f"),
|
||||||
|
}],
|
||||||
|
diagnostics: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
const { container } = render(<App dashboard={ready(localManifest, v2Snapshot)} />);
|
||||||
|
|
||||||
|
expect(screen.getByText("Version 2")).toBeTruthy();
|
||||||
|
expect(screen.getByText("Alice transferred 250.000000 mUSDC to Bob")).toBeTruthy();
|
||||||
|
expect(container.querySelector("button, form")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
it("uses validated Base Sepolia explorer links and public shortened account labels", () => {
|
it("uses validated Base Sepolia explorer links and public shortened account labels", () => {
|
||||||
const baseManifest: DeploymentManifest = {
|
const baseManifest: DeploymentManifest = {
|
||||||
...localManifest,
|
...localManifest,
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ function description(activity: Activity, manifest: DeploymentManifest): string {
|
|||||||
switch (activity.kind) {
|
switch (activity.kind) {
|
||||||
case "deposit": return `Deposited ${formatAmount(activity.amount)} for ${actorLabel(activity.account, manifest)}`;
|
case "deposit": return `Deposited ${formatAmount(activity.amount)} for ${actorLabel(activity.account, manifest)}`;
|
||||||
case "withdrawal": return `Withdrawn ${formatAmount(activity.amount)} for ${actorLabel(activity.account, manifest)}`;
|
case "withdrawal": return `Withdrawn ${formatAmount(activity.amount)} for ${actorLabel(activity.account, manifest)}`;
|
||||||
|
case "transfer": return `${actorLabel(activity.from, manifest)} transferred ${formatTransferAmount(activity.amount)} to ${actorLabel(activity.to, manifest)}`;
|
||||||
case "paused": return `Paused by ${actorLabel(activity.account, manifest)}`;
|
case "paused": return `Paused by ${actorLabel(activity.account, manifest)}`;
|
||||||
case "unpaused": return `Unpaused by ${actorLabel(activity.account, manifest)}`;
|
case "unpaused": return `Unpaused by ${actorLabel(activity.account, manifest)}`;
|
||||||
case "ownershipTransferred": return `Ownership transferred from ${actorLabel(activity.previousOwner, manifest)} to ${actorLabel(activity.newOwner, manifest)}`;
|
case "ownershipTransferred": return `Ownership transferred from ${actorLabel(activity.previousOwner, manifest)} to ${actorLabel(activity.newOwner, manifest)}`;
|
||||||
@@ -20,6 +21,12 @@ function description(activity: Activity, manifest: DeploymentManifest): string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatTransferAmount(amount: bigint): string {
|
||||||
|
const whole = amount / 1_000_000n;
|
||||||
|
const fraction = (amount % 1_000_000n).toString().padStart(6, "0");
|
||||||
|
return `${new Intl.NumberFormat("en-US").format(whole)}.${fraction} mUSDC`;
|
||||||
|
}
|
||||||
|
|
||||||
function transactionUrl(manifest: DeploymentManifest, transactionHash: Hex): string | undefined {
|
function transactionUrl(manifest: DeploymentManifest, transactionHash: Hex): string | undefined {
|
||||||
if (!manifest.explorerBaseUrl) return undefined;
|
if (!manifest.explorerBaseUrl) return undefined;
|
||||||
return `${manifest.explorerBaseUrl.replace(/\/$/, "")}/tx/${transactionHash}`;
|
return `${manifest.explorerBaseUrl.replace(/\/$/, "")}/tx/${transactionHash}`;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { encodeAbiParameters, encodeEventTopics, parseAbiParameters, type Address, type Hex } from "viem";
|
import { encodeAbiParameters, encodeEventTopics, parseAbiParameters, type Address, type Hex } from "viem";
|
||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import { bankV1Abi } from "../generated/contracts";
|
import { bankV1Abi, bankV2Abi } from "../generated/contracts";
|
||||||
import type { DeploymentManifest } from "../types/dashboard";
|
import type { DeploymentManifest } from "../types/dashboard";
|
||||||
import { EIP1967_IMPLEMENTATION_SLOT, loadDashboardSnapshot, type BankReader } from "./bankClient";
|
import { EIP1967_IMPLEMENTATION_SLOT, loadDashboardSnapshot, type BankReader } from "./bankClient";
|
||||||
|
|
||||||
@@ -27,6 +27,7 @@ class ReaderDouble implements BankReader {
|
|||||||
actorBalances = new Map<Address, bigint>([[owner, 70n], [alice, 30n]]);
|
actorBalances = new Map<Address, bigint>([[owner, 70n], [alice, 30n]]);
|
||||||
logs: readonly unknown[] = [];
|
logs: readonly unknown[] = [];
|
||||||
error?: Error;
|
error?: Error;
|
||||||
|
version = 1n;
|
||||||
|
|
||||||
async getChainId() { this.operations.push("chain"); return this.chainId; }
|
async getChainId() { this.operations.push("chain"); return this.chainId; }
|
||||||
async getCode({ address }: { address: Address }) { this.operations.push(`code:${address}`); return "0x6000" as Hex; }
|
async getCode({ address }: { address: Address }) { this.operations.push(`code:${address}`); return "0x6000" as Hex; }
|
||||||
@@ -34,7 +35,7 @@ class ReaderDouble implements BankReader {
|
|||||||
async readContract(call: { address: Address; functionName: string; args?: readonly unknown[]; blockNumber: bigint }) {
|
async readContract(call: { address: Address; functionName: string; args?: readonly unknown[]; blockNumber: bigint }) {
|
||||||
this.operations.push(`read:${call.functionName}`); this.contractCalls.push(call);
|
this.operations.push(`read:${call.functionName}`); this.contractCalls.push(call);
|
||||||
if (this.error) throw this.error;
|
if (this.error) throw this.error;
|
||||||
if (call.functionName === "contractVersion") return 1n;
|
if (call.functionName === "contractVersion") return this.version;
|
||||||
if (call.functionName === "paused") return false;
|
if (call.functionName === "paused") return false;
|
||||||
if (call.functionName === "asset") return this.asset;
|
if (call.functionName === "asset") return this.asset;
|
||||||
if (call.functionName === "owner") return this.bankOwner;
|
if (call.functionName === "owner") return this.bankOwner;
|
||||||
@@ -46,6 +47,17 @@ class ReaderDouble implements BankReader {
|
|||||||
async getLogs(call: { address: Address; fromBlock: bigint; toBlock: bigint }) { this.operations.push("logs"); this.logCalls.push(call); return this.logs; }
|
async getLogs(call: { address: Address; fromBlock: bigint; toBlock: bigint }) { this.operations.push("logs"); this.logCalls.push(call); return this.logs; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function transferredLog(blockNumber: bigint, logIndex: number, from: Address, to: Address, amount: bigint) {
|
||||||
|
return {
|
||||||
|
address: proxy,
|
||||||
|
blockNumber,
|
||||||
|
logIndex,
|
||||||
|
transactionHash: "0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
|
||||||
|
topics: encodeEventTopics({ abi: bankV2Abi, eventName: "BalanceTransferred", args: { from, to } }),
|
||||||
|
data: encodeAbiParameters(parseAbiParameters("uint256"), [amount]),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function depositedLog(blockNumber: bigint, logIndex: number, account: Address, amount: bigint) {
|
function depositedLog(blockNumber: bigint, logIndex: number, account: Address, amount: bigint) {
|
||||||
return {
|
return {
|
||||||
address: proxy,
|
address: proxy,
|
||||||
@@ -125,6 +137,21 @@ describe("loadDashboardSnapshot", () => {
|
|||||||
expect(snapshot.diagnostics).toEqual([]);
|
expect(snapshot.diagnostics).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("loads a V2 snapshot through the proxy and decodes its internal transfer", async () => {
|
||||||
|
// Catches V2 being rejected or BalanceTransferred being decoded with the V1-only ABI.
|
||||||
|
const reader = new ReaderDouble();
|
||||||
|
reader.version = 2n;
|
||||||
|
reader.logs = [transferredLog(9n, 2, alice, owner, 25n)];
|
||||||
|
|
||||||
|
const snapshot = await loadDashboardSnapshot(reader, manifest);
|
||||||
|
|
||||||
|
expect(snapshot.version).toBe(2);
|
||||||
|
expect(snapshot.activity).toEqual([
|
||||||
|
expect.objectContaining({ kind: "transfer", from: alice, to: owner, amount: 25n, blockNumber: 9n, logIndex: 2 }),
|
||||||
|
]);
|
||||||
|
expect(reader.contractCalls.filter((call) => call.address !== token).every((call) => call.address === proxy)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
it("keeps valid activity when one proxy log cannot be decoded", async () => {
|
it("keeps valid activity when one proxy log cannot be decoded", async () => {
|
||||||
// Catches a single malformed RPC log blanking the entire timeline.
|
// Catches a single malformed RPC log blanking the entire timeline.
|
||||||
const reader = new ReaderDouble();
|
const reader = new ReaderDouble();
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { decodeEventLog, getAddress, isAddress, type Abi, type Address, type Hex, type PublicClient } from "viem";
|
import { decodeEventLog, getAddress, isAddress, type Abi, type Address, type Hex, type PublicClient } from "viem";
|
||||||
import { bankV1Abi, mockUsdcAbi } from "../generated/contracts";
|
import { bankV1Abi, bankV2Abi, mockUsdcAbi } from "../generated/contracts";
|
||||||
import type { Activity, DashboardSnapshot, DecodeDiagnostic, DeploymentManifest } from "../types/dashboard";
|
import type { Activity, DashboardSnapshot, DecodeDiagnostic, DeploymentManifest } from "../types/dashboard";
|
||||||
|
|
||||||
export const EIP1967_IMPLEMENTATION_SLOT =
|
export const EIP1967_IMPLEMENTATION_SLOT =
|
||||||
@@ -52,7 +52,7 @@ export async function loadDashboardSnapshot(reader: BankReader, manifest: Deploy
|
|||||||
reader.getLogs({ address: manifest.proxy, fromBlock: manifest.deploymentBlock, toBlock: blockNumber }),
|
reader.getLogs({ address: manifest.proxy, fromBlock: manifest.deploymentBlock, toBlock: blockNumber }),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if (version !== 1n) throw new Error("proxy is not running BankV1");
|
if (version !== 1n && version !== 2n) throw new Error("proxy contract version is unsupported");
|
||||||
if (typeof paused !== "boolean") throw new Error("proxy pause state is invalid");
|
if (typeof paused !== "boolean") throw new Error("proxy pause state is invalid");
|
||||||
if (sameAddress(asAddress(asset, "asset"), manifest.token) === false) throw new Error("proxy asset does not match manifest token");
|
if (sameAddress(asAddress(asset, "asset"), manifest.token) === false) throw new Error("proxy asset does not match manifest token");
|
||||||
if (sameAddress(asAddress(owner, "owner"), manifest.owner) === false) throw new Error("proxy owner does not match manifest owner");
|
if (sameAddress(asAddress(owner, "owner"), manifest.owner) === false) throw new Error("proxy owner does not match manifest owner");
|
||||||
@@ -62,11 +62,12 @@ export async function loadDashboardSnapshot(reader: BankReader, manifest: Deploy
|
|||||||
const resolvedReserves = asBigint(reserves, "reserves");
|
const resolvedReserves = asBigint(reserves, "reserves");
|
||||||
if (resolvedReserves < resolvedLiabilities) throw new Error("proxy is insolvent: reserves are below liabilities");
|
if (resolvedReserves < resolvedLiabilities) throw new Error("proxy is insolvent: reserves are below liabilities");
|
||||||
|
|
||||||
const { activity, diagnostics } = decodeActivity(logs, manifest.proxy);
|
const resolvedVersion = Number(version) as 1 | 2;
|
||||||
|
const { activity, diagnostics } = decodeActivity(logs, manifest.proxy, resolvedVersion);
|
||||||
return {
|
return {
|
||||||
blockNumber,
|
blockNumber,
|
||||||
synchronizedAt: new Date(),
|
synchronizedAt: new Date(),
|
||||||
version: 1,
|
version: resolvedVersion,
|
||||||
paused,
|
paused,
|
||||||
asset: manifest.token,
|
asset: manifest.token,
|
||||||
owner: manifest.owner,
|
owner: manifest.owner,
|
||||||
@@ -102,7 +103,7 @@ function implementationFromSlot(value: Hex | undefined): Address {
|
|||||||
return asAddress(`0x${value.slice(-40)}`, "proxy implementation slot");
|
return asAddress(`0x${value.slice(-40)}`, "proxy implementation slot");
|
||||||
}
|
}
|
||||||
|
|
||||||
function decodeActivity(logs: readonly unknown[], proxy: Address): { activity: readonly Activity[]; diagnostics: readonly DecodeDiagnostic[] } {
|
function decodeActivity(logs: readonly unknown[], proxy: Address, version: 1 | 2): { activity: readonly Activity[]; diagnostics: readonly DecodeDiagnostic[] } {
|
||||||
const activity: Activity[] = [];
|
const activity: Activity[] = [];
|
||||||
const diagnostics: DecodeDiagnostic[] = [];
|
const diagnostics: DecodeDiagnostic[] = [];
|
||||||
for (const log of logs) {
|
for (const log of logs) {
|
||||||
@@ -110,7 +111,7 @@ function decodeActivity(logs: readonly unknown[], proxy: Address): { activity: r
|
|||||||
const identity = { blockNumber: log.blockNumber, logIndex: log.logIndex, transactionHash: log.transactionHash };
|
const identity = { blockNumber: log.blockNumber, logIndex: log.logIndex, transactionHash: log.transactionHash };
|
||||||
try {
|
try {
|
||||||
if (!isLogPayload(log)) throw new Error("proxy event payload is malformed");
|
if (!isLogPayload(log)) throw new Error("proxy event payload is malformed");
|
||||||
const decoded = decodeEventLog({ abi: bankV1Abi, data: log.data, topics: log.topics as [Hex, ...Hex[]] });
|
const decoded = decodeEventLog({ abi: version === 2 ? bankV2Abi : bankV1Abi, data: log.data, topics: log.topics as [Hex, ...Hex[]] });
|
||||||
const next = decodedActivity(decoded.eventName, decoded.args, identity);
|
const next = decodedActivity(decoded.eventName, decoded.args, identity);
|
||||||
if (next) activity.push(next);
|
if (next) activity.push(next);
|
||||||
} catch {
|
} catch {
|
||||||
@@ -141,6 +142,7 @@ function decodedActivity(eventName: string, args: unknown, identity: Readonly<{
|
|||||||
switch (eventName) {
|
switch (eventName) {
|
||||||
case "Deposited": return { ...identity, kind: "deposit", account: asAddress(values.account, "deposit account"), amount: asBigint(values.amount, "deposit amount") };
|
case "Deposited": return { ...identity, kind: "deposit", account: asAddress(values.account, "deposit account"), amount: asBigint(values.amount, "deposit amount") };
|
||||||
case "Withdrawn": return { ...identity, kind: "withdrawal", account: asAddress(values.account, "withdrawal account"), amount: asBigint(values.amount, "withdrawal amount") };
|
case "Withdrawn": return { ...identity, kind: "withdrawal", account: asAddress(values.account, "withdrawal account"), amount: asBigint(values.amount, "withdrawal amount") };
|
||||||
|
case "BalanceTransferred": return { ...identity, kind: "transfer", from: asAddress(values.from, "transfer sender"), to: asAddress(values.to, "transfer recipient"), amount: asBigint(values.amount, "transfer amount") };
|
||||||
case "Paused": return { ...identity, kind: "paused", account: asAddress(values.account, "pause account") };
|
case "Paused": return { ...identity, kind: "paused", account: asAddress(values.account, "pause account") };
|
||||||
case "Unpaused": return { ...identity, kind: "unpaused", account: asAddress(values.account, "unpause account") };
|
case "Unpaused": return { ...identity, kind: "unpaused", account: asAddress(values.account, "unpause account") };
|
||||||
case "OwnershipTransferred": return { ...identity, kind: "ownershipTransferred", previousOwner: asAddress(values.previousOwner, "previous owner"), newOwner: asAddress(values.newOwner, "new owner") };
|
case "OwnershipTransferred": return { ...identity, kind: "ownershipTransferred", previousOwner: asAddress(values.previousOwner, "previous owner"), newOwner: asAddress(values.newOwner, "new owner") };
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ type ActivityIdentity = Readonly<{
|
|||||||
export type Activity =
|
export type Activity =
|
||||||
| Readonly<ActivityIdentity & { kind: "deposit"; account: Address; amount: bigint }>
|
| Readonly<ActivityIdentity & { kind: "deposit"; account: Address; amount: bigint }>
|
||||||
| Readonly<ActivityIdentity & { kind: "withdrawal"; account: Address; amount: bigint }>
|
| Readonly<ActivityIdentity & { kind: "withdrawal"; account: Address; amount: bigint }>
|
||||||
|
| Readonly<ActivityIdentity & { kind: "transfer"; from: Address; to: Address; amount: bigint }>
|
||||||
| Readonly<ActivityIdentity & { kind: "paused"; account: Address }>
|
| Readonly<ActivityIdentity & { kind: "paused"; account: Address }>
|
||||||
| Readonly<ActivityIdentity & { kind: "unpaused"; account: Address }>
|
| Readonly<ActivityIdentity & { kind: "unpaused"; account: Address }>
|
||||||
| Readonly<ActivityIdentity & { kind: "ownershipTransferred"; previousOwner: Address; newOwner: Address }>
|
| Readonly<ActivityIdentity & { kind: "ownershipTransferred"; previousOwner: Address; newOwner: Address }>
|
||||||
@@ -39,7 +40,7 @@ export type DecodeDiagnostic = Readonly<ActivityIdentity & { message: string }>;
|
|||||||
export type DashboardSnapshot = Readonly<{
|
export type DashboardSnapshot = Readonly<{
|
||||||
blockNumber: bigint;
|
blockNumber: bigint;
|
||||||
synchronizedAt: Date;
|
synchronizedAt: Date;
|
||||||
version: 1;
|
version: 1 | 2;
|
||||||
paused: boolean;
|
paused: boolean;
|
||||||
asset: Address;
|
asset: Address;
|
||||||
owner: Address;
|
owner: Address;
|
||||||
|
|||||||
Reference in New Issue
Block a user