Files
2026-08-17 16:54:31 -06:00

1624 lines
82 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 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 `@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
| 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 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`, `@typescript/native@npm:typescript@7.0.2`, `@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@npm:@typescript/typescript6@6.0.2`, `typescript-eslint@8.65.0`, `vite@8.2.0`, and `vitest@4.1.10`. The TypeScript 7 native compiler ships without the API consumed by typescript-eslint, so install its range-free TypeScript 6 compatibility alias under `typescript` and the range-free TypeScript 7 compiler alias under `@typescript/native`. 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"
```
---
### 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"
```
---
### 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 {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
{
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 `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();
}
```
- [ ] 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 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"
```
---
### 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 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"
```
---
### 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 Nodes 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 callers 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 endpoints `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 viems 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 owners 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 endpoints 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 `<abbr title>` 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 6s 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/<pid>/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/<pid>/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 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`.
- [ ] 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 constructors 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 manifests `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 chains 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 Foundrys 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 consoles generic explorer-link support to the confirmed Base manifest and verify exact `https://sepolia.basescan.org/address/<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 scanners 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.