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
37 changes: 37 additions & 0 deletions script/smoke/chain.py
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down Expand Up @@ -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(
Expand Down
16 changes: 16 additions & 0 deletions script/smoke/journeys/asset_lifecycle.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
7 changes: 5 additions & 2 deletions src/interfaces/IB20Asset.sol
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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.
Expand Down
14 changes: 12 additions & 2 deletions test/lib/mocks/MockB20Asset.sol
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
15 changes: 15 additions & 0 deletions test/unit/B20Asset/announcement/announce.t.sol
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading