1624 lines
81 KiB
Markdown
1624 lines
81 KiB
Markdown
# UUPS Bank Demo Implementation Plan
|
||
|
||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||
|
||
**Goal:** Deliver a deterministic, local-first UUPS “bank” demo that starts at a verified V1 checkpoint, adds internal balance transfers in V2 without losing state, and explains the result through a read-only React operations console.
|
||
|
||
**Architecture:** Foundry scripts are the only state-changing control plane. A six-decimal `MockUSDC` and a UUPS `BankV1` implementation run behind a stable ERC-1967 proxy on Anvil or optional Base Sepolia. Scripts export a public deployment manifest and contract ABIs; a wagmi/viem client reads those artifacts and renders reserves, liabilities, identities, actors, and events without a signer or wallet connector. V2 inherits V1, adds no storage, and adds one internal transfer function.
|
||
|
||
**Tech Stack:** Foundry `v1.7.1`, forge-std `v1.16.1`, Solidity `0.8.35`, OpenZeppelin Contracts Upgradeable `v5.6.1`, OpenZeppelin Foundry Upgrades `v0.4.1`, OpenZeppelin Upgrades Core `1.46.0`, Node `24.18.0`, npm `11.17.0`, React `19.2.8`, TypeScript `7.0.2`, Vite `8.2.0`, wagmi `3.7.5`, viem `2.55.8`, TanStack Query `5.101.4`, Vitest `4.1.10`, Testing Library React `16.3.2`, and jsdom `30.0.1`.
|
||
|
||
## Global Constraints
|
||
|
||
- The contracts are educational and unaudited. Every user-facing surface says: `Educational demo — mock token — never use real funds.`
|
||
- State-changing scripts accept only chain IDs `31337` and `84532`; no mainnet RPC, address, target, or configuration is added.
|
||
- Local accounts come only from Anvil’s standard development mnemonic. Testnet signing uses a named encrypted Foundry keystore and `--account`; no supported command accepts a raw private key or mnemonic environment variable.
|
||
- Every application call uses the proxy address. The implementation address is read only for validation and explanation.
|
||
- `Upgrades`, never `UnsafeUpgrades`, performs deploy/upgrade validation. The sole validator allowance is the constructor annotation immediately above `_disableInitializers()`.
|
||
- V2 does not add, delete, reorder, or change the type of any storage variable and has no initializer.
|
||
- The browser has no connector, signer, transaction client, or write button. Failed reads remain unknown; they are never rendered as zero.
|
||
- Generated Foundry output, local manifests, copied web artifacts, `.demo/` process state, `.env`, and `.superpowers/` are ignored by Git.
|
||
- Run each red/green command exactly as written. A test that unexpectedly passes in a red step means the test is not proving the intended behavior; fix the test before implementation.
|
||
- Use exact versions and committed lockfiles/submodule revisions. Do not replace exact versions with ranges.
|
||
- OpenZeppelin Foundry Upgrades `v0.4.1` invokes `npx @openzeppelin/upgrades-core@^1.45.0`; the root lockfile pins the satisfying implementation to `1.46.0`, and every validation-bearing command runs with `npm_config_offline=true` after setup proves the local CLI is available.
|
||
- Commit only files listed for the task and inspect `git status --short` before every commit so unrelated user changes remain untouched.
|
||
|
||
## File and Interface Map
|
||
|
||
| Area | Files | Produces / consumes |
|
||
| --- | --- | --- |
|
||
| Toolchain | `.nvmrc`, `.env.example`, `.gitignore`, `.gitmodules`, `foundry.lock`, `foundry.toml`, `remappings.txt`, root/web `package.json` and `package-lock.json`, `Makefile` | Pins compilers, validation CLI, and dependencies; defines the command contract used by scripts, CI-style verification, and docs |
|
||
| V1 contracts | `src/MockUSDC.sol`, `src/BankV1.sol` | Produces token and bank ABIs; consumes OpenZeppelin ERC-20, access, pause, reentrancy, initializer, and UUPS modules |
|
||
| V2 contract | `src/BankV2.sol` | Consumes V1 storage and behavior; produces `transferBalance`, `BalanceTransferred`, and version `2` |
|
||
| Test support | `test/helpers/BankTestBase.sol`, `test/mocks/FeeOnTransferToken.sol`, `test/mocks/ReentrantToken.sol`, `test/mocks/IncompatibleBank.sol`, `test/mocks/NonUUPSImplementation.sol`, `test/helpers/BankHandler.sol`, `test/helpers/BankV2Handler.sol` | Reusable actors, proxy deployment, hostile token behavior, invalid upgrades, and stateful action generation |
|
||
| Contract tests | `test/MockUSDC.t.sol`, `test/BankV1Admin.t.sol`, `test/BankV1.t.sol`, `test/BankInvariant.t.sol`, `test/BankV2.t.sol`, `test/BankUpgrade.t.sol` | Consumes contracts and Upgrades plugin; proves units, fuzz properties, invariants, authorization, and upgrade continuity |
|
||
| Foundry scripts | `script/lib/DemoScript.sol`, `script/DeployV1.s.sol`, `script/SeedV1Demo.s.sol`, `script/CheckState.s.sol`, `script/UpgradeV2.s.sol`, `script/TransferV2Demo.s.sol` | Consumes RPC plus explicit sender/account; produces transactions, postcondition evidence, and `deployments/active.json` |
|
||
| Artifact/manifest bridge | `tools/sync-web-artifacts.mjs`, `tools/finalize-manifest.mjs`, `tools/select-manifest.mjs`, `tools/publish-web-manifest.mjs`, `web/src/generated/.gitkeep`, `web/public/.gitkeep` | Generates ABIs without a chain; confirms receipts into per-chain manifests; explicitly selects/publishes the active network |
|
||
| Web data | `web/src/types/dashboard.ts`, `web/src/config/chains.ts`, `web/src/config/manifest.ts`, `web/src/data/bankClient.ts` | Consumes manifest, ABI, public RPC; produces validated immutable dashboard snapshots and decoded activity |
|
||
| Web UI | `web/src/main.tsx`, `web/src/App.tsx`, `web/src/app.css`, `web/src/hooks/useBankDashboard.ts`, `web/src/components/*.tsx` | Consumes dashboard states; renders a strictly read-only operations console |
|
||
| Web tests | `web/src/test/setup.ts`, `web/src/**/*.test.ts`, `web/src/**/*.test.tsx` | Proves schema, reads, decoding, accounting, ready/loading/stale/disconnected/mismatch views, and read-only behavior |
|
||
| Orchestration | `tools/doctor.sh`, `tools/process-lib.sh`, `tools/demo-local.sh`, `tools/reset-local.sh`, `tools/test-process-safety.sh`, `Makefile` | Produces memorable commands and project-scoped Anvil/Vite lifecycle; consumes only `.demo/` PID files |
|
||
| Learning/demo docs | `README.md`, `docs/LEARNING_GUIDE.md`, `docs/PRESENTER_RUNBOOK.md` | Consumes actual commands and exact Act 1/Act 3 states; produces quick start, explanations, live script, and recovery steps |
|
||
|
||
The public manifest contract is:
|
||
|
||
```json
|
||
{
|
||
"schemaVersion": 1,
|
||
"network": "anvil",
|
||
"chainId": 31337,
|
||
"deploymentBlock": 1,
|
||
"rpcUrl": "http://127.0.0.1:8545",
|
||
"explorerBaseUrl": null,
|
||
"token": "0x...",
|
||
"proxy": "0x...",
|
||
"implementation": "0x...",
|
||
"owner": "0x...",
|
||
"actors": [
|
||
{ "label": "Alice", "address": "0x..." },
|
||
{ "label": "Bob", "address": "0x..." }
|
||
]
|
||
}
|
||
```
|
||
|
||
`rpcUrl` is public local/testnet configuration, not a credential-bearing vendor URL. Testnet operators provide `BASE_SEPOLIA_RPC_URL` at runtime; the script writes only a redacted/public URL selected explicitly for display, or omits it and the browser requires `VITE_RPC_URL`.
|
||
|
||
Confirmed manifests are stored separately as ignored `deployments/anvil.json` and `deployments/base-sepolia.json`. `deployments/active.json` is an atomic copy selected from one of those files for scripts/the browser. Local reset may remove only the Anvil manifest and an active/copy whose parsed chain ID is `31337`; it must preserve Base Sepolia state. Explicit `make select-anvil` and `make select-base-sepolia` targets switch the active copy without overwriting the other network.
|
||
|
||
---
|
||
|
||
### Task 1: Pin and prove the repository toolchain
|
||
|
||
**Files:**
|
||
- Modify: `.gitignore`
|
||
- Create: `.nvmrc`
|
||
- Create: `.env.example`
|
||
- Create: `.gitmodules` (generated by `forge install`)
|
||
- Create: `foundry.lock` (generated by `forge install`)
|
||
- Create: `foundry.toml`
|
||
- Create: `remappings.txt`
|
||
- Create: `Makefile`
|
||
- Create: `package.json`
|
||
- Create: `package-lock.json` (generated by npm)
|
||
- Create: `tools/check-upgrades-cli.mjs`
|
||
- Create: `web/package.json`
|
||
- Create: `web/package-lock.json` (generated by npm)
|
||
- Create: `web/index.html`
|
||
- Create: `web/tsconfig.json`
|
||
- Create: `web/tsconfig.app.json`
|
||
- Create: `web/tsconfig.node.json`
|
||
- Create: `web/vite.config.ts`
|
||
- Create: `web/eslint.config.js`
|
||
- Create: `web/src/test/toolchain.test.ts`
|
||
|
||
**Interfaces:**
|
||
- Produces `make doctor`, `make setup`, and the initial verification gate that later tasks extend.
|
||
- Produces exact dependency revisions for every later Solidity and web task.
|
||
- Consumes no application source.
|
||
|
||
- [ ] Verify host tools and record the expected initial limitation:
|
||
|
||
```bash
|
||
forge --version
|
||
anvil --version
|
||
node --version
|
||
npm --version
|
||
make --version
|
||
```
|
||
|
||
Expected now: Foundry commands may be missing; Node/npm/make print their installed versions. If Foundry is missing during execution, request permission to install Foundry from its official installer, then pin with `foundryup -i 1.7.1`. Do not silently install system-wide tools.
|
||
|
||
- [ ] Extend `.gitignore` with exactly these runtime classes:
|
||
|
||
```gitignore
|
||
.env
|
||
.env.local
|
||
.demo/
|
||
cache/
|
||
out/
|
||
broadcast/
|
||
deployments/*.json
|
||
deployments/**/*.json
|
||
!deployments/*.example.json
|
||
node_modules/
|
||
web/node_modules/
|
||
web/dist/
|
||
web/coverage/
|
||
web/public/deployment.json
|
||
web/src/generated/*.ts
|
||
!.gitkeep
|
||
.superpowers/
|
||
```
|
||
|
||
- [ ] Pin Node/npm in `.nvmrc` and create a private root `package.json` with the same `packageManager`/`engines` fields plus exactly one dev dependency: `"@openzeppelin/upgrades-core": "1.46.0"`. Generate and commit the root lockfile with `npm install --save-exact`. This locally satisfies the plugin’s hard-coded `^1.45.0` range.
|
||
|
||
- [ ] Pin the browser dependencies in `web/package.json`; the package must be private and use only exact versions:
|
||
|
||
```json
|
||
{
|
||
"name": "uups-bank-operations-console",
|
||
"private": true,
|
||
"version": "0.1.0",
|
||
"type": "module",
|
||
"packageManager": "npm@11.17.0",
|
||
"engines": { "node": ">=24.18.0 <25", "npm": ">=11.17.0 <12" },
|
||
"scripts": {
|
||
"dev": "vite --host 127.0.0.1",
|
||
"lint": "eslint . --max-warnings 0",
|
||
"typecheck": "tsc -b --pretty false",
|
||
"test": "vitest run",
|
||
"build": "tsc -b && vite build"
|
||
}
|
||
}
|
||
```
|
||
|
||
Add exact runtime dependencies `@tanstack/react-query@5.101.4`, `react@19.2.8`, `react-dom@19.2.8`, `viem@2.55.8`, and `wagmi@3.7.5`. Add exact dev dependencies `@eslint/js@10.0.1`, `@testing-library/dom@10.4.1`, `@testing-library/react@16.3.2`, `@types/node@24.10.0`, `@types/react@19.2.14`, `@types/react-dom@19.2.4`, `@vitejs/plugin-react@6.0.4`, `eslint@10.0.1`, `eslint-plugin-react-hooks@7.1.1`, `eslint-plugin-react-refresh@0.5.3`, `globals@17.7.0`, `jsdom@30.0.1`, `typescript@7.0.2`, `typescript-eslint@8.65.0`, `vite@8.2.0`, and `vitest@4.1.10`. If npm rejects one exact revision because the registry changed, verify the official release before changing both this plan and the package file.
|
||
|
||
- [ ] Configure Foundry in `foundry.toml`:
|
||
|
||
```toml
|
||
[profile.default]
|
||
src = "src"
|
||
test = "test"
|
||
script = "script"
|
||
out = "out"
|
||
libs = ["lib"]
|
||
solc_version = "0.8.35"
|
||
evm_version = "cancun"
|
||
optimizer = true
|
||
optimizer_runs = 200
|
||
ffi = true
|
||
ast = true
|
||
build_info = true
|
||
extra_output = ["storageLayout"]
|
||
fs_permissions = [
|
||
{ access = "read", path = "out" },
|
||
{ access = "read-write", path = "deployments" }
|
||
]
|
||
|
||
[fuzz]
|
||
runs = 512
|
||
seed = "0x5555505342414e4b"
|
||
|
||
[invariant]
|
||
runs = 128
|
||
depth = 64
|
||
fail_on_revert = true
|
||
```
|
||
|
||
- [ ] Install exact Solidity dependencies as Git submodules and write canonical remappings:
|
||
|
||
```bash
|
||
forge install foundry-rs/forge-std@v1.16.1
|
||
forge install OpenZeppelin/openzeppelin-foundry-upgrades@v0.4.1
|
||
forge install OpenZeppelin/openzeppelin-contracts-upgradeable@v5.6.1
|
||
git submodule update --init --recursive
|
||
```
|
||
|
||
```text
|
||
forge-std/=lib/forge-std/src/
|
||
openzeppelin-foundry-upgrades/=lib/openzeppelin-foundry-upgrades/src/
|
||
@openzeppelin/contracts/=lib/openzeppelin-contracts-upgradeable/lib/openzeppelin-contracts/contracts/
|
||
@openzeppelin/contracts-upgradeable/=lib/openzeppelin-contracts-upgradeable/contracts/
|
||
```
|
||
|
||
Do not install a second top-level copy of `openzeppelin-contracts`; the upgradeable submodule’s pinned transitive copy supplies both canonical remappings.
|
||
|
||
- [ ] Create `tools/check-upgrades-cli.mjs`. It must assert that the plugin source contains `UPGRADES_CORE = "^1.45.0"`, the root lockfile resolves `@openzeppelin/upgrades-core` to exactly `1.46.0`, and the locally installed package reports `1.46.0`. Then prove the CLI can start with networking disabled:
|
||
|
||
```bash
|
||
npm ci
|
||
node tools/check-upgrades-cli.mjs
|
||
npm_config_offline=true npx @openzeppelin/upgrades-core@^1.45.0 validate --help
|
||
```
|
||
|
||
Expected: all commands exit `0` without fetching. Do not patch the vendored OpenZeppelin version constant.
|
||
|
||
- [ ] Create Vite/React/TypeScript/ESLint/Vitest configuration manually so no unpinned scaffold generator is executed. Configure jsdom, `web/src/test/setup.ts`, strict TypeScript, and React refresh. Run:
|
||
|
||
```bash
|
||
npm --prefix web install --save-exact
|
||
npm --prefix web test
|
||
```
|
||
|
||
Expected red: `toolchain.test.ts` cannot import the not-yet-created `src/config/toolchain.ts`.
|
||
|
||
- [ ] Create `web/src/config/toolchain.ts` exporting the display labels `Foundry 1.7.1`, `Solidity 0.8.35`, `OpenZeppelin 5.6.1`, and `UUPS`; make the test assert those exact values.
|
||
|
||
- [ ] Add an initial `Makefile` with shell safety (`SHELL := /bin/bash`, `.SHELLFLAGS := -euo pipefail -c`) and non-destructive targets:
|
||
|
||
```make
|
||
.PHONY: doctor setup verify
|
||
doctor:
|
||
@./tools/doctor.sh
|
||
setup:
|
||
@git submodule update --init --recursive
|
||
@npm ci
|
||
@npm --prefix web ci
|
||
verify:
|
||
@forge fmt --check
|
||
@forge clean
|
||
@npm_config_offline=true forge build --force
|
||
@npm_config_offline=true forge test --force
|
||
@npm --prefix web run lint
|
||
@npm --prefix web run typecheck
|
||
@npm --prefix web test
|
||
@npm --prefix web run build
|
||
```
|
||
|
||
Create a temporary minimal `tools/doctor.sh` that reports missing commands and exact expected versions without installing anything; Task 9 replaces it with full port/config checks.
|
||
|
||
- [ ] Run the green foundation checks:
|
||
|
||
```bash
|
||
forge fmt --check
|
||
forge clean
|
||
npm_config_offline=true forge build --force
|
||
npm_config_offline=true forge test --force
|
||
node tools/check-upgrades-cli.mjs
|
||
npm --prefix web run lint
|
||
npm --prefix web run typecheck
|
||
npm --prefix web test
|
||
npm --prefix web run build
|
||
```
|
||
|
||
Expected: all exit `0`; the empty Solidity source tree builds and the toolchain test passes.
|
||
|
||
- [ ] Commit the foundation:
|
||
|
||
```bash
|
||
git add .gitignore .nvmrc .env.example .gitmodules foundry.lock foundry.toml remappings.txt package.json package-lock.json Makefile tools/check-upgrades-cli.mjs tools/doctor.sh lib web
|
||
git commit -m "build: pin demo toolchains"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 2: Build the valueless six-decimal mock asset with TDD
|
||
|
||
**Files:**
|
||
- Create: `src/MockUSDC.sol`
|
||
- Create: `test/MockUSDC.t.sol`
|
||
|
||
**Interfaces:**
|
||
- Produces `MockUSDC.mint(address,uint256)`, `decimals() == 6`, and standard ERC-20 behavior.
|
||
- Consumed by the bank tests, deployment scripts, and dashboard ABI export.
|
||
|
||
- [ ] Write `test/MockUSDC.t.sol` first. Cover the exact name/symbol/decimals, owner-only mint, successful mint, transfer/approve behavior inherited from ERC-20, and zero initial supply. Use `vm.expectRevert(abi.encodeWithSelector(Ownable.OwnableUnauthorizedAccount.selector, stranger))` for non-owner minting.
|
||
|
||
- [ ] Run the focused test and observe red:
|
||
|
||
```bash
|
||
forge test --match-path test/MockUSDC.t.sol -vvv --force
|
||
```
|
||
|
||
Expected red: `src/MockUSDC.sol` is missing.
|
||
|
||
- [ ] Implement only the tested contract:
|
||
|
||
```solidity
|
||
/// @notice Educational mock token with no monetary value. Never use as real USDC.
|
||
contract MockUSDC is ERC20, Ownable {
|
||
constructor(address initialOwner) ERC20("Mock USD Coin", "mUSDC") Ownable(initialOwner) {}
|
||
|
||
function decimals() public pure override returns (uint8) { return 6; }
|
||
function mint(address to, uint256 amount) external onlyOwner { _mint(to, amount); }
|
||
}
|
||
```
|
||
|
||
Reject a zero `initialOwner` through OpenZeppelin’s `OwnableInvalidOwner` behavior. Do not add faucet, burn, permit, blacklist, proxy, or bank-specific logic.
|
||
|
||
- [ ] Run red-to-green verification:
|
||
|
||
```bash
|
||
forge fmt
|
||
forge test --match-path test/MockUSDC.t.sol -vvv --force
|
||
```
|
||
|
||
Expected: all mock-token tests pass.
|
||
|
||
- [ ] Commit:
|
||
|
||
```bash
|
||
git add src/MockUSDC.sol test/MockUSDC.t.sol
|
||
git commit -m "feat: add valueless mock USDC"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 3: Establish the V1 proxy, initialization, views, and administration
|
||
|
||
**Files:**
|
||
- Create: `src/BankV1.sol`
|
||
- Create: `test/helpers/BankTestBase.sol`
|
||
- Create: `test/BankV1Admin.t.sol`
|
||
|
||
**Interfaces:**
|
||
- Produces `initialize(address,address)`, `asset()`, `balanceOf(address)`, `totalLiabilities()`, `contractVersion()`, `pause()`, `unpause()`, and owner-authorized UUPS upgrades.
|
||
- Consumes `MockUSDC` and OpenZeppelin `Upgrades.deployUUPSProxy`.
|
||
- Freezes V1 application storage as `_asset`, `_balances`, `_totalLiabilities`, then `uint256[47] __gap`.
|
||
|
||
- [ ] Create `BankTestBase.sol` with deterministic `owner`, `alice`, `bob`, and `stranger` addresses from `makeAddr`; deploy `MockUSDC`; deploy the proxy with:
|
||
|
||
```solidity
|
||
proxy = Upgrades.deployUUPSProxy(
|
||
"BankV1.sol:BankV1",
|
||
abi.encodeCall(BankV1.initialize, (address(token), owner))
|
||
);
|
||
bank = BankV1(proxy);
|
||
```
|
||
|
||
All bank calls in tests target `proxy`; retain `implementation = Upgrades.getImplementationAddress(proxy)` only for assertions.
|
||
|
||
- [ ] Write failing admin tests for:
|
||
|
||
- initialized asset, owner, unpaused state, zero liabilities, and version `1`;
|
||
- initialization with zero asset;
|
||
- initialization with zero owner through `OwnableInvalidOwner`;
|
||
- proxy double initialization;
|
||
- direct implementation initialization;
|
||
- only owner can pause/unpause;
|
||
- views still work while paused;
|
||
- `_authorizeUpgrade` rejects a non-owner through `upgradeToAndCall`;
|
||
- implementation `proxiableUUID()` is available directly while the proxy-context call reverts.
|
||
|
||
- [ ] Run the focused suite and observe red:
|
||
|
||
```bash
|
||
npm_config_offline=true forge test --match-path test/BankV1Admin.t.sol -vvv --force
|
||
```
|
||
|
||
Expected red: `BankV1` is missing.
|
||
|
||
- [ ] Implement the minimum V1 skeleton with these imports and inheritance order:
|
||
|
||
```solidity
|
||
import {Initializable} from "@openzeppelin/contracts/proxy/utils/Initializable.sol";
|
||
import {UUPSUpgradeable} from "@openzeppelin/contracts/proxy/utils/UUPSUpgradeable.sol";
|
||
import {ReentrancyGuard} from "@openzeppelin/contracts/utils/ReentrancyGuard.sol";
|
||
import {OwnableUpgradeable} from "@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol";
|
||
import {PausableUpgradeable} from "@openzeppelin/contracts-upgradeable/utils/PausableUpgradeable.sol";
|
||
|
||
contract BankV1 is
|
||
Initializable,
|
||
UUPSUpgradeable,
|
||
OwnableUpgradeable,
|
||
PausableUpgradeable,
|
||
ReentrancyGuard
|
||
{
|
||
IERC20 internal _asset;
|
||
mapping(address account => uint256 balance) internal _balances;
|
||
uint256 internal _totalLiabilities;
|
||
uint256[47] private __gap;
|
||
}
|
||
```
|
||
|
||
Use `error InvalidAsset(address asset);`. The initializer checks the asset before calling only `__Ownable_init(initialOwner)` and `__Pausable_init()`. `Initializable`, `UUPSUpgradeable`, and `ReentrancyGuard` are stateless/shared in pinned OpenZeppelin 5.6.1 and have no initializer calls.
|
||
|
||
- [ ] Add the only permitted validator annotation and no other bypass:
|
||
|
||
```solidity
|
||
/// @custom:oz-upgrades-unsafe-allow constructor
|
||
constructor() {
|
||
_disableInitializers();
|
||
}
|
||
```
|
||
|
||
- [ ] Add `pause`/`unpause` with `onlyOwner`, simple views, `contractVersion() public pure virtual returns (uint256)`, and `_authorizeUpgrade(address) internal override onlyOwner {}`. Do not add deposits or withdrawals yet.
|
||
|
||
- [ ] Run green verification and inspect storage:
|
||
|
||
```bash
|
||
forge fmt
|
||
npm_config_offline=true forge test --match-path test/BankV1Admin.t.sol -vvv --force
|
||
forge inspect BankV1 storage-layout
|
||
```
|
||
|
||
Expected: tests pass and the application fields appear in the frozen order. OpenZeppelin namespaced/stateless internals must not be mistaken for permission to reorder application fields.
|
||
|
||
- [ ] Commit:
|
||
|
||
```bash
|
||
git add src/BankV1.sol test/helpers/BankTestBase.sol test/BankV1Admin.t.sol
|
||
git commit -m "feat: establish UUPS bank V1"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 4: Implement V1 custody accounting and adversarial unit tests
|
||
|
||
**Files:**
|
||
- Modify: `src/BankV1.sol`
|
||
- Create: `test/BankV1.t.sol`
|
||
- Create: `test/mocks/FeeOnTransferToken.sol`
|
||
- Create: `test/mocks/ReentrantToken.sol`
|
||
|
||
**Interfaces:**
|
||
- Produces `deposit(uint256)` and `withdraw(uint256)` plus `Deposited`, `Withdrawn`, `ZeroAmount`, `InsufficientBalance`, and `UnexpectedAssetDelta`.
|
||
- Consumes only a standard non-rebasing ERC-20 through `IERC20`/`SafeERC20`.
|
||
- Preserves `reserves >= totalLiabilities`; direct token transfers may create surplus.
|
||
|
||
- [ ] Write event and error declarations into the test expectations before production code:
|
||
|
||
```solidity
|
||
event Deposited(address indexed account, uint256 amount);
|
||
event Withdrawn(address indexed account, uint256 amount);
|
||
|
||
error ZeroAmount();
|
||
error InsufficientBalance(address account, uint256 available, uint256 requested);
|
||
error UnexpectedAssetDelta(uint256 expected, uint256 actual);
|
||
```
|
||
|
||
- [ ] Write failing deposit tests for exact balance/liability/reserve deltas, event emission, zero amount, paused state, inadequate allowance, inadequate wallet balance, and two independent customers. Use six-decimal constants (`1_000e6`) so assertions read like the demo.
|
||
|
||
- [ ] Add `FeeOnTransferToken` whose `transferFrom` delivers `99%` of the requested amount. Assert a deposit reverts with `UnexpectedAssetDelta(requested, received)` and that the ERC-20 transfer, bank balance, and liabilities are all rolled back.
|
||
|
||
- [ ] Add `ReentrantToken` that attempts a nested `bank.deposit` during `transferFrom`. Assert the nested call receives `ReentrancyGuardReentrantCall` and the outer call either completes once or reverts atomically according to the mock’s configured propagation mode; no double credit is permitted.
|
||
|
||
- [ ] Write failing withdrawal tests for exact deltas, event emission, zero amount, paused state, insufficient internal balance (including available/requested values), checks-effects-interactions under a transfer callback, and one account’s withdrawal leaving another account unchanged.
|
||
|
||
- [ ] Write the surplus test: deposit 100 mUSDC, transfer 25 mUSDC directly to the proxy, assert reserves `125e6`, liabilities `100e6`, and a normal 100 mUSDC withdrawal succeeds while the 25 mUSDC surplus remains. Assert there is no owner sweep/rescue behavior by keeping such a function out of the interface.
|
||
|
||
- [ ] Run red tests:
|
||
|
||
```bash
|
||
npm_config_offline=true forge test --match-path test/BankV1.t.sol -vvv --force
|
||
```
|
||
|
||
Expected red: `deposit`/`withdraw` and their errors/events are absent.
|
||
|
||
- [ ] Implement `deposit` with exact received-amount validation and state credit only after a successful transfer:
|
||
|
||
```solidity
|
||
function deposit(uint256 amount) external whenNotPaused nonReentrant {
|
||
if (amount == 0) revert ZeroAmount();
|
||
|
||
uint256 reservesBefore = _asset.balanceOf(address(this));
|
||
_asset.safeTransferFrom(msg.sender, address(this), amount);
|
||
uint256 reservesAfter = _asset.balanceOf(address(this));
|
||
uint256 received = reservesAfter >= reservesBefore ? reservesAfter - reservesBefore : 0;
|
||
if (received != amount) revert UnexpectedAssetDelta(amount, received);
|
||
|
||
_balances[msg.sender] += amount;
|
||
_totalLiabilities += amount;
|
||
emit Deposited(msg.sender, amount);
|
||
}
|
||
```
|
||
|
||
- [ ] Implement `withdraw` with checks-effects-interactions:
|
||
|
||
```solidity
|
||
function withdraw(uint256 amount) external whenNotPaused nonReentrant {
|
||
if (amount == 0) revert ZeroAmount();
|
||
uint256 available = _balances[msg.sender];
|
||
if (amount > available) revert InsufficientBalance(msg.sender, available, amount);
|
||
|
||
_balances[msg.sender] = available - amount;
|
||
_totalLiabilities -= amount;
|
||
_asset.safeTransfer(msg.sender, amount);
|
||
emit Withdrawn(msg.sender, amount);
|
||
}
|
||
```
|
||
|
||
- [ ] Add fuzz tests in `BankV1.t.sol`: bound deposit to `[1, 1_000_000e6]`; bound withdrawal to `[1, deposited]`; prove exact reserve/liability/customer deltas and that over-withdraw always reverts. Keep a fixed seed in `foundry.toml` for presentation reproducibility while printing Foundry’s replay seed on failure.
|
||
|
||
- [ ] Run focused and aggregate green tests:
|
||
|
||
```bash
|
||
forge fmt
|
||
npm_config_offline=true forge test --match-path test/BankV1.t.sol -vvv --force
|
||
npm_config_offline=true forge test --match-path 'test/BankV1*.t.sol' --force
|
||
```
|
||
|
||
Expected: all V1 tests pass with validated proxy deployment enabled.
|
||
|
||
- [ ] Commit:
|
||
|
||
```bash
|
||
git add src/BankV1.sol test/BankV1.t.sol test/mocks/FeeOnTransferToken.sol test/mocks/ReentrantToken.sol
|
||
git commit -m "feat: add V1 custody accounting"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 5: Prove V1 ledger conservation and solvency with stateful invariants
|
||
|
||
**Files:**
|
||
- Create: `test/helpers/BankHandler.sol`
|
||
- Create: `test/BankInvariant.t.sol`
|
||
|
||
**Interfaces:**
|
||
- Consumes `MockUSDC`, the V1 proxy, and a bounded set of four actor addresses.
|
||
- Produces independent ghost counters and handler accessors used by invariant assertions.
|
||
- Later Task 11 extends the same handler with V2 transfers.
|
||
|
||
- [ ] Write `BankHandler` with four immutable actor addresses and only these V1 actions:
|
||
|
||
- `deposit(uint256 actorSeed, uint256 amount)`: select an actor, bound amount to `[1, 10_000e6]`, mint to the actor, approve, deposit, and increment `ghostDeposited`.
|
||
- `withdraw(uint256 actorSeed, uint256 amount)`: select an actor, return early only when its internal balance is zero, bound to `[1, balance]`, withdraw, and increment `ghostWithdrawn`.
|
||
- `donate(uint256 actorSeed, uint256 amount)`: mint and directly transfer `[1, 1_000e6]` to the proxy, incrementing `ghostDonated` but not liabilities.
|
||
|
||
Every action uses `vm.startPrank(actor)`/`vm.stopPrank()` in a balanced scope. The handler exposes the actor array so the invariant test, not the handler, sums on-chain balances.
|
||
|
||
In invariant setup, transfer `MockUSDC` ownership to the handler so only the handler can mint bounded test liquidity. This avoids impersonating an owner inside actions and does not affect bank ownership.
|
||
|
||
- [ ] Write failing invariant tests:
|
||
|
||
```solidity
|
||
function invariant_liabilitiesEqualTrackedBalances() public view {
|
||
uint256 sum;
|
||
for (uint256 i; i < handler.actorCount(); ++i) {
|
||
sum += bank.balanceOf(handler.actorAt(i));
|
||
}
|
||
assertEq(sum, bank.totalLiabilities());
|
||
}
|
||
|
||
function invariant_reservesCoverLiabilities() public view {
|
||
assertGe(token.balanceOf(address(bank)), bank.totalLiabilities());
|
||
}
|
||
|
||
function invariant_ghostAccountingMatchesChain() public view {
|
||
assertEq(handler.ghostDeposited() - handler.ghostWithdrawn(), bank.totalLiabilities());
|
||
assertEq(
|
||
handler.ghostDeposited() + handler.ghostDonated() - handler.ghostWithdrawn(),
|
||
token.balanceOf(address(bank))
|
||
);
|
||
}
|
||
```
|
||
|
||
- [ ] Run red:
|
||
|
||
```bash
|
||
npm_config_offline=true forge test --match-path test/BankInvariant.t.sol -vvv --force
|
||
```
|
||
|
||
Expected red: handler implementation is absent/incomplete.
|
||
|
||
- [ ] Implement the minimum handler. Register it with `targetContract(address(handler))` and explicitly target only its three action selectors so inherited test/helper functions cannot enter the action space.
|
||
|
||
- [ ] Run green and retain the call summary in output:
|
||
|
||
```bash
|
||
npm_config_offline=true forge test --match-path test/BankInvariant.t.sol -vvv --force
|
||
```
|
||
|
||
Expected: all three invariants pass for `128` runs at depth `64`; the summary shows deposits, withdrawals, and donations were each exercised.
|
||
|
||
- [ ] Commit:
|
||
|
||
```bash
|
||
git add test/helpers/BankHandler.sol test/BankInvariant.t.sol
|
||
git commit -m "test: prove V1 accounting invariants"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 6: Deploy, seed, inspect, and export deterministic V1 state
|
||
|
||
**Files:**
|
||
- Create: `script/lib/DemoScript.sol`
|
||
- Create: `script/DeployV1.s.sol`
|
||
- Create: `script/SeedV1Demo.s.sol`
|
||
- Create: `script/CheckState.s.sol`
|
||
- Create: `test/ScriptPreflight.t.sol`
|
||
- Create: `tools/finalize-manifest.mjs`
|
||
- Create: `tools/select-manifest.mjs`
|
||
- Create: `tools/test-finalize-manifest.mjs`
|
||
- Create: `deployments/.gitkeep`
|
||
- Modify: `Makefile`
|
||
|
||
**Interfaces:**
|
||
- Consumes an RPC, chain ID, explicit broadcaster, pending manifest, and Foundry broadcast receipts.
|
||
- Produces an active schema-versioned public manifest only after receipt/code/postcondition checks, then the exact Act 1 state.
|
||
- Establishes local actor indexes: owner `0`, Alice `1`, Bob `2` from Anvil’s standard development mnemonic.
|
||
|
||
- [ ] Write `ScriptPreflight.t.sol` around a small public harness for `DemoScript` and cover:
|
||
|
||
- chain IDs `31337` and `84532` accepted;
|
||
- chain IDs `1`, `8453`, and an arbitrary value rejected before broadcast;
|
||
- missing/invalid manifest, wrong manifest chain, zero address, and address-without-code rejected;
|
||
- internal pending manifests may use deployment block `0`, while active manifests may not;
|
||
- local keys derivable only when chain ID is `31337`;
|
||
- serialized JSON contains public addresses but never the mnemonic, a private key, `PRIVATE_KEY`, or `MNEMONIC`.
|
||
|
||
- [ ] Run red:
|
||
|
||
```bash
|
||
forge test --match-path test/ScriptPreflight.t.sol -vvv --force
|
||
```
|
||
|
||
Expected red: `DemoScript` does not exist.
|
||
|
||
- [ ] Implement `DemoScript` constants and guards:
|
||
|
||
```solidity
|
||
uint256 internal constant ANVIL_CHAIN_ID = 31337;
|
||
uint256 internal constant BASE_SEPOLIA_CHAIN_ID = 84532;
|
||
string internal constant ANVIL_TEST_PHRASE =
|
||
"test test test test test test test test test test test junk";
|
||
string internal constant PENDING_MANIFEST_PATH = "deployments/pending.json";
|
||
string internal constant ACTIVE_MANIFEST_PATH = "deployments/active.json";
|
||
|
||
error UnsupportedChain(uint256 chainId);
|
||
error ManifestChainMismatch(uint256 expected, uint256 actual);
|
||
error MissingCode(string label, address target);
|
||
```
|
||
|
||
Provide narrow helpers for chain checks, `vm.deriveKey` on local only, manifest read/write through `vm.parseJson*`/`vm.serialize*`, code checks, and exact-state assertions. Resolve the input/output manifest from `DEPLOYMENT_MANIFEST_PATH` with a narrow default appropriate to each script. Never log or serialize a derived private key.
|
||
|
||
- [ ] Write `DeployV1.s.sol` red tests/behavior first, then implement:
|
||
|
||
1. validate chain and resolve the public `SCRIPT_SENDER` value;
|
||
2. start broadcast;
|
||
3. deploy `MockUSDC(sender)`;
|
||
4. call `Upgrades.deployUUPSProxy("BankV1.sol:BankV1", abi.encodeCall(...))`;
|
||
5. stop broadcast;
|
||
6. assert code, owner, asset, version `1`, and implementation identity;
|
||
7. write `deployments/pending.json` with schema `1`, network, chain, deployment block `0`, display RPC/explorer metadata, token, proxy, implementation, owner, and local actor labels/addresses.
|
||
|
||
On local, derive account `0`, require it equals `SCRIPT_SENDER`, and broadcast with that derived development key. On Base Sepolia, require `SCRIPT_SENDER` equals the expected owner and use the signer selected by Forge’s matching `--account`/`--sender` options; never read a raw signing secret from environment.
|
||
|
||
- [ ] Test `finalize-manifest.mjs` and `select-manifest.mjs` with temporary pending/broadcast fixtures and an injected fake JSON-RPC function. Cover: a pre-broadcast deploy guard that rejects an existing target-chain canonical file, exact proxy transaction match, successful receipt, receipt-derived block, actual chain-ID match, code at token/proxy/implementation, implementation-slot match, failed/missing/ambiguous receipt, partial broadcast, wrong chain, missing code, an existing active manifest from either network remaining untouched until selection, secret-bearing content, atomic same-directory rename, and selection of only a valid confirmed chain manifest. Prove a failure never creates/changes a confirmed or active manifest, and selecting Base preserves Anvil byte-for-byte (and vice versa).
|
||
|
||
- [ ] Implement the finalizer using Node standard modules and JSON-RPC `fetch`. Its network-free `preflight-deploy <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 receipt’s real block number, queries `eth_chainId`, `eth_getCode` for all three contracts, and queries the EIP-1967 implementation slot. Atomically write the chain’s canonical `anvil.json` or `base-sepolia.json` only after all checks pass. `select-manifest.mjs` validates a named canonical file and atomically copies it to `active.json`; it never deletes or overwrites the other chain. Never copy a credential-bearing terminal RPC into JSON.
|
||
|
||
- [ ] Write and implement `SeedV1Demo.s.sol`, hard-guarded to chain `31337`. It must perform and assert this exact sequence:
|
||
|
||
```text
|
||
owner mints Alice 2,000 mUSDC
|
||
owner mints Bob 1,000 mUSDC
|
||
Alice approves and deposits 1,000 mUSDC
|
||
Bob approves and deposits 500 mUSDC
|
||
Alice withdraws 100 mUSDC
|
||
```
|
||
|
||
Postconditions: Alice internal `900e6`, Bob internal `500e6`, liabilities `1_400e6`, reserves `1_400e6`, version `1`, no surplus.
|
||
|
||
- [ ] Write and implement `CheckState.s.sol`. Always print network, block, token, proxy, implementation, owner, pause state, version, each configured actor balance, reserves, liabilities, and surplus. Always fail on `reserves < liabilities`, implementation/manifest mismatch, or manifest chain mismatch. `DEMO_EXPECTED_STAGE=deployed` asserts version `1` and empty accounting; `v1` asserts exact Act 1 values; `invariants` checks network-independent invariants only. Task 11 adds stage `v2`.
|
||
|
||
- [ ] Add direct Make targets that do not start background processes yet:
|
||
|
||
```make
|
||
RPC_LOCAL := http://127.0.0.1:8545
|
||
ANVIL_OWNER := 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266
|
||
|
||
.PHONY: deploy-v1 seed-v1 check-state
|
||
deploy-v1:
|
||
@node tools/finalize-manifest.mjs preflight-deploy anvil
|
||
@SCRIPT_SENDER=$(ANVIL_OWNER) DEPLOYMENT_MANIFEST_PATH=deployments/pending.json npm_config_offline=true forge script script/DeployV1.s.sol:DeployV1 --rpc-url $(RPC_LOCAL) --sender $(ANVIL_OWNER) --broadcast --force
|
||
@DEPLOYMENT_MANIFEST_PATH=deployments/pending.json DEMO_EXPECTED_STAGE=deployed forge script script/CheckState.s.sol:CheckState --rpc-url $(RPC_LOCAL) --force
|
||
@node tools/finalize-manifest.mjs deploy --rpc-url $(RPC_LOCAL)
|
||
@node tools/select-manifest.mjs anvil
|
||
seed-v1:
|
||
@forge script script/SeedV1Demo.s.sol:SeedV1Demo --rpc-url $(RPC_LOCAL) --broadcast --force
|
||
check-state:
|
||
@DEMO_EXPECTED_STAGE=$${DEMO_EXPECTED_STAGE:-v1} forge script script/CheckState.s.sol:CheckState --rpc-url $(RPC_LOCAL) --force
|
||
```
|
||
|
||
- [ ] Run unit/preflight green checks:
|
||
|
||
```bash
|
||
forge fmt
|
||
forge test --match-path test/ScriptPreflight.t.sol -vvv --force
|
||
```
|
||
|
||
- [ ] Run the first real local smoke test in two terminals. Terminal A:
|
||
|
||
```bash
|
||
anvil --host 127.0.0.1 --port 8545 --chain-id 31337
|
||
```
|
||
|
||
Terminal B:
|
||
|
||
```bash
|
||
make deploy-v1
|
||
make seed-v1
|
||
DEMO_EXPECTED_STAGE=v1 make check-state
|
||
```
|
||
|
||
Expected: the exact Act 1 table prints and all commands exit `0`. Inspect identical `deployments/anvil.json` and selected `deployments/active.json`, verify their deployment block matches the confirmed proxy receipt, and verify they contain no secret material. Simulate a failed receipt fixture and confirm the finalizer changes neither confirmed nor active files.
|
||
|
||
- [ ] Commit:
|
||
|
||
```bash
|
||
git add script test/ScriptPreflight.t.sol tools/finalize-manifest.mjs tools/select-manifest.mjs tools/test-finalize-manifest.mjs deployments/.gitkeep Makefile
|
||
git commit -m "feat: script deterministic V1 demo state"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 7: Build the artifact bridge and typed read model
|
||
|
||
**Files:**
|
||
- Create: `tools/sync-web-artifacts.mjs`
|
||
- Create: `tools/test-sync-web-artifacts.mjs`
|
||
- Create: `tools/publish-web-manifest.mjs`
|
||
- Create: `web/src/generated/.gitkeep`
|
||
- Create: `web/public/.gitkeep`
|
||
- Create: `web/src/types/dashboard.ts`
|
||
- Create: `web/src/config/chains.ts`
|
||
- Create: `web/src/config/manifest.ts`
|
||
- Create: `web/src/config/manifest.test.ts`
|
||
- Create: `web/src/data/bankClient.ts`
|
||
- Create: `web/src/data/bankClient.test.ts`
|
||
- Modify: `Makefile`
|
||
|
||
**Interfaces:**
|
||
- ABI sync consumes only Foundry V1/token artifacts and works without RPC or a deployment manifest.
|
||
- Manifest publishing separately consumes confirmed `deployments/active.json`.
|
||
- Produces ignored `web/src/generated/contracts.ts` and `web/public/deployment.json` without mixing build and live-state requirements.
|
||
- Produces `DashboardSnapshot` from a narrow injectable reader so web tests never need a live chain.
|
||
|
||
- [ ] Define discriminated TypeScript types first:
|
||
|
||
```ts
|
||
export type DeploymentManifest = Readonly<{
|
||
schemaVersion: 1
|
||
network: 'anvil' | 'baseSepolia'
|
||
chainId: 31337 | 84532
|
||
deploymentBlock: bigint
|
||
rpcUrl?: string
|
||
explorerBaseUrl?: string
|
||
token: Address
|
||
proxy: Address
|
||
implementation: Address
|
||
owner: Address
|
||
actors: readonly Readonly<{ label: string; address: Address }>[]
|
||
}>
|
||
|
||
export type DashboardSnapshot = Readonly<{
|
||
blockNumber: bigint
|
||
synchronizedAt: Date
|
||
version: 1
|
||
paused: boolean
|
||
asset: Address
|
||
owner: Address
|
||
proxy: Address
|
||
implementation: Address
|
||
reserves: bigint
|
||
liabilities: bigint
|
||
surplus: bigint
|
||
actors: readonly ActorBalance[]
|
||
activity: readonly Activity[]
|
||
diagnostics: readonly DecodeDiagnostic[]
|
||
}>
|
||
```
|
||
|
||
Use tagged `Activity` variants only for V1-visible `deposit`, `withdrawal`, `paused`, `unpaused`, `ownershipTransferred`, and `upgraded`, each carrying block number, log index, transaction hash, and typed event fields. A malformed log becomes a separate `DecodeDiagnostic` containing block/log/transaction identity and a safe message; it is never an `Activity` and never removes valid events. Do not add a transfer variant or accept version `2` before `demo-start`; Task 11 implements that live.
|
||
|
||
- [ ] Write `manifest.test.ts` before its parser. Test a valid local file, valid Base Sepolia file, malformed JSON, schema mismatch, chain/network mismatch, invalid address, zero contract address, empty/duplicate actor, deployment block below `1`, and credential-bearing RPC URLs containing user info or sensitive query keys (`key`, `token`, `secret`). The parser returns a typed value or an explanatory validation error; it never supplies defaults for missing addresses.
|
||
|
||
- [ ] Run red:
|
||
|
||
```bash
|
||
npm --prefix web test -- src/config/manifest.test.ts
|
||
```
|
||
|
||
Expected red: parser is missing.
|
||
|
||
- [ ] Implement `parseDeploymentManifest(unknown)` with explicit field guards and viem `isAddress`. Enforce `anvil -> 31337` and `baseSepolia -> 84532`. Normalize addresses only with `getAddress`; preserve `undefined` for optional URLs.
|
||
|
||
- [ ] Write `test-sync-web-artifacts.mjs` using Node’s built-in `assert` and temporary fixture directories. Prove that sync:
|
||
|
||
- rejects a missing V1 or token artifact;
|
||
- validates required V1 functions/events;
|
||
- emits `bankV1Abi` and `mockUsdcAbi` using `as const`;
|
||
- neither reads a manifest nor emits `bankV2Abi`;
|
||
- makes `--check` fail when generated output is stale.
|
||
|
||
Test `publish-web-manifest.mjs` separately in the same harness: only a schema-valid active manifest is copied, browser RPC is public/credential-free, pending/zero-block manifests are rejected, secret markers are rejected, and no address/default is fabricated.
|
||
|
||
- [ ] Run red:
|
||
|
||
```bash
|
||
node tools/test-sync-web-artifacts.mjs
|
||
```
|
||
|
||
Expected red: sync module is missing.
|
||
|
||
- [ ] Implement `sync-web-artifacts.mjs` using only Node standard modules. Export pure `extractAbi`, `renderContractsModule`, and `syncArtifacts` functions for the test. Resolve every path from the repository root derived from `import.meta.url`, never the caller’s working directory. Write only `web/src/generated/contracts.ts`. Implement the separate publisher to validate/copy only `deployments/active.json` to `web/public/deployment.json`.
|
||
|
||
- [ ] Write `bankClient.test.ts` against a `BankReader` test double. Cover:
|
||
|
||
- all reads pinned to one latest block number;
|
||
- `reader.getChainId()` compared with the manifest before any contract read;
|
||
- nonempty bytecode at proxy, token, and implementation before state reads;
|
||
- version, pause, owner, asset, liabilities, reserves, actor balances;
|
||
- EIP-1967 implementation slot decoding;
|
||
- implementation-slot/manifest mismatch;
|
||
- asset/proxy/owner mismatch;
|
||
- surplus as `reserves - liabilities`;
|
||
- insolvency rejected instead of producing a negative surplus;
|
||
- V1 event decoding from the proxy address only and deterministic `(blockNumber, logIndex)` ordering;
|
||
- one malformed log becoming a `DecodeDiagnostic` while other logs render;
|
||
- read rejection preserving the error rather than substituting `0n`.
|
||
|
||
Use the canonical implementation slot:
|
||
|
||
```ts
|
||
export const EIP1967_IMPLEMENTATION_SLOT =
|
||
'0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc'
|
||
```
|
||
|
||
- [ ] Run red:
|
||
|
||
```bash
|
||
npm --prefix web test -- src/data/bankClient.test.ts
|
||
```
|
||
|
||
Expected red: `BankReader`/snapshot loader is missing.
|
||
|
||
- [ ] Implement a narrow `ViemBankReader` adapter and pure `loadDashboardSnapshot(reader, manifest)`. Call the endpoint’s `eth_chainId` and check code at proxy/token/implementation first. Read the latest block next and pass that block to every direct `readContract`, token-balance call, storage-slot read, and log range endpoint. The token reserve must come from `MockUSDC.balanceOf(proxy)`, not native ETH balance. Do not use `useReadContracts`/Multicall3 because a fresh Anvil chain does not guarantee a Multicall deployment.
|
||
|
||
- [ ] Configure exactly two public clients in `chains.ts`: a custom Anvil chain with ID `31337`/`http://127.0.0.1:8545`, and viem’s Base Sepolia definition with ID `84532`. Select RPC from the validated manifest or `VITE_RPC_URL`; reject absence rather than inventing a remote provider.
|
||
|
||
- [ ] Add bridge targets:
|
||
|
||
```make
|
||
.PHONY: sync-abis publish-web-manifest sync-artifacts sync-artifacts-check
|
||
sync-abis:
|
||
@node tools/sync-web-artifacts.mjs
|
||
publish-web-manifest:
|
||
@node tools/publish-web-manifest.mjs
|
||
sync-artifacts: sync-abis publish-web-manifest
|
||
sync-artifacts-check:
|
||
@node tools/test-sync-web-artifacts.mjs
|
||
@node tools/sync-web-artifacts.mjs
|
||
@node tools/sync-web-artifacts.mjs --check
|
||
```
|
||
|
||
- [ ] Run all green checks with a live V1 manifest from Task 6:
|
||
|
||
```bash
|
||
forge build --force
|
||
node tools/test-sync-web-artifacts.mjs
|
||
make sync-abis
|
||
make publish-web-manifest
|
||
npm --prefix web test -- src/config/manifest.test.ts src/data/bankClient.test.ts
|
||
npm --prefix web run typecheck
|
||
```
|
||
|
||
Expected: generated ABI/manifest exist locally, all tests pass, and `git status --short` does not list generated files.
|
||
|
||
- [ ] Commit:
|
||
|
||
```bash
|
||
git add tools/sync-web-artifacts.mjs tools/test-sync-web-artifacts.mjs tools/publish-web-manifest.mjs web/src/generated/.gitkeep web/public/.gitkeep web/src/types web/src/config web/src/data Makefile
|
||
git commit -m "feat: bridge chain artifacts to typed web reads"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 8: Render the read-only V1 operations console
|
||
|
||
**Files:**
|
||
- Create: `web/src/main.tsx`
|
||
- Create: `web/src/App.tsx`
|
||
- Create: `web/src/App.test.tsx`
|
||
- Create: `web/src/app.css`
|
||
- Create: `web/src/hooks/useBankDashboard.ts`
|
||
- Create: `web/src/hooks/useBankDashboard.test.tsx`
|
||
- Create: `web/src/components/StatusHeader.tsx`
|
||
- Create: `web/src/components/AccountingGrid.tsx`
|
||
- Create: `web/src/components/ContractIdentity.tsx`
|
||
- Create: `web/src/components/AccountTable.tsx`
|
||
- Create: `web/src/components/ActivityTimeline.tsx`
|
||
- Create: `web/src/components/WarningBanner.tsx`
|
||
- Create: `web/src/components/TrustDisclosure.tsx`
|
||
- Create: `web/src/components/format.ts`
|
||
- Create: `web/src/components/format.test.ts`
|
||
- Modify: `web/src/test/setup.ts`
|
||
|
||
**Interfaces:**
|
||
- Consumes the manifest parser, `DashboardSnapshot`, and block-triggered snapshot loader.
|
||
- Produces loading, ready, stale, disconnected, invalid-manifest, and chain-mismatch screens.
|
||
- Produces no transaction, signing, connector, form, or wallet interface.
|
||
|
||
- [ ] Write `format.test.ts` first. Cover six-decimal amounts, shortened addresses, surplus, exact `100.00%` reserve ratio when equal, overcollateralized ratio, and zero-liability text `No deposits`. Failed/unknown values format as an em dash, never `0`.
|
||
|
||
- [ ] Write `useBankDashboard.test.tsx` with an injected loader and controllable clock. Cover:
|
||
|
||
- initial `loading`;
|
||
- successful `ready` with last synchronized block/time;
|
||
- later failure retaining the last snapshot as `stale`;
|
||
- initial RPC failure as `disconnected` with no fabricated snapshot;
|
||
- invalid manifest and manifest/network mismatch as terminal explanatory states;
|
||
- a new watched block triggering exactly one reconciled refetch.
|
||
|
||
- [ ] Write `App.test.tsx` using deterministic V1 snapshots. Assert:
|
||
|
||
- persistent educational/mock/never-real-funds warning;
|
||
- visible unaudited status, the owner’s power to install arbitrary future logic, UUPS state/bricking risk, and a statement that a real product needs professional audits, operational key controls, multisig or timelocked governance, incident procedures, legal advice, and jurisdiction-specific compliance;
|
||
- network, block, ready/stale/disconnected, paused, and version status;
|
||
- reserves, liabilities, surplus, reserve ratio, proxy, implementation, token, owner, and deployment block;
|
||
- Alice/Bob labels locally and shortened addresses publicly;
|
||
- deposit/withdraw/pause/ownership/upgrade events;
|
||
- a decode diagnostic warning alongside still-visible valid activity;
|
||
- Base Sepolia explorer links and no local explorer links;
|
||
- `No deposits` for zero liabilities;
|
||
- no `button`, `form`, wallet-connect text, deposit/withdraw action, or transaction control anywhere in the rendered document.
|
||
|
||
- [ ] Run the three red suites:
|
||
|
||
```bash
|
||
npm --prefix web test -- src/components/format.test.ts src/hooks/useBankDashboard.test.tsx src/App.test.tsx
|
||
```
|
||
|
||
Expected red: formatters, hook, and UI components are missing.
|
||
|
||
- [ ] Implement the wagmi provider with exactly two chains, public HTTP transports, and `connectors: []`. Mount `QueryClientProvider` and `WagmiProvider`; do not import wallet packages or expose a `WalletClient`. The configured wagmi chain is UI/query configuration only; the data loader still verifies the RPC endpoint’s returned chain ID.
|
||
|
||
- [ ] Implement `useBankDashboard` as the sole async coordinator. Use a watched block number to invalidate the snapshot query, fetch direct reads and proxy-only event logs at one block, retain the last successful snapshot on later failures, and mark it stale with the failing timestamp/error. Stop polling when the manifest is invalid or the RPC-reported chain differs from it.
|
||
|
||
- [ ] Implement the six console regions plus an always-visible/semantic `TrustDisclosure`. Use semantic text/table/list markup, visible focus styles, responsive single-column fallbacks, and CSS custom properties. Tooltips must be keyboard-readable `<abbr title>` or always-visible helper text rather than action buttons.
|
||
|
||
- [ ] Keep the visual hierarchy presentation-oriented:
|
||
|
||
- dark neutral canvas and high-contrast green/amber/red status indicators;
|
||
- proxy address visually primary, implementation visually secondary;
|
||
- large reserves/liabilities figures with surplus/ratio below;
|
||
- activity newest first while retaining block and transaction context;
|
||
- no consumer-banking imagery or language suggesting regulated status.
|
||
|
||
- [ ] Run green verification:
|
||
|
||
```bash
|
||
npm --prefix web run lint
|
||
npm --prefix web run typecheck
|
||
npm --prefix web test
|
||
npm --prefix web run build
|
||
```
|
||
|
||
Expected: all checks pass and Vite produces `web/dist`.
|
||
|
||
- [ ] With Task 6’s Anvil running, run `make sync-artifacts` and `npm --prefix web run dev`; visually verify the exact Act 1 values and resize to desktop/tablet widths. Record any visual defect as a failing component test before correcting it.
|
||
|
||
- [ ] Commit:
|
||
|
||
```bash
|
||
git add web/src
|
||
git commit -m "feat: add read-only bank operations console"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 9: Orchestrate, document, verify, and checkpoint the prepared V1 demo
|
||
|
||
**Files:**
|
||
- Modify: `tools/doctor.sh`
|
||
- Create: `tools/process-lib.sh`
|
||
- Create: `tools/demo-local.sh`
|
||
- Create: `tools/reset-local.sh`
|
||
- Create: `tools/test-process-safety.sh`
|
||
- Create: `tools/scan-project.sh`
|
||
- Modify: `Makefile`
|
||
- Create: `README.md`
|
||
- Create: `docs/LEARNING_GUIDE.md`
|
||
- Create: `docs/PRESENTER_RUNBOOK.md`
|
||
|
||
**Interfaces:**
|
||
- Produces the supported user commands and the `demo-start` Git tag.
|
||
- Consumes the V1 scripts, artifact sync, Vite app, and only project-owned `.demo/` PIDs.
|
||
- Leaves the repository at the exact checkpoint from which the live Codex V2 change begins.
|
||
|
||
- [ ] Write `test-process-safety.sh` first using temporary directories and harmless child processes. Assert:
|
||
|
||
- an absent PID file is a no-op;
|
||
- a stale numeric PID file is removed;
|
||
- a nonnumeric PID is rejected;
|
||
- a live PID whose command does not match the recorded `anvil`/`vite` signature is never signaled;
|
||
- PID reuse is rejected by comparing the recorded `/proc/<pid>/stat` start tick;
|
||
- a matching project-started child is terminated and reaped;
|
||
- an owned process group is stopped without leaving a Vite child, while an unrelated group survives;
|
||
- reset deletes only known `.demo/{anvil,vite}.{pid,start,pgid,log}` files, Anvil staging/canonical files, an active/copied web manifest only when its parsed chain is `31337`, and the generated ABI module;
|
||
- a Base canonical or active manifest survives `reset-local` byte-for-byte;
|
||
- a sentinel adjacent to `.demo/` survives.
|
||
|
||
- [ ] Run red:
|
||
|
||
```bash
|
||
bash tools/test-process-safety.sh
|
||
```
|
||
|
||
Expected red: process library/reset script is missing.
|
||
|
||
- [ ] Implement `process-lib.sh` with repository-root resolution, numeric PID/PGID validation, recorded process start tick, `/proc/<pid>/cmdline` or `ps` signature checks, and TERM-then-bounded-KILL only for a matching recorded process group. Never use `pkill`, `killall`, a wildcard PID, or recursive workspace deletion.
|
||
|
||
- [ ] Implement `reset-local.sh` by stopping validated PID files and removing each known runtime file by explicit path. Parse before deleting: remove `deployments/anvil.json`; remove staging/active/browser manifests only when they validate as chain `31337`; preserve every chain `84532` file. Use `rmdir .demo` only if empty. Report each removed process/file and that local generated state is reproducible.
|
||
|
||
- [ ] Expand `doctor.sh` to check exact Foundry/Node/npm versions, recursive Git submodules, root/web `node_modules`, the pinned offline upgrades CLI, Bash, Make, `curl`, ports `8545`/`5173`, writable runtime directories, and optional Base variables. It must be read-only and print direct official installation links for missing prerequisites.
|
||
|
||
- [ ] Implement `demo-local.sh`:
|
||
|
||
1. call the scoped reset helper, which can stop only previously recorded project-owned processes;
|
||
2. run doctor and refuse any still-occupied (therefore external) required port;
|
||
3. create `.demo/` with mode `0700`;
|
||
4. launch pinned Anvil in a new process group on `127.0.0.1:8545`, chain ID `31337`, standard development mnemonic, and record/validate its PID, PGID, and start tick;
|
||
5. wait with a bounded readiness loop using `cast chain-id`;
|
||
6. run deploy/finalization, seed, `DEMO_EXPECTED_STAGE=v1` state check, build, ABI sync, and confirmed-manifest publishing;
|
||
7. launch `web/node_modules/.bin/vite web --host 127.0.0.1 --port 5173` directly in its own process group (not through a parent npm shell), record/validate identity, wait for bounded HTTP readiness, then print the console URL and next commands;
|
||
8. install `INT`/`TERM`/`EXIT` traps and remain attached while both children are alive;
|
||
9. stop only its recorded children on exit.
|
||
|
||
- [ ] Replace the Makefile with the complete V1 command contract:
|
||
|
||
```make
|
||
.PHONY: doctor setup demo-local verify check-state reset-local
|
||
doctor:
|
||
@bash tools/doctor.sh
|
||
setup:
|
||
@git submodule update --init --recursive
|
||
@npm ci
|
||
@npm --prefix web ci
|
||
demo-local:
|
||
@bash tools/demo-local.sh
|
||
verify:
|
||
@forge fmt --check
|
||
@forge clean
|
||
@npm_config_offline=true forge build --force
|
||
@node tools/sync-web-artifacts.mjs
|
||
@node tools/sync-web-artifacts.mjs --check
|
||
@node tools/check-upgrades-cli.mjs
|
||
@npm_config_offline=true forge test --force
|
||
@node tools/test-finalize-manifest.mjs
|
||
@node tools/test-sync-web-artifacts.mjs
|
||
@bash tools/test-process-safety.sh
|
||
@bash tools/scan-project.sh
|
||
@npm --prefix web run lint
|
||
@npm --prefix web run typecheck
|
||
@npm --prefix web test
|
||
@npm --prefix web run build
|
||
check-state:
|
||
@forge script script/CheckState.s.sol:CheckState --rpc-url http://127.0.0.1:8545 --force
|
||
reset-local:
|
||
@bash tools/reset-local.sh
|
||
```
|
||
|
||
Retain the explicit `deploy-v1`, `seed-v1`, and `sync-artifacts` lower-level targets for teaching and recovery.
|
||
|
||
- [ ] Write `README.md` with prerequisites, `make setup`, the ten-minute `make demo-local` quick start, second-terminal commands, architecture paragraph, exact URLs, command reference, and links to both guides. Its trust box must say the token has no value; code is educational/unaudited; real deposits must never be sent; the owner can pause and install arbitrary future logic; UUPS mistakes can corrupt state or brick upgrades; and a real custody product needs professional audits, operational key controls, multisig/timelocked governance, incident procedures, legal advice, and jurisdiction-specific compliance.
|
||
|
||
- [ ] Write the V1-complete `LEARNING_GUIDE.md`: proxy/implementation/delegatecall/storage, constructor vs initializer, disabled implementation initialization, safe/unsafe storage examples, reserves/liabilities/surplus, exact deposit/withdraw flow, SafeERC20, pause, reentrancy, checks-effects-interactions, single-owner arbitrary-upgrade power, UUPS corruption/bricking risk, the complete real-product safeguards disclosure, and local exercises that trigger each named error.
|
||
|
||
- [ ] Write the prepared portion of `PRESENTER_RUNBOOK.md`: preflight, rehearsal timing, exact three-act story, the approved live Codex prompt verbatim, Act 1 expected state, commands/console callouts, occupied-port/stale-console/test-failure recovery, and the rule that no upgrade runs until `make verify` passes. Describe Act 2/3 expected outcomes without including reference V2 source, and include a closing trust disclosure checklist matching README/UI word-for-word in substance.
|
||
|
||
- [ ] Implement `scan-project.sh` with a NUL-safe array from `git ls-files`, excluding dependency gitlinks, `docs/superpowers`, and generated lockfiles. Return nonzero if project-owned tracked content contains an actual `PRIVATE_KEY`/`MNEMONIC` assignment, PEM private key, `TODO`, `TBD`, `FIXME`, filler text, or an unsafe upgrade bypass in `src`/`test`/`script`. Construct the scanner’s own pattern from split shell literals so it does not match itself. Permit only the exact hyphenated constructor annotation. The committed `ANVIL_TEST_PHRASE` is the universally known test fixture, not a supported secret input; test that exact fixture is the only scan exception.
|
||
|
||
- [ ] Run process and full verification:
|
||
|
||
```bash
|
||
bash tools/test-process-safety.sh
|
||
make doctor
|
||
make verify
|
||
```
|
||
|
||
Expected: all checks exit `0`.
|
||
|
||
- [ ] Run a fresh presentation smoke test:
|
||
|
||
```bash
|
||
make reset-local
|
||
make demo-local
|
||
```
|
||
|
||
In a second terminal:
|
||
|
||
```bash
|
||
DEMO_EXPECTED_STAGE=v1 make check-state
|
||
curl --fail http://127.0.0.1:5173/
|
||
```
|
||
|
||
Expected: exact Act 1 state, HTTP `200`, and the operations console shows V1. Stop the attached demo with Ctrl-C, then prove `make reset-local` leaves no owned process or runtime manifest.
|
||
|
||
- [ ] Scan for forbidden material and placeholders:
|
||
|
||
```bash
|
||
bash tools/scan-project.sh
|
||
git diff --check
|
||
git status --short
|
||
```
|
||
|
||
Expected: no secret assignment/value, unfinished placeholder, or whitespace error appears. Separately inspect chain constants/targets and confirm only `31337`/`84532` are accepted; tests may intentionally mention rejected mainnet IDs.
|
||
|
||
- [ ] Commit and tag the live starting point:
|
||
|
||
```bash
|
||
git add .gitignore Makefile tools README.md docs/LEARNING_GUIDE.md docs/PRESENTER_RUNBOOK.md
|
||
git commit -m "docs: prepare repeatable V1 presentation"
|
||
git tag -a demo-start -m "Verified V1 starting point for live UUPS demo"
|
||
```
|
||
|
||
Do not create the tag unless `make verify` and the fresh local smoke test have just passed.
|
||
|
||
---
|
||
|
||
### Task 10: Add BankV2 and prove upgrade safety before scripting it
|
||
|
||
**Files:**
|
||
- Create: `src/BankV2.sol`
|
||
- Create: `test/BankV2.t.sol`
|
||
- Create: `test/BankUpgrade.t.sol`
|
||
- Create: `test/mocks/IncompatibleBank.sol`
|
||
- Create: `test/mocks/NonUUPSImplementation.sol`
|
||
|
||
**Interfaces:**
|
||
- Consumes the exact V1 source/storage layout and validated V1 proxy fixture.
|
||
- Produces `transferBalance(address,uint256)`, `BalanceTransferred`, `InvalidRecipient`, `SelfTransfer`, and `contractVersion() == 2`.
|
||
- Produces no initializer and no storage variable.
|
||
|
||
- [ ] Start `BankV2.t.sol` by upgrading a populated V1 proxy in test setup with the owner-aware overload:
|
||
|
||
```solidity
|
||
Options memory opts;
|
||
opts.referenceContract = "BankV1.sol:BankV1";
|
||
Upgrades.upgradeProxy(proxy, "BankV2.sol:BankV2", "", opts, owner);
|
||
bankV2 = BankV2(proxy);
|
||
```
|
||
|
||
Use `--force` on every test command because validation consumes fresh build info.
|
||
|
||
- [ ] Write failing transfer unit/fuzz tests for:
|
||
|
||
- 250 mUSDC Alice-to-Bob balance deltas and exact event;
|
||
- unchanged liabilities and token reserves;
|
||
- zero amount;
|
||
- zero recipient;
|
||
- sender as recipient;
|
||
- insufficient sender balance with available/requested values;
|
||
- paused state;
|
||
- recipients that previously had no balance;
|
||
- fuzzed tracked recipients and amounts bounded to the sender balance;
|
||
- deposit, withdrawal, views, pause, and unpause still behaving through the V2 proxy.
|
||
|
||
- [ ] Run red:
|
||
|
||
```bash
|
||
npm_config_offline=true forge test --match-path test/BankV2.t.sol -vvv --force
|
||
```
|
||
|
||
Expected red: `BankV2.sol` is missing.
|
||
|
||
- [ ] Implement only this storage-free extension:
|
||
|
||
```solidity
|
||
/// @custom:oz-upgrades-from src/BankV1.sol:BankV1
|
||
contract BankV2 is BankV1 {
|
||
event BalanceTransferred(address indexed from, address indexed to, uint256 amount);
|
||
|
||
error InvalidRecipient(address recipient);
|
||
error SelfTransfer();
|
||
|
||
function transferBalance(address recipient, uint256 amount) external whenNotPaused {
|
||
if (amount == 0) revert ZeroAmount();
|
||
if (recipient == address(0)) revert InvalidRecipient(recipient);
|
||
if (recipient == msg.sender) revert SelfTransfer();
|
||
uint256 available = _balances[msg.sender];
|
||
if (amount > available) {
|
||
revert InsufficientBalance(msg.sender, available, amount);
|
||
}
|
||
_balances[msg.sender] = available - amount;
|
||
_balances[recipient] += amount;
|
||
emit BalanceTransferred(msg.sender, recipient, amount);
|
||
}
|
||
|
||
function contractVersion() public pure override returns (uint256) { return 2; }
|
||
}
|
||
```
|
||
|
||
Do not add `nonReentrant`: this function performs no external call. Do not consume the gap or redeclare inherited fields.
|
||
|
||
- [ ] Write `BankUpgrade.t.sol` to populate Alice/Bob balances, create a reserve surplus, pause the bank, and snapshot proxy, implementation, owner, asset, pause state, both balances, liabilities, reserves, and surplus. Run a validated owner upgrade and assert:
|
||
|
||
- proxy address unchanged;
|
||
- implementation address changed and has code;
|
||
- version changed exactly `1 -> 2`;
|
||
- every other snapshot field is byte-for-byte equal, including paused state;
|
||
- unpause plus every V1 mutation still works after upgrade;
|
||
- implementation initialization still rejects.
|
||
|
||
- [ ] Add three distinct failure cases:
|
||
|
||
1. non-owner `upgradeToAndCall` rejects with `OwnableUnauthorizedAccount`;
|
||
2. `Upgrades.validateUpgrade` rejects `IncompatibleBank`, which deliberately reorders/changes a V1 application storage field while remaining UUPS-capable;
|
||
3. a direct owner `upgradeToAndCall` to `NonUUPSImplementation` rejects at runtime through ERC-1822/UUPS compatibility.
|
||
|
||
Do not weaken these tests with `UnsafeUpgrades`, `unsafeSkipStorageCheck`, `unsafeSkipAllChecks`, `unsafeAllow`, or annotation bypasses.
|
||
|
||
- [ ] Run validated green suites and layout inspection:
|
||
|
||
```bash
|
||
forge fmt
|
||
npm_config_offline=true forge test --match-path test/BankV2.t.sol -vvv --force
|
||
npm_config_offline=true forge test --match-path test/BankUpgrade.t.sol -vvv --force
|
||
forge inspect BankV1 storage-layout
|
||
forge inspect BankV2 storage-layout
|
||
```
|
||
|
||
Expected: all tests pass; V2 shows the same inherited application fields and no new application slot.
|
||
|
||
- [ ] Run a source guard:
|
||
|
||
```bash
|
||
if rg -n '(unsafeSkip|UnsafeUpgrades|unsafeAllow)' src test script; then
|
||
echo "unsafe upgrade bypass found" >&2
|
||
exit 1
|
||
fi
|
||
```
|
||
|
||
Expected: no production/test use. The constructor’s narrowly scoped `oz-upgrades-unsafe-allow constructor` annotation is intentionally not matched by this expression.
|
||
|
||
- [ ] Commit:
|
||
|
||
```bash
|
||
git add src/BankV2.sol test/BankV2.t.sol test/BankUpgrade.t.sol test/mocks/IncompatibleBank.sol test/mocks/NonUUPSImplementation.sol
|
||
git commit -m "feat: add storage-safe bank V2"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 11: Extend invariants, scripts, artifacts, and console for the live V2 change
|
||
|
||
**Files:**
|
||
- Create: `test/helpers/BankV2Handler.sol`
|
||
- Modify: `test/BankInvariant.t.sol`
|
||
- Create: `script/UpgradeV2.s.sol`
|
||
- Create: `script/TransferV2Demo.s.sol`
|
||
- Modify: `script/CheckState.s.sol`
|
||
- Modify: `test/ScriptPreflight.t.sol`
|
||
- Modify: `tools/finalize-manifest.mjs`
|
||
- Modify: `tools/test-finalize-manifest.mjs`
|
||
- Modify: `tools/sync-web-artifacts.mjs`
|
||
- Modify: `tools/test-sync-web-artifacts.mjs`
|
||
- Modify: `web/src/types/dashboard.ts`
|
||
- Modify: `web/src/data/bankClient.ts`
|
||
- Modify: `web/src/data/bankClient.test.ts`
|
||
- Modify: `web/src/components/ActivityTimeline.tsx`
|
||
- Modify: `web/src/App.test.tsx`
|
||
- Modify: `Makefile`
|
||
|
||
**Interfaces:**
|
||
- Consumes V1 manifest/proxy and owner sender.
|
||
- Produces a confirmed implementation update, exact Act 3 transfer, V2 activity decoding, and version-aware UI.
|
||
- Updates only the manifest’s `implementation`; preserves schema, deployment block, proxy, token, owner, actors, and network.
|
||
|
||
- [ ] Extend stateful testing first. `BankV2Handler` adds `transfer(uint256 fromSeed,uint256 toSeed,uint256 amount)`, restricts both endpoints to the tracked actor set, chooses a different recipient, returns early only when sender balance is zero, and bounds amount to `[1, senderBalance]`. Track `ghostTransferred` for action coverage but do not alter deposit/withdraw liability ghosts.
|
||
|
||
- [ ] Add V2 invariants/assertions:
|
||
|
||
- sum of every tracked actor balance equals liabilities;
|
||
- reserves cover liabilities;
|
||
- liabilities equal deposits minus withdrawals despite any number of transfers;
|
||
- reserves equal deposits plus donations minus withdrawals;
|
||
- at least one transfer executes in a deterministic handler unit test before relying on random invariant coverage.
|
||
|
||
- [ ] Run red then green after the minimum handler implementation:
|
||
|
||
```bash
|
||
npm_config_offline=true forge test --match-path test/BankInvariant.t.sol -vvv --force
|
||
```
|
||
|
||
- [ ] Write preflight tests for the upgrade script before the script:
|
||
|
||
- manifest/proxy/code/chain/owner/version-1 requirements;
|
||
- CLI sender must equal on-chain owner;
|
||
- already-V2 path writes a verifiable `mode: "noop"` staging record, reports a no-op, and does not broadcast;
|
||
- snapshot comparison rejects any changed field other than implementation/version;
|
||
- manifest update rejects proxy/deployment-block/actor mutation.
|
||
|
||
- [ ] Implement `UpgradeV2.s.sol` in this order:
|
||
|
||
1. validate chain before any broadcast;
|
||
2. load the active manifest, validate actual code and chain, and require public `SCRIPT_SENDER` equals proxy owner (the Make target supplies the identical value to Forge `--sender`);
|
||
3. if version is already `2`, write/overwrite a `mode: "noop"` staging record containing chain, observed block, owner nonce, proxy, and current implementation, then return without `vm.startBroadcast`; reject any other unexpected version;
|
||
4. snapshot proxy, implementation, owner, asset, paused, configured balances, liabilities, reserves, surplus, and deployment block;
|
||
5. set `Options.referenceContract = "BankV1.sol:BankV1"` and call `Upgrades.validateUpgrade("BankV2.sol:BankV2", opts)` before broadcast;
|
||
6. broadcast `Upgrades.upgradeProxy(proxy, "BankV2.sol:BankV2", "", opts)` from the verified owner;
|
||
7. stop broadcast and re-read every postcondition through the proxy;
|
||
8. for the normal path, write/overwrite `deployments/upgrade-pending.json` with `mode: "upgrade"`, the proposed implementation, confirmed-manifest identity, and the complete pre-upgrade snapshot; never write `active.json` directly.
|
||
|
||
Extend the finalizer tests and implementation with upgrade/no-op modes. For `mode: "upgrade"`, match the successful transaction to the proxy and its `Upgraded(newImplementation)` log, require live `contractVersion() == 2`, require the live ERC-1967 slot to equal the pending implementation, and use artifact method identifiers plus direct JSON-RPC calls to compare owner, asset, pause state, every configured actor balance, liabilities, token reserves, surplus, proxy, and deployment block with the stored pre-upgrade snapshot. Atomically update only `implementation` in the selected chain’s canonical manifest and `active.json`. For `mode: "noop"`, deliberately ignore historical broadcast files, require the live block not precede the observation, require the owner nonce is unchanged, and require live version `2` plus chain/proxy/implementation equality with marker/active manifest; leave both confirmed files byte-for-byte unchanged. Include a stale prior `run-latest.json` fixture proving it is ignored. Delete the staging record only after either path succeeds. Any failed receipt/postcheck or unknown mode changes no confirmed file.
|
||
|
||
- [ ] Implement `TransferV2Demo.s.sol`. On local it derives only Alice account index `1`, requires version `2`, asserts the pre-state Alice `900e6`/Bob `500e6`/liabilities-reserves `1_400e6`, snapshots liabilities/reserves, broadcasts `transferBalance(bob, 250e6)`, and asserts Alice `650e6`, Bob `750e6`, unchanged liabilities/reserves, and no surplus.
|
||
|
||
- [ ] Extend `CheckState` with `DEMO_EXPECTED_STAGE=upgraded` for version `2` plus the pre-transfer Act 1 balances, and `v2` for the exact Act 3 table, while retaining `deployed`, `v1`, and `invariants` modes. The orchestration uses `invariants` after an idempotent upgrade because a valid already-V2 bank may have progressed past Act 3.
|
||
|
||
- [ ] Extend the ABI bridge only now: require the real `BankV2` artifact, emit `bankV2Abi`, validate `transferBalance` and `BalanceTransferred`, and make freshness checks fail if the generated module remains V1-only.
|
||
|
||
- [ ] Extend the dashboard types from `version: 1` to `version: 1 | 2` and add the `transfer` activity variant. Decode `BalanceTransferred` from proxy logs, render “Alice transferred 250.000000 mUSDC to Bob,” and add V2 snapshot/event tests. Keep all state reads through the proxy and keep the UI read-only.
|
||
|
||
- [ ] Add exact local targets with owner/sender flags:
|
||
|
||
```make
|
||
ANVIL_OWNER := 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266
|
||
ANVIL_ALICE := 0x70997970C51812dc3A010C7d01b50e0d17dc79C8
|
||
|
||
.PHONY: upgrade-v2 demo-transfer
|
||
upgrade-v2:
|
||
@SCRIPT_SENDER=$(ANVIL_OWNER) DEPLOYMENT_MANIFEST_PATH=deployments/upgrade-pending.json npm_config_offline=true forge script script/UpgradeV2.s.sol:UpgradeV2 --rpc-url $(RPC_LOCAL) --sender $(ANVIL_OWNER) --broadcast --force
|
||
@node tools/finalize-manifest.mjs upgrade --rpc-url $(RPC_LOCAL)
|
||
@DEMO_EXPECTED_STAGE=invariants forge script script/CheckState.s.sol:CheckState --rpc-url $(RPC_LOCAL) --force
|
||
@node tools/sync-web-artifacts.mjs
|
||
@node tools/publish-web-manifest.mjs
|
||
demo-transfer:
|
||
@forge script script/TransferV2Demo.s.sol:TransferV2Demo --rpc-url $(RPC_LOCAL) --sender $(ANVIL_ALICE) --broadcast --force
|
||
@DEMO_EXPECTED_STAGE=v2 $(MAKE) check-state
|
||
```
|
||
|
||
After finalization, republish the web manifest because implementation identity changed. Keep shared finalization logic in one file; do not create an untested second upgrade finalizer.
|
||
|
||
- [ ] Run the full V2 verification gate before broadcasting:
|
||
|
||
```bash
|
||
make verify
|
||
git diff --check
|
||
```
|
||
|
||
Expected: all Solidity validation/unit/fuzz/invariant/upgrade tests, artifact tests, process tests, and web checks pass.
|
||
|
||
- [ ] Run the real Act 2/3 local smoke from the tagged V1 state:
|
||
|
||
```bash
|
||
make upgrade-v2
|
||
make demo-transfer
|
||
```
|
||
|
||
Expected: proxy unchanged; implementation changed; version `2`; Alice `650e6`; Bob `750e6`; liabilities and reserves `1_400e6`; one decoded transfer; no token transfer during the internal move.
|
||
|
||
- [ ] Commit:
|
||
|
||
```bash
|
||
git add src test script tools web/src Makefile
|
||
git commit -m "feat: demonstrate state-preserving V2 upgrade"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 12: Add the explicitly optional Base Sepolia encore
|
||
|
||
**Files:**
|
||
- Modify: `.env.example`
|
||
- Modify: `script/lib/DemoScript.sol`
|
||
- Modify: `script/DeployV1.s.sol`
|
||
- Create: `script/SeedBaseSepolia.s.sol`
|
||
- Modify: `script/UpgradeV2.s.sol`
|
||
- Modify: `script/TransferV2Demo.s.sol`
|
||
- Modify: `script/CheckState.s.sol`
|
||
- Modify: `test/ScriptPreflight.t.sol`
|
||
- Modify: `tools/finalize-manifest.mjs`
|
||
- Modify: `tools/select-manifest.mjs`
|
||
- Modify: `tools/publish-web-manifest.mjs`
|
||
- Create: `tools/require-base-config.sh`
|
||
- Create: `tools/test-base-config.sh`
|
||
- Modify: `Makefile`
|
||
- Modify: `README.md`
|
||
- Modify: `docs/PRESENTER_RUNBOOK.md`
|
||
|
||
**Interfaces:**
|
||
- Consumes `BASE_SEPOLIA_RPC_URL`, `BASE_SEPOLIA_PUBLIC_RPC_URL`, `BASE_SEPOLIA_ACCOUNT`, `BASE_SEPOLIA_SENDER`, and `BASE_SEPOLIA_RECIPIENT`.
|
||
- Produces explicit deploy/upgrade/transfer commands for chain `84532` using a named encrypted Foundry keystore.
|
||
- Never makes a keystore, faucet, Base RPC call, or actual encore a prerequisite of `make verify` or local completion; only offline configuration tests join the gate.
|
||
|
||
- [ ] Document public configuration only:
|
||
|
||
```dotenv
|
||
# Terminal RPC may be credentialed; never copied into browser artifacts.
|
||
BASE_SEPOLIA_RPC_URL=
|
||
# Browser RPC is intentionally public and visible to browser users.
|
||
BASE_SEPOLIA_PUBLIC_RPC_URL=https://sepolia.base.org
|
||
BASE_SEPOLIA_ACCOUNT=
|
||
BASE_SEPOLIA_SENDER=
|
||
BASE_SEPOLIA_RECIPIENT=
|
||
```
|
||
|
||
State that keystore passwords are entered through Foundry’s interactive prompt and never stored in `.env`.
|
||
|
||
- [ ] Add exact learner onboarding to the runbook before any Base command:
|
||
|
||
```bash
|
||
cast wallet import uups-bank-base --interactive
|
||
cast wallet address --account uups-bank-base
|
||
```
|
||
|
||
The presenter copies the displayed public address to `BASE_SEPOLIA_SENDER`, sets `BASE_SEPOLIA_ACCOUNT=uups-bank-base`, verifies both refer to the same account, funds only that Base Sepolia address with test ETH, and never pastes the key/password into Codex, shell history, or `.env`.
|
||
|
||
- [ ] Extend preflight tests for chain `84532`, sender/owner equality, nonzero/different recipient, public browser RPC credential rejection, and all unsupported/mainnet chain IDs. Assert the guard runs before `vm.startBroadcast` in every testnet script path.
|
||
|
||
- [ ] Before Base deployment/finalization, extend `DeployV1.s.sol` to require `BASE_SEPOLIA_SENDER`, `BASE_SEPOLIA_RECIPIENT`, and the intentionally public browser RPC, validate the actors as nonzero/different, and place both in the pending actor array as `Presenter` and `Recipient`. Serialize only `BASE_SEPOLIA_PUBLIC_RPC_URL`, never the terminal RPC. The active manifest is immutable deployment identity; seeding must never add actors after finalization.
|
||
|
||
- [ ] Implement `SeedBaseSepolia.s.sol`: consume and verify the already-finalized presenter/recipient actors and sender, mint `1_000e6` valueless mUSDC to the presenter, approve/deposit `1_000e6`, and assert reserves/liabilities/presenter balance `1_000e6` plus recipient balance `0`. No Anvil test phrase or derived key path is reachable on Base Sepolia.
|
||
|
||
- [ ] Extend `TransferV2Demo` testnet mode to transfer `250e6` from the verified presenter to the configured nonzero/different recipient, asserting unchanged reserves/liabilities and post-balances `750e6`/`250e6`. Keep the local exact path unchanged.
|
||
|
||
- [ ] Extend manifest finalization to locate the matching chain-specific broadcast receipt, require receipt success, derive the real deployment/upgrade block, re-check code and EIP-1967 implementation, preserve the original deployment block on upgrade, and publish only through an atomic same-directory rename.
|
||
|
||
- [ ] Extend `select-manifest.mjs` with a tested `archive baseSepolia` command that moves only `deployments/base-sepolia.json` to a validated timestamped sibling filename and never alters `active.json` or Anvil state. A subsequent deploy finalizer may create a new Base canonical file while the old Base active copy remains a fallback; only successful explicit selection replaces it. This gives a recoverable path before intentionally redeploying Base. Selecting either canonical manifest must update `active.json` and then the browser copy; refreshing the browser switches networks.
|
||
|
||
- [ ] Add guarded Make helpers that fail when any required public variable is empty and then invoke Forge with both `--account` and `--sender`. Load an optional root `.env` through a narrowly documented Make include; it contains no signing secret or password:
|
||
|
||
```make
|
||
-include .env
|
||
export BASE_SEPOLIA_RPC_URL BASE_SEPOLIA_PUBLIC_RPC_URL BASE_SEPOLIA_ACCOUNT BASE_SEPOLIA_SENDER BASE_SEPOLIA_RECIPIENT
|
||
|
||
.PHONY: deploy-base-sepolia upgrade-base-sepolia transfer-base-sepolia select-anvil select-base-sepolia archive-base-manifest
|
||
select-anvil:
|
||
@node tools/select-manifest.mjs anvil
|
||
@node tools/publish-web-manifest.mjs
|
||
select-base-sepolia:
|
||
@node tools/select-manifest.mjs baseSepolia
|
||
@node tools/publish-web-manifest.mjs
|
||
archive-base-manifest:
|
||
@node tools/select-manifest.mjs archive baseSepolia
|
||
deploy-base-sepolia:
|
||
@./tools/require-base-config.sh deploy
|
||
@node tools/finalize-manifest.mjs preflight-deploy baseSepolia
|
||
@SCRIPT_SENDER="$(BASE_SEPOLIA_SENDER)" DEPLOYMENT_MANIFEST_PATH=deployments/pending.json npm_config_offline=true forge script script/DeployV1.s.sol:DeployV1 --rpc-url "$(BASE_SEPOLIA_RPC_URL)" --account "$(BASE_SEPOLIA_ACCOUNT)" --sender "$(BASE_SEPOLIA_SENDER)" --broadcast --slow --force
|
||
@DEPLOYMENT_MANIFEST_PATH=deployments/pending.json DEMO_EXPECTED_STAGE=deployed forge script script/CheckState.s.sol:CheckState --rpc-url "$(BASE_SEPOLIA_RPC_URL)" --force
|
||
@node tools/finalize-manifest.mjs deploy --rpc-url "$(BASE_SEPOLIA_RPC_URL)"
|
||
@SCRIPT_SENDER="$(BASE_SEPOLIA_SENDER)" DEPLOYMENT_MANIFEST_PATH=deployments/base-sepolia.json forge script script/SeedBaseSepolia.s.sol:SeedBaseSepolia --rpc-url "$(BASE_SEPOLIA_RPC_URL)" --account "$(BASE_SEPOLIA_ACCOUNT)" --sender "$(BASE_SEPOLIA_SENDER)" --broadcast --slow --force
|
||
@DEPLOYMENT_MANIFEST_PATH=deployments/base-sepolia.json DEMO_EXPECTED_STAGE=invariants forge script script/CheckState.s.sol:CheckState --rpc-url "$(BASE_SEPOLIA_RPC_URL)" --force
|
||
@node tools/select-manifest.mjs baseSepolia
|
||
@node tools/sync-web-artifacts.mjs
|
||
@node tools/publish-web-manifest.mjs
|
||
upgrade-base-sepolia:
|
||
@./tools/require-base-config.sh upgrade
|
||
@SCRIPT_SENDER="$(BASE_SEPOLIA_SENDER)" DEPLOYMENT_MANIFEST_PATH=deployments/upgrade-pending.json npm_config_offline=true forge script script/UpgradeV2.s.sol:UpgradeV2 --rpc-url "$(BASE_SEPOLIA_RPC_URL)" --account "$(BASE_SEPOLIA_ACCOUNT)" --sender "$(BASE_SEPOLIA_SENDER)" --broadcast --slow --force
|
||
@node tools/finalize-manifest.mjs upgrade --rpc-url "$(BASE_SEPOLIA_RPC_URL)"
|
||
@DEMO_EXPECTED_STAGE=invariants forge script script/CheckState.s.sol:CheckState --rpc-url "$(BASE_SEPOLIA_RPC_URL)" --force
|
||
@node tools/sync-web-artifacts.mjs
|
||
@node tools/publish-web-manifest.mjs
|
||
transfer-base-sepolia:
|
||
@./tools/require-base-config.sh transfer
|
||
@SCRIPT_SENDER="$(BASE_SEPOLIA_SENDER)" forge script script/TransferV2Demo.s.sol:TransferV2Demo --rpc-url "$(BASE_SEPOLIA_RPC_URL)" --account "$(BASE_SEPOLIA_ACCOUNT)" --sender "$(BASE_SEPOLIA_SENDER)" --broadcast --slow --force
|
||
@DEMO_EXPECTED_STAGE=invariants forge script script/CheckState.s.sol:CheckState --rpc-url "$(BASE_SEPOLIA_RPC_URL)" --force
|
||
```
|
||
|
||
The repeated `SCRIPT_SENDER`/`--sender` values are deliberate: Solidity preflight validates the public expected signer, while Forge binds the actual encrypted-keystore signer. The finalizer receives the terminal RPC as a process argument but never prints or serializes it.
|
||
|
||
- [ ] Add `tools/require-base-config.sh` and shell tests. Validate account name as a conservative identifier, sender/recipient as addresses, sender != recipient, both RPC schemes are HTTPS, browser URL has no user info or credential query, and terminal/public URL variables are never printed. Do not test a keystore password or accept `PRIVATE_KEY`/`MNEMONIC` fallbacks.
|
||
|
||
- [ ] Append `bash tools/test-base-config.sh` to `make verify`. The test supplies only fake fixture values and never requires a keystore, RPC, faucet, or network.
|
||
|
||
- [ ] Bind the console’s generic explorer-link support to the confirmed Base manifest and verify exact `https://sepolia.basescan.org/address/<address>`/transaction links. The browser uses only `BASE_SEPOLIA_PUBLIC_RPC_URL`; the terminal RPC never enters `web/public/deployment.json` or a Vite variable.
|
||
|
||
- [ ] Run offline/dry verification without requiring faucet funds or a public RPC:
|
||
|
||
```bash
|
||
make verify
|
||
bash tools/test-base-config.sh
|
||
make -n deploy-base-sepolia BASE_SEPOLIA_RPC_URL=https://terminal.invalid BASE_SEPOLIA_PUBLIC_RPC_URL=https://public.invalid BASE_SEPOLIA_ACCOUNT=demo BASE_SEPOLIA_SENDER=0x1111111111111111111111111111111111111111 BASE_SEPOLIA_RECIPIENT=0x2222222222222222222222222222222222222222
|
||
```
|
||
|
||
Expected: tests pass; dry output contains `--account`, `--sender`, `--slow`, and no raw key option. It may print the deliberately fake URLs supplied to `make -n`; production helpers must not echo configured URLs.
|
||
|
||
- [ ] If the user explicitly chooses to run the encore, use a funded Base Sepolia-only account and execute deploy, upgrade, transfer, and invariant-only state check. If faucet/RPC/explorer fails, record the optional failure and leave the completed local demo untouched.
|
||
|
||
- [ ] Commit:
|
||
|
||
```bash
|
||
git add .env.example script test/ScriptPreflight.t.sol tools Makefile README.md docs/PRESENTER_RUNBOOK.md
|
||
git commit -m "feat: add guarded Base Sepolia encore"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 13: Finish learning materials and create the verified reference checkpoint
|
||
|
||
**Files:**
|
||
- Modify: `README.md`
|
||
- Modify: `docs/LEARNING_GUIDE.md`
|
||
- Modify: `docs/PRESENTER_RUNBOOK.md`
|
||
|
||
**Interfaces:**
|
||
- Produces the final audience/user handoff and annotated `demo-complete` tag.
|
||
- Consumes actual verified commands/output from V1 and V2; documentation may not claim an unrun result.
|
||
|
||
- [ ] Complete the learning guide with the V2 transfer conservation proof, upgrade snapshot, storage-layout validator explanation, why V2 has no initializer/storage, central owner threat model, and exercises for every V2 error. Include safe vs unsafe storage diagrams using the actual field order.
|
||
|
||
- [ ] Complete the presenter runbook with:
|
||
|
||
- exact preflight and ten-minute rehearsal;
|
||
- two-terminal local sequence;
|
||
- verbatim live Codex prompt;
|
||
- what files should change in Act 2;
|
||
- `make verify` evidence to point out;
|
||
- Act 3 commands/values/event/identity callouts;
|
||
- recovery for a failed edit/test, occupied port, stale UI, partial manifest, and unavailable Base services;
|
||
- recovery from `demo-complete` in a new branch/worktree without resetting or overwriting unrelated work;
|
||
- closing distinction between work performed by Codex and guarantees supplied by OpenZeppelin/Foundry.
|
||
|
||
- [ ] Complete README commands and warnings. Verify every copied command against `make help`, ensure the quick start never requires Base Sepolia, a wallet browser extension, or a real secret, and cross-check the complete trust disclosures against the UI, learning guide, and presenter runbook.
|
||
|
||
- [ ] Run the full static gate from a clean generated state:
|
||
|
||
```bash
|
||
make reset-local
|
||
make setup
|
||
make verify
|
||
git diff --check
|
||
```
|
||
|
||
Expected: all gates pass without a running chain or deployment manifest.
|
||
|
||
- [ ] Run the full local three-act smoke on a fresh Anvil process:
|
||
|
||
```bash
|
||
make demo-local
|
||
```
|
||
|
||
In the second terminal:
|
||
|
||
```bash
|
||
DEMO_EXPECTED_STAGE=v1 make check-state
|
||
make upgrade-v2
|
||
make demo-transfer
|
||
DEMO_EXPECTED_STAGE=v2 make check-state
|
||
curl --fail http://127.0.0.1:5173/
|
||
```
|
||
|
||
Expected: exact Act 1 and Act 3 states; stable proxy; changed implementation; decoded transfer; HTTP `200`.
|
||
|
||
- [ ] Stop the attached demo, then verify cleanup and repository safety:
|
||
|
||
```bash
|
||
make reset-local
|
||
bash tools/test-process-safety.sh
|
||
bash tools/test-base-config.sh
|
||
git diff --check
|
||
git status --short
|
||
```
|
||
|
||
Expected: no Anvil/Vite child remains; only intentional source/document changes are present.
|
||
|
||
- [ ] Scan only project-owned tracked files, excluding dependency gitlinks/locks and the approved design/plan prose. Review every match:
|
||
|
||
```bash
|
||
bash tools/scan-project.sh
|
||
```
|
||
|
||
Expected: no secret assignment/value or placeholder; no unsafe upgrade bypass beyond the scanner’s single exact constructor-annotation allowance.
|
||
|
||
- [ ] Inspect final evidence:
|
||
|
||
```bash
|
||
git diff --stat demo-start..HEAD
|
||
git log --oneline --decorate demo-start..HEAD
|
||
git status --short
|
||
```
|
||
|
||
Confirm the V2 diff is bounded to the approved live prompt and that docs match actual output.
|
||
|
||
- [ ] Commit final documentation and tag only after fresh verification:
|
||
|
||
```bash
|
||
git add README.md docs/LEARNING_GUIDE.md docs/PRESENTER_RUNBOOK.md
|
||
git commit -m "docs: finish UUPS bank demo guide"
|
||
git tag -a demo-complete -m "Verified reference solution for UUPS bank demo"
|
||
```
|
||
|
||
Expected final history: `demo-start` points to the verified V1-only checkpoint; `demo-complete` points to the verified V2 reference solution.
|
||
|
||
---
|
||
|
||
## Final Acceptance Run
|
||
|
||
The implementation is complete only when one fresh run records all of the following:
|
||
|
||
```bash
|
||
make setup
|
||
make verify
|
||
make demo-local # terminal 1, remains attached
|
||
DEMO_EXPECTED_STAGE=v1 make check-state
|
||
make upgrade-v2
|
||
make demo-transfer
|
||
DEMO_EXPECTED_STAGE=v2 make check-state
|
||
make reset-local # after stopping terminal 1
|
||
```
|
||
|
||
Evidence must show:
|
||
|
||
- Act 1: Alice `900e6`, Bob `500e6`, liabilities/reserves `1_400e6`, version `1`.
|
||
- Upgrade: identical proxy/owner/asset/pause/balances/liabilities/reserves and a changed implementation.
|
||
- Act 3: Alice `650e6`, Bob `750e6`, liabilities/reserves `1_400e6`, version `2`, decoded internal transfer, and no reserve movement.
|
||
- `make verify`: Solidity format/build/unit/fuzz/invariant/upgrade validation, artifact/shell safety checks, web lint/types/tests/build all green.
|
||
- Cleanup: only project-owned processes/runtime files removed.
|
||
- Git: `demo-start` contains no V2 source or transfer-aware UI; `demo-complete` contains the verified reference implementation.
|