feat: demonstrate state-preserving V2 upgrade

This commit is contained in:
golem
2026-08-21 15:31:40 -06:00
parent ca6a99b913
commit 94f2ad6e09
16 changed files with 1082 additions and 36 deletions
+11 -1
View File
@@ -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
+19 -2
View File
@@ -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);
}
+41
View File
@@ -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());
}
}
+183
View File
@@ -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);
}
}
+23 -4
View File
@@ -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) {
+244 -7
View File
@@ -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(
+32
View File
@@ -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
View File
@@ -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 <url>");
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) {
+18 -5
View File
@@ -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"); }
+216 -1
View File
@@ -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`);
}
+20 -6
View File
@@ -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) => {
+28
View File
@@ -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(<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", () => {
const baseManifest: DeploymentManifest = {
...localManifest,
+7
View File
@@ -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}`;
+29 -2
View File
@@ -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<Address, bigint>([[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();
+8 -6
View File
@@ -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") };
+2 -1
View File
@@ -29,6 +29,7 @@ type ActivityIdentity = Readonly<{
export type Activity =
| Readonly<ActivityIdentity & { kind: "deposit"; 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: "unpaused"; account: Address }>
| Readonly<ActivityIdentity & { kind: "ownershipTransferred"; previousOwner: Address; newOwner: Address }>
@@ -39,7 +40,7 @@ export type DecodeDiagnostic = Readonly<ActivityIdentity & { message: string }>;
export type DashboardSnapshot = Readonly<{
blockNumber: bigint;
synchronizedAt: Date;
version: 1;
version: 1 | 2;
paused: boolean;
asset: Address;
owner: Address;