""" Vrismcost — chain fetcher. Turns a list of addresses into the exact input the cost-basis engine wants: every Transfer leg that touches those addresses, grouped by transaction, with the block timestamp, the native ETH the transaction moved, and the gas it paid. Standard library only. No API key, no account, no third-party service — the public RPC answers everything, so nothing about a user's wallets is ever sent anywhere except the node itself. Four measured facts about Robinhood Chain shape every decision here: 1. The RPC sits behind Cloudflare and rejects default library user agents with 403. A browser-shaped User-Agent header is mandatory, not cosmetic. 2. eth_getLogs refuses any query matching more than 10,000 logs — as an explicit error, never a silent truncation. Ranges are bisected on it. 3. Topics cannot be OR-ed across positions, so "touches this address" is two sweeps: one where it is the sender, one where it is the recipient. 4. Log objects do NOT carry blockTimestamp, and native ETH emits no Transfer log at all. Dates and ETH amounts each cost an extra call. python fetcher.py 0xabc... # scan one address python fetcher.py 0xabc... 0xdef... # scan a wallet set """ from __future__ import annotations import json import sys import time import urllib.error import urllib.request from dataclasses import dataclass, field from typing import Any, Iterable RPC_URL = "https://rpc.mainnet.chain.robinhood.com" TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef" # Cloudflare fronts this RPC and 403s Python's default agent. This is the # smallest header set that gets through. HEADERS = { "content-type": "application/json", "accept": "application/json", "user-agent": ( "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 " "(KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36" ), } # Robinhood Chain was stamped at block 1 on 2026-04-30 but crawled at up to # 195s/block until it reached production cadence here. Scanning below this is # half a million blocks of nothing. LAUNCH_BLOCK = 505_859 BATCH_SIZE = 25 # measured: 25 calls in one round trip, ~900ms RETRY_MAX = 6 RETRY_BASE = 1.5 # seconds; doubles each attempt PACE_SECONDS = 0.35 # between heavy log queries WINDOW_START = 4_000_000 # optimistic first window once the full sweep fails WINDOW_MIN = 500 # at 18.9 logs/block chain-wide, 10k logs is ~530 blocks WINDOW_MAX = 16_000_000 LOG_CAP = 10_000 # the node's hard ceiling per eth_getLogs query MAX_BISECT_DEPTH = 40 class HttpStatus(Exception): """The node answered, with a status that is not success. retry_after is its Retry-After header in seconds, when the transport could read one.""" def __init__(self, code: int, retry_after: float | None = None): super().__init__(f"HTTP {code}") self.code = code self.retry_after = retry_after class TransportError(Exception): """The request never got an answer at all: DNS, reset, timeout.""" class RpcError(Exception): def __init__(self, message: str, code: int | None = None): super().__init__(message) self.code = code def to_topic(address: str) -> str: """An indexed address topic is the address left-padded to 32 bytes.""" return "0x" + "0" * 24 + address.lower().removeprefix("0x") def from_topic(topic: str) -> str: return "0x" + topic[-40:] @dataclass class Leg: token: str frm: str to: str raw: int # base units; decimals are applied by the engine log_index: int @dataclass class Tx: tx: str block: int timestamp: int | None sender: str | None to: str | None native_value: int # wei moved by the transaction itself gas_wei: int # gasUsed x effectiveGasPrice status: str | None raw_legs: int | None # every Transfer in the tx, not only ours legs: list[Leg] = field(default_factory=list) class Fetcher: def __init__(self, rpc: str = RPC_URL, timeout: int = 60, verbose: bool = False): self.rpc = rpc self.timeout = timeout self.verbose = verbose self.calls = 0 self.round_trips = 0 self.retries = 0 self.log_queries = 0 self.interpolated = 0 # ---------------------------------------------------------------- transport def _post(self, payload: Any) -> Any: """POST with backoff. Light calls are not throttled, but a real scan issues wide eth_getLogs queries and those earn HTTP 429 quickly. Retrying with a widening pause — and honouring Retry-After when the node sends one — is the difference between a scan that finishes and one that dies halfway through a user's history. """ data = json.dumps(payload).encode() delay = RETRY_BASE for attempt in range(RETRY_MAX): self.round_trips += 1 try: return json.loads(self._send(data)) except HttpStatus as e: if e.code in (429, 500, 502, 503, 504) and attempt < RETRY_MAX - 1: wait = max(delay, e.retry_after or 0) self.retries += 1 if self.verbose: print(f" HTTP {e.code}, waiting {wait:.1f}s " f"(attempt {attempt + 1}/{RETRY_MAX})", file=sys.stderr) self._sleep(wait) delay *= 2 continue raise RpcError(f"HTTP {e.code}", e.code) from None except TransportError as e: if attempt < RETRY_MAX - 1: self.retries += 1 self._sleep(delay) delay *= 2 continue raise RpcError(f"transport: {e}") from None raise RpcError("exhausted retries") def _send(self, data: bytes) -> bytes: """One POST and nothing else: no retries, no pacing, no parsing. This is the only method that knows which HTTP library is in use. It is kept apart from the rules above so the tests can put a node in its place -- one that answers 429, drops the connection, or names a Retry-After -- and exercise those rules exactly as written. """ req = urllib.request.Request(self.rpc, data=data, headers=HEADERS) try: with urllib.request.urlopen(req, timeout=self.timeout) as r: return r.read() except urllib.error.HTTPError as e: retry_after = None hdr = e.headers.get("Retry-After") if e.headers else None if hdr: try: retry_after = float(hdr) except ValueError: pass raise HttpStatus(e.code, retry_after) from None except (urllib.error.URLError, TimeoutError) as e: raise TransportError(type(e).__name__) from None def _sleep(self, seconds: float) -> None: time.sleep(seconds) def call(self, method: str, params: list) -> Any: return self.batch([(method, params)])[0] def batch(self, requests: list[tuple[str, list]]) -> list[Any]: """One round trip carries many calls; each error stays with its own.""" if not requests: return [] self.calls += len(requests) payload = [ {"jsonrpc": "2.0", "id": i, "method": m, "params": p} for i, (m, p) in enumerate(requests) ] rows = self._post(payload) if isinstance(rows, dict): rows = [rows] out: list[Any] = [None] * len(requests) for row in rows: i = row.get("id", 0) out[i] = RpcError(row["error"]["message"], row["error"].get("code")) \ if "error" in row else row.get("result") return out def batched(self, method: str, params_list: list[list], label: str = "") -> list[Any]: out: list[Any] = [] for i in range(0, len(params_list), BATCH_SIZE): chunk = params_list[i:i + BATCH_SIZE] out.extend(self.batch([(method, p) for p in chunk])) if self.verbose and label and sys.stderr.isatty(): done = min(i + len(chunk), len(params_list)) print(f" {label}: {done}/{len(params_list)}", end="\r", file=sys.stderr) if self.verbose and label: if sys.stderr.isatty(): print(" " * 44, end="\r", file=sys.stderr) else: print(f" {label}: {len(params_list)}", file=sys.stderr) return out # ---------------------------------------------------------------- reads def head(self) -> int: r = self.call("eth_blockNumber", []) if isinstance(r, RpcError): raise r return int(r, 16) def _try_logs(self, topics: list, start: int, end: int): """One eth_getLogs attempt. Returns the rows, or None if the node said the window was too wide. The node refuses in two different ways and both mean "narrow it": "logs matched by query exceeds limit of 10000" when the result set is too big, and "log query timed out" when the scan itself is too slow. Neither is a silent truncation, which is the only reason paging is safe at all — a node that quietly returned the first 10,000 rows would give a confident, incomplete tax report. """ r = self.paced_logs({"fromBlock": hex(start), "toBlock": hex(end), "topics": topics}) if isinstance(r, RpcError): msg = str(r).lower() if any(t in msg for t in ("exceeds limit", "limit exceeded", "too many", "response size", "timed out", "timeout", "too large", "range")): return None raise r if len(r) >= LOG_CAP: return None # belt and braces against a silent cap return r def paced_logs(self, flt: dict): """eth_getLogs, spaced from the query before it. Every log query in the tool comes through here, the price oracle's included. Log queries are what the public node rate-limits, and the oracle's are the heaviest the tool makes -- every WETH and USDG transfer in a window. Pacing only the address sweeps left those running back to back, and a full scan died of HTTP 429 at the pricing stage with all of its transfers already read. """ if self.log_queries: self._sleep(PACE_SECONDS) self.log_queries += 1 return self.call("eth_getLogs", [flt]) def get_logs(self, topics: list, start: int, end: int) -> list[dict]: """Sweep a block range, widening the window while it holds and shrinking it when the node pushes back. Recursive bisection was the obvious approach and the wrong one: every failure at the top of the tree costs a full query timeout before it learns anything. Walking forward with an adaptive window pays that price at most once. """ rows = self._try_logs(topics, start, end) if rows is not None: return rows # a light address needs one query out: list[dict] = [] pos = start window = WINDOW_START while pos <= end: stop = min(pos + window, end) got = self._try_logs(topics, pos, stop) if got is None: if window <= WINDOW_MIN: raise RpcError( f"blocks {pos:,}-{stop:,} hold more than {LOG_CAP:,} matching " "logs even at the minimum window; this address is too active " "to page with the public RPC alone") window = max(WINDOW_MIN, window // 4) continue out.extend(got) pos = stop + 1 if self.verbose and sys.stderr.isatty(): pct = min(100, (pos - start) * 100 // max(1, end - start)) print(f" {pct:>3}% {len(out):,} logs window {window:,}", end="\r", file=sys.stderr) window = min(WINDOW_MAX, int(window * 1.8)) if self.verbose: if sys.stderr.isatty(): print(" " * 60, end="\r", file=sys.stderr) else: print(f" paged {self.log_queries} queries, {len(out):,} logs", file=sys.stderr) return out def transfers_for(self, addresses: Iterable[str], start: int = LAUNCH_BLOCK, end: int | None = None) -> list[dict]: """Every Transfer leg touching these addresses. Two sweeps, because topic positions cannot be OR-ed: position 1 is the sender, position 2 is the recipient. A move between two of the user's own wallets appears in both, so results are de-duplicated. """ end = self.head() if end is None else end topics = [to_topic(a) for a in addresses] seen: dict[str, dict] = {} for position, label in ((1, "outgoing"), (2, "incoming")): flt: list = [TRANSFER_TOPIC, None, None] flt[position] = topics logs = self.get_logs(flt, start, end) if self.verbose: print(f" {label}: {len(logs):,} logs", file=sys.stderr) for log in logs: seen[f"{log['transactionHash']}:{log['logIndex']}"] = log return list(seen.values()) def block_times(self, block_nums: list[str], exact_limit: int = 400) -> dict[str, int]: """Block timestamps, exactly when that is cheap and by calibrated interpolation when it is not. Log objects do not carry blockTimestamp, so a date costs a call per block. For a normal wallet that is a few dozen calls and worth paying. For a very active one it dominates the whole scan, so instead we sample anchors across the range and interpolate between them — safe here only because the chain's cadence is both fast and regular. The anchors are real, and the seconds-per-block is measured rather than assumed. """ uniq = sorted(set(block_nums), key=lambda b: int(b, 16)) if len(uniq) <= exact_limit: rows = self.batched("eth_getBlockByNumber", [[b, False] for b in uniq], "timestamps") return {b: int(r["timestamp"], 16) for b, r in zip(uniq, rows) if isinstance(r, dict) and r.get("timestamp")} anchors_n = min(64, len(uniq)) step = max(1, len(uniq) // anchors_n) picks = uniq[::step] if uniq[-1] not in picks: picks.append(uniq[-1]) rows = self.batched("eth_getBlockByNumber", [[b, False] for b in picks], "timestamp anchors") anchors = sorted( (int(b, 16), int(r["timestamp"], 16)) for b, r in zip(picks, rows) if isinstance(r, dict) and r.get("timestamp")) if len(anchors) < 2: return {b: t for b, t in ((b, a[1]) for b in uniq for a in anchors[:1])} self.interpolated = len(uniq) - len(anchors) out: dict[str, int] = {} i = 0 for b in uniq: n = int(b, 16) while i < len(anchors) - 2 and anchors[i + 1][0] < n: i += 1 (b0, t0), (b1, t1) = anchors[i], anchors[i + 1] span = max(1, b1 - b0) out[b] = int(t0 + (t1 - t0) * (n - b0) / span) for b, t in ((hex(a[0]), a[1]) for a in anchors): out[b] = t # anchors keep their exact value return out def hydrate(self, logs: list[dict]) -> list[Tx]: """Attach what the logs do not carry: dates, native ETH, gas.""" tx_hashes = list(dict.fromkeys(l["transactionHash"] for l in logs)) block_nums = list(dict.fromkeys(l["blockNumber"] for l in logs)) time_of = self.block_times(block_nums) txs = self.batched("eth_getTransactionByHash", [[h] for h in tx_hashes], "transactions") receipts = self.batched("eth_getTransactionReceipt", [[h] for h in tx_hashes], "receipts") by_tx: dict[str, Tx] = {} for h, tx, rc in zip(tx_hashes, txs, receipts): tx = tx if isinstance(tx, dict) else None rc = rc if isinstance(rc, dict) else None gas = 0 if rc: gas = int(rc.get("gasUsed", "0x0"), 16) * \ int(rc.get("effectiveGasPrice", "0x0"), 16) raw_legs = None if rc and isinstance(rc.get("logs"), list): raw_legs = sum(1 for l in rc["logs"] if l.get("topics") and l["topics"][0].lower() == TRANSFER_TOPIC) by_tx[h] = Tx( tx=h, block=0, timestamp=None, # Who sent it decides whose gas it was, so the receipt's own # "from" stands in when the transaction lookup came back empty. sender=((tx or {}).get("from") or (rc or {}).get("from") or "").lower() or None, to=(tx.get("to") or "").lower() or None if tx else None, native_value=int(tx.get("value", "0x0"), 16) if tx else 0, gas_wei=gas, status=rc.get("status") if rc else None, raw_legs=raw_legs, ) for log in logs: entry = by_tx.get(log["transactionHash"]) if entry is None: continue entry.block = int(log["blockNumber"], 16) entry.timestamp = time_of.get(log["blockNumber"]) data = log.get("data") or "0x" entry.legs.append(Leg( token=log["address"].lower(), frm=from_topic(log["topics"][1]), to=from_topic(log["topics"][2]), raw=int(data, 16) if data != "0x" else 0, log_index=int(log["logIndex"], 16), )) out = list(by_tx.values()) for t in out: t.legs.sort(key=lambda l: l.log_index) out.sort(key=lambda t: (t.block, t.tx)) return out # ---------------------------------------------------------------- entry def scan(self, addresses: list[str], start: int = LAUNCH_BLOCK, end: int | None = None) -> dict: began = time.time() head = self.head() if end is None else end logs = self.transfers_for(addresses, start, head) transactions = self.hydrate(logs) return { "addresses": [a.lower() for a in addresses], "headBlock": head, "rawLogs": len(logs), "transactions": transactions, "rpcCalls": self.calls, "roundTrips": self.round_trips, "retries": self.retries, "logQueries": self.log_queries, "interpolatedTimestamps": self.interpolated, "elapsed": round(time.time() - began, 2), } def main(argv: list[str]) -> int: if len(argv) < 2: print(__doc__.strip().split("\n\n")[-1]) return 1 addresses = [a for a in argv[1:] if a.startswith("0x") and len(a) == 42] recent = None for a in argv[1:]: if a.startswith("--recent="): recent = int(a.split("=", 1)[1]) if not addresses: print("Give at least one 0x address (42 characters).", file=sys.stderr) return 1 f = Fetcher(verbose=True) print(f"scanning {len(addresses)} address(es) on Robinhood Chain\n", file=sys.stderr) head = f.head() begin = max(LAUNCH_BLOCK, head - recent) if recent else LAUNCH_BLOCK if recent: print(f"window: last {recent:,} blocks (from {begin:,})\n", file=sys.stderr) result = f.scan(addresses, start=begin, end=head) txs = result["transactions"] print(f"head block {result['headBlock']:,}") print(f"raw logs {result['rawLogs']:,}") print(f"transactions {len(txs):,}") print(f"rpc calls {result['rpcCalls']:,} in {result['roundTrips']:,} round trips") print(f"log queries {result['logQueries']:,} ({result['retries']} retried)") if result["interpolatedTimestamps"]: print(f"timestamps {result['interpolatedTimestamps']:,} interpolated " f"between measured anchors") print(f"elapsed {result['elapsed']}s\n") if txs: with_value = sum(1 for t in txs if t.native_value > 0) mine = {a.lower() for a in addresses} gas_total = sum(t.gas_wei for t in txs if (t.sender or "") in mine) multi = sum(1 for t in txs if (t.raw_legs or 0) > 2) print(f"moving native ETH {with_value}/{len(txs)}") print(f"more than 2 legs {multi}/{len(txs)}") print(f"gas you sent {gas_total / 1e18:.10f} ETH\n") print("first transactions:") for t in txs[:6]: when = time.strftime("%Y-%m-%d %H:%M", time.gmtime(t.timestamp)) if t.timestamp else "?" print(f" {t.tx[:14]}… blk {t.block:>11,} {when} " f"ours {len(t.legs)}/{t.raw_legs} legs " f"value {t.native_value / 1e18:.6f} ETH") return 0 if __name__ == "__main__": raise SystemExit(main(sys.argv)) # ---------------------------------------------------------------- token metadata # The chain speaks in contract addresses and base units. Cost basis is written # in symbols and decimal quantities. These four selectors bridge the two. SEL_SYMBOL = "0x95d89b41" SEL_DECIMALS = "0x313ce567" SEL_NAME = "0x06fdde03" def _decode_string(hexdata: str) -> str | None: """ERC-20 symbol() is usually a dynamic string, but older tokens return a fixed bytes32. Both shapes appear in the wild, so handle both.""" if not hexdata or hexdata == "0x": return None raw = bytes.fromhex(hexdata[2:]) if len(raw) >= 64: try: offset = int.from_bytes(raw[0:32], "big") length = int.from_bytes(raw[offset:offset + 32], "big") if 0 < length <= 128: text = raw[offset + 32: offset + 32 + length].decode("utf-8", "replace") if text.strip(): return text.strip() except (ValueError, IndexError): pass text = raw.rstrip(b"\x00").decode("utf-8", "replace").strip() return text or None def token_metadata(f: "Fetcher", tokens: list[str]) -> dict[str, dict]: """symbol / decimals / name for a set of contracts, in one batched pass. These are constants, so reading them at the chain tip is safe even though historical state is pruned. """ tokens = list(dict.fromkeys(t.lower() for t in tokens)) if not tokens: return {} calls = [] for t in tokens: calls += [ [{"to": t, "data": SEL_SYMBOL}, "latest"], [{"to": t, "data": SEL_DECIMALS}, "latest"], [{"to": t, "data": SEL_NAME}, "latest"], ] rows = f.batched("eth_call", calls, "token metadata") out: dict[str, dict] = {} for i, t in enumerate(tokens): sym, dec, name = rows[i * 3], rows[i * 3 + 1], rows[i * 3 + 2] decimals = None if isinstance(dec, str) and dec not in ("0x", ""): try: decimals = int(dec, 16) except ValueError: decimals = None if decimals is None or decimals > 36: decimals = 18 # the overwhelming default out[t] = { "address": t, "symbol": (_decode_string(sym) if isinstance(sym, str) else None) or t[:10], "name": (_decode_string(name) if isinstance(name, str) else None) or "", "decimals": decimals, "resolved": isinstance(sym, str) and sym not in ("0x", ""), } return out