"""On-chain check of an x402 seller on Base: USDC Transfer logs INTO a payTo wallet, via a public Base RPC (eth_getLogs). Usage: python onchain.py [chunk_blocks] Base makes one block every 2 s, so -> days*43200 blocks back from the head. Writes raw/onchain__d.json. v2 (cycle 68), after a reader's review (Nostr 4a391a11...): v1 counted EVERY inbound USDC Transfer as if it were a sale. That is "wallet inflows": funding, exchange withdrawals and non-x402 sales land in the same bucket. v2 keeps that count under its honest name and adds a second, tighter one: eip3009_* = inbound Transfers whose transaction also emits USDC's AuthorizationUsed(authorizer, nonce) with authorizer == the Transfer's sender. x402's "exact" scheme on EVM settles USDC with EIP-3009 transferWithAuthorization, so this is the settlement SHAPE. It is not proof of x402: any EIP-3009 transfer passes, and the resource URL is never on chain, so no tx-to-resource mapping is possible here. submitters = who sent a sample of those transactions (the relayer/facilitator that paid the gas). Gentle: one request at a time, 0.3 s apart. No key, no explorer. CC0. Vonk, cycles 52 and 68.""" import json, sys, time, urllib.request, datetime, collections, random import os RPC = os.environ.get("BASE_RPC", "https://base-rpc.publicnode.com") USDC = "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913" TRANSFER = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef" AUTH_USED = "0x98de503528ee59b575ef0c0a2576a82497bfc029a5685b209e9ec333479b10a5" # AuthorizationUsed(address,bytes32) SAMPLE_TX = 12 def rpc(method, params): body = json.dumps({"jsonrpc": "2.0", "id": 1, "method": method, "params": params}).encode() for attempt in range(6): try: req = urllib.request.Request(RPC, data=body, headers={"content-type": "application/json", "User-Agent": "vonk-x402-census/2"}) with urllib.request.urlopen(req, timeout=60) as r: d = json.load(r) if "error" in d: raise RuntimeError(d["error"]) return d["result"] except Exception as e: # noqa: BLE001 print("retry", str(e)[:160], file=sys.stderr) time.sleep(2 * (attempt + 1)) raise SystemExit("rpc failed") def bucket(v): return "<=0.01" if v <= 0.01 else "<=0.1" if v <= 0.1 else "<=1" if v <= 1 else "<=10" if v <= 10 else ">10" def main(): wallet, days = sys.argv[1].lower(), float(sys.argv[2]) chunk = int(sys.argv[3]) if len(sys.argv) > 3 else 10000 head = int(rpc("eth_blockNumber", []), 16) start = head - int(days * 43200) topic_to = "0x" + "0" * 24 + wallet[2:] allc = {"n": 0, "usdc": 0.0, "payers": collections.Counter(), "hist": collections.Counter()} e3 = {"n": 0, "usdc": 0.0, "payers": collections.Counter(), "hist": collections.Counter()} matched_tx = [] b, chunks = start, 0 while b <= head: e = min(b + chunk - 1, head) logs = rpc("eth_getLogs", [{"address": USDC, "fromBlock": hex(b), "toBlock": hex(e), "topics": [TRANSFER, None, topic_to]}]) auth = set() # (txhash, authorizer topic) pairs senders = sorted({lg["topics"][1] for lg in logs}) for i in range(0, len(senders), 50): # OR-filter on topic1, 50 addresses per call for al in rpc("eth_getLogs", [{"address": USDC, "fromBlock": hex(b), "toBlock": hex(e), "topics": [AUTH_USED, senders[i:i + 50]]}]): auth.add((al["transactionHash"], al["topics"][1])) time.sleep(0.3) for lg in logs: v = int(lg["data"], 16) / 1e6 frm = "0x" + lg["topics"][1][-40:] for c in (allc,) + ((e3,) if (lg["transactionHash"], lg["topics"][1]) in auth else ()): c["n"] += 1 c["usdc"] += v c["payers"][frm] += 1 c["hist"][bucket(v)] += 1 if (lg["transactionHash"], lg["topics"][1]) in auth: matched_tx.append(lg["transactionHash"]) b = e + 1 chunks += 1 if chunks % 20 == 0: print("chunk", chunks, "all", allc["n"], "eip3009", e3["n"], file=sys.stderr) time.sleep(0.3) random.seed(wallet) sample = random.sample(matched_tx, min(SAMPLE_TX, len(matched_tx))) submitters = collections.Counter() for h in sample: tx = rpc("eth_getTransactionByHash", [h]) submitters[tx["from"].lower()] += 1 time.sleep(0.3) def pack(c): return {"transfers_in": c["n"], "usdc_in": round(c["usdc"], 4), "distinct_payers": len(c["payers"]), "top_payer_share": round(c["payers"].most_common(1)[0][1] / c["n"], 4) if c["n"] else 0, "amount_hist": dict(c["hist"])} a, x = pack(allc), pack(e3) res = {"version": 2, "wallet": wallet, "days": days, "from_block": start, "to_block": head, "checked_utc": datetime.datetime.now(datetime.timezone.utc).isoformat(timespec="seconds"), "wallet_inflows": a, "eip3009": x, "eip3009_share_of_inflows": round(x["transfers_in"] / a["transfers_in"], 4) if a["transfers_in"] else 0, "submitter_sample": {"n": len(sample), "by_sender": dict(submitters), "tx": sample}, # v1 field names kept so older readers still work; they mean wallet inflows "transfers_in": a["transfers_in"], "usdc_in": a["usdc_in"], "distinct_payers": a["distinct_payers"]} json.dump(res, open("raw/onchain_%s_%gd.json" % (wallet[:10], days), "w"), indent=1) print(json.dumps({k: res[k] for k in ("wallet", "days", "wallet_inflows", "eip3009", "eip3009_share_of_inflows")})) print("submitters", dict(submitters)) if __name__ == "__main__": main()