Skip to content

Commit 56ffbe7

Browse files
Toby1009claude
andcommitted
Normalise logs across providers, and say what the server said
Found by solving a real challenge end to end rather than by reading the code: reconstruct a fund-flow trail over a 200,000-block window under rules that specify successful-transaction, EOA-at-block, valid-ERC-20-log and exact-integer arithmetic. Three defects, each of which produced a confident wrong answer: A 400 from the RPC endpoint surfaced as "Client error '400 Bad Request' for url ..." and nothing else. The reason was in the response body, which the transport discarded. With it included the answer was immediate: Alchemy's free tier caps `eth_getLogs` at a ten-block range. That is a five-second fix hidden behind an afternoon of guessing, so the body now travels with the error. Blockscout's Etherscan-compatible getLogs pads topics to four with nulls and omits `blockHash`. A caller applying the ERC-20 rule "exactly three topics" rejected every record --- fifteen real transfers became zero, and an empty set does not look like a mistake. Normalised at the provider boundary, because two providers exist here so one can catch the other being wrong, and that fails if a caller written against one shape silently gets a different answer from the other. Etherscan's `tokentx` is not a substitute for raw logs. Deriving the token sums from it gave IN=277,433,022,846 against OUT=301,665,542,644; from logs both are 301,665,542,644, which is the eligibility condition the task defines. A silently short answer to a set-valued query, which is the exact failure `Router.corroborate` was built for. The challenge is now solvable with this package alone, and the arithmetic balances to the raw unit. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FXWQ25VgFeMuuMhGWjvdof
1 parent 5ac857c commit 56ffbe7

3 files changed

Lines changed: 110 additions & 2 deletions

File tree

src/chainscope/providers/blockscout.py

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,33 @@ def is_cacheable(body: Any) -> bool:
8585
return any(n in str(body.get("message", "")).lower() for n in _NO_DATA)
8686

8787

88+
def _normalise_log(row: dict[str, Any]) -> dict[str, Any]:
89+
"""One log, in the shape `eth_getLogs` returns.
90+
91+
Blockscout's Etherscan-compatible endpoint differs from the JSON-RPC one in
92+
two ways that are silent rather than loud, which is what makes them worth
93+
fixing here rather than in every caller:
94+
95+
**Topics are padded to four with nulls.** A caller applying the ERC-20 rule
96+
"exactly three topics" gets four and rejects every record --- fifteen real
97+
transfers became zero, and an empty set does not look like a mistake.
98+
99+
**There is no `blockHash`.** Deduplicating by `(blockHash, transactionHash,
100+
logIndex)`, which is the standard identity for a log, silently keys on
101+
`None` and folds unrelated records together.
102+
103+
Normalising at the provider boundary is the point: a caller written against
104+
one provider's shape must not quietly return a different answer from
105+
another, because the whole reason a second provider exists here is to catch
106+
the first one being wrong.
107+
"""
108+
out = dict(row)
109+
topics = [t for t in (row.get("topics") or []) if t is not None]
110+
out["topics"] = topics
111+
out.setdefault("blockHash", None)
112+
return out
113+
114+
88115
class BlockscoutProvider(ReadOnlyProvider):
89116
"""Explorer-backed history from a public Blockscout instance."""
90117

@@ -454,4 +481,4 @@ def get_logs(
454481
f"There is very likely more. Narrow fromBlock/toBlock; any set "
455482
f"derived from this is a subset and not a complete enumeration."
456483
)
457-
return [r for r in rows if isinstance(r, dict)]
484+
return [_normalise_log(r) for r in rows if isinstance(r, dict)]

src/chainscope/transport/http.py

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -448,7 +448,25 @@ def _send(
448448
self.breaker.record_failure(host)
449449
time.sleep(0.5 * (attempt + 1))
450450

451-
raise TransportError(f"{host}: failed after {self.max_retries} attempts: {last}")
451+
# The body, when there was one.
452+
#
453+
# httpx's message for a 4xx is "Client error '400 Bad Request' for url
454+
# ...", which says a request was refused and nothing about why. The
455+
# reason is almost always in the body --- "query returned more than
456+
# 10000 results", "invalid params: trailing null in topics" --- and
457+
# discarding it turns a five-second fix into an afternoon of guessing.
458+
# Truncated, because an HTML error page is not worth a screen of noise.
459+
detail = ""
460+
if isinstance(last, httpx.HTTPStatusError):
461+
try:
462+
body = last.response.text.strip()
463+
except Exception:
464+
body = ""
465+
if body:
466+
detail = f"\n the server said: {body[:400]}"
467+
raise TransportError(
468+
f"{host}: failed after {self.max_retries} attempts: {last}{detail}"
469+
)
452470

453471
def close(self) -> None:
454472
if self._client is not None:
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
"""Every provider's logs must arrive in the same shape.
2+
3+
Two providers exist here so one can catch the other being wrong. That only
4+
works if a caller written against one does not silently return a different
5+
answer from the other — and Blockscout's Etherscan-compatible endpoint differs
6+
from JSON-RPC in two ways that are silent rather than loud:
7+
8+
* topics are padded to four with nulls, so the ERC-20 rule "exactly three
9+
topics" rejects every record;
10+
* there is no `blockHash`, so deduplicating by the standard log identity
11+
keys on None and folds unrelated records together.
12+
13+
Measured on a real case: fifteen valid transfers became zero. An empty set does
14+
not look like a mistake, which is exactly why this is normalised at the
15+
provider boundary rather than in each caller.
16+
"""
17+
18+
from __future__ import annotations
19+
20+
from chainscope.providers.blockscout import _normalise_log
21+
22+
TRANSFER = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"
23+
FROM = "0x0000000000000000000000001c6e28d3f5175e9093de62a188d87c5ba8148b4d"
24+
TO = "0x000000000000000000000000f600c14e09c8997851b732d079d3b8e7b357980b"
25+
26+
27+
def test_trailing_null_topics_are_dropped() -> None:
28+
"""`len(topics) == 3` is the ERC-20 test; four with a null fails it."""
29+
row = _normalise_log({"topics": [TRANSFER, FROM, TO, None], "data": "0x" + "0" * 64})
30+
assert row["topics"] == [TRANSFER, FROM, TO]
31+
assert len(row["topics"]) == 3
32+
33+
34+
def test_a_genuine_fourth_topic_survives() -> None:
35+
"""ERC-721 has four real topics and must stay distinguishable from ERC-20."""
36+
row = _normalise_log({"topics": [TRANSFER, FROM, TO, "0x" + "1" * 64]})
37+
assert len(row["topics"]) == 4
38+
39+
40+
def test_block_hash_is_always_present_as_a_key() -> None:
41+
"""So a dedup key cannot silently read a missing field on one provider."""
42+
assert "blockHash" in _normalise_log({"topics": []})
43+
44+
45+
def test_a_supplied_block_hash_is_kept() -> None:
46+
row = _normalise_log({"topics": [], "blockHash": "0xabc"})
47+
assert row["blockHash"] == "0xabc"
48+
49+
50+
def test_nothing_else_is_touched() -> None:
51+
"""Normalising must not become editing."""
52+
original = {
53+
"address": "0xdac17f958d2ee523a2206206994597c13d831ec7",
54+
"data": "0x" + "0" * 63 + "1",
55+
"logIndex": "0xde",
56+
"transactionHash": "0x6e98",
57+
"topics": [TRANSFER],
58+
}
59+
row = _normalise_log(original)
60+
for field in ("address", "data", "logIndex", "transactionHash"):
61+
assert row[field] == original[field]
62+
# And the input is not mutated: callers may still hold it.
63+
assert original["topics"] == [TRANSFER]

0 commit comments

Comments
 (0)