diff --git a/Makefile b/Makefile index 598bae8..42b3ca6 100644 --- a/Makefile +++ b/Makefile @@ -3,8 +3,9 @@ SHELL := /bin/bash RPC_LOCAL := http://127.0.0.1:8545 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: @bash tools/doctor.sh setup: @@ -51,5 +52,14 @@ deploy-v1: @node tools/select-manifest.mjs anvil seed-v1: @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: @forge script script/CheckState.s.sol:CheckState --rpc-url $(RPC_LOCAL) --force diff --git a/script/CheckState.s.sol b/script/CheckState.s.sol index 1f6c712..0aac173 100644 --- a/script/CheckState.s.sol +++ b/script/CheckState.s.sol @@ -12,11 +12,14 @@ contract CheckState is DemoScript { error UnknownStage(string stage); 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); _printEducationalWarning(); - string memory stage = vm.envOr("DEMO_EXPECTED_STAGE", string("v1")); bool deployedStage = keccak256(bytes(stage)) == keccak256("deployed"); - Manifest memory manifest = _readManifest(_manifestPath(ACTIVE_MANIFEST_PATH), !deployedStage); + Manifest memory manifest = _readManifest(manifestPath, !deployedStage); BankV1 bank = BankV1(manifest.proxy); MockUSDC token = MockUSDC(manifest.token); @@ -53,6 +56,20 @@ contract CheckState is DemoScript { } else if (stageHash == keccak256("v1")) { _assertV1State(manifest); _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")) { revert UnknownStage(stage); } diff --git a/script/TransferV2Demo.s.sol b/script/TransferV2Demo.s.sol new file mode 100644 index 0000000..8dc4943 --- /dev/null +++ b/script/TransferV2Demo.s.sol @@ -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()); + } +} diff --git a/script/UpgradeV2.s.sol b/script/UpgradeV2.s.sol new file mode 100644 index 0000000..578dc36 --- /dev/null +++ b/script/UpgradeV2.s.sol @@ -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); + } +} diff --git a/test/BankInvariant.t.sol b/test/BankInvariant.t.sol index f699902..6140106 100644 --- a/test/BankInvariant.t.sol +++ b/test/BankInvariant.t.sol @@ -2,26 +2,45 @@ pragma solidity 0.8.35; 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 { - BankHandler internal handler; + BankV2Handler internal handler; function setUp() public override { 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); token.transferOwnership(address(handler)); targetContract(address(handler)); - bytes4[] memory selectors = new bytes4[](3); + bytes4[] memory selectors = new bytes4[](4); selectors[0] = handler.deposit.selector; selectors[1] = handler.withdraw.selector; selectors[2] = handler.donate.selector; + selectors[3] = handler.transfer.selector; 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 { uint256 sum; for (uint256 i; i < handler.actorCount(); ++i) { diff --git a/test/ScriptPreflight.t.sol b/test/ScriptPreflight.t.sol index 7f1f96b..0aedca0 100644 --- a/test/ScriptPreflight.t.sol +++ b/test/ScriptPreflight.t.sol @@ -2,11 +2,15 @@ pragma solidity 0.8.35; import {Test} from "forge-std/Test.sol"; +import {Upgrades} from "openzeppelin-foundry-upgrades/Upgrades.sol"; import {DemoScript} from "../script/lib/DemoScript.sol"; import {DeployV1} from "../script/DeployV1.s.sol"; import {SeedV1Demo} from "../script/SeedV1Demo.s.sol"; import {CheckState} from "../script/CheckState.s.sol"; +import {UpgradeV2} from "../script/UpgradeV2.s.sol"; +import {TransferV2Demo} from "../script/TransferV2Demo.s.sol"; import {BankV1} from "../src/BankV1.sol"; +import {BankV2} from "../src/BankV2.sol"; import {MockUSDC} from "../src/MockUSDC.sol"; 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 { ScriptPreflightHarness internal harness; string internal fixtureDir; address internal constant OWNER = 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266; address internal constant ALICE = 0x70997970C51812dc3A010C7d01b50e0d17dc79C8; + address internal constant BOB = 0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC; address internal constant TOKEN = 0x1000000000000000000000000000000000000001; address internal constant PROXY = 0x2000000000000000000000000000000000000002; address internal constant IMPLEMENTATION = 0x3000000000000000000000000000000000000003; @@ -302,7 +333,7 @@ contract ScriptPreflightTest is Test { } 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); new SeedV1Demo().run(); @@ -320,18 +351,176 @@ contract ScriptPreflightTest is Test { } 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.setEnv("DEPLOYMENT_MANIFEST_PATH", path); - vm.setEnv("DEMO_EXPECTED_STAGE", "deployed"); - CheckState checker = new CheckState(); + CheckStateHarness checker = new CheckStateHarness(); vm.expectRevert( abi.encodeWithSelector( 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( @@ -365,7 +554,14 @@ contract ScriptPreflightTest is Test { } 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.setEnv("SCRIPT_SENDER", vm.toString(OWNER)); vm.setEnv("DEPLOYMENT_MANIFEST_PATH", path); @@ -374,6 +570,47 @@ contract ScriptPreflightTest is Test { 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) { path = string.concat(fixtureDir, "/public-anvil-manifest.json"); vm.writeFile( diff --git a/test/helpers/BankV2Handler.sol b/test/helpers/BankV2Handler.sol new file mode 100644 index 0000000..6aa863d --- /dev/null +++ b/test/helpers/BankV2Handler.sol @@ -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; + } +} diff --git a/tools/finalize-manifest.mjs b/tools/finalize-manifest.mjs index 0ac0bb1..3a0109b 100644 --- a/tools/finalize-manifest.mjs +++ b/tools/finalize-manifest.mjs @@ -4,6 +4,7 @@ import { dirname, join, resolve } from "node:path"; import { pathToFileURL } from "node:url"; export const IMPLEMENTATION_SLOT = "0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc"; +export const UPGRADED_TOPIC = "0xbc7cd75a20ee27fd9adebab32041f755214dbc6bffa90cc0225b39da2e5c2d3b"; export const EDUCATIONAL_WARNING = "Educational demo — mock token — never use real funds."; const NETWORKS = { @@ -88,6 +89,201 @@ export async function finalizeDeployment({ root = process.cwd(), rpc }) { 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 } = {}) { const manifest = await readJson(path); 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 "); return finalizeDeployment({ root, rpc: fetchRpc(rest[0]) }); } - throw new Error("usage: finalize-manifest.mjs preflight-deploy | deploy --rpc-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 "); + return finalizeUpgrade({ root, rpc: fetchRpc(rest[0]) }); + } + throw new Error("usage: finalize-manifest.mjs preflight-deploy | deploy --rpc-url | upgrade --rpc-url "); } if (process.argv[1] && pathToFileURL(resolve(process.argv[1])).href === import.meta.url) { diff --git a/tools/sync-web-artifacts.mjs b/tools/sync-web-artifacts.mjs index 912e686..889f712 100644 --- a/tools/sync-web-artifacts.mjs +++ b/tools/sync-web-artifacts.mjs @@ -18,12 +18,19 @@ const bankRequirements = [ eventSignature("Upgraded", [parameter("implementation", "address", true)]), ]; 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) { if (!artifact || typeof artifact !== "object" || !Array.isArray(artifact.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}`); for (const requirement of requirements) { 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) { - 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`; +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 bankV2Abi = ${JSON.stringify(bankV2Abi, null, 2)} as const;\n\nexport const mockUsdcAbi = ${JSON.stringify(mockUsdcAbi, null, 2)} as const;\n`; } export async function syncArtifacts({ bankArtifactPath = resolve(repositoryRoot, "out/BankV1.sol/BankV1.json"), + bankV2ArtifactPath = resolve(repositoryRoot, "out/BankV2.sol/BankV2.json"), tokenArtifactPath = resolve(repositoryRoot, "out/MockUSDC.sol/MockUSDC.json"), outputPath = resolve(repositoryRoot, "web/src/generated/contracts.ts"), check = false, } = {}) { - const [bankArtifact, tokenArtifact] = await Promise.all([ + const [bankArtifact, bankV2Artifact, tokenArtifact] = await Promise.all([ readArtifact(bankArtifactPath, "BankV1"), + readArtifact(bankV2ArtifactPath, "BankV2"), 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) { let existing; try { existing = await readFile(outputPath, "utf8"); } catch { throw new Error("generated contracts module is stale or missing"); } diff --git a/tools/test-finalize-manifest.mjs b/tools/test-finalize-manifest.mjs index 7b4e1eb..762a028 100644 --- a/tools/test-finalize-manifest.mjs +++ b/tools/test-finalize-manifest.mjs @@ -4,15 +4,18 @@ import { tmpdir } from "node:os"; import { dirname, join } from "node:path"; 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"; const TOKEN = "0x1000000000000000000000000000000000000001"; const PROXY = "0x2000000000000000000000000000000000000002"; const IMPLEMENTATION = "0x3000000000000000000000000000000000000003"; +const V2_IMPLEMENTATION = "0x4000000000000000000000000000000000000004"; const OWNER = "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266"; const IMPLEMENTATION_SLOT = "0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc"; 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 () => { 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 () => { await withFixture(async (root) => { 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) { await writeFile(path, `${JSON.stringify(value, null, 2)}\n`); } diff --git a/tools/test-sync-web-artifacts.mjs b/tools/test-sync-web-artifacts.mjs index a5fdc4a..d40471d 100644 --- a/tools/test-sync-web-artifacts.mjs +++ b/tools/test-sync-web-artifacts.mjs @@ -22,6 +22,11 @@ const bankAbi = [ { 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 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 = { schemaVersion: 1, network: "anvil", chainId: 31337, deploymentBlock: 3, rpcUrl: "http://127.0.0.1:8545", token: address, proxy: address, @@ -40,13 +45,18 @@ async function expectRejects(action, pattern) { await withFixture(async (root) => { const bank = join(root, "BankV1.json"); + const bankV2 = join(root, "BankV2.json"); const token = join(root, "MockUSDC.json"); const output = join(root, "contracts.ts"); // 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 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 })); // 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); wrongEvent.find((entry) => entry.name === "Deposited").inputs[0].indexed = false; 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 bankV2Abi = .* 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. - 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); 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) => { diff --git a/web/src/App.test.tsx b/web/src/App.test.tsx index db10867..95f3c3d 100644 --- a/web/src/App.test.tsx +++ b/web/src/App.test.tsx @@ -128,6 +128,34 @@ describe("read-only operations console", () => { 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(); + + 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", () => { const baseManifest: DeploymentManifest = { ...localManifest, diff --git a/web/src/components/ActivityTimeline.tsx b/web/src/components/ActivityTimeline.tsx index 1e05f21..338aff2 100644 --- a/web/src/components/ActivityTimeline.tsx +++ b/web/src/components/ActivityTimeline.tsx @@ -13,6 +13,7 @@ function description(activity: Activity, manifest: DeploymentManifest): string { switch (activity.kind) { 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 "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 "unpaused": return `Unpaused by ${actorLabel(activity.account, 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 { if (!manifest.explorerBaseUrl) return undefined; return `${manifest.explorerBaseUrl.replace(/\/$/, "")}/tx/${transactionHash}`; diff --git a/web/src/data/bankClient.test.ts b/web/src/data/bankClient.test.ts index ed664bb..db5ccaa 100644 --- a/web/src/data/bankClient.test.ts +++ b/web/src/data/bankClient.test.ts @@ -1,6 +1,6 @@ import { encodeAbiParameters, encodeEventTopics, parseAbiParameters, type Address, type Hex } from "viem"; import { describe, expect, it } from "vitest"; -import { bankV1Abi } from "../generated/contracts"; +import { bankV1Abi, bankV2Abi } from "../generated/contracts"; import type { DeploymentManifest } from "../types/dashboard"; import { EIP1967_IMPLEMENTATION_SLOT, loadDashboardSnapshot, type BankReader } from "./bankClient"; @@ -27,6 +27,7 @@ class ReaderDouble implements BankReader { actorBalances = new Map([[owner, 70n], [alice, 30n]]); logs: readonly unknown[] = []; error?: Error; + version = 1n; async getChainId() { this.operations.push("chain"); return this.chainId; } 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 }) { this.operations.push(`read:${call.functionName}`); this.contractCalls.push(call); 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 === "asset") return this.asset; 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; } } +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) { return { address: proxy, @@ -125,6 +137,21 @@ describe("loadDashboardSnapshot", () => { 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 () => { // Catches a single malformed RPC log blanking the entire timeline. const reader = new ReaderDouble(); diff --git a/web/src/data/bankClient.ts b/web/src/data/bankClient.ts index 8b3673c..dd0b5e2 100644 --- a/web/src/data/bankClient.ts +++ b/web/src/data/bankClient.ts @@ -1,5 +1,5 @@ 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"; 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 }), ]); - 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 (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"); @@ -62,11 +62,12 @@ export async function loadDashboardSnapshot(reader: BankReader, manifest: Deploy const resolvedReserves = asBigint(reserves, "reserves"); 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 { blockNumber, synchronizedAt: new Date(), - version: 1, + version: resolvedVersion, paused, asset: manifest.token, owner: manifest.owner, @@ -102,7 +103,7 @@ function implementationFromSlot(value: Hex | undefined): Address { 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 diagnostics: DecodeDiagnostic[] = []; 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 }; try { 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); if (next) activity.push(next); } catch { @@ -141,6 +142,7 @@ function decodedActivity(eventName: string, args: unknown, identity: Readonly<{ switch (eventName) { 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 "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 "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") }; diff --git a/web/src/types/dashboard.ts b/web/src/types/dashboard.ts index 928ee19..9c44b8e 100644 --- a/web/src/types/dashboard.ts +++ b/web/src/types/dashboard.ts @@ -29,6 +29,7 @@ type ActivityIdentity = Readonly<{ export type Activity = | Readonly | Readonly + | Readonly | Readonly | Readonly | Readonly @@ -39,7 +40,7 @@ export type DecodeDiagnostic = Readonly; export type DashboardSnapshot = Readonly<{ blockNumber: bigint; synchronizedAt: Date; - version: 1; + version: 1 | 2; paused: boolean; asset: Address; owner: Address;