docs: track superpowers working documents in git
The uups-bank-demo wave's SDD records (ledger, six task briefs and reports, review diffs) and the brainstorm design mockups were git-ignored, so they existed only on one sandbox VM and reached no remote — this repo had no remote at all until now. Removes `.superpowers/` from .gitignore and the `*` .gitignore the superpowers plugin writes inside .superpowers/sdd/; the second blocks the directory even with the first removed. Excluded as ephemeral local-server state, and now ignored by name: .last-port, .last-token (a 64-char session token for a brainstorm server on a port that is long gone), and the per-session state/ directories. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Fnwzj6McD6kSkXwjUKFKxe
This commit is contained in:
+5
-1
@@ -1,2 +1,6 @@
|
|||||||
.superpowers/
|
|
||||||
.worktrees/
|
.worktrees/
|
||||||
|
|
||||||
|
# ephemeral brainstorm-server session state
|
||||||
|
.superpowers/brainstorm/.last-port
|
||||||
|
.superpowers/brainstorm/.last-token
|
||||||
|
.superpowers/brainstorm/*/state/
|
||||||
|
|||||||
+97
@@ -0,0 +1,97 @@
|
|||||||
|
<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>
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
<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 0x8B31…A942<br>
|
||||||
|
Implementation 0x19F4…71C0<br>
|
||||||
|
Asset 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>
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
<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>
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
<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>
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
<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>
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
<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>
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
# 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 6’s 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 Base’s 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 spec’s 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. |
|
||||||
@@ -0,0 +1,569 @@
|
|||||||
|
# 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
@@ -0,0 +1,311 @@
|
|||||||
|
# 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 Anvil’s standard development mnemonic. Testnet signing uses a named encrypted Foundry keystore and `--account`; no supported command accepts a raw private key or mnemonic environment variable.
|
||||||
|
- Every application call uses the proxy address. The implementation address is read only for validation and explanation.
|
||||||
|
-- `Upgrades`, never `UnsafeUpgrades`, performs deploy/upgrade validation. The sole validator allowance is the constructor annotation immediately above `_disableInitializers()`.
|
||||||
|
+- `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 scanner’s own pattern from split shell literals so it does not match itself. Permit only the exact hyphenated constructor annotation. The committed `ANVIL_TEST_PHRASE` is the universally known test fixture, not a supported secret input; test that exact fixture is the only scan exception.
|
||||||
|
+- [ ] Implement `scan-project.sh` with a NUL-safe array from `git ls-files`, excluding dependency gitlinks, `docs/superpowers`, and generated lockfiles. Return nonzero if project-owned tracked content contains an actual `PRIVATE_KEY`/`MNEMONIC` assignment, PEM private key, `TODO`, `TBD`, `FIXME`, filler text, or an unsafe upgrade bypass in `src`/`test`/`script`. Construct the scanner’s own pattern from split shell literals so it does not match itself. Permit only the exact hyphenated `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);
|
||||||
|
+ }
|
||||||
|
+}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
# Review package: 1f4175ba729fbbab58538cd3ac02b5459e014c86..1f4175ba729fbbab58538cd3ac02b5459e014c86
|
||||||
|
|
||||||
|
## Commits
|
||||||
|
|
||||||
|
## Files changed
|
||||||
|
|
||||||
|
## Diff
|
||||||
@@ -0,0 +1,145 @@
|
|||||||
|
# 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;
|
||||||
|
+ }
|
||||||
|
+}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
# Review package: 6dcbb03f3c79979416c2f0dd37556539276a4e29..6dcbb03f3c79979416c2f0dd37556539276a4e29
|
||||||
|
|
||||||
|
## Commits
|
||||||
|
|
||||||
|
## Files changed
|
||||||
|
|
||||||
|
## Diff
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
# 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));
|
||||||
|
+ }
|
||||||
|
+}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
# 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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,206 @@
|
|||||||
|
### Task 1: Pin and prove the repository toolchain
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `.gitignore`
|
||||||
|
- Create: `.nvmrc`
|
||||||
|
- Create: `.env.example`
|
||||||
|
- Create: `.gitmodules` (generated by `forge install`)
|
||||||
|
- Create: `foundry.lock` (generated by `forge install`)
|
||||||
|
- Create: `foundry.toml`
|
||||||
|
- Create: `remappings.txt`
|
||||||
|
- Create: `Makefile`
|
||||||
|
- Create: `package.json`
|
||||||
|
- Create: `package-lock.json` (generated by npm)
|
||||||
|
- Create: `tools/check-upgrades-cli.mjs`
|
||||||
|
- Create: `web/package.json`
|
||||||
|
- Create: `web/package-lock.json` (generated by npm)
|
||||||
|
- Create: `web/index.html`
|
||||||
|
- Create: `web/tsconfig.json`
|
||||||
|
- Create: `web/tsconfig.app.json`
|
||||||
|
- Create: `web/tsconfig.node.json`
|
||||||
|
- Create: `web/vite.config.ts`
|
||||||
|
- Create: `web/eslint.config.js`
|
||||||
|
- Create: `web/src/test/toolchain.test.ts`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Produces `make doctor`, `make setup`, and the initial verification gate that later tasks extend.
|
||||||
|
- Produces exact dependency revisions for every later Solidity and web task.
|
||||||
|
- Consumes no application source.
|
||||||
|
|
||||||
|
- [ ] Verify host tools and record the expected initial limitation:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
forge --version
|
||||||
|
anvil --version
|
||||||
|
node --version
|
||||||
|
npm --version
|
||||||
|
make --version
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected now: Foundry commands may be missing; Node/npm/make print their installed versions. If Foundry is missing during execution, request permission to install Foundry from its official installer, then pin with `foundryup -i 1.7.1`. Do not silently install system-wide tools.
|
||||||
|
|
||||||
|
- [ ] Extend `.gitignore` with exactly these runtime classes:
|
||||||
|
|
||||||
|
```gitignore
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
.demo/
|
||||||
|
cache/
|
||||||
|
out/
|
||||||
|
broadcast/
|
||||||
|
deployments/*.json
|
||||||
|
deployments/**/*.json
|
||||||
|
!deployments/*.example.json
|
||||||
|
node_modules/
|
||||||
|
web/node_modules/
|
||||||
|
web/dist/
|
||||||
|
web/coverage/
|
||||||
|
web/public/deployment.json
|
||||||
|
web/src/generated/*.ts
|
||||||
|
!.gitkeep
|
||||||
|
.superpowers/
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] Pin Node/npm in `.nvmrc` and create a private root `package.json` with the same `packageManager`/`engines` fields plus exactly one dev dependency: `"@openzeppelin/upgrades-core": "1.46.0"`. Generate and commit the root lockfile with `npm install --save-exact`. This locally satisfies the plugin’s hard-coded `^1.45.0` range.
|
||||||
|
|
||||||
|
- [ ] Pin the browser dependencies in `web/package.json`; the package must be private and use only exact versions:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"name": "uups-bank-operations-console",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.1.0",
|
||||||
|
"type": "module",
|
||||||
|
"packageManager": "npm@11.17.0",
|
||||||
|
"engines": { "node": ">=24.18.0 <25", "npm": ">=11.17.0 <12" },
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite --host 127.0.0.1",
|
||||||
|
"lint": "eslint . --max-warnings 0",
|
||||||
|
"typecheck": "tsc -b --pretty false",
|
||||||
|
"test": "vitest run",
|
||||||
|
"build": "tsc -b && vite build"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Add exact runtime dependencies `@tanstack/react-query@5.101.4`, `react@19.2.8`, `react-dom@19.2.8`, `viem@2.55.8`, and `wagmi@3.7.5`. Add exact dev dependencies `@eslint/js@10.0.1`, `@testing-library/dom@10.4.1`, `@testing-library/react@16.3.2`, `@types/node@24.10.0`, `@types/react@19.2.14`, `@types/react-dom@19.2.4`, `@vitejs/plugin-react@6.0.4`, `eslint@10.0.1`, `eslint-plugin-react-hooks@7.1.1`, `eslint-plugin-react-refresh@0.5.3`, `globals@17.7.0`, `jsdom@30.0.1`, `typescript@7.0.2`, `typescript-eslint@8.65.0`, `vite@8.2.0`, and `vitest@4.1.10`. If npm rejects one exact revision because the registry changed, verify the official release before changing both this plan and the package file.
|
||||||
|
|
||||||
|
- [ ] Configure Foundry in `foundry.toml`:
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[profile.default]
|
||||||
|
src = "src"
|
||||||
|
test = "test"
|
||||||
|
script = "script"
|
||||||
|
out = "out"
|
||||||
|
libs = ["lib"]
|
||||||
|
solc_version = "0.8.35"
|
||||||
|
evm_version = "cancun"
|
||||||
|
optimizer = true
|
||||||
|
optimizer_runs = 200
|
||||||
|
ffi = true
|
||||||
|
ast = true
|
||||||
|
build_info = true
|
||||||
|
extra_output = ["storageLayout"]
|
||||||
|
fs_permissions = [
|
||||||
|
{ access = "read", path = "out" },
|
||||||
|
{ access = "read-write", path = "deployments" }
|
||||||
|
]
|
||||||
|
|
||||||
|
[fuzz]
|
||||||
|
runs = 512
|
||||||
|
seed = "0x5555505342414e4b"
|
||||||
|
|
||||||
|
[invariant]
|
||||||
|
runs = 128
|
||||||
|
depth = 64
|
||||||
|
fail_on_revert = true
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] Install exact Solidity dependencies as Git submodules and write canonical remappings:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
forge install foundry-rs/forge-std@v1.16.1
|
||||||
|
forge install OpenZeppelin/openzeppelin-foundry-upgrades@v0.4.1
|
||||||
|
forge install OpenZeppelin/openzeppelin-contracts-upgradeable@v5.6.1
|
||||||
|
git submodule update --init --recursive
|
||||||
|
```
|
||||||
|
|
||||||
|
```text
|
||||||
|
forge-std/=lib/forge-std/src/
|
||||||
|
openzeppelin-foundry-upgrades/=lib/openzeppelin-foundry-upgrades/src/
|
||||||
|
@openzeppelin/contracts/=lib/openzeppelin-contracts-upgradeable/lib/openzeppelin-contracts/contracts/
|
||||||
|
@openzeppelin/contracts-upgradeable/=lib/openzeppelin-contracts-upgradeable/contracts/
|
||||||
|
```
|
||||||
|
|
||||||
|
Do not install a second top-level copy of `openzeppelin-contracts`; the upgradeable submodule’s pinned transitive copy supplies both canonical remappings.
|
||||||
|
|
||||||
|
- [ ] Create `tools/check-upgrades-cli.mjs`. It must assert that the plugin source contains `UPGRADES_CORE = "^1.45.0"`, the root lockfile resolves `@openzeppelin/upgrades-core` to exactly `1.46.0`, and the locally installed package reports `1.46.0`. Then prove the CLI can start with networking disabled:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm ci
|
||||||
|
node tools/check-upgrades-cli.mjs
|
||||||
|
npm_config_offline=true npx @openzeppelin/upgrades-core@^1.45.0 validate --help
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: all commands exit `0` without fetching. Do not patch the vendored OpenZeppelin version constant.
|
||||||
|
|
||||||
|
- [ ] Create Vite/React/TypeScript/ESLint/Vitest configuration manually so no unpinned scaffold generator is executed. Configure jsdom, `web/src/test/setup.ts`, strict TypeScript, and React refresh. Run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm --prefix web install --save-exact
|
||||||
|
npm --prefix web test
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected red: `toolchain.test.ts` cannot import the not-yet-created `src/config/toolchain.ts`.
|
||||||
|
|
||||||
|
- [ ] Create `web/src/config/toolchain.ts` exporting the display labels `Foundry 1.7.1`, `Solidity 0.8.35`, `OpenZeppelin 5.6.1`, and `UUPS`; make the test assert those exact values.
|
||||||
|
|
||||||
|
- [ ] Add an initial `Makefile` with shell safety (`SHELL := /bin/bash`, `.SHELLFLAGS := -euo pipefail -c`) and non-destructive targets:
|
||||||
|
|
||||||
|
```make
|
||||||
|
.PHONY: doctor setup verify
|
||||||
|
doctor:
|
||||||
|
@./tools/doctor.sh
|
||||||
|
setup:
|
||||||
|
@git submodule update --init --recursive
|
||||||
|
@npm ci
|
||||||
|
@npm --prefix web ci
|
||||||
|
verify:
|
||||||
|
@forge fmt --check
|
||||||
|
@forge clean
|
||||||
|
@npm_config_offline=true forge build --force
|
||||||
|
@npm_config_offline=true forge test --force
|
||||||
|
@npm --prefix web run lint
|
||||||
|
@npm --prefix web run typecheck
|
||||||
|
@npm --prefix web test
|
||||||
|
@npm --prefix web run build
|
||||||
|
```
|
||||||
|
|
||||||
|
Create a temporary minimal `tools/doctor.sh` that reports missing commands and exact expected versions without installing anything; Task 9 replaces it with full port/config checks.
|
||||||
|
|
||||||
|
- [ ] Run the green foundation checks:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
forge fmt --check
|
||||||
|
forge clean
|
||||||
|
npm_config_offline=true forge build --force
|
||||||
|
npm_config_offline=true forge test --force
|
||||||
|
node tools/check-upgrades-cli.mjs
|
||||||
|
npm --prefix web run lint
|
||||||
|
npm --prefix web run typecheck
|
||||||
|
npm --prefix web test
|
||||||
|
npm --prefix web run build
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: all exit `0`; the empty Solidity source tree builds and the toolchain test passes.
|
||||||
|
|
||||||
|
- [ ] Commit the foundation:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add .gitignore .nvmrc .env.example .gitmodules foundry.lock foundry.toml remappings.txt package.json package-lock.json Makefile tools/check-upgrades-cli.mjs tools/doctor.sh lib web
|
||||||
|
git commit -m "build: pin demo toolchains"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
# 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 brief’s 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. Microsoft’s 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 Node’s 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.17’s `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.17’s `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 Foundry’s informational empty-tree `Nothing to format` notice because this foundation task intentionally has no Solidity sources.
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
### Task 2: Build the valueless six-decimal mock asset with TDD
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `src/MockUSDC.sol`
|
||||||
|
- Create: `test/MockUSDC.t.sol`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Produces `MockUSDC.mint(address,uint256)`, `decimals() == 6`, and standard ERC-20 behavior.
|
||||||
|
- Consumed by the bank tests, deployment scripts, and dashboard ABI export.
|
||||||
|
|
||||||
|
- [ ] Write `test/MockUSDC.t.sol` first. Cover the exact name/symbol/decimals, owner-only mint, successful mint, transfer/approve behavior inherited from ERC-20, and zero initial supply. Use `vm.expectRevert(abi.encodeWithSelector(Ownable.OwnableUnauthorizedAccount.selector, stranger))` for non-owner minting.
|
||||||
|
|
||||||
|
- [ ] Run the focused test and observe red:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
forge test --match-path test/MockUSDC.t.sol -vvv --force
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected red: `src/MockUSDC.sol` is missing.
|
||||||
|
|
||||||
|
- [ ] Implement only the tested contract:
|
||||||
|
|
||||||
|
```solidity
|
||||||
|
/// @notice Educational mock token with no monetary value. Never use as real USDC.
|
||||||
|
contract MockUSDC is ERC20, Ownable {
|
||||||
|
constructor(address initialOwner) ERC20("Mock USD Coin", "mUSDC") Ownable(initialOwner) {}
|
||||||
|
|
||||||
|
function decimals() public pure override returns (uint8) { return 6; }
|
||||||
|
function mint(address to, uint256 amount) external onlyOwner { _mint(to, amount); }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Reject a zero `initialOwner` through OpenZeppelin’s `OwnableInvalidOwner` behavior. Do not add faucet, burn, permit, blacklist, proxy, or bank-specific logic.
|
||||||
|
|
||||||
|
- [ ] Run red-to-green verification:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
forge fmt
|
||||||
|
forge test --match-path test/MockUSDC.t.sol -vvv --force
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: all mock-token tests pass.
|
||||||
|
|
||||||
|
- [ ] Commit:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/MockUSDC.sol test/MockUSDC.t.sol
|
||||||
|
git commit -m "feat: add valueless mock USDC"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
# 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.
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
### 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"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
# 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.
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
### Task 4: Implement V1 custody accounting and adversarial unit tests
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/BankV1.sol`
|
||||||
|
- Create: `test/BankV1.t.sol`
|
||||||
|
- Create: `test/mocks/FeeOnTransferToken.sol`
|
||||||
|
- Create: `test/mocks/ReentrantToken.sol`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Produces `deposit(uint256)` and `withdraw(uint256)` plus `Deposited`, `Withdrawn`, `ZeroAmount`, `InsufficientBalance`, and `UnexpectedAssetDelta`.
|
||||||
|
- Consumes only a standard non-rebasing ERC-20 through `IERC20`/`SafeERC20`.
|
||||||
|
- Preserves `reserves >= totalLiabilities`; direct token transfers may create surplus.
|
||||||
|
|
||||||
|
- [ ] Write event and error declarations into the test expectations before production code:
|
||||||
|
|
||||||
|
```solidity
|
||||||
|
event Deposited(address indexed account, uint256 amount);
|
||||||
|
event Withdrawn(address indexed account, uint256 amount);
|
||||||
|
|
||||||
|
error ZeroAmount();
|
||||||
|
error InsufficientBalance(address account, uint256 available, uint256 requested);
|
||||||
|
error UnexpectedAssetDelta(uint256 expected, uint256 actual);
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] Write failing deposit tests for exact balance/liability/reserve deltas, event emission, zero amount, paused state, inadequate allowance, inadequate wallet balance, and two independent customers. Use six-decimal constants (`1_000e6`) so assertions read like the demo.
|
||||||
|
|
||||||
|
- [ ] Add `FeeOnTransferToken` whose `transferFrom` delivers `99%` of the requested amount. Assert a deposit reverts with `UnexpectedAssetDelta(requested, received)` and that the ERC-20 transfer, bank balance, and liabilities are all rolled back.
|
||||||
|
|
||||||
|
- [ ] Add `ReentrantToken` that attempts a nested `bank.deposit` during `transferFrom`. Assert the nested call receives `ReentrancyGuardReentrantCall` and the outer call either completes once or reverts atomically according to the mock’s configured propagation mode; no double credit is permitted.
|
||||||
|
|
||||||
|
- [ ] Write failing withdrawal tests for exact deltas, event emission, zero amount, paused state, insufficient internal balance (including available/requested values), checks-effects-interactions under a transfer callback, and one account’s withdrawal leaving another account unchanged.
|
||||||
|
|
||||||
|
- [ ] Write the surplus test: deposit 100 mUSDC, transfer 25 mUSDC directly to the proxy, assert reserves `125e6`, liabilities `100e6`, and a normal 100 mUSDC withdrawal succeeds while the 25 mUSDC surplus remains. Assert there is no owner sweep/rescue behavior by keeping such a function out of the interface.
|
||||||
|
|
||||||
|
- [ ] Run red tests:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm_config_offline=true forge test --match-path test/BankV1.t.sol -vvv --force
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected red: `deposit`/`withdraw` and their errors/events are absent.
|
||||||
|
|
||||||
|
- [ ] Implement `deposit` with exact received-amount validation and state credit only after a successful transfer:
|
||||||
|
|
||||||
|
```solidity
|
||||||
|
function deposit(uint256 amount) external whenNotPaused nonReentrant {
|
||||||
|
if (amount == 0) revert ZeroAmount();
|
||||||
|
|
||||||
|
uint256 reservesBefore = _asset.balanceOf(address(this));
|
||||||
|
_asset.safeTransferFrom(msg.sender, address(this), amount);
|
||||||
|
uint256 reservesAfter = _asset.balanceOf(address(this));
|
||||||
|
uint256 received = reservesAfter >= reservesBefore ? reservesAfter - reservesBefore : 0;
|
||||||
|
if (received != amount) revert UnexpectedAssetDelta(amount, received);
|
||||||
|
|
||||||
|
_balances[msg.sender] += amount;
|
||||||
|
_totalLiabilities += amount;
|
||||||
|
emit Deposited(msg.sender, amount);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] Implement `withdraw` with checks-effects-interactions:
|
||||||
|
|
||||||
|
```solidity
|
||||||
|
function withdraw(uint256 amount) external whenNotPaused nonReentrant {
|
||||||
|
if (amount == 0) revert ZeroAmount();
|
||||||
|
uint256 available = _balances[msg.sender];
|
||||||
|
if (amount > available) revert InsufficientBalance(msg.sender, available, amount);
|
||||||
|
|
||||||
|
_balances[msg.sender] = available - amount;
|
||||||
|
_totalLiabilities -= amount;
|
||||||
|
_asset.safeTransfer(msg.sender, amount);
|
||||||
|
emit Withdrawn(msg.sender, amount);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] Add fuzz tests in `BankV1.t.sol`: bound deposit to `[1, 1_000_000e6]`; bound withdrawal to `[1, deposited]`; prove exact reserve/liability/customer deltas and that over-withdraw always reverts. Keep a fixed seed in `foundry.toml` for presentation reproducibility while printing Foundry’s replay seed on failure.
|
||||||
|
|
||||||
|
- [ ] Run focused and aggregate green tests:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
forge fmt
|
||||||
|
npm_config_offline=true forge test --match-path test/BankV1.t.sol -vvv --force
|
||||||
|
npm_config_offline=true forge test --match-path 'test/BankV1*.t.sol' --force
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: all V1 tests pass with validated proxy deployment enabled.
|
||||||
|
|
||||||
|
- [ ] Commit:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/BankV1.sol test/BankV1.t.sol test/mocks/FeeOnTransferToken.sol test/mocks/ReentrantToken.sol
|
||||||
|
git commit -m "feat: add V1 custody accounting"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
# 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.
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
### 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"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
@@ -0,0 +1,219 @@
|
|||||||
|
# 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.
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
### Task 6: Deploy, seed, inspect, and export deterministic V1 state
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `script/lib/DemoScript.sol`
|
||||||
|
- Create: `script/DeployV1.s.sol`
|
||||||
|
- Create: `script/SeedV1Demo.s.sol`
|
||||||
|
- Create: `script/CheckState.s.sol`
|
||||||
|
- Create: `test/ScriptPreflight.t.sol`
|
||||||
|
- Create: `tools/finalize-manifest.mjs`
|
||||||
|
- Create: `tools/select-manifest.mjs`
|
||||||
|
- Create: `tools/test-finalize-manifest.mjs`
|
||||||
|
- Create: `deployments/.gitkeep`
|
||||||
|
- Modify: `Makefile`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes an RPC, chain ID, explicit broadcaster, pending manifest, and Foundry broadcast receipts.
|
||||||
|
- Produces an active schema-versioned public manifest only after receipt/code/postcondition checks, then the exact Act 1 state.
|
||||||
|
- Establishes local actor indexes: owner `0`, Alice `1`, Bob `2` from Anvil’s standard development mnemonic.
|
||||||
|
|
||||||
|
- [ ] Write `ScriptPreflight.t.sol` around a small public harness for `DemoScript` and cover:
|
||||||
|
|
||||||
|
- chain IDs `31337` and `84532` accepted;
|
||||||
|
- chain IDs `1`, `8453`, and an arbitrary value rejected before broadcast;
|
||||||
|
- missing/invalid manifest, wrong manifest chain, zero address, and address-without-code rejected;
|
||||||
|
- internal pending manifests may use deployment block `0`, while active manifests may not;
|
||||||
|
- local keys derivable only when chain ID is `31337`;
|
||||||
|
- serialized JSON contains public addresses but never the mnemonic, a private key, `PRIVATE_KEY`, or `MNEMONIC`.
|
||||||
|
|
||||||
|
- [ ] Run red:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
forge test --match-path test/ScriptPreflight.t.sol -vvv --force
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected red: `DemoScript` does not exist.
|
||||||
|
|
||||||
|
- [ ] Implement `DemoScript` constants and guards:
|
||||||
|
|
||||||
|
```solidity
|
||||||
|
uint256 internal constant ANVIL_CHAIN_ID = 31337;
|
||||||
|
uint256 internal constant BASE_SEPOLIA_CHAIN_ID = 84532;
|
||||||
|
string internal constant ANVIL_TEST_PHRASE =
|
||||||
|
"test test test test test test test test test test test junk";
|
||||||
|
string internal constant PENDING_MANIFEST_PATH = "deployments/pending.json";
|
||||||
|
string internal constant ACTIVE_MANIFEST_PATH = "deployments/active.json";
|
||||||
|
|
||||||
|
error UnsupportedChain(uint256 chainId);
|
||||||
|
error ManifestChainMismatch(uint256 expected, uint256 actual);
|
||||||
|
error MissingCode(string label, address target);
|
||||||
|
```
|
||||||
|
|
||||||
|
Provide narrow helpers for chain checks, `vm.deriveKey` on local only, manifest read/write through `vm.parseJson*`/`vm.serialize*`, code checks, and exact-state assertions. Resolve the input/output manifest from `DEPLOYMENT_MANIFEST_PATH` with a narrow default appropriate to each script. Never log or serialize a derived private key.
|
||||||
|
|
||||||
|
- [ ] Write `DeployV1.s.sol` red tests/behavior first, then implement:
|
||||||
|
|
||||||
|
1. validate chain and resolve the public `SCRIPT_SENDER` value;
|
||||||
|
2. start broadcast;
|
||||||
|
3. deploy `MockUSDC(sender)`;
|
||||||
|
4. call `Upgrades.deployUUPSProxy("BankV1.sol:BankV1", abi.encodeCall(...))`;
|
||||||
|
5. stop broadcast;
|
||||||
|
6. assert code, owner, asset, version `1`, and implementation identity;
|
||||||
|
7. write `deployments/pending.json` with schema `1`, network, chain, deployment block `0`, display RPC/explorer metadata, token, proxy, implementation, owner, and local actor labels/addresses.
|
||||||
|
|
||||||
|
On local, derive account `0`, require it equals `SCRIPT_SENDER`, and broadcast with that derived development key. On Base Sepolia, require `SCRIPT_SENDER` equals the expected owner and use the signer selected by Forge’s matching `--account`/`--sender` options; never read a raw signing secret from environment.
|
||||||
|
|
||||||
|
- [ ] Test `finalize-manifest.mjs` and `select-manifest.mjs` with temporary pending/broadcast fixtures and an injected fake JSON-RPC function. Cover: a pre-broadcast deploy guard that rejects an existing target-chain canonical file, exact proxy transaction match, successful receipt, receipt-derived block, actual chain-ID match, code at token/proxy/implementation, implementation-slot match, failed/missing/ambiguous receipt, partial broadcast, wrong chain, missing code, an existing active manifest from either network remaining untouched until selection, secret-bearing content, atomic same-directory rename, and selection of only a valid confirmed chain manifest. Prove a failure never creates/changes a confirmed or active manifest, and selecting Base preserves Anvil byte-for-byte (and vice versa).
|
||||||
|
|
||||||
|
- [ ] Implement the finalizer using Node standard modules and JSON-RPC `fetch`. Its network-free `preflight-deploy <network>` fails before Forge runs when the target canonical file exists and prints the exact safe recovery command (`make reset-local` or `make archive-base-manifest`). Deploy finalization reads `broadcast/DeployV1.s.sol/<chainId>/run-latest.json`, matches the transaction whose created address is the pending proxy, matches its transaction hash to a successful receipt, uses that receipt’s real block number, queries `eth_chainId`, `eth_getCode` for all three contracts, and queries the EIP-1967 implementation slot. Atomically write the chain’s canonical `anvil.json` or `base-sepolia.json` only after all checks pass. `select-manifest.mjs` validates a named canonical file and atomically copies it to `active.json`; it never deletes or overwrites the other chain. Never copy a credential-bearing terminal RPC into JSON.
|
||||||
|
|
||||||
|
- [ ] Write and implement `SeedV1Demo.s.sol`, hard-guarded to chain `31337`. It must perform and assert this exact sequence:
|
||||||
|
|
||||||
|
```text
|
||||||
|
owner mints Alice 2,000 mUSDC
|
||||||
|
owner mints Bob 1,000 mUSDC
|
||||||
|
Alice approves and deposits 1,000 mUSDC
|
||||||
|
Bob approves and deposits 500 mUSDC
|
||||||
|
Alice withdraws 100 mUSDC
|
||||||
|
```
|
||||||
|
|
||||||
|
Postconditions: Alice internal `900e6`, Bob internal `500e6`, liabilities `1_400e6`, reserves `1_400e6`, version `1`, no surplus.
|
||||||
|
|
||||||
|
- [ ] Write and implement `CheckState.s.sol`. Always print network, block, token, proxy, implementation, owner, pause state, version, each configured actor balance, reserves, liabilities, and surplus. Always fail on `reserves < liabilities`, implementation/manifest mismatch, or manifest chain mismatch. `DEMO_EXPECTED_STAGE=deployed` asserts version `1` and empty accounting; `v1` asserts exact Act 1 values; `invariants` checks network-independent invariants only. Task 11 adds stage `v2`.
|
||||||
|
|
||||||
|
- [ ] Add direct Make targets that do not start background processes yet:
|
||||||
|
|
||||||
|
```make
|
||||||
|
RPC_LOCAL := http://127.0.0.1:8545
|
||||||
|
ANVIL_OWNER := 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266
|
||||||
|
|
||||||
|
.PHONY: deploy-v1 seed-v1 check-state
|
||||||
|
deploy-v1:
|
||||||
|
@node tools/finalize-manifest.mjs preflight-deploy anvil
|
||||||
|
@SCRIPT_SENDER=$(ANVIL_OWNER) DEPLOYMENT_MANIFEST_PATH=deployments/pending.json npm_config_offline=true forge script script/DeployV1.s.sol:DeployV1 --rpc-url $(RPC_LOCAL) --sender $(ANVIL_OWNER) --broadcast --force
|
||||||
|
@DEPLOYMENT_MANIFEST_PATH=deployments/pending.json DEMO_EXPECTED_STAGE=deployed forge script script/CheckState.s.sol:CheckState --rpc-url $(RPC_LOCAL) --force
|
||||||
|
@node tools/finalize-manifest.mjs deploy --rpc-url $(RPC_LOCAL)
|
||||||
|
@node tools/select-manifest.mjs anvil
|
||||||
|
seed-v1:
|
||||||
|
@forge script script/SeedV1Demo.s.sol:SeedV1Demo --rpc-url $(RPC_LOCAL) --broadcast --force
|
||||||
|
check-state:
|
||||||
|
@DEMO_EXPECTED_STAGE=$${DEMO_EXPECTED_STAGE:-v1} forge script script/CheckState.s.sol:CheckState --rpc-url $(RPC_LOCAL) --force
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] Run unit/preflight green checks:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
forge fmt
|
||||||
|
forge test --match-path test/ScriptPreflight.t.sol -vvv --force
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] Run the first real local smoke test in two terminals. Terminal A:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
anvil --host 127.0.0.1 --port 8545 --chain-id 31337
|
||||||
|
```
|
||||||
|
|
||||||
|
Terminal B:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make deploy-v1
|
||||||
|
make seed-v1
|
||||||
|
DEMO_EXPECTED_STAGE=v1 make check-state
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: the exact Act 1 table prints and all commands exit `0`. Inspect identical `deployments/anvil.json` and selected `deployments/active.json`, verify their deployment block matches the confirmed proxy receipt, and verify they contain no secret material. Simulate a failed receipt fixture and confirm the finalizer changes neither confirmed nor active files.
|
||||||
|
|
||||||
|
- [ ] Commit:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add script test/ScriptPreflight.t.sol tools/finalize-manifest.mjs tools/select-manifest.mjs tools/test-finalize-manifest.mjs deployments/.gitkeep Makefile
|
||||||
|
git commit -m "feat: script deterministic V1 demo state"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
# 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.
|
||||||
Reference in New Issue
Block a user