diff --git a/.github/workflows/smoke-tests.yml b/.github/workflows/smoke-tests.yml new file mode 100644 index 0000000..355ee13 --- /dev/null +++ b/.github/workflows/smoke-tests.yml @@ -0,0 +1,117 @@ +name: Base Std Smoke Tests (Vibenet) + +# 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. 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: + 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 + 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: + 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 the full Vibenet smoke suite + id: smoke + shell: bash + # 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 }} + 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 all -k \ + 2>&1 | tee "$RUNNER_TEMP/smoke-output.txt" + + - name: Summarize smoke results + if: always() + shell: bash + 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} + + # 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 "| Ref | \`${{ github.ref_name }}\` |" + echo "| RPC endpoint | \`${{ inputs.rpc_url }}\` |" + echo "| Chain id | \`${chain_id}\` |" + echo "| Passed / Failed / Skipped | ${passed} / ${failed} / ${skipped} |" + echo "" + 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 5ece7a1..8e4d508 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 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,31 +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-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 - -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 5d22547..86cc41c 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|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 @@ -75,17 +83,34 @@ 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 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 | |---|---| | `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 +130,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 +219,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 9ed8c69..b6c355e 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 @@ -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 550e7f1..b37c0a5 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,46 @@ 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 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. + + 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 33a7b33..047c5fa 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 a6f3dca..7f969c2 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 0000000..9f84208 --- /dev/null +++ b/script/smoke/journeys/scheduled_multiplier.py @@ -0,0 +1,197 @@ +"""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) + # 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") + + 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) + 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()") + 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") + 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") + # 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)") + + +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 — 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)") + + +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")