diff --git a/backend/protocol_rpc/transactions_parser.py b/backend/protocol_rpc/transactions_parser.py index 1a0f85126..93e754682 100644 --- a/backend/protocol_rpc/transactions_parser.py +++ b/backend/protocol_rpc/transactions_parser.py @@ -604,17 +604,22 @@ def decode_method_send_data(self, data: str) -> DecodedMethodSendData: def decode_method_call_data(self, data: str) -> DecodedMethodCallData: raw_bytes = eth_utils.hexadecimal.decode_hex(data) - # Remove the null byte - if raw_bytes[-1] == 0: + # Newer clients send rlp([calldata, leader_only]). leader_only is a + # single byte, so it survives RLP encoding as a literal 0x00 or 0x01. + if len(raw_bytes) > 1 and raw_bytes[-1] in (0, 1): raw_bytes = raw_bytes[:-1] - # Try to decode the outer list first - if raw_bytes[0] >= 0xF8: # Long list - raw_bytes = raw_bytes[2:] # Skip list prefix and length - elif raw_bytes[0] >= 0xC0: # Short list - raw_bytes = raw_bytes[1:] # Skip list prefix - - # Now try to decode the inner string + # Strip the outer list header. + prefix = raw_bytes[0] + if prefix >= 0xF8: + # Long list: 0xF7 + number of length bytes, so the header is + # 1 + (prefix - 0xF7) bytes. It is 3 bytes once the payload + # reaches 256 bytes, not always 2. + raw_bytes = raw_bytes[1 + (prefix - 0xF7) :] + elif prefix >= 0xC0: # Short list + raw_bytes = raw_bytes[1:] + + # Now decode the inner string raw_bytes = rlp.decode(raw_bytes) return DecodedMethodCallData(raw_bytes) diff --git a/tests/unit/test_transactions_parser.py b/tests/unit/test_transactions_parser.py index 8c102d4fa..0f9fc032a 100644 --- a/tests/unit/test_transactions_parser.py +++ b/tests/unit/test_transactions_parser.py @@ -579,3 +579,48 @@ def test_decode_signed_transaction_submit_appeal_uses_value_as_bond(monkeypatch) assert decoded.data.tx_id == tx_id assert decoded.data.fees_distribution is None assert decoded.data.top_up_and_submit is False + + +# --- decode_method_call_data ------------------------------------------------- +# Wire format produced by genlayer-js readContract / eth_call is +# rlp([calldata, leader_only]); see genlayer-js src/contracts/actions.ts. + + +def _call_wire(calldata_bytes: bytes, leader_only: bool = False) -> str: + payload = encode([calldata_bytes, b"\x01" if leader_only else b"\x00"]) + return "0x" + payload.hex() + + +def _opaque_calldata(size: int) -> bytes: + return bytes([(3 << 3) | 6]) + b"\x41" * (size - 1) + + +@pytest.mark.parametrize("size", [1, 5, 100, 252, 253, 300, 1000, 70000]) +def test_decode_method_call_data_roundtrip(transaction_parser, size): + calldata_bytes = _opaque_calldata(size) + decoded = transaction_parser.decode_method_call_data(_call_wire(calldata_bytes)) + assert decoded.calldata == calldata_bytes + + +@pytest.mark.parametrize("size", [5, 253, 1000]) +def test_decode_method_call_data_leader_only(transaction_parser, size): + calldata_bytes = _opaque_calldata(size) + decoded = transaction_parser.decode_method_call_data( + _call_wire(calldata_bytes, leader_only=True) + ) + assert decoded.calldata == calldata_bytes + + +def test_decode_method_call_data_long_list_header_boundary(transaction_parser): + # RLP switches from a 2-byte to a 3-byte list header at 256 payload bytes, + # which lands between these two calldata sizes. + for size in (252, 253): + calldata_bytes = _opaque_calldata(size) + decoded = transaction_parser.decode_method_call_data(_call_wire(calldata_bytes)) + assert decoded.calldata == calldata_bytes + + +@pytest.mark.parametrize("data", ["0x", "0x00", "0x01"]) +def test_decode_method_call_data_degenerate_input(transaction_parser, data): + # Too short to be a wrapped payload; must not raise. + transaction_parser.decode_method_call_data(data)