docs: prepare repeatable V1 presentation
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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).
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
Executable
+73
@@ -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
@@ -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.'
|
||||
|
||||
Executable
+164
@@ -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"
|
||||
}
|
||||
Executable
+45
@@ -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.'
|
||||
Executable
+53
@@ -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[@]}"
|
||||
Executable
+201
@@ -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"
|
||||
Reference in New Issue
Block a user