123 lines
7.8 KiB
Markdown
123 lines
7.8 KiB
Markdown
# 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
|
|
|
|
# DemoScript.UnsupportedChain (the test deliberately uses a rejected chain)
|
|
forge test --match-test testUnsupportedChainsAreRejectedBeforeBroadcast -vv
|
|
|
|
# DemoScript.ManifestChainMismatch and DemoScript.MissingCode
|
|
forge test --match-test 'test(WrongManifestChain|AddressWithoutCode)IsRejected' -vv
|
|
|
|
# DemoScript.InvalidDeploymentBlock
|
|
forge test --match-test testActiveManifestRejectsDeploymentBlockZero -vv
|
|
|
|
# DemoScript.InvalidManifestSchema
|
|
forge test --match-test testLegacyParallelActorManifestIsRejected -vv
|
|
|
|
# DemoScript.InvalidActorConfiguration
|
|
forge test --match-test testAnvilManifestRequiresOwnerAtActorZero -vv
|
|
|
|
# DemoScript.UnexpectedState example payload: label, expected, actual
|
|
cast calldata 'UnexpectedState(string,uint256,uint256)' 'reserves' 1400000000 1399000000
|
|
|
|
# DemoScript.UnexpectedAddress, including expected/actual implementation addresses
|
|
forge test --match-test testCheckStateRejectsManifestImplementationMismatch -vv
|
|
|
|
# CheckState.Insolvent example payload: reserves below ledger liabilities
|
|
cast calldata 'Insolvent(uint256,uint256)' 1399000000 1400000000
|
|
|
|
# CheckState.UnknownStage example payload
|
|
cast calldata 'UnknownStage(string)' 'mystery'
|
|
```
|
|
|
|
Finish by running `make verify`; it combines unit, fuzz, invariant, script, process-safety, scanner, and web gates.
|