diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..18aa1d4 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,31 @@ +name: Test + +on: + pull_request: + push: + branches: [main] + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + pytest: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - uses: actions/setup-python@v6 + with: + python-version: "3.12" + - uses: dtolnay/rust-toolchain@stable + - uses: Swatinem/rust-cache@v2 + - name: Build and install extension + run: | + python -m venv .venv + .venv/bin/pip install maturin pytest + .venv/bin/maturin develop + - name: Run tests + run: .venv/bin/pytest -q tests diff --git a/CHANGELOG.md b/CHANGELOG.md index c623714..dfeb08d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,27 @@ # Changelog +## [Unreleased] + +### Fixed + +- `CallDecoder.decode_inputs` and `CallDecoder.decode_inputs_sync` no longer + crash: they forwarded the `input` builtin instead of `inputs`, and the sync + variant called a misspelled `decode_input_syncs` method (#55). +- `CallDecoder` return type hints are now `list[Optional[list[DecodedSolValue]]]`, + and the docstrings say an entry is `None` when no signature matches (#56). +- `CallDecoder` returns `None` for calldata it can't decode (empty `0x`, shorter + than a selector, truncated arguments, non-hex) instead of panicking with an + uncatchable `PanicException`. +- `HypersyncClient.get_with_rate_limit` is typed as returning + `Optional[QueryResponse]`; the response is `None` on HTTP 429. + +### Known issue + +- Decoded `CallDecoder` argument values are still wrong: the underlying + hypersync-client decodes the 4-byte selector as part of the arguments. Fixed + upstream in enviodev/hypersync-client-rust#144; this needs a release that + bumps to it. + ## [1.3.0] - 2026-10-29 ### Upgrade to hypersync-client-rust v2.0.0 diff --git a/hypersync/__init__.py b/hypersync/__init__.py index b1de5dd..43013a4 100644 --- a/hypersync/__init__.py +++ b/hypersync/__init__.py @@ -193,10 +193,10 @@ def decode_events_sync(self, events: list[Event]) -> list[Optional[DecodedEvent] class CallDecoder: - """Decode logs parsing topics and log data.""" + """Decode function call inputs (calldata) using function signatures.""" def __init__(self, signatures: list[str]): - """Initialize decoder from event signatures.""" + """Initialize decoder from function signatures, e.g. "transfer(address to, uint256 amount)".""" self.inner = _CallDecoder(signatures) def enable_checksummed_addresses(self): @@ -205,36 +205,40 @@ def enable_checksummed_addresses(self): def disable_checksummed_addresses(self): self.inner.disable_checksummed_addresses() - async def decode_inputs(self, inputs: list[str]) -> list[list[DecodedSolValue]]: - """Parse log and return decoded event. Returns None if topic0 not found.""" - return await self.inner.decode_inputs(input) + async def decode_inputs( + self, inputs: list[str] + ) -> list[Optional[list[DecodedSolValue]]]: + """Decode hex calldata strings. An entry is None if it is not valid calldata or matches no signature.""" + return await self.inner.decode_inputs(inputs) - def decode_inputs_sync(self, inputs: list[str]) -> list[list[DecodedSolValue]]: - """Parse log and return decoded event. Returns None if topic0 not found.""" - return self.inner.decode_input_syncs(input) + def decode_inputs_sync( + self, inputs: list[str] + ) -> list[Optional[list[DecodedSolValue]]]: + """Decode hex calldata strings. An entry is None if it is not valid calldata or matches no signature.""" + return self.inner.decode_inputs_sync(inputs) async def decode_transactions_input( self, txs: list[Transaction] - ) -> list[list[DecodedSolValue]]: - """Parse log and return decoded event. Returns None if topic0 not found.""" + ) -> list[Optional[list[DecodedSolValue]]]: + """Decode transaction inputs. An entry is None if the input is missing, is not valid calldata, or matches no signature.""" return await self.inner.decode_transactions_input(txs) def decode_transactions_input_sync( self, txs: list[Transaction] - ) -> list[list[DecodedSolValue]]: - """Parse log and return decoded event. Returns None if topic0 not found.""" + ) -> list[Optional[list[DecodedSolValue]]]: + """Decode transaction inputs. An entry is None if the input is missing, is not valid calldata, or matches no signature.""" return self.inner.decode_transactions_input_sync(txs) async def decode_traces_input( self, traces: list[Trace] - ) -> list[list[DecodedSolValue]]: - """Parse log and return decoded event. Returns None if topic0 not found.""" + ) -> list[Optional[list[DecodedSolValue]]]: + """Decode trace inputs. An entry is None if the input is missing, is not valid calldata, or matches no signature.""" return await self.inner.decode_traces_input(traces) def decode_traces_input_sync( self, traces: list[Trace] - ) -> list[list[DecodedSolValue]]: - """Parse log and return decoded event. Returns None if topic0 not found.""" + ) -> list[Optional[list[DecodedSolValue]]]: + """Decode trace inputs. An entry is None if the input is missing, is not valid calldata, or matches no signature.""" return self.inner.decode_traces_input_sync(traces) @@ -938,8 +942,13 @@ async def get_arrow(self, query: Query) -> ArrowResponse: """Executes query with retries and returns the response in Arrow format.""" return await self.inner.get_arrow(query) - async def get_with_rate_limit(self, query: Query) -> Tuple[QueryResponse, RateLimitInfo]: - """Executes query with retries and returns the response with rate limit info.""" + async def get_with_rate_limit( + self, query: Query + ) -> Tuple[Optional[QueryResponse], RateLimitInfo]: + """Executes query with retries and returns the response with rate limit info. + + The response is None if the request was rate limited (HTTP 429). + """ return await self.inner.get_with_rate_limit(query) def rate_limit_info(self) -> Optional[RateLimitInfo]: diff --git a/src/decode_call.rs b/src/decode_call.rs index 3f1e178..e1ea1a7 100644 --- a/src/decode_call.rs +++ b/src/decode_call.rs @@ -117,13 +117,15 @@ impl CallDecoder { .collect() } + /// Returns None for calldata that isn't hex, is shorter than a selector, + /// has an unknown selector, or doesn't ABI-decode; never panics. pub fn decode_impl(&self, input: &str, py: Python) -> Option> { - let input = Data::decode_hex(input).context("decode input").unwrap(); - let decoded_input = self - .inner - .decode_input(&input) - .context("decode log") - .unwrap(); + let input = Data::decode_hex(input).ok()?; + // Older hypersync-client versions index input[0..4] unchecked. + if input.len() < 4 { + return None; + } + let decoded_input = self.inner.decode_input(&input).ok()?; decoded_input.map(|decoded_input| { decoded_input .into_iter() diff --git a/tests/test_call_decoder.py b/tests/test_call_decoder.py new file mode 100644 index 0000000..7f5ea2e --- /dev/null +++ b/tests/test_call_decoder.py @@ -0,0 +1,59 @@ +"""Offline tests for the Python CallDecoder wrapper (no network needed).""" + +import asyncio + +import pytest + +import hypersync + +# transfer(0x000000000000000000000000000000000000dEaD, 1000) +_TRANSFER_INPUT = ( + "0xa9059cbb" + "000000000000000000000000000000000000000000000000000000000000dead" + "00000000000000000000000000000000000000000000000000000000000003e8" +) +_UNKNOWN_INPUT = "0xdeadbeef" + + +def _decoder() -> hypersync.CallDecoder: + return hypersync.CallDecoder(["transfer(address to, uint256 amount)"]) + + +def _assert_shape(out): + assert len(out) == 2 + assert out[0] is not None and len(out[0]) == 2 + # Unmatched selectors decode to None. + assert out[1] is None + + +def test_decode_inputs_sync(): + _assert_shape(_decoder().decode_inputs_sync([_TRANSFER_INPUT, _UNKNOWN_INPUT])) + + +def test_decode_inputs_async(): + _assert_shape(asyncio.run(_decoder().decode_inputs([_TRANSFER_INPUT, _UNKNOWN_INPUT]))) + + +@pytest.mark.parametrize( + "bad_input", + [ + "0x", # plain ETH transfer + "0xa905", # shorter than a selector + "0xa9059cbb0000", # known selector, truncated args + "not hex", + ], +) +def test_decode_inputs_invalid_calldata_is_none(bad_input): + assert _decoder().decode_inputs_sync([bad_input]) == [None] + assert asyncio.run(_decoder().decode_inputs([bad_input])) == [None] + + +@pytest.mark.xfail( + strict=True, + reason="hypersync-client's CallDecoder::decode_input doesn't strip the 4-byte selector; " + "remove once the Rust fix is released and bumped here", +) +def test_decode_inputs_values(): + to, amount = _decoder().decode_inputs_sync([_TRANSFER_INPUT])[0] + assert to.val.lower() == "0x000000000000000000000000000000000000dead" + assert amount.val == 1000