Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
79 changes: 79 additions & 0 deletions script/smoke/chain.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
8 changes: 6 additions & 2 deletions script/smoke/journeys/factory.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
19 changes: 7 additions & 12 deletions script/smoke/journeys/precompile_invariants.py
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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) ───────────────────────────────────
Expand Down Expand Up @@ -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),
Expand Down
9 changes: 4 additions & 5 deletions test/unit/B20Factory/getTokenAddress.t.sol
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
Loading