Files
uupl-smart-contract/.superpowers/sdd/2026-08-17-uups-bank-demo/review-1f4175b..0664eb4.diff
T
golemandClaude Opus 5 fa36215def docs: track superpowers working documents in git
The uups-bank-demo wave's SDD records (ledger, six task briefs and reports,
review diffs) and the brainstorm design mockups were git-ignored, so they
existed only on one sandbox VM and reached no remote — this repo had no remote
at all until now.

Removes `.superpowers/` from .gitignore and the `*` .gitignore the superpowers
plugin writes inside .superpowers/sdd/; the second blocks the directory even
with the first removed.

Excluded as ephemeral local-server state, and now ignored by name:
.last-port, .last-token (a 64-char session token for a brainstorm server on a
port that is long gone), and the per-session state/ directories.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Fnwzj6McD6kSkXwjUKFKxe
2026-08-20 14:38:00 -06:00

312 lines
17 KiB
Diff
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 Anvils 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 scanners 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 scanners 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);
+ }
+}