From 0664eb4688bd236e2678c5dda2f4a10e898205ed Mon Sep 17 00:00:00 2001 From: golem Date: Mon, 17 Aug 2026 16:54:31 -0600 Subject: [PATCH] feat: establish UUPS bank V1 --- .../plans/2026-08-17-uups-bank-demo.md | 10 +- src/BankV1.sol | 60 ++++++++++++ test/BankV1Admin.t.sol | 94 +++++++++++++++++++ test/helpers/BankTestBase.sol | 33 +++++++ 4 files changed, 192 insertions(+), 5 deletions(-) create mode 100644 src/BankV1.sol create mode 100644 test/BankV1Admin.t.sol create mode 100644 test/helpers/BankTestBase.sol diff --git a/docs/superpowers/plans/2026-08-17-uups-bank-demo.md b/docs/superpowers/plans/2026-08-17-uups-bank-demo.md index 3a3ca8f..cfaaaa6 100644 --- a/docs/superpowers/plans/2026-08-17-uups-bank-demo.md +++ b/docs/superpowers/plans/2026-08-17-uups-bank-demo.md @@ -14,7 +14,7 @@ - State-changing scripts accept only chain IDs `31337` and `84532`; no mainnet RPC, address, target, or configuration is added. - Local accounts come only from Anvil’s standard development mnemonic. Testnet signing uses a named encrypted Foundry keystore and `--account`; no supported command accepts a raw private key or mnemonic environment variable. - Every application call uses the proxy address. The implementation address is read only for validation and explanation. -- `Upgrades`, never `UnsafeUpgrades`, performs deploy/upgrade validation. The sole validator allowance is the constructor annotation immediately above `_disableInitializers()`. +- `Upgrades`, never `UnsafeUpgrades`, performs deploy/upgrade validation. The sole validator allowance is `@custom:oz-upgrades-unsafe-allow constructor` immediately above the constructor that calls `_disableInitializers()`; no Options `unsafeAllow`, exclude, skip, `UnsafeUpgrades`, reachable annotation, or other bypass is permitted. BankV1 uses OpenZeppelin 5.6.1 `ReentrancyGuardTransient`, the user-approved constructor-free guard for this project's Cancun-targeted Anvil and Base Sepolia networks. - V2 does not add, delete, reorder, or change the type of any storage variable and has no initializer. - The browser has no connector, signer, transaction client, or write button. Failed reads remain unknown; they are never rendered as zero. - Generated Foundry output, local manifests, copied web artifacts, `.demo/` process state, `.env`, and `.superpowers/` are ignored by Git. @@ -374,7 +374,7 @@ Expected red: `BankV1` is missing. ```solidity import {Initializable} from "@openzeppelin/contracts/proxy/utils/Initializable.sol"; import {UUPSUpgradeable} from "@openzeppelin/contracts/proxy/utils/UUPSUpgradeable.sol"; -import {ReentrancyGuard} from "@openzeppelin/contracts/utils/ReentrancyGuard.sol"; +import {ReentrancyGuardTransient} from "@openzeppelin/contracts/utils/ReentrancyGuardTransient.sol"; import {OwnableUpgradeable} from "@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol"; import {PausableUpgradeable} from "@openzeppelin/contracts-upgradeable/utils/PausableUpgradeable.sol"; @@ -383,7 +383,7 @@ contract BankV1 is UUPSUpgradeable, OwnableUpgradeable, PausableUpgradeable, - ReentrancyGuard + ReentrancyGuardTransient { IERC20 internal _asset; mapping(address account => uint256 balance) internal _balances; @@ -392,7 +392,7 @@ contract BankV1 is } ``` -Use `error InvalidAsset(address asset);`. The initializer checks the asset before calling only `__Ownable_init(initialOwner)` and `__Pausable_init()`. `Initializable`, `UUPSUpgradeable`, and `ReentrancyGuard` are stateless/shared in pinned OpenZeppelin 5.6.1 and have no initializer calls. +Use `error InvalidAsset(address asset);`. The initializer checks the asset before calling only `__Ownable_init(initialOwner)` and `__Pausable_init()`. `Initializable`, `UUPSUpgradeable`, and `ReentrancyGuardTransient` are stateless/shared in pinned OpenZeppelin 5.6.1 and have no initializer calls. The transient guard is required by the user-approved final design because it is constructor-free and the project's supported Anvil and Base Sepolia networks target Cancun/EIP-1153. - [ ] Add the only permitted validator annotation and no other bypass: @@ -1081,7 +1081,7 @@ Retain the explicit `deploy-v1`, `seed-v1`, and `sync-artifacts` lower-level tar - [ ] Write the prepared portion of `PRESENTER_RUNBOOK.md`: preflight, rehearsal timing, exact three-act story, the approved live Codex prompt verbatim, Act 1 expected state, commands/console callouts, occupied-port/stale-console/test-failure recovery, and the rule that no upgrade runs until `make verify` passes. Describe Act 2/3 expected outcomes without including reference V2 source, and include a closing trust disclosure checklist matching README/UI word-for-word in substance. -- [ ] Implement `scan-project.sh` with a NUL-safe array from `git ls-files`, excluding dependency gitlinks, `docs/superpowers`, and generated lockfiles. Return nonzero if project-owned tracked content contains an actual `PRIVATE_KEY`/`MNEMONIC` assignment, PEM private key, `TODO`, `TBD`, `FIXME`, filler text, or an unsafe upgrade bypass in `src`/`test`/`script`. Construct the scanner’s own pattern from split shell literals so it does not match itself. Permit only the exact hyphenated constructor annotation. The committed `ANVIL_TEST_PHRASE` is the universally known test fixture, not a supported secret input; test that exact fixture is the only scan exception. +- [ ] Implement `scan-project.sh` with a NUL-safe array from `git ls-files`, excluding dependency gitlinks, `docs/superpowers`, and generated lockfiles. Return nonzero if project-owned tracked content contains an actual `PRIVATE_KEY`/`MNEMONIC` assignment, PEM private key, `TODO`, `TBD`, `FIXME`, filler text, or an unsafe upgrade bypass in `src`/`test`/`script`. Construct the scanner’s own pattern from split shell literals so it does not match itself. Permit only the exact hyphenated `oz-upgrades-unsafe-allow constructor` annotation approved for `BankV1`. The committed `ANVIL_TEST_PHRASE` is the universally known test fixture, not a supported secret input; test that exact fixture is the only scan exception. - [ ] Run process and full verification: diff --git a/src/BankV1.sol b/src/BankV1.sol new file mode 100644 index 0000000..13ffca5 --- /dev/null +++ b/src/BankV1.sol @@ -0,0 +1,60 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.35; + +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import {Initializable} from "@openzeppelin/contracts/proxy/utils/Initializable.sol"; +import {UUPSUpgradeable} from "@openzeppelin/contracts/proxy/utils/UUPSUpgradeable.sol"; +import {ReentrancyGuardTransient} from "@openzeppelin/contracts/utils/ReentrancyGuardTransient.sol"; +import {OwnableUpgradeable} from "@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol"; +import {PausableUpgradeable} from "@openzeppelin/contracts-upgradeable/utils/PausableUpgradeable.sol"; + +contract BankV1 is Initializable, UUPSUpgradeable, OwnableUpgradeable, PausableUpgradeable, ReentrancyGuardTransient { + error InvalidAsset(address asset); + + IERC20 internal _asset; + mapping(address account => uint256 balance) internal _balances; + uint256 internal _totalLiabilities; + uint256[47] private __gap; + + /// @custom:oz-upgrades-unsafe-allow constructor + constructor() { + _disableInitializers(); + } + + function initialize(address asset_, address initialOwner) external initializer { + if (asset_ == address(0)) { + revert InvalidAsset(asset_); + } + + __Ownable_init(initialOwner); + __Pausable_init(); + + _asset = IERC20(asset_); + } + + function pause() external onlyOwner { + _pause(); + } + + function unpause() external onlyOwner { + _unpause(); + } + + function asset() external view returns (IERC20) { + return _asset; + } + + function balanceOf(address account) external view returns (uint256) { + return _balances[account]; + } + + function totalLiabilities() external view returns (uint256) { + return _totalLiabilities; + } + + function contractVersion() public pure virtual returns (uint256) { + return 1; + } + + function _authorizeUpgrade(address) internal override onlyOwner {} +} diff --git a/test/BankV1Admin.t.sol b/test/BankV1Admin.t.sol new file mode 100644 index 0000000..7e3c26c --- /dev/null +++ b/test/BankV1Admin.t.sol @@ -0,0 +1,94 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.35; + +import {ERC1967Proxy} from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol"; +import {Initializable} from "@openzeppelin/contracts/proxy/utils/Initializable.sol"; +import {UUPSUpgradeable} from "@openzeppelin/contracts/proxy/utils/UUPSUpgradeable.sol"; +import {OwnableUpgradeable} from "@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol"; + +import {BankV1} from "../src/BankV1.sol"; +import {BankTestBase} from "./helpers/BankTestBase.sol"; + +contract BankV1AdminTest is BankTestBase { + bytes32 private constant ERC1967_IMPLEMENTATION_SLOT = + 0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc; + + function testProxyStartsWithConfiguredAdministrationAndEmptyAccounting() public view { + assertEq(address(bank.asset()), address(token)); + assertEq(bank.owner(), owner); + assertFalse(bank.paused()); + assertEq(bank.balanceOf(alice), 0); + assertEq(bank.totalLiabilities(), 0); + assertEq(bank.contractVersion(), 1); + } + + function testInitializationChecksZeroAssetBeforeZeroOwner() public { + vm.expectRevert(abi.encodeWithSelector(BankV1.InvalidAsset.selector, address(0))); + new ERC1967Proxy(implementation, abi.encodeCall(BankV1.initialize, (address(0), address(0)))); + } + + function testInitializationRejectsZeroOwner() public { + vm.expectRevert(abi.encodeWithSelector(OwnableUpgradeable.OwnableInvalidOwner.selector, address(0))); + new ERC1967Proxy(implementation, abi.encodeCall(BankV1.initialize, (address(token), address(0)))); + } + + function testProxyCannotBeInitializedTwice() public { + vm.expectRevert(Initializable.InvalidInitialization.selector); + bank.initialize(address(token), owner); + } + + function testImplementationCannotBeInitialized() public { + vm.expectRevert(Initializable.InvalidInitialization.selector); + BankV1(implementation).initialize(address(token), owner); + } + + function testOwnerCanPauseAndUnpause() public { + vm.prank(owner); + bank.pause(); + assertTrue(bank.paused()); + + vm.prank(owner); + bank.unpause(); + assertFalse(bank.paused()); + } + + function testNonOwnerCannotPause() public { + vm.prank(stranger); + vm.expectRevert(abi.encodeWithSelector(OwnableUpgradeable.OwnableUnauthorizedAccount.selector, stranger)); + bank.pause(); + } + + function testNonOwnerCannotUnpause() public { + vm.prank(owner); + bank.pause(); + + vm.prank(stranger); + vm.expectRevert(abi.encodeWithSelector(OwnableUpgradeable.OwnableUnauthorizedAccount.selector, stranger)); + bank.unpause(); + } + + function testViewsRemainAvailableWhilePaused() public { + vm.prank(owner); + bank.pause(); + + assertEq(address(bank.asset()), address(token)); + assertEq(bank.balanceOf(alice), 0); + assertEq(bank.totalLiabilities(), 0); + assertEq(bank.contractVersion(), 1); + } + + function testNonOwnerCannotAuthorizeUpgrade() public { + vm.prank(stranger); + vm.expectRevert(abi.encodeWithSelector(OwnableUpgradeable.OwnableUnauthorizedAccount.selector, stranger)); + bank.upgradeToAndCall(implementation, ""); + } + + function testImplementationExposesERC1967ProxiableUUID() public view { + assertEq(BankV1(implementation).proxiableUUID(), ERC1967_IMPLEMENTATION_SLOT); + } + + function testProxyRejectsProxiableUUIDCall() public { + vm.expectRevert(UUPSUpgradeable.UUPSUnauthorizedCallContext.selector); + bank.proxiableUUID(); + } +} diff --git a/test/helpers/BankTestBase.sol b/test/helpers/BankTestBase.sol new file mode 100644 index 0000000..9287cc4 --- /dev/null +++ b/test/helpers/BankTestBase.sol @@ -0,0 +1,33 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.35; + +import {Test} from "forge-std/Test.sol"; +import {Upgrades} from "openzeppelin-foundry-upgrades/Upgrades.sol"; + +import {BankV1} from "../../src/BankV1.sol"; +import {MockUSDC} from "../../src/MockUSDC.sol"; + +abstract contract BankTestBase is Test { + address internal owner; + address internal alice; + address internal bob; + address internal stranger; + + MockUSDC internal token; + address internal proxy; + address internal implementation; + BankV1 internal bank; + + function setUp() public virtual { + owner = makeAddr("owner"); + alice = makeAddr("alice"); + bob = makeAddr("bob"); + stranger = makeAddr("stranger"); + + token = new MockUSDC(owner); + proxy = + Upgrades.deployUUPSProxy("BankV1.sol:BankV1", abi.encodeCall(BankV1.initialize, (address(token), owner))); + bank = BankV1(proxy); + implementation = Upgrades.getImplementationAddress(proxy); + } +}