From 2653b6ef17912996f819683d9f71a3feca0e17cf Mon Sep 17 00:00:00 2001 From: memosr Date: Sat, 22 Aug 2026 22:35:04 +0300 Subject: [PATCH] fix(rpc): handle 3-byte RLP list headers in decode_method_call_data `decode_method_call_data` unwraps the rlp([calldata, leader_only]) payload that genlayer-js sends for `gen_call` (type "read") and `eth_call`. It parsed the outer list header by hand and assumed a long list header is always 2 bytes. An RLP long list header is 0xF7 + the number of length bytes, so it is 3 bytes once the payload reaches 256 bytes. Any read whose calldata pushes the payload past that point hit the wrong offset: rlp.exceptions.DecodingError: RLP string ends with 255 superfluous bytes The boundary is exact: 252 bytes of calldata decode, 253 bytes fail. In practice that is roughly 235 characters of string argument. Two further defects shared the same root cause and are fixed here: - `leader_only=True` encodes as a trailing 0x01, so the `raw_bytes[-1] == 0` guard never fired and the entire RLP payload was returned as if it were calldata. This failed silently rather than raising, and `leaderOnly` is a public option on genlayer-js `readContract`. - Empty or single-byte input raised IndexError. The header length is now computed from the prefix. Every existing test of this method mocks it, so it had no real coverage; adds tests covering the 252/253 boundary, payloads up to 70000 bytes, the leader_only path, and degenerate inputs. Follow-up to #1184, which introduced the 2-byte assumption. --- backend/protocol_rpc/transactions_parser.py | 23 ++++++----- tests/unit/test_transactions_parser.py | 45 +++++++++++++++++++++ 2 files changed, 59 insertions(+), 9 deletions(-) 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)