From c012df6ce51a7e0077f91629ecb9129f7a9a1b6c Mon Sep 17 00:00:00 2001 From: Rayyan Alam Date: Wed, 10 Jun 2026 23:55:53 -0400 Subject: [PATCH 1/2] fix(smoke): align journeys with strict Rust ABI decode failures Smoke tests expected typed InvalidVariant and dirty-bit masking, but Rust precompiles reject non-canonical calldata with AbiDecodeFailed before dispatch. Add decode-failure helpers on Chain and update factory/invariant journeys. Co-authored-by: Cursor --- Makefile | 2 +- script/smoke/chain.py | 79 +++++++++++++++++++ script/smoke/journeys/factory.py | 8 +- .../smoke/journeys/precompile_invariants.py | 19 ++--- test/unit/B20Factory/getTokenAddress.t.sol | 9 +-- 5 files changed, 97 insertions(+), 20 deletions(-) diff --git a/Makefile b/Makefile index 77d513a..c059871 100644 --- a/Makefile +++ b/Makefile @@ -1,7 +1,7 @@ # Source the gitignored .env for the smoke recipes. LOAD_ENV = pre=$$(export -p); set -a; [ -f .env ] && . ./.env; set +a; eval "$$pre"; -PYTHON ?= python3.13 +PYTHON ?= python3 VENV = script/smoke/.venv # `smoke` is the package at script/smoke/, so its parent (script) is on the path. SMOKE_RUN = $(LOAD_ENV) PYTHONPATH=script $(VENV)/bin/python -m smoke diff --git a/script/smoke/chain.py b/script/smoke/chain.py index e71cb6d..9177ec8 100644 --- a/script/smoke/chain.py +++ b/script/smoke/chain.py @@ -238,6 +238,85 @@ def expect_revert(self, error_name: str, fn, frm: ChecksumAddress) -> None: self._diagnose(f"expected revert {error_name} but call succeeded", repro_fn=fn, repro_overrides={"from": frm}) die(f"expected revert {error_name} but call succeeded") + @staticmethod + def _revert_bytes(exc: ContractLogicError) -> bytes | None: + data = getattr(exc, "data", None) + if isinstance(data, str) and data.startswith("0x"): + return bytes(HexBytes(data)) + if isinstance(data, (bytes, bytearray)): + return bytes(data) + return None + + def expect_abi_decode_failed(self, desc: str, fn, frm: ChecksumAddress) -> None: + """Simulate `fn` via eth_call; assert Rust precompile AbiDecodeFailed revert shape. + + Dispatch decode failures encode as `function_selector || utf8_error`, not a typed + custom error such as InvalidVariant(). + """ + fn_selector = bytes(HexBytes(fn.selector)) + try: + fn.call({"from": frm}) + except ContractLogicError as exc: + self._assert_abi_decode_revert( + self._revert_bytes(exc), fn_selector, desc, repro_fn=fn, repro_overrides={"from": frm} + ) + ok(desc) + return + except Exception as exc: # noqa: BLE001 - surface any non-revert failure + die(f"expected ABI decode failure for {desc} but call raised {type(exc).__name__}: {exc}") + self._diagnose(f"expected ABI decode failure: {desc}", repro_fn=fn, repro_overrides={"from": frm}) + die(f"expected ABI decode failure for {desc} but call succeeded") + + def _assert_abi_decode_revert( + self, + raw: bytes | None, + fn_selector: bytes, + desc: str, + *, + repro_fn=None, + repro_overrides: dict | None = None, + repro_call: dict | None = None, + ) -> None: + if raw is None: + self._diagnose(f"expected ABI decode failure: {desc}", repro_fn, repro_overrides, repro_call) + die(f"expected ABI decode failure for {desc} but revert had no data") + if len(raw) <= 4: + self._diagnose(f"expected ABI decode failure: {desc}", repro_fn, repro_overrides, repro_call) + die(f"expected ABI decode failure for {desc} but revert was only {len(raw)} byte(s): 0x{raw.hex()}") + if raw[:4] != fn_selector: + typed = ERROR_BY_SELECTOR.get(("0x" + raw[:4].hex()).lower()) + self._diagnose(f"expected ABI decode failure: {desc}", repro_fn, repro_overrides, repro_call) + die( + f"expected ABI decode failure for {desc} " + f"(selector 0x{fn_selector.hex()}) but got 0x{raw[:4].hex()}" + f"{f' ({typed})' if typed else ''} (raw: 0x{raw.hex()})" + ) + + def expect_raw_abi_decode_failed( + self, + desc: str, + to: ChecksumAddress, + data: bytes, + *, + value: int = 0, + frm: ChecksumAddress | None = None, + ) -> None: + """Assert hand-built calldata reverts with AbiDecodeFailed (selector || utf8).""" + if len(data) < 4: + die(f"expected ABI decode failure for {desc} but calldata is shorter than 4 bytes") + fn_selector = data[:4] + tx = {"to": to, "from": frm or self.DEPLOYER, "data": HexBytes(data), "value": value} + try: + self.w3.eth.call(tx) + except ContractLogicError as exc: + self._assert_abi_decode_revert(self._revert_bytes(exc), fn_selector, desc, repro_call=tx) + ok(desc) + return + except Exception as exc: # noqa: BLE001 - surface any non-revert failure + die(f"expected ABI decode failure for {desc} but call raised {type(exc).__name__}: {exc}") + self._diagnose(f"expected ABI decode failure: {desc}", repro_call=tx) + die(f"expected ABI decode failure for {desc} but call succeeded") + def assert_log_order(self, receipt: TxReceipt, sig_a: str, sig_b: str, desc: str) -> None: """Assert event A is logged immediately before event B in the receipt.""" a, b = topic0(sig_a), topic0(sig_b) diff --git a/script/smoke/journeys/factory.py b/script/smoke/journeys/factory.py index 3eccd2a..542e280 100644 --- a/script/smoke/journeys/factory.py +++ b/script/smoke/journeys/factory.py @@ -62,8 +62,12 @@ def create(variant, journey, params): c.expect_revert("InvalidCurrency", create(config.VARIANT_STABLECOIN, "factory-lc", params_lower_ccy), c.DEPLOYER) c.expect_revert("MissingRequiredField", create(config.VARIANT_STABLECOIN, "factory-ec", params_empty_ccy), c.DEPLOYER) - step(8, "unknown variant -> InvalidVariant") - c.expect_revert("InvalidVariant", create(2, "factory-bv", params_a), c.DEPLOYER) + step(8, "out-of-range variant -> ABI decode failure") + c.expect_abi_decode_failed( + "out-of-range variant AbiDecodeFailed", + create(2, "factory-bv", params_a), + c.DEPLOYER, + ) def _events(c: Chain) -> None: diff --git a/script/smoke/journeys/precompile_invariants.py b/script/smoke/journeys/precompile_invariants.py index f0aff34..3570cef 100644 --- a/script/smoke/journeys/precompile_invariants.py +++ b/script/smoke/journeys/precompile_invariants.py @@ -1,8 +1,8 @@ """Precompile EVM-context invariant smoketest. Audits the behaviors Solidity grants for free that a precompile has no notion of and must implement -explicitly: payable rejection, selector dispatch, calldata canonicalization, STATICCALL read-only -enforcement, returndata fidelity, gas containment, and revert atomicity. Two layers: +explicitly: payable rejection, selector dispatch, strict ABI decode (non-canonical calldata rejected), +STATICCALL read-only enforcement, returndata fidelity, gas containment, and revert atomicity. Two layers: * raw `eth_call` with hand-built (often deliberately malformed) calldata, straight from web3 — for inputs the Solidity compiler would never emit (dirty high bits, unknown selectors, truncated args, @@ -75,18 +75,13 @@ def _enum_out_of_range_reverts(c: Chain, _probe) -> None: c.expect_raw_revert("enum out of range", POLICY, bad_enum) -def _dirty_high_bits_masked(c: Chain, _probe) -> None: +def _dirty_high_bits_rejected(c: Chain, _probe) -> None: pid, acct = config.ALWAYS_BLOCK_ID, c.BOB sel = _selector("isAuthorized(uint64,address)") dirty = sel + (b"\xff" * 24 + pid.to_bytes(8, "big")) + (b"\xff" * 12 + bytes(HexBytes(acct))) - clean_ret = c.raw_call(POLICY, _clean(c.policy, "isAuthorized", pid, acct)) - dirty_ret = c.raw_call(POLICY, dirty) - c.assert_eq( - "0x" + dirty_ret.hex(), - "0x" + clean_ret.hex(), - "dirty-bit and clean isAuthorized agree", - repro_call={"to": POLICY, "from": c.DEPLOYER, "data": dirty}, - ) + c.raw_call(POLICY, _clean(c.policy, "isAuthorized", pid, acct)) + ok("clean isAuthorized succeeds") + c.expect_raw_abi_decode_failed("dirty high bits AbiDecodeFailed", POLICY, dirty) # ── caller-frame context (requires the deployed PrecompileProbe) ─────────────────────────────────── @@ -180,7 +175,7 @@ def _create_gas_independent_of_prefunded_balance(c: Chain, _probe) -> None: ("empty calldata reverts (no implicit receive/fallback)", _empty_calldata_reverts), ("truncated args revert (strict ABI decode)", _truncated_args_revert), ("out-of-range enum reverts", _enum_out_of_range_reverts), - ("dirty high bits masked (uint64 + address canonicalization)", _dirty_high_bits_masked), + ("dirty high bits rejected (strict ABI decode)", _dirty_high_bits_rejected), ("STATICCALL read-only enforced", _staticcall_read_only), ("value forwarding rejected through a contract", _value_forwarding_rejected), ("returndata fidelity (RETURNDATACOPY of revert payload)", _returndata_fidelity), diff --git a/test/unit/B20Factory/getTokenAddress.t.sol b/test/unit/B20Factory/getTokenAddress.t.sol index 78ebf87..ac33809 100644 --- a/test/unit/B20Factory/getTokenAddress.t.sol +++ b/test/unit/B20Factory/getTokenAddress.t.sol @@ -117,11 +117,10 @@ contract B20FactoryGetTokenAddressTest is B20FactoryTest { /// @notice Verifies getB20Address rejects raw variant bytes outside the B20Variant enum range /// @dev Mirrors the createB20 raw-bytes test. B20Variant has no "NONE" sentinel; typed /// callers cannot construct an out-of-range value, so this path is only reachable via - /// raw calldata. Solidity rejects via the ABI decoder with Panic(0x21); the Rust - /// precompile rejects with the typed `InvalidVariant()` revert. The observable - /// contract from a raw-bytes caller is "the call reverts" on both backends; the - /// specific selector differs because Solidity has no opportunity to run a function - /// body before the decoder rejects. + /// raw calldata. Both Solidity and the Rust precompile reject at ABI decode before + /// any factory body runs (Solidity: Panic(0x21); Rust: AbiDecodeFailed). Typed + /// `InvalidVariant()` is only reachable on the Solidity mock after decode succeeds. + /// The observable contract from a raw-bytes caller is simply "the call reverts". function test_getB20Address_revert_outOfRangeVariant(address sender, bytes32 salt, uint8 badVariant) public { badVariant = uint8(bound(uint256(badVariant), uint256(type(IB20Factory.B20Variant).max) + 1, 255)); vm.expectRevert(); From 58227672ff4ef163dc81a675d314dfec2966e1c0 Mon Sep 17 00:00:00 2001 From: Rayyan Alam Date: Wed, 10 Jun 2026 23:59:18 -0400 Subject: [PATCH 2/2] chore(smoke): restore default PYTHON to python3.13 Keep the Makefile default aligned with the repo's expected Python version; callers can still override with PYTHON=python3 for local pyenv setups. Co-authored-by: Cursor --- Makefile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Makefile b/Makefile index c059871..77d513a 100644 --- a/Makefile +++ b/Makefile @@ -1,7 +1,7 @@ # Source the gitignored .env for the smoke recipes. LOAD_ENV = pre=$$(export -p); set -a; [ -f .env ] && . ./.env; set +a; eval "$$pre"; -PYTHON ?= python3 +PYTHON ?= python3.13 VENV = script/smoke/.venv # `smoke` is the package at script/smoke/, so its parent (script) is on the path. SMOKE_RUN = $(LOAD_ENV) PYTHONPATH=script $(VENV)/bin/python -m smoke