
Get ERC-20 Token Transfers for an Address Across EVM Chains
Get one address's ERC-20 transfers on Ethereum, Base, Arbitrum and Polygon with plain eth_getLogs, exact decimals via eth_call, and a CSV per chain in Python.
A wallet backend, an exchange deposit monitor and a crypto tax export all ask the same question: which tokens moved in and out of this address, on every chain we support? JSON-RPC has no method for that. There is no eth_getTransfersByAddress.
There is an index you can use. The ERC-20 Transfer event indexes both the sender and the recipient, so every node can filter on them. With the address in topics[1] you get what it sent. With the address in topics[2] you get what it received. We will build a Python script that runs both queries on Ethereum, Base, Arbitrum and Polygon, decodes each amount with the token's own decimals, merges both directions in block order, and writes one CSV per chain.
The four chains do not behave the same under that script. We measured each Dwellir endpoint on 13 November 2026. The result caps, finality gaps and trace support all differ, and the request count depends on block time, not on how active the address is.
What You Will Learn
- Filter
Transferlogs on the sender or recipient topic, and drop ERC-721 logs that share the same event signature - Merge sent and received transfers into one ordered list without counting self-transfers twice
- Resolve token decimals and symbols with
eth_call, including old tokens that returnbytes32 - Mark rows above each chain's finalized block, and detect and repair a reorg, including one that adds a transfer
- Find native and internal transfers with
trace_filterordebug_traceBlockByNumber, and on Polygon with plain logs
Prerequisites
- Python 3.10 or newer. Run
python3 --versionto confirm. - A Dwellir API key on a Developer plan or above. The Free plan does not include
eth_getLogs. One key works on Ethereum, Base, Arbitrum and Polygon. - The example code. The full project is in dwellir-examples:
git clone https://github.com/dwellir-public/dwellir-examples
cd dwellir-examples/examples/multichain-erc20-transfers
python3 -m venv .venv && source .venv/bin/activate
pip install -r requirements.txt
export DWELLIR_API_KEY=your-api-key- Familiarity with event logs:
topics[0]is the event signature hash, and indexed arguments follow it.
What the four endpoints allow
We measured these on 13 November 2026 between 08:05 and 08:53 Stockholm time (CET), one request at a time, on a key whose plan allows 10,000 blocks per eth_getLogs call.
| Ethereum | Base | Arbitrum | Polygon | |
|---|---|---|---|---|
eth_getLogs block range | 10,000 | 10,000 | 10,000 | 10,000 |
eth_getLogs result cap | 20,000 logs | 10,000 logs | none at 84,116 logs (53 MB) | none at 130,333 logs (83 MB) |
| Blocks per day | 7,193 | 432,001 | 306,383 | 57,601 |
finalized behind head | 91 blocks | 5,854 to 12,111 blocks | 4,760 blocks | 1 to 2 blocks |
blockTimestamp on each log | yes | yes | yes | yes |
trace_filter | 100 blocks per call, about 10 s each | 100 blocks per call, about 1 s each | not available | not available |
debug_traceBlockByNumber | yes | yes | yes | yes |
Blocks per day is the script's own estimate from the last 1,000 blocks. Base produced a block about every 0.2 seconds.
Three things in this table shape the script.
The block range is plan policy. Every endpoint rejected 10,001 blocks with the same message: eth_getLogs range of 10001 blocks exceeds the 10000-block limit for this plan. A Developer key gets 500 blocks. The rate limits page lists each plan. The script starts at 10,000 and reads the real cap from the first rejection.
The result cap differs by chain. Ethereum rejected more than 20,000 logs and Base more than 10,000, each with a block range the node would answer. Arbitrum and Polygon returned 84,116 and 130,333 logs in one response. Polygon's limit is time instead: 2,000 blocks of all Transfer logs came back as -32002 request timed out after 30 seconds. Our 7-day run for one address never came near them. Busy traffic does: on Base, all Transfer logs filled the 10,000-log cap in 441 blocks.
For one address, the range cap is what you pay for. An address filter returns few logs, so each window covers the full 10,000 blocks and most windows return nothing. Base produces 60 times as many blocks per day as Ethereum, and Arbitrum 43 times, so they need that many more windows.
Step 1: Filter Transfer logs by address
Transfer(address indexed from, address indexed to, uint256 value) has the signature hash 0xddf252ad.... Its two indexed arguments land in topics[1] and topics[2], each left-padded to 32 bytes. Add these helpers to transfers.py:
TRANSFER = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"
def topic_for(address):
return "0x" + address[2:].lower().rjust(64, "0")
def address_from(topic):
return "0x" + topic[-40:]The two filters differ only in where the address goes. None in a topic position matches anything:
sent = [TRANSFER, topic_for(address)] # from = address, any recipient
received = [TRANSFER, None, topic_for(address)] # any sender, to = addressWe leave out the filter's address field, which selects the emitting contract. Without it, the query matches every token contract on the chain.
Important: ERC-721 uses the same Transfer signature, so this filter also returns NFT transfers. ERC-721 indexes the token ID as a fourth topic and leaves data empty. Keep only logs with exactly three topics, or you will decode a token ID as an amount.
Step 2: Scan both directions into one list
fetch_transfers runs both filters over the same block range and merges the results:
from windows import Window, scan_logs
START_WINDOW = int(os.environ.get("START_WINDOW", "10000"))
def fetch_transfers(chain, address, from_block, to_block):
"""Both directions, merged on (tx_hash, log_index) so a self-transfer appears once."""
me = topic_for(address)
window = Window(START_WINDOW)
merged = {}
for direction, topics in (("out", [TRANSFER, me]), ("in", [TRANSFER, None, me])):
for logs in scan_logs(chain, from_block, to_block, topics, window):
for log in logs:
if len(log["topics"]) != 3:
continue # ERC-721 uses the same event with a 4th indexed topic
key = (log["transactionHash"], int(log["logIndex"], 16))
if key in merged:
merged[key]["direction"] = "self"
continue
merged[key] = {"log": log, "direction": direction}
return [merged[key] for key in sorted(merged, key=lambda k: (
int(merged[k]["log"]["blockNumber"], 16), k[1]))](transactionHash, logIndex) identifies one log. A transfer from the address to itself matches both filters, so the second sighting relabels it self instead of adding a row. Sorting on block number, then log index, gives the order the transfers executed in.
scan_logs and Window come from windows.py. They are a condensed copy of the self-sizing scanner in the eth_getLogs limits guide, which explains each rule: read the plan cap out of a range rejection, use the block range the node suggests in a result rejection, and size the next window from the last result. We made two changes for these four chains. The scanner treats a timeout like a result rejection and halves the window, because that is how Polygon fails. It also aims at 5,000 logs per window, half of Base's cap. One Window serves both directions, so a cap learned on the first scan is not learned again on the second.
Every log on all four endpoints carried a blockTimestamp field, so the script reads the time from the log. It falls back to eth_getBlockByNumber when a node omits the field.
Step 3: Decimals and symbols with eth_call
A Transfer log holds a raw integer. 1000000 is 1 USDC and 0.000000000001 WETH. To show amounts, we call the token's decimals() and symbol() functions. Create tokens.py:
"""Token decimals and symbol with eth_call, cached per chain and token.
Reads at `latest`, so every row shows the token's current decimals and symbol.
"""
import time
from rpc import RpcError
from windows import TRANSIENT
DECIMALS = "0x313ce567" # decimals()
SYMBOL = "0x95d89b41" # symbol()
def _eth_call(chain, token, selector, retries=3):
for attempt in range(retries + 1):
try:
return chain.call("eth_call", [{"to": token, "data": selector}, "latest"])
except RpcError as error:
if error.code is not None and not TRANSIENT.search(error.message):
return None # reverted, or no such function
if attempt == retries:
raise # a timeout or rate limit says nothing about the token: do not cache it
time.sleep(2 ** (attempt + 1))
def decode_symbol(result):
raw = bytes.fromhex((result or "0x")[2:])
if len(raw) == 32:
# Old tokens such as MKR return bytes32, not a string.
text = raw.rstrip(b"\0")
elif len(raw) >= 64:
offset = int.from_bytes(raw[:32], "big")
length = int.from_bytes(raw[offset : offset + 32], "big") if offset + 32 <= len(raw) else 0
text = raw[offset + 32 : offset + 32 + length]
else:
return ""
clean = "".join(c for c in text.decode("utf-8", "replace") if c.isprintable())
return clean[:32]
class TokenCache:
def __init__(self, chain):
self.chain = chain
self._cache = {}
def get(self, token):
if token not in self._cache:
decimals = None
result = _eth_call(self.chain, token, DECIMALS)
if result and len(result) >= 66:
value = int(result[:66], 16)
decimals = value if value <= 36 else None
symbol = decode_symbol(_eth_call(self.chain, token, SYMBOL))
self._cache[token] = (symbol, decimals)
return self._cache[token]The two selectors are the first 4 bytes of keccak256("decimals()") and keccak256("symbol()"). decimals and symbol are optional in ERC-20, so a call can revert or return nothing. In that case the row keeps its raw amount and leaves amount empty. The script never guesses 18.
A timeout or rate limit is different from a revert: it says nothing about the token. _eth_call retries those three times and then stops the run, so a busy endpoint cannot leave a valid token cached without decimals.
The calls read latest, so every row shows the token's current decimals and symbol, not the values at the transfer's block. If a token contract was upgraded to new decimals or destroyed, its older amounts come out wrong or blank. Reading at each log's block would fix that, but it costs two calls per token and block instead of two per token, and it needs archive state. On 13 November 2026 at 09:41, the Polygon endpoint answered a decimals() call 7 days back with historical state ... is not available. Ethereum, Base and Arbitrum answered it.
decode_symbol handles both return shapes. Most tokens return an ABI-encoded string. Early tokens such as MKR return a fixed bytes32. Anyone can deploy a token with any symbol, so the function also strips unprintable characters and cuts the result to 32 characters. A printable symbol can still be a spreadsheet formula, such as =HYPERLINK(...). Step 4 escapes those.
Check both shapes against Ethereum MKR and Polygon USDC:
python3 -c "
from rpc import Chain
from tokens import TokenCache
print(TokenCache(Chain('ethereum')).get('0x9f8F72aA9304c8B593d555F12eF6589cC3A579A2'))
print(TokenCache(Chain('polygon')).get('0x3c499c542cEF5E3811e1192ce70d8cC03d5c3359'))
"('MKR', 18)
('USDC', 6)The cache matters on a spam-heavy address. In our 7-day run below, 34 transfers involved 26 distinct tokens, so the script made 52 eth_call requests for metadata instead of 68.
Step 4: Exact amounts and CSV rows
Do not convert amounts with float. A float holds about 16 significant digits, and Python's default Decimal context rounds at 28. One spam transfer in our run had a 27-digit raw amount. Integer division keeps every digit:
def scale(raw, decimals):
"""Exact decimal string. float and the default Decimal context both round large amounts."""
if decimals == 0:
return str(raw)
whole, fraction = divmod(raw, 10**decimals)
return f"{whole}.{fraction:0{decimals}d}"to_row builds one CSV row per log. finalized marks rows that can still change, and block_hash records the block the log came from:
def to_row(chain, item, tokens, finalized, time_cache):
log, direction = item["log"], item["direction"]
sender, recipient = address_from(log["topics"][1]), address_from(log["topics"][2])
symbol, decimals = tokens.get(log["address"])
raw = int(log["data"], 16) if log["data"] not in ("0x", "") else 0
number = int(log["blockNumber"], 16)
return {
"chain": chain.name,
"block_number": number,
"block_time_utc": block_time(chain, log, time_cache),
"tx_hash": log["transactionHash"],
"log_index": int(log["logIndex"], 16),
"direction": direction,
"counterparty": sender if direction == "in" else recipient,
"token": log["address"],
"symbol": symbol,
"decimals": "" if decimals is None else decimals,
"amount": "" if decimals is None else scale(raw, decimals),
"amount_raw": raw,
"finalized": number <= finalized,
"block_hash": log["blockHash"],
}Before a row reaches the file, cell escapes text that starts with =, +, - or @. Excel and Google Sheets run such a cell as a formula, and anyone can deploy a token with a symbol like that and send it to your address. A leading single quote makes the spreadsheet show the text instead:
def cell(value):
"""Spreadsheets run a cell that starts with = + - or @ as a formula. Anyone can name a token that way."""
if isinstance(value, str) and value.startswith(("=", "+", "-", "@")):
return "'" + value
return valuerun_chain in the repository ties the steps together for one chain. It reads the head block and the finalized block with eth_getBlockByNumber, and estimates the start block from --days and the average block time of the last 1,000 blocks. scan_to_csv then scans, writes out/<chain>.csv, and records the scan in out/<chain>.scan.json for the reorg check.
Putting it all together
Run the script for one address over 7 days on all four chains. We used vitalik.eth, a public address that strangers send tokens to on several chains:
python transfers.py 0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045 --days 7ethereum blocks 26354633-26404932 (50,300), 19 transfers (0 not final), 14 tokens, 46 requests, 37.6s -> out/ethereum.csv
base blocks 53821927-56845927 (3,024,001), 13 transfers (0 not final), 10 tokens, 629 requests, 331.1s -> out/base.csv
arbitrum blocks 521702752-523817437 (2,114,686), 0 transfers (0 not final), 0 tokens, 427 requests, 219.6s -> out/arbitrum.csv
polygon blocks 96833237-97236437 (403,201), 2 transfers (0 not final), 2 tokens, 89 requests, 46.3s -> out/polygon.csvThe run started at 09:45 Stockholm time on 13 November 2026. It took 10 minutes 35 seconds and 1,191 requests at the default pace of 2 requests per second. The Polygon file:
cut -d, -f2,3,6,9,11,13 out/polygon.csvblock_number,block_time_utc,direction,symbol,amount,finalized
97013222,2026-11-09 11:54:49,in,USDT0,5.098092,True
97224835,2026-11-13 04:05:09,in,USDC,0.100000,TrueTwo things in that output matter more than the transfer list.
Requests follow blocks, not transfers. Arbitrum returned no transfers and still took 427 requests, because 2,114,686 blocks at 10,000 per window is 212 windows in each direction. Ethereum covered the same 7 days with 46 requests. The cost of a backfill is set by the chain's block time and your plan's range cap, before the address has any say.
Anyone can send you a token. Anyone can mint a token and emit a Transfer to any address. On Ethereum, the run picked up tokens with symbols such as CHADTALIK and SEPPUKU, and on Base one called FakeDontBuy. Two Ethereum rows show the address sending a token called VITALIK. Neither transaction was signed by the address: eth_getTransactionByHash returned a different from for both. A token contract can emit a Transfer from any address, so a log proves only that the contract said so.
Detect a reorg on recent blocks
A log from a block that is later replaced describes a transfer that did not happen. The replacement block can also hold a transfer that the old block did not. Nothing in the eth_getLogs response warns you about either case.
The gap between head and finalized differs by chain, and it moves. Minutes are calculated from the measured block counts and block times:
| Chain | finalized behind head | About |
|---|---|---|
| Ethereum | 91 blocks | 18 minutes |
| Base | 5,854 blocks at 08:05, 12,111 at 08:52 | 20 to 40 minutes |
| Arbitrum | 4,760 blocks | 22 minutes |
| Polygon | 1 to 2 blocks | 3 seconds |
Base and Arbitrum report finalized once their batches are final on Ethereum, which is why they trail by tens of minutes even with sub-second blocks. A row written inside that window can still change.
scan_to_csv records each scan next to its CSV: the first block, the head block's number and hash, and the finalized block at scan time. A block hash commits to its parent's hash. If the head hash is unchanged, every block below it is unchanged too, including the blocks where the scan found nothing. A check that re-reads only the blocks that hold rows would miss a replacement block that adds a transfer.
def scan_to_csv(chain, address, first, head_block, finalized, path, kept=()):
"""Scan [first, head], write the CSV after the `kept` rows, and record the scan for reorg_check.py."""
head = int(head_block["number"], 16)
items = fetch_transfers(chain, address, first, head)
tokens, time_cache = TokenCache(chain), {}
rows = list(kept) + [to_row(chain, item, tokens, finalized, time_cache) for item in items]
with path.open("w", newline="") as fh:
writer = csv.DictWriter(fh, fieldnames=FIELDS)
writer.writeheader()
writer.writerows({key: cell(value) for key, value in row.items()} for row in rows)
# A block hash commits to its parent, so the head hash vouches for every block below it,
# including blocks that held no transfer.
record = {"chain": chain.name, "address": address, "first": first, "head": head,
"head_hash": head_block["hash"], "finalized": finalized}
path.with_suffix(".scan.json").write_text(json.dumps(record, indent=2) + "\n")
return rows, tokensreorg_check.py re-reads the head hash, then the hash of every block that held a non-final row. The row hashes catch a log read from a fork that lost, even after the head is back on the old chain. On the first mismatch, the check keeps the rows at or below the old finalized block and rescans the rest of the scan up to the current head. The rescan starts at the scan's first block or just above the old finalized block, whichever is later. Then it rewrites both files:
def check(path):
path = Path(path)
scan = json.loads(path.with_suffix(".scan.json").read_text())
if scan["finalized"] >= scan["head"]:
print(f"{path}: every scanned block was final when written")
return 0
with path.open(newline="") as fh:
rows = list(csv.DictReader(fh))
kept = [row for row in rows if int(row["block_number"]) <= scan["finalized"]]
pending = [row for row in rows if int(row["block_number"]) > scan["finalized"]]
# The head hash covers blocks with no rows: a fork can add a transfer where there was none.
# Row hashes catch a row read from a short-lived fork, even if the head is back on the old one.
stored = [(scan["head"], scan["head_hash"])]
stored += sorted({(int(row["block_number"]), row["block_hash"]) for row in pending})
chain = Chain(scan["chain"])
finalized = int(chain.block("finalized")["number"], 16)
replaced, canonical = None, {}
for number, block_hash in stored:
if number not in canonical:
block = chain.block(number)
canonical[number] = block and block["hash"]
if canonical[number] != block_hash:
replaced = number
break
first = max(scan["first"], scan["finalized"] + 1)
if replaced is None:
state = "all final now" if scan["head"] <= finalized else f"final up to block {finalized}"
print(f"{path}: blocks {first}-{scan['head']} ({len(pending)} rows) unchanged, {state}")
return 0
# Rows at or below the old finalized block stand. The rest of the scan is read again.
head_block = chain.block("latest")
rows, _ = scan_to_csv(chain, scan["address"], first, head_block, finalized, path, kept)
print(f"{path}: block {replaced} replaced, rescanned {first}-{int(head_block['number'], 16)}: "
f"{len(pending)} rows above block {scan['finalized']} are now {len(rows) - len(kept)}")
return 1A rescan is cheap for a quiet address. Base's largest measured gap, 12,111 blocks, is two windows in each direction at a 10,000-block range. The check exits with status 1 after a rescan, so a scheduler can alert on it or reload the file.
The 7-day files for vitalik.eth held no non-final rows. The check still re-read each head hash, because a replacement block could add a transfer. At 09:56:
python reorg_check.py out/*.csvout/arbitrum.csv: blocks 523813854-523817437 (0 rows) unchanged, final up to block 523814933
out/base.csv: blocks 56840750-56845927 (0 rows) unchanged, final up to block 56842760
out/ethereum.csv: blocks 26404853-26404932 (0 rows) unchanged, final up to block 26404916
out/polygon.csv: blocks 97236437-97236437 (0 rows) unchanged, all final nowTo test rows that can still change, we scanned the last 433 blocks of a busy Base contract at 10:06 and ran the check right away:
python transfers.py 0x498581ff718922c3f8e6a244956af099b2652b2b --days 0.001 --chains base
python reorg_check.py out/base.csvbase blocks 56851670-56852102 (433), 727 transfers (727 not final), 78 tokens, 161 requests, 82.3s -> out/base.csv
out/base.csv: blocks 56851670-56852102 (727 rows) unchanged, final up to block 56842760The check took 95 seconds. It read the finalized block and the hashes of the 184 blocks that held rows, the head among them.
No reorg happened during our tests, so we forced the rescan path. We copied both files, replaced the stored head hash with zeros, and ran the check on the copy at 10:09:
base.csv: block 56852102 replaced, rescanned 56851670-56853050: 727 rows above block 56842760 are now 5526The check rescanned from the first scanned block to the new head, rewrote both files and exited with status 1. It took 2 minutes 49 seconds, because the new range held 5,526 transfers. Base's finalized block stayed at 56842760 from 09:56 to 10:12, so none of the 727 rows had become final.
Tip: If you can wait, scan only to finalized and skip the check. Read finalized on every run: Base's gap doubled within 47 minutes in our tests. For a live feed, keep the check and run it until every row is final.
What logs cannot show
transfers.py misses two kinds of movement, and both matter for a wallet balance or a tax export:
- Native coin transfers. Sending ETH on Ethereum, Base or Arbitrum emits no log.
- Internal transfers. A contract that sends ETH during a call, such as a DEX paying out or a multisig executing, moves the ETH without a log.
On those three chains, both appear only in execution traces. Polygon is a partial exception. Its POL system contract at 0x0000000000000000000000000000000000001010 emits a LogTransfer event when a call or a contract creation moves POL. A SELFDESTRUCT does not: Bor credits the beneficiary directly and emits no log. At 10:16 we traced every call in Polygon blocks 97237270 to 97237272 and matched all 11 POL value transfers, 4 of them internal, to a LogTransfer log with the same sender, recipient and amount. Those blocks held no SELFDESTRUCT. Logs cover the POL that calls and creations move. Only traces show a SELFDESTRUCT payout.
On Dwellir, the route depends on the chain:
| Chain | Method | Range per call | Requests per day of history (calculated) |
|---|---|---|---|
| Ethereum | trace_filter | 100 blocks | 144 (72 windows, 2 directions), plus 1 per transaction with an internal transfer |
| Base | trace_filter | 100 blocks | 8,642, plus 1 per transaction with an internal transfer |
| Arbitrum | debug_traceBlockByNumber | 1 block | 306,383 |
| Polygon | eth_getLogs on LogTransfer, no SELFDESTRUCT payouts | 10,000 blocks | 12 |
trace_filter returned -32601 the method trace_filter does not exist/is not available on Arbitrum and Polygon, and Block range too large; currently limited to 100 blocks on Ethereum and Base for wider ranges. On Ethereum and Base it filters on fromAddress and toAddress, so it works like the log query:
TRACE_WINDOW = 100 # trace_filter range cap measured on Dwellir Ethereum and Base, 13 Nov 2026
def _move(trace):
"""(from, to, value) when a trace_filter trace can move native coin, else None."""
action, kind = trace["action"], trace["type"]
if kind == "call" and action.get("callType") == "call":
# delegatecall and staticcall repeat the caller's value without moving coins
return action["from"], action["to"], int(action.get("value", "0x0"), 16)
if kind == "create":
# value sent with a contract deployment; the new address is in the result
return action["from"], (trace.get("result") or {}).get("address", ""), int(action["value"], 16)
if kind in ("suicide", "selfdestruct"):
# SELFDESTRUCT sends the contract's whole balance to the beneficiary
return action["address"], action["refundAddress"], int(action["balance"], 16)
return None
def via_trace_filter(chain, address, from_block, to_block):
found = {}
windows = [(lo, min(lo + TRACE_WINDOW - 1, to_block))
for lo in range(from_block, to_block + 1, TRACE_WINDOW)]
for field in ("fromAddress", "toAddress"):
traces = []
for lo, hi in windows:
traces += chain.call("trace_filter", [{"fromBlock": hex(lo), "toBlock": hex(hi), field: [address]}])
for trace in traces:
move = None if trace.get("error") else _move(trace)
if move and move[2]:
key = (trace["transactionHash"], tuple(trace["traceAddress"]))
found[key] = (trace["blockNumber"], trace["transactionHash"],
"top-level" if not trace["traceAddress"] else "internal", *move)
# trace_filter marks an error only on the frame that reverted, not on the frames beneath it,
# and a failed transaction carries its error on the top frame. For an internal transfer,
# read the whole transaction once and drop the transfer if any frame above it reverted.
reverted = {}
for tx_hash, path in found:
if path and tx_hash not in reverted:
reverted[tx_hash] = {tuple(t["traceAddress"])
for t in chain.call("trace_transaction", [tx_hash]) if t.get("error")}
return sorted(row for (tx_hash, path), row in found.items()
if not any(path[:depth] in reverted.get(tx_hash, ()) for depth in range(len(path))))An empty traceAddress is the transaction itself, a top-level transfer. Anything deeper is internal. _move reads three trace types: a call, a create that sends value to the contract it deploys, and a suicide, the trace name for SELFDESTRUCT, which pays out a contract's whole balance.
trace_filter sets error only on the frame that reverted. A transfer in a call beneath that frame has no error of its own, though the revert undid it. So for each transaction with an internal transfer, the script reads the full trace once with trace_transaction and drops the transfer if any frame above it reverted. That adds one request per such transaction. A top-level transfer needs no extra request, because a failed transaction carries its error on the top frame.
On Arbitrum, native.py falls back to debug_traceBlockByNumber with callTracer and walks every frame of every transaction in every block, including SELFDESTRUCT frames. It skips everything beneath a reverted frame. That works, but it is one request per block, and the response holds the whole block's call tree.
On Polygon, native.py reads LogTransfer with the same windowed scanner as the ERC-20 script. from is topics[2] and to is topics[3], because the token address takes topics[1]. Tracing Polygon blocks also works (debug_traceBlockByNumber, pass --traces), but one block trace was 1.3 to 1.9 MB in our tests.
vitalik.eth had no native transfers in the last 1,000 Ethereum blocks or 20 Arbitrum blocks when we ran it, and the 1,000 Ethereum blocks took 290 seconds for 20 trace_filter calls. To see output, point it at WETH, which takes and pays out ETH all day:
python native.py ethereum 0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2 --blocks 10
python native.py arbitrum 0x82aF49447D8a07e3bd95BD0d56f35241523fBab1 --blocks 50
python native.py polygon 0x0d500B1d8E8eF31E21C99d1Db9A6444d3ADf1270 --blocks 50
python native.py polygon 0x0d500B1d8E8eF31E21C99d1Db9A6444d3ADf1270 --blocks 50 --traces26405060 0x014134574043a1c8f54b65e910119b8e6f707b4cd8e1f4a4985412134f16f35f internal 0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2 0xe6eeda550918638529c6cf2ea14f9ea0572d6f88 0.051601 26405060 0x098ddd8ec51558dff3fc922303f5c65f113cc56dac176a67fbf47cdf2777de77 internal 0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2 0xaddc26a3854feabfd20d6bf658254088d83a425a 0.014467 ... ethereum: trace_filter, blocks 26405060-26405069, 118 native transfers, 102 requests, 54.1s 523822814 0xa063c649184c9955ee0fb6be5ae29ae3fb6e451275afbdfa38a4e924bc59a08d internal 0x278d858f05b94576c1e6f73285886876ff6ef8d2 0x82af49447d8a07e3bd95bd0d56f35241523fbab1 0.022801 ... arbitrum: debug_traceBlockByNumber, blocks 523822807-523822856, 8 native transfers, 52 requests, 26.5s 97237185 0xdc4111152d8869a7b783865a7ea97523764334c2da1079f505657079a55fb957 LogTransfer 0x0d500b1d8e8ef31e21c99d1db9a6444d3adf1270 0x643770e279d5d0733f21d6dc03a8efbabf3255b4 10.602739 ... polygon: LogTransfer logs, blocks 97237184-97237233, 46 native transfers, 3 requests, 1.1s ... polygon: debug_traceBlockByNumber, blocks 97237192-97237241, 16 native transfers, 52 requests, 42.8s
We ran these between 10:12 and 10:17. On Ethereum, 117 of the 118 transfers were internal: contracts wrapping ETH into WETH, or WETH paying ETH out on an unwrap. The ETH in those calls moves without a Transfer log. WETH emits its own Deposit and Withdrawal events, but most contracts that send ETH emit nothing. The 102 requests were 1 eth_blockNumber, 2 trace_filter calls and 99 trace_transaction calls, one per transaction with an internal transfer. On a contract this busy, the revert check costs more than the scan.
The Arbitrum run needed 50 trace calls for 50 blocks. On Polygon, logs found 46 WPOL (wrapped POL) transfers in 3 requests and 1.1 seconds, and traces found 16 in 52 requests and 42.8 seconds. The two runs cover different 50-block windows, because the head moved between them. On the same 20 blocks, 97237261 to 97237280, both routes returned the same 2 transfers.
To check that the two trace routes agree, we ran both on WETH in Ethereum block 26405084. trace_filter and the callTracer walk returned the same 14 transfers. trace_filter needed 11 requests for that block, and the walk needed 1.
Indexer APIs return this history pre-built; Alchemy's Transfers API (alchemy_getAssetTransfers), for example, accepts external, internal and ERC-20 categories in one request.
Going to Production
Budget requests by blocks
For one address, every window hits the range cap, so the request count is close to 2 × blocks ÷ range. From the measured blocks per day (calculated):
| Chain | 1 year at 10,000 blocks per call | 1 year at 500 blocks per call |
|---|---|---|
| Ethereum | 526 | 10,502 |
| Base | 31,538 | 630,722 |
| Arbitrum | 22,366 | 447,320 |
| Polygon | 4,206 | 84,098 |
A Developer key scanning a year of Base for one quiet address makes about 630,000 eth_getLogs calls, most of them empty. Dwellir bills each response as one request with no compute unit multiplier, so that number is also the bill. If you track many addresses, pass them as a list in the topic position, which the node treats as an OR. One scan then covers all of them for the same number of windows, as long as each window stays under the result cap. Deep history also needs archive data, and archive queries have their own cost traps.
Treat a log as a claim, not a fact
Filter rows against a token allowlist before you show balances or file taxes. For every out row, compare the address with the transaction's from. If they differ, the transfer came from an approved spender through transferFrom, or from a contract that emitted a Transfer it had no right to. The two VITALIK rows in the 7-day run fail this check.
A balance summed from Transfer logs also misses balance changes that emit no event, such as rebasing tokens. Reconcile with balanceOf at your end block.
Run chains in parallel, resume per direction
transfers.py scans one chain after another. The chains are independent, so run one process per chain, each with its own MIN_INTERVAL. The total request rate is the sum, and it must stay under your plan's limit.
Each run rescans its whole window. In production, store the last scanned block per chain and direction, and start the next run there. The limits guide shows a checkpoint that survives a crash.
Keep the key out of the URL
rpc.py puts the API key in the endpoint path because it is short to paste. In production, send it as an X-Api-Key header so it stays out of logs and proxy traces. See Getting Started for the header form.
Next Steps
- Add a live tail. Pair the backfill with an
eth_subscribelogs subscription using the same two topic filters. The Robinhood Chain whale tracker shows the subscription side. - Load into a database. Key rows on
(chain, tx_hash, log_index). A rescanned window after a reorg or a crash then overwrites instead of duplicating. - Add chains. Any EVM chain works the same way. Add its endpoint to
ENDPOINTSinrpc.py, then measure its range cap, result cap andfinalizedgap before you trust a backfill.
This guide ran on Dwellir's Ethereum, Base, Arbitrum and Polygon endpoints with one API key. To run it yourself, create a free account and upgrade to a plan that includes eth_getLogs.
Frequently Asked Questions
How do I get ERC-20 token transfers for an address with JSON-RPC?
Call eth_getLogs twice with the Transfer event signature in topics[0]. Put the address, padded to 32 bytes, in topics[1] to get the transfers it sent, and in topics[2] to get the transfers it received. Keep logs with exactly three topics, merge both lists on transaction hash and log index, and sort by block number and log index. Read decimals() and symbol() with eth_call to turn raw amounts into token amounts.
Why does a Transfer filter return NFT transfers too?
ERC-721 uses the same Transfer(address,address,uint256) signature, so topics[0] is identical. ERC-721 indexes the token ID as a fourth topic and leaves data empty. ERC-20 has three topics and puts the amount in data. Count the topics to tell them apart.
Can eth_getLogs find native ETH transfers?
Not on Ethereum, Base or Arbitrum. A native ETH transfer emits no log, and neither does ETH a contract sends during a call. You need traces. On Dwellir, trace_filter takes fromAddress and toAddress filters on Ethereum and Base, with a 100-block range per call (13 November 2026). Arbitrum does not serve trace_filter, so you trace each block with debug_traceBlockByNumber. Polygon is a partial exception: its POL system contract at 0x...1010 emits a LogTransfer event when a call or contract creation moves POL, internal calls included, so eth_getLogs finds those. A SELFDESTRUCT pays its beneficiary without a LogTransfer, so only traces show that case.
What are the eth_getLogs limits on Ethereum, Base, Arbitrum and Polygon?
On 13 November 2026, on a plan with a 10,000-block range, all four Dwellir endpoints accepted 10,000 blocks per eth_getLogs call and rejected 10,001. Ethereum rejected answers over 20,000 logs and Base over 10,000 logs. Arbitrum returned 84,116 logs and Polygon 130,333 logs in single responses without a cap. Polygon timed out on 2,000 blocks of all Transfer logs.
eth_getLogs Limits
Why eth_getLogs fails on wide block ranges, what each rejection means, and a Python scanner that sizes its own window, retries, and resumes.
Backfill the Full Hyperliquid OHLCV Archive
Pull the full Hyperliquid OHLCV archive for one market, or every market at one interval, with a resumable multi-process Python exporter.