docs: finish UUPS bank demo guide
This commit is contained in:
+116
-45
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user