Merge pull request 'feat: deliver verified UUPS bank demo' (#2) from feature/uups-bank-demo into main

Reviewed-on: https://git.zavage.net/MG/uupl-smart-contract/pulls/2
This commit is contained in:
zavage
2026-08-25 02:06:27 -06:00
123 changed files with 15049 additions and 9375 deletions
+11
View File
@@ -0,0 +1,11 @@
# Copy this file to .env for local-only public configuration.
# 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=
# Foundry prompts interactively for the named keystore password. Never store a
# signing key, mnemonic, or keystore password in .env.
+20 -5
View File
@@ -1,6 +1,21 @@
.worktrees/ # Local agent/session state
**/.superpowers/
**/.claude/superpowers/
**/.worktrees/
# ephemeral brainstorm-server session state .env
.superpowers/brainstorm/.last-port .env.local
.superpowers/brainstorm/.last-token .demo/
.superpowers/brainstorm/*/state/ 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
+9
View File
@@ -0,0 +1,9 @@
[submodule "lib/forge-std"]
path = lib/forge-std
url = https://github.com/foundry-rs/forge-std
[submodule "lib/openzeppelin-foundry-upgrades"]
path = lib/openzeppelin-foundry-upgrades
url = https://github.com/OpenZeppelin/openzeppelin-foundry-upgrades
[submodule "lib/openzeppelin-contracts-upgradeable"]
path = lib/openzeppelin-contracts-upgradeable
url = https://github.com/OpenZeppelin/openzeppelin-contracts-upgradeable
+1
View File
@@ -0,0 +1 @@
24.18.0
@@ -1,97 +0,0 @@
<div class="label">Design review · Section 1 of 4</div>
<h2>Terminal-orchestrated architecture</h2>
<p class="subtitle">All state-changing demo actions originate in auditable Foundry scripts. The operations console is a read-only window into the chain.</p>
<div class="mockup" style="margin-top:22px">
<div class="mockup-header">System boundaries and data flow</div>
<div class="mockup-body" style="padding:22px;background:#f6f8fb;color:#172033">
<div style="display:grid;grid-template-columns:1fr 42px 1.1fr 42px 1.35fr;align-items:center;gap:5px">
<div style="display:grid;gap:12px">
<div style="padding:16px;border:2px solid #59677e;border-radius:12px;background:white">
<div class="label">Codex + presenter</div>
<strong>Foundry terminal</strong>
<div style="font-size:12px;margin-top:8px;line-height:1.7;color:#596477">
DeployV1.s.sol<br>
SeedDemo.s.sol<br>
UpgradeV2.s.sol<br>
forge test / inspect
</div>
<div style="margin-top:9px;padding:7px 9px;background:#edf2f8;border-radius:7px;font-size:11px">Only state-changing control plane</div>
</div>
<div style="padding:16px;border:2px solid #5e806c;border-radius:12px;background:white">
<div class="label">Audience view</div>
<strong>React operations console</strong>
<div style="font-size:12px;margin-top:8px;line-height:1.7;color:#596477">
Balances + total liabilities<br>
USDC reserves + reserve ratio<br>
Proxy / implementation / version<br>
Event-driven activity timeline
</div>
<div style="margin-top:9px;padding:7px 9px;background:#ebf5ef;border-radius:7px;font-size:11px">Read-only via viem</div>
</div>
</div>
<div style="text-align:center;color:#65728a;font-size:25px">
<div title="JSON-RPC writes"></div>
<div style="height:118px"></div>
<div title="JSON-RPC reads"></div>
</div>
<div style="padding:18px;border:2px solid #7f6caf;border-radius:14px;background:#f0ecf9;text-align:center">
<div class="label">Same interface, selectable target</div>
<strong style="font-size:18px">Ethereum JSON-RPC</strong>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:9px;margin-top:16px">
<div style="padding:13px 8px;background:white;border-radius:9px;border:1px solid #cfc5e4">
<strong>Anvil</strong><br><small>Default live demo<br>fast + deterministic</small>
</div>
<div style="padding:13px 8px;background:white;border-radius:9px;border:1px solid #cfc5e4">
<strong>Base Sepolia</strong><br><small>Optional public proof<br>same scripts</small>
</div>
</div>
<div style="margin-top:13px;font-size:11px;color:#625879">Network config supplies RPC URL, chain ID, and deployed addresses.</div>
</div>
<div style="text-align:center;color:#65728a;font-size:25px"></div>
<div style="display:grid;gap:11px">
<div style="padding:15px;border:2px solid #23536a;border-radius:12px;background:white">
<div class="label">Stable public address</div>
<strong>ERC1967 Proxy</strong>
<div style="margin-top:8px;font-size:11px;color:#526170">Owns all bank storage: owner, asset, paused flag, balances, total liabilities.</div>
</div>
<div style="text-align:center;color:#23536a;font-size:18px">delegatecall ↓</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:8px">
<div style="padding:13px;border:2px solid #83909f;border-radius:10px;background:white">
<strong>Bank V1</strong><br><small>deposit<br>withdraw<br>pause</small>
</div>
<div style="padding:13px;border:2px solid #269064;border-radius:10px;background:#eaf7f0">
<strong>Bank V2</strong><br><small>V1 behavior<br>+ internal transfer</small>
</div>
</div>
<div style="padding:13px;border:2px solid #2772a5;border-radius:10px;background:#eaf4fb">
<strong>MockUSDC</strong><br><small>The proxy holds reserves; liabilities must never exceed its token balance.</small>
</div>
</div>
</div>
<div style="margin-top:20px;padding:13px 16px;background:#152239;color:white;border-radius:10px;display:flex;justify-content:space-between;gap:18px;font-size:12px">
<span><strong>V1 prepared:</strong> deploy, seed accounts, deposit, withdraw</span>
<span></span>
<span><strong>Codex live:</strong> implement + test V2</span>
<span></span>
<span><strong>Reveal:</strong> upgrade, transfer, prove preserved state</span>
</div>
</div>
</div>
<div class="options" style="margin-top:22px">
<div class="option" data-choice="architecture-approved" onclick="toggleSelect(this)">
<div class="letter"></div>
<div class="content"><h3>Architecture approved</h3><p>Continue to contract responsibilities and invariants.</p></div>
</div>
<div class="option" data-choice="architecture-changes" onclick="toggleSelect(this)">
<div class="letter"></div>
<div class="content"><h3>Needs changes</h3><p>Select this and describe the adjustment in the terminal.</p></div>
</div>
</div>
@@ -1,98 +0,0 @@
<h2>What should the demo dashboard emphasize?</h2>
<p class="subtitle">All three support connect wallet, mock-USDC deposits and withdrawals, and the V1 → V2 upgrade. Click the layout that tells the clearest story.</p>
<div class="cards" style="grid-template-columns:repeat(3,minmax(0,1fr));align-items:stretch">
<div class="card" data-choice="customer-first" onclick="toggleSelect(this)">
<div class="card-image" style="padding:14px;background:#f5f7fb;min-height:330px;color:#162033">
<div style="display:flex;justify-content:space-between;align-items:center;font-size:11px;margin-bottom:14px">
<strong>MY BANK</strong><span style="padding:5px 8px;border:1px solid #ccd4e0;border-radius:12px">0x71…9a2</span>
</div>
<div style="padding:18px;border-radius:12px;background:#17243c;color:white;margin-bottom:10px">
<div style="font-size:10px;opacity:.7">AVAILABLE BALANCE</div>
<div style="font-size:27px;font-weight:700;margin:6px 0">1,250.00 USDC</div>
<div style="font-size:10px;color:#9ee6bf">Backed 1:1 by contract reserves</div>
</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:8px;margin-bottom:12px">
<button class="mock-button" style="margin:0">Deposit</button>
<button class="mock-button" style="margin:0;background:#e7ecf4;color:#17243c">Withdraw</button>
</div>
<div style="background:white;border:1px solid #dde3ec;border-radius:10px;padding:12px">
<div style="display:flex;justify-content:space-between;font-size:11px;margin-bottom:10px"><strong>Recent activity</strong><span>View all</span></div>
<div style="display:flex;justify-content:space-between;font-size:10px;padding:7px 0;border-top:1px solid #edf0f5"><span>Deposit</span><strong style="color:#168451">+500 USDC</strong></div>
<div style="display:flex;justify-content:space-between;font-size:10px;padding:7px 0;border-top:1px solid #edf0f5"><span>Withdrawal</span><strong>50 USDC</strong></div>
</div>
<div style="font-size:9px;text-align:center;margin-top:11px;color:#687387">Bank V1 · Proxy 0x8B…42</div>
</div>
<div class="card-body">
<h3>A · Customer-first</h3>
<p>Feels like a consumer banking app. The upgrade story stays mostly behind the scenes.</p>
</div>
</div>
<div class="card" data-choice="operations-console" onclick="toggleSelect(this)">
<div class="card-image" style="padding:0;background:#101827;min-height:330px;color:#dfe8f5;display:grid;grid-template-columns:74px 1fr">
<div style="padding:14px 8px;background:#0b1220;font-size:9px">
<strong style="font-size:10px">BANK OPS</strong>
<div style="margin-top:22px;padding:6px;background:#24314a;border-radius:5px">Overview</div>
<div style="padding:7px 6px">Accounts</div>
<div style="padding:7px 6px">Reserves</div>
<div style="padding:7px 6px">Upgrades</div>
</div>
<div style="padding:14px">
<div style="display:flex;justify-content:space-between;font-size:11px;margin-bottom:14px"><strong>System overview</strong><span style="color:#83d9a8">● Healthy</span></div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:7px;margin-bottom:10px">
<div style="padding:10px;background:#19243a;border-radius:8px"><small>Total deposits</small><div style="font-size:16px;font-weight:700;margin-top:5px">25,000 USDC</div></div>
<div style="padding:10px;background:#19243a;border-radius:8px"><small>Reserve ratio</small><div style="font-size:16px;font-weight:700;margin-top:5px">100.0%</div></div>
<div style="padding:10px;background:#19243a;border-radius:8px"><small>Proxy version</small><div style="font-size:16px;font-weight:700;margin-top:5px">V1</div></div>
<div style="padding:10px;background:#19243a;border-radius:8px"><small>Status</small><div style="font-size:16px;font-weight:700;margin-top:5px">Active</div></div>
</div>
<div style="padding:11px;background:#19243a;border-radius:8px;font-size:9px;line-height:1.7">
<strong>Contract addresses</strong><br>
Proxy&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;0x8B31…A942<br>
Implementation&nbsp;0x19F4…71C0<br>
Asset&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;Mock USDC
</div>
<button class="mock-button" style="width:100%;margin-top:10px">Upgrade to V2</button>
</div>
</div>
<div class="card-body">
<h3>B · Operations console</h3>
<p>Highlights reserves, proxy state, and owner controls. Strong technically, less relatable to customers.</p>
</div>
</div>
<div class="card" data-choice="upgrade-studio" onclick="toggleSelect(this)">
<div class="card-image" style="padding:14px;background:#f7f4ed;min-height:330px;color:#202926">
<div style="display:flex;justify-content:space-between;align-items:center;font-size:11px;margin-bottom:12px">
<strong>UUPS BANK LAB</strong><span style="padding:5px 8px;background:#dff3e7;color:#16643c;border-radius:12px">● V1 live</span>
</div>
<div style="display:grid;grid-template-columns:1.15fr .85fr;gap:9px">
<div>
<div style="padding:14px;background:#173f36;color:white;border-radius:10px;margin-bottom:8px">
<small>ALICE'S BALANCE</small><div style="font-size:22px;font-weight:700;margin-top:6px">1,250 USDC</div>
</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:6px;margin-bottom:8px">
<button class="mock-button" style="margin:0">Deposit</button><button class="mock-button" style="margin:0;background:#e6e2d8;color:#26322e">Withdraw</button>
</div>
<div style="padding:10px;border:1px dashed #aab2aa;border-radius:8px;font-size:9px;color:#737c75">
Transfer to another customer<br><strong>Unlocks after V2 upgrade</strong>
</div>
</div>
<div style="padding:11px;background:white;border:1px solid #ddd8cc;border-radius:10px;font-size:9px">
<strong>Upgrade journey</strong>
<div style="margin-top:11px;padding-left:9px;border-left:2px solid #3d8967;line-height:1.5"><strong>1 · V1 deployed</strong><br><span style="color:#6a736e">Deposit + withdraw</span></div>
<div style="margin-top:11px;padding-left:9px;border-left:2px solid #cbd0ca;line-height:1.5"><strong>2 · Upgrade V2</strong><br><span style="color:#6a736e">Owner only</span></div>
<div style="margin-top:11px;padding-left:9px;border-left:2px solid #cbd0ca;line-height:1.5"><strong>3 · Transfer</strong><br><span style="color:#6a736e">Balances persist</span></div>
<button class="mock-button" style="width:100%;margin-top:14px;font-size:9px">Run upgrade</button>
</div>
</div>
<div style="display:flex;justify-content:space-between;margin-top:10px;padding:9px;background:#ece7dc;border-radius:8px;font-size:9px">
<span>Proxy stays <strong>0x8B…42</strong></span><span>Reserves <strong>100%</strong></span>
</div>
</div>
<div class="card-body">
<h3>C · Upgrade demo studio — recommended</h3>
<p>Pairs a usable customer account with a guided UUPS timeline, making state preservation obvious.</p>
</div>
</div>
</div>
@@ -1,94 +0,0 @@
<div class="label">Design review · Section 3 of 4</div>
<h2>The live demo as a three-act story</h2>
<p class="subtitle">The audience sees every command, every test, and every state transition. The operations console mirrors the chain without controlling it.</p>
<div style="display:grid;grid-template-columns:1fr 54px 1fr 54px 1fr;align-items:stretch;margin-top:22px">
<div style="padding:18px;border:2px solid #6c7a8f;border-radius:14px;background:#f8fafc">
<div class="label">Act 1 · Prepared V1</div>
<h3 style="margin-top:5px">Establish trust</h3>
<div style="padding:9px 11px;background:#18243a;color:#d9e5f4;border-radius:7px;font-family:monospace;font-size:11px">make demo-v1</div>
<ol style="font-size:12px;line-height:1.65;padding-left:18px">
<li>Deploy MockUSDC, BankV1, and proxy</li>
<li>Mint demo funds to Alice and Bob</li>
<li>Deposit 1,000 + 500 USDC</li>
<li>Alice withdraws 100 USDC</li>
</ol>
<div style="padding:10px;background:white;border:1px solid #d8dee8;border-radius:8px;font-size:11px">
<strong>Console proves</strong><br>
Proxy: <strong>0x8B…42</strong><br>
Version: <strong>V1</strong><br>
Reserves: <strong>1,400</strong><br>
Liabilities: <strong>1,400</strong>
</div>
</div>
<div style="display:flex;align-items:center;justify-content:center;font-size:28px;color:#68768d"></div>
<div style="padding:18px;border:2px solid #7a64b0;border-radius:14px;background:#f4f0fb">
<div class="label">Act 2 · Codex live</div>
<h3 style="margin-top:5px">Change the system safely</h3>
<div style="padding:10px;background:white;border:1px solid #d8cfee;border-radius:8px;font-size:12px;line-height:1.55">
Ask Codex to add customer-to-customer transfers while preserving all V1 state.
</div>
<ol style="font-size:12px;line-height:1.65;padding-left:18px">
<li>Implement BankV2</li>
<li>Add unit, fuzz, and invariant tests</li>
<li>Add upgrade and transfer scripts</li>
<li>Update ABI and console timeline</li>
<li>Explain the storage-safety choices</li>
</ol>
<div style="padding:9px 11px;background:#221b35;color:#e8e0fa;border-radius:7px;font-family:monospace;font-size:11px">forge test --force</div>
<div style="margin-top:7px;font-size:11px;color:#665d78">No upgrade runs until tests and layout validation pass.</div>
</div>
<div style="display:flex;align-items:center;justify-content:center;font-size:28px;color:#68768d"></div>
<div style="padding:18px;border:2px solid #27865f;border-radius:14px;background:#edf8f2">
<div class="label">Act 3 · V2 reveal</div>
<h3 style="margin-top:5px">Prove state survived</h3>
<div style="padding:9px 11px;background:#14382d;color:#ddf5e9;border-radius:7px;font-family:monospace;font-size:11px">make upgrade-v2<br>make demo-transfer</div>
<ol style="font-size:12px;line-height:1.65;padding-left:18px">
<li>Owner upgrades proxy to BankV2</li>
<li>Proxy address remains unchanged</li>
<li>Alice transfers 250 to Bob internally</li>
<li>No MockUSDC moves during transfer</li>
</ol>
<div style="padding:10px;background:white;border:1px solid #cde6d8;border-radius:8px;font-size:11px">
<strong>Console proves</strong><br>
Alice: 900 → <strong>650</strong><br>
Bob: 500 → <strong>750</strong><br>
Reserves = liabilities = <strong>1,400</strong><br>
Version: <strong>V2</strong>
</div>
</div>
</div>
<div style="margin-top:18px;padding:15px 18px;border:1px solid #d6dce6;border-radius:11px;background:white;display:grid;grid-template-columns:1fr 1fr;gap:22px;font-size:12px">
<div>
<strong>Failure behavior</strong>
<ul style="line-height:1.65;margin-bottom:0">
<li>Scripts stop on wrong owner, network, address, or failed postcondition.</li>
<li>The console shows disconnected/stale state instead of fake zero balances.</li>
<li>Contract reverts are decoded into named, teachable errors.</li>
</ul>
</div>
<div>
<strong>Optional public encore</strong>
<ul style="line-height:1.65;margin-bottom:0">
<li>Repeat the same scripts on Base Sepolia with an explicit network flag.</li>
<li>Load deployed addresses into the same console.</li>
<li>Show explorer links for the proxy, upgrade, and transfer events.</li>
</ul>
</div>
</div>
<div class="options" style="margin-top:22px">
<div class="option" data-choice="sequence-approved" onclick="toggleSelect(this)">
<div class="letter"></div>
<div class="content"><h3>Demo sequence approved</h3><p>Continue to testing, documentation, and presentation safeguards.</p></div>
</div>
<div class="option" data-choice="sequence-changes" onclick="toggleSelect(this)">
<div class="letter"></div>
<div class="content"><h3>Needs changes</h3><p>Select this and describe what should change in the terminal.</p></div>
</div>
</div>
@@ -1,6 +0,0 @@
<div style="display:flex;align-items:center;justify-content:center;min-height:60vh;text-align:center">
<div>
<h2>Architecture approved</h2>
<p class="subtitle">Continuing in the terminal with the contract model, safety rules, and teaching notes…</p>
</div>
</div>
@@ -1,6 +0,0 @@
<div style="display:flex;align-items:center;justify-content:center;min-height:60vh;text-align:center">
<div>
<h2>Operations console selected</h2>
<p class="subtitle">Continuing in the terminal to shape the live Codex demonstration…</p>
</div>
</div>
@@ -1,6 +0,0 @@
<div style="display:flex;align-items:center;justify-content:center;min-height:60vh;text-align:center">
<div>
<h2>Demo sequence approved</h2>
<p class="subtitle">Continuing in the terminal with the final design section: safe setup, testing, and learning materials…</p>
</div>
</div>
@@ -1,86 +0,0 @@
# SDD ledger — plan: /home/golem/dev/sandbox/codius-test-uupl-smart-contract/.worktrees/uups-bank-demo/docs/superpowers/plans/2026-08-17-uups-bank-demo.md
Setup: isolated worktree `/home/golem/dev/sandbox/codius-test-uupl-smart-contract/.worktrees/uups-bank-demo` on branch `feature/uups-bank-demo`.
Baseline: clean documentation-only repository; no package manifest or runnable test suite exists before Task 1.
Task 1: fix round 1/5 (1 addressed, 1 open — plan-file scope conflict awaiting user ruling; commits eece34c..a6f9533)
Task 1: ruling — keep the plan correction; the task-specific verified-compatibility clause governs the general listed-files constraint.
Task 1: complete (commits 14980e6..a6f9533, review findings resolved)
Task 2: minor (deferred): standardize MockUSDC public NatSpec to the exact global educational warning.
Task 2: fix round 1/5 (1 addressed, 0 open — literal RED/GREEN command evidence reproduced; no new commit)
Task 2: controller check — reported pre-production missing-source failure and isolated reproduction substantiate TDD chronology; no real gap found.
Task 2: complete (commits a6f9533..1f4175b, review clean; 1 minor deferred)
Task 3: awaiting user ruling — pinned upgrades-core rejects OpenZeppelin 5.6.1 ReentrancyGuard's inherited constructor despite its `@custom:stateless` marker.
Task 3: ruling — user approved the single `unsafe-allow-reachable constructor` annotation; all other upgrade validation remains enabled.
Task 3: approved reachable annotation proved ineffective — upgrades-core 1.46.0 applies it only to opcode traversal, not inherited constructor errors; awaiting second ruling.
Task 3: ruling — user selected OpenZeppelin `ReentrancyGuardTransient`; retain the original sole constructor annotation and add no validator bypass.
Task 3: controller check — report confirms literal RED/GREEN, 19/19 full-suite, and exact storage output; focused source scan confirms no validator bypass, while chain allowlisting remains a later script-task requirement.
Task 3: complete (commits 1f4175b..0664eb4, review clean)
Task 4: controller check — prior fixture confirms proxy binding; foundry config confirms Cancun/fixed seed/validation settings; report records literal offline RED/GREEN commands.
Task 4: complete (commits 0664eb4..2a21766, review clean)
Task 5: minor (deferred): make `actorAt` reject indices above three instead of silently returning actor3.
Task 5: fix round 1/5 (1 addressed, 0 open — genuine handler-absent RED and fresh selector-summary GREEN reproduced; no new commit)
Task 5: complete (commits 2a21766..6dcbb03, review clean; 1 minor deferred)
Task 6: PAUSED by user for host reboot at committed HEAD `6dcbb03`; active worker interrupted. Uncommitted work is limited to `script/lib/DemoScript.sol`, `script/DeployV1.s.sol`, `script/SeedV1Demo.s.sol`, `script/CheckState.s.sol`, and `test/ScriptPreflight.t.sol`. Resume Task 6 from focused preflight GREEN/debug; no Anvil process was running.
Task 6: reboot recovery — focused `forge test --match-path test/ScriptPreflight.t.sol -vvv --force` is GREEN (14/14); preserved work is internally coherent and ready for the remaining Node finalizer/selector, Make targets, and live smoke.
## Preflight compatibility scan (restored after reboot)
The approved design spec is authoritative. “Shared interface” below means a direct producer/consumer dependency or a file touched by both tasks, not a merely transitive use of the pinned toolchain.
| Tasks | Producer / consumer or shared file | Finding / ruling |
| --- | --- | --- |
| 1 / 6 | `Makefile`, Foundry/npm pins, upgrades CLI | Compatible: Task 6 extends the exact pinned foundation and offline validator contract. |
| 1 / 7 | `Makefile`, web configuration and package scripts | Compatible: Task 7 adds bridge targets without changing exact dependency pins. |
| 1 / 8 | `web/src/test/setup.ts`, web scripts | Compatible: Task 8 consumes the configured jsdom/Vitest/React setup. |
| 1 / 9 | `Makefile`, `tools/doctor.sh` | Compatible: Task 9 replaces the temporary doctor and completes the gate. |
| 1 / 11 | `Makefile`, generated artifact policy | Compatible: V2 extends the existing gate and ignored generated module. |
| 1 / 12 | `.env.example`, `Makefile` | Compatible: public Base configuration is added later without secrets or floating dependencies. |
| 2 / 3 | `MockUSDC` asset interface | Compatible: V1 stores and reads the fixed six-decimal ERC-20. |
| 2 / 4 | `MockUSDC` and ERC-20 behavior | Compatible: custody tests build on standard mint/transfer/approve behavior. |
| 2 / 5 | Mintable test liquidity | Compatible: handler receives token ownership only in invariant setup. |
| 2 / 6 | Deployment/seeding asset | Compatible: scripts deploy and seed the same mock token. |
| 2 / 7 | Token ABI and reserve reads | Compatible: ABI sync exports `balanceOf`; browser reads proxy token reserves. |
| 3 / 4 | `src/BankV1.sol` | Compatible: Task 4 adds mutations without changing frozen field order or admin behavior. |
| 3 / 5 | `BankTestBase`, V1 proxy | Compatible: invariants target the validated proxy fixture. |
| 3 / 6 | V1 deploy/init/view interfaces | Compatible: scripts consume only established proxy-facing APIs. |
| 3 / 7 | V1 ABI and state views | Compatible: browser model remains V1-only through `demo-start`. |
| 3 / 10 | V1 storage/layout and UUPS authorization | Compatible subject to the recorded user ruling selecting `ReentrancyGuardTransient`; V2 must inherit the resulting actual layout and add no storage. |
| 4 / 5 | Deposit/withdraw accounting | Compatible: ghost counters independently model exact successful deltas and surplus donations. |
| 4 / 6 | Deposit/withdraw and Act 1 values | Compatible: scripted sequence yields the exact tested accounting state. |
| 4 / 10 | V1 behavior retained through V2 | Compatible: upgrade regression explicitly re-exercises V1 mutations. |
| 5 / 11 | `test/BankInvariant.t.sol`, handler interface | Compatible: Task 11 adds transfers without altering liability/reserve ghosts. |
| 6 / 7 | Confirmed/active manifest contract | Compatible: ABI sync is chain-independent; publishing alone consumes validated `active.json`. |
| 6 / 8 | Dashboard deployment identity and Act 1 snapshot | Compatible: UI consumes only validated manifest/state, never a signer. |
| 6 / 9 | `Makefile`, scripts, manifests, process lifecycle | Compatible: orchestration composes Task 6s direct targets and preserves Base state during local reset. |
| 6 / 11 | Upgrade/check scripts, finalizer, manifest identity | Compatible: upgrade mutates only implementation/version and stages before confirmed writes. |
| 6 / 12 | Base script paths, finalizer/selector, manifest schema | Ruling: Task 6 provides chain recognition and generic public manifest validation; Task 12 owns the fully runnable Base actor/RPC/keystore path. Cost if wrong: Task 6 may need a narrow compatibility adjustment when Task 12 adds Base actors. |
| 7 / 8 | Dashboard types, manifest parser, bank client | Compatible: Task 8 renders the typed, V1-only read model. |
| 7 / 9 | ABI sync/publish targets and offline verification | Compatible: `make verify` requires ABI freshness but no live manifest. |
| 7 / 11 | `dashboard.ts`, `bankClient`, artifact sync | Compatible: V2 ABI/activity is deliberately absent at `demo-start` and added only after Task 10. |
| 7 / 12 | manifest publisher/selector and Base explorer/RPC metadata | Compatible: browser receives only public credential-free RPC data. |
| 8 / 9 | V1 console and local orchestration | Compatible: Task 9 launches Vite directly and verifies read-only Act 1 display. |
| 8 / 11 | activity timeline and App tests | Compatible: Task 11 adds one transfer variant while retaining read-only UI constraints. |
| 8 / 12 | generic explorer links and Base manifest | Compatible: links derive only from validated confirmed public metadata. |
| 9 / 10 | `demo-start` checkpoint | Compatible: Task 10 begins after the verified V1 tag and does not rewrite it. |
| 9 / 11 | `Makefile`, scan/process gates, prepared V1 state | Compatible: Task 11 verifies before broadcast and runs from the V1 checkpoint. |
| 9 / 12 | `Makefile`, README/runbook, Base-preserving reset | Compatible: the encore remains optional and offline config tests alone join `verify`. |
| 9 / 13 | README/guides, full smoke and tags | Compatible: Task 13 completes claims only from newly observed output. |
| 10 / 11 | `BankV2`, upgrade validation, V2 transfer ABI | Compatible: scripts/UI consume the tested storage-free extension. |
| 10 / 12 | V2 upgrade/transfer on Base | Compatible: Task 12 reuses the same V2 implementation with explicit actors/signing. |
| 10 / 13 | upgrade proof and V2 learning material | Compatible: documentation is conditioned on final verification evidence. |
| 11 / 12 | upgrade/transfer/check scripts, finalizer, `Makefile` | Compatible: Base branches extend local exact paths without weakening local guards. |
| 11 / 13 | Act 2/3 state and event evidence | Compatible: docs consume exact verified proxy/implementation/balance results. |
| 12 / 13 | README/runbook and optional encore | Compatible: no public-network execution is required for local completion. |
| 1 self | Exact versions, install, RED/GREEN web scaffold, initial gate | Internally consistent; prior compatibility exceptions are committed and recorded. |
| 2 self | Token tests then minimal token | Internally consistent; deferred NatSpec wording is non-load-bearing and final scan/docs can normalize it. |
| 3 self | Admin tests, initializer, storage, validator | Ruling already recorded: use `ReentrancyGuardTransient` because pinned validator rejects the inherited ordinary constructor. Cost if wrong: transient-storage behavior would need replacement and revalidation. |
| 4 self | Adversarial tests and exact custody mutations | Internally consistent; rollback and CEI expectations match implementations. |
| 5 self | Three bounded handler actions and three invariants | Internally consistent; deferred `actorAt` bounds check is non-load-bearing because callers use `actorCount`. |
| 6 self | Preflight, staging, finalization, exact local smoke | Internally consistent after ruling Bases fully configured path belongs to Task 12; confirmed files remain untouched until receipt/code validation. |
| 7 self | Separate ABI sync and manifest publishing | Internally consistent: static verification can run without RPC/manifest while live publishing remains explicit. |
| 8 self | Hook states, console regions, read-only prohibition | Internally consistent; all unknown/failure values remain nonzero-fabricating. |
| 9 self | Process ownership, reset scope, V1 docs/tag | Internally consistent; attached processes and second-terminal smoke are compatible with bounded lifecycle checks. |
| 10 self | Storage-free V2, unit/fuzz/upgrade failures | Internally consistent with the actual V1 layout and no unsafe validation bypass. |
| 11 self | V2 invariants, staging/finalizer, scripts, UI | Internally consistent: already-V2 is a non-broadcast no-op, satisfying the specs refusal to send a transaction. |
| 12 self | Explicit optional Base encore | Internally consistent: only offline fake-value config tests enter the mandatory gate; real network use requires explicit user choice. |
| 13 self | Final docs, static gate, three-act smoke, cleanup/tag | Internally consistent; documentation claims are gated on fresh observed evidence. |
@@ -1,569 +0,0 @@
# Review package: 0664eb4688bd236e2678c5dda2f4a10e898205ed..2a21766237a9c29970519f7fa00482faecd06fdd
## Commits
2a21766 feat: add V1 custody accounting
## Files changed
src/BankV1.sol | 34 ++++
test/BankV1.t.sol | 326 ++++++++++++++++++++++++++++++++++++++
test/mocks/FeeOnTransferToken.sol | 26 +++
test/mocks/ReentrantToken.sol | 104 ++++++++++++
4 files changed, 490 insertions(+)
## Diff
diff --git a/src/BankV1.sol b/src/BankV1.sol
index 13ffca5..0f55426 100644
--- a/src/BankV1.sol
+++ b/src/BankV1.sol
@@ -1,22 +1,31 @@
// SPDX-License-Identifier: MIT
pragma solidity 0.8.35;
import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
+import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";
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 {
+ using SafeERC20 for IERC20;
+
error InvalidAsset(address asset);
+ error ZeroAmount();
+ error InsufficientBalance(address account, uint256 available, uint256 requested);
+ error UnexpectedAssetDelta(uint256 expected, uint256 actual);
+
+ event Deposited(address indexed account, uint256 amount);
+ event Withdrawn(address indexed account, uint256 amount);
IERC20 internal _asset;
mapping(address account => uint256 balance) internal _balances;
uint256 internal _totalLiabilities;
uint256[47] private __gap;
/// @custom:oz-upgrades-unsafe-allow constructor
constructor() {
_disableInitializers();
}
@@ -33,20 +42,45 @@ contract BankV1 is Initializable, UUPSUpgradeable, OwnableUpgradeable, PausableU
}
function pause() external onlyOwner {
_pause();
}
function unpause() external onlyOwner {
_unpause();
}
+ 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);
+ }
+
+ 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);
+ }
+
function asset() external view returns (IERC20) {
return _asset;
}
function balanceOf(address account) external view returns (uint256) {
return _balances[account];
}
function totalLiabilities() external view returns (uint256) {
return _totalLiabilities;
diff --git a/test/BankV1.t.sol b/test/BankV1.t.sol
new file mode 100644
index 0000000..e53edc1
--- /dev/null
+++ b/test/BankV1.t.sol
@@ -0,0 +1,326 @@
+// SPDX-License-Identifier: MIT
+pragma solidity 0.8.35;
+
+import {IERC20Errors} from "@openzeppelin/contracts/interfaces/draft-IERC6093.sol";
+import {PausableUpgradeable} from "@openzeppelin/contracts-upgradeable/utils/PausableUpgradeable.sol";
+import {ReentrancyGuardTransient} from "@openzeppelin/contracts/utils/ReentrancyGuardTransient.sol";
+import {Upgrades} from "openzeppelin-foundry-upgrades/Upgrades.sol";
+
+import {BankV1} from "../src/BankV1.sol";
+import {BankTestBase} from "./helpers/BankTestBase.sol";
+import {FeeOnTransferToken} from "./mocks/FeeOnTransferToken.sol";
+import {ReentrantToken} from "./mocks/ReentrantToken.sol";
+
+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);
+
+contract BankV1CustodyTest is BankTestBase {
+ uint256 private constant MAX_DEPOSIT = 1_000_000e6;
+
+ function testDepositCreditsExactCustomerAndLiabilityAgainstReceivedReserves() public {
+ _mintAndApprove(alice, 1_000e6, 100e6);
+
+ vm.prank(alice);
+ bank.deposit(100e6);
+
+ assertEq(token.balanceOf(alice), 900e6);
+ assertEq(token.balanceOf(proxy), 100e6);
+ assertEq(bank.balanceOf(alice), 100e6);
+ assertEq(bank.totalLiabilities(), 100e6);
+ }
+
+ function testDepositEmitsDepositedEvent() public {
+ _mintAndApprove(alice, 100e6, 100e6);
+
+ vm.expectEmit(true, false, false, true, proxy);
+ emit Deposited(alice, 100e6);
+ vm.prank(alice);
+ bank.deposit(100e6);
+ }
+
+ function testDepositRejectsZeroAmount() public {
+ vm.prank(alice);
+ vm.expectRevert(ZeroAmount.selector);
+ bank.deposit(0);
+ }
+
+ function testDepositRejectsCallsWhilePaused() public {
+ _mintAndApprove(alice, 100e6, 100e6);
+ vm.prank(owner);
+ bank.pause();
+
+ vm.prank(alice);
+ vm.expectRevert(PausableUpgradeable.EnforcedPause.selector);
+ bank.deposit(100e6);
+ }
+
+ function testDepositRollsBackWhenAllowanceIsInadequate() public {
+ _mintAndApprove(alice, 100e6, 99e6);
+
+ vm.prank(alice);
+ vm.expectRevert(abi.encodeWithSelector(IERC20Errors.ERC20InsufficientAllowance.selector, proxy, 99e6, 100e6));
+ bank.deposit(100e6);
+
+ _assertEmptyAccounting(alice);
+ assertEq(token.balanceOf(alice), 100e6);
+ }
+
+ function testDepositRollsBackWhenWalletBalanceIsInadequate() public {
+ _mintAndApprove(alice, 99e6, 100e6);
+
+ vm.prank(alice);
+ vm.expectRevert(abi.encodeWithSelector(IERC20Errors.ERC20InsufficientBalance.selector, alice, 99e6, 100e6));
+ bank.deposit(100e6);
+
+ _assertEmptyAccounting(alice);
+ assertEq(token.balanceOf(alice), 99e6);
+ }
+
+ function testDepositsKeepTwoCustomersAccountingIndependent() public {
+ _mintAndApprove(alice, 1_000e6, 300e6);
+ _mintAndApprove(bob, 1_000e6, 700e6);
+
+ vm.prank(alice);
+ bank.deposit(300e6);
+ vm.prank(bob);
+ bank.deposit(700e6);
+
+ assertEq(bank.balanceOf(alice), 300e6);
+ assertEq(bank.balanceOf(bob), 700e6);
+ assertEq(bank.totalLiabilities(), 1_000e6);
+ assertEq(token.balanceOf(proxy), 1_000e6);
+ }
+
+ function testFeeOnTransferDepositRevertsAndRollsBackTokenAndAccounting() public {
+ FeeOnTransferToken feeToken = new FeeOnTransferToken();
+ BankV1 feeBank = _deployBank(address(feeToken));
+ feeToken.mint(alice, 100e6);
+ vm.prank(alice);
+ feeToken.approve(address(feeBank), 100e6);
+
+ vm.prank(alice);
+ vm.expectRevert(abi.encodeWithSelector(UnexpectedAssetDelta.selector, 100e6, 99e6));
+ feeBank.deposit(100e6);
+
+ assertEq(feeToken.balanceOf(alice), 100e6);
+ assertEq(feeToken.balanceOf(address(feeBank)), 0);
+ assertEq(feeBank.balanceOf(alice), 0);
+ assertEq(feeBank.totalLiabilities(), 0);
+ }
+
+ function testDepositSwallowsNestedRevertAndCreditsOnlyOnce() public {
+ ReentrantToken reentrantToken = new ReentrantToken();
+ BankV1 reentrantBank = _deployBank(address(reentrantToken));
+ reentrantToken.mint(alice, 100e6);
+ vm.prank(alice);
+ reentrantToken.approve(address(reentrantBank), 100e6);
+ reentrantToken.configureDepositCallback(address(reentrantBank), false);
+
+ vm.prank(alice);
+ reentrantBank.deposit(100e6);
+
+ assertTrue(reentrantToken.nestedCallAttempted());
+ assertFalse(reentrantToken.nestedCallSucceeded());
+ assertEq(reentrantToken.nestedRevertSelector(), ReentrancyGuardTransient.ReentrancyGuardReentrantCall.selector);
+ assertEq(reentrantToken.observedAccountBalance(), 0);
+ assertEq(reentrantToken.observedLiabilities(), 0);
+ assertEq(reentrantToken.balanceOf(alice), 0);
+ assertEq(reentrantToken.balanceOf(address(reentrantBank)), 100e6);
+ assertEq(reentrantBank.balanceOf(alice), 100e6);
+ assertEq(reentrantBank.totalLiabilities(), 100e6);
+ }
+
+ function testDepositPropagatesNestedRevertAtomicallyWhenConfigured() public {
+ ReentrantToken reentrantToken = new ReentrantToken();
+ BankV1 reentrantBank = _deployBank(address(reentrantToken));
+ reentrantToken.mint(alice, 100e6);
+ vm.prank(alice);
+ reentrantToken.approve(address(reentrantBank), 100e6);
+ reentrantToken.configureDepositCallback(address(reentrantBank), true);
+
+ vm.prank(alice);
+ vm.expectRevert(ReentrancyGuardTransient.ReentrancyGuardReentrantCall.selector);
+ reentrantBank.deposit(100e6);
+
+ assertEq(reentrantToken.balanceOf(alice), 100e6);
+ assertEq(reentrantToken.balanceOf(address(reentrantBank)), 0);
+ assertEq(reentrantBank.balanceOf(alice), 0);
+ assertEq(reentrantBank.totalLiabilities(), 0);
+ }
+
+ function testWithdrawDebitsExactCustomerLiabilityAndReserves() public {
+ _deposit(alice, 1_000e6);
+
+ vm.prank(alice);
+ bank.withdraw(400e6);
+
+ assertEq(token.balanceOf(alice), 400e6);
+ assertEq(token.balanceOf(proxy), 600e6);
+ assertEq(bank.balanceOf(alice), 600e6);
+ assertEq(bank.totalLiabilities(), 600e6);
+ }
+
+ function testWithdrawEmitsWithdrawnEvent() public {
+ _deposit(alice, 100e6);
+
+ vm.expectEmit(true, false, false, true, proxy);
+ emit Withdrawn(alice, 40e6);
+ vm.prank(alice);
+ bank.withdraw(40e6);
+ }
+
+ function testWithdrawRejectsZeroAmount() public {
+ vm.prank(alice);
+ vm.expectRevert(ZeroAmount.selector);
+ bank.withdraw(0);
+ }
+
+ function testWithdrawRejectsCallsWhilePaused() public {
+ _deposit(alice, 100e6);
+ vm.prank(owner);
+ bank.pause();
+
+ vm.prank(alice);
+ vm.expectRevert(PausableUpgradeable.EnforcedPause.selector);
+ bank.withdraw(100e6);
+ }
+
+ function testWithdrawReportsAvailableAndRequestedOnInsufficientInternalBalance() public {
+ _deposit(alice, 40e6);
+
+ vm.prank(alice);
+ vm.expectRevert(abi.encodeWithSelector(InsufficientBalance.selector, alice, 40e6, 41e6));
+ bank.withdraw(41e6);
+
+ assertEq(bank.balanceOf(alice), 40e6);
+ assertEq(bank.totalLiabilities(), 40e6);
+ assertEq(token.balanceOf(proxy), 40e6);
+ }
+
+ function testWithdrawUpdatesAccountingBeforeTransferCallbackAndCannotDoubleDebit() public {
+ ReentrantToken reentrantToken = new ReentrantToken();
+ BankV1 reentrantBank = _deployBank(address(reentrantToken));
+ reentrantToken.mint(alice, 100e6);
+ vm.prank(alice);
+ reentrantToken.approve(address(reentrantBank), 100e6);
+ vm.prank(alice);
+ reentrantBank.deposit(100e6);
+ reentrantToken.configureWithdrawalCallback(address(reentrantBank), false);
+
+ vm.prank(alice);
+ reentrantBank.withdraw(40e6);
+
+ assertEq(reentrantToken.observedAccountBalance(), 60e6);
+ assertEq(reentrantToken.observedLiabilities(), 60e6);
+ assertTrue(reentrantToken.nestedCallAttempted());
+ assertFalse(reentrantToken.nestedCallSucceeded());
+ assertEq(reentrantToken.nestedRevertSelector(), ReentrancyGuardTransient.ReentrancyGuardReentrantCall.selector);
+ assertEq(reentrantToken.balanceOf(alice), 40e6);
+ assertEq(reentrantToken.balanceOf(address(reentrantBank)), 60e6);
+ assertEq(reentrantBank.balanceOf(alice), 60e6);
+ assertEq(reentrantBank.totalLiabilities(), 60e6);
+ }
+
+ function testOneCustomersWithdrawalLeavesOtherCustomerUnchanged() public {
+ _deposit(alice, 100e6);
+ _deposit(bob, 200e6);
+
+ vm.prank(alice);
+ bank.withdraw(40e6);
+
+ assertEq(bank.balanceOf(alice), 60e6);
+ assertEq(bank.balanceOf(bob), 200e6);
+ assertEq(bank.totalLiabilities(), 260e6);
+ assertEq(token.balanceOf(proxy), 260e6);
+ }
+
+ function testDirectTransferCreatesSurplusThatRemainsAfterFullWithdrawal() public {
+ _deposit(alice, 100e6);
+ vm.prank(owner);
+ token.mint(bob, 25e6);
+ vm.prank(bob);
+ token.transfer(proxy, 25e6);
+
+ assertEq(token.balanceOf(proxy), 125e6);
+ assertEq(bank.totalLiabilities(), 100e6);
+
+ vm.prank(alice);
+ bank.withdraw(100e6);
+
+ assertEq(token.balanceOf(proxy), 25e6);
+ assertEq(bank.balanceOf(alice), 0);
+ assertEq(bank.totalLiabilities(), 0);
+ }
+
+ function testFuzzDepositPreservesExactAccounting(uint256 amountSeed) public {
+ uint256 amount = bound(amountSeed, 1, MAX_DEPOSIT);
+ _mintAndApprove(alice, amount, amount);
+
+ vm.prank(alice);
+ bank.deposit(amount);
+
+ assertEq(token.balanceOf(alice), 0);
+ assertEq(token.balanceOf(proxy), amount);
+ assertEq(bank.balanceOf(alice), amount);
+ assertEq(bank.totalLiabilities(), amount);
+ }
+
+ function testFuzzWithdrawPreservesExactAccounting(uint256 depositSeed, uint256 withdrawalSeed) public {
+ uint256 deposited = bound(depositSeed, 1, MAX_DEPOSIT);
+ uint256 withdrawn = bound(withdrawalSeed, 1, deposited);
+ _deposit(alice, deposited);
+
+ vm.prank(alice);
+ bank.withdraw(withdrawn);
+
+ uint256 remaining = deposited - withdrawn;
+ assertEq(token.balanceOf(alice), withdrawn);
+ assertEq(token.balanceOf(proxy), remaining);
+ assertEq(bank.balanceOf(alice), remaining);
+ assertEq(bank.totalLiabilities(), remaining);
+ }
+
+ function testFuzzOverWithdrawAlwaysReverts(uint256 depositSeed, uint256 excessSeed) public {
+ uint256 deposited = bound(depositSeed, 1, MAX_DEPOSIT);
+ uint256 excess = bound(excessSeed, 1, MAX_DEPOSIT);
+ uint256 requested = deposited + excess;
+ _deposit(alice, deposited);
+
+ vm.prank(alice);
+ vm.expectRevert(abi.encodeWithSelector(InsufficientBalance.selector, alice, deposited, requested));
+ bank.withdraw(requested);
+
+ assertEq(bank.balanceOf(alice), deposited);
+ assertEq(bank.totalLiabilities(), deposited);
+ assertEq(token.balanceOf(proxy), deposited);
+ }
+
+ function _mintAndApprove(address account, uint256 mintAmount, uint256 approveAmount) private {
+ vm.prank(owner);
+ token.mint(account, mintAmount);
+ vm.prank(account);
+ token.approve(proxy, approveAmount);
+ }
+
+ function _deposit(address account, uint256 amount) private {
+ _mintAndApprove(account, amount, amount);
+ vm.prank(account);
+ bank.deposit(amount);
+ }
+
+ function _deployBank(address asset_) private returns (BankV1 deployedBank) {
+ address deployedProxy =
+ Upgrades.deployUUPSProxy("BankV1.sol:BankV1", abi.encodeCall(BankV1.initialize, (asset_, owner)));
+ deployedBank = BankV1(deployedProxy);
+ }
+
+ function _assertEmptyAccounting(address account) private view {
+ assertEq(token.balanceOf(proxy), 0);
+ assertEq(bank.balanceOf(account), 0);
+ assertEq(bank.totalLiabilities(), 0);
+ }
+}
diff --git a/test/mocks/FeeOnTransferToken.sol b/test/mocks/FeeOnTransferToken.sol
new file mode 100644
index 0000000..80f5280
--- /dev/null
+++ b/test/mocks/FeeOnTransferToken.sol
@@ -0,0 +1,26 @@
+// SPDX-License-Identifier: MIT
+pragma solidity 0.8.35;
+
+import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol";
+
+contract FeeOnTransferToken is ERC20 {
+ constructor() ERC20("Fee-on-Transfer Token", "FOT") {}
+
+ function decimals() public pure override returns (uint8) {
+ return 6;
+ }
+
+ function mint(address to, uint256 amount) external {
+ _mint(to, amount);
+ }
+
+ function transferFrom(address from, address to, uint256 amount) public override returns (bool) {
+ address spender = _msgSender();
+ _spendAllowance(from, spender, amount);
+
+ uint256 received = amount * 99 / 100;
+ _transfer(from, to, received);
+ _burn(from, amount - received);
+ return true;
+ }
+}
diff --git a/test/mocks/ReentrantToken.sol b/test/mocks/ReentrantToken.sol
new file mode 100644
index 0000000..98dabf3
--- /dev/null
+++ b/test/mocks/ReentrantToken.sol
@@ -0,0 +1,104 @@
+// SPDX-License-Identifier: MIT
+pragma solidity 0.8.35;
+
+import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol";
+
+interface IReentrantBankTarget {
+ function deposit(uint256 amount) external;
+ function withdraw(uint256 amount) external;
+ function balanceOf(address account) external view returns (uint256);
+ function totalLiabilities() external view returns (uint256);
+}
+
+contract ReentrantToken is ERC20 {
+ enum Callback {
+ None,
+ Deposit,
+ Withdraw
+ }
+
+ IReentrantBankTarget public callbackTarget;
+ Callback public callback;
+ bool public propagateRevert;
+ bool public nestedCallAttempted;
+ bool public nestedCallSucceeded;
+ bytes4 public nestedRevertSelector;
+ uint256 public observedAccountBalance;
+ uint256 public observedLiabilities;
+
+ constructor() ERC20("Reentrant Token", "REENT") {}
+
+ function decimals() public pure override returns (uint8) {
+ return 6;
+ }
+
+ function mint(address to, uint256 amount) external {
+ _mint(to, amount);
+ }
+
+ function configureDepositCallback(address bank, bool propagate) external {
+ callbackTarget = IReentrantBankTarget(bank);
+ callback = Callback.Deposit;
+ propagateRevert = propagate;
+ _resetObservations();
+ }
+
+ function configureWithdrawalCallback(address bank, bool propagate) external {
+ callbackTarget = IReentrantBankTarget(bank);
+ callback = Callback.Withdraw;
+ propagateRevert = propagate;
+ _resetObservations();
+ }
+
+ function clearCallback() external {
+ callback = Callback.None;
+ propagateRevert = false;
+ _resetObservations();
+ }
+
+ function transferFrom(address from, address to, uint256 amount) public override returns (bool) {
+ if (callback == Callback.Deposit && _msgSender() == address(callbackTarget)) {
+ observedAccountBalance = callbackTarget.balanceOf(from);
+ observedLiabilities = callbackTarget.totalLiabilities();
+ _attemptNestedCall(abi.encodeCall(IReentrantBankTarget.deposit, (1)));
+ }
+ return super.transferFrom(from, to, amount);
+ }
+
+ function transfer(address to, uint256 amount) public override returns (bool) {
+ if (callback == Callback.Withdraw && _msgSender() == address(callbackTarget)) {
+ observedAccountBalance = callbackTarget.balanceOf(to);
+ observedLiabilities = callbackTarget.totalLiabilities();
+ _attemptNestedCall(abi.encodeCall(IReentrantBankTarget.withdraw, (1)));
+ }
+ return super.transfer(to, amount);
+ }
+
+ function _attemptNestedCall(bytes memory callData) private {
+ nestedCallAttempted = true;
+ bytes memory revertData;
+ (nestedCallSucceeded, revertData) = address(callbackTarget).call(callData);
+
+ if (!nestedCallSucceeded && revertData.length >= 4) {
+ bytes4 selector;
+ assembly ("memory-safe") {
+ selector := mload(add(revertData, 0x20))
+ }
+ nestedRevertSelector = selector;
+ }
+
+ if (!nestedCallSucceeded && propagateRevert) {
+ assembly ("memory-safe") {
+ revert(add(revertData, 0x20), mload(revertData))
+ }
+ }
+ }
+
+ function _resetObservations() private {
+ nestedCallAttempted = false;
+ nestedCallSucceeded = false;
+ nestedRevertSelector = bytes4(0);
+ observedAccountBalance = 0;
+ observedLiabilities = 0;
+ }
+}
File diff suppressed because it is too large Load Diff
@@ -1,311 +0,0 @@
# Review package: 1f4175ba729fbbab58538cd3ac02b5459e014c86..0664eb4688bd236e2678c5dda2f4a10e898205ed
## Commits
0664eb4 feat: establish UUPS bank V1
## Files changed
.../superpowers/plans/2026-08-17-uups-bank-demo.md | 10 +--
src/BankV1.sol | 60 ++++++++++++++
test/BankV1Admin.t.sol | 94 ++++++++++++++++++++++
test/helpers/BankTestBase.sol | 33 ++++++++
4 files changed, 192 insertions(+), 5 deletions(-)
## Diff
diff --git a/docs/superpowers/plans/2026-08-17-uups-bank-demo.md b/docs/superpowers/plans/2026-08-17-uups-bank-demo.md
index 3a3ca8f..cfaaaa6 100644
--- a/docs/superpowers/plans/2026-08-17-uups-bank-demo.md
+++ b/docs/superpowers/plans/2026-08-17-uups-bank-demo.md
@@ -7,21 +7,21 @@
**Architecture:** Foundry scripts are the only state-changing control plane. A six-decimal `MockUSDC` and a UUPS `BankV1` implementation run behind a stable ERC-1967 proxy on Anvil or optional Base Sepolia. Scripts export a public deployment manifest and contract ABIs; a wagmi/viem client reads those artifacts and renders reserves, liabilities, identities, actors, and events without a signer or wallet connector. V2 inherits V1, adds no storage, and adds one internal transfer function.
**Tech Stack:** Foundry `v1.7.1`, forge-std `v1.16.1`, Solidity `0.8.35`, OpenZeppelin Contracts Upgradeable `v5.6.1`, OpenZeppelin Foundry Upgrades `v0.4.1`, OpenZeppelin Upgrades Core `1.46.0`, Node `24.18.0`, npm `11.17.0`, React `19.2.8`, TypeScript compiler `7.0.2` via `@typescript/native`, TypeScript API compatibility `6.0.2` via the `typescript` alias, Vite `8.2.0`, wagmi `3.7.5`, viem `2.55.8`, TanStack Query `5.101.4`, Vitest `4.1.10`, Testing Library React `16.3.2`, and jsdom `30.0.1`.
## Global Constraints
- The contracts are educational and unaudited. Every user-facing surface says: `Educational demo — mock token — never use real funds.`
- State-changing scripts accept only chain IDs `31337` and `84532`; no mainnet RPC, address, target, or configuration is added.
- Local accounts come only from Anvils standard development mnemonic. Testnet signing uses a named encrypted Foundry keystore and `--account`; no supported command accepts a raw private key or mnemonic environment variable.
- Every application call uses the proxy address. The implementation address is read only for validation and explanation.
-- `Upgrades`, never `UnsafeUpgrades`, performs deploy/upgrade validation. The sole validator allowance is the constructor annotation immediately above `_disableInitializers()`.
+- `Upgrades`, never `UnsafeUpgrades`, performs deploy/upgrade validation. The sole validator allowance is `@custom:oz-upgrades-unsafe-allow constructor` immediately above the constructor that calls `_disableInitializers()`; no Options `unsafeAllow`, exclude, skip, `UnsafeUpgrades`, reachable annotation, or other bypass is permitted. BankV1 uses OpenZeppelin 5.6.1 `ReentrancyGuardTransient`, the user-approved constructor-free guard for this project's Cancun-targeted Anvil and Base Sepolia networks.
- V2 does not add, delete, reorder, or change the type of any storage variable and has no initializer.
- The browser has no connector, signer, transaction client, or write button. Failed reads remain unknown; they are never rendered as zero.
- Generated Foundry output, local manifests, copied web artifacts, `.demo/` process state, `.env`, and `.superpowers/` are ignored by Git.
- Run each red/green command exactly as written. A test that unexpectedly passes in a red step means the test is not proving the intended behavior; fix the test before implementation.
- Use exact versions and committed lockfiles/submodule revisions. Do not replace exact versions with ranges.
- OpenZeppelin Foundry Upgrades `v0.4.1` invokes `npx @openzeppelin/upgrades-core@^1.45.0`; the root lockfile pins the satisfying implementation to `1.46.0`, and every validation-bearing command runs with `npm_config_offline=true` after setup proves the local CLI is available.
- Commit only files listed for the task and inspect `git status --short` before every commit so unrelated user changes remain untouched.
## File and Interface Map
@@ -367,39 +367,39 @@ All bank calls in tests target `proxy`; retain `implementation = Upgrades.getImp
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 {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,
- ReentrancyGuard
+ 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 `ReentrancyGuard` are stateless/shared in pinned OpenZeppelin 5.6.1 and have no initializer calls.
+Use `error InvalidAsset(address asset);`. The initializer checks the asset before calling only `__Ownable_init(initialOwner)` and `__Pausable_init()`. `Initializable`, `UUPSUpgradeable`, and `ReentrancyGuardTransient` are stateless/shared in pinned OpenZeppelin 5.6.1 and have no initializer calls. The transient guard is required by the user-approved final design because it is constructor-free and the project's supported Anvil and Base Sepolia networks target Cancun/EIP-1153.
- [ ] Add the only permitted validator annotation and no other bypass:
```solidity
/// @custom:oz-upgrades-unsafe-allow constructor
constructor() {
_disableInitializers();
}
```
@@ -1074,21 +1074,21 @@ reset-local:
```
Retain the explicit `deploy-v1`, `seed-v1`, and `sync-artifacts` lower-level targets for teaching and recovery.
- [ ] Write `README.md` with prerequisites, `make setup`, the ten-minute `make demo-local` quick start, second-terminal commands, architecture paragraph, exact URLs, command reference, and links to both guides. Its trust box must say the token has no value; code is educational/unaudited; real deposits must never be sent; the owner can pause and install arbitrary future logic; UUPS mistakes can corrupt state or brick upgrades; and a real custody product needs professional audits, operational key controls, multisig/timelocked governance, incident procedures, legal advice, and jurisdiction-specific compliance.
- [ ] Write the V1-complete `LEARNING_GUIDE.md`: proxy/implementation/delegatecall/storage, constructor vs initializer, disabled implementation initialization, safe/unsafe storage examples, reserves/liabilities/surplus, exact deposit/withdraw flow, SafeERC20, pause, reentrancy, checks-effects-interactions, single-owner arbitrary-upgrade power, UUPS corruption/bricking risk, the complete real-product safeguards disclosure, and local exercises that trigger each named error.
- [ ] Write the prepared portion of `PRESENTER_RUNBOOK.md`: preflight, rehearsal timing, exact three-act story, the approved live Codex prompt verbatim, Act 1 expected state, commands/console callouts, occupied-port/stale-console/test-failure recovery, and the rule that no upgrade runs until `make verify` passes. Describe Act 2/3 expected outcomes without including reference V2 source, and include a closing trust disclosure checklist matching README/UI word-for-word in substance.
-- [ ] Implement `scan-project.sh` with a NUL-safe array from `git ls-files`, excluding dependency gitlinks, `docs/superpowers`, and generated lockfiles. Return nonzero if project-owned tracked content contains an actual `PRIVATE_KEY`/`MNEMONIC` assignment, PEM private key, `TODO`, `TBD`, `FIXME`, filler text, or an unsafe upgrade bypass in `src`/`test`/`script`. Construct the scanners own pattern from split shell literals so it does not match itself. Permit only the exact hyphenated 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.
+- [ ] Implement `scan-project.sh` with a NUL-safe array from `git ls-files`, excluding dependency gitlinks, `docs/superpowers`, and generated lockfiles. Return nonzero if project-owned tracked content contains an actual `PRIVATE_KEY`/`MNEMONIC` assignment, PEM private key, `TODO`, `TBD`, `FIXME`, filler text, or an unsafe upgrade bypass in `src`/`test`/`script`. Construct the scanners own pattern from split shell literals so it does not match itself. Permit only the exact hyphenated `oz-upgrades-unsafe-allow constructor` annotation approved for `BankV1`. The committed `ANVIL_TEST_PHRASE` is the universally known test fixture, not a supported secret input; test that exact fixture is the only scan exception.
- [ ] Run process and full verification:
```bash
bash tools/test-process-safety.sh
make doctor
make verify
```
Expected: all checks exit `0`.
diff --git a/src/BankV1.sol b/src/BankV1.sol
new file mode 100644
index 0000000..13ffca5
--- /dev/null
+++ b/src/BankV1.sol
@@ -0,0 +1,60 @@
+// SPDX-License-Identifier: MIT
+pragma solidity 0.8.35;
+
+import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
+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 {
+ error InvalidAsset(address asset);
+
+ IERC20 internal _asset;
+ mapping(address account => uint256 balance) internal _balances;
+ uint256 internal _totalLiabilities;
+ uint256[47] private __gap;
+
+ /// @custom:oz-upgrades-unsafe-allow constructor
+ constructor() {
+ _disableInitializers();
+ }
+
+ function initialize(address asset_, address initialOwner) external initializer {
+ if (asset_ == address(0)) {
+ revert InvalidAsset(asset_);
+ }
+
+ __Ownable_init(initialOwner);
+ __Pausable_init();
+
+ _asset = IERC20(asset_);
+ }
+
+ function pause() external onlyOwner {
+ _pause();
+ }
+
+ function unpause() external onlyOwner {
+ _unpause();
+ }
+
+ function asset() external view returns (IERC20) {
+ return _asset;
+ }
+
+ function balanceOf(address account) external view returns (uint256) {
+ return _balances[account];
+ }
+
+ function totalLiabilities() external view returns (uint256) {
+ return _totalLiabilities;
+ }
+
+ function contractVersion() public pure virtual returns (uint256) {
+ return 1;
+ }
+
+ function _authorizeUpgrade(address) internal override onlyOwner {}
+}
diff --git a/test/BankV1Admin.t.sol b/test/BankV1Admin.t.sol
new file mode 100644
index 0000000..7e3c26c
--- /dev/null
+++ b/test/BankV1Admin.t.sol
@@ -0,0 +1,94 @@
+// SPDX-License-Identifier: MIT
+pragma solidity 0.8.35;
+
+import {ERC1967Proxy} from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol";
+import {Initializable} from "@openzeppelin/contracts/proxy/utils/Initializable.sol";
+import {UUPSUpgradeable} from "@openzeppelin/contracts/proxy/utils/UUPSUpgradeable.sol";
+import {OwnableUpgradeable} from "@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol";
+
+import {BankV1} from "../src/BankV1.sol";
+import {BankTestBase} from "./helpers/BankTestBase.sol";
+
+contract BankV1AdminTest is BankTestBase {
+ bytes32 private constant ERC1967_IMPLEMENTATION_SLOT =
+ 0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc;
+
+ function testProxyStartsWithConfiguredAdministrationAndEmptyAccounting() public view {
+ assertEq(address(bank.asset()), address(token));
+ assertEq(bank.owner(), owner);
+ assertFalse(bank.paused());
+ assertEq(bank.balanceOf(alice), 0);
+ assertEq(bank.totalLiabilities(), 0);
+ assertEq(bank.contractVersion(), 1);
+ }
+
+ function testInitializationChecksZeroAssetBeforeZeroOwner() public {
+ vm.expectRevert(abi.encodeWithSelector(BankV1.InvalidAsset.selector, address(0)));
+ new ERC1967Proxy(implementation, abi.encodeCall(BankV1.initialize, (address(0), address(0))));
+ }
+
+ function testInitializationRejectsZeroOwner() public {
+ vm.expectRevert(abi.encodeWithSelector(OwnableUpgradeable.OwnableInvalidOwner.selector, address(0)));
+ new ERC1967Proxy(implementation, abi.encodeCall(BankV1.initialize, (address(token), address(0))));
+ }
+
+ function testProxyCannotBeInitializedTwice() public {
+ vm.expectRevert(Initializable.InvalidInitialization.selector);
+ bank.initialize(address(token), owner);
+ }
+
+ function testImplementationCannotBeInitialized() public {
+ vm.expectRevert(Initializable.InvalidInitialization.selector);
+ BankV1(implementation).initialize(address(token), owner);
+ }
+
+ function testOwnerCanPauseAndUnpause() public {
+ vm.prank(owner);
+ bank.pause();
+ assertTrue(bank.paused());
+
+ vm.prank(owner);
+ bank.unpause();
+ assertFalse(bank.paused());
+ }
+
+ function testNonOwnerCannotPause() public {
+ vm.prank(stranger);
+ vm.expectRevert(abi.encodeWithSelector(OwnableUpgradeable.OwnableUnauthorizedAccount.selector, stranger));
+ bank.pause();
+ }
+
+ function testNonOwnerCannotUnpause() public {
+ vm.prank(owner);
+ bank.pause();
+
+ vm.prank(stranger);
+ vm.expectRevert(abi.encodeWithSelector(OwnableUpgradeable.OwnableUnauthorizedAccount.selector, stranger));
+ bank.unpause();
+ }
+
+ function testViewsRemainAvailableWhilePaused() public {
+ vm.prank(owner);
+ bank.pause();
+
+ assertEq(address(bank.asset()), address(token));
+ assertEq(bank.balanceOf(alice), 0);
+ assertEq(bank.totalLiabilities(), 0);
+ assertEq(bank.contractVersion(), 1);
+ }
+
+ function testNonOwnerCannotAuthorizeUpgrade() public {
+ vm.prank(stranger);
+ vm.expectRevert(abi.encodeWithSelector(OwnableUpgradeable.OwnableUnauthorizedAccount.selector, stranger));
+ bank.upgradeToAndCall(implementation, "");
+ }
+
+ function testImplementationExposesERC1967ProxiableUUID() public view {
+ assertEq(BankV1(implementation).proxiableUUID(), ERC1967_IMPLEMENTATION_SLOT);
+ }
+
+ function testProxyRejectsProxiableUUIDCall() public {
+ vm.expectRevert(UUPSUpgradeable.UUPSUnauthorizedCallContext.selector);
+ bank.proxiableUUID();
+ }
+}
diff --git a/test/helpers/BankTestBase.sol b/test/helpers/BankTestBase.sol
new file mode 100644
index 0000000..9287cc4
--- /dev/null
+++ b/test/helpers/BankTestBase.sol
@@ -0,0 +1,33 @@
+// SPDX-License-Identifier: MIT
+pragma solidity 0.8.35;
+
+import {Test} from "forge-std/Test.sol";
+import {Upgrades} from "openzeppelin-foundry-upgrades/Upgrades.sol";
+
+import {BankV1} from "../../src/BankV1.sol";
+import {MockUSDC} from "../../src/MockUSDC.sol";
+
+abstract contract BankTestBase is Test {
+ address internal owner;
+ address internal alice;
+ address internal bob;
+ address internal stranger;
+
+ MockUSDC internal token;
+ address internal proxy;
+ address internal implementation;
+ BankV1 internal bank;
+
+ function setUp() public virtual {
+ owner = makeAddr("owner");
+ alice = makeAddr("alice");
+ bob = makeAddr("bob");
+ stranger = makeAddr("stranger");
+
+ token = new MockUSDC(owner);
+ proxy =
+ Upgrades.deployUUPSProxy("BankV1.sol:BankV1", abi.encodeCall(BankV1.initialize, (address(token), owner)));
+ bank = BankV1(proxy);
+ implementation = Upgrades.getImplementationAddress(proxy);
+ }
+}
@@ -1,7 +0,0 @@
# Review package: 1f4175ba729fbbab58538cd3ac02b5459e014c86..1f4175ba729fbbab58538cd3ac02b5459e014c86
## Commits
## Files changed
## Diff
@@ -1,145 +0,0 @@
# Review package: 2a21766237a9c29970519f7fa00482faecd06fdd..6dcbb03f3c79979416c2f0dd37556539276a4e29
## Commits
6dcbb03 test: prove V1 accounting invariants
## Files changed
test/BankInvariant.t.sol | 43 ++++++++++++++++++++++++
test/helpers/BankHandler.sol | 79 ++++++++++++++++++++++++++++++++++++++++++++
2 files changed, 122 insertions(+)
## Diff
diff --git a/test/BankInvariant.t.sol b/test/BankInvariant.t.sol
new file mode 100644
index 0000000..f699902
--- /dev/null
+++ b/test/BankInvariant.t.sol
@@ -0,0 +1,43 @@
+// SPDX-License-Identifier: MIT
+pragma solidity 0.8.35;
+
+import {BankTestBase} from "./helpers/BankTestBase.sol";
+import {BankHandler} from "./helpers/BankHandler.sol";
+
+contract BankInvariantTest is BankTestBase {
+ BankHandler internal handler;
+
+ function setUp() public override {
+ super.setUp();
+
+ handler = new BankHandler(token, bank);
+ vm.prank(owner);
+ token.transferOwnership(address(handler));
+
+ targetContract(address(handler));
+ bytes4[] memory selectors = new bytes4[](3);
+ selectors[0] = handler.deposit.selector;
+ selectors[1] = handler.withdraw.selector;
+ selectors[2] = handler.donate.selector;
+ targetSelector(FuzzSelector({addr: address(handler), selectors: selectors}));
+ }
+
+ 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))
+ );
+ }
+}
diff --git a/test/helpers/BankHandler.sol b/test/helpers/BankHandler.sol
new file mode 100644
index 0000000..20ec183
--- /dev/null
+++ b/test/helpers/BankHandler.sol
@@ -0,0 +1,79 @@
+// SPDX-License-Identifier: MIT
+pragma solidity 0.8.35;
+
+import {Test} from "forge-std/Test.sol";
+
+import {BankV1} from "../../src/BankV1.sol";
+import {MockUSDC} from "../../src/MockUSDC.sol";
+
+contract BankHandler is Test {
+ MockUSDC internal immutable token;
+ BankV1 internal immutable bank;
+
+ address internal immutable actor0;
+ address internal immutable actor1;
+ address internal immutable actor2;
+ address internal immutable actor3;
+
+ uint256 public ghostDeposited;
+ uint256 public ghostWithdrawn;
+ uint256 public ghostDonated;
+
+ constructor(MockUSDC token_, BankV1 bank_) {
+ token = token_;
+ bank = bank_;
+ actor0 = address(0x1001);
+ actor1 = address(0x1002);
+ actor2 = address(0x1003);
+ actor3 = address(0x1004);
+ }
+
+ function deposit(uint256 actorSeed, uint256 amount) external {
+ address actor = actorAt(actorSeed % actorCount());
+ amount = bound(amount, 1, 10_000e6);
+ token.mint(actor, amount);
+
+ vm.startPrank(actor);
+ token.approve(address(bank), amount);
+ bank.deposit(amount);
+ vm.stopPrank();
+
+ ghostDeposited += amount;
+ }
+
+ function withdraw(uint256 actorSeed, uint256 amount) external {
+ address actor = actorAt(actorSeed % actorCount());
+ uint256 balance = bank.balanceOf(actor);
+ if (balance == 0) return;
+
+ amount = bound(amount, 1, balance);
+ vm.startPrank(actor);
+ bank.withdraw(amount);
+ vm.stopPrank();
+
+ ghostWithdrawn += amount;
+ }
+
+ function donate(uint256 actorSeed, uint256 amount) external {
+ address actor = actorAt(actorSeed % actorCount());
+ amount = bound(amount, 1, 1_000e6);
+ token.mint(actor, amount);
+
+ vm.startPrank(actor);
+ token.transfer(address(bank), amount);
+ vm.stopPrank();
+
+ ghostDonated += amount;
+ }
+
+ function actorCount() public pure returns (uint256) {
+ return 4;
+ }
+
+ function actorAt(uint256 index) public view returns (address) {
+ if (index == 0) return actor0;
+ if (index == 1) return actor1;
+ if (index == 2) return actor2;
+ return actor3;
+ }
+}
@@ -1,7 +0,0 @@
# Review package: 6dcbb03f3c79979416c2f0dd37556539276a4e29..6dcbb03f3c79979416c2f0dd37556539276a4e29
## Commits
## Files changed
## Diff
@@ -1,101 +0,0 @@
# Review package: a6f9533aacd4d6cdbbf9682bfd8482ced065ebf5..1f4175ba729fbbab58538cd3ac02b5459e014c86
## Commits
1f4175b feat: add valueless mock USDC
## Files changed
src/MockUSDC.sol | 18 ++++++++++++++++
test/MockUSDC.t.sol | 60 +++++++++++++++++++++++++++++++++++++++++++++++++++++
2 files changed, 78 insertions(+)
## Diff
diff --git a/src/MockUSDC.sol b/src/MockUSDC.sol
new file mode 100644
index 0000000..96ca297
--- /dev/null
+++ b/src/MockUSDC.sol
@@ -0,0 +1,18 @@
+// SPDX-License-Identifier: MIT
+pragma solidity 0.8.35;
+
+import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol";
+import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol";
+
+/// @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);
+ }
+}
diff --git a/test/MockUSDC.t.sol b/test/MockUSDC.t.sol
new file mode 100644
index 0000000..028c1a0
--- /dev/null
+++ b/test/MockUSDC.t.sol
@@ -0,0 +1,60 @@
+// SPDX-License-Identifier: MIT
+pragma solidity 0.8.35;
+
+import {Test} from "forge-std/Test.sol";
+import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol";
+import {MockUSDC} from "../src/MockUSDC.sol";
+
+contract MockUSDCTest is Test {
+ MockUSDC internal token;
+ address internal constant STRANGER = address(0xBEEF);
+ address internal constant RECIPIENT = address(0xCAFE);
+ address internal constant SPENDER = address(0xD00D);
+
+ function setUp() public {
+ token = new MockUSDC(address(this));
+ }
+
+ function testMetadataUsesSixDecimalMockUSDC() public view {
+ assertEq(token.name(), "Mock USD Coin");
+ assertEq(token.symbol(), "mUSDC");
+ assertEq(token.decimals(), 6);
+ }
+
+ function testInitialSupplyIsZero() public view {
+ assertEq(token.totalSupply(), 0);
+ }
+
+ function testMintMintsToRecipientWhenCalledByOwner() public {
+ token.mint(RECIPIENT, 1_250_000);
+
+ assertEq(token.totalSupply(), 1_250_000);
+ assertEq(token.balanceOf(RECIPIENT), 1_250_000);
+ }
+
+ function testMintRevertsWhenCalledByNonOwner() public {
+ vm.prank(STRANGER);
+ vm.expectRevert(abi.encodeWithSelector(Ownable.OwnableUnauthorizedAccount.selector, STRANGER));
+ token.mint(RECIPIENT, 1);
+ }
+
+ function testTransferMovesMintedBalance() public {
+ token.mint(address(this), 1_250_000);
+
+ token.transfer(RECIPIENT, 250_000);
+
+ assertEq(token.balanceOf(address(this)), 1_000_000);
+ assertEq(token.balanceOf(RECIPIENT), 250_000);
+ }
+
+ function testApproveSetsAllowanceForSpender() public {
+ token.approve(SPENDER, 750_000);
+
+ assertEq(token.allowance(address(this), SPENDER), 750_000);
+ }
+
+ function testConstructorRevertsForZeroInitialOwner() public {
+ vm.expectRevert(abi.encodeWithSelector(Ownable.OwnableInvalidOwner.selector, address(0)));
+ new MockUSDC(address(0));
+ }
+}
@@ -1,31 +0,0 @@
# Review package: eece34c088d52f1650baef34b51a8fa26ec12a00..a6f9533aacd4d6cdbbf9682bfd8482ced065ebf5
## Commits
a6f9533 build: approve pinned install scripts
## Files changed
package.json | 4 ++++
1 file changed, 4 insertions(+)
## Diff
diff --git a/package.json b/package.json
index 1612ecb..dd15769 100644
--- a/package.json
+++ b/package.json
@@ -3,12 +3,16 @@
"private": true,
"version": "0.1.0",
"type": "module",
"packageManager": "npm@11.17.0",
"engines": {
"node": ">=24.18.0 <25",
"npm": ">=11.17.0 <12"
},
"devDependencies": {
"@openzeppelin/upgrades-core": "1.46.0"
+ },
+ "allowScripts": {
+ "keccak@3.0.4": true,
+ "secp256k1@4.0.5": true
}
}
@@ -1,206 +0,0 @@
### Task 1: Pin and prove the repository toolchain
**Files:**
- Modify: `.gitignore`
- Create: `.nvmrc`
- Create: `.env.example`
- Create: `.gitmodules` (generated by `forge install`)
- Create: `foundry.lock` (generated by `forge install`)
- Create: `foundry.toml`
- Create: `remappings.txt`
- Create: `Makefile`
- Create: `package.json`
- Create: `package-lock.json` (generated by npm)
- Create: `tools/check-upgrades-cli.mjs`
- Create: `web/package.json`
- Create: `web/package-lock.json` (generated by npm)
- Create: `web/index.html`
- Create: `web/tsconfig.json`
- Create: `web/tsconfig.app.json`
- Create: `web/tsconfig.node.json`
- Create: `web/vite.config.ts`
- Create: `web/eslint.config.js`
- Create: `web/src/test/toolchain.test.ts`
**Interfaces:**
- Produces `make doctor`, `make setup`, and the initial verification gate that later tasks extend.
- Produces exact dependency revisions for every later Solidity and web task.
- Consumes no application source.
- [ ] Verify host tools and record the expected initial limitation:
```bash
forge --version
anvil --version
node --version
npm --version
make --version
```
Expected now: Foundry commands may be missing; Node/npm/make print their installed versions. If Foundry is missing during execution, request permission to install Foundry from its official installer, then pin with `foundryup -i 1.7.1`. Do not silently install system-wide tools.
- [ ] Extend `.gitignore` with exactly these runtime classes:
```gitignore
.env
.env.local
.demo/
cache/
out/
broadcast/
deployments/*.json
deployments/**/*.json
!deployments/*.example.json
node_modules/
web/node_modules/
web/dist/
web/coverage/
web/public/deployment.json
web/src/generated/*.ts
!.gitkeep
.superpowers/
```
- [ ] Pin Node/npm in `.nvmrc` and create a private root `package.json` with the same `packageManager`/`engines` fields plus exactly one dev dependency: `"@openzeppelin/upgrades-core": "1.46.0"`. Generate and commit the root lockfile with `npm install --save-exact`. This locally satisfies the plugins hard-coded `^1.45.0` range.
- [ ] Pin the browser dependencies in `web/package.json`; the package must be private and use only exact versions:
```json
{
"name": "uups-bank-operations-console",
"private": true,
"version": "0.1.0",
"type": "module",
"packageManager": "npm@11.17.0",
"engines": { "node": ">=24.18.0 <25", "npm": ">=11.17.0 <12" },
"scripts": {
"dev": "vite --host 127.0.0.1",
"lint": "eslint . --max-warnings 0",
"typecheck": "tsc -b --pretty false",
"test": "vitest run",
"build": "tsc -b && vite build"
}
}
```
Add exact runtime dependencies `@tanstack/react-query@5.101.4`, `react@19.2.8`, `react-dom@19.2.8`, `viem@2.55.8`, and `wagmi@3.7.5`. Add exact dev dependencies `@eslint/js@10.0.1`, `@testing-library/dom@10.4.1`, `@testing-library/react@16.3.2`, `@types/node@24.10.0`, `@types/react@19.2.14`, `@types/react-dom@19.2.4`, `@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 submodules pinned transitive copy supplies both canonical remappings.
- [ ] Create `tools/check-upgrades-cli.mjs`. It must assert that the plugin source contains `UPGRADES_CORE = "^1.45.0"`, the root lockfile resolves `@openzeppelin/upgrades-core` to exactly `1.46.0`, and the locally installed package reports `1.46.0`. Then prove the CLI can start with networking disabled:
```bash
npm ci
node tools/check-upgrades-cli.mjs
npm_config_offline=true npx @openzeppelin/upgrades-core@^1.45.0 validate --help
```
Expected: all commands exit `0` without fetching. Do not patch the vendored OpenZeppelin version constant.
- [ ] Create Vite/React/TypeScript/ESLint/Vitest configuration manually so no unpinned scaffold generator is executed. Configure jsdom, `web/src/test/setup.ts`, strict TypeScript, and React refresh. Run:
```bash
npm --prefix web install --save-exact
npm --prefix web test
```
Expected red: `toolchain.test.ts` cannot import the not-yet-created `src/config/toolchain.ts`.
- [ ] Create `web/src/config/toolchain.ts` exporting the display labels `Foundry 1.7.1`, `Solidity 0.8.35`, `OpenZeppelin 5.6.1`, and `UUPS`; make the test assert those exact values.
- [ ] Add an initial `Makefile` with shell safety (`SHELL := /bin/bash`, `.SHELLFLAGS := -euo pipefail -c`) and non-destructive targets:
```make
.PHONY: doctor setup verify
doctor:
@./tools/doctor.sh
setup:
@git submodule update --init --recursive
@npm ci
@npm --prefix web ci
verify:
@forge fmt --check
@forge clean
@npm_config_offline=true forge build --force
@npm_config_offline=true forge test --force
@npm --prefix web run lint
@npm --prefix web run typecheck
@npm --prefix web test
@npm --prefix web run build
```
Create a temporary minimal `tools/doctor.sh` that reports missing commands and exact expected versions without installing anything; Task 9 replaces it with full port/config checks.
- [ ] Run the green foundation checks:
```bash
forge fmt --check
forge clean
npm_config_offline=true forge build --force
npm_config_offline=true forge test --force
node tools/check-upgrades-cli.mjs
npm --prefix web run lint
npm --prefix web run typecheck
npm --prefix web test
npm --prefix web run build
```
Expected: all exit `0`; the empty Solidity source tree builds and the toolchain test passes.
- [ ] Commit the foundation:
```bash
git add .gitignore .nvmrc .env.example .gitmodules foundry.lock foundry.toml remappings.txt package.json package-lock.json Makefile tools/check-upgrades-cli.mjs tools/doctor.sh lib web
git commit -m "build: pin demo toolchains"
```
---
@@ -1,150 +0,0 @@
# Task 1 report: Pin and prove the repository toolchain
## Implemented
- Pinned the repository to Node `24.18.0` / npm `11.17.0`, Foundry `1.7.1`, Solidity `0.8.35`, and the required Foundry profiles.
- Added exact root and web dependency manifests and generated lockfiles. The root lock resolves `@openzeppelin/upgrades-core` to `1.46.0`.
- Installed and pinned Git submodules at `forge-std@v1.16.1`, `openzeppelin-foundry-upgrades@v0.4.1`, and `openzeppelin-contracts-upgradeable@v5.6.1`, including canonical remappings.
- Added offline upgrades-core verification, a non-destructive doctor script, Make targets, and a manually configured Vite/React/TypeScript/ESLint/Vitest harness.
- Added the first toolchain-label module and its Vitest coverage.
## Files changed
- `.gitignore`, `.nvmrc`, `.env.example`, `.gitmodules`, `foundry.lock`, `foundry.toml`, `remappings.txt`, `Makefile`
- `package.json`, `package-lock.json`, `tools/check-upgrades-cli.mjs`, `tools/doctor.sh`
- `lib/forge-std`, `lib/openzeppelin-foundry-upgrades`, `lib/openzeppelin-contracts-upgradeable`
- `web/package.json`, `web/package-lock.json`, `web/index.html`, `web/tsconfig.json`, `web/tsconfig.app.json`, `web/tsconfig.node.json`, `web/vite.config.ts`, `web/eslint.config.js`
- `web/src/config/toolchain.ts`, `web/src/test/setup.ts`, `web/src/test/toolchain.test.ts`
## TDD evidence
The test names the break it catches: a missing or incorrect operations-console toolchain label. Its expected object is hand-written, not derived from the production module.
### RED
Command:
```bash
npm --prefix web test
```
Result: exit `1`, with `Failed to resolve import "../config/toolchain" from "src/test/toolchain.test.ts"` and `0 test` execution. This was expected because `web/src/config/toolchain.ts` had not yet been created.
### GREEN
Created the minimal `toolchainLabels` export with the four required display values, then ran:
```bash
npm --prefix web test
```
Result: exit `0`; `1 passed` test file and `1 passed` test.
## Verification results
Initial host check: Foundry and Anvil were absent; Node was `v26.7.0`, npm `12.0.2`, and Make `4.4.1`. Installed Foundry through the official user-local installer and pinned it with `foundryup -i 1.7.1`; `forge --version` and `anvil --version` now report `1.7.1`.
The final complete verification command exited `0`:
```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
make verify
```
Observed results: Foundry reported the expected empty-tree `Nothing to compile`; the upgrades-core check reported `@openzeppelin/upgrades-core 1.46.0 is pinned and installed`; lint and typecheck exited `0`; Vitest reported `1 passed`; and Vite built `dist/index.html` successfully. `npm_config_offline=true npx @openzeppelin/upgrades-core@^1.45.0 validate --help` also exited `0` without fetching.
The restricted filesystem sandbox returns EROFS for `forge clean`; the same required commands exited `0` when run outside that mount restriction. This is an execution-environment limitation, not a repository failure.
## Self-review
- Confirmed the three top-level Gitlinks equal the tag revisions in `foundry.lock`; recursive nested submodules were synchronized after `forge install` initially left top-level Gitlinks at their default branch heads.
- Confirmed the upgrades script checks the unmodified vendored `UPGRADES_CORE = "^1.45.0"`, the root lockfile, and the installed package version.
- Confirmed generated output and node modules are ignored, and no generated output is staged.
- Kept TypeScript strict through `tsc -b`; ESLint does not import `typescript-eslint` because that exact package hard-fails under TypeScript 7.0.
## Concerns
1. The task-mandated `typescript@7.0.2` and `typescript-eslint@8.65.0` are upstream-incompatible. The latter declares TypeScript `<6.1.0` and throws when loaded under TypeScript 7. Official TypeScript 7 documentation states that TypeScript 7 ships without the compiler API and advises a side-by-side `@typescript/typescript6` alias for tools such as typescript-eslint. Changing to that official arrangement would violate the briefs required direct `typescript@7.0.2` pin, so it was not done. The ESLint config therefore uses ESLint core plus React rules, while strict TypeScript checking remains green through `tsc -b`.
2. The same peer conflict makes a clean `npm --prefix web ci` (including with pinned npm `11.17.0`) fail unless legacy peer resolution is supplied. The committed lockfile was generated with `npm --prefix web install --save-exact --legacy-peer-deps`; exact versions and the offline verification gate were not weakened. Consequently `make setup` inherits this known npm peer-resolution limitation on a clean install.
3. The current host is outside the pinned Node/npm engines (`v26.7.0` / `12.0.2`), so npm emits engine warnings during installation. The repository records the requested Node/npm pins; final lint/typecheck/test/build all pass on the available host.
## Compatibility resolution
The first two concerns above are resolved. Microsofts official TypeScript 7 announcement explains that TypeScript 7.0 ships without the compiler API and directs tooling such as typescript-eslint to a side-by-side TypeScript 6 API package. The package now uses only range-free aliases:
- `typescript`: `npm:@typescript/typescript6@6.0.2`, which supplies the API imported by `typescript-eslint@8.65.0`.
- `@typescript/native`: `npm:typescript@7.0.2`, which supplies the production compiler.
`typecheck` and `build` invoke `node ./node_modules/@typescript/native/bin/tsc` explicitly, so they cannot select an ambiguous binary. ESLint now imports and applies `typescript-eslint` again. The Tech Stack and Task 1 dependency wording in the implementation plan were updated under its stated compatibility-rejection condition.
### Resolution RED
Before the alias change, the clean pinned-runtime command failed as expected:
```bash
npx --yes npm@11.17.0 --prefix web ci
```
Result: exit `1` with `ERESOLVE`; `typescript-eslint@8.65.0` required `typescript >=4.8.4 <6.1.0` while the direct package was TypeScript `7.0.2`.
### Resolution GREEN
After the alias change, all of these exited `0` without `legacy-peer-deps`:
```bash
npx --yes npm@11.17.0 --prefix web ci
node -p "require('./web/node_modules/typescript/package.json').version"
node ./web/node_modules/@typescript/native/bin/tsc --version
```
Observed output: TypeScript API alias `6.0.2` and compiler `Version 7.0.2`. With npm `11.17.0` placed first on `PATH`, both `make setup` and `make verify` exited `0`; the latter passed ESLint with typescript-eslint enabled, strict typecheck, Vitest (`1 passed`), and Vite build. The final standalone Foundry/offline CLI/web gate also exited `0`.
## Fix Round 1: exact Node runtime proof
### Runtime installation and version proof
Downloaded the official Node `v24.18.0` Linux archive to `/tmp`, verified it against Nodes published `SHASUMS256.txt`, and installed it outside repository state at `/tmp/codius-node-v24.18.0`. Its bundled npm was `11.16.0`, so npm `11.17.0` was installed exactly into that same temporary prefix. With that prefix first on `PATH`, the proof output was:
```text
node: v24.18.0
npm: 11.17.0
forge: forge Version: 1.7.1
anvil: anvil Version: 1.7.1
```
### Warning investigation and correction
The first clean exact-runtime setup emitted npm 11.17s `allow-scripts` advisory for `keccak@3.0.4` and `secp256k1@4.0.5`. Both packages declare the reviewed install command `node-gyp-build || exit 0`. npm 11.17s `approve-scripts` documentation identifies `package.json` `allowScripts` as the intended project policy, so the manifest now records exact-version approvals for those two packages. No dependency version or install mode changed.
### Covering commands and results
Covering test file: `web/src/test/toolchain.test.ts` (Vitest: `1 passed`).
With `PATH=/tmp/codius-node-v24.18.0/bin:/home/golem/.foundry/bin:$PATH`, these all exited `0` from a clean install:
```bash
make doctor
make setup
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_config_offline=true npx @openzeppelin/upgrades-core@^1.45.0 validate --help
npm --prefix web run lint
npm --prefix web run typecheck
npm --prefix web test
npm --prefix web run build
node ./web/node_modules/@typescript/native/bin/tsc --version
make verify
```
`make setup` completed without npm warnings on the exact Node/npm pair. The explicit compiler command printed `Version 7.0.2`; the offline CLI printed its help without fetching; Foundry reported the expected zero-source `Nothing to compile`; lint/typecheck passed; and the Vite build succeeded. `forge fmt --check` retains Foundrys informational empty-tree `Nothing to format` notice because this foundation task intentionally has no Solidity sources.
@@ -1,52 +0,0 @@
### Task 2: Build the valueless six-decimal mock asset with TDD
**Files:**
- Create: `src/MockUSDC.sol`
- Create: `test/MockUSDC.t.sol`
**Interfaces:**
- Produces `MockUSDC.mint(address,uint256)`, `decimals() == 6`, and standard ERC-20 behavior.
- Consumed by the bank tests, deployment scripts, and dashboard ABI export.
- [ ] Write `test/MockUSDC.t.sol` first. Cover the exact name/symbol/decimals, owner-only mint, successful mint, transfer/approve behavior inherited from ERC-20, and zero initial supply. Use `vm.expectRevert(abi.encodeWithSelector(Ownable.OwnableUnauthorizedAccount.selector, stranger))` for non-owner minting.
- [ ] Run the focused test and observe red:
```bash
forge test --match-path test/MockUSDC.t.sol -vvv --force
```
Expected red: `src/MockUSDC.sol` is missing.
- [ ] Implement only the tested contract:
```solidity
/// @notice Educational mock token with no monetary value. Never use as real USDC.
contract MockUSDC is ERC20, Ownable {
constructor(address initialOwner) ERC20("Mock USD Coin", "mUSDC") Ownable(initialOwner) {}
function decimals() public pure override returns (uint8) { return 6; }
function mint(address to, uint256 amount) external onlyOwner { _mint(to, amount); }
}
```
Reject a zero `initialOwner` through OpenZeppelins `OwnableInvalidOwner` behavior. Do not add faucet, burn, permit, blacklist, proxy, or bank-specific logic.
- [ ] Run red-to-green verification:
```bash
forge fmt
forge test --match-path test/MockUSDC.t.sol -vvv --force
```
Expected: all mock-token tests pass.
- [ ] Commit:
```bash
git add src/MockUSDC.sol test/MockUSDC.t.sol
git commit -m "feat: add valueless mock USDC"
```
---
@@ -1,133 +0,0 @@
# Task 2 Report: Valueless Six-Decimal Mock Asset
## Implementation
Added `MockUSDC`, an educational ERC-20 mock with no monetary value. It uses OpenZeppelin `ERC20` and `Ownable`, initializes the token as `Mock USD Coin` (`mUSDC`), overrides `decimals()` to return `6`, and permits `mint(address,uint256)` only to the configured owner. OpenZeppelin rejects `address(0)` as the initial owner via `OwnableInvalidOwner`.
## Changed Files
- `src/MockUSDC.sol` (new): minimal six-decimal owner-mintable ERC-20 mock.
- `test/MockUSDC.t.sol` (new): focused tests for metadata, zero supply, authorized/unauthorized minting, transfer, approval, and zero-owner rejection.
## TDD Evidence
Before writing production code, the test suite was added. The production change that each test guards was identified as a missing or incorrect ERC-20 observable behavior: metadata/decimals, initial supply, owner authorization and mint balances, transfer balance movement, approval allowance, or constructor owner validation.
### RED
Command:
```bash
PATH=/home/golem/.foundry/bin:$PATH forge test --match-path test/MockUSDC.t.sol -vvv --force
```
Relevant output:
```text
Error (6275): Source "src/MockUSDC.sol" not found: File not found.
--> test/MockUSDC.t.sol:6:1:
Error: Compilation failed
```
This was the expected RED result: the test imports the intentionally absent `src/MockUSDC.sol`; it failed because the requested feature did not yet exist, not because of a test typo or unrelated failure.
### GREEN
Commands:
```bash
PATH=/home/golem/.foundry/bin:$PATH forge fmt
PATH=/home/golem/.foundry/bin:$PATH forge test --match-path test/MockUSDC.t.sol -vvv --force
```
Relevant output:
```text
Ran 7 tests for test/MockUSDC.t.sol:MockUSDCTest
Suite result: ok. 7 passed; 0 failed; 0 skipped
```
## Full Relevant Suite
Command:
```bash
PATH=/home/golem/.foundry/bin:$PATH forge test -vvv
```
Result:
```text
Ran 1 test suite: 7 tests passed, 0 failed, 0 skipped (7 total tests)
```
`git diff --check` also completed without reporting whitespace errors.
## Self-Review
- Implementation contains only the briefed `ERC20`, `Ownable`, `decimals`, and owner-only `mint` behavior; no faucet, burn, permit, blacklist, proxy, or bank-specific logic was added.
- Tests exercise the deployed contract's observable state and OpenZeppelin's real authorization behavior; no mocks were used.
- The non-owner case uses the required exact `Ownable.OwnableUnauthorizedAccount` selector and `stranger` address.
- Mutations such as changing metadata/decimals, allowing arbitrary minting, omitting minting, misrouting transfers, failing to set allowance, or accepting a zero owner are covered by at least one test.
## Concerns
None. The report itself is intentionally left uncommitted because the task required committing only `src/MockUSDC.sol` and `test/MockUSDC.t.sol`.
## Fix Round 1: Literal Command Evidence
Foundry was placed on `PATH` before each command session with a separate shell environment setup step. The commands below were then executed literally, with no inline `PATH` prefix.
### Isolated RED Reproduction
An isolated temporary filesystem copy of the complete worktree (including the already-installed pinned dependencies) was created under `/tmp`, and only `src/MockUSDC.sol` was removed from that copy. This keeps the real committed worktree untouched while preserving the same test file and dependency graph.
Covering test file: `test/MockUSDC.t.sol`
Literal command:
```bash
forge test --match-path test/MockUSDC.t.sol -vvv --force
```
Relevant output:
```text
Error (6275): Source "src/MockUSDC.sol" not found: File not found.
--> test/MockUSDC.t.sol:6:1:
Error: Compilation failed
```
This RED result is correct because the isolated state retains the new test but deliberately lacks the production contract it imports. The failure is therefore the expected feature-missing failure, rather than a dependency, compiler, or test error.
### Real-Worktree GREEN
Literal commands:
```bash
forge fmt
forge test --match-path test/MockUSDC.t.sol -vvv --force
```
Relevant output:
```text
Ran 7 tests for test/MockUSDC.t.sol:MockUSDCTest
Suite result: ok. 7 passed; 0 failed; 0 skipped
```
### Full Relevant Suite
Literal command:
```bash
forge test -vvv
```
Result:
```text
Ran 1 test suite: 7 tests passed, 0 failed, 0 skipped (7 total tests)
```
No tracked implementation or test file changed during this correction, so no tracked commit was needed. Commit `1f4175b feat: add valueless mock USDC` remains the sole task commit.
@@ -1,99 +0,0 @@
### 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"
```
---
@@ -1,132 +0,0 @@
# Task 3 Report: V1 proxy, initialization, views, and administration
## Status
Complete. BankV1 now provides the initialized UUPS proxy shell, frozen V1 application storage, administrative pause controls, read-only accounting views, version reporting, and owner-gated upgrade authorization. The final implementation uses OpenZeppelin 5.6.1 `ReentrancyGuardTransient`, as explicitly selected by the user after the pinned validator incompatibility investigation described below.
## Implementation
- Added `BankV1` with inheritance in the required order: `Initializable`, `UUPSUpgradeable`, `OwnableUpgradeable`, `PausableUpgradeable`, then the approved constructor-free `ReentrancyGuardTransient`.
- Kept the V1 application fields in the frozen order: `_asset`, `_balances`, `_totalLiabilities`, then `uint256[47] __gap`.
- Disabled initialization on the implementation constructor with the sole validator annotation `@custom:oz-upgrades-unsafe-allow constructor`.
- Added `initialize(address,address)`, checking the asset before invoking only `__Ownable_init(initialOwner)` and `__Pausable_init()`.
- Added owner-only `pause()` and `unpause()`.
- Added `asset()`, `balanceOf(address)`, `totalLiabilities()`, and virtual pure `contractVersion()` returning literal `1`.
- Added owner-gated `_authorizeUpgrade(address)`.
- Added a reusable proxy fixture with deterministic `owner`, `alice`, `bob`, and `stranger` addresses, a `MockUSDC`, `Upgrades.deployUUPSProxy`, proxy-bound `bank`, and implementation address retained only for assertions.
- Added 12 behavior tests covering successful initialization, validation order, disabled/double initialization, ownership and pause administration, paused views, upgrade authorization, and direct-versus-proxy UUPS context.
- Updated the implementation plan's global, Task 3, scanner, and source-guard references to record the explicitly approved transient-guard design and restore the single ordinary constructor annotation.
## Files
- `src/BankV1.sol` — V1 UUPS implementation shell.
- `test/helpers/BankTestBase.sol` — reusable proxy deployment fixture.
- `test/BankV1Admin.t.sol` — initialization, administration, views, and UUPS-context tests.
- `docs/superpowers/plans/2026-08-17-uups-bank-demo.md` — narrowly authorized compatibility/design update.
- `.superpowers/sdd/2026-08-17-uups-bank-demo/task-3-report.md` — this ignored task report; not part of the commit.
## TDD RED evidence
The required tool binaries were placed on `PATH` in a separate persistent-shell setup. The literal RED command was then issued exactly as briefed:
```bash
npm_config_offline=true forge test --match-path test/BankV1Admin.t.sol -vvv --force
```
Relevant output:
```text
Compiler run failed:
Error (6275): Source "src/BankV1.sol" not found: File not found.
--> test/BankV1Admin.t.sol:9:1
Error (6275): Source "src/BankV1.sol" not found: File not found.
--> test/helpers/BankTestBase.sol:7:1
Error: Compilation failed
```
This was the expected RED: the tests and fixture referenced the required production API before `BankV1` existed, and failed specifically because the feature was missing.
## Validator incompatibility investigation and rulings
The first literal GREEN attempt used the brief's original non-upgradeable `ReentrancyGuard` and sole local `@custom:oz-upgrades-unsafe-allow constructor`. Solidity compiled, but Foundry Upgrades validation rejected the inherited dependency:
```text
Upgrade safety validation failed:
lib/openzeppelin-contracts-upgradeable/lib/openzeppelin-contracts/contracts/utils/ReentrancyGuard.sol:58:
Contract `ReentrancyGuard` has a constructor
```
Investigation established:
- Pinned OpenZeppelin Contracts is 5.6.1 and marks `ReentrancyGuard` `@custom:stateless`, but that guard still has a constructor that initializes its namespaced guard slot.
- Pinned upgrades-core is 1.46.0 and contains no support for the `@custom:stateless` annotation.
- upgrades-core aggregates constructor errors from inherited contracts.
The first user ruling approved replacing the local annotation with `@custom:oz-upgrades-unsafe-allow-reachable constructor`. The exact change was made and the literal focused command rerun, but validation failed identically. Source-level tracing then established that upgrades-core 1.46.0 uses `skipCheckReachable` only for opcode traversal (`delegatecall`/`selfdestruct`); `getConstructorErrors` checks annotations lexically on each constructor node, so an annotation on `BankV1` cannot suppress an inherited constructor finding.
No `Options.unsafeAllow`, exclude, skip, `UnsafeUpgrades`, dependency edit, or other workaround was applied. After that evidence, the user's final ruling selected OpenZeppelin 5.6.1 `ReentrancyGuardTransient` and restored the ordinary sole constructor annotation. This guard has no constructor or persistent application storage and is appropriate for this project's explicitly Cancun-targeted Anvil and Base Sepolia networks, where EIP-1153 is available.
One subsequent test-harness issue was also corrected: `vm.expectRevert` placed before `Upgrades.deployUUPSProxy` intercepted the library's preliminary implementation deployment. The invalid-initializer tests now construct a real `ERC1967Proxy` against the already validated implementation, making the next external operation the intended initializer delegatecall and preserving exact custom-error assertions.
## GREEN and verification evidence
The brief's literal GREEN sequence was run in the same shell after the separate PATH setup:
```bash
forge fmt
npm_config_offline=true forge test --match-path test/BankV1Admin.t.sol -vvv --force
forge inspect BankV1 storage-layout
```
Final focused result:
```text
Ran 12 tests for test/BankV1Admin.t.sol:BankV1AdminTest
Suite result: ok. 12 passed; 0 failed; 0 skipped
```
Storage inspection result:
```text
_asset IERC20 slot 0 offset 0
_balances mapping(address => uint256) slot 1 offset 0
_totalLiabilities uint256 slot 2 offset 0
__gap uint256[47] slot 3 offset 0 (1504 bytes)
```
The final full validation-bearing suite was run offline:
```bash
npm_config_offline=true forge test -vvv --force
```
Result:
```text
Ran 2 test suites: 19 tests passed, 0 failed, 0 skipped
```
Both focused and full commands compiled successfully with only two existing dependency warnings in `openzeppelin-foundry-upgrades` about functions whose mutability could be `pure`; no project warning or error was emitted.
## Storage and upgrade safety notes
- Application storage is exactly `_asset` at slot 0, `_balances` at slot 1, `_totalLiabilities` at slot 2, and the 47-word gap beginning at slot 3. Later versions must not reorder, remove, or change the type of these fields.
- OpenZeppelin ownership and pause state use ERC-7201 namespaced storage. UUPS, Initializable, and the approved transient reentrancy guard do not consume ordinary application slots; this does not permit reordering the frozen application fields.
- Every ordinary bank call in the fixture and tests targets `BankV1(proxy)`. The implementation address is used only to assert disabled initialization and direct `proxiableUUID()` behavior, as required.
- `proxiableUUID()` returns the literal ERC-1967 implementation slot on the implementation and rejects proxy-context calls with `UUPSUnauthorizedCallContext`.
- `_authorizeUpgrade` is covered through the public proxy `upgradeToAndCall` boundary and rejects a non-owner with `OwnableUnauthorizedAccount`.
- The transient guard restricts supported execution environments to EIP-1153-capable networks. That is an explicit design constraint, not a hidden fallback.
## Self-review
- Reviewed all four committed diffs and ran `git diff --check`; no whitespace errors were found.
- Confirmed the fixture uses the exact `Upgrades.deployUUPSProxy("BankV1.sol:BankV1", abi.encodeCall(...))` form and binds all application behavior to the proxy.
- Confirmed only one source validator annotation exists and no `unsafeSkip`, `UnsafeUpgrades`, Options `unsafeAllow`, exclude, reachable annotation, or dependency edit exists in Task 3 code/tests.
- Strengthened the zero-asset test to pass both zero asset and zero owner, so it proves the mandated asset-check ordering; a mutation that invokes ownable initialization first now fails that test.
- Mentally checked realistic mutations: wrong/missing asset assignment, wrong version, missing owner initialization, missing pause authorization/state change, pausing views, enabled reinitialization, implementation initialization, missing upgrade authorization, or broken UUPS call context each fail at least one test.
- No deposit or withdrawal behavior was added.
## Concerns
- `ReentrancyGuardTransient` requires EIP-1153/Cancun. The approved project targets (Anvil with `evm_version = "cancun"` and Base Sepolia) satisfy this, but deploying this implementation on a pre-Cancun EVM is unsupported.
- Existing dependency-only compiler mutability warnings remain; they do not originate from Task 3 files.
@@ -1,96 +0,0 @@
### Task 4: Implement V1 custody accounting and adversarial unit tests
**Files:**
- Modify: `src/BankV1.sol`
- Create: `test/BankV1.t.sol`
- Create: `test/mocks/FeeOnTransferToken.sol`
- Create: `test/mocks/ReentrantToken.sol`
**Interfaces:**
- Produces `deposit(uint256)` and `withdraw(uint256)` plus `Deposited`, `Withdrawn`, `ZeroAmount`, `InsufficientBalance`, and `UnexpectedAssetDelta`.
- Consumes only a standard non-rebasing ERC-20 through `IERC20`/`SafeERC20`.
- Preserves `reserves >= totalLiabilities`; direct token transfers may create surplus.
- [ ] Write event and error declarations into the test expectations before production code:
```solidity
event Deposited(address indexed account, uint256 amount);
event Withdrawn(address indexed account, uint256 amount);
error ZeroAmount();
error InsufficientBalance(address account, uint256 available, uint256 requested);
error UnexpectedAssetDelta(uint256 expected, uint256 actual);
```
- [ ] Write failing deposit tests for exact balance/liability/reserve deltas, event emission, zero amount, paused state, inadequate allowance, inadequate wallet balance, and two independent customers. Use six-decimal constants (`1_000e6`) so assertions read like the demo.
- [ ] Add `FeeOnTransferToken` whose `transferFrom` delivers `99%` of the requested amount. Assert a deposit reverts with `UnexpectedAssetDelta(requested, received)` and that the ERC-20 transfer, bank balance, and liabilities are all rolled back.
- [ ] Add `ReentrantToken` that attempts a nested `bank.deposit` during `transferFrom`. Assert the nested call receives `ReentrancyGuardReentrantCall` and the outer call either completes once or reverts atomically according to the mocks configured propagation mode; no double credit is permitted.
- [ ] Write failing withdrawal tests for exact deltas, event emission, zero amount, paused state, insufficient internal balance (including available/requested values), checks-effects-interactions under a transfer callback, and one accounts withdrawal leaving another account unchanged.
- [ ] Write the surplus test: deposit 100 mUSDC, transfer 25 mUSDC directly to the proxy, assert reserves `125e6`, liabilities `100e6`, and a normal 100 mUSDC withdrawal succeeds while the 25 mUSDC surplus remains. Assert there is no owner sweep/rescue behavior by keeping such a function out of the interface.
- [ ] Run red tests:
```bash
npm_config_offline=true forge test --match-path test/BankV1.t.sol -vvv --force
```
Expected red: `deposit`/`withdraw` and their errors/events are absent.
- [ ] Implement `deposit` with exact received-amount validation and state credit only after a successful transfer:
```solidity
function deposit(uint256 amount) external whenNotPaused nonReentrant {
if (amount == 0) revert ZeroAmount();
uint256 reservesBefore = _asset.balanceOf(address(this));
_asset.safeTransferFrom(msg.sender, address(this), amount);
uint256 reservesAfter = _asset.balanceOf(address(this));
uint256 received = reservesAfter >= reservesBefore ? reservesAfter - reservesBefore : 0;
if (received != amount) revert UnexpectedAssetDelta(amount, received);
_balances[msg.sender] += amount;
_totalLiabilities += amount;
emit Deposited(msg.sender, amount);
}
```
- [ ] Implement `withdraw` with checks-effects-interactions:
```solidity
function withdraw(uint256 amount) external whenNotPaused nonReentrant {
if (amount == 0) revert ZeroAmount();
uint256 available = _balances[msg.sender];
if (amount > available) revert InsufficientBalance(msg.sender, available, amount);
_balances[msg.sender] = available - amount;
_totalLiabilities -= amount;
_asset.safeTransfer(msg.sender, amount);
emit Withdrawn(msg.sender, amount);
}
```
- [ ] Add fuzz tests in `BankV1.t.sol`: bound deposit to `[1, 1_000_000e6]`; bound withdrawal to `[1, deposited]`; prove exact reserve/liability/customer deltas and that over-withdraw always reverts. Keep a fixed seed in `foundry.toml` for presentation reproducibility while printing Foundrys replay seed on failure.
- [ ] Run focused and aggregate green tests:
```bash
forge fmt
npm_config_offline=true forge test --match-path test/BankV1.t.sol -vvv --force
npm_config_offline=true forge test --match-path 'test/BankV1*.t.sol' --force
```
Expected: all V1 tests pass with validated proxy deployment enabled.
- [ ] Commit:
```bash
git add src/BankV1.sol test/BankV1.t.sol test/mocks/FeeOnTransferToken.sol test/mocks/ReentrantToken.sol
git commit -m "feat: add V1 custody accounting"
```
---
@@ -1,133 +0,0 @@
# Task 4 Report: V1 Custody Accounting and Adversarial Unit Tests
## Status
Complete. `BankV1` now supports exact-accounting deposits and CEI withdrawals behind the existing UUPS proxy. The focused custody suite, aggregate V1 suite, and full offline suite pass.
## Implementation
- Added `Deposited` and `Withdrawn` events and the exact `ZeroAmount`, `InsufficientBalance`, and `UnexpectedAssetDelta` errors.
- Added `deposit(uint256)` guarded by `whenNotPaused` and `nonReentrant`. It measures proxy reserves before and after `SafeERC20.safeTransferFrom`, rejects any non-exact received amount, and credits customer balance/liabilities only after the transfer and delta validation succeed.
- Added `withdraw(uint256)` guarded by `whenNotPaused` and `nonReentrant`. It validates zero/available balance, debits customer balance and liabilities before the external `SafeERC20.safeTransfer`, and emits the withdrawal event.
- Preserved application storage exactly: `_asset` slot 0, `_balances` slot 1, `_totalLiabilities` slot 2, and `uint256[47] __gap` slot 3.
- Kept all application behavior proxy-bound and added no sweep/rescue interface.
## Changed Files
- `src/BankV1.sol` — custody API, errors/events, SafeERC20 accounting.
- `test/BankV1.t.sol` — 18 deterministic and 3 fuzz custody tests.
- `test/mocks/FeeOnTransferToken.sol` — six-decimal ERC-20 whose `transferFrom` delivers 99% and burns the remainder.
- `test/mocks/ReentrantToken.sol` — six-decimal ERC-20 with configurable deposit/withdraw callbacks, revert propagation, nested-call result/selector recording, and callback-time accounting observations.
## TDD RED
Tool paths were set separately in a dedicated shell:
```bash
export PATH=/tmp/codius-node-v24.18.0/bin:/home/golem/.foundry/bin:$PATH
```
The required RED command was run verbatim:
```bash
npm_config_offline=true forge test --match-path test/BankV1.t.sol -vvv --force
```
The first invocation found a Solidity tuple-declaration syntax error in the new `ReentrantToken` test double. Only that test-double syntax was corrected; production remained unchanged. The command was rerun verbatim and produced the valid expected RED:
```text
Compiler run failed:
Error (9582): Member "deposit" not found or not visible after argument-dependent lookup in contract BankV1.
--> test/BankV1.t.sol:28:9:
28 | bank.deposit(100e6);
| ^^^^^^^^^^^^
Error: Compilation failed
```
This RED was expected because the custody tests called the required proxy-bound `deposit`/`withdraw` API before those functions existed. It therefore failed for the missing production behavior rather than a malformed assertion or environment problem.
## GREEN Verification
All upgrade-validation-bearing commands were kept offline.
```bash
forge fmt
```
Output: completed successfully and formatted `test/BankV1.t.sol` on the initial run; the final run completed with no further formatting changes.
```bash
npm_config_offline=true forge test --match-path test/BankV1.t.sol -vvv --force
```
Final output summary:
```text
Ran 21 tests for test/BankV1.t.sol:BankV1CustodyTest
Suite result: ok. 21 passed; 0 failed; 0 skipped
Fuzz tests: 512 runs each
```
```bash
npm_config_offline=true forge test --match-path 'test/BankV1*.t.sol' --force
```
Final output summary:
```text
BankV1AdminTest: 12 passed; 0 failed; 0 skipped
BankV1CustodyTest: 21 passed; 0 failed; 0 skipped
Ran 2 test suites: 33 passed, 0 failed, 0 skipped
```
Full-suite command:
```bash
npm_config_offline=true forge test --force
```
Final output summary:
```text
BankV1AdminTest: 12 passed
MockUSDCTest: 7 passed
BankV1CustodyTest: 21 passed
Ran 3 test suites: 40 passed, 0 failed, 0 skipped
```
Solc emitted only two pre-existing dependency warnings in `openzeppelin-foundry-upgrades` about functions whose mutability could be `pure`; there were no project warnings or test failures.
Storage inspection:
```bash
forge inspect src/BankV1.sol:BankV1 storage-layout
```
Output confirmed `_asset` at slot 0, `_balances` at slot 1, `_totalLiabilities` at slot 2, and `__gap` at slot 3.
## Accounting and Adversarial Coverage
- Exact wallet, reserve, customer balance, and total-liability deltas for deposit and withdrawal.
- Indexed event emission for each custody operation.
- Zero amount and paused-state rejection for both operations.
- Exact ERC-6093 inadequate allowance and wallet-balance failures, with empty bank accounting after revert.
- Independent deposits for Alice and Bob and a withdrawal that leaves the other customer's balance unchanged.
- Fee-on-transfer deposit rejection with `UnexpectedAssetDelta(100e6, 99e6)` and atomic rollback of sender balance, bank reserves, customer credit, and liabilities.
- Deposit callback observes zero customer credit/liabilities before transfer completion, attempts nested deposit, and records `ReentrancyGuardReentrantCall`; swallowing the nested revert credits exactly once, while configured propagation atomically reverts the outer deposit.
- Withdrawal callback observes already-debited customer balance/liabilities, attempts nested withdrawal, records `ReentrancyGuardReentrantCall`, and cannot double debit.
- Direct-transfer surplus: 100 mUSDC liability plus 25 mUSDC direct transfer yields 125 mUSDC reserves; withdrawing the full 100 mUSDC customer balance leaves the 25 mUSDC surplus.
- Deterministic fuzzing uses the existing fixed seed, bounds deposits to `[1, 1_000_000e6]`, bounds withdrawals to `[1, deposited]`, proves exact deltas, and proves every bounded over-withdraw reverts with exact available/requested values.
## Self-Review
- `git diff --check` passed.
- Diff inspection confirmed no persistent storage changes and no owner sweep/rescue behavior.
- Mutation review: tests fail for missing credit/debit/liability changes, incorrect transfer amount or event, removed zero/pause/available checks, fee-token over-credit, credit-before-deposit-transfer, interaction-before-withdraw-effects, absent reentrancy guard, customer cross-account corruption, or surplus consumption.
- Independent read-only review reported no Critical, Important, or Minor findings and assessed the change ready to merge. The reviewer independently saw 21/21 focused and 33/33 aggregate tests pass and confirmed formatting/diff checks.
- Only the four task-listed source/test files are staged for the commit; this report is intentionally outside that scoped commit.
## Concerns
- The bank intentionally supports only standard non-rebasing ERC-20 assets. Outbound transfer-fee/rebasing behavior is outside V1's stated contract.
- `ReentrancyGuardTransient` requires Cancun/EIP-1153, matching the project's configured target.
- The two compiler warnings originate in the pinned `openzeppelin-foundry-upgrades` dependency and are unrelated to this change.
@@ -1,72 +0,0 @@
### 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"
```
---
@@ -1,219 +0,0 @@
# Task 5 Report: V1 Stateful Accounting Invariants
## Status
Complete. Commit `6dcbb03` (`test: prove V1 accounting invariants`) adds only the two task-listed test files. Production contracts were not modified.
## Handler design
- `BankHandler` owns four fixed immutable actor addresses (`0x1001` through `0x1004`) and exposes the bounded actor set through `actorCount()` and `actorAt()`.
- `deposit` selects an actor from the seed, bounds the amount to `[1, 10_000e6]`, mints test liquidity, performs actor approval/deposit inside a balanced prank scope, and increments `ghostDeposited` only after success.
- `withdraw` selects an actor, returns early only for a zero bank balance, bounds the amount to `[1, balance]`, performs the withdrawal inside a balanced prank scope, and increments `ghostWithdrawn` only after success.
- `donate` selects an actor, bounds the amount to `[1, 1_000e6]`, mints test liquidity, directly transfers it to the proxy inside a balanced prank scope, and increments `ghostDonated` without touching liabilities.
- In setup, `MockUSDC` ownership transfers from the token owner to the handler. Bank ownership remains unchanged.
- The handler is registered with `targetContract(address(handler))`, and `targetSelector` restricts generated calls to exactly `deposit`, `withdraw`, and `donate`.
## Invariant derivations
1. Ledger conservation: the test independently enumerates the four actors and sums `bank.balanceOf(actor)`. That sum must equal `bank.totalLiabilities()`.
2. Solvency: the token's actual proxy balance must be greater than or equal to total liabilities. Direct donations create only surplus.
3. Independent flow accounting:
- `ghostDeposited - ghostWithdrawn == totalLiabilities` because only customer custody flows create or extinguish liabilities.
- `ghostDeposited + ghostDonated - ghostWithdrawn == proxy token reserves` because donations increase reserves without increasing liabilities.
Ghost counters record successful handler inputs, while expected chain state is read from the real proxy and token. The handler does not mirror per-account bank balances, and the invariant test—not the handler—sums on-chain actor balances.
## Changed files
- `test/helpers/BankHandler.sol`: bounded V1 stateful action handler and independent ghost counters.
- `test/BankInvariant.t.sol`: handler setup, selector targeting, and three conservation/solvency invariants.
## TDD evidence
PATH was set in a separate persistent shell step:
```text
export PATH=/tmp/codius-node-v24.18.0/bin:/home/golem/.foundry/bin:$PATH
```
### RED
Exact command:
```text
npm_config_offline=true forge test --match-path test/BankInvariant.t.sol -vvv --force
```
Output before either requested file existed:
```text
[⠒] Compiling 28 files with Solc 0.8.35
Compiler run successful!
No tests found in project! Forge looks for functions that start with `test`
```
This was the expected feature-absent failure: no invariant target or handler existed.
### GREEN
Exact command:
```text
npm_config_offline=true forge test --match-path test/BankInvariant.t.sol -vvv --force
```
Key verbatim output:
```text
Ran 3 tests for test/BankInvariant.t.sol:BankInvariantTest
[PASS] invariant_ghostAccountingMatchesChain() (runs: 128, calls: 8192, reverts: 0)
[PASS] invariant_liabilitiesEqualTrackedBalances() (runs: 128, calls: 8192, reverts: 0)
[PASS] invariant_reservesCoverLiabilities() (runs: 128, calls: 8192, reverts: 0)
| Contract | Selector | Calls | Reverts | Discards |
| BankHandler | deposit | 2666 | 0 | 0 |
| BankHandler | donate | 2791 | 0 | 0 |
| BankHandler | withdraw | 2735 | 0 | 0 |
Suite result: ok. 3 passed; 0 failed; 0 skipped; finished in 3.43s (4.86s CPU time)
Ran 1 test suite in 3.43s (3.43s CPU time): 3 tests passed, 0 failed, 0 skipped (3 total tests)
```
The configured `128` runs and depth `64` produced `8,192` calls per invariant. The fixed seed yielded the same selector counts in the later full-suite run, proving all three actions were exercised.
## Formatting and full-suite validation
Formatting check after applying `forge fmt`:
```text
forge fmt --check test/helpers/BankHandler.sol test/BankInvariant.t.sol
```
Output: empty, exit success.
Full relevant suite command:
```text
npm_config_offline=true forge test -vvv --force
```
Result:
```text
Ran 4 test suites in 6.23s (12.11s CPU time): 43 tests passed, 0 failed, 0 skipped (43 total tests)
```
The invariant portion again passed all three properties for `128` runs and `8,192` calls each, with `2,666` deposits, `2,791` donations, `2,735` withdrawals, and zero reverts/discards. Compilation emitted only two pre-existing OpenZeppelin Foundry Upgrades mutability warnings in `StringFinder.sol` and `DefenderDeploy.sol`.
## Mutation and self-review
Mentally checked realistic mutations:
- Omitting or mis-sizing a deposit/withdraw liability update breaks the ghost-liability equality.
- Updating aggregate liabilities without the selected account balance (or vice versa) breaks the summed-ledger equality.
- Failing to transfer reserves on deposit/withdraw breaks reserve ghost accounting and may break solvency.
- Treating donations as liabilities breaks both the ghost-liability equation and the intended surplus model.
- Draining or under-crediting proxy reserves breaks exact reserve ghost accounting and the solvency inequality.
`git diff --check` and the final formatting check passed. Pre-commit status contained only the two task-listed files. The commit contains exactly those files and 122 inserted test lines. No production files changed.
## Concerns
None. The only warnings are existing third-party mutability warnings noted above.
## Fix Round 1: Genuine handler-absent RED evidence
The original RED evidence above is superseded by this fix round. A missing invariant test only established that Forge had no matching tests; it did not establish that the invariant specification failed when the handler feature was absent.
### Covering test state
An isolated temporary project was created at `/tmp/task-5-red.dLa7CJ`. It contained the committed `test/BankInvariant.t.sol` unchanged but deliberately excluded `test/helpers/BankHandler.sol`. The real worktree remained at commit `6dcbb03f3c79979416c2f0dd37556539276a4e29`, with its tracked files untouched.
Exact isolation command:
```text
rsync -a --exclude=.git --exclude=out --exclude=cache --exclude=test/helpers/BankHandler.sol ./ /tmp/task-5-red.dLa7CJ/
```
Precondition checks confirmed:
```text
isolated invariant test: present
isolated handler: absent
```
PATH was set separately in the isolated shell:
```text
export PATH=/tmp/codius-node-v24.18.0/bin:/home/golem/.foundry/bin:$PATH
```
### Genuine RED
The brief's command was then run literally in the isolated project:
```text
npm_config_offline=true forge test --match-path test/BankInvariant.t.sol -vvv --force
```
Relevant verbatim output:
```text
Compiler run failed:
Error (6275): Source "test/helpers/BankHandler.sol" not found: File not found. Searched the following locations: "/tmp/task-5-red.dLa7CJ".
ParserError: Source "test/helpers/BankHandler.sol" not found: File not found. Searched the following locations: "/tmp/task-5-red.dLa7CJ".
--> test/BankInvariant.t.sol:5:1:
|
5 | import {BankHandler} from "./helpers/BankHandler.sol";
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
Error: Compilation failed
```
Exit status was captured immediately afterward with `echo $?`:
```text
1
```
This is a genuine RED because the completed invariant test is present and compilation fails specifically at its dependency on the absent stateful handler. Adding `BankHandler.sol` is the production-of-the-test-suite change that makes this exact specification compile and run.
### Fresh GREEN in the real worktree
PATH was again set separately in a new shell rooted at the real worktree:
```text
export PATH=/tmp/codius-node-v24.18.0/bin:/home/golem/.foundry/bin:$PATH
```
The brief's GREEN command was run literally:
```text
npm_config_offline=true forge test --match-path test/BankInvariant.t.sol -vvv --force
```
Relevant verbatim output, repeated for each of the three invariants:
```text
Ran 3 tests for test/BankInvariant.t.sol:BankInvariantTest
[PASS] invariant_ghostAccountingMatchesChain() (runs: 128, calls: 8192, reverts: 0)
| Contract | Selector | Calls | Reverts | Discards |
| BankHandler | deposit | 2666 | 0 | 0 |
| BankHandler | donate | 2791 | 0 | 0 |
| BankHandler | withdraw | 2735 | 0 | 0 |
[PASS] invariant_liabilitiesEqualTrackedBalances() (runs: 128, calls: 8192, reverts: 0)
[PASS] invariant_reservesCoverLiabilities() (runs: 128, calls: 8192, reverts: 0)
Suite result: ok. 3 passed; 0 failed; 0 skipped; finished in 3.53s (4.76s CPU time)
Ran 1 test suite in 3.53s (3.53s CPU time): 3 tests passed, 0 failed, 0 skipped (3 total tests)
```
Exit status captured immediately with `echo $?`:
```text
0
```
All three selectors were exercised over `128` runs at depth `64` (`8,192` calls per invariant), with no revert or discard. No tracked commit was needed for Fix Round 1 because the implementation was already correct; only the ignored evidence report changed. The deferred `actorAt` minor was not addressed.
@@ -1,134 +0,0 @@
### Task 6: Deploy, seed, inspect, and export deterministic V1 state
**Files:**
- Create: `script/lib/DemoScript.sol`
- Create: `script/DeployV1.s.sol`
- Create: `script/SeedV1Demo.s.sol`
- Create: `script/CheckState.s.sol`
- Create: `test/ScriptPreflight.t.sol`
- Create: `tools/finalize-manifest.mjs`
- Create: `tools/select-manifest.mjs`
- Create: `tools/test-finalize-manifest.mjs`
- Create: `deployments/.gitkeep`
- Modify: `Makefile`
**Interfaces:**
- Consumes an RPC, chain ID, explicit broadcaster, pending manifest, and Foundry broadcast receipts.
- Produces an active schema-versioned public manifest only after receipt/code/postcondition checks, then the exact Act 1 state.
- Establishes local actor indexes: owner `0`, Alice `1`, Bob `2` from Anvils standard development mnemonic.
- [ ] Write `ScriptPreflight.t.sol` around a small public harness for `DemoScript` and cover:
- chain IDs `31337` and `84532` accepted;
- chain IDs `1`, `8453`, and an arbitrary value rejected before broadcast;
- missing/invalid manifest, wrong manifest chain, zero address, and address-without-code rejected;
- internal pending manifests may use deployment block `0`, while active manifests may not;
- local keys derivable only when chain ID is `31337`;
- serialized JSON contains public addresses but never the mnemonic, a private key, `PRIVATE_KEY`, or `MNEMONIC`.
- [ ] Run red:
```bash
forge test --match-path test/ScriptPreflight.t.sol -vvv --force
```
Expected red: `DemoScript` does not exist.
- [ ] Implement `DemoScript` constants and guards:
```solidity
uint256 internal constant ANVIL_CHAIN_ID = 31337;
uint256 internal constant BASE_SEPOLIA_CHAIN_ID = 84532;
string internal constant ANVIL_TEST_PHRASE =
"test test test test test test test test test test test junk";
string internal constant PENDING_MANIFEST_PATH = "deployments/pending.json";
string internal constant ACTIVE_MANIFEST_PATH = "deployments/active.json";
error UnsupportedChain(uint256 chainId);
error ManifestChainMismatch(uint256 expected, uint256 actual);
error MissingCode(string label, address target);
```
Provide narrow helpers for chain checks, `vm.deriveKey` on local only, manifest read/write through `vm.parseJson*`/`vm.serialize*`, code checks, and exact-state assertions. Resolve the input/output manifest from `DEPLOYMENT_MANIFEST_PATH` with a narrow default appropriate to each script. Never log or serialize a derived private key.
- [ ] Write `DeployV1.s.sol` red tests/behavior first, then implement:
1. validate chain and resolve the public `SCRIPT_SENDER` value;
2. start broadcast;
3. deploy `MockUSDC(sender)`;
4. call `Upgrades.deployUUPSProxy("BankV1.sol:BankV1", abi.encodeCall(...))`;
5. stop broadcast;
6. assert code, owner, asset, version `1`, and implementation identity;
7. write `deployments/pending.json` with schema `1`, network, chain, deployment block `0`, display RPC/explorer metadata, token, proxy, implementation, owner, and local actor labels/addresses.
On local, derive account `0`, require it equals `SCRIPT_SENDER`, and broadcast with that derived development key. On Base Sepolia, require `SCRIPT_SENDER` equals the expected owner and use the signer selected by Forges matching `--account`/`--sender` options; never read a raw signing secret from environment.
- [ ] Test `finalize-manifest.mjs` and `select-manifest.mjs` with temporary pending/broadcast fixtures and an injected fake JSON-RPC function. Cover: a pre-broadcast deploy guard that rejects an existing target-chain canonical file, exact proxy transaction match, successful receipt, receipt-derived block, actual chain-ID match, code at token/proxy/implementation, implementation-slot match, failed/missing/ambiguous receipt, partial broadcast, wrong chain, missing code, an existing active manifest from either network remaining untouched until selection, secret-bearing content, atomic same-directory rename, and selection of only a valid confirmed chain manifest. Prove a failure never creates/changes a confirmed or active manifest, and selecting Base preserves Anvil byte-for-byte (and vice versa).
- [ ] Implement the finalizer using Node standard modules and JSON-RPC `fetch`. Its network-free `preflight-deploy <network>` fails before Forge runs when the target canonical file exists and prints the exact safe recovery command (`make reset-local` or `make archive-base-manifest`). Deploy finalization reads `broadcast/DeployV1.s.sol/<chainId>/run-latest.json`, matches the transaction whose created address is the pending proxy, matches its transaction hash to a successful receipt, uses that receipts real block number, queries `eth_chainId`, `eth_getCode` for all three contracts, and queries the EIP-1967 implementation slot. Atomically write the chains canonical `anvil.json` or `base-sepolia.json` only after all checks pass. `select-manifest.mjs` validates a named canonical file and atomically copies it to `active.json`; it never deletes or overwrites the other chain. Never copy a credential-bearing terminal RPC into JSON.
- [ ] Write and implement `SeedV1Demo.s.sol`, hard-guarded to chain `31337`. It must perform and assert this exact sequence:
```text
owner mints Alice 2,000 mUSDC
owner mints Bob 1,000 mUSDC
Alice approves and deposits 1,000 mUSDC
Bob approves and deposits 500 mUSDC
Alice withdraws 100 mUSDC
```
Postconditions: Alice internal `900e6`, Bob internal `500e6`, liabilities `1_400e6`, reserves `1_400e6`, version `1`, no surplus.
- [ ] Write and implement `CheckState.s.sol`. Always print network, block, token, proxy, implementation, owner, pause state, version, each configured actor balance, reserves, liabilities, and surplus. Always fail on `reserves < liabilities`, implementation/manifest mismatch, or manifest chain mismatch. `DEMO_EXPECTED_STAGE=deployed` asserts version `1` and empty accounting; `v1` asserts exact Act 1 values; `invariants` checks network-independent invariants only. Task 11 adds stage `v2`.
- [ ] Add direct Make targets that do not start background processes yet:
```make
RPC_LOCAL := http://127.0.0.1:8545
ANVIL_OWNER := 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266
.PHONY: deploy-v1 seed-v1 check-state
deploy-v1:
@node tools/finalize-manifest.mjs preflight-deploy anvil
@SCRIPT_SENDER=$(ANVIL_OWNER) DEPLOYMENT_MANIFEST_PATH=deployments/pending.json npm_config_offline=true forge script script/DeployV1.s.sol:DeployV1 --rpc-url $(RPC_LOCAL) --sender $(ANVIL_OWNER) --broadcast --force
@DEPLOYMENT_MANIFEST_PATH=deployments/pending.json DEMO_EXPECTED_STAGE=deployed forge script script/CheckState.s.sol:CheckState --rpc-url $(RPC_LOCAL) --force
@node tools/finalize-manifest.mjs deploy --rpc-url $(RPC_LOCAL)
@node tools/select-manifest.mjs anvil
seed-v1:
@forge script script/SeedV1Demo.s.sol:SeedV1Demo --rpc-url $(RPC_LOCAL) --broadcast --force
check-state:
@DEMO_EXPECTED_STAGE=$${DEMO_EXPECTED_STAGE:-v1} forge script script/CheckState.s.sol:CheckState --rpc-url $(RPC_LOCAL) --force
```
- [ ] Run unit/preflight green checks:
```bash
forge fmt
forge test --match-path test/ScriptPreflight.t.sol -vvv --force
```
- [ ] Run the first real local smoke test in two terminals. Terminal A:
```bash
anvil --host 127.0.0.1 --port 8545 --chain-id 31337
```
Terminal B:
```bash
make deploy-v1
make seed-v1
DEMO_EXPECTED_STAGE=v1 make check-state
```
Expected: the exact Act 1 table prints and all commands exit `0`. Inspect identical `deployments/anvil.json` and selected `deployments/active.json`, verify their deployment block matches the confirmed proxy receipt, and verify they contain no secret material. Simulate a failed receipt fixture and confirm the finalizer changes neither confirmed nor active files.
- [ ] Commit:
```bash
git add script test/ScriptPreflight.t.sol tools/finalize-manifest.mjs tools/select-manifest.mjs tools/test-finalize-manifest.mjs deployments/.gitkeep Makefile
git commit -m "feat: script deterministic V1 demo state"
```
---
@@ -1,34 +0,0 @@
# Task 6 report: deterministic V1 deployment state
## Implemented
- Preserved the pre-reboot Solidity work: `DemoScript`, `DeployV1`, `SeedV1Demo`, `CheckState`, and its 14-test preflight harness.
- Added the Node manifest control plane. `finalize-manifest.mjs` validates public manifests, blocks canonical overwrite before deploy, confirms the exact `CREATE` proxy transaction, receipt/block/chain/code/ERC-1967 slot, and atomically writes only the selected canonical manifest. `select-manifest.mjs` validates and atomically selects one canonical manifest without altering the other.
- Added fixture-driven Node tests, local Make targets, and `deployments/.gitkeep`.
- Completed a live Anvil deployment/seed/check smoke: confirmed and active manifests were byte-identical, their deployment block was receipt block `2`, and neither contained secret markers. Act 1 ended at Alice `900e6`, Bob `500e6`, liabilities/reserves `1_400e6`, surplus `0`.
## Verification
- `node tools/test-finalize-manifest.mjs` — 5/5 pass.
- `forge fmt --check` — pass.
- `forge test --match-path test/ScriptPreflight.t.sol -vvv --force` — 14/14 pass.
- `npm_config_offline=true forge test --force` — 57/57 pass (fresh final run).
- Live smoke (Anvil `31337`): `make deploy-v1`, `make seed-v1`, `DEMO_EXPECTED_STAGE=v1 make check-state` — all exit 0; receipt/manifest inspection passed. Anvil was stopped afterward.
- `git diff --check` — pass.
## TDD evidence
- Preserved pre-reboot Solidity RED/GREEN history honestly: it predates this resumed session; I only re-ran its green 14-test preflight suite.
- Node initial RED: `node --test tools/test-finalize-manifest.mjs` failed with `ERR_MODULE_NOT_FOUND` for `tools/finalize-manifest.mjs`, as expected before implementation. GREEN: the new test suite passed after implementing finalizer/selector.
- Atomic rename RED: the new atomic-writer test failed with `ENOENT` because the writer did not accept the injected I/O boundary; GREEN: it passed after staging a same-directory temporary path and renaming it.
- Proxy provenance RED: a `CALL` record bearing the proxy address was incorrectly accepted; GREEN: the finalizer now requires `transactionType === "CREATE"`, and all five Node tests pass.
## Changed files
`Makefile`; `deployments/.gitkeep`; `script/lib/DemoScript.sol`; `script/DeployV1.s.sol`; `script/SeedV1Demo.s.sol`; `script/CheckState.s.sol`; `test/ScriptPreflight.t.sol`; `tools/finalize-manifest.mjs`; `tools/select-manifest.mjs`; `tools/test-finalize-manifest.mjs`.
## Self-review and concerns
- Reviewed the scoped diff and real Foundry broadcast shape. The only finding was missing explicit `CREATE` filtering; fixed with a RED/GREEN regression test.
- Code-level Task 6 concerns: none. Base actor/RPC/keystore configuration was intentionally not added; it remains Task 12 per the stated ruling.
- Handoff blocker: staging/committing requires writing the Git worktree index outside the sandbox. The elevated `git add` request was rejected because the approval service reported a usage-limit failure. No files were staged or committed.
+99
View File
@@ -0,0 +1,99 @@
SHELL := /bin/bash
.SHELLFLAGS := -euo pipefail -c
-include .env
export BASE_SEPOLIA_RPC_URL BASE_SEPOLIA_PUBLIC_RPC_URL BASE_SEPOLIA_ACCOUNT BASE_SEPOLIA_SENDER BASE_SEPOLIA_RECIPIENT
RPC_LOCAL := http://127.0.0.1:8545
ANVIL_OWNER := 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266
ANVIL_ALICE := 0x70997970C51812dc3A010C7d01b50e0d17dc79C8
.PHONY: doctor setup demo-local verify check-state reset-local deploy-v1 seed-v1 upgrade-v2 demo-transfer sync-artifacts sync-artifacts-check sync-abis publish-web-manifest test-finalize-manifest deploy-base-sepolia upgrade-base-sepolia transfer-base-sepolia select-anvil select-base-sepolia archive-base-manifest
doctor:
@bash 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
@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/test-base-config.sh
@bash tools/test-scan-project.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
demo-local:
@bash tools/demo-local.sh
reset-local:
@bash tools/reset-local.sh
test-finalize-manifest:
@node tools/test-finalize-manifest.mjs
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
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
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
check-state:
@forge script script/CheckState.s.sol:CheckState --rpc-url $(RPC_LOCAL) --force
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
+81
View File
@@ -0,0 +1,81 @@
# UUPS Bank V1 → V2 Demo
> Educational demo — mock token — never use real funds.
This repository is a local-first upgradeability lesson. It deploys a six-decimal mock ERC-20 and a V1 custody ledger behind an ERC-1967 proxy, upgrades the proxy to V2 without losing state, performs a customer-to-customer ledger transfer, and presents the result in a read-only operations console.
> **Trust boundary:** MockUSDC has no value. These contracts are educational and unaudited; real deposits must never be sent here. The owner can pause customer actions and install arbitrary future logic. UUPS mistakes can corrupt state or permanently brick upgradeability. A real custody product requires professional audits, operational key controls, multisig or timelocked governance, incident procedures, legal advice, and jurisdiction-specific compliance work.
## Prerequisites
Use a Linux-like Bash environment with Git, GNU Make, `curl`, Foundry `1.7.1` (`forge`, `anvil`, and `cast`), Node `24.18.0`, and npm `11.17.0`. The exact package graph is committed in the lockfiles. Installation documentation: [Git](https://git-scm.com/downloads), [Foundry](https://getfoundry.sh), [Node](https://nodejs.org/en/download), [npm](https://docs.npmjs.com/downloading-and-installing-node-js-and-npm), [Bash](https://www.gnu.org/software/bash/), [Make](https://www.gnu.org/software/make/), and [curl](https://curl.se/download.html).
```bash
make setup
make doctor
```
`make setup` initializes recursive Git submodules and installs both pinned npm dependency trees. `make doctor` is read-only: it verifies versions, dependencies, writable runtime locations, and that local ports `8545` and `5173` are available.
## Ten-minute local quick start
This complete path is local. It needs no Base Sepolia service, browser wallet, faucet, explorer, public RPC, keystore, secret, or real funds.
In the first terminal:
```bash
make demo-local
```
The command performs a scoped reset, starts deterministic Anvil at `http://127.0.0.1:8545`, deploys and seeds V1, checks its exact state, exports public artifacts, and starts the console at `http://127.0.0.1:5173/`. It remains attached so Ctrl-C safely stops only the recorded project process groups.
In a 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/
```
Act 1 proves Alice has `900 mUSDC`, Bob has `500 mUSDC`, liabilities and reserves are both `1,400 mUSDC`, and version is `1`. The upgrade keeps the proxy, owner, asset, pause state, customer balances, liabilities, reserves, surplus, and deployment block unchanged while changing the implementation and version to `2`. The internal `250 mUSDC` transfer then leaves Alice with `650 mUSDC` and Bob with `750 mUSDC`; liabilities and reserves remain `1,400 mUSDC`, and the console decodes `BalanceTransferred` without an ERC-20 reserve transfer.
Stop the attached first terminal with Ctrl-C, then remove only reproducible local state:
```bash
make reset-local
```
## Architecture and guarantees
Foundry scripts are the state-changing control plane; the browser has no connector, signer, transaction client, or write control. `MockUSDC` holds no value. An ERC-1967 proxy keeps the bank address and storage stable while delegating calls to `BankV1` or `BankV2`. The proxy holds token reserves and the internal ledger records liabilities. Confirmed public manifests and generated ABIs connect that system to the React/Vite console through viem and wagmi.
OpenZeppelin supplies the UUPS and access-control primitives plus storage/upgrade validation. Foundry supplies compilation, tests, scripts, and the local chain. The project supplies the application logic, tests, manifests, lifecycle tooling, and UI. Passing those checks does not make the demo audited, decentralized, or suitable for custody.
## Command reference
- `make doctor` — check prerequisites, dependencies, runtime locations, configuration, and local ports without changing chain state.
- `make setup` — initialize pinned submodules and npm dependencies.
- `make demo-local` — run the attached local V1 experience.
- `make verify` — run Solidity format/build/unit/fuzz/invariant/upgrade gates, artifact and shell-safety tests, the project scanner, and web lint/type/tests/build.
- `make check-state` — print and validate the selected stage; use `DEMO_EXPECTED_STAGE=v1` or `v2` for the exact local acts.
- `make upgrade-v2` — validate and perform the owner-authorized local V1-to-V2 upgrade, finalize its manifest, and refresh exported artifacts.
- `make demo-transfer` — perform Alice's local `250 mUSDC` internal transfer to Bob and validate Act 3.
- `make reset-local` — validate recorded process identity, stop only owned groups, and remove only known local artifacts.
- `make deploy-v1` and `make seed-v1` — run the lower-level guarded V1 deployment and deterministic Act 1 setup used by `make demo-local`.
- `make sync-artifacts`, `make sync-artifacts-check`, `make sync-abis`, and `make publish-web-manifest` — regenerate or validate the ABI bridge and publish only the confirmed active manifest.
- `make test-finalize-manifest` — exercise manifest finalization in isolation.
- `make select-anvil` and `make select-base-sepolia` — explicitly select and republish an existing confirmed manifest.
- `make archive-base-manifest` — safely archive the Base canonical manifest before an intentional redeployment.
- `make deploy-base-sepolia`, `make upgrade-base-sepolia`, and `make transfer-base-sepolia` — explicit optional-testnet commands with chain, actor, account, and RPC guards.
## Optional Base Sepolia encore
The local lesson above is complete without a wallet, faucet, explorer, or public RPC. The optional Base Sepolia encore is separate and runs only after the local V1→V2 demo and `make verify` succeed. It uses a named encrypted Foundry keystore through `--account` and the same public address through `--sender`; no supported command accepts a raw private key or mnemonic.
Copy `.env.example` to `.env` and fill only its public configuration. `BASE_SEPOLIA_RPC_URL` is the terminal endpoint and may be credentialed; `BASE_SEPOLIA_PUBLIC_RPC_URL` is intentionally public and is the only RPC serialized for the browser. Foundry requests the keystore password interactively, and that password never belongs in `.env`.
Use `make archive-base-manifest` before an intentional Base redeployment. It moves only the Base canonical manifest to a timestamped sibling while preserving the active browser fallback and Anvil state. See the [presenter runbook](docs/PRESENTER_RUNBOOK.md) for the exact optional onboarding, funding boundary, execution sequence, and recovery rules.
Continue with the [learning guide](docs/LEARNING_GUIDE.md) or rehearse from the [presenter runbook](docs/PRESENTER_RUNBOOK.md).
+1
View File
@@ -0,0 +1 @@
+193
View File
@@ -0,0 +1,193 @@
# Learning Guide: Custody Accounting Behind a UUPS Proxy
> Educational demo — mock token — never use real funds.
## The two addresses that make upgrades possible
Users and scripts call the **proxy**, whose address remains stable and whose storage contains the asset address, customer ledger, liabilities, owner, and pause state. The **implementation** contains executable logic. The proxy forwards each application call with `delegatecall`: implementation code runs in the proxy's context, so `address(this)` is the proxy and all application reads and writes affect proxy storage. Calling the implementation directly is not an application call.
UUPS places upgrade authorization in the implementation. OpenZeppelin's proxy-context checks ensure upgrade entry points run only through a compatible proxy; `BankV1._authorizeUpgrade` then restricts authorization to the owner. This keeps the proxy small, but makes implementation correctness, storage compatibility, and owner security critical.
## Initializers replace constructor state
A normal implementation constructor changes only the implementation's own storage. It cannot initialize the proxy storage used by delegated calls. `initialize(asset, initialOwner)` therefore performs the one-time proxy setup and initializes ownership and pausing. Its public visibility lets the pinned OpenZeppelin upgrades-core tooling recognize the initializer as inherited by `BankV2`; the `initializer` modifier still permits exactly one proxy initialization. The encoded initializer runs atomically when the proxy is created, avoiding an uninitialized-proxy takeover window.
The `BankV1` constructor calls `_disableInitializers()`. That locks the standalone implementation so an outsider cannot initialize it and create a misleading or dangerous separately owned instance. The narrow constructor annotation tells the OpenZeppelin validator why this constructor is intentional; it does not bypass storage-layout, missing-initializer, or UUPS compatibility checks. Double initialization of the proxy and direct initialization of either implementation revert with `InvalidInitialization`.
`BankV2` needs no initializer or reinitializer because the V1 proxy is already initialized and V2 adds behavior only. It inherits the existing asset, owner, pause state, customer balances, liabilities, and transient reentrancy guard. Adding an initializer when there is no new state would create an unnecessary privileged transition and another state to reason about.
## Storage layout is a permanent API
Delegate calls interpret storage according to the active implementation's layout. Alongside OpenZeppelin's namespaced base-contract state, the actual V1 application fields occupy these slots in source order:
```text
BankV1 proxy application storage
├─ slot 0: _asset: IERC20
├─ slot 1: _balances: mapping(address => uint256)
├─ slot 2: _totalLiabilities: uint256
└─ slots 3…49: __gap: uint256[47]
```
OpenZeppelin upgradeable ownership and pausing use their own namespaced storage. `ReentrancyGuardTransient` uses transient storage rather than adding an initialized persistent field. Those implementation details do not make the application's declaration order optional.
V2 is safe because inheritance preserves that layout byte-for-byte and V2 declares no state:
```text
BankV2 is BankV1
├─ slot 0: _asset: IERC20 unchanged
├─ slot 1: _balances: mapping(address => uint256) unchanged
├─ slot 2: _totalLiabilities: uint256 unchanged
├─ slots 3…49: __gap: uint256[47] unchanged
└─ no V2 storage variables
```
A conceptual future revision could consume one reserved word only by carefully changing the layout in the base contract that declares the gap and validating every descendant against the prior version:
```text
Conceptual validated revision of the declaring base layout
├─ slot 0: _asset: IERC20
├─ slot 1: _balances: mapping(address => uint256)
├─ slot 2: _totalLiabilities: uint256
├─ slot 3: _newValue: uint256
└─ slots 4…49: __gap: uint256[46]
```
Simply declaring a new `BankV2` field would append it after the already inherited `__gap`; it would not consume that gap. This demo avoids that ambiguity entirely because the actual `BankV2` declares no state. Reordering existing declarations is unsafe:
```text
Unsafe layout
├─ slot 0: _totalLiabilities: uint256 moved into _asset's old slot
├─ slot 1: _asset: IERC20 moved into the mapping's old slot
├─ slot 2: _balances: mapping(address => uint256) moved into liabilities' old slot
└─ slots 3…49: __gap: uint256[47]
```
Changing order, type, inheritance order, or removing state can make balances appear as addresses, overwrite accounting, or damage control state. A bad implementation can also remove working upgrade machinery and brick future upgrades.
`BankV2` declares `@custom:oz-upgrades-from src/BankV1.sol:BankV1`. The upgrade script sets `BankV1` as the reference contract and calls `Upgrades.validateUpgrade` before broadcasting. Foundry emits AST, build information, and storage layouts; the OpenZeppelin validator compares the inheritance and storage layouts and checks UUPS compatibility. The validation uses no storage or UUPS bypass. It is a compatibility gate, not a business-logic audit: it cannot prove that a future owner-authorized implementation is honest, solvent, or correctly governed.
## Reserves, liabilities, and surplus
**Reserves** are `MockUSDC.balanceOf(proxy)`: tokens actually held by the proxy. **Liabilities** are `totalLiabilities()`: the aggregate amount the ledger owes customers. **Surplus** is reserves minus liabilities. The solvency rule is:
```text
reserves >= total liabilities
```
Equality holds in the prepared local states. Anyone can transfer mock tokens directly to the proxy without receiving ledger credit, so a surplus is possible and the invariant deliberately uses `>=`.
For `deposit(amount)`, the bank rejects zero, paused, or reentrant calls; reads reserves; uses `SafeERC20.safeTransferFrom`; measures the exact received delta; and only then credits the sender's internal balance and total liabilities. A fee-on-transfer or otherwise unexpected asset delta reverts the entire transaction.
For `withdraw(amount)`, the bank rejects zero, paused, reentrant, or underfunded ledger calls. It debits the customer's internal balance and total liabilities before `SafeERC20.safeTransfer` sends tokens. That ordering is checks-effects-interactions: validate first, commit internal effects second, interact externally last. `nonReentrant` adds a second boundary against a malicious token callback. A token revert rolls the whole transaction back.
`SafeERC20` handles ERC-20 implementations that return `false`, omit return values, or revert in different ways. Pausing gives the owner an emergency stop for deposits, withdrawals, and V2 internal transfers while views remain available. There is intentionally no owner reserve sweep.
## V2 transfer conservation proof
`transferBalance(recipient, amount)` validates the active pause state, a nonzero amount, a nonzero recipient distinct from the sender, and sufficient sender balance. It then debits the sender, credits the recipient, and emits `BalanceTransferred(from, to, amount)`. It makes no external call, so it needs no reentrancy guard and cannot move ERC-20 tokens.
The prepared Act 3 transfers `250e6` base units (`250.000000 mUSDC`) from Alice to Bob:
| Quantity | Before | Delta | After |
|---|---:|---:|---:|
| Alice ledger balance | `900e6` | `-250e6` | `650e6` |
| Bob ledger balance | `500e6` | `+250e6` | `750e6` |
| Tracked balance sum | `1_400e6` | `0` | `1_400e6` |
| Total liabilities | `1_400e6` | `0` | `1_400e6` |
| Proxy token reserves | `1_400e6` | `0` | `1_400e6` |
| Surplus | `0` | `0` | `0` |
Conservation follows directly from subtracting and adding the same `amount`: `(Alice - amount) + (Bob + amount) = Alice + Bob`. The `BalanceTransferred` log proves the ledger operation. The absence of a MockUSDC `Transfer` log in that transaction, together with equal before/after proxy reserves, proves that no reserve token moved.
## What the upgrade snapshot proves
Before broadcasting, `UpgradeV2` records the proxy, old implementation, owner, asset, pause state, every manifest actor balance, liabilities, reserves, surplus, deployment block, and version. After the OpenZeppelin-validated owner upgrade, it takes the same snapshot and requires:
- the proxy, owner, asset, pause state, actor count and balances, liabilities, reserves, surplus, and deployment block to be identical;
- the implementation address to differ;
- the implementation slot and manifest to identify the new implementation; and
- `contractVersion()` to change from `1` to `2`.
The finalizer independently matches the successful broadcast and `Upgraded(newImplementation)` log, reads live proxy and token state, and only then atomically updates the confirmed manifests. Thus the upgrade proof covers both storage continuity and the identity of the logic now serving the stable proxy.
## The owner is a central threat
The owner can pause every customer mutation and authorize an implementation containing arbitrary future logic. A malicious or compromised owner could install code that changes balances, transfers reserves, removes checks, breaks storage, or prevents later upgrades. Tests proving today's V1 and V2 behavior cannot constrain tomorrow's authorized implementation.
The demo deliberately uses one owner and has no multisig, timelock, role separation, upgrade delay, monitoring service, emergency governance process, or audited deployment procedure. OpenZeppelin's `onlyOwner` check proves that the configured owner authorized an upgrade; it does not prove the owner made a safe decision. A production threat model must protect the key, constrain and review upgrade proposals, make changes observable, plan incident response, and address legal and regulatory obligations.
> **Trust boundary:** MockUSDC has no value. These contracts are educational and unaudited; real deposits must never be sent here. The owner can pause customer actions and install arbitrary future logic. UUPS mistakes can corrupt state or permanently brick upgradeability. A real custody product requires professional audits, operational key controls, multisig or timelocked governance, incident procedures, legal advice, and jurisdiction-specific compliance work.
## Local exercises: observe the named failures
These commands run deterministic tests and need no wallet or public RPC. Add `-vvvv` to inspect a revert trace.
### Every V2 transfer failure
```bash
# Inherited BankV1.ZeroAmount
forge test --match-test testTransferBalanceRejectsZeroAmount -vv
# BankV2.InvalidRecipient(address)
forge test --match-test testTransferBalanceRejectsZeroRecipient -vv
# BankV2.SelfTransfer()
forge test --match-test testTransferBalanceRejectsSenderAsRecipient -vv
# Inherited BankV1.InsufficientBalance(account, available, requested)
forge test --match-test testTransferBalanceReportsAvailableAndRequestedWhenBalanceIsInsufficient -vv
# OpenZeppelin EnforcedPause()
forge test --match-test testTransferBalanceRejectsCallsWhilePaused -vv
```
### V1, initialization, authorization, and script failures
```bash
# BankV1.InvalidAsset
forge test --match-test testInitializationChecksZeroAssetBeforeZeroOwner -vv
# BankV1.ZeroAmount on both V1 customer paths
forge test --match-test 'test(Deposit|Withdraw)RejectsZeroAmount' -vv
# BankV1.InsufficientBalance with available/requested values
forge test --match-test testWithdrawReportsAvailableAndRequestedOnInsufficientInternalBalance -vv
# BankV1.UnexpectedAssetDelta with a fee-taking token
forge test --match-test testFeeOnTransferDepositRevertsAndRollsBackTokenAndAccounting -vv
# OpenZeppelin EnforcedPause
forge test --match-test 'test(Deposit|Withdraw)RejectsCallsWhilePaused' -vv
# OpenZeppelin OwnableUnauthorizedAccount
forge test --match-test 'testNonOwnerCannot(Pause|Unpause|AuthorizeUpgrade)' -vv
# OpenZeppelin InvalidInitialization on the proxy and both implementations
forge test --match-test 'test(ProxyCannotBeInitializedTwice|ImplementationCannotBeInitialized|NewImplementationCannotBeInitializedDirectly)' -vv
# OpenZeppelin ReentrancyGuardReentrantCall
forge test --match-test testDepositPropagatesNestedRevertAtomicallyWhenConfigured -vv
# DemoScript.UnsupportedChain
forge test --match-test testUnsupportedChainsAreRejectedBeforeBroadcast -vv
# DemoScript.ManifestChainMismatch and DemoScript.MissingCode
forge test --match-test 'test(WrongManifestChain|AddressWithoutCode)IsRejected' -vv
# DemoScript.InvalidDeploymentBlock
forge test --match-test testActiveManifestRejectsDeploymentBlockZero -vv
# DemoScript.InvalidManifestSchema
forge test --match-test testLegacyParallelActorManifestIsRejected -vv
# DemoScript.InvalidActorConfiguration, including strict local and Base actor schemas
forge test --match-test 'test(AnvilManifestRequiresOwnerAtActorZero|BaseManifestRequiresExactPresenterRecipientActors)' -vv
# DemoScript.UnexpectedState and DemoScript.UnexpectedAddress
forge test --match-test 'test(UnexpectedStateExerciseRevertsThroughAssertionBranch|CheckStateRejectsManifestImplementationMismatch)' -vv
# CheckState.Insolvent and CheckState.UnknownStage
forge test --match-test testCheckStateExercisesRevertThroughInsolventAndUnknownStageBranches -vv
```
Finish with `make verify`; it combines formatting, build, storage/upgrade validation, unit, fuzz, invariant, script, process-safety, scanner, and web gates.
+195
View File
@@ -0,0 +1,195 @@
# Presenter Runbook: Prepared V1 and Live UUPS Upgrade
> Educational demo — mock token — never use real funds.
## Exact preflight
Rehearse from a clean disposable branch or worktree created at the immutable `demo-start` tag. Use two terminals, keep the browser closed until Vite reports ready, and run this before the audience arrives:
```bash
make reset-local
make setup
make doctor
make verify
git diff --check
git status --short
```
The doctor must report Foundry `1.7.1`, Node `24.18.0`, npm `11.17.0`, initialized dependencies, writable runtime paths, and free ports `8545` and `5173`. `make verify` and `git diff --check` must exit `0`. At `demo-start`, tracked status must be clean. Do not upgrade after a partial or failed gate.
## Ten-minute rehearsal
Use this exact local timing once the dependencies are installed:
| Time | Action | Evidence to say aloud |
|---|---|---|
| `0:001:00` | Run the preflight checks and start `make demo-local` in terminal 1. | Anvil is chain `31337`; Vite and Anvil are project-owned and attached. |
| `1:003:00` | Run the V1 state check in terminal 2 and open the console. | Stable proxy, distinct implementation, version `1`, Alice `900`, Bob `500`, reserves = liabilities = `1,400 mUSDC`. |
| `3:005:30` | Deliver the live Codex prompt and review the prepared Act 2 diff. | V2 inherits V1, adds no storage or initializer, and the browser remains read-only. |
| `5:307:00` | Point to the completed `make verify` evidence, then run `make upgrade-v2`. | All gates passed before broadcast; proxy and state remain stable while implementation and version change. |
| `7:009:00` | Run `make demo-transfer`, the V2 check, and the HTTP check. | Alice `650`, Bob `750`, reserves and liabilities still `1,400`; one decoded internal transfer and no token transfer. |
| `9:0010:00` | Read the trust disclosure, Ctrl-C terminal 1, and run `make reset-local`. | The owner remains central; local cleanup targets only validated project processes and artifacts. |
If a live Codex edit takes longer, treat this as the prepared reference rehearsal and allow a separate coding block. The verification-before-upgrade rule never changes to meet the clock.
## Two-terminal three-act sequence
### Act 1 — Prepared V1 establishes trust
Terminal 1 remains attached:
```bash
make demo-local
```
Terminal 2:
```bash
DEMO_EXPECTED_STAGE=v1 make check-state
curl --fail http://127.0.0.1:5173/
```
Open `http://127.0.0.1:5173/`; Anvil is at `http://127.0.0.1:8545`. Call out that every application read and write targets the proxy. The implementation address is separate and displayed only to explain delegation. The expected state is Alice `900 mUSDC`, Bob `500 mUSDC`, liabilities `1,400 mUSDC`, reserves `1,400 mUSDC`, surplus `0`, pause state `false`, and version `1`.
The scripts minted `2,000 mUSDC` to Alice and `1,000 mUSDC` to Bob; Alice deposited `1,000` and withdrew `100`; Bob deposited `500`. The stable proxy holds the reserves and application storage. Point to the deposits, withdrawal, initial ownership, and upgrade-to-V1 events, then state that the console is read-only and never signs.
### Act 2 — Codex changes the system live
Give Codex this approved prompt verbatim:
> Add `BankV2` with customer-to-customer internal transfers. Preserve the UUPS storage layout and all V1 behavior. Add unit, fuzz, invariant, and upgrade-regression tests; an owner upgrade script; a scripted Alice-to-Bob transfer; exported ABI support; and the read-only console updates needed to show V2 and its transfer event. Explain each security decision. Do not perform an upgrade until all verification passes.
The bounded local Act 2 diff from `demo-start` consists of these files:
- Contracts and direct tests: `src/BankV2.sol`, the initializer-visibility compatibility edit in `src/BankV1.sol`, `test/BankV2.t.sol`, `test/BankUpgrade.t.sol`, `test/mocks/IncompatibleBank.sol`, and `test/mocks/NonUUPSImplementation.sol`.
- Upgrade/demo/invariant path: `Makefile`, `script/CheckState.s.sol`, `script/UpgradeV2.s.sol`, `script/TransferV2Demo.s.sol`, `test/BankInvariant.t.sol`, `test/ScriptPreflight.t.sol`, and `test/helpers/BankV2Handler.sol`.
- Manifest and ABI bridge: `tools/finalize-manifest.mjs`, `tools/sync-web-artifacts.mjs`, `tools/test-finalize-manifest.mjs`, and `tools/test-sync-web-artifacts.mjs`.
- Read-only console: `web/src/App.test.tsx`, `web/src/components/ActivityTimeline.tsx`, `web/src/data/bankClient.ts`, `web/src/data/bankClient.test.ts`, and `web/src/types/dashboard.ts`.
The V1 compatibility edit changes `initialize` visibility from `external` to `public` so the pinned upgrades-core tooling recognizes the initializer as inherited by `BankV2`; it does not change the external ABI, initializer guard, or stored state. Generated ABI and deployment files may appear during execution but are reproducible runtime artifacts, not live source edits. The optional Base Sepolia files are a later encore and are not required for the local Act 2 story.
Ask Codex to show the diff and explain layout preservation, owner authorization, transfer conservation, error paths, snapshot postconditions, and why the browser remains read-only. Then run:
```bash
make verify
```
Point to each successful layer in the output: `forge fmt --check`; clean forced build; generated ABI freshness; the pinned OpenZeppelin upgrades CLI; 102 Solidity unit, fuzz, invariant, upgrade-regression, and script tests; 16 manifest-finalizer tests; 26 process-safety cases; offline Base-config and project-scanner gates; then web lint, TypeScript checking, 55 Vitest tests across six files, and the production Vite build. In the upgrade tests, highlight the validator accepting `BankV1``BankV2`, rejecting an incompatible layout, rejecting non-UUPS logic, and preserving the snapshot. If any layer fails, do not upgrade.
### Act 3 — V2 proves continuity
Continue in terminal 2 only after the complete gate passes:
```bash
make upgrade-v2
make demo-transfer
DEMO_EXPECTED_STAGE=v2 make check-state
curl --fail http://127.0.0.1:5173/
```
At the upgrade boundary, compare the printed identities and state:
- the proxy address is exactly the Act 1 proxy;
- the implementation address is different;
- owner, asset, pause state, Alice/Bob addresses and balances, liabilities, reserves, surplus, and deployment block are unchanged;
- version changes from `1` to `2`; and
- the upgrade finalizer matches the `Upgraded` event and live ERC-1967 implementation slot before publishing the V2 manifest.
After the transfer, call out Alice `650 mUSDC`, Bob `750 mUSDC`, liabilities `1,400 mUSDC`, reserves `1,400 mUSDC`, surplus `0`, and version `2`. The timeline must decode `BalanceTransferred(Alice, Bob, 250e6)` as “Alice transferred 250.000000 mUSDC to Bob.” That transaction has no MockUSDC `Transfer` log, and the unchanged reserves prove no ERC-20 left or entered the proxy. Refresh only if the console has not observed the new block; normally its live synchronization should show the change.
## Recovery without broad cleanup
- **Failed edit or test:** do not upgrade. Keep the failing command and diff visible, let Codex diagnose on the disposable live branch, or finish with the verified V1. If time expires, create a new disposable branch/worktree from `demo-start`; never reset or overwrite unrelated work.
- **Occupied port:** `make demo-local` refuses to claim either port. Probe with `curl http://127.0.0.1:8545` and `curl http://127.0.0.1:5173/`, then use operating-system process inspection to identify the external owner. Stop it only after proving ownership. The project never uses broad process matching.
- **Recorded stale process:** run `make reset-local`. It validates numeric PID, process group, command signature, and Linux start tick before signaling. A mismatched live process is preserved and reset exits nonzero.
- **Stale UI:** run the stage-appropriate check (`DEMO_EXPECTED_STAGE=v1 make check-state` before transfer or `DEMO_EXPECTED_STAGE=v2 make check-state` after it), inspect `.demo/vite.log`, and use `curl --fail http://127.0.0.1:5173/`. A stale/disconnected console preserves the last good snapshot and labels it; it never invents zeros.
- **Partial local manifest:** never copy `pending.json` or `upgrade-pending.json` into a confirmed manifest and never hand-edit `active.json`. If an upgrade broadcast succeeded but a transient finalizer step failed, preserve the staging and broadcast files, diagnose the cause, and rerun `node tools/finalize-manifest.mjs upgrade --rpc-url http://127.0.0.1:8545`; then run `make sync-artifacts`. Otherwise stop the attached process, run `make reset-local`, and restart from a fresh V1. Confirmed manifests remain unchanged on a failed finalization.
- **Unexpected child exit:** the attached launcher stops its other validated group. Inspect `.demo/anvil.log` and `.demo/vite.log`, run `make reset-local`, and restart.
- **Unavailable Base service:** stop the optional encore, record whether the faucet, RPC, or explorer failed, and leave the completed local result untouched. Never substitute another chain or expose a signing secret to rescue an encore.
End the local session with Ctrl-C in terminal 1, then run:
```bash
make reset-local
bash tools/test-process-safety.sh
bash tools/test-base-config.sh
git diff --check
git status --short
```
### Recover the verified reference without touching current work
`demo-complete` is a reference/recovery checkpoint, not a reason to reset the current checkout. From the repository root, first verify the tag and inspect current work:
```bash
git cat-file -t demo-complete
git tag -n99 demo-complete
git rev-parse 'demo-complete^{}'
git status --short
```
The type must be `tag`, the annotation must read `Verified reference solution for UUPS bank demo`, and the peeled target is the verified documentation commit. This checks the required annotated tag without implying that it carries a GPG signature.
Then create a new branch in a new worktree; choose unused names if either example name already exists:
```bash
git worktree add -b recovery/uups-bank-demo .worktrees/uups-bank-demo-recovery demo-complete
git -C .worktrees/uups-bank-demo-recovery status --short
```
This peels the annotated tag into a new branch without switching, resetting, cleaning, stashing, or overwriting the current worktree. Never delete an existing recovery directory or move either demo tag as part of recovery.
## Optional Base Sepolia encore
Run this only after the completed local demo and only when the presenter explicitly chooses the public encore. Create a named encrypted Foundry keystore before any Base deployment, upgrade, transfer, selection, or archive command:
```bash
cast wallet import uups-bank-base --interactive
cast wallet address --account uups-bank-base
```
Copy the displayed public address to `BASE_SEPOLIA_SENDER`, set `BASE_SEPOLIA_ACCOUNT=uups-bank-base`, and choose a different nonzero public address for `BASE_SEPOLIA_RECIPIENT`. The Base manifest schema is exactly two nested actors in order: `Presenter` at the configured sender, then `Recipient` at the configured recipient. Fund only the displayed Base Sepolia sender with Base Sepolia test ETH. Never paste the key or password into Codex, shell history, or `.env`; Foundry requests the encrypted-keystore password interactively.
Copy `.env.example` to `.env` and configure only public identifiers and endpoints:
```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=uups-bank-base
BASE_SEPOLIA_SENDER=
BASE_SEPOLIA_RECIPIENT=
```
The Base commands require HTTPS RPCs, reject browser URL credentials, validate the named account identifier and distinct nonzero actors, and bind both `--account` and `--sender`. They never accept a raw key or mnemonic. First run the offline gate and inspect the fake-value dry run:
```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
```
The dry run must contain `--account`, `--sender`, and `--slow`, and no raw-key option. The URLs are deliberately fake; these checks need no keystore, faucet, or network. Only with explicit authorization and Base Sepolia test funds may the presenter run:
```bash
make deploy-base-sepolia
make upgrade-base-sepolia
make transfer-base-sepolia
```
Deployment mints valueless mUSDC only to Presenter and deposits `1,000 mUSDC`; Recipient starts at `0`. The V2 internal transfer produces Presenter `750 mUSDC` and Recipient `250 mUSDC`, while reserves and liabilities remain `1,000 mUSDC`. Each command runs an invariant-only state check. The confirmed browser manifest contains only the intentionally public browser RPC and `https://sepolia.basescan.org`; the terminal RPC is never serialized.
Before an intentional Base redeployment, run `make archive-base-manifest`. It moves only `deployments/base-sepolia.json` to a validated timestamped sibling and leaves `active.json` and Anvil state untouched. A subsequent finalizer may create a new Base canonical manifest; the old active copy remains the browser fallback until `make select-base-sepolia` succeeds. Use `make select-anvil` to return the browser to the local manifest.
## Closing trust disclosure and tool boundaries
Read these points while the matching console panel is visible:
- MockUSDC has no value.
- These contracts are educational and unaudited; real deposits must never be sent here.
- The owner can pause customer actions and install arbitrary future logic.
- UUPS mistakes can corrupt state or permanently brick upgradeability.
- A real custody product requires professional audits, operational key controls, multisig or timelocked governance, incident procedures, legal advice, and jurisdiction-specific compliance work.
Codex performs the repository-specific work: application and UI implementation, tests, scripts, lifecycle orchestration, manifest handling, and explanation. OpenZeppelin supplies reviewed reusable primitives and a validator that checks declared upgrade/storage compatibility; it does not audit this application's economics, owner choices, or operations. Foundry compiles and executes the declared tests and scripts and provides local Anvil; it guarantees neither test completeness nor production safety. None of Codex, OpenZeppelin, Foundry, or a passing gate turns this teaching artifact into audited, decentralized, or regulated custody software.
@@ -6,7 +6,7 @@
**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. **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`. **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 ## Global Constraints
@@ -14,7 +14,7 @@
- State-changing scripts accept only chain IDs `31337` and `84532`; no mainnet RPC, address, target, or configuration is added. - State-changing scripts accept only chain IDs `31337` and `84532`; no mainnet RPC, address, target, or configuration is added.
- Local accounts come only from Anvils standard development mnemonic. Testnet signing uses a named encrypted Foundry keystore and `--account`; no supported command accepts a raw private key or mnemonic environment variable. - Local accounts come only from Anvils standard development mnemonic. Testnet signing uses a named encrypted Foundry keystore and `--account`; no supported command accepts a raw private key or mnemonic environment variable.
- Every application call uses the proxy address. The implementation address is read only for validation and explanation. - 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()`. - `Upgrades`, never `UnsafeUpgrades`, performs deploy/upgrade validation. The sole validator allowance is `@custom:oz-upgrades-unsafe-allow constructor` immediately above the constructor that calls `_disableInitializers()`; no Options `unsafeAllow`, exclude, skip, `UnsafeUpgrades`, reachable annotation, or other bypass is permitted. BankV1 uses OpenZeppelin 5.6.1 `ReentrancyGuardTransient`, the user-approved constructor-free guard for this project's Cancun-targeted Anvil and Base Sepolia networks.
- V2 does not add, delete, reorder, or change the type of any storage variable and has no initializer. - 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. - 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. - Generated Foundry output, local manifests, copied web artifacts, `.demo/` process state, `.env`, and `.superpowers/` are ignored by Git.
@@ -152,7 +152,7 @@ web/src/generated/*.ts
} }
``` ```
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. 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`: - [ ] Configure Foundry in `foundry.toml`:
@@ -374,7 +374,7 @@ Expected red: `BankV1` is missing.
```solidity ```solidity
import {Initializable} from "@openzeppelin/contracts/proxy/utils/Initializable.sol"; import {Initializable} from "@openzeppelin/contracts/proxy/utils/Initializable.sol";
import {UUPSUpgradeable} from "@openzeppelin/contracts/proxy/utils/UUPSUpgradeable.sol"; import {UUPSUpgradeable} from "@openzeppelin/contracts/proxy/utils/UUPSUpgradeable.sol";
import {ReentrancyGuard} from "@openzeppelin/contracts/utils/ReentrancyGuard.sol"; import {ReentrancyGuardTransient} from "@openzeppelin/contracts/utils/ReentrancyGuardTransient.sol";
import {OwnableUpgradeable} from "@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol"; import {OwnableUpgradeable} from "@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol";
import {PausableUpgradeable} from "@openzeppelin/contracts-upgradeable/utils/PausableUpgradeable.sol"; import {PausableUpgradeable} from "@openzeppelin/contracts-upgradeable/utils/PausableUpgradeable.sol";
@@ -383,7 +383,7 @@ contract BankV1 is
UUPSUpgradeable, UUPSUpgradeable,
OwnableUpgradeable, OwnableUpgradeable,
PausableUpgradeable, PausableUpgradeable,
ReentrancyGuard ReentrancyGuardTransient
{ {
IERC20 internal _asset; IERC20 internal _asset;
mapping(address account => uint256 balance) internal _balances; mapping(address account => uint256 balance) internal _balances;
@@ -392,7 +392,7 @@ contract BankV1 is
} }
``` ```
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. 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: - [ ] Add the only permitted validator annotation and no other bypass:
@@ -1081,7 +1081,7 @@ Retain the explicit `deploy-v1`, `seed-v1`, and `sync-artifacts` lower-level tar
- [ ] 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. - [ ] Write the prepared portion of `PRESENTER_RUNBOOK.md`: preflight, rehearsal timing, exact three-act story, the approved live Codex prompt verbatim, Act 1 expected state, commands/console callouts, occupied-port/stale-console/test-failure recovery, and the rule that no upgrade runs until `make verify` passes. Describe Act 2/3 expected outcomes without including reference V2 source, and include a closing trust disclosure checklist matching README/UI word-for-word in substance.
- [ ] Implement `scan-project.sh` with a NUL-safe array from `git ls-files`, excluding dependency gitlinks, `docs/superpowers`, and generated lockfiles. Return nonzero if project-owned tracked content contains an actual `PRIVATE_KEY`/`MNEMONIC` assignment, PEM private key, `TODO`, `TBD`, `FIXME`, filler text, or an unsafe upgrade bypass in `src`/`test`/`script`. Construct the scanners own pattern from split shell literals so it does not match itself. Permit only the exact hyphenated 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. - [ ] Implement `scan-project.sh` with a NUL-safe array from `git ls-files`, excluding dependency gitlinks, `docs/superpowers`, and generated lockfiles. Return nonzero if project-owned tracked content contains an actual `PRIVATE_KEY`/`MNEMONIC` assignment, PEM private key, `TODO`, `TBD`, `FIXME`, filler text, or an unsafe upgrade bypass in `src`/`test`/`script`. Construct the scanners own pattern from split shell literals so it does not match itself. Permit only the exact hyphenated `oz-upgrades-unsafe-allow constructor` annotation approved for `BankV1`. The committed `ANVIL_TEST_PHRASE` is the universally known test fixture, not a supported secret input; test that exact fixture is the only scan exception.
- [ ] Run process and full verification: - [ ] Run process and full verification:
@@ -0,0 +1,240 @@
# Superpowers Artifact Ignore Policy 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:** Remove operational Superpowers artifacts from the current repository tip, prevent them from being committed in future projects, and resolve PR #2's `.gitignore` merge conflict without rewriting history.
**Architecture:** Apply defense in depth: the user-level Git excludes file protects every local repository, while this repository's `.gitignore` protects collaborators and other machines. Merge current `origin/main` into `feature/uups-bank-demo`, retain local `.superpowers/` copies as ignored files, remove those artifacts from Git's index, and keep intentional documents under `docs/superpowers/` tracked.
**Tech Stack:** Git, Bash, Make, the repository's existing Foundry/Node verification gate, and Gitea CLI `tea`.
**Spec:** `docs/superpowers/specs/2026-08-25-superpowers-artifact-ignore-policy-design.md`
## Global Constraints
- Operational Superpowers and worktree directories are private local state and must not be committed: `**/.superpowers/`, `**/.claude/superpowers/`, and `**/.worktrees/`.
- Intentional design and implementation documents remain tracked under `docs/superpowers/specs/` and `docs/superpowers/plans/`.
- Enforce the policy in both the machine-wide Git excludes file and this repository's `.gitignore`.
- Remove `.superpowers/` from the current repository index without deleting local copies.
- Do not rewrite published history or force-push.
- Do not move or recreate `demo-start` or `demo-complete`.
- Preserve existing global ignore entries byte-for-byte apart from appending the approved rules once.
- Never print or inspect operational artifact contents during cleanup.
- If any merge conflict other than `.gitignore` appears, stop and report it rather than applying a broad resolution.
- Run the complete `make verify` gate before publishing the resolved branch.
## File and Interface Map
| Area | Files | Responsibility |
| --- | --- | --- |
| Machine policy | `/home/golem/.config/git/ignore` | Ignore operational Superpowers and worktree directories in all local repositories |
| Repository policy | `.gitignore` | Carry the same protection for collaborators while retaining application-specific generated/build exclusions |
| Repository cleanup | `.superpowers/**` index entries introduced by current `origin/main` | Remove generated operational artifacts from the resulting repository tree while retaining local ignored copies |
| Intentional docs | `docs/superpowers/specs/**`, `docs/superpowers/plans/**` | Remain tracked as authored project source |
---
### Task 1: Add the machine-wide Git ignore safeguard
**Files:**
- Modify: `/home/golem/.config/git/ignore`
**Interfaces:**
- Consumes: Git's default XDG user excludes file at `/home/golem/.config/git/ignore`.
- Produces: machine-wide ignored-directory behavior for `.superpowers/`, `.claude/superpowers/`, and `.worktrees/` in repositories that have no local `.gitignore` rule.
- [ ] **Step 1: Verify the current global policy does not yet ignore the three directories**
Create an isolated repository with no local ignore file:
```bash
probe_dir=$(mktemp -d /tmp/superpowers-ignore-policy.XXXXXX)
git -C "$probe_dir" init -q
mkdir -p "$probe_dir/.superpowers/sdd" "$probe_dir/.claude/superpowers" "$probe_dir/.worktrees/feature"
touch "$probe_dir/.superpowers/sdd/report.md" "$probe_dir/.claude/superpowers/state.json" "$probe_dir/.worktrees/feature/marker"
git -C "$probe_dir" check-ignore -v .superpowers/sdd/report.md .claude/superpowers/state.json .worktrees/feature/marker
```
Expected RED: `git check-ignore` exits `1` and prints no matching ignore rule. If all three paths are already ignored by the user-level file, do not duplicate them; record the existing matching rules and continue to Step 3.
- [ ] **Step 2: Append the exact global exclusions once**
Preserve the existing `**/.claude/settings.local.json` line and append exactly:
```gitignore
**/.superpowers/
**/.claude/superpowers/
**/.worktrees/
```
Use `apply_patch` directly when permitted. If the sandbox blocks editing `/home/golem/.config/git/ignore`, copy the file to a uniquely named `/tmp` path, use `apply_patch` on that copy, verify its diff against the original, then request narrowly scoped approval to install that exact prepared file back at `/home/golem/.config/git/ignore`. Do not use an in-place shell append that can duplicate entries.
- [ ] **Step 3: Verify the global policy in an isolated repository**
Run against the same `probe_dir`:
```bash
git -C "$probe_dir" check-ignore -v .superpowers/sdd/report.md .claude/superpowers/state.json .worktrees/feature/marker
```
Expected GREEN: exit `0`; all three paths print a matching rule sourced from `/home/golem/.config/git/ignore`.
- [ ] **Step 4: Verify uniqueness and preserve the existing rule**
```bash
test "$(rg -n -x -F '**/.superpowers/' /home/golem/.config/git/ignore | wc -l)" -eq 1
test "$(rg -n -x -F '**/.claude/superpowers/' /home/golem/.config/git/ignore | wc -l)" -eq 1
test "$(rg -n -x -F '**/.worktrees/' /home/golem/.config/git/ignore | wc -l)" -eq 1
rg -n -x -F '**/.claude/settings.local.json' /home/golem/.config/git/ignore
```
Expected: each new rule occurs exactly once and the pre-existing settings rule remains present.
- [ ] **Step 5: Remove only the isolated probe**
Validate the prefix before deleting:
```bash
case "$probe_dir" in
/tmp/superpowers-ignore-policy.*) rm -rf "$probe_dir" ;;
*) echo "refusing unexpected probe path: $probe_dir" >&2; exit 1 ;;
esac
```
No repository commit is created for this user-level configuration task.
---
### Task 2: Merge main, resolve the policy conflict, and untrack runtime artifacts
**Files:**
- Modify: `.gitignore`
- Remove from index only: `.superpowers/**`
- Preserve tracked: `docs/superpowers/specs/**`
- Preserve tracked: `docs/superpowers/plans/**`
**Interfaces:**
- Consumes: `origin/main` at the latest fetched commit, `feature/uups-bank-demo`, and the global exclusions produced by Task 1.
- Produces: a normal merge commit whose tree contains no `.superpowers/**` paths, keeps intentional `docs/superpowers/**` documents, and is conflict-free against the fetched `origin/main`.
- [ ] **Step 1: Confirm the exact pre-merge state**
```bash
git fetch origin main feature/uups-bank-demo
git status --short --branch
git rev-parse HEAD
git rev-parse origin/main
git rev-parse origin/feature/uups-bank-demo
git merge-tree --write-tree --messages origin/main HEAD
```
Expected RED: the branch is clean and named `feature/uups-bank-demo`; `git merge-tree` reports exactly one content conflict, `.gitignore`. If another path conflicts, stop and report it.
- [ ] **Step 2: Start a non-fast-forward merge without committing**
```bash
git merge --no-ff --no-commit origin/main
```
Expected: Git stops with one unresolved path, `.gitignore`; files newly tracked by `origin/main` may appear under `.superpowers/`.
- [ ] **Step 3: Resolve `.gitignore` to the exact repository policy**
Replace the conflicted file with:
```gitignore
# Local agent/session state
.claude/superpowers/
.superpowers/
.worktrees/
.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
```
Do not retain `main`'s granular `.superpowers/brainstorm/...` rules; the broad `.superpowers/` rule is the approved policy.
- [ ] **Step 4: Keep operational artifacts locally while removing them from Git's index**
```bash
git add .gitignore
git rm -r --cached .superpowers
```
Expected: `.superpowers/**` paths are staged as deletions from the merge result but remain present locally and ignored.
- [ ] **Step 5: Verify the resolved merge before committing**
```bash
test -z "$(git diff --name-only --diff-filter=U)"
test -z "$(git ls-files '.superpowers/**')"
git check-ignore -v --no-index .superpowers/sdd/example.md .claude/superpowers/state.json .worktrees/example/marker
git ls-files 'docs/superpowers/specs/**' 'docs/superpowers/plans/**'
git diff --cached --check
git status --short
```
Expected: no unresolved paths; no `.superpowers/**` index entries; all three operational paths ignored by `.gitignore`; the approved design and implementation plan remain tracked; no whitespace errors.
- [ ] **Step 6: Commit the merge cleanup**
Inspect the staged path set without opening operational artifact contents, then commit:
```bash
git diff --cached --name-status
git commit -m "chore: keep superpowers artifacts local"
```
Expected: a merge commit with `origin/main` and the former feature HEAD as parents; no history rewrite and no tag movement.
- [ ] **Step 7: Run focused policy verification**
```bash
git merge-base --is-ancestor origin/main HEAD
test -z "$(git ls-tree -r --name-only HEAD -- .superpowers)"
git check-ignore -v --no-index .superpowers/sdd/example.md .claude/superpowers/state.json .worktrees/example/marker
git ls-tree -r --name-only HEAD -- docs/superpowers/specs docs/superpowers/plans
git diff --check origin/feature/uups-bank-demo..HEAD
git status --short --branch
```
Expected: `origin/main` is an ancestor; the committed tree has no `.superpowers` paths; intentional docs remain; the branch is ahead of `origin/feature/uups-bank-demo` only by the policy/design and merge-cleanup commits; the worktree has no tracked changes.
- [ ] **Step 8: Run the complete repository gate**
```bash
export PATH=/tmp/node-v24.18.0-linux-x64/bin:$PATH
export npm_config_cache=/tmp/uups-demo-npm-cache
export npm_config_offline=true
make verify
git diff --check
```
Expected GREEN: all Solidity, finalizer, process-safety, Base configuration, scanner, web lint/typecheck/test/build checks exit `0`; `git diff --check` exits `0`.
---
## Final Review and Publication
After both tasks pass their independent SDD review gates:
1. Run the whole-plan review over the design commit and merge-cleanup range.
2. Run a fresh controller `make verify` and `git diff --check` at the reviewed HEAD.
3. Confirm `demo-start` and `demo-complete` still peel to their pre-cleanup targets.
4. Push `feature/uups-bank-demo` normally with no force option.
5. Query Gitea PR #2 and require `state: open`, `base: main`, `head: feature/uups-bank-demo`, and `mergeable: true`.
6. Preserve the existing linked worktree for PR feedback.
@@ -0,0 +1,82 @@
# Superpowers Artifact Ignore Policy Design
- Status: proposed
- Date: 2026-08-25
- Scope: repository cleanup for this project plus a machine-wide Git ignore safeguard for future projects
## Problem
Superpowers runtime directories contain orchestration state rather than product source. SDD ledgers, task briefs, reports, review diffs, screenshots, generated visual companions, local worktree registrations, command output, and absolute local paths create noisy changes and may expose operational or sensitive context when committed.
The current repository demonstrates the failure mode: `main` tracks 27 files under `.superpowers/`, totaling roughly 374 KB, and its decision to track those files conflicts with the feature branch's broad `.superpowers/` ignore rule.
## Policy
Operational Superpowers and worktree directories are private local state and must not be committed:
```gitignore
**/.superpowers/
**/.claude/superpowers/
**/.worktrees/
```
Intentional design and implementation documents remain normal project source under:
```text
docs/superpowers/specs/
docs/superpowers/plans/
```
The dot-directory and documentation-directory policies are deliberately different. `.superpowers/` is generated session state; `docs/superpowers/` contains authored, reviewable project decisions.
## Defense in Depth
The policy is enforced in two places:
1. The machine-wide Git excludes file protects every local repository, including new projects that have not yet added repository rules.
2. Each repository's `.gitignore` carries the same exclusions so collaborators and other machines receive the protection.
Global-only enforcement would not protect collaborators. Repository-only enforcement would depend on remembering to add the rules to every new project. Both layers are therefore required.
## Current Repository Cleanup
The feature branch will merge the current `origin/main`. The `.gitignore` conflict will resolve in favor of the broad operational-directory exclusions plus the existing application build, dependency, secret, deployment, and generated-artifact rules.
The `.superpowers/` files introduced on `main` will be removed from Git's index and from the resulting repository tree without rewriting history. Local copies will be retained as ignored files when present. The authored files under `docs/superpowers/` remain tracked.
The existing `demo-start` and `demo-complete` tags remain unchanged. No branch or tag history is rewritten.
## Global Configuration
The existing user-level ignore file at `~/.config/git/ignore` will retain its current rules and gain the three operational-directory exclusions. No repository-specific build paths or application secrets belong in the global file.
The global ignore protects only untracked files. It does not retroactively remove already tracked artifacts, which is why the current repository also needs an index cleanup commit.
## Safety and Failure Handling
- Fetch and merge only the named `origin/main` into `feature/uups-bank-demo`.
- If any conflict other than `.gitignore` appears, stop and inspect it rather than applying a broad resolution.
- Remove `.superpowers/` from the index without deleting local copies.
- Do not rewrite published history or force-push.
- Do not move or recreate tags.
- Preserve existing global ignore entries byte-for-byte apart from appending the approved rules once.
- Never print or inspect the contents of operational artifacts as part of cleanup.
## Verification
The cleanup is accepted only when all of the following hold:
1. `git ls-files '.superpowers/**'` returns no paths in the feature result.
2. `git check-ignore` confirms representative `.superpowers/`, `.claude/superpowers/`, and `.worktrees/` paths are ignored by both repository and global policy.
3. `docs/superpowers/specs/` and `docs/superpowers/plans/` remain tracked.
4. The merge commit contains no unexpected path changes beyond the current `main` merge, artifact removal, ignore policy, and this approved policy documentation.
5. `make verify` and `git diff --check` exit successfully.
6. The feature branch pushes normally without force and PR #2 becomes mergeable.
## Non-goals
- Purging `.superpowers/` artifacts from existing Git history.
- Rotating credentials; no credential exposure has been established.
- Changing Superpowers runtime behavior or storage locations.
- Ignoring intentional project documents under `docs/superpowers/`.
- Applying repository-specific ignores to unrelated existing repositories automatically.
+20
View File
@@ -0,0 +1,20 @@
{
"lib/forge-std": {
"tag": {
"name": "v1.16.1",
"rev": "620536fa5277db4e3fd46772d5cbc1ea0696fb43"
}
},
"lib/openzeppelin-contracts-upgradeable": {
"tag": {
"name": "v5.6.1",
"rev": "7bf4727aacdbfaa0f36cbd664654d0c9e1dc52bf"
}
},
"lib/openzeppelin-foundry-upgrades": {
"tag": {
"name": "v0.4.1",
"rev": "258e12e727bfe7f0ec30c51995d01ec88b82efc1"
}
}
}
+27
View File
@@ -0,0 +1,27 @@
[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
Submodule
+1
Submodule lib/forge-std added at 620536fa52
+1258
View File
File diff suppressed because it is too large Load Diff
+18
View File
@@ -0,0 +1,18 @@
{
"name": "uups-bank-demo",
"private": true,
"version": "0.1.0",
"type": "module",
"packageManager": "npm@11.17.0",
"engines": {
"node": ">=24.18.0 <25",
"npm": ">=11.17.0 <12"
},
"devDependencies": {
"@openzeppelin/upgrades-core": "1.46.0"
},
"allowScripts": {
"keccak@3.0.4": true,
"secp256k1@4.0.5": true
}
}
+4
View File
@@ -0,0 +1,4 @@
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/
+83
View File
@@ -0,0 +1,83 @@
// SPDX-License-Identifier: MIT
pragma solidity 0.8.35;
import {console2} from "forge-std/console2.sol";
import {Upgrades} from "openzeppelin-foundry-upgrades/Upgrades.sol";
import {BankV1} from "../src/BankV1.sol";
import {MockUSDC} from "../src/MockUSDC.sol";
import {DemoScript} from "./lib/DemoScript.sol";
contract CheckState is DemoScript {
error Insolvent(uint256 reserves, uint256 liabilities);
error UnknownStage(string stage);
function run() external view {
_run(_manifestPath(ACTIVE_MANIFEST_PATH), vm.envOr("DEMO_EXPECTED_STAGE", string("v1")));
}
function _run(string memory manifestPath, string memory stage) internal view {
_requireSupportedChain(block.chainid);
_printEducationalWarning();
bool deployedStage = keccak256(bytes(stage)) == keccak256("deployed");
Manifest memory manifest = _readManifest(manifestPath, !deployedStage);
if (block.chainid == BASE_SEPOLIA_CHAIN_ID) {
address expectedSender = vm.envAddress("BASE_SEPOLIA_SENDER");
(address presenter, address recipient,) = _requireBaseConfig(expectedSender);
_assertAddress("Presenter", manifest.actors[0].address_, presenter);
_assertAddress("Recipient", manifest.actors[1].address_, recipient);
}
BankV1 bank = BankV1(manifest.proxy);
MockUSDC token = MockUSDC(manifest.token);
address actualImplementation = Upgrades.getImplementationAddress(manifest.proxy);
_assertAddress("implementation", manifest.implementation, actualImplementation);
_assertAddress("owner", manifest.owner, bank.owner());
_assertAddress("asset", manifest.token, address(bank.asset()));
uint256 reserves = token.balanceOf(manifest.proxy);
uint256 liabilities = bank.totalLiabilities();
if (reserves < liabilities) revert Insolvent(reserves, liabilities);
uint256 surplus = reserves - liabilities;
console2.log("network", manifest.network);
console2.log("block", block.number);
console2.log("token", manifest.token);
console2.log("proxy", manifest.proxy);
console2.log("implementation", actualImplementation);
console2.log("owner", bank.owner());
console2.log("paused", bank.paused());
console2.log("version", bank.contractVersion());
for (uint256 i; i < manifest.actors.length; ++i) {
console2.log(manifest.actors[i].label, manifest.actors[i].address_);
console2.log(" bank balance", bank.balanceOf(manifest.actors[i].address_));
console2.log(" token balance", token.balanceOf(manifest.actors[i].address_));
}
console2.log("reserves", reserves);
console2.log("liabilities", liabilities);
console2.log("surplus", surplus);
bytes32 stageHash = keccak256(bytes(stage));
if (deployedStage) {
_assertDeployedState(manifest);
} else if (stageHash == keccak256("v1")) {
_assertV1State(manifest);
_assertUint("surplus", 0, surplus);
} else if (stageHash == keccak256("upgraded")) {
_assertUint("Alice internal balance", 900e6, bank.balanceOf(manifest.actors[1].address_));
_assertUint("Bob internal balance", 500e6, bank.balanceOf(manifest.actors[2].address_));
_assertUint("liabilities", 1_400e6, liabilities);
_assertUint("reserves", 1_400e6, reserves);
_assertUint("surplus", 0, surplus);
_assertUint("version", 2, bank.contractVersion());
} else if (stageHash == keccak256("v2")) {
_assertUint("Alice internal balance", 650e6, bank.balanceOf(manifest.actors[1].address_));
_assertUint("Bob internal balance", 750e6, bank.balanceOf(manifest.actors[2].address_));
_assertUint("liabilities", 1_400e6, liabilities);
_assertUint("reserves", 1_400e6, reserves);
_assertUint("surplus", 0, surplus);
_assertUint("version", 2, bank.contractVersion());
} else if (stageHash != keccak256("invariants")) {
revert UnknownStage(stage);
}
}
}
+74
View File
@@ -0,0 +1,74 @@
// SPDX-License-Identifier: MIT
pragma solidity 0.8.35;
import {Upgrades} from "openzeppelin-foundry-upgrades/Upgrades.sol";
import {BankV1} from "../src/BankV1.sol";
import {MockUSDC} from "../src/MockUSDC.sol";
import {DemoScript} from "./lib/DemoScript.sol";
contract DeployV1 is DemoScript {
function run() external returns (address tokenAddress, address proxy, address implementation) {
_requireSupportedChain(block.chainid);
_printEducationalWarning();
address sender = vm.envAddress("SCRIPT_SENDER");
address recipient;
string memory publicRpcUrl;
if (block.chainid == BASE_SEPOLIA_CHAIN_ID) {
(, recipient, publicRpcUrl) = _requireBaseConfig(sender);
}
if (block.chainid == ANVIL_CHAIN_ID) {
(uint256 ownerKey, address derivedOwner) = _deriveLocalActor(block.chainid, 0);
_assertAddress("SCRIPT_SENDER", derivedOwner, sender);
vm.startBroadcast(ownerKey);
} else {
vm.startBroadcast(sender);
}
MockUSDC token = new MockUSDC(sender);
proxy =
Upgrades.deployUUPSProxy("BankV1.sol:BankV1", abi.encodeCall(BankV1.initialize, (address(token), sender)));
vm.stopBroadcast();
tokenAddress = address(token);
implementation = Upgrades.getImplementationAddress(proxy);
_requireCode("token", tokenAddress);
_requireCode("proxy", proxy);
_requireCode("implementation", implementation);
_assertAddress("owner", sender, BankV1(proxy).owner());
_assertAddress("asset", tokenAddress, address(BankV1(proxy).asset()));
_assertUint("version", 1, BankV1(proxy).contractVersion());
_assertAddress("implementation", implementation, Upgrades.getImplementationAddress(proxy));
Manifest memory manifest;
manifest.schemaVersion = 1;
manifest.network = block.chainid == ANVIL_CHAIN_ID ? "anvil" : "baseSepolia";
manifest.chainId = block.chainid;
manifest.deploymentBlock = 0;
manifest.rpcUrl = block.chainid == ANVIL_CHAIN_ID ? "http://127.0.0.1:8545" : publicRpcUrl;
manifest.explorerBaseUrl = block.chainid == BASE_SEPOLIA_CHAIN_ID ? "https://sepolia.basescan.org" : "";
manifest.token = tokenAddress;
manifest.proxy = proxy;
manifest.implementation = implementation;
manifest.owner = sender;
manifest.actors = _actors(sender, recipient);
_writeManifest(_manifestPath(PENDING_MANIFEST_PATH), manifest);
}
function _actors(address sender, address recipient) private view returns (Actor[] memory actors) {
if (block.chainid == ANVIL_CHAIN_ID) {
actors = new Actor[](3);
actors[0] = Actor({label: "owner", address_: sender});
(, actors[1].address_) = _deriveLocalActor(block.chainid, 1);
(, actors[2].address_) = _deriveLocalActor(block.chainid, 2);
actors[1].label = "Alice";
actors[2].label = "Bob";
} else {
actors = new Actor[](2);
actors[0] = Actor({label: "Presenter", address_: sender});
actors[1] = Actor({label: "Recipient", address_: recipient});
}
}
}
+35
View File
@@ -0,0 +1,35 @@
// SPDX-License-Identifier: MIT
pragma solidity 0.8.35;
import {BankV1} from "../src/BankV1.sol";
import {MockUSDC} from "../src/MockUSDC.sol";
import {DemoScript} from "./lib/DemoScript.sol";
contract SeedBaseSepolia is DemoScript {
uint256 internal constant SEED_AMOUNT = 1_000e6;
function run() external {
if (block.chainid != BASE_SEPOLIA_CHAIN_ID) revert UnsupportedChain(block.chainid);
_printEducationalWarning();
address scriptSender = vm.envAddress("SCRIPT_SENDER");
(address presenter, address recipient,) = _requireBaseConfig(scriptSender);
Manifest memory manifest = _readManifest(_manifestPath(ACTIVE_MANIFEST_PATH), true);
_assertAddress("Presenter", manifest.actors[0].address_, presenter);
_assertAddress("Recipient", manifest.actors[1].address_, recipient);
BankV1 bank = BankV1(manifest.proxy);
MockUSDC token = MockUSDC(manifest.token);
_assertDeployedState(manifest);
vm.startBroadcast(presenter);
token.mint(presenter, SEED_AMOUNT);
token.approve(manifest.proxy, SEED_AMOUNT);
bank.deposit(SEED_AMOUNT);
vm.stopBroadcast();
_assertUint("Presenter internal balance", SEED_AMOUNT, bank.balanceOf(presenter));
_assertUint("Recipient internal balance", 0, bank.balanceOf(recipient));
_assertUint("liabilities", SEED_AMOUNT, bank.totalLiabilities());
_assertUint("reserves", SEED_AMOUNT, token.balanceOf(manifest.proxy));
}
}
+45
View File
@@ -0,0 +1,45 @@
// SPDX-License-Identifier: MIT
pragma solidity 0.8.35;
import {BankV1} from "../src/BankV1.sol";
import {MockUSDC} from "../src/MockUSDC.sol";
import {DemoScript} from "./lib/DemoScript.sol";
contract SeedV1Demo is DemoScript {
function run() external {
if (block.chainid != ANVIL_CHAIN_ID) revert UnsupportedChain(block.chainid);
_printEducationalWarning();
Manifest memory manifest = _readManifest(_manifestPath(ACTIVE_MANIFEST_PATH), true);
(uint256 ownerKey, address owner) = _deriveLocalActor(block.chainid, 0);
(uint256 aliceKey, address alice) = _deriveLocalActor(block.chainid, 1);
(uint256 bobKey, address bob) = _deriveLocalActor(block.chainid, 2);
_assertAddress("owner actor", manifest.owner, owner);
_assertAddress("Alice actor", manifest.actors[1].address_, alice);
_assertAddress("Bob actor", manifest.actors[2].address_, bob);
MockUSDC token = MockUSDC(manifest.token);
BankV1 bank = BankV1(manifest.proxy);
vm.startBroadcast(ownerKey);
token.mint(alice, 2_000e6);
token.mint(bob, 1_000e6);
vm.stopBroadcast();
vm.startBroadcast(aliceKey);
token.approve(manifest.proxy, 1_000e6);
bank.deposit(1_000e6);
vm.stopBroadcast();
vm.startBroadcast(bobKey);
token.approve(manifest.proxy, 500e6);
bank.deposit(500e6);
vm.stopBroadcast();
vm.startBroadcast(aliceKey);
bank.withdraw(100e6);
vm.stopBroadcast();
_assertV1State(manifest);
}
}
+77
View File
@@ -0,0 +1,77 @@
// SPDX-License-Identifier: MIT
pragma solidity 0.8.35;
import {BankV2} from "../src/BankV2.sol";
import {MockUSDC} from "../src/MockUSDC.sol";
import {DemoScript} from "./lib/DemoScript.sol";
contract TransferV2Demo is DemoScript {
function run() external {
_run(_manifestPath(ACTIVE_MANIFEST_PATH));
}
function _run(string memory manifestPath) internal {
_requireSupportedChain(block.chainid);
_printEducationalWarning();
Manifest memory manifest = _readManifest(manifestPath, true);
if (block.chainid == BASE_SEPOLIA_CHAIN_ID) {
_runBase(manifest);
return;
}
_runLocal(manifest);
}
function _runLocal(Manifest memory manifest) private {
(uint256 aliceKey, address alice) = _deriveLocalActor(block.chainid, 1);
address bob = manifest.actors[2].address_;
_assertAddress("Alice actor", manifest.actors[1].address_, alice);
BankV2 bank = BankV2(manifest.proxy);
MockUSDC token = MockUSDC(manifest.token);
_assertUint("version", 2, bank.contractVersion());
_assertUint("Alice internal balance", 900e6, bank.balanceOf(alice));
_assertUint("Bob internal balance", 500e6, bank.balanceOf(bob));
uint256 liabilities = bank.totalLiabilities();
uint256 reserves = token.balanceOf(manifest.proxy);
_assertUint("liabilities", 1_400e6, liabilities);
_assertUint("reserves", 1_400e6, reserves);
vm.startBroadcast(aliceKey);
bank.transferBalance(bob, 250e6);
vm.stopBroadcast();
_assertUint("Alice internal balance", 650e6, bank.balanceOf(alice));
_assertUint("Bob internal balance", 750e6, bank.balanceOf(bob));
_assertUint("liabilities", liabilities, bank.totalLiabilities());
_assertUint("reserves", reserves, token.balanceOf(manifest.proxy));
_assertUint("surplus", 0, token.balanceOf(manifest.proxy) - bank.totalLiabilities());
}
function _runBase(Manifest memory manifest) private {
address scriptSender = vm.envAddress("SCRIPT_SENDER");
(address presenter, address recipient,) = _requireBaseConfig(scriptSender);
_assertAddress("Presenter", manifest.actors[0].address_, presenter);
_assertAddress("Recipient", manifest.actors[1].address_, recipient);
BankV2 bank = BankV2(manifest.proxy);
MockUSDC token = MockUSDC(manifest.token);
_assertUint("version", 2, bank.contractVersion());
_assertUint("Presenter internal balance", 1_000e6, bank.balanceOf(presenter));
_assertUint("Recipient internal balance", 0, bank.balanceOf(recipient));
uint256 liabilities = bank.totalLiabilities();
uint256 reserves = token.balanceOf(manifest.proxy);
_assertUint("liabilities", 1_000e6, liabilities);
_assertUint("reserves", 1_000e6, reserves);
vm.startBroadcast(presenter);
bank.transferBalance(recipient, 250e6);
vm.stopBroadcast();
_assertUint("Presenter internal balance", 750e6, bank.balanceOf(presenter));
_assertUint("Recipient internal balance", 250e6, bank.balanceOf(recipient));
_assertUint("liabilities", liabilities, bank.totalLiabilities());
_assertUint("reserves", reserves, token.balanceOf(manifest.proxy));
_assertUint("surplus", 0, token.balanceOf(manifest.proxy) - bank.totalLiabilities());
}
}
+191
View File
@@ -0,0 +1,191 @@
// SPDX-License-Identifier: MIT
pragma solidity 0.8.35;
import {Options, Upgrades} from "openzeppelin-foundry-upgrades/Upgrades.sol";
import {console2} from "forge-std/console2.sol";
import {BankV1} from "../src/BankV1.sol";
import {MockUSDC} from "../src/MockUSDC.sol";
import {DemoScript} from "./lib/DemoScript.sol";
contract UpgradeV2 is DemoScript {
string internal constant UPGRADE_PENDING_MANIFEST_PATH = "deployments/upgrade-pending.json";
error UnexpectedVersion(uint256 version);
struct Snapshot {
address proxy;
address implementation;
address owner;
address asset;
bool paused;
uint256[] balances;
uint256 liabilities;
uint256 reserves;
uint256 surplus;
uint256 deploymentBlock;
uint256 version;
}
function run() external returns (bool upgraded, address implementation) {
_requireSupportedChain(block.chainid);
_printEducationalWarning();
string memory activePath = vm.envOr("UPGRADE_ACTIVE_MANIFEST_PATH", ACTIVE_MANIFEST_PATH);
return _run(activePath, _manifestPath(UPGRADE_PENDING_MANIFEST_PATH), vm.envAddress("SCRIPT_SENDER"));
}
function _run(string memory activePath, string memory pendingPath, address sender)
internal
returns (bool upgraded, address implementation)
{
address configuredRecipient;
if (block.chainid == BASE_SEPOLIA_CHAIN_ID) {
(, configuredRecipient,) = _requireBaseConfig(sender);
}
Manifest memory manifest = _readManifest(activePath, true);
if (block.chainid == BASE_SEPOLIA_CHAIN_ID) {
_assertAddress("Presenter", manifest.actors[0].address_, sender);
_assertAddress("Recipient", manifest.actors[1].address_, configuredRecipient);
}
BankV1 bank = BankV1(manifest.proxy);
address actualImplementation = Upgrades.getImplementationAddress(manifest.proxy);
_assertAddress("implementation", manifest.implementation, actualImplementation);
_assertAddress("owner", manifest.owner, bank.owner());
_assertAddress("asset", manifest.token, address(bank.asset()));
_assertAddress("SCRIPT_SENDER", bank.owner(), sender);
uint256 version = bank.contractVersion();
if (version == 2) {
implementation = actualImplementation;
_writeNoopMarker(pendingPath, manifest, vm.getNonce(bank.owner()), block.number);
console2.log("BankV2 already active; no upgrade broadcast.");
return (false, implementation);
}
if (version != 1) revert UnexpectedVersion(version);
Snapshot memory before_ = _snapshot(manifest);
Options memory opts;
opts.referenceContract = "BankV1.sol:BankV1";
Upgrades.validateUpgrade("BankV2.sol:BankV2", opts);
if (block.chainid == ANVIL_CHAIN_ID) {
(uint256 ownerKey, address derivedOwner) = _deriveLocalActor(block.chainid, 0);
_assertAddress("local owner", sender, derivedOwner);
vm.startBroadcast(ownerKey);
} else {
vm.startBroadcast(sender);
}
Upgrades.upgradeProxy(manifest.proxy, "BankV2.sol:BankV2", "", opts);
vm.stopBroadcast();
Snapshot memory after_ = _snapshot(manifest);
implementation = after_.implementation;
_requireCode("implementation", implementation);
if (implementation == before_.implementation) {
revert UnexpectedAddress("implementation changed", before_.implementation, implementation);
}
_assertUint("version", 2, after_.version);
_assertSnapshotUnchanged(before_, after_);
_writeUpgradeMarker(pendingPath, manifest, before_, implementation);
return (true, implementation);
}
function _snapshot(Manifest memory manifest) internal view returns (Snapshot memory snapshot) {
BankV1 bank = BankV1(manifest.proxy);
uint256 reserves = MockUSDC(manifest.token).balanceOf(manifest.proxy);
uint256 liabilities = bank.totalLiabilities();
snapshot.proxy = manifest.proxy;
snapshot.implementation = Upgrades.getImplementationAddress(manifest.proxy);
snapshot.owner = bank.owner();
snapshot.asset = address(bank.asset());
snapshot.paused = bank.paused();
snapshot.balances = new uint256[](manifest.actors.length);
for (uint256 i; i < manifest.actors.length; ++i) {
snapshot.balances[i] = bank.balanceOf(manifest.actors[i].address_);
}
snapshot.liabilities = liabilities;
snapshot.reserves = reserves;
snapshot.surplus = reserves - liabilities;
snapshot.deploymentBlock = manifest.deploymentBlock;
snapshot.version = bank.contractVersion();
}
function _assertSnapshotUnchanged(Snapshot memory before_, Snapshot memory after_) internal pure {
_assertAddress("proxy", before_.proxy, after_.proxy);
_assertAddress("owner", before_.owner, after_.owner);
_assertAddress("asset", before_.asset, after_.asset);
_assertUint("paused", before_.paused ? 1 : 0, after_.paused ? 1 : 0);
_assertUint("actor count", before_.balances.length, after_.balances.length);
for (uint256 i; i < before_.balances.length; ++i) {
_assertUint("actor balance", before_.balances[i], after_.balances[i]);
}
_assertUint("liabilities", before_.liabilities, after_.liabilities);
_assertUint("reserves", before_.reserves, after_.reserves);
_assertUint("surplus", before_.surplus, after_.surplus);
_assertUint("deployment block", before_.deploymentBlock, after_.deploymentBlock);
}
function _writeNoopMarker(string memory path, Manifest memory manifest, uint256 ownerNonce, uint256 observedBlock)
private
{
vm.writeJson(
string.concat(
'{"mode":"noop","chainId":',
vm.toString(manifest.chainId),
',"observedBlock":',
vm.toString(observedBlock),
',"ownerNonce":',
vm.toString(ownerNonce),
',"proxy":"',
vm.toString(manifest.proxy),
'","implementation":"',
vm.toString(manifest.implementation),
'"}'
),
path
);
}
function _writeUpgradeMarker(
string memory path,
Manifest memory manifest,
Snapshot memory before_,
address implementation
) private {
string memory balances = "[";
for (uint256 i; i < manifest.actors.length; ++i) {
if (i != 0) balances = string.concat(balances, ",");
balances = string.concat(
balances,
'{"address":"',
vm.toString(manifest.actors[i].address_),
'","balance":',
vm.toString(before_.balances[i]),
"}"
);
}
balances = string.concat(balances, "]");
string memory snapshot = string.concat('{"proxy":"', vm.toString(before_.proxy), '"');
snapshot = string.concat(snapshot, ',"implementation":"', vm.toString(before_.implementation), '"');
snapshot = string.concat(snapshot, ',"owner":"', vm.toString(before_.owner), '"');
snapshot = string.concat(snapshot, ',"asset":"', vm.toString(before_.asset), '"');
snapshot = string.concat(snapshot, ',"paused":', vm.toString(before_.paused));
snapshot = string.concat(snapshot, ',"balances":', balances);
snapshot = string.concat(snapshot, ',"liabilities":', vm.toString(before_.liabilities));
snapshot = string.concat(snapshot, ',"reserves":', vm.toString(before_.reserves));
snapshot = string.concat(snapshot, ',"surplus":', vm.toString(before_.surplus));
snapshot = string.concat(snapshot, ',"deploymentBlock":', vm.toString(before_.deploymentBlock));
snapshot = string.concat(snapshot, ',"version":', vm.toString(before_.version), "}");
string memory json = string.concat('{"mode":"upgrade","network":"', manifest.network, '"');
json = string.concat(json, ',"chainId":', vm.toString(manifest.chainId));
json = string.concat(json, ',"token":"', vm.toString(manifest.token), '"');
json = string.concat(json, ',"proxy":"', vm.toString(manifest.proxy), '"');
json = string.concat(json, ',"previousImplementation":"', vm.toString(manifest.implementation), '"');
json = string.concat(json, ',"implementation":"', vm.toString(implementation), '"');
json = string.concat(json, ',"owner":"', vm.toString(manifest.owner), '"');
json = string.concat(json, ',"deploymentBlock":', vm.toString(manifest.deploymentBlock));
json = string.concat(json, ',"snapshot":', snapshot, "}");
vm.writeJson(json, path);
}
}
+320
View File
@@ -0,0 +1,320 @@
// SPDX-License-Identifier: MIT
pragma solidity 0.8.35;
import {Script} from "forge-std/Script.sol";
import {console2} from "forge-std/console2.sol";
import {BankV1} from "../../src/BankV1.sol";
import {MockUSDC} from "../../src/MockUSDC.sol";
abstract contract DemoScript is Script {
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";
string internal constant EDUCATIONAL_WARNING = unicode"Educational demo — mock token — never use real funds.";
error UnsupportedChain(uint256 chainId);
error ManifestChainMismatch(uint256 expected, uint256 actual);
error MissingCode(string label, address target);
error InvalidDeploymentBlock();
error InvalidManifestSchema(uint256 schemaVersion);
error InvalidActorConfiguration();
error InvalidPublicRpcUrl();
error UnexpectedState(string label, uint256 expected, uint256 actual);
error UnexpectedAddress(string label, address expected, address actual);
struct Actor {
string label;
address address_;
}
struct Manifest {
uint256 schemaVersion;
string network;
uint256 chainId;
uint256 deploymentBlock;
string rpcUrl;
string explorerBaseUrl;
address token;
address proxy;
address implementation;
address owner;
Actor[] actors;
}
function _requireSupportedChain(uint256 chainId) internal pure {
if (chainId != ANVIL_CHAIN_ID && chainId != BASE_SEPOLIA_CHAIN_ID) revert UnsupportedChain(chainId);
}
function _deriveLocalActor(uint256 chainId, uint32 index)
internal
pure
returns (uint256 privateKey, address actor)
{
if (chainId != ANVIL_CHAIN_ID) revert UnsupportedChain(chainId);
privateKey = vm.deriveKey(ANVIL_TEST_PHRASE, index);
actor = vm.addr(privateKey);
}
function _manifestPath(string memory defaultPath) internal view returns (string memory) {
return vm.envOr("DEPLOYMENT_MANIFEST_PATH", defaultPath);
}
function _requireBaseSender(address scriptSender) internal view returns (address sender) {
sender = vm.envAddress("BASE_SEPOLIA_SENDER");
_assertAddress("BASE_SEPOLIA_SENDER", scriptSender, sender);
}
function _requireBaseConfig(address scriptSender)
internal
view
returns (address sender, address recipient, string memory publicRpcUrl)
{
sender = _requireBaseSender(scriptSender);
recipient = vm.envAddress("BASE_SEPOLIA_RECIPIENT");
if (sender == address(0) || recipient == address(0) || sender == recipient) revert InvalidActorConfiguration();
publicRpcUrl = vm.envString("BASE_SEPOLIA_PUBLIC_RPC_URL");
_requirePublicHttpsUrl(publicRpcUrl);
}
function _requirePublicHttpsUrl(string memory value) internal pure {
bytes memory url = bytes(value);
bytes memory prefix = bytes("https://");
if (url.length <= prefix.length) revert InvalidPublicRpcUrl();
for (uint256 i; i < prefix.length; ++i) {
if (url[i] != prefix[i]) revert InvalidPublicRpcUrl();
}
bool query;
uint256 queryStart;
for (uint256 i = prefix.length; i < url.length; ++i) {
bytes1 character = url[i];
if (!query && character == "@") revert InvalidPublicRpcUrl();
if (character == "?") {
query = true;
queryStart = i + 1;
break;
}
if (character == "#") break;
}
if (!query) return;
bytes memory lowered = new bytes(url.length - queryStart);
for (uint256 i; i < lowered.length; ++i) {
uint8 character = uint8(url[queryStart + i]);
lowered[i] = character >= 65 && character <= 90 ? bytes1(character + 32) : bytes1(character);
}
if (
_containsBytes(lowered, bytes("key=")) || _containsBytes(lowered, bytes("token="))
|| _containsBytes(lowered, bytes("secret=")) || _containsBytes(lowered, bytes("password="))
|| _containsBytes(lowered, bytes("credential="))
) revert InvalidPublicRpcUrl();
}
function _containsBytes(bytes memory haystack, bytes memory needle) private pure returns (bool) {
if (needle.length > haystack.length) return false;
for (uint256 i; i + needle.length <= haystack.length; ++i) {
bool matches = true;
for (uint256 j; j < needle.length; ++j) {
if (haystack[i + j] != needle[j]) {
matches = false;
break;
}
}
if (matches) return true;
}
return false;
}
function _readManifest(string memory path, bool active) internal view returns (Manifest memory manifest) {
string memory json = vm.readFile(path);
_assertExactManifestSchema(json);
manifest.schemaVersion = vm.parseJsonUint(json, ".schemaVersion");
manifest.network = vm.parseJsonString(json, ".network");
manifest.chainId = vm.parseJsonUint(json, ".chainId");
manifest.deploymentBlock = vm.parseJsonUint(json, ".deploymentBlock");
if (vm.keyExistsJson(json, ".rpcUrl")) manifest.rpcUrl = vm.parseJsonString(json, ".rpcUrl");
if (vm.keyExistsJson(json, ".explorerBaseUrl")) {
manifest.explorerBaseUrl = vm.parseJsonString(json, ".explorerBaseUrl");
}
manifest.token = vm.parseJsonAddress(json, ".token");
manifest.proxy = vm.parseJsonAddress(json, ".proxy");
manifest.implementation = vm.parseJsonAddress(json, ".implementation");
manifest.owner = vm.parseJsonAddress(json, ".owner");
if (manifest.schemaVersion != 1) revert InvalidManifestSchema(manifest.schemaVersion);
if (manifest.chainId != block.chainid) revert ManifestChainMismatch(block.chainid, manifest.chainId);
if (active && manifest.deploymentBlock == 0) revert InvalidDeploymentBlock();
manifest.actors = _parseActors(json, manifest.chainId);
_validateActorConfiguration(manifest);
_requireCode("token", manifest.token);
_requireCode("proxy", manifest.proxy);
_requireCode("implementation", manifest.implementation);
}
function _requireCode(string memory label, address target) internal view {
if (target == address(0) || target.code.length == 0) revert MissingCode(label, target);
}
function _assertExactManifestSchema(string memory json) private view {
string[] memory keys = vm.parseJsonKeys(json, ".");
if (keys.length < 9 || keys.length > 11) revert InvalidManifestSchema(0);
bool[9] memory required;
for (uint256 i; i < keys.length; ++i) {
string memory key = keys[i];
if (_equals(key, "schemaVersion")) required[0] = true;
else if (_equals(key, "network")) required[1] = true;
else if (_equals(key, "chainId")) required[2] = true;
else if (_equals(key, "deploymentBlock")) required[3] = true;
else if (_equals(key, "token")) required[4] = true;
else if (_equals(key, "proxy")) required[5] = true;
else if (_equals(key, "implementation")) required[6] = true;
else if (_equals(key, "owner")) required[7] = true;
else if (_equals(key, "actors")) required[8] = true;
else if (!_equals(key, "rpcUrl") && !_equals(key, "explorerBaseUrl")) revert InvalidManifestSchema(0);
}
for (uint256 i; i < required.length; ++i) {
if (!required[i]) revert InvalidManifestSchema(0);
}
}
function _parseActors(string memory json, uint256 chainId) private view returns (Actor[] memory actors) {
uint256 actorCount = chainId == ANVIL_CHAIN_ID ? 3 : 2;
actors = new Actor[](actorCount);
for (uint256 i; i < actorCount; ++i) {
string memory index = vm.toString(i);
_assertExactActorSchema(json, index);
actors[i].label = vm.parseJsonString(json, string.concat(".actors[", index, "].label"));
actors[i].address_ = vm.parseJsonAddress(json, string.concat(".actors[", index, "].address"));
}
if (vm.keyExistsJson(json, string.concat(".actors[", vm.toString(actorCount), "]"))) {
revert InvalidActorConfiguration();
}
}
function _assertExactActorSchema(string memory json, string memory index) private view {
string[] memory keys = vm.parseJsonKeys(json, string.concat(".actors[", index, "]"));
bool hasLabel;
bool hasAddress;
if (keys.length != 2) revert InvalidManifestSchema(0);
for (uint256 i; i < keys.length; ++i) {
if (_equals(keys[i], "label")) hasLabel = true;
else if (_equals(keys[i], "address")) hasAddress = true;
else revert InvalidManifestSchema(0);
}
if (!hasLabel || !hasAddress) revert InvalidManifestSchema(0);
}
function _validateActorConfiguration(Manifest memory manifest) internal pure {
if (manifest.chainId == ANVIL_CHAIN_ID) {
if (
manifest.actors.length != 3 || !_equals(manifest.network, "anvil")
|| !_equals(manifest.actors[0].label, "owner") || !_equals(manifest.actors[1].label, "Alice")
|| !_equals(manifest.actors[2].label, "Bob")
) revert InvalidActorConfiguration();
(, address owner) = _deriveLocalActor(ANVIL_CHAIN_ID, 0);
(, address alice) = _deriveLocalActor(ANVIL_CHAIN_ID, 1);
(, address bob) = _deriveLocalActor(ANVIL_CHAIN_ID, 2);
if (
manifest.actors[0].address_ != manifest.owner || manifest.actors[0].address_ != owner
|| manifest.actors[1].address_ != alice || manifest.actors[2].address_ != bob
) revert InvalidActorConfiguration();
return;
}
if (
manifest.chainId != BASE_SEPOLIA_CHAIN_ID || manifest.actors.length != 2
|| !_equals(manifest.network, "baseSepolia") || !_equals(manifest.actors[0].label, "Presenter")
|| !_equals(manifest.actors[1].label, "Recipient") || manifest.actors[0].address_ != manifest.owner
) revert InvalidActorConfiguration();
if (
manifest.actors[0].address_ == address(0) || manifest.actors[1].address_ == address(0)
|| manifest.actors[0].address_ == manifest.actors[1].address_
) revert InvalidActorConfiguration();
}
function _educationalWarning() internal pure returns (string memory) {
return EDUCATIONAL_WARNING;
}
function _printEducationalWarning() internal pure {
console2.log(EDUCATIONAL_WARNING);
}
function _equals(string memory left, string memory right) private pure returns (bool) {
return keccak256(bytes(left)) == keccak256(bytes(right));
}
function _serializeManifest(Manifest memory manifest) internal view returns (string memory json) {
json = string.concat(
'{"schemaVersion":',
vm.toString(manifest.schemaVersion),
',"network":"',
manifest.network,
'","chainId":',
vm.toString(manifest.chainId),
',"deploymentBlock":',
vm.toString(manifest.deploymentBlock)
);
if (bytes(manifest.rpcUrl).length != 0) json = string.concat(json, ',"rpcUrl":"', manifest.rpcUrl, '"');
if (bytes(manifest.explorerBaseUrl).length != 0) {
json = string.concat(json, ',"explorerBaseUrl":"', manifest.explorerBaseUrl, '"');
}
json = string.concat(
json,
',"token":"',
vm.toString(manifest.token),
'","proxy":"',
vm.toString(manifest.proxy),
'","implementation":"',
vm.toString(manifest.implementation),
'","owner":"',
vm.toString(manifest.owner),
'","actors":',
_serializeActors(manifest.actors),
"}"
);
}
function _serializeActors(Actor[] memory actors) private view returns (string memory json) {
json = "[";
for (uint256 i; i < actors.length; ++i) {
if (i != 0) json = string.concat(json, ",");
json = string.concat(
json, '{"label":"', actors[i].label, '","address":"', vm.toString(actors[i].address_), '"}'
);
}
json = string.concat(json, "]");
}
function _writeManifest(string memory path, Manifest memory manifest) internal {
vm.writeJson(_serializeManifest(manifest), path);
}
function _assertDeployedState(Manifest memory manifest) internal view {
BankV1 bank = BankV1(manifest.proxy);
_assertAddress("owner", manifest.owner, bank.owner());
_assertAddress("asset", manifest.token, address(bank.asset()));
_assertUint("version", 1, bank.contractVersion());
_assertUint("liabilities", 0, bank.totalLiabilities());
_assertUint("reserves", 0, MockUSDC(manifest.token).balanceOf(manifest.proxy));
}
function _assertV1State(Manifest memory manifest) internal view {
BankV1 bank = BankV1(manifest.proxy);
_assertUint("Alice internal balance", 900e6, bank.balanceOf(manifest.actors[1].address_));
_assertUint("Bob internal balance", 500e6, bank.balanceOf(manifest.actors[2].address_));
_assertUint("liabilities", 1_400e6, bank.totalLiabilities());
_assertUint("reserves", 1_400e6, MockUSDC(manifest.token).balanceOf(manifest.proxy));
_assertUint("version", 1, bank.contractVersion());
}
function _assertUint(string memory label, uint256 expected, uint256 actual) internal pure {
if (actual != expected) revert UnexpectedState(label, expected, actual);
}
function _assertAddress(string memory label, address expected, address actual) internal pure {
if (actual != expected) revert UnexpectedAddress(label, expected, actual);
}
}
+94
View File
@@ -0,0 +1,94 @@
// SPDX-License-Identifier: MIT
pragma solidity 0.8.35;
import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";
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 {
using SafeERC20 for IERC20;
error InvalidAsset(address asset);
error ZeroAmount();
error InsufficientBalance(address account, uint256 available, uint256 requested);
error UnexpectedAssetDelta(uint256 expected, uint256 actual);
event Deposited(address indexed account, uint256 amount);
event Withdrawn(address indexed account, uint256 amount);
IERC20 internal _asset;
mapping(address account => uint256 balance) internal _balances;
uint256 internal _totalLiabilities;
uint256[47] private __gap;
/// @custom:oz-upgrades-unsafe-allow constructor
constructor() {
_disableInitializers();
}
function initialize(address asset_, address initialOwner) public initializer {
if (asset_ == address(0)) {
revert InvalidAsset(asset_);
}
__Ownable_init(initialOwner);
__Pausable_init();
_asset = IERC20(asset_);
}
function pause() external onlyOwner {
_pause();
}
function unpause() external onlyOwner {
_unpause();
}
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);
}
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);
}
function asset() external view returns (IERC20) {
return _asset;
}
function balanceOf(address account) external view returns (uint256) {
return _balances[account];
}
function totalLiabilities() external view returns (uint256) {
return _totalLiabilities;
}
function contractVersion() public pure virtual returns (uint256) {
return 1;
}
function _authorizeUpgrade(address) internal override onlyOwner {}
}
+29
View File
@@ -0,0 +1,29 @@
// SPDX-License-Identifier: MIT
pragma solidity 0.8.35;
import {BankV1} from "./BankV1.sol";
/// @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;
}
}
+18
View File
@@ -0,0 +1,18 @@
// SPDX-License-Identifier: MIT
pragma solidity 0.8.35;
import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol";
import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol";
/// @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);
}
}
+62
View File
@@ -0,0 +1,62 @@
// SPDX-License-Identifier: MIT
pragma solidity 0.8.35;
import {BankTestBase} from "./helpers/BankTestBase.sol";
import {BankV2} from "../src/BankV2.sol";
import {Options, Upgrades} from "openzeppelin-foundry-upgrades/Upgrades.sol";
import {BankV2Handler} from "./helpers/BankV2Handler.sol";
contract BankInvariantTest is BankTestBase {
BankV2Handler internal handler;
function setUp() public override {
super.setUp();
Options memory opts;
opts.referenceContract = "BankV1.sol:BankV1";
Upgrades.upgradeProxy(proxy, "BankV2.sol:BankV2", "", opts, owner);
BankV2 bankV2 = BankV2(proxy);
handler = new BankV2Handler(token, bankV2);
vm.prank(owner);
token.transferOwnership(address(handler));
targetContract(address(handler));
bytes4[] memory selectors = new bytes4[](4);
selectors[0] = handler.deposit.selector;
selectors[1] = handler.withdraw.selector;
selectors[2] = handler.donate.selector;
selectors[3] = handler.transfer.selector;
targetSelector(FuzzSelector({addr: address(handler), selectors: selectors}));
}
function testHandlerExecutesDeterministicTrackedTransfer() public {
handler.deposit(0, 40e6);
handler.transfer(0, 0, 15e6);
assertEq(bank.balanceOf(handler.actorAt(0)), 25e6);
assertEq(bank.balanceOf(handler.actorAt(1)), 15e6);
assertEq(handler.ghostTransferred(), 15e6);
assertEq(bank.totalLiabilities(), 40e6);
assertEq(token.balanceOf(address(bank)), 40e6);
}
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))
);
}
}
+186
View File
@@ -0,0 +1,186 @@
// SPDX-License-Identifier: MIT
pragma solidity 0.8.35;
import {ERC1967Utils} from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Utils.sol";
import {Initializable} from "@openzeppelin/contracts/proxy/utils/Initializable.sol";
import {OwnableUpgradeable} from "@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol";
import {Options, Upgrades} from "openzeppelin-foundry-upgrades/Upgrades.sol";
import {BankV1} from "../src/BankV1.sol";
import {BankV2} from "../src/BankV2.sol";
import {BankTestBase} from "./helpers/BankTestBase.sol";
import {IncompatibleBank} from "./mocks/IncompatibleBank.sol";
import {NonUUPSImplementation} from "./mocks/NonUUPSImplementation.sol";
contract UpgradeValidator {
function validate(string memory contractName, Options memory opts) external {
Upgrades.validateUpgrade(contractName, opts);
}
}
contract BankUpgradeTest is BankTestBase {
struct Snapshot {
address proxyAddress;
address implementationAddress;
address ownerAddress;
address assetAddress;
bool pausedState;
uint256 aliceBalance;
uint256 bobBalance;
uint256 liabilities;
uint256 reserves;
uint256 surplus;
uint256 version;
}
Snapshot private beforeUpgrade;
function setUp() public override {
super.setUp();
_deposit(alice, 1_000e6);
_deposit(bob, 500e6);
vm.prank(owner);
token.mint(stranger, 75e6);
vm.prank(stranger);
assertTrue(token.transfer(proxy, 75e6));
vm.prank(owner);
bank.pause();
uint256 liabilities = bank.totalLiabilities();
uint256 reserves = token.balanceOf(proxy);
beforeUpgrade = Snapshot({
proxyAddress: proxy,
implementationAddress: Upgrades.getImplementationAddress(proxy),
ownerAddress: bank.owner(),
assetAddress: address(bank.asset()),
pausedState: bank.paused(),
aliceBalance: bank.balanceOf(alice),
bobBalance: bank.balanceOf(bob),
liabilities: liabilities,
reserves: reserves,
surplus: reserves - liabilities,
version: bank.contractVersion()
});
}
function testValidatedOwnerUpgradeChangesOnlyImplementationAndVersion() public {
BankV2 upgraded = _validatedOwnerUpgrade();
address implementationAfter = Upgrades.getImplementationAddress(proxy);
assertEq(address(upgraded), beforeUpgrade.proxyAddress);
assertNotEq(implementationAfter, beforeUpgrade.implementationAddress);
assertGt(implementationAfter.code.length, 0);
assertEq(beforeUpgrade.version, 1);
assertEq(upgraded.contractVersion(), 2);
_assertSnapshotPreserved(upgraded);
}
function testEveryV1MutationStillWorksAfterUpgrade() public {
BankV2 upgraded = _validatedOwnerUpgrade();
vm.prank(owner);
upgraded.unpause();
assertFalse(upgraded.paused());
_mintAndApprove(stranger, 100e6);
vm.prank(stranger);
upgraded.deposit(100e6);
vm.prank(alice);
upgraded.withdraw(200e6);
assertEq(address(upgraded.asset()), address(token));
assertEq(upgraded.owner(), owner);
assertEq(upgraded.balanceOf(alice), 800e6);
assertEq(upgraded.balanceOf(bob), 500e6);
assertEq(upgraded.balanceOf(stranger), 100e6);
assertEq(upgraded.totalLiabilities(), 1_400e6);
assertEq(token.balanceOf(proxy), 1_475e6);
assertEq(upgraded.contractVersion(), 2);
vm.prank(owner);
upgraded.pause();
assertTrue(upgraded.paused());
vm.prank(owner);
upgraded.unpause();
assertFalse(upgraded.paused());
}
function testNewImplementationCannotBeInitializedDirectly() public {
_validatedOwnerUpgrade();
address upgradedImplementation = Upgrades.getImplementationAddress(proxy);
vm.expectRevert(Initializable.InvalidInitialization.selector);
BankV2(upgradedImplementation).initialize(address(token), owner);
}
function testNonOwnerUpgradeToAndCallRejectsWithUnauthorizedAccount() public {
address candidate = address(new BankV2());
vm.prank(stranger);
vm.expectRevert(abi.encodeWithSelector(OwnableUpgradeable.OwnableUnauthorizedAccount.selector, stranger));
bank.upgradeToAndCall(candidate, "");
assertEq(Upgrades.getImplementationAddress(proxy), beforeUpgrade.implementationAddress);
assertEq(bank.contractVersion(), 1);
}
function testValidateUpgradeRejectsIncompatibleApplicationStorageLayout() public {
Options memory opts;
opts.referenceContract = "BankV1.sol:BankV1";
UpgradeValidator validator = new UpgradeValidator();
try validator.validate("IncompatibleBank.sol:IncompatibleBank", opts) {
fail("incompatible storage layout was accepted");
} catch Error(string memory reason) {
assertTrue(vm.contains(reason, "Upgrade safety validation failed"));
assertTrue(vm.contains(reason, "Deleted `_asset`"));
assertTrue(vm.contains(reason, "Inserted `_asset`"));
}
}
function testOwnerUpgradeToAndCallRejectsNonUUPSImplementationAtRuntime() public {
NonUUPSImplementation candidate = new NonUUPSImplementation();
vm.prank(owner);
vm.expectRevert(abi.encodeWithSelector(ERC1967Utils.ERC1967InvalidImplementation.selector, address(candidate)));
bank.upgradeToAndCall(address(candidate), "");
assertEq(Upgrades.getImplementationAddress(proxy), beforeUpgrade.implementationAddress);
assertEq(bank.contractVersion(), 1);
}
function _validatedOwnerUpgrade() private returns (BankV2 upgraded) {
Options memory opts;
opts.referenceContract = "BankV1.sol:BankV1";
Upgrades.upgradeProxy(proxy, "BankV2.sol:BankV2", "", opts, owner);
upgraded = BankV2(proxy);
}
function _assertSnapshotPreserved(BankV2 upgraded) private view {
assertEq(address(upgraded), beforeUpgrade.proxyAddress);
assertEq(upgraded.owner(), beforeUpgrade.ownerAddress);
assertEq(address(upgraded.asset()), beforeUpgrade.assetAddress);
assertEq(upgraded.paused(), beforeUpgrade.pausedState);
assertEq(upgraded.balanceOf(alice), beforeUpgrade.aliceBalance);
assertEq(upgraded.balanceOf(bob), beforeUpgrade.bobBalance);
assertEq(upgraded.totalLiabilities(), beforeUpgrade.liabilities);
assertEq(token.balanceOf(proxy), beforeUpgrade.reserves);
assertEq(token.balanceOf(proxy) - upgraded.totalLiabilities(), beforeUpgrade.surplus);
}
function _deposit(address account, uint256 amount) private {
_mintAndApprove(account, amount);
vm.prank(account);
bank.deposit(amount);
}
function _mintAndApprove(address account, uint256 amount) private {
vm.prank(owner);
token.mint(account, amount);
vm.prank(account);
token.approve(proxy, amount);
}
}
+326
View File
@@ -0,0 +1,326 @@
// SPDX-License-Identifier: MIT
pragma solidity 0.8.35;
import {IERC20Errors} from "@openzeppelin/contracts/interfaces/draft-IERC6093.sol";
import {PausableUpgradeable} from "@openzeppelin/contracts-upgradeable/utils/PausableUpgradeable.sol";
import {ReentrancyGuardTransient} from "@openzeppelin/contracts/utils/ReentrancyGuardTransient.sol";
import {Upgrades} from "openzeppelin-foundry-upgrades/Upgrades.sol";
import {BankV1} from "../src/BankV1.sol";
import {BankTestBase} from "./helpers/BankTestBase.sol";
import {FeeOnTransferToken} from "./mocks/FeeOnTransferToken.sol";
import {ReentrantToken} from "./mocks/ReentrantToken.sol";
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);
contract BankV1CustodyTest is BankTestBase {
uint256 private constant MAX_DEPOSIT = 1_000_000e6;
function testDepositCreditsExactCustomerAndLiabilityAgainstReceivedReserves() public {
_mintAndApprove(alice, 1_000e6, 100e6);
vm.prank(alice);
bank.deposit(100e6);
assertEq(token.balanceOf(alice), 900e6);
assertEq(token.balanceOf(proxy), 100e6);
assertEq(bank.balanceOf(alice), 100e6);
assertEq(bank.totalLiabilities(), 100e6);
}
function testDepositEmitsDepositedEvent() public {
_mintAndApprove(alice, 100e6, 100e6);
vm.expectEmit(true, false, false, true, proxy);
emit Deposited(alice, 100e6);
vm.prank(alice);
bank.deposit(100e6);
}
function testDepositRejectsZeroAmount() public {
vm.prank(alice);
vm.expectRevert(ZeroAmount.selector);
bank.deposit(0);
}
function testDepositRejectsCallsWhilePaused() public {
_mintAndApprove(alice, 100e6, 100e6);
vm.prank(owner);
bank.pause();
vm.prank(alice);
vm.expectRevert(PausableUpgradeable.EnforcedPause.selector);
bank.deposit(100e6);
}
function testDepositRollsBackWhenAllowanceIsInadequate() public {
_mintAndApprove(alice, 100e6, 99e6);
vm.prank(alice);
vm.expectRevert(abi.encodeWithSelector(IERC20Errors.ERC20InsufficientAllowance.selector, proxy, 99e6, 100e6));
bank.deposit(100e6);
_assertEmptyAccounting(alice);
assertEq(token.balanceOf(alice), 100e6);
}
function testDepositRollsBackWhenWalletBalanceIsInadequate() public {
_mintAndApprove(alice, 99e6, 100e6);
vm.prank(alice);
vm.expectRevert(abi.encodeWithSelector(IERC20Errors.ERC20InsufficientBalance.selector, alice, 99e6, 100e6));
bank.deposit(100e6);
_assertEmptyAccounting(alice);
assertEq(token.balanceOf(alice), 99e6);
}
function testDepositsKeepTwoCustomersAccountingIndependent() public {
_mintAndApprove(alice, 1_000e6, 300e6);
_mintAndApprove(bob, 1_000e6, 700e6);
vm.prank(alice);
bank.deposit(300e6);
vm.prank(bob);
bank.deposit(700e6);
assertEq(bank.balanceOf(alice), 300e6);
assertEq(bank.balanceOf(bob), 700e6);
assertEq(bank.totalLiabilities(), 1_000e6);
assertEq(token.balanceOf(proxy), 1_000e6);
}
function testFeeOnTransferDepositRevertsAndRollsBackTokenAndAccounting() public {
FeeOnTransferToken feeToken = new FeeOnTransferToken();
BankV1 feeBank = _deployBank(address(feeToken));
feeToken.mint(alice, 100e6);
vm.prank(alice);
feeToken.approve(address(feeBank), 100e6);
vm.prank(alice);
vm.expectRevert(abi.encodeWithSelector(UnexpectedAssetDelta.selector, 100e6, 99e6));
feeBank.deposit(100e6);
assertEq(feeToken.balanceOf(alice), 100e6);
assertEq(feeToken.balanceOf(address(feeBank)), 0);
assertEq(feeBank.balanceOf(alice), 0);
assertEq(feeBank.totalLiabilities(), 0);
}
function testDepositSwallowsNestedRevertAndCreditsOnlyOnce() public {
ReentrantToken reentrantToken = new ReentrantToken();
BankV1 reentrantBank = _deployBank(address(reentrantToken));
reentrantToken.mint(alice, 100e6);
vm.prank(alice);
reentrantToken.approve(address(reentrantBank), 100e6);
reentrantToken.configureDepositCallback(address(reentrantBank), false);
vm.prank(alice);
reentrantBank.deposit(100e6);
assertTrue(reentrantToken.nestedCallAttempted());
assertFalse(reentrantToken.nestedCallSucceeded());
assertEq(reentrantToken.nestedRevertSelector(), ReentrancyGuardTransient.ReentrancyGuardReentrantCall.selector);
assertEq(reentrantToken.observedAccountBalance(), 0);
assertEq(reentrantToken.observedLiabilities(), 0);
assertEq(reentrantToken.balanceOf(alice), 0);
assertEq(reentrantToken.balanceOf(address(reentrantBank)), 100e6);
assertEq(reentrantBank.balanceOf(alice), 100e6);
assertEq(reentrantBank.totalLiabilities(), 100e6);
}
function testDepositPropagatesNestedRevertAtomicallyWhenConfigured() public {
ReentrantToken reentrantToken = new ReentrantToken();
BankV1 reentrantBank = _deployBank(address(reentrantToken));
reentrantToken.mint(alice, 100e6);
vm.prank(alice);
reentrantToken.approve(address(reentrantBank), 100e6);
reentrantToken.configureDepositCallback(address(reentrantBank), true);
vm.prank(alice);
vm.expectRevert(ReentrancyGuardTransient.ReentrancyGuardReentrantCall.selector);
reentrantBank.deposit(100e6);
assertEq(reentrantToken.balanceOf(alice), 100e6);
assertEq(reentrantToken.balanceOf(address(reentrantBank)), 0);
assertEq(reentrantBank.balanceOf(alice), 0);
assertEq(reentrantBank.totalLiabilities(), 0);
}
function testWithdrawDebitsExactCustomerLiabilityAndReserves() public {
_deposit(alice, 1_000e6);
vm.prank(alice);
bank.withdraw(400e6);
assertEq(token.balanceOf(alice), 400e6);
assertEq(token.balanceOf(proxy), 600e6);
assertEq(bank.balanceOf(alice), 600e6);
assertEq(bank.totalLiabilities(), 600e6);
}
function testWithdrawEmitsWithdrawnEvent() public {
_deposit(alice, 100e6);
vm.expectEmit(true, false, false, true, proxy);
emit Withdrawn(alice, 40e6);
vm.prank(alice);
bank.withdraw(40e6);
}
function testWithdrawRejectsZeroAmount() public {
vm.prank(alice);
vm.expectRevert(ZeroAmount.selector);
bank.withdraw(0);
}
function testWithdrawRejectsCallsWhilePaused() public {
_deposit(alice, 100e6);
vm.prank(owner);
bank.pause();
vm.prank(alice);
vm.expectRevert(PausableUpgradeable.EnforcedPause.selector);
bank.withdraw(100e6);
}
function testWithdrawReportsAvailableAndRequestedOnInsufficientInternalBalance() public {
_deposit(alice, 40e6);
vm.prank(alice);
vm.expectRevert(abi.encodeWithSelector(InsufficientBalance.selector, alice, 40e6, 41e6));
bank.withdraw(41e6);
assertEq(bank.balanceOf(alice), 40e6);
assertEq(bank.totalLiabilities(), 40e6);
assertEq(token.balanceOf(proxy), 40e6);
}
function testWithdrawUpdatesAccountingBeforeTransferCallbackAndCannotDoubleDebit() public {
ReentrantToken reentrantToken = new ReentrantToken();
BankV1 reentrantBank = _deployBank(address(reentrantToken));
reentrantToken.mint(alice, 100e6);
vm.prank(alice);
reentrantToken.approve(address(reentrantBank), 100e6);
vm.prank(alice);
reentrantBank.deposit(100e6);
reentrantToken.configureWithdrawalCallback(address(reentrantBank), false);
vm.prank(alice);
reentrantBank.withdraw(40e6);
assertEq(reentrantToken.observedAccountBalance(), 60e6);
assertEq(reentrantToken.observedLiabilities(), 60e6);
assertTrue(reentrantToken.nestedCallAttempted());
assertFalse(reentrantToken.nestedCallSucceeded());
assertEq(reentrantToken.nestedRevertSelector(), ReentrancyGuardTransient.ReentrancyGuardReentrantCall.selector);
assertEq(reentrantToken.balanceOf(alice), 40e6);
assertEq(reentrantToken.balanceOf(address(reentrantBank)), 60e6);
assertEq(reentrantBank.balanceOf(alice), 60e6);
assertEq(reentrantBank.totalLiabilities(), 60e6);
}
function testOneCustomersWithdrawalLeavesOtherCustomerUnchanged() public {
_deposit(alice, 100e6);
_deposit(bob, 200e6);
vm.prank(alice);
bank.withdraw(40e6);
assertEq(bank.balanceOf(alice), 60e6);
assertEq(bank.balanceOf(bob), 200e6);
assertEq(bank.totalLiabilities(), 260e6);
assertEq(token.balanceOf(proxy), 260e6);
}
function testDirectTransferCreatesSurplusThatRemainsAfterFullWithdrawal() public {
_deposit(alice, 100e6);
vm.prank(owner);
token.mint(bob, 25e6);
vm.prank(bob);
token.transfer(proxy, 25e6);
assertEq(token.balanceOf(proxy), 125e6);
assertEq(bank.totalLiabilities(), 100e6);
vm.prank(alice);
bank.withdraw(100e6);
assertEq(token.balanceOf(proxy), 25e6);
assertEq(bank.balanceOf(alice), 0);
assertEq(bank.totalLiabilities(), 0);
}
function testFuzzDepositPreservesExactAccounting(uint256 amountSeed) public {
uint256 amount = bound(amountSeed, 1, MAX_DEPOSIT);
_mintAndApprove(alice, amount, amount);
vm.prank(alice);
bank.deposit(amount);
assertEq(token.balanceOf(alice), 0);
assertEq(token.balanceOf(proxy), amount);
assertEq(bank.balanceOf(alice), amount);
assertEq(bank.totalLiabilities(), amount);
}
function testFuzzWithdrawPreservesExactAccounting(uint256 depositSeed, uint256 withdrawalSeed) public {
uint256 deposited = bound(depositSeed, 1, MAX_DEPOSIT);
uint256 withdrawn = bound(withdrawalSeed, 1, deposited);
_deposit(alice, deposited);
vm.prank(alice);
bank.withdraw(withdrawn);
uint256 remaining = deposited - withdrawn;
assertEq(token.balanceOf(alice), withdrawn);
assertEq(token.balanceOf(proxy), remaining);
assertEq(bank.balanceOf(alice), remaining);
assertEq(bank.totalLiabilities(), remaining);
}
function testFuzzOverWithdrawAlwaysReverts(uint256 depositSeed, uint256 excessSeed) public {
uint256 deposited = bound(depositSeed, 1, MAX_DEPOSIT);
uint256 excess = bound(excessSeed, 1, MAX_DEPOSIT);
uint256 requested = deposited + excess;
_deposit(alice, deposited);
vm.prank(alice);
vm.expectRevert(abi.encodeWithSelector(InsufficientBalance.selector, alice, deposited, requested));
bank.withdraw(requested);
assertEq(bank.balanceOf(alice), deposited);
assertEq(bank.totalLiabilities(), deposited);
assertEq(token.balanceOf(proxy), deposited);
}
function _mintAndApprove(address account, uint256 mintAmount, uint256 approveAmount) private {
vm.prank(owner);
token.mint(account, mintAmount);
vm.prank(account);
token.approve(proxy, approveAmount);
}
function _deposit(address account, uint256 amount) private {
_mintAndApprove(account, amount, amount);
vm.prank(account);
bank.deposit(amount);
}
function _deployBank(address asset_) private returns (BankV1 deployedBank) {
address deployedProxy =
Upgrades.deployUUPSProxy("BankV1.sol:BankV1", abi.encodeCall(BankV1.initialize, (asset_, owner)));
deployedBank = BankV1(deployedProxy);
}
function _assertEmptyAccounting(address account) private view {
assertEq(token.balanceOf(proxy), 0);
assertEq(bank.balanceOf(account), 0);
assertEq(bank.totalLiabilities(), 0);
}
}
+94
View File
@@ -0,0 +1,94 @@
// SPDX-License-Identifier: MIT
pragma solidity 0.8.35;
import {ERC1967Proxy} from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol";
import {Initializable} from "@openzeppelin/contracts/proxy/utils/Initializable.sol";
import {UUPSUpgradeable} from "@openzeppelin/contracts/proxy/utils/UUPSUpgradeable.sol";
import {OwnableUpgradeable} from "@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol";
import {BankV1} from "../src/BankV1.sol";
import {BankTestBase} from "./helpers/BankTestBase.sol";
contract BankV1AdminTest is BankTestBase {
bytes32 private constant ERC1967_IMPLEMENTATION_SLOT =
0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc;
function testProxyStartsWithConfiguredAdministrationAndEmptyAccounting() public view {
assertEq(address(bank.asset()), address(token));
assertEq(bank.owner(), owner);
assertFalse(bank.paused());
assertEq(bank.balanceOf(alice), 0);
assertEq(bank.totalLiabilities(), 0);
assertEq(bank.contractVersion(), 1);
}
function testInitializationChecksZeroAssetBeforeZeroOwner() public {
vm.expectRevert(abi.encodeWithSelector(BankV1.InvalidAsset.selector, address(0)));
new ERC1967Proxy(implementation, abi.encodeCall(BankV1.initialize, (address(0), address(0))));
}
function testInitializationRejectsZeroOwner() public {
vm.expectRevert(abi.encodeWithSelector(OwnableUpgradeable.OwnableInvalidOwner.selector, address(0)));
new ERC1967Proxy(implementation, abi.encodeCall(BankV1.initialize, (address(token), address(0))));
}
function testProxyCannotBeInitializedTwice() public {
vm.expectRevert(Initializable.InvalidInitialization.selector);
bank.initialize(address(token), owner);
}
function testImplementationCannotBeInitialized() public {
vm.expectRevert(Initializable.InvalidInitialization.selector);
BankV1(implementation).initialize(address(token), owner);
}
function testOwnerCanPauseAndUnpause() public {
vm.prank(owner);
bank.pause();
assertTrue(bank.paused());
vm.prank(owner);
bank.unpause();
assertFalse(bank.paused());
}
function testNonOwnerCannotPause() public {
vm.prank(stranger);
vm.expectRevert(abi.encodeWithSelector(OwnableUpgradeable.OwnableUnauthorizedAccount.selector, stranger));
bank.pause();
}
function testNonOwnerCannotUnpause() public {
vm.prank(owner);
bank.pause();
vm.prank(stranger);
vm.expectRevert(abi.encodeWithSelector(OwnableUpgradeable.OwnableUnauthorizedAccount.selector, stranger));
bank.unpause();
}
function testViewsRemainAvailableWhilePaused() public {
vm.prank(owner);
bank.pause();
assertEq(address(bank.asset()), address(token));
assertEq(bank.balanceOf(alice), 0);
assertEq(bank.totalLiabilities(), 0);
assertEq(bank.contractVersion(), 1);
}
function testNonOwnerCannotAuthorizeUpgrade() public {
vm.prank(stranger);
vm.expectRevert(abi.encodeWithSelector(OwnableUpgradeable.OwnableUnauthorizedAccount.selector, stranger));
bank.upgradeToAndCall(implementation, "");
}
function testImplementationExposesERC1967ProxiableUUID() public view {
assertEq(BankV1(implementation).proxiableUUID(), ERC1967_IMPLEMENTATION_SLOT);
}
function testProxyRejectsProxiableUUIDCall() public {
vm.expectRevert(UUPSUpgradeable.UUPSUnauthorizedCallContext.selector);
bank.proxiableUUID();
}
}
+165
View File
@@ -0,0 +1,165 @@
// SPDX-License-Identifier: MIT
pragma solidity 0.8.35;
import {PausableUpgradeable} from "@openzeppelin/contracts-upgradeable/utils/PausableUpgradeable.sol";
import {Options, Upgrades} from "openzeppelin-foundry-upgrades/Upgrades.sol";
import {BankV1} from "../src/BankV1.sol";
import {BankV2} from "../src/BankV2.sol";
import {BankTestBase} from "./helpers/BankTestBase.sol";
event BalanceTransferred(address indexed from, address indexed to, uint256 amount);
contract BankV2Test is BankTestBase {
uint256 private constant ALICE_DEPOSIT = 1_000e6;
uint256 private constant BOB_DEPOSIT = 500e6;
BankV2 internal bankV2;
function setUp() public override {
super.setUp();
_deposit(alice, ALICE_DEPOSIT);
_deposit(bob, BOB_DEPOSIT);
Options memory opts;
opts.referenceContract = "BankV1.sol:BankV1";
Upgrades.upgradeProxy(proxy, "BankV2.sol:BankV2", "", opts, owner);
bankV2 = BankV2(proxy);
}
function testTransferBalanceMoves250MillionUnitsAndEmitsExactEvent() public {
vm.expectEmit(true, true, false, true, proxy);
emit BalanceTransferred(alice, bob, 250e6);
vm.prank(alice);
bankV2.transferBalance(bob, 250e6);
assertEq(bankV2.balanceOf(alice), 750e6);
assertEq(bankV2.balanceOf(bob), 750e6);
}
function testTransferBalanceLeavesLiabilitiesAndTokenReservesUnchanged() public {
uint256 liabilitiesBefore = bankV2.totalLiabilities();
uint256 reservesBefore = token.balanceOf(proxy);
vm.prank(alice);
bankV2.transferBalance(bob, 250e6);
assertEq(bankV2.totalLiabilities(), liabilitiesBefore);
assertEq(token.balanceOf(proxy), reservesBefore);
}
function testTransferBalanceRejectsZeroAmount() public {
vm.prank(alice);
vm.expectRevert(BankV1.ZeroAmount.selector);
bankV2.transferBalance(bob, 0);
}
function testTransferBalanceRejectsZeroRecipient() public {
vm.prank(alice);
vm.expectRevert(abi.encodeWithSelector(BankV2.InvalidRecipient.selector, address(0)));
bankV2.transferBalance(address(0), 1);
}
function testTransferBalanceRejectsSenderAsRecipient() public {
vm.prank(alice);
vm.expectRevert(BankV2.SelfTransfer.selector);
bankV2.transferBalance(alice, 1);
}
function testTransferBalanceReportsAvailableAndRequestedWhenBalanceIsInsufficient() public {
vm.prank(alice);
vm.expectRevert(
abi.encodeWithSelector(BankV1.InsufficientBalance.selector, alice, ALICE_DEPOSIT, ALICE_DEPOSIT + 1)
);
bankV2.transferBalance(bob, ALICE_DEPOSIT + 1);
assertEq(bankV2.balanceOf(alice), ALICE_DEPOSIT);
assertEq(bankV2.balanceOf(bob), BOB_DEPOSIT);
}
function testTransferBalanceRejectsCallsWhilePaused() public {
vm.prank(owner);
bankV2.pause();
vm.prank(alice);
vm.expectRevert(PausableUpgradeable.EnforcedPause.selector);
bankV2.transferBalance(bob, 1);
assertEq(bankV2.balanceOf(alice), ALICE_DEPOSIT);
assertEq(bankV2.balanceOf(bob), BOB_DEPOSIT);
}
function testTransferBalanceCreditsRecipientWithNoPreviousBalance() public {
assertEq(bankV2.balanceOf(stranger), 0);
vm.prank(alice);
bankV2.transferBalance(stranger, 125e6);
assertEq(bankV2.balanceOf(alice), 875e6);
assertEq(bankV2.balanceOf(stranger), 125e6);
}
function testFuzzTransferBalanceAcrossTrackedRecipientsAndSenderBoundedAmounts(
uint256 recipientSeed,
uint256 amountSeed
) public {
address recipient = _trackedRecipient(bound(recipientSeed, 0, 2));
uint256 amount = bound(amountSeed, 1, ALICE_DEPOSIT);
uint256 recipientBefore = bankV2.balanceOf(recipient);
uint256 liabilitiesBefore = bankV2.totalLiabilities();
uint256 reservesBefore = token.balanceOf(proxy);
vm.prank(alice);
bankV2.transferBalance(recipient, amount);
assertEq(bankV2.balanceOf(alice), ALICE_DEPOSIT - amount);
assertEq(bankV2.balanceOf(recipient), recipientBefore + amount);
assertEq(bankV2.totalLiabilities(), liabilitiesBefore);
assertEq(token.balanceOf(proxy), reservesBefore);
}
function testV1DepositWithdrawalViewsPauseAndUnpauseStillWorkThroughV2Proxy() public {
_mintAndApprove(stranger, 100e6);
vm.prank(stranger);
bankV2.deposit(100e6);
vm.prank(alice);
bankV2.withdraw(100e6);
assertEq(address(bankV2.asset()), address(token));
assertEq(bankV2.owner(), owner);
assertEq(bankV2.balanceOf(alice), 900e6);
assertEq(bankV2.balanceOf(bob), BOB_DEPOSIT);
assertEq(bankV2.balanceOf(stranger), 100e6);
assertEq(bankV2.totalLiabilities(), 1_500e6);
assertEq(token.balanceOf(proxy), 1_500e6);
assertEq(bankV2.contractVersion(), 2);
vm.prank(owner);
bankV2.pause();
assertTrue(bankV2.paused());
vm.prank(owner);
bankV2.unpause();
assertFalse(bankV2.paused());
}
function _deposit(address account, uint256 amount) private {
_mintAndApprove(account, amount);
vm.prank(account);
bank.deposit(amount);
}
function _mintAndApprove(address account, uint256 amount) private {
vm.prank(owner);
token.mint(account, amount);
vm.prank(account);
token.approve(proxy, amount);
}
function _trackedRecipient(uint256 index) private view returns (address) {
if (index == 0) return bob;
if (index == 1) return stranger;
return owner;
}
}
+60
View File
@@ -0,0 +1,60 @@
// SPDX-License-Identifier: MIT
pragma solidity 0.8.35;
import {Test} from "forge-std/Test.sol";
import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol";
import {MockUSDC} from "../src/MockUSDC.sol";
contract MockUSDCTest is Test {
MockUSDC internal token;
address internal constant STRANGER = address(0xBEEF);
address internal constant RECIPIENT = address(0xCAFE);
address internal constant SPENDER = address(0xD00D);
function setUp() public {
token = new MockUSDC(address(this));
}
function testMetadataUsesSixDecimalMockUSDC() public view {
assertEq(token.name(), "Mock USD Coin");
assertEq(token.symbol(), "mUSDC");
assertEq(token.decimals(), 6);
}
function testInitialSupplyIsZero() public view {
assertEq(token.totalSupply(), 0);
}
function testMintMintsToRecipientWhenCalledByOwner() public {
token.mint(RECIPIENT, 1_250_000);
assertEq(token.totalSupply(), 1_250_000);
assertEq(token.balanceOf(RECIPIENT), 1_250_000);
}
function testMintRevertsWhenCalledByNonOwner() public {
vm.prank(STRANGER);
vm.expectRevert(abi.encodeWithSelector(Ownable.OwnableUnauthorizedAccount.selector, STRANGER));
token.mint(RECIPIENT, 1);
}
function testTransferMovesMintedBalance() public {
token.mint(address(this), 1_250_000);
token.transfer(RECIPIENT, 250_000);
assertEq(token.balanceOf(address(this)), 1_000_000);
assertEq(token.balanceOf(RECIPIENT), 250_000);
}
function testApproveSetsAllowanceForSpender() public {
token.approve(SPENDER, 750_000);
assertEq(token.allowance(address(this), SPENDER), 750_000);
}
function testConstructorRevertsForZeroInitialOwner() public {
vm.expectRevert(abi.encodeWithSelector(Ownable.OwnableInvalidOwner.selector, address(0)));
new MockUSDC(address(0));
}
}
+92
View File
@@ -0,0 +1,92 @@
// SPDX-License-Identifier: MIT
pragma solidity 0.8.35;
import {Test} from "forge-std/Test.sol";
import {ERC1967Proxy} from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol";
import {BankV1} from "../src/BankV1.sol";
import {MockUSDC} from "../src/MockUSDC.sol";
import {CheckState} from "../script/CheckState.s.sol";
import {DemoScript} from "../script/lib/DemoScript.sol";
contract NamedErrorHarness is DemoScript {
function assertUint(string calldata label, uint256 expected, uint256 actual) external pure {
_assertUint(label, expected, actual);
}
function writeManifest(string calldata path, Manifest calldata manifest) external {
_writeManifest(path, manifest);
}
}
contract NamedErrorExercisesTest is Test {
address internal constant OWNER = 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266;
address internal constant ALICE = 0x70997970C51812dc3A010C7d01b50e0d17dc79C8;
address internal constant BOB = 0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC;
NamedErrorHarness internal harness;
function setUp() public {
vm.chainId(31337);
harness = new NamedErrorHarness();
}
function testUnexpectedStateExerciseRevertsThroughAssertionBranch() public {
vm.expectRevert(
abi.encodeWithSelector(DemoScript.UnexpectedState.selector, "reserves", uint256(1_400e6), uint256(1_399e6))
);
harness.assertUint("reserves", 1_400e6, 1_399e6);
}
function testCheckStateExercisesRevertThroughInsolventAndUnknownStageBranches() public {
(MockUSDC token, BankV1 bank) = _deployFixture("insolvent.json");
token.mint(ALICE, 1_400e6);
vm.startPrank(ALICE);
token.approve(address(bank), 1_400e6);
bank.deposit(1_400e6);
vm.stopPrank();
deal(address(token), address(bank), 1_399e6);
vm.setEnv("DEMO_EXPECTED_STAGE", "invariants");
CheckState checker = new CheckState();
vm.expectRevert(abi.encodeWithSelector(CheckState.Insolvent.selector, uint256(1_399e6), uint256(1_400e6)));
checker.run();
_deployFixture("unknown-stage.json");
vm.setEnv("DEMO_EXPECTED_STAGE", "mystery");
checker = new CheckState();
vm.expectRevert(abi.encodeWithSelector(CheckState.UnknownStage.selector, "mystery"));
checker.run();
}
function _deployFixture(string memory fixtureName) internal returns (MockUSDC token, BankV1 bank) {
token = new MockUSDC(address(this));
BankV1 implementation = new BankV1();
ERC1967Proxy proxy =
new ERC1967Proxy(address(implementation), abi.encodeCall(BankV1.initialize, (address(token), OWNER)));
bank = BankV1(address(proxy));
DemoScript.Actor[] memory actors = new DemoScript.Actor[](3);
actors[0] = DemoScript.Actor({label: "owner", address_: OWNER});
actors[1] = DemoScript.Actor({label: "Alice", address_: ALICE});
actors[2] = DemoScript.Actor({label: "Bob", address_: BOB});
DemoScript.Manifest memory manifest = DemoScript.Manifest({
schemaVersion: 1,
network: "anvil",
chainId: 31337,
deploymentBlock: 1,
rpcUrl: "http://127.0.0.1:8545",
explorerBaseUrl: "",
token: address(token),
proxy: address(proxy),
implementation: address(implementation),
owner: OWNER,
actors: actors
});
string memory fixtureDir = string.concat(vm.projectRoot(), "/deployments/test-named-error-exercises");
vm.createDir(fixtureDir, true);
string memory manifestPath = string.concat(fixtureDir, "/", fixtureName);
harness.writeManifest(manifestPath, manifest);
vm.setEnv("DEPLOYMENT_MANIFEST_PATH", manifestPath);
}
}
+948
View File
@@ -0,0 +1,948 @@
// SPDX-License-Identifier: MIT
pragma solidity 0.8.35;
import {Test} from "forge-std/Test.sol";
import {Upgrades} from "openzeppelin-foundry-upgrades/Upgrades.sol";
import {DemoScript} from "../script/lib/DemoScript.sol";
import {DeployV1} from "../script/DeployV1.s.sol";
import {SeedV1Demo} from "../script/SeedV1Demo.s.sol";
import {SeedBaseSepolia} from "../script/SeedBaseSepolia.s.sol";
import {CheckState} from "../script/CheckState.s.sol";
import {UpgradeV2} from "../script/UpgradeV2.s.sol";
import {TransferV2Demo} from "../script/TransferV2Demo.s.sol";
import {BankV1} from "../src/BankV1.sol";
import {BankV2} from "../src/BankV2.sol";
import {MockUSDC} from "../src/MockUSDC.sol";
contract ScriptPreflightHarness is DemoScript {
function requireSupportedChain(uint256 chainId) external pure {
_requireSupportedChain(chainId);
}
function deriveLocalActor(uint256 chainId, uint32 index) external pure returns (address actor) {
(, actor) = _deriveLocalActor(chainId, index);
}
function readManifest(string calldata path, bool active) external view returns (Manifest memory) {
return _readManifest(path, active);
}
function requireCode(string calldata label, address target) external view {
_requireCode(label, target);
}
function serializeManifest(Manifest calldata manifest) external returns (string memory) {
return _serializeManifest(manifest);
}
function educationalWarning() external pure returns (string memory) {
return _educationalWarning();
}
}
contract UpgradeV2Harness is UpgradeV2 {
function assertSnapshotUnchanged(Snapshot calldata before_, Snapshot calldata after_) external pure {
_assertSnapshotUnchanged(before_, after_);
}
function runWithPaths(string calldata activePath, string calldata pendingPath, address sender)
external
returns (bool upgraded, address implementation)
{
_requireSupportedChain(block.chainid);
return _run(activePath, pendingPath, sender);
}
}
contract TransferV2DemoHarness is TransferV2Demo {
function runWithPath(string calldata manifestPath) external {
_run(manifestPath);
}
}
contract CheckStateHarness is CheckState {
function runWithPath(string calldata manifestPath, string calldata stage) external view {
_run(manifestPath, stage);
}
}
contract ScriptPreflightTest is Test {
ScriptPreflightHarness internal harness;
string internal fixtureDir;
address internal constant OWNER = 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266;
address internal constant ALICE = 0x70997970C51812dc3A010C7d01b50e0d17dc79C8;
address internal constant BOB = 0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC;
address internal constant TOKEN = 0x1000000000000000000000000000000000000001;
address internal constant PROXY = 0x2000000000000000000000000000000000000002;
address internal constant IMPLEMENTATION = 0x3000000000000000000000000000000000000003;
function setUp() public {
harness = new ScriptPreflightHarness();
fixtureDir = string.concat(vm.projectRoot(), "/deployments/test-script-preflight");
vm.createDir(fixtureDir, true);
}
function testSupportedChainsAreAccepted() public view {
harness.requireSupportedChain(31337);
harness.requireSupportedChain(84532);
}
function testUnsupportedChainsAreRejectedBeforeBroadcast() public {
uint256[7] memory rejected =
[uint256(1), uint256(10), uint256(56), uint256(137), uint256(8453), uint256(42161), uint256(7777777)];
for (uint256 i; i < rejected.length; ++i) {
vm.expectRevert(abi.encodeWithSelector(DemoScript.UnsupportedChain.selector, rejected[i]));
harness.requireSupportedChain(rejected[i]);
}
}
function testLocalActorsDeriveOnlyOnAnvil() public {
assertEq(harness.deriveLocalActor(31337, 0), OWNER);
assertEq(harness.deriveLocalActor(31337, 1), ALICE);
vm.expectRevert(abi.encodeWithSelector(DemoScript.UnsupportedChain.selector, uint256(84532)));
harness.deriveLocalActor(84532, 0);
}
function testMissingManifestIsRejected() public {
vm.expectRevert();
harness.readManifest(string.concat(fixtureDir, "/missing.json"), false);
}
function testInvalidManifestIsRejected() public {
string memory path = string.concat(fixtureDir, "/invalid.json");
vm.writeFile(path, "not-json");
vm.expectRevert();
harness.readManifest(path, false);
}
function testWrongManifestChainIsRejected() public {
string memory path = _writeManifest(84532, 1, TOKEN, PROXY, IMPLEMENTATION);
vm.chainId(31337);
vm.expectRevert(
abi.encodeWithSelector(DemoScript.ManifestChainMismatch.selector, uint256(31337), uint256(84532))
);
harness.readManifest(path, true);
}
function testZeroManifestAddressIsRejected() public {
string memory path = _writeManifest(31337, 1, address(0), PROXY, IMPLEMENTATION);
vm.chainId(31337);
vm.expectRevert(abi.encodeWithSelector(DemoScript.MissingCode.selector, "token", address(0)));
harness.readManifest(path, true);
}
function testAddressWithoutCodeIsRejected() public {
vm.expectRevert(abi.encodeWithSelector(DemoScript.MissingCode.selector, "token", TOKEN));
harness.requireCode("token", TOKEN);
}
function testPendingManifestMayUseDeploymentBlockZero() public {
string memory path = _writeManifest(31337, 0, TOKEN, PROXY, IMPLEMENTATION);
vm.chainId(31337);
vm.etch(TOKEN, hex"00");
vm.etch(PROXY, hex"00");
vm.etch(IMPLEMENTATION, hex"00");
DemoScript.Manifest memory manifest = harness.readManifest(path, false);
assertEq(manifest.deploymentBlock, 0);
}
function testActiveManifestRejectsDeploymentBlockZero() public {
string memory path = _writeManifest(31337, 0, TOKEN, PROXY, IMPLEMENTATION);
vm.chainId(31337);
vm.etch(TOKEN, hex"00");
vm.etch(PROXY, hex"00");
vm.etch(IMPLEMENTATION, hex"00");
vm.expectRevert(DemoScript.InvalidDeploymentBlock.selector);
harness.readManifest(path, true);
}
function testAnvilManifestRejectsUnexpectedActorObjectCount() public {
string memory path = _writeManifest(31337, 1, TOKEN, PROXY, IMPLEMENTATION);
_etchManifestContracts();
vm.writeJson(
string.concat(
'[{"label":"owner","address":"',
vm.toString(OWNER),
'"},{"label":"Alice","address":"',
vm.toString(ALICE),
'"}]'
),
path,
".actors"
);
vm.expectRevert();
harness.readManifest(path, true);
}
function testAnvilManifestRejectsUnexpectedActorLabels() public {
string memory path = _writeManifest(31337, 1, TOKEN, PROXY, IMPLEMENTATION);
_etchManifestContracts();
vm.writeJson(
string.concat(
'[{"label":"owner","address":"',
vm.toString(OWNER),
'"},{"label":"Mallory","address":"',
vm.toString(ALICE),
'"},{"label":"Bob","address":"0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC"}]'
),
path,
".actors"
);
vm.expectRevert(DemoScript.InvalidActorConfiguration.selector);
harness.readManifest(path, true);
}
function testAnvilManifestRequiresOwnerAtActorZero() public {
string memory path = _writeManifest(31337, 1, TOKEN, PROXY, IMPLEMENTATION);
_etchManifestContracts();
vm.writeJson(
string.concat(
'[{"label":"owner","address":"',
vm.toString(ALICE),
'"},{"label":"Alice","address":"',
vm.toString(OWNER),
'"},{"label":"Bob","address":"0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC"}]'
),
path,
".actors"
);
vm.expectRevert(DemoScript.InvalidActorConfiguration.selector);
harness.readManifest(path, true);
}
function testBaseManifestRequiresExactPresenterRecipientActors() public {
string memory path = _writePublicBaseManifest();
vm.chainId(84532);
_etchManifestContracts();
vm.writeJson(
string.concat(
'[{"label":"owner","address":"',
vm.toString(OWNER),
'"},{"label":"Alice","address":"',
vm.toString(ALICE),
'"}]'
),
path,
".actors"
);
vm.expectRevert(DemoScript.InvalidActorConfiguration.selector);
harness.readManifest(path, true);
}
function testEducationalWarningIsExact() public view {
assertEq(harness.educationalWarning(), unicode"Educational demo — mock token — never use real funds.");
}
function testNestedActorsRoundTripThroughManifestSerialization() public {
string memory path = _writePublicAnvilManifest();
_etchManifestContracts();
DemoScript.Manifest memory manifest = harness.readManifest(path, true);
assertEq(manifest.actors[0].label, "owner");
assertEq(manifest.actors[1].address_, ALICE);
string memory serialized = harness.serializeManifest(manifest);
assertFalse(_contains(serialized, "actorLabels"));
assertFalse(_contains(serialized, "explorerUrl"));
vm.writeFile(path, serialized);
DemoScript.Manifest memory roundTrip = harness.readManifest(path, true);
assertEq(roundTrip.actors[2].label, "Bob");
assertEq(roundTrip.actors[2].address_, 0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC);
}
function testLegacyParallelActorManifestIsRejected() public {
string memory path = _writeLegacyParallelActorManifest();
_etchManifestContracts();
vm.expectRevert(abi.encodeWithSelector(DemoScript.InvalidManifestSchema.selector, uint256(0)));
harness.readManifest(path, true);
}
function testActorObjectWithExtraKeyIsRejected() public {
string memory path = _writeAnvilManifestWithExtraActorKey("note", "public");
_etchManifestContracts();
vm.expectRevert(abi.encodeWithSelector(DemoScript.InvalidManifestSchema.selector, uint256(0)));
harness.readManifest(path, true);
}
function testActorObjectWithSecretBearingKeyIsRejected() public {
string memory path = _writeAnvilManifestWithExtraActorKey("privateKey", "not-a-key");
_etchManifestContracts();
vm.expectRevert(abi.encodeWithSelector(DemoScript.InvalidManifestSchema.selector, uint256(0)));
harness.readManifest(path, true);
}
function testBaseSepoliaManifestUsesPublicUrlsAndExactActors() public {
string memory path = _writePublicBaseManifest();
vm.chainId(84532);
_etchManifestContracts();
DemoScript.Manifest memory manifest = harness.readManifest(path, true);
assertEq(manifest.network, "baseSepolia");
assertEq(manifest.rpcUrl, "https://public.invalid");
assertEq(manifest.explorerBaseUrl, "https://sepolia.basescan.org");
assertEq(manifest.actors[0].label, "Presenter");
assertEq(manifest.actors[0].address_, OWNER);
assertEq(manifest.actors[1].label, "Recipient");
assertEq(manifest.actors[1].address_, BOB);
}
function testSerializedManifestContainsPublicAddressesAndNoSecrets() public {
DemoScript.Manifest memory manifest = DemoScript.Manifest({
schemaVersion: 1,
network: "anvil",
chainId: 31337,
deploymentBlock: 0,
rpcUrl: "http://127.0.0.1:8545",
explorerBaseUrl: "",
token: TOKEN,
proxy: PROXY,
implementation: IMPLEMENTATION,
owner: OWNER,
actors: _actors()
});
string memory json = harness.serializeManifest(manifest);
assertTrue(_contains(json, vm.toString(TOKEN)));
assertTrue(_contains(json, vm.toString(PROXY)));
assertTrue(_contains(json, vm.toString(IMPLEMENTATION)));
assertTrue(_contains(json, vm.toString(OWNER)));
assertFalse(_contains(json, "test test test test test test test test test test test junk"));
assertFalse(_contains(json, "PRIVATE_KEY"));
assertFalse(_contains(json, "MNEMONIC"));
assertFalse(_contains(json, "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80"));
}
function testDeployV1CreatesInitializedProxyAndPublicPendingManifest() public {
string memory path = string.concat(fixtureDir, "/deployed.json");
vm.chainId(31337);
vm.setEnv("SCRIPT_SENDER", vm.toString(OWNER));
vm.setEnv("DEPLOYMENT_MANIFEST_PATH", path);
DeployV1 deployer = new DeployV1();
(address token, address proxy, address implementation) = deployer.run();
DemoScript.Manifest memory manifest = harness.readManifest(path, false);
assertEq(manifest.token, token);
assertEq(manifest.proxy, proxy);
assertEq(manifest.implementation, implementation);
assertEq(BankV1(proxy).owner(), OWNER);
assertEq(address(BankV1(proxy).asset()), token);
assertEq(BankV1(proxy).contractVersion(), 1);
assertEq(manifest.actors[0].label, "owner");
assertEq(manifest.actors[1].label, "Alice");
assertEq(manifest.actors[2].label, "Bob");
assertEq(manifest.actors[0].address_, OWNER);
assertEq(manifest.actors[1].address_, ALICE);
assertEq(manifest.actors[2].address_, 0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC);
}
function testBaseDeployRejectsMismatchedConfiguredSenderBeforeBroadcast() public {
string memory path = string.concat(fixtureDir, "/base-deploy-mismatch.json");
vm.chainId(84532);
vm.setEnv("SCRIPT_SENDER", vm.toString(OWNER));
vm.setEnv("BASE_SEPOLIA_SENDER", vm.toString(ALICE));
vm.setEnv("BASE_SEPOLIA_RECIPIENT", vm.toString(BOB));
vm.setEnv("BASE_SEPOLIA_PUBLIC_RPC_URL", "https://public.invalid");
vm.setEnv("DEPLOYMENT_MANIFEST_PATH", path);
uint256 nonceBefore = vm.getNonce(OWNER);
DeployV1 deployer = new DeployV1();
vm.expectRevert(
abi.encodeWithSelector(DemoScript.UnexpectedAddress.selector, "BASE_SEPOLIA_SENDER", OWNER, ALICE)
);
deployer.run();
assertEq(vm.getNonce(OWNER), nonceBefore);
}
function testBaseDeployWritesPublicRpcExplorerAndPresenterRecipientActors() public {
string memory path = string.concat(fixtureDir, "/base-deployed.json");
vm.chainId(84532);
_setBaseConfig(OWNER, BOB, "https://public.invalid");
vm.setEnv("DEPLOYMENT_MANIFEST_PATH", path);
(address token, address proxy, address implementation) = new DeployV1().run();
_etchManifestContracts();
DemoScript.Manifest memory manifest = harness.readManifest(path, false);
assertEq(manifest.token, token);
assertEq(manifest.proxy, proxy);
assertEq(manifest.implementation, implementation);
assertEq(manifest.owner, OWNER);
assertEq(manifest.rpcUrl, "https://public.invalid");
assertEq(manifest.explorerBaseUrl, "https://sepolia.basescan.org");
assertEq(manifest.actors.length, 2);
assertEq(manifest.actors[0].label, "Presenter");
assertEq(manifest.actors[0].address_, OWNER);
assertEq(manifest.actors[1].label, "Recipient");
assertEq(manifest.actors[1].address_, BOB);
}
function testBaseDeployRejectsZeroOrSameRecipientBeforeBroadcast() public {
vm.chainId(84532);
DeployV1 deployer = new DeployV1();
address[2] memory invalidRecipients = [address(0), OWNER];
for (uint256 i; i < invalidRecipients.length; ++i) {
_setBaseConfig(OWNER, invalidRecipients[i], "https://public.invalid");
uint256 nonceBefore = vm.getNonce(OWNER);
vm.expectRevert(DemoScript.InvalidActorConfiguration.selector);
deployer.run();
assertEq(vm.getNonce(OWNER), nonceBefore);
}
}
function testBaseDeployRejectsCredentialBearingPublicRpcBeforeBroadcast() public {
vm.chainId(84532);
DeployV1 deployer = new DeployV1();
string[3] memory invalidUrls =
["http://public.invalid", "https://user@public.invalid", "https://public.invalid/path?api_key=fixture"];
bytes4 invalidPublicRpcUrl = bytes4(keccak256("InvalidPublicRpcUrl()"));
for (uint256 i; i < invalidUrls.length; ++i) {
_setBaseConfig(OWNER, BOB, invalidUrls[i]);
uint256 nonceBefore = vm.getNonce(OWNER);
vm.expectRevert(invalidPublicRpcUrl);
deployer.run();
assertEq(vm.getNonce(OWNER), nonceBefore);
}
}
function testSeedV1DemoExecutesExactActOneStateAndCheckStateAcceptsIt() public {
(string memory path, DemoScript.Manifest memory manifest) = _deployFixtureNamed("seed-v1");
vm.setEnv("DEPLOYMENT_MANIFEST_PATH", path);
new SeedV1Demo().run();
BankV1 bank = BankV1(manifest.proxy);
assertEq(bank.balanceOf(manifest.actors[1].address_), 900e6);
assertEq(bank.balanceOf(manifest.actors[2].address_), 500e6);
assertEq(bank.totalLiabilities(), 1_400e6);
assertEq(MockUSDC(manifest.token).balanceOf(manifest.proxy), 1_400e6);
assertEq(MockUSDC(manifest.token).balanceOf(manifest.actors[1].address_), 1_100e6);
assertEq(MockUSDC(manifest.token).balanceOf(manifest.actors[2].address_), 500e6);
vm.setEnv("DEMO_EXPECTED_STAGE", "v1");
new CheckState().run();
}
function testSeedBaseSepoliaCreatesExactPresenterDepositState() public {
(string memory path, DemoScript.Manifest memory manifest) = _deployBaseFixtureNamed("seed-base");
_setBaseConfig(OWNER, BOB, "https://public.invalid");
vm.setEnv("DEPLOYMENT_MANIFEST_PATH", path);
new SeedBaseSepolia().run();
BankV1 bank = BankV1(manifest.proxy);
MockUSDC token = MockUSDC(manifest.token);
assertEq(bank.balanceOf(OWNER), 1_000e6);
assertEq(bank.balanceOf(BOB), 0);
assertEq(bank.totalLiabilities(), 1_000e6);
assertEq(token.balanceOf(manifest.proxy), 1_000e6);
assertEq(token.balanceOf(OWNER), 0);
}
function testSeedBaseSepoliaRejectsMismatchedSenderBeforeMintOrBroadcast() public {
(string memory path, DemoScript.Manifest memory manifest) = _deployBaseFixtureNamed("seed-base-mismatch");
_setBaseConfig(OWNER, BOB, "https://public.invalid");
vm.setEnv("BASE_SEPOLIA_SENDER", vm.toString(ALICE));
vm.setEnv("DEPLOYMENT_MANIFEST_PATH", path);
SeedBaseSepolia seeder = new SeedBaseSepolia();
vm.expectRevert(
abi.encodeWithSelector(DemoScript.UnexpectedAddress.selector, "BASE_SEPOLIA_SENDER", OWNER, ALICE)
);
seeder.run();
assertEq(MockUSDC(manifest.token).totalSupply(), 0);
assertEq(BankV1(manifest.proxy).totalLiabilities(), 0);
}
function testCheckStateRejectsManifestImplementationMismatch() public {
(string memory path, DemoScript.Manifest memory manifest) = _deployUpgradeFixtureNamed("check-mismatch");
vm.writeJson(string.concat('"', vm.toString(manifest.token), '"'), path, ".implementation");
CheckStateHarness checker = new CheckStateHarness();
vm.expectRevert(
abi.encodeWithSelector(
DemoScript.UnexpectedAddress.selector, "implementation", manifest.token, manifest.implementation
)
);
checker.runWithPath(path, "deployed");
}
function testUpgradeV2RequiresManifestIdentityCodeOwnerSenderAndVersionOne() public {
(string memory path, DemoScript.Manifest memory manifest) = _deployUpgradeFixtureNamed("upgrade-preflight");
string memory pending = string.concat(fixtureDir, "/upgrade-pending.json");
UpgradeV2Harness upgrader = new UpgradeV2Harness();
vm.expectRevert(
abi.encodeWithSelector(DemoScript.UnexpectedAddress.selector, "SCRIPT_SENDER", manifest.owner, ALICE)
);
upgrader.runWithPaths(path, pending, ALICE);
vm.writeJson(string.concat('"', vm.toString(manifest.token), '"'), path, ".implementation");
vm.expectRevert(
abi.encodeWithSelector(
DemoScript.UnexpectedAddress.selector, "implementation", manifest.token, manifest.implementation
)
);
upgrader.runWithPaths(path, pending, OWNER);
}
function testUpgradeV2RejectsUnsupportedChainMissingProxyCodeWrongOwnerAndUnexpectedVersion() public {
UpgradeV2Harness upgrader = new UpgradeV2Harness();
vm.chainId(1);
vm.expectRevert(abi.encodeWithSelector(DemoScript.UnsupportedChain.selector, uint256(1)));
upgrader.runWithPaths("unused", "unused", OWNER);
vm.chainId(31337);
(string memory missingPath,) = _deployUpgradeFixtureNamed("upgrade-missing-proxy");
vm.writeJson('"0x4000000000000000000000000000000000000004"', missingPath, ".proxy");
vm.expectRevert(
abi.encodeWithSelector(DemoScript.MissingCode.selector, "proxy", 0x4000000000000000000000000000000000000004)
);
upgrader.runWithPaths(missingPath, string.concat(fixtureDir, "/missing-pending.json"), OWNER);
(string memory ownerPath, DemoScript.Manifest memory ownerManifest) =
_deployUpgradeFixtureNamed("upgrade-owner");
vm.prank(OWNER);
BankV1(ownerManifest.proxy).transferOwnership(ALICE);
vm.expectRevert(abi.encodeWithSelector(DemoScript.UnexpectedAddress.selector, "owner", OWNER, ALICE));
upgrader.runWithPaths(ownerPath, string.concat(fixtureDir, "/owner-pending.json"), ALICE);
(string memory versionPath, DemoScript.Manifest memory versionManifest) =
_deployUpgradeFixtureNamed("upgrade-version");
vm.mockCall(
versionManifest.proxy, abi.encodeWithSelector(BankV1.contractVersion.selector), abi.encode(uint256(3))
);
vm.expectRevert(abi.encodeWithSelector(UpgradeV2.UnexpectedVersion.selector, uint256(3)));
upgrader.runWithPaths(versionPath, string.concat(fixtureDir, "/version-pending.json"), OWNER);
}
function testUpgradeV2PreservesCompleteSnapshotAndWritesOnlyStagingRecord() public {
(string memory path, DemoScript.Manifest memory manifest) = _deployUpgradeFixtureNamed("upgrade-preserve");
_seedState(manifest);
string memory beforeManifest = vm.readFile(path);
string memory pending = string.concat(fixtureDir, "/upgrade-pending.json");
(bool upgraded, address implementation) = new UpgradeV2Harness().runWithPaths(path, pending, OWNER);
assertTrue(upgraded);
assertNotEq(implementation, manifest.implementation);
assertEq(vm.readFile(path), beforeManifest);
BankV2 bankV2 = BankV2(manifest.proxy);
assertEq(bankV2.contractVersion(), 2);
assertEq(bankV2.owner(), manifest.owner);
assertEq(address(bankV2.asset()), manifest.token);
assertFalse(bankV2.paused());
assertEq(bankV2.balanceOf(ALICE), 900e6);
assertEq(bankV2.balanceOf(BOB), 500e6);
assertEq(bankV2.totalLiabilities(), 1_400e6);
assertEq(MockUSDC(manifest.token).balanceOf(manifest.proxy), 1_400e6);
string memory marker = vm.readFile(pending);
assertEq(vm.parseJsonString(marker, ".mode"), "upgrade");
assertEq(vm.parseJsonAddress(marker, ".implementation"), implementation);
assertEq(vm.parseJsonAddress(marker, ".snapshot.proxy"), manifest.proxy);
assertEq(vm.parseJsonUint(marker, ".snapshot.balances[1].balance"), 900e6);
assertEq(vm.parseJsonUint(marker, ".snapshot.balances[2].balance"), 500e6);
}
function testUpgradeV2AlreadyActiveWritesVerifiableNoopWithoutChangingImplementation() public {
(string memory path, DemoScript.Manifest memory manifest) = _deployUpgradeFixtureNamed("upgrade-noop");
string memory pending = string.concat(fixtureDir, "/upgrade-pending.json");
UpgradeV2Harness upgrader = new UpgradeV2Harness();
(, address implementation) = upgrader.runWithPaths(path, pending, OWNER);
vm.writeJson(string.concat('"', vm.toString(implementation), '"'), path, ".implementation");
uint256 nonceBefore = vm.getNonce(OWNER);
(bool upgraded, address observedImplementation) = upgrader.runWithPaths(path, pending, OWNER);
assertFalse(upgraded);
assertEq(observedImplementation, implementation);
assertEq(vm.getNonce(OWNER), nonceBefore);
string memory marker = vm.readFile(pending);
assertEq(vm.parseJsonString(marker, ".mode"), "noop");
assertEq(vm.parseJsonUint(marker, ".chainId"), 31337);
assertEq(vm.parseJsonUint(marker, ".observedBlock"), block.number);
assertEq(vm.parseJsonUint(marker, ".ownerNonce"), nonceBefore);
assertEq(vm.parseJsonAddress(marker, ".proxy"), manifest.proxy);
assertEq(vm.parseJsonAddress(marker, ".implementation"), implementation);
}
function testSnapshotComparisonRejectsApplicationMutationButAllowsImplementationAndVersionChange() public {
UpgradeV2Harness upgradeHarness = new UpgradeV2Harness();
UpgradeV2.Snapshot memory before_ = _snapshot();
UpgradeV2.Snapshot memory after_ = _snapshot();
after_.implementation = address(0x9999);
after_.version = 2;
upgradeHarness.assertSnapshotUnchanged(before_, after_);
for (uint256 mutation; mutation < 10; ++mutation) {
before_ = _snapshot();
after_ = _snapshot();
if (mutation == 0) after_.proxy = address(0x9999);
else if (mutation == 1) after_.owner = address(0x9999);
else if (mutation == 2) after_.asset = address(0x9999);
else if (mutation == 3) after_.paused = true;
else if (mutation == 4) after_.balances = new uint256[](2);
else if (mutation == 5) after_.balances[1] += 1;
else if (mutation == 6) after_.liabilities += 1;
else if (mutation == 7) after_.reserves += 1;
else if (mutation == 8) after_.surplus += 1;
else after_.deploymentBlock += 1;
vm.expectRevert();
upgradeHarness.assertSnapshotUnchanged(before_, after_);
}
}
function testTransferV2DemoExecutesExactActThreeAndCheckStateAcceptsBothV2Stages() public {
(string memory path, DemoScript.Manifest memory manifest) = _deployUpgradeFixtureNamed("transfer-v2");
_seedState(manifest);
string memory pending = string.concat(fixtureDir, "/upgrade-pending.json");
(, address implementation) = new UpgradeV2Harness().runWithPaths(path, pending, OWNER);
vm.writeJson(string.concat('"', vm.toString(implementation), '"'), path, ".implementation");
new CheckStateHarness().runWithPath(path, "upgraded");
new TransferV2DemoHarness().runWithPath(path);
new CheckStateHarness().runWithPath(path, "v2");
BankV2 bankV2 = BankV2(manifest.proxy);
assertEq(bankV2.balanceOf(ALICE), 650e6);
assertEq(bankV2.balanceOf(BOB), 750e6);
assertEq(bankV2.totalLiabilities(), 1_400e6);
assertEq(MockUSDC(manifest.token).balanceOf(manifest.proxy), 1_400e6);
}
function testBaseUpgradeAndTransferPreserveAccountingAndMoveExactBalance() public {
(string memory path, DemoScript.Manifest memory manifest) = _deployBaseFixtureNamed("transfer-base");
_setBaseConfig(OWNER, BOB, "https://public.invalid");
vm.setEnv("DEPLOYMENT_MANIFEST_PATH", path);
new SeedBaseSepolia().run();
string memory pending = string.concat(fixtureDir, "/base-upgrade-pending.json");
(, address implementation) = new UpgradeV2Harness().runWithPaths(path, pending, OWNER);
vm.writeJson(string.concat('"', vm.toString(implementation), '"'), path, ".implementation");
new TransferV2DemoHarness().runWithPath(path);
BankV2 bank = BankV2(manifest.proxy);
MockUSDC token = MockUSDC(manifest.token);
assertEq(bank.balanceOf(OWNER), 750e6);
assertEq(bank.balanceOf(BOB), 250e6);
assertEq(bank.totalLiabilities(), 1_000e6);
assertEq(token.balanceOf(manifest.proxy), 1_000e6);
new CheckStateHarness().runWithPath(path, "invariants");
}
function testBaseUpgradeRejectsMismatchedConfiguredSenderBeforeBroadcast() public {
(string memory path, DemoScript.Manifest memory manifest) = _deployBaseFixtureNamed("upgrade-base-mismatch");
_setBaseConfig(ALICE, BOB, "https://public.invalid");
string memory pending = string.concat(fixtureDir, "/base-mismatch-pending.json");
UpgradeV2Harness upgrader = new UpgradeV2Harness();
vm.expectRevert(
abi.encodeWithSelector(DemoScript.UnexpectedAddress.selector, "BASE_SEPOLIA_SENDER", OWNER, ALICE)
);
upgrader.runWithPaths(path, pending, OWNER);
assertEq(Upgrades.getImplementationAddress(manifest.proxy), manifest.implementation);
}
function testBaseCheckStateRejectsConfiguredActorMismatch() public {
(string memory path, DemoScript.Manifest memory manifest) = _deployBaseFixtureNamed("check-base-mismatch");
_setBaseConfig(OWNER, ALICE, "https://public.invalid");
CheckStateHarness checker = new CheckStateHarness();
vm.expectRevert(
abi.encodeWithSelector(
DemoScript.UnexpectedAddress.selector, "Recipient", manifest.actors[1].address_, ALICE
)
);
checker.runWithPath(path, "deployed");
}
function testBaseTransferRejectsInvalidRecipientBeforeBroadcast() public {
(string memory path, DemoScript.Manifest memory manifest) = _deployBaseFixtureNamed("transfer-base-invalid");
_setBaseConfig(OWNER, BOB, "https://public.invalid");
vm.setEnv("DEPLOYMENT_MANIFEST_PATH", path);
new SeedBaseSepolia().run();
string memory pending = string.concat(fixtureDir, "/base-transfer-upgrade.json");
(, address implementation) = new UpgradeV2Harness().runWithPaths(path, pending, OWNER);
vm.writeJson(string.concat('"', vm.toString(implementation), '"'), path, ".implementation");
_setBaseConfig(OWNER, OWNER, "https://public.invalid");
TransferV2DemoHarness transfer = new TransferV2DemoHarness();
vm.expectRevert(DemoScript.InvalidActorConfiguration.selector);
transfer.runWithPath(path);
BankV2 bank = BankV2(manifest.proxy);
assertEq(bank.balanceOf(OWNER), 1_000e6);
assertEq(bank.balanceOf(BOB), 0);
}
function _snapshot() internal pure returns (UpgradeV2.Snapshot memory snapshot) {
snapshot.proxy = PROXY;
snapshot.implementation = IMPLEMENTATION;
snapshot.owner = OWNER;
snapshot.asset = TOKEN;
snapshot.paused = false;
snapshot.balances = new uint256[](3);
snapshot.balances[0] = 10;
snapshot.balances[1] = 20;
snapshot.balances[2] = 30;
snapshot.liabilities = 60;
snapshot.reserves = 70;
snapshot.surplus = 10;
snapshot.deploymentBlock = 1;
snapshot.version = 1;
}
function _writeManifest(
uint256 chainId,
uint256 deploymentBlock,
address token,
address proxy,
address implementation
) internal returns (string memory path) {
path = string.concat(fixtureDir, "/manifest.json");
string memory json = string.concat(
'{"schemaVersion":1,"network":"anvil","chainId":',
vm.toString(chainId),
',"deploymentBlock":',
vm.toString(deploymentBlock),
',"rpcUrl":"http://127.0.0.1:8545","token":"',
vm.toString(token),
'","proxy":"',
vm.toString(proxy),
'","implementation":"',
vm.toString(implementation),
'","owner":"',
vm.toString(OWNER),
'","actors":[{"label":"owner","address":"',
vm.toString(OWNER),
'"},{"label":"Alice","address":"',
vm.toString(ALICE),
'"},{"label":"Bob","address":"0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC"}]}'
);
vm.writeFile(path, json);
}
function _deployFixture() internal returns (string memory path, DemoScript.Manifest memory manifest) {
return _deployFixtureNamed("deployed");
}
function _deployFixtureNamed(string memory name)
internal
returns (string memory path, DemoScript.Manifest memory manifest)
{
path = string.concat(fixtureDir, "/", name, ".json");
vm.chainId(31337);
vm.setEnv("SCRIPT_SENDER", vm.toString(OWNER));
vm.setEnv("DEPLOYMENT_MANIFEST_PATH", path);
new DeployV1().run();
vm.writeJson("1", path, ".deploymentBlock");
manifest = harness.readManifest(path, true);
}
function _deployUpgradeFixtureNamed(string memory name)
internal
returns (string memory path, DemoScript.Manifest memory manifest)
{
vm.chainId(31337);
MockUSDC deployedToken = new MockUSDC(OWNER);
address deployedProxy = Upgrades.deployUUPSProxy(
"BankV1.sol:BankV1", abi.encodeCall(BankV1.initialize, (address(deployedToken), OWNER))
);
manifest.schemaVersion = 1;
manifest.network = "anvil";
manifest.chainId = 31337;
manifest.deploymentBlock = 1;
manifest.rpcUrl = "http://127.0.0.1:8545";
manifest.token = address(deployedToken);
manifest.proxy = deployedProxy;
manifest.implementation = Upgrades.getImplementationAddress(deployedProxy);
manifest.owner = OWNER;
manifest.actors = _actors();
path = string.concat(fixtureDir, "/", name, ".json");
vm.writeFile(path, harness.serializeManifest(manifest));
}
function _deployBaseFixtureNamed(string memory name)
internal
returns (string memory path, DemoScript.Manifest memory manifest)
{
vm.chainId(84532);
MockUSDC deployedToken = new MockUSDC(OWNER);
address deployedProxy = Upgrades.deployUUPSProxy(
"BankV1.sol:BankV1", abi.encodeCall(BankV1.initialize, (address(deployedToken), OWNER))
);
manifest.schemaVersion = 1;
manifest.network = "baseSepolia";
manifest.chainId = 84532;
manifest.deploymentBlock = 1;
manifest.rpcUrl = "https://public.invalid";
manifest.explorerBaseUrl = "https://sepolia.basescan.org";
manifest.token = address(deployedToken);
manifest.proxy = deployedProxy;
manifest.implementation = Upgrades.getImplementationAddress(deployedProxy);
manifest.owner = OWNER;
manifest.actors = _baseActors();
path = string.concat(fixtureDir, "/", name, ".json");
vm.writeFile(path, harness.serializeManifest(manifest));
}
function _seedState(DemoScript.Manifest memory manifest) internal {
MockUSDC deployedToken = MockUSDC(manifest.token);
BankV1 deployedBank = BankV1(manifest.proxy);
vm.startPrank(OWNER);
deployedToken.mint(ALICE, 2_000e6);
deployedToken.mint(BOB, 1_000e6);
vm.stopPrank();
vm.startPrank(ALICE);
deployedToken.approve(manifest.proxy, 1_000e6);
deployedBank.deposit(1_000e6);
deployedBank.withdraw(100e6);
vm.stopPrank();
vm.startPrank(BOB);
deployedToken.approve(manifest.proxy, 500e6);
deployedBank.deposit(500e6);
vm.stopPrank();
}
function _writePublicAnvilManifest() internal returns (string memory path) {
path = string.concat(fixtureDir, "/public-anvil-manifest.json");
vm.writeFile(
path,
string.concat(
'{"schemaVersion":1,"network":"anvil","chainId":31337,"deploymentBlock":1,"rpcUrl":"http://127.0.0.1:8545","token":"',
vm.toString(TOKEN),
'","proxy":"',
vm.toString(PROXY),
'","implementation":"',
vm.toString(IMPLEMENTATION),
'","owner":"',
vm.toString(OWNER),
'","actors":[{"label":"owner","address":"',
vm.toString(OWNER),
'"},{"label":"Alice","address":"',
vm.toString(ALICE),
'"},{"label":"Bob","address":"0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC"}]}'
)
);
}
function _writeLegacyParallelActorManifest() internal returns (string memory path) {
path = string.concat(fixtureDir, "/legacy-parallel-actors.json");
vm.writeFile(
path,
string.concat(
'{"schemaVersion":1,"network":"anvil","chainId":31337,"deploymentBlock":1,"rpcUrl":"http://127.0.0.1:8545","token":"',
vm.toString(TOKEN),
'","proxy":"',
vm.toString(PROXY),
'","implementation":"',
vm.toString(IMPLEMENTATION),
'","owner":"',
vm.toString(OWNER),
'","actorLabels":["owner","Alice","Bob"],"actors":[{"label":"owner","address":"',
vm.toString(OWNER),
'"},{"label":"Alice","address":"',
vm.toString(ALICE),
'"},{"label":"Bob","address":"0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC"}]}'
)
);
}
function _writeAnvilManifestWithExtraActorKey(string memory key, string memory value)
internal
returns (string memory path)
{
path = string.concat(fixtureDir, "/extra-actor-key.json");
vm.writeFile(
path,
string.concat(
'{"schemaVersion":1,"network":"anvil","chainId":31337,"deploymentBlock":1,"rpcUrl":"http://127.0.0.1:8545","token":"',
vm.toString(TOKEN),
'","proxy":"',
vm.toString(PROXY),
'","implementation":"',
vm.toString(IMPLEMENTATION),
'","owner":"',
vm.toString(OWNER),
'","actors":[{"label":"owner","address":"',
vm.toString(OWNER),
'","',
key,
'":"',
value,
'"},{"label":"Alice","address":"',
vm.toString(ALICE),
'"},{"label":"Bob","address":"0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC"}]}'
)
);
}
function _writePublicBaseManifest() internal returns (string memory path) {
path = string.concat(fixtureDir, "/public-base-manifest.json");
vm.writeFile(
path,
string.concat(
'{"schemaVersion":1,"network":"baseSepolia","chainId":84532,"deploymentBlock":1,"rpcUrl":"https://public.invalid","explorerBaseUrl":"https://sepolia.basescan.org","token":"',
vm.toString(TOKEN),
'","proxy":"',
vm.toString(PROXY),
'","implementation":"',
vm.toString(IMPLEMENTATION),
'","owner":"',
vm.toString(OWNER),
'","actors":[{"label":"Presenter","address":"',
vm.toString(OWNER),
'"},{"label":"Recipient","address":"',
vm.toString(BOB),
'"}]}'
)
);
}
function _etchManifestContracts() internal {
vm.etch(TOKEN, hex"00");
vm.etch(PROXY, hex"00");
vm.etch(IMPLEMENTATION, hex"00");
}
function _actors() internal pure returns (DemoScript.Actor[] memory actors) {
actors = new DemoScript.Actor[](3);
actors[0] = DemoScript.Actor({label: "owner", address_: OWNER});
actors[1] = DemoScript.Actor({label: "Alice", address_: ALICE});
actors[2] = DemoScript.Actor({label: "Bob", address_: 0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC});
}
function _baseActors() internal pure returns (DemoScript.Actor[] memory actors) {
actors = new DemoScript.Actor[](2);
actors[0] = DemoScript.Actor({label: "Presenter", address_: OWNER});
actors[1] = DemoScript.Actor({label: "Recipient", address_: BOB});
}
function _contains(string memory haystack, string memory needle) internal pure returns (bool) {
bytes memory h = bytes(haystack);
bytes memory n = bytes(needle);
if (n.length > h.length) return false;
for (uint256 i; i + n.length <= h.length; ++i) {
bool match_ = true;
for (uint256 j; j < n.length; ++j) {
if (h[i + j] != n[j]) {
match_ = false;
break;
}
}
if (match_) return true;
}
return false;
}
function _setBaseConfig(address sender, address recipient, string memory publicRpcUrl) internal {
vm.setEnv("SCRIPT_SENDER", vm.toString(sender));
vm.setEnv("BASE_SEPOLIA_SENDER", vm.toString(sender));
vm.setEnv("BASE_SEPOLIA_RECIPIENT", vm.toString(recipient));
vm.setEnv("BASE_SEPOLIA_PUBLIC_RPC_URL", publicRpcUrl);
}
}
+79
View File
@@ -0,0 +1,79 @@
// SPDX-License-Identifier: MIT
pragma solidity 0.8.35;
import {Test} from "forge-std/Test.sol";
import {BankV1} from "../../src/BankV1.sol";
import {MockUSDC} from "../../src/MockUSDC.sol";
contract BankHandler is Test {
MockUSDC internal immutable token;
BankV1 internal immutable bank;
address internal immutable actor0;
address internal immutable actor1;
address internal immutable actor2;
address internal immutable actor3;
uint256 public ghostDeposited;
uint256 public ghostWithdrawn;
uint256 public ghostDonated;
constructor(MockUSDC token_, BankV1 bank_) {
token = token_;
bank = bank_;
actor0 = address(0x1001);
actor1 = address(0x1002);
actor2 = address(0x1003);
actor3 = address(0x1004);
}
function deposit(uint256 actorSeed, uint256 amount) external {
address actor = actorAt(actorSeed % actorCount());
amount = bound(amount, 1, 10_000e6);
token.mint(actor, amount);
vm.startPrank(actor);
token.approve(address(bank), amount);
bank.deposit(amount);
vm.stopPrank();
ghostDeposited += amount;
}
function withdraw(uint256 actorSeed, uint256 amount) external {
address actor = actorAt(actorSeed % actorCount());
uint256 balance = bank.balanceOf(actor);
if (balance == 0) return;
amount = bound(amount, 1, balance);
vm.startPrank(actor);
bank.withdraw(amount);
vm.stopPrank();
ghostWithdrawn += amount;
}
function donate(uint256 actorSeed, uint256 amount) external {
address actor = actorAt(actorSeed % actorCount());
amount = bound(amount, 1, 1_000e6);
token.mint(actor, amount);
vm.startPrank(actor);
token.transfer(address(bank), amount);
vm.stopPrank();
ghostDonated += amount;
}
function actorCount() public pure returns (uint256) {
return 4;
}
function actorAt(uint256 index) public view returns (address) {
if (index == 0) return actor0;
if (index == 1) return actor1;
if (index == 2) return actor2;
return actor3;
}
}
+33
View File
@@ -0,0 +1,33 @@
// SPDX-License-Identifier: MIT
pragma solidity 0.8.35;
import {Test} from "forge-std/Test.sol";
import {Upgrades} from "openzeppelin-foundry-upgrades/Upgrades.sol";
import {BankV1} from "../../src/BankV1.sol";
import {MockUSDC} from "../../src/MockUSDC.sol";
abstract contract BankTestBase is Test {
address internal owner;
address internal alice;
address internal bob;
address internal stranger;
MockUSDC internal token;
address internal proxy;
address internal implementation;
BankV1 internal bank;
function setUp() public virtual {
owner = makeAddr("owner");
alice = makeAddr("alice");
bob = makeAddr("bob");
stranger = makeAddr("stranger");
token = new MockUSDC(owner);
proxy =
Upgrades.deployUUPSProxy("BankV1.sol:BankV1", abi.encodeCall(BankV1.initialize, (address(token), owner)));
bank = BankV1(proxy);
implementation = Upgrades.getImplementationAddress(proxy);
}
}
+32
View File
@@ -0,0 +1,32 @@
// SPDX-License-Identifier: MIT
pragma solidity 0.8.35;
import {BankV2} from "../../src/BankV2.sol";
import {MockUSDC} from "../../src/MockUSDC.sol";
import {BankHandler} from "./BankHandler.sol";
contract BankV2Handler is BankHandler {
BankV2 internal immutable bankV2;
uint256 public ghostTransferred;
constructor(MockUSDC token_, BankV2 bank_) BankHandler(token_, bank_) {
bankV2 = bank_;
}
function transfer(uint256 fromSeed, uint256 toSeed, uint256 amount) external {
uint256 count = actorCount();
uint256 fromIndex = fromSeed % count;
address from = actorAt(fromIndex);
uint256 balance = bankV2.balanceOf(from);
if (balance == 0) return;
uint256 toIndex = (fromIndex + 1 + (toSeed % (count - 1))) % count;
address to = actorAt(toIndex);
amount = bound(amount, 1, balance);
vm.prank(from);
bankV2.transferBalance(to, amount);
ghostTransferred += amount;
}
}
+26
View File
@@ -0,0 +1,26 @@
// SPDX-License-Identifier: MIT
pragma solidity 0.8.35;
import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol";
contract FeeOnTransferToken is ERC20 {
constructor() ERC20("Fee-on-Transfer Token", "FOT") {}
function decimals() public pure override returns (uint8) {
return 6;
}
function mint(address to, uint256 amount) external {
_mint(to, amount);
}
function transferFrom(address from, address to, uint256 amount) public override returns (bool) {
address spender = _msgSender();
_spendAllowance(from, spender, amount);
uint256 received = amount * 99 / 100;
_transfer(from, to, received);
_burn(from, amount - received);
return true;
}
}
+30
View File
@@ -0,0 +1,30 @@
// SPDX-License-Identifier: MIT
pragma solidity 0.8.35;
import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
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 IncompatibleBank is
Initializable,
UUPSUpgradeable,
OwnableUpgradeable,
PausableUpgradeable,
ReentrancyGuardTransient
{
mapping(address account => uint256 balance) internal _balances;
IERC20 internal _asset;
uint256 internal _totalLiabilities;
uint256[47] private __gap;
function initialize(address asset_, address initialOwner) public initializer {
__Ownable_init(initialOwner);
__Pausable_init();
_asset = IERC20(asset_);
}
function _authorizeUpgrade(address) internal override onlyOwner {}
}
+8
View File
@@ -0,0 +1,8 @@
// SPDX-License-Identifier: MIT
pragma solidity 0.8.35;
contract NonUUPSImplementation {
function contractVersion() external pure returns (uint256) {
return 2;
}
}
+104
View File
@@ -0,0 +1,104 @@
// SPDX-License-Identifier: MIT
pragma solidity 0.8.35;
import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol";
interface IReentrantBankTarget {
function deposit(uint256 amount) external;
function withdraw(uint256 amount) external;
function balanceOf(address account) external view returns (uint256);
function totalLiabilities() external view returns (uint256);
}
contract ReentrantToken is ERC20 {
enum Callback {
None,
Deposit,
Withdraw
}
IReentrantBankTarget public callbackTarget;
Callback public callback;
bool public propagateRevert;
bool public nestedCallAttempted;
bool public nestedCallSucceeded;
bytes4 public nestedRevertSelector;
uint256 public observedAccountBalance;
uint256 public observedLiabilities;
constructor() ERC20("Reentrant Token", "REENT") {}
function decimals() public pure override returns (uint8) {
return 6;
}
function mint(address to, uint256 amount) external {
_mint(to, amount);
}
function configureDepositCallback(address bank, bool propagate) external {
callbackTarget = IReentrantBankTarget(bank);
callback = Callback.Deposit;
propagateRevert = propagate;
_resetObservations();
}
function configureWithdrawalCallback(address bank, bool propagate) external {
callbackTarget = IReentrantBankTarget(bank);
callback = Callback.Withdraw;
propagateRevert = propagate;
_resetObservations();
}
function clearCallback() external {
callback = Callback.None;
propagateRevert = false;
_resetObservations();
}
function transferFrom(address from, address to, uint256 amount) public override returns (bool) {
if (callback == Callback.Deposit && _msgSender() == address(callbackTarget)) {
observedAccountBalance = callbackTarget.balanceOf(from);
observedLiabilities = callbackTarget.totalLiabilities();
_attemptNestedCall(abi.encodeCall(IReentrantBankTarget.deposit, (1)));
}
return super.transferFrom(from, to, amount);
}
function transfer(address to, uint256 amount) public override returns (bool) {
if (callback == Callback.Withdraw && _msgSender() == address(callbackTarget)) {
observedAccountBalance = callbackTarget.balanceOf(to);
observedLiabilities = callbackTarget.totalLiabilities();
_attemptNestedCall(abi.encodeCall(IReentrantBankTarget.withdraw, (1)));
}
return super.transfer(to, amount);
}
function _attemptNestedCall(bytes memory callData) private {
nestedCallAttempted = true;
bytes memory revertData;
(nestedCallSucceeded, revertData) = address(callbackTarget).call(callData);
if (!nestedCallSucceeded && revertData.length >= 4) {
bytes4 selector;
assembly ("memory-safe") {
selector := mload(add(revertData, 0x20))
}
nestedRevertSelector = selector;
}
if (!nestedCallSucceeded && propagateRevert) {
assembly ("memory-safe") {
revert(add(revertData, 0x20), mload(revertData))
}
}
}
function _resetObservations() private {
nestedCallAttempted = false;
nestedCallSucceeded = false;
nestedRevertSelector = bytes4(0);
observedAccountBalance = 0;
observedLiabilities = 0;
}
}
+28
View File
@@ -0,0 +1,28 @@
import { readFile } from "node:fs/promises";
import { createRequire } from "node:module";
const require = createRequire(import.meta.url);
const expectedVersion = "1.46.0";
const pluginSource = await readFile(
new URL("../lib/openzeppelin-foundry-upgrades/src/internal/Versions.sol", import.meta.url),
"utf8"
);
const lockfile = JSON.parse(
await readFile(new URL("../package-lock.json", import.meta.url), "utf8")
);
const lockedVersion = lockfile.packages?.["node_modules/@openzeppelin/upgrades-core"]?.version;
const installedVersion = require("@openzeppelin/upgrades-core/package.json").version;
if (!pluginSource.includes('UPGRADES_CORE = "^1.45.0"')) {
throw new Error('Expected the vendored plugin to declare UPGRADES_CORE = "^1.45.0".');
}
if (lockedVersion !== expectedVersion) {
throw new Error(`Expected package-lock.json to resolve @openzeppelin/upgrades-core to ${expectedVersion}, found ${lockedVersion}.`);
}
if (installedVersion !== expectedVersion) {
throw new Error(`Expected installed @openzeppelin/upgrades-core to report ${expectedVersion}, found ${installedVersion}.`);
}
console.log(`@openzeppelin/upgrades-core ${expectedVersion} is pinned and installed.`);
+104
View File
@@ -0,0 +1,104 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd -P)
# shellcheck source=process-lib.sh
source "$ROOT/tools/process-lib.sh"
ANVIL_DEV_WORDS=(test test test test test test test test test test test junk)
CLEANING=0
ANVIL_PID='' ANVIL_START='' ANVIL_PGID=''
VITE_PID='' VITE_START='' VITE_PGID=''
ANVIL_VALIDATED=0 VITE_VALIDATED=0
cleanup() {
local status=$? cleanup_status=0
((CLEANING == 0)) || return
CLEANING=1
trap - INT TERM EXIT
if [[ -n "$VITE_PID" && -n "$VITE_START" && -n "$VITE_PGID" ]]; then
if ((VITE_VALIDATED == 1)); then demo_stop_launch "$ROOT" vite "$VITE_PID" "$VITE_START" "$VITE_PGID" || cleanup_status=1
else demo_stop_raw_launch "$ROOT" vite "$VITE_PID" "$VITE_START" "$VITE_PGID" || cleanup_status=1; fi
else
demo_stop_recorded "$ROOT" vite || cleanup_status=1
fi
if [[ -n "$ANVIL_PID" && -n "$ANVIL_START" && -n "$ANVIL_PGID" ]]; then
if ((ANVIL_VALIDATED == 1)); then demo_stop_launch "$ROOT" anvil "$ANVIL_PID" "$ANVIL_START" "$ANVIL_PGID" || cleanup_status=1
else demo_stop_raw_launch "$ROOT" anvil "$ANVIL_PID" "$ANVIL_START" "$ANVIL_PGID" || cleanup_status=1; fi
else
demo_stop_recorded "$ROOT" anvil || cleanup_status=1
fi
((status == 0 && cleanup_status != 0)) && status=$cleanup_status
exit "$status"
}
trap 'exit 130' INT
trap 'exit 143' TERM
trap cleanup EXIT
capture_and_publish() {
local kind=$1 pid=$2 start pgid
for _ in {1..50}; do
if demo_capture_raw_launch "$pid" start pgid; then
if [[ "$kind" == anvil ]]; then ANVIL_START=$start; ANVIL_PGID=$pgid
else VITE_START=$start; VITE_PGID=$pgid; fi
break
fi
demo_pid_is_running "$pid" || break
sleep 0.02
done
[[ -n ${start:-} && -n ${pgid:-} ]] || {
printf 'Could not capture raw ownership for launched %s PID %s; no signal will be guessed.\n' "$kind" "$pid" >&2
return 1
}
for _ in {1..50}; do
if demo_identity_matches "$ROOT" "$kind" "$pid" "$start" "$pgid"; then
demo_publish_process "$ROOT" "$kind" "$pid" "$start" "$pgid"
if [[ "$kind" == anvil ]]; then ANVIL_VALIDATED=1
else VITE_VALIDATED=1; fi
return
fi
demo_pid_is_running "$pid" || break
sleep 0.02
done
printf 'Could not prove identity for launched %s PID %s; no signal will be guessed.\n' "$kind" "$pid" >&2
return 1
}
cd "$ROOT"
bash tools/reset-local.sh
bash tools/doctor.sh
install -d -m 0700 "$ROOT/.demo"
setsid anvil --host 127.0.0.1 --port 8545 --chain-id 31337 --mnemonic "${ANVIL_DEV_WORDS[*]}" >"$ROOT/.demo/anvil.log" 2>&1 &
ANVIL_PID=$!
capture_and_publish anvil "$ANVIL_PID"
for _ in {1..100}; do
if [[ $(cast chain-id --rpc-url http://127.0.0.1:8545 2>/dev/null || true) == 31337 ]]; then ANVIL_READY=1; break; fi
demo_pid_is_running "$ANVIL_PID" || break
sleep 0.1
done
[[ ${ANVIL_READY:-0} == 1 ]] || { printf '%s\n' 'Anvil did not become ready; see .demo/anvil.log' >&2; exit 1; }
make deploy-v1
make seed-v1
DEMO_EXPECTED_STAGE=v1 make check-state
npm_config_offline=true forge build --force
node tools/sync-web-artifacts.mjs
node tools/sync-web-artifacts.mjs --check
node tools/publish-web-manifest.mjs
setsid "$ROOT/web/node_modules/.bin/vite" web --host 127.0.0.1 --port 5173 >"$ROOT/.demo/vite.log" 2>&1 &
VITE_PID=$!
capture_and_publish vite "$VITE_PID"
for _ in {1..100}; do
if curl --fail --silent --output /dev/null http://127.0.0.1:5173/; then VITE_READY=1; break; fi
demo_pid_is_running "$VITE_PID" || break
sleep 0.1
done
[[ ${VITE_READY:-0} == 1 ]] || { printf '%s\n' 'Vite did not become ready; see .demo/vite.log' >&2; exit 1; }
printf '\nV1 operations console: http://127.0.0.1:5173/\n'
printf '%s\n' 'In a second terminal: DEMO_EXPECTED_STAGE=v1 make check-state'
printf '%s\n' 'Stop this attached demo with Ctrl-C; make reset-local is the recovery command.'
while demo_pid_is_running "$ANVIL_PID" && demo_pid_is_running "$VITE_PID"; do sleep 1; done
printf '%s\n' 'A demo child exited unexpectedly; inspect .demo/anvil.log and .demo/vite.log.' >&2
exit 1
+63
View File
@@ -0,0 +1,63 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd -P)
FAILURES=0
fail() { printf 'FAIL: %s\n' "$1" >&2; FAILURES=$((FAILURES + 1)); }
have() {
local name=$1 link=$2
if ! command -v "$name" >/dev/null 2>&1; then
fail "$name is missing — install from $link"
return 1
fi
}
exact_version() {
local name=$1 expected=$2 actual=$3 link=$4
if [[ "$actual" == "$expected" ]]; then printf 'ok: %s %s\n' "$name" "$actual"
else fail "$name must be $expected (found $actual) — install from $link"; fi
}
port_free() {
local port=$1
if (exec 9<>"/dev/tcp/127.0.0.1/$port") 2>/dev/null; then
fail "127.0.0.1:$port is occupied; stop that external service before starting the demo"
else
printf 'ok: 127.0.0.1:%s is available\n' "$port"
fi
}
printf '%s\n' 'Checking the pinned V1 demo environment (read-only).'
if have git https://git-scm.com/downloads; then printf 'ok: %s\n' "$(git --version)"; fi
if have forge https://getfoundry.sh; then exact_version forge 1.7.1 "$(forge --version | sed -nE 's/^forge Version: ([^ ]+).*/\1/p')" https://getfoundry.sh; fi
if have anvil https://getfoundry.sh; then exact_version anvil 1.7.1 "$(anvil --version | sed -nE 's/^anvil Version: ([^ ]+).*/\1/p')" https://getfoundry.sh; fi
if have cast https://getfoundry.sh; then exact_version cast 1.7.1 "$(cast --version | sed -nE 's/^cast Version: ([^ ]+).*/\1/p')" https://getfoundry.sh; fi
if have node https://nodejs.org/en/download; then exact_version Node 24.18.0 "$(node --version | sed 's/^v//')" https://nodejs.org/en/download; fi
if have npm https://docs.npmjs.com/downloading-and-installing-node-js-and-npm; then exact_version npm 11.17.0 "$(npm --version)" https://docs.npmjs.com/downloading-and-installing-node-js-and-npm; fi
if have bash https://www.gnu.org/software/bash/; then printf 'ok: %s\n' "$(bash --version | sed -n '1p')"; fi
if have make https://www.gnu.org/software/make/; then printf 'ok: %s\n' "$(make --version | sed -n '1p')"; fi
if have curl https://curl.se/download.html; then printf 'ok: %s\n' "$(curl --version | sed -n '1p')"; fi
if git -C "$ROOT" submodule status --recursive | while IFS= read -r line; do [[ "$line" == ' '* ]] || exit 1; done; then
printf '%s\n' 'ok: recursive Git submodules are initialized at recorded commits'
else
fail 'recursive Git submodules are missing or differ from recorded commits — run make setup'
fi
if [[ -d "$ROOT/node_modules" ]]; then printf '%s\n' 'ok: root node_modules is installed'; else fail 'root node_modules is missing — run make setup'; fi
if [[ -d "$ROOT/web/node_modules" ]]; then printf '%s\n' 'ok: web node_modules is installed'; else fail 'web node_modules is missing — run make setup'; fi
if command -v node >/dev/null 2>&1 && [[ -d "$ROOT/node_modules" ]]; then
node "$ROOT/tools/check-upgrades-cli.mjs" || fail 'the pinned offline OpenZeppelin upgrades CLI is unavailable — run make setup'
fi
for path in "$ROOT" "$ROOT/deployments" "$ROOT/web/public" "$ROOT/web/src/generated"; do
if [[ -d "$path" && -w "$path" ]]; then printf 'ok: writable runtime directory %s\n' "${path#"$ROOT/"}"; else fail "runtime directory is not writable: $path"; fi
done
if [[ -d "$ROOT/.demo" ]]; then [[ -w "$ROOT/.demo" ]] || fail "$ROOT/.demo is not writable"; fi
port_free 8545
port_free 5173
for variable in BASE_SEPOLIA_RPC_URL BASE_SEPOLIA_ACCOUNT; do
if [[ -n ${!variable:-} ]]; then printf 'optional: %s is configured\n' "$variable"; else printf 'optional: %s is not configured (local demo unaffected)\n' "$variable"; fi
done
if ((FAILURES)); then printf 'Doctor found %d problem(s).\n' "$FAILURES" >&2; exit 1; fi
printf '%s\n' 'Doctor passed.'
+546
View File
@@ -0,0 +1,546 @@
import { randomUUID } from "node:crypto";
import { readFile, rename, rm, writeFile } from "node:fs/promises";
import { dirname, join, resolve } from "node:path";
import { pathToFileURL } from "node:url";
export const IMPLEMENTATION_SLOT = "0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc";
export const UPGRADED_TOPIC = "0xbc7cd75a20ee27fd9adebab32041f755214dbc6bffa90cc0225b39da2e5c2d3b";
export const EDUCATIONAL_WARNING = "Educational demo — mock token — never use real funds.";
const NETWORKS = {
anvil: {
name: "anvil", chainId: 31337, canonical: "anvil.json", recovery: "make reset-local", rpcUrl: "http://127.0.0.1:8545",
},
baseSepolia: {
name: "baseSepolia", chainId: 84532, canonical: "base-sepolia.json", recovery: "make archive-base-manifest",
},
};
const REQUIRED_MANIFEST_FIELDS = ["schemaVersion", "network", "chainId", "deploymentBlock", "token", "proxy", "implementation", "owner", "actors"];
const OPTIONAL_MANIFEST_FIELDS = ["rpcUrl", "explorerBaseUrl"];
const ANVIL_TEST_WORDS = [...Array(11).fill("test"), "junk"].join(" ");
const PROHIBITED_STRING_VALUE = /(?:private[_ -]?key|mnemonic|secret|password|credential|api[_ -]?key)|0x[a-fA-F0-9]{64}/i;
export function networkSpec(network) {
const spec = NETWORKS[network];
if (!spec) throw new Error(`unsupported deployment network: ${network}`);
return spec;
}
export async function preflightDeploy({ root = process.cwd(), network }) {
const spec = networkSpec(network);
const target = join(root, "deployments", spec.canonical);
try {
await readFile(target);
} catch (error) {
if (error.code === "ENOENT") return;
throw error;
}
throw new Error(`refusing to overwrite ${target}; recover safely with: ${spec.recovery}`);
}
export async function finalizeDeployment({ root = process.cwd(), rpc }) {
if (typeof rpc !== "function") throw new Error("finalizer requires an RPC function");
const pendingPath = join(root, "deployments", "pending.json");
const pending = await readManifest(pendingPath, { pending: true });
const spec = networkSpec(pending.network);
if (pending.chainId !== spec.chainId) throw new Error(`pending manifest chain ID does not match ${pending.network}`);
if (pending.deploymentBlock !== 0) throw new Error("pending manifest deploymentBlock must be 0");
const broadcastPath = join(root, "broadcast", "DeployV1.s.sol", String(pending.chainId), "run-latest.json");
const broadcast = await readJson(broadcastPath);
if (!Array.isArray(broadcast.transactions)) throw new Error("broadcast is partial: transactions are missing");
const proxyTransactions = broadcast.transactions.filter(
(transaction) => typeof transaction?.contractAddress === "string"
&& transaction.contractAddress.toLowerCase() === pending.proxy.toLowerCase()
&& transaction.transactionType === "CREATE"
&& typeof transaction.hash === "string"
);
if (proxyTransactions.length !== 1) {
throw new Error(`expected exactly one proxy creation transaction, found ${proxyTransactions.length}`);
}
const proxyTransaction = proxyTransactions[0];
const receipt = await rpc("eth_getTransactionReceipt", [proxyTransaction.hash]);
if (!receipt) throw new Error(`missing receipt for proxy transaction ${proxyTransaction.hash}`);
if (!isSuccessfulReceipt(receipt.status)) throw new Error(`proxy receipt ${proxyTransaction.hash} was not successful`);
const deploymentBlock = parseRpcQuantity(receipt.blockNumber, "receipt block number");
if (deploymentBlock === 0) throw new Error("receipt block number must be nonzero");
const actualChainId = parseRpcQuantity(await rpc("eth_chainId", []), "RPC chain ID");
if (actualChainId !== pending.chainId) {
throw new Error(`RPC chain ID ${actualChainId} does not match pending manifest chain ID ${pending.chainId}`);
}
for (const [label, address] of [["token", pending.token], ["proxy", pending.proxy], ["implementation", pending.implementation]]) {
const code = await rpc("eth_getCode", [address, "latest"]);
if (typeof code !== "string" || !/^0x[0-9a-fA-F]+$/.test(code) || code.length <= 2) {
throw new Error(`${label} ${address} has no code`);
}
}
const storage = await rpc("eth_getStorageAt", [pending.proxy, IMPLEMENTATION_SLOT, "latest"]);
if (slotAddress(storage) !== pending.implementation.toLowerCase()) {
throw new Error("proxy implementation slot does not match pending manifest implementation");
}
const confirmed = publicManifest(pending, deploymentBlock);
validateManifest(confirmed);
const path = join(root, "deployments", spec.canonical);
await atomicWriteJson(path, confirmed);
return { path, manifest: confirmed };
}
export async function finalizeUpgrade({ root = process.cwd(), rpc, writeManifest = atomicWriteJson }) {
if (typeof rpc !== "function") throw new Error("finalizer requires an RPC function");
const pendingPath = join(root, "deployments", "upgrade-pending.json");
const pending = await readJson(pendingPath);
if (pending?.mode !== "upgrade" && pending?.mode !== "noop") throw new Error("unknown upgrade staging mode");
const activePath = join(root, "deployments", "active.json");
const active = await readManifest(activePath);
const spec = networkSpec(active.network);
const canonicalPath = join(root, "deployments", spec.canonical);
const canonical = await readManifest(canonicalPath);
const [activeBytes, canonicalBytes] = await Promise.all([readFile(activePath), readFile(canonicalPath)]);
if (pending.mode === "noop") {
assertConfirmedManifestPair(active, canonical, activeBytes, canonicalBytes);
const chainId = parseRpcQuantity(await rpc("eth_chainId", []), "RPC chain ID");
if (chainId !== active.chainId) throw new Error("RPC chain ID does not match active manifest");
const methods = await readUpgradeMethods(root);
validateNoopMarker(pending, active);
const latestBlock = parseRpcQuantity(await rpc("eth_blockNumber", []), "latest block number");
if (latestBlock < pending.observedBlock) throw new Error("live block precedes no-op observation block");
const nonce = parseRpcQuantity(
await rpc("eth_getTransactionCount", [active.owner, "latest"]), "owner nonce"
);
if (nonce !== pending.ownerNonce) throw new Error("owner nonce changed after no-op observation");
await assertLiveVersionAndImplementation({ rpc, active, implementation: pending.implementation, methods });
await rm(pendingPath);
return { mode: "noop", path: canonicalPath, manifest: active };
}
const previous = recoverableUpgradeBaseline(pending, active, canonical, activeBytes, canonicalBytes);
const chainId = parseRpcQuantity(await rpc("eth_chainId", []), "RPC chain ID");
if (chainId !== previous.chainId) throw new Error("RPC chain ID does not match active manifest");
const methods = await readUpgradeMethods(root);
const broadcastPath = join(root, "broadcast", "UpgradeV2.s.sol", String(previous.chainId), "run-latest.json");
const broadcast = await readJson(broadcastPath);
if (!Array.isArray(broadcast.transactions)) throw new Error("upgrade broadcast is partial: transactions are missing");
const hashes = [...new Set(broadcast.transactions.map((transaction) => transaction?.hash)
.filter((hash) => typeof hash === "string"))];
const resolvedTransactions = await Promise.all(hashes.map(async (hash) => ({
hash,
transaction: await rpc("eth_getTransactionByHash", [hash]),
})));
const matching = resolvedTransactions.filter(({ transaction }) =>
typeof transaction?.to === "string" && sameAddress(transaction.to, previous.proxy));
if (matching.length !== 1) throw new Error(`expected exactly one live upgrade transaction to proxy, found ${matching.length}`);
const receipt = await rpc("eth_getTransactionReceipt", [matching[0].hash]);
if (!receipt || !isSuccessfulReceipt(receipt.status)) throw new Error("upgrade receipt was not successful");
const upgradeBlock = parseRpcQuantity(receipt.blockNumber, "upgrade receipt block number");
if (upgradeBlock === 0) throw new Error("upgrade receipt block number must be nonzero");
if (!Array.isArray(receipt.logs) || !receipt.logs.some((log) => isUpgradeLog(log, previous.proxy, pending.implementation))) {
throw new Error("successful upgrade receipt is missing the expected Upgraded event");
}
await assertLiveVersionAndImplementation({ rpc, active: previous, implementation: pending.implementation, methods });
await assertUpgradeSnapshot({ rpc, active: previous, pending, methods });
const updated = { ...previous, implementation: pending.implementation };
validateManifest(updated);
const updatedBytes = Buffer.from(`${JSON.stringify(updated, null, 2)}\n`);
if (!canonicalBytes.equals(updatedBytes)) await writeManifest(canonicalPath, updated);
if (!activeBytes.equals(updatedBytes)) await writeManifest(activePath, updated);
const [confirmedCanonical, confirmedActive] = await Promise.all([
readFile(canonicalPath),
readFile(activePath),
]);
if (!confirmedCanonical.equals(updatedBytes) || !confirmedActive.equals(updatedBytes)) {
throw new Error("active and canonical manifests did not converge after upgrade finalization");
}
await rm(pendingPath);
return { mode: "upgrade", path: canonicalPath, manifest: updated, upgradeBlock };
}
function assertConfirmedManifestPair(active, canonical, activeBytes, canonicalBytes) {
if (!activeBytes.equals(canonicalBytes) || JSON.stringify(active) !== JSON.stringify(canonical)) {
throw new Error("active and canonical manifest identity must match before upgrade finalization");
}
}
function recoverableUpgradeBaseline(pending, active, canonical, activeBytes, canonicalBytes) {
assertAddress(pending?.previousImplementation, "upgrade previous implementation");
assertAddress(pending?.implementation, "upgrade implementation");
const allowedImplementation = (manifest) => sameAddress(manifest.implementation, pending.previousImplementation)
|| sameAddress(manifest.implementation, pending.implementation);
if (!allowedImplementation(active) || !allowedImplementation(canonical)) {
throw new Error("active and canonical manifest divergence is not justified by the upgrade marker");
}
if (sameAddress(active.implementation, canonical.implementation)) {
assertConfirmedManifestPair(active, canonical, activeBytes, canonicalBytes);
}
const previousActive = { ...active, implementation: pending.previousImplementation };
const previousCanonical = { ...canonical, implementation: pending.previousImplementation };
if (JSON.stringify(previousActive) !== JSON.stringify(previousCanonical)) {
throw new Error("active and canonical manifest divergence is not justified by the upgrade marker");
}
validateUpgradeMarker(pending, previousActive);
return previousActive;
}
function validateNoopMarker(marker, active) {
assertExactKeys(marker, ["mode", "chainId", "observedBlock", "ownerNonce", "proxy", "implementation"], "no-op marker");
for (const field of ["chainId", "observedBlock", "ownerNonce"]) {
if (!Number.isSafeInteger(marker[field]) || marker[field] < 0) throw new Error(`no-op marker ${field} is invalid`);
}
if (marker.chainId !== active.chainId || !sameAddress(marker.proxy, active.proxy)
|| !sameAddress(marker.implementation, active.implementation)) {
throw new Error("no-op marker chain/proxy/implementation identity does not match active manifest");
}
}
function validateUpgradeMarker(marker, active) {
assertExactKeys(marker, ["mode", "network", "chainId", "token", "proxy", "previousImplementation", "implementation", "owner", "deploymentBlock", "snapshot"], "upgrade marker");
for (const field of ["network", "chainId", "deploymentBlock"]) {
if (marker[field] !== active[field]) throw new Error(`upgrade marker ${field} identity does not match active manifest`);
}
for (const field of ["token", "proxy", "previousImplementation", "owner"]) {
const activeField = field === "previousImplementation" ? "implementation" : field;
if (!sameAddress(marker[field], active[activeField])) throw new Error(`upgrade marker ${field} identity does not match active manifest`);
}
assertAddress(marker.implementation, "upgrade implementation");
if (sameAddress(marker.implementation, active.implementation)) throw new Error("upgrade implementation did not change");
const snapshot = marker.snapshot;
assertExactKeys(snapshot, ["proxy", "implementation", "owner", "asset", "paused", "balances", "liabilities", "reserves", "surplus", "deploymentBlock", "version"], "upgrade snapshot");
if (!sameAddress(snapshot.proxy, active.proxy) || snapshot.deploymentBlock !== active.deploymentBlock) {
throw new Error("upgrade snapshot proxy or deployment identity changed");
}
if (!sameAddress(snapshot.implementation, active.implementation) || !sameAddress(snapshot.owner, active.owner)
|| !sameAddress(snapshot.asset, active.token) || snapshot.version !== 1 || typeof snapshot.paused !== "boolean") {
throw new Error("upgrade snapshot identity does not match active manifest");
}
if (!Array.isArray(snapshot.balances) || snapshot.balances.length !== active.actors.length) {
throw new Error("upgrade snapshot actor count does not match active manifest");
}
snapshot.balances.forEach((record, index) => {
assertExactKeys(record, ["address", "balance"], `upgrade snapshot actor ${index}`);
if (!sameAddress(record.address, active.actors[index].address)) throw new Error("upgrade snapshot actor identity changed");
assertNonnegativeInteger(record.balance, "upgrade snapshot actor balance");
});
for (const field of ["liabilities", "reserves", "surplus"]) assertNonnegativeInteger(snapshot[field], `upgrade snapshot ${field}`);
if (snapshot.reserves < snapshot.liabilities || snapshot.reserves - snapshot.liabilities !== snapshot.surplus) {
throw new Error("upgrade snapshot accounting is inconsistent");
}
}
async function readUpgradeMethods(root) {
const bank = await readJson(join(root, "out", "BankV2.sol", "BankV2.json"));
const token = await readJson(join(root, "out", "MockUSDC.sol", "MockUSDC.json"));
const requiredBank = ["owner()", "asset()", "paused()", "balanceOf(address)", "totalLiabilities()", "contractVersion()"];
const result = { bank: {}, token: {} };
for (const signature of requiredBank) result.bank[signature] = methodSelector(bank, signature, "BankV2");
result.token["balanceOf(address)"] = methodSelector(token, "balanceOf(address)", "MockUSDC");
return result;
}
function methodSelector(artifact, signature, contractName) {
const selector = artifact?.methodIdentifiers?.[signature];
if (typeof selector !== "string" || !/^[0-9a-fA-F]{8}$/.test(selector)) {
throw new Error(`${contractName} artifact is missing method identifier ${signature}`);
}
return `0x${selector.toLowerCase()}`;
}
async function assertLiveVersionAndImplementation({ rpc, active, implementation, methods }) {
const code = await rpc("eth_getCode", [implementation, "latest"]);
if (typeof code !== "string" || code.length <= 2) throw new Error("upgrade implementation has no live code");
const version = decodeUint(await rpcCall(rpc, active.proxy, methods.bank["contractVersion()"]), "contract version");
if (version !== 2) throw new Error("live contract version is not 2");
const slot = await rpc("eth_getStorageAt", [active.proxy, IMPLEMENTATION_SLOT, "latest"]);
if (slotAddress(slot) !== implementation.toLowerCase()) throw new Error("live proxy implementation slot does not match upgrade marker");
}
async function assertUpgradeSnapshot({ rpc, active, pending, methods }) {
const snapshot = pending.snapshot;
const owner = decodeAddress(await rpcCall(rpc, active.proxy, methods.bank["owner()"]), "owner");
const asset = decodeAddress(await rpcCall(rpc, active.proxy, methods.bank["asset()"]), "asset");
const paused = decodeBool(await rpcCall(rpc, active.proxy, methods.bank["paused()"]), "paused");
if (!sameAddress(owner, snapshot.owner)) throw new Error("live owner changed during upgrade");
if (!sameAddress(asset, snapshot.asset)) throw new Error("live asset changed during upgrade");
if (paused !== snapshot.paused) throw new Error("live pause state changed during upgrade");
for (const [index, actor] of snapshot.balances.entries()) {
const balance = decodeUint(
await rpcCall(rpc, active.proxy, `${methods.bank["balanceOf(address)"]}${encodeAddressWord(actor.address)}`),
`actor ${index} balance`
);
if (balance !== actor.balance) throw new Error(`live actor ${index} balance changed during upgrade`);
}
const liabilities = decodeUint(await rpcCall(rpc, active.proxy, methods.bank["totalLiabilities()"]), "liabilities");
const reserves = decodeUint(
await rpcCall(rpc, active.token, `${methods.token["balanceOf(address)"]}${encodeAddressWord(active.proxy)}`),
"reserves"
);
if (liabilities !== snapshot.liabilities) throw new Error("live liabilities changed during upgrade");
if (reserves !== snapshot.reserves) throw new Error("live reserves changed during upgrade");
if (reserves - liabilities !== snapshot.surplus) throw new Error("live surplus changed during upgrade");
}
function rpcCall(rpc, to, data) { return rpc("eth_call", [{ to, data }, "latest"]); }
function encodeAddressWord(address) { assertAddress(address, "call address"); return address.slice(2).toLowerCase().padStart(64, "0"); }
function decodeUint(value, label) {
if (typeof value !== "string" || !/^0x[0-9a-fA-F]{64}$/.test(value)) throw new Error(`${label} RPC result is invalid`);
const parsed = BigInt(value);
if (parsed > BigInt(Number.MAX_SAFE_INTEGER)) throw new Error(`${label} exceeds JavaScript safe integer range`);
return Number(parsed);
}
function decodeAddress(value, label) {
if (typeof value !== "string" || !/^0x0{24}[0-9a-fA-F]{40}$/.test(value)) throw new Error(`${label} RPC result is invalid`);
return `0x${value.slice(-40)}`;
}
function decodeBool(value, label) {
const decoded = decodeUint(value, label);
if (decoded !== 0 && decoded !== 1) throw new Error(`${label} RPC result is not boolean`);
return decoded === 1;
}
function isUpgradeLog(log, proxy, implementation) {
return typeof log?.address === "string" && sameAddress(log.address, proxy) && Array.isArray(log.topics)
&& log.topics.length >= 2 && log.topics[0]?.toLowerCase() === UPGRADED_TOPIC
&& slotAddress(log.topics[1]) === implementation.toLowerCase();
}
function sameAddress(first, second) {
return typeof first === "string" && typeof second === "string" && /^0x[0-9a-fA-F]{40}$/.test(first)
&& /^0x[0-9a-fA-F]{40}$/.test(second) && first.toLowerCase() === second.toLowerCase();
}
function assertExactKeys(value, expected, label) {
if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error(`${label} must be an object`);
const actual = Object.keys(value).sort();
const wanted = [...expected].sort();
if (actual.length !== wanted.length || actual.some((key, index) => key !== wanted[index])) throw new Error(`${label} schema is invalid`);
}
function assertNonnegativeInteger(value, label) {
if (!Number.isSafeInteger(value) || value < 0) throw new Error(`${label} must be a nonnegative integer`);
}
export async function readManifest(path, { pending = false } = {}) {
const manifest = await readJson(path);
validateManifest(manifest, { pending });
return manifest;
}
export function validateManifest(manifest, { pending = false } = {}) {
if (!manifest || typeof manifest !== "object" || Array.isArray(manifest)) throw new Error("manifest must be a JSON object");
assertExactSchema(manifest);
rejectProhibitedStringValues(manifest);
if (manifest.schemaVersion !== 1) throw new Error("manifest schemaVersion must be 1");
const spec = networkSpec(manifest.network);
if (manifest.chainId !== spec.chainId) throw new Error(`manifest chain ID does not match ${manifest.network}`);
if (!Number.isSafeInteger(manifest.deploymentBlock) || manifest.deploymentBlock < (pending ? 0 : 1)) {
throw new Error(`manifest deploymentBlock must be ${pending ? "a nonnegative integer" : "at least 1"}`);
}
assertManifestUrls(manifest, spec);
for (const field of ["token", "proxy", "implementation", "owner"]) assertAddress(manifest[field], field);
if (!Array.isArray(manifest.actors) || manifest.actors.length === 0) throw new Error("manifest actors must be a nonempty array");
const labels = new Set();
const actors = new Set();
for (const [index, actorRecord] of manifest.actors.entries()) {
if (!actorRecord || typeof actorRecord !== "object" || Array.isArray(actorRecord)) throw new Error(`actors[${index}] must be an object`);
const keys = Object.keys(actorRecord);
if (keys.length !== 2 || !Object.hasOwn(actorRecord, "label") || !Object.hasOwn(actorRecord, "address")) {
throw new Error(`actors[${index}] must contain exactly label and address`);
}
const { label, address } = actorRecord;
if (typeof label !== "string" || label.trim() === "" || labels.has(label)) throw new Error("manifest actor labels must be unique nonempty strings");
labels.add(label);
assertAddress(address, `actors[${index}].address`);
const actor = address.toLowerCase();
if (actors.has(actor)) throw new Error("manifest actors must be unique");
actors.add(actor);
}
assertActorConfiguration(manifest);
}
export async function atomicWriteJson(path, value) {
await atomicWrite(path, `${JSON.stringify(value, null, 2)}\n`);
}
export async function atomicWrite(path, contents, io = { writeFile, rename }) {
const temporary = join(dirname(path), `.${randomUUID()}.json`);
const operations = { writeFile, rename, rm, ...io };
try {
await operations.writeFile(temporary, contents, { mode: 0o600 });
await operations.rename(temporary, path);
} catch (error) {
await operations.rm(temporary, { force: true }).catch(() => {});
throw error;
}
}
function assertAddress(value, label) {
if (typeof value !== "string" || !/^0x[0-9a-fA-F]{40}$/.test(value) || /^0x0{40}$/i.test(value)) {
throw new Error(`manifest ${label} must be a nonzero address`);
}
}
function assertExactSchema(manifest) {
for (const field of REQUIRED_MANIFEST_FIELDS) {
if (!Object.hasOwn(manifest, field)) throw new Error(`manifest is missing required field ${field}`);
}
for (const field of Object.keys(manifest)) {
if (![...REQUIRED_MANIFEST_FIELDS, ...OPTIONAL_MANIFEST_FIELDS].includes(field)) throw new Error(`manifest contains unknown field ${field}`);
}
}
function assertManifestUrls(manifest, spec) {
if (manifest.network === "anvil") {
if (Object.hasOwn(manifest, "rpcUrl") && manifest.rpcUrl !== spec.rpcUrl) {
throw new Error("manifest anvil rpcUrl must use the local public endpoint");
}
if (Object.hasOwn(manifest, "explorerBaseUrl")) throw new Error("manifest anvil must omit explorerBaseUrl");
return;
}
for (const field of ["rpcUrl", "explorerBaseUrl"]) {
if (!Object.hasOwn(manifest, field)) throw new Error(`manifest baseSepolia is missing ${field}`);
assertPublicUrl(manifest[field], field);
}
if (new URL(manifest.rpcUrl).protocol !== "https:") {
throw new Error("manifest Base Sepolia rpcUrl must use HTTPS");
}
if (manifest.explorerBaseUrl !== "https://sepolia.basescan.org") {
throw new Error("manifest Base Sepolia explorerBaseUrl must use the BaseScan root");
}
}
function assertPublicUrl(value, field) {
if (typeof value !== "string") throw new Error(`manifest ${field} must be a URL string`);
let url;
try {
url = new URL(value);
} catch {
throw new Error(`manifest ${field} must be a public URL`);
}
if ((url.protocol !== "https:" && url.protocol !== "http:") || url.username || url.password) {
throw new Error(`manifest ${field} must be a public URL without credentials`);
}
for (const key of url.searchParams.keys()) {
if (/(key|token|secret|password|credential)/i.test(key)) {
throw new Error(`manifest ${field} must not contain credential query parameters`);
}
}
}
function assertActorConfiguration(manifest) {
const { actors, network, owner } = manifest;
if (network === "anvil") {
const expected = [
["owner", "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266"],
["Alice", "0x70997970C51812dc3A010C7d01b50e0d17dc79C8"],
["Bob", "0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC"],
];
if (actors.length !== expected.length || actors.some((actor, index) => actor.label !== expected[index][0] || actor.address.toLowerCase() !== expected[index][1].toLowerCase())) {
throw new Error("manifest anvil actors must match the documented local actor configuration");
}
} else {
if (actors.length !== 2 || actors[0].label !== "Presenter" || actors[1].label !== "Recipient") {
throw new Error("manifest baseSepolia actors must contain exactly Presenter and Recipient");
}
}
if (actors[0].address.toLowerCase() !== owner.toLowerCase()) {
throw new Error(`manifest owner must be the ${network === "baseSepolia" ? "Presenter" : "first actor"}`);
}
}
function rejectProhibitedStringValues(value, path = "") {
if (typeof value === "string") {
if (value.includes(ANVIL_TEST_WORDS) || PROHIBITED_STRING_VALUE.test(value)) {
throw new Error(`manifest contains prohibited secret material at ${path}`);
}
return;
}
if (Array.isArray(value)) {
value.forEach((item, index) => rejectProhibitedStringValues(item, `${path}[${index}]`));
return;
}
if (!value || typeof value !== "object") return;
for (const [key, nested] of Object.entries(value)) {
const nestedPath = path ? `${path}.${key}` : key;
rejectProhibitedStringValues(key, nestedPath);
rejectProhibitedStringValues(nested, nestedPath);
}
}
function publicManifest(manifest, deploymentBlock) {
return {
schemaVersion: manifest.schemaVersion,
network: manifest.network,
chainId: manifest.chainId,
deploymentBlock,
...(Object.hasOwn(manifest, "rpcUrl") ? { rpcUrl: manifest.rpcUrl } : {}),
...(Object.hasOwn(manifest, "explorerBaseUrl") ? { explorerBaseUrl: manifest.explorerBaseUrl } : {}),
token: manifest.token,
proxy: manifest.proxy,
implementation: manifest.implementation,
owner: manifest.owner,
actors: manifest.actors.map(({ label, address }) => ({ label, address })),
};
}
function isSuccessfulReceipt(status) {
return status === "0x1" || status === 1 || status === "1";
}
function parseRpcQuantity(value, label) {
if (typeof value !== "string" || !/^0x[0-9a-fA-F]+$/.test(value)) throw new Error(`${label} is not a hexadecimal RPC quantity`);
const parsed = Number.parseInt(value, 16);
if (!Number.isSafeInteger(parsed)) throw new Error(`${label} exceeds JavaScript safe integer range`);
return parsed;
}
function slotAddress(value) {
if (typeof value !== "string" || !/^0x[0-9a-fA-F]{64}$/.test(value)) throw new Error("proxy implementation slot response is invalid");
return `0x${value.slice(-40)}`.toLowerCase();
}
async function readJson(path) {
try {
return JSON.parse(await readFile(path, "utf8"));
} catch (error) {
if (error instanceof SyntaxError) throw new Error(`invalid JSON at ${path}`);
throw error;
}
}
function fetchRpc(rpcUrl) {
let nextId = 1;
return async (method, params) => {
const response = await fetch(rpcUrl, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ jsonrpc: "2.0", id: nextId++, method, params }),
});
if (!response.ok) throw new Error(`RPC ${method} returned HTTP ${response.status}`);
const body = await response.json();
if (body.error) throw new Error(`RPC ${method} failed: ${body.error.message ?? "unknown error"}`);
return body.result;
};
}
export async function runFinalizeCli(argv, { root = process.cwd(), log = console.log } = {}) {
log(EDUCATIONAL_WARNING);
const [command, network, ...rest] = argv;
if (command === "preflight-deploy" && network && rest.length === 0) return preflightDeploy({ root, network });
if (command === "deploy") {
if (network !== "--rpc-url" || typeof rest[0] !== "string" || rest.length !== 1) throw new Error("usage: finalize-manifest.mjs deploy --rpc-url <url>");
return finalizeDeployment({ root, rpc: fetchRpc(rest[0]) });
}
if (command === "upgrade") {
if (network !== "--rpc-url" || typeof rest[0] !== "string" || rest.length !== 1) throw new Error("usage: finalize-manifest.mjs upgrade --rpc-url <url>");
return finalizeUpgrade({ root, rpc: fetchRpc(rest[0]) });
}
throw new Error("usage: finalize-manifest.mjs preflight-deploy <anvil|baseSepolia> | deploy --rpc-url <url> | upgrade --rpc-url <url>");
}
if (process.argv[1] && pathToFileURL(resolve(process.argv[1])).href === import.meta.url) {
runFinalizeCli(process.argv.slice(2)).catch((error) => {
console.error(error.message);
process.exitCode = 1;
});
}
+359
View File
@@ -0,0 +1,359 @@
#!/usr/bin/env bash
demo_repository_root() {
cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd -P
}
demo_is_uint() {
[[ ${1:-} =~ ^[0-9]+$ ]]
}
demo_is_process_id() {
[[ ${1:-} =~ ^[1-9][0-9]*$ ]] && ((10#$1 > 1))
}
demo_read_one_line() {
local path=$1 destination=$2 value extra
IFS= read -r value <"$path" || return 1
if IFS= read -r extra < <(sed -n '2p' "$path") && [[ -n "$extra" ]]; then return 1; fi
printf -v "$destination" '%s' "$value"
}
demo_read_process_identity() {
local pid=$1 state_name=$2 start_name=$3 pgid_name=$4 sid_name=$5 stat rest
local -a fields
demo_is_process_id "$pid" || return 1
{ IFS= read -r stat <"/proc/$pid/stat"; } 2>/dev/null || return 1
rest=${stat##*) }
read -r -a fields <<<"$rest"
((${#fields[@]} >= 20)) || return 1
demo_is_uint "${fields[2]}" && demo_is_uint "${fields[3]}" && demo_is_uint "${fields[19]}" || return 1
printf -v "$state_name" '%s' "${fields[0]}"
printf -v "$pgid_name" '%s' "${fields[2]}"
printf -v "$sid_name" '%s' "${fields[3]}"
printf -v "$start_name" '%s' "${fields[19]}"
}
demo_start_tick() {
local state start pgid sid
demo_read_process_identity "$1" state start pgid sid || return 1
printf '%s\n' "$start"
}
demo_process_group() {
local state start pgid sid
demo_read_process_identity "$1" state start pgid sid || return 1
printf '%s\n' "$pgid"
}
demo_pid_is_running() {
local state start pgid sid
demo_read_process_identity "$1" state start pgid sid && [[ "$state" != Z ]]
}
demo_process_has_command_line() {
local pid=$1 first
{ IFS= read -r -d '' first <"/proc/$pid/cmdline"; } 2>/dev/null || return 1
[[ -n "$first" ]]
}
demo_resolve_command() {
local value=$1 resolved
if [[ "$value" == */* ]]; then
readlink -f -- "$value" 2>/dev/null
else
resolved=$(command -v -- "$value" 2>/dev/null) || return 1
readlink -f -- "$resolved" 2>/dev/null
fi
}
demo_entry_index() {
local expected=$1 array_name=$2 argument resolved index
local -n argv_ref=$array_name
for index in "${!argv_ref[@]}"; do
argument=${argv_ref[index]}
resolved=$(demo_resolve_command "$argument" 2>/dev/null || true)
if [[ "$resolved" == "$expected" ]]; then printf '%s\n' "$index"; return 0; fi
done
return 1
}
demo_interpreter_for_entry() {
local entry=$1 line interpreter
IFS= read -r line <"$entry" 2>/dev/null || return 1
[[ "$line" == '#!'* ]] || return 1
line=${line#\#!}
read -r -a interpreter <<<"$line"
((${#interpreter[@]} > 0)) || return 1
if [[ ${interpreter[0]} == */env && ${interpreter[1]:-} ]]; then demo_resolve_command "${interpreter[1]}"
else demo_resolve_command "${interpreter[0]}"; fi
}
demo_has_exact_tail() {
local array_name=$1 begin=$2
local -n argv_ref=$array_name
shift 2
local -a wanted=("$@")
local index
((${#argv_ref[@]} - begin == ${#wanted[@]})) || return 1
for index in "${!wanted[@]}"; do
[[ ${argv_ref[begin+index]} == "${wanted[index]}" ]] || return 1
done
}
demo_command_matches() {
local root=$1 pid=$2 kind=$3 expected executable interpreter entry_index
local -a arguments=()
# Status 2 means /proc became unreadable while identity was being checked.
# Status 1 remains a definitive command mismatch.
[[ -r "/proc/$pid/cmdline" && -e "/proc/$pid/exe" ]] || return 2
{ mapfile -d '' -t arguments <"/proc/$pid/cmdline"; } 2>/dev/null || return 2
((${#arguments[@]} > 0)) || return 2
executable=$(readlink -f -- "/proc/$pid/exe" 2>/dev/null) || return 2
if [[ "$kind" == anvil ]]; then
expected=$(demo_resolve_command anvil) || return 1
elif [[ "$kind" == vite ]]; then
expected=$(readlink -f -- "$root/web/node_modules/.bin/vite" 2>/dev/null) || return 1
else
return 1
fi
entry_index=$(demo_entry_index "$expected" arguments) || return 1
if [[ "$executable" != "$expected" ]]; then
interpreter=$(demo_interpreter_for_entry "$expected") || return 1
[[ "$executable" == "$interpreter" && "$entry_index" -eq 1 ]] || return 1
else
[[ "$entry_index" -eq 0 ]] || return 1
fi
if [[ "$kind" == anvil ]]; then
local dev_phrase='test test test test test test test test test test test junk'
demo_has_exact_tail arguments "$((entry_index + 1))" \
--host 127.0.0.1 --port 8545 --chain-id 31337 --mnemonic "$dev_phrase"
else
demo_has_exact_tail arguments "$((entry_index + 1))" web --host 127.0.0.1 --port 5173
fi
}
demo_identity_matches() {
local root=$1 kind=$2 pid=$3 expected_start=$4 expected_pgid=$5 state start pgid sid
demo_read_process_identity "$pid" state start pgid sid || return 1
[[ "$state" != Z && "$start" == "$expected_start" && "$pgid" == "$expected_pgid" \
&& "$sid" == "$expected_pgid" && "$pid" == "$expected_pgid" ]] || return 1
demo_command_matches "$root" "$pid" "$kind"
}
demo_capture_raw_launch() {
local pid=$1 start_name=$2 pgid_name=$3 state raw_actual_start raw_actual_pgid sid
demo_read_process_identity "$pid" state raw_actual_start raw_actual_pgid sid || return 1
[[ "$state" != Z && "$pid" == "$raw_actual_pgid" && "$pid" == "$sid" ]] || return 1
printf -v "$start_name" '%s' "$raw_actual_start"
printf -v "$pgid_name" '%s' "$raw_actual_pgid"
}
demo_capture_process() {
local root=$1 kind=$2 pid=$3 start_name=$4 pgid_name=$5 actual_start actual_pgid
demo_capture_raw_launch "$pid" actual_start actual_pgid || return 1
demo_command_matches "$root" "$pid" "$kind" || return 1
printf -v "$start_name" '%s' "$actual_start"
printf -v "$pgid_name" '%s' "$actual_pgid"
}
demo_remove_record() {
local root=$1 kind=$2 path
for path in "$root/.demo/$kind.pid" "$root/.demo/$kind.start" "$root/.demo/$kind.pgid"; do
if [[ -e "$path" ]]; then
rm -f -- "$path"
printf 'Removed %s\n' "${path#"$root/"}"
fi
done
}
demo_publish_process() {
local root=$1 kind=$2 pid=$3 start=$4 pgid=$5 path
demo_is_process_id "$pid" && demo_is_uint "$start" && demo_is_process_id "$pgid" && [[ "$pid" == "$pgid" ]] || return 1
demo_identity_matches "$root" "$kind" "$pid" "$start" "$pgid" || return 1
mkdir -p "$root/.demo"
chmod 0700 "$root/.demo"
for path in "$root/.demo/$kind.pid" "$root/.demo/$kind.start" "$root/.demo/$kind.pgid"; do [[ ! -e "$path" ]] || return 1; done
printf '%s\n' "$start" >"$root/.demo/$kind.start"
printf '%s\n' "$pgid" >"$root/.demo/$kind.pgid"
chmod 0600 "$root/.demo/$kind.start" "$root/.demo/$kind.pgid"
demo_identity_matches "$root" "$kind" "$pid" "$start" "$pgid" || return 1
# PID is the atomic commit marker. PID equals PGID, so a hard link publishes
# the validated value without a temporary path or separately visible write.
ln -- "$root/.demo/$kind.pgid" "$root/.demo/$kind.pid" || return 1
chmod 0600 "$root/.demo/$kind.pid"
}
demo_record_process() {
local root=$1 kind=$2 pid=$3 start pgid
demo_capture_process "$root" "$kind" "$pid" start pgid || return 1
demo_publish_process "$root" "$kind" "$pid" "$start" "$pgid"
}
demo_collect_group_members() {
local expected_pgid=$1 expected_sid=$2 output_name=$3 pid ps_pgid ps_sid ps_state state start pgid sid
local -n output_ref=$output_name
output_ref=()
while read -r pid ps_pgid ps_sid ps_state; do
[[ "$ps_pgid" == "$expected_pgid" && "$ps_sid" == "$expected_sid" && "$ps_state" != Z* ]] || continue
demo_read_process_identity "$pid" state start pgid sid || continue
[[ "$state" != Z && "$pgid" == "$expected_pgid" && "$sid" == "$expected_sid" ]] || continue
output_ref+=("$pid:$start")
done < <(ps -eo pid=,pgid=,sid=,stat=)
}
demo_anchor_allows_signal() {
local root=$1 kind=$2 leader=$3 expected_start=$4 expected_pgid=$5 identity_mode=${6:-validated}
local state start pgid sid command_status attempt
local attempts=${DEMO_ANCHOR_RECHECK_ATTEMPTS:-10} interval=${DEMO_ANCHOR_RECHECK_INTERVAL:-0.01}
demo_is_uint "$attempts" && ((10#$attempts > 0)) || attempts=10
[[ "$interval" =~ ^[0-9]+([.][0-9]+)?$ ]] || interval=0.01
for ((attempt = 0; attempt < attempts; attempt++)); do
if ! demo_read_process_identity "$leader" state start pgid sid; then return 0; fi
[[ "$start" == "$expected_start" && "$pgid" == "$expected_pgid" && "$sid" == "$expected_pgid" ]] || return 1
[[ "$state" == Z ]] && return 0
[[ "$identity_mode" == raw ]] && return 0
if demo_command_matches "$root" "$leader" "$kind"; then return 0; else command_status=$?; fi
((command_status == 2)) || return 1
if ((attempt + 1 < attempts)); then sleep "$interval"; fi
done
return 1
}
demo_signal_member() {
local pid=$1 expected_start=$2 expected_pgid=$3 expected_sid=$4 signal=$5 state start pgid sid signal_status
if ! demo_read_process_identity "$pid" state start pgid sid; then return 0; fi
[[ "$state" != Z ]] || return 0
[[ "$start" == "$expected_start" && "$pgid" == "$expected_pgid" && "$sid" == "$expected_sid" ]] || return 2
if kill -"$signal" -- "$pid" 2>/dev/null; then return 0; else signal_status=$?; fi
if ! demo_read_process_identity "$pid" state start pgid sid; then return 0; fi
[[ "$state" != Z ]] || return 0
[[ "$start" == "$expected_start" && "$pgid" == "$expected_pgid" && "$sid" == "$expected_sid" ]] || return 0
printf 'Failed to signal unchanged live PID %s with %s\n' "$pid" "$signal" >&2
return "$signal_status"
}
demo_group_has_live_members() {
local pgid=$1 sid=$2
local -a members
demo_collect_group_members "$pgid" "$sid" members
((${#members[@]} > 0))
}
demo_stop_owned_session() {
local root=$1 kind=$2 leader=$3 expected_start=$4 expected_pgid=$5 identity_mode=$6 member member_pid member_start result
local -a members
[[ "$identity_mode" == validated || "$identity_mode" == raw ]] || return 1
demo_is_process_id "$leader" && demo_is_uint "$expected_start" && demo_is_process_id "$expected_pgid" \
&& [[ "$leader" == "$expected_pgid" ]] || return 1
demo_anchor_allows_signal "$root" "$kind" "$leader" "$expected_start" "$expected_pgid" "$identity_mode" || {
printf 'Refusing %s PID %s: process identity changed\n' "$kind" "$leader" >&2
return 1
}
demo_collect_group_members "$expected_pgid" "$expected_pgid" members
if ((${#members[@]} == 0)); then
demo_remove_record "$root" "$kind"
return 0
fi
printf 'Stopping validated %s session/process group %s\n' "$kind" "$expected_pgid"
for member in "${members[@]}"; do
IFS=: read -r member_pid member_start <<<"$member"
[[ "$member_pid" != "$leader" ]] || continue
if ! demo_anchor_allows_signal "$root" "$kind" "$leader" "$expected_start" "$expected_pgid" "$identity_mode"; then
printf 'Refusing TERM: %s leader identity changed or remained unverifiable\n' "$kind" >&2
return 1
fi
demo_signal_member "$member_pid" "$member_start" "$expected_pgid" "$expected_pgid" TERM || return 1
done
for member in "${members[@]}"; do
IFS=: read -r member_pid member_start <<<"$member"
[[ "$member_pid" == "$leader" ]] || continue
if ! demo_anchor_allows_signal "$root" "$kind" "$leader" "$expected_start" "$expected_pgid" "$identity_mode"; then
printf 'Refusing TERM: %s leader identity changed or remained unverifiable\n' "$kind" >&2
return 1
fi
demo_signal_member "$member_pid" "$member_start" "$expected_pgid" "$expected_pgid" TERM || return 1
done
for ((result = 0; result < ${DEMO_TERM_WAIT_ATTEMPTS:-50}; result++)); do
demo_group_has_live_members "$expected_pgid" "$expected_pgid" || break
sleep 0.1
done
if demo_group_has_live_members "$expected_pgid" "$expected_pgid"; then
printf 'Escalating surviving validated %s session members to KILL\n' "$kind"
for ((result = 0; result < ${DEMO_KILL_WAIT_ATTEMPTS:-20}; result++)); do
demo_collect_group_members "$expected_pgid" "$expected_pgid" members
((${#members[@]} > 0)) || break
for member in "${members[@]}"; do
IFS=: read -r member_pid member_start <<<"$member"
if ! demo_anchor_allows_signal "$root" "$kind" "$leader" "$expected_start" "$expected_pgid" "$identity_mode"; then
printf 'Refusing KILL escalation: %s leader identity changed\n' "$kind" >&2
return 1
fi
if demo_signal_member "$member_pid" "$member_start" "$expected_pgid" "$expected_pgid" KILL; then
:
else
result=$?
printf 'Refusing KILL escalation: %s member identity changed (%s)\n' "$kind" "$result" >&2
return 1
fi
done
sleep 0.05
done
fi
wait "$leader" 2>/dev/null || true
if demo_group_has_live_members "$expected_pgid" "$expected_pgid"; then
printf 'Validated %s session/process group %s did not stop\n' "$kind" "$expected_pgid" >&2
return 1
fi
demo_remove_record "$root" "$kind"
}
demo_stop_launch() {
demo_stop_owned_session "$1" "$2" "$3" "$4" "$5" validated
}
demo_stop_raw_launch() {
demo_stop_owned_session "$1" "$2" "$3" "$4" "$5" raw
}
demo_stop_recorded() {
local root=$1 kind=$2 pid start pgid
local pid_path="$root/.demo/$kind.pid" start_path="$root/.demo/$kind.start" pgid_path="$root/.demo/$kind.pgid"
[[ "$kind" == anvil || "$kind" == vite ]] || return 1
if [[ ! -e "$pid_path" ]]; then
if [[ ! -e "$start_path" && ! -e "$pgid_path" ]]; then return 0; fi
if [[ -e "$start_path" && -e "$pgid_path" ]] && demo_read_one_line "$start_path" start \
&& demo_read_one_line "$pgid_path" pgid && demo_is_uint "$start" && demo_is_process_id "$pgid"; then
pid=$pgid
printf 'Recovering partial %s launch record with validated in-memory shape\n' "$kind"
else
printf 'Removing incomplete %s launch metadata without signaling\n' "$kind"
demo_remove_record "$root" "$kind"
return 0
fi
else
if ! demo_read_one_line "$pid_path" pid || ! demo_is_process_id "$pid"; then
printf 'Refusing invalid %s PID record\n' "$kind" >&2
return 1
fi
if ! demo_read_one_line "$start_path" start || ! demo_is_uint "$start"; then
printf 'Refusing incomplete %s start-tick record\n' "$kind" >&2
return 1
fi
if ! demo_read_one_line "$pgid_path" pgid || ! demo_is_process_id "$pgid"; then
printf 'Refusing incomplete %s process-group record\n' "$kind" >&2
return 1
fi
fi
[[ "$pid" == "$pgid" ]] || { printf 'Refusing %s: PID/PGID ownership mismatch\n' "$kind" >&2; return 1; }
if ! demo_group_has_live_members "$pgid" "$pgid" && [[ ! -e "/proc/$pid/stat" ]]; then
printf 'Discarding stale %s process record for PID %s\n' "$kind" "$pid"
demo_remove_record "$root" "$kind"
return 0
fi
demo_stop_launch "$root" "$kind" "$pid" "$start" "$pgid"
}
+102
View File
@@ -0,0 +1,102 @@
import { randomUUID } from "node:crypto";
import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
const requiredFields = ["schemaVersion", "network", "chainId", "deploymentBlock", "token", "proxy", "implementation", "owner", "actors"];
const optionalFields = ["rpcUrl", "explorerBaseUrl"];
const zeroAddress = "0x0000000000000000000000000000000000000000";
const secretMarker = /(?:private[_ -]?key|mnemonic|secret|password|credential|api[_ -]?key)|0x[a-fA-F0-9]{64}/i;
export async function publishManifest({
activePath = resolve(repositoryRoot, "deployments/active.json"),
outputPath = resolve(repositoryRoot, "web/public/deployment.json"),
io,
} = {}) {
let contents;
try { contents = await readFile(activePath, "utf8"); } catch { throw new Error(`active manifest is missing at ${activePath}`); }
let manifest;
try { manifest = JSON.parse(contents); } catch { throw new Error("active manifest contains invalid JSON"); }
validatePublicManifest(manifest);
await mkdir(dirname(outputPath), { recursive: true });
await atomicPublish(outputPath, `${JSON.stringify(manifest, null, 2)}\n`, io);
return manifest;
}
async function atomicPublish(path, contents, io = {}) {
const temporary = join(dirname(path), `.${randomUUID()}.json`);
const operations = { writeFile, rename, rm, ...io };
try {
await operations.writeFile(temporary, contents, { mode: 0o600 });
await operations.rename(temporary, path);
} catch (error) {
await operations.rm(temporary, { force: true }).catch(() => {});
throw error;
}
}
export function validatePublicManifest(manifest) {
if (!isRecord(manifest)) throw new Error("manifest must be an object");
for (const field of requiredFields) if (!Object.hasOwn(manifest, field)) throw new Error(`manifest is missing required field ${field}`);
for (const field of Object.keys(manifest)) if (!requiredFields.includes(field) && !optionalFields.includes(field)) throw new Error(`manifest contains unknown field ${field}`);
rejectSecrets(manifest);
if (manifest.schemaVersion !== 1) throw new Error("manifest schemaVersion must be 1");
if (manifest.network !== "anvil" && manifest.network !== "baseSepolia") throw new Error("manifest network is unsupported");
if (manifest.chainId !== (manifest.network === "anvil" ? 31337 : 84532)) throw new Error("manifest chainId does not match network");
if (!Number.isSafeInteger(manifest.deploymentBlock) || manifest.deploymentBlock < 1) throw new Error("manifest deploymentBlock must be at least 1");
for (const field of ["token", "proxy", "implementation", "owner"]) assertAddress(manifest[field], field);
assertActors(manifest.actors);
for (const field of optionalFields) if (Object.hasOwn(manifest, field)) assertPublicUrl(manifest[field], field);
if (manifest.network === "baseSepolia") {
for (const field of ["rpcUrl", "explorerBaseUrl"]) {
if (!Object.hasOwn(manifest, field)) throw new Error(`manifest baseSepolia is missing ${field}`);
}
if (new URL(manifest.rpcUrl).protocol !== "https:") {
throw new Error("manifest Base Sepolia rpcUrl must use HTTPS");
}
if (manifest.explorerBaseUrl !== "https://sepolia.basescan.org") {
throw new Error("manifest Base Sepolia explorerBaseUrl must use the BaseScan root");
}
if (manifest.actors.length !== 2 || manifest.actors[0].label !== "Presenter" || manifest.actors[1].label !== "Recipient") {
throw new Error("manifest baseSepolia actors must contain exactly Presenter and Recipient");
}
if (manifest.owner.toLowerCase() !== manifest.actors[0].address.toLowerCase()) {
throw new Error("manifest owner must be the Presenter");
}
}
}
function isRecord(value) { return typeof value === "object" && value !== null && !Array.isArray(value); }
function assertAddress(value, field) {
if (typeof value !== "string" || !/^0x[0-9a-fA-F]{40}$/.test(value) || value.toLowerCase() === zeroAddress) throw new Error(`manifest ${field} must be a nonzero address`);
}
function assertActors(value) {
if (!Array.isArray(value) || value.length === 0) throw new Error("manifest actors must be a nonempty array");
const labels = new Set(); const addresses = new Set();
for (const [index, actor] of value.entries()) {
if (!isRecord(actor) || Object.keys(actor).length !== 2 || !Object.hasOwn(actor, "label") || !Object.hasOwn(actor, "address")) throw new Error(`manifest actors[${index}] must contain label and address`);
if (typeof actor.label !== "string" || actor.label.trim() === "" || labels.has(actor.label)) throw new Error("manifest actor labels must be unique nonempty strings");
assertAddress(actor.address, `actors[${index}].address`);
const normalized = actor.address.toLowerCase();
if (addresses.has(normalized)) throw new Error("manifest actors must be unique");
labels.add(actor.label); addresses.add(normalized);
}
}
function assertPublicUrl(value, field) {
if (typeof value !== "string") throw new Error(`manifest ${field} must be a public URL`);
let url; try { url = new URL(value); } catch { throw new Error(`manifest ${field} must be a public URL`); }
if ((url.protocol !== "http:" && url.protocol !== "https:") || url.username || url.password) throw new Error(`manifest ${field} must be a public URL without credentials`);
for (const key of url.searchParams.keys()) if (/(key|token|secret)/i.test(key)) throw new Error(`manifest ${field} must not contain credential query parameters`);
}
function rejectSecrets(value, path = "") {
if (typeof value === "string") { if (secretMarker.test(value)) throw new Error(`manifest contains prohibited secret material at ${path}`); return; }
if (Array.isArray(value)) { value.forEach((item, index) => rejectSecrets(item, `${path}[${index}]`)); return; }
if (!isRecord(value)) return;
for (const [key, nested] of Object.entries(value)) { rejectSecrets(key, path); rejectSecrets(nested, path ? `${path}.${key}` : key); }
}
if (process.argv[1] && pathToFileURL(resolve(process.argv[1])).href === import.meta.url) {
if (process.argv.length !== 2) { console.error("usage: publish-web-manifest.mjs"); process.exitCode = 1; }
else publishManifest().catch((error) => { console.error(error.message); process.exitCode = 1; });
}
+56
View File
@@ -0,0 +1,56 @@
#!/usr/bin/env bash
set -euo pipefail
fail() {
printf '%s\n' "$1" >&2
exit 1
}
case ${1-} in
deploy|upgrade|transfer) ;;
*) fail 'usage: require-base-config.sh <deploy|upgrade|transfer>' ;;
esac
for variable in \
BASE_SEPOLIA_RPC_URL \
BASE_SEPOLIA_PUBLIC_RPC_URL \
BASE_SEPOLIA_ACCOUNT \
BASE_SEPOLIA_SENDER \
BASE_SEPOLIA_RECIPIENT; do
[[ -n ${!variable-} ]] || fail "$variable is required"
done
[[ $BASE_SEPOLIA_ACCOUNT =~ ^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$ ]] || \
fail 'BASE_SEPOLIA_ACCOUNT must be a conservative Foundry account identifier'
address_pattern='^0x[[:xdigit:]]{40}$'
[[ $BASE_SEPOLIA_SENDER =~ $address_pattern ]] || fail 'BASE_SEPOLIA_SENDER must be an address'
[[ $BASE_SEPOLIA_RECIPIENT =~ $address_pattern ]] || fail 'BASE_SEPOLIA_RECIPIENT must be an address'
[[ ${BASE_SEPOLIA_SENDER,,} != 0x0000000000000000000000000000000000000000 ]] || \
fail 'BASE_SEPOLIA_SENDER must be nonzero'
[[ ${BASE_SEPOLIA_RECIPIENT,,} != 0x0000000000000000000000000000000000000000 ]] || \
fail 'BASE_SEPOLIA_RECIPIENT must be nonzero'
[[ ${BASE_SEPOLIA_SENDER,,} != "${BASE_SEPOLIA_RECIPIENT,,}" ]] || \
fail 'BASE_SEPOLIA_SENDER and BASE_SEPOLIA_RECIPIENT must differ'
[[ $BASE_SEPOLIA_RPC_URL =~ ^https://[^[:space:]]+$ ]] || \
fail 'BASE_SEPOLIA_RPC_URL must use HTTPS'
[[ $BASE_SEPOLIA_PUBLIC_RPC_URL =~ ^https://[^[:space:]]+$ ]] || \
fail 'BASE_SEPOLIA_PUBLIC_RPC_URL must use HTTPS'
public_remainder=${BASE_SEPOLIA_PUBLIC_RPC_URL#https://}
public_authority=${public_remainder%%[/?#]*}
[[ -n $public_authority && $public_authority != *@* ]] || \
fail 'BASE_SEPOLIA_PUBLIC_RPC_URL must not contain user info'
if [[ $BASE_SEPOLIA_PUBLIC_RPC_URL == *\?* ]]; then
public_query=${BASE_SEPOLIA_PUBLIC_RPC_URL#*\?}
public_query=${public_query%%#*}
IFS='&' read -r -a parameters <<<"$public_query"
for parameter in "${parameters[@]}"; do
key=${parameter%%=*}
if [[ ${key,,} =~ (key|token|secret|password|credential) ]]; then
fail 'BASE_SEPOLIA_PUBLIC_RPC_URL must not contain credential query parameters'
fi
done
fi
+89
View File
@@ -0,0 +1,89 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd -P)
# shellcheck source=process-lib.sh
source "$ROOT/tools/process-lib.sh"
remove_exact() {
local path=$1
if [[ -e "$path" ]]; then
rm -f -- "$path"
printf 'Removed %s\n' "${path#"$ROOT/"}"
fi
}
remove_local_manifest() {
local path=$1
[[ -e "$path" ]] || return 0
if node -e 'const fs=require("fs"); const value=JSON.parse(fs.readFileSync(process.argv[1],"utf8")); process.exit(value && value.network === "anvil" && value.chainId === 31337 ? 0 : 1)' "$path" 2>/dev/null; then
remove_exact "$path"
else
printf 'Preserved non-local or invalid manifest %s\n' "${path#"$ROOT/"}"
fi
}
remove_local_upgrade_marker() {
local path=$1
[[ -e "$path" ]] || return 0
if node -e '
const fs = require("fs");
const marker = JSON.parse(fs.readFileSync(process.argv[1], "utf8"));
const record = (value) => value && typeof value === "object" && !Array.isArray(value);
const exact = (value, keys) => record(value)
&& Object.keys(value).sort().join("\0") === [...keys].sort().join("\0");
const uint = (value) => Number.isSafeInteger(value) && value >= 0;
const address = (value) => typeof value === "string"
&& /^0x[0-9a-fA-F]{40}$/.test(value) && !/^0x0{40}$/i.test(value);
const same = (first, second) => address(first) && address(second) && first.toLowerCase() === second.toLowerCase();
const noop = exact(marker, ["mode", "chainId", "observedBlock", "ownerNonce", "proxy", "implementation"])
&& marker.mode === "noop" && marker.chainId === 31337
&& uint(marker.observedBlock) && uint(marker.ownerNonce)
&& address(marker.proxy) && address(marker.implementation);
const upgradeKeys = ["mode", "network", "chainId", "token", "proxy", "previousImplementation", "implementation", "owner", "deploymentBlock", "snapshot"];
const snapshotKeys = ["proxy", "implementation", "owner", "asset", "paused", "balances", "liabilities", "reserves", "surplus", "deploymentBlock", "version"];
const snapshot = marker?.snapshot;
const balances = Array.isArray(snapshot?.balances) && snapshot.balances.length > 0
&& snapshot.balances.every((balance) => exact(balance, ["address", "balance"])
&& address(balance.address) && uint(balance.balance));
const upgrade = exact(marker, upgradeKeys)
&& marker.mode === "upgrade" && marker.network === "anvil" && marker.chainId === 31337
&& uint(marker.deploymentBlock) && marker.deploymentBlock >= 1
&& [marker.token, marker.proxy, marker.previousImplementation, marker.implementation, marker.owner].every(address)
&& !same(marker.previousImplementation, marker.implementation)
&& exact(snapshot, snapshotKeys) && typeof snapshot.paused === "boolean" && snapshot.version === 1
&& same(snapshot.proxy, marker.proxy) && same(snapshot.implementation, marker.previousImplementation)
&& same(snapshot.owner, marker.owner) && same(snapshot.asset, marker.token)
&& snapshot.deploymentBlock === marker.deploymentBlock && balances
&& uint(snapshot.liabilities) && uint(snapshot.reserves) && uint(snapshot.surplus)
&& snapshot.reserves >= snapshot.liabilities
&& snapshot.reserves - snapshot.liabilities === snapshot.surplus;
process.exit(noop || upgrade ? 0 : 1);
' "$path" 2>/dev/null; then
remove_exact "$path"
else
printf 'Preserved non-local or invalid upgrade marker %s\n' "${path#"$ROOT/"}"
fi
}
demo_stop_recorded "$ROOT" vite
demo_stop_recorded "$ROOT" anvil
remove_exact "$ROOT/.demo/anvil.pid"
remove_exact "$ROOT/.demo/anvil.start"
remove_exact "$ROOT/.demo/anvil.pgid"
remove_exact "$ROOT/.demo/anvil.log"
remove_exact "$ROOT/.demo/vite.pid"
remove_exact "$ROOT/.demo/vite.start"
remove_exact "$ROOT/.demo/vite.pgid"
remove_exact "$ROOT/.demo/vite.log"
remove_local_manifest "$ROOT/deployments/pending.json"
remove_local_upgrade_marker "$ROOT/deployments/upgrade-pending.json"
remove_local_manifest "$ROOT/deployments/anvil.json"
remove_local_manifest "$ROOT/deployments/active.json"
remove_local_manifest "$ROOT/web/public/deployment.json"
remove_exact "$ROOT/web/src/generated/contracts.ts"
rmdir -- "$ROOT/.demo" 2>/dev/null || true
printf '%s\n' 'Local generated state is reproducible with make demo-local.'
+64
View File
@@ -0,0 +1,64 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd -P)
cd "$ROOT"
mapfile -d '' -t TRACKED < <(git ls-files -z --cached --others --exclude-standard -- ':!docs/superpowers/**' ':!foundry.lock' ':!package-lock.json' ':!web/package-lock.json')
secret_names='PRIVATE''_KEY|MNEM''ONIC'
assignment_pattern="(^|[^[:alnum:]_])(${secret_names})[[:space:]]*="
pem_pattern='-----BEGIN .*PRI''VATE KEY-----'
unfinished_pattern='(^|[^[:alnum:]_])(TO''DO|T''BD|FIX''ME)([^[:alnum:]_]|$)'
filler_pattern='lorem[[:space:]]+ip''sum|fill''er[[:space:]]+text'
unsafe_pattern='unsafe''Allow|unsafe''SkipStorageCheck|unsafe''SkipAllChecks|oz-upgrades-unsafe-allow'
allowed_annotation=' /// @custom:oz-upgrades-unsafe-allow constructor'
fixture_name='ANVIL_''TEST_PHRASE'
allowed_fixture_path='script/lib/DemoScript.sol'
allowed_fixture_line=' string internal constant ANVIL_''TEST_PHRASE = "test test test test test test test test test test test junk";'
fixture_count=0
violations=0
report_matches() {
local path=$1 pattern=$2 label=$3 line number=0
while IFS= read -r line || [[ -n "$line" ]]; do
number=$((number + 1))
if [[ "$line" =~ $pattern ]]; then
printf 'forbidden %s: %s:%d:%s\n' "$label" "$path" "$number" "$line" >&2
violations=$((violations + 1))
fi
done <"$path"
}
for path in "${TRACKED[@]}"; do
[[ "$path" == lib/* || ! -f "$path" ]] && continue
report_matches "$path" "$assignment_pattern" 'secret assignment'
report_matches "$path" "$pem_pattern" 'PEM private key'
report_matches "$path" "$unfinished_pattern" 'unfinished marker'
report_matches "$path" "$filler_pattern" 'filler content'
while IFS= read -r line || [[ -n "$line" ]]; do
if [[ "$line" == *"$fixture_name"*'='* ]]; then
if [[ "$path" == "$allowed_fixture_path" && "$line" == "$allowed_fixture_line" ]]; then
fixture_count=$((fixture_count + 1))
else
printf 'forbidden local phrase assignment outside exact fixture: %s:%s\n' "$path" "$line" >&2
violations=$((violations + 1))
fi
fi
done <"$path"
if [[ "$path" == src/* || "$path" == test/* || "$path" == script/* ]]; then
while IFS= read -r line || [[ -n "$line" ]]; do
if [[ "$line" =~ $unsafe_pattern && "$path:$line" != "src/BankV1.sol:$allowed_annotation" ]]; then
printf 'forbidden unsafe upgrade bypass: %s:%s\n' "$path" "$line" >&2
violations=$((violations + 1))
fi
done <"$path"
fi
done
if ((fixture_count != 1)); then
printf 'expected exactly one local phrase fixture assignment, found %d\n' "$fixture_count" >&2
violations=$((violations + 1))
fi
((violations == 0)) || { printf 'Project scan failed with %d violation(s).\n' "$violations" >&2; exit 1; }
printf 'Project scan passed across %d tracked paths.\n' "${#TRACKED[@]}"
+58
View File
@@ -0,0 +1,58 @@
import { link, readFile, rm } from "node:fs/promises";
import { join, resolve } from "node:path";
import { pathToFileURL } from "node:url";
import { atomicWrite, EDUCATIONAL_WARNING, networkSpec, readManifest } from "./finalize-manifest.mjs";
export async function selectManifest({ root = process.cwd(), network }) {
const spec = networkSpec(network);
const source = join(root, "deployments", spec.canonical);
await readManifest(source);
const contents = await readFile(source);
const active = join(root, "deployments", "active.json");
await atomicWrite(active, contents);
return { source, active };
}
export async function archiveManifest({ root = process.cwd(), network, now = new Date(), io = {} }) {
if (network !== "baseSepolia") throw new Error("only baseSepolia may be archived");
const spec = networkSpec(network);
const source = join(root, "deployments", spec.canonical);
await readManifest(source);
if (!(now instanceof Date) || Number.isNaN(now.valueOf())) throw new Error("archive timestamp is invalid");
const timestamp = now.toISOString().replace(/[-:.]/g, "");
if (!/^\d{8}T\d{9}Z$/.test(timestamp)) throw new Error("archive timestamp is invalid");
const target = join(root, "deployments", `base-sepolia.${timestamp}.json`);
const operations = { link, rm, ...io };
try {
await operations.link(source, target);
} catch (error) {
if (error.code === "EEXIST") throw new Error("refusing to overwrite an existing Base Sepolia archive");
throw error;
}
try {
await operations.rm(source);
} catch (error) {
throw new Error(
`archive created at ${target}, but source ${source} could not be removed; both paths were preserved: ${error.message}`,
{ cause: error },
);
}
return { source, path: target };
}
export async function runSelectCli(argv, { root = process.cwd(), log = console.log } = {}) {
log(EDUCATIONAL_WARNING);
if (argv.length === 2 && argv[0] === "archive" && argv[1] === "baseSepolia") {
return archiveManifest({ root, network: argv[1] });
}
if (argv.length !== 1) throw new Error("usage: select-manifest.mjs <anvil|baseSepolia> | archive baseSepolia");
return selectManifest({ root, network: argv[0] });
}
if (process.argv[1] && pathToFileURL(resolve(process.argv[1])).href === import.meta.url) {
runSelectCli(process.argv.slice(2)).catch((error) => {
console.error(error.message);
process.exitCode = 1;
});
}
+109
View File
@@ -0,0 +1,109 @@
import { mkdir, readFile, writeFile } from "node:fs/promises";
import { dirname, resolve } from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
const bankRequirements = [
functionSignature("contractVersion", [], ["uint256"], "pure"),
functionSignature("paused", [], ["bool"], "view"),
functionSignature("asset", [], ["address"], "view"),
functionSignature("owner", [], ["address"], "view"),
functionSignature("totalLiabilities", [], ["uint256"], "view"),
functionSignature("balanceOf", [parameter("account", "address")], ["uint256"], "view"),
eventSignature("Deposited", [parameter("account", "address", true), parameter("amount", "uint256", false)]),
eventSignature("Withdrawn", [parameter("account", "address", true), parameter("amount", "uint256", false)]),
eventSignature("Paused", [parameter("account", "address", false)]),
eventSignature("Unpaused", [parameter("account", "address", false)]),
eventSignature("OwnershipTransferred", [parameter("previousOwner", "address", true), parameter("newOwner", "address", true)]),
eventSignature("Upgraded", [parameter("implementation", "address", true)]),
];
const tokenRequirements = [functionSignature("balanceOf", [parameter("account", "address")], ["uint256"], "view")];
const bankV2Requirements = [
...bankRequirements,
functionSignature("transferBalance", [parameter("recipient", "address"), parameter("amount", "uint256")], [], "nonpayable"),
eventSignature("BalanceTransferred", [parameter("from", "address", true), parameter("to", "address", true), parameter("amount", "uint256", false)]),
];
export function extractAbi(artifact, contractName) {
if (!artifact || typeof artifact !== "object" || !Array.isArray(artifact.abi)) {
throw new Error(`${contractName} artifact must contain an ABI`);
}
const requirements = contractName === "BankV1" ? bankRequirements
: contractName === "BankV2" ? bankV2Requirements
: contractName === "MockUSDC" ? tokenRequirements : null;
if (!requirements) throw new Error(`unsupported contract artifact ${contractName}`);
for (const requirement of requirements) {
const entries = artifact.abi.filter((entry) => entry && entry.type === requirement.type && entry.name === requirement.name);
if (entries.length === 0) throw new Error(`${contractName} ABI is missing required ${requirement.type} ${requirement.name}`);
if (!entries.some((entry) => matchesSignature(entry, requirement))) {
throw new Error(`${contractName} ABI has an invalid signature for ${requirement.name}`);
}
}
return artifact.abi;
}
function parameter(name, type, indexed) { return { name, type, ...(indexed === undefined ? {} : { indexed }) }; }
function functionSignature(name, inputs, outputs, stateMutability) {
return { type: "function", name, inputs, outputs: outputs.map((type) => parameter("", type)), stateMutability };
}
function eventSignature(name, inputs) { return { type: "event", name, inputs, anonymous: false }; }
function matchesSignature(entry, requirement) {
return entry.stateMutability === requirement.stateMutability
&& entry.anonymous === requirement.anonymous
&& matchesParameters(entry.inputs, requirement.inputs)
&& (requirement.outputs === undefined || matchesParameters(entry.outputs, requirement.outputs));
}
function matchesParameters(actual, expected) {
return Array.isArray(actual) && actual.length === expected.length && actual.every((parameter, index) => {
const required = expected[index];
return parameter && parameter.name === required.name && parameter.type === required.type && parameter.indexed === required.indexed;
});
}
export function renderContractsModule(bankV1Abi, bankV2Abi, mockUsdcAbi) {
return `/* This file is generated by tools/sync-web-artifacts.mjs. Do not edit. */\n\nexport const bankV1Abi = ${JSON.stringify(bankV1Abi, null, 2)} as const;\n\nexport const bankV2Abi = ${JSON.stringify(bankV2Abi, null, 2)} as const;\n\nexport const mockUsdcAbi = ${JSON.stringify(mockUsdcAbi, null, 2)} as const;\n`;
}
export async function syncArtifacts({
bankArtifactPath = resolve(repositoryRoot, "out/BankV1.sol/BankV1.json"),
bankV2ArtifactPath = resolve(repositoryRoot, "out/BankV2.sol/BankV2.json"),
tokenArtifactPath = resolve(repositoryRoot, "out/MockUSDC.sol/MockUSDC.json"),
outputPath = resolve(repositoryRoot, "web/src/generated/contracts.ts"),
check = false,
} = {}) {
const [bankArtifact, bankV2Artifact, tokenArtifact] = await Promise.all([
readArtifact(bankArtifactPath, "BankV1"),
readArtifact(bankV2ArtifactPath, "BankV2"),
readArtifact(tokenArtifactPath, "MockUSDC"),
]);
const contents = renderContractsModule(
extractAbi(bankArtifact, "BankV1"),
extractAbi(bankV2Artifact, "BankV2"),
extractAbi(tokenArtifact, "MockUSDC")
);
if (check) {
let existing;
try { existing = await readFile(outputPath, "utf8"); } catch { throw new Error("generated contracts module is stale or missing"); }
if (existing !== contents) throw new Error("generated contracts module is stale");
return contents;
}
await mkdir(dirname(outputPath), { recursive: true });
await writeFile(outputPath, contents);
return contents;
}
async function readArtifact(path, contractName) {
let contents;
try { contents = await readFile(path, "utf8"); } catch { throw new Error(`${contractName} artifact is missing at ${path}`); }
try { return JSON.parse(contents); } catch { throw new Error(`${contractName} artifact contains invalid JSON`); }
}
if (process.argv[1] && pathToFileURL(resolve(process.argv[1])).href === import.meta.url) {
const check = process.argv.slice(2).every((argument) => argument === "--check") && process.argv.includes("--check");
if (process.argv.slice(2).some((argument) => argument !== "--check")) {
console.error("usage: sync-web-artifacts.mjs [--check]");
process.exitCode = 1;
} else {
syncArtifacts({ check }).catch((error) => { console.error(error.message); process.exitCode = 1; });
}
}
+262
View File
@@ -0,0 +1,262 @@
#!/usr/bin/env bash
set -euo pipefail
repository_root=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd -P)
guard="$repository_root/tools/require-base-config.sh"
terminal_url=https://terminal.invalid/rpc
public_url=https://public.invalid/rpc
account=demo-account
sender=0x1111111111111111111111111111111111111111
recipient=0x2222222222222222222222222222222222222222
run_guard() {
local mode=${1-deploy}
shift || true
env -i PATH="$PATH" \
BASE_SEPOLIA_RPC_URL="$terminal_url" \
BASE_SEPOLIA_PUBLIC_RPC_URL="$public_url" \
BASE_SEPOLIA_ACCOUNT="$account" \
BASE_SEPOLIA_SENDER="$sender" \
BASE_SEPOLIA_RECIPIENT="$recipient" \
"$@" bash "$guard" "$mode"
}
assert_no_config_values() {
local output=$1
shift
local value
for value in "$terminal_url" "$public_url" "$account" "$sender" "$recipient" "$@"; do
[[ -n $value ]] || continue
if [[ $output == *"$value"* ]]; then
echo "configuration guard leaked a configured value" >&2
exit 1
fi
done
}
expect_rejected() {
local assignment=$1
local output
if output=$(run_guard deploy "$assignment" 2>&1); then
echo "configuration guard accepted invalid fixture: ${assignment%%=*}" >&2
exit 1
fi
assert_no_config_values "$output" "${assignment#*=}"
}
for mode in deploy upgrade transfer; do
output=$(run_guard "$mode" 2>&1)
[[ -z $output ]] || { echo "configuration guard produced output for valid fixtures" >&2; exit 1; }
done
for assignment in \
BASE_SEPOLIA_RPC_URL= \
BASE_SEPOLIA_PUBLIC_RPC_URL= \
BASE_SEPOLIA_ACCOUNT= \
BASE_SEPOLIA_SENDER= \
BASE_SEPOLIA_RECIPIENT= \
BASE_SEPOLIA_ACCOUNT='bad account' \
BASE_SEPOLIA_SENDER=0x1234 \
BASE_SEPOLIA_RECIPIENT=0x0000000000000000000000000000000000000000 \
BASE_SEPOLIA_RECIPIENT="$sender" \
BASE_SEPOLIA_RPC_URL=http://terminal.invalid \
BASE_SEPOLIA_PUBLIC_RPC_URL=http://public.invalid \
BASE_SEPOLIA_PUBLIC_RPC_URL=https://user@public.invalid \
BASE_SEPOLIA_PUBLIC_RPC_URL='https://public.invalid/?api_key=fixture' \
BASE_SEPOLIA_PUBLIC_RPC_URL='https://public.invalid/?token=fixture'; do
expect_rejected "$assignment"
done
private_key_name=PRIVATE_$(printf KEY)
mnemonic_name=MNEM$(printf ONIC)
fake_key=0x$(printf 'a%.0s' {1..64})
output=$(
env -i PATH="$PATH" "$private_key_name=$fake_key" "$mnemonic_name=fixture words only" \
bash "$guard" deploy 2>&1 || true
)
if [[ $output != *BASE_SEPOLIA_RPC_URL* ]]; then
echo "configuration guard unexpectedly accepted a raw-key or mnemonic fallback" >&2
exit 1
fi
assert_no_config_values "$output"
dry_run=$(
make -n -C "$repository_root" 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
)
[[ $dry_run == *'--account "demo"'* ]] || { echo "Base dry run omitted --account" >&2; exit 1; }
[[ $dry_run == *'--sender "0x1111111111111111111111111111111111111111"'* ]] || \
{ echo "Base dry run omitted --sender" >&2; exit 1; }
[[ $dry_run == *'--slow'* ]] || { echo "Base dry run omitted --slow" >&2; exit 1; }
if [[ ${dry_run,,} == *private-key* || ${dry_run,,} == *mnemonic* ]]; then
echo "Base dry run exposed a raw signing option" >&2
exit 1
fi
node --input-type=module - "$repository_root" <<'NODE'
import assert from "node:assert/strict";
import { access, mkdtemp, mkdir, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { pathToFileURL } from "node:url";
const repositoryRoot = process.argv[2];
const { archiveManifest, selectManifest } = await import(pathToFileURL(join(repositoryRoot, "tools/select-manifest.mjs")));
const { validateManifest } = await import(pathToFileURL(join(repositoryRoot, "tools/finalize-manifest.mjs")));
const { publishManifest, validatePublicManifest } = await import(pathToFileURL(join(repositoryRoot, "tools/publish-web-manifest.mjs")));
const root = await mkdtemp(join(tmpdir(), "uups-base-config-"));
const deployments = join(root, "deployments");
const webManifest = join(root, "web/public/deployment.json");
const owner = "0x1111111111111111111111111111111111111111";
const recipient = "0x2222222222222222222222222222222222222222";
const base = {
schemaVersion: 1,
network: "baseSepolia",
chainId: 84532,
deploymentBlock: 99,
rpcUrl: "https://public.invalid/rpc",
explorerBaseUrl: "https://sepolia.basescan.org",
token: "0x3333333333333333333333333333333333333333",
proxy: "0x4444444444444444444444444444444444444444",
implementation: "0x5555555555555555555555555555555555555555",
owner,
actors: [
{ label: "Presenter", address: owner },
{ label: "Recipient", address: recipient },
],
};
const anvil = {
...base,
network: "anvil",
chainId: 31337,
rpcUrl: "http://127.0.0.1:8545",
explorerBaseUrl: undefined,
actors: [
{ label: "owner", address: "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266" },
{ label: "Alice", address: "0x70997970C51812dc3A010C7d01b50e0d17dc79C8" },
{ label: "Bob", address: "0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC" },
],
owner: "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266",
};
delete anvil.explorerBaseUrl;
try {
await mkdir(deployments, { recursive: true });
await writeFile(join(deployments, "base-sepolia.json"), `${JSON.stringify(base)}\n`);
await writeFile(join(deployments, "anvil.json"), `${JSON.stringify(anvil)}\n`);
await selectManifest({ root, network: "baseSepolia" });
const activeBefore = await readFile(join(deployments, "active.json"));
const anvilBefore = await readFile(join(deployments, "anvil.json"));
const published = await publishManifest({
activePath: join(deployments, "active.json"),
outputPath: webManifest,
});
assert.equal(published.rpcUrl, "https://public.invalid/rpc");
assert.equal(published.explorerBaseUrl, "https://sepolia.basescan.org");
assert.equal((await readFile(webManifest, "utf8")).includes("terminal.invalid"), false);
const without = (field) => {
const manifest = { ...base };
delete manifest[field];
return manifest;
};
const invalidCases = [
["missing rpcUrl", without("rpcUrl"), /rpcUrl/],
["missing explorerBaseUrl", without("explorerBaseUrl"), /explorerBaseUrl/],
["wrong actor count", { ...base, actors: [base.actors[0]] }, /exactly Presenter and Recipient|actor configuration/],
["wrong actor labels", { ...base, actors: [{ ...base.actors[0], label: "Owner" }, base.actors[1]] }, /Presenter and Recipient|actor configuration/],
["owner is not Presenter", { ...base, owner: recipient }, /owner.*Presenter|first actor/],
["HTTP public RPC", { ...base, rpcUrl: "http://public.invalid/rpc" }, /HTTPS/],
["credential-bearing public RPC", { ...base, rpcUrl: "https://user@public.invalid/rpc" }, /credentials|prohibited secret/],
["wrong BaseScan root", { ...base, explorerBaseUrl: "https://example.invalid" }, /BaseScan/],
];
for (const [label, invalid, rejection] of invalidCases) {
assert.throws(() => validateManifest(invalid), rejection, `confirmation accepted ${label}`);
assert.throws(() => validatePublicManifest(invalid), rejection, `publication validation accepted ${label}`);
await writeFile(join(deployments, "active.json"), `${JSON.stringify(invalid)}\n`);
await assert.rejects(
() => publishManifest({ activePath: join(deployments, "active.json"), outputPath: webManifest }),
rejection,
`publication accepted ${label}`,
);
}
await writeFile(join(deployments, "active.json"), activeBefore);
const archived = await archiveManifest({
root,
network: "baseSepolia",
now: new Date("2026-08-21T12:34:56.789Z"),
});
assert.equal(archived.path, join(deployments, "base-sepolia.20260821T123456789Z.json"));
await access(archived.path);
await assert.rejects(() => access(join(deployments, "base-sepolia.json")));
assert.deepEqual(await readFile(join(deployments, "active.json")), activeBefore);
assert.deepEqual(await readFile(join(deployments, "anvil.json")), anvilBefore);
const canonicalPath = join(deployments, "base-sepolia.json");
const canonicalBytes = Buffer.from(`${JSON.stringify(base)}\n`);
const existingBytes = Buffer.from("existing archive bytes\n");
const collisionDate = new Date("2026-08-21T12:35:56.789Z");
const collisionPath = join(deployments, "base-sepolia.20260821T123556789Z.json");
await writeFile(canonicalPath, canonicalBytes);
await writeFile(collisionPath, existingBytes);
await assert.rejects(
() => archiveManifest({ root, network: "baseSepolia", now: collisionDate }),
/existing Base Sepolia archive/,
);
assert.deepEqual(await readFile(canonicalPath), canonicalBytes);
assert.deepEqual(await readFile(collisionPath), existingBytes);
const boundaryDate = new Date("2026-08-21T12:36:56.789Z");
const boundaryPath = join(deployments, "base-sepolia.20260821T123656789Z.json");
const boundaryBytes = Buffer.from("archive created by racing process\n");
await assert.rejects(
() => archiveManifest({
root,
network: "baseSepolia",
now: boundaryDate,
io: {
link: async (_source, target) => {
await writeFile(target, boundaryBytes);
const error = new Error("collision at link boundary");
error.code = "EEXIST";
throw error;
},
},
}),
/existing Base Sepolia archive/,
);
assert.deepEqual(await readFile(canonicalPath), canonicalBytes);
assert.deepEqual(await readFile(boundaryPath), boundaryBytes);
const unlinkFailureDate = new Date("2026-08-21T12:37:56.789Z");
const unlinkFailurePath = join(deployments, "base-sepolia.20260821T123756789Z.json");
await assert.rejects(
() => archiveManifest({
root,
network: "baseSepolia",
now: unlinkFailureDate,
io: {
rm: async (path, options) => {
if (path === canonicalPath) {
assert.deepEqual(await readFile(unlinkFailurePath), canonicalBytes);
throw new Error("source unlink failed");
}
return rm(path, options);
},
},
}),
/source unlink failed/,
);
assert.deepEqual(await readFile(canonicalPath), canonicalBytes);
assert.deepEqual(await readFile(unlinkFailurePath), canonicalBytes);
await assert.rejects(() => archiveManifest({ root, network: "anvil" }), /baseSepolia/);
} finally {
await rm(root, { recursive: true, force: true });
}
NODE
echo "Base configuration and manifest filesystem tests passed"
+530
View File
@@ -0,0 +1,530 @@
import assert from "node:assert/strict";
import { access, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { dirname, join } from "node:path";
import test from "node:test";
import { UPGRADED_TOPIC, atomicWrite, finalizeDeployment, finalizeUpgrade, preflightDeploy, runFinalizeCli, validateManifest } from "./finalize-manifest.mjs";
import { runSelectCli, selectManifest } from "./select-manifest.mjs";
const TOKEN = "0x1000000000000000000000000000000000000001";
const PROXY = "0x2000000000000000000000000000000000000002";
const IMPLEMENTATION = "0x3000000000000000000000000000000000000003";
const V2_IMPLEMENTATION = "0x4000000000000000000000000000000000000004";
const OWNER = "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266";
const IMPLEMENTATION_SLOT = "0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc";
const EDUCATIONAL_WARNING = "Educational demo — mock token — never use real funds.";
const DECLARED_CREATE_HASH = "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
const DECLARED_CALL_HASH = "0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb";
test("preflight rejects an existing target canonical manifest with the safe recovery command", async () => {
await withFixture(async (root) => {
await writeJson(join(root, "deployments", "anvil.json"), manifest());
await assert.rejects(
() => preflightDeploy({ root, network: "anvil" }),
/make reset-local/
);
await writeJson(join(root, "deployments", "base-sepolia.json"), manifest({ network: "baseSepolia", chainId: 84532 }));
await assert.rejects(
() => preflightDeploy({ root, network: "baseSepolia" }),
/make archive-base-manifest/
);
});
});
test("direct manifest CLIs print the exact educational warning", async () => {
await withFixture(async (root) => {
const finalizeLogs = [];
await runFinalizeCli(["preflight-deploy", "anvil"], { root, log: (line) => finalizeLogs.push(line) });
assert.deepEqual(finalizeLogs, [EDUCATIONAL_WARNING]);
await writeJson(join(root, "deployments", "anvil.json"), manifest());
const selectLogs = [];
await runSelectCli(["anvil"], { root, log: (line) => selectLogs.push(line) });
assert.deepEqual(selectLogs, [EDUCATIONAL_WARNING]);
});
});
test("finalizer confirms the exact Base public URLs and Presenter/Recipient actors", async () => {
await withFixture(async (root) => {
const pending = manifest({ network: "baseSepolia", chainId: 84532, deploymentBlock: 0 });
await writeJson(join(root, "deployments", "pending.json"), pending);
await writeBroadcast(root, pending);
const output = await finalizeDeployment({ root, rpc: fakeRpc({ chainId: "0x14a34" }) });
assert.deepEqual(output.manifest, { ...pending, deploymentBlock: 42 });
assert.equal(output.manifest.rpcUrl, "https://sepolia.base.org");
assert.equal(output.manifest.explorerBaseUrl, "https://sepolia.basescan.org");
assert.deepEqual(output.manifest.actors, [
{ label: "Presenter", address: OWNER },
{ label: "Recipient", address: "0x70997970C51812dc3A010C7d01b50e0d17dc79C8" },
]);
});
});
test("finalizer accepts an Anvil manifest with omitted optional rpcUrl and preserves its absence", async () => {
await withFixture(async (root) => {
const pending = manifest({ deploymentBlock: 0 });
delete pending.rpcUrl;
await writeJson(join(root, "deployments", "pending.json"), pending);
await writeBroadcast(root, pending);
const output = await finalizeDeployment({ root, rpc: fakeRpc() });
assert.equal(Object.hasOwn(output.manifest, "rpcUrl"), false);
assert.equal(Object.hasOwn(await readJson(output.path), "rpcUrl"), false);
});
});
test("validator rejects the legacy parallel actor and explorer schema", () => {
assert.throws(
() => validateManifest(legacyManifest()),
/unknown field|missing required field/
);
});
test("finalizer writes only a receipt-confirmed manifest and preserves active until selection", async () => {
await withFixture(async (root) => {
const pending = manifest({ deploymentBlock: 0 });
await writeJson(join(root, "deployments", "pending.json"), pending);
await writeJson(join(root, "deployments", "active.json"), manifest({ network: "baseSepolia", chainId: 84532, deploymentBlock: 88 }));
await writeBroadcast(root, pending, { hash: "0xaaa", contractAddress: PROXY });
const output = await finalizeDeployment({ root, rpc: fakeRpc() });
assert.equal(output.path, join(root, "deployments", "anvil.json"));
assert.deepEqual(await readJson(output.path), { ...pending, deploymentBlock: 42 });
assert.deepEqual(await readJson(join(root, "deployments", "active.json")), manifest({ network: "baseSepolia", chainId: 84532, deploymentBlock: 88 }));
});
});
test("finalizer rejects invalid receipt, transaction, RPC, code, slot, and secret data without touching confirmed files", async () => {
const cases = [
["failed receipt", { rpc: fakeRpc({ receipt: { status: "0x0", blockNumber: "0x2a" } }) }, /not successful/],
["missing receipt", { rpc: fakeRpc({ receipt: null }) }, /missing receipt/],
["ambiguous proxy transaction", { broadcast: { extraProxy: true } }, /exactly one/],
["partial broadcast", { broadcast: { omitProxy: true } }, /exactly one/],
["non-creation proxy transaction", { broadcast: { transactionType: "CALL" } }, /exactly one/],
["wrong chain", { rpc: fakeRpc({ chainId: "0x14a34" }) }, /chain ID/],
["missing code", { rpc: fakeRpc({ missingCode: TOKEN }) }, /has no code/],
["implementation slot mismatch", { rpc: fakeRpc({ slot: TOKEN }) }, /implementation slot/],
["secret-bearing pending manifest", { pending: manifest({ rpcUrl: "https://user:password@example.invalid" }) }, /credential|secret|endpoints/i],
["mnemonic in actor label", { pending: manifest({ deploymentBlock: 0, actors: localActors("test test test test test test test test test test test junk") }) }, /prohibited/i],
["private key in actor label", { pending: manifest({ deploymentBlock: 0, actors: localActors("0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80") }) }, /prohibited/i],
["credential in RPC path", { pending: manifest({ network: "baseSepolia", chainId: 84532, deploymentBlock: 0, rpcUrl: "https://sepolia.base.org/v1/secret-token" }) }, /public URL|prohibited/i],
["unknown manifest field", { pending: manifest({ deploymentBlock: 0, harmlessLookingField: "not allowed" }) }, /unknown field/i],
];
for (const [name, options, expected] of cases) {
await withFixture(async (root) => {
const pending = options.pending ?? manifest({ deploymentBlock: 0 });
await writeJson(join(root, "deployments", "pending.json"), pending);
await writeJson(join(root, "deployments", "anvil.json"), manifest({ deploymentBlock: 7 }));
await writeJson(join(root, "deployments", "active.json"), manifest({ deploymentBlock: 8 }));
await writeBroadcast(root, pending, options.broadcast);
const beforeCanonical = await readFile(join(root, "deployments", "anvil.json"));
const beforeActive = await readFile(join(root, "deployments", "active.json"));
await assert.rejects(() => finalizeDeployment({ root, rpc: options.rpc ?? fakeRpc() }), expected, name);
assert.deepEqual(await readFile(join(root, "deployments", "anvil.json")), beforeCanonical, name);
assert.deepEqual(await readFile(join(root, "deployments", "active.json")), beforeActive, name);
});
}
});
test("finalizer failure leaves an initially absent canonical manifest absent", async () => {
await withFixture(async (root) => {
const pending = manifest({ deploymentBlock: 0, actors: localActors("MNEMONIC") });
await writeJson(join(root, "deployments", "pending.json"), pending);
await writeJson(join(root, "deployments", "active.json"), manifest({ deploymentBlock: 8 }));
await writeBroadcast(root, pending);
await assert.rejects(() => finalizeDeployment({ root, rpc: fakeRpc() }), /prohibited/i);
await assert.rejects(() => access(join(root, "deployments", "anvil.json")));
});
});
test("upgrade finalizer verifies receipt, event, slot, artifact-driven state and changes only implementation", async () => {
await withUpgradeFixture(async (root, active) => {
const before = structuredClone(active);
const output = await finalizeUpgrade({ root, rpc: fakeUpgradeRpc() });
assert.equal(output.mode, "upgrade");
assert.equal(output.upgradeBlock, 90);
assert.deepEqual(output.manifest, { ...before, implementation: V2_IMPLEMENTATION });
for (const name of ["anvil.json", "active.json"]) {
const confirmed = await readJson(join(root, "deployments", name));
assert.deepEqual(confirmed, { ...before, implementation: V2_IMPLEMENTATION });
assert.deepEqual({ ...confirmed, implementation: before.implementation }, before);
}
await assert.rejects(() => access(join(root, "deployments", "upgrade-pending.json")));
});
});
test("upgrade finalizer retains staging after the second manifest write fails and safely converges on retry", async () => {
await withUpgradeFixture(async (root, active) => {
let writes = 0;
const writeManifest = async (path, value) => {
writes += 1;
if (writes === 2) throw new Error("injected active replacement failure");
await atomicWrite(path, `${JSON.stringify(value, null, 2)}\n`);
};
await assert.rejects(
() => finalizeUpgrade({ root, rpc: fakeUpgradeRpc(), writeManifest }),
/injected active replacement failure/,
);
assert.equal((await readJson(join(root, "deployments", "anvil.json"))).implementation, V2_IMPLEMENTATION);
assert.equal((await readJson(join(root, "deployments", "active.json"))).implementation, active.implementation);
await access(join(root, "deployments", "upgrade-pending.json"));
const recovered = await finalizeUpgrade({ root, rpc: fakeUpgradeRpc() });
assert.equal(recovered.manifest.implementation, V2_IMPLEMENTATION);
assert.deepEqual(
await readFile(join(root, "deployments", "active.json")),
await readFile(join(root, "deployments", "anvil.json")),
);
await assert.rejects(() => access(join(root, "deployments", "upgrade-pending.json")));
});
});
test("upgrade finalizer rejects proxy, deployment-block, and actor identity mutation without changing confirmed files", async () => {
for (const [name, mutate] of [
["proxy", (pending) => { pending.proxy = TOKEN; }],
["deployment block", (pending) => { pending.deploymentBlock += 1; }],
["actor", (pending) => { pending.snapshot.balances[1].address = OWNER; }],
]) {
await withUpgradeFixture(async (root) => {
const pendingPath = join(root, "deployments", "upgrade-pending.json");
const pending = await readJson(pendingPath); mutate(pending); await writeJson(pendingPath, pending);
const beforeCanonical = await readFile(join(root, "deployments", "anvil.json"));
const beforeActive = await readFile(join(root, "deployments", "active.json"));
await assert.rejects(() => finalizeUpgrade({ root, rpc: fakeUpgradeRpc() }), /proxy|deployment|actor|identity/i, name);
assert.deepEqual(await readFile(join(root, "deployments", "anvil.json")), beforeCanonical);
assert.deepEqual(await readFile(join(root, "deployments", "active.json")), beforeActive);
await access(pendingPath);
});
}
});
test("upgrade finalizer leaves confirmed files untouched for failed receipt, missing event, live mismatch, and unknown mode", async () => {
const cases = [
["failed receipt", fakeUpgradeRpc({ receiptStatus: "0x0" }), null, /successful/],
["zero receipt block", fakeUpgradeRpc({ receiptBlock: "0x0" }), null, /nonzero/],
["missing Upgraded event", fakeUpgradeRpc({ omitUpgradeLog: true }), null, /Upgraded/],
["wrong live version", fakeUpgradeRpc({ version: 1n }), null, /version/],
["slot mismatch", fakeUpgradeRpc({ slot: IMPLEMENTATION }), null, /slot/],
["owner changed", fakeUpgradeRpc({ owner: TOKEN }), null, /owner/],
["unknown mode", fakeUpgradeRpc(), (pending) => { pending.mode = "mystery"; }, /mode/],
];
for (const [name, rpc, mutate, expected] of cases) {
await withUpgradeFixture(async (root) => {
const pendingPath = join(root, "deployments", "upgrade-pending.json");
if (mutate) { const pending = await readJson(pendingPath); mutate(pending); await writeJson(pendingPath, pending); }
const beforeCanonical = await readFile(join(root, "deployments", "anvil.json"));
const beforeActive = await readFile(join(root, "deployments", "active.json"));
await assert.rejects(() => finalizeUpgrade({ root, rpc }), expected, name);
assert.deepEqual(await readFile(join(root, "deployments", "anvil.json")), beforeCanonical);
assert.deepEqual(await readFile(join(root, "deployments", "active.json")), beforeActive);
await access(pendingPath);
});
}
});
test("noop finalizer ignores stale broadcast history and leaves confirmed manifests byte-for-byte unchanged", async () => {
await withUpgradeFixture(async (root, active) => {
active.implementation = V2_IMPLEMENTATION;
await writeJson(join(root, "deployments", "anvil.json"), active);
await writeJson(join(root, "deployments", "active.json"), active);
await writeJson(join(root, "deployments", "upgrade-pending.json"), {
mode: "noop", chainId: 31337, observedBlock: 80, ownerNonce: 5,
proxy: PROXY, implementation: V2_IMPLEMENTATION,
});
await writeJson(join(root, "broadcast", "UpgradeV2.s.sol", "31337", "run-latest.json"), {
transactions: [{ hash: "0xstale", transaction: { to: TOKEN } }], receipts: [{ status: "0x0" }],
});
const beforeCanonical = await readFile(join(root, "deployments", "anvil.json"));
const beforeActive = await readFile(join(root, "deployments", "active.json"));
const output = await finalizeUpgrade({ root, rpc: fakeUpgradeRpc() });
assert.equal(output.mode, "noop");
assert.deepEqual(await readFile(join(root, "deployments", "anvil.json")), beforeCanonical);
assert.deepEqual(await readFile(join(root, "deployments", "active.json")), beforeActive);
await assert.rejects(() => access(join(root, "deployments", "upgrade-pending.json")));
});
});
test("noop finalizer rejects a regressed block or changed owner nonce without touching confirmed state", async () => {
for (const [name, rpc] of [["block", fakeUpgradeRpc({ blockNumber: 79 })], ["nonce", fakeUpgradeRpc({ nonce: 6 })]]) {
await withUpgradeFixture(async (root, active) => {
active.implementation = V2_IMPLEMENTATION;
await writeJson(join(root, "deployments", "anvil.json"), active);
await writeJson(join(root, "deployments", "active.json"), active);
await writeJson(join(root, "deployments", "upgrade-pending.json"), {
mode: "noop", chainId: 31337, observedBlock: 80, ownerNonce: 5,
proxy: PROXY, implementation: V2_IMPLEMENTATION,
});
const before = await readFile(join(root, "deployments", "active.json"));
await assert.rejects(() => finalizeUpgrade({ root, rpc }), /block|nonce/i, name);
assert.deepEqual(await readFile(join(root, "deployments", "active.json")), before);
await access(join(root, "deployments", "upgrade-pending.json"));
});
}
});
test("selection atomically replaces active with only a valid named canonical manifest", async () => {
await withFixture(async (root) => {
const anvil = manifest({ deploymentBlock: 31 });
const base = manifest({ network: "baseSepolia", chainId: 84532, deploymentBlock: 32 });
await writeJson(join(root, "deployments", "anvil.json"), anvil);
await writeJson(join(root, "deployments", "base-sepolia.json"), base);
await selectManifest({ root, network: "anvil" });
const anvilBytes = await readFile(join(root, "deployments", "anvil.json"));
assert.deepEqual(await readFile(join(root, "deployments", "active.json")), anvilBytes);
await selectManifest({ root, network: "baseSepolia" });
assert.deepEqual(await readFile(join(root, "deployments", "anvil.json")), anvilBytes);
assert.deepEqual(await readJson(join(root, "deployments", "active.json")), base);
const baseBytes = await readFile(join(root, "deployments", "base-sepolia.json"));
await selectManifest({ root, network: "anvil" });
assert.deepEqual(await readFile(join(root, "deployments", "base-sepolia.json")), baseBytes);
await writeJson(join(root, "deployments", "base-sepolia.json"), manifest({ network: "baseSepolia", chainId: 84532, deploymentBlock: 0 }));
const beforeActive = await readFile(join(root, "deployments", "active.json"));
await assert.rejects(() => selectManifest({ root, network: "baseSepolia" }), /deploymentBlock/);
assert.deepEqual(await readFile(join(root, "deployments", "active.json")), beforeActive);
});
});
test("atomic writes stage a same-directory temporary file before renaming it into place", async () => {
const target = "/tmp/deployments/active.json";
const calls = [];
const io = {
writeFile: async (path, contents) => calls.push(["write", path, contents]),
rename: async (source, destination) => calls.push(["rename", source, destination]),
};
await atomicWrite(target, "confirmed", io);
assert.equal(calls[0][0], "write");
assert.equal(dirname(calls[0][1]), dirname(target));
assert.notEqual(calls[0][1], target);
assert.match(calls[0][1], /\.json$/);
assert.deepEqual(calls[1], ["rename", calls[0][1], target]);
});
test("atomic writes remove the ignored staging file when rename fails", async () => {
const target = "/tmp/deployments/active.json";
const calls = [];
const io = {
writeFile: async (path) => calls.push(["write", path]),
rename: async () => { throw new Error("rename failed"); },
rm: async (path) => calls.push(["rm", path]),
};
await assert.rejects(() => atomicWrite(target, "confirmed", io), /rename failed/);
assert.deepEqual(calls[1], ["rm", calls[0][1]]);
});
function manifest(overrides = {}) {
const baseSepolia = overrides.network === "baseSepolia";
return {
schemaVersion: 1,
network: baseSepolia ? "baseSepolia" : "anvil",
chainId: baseSepolia ? 84532 : 31337,
deploymentBlock: 1,
...(baseSepolia
? { rpcUrl: "https://sepolia.base.org", explorerBaseUrl: "https://sepolia.basescan.org" }
: { rpcUrl: "http://127.0.0.1:8545" }),
token: TOKEN,
proxy: PROXY,
implementation: IMPLEMENTATION,
owner: OWNER,
actors: baseSepolia
? [
{ label: "Presenter", address: OWNER },
{ label: "Recipient", address: "0x70997970C51812dc3A010C7d01b50e0d17dc79C8" },
]
: localActors(),
...overrides,
};
}
function legacyManifest(overrides = {}) {
return {
schemaVersion: 1,
network: "anvil",
chainId: 31337,
deploymentBlock: 1,
rpcUrl: "http://127.0.0.1:8545",
explorerUrl: "",
token: TOKEN,
proxy: PROXY,
implementation: IMPLEMENTATION,
owner: OWNER,
actorLabels: ["owner", "Alice", "Bob"],
actors: [OWNER, "0x70997970C51812dc3A010C7d01b50e0d17dc79C8", "0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC"],
...overrides,
};
}
function localActors(aliceLabel = "Alice") {
return [
{ label: "owner", address: OWNER },
{ label: aliceLabel, address: "0x70997970C51812dc3A010C7d01b50e0d17dc79C8" },
{ label: "Bob", address: "0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC" },
];
}
async function withFixture(fn) {
const root = await mkdtemp(join(tmpdir(), "uups-finalizer-"));
try {
await import("node:fs/promises").then(({ mkdir }) => mkdir(join(root, "deployments"), { recursive: true }));
await fn(root);
} finally {
await rm(root, { recursive: true, force: true });
}
}
async function writeBroadcast(root, pending, options = {}) {
const directory = join(root, "broadcast", "DeployV1.s.sol", String(pending.chainId));
await import("node:fs/promises").then(({ mkdir }) => mkdir(directory, { recursive: true }));
const transactions = options.omitProxy
? [{ hash: "0xbbb", transactionType: "CREATE", contractAddress: pending.token }]
: [{ hash: "0xaaa", transactionType: options.transactionType ?? "CREATE", contractAddress: pending.proxy }];
if (options.extraProxy) transactions.push({ hash: "0xccc", transactionType: "CREATE", contractAddress: pending.proxy });
await writeJson(join(directory, "run-latest.json"), { transactions });
}
function fakeRpc(overrides = {}) {
return async (method, params) => {
if (method === "eth_chainId") return overrides.chainId ?? "0x7a69";
if (method === "eth_getTransactionReceipt") return Object.hasOwn(overrides, "receipt") ? overrides.receipt : { status: "0x1", blockNumber: "0x2a" };
if (method === "eth_getCode") return params[0].toLowerCase() === overrides.missingCode?.toLowerCase() ? "0x" : "0x6000";
if (method === "eth_getStorageAt") {
assert.equal(params[1], IMPLEMENTATION_SLOT);
return `0x000000000000000000000000${(overrides.slot ?? IMPLEMENTATION).slice(2)}`;
}
throw new Error(`unexpected RPC method: ${method}`);
};
}
const selectors = {
"owner()": "11111111",
"asset()": "22222222",
"paused()": "33333333",
"balanceOf(address)": "44444444",
"totalLiabilities()": "55555555",
"contractVersion()": "66666666",
};
async function withUpgradeFixture(fn) {
await withFixture(async (root) => {
const active = manifest();
await writeJson(join(root, "deployments", "anvil.json"), active);
await writeJson(join(root, "deployments", "active.json"), active);
await writeJson(join(root, "deployments", "upgrade-pending.json"), upgradePending(active));
await writeUpgradeArtifacts(root);
await writeUpgradeBroadcast(root);
await fn(root, active);
});
}
function upgradePending(active) {
return {
mode: "upgrade",
network: active.network,
chainId: active.chainId,
token: active.token,
proxy: active.proxy,
previousImplementation: active.implementation,
implementation: V2_IMPLEMENTATION,
owner: active.owner,
deploymentBlock: active.deploymentBlock,
snapshot: {
proxy: active.proxy,
implementation: active.implementation,
owner: active.owner,
asset: active.token,
paused: false,
balances: active.actors.map((actor, index) => ({ address: actor.address, balance: index === 1 ? 900_000_000 : index === 2 ? 500_000_000 : 0 })),
liabilities: 1_400_000_000,
reserves: 1_400_000_000,
surplus: 0,
deploymentBlock: active.deploymentBlock,
version: 1,
},
};
}
async function writeUpgradeArtifacts(root) {
const bankDirectory = join(root, "out", "BankV2.sol");
const tokenDirectory = join(root, "out", "MockUSDC.sol");
const { mkdir } = await import("node:fs/promises");
await mkdir(bankDirectory, { recursive: true });
await mkdir(tokenDirectory, { recursive: true });
await writeJson(join(bankDirectory, "BankV2.json"), { methodIdentifiers: selectors });
await writeJson(join(tokenDirectory, "MockUSDC.json"), { methodIdentifiers: { "balanceOf(address)": selectors["balanceOf(address)"] } });
}
async function writeUpgradeBroadcast(root) {
const directory = join(root, "broadcast", "UpgradeV2.s.sol", "31337");
const { mkdir } = await import("node:fs/promises");
await mkdir(directory, { recursive: true });
await writeJson(join(directory, "run-latest.json"), {
transactions: [
{ hash: DECLARED_CREATE_HASH, transactionType: "CREATE", transaction: { to: null } },
{ hash: DECLARED_CALL_HASH, transactionType: "CALL", transaction: { to: PROXY } },
],
});
}
function fakeUpgradeRpc(overrides = {}) {
const balanceByAddress = new Map([
[OWNER.toLowerCase(), 0n],
["0x70997970c51812dc3a010c7d01b50e0d17dc79c8", 900_000_000n],
["0x3c44cdddb6a900fa2b585dd299e03d12fa4293bc", 500_000_000n],
]);
return async (method, params) => {
if (method === "eth_chainId") return "0x7a69";
if (method === "eth_blockNumber") return hexQuantity(overrides.blockNumber ?? 100);
if (method === "eth_getTransactionCount") return hexQuantity(overrides.nonce ?? 5);
if (method === "eth_getCode") return "0x6000";
if (method === "eth_getStorageAt") return wordForRpc(overrides.slot ?? V2_IMPLEMENTATION);
if (method === "eth_getTransactionByHash") {
return { hash: params[0], to: params[0] === DECLARED_CREATE_HASH ? PROXY : null };
}
if (method === "eth_getTransactionReceipt") return {
status: overrides.receiptStatus ?? "0x1",
blockNumber: overrides.receiptBlock ?? "0x5a",
logs: overrides.omitUpgradeLog || params[0] === DECLARED_CALL_HASH
? [] : [{ address: PROXY, topics: [UPGRADED_TOPIC, wordForRpc(V2_IMPLEMENTATION)], data: "0x" }],
};
if (method === "eth_call") {
const call = params[0];
const selector = call.data.slice(2, 10);
if (selector === selectors["owner()"]) return wordForRpc(overrides.owner ?? OWNER);
if (selector === selectors["asset()"]) return wordForRpc(TOKEN);
if (selector === selectors["paused()"]) return uintWord(0n);
if (selector === selectors["totalLiabilities()"]) return uintWord(1_400_000_000n);
if (selector === selectors["contractVersion()"]) return uintWord(overrides.version ?? 2n);
if (selector === selectors["balanceOf(address)"]) {
if (call.to.toLowerCase() === TOKEN.toLowerCase()) return uintWord(1_400_000_000n);
return uintWord(balanceByAddress.get(`0x${call.data.slice(-40)}`.toLowerCase()) ?? 0n);
}
}
throw new Error(`unexpected upgrade RPC method: ${method}`);
};
}
function wordForRpc(address) { return `0x${"0".repeat(24)}${address.slice(2).toLowerCase()}`; }
function uintWord(value) { return `0x${BigInt(value).toString(16).padStart(64, "0")}`; }
function hexQuantity(value) { return `0x${Number(value).toString(16)}`; }
async function writeJson(path, value) {
await writeFile(path, `${JSON.stringify(value, null, 2)}\n`);
}
async function readJson(path) {
return JSON.parse(await readFile(path, "utf8"));
}
+636
View File
@@ -0,0 +1,636 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd -P)
PROCESS_LIB="$ROOT/tools/process-lib.sh"
RESET_SCRIPT="$ROOT/tools/reset-local.sh"
TEST_ROOT=$(mktemp -d /tmp/uups-bank-process-test.XXXXXX)
PATH="$TEST_ROOT/bin:$PATH"
export DEMO_TERM_WAIT_ATTEMPTS=5
export DEMO_KILL_WAIT_ATTEMPTS=10
export DEMO_ANCHOR_RECHECK_ATTEMPTS=10
export DEMO_ANCHOR_RECHECK_INTERVAL=0.01
ANVIL_DEV_PHRASE='test test test test test test test test test test test junk'
ANVIL_EXACT_ARGS=(--host 127.0.0.1 --port 8545 --chain-id 31337 --mnemonic "$ANVIL_DEV_PHRASE")
declare -a TEST_IDENTITIES=()
PASSED=0
STARTED_PID=
cleanup() {
local identity root relative
for identity in "${TEST_IDENTITIES[@]}"; do test_safe_stop "$identity"; done
for root in absent stale nonnumeric wrong-command false-anvil false-vite exact-anvil raw-capture reused atomic matching group term-refusal anchor-race mutated partial local-reset local-noop base-canonical base-active base-upgrade uncertain-upgrade sentinel; do
for relative in \
.demo/anvil.pid .demo/anvil.start .demo/anvil.pgid .demo/anvil.log \
.demo/vite.pid .demo/vite.start .demo/vite.pgid .demo/vite.log \
.demo/sentinel .demo/adjacent.keep tools/process-lib.sh tools/reset-local.sh \
deployments/pending.json deployments/upgrade-pending.json deployments/anvil.json deployments/active.json deployments/base-sepolia.json \
web/public/deployment.json web/src/generated/contracts.ts web/node_modules/.bin/vite; do
rm -f -- "$TEST_ROOT/$root/$relative"
done
rmdir -- "$TEST_ROOT/$root/web/node_modules/.bin" "$TEST_ROOT/$root/web/node_modules" \
"$TEST_ROOT/$root/web/src/generated" "$TEST_ROOT/$root/web/src" "$TEST_ROOT/$root/web/public" \
"$TEST_ROOT/$root/web" "$TEST_ROOT/$root/deployments" "$TEST_ROOT/$root/tools" "$TEST_ROOT/$root/.demo" \
"$TEST_ROOT/$root" 2>/dev/null || true
done
rm -f -- "$TEST_ROOT/bin/anvil" "$TEST_ROOT/bin/vite" "$TEST_ROOT/bin/leaderless" \
"$TEST_ROOT/vite-child.pid" "$TEST_ROOT/term-refusal-child.pid" "$TEST_ROOT/anchor-race-child.pid" \
"$TEST_ROOT/leaderless-child.pid" "$TEST_ROOT/vanished-signal.err" "$TEST_ROOT/unsignaled-signal.err" \
"$TEST_ROOT/term-refusal.err"
rmdir -- "$TEST_ROOT/bin" "$TEST_ROOT" 2>/dev/null || true
}
trap cleanup EXIT
fail() { printf 'FAIL: %s\n' "$*" >&2; exit 1; }
pass() { PASSED=$((PASSED + 1)); printf 'ok %d - %s\n' "$PASSED" "$1"; }
assert_exists() { [[ -e "$1" ]] || fail "expected $1 to exist"; }
assert_absent() { [[ ! -e "$1" ]] || fail "expected $1 to be absent"; }
assert_dead() {
local state start pgid sid
! test_process_identity "$1" state start pgid sid || [[ "$state" == Z ]] || fail "expected PID $1 to be stopped"
}
assert_alive() {
local state start pgid sid
test_process_identity "$1" state start pgid sid && [[ "$state" != Z ]] || fail "expected PID $1 to remain alive"
}
test_process_identity() {
local pid=$1 state_name=$2 start_name=$3 pgid_name=$4 sid_name=$5 stat rest
local -a fields
{ IFS= read -r stat <"/proc/$pid/stat"; } 2>/dev/null || return 1
rest=${stat##*) }
read -r -a fields <<<"$rest"
printf -v "$state_name" '%s' "${fields[0]}"
printf -v "$pgid_name" '%s' "${fields[2]}"
printf -v "$sid_name" '%s' "${fields[3]}"
printf -v "$start_name" '%s' "${fields[19]}"
}
track_process() {
local pid=$1 state start pgid sid
for _ in {1..50}; do
if test_process_identity "$pid" state start pgid sid && [[ "$pid" == "$pgid" && "$pid" == "$sid" ]]; then
TEST_IDENTITIES+=("$pid:$start:$pgid:$sid")
return 0
fi
sleep 0.02
done
fail "could not capture test process identity for PID $pid"
}
test_safe_stop() {
local identity=$1 leader expected_start expected_pgid expected_sid state start pgid sid signal line member member_start
local current_state current_start current_pgid current_sid
IFS=: read -r leader expected_start expected_pgid expected_sid <<<"$identity"
if test_process_identity "$leader" state start pgid sid \
&& [[ "$start" != "$expected_start" || "$pgid" != "$expected_pgid" || "$sid" != "$expected_sid" ]]; then return 0; fi
for signal in TERM KILL; do
while read -r member pgid sid state; do
[[ "$pgid" == "$expected_pgid" && "$sid" == "$expected_sid" && "$state" != Z ]] || continue
test_process_identity "$member" state member_start pgid sid || continue
[[ "$pgid" == "$expected_pgid" && "$sid" == "$expected_sid" ]] || continue
if test_process_identity "$leader" current_state current_start current_pgid current_sid; then
[[ "$current_start" == "$expected_start" && "$current_pgid" == "$expected_pgid" \
&& "$current_sid" == "$expected_sid" ]] || continue
fi
test_process_identity "$member" current_state current_start current_pgid current_sid || continue
[[ "$current_state" != Z && "$current_start" == "$member_start" \
&& "$current_pgid" == "$expected_pgid" && "$current_sid" == "$expected_sid" ]] || continue
kill -"$signal" -- "$member" 2>/dev/null || true
done < <(ps -eo pid=,pgid=,sid=,stat=)
for _ in {1..20}; do
line=$(ps -eo pgid=,sid=,stat= | awk -v p="$expected_pgid" -v s="$expected_sid" '$1 == p && $2 == s && $3 !~ /^Z/ { print; exit }')
[[ -z "$line" ]] && break
sleep 0.02
done
done
wait "$leader" 2>/dev/null || true
}
# RED gate: these are the missing production interfaces this suite specifies.
[[ -f "$PROCESS_LIB" ]] || fail "missing process library: $PROCESS_LIB"
[[ -x "$RESET_SCRIPT" ]] || fail "missing executable reset script: $RESET_SCRIPT"
# shellcheck source=process-lib.sh
source "$PROCESS_LIB"
make_root() {
local root="$TEST_ROOT/$1"
mkdir -p "$root/.demo" "$root/tools" "$root/deployments" "$root/web/public" "$root/web/src/generated"
cp "$PROCESS_LIB" "$RESET_SCRIPT" "$root/tools/"
chmod +x "$root/tools/reset-local.sh"
printf '%s\n' "$root"
}
write_record() {
local root=$1 kind=$2 pid=$3 start=$4 pgid=$5
printf '%s\n' "$pid" >"$root/.demo/$kind.pid"
printf '%s\n' "$start" >"$root/.demo/$kind.start"
printf '%s\n' "$pgid" >"$root/.demo/$kind.pgid"
}
start_tick() {
local stat rest
local -a fields
stat=$(<"/proc/$1/stat")
rest=${stat##*) }
read -r -a fields <<<"$rest"
printf '%s\n' "${fields[19]}"
}
start_owned() {
local root=$1 kind=$2 helper="$TEST_ROOT/bin/$2"
mkdir -p "$TEST_ROOT/bin"
cat >"$helper" <<'HELPER'
#!/usr/bin/env bash
sleep 120 &
wait
HELPER
chmod +x "$helper"
if [[ "$kind" == anvil ]]; then
setsid "$helper" "${ANVIL_EXACT_ARGS[@]}" >/dev/null 2>&1 &
else
setsid "$helper" web --host 127.0.0.1 --port 5173 >/dev/null 2>&1 &
fi
local pid=$!
track_process "$pid"
for _ in {1..50}; do
if demo_record_process "$root" "$kind" "$pid"; then break; fi
sleep 0.02
done
assert_exists "$root/.demo/$kind.pid"
STARTED_PID=$pid
}
assert_anvil_args_rejected() {
local root=$1 label=$2 state start pgid sid pid
shift 2
setsid "$TEST_ROOT/bin/anvil" "$@" & pid=$!
track_process "$pid"
test_process_identity "$pid" state start pgid sid
if demo_command_matches "$root" "$pid" anvil; then fail "$label Anvil arguments were accepted"; fi
demo_stop_raw_launch "$root" anvil "$pid" "$start" "$pgid"
}
printf '1..29\n'
# Catches cleanup treating a missing record as an error or signaling an inferred PID.
root=$(make_root absent)
demo_stop_recorded "$root" anvil
pass 'an absent PID file is a no-op'
# Catches stale PID metadata accumulating or being treated as a live target.
root=$(make_root stale)
write_record "$root" anvil 999999999 1 999999999
demo_stop_recorded "$root" anvil
assert_absent "$root/.demo/anvil.pid"
assert_absent "$root/.demo/anvil.start"
assert_absent "$root/.demo/anvil.pgid"
pass 'a stale numeric PID record is removed'
# Catches unvalidated PID text reaching kill or shell option parsing.
root=$(make_root nonnumeric)
write_record "$root" anvil 'not-a-pid' 1 1
if demo_stop_recorded "$root" anvil 2>/dev/null; then fail 'nonnumeric PID was accepted'; fi
assert_exists "$root/.demo/anvil.pid"
pass 'a nonnumeric PID is rejected'
# Catches PID collision signaling an unrelated live process with the wrong command.
root=$(make_root wrong-command)
setsid /bin/sleep 120 & wrong_pid=$!
track_process "$wrong_pid"
write_record "$root" anvil "$wrong_pid" "$(start_tick "$wrong_pid")" "$wrong_pid"
if demo_stop_recorded "$root" anvil 2>/dev/null; then fail 'wrong command signature was accepted'; fi
assert_alive "$wrong_pid"
pass 'a live PID with the wrong command signature is never signaled'
# Catches accepting anvil merely because it appears as a non-executable argument.
root=$(make_root false-anvil)
setsid /bin/bash -c 'sleep 120 & wait' anvil --host 127.0.0.1 --port 8545 --chain-id 31337 & false_anvil_pid=$!
track_process "$false_anvil_pid"
write_record "$root" anvil "$false_anvil_pid" "$(start_tick "$false_anvil_pid")" "$false_anvil_pid"
if demo_stop_recorded "$root" anvil 2>/dev/null; then fail 'argument-only anvil signature was accepted'; fi
assert_alive "$false_anvil_pid"
pass 'Anvil identity requires its actual executable and argv zero'
# Catches accepting a project-local Vite-looking argument under an unrelated executable.
root=$(make_root false-vite)
mkdir -p "$root/web/node_modules/.bin"
printf '#!/usr/bin/env bash\n' >"$root/web/node_modules/.bin/vite"
chmod +x "$root/web/node_modules/.bin/vite"
setsid /bin/bash -c 'sleep 120 & wait' "$root/web/node_modules/.bin/vite" web --host 127.0.0.1 --port 5173 & false_vite_pid=$!
track_process "$false_vite_pid"
write_record "$root" vite "$false_vite_pid" "$(start_tick "$false_vite_pid")" "$false_vite_pid"
if demo_stop_recorded "$root" vite 2>/dev/null; then fail 'argument-only Vite signature was accepted'; fi
assert_alive "$false_vite_pid"
pass 'Vite identity requires the exact project-local entry and interpreter'
# Catches accepting extra or overriding Anvil arguments after a valid-looking prefix.
root=$(make_root exact-anvil)
mkdir -p "$TEST_ROOT/bin"
cat >"$TEST_ROOT/bin/anvil" <<'HELPER'
#!/usr/bin/env bash
sleep 120 &
wait
HELPER
chmod +x "$TEST_ROOT/bin/anvil"
assert_anvil_args_rejected "$root" extra "${ANVIL_EXACT_ARGS[@]}" --silent
assert_anvil_args_rejected "$root" duplicate "${ANVIL_EXACT_ARGS[@]}" --port 9999
assert_anvil_args_rejected "$root" reordered \
--port 8545 --host 127.0.0.1 --chain-id 31337 --mnemonic "$ANVIL_DEV_PHRASE"
pass 'Anvil identity requires the exact complete launch argument tail'
# Catches treating one transient cmdline/exe read failure as a permanent identity mismatch.
setsid /bin/sleep 120 & recovered_anchor_pid=$!
track_process "$recovered_anchor_pid"
recovered_anchor_state='' recovered_anchor_start='' recovered_anchor_pgid='' recovered_anchor_sid=''
test_process_identity "$recovered_anchor_pid" recovered_anchor_state recovered_anchor_start \
recovered_anchor_pgid recovered_anchor_sid
[[ "$recovered_anchor_state" != Z && "$recovered_anchor_sid" == "$recovered_anchor_pid" ]] \
|| fail 'recovering anchor fixture was not a live session leader'
if (
anchor_command_attempt=0
demo_command_matches() {
anchor_command_attempt=$((anchor_command_attempt + 1))
((anchor_command_attempt > 1)) && return 0
return 2
}
demo_anchor_allows_signal "$ROOT" anvil "$recovered_anchor_pid" "$recovered_anchor_start" \
"$recovered_anchor_pgid" validated
); then
recovered_anchor_result=0
else
recovered_anchor_result=$?
fi
((recovered_anchor_result == 0)) || fail 'a transient anchor command-read failure was not retried'
assert_alive "$recovered_anchor_pid"
pass 'a transient anchor command-read failure recovers through exact revalidation'
# Catches failing shutdown when an unverifiable leader vanishes during bounded observation.
setsid /bin/sleep 120 & vanishing_anchor_pid=$!
track_process "$vanishing_anchor_pid"
vanishing_anchor_state='' vanishing_anchor_start='' vanishing_anchor_pgid='' vanishing_anchor_sid=''
test_process_identity "$vanishing_anchor_pid" vanishing_anchor_state vanishing_anchor_start \
vanishing_anchor_pgid vanishing_anchor_sid
[[ "$vanishing_anchor_state" != Z && "$vanishing_anchor_sid" == "$vanishing_anchor_pid" ]] \
|| fail 'vanishing anchor fixture was not a live session leader'
(
sleep 0.03
builtin kill -TERM -- "$vanishing_anchor_pid" 2>/dev/null || true
) & vanishing_anchor_killer=$!
if (
demo_command_matches() { return 2; }
demo_anchor_allows_signal "$ROOT" anvil "$vanishing_anchor_pid" "$vanishing_anchor_start" \
"$vanishing_anchor_pgid" validated
); then
vanishing_anchor_result=0
else
vanishing_anchor_result=$?
fi
wait "$vanishing_anchor_killer"
wait "$vanishing_anchor_pid" 2>/dev/null || true
((vanishing_anchor_result == 0)) || fail 'an exiting unverifiable anchor made shutdown fatal'
pass 'an unverifiable anchor is accepted only after it vanishes or becomes a zombie'
# Catches accepting a persistent live anchor merely because cmdline/exe cannot be read.
setsid /bin/sleep 120 & unreadable_anchor_pid=$!
track_process "$unreadable_anchor_pid"
unreadable_anchor_state='' unreadable_anchor_start='' unreadable_anchor_pgid='' unreadable_anchor_sid=''
test_process_identity "$unreadable_anchor_pid" unreadable_anchor_state unreadable_anchor_start \
unreadable_anchor_pgid unreadable_anchor_sid
[[ "$unreadable_anchor_state" != Z && "$unreadable_anchor_sid" == "$unreadable_anchor_pid" ]] \
|| fail 'unreadable anchor fixture was not a live session leader'
if (
demo_process_has_command_line() { return 1; }
demo_command_matches() { return 2; }
demo_anchor_allows_signal "$ROOT" anvil "$unreadable_anchor_pid" "$unreadable_anchor_start" \
"$unreadable_anchor_pgid" validated
); then
unreadable_anchor_result=0
else
unreadable_anchor_result=$?
fi
((unreadable_anchor_result != 0)) || fail 'a stable live anchor with unreadable command identity was accepted'
assert_alive "$unreadable_anchor_pid"
pass 'a stable live anchor remains refused when command identity is unverifiable'
# Catches treating an ESRCH-like signal race as fatal after the captured member has exited.
setsid /bin/bash -c 'trap "exit 0" TERM; while :; do sleep 0.01; done' & vanished_pid=$!
track_process "$vanished_pid"
vanished_state='' vanished_start='' vanished_pgid='' vanished_sid=''
test_process_identity "$vanished_pid" vanished_state vanished_start vanished_pgid vanished_sid
[[ "$vanished_state" != Z ]] || fail 'vanishing race fixture exited before injection'
kill() {
builtin kill -TERM -- "$vanished_pid" 2>/dev/null || true
for _ in {1..50}; do
vanished_state_after=''
if ! test_process_identity "$vanished_pid" vanished_state_after vanished_start_after vanished_pgid_after vanished_sid_after \
|| [[ "$vanished_state_after" == Z ]]; then break; fi
sleep 0.01
done
return 1
}
if demo_signal_member "$vanished_pid" "$vanished_start" "$vanished_pgid" "$vanished_sid" TERM \
2>"$TEST_ROOT/vanished-signal.err"; then
vanished_result=0
else
vanished_result=$?
fi
unset -f kill
wait "$vanished_pid" 2>/dev/null || true
((vanished_result == 0)) || fail 'a vanished member made its raced signal fatal'
[[ ! -s "$TEST_ROOT/vanished-signal.err" ]] || fail 'a benign vanished-member signal race emitted raw stderr'
pass 'a failed signal is idempotent after the captured member vanishes'
# Catches swallowing a real signal failure while the exact captured member remains live.
setsid /bin/sleep 120 & unsignaled_pid=$!
track_process "$unsignaled_pid"
unsignaled_state='' unsignaled_start='' unsignaled_pgid='' unsignaled_sid=''
test_process_identity "$unsignaled_pid" unsignaled_state unsignaled_start unsignaled_pgid unsignaled_sid
[[ "$unsignaled_state" != Z ]] || fail 'unchanged signal-failure fixture exited early'
kill() { return 1; }
if demo_signal_member "$unsignaled_pid" "$unsignaled_start" "$unsignaled_pgid" "$unsignaled_sid" TERM \
2>"$TEST_ROOT/unsignaled-signal.err"; then
unsignaled_result=0
else
unsignaled_result=$?
fi
unset -f kill
((unsignaled_result != 0)) || fail 'a failed signal to the unchanged live member was accepted'
[[ $(<"$TEST_ROOT/unsignaled-signal.err") == \
"Failed to signal unchanged live PID $unsignaled_pid with TERM" ]] \
|| fail 'an unchanged-live signal failure lacked its controlled diagnostic'
assert_alive "$unsignaled_pid"
pass 'a failed signal remains fatal for the same live member tuple'
# Catches command validation failure occurring before launch ownership is retained for cleanup.
root=$(make_root raw-capture)
setsid /bin/bash -c 'sleep 120 & wait' unexpected-entry --not-the-demo & raw_pid=$!
track_process "$raw_pid"
raw_start='' raw_pgid=''
declare -F demo_capture_raw_launch >/dev/null || fail 'missing raw launch identity capture'
declare -F demo_stop_raw_launch >/dev/null || fail 'missing command-independent raw launch cleanup'
for _ in {1..50}; do
if demo_capture_raw_launch "$raw_pid" raw_start raw_pgid; then break; fi
sleep 0.02
done
[[ -n ${raw_start:-} && "$raw_pgid" == "$raw_pid" ]] || fail 'raw launch identity was not captured'
if demo_command_matches "$root" "$raw_pid" anvil; then fail 'raw test command unexpectedly passed Anvil validation'; fi
demo_stop_raw_launch "$root" anvil "$raw_pid" "$raw_start" "$raw_pgid"
assert_dead "$raw_pid"
pass 'raw launch ownership safely cleans a command-validation failure'
# Catches recycled PID ownership being inferred from PID and command alone.
root=$(make_root reused)
start_owned "$root" anvil
reused_pid=$STARTED_PID
printf '%s\n' "$(( $(start_tick "$reused_pid") + 1 ))" >"$root/.demo/anvil.start"
if demo_stop_recorded "$root" anvil 2>/dev/null; then fail 'mismatched start tick was accepted'; fi
assert_alive "$reused_pid"
pass 'PID reuse is rejected by recorded process start tick'
# Catches publishing the PID before the complete identity has an atomic commit marker.
root=$(make_root atomic)
start_owned "$root" anvil
[[ $(stat -c '%i' "$root/.demo/anvil.pid") == $(stat -c '%i' "$root/.demo/anvil.pgid") ]] || fail 'PID is not an atomic hard-link commit marker'
demo_stop_recorded "$root" anvil
pass 'the PID commit marker is atomically linked only after start and group metadata'
# Catches a validated project child not being terminated and reaped.
root=$(make_root matching)
start_owned "$root" anvil
matching_pid=$STARTED_PID
demo_stop_recorded "$root" anvil
wait "$matching_pid" 2>/dev/null || true
assert_dead "$matching_pid"
assert_absent "$root/.demo/anvil.pid"
pass 'a matching project-started child is terminated and reaped'
# Catches treating leader exit as group exit and orphaning a TERM-resistant Vite child.
root=$(make_root group)
mkdir -p "$root/web/node_modules/.bin"
group_helper="$root/web/node_modules/.bin/vite"
child_file="$TEST_ROOT/vite-child.pid"
cat >"$group_helper" <<'HELPER'
#!/usr/bin/env bash
bash -c 'trap "" TERM; exec sleep 120' &
printf '%s\n' "$!" >"$DEMO_CHILD_PID_FILE"
wait
HELPER
chmod +x "$group_helper"
DEMO_CHILD_PID_FILE="$child_file" setsid "$group_helper" web --host 127.0.0.1 --port 5173 & group_pid=$!
track_process "$group_pid"
for _ in {1..50}; do [[ -s "$child_file" ]] && break; sleep 0.02; done
[[ -s "$child_file" ]] || fail 'Vite helper child did not start'
child_pid=$(<"$child_file")
setsid /bin/sleep 120 & unrelated_pid=$!
track_process "$unrelated_pid"
write_record "$root" vite "$group_pid" "$(start_tick "$group_pid")" "$group_pid"
demo_stop_recorded "$root" vite
wait "$group_pid" 2>/dev/null || true
for _ in {1..50}; do ! kill -0 "$child_pid" 2>/dev/null && break; sleep 0.02; done
assert_dead "$child_pid"
assert_alive "$unrelated_pid"
pass 'bounded KILL removes a TERM-resistant owned child while an unrelated group survives'
# Catches a silent TERM-loop refusal after exact command identity definitively changes.
root=$(make_root term-refusal)
term_refusal_helper="$TEST_ROOT/bin/anvil"
term_refusal_child_file="$TEST_ROOT/term-refusal-child.pid"
cat >"$term_refusal_helper" <<'HELPER'
#!/usr/bin/env bash
sleep 120 &
printf '%s\n' "$!" >"$DEMO_CHILD_PID_FILE"
wait
sleep 120
HELPER
chmod +x "$term_refusal_helper"
DEMO_CHILD_PID_FILE="$term_refusal_child_file" setsid "$term_refusal_helper" "${ANVIL_EXACT_ARGS[@]}" \
& term_refusal_pid=$!
track_process "$term_refusal_pid"
for _ in {1..50}; do [[ -s "$term_refusal_child_file" ]] && break; sleep 0.02; done
[[ -s "$term_refusal_child_file" ]] || fail 'TERM-refusal helper child did not start'
term_refusal_start=$(start_tick "$term_refusal_pid")
if (
anchor_command_attempt=0
demo_command_matches() {
anchor_command_attempt=$((anchor_command_attempt + 1))
((anchor_command_attempt <= 2)) && return 0
return 1
}
demo_stop_launch "$root" anvil "$term_refusal_pid" "$term_refusal_start" "$term_refusal_pid"
) 2>"$TEST_ROOT/term-refusal.err"; then
term_refusal_result=0
else
term_refusal_result=$?
fi
((term_refusal_result != 0)) || fail 'a definitive TERM-loop anchor mismatch was accepted'
[[ $(<"$TEST_ROOT/term-refusal.err") == \
"Refusing TERM: anvil leader identity changed or remained unverifiable" ]] \
|| fail 'a TERM-loop anchor refusal lacked its controlled diagnostic'
assert_alive "$term_refusal_pid"
pass 'a definitive TERM-loop anchor mismatch is refused with a controlled diagnostic'
# Catches the child-exit/leader-exit race becoming fatal during transient command-read loss.
root=$(make_root anchor-race)
anchor_race_helper="$TEST_ROOT/bin/anvil"
anchor_race_child_file="$TEST_ROOT/anchor-race-child.pid"
cat >"$anchor_race_helper" <<'HELPER'
#!/usr/bin/env bash
sleep 120 &
printf '%s\n' "$!" >"$DEMO_CHILD_PID_FILE"
wait
sleep 0.05
HELPER
chmod +x "$anchor_race_helper"
DEMO_CHILD_PID_FILE="$anchor_race_child_file" setsid "$anchor_race_helper" "${ANVIL_EXACT_ARGS[@]}" \
& anchor_race_pid=$!
track_process "$anchor_race_pid"
for _ in {1..50}; do [[ -s "$anchor_race_child_file" ]] && break; sleep 0.02; done
[[ -s "$anchor_race_child_file" ]] || fail 'anchor-race helper child did not start'
anchor_race_start=$(start_tick "$anchor_race_pid")
if (
anchor_command_attempt=0
demo_command_matches() {
anchor_command_attempt=$((anchor_command_attempt + 1))
((anchor_command_attempt <= 2)) && return 0
return 2
}
demo_stop_launch "$root" anvil "$anchor_race_pid" "$anchor_race_start" "$anchor_race_pid"
); then
anchor_race_result=0
else
anchor_race_result=$?
fi
wait "$anchor_race_pid" 2>/dev/null || true
((anchor_race_result == 0)) || fail 'child exit made the concurrently exiting leader fatal'
assert_dead "$anchor_race_pid"
pass 'child-exit cleanup tolerates transient identity loss while the leader exits'
# Catches escalating after the recorded leader changes to a different command identity.
root=$(make_root mutated)
mutating_helper="$TEST_ROOT/bin/anvil"
cat >"$mutating_helper" <<'HELPER'
#!/usr/bin/env bash
trap 'exec -a changed-after-term /bin/sleep 120' TERM
while :; do sleep 0.05; done
HELPER
chmod +x "$mutating_helper"
setsid "$mutating_helper" "${ANVIL_EXACT_ARGS[@]}" & mutated_pid=$!
track_process "$mutated_pid"
write_record "$root" anvil "$mutated_pid" "$(start_tick "$mutated_pid")" "$mutated_pid"
if demo_stop_recorded "$root" anvil 2>/dev/null; then fail 'changed identity was escalated instead of rejected'; fi
assert_alive "$mutated_pid"
pass 'identity is revalidated and a changed leader is never escalated'
# Catches launch-record publication failure leaving an independently known child alive.
root=$(make_root partial)
start_owned "$root" anvil
partial_pid=$STARTED_PID
partial_start=$(start_tick "$partial_pid")
rm -f -- "$root/.demo/anvil.pid" "$root/.demo/anvil.start" "$root/.demo/anvil.pgid"
printf '%s\n' "$partial_start" >"$root/.demo/anvil.start"
printf '%s\n' "$partial_pid" >"$root/.demo/anvil.pgid"
demo_stop_launch "$root" anvil "$partial_pid" "$partial_start" "$partial_pid"
assert_dead "$partial_pid"
assert_absent "$root/.demo/anvil.start"
assert_absent "$root/.demo/anvil.pgid"
pass 'in-memory launch identity safely cleans a partial unpublished record'
# Catches test cleanup leaking a TERM-resistant session member after its original leader is reaped.
leaderless_helper="$TEST_ROOT/bin/leaderless"
leaderless_child_file="$TEST_ROOT/leaderless-child.pid"
cat >"$leaderless_helper" <<'HELPER'
#!/usr/bin/env bash
bash -c 'trap "" TERM; exec sleep 120' &
printf '%s\n' "$!" >"$DEMO_CHILD_PID_FILE"
sleep 1
HELPER
chmod +x "$leaderless_helper"
DEMO_CHILD_PID_FILE="$leaderless_child_file" setsid "$leaderless_helper" & leaderless_pid=$!
track_process "$leaderless_pid"
leaderless_identity=${TEST_IDENTITIES[${#TEST_IDENTITIES[@]}-1]}
for _ in {1..50}; do [[ -s "$leaderless_child_file" ]] && break; sleep 0.02; done
[[ -s "$leaderless_child_file" ]] || fail 'leaderless cleanup child did not start'
leaderless_child=$(<"$leaderless_child_file")
child_state='' child_start='' child_pgid='' child_sid=''
test_process_identity "$leaderless_child" child_state child_start child_pgid child_sid
[[ "$child_state" != Z ]] || fail 'leaderless cleanup child exited early'
wait "$leaderless_pid"
test_safe_stop "$leaderless_identity"
if test_process_identity "$leaderless_child" current_state current_start current_pgid current_sid \
&& [[ "$current_state" != Z ]]; then
if [[ "$current_start" == "$child_start" && "$current_pgid" == "$child_pgid" && "$current_sid" == "$child_sid" ]]; then
builtin kill -KILL -- "$leaderless_child" 2>/dev/null || true
fi
fail 'test cleanup leaked a session member after the leader was reaped'
fi
pass 'test cleanup safely stops surviving members after leader reaping'
# Catches reset deleting arbitrary neighbors or leaving known reproducible local artifacts.
root=$(make_root local-reset)
for kind in anvil vite; do printf 'log\n' >"$root/.demo/$kind.log"; printf '1\n' >"$root/.demo/$kind.start"; printf '1\n' >"$root/.demo/$kind.pgid"; done
printf 'sentinel\n' >"$root/.demo/sentinel"
for path in deployments/pending.json deployments/anvil.json deployments/active.json web/public/deployment.json; do
printf '{"network":"anvil","chainId":31337}\n' >"$root/$path"
done
printf '%s\n' '{"mode":"upgrade","network":"anvil","chainId":31337,"token":"0x1111111111111111111111111111111111111111","proxy":"0x2222222222222222222222222222222222222222","previousImplementation":"0x3333333333333333333333333333333333333333","implementation":"0x4444444444444444444444444444444444444444","owner":"0x5555555555555555555555555555555555555555","deploymentBlock":1,"snapshot":{"proxy":"0x2222222222222222222222222222222222222222","implementation":"0x3333333333333333333333333333333333333333","owner":"0x5555555555555555555555555555555555555555","asset":"0x1111111111111111111111111111111111111111","paused":false,"balances":[{"address":"0x5555555555555555555555555555555555555555","balance":0}],"liabilities":0,"reserves":0,"surplus":0,"deploymentBlock":1,"version":1}}' >"$root/deployments/upgrade-pending.json"
printf 'generated ABI\n' >"$root/web/src/generated/contracts.ts"
(cd "$root" && bash tools/reset-local.sh >/dev/null)
for path in .demo/anvil.pid .demo/anvil.start .demo/anvil.pgid .demo/anvil.log .demo/vite.pid .demo/vite.start .demo/vite.pgid .demo/vite.log deployments/pending.json deployments/upgrade-pending.json deployments/anvil.json deployments/active.json web/public/deployment.json web/src/generated/contracts.ts; do
assert_absent "$root/$path"
done
assert_exists "$root/.demo/sentinel"
pass 'reset removes only explicitly known local runtime and generated files'
# Catches reset leaving a strictly validated local no-op upgrade marker behind.
root=$(make_root local-noop)
printf '%s\n' '{"mode":"noop","chainId":31337,"observedBlock":9,"ownerNonce":3,"proxy":"0x2222222222222222222222222222222222222222","implementation":"0x4444444444444444444444444444444444444444"}' >"$root/deployments/upgrade-pending.json"
(cd "$root" && bash tools/reset-local.sh >/dev/null)
assert_absent "$root/deployments/upgrade-pending.json"
pass 'reset removes a strictly validated local no-op upgrade marker'
# Catches a local reset corrupting a canonical Base Sepolia deployment.
root=$(make_root base-canonical)
printf '{"network":"baseSepolia","chainId":84532,"marker":"canonical"}\n' >"$root/deployments/base-sepolia.json"
before=$(sha256sum "$root/deployments/base-sepolia.json")
(cd "$root" && bash tools/reset-local.sh >/dev/null)
after=$(sha256sum "$root/deployments/base-sepolia.json")
[[ "$before" == "$after" ]] || fail 'Base canonical manifest changed'
pass 'a Base canonical manifest survives reset byte-for-byte'
# Catches a local reset deleting or rewriting selected and browser-copied Base state.
root=$(make_root base-active)
base='{"network":"baseSepolia","chainId":84532,"marker":"active"}'
printf '%s\n' "$base" >"$root/deployments/active.json"
printf '%s\n' "$base" >"$root/web/public/deployment.json"
active_before=$(sha256sum "$root/deployments/active.json")
browser_before=$(sha256sum "$root/web/public/deployment.json")
(cd "$root" && bash tools/reset-local.sh >/dev/null)
[[ "$active_before" == "$(sha256sum "$root/deployments/active.json")" ]] || fail 'Base active manifest changed'
[[ "$browser_before" == "$(sha256sum "$root/web/public/deployment.json")" ]] || fail 'Base browser manifest changed'
pass 'Base active and browser manifests survive reset byte-for-byte'
# Catches reset deleting a valid Base-shaped upgrade marker that may require public recovery.
root=$(make_root base-upgrade)
printf '%s\n' '{"mode":"upgrade","network":"baseSepolia","chainId":84532,"token":"0x1111111111111111111111111111111111111111","proxy":"0x2222222222222222222222222222222222222222","previousImplementation":"0x3333333333333333333333333333333333333333","implementation":"0x4444444444444444444444444444444444444444","owner":"0x5555555555555555555555555555555555555555","deploymentBlock":1,"snapshot":{"proxy":"0x2222222222222222222222222222222222222222","implementation":"0x3333333333333333333333333333333333333333","owner":"0x5555555555555555555555555555555555555555","asset":"0x1111111111111111111111111111111111111111","paused":false,"balances":[{"address":"0x5555555555555555555555555555555555555555","balance":0}],"liabilities":0,"reserves":0,"surplus":0,"deploymentBlock":1,"version":1}}' >"$root/deployments/upgrade-pending.json"
base_upgrade_before=$(sha256sum "$root/deployments/upgrade-pending.json")
(cd "$root" && bash tools/reset-local.sh >/dev/null)
[[ "$base_upgrade_before" == "$(sha256sum "$root/deployments/upgrade-pending.json")" ]] || fail 'Base upgrade marker changed'
pass 'a Base-shaped upgrade marker survives reset byte-for-byte'
# Catches reset guessing that malformed or incomplete upgrade staging is safe to delete.
root=$(make_root uncertain-upgrade)
printf '%s\n' '{"mode":"upgrade","chainId":31337}' >"$root/deployments/upgrade-pending.json"
uncertain_before=$(sha256sum "$root/deployments/upgrade-pending.json")
(cd "$root" && bash tools/reset-local.sh >/dev/null)
[[ "$uncertain_before" == "$(sha256sum "$root/deployments/upgrade-pending.json")" ]] || fail 'uncertain upgrade marker changed'
pass 'a malformed or uncertain upgrade marker survives reset byte-for-byte'
# Catches cleanup broadening from exact files to recursive .demo deletion.
root=$(make_root sentinel)
printf 'keep me\n' >"$root/.demo/adjacent.keep"
(cd "$root" && bash tools/reset-local.sh >/dev/null)
assert_exists "$root/.demo/adjacent.keep"
pass 'a sentinel adjacent to runtime records survives'
printf 'PASS: %d process-safety cases\n' "$PASSED"
+38
View File
@@ -0,0 +1,38 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd -P)
TEST_ROOT=$(mktemp -d /tmp/uups-bank-scan-test.XXXXXX)
fixture_name='ANVIL_''TEST_PHRASE'
fixture_value='test test test test test test test test test test test junk'
cleanup() {
rm -f -- "$TEST_ROOT/script/lib/DemoScript.sol" "$TEST_ROOT/tools/scan-project.sh" "$TEST_ROOT/duplicate.sh" \
"$TEST_ROOT/.git/index" "$TEST_ROOT/.git/HEAD" "$TEST_ROOT/.git/config" "$TEST_ROOT/.git/description" "$TEST_ROOT/.git/info/exclude"
rmdir -- "$TEST_ROOT/.git/objects/pack" "$TEST_ROOT/.git/objects/info" "$TEST_ROOT/.git/objects" \
"$TEST_ROOT/.git/refs/tags" "$TEST_ROOT/.git/refs/heads" "$TEST_ROOT/.git/refs" \
"$TEST_ROOT/.git/branches" "$TEST_ROOT/.git/hooks" "$TEST_ROOT/.git/info" "$TEST_ROOT/.git" \
"$TEST_ROOT/script/lib" "$TEST_ROOT/script" "$TEST_ROOT/tools" "$TEST_ROOT" 2>/dev/null || true
}
trap cleanup EXIT
fail() { printf 'FAIL: %s\n' "$*" >&2; exit 1; }
mkdir -p "$TEST_ROOT/script/lib" "$TEST_ROOT/tools"
cp "$ROOT/tools/scan-project.sh" "$TEST_ROOT/tools/scan-project.sh"
git -C "$TEST_ROOT" init -q --template=
printf ' string internal constant %s = "%s";\n' "$fixture_name" "$fixture_value" >"$TEST_ROOT/script/lib/DemoScript.sol"
git -C "$TEST_ROOT" add script/lib/DemoScript.sol tools/scan-project.sh
(cd "$TEST_ROOT" && bash tools/scan-project.sh >/dev/null) || fail 'exact intended fixture assignment was rejected'
# Catches accepting the known phrase assignment in a duplicate tracked path.
printf '%s="%s"\n' "$fixture_name" "$fixture_value" >"$TEST_ROOT/duplicate.sh"
git -C "$TEST_ROOT" add duplicate.sh
if (cd "$TEST_ROOT" && bash tools/scan-project.sh >/dev/null 2>&1); then fail 'duplicate fixture assignment was accepted'; fi
git -C "$TEST_ROOT" rm -q --cached duplicate.sh
rm -f -- "$TEST_ROOT/duplicate.sh"
# Catches substring matching that accepts appended content on the intended line.
printf ' string internal constant %s = "%s"; appended\n' "$fixture_name" "$fixture_value" >"$TEST_ROOT/script/lib/DemoScript.sol"
if (cd "$TEST_ROOT" && bash tools/scan-project.sh >/dev/null 2>&1); then fail 'appended fixture assignment was accepted'; fi
printf '%s\n' 'PASS: scanner fixture exception is exact, unique, and path-anchored'
+142
View File
@@ -0,0 +1,142 @@
import assert from "node:assert/strict";
import { mkdtemp, readFile, readdir, rename, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { basename, dirname, join } from "node:path";
import { extractAbi, renderContractsModule, syncArtifacts } from "./sync-web-artifacts.mjs";
import { publishManifest } from "./publish-web-manifest.mjs";
const address = "0x5FbDB2315678afecb367f032d93F642f64180aa3";
const owner = "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266";
const bankAbi = [
{ type: "function", name: "contractVersion", inputs: [], outputs: [{ name: "", type: "uint256" }], stateMutability: "pure" },
{ type: "function", name: "paused", inputs: [], outputs: [{ name: "", type: "bool" }], stateMutability: "view" },
{ type: "function", name: "asset", inputs: [], outputs: [{ name: "", type: "address" }], stateMutability: "view" },
{ type: "function", name: "owner", inputs: [], outputs: [{ name: "", type: "address" }], stateMutability: "view" },
{ type: "function", name: "totalLiabilities", inputs: [], outputs: [{ name: "", type: "uint256" }], stateMutability: "view" },
{ type: "function", name: "balanceOf", inputs: [{ name: "account", type: "address" }], outputs: [{ name: "", type: "uint256" }], stateMutability: "view" },
{ type: "event", name: "Deposited", inputs: [{ name: "account", type: "address", indexed: true }, { name: "amount", type: "uint256", indexed: false }], anonymous: false },
{ type: "event", name: "Withdrawn", inputs: [{ name: "account", type: "address", indexed: true }, { name: "amount", type: "uint256", indexed: false }], anonymous: false },
{ type: "event", name: "Paused", inputs: [{ name: "account", type: "address", indexed: false }], anonymous: false },
{ type: "event", name: "Unpaused", inputs: [{ name: "account", type: "address", indexed: false }], anonymous: false },
{ type: "event", name: "OwnershipTransferred", inputs: [{ name: "previousOwner", type: "address", indexed: true }, { name: "newOwner", type: "address", indexed: true }], anonymous: false },
{ type: "event", name: "Upgraded", inputs: [{ name: "implementation", type: "address", indexed: true }], anonymous: false },
];
const tokenAbi = [{ type: "function", name: "balanceOf", inputs: [{ name: "account", type: "address" }], outputs: [{ name: "", type: "uint256" }], stateMutability: "view" }];
const bankV2Abi = [
...bankAbi,
{ type: "function", name: "transferBalance", inputs: [{ name: "recipient", type: "address" }, { name: "amount", type: "uint256" }], outputs: [], stateMutability: "nonpayable" },
{ type: "event", name: "BalanceTransferred", inputs: [{ name: "from", type: "address", indexed: true }, { name: "to", type: "address", indexed: true }, { name: "amount", type: "uint256", indexed: false }], anonymous: false },
];
const manifest = {
schemaVersion: 1, network: "anvil", chainId: 31337, deploymentBlock: 3,
rpcUrl: "http://127.0.0.1:8545", token: address, proxy: address,
implementation: "0xe7f1725E7734CE288F8367e1Bb143E90bb3F0512", owner,
actors: [{ label: "owner", address: owner }],
};
async function withFixture(run) {
const root = await mkdtemp(join(tmpdir(), "uups-artifacts-"));
try { await run(root); } finally { await rm(root, { recursive: true, force: true }); }
}
async function expectRejects(action, pattern) {
await assert.rejects(action, pattern);
}
await withFixture(async (root) => {
const bank = join(root, "BankV1.json");
const bankV2 = join(root, "BankV2.json");
const token = join(root, "MockUSDC.json");
const output = join(root, "contracts.ts");
// Catches a production bridge that silently produces an ABI module from incomplete artifacts.
await expectRejects(() => syncArtifacts({ bankArtifactPath: bank, bankV2ArtifactPath: bankV2, tokenArtifactPath: token, outputPath: output }), /BankV1 artifact/i);
await writeFile(bank, JSON.stringify({ abi: bankAbi }));
await writeFile(token, JSON.stringify({ abi: tokenAbi }));
await expectRejects(() => syncArtifacts({ bankArtifactPath: bank, bankV2ArtifactPath: bankV2, tokenArtifactPath: token, outputPath: output }), /BankV2 artifact/i);
await writeFile(bankV2, JSON.stringify({ abi: bankV2Abi }));
await rm(token);
await expectRejects(() => syncArtifacts({ bankArtifactPath: bank, bankV2ArtifactPath: bankV2, tokenArtifactPath: token, outputPath: output }), /MockUSDC artifact/i);
await writeFile(token, JSON.stringify({ abi: tokenAbi }));
// Catches a bridge that exports an ABI missing a V1 contract function or event.
assert.throws(() => extractAbi({ abi: bankAbi.filter((entry) => entry.name !== "Withdrawn") }, "BankV1"), /Withdrawn/);
// Catches generated calls/log decoders accepting ABI entries with the right name but wrong wire signature.
const wrongFunction = structuredClone(bankAbi);
wrongFunction.find((entry) => entry.name === "balanceOf").outputs = [];
assert.throws(() => extractAbi({ abi: wrongFunction }, "BankV1"), /signature.*balanceOf/i);
const wrongEvent = structuredClone(bankAbi);
wrongEvent.find((entry) => entry.name === "Deposited").inputs[0].indexed = false;
assert.throws(() => extractAbi({ abi: wrongEvent }, "BankV1"), /signature.*Deposited/i);
assert.throws(() => extractAbi({ abi: bankV2Abi.filter((entry) => entry.name !== "transferBalance") }, "BankV2"), /transferBalance/);
assert.throws(() => extractAbi({ abi: bankV2Abi.filter((entry) => entry.name !== "BalanceTransferred") }, "BankV2"), /BalanceTransferred/);
const rendered = renderContractsModule(extractAbi({ abi: bankAbi }, "BankV1"), extractAbi({ abi: bankV2Abi }, "BankV2"), extractAbi({ abi: tokenAbi }, "MockUSDC"));
assert.match(rendered, /export const bankV1Abi = .* as const;/s);
assert.match(rendered, /export const bankV2Abi = .* as const;/s);
assert.match(rendered, /export const mockUsdcAbi = .* as const;/s);
assert.match(rendered, /transferBalance/);
assert.match(rendered, /BalanceTransferred/);
// Catches a bridge that requires an active manifest or reads V2 as part of ABI generation.
await syncArtifacts({ bankArtifactPath: bank, bankV2ArtifactPath: bankV2, tokenArtifactPath: token, outputPath: output });
assert.equal(await readFile(output, "utf8"), rendered);
await writeFile(output, "stale\n");
await expectRejects(() => syncArtifacts({ bankArtifactPath: bank, bankV2ArtifactPath: bankV2, tokenArtifactPath: token, outputPath: output, check: true }), /stale/i);
});
await withFixture(async (root) => {
const activePath = join(root, "active.json");
const outputPath = join(root, "deployment.json");
await writeFile(activePath, JSON.stringify(manifest));
// Catches a publisher that writes the browser destination directly instead of replacing a complete same-directory file.
const operations = [];
await publishManifest({
activePath,
outputPath,
io: {
writeFile: async (...args) => { operations.push(["write", args[0]]); await writeFile(...args); },
rename: async (...args) => { operations.push(["rename", ...args]); await rename(...args); },
rm,
},
});
assert.deepEqual(JSON.parse(await readFile(outputPath, "utf8")), manifest);
assert.equal(operations.length, 2);
assert.equal(operations[0][0], "write");
assert.equal(dirname(operations[0][1]), dirname(outputPath));
assert.notEqual(operations[0][1], outputPath);
assert.deepEqual(operations[1], ["rename", operations[0][1], outputPath]);
// Catches a failed replacement truncating the published manifest or leaking its staging file.
const preserved = Buffer.from("preserved browser manifest\n");
await writeFile(outputPath, preserved);
let failedStage;
await assert.rejects(
() => publishManifest({
activePath,
outputPath,
io: {
writeFile: async (...args) => { failedStage = args[0]; await writeFile(...args); },
rename: async () => { throw new Error("injected browser replacement failure"); },
rm,
},
}),
/injected browser replacement failure/,
);
assert.deepEqual(await readFile(outputPath), preserved);
assert.equal((await readdir(root)).includes(basename(failedStage)), false);
// Catches a publisher that copies pending, secret-bearing, or malformed live state into the browser bundle.
for (const invalid of [
{ ...manifest, deploymentBlock: 0 },
{ ...manifest, rpcUrl: "https://token@example.test" },
{ ...manifest, rpcUrl: "https://example.test/?secret=value" },
{ ...manifest, privateKey: "not-public" },
{ ...manifest, proxy: "0x0000000000000000000000000000000000000000" },
]) {
await writeFile(activePath, JSON.stringify(invalid));
await expectRejects(() => publishManifest({ activePath, outputPath }), /manifest|deployment|credential|secret|address/i);
}
});
console.log("artifact bridge tests passed");
+26
View File
@@ -0,0 +1,26 @@
import js from "@eslint/js";
import globals from "globals";
import reactHooks from "eslint-plugin-react-hooks";
import reactRefresh from "eslint-plugin-react-refresh";
import tseslint from "typescript-eslint";
export default tseslint.config(
{ ignores: ["dist", "coverage"] },
js.configs.recommended,
...tseslint.configs.recommended,
{
files: ["**/*.{ts,tsx}"],
languageOptions: {
ecmaVersion: 2022,
globals: { ...globals.browser, ...globals.node }
},
plugins: {
"react-hooks": reactHooks,
"react-refresh": reactRefresh
},
rules: {
...reactHooks.configs.recommended.rules,
"react-refresh/only-export-components": ["warn", { allowConstantExport: true }]
}
}
);
+12
View File
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>UUPS Bank Operations Console</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+4531
View File
File diff suppressed because it is too large Load Diff
+41
View File
@@ -0,0 +1,41 @@
{
"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": "node ./node_modules/@typescript/native/bin/tsc -b --pretty false",
"test": "vitest run",
"build": "node ./node_modules/@typescript/native/bin/tsc -b && vite build"
},
"dependencies": {
"@tanstack/react-query": "5.101.4",
"react": "19.2.8",
"react-dom": "19.2.8",
"viem": "2.55.8",
"wagmi": "3.7.5"
},
"devDependencies": {
"@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",
"vitest": "4.1.10"
}
}
+1
View File
@@ -0,0 +1 @@
+232
View File
@@ -0,0 +1,232 @@
import { render, screen } from "@testing-library/react";
import type { Address, Hex } from "viem";
import { describe, expect, it } from "vitest";
import shellHtml from "../index.html?raw";
import { App } from "./App";
import type { DashboardState } from "./hooks/useBankDashboard";
import type { DashboardSnapshot, DeploymentManifest } from "./types/dashboard";
const address = (digit: string) => `0x${digit.repeat(40)}` as Address;
const transactionHash = (digit: string) => `0x${digit.repeat(64)}` as Hex;
const localManifest: DeploymentManifest = {
schemaVersion: 1,
network: "anvil",
chainId: 31337,
deploymentBlock: 3n,
rpcUrl: "http://127.0.0.1:8545",
token: address("1"),
proxy: address("2"),
implementation: address("3"),
owner: address("4"),
actors: [
{ label: "Alice", address: address("5") },
{ label: "Bob", address: address("6") },
],
};
const snapshot: DashboardSnapshot = {
blockNumber: 18n,
synchronizedAt: new Date("2026-08-21T10:00:00.000Z"),
version: 1,
paused: true,
asset: localManifest.token,
owner: localManifest.owner,
proxy: localManifest.proxy,
implementation: localManifest.implementation,
reserves: 1_500_000_000n,
liabilities: 1_400_000_000n,
surplus: 100_000_000n,
actors: [
{ label: "Alice", address: address("5"), balance: 900_000_000n },
{ label: "Bob", address: address("6"), balance: 500_000_000n },
],
activity: [
{ kind: "deposit", account: address("5"), amount: 1_000_000_000n, blockNumber: 4n, logIndex: 0, transactionHash: transactionHash("a") },
{ kind: "withdrawal", account: address("5"), amount: 100_000_000n, blockNumber: 5n, logIndex: 0, transactionHash: transactionHash("b") },
{ kind: "paused", account: address("4"), blockNumber: 6n, logIndex: 0, transactionHash: transactionHash("c") },
{ kind: "ownershipTransferred", previousOwner: address("7"), newOwner: address("4"), blockNumber: 3n, logIndex: 1, transactionHash: transactionHash("d") },
{ kind: "upgraded", implementation: address("3"), blockNumber: 3n, logIndex: 0, transactionHash: transactionHash("d") },
],
diagnostics: [{ blockNumber: 7n, logIndex: 2, transactionHash: transactionHash("e"), message: "Could not decode a proxy event log." }],
};
function ready(manifest = localManifest, value = snapshot): DashboardState {
return { status: "ready", manifest, snapshot: value };
}
describe("read-only operations console", () => {
it("loads the console entry point from the browser document", () => {
const document = new DOMParser().parseFromString(shellHtml, "text/html");
expect(document.querySelector('script[type="module"]')?.getAttribute("src")).toBe("/src/main.tsx");
});
it("keeps the exact educational funds warning and complete trust disclosure visible", () => {
render(<App dashboard={ready()} />);
expect(screen.getByText("Educational demo — mock token — never use real funds.")).toBeTruthy();
expect(screen.getByText(/educational and unaudited/i)).toBeTruthy();
expect(screen.getByText(/owner can pause customer actions and install arbitrary future logic/i)).toBeTruthy();
expect(screen.getByText(/UUPS mistakes can corrupt state or permanently brick upgradeability/i)).toBeTruthy();
expect(screen.getByText(/professional audits, operational key controls, multisig or timelocked governance, incident procedures, legal advice, and jurisdiction-specific compliance/i)).toBeTruthy();
});
it("renders network, synchronization, lifecycle, pause, and V1 status", () => {
render(<App dashboard={ready()} />);
expect(screen.getByText("Local Anvil")).toBeTruthy();
expect(screen.getByText("Ready")).toBeTruthy();
expect(screen.getByText("Block 18")).toBeTruthy();
expect(screen.getByText("Paused")).toBeTruthy();
expect(screen.getByText("Version 1")).toBeTruthy();
expect(screen.getByText(/Aug 21, 2026/)).toBeTruthy();
});
it("renders accounting, contract identity, and local tracked accounts", () => {
render(<App dashboard={ready()} />);
expect(screen.getByText("1,500.00 mUSDC")).toBeTruthy();
expect(screen.getByText("1,400.00 mUSDC")).toBeTruthy();
expect(screen.getByText("100.00 mUSDC")).toBeTruthy();
expect(screen.getByText("107.14%")).toBeTruthy();
expect(screen.getByText("Proxy")).toBeTruthy();
expect(screen.getByText("Implementation")).toBeTruthy();
expect(screen.getByText("Token")).toBeTruthy();
expect(screen.getByText("Owner")).toBeTruthy();
expect(screen.getByText("Deployment block")).toBeTruthy();
expect(screen.getByText("Alice")).toBeTruthy();
expect(screen.getByText("Bob")).toBeTruthy();
expect(screen.getByText("900.00 mUSDC")).toBeTruthy();
expect(screen.getByText("500.00 mUSDC")).toBeTruthy();
});
it("makes every learning abbreviation keyboard-focusable with its explanation intact", () => {
const { container } = render(<App dashboard={ready()} />);
const abbreviations = Array.from(container.querySelectorAll("abbr"));
expect(abbreviations).toHaveLength(5);
for (const abbreviation of abbreviations) {
expect(abbreviation.getAttribute("title")?.trim().length).toBeGreaterThan(0);
expect(abbreviation.getAttribute("tabindex")).toBe("0");
(abbreviation as HTMLElement).focus();
expect(document.activeElement).toBe(abbreviation);
}
});
it("renders valid V1 activity newest first beside an isolated decode warning", () => {
render(<App dashboard={ready()} />);
const timeline = screen.getByLabelText("Proxy activity");
expect(timeline.textContent).toContain("Deposited");
expect(timeline.textContent).toContain("Withdrawn");
expect(timeline.textContent).toContain("Paused by");
expect(timeline.textContent).toContain("Ownership transferred");
expect(timeline.textContent).toContain("Implementation upgraded");
expect(timeline.textContent).toContain("Block 7");
expect(timeline.textContent).toContain("Could not decode a proxy event log.");
expect(timeline.textContent?.indexOf("Paused by")).toBeLessThan(timeline.textContent?.indexOf("Withdrawn") ?? 0);
expect(timeline.textContent).toContain("0xcccc…cccc");
});
it("renders the V2 version and exact internal transfer activity without transaction controls", () => {
const v2Snapshot: DashboardSnapshot = {
...snapshot,
version: 2,
paused: false,
actors: [
{ label: "Alice", address: address("5"), balance: 650_000_000n },
{ label: "Bob", address: address("6"), balance: 750_000_000n },
],
activity: [{
kind: "transfer",
from: address("5"),
to: address("6"),
amount: 250_000_000n,
blockNumber: 19n,
logIndex: 0,
transactionHash: transactionHash("f"),
}],
diagnostics: [],
};
const { container } = render(<App dashboard={ready(localManifest, v2Snapshot)} />);
expect(screen.getByText("Version 2")).toBeTruthy();
expect(screen.getByText("Alice transferred 250.000000 mUSDC to Bob")).toBeTruthy();
expect(container.querySelector("button, form")).toBeNull();
});
it("uses validated Base Sepolia explorer links and public shortened account labels", () => {
const baseManifest: DeploymentManifest = {
...localManifest,
network: "baseSepolia",
chainId: 84532,
rpcUrl: "https://sepolia.base.org",
explorerBaseUrl: "https://sepolia.basescan.org",
};
render(<App dashboard={ready(baseManifest)} />);
expect(screen.getByText("Base Sepolia")).toBeTruthy();
expect(screen.queryByText("Alice")).toBeNull();
expect(screen.getAllByText("0x5555…5555").length).toBeGreaterThan(0);
const proxyLink = screen.getByRole("link", { name: /proxy.*0x2222…2222/i });
expect(proxyLink.getAttribute("href")).toBe(`https://sepolia.basescan.org/address/${snapshot.proxy}`);
const transactionLink = screen.getByRole("link", { name: /transaction 0xcccc…cccc/i });
expect(transactionLink.getAttribute("href")).toBe(`https://sepolia.basescan.org/tx/${transactionHash("c")}`);
});
it("does not create explorer links for local Anvil", () => {
render(<App dashboard={ready()} />);
expect(screen.queryAllByRole("link")).toHaveLength(0);
});
it("shows No deposits instead of dividing zero liabilities", () => {
render(<App dashboard={ready(localManifest, { ...snapshot, reserves: 0n, liabilities: 0n, surplus: 0n })} />);
expect(screen.getByText("No deposits")).toBeTruthy();
});
it("distinguishes stale and disconnected conditions without hiding the persistent warnings", () => {
const { rerender } = render(<App dashboard={{
status: "stale",
manifest: localManifest,
snapshot,
failedAt: new Date("2026-08-21T10:05:00.000Z"),
error: "RPC timeout",
}} />);
expect(screen.getByText("Stale")).toBeTruthy();
expect(screen.getByText(/RPC timeout/)).toBeTruthy();
expect(screen.getByText("1,500.00 mUSDC")).toBeTruthy();
rerender(<App dashboard={{
status: "disconnected",
manifest: localManifest,
failedAt: new Date("2026-08-21T10:06:00.000Z"),
error: "connection refused",
}} />);
expect(screen.getAllByText("Disconnected")).toHaveLength(2);
expect(screen.getByText(/connection refused/)).toBeTruthy();
expect(screen.getByText("Educational demo — mock token — never use real funds.")).toBeTruthy();
expect(screen.getByText(/educational and unaudited/i)).toBeTruthy();
});
it("renders explanatory invalid-manifest and chain-mismatch terminal screens", () => {
const { rerender } = render(<App dashboard={{ status: "invalid-manifest", error: "manifest is missing required field proxy" }} />);
expect(screen.getAllByText("Invalid manifest")).toHaveLength(2);
expect(screen.getByText(/missing required field proxy/)).toBeTruthy();
rerender(<App dashboard={{
status: "chain-mismatch",
manifest: localManifest,
error: "endpoint chain ID 84532 does not match manifest chain ID 31337",
}} />);
expect(screen.getAllByText("Chain mismatch")).toHaveLength(2);
expect(screen.getByText(/84532.*31337/)).toBeTruthy();
});
it("exposes no forms, buttons, wallet connection, signing, or transaction controls", () => {
const { container } = render(<App dashboard={ready()} />);
expect(container.querySelector("button")).toBeNull();
expect(container.querySelector("form")).toBeNull();
expect(container.textContent).not.toMatch(/connect wallet|sign transaction|submit transaction|deposit funds|withdraw funds/i);
});
});
+37
View File
@@ -0,0 +1,37 @@
import { AccountingGrid } from "./components/AccountingGrid";
import { AccountTable } from "./components/AccountTable";
import { ActivityTimeline } from "./components/ActivityTimeline";
import { ContractIdentity } from "./components/ContractIdentity";
import { StatusHeader } from "./components/StatusHeader";
import { TrustDisclosure } from "./components/TrustDisclosure";
import { WarningBanner } from "./components/WarningBanner";
import type { DashboardState } from "./hooks/useBankDashboard";
export function App({ dashboard }: { dashboard: DashboardState }) {
const hasSnapshot = dashboard.status === "ready" || dashboard.status === "stale";
return (
<div className="app-shell">
<WarningBanner />
<main>
<StatusHeader state={dashboard} />
{hasSnapshot ? (
<div className="console-grid">
<AccountingGrid snapshot={dashboard.snapshot} />
<ContractIdentity manifest={dashboard.manifest} snapshot={dashboard.snapshot} />
<AccountTable manifest={dashboard.manifest} snapshot={dashboard.snapshot} />
<ActivityTimeline manifest={dashboard.manifest} snapshot={dashboard.snapshot} />
</div>
) : (
<section className="panel unavailable-panel" aria-labelledby="unavailable-heading">
<p className="eyebrow">State unavailable</p>
<h2 id="unavailable-heading">
{dashboard.status === "loading" ? "Synchronizing deployment" : dashboard.status === "invalid-manifest" ? "Invalid manifest" : dashboard.status === "chain-mismatch" ? "Chain mismatch" : "Disconnected"}
</h2>
<p>{dashboard.status === "loading" ? "Validating the public deployment manifest and reconciling a block-consistent snapshot." : "Contract values remain unknown until the configuration and RPC endpoint can be verified."}</p>
</section>
)}
</main>
<TrustDisclosure />
</div>
);
}
+109
View File
@@ -0,0 +1,109 @@
:root {
color: #f5f7f3;
background: #0b0d0c;
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
font-synthesis: none;
--canvas: #0b0d0c;
--surface: #121513;
--surface-raised: #181c19;
--line: #303630;
--muted: #aab3aa;
--green: #5ff59b;
--amber: #ffcf5a;
--red: #ff716c;
--white: #f5f7f3;
}
* { box-sizing: border-box; }
html { background: var(--canvas); }
body { margin: 0; min-width: 320px; min-height: 100vh; background: radial-gradient(circle at 75% 0%, #18251d 0, transparent 32rem), var(--canvas); }
body::before { content: ""; position: fixed; inset: 0; pointer-events: none; opacity: .2; background-image: linear-gradient(rgba(255,255,255,.025) 1px, transparent 1px), linear-gradient(90deg, rgba(255,255,255,.025) 1px, transparent 1px); background-size: 32px 32px; }
a { color: var(--green); text-underline-offset: .2em; }
a:hover { color: var(--white); }
a:focus-visible, abbr:focus-visible { outline: 3px solid var(--amber); outline-offset: 4px; border-radius: 2px; }
abbr[title] { text-decoration-color: #758078; text-underline-offset: .2em; cursor: help; }
code { font-family: "SFMono-Regular", Consolas, "Liberation Mono", monospace; }
.app-shell { position: relative; width: min(1440px, 100%); margin: 0 auto; padding: 1rem clamp(1rem, 3vw, 3rem) 3rem; }
.warning-banner { position: sticky; z-index: 10; top: .75rem; display: flex; align-items: center; justify-content: center; gap: .65rem; margin-bottom: clamp(2rem, 5vw, 4.5rem); padding: .8rem 1rem; border: 1px solid #806e32; border-radius: 4px; color: #fff3c5; background: rgba(66, 52, 10, .96); box-shadow: 0 12px 40px rgba(0,0,0,.32); font-size: .88rem; letter-spacing: .02em; }
main { display: grid; gap: 1.25rem; }
.status-header { display: grid; grid-template-columns: minmax(0, 1.3fr) minmax(34rem, 1fr); column-gap: 3rem; align-items: end; padding: 0 0 1.6rem; border-bottom: 1px solid var(--line); }
.eyebrow { margin: 0 0 .55rem; color: var(--green); font: 700 .7rem/1.2 "SFMono-Regular", Consolas, monospace; letter-spacing: .16em; text-transform: uppercase; }
h1, h2, p { margin-top: 0; }
h1 { margin-bottom: .75rem; max-width: 13ch; font-size: clamp(2.25rem, 6vw, 5rem); line-height: .92; letter-spacing: -.065em; }
h2 { margin-bottom: 0; font-size: 1.3rem; letter-spacing: -.025em; }
.lede { max-width: 62ch; margin-bottom: 0; color: var(--muted); line-height: 1.6; }
.status-strip { display: grid; grid-template-columns: repeat(5, auto); gap: .5rem; margin: 0; }
.status-strip > div { min-width: 0; padding: .7rem .8rem; border-left: 1px solid var(--line); }
dt { color: var(--muted); font-size: .72rem; letter-spacing: .08em; text-transform: uppercase; }
dd { margin: .35rem 0 0; font-weight: 680; }
.status-strip dd { white-space: nowrap; font-size: .85rem; }
.status-dot { display: inline-block; width: .55rem; height: .55rem; margin-right: .45rem; border-radius: 50%; background: var(--muted); box-shadow: 0 0 12px currentColor; }
.status-dot.good { color: var(--green); background: var(--green); }
.status-dot.warn { color: var(--amber); background: var(--amber); }
.status-dot.bad { color: var(--red); background: var(--red); }
.sync-time { grid-column: 2; margin: .7rem 0 0; color: var(--muted); font-size: .78rem; text-align: right; }
.state-message { grid-column: 1 / -1; margin: 1.2rem 0 0; padding: .8rem 1rem; border-left: 3px solid var(--amber); color: #ffe8a6; background: #28220f; }
.console-grid { display: grid; grid-template-columns: minmax(0, 1.45fr) minmax(24rem, .85fr); gap: 1.25rem; align-items: start; }
.panel, .trust-disclosure { border: 1px solid var(--line); border-radius: 5px; background: linear-gradient(145deg, rgba(24, 28, 25, .97), rgba(16, 19, 17, .97)); box-shadow: 0 18px 50px rgba(0,0,0,.17); }
.panel { padding: clamp(1.2rem, 3vw, 2rem); }
.section-heading { display: flex; justify-content: space-between; align-items: end; gap: 2rem; margin-bottom: 1.7rem; }
.helper { max-width: 38ch; margin: 0; color: var(--muted); font-size: .8rem; line-height: 1.5; text-align: right; }
.accounting-panel { grid-column: 1; }
.accounting-grid { display: grid; grid-template-columns: 1fr 1fr; margin: 0; }
.accounting-grid > div { padding: 1rem 0; border-top: 1px solid var(--line); }
.accounting-grid > div:nth-child(even) { padding-left: 1.5rem; border-left: 1px solid var(--line); }
.accounting-grid dd { font-family: "SFMono-Regular", Consolas, monospace; font-size: 1.15rem; }
.accounting-grid .hero-figure { padding-top: 1.25rem; padding-bottom: 2.2rem; }
.accounting-grid .hero-figure dd { color: var(--white); font-size: clamp(1.75rem, 3.4vw, 3.25rem); line-height: 1; letter-spacing: -.07em; }
.identity-panel { grid-column: 2; grid-row: 1 / span 2; }
.identity-list { display: grid; gap: 0; margin: 0; }
.identity-list > div { padding: 1rem 0; border-top: 1px solid var(--line); }
.identity-list code { display: block; overflow-wrap: anywhere; color: var(--muted); font-size: .78rem; line-height: 1.6; }
.identity-list .proxy-row { margin: 0 -.75rem; padding: 1.25rem .75rem; border: 1px solid #3d5d49; background: #142119; }
.identity-list .primary-address code { color: var(--green); font-size: .93rem; }
.accounts-panel { grid-column: 1; }
.table-scroll { overflow-x: auto; }
table { width: 100%; border-collapse: collapse; }
th, td { padding: .9rem .8rem; border-top: 1px solid var(--line); text-align: left; }
thead th { color: var(--muted); font-size: .68rem; letter-spacing: .1em; text-transform: uppercase; }
tbody th { color: var(--white); }
td:last-child, th:last-child { text-align: right; }
td code { color: var(--muted); font-size: .82rem; }
.activity-panel { grid-column: 1 / -1; }
.timeline { display: grid; grid-template-columns: 1fr 1fr; gap: 0 2rem; margin: 0; padding: 0; list-style: none; }
.timeline li { position: relative; display: grid; grid-template-columns: 1rem 1fr; gap: .8rem; min-width: 0; padding: 1rem 0; border-top: 1px solid var(--line); }
.timeline-marker { width: .5rem; height: .5rem; margin-top: .35rem; border: 1px solid var(--green); border-radius: 50%; background: #163522; box-shadow: 0 0 10px rgba(95,245,155,.28); }
.timeline p { margin-bottom: .4rem; line-height: 1.45; }
.timeline .event-context { margin: 0; color: var(--muted); font-size: .76rem; }
.timeline .diagnostic { color: #ffe3a0; }
.timeline .diagnostic .timeline-marker { border-color: var(--amber); background: #3a2e0d; box-shadow: 0 0 10px rgba(255,207,90,.25); }
.empty-state { margin: 0; color: var(--muted); }
.unavailable-panel { min-height: 15rem; display: grid; align-content: center; justify-items: start; }
.unavailable-panel p:last-child { max-width: 60ch; margin-bottom: 0; color: var(--muted); line-height: 1.6; }
.trust-disclosure { margin-top: 1.25rem; padding: clamp(1.3rem, 3vw, 2.2rem); border-color: #704542; background: linear-gradient(145deg, #211515, #171313); }
.trust-disclosure .eyebrow { color: var(--red); }
.trust-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 1.5rem; margin-top: 1.5rem; }
.trust-grid p { margin-bottom: 0; color: #d3c4c2; line-height: 1.55; }
.professional-note { margin: 1.5rem 0 0; padding-top: 1.25rem; border-top: 1px solid #533632; color: var(--white); line-height: 1.6; }
@media (max-width: 980px) {
.status-header { grid-template-columns: 1fr; align-items: start; }
.status-strip { grid-template-columns: repeat(5, 1fr); margin-top: 2rem; }
.sync-time { grid-column: 1; text-align: left; }
.console-grid { grid-template-columns: 1fr; }
.accounting-panel, .identity-panel, .accounts-panel, .activity-panel { grid-column: 1; grid-row: auto; }
.timeline { grid-template-columns: 1fr; }
}
@media (max-width: 700px) {
.app-shell { padding-inline: .75rem; }
.warning-banner { top: .35rem; margin-bottom: 2.5rem; }
.status-strip { grid-template-columns: repeat(2, 1fr); }
.section-heading { display: block; }
.helper { margin-top: .75rem; text-align: left; }
.accounting-grid { grid-template-columns: 1fr; }
.accounting-grid > div:nth-child(even) { padding-left: 0; border-left: 0; }
.accounting-grid .hero-figure { padding-bottom: 1.35rem; }
.trust-grid { grid-template-columns: 1fr; gap: .7rem; }
}
+27
View File
@@ -0,0 +1,27 @@
import type { DashboardSnapshot, DeploymentManifest } from "../types/dashboard";
import { formatAmount, shortenAddress } from "./format";
export function AccountTable({ manifest, snapshot }: { manifest: DeploymentManifest; snapshot: DashboardSnapshot }) {
return (
<section className="panel accounts-panel" aria-labelledby="accounts-heading">
<div className="section-heading">
<div><p className="eyebrow">Internal ledger</p><h2 id="accounts-heading">Tracked accounts</h2></div>
<p className="helper">Balances are bank liabilities, not wallet token balances.</p>
</div>
<div className="table-scroll">
<table>
<thead><tr><th scope="col">Account</th><th scope="col">Address</th><th scope="col">Balance</th></tr></thead>
<tbody>
{snapshot.actors.map((actor) => (
<tr key={actor.address}>
<th scope="row">{manifest.network === "anvil" ? actor.label : shortenAddress(actor.address)}</th>
<td><code title={actor.address}>{shortenAddress(actor.address)}</code></td>
<td>{formatAmount(actor.balance)}</td>
</tr>
))}
</tbody>
</table>
</div>
</section>
);
}
+19
View File
@@ -0,0 +1,19 @@
import type { DashboardSnapshot } from "../types/dashboard";
import { formatAmount, formatReserveRatio, formatSurplus } from "./format";
export function AccountingGrid({ snapshot }: { snapshot: DashboardSnapshot }) {
return (
<section className="panel accounting-panel" aria-labelledby="accounting-heading">
<div className="section-heading">
<div><p className="eyebrow">Custody invariant</p><h2 id="accounting-heading">Accounting</h2></div>
<p className="helper">Solvent when reserves are greater than or equal to liabilities.</p>
</div>
<dl className="accounting-grid">
<div className="hero-figure"><dt><abbr title="MockUSDC held by the stable proxy address" tabIndex={0}>Reserves</abbr></dt><dd>{formatAmount(snapshot.reserves)}</dd></div>
<div className="hero-figure"><dt><abbr title="The sum of all balances recorded in the bank ledger" tabIndex={0}>Liabilities</abbr></dt><dd>{formatAmount(snapshot.liabilities)}</dd></div>
<div><dt>Surplus</dt><dd>{formatSurplus(snapshot.surplus)}</dd></div>
<div><dt>Reserve ratio</dt><dd>{formatReserveRatio(snapshot.reserves, snapshot.liabilities)}</dd></div>
</dl>
</section>
);
}
+76
View File
@@ -0,0 +1,76 @@
import type { Address, Hex } from "viem";
import type { Activity, DashboardSnapshot, DecodeDiagnostic, DeploymentManifest } from "../types/dashboard";
import { formatAmount, shortenAddress } from "./format";
type TimelineItem = (Activity & { diagnostic?: false }) | (DecodeDiagnostic & { diagnostic: true });
function actorLabel(address: Address, manifest: DeploymentManifest): string {
if (manifest.network === "baseSepolia") return shortenAddress(address);
return manifest.actors.find((actor) => actor.address.toLowerCase() === address.toLowerCase())?.label ?? shortenAddress(address);
}
function description(activity: Activity, manifest: DeploymentManifest): string {
switch (activity.kind) {
case "deposit": return `Deposited ${formatAmount(activity.amount)} for ${actorLabel(activity.account, manifest)}`;
case "withdrawal": return `Withdrawn ${formatAmount(activity.amount)} for ${actorLabel(activity.account, manifest)}`;
case "transfer": return `${actorLabel(activity.from, manifest)} transferred ${formatTransferAmount(activity.amount)} to ${actorLabel(activity.to, manifest)}`;
case "paused": return `Paused by ${actorLabel(activity.account, manifest)}`;
case "unpaused": return `Unpaused by ${actorLabel(activity.account, manifest)}`;
case "ownershipTransferred": return `Ownership transferred from ${actorLabel(activity.previousOwner, manifest)} to ${actorLabel(activity.newOwner, manifest)}`;
case "upgraded": return `Implementation upgraded to ${shortenAddress(activity.implementation)}`;
}
}
function formatTransferAmount(amount: bigint): string {
const whole = amount / 1_000_000n;
const fraction = (amount % 1_000_000n).toString().padStart(6, "0");
return `${new Intl.NumberFormat("en-US").format(whole)}.${fraction} mUSDC`;
}
function transactionUrl(manifest: DeploymentManifest, transactionHash: Hex): string | undefined {
if (!manifest.explorerBaseUrl) return undefined;
return `${manifest.explorerBaseUrl.replace(/\/$/, "")}/tx/${transactionHash}`;
}
function newestFirst(first: TimelineItem, second: TimelineItem): number {
if (first.blockNumber === second.blockNumber) return second.logIndex - first.logIndex;
return first.blockNumber > second.blockNumber ? -1 : 1;
}
export function ActivityTimeline({ manifest, snapshot }: { manifest: DeploymentManifest; snapshot: DashboardSnapshot }) {
const items: TimelineItem[] = [
...snapshot.activity.map((activity) => ({ ...activity, diagnostic: false as const })),
...snapshot.diagnostics.map((diagnostic) => ({ ...diagnostic, diagnostic: true as const })),
].sort(newestFirst);
return (
<section className="panel activity-panel" aria-labelledby="activity-heading">
<div className="section-heading">
<div><p className="eyebrow">Proxy-only log stream</p><h2 id="activity-heading">Activity</h2></div>
<p className="helper">Newest first. Direct reads reconcile the accounting shown above.</p>
</div>
{items.length === 0 ? <p className="empty-state">No proxy activity found from the deployment block.</p> : (
<ol className="timeline" aria-label="Proxy activity">
{items.map((item) => {
const key = `${item.transactionHash}-${item.logIndex}`;
const url = transactionUrl(manifest, item.transactionHash);
const transaction = <code>{shortenAddress(item.transactionHash)}</code>;
return (
<li key={key} className={item.diagnostic ? "diagnostic" : undefined}>
<div className="timeline-marker" aria-hidden="true" />
<div>
<p>{item.diagnostic ? item.message : description(item, manifest)}</p>
<p className="event-context">
Block {item.blockNumber.toString()} · {url
? <a href={url} aria-label={`Transaction ${shortenAddress(item.transactionHash)}`} target="_blank" rel="noreferrer">{transaction}</a>
: transaction}
</p>
</div>
</li>
);
})}
</ol>
)}
</section>
);
}
+41
View File
@@ -0,0 +1,41 @@
import type { Address } from "viem";
import type { DashboardSnapshot, DeploymentManifest } from "../types/dashboard";
import { shortenAddress } from "./format";
function addressUrl(manifest: DeploymentManifest, address: Address): string | undefined {
if (!manifest.explorerBaseUrl) return undefined;
return `${manifest.explorerBaseUrl.replace(/\/$/, "")}/address/${address}`;
}
function AddressValue({ label, address, manifest, primary = false }: {
label: string;
address: Address;
manifest: DeploymentManifest;
primary?: boolean;
}) {
const value = <code title={address}>{manifest.network === "baseSepolia" ? shortenAddress(address) : address}</code>;
const url = addressUrl(manifest, address);
return (
<dd className={primary ? "primary-address" : undefined}>
{url ? <a href={url} aria-label={`${label} ${shortenAddress(address)}`} target="_blank" rel="noreferrer">{value}</a> : value}
</dd>
);
}
export function ContractIdentity({ manifest, snapshot }: { manifest: DeploymentManifest; snapshot: DashboardSnapshot }) {
return (
<section className="panel identity-panel" aria-labelledby="identity-heading">
<div className="section-heading">
<div><p className="eyebrow">Upgrade boundary</p><h2 id="identity-heading">Contract identity</h2></div>
<p className="helper">Reads target the proxy. The implementation address identifies its current logic.</p>
</div>
<dl className="identity-list">
<div className="proxy-row"><dt><abbr title="The stable application address that holds storage and reserves" tabIndex={0}>Proxy</abbr></dt><AddressValue label="Proxy" address={snapshot.proxy} manifest={manifest} primary /></div>
<div><dt><abbr title="The replaceable contract containing the current executable logic" tabIndex={0}>Implementation</abbr></dt><AddressValue label="Implementation" address={snapshot.implementation} manifest={manifest} /></div>
<div><dt>Token</dt><AddressValue label="Token" address={snapshot.asset} manifest={manifest} /></div>
<div><dt><abbr title="The account authorized to pause and upgrade the proxy" tabIndex={0}>Owner</abbr></dt><AddressValue label="Owner" address={snapshot.owner} manifest={manifest} /></div>
<div><dt>Deployment block</dt><dd>{manifest.deploymentBlock.toString()}</dd></div>
</dl>
</section>
);
}

Some files were not shown because too many files have changed in this diff Show More