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,86 @@
# SDD ledger — plan: /home/golem/dev/sandbox/codius-test-uupl-smart-contract/.worktrees/uups-bank-demo/docs/superpowers/plans/2026-08-17-uups-bank-demo.md
Setup: isolated worktree `/home/golem/dev/sandbox/codius-test-uupl-smart-contract/.worktrees/uups-bank-demo` on branch `feature/uups-bank-demo`.
Baseline: clean documentation-only repository; no package manifest or runnable test suite exists before Task 1.
Task 1: fix round 1/5 (1 addressed, 1 open — plan-file scope conflict awaiting user ruling; commits eece34c..a6f9533)
Task 1: ruling — keep the plan correction; the task-specific verified-compatibility clause governs the general listed-files constraint.
Task 1: complete (commits 14980e6..a6f9533, review findings resolved)
Task 2: minor (deferred): standardize MockUSDC public NatSpec to the exact global educational warning.
Task 2: fix round 1/5 (1 addressed, 0 open — literal RED/GREEN command evidence reproduced; no new commit)
Task 2: controller check — reported pre-production missing-source failure and isolated reproduction substantiate TDD chronology; no real gap found.
Task 2: complete (commits a6f9533..1f4175b, review clean; 1 minor deferred)
Task 3: awaiting user ruling — pinned upgrades-core rejects OpenZeppelin 5.6.1 ReentrancyGuard's inherited constructor despite its `@custom:stateless` marker.
Task 3: ruling — user approved the single `unsafe-allow-reachable constructor` annotation; all other upgrade validation remains enabled.
Task 3: approved reachable annotation proved ineffective — upgrades-core 1.46.0 applies it only to opcode traversal, not inherited constructor errors; awaiting second ruling.
Task 3: ruling — user selected OpenZeppelin `ReentrancyGuardTransient`; retain the original sole constructor annotation and add no validator bypass.
Task 3: controller check — report confirms literal RED/GREEN, 19/19 full-suite, and exact storage output; focused source scan confirms no validator bypass, while chain allowlisting remains a later script-task requirement.
Task 3: complete (commits 1f4175b..0664eb4, review clean)
Task 4: controller check — prior fixture confirms proxy binding; foundry config confirms Cancun/fixed seed/validation settings; report records literal offline RED/GREEN commands.
Task 4: complete (commits 0664eb4..2a21766, review clean)
Task 5: minor (deferred): make `actorAt` reject indices above three instead of silently returning actor3.
Task 5: fix round 1/5 (1 addressed, 0 open — genuine handler-absent RED and fresh selector-summary GREEN reproduced; no new commit)
Task 5: complete (commits 2a21766..6dcbb03, review clean; 1 minor deferred)
Task 6: PAUSED by user for host reboot at committed HEAD `6dcbb03`; active worker interrupted. Uncommitted work is limited to `script/lib/DemoScript.sol`, `script/DeployV1.s.sol`, `script/SeedV1Demo.s.sol`, `script/CheckState.s.sol`, and `test/ScriptPreflight.t.sol`. Resume Task 6 from focused preflight GREEN/debug; no Anvil process was running.
Task 6: reboot recovery — focused `forge test --match-path test/ScriptPreflight.t.sol -vvv --force` is GREEN (14/14); preserved work is internally coherent and ready for the remaining Node finalizer/selector, Make targets, and live smoke.
## Preflight compatibility scan (restored after reboot)
The approved design spec is authoritative. “Shared interface” below means a direct producer/consumer dependency or a file touched by both tasks, not a merely transitive use of the pinned toolchain.
| Tasks | Producer / consumer or shared file | Finding / ruling |
| --- | --- | --- |
| 1 / 6 | `Makefile`, Foundry/npm pins, upgrades CLI | Compatible: Task 6 extends the exact pinned foundation and offline validator contract. |
| 1 / 7 | `Makefile`, web configuration and package scripts | Compatible: Task 7 adds bridge targets without changing exact dependency pins. |
| 1 / 8 | `web/src/test/setup.ts`, web scripts | Compatible: Task 8 consumes the configured jsdom/Vitest/React setup. |
| 1 / 9 | `Makefile`, `tools/doctor.sh` | Compatible: Task 9 replaces the temporary doctor and completes the gate. |
| 1 / 11 | `Makefile`, generated artifact policy | Compatible: V2 extends the existing gate and ignored generated module. |
| 1 / 12 | `.env.example`, `Makefile` | Compatible: public Base configuration is added later without secrets or floating dependencies. |
| 2 / 3 | `MockUSDC` asset interface | Compatible: V1 stores and reads the fixed six-decimal ERC-20. |
| 2 / 4 | `MockUSDC` and ERC-20 behavior | Compatible: custody tests build on standard mint/transfer/approve behavior. |
| 2 / 5 | Mintable test liquidity | Compatible: handler receives token ownership only in invariant setup. |
| 2 / 6 | Deployment/seeding asset | Compatible: scripts deploy and seed the same mock token. |
| 2 / 7 | Token ABI and reserve reads | Compatible: ABI sync exports `balanceOf`; browser reads proxy token reserves. |
| 3 / 4 | `src/BankV1.sol` | Compatible: Task 4 adds mutations without changing frozen field order or admin behavior. |
| 3 / 5 | `BankTestBase`, V1 proxy | Compatible: invariants target the validated proxy fixture. |
| 3 / 6 | V1 deploy/init/view interfaces | Compatible: scripts consume only established proxy-facing APIs. |
| 3 / 7 | V1 ABI and state views | Compatible: browser model remains V1-only through `demo-start`. |
| 3 / 10 | V1 storage/layout and UUPS authorization | Compatible subject to the recorded user ruling selecting `ReentrancyGuardTransient`; V2 must inherit the resulting actual layout and add no storage. |
| 4 / 5 | Deposit/withdraw accounting | Compatible: ghost counters independently model exact successful deltas and surplus donations. |
| 4 / 6 | Deposit/withdraw and Act 1 values | Compatible: scripted sequence yields the exact tested accounting state. |
| 4 / 10 | V1 behavior retained through V2 | Compatible: upgrade regression explicitly re-exercises V1 mutations. |
| 5 / 11 | `test/BankInvariant.t.sol`, handler interface | Compatible: Task 11 adds transfers without altering liability/reserve ghosts. |
| 6 / 7 | Confirmed/active manifest contract | Compatible: ABI sync is chain-independent; publishing alone consumes validated `active.json`. |
| 6 / 8 | Dashboard deployment identity and Act 1 snapshot | Compatible: UI consumes only validated manifest/state, never a signer. |
| 6 / 9 | `Makefile`, scripts, manifests, process lifecycle | Compatible: orchestration composes Task 6s direct targets and preserves Base state during local reset. |
| 6 / 11 | Upgrade/check scripts, finalizer, manifest identity | Compatible: upgrade mutates only implementation/version and stages before confirmed writes. |
| 6 / 12 | Base script paths, finalizer/selector, manifest schema | Ruling: Task 6 provides chain recognition and generic public manifest validation; Task 12 owns the fully runnable Base actor/RPC/keystore path. Cost if wrong: Task 6 may need a narrow compatibility adjustment when Task 12 adds Base actors. |
| 7 / 8 | Dashboard types, manifest parser, bank client | Compatible: Task 8 renders the typed, V1-only read model. |
| 7 / 9 | ABI sync/publish targets and offline verification | Compatible: `make verify` requires ABI freshness but no live manifest. |
| 7 / 11 | `dashboard.ts`, `bankClient`, artifact sync | Compatible: V2 ABI/activity is deliberately absent at `demo-start` and added only after Task 10. |
| 7 / 12 | manifest publisher/selector and Base explorer/RPC metadata | Compatible: browser receives only public credential-free RPC data. |
| 8 / 9 | V1 console and local orchestration | Compatible: Task 9 launches Vite directly and verifies read-only Act 1 display. |
| 8 / 11 | activity timeline and App tests | Compatible: Task 11 adds one transfer variant while retaining read-only UI constraints. |
| 8 / 12 | generic explorer links and Base manifest | Compatible: links derive only from validated confirmed public metadata. |
| 9 / 10 | `demo-start` checkpoint | Compatible: Task 10 begins after the verified V1 tag and does not rewrite it. |
| 9 / 11 | `Makefile`, scan/process gates, prepared V1 state | Compatible: Task 11 verifies before broadcast and runs from the V1 checkpoint. |
| 9 / 12 | `Makefile`, README/runbook, Base-preserving reset | Compatible: the encore remains optional and offline config tests alone join `verify`. |
| 9 / 13 | README/guides, full smoke and tags | Compatible: Task 13 completes claims only from newly observed output. |
| 10 / 11 | `BankV2`, upgrade validation, V2 transfer ABI | Compatible: scripts/UI consume the tested storage-free extension. |
| 10 / 12 | V2 upgrade/transfer on Base | Compatible: Task 12 reuses the same V2 implementation with explicit actors/signing. |
| 10 / 13 | upgrade proof and V2 learning material | Compatible: documentation is conditioned on final verification evidence. |
| 11 / 12 | upgrade/transfer/check scripts, finalizer, `Makefile` | Compatible: Base branches extend local exact paths without weakening local guards. |
| 11 / 13 | Act 2/3 state and event evidence | Compatible: docs consume exact verified proxy/implementation/balance results. |
| 12 / 13 | README/runbook and optional encore | Compatible: no public-network execution is required for local completion. |
| 1 self | Exact versions, install, RED/GREEN web scaffold, initial gate | Internally consistent; prior compatibility exceptions are committed and recorded. |
| 2 self | Token tests then minimal token | Internally consistent; deferred NatSpec wording is non-load-bearing and final scan/docs can normalize it. |
| 3 self | Admin tests, initializer, storage, validator | Ruling already recorded: use `ReentrancyGuardTransient` because pinned validator rejects the inherited ordinary constructor. Cost if wrong: transient-storage behavior would need replacement and revalidation. |
| 4 self | Adversarial tests and exact custody mutations | Internally consistent; rollback and CEI expectations match implementations. |
| 5 self | Three bounded handler actions and three invariants | Internally consistent; deferred `actorAt` bounds check is non-load-bearing because callers use `actorCount`. |
| 6 self | Preflight, staging, finalization, exact local smoke | Internally consistent after ruling Bases fully configured path belongs to Task 12; confirmed files remain untouched until receipt/code validation. |
| 7 self | Separate ABI sync and manifest publishing | Internally consistent: static verification can run without RPC/manifest while live publishing remains explicit. |
| 8 self | Hook states, console regions, read-only prohibition | Internally consistent; all unknown/failure values remain nonzero-fabricating. |
| 9 self | Process ownership, reset scope, V1 docs/tag | Internally consistent; attached processes and second-terminal smoke are compatible with bounded lifecycle checks. |
| 10 self | Storage-free V2, unit/fuzz/upgrade failures | Internally consistent with the actual V1 layout and no unsafe validation bypass. |
| 11 self | V2 invariants, staging/finalizer, scripts, UI | Internally consistent: already-V2 is a non-broadcast no-op, satisfying the specs refusal to send a transaction. |
| 12 self | Explicit optional Base encore | Internally consistent: only offline fake-value config tests enter the mandatory gate; real network use requires explicit user choice. |
| 13 self | Final docs, static gate, three-act smoke, cleanup/tag | Internally consistent; documentation claims are gated on fresh observed evidence. |
@@ -0,0 +1,569 @@
# Review package: 0664eb4688bd236e2678c5dda2f4a10e898205ed..2a21766237a9c29970519f7fa00482faecd06fdd
## Commits
2a21766 feat: add V1 custody accounting
## Files changed
src/BankV1.sol | 34 ++++
test/BankV1.t.sol | 326 ++++++++++++++++++++++++++++++++++++++
test/mocks/FeeOnTransferToken.sol | 26 +++
test/mocks/ReentrantToken.sol | 104 ++++++++++++
4 files changed, 490 insertions(+)
## Diff
diff --git a/src/BankV1.sol b/src/BankV1.sol
index 13ffca5..0f55426 100644
--- a/src/BankV1.sol
+++ b/src/BankV1.sol
@@ -1,22 +1,31 @@
// SPDX-License-Identifier: MIT
pragma solidity 0.8.35;
import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
+import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.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 {
+ using SafeERC20 for IERC20;
+
error InvalidAsset(address asset);
+ error ZeroAmount();
+ error InsufficientBalance(address account, uint256 available, uint256 requested);
+ error UnexpectedAssetDelta(uint256 expected, uint256 actual);
+
+ event Deposited(address indexed account, uint256 amount);
+ event Withdrawn(address indexed account, uint256 amount);
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();
}
@@ -33,20 +42,45 @@ contract BankV1 is Initializable, UUPSUpgradeable, OwnableUpgradeable, PausableU
}
function pause() external onlyOwner {
_pause();
}
function unpause() external onlyOwner {
_unpause();
}
+ function deposit(uint256 amount) external whenNotPaused nonReentrant {
+ if (amount == 0) revert ZeroAmount();
+
+ uint256 reservesBefore = _asset.balanceOf(address(this));
+ _asset.safeTransferFrom(msg.sender, address(this), amount);
+ uint256 reservesAfter = _asset.balanceOf(address(this));
+ uint256 received = reservesAfter >= reservesBefore ? reservesAfter - reservesBefore : 0;
+ if (received != amount) revert UnexpectedAssetDelta(amount, received);
+
+ _balances[msg.sender] += amount;
+ _totalLiabilities += amount;
+ emit Deposited(msg.sender, amount);
+ }
+
+ function withdraw(uint256 amount) external whenNotPaused nonReentrant {
+ if (amount == 0) revert ZeroAmount();
+ uint256 available = _balances[msg.sender];
+ if (amount > available) revert InsufficientBalance(msg.sender, available, amount);
+
+ _balances[msg.sender] = available - amount;
+ _totalLiabilities -= amount;
+ _asset.safeTransfer(msg.sender, amount);
+ emit Withdrawn(msg.sender, amount);
+ }
+
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;
diff --git a/test/BankV1.t.sol b/test/BankV1.t.sol
new file mode 100644
index 0000000..e53edc1
--- /dev/null
+++ b/test/BankV1.t.sol
@@ -0,0 +1,326 @@
+// SPDX-License-Identifier: MIT
+pragma solidity 0.8.35;
+
+import {IERC20Errors} from "@openzeppelin/contracts/interfaces/draft-IERC6093.sol";
+import {PausableUpgradeable} from "@openzeppelin/contracts-upgradeable/utils/PausableUpgradeable.sol";
+import {ReentrancyGuardTransient} from "@openzeppelin/contracts/utils/ReentrancyGuardTransient.sol";
+import {Upgrades} from "openzeppelin-foundry-upgrades/Upgrades.sol";
+
+import {BankV1} from "../src/BankV1.sol";
+import {BankTestBase} from "./helpers/BankTestBase.sol";
+import {FeeOnTransferToken} from "./mocks/FeeOnTransferToken.sol";
+import {ReentrantToken} from "./mocks/ReentrantToken.sol";
+
+event Deposited(address indexed account, uint256 amount);
+event Withdrawn(address indexed account, uint256 amount);
+
+error ZeroAmount();
+error InsufficientBalance(address account, uint256 available, uint256 requested);
+error UnexpectedAssetDelta(uint256 expected, uint256 actual);
+
+contract BankV1CustodyTest is BankTestBase {
+ uint256 private constant MAX_DEPOSIT = 1_000_000e6;
+
+ function testDepositCreditsExactCustomerAndLiabilityAgainstReceivedReserves() public {
+ _mintAndApprove(alice, 1_000e6, 100e6);
+
+ vm.prank(alice);
+ bank.deposit(100e6);
+
+ assertEq(token.balanceOf(alice), 900e6);
+ assertEq(token.balanceOf(proxy), 100e6);
+ assertEq(bank.balanceOf(alice), 100e6);
+ assertEq(bank.totalLiabilities(), 100e6);
+ }
+
+ function testDepositEmitsDepositedEvent() public {
+ _mintAndApprove(alice, 100e6, 100e6);
+
+ vm.expectEmit(true, false, false, true, proxy);
+ emit Deposited(alice, 100e6);
+ vm.prank(alice);
+ bank.deposit(100e6);
+ }
+
+ function testDepositRejectsZeroAmount() public {
+ vm.prank(alice);
+ vm.expectRevert(ZeroAmount.selector);
+ bank.deposit(0);
+ }
+
+ function testDepositRejectsCallsWhilePaused() public {
+ _mintAndApprove(alice, 100e6, 100e6);
+ vm.prank(owner);
+ bank.pause();
+
+ vm.prank(alice);
+ vm.expectRevert(PausableUpgradeable.EnforcedPause.selector);
+ bank.deposit(100e6);
+ }
+
+ function testDepositRollsBackWhenAllowanceIsInadequate() public {
+ _mintAndApprove(alice, 100e6, 99e6);
+
+ vm.prank(alice);
+ vm.expectRevert(abi.encodeWithSelector(IERC20Errors.ERC20InsufficientAllowance.selector, proxy, 99e6, 100e6));
+ bank.deposit(100e6);
+
+ _assertEmptyAccounting(alice);
+ assertEq(token.balanceOf(alice), 100e6);
+ }
+
+ function testDepositRollsBackWhenWalletBalanceIsInadequate() public {
+ _mintAndApprove(alice, 99e6, 100e6);
+
+ vm.prank(alice);
+ vm.expectRevert(abi.encodeWithSelector(IERC20Errors.ERC20InsufficientBalance.selector, alice, 99e6, 100e6));
+ bank.deposit(100e6);
+
+ _assertEmptyAccounting(alice);
+ assertEq(token.balanceOf(alice), 99e6);
+ }
+
+ function testDepositsKeepTwoCustomersAccountingIndependent() public {
+ _mintAndApprove(alice, 1_000e6, 300e6);
+ _mintAndApprove(bob, 1_000e6, 700e6);
+
+ vm.prank(alice);
+ bank.deposit(300e6);
+ vm.prank(bob);
+ bank.deposit(700e6);
+
+ assertEq(bank.balanceOf(alice), 300e6);
+ assertEq(bank.balanceOf(bob), 700e6);
+ assertEq(bank.totalLiabilities(), 1_000e6);
+ assertEq(token.balanceOf(proxy), 1_000e6);
+ }
+
+ function testFeeOnTransferDepositRevertsAndRollsBackTokenAndAccounting() public {
+ FeeOnTransferToken feeToken = new FeeOnTransferToken();
+ BankV1 feeBank = _deployBank(address(feeToken));
+ feeToken.mint(alice, 100e6);
+ vm.prank(alice);
+ feeToken.approve(address(feeBank), 100e6);
+
+ vm.prank(alice);
+ vm.expectRevert(abi.encodeWithSelector(UnexpectedAssetDelta.selector, 100e6, 99e6));
+ feeBank.deposit(100e6);
+
+ assertEq(feeToken.balanceOf(alice), 100e6);
+ assertEq(feeToken.balanceOf(address(feeBank)), 0);
+ assertEq(feeBank.balanceOf(alice), 0);
+ assertEq(feeBank.totalLiabilities(), 0);
+ }
+
+ function testDepositSwallowsNestedRevertAndCreditsOnlyOnce() public {
+ ReentrantToken reentrantToken = new ReentrantToken();
+ BankV1 reentrantBank = _deployBank(address(reentrantToken));
+ reentrantToken.mint(alice, 100e6);
+ vm.prank(alice);
+ reentrantToken.approve(address(reentrantBank), 100e6);
+ reentrantToken.configureDepositCallback(address(reentrantBank), false);
+
+ vm.prank(alice);
+ reentrantBank.deposit(100e6);
+
+ assertTrue(reentrantToken.nestedCallAttempted());
+ assertFalse(reentrantToken.nestedCallSucceeded());
+ assertEq(reentrantToken.nestedRevertSelector(), ReentrancyGuardTransient.ReentrancyGuardReentrantCall.selector);
+ assertEq(reentrantToken.observedAccountBalance(), 0);
+ assertEq(reentrantToken.observedLiabilities(), 0);
+ assertEq(reentrantToken.balanceOf(alice), 0);
+ assertEq(reentrantToken.balanceOf(address(reentrantBank)), 100e6);
+ assertEq(reentrantBank.balanceOf(alice), 100e6);
+ assertEq(reentrantBank.totalLiabilities(), 100e6);
+ }
+
+ function testDepositPropagatesNestedRevertAtomicallyWhenConfigured() public {
+ ReentrantToken reentrantToken = new ReentrantToken();
+ BankV1 reentrantBank = _deployBank(address(reentrantToken));
+ reentrantToken.mint(alice, 100e6);
+ vm.prank(alice);
+ reentrantToken.approve(address(reentrantBank), 100e6);
+ reentrantToken.configureDepositCallback(address(reentrantBank), true);
+
+ vm.prank(alice);
+ vm.expectRevert(ReentrancyGuardTransient.ReentrancyGuardReentrantCall.selector);
+ reentrantBank.deposit(100e6);
+
+ assertEq(reentrantToken.balanceOf(alice), 100e6);
+ assertEq(reentrantToken.balanceOf(address(reentrantBank)), 0);
+ assertEq(reentrantBank.balanceOf(alice), 0);
+ assertEq(reentrantBank.totalLiabilities(), 0);
+ }
+
+ function testWithdrawDebitsExactCustomerLiabilityAndReserves() public {
+ _deposit(alice, 1_000e6);
+
+ vm.prank(alice);
+ bank.withdraw(400e6);
+
+ assertEq(token.balanceOf(alice), 400e6);
+ assertEq(token.balanceOf(proxy), 600e6);
+ assertEq(bank.balanceOf(alice), 600e6);
+ assertEq(bank.totalLiabilities(), 600e6);
+ }
+
+ function testWithdrawEmitsWithdrawnEvent() public {
+ _deposit(alice, 100e6);
+
+ vm.expectEmit(true, false, false, true, proxy);
+ emit Withdrawn(alice, 40e6);
+ vm.prank(alice);
+ bank.withdraw(40e6);
+ }
+
+ function testWithdrawRejectsZeroAmount() public {
+ vm.prank(alice);
+ vm.expectRevert(ZeroAmount.selector);
+ bank.withdraw(0);
+ }
+
+ function testWithdrawRejectsCallsWhilePaused() public {
+ _deposit(alice, 100e6);
+ vm.prank(owner);
+ bank.pause();
+
+ vm.prank(alice);
+ vm.expectRevert(PausableUpgradeable.EnforcedPause.selector);
+ bank.withdraw(100e6);
+ }
+
+ function testWithdrawReportsAvailableAndRequestedOnInsufficientInternalBalance() public {
+ _deposit(alice, 40e6);
+
+ vm.prank(alice);
+ vm.expectRevert(abi.encodeWithSelector(InsufficientBalance.selector, alice, 40e6, 41e6));
+ bank.withdraw(41e6);
+
+ assertEq(bank.balanceOf(alice), 40e6);
+ assertEq(bank.totalLiabilities(), 40e6);
+ assertEq(token.balanceOf(proxy), 40e6);
+ }
+
+ function testWithdrawUpdatesAccountingBeforeTransferCallbackAndCannotDoubleDebit() public {
+ ReentrantToken reentrantToken = new ReentrantToken();
+ BankV1 reentrantBank = _deployBank(address(reentrantToken));
+ reentrantToken.mint(alice, 100e6);
+ vm.prank(alice);
+ reentrantToken.approve(address(reentrantBank), 100e6);
+ vm.prank(alice);
+ reentrantBank.deposit(100e6);
+ reentrantToken.configureWithdrawalCallback(address(reentrantBank), false);
+
+ vm.prank(alice);
+ reentrantBank.withdraw(40e6);
+
+ assertEq(reentrantToken.observedAccountBalance(), 60e6);
+ assertEq(reentrantToken.observedLiabilities(), 60e6);
+ assertTrue(reentrantToken.nestedCallAttempted());
+ assertFalse(reentrantToken.nestedCallSucceeded());
+ assertEq(reentrantToken.nestedRevertSelector(), ReentrancyGuardTransient.ReentrancyGuardReentrantCall.selector);
+ assertEq(reentrantToken.balanceOf(alice), 40e6);
+ assertEq(reentrantToken.balanceOf(address(reentrantBank)), 60e6);
+ assertEq(reentrantBank.balanceOf(alice), 60e6);
+ assertEq(reentrantBank.totalLiabilities(), 60e6);
+ }
+
+ function testOneCustomersWithdrawalLeavesOtherCustomerUnchanged() public {
+ _deposit(alice, 100e6);
+ _deposit(bob, 200e6);
+
+ vm.prank(alice);
+ bank.withdraw(40e6);
+
+ assertEq(bank.balanceOf(alice), 60e6);
+ assertEq(bank.balanceOf(bob), 200e6);
+ assertEq(bank.totalLiabilities(), 260e6);
+ assertEq(token.balanceOf(proxy), 260e6);
+ }
+
+ function testDirectTransferCreatesSurplusThatRemainsAfterFullWithdrawal() public {
+ _deposit(alice, 100e6);
+ vm.prank(owner);
+ token.mint(bob, 25e6);
+ vm.prank(bob);
+ token.transfer(proxy, 25e6);
+
+ assertEq(token.balanceOf(proxy), 125e6);
+ assertEq(bank.totalLiabilities(), 100e6);
+
+ vm.prank(alice);
+ bank.withdraw(100e6);
+
+ assertEq(token.balanceOf(proxy), 25e6);
+ assertEq(bank.balanceOf(alice), 0);
+ assertEq(bank.totalLiabilities(), 0);
+ }
+
+ function testFuzzDepositPreservesExactAccounting(uint256 amountSeed) public {
+ uint256 amount = bound(amountSeed, 1, MAX_DEPOSIT);
+ _mintAndApprove(alice, amount, amount);
+
+ vm.prank(alice);
+ bank.deposit(amount);
+
+ assertEq(token.balanceOf(alice), 0);
+ assertEq(token.balanceOf(proxy), amount);
+ assertEq(bank.balanceOf(alice), amount);
+ assertEq(bank.totalLiabilities(), amount);
+ }
+
+ function testFuzzWithdrawPreservesExactAccounting(uint256 depositSeed, uint256 withdrawalSeed) public {
+ uint256 deposited = bound(depositSeed, 1, MAX_DEPOSIT);
+ uint256 withdrawn = bound(withdrawalSeed, 1, deposited);
+ _deposit(alice, deposited);
+
+ vm.prank(alice);
+ bank.withdraw(withdrawn);
+
+ uint256 remaining = deposited - withdrawn;
+ assertEq(token.balanceOf(alice), withdrawn);
+ assertEq(token.balanceOf(proxy), remaining);
+ assertEq(bank.balanceOf(alice), remaining);
+ assertEq(bank.totalLiabilities(), remaining);
+ }
+
+ function testFuzzOverWithdrawAlwaysReverts(uint256 depositSeed, uint256 excessSeed) public {
+ uint256 deposited = bound(depositSeed, 1, MAX_DEPOSIT);
+ uint256 excess = bound(excessSeed, 1, MAX_DEPOSIT);
+ uint256 requested = deposited + excess;
+ _deposit(alice, deposited);
+
+ vm.prank(alice);
+ vm.expectRevert(abi.encodeWithSelector(InsufficientBalance.selector, alice, deposited, requested));
+ bank.withdraw(requested);
+
+ assertEq(bank.balanceOf(alice), deposited);
+ assertEq(bank.totalLiabilities(), deposited);
+ assertEq(token.balanceOf(proxy), deposited);
+ }
+
+ function _mintAndApprove(address account, uint256 mintAmount, uint256 approveAmount) private {
+ vm.prank(owner);
+ token.mint(account, mintAmount);
+ vm.prank(account);
+ token.approve(proxy, approveAmount);
+ }
+
+ function _deposit(address account, uint256 amount) private {
+ _mintAndApprove(account, amount, amount);
+ vm.prank(account);
+ bank.deposit(amount);
+ }
+
+ function _deployBank(address asset_) private returns (BankV1 deployedBank) {
+ address deployedProxy =
+ Upgrades.deployUUPSProxy("BankV1.sol:BankV1", abi.encodeCall(BankV1.initialize, (asset_, owner)));
+ deployedBank = BankV1(deployedProxy);
+ }
+
+ function _assertEmptyAccounting(address account) private view {
+ assertEq(token.balanceOf(proxy), 0);
+ assertEq(bank.balanceOf(account), 0);
+ assertEq(bank.totalLiabilities(), 0);
+ }
+}
diff --git a/test/mocks/FeeOnTransferToken.sol b/test/mocks/FeeOnTransferToken.sol
new file mode 100644
index 0000000..80f5280
--- /dev/null
+++ b/test/mocks/FeeOnTransferToken.sol
@@ -0,0 +1,26 @@
+// SPDX-License-Identifier: MIT
+pragma solidity 0.8.35;
+
+import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol";
+
+contract FeeOnTransferToken is ERC20 {
+ constructor() ERC20("Fee-on-Transfer Token", "FOT") {}
+
+ function decimals() public pure override returns (uint8) {
+ return 6;
+ }
+
+ function mint(address to, uint256 amount) external {
+ _mint(to, amount);
+ }
+
+ function transferFrom(address from, address to, uint256 amount) public override returns (bool) {
+ address spender = _msgSender();
+ _spendAllowance(from, spender, amount);
+
+ uint256 received = amount * 99 / 100;
+ _transfer(from, to, received);
+ _burn(from, amount - received);
+ return true;
+ }
+}
diff --git a/test/mocks/ReentrantToken.sol b/test/mocks/ReentrantToken.sol
new file mode 100644
index 0000000..98dabf3
--- /dev/null
+++ b/test/mocks/ReentrantToken.sol
@@ -0,0 +1,104 @@
+// SPDX-License-Identifier: MIT
+pragma solidity 0.8.35;
+
+import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol";
+
+interface IReentrantBankTarget {
+ function deposit(uint256 amount) external;
+ function withdraw(uint256 amount) external;
+ function balanceOf(address account) external view returns (uint256);
+ function totalLiabilities() external view returns (uint256);
+}
+
+contract ReentrantToken is ERC20 {
+ enum Callback {
+ None,
+ Deposit,
+ Withdraw
+ }
+
+ IReentrantBankTarget public callbackTarget;
+ Callback public callback;
+ bool public propagateRevert;
+ bool public nestedCallAttempted;
+ bool public nestedCallSucceeded;
+ bytes4 public nestedRevertSelector;
+ uint256 public observedAccountBalance;
+ uint256 public observedLiabilities;
+
+ constructor() ERC20("Reentrant Token", "REENT") {}
+
+ function decimals() public pure override returns (uint8) {
+ return 6;
+ }
+
+ function mint(address to, uint256 amount) external {
+ _mint(to, amount);
+ }
+
+ function configureDepositCallback(address bank, bool propagate) external {
+ callbackTarget = IReentrantBankTarget(bank);
+ callback = Callback.Deposit;
+ propagateRevert = propagate;
+ _resetObservations();
+ }
+
+ function configureWithdrawalCallback(address bank, bool propagate) external {
+ callbackTarget = IReentrantBankTarget(bank);
+ callback = Callback.Withdraw;
+ propagateRevert = propagate;
+ _resetObservations();
+ }
+
+ function clearCallback() external {
+ callback = Callback.None;
+ propagateRevert = false;
+ _resetObservations();
+ }
+
+ function transferFrom(address from, address to, uint256 amount) public override returns (bool) {
+ if (callback == Callback.Deposit && _msgSender() == address(callbackTarget)) {
+ observedAccountBalance = callbackTarget.balanceOf(from);
+ observedLiabilities = callbackTarget.totalLiabilities();
+ _attemptNestedCall(abi.encodeCall(IReentrantBankTarget.deposit, (1)));
+ }
+ return super.transferFrom(from, to, amount);
+ }
+
+ function transfer(address to, uint256 amount) public override returns (bool) {
+ if (callback == Callback.Withdraw && _msgSender() == address(callbackTarget)) {
+ observedAccountBalance = callbackTarget.balanceOf(to);
+ observedLiabilities = callbackTarget.totalLiabilities();
+ _attemptNestedCall(abi.encodeCall(IReentrantBankTarget.withdraw, (1)));
+ }
+ return super.transfer(to, amount);
+ }
+
+ function _attemptNestedCall(bytes memory callData) private {
+ nestedCallAttempted = true;
+ bytes memory revertData;
+ (nestedCallSucceeded, revertData) = address(callbackTarget).call(callData);
+
+ if (!nestedCallSucceeded && revertData.length >= 4) {
+ bytes4 selector;
+ assembly ("memory-safe") {
+ selector := mload(add(revertData, 0x20))
+ }
+ nestedRevertSelector = selector;
+ }
+
+ if (!nestedCallSucceeded && propagateRevert) {
+ assembly ("memory-safe") {
+ revert(add(revertData, 0x20), mload(revertData))
+ }
+ }
+ }
+
+ function _resetObservations() private {
+ nestedCallAttempted = false;
+ nestedCallSucceeded = false;
+ nestedRevertSelector = bytes4(0);
+ observedAccountBalance = 0;
+ observedLiabilities = 0;
+ }
+}
File diff suppressed because it is too large Load Diff
@@ -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);
+ }
+}
@@ -0,0 +1,7 @@
# Review package: 1f4175ba729fbbab58538cd3ac02b5459e014c86..1f4175ba729fbbab58538cd3ac02b5459e014c86
## Commits
## Files changed
## Diff
@@ -0,0 +1,145 @@
# Review package: 2a21766237a9c29970519f7fa00482faecd06fdd..6dcbb03f3c79979416c2f0dd37556539276a4e29
## Commits
6dcbb03 test: prove V1 accounting invariants
## Files changed
test/BankInvariant.t.sol | 43 ++++++++++++++++++++++++
test/helpers/BankHandler.sol | 79 ++++++++++++++++++++++++++++++++++++++++++++
2 files changed, 122 insertions(+)
## Diff
diff --git a/test/BankInvariant.t.sol b/test/BankInvariant.t.sol
new file mode 100644
index 0000000..f699902
--- /dev/null
+++ b/test/BankInvariant.t.sol
@@ -0,0 +1,43 @@
+// SPDX-License-Identifier: MIT
+pragma solidity 0.8.35;
+
+import {BankTestBase} from "./helpers/BankTestBase.sol";
+import {BankHandler} from "./helpers/BankHandler.sol";
+
+contract BankInvariantTest is BankTestBase {
+ BankHandler internal handler;
+
+ function setUp() public override {
+ super.setUp();
+
+ handler = new BankHandler(token, bank);
+ vm.prank(owner);
+ token.transferOwnership(address(handler));
+
+ targetContract(address(handler));
+ bytes4[] memory selectors = new bytes4[](3);
+ selectors[0] = handler.deposit.selector;
+ selectors[1] = handler.withdraw.selector;
+ selectors[2] = handler.donate.selector;
+ targetSelector(FuzzSelector({addr: address(handler), selectors: selectors}));
+ }
+
+ function invariant_liabilitiesEqualTrackedBalances() public view {
+ uint256 sum;
+ for (uint256 i; i < handler.actorCount(); ++i) {
+ sum += bank.balanceOf(handler.actorAt(i));
+ }
+ assertEq(sum, bank.totalLiabilities());
+ }
+
+ function invariant_reservesCoverLiabilities() public view {
+ assertGe(token.balanceOf(address(bank)), bank.totalLiabilities());
+ }
+
+ function invariant_ghostAccountingMatchesChain() public view {
+ assertEq(handler.ghostDeposited() - handler.ghostWithdrawn(), bank.totalLiabilities());
+ assertEq(
+ handler.ghostDeposited() + handler.ghostDonated() - handler.ghostWithdrawn(), token.balanceOf(address(bank))
+ );
+ }
+}
diff --git a/test/helpers/BankHandler.sol b/test/helpers/BankHandler.sol
new file mode 100644
index 0000000..20ec183
--- /dev/null
+++ b/test/helpers/BankHandler.sol
@@ -0,0 +1,79 @@
+// SPDX-License-Identifier: MIT
+pragma solidity 0.8.35;
+
+import {Test} from "forge-std/Test.sol";
+
+import {BankV1} from "../../src/BankV1.sol";
+import {MockUSDC} from "../../src/MockUSDC.sol";
+
+contract BankHandler is Test {
+ MockUSDC internal immutable token;
+ BankV1 internal immutable bank;
+
+ address internal immutable actor0;
+ address internal immutable actor1;
+ address internal immutable actor2;
+ address internal immutable actor3;
+
+ uint256 public ghostDeposited;
+ uint256 public ghostWithdrawn;
+ uint256 public ghostDonated;
+
+ constructor(MockUSDC token_, BankV1 bank_) {
+ token = token_;
+ bank = bank_;
+ actor0 = address(0x1001);
+ actor1 = address(0x1002);
+ actor2 = address(0x1003);
+ actor3 = address(0x1004);
+ }
+
+ function deposit(uint256 actorSeed, uint256 amount) external {
+ address actor = actorAt(actorSeed % actorCount());
+ amount = bound(amount, 1, 10_000e6);
+ token.mint(actor, amount);
+
+ vm.startPrank(actor);
+ token.approve(address(bank), amount);
+ bank.deposit(amount);
+ vm.stopPrank();
+
+ ghostDeposited += amount;
+ }
+
+ function withdraw(uint256 actorSeed, uint256 amount) external {
+ address actor = actorAt(actorSeed % actorCount());
+ uint256 balance = bank.balanceOf(actor);
+ if (balance == 0) return;
+
+ amount = bound(amount, 1, balance);
+ vm.startPrank(actor);
+ bank.withdraw(amount);
+ vm.stopPrank();
+
+ ghostWithdrawn += amount;
+ }
+
+ function donate(uint256 actorSeed, uint256 amount) external {
+ address actor = actorAt(actorSeed % actorCount());
+ amount = bound(amount, 1, 1_000e6);
+ token.mint(actor, amount);
+
+ vm.startPrank(actor);
+ token.transfer(address(bank), amount);
+ vm.stopPrank();
+
+ ghostDonated += amount;
+ }
+
+ function actorCount() public pure returns (uint256) {
+ return 4;
+ }
+
+ function actorAt(uint256 index) public view returns (address) {
+ if (index == 0) return actor0;
+ if (index == 1) return actor1;
+ if (index == 2) return actor2;
+ return actor3;
+ }
+}
@@ -0,0 +1,7 @@
# Review package: 6dcbb03f3c79979416c2f0dd37556539276a4e29..6dcbb03f3c79979416c2f0dd37556539276a4e29
## Commits
## Files changed
## Diff
@@ -0,0 +1,101 @@
# Review package: a6f9533aacd4d6cdbbf9682bfd8482ced065ebf5..1f4175ba729fbbab58538cd3ac02b5459e014c86
## Commits
1f4175b feat: add valueless mock USDC
## Files changed
src/MockUSDC.sol | 18 ++++++++++++++++
test/MockUSDC.t.sol | 60 +++++++++++++++++++++++++++++++++++++++++++++++++++++
2 files changed, 78 insertions(+)
## Diff
diff --git a/src/MockUSDC.sol b/src/MockUSDC.sol
new file mode 100644
index 0000000..96ca297
--- /dev/null
+++ b/src/MockUSDC.sol
@@ -0,0 +1,18 @@
+// SPDX-License-Identifier: MIT
+pragma solidity 0.8.35;
+
+import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol";
+import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol";
+
+/// @notice Educational mock token with no monetary value. Never use as real USDC.
+contract MockUSDC is ERC20, Ownable {
+ constructor(address initialOwner) ERC20("Mock USD Coin", "mUSDC") Ownable(initialOwner) {}
+
+ function decimals() public pure override returns (uint8) {
+ return 6;
+ }
+
+ function mint(address to, uint256 amount) external onlyOwner {
+ _mint(to, amount);
+ }
+}
diff --git a/test/MockUSDC.t.sol b/test/MockUSDC.t.sol
new file mode 100644
index 0000000..028c1a0
--- /dev/null
+++ b/test/MockUSDC.t.sol
@@ -0,0 +1,60 @@
+// SPDX-License-Identifier: MIT
+pragma solidity 0.8.35;
+
+import {Test} from "forge-std/Test.sol";
+import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol";
+import {MockUSDC} from "../src/MockUSDC.sol";
+
+contract MockUSDCTest is Test {
+ MockUSDC internal token;
+ address internal constant STRANGER = address(0xBEEF);
+ address internal constant RECIPIENT = address(0xCAFE);
+ address internal constant SPENDER = address(0xD00D);
+
+ function setUp() public {
+ token = new MockUSDC(address(this));
+ }
+
+ function testMetadataUsesSixDecimalMockUSDC() public view {
+ assertEq(token.name(), "Mock USD Coin");
+ assertEq(token.symbol(), "mUSDC");
+ assertEq(token.decimals(), 6);
+ }
+
+ function testInitialSupplyIsZero() public view {
+ assertEq(token.totalSupply(), 0);
+ }
+
+ function testMintMintsToRecipientWhenCalledByOwner() public {
+ token.mint(RECIPIENT, 1_250_000);
+
+ assertEq(token.totalSupply(), 1_250_000);
+ assertEq(token.balanceOf(RECIPIENT), 1_250_000);
+ }
+
+ function testMintRevertsWhenCalledByNonOwner() public {
+ vm.prank(STRANGER);
+ vm.expectRevert(abi.encodeWithSelector(Ownable.OwnableUnauthorizedAccount.selector, STRANGER));
+ token.mint(RECIPIENT, 1);
+ }
+
+ function testTransferMovesMintedBalance() public {
+ token.mint(address(this), 1_250_000);
+
+ token.transfer(RECIPIENT, 250_000);
+
+ assertEq(token.balanceOf(address(this)), 1_000_000);
+ assertEq(token.balanceOf(RECIPIENT), 250_000);
+ }
+
+ function testApproveSetsAllowanceForSpender() public {
+ token.approve(SPENDER, 750_000);
+
+ assertEq(token.allowance(address(this), SPENDER), 750_000);
+ }
+
+ function testConstructorRevertsForZeroInitialOwner() public {
+ vm.expectRevert(abi.encodeWithSelector(Ownable.OwnableInvalidOwner.selector, address(0)));
+ new MockUSDC(address(0));
+ }
+}
@@ -0,0 +1,31 @@
# Review package: eece34c088d52f1650baef34b51a8fa26ec12a00..a6f9533aacd4d6cdbbf9682bfd8482ced065ebf5
## Commits
a6f9533 build: approve pinned install scripts
## Files changed
package.json | 4 ++++
1 file changed, 4 insertions(+)
## Diff
diff --git a/package.json b/package.json
index 1612ecb..dd15769 100644
--- a/package.json
+++ b/package.json
@@ -3,12 +3,16 @@
"private": true,
"version": "0.1.0",
"type": "module",
"packageManager": "npm@11.17.0",
"engines": {
"node": ">=24.18.0 <25",
"npm": ">=11.17.0 <12"
},
"devDependencies": {
"@openzeppelin/upgrades-core": "1.46.0"
+ },
+ "allowScripts": {
+ "keccak@3.0.4": true,
+ "secp256k1@4.0.5": true
}
}
@@ -0,0 +1,206 @@
### Task 1: Pin and prove the repository toolchain
**Files:**
- Modify: `.gitignore`
- Create: `.nvmrc`
- Create: `.env.example`
- Create: `.gitmodules` (generated by `forge install`)
- Create: `foundry.lock` (generated by `forge install`)
- Create: `foundry.toml`
- Create: `remappings.txt`
- Create: `Makefile`
- Create: `package.json`
- Create: `package-lock.json` (generated by npm)
- Create: `tools/check-upgrades-cli.mjs`
- Create: `web/package.json`
- Create: `web/package-lock.json` (generated by npm)
- Create: `web/index.html`
- Create: `web/tsconfig.json`
- Create: `web/tsconfig.app.json`
- Create: `web/tsconfig.node.json`
- Create: `web/vite.config.ts`
- Create: `web/eslint.config.js`
- Create: `web/src/test/toolchain.test.ts`
**Interfaces:**
- Produces `make doctor`, `make setup`, and the initial verification gate that later tasks extend.
- Produces exact dependency revisions for every later Solidity and web task.
- Consumes no application source.
- [ ] Verify host tools and record the expected initial limitation:
```bash
forge --version
anvil --version
node --version
npm --version
make --version
```
Expected now: Foundry commands may be missing; Node/npm/make print their installed versions. If Foundry is missing during execution, request permission to install Foundry from its official installer, then pin with `foundryup -i 1.7.1`. Do not silently install system-wide tools.
- [ ] Extend `.gitignore` with exactly these runtime classes:
```gitignore
.env
.env.local
.demo/
cache/
out/
broadcast/
deployments/*.json
deployments/**/*.json
!deployments/*.example.json
node_modules/
web/node_modules/
web/dist/
web/coverage/
web/public/deployment.json
web/src/generated/*.ts
!.gitkeep
.superpowers/
```
- [ ] Pin Node/npm in `.nvmrc` and create a private root `package.json` with the same `packageManager`/`engines` fields plus exactly one dev dependency: `"@openzeppelin/upgrades-core": "1.46.0"`. Generate and commit the root lockfile with `npm install --save-exact`. This locally satisfies the plugins hard-coded `^1.45.0` range.
- [ ] Pin the browser dependencies in `web/package.json`; the package must be private and use only exact versions:
```json
{
"name": "uups-bank-operations-console",
"private": true,
"version": "0.1.0",
"type": "module",
"packageManager": "npm@11.17.0",
"engines": { "node": ">=24.18.0 <25", "npm": ">=11.17.0 <12" },
"scripts": {
"dev": "vite --host 127.0.0.1",
"lint": "eslint . --max-warnings 0",
"typecheck": "tsc -b --pretty false",
"test": "vitest run",
"build": "tsc -b && vite build"
}
}
```
Add exact runtime dependencies `@tanstack/react-query@5.101.4`, `react@19.2.8`, `react-dom@19.2.8`, `viem@2.55.8`, and `wagmi@3.7.5`. Add exact dev dependencies `@eslint/js@10.0.1`, `@testing-library/dom@10.4.1`, `@testing-library/react@16.3.2`, `@types/node@24.10.0`, `@types/react@19.2.14`, `@types/react-dom@19.2.4`, `@vitejs/plugin-react@6.0.4`, `eslint@10.0.1`, `eslint-plugin-react-hooks@7.1.1`, `eslint-plugin-react-refresh@0.5.3`, `globals@17.7.0`, `jsdom@30.0.1`, `typescript@7.0.2`, `typescript-eslint@8.65.0`, `vite@8.2.0`, and `vitest@4.1.10`. If npm rejects one exact revision because the registry changed, verify the official release before changing both this plan and the package file.
- [ ] Configure Foundry in `foundry.toml`:
```toml
[profile.default]
src = "src"
test = "test"
script = "script"
out = "out"
libs = ["lib"]
solc_version = "0.8.35"
evm_version = "cancun"
optimizer = true
optimizer_runs = 200
ffi = true
ast = true
build_info = true
extra_output = ["storageLayout"]
fs_permissions = [
{ access = "read", path = "out" },
{ access = "read-write", path = "deployments" }
]
[fuzz]
runs = 512
seed = "0x5555505342414e4b"
[invariant]
runs = 128
depth = 64
fail_on_revert = true
```
- [ ] Install exact Solidity dependencies as Git submodules and write canonical remappings:
```bash
forge install foundry-rs/forge-std@v1.16.1
forge install OpenZeppelin/openzeppelin-foundry-upgrades@v0.4.1
forge install OpenZeppelin/openzeppelin-contracts-upgradeable@v5.6.1
git submodule update --init --recursive
```
```text
forge-std/=lib/forge-std/src/
openzeppelin-foundry-upgrades/=lib/openzeppelin-foundry-upgrades/src/
@openzeppelin/contracts/=lib/openzeppelin-contracts-upgradeable/lib/openzeppelin-contracts/contracts/
@openzeppelin/contracts-upgradeable/=lib/openzeppelin-contracts-upgradeable/contracts/
```
Do not install a second top-level copy of `openzeppelin-contracts`; the upgradeable submodules pinned transitive copy supplies both canonical remappings.
- [ ] Create `tools/check-upgrades-cli.mjs`. It must assert that the plugin source contains `UPGRADES_CORE = "^1.45.0"`, the root lockfile resolves `@openzeppelin/upgrades-core` to exactly `1.46.0`, and the locally installed package reports `1.46.0`. Then prove the CLI can start with networking disabled:
```bash
npm ci
node tools/check-upgrades-cli.mjs
npm_config_offline=true npx @openzeppelin/upgrades-core@^1.45.0 validate --help
```
Expected: all commands exit `0` without fetching. Do not patch the vendored OpenZeppelin version constant.
- [ ] Create Vite/React/TypeScript/ESLint/Vitest configuration manually so no unpinned scaffold generator is executed. Configure jsdom, `web/src/test/setup.ts`, strict TypeScript, and React refresh. Run:
```bash
npm --prefix web install --save-exact
npm --prefix web test
```
Expected red: `toolchain.test.ts` cannot import the not-yet-created `src/config/toolchain.ts`.
- [ ] Create `web/src/config/toolchain.ts` exporting the display labels `Foundry 1.7.1`, `Solidity 0.8.35`, `OpenZeppelin 5.6.1`, and `UUPS`; make the test assert those exact values.
- [ ] Add an initial `Makefile` with shell safety (`SHELL := /bin/bash`, `.SHELLFLAGS := -euo pipefail -c`) and non-destructive targets:
```make
.PHONY: doctor setup verify
doctor:
@./tools/doctor.sh
setup:
@git submodule update --init --recursive
@npm ci
@npm --prefix web ci
verify:
@forge fmt --check
@forge clean
@npm_config_offline=true forge build --force
@npm_config_offline=true forge test --force
@npm --prefix web run lint
@npm --prefix web run typecheck
@npm --prefix web test
@npm --prefix web run build
```
Create a temporary minimal `tools/doctor.sh` that reports missing commands and exact expected versions without installing anything; Task 9 replaces it with full port/config checks.
- [ ] Run the green foundation checks:
```bash
forge fmt --check
forge clean
npm_config_offline=true forge build --force
npm_config_offline=true forge test --force
node tools/check-upgrades-cli.mjs
npm --prefix web run lint
npm --prefix web run typecheck
npm --prefix web test
npm --prefix web run build
```
Expected: all exit `0`; the empty Solidity source tree builds and the toolchain test passes.
- [ ] Commit the foundation:
```bash
git add .gitignore .nvmrc .env.example .gitmodules foundry.lock foundry.toml remappings.txt package.json package-lock.json Makefile tools/check-upgrades-cli.mjs tools/doctor.sh lib web
git commit -m "build: pin demo toolchains"
```
---
@@ -0,0 +1,150 @@
# Task 1 report: Pin and prove the repository toolchain
## Implemented
- Pinned the repository to Node `24.18.0` / npm `11.17.0`, Foundry `1.7.1`, Solidity `0.8.35`, and the required Foundry profiles.
- Added exact root and web dependency manifests and generated lockfiles. The root lock resolves `@openzeppelin/upgrades-core` to `1.46.0`.
- Installed and pinned Git submodules at `forge-std@v1.16.1`, `openzeppelin-foundry-upgrades@v0.4.1`, and `openzeppelin-contracts-upgradeable@v5.6.1`, including canonical remappings.
- Added offline upgrades-core verification, a non-destructive doctor script, Make targets, and a manually configured Vite/React/TypeScript/ESLint/Vitest harness.
- Added the first toolchain-label module and its Vitest coverage.
## Files changed
- `.gitignore`, `.nvmrc`, `.env.example`, `.gitmodules`, `foundry.lock`, `foundry.toml`, `remappings.txt`, `Makefile`
- `package.json`, `package-lock.json`, `tools/check-upgrades-cli.mjs`, `tools/doctor.sh`
- `lib/forge-std`, `lib/openzeppelin-foundry-upgrades`, `lib/openzeppelin-contracts-upgradeable`
- `web/package.json`, `web/package-lock.json`, `web/index.html`, `web/tsconfig.json`, `web/tsconfig.app.json`, `web/tsconfig.node.json`, `web/vite.config.ts`, `web/eslint.config.js`
- `web/src/config/toolchain.ts`, `web/src/test/setup.ts`, `web/src/test/toolchain.test.ts`
## TDD evidence
The test names the break it catches: a missing or incorrect operations-console toolchain label. Its expected object is hand-written, not derived from the production module.
### RED
Command:
```bash
npm --prefix web test
```
Result: exit `1`, with `Failed to resolve import "../config/toolchain" from "src/test/toolchain.test.ts"` and `0 test` execution. This was expected because `web/src/config/toolchain.ts` had not yet been created.
### GREEN
Created the minimal `toolchainLabels` export with the four required display values, then ran:
```bash
npm --prefix web test
```
Result: exit `0`; `1 passed` test file and `1 passed` test.
## Verification results
Initial host check: Foundry and Anvil were absent; Node was `v26.7.0`, npm `12.0.2`, and Make `4.4.1`. Installed Foundry through the official user-local installer and pinned it with `foundryup -i 1.7.1`; `forge --version` and `anvil --version` now report `1.7.1`.
The final complete verification command exited `0`:
```bash
forge fmt --check
forge clean
npm_config_offline=true forge build --force
npm_config_offline=true forge test --force
node tools/check-upgrades-cli.mjs
npm --prefix web run lint
npm --prefix web run typecheck
npm --prefix web test
npm --prefix web run build
make verify
```
Observed results: Foundry reported the expected empty-tree `Nothing to compile`; the upgrades-core check reported `@openzeppelin/upgrades-core 1.46.0 is pinned and installed`; lint and typecheck exited `0`; Vitest reported `1 passed`; and Vite built `dist/index.html` successfully. `npm_config_offline=true npx @openzeppelin/upgrades-core@^1.45.0 validate --help` also exited `0` without fetching.
The restricted filesystem sandbox returns EROFS for `forge clean`; the same required commands exited `0` when run outside that mount restriction. This is an execution-environment limitation, not a repository failure.
## Self-review
- Confirmed the three top-level Gitlinks equal the tag revisions in `foundry.lock`; recursive nested submodules were synchronized after `forge install` initially left top-level Gitlinks at their default branch heads.
- Confirmed the upgrades script checks the unmodified vendored `UPGRADES_CORE = "^1.45.0"`, the root lockfile, and the installed package version.
- Confirmed generated output and node modules are ignored, and no generated output is staged.
- Kept TypeScript strict through `tsc -b`; ESLint does not import `typescript-eslint` because that exact package hard-fails under TypeScript 7.0.
## Concerns
1. The task-mandated `typescript@7.0.2` and `typescript-eslint@8.65.0` are upstream-incompatible. The latter declares TypeScript `<6.1.0` and throws when loaded under TypeScript 7. Official TypeScript 7 documentation states that TypeScript 7 ships without the compiler API and advises a side-by-side `@typescript/typescript6` alias for tools such as typescript-eslint. Changing to that official arrangement would violate the briefs required direct `typescript@7.0.2` pin, so it was not done. The ESLint config therefore uses ESLint core plus React rules, while strict TypeScript checking remains green through `tsc -b`.
2. The same peer conflict makes a clean `npm --prefix web ci` (including with pinned npm `11.17.0`) fail unless legacy peer resolution is supplied. The committed lockfile was generated with `npm --prefix web install --save-exact --legacy-peer-deps`; exact versions and the offline verification gate were not weakened. Consequently `make setup` inherits this known npm peer-resolution limitation on a clean install.
3. The current host is outside the pinned Node/npm engines (`v26.7.0` / `12.0.2`), so npm emits engine warnings during installation. The repository records the requested Node/npm pins; final lint/typecheck/test/build all pass on the available host.
## Compatibility resolution
The first two concerns above are resolved. Microsofts official TypeScript 7 announcement explains that TypeScript 7.0 ships without the compiler API and directs tooling such as typescript-eslint to a side-by-side TypeScript 6 API package. The package now uses only range-free aliases:
- `typescript`: `npm:@typescript/typescript6@6.0.2`, which supplies the API imported by `typescript-eslint@8.65.0`.
- `@typescript/native`: `npm:typescript@7.0.2`, which supplies the production compiler.
`typecheck` and `build` invoke `node ./node_modules/@typescript/native/bin/tsc` explicitly, so they cannot select an ambiguous binary. ESLint now imports and applies `typescript-eslint` again. The Tech Stack and Task 1 dependency wording in the implementation plan were updated under its stated compatibility-rejection condition.
### Resolution RED
Before the alias change, the clean pinned-runtime command failed as expected:
```bash
npx --yes npm@11.17.0 --prefix web ci
```
Result: exit `1` with `ERESOLVE`; `typescript-eslint@8.65.0` required `typescript >=4.8.4 <6.1.0` while the direct package was TypeScript `7.0.2`.
### Resolution GREEN
After the alias change, all of these exited `0` without `legacy-peer-deps`:
```bash
npx --yes npm@11.17.0 --prefix web ci
node -p "require('./web/node_modules/typescript/package.json').version"
node ./web/node_modules/@typescript/native/bin/tsc --version
```
Observed output: TypeScript API alias `6.0.2` and compiler `Version 7.0.2`. With npm `11.17.0` placed first on `PATH`, both `make setup` and `make verify` exited `0`; the latter passed ESLint with typescript-eslint enabled, strict typecheck, Vitest (`1 passed`), and Vite build. The final standalone Foundry/offline CLI/web gate also exited `0`.
## Fix Round 1: exact Node runtime proof
### Runtime installation and version proof
Downloaded the official Node `v24.18.0` Linux archive to `/tmp`, verified it against Nodes published `SHASUMS256.txt`, and installed it outside repository state at `/tmp/codius-node-v24.18.0`. Its bundled npm was `11.16.0`, so npm `11.17.0` was installed exactly into that same temporary prefix. With that prefix first on `PATH`, the proof output was:
```text
node: v24.18.0
npm: 11.17.0
forge: forge Version: 1.7.1
anvil: anvil Version: 1.7.1
```
### Warning investigation and correction
The first clean exact-runtime setup emitted npm 11.17s `allow-scripts` advisory for `keccak@3.0.4` and `secp256k1@4.0.5`. Both packages declare the reviewed install command `node-gyp-build || exit 0`. npm 11.17s `approve-scripts` documentation identifies `package.json` `allowScripts` as the intended project policy, so the manifest now records exact-version approvals for those two packages. No dependency version or install mode changed.
### Covering commands and results
Covering test file: `web/src/test/toolchain.test.ts` (Vitest: `1 passed`).
With `PATH=/tmp/codius-node-v24.18.0/bin:/home/golem/.foundry/bin:$PATH`, these all exited `0` from a clean install:
```bash
make doctor
make setup
forge fmt --check
forge clean
npm_config_offline=true forge build --force
npm_config_offline=true forge test --force
node tools/check-upgrades-cli.mjs
npm_config_offline=true npx @openzeppelin/upgrades-core@^1.45.0 validate --help
npm --prefix web run lint
npm --prefix web run typecheck
npm --prefix web test
npm --prefix web run build
node ./web/node_modules/@typescript/native/bin/tsc --version
make verify
```
`make setup` completed without npm warnings on the exact Node/npm pair. The explicit compiler command printed `Version 7.0.2`; the offline CLI printed its help without fetching; Foundry reported the expected zero-source `Nothing to compile`; lint/typecheck passed; and the Vite build succeeded. `forge fmt --check` retains Foundrys informational empty-tree `Nothing to format` notice because this foundation task intentionally has no Solidity sources.
@@ -0,0 +1,52 @@
### Task 2: Build the valueless six-decimal mock asset with TDD
**Files:**
- Create: `src/MockUSDC.sol`
- Create: `test/MockUSDC.t.sol`
**Interfaces:**
- Produces `MockUSDC.mint(address,uint256)`, `decimals() == 6`, and standard ERC-20 behavior.
- Consumed by the bank tests, deployment scripts, and dashboard ABI export.
- [ ] Write `test/MockUSDC.t.sol` first. Cover the exact name/symbol/decimals, owner-only mint, successful mint, transfer/approve behavior inherited from ERC-20, and zero initial supply. Use `vm.expectRevert(abi.encodeWithSelector(Ownable.OwnableUnauthorizedAccount.selector, stranger))` for non-owner minting.
- [ ] Run the focused test and observe red:
```bash
forge test --match-path test/MockUSDC.t.sol -vvv --force
```
Expected red: `src/MockUSDC.sol` is missing.
- [ ] Implement only the tested contract:
```solidity
/// @notice Educational mock token with no monetary value. Never use as real USDC.
contract MockUSDC is ERC20, Ownable {
constructor(address initialOwner) ERC20("Mock USD Coin", "mUSDC") Ownable(initialOwner) {}
function decimals() public pure override returns (uint8) { return 6; }
function mint(address to, uint256 amount) external onlyOwner { _mint(to, amount); }
}
```
Reject a zero `initialOwner` through OpenZeppelins `OwnableInvalidOwner` behavior. Do not add faucet, burn, permit, blacklist, proxy, or bank-specific logic.
- [ ] Run red-to-green verification:
```bash
forge fmt
forge test --match-path test/MockUSDC.t.sol -vvv --force
```
Expected: all mock-token tests pass.
- [ ] Commit:
```bash
git add src/MockUSDC.sol test/MockUSDC.t.sol
git commit -m "feat: add valueless mock USDC"
```
---
@@ -0,0 +1,133 @@
# Task 2 Report: Valueless Six-Decimal Mock Asset
## Implementation
Added `MockUSDC`, an educational ERC-20 mock with no monetary value. It uses OpenZeppelin `ERC20` and `Ownable`, initializes the token as `Mock USD Coin` (`mUSDC`), overrides `decimals()` to return `6`, and permits `mint(address,uint256)` only to the configured owner. OpenZeppelin rejects `address(0)` as the initial owner via `OwnableInvalidOwner`.
## Changed Files
- `src/MockUSDC.sol` (new): minimal six-decimal owner-mintable ERC-20 mock.
- `test/MockUSDC.t.sol` (new): focused tests for metadata, zero supply, authorized/unauthorized minting, transfer, approval, and zero-owner rejection.
## TDD Evidence
Before writing production code, the test suite was added. The production change that each test guards was identified as a missing or incorrect ERC-20 observable behavior: metadata/decimals, initial supply, owner authorization and mint balances, transfer balance movement, approval allowance, or constructor owner validation.
### RED
Command:
```bash
PATH=/home/golem/.foundry/bin:$PATH forge test --match-path test/MockUSDC.t.sol -vvv --force
```
Relevant output:
```text
Error (6275): Source "src/MockUSDC.sol" not found: File not found.
--> test/MockUSDC.t.sol:6:1:
Error: Compilation failed
```
This was the expected RED result: the test imports the intentionally absent `src/MockUSDC.sol`; it failed because the requested feature did not yet exist, not because of a test typo or unrelated failure.
### GREEN
Commands:
```bash
PATH=/home/golem/.foundry/bin:$PATH forge fmt
PATH=/home/golem/.foundry/bin:$PATH forge test --match-path test/MockUSDC.t.sol -vvv --force
```
Relevant output:
```text
Ran 7 tests for test/MockUSDC.t.sol:MockUSDCTest
Suite result: ok. 7 passed; 0 failed; 0 skipped
```
## Full Relevant Suite
Command:
```bash
PATH=/home/golem/.foundry/bin:$PATH forge test -vvv
```
Result:
```text
Ran 1 test suite: 7 tests passed, 0 failed, 0 skipped (7 total tests)
```
`git diff --check` also completed without reporting whitespace errors.
## Self-Review
- Implementation contains only the briefed `ERC20`, `Ownable`, `decimals`, and owner-only `mint` behavior; no faucet, burn, permit, blacklist, proxy, or bank-specific logic was added.
- Tests exercise the deployed contract's observable state and OpenZeppelin's real authorization behavior; no mocks were used.
- The non-owner case uses the required exact `Ownable.OwnableUnauthorizedAccount` selector and `stranger` address.
- Mutations such as changing metadata/decimals, allowing arbitrary minting, omitting minting, misrouting transfers, failing to set allowance, or accepting a zero owner are covered by at least one test.
## Concerns
None. The report itself is intentionally left uncommitted because the task required committing only `src/MockUSDC.sol` and `test/MockUSDC.t.sol`.
## Fix Round 1: Literal Command Evidence
Foundry was placed on `PATH` before each command session with a separate shell environment setup step. The commands below were then executed literally, with no inline `PATH` prefix.
### Isolated RED Reproduction
An isolated temporary filesystem copy of the complete worktree (including the already-installed pinned dependencies) was created under `/tmp`, and only `src/MockUSDC.sol` was removed from that copy. This keeps the real committed worktree untouched while preserving the same test file and dependency graph.
Covering test file: `test/MockUSDC.t.sol`
Literal command:
```bash
forge test --match-path test/MockUSDC.t.sol -vvv --force
```
Relevant output:
```text
Error (6275): Source "src/MockUSDC.sol" not found: File not found.
--> test/MockUSDC.t.sol:6:1:
Error: Compilation failed
```
This RED result is correct because the isolated state retains the new test but deliberately lacks the production contract it imports. The failure is therefore the expected feature-missing failure, rather than a dependency, compiler, or test error.
### Real-Worktree GREEN
Literal commands:
```bash
forge fmt
forge test --match-path test/MockUSDC.t.sol -vvv --force
```
Relevant output:
```text
Ran 7 tests for test/MockUSDC.t.sol:MockUSDCTest
Suite result: ok. 7 passed; 0 failed; 0 skipped
```
### Full Relevant Suite
Literal command:
```bash
forge test -vvv
```
Result:
```text
Ran 1 test suite: 7 tests passed, 0 failed, 0 skipped (7 total tests)
```
No tracked implementation or test file changed during this correction, so no tracked commit was needed. Commit `1f4175b feat: add valueless mock USDC` remains the sole task commit.
@@ -0,0 +1,99 @@
### Task 3: Establish the V1 proxy, initialization, views, and administration
**Files:**
- Create: `src/BankV1.sol`
- Create: `test/helpers/BankTestBase.sol`
- Create: `test/BankV1Admin.t.sol`
**Interfaces:**
- Produces `initialize(address,address)`, `asset()`, `balanceOf(address)`, `totalLiabilities()`, `contractVersion()`, `pause()`, `unpause()`, and owner-authorized UUPS upgrades.
- Consumes `MockUSDC` and OpenZeppelin `Upgrades.deployUUPSProxy`.
- Freezes V1 application storage as `_asset`, `_balances`, `_totalLiabilities`, then `uint256[47] __gap`.
- [ ] Create `BankTestBase.sol` with deterministic `owner`, `alice`, `bob`, and `stranger` addresses from `makeAddr`; deploy `MockUSDC`; deploy the proxy with:
```solidity
proxy = Upgrades.deployUUPSProxy(
"BankV1.sol:BankV1",
abi.encodeCall(BankV1.initialize, (address(token), owner))
);
bank = BankV1(proxy);
```
All bank calls in tests target `proxy`; retain `implementation = Upgrades.getImplementationAddress(proxy)` only for assertions.
- [ ] Write failing admin tests for:
- initialized asset, owner, unpaused state, zero liabilities, and version `1`;
- initialization with zero asset;
- initialization with zero owner through `OwnableInvalidOwner`;
- proxy double initialization;
- direct implementation initialization;
- only owner can pause/unpause;
- views still work while paused;
- `_authorizeUpgrade` rejects a non-owner through `upgradeToAndCall`;
- implementation `proxiableUUID()` is available directly while the proxy-context call reverts.
- [ ] Run the focused suite and observe red:
```bash
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 {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
{
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.
- [ ] Add the only permitted validator annotation and no other bypass:
```solidity
/// @custom:oz-upgrades-unsafe-allow constructor
constructor() {
_disableInitializers();
}
```
- [ ] Add `pause`/`unpause` with `onlyOwner`, simple views, `contractVersion() public pure virtual returns (uint256)`, and `_authorizeUpgrade(address) internal override onlyOwner {}`. Do not add deposits or withdrawals yet.
- [ ] Run green verification and inspect storage:
```bash
forge fmt
npm_config_offline=true forge test --match-path test/BankV1Admin.t.sol -vvv --force
forge inspect BankV1 storage-layout
```
Expected: tests pass and the application fields appear in the frozen order. OpenZeppelin namespaced/stateless internals must not be mistaken for permission to reorder application fields.
- [ ] Commit:
```bash
git add src/BankV1.sol test/helpers/BankTestBase.sol test/BankV1Admin.t.sol
git commit -m "feat: establish UUPS bank V1"
```
---
@@ -0,0 +1,132 @@
# Task 3 Report: V1 proxy, initialization, views, and administration
## Status
Complete. BankV1 now provides the initialized UUPS proxy shell, frozen V1 application storage, administrative pause controls, read-only accounting views, version reporting, and owner-gated upgrade authorization. The final implementation uses OpenZeppelin 5.6.1 `ReentrancyGuardTransient`, as explicitly selected by the user after the pinned validator incompatibility investigation described below.
## Implementation
- Added `BankV1` with inheritance in the required order: `Initializable`, `UUPSUpgradeable`, `OwnableUpgradeable`, `PausableUpgradeable`, then the approved constructor-free `ReentrancyGuardTransient`.
- Kept the V1 application fields in the frozen order: `_asset`, `_balances`, `_totalLiabilities`, then `uint256[47] __gap`.
- Disabled initialization on the implementation constructor with the sole validator annotation `@custom:oz-upgrades-unsafe-allow constructor`.
- Added `initialize(address,address)`, checking the asset before invoking only `__Ownable_init(initialOwner)` and `__Pausable_init()`.
- Added owner-only `pause()` and `unpause()`.
- Added `asset()`, `balanceOf(address)`, `totalLiabilities()`, and virtual pure `contractVersion()` returning literal `1`.
- Added owner-gated `_authorizeUpgrade(address)`.
- Added a reusable proxy fixture with deterministic `owner`, `alice`, `bob`, and `stranger` addresses, a `MockUSDC`, `Upgrades.deployUUPSProxy`, proxy-bound `bank`, and implementation address retained only for assertions.
- Added 12 behavior tests covering successful initialization, validation order, disabled/double initialization, ownership and pause administration, paused views, upgrade authorization, and direct-versus-proxy UUPS context.
- Updated the implementation plan's global, Task 3, scanner, and source-guard references to record the explicitly approved transient-guard design and restore the single ordinary constructor annotation.
## Files
- `src/BankV1.sol` — V1 UUPS implementation shell.
- `test/helpers/BankTestBase.sol` — reusable proxy deployment fixture.
- `test/BankV1Admin.t.sol` — initialization, administration, views, and UUPS-context tests.
- `docs/superpowers/plans/2026-08-17-uups-bank-demo.md` — narrowly authorized compatibility/design update.
- `.superpowers/sdd/2026-08-17-uups-bank-demo/task-3-report.md` — this ignored task report; not part of the commit.
## TDD RED evidence
The required tool binaries were placed on `PATH` in a separate persistent-shell setup. The literal RED command was then issued exactly as briefed:
```bash
npm_config_offline=true forge test --match-path test/BankV1Admin.t.sol -vvv --force
```
Relevant output:
```text
Compiler run failed:
Error (6275): Source "src/BankV1.sol" not found: File not found.
--> test/BankV1Admin.t.sol:9:1
Error (6275): Source "src/BankV1.sol" not found: File not found.
--> test/helpers/BankTestBase.sol:7:1
Error: Compilation failed
```
This was the expected RED: the tests and fixture referenced the required production API before `BankV1` existed, and failed specifically because the feature was missing.
## Validator incompatibility investigation and rulings
The first literal GREEN attempt used the brief's original non-upgradeable `ReentrancyGuard` and sole local `@custom:oz-upgrades-unsafe-allow constructor`. Solidity compiled, but Foundry Upgrades validation rejected the inherited dependency:
```text
Upgrade safety validation failed:
lib/openzeppelin-contracts-upgradeable/lib/openzeppelin-contracts/contracts/utils/ReentrancyGuard.sol:58:
Contract `ReentrancyGuard` has a constructor
```
Investigation established:
- Pinned OpenZeppelin Contracts is 5.6.1 and marks `ReentrancyGuard` `@custom:stateless`, but that guard still has a constructor that initializes its namespaced guard slot.
- Pinned upgrades-core is 1.46.0 and contains no support for the `@custom:stateless` annotation.
- upgrades-core aggregates constructor errors from inherited contracts.
The first user ruling approved replacing the local annotation with `@custom:oz-upgrades-unsafe-allow-reachable constructor`. The exact change was made and the literal focused command rerun, but validation failed identically. Source-level tracing then established that upgrades-core 1.46.0 uses `skipCheckReachable` only for opcode traversal (`delegatecall`/`selfdestruct`); `getConstructorErrors` checks annotations lexically on each constructor node, so an annotation on `BankV1` cannot suppress an inherited constructor finding.
No `Options.unsafeAllow`, exclude, skip, `UnsafeUpgrades`, dependency edit, or other workaround was applied. After that evidence, the user's final ruling selected OpenZeppelin 5.6.1 `ReentrancyGuardTransient` and restored the ordinary sole constructor annotation. This guard has no constructor or persistent application storage and is appropriate for this project's explicitly Cancun-targeted Anvil and Base Sepolia networks, where EIP-1153 is available.
One subsequent test-harness issue was also corrected: `vm.expectRevert` placed before `Upgrades.deployUUPSProxy` intercepted the library's preliminary implementation deployment. The invalid-initializer tests now construct a real `ERC1967Proxy` against the already validated implementation, making the next external operation the intended initializer delegatecall and preserving exact custom-error assertions.
## GREEN and verification evidence
The brief's literal GREEN sequence was run in the same shell after the separate PATH setup:
```bash
forge fmt
npm_config_offline=true forge test --match-path test/BankV1Admin.t.sol -vvv --force
forge inspect BankV1 storage-layout
```
Final focused result:
```text
Ran 12 tests for test/BankV1Admin.t.sol:BankV1AdminTest
Suite result: ok. 12 passed; 0 failed; 0 skipped
```
Storage inspection result:
```text
_asset IERC20 slot 0 offset 0
_balances mapping(address => uint256) slot 1 offset 0
_totalLiabilities uint256 slot 2 offset 0
__gap uint256[47] slot 3 offset 0 (1504 bytes)
```
The final full validation-bearing suite was run offline:
```bash
npm_config_offline=true forge test -vvv --force
```
Result:
```text
Ran 2 test suites: 19 tests passed, 0 failed, 0 skipped
```
Both focused and full commands compiled successfully with only two existing dependency warnings in `openzeppelin-foundry-upgrades` about functions whose mutability could be `pure`; no project warning or error was emitted.
## Storage and upgrade safety notes
- Application storage is exactly `_asset` at slot 0, `_balances` at slot 1, `_totalLiabilities` at slot 2, and the 47-word gap beginning at slot 3. Later versions must not reorder, remove, or change the type of these fields.
- OpenZeppelin ownership and pause state use ERC-7201 namespaced storage. UUPS, Initializable, and the approved transient reentrancy guard do not consume ordinary application slots; this does not permit reordering the frozen application fields.
- Every ordinary bank call in the fixture and tests targets `BankV1(proxy)`. The implementation address is used only to assert disabled initialization and direct `proxiableUUID()` behavior, as required.
- `proxiableUUID()` returns the literal ERC-1967 implementation slot on the implementation and rejects proxy-context calls with `UUPSUnauthorizedCallContext`.
- `_authorizeUpgrade` is covered through the public proxy `upgradeToAndCall` boundary and rejects a non-owner with `OwnableUnauthorizedAccount`.
- The transient guard restricts supported execution environments to EIP-1153-capable networks. That is an explicit design constraint, not a hidden fallback.
## Self-review
- Reviewed all four committed diffs and ran `git diff --check`; no whitespace errors were found.
- Confirmed the fixture uses the exact `Upgrades.deployUUPSProxy("BankV1.sol:BankV1", abi.encodeCall(...))` form and binds all application behavior to the proxy.
- Confirmed only one source validator annotation exists and no `unsafeSkip`, `UnsafeUpgrades`, Options `unsafeAllow`, exclude, reachable annotation, or dependency edit exists in Task 3 code/tests.
- Strengthened the zero-asset test to pass both zero asset and zero owner, so it proves the mandated asset-check ordering; a mutation that invokes ownable initialization first now fails that test.
- Mentally checked realistic mutations: wrong/missing asset assignment, wrong version, missing owner initialization, missing pause authorization/state change, pausing views, enabled reinitialization, implementation initialization, missing upgrade authorization, or broken UUPS call context each fail at least one test.
- No deposit or withdrawal behavior was added.
## Concerns
- `ReentrancyGuardTransient` requires EIP-1153/Cancun. The approved project targets (Anvil with `evm_version = "cancun"` and Base Sepolia) satisfy this, but deploying this implementation on a pre-Cancun EVM is unsupported.
- Existing dependency-only compiler mutability warnings remain; they do not originate from Task 3 files.
@@ -0,0 +1,96 @@
### Task 4: Implement V1 custody accounting and adversarial unit tests
**Files:**
- Modify: `src/BankV1.sol`
- Create: `test/BankV1.t.sol`
- Create: `test/mocks/FeeOnTransferToken.sol`
- Create: `test/mocks/ReentrantToken.sol`
**Interfaces:**
- Produces `deposit(uint256)` and `withdraw(uint256)` plus `Deposited`, `Withdrawn`, `ZeroAmount`, `InsufficientBalance`, and `UnexpectedAssetDelta`.
- Consumes only a standard non-rebasing ERC-20 through `IERC20`/`SafeERC20`.
- Preserves `reserves >= totalLiabilities`; direct token transfers may create surplus.
- [ ] Write event and error declarations into the test expectations before production code:
```solidity
event Deposited(address indexed account, uint256 amount);
event Withdrawn(address indexed account, uint256 amount);
error ZeroAmount();
error InsufficientBalance(address account, uint256 available, uint256 requested);
error UnexpectedAssetDelta(uint256 expected, uint256 actual);
```
- [ ] Write failing deposit tests for exact balance/liability/reserve deltas, event emission, zero amount, paused state, inadequate allowance, inadequate wallet balance, and two independent customers. Use six-decimal constants (`1_000e6`) so assertions read like the demo.
- [ ] Add `FeeOnTransferToken` whose `transferFrom` delivers `99%` of the requested amount. Assert a deposit reverts with `UnexpectedAssetDelta(requested, received)` and that the ERC-20 transfer, bank balance, and liabilities are all rolled back.
- [ ] Add `ReentrantToken` that attempts a nested `bank.deposit` during `transferFrom`. Assert the nested call receives `ReentrancyGuardReentrantCall` and the outer call either completes once or reverts atomically according to the mocks configured propagation mode; no double credit is permitted.
- [ ] Write failing withdrawal tests for exact deltas, event emission, zero amount, paused state, insufficient internal balance (including available/requested values), checks-effects-interactions under a transfer callback, and one accounts withdrawal leaving another account unchanged.
- [ ] Write the surplus test: deposit 100 mUSDC, transfer 25 mUSDC directly to the proxy, assert reserves `125e6`, liabilities `100e6`, and a normal 100 mUSDC withdrawal succeeds while the 25 mUSDC surplus remains. Assert there is no owner sweep/rescue behavior by keeping such a function out of the interface.
- [ ] Run red tests:
```bash
npm_config_offline=true forge test --match-path test/BankV1.t.sol -vvv --force
```
Expected red: `deposit`/`withdraw` and their errors/events are absent.
- [ ] Implement `deposit` with exact received-amount validation and state credit only after a successful transfer:
```solidity
function deposit(uint256 amount) external whenNotPaused nonReentrant {
if (amount == 0) revert ZeroAmount();
uint256 reservesBefore = _asset.balanceOf(address(this));
_asset.safeTransferFrom(msg.sender, address(this), amount);
uint256 reservesAfter = _asset.balanceOf(address(this));
uint256 received = reservesAfter >= reservesBefore ? reservesAfter - reservesBefore : 0;
if (received != amount) revert UnexpectedAssetDelta(amount, received);
_balances[msg.sender] += amount;
_totalLiabilities += amount;
emit Deposited(msg.sender, amount);
}
```
- [ ] Implement `withdraw` with checks-effects-interactions:
```solidity
function withdraw(uint256 amount) external whenNotPaused nonReentrant {
if (amount == 0) revert ZeroAmount();
uint256 available = _balances[msg.sender];
if (amount > available) revert InsufficientBalance(msg.sender, available, amount);
_balances[msg.sender] = available - amount;
_totalLiabilities -= amount;
_asset.safeTransfer(msg.sender, amount);
emit Withdrawn(msg.sender, amount);
}
```
- [ ] Add fuzz tests in `BankV1.t.sol`: bound deposit to `[1, 1_000_000e6]`; bound withdrawal to `[1, deposited]`; prove exact reserve/liability/customer deltas and that over-withdraw always reverts. Keep a fixed seed in `foundry.toml` for presentation reproducibility while printing Foundrys replay seed on failure.
- [ ] Run focused and aggregate green tests:
```bash
forge fmt
npm_config_offline=true forge test --match-path test/BankV1.t.sol -vvv --force
npm_config_offline=true forge test --match-path 'test/BankV1*.t.sol' --force
```
Expected: all V1 tests pass with validated proxy deployment enabled.
- [ ] Commit:
```bash
git add src/BankV1.sol test/BankV1.t.sol test/mocks/FeeOnTransferToken.sol test/mocks/ReentrantToken.sol
git commit -m "feat: add V1 custody accounting"
```
---
@@ -0,0 +1,133 @@
# Task 4 Report: V1 Custody Accounting and Adversarial Unit Tests
## Status
Complete. `BankV1` now supports exact-accounting deposits and CEI withdrawals behind the existing UUPS proxy. The focused custody suite, aggregate V1 suite, and full offline suite pass.
## Implementation
- Added `Deposited` and `Withdrawn` events and the exact `ZeroAmount`, `InsufficientBalance`, and `UnexpectedAssetDelta` errors.
- Added `deposit(uint256)` guarded by `whenNotPaused` and `nonReentrant`. It measures proxy reserves before and after `SafeERC20.safeTransferFrom`, rejects any non-exact received amount, and credits customer balance/liabilities only after the transfer and delta validation succeed.
- Added `withdraw(uint256)` guarded by `whenNotPaused` and `nonReentrant`. It validates zero/available balance, debits customer balance and liabilities before the external `SafeERC20.safeTransfer`, and emits the withdrawal event.
- Preserved application storage exactly: `_asset` slot 0, `_balances` slot 1, `_totalLiabilities` slot 2, and `uint256[47] __gap` slot 3.
- Kept all application behavior proxy-bound and added no sweep/rescue interface.
## Changed Files
- `src/BankV1.sol` — custody API, errors/events, SafeERC20 accounting.
- `test/BankV1.t.sol` — 18 deterministic and 3 fuzz custody tests.
- `test/mocks/FeeOnTransferToken.sol` — six-decimal ERC-20 whose `transferFrom` delivers 99% and burns the remainder.
- `test/mocks/ReentrantToken.sol` — six-decimal ERC-20 with configurable deposit/withdraw callbacks, revert propagation, nested-call result/selector recording, and callback-time accounting observations.
## TDD RED
Tool paths were set separately in a dedicated shell:
```bash
export PATH=/tmp/codius-node-v24.18.0/bin:/home/golem/.foundry/bin:$PATH
```
The required RED command was run verbatim:
```bash
npm_config_offline=true forge test --match-path test/BankV1.t.sol -vvv --force
```
The first invocation found a Solidity tuple-declaration syntax error in the new `ReentrantToken` test double. Only that test-double syntax was corrected; production remained unchanged. The command was rerun verbatim and produced the valid expected RED:
```text
Compiler run failed:
Error (9582): Member "deposit" not found or not visible after argument-dependent lookup in contract BankV1.
--> test/BankV1.t.sol:28:9:
28 | bank.deposit(100e6);
| ^^^^^^^^^^^^
Error: Compilation failed
```
This RED was expected because the custody tests called the required proxy-bound `deposit`/`withdraw` API before those functions existed. It therefore failed for the missing production behavior rather than a malformed assertion or environment problem.
## GREEN Verification
All upgrade-validation-bearing commands were kept offline.
```bash
forge fmt
```
Output: completed successfully and formatted `test/BankV1.t.sol` on the initial run; the final run completed with no further formatting changes.
```bash
npm_config_offline=true forge test --match-path test/BankV1.t.sol -vvv --force
```
Final output summary:
```text
Ran 21 tests for test/BankV1.t.sol:BankV1CustodyTest
Suite result: ok. 21 passed; 0 failed; 0 skipped
Fuzz tests: 512 runs each
```
```bash
npm_config_offline=true forge test --match-path 'test/BankV1*.t.sol' --force
```
Final output summary:
```text
BankV1AdminTest: 12 passed; 0 failed; 0 skipped
BankV1CustodyTest: 21 passed; 0 failed; 0 skipped
Ran 2 test suites: 33 passed, 0 failed, 0 skipped
```
Full-suite command:
```bash
npm_config_offline=true forge test --force
```
Final output summary:
```text
BankV1AdminTest: 12 passed
MockUSDCTest: 7 passed
BankV1CustodyTest: 21 passed
Ran 3 test suites: 40 passed, 0 failed, 0 skipped
```
Solc emitted only two pre-existing dependency warnings in `openzeppelin-foundry-upgrades` about functions whose mutability could be `pure`; there were no project warnings or test failures.
Storage inspection:
```bash
forge inspect src/BankV1.sol:BankV1 storage-layout
```
Output confirmed `_asset` at slot 0, `_balances` at slot 1, `_totalLiabilities` at slot 2, and `__gap` at slot 3.
## Accounting and Adversarial Coverage
- Exact wallet, reserve, customer balance, and total-liability deltas for deposit and withdrawal.
- Indexed event emission for each custody operation.
- Zero amount and paused-state rejection for both operations.
- Exact ERC-6093 inadequate allowance and wallet-balance failures, with empty bank accounting after revert.
- Independent deposits for Alice and Bob and a withdrawal that leaves the other customer's balance unchanged.
- Fee-on-transfer deposit rejection with `UnexpectedAssetDelta(100e6, 99e6)` and atomic rollback of sender balance, bank reserves, customer credit, and liabilities.
- Deposit callback observes zero customer credit/liabilities before transfer completion, attempts nested deposit, and records `ReentrancyGuardReentrantCall`; swallowing the nested revert credits exactly once, while configured propagation atomically reverts the outer deposit.
- Withdrawal callback observes already-debited customer balance/liabilities, attempts nested withdrawal, records `ReentrancyGuardReentrantCall`, and cannot double debit.
- Direct-transfer surplus: 100 mUSDC liability plus 25 mUSDC direct transfer yields 125 mUSDC reserves; withdrawing the full 100 mUSDC customer balance leaves the 25 mUSDC surplus.
- Deterministic fuzzing uses the existing fixed seed, bounds deposits to `[1, 1_000_000e6]`, bounds withdrawals to `[1, deposited]`, proves exact deltas, and proves every bounded over-withdraw reverts with exact available/requested values.
## Self-Review
- `git diff --check` passed.
- Diff inspection confirmed no persistent storage changes and no owner sweep/rescue behavior.
- Mutation review: tests fail for missing credit/debit/liability changes, incorrect transfer amount or event, removed zero/pause/available checks, fee-token over-credit, credit-before-deposit-transfer, interaction-before-withdraw-effects, absent reentrancy guard, customer cross-account corruption, or surplus consumption.
- Independent read-only review reported no Critical, Important, or Minor findings and assessed the change ready to merge. The reviewer independently saw 21/21 focused and 33/33 aggregate tests pass and confirmed formatting/diff checks.
- Only the four task-listed source/test files are staged for the commit; this report is intentionally outside that scoped commit.
## Concerns
- The bank intentionally supports only standard non-rebasing ERC-20 assets. Outbound transfer-fee/rebasing behavior is outside V1's stated contract.
- `ReentrancyGuardTransient` requires Cancun/EIP-1153, matching the project's configured target.
- The two compiler warnings originate in the pinned `openzeppelin-foundry-upgrades` dependency and are unrelated to this change.
@@ -0,0 +1,72 @@
### Task 5: Prove V1 ledger conservation and solvency with stateful invariants
**Files:**
- Create: `test/helpers/BankHandler.sol`
- Create: `test/BankInvariant.t.sol`
**Interfaces:**
- Consumes `MockUSDC`, the V1 proxy, and a bounded set of four actor addresses.
- Produces independent ghost counters and handler accessors used by invariant assertions.
- Later Task 11 extends the same handler with V2 transfers.
- [ ] Write `BankHandler` with four immutable actor addresses and only these V1 actions:
- `deposit(uint256 actorSeed, uint256 amount)`: select an actor, bound amount to `[1, 10_000e6]`, mint to the actor, approve, deposit, and increment `ghostDeposited`.
- `withdraw(uint256 actorSeed, uint256 amount)`: select an actor, return early only when its internal balance is zero, bound to `[1, balance]`, withdraw, and increment `ghostWithdrawn`.
- `donate(uint256 actorSeed, uint256 amount)`: mint and directly transfer `[1, 1_000e6]` to the proxy, incrementing `ghostDonated` but not liabilities.
Every action uses `vm.startPrank(actor)`/`vm.stopPrank()` in a balanced scope. The handler exposes the actor array so the invariant test, not the handler, sums on-chain balances.
In invariant setup, transfer `MockUSDC` ownership to the handler so only the handler can mint bounded test liquidity. This avoids impersonating an owner inside actions and does not affect bank ownership.
- [ ] Write failing invariant tests:
```solidity
function invariant_liabilitiesEqualTrackedBalances() public view {
uint256 sum;
for (uint256 i; i < handler.actorCount(); ++i) {
sum += bank.balanceOf(handler.actorAt(i));
}
assertEq(sum, bank.totalLiabilities());
}
function invariant_reservesCoverLiabilities() public view {
assertGe(token.balanceOf(address(bank)), bank.totalLiabilities());
}
function invariant_ghostAccountingMatchesChain() public view {
assertEq(handler.ghostDeposited() - handler.ghostWithdrawn(), bank.totalLiabilities());
assertEq(
handler.ghostDeposited() + handler.ghostDonated() - handler.ghostWithdrawn(),
token.balanceOf(address(bank))
);
}
```
- [ ] Run red:
```bash
npm_config_offline=true forge test --match-path test/BankInvariant.t.sol -vvv --force
```
Expected red: handler implementation is absent/incomplete.
- [ ] Implement the minimum handler. Register it with `targetContract(address(handler))` and explicitly target only its three action selectors so inherited test/helper functions cannot enter the action space.
- [ ] Run green and retain the call summary in output:
```bash
npm_config_offline=true forge test --match-path test/BankInvariant.t.sol -vvv --force
```
Expected: all three invariants pass for `128` runs at depth `64`; the summary shows deposits, withdrawals, and donations were each exercised.
- [ ] Commit:
```bash
git add test/helpers/BankHandler.sol test/BankInvariant.t.sol
git commit -m "test: prove V1 accounting invariants"
```
---
@@ -0,0 +1,219 @@
# Task 5 Report: V1 Stateful Accounting Invariants
## Status
Complete. Commit `6dcbb03` (`test: prove V1 accounting invariants`) adds only the two task-listed test files. Production contracts were not modified.
## Handler design
- `BankHandler` owns four fixed immutable actor addresses (`0x1001` through `0x1004`) and exposes the bounded actor set through `actorCount()` and `actorAt()`.
- `deposit` selects an actor from the seed, bounds the amount to `[1, 10_000e6]`, mints test liquidity, performs actor approval/deposit inside a balanced prank scope, and increments `ghostDeposited` only after success.
- `withdraw` selects an actor, returns early only for a zero bank balance, bounds the amount to `[1, balance]`, performs the withdrawal inside a balanced prank scope, and increments `ghostWithdrawn` only after success.
- `donate` selects an actor, bounds the amount to `[1, 1_000e6]`, mints test liquidity, directly transfers it to the proxy inside a balanced prank scope, and increments `ghostDonated` without touching liabilities.
- In setup, `MockUSDC` ownership transfers from the token owner to the handler. Bank ownership remains unchanged.
- The handler is registered with `targetContract(address(handler))`, and `targetSelector` restricts generated calls to exactly `deposit`, `withdraw`, and `donate`.
## Invariant derivations
1. Ledger conservation: the test independently enumerates the four actors and sums `bank.balanceOf(actor)`. That sum must equal `bank.totalLiabilities()`.
2. Solvency: the token's actual proxy balance must be greater than or equal to total liabilities. Direct donations create only surplus.
3. Independent flow accounting:
- `ghostDeposited - ghostWithdrawn == totalLiabilities` because only customer custody flows create or extinguish liabilities.
- `ghostDeposited + ghostDonated - ghostWithdrawn == proxy token reserves` because donations increase reserves without increasing liabilities.
Ghost counters record successful handler inputs, while expected chain state is read from the real proxy and token. The handler does not mirror per-account bank balances, and the invariant test—not the handler—sums on-chain actor balances.
## Changed files
- `test/helpers/BankHandler.sol`: bounded V1 stateful action handler and independent ghost counters.
- `test/BankInvariant.t.sol`: handler setup, selector targeting, and three conservation/solvency invariants.
## TDD evidence
PATH was set in a separate persistent shell step:
```text
export PATH=/tmp/codius-node-v24.18.0/bin:/home/golem/.foundry/bin:$PATH
```
### RED
Exact command:
```text
npm_config_offline=true forge test --match-path test/BankInvariant.t.sol -vvv --force
```
Output before either requested file existed:
```text
[⠒] Compiling 28 files with Solc 0.8.35
Compiler run successful!
No tests found in project! Forge looks for functions that start with `test`
```
This was the expected feature-absent failure: no invariant target or handler existed.
### GREEN
Exact command:
```text
npm_config_offline=true forge test --match-path test/BankInvariant.t.sol -vvv --force
```
Key verbatim output:
```text
Ran 3 tests for test/BankInvariant.t.sol:BankInvariantTest
[PASS] invariant_ghostAccountingMatchesChain() (runs: 128, calls: 8192, reverts: 0)
[PASS] invariant_liabilitiesEqualTrackedBalances() (runs: 128, calls: 8192, reverts: 0)
[PASS] invariant_reservesCoverLiabilities() (runs: 128, calls: 8192, reverts: 0)
| Contract | Selector | Calls | Reverts | Discards |
| BankHandler | deposit | 2666 | 0 | 0 |
| BankHandler | donate | 2791 | 0 | 0 |
| BankHandler | withdraw | 2735 | 0 | 0 |
Suite result: ok. 3 passed; 0 failed; 0 skipped; finished in 3.43s (4.86s CPU time)
Ran 1 test suite in 3.43s (3.43s CPU time): 3 tests passed, 0 failed, 0 skipped (3 total tests)
```
The configured `128` runs and depth `64` produced `8,192` calls per invariant. The fixed seed yielded the same selector counts in the later full-suite run, proving all three actions were exercised.
## Formatting and full-suite validation
Formatting check after applying `forge fmt`:
```text
forge fmt --check test/helpers/BankHandler.sol test/BankInvariant.t.sol
```
Output: empty, exit success.
Full relevant suite command:
```text
npm_config_offline=true forge test -vvv --force
```
Result:
```text
Ran 4 test suites in 6.23s (12.11s CPU time): 43 tests passed, 0 failed, 0 skipped (43 total tests)
```
The invariant portion again passed all three properties for `128` runs and `8,192` calls each, with `2,666` deposits, `2,791` donations, `2,735` withdrawals, and zero reverts/discards. Compilation emitted only two pre-existing OpenZeppelin Foundry Upgrades mutability warnings in `StringFinder.sol` and `DefenderDeploy.sol`.
## Mutation and self-review
Mentally checked realistic mutations:
- Omitting or mis-sizing a deposit/withdraw liability update breaks the ghost-liability equality.
- Updating aggregate liabilities without the selected account balance (or vice versa) breaks the summed-ledger equality.
- Failing to transfer reserves on deposit/withdraw breaks reserve ghost accounting and may break solvency.
- Treating donations as liabilities breaks both the ghost-liability equation and the intended surplus model.
- Draining or under-crediting proxy reserves breaks exact reserve ghost accounting and the solvency inequality.
`git diff --check` and the final formatting check passed. Pre-commit status contained only the two task-listed files. The commit contains exactly those files and 122 inserted test lines. No production files changed.
## Concerns
None. The only warnings are existing third-party mutability warnings noted above.
## Fix Round 1: Genuine handler-absent RED evidence
The original RED evidence above is superseded by this fix round. A missing invariant test only established that Forge had no matching tests; it did not establish that the invariant specification failed when the handler feature was absent.
### Covering test state
An isolated temporary project was created at `/tmp/task-5-red.dLa7CJ`. It contained the committed `test/BankInvariant.t.sol` unchanged but deliberately excluded `test/helpers/BankHandler.sol`. The real worktree remained at commit `6dcbb03f3c79979416c2f0dd37556539276a4e29`, with its tracked files untouched.
Exact isolation command:
```text
rsync -a --exclude=.git --exclude=out --exclude=cache --exclude=test/helpers/BankHandler.sol ./ /tmp/task-5-red.dLa7CJ/
```
Precondition checks confirmed:
```text
isolated invariant test: present
isolated handler: absent
```
PATH was set separately in the isolated shell:
```text
export PATH=/tmp/codius-node-v24.18.0/bin:/home/golem/.foundry/bin:$PATH
```
### Genuine RED
The brief's command was then run literally in the isolated project:
```text
npm_config_offline=true forge test --match-path test/BankInvariant.t.sol -vvv --force
```
Relevant verbatim output:
```text
Compiler run failed:
Error (6275): Source "test/helpers/BankHandler.sol" not found: File not found. Searched the following locations: "/tmp/task-5-red.dLa7CJ".
ParserError: Source "test/helpers/BankHandler.sol" not found: File not found. Searched the following locations: "/tmp/task-5-red.dLa7CJ".
--> test/BankInvariant.t.sol:5:1:
|
5 | import {BankHandler} from "./helpers/BankHandler.sol";
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
Error: Compilation failed
```
Exit status was captured immediately afterward with `echo $?`:
```text
1
```
This is a genuine RED because the completed invariant test is present and compilation fails specifically at its dependency on the absent stateful handler. Adding `BankHandler.sol` is the production-of-the-test-suite change that makes this exact specification compile and run.
### Fresh GREEN in the real worktree
PATH was again set separately in a new shell rooted at the real worktree:
```text
export PATH=/tmp/codius-node-v24.18.0/bin:/home/golem/.foundry/bin:$PATH
```
The brief's GREEN command was run literally:
```text
npm_config_offline=true forge test --match-path test/BankInvariant.t.sol -vvv --force
```
Relevant verbatim output, repeated for each of the three invariants:
```text
Ran 3 tests for test/BankInvariant.t.sol:BankInvariantTest
[PASS] invariant_ghostAccountingMatchesChain() (runs: 128, calls: 8192, reverts: 0)
| Contract | Selector | Calls | Reverts | Discards |
| BankHandler | deposit | 2666 | 0 | 0 |
| BankHandler | donate | 2791 | 0 | 0 |
| BankHandler | withdraw | 2735 | 0 | 0 |
[PASS] invariant_liabilitiesEqualTrackedBalances() (runs: 128, calls: 8192, reverts: 0)
[PASS] invariant_reservesCoverLiabilities() (runs: 128, calls: 8192, reverts: 0)
Suite result: ok. 3 passed; 0 failed; 0 skipped; finished in 3.53s (4.76s CPU time)
Ran 1 test suite in 3.53s (3.53s CPU time): 3 tests passed, 0 failed, 0 skipped (3 total tests)
```
Exit status captured immediately with `echo $?`:
```text
0
```
All three selectors were exercised over `128` runs at depth `64` (`8,192` calls per invariant), with no revert or discard. No tracked commit was needed for Fix Round 1 because the implementation was already correct; only the ignored evidence report changed. The deferred `actorAt` minor was not addressed.
@@ -0,0 +1,134 @@
### Task 6: Deploy, seed, inspect, and export deterministic V1 state
**Files:**
- Create: `script/lib/DemoScript.sol`
- Create: `script/DeployV1.s.sol`
- Create: `script/SeedV1Demo.s.sol`
- Create: `script/CheckState.s.sol`
- Create: `test/ScriptPreflight.t.sol`
- Create: `tools/finalize-manifest.mjs`
- Create: `tools/select-manifest.mjs`
- Create: `tools/test-finalize-manifest.mjs`
- Create: `deployments/.gitkeep`
- Modify: `Makefile`
**Interfaces:**
- Consumes an RPC, chain ID, explicit broadcaster, pending manifest, and Foundry broadcast receipts.
- Produces an active schema-versioned public manifest only after receipt/code/postcondition checks, then the exact Act 1 state.
- Establishes local actor indexes: owner `0`, Alice `1`, Bob `2` from Anvils standard development mnemonic.
- [ ] Write `ScriptPreflight.t.sol` around a small public harness for `DemoScript` and cover:
- chain IDs `31337` and `84532` accepted;
- chain IDs `1`, `8453`, and an arbitrary value rejected before broadcast;
- missing/invalid manifest, wrong manifest chain, zero address, and address-without-code rejected;
- internal pending manifests may use deployment block `0`, while active manifests may not;
- local keys derivable only when chain ID is `31337`;
- serialized JSON contains public addresses but never the mnemonic, a private key, `PRIVATE_KEY`, or `MNEMONIC`.
- [ ] Run red:
```bash
forge test --match-path test/ScriptPreflight.t.sol -vvv --force
```
Expected red: `DemoScript` does not exist.
- [ ] Implement `DemoScript` constants and guards:
```solidity
uint256 internal constant ANVIL_CHAIN_ID = 31337;
uint256 internal constant BASE_SEPOLIA_CHAIN_ID = 84532;
string internal constant ANVIL_TEST_PHRASE =
"test test test test test test test test test test test junk";
string internal constant PENDING_MANIFEST_PATH = "deployments/pending.json";
string internal constant ACTIVE_MANIFEST_PATH = "deployments/active.json";
error UnsupportedChain(uint256 chainId);
error ManifestChainMismatch(uint256 expected, uint256 actual);
error MissingCode(string label, address target);
```
Provide narrow helpers for chain checks, `vm.deriveKey` on local only, manifest read/write through `vm.parseJson*`/`vm.serialize*`, code checks, and exact-state assertions. Resolve the input/output manifest from `DEPLOYMENT_MANIFEST_PATH` with a narrow default appropriate to each script. Never log or serialize a derived private key.
- [ ] Write `DeployV1.s.sol` red tests/behavior first, then implement:
1. validate chain and resolve the public `SCRIPT_SENDER` value;
2. start broadcast;
3. deploy `MockUSDC(sender)`;
4. call `Upgrades.deployUUPSProxy("BankV1.sol:BankV1", abi.encodeCall(...))`;
5. stop broadcast;
6. assert code, owner, asset, version `1`, and implementation identity;
7. write `deployments/pending.json` with schema `1`, network, chain, deployment block `0`, display RPC/explorer metadata, token, proxy, implementation, owner, and local actor labels/addresses.
On local, derive account `0`, require it equals `SCRIPT_SENDER`, and broadcast with that derived development key. On Base Sepolia, require `SCRIPT_SENDER` equals the expected owner and use the signer selected by Forges matching `--account`/`--sender` options; never read a raw signing secret from environment.
- [ ] Test `finalize-manifest.mjs` and `select-manifest.mjs` with temporary pending/broadcast fixtures and an injected fake JSON-RPC function. Cover: a pre-broadcast deploy guard that rejects an existing target-chain canonical file, exact proxy transaction match, successful receipt, receipt-derived block, actual chain-ID match, code at token/proxy/implementation, implementation-slot match, failed/missing/ambiguous receipt, partial broadcast, wrong chain, missing code, an existing active manifest from either network remaining untouched until selection, secret-bearing content, atomic same-directory rename, and selection of only a valid confirmed chain manifest. Prove a failure never creates/changes a confirmed or active manifest, and selecting Base preserves Anvil byte-for-byte (and vice versa).
- [ ] Implement the finalizer using Node standard modules and JSON-RPC `fetch`. Its network-free `preflight-deploy <network>` fails before Forge runs when the target canonical file exists and prints the exact safe recovery command (`make reset-local` or `make archive-base-manifest`). Deploy finalization reads `broadcast/DeployV1.s.sol/<chainId>/run-latest.json`, matches the transaction whose created address is the pending proxy, matches its transaction hash to a successful receipt, uses that receipts real block number, queries `eth_chainId`, `eth_getCode` for all three contracts, and queries the EIP-1967 implementation slot. Atomically write the chains canonical `anvil.json` or `base-sepolia.json` only after all checks pass. `select-manifest.mjs` validates a named canonical file and atomically copies it to `active.json`; it never deletes or overwrites the other chain. Never copy a credential-bearing terminal RPC into JSON.
- [ ] Write and implement `SeedV1Demo.s.sol`, hard-guarded to chain `31337`. It must perform and assert this exact sequence:
```text
owner mints Alice 2,000 mUSDC
owner mints Bob 1,000 mUSDC
Alice approves and deposits 1,000 mUSDC
Bob approves and deposits 500 mUSDC
Alice withdraws 100 mUSDC
```
Postconditions: Alice internal `900e6`, Bob internal `500e6`, liabilities `1_400e6`, reserves `1_400e6`, version `1`, no surplus.
- [ ] Write and implement `CheckState.s.sol`. Always print network, block, token, proxy, implementation, owner, pause state, version, each configured actor balance, reserves, liabilities, and surplus. Always fail on `reserves < liabilities`, implementation/manifest mismatch, or manifest chain mismatch. `DEMO_EXPECTED_STAGE=deployed` asserts version `1` and empty accounting; `v1` asserts exact Act 1 values; `invariants` checks network-independent invariants only. Task 11 adds stage `v2`.
- [ ] Add direct Make targets that do not start background processes yet:
```make
RPC_LOCAL := http://127.0.0.1:8545
ANVIL_OWNER := 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266
.PHONY: deploy-v1 seed-v1 check-state
deploy-v1:
@node tools/finalize-manifest.mjs preflight-deploy anvil
@SCRIPT_SENDER=$(ANVIL_OWNER) DEPLOYMENT_MANIFEST_PATH=deployments/pending.json npm_config_offline=true forge script script/DeployV1.s.sol:DeployV1 --rpc-url $(RPC_LOCAL) --sender $(ANVIL_OWNER) --broadcast --force
@DEPLOYMENT_MANIFEST_PATH=deployments/pending.json DEMO_EXPECTED_STAGE=deployed forge script script/CheckState.s.sol:CheckState --rpc-url $(RPC_LOCAL) --force
@node tools/finalize-manifest.mjs deploy --rpc-url $(RPC_LOCAL)
@node tools/select-manifest.mjs anvil
seed-v1:
@forge script script/SeedV1Demo.s.sol:SeedV1Demo --rpc-url $(RPC_LOCAL) --broadcast --force
check-state:
@DEMO_EXPECTED_STAGE=$${DEMO_EXPECTED_STAGE:-v1} forge script script/CheckState.s.sol:CheckState --rpc-url $(RPC_LOCAL) --force
```
- [ ] Run unit/preflight green checks:
```bash
forge fmt
forge test --match-path test/ScriptPreflight.t.sol -vvv --force
```
- [ ] Run the first real local smoke test in two terminals. Terminal A:
```bash
anvil --host 127.0.0.1 --port 8545 --chain-id 31337
```
Terminal B:
```bash
make deploy-v1
make seed-v1
DEMO_EXPECTED_STAGE=v1 make check-state
```
Expected: the exact Act 1 table prints and all commands exit `0`. Inspect identical `deployments/anvil.json` and selected `deployments/active.json`, verify their deployment block matches the confirmed proxy receipt, and verify they contain no secret material. Simulate a failed receipt fixture and confirm the finalizer changes neither confirmed nor active files.
- [ ] Commit:
```bash
git add script test/ScriptPreflight.t.sol tools/finalize-manifest.mjs tools/select-manifest.mjs tools/test-finalize-manifest.mjs deployments/.gitkeep Makefile
git commit -m "feat: script deterministic V1 demo state"
```
---
@@ -0,0 +1,34 @@
# Task 6 report: deterministic V1 deployment state
## Implemented
- Preserved the pre-reboot Solidity work: `DemoScript`, `DeployV1`, `SeedV1Demo`, `CheckState`, and its 14-test preflight harness.
- Added the Node manifest control plane. `finalize-manifest.mjs` validates public manifests, blocks canonical overwrite before deploy, confirms the exact `CREATE` proxy transaction, receipt/block/chain/code/ERC-1967 slot, and atomically writes only the selected canonical manifest. `select-manifest.mjs` validates and atomically selects one canonical manifest without altering the other.
- Added fixture-driven Node tests, local Make targets, and `deployments/.gitkeep`.
- Completed a live Anvil deployment/seed/check smoke: confirmed and active manifests were byte-identical, their deployment block was receipt block `2`, and neither contained secret markers. Act 1 ended at Alice `900e6`, Bob `500e6`, liabilities/reserves `1_400e6`, surplus `0`.
## Verification
- `node tools/test-finalize-manifest.mjs` — 5/5 pass.
- `forge fmt --check` — pass.
- `forge test --match-path test/ScriptPreflight.t.sol -vvv --force` — 14/14 pass.
- `npm_config_offline=true forge test --force` — 57/57 pass (fresh final run).
- Live smoke (Anvil `31337`): `make deploy-v1`, `make seed-v1`, `DEMO_EXPECTED_STAGE=v1 make check-state` — all exit 0; receipt/manifest inspection passed. Anvil was stopped afterward.
- `git diff --check` — pass.
## TDD evidence
- Preserved pre-reboot Solidity RED/GREEN history honestly: it predates this resumed session; I only re-ran its green 14-test preflight suite.
- Node initial RED: `node --test tools/test-finalize-manifest.mjs` failed with `ERR_MODULE_NOT_FOUND` for `tools/finalize-manifest.mjs`, as expected before implementation. GREEN: the new test suite passed after implementing finalizer/selector.
- Atomic rename RED: the new atomic-writer test failed with `ENOENT` because the writer did not accept the injected I/O boundary; GREEN: it passed after staging a same-directory temporary path and renaming it.
- Proxy provenance RED: a `CALL` record bearing the proxy address was incorrectly accepted; GREEN: the finalizer now requires `transactionType === "CREATE"`, and all five Node tests pass.
## Changed files
`Makefile`; `deployments/.gitkeep`; `script/lib/DemoScript.sol`; `script/DeployV1.s.sol`; `script/SeedV1Demo.s.sol`; `script/CheckState.s.sol`; `test/ScriptPreflight.t.sol`; `tools/finalize-manifest.mjs`; `tools/select-manifest.mjs`; `tools/test-finalize-manifest.mjs`.
## Self-review and concerns
- Reviewed the scoped diff and real Foundry broadcast shape. The only finding was missing explicit `CREATE` filtering; fixed with a RED/GREEN regression test.
- Code-level Task 6 concerns: none. Base actor/RPC/keystore configuration was intentionally not added; it remains Task 12 per the stated ruling.
- Handoff blocker: staging/committing requires writing the Git worktree index outside the sandbox. The elevated `git add` request was rejected because the approval service reported a usage-limit failure. No files were staged or committed.