From d29dc86383de5860d42c72cc8ff51d96eb192459 Mon Sep 17 00:00:00 2001 From: golem Date: Sat, 22 Aug 2026 21:00:47 -0600 Subject: [PATCH] docs: finish UUPS bank demo guide --- README.md | 51 ++++++++---- docs/LEARNING_GUIDE.md | 161 +++++++++++++++++++++++++++----------- docs/PRESENTER_RUNBOOK.md | 119 +++++++++++++++++++++------- 3 files changed, 241 insertions(+), 90 deletions(-) diff --git a/README.md b/README.md index 6c97164..144b7ce 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,8 @@ -# UUPS Bank V1 Demo +# UUPS Bank V1 → V2 Demo > Educational demo — mock token — never use real funds. -This repository is the prepared starting point for a live upgradeability lesson. It deploys a local V1 bank that custodies a six-decimal mock ERC-20, records customer balances behind an ERC-1967 proxy, and presents the result in a read-only operations console. +This repository is a local-first upgradeability lesson. It deploys a six-decimal mock ERC-20 and a V1 custody ledger behind an ERC-1967 proxy, upgrades the proxy to V2 without losing state, performs a customer-to-customer ledger transfer, and presents the result in a read-only operations console. > **Trust boundary:** MockUSDC has no value. These contracts are educational and unaudited; real deposits must never be sent here. The owner can pause customer actions and install arbitrary future logic. UUPS mistakes can corrupt state or permanently brick upgradeability. A real custody product requires professional audits, operational key controls, multisig or timelocked governance, incident procedures, legal advice, and jurisdiction-specific compliance work. @@ -19,46 +19,63 @@ make doctor ## Ten-minute local quick start +This complete path is local. It needs no Base Sepolia service, browser wallet, faucet, explorer, public RPC, keystore, secret, or real funds. + In the first terminal: ```bash make demo-local ``` -The command performs a scoped reset, starts a deterministic Anvil chain at `http://127.0.0.1:8545`, deploys and seeds V1, checks its exact state, exports public artifacts, and starts the console at `http://127.0.0.1:5173/`. It remains attached so Ctrl-C safely stops only the recorded project process groups. +The command performs a scoped reset, starts deterministic Anvil at `http://127.0.0.1:8545`, deploys and seeds V1, checks its exact state, exports public artifacts, and starts the console at `http://127.0.0.1:5173/`. It remains attached so Ctrl-C safely stops only the recorded project process groups. In a second terminal: ```bash DEMO_EXPECTED_STAGE=v1 make check-state +make upgrade-v2 +make demo-transfer +DEMO_EXPECTED_STAGE=v2 make check-state curl --fail http://127.0.0.1:5173/ ``` -The state check proves Alice has `900 mUSDC`, Bob has `500 mUSDC`, liabilities and reserves are both `1,400 mUSDC`, and the contract version is `1`. After Ctrl-C in the first terminal, run `make reset-local` to remove reproducible local state. +Act 1 proves Alice has `900 mUSDC`, Bob has `500 mUSDC`, liabilities and reserves are both `1,400 mUSDC`, and version is `1`. The upgrade keeps the proxy, owner, asset, pause state, customer balances, liabilities, reserves, surplus, and deployment block unchanged while changing the implementation and version to `2`. The internal `250 mUSDC` transfer then leaves Alice with `650 mUSDC` and Bob with `750 mUSDC`; liabilities and reserves remain `1,400 mUSDC`, and the console decodes `BalanceTransferred` without an ERC-20 reserve transfer. -## Architecture +Stop the attached first terminal with Ctrl-C, then remove only reproducible local state: -Foundry scripts are the state-changing control plane; the browser never signs. `MockUSDC` holds no value. An ERC-1967 proxy keeps the bank address and storage stable while delegating calls to `BankV1`. The proxy itself holds token reserves and the internal ledger records liabilities. A confirmed public deployment manifest and generated ABI connect that on-chain system to a React/Vite console using viem and wagmi for read-only, block-consistent state and event display. +```bash +make reset-local +``` + +## Architecture and guarantees + +Foundry scripts are the state-changing control plane; the browser has no connector, signer, transaction client, or write control. `MockUSDC` holds no value. An ERC-1967 proxy keeps the bank address and storage stable while delegating calls to `BankV1` or `BankV2`. The proxy holds token reserves and the internal ledger records liabilities. Confirmed public manifests and generated ABIs connect that system to the React/Vite console through viem and wagmi. + +OpenZeppelin supplies the UUPS and access-control primitives plus storage/upgrade validation. Foundry supplies compilation, tests, scripts, and the local chain. The project supplies the application logic, tests, manifests, lifecycle tooling, and UI. Passing those checks does not make the demo audited, decentralized, or suitable for custody. ## Command reference -- `make doctor` — read-only prerequisite, dependency, directory, and port checks. +- `make doctor` — check prerequisites, dependencies, runtime locations, configuration, and local ports without changing chain state. - `make setup` — initialize pinned submodules and npm dependencies. -- `make demo-local` — run the complete attached V1 experience. -- `make verify` — run formatting, clean build, artifact checks, upgrade CLI check, Solidity tests, script tests, process tests, project scan, web lint/typecheck/tests, and production build. -- `make check-state` — validate and print the active local V1 state at `http://127.0.0.1:8545`. +- `make demo-local` — run the attached local V1 experience. +- `make verify` — run Solidity format/build/unit/fuzz/invariant/upgrade gates, artifact and shell-safety tests, the project scanner, and web lint/type/tests/build. +- `make check-state` — print and validate the selected stage; use `DEMO_EXPECTED_STAGE=v1` or `v2` for the exact local acts. +- `make upgrade-v2` — validate and perform the owner-authorized local V1-to-V2 upgrade, finalize its manifest, and refresh exported artifacts. +- `make demo-transfer` — perform Alice's local `250 mUSDC` internal transfer to Bob and validate Act 3. - `make reset-local` — validate recorded process identity, stop only owned groups, and remove only known local artifacts. -- `make deploy-v1` — lower-level guarded V1 deployment and manifest finalization. -- `make seed-v1` — lower-level deterministic Act 1 deposits and withdrawal. -- `make sync-artifacts` — regenerate the ABI module and publish the confirmed active manifest. -- `make sync-artifacts-check` — test generation and prove generated ABIs are current. +- `make deploy-v1` and `make seed-v1` — run the lower-level guarded V1 deployment and deterministic Act 1 setup used by `make demo-local`. +- `make sync-artifacts`, `make sync-artifacts-check`, `make sync-abis`, and `make publish-web-manifest` — regenerate or validate the ABI bridge and publish only the confirmed active manifest. +- `make test-finalize-manifest` — exercise manifest finalization in isolation. +- `make select-anvil` and `make select-base-sepolia` — explicitly select and republish an existing confirmed manifest. +- `make archive-base-manifest` — safely archive the Base canonical manifest before an intentional redeployment. +- `make deploy-base-sepolia`, `make upgrade-base-sepolia`, and `make transfer-base-sepolia` — explicit optional-testnet commands with chain, actor, account, and RPC guards. ## Optional Base Sepolia encore -The local lesson is complete without a wallet, faucet, explorer, or public RPC. An optional Base Sepolia encore is available only after the local V1→V2 demo and `make verify` succeed. It uses a named encrypted Foundry keystore through `--account` plus the same public address through `--sender`; there is no private-key or mnemonic fallback. +The local lesson above is complete without a wallet, faucet, explorer, or public RPC. The optional Base Sepolia encore is separate and runs only after the local V1→V2 demo and `make verify` succeed. It uses a named encrypted Foundry keystore through `--account` and the same public address through `--sender`; no supported command accepts a raw private key or mnemonic. -Copy `.env.example` to `.env` and fill only its public configuration. `BASE_SEPOLIA_RPC_URL` is the terminal endpoint and may be credentialed; `BASE_SEPOLIA_PUBLIC_RPC_URL` is intentionally public and is the only RPC serialized for the browser. Foundry requests the keystore password interactively, and the password never belongs in `.env`. +Copy `.env.example` to `.env` and fill only its public configuration. `BASE_SEPOLIA_RPC_URL` is the terminal endpoint and may be credentialed; `BASE_SEPOLIA_PUBLIC_RPC_URL` is intentionally public and is the only RPC serialized for the browser. Foundry requests the keystore password interactively, and that password never belongs in `.env`. -Use `make archive-base-manifest` before an intentional Base redeployment. The command moves only the Base canonical manifest to a timestamped sibling; it preserves the active browser fallback and all Anvil state. `make select-anvil` and `make select-base-sepolia` explicitly switch the active manifest and republish the browser copy. See the presenter runbook for the exact wallet onboarding, funding boundary, deploy/upgrade/transfer sequence, and recovery rules. +Use `make archive-base-manifest` before an intentional Base redeployment. It moves only the Base canonical manifest to a timestamped sibling while preserving the active browser fallback and Anvil state. See the [presenter runbook](docs/PRESENTER_RUNBOOK.md) for the exact optional onboarding, funding boundary, execution sequence, and recovery rules. Continue with the [learning guide](docs/LEARNING_GUIDE.md) or rehearse from the [presenter runbook](docs/PRESENTER_RUNBOOK.md). diff --git a/docs/LEARNING_GUIDE.md b/docs/LEARNING_GUIDE.md index 138ad58..e935bf5 100644 --- a/docs/LEARNING_GUIDE.md +++ b/docs/LEARNING_GUIDE.md @@ -1,76 +1,153 @@ # Learning Guide: Custody Accounting Behind a UUPS Proxy +> Educational demo — mock token — never use real funds. + ## 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. +Users and scripts call the **proxy**, whose address remains stable and whose storage contains the asset address, customer ledger, liabilities, owner, and pause state. The **implementation** contains executable logic. The proxy forwards each application call with `delegatecall`: implementation code runs in the proxy's context, so `address(this)` is the proxy and all application reads and writes affect proxy storage. Calling the implementation directly is not an application call. -UUPS places upgrade authorization in the implementation. OpenZeppelin's proxy-context checks ensure upgrade entry points run only through a compatible proxy; `BankV1._authorizeUpgrade` then restricts authorization to the owner. This keeps the proxy small, but makes implementation correctness and storage compatibility critical. +UUPS places upgrade authorization in the implementation. OpenZeppelin's proxy-context checks ensure upgrade entry points run only through a compatible proxy; `BankV1._authorizeUpgrade` then restricts authorization to the owner. This keeps the proxy small, but makes implementation correctness, storage compatibility, and owner security critical. ## Initializers replace constructor state -A normal implementation constructor changes only the implementation's own storage. It cannot initialize the proxy storage used by delegated calls. `initialize(asset, initialOwner)` therefore performs the one-time proxy setup and initializes ownership and pausing. The encoded initializer runs atomically when the proxy is created, avoiding an uninitialized-proxy takeover window. +A normal implementation constructor changes only the implementation's own storage. It cannot initialize the proxy storage used by delegated calls. `initialize(asset, initialOwner)` therefore performs the one-time proxy setup and initializes ownership and pausing. Its public visibility lets the pinned OpenZeppelin upgrades-core tooling recognize the initializer as inherited by `BankV2`; the `initializer` modifier still permits exactly one proxy initialization. The encoded initializer runs atomically when the proxy is created, avoiding an uninitialized-proxy takeover window. -The `BankV1` constructor calls `_disableInitializers()`. That locks the standalone implementation so an outsider cannot initialize it and create a misleading or dangerous separately owned instance. The narrow constructor annotation tells the OpenZeppelin validator why this constructor is intentional; it does not bypass storage-layout or UUPS compatibility checks. Double initialization of the proxy and direct initialization of the implementation both revert with `InvalidInitialization`. +The `BankV1` constructor calls `_disableInitializers()`. That locks the standalone implementation so an outsider cannot initialize it and create a misleading or dangerous separately owned instance. The narrow constructor annotation tells the OpenZeppelin validator why this constructor is intentional; it does not bypass storage-layout, missing-initializer, or UUPS compatibility checks. Double initialization of the proxy and direct initialization of either implementation revert with `InvalidInitialization`. -## Storage layout is an API +`BankV2` needs no initializer or reinitializer because the V1 proxy is already initialized and V2 adds behavior only. It inherits the existing asset, owner, pause state, customer balances, liabilities, and transient reentrancy guard. Adding an initializer when there is no new state would create an unnecessary privileged transition and another state to reason about. -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. +## Storage layout is a permanent API -Safe conceptual extension: +Delegate calls interpret storage according to the active implementation's layout. Alongside OpenZeppelin's namespaced base-contract state, the actual V1 application fields occupy these slots in source order: -```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; +```text +BankV1 proxy application storage +├─ slot 0: _asset: IERC20 +├─ slot 1: _balances: mapping(address => uint256) +├─ slot 2: _totalLiabilities: uint256 +└─ slots 3…49: __gap: uint256[47] ``` -Unsafe conceptual extension: +OpenZeppelin upgradeable ownership and pausing use their own namespaced storage. `ReentrancyGuardTransient` uses transient storage rather than adding an initialized persistent field. Those implementation details do not make the application's declaration order optional. -```solidity -uint256 internal _totalLiabilities; // reordered: corrupts interpretation -IERC20 internal _asset; -mapping(address => uint256) internal _balances; +V2 is safe because inheritance preserves that layout byte-for-byte and V2 declares no state: + +```text +BankV2 is BankV1 +├─ slot 0: _asset: IERC20 unchanged +├─ slot 1: _balances: mapping(address => uint256) unchanged +├─ slot 2: _totalLiabilities: uint256 unchanged +├─ slots 3…49: __gap: uint256[47] unchanged +└─ no V2 storage variables ``` -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. +A conceptual future revision could consume one reserved word only by carefully changing the layout in the base contract that declares the gap and validating every descendant against the prior version: + +```text +Conceptual validated revision of the declaring base layout +├─ slot 0: _asset: IERC20 +├─ slot 1: _balances: mapping(address => uint256) +├─ slot 2: _totalLiabilities: uint256 +├─ slot 3: _newValue: uint256 +└─ slots 4…49: __gap: uint256[46] +``` + +Simply declaring a new `BankV2` field would append it after the already inherited `__gap`; it would not consume that gap. This demo avoids that ambiguity entirely because the actual `BankV2` declares no state. Reordering existing declarations is unsafe: + +```text +Unsafe layout +├─ slot 0: _totalLiabilities: uint256 moved into _asset's old slot +├─ slot 1: _asset: IERC20 moved into the mapping's old slot +├─ slot 2: _balances: mapping(address => uint256) moved into liabilities' old slot +└─ slots 3…49: __gap: uint256[47] +``` + +Changing order, type, inheritance order, or removing state can make balances appear as addresses, overwrite accounting, or damage control state. A bad implementation can also remove working upgrade machinery and brick future upgrades. + +`BankV2` declares `@custom:oz-upgrades-from src/BankV1.sol:BankV1`. The upgrade script sets `BankV1` as the reference contract and calls `Upgrades.validateUpgrade` before broadcasting. Foundry emits AST, build information, and storage layouts; the OpenZeppelin validator compares the inheritance and storage layouts and checks UUPS compatibility. The validation uses no storage or UUPS bypass. It is a compatibility gate, not a business-logic audit: it cannot prove that a future owner-authorized implementation is honest, solvent, or correctly governed. ## Reserves, liabilities, and surplus -**Reserves** are `MockUSDC.balanceOf(proxy)`: tokens actually held by the proxy. **Liabilities** are `totalLiabilities()`: the sum the ledger owes customers. **Surplus** is reserves minus liabilities. The solvency rule is: +**Reserves** are `MockUSDC.balanceOf(proxy)`: tokens actually held by the proxy. **Liabilities** are `totalLiabilities()`: the aggregate amount the ledger owes customers. **Surplus** is reserves minus liabilities. The solvency rule is: ```text reserves >= total liabilities ``` -Equality holds in the prepared 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 +Equality holds in the prepared local states. Anyone can transfer mock tokens directly to the proxy without receiving ledger credit, so a surplus is possible and the invariant deliberately uses `>=`. For `deposit(amount)`, the bank rejects zero, paused, or reentrant calls; reads reserves; uses `SafeERC20.safeTransferFrom`; measures the exact received delta; and only then credits the sender's internal balance and total liabilities. A fee-on-transfer or otherwise unexpected asset delta reverts the entire transaction. -For `withdraw(amount)`, the bank rejects zero, paused, reentrant, or underfunded ledger calls. It debits the customer's internal balance and total liabilities before `SafeERC20.safeTransfer` sends tokens. That ordering is checks-effects-interactions: validate first, commit internal effects second, interact externally last. `nonReentrant` adds a second boundary against a malicious token callback. A revert from the token rolls the whole transaction back. +For `withdraw(amount)`, the bank rejects zero, paused, reentrant, or underfunded ledger calls. It debits the customer's internal balance and total liabilities before `SafeERC20.safeTransfer` sends tokens. That ordering is checks-effects-interactions: validate first, commit internal effects second, interact externally last. `nonReentrant` adds a second boundary against a malicious token callback. A token revert rolls the whole transaction back. -`SafeERC20` handles ERC-20 implementations that return `false`, omit return values, or revert in different ways. Pausing gives the owner an emergency stop for deposits and withdrawals while views remain available. There is intentionally no owner reserve sweep. +`SafeERC20` handles ERC-20 implementations that return `false`, omit return values, or revert in different ways. Pausing gives the owner an emergency stop for deposits, withdrawals, and V2 internal transfers while views remain available. There is intentionally no owner reserve sweep. -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. +## V2 transfer conservation proof -## The owner is a central trust assumption +`transferBalance(recipient, amount)` validates the active pause state, a nonzero amount, a nonzero recipient distinct from the sender, and sufficient sender balance. It then debits the sender, credits the recipient, and emits `BalanceTransferred(from, to, amount)`. It makes no external call, so it needs no reentrancy guard and cannot move ERC-20 tokens. -The 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. +The prepared Act 3 transfers `250e6` base units (`250.000000 mUSDC`) from Alice to Bob: + +| Quantity | Before | Delta | After | +|---|---:|---:|---:| +| Alice ledger balance | `900e6` | `-250e6` | `650e6` | +| Bob ledger balance | `500e6` | `+250e6` | `750e6` | +| Tracked balance sum | `1_400e6` | `0` | `1_400e6` | +| Total liabilities | `1_400e6` | `0` | `1_400e6` | +| Proxy token reserves | `1_400e6` | `0` | `1_400e6` | +| Surplus | `0` | `0` | `0` | + +Conservation follows directly from subtracting and adding the same `amount`: `(Alice - amount) + (Bob + amount) = Alice + Bob`. The `BalanceTransferred` log proves the ledger operation. The absence of a MockUSDC `Transfer` log in that transaction, together with equal before/after proxy reserves, proves that no reserve token moved. + +## What the upgrade snapshot proves + +Before broadcasting, `UpgradeV2` records the proxy, old implementation, owner, asset, pause state, every manifest actor balance, liabilities, reserves, surplus, deployment block, and version. After the OpenZeppelin-validated owner upgrade, it takes the same snapshot and requires: + +- the proxy, owner, asset, pause state, actor count and balances, liabilities, reserves, surplus, and deployment block to be identical; +- the implementation address to differ; +- the implementation slot and manifest to identify the new implementation; and +- `contractVersion()` to change from `1` to `2`. + +The finalizer independently matches the successful broadcast and `Upgraded(newImplementation)` log, reads live proxy and token state, and only then atomically updates the confirmed manifests. Thus the upgrade proof covers both storage continuity and the identity of the logic now serving the stable proxy. + +## The owner is a central threat + +The owner can pause every customer mutation and authorize an implementation containing arbitrary future logic. A malicious or compromised owner could install code that changes balances, transfers reserves, removes checks, breaks storage, or prevents later upgrades. Tests proving today's V1 and V2 behavior cannot constrain tomorrow's authorized implementation. + +The demo deliberately uses one owner and has no multisig, timelock, role separation, upgrade delay, monitoring service, emergency governance process, or audited deployment procedure. OpenZeppelin's `onlyOwner` check proves that the configured owner authorized an upgrade; it does not prove the owner made a safe decision. A production threat model must protect the key, constrain and review upgrade proposals, make changes observable, plan incident response, and address legal and regulatory obligations. > **Trust boundary:** MockUSDC has no value. These contracts are educational and unaudited; real deposits must never be sent here. The owner can pause customer actions and install arbitrary future logic. UUPS mistakes can corrupt state or permanently brick upgradeability. A real custody product requires professional audits, operational key controls, multisig or timelocked governance, incident procedures, legal advice, and jurisdiction-specific compliance work. ## Local exercises: observe the named failures -These commands run deterministic tests and do not require a wallet or public RPC. Add `-vvvv` to inspect a revert trace. +These commands run deterministic tests and need no wallet or public RPC. Add `-vvvv` to inspect a revert trace. + +### Every V2 transfer failure + +```bash +# Inherited BankV1.ZeroAmount +forge test --match-test testTransferBalanceRejectsZeroAmount -vv + +# BankV2.InvalidRecipient(address) +forge test --match-test testTransferBalanceRejectsZeroRecipient -vv + +# BankV2.SelfTransfer() +forge test --match-test testTransferBalanceRejectsSenderAsRecipient -vv + +# Inherited BankV1.InsufficientBalance(account, available, requested) +forge test --match-test testTransferBalanceReportsAvailableAndRequestedWhenBalanceIsInsufficient -vv + +# OpenZeppelin EnforcedPause() +forge test --match-test testTransferBalanceRejectsCallsWhilePaused -vv +``` + +### V1, initialization, authorization, and script failures ```bash # BankV1.InvalidAsset forge test --match-test testInitializationChecksZeroAssetBeforeZeroOwner -vv -# BankV1.ZeroAmount on both customer paths +# BankV1.ZeroAmount on both V1 customer paths forge test --match-test 'test(Deposit|Withdraw)RejectsZeroAmount' -vv # BankV1.InsufficientBalance with available/requested values @@ -85,13 +162,13 @@ 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 InvalidInitialization on the proxy and both implementations +forge test --match-test 'test(ProxyCannotBeInitializedTwice|ImplementationCannotBeInitialized|NewImplementationCannotBeInitializedDirectly)' -vv # OpenZeppelin ReentrancyGuardReentrantCall forge test --match-test testDepositPropagatesNestedRevertAtomicallyWhenConfigured -vv -# DemoScript.UnsupportedChain (the test deliberately uses a rejected chain) +# DemoScript.UnsupportedChain forge test --match-test testUnsupportedChainsAreRejectedBeforeBroadcast -vv # DemoScript.ManifestChainMismatch and DemoScript.MissingCode @@ -103,20 +180,14 @@ forge test --match-test testActiveManifestRejectsDeploymentBlockZero -vv # DemoScript.InvalidManifestSchema forge test --match-test testLegacyParallelActorManifestIsRejected -vv -# DemoScript.InvalidActorConfiguration -forge test --match-test testAnvilManifestRequiresOwnerAtActorZero -vv +# DemoScript.InvalidActorConfiguration, including strict local and Base actor schemas +forge test --match-test 'test(AnvilManifestRequiresOwnerAtActorZero|BaseManifestRequiresExactPresenterRecipientActors)' -vv -# DemoScript.UnexpectedState, including label and expected/actual values -forge test --match-test testUnexpectedStateExerciseRevertsThroughAssertionBranch -vv +# DemoScript.UnexpectedState and DemoScript.UnexpectedAddress +forge test --match-test 'test(UnexpectedStateExerciseRevertsThroughAssertionBranch|CheckStateRejectsManifestImplementationMismatch)' -vv -# DemoScript.UnexpectedAddress, including expected/actual implementation addresses -forge test --match-test testCheckStateRejectsManifestImplementationMismatch -vv - -# CheckState.Insolvent with reserves below ledger liabilities -forge test --match-test testCheckStateExercisesRevertThroughInsolventAndUnknownStageBranches -vv - -# CheckState.UnknownStage through the real state checker +# CheckState.Insolvent and CheckState.UnknownStage forge test --match-test testCheckStateExercisesRevertThroughInsolventAndUnknownStageBranches -vv ``` -Finish by running `make verify`; it combines unit, fuzz, invariant, script, process-safety, scanner, and web gates. +Finish with `make verify`; it combines formatting, build, storage/upgrade validation, unit, fuzz, invariant, script, process-safety, scanner, and web gates. diff --git a/docs/PRESENTER_RUNBOOK.md b/docs/PRESENTER_RUNBOOK.md index 9ac08e0..dd89e1f 100644 --- a/docs/PRESENTER_RUNBOOK.md +++ b/docs/PRESENTER_RUNBOOK.md @@ -2,43 +2,56 @@ > Educational demo — mock token — never use real funds. -## Preflight and rehearsal +## Exact preflight -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: +Rehearse from a clean disposable branch or worktree created at the immutable `demo-start` tag. Use two terminals, keep the browser closed until Vite reports ready, and run this before the audience arrives: ```bash -make setup make reset-local +make setup 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 doctor must report Foundry `1.7.1`, Node `24.18.0`, npm `11.17.0`, initialized dependencies, writable runtime paths, and free ports `8545` and `5173`. `make verify` and `git diff --check` must exit `0`. At `demo-start`, tracked status must be clean. Do not upgrade after a partial or failed gate. -## The three-act story +## Ten-minute rehearsal + +Use this exact local timing once the dependencies are installed: + +| Time | Action | Evidence to say aloud | +|---|---|---| +| `0:00–1:00` | Run the preflight checks and start `make demo-local` in terminal 1. | Anvil is chain `31337`; Vite and Anvil are project-owned and attached. | +| `1:00–3:00` | Run the V1 state check in terminal 2 and open the console. | Stable proxy, distinct implementation, version `1`, Alice `900`, Bob `500`, reserves = liabilities = `1,400 mUSDC`. | +| `3:00–5:30` | Deliver the live Codex prompt and review the prepared Act 2 diff. | V2 inherits V1, adds no storage or initializer, and the browser remains read-only. | +| `5:30–7:00` | Point to the completed `make verify` evidence, then run `make upgrade-v2`. | All gates passed before broadcast; proxy and state remain stable while implementation and version change. | +| `7:00–9:00` | Run `make demo-transfer`, the V2 check, and the HTTP check. | Alice `650`, Bob `750`, reserves and liabilities still `1,400`; one decoded internal transfer and no token transfer. | +| `9:00–10:00` | Read the trust disclosure, Ctrl-C terminal 1, and run `make reset-local`. | The owner remains central; local cleanup targets only validated project processes and artifacts. | + +If a live Codex edit takes longer, treat this as the prepared reference rehearsal and allow a separate coding block. The verification-before-upgrade rule never changes to meet the clock. + +## Two-terminal three-act sequence ### Act 1 — Prepared V1 establishes trust -First terminal: +Terminal 1 remains attached: ```bash make demo-local ``` -Second terminal: +Terminal 2: ```bash DEMO_EXPECTED_STAGE=v1 make check-state curl --fail http://127.0.0.1:5173/ ``` -Open `http://127.0.0.1:5173/`; Anvil is at `http://127.0.0.1:8545`. Call out that every 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. +Open `http://127.0.0.1:5173/`; Anvil is at `http://127.0.0.1:8545`. Call out that every application read and write targets the proxy. The implementation address is separate and displayed only to explain delegation. The expected state is Alice `900 mUSDC`, Bob `500 mUSDC`, liabilities `1,400 mUSDC`, reserves `1,400 mUSDC`, surplus `0`, pause state `false`, and version `1`. -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`. +The scripts minted `2,000 mUSDC` to Alice and `1,000 mUSDC` to Bob; Alice deposited `1,000` and withdrew `100`; Bob deposited `500`. The stable proxy holds the reserves and application storage. Point to the deposits, withdrawal, initial ownership, and upgrade-to-V1 events, then state that the console is read-only and never signs. ### Act 2 — Codex changes the system live @@ -46,35 +59,85 @@ 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: +The bounded local Act 2 diff from `demo-start` consists of these files: + +- Contracts and direct tests: `src/BankV2.sol`, the initializer-visibility compatibility edit in `src/BankV1.sol`, `test/BankV2.t.sol`, `test/BankUpgrade.t.sol`, `test/mocks/IncompatibleBank.sol`, and `test/mocks/NonUUPSImplementation.sol`. +- Upgrade/demo/invariant path: `Makefile`, `script/CheckState.s.sol`, `script/UpgradeV2.s.sol`, `script/TransferV2Demo.s.sol`, `test/BankInvariant.t.sol`, `test/ScriptPreflight.t.sol`, and `test/helpers/BankV2Handler.sol`. +- Manifest and ABI bridge: `tools/finalize-manifest.mjs`, `tools/sync-web-artifacts.mjs`, `tools/test-finalize-manifest.mjs`, and `tools/test-sync-web-artifacts.mjs`. +- Read-only console: `web/src/App.test.tsx`, `web/src/components/ActivityTimeline.tsx`, `web/src/data/bankClient.ts`, `web/src/data/bankClient.test.ts`, and `web/src/types/dashboard.ts`. + +The V1 compatibility edit changes `initialize` visibility from `external` to `public` so the pinned upgrades-core tooling recognizes the initializer as inherited by `BankV2`; it does not change the external ABI, initializer guard, or stored state. Generated ABI and deployment files may appear during execution but are reproducible runtime artifacts, not live source edits. The optional Base Sepolia files are a later encore and are not required for the local Act 2 story. + +Ask Codex to show the diff and explain layout preservation, owner authorization, transfer conservation, error paths, snapshot postconditions, and why the browser remains read-only. Then run: ```bash make verify ``` -If any gate fails, stop and let Codex diagnose it. Do not run an upgrade merely because a partial test command passed. +Point to each successful layer in the output: `forge fmt --check`; clean forced build; generated ABI freshness; the pinned OpenZeppelin upgrades CLI; 102 Solidity unit, fuzz, invariant, upgrade-regression, and script tests; 16 manifest-finalizer tests; 26 process-safety cases; offline Base-config and project-scanner gates; then web lint, TypeScript checking, 55 Vitest tests across six files, and the production Vite build. In the upgrade tests, highlight the validator accepting `BankV1` → `BankV2`, rejecting an incompatible layout, rejecting non-UUPS logic, and preserving the snapshot. If any layer fails, do not upgrade. ### Act 3 — V2 proves continuity -After Codex has implemented these targets and the full gate has passed: +Continue in terminal 2 only after the complete gate passes: ```bash make upgrade-v2 make demo-transfer -make check-state +DEMO_EXPECTED_STAGE=v2 make check-state +curl --fail http://127.0.0.1:5173/ ``` -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. +At the upgrade boundary, compare the printed identities and state: + +- the proxy address is exactly the Act 1 proxy; +- the implementation address is different; +- owner, asset, pause state, Alice/Bob addresses and balances, liabilities, reserves, surplus, and deployment block are unchanged; +- version changes from `1` to `2`; and +- the upgrade finalizer matches the `Upgraded` event and live ERC-1967 implementation slot before publishing the V2 manifest. + +After the transfer, call out Alice `650 mUSDC`, Bob `750 mUSDC`, liabilities `1,400 mUSDC`, reserves `1,400 mUSDC`, surplus `0`, and version `2`. The timeline must decode `BalanceTransferred(Alice, Bob, 250e6)` as “Alice transferred 250.000000 mUSDC to Bob.” That transaction has no MockUSDC `Transfer` log, and the unchanged reserves prove no ERC-20 left or entered the proxy. Refresh only if the console has not observed the new block; normally its live synchronization should show the change. ## Recovery without broad cleanup -- **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. +- **Failed edit or test:** do not upgrade. Keep the failing command and diff visible, let Codex diagnose on the disposable live branch, or finish with the verified V1. If time expires, create a new disposable branch/worktree from `demo-start`; never reset or overwrite unrelated work. +- **Occupied port:** `make demo-local` refuses to claim either port. Probe with `curl http://127.0.0.1:8545` and `curl http://127.0.0.1:5173/`, then use operating-system process inspection to identify the external owner. Stop it only after proving ownership. The project never uses broad process matching. - **Recorded stale process:** run `make reset-local`. It validates numeric PID, process group, command signature, and Linux start tick before signaling. A mismatched live process is preserved and reset exits nonzero. -- **Stale 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. +- **Stale UI:** run the stage-appropriate check (`DEMO_EXPECTED_STAGE=v1 make check-state` before transfer or `DEMO_EXPECTED_STAGE=v2 make check-state` after it), inspect `.demo/vite.log`, and use `curl --fail http://127.0.0.1:5173/`. A stale/disconnected console preserves the last good snapshot and labels it; it never invents zeros. +- **Partial local manifest:** never copy `pending.json` or `upgrade-pending.json` into a confirmed manifest and never hand-edit `active.json`. If an upgrade broadcast succeeded but a transient finalizer step failed, preserve the staging and broadcast files, diagnose the cause, and rerun `node tools/finalize-manifest.mjs upgrade --rpc-url http://127.0.0.1:8545`; then run `make sync-artifacts`. Otherwise stop the attached process, run `make reset-local`, and restart from a fresh V1. Confirmed manifests remain unchanged on a failed finalization. +- **Unexpected child exit:** the attached launcher stops its other validated group. Inspect `.demo/anvil.log` and `.demo/vite.log`, run `make reset-local`, and restart. +- **Unavailable Base service:** stop the optional encore, record whether the faucet, RPC, or explorer failed, and leave the completed local result untouched. Never substitute another chain or expose a signing secret to rescue an encore. -End the local session with Ctrl-C in 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. +End the local session with Ctrl-C in terminal 1, then run: + +```bash +make reset-local +bash tools/test-process-safety.sh +bash tools/test-base-config.sh +git diff --check +git status --short +``` + +### Recover the verified reference without touching current work + +`demo-complete` is a reference/recovery checkpoint, not a reason to reset the current checkout. From the repository root, first verify the tag and inspect current work: + +```bash +git cat-file -t demo-complete +git tag -n99 demo-complete +git rev-parse 'demo-complete^{}' +git status --short +``` + +The type must be `tag`, the annotation must read `Verified reference solution for UUPS bank demo`, and the peeled target is the verified documentation commit. This checks the required annotated tag without implying that it carries a GPG signature. + +Then create a new branch in a new worktree; choose unused names if either example name already exists: + +```bash +git worktree add -b recovery/uups-bank-demo .worktrees/uups-bank-demo-recovery demo-complete +git -C .worktrees/uups-bank-demo-recovery status --short +``` + +This peels the annotated tag into a new branch without switching, resetting, cleaning, stashing, or overwriting the current worktree. Never delete an existing recovery directory or move either demo tag as part of recovery. ## Optional Base Sepolia encore @@ -85,9 +148,9 @@ cast wallet import uups-bank-base --interactive cast wallet address --account uups-bank-base ``` -Copy the displayed public address to `BASE_SEPOLIA_SENDER`, set `BASE_SEPOLIA_ACCOUNT=uups-bank-base`, and verify the two refer to the same account. Choose a different nonzero public address for `BASE_SEPOLIA_RECIPIENT`. Fund only the displayed Base Sepolia sender address with Base Sepolia test ETH. Never paste the key or password into Codex, shell history, or `.env`; Foundry requests the encrypted-keystore password through its interactive prompt. +Copy the displayed public address to `BASE_SEPOLIA_SENDER`, set `BASE_SEPOLIA_ACCOUNT=uups-bank-base`, and choose a different nonzero public address for `BASE_SEPOLIA_RECIPIENT`. The Base manifest schema is exactly two nested actors in order: `Presenter` at the configured sender, then `Recipient` at the configured recipient. Fund only the displayed Base Sepolia sender with Base Sepolia test ETH. Never paste the key or password into Codex, shell history, or `.env`; Foundry requests the encrypted-keystore password interactively. -Copy `.env.example` to `.env` and configure: +Copy `.env.example` to `.env` and configure only public identifiers and endpoints: ```dotenv # Terminal RPC may be credentialed; never copied into browser artifacts. @@ -107,7 +170,7 @@ bash tools/test-base-config.sh make -n deploy-base-sepolia BASE_SEPOLIA_RPC_URL=https://terminal.invalid BASE_SEPOLIA_PUBLIC_RPC_URL=https://public.invalid BASE_SEPOLIA_ACCOUNT=demo BASE_SEPOLIA_SENDER=0x1111111111111111111111111111111111111111 BASE_SEPOLIA_RECIPIENT=0x2222222222222222222222222222222222222222 ``` -The dry run must contain `--account`, `--sender`, and `--slow`, and must not contain a raw-key option. The URLs above are deliberately fake; these checks require no keystore, faucet, or network. Then, only with explicit authorization and Base Sepolia test funds, execute: +The dry run must contain `--account`, `--sender`, and `--slow`, and no raw-key option. The URLs are deliberately fake; these checks need no keystore, faucet, or network. Only with explicit authorization and Base Sepolia test funds may the presenter run: ```bash make deploy-base-sepolia @@ -115,11 +178,11 @@ make upgrade-base-sepolia make transfer-base-sepolia ``` -Deployment mints valueless mUSDC only to Presenter and deposits `1,000 mUSDC`; Recipient starts at `0`. The V2 transfer produces Presenter `750 mUSDC` and Recipient `250 mUSDC`, while reserves and liabilities stay `1,000 mUSDC`. Each command runs an invariant-only state check. The confirmed browser manifest contains only the intentionally public browser RPC plus `https://sepolia.basescan.org`; the terminal RPC is never serialized. +Deployment mints valueless mUSDC only to Presenter and deposits `1,000 mUSDC`; Recipient starts at `0`. The V2 internal transfer produces Presenter `750 mUSDC` and Recipient `250 mUSDC`, while reserves and liabilities remain `1,000 mUSDC`. Each command runs an invariant-only state check. The confirmed browser manifest contains only the intentionally public browser RPC and `https://sepolia.basescan.org`; the terminal RPC is never serialized. -Before an intentional Base redeployment, run `make archive-base-manifest`. It moves only `deployments/base-sepolia.json` to a validated timestamped sibling and leaves `active.json` and Anvil state untouched. A subsequent finalizer may create a new Base canonical manifest; the old active copy remains the browser fallback until `make select-base-sepolia` succeeds. Use `make select-anvil` to return the browser to the local manifest. If a faucet, RPC, or explorer fails, record the optional failure and stop—the completed local demo remains the successful outcome. +Before an intentional Base redeployment, run `make archive-base-manifest`. It moves only `deployments/base-sepolia.json` to a validated timestamped sibling and leaves `active.json` and Anvil state untouched. A subsequent finalizer may create a new Base canonical manifest; the old active copy remains the browser fallback until `make select-base-sepolia` succeeds. Use `make select-anvil` to return the browser to the local manifest. -## Closing trust disclosure checklist +## Closing trust disclosure and tool boundaries Read these points while the matching console panel is visible: @@ -129,4 +192,4 @@ Read these points while the matching console panel is visible: - 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. +Codex performs the repository-specific work: application and UI implementation, tests, scripts, lifecycle orchestration, manifest handling, and explanation. OpenZeppelin supplies reviewed reusable primitives and a validator that checks declared upgrade/storage compatibility; it does not audit this application's economics, owner choices, or operations. Foundry compiles and executes the declared tests and scripts and provides local Anvil; it guarantees neither test completeness nor production safety. None of Codex, OpenZeppelin, Foundry, or a passing gate turns this teaching artifact into audited, decentralized, or regulated custody software.