From 8f43fe32a5b486c76b9ea75e7335158d678ac6e2 Mon Sep 17 00:00:00 2001 From: pauldelucia Date: Thu, 25 Jun 2026 12:21:44 +0700 Subject: [PATCH 1/2] feat: client-side completeness verify (canonical hash + verifyServedEvents) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mirror Willow's on-chain completeness commitment into the SDK so a client can verify an indexer's served matched-log set for a (subgrove, block) is the complete, untampered filter-matched event set the chain attests to — without trusting the indexer. - canonical_event_set_hash(block_number, matched_logs): domain-separated keccak-256 over the exact on-chain preimage (WILLOW_CRYPTO_EVENTS_V1 tag, big-endian length prefixes, per-log address/topics/data), mirroring willow-network::data_sources::types::canonical_event_set_hash. - verify_served_events(commitment, block_number, matched_logs): re-hash the served preimage and compare to the on-chain events_commitment anchor. Reuses the SDK's eth_hash keccak dep. Adds tests asserting both cross-language vectors exactly plus tamper cases (wrong block, drop/add/reorder/mutate log). Skips the optional end-to-end fetch helper: this SDK has no ABCI store-query client for the events_commitment path nor an indexer HTTP client for the matched-logs route (documented in the module). Co-Authored-By: Claude Opus 4.8 (1M context) --- src/willow/__init__.py | 11 +++ src/willow/completeness.py | 142 +++++++++++++++++++++++++++++++++++++ tests/test_completeness.py | 125 ++++++++++++++++++++++++++++++++ 3 files changed, 278 insertions(+) create mode 100644 src/willow/completeness.py create mode 100644 tests/test_completeness.py diff --git a/src/willow/__init__.py b/src/willow/__init__.py index 3ce74c1..13489da 100644 --- a/src/willow/__init__.py +++ b/src/willow/__init__.py @@ -236,6 +236,13 @@ async def main(): decrypt_file, ) +# Client-side completeness verification. +from .completeness import ( + Log, + canonical_event_set_hash, + verify_served_events, +) + # Verifiable Ethereum state reads. from .eth_state import ( EthOperations, @@ -412,6 +419,10 @@ async def main(): "extract_root_hash_from_proof", "verify_proof_quick", "verify_proof_with_expected_root", + # Completeness Verification + "Log", + "canonical_event_set_hash", + "verify_served_events", # GroveDB module "grovedb", # Light Client diff --git a/src/willow/completeness.py b/src/willow/completeness.py new file mode 100644 index 0000000..22b2043 --- /dev/null +++ b/src/willow/completeness.py @@ -0,0 +1,142 @@ +"""Client-side completeness verification. + +Counterpart to Willow's on-chain ``events_commitment``: a domain-separated +keccak-256 commitment over the filter-matched event set for a ``(subgrove, +block)``. The chain stores this 32-byte hash; an indexer serves the matched-log +preimage; the client re-hashes the preimage here and compares. A match proves +the served set is the complete, untampered set the chain attests to — without +trusting the indexer. + +Canonical Rust source: +``willow-network::data_sources::types::canonical_event_set_hash`` (mirrored from +``willow-consensus``'s ``full_block_auth::canonical_event_set_hash``). The +preimage binds only ``(address, topics, data)`` — the consensus-derivable, +root-bound fields — length-prefixed so no boundary is ambiguous. + +The optional ``verify_block_completeness`` end-to-end helper (fetch the anchor +from the validator ABCI store + the preimage from the indexer, then verify) is +intentionally NOT included: this SDK has no generic ABCI store-query client for +the ``events_commitment`` path nor an indexer HTTP client for the +``/completeness/{subgrove}/{block}/matched-logs`` route. Once those land, wire +them up and call :func:`verify_served_events` with the fetched values. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Optional, Sequence, Union + +from eth_hash.auto import keccak + +# Domain separator — must match the on-chain tag byte-for-byte (23 ASCII bytes, +# no null terminator). +DOMAIN_TAG = b"WILLOW_CRYPTO_EVENTS_V1" + +HexOrBytes = Union[str, bytes, bytearray] + + +def _to_bytes(value: HexOrBytes, *, name: str, length: Optional[int] = None) -> bytes: + """Coerce hex string (``0x``-optional) or raw bytes to ``bytes``.""" + if isinstance(value, str): + out = bytes.fromhex(value[2:] if value.startswith("0x") else value) + elif isinstance(value, (bytes, bytearray)): + out = bytes(value) + else: + raise TypeError(f"{name} must be hex str or bytes, got {type(value).__name__}") + if length is not None and len(out) != length: + raise ValueError(f"{name} must be {length} bytes, got {len(out)}") + return out + + +@dataclass +class Log: + """A filter-matched Ethereum log, as committed by ``events_commitment``. + + Only the consensus-derivable, root-bound fields are part of the commitment. + + Args: + address: 20-byte contract address (hex str or raw bytes). + topics: ordered list of 32-byte topics (hex str or raw bytes each). + data: raw log data bytes (hex str or raw bytes). + """ + + address: HexOrBytes + topics: Sequence[HexOrBytes] = field(default_factory=list) + data: HexOrBytes = b"" + + def canonical_bytes(self) -> bytes: + """Encode this log into its canonical commitment preimage fragment. + + Layout (all integers big-endian, no separators): + - address (20 bytes) + - topics.length as u32 big-endian (4 bytes) + - each topic (32 bytes each) + - data.length as u32 big-endian (4 bytes) + - data (raw bytes) + """ + addr = _to_bytes(self.address, name="address", length=20) + topics = [_to_bytes(t, name="topic", length=32) for t in self.topics] + data = _to_bytes(self.data, name="data") + + buf = bytearray() + buf += addr + buf += len(topics).to_bytes(4, "big") + for topic in topics: + buf += topic + buf += len(data).to_bytes(4, "big") + buf += data + return bytes(buf) + + +def canonical_event_set_hash(block_number: int, matched_logs: Sequence[Log]) -> bytes: + """Domain-separated keccak-256 commitment over the filter-matched event set. + + Mirrors the on-chain ``canonical_event_set_hash``. The preimage is, with all + integers big-endian and no separators: + - ``b"WILLOW_CRYPTO_EVENTS_V1"`` (23 bytes) + - ``block_number`` as u64 big-endian (8 bytes) + - ``len(matched_logs)`` as u64 big-endian (8 bytes) + - then each log's :meth:`Log.canonical_bytes`, in order. + + Args: + block_number: the block the matched set belongs to. + matched_logs: the filter-matched logs, in canonical (chain) order. + + Returns: + The 32-byte keccak-256 commitment. + """ + if block_number < 0 or block_number >= (1 << 64): + raise ValueError("block_number must fit in u64") + + buf = bytearray() + buf += DOMAIN_TAG + buf += block_number.to_bytes(8, "big") + buf += len(matched_logs).to_bytes(8, "big") + for log in matched_logs: + buf += log.canonical_bytes() + return keccak(bytes(buf)) + + +def verify_served_events( + commitment: HexOrBytes, + block_number: int, + matched_logs: Sequence[Log], +) -> bool: + """Verify served matched-logs against the on-chain ``events_commitment``. + + Re-hashes the served preimage and compares it to the trusted anchor. ``True`` + means the served set is exactly the complete, untampered set the chain + attests to; ``False`` means it was tampered with (a log added, dropped, or + mutated) or the block number is wrong. + + Args: + commitment: the 32-byte on-chain anchor (hex str or raw bytes). + block_number: the block the served set claims to cover. + matched_logs: the matched logs served by the indexer. + + Returns: + ``True`` if the recomputed hash equals ``commitment``. + """ + anchor = _to_bytes(commitment, name="commitment", length=32) + computed = canonical_event_set_hash(block_number, matched_logs) + return computed == anchor diff --git a/tests/test_completeness.py b/tests/test_completeness.py new file mode 100644 index 0000000..71d6ed1 --- /dev/null +++ b/tests/test_completeness.py @@ -0,0 +1,125 @@ +"""Tests for client-side completeness verification. + +The two vectors below are the cross-language correctness gate — they MUST match +the canonical Rust ``canonical_event_set_hash`` byte-for-byte. +""" + +import pytest + +from willow.completeness import ( + Log, + canonical_event_set_hash, + verify_served_events, +) + +# Vector A — the empty matched set at block 0. +VECTOR_A_BLOCK = 0 +VECTOR_A_LOGS: list = [] +VECTOR_A_HASH = "0x52089e4c924fbab0475d310d7f74bf8cae542d006a45d3c5d94adacda6937da5" + +# Vector B — two logs at block 7. +VECTOR_B_BLOCK = 7 +VECTOR_B_LOGS = [ + Log( + address=bytes([0x42]) * 20, + topics=[bytes([0xDD]) * 32, bytes([0x11]) * 32], + data=bytes([0x01, 0x02, 0x03, 0x04]), + ), + Log( + address=bytes([0x43]) * 20, + topics=[bytes([0xAA]) * 32], + data=b"", + ), +] +VECTOR_B_HASH = "0xe1544ae919458663e8fce14bdcd06df6a777410c068302c0584dff1587524dfd" + + +class TestCanonicalEventSetHash: + """The hash must match the on-chain ``canonical_event_set_hash`` exactly.""" + + def test_vector_a_empty_set(self): + """Vector A: block 0, no matched logs.""" + digest = canonical_event_set_hash(VECTOR_A_BLOCK, VECTOR_A_LOGS) + assert digest.hex() == VECTOR_A_HASH[2:] + + def test_vector_b_two_logs(self): + """Vector B: block 7, two logs (one with data, one empty).""" + digest = canonical_event_set_hash(VECTOR_B_BLOCK, VECTOR_B_LOGS) + assert digest.hex() == VECTOR_B_HASH[2:] + + def test_deterministic(self): + """Hashing the same input twice yields the same digest.""" + a = canonical_event_set_hash(VECTOR_B_BLOCK, VECTOR_B_LOGS) + b = canonical_event_set_hash(VECTOR_B_BLOCK, VECTOR_B_LOGS) + assert a == b + + def test_accepts_hex_string_fields(self): + """Hex-string fields encode identically to raw bytes.""" + hex_logs = [ + Log( + address="0x" + "42" * 20, + topics=["0x" + "dd" * 32, "0x" + "11" * 32], + data="0x01020304", + ), + Log(address="43" * 20, topics=["aa" * 32], data="0x"), + ] + assert ( + canonical_event_set_hash(VECTOR_B_BLOCK, hex_logs) + == canonical_event_set_hash(VECTOR_B_BLOCK, VECTOR_B_LOGS) + ) + + def test_block_number_must_fit_u64(self): + """Out-of-range block numbers are rejected.""" + with pytest.raises(ValueError): + canonical_event_set_hash(1 << 64, []) + with pytest.raises(ValueError): + canonical_event_set_hash(-1, []) + + +class TestVerifyServedEvents: + """Re-hash the served preimage and compare to the on-chain anchor.""" + + def test_vector_a_verifies(self): + assert verify_served_events(VECTOR_A_HASH, VECTOR_A_BLOCK, VECTOR_A_LOGS) + + def test_vector_b_verifies(self): + assert verify_served_events(VECTOR_B_HASH, VECTOR_B_BLOCK, VECTOR_B_LOGS) + + def test_commitment_accepts_raw_bytes(self): + anchor = bytes.fromhex(VECTOR_B_HASH[2:]) + assert verify_served_events(anchor, VECTOR_B_BLOCK, VECTOR_B_LOGS) + + def test_tamper_wrong_block_number(self): + """Changing the block number fails verification.""" + assert not verify_served_events( + VECTOR_B_HASH, VECTOR_B_BLOCK + 1, VECTOR_B_LOGS + ) + + def test_tamper_dropped_log(self): + """Dropping a log from the served set fails verification.""" + dropped = VECTOR_B_LOGS[:1] + assert not verify_served_events(VECTOR_B_HASH, VECTOR_B_BLOCK, dropped) + + def test_tamper_added_log(self): + """Adding an extra log to the served set fails verification.""" + added = VECTOR_B_LOGS + [ + Log(address=bytes([0x44]) * 20, topics=[], data=b"") + ] + assert not verify_served_events(VECTOR_B_HASH, VECTOR_B_BLOCK, added) + + def test_tamper_mutated_data(self): + """Mutating a single data byte fails verification.""" + mutated = [ + Log( + address=VECTOR_B_LOGS[0].address, + topics=VECTOR_B_LOGS[0].topics, + data=bytes([0x01, 0x02, 0x03, 0x05]), + ), + VECTOR_B_LOGS[1], + ] + assert not verify_served_events(VECTOR_B_HASH, VECTOR_B_BLOCK, mutated) + + def test_tamper_reordered_logs(self): + """Reordering the served set fails verification (order is committed).""" + reordered = list(reversed(VECTOR_B_LOGS)) + assert not verify_served_events(VECTOR_B_HASH, VECTOR_B_BLOCK, reordered) From f7f0900ff11acc673ca1e186f7b0f0ff7254bfcf Mon Sep 17 00:00:00 2001 From: pauldelucia Date: Thu, 25 Jun 2026 12:40:49 +0700 Subject: [PATCH 2/2] feat: end-to-end verify_block_completeness wrapper Adds CompletenessOperations on top of the existing verify_served_events helper. verify_block_completeness(subgrove_id, block_number) does the full client-side check: reads the on-chain events_commitment anchor via the validator's CometBFT abci_query (/store/events_commitment/{sg}/{block}), GETs the indexer's matched-log preimage (/completeness/{sg}/{block}/matched-logs), builds the canonical Log[] from {address, topics, data}, and re-hashes to compare. Reachable as client.completeness; the CometBFT RPC URL is derived from api_url (:3031 -> :26657, matching the light client) and the indexer base URL comes from the existing indexer_url config. A missing anchor (ABCI code != 0) or missing preimage (non-200) raises CompletenessError (not-verifiable), distinct from a False result (tampered set). Tests: gates the JSON->Log parse against the authoritative vector (block 7, two logs -> e1544ae9...) and adds a full mocked e2e path over httpx.MockTransport (anchor + preimage + tampered + missing cases). Co-Authored-By: Claude Opus 4.8 (1M context) --- src/willow/__init__.py | 10 +++ src/willow/client.py | 11 +++ src/willow/completeness.py | 176 +++++++++++++++++++++++++++++++++++-- tests/test_completeness.py | 173 ++++++++++++++++++++++++++++++++++++ 4 files changed, 363 insertions(+), 7 deletions(-) diff --git a/src/willow/__init__.py b/src/willow/__init__.py index 13489da..33d51f5 100644 --- a/src/willow/__init__.py +++ b/src/willow/__init__.py @@ -239,7 +239,12 @@ async def main(): # Client-side completeness verification. from .completeness import ( Log, + CompletenessError, + CompletenessOperations, canonical_event_set_hash, + commitment_from_anchor_value, + log_from_indexer_json, + logs_from_matched_logs_response, verify_served_events, ) @@ -421,7 +426,12 @@ async def main(): "verify_proof_with_expected_root", # Completeness Verification "Log", + "CompletenessError", + "CompletenessOperations", "canonical_event_set_hash", + "commitment_from_anchor_value", + "log_from_indexer_json", + "logs_from_matched_logs_response", "verify_served_events", # GroveDB module "grovedb", diff --git a/src/willow/client.py b/src/willow/client.py index f0cde42..d3355ba 100644 --- a/src/willow/client.py +++ b/src/willow/client.py @@ -60,6 +60,7 @@ ) from .privacy import PrivacyOperations from .files import FileOperations +from .completeness import CompletenessOperations if TYPE_CHECKING: from .light_client import LightClient @@ -901,6 +902,16 @@ def __init__( default_headers = {"X-API-Key": api_key} if api_key else {} self._http = httpx.AsyncClient(timeout=timeout, headers=default_headers) + # Client-side completeness checks. The on-chain anchor is read from the + # validator's CometBFT RPC (derived from api_url, typically :3031 -> + # :26657, matching the light client); the matched-log preimage comes + # from the configured indexer. + self.completeness = CompletenessOperations( + self._http, + self.api_url.replace(":3031", ":26657"), + self.indexer_url, + ) + # Indexer discovery client. When ``indexer_url`` is set, discovery # is bypassed and a synthetic single-entry list is returned so the # routing layer stays uniform. diff --git a/src/willow/completeness.py b/src/willow/completeness.py index 22b2043..402b11b 100644 --- a/src/willow/completeness.py +++ b/src/willow/completeness.py @@ -13,21 +13,26 @@ preimage binds only ``(address, topics, data)`` — the consensus-derivable, root-bound fields — length-prefixed so no boundary is ambiguous. -The optional ``verify_block_completeness`` end-to-end helper (fetch the anchor -from the validator ABCI store + the preimage from the indexer, then verify) is -intentionally NOT included: this SDK has no generic ABCI store-query client for -the ``events_commitment`` path nor an indexer HTTP client for the -``/completeness/{subgrove}/{block}/matched-logs`` route. Once those land, wire -them up and call :func:`verify_served_events` with the fetched values. +The end-to-end :meth:`CompletenessOperations.verify_block_completeness` helper +fetches the anchor from the validator's CometBFT ABCI store +(``/store/events_commitment/{subgrove}/{block}``) and the matched-log preimage +from the indexer (``/completeness/{subgrove}/{block}/matched-logs``), then calls +:func:`verify_served_events`. Reachable as ``client.completeness`` on +:class:`~willow.client.WillowClient`. """ from __future__ import annotations +import base64 +import json from dataclasses import dataclass, field -from typing import Optional, Sequence, Union +from typing import TYPE_CHECKING, Any, Mapping, Optional, Sequence, Union from eth_hash.auto import keccak +if TYPE_CHECKING: # pragma: no cover + import httpx + # Domain separator — must match the on-chain tag byte-for-byte (23 ASCII bytes, # no null terminator). DOMAIN_TAG = b"WILLOW_CRYPTO_EVENTS_V1" @@ -140,3 +145,160 @@ def verify_served_events( anchor = _to_bytes(commitment, name="commitment", length=32) computed = canonical_event_set_hash(block_number, matched_logs) return computed == anchor + + +# --- Wire decoding: the two on-the-wire shapes from willow PR #676 ---------- + + +class CompletenessError(Exception): + """A completeness check could not be performed (no anchor, no preimage).""" + + +def log_from_indexer_json(obj: Mapping[str, Any]) -> Log: + """Build a :class:`Log` from one ``IndexedLog`` JSON object. + + The indexer serves many fields per log (block_number, block_hash, + transaction_hash, transaction_index, log_index, removed); only the three + root-bound, consensus-derivable fields are part of the commitment, so only + those are read here — ``address``, ``topics``, ``data``, all ``0x``-hex. + """ + return Log( + address=obj["address"], + topics=list(obj.get("topics") or []), + data=obj.get("data") or b"", + ) + + +def logs_from_matched_logs_response(body: Mapping[str, Any]) -> list[Log]: + """Parse an indexer ``matched-logs`` response body into ordered ``Log``s. + + Body shape: ``{"subgrove_id", "block_number", "count", "matched_logs": [...]}``. + Order is preserved — the commitment binds it. + """ + return [log_from_indexer_json(entry) for entry in body.get("matched_logs", [])] + + +def commitment_from_anchor_value(value: bytes) -> bytes: + """Decode the 32-byte commitment from an ABCI store-query value. + + ``value`` is the raw bytes of the ABCI ``ResponseQuery.value`` (already + base64-decoded from the CometBFT RPC envelope). On success the chain encodes + it as JSON ``{"subgrove_id", "block_number", "events_commitment": "<64-hex>"}``. + """ + obj = json.loads(value) + return _to_bytes(obj["events_commitment"], name="events_commitment", length=32) + + +class CompletenessOperations: + """End-to-end client-side completeness checks for a ``(subgrove, block)``. + + Fetches the on-chain anchor from the validator's CometBFT ABCI store and the + matched-log preimage from the indexer, then re-hashes and compares via + :func:`verify_served_events`. The indexer it queries is configured by + ``indexer_url`` on the client (or :class:`~willow.client.WillowClientBuilder`). + """ + + def __init__( + self, + http: "httpx.AsyncClient", + cometbft_rpc_url: str, + indexer_url: Optional[str], + ): + """ + Args: + http: shared httpx client (carries any ``X-API-Key`` default header). + cometbft_rpc_url: validator CometBFT RPC base (e.g. ``:26657``), used + for the ``abci_query`` that reads the on-chain anchor. + indexer_url: indexer base URL serving ``/completeness/...``. ``None`` + until configured; the wrapper raises if it's needed unset. + """ + self._http = http + self._rpc_url = cometbft_rpc_url.rstrip("/") + self._indexer_url = indexer_url.rstrip("/") if indexer_url else None + + async def fetch_anchor(self, subgrove_id: str, block_number: int) -> bytes: + """Read the on-chain ``events_commitment`` (32 bytes) via ABCI store query. + + Raises :class:`CompletenessError` if the chain has no commitment for the + block (ABCI ``code != 0``). + """ + path = f"/store/events_commitment/{subgrove_id}/{block_number}" + rpc_request = { + "jsonrpc": "2.0", + "id": 1, + "method": "abci_query", + "params": {"path": path, "data": "", "height": "0", "prove": False}, + } + resp = await self._http.post(self._rpc_url, json=rpc_request) + resp.raise_for_status() + envelope = resp.json() + if "error" in envelope and envelope["error"]: + raise CompletenessError(f"abci_query RPC error: {envelope['error']}") + response = envelope.get("result", {}).get("response", {}) + code = response.get("code", 0) + if code != 0: + log = response.get("log") or f"code {code}" + raise CompletenessError( + f"no anchor for {subgrove_id} block {block_number}: {log}" + ) + value_b64 = response.get("value") + if not value_b64: + raise CompletenessError( + f"empty anchor value for {subgrove_id} block {block_number}" + ) + return commitment_from_anchor_value(base64.b64decode(value_b64)) + + async def fetch_matched_logs( + self, subgrove_id: str, block_number: int + ) -> list[Log]: + """GET the indexer's matched-log preimage and parse it into ``Log``s. + + Raises :class:`CompletenessError` on a non-200 (e.g. 404 "no retained + matched logs" / "block not finalized") or if no indexer is configured. + """ + if not self._indexer_url: + raise CompletenessError( + "no indexer_url configured; set it on WillowClient to fetch " + "matched logs" + ) + url = ( + f"{self._indexer_url}/completeness/{subgrove_id}/{block_number}/" + "matched-logs" + ) + resp = await self._http.get(url) + if resp.status_code != 200: + detail = "" + try: + detail = resp.text + except Exception: # pragma: no cover - defensive + pass + raise CompletenessError( + f"matched-logs unavailable for {subgrove_id} block {block_number}:" + f" HTTP {resp.status_code} {detail}".rstrip() + ) + return logs_from_matched_logs_response(resp.json()) + + async def verify_block_completeness( + self, subgrove_id: str, block_number: int + ) -> bool: + """Run the full client-side completeness check for one block. + + Fetches the on-chain anchor and the indexer's matched-log preimage, then + returns whether the served set re-hashes to the anchor. + + Args: + subgrove_id: the subgrove the block belongs to. + block_number: the block to check. + + Returns: + ``True`` if the indexer's matched set is exactly the complete, + untampered set the chain commits to. + + Raises: + CompletenessError: if the anchor or the preimage cannot be fetched + (the check is not verifiable, distinct from a ``False`` result + which means the served set was tampered with). + """ + commitment = await self.fetch_anchor(subgrove_id, block_number) + logs = await self.fetch_matched_logs(subgrove_id, block_number) + return verify_served_events(commitment, block_number, logs) diff --git a/tests/test_completeness.py b/tests/test_completeness.py index 71d6ed1..ce9738d 100644 --- a/tests/test_completeness.py +++ b/tests/test_completeness.py @@ -4,11 +4,18 @@ the canonical Rust ``canonical_event_set_hash`` byte-for-byte. """ +import base64 +import json + +import httpx import pytest from willow.completeness import ( + CompletenessError, + CompletenessOperations, Log, canonical_event_set_hash, + logs_from_matched_logs_response, verify_served_events, ) @@ -123,3 +130,169 @@ def test_tamper_reordered_logs(self): """Reordering the served set fails verification (order is committed).""" reordered = list(reversed(VECTOR_B_LOGS)) assert not verify_served_events(VECTOR_B_HASH, VECTOR_B_BLOCK, reordered) + + +# The authoritative indexer ``matched-logs`` response body for vector B. This is +# the exact JSON contract from willow PR #676 — the JSON->Log parse must reduce +# it to the canonical set that hashes to VECTOR_B_HASH. +MATCHED_LOGS_BODY = { + "subgrove_id": "sg", + "block_number": 7, + "count": 2, + "matched_logs": [ + { + "block_number": 7, + "block_hash": "0x" + "00" * 32, + "transaction_hash": "0x" + "00" * 32, + "transaction_index": 0, + "log_index": "0x0", + "address": "0x4242424242424242424242424242424242424242", + "topics": [ + "0xdddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd", + "0x1111111111111111111111111111111111111111111111111111111111111111", + ], + "data": "0x01020304", + "removed": False, + }, + { + "block_number": 7, + "block_hash": "0x" + "00" * 32, + "transaction_hash": "0x" + "00" * 32, + "transaction_index": 0, + "log_index": "0x1", + "address": "0x4343434343434343434343434343434343434343", + "topics": [ + "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + ], + "data": "0x", + "removed": False, + }, + ], +} + + +class TestParseMatchedLogsBody: + """Gate the JSON->Log parse against the authoritative indexer body.""" + + def test_parsed_body_verifies_against_anchor(self): + """Parsing the real response body yields logs that hash to the anchor. + + This is the cross-implementation gate: only ``address``/``topics``/ + ``data`` are part of the commitment, so dropping block_hash, tx_hash, + log_index, removed, etc. must still reproduce VECTOR_B_HASH exactly. + """ + logs = logs_from_matched_logs_response(MATCHED_LOGS_BODY) + assert verify_served_events(VECTOR_B_HASH, 7, logs) + + def test_parse_preserves_order_and_count(self): + logs = logs_from_matched_logs_response(MATCHED_LOGS_BODY) + assert len(logs) == 2 + assert logs[0].data == "0x01020304" + assert logs[1].data == "0x" + + +def _anchor_rpc_response(commitment_hex: str) -> dict: + """Build a CometBFT ``abci_query`` envelope carrying the anchor value. + + The chain JSON-encodes ``{subgrove_id, block_number, events_commitment}`` + into ResponseQuery.value; CometBFT base64s it into ``result.response.value``. + """ + value = json.dumps( + { + "subgrove_id": "sg", + "block_number": 7, + "events_commitment": commitment_hex, + } + ).encode() + return { + "jsonrpc": "2.0", + "id": 1, + "result": {"response": {"code": 0, "value": base64.b64encode(value).decode()}}, + } + + +class TestVerifyBlockCompletenessMocked: + """Full fetch-anchor + fetch-preimage + verify path over a mock transport.""" + + def _ops(self, handler) -> CompletenessOperations: + transport = httpx.MockTransport(handler) + http = httpx.AsyncClient(transport=transport) + return CompletenessOperations( + http, + "http://validator:26657", + "http://indexer:9090", + ) + + async def test_verify_block_completeness_true(self): + def handler(request: httpx.Request) -> httpx.Response: + if request.url.path.endswith("/matched-logs"): + assert request.url.path == "/completeness/sg/7/matched-logs" + return httpx.Response(200, json=MATCHED_LOGS_BODY) + # CometBFT abci_query JSON-RPC POST. + assert request.method == "POST" + return httpx.Response(200, json=_anchor_rpc_response(VECTOR_B_HASH[2:])) + + ops = self._ops(handler) + assert await ops.verify_block_completeness("sg", 7) is True + await ops._http.aclose() + + async def test_verify_block_completeness_tampered_false(self): + """A served set that doesn't match the anchor verifies False (not an error).""" + tampered = json.loads(json.dumps(MATCHED_LOGS_BODY)) + tampered["matched_logs"][0]["data"] = "0x01020305" + + def handler(request: httpx.Request) -> httpx.Response: + if request.url.path.endswith("/matched-logs"): + return httpx.Response(200, json=tampered) + return httpx.Response(200, json=_anchor_rpc_response(VECTOR_B_HASH[2:])) + + ops = self._ops(handler) + assert await ops.verify_block_completeness("sg", 7) is False + await ops._http.aclose() + + async def test_missing_anchor_raises(self): + """ABCI code != 0 surfaces as not-verifiable (CompletenessError).""" + + def handler(request: httpx.Request) -> httpx.Response: + return httpx.Response( + 200, + json={ + "jsonrpc": "2.0", + "id": 1, + "result": { + "response": { + "code": 1, + "log": "No events commitment for block 7", + } + }, + }, + ) + + ops = self._ops(handler) + with pytest.raises(CompletenessError): + await ops.verify_block_completeness("sg", 7) + await ops._http.aclose() + + async def test_missing_preimage_raises(self): + """A 404 from the indexer surfaces as not-verifiable (CompletenessError).""" + + def handler(request: httpx.Request) -> httpx.Response: + if request.url.path.endswith("/matched-logs"): + return httpx.Response(404, text="no retained matched logs") + return httpx.Response(200, json=_anchor_rpc_response(VECTOR_B_HASH[2:])) + + ops = self._ops(handler) + with pytest.raises(CompletenessError): + await ops.verify_block_completeness("sg", 7) + await ops._http.aclose() + + async def test_no_indexer_configured_raises(self): + def handler(request: httpx.Request) -> httpx.Response: + return httpx.Response(200, json=_anchor_rpc_response(VECTOR_B_HASH[2:])) + + transport = httpx.MockTransport(handler) + http = httpx.AsyncClient(transport=transport) + ops = CompletenessOperations(http, "http://validator:26657", None) + with pytest.raises(CompletenessError): + await ops.fetch_matched_logs("sg", 7) + await http.aclose()