Skip to content

Commit 6533dba

Browse files
Toby1009claude
andcommitted
Anchor expansion on the case, and measure what log scanning actually costs
Expanding a counterparty has a cheaper question available than "everything this address ever did", and the case already contains it: when a seed's transfers span 113.61M to 113.92M, its counterparties are interesting around *that*. `_known_range` reads the oldest block the case knows and starts there, less a margin --- the margin because what a counterparty did *before* the money arrived is frequently the point, and the LpdFi attacker's funding sat two thousand blocks ahead of the exploit. `_fetch_into` and `_fetch_page` take `start_block`; `expand` passes it. **And the measurement that says this is not enough.** The endpoint costs about a millisecond per block scanned, flat, whatever the chunk width --- 500 blocks in 1.0s, 5,000 in 5.2s, 20,000 in 20.7s. Widening buys fewer requests and no time at all, so the only lever is scanning fewer blocks, and anchoring saved 10%: the case's oldest block already sat near the window start. seed, 400,000 blocks, two directions 828s of server time, 207s at 4 concurrent anchored expansion, 358,466 blocks 742s, 186s three addresses 9.3 minutes That is the honest cost of reconstructing transfers from `eth_getLogs` on a free public node, and it is not a bug to be tuned away. A log scan is the fallback that makes an unindexed chain readable at all; it is not a substitute for an indexer, and multi-address expansion is where the difference stops being academic. Recorded here so the next person does not spend an afternoon rediscovering it. `_known_range` degrades to None on a store that cannot answer, because it is an optimisation and must not break a fetch for the sake of making it cheaper. Two test doubles caught that and the argument being threaded through. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FXWQ25VgFeMuuMhGWjvdof
1 parent 2a3fec8 commit 6533dba

2 files changed

Lines changed: 77 additions & 8 deletions

File tree

src/chainscope/server/local.py

Lines changed: 75 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -499,7 +499,12 @@ def _fetch_one(self, key: str, chain: ChainId) -> tuple[int, bool, str | None]:
499499
"""
500500
store = self._store(create=True)
501501
try:
502-
fetched, complete = _fetch_into(store, key, chain)
502+
# Anchored on what the case already knows, when it knows anything.
503+
# A counterparty is interesting around the money that reached it,
504+
# not for four hundred thousand blocks back from the chain head.
505+
fetched, complete = _fetch_into(
506+
store, key, chain, start_block=_known_range(store, chain) or 0
507+
)
503508
return fetched, complete, None
504509
except (ProviderError, OSError) as exc:
505510
return 0, False, f"{type(exc).__name__}: {exc}"
@@ -1202,7 +1207,12 @@ def _sift(
12021207

12031208

12041209
def _fetch_page(
1205-
provider: Any, chain: ChainId, address: str, page: int
1210+
provider: Any,
1211+
chain: ChainId,
1212+
address: str,
1213+
page: int,
1214+
*,
1215+
start_block: int | str = 0,
12061216
) -> tuple[list[Any], bool, bool]:
12071217
"""One page. Returns ``(rows, ended)`` and never raises for an empty listing.
12081218
@@ -1248,7 +1258,14 @@ def note(outcome: Any, rows: int, detail: str = "") -> None:
12481258

12491259
try:
12501260
rows = list(
1251-
provider.asset_transfers(chain, address, direction="all", limit=1000, page=page)
1261+
provider.asset_transfers(
1262+
chain,
1263+
address,
1264+
direction="all",
1265+
limit=1000,
1266+
page=page,
1267+
start_block=start_block,
1268+
)
12521269
)
12531270
note("ok" if rows else "empty", len(rows))
12541271
return rows, False, False
@@ -1281,8 +1298,55 @@ def note(outcome: Any, rows: int, detail: str = "") -> None:
12811298
raise
12821299

12831300

1301+
def _known_range(store: SqliteStore, chain: ChainId, margin: int = 50_000) -> int | None:
1302+
"""The oldest block the case already knows about, less a margin.
1303+
1304+
Expansion has a cheaper question available than "everything this address
1305+
ever did", and the case already contains it. When a seed's transfers span
1306+
blocks 113.61M to 113.92M, its counterparties are interesting *around
1307+
that*, and scanning from there rather than from a fixed window back from
1308+
the chain head is the difference between reading 100,000 blocks and
1309+
400,000.
1310+
1311+
Measured on the endpoint this was failing against: response time is about
1312+
a millisecond per block scanned, flat, whatever the chunk width --- 500
1313+
blocks in 1.0s, 5,000 in 5.2s, 20,000 in 20.7s. Widening buys fewer
1314+
requests and no time at all, so the only lever that matters is scanning
1315+
fewer blocks. A 400,000-block window over two directions is roughly 800
1316+
seconds of somebody else's server, per address, which does not survive
1317+
expanding three of them.
1318+
1319+
The margin exists because the interesting thing about a counterparty is
1320+
frequently what it did *before* the money arrived --- the funding of the
1321+
LpdFi attacker sat two thousand blocks ahead of the exploit, and a range
1322+
starting exactly at the earliest known transfer would have missed it.
1323+
1324+
Returns None when the case is empty, which means the caller has nothing
1325+
better to go on and should use its own default.
1326+
"""
1327+
# Guarded, because this is an optimisation and not a requirement. A store
1328+
# that cannot answer means "no better idea than the default", which is
1329+
# exactly what None already communicates --- failing here would break a
1330+
# fetch for the sake of making it cheaper.
1331+
reader = getattr(store, "transfers", None)
1332+
if reader is None:
1333+
return None
1334+
oldest: int | None = None
1335+
for row in reader(Query(chain=chain, limit=20_000)):
1336+
block = getattr(row, "block", None)
1337+
if block and (oldest is None or block < oldest):
1338+
oldest = block
1339+
return max(0, oldest - margin) if oldest else None
1340+
1341+
12841342
def _fetch_into(
1285-
store: SqliteStore, address: str, chain: ChainId, *, max_pages: int = 15, width: int = 4
1343+
store: SqliteStore,
1344+
address: str,
1345+
chain: ChainId,
1346+
*,
1347+
max_pages: int = 15,
1348+
width: int = 4,
1349+
start_block: int | str = 0,
12861350
) -> tuple[int, bool]:
12871351
"""Pull an address's transfers into the store, paging until they run out.
12881352
@@ -1341,7 +1405,9 @@ def absorb(batch: list[Any]) -> int:
13411405
refused: Exception | None = None
13421406
for candidate in providers:
13431407
try:
1344-
first, ended, short = _fetch_page(candidate, chain, address, 1)
1408+
first, ended, short = _fetch_page(
1409+
candidate, chain, address, 1, start_block=start_block
1410+
)
13451411
provider = candidate
13461412
break
13471413
except ProviderError as exc:
@@ -1368,7 +1434,10 @@ def absorb(batch: list[Any]) -> int:
13681434
window = list(range(page, min(page + span, max_pages + 1)))
13691435
with ThreadPoolExecutor(max_workers=len(window)) as pool:
13701436
jobs = {
1371-
n: pool.submit(_fetch_page, provider, chain, address, n) for n in window
1437+
n: pool.submit(
1438+
_fetch_page, provider, chain, address, n, start_block=start_block
1439+
)
1440+
for n in window
13721441
}
13731442
# Absorbed in page order regardless of completion order, so the
13741443
# rows land in the same sequence a serial read would produce

tests/unit/test_paging_widens_only_after_it_must.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ def __init__(self, pages: list[list[object]], *, delay: float = 0.0):
3030
self.asked: list[int] = []
3131
self._lock = threading.Lock()
3232

33-
def asset_transfers(self, chain, address, *, direction, limit, page):
33+
def asset_transfers(self, chain, address, *, direction, limit, page, **_):
3434
with self._lock:
3535
self.asked.append(page)
3636
if self._delay:
@@ -115,7 +115,7 @@ def test_rows_land_in_page_order_whatever_the_network_did(monkeypatch) -> None:
115115
"""Completion order must not decide the stored sequence."""
116116

117117
class Jittery(FakeProvider):
118-
def asset_transfers(self, chain, address, *, direction, limit, page):
118+
def asset_transfers(self, chain, address, *, direction, limit, page, **_):
119119
# Later pages return sooner, so completion order is reversed.
120120
time.sleep(max(0.0, 0.3 - 0.1 * page))
121121
return FakeProvider.asset_transfers(

0 commit comments

Comments
 (0)