From 46b108af2742cfafe1b2dc30561b3d73bbc7ab79 Mon Sep 17 00:00:00 2001 From: Amie Corso Date: Wed, 10 Jun 2026 09:42:30 -0700 Subject: [PATCH 1/2] docs(smoke): add quickstart runner + README for local b20 smoketests --- Makefile | 10 +- script/smoke/README.md | 175 ++++++++++++++++++++++++++++++++ script/smoke/run-smoke-local.sh | 153 ++++++++++++++++++++++++++++ 3 files changed, 337 insertions(+), 1 deletion(-) create mode 100644 script/smoke/README.md create mode 100755 script/smoke/run-smoke-local.sh diff --git a/Makefile b/Makefile index 2a9ce0a..956237b 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: build coverage smoke smoke-all smoke-factory smoke-asset smoke-stablecoin smoke-policy smoke-invariants smoke-setup +.PHONY: build coverage smoke smoke-all smoke-factory smoke-asset smoke-stablecoin smoke-policy smoke-invariants smoke-setup smoke-local # Generate an lcov coverage report and open it in the browser. # Scoped to src/ and test/lib/mocks/ (excludes test runner files and the smoke probe helper). @@ -56,3 +56,11 @@ smoke-policy: build smoke-invariants: build @$(SMOKE_RUN) invariants + +# Zero-config local run: spins up a `--base` anvil, activates the b20 features, +# and runs every journey against it using anvil's pre-funded dev accounts (no +# .env, no manual funding). Needs a base-anvil binary (see FORK_TESTING.md); the +# script creates the venv and builds for you. For a journey subset or a remote +# chain, call the script / `make smoke-*` directly — see script/smoke/README.md. +smoke-local: + @./script/smoke/run-smoke-local.sh diff --git a/script/smoke/README.md b/script/smoke/README.md new file mode 100644 index 0000000..1d712c7 --- /dev/null +++ b/script/smoke/README.md @@ -0,0 +1,175 @@ +# b20 precompile smoketest + +A lightweight, dependency-thin smoketest that drives the b20 precompiles +(`B20Factory`, `B20Asset`, `B20Stablecoin`, `PolicyRegistry`) by sending **real +transactions to a live JSON-RPC endpoint**. It is the runbook check for +precompile bring-up: point it at a chain where the b20 features are activated and +it walks the full operator lifecycle of each precompile, asserting balances, +events, and revert reasons against the real Rust implementation. + +It is deliberately *not* a Foundry test. The harness is plain +[`web3.py`](https://web3py.readthedocs.io/) talking directly to RPC, so it has no +dependency on `forge`'s in-process EVM. The only thing it borrows from the build +is the **interface ABIs**, which it reads straight from `out/` after a +`forge build`, so the surface it binds to always matches the current source. + +## Quickstart (local, zero config) + +One command — spins up a local node with the precompiles, activates the +features, and runs every journey against it: + +```bash +make smoke-local +``` + +That's the whole happy path. It requires a **`base-anvil` binary** (the Foundry +fork that adds the `--base` flag installing the b20 precompiles — build it once, +see [`../../FORK_TESTING.md`](../../FORK_TESTING.md)). Everything else is handled +for you by [`run-smoke-local.sh`](run-smoke-local.sh): + +- creates the Python venv on first run (no separate `make smoke-setup`); +- runs `forge build` so the ABIs are current; +- launches `anvil --base` on port 8546 and waits for it; +- **activates** the three gated features via the ActivationRegistry (impersonates + the activation admin — no key needed locally); +- runs the suite using **two of anvil's pre-funded dev accounts** as the signers; +- tears the node down on exit. + +**Do I need to fund my deployer? No — not locally.** anvil's default dev accounts +start with 10000 ETH each, and the runner uses them, so there is nothing to fund +by hand. (On a real network you can't conjure ether — you supply your own funded +keys; see [Running against a remote chain](#running-against-a-remote-chain).) + +Run a subset, or fail-fast instead of the audit-mode summary, by calling the +script directly (args are forwarded to the CLI): + +```bash +./script/smoke/run-smoke-local.sh factory # one journey, fail-fast +./script/smoke/run-smoke-local.sh asset policy # a subset +./script/smoke/run-smoke-local.sh all # all, fail-fast +# (bare `make smoke-local` == `... all --keep-going`: run all, summarize, exit 0) +``` + +If `make smoke-local` errors with "base-anvil binary not found", build it or +point `ANVIL_BIN=/abs/path/to/anvil` at an existing build. Other knobs: `PORT`, +`ANVIL_LOG`, `PYTHON`. + +## What it checks + +Five "journeys", each runnable on its own or all together: + +| Journey | What it exercises | +|---|---| +| `factory` | Deterministic create + address prediction, the `isB20` / `isB20Initialized` query surface, and creation-time reverts (duplicate salt, bad decimals, bad currency, unknown variant). | +| `asset` | Full Asset-variant lifecycle (18 decimals): mint, transfer, `transferWithMemo`, delegated `transferFrom`, `announce` + `batchMint`, rebase via `updateMultiplier`, metadata, burn, then the gates that must reject (supply cap, pause, role, announcement-id reuse). | +| `stablecoin` | Stablecoin-variant deltas (fixed 6 decimals, immutable currency) plus the regulated freeze-and-seize path (blocklist policy + `burnBlocked`). | +| `policy` | Policy creation (both types), membership, built-in sentinels, the two-step admin transfer lifecycle, and a token actually *enforcing* a policy (`PolicyForbids` on transfer + mint). | +| `invariants` | EVM-context invariants a precompile must implement explicitly: payable rejection, unknown-selector revert, strict ABI decode, dirty-bit canonicalization, `STATICCALL` read-only enforcement, returndata fidelity, OOG containment, revert atomicity, and gas independence from a force-fed balance. Uses the `PrecompileProbe` + `ForceFeeder` helpers under `test/lib/`. | + +Each lifecycle journey ends with a flow-level check that every expected event +type was emitted. The `invariants` journey is a *collect-all audit*: it runs +every check, reports findings at the end, and fails only if a required invariant +did not hold (see [Interpreting output](#interpreting-output)). + +## Running against a remote chain + +The Quickstart targets a local `base-anvil`. To run against any other chain +(fork >= Beryl) that already has the b20 features activated, drive the Make +targets directly with your own config: + +```bash +make smoke-setup # one-time: create the venv + install web3 +cp .env.template .env # then set RPC_URL, DEPLOYER_PK, USER2_PK +make smoke-all KEEP_GOING=1 # or a single journey: make smoke-factory +``` + +Here **you** are responsible for funding: `DEPLOYER_PK` must hold enough ether to +sign the setup/admin txs. If the chain has a faucet, set `FAUCET_URL` + +`FAUCET_NETWORK` in `.env` and the preflight tops the deployer up when it falls +below the floor; otherwise fund it yourself. The repo also defines fork RPC +endpoints in `foundry.toml` (e.g. `vibenet`) you can point `RPC_URL` at, provided +the features are activated there. + +`.env` is gitignored; the Makefile sources it for every smoke recipe and existing +shell env wins over `.env` values. + +### Make targets + +```bash +make smoke-local # local base-anvil, all journeys, audit summary (the Quickstart) +make smoke # run every journey, fail-fast (CI gating default) +make smoke-all # all journeys, single process, fail-fast +make smoke-all KEEP_GOING=1 # all journeys, summarize, exit 0 regardless +make smoke-factory # one journey at a time: factory|asset|stablecoin|policy|invariants +make smoke-setup # create the venv + install web3 (one-time) +``` + +> The `smoke-*` targets set `PYTHONPATH=script` for you. Running `python -m smoke` +> by hand needs that too (and the env exported), else you get `No module named +> smoke` — prefer the Make targets or `run-smoke-local.sh`. + +### Environment / config knobs + +| Var | Required | Default | Meaning | +|---|---|---|---| +| `RPC_URL` | yes (remote) | — | JSON-RPC endpoint to send txs to. Set automatically by `smoke-local`. | +| `DEPLOYER_PK` | yes (remote) | — | Funded key that signs setup/admin txs. A prefunded anvil key under `smoke-local`. | +| `USER2_PK` | yes (remote) | — | Second actor (recipient / non-admin paths). | +| `GAS_FLOAT_ETHER` | no | `0.01` | One-time gas float the deployer sends user2. | +| `SMOKE_SALT` | no | random | Pin the per-run salt namespace (reproducible addresses). | +| `SMOKE_TRACE` | no | `1` | On failure, dump a `debug_traceCall/Transaction` call tree. Set `0` for just the request + replayed revert data. | +| `FAUCET_URL` / `FAUCET_NETWORK` | no | — | Optional deployer top-up when underfunded (remote chains). | +| `FAUCET_AMOUNT` / `FAUCET_MIN_ETHER` | no | `0.05` / `0.02` | Faucet amount and balance floor. | + +## Interpreting output + +Per-step lines are prefixed `→` (step), `✓` (assertion passed), `✗` (failed). +Each journey logs `: OK` on success. A run ends in one of three states per +journey: + +- **pass** — all assertions held. +- **fail** — an assertion or expected revert did not match. For lifecycle + journeys this is fail-fast; the harness dumps the offending call (and a trace + when `SMOKE_TRACE=1`). +- **skip** — the preflight found the b20 features are **not activated** on the + target chain. Reported as chain/fork state, *not* a contract defect: + + ``` + [smoke] b20 features NOT ACTIVE on chain : ActivationRegistry not installed ... fork < Beryl? + [smoke] ... skipping (use the ActivationRegistry to enable). + ``` + + If everything skips, your RPC simply doesn't have the precompiles active. Use + `make smoke-local`, or activate the features on your target chain. + +The `invariants` journey is special: it collects all findings and prints +`N/12 invariants held`. A finding is a precompile behavior to triage, not a flaky +test. To accept a known divergence, add its check name to the `INFORMATIONAL` set +in `journeys/precompile_invariants.py` — it stays reported but no longer fails the +run. + +## Troubleshooting + +| Symptom | Cause / fix | +|---|---| +| `base-anvil binary not found` | `make smoke-local` needs the `--base` anvil. Build it (`FORK_TESTING.md`) or set `ANVIL_BIN=/abs/path`. | +| `No module named smoke` | Running outside `make` / the script. Use the Make targets or export `PYTHONPATH=script`. | +| `RPC_URL did not answer` | Endpoint unreachable. Check the node is up and the URL/port. | +| Everything **skipped** | Target chain doesn't have the b20 features active. Use `make smoke-local`, or activate them on your chain. | +| `deployer ... underfunded ... no faucet configured` | Remote run: fund `DEPLOYER_PK`, or set `FAUCET_URL` + `FAUCET_NETWORK`. | +| `port 8546 already in use` | A node is already bound. `PORT=8547 make smoke-local` or kill the listener. | + +## Package layout + +``` +script/smoke/ + run-smoke-local.sh # zero-config local runner (anvil up -> activate -> run -> teardown) + __main__.py # CLI: python -m smoke [-k]; preflight + dispatch + config.py # addresses, enum/role/feature constants, env -> Config + chain.py # web3 harness: send/read, revert + event assertions, RPC tracing + abis.py # interface ABIs + probe/feeder artifacts, read from out/ + codec.py # the one hand-written encode: createB20 params + initCalls + errors.py # selector -> custom-error-name map (from the ABIs) + journeys/ # factory, asset_lifecycle, stablecoin_lifecycle, policy_registry, precompile_invariants + requirements.txt +``` diff --git a/script/smoke/run-smoke-local.sh b/script/smoke/run-smoke-local.sh new file mode 100755 index 0000000..2961af5 --- /dev/null +++ b/script/smoke/run-smoke-local.sh @@ -0,0 +1,153 @@ +#!/usr/bin/env bash +# run-smoke-local.sh — one-command local smoketest against a base-anvil node. +# +# Stands up everything the b20 smoketest needs and tears it down again: +# 1. Launch a `--base` anvil (installs the b20 precompile suite into the EVM). +# 2. Activate the gated features via the ActivationRegistry (impersonating the +# activation admin — no key needed on a local anvil). +# 3. Run the smoke suite with RPC_URL + two of anvil's PRE-FUNDED dev accounts, +# so there is nothing to fund by hand. +# 4. Tear down anvil regardless of success / failure. +# +# This is the happy-path local runner. For a remote/shared chain, skip this and +# drive `make smoke-*` directly with your own RPC_URL + funded keys (see README). +# +# Any extra arguments are forwarded to the smoke CLI (journey names + flags): +# ./script/smoke/run-smoke-local.sh # all journeys, summary, exit 0 +# ./script/smoke/run-smoke-local.sh factory # just one journey, fail-fast +# ./script/smoke/run-smoke-local.sh asset policy # a subset +# +# Env vars (with defaults): +# ANVIL_BIN path to the patched `--base` anvil binary +# (default: /../base-anvil/target/release/anvil, then debug) +# PORT local RPC port for anvil (default: 8546) +# ANVIL_LOG anvil stdout/stderr log path (default: /tmp/anvil-smoke.log) +# PYTHON python used to create the venv (default: python3.13) +# +# Note: only the patched ANVIL is needed here. The smoke harness talks to the +# node over RPC and reads ABIs from out/, so a stock `forge build` is enough — it +# does NOT need the patched forge that run-fork-tests.sh uses. +# +# Exit codes: 0 suite passed/skipped clean · non-zero suite failed · 2 setup error. + +set -euo pipefail + +# ── Layout ──────────────────────────────────────────────────────────────────── +SMOKE_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "$SMOKE_DIR/../.." && pwd)" +VENV="$SMOKE_DIR/.venv" + +DEFAULT_ANVIL_RELEASE="$REPO_ROOT/../base-anvil/target/release/anvil" +DEFAULT_ANVIL_DEBUG="$REPO_ROOT/../base-anvil/target/debug/anvil" + +PORT="${PORT:-8546}" +ACTIVATION_ADMIN="0x9965507D1a55bcC2695C58ba16FB37d819B0A4dc" +REGISTRY=0x8453000000000000000000000000000000000001 +LOG_FILE="${ANVIL_LOG:-/tmp/anvil-smoke.log}" +PYTHON="${PYTHON:-python3.13}" + +# Standard anvil dev accounts (mnemonic "test test ... junk"), each pre-funded +# with 10000 ETH. Using them as the smoke signers means no manual funding on a +# local node. NEVER use these keys on a real network — they are public. +DEPLOYER_PK=0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80 # acct 0 +USER2_PK=0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d # acct 1 + +# Feature IDs mirror test/lib/mocks/ActivationRegistryFeatureList.sol. +FEATURE_IDS=( + 0xcdcc772fe4cbdb1029f822861176d09e646db96723d4c1e82ddfdeb8163ef54c # B20_ASSET + 0xb582ebae03f16fee49a6763f78df482fb11ae73f103ed0d330bbe556aa90a43f # POLICY_REGISTRY + 0xecfa0def2c10020caaf65e6155aa69c84b24892aaef76eeac52e0e2b3a0b8601 # B20_STABLECOIN +) + +log() { echo "[smoke-local] $*" >&2; } +die() { echo "[smoke-local] ERROR: $*" >&2; exit 2; } + +rpc() { + curl -s -X POST -H "Content-Type: application/json" \ + --data "{\"jsonrpc\":\"2.0\",\"method\":\"$1\",\"params\":$2,\"id\":1}" \ + "http://127.0.0.1:$PORT" +} + +# ── Resolve the patched anvil binary ─────────────────────────────────────────── +if [[ -z "${ANVIL_BIN:-}" ]]; then + if [[ -x "$DEFAULT_ANVIL_RELEASE" ]]; then + ANVIL_BIN="$DEFAULT_ANVIL_RELEASE" + elif [[ -x "$DEFAULT_ANVIL_DEBUG" ]]; then + ANVIL_BIN="$DEFAULT_ANVIL_DEBUG" + else + echo "ERROR: base-anvil binary not found. Expected at:" >&2 + echo " $DEFAULT_ANVIL_RELEASE" >&2 + echo " $DEFAULT_ANVIL_DEBUG" >&2 + echo "Build it (see FORK_TESTING.md):" >&2 + echo " cd ../base-anvil && cargo build --release -p anvil" >&2 + echo "Or point ANVIL_BIN=/abs/path/to/anvil at an existing build." >&2 + exit 2 + fi +fi + +# ── Pre-flight ────────────────────────────────────────────────────────────────── +command -v cast >/dev/null 2>&1 || die "cast not found (install foundry: https://getfoundry.sh)" +command -v forge >/dev/null 2>&1 || die "forge not found (install foundry: https://getfoundry.sh)" +command -v curl >/dev/null 2>&1 || die "curl not found" +lsof -i ":$PORT" >/dev/null 2>&1 && die "port $PORT already in use. Set PORT= or kill the listener." + +# ── Ensure the python venv exists ─────────────────────────────────────────────── +if [[ ! -x "$VENV/bin/python" ]]; then + log "creating smoke venv (one-time)…" + "$PYTHON" -m venv "$VENV" + "$VENV/bin/python" -m pip install --quiet --upgrade pip + "$VENV/bin/python" -m pip install --quiet -r "$SMOKE_DIR/requirements.txt" +fi + +# ── Compile ABIs (stock forge is fine; precompiles live in the node) ──────────── +log "forge build (compiling interface ABIs into out/)…" +( cd "$REPO_ROOT" && forge build >/dev/null ) + +log "anvil: $ANVIL_BIN" +log "port: $PORT" +log "activation admin: $ACTIVATION_ADMIN" +log "log file: $LOG_FILE" + +# ── Launch anvil ──────────────────────────────────────────────────────────────── +log "starting --base anvil…" +"$ANVIL_BIN" --base --base-activation-admin "$ACTIVATION_ADMIN" --port "$PORT" \ + > "$LOG_FILE" 2>&1 & +ANVIL_PID=$! +trap 'kill $ANVIL_PID 2>/dev/null; wait $ANVIL_PID 2>/dev/null; true' EXIT + +for _ in $(seq 1 20); do + rpc eth_chainId '[]' 2>/dev/null | grep -q '"result"' && break + sleep 0.5 + if ! kill -0 $ANVIL_PID 2>/dev/null; then + echo "--- last 20 lines of $LOG_FILE ---" >&2; tail -20 "$LOG_FILE" >&2 + die "anvil exited during startup; see $LOG_FILE" + fi +done +log "anvil up (pid=$ANVIL_PID)" + +# ── Activate the gated features ────────────────────────────────────────────────── +log "funding + impersonating activation admin…" +rpc anvil_setBalance "[\"$ACTIVATION_ADMIN\", \"0xffffffffffffffff\"]" > /dev/null +rpc anvil_impersonateAccount "[\"$ACTIVATION_ADMIN\"]" > /dev/null +for fid in "${FEATURE_IDS[@]}"; do + log "activating feature $fid" + out=$(cast send --rpc-url "http://127.0.0.1:$PORT" --from "$ACTIVATION_ADMIN" \ + --unlocked "$REGISTRY" "activate(bytes32)" "$fid" 2>&1) \ + || die "activation tx failed for $fid:"$'\n'"$out" + echo "$out" | grep -E "^status\b" | head -1 >&2 || die "no status in cast output for $fid" +done + +# ── Run the smoke suite ────────────────────────────────────────────────────────── +# Default to every journey in audit mode (run all, summarize, exit 0) when no +# args are given; otherwise forward the caller's journeys/flags verbatim. +if [[ $# -eq 0 ]]; then + set -- all --keep-going +fi + +log "running smoke suite: $*" +RPC_URL="http://127.0.0.1:$PORT" DEPLOYER_PK="$DEPLOYER_PK" USER2_PK="$USER2_PK" \ + PYTHONPATH="$REPO_ROOT/script" "$VENV/bin/python" -m smoke "$@" +smoke_exit=$? + +log "smoke suite exited $smoke_exit" +exit $smoke_exit From f5d6dda34d6af5b2336f7f3a96335a6bc3c12650 Mon Sep 17 00:00:00 2001 From: Amie Corso Date: Wed, 10 Jun 2026 11:14:18 -0700 Subject: [PATCH 2/2] docs(smoke): drop local anvil quickstart; document RPC-against-a-real-node flow --- Makefile | 10 +-- script/smoke/README.md | 130 ++++++++++----------------- script/smoke/run-smoke-local.sh | 153 -------------------------------- 3 files changed, 48 insertions(+), 245 deletions(-) delete mode 100755 script/smoke/run-smoke-local.sh diff --git a/Makefile b/Makefile index 956237b..2a9ce0a 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: build coverage smoke smoke-all smoke-factory smoke-asset smoke-stablecoin smoke-policy smoke-invariants smoke-setup smoke-local +.PHONY: build coverage smoke smoke-all smoke-factory smoke-asset smoke-stablecoin smoke-policy smoke-invariants smoke-setup # Generate an lcov coverage report and open it in the browser. # Scoped to src/ and test/lib/mocks/ (excludes test runner files and the smoke probe helper). @@ -56,11 +56,3 @@ smoke-policy: build smoke-invariants: build @$(SMOKE_RUN) invariants - -# Zero-config local run: spins up a `--base` anvil, activates the b20 features, -# and runs every journey against it using anvil's pre-funded dev accounts (no -# .env, no manual funding). Needs a base-anvil binary (see FORK_TESTING.md); the -# script creates the venv and builds for you. For a journey subset or a remote -# chain, call the script / `make smoke-*` directly — see script/smoke/README.md. -smoke-local: - @./script/smoke/run-smoke-local.sh diff --git a/script/smoke/README.md b/script/smoke/README.md index 1d712c7..f0138ba 100644 --- a/script/smoke/README.md +++ b/script/smoke/README.md @@ -3,7 +3,7 @@ A lightweight, dependency-thin smoketest that drives the b20 precompiles (`B20Factory`, `B20Asset`, `B20Stablecoin`, `PolicyRegistry`) by sending **real transactions to a live JSON-RPC endpoint**. It is the runbook check for -precompile bring-up: point it at a chain where the b20 features are activated and +precompile bring-up: point it at a node where the b20 features are activated and it walks the full operator lifecycle of each precompile, asserting balances, events, and revert reasons against the real Rust implementation. @@ -13,82 +13,31 @@ dependency on `forge`'s in-process EVM. The only thing it borrows from the build is the **interface ABIs**, which it reads straight from `out/` after a `forge build`, so the surface it binds to always matches the current source. -## Quickstart (local, zero config) +## What you need -One command — spins up a local node with the precompiles, activates the -features, and runs every journey against it: +The suite talks to a **real node over JSON-RPC** that has the b20 features +activated. It is not coupled to any particular node: a remote Base fork +(>= Beryl), or a node you run yourself (for example a local build of +[`base/base`](https://github.com/base/base)) both work, as long as the +precompiles are deployed and the features are switched on in the +ActivationRegistry. The suite does not stand a node up for you and does not fund +anyone for you: you supply the endpoint and two funded keys. -```bash -make smoke-local -``` - -That's the whole happy path. It requires a **`base-anvil` binary** (the Foundry -fork that adds the `--base` flag installing the b20 precompiles — build it once, -see [`../../FORK_TESTING.md`](../../FORK_TESTING.md)). Everything else is handled -for you by [`run-smoke-local.sh`](run-smoke-local.sh): - -- creates the Python venv on first run (no separate `make smoke-setup`); -- runs `forge build` so the ABIs are current; -- launches `anvil --base` on port 8546 and waits for it; -- **activates** the three gated features via the ActivationRegistry (impersonates - the activation admin — no key needed locally); -- runs the suite using **two of anvil's pre-funded dev accounts** as the signers; -- tears the node down on exit. - -**Do I need to fund my deployer? No — not locally.** anvil's default dev accounts -start with 10000 ETH each, and the runner uses them, so there is nothing to fund -by hand. (On a real network you can't conjure ether — you supply your own funded -keys; see [Running against a remote chain](#running-against-a-remote-chain).) - -Run a subset, or fail-fast instead of the audit-mode summary, by calling the -script directly (args are forwarded to the CLI): - -```bash -./script/smoke/run-smoke-local.sh factory # one journey, fail-fast -./script/smoke/run-smoke-local.sh asset policy # a subset -./script/smoke/run-smoke-local.sh all # all, fail-fast -# (bare `make smoke-local` == `... all --keep-going`: run all, summarize, exit 0) -``` - -If `make smoke-local` errors with "base-anvil binary not found", build it or -point `ANVIL_BIN=/abs/path/to/anvil` at an existing build. Other knobs: `PORT`, -`ANVIL_LOG`, `PYTHON`. - -## What it checks - -Five "journeys", each runnable on its own or all together: - -| Journey | What it exercises | -|---|---| -| `factory` | Deterministic create + address prediction, the `isB20` / `isB20Initialized` query surface, and creation-time reverts (duplicate salt, bad decimals, bad currency, unknown variant). | -| `asset` | Full Asset-variant lifecycle (18 decimals): mint, transfer, `transferWithMemo`, delegated `transferFrom`, `announce` + `batchMint`, rebase via `updateMultiplier`, metadata, burn, then the gates that must reject (supply cap, pause, role, announcement-id reuse). | -| `stablecoin` | Stablecoin-variant deltas (fixed 6 decimals, immutable currency) plus the regulated freeze-and-seize path (blocklist policy + `burnBlocked`). | -| `policy` | Policy creation (both types), membership, built-in sentinels, the two-step admin transfer lifecycle, and a token actually *enforcing* a policy (`PolicyForbids` on transfer + mint). | -| `invariants` | EVM-context invariants a precompile must implement explicitly: payable rejection, unknown-selector revert, strict ABI decode, dirty-bit canonicalization, `STATICCALL` read-only enforcement, returndata fidelity, OOG containment, revert atomicity, and gas independence from a force-fed balance. Uses the `PrecompileProbe` + `ForceFeeder` helpers under `test/lib/`. | - -Each lifecycle journey ends with a flow-level check that every expected event -type was emitted. The `invariants` journey is a *collect-all audit*: it runs -every check, reports findings at the end, and fails only if a required invariant -did not hold (see [Interpreting output](#interpreting-output)). - -## Running against a remote chain - -The Quickstart targets a local `base-anvil`. To run against any other chain -(fork >= Beryl) that already has the b20 features activated, drive the Make -targets directly with your own config: +## Running ```bash make smoke-setup # one-time: create the venv + install web3 cp .env.template .env # then set RPC_URL, DEPLOYER_PK, USER2_PK -make smoke-all KEEP_GOING=1 # or a single journey: make smoke-factory +make smoke-all KEEP_GOING=1 # all journeys, audit summary; or one: make smoke-factory ``` -Here **you** are responsible for funding: `DEPLOYER_PK` must hold enough ether to -sign the setup/admin txs. If the chain has a faucet, set `FAUCET_URL` + -`FAUCET_NETWORK` in `.env` and the preflight tops the deployer up when it falls -below the floor; otherwise fund it yourself. The repo also defines fork RPC -endpoints in `foundry.toml` (e.g. `vibenet`) you can point `RPC_URL` at, provided -the features are activated there. +`DEPLOYER_PK` must hold enough ether to sign the setup and admin txs (it also +sends `USER2_PK` a small one-time gas float). You are responsible for funding it: +on a real network you fund it yourself, or, if the chain has a faucet, set +`FAUCET_URL` + `FAUCET_NETWORK` in `.env` and the preflight tops the deployer up +when it falls below the floor. `foundry.toml` also defines fork RPC endpoints +(e.g. `vibenet`) you can point `RPC_URL` at, provided the features are activated +there. `.env` is gitignored; the Makefile sources it for every smoke recipe and existing shell env wins over `.env` values. @@ -96,7 +45,6 @@ shell env wins over `.env` values. ### Make targets ```bash -make smoke-local # local base-anvil, all journeys, audit summary (the Quickstart) make smoke # run every journey, fail-fast (CI gating default) make smoke-all # all journeys, single process, fail-fast make smoke-all KEEP_GOING=1 # all journeys, summarize, exit 0 regardless @@ -106,21 +54,39 @@ make smoke-setup # create the venv + install web3 (one-time) > The `smoke-*` targets set `PYTHONPATH=script` for you. Running `python -m smoke` > by hand needs that too (and the env exported), else you get `No module named -> smoke` — prefer the Make targets or `run-smoke-local.sh`. +> smoke` — prefer the Make targets. The raw CLI takes an arbitrary subset and a +> fail-fast/keep-going flag, e.g. `python -m smoke asset policy -k`. ### Environment / config knobs | Var | Required | Default | Meaning | |---|---|---|---| -| `RPC_URL` | yes (remote) | — | JSON-RPC endpoint to send txs to. Set automatically by `smoke-local`. | -| `DEPLOYER_PK` | yes (remote) | — | Funded key that signs setup/admin txs. A prefunded anvil key under `smoke-local`. | -| `USER2_PK` | yes (remote) | — | Second actor (recipient / non-admin paths). | +| `RPC_URL` | yes | — | JSON-RPC endpoint to send txs to. | +| `DEPLOYER_PK` | yes | — | Funded key that signs setup/admin txs. | +| `USER2_PK` | yes | — | Second actor (recipient / non-admin paths). | | `GAS_FLOAT_ETHER` | no | `0.01` | One-time gas float the deployer sends user2. | | `SMOKE_SALT` | no | random | Pin the per-run salt namespace (reproducible addresses). | | `SMOKE_TRACE` | no | `1` | On failure, dump a `debug_traceCall/Transaction` call tree. Set `0` for just the request + replayed revert data. | -| `FAUCET_URL` / `FAUCET_NETWORK` | no | — | Optional deployer top-up when underfunded (remote chains). | +| `FAUCET_URL` / `FAUCET_NETWORK` | no | — | Optional deployer top-up when underfunded. | | `FAUCET_AMOUNT` / `FAUCET_MIN_ETHER` | no | `0.05` / `0.02` | Faucet amount and balance floor. | +## What it checks + +Five "journeys", each runnable on its own or all together: + +| Journey | What it exercises | +|---|---| +| `factory` | Deterministic create + address prediction, the `isB20` / `isB20Initialized` query surface, and creation-time reverts (duplicate salt, bad decimals, bad currency, unknown variant). | +| `asset` | Full Asset-variant lifecycle (18 decimals): mint, transfer, `transferWithMemo`, delegated `transferFrom`, `announce` + `batchMint`, rebase via `updateMultiplier`, metadata, burn, then the gates that must reject (supply cap, pause, role, announcement-id reuse). | +| `stablecoin` | Stablecoin-variant deltas (fixed 6 decimals, immutable currency) plus the regulated freeze-and-seize path (blocklist policy + `burnBlocked`). | +| `policy` | Policy creation (both types), membership, built-in sentinels, the two-step admin transfer lifecycle, and a token actually *enforcing* a policy (`PolicyForbids` on transfer + mint). | +| `invariants` | EVM-context invariants a precompile must implement explicitly: payable rejection, unknown-selector revert, strict ABI decode, dirty-bit canonicalization, `STATICCALL` read-only enforcement, returndata fidelity, OOG containment, revert atomicity, and gas independence from a force-fed balance. Uses the `PrecompileProbe` + `ForceFeeder` helpers under `test/lib/`. | + +Each lifecycle journey ends with a flow-level check that every expected event +type was emitted. The `invariants` journey is a *collect-all audit*: it runs +every check, reports findings at the end, and fails only if a required invariant +did not hold (see [Interpreting output](#interpreting-output)). + ## Interpreting output Per-step lines are prefixed `→` (step), `✓` (assertion passed), `✗` (failed). @@ -139,8 +105,9 @@ journey: [smoke] ... skipping (use the ActivationRegistry to enable). ``` - If everything skips, your RPC simply doesn't have the precompiles active. Use - `make smoke-local`, or activate the features on your target chain. + If everything skips, your RPC simply doesn't have the precompiles active. + Activate the b20 features in the ActivationRegistry, or point `RPC_URL` at a + node that already has them. The `invariants` journey is special: it collects all findings and prints `N/12 invariants held`. A finding is a precompile behavior to triage, not a flaky @@ -152,18 +119,15 @@ run. | Symptom | Cause / fix | |---|---| -| `base-anvil binary not found` | `make smoke-local` needs the `--base` anvil. Build it (`FORK_TESTING.md`) or set `ANVIL_BIN=/abs/path`. | -| `No module named smoke` | Running outside `make` / the script. Use the Make targets or export `PYTHONPATH=script`. | +| `No module named smoke` | Running outside `make`. Use the Make targets or export `PYTHONPATH=script`. | | `RPC_URL did not answer` | Endpoint unreachable. Check the node is up and the URL/port. | -| Everything **skipped** | Target chain doesn't have the b20 features active. Use `make smoke-local`, or activate them on your chain. | -| `deployer ... underfunded ... no faucet configured` | Remote run: fund `DEPLOYER_PK`, or set `FAUCET_URL` + `FAUCET_NETWORK`. | -| `port 8546 already in use` | A node is already bound. `PORT=8547 make smoke-local` or kill the listener. | +| Everything **skipped** | Target node doesn't have the b20 features active. Activate them in the ActivationRegistry, or point `RPC_URL` at a node that has them. | +| `deployer ... underfunded ... no faucet configured` | Fund `DEPLOYER_PK`, or set `FAUCET_URL` + `FAUCET_NETWORK`. | ## Package layout ``` script/smoke/ - run-smoke-local.sh # zero-config local runner (anvil up -> activate -> run -> teardown) __main__.py # CLI: python -m smoke [-k]; preflight + dispatch config.py # addresses, enum/role/feature constants, env -> Config chain.py # web3 harness: send/read, revert + event assertions, RPC tracing diff --git a/script/smoke/run-smoke-local.sh b/script/smoke/run-smoke-local.sh deleted file mode 100755 index 2961af5..0000000 --- a/script/smoke/run-smoke-local.sh +++ /dev/null @@ -1,153 +0,0 @@ -#!/usr/bin/env bash -# run-smoke-local.sh — one-command local smoketest against a base-anvil node. -# -# Stands up everything the b20 smoketest needs and tears it down again: -# 1. Launch a `--base` anvil (installs the b20 precompile suite into the EVM). -# 2. Activate the gated features via the ActivationRegistry (impersonating the -# activation admin — no key needed on a local anvil). -# 3. Run the smoke suite with RPC_URL + two of anvil's PRE-FUNDED dev accounts, -# so there is nothing to fund by hand. -# 4. Tear down anvil regardless of success / failure. -# -# This is the happy-path local runner. For a remote/shared chain, skip this and -# drive `make smoke-*` directly with your own RPC_URL + funded keys (see README). -# -# Any extra arguments are forwarded to the smoke CLI (journey names + flags): -# ./script/smoke/run-smoke-local.sh # all journeys, summary, exit 0 -# ./script/smoke/run-smoke-local.sh factory # just one journey, fail-fast -# ./script/smoke/run-smoke-local.sh asset policy # a subset -# -# Env vars (with defaults): -# ANVIL_BIN path to the patched `--base` anvil binary -# (default: /../base-anvil/target/release/anvil, then debug) -# PORT local RPC port for anvil (default: 8546) -# ANVIL_LOG anvil stdout/stderr log path (default: /tmp/anvil-smoke.log) -# PYTHON python used to create the venv (default: python3.13) -# -# Note: only the patched ANVIL is needed here. The smoke harness talks to the -# node over RPC and reads ABIs from out/, so a stock `forge build` is enough — it -# does NOT need the patched forge that run-fork-tests.sh uses. -# -# Exit codes: 0 suite passed/skipped clean · non-zero suite failed · 2 setup error. - -set -euo pipefail - -# ── Layout ──────────────────────────────────────────────────────────────────── -SMOKE_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -REPO_ROOT="$(cd "$SMOKE_DIR/../.." && pwd)" -VENV="$SMOKE_DIR/.venv" - -DEFAULT_ANVIL_RELEASE="$REPO_ROOT/../base-anvil/target/release/anvil" -DEFAULT_ANVIL_DEBUG="$REPO_ROOT/../base-anvil/target/debug/anvil" - -PORT="${PORT:-8546}" -ACTIVATION_ADMIN="0x9965507D1a55bcC2695C58ba16FB37d819B0A4dc" -REGISTRY=0x8453000000000000000000000000000000000001 -LOG_FILE="${ANVIL_LOG:-/tmp/anvil-smoke.log}" -PYTHON="${PYTHON:-python3.13}" - -# Standard anvil dev accounts (mnemonic "test test ... junk"), each pre-funded -# with 10000 ETH. Using them as the smoke signers means no manual funding on a -# local node. NEVER use these keys on a real network — they are public. -DEPLOYER_PK=0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80 # acct 0 -USER2_PK=0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d # acct 1 - -# Feature IDs mirror test/lib/mocks/ActivationRegistryFeatureList.sol. -FEATURE_IDS=( - 0xcdcc772fe4cbdb1029f822861176d09e646db96723d4c1e82ddfdeb8163ef54c # B20_ASSET - 0xb582ebae03f16fee49a6763f78df482fb11ae73f103ed0d330bbe556aa90a43f # POLICY_REGISTRY - 0xecfa0def2c10020caaf65e6155aa69c84b24892aaef76eeac52e0e2b3a0b8601 # B20_STABLECOIN -) - -log() { echo "[smoke-local] $*" >&2; } -die() { echo "[smoke-local] ERROR: $*" >&2; exit 2; } - -rpc() { - curl -s -X POST -H "Content-Type: application/json" \ - --data "{\"jsonrpc\":\"2.0\",\"method\":\"$1\",\"params\":$2,\"id\":1}" \ - "http://127.0.0.1:$PORT" -} - -# ── Resolve the patched anvil binary ─────────────────────────────────────────── -if [[ -z "${ANVIL_BIN:-}" ]]; then - if [[ -x "$DEFAULT_ANVIL_RELEASE" ]]; then - ANVIL_BIN="$DEFAULT_ANVIL_RELEASE" - elif [[ -x "$DEFAULT_ANVIL_DEBUG" ]]; then - ANVIL_BIN="$DEFAULT_ANVIL_DEBUG" - else - echo "ERROR: base-anvil binary not found. Expected at:" >&2 - echo " $DEFAULT_ANVIL_RELEASE" >&2 - echo " $DEFAULT_ANVIL_DEBUG" >&2 - echo "Build it (see FORK_TESTING.md):" >&2 - echo " cd ../base-anvil && cargo build --release -p anvil" >&2 - echo "Or point ANVIL_BIN=/abs/path/to/anvil at an existing build." >&2 - exit 2 - fi -fi - -# ── Pre-flight ────────────────────────────────────────────────────────────────── -command -v cast >/dev/null 2>&1 || die "cast not found (install foundry: https://getfoundry.sh)" -command -v forge >/dev/null 2>&1 || die "forge not found (install foundry: https://getfoundry.sh)" -command -v curl >/dev/null 2>&1 || die "curl not found" -lsof -i ":$PORT" >/dev/null 2>&1 && die "port $PORT already in use. Set PORT= or kill the listener." - -# ── Ensure the python venv exists ─────────────────────────────────────────────── -if [[ ! -x "$VENV/bin/python" ]]; then - log "creating smoke venv (one-time)…" - "$PYTHON" -m venv "$VENV" - "$VENV/bin/python" -m pip install --quiet --upgrade pip - "$VENV/bin/python" -m pip install --quiet -r "$SMOKE_DIR/requirements.txt" -fi - -# ── Compile ABIs (stock forge is fine; precompiles live in the node) ──────────── -log "forge build (compiling interface ABIs into out/)…" -( cd "$REPO_ROOT" && forge build >/dev/null ) - -log "anvil: $ANVIL_BIN" -log "port: $PORT" -log "activation admin: $ACTIVATION_ADMIN" -log "log file: $LOG_FILE" - -# ── Launch anvil ──────────────────────────────────────────────────────────────── -log "starting --base anvil…" -"$ANVIL_BIN" --base --base-activation-admin "$ACTIVATION_ADMIN" --port "$PORT" \ - > "$LOG_FILE" 2>&1 & -ANVIL_PID=$! -trap 'kill $ANVIL_PID 2>/dev/null; wait $ANVIL_PID 2>/dev/null; true' EXIT - -for _ in $(seq 1 20); do - rpc eth_chainId '[]' 2>/dev/null | grep -q '"result"' && break - sleep 0.5 - if ! kill -0 $ANVIL_PID 2>/dev/null; then - echo "--- last 20 lines of $LOG_FILE ---" >&2; tail -20 "$LOG_FILE" >&2 - die "anvil exited during startup; see $LOG_FILE" - fi -done -log "anvil up (pid=$ANVIL_PID)" - -# ── Activate the gated features ────────────────────────────────────────────────── -log "funding + impersonating activation admin…" -rpc anvil_setBalance "[\"$ACTIVATION_ADMIN\", \"0xffffffffffffffff\"]" > /dev/null -rpc anvil_impersonateAccount "[\"$ACTIVATION_ADMIN\"]" > /dev/null -for fid in "${FEATURE_IDS[@]}"; do - log "activating feature $fid" - out=$(cast send --rpc-url "http://127.0.0.1:$PORT" --from "$ACTIVATION_ADMIN" \ - --unlocked "$REGISTRY" "activate(bytes32)" "$fid" 2>&1) \ - || die "activation tx failed for $fid:"$'\n'"$out" - echo "$out" | grep -E "^status\b" | head -1 >&2 || die "no status in cast output for $fid" -done - -# ── Run the smoke suite ────────────────────────────────────────────────────────── -# Default to every journey in audit mode (run all, summarize, exit 0) when no -# args are given; otherwise forward the caller's journeys/flags verbatim. -if [[ $# -eq 0 ]]; then - set -- all --keep-going -fi - -log "running smoke suite: $*" -RPC_URL="http://127.0.0.1:$PORT" DEPLOYER_PK="$DEPLOYER_PK" USER2_PK="$USER2_PK" \ - PYTHONPATH="$REPO_ROOT/script" "$VENV/bin/python" -m smoke "$@" -smoke_exit=$? - -log "smoke suite exited $smoke_exit" -exit $smoke_exit