From 9a5306ef2ab7d54bc55bc00e926bed0682ffffd0 Mon Sep 17 00:00:00 2001 From: robriks Date: Tue, 28 Jul 2026 15:49:13 -0400 Subject: [PATCH 1/4] test(smoke): ERC-8056 scheduled-multiplier journey + advisory Vibenet workflow (BOP-473) Adds live-network smoke coverage for the AssetV2 @ Cobalt scheduled-multiplier surface (ERC-8056) and an advisory, manually-triggered CI workflow to run it. - New `multiplier` journey: setUIMultiplier scheduling + guards (InvalidMultiplier, EffectiveAtInPast/TooFar, ScheduleOverlap), cancelScheduledMultiplier (+ NoScheduledMultiplier), the updateMultiplier V2 event semantics (UIMultiplierUpdated + MultiplierUpdateCancelled, not MultiplierUpdated), the read aliases (uiMultiplier/balanceOfUI/totalSupplyUI), and ERC-165 advertisement. Scheduled state is asserted read-only (no time travel on a live chain); the lazy flip is opt-in via SMOKE_OBSERVE_FLIP (bounded real-time poll, soft-skips). - Fork-gated via supportsInterface(0xa60bf13d): cleanly SKIPS on a pre-Cobalt chain, mirroring the existing features-not-active preflight skip (no runtime fork selector). - Fix asset_lifecycle step-7 rebase event assertion to be fork-aware (V1 MultiplierUpdated vs Cobalt UIMultiplierUpdated) so it stays green on both forks. - Harness: add a Skip signal (recorded as skip, not fail), supports_erc165, and per-receipt assert_log / assert_no_log helpers. - Advisory workflow_dispatch-only smoke workflow (never gates merges; Vibenet is a live, per-hardfork chain). Reports pass/skip/fail + chain id / fork / base-std ref. Co-authored-by: Cursor --- .github/workflows/smoke-tests.yml | 137 ++++++++++++++ Makefile | 9 +- script/smoke/README.md | 53 ++++-- script/smoke/__main__.py | 11 +- script/smoke/chain.py | 43 +++++ script/smoke/config.py | 21 +++ script/smoke/journeys/asset_lifecycle.py | 14 +- script/smoke/journeys/scheduled_multiplier.py | 177 ++++++++++++++++++ 8 files changed, 446 insertions(+), 19 deletions(-) create mode 100644 .github/workflows/smoke-tests.yml create mode 100644 script/smoke/journeys/scheduled_multiplier.py diff --git a/.github/workflows/smoke-tests.yml b/.github/workflows/smoke-tests.yml new file mode 100644 index 00000000..62dc2302 --- /dev/null +++ b/.github/workflows/smoke-tests.yml @@ -0,0 +1,137 @@ +name: Base Std Smoke Tests (Vibenet) + +# Advisory, MANUALLY-triggered smoke run against a LIVE Base network (Vibenet by default). +# +# workflow_dispatch ONLY — deliberately not pull_request / merge_group / schedule. Vibenet is a +# live chain, manually deployed per hardfork; CI cannot guarantee which precompile set is live, so +# an automated run would flap. This job never gates merges: it is a bring-up check you run by hand +# (e.g. after a hardfork ships to Vibenet) to confirm the b20 surface behaves against the real +# Rust precompiles. The branch-per-fork model applies: pick the base-std ref that matches the fork +# live on the target chain (latest = main; a prior hardfork = its pinned ref). There is no runtime +# fork selector. If the chain is not yet on the expected fork, the harness SKIPS (not fails). + +on: + workflow_dispatch: + inputs: + journey: + description: "Smoke journey to run (e.g. multiplier, asset, all)" + required: true + default: multiplier + fork: + description: "Fork the target chain is expected to be on (informational; pick base-std ref to match)" + required: true + type: choice + default: cobalt + options: + - cobalt + - beryl + base_std_ref: + description: "base-std ref to run (latest = main; pin a prior hardfork's ref to match an older fork)" + required: true + default: main + rpc_url: + description: "JSON-RPC endpoint of the live chain" + required: true + default: https://rpc.vibes.base.org/ + +concurrency: + group: ${{ github.workflow }}-${{ github.run_id }} + cancel-in-progress: false + +permissions: + contents: read + +jobs: + smoke: + name: Vibenet smoke (${{ inputs.journey }} @ ${{ inputs.fork }}) + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - name: Harden the runner (Audit all outbound calls) + uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4 + with: + egress-policy: audit + + - name: Checkout base-std + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + ref: ${{ inputs.base_std_ref }} + fetch-depth: 1 + submodules: recursive + + - name: Install Foundry + uses: foundry-rs/foundry-toolchain@50d5a8956f2e319df19e6b57539d7e2acb9f8c1e # v1.5.0 + with: + version: stable + + - name: Set up Python 3.13 + uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 + with: + python-version: '3.13' + + - name: Set up smoke runner venv + shell: bash + # setup-python makes python3 == 3.13; make python-check enforces it. + run: make smoke-setup PYTHON=python3 + + - name: Build contracts (interface ABIs the harness binds to) + shell: bash + run: forge build + + - name: Run Vibenet smoke journey + id: smoke + shell: bash + # RPC_URL comes from the dispatch input; the two keys are repo secrets (never echoed). Fail-fast + # (no -k): a genuine contract defect exits non-zero; a chain not on the expected fork exits 0 via + # the harness skip. continue-on-error keeps the (advisory) job from going red on either outcome — + # the summary below reports the real status. + continue-on-error: true + env: + RPC_URL: ${{ inputs.rpc_url }} + DEPLOYER_PK: ${{ secrets.SMOKE_DEPLOYER_PK }} + USER2_PK: ${{ secrets.SMOKE_USER2_PK }} + run: | + set -o pipefail + PYTHONPATH=script script/smoke/.venv/bin/python -m smoke "${{ inputs.journey }}" \ + 2>&1 | tee "$RUNNER_TEMP/smoke-output.txt" + echo "exit_code=${PIPESTATUS[0]}" >> "$GITHUB_OUTPUT" + + - name: Summarize smoke results + if: always() + shell: bash + env: + EXIT_CODE: ${{ steps.smoke.outputs.exit_code }} + run: | + output="$RUNNER_TEMP/smoke-output.txt" + # Vibenet chain id, as logged by the harness preflight ("preflight ok — chain= ..."). + chain_id=$(grep -oE 'chain=[0-9]+' "$output" 2>/dev/null | head -1 | cut -d= -f2) + chain_id=${chain_id:-unknown} + + # Non-zero exit => a real contract defect (fail-fast). Exit 0 + a skip marker => chain not on + # the expected fork (harness skip). Exit 0 + a journey "…: OK" line => pass. + status="fail" + if [ "${EXIT_CODE:-1}" = "0" ]; then + if grep -qE 'not applicable|NOT ACTIVE|skipping' "$output" 2>/dev/null; then + status="skip" + elif grep -qE ': OK$' "$output" 2>/dev/null; then + status="pass" + fi + fi + + { + echo "## Vibenet Smoke Results" + echo "" + echo "| Field | Value |" + echo "|---|---|" + echo "| Journey | \`${{ inputs.journey }}\` |" + echo "| Expected fork | \`${{ inputs.fork }}\` |" + echo "| base-std ref | \`${{ inputs.base_std_ref }}\` |" + echo "| RPC endpoint | \`${{ inputs.rpc_url }}\` |" + echo "| Chain id | \`${chain_id}\` |" + echo "" + case "$status" in + pass) echo "✅ **Passed** — the b20 surface behaved against the live precompiles." ;; + skip) echo "⏭️ **Skipped** — Vibenet is not on \`${{ inputs.fork }}\` yet (feature inactive or pre-fork). This is chain/fork state, not a defect." ;; + *) echo "❌ **Failed** — an assertion or expected revert did not match. Advisory only; does not gate merges. Check the run log." ;; + esac + } >> "$GITHUB_STEP_SUMMARY" diff --git a/Makefile b/Makefile index 5ece7a1d..c784a39b 100644 --- a/Makefile +++ b/Makefile @@ -12,7 +12,7 @@ VENV = script/smoke/.venv SMOKE_RUN = $(LOAD_ENV) PYTHONPATH=script $(VENV)/bin/python -m smoke FORK_RUN = $(LOAD_ENV) PYTHONPATH=script $(VENV)/bin/python -m fork -.PHONY: build coverage smoke smoke-all smoke-factory smoke-asset smoke-stablecoin smoke-policy smoke-invariants python-check smoke-setup fork-tests +.PHONY: build coverage smoke smoke-all smoke-factory smoke-asset smoke-multiplier smoke-stablecoin smoke-policy smoke-invariants python-check smoke-setup fork-tests # 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). @@ -62,7 +62,7 @@ build: # `out/`). Sends real txs to $RPC_URL; requires env RPC_URL, DEPLOYER_PK, # USER2_PK and a venv (`make smoke-setup`). `make smoke` runs every journey # fail-fast. -smoke: smoke-factory smoke-asset smoke-stablecoin smoke-policy smoke-invariants +smoke: smoke-factory smoke-asset smoke-multiplier smoke-stablecoin smoke-policy smoke-invariants # Run every journey in a single process. KEEP_GOING=1 runs them all and reports a # summary without erroring on failure (audit/triage mode); default fails fast and @@ -78,6 +78,11 @@ smoke-factory: build smoke-asset: build @$(SMOKE_RUN) asset +# ERC-8056 scheduled multiplier (AssetV2 @ Cobalt). Cleanly skips on a pre-Cobalt chain. Set +# SMOKE_OBSERVE_FLIP=1 to also observe the lazy flip via a bounded real-time poll (off by default). +smoke-multiplier: build + @$(SMOKE_RUN) multiplier + smoke-stablecoin: build @$(SMOKE_RUN) stablecoin diff --git a/script/smoke/README.md b/script/smoke/README.md index 5d22547c..94d5f520 100644 --- a/script/smoke/README.md +++ b/script/smoke/README.md @@ -56,7 +56,7 @@ shell env wins over `.env` values. 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-factory # one journey at a time: factory|asset|multiplier|stablecoin|policy|invariants make smoke-setup # create the venv + install web3 (one-time) ``` @@ -75,9 +75,25 @@ make smoke-setup # create the venv + install web3 (one-time) | `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. | +| `SMOKE_OBSERVE_FLIP` | no | `0` | `multiplier` journey: also observe the scheduled multiplier's lazy flip via a bounded real-time poll (off by default — real-time coupling would flap on a slow chain; pending state is asserted read-only regardless). | +| `SMOKE_FLIP_WINDOW_S` / `SMOKE_FLIP_TIMEOUT_S` | no | `30` / `150` | When `SMOKE_OBSERVE_FLIP=1`: how far in the future to schedule the flip, and how long to poll for it. | | `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. | +### Advisory CI against Vibenet + +`.github/workflows/smoke-tests.yml` runs a journey against a live Base network on demand. It is +**`workflow_dispatch` only** — deliberately not wired to pull requests, merge groups, or a schedule. +Vibenet is a live chain that is manually deployed per hardfork, so CI can't guarantee which +precompile set is live; an automated run would flap. The workflow is **advisory and never gates +merges**: run it by hand (Actions → *Base Std Smoke Tests (Vibenet)* → *Run workflow*) after a +hardfork ships, choosing the journey, the expected `fork`, the `base_std_ref` to run (latest = `main`; +pin a prior hardfork's ref to match an older live fork — branch-per-fork, no runtime selector), and +the RPC endpoint (default `https://rpc.vibes.base.org/`). It exports `RPC_URL` from the input and +`DEPLOYER_PK` / `USER2_PK` from repo secrets (`SMOKE_DEPLOYER_PK` / `SMOKE_USER2_PK`), then reports +**pass / skip / fail** plus the chain id, fork, and base-std ref in the run summary. A chain not yet +on the expected fork is reported as *skipped*, not failed. + ## What it checks Five "journeys", each runnable on its own or all together: @@ -85,7 +101,8 @@ 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). | +| `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). The rebase event is fork-aware (V1 `MultiplierUpdated` vs Cobalt `UIMultiplierUpdated`). | +| `multiplier` | ERC-8056 scheduled multiplier (AssetV2 @ Cobalt): `setUIMultiplier` scheduling + its guards (`InvalidMultiplier`, `EffectiveAtInPast`, `EffectiveAtTooFar`, `ScheduleOverlap`), `cancelScheduledMultiplier` (+ `NoScheduledMultiplier`), the `updateMultiplier` instant-failsafe V2 event semantics (`UIMultiplierUpdated` + `MultiplierUpdateCancelled`, *not* `MultiplierUpdated`), the read aliases (`uiMultiplier`/`balanceOfUI`/`totalSupplyUI`), and ERC-165 advertisement. **Skips** cleanly on a pre-Cobalt chain (probed via `supportsInterface(0xa60bf13d)`). | | `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/`. | @@ -105,17 +122,29 @@ journey: - **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: +- **skip** — reported as chain/fork state, *not* a contract defect. Two cases: + + 1. The preflight found the b20 features are **not activated** on the target chain: + + ``` + [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. + Activate the b20 features in the ActivationRegistry, or point `RPC_URL` at a + node that already has them. + + 2. A journey's surface only exists on a **later fork** than the target chain — e.g. the + `multiplier` journey against a pre-Cobalt chain (the ERC-8056 scheduled multiplier is + AssetV2 @ Cobalt). It probes `supportsInterface(0xa60bf13d)` and opts out: - ``` - [smoke] b20 features NOT ACTIVE on chain : ActivationRegistry not installed ... fork < Beryl? - [smoke] ... skipping (use the ActivationRegistry to enable). - ``` + ``` + [smoke] multiplier: not applicable on chain — Asset does not advertise ERC-8056 ... pre-Cobalt + ``` - 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. + Point `RPC_URL` at a chain on the fork that ships the surface (branch-per-fork: run the + matching base-std ref). 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 @@ -182,6 +211,6 @@ script/smoke/ 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 + journeys/ # factory, asset_lifecycle, scheduled_multiplier, stablecoin_lifecycle, policy_registry, precompile_invariants requirements.txt ``` diff --git a/script/smoke/__main__.py b/script/smoke/__main__.py index 9ed8c693..873f9424 100644 --- a/script/smoke/__main__.py +++ b/script/smoke/__main__.py @@ -20,18 +20,19 @@ import sys from . import config -from .chain import Chain, log +from .chain import Chain, Skip, log JOURNEYS = { "factory": "smoke.journeys.factory", "asset": "smoke.journeys.asset_lifecycle", + "multiplier": "smoke.journeys.scheduled_multiplier", "stablecoin": "smoke.journeys.stablecoin_lifecycle", "policy": "smoke.journeys.policy_registry", "invariants": "smoke.journeys.precompile_invariants", } # Canonical run order; also the expansion of `all`. -ORDER = ["factory", "asset", "stablecoin", "policy", "invariants"] +ORDER = ["factory", "asset", "multiplier", "stablecoin", "policy", "invariants"] def _usage() -> str: @@ -78,6 +79,12 @@ def main(argv: list[str]) -> None: try: module.run(chain) results.append((name, "pass", "")) + except Skip as exc: + # A journey opting out because its surface only exists on a later fork (e.g. the ERC-8056 + # scheduled multiplier at Cobalt). Fork/activation state, not a defect — record and continue + # even in fail-fast mode, mirroring the features-not-active preflight skip above. + log(f"{name}: not applicable on chain {chain.chain_id} \u2014 {exc}") + results.append((name, "skip", str(exc))) except SystemExit as exc: if not keep_going: raise diff --git a/script/smoke/chain.py b/script/smoke/chain.py index 550e7f10..f05439b2 100644 --- a/script/smoke/chain.py +++ b/script/smoke/chain.py @@ -36,6 +36,15 @@ from .provider import ConsistentHTTPProvider +class Skip(Exception): + """Raised by a journey to signal it does not apply to this chain (recorded as a skip, not a fail). + + Mirrors the runner's activation-gate skip: a journey whose surface only exists on a later fork + (e.g. the ERC-8056 scheduled multiplier at Cobalt) raises this on a pre-fork chain so the run + reports chain/fork state rather than a contract defect. + """ + + def log(msg: str) -> None: print(f"[smoke] {msg}", file=sys.stderr) @@ -52,6 +61,11 @@ def die(msg: str) -> None: raise SystemExit(f"[smoke] ERROR: {msg}") +def skip(msg: str) -> None: + """Abort the current journey as not-applicable to this chain (recorded as skip).""" + raise Skip(msg) + + class Chain: """Live-node harness bound to one run's config.""" @@ -347,6 +361,35 @@ def assert_log_order(self, receipt: TxReceipt, sig_a: str, sig_b: str, desc: str die(f"log order [{desc}]: expected {sig_a} immediately before {sig_b}") ok(desc) + @staticmethod + def _emitted(receipt: TxReceipt, sig: str) -> bool: + """Whether an event with signature `sig` appears in this receipt's logs.""" + want = topic0(sig) + return any(lg["topics"] and HexBytes(lg["topics"][0]) == want for lg in receipt["logs"]) + + def assert_log(self, receipt: TxReceipt, sig: str, desc: str) -> None: + """Assert this receipt emitted an event with signature `sig`.""" + if not self._emitted(receipt, sig): + die(f"expected event not emitted [{desc}]: {sig}") + ok(desc) + + def assert_no_log(self, receipt: TxReceipt, sig: str, desc: str) -> None: + """Assert this receipt did NOT emit an event with signature `sig` (e.g. the superseded V1 event).""" + if self._emitted(receipt, sig): + die(f"unexpected event emitted [{desc}]: {sig}") + ok(desc) + + def supports_erc165(self, token: Contract, interface_id: bytes) -> bool: + """ERC-165 `supportsInterface(interface_id)` on `token`, treating an absent/reverting selector as False. + + A pre-ERC-165 fork's Asset has no `supportsInterface` selector, so the call reverts; that (and a + clean `false`) both mean "interface not advertised", which the caller uses to gate fork-specific flow. + """ + try: + return bool(token.functions.supportsInterface(interface_id).call()) + except Exception: # noqa: BLE001 - a reverting/absent selector means "not supported" + return False + def assert_events_emitted(self, desc: str, *signatures: str) -> None: """Flow-level check: each signature's topic0 appears across recorded txs.""" if not self._receipts: diff --git a/script/smoke/config.py b/script/smoke/config.py index 33a7b33b..047c5fa6 100644 --- a/script/smoke/config.py +++ b/script/smoke/config.py @@ -53,6 +53,16 @@ def amt(whole: int, decimals: int) -> int: ASSET_DECIMALS = 18 STABLECOIN_DECIMALS = 6 +# ERC-165 + ERC-8056 interface ids advertised by the Asset variant (AssetV2 @ Cobalt). See +# src/interfaces/IScaledUIAmount.sol; `supportsInterface(SCALED_UI_AMOUNT_ID)` doubles as the +# probe that tells a Cobalt (ERC-8056 scheduled multiplier) chain apart from a pre-Cobalt one. +ERC165_ID = bytes.fromhex("01ffc9a7") +SCALED_UI_AMOUNT_ID = bytes.fromhex("a60bf13d") +NEW_UI_MULTIPLIER_ID = bytes.fromhex("4bd27648") +BALANCES_ID = bytes.fromhex("d890fd71") +# ERC-165 mandates 0xffffffff always reads false; any never-advertised id works as the negative probe. +UNADVERTISED_ID = bytes.fromhex("ffffffff") + def _role(name: str) -> bytes: """keccak256(name) for a role / policy-scope constant (B20Constants).""" @@ -89,6 +99,9 @@ class Config: faucet_network: str faucet_amount: str faucet_min_wei: int + observe_flip: bool + flip_window_s: int + flip_timeout_s: int @classmethod def from_env(cls) -> "Config": @@ -100,6 +113,11 @@ def need(key: str) -> str: pinned = os.environ.get("SMOKE_SALT") gas_ether = os.environ.get("GAS_FLOAT_ETHER", "0.01") + # The scheduled-multiplier journey asserts the pending state read-only by default (no time + # travel on a live chain). SMOKE_OBSERVE_FLIP=1 additionally schedules a near-future update + # and polls until it matures, to observe the lazy flip. Off by default: real-time coupling + # would make the advisory run flap on a chain with slow/variable block times. + observe_flip = os.environ.get("SMOKE_OBSERVE_FLIP", "0").strip().lower() in ("1", "true", "on", "yes") # Failure diagnostics emit a debug_traceCall/Transaction call tree. On by default (only fires on # failures); set SMOKE_TRACE=0 to print just the request + replayed revert data instead. trace = os.environ.get("SMOKE_TRACE", "1").strip().lower() not in ("0", "false", "off", "no", "") @@ -118,6 +136,9 @@ def need(key: str) -> str: faucet_network=os.environ.get("FAUCET_NETWORK", "").strip(), faucet_amount=os.environ.get("FAUCET_AMOUNT", "0.05").strip(), faucet_min_wei=Web3.to_wei(os.environ.get("FAUCET_MIN_ETHER", "0.02"), "ether"), + observe_flip=observe_flip, + flip_window_s=int(os.environ.get("SMOKE_FLIP_WINDOW_S", "30")), + flip_timeout_s=int(os.environ.get("SMOKE_FLIP_TIMEOUT_S", "150")), ) def salt_for(self, journey: str) -> bytes: diff --git a/script/smoke/journeys/asset_lifecycle.py b/script/smoke/journeys/asset_lifecycle.py index a6f3dcab..7f969c28 100644 --- a/script/smoke/journeys/asset_lifecycle.py +++ b/script/smoke/journeys/asset_lifecycle.py @@ -141,8 +141,11 @@ def _edges(c: Chain, tok) -> None: c.expect_revert("AnnouncementIdAlreadyUsed", reuse, c.DEPLOYER) -def _events(c: Chain) -> None: +def _events(c: Chain, v2: bool) -> None: step(15, "expected events emitted across the flow") + # Cobalt (AssetV2) reworked the rebase event: the step-7 updateMultiplier emits ERC-8056's + # UIMultiplierUpdated, whereas V1 emits MultiplierUpdated. Assert whichever the fork under test uses. + multiplier_event = "UIMultiplierUpdated(uint256,uint256,uint256)" if v2 else "MultiplierUpdated(uint256)" c.assert_events_emitted( "asset events", "B20Created(address,uint8,string,string,uint8,bytes)", @@ -153,7 +156,7 @@ def _events(c: Chain) -> None: "Approval(address,address,uint256)", "Announcement(address,string,string,string)", "EndAnnouncement(string)", - "MultiplierUpdated(uint256)", + multiplier_event, "ExtraMetadataUpdated(string,string)", "NameUpdated(address,string)", "SymbolUpdated(address,string)", @@ -165,7 +168,12 @@ def _events(c: Chain) -> None: def run(c: Chain) -> None: log("asset-lifecycle: starting") tok = _setup(c) + # The step-7 rebase (updateMultiplier) emits a fork-specific event: V1's MultiplierUpdated vs + # Cobalt/AssetV2's ERC-8056 UIMultiplierUpdated. Probe the ERC-8056 core id once so the flow-level + # event check asserts the right one — keeping this journey correct on both Beryl (V1) and Cobalt (V2). + v2 = c.supports_erc165(tok, config.SCALED_UI_AMOUNT_ID) + log(f"multiplier surface: {'ERC-8056 / AssetV2 (UIMultiplierUpdated)' if v2 else 'V1 (MultiplierUpdated)'}") _journey(c, tok) _edges(c, tok) - _events(c) + _events(c, v2) log("asset-lifecycle: OK") diff --git a/script/smoke/journeys/scheduled_multiplier.py b/script/smoke/journeys/scheduled_multiplier.py new file mode 100644 index 00000000..06549012 --- /dev/null +++ b/script/smoke/journeys/scheduled_multiplier.py @@ -0,0 +1,177 @@ +"""ERC-8056 scheduled-multiplier smoketest (AssetV2 @ Cobalt). + +Exercises the "Scaled UI Amount" surface added to the Asset variant at Cobalt: the scheduled +`setUIMultiplier` path (with its guards), `cancelScheduledMultiplier`, the `updateMultiplier` +instant-failsafe V2 event semantics, the ERC-8056 read aliases, and ERC-165 advertisement. + +Fork-gated: the whole surface is version-specific, so the journey probes `supportsInterface` +for the ERC-8056 core id and cleanly SKIPS on a pre-Cobalt chain (where these selectors do not +exist) — the same "chain/fork state, not a contract defect" stance as the activation preflight. +This keeps one journey correct on both a Beryl-Vibenet (V1, skips) and a Cobalt-Vibenet (V2, runs). + +Time on a live chain cannot be warped, so scheduled state is asserted read-only (the pending +target / effective-at, with the current multiplier unchanged). Observing the lazy flip requires +real time to pass; that check is opt-in via SMOKE_OBSERVE_FLIP and soft-skips on timeout so the +advisory run stays green. +""" + +from __future__ import annotations + +import time + +from .. import config +from ..chain import Chain, log, ok, skip, step +from ..codec import AssetCreateParams, init_call + +# ERC-8056 events. UIMultiplierUpdated is emitted by both setUIMultiplier and (on V2) updateMultiplier; +# MultiplierUpdateCancelled by cancelScheduledMultiplier and by updateMultiplier when it clears a +# live pending. V1_UPDATED is the superseded V1 event that V2's updateMultiplier must NOT emit. +UI_UPDATED = "UIMultiplierUpdated(uint256,uint256,uint256)" +CANCELLED = "MultiplierUpdateCancelled(uint256,uint256)" +V1_UPDATED = "MultiplierUpdated(uint256)" + +WAD = config.amt(1, 18) + + +def _now(c: Chain) -> int: + """Latest block timestamp — the reference for 'future' (schedule) and 'past' (reject) effective-ats.""" + return c.w3.eth.get_block("latest")["timestamp"] + + +def _setup(c: Chain): + salt = c.cfg.salt_for("multiplier") + params = AssetCreateParams("Scaled Asset", "SCAL", c.DEPLOYER, config.ASSET_DECIMALS).encode() + init_calls = [ + init_call(c.asset_abi, "grantRole", config.OPERATOR_ROLE, c.DEPLOYER), + init_call(c.asset_abi, "grantRole", config.MINT_ROLE, c.DEPLOYER), + ] + step("setup", f"create ASSET token (admin=deployer, decimals={config.ASSET_DECIMALS}, OPERATOR+MINT -> deployer)") + tok_addr = c.predict_b20(config.VARIANT_ASSET, salt) + c.create_b20(config.VARIANT_ASSET, salt, params, init_calls) + tok = c.asset_at(tok_addr) + c.assert_eq(c.factory.functions.isB20Initialized(tok_addr).call(), True, "token initialized") + return tok + + +def _interface_ids(c: Chain, tok) -> None: + step(1, "supportsInterface advertises ERC-165 + the three ERC-8056 ids; an unadvertised id is false") + c.assert_eq(c.supports_erc165(tok, config.ERC165_ID), True, "advertises IERC165 (0x01ffc9a7)") + c.assert_eq(c.supports_erc165(tok, config.SCALED_UI_AMOUNT_ID), True, "advertises IScaledUIAmount (0xa60bf13d)") + c.assert_eq(c.supports_erc165(tok, config.NEW_UI_MULTIPLIER_ID), True, "advertises pending ext (0x4bd27648)") + c.assert_eq(c.supports_erc165(tok, config.BALANCES_ID), True, "advertises balances ext (0xd890fd71)") + c.assert_eq(c.supports_erc165(tok, config.UNADVERTISED_ID), False, "unadvertised id (0xffffffff) is false") + + +def _current_multiplier_and_aliases(c: Chain, tok) -> None: + step(2, "seed a non-unit current multiplier: updateMultiplier(2e18) — V2 emits UIMultiplierUpdated, not MultiplierUpdated") + c.send(tok.functions.mint(c.ALICE, config.amt(1000, 18)), c.deployer) + receipt = c.send(tok.functions.updateMultiplier(config.amt(2, 18)), c.deployer) + c.assert_log(receipt, UI_UPDATED, "updateMultiplier emits UIMultiplierUpdated") + c.assert_no_log(receipt, V1_UPDATED, "V2 updateMultiplier does NOT emit the V1 MultiplierUpdated") + c.assert_eq(tok.functions.multiplier().call(), config.amt(2, 18), "multiplier == 2e18 immediately") + + step(3, "ERC-8056 read aliases mirror their B20 originals") + c.assert_eq(tok.functions.uiMultiplier().call(), tok.functions.multiplier().call(), "uiMultiplier() == multiplier()") + raw = tok.functions.balanceOf(c.ALICE).call() + c.assert_eq(tok.functions.balanceOfUI(c.ALICE).call(), tok.functions.scaledBalanceOf(c.ALICE).call(), + "balanceOfUI(alice) == scaledBalanceOf(alice)") + c.assert_eq(tok.functions.balanceOfUI(c.ALICE).call(), raw * 2, "balanceOfUI(alice) == 2 * balanceOf(alice)") + total = tok.functions.totalSupply().call() + c.assert_eq(tok.functions.totalSupplyUI().call(), tok.functions.toScaledBalance(total).call(), + "totalSupplyUI() == toScaledBalance(totalSupply())") + + +def _schedule_reverts(c: Chain, tok) -> None: + # No live pending exists yet, so ScheduleOverlap cannot fire — each guard is the binding revert. + # Every non-target argument is kept valid so the intended check is what reverts (mirrors the reference). + step(4, "setUIMultiplier input guards: InvalidMultiplier / EffectiveAtInPast / EffectiveAtTooFar") + future = _now(c) + 3600 + c.expect_revert("InvalidMultiplier", tok.functions.setUIMultiplier(0, future), c.DEPLOYER) + c.expect_revert("InvalidMultiplier", tok.functions.setUIMultiplier(1 << 128, future), c.DEPLOYER) + c.expect_revert("EffectiveAtInPast", tok.functions.setUIMultiplier(config.amt(3, 18), _now(c)), c.DEPLOYER) + c.expect_revert("EffectiveAtTooFar", tok.functions.setUIMultiplier(config.amt(3, 18), 1 << 64), c.DEPLOYER) + + +def _schedule_and_cancel(c: Chain, tok) -> None: + old = tok.functions.uiMultiplier().call() + sched = _now(c) + 3600 + target = config.amt(3, 18) + step(5, f"setUIMultiplier({target}, now+3600) schedules a pending update (read-only assertions; no time travel)") + receipt = c.send(tok.functions.setUIMultiplier(target, sched), c.deployer) + c.assert_log(receipt, UI_UPDATED, "setUIMultiplier emits UIMultiplierUpdated") + c.assert_eq(tok.functions.newUIMultiplier().call(), target, "newUIMultiplier() == scheduled target") + c.assert_eq(tok.functions.effectiveAt().call(), sched, "effectiveAt() == schedule time") + c.assert_eq(tok.functions.uiMultiplier().call(), old, "uiMultiplier() still reads the old value while pending is future") + + step(6, "a second setUIMultiplier while a live pending exists -> ScheduleOverlap") + c.expect_revert("ScheduleOverlap", tok.functions.setUIMultiplier(config.amt(4, 18), _now(c) + 7200), c.DEPLOYER) + + step(7, "cancelScheduledMultiplier clears the live pending -> MultiplierUpdateCancelled, effectiveAt() == 0") + receipt = c.send(tok.functions.cancelScheduledMultiplier(), c.deployer) + c.assert_log(receipt, CANCELLED, "cancel emits MultiplierUpdateCancelled") + c.assert_eq(tok.functions.effectiveAt().call(), 0, "effectiveAt() resets to 0 after cancel") + c.assert_eq(tok.functions.newUIMultiplier().call(), tok.functions.uiMultiplier().call(), + "no-live-pending: newUIMultiplier() == uiMultiplier()") + c.assert_eq(tok.functions.uiMultiplier().call(), old, "cancel leaves the current multiplier untouched") + + step(8, "cancelScheduledMultiplier with nothing scheduled -> NoScheduledMultiplier") + c.expect_revert("NoScheduledMultiplier", tok.functions.cancelScheduledMultiplier(), c.DEPLOYER) + + +def _failsafe_clears_pending(c: Chain, tok) -> None: + step(9, "updateMultiplier instant-failsafe clears a live pending: UIMultiplierUpdated + MultiplierUpdateCancelled, not MultiplierUpdated") + c.send(tok.functions.setUIMultiplier(config.amt(5, 18), _now(c) + 3600), c.deployer) + receipt = c.send(tok.functions.updateMultiplier(config.amt(6, 18)), c.deployer) + c.assert_log(receipt, UI_UPDATED, "updateMultiplier emits UIMultiplierUpdated") + c.assert_log(receipt, CANCELLED, "updateMultiplier clearing a live pending emits MultiplierUpdateCancelled") + c.assert_no_log(receipt, V1_UPDATED, "V2 updateMultiplier does NOT emit the V1 MultiplierUpdated") + c.assert_eq(tok.functions.multiplier().call(), config.amt(6, 18), "updateMultiplier sets the current multiplier immediately") + c.assert_eq(tok.functions.effectiveAt().call(), 0, "updateMultiplier cleared the pending (effectiveAt() == 0)") + + +def _observe_lazy_flip(c: Chain, tok) -> None: + window, timeout = c.cfg.flip_window_s, c.cfg.flip_timeout_s + target = config.amt(7, 18) + old = tok.functions.uiMultiplier().call() + sched = _now(c) + window + step(10, f"opt-in lazy flip: schedule {target} at now+{window}s, poll multiplier() up to {timeout}s for the matured value") + c.send(tok.functions.setUIMultiplier(target, sched), c.deployer) + c.assert_eq(tok.functions.uiMultiplier().call(), old, "uiMultiplier() still old immediately after scheduling") + + deadline = time.time() + timeout + while time.time() < deadline: + if _now(c) >= sched and tok.functions.multiplier().call() == target: + ok(f"multiplier() lazily flipped to the scheduled target at/after effectiveAt={sched}") + c.assert_eq(tok.functions.uiMultiplier().call(), target, "uiMultiplier() mirrors the matured multiplier") + c.assert_eq(tok.functions.newUIMultiplier().call(), target, "matured: newUIMultiplier() == uiMultiplier() == target") + return + time.sleep(3) + + # ponytail: real-time coupling — on a slow/variable-block chain the flip may not land inside the budget. + # Soft-skip (log, don't fail) so the advisory run stays green; the read-only pending assertions above + # already prove scheduling works. Upgrade path: raise SMOKE_FLIP_TIMEOUT_S, or assert via a fork test. + log(f"lazy flip not observed within {timeout}s (effectiveAt={sched}) — soft skip (chain block time)") + if _now(c) < sched: + c.send(tok.functions.cancelScheduledMultiplier(), c.deployer) + + +def _events(c: Chain) -> None: + step("events", "expected ERC-8056 events emitted across the flow") + c.assert_events_emitted("scheduled-multiplier events", UI_UPDATED, CANCELLED) + + +def run(c: Chain) -> None: + log("scheduled-multiplier: starting") + tok = _setup(c) + if not c.supports_erc165(tok, config.SCALED_UI_AMOUNT_ID): + skip("Asset does not advertise ERC-8056 IScaledUIAmount (0xa60bf13d) — chain is pre-Cobalt") + ok("chain is Cobalt-capable (Asset advertises ERC-8056 IScaledUIAmount)") + _interface_ids(c, tok) + _current_multiplier_and_aliases(c, tok) + _schedule_reverts(c, tok) + _schedule_and_cancel(c, tok) + _failsafe_clears_pending(c, tok) + if c.cfg.observe_flip: + _observe_lazy_flip(c, tok) + _events(c) + log("scheduled-multiplier: OK") From 580028d685edcec811782420833a25249aa51206 Mon Sep 17 00:00:00 2001 From: robriks Date: Tue, 28 Jul 2026 15:58:25 -0400 Subject: [PATCH 2/4] fix(smoke): leave pending untouched in the opt-in lazy-flip soft-skip MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The tidy-up cancelScheduledMultiplier raced maturation: on a chain that only advances its block clock when mining (e.g. anvil), the cancel tx mined a block whose timestamp jumped past effectiveAt, so the pending had matured and cancel reverted NoScheduledMultiplier — failing the (advisory) journey. The cleanup is non-essential (throwaway token), so drop it; the soft-skip now just logs and the journey stays green. Found while validating against a local ERC-8056 anvil. Co-authored-by: Cursor --- script/smoke/journeys/scheduled_multiplier.py | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/script/smoke/journeys/scheduled_multiplier.py b/script/smoke/journeys/scheduled_multiplier.py index 06549012..4246e161 100644 --- a/script/smoke/journeys/scheduled_multiplier.py +++ b/script/smoke/journeys/scheduled_multiplier.py @@ -147,12 +147,13 @@ def _observe_lazy_flip(c: Chain, tok) -> None: return time.sleep(3) - # ponytail: real-time coupling — on a slow/variable-block chain the flip may not land inside the budget. - # Soft-skip (log, don't fail) so the advisory run stays green; the read-only pending assertions above - # already prove scheduling works. Upgrade path: raise SMOKE_FLIP_TIMEOUT_S, or assert via a fork test. + # ponytail: real-time coupling — the flip only materializes as wall-clock passes effectiveAt, which + # needs the chain to keep producing blocks. A live chain will; an idle dev node (e.g. anvil, whose + # block clock only advances when a block is mined) may not within the budget. Soft-skip (log, don't + # fail) so the advisory run stays green — the read-only pending assertions above already prove + # scheduling works. The pending is left in place (throwaway token); no cleanup, since a cancel here + # would race the maturation and revert. Upgrade path: raise SMOKE_FLIP_TIMEOUT_S, or use a fork test. log(f"lazy flip not observed within {timeout}s (effectiveAt={sched}) — soft skip (chain block time)") - if _now(c) < sched: - c.send(tok.functions.cancelScheduledMultiplier(), c.deployer) def _events(c: Chain) -> None: From 8d41f4de94166bdb1faad4fecfba2f0a438cf5ad Mon Sep 17 00:00:00 2001 From: robriks Date: Tue, 28 Jul 2026 16:31:59 -0400 Subject: [PATCH 3/4] test(smoke): always run the full suite; drop journey/fork selectors (review) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses review feedback (Stephan Ciliers): the smoke suite should run as a whole rather than exposing per-suite granularity, and the Vibenet workflow should always run the latest rather than offer fork/ref selection. - Makefile: remove the six per-journey targets; `make smoke` / `make smoke-all` now run the full suite in one process (KEEP_GOING=1 for the audit summary). The raw `python -m smoke ` CLI is kept as a documented debugging escape hatch (cheaper/faster than the whole suite on a live chain), so nothing is lost. - Workflow: drop the `journey`, `fork`, and `base_std_ref` inputs (keep only `rpc_url`). It always runs `python -m smoke all -k` against the dispatched ref (latest = main; branch-per-fork for older forks) and reports per-journey passed/failed/skipped from the runner summary. No journey picker, no fork/ref selector — consistent with the repo's no-runtime-fork-selector model. - Docs: README + CLI docstring updated to match (full-suite entrypoint, CLI for ad-hoc single-journey debugging, single rpc_url workflow input). Co-authored-by: Cursor --- .github/workflows/smoke-tests.yml | 80 ++++++++++++------------------- Makefile | 45 +++++------------ script/smoke/README.md | 52 +++++++++++--------- script/smoke/__main__.py | 4 +- 4 files changed, 74 insertions(+), 107 deletions(-) diff --git a/.github/workflows/smoke-tests.yml b/.github/workflows/smoke-tests.yml index 62dc2302..355ee130 100644 --- a/.github/workflows/smoke-tests.yml +++ b/.github/workflows/smoke-tests.yml @@ -1,34 +1,20 @@ name: Base Std Smoke Tests (Vibenet) -# Advisory, MANUALLY-triggered smoke run against a LIVE Base network (Vibenet by default). +# Advisory, MANUALLY-triggered smoke run of the FULL b20 suite against a LIVE Base network +# (Vibenet by default). # # workflow_dispatch ONLY — deliberately not pull_request / merge_group / schedule. Vibenet is a # live chain, manually deployed per hardfork; CI cannot guarantee which precompile set is live, so # an automated run would flap. This job never gates merges: it is a bring-up check you run by hand -# (e.g. after a hardfork ships to Vibenet) to confirm the b20 surface behaves against the real -# Rust precompiles. The branch-per-fork model applies: pick the base-std ref that matches the fork -# live on the target chain (latest = main; a prior hardfork = its pinned ref). There is no runtime -# fork selector. If the chain is not yet on the expected fork, the harness SKIPS (not fails). +# (e.g. after a hardfork ships to Vibenet) to confirm the b20 surface behaves against the real Rust +# precompiles. It always runs the whole suite against the ref you dispatch from (latest = main) — +# there is no journey picker and no fork/ref selector (branch-per-fork: dispatch from the branch/tag +# that matches the fork you want). Each journey self-skips when the live chain is not yet on the fork +# that ships its surface (e.g. the ERC-8056 multiplier journey on a pre-Cobalt chain). on: workflow_dispatch: inputs: - journey: - description: "Smoke journey to run (e.g. multiplier, asset, all)" - required: true - default: multiplier - fork: - description: "Fork the target chain is expected to be on (informational; pick base-std ref to match)" - required: true - type: choice - default: cobalt - options: - - cobalt - - beryl - base_std_ref: - description: "base-std ref to run (latest = main; pin a prior hardfork's ref to match an older fork)" - required: true - default: main rpc_url: description: "JSON-RPC endpoint of the live chain" required: true @@ -43,7 +29,7 @@ permissions: jobs: smoke: - name: Vibenet smoke (${{ inputs.journey }} @ ${{ inputs.fork }}) + name: Vibenet smoke runs-on: ubuntu-latest timeout-minutes: 30 steps: @@ -55,7 +41,6 @@ jobs: - name: Checkout base-std uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: - ref: ${{ inputs.base_std_ref }} fetch-depth: 1 submodules: recursive @@ -78,13 +63,13 @@ jobs: shell: bash run: forge build - - name: Run Vibenet smoke journey + - name: Run the full Vibenet smoke suite id: smoke shell: bash - # RPC_URL comes from the dispatch input; the two keys are repo secrets (never echoed). Fail-fast - # (no -k): a genuine contract defect exits non-zero; a chain not on the expected fork exits 0 via - # the harness skip. continue-on-error keeps the (advisory) job from going red on either outcome — - # the summary below reports the real status. + # RPC_URL comes from the dispatch input; the two keys are repo secrets (never echoed). `-k` + # (keep-going) runs every journey and prints a summary, so one journey's failure/skip never + # masks the rest; it always exits 0, so the (advisory) job stays green and the summary below + # reports the real per-journey status. continue-on-error still guards against an infra crash. continue-on-error: true env: RPC_URL: ${{ inputs.rpc_url }} @@ -92,46 +77,41 @@ jobs: USER2_PK: ${{ secrets.SMOKE_USER2_PK }} run: | set -o pipefail - PYTHONPATH=script script/smoke/.venv/bin/python -m smoke "${{ inputs.journey }}" \ + PYTHONPATH=script script/smoke/.venv/bin/python -m smoke all -k \ 2>&1 | tee "$RUNNER_TEMP/smoke-output.txt" - echo "exit_code=${PIPESTATUS[0]}" >> "$GITHUB_OUTPUT" - name: Summarize smoke results if: always() shell: bash - env: - EXIT_CODE: ${{ steps.smoke.outputs.exit_code }} run: | output="$RUNNER_TEMP/smoke-output.txt" # Vibenet chain id, as logged by the harness preflight ("preflight ok — chain= ..."). chain_id=$(grep -oE 'chain=[0-9]+' "$output" 2>/dev/null | head -1 | cut -d= -f2) chain_id=${chain_id:-unknown} - # Non-zero exit => a real contract defect (fail-fast). Exit 0 + a skip marker => chain not on - # the expected fork (harness skip). Exit 0 + a journey "…: OK" line => pass. - status="fail" - if [ "${EXIT_CODE:-1}" = "0" ]; then - if grep -qE 'not applicable|NOT ACTIVE|skipping' "$output" 2>/dev/null; then - status="skip" - elif grep -qE ': OK$' "$output" 2>/dev/null; then - status="pass" - fi - fi + # The -k runner prints "smoke summary: P passed, F failed, S skipped (of N)". + summary=$(grep -oE 'smoke summary: [0-9]+ passed, [0-9]+ failed, [0-9]+ skipped' "$output" 2>/dev/null | tail -1) + passed=$(echo "$summary" | grep -oE '[0-9]+ passed' | grep -oE '[0-9]+'); passed=${passed:-0} + failed=$(echo "$summary" | grep -oE '[0-9]+ failed' | grep -oE '[0-9]+'); failed=${failed:-0} + skipped=$(echo "$summary" | grep -oE '[0-9]+ skipped' | grep -oE '[0-9]+'); skipped=${skipped:-0} { echo "## Vibenet Smoke Results" echo "" echo "| Field | Value |" echo "|---|---|" - echo "| Journey | \`${{ inputs.journey }}\` |" - echo "| Expected fork | \`${{ inputs.fork }}\` |" - echo "| base-std ref | \`${{ inputs.base_std_ref }}\` |" + echo "| Ref | \`${{ github.ref_name }}\` |" echo "| RPC endpoint | \`${{ inputs.rpc_url }}\` |" echo "| Chain id | \`${chain_id}\` |" + echo "| Passed / Failed / Skipped | ${passed} / ${failed} / ${skipped} |" echo "" - case "$status" in - pass) echo "✅ **Passed** — the b20 surface behaved against the live precompiles." ;; - skip) echo "⏭️ **Skipped** — Vibenet is not on \`${{ inputs.fork }}\` yet (feature inactive or pre-fork). This is chain/fork state, not a defect." ;; - *) echo "❌ **Failed** — an assertion or expected revert did not match. Advisory only; does not gate merges. Check the run log." ;; - esac + if [ -z "$summary" ]; then + echo "❓ **Did not run** — no summary produced (likely an infra error before the suite ran, e.g. RPC unreachable). Check the run log." + elif [ "$failed" -ne 0 ]; then + echo "❌ **${failed} journey(s) failed** — an assertion or expected revert did not match. Advisory only; does not gate merges. See the run log for the failing journey and its trace." + elif [ "$passed" -ne 0 ]; then + echo "✅ **${passed} passed** (${skipped} skipped) — the b20 surface behaved against the live precompiles." + else + echo "⏭️ **All ${skipped} skipped** — Vibenet is not on the expected fork yet (feature inactive or pre-fork). Chain/fork state, not a defect." + fi } >> "$GITHUB_STEP_SUMMARY" diff --git a/Makefile b/Makefile index c784a39b..8e4d5085 100644 --- a/Makefile +++ b/Makefile @@ -12,7 +12,7 @@ VENV = script/smoke/.venv SMOKE_RUN = $(LOAD_ENV) PYTHONPATH=script $(VENV)/bin/python -m smoke FORK_RUN = $(LOAD_ENV) PYTHONPATH=script $(VENV)/bin/python -m fork -.PHONY: build coverage smoke smoke-all smoke-factory smoke-asset smoke-multiplier smoke-stablecoin smoke-policy smoke-invariants python-check smoke-setup fork-tests +.PHONY: build coverage smoke smoke-all python-check smoke-setup fork-tests # 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). @@ -58,36 +58,15 @@ fork-tests: build: forge build -# b20 precompile bring-up smoketest (web3.py + the interface ABIs read from -# `out/`). Sends real txs to $RPC_URL; requires env RPC_URL, DEPLOYER_PK, -# USER2_PK and a venv (`make smoke-setup`). `make smoke` runs every journey -# fail-fast. -smoke: smoke-factory smoke-asset smoke-multiplier smoke-stablecoin smoke-policy smoke-invariants - -# Run every journey in a single process. KEEP_GOING=1 runs them all and reports a -# summary without erroring on failure (audit/triage mode); default fails fast and -# exits non-zero on the first failure (CI gating). -# make smoke-all # fail-fast -# make smoke-all KEEP_GOING=1 # run all, report, exit 0 -smoke-all: build +# b20 precompile bring-up smoketest (web3.py + the interface ABIs read from `out/`). Sends real txs +# to $RPC_URL; requires env RPC_URL, DEPLOYER_PK, USER2_PK and a venv (`make smoke-setup`). The suite +# always runs as a whole — there are deliberately no per-journey targets. Fail-fast by default (CI +# gating); KEEP_GOING=1 runs every journey and prints a summary without erroring (audit/triage). +# make smoke # full suite, fail-fast +# make smoke KEEP_GOING=1 # full suite, run all, report, exit 0 +# For ad-hoc single-journey debugging (cheaper/faster on a live chain), call the CLI directly, e.g. +# PYTHONPATH=script script/smoke/.venv/bin/python -m smoke asset +# The `multiplier` journey skips on a pre-Cobalt chain; SMOKE_OBSERVE_FLIP=1 also observes its lazy +# flip via a bounded real-time poll (off by default). +smoke smoke-all: build @$(SMOKE_RUN) all $(if $(KEEP_GOING),--keep-going,) - -smoke-factory: build - @$(SMOKE_RUN) factory - -smoke-asset: build - @$(SMOKE_RUN) asset - -# ERC-8056 scheduled multiplier (AssetV2 @ Cobalt). Cleanly skips on a pre-Cobalt chain. Set -# SMOKE_OBSERVE_FLIP=1 to also observe the lazy flip via a bounded real-time poll (off by default). -smoke-multiplier: build - @$(SMOKE_RUN) multiplier - -smoke-stablecoin: build - @$(SMOKE_RUN) stablecoin - -smoke-policy: build - @$(SMOKE_RUN) policy - -smoke-invariants: build - @$(SMOKE_RUN) invariants diff --git a/script/smoke/README.md b/script/smoke/README.md index 94d5f520..86cc41cc 100644 --- a/script/smoke/README.md +++ b/script/smoke/README.md @@ -36,7 +36,7 @@ Requires **Python 3.13** (`make smoke-setup` enforces it; override with `PYTHON= ```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 # all journeys, audit summary; or one: make smoke-factory +make smoke KEEP_GOING=1 # run the full suite, audit summary ``` `DEPLOYER_PK` must hold enough ether to sign the setup and admin txs (it also @@ -53,17 +53,25 @@ shell env wins over `.env` values. ### Make targets ```bash -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|multiplier|stablecoin|policy|invariants -make smoke-setup # create the venv + install web3 (one-time) +make smoke # run the FULL suite, fail-fast (CI gating default) +make smoke KEEP_GOING=1 # full suite, summarize, exit 0 regardless (audit/triage) +make smoke-all # alias of `make smoke` (also honors KEEP_GOING) +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. The raw CLI takes an arbitrary subset and a -> fail-fast/keep-going flag, e.g. `python -m smoke asset policy -k`. +The suite always runs as a whole — there are deliberately **no per-journey `make` +targets**, so you can't get false confidence from a partial run. For ad-hoc +single-journey debugging (cheaper and faster on a live chain, where every journey +spends real gas and time), call the CLI directly — it takes an arbitrary subset and +a fail-fast/keep-going flag: + +```bash +PYTHONPATH=script python -m smoke asset # one journey +PYTHONPATH=script python -m smoke asset policy -k # a subset, keep-going +``` + +> `PYTHONPATH=script` is required (the Make targets set it for you); without it you +> get `No module named smoke`. ### Environment / config knobs @@ -82,21 +90,21 @@ make smoke-setup # create the venv + install web3 (one-time) ### Advisory CI against Vibenet -`.github/workflows/smoke-tests.yml` runs a journey against a live Base network on demand. It is -**`workflow_dispatch` only** — deliberately not wired to pull requests, merge groups, or a schedule. -Vibenet is a live chain that is manually deployed per hardfork, so CI can't guarantee which -precompile set is live; an automated run would flap. The workflow is **advisory and never gates -merges**: run it by hand (Actions → *Base Std Smoke Tests (Vibenet)* → *Run workflow*) after a -hardfork ships, choosing the journey, the expected `fork`, the `base_std_ref` to run (latest = `main`; -pin a prior hardfork's ref to match an older live fork — branch-per-fork, no runtime selector), and -the RPC endpoint (default `https://rpc.vibes.base.org/`). It exports `RPC_URL` from the input and -`DEPLOYER_PK` / `USER2_PK` from repo secrets (`SMOKE_DEPLOYER_PK` / `SMOKE_USER2_PK`), then reports -**pass / skip / fail** plus the chain id, fork, and base-std ref in the run summary. A chain not yet -on the expected fork is reported as *skipped*, not failed. +`.github/workflows/smoke-tests.yml` runs the **full suite** against a live Base network on demand. It +is **`workflow_dispatch` only** — deliberately not wired to pull requests, merge groups, or a schedule. +Vibenet is a live chain that is manually deployed per hardfork, so CI can't guarantee which precompile +set is live; an automated run would flap. The workflow is **advisory and never gates merges**: run it +by hand (Actions → *Base Std Smoke Tests (Vibenet)* → *Run workflow*) after a hardfork ships. Its only +input is the RPC endpoint (default `https://rpc.vibes.base.org/`); it always runs every journey (`-k`) +against the ref you dispatch from — latest is `main`, and to run an older fork you dispatch from the +matching branch/tag (branch-per-fork; there is no journey picker and no fork/ref selector). It exports +`RPC_URL` from the input and `DEPLOYER_PK` / `USER2_PK` from repo secrets (`SMOKE_DEPLOYER_PK` / +`SMOKE_USER2_PK`), then reports per-journey **passed / failed / skipped** plus the chain id in the run +summary. A journey whose surface the live chain does not yet ship is reported as *skipped*, not failed. ## What it checks -Five "journeys", each runnable on its own or all together: +Six "journeys", run as a whole suite (a single journey can still be run via the CLI for debugging): | Journey | What it exercises | |---|---| diff --git a/script/smoke/__main__.py b/script/smoke/__main__.py index 873f9424..b6c355e3 100644 --- a/script/smoke/__main__.py +++ b/script/smoke/__main__.py @@ -1,7 +1,7 @@ """CLI: python -m smoke [ ...] [-k] -Journeys: factory, asset, stablecoin, policy, invariants — or `all` to run every -journey in sequence. Env (RPC_URL / DEPLOYER_PK / USER2_PK) is sourced by the +Journeys: factory, asset, multiplier, stablecoin, policy, invariants — or `all` to run +every journey in sequence. Env (RPC_URL / DEPLOYER_PK / USER2_PK) is sourced by the Makefile from .env; running directly requires it exported. A preflight liveness probe checks the b20 precompiles are actually active on the target chain (fork >= Beryl); if not, the journey is skipped rather than reporting environment state From 3fd991fc8f2476faba479268d7858445a5c3a409 Mon Sep 17 00:00:00 2001 From: robriks Date: Tue, 28 Jul 2026 17:06:51 -0400 Subject: [PATCH 4/4] test(smoke): decode multiplier event payloads (align with #178) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mirrors the composite-policy smoke PR (#178): `assert_events_emitted` and the per-receipt `assert_log` are presence-only — an event with the right name but a wrong payload passes them. #178 added `composite_children_from` to decode the specific receipt; do the same here. - chain.py: add `event_args(receipt, contract, event_name)` to decode a single event from a specific receipt and return its args. - scheduled_multiplier: assert the scheduled/cancelled values from the event payloads — setUIMultiplier's UIMultiplierUpdated (old, target, effectiveAt) and the MultiplierUpdateCancelled payloads on cancel and on the updateMultiplier failsafe — which a presence-only check cannot verify. Validated green against a local ERC-8056 Cobalt anvil. Co-authored-by: Cursor --- script/smoke/chain.py | 11 ++++++++ script/smoke/journeys/scheduled_multiplier.py | 27 ++++++++++++++++--- 2 files changed, 34 insertions(+), 4 deletions(-) diff --git a/script/smoke/chain.py b/script/smoke/chain.py index f05439b2..b37c0a5c 100644 --- a/script/smoke/chain.py +++ b/script/smoke/chain.py @@ -379,6 +379,17 @@ def assert_no_log(self, receipt: TxReceipt, sig: str, desc: str) -> None: die(f"unexpected event emitted [{desc}]: {sig}") ok(desc) + def event_args(self, receipt: TxReceipt, contract: Contract, event_name: str) -> dict: + """Decode a single occurrence of `event_name` from THIS receipt and return its args. + + `assert_events_emitted` and `assert_log` are presence-only — an event with the right name but a + WRONG payload passes them. Decode the specific receipt to assert on the emitted values. + """ + decoded = getattr(contract.events, event_name)().process_receipt(receipt, errors=DISCARD) + if not decoded: + die(f"{event_name} not found in receipt") + return dict(decoded[0]["args"]) + def supports_erc165(self, token: Contract, interface_id: bytes) -> bool: """ERC-165 `supportsInterface(interface_id)` on `token`, treating an absent/reverting selector as False. diff --git a/script/smoke/journeys/scheduled_multiplier.py b/script/smoke/journeys/scheduled_multiplier.py index 4246e161..9f84208b 100644 --- a/script/smoke/journeys/scheduled_multiplier.py +++ b/script/smoke/journeys/scheduled_multiplier.py @@ -98,7 +98,14 @@ def _schedule_and_cancel(c: Chain, tok) -> None: target = config.amt(3, 18) step(5, f"setUIMultiplier({target}, now+3600) schedules a pending update (read-only assertions; no time travel)") receipt = c.send(tok.functions.setUIMultiplier(target, sched), c.deployer) - c.assert_log(receipt, UI_UPDATED, "setUIMultiplier emits UIMultiplierUpdated") + # Decode the receipt (not just presence): the scheduled target + effectiveAt are exactly what a + # presence-only check can't verify. + ui = c.event_args(receipt, tok, "UIMultiplierUpdated") + c.assert_eq( + [ui["oldMultiplier"], ui["newMultiplier"], ui["effectiveAtTimestamp"]], + [old, target, sched], + "UIMultiplierUpdated payload == (old, scheduled target, effectiveAt)", + ) c.assert_eq(tok.functions.newUIMultiplier().call(), target, "newUIMultiplier() == scheduled target") c.assert_eq(tok.functions.effectiveAt().call(), sched, "effectiveAt() == schedule time") c.assert_eq(tok.functions.uiMultiplier().call(), old, "uiMultiplier() still reads the old value while pending is future") @@ -108,7 +115,12 @@ def _schedule_and_cancel(c: Chain, tok) -> None: step(7, "cancelScheduledMultiplier clears the live pending -> MultiplierUpdateCancelled, effectiveAt() == 0") receipt = c.send(tok.functions.cancelScheduledMultiplier(), c.deployer) - c.assert_log(receipt, CANCELLED, "cancel emits MultiplierUpdateCancelled") + cancelled = c.event_args(receipt, tok, "MultiplierUpdateCancelled") + c.assert_eq( + [cancelled["cancelledMultiplier"], cancelled["cancelledEffectiveAt"]], + [target, sched], + "MultiplierUpdateCancelled payload == (cancelled target, cancelled effectiveAt)", + ) c.assert_eq(tok.functions.effectiveAt().call(), 0, "effectiveAt() resets to 0 after cancel") c.assert_eq(tok.functions.newUIMultiplier().call(), tok.functions.uiMultiplier().call(), "no-live-pending: newUIMultiplier() == uiMultiplier()") @@ -120,10 +132,17 @@ def _schedule_and_cancel(c: Chain, tok) -> None: def _failsafe_clears_pending(c: Chain, tok) -> None: step(9, "updateMultiplier instant-failsafe clears a live pending: UIMultiplierUpdated + MultiplierUpdateCancelled, not MultiplierUpdated") - c.send(tok.functions.setUIMultiplier(config.amt(5, 18), _now(c) + 3600), c.deployer) + cleared_target, cleared_sched = config.amt(5, 18), _now(c) + 3600 + c.send(tok.functions.setUIMultiplier(cleared_target, cleared_sched), c.deployer) receipt = c.send(tok.functions.updateMultiplier(config.amt(6, 18)), c.deployer) c.assert_log(receipt, UI_UPDATED, "updateMultiplier emits UIMultiplierUpdated") - c.assert_log(receipt, CANCELLED, "updateMultiplier clearing a live pending emits MultiplierUpdateCancelled") + # Decode the cancel: it must carry the pending it cleared, not any live pending. + cancelled = c.event_args(receipt, tok, "MultiplierUpdateCancelled") + c.assert_eq( + [cancelled["cancelledMultiplier"], cancelled["cancelledEffectiveAt"]], + [cleared_target, cleared_sched], + "MultiplierUpdateCancelled payload == the pending that updateMultiplier cleared", + ) c.assert_no_log(receipt, V1_UPDATED, "V2 updateMultiplier does NOT emit the V1 MultiplierUpdated") c.assert_eq(tok.functions.multiplier().call(), config.amt(6, 18), "updateMultiplier sets the current multiplier immediately") c.assert_eq(tok.functions.effectiveAt().call(), 0, "updateMultiplier cleared the pending (effectiveAt() == 0)")