Skip to content
Open
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
31 changes: 31 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
@@ -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
22 changes: 22 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
45 changes: 27 additions & 18 deletions hypersync/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand All @@ -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)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved

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)


Expand Down Expand Up @@ -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]:
Expand Down
14 changes: 8 additions & 6 deletions src/decode_call.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Vec<DecodedSolValue>> {
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()
Expand Down
59 changes: 59 additions & 0 deletions tests/test_call_decoder.py
Original file line number Diff line number Diff line change
@@ -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(
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
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
Loading