docs: prepare repeatable V1 presentation

This commit is contained in:
golem
2026-08-21 04:04:03 -06:00
parent d1a86ad162
commit 2446f0b2ef
10 changed files with 848 additions and 10 deletions
+13 -3
View File
@@ -4,9 +4,9 @@ SHELL := /bin/bash
RPC_LOCAL := http://127.0.0.1:8545
ANVIL_OWNER := 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266
.PHONY: doctor setup verify deploy-v1 seed-v1 check-state test-finalize-manifest sync-abis publish-web-manifest sync-artifacts sync-artifacts-check
.PHONY: doctor setup demo-local verify check-state reset-local deploy-v1 seed-v1 sync-artifacts sync-artifacts-check sync-abis publish-web-manifest test-finalize-manifest
doctor:
@./tools/doctor.sh
@bash tools/doctor.sh
setup:
@git submodule update --init --recursive
@npm ci
@@ -15,12 +15,22 @@ verify:
@forge fmt --check
@forge clean
@npm_config_offline=true forge build --force
@node tools/sync-web-artifacts.mjs
@node tools/sync-web-artifacts.mjs --check
@node tools/check-upgrades-cli.mjs
@npm_config_offline=true forge test --force
@node tools/test-finalize-manifest.mjs
@node tools/test-sync-web-artifacts.mjs
@bash tools/test-process-safety.sh
@bash tools/scan-project.sh
@npm --prefix web run lint
@npm --prefix web run typecheck
@npm --prefix web test
@npm --prefix web run build
demo-local:
@bash tools/demo-local.sh
reset-local:
@bash tools/reset-local.sh
test-finalize-manifest:
@node tools/test-finalize-manifest.mjs
sync-abis:
@@ -41,4 +51,4 @@ deploy-v1:
seed-v1:
@forge script script/SeedV1Demo.s.sol:SeedV1Demo --rpc-url $(RPC_LOCAL) --broadcast --force
check-state:
@DEMO_EXPECTED_STAGE=$${DEMO_EXPECTED_STAGE:-v1} forge script script/CheckState.s.sol:CheckState --rpc-url $(RPC_LOCAL) --force
@forge script script/CheckState.s.sol:CheckState --rpc-url $(RPC_LOCAL) --force
+54
View File
@@ -0,0 +1,54 @@
# UUPS Bank V1 Demo
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.
> **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.
## Prerequisites
Use a Linux-like Bash environment with Git, GNU Make, `curl`, Foundry `1.7.1` (`forge`, `anvil`, and `cast`), Node `24.18.0`, and npm `11.17.0`. The exact package graph is committed in the lockfiles. Installation documentation: [Git](https://git-scm.com/downloads), [Foundry](https://getfoundry.sh), [Node](https://nodejs.org/en/download), [npm](https://docs.npmjs.com/downloading-and-installing-node-js-and-npm), [Bash](https://www.gnu.org/software/bash/), [Make](https://www.gnu.org/software/make/), and [curl](https://curl.se/download.html).
```bash
make setup
make doctor
```
`make setup` initializes recursive Git submodules and installs both pinned npm dependency trees. `make doctor` is read-only: it verifies versions, dependencies, writable runtime locations, and that local ports `8545` and `5173` are available.
## Ten-minute local quick start
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.
In a second terminal:
```bash
DEMO_EXPECTED_STAGE=v1 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.
## Architecture
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.
## Command reference
- `make doctor` — read-only prerequisite, dependency, directory, and port checks.
- `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 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.
Continue with the [learning guide](docs/LEARNING_GUIDE.md) or rehearse from the [presenter runbook](docs/PRESENTER_RUNBOOK.md).
+101
View File
@@ -0,0 +1,101 @@
# Learning Guide: Custody Accounting Behind a UUPS Proxy
## The two addresses that make upgrades possible
Users call the **proxy**, whose address remains stable and whose storage contains the asset address, customer ledger, liabilities, owner, and pause state. The **implementation** contains executable logic. The proxy forwards each application call with `delegatecall`: implementation code runs in the proxy's context, so `address(this)` is the proxy and reads/writes affect proxy storage. Calling the implementation directly is not equivalent and is never the supported application path.
UUPS places upgrade authorization in the implementation. OpenZeppelin's proxy-context checks ensure upgrade entry points run only through a compatible proxy; `BankV1._authorizeUpgrade` then restricts authorization to the owner. This keeps the proxy small, but makes implementation correctness and storage compatibility critical.
## Initializers replace constructor state
A normal implementation constructor changes only the implementation's own storage. It cannot initialize the proxy storage used by delegated calls. `initialize(asset, initialOwner)` therefore performs the one-time proxy setup and initializes ownership and pausing. The encoded initializer runs atomically when the proxy is created, avoiding an uninitialized-proxy takeover window.
The `BankV1` constructor calls `_disableInitializers()`. That locks the standalone implementation so an outsider cannot initialize it and create a misleading or dangerous separately owned instance. The narrow constructor annotation tells the OpenZeppelin validator why this constructor is intentional; it does not bypass storage-layout or UUPS compatibility checks. Double initialization of the proxy and direct initialization of the implementation both revert with `InvalidInitialization`.
## Storage layout is an API
Delegate calls interpret numbered storage slots according to the current implementation. A compatible future implementation preserves every existing declaration in its original order and consumes reserved gap space only when new state is truly required.
Safe conceptual extension:
```solidity
IERC20 internal _asset; // unchanged slot
mapping(address => uint256) internal _balances; // unchanged slot
uint256 internal _totalLiabilities; // unchanged slot
uint256 internal _newValue; // consumes reserved space
uint256[46] private __gap;
```
Unsafe conceptual extension:
```solidity
uint256 internal _totalLiabilities; // reordered: corrupts interpretation
IERC20 internal _asset;
mapping(address => uint256) internal _balances;
```
Changing order, type, inheritance order, or removing state can make balances appear as addresses or overwrite control data. A bad implementation can also remove working upgrade machinery and permanently brick future upgrades. Layout validation is necessary, but authorization and implementation behavior still require review.
## Reserves, liabilities, and surplus
**Reserves** are `MockUSDC.balanceOf(proxy)`: tokens actually held by the proxy. **Liabilities** are `totalLiabilities()`: the sum the ledger owes customers. **Surplus** is reserves minus liabilities. The solvency rule is:
```text
reserves >= total liabilities
```
Equality holds in the prepared Act 1 state. Anyone can transfer mock tokens directly to the proxy without receiving ledger credit, so a surplus is possible and the invariant deliberately uses `>=`.
## Exact V1 money flows
For `deposit(amount)`, the bank rejects zero, paused, or reentrant calls; reads reserves; uses `SafeERC20.safeTransferFrom`; measures the exact received delta; and only then credits the sender's internal balance and total liabilities. A fee-on-transfer or otherwise unexpected asset delta reverts the entire transaction.
For `withdraw(amount)`, the bank rejects zero, paused, reentrant, or underfunded ledger calls. It debits the customer's internal balance and total liabilities before `SafeERC20.safeTransfer` sends tokens. That ordering is checks-effects-interactions: validate first, commit internal effects second, interact externally last. `nonReentrant` adds a second boundary against a malicious token callback. A revert from the token rolls the whole transaction back.
`SafeERC20` handles ERC-20 implementations that return `false`, omit return values, or revert in different ways. Pausing gives the owner an emergency stop for deposits and withdrawals while views remain available. There is intentionally no owner reserve sweep.
An internal V2 customer transfer, when implemented during the presentation, moves ledger balances only. It must not move ERC-20 reserves or change aggregate liabilities.
## The owner is a central trust assumption
The owner may pause customer actions and authorize an implementation containing arbitrary future logic. Tests proving today's V1 behavior cannot constrain tomorrow's authorized implementation. Multisig/timelocked governance and operational controls are absent from this educational V1.
> **Trust boundary:** MockUSDC has no value. These contracts are educational and unaudited; real deposits must never be sent here. The owner can pause customer actions and install arbitrary future logic. UUPS mistakes can corrupt state or permanently brick upgradeability. A real custody product requires professional audits, operational key controls, multisig or timelocked governance, incident procedures, legal advice, and jurisdiction-specific compliance work.
## Local exercises: observe the named failures
These commands run deterministic tests and do not require a wallet or public RPC. Add `-vvvv` to inspect a revert trace.
```bash
# BankV1.InvalidAsset
forge test --match-test testInitializationChecksZeroAssetBeforeZeroOwner -vv
# BankV1.ZeroAmount on both customer paths
forge test --match-test 'test(Deposit|Withdraw)RejectsZeroAmount' -vv
# BankV1.InsufficientBalance with available/requested values
forge test --match-test testWithdrawReportsAvailableAndRequestedOnInsufficientInternalBalance -vv
# BankV1.UnexpectedAssetDelta with a fee-taking token
forge test --match-test testFeeOnTransferDepositRevertsAndRollsBackTokenAndAccounting -vv
# OpenZeppelin EnforcedPause
forge test --match-test 'test(Deposit|Withdraw)RejectsCallsWhilePaused' -vv
# OpenZeppelin OwnableUnauthorizedAccount
forge test --match-test 'testNonOwnerCannot(Pause|Unpause|AuthorizeUpgrade)' -vv
# OpenZeppelin InvalidInitialization
forge test --match-test 'test(ProxyCannotBeInitializedTwice|ImplementationCannotBeInitialized)' -vv
# OpenZeppelin ReentrancyGuardReentrantCall
forge test --match-test testDepositPropagatesNestedRevertAtomicallyWhenConfigured -vv
# Script UnsupportedChain (the test deliberately uses a rejected chain)
forge test --match-test testUnsupportedChainsAreRejectedBeforeBroadcast -vv
# ManifestChainMismatch and MissingCode
forge test --match-test 'test(WrongManifestChain|AddressWithoutCode)IsRejected' -vv
```
Finish by running `make verify`; it combines unit, fuzz, invariant, script, process-safety, scanner, and web gates.
+87
View File
@@ -0,0 +1,87 @@
# Presenter Runbook: Prepared V1 and Live UUPS Upgrade
## Preflight and rehearsal
Rehearse once from a clean disposable branch created at the `demo-start` tag. Allow 25 minutes: 3 minutes for preflight, 5 for Act 1, 10 for the Codex change and verification, 4 for Act 3, and 3 for questions. Keep two terminals visible and open the browser only after Vite reports ready.
Before the audience arrives:
```bash
make setup
make reset-local
make doctor
make verify
git diff --check
git status --short
```
The doctor must show Foundry `1.7.1`, Node `24.18.0`, npm `11.17.0`, initialized dependencies, writable runtime paths, and free ports `8545`/`5173`. Verification must exit `0`. No upgrade or transfer command runs until `make verify` passes.
## The three-act story
### Act 1 — Prepared V1 establishes trust
First terminal:
```bash
make demo-local
```
Second terminal:
```bash
DEMO_EXPECTED_STAGE=v1 make check-state
curl --fail http://127.0.0.1:5173/
```
Open `http://127.0.0.1:5173/`; Anvil is at `http://127.0.0.1:8545`. Call out that every browser read targets the proxy, while the distinct implementation address is shown only to teach delegation. The exact expected state is Alice `900 mUSDC`, Bob `500 mUSDC`, liabilities `1,400 mUSDC`, reserves `1,400 mUSDC`, surplus `0`, and version `1`. Point to deposit, withdrawal, and upgrade/ownership events, then state that the browser is read-only.
Explain the initial flow: scripts minted 2,000 mUSDC to Alice and 1,000 to Bob; Alice deposited 1,000 and withdrew 100; Bob deposited 500. The stable proxy holds reserves and storage. Implementation code runs against that storage with `delegatecall`.
### Act 2 — Codex changes the system live
Give Codex this approved prompt verbatim:
> Add `BankV2` with customer-to-customer internal transfers. Preserve the UUPS storage layout and all V1 behavior. Add unit, fuzz, invariant, and upgrade-regression tests; an owner upgrade script; a scripted Alice-to-Bob transfer; exported ABI support; and the read-only console updates needed to show V2 and its transfer event. Explain each security decision. Do not perform an upgrade until all verification passes.
Ask Codex to show the diff and explain storage compatibility, authorization, conservation, error paths, and why the browser remains read-only. The expected prepared change adds transfer-aware source, tests, scripts, ABI output, and console rendering without changing V1 storage. Run:
```bash
make verify
```
If any gate fails, stop and let Codex diagnose it. Do not run an upgrade merely because a partial test command passed.
### Act 3 — V2 proves continuity
After Codex has implemented these targets and the full gate has passed:
```bash
make upgrade-v2
make demo-transfer
make check-state
```
The expected result is the same proxy and a new implementation, version `2`, Alice `650 mUSDC`, Bob `750 mUSDC`, liabilities `1,400 mUSDC`, reserves `1,400 mUSDC`, and a decoded 250 mUSDC internal transfer event. No ERC-20 transfer should accompany the ledger transfer. Refresh only if the console has not observed the next block; otherwise let its live status prove the change.
## Recovery without broad cleanup
- **Occupied port:** `make demo-local` refuses to claim either port. Use `curl http://127.0.0.1:8545` and `curl http://127.0.0.1:5173/` plus your operating system's process inspection to identify the external owner. Stop it yourself only after proving ownership; the project never uses broad process matching.
- **Recorded stale process:** run `make reset-local`. It validates numeric PID, process group, command signature, and Linux start tick before signaling. A mismatched live process is preserved and reset exits nonzero.
- **Stale console:** confirm `DEMO_EXPECTED_STAGE=v1 make check-state`, inspect `.demo/vite.log`, and use `curl --fail http://127.0.0.1:5173/`. The console labels stale/disconnected state and preserves the last good snapshot rather than inventing zeros.
- **Failed test or live edit:** do not upgrade. Save the diff for review, continue diagnosis on the disposable live branch, or start a new disposable branch from `demo-start`; never overwrite unrelated work. The prepared V1 remains the successful ending if time expires.
- **Unexpected child exit:** the attached launcher stops its other validated group. Inspect `.demo/anvil.log` and `.demo/vite.log`, then run `make reset-local` and restart.
End the local session with Ctrl-C in the attached terminal, then `make reset-local`. The optional public encore is outside this prepared V1 run: local success does not depend on Base Sepolia, a faucet, an explorer, a wallet, or any external RPC.
## Closing trust disclosure checklist
Read these points while the matching console panel is visible:
- MockUSDC has no value.
- These contracts are educational and unaudited; real deposits must never be sent here.
- The owner can pause customer actions and install arbitrary future logic.
- UUPS mistakes can corrupt state or permanently brick upgradeability.
- A real custody product requires professional audits, operational key controls, multisig or timelocked governance, incident procedures, legal advice, and jurisdiction-specific compliance work.
Codex supplies the cross-stack implementation, tests, orchestration, and explanation. OpenZeppelin supplies reviewed contract primitives and upgrade validation; Foundry supplies compilation, tests, scripts, and the local chain. None of those tools turns this teaching artifact into an audited or regulated custody product.
+73
View File
@@ -0,0 +1,73 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd -P)
# shellcheck source=process-lib.sh
source "$ROOT/tools/process-lib.sh"
ANVIL_TEST_PHRASE='test test test test test test test test test test test junk'
CLEANING=0
cleanup() {
local status=$? cleanup_status=0
((CLEANING == 0)) || return
CLEANING=1
trap - INT TERM EXIT
demo_stop_recorded "$ROOT" vite || cleanup_status=1
demo_stop_recorded "$ROOT" anvil || cleanup_status=1
((status == 0 && cleanup_status != 0)) && status=$cleanup_status
exit "$status"
}
trap 'exit 130' INT
trap 'exit 143' TERM
trap cleanup EXIT
record_launched() {
local kind=$1 pid=$2
for _ in {1..50}; do
if demo_record_process "$ROOT" "$kind" "$pid"; then return 0; fi
demo_pid_is_running "$pid" || break
sleep 0.02
done
printf 'Could not prove identity for launched %s PID %s; stopping without targeting an unverified PID.\n' "$kind" "$pid" >&2
return 1
}
cd "$ROOT"
bash tools/reset-local.sh
bash tools/doctor.sh
install -d -m 0700 "$ROOT/.demo"
setsid anvil --host 127.0.0.1 --port 8545 --chain-id 31337 --mnemonic "$ANVIL_TEST_PHRASE" >"$ROOT/.demo/anvil.log" 2>&1 &
ANVIL_PID=$!
record_launched anvil "$ANVIL_PID"
for _ in {1..100}; do
if [[ $(cast chain-id --rpc-url http://127.0.0.1:8545 2>/dev/null || true) == 31337 ]]; then ANVIL_READY=1; break; fi
demo_pid_is_running "$ANVIL_PID" || break
sleep 0.1
done
[[ ${ANVIL_READY:-0} == 1 ]] || { printf '%s\n' 'Anvil did not become ready; see .demo/anvil.log' >&2; exit 1; }
make deploy-v1
make seed-v1
DEMO_EXPECTED_STAGE=v1 make check-state
npm_config_offline=true forge build --force
node tools/sync-web-artifacts.mjs
node tools/sync-web-artifacts.mjs --check
node tools/publish-web-manifest.mjs
setsid "$ROOT/web/node_modules/.bin/vite" web --host 127.0.0.1 --port 5173 >"$ROOT/.demo/vite.log" 2>&1 &
VITE_PID=$!
record_launched vite "$VITE_PID"
for _ in {1..100}; do
if curl --fail --silent --output /dev/null http://127.0.0.1:5173/; then VITE_READY=1; break; fi
demo_pid_is_running "$VITE_PID" || break
sleep 0.1
done
[[ ${VITE_READY:-0} == 1 ]] || { printf '%s\n' 'Vite did not become ready; see .demo/vite.log' >&2; exit 1; }
printf '\nV1 operations console: http://127.0.0.1:5173/\n'
printf '%s\n' 'In a second terminal: DEMO_EXPECTED_STAGE=v1 make check-state'
printf '%s\n' 'Stop this attached demo with Ctrl-C; make reset-local is the recovery command.'
while demo_pid_is_running "$ANVIL_PID" && demo_pid_is_running "$VITE_PID"; do sleep 1; done
printf '%s\n' 'A demo child exited unexpectedly; inspect .demo/anvil.log and .demo/vite.log.' >&2
exit 1
+57 -7
View File
@@ -1,13 +1,63 @@
#!/usr/bin/env bash
set -euo pipefail
printf '%s\n' 'Expected: Foundry 1.7.1, Node 24.18.0, npm 11.17.0'
ROOT=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd -P)
FAILURES=0
for tool in forge anvil node npm make; do
if command -v "$tool" >/dev/null 2>&1; then
printf '%s: ' "$tool"
"$tool" --version | sed -n '1p'
else
printf '%s\n' "missing: $tool"
fail() { printf 'FAIL: %s\n' "$1" >&2; FAILURES=$((FAILURES + 1)); }
have() {
local name=$1 link=$2
if ! command -v "$name" >/dev/null 2>&1; then
fail "$name is missing — install from $link"
return 1
fi
}
exact_version() {
local name=$1 expected=$2 actual=$3 link=$4
if [[ "$actual" == "$expected" ]]; then printf 'ok: %s %s\n' "$name" "$actual"
else fail "$name must be $expected (found $actual) — install from $link"; fi
}
port_free() {
local port=$1
if (exec 9<>"/dev/tcp/127.0.0.1/$port") 2>/dev/null; then
fail "127.0.0.1:$port is occupied; stop that external service before starting the demo"
else
printf 'ok: 127.0.0.1:%s is available\n' "$port"
fi
}
printf '%s\n' 'Checking the pinned V1 demo environment (read-only).'
if have git https://git-scm.com/downloads; then printf 'ok: %s\n' "$(git --version)"; fi
if have forge https://getfoundry.sh; then exact_version forge 1.7.1 "$(forge --version | sed -nE 's/^forge Version: ([^ ]+).*/\1/p')" https://getfoundry.sh; fi
if have anvil https://getfoundry.sh; then exact_version anvil 1.7.1 "$(anvil --version | sed -nE 's/^anvil Version: ([^ ]+).*/\1/p')" https://getfoundry.sh; fi
if have cast https://getfoundry.sh; then exact_version cast 1.7.1 "$(cast --version | sed -nE 's/^cast Version: ([^ ]+).*/\1/p')" https://getfoundry.sh; fi
if have node https://nodejs.org/en/download; then exact_version Node 24.18.0 "$(node --version | sed 's/^v//')" https://nodejs.org/en/download; fi
if have npm https://docs.npmjs.com/downloading-and-installing-node-js-and-npm; then exact_version npm 11.17.0 "$(npm --version)" https://docs.npmjs.com/downloading-and-installing-node-js-and-npm; fi
if have bash https://www.gnu.org/software/bash/; then printf 'ok: %s\n' "$(bash --version | sed -n '1p')"; fi
if have make https://www.gnu.org/software/make/; then printf 'ok: %s\n' "$(make --version | sed -n '1p')"; fi
if have curl https://curl.se/download.html; then printf 'ok: %s\n' "$(curl --version | sed -n '1p')"; fi
if git -C "$ROOT" submodule status --recursive | while IFS= read -r line; do [[ "$line" == ' '* ]] || exit 1; done; then
printf '%s\n' 'ok: recursive Git submodules are initialized at recorded commits'
else
fail 'recursive Git submodules are missing or differ from recorded commits — run make setup'
fi
if [[ -d "$ROOT/node_modules" ]]; then printf '%s\n' 'ok: root node_modules is installed'; else fail 'root node_modules is missing — run make setup'; fi
if [[ -d "$ROOT/web/node_modules" ]]; then printf '%s\n' 'ok: web node_modules is installed'; else fail 'web node_modules is missing — run make setup'; fi
if command -v node >/dev/null 2>&1 && [[ -d "$ROOT/node_modules" ]]; then
node "$ROOT/tools/check-upgrades-cli.mjs" || fail 'the pinned offline OpenZeppelin upgrades CLI is unavailable — run make setup'
fi
for path in "$ROOT" "$ROOT/deployments" "$ROOT/web/public" "$ROOT/web/src/generated"; do
if [[ -d "$path" && -w "$path" ]]; then printf 'ok: writable runtime directory %s\n' "${path#"$ROOT/"}"; else fail "runtime directory is not writable: $path"; fi
done
if [[ -d "$ROOT/.demo" ]]; then [[ -w "$ROOT/.demo" ]] || fail "$ROOT/.demo is not writable"; fi
port_free 8545
port_free 5173
for variable in BASE_SEPOLIA_RPC_URL BASE_SEPOLIA_ACCOUNT; do
if [[ -n ${!variable:-} ]]; then printf 'optional: %s is configured\n' "$variable"; else printf 'optional: %s is not configured (local demo unaffected)\n' "$variable"; fi
done
if ((FAILURES)); then printf 'Doctor found %d problem(s).\n' "$FAILURES" >&2; exit 1; fi
printf '%s\n' 'Doctor passed.'
+164
View File
@@ -0,0 +1,164 @@
#!/usr/bin/env bash
demo_repository_root() {
cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd -P
}
demo_is_uint() {
[[ ${1:-} =~ ^[0-9]+$ ]]
}
demo_read_one_line() {
local path=$1 destination=$2 value extra
IFS= read -r value <"$path" || return 1
if IFS= read -r extra < <(sed -n '2p' "$path") && [[ -n "$extra" ]]; then return 1; fi
printf -v "$destination" '%s' "$value"
}
demo_start_tick() {
local pid=$1 stat rest
local -a fields
demo_is_uint "$pid" || return 1
[[ -r "/proc/$pid/stat" ]] || return 1
stat=$(<"/proc/$pid/stat")
rest=${stat##*) }
read -r -a fields <<<"$rest"
demo_is_uint "${fields[19]:-}" || return 1
printf '%s\n' "${fields[19]}"
}
demo_process_group() {
local pid=$1 pgid
pgid=$(ps -o pgid= -p "$pid" 2>/dev/null) || return 1
pgid=${pgid//[[:space:]]/}
demo_is_uint "$pgid" || return 1
printf '%s\n' "$pgid"
}
demo_has_sequence() {
local array_name=$1
local -n argv_ref=$array_name
shift
local -a wanted=("$@")
local i j
for ((i = 0; i + ${#wanted[@]} <= ${#argv_ref[@]}; i++)); do
for ((j = 0; j < ${#wanted[@]}; j++)); do
[[ ${argv_ref[i+j]} == "${wanted[j]}" ]] || break
done
((j == ${#wanted[@]})) && return 0
done
return 1
}
demo_command_matches() {
local pid=$1 kind=$2 argument base found=0
local -a arguments=()
[[ -r "/proc/$pid/cmdline" ]] || return 1
mapfile -d '' -t arguments <"/proc/$pid/cmdline"
((${#arguments[@]} > 0)) || return 1
for argument in "${arguments[@]}"; do
base=${argument##*/}
if [[ "$kind" == anvil && "$base" == anvil ]]; then found=1; fi
if [[ "$kind" == vite && ( "$base" == vite || "$base" == vite.js ) ]]; then found=1; fi
done
((found == 1)) || return 1
if [[ "$kind" == anvil ]]; then
demo_has_sequence arguments --host 127.0.0.1 || return 1
demo_has_sequence arguments --port 8545 || return 1
demo_has_sequence arguments --chain-id 31337 || return 1
elif [[ "$kind" == vite ]]; then
demo_has_sequence arguments web --host 127.0.0.1 --port 5173 || return 1
else
return 1
fi
}
demo_remove_record() {
local root=$1 kind=$2 path
for path in "$root/.demo/$kind.pid" "$root/.demo/$kind.start" "$root/.demo/$kind.pgid"; do
if [[ -e "$path" ]]; then
rm -f -- "$path"
printf 'Removed %s\n' "${path#"$root/"}"
fi
done
}
demo_record_process() {
local root=$1 kind=$2 pid=$3 start pgid
[[ "$kind" == anvil || "$kind" == vite ]] || return 1
demo_is_uint "$pid" || return 1
start=$(demo_start_tick "$pid") || return 1
pgid=$(demo_process_group "$pid") || return 1
[[ "$pid" == "$pgid" ]] || return 1
demo_command_matches "$pid" "$kind" || return 1
mkdir -p "$root/.demo"
chmod 0700 "$root/.demo"
printf '%s\n' "$pid" >"$root/.demo/$kind.pid"
printf '%s\n' "$start" >"$root/.demo/$kind.start"
printf '%s\n' "$pgid" >"$root/.demo/$kind.pgid"
chmod 0600 "$root/.demo/$kind.pid" "$root/.demo/$kind.start" "$root/.demo/$kind.pgid"
}
demo_pid_is_running() {
local pid=$1 stat rest state
{ IFS= read -r stat <"/proc/$pid/stat"; } 2>/dev/null || return 1
rest=${stat##*) }
state=${rest%% *}
[[ "$state" != Z ]]
}
demo_stop_recorded() {
local root=$1 kind=$2 pid start pgid actual_start actual_pgid
local pid_path="$root/.demo/$kind.pid"
local start_path="$root/.demo/$kind.start"
local pgid_path="$root/.demo/$kind.pgid"
[[ "$kind" == anvil || "$kind" == vite ]] || return 1
[[ -e "$pid_path" ]] || return 0
if ! demo_read_one_line "$pid_path" pid || [[ ! "$pid" =~ ^[1-9][0-9]*$ ]] || ((10#$pid <= 1)); then
printf 'Refusing invalid %s PID record\n' "$kind" >&2
return 1
fi
if ! demo_pid_is_running "$pid"; then
printf 'Discarding stale %s process record for PID %s\n' "$kind" "$pid"
demo_remove_record "$root" "$kind"
return 0
fi
if ! demo_read_one_line "$start_path" start || ! demo_is_uint "$start"; then
printf 'Refusing incomplete %s start-tick record\n' "$kind" >&2
return 1
fi
if ! demo_read_one_line "$pgid_path" pgid || [[ ! "$pgid" =~ ^[1-9][0-9]*$ ]] || ((10#$pgid <= 1)); then
printf 'Refusing incomplete %s process-group record\n' "$kind" >&2
return 1
fi
actual_start=$(demo_start_tick "$pid") || return 1
actual_pgid=$(demo_process_group "$pid") || return 1
if [[ "$actual_start" != "$start" || "$actual_pgid" != "$pgid" || "$pid" != "$pgid" ]]; then
printf 'Refusing %s PID %s: recorded identity does not match\n' "$kind" "$pid" >&2
return 1
fi
if ! demo_command_matches "$pid" "$kind"; then
printf 'Refusing %s PID %s: command signature does not match\n' "$kind" "$pid" >&2
return 1
fi
printf 'Stopping validated %s process group %s\n' "$kind" "$pgid"
kill -TERM -- "-$pgid"
for _ in {1..50}; do
demo_pid_is_running "$pid" || break
sleep 0.1
done
if demo_pid_is_running "$pid"; then
printf 'Escalating validated %s process group %s to KILL\n' "$kind" "$pgid"
kill -KILL -- "-$pgid"
for _ in {1..20}; do
demo_pid_is_running "$pid" || break
sleep 0.1
done
fi
wait "$pid" 2>/dev/null || true
demo_pid_is_running "$pid" && {
printf 'Validated %s process group %s did not stop\n' "$kind" "$pgid" >&2
return 1
}
demo_remove_record "$root" "$kind"
}
+45
View File
@@ -0,0 +1,45 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd -P)
# shellcheck source=process-lib.sh
source "$ROOT/tools/process-lib.sh"
remove_exact() {
local path=$1
if [[ -e "$path" ]]; then
rm -f -- "$path"
printf 'Removed %s\n' "${path#"$ROOT/"}"
fi
}
remove_local_manifest() {
local path=$1
[[ -e "$path" ]] || return 0
if node -e 'const fs=require("fs"); const value=JSON.parse(fs.readFileSync(process.argv[1],"utf8")); process.exit(value && value.network === "anvil" && value.chainId === 31337 ? 0 : 1)' "$path" 2>/dev/null; then
remove_exact "$path"
else
printf 'Preserved non-local or invalid manifest %s\n' "${path#"$ROOT/"}"
fi
}
demo_stop_recorded "$ROOT" vite
demo_stop_recorded "$ROOT" anvil
remove_exact "$ROOT/.demo/anvil.pid"
remove_exact "$ROOT/.demo/anvil.start"
remove_exact "$ROOT/.demo/anvil.pgid"
remove_exact "$ROOT/.demo/anvil.log"
remove_exact "$ROOT/.demo/vite.pid"
remove_exact "$ROOT/.demo/vite.start"
remove_exact "$ROOT/.demo/vite.pgid"
remove_exact "$ROOT/.demo/vite.log"
remove_local_manifest "$ROOT/deployments/pending.json"
remove_local_manifest "$ROOT/deployments/anvil.json"
remove_local_manifest "$ROOT/deployments/active.json"
remove_local_manifest "$ROOT/web/public/deployment.json"
remove_exact "$ROOT/web/src/generated/contracts.ts"
rmdir -- "$ROOT/.demo" 2>/dev/null || true
printf '%s\n' 'Local generated state is reproducible with make demo-local.'
+53
View File
@@ -0,0 +1,53 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd -P)
cd "$ROOT"
mapfile -d '' -t TRACKED < <(git ls-files -z --cached --others --exclude-standard -- ':!docs/superpowers/**' ':!foundry.lock' ':!package-lock.json' ':!web/package-lock.json')
secret_names='PRIVATE''_KEY|MNEM''ONIC'
assignment_pattern="(^|[^[:alnum:]_])(${secret_names})[[:space:]]*="
pem_pattern='-----BEGIN .*PRI''VATE KEY-----'
unfinished_pattern='(^|[^[:alnum:]_])(TO''DO|T''BD|FIX''ME)([^[:alnum:]_]|$)'
filler_pattern='lorem[[:space:]]+ip''sum|fill''er[[:space:]]+text'
unsafe_pattern='unsafe''Allow|unsafe''SkipStorageCheck|unsafe''SkipAllChecks|oz-upgrades-unsafe-allow'
allowed_annotation=' /// @custom:oz-upgrades-unsafe-allow constructor'
fixture_name='ANVIL_''TEST_PHRASE'
fixture_value='test test test test test test test test test test test junk'
violations=0
report_matches() {
local path=$1 pattern=$2 label=$3 line number=0
while IFS= read -r line || [[ -n "$line" ]]; do
number=$((number + 1))
if [[ "$line" =~ $pattern ]]; then
printf 'forbidden %s: %s:%d:%s\n' "$label" "$path" "$number" "$line" >&2
violations=$((violations + 1))
fi
done <"$path"
}
for path in "${TRACKED[@]}"; do
[[ "$path" == lib/* || ! -f "$path" ]] && continue
report_matches "$path" "$assignment_pattern" 'secret assignment'
report_matches "$path" "$pem_pattern" 'PEM private key'
report_matches "$path" "$unfinished_pattern" 'unfinished marker'
report_matches "$path" "$filler_pattern" 'filler content'
while IFS= read -r line || [[ -n "$line" ]]; do
if [[ "$line" == *"$fixture_name"*'='* && "$line" != *"$fixture_value"* ]]; then
printf 'forbidden non-fixture local phrase assignment: %s:%s\n' "$path" "$line" >&2
violations=$((violations + 1))
fi
done <"$path"
if [[ "$path" == src/* || "$path" == test/* || "$path" == script/* ]]; then
while IFS= read -r line || [[ -n "$line" ]]; do
if [[ "$line" =~ $unsafe_pattern && "$path:$line" != "src/BankV1.sol:$allowed_annotation" ]]; then
printf 'forbidden unsafe upgrade bypass: %s:%s\n' "$path" "$line" >&2
violations=$((violations + 1))
fi
done <"$path"
fi
done
((violations == 0)) || { printf 'Project scan failed with %d violation(s).\n' "$violations" >&2; exit 1; }
printf 'Project scan passed across %d tracked paths.\n' "${#TRACKED[@]}"
+201
View File
@@ -0,0 +1,201 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd -P)
PROCESS_LIB="$ROOT/tools/process-lib.sh"
RESET_SCRIPT="$ROOT/tools/reset-local.sh"
TEST_ROOT=$(mktemp -d /tmp/uups-bank-process-test.XXXXXX)
declare -a TEST_PIDS=()
PASSED=0
STARTED_PID=
cleanup() {
local pid
for pid in "${TEST_PIDS[@]}"; do
if [[ "$pid" =~ ^[0-9]+$ ]] && kill -0 "$pid" 2>/dev/null; then
kill -TERM -- "-$pid" 2>/dev/null || kill -TERM -- "$pid" 2>/dev/null || true
wait "$pid" 2>/dev/null || true
fi
done
rm -rf -- "$TEST_ROOT"
}
trap cleanup EXIT
fail() { printf 'FAIL: %s\n' "$*" >&2; exit 1; }
pass() { PASSED=$((PASSED + 1)); printf 'ok %d - %s\n' "$PASSED" "$1"; }
assert_exists() { [[ -e "$1" ]] || fail "expected $1 to exist"; }
assert_absent() { [[ ! -e "$1" ]] || fail "expected $1 to be absent"; }
assert_dead() { ! kill -0 "$1" 2>/dev/null || fail "expected PID $1 to be stopped"; }
assert_alive() { kill -0 "$1" 2>/dev/null || fail "expected PID $1 to remain alive"; }
# RED gate: these are the missing production interfaces this suite specifies.
[[ -f "$PROCESS_LIB" ]] || fail "missing process library: $PROCESS_LIB"
[[ -x "$RESET_SCRIPT" ]] || fail "missing executable reset script: $RESET_SCRIPT"
# shellcheck source=process-lib.sh
source "$PROCESS_LIB"
make_root() {
local root="$TEST_ROOT/$1"
mkdir -p "$root/.demo" "$root/tools" "$root/deployments" "$root/web/public" "$root/web/src/generated"
cp "$PROCESS_LIB" "$RESET_SCRIPT" "$root/tools/"
chmod +x "$root/tools/reset-local.sh"
printf '%s\n' "$root"
}
write_record() {
local root=$1 kind=$2 pid=$3 start=$4 pgid=$5
printf '%s\n' "$pid" >"$root/.demo/$kind.pid"
printf '%s\n' "$start" >"$root/.demo/$kind.start"
printf '%s\n' "$pgid" >"$root/.demo/$kind.pgid"
}
start_tick() {
local stat rest
local -a fields
stat=$(<"/proc/$1/stat")
rest=${stat##*) }
read -r -a fields <<<"$rest"
printf '%s\n' "${fields[19]}"
}
start_owned() {
local root=$1 kind=$2 helper="$TEST_ROOT/bin/$2"
mkdir -p "$TEST_ROOT/bin"
cat >"$helper" <<'HELPER'
#!/usr/bin/env bash
sleep 120 &
wait
HELPER
chmod +x "$helper"
if [[ "$kind" == anvil ]]; then
setsid "$helper" --host 127.0.0.1 --port 8545 --chain-id 31337 >/dev/null 2>&1 &
else
setsid "$helper" web --host 127.0.0.1 --port 5173 >/dev/null 2>&1 &
fi
local pid=$!
TEST_PIDS+=("$pid")
write_record "$root" "$kind" "$pid" "$(start_tick "$pid")" "$pid"
STARTED_PID=$pid
}
printf '1..11\n'
# Catches cleanup treating a missing record as an error or signaling an inferred PID.
root=$(make_root absent)
demo_stop_recorded "$root" anvil
pass 'an absent PID file is a no-op'
# Catches stale PID metadata accumulating or being treated as a live target.
root=$(make_root stale)
write_record "$root" anvil 999999999 1 999999999
demo_stop_recorded "$root" anvil
assert_absent "$root/.demo/anvil.pid"
assert_absent "$root/.demo/anvil.start"
assert_absent "$root/.demo/anvil.pgid"
pass 'a stale numeric PID record is removed'
# Catches unvalidated PID text reaching kill or shell option parsing.
root=$(make_root nonnumeric)
write_record "$root" anvil 'not-a-pid' 1 1
if demo_stop_recorded "$root" anvil 2>/dev/null; then fail 'nonnumeric PID was accepted'; fi
assert_exists "$root/.demo/anvil.pid"
pass 'a nonnumeric PID is rejected'
# Catches PID collision signaling an unrelated live process with the wrong command.
root=$(make_root wrong-command)
setsid /bin/sleep 120 & wrong_pid=$!
TEST_PIDS+=("$wrong_pid")
write_record "$root" anvil "$wrong_pid" "$(start_tick "$wrong_pid")" "$wrong_pid"
if demo_stop_recorded "$root" anvil 2>/dev/null; then fail 'wrong command signature was accepted'; fi
assert_alive "$wrong_pid"
pass 'a live PID with the wrong command signature is never signaled'
# Catches recycled PID ownership being inferred from PID and command alone.
root=$(make_root reused)
start_owned "$root" anvil
reused_pid=$STARTED_PID
printf '%s\n' "$(( $(start_tick "$reused_pid") + 1 ))" >"$root/.demo/anvil.start"
if demo_stop_recorded "$root" anvil 2>/dev/null; then fail 'mismatched start tick was accepted'; fi
assert_alive "$reused_pid"
pass 'PID reuse is rejected by recorded process start tick'
# Catches a validated project child not being terminated and reaped.
root=$(make_root matching)
start_owned "$root" anvil
matching_pid=$STARTED_PID
demo_stop_recorded "$root" anvil
wait "$matching_pid" 2>/dev/null || true
assert_dead "$matching_pid"
assert_absent "$root/.demo/anvil.pid"
pass 'a matching project-started child is terminated and reaped'
# Catches stopping only the Vite leader and orphaning a child, or broad group signaling.
root=$(make_root group)
group_helper="$TEST_ROOT/bin/vite"
child_file="$TEST_ROOT/vite-child.pid"
cat >"$group_helper" <<'HELPER'
#!/usr/bin/env bash
sleep 120 &
printf '%s\n' "$!" >"$DEMO_CHILD_PID_FILE"
wait
HELPER
chmod +x "$group_helper"
DEMO_CHILD_PID_FILE="$child_file" setsid "$group_helper" web --host 127.0.0.1 --port 5173 & group_pid=$!
TEST_PIDS+=("$group_pid")
for _ in {1..50}; do [[ -s "$child_file" ]] && break; sleep 0.02; done
[[ -s "$child_file" ]] || fail 'Vite helper child did not start'
child_pid=$(<"$child_file")
setsid /bin/sleep 120 & unrelated_pid=$!
TEST_PIDS+=("$unrelated_pid")
write_record "$root" vite "$group_pid" "$(start_tick "$group_pid")" "$group_pid"
demo_stop_recorded "$root" vite
wait "$group_pid" 2>/dev/null || true
for _ in {1..50}; do ! kill -0 "$child_pid" 2>/dev/null && break; sleep 0.02; done
assert_dead "$child_pid"
assert_alive "$unrelated_pid"
pass 'an owned process group is stopped completely while an unrelated group survives'
# Catches reset deleting arbitrary neighbors or leaving known reproducible local artifacts.
root=$(make_root local-reset)
for kind in anvil vite; do printf 'log\n' >"$root/.demo/$kind.log"; printf '1\n' >"$root/.demo/$kind.start"; printf '1\n' >"$root/.demo/$kind.pgid"; done
printf 'sentinel\n' >"$root/.demo/sentinel"
for path in deployments/pending.json deployments/anvil.json deployments/active.json web/public/deployment.json; do
printf '{"network":"anvil","chainId":31337}\n' >"$root/$path"
done
printf 'generated ABI\n' >"$root/web/src/generated/contracts.ts"
(cd "$root" && bash tools/reset-local.sh >/dev/null)
for path in .demo/anvil.pid .demo/anvil.start .demo/anvil.pgid .demo/anvil.log .demo/vite.pid .demo/vite.start .demo/vite.pgid .demo/vite.log deployments/pending.json deployments/anvil.json deployments/active.json web/public/deployment.json web/src/generated/contracts.ts; do
assert_absent "$root/$path"
done
assert_exists "$root/.demo/sentinel"
pass 'reset removes only explicitly known local runtime and generated files'
# Catches a local reset corrupting a canonical Base Sepolia deployment.
root=$(make_root base-canonical)
printf '{"network":"baseSepolia","chainId":84532,"marker":"canonical"}\n' >"$root/deployments/base-sepolia.json"
before=$(sha256sum "$root/deployments/base-sepolia.json")
(cd "$root" && bash tools/reset-local.sh >/dev/null)
after=$(sha256sum "$root/deployments/base-sepolia.json")
[[ "$before" == "$after" ]] || fail 'Base canonical manifest changed'
pass 'a Base canonical manifest survives reset byte-for-byte'
# Catches a local reset deleting or rewriting selected and browser-copied Base state.
root=$(make_root base-active)
base='{"network":"baseSepolia","chainId":84532,"marker":"active"}'
printf '%s\n' "$base" >"$root/deployments/active.json"
printf '%s\n' "$base" >"$root/web/public/deployment.json"
active_before=$(sha256sum "$root/deployments/active.json")
browser_before=$(sha256sum "$root/web/public/deployment.json")
(cd "$root" && bash tools/reset-local.sh >/dev/null)
[[ "$active_before" == "$(sha256sum "$root/deployments/active.json")" ]] || fail 'Base active manifest changed'
[[ "$browser_before" == "$(sha256sum "$root/web/public/deployment.json")" ]] || fail 'Base browser manifest changed'
pass 'Base active and browser manifests survive reset byte-for-byte'
# Catches cleanup broadening from exact files to recursive .demo deletion.
root=$(make_root sentinel)
printf 'keep me\n' >"$root/.demo/adjacent.keep"
(cd "$root" && bash tools/reset-local.sh >/dev/null)
assert_exists "$root/.demo/adjacent.keep"
pass 'a sentinel adjacent to runtime records survives'
printf 'PASS: %d process-safety cases\n' "$PASSED"