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
This commit is contained in:
golem
2026-08-20 14:38:00 -06:00
co-authored by Claude Opus 5
parent 14980e6083
commit fa36215def
28 changed files with 9368 additions and 1 deletions
@@ -0,0 +1,311 @@
# 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);
+ }
+}