# Get ERC-20 Token Transfers for an Address Across EVM Chains

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 `Transfer` logs 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 return `bytes32`
- 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_filter` or `debug_traceBlockByNumber`, and on Polygon with plain logs

## Prerequisites

- Python 3.10 or newer. Run `python3 --version` to confirm.
- A Dwellir API key on a [Developer plan or above](https://www.dwellir.com/pricing). The Free plan does not include `eth_getLogs`. One key works on [Ethereum](https://www.dwellir.com/networks/ethereum), [Base](https://www.dwellir.com/networks/base), [Arbitrum](https://www.dwellir.com/networks/arbitrum) and [Polygon](https://www.dwellir.com/networks/polygon).
- The example code. The full project is in [dwellir-examples](https://github.com/dwellir-public/dwellir-examples/tree/main/examples/multichain-erc20-transfers):

```bash
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`](https://www.dwellir.com/docs/ethereum/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](https://www.dwellir.com/docs/getting-started/rate-limits) 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`:

```python
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:

```python
sent = [TRANSFER, topic_for(address)]            # from = address, any recipient
received = [TRANSFER, None, topic_for(address)]  # any sender, to = address
```

We 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:

```python
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](https://www.dwellir.com/guides/eth-getlogs-limits), 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`:

```python
"""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:

```bash
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:

```python
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:

```python
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:

```python
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 value
```

`run_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:

```bash
python transfers.py 0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045 --days 7
```

```
ethereum  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.csv
```

The 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:

```bash
cut -d, -f2,3,6,9,11,13 out/polygon.csv
```

```
block_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,True
```

Two 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.

```python
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, tokens
```

`reorg_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:

```python
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 1
```

A 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:

```bash
python reorg_check.py out/*.csv
```

```
out/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 now
```

To 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:

```bash
python transfers.py 0x498581ff718922c3f8e6a244956af099b2652b2b --days 0.001 --chains base
python reorg_check.py out/base.csv
```

```
base      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 56842760
```

The 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 5526
```

The 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](https://github.com/0xPolygon/bor/blob/09f4bcaf3def1c6d5f61bff8f93b4f1fed817680/core/vm/instructions.go#L990) 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`](https://www.dwellir.com/docs/ethereum/trace_filter)                         | 100 blocks     | 144 (72 windows, 2 directions), plus 1 per transaction with an internal transfer |
| Base     | [`trace_filter`](https://www.dwellir.com/docs/base/trace_filter)                             | 100 blocks     | 8,642, plus 1 per transaction with an internal transfer                          |
| Arbitrum | [`debug_traceBlockByNumber`](https://www.dwellir.com/docs/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:

```python
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`](https://www.dwellir.com/docs/polygon/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:

```bash
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 --traces
```

```
26405060 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](https://www.alchemy.com/docs/data/transfers-api/transfers-endpoints/alchemy-get-asset-transfers) (`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](https://www.dwellir.com/blog/eth-getlogs-archive-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](https://www.dwellir.com/guides/eth-getlogs-limits) 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](https://www.dwellir.com/docs/getting-started) for the header form.

## Next Steps

- **Add a live tail.** Pair the backfill with an `eth_subscribe` logs subscription using the same two topic filters. The [Robinhood Chain whale tracker](https://www.dwellir.com/guides/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 `ENDPOINTS` in `rpc.py`, then measure its range cap, result cap and `finalized` gap 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](https://dashboard.dwellir.com/register) and upgrade to a plan that includes `eth_getLogs`.
