github.com/khalidjshaikh/ethereum-track
main / published Sep 27, 2026 / repository
track
Crystal CLI that lists the ETH and ERC-20 transactions (with amounts) for an Ethereum address.
$ crystal build src/track.cr -o bin/track
$ bin/track 0x033958c4d407160Ac2a2ffcA6621E4669DA9E6A3
Address : 0x033958c4d407160Ac2a2ffcA6621E4669DA9E6A3
Source : Explorer https://eth.blockscout.com/api
Balance : 0.0000021191270058 ETH
== NATIVE ETH (45 entries) ==
in native 0.043 ETH 0x8bda…3fbe -> 0x0339…e6a3 2026-05-16
self native 0 ETH 0x0339…e6a3 -> 0x0339…e6a3 2026-05-16
out native 0.068 ETH 0x0339…e6a3 -> 0x1481…db70 2026-06-12
...
== ERC-20 (N entries) ==
in erc20 1 DFRD 0x3c47…3e42 -> 0x0339…e6a3 2026-09-10
in erc20 0.998893 USDT 0xacdb…6d07 -> 0x0339…e6a3 2026-05-16
...
Subcommands
bin/track snapshot ADDRESS [--token=…] [--amount=N] [--rpc=URL] [--out=DIR]
bin/track merkle [--snapshot=FILE] [--out=DIR]
snapshot probes every distinct out-recipient of an address for its balance
of a token at a fixed block and writes snapshots/<block>.json (--out
changes the directory). Holders are excluded from eligible; so are contract
recipients, which are dropped entirely — a token sent to a contract cannot be
claimed and is burned. EOA-only is the default (pass --eoas-only=false
to keep contract recipients, which is only useful for inspection). Note
candidates holds every probed recipient (including holders), so it is larger
than eligible.
merkle turns the eligible recipients into a sorted merkle tree and writes
the root plus per-recipient proofs (merkle/<root>.json). See AIRDROP.md
for the full flow, including the DFRDAirdrop claim contract and
forge test verification.
Resolvers
| Resolver | What it lists | Requirement |
|---|---|---|
blockscout | Native ETH + ERC-20 transfers | None (default, rate-limited) |
etherscan | Native ETH + ERC-20 transfers | Free API key (EXPLORER_KEY) |
rpc | Native (via trace_filter) + ERC-20 (via eth_getLogs) | Archive node with trace_filter |
Resolvers are selected with a resolver:ADDRESS prefix:
bin/track 0x0339…
bin/track etherscan:0x0339… # requires EXPLORER_KEY
bin/track rpc:0x0339… --rpc https://my-archive.node
Blockscout
The public instance (https://eth.blockscout.com/api) needs no key but is
throttled to roughly 10 requests/minute. Requests are spaced out and retried;
tune with --interval=SEC. Pass --progress to watch the pages land — one
dot per request is the only signal a throttled run gives.
Some networks block direct access to the explorer host (Cloudflare-gated
egress). Route requests through an HTTP proxy with --proxy URL (or the
EXPLORER_PROXY env var). A working option is https://r.jina.ai:
EXPLORER_PROXY=https://r.jina.ai bin/track 0x0339…
Etherscan
Create a free API key at https://etherscan.io and export it:
export EXPLORER_KEY=yourkey
bin/track etherscan:0x0339…
JSON-RPC
Useful when you already run a node or have a keyed/archive RPC. The
--from-block / --to-block flags limit the scan (defaults: whole chain),
--no-traces / --no-logs disable the liability of a feature that the node
does not support. trace_filter is required for native ETH balances on this
path; most free public endpoints reject it, so expect a warning.
bin/track rpc:0x0339… --rpc https://eth-mainnet.g.alchemy.com/v2/KEY
An unbounded scan is very slow. This resolver has no index to query, so
without --from-block/--to-block it walks the entire chain in 2000-block
eth_getLogs chunks — tens of thousands of calls, and on a public node easily
the best part of an hour before the first row is printed. The CLI estimates
this up front and says so:
$ bin/track rpc:0x0339… --progress
warn: no block bounds on the rpc resolver; scanning the whole chain
warn: head is 26064847, so expect about 26066 eth_getLogs calls (~13033 chunks x 2 topics)
warn: bound it with --from-block=N --to-block=N, or pass --no-logs; watch progress with --progress
It warns rather than refusing, so a deliberate full sweep still works. Bound
the range whenever you can, and add --progress so the silence is visible:
bin/track rpc:0x0339… --from-block=26060000 --to-block=26064847 --progress
--trace (or TRACE=1) echoes the parsed arguments before anything else
runs, which separates "flags parsed wrong" from "still scanning":
$ bin/track rpc:0x0339… --depth 0 --trace
trace: argv ["rpc:0x033958c4d407160Ac2a2ffcA6621E4669DA9E6A3"]
trace: resolver="rpc" address="0x033958c4d407160Ac2a2ffcA6621E4669DA9E6A3"
trace: depth=0 max_addresses=500 full_addresses=false
trace: from_block=0 to_block=nil no_traces=false no_logs=false
trace: rpc_url=nil rpc_key=none explorer_key=none proxy=nil interval=6.0
trace: progress=false
Note: eth_getLogs filters on the log emitter (the token contract), not on
participants, so the RPC resolver scans token contracts where the address was
a from/to index. Full-history discovery without an explorer/archive index
is impractical on free RPC tiers (Alchemy free caps ranges at 10 blocks).
Options
Usage: track [RESOLVER:]ADDRESS
--rpc=URL RPC endpoint (rpc resolver)
--from-block=N First block to scan (rpc resolver)
--to-block=N Last block to scan (rpc resolver)
--no-traces Skip native ETH transfers (rpc resolver)
--no-logs Skip ERC-20 transfer logs (rpc resolver)
--interval=SEC Seconds between explorer API calls (default 6)
--progress Print a dot per completed request on stderr
--no-progress Do not print progress (default)
--proxy=URL Route explorer requests through an HTTP proxy
-h, --help Show this help
Progress
Every resolver fetches the whole history before it prints anything, and the
explorer resolvers deliberately sleep between pages (6s by default), so a
healthy run is silent for a long time. --progress makes it visibly alive: a
start line naming the scan, then one dot per completed request on stderr,
so stdout — and any pipe or redirect — keeps only the report.
$ bin/track 0x0339… --progress
note: fetching 0x0339…E6A3 via blockscout
....... note: level 1, 8 addresses fetched
...........
note: 18 pages completed
Address : 0x0339…
Dots go to stderr specifically so bin/track … > out.txt stays clean; the
existing note:/warn: lines are routed through the same reporter so they
never land in the middle of a dot run. In a terminal the dot stream is
followed by a running counter; when stderr is redirected you get plain dots.
On the rpc resolver each dot is one JSON-RPC call — an eth_getLogs scan
issues many per address, so expect a steady stream. --no-progress (the
default) restores the old quiet behaviour.
Environment: RESOLVER, RPC_URL, RPC_API_KEY, EXPLORER_KEY,
EXPLORER_PROXY.
Layout
src/track.cr— CLI, output formatting,snapshot/merklesubcommandssrc/explorer.cr— Etherscan/Blockscout explorer API client (pagination, throttling)src/ethereum_rpc.cr— minimal JSON-RPC client (BigInt-aware)src/transactions.cr— transfer model + listing logicsrc/keccak.cr— pure-Crystal Keccak-256 (for merkle generation)src/merkle.cr— sorted merkle tree + proofssrc/snapshot.cr— airdrop recipient snapshot + holder probescontracts/—DFRDAirdrop.solclaim contract + helperstest/— Foundry suite (cross-validates the merkle tree on-chain)spec/— unit tests (crystal spec)
Tests
crystal spec
forge test
Built with the Crystal stdlib only (no shards).
API
- Cli
- EthereumRpc
Minimal Ethereum JSON-RPC client backed by the Crystal stdlib.
- ExplorerApi
Reads transaction history from an Etherscan-style explorer API.
- HistoryResult
Result of a history scan: native transfers, ERC-20 transfers, present balance, and any warnings raised along the way (e.g.
- HolderStatus
Balance-probe outcome for a candidate recipient.
- Keccak
Pure-Crystal Keccak-256 (the Ethereum hash, NOT SHA3-256: domain byte is 0x01 instead of 0x06).
- Merkle
Sorted merkle tree over airdrop leaves, compatible with the scheme found in OpenZeppelin's MerkleProof and forge-std:
- Progress
Live heartbeat for long network-bound runs.
- SnapEntry
- Snapshot
Builds the frozen airdrop snapshot for an address: 1.
- SnapshotIssue
- TokenInfo
- Transactions
- Transfer
A single movement of funds involving the target address.
- Walk
Breadth-first walk of the fund graph, following OUT-transfers only: level 1 is every distinct address the root paid, level 2 everything those paid, and so on.
- WalkNode
An address reached by the walk, together with the history pulled for it.
- WalkResult
- WalkStats
In/out totals for one walked address, used to render a one-line summary.