diff --git a/script/smoke/chain.py b/script/smoke/chain.py index 53383028..2be56e9e 100644 --- a/script/smoke/chain.py +++ b/script/smoke/chain.py @@ -36,6 +36,10 @@ from .errors import ERROR_BY_SELECTOR from .provider import ConsistentHTTPProvider +# Solidity built-in `Panic(uint256)` selector. Not a custom error in any ABI, so it is not in +# `ERROR_BY_SELECTOR`; matched directly by `expect_raw_panic`. +PANIC_SELECTOR = bytes.fromhex("4e487b71") + class Skip(Exception): """Raised by a journey to signal it does not apply to this chain (recorded as a skip, not a fail). @@ -513,6 +517,39 @@ def expect_raw_revert( self._diagnose(f"{desc}: expected revert but call succeeded", repro_call=tx) die(f"{desc}: expected revert but call succeeded") + def expect_raw_panic( + self, + desc: str, + to: ChecksumAddress, + data: bytes, + code: int, + *, + frm: ChecksumAddress | None = None, + ) -> None: + """Simulate a hand-built call; assert it reverts with a Solidity `Panic(uint256)` of `code`. + + Asserts the raw revert data is `PANIC_SELECTOR` followed by the 32-byte `code` (e.g. 0x11 for + arithmetic overflow). + """ + tx = {"to": to, "from": frm or self.DEPLOYER, "data": HexBytes(data), "value": 0} + try: + self.w3.eth.call(tx) + except ContractLogicError as exc: + raw = self._revert_bytes(exc) + if raw is None or len(raw) < 4 or raw[:4] != PANIC_SELECTOR: + self._diagnose(f"{desc}: expected Panic({code:#x})", repro_call=tx) + die(f"{desc}: expected Panic(uint256) 0x4e487b71 but got {('0x' + raw.hex()) if raw else raw!r}") + got_code = int.from_bytes(raw[4:36], "big") if len(raw) >= 36 else None + if got_code != code: + self._diagnose(f"{desc}: wrong Panic code", repro_call=tx) + die(f"{desc}: expected Panic code {code:#x} but got {got_code if got_code is None else hex(got_code)}") + ok(f"{desc} (reverts Panic({code:#x}))") + return + except Exception as exc: # noqa: BLE001 - surface any non-revert failure + die(f"{desc}: expected Panic({code:#x}) but call raised {type(exc).__name__}: {exc}") + self._diagnose(f"{desc}: expected Panic but call succeeded", repro_call=tx) + die(f"{desc}: expected Panic({code:#x}) but call succeeded") + def send_expecting_revert(self, fn, account: LocalAccount, *, gas: int = 2_000_000) -> TxReceipt: """Broadcast a real tx with explicit gas (skips estimation) and assert the receipt reverted.""" tx = fn.build_transaction( diff --git a/script/smoke/journeys/asset_lifecycle.py b/script/smoke/journeys/asset_lifecycle.py index 7f969c28..9e1e0601 100644 --- a/script/smoke/journeys/asset_lifecycle.py +++ b/script/smoke/journeys/asset_lifecycle.py @@ -140,6 +140,22 @@ def _edges(c: Chain, tok) -> None: reuse = tok.functions.announce([], "smoke-batch-1", "dup", "ipfs://smoke/dup") c.expect_revert("AnnouncementIdAlreadyUsed", reuse, c.DEPLOYER) + step("14b", "announce inner Panic propagates raw (not wrapped as InternalCallFailed)") + # multiplier is 2e18 (step 7), so an inner toScaledBalance(uint256 max) overflows -> Panic(0x11); + # assert the live precompile bubbles the raw payload instead of wrapping it. + inner_overflow = init_call(c.asset_abi, "toScaledBalance", 2**256 - 1) + panic_announce = init_call( + c.asset_abi, "announce", [inner_overflow], "smoke-panic-1", "overflow probe", "ipfs://smoke/panic-1" + ) + c.expect_raw_panic( + "announce inner overflow -> raw Panic(0x11)", tok.address, panic_announce, 0x11, frm=c.DEPLOYER + ) + c.assert_eq( + tok.functions.isAnnouncementIdUsed("smoke-panic-1").call(), + False, + "reverted announce must not consume the panic-probe id", + ) + def _events(c: Chain, v2: bool) -> None: step(15, "expected events emitted across the flow") diff --git a/src/interfaces/IB20Asset.sol b/src/interfaces/IB20Asset.sol index d850c60a..49b9611d 100644 --- a/src/interfaces/IB20Asset.sol +++ b/src/interfaces/IB20Asset.sol @@ -63,7 +63,8 @@ interface IB20Asset is IB20, IERC165, IScaledUIAmount, IScaledUIAmountNewUIMulti /// @param call Offending raw calldata blob. error InternalCallMalformed(bytes call); - /// @notice An inner call dispatched by `announce` reverted. The inner revert reason is not bubbled. + /// @notice An inner call dispatched by `announce` reverted with an ordinary revert; its reason is + /// not bubbled. A Solidity `Panic` propagates raw instead (see `announce`). /// /// @param call Offending raw calldata blob. error InternalCallFailed(bytes call); @@ -119,7 +120,9 @@ interface IB20Asset is IB20, IERC165, IScaledUIAmount, IScaledUIAmountNewUIMulti /// @dev Reverts with `AnnouncementIdAlreadyUsed` when `id` has previously been consumed. /// @dev Reverts with `InternalCallMalformed` when an entry in `internalCalls` is shorter than four bytes. /// @dev Reverts with `AnnouncementInProgress` when an entry in `internalCalls` targets `announce` itself. - /// @dev Reverts with `InternalCallFailed` when an entry in `internalCalls` reverts during the inner `delegatecall`. + /// @dev An inner call that raises a Solidity `Panic` (e.g. arithmetic overflow) propagates the + /// raw Panic unchanged; any other inner revert wraps as `InternalCallFailed(call)`. An inner + /// out-of-gas halts the whole call. /// /// @param internalCalls ABI-encoded calldata blobs executed in order via self-`delegatecall`; may be empty. /// @param id Caller-chosen announcement id; single-use over the token's lifetime. diff --git a/test/lib/mocks/MockB20Asset.sol b/test/lib/mocks/MockB20Asset.sol index dca1515b..6420adea 100644 --- a/test/lib/mocks/MockB20Asset.sol +++ b/test/lib/mocks/MockB20Asset.sol @@ -107,8 +107,18 @@ contract MockB20Asset is MockB20, IB20Asset { for (uint256 i = 0; i < internalCalls.length; i++) { _checkSelector(internalCalls[i]); - (bool success,) = address(this).delegatecall(internalCalls[i]); - if (!success) revert InternalCallFailed(internalCalls[i]); + (bool success, bytes memory ret) = address(this).delegatecall(internalCalls[i]); + if (!success) { + // Match the Rust precompile's is_system_error(): a Solidity Panic propagates + // unwrapped; only ordinary reverts wrap as InternalCallFailed. + if (ret.length >= 4 && bytes4(ret) == bytes4(0x4e487b71)) { + // Re-raise the exact returndata; offset 0x20 skips the length word. + assembly { + revert(add(ret, 0x20), mload(ret)) + } + } + revert InternalCallFailed(internalCalls[i]); + } } emit EndAnnouncement(id); diff --git a/test/unit/B20Asset/announcement/announce.t.sol b/test/unit/B20Asset/announcement/announce.t.sol index b48d422d..f8e4a18b 100644 --- a/test/unit/B20Asset/announcement/announce.t.sol +++ b/test/unit/B20Asset/announcement/announce.t.sol @@ -76,6 +76,21 @@ contract B20AssetAnnounceTest is B20AssetTest { asset().announce(_singletonBytes(failingCall), "fail-id", "desc", "uri"); } + /// @notice Verifies an inner call that raises a Solidity Panic propagates the raw Panic + /// unchanged instead of being wrapped as InternalCallFailed (parity with the Rust impl). + /// @dev Arithmetic overflow (0x11) is the one inner-call Panic reachable on both sides: a + /// multiplier > 1 makes toScaledBalance(uint256 max) overflow. NOT skipped under live + /// precompiles — asserting the raw payload from the live precompile is the conformance point. + function test_announce_innerPanic_propagatesRaw() public { + _grantOperator(); + _updateMultiplier(2 * asset().WAD_PRECISION()); + bytes memory inner = abi.encodeWithSelector(IB20Asset.toScaledBalance.selector, type(uint256).max); + + vm.prank(operator); + vm.expectRevert(abi.encodeWithSignature("Panic(uint256)", 0x11)); + asset().announce(_singletonBytes(inner), "panic-id", "desc", "uri"); + } + /// @notice Verifies a failed announcement does NOT consume the id (atomicity) /// @dev The whole tx unwinds on inner-call failure, including the /// `usedAnnouncementIds[id] = true` write that announce performs before the