82 KiB
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
31337and84532; no mainnet RPC, address, target, or configuration is added. - Local accounts come only from Anvil’s standard development mnemonic. Testnet signing uses a named encrypted Foundry keystore and
--account; no supported command accepts a raw private key or mnemonic environment variable. - Every application call uses the proxy address. The implementation address is read only for validation and explanation.
Upgrades, neverUnsafeUpgrades, performs deploy/upgrade validation. The sole validator allowance is@custom:oz-upgrades-unsafe-allow constructorimmediately above the constructor that calls_disableInitializers(); no OptionsunsafeAllow, exclude, skip,UnsafeUpgrades, reachable annotation, or other bypass is permitted. BankV1 uses OpenZeppelin 5.6.1ReentrancyGuardTransient, 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.1invokesnpx @openzeppelin/upgrades-core@^1.45.0; the root lockfile pins the satisfying implementation to1.46.0, and every validation-bearing command runs withnpm_config_offline=trueafter setup proves the local CLI is available. - Commit only files listed for the task and inspect
git status --shortbefore 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 byforge install) - Create:
foundry.lock(generated byforge 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
.gitignorewith 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
.nvmrcand create a private rootpackage.jsonwith the samepackageManager/enginesfields plus exactly one dev dependency:"@openzeppelin/upgrades-core": "1.46.0". Generate and commit the root lockfile withnpm install --save-exact. This locally satisfies the plugin’s hard-coded^1.45.0range. -
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 submodule’s pinned transitive copy supplies both canonical remappings.
- Create
tools/check-upgrades-cli.mjs. It must assert that the plugin source containsUPGRADES_CORE = "^1.45.0", the root lockfile resolves@openzeppelin/upgrades-coreto exactly1.46.0, and the locally installed package reports1.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.tsexporting the display labelsFoundry 1.7.1,Solidity 0.8.35,OpenZeppelin 5.6.1, andUUPS; make the test assert those exact values. -
Add an initial
Makefilewith 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.solfirst. Cover the exact name/symbol/decimals, owner-only mint, successful mint, transfer/approve behavior inherited from ERC-20, and zero initial supply. Usevm.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 OpenZeppelin’s 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
MockUSDCand OpenZeppelinUpgrades.deployUUPSProxy. -
Freezes V1 application storage as
_asset,_balances,_totalLiabilities, thenuint256[47] __gap. -
Create
BankTestBase.solwith deterministicowner,alice,bob, andstrangeraddresses frommakeAddr; deployMockUSDC; 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;
_authorizeUpgraderejects a non-owner throughupgradeToAndCall;- implementation
proxiableUUID()is available directly while the proxy-context call reverts.
- initialized asset, owner, unpaused state, zero liabilities, and version
-
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/unpausewithonlyOwner, 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)andwithdraw(uint256)plusDeposited,Withdrawn,ZeroAmount,InsufficientBalance, andUnexpectedAssetDelta. -
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
FeeOnTransferTokenwhosetransferFromdelivers99%of the requested amount. Assert a deposit reverts withUnexpectedAssetDelta(requested, received)and that the ERC-20 transfer, bank balance, and liabilities are all rolled back. -
Add
ReentrantTokenthat attempts a nestedbank.depositduringtransferFrom. Assert the nested call receivesReentrancyGuardReentrantCalland the outer call either completes once or reverts atomically according to the mock’s configured propagation mode; no double credit is permitted. -
Write failing withdrawal tests for exact deltas, event emission, zero amount, paused state, insufficient internal balance (including available/requested values), checks-effects-interactions under a transfer callback, and one account’s withdrawal leaving another account unchanged.
-
Write the surplus test: deposit 100 mUSDC, transfer 25 mUSDC directly to the proxy, assert reserves
125e6, liabilities100e6, 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
depositwith 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
withdrawwith 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 infoundry.tomlfor presentation reproducibility while printing Foundry’s 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
BankHandlerwith 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 incrementghostDeposited.withdraw(uint256 actorSeed, uint256 amount): select an actor, return early only when its internal balance is zero, bound to[1, balance], withdraw, and incrementghostWithdrawn.donate(uint256 actorSeed, uint256 amount): mint and directly transfer[1, 1_000e6]to the proxy, incrementingghostDonatedbut 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, Alice1, Bob2from Anvil’s standard development mnemonic. -
Write
ScriptPreflight.t.solaround a small public harness forDemoScriptand cover:- chain IDs
31337and84532accepted; - 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, orMNEMONIC.
- chain IDs
-
Run red:
forge test --match-path test/ScriptPreflight.t.sol -vvv --force
Expected red: DemoScript does not exist.
- Implement
DemoScriptconstants 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.solred tests/behavior first, then implement:- validate chain and resolve the public
SCRIPT_SENDERvalue; - start broadcast;
- deploy
MockUSDC(sender); - call
Upgrades.deployUUPSProxy("BankV1.sol:BankV1", abi.encodeCall(...)); - stop broadcast;
- assert code, owner, asset, version
1, and implementation identity; - write
deployments/pending.jsonwith schema1, network, chain, deployment block0, display RPC/explorer metadata, token, proxy, implementation, owner, and local actor labels/addresses.
- validate chain and resolve the public
On local, derive account 0, require it equals SCRIPT_SENDER, and broadcast with that derived development key. On Base Sepolia, require SCRIPT_SENDER equals the expected owner and use the signer selected by Forge’s matching --account/--sender options; never read a raw signing secret from environment.
-
Test
finalize-manifest.mjsandselect-manifest.mjswith 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-freepreflight-deploy <network>fails before Forge runs when the target canonical file exists and prints the exact safe recovery command (make reset-localormake archive-base-manifest). Deploy finalization readsbroadcast/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 receipt’s real block number, querieseth_chainId,eth_getCodefor all three contracts, and queries the EIP-1967 implementation slot. Atomically write the chain’s canonicalanvil.jsonorbase-sepolia.jsononly after all checks pass.select-manifest.mjsvalidates a named canonical file and atomically copies it toactive.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 chain31337. 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 onreserves < liabilities, implementation/manifest mismatch, or manifest chain mismatch.DEMO_EXPECTED_STAGE=deployedasserts version1and empty accounting;v1asserts exact Act 1 values;invariantschecks network-independent invariants only. Task 11 adds stagev2. -
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.tsandweb/public/deployment.jsonwithout mixing build and live-state requirements. -
Produces
DashboardSnapshotfrom 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.tsbefore 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 below1, 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 viemisAddress. Enforceanvil -> 31337andbaseSepolia -> 84532. Normalize addresses only withgetAddress; preserveundefinedfor optional URLs. -
Write
test-sync-web-artifacts.mjsusing Node’s built-inassertand temporary fixture directories. Prove that sync:- rejects a missing V1 or token artifact;
- validates required V1 functions/events;
- emits
bankV1AbiandmockUsdcAbiusingas const; - neither reads a manifest nor emits
bankV2Abi; - makes
--checkfail 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.mjsusing only Node standard modules. Export pureextractAbi,renderContractsModule, andsyncArtifactsfunctions for the test. Resolve every path from the repository root derived fromimport.meta.url, never the caller’s working directory. Write onlyweb/src/generated/contracts.ts. Implement the separate publisher to validate/copy onlydeployments/active.jsontoweb/public/deployment.json. -
Write
bankClient.test.tsagainst aBankReadertest 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
DecodeDiagnosticwhile 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
ViemBankReaderadapter and pureloadDashboardSnapshot(reader, manifest). Call the endpoint’seth_chainIdand check code at proxy/token/implementation first. Read the latest block next and pass that block to every directreadContract, token-balance call, storage-slot read, and log range endpoint. The token reserve must come fromMockUSDC.balanceOf(proxy), not native ETH balance. Do not useuseReadContracts/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 ID31337/http://127.0.0.1:8545, and viem’s Base Sepolia definition with ID84532. Select RPC from the validated manifest orVITE_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.tsfirst. Cover six-decimal amounts, shortened addresses, surplus, exact100.00%reserve ratio when equal, overcollateralized ratio, and zero-liability textNo deposits. Failed/unknown values format as an em dash, never0. -
Write
useBankDashboard.test.tsxwith an injected loader and controllable clock. Cover:- initial
loading; - successful
readywith last synchronized block/time; - later failure retaining the last snapshot as
stale; - initial RPC failure as
disconnectedwith no fabricated snapshot; - invalid manifest and manifest/network mismatch as terminal explanatory states;
- a new watched block triggering exactly one reconciled refetch.
- initial
-
Write
App.test.tsxusing deterministic V1 snapshots. Assert:- persistent educational/mock/never-real-funds warning;
- visible unaudited status, the owner’s power to install arbitrary future logic, UUPS state/bricking risk, and a statement that a real product needs professional audits, operational key controls, multisig or timelocked governance, incident procedures, legal advice, and jurisdiction-specific compliance;
- network, block, ready/stale/disconnected, paused, and version status;
- reserves, liabilities, surplus, reserve ratio, proxy, implementation, token, owner, and deployment block;
- Alice/Bob labels locally and shortened addresses publicly;
- deposit/withdraw/pause/ownership/upgrade events;
- a decode diagnostic warning alongside still-visible valid activity;
- Base Sepolia explorer links and no local explorer links;
No depositsfor 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: []. MountQueryClientProviderandWagmiProvider; do not import wallet packages or expose aWalletClient. The configured wagmi chain is UI/query configuration only; the data loader still verifies the RPC endpoint’s returned chain ID. -
Implement
useBankDashboardas 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 6’s Anvil running, run
make sync-artifactsandnpm --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-startGit 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.shfirst 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/vitesignature is never signaled; - PID reuse is rejected by comparing the recorded
/proc/<pid>/statstart 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 is31337, and the generated ABI module; - a Base canonical or active manifest survives
reset-localbyte-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.shwith repository-root resolution, numeric PID/PGID validation, recorded process start tick,/proc/<pid>/cmdlineorpssignature checks, and TERM-then-bounded-KILL only for a matching recorded process group. Never usepkill,killall, a wildcard PID, or recursive workspace deletion. -
Implement
reset-local.shby stopping validated PID files and removing each known runtime file by explicit path. Parse before deleting: removedeployments/anvil.json; remove staging/active/browser manifests only when they validate as chain31337; preserve every chain84532file. Usermdir .demoonly if empty. Report each removed process/file and that local generated state is reproducible. -
Expand
doctor.shto check exact Foundry/Node/npm versions, recursive Git submodules, root/webnode_modules, the pinned offline upgrades CLI, Bash, Make,curl, ports8545/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:- call the scoped reset helper, which can stop only previously recorded project-owned processes;
- run doctor and refuse any still-occupied (therefore external) required port;
- create
.demo/with mode0700; - launch pinned Anvil in a new process group on
127.0.0.1:8545, chain ID31337, standard development mnemonic, and record/validate its PID, PGID, and start tick; - wait with a bounded readiness loop using
cast chain-id; - run deploy/finalization, seed,
DEMO_EXPECTED_STAGE=v1state check, build, ABI sync, and confirmed-manifest publishing; - launch
web/node_modules/.bin/vite web --host 127.0.0.1 --port 5173directly 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; - install
INT/TERM/EXITtraps and remain attached while both children are alive; - 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.mdwith prerequisites,make setup, the ten-minutemake demo-localquick 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 untilmake verifypasses. 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.shwith a NUL-safe array fromgit ls-files, excluding dependency gitlinks,docs/superpowers, and generated lockfiles. Return nonzero if project-owned tracked content contains an actualPRIVATE_KEY/MNEMONICassignment, PEM private key,TODO,TBD,FIXME, filler text, or an unsafe upgrade bypass insrc/test/script. Construct the scanner’s own pattern from split shell literals so it does not match itself. Permit only the exact hyphenatedoz-upgrades-unsafe-allow constructorannotation approved forBankV1. The committedANVIL_TEST_PHRASEis 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, andcontractVersion() == 2. -
Produces no initializer and no storage variable.
-
Start
BankV2.t.solby 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.solto 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:
- non-owner
upgradeToAndCallrejects withOwnableUnauthorizedAccount; Upgrades.validateUpgraderejectsIncompatibleBank, which deliberately reorders/changes a V1 application storage field while remaining UUPS-capable;- a direct owner
upgradeToAndCalltoNonUUPSImplementationrejects at runtime through ERC-1822/UUPS compatibility.
- non-owner
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 constructor’s 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 manifest’s
implementation; preserves schema, deployment block, proxy, token, owner, actors, and network. -
Extend stateful testing first.
BankV2Handleraddstransfer(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]. TrackghostTransferredfor 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.solin this order:- validate chain before any broadcast;
- load the active manifest, validate actual code and chain, and require public
SCRIPT_SENDERequals proxy owner (the Make target supplies the identical value to Forge--sender); - if version is already
2, write/overwrite amode: "noop"staging record containing chain, observed block, owner nonce, proxy, and current implementation, then return withoutvm.startBroadcast; reject any other unexpected version; - snapshot proxy, implementation, owner, asset, paused, configured balances, liabilities, reserves, surplus, and deployment block;
- set
Options.referenceContract = "BankV1.sol:BankV1"and callUpgrades.validateUpgrade("BankV2.sol:BankV2", opts)before broadcast; - broadcast
Upgrades.upgradeProxy(proxy, "BankV2.sol:BankV2", "", opts)from the verified owner; - stop broadcast and re-read every postcondition through the proxy;
- for the normal path, write/overwrite
deployments/upgrade-pending.jsonwithmode: "upgrade", the proposed implementation, confirmed-manifest identity, and the complete pre-upgrade snapshot; never writeactive.jsondirectly.
Extend the finalizer tests and implementation with upgrade/no-op modes. For mode: "upgrade", match the successful transaction to the proxy and its Upgraded(newImplementation) log, require live contractVersion() == 2, require the live ERC-1967 slot to equal the pending implementation, and use artifact method identifiers plus direct JSON-RPC calls to compare owner, asset, pause state, every configured actor balance, liabilities, token reserves, surplus, proxy, and deployment block with the stored pre-upgrade snapshot. Atomically update only implementation in the selected chain’s canonical manifest and active.json. For mode: "noop", deliberately ignore historical broadcast files, require the live block not precede the observation, require the owner nonce is unchanged, and require live version 2 plus chain/proxy/implementation equality with marker/active manifest; leave both confirmed files byte-for-byte unchanged. Include a stale prior run-latest.json fixture proving it is ignored. Delete the staging record only after either path succeeds. Any failed receipt/postcheck or unknown mode changes no confirmed file.
-
Implement
TransferV2Demo.s.sol. On local it derives only Alice account index1, requires version2, asserts the pre-state Alice900e6/Bob500e6/liabilities-reserves1_400e6, snapshots liabilities/reserves, broadcaststransferBalance(bob, 250e6), and asserts Alice650e6, Bob750e6, unchanged liabilities/reserves, and no surplus. -
Extend
CheckStatewithDEMO_EXPECTED_STAGE=upgradedfor version2plus the pre-transfer Act 1 balances, andv2for the exact Act 3 table, while retainingdeployed,v1, andinvariantsmodes. The orchestration usesinvariantsafter an idempotent upgrade because a valid already-V2 bank may have progressed past Act 3. -
Extend the ABI bridge only now: require the real
BankV2artifact, emitbankV2Abi, validatetransferBalanceandBalanceTransferred, and make freshness checks fail if the generated module remains V1-only. -
Extend the dashboard types from
version: 1toversion: 1 | 2and add thetransferactivity variant. DecodeBalanceTransferredfrom 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, andBASE_SEPOLIA_RECIPIENT. -
Produces explicit deploy/upgrade/transfer commands for chain
84532using a named encrypted Foundry keystore. -
Never makes a keystore, faucet, Base RPC call, or actual encore a prerequisite of
make verifyor 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 Foundry’s 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 beforevm.startBroadcastin every testnet script path. -
Before Base deployment/finalization, extend
DeployV1.s.solto requireBASE_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 asPresenterandRecipient. Serialize onlyBASE_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, mint1_000e6valueless mUSDC to the presenter, approve/deposit1_000e6, and assert reserves/liabilities/presenter balance1_000e6plus recipient balance0. No Anvil test phrase or derived key path is reachable on Base Sepolia. -
Extend
TransferV2Demotestnet mode to transfer250e6from the verified presenter to the configured nonzero/different recipient, asserting unchanged reserves/liabilities and post-balances750e6/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.mjswith a testedarchive baseSepoliacommand that moves onlydeployments/base-sepolia.jsonto a validated timestamped sibling filename and never altersactive.jsonor 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 updateactive.jsonand 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
--accountand--sender. Load an optional root.envthrough 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.shand 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 acceptPRIVATE_KEY/MNEMONICfallbacks. -
Append
bash tools/test-base-config.shtomake verify. The test supplies only fake fixture values and never requires a keystore, RPC, faucet, or network. -
Bind the console’s generic explorer-link support to the confirmed Base manifest and verify exact
https://sepolia.basescan.org/address/<address>/transaction links. The browser uses onlyBASE_SEPOLIA_PUBLIC_RPC_URL; the terminal RPC never entersweb/public/deployment.jsonor 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-completetag. -
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 verifyevidence 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-completein 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 scanner’s 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, Bob500e6, liabilities/reserves1_400e6, version1. - Upgrade: identical proxy/owner/asset/pause/balances/liabilities/reserves and a changed implementation.
- Act 3: Alice
650e6, Bob750e6, liabilities/reserves1_400e6, version2, 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-startcontains no V2 source or transfer-aware UI;demo-completecontains the verified reference implementation.