# Review package: 1f4175ba729fbbab58538cd3ac02b5459e014c86..0664eb4688bd236e2678c5dda2f4a10e898205ed ## Commits 0664eb4 feat: establish UUPS bank V1 ## Files changed .../superpowers/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(-) ## Diff 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 @@ -7,21 +7,21 @@ **Architecture:** Foundry scripts are the only state-changing control plane. A six-decimal `MockUSDC` and a UUPS `BankV1` implementation run behind a stable ERC-1967 proxy on Anvil or optional Base Sepolia. Scripts export a public deployment manifest and contract ABIs; a wagmi/viem client reads those artifacts and renders reserves, liabilities, identities, actors, and events without a signer or wallet connector. V2 inherits V1, adds no storage, and adds one internal transfer function. **Tech Stack:** Foundry `v1.7.1`, forge-std `v1.16.1`, Solidity `0.8.35`, OpenZeppelin Contracts Upgradeable `v5.6.1`, OpenZeppelin Foundry Upgrades `v0.4.1`, OpenZeppelin Upgrades Core `1.46.0`, Node `24.18.0`, npm `11.17.0`, React `19.2.8`, TypeScript compiler `7.0.2` via `@typescript/native`, TypeScript API compatibility `6.0.2` via the `typescript` alias, Vite `8.2.0`, wagmi `3.7.5`, viem `2.55.8`, TanStack Query `5.101.4`, Vitest `4.1.10`, Testing Library React `16.3.2`, and jsdom `30.0.1`. ## Global Constraints - The contracts are educational and unaudited. Every user-facing surface says: `Educational demo — mock token — never use real funds.` - 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. - Run each red/green command exactly as written. A test that unexpectedly passes in a red step means the test is not proving the intended behavior; fix the test before implementation. - Use exact versions and committed lockfiles/submodule revisions. Do not replace exact versions with ranges. - OpenZeppelin Foundry Upgrades `v0.4.1` invokes `npx @openzeppelin/upgrades-core@^1.45.0`; the root lockfile pins the satisfying implementation to `1.46.0`, and every validation-bearing command runs with `npm_config_offline=true` after setup proves the local CLI is available. - Commit only files listed for the task and inspect `git status --short` before every commit so unrelated user changes remain untouched. ## File and Interface Map @@ -367,39 +367,39 @@ All bank calls in tests target `proxy`; retain `implementation = Upgrades.getImp npm_config_offline=true forge test --match-path test/BankV1Admin.t.sol -vvv --force ``` Expected red: `BankV1` is missing. - [ ] Implement the minimum V1 skeleton with these imports and inheritance order: ```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"; contract BankV1 is Initializable, UUPSUpgradeable, OwnableUpgradeable, PausableUpgradeable, - ReentrancyGuard + ReentrancyGuardTransient { IERC20 internal _asset; mapping(address account => uint256 balance) internal _balances; uint256 internal _totalLiabilities; uint256[47] private __gap; } ``` -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: ```solidity /// @custom:oz-upgrades-unsafe-allow constructor constructor() { _disableInitializers(); } ``` @@ -1074,21 +1074,21 @@ reset-local: ``` Retain the explicit `deploy-v1`, `seed-v1`, and `sync-artifacts` lower-level targets for teaching and recovery. - [ ] Write `README.md` with prerequisites, `make setup`, the ten-minute `make demo-local` quick start, second-terminal commands, architecture paragraph, exact URLs, command reference, and links to both guides. Its trust box must say the token has no value; code is educational/unaudited; real deposits must never be sent; the owner can pause and install arbitrary future logic; UUPS mistakes can corrupt state or brick upgrades; and a real custody product needs professional audits, operational key controls, multisig/timelocked governance, incident procedures, legal advice, and jurisdiction-specific compliance. - [ ] Write the V1-complete `LEARNING_GUIDE.md`: proxy/implementation/delegatecall/storage, constructor vs initializer, disabled implementation initialization, safe/unsafe storage examples, reserves/liabilities/surplus, exact deposit/withdraw flow, SafeERC20, pause, reentrancy, checks-effects-interactions, single-owner arbitrary-upgrade power, UUPS corruption/bricking risk, the complete real-product safeguards disclosure, and local exercises that trigger each named error. - [ ] 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: ```bash bash tools/test-process-safety.sh make doctor make verify ``` Expected: all checks exit `0`. 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); + } +}