docs: prepare repeatable V1 presentation
This commit is contained in:
@@ -0,0 +1,101 @@
|
||||
# Learning Guide: Custody Accounting Behind a UUPS Proxy
|
||||
|
||||
## The two addresses that make upgrades possible
|
||||
|
||||
Users call the **proxy**, whose address remains stable and whose storage contains the asset address, customer ledger, liabilities, owner, and pause state. The **implementation** contains executable logic. The proxy forwards each application call with `delegatecall`: implementation code runs in the proxy's context, so `address(this)` is the proxy and reads/writes affect proxy storage. Calling the implementation directly is not equivalent and is never the supported application path.
|
||||
|
||||
UUPS places upgrade authorization in the implementation. OpenZeppelin's proxy-context checks ensure upgrade entry points run only through a compatible proxy; `BankV1._authorizeUpgrade` then restricts authorization to the owner. This keeps the proxy small, but makes implementation correctness and storage compatibility critical.
|
||||
|
||||
## Initializers replace constructor state
|
||||
|
||||
A normal implementation constructor changes only the implementation's own storage. It cannot initialize the proxy storage used by delegated calls. `initialize(asset, initialOwner)` therefore performs the one-time proxy setup and initializes ownership and pausing. The encoded initializer runs atomically when the proxy is created, avoiding an uninitialized-proxy takeover window.
|
||||
|
||||
The `BankV1` constructor calls `_disableInitializers()`. That locks the standalone implementation so an outsider cannot initialize it and create a misleading or dangerous separately owned instance. The narrow constructor annotation tells the OpenZeppelin validator why this constructor is intentional; it does not bypass storage-layout or UUPS compatibility checks. Double initialization of the proxy and direct initialization of the implementation both revert with `InvalidInitialization`.
|
||||
|
||||
## Storage layout is an API
|
||||
|
||||
Delegate calls interpret numbered storage slots according to the current implementation. A compatible future implementation preserves every existing declaration in its original order and consumes reserved gap space only when new state is truly required.
|
||||
|
||||
Safe conceptual extension:
|
||||
|
||||
```solidity
|
||||
IERC20 internal _asset; // unchanged slot
|
||||
mapping(address => uint256) internal _balances; // unchanged slot
|
||||
uint256 internal _totalLiabilities; // unchanged slot
|
||||
uint256 internal _newValue; // consumes reserved space
|
||||
uint256[46] private __gap;
|
||||
```
|
||||
|
||||
Unsafe conceptual extension:
|
||||
|
||||
```solidity
|
||||
uint256 internal _totalLiabilities; // reordered: corrupts interpretation
|
||||
IERC20 internal _asset;
|
||||
mapping(address => uint256) internal _balances;
|
||||
```
|
||||
|
||||
Changing order, type, inheritance order, or removing state can make balances appear as addresses or overwrite control data. A bad implementation can also remove working upgrade machinery and permanently brick future upgrades. Layout validation is necessary, but authorization and implementation behavior still require review.
|
||||
|
||||
## Reserves, liabilities, and surplus
|
||||
|
||||
**Reserves** are `MockUSDC.balanceOf(proxy)`: tokens actually held by the proxy. **Liabilities** are `totalLiabilities()`: the sum the ledger owes customers. **Surplus** is reserves minus liabilities. The solvency rule is:
|
||||
|
||||
```text
|
||||
reserves >= total liabilities
|
||||
```
|
||||
|
||||
Equality holds in the prepared Act 1 state. Anyone can transfer mock tokens directly to the proxy without receiving ledger credit, so a surplus is possible and the invariant deliberately uses `>=`.
|
||||
|
||||
## Exact V1 money flows
|
||||
|
||||
For `deposit(amount)`, the bank rejects zero, paused, or reentrant calls; reads reserves; uses `SafeERC20.safeTransferFrom`; measures the exact received delta; and only then credits the sender's internal balance and total liabilities. A fee-on-transfer or otherwise unexpected asset delta reverts the entire transaction.
|
||||
|
||||
For `withdraw(amount)`, the bank rejects zero, paused, reentrant, or underfunded ledger calls. It debits the customer's internal balance and total liabilities before `SafeERC20.safeTransfer` sends tokens. That ordering is checks-effects-interactions: validate first, commit internal effects second, interact externally last. `nonReentrant` adds a second boundary against a malicious token callback. A revert from the token rolls the whole transaction back.
|
||||
|
||||
`SafeERC20` handles ERC-20 implementations that return `false`, omit return values, or revert in different ways. Pausing gives the owner an emergency stop for deposits and withdrawals while views remain available. There is intentionally no owner reserve sweep.
|
||||
|
||||
An internal V2 customer transfer, when implemented during the presentation, moves ledger balances only. It must not move ERC-20 reserves or change aggregate liabilities.
|
||||
|
||||
## The owner is a central trust assumption
|
||||
|
||||
The owner may pause customer actions and authorize an implementation containing arbitrary future logic. Tests proving today's V1 behavior cannot constrain tomorrow's authorized implementation. Multisig/timelocked governance and operational controls are absent from this educational V1.
|
||||
|
||||
> **Trust boundary:** MockUSDC has no value. These contracts are educational and unaudited; real deposits must never be sent here. The owner can pause customer actions and install arbitrary future logic. UUPS mistakes can corrupt state or permanently brick upgradeability. A real custody product requires professional audits, operational key controls, multisig or timelocked governance, incident procedures, legal advice, and jurisdiction-specific compliance work.
|
||||
|
||||
## Local exercises: observe the named failures
|
||||
|
||||
These commands run deterministic tests and do not require a wallet or public RPC. Add `-vvvv` to inspect a revert trace.
|
||||
|
||||
```bash
|
||||
# BankV1.InvalidAsset
|
||||
forge test --match-test testInitializationChecksZeroAssetBeforeZeroOwner -vv
|
||||
|
||||
# BankV1.ZeroAmount on both customer paths
|
||||
forge test --match-test 'test(Deposit|Withdraw)RejectsZeroAmount' -vv
|
||||
|
||||
# BankV1.InsufficientBalance with available/requested values
|
||||
forge test --match-test testWithdrawReportsAvailableAndRequestedOnInsufficientInternalBalance -vv
|
||||
|
||||
# BankV1.UnexpectedAssetDelta with a fee-taking token
|
||||
forge test --match-test testFeeOnTransferDepositRevertsAndRollsBackTokenAndAccounting -vv
|
||||
|
||||
# OpenZeppelin EnforcedPause
|
||||
forge test --match-test 'test(Deposit|Withdraw)RejectsCallsWhilePaused' -vv
|
||||
|
||||
# OpenZeppelin OwnableUnauthorizedAccount
|
||||
forge test --match-test 'testNonOwnerCannot(Pause|Unpause|AuthorizeUpgrade)' -vv
|
||||
|
||||
# OpenZeppelin InvalidInitialization
|
||||
forge test --match-test 'test(ProxyCannotBeInitializedTwice|ImplementationCannotBeInitialized)' -vv
|
||||
|
||||
# OpenZeppelin ReentrancyGuardReentrantCall
|
||||
forge test --match-test testDepositPropagatesNestedRevertAtomicallyWhenConfigured -vv
|
||||
|
||||
# Script UnsupportedChain (the test deliberately uses a rejected chain)
|
||||
forge test --match-test testUnsupportedChainsAreRejectedBeforeBroadcast -vv
|
||||
|
||||
# ManifestChainMismatch and MissingCode
|
||||
forge test --match-test 'test(WrongManifestChain|AddressWithoutCode)IsRejected' -vv
|
||||
```
|
||||
|
||||
Finish by running `make verify`; it combines unit, fuzz, invariant, script, process-safety, scanner, and web gates.
|
||||
@@ -0,0 +1,87 @@
|
||||
# Presenter Runbook: Prepared V1 and Live UUPS Upgrade
|
||||
|
||||
## Preflight and rehearsal
|
||||
|
||||
Rehearse once from a clean disposable branch created at the `demo-start` tag. Allow 25 minutes: 3 minutes for preflight, 5 for Act 1, 10 for the Codex change and verification, 4 for Act 3, and 3 for questions. Keep two terminals visible and open the browser only after Vite reports ready.
|
||||
|
||||
Before the audience arrives:
|
||||
|
||||
```bash
|
||||
make setup
|
||||
make reset-local
|
||||
make doctor
|
||||
make verify
|
||||
git diff --check
|
||||
git status --short
|
||||
```
|
||||
|
||||
The doctor must show Foundry `1.7.1`, Node `24.18.0`, npm `11.17.0`, initialized dependencies, writable runtime paths, and free ports `8545`/`5173`. Verification must exit `0`. No upgrade or transfer command runs until `make verify` passes.
|
||||
|
||||
## The three-act story
|
||||
|
||||
### Act 1 — Prepared V1 establishes trust
|
||||
|
||||
First terminal:
|
||||
|
||||
```bash
|
||||
make demo-local
|
||||
```
|
||||
|
||||
Second terminal:
|
||||
|
||||
```bash
|
||||
DEMO_EXPECTED_STAGE=v1 make check-state
|
||||
curl --fail http://127.0.0.1:5173/
|
||||
```
|
||||
|
||||
Open `http://127.0.0.1:5173/`; Anvil is at `http://127.0.0.1:8545`. Call out that every browser read targets the proxy, while the distinct implementation address is shown only to teach delegation. The exact expected state is Alice `900 mUSDC`, Bob `500 mUSDC`, liabilities `1,400 mUSDC`, reserves `1,400 mUSDC`, surplus `0`, and version `1`. Point to deposit, withdrawal, and upgrade/ownership events, then state that the browser is read-only.
|
||||
|
||||
Explain the initial flow: scripts minted 2,000 mUSDC to Alice and 1,000 to Bob; Alice deposited 1,000 and withdrew 100; Bob deposited 500. The stable proxy holds reserves and storage. Implementation code runs against that storage with `delegatecall`.
|
||||
|
||||
### Act 2 — Codex changes the system live
|
||||
|
||||
Give Codex this approved prompt verbatim:
|
||||
|
||||
> Add `BankV2` with customer-to-customer internal transfers. Preserve the UUPS storage layout and all V1 behavior. Add unit, fuzz, invariant, and upgrade-regression tests; an owner upgrade script; a scripted Alice-to-Bob transfer; exported ABI support; and the read-only console updates needed to show V2 and its transfer event. Explain each security decision. Do not perform an upgrade until all verification passes.
|
||||
|
||||
Ask Codex to show the diff and explain storage compatibility, authorization, conservation, error paths, and why the browser remains read-only. The expected prepared change adds transfer-aware source, tests, scripts, ABI output, and console rendering without changing V1 storage. Run:
|
||||
|
||||
```bash
|
||||
make verify
|
||||
```
|
||||
|
||||
If any gate fails, stop and let Codex diagnose it. Do not run an upgrade merely because a partial test command passed.
|
||||
|
||||
### Act 3 — V2 proves continuity
|
||||
|
||||
After Codex has implemented these targets and the full gate has passed:
|
||||
|
||||
```bash
|
||||
make upgrade-v2
|
||||
make demo-transfer
|
||||
make check-state
|
||||
```
|
||||
|
||||
The expected result is the same proxy and a new implementation, version `2`, Alice `650 mUSDC`, Bob `750 mUSDC`, liabilities `1,400 mUSDC`, reserves `1,400 mUSDC`, and a decoded 250 mUSDC internal transfer event. No ERC-20 transfer should accompany the ledger transfer. Refresh only if the console has not observed the next block; otherwise let its live status prove the change.
|
||||
|
||||
## Recovery without broad cleanup
|
||||
|
||||
- **Occupied port:** `make demo-local` refuses to claim either port. Use `curl http://127.0.0.1:8545` and `curl http://127.0.0.1:5173/` plus your operating system's process inspection to identify the external owner. Stop it yourself only after proving ownership; the project never uses broad process matching.
|
||||
- **Recorded stale process:** run `make reset-local`. It validates numeric PID, process group, command signature, and Linux start tick before signaling. A mismatched live process is preserved and reset exits nonzero.
|
||||
- **Stale console:** confirm `DEMO_EXPECTED_STAGE=v1 make check-state`, inspect `.demo/vite.log`, and use `curl --fail http://127.0.0.1:5173/`. The console labels stale/disconnected state and preserves the last good snapshot rather than inventing zeros.
|
||||
- **Failed test or live edit:** do not upgrade. Save the diff for review, continue diagnosis on the disposable live branch, or start a new disposable branch from `demo-start`; never overwrite unrelated work. The prepared V1 remains the successful ending if time expires.
|
||||
- **Unexpected child exit:** the attached launcher stops its other validated group. Inspect `.demo/anvil.log` and `.demo/vite.log`, then run `make reset-local` and restart.
|
||||
|
||||
End the local session with Ctrl-C in the attached terminal, then `make reset-local`. The optional public encore is outside this prepared V1 run: local success does not depend on Base Sepolia, a faucet, an explorer, a wallet, or any external RPC.
|
||||
|
||||
## Closing trust disclosure checklist
|
||||
|
||||
Read these points while the matching console panel is visible:
|
||||
|
||||
- MockUSDC has no value.
|
||||
- These contracts are educational and unaudited; real deposits must never be sent here.
|
||||
- The owner can pause customer actions and install arbitrary future logic.
|
||||
- UUPS mistakes can corrupt state or permanently brick upgradeability.
|
||||
- A real custody product requires professional audits, operational key controls, multisig or timelocked governance, incident procedures, legal advice, and jurisdiction-specific compliance work.
|
||||
|
||||
Codex supplies the cross-stack implementation, tests, orchestration, and explanation. OpenZeppelin supplies reviewed contract primitives and upgrade validation; Foundry supplies compilation, tests, scripts, and the local chain. None of those tools turns this teaching artifact into an audited or regulated custody product.
|
||||
Reference in New Issue
Block a user