Files
uupl-smart-contract/docs/superpowers/specs/2026-08-17-uups-bank-demo-design.md
T

29 KiB
Raw Blame History

UUPS Bank Demo Design

  • Status: approved
  • Date: 2026-08-17
  • Audience: a blockchain entrepreneur evaluating Codex, and a presenter learning Solidity and UUPS upgradeability
  • Scope: one educational, terminal-orchestrated smart-contract demo with a read-only operations console

Summary

Build a demo “bank” that custodies a mock six-decimal ERC-20 and records one internal balance per address. Version 1 supports deposits, withdrawals, pausing, and owner-authorized UUPS upgrades. During the presentation, Codex adds Version 2, which introduces internal customer-to-customer transfers without changing the proxy address or losing any V1 state.

The main presentation runs on a deterministic local Anvil chain. The same contract and script architecture also supports an optional Base Sepolia encore. A read-only React operations console displays reserves, liabilities, customer balances, contract addresses, version information, status, and decoded events. All state changes originate in visible Foundry scripts so the audience can follow and audit the complete flow.

This is a teaching artifact, not an audited financial product or legally regulated bank.

Goals

  1. Demonstrate Codex working across Solidity, Foundry tests and scripts, upgrade validation, TypeScript, and React.
  2. Teach the UUPS proxy model: a stable proxy address and storage with replaceable implementation logic.
  3. Teach custody accounting through an explicit reserve-versus-liability invariant.
  4. Make the live V1-to-V2 change small enough to understand and meaningful enough to impress.
  5. Make the local presentation safe and repeatable through a few memorable commands.
  6. Provide an optional public Base Sepolia proof without making testnet availability a dependency of the main demo.
  7. Preserve a clear recovery path so a presentation can continue if a live edit or public RPC fails.

Non-goals

  • Lending, borrowing, collateral, liquidation, interest, yield, or investment strategies.
  • Multiple deposited assets.
  • A bank-issued token or stablecoin.
  • KYC, identity, account approval, sanctions screening, or other compliance features.
  • Production governance, multisig administration, or timelocked upgrades.
  • Mainnet deployment or support for real funds.
  • A transaction-capable browser wallet experience.
  • Production-grade indexing, backend services, or a database.
  • Claims that the demo is audited, decentralized, legally compliant, or suitable for commercial custody.

Fixed Product Decisions

Area Decision
Upgrade pattern UUPS implementation behind an ERC-1967 proxy
Governance One owner address controls pausing and upgrades
Deposited asset One clearly labeled MockUSDC token with six decimals
Customer access Any address may deposit; no registration or allowlist
V1 behavior Deposit, withdraw, pause, unpause, and read account/system state
V2 behavior All V1 behavior plus internal balance transfers
Presentation control Foundry scripts perform every state-changing action
Browser role Read-only operations console
Primary network Local Anvil
Optional network Base Sepolia, chain ID 84532
Contract toolchain Foundry and OpenZeppelin Contracts/Upgrades
Web toolchain React, TypeScript, Vite, wagmi, and viem

All package revisions and the Solidity compiler must be pinned in project configuration and lockfiles. Dependencies must not use floating branches or unbounded versions.

Demo Story

Act 1: Prepared V1 establishes trust

The presenter runs make demo-local. The command starts or connects to a project-scoped Anvil process, deploys MockUSDC, deploys and initializes the V1 UUPS proxy, seeds local actors, performs deposits and one withdrawal, exports a deployment manifest, and starts the read-only console.

The deterministic local state after Act 1 is:

Value State
Alice internal balance 900 mUSDC
Bob internal balance 500 mUSDC
Total liabilities 1,400 mUSDC
Proxy MockUSDC reserves 1,400 mUSDC
Contract version 1

The presenter calls out the stable proxy address, the distinct implementation address, and the equality between reserves and liabilities.

Act 2: Codex changes the system live

From the prepared V1 checkpoint, the presenter asks Codex:

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.

Codex implements the bounded change and runs make verify. The audience sees the code diff, test failures if any, corrections, the upgrade-safety validation, and the final passing evidence.

Act 3: V2 proves state continuity

The presenter runs make upgrade-v2, followed by make demo-transfer. The upgrade script replaces only the implementation. The transfer script moves 250 mUSDC internally from Alice to Bob.

The deterministic local state after Act 3 is:

Value State
Alice internal balance 650 mUSDC
Bob internal balance 750 mUSDC
Total liabilities 1,400 mUSDC
Proxy MockUSDC reserves 1,400 mUSDC
Contract version 2
Proxy address Unchanged from V1

No ERC-20 transfer occurs during the internal balance transfer. The console proves this through unchanged reserves and a decoded BalanceTransferred event.

Optional public encore

Base Sepolia uses the same source contracts and narrowly scoped Foundry scripts, but it is not required for the main presentation. The presenter deploys with an encrypted Foundry keystore, deposits mock funds from the presenter account, upgrades, transfers to a configured recipient, and opens explorer links from the console.

If Base Sepolia, its faucet, the RPC, or the explorer is unavailable, the presentation ends successfully after the local Act 3.

Architecture

flowchart LR
    subgraph Workspace[Codex workspace]
        Terminal[Foundry terminal\nstate-changing control plane]
        Console[React operations console\nread-only audience view]
    end

    subgraph Networks[Selectable JSON-RPC target]
        Anvil[Anvil\nprimary]
        Base[Base Sepolia\noptional]
    end

    subgraph Chain[On-chain system]
        Token[MockUSDC]
        Proxy[ERC1967Proxy\nstable address and storage]
        V1[BankV1\ndeposit + withdraw]
        V2[BankV2\nV1 + internal transfer]
    end

    Terminal -->|signed transactions| Anvil
    Terminal -->|signed transactions| Base
    Console -->|reads and event queries| Anvil
    Console -->|reads and event queries| Base
    Anvil --> Chain
    Base --> Chain
    Proxy -->|delegatecall before upgrade| V1
    Proxy -->|delegatecall after upgrade| V2
    Proxy -->|holds reserves| Token

The terminal and console share a generated deployment manifest. The manifest contains only public data: network name, chain ID, deployment block, actor addresses, proxy address, implementation address, token address, and explorer base URL. It never contains private keys, mnemonics, passwords, or RPC credentials.

Repository Layout

.
├── src/
│   ├── BankV1.sol
│   ├── BankV2.sol
│   └── MockUSDC.sol
├── script/
│   ├── DeployV1.s.sol
│   ├── SeedV1Demo.s.sol
│   ├── UpgradeV2.s.sol
│   ├── TransferV2Demo.s.sol
│   └── CheckState.s.sol
├── test/
│   ├── BankV1.t.sol
│   ├── BankV2.t.sol
│   ├── BankUpgrade.t.sol
│   └── BankInvariant.t.sol
├── web/
│   ├── src/
│   ├── package.json
│   └── package-lock.json
├── deployments/
├── docs/
│   ├── LEARNING_GUIDE.md
│   └── PRESENTER_RUNBOOK.md
├── Makefile
├── foundry.toml
├── remappings.txt
├── .nvmrc
├── .env.example
└── README.md

Generated build output, local process state, secrets, visual-companion files under .superpowers/, and local deployment manifests must be ignored by Git. A sanitized Base Sepolia deployment manifest may be committed only when it contains public values and is intentionally marked as an example or verified deployment.

On-chain Design

MockUSDC

MockUSDC is a non-upgradeable OpenZeppelin ERC-20 with:

  • Name Mock USD Coin.
  • Symbol mUSDC.
  • Six decimals.
  • Owner-restricted minting used only by setup scripts.
  • A prominent NatSpec warning that it has no monetary value.

Local scripts mint 2,000 mUSDC to Alice and 1,000 mUSDC to Bob. The bank supports this standard, non-rebasing token. Rebasing and fee-on-transfer tokens are outside scope.

Proxy and initialization

OpenZeppelin Foundry Upgrades deploys an ERC-1967 proxy whose implementation is BankV1. The deployment calls:

initialize(address asset, address initialOwner)

The initializer rejects zero addresses and initializes the stateful OwnableUpgradeable and PausableUpgradeable modules. With the pinned OpenZeppelin Contracts 5.6.1 release, Initializable, UUPSUpgradeable, and ReentrancyGuard are stateless shared modules imported from @openzeppelin/contracts; they have no upgradeable initializer calls. The implementation constructor calls _disableInitializers() so the implementation cannot be initialized directly.

The implementation may use OpenZeppelin's narrowly scoped @custom:oz-upgrades-unsafe-allow constructor annotation only on that initializer-disabling constructor. No storage-layout, missing-initializer, delegate-call, self-destruct, or UUPS-compatibility validation bypass is permitted.

foundry.toml enables the AST, build information, FFI, and storage-layout output required by the OpenZeppelin Foundry Upgrades validator. FFI is used only by pinned, reviewed project dependencies and project-owned validation/export helpers; arbitrary user input must never become an FFI command.

Every application call and every dashboard read uses the proxy address. The implementation address is displayed only to teach the separation of proxy and logic.

V1 state

Application storage has this fixed logical order:

  1. Deposited asset address.
  2. Internal balance mapping keyed by customer address.
  3. Total liabilities.
  4. Reserved storage gap for future compatible extensions.

V2 does not add or reorder storage. The OpenZeppelin upgrade validator must approve the V1-to-V2 layout without unsafe overrides.

V1 interface and behavior

BankV1 exposes:

  • deposit(uint256 amount)
  • withdraw(uint256 amount)
  • pause() and unpause(), restricted to the owner
  • balanceOf(address account)
  • asset()
  • totalLiabilities()
  • contractVersion(), returning 1

deposit requires a nonzero amount, an adequate ERC-20 allowance, and an active bank. It uses SafeERC20.safeTransferFrom and checks the banks token balance before and after the call. The received amount must equal the requested amount before the customer balance and total liabilities are credited. Unsupported token transfer behavior reverts the complete transaction.

withdraw requires a nonzero amount, an active bank, and a sufficient internal balance. It reduces the customer balance and total liabilities before calling SafeERC20.safeTransfer, following checks-effects-interactions. Deposit and withdrawal are non-reentrant.

Pausing stops all customer mutations but never blocks view functions. There is no owner function for withdrawing, sweeping, or rescuing the deposited asset.

_authorizeUpgrade(address) is restricted to the owner. UUPS proxy-context checks remain those supplied by OpenZeppelin.

V2 interface and behavior

BankV2 inherits all V1 behavior and adds:

transferBalance(address recipient, uint256 amount)

The function requires an active bank, nonzero amount, nonzero recipient, a recipient different from the sender, and adequate sender balance. It debits the sender and credits the recipient atomically. It does not call MockUSDC and does not change total liabilities. contractVersion() returns 2.

V2 adds no initializer and no storage variables.

Events

The application emits:

  • Deposited(address indexed account, uint256 amount)
  • Withdrawn(address indexed account, uint256 amount)
  • BalanceTransferred(address indexed from, address indexed to, uint256 amount) in V2

OpenZeppelin ownership, pause, and ERC-1967 upgrade events are retained and decoded by the console.

Custom errors

Application-level failures use named custom errors for:

  • Zero amount.
  • Zero or otherwise invalid recipient.
  • Self-transfer.
  • Insufficient internal balance, including available and requested values.
  • Unsupported asset transfer behavior, including expected and received values.

OpenZeppelin errors cover ownership, pause state, initialization, reentrancy, safe ERC-20 interaction, and invalid UUPS context.

Accounting and Security Invariants

  1. Ledger conservation: totalLiabilities equals the sum of all tracked customer balances.
  2. Solvency: MockUSDC.balanceOf(proxy) >= totalLiabilities at every successful state transition.
  3. Deposit symmetry: a deposit increases reserves, the customer balance, and total liabilities by the same amount.
  4. Withdrawal symmetry: a withdrawal decreases reserves, the customer balance, and total liabilities by the same amount.
  5. Transfer conservation: a V2 transfer changes neither reserves nor total liabilities.
  6. Authorization: only the owner can pause, unpause, or upgrade.
  7. State continuity: a valid V1-to-V2 upgrade preserves the proxy address, owner, asset, pause state, balances, liabilities, and token reserves.
  8. Initialization safety: the proxy initializes once and the implementation cannot initialize directly.

Anyone can send MockUSDC directly to the proxy, which may create a reserve surplus. Therefore solvency uses >=, not equality. The demo intentionally provides no surplus rescue function because an owner-controlled asset withdrawal would weaken the custody story.

The single owner remains a central trust assumption: that address can pause customers and upgrade to arbitrary future logic. The console and learning guide must state this plainly.

Foundry Scripts and Data Flow

DeployV1.s.sol

  • Verifies the selected chain is local Anvil or Base Sepolia.
  • Deploys MockUSDC.
  • Deploys the UUPS proxy with an encoded V1 initializer.
  • Confirms owner, asset, proxy implementation, and version.
  • Writes the public deployment manifest through the projects narrow export helper.

SeedV1Demo.s.sol

  • Is enabled by default only on local Anvil.
  • Mints deterministic demo funds.
  • Executes Alices 1,000 mUSDC deposit, Bobs 500 mUSDC deposit, and Alices 100 mUSDC withdrawal.
  • Checks every expected balance, reserve, and liability after execution.

The Base Sepolia encore uses presenter-controlled accounts and explicit parameters rather than assuming Anvil keys. It may use one depositor and one recipient; it must still prove deposit, upgrade, internal transfer, and state continuity.

UpgradeV2.s.sol

  • Loads the existing proxy and V1 reference from the deployment manifest.
  • Verifies the caller is the proxy owner and the current version is 1.
  • Snapshots owner, asset, pause state, selected balances, liabilities, reserves, and proxy address.
  • Runs OpenZeppelin upgrade validation and upgrades to BankV2 without storage-layout or UUPS-safety bypasses.
  • Verifies version 2 and equality of every snapshotted value.
  • Records the new public implementation address.
  • Refuses to send a transaction if the proxy is already V2 or any precondition fails.

TransferV2Demo.s.sol

  • Verifies version 2.
  • Snapshots reserves and liabilities.
  • Transfers 250 mUSDC internally from Alice to Bob on local Anvil.
  • Confirms Alice has 650, Bob has 750, and reserves and liabilities remain 1,400.

CheckState.s.sol

Prints a concise table of network, proxy, implementation, version, owner, pause state, asset, tracked balances, reserves, liabilities, and surplus. It exits unsuccessfully when a required invariant or expected demo value is false.

Network and Key Safety

State-changing scripts allow only:

  • Anvil chain ID 31337 for the primary local demo.
  • Base Sepolia chain ID 84532 for the optional public encore.

No mainnet chain ID, endpoint, Make target, or deployment configuration is provided. A mismatched RPC response or chain ID causes a fail-fast error before broadcasting.

Local operation uses only Anvils well-known development accounts and labels them unsafe outside localhost. Base Sepolia uses a named encrypted Foundry keystore imported through cast wallet import --interactive. .env.example documents only public configuration such as BASE_SEPOLIA_RPC_URL; raw private keys and mnemonic phrases are not accepted by the supported Make targets.

The Base Sepolia account requires test ETH, and MockUSDC remains valueless. The runbook treats faucet and RPC outages as optional-encore failures, not demo failures.

Read-only Operations Console

The selected interface is an operations console, not a consumer banking UI. It contains:

  1. System status: connected network, last synchronized block, active/stale/disconnected state, paused state, and contract version.
  2. Accounting cards: token reserves, total liabilities, surplus, and reserve ratio. When liabilities are zero, reserve ratio displays No deposits rather than dividing by zero.
  3. Contract identity: proxy, implementation, token, owner, and deployment block, with Base Sepolia explorer links when applicable.
  4. Tracked accounts: Alice and Bob labels on local Anvil and shortened address labels on public networks.
  5. Activity timeline: decoded deposits, withdrawals, internal transfers, pauses, unpauses, ownership events, and upgrades from the deployment block onward.
  6. Learning tooltips: concise definitions for proxy, implementation, reserves, liabilities, and upgrade authorization.
  7. Persistent warning: Educational demo — mock token — never use real funds.

The console performs no signing, deployment, upgrade, deposit, withdrawal, or transfer. It has no wallet-connect or owner-action button. wagmi supplies React query/state integration and viem supplies typed Ethereum clients, ABI decoding, and contract reads.

The generated ABI and deployment manifest are the only bridge between Foundry artifacts and the web application. A synchronization command regenerates them after a successful contract build. The console watches new blocks and relevant logs, then reconciles event-derived UI with direct contract reads.

If RPC access fails, the console shows Disconnected or Stale and the last successful block/time. Unknown values remain visibly unknown; the UI never substitutes zero for failed reads. A network or manifest chain mismatch blocks rendering of contract state and explains the mismatch.

Local Command Experience

The supported presentation environment is a Linux-like Bash sandbox with make, Git, Foundry, and the Node/npm version pinned by .nvmrc and web/package.json. Native Windows support and automatic installation of system-wide developer tools are outside scope; make doctor reports missing prerequisites with direct installation-documentation links.

The supported commands are:

  • make doctor: checks Foundry, Node, npm, dependency state, required ports, and configuration without changing chain state.
  • make setup: installs pinned project dependencies.
  • make demo-local: starts project-scoped Anvil and Vite processes, deploys and seeds V1, exports public artifacts, prints the console URL, and remains attached until stopped.
  • make verify: runs all contract and web verification gates.
  • make check-state: prints and validates current demo state.
  • make upgrade-v2: validates and performs the local V1-to-V2 upgrade.
  • make demo-transfer: performs and validates the local 250 mUSDC transfer.
  • make reset-local: stops only processes recorded by this project and clears only project-scoped local runtime state.
  • make deploy-base-sepolia, make upgrade-base-sepolia, and make transfer-base-sepolia: explicit optional-testnet commands with chain and account guards.

Local process identifiers and generated runtime state live in a narrow .demo/ directory. Cleanup must validate those targets and must not use broad process-killing patterns, recursive workspace deletion, or implicit environment-variable paths.

Error Handling

Contracts

  • Invalid customer calls revert atomically with named errors.
  • Storage updates precede the external token transfer during withdrawal, with reentrancy protection.
  • Unexpected token transfer amounts revert the entire deposit.
  • Unauthorized administration and upgrades use OpenZeppelin access errors.
  • All customer mutations revert while paused.

Scripts

  • Preflight checks validate chain ID, caller, code at each address, proxy version, manifest consistency, and expected ownership.
  • Broadcast happens only after preflight passes.
  • Postconditions validate state immediately after each action.
  • Failures exit nonzero and identify the failing precondition or postcondition.
  • Re-running a completed upgrade reports that V2 is already active without broadcasting.

Console

  • Loading, empty, stale, disconnected, unsupported-network, and malformed-manifest states are distinct.
  • Log decoding failures isolate the individual event instead of blanking the dashboard.
  • The last known good snapshot remains visible but is clearly timestamped as stale.

Verification Strategy

V1 unit tests

  • Initialization, double-initialization rejection, and direct implementation initialization rejection.
  • Successful deposit and withdrawal state deltas and emitted events.
  • Zero amounts, insufficient allowance, insufficient token balance, and insufficient internal balance.
  • Paused behavior and owner-only pause/unpause.
  • Owner-only upgrade authorization.
  • Direct token transfers create surplus without breaking solvency.

V2 unit tests

  • Successful transfer balance deltas and emitted event.
  • Zero amount, zero recipient, self-transfer, insufficient balance, and paused transfer.
  • Transfer leaves token reserves and total liabilities unchanged.
  • Every V1 function behaves identically through the upgraded proxy.

Fuzz and invariant tests

  • Fuzz deposit, withdrawal, and V2 transfer amounts across a bounded actor set.
  • A stateful handler tracks actor addresses so the test can independently sum balances.
  • Assert ledger conservation and solvency after every successful action sequence.
  • Assert that internal transfers conserve aggregate balances and reserves.
  • Bound mock minting and actor counts so failures are reproducible and understandable.

Upgrade regression tests

  • Deploy and populate V1, snapshot all required state, perform a validated UUPS upgrade, and compare every snapshot field.
  • Assert the proxy address is unchanged and implementation address is changed.
  • Assert version transitions from 1 to 2.
  • Assert non-owner upgrade attempts and incompatible implementations fail.
  • Run OpenZeppelin layout validation with V1 as the explicit reference contract and no unsafe bypasses.

Web tests

  • Correct V1 and V2 summary rendering from deterministic client responses.
  • Reserve, liability, surplus, and zero-liability formatting.
  • Proxy/implementation identity and Base Sepolia explorer links.
  • Event ordering and decoding for deposit, withdrawal, transfer, pause, ownership, and upgrade events.
  • Loading, stale, disconnected, malformed-manifest, and chain-mismatch behavior.
  • Production build and TypeScript type-checking.

make verify gate

The aggregate gate runs, in order:

  1. Solidity formatting check.
  2. Clean Solidity build with storage-layout output.
  3. Full Foundry unit, fuzz, invariant, and upgrade suite with upgrade validation enabled.
  4. Web lint.
  5. Web TypeScript type-check.
  6. Web component tests in non-watch mode.
  7. Web production build.

Any failed stage stops the gate and prevents the runbook from proceeding to the live upgrade.

Learning and Presentation Materials

README.md

  • Prerequisites and a ten-minute local quick start.
  • Exact supported commands and expected URLs.
  • A one-paragraph architecture summary.
  • Prominent demo-only and centralized-owner warnings.
  • Links to the learning guide and presenter runbook.

docs/LEARNING_GUIDE.md

  • Proxy, implementation, delegatecall, and storage explained in plain language.
  • Why upgradeable contracts use initializers instead of ordinary constructor state.
  • Why the implementation disables initialization.
  • Storage-order examples showing a safe and an unsafe upgrade.
  • Reserves, liabilities, surplus, and the solvency invariant.
  • Why internal transfers do not move ERC-20 reserves.
  • Why SafeERC20, reentrancy protection, pausing, and checks-effects-interactions exist.
  • Why a single upgrade owner is a central trust assumption.
  • Short exercises that intentionally trigger named errors on local Anvil.

docs/PRESENTER_RUNBOOK.md

  • A preflight checklist and rehearsal timing.
  • The exact three-act sequence and live Codex prompt.
  • Expected commands, values, events, and console changes at each step.
  • A local-first rule and an explicit decision point for the Base Sepolia encore.
  • Recovery steps for a failed edit, failed test, occupied port, stale console, or unavailable testnet.
  • A closing explanation of what Codex did versus what OpenZeppelin and Foundry supplied.

Repeatable Git checkpoints

The completed repository history provides:

  • A demo-start tag containing the prepared and verified V1 state.
  • A later demo-complete tag containing the verified reference V2 solution.

The live presentation begins from demo-start in a fresh sandbox branch. The complete tag is a recovery/reference point, not code imported into the live Codex context. Recovery instructions avoid overwriting unrelated work.

Trust and Safety Disclosure

The UI and documentation must state all of the following:

  • MockUSDC has no value.
  • The contracts are educational and unaudited.
  • Real deposits must never be sent to the demo.
  • The owner can pause customer actions and authorize arbitrary future logic.
  • UUPS mistakes can corrupt state or permanently damage upgradeability.
  • A real custody product would require professional audits, operational key controls, multisig or timelocked governance, incident procedures, legal advice, and jurisdiction-specific compliance work.

Acceptance Criteria

The design is implemented successfully when all of these are true:

  1. A fresh supported sandbox passes make doctor and can reach a running V1 console through make demo-local after dependency setup.
  2. Act 1 reaches the exact documented local balances, liabilities, reserves, version, and event history.
  3. The console is read-only and accurately displays proxy and implementation identity, accounting state, actor balances, status, and events.
  4. make verify passes all Solidity, upgrade, invariant, and web gates.
  5. The validated upgrade changes the implementation but not the proxy address or any V1 application state.
  6. Act 3 reaches the exact documented post-transfer balances while reserves and liabilities remain 1,400 mUSDC.
  7. Non-owner upgrade and administration attempts fail in tests and scripts.
  8. State-changing scripts refuse unsupported chain IDs, including every mainnet.
  9. Local setup requires no secrets or real funds, and cleanup targets only project-scoped runtime state.
  10. Base Sepolia deployment works through an encrypted keystore when explicitly configured, but its absence cannot prevent the local demo from succeeding.
  11. The README, learning guide, presenter runbook, warnings, and recovery checkpoint are complete and agree with actual commands and behavior.
  12. No placeholder text, unsafe storage/UUPS validation bypass, committed credential, real token address, lending behavior, or browser-side transaction control remains. The documented constructor-only initializer-disabling annotation is the sole permitted validator allowance.

References