Files
uupl-smart-contract/docs/superpowers/plans/2026-08-17-uups-bank-demo.md
T
2026-08-17 16:54:31 -06:00

82 KiB
Raw Blame History

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:

{
  "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:

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:
.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:

{
  "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:
[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:
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
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:
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:
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:

.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:
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:
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:

forge test --match-path test/MockUSDC.t.sol -vvv --force

Expected red: src/MockUSDC.sol is missing.

  • Implement only the tested contract:
/// @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:
forge fmt
forge test --match-path test/MockUSDC.t.sol -vvv --force

Expected: all mock-token tests pass.

  • Commit:
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:

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:

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:
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:
/// @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:

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:
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:

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:

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:
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:
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:

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:
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:
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:
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:

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:
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:

forge test --match-path test/ScriptPreflight.t.sol -vvv --force

Expected red: DemoScript does not exist.

  • Implement DemoScript constants and guards:
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:

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:

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:
forge fmt
forge test --match-path test/ScriptPreflight.t.sol -vvv --force
  • Run the first real local smoke test in two terminals. Terminal A:
anvil --host 127.0.0.1 --port 8545 --chain-id 31337

Terminal B:

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:
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:

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:

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:
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:

export const EIP1967_IMPLEMENTATION_SLOT =
  '0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc'
  • Run red:
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:

.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:
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:
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:

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:

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:

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 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:

.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 tools/test-process-safety.sh
make doctor
make verify

Expected: all checks exit 0.

  • Run a fresh presentation smoke test:
make reset-local
make demo-local

In a second terminal:

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 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:
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:

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:

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:
/// @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:
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:
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:
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:

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:

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:
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:
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:
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:

# 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:
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:

-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:

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:

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:

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:
make demo-local

In the second terminal:

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:
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 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:
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:
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:

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.