From d7883a3684808464e24283ef1ec3ec14514b8f69 Mon Sep 17 00:00:00 2001 From: golem Date: Mon, 17 Aug 2026 15:41:10 -0600 Subject: [PATCH] docs: plan UUPS bank demo implementation --- .../plans/2026-08-17-uups-bank-demo.md | 1623 +++++++++++++++++ .../specs/2026-08-17-uups-bank-demo-design.md | 2 +- 2 files changed, 1624 insertions(+), 1 deletion(-) create mode 100644 docs/superpowers/plans/2026-08-17-uups-bank-demo.md diff --git a/docs/superpowers/plans/2026-08-17-uups-bank-demo.md b/docs/superpowers/plans/2026-08-17-uups-bank-demo.md new file mode 100644 index 0000000..7f88085 --- /dev/null +++ b/docs/superpowers/plans/2026-08-17-uups-bank-demo.md @@ -0,0 +1,1623 @@ +# UUPS Bank Demo Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Deliver a deterministic, local-first UUPS “bank” demo that starts at a verified V1 checkpoint, adds internal balance transfers in V2 without losing state, and explains the result through a read-only React operations console. + +**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 `7.0.2`, Vite `8.2.0`, wagmi `3.7.5`, viem `2.55.8`, TanStack Query `5.101.4`, Vitest `4.1.10`, Testing Library React `16.3.2`, and jsdom `30.0.1`. + +## Global Constraints + +- The contracts are educational and unaudited. Every user-facing surface says: `Educational demo — mock token — never use real funds.` +- State-changing scripts accept only chain IDs `31337` and `84532`; no mainnet RPC, address, target, or configuration is added. +- Local accounts come only from Anvil’s standard development mnemonic. Testnet signing uses a named encrypted Foundry keystore and `--account`; no supported command accepts a raw private key or mnemonic environment variable. +- Every application call uses the proxy address. The implementation address is read only for validation and explanation. +- `Upgrades`, never `UnsafeUpgrades`, performs deploy/upgrade validation. The sole validator allowance is the constructor annotation immediately above `_disableInitializers()`. +- 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 + +| Area | Files | Produces / consumes | +| --- | --- | --- | +| Toolchain | `.nvmrc`, `.env.example`, `.gitignore`, `.gitmodules`, `foundry.lock`, `foundry.toml`, `remappings.txt`, root/web `package.json` and `package-lock.json`, `Makefile` | Pins compilers, validation CLI, and dependencies; defines the command contract used by scripts, CI-style verification, and docs | +| V1 contracts | `src/MockUSDC.sol`, `src/BankV1.sol` | Produces token and bank ABIs; consumes OpenZeppelin ERC-20, access, pause, reentrancy, initializer, and UUPS modules | +| V2 contract | `src/BankV2.sol` | Consumes V1 storage and behavior; produces `transferBalance`, `BalanceTransferred`, and version `2` | +| Test support | `test/helpers/BankTestBase.sol`, `test/mocks/FeeOnTransferToken.sol`, `test/mocks/ReentrantToken.sol`, `test/mocks/IncompatibleBank.sol`, `test/mocks/NonUUPSImplementation.sol`, `test/helpers/BankHandler.sol`, `test/helpers/BankV2Handler.sol` | Reusable actors, proxy deployment, hostile token behavior, invalid upgrades, and stateful action generation | +| Contract tests | `test/MockUSDC.t.sol`, `test/BankV1Admin.t.sol`, `test/BankV1.t.sol`, `test/BankInvariant.t.sol`, `test/BankV2.t.sol`, `test/BankUpgrade.t.sol` | Consumes contracts and Upgrades plugin; proves units, fuzz properties, invariants, authorization, and upgrade continuity | +| Foundry scripts | `script/lib/DemoScript.sol`, `script/DeployV1.s.sol`, `script/SeedV1Demo.s.sol`, `script/CheckState.s.sol`, `script/UpgradeV2.s.sol`, `script/TransferV2Demo.s.sol` | Consumes RPC plus explicit sender/account; produces transactions, postcondition evidence, and `deployments/active.json` | +| Artifact/manifest bridge | `tools/sync-web-artifacts.mjs`, `tools/finalize-manifest.mjs`, `tools/select-manifest.mjs`, `tools/publish-web-manifest.mjs`, `web/src/generated/.gitkeep`, `web/public/.gitkeep` | Generates ABIs without a chain; confirms receipts into per-chain manifests; explicitly selects/publishes the active network | +| Web data | `web/src/types/dashboard.ts`, `web/src/config/chains.ts`, `web/src/config/manifest.ts`, `web/src/data/bankClient.ts` | Consumes manifest, ABI, public RPC; produces validated immutable dashboard snapshots and decoded activity | +| Web UI | `web/src/main.tsx`, `web/src/App.tsx`, `web/src/app.css`, `web/src/hooks/useBankDashboard.ts`, `web/src/components/*.tsx` | Consumes dashboard states; renders a strictly read-only operations console | +| Web tests | `web/src/test/setup.ts`, `web/src/**/*.test.ts`, `web/src/**/*.test.tsx` | Proves schema, reads, decoding, accounting, ready/loading/stale/disconnected/mismatch views, and read-only behavior | +| Orchestration | `tools/doctor.sh`, `tools/process-lib.sh`, `tools/demo-local.sh`, `tools/reset-local.sh`, `tools/test-process-safety.sh`, `Makefile` | Produces memorable commands and project-scoped Anvil/Vite lifecycle; consumes only `.demo/` PID files | +| Learning/demo docs | `README.md`, `docs/LEARNING_GUIDE.md`, `docs/PRESENTER_RUNBOOK.md` | Consumes actual commands and exact Act 1/Act 3 states; produces quick start, explanations, live script, and recovery steps | + +The public manifest contract is: + +```json +{ + "schemaVersion": 1, + "network": "anvil", + "chainId": 31337, + "deploymentBlock": 1, + "rpcUrl": "http://127.0.0.1:8545", + "explorerBaseUrl": null, + "token": "0x...", + "proxy": "0x...", + "implementation": "0x...", + "owner": "0x...", + "actors": [ + { "label": "Alice", "address": "0x..." }, + { "label": "Bob", "address": "0x..." } + ] +} +``` + +`rpcUrl` is public local/testnet configuration, not a credential-bearing vendor URL. Testnet operators provide `BASE_SEPOLIA_RPC_URL` at runtime; the script writes only a redacted/public URL selected explicitly for display, or omits it and the browser requires `VITE_RPC_URL`. + +Confirmed manifests are stored separately as ignored `deployments/anvil.json` and `deployments/base-sepolia.json`. `deployments/active.json` is an atomic copy selected from one of those files for scripts/the browser. Local reset may remove only the Anvil manifest and an active/copy whose parsed chain ID is `31337`; it must preserve Base Sepolia state. Explicit `make select-anvil` and `make select-base-sepolia` targets switch the active copy without overwriting the other network. + +--- + +### 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 plugin’s 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 submodule’s 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" +``` + +--- + +### 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 OpenZeppelin’s `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" +``` + +--- + +### 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" +``` + +--- + +### 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 mock’s 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 account’s 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 Foundry’s 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" +``` + +--- + +### 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" +``` + +--- + +### 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 Anvil’s 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 Forge’s 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 ` 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//run-latest.json`, matches the transaction whose created address is the pending proxy, matches its transaction hash to a successful receipt, uses that receipt’s real block number, queries `eth_chainId`, `eth_getCode` for all three contracts, and queries the EIP-1967 implementation slot. Atomically write the chain’s 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" +``` + +--- + +### Task 7: Build the artifact bridge and typed read model + +**Files:** +- Create: `tools/sync-web-artifacts.mjs` +- Create: `tools/test-sync-web-artifacts.mjs` +- Create: `tools/publish-web-manifest.mjs` +- Create: `web/src/generated/.gitkeep` +- Create: `web/public/.gitkeep` +- Create: `web/src/types/dashboard.ts` +- Create: `web/src/config/chains.ts` +- Create: `web/src/config/manifest.ts` +- Create: `web/src/config/manifest.test.ts` +- Create: `web/src/data/bankClient.ts` +- Create: `web/src/data/bankClient.test.ts` +- Modify: `Makefile` + +**Interfaces:** +- ABI sync consumes only Foundry V1/token artifacts and works without RPC or a deployment manifest. +- Manifest publishing separately consumes confirmed `deployments/active.json`. +- Produces ignored `web/src/generated/contracts.ts` and `web/public/deployment.json` without mixing build and live-state requirements. +- Produces `DashboardSnapshot` from a narrow injectable reader so web tests never need a live chain. + +- [ ] Define discriminated TypeScript types first: + +```ts +export type DeploymentManifest = Readonly<{ + schemaVersion: 1 + network: 'anvil' | 'baseSepolia' + chainId: 31337 | 84532 + deploymentBlock: bigint + rpcUrl?: string + explorerBaseUrl?: string + token: Address + proxy: Address + implementation: Address + owner: Address + actors: readonly Readonly<{ label: string; address: Address }>[] +}> + +export type DashboardSnapshot = Readonly<{ + blockNumber: bigint + synchronizedAt: Date + version: 1 + paused: boolean + asset: Address + owner: Address + proxy: Address + implementation: Address + reserves: bigint + liabilities: bigint + surplus: bigint + actors: readonly ActorBalance[] + activity: readonly Activity[] + diagnostics: readonly DecodeDiagnostic[] +}> +``` + +Use tagged `Activity` variants only for V1-visible `deposit`, `withdrawal`, `paused`, `unpaused`, `ownershipTransferred`, and `upgraded`, each carrying block number, log index, transaction hash, and typed event fields. A malformed log becomes a separate `DecodeDiagnostic` containing block/log/transaction identity and a safe message; it is never an `Activity` and never removes valid events. Do not add a transfer variant or accept version `2` before `demo-start`; Task 11 implements that live. + +- [ ] Write `manifest.test.ts` before its parser. Test a valid local file, valid Base Sepolia file, malformed JSON, schema mismatch, chain/network mismatch, invalid address, zero contract address, empty/duplicate actor, deployment block below `1`, and credential-bearing RPC URLs containing user info or sensitive query keys (`key`, `token`, `secret`). The parser returns a typed value or an explanatory validation error; it never supplies defaults for missing addresses. + +- [ ] Run red: + +```bash +npm --prefix web test -- src/config/manifest.test.ts +``` + +Expected red: parser is missing. + +- [ ] Implement `parseDeploymentManifest(unknown)` with explicit field guards and viem `isAddress`. Enforce `anvil -> 31337` and `baseSepolia -> 84532`. Normalize addresses only with `getAddress`; preserve `undefined` for optional URLs. + +- [ ] Write `test-sync-web-artifacts.mjs` using Node’s built-in `assert` and temporary fixture directories. Prove that sync: + + - rejects a missing V1 or token artifact; + - validates required V1 functions/events; + - emits `bankV1Abi` and `mockUsdcAbi` using `as const`; + - neither reads a manifest nor emits `bankV2Abi`; + - makes `--check` fail when generated output is stale. + +Test `publish-web-manifest.mjs` separately in the same harness: only a schema-valid active manifest is copied, browser RPC is public/credential-free, pending/zero-block manifests are rejected, secret markers are rejected, and no address/default is fabricated. + +- [ ] Run red: + +```bash +node tools/test-sync-web-artifacts.mjs +``` + +Expected red: sync module is missing. + +- [ ] Implement `sync-web-artifacts.mjs` using only Node standard modules. Export pure `extractAbi`, `renderContractsModule`, and `syncArtifacts` functions for the test. Resolve every path from the repository root derived from `import.meta.url`, never the caller’s working directory. Write only `web/src/generated/contracts.ts`. Implement the separate publisher to validate/copy only `deployments/active.json` to `web/public/deployment.json`. + +- [ ] Write `bankClient.test.ts` against a `BankReader` test double. Cover: + + - all reads pinned to one latest block number; + - `reader.getChainId()` compared with the manifest before any contract read; + - nonempty bytecode at proxy, token, and implementation before state reads; + - version, pause, owner, asset, liabilities, reserves, actor balances; + - EIP-1967 implementation slot decoding; + - implementation-slot/manifest mismatch; + - asset/proxy/owner mismatch; + - surplus as `reserves - liabilities`; + - insolvency rejected instead of producing a negative surplus; + - V1 event decoding from the proxy address only and deterministic `(blockNumber, logIndex)` ordering; + - one malformed log becoming a `DecodeDiagnostic` while other logs render; + - read rejection preserving the error rather than substituting `0n`. + +Use the canonical implementation slot: + +```ts +export const EIP1967_IMPLEMENTATION_SLOT = + '0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc' +``` + +- [ ] Run red: + +```bash +npm --prefix web test -- src/data/bankClient.test.ts +``` + +Expected red: `BankReader`/snapshot loader is missing. + +- [ ] Implement a narrow `ViemBankReader` adapter and pure `loadDashboardSnapshot(reader, manifest)`. Call the endpoint’s `eth_chainId` and check code at proxy/token/implementation first. Read the latest block next and pass that block to every direct `readContract`, token-balance call, storage-slot read, and log range endpoint. The token reserve must come from `MockUSDC.balanceOf(proxy)`, not native ETH balance. Do not use `useReadContracts`/Multicall3 because a fresh Anvil chain does not guarantee a Multicall deployment. + +- [ ] Configure exactly two public clients in `chains.ts`: a custom Anvil chain with ID `31337`/`http://127.0.0.1:8545`, and viem’s Base Sepolia definition with ID `84532`. Select RPC from the validated manifest or `VITE_RPC_URL`; reject absence rather than inventing a remote provider. + +- [ ] Add bridge targets: + +```make +.PHONY: sync-abis publish-web-manifest sync-artifacts sync-artifacts-check +sync-abis: + @node tools/sync-web-artifacts.mjs +publish-web-manifest: + @node tools/publish-web-manifest.mjs +sync-artifacts: sync-abis publish-web-manifest +sync-artifacts-check: + @node tools/test-sync-web-artifacts.mjs + @node tools/sync-web-artifacts.mjs + @node tools/sync-web-artifacts.mjs --check +``` + +- [ ] Run all green checks with a live V1 manifest from Task 6: + +```bash +forge build --force +node tools/test-sync-web-artifacts.mjs +make sync-abis +make publish-web-manifest +npm --prefix web test -- src/config/manifest.test.ts src/data/bankClient.test.ts +npm --prefix web run typecheck +``` + +Expected: generated ABI/manifest exist locally, all tests pass, and `git status --short` does not list generated files. + +- [ ] Commit: + +```bash +git add tools/sync-web-artifacts.mjs tools/test-sync-web-artifacts.mjs tools/publish-web-manifest.mjs web/src/generated/.gitkeep web/public/.gitkeep web/src/types web/src/config web/src/data Makefile +git commit -m "feat: bridge chain artifacts to typed web reads" +``` + +--- + +### Task 8: Render the read-only V1 operations console + +**Files:** +- Create: `web/src/main.tsx` +- Create: `web/src/App.tsx` +- Create: `web/src/App.test.tsx` +- Create: `web/src/app.css` +- Create: `web/src/hooks/useBankDashboard.ts` +- Create: `web/src/hooks/useBankDashboard.test.tsx` +- Create: `web/src/components/StatusHeader.tsx` +- Create: `web/src/components/AccountingGrid.tsx` +- Create: `web/src/components/ContractIdentity.tsx` +- Create: `web/src/components/AccountTable.tsx` +- Create: `web/src/components/ActivityTimeline.tsx` +- Create: `web/src/components/WarningBanner.tsx` +- Create: `web/src/components/TrustDisclosure.tsx` +- Create: `web/src/components/format.ts` +- Create: `web/src/components/format.test.ts` +- Modify: `web/src/test/setup.ts` + +**Interfaces:** +- Consumes the manifest parser, `DashboardSnapshot`, and block-triggered snapshot loader. +- Produces loading, ready, stale, disconnected, invalid-manifest, and chain-mismatch screens. +- Produces no transaction, signing, connector, form, or wallet interface. + +- [ ] Write `format.test.ts` first. Cover six-decimal amounts, shortened addresses, surplus, exact `100.00%` reserve ratio when equal, overcollateralized ratio, and zero-liability text `No deposits`. Failed/unknown values format as an em dash, never `0`. + +- [ ] Write `useBankDashboard.test.tsx` with an injected loader and controllable clock. Cover: + + - initial `loading`; + - successful `ready` with last synchronized block/time; + - later failure retaining the last snapshot as `stale`; + - initial RPC failure as `disconnected` with no fabricated snapshot; + - invalid manifest and manifest/network mismatch as terminal explanatory states; + - a new watched block triggering exactly one reconciled refetch. + +- [ ] Write `App.test.tsx` using deterministic V1 snapshots. Assert: + + - persistent educational/mock/never-real-funds warning; + - visible unaudited status, the owner’s power to install arbitrary future logic, UUPS state/bricking risk, and a statement that a real product needs professional audits, operational key controls, multisig or timelocked governance, incident procedures, legal advice, and jurisdiction-specific compliance; + - network, block, ready/stale/disconnected, paused, and version status; + - reserves, liabilities, surplus, reserve ratio, proxy, implementation, token, owner, and deployment block; + - Alice/Bob labels locally and shortened addresses publicly; + - deposit/withdraw/pause/ownership/upgrade events; + - a decode diagnostic warning alongside still-visible valid activity; + - Base Sepolia explorer links and no local explorer links; + - `No deposits` for zero liabilities; + - no `button`, `form`, wallet-connect text, deposit/withdraw action, or transaction control anywhere in the rendered document. + +- [ ] Run the three red suites: + +```bash +npm --prefix web test -- src/components/format.test.ts src/hooks/useBankDashboard.test.tsx src/App.test.tsx +``` + +Expected red: formatters, hook, and UI components are missing. + +- [ ] Implement the wagmi provider with exactly two chains, public HTTP transports, and `connectors: []`. Mount `QueryClientProvider` and `WagmiProvider`; do not import wallet packages or expose a `WalletClient`. The configured wagmi chain is UI/query configuration only; the data loader still verifies the RPC endpoint’s returned chain ID. + +- [ ] Implement `useBankDashboard` as the sole async coordinator. Use a watched block number to invalidate the snapshot query, fetch direct reads and proxy-only event logs at one block, retain the last successful snapshot on later failures, and mark it stale with the failing timestamp/error. Stop polling when the manifest is invalid or the RPC-reported chain differs from it. + +- [ ] Implement the six console regions plus an always-visible/semantic `TrustDisclosure`. Use semantic text/table/list markup, visible focus styles, responsive single-column fallbacks, and CSS custom properties. Tooltips must be keyboard-readable `` or always-visible helper text rather than action buttons. + +- [ ] Keep the visual hierarchy presentation-oriented: + + - dark neutral canvas and high-contrast green/amber/red status indicators; + - proxy address visually primary, implementation visually secondary; + - large reserves/liabilities figures with surplus/ratio below; + - activity newest first while retaining block and transaction context; + - no consumer-banking imagery or language suggesting regulated status. + +- [ ] Run green verification: + +```bash +npm --prefix web run lint +npm --prefix web run typecheck +npm --prefix web test +npm --prefix web run build +``` + +Expected: all checks pass and Vite produces `web/dist`. + +- [ ] With Task 6’s Anvil running, run `make sync-artifacts` and `npm --prefix web run dev`; visually verify the exact Act 1 values and resize to desktop/tablet widths. Record any visual defect as a failing component test before correcting it. + +- [ ] Commit: + +```bash +git add web/src +git commit -m "feat: add read-only bank operations console" +``` + +--- + +### Task 9: Orchestrate, document, verify, and checkpoint the prepared V1 demo + +**Files:** +- Modify: `tools/doctor.sh` +- Create: `tools/process-lib.sh` +- Create: `tools/demo-local.sh` +- Create: `tools/reset-local.sh` +- Create: `tools/test-process-safety.sh` +- Create: `tools/scan-project.sh` +- Modify: `Makefile` +- Create: `README.md` +- Create: `docs/LEARNING_GUIDE.md` +- Create: `docs/PRESENTER_RUNBOOK.md` + +**Interfaces:** +- Produces the supported user commands and the `demo-start` Git tag. +- Consumes the V1 scripts, artifact sync, Vite app, and only project-owned `.demo/` PIDs. +- Leaves the repository at the exact checkpoint from which the live Codex V2 change begins. + +- [ ] Write `test-process-safety.sh` first using temporary directories and harmless child processes. Assert: + + - an absent PID file is a no-op; + - a stale numeric PID file is removed; + - a nonnumeric PID is rejected; + - a live PID whose command does not match the recorded `anvil`/`vite` signature is never signaled; + - PID reuse is rejected by comparing the recorded `/proc//stat` start tick; + - a matching project-started child is terminated and reaped; + - an owned process group is stopped without leaving a Vite child, while an unrelated group survives; + - reset deletes only known `.demo/{anvil,vite}.{pid,start,pgid,log}` files, Anvil staging/canonical files, an active/copied web manifest only when its parsed chain is `31337`, and the generated ABI module; + - a Base canonical or active manifest survives `reset-local` byte-for-byte; + - a sentinel adjacent to `.demo/` survives. + +- [ ] Run red: + +```bash +bash tools/test-process-safety.sh +``` + +Expected red: process library/reset script is missing. + +- [ ] Implement `process-lib.sh` with repository-root resolution, numeric PID/PGID validation, recorded process start tick, `/proc//cmdline` or `ps` signature checks, and TERM-then-bounded-KILL only for a matching recorded process group. Never use `pkill`, `killall`, a wildcard PID, or recursive workspace deletion. + +- [ ] Implement `reset-local.sh` by stopping validated PID files and removing each known runtime file by explicit path. Parse before deleting: remove `deployments/anvil.json`; remove staging/active/browser manifests only when they validate as chain `31337`; preserve every chain `84532` file. Use `rmdir .demo` only if empty. Report each removed process/file and that local generated state is reproducible. + +- [ ] Expand `doctor.sh` to check exact Foundry/Node/npm versions, recursive Git submodules, root/web `node_modules`, the pinned offline upgrades CLI, Bash, Make, `curl`, ports `8545`/`5173`, writable runtime directories, and optional Base variables. It must be read-only and print direct official installation links for missing prerequisites. + +- [ ] Implement `demo-local.sh`: + + 1. call the scoped reset helper, which can stop only previously recorded project-owned processes; + 2. run doctor and refuse any still-occupied (therefore external) required port; + 3. create `.demo/` with mode `0700`; + 4. launch pinned Anvil in a new process group on `127.0.0.1:8545`, chain ID `31337`, standard development mnemonic, and record/validate its PID, PGID, and start tick; + 5. wait with a bounded readiness loop using `cast chain-id`; + 6. run deploy/finalization, seed, `DEMO_EXPECTED_STAGE=v1` state check, build, ABI sync, and confirmed-manifest publishing; + 7. launch `web/node_modules/.bin/vite web --host 127.0.0.1 --port 5173` directly in its own process group (not through a parent npm shell), record/validate identity, wait for bounded HTTP readiness, then print the console URL and next commands; + 8. install `INT`/`TERM`/`EXIT` traps and remain attached while both children are alive; + 9. stop only its recorded children on exit. + +- [ ] Replace the Makefile with the complete V1 command contract: + +```make +.PHONY: doctor setup demo-local verify check-state reset-local +doctor: + @bash tools/doctor.sh +setup: + @git submodule update --init --recursive + @npm ci + @npm --prefix web ci +demo-local: + @bash tools/demo-local.sh +verify: + @forge fmt --check + @forge clean + @npm_config_offline=true forge build --force + @node tools/sync-web-artifacts.mjs + @node tools/sync-web-artifacts.mjs --check + @node tools/check-upgrades-cli.mjs + @npm_config_offline=true forge test --force + @node tools/test-finalize-manifest.mjs + @node tools/test-sync-web-artifacts.mjs + @bash tools/test-process-safety.sh + @bash tools/scan-project.sh + @npm --prefix web run lint + @npm --prefix web run typecheck + @npm --prefix web test + @npm --prefix web run build +check-state: + @forge script script/CheckState.s.sol:CheckState --rpc-url http://127.0.0.1:8545 --force +reset-local: + @bash tools/reset-local.sh +``` + +Retain the explicit `deploy-v1`, `seed-v1`, and `sync-artifacts` lower-level targets for teaching and recovery. + +- [ ] Write `README.md` with prerequisites, `make setup`, the ten-minute `make demo-local` quick start, second-terminal commands, architecture paragraph, exact URLs, command reference, and links to both guides. Its trust box must say the token has no value; code is educational/unaudited; real deposits must never be sent; the owner can pause and install arbitrary future logic; UUPS mistakes can corrupt state or brick upgrades; and a real custody product needs professional audits, operational key controls, multisig/timelocked governance, incident procedures, legal advice, and jurisdiction-specific compliance. + +- [ ] Write the V1-complete `LEARNING_GUIDE.md`: proxy/implementation/delegatecall/storage, constructor vs initializer, disabled implementation initialization, safe/unsafe storage examples, reserves/liabilities/surplus, exact deposit/withdraw flow, SafeERC20, pause, reentrancy, checks-effects-interactions, single-owner arbitrary-upgrade power, UUPS corruption/bricking risk, the complete real-product safeguards disclosure, and local exercises that trigger each named error. + +- [ ] Write the prepared portion of `PRESENTER_RUNBOOK.md`: preflight, rehearsal timing, exact three-act story, the approved live Codex prompt verbatim, Act 1 expected state, commands/console callouts, occupied-port/stale-console/test-failure recovery, and the rule that no upgrade runs until `make verify` passes. Describe Act 2/3 expected outcomes without including reference V2 source, and include a closing trust disclosure checklist matching README/UI word-for-word in substance. + +- [ ] Implement `scan-project.sh` with a NUL-safe array from `git ls-files`, excluding dependency gitlinks, `docs/superpowers`, and generated lockfiles. Return nonzero if project-owned tracked content contains an actual `PRIVATE_KEY`/`MNEMONIC` assignment, PEM private key, `TODO`, `TBD`, `FIXME`, filler text, or an unsafe upgrade bypass in `src`/`test`/`script`. Construct the scanner’s own pattern from split shell literals so it does not match itself. Permit only the exact hyphenated constructor annotation. The committed `ANVIL_TEST_PHRASE` is the universally known test fixture, not a supported secret input; test that exact fixture is the only scan exception. + +- [ ] Run process and full verification: + +```bash +bash tools/test-process-safety.sh +make doctor +make verify +``` + +Expected: all checks exit `0`. + +- [ ] Run a fresh presentation smoke test: + +```bash +make reset-local +make demo-local +``` + +In a second terminal: + +```bash +DEMO_EXPECTED_STAGE=v1 make check-state +curl --fail http://127.0.0.1:5173/ +``` + +Expected: exact Act 1 state, HTTP `200`, and the operations console shows V1. Stop the attached demo with Ctrl-C, then prove `make reset-local` leaves no owned process or runtime manifest. + +- [ ] Scan for forbidden material and placeholders: + +```bash +bash tools/scan-project.sh +git diff --check +git status --short +``` + +Expected: no secret assignment/value, unfinished placeholder, or whitespace error appears. Separately inspect chain constants/targets and confirm only `31337`/`84532` are accepted; tests may intentionally mention rejected mainnet IDs. + +- [ ] Commit and tag the live starting point: + +```bash +git add .gitignore Makefile tools README.md docs/LEARNING_GUIDE.md docs/PRESENTER_RUNBOOK.md +git commit -m "docs: prepare repeatable V1 presentation" +git tag -a demo-start -m "Verified V1 starting point for live UUPS demo" +``` + +Do not create the tag unless `make verify` and the fresh local smoke test have just passed. + +--- + +### Task 10: Add BankV2 and prove upgrade safety before scripting it + +**Files:** +- Create: `src/BankV2.sol` +- Create: `test/BankV2.t.sol` +- Create: `test/BankUpgrade.t.sol` +- Create: `test/mocks/IncompatibleBank.sol` +- Create: `test/mocks/NonUUPSImplementation.sol` + +**Interfaces:** +- Consumes the exact V1 source/storage layout and validated V1 proxy fixture. +- Produces `transferBalance(address,uint256)`, `BalanceTransferred`, `InvalidRecipient`, `SelfTransfer`, and `contractVersion() == 2`. +- Produces no initializer and no storage variable. + +- [ ] Start `BankV2.t.sol` by upgrading a populated V1 proxy in test setup with the owner-aware overload: + +```solidity +Options memory opts; +opts.referenceContract = "BankV1.sol:BankV1"; +Upgrades.upgradeProxy(proxy, "BankV2.sol:BankV2", "", opts, owner); +bankV2 = BankV2(proxy); +``` + +Use `--force` on every test command because validation consumes fresh build info. + +- [ ] Write failing transfer unit/fuzz tests for: + + - 250 mUSDC Alice-to-Bob balance deltas and exact event; + - unchanged liabilities and token reserves; + - zero amount; + - zero recipient; + - sender as recipient; + - insufficient sender balance with available/requested values; + - paused state; + - recipients that previously had no balance; + - fuzzed tracked recipients and amounts bounded to the sender balance; + - deposit, withdrawal, views, pause, and unpause still behaving through the V2 proxy. + +- [ ] Run red: + +```bash +npm_config_offline=true forge test --match-path test/BankV2.t.sol -vvv --force +``` + +Expected red: `BankV2.sol` is missing. + +- [ ] Implement only this storage-free extension: + +```solidity +/// @custom:oz-upgrades-from src/BankV1.sol:BankV1 +contract BankV2 is BankV1 { + event BalanceTransferred(address indexed from, address indexed to, uint256 amount); + + error InvalidRecipient(address recipient); + error SelfTransfer(); + + function transferBalance(address recipient, uint256 amount) external whenNotPaused { + if (amount == 0) revert ZeroAmount(); + if (recipient == address(0)) revert InvalidRecipient(recipient); + if (recipient == msg.sender) revert SelfTransfer(); + uint256 available = _balances[msg.sender]; + if (amount > available) { + revert InsufficientBalance(msg.sender, available, amount); + } + _balances[msg.sender] = available - amount; + _balances[recipient] += amount; + emit BalanceTransferred(msg.sender, recipient, amount); + } + + function contractVersion() public pure override returns (uint256) { return 2; } +} +``` + +Do not add `nonReentrant`: this function performs no external call. Do not consume the gap or redeclare inherited fields. + +- [ ] Write `BankUpgrade.t.sol` to populate Alice/Bob balances, create a reserve surplus, pause the bank, and snapshot proxy, implementation, owner, asset, pause state, both balances, liabilities, reserves, and surplus. Run a validated owner upgrade and assert: + + - proxy address unchanged; + - implementation address changed and has code; + - version changed exactly `1 -> 2`; + - every other snapshot field is byte-for-byte equal, including paused state; + - unpause plus every V1 mutation still works after upgrade; + - implementation initialization still rejects. + +- [ ] Add three distinct failure cases: + + 1. non-owner `upgradeToAndCall` rejects with `OwnableUnauthorizedAccount`; + 2. `Upgrades.validateUpgrade` rejects `IncompatibleBank`, which deliberately reorders/changes a V1 application storage field while remaining UUPS-capable; + 3. a direct owner `upgradeToAndCall` to `NonUUPSImplementation` rejects at runtime through ERC-1822/UUPS compatibility. + +Do not weaken these tests with `UnsafeUpgrades`, `unsafeSkipStorageCheck`, `unsafeSkipAllChecks`, `unsafeAllow`, or annotation bypasses. + +- [ ] Run validated green suites and layout inspection: + +```bash +forge fmt +npm_config_offline=true forge test --match-path test/BankV2.t.sol -vvv --force +npm_config_offline=true forge test --match-path test/BankUpgrade.t.sol -vvv --force +forge inspect BankV1 storage-layout +forge inspect BankV2 storage-layout +``` + +Expected: all tests pass; V2 shows the same inherited application fields and no new application slot. + +- [ ] Run a source guard: + +```bash +if rg -n '(unsafeSkip|UnsafeUpgrades|unsafeAllow)' src test script; then + echo "unsafe upgrade bypass found" >&2 + exit 1 +fi +``` + +Expected: no production/test use. The constructor’s narrowly scoped `oz-upgrades-unsafe-allow constructor` annotation is intentionally not matched by this expression. + +- [ ] Commit: + +```bash +git add src/BankV2.sol test/BankV2.t.sol test/BankUpgrade.t.sol test/mocks/IncompatibleBank.sol test/mocks/NonUUPSImplementation.sol +git commit -m "feat: add storage-safe bank V2" +``` + +--- + +### Task 11: Extend invariants, scripts, artifacts, and console for the live V2 change + +**Files:** +- Create: `test/helpers/BankV2Handler.sol` +- Modify: `test/BankInvariant.t.sol` +- Create: `script/UpgradeV2.s.sol` +- Create: `script/TransferV2Demo.s.sol` +- Modify: `script/CheckState.s.sol` +- Modify: `test/ScriptPreflight.t.sol` +- Modify: `tools/finalize-manifest.mjs` +- Modify: `tools/test-finalize-manifest.mjs` +- Modify: `tools/sync-web-artifacts.mjs` +- Modify: `tools/test-sync-web-artifacts.mjs` +- Modify: `web/src/types/dashboard.ts` +- Modify: `web/src/data/bankClient.ts` +- Modify: `web/src/data/bankClient.test.ts` +- Modify: `web/src/components/ActivityTimeline.tsx` +- Modify: `web/src/App.test.tsx` +- Modify: `Makefile` + +**Interfaces:** +- Consumes V1 manifest/proxy and owner sender. +- Produces a confirmed implementation update, exact Act 3 transfer, V2 activity decoding, and version-aware UI. +- Updates only the manifest’s `implementation`; preserves schema, deployment block, proxy, token, owner, actors, and network. + +- [ ] Extend stateful testing first. `BankV2Handler` adds `transfer(uint256 fromSeed,uint256 toSeed,uint256 amount)`, restricts both endpoints to the tracked actor set, chooses a different recipient, returns early only when sender balance is zero, and bounds amount to `[1, senderBalance]`. Track `ghostTransferred` for action coverage but do not alter deposit/withdraw liability ghosts. + +- [ ] Add V2 invariants/assertions: + + - sum of every tracked actor balance equals liabilities; + - reserves cover liabilities; + - liabilities equal deposits minus withdrawals despite any number of transfers; + - reserves equal deposits plus donations minus withdrawals; + - at least one transfer executes in a deterministic handler unit test before relying on random invariant coverage. + +- [ ] Run red then green after the minimum handler implementation: + +```bash +npm_config_offline=true forge test --match-path test/BankInvariant.t.sol -vvv --force +``` + +- [ ] Write preflight tests for the upgrade script before the script: + + - manifest/proxy/code/chain/owner/version-1 requirements; + - CLI sender must equal on-chain owner; + - already-V2 path writes a verifiable `mode: "noop"` staging record, reports a no-op, and does not broadcast; + - snapshot comparison rejects any changed field other than implementation/version; + - manifest update rejects proxy/deployment-block/actor mutation. + +- [ ] Implement `UpgradeV2.s.sol` in this order: + + 1. validate chain before any broadcast; + 2. load the active manifest, validate actual code and chain, and require public `SCRIPT_SENDER` equals proxy owner (the Make target supplies the identical value to Forge `--sender`); + 3. if version is already `2`, write/overwrite a `mode: "noop"` staging record containing chain, observed block, owner nonce, proxy, and current implementation, then return without `vm.startBroadcast`; reject any other unexpected version; + 4. snapshot proxy, implementation, owner, asset, paused, configured balances, liabilities, reserves, surplus, and deployment block; + 5. set `Options.referenceContract = "BankV1.sol:BankV1"` and call `Upgrades.validateUpgrade("BankV2.sol:BankV2", opts)` before broadcast; + 6. broadcast `Upgrades.upgradeProxy(proxy, "BankV2.sol:BankV2", "", opts)` from the verified owner; + 7. stop broadcast and re-read every postcondition through the proxy; + 8. for the normal path, write/overwrite `deployments/upgrade-pending.json` with `mode: "upgrade"`, the proposed implementation, confirmed-manifest identity, and the complete pre-upgrade snapshot; never write `active.json` directly. + +Extend the finalizer tests and implementation with upgrade/no-op modes. For `mode: "upgrade"`, match the successful transaction to the proxy and its `Upgraded(newImplementation)` log, require live `contractVersion() == 2`, require the live ERC-1967 slot to equal the pending implementation, and use artifact method identifiers plus direct JSON-RPC calls to compare owner, asset, pause state, every configured actor balance, liabilities, token reserves, surplus, proxy, and deployment block with the stored pre-upgrade snapshot. Atomically update only `implementation` in the selected chain’s canonical manifest and `active.json`. For `mode: "noop"`, deliberately ignore historical broadcast files, require the live block not precede the observation, require the owner nonce is unchanged, and require live version `2` plus chain/proxy/implementation equality with marker/active manifest; leave both confirmed files byte-for-byte unchanged. Include a stale prior `run-latest.json` fixture proving it is ignored. Delete the staging record only after either path succeeds. Any failed receipt/postcheck or unknown mode changes no confirmed file. + +- [ ] Implement `TransferV2Demo.s.sol`. On local it derives only Alice account index `1`, requires version `2`, asserts the pre-state Alice `900e6`/Bob `500e6`/liabilities-reserves `1_400e6`, snapshots liabilities/reserves, broadcasts `transferBalance(bob, 250e6)`, and asserts Alice `650e6`, Bob `750e6`, unchanged liabilities/reserves, and no surplus. + +- [ ] Extend `CheckState` with `DEMO_EXPECTED_STAGE=upgraded` for version `2` plus the pre-transfer Act 1 balances, and `v2` for the exact Act 3 table, while retaining `deployed`, `v1`, and `invariants` modes. The orchestration uses `invariants` after an idempotent upgrade because a valid already-V2 bank may have progressed past Act 3. + +- [ ] Extend the ABI bridge only now: require the real `BankV2` artifact, emit `bankV2Abi`, validate `transferBalance` and `BalanceTransferred`, and make freshness checks fail if the generated module remains V1-only. + +- [ ] Extend the dashboard types from `version: 1` to `version: 1 | 2` and add the `transfer` activity variant. Decode `BalanceTransferred` from proxy logs, render “Alice transferred 250.000000 mUSDC to Bob,” and add V2 snapshot/event tests. Keep all state reads through the proxy and keep the UI read-only. + +- [ ] Add exact local targets with owner/sender flags: + +```make +ANVIL_OWNER := 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266 +ANVIL_ALICE := 0x70997970C51812dc3A010C7d01b50e0d17dc79C8 + +.PHONY: upgrade-v2 demo-transfer +upgrade-v2: + @SCRIPT_SENDER=$(ANVIL_OWNER) DEPLOYMENT_MANIFEST_PATH=deployments/upgrade-pending.json npm_config_offline=true forge script script/UpgradeV2.s.sol:UpgradeV2 --rpc-url $(RPC_LOCAL) --sender $(ANVIL_OWNER) --broadcast --force + @node tools/finalize-manifest.mjs upgrade --rpc-url $(RPC_LOCAL) + @DEMO_EXPECTED_STAGE=invariants forge script script/CheckState.s.sol:CheckState --rpc-url $(RPC_LOCAL) --force + @node tools/sync-web-artifacts.mjs + @node tools/publish-web-manifest.mjs +demo-transfer: + @forge script script/TransferV2Demo.s.sol:TransferV2Demo --rpc-url $(RPC_LOCAL) --sender $(ANVIL_ALICE) --broadcast --force + @DEMO_EXPECTED_STAGE=v2 $(MAKE) check-state +``` + +After finalization, republish the web manifest because implementation identity changed. Keep shared finalization logic in one file; do not create an untested second upgrade finalizer. + +- [ ] Run the full V2 verification gate before broadcasting: + +```bash +make verify +git diff --check +``` + +Expected: all Solidity validation/unit/fuzz/invariant/upgrade tests, artifact tests, process tests, and web checks pass. + +- [ ] Run the real Act 2/3 local smoke from the tagged V1 state: + +```bash +make upgrade-v2 +make demo-transfer +``` + +Expected: proxy unchanged; implementation changed; version `2`; Alice `650e6`; Bob `750e6`; liabilities and reserves `1_400e6`; one decoded transfer; no token transfer during the internal move. + +- [ ] Commit: + +```bash +git add src test script tools web/src Makefile +git commit -m "feat: demonstrate state-preserving V2 upgrade" +``` + +--- + +### Task 12: Add the explicitly optional Base Sepolia encore + +**Files:** +- Modify: `.env.example` +- Modify: `script/lib/DemoScript.sol` +- Modify: `script/DeployV1.s.sol` +- Create: `script/SeedBaseSepolia.s.sol` +- Modify: `script/UpgradeV2.s.sol` +- Modify: `script/TransferV2Demo.s.sol` +- Modify: `script/CheckState.s.sol` +- Modify: `test/ScriptPreflight.t.sol` +- Modify: `tools/finalize-manifest.mjs` +- Modify: `tools/select-manifest.mjs` +- Modify: `tools/publish-web-manifest.mjs` +- Create: `tools/require-base-config.sh` +- Create: `tools/test-base-config.sh` +- Modify: `Makefile` +- Modify: `README.md` +- Modify: `docs/PRESENTER_RUNBOOK.md` + +**Interfaces:** +- Consumes `BASE_SEPOLIA_RPC_URL`, `BASE_SEPOLIA_PUBLIC_RPC_URL`, `BASE_SEPOLIA_ACCOUNT`, `BASE_SEPOLIA_SENDER`, and `BASE_SEPOLIA_RECIPIENT`. +- Produces explicit deploy/upgrade/transfer commands for chain `84532` using a named encrypted Foundry keystore. +- Never makes a keystore, faucet, Base RPC call, or actual encore a prerequisite of `make verify` or local completion; only offline configuration tests join the gate. + +- [ ] Document public configuration only: + +```dotenv +# Terminal RPC may be credentialed; never copied into browser artifacts. +BASE_SEPOLIA_RPC_URL= +# Browser RPC is intentionally public and visible to browser users. +BASE_SEPOLIA_PUBLIC_RPC_URL=https://sepolia.base.org +BASE_SEPOLIA_ACCOUNT= +BASE_SEPOLIA_SENDER= +BASE_SEPOLIA_RECIPIENT= +``` + +State that keystore passwords are entered through Foundry’s interactive prompt and never stored in `.env`. + +- [ ] Add exact learner onboarding to the runbook before any Base command: + +```bash +cast wallet import uups-bank-base --interactive +cast wallet address --account uups-bank-base +``` + +The presenter copies the displayed public address to `BASE_SEPOLIA_SENDER`, sets `BASE_SEPOLIA_ACCOUNT=uups-bank-base`, verifies both refer to the same account, funds only that Base Sepolia address with test ETH, and never pastes the key/password into Codex, shell history, or `.env`. + +- [ ] Extend preflight tests for chain `84532`, sender/owner equality, nonzero/different recipient, public browser RPC credential rejection, and all unsupported/mainnet chain IDs. Assert the guard runs before `vm.startBroadcast` in every testnet script path. + +- [ ] Before Base deployment/finalization, extend `DeployV1.s.sol` to require `BASE_SEPOLIA_SENDER`, `BASE_SEPOLIA_RECIPIENT`, and the intentionally public browser RPC, validate the actors as nonzero/different, and place both in the pending actor array as `Presenter` and `Recipient`. Serialize only `BASE_SEPOLIA_PUBLIC_RPC_URL`, never the terminal RPC. The active manifest is immutable deployment identity; seeding must never add actors after finalization. + +- [ ] Implement `SeedBaseSepolia.s.sol`: consume and verify the already-finalized presenter/recipient actors and sender, mint `1_000e6` valueless mUSDC to the presenter, approve/deposit `1_000e6`, and assert reserves/liabilities/presenter balance `1_000e6` plus recipient balance `0`. No Anvil test phrase or derived key path is reachable on Base Sepolia. + +- [ ] Extend `TransferV2Demo` testnet mode to transfer `250e6` from the verified presenter to the configured nonzero/different recipient, asserting unchanged reserves/liabilities and post-balances `750e6`/`250e6`. Keep the local exact path unchanged. + +- [ ] Extend manifest finalization to locate the matching chain-specific broadcast receipt, require receipt success, derive the real deployment/upgrade block, re-check code and EIP-1967 implementation, preserve the original deployment block on upgrade, and publish only through an atomic same-directory rename. + +- [ ] Extend `select-manifest.mjs` with a tested `archive baseSepolia` command that moves only `deployments/base-sepolia.json` to a validated timestamped sibling filename and never alters `active.json` or Anvil state. A subsequent deploy finalizer may create a new Base canonical file while the old Base active copy remains a fallback; only successful explicit selection replaces it. This gives a recoverable path before intentionally redeploying Base. Selecting either canonical manifest must update `active.json` and then the browser copy; refreshing the browser switches networks. + +- [ ] Add guarded Make helpers that fail when any required public variable is empty and then invoke Forge with both `--account` and `--sender`. Load an optional root `.env` through a narrowly documented Make include; it contains no signing secret or password: + +```make +-include .env +export BASE_SEPOLIA_RPC_URL BASE_SEPOLIA_PUBLIC_RPC_URL BASE_SEPOLIA_ACCOUNT BASE_SEPOLIA_SENDER BASE_SEPOLIA_RECIPIENT + +.PHONY: deploy-base-sepolia upgrade-base-sepolia transfer-base-sepolia select-anvil select-base-sepolia archive-base-manifest +select-anvil: + @node tools/select-manifest.mjs anvil + @node tools/publish-web-manifest.mjs +select-base-sepolia: + @node tools/select-manifest.mjs baseSepolia + @node tools/publish-web-manifest.mjs +archive-base-manifest: + @node tools/select-manifest.mjs archive baseSepolia +deploy-base-sepolia: + @./tools/require-base-config.sh deploy + @node tools/finalize-manifest.mjs preflight-deploy baseSepolia + @SCRIPT_SENDER="$(BASE_SEPOLIA_SENDER)" DEPLOYMENT_MANIFEST_PATH=deployments/pending.json npm_config_offline=true forge script script/DeployV1.s.sol:DeployV1 --rpc-url "$(BASE_SEPOLIA_RPC_URL)" --account "$(BASE_SEPOLIA_ACCOUNT)" --sender "$(BASE_SEPOLIA_SENDER)" --broadcast --slow --force + @DEPLOYMENT_MANIFEST_PATH=deployments/pending.json DEMO_EXPECTED_STAGE=deployed forge script script/CheckState.s.sol:CheckState --rpc-url "$(BASE_SEPOLIA_RPC_URL)" --force + @node tools/finalize-manifest.mjs deploy --rpc-url "$(BASE_SEPOLIA_RPC_URL)" + @SCRIPT_SENDER="$(BASE_SEPOLIA_SENDER)" DEPLOYMENT_MANIFEST_PATH=deployments/base-sepolia.json forge script script/SeedBaseSepolia.s.sol:SeedBaseSepolia --rpc-url "$(BASE_SEPOLIA_RPC_URL)" --account "$(BASE_SEPOLIA_ACCOUNT)" --sender "$(BASE_SEPOLIA_SENDER)" --broadcast --slow --force + @DEPLOYMENT_MANIFEST_PATH=deployments/base-sepolia.json DEMO_EXPECTED_STAGE=invariants forge script script/CheckState.s.sol:CheckState --rpc-url "$(BASE_SEPOLIA_RPC_URL)" --force + @node tools/select-manifest.mjs baseSepolia + @node tools/sync-web-artifacts.mjs + @node tools/publish-web-manifest.mjs +upgrade-base-sepolia: + @./tools/require-base-config.sh upgrade + @SCRIPT_SENDER="$(BASE_SEPOLIA_SENDER)" DEPLOYMENT_MANIFEST_PATH=deployments/upgrade-pending.json npm_config_offline=true forge script script/UpgradeV2.s.sol:UpgradeV2 --rpc-url "$(BASE_SEPOLIA_RPC_URL)" --account "$(BASE_SEPOLIA_ACCOUNT)" --sender "$(BASE_SEPOLIA_SENDER)" --broadcast --slow --force + @node tools/finalize-manifest.mjs upgrade --rpc-url "$(BASE_SEPOLIA_RPC_URL)" + @DEMO_EXPECTED_STAGE=invariants forge script script/CheckState.s.sol:CheckState --rpc-url "$(BASE_SEPOLIA_RPC_URL)" --force + @node tools/sync-web-artifacts.mjs + @node tools/publish-web-manifest.mjs +transfer-base-sepolia: + @./tools/require-base-config.sh transfer + @SCRIPT_SENDER="$(BASE_SEPOLIA_SENDER)" forge script script/TransferV2Demo.s.sol:TransferV2Demo --rpc-url "$(BASE_SEPOLIA_RPC_URL)" --account "$(BASE_SEPOLIA_ACCOUNT)" --sender "$(BASE_SEPOLIA_SENDER)" --broadcast --slow --force + @DEMO_EXPECTED_STAGE=invariants forge script script/CheckState.s.sol:CheckState --rpc-url "$(BASE_SEPOLIA_RPC_URL)" --force +``` + +The repeated `SCRIPT_SENDER`/`--sender` values are deliberate: Solidity preflight validates the public expected signer, while Forge binds the actual encrypted-keystore signer. The finalizer receives the terminal RPC as a process argument but never prints or serializes it. + +- [ ] Add `tools/require-base-config.sh` and shell tests. Validate account name as a conservative identifier, sender/recipient as addresses, sender != recipient, both RPC schemes are HTTPS, browser URL has no user info or credential query, and terminal/public URL variables are never printed. Do not test a keystore password or accept `PRIVATE_KEY`/`MNEMONIC` fallbacks. + +- [ ] Append `bash tools/test-base-config.sh` to `make verify`. The test supplies only fake fixture values and never requires a keystore, RPC, faucet, or network. + +- [ ] Bind the console’s generic explorer-link support to the confirmed Base manifest and verify exact `https://sepolia.basescan.org/address/
`/transaction links. The browser uses only `BASE_SEPOLIA_PUBLIC_RPC_URL`; the terminal RPC never enters `web/public/deployment.json` or a Vite variable. + +- [ ] Run offline/dry verification without requiring faucet funds or a public RPC: + +```bash +make verify +bash tools/test-base-config.sh +make -n deploy-base-sepolia BASE_SEPOLIA_RPC_URL=https://terminal.invalid BASE_SEPOLIA_PUBLIC_RPC_URL=https://public.invalid BASE_SEPOLIA_ACCOUNT=demo BASE_SEPOLIA_SENDER=0x1111111111111111111111111111111111111111 BASE_SEPOLIA_RECIPIENT=0x2222222222222222222222222222222222222222 +``` + +Expected: tests pass; dry output contains `--account`, `--sender`, `--slow`, and no raw key option. It may print the deliberately fake URLs supplied to `make -n`; production helpers must not echo configured URLs. + +- [ ] If the user explicitly chooses to run the encore, use a funded Base Sepolia-only account and execute deploy, upgrade, transfer, and invariant-only state check. If faucet/RPC/explorer fails, record the optional failure and leave the completed local demo untouched. + +- [ ] Commit: + +```bash +git add .env.example script test/ScriptPreflight.t.sol tools Makefile README.md docs/PRESENTER_RUNBOOK.md +git commit -m "feat: add guarded Base Sepolia encore" +``` + +--- + +### Task 13: Finish learning materials and create the verified reference checkpoint + +**Files:** +- Modify: `README.md` +- Modify: `docs/LEARNING_GUIDE.md` +- Modify: `docs/PRESENTER_RUNBOOK.md` + +**Interfaces:** +- Produces the final audience/user handoff and annotated `demo-complete` tag. +- Consumes actual verified commands/output from V1 and V2; documentation may not claim an unrun result. + +- [ ] Complete the learning guide with the V2 transfer conservation proof, upgrade snapshot, storage-layout validator explanation, why V2 has no initializer/storage, central owner threat model, and exercises for every V2 error. Include safe vs unsafe storage diagrams using the actual field order. + +- [ ] Complete the presenter runbook with: + + - exact preflight and ten-minute rehearsal; + - two-terminal local sequence; + - verbatim live Codex prompt; + - what files should change in Act 2; + - `make verify` evidence to point out; + - Act 3 commands/values/event/identity callouts; + - recovery for a failed edit/test, occupied port, stale UI, partial manifest, and unavailable Base services; + - recovery from `demo-complete` in a new branch/worktree without resetting or overwriting unrelated work; + - closing distinction between work performed by Codex and guarantees supplied by OpenZeppelin/Foundry. + +- [ ] Complete README commands and warnings. Verify every copied command against `make help`, ensure the quick start never requires Base Sepolia, a wallet browser extension, or a real secret, and cross-check the complete trust disclosures against the UI, learning guide, and presenter runbook. + +- [ ] Run the full static gate from a clean generated state: + +```bash +make reset-local +make setup +make verify +git diff --check +``` + +Expected: all gates pass without a running chain or deployment manifest. + +- [ ] Run the full local three-act smoke on a fresh Anvil process: + +```bash +make demo-local +``` + +In the second terminal: + +```bash +DEMO_EXPECTED_STAGE=v1 make check-state +make upgrade-v2 +make demo-transfer +DEMO_EXPECTED_STAGE=v2 make check-state +curl --fail http://127.0.0.1:5173/ +``` + +Expected: exact Act 1 and Act 3 states; stable proxy; changed implementation; decoded transfer; HTTP `200`. + +- [ ] Stop the attached demo, then verify cleanup and repository safety: + +```bash +make reset-local +bash tools/test-process-safety.sh +bash tools/test-base-config.sh +git diff --check +git status --short +``` + +Expected: no Anvil/Vite child remains; only intentional source/document changes are present. + +- [ ] Scan only project-owned tracked files, excluding dependency gitlinks/locks and the approved design/plan prose. Review every match: + +```bash +bash tools/scan-project.sh +``` + +Expected: no secret assignment/value or placeholder; no unsafe upgrade bypass beyond the scanner’s single exact constructor-annotation allowance. + +- [ ] Inspect final evidence: + +```bash +git diff --stat demo-start..HEAD +git log --oneline --decorate demo-start..HEAD +git status --short +``` + +Confirm the V2 diff is bounded to the approved live prompt and that docs match actual output. + +- [ ] Commit final documentation and tag only after fresh verification: + +```bash +git add README.md docs/LEARNING_GUIDE.md docs/PRESENTER_RUNBOOK.md +git commit -m "docs: finish UUPS bank demo guide" +git tag -a demo-complete -m "Verified reference solution for UUPS bank demo" +``` + +Expected final history: `demo-start` points to the verified V1-only checkpoint; `demo-complete` points to the verified V2 reference solution. + +--- + +## Final Acceptance Run + +The implementation is complete only when one fresh run records all of the following: + +```bash +make setup +make verify +make demo-local # terminal 1, remains attached +DEMO_EXPECTED_STAGE=v1 make check-state +make upgrade-v2 +make demo-transfer +DEMO_EXPECTED_STAGE=v2 make check-state +make reset-local # after stopping terminal 1 +``` + +Evidence must show: + +- Act 1: Alice `900e6`, Bob `500e6`, liabilities/reserves `1_400e6`, version `1`. +- Upgrade: identical proxy/owner/asset/pause/balances/liabilities/reserves and a changed implementation. +- Act 3: Alice `650e6`, Bob `750e6`, liabilities/reserves `1_400e6`, version `2`, decoded internal transfer, and no reserve movement. +- `make verify`: Solidity format/build/unit/fuzz/invariant/upgrade validation, artifact/shell safety checks, web lint/types/tests/build all green. +- Cleanup: only project-owned processes/runtime files removed. +- Git: `demo-start` contains no V2 source or transfer-aware UI; `demo-complete` contains the verified reference implementation. diff --git a/docs/superpowers/specs/2026-08-17-uups-bank-demo-design.md b/docs/superpowers/specs/2026-08-17-uups-bank-demo-design.md index 3623e18..0fff1da 100644 --- a/docs/superpowers/specs/2026-08-17-uups-bank-demo-design.md +++ b/docs/superpowers/specs/2026-08-17-uups-bank-demo-design.md @@ -196,7 +196,7 @@ OpenZeppelin Foundry Upgrades deploys an ERC-1967 proxy whose implementation is initialize(address asset, address initialOwner) ``` -The initializer rejects zero addresses and initializes `OwnableUpgradeable`, `PausableUpgradeable`, `ReentrancyGuardUpgradeable`, and `UUPSUpgradeable`. The implementation constructor calls `_disableInitializers()` so the implementation cannot be initialized directly. +The initializer rejects zero addresses and initializes the stateful `OwnableUpgradeable` and `PausableUpgradeable` modules. With the pinned OpenZeppelin Contracts 5.6.1 release, `Initializable`, `UUPSUpgradeable`, and `ReentrancyGuard` are stateless shared modules imported from `@openzeppelin/contracts`; they have no upgradeable initializer calls. The implementation constructor calls `_disableInitializers()` so the implementation cannot be initialized directly. The implementation may use OpenZeppelin's narrowly scoped `@custom:oz-upgrades-unsafe-allow constructor` annotation only on that initializer-disabling constructor. No storage-layout, missing-initializer, delegate-call, self-destruct, or UUPS-compatibility validation bypass is permitted.