etherscan-actions
by billywgd
README.md
# etherscan-actions
[](https://github.com/billywgd/etherscan-actions/actions/workflows/ci.yml) [](LICENSE)
Ethereum-only collector and RPC alternative built entirely on [etherscan.io](https://etherscan.io). It provides a Python SDK, a JSON CLI (`escan`), and a stdio MCP server. No API key or browser is needed: requests go to the same public pages and read-only UI JSON handlers a browser uses, over a Chrome-impersonating TLS client (`curl_cffi`).
This is not an Etherscan API client. See [endpoint coverage](docs/endpoint-coverage.md) for how each documented API endpoint, including every PRO and PRO+ endpoint, maps to public data.
## Install
Requires Python 3.11+.
```sh
uv sync
uv run escan --help
```
Or install it straight from GitHub:
```sh
pip install git+https://github.com/billywgd/etherscan-actions
```
## Dedicated operations
These return an `ActionResult` with `operation`, `target`, `source`, `chain_id` (always `1`), `status`, `complete`, `data`, per-request provenance (`sources`), `pagination`, `unavailable_fields`, `warnings` and `errors`.
| Operation (API counterpart) | SDK | CLI | MCP |
| --- | --- | --- | --- |
| First funder (`fundedby`) | `funded_by(address)` | `funded-by` | `etherscan_funded_by` |
| Address metadata (`getaddresstag`) | `metadata(address)` | `metadata` | `etherscan_metadata` |
| ERC20 holdings (`addresstokenbalance`) | `erc20_holdings(address)` | `erc20-holdings` | `etherscan_erc20_holdings` |
| Token holder list (`tokenholderlist`) | `token_holders(token)` | `token-holders` | `etherscan_token_holder_list` |
| Transaction history (`txlist`, `txlistinternal`, `tokentx`, `tokennfttx`) | `tx_history(address, kind=...)` | `tx-history --kind ...` | `etherscan_tx_history` |
| Transaction details (`eth_getTransactionByHash` + receipt) | `tx_info(hash)` | `tx-info` | `etherscan_tx_info` |
```sh
uv run escan funded-by 0xd8da6bf26964af9d7eed9e03e53415d37aa96045
uv run escan metadata 0x4838b106fce9647bdf1e7877bf73ce8b0bad5f97
uv run escan erc20-holdings 0xd8da6bf26964af9d7eed9e03e53415d37aa96045 --all --max-pages 5
uv run escan token-holders 0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48 --page 3
uv run escan tx-history 0xd8da6bf26964af9d7eed9e03e53415d37aa96045 --kind erc20 --all --max-pages 5
uv run escan tx-info 0x53f109a88a180457eb8f558e73c02636f9a3ddb8d017f886a242dd2c4dc1a8e9
```
- **Funded By** reads the address page's first-funder annotation (funder address, its public label and the funding transaction). It then reads that transaction for `block`, `timeStamp` and an exact wei `value`. The value is used only if the transaction's sender and recipient match the annotation. A contract creator is never substituted for a funder.
- **Metadata** returns the public nametag, ENS name and labels. It is always `partial`: private/internal PRO+ fields are listed in `unavailable_fields` and never invented.
- **ERC20 holdings** excludes ETH and returns full-precision `balance` decimal strings plus untruncated names and symbols. Price and value are kept as displayed. `balance_raw` and decimals are not public and stay unavailable.
- **Token holders** returns `address`, full-precision `balance` and public `label` (`null` when unlabelled). Etherscan shows at most 1,000 holders. The cap and the reported total are kept in `pagination`, and capped results are never marked `complete`.
- **Transaction history** returns one record per row, using Etherscan API field names: `blockNumber`, `timeStamp`, `hash`, `from`, `to`, `value`, `isError`, `functionName` and so on. It adds public labels (`fromLabel`, `toLabel`), `direction` and `errorReason`. ETH values are exact wei, read from full-precision tooltips. `kind` selects `normal`, `internal`, `erc20` or `nft`. Token amounts are exact decimal strings; raw units are not public in the listing. Etherscan shows only the latest 10,000 records (5,000 for token transfers). `pagination` reports `reported_total` and `public_record_cap`, and capped histories are never `complete`.
- **Transaction details** returns status and failure reason, block, timestamp, and from/to with labels. Value, fee, gas price, base/max/priority fees and the burnt fee are all exact wei. It also returns gas limit/used, type, nonce, position, raw `input`, `methodId` and the decoded function signature. ERC-20 `tokenTransfers` come with exact amounts, and `logs` include raw topics/data plus decoded parameters. For contract deployments, `to` is empty and `contractAddress` is set.
Listings resume with `--page` / `start_page` from `pagination.next_page`. Use `--all` / `all_pages=True` with a bounded `--max-pages` to walk them.
## Ethereum RPC from Etherscan
`escan rpc` answers standard Ethereum JSON-RPC methods using only Etherscan, with no node provider or API key. Results use the usual RPC shapes (hex quantities, lowercase addresses). Fields Etherscan doesn't publish are `null`, never invented.
```sh
uv run escan rpc eth_getTransactionReceipt 0x53f109a88a180457eb8f558e73c02636f9a3ddb8d017f886a242dd2c4dc1a8e9
uv run escan rpc eth_getBalance 0xd8da6bf26964af9d7eed9e03e53415d37aa96045
uv run escan rpc eth_call '{"to":"0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48","data":"0x18160ddd"}'
uv run escan rpc escan_fundedBy 0x4838b106fce9647bdf1e7877bf73ce8b0bad5f97
```
| Method | Source | Notes |
| --- | --- | --- |
| `eth_blockNumber`, `eth_call` (incl. state overrides), `eth_getCode` | Etherscan's node | The node that powers etherscan.io's Read Contract tab. Only recent state (last 128 blocks). |
| `eth_getBalance`, `eth_getStorageAt` | Etherscan's node | Exact: an `eth_call` whose state override runs `BALANCE` / `SLOAD`. Same 128-block window. |
| `eth_getTransactionByHash`, `eth_getTransactionReceipt` | Transaction page (+ block page for `blockHash`) | Full input, exact fees, logs with block-level `logIndex`. `v/r/s`, `logsBloom`, `cumulativeGasUsed` are `null`. |
| `eth_getBlockByNumber` / `ByHash`, `eth_getBlockTransactionCountBy*` | Block page | Header fields including hash, parent, state/withdrawals roots, miner, gas, base fee, blob gas and extra data. Tags `latest`, `safe`, `finalized` and `earliest` resolve through the node. `transactions` is `null`: Etherscan serves a Cloudflare challenge on block transaction lists. |
| `eth_chainId`, `net_version`, `web3_clientVersion` | constant | |
| `escan_txInfo`, `escan_txHistory`, `escan_fundedBy`, `escan_metadata`, `escan_erc20Holdings`, `escan_tokenHolders` | pages | The sleuthing operations above, through the same interface. Options go in a second parameter, e.g. `{"kind": "erc20", "all_pages": true}`. |
> **Live verification status (0.4.0):** the node-backed methods and the `escan_*` methods were verified against etherscan.io. `eth_getTransactionByHash`, `eth_getTransactionReceipt`, `eth_getBlockByNumber` and `eth_getBlockByHash` are tested only against saved copies of real pages: Cloudflare was challenging the test machine's IP when they shipped. `pytest -m live` covers them.
Not available from Etherscan without a browser: `eth_getLogs`, full block transaction lists, `eth_getTransactionCount`, `eth_estimateGas`, traces, and state older than 128 blocks. Sending transactions is out of scope.
### Bulk
Batches read NDJSON: full JSON-RPC requests, or plain values with a METHOD. Results stream as NDJSON in completion order, each with its request `id`. `--resume` appends to a results file and skips ids it already answered, so an interrupted run continues where it stopped.
```sh
uv run escan rpc eth_getTransactionReceipt --batch hashes.txt --resume receipts.ndjson
uv run escan rpc escan_txHistory --batch addresses.txt '{"kind":"erc20","all_pages":true,"max_pages":20}' --resume history.ndjson
uv run escan rpc --batch requests.ndjson --workers 8 > results.ndjson
```
```python
with EtherscanClient() as client:
receipt = client.rpc("eth_getTransactionReceipt", [tx_hash])
for response in client.rpc_batch(
{"id": h, "method": "eth_getTransactionByHash", "params": [h]} for h in hashes
):
...
```
### Throughput
Measured from one IP on 2026-10-06:
- **Etherscan's node:** about 18 requests/s, with no errors at any concurrency. The default node rate is 15/s (`node_rps`).
- **Pages:** short bursts of 5 to 10 requests/s succeed. But about 140 page requests within a minute got the IP challenged site-wide, and the block outlasted 20 minutes. The default page rate is therefore 1 request/s (`--interval`). Raising it risks a long block.
Finalized transaction and block pages are cached permanently, so repeated lookups and transactions in the same block cost no requests. `--no-block-hash` skips the block-page lookup that fills `blockHash`, halving page requests for transactions and receipts. After a challenge, page requests pause for a cooldown that doubles from 60 s up to 15 min. Batches wait it out (up to `--max-wait`) rather than extending the block.
## Page and section collection
The generic collectors keep every public field, table, link, inline chart dataset and ABI on a page, along with section-level status, pagination and completeness.
```sh
uv run escan discover address 0xd8da6bf26964af9d7eed9e03e53415d37aa96045 # advertised sections
uv run escan address 0xd8da6bf26964af9d7eed9e03e53415d37aa96045 --sections transactions,internals,erc20 --all --max-pages 3
uv run escan token 0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48 --summary
uv run escan tx 0x5c504ed432cb51138bcf09aa5e8a410dd4a1e204ef84bfed1be16dfba1b22060
uv run escan contract 0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48
uv run escan history address 0xd8da6bf26964af9d7eed9e03e53415d37aa96045 transactions --page 2
uv run escan holders 0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48
uv run escan page /gastracker
uv run escan cache-clear
```
`--summary` prints coverage and counts instead of the full record. `--output FILE` atomically writes the full JSON to a new file. `--html` keeps the original HTML.
```python
from etherscan_actions import EtherscanClient
with EtherscanClient() as client:
funding = client.funded_by("0x4838b106fce9647bdf1e7877bf73ce8b0bad5f97")
print(funding.data) # fundingAddress, fundingAddressLabel, fundingTxn, block, timeStamp, value
holders = client.token_holders(
"0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48", all_pages=True, max_pages=4
)
print(len(holders.data), holders.pagination)
token = client.token("0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48", sections=["holders"])
print(token.summary())
```
## MCP server
```sh
uv run etherscan-actions-mcp
```
Tools: `etherscan_funded_by`, `etherscan_metadata`, `etherscan_erc20_holdings`, `etherscan_token_holder_list`, `etherscan_tx_history`, `etherscan_tx_info`, `etherscan_rpc`, `etherscan_address`, `etherscan_token`, `etherscan_transaction`, `etherscan_contract`, `etherscan_holders`, `etherscan_history`, `etherscan_discover`, `etherscan_page`, `etherscan_read_artifact`.
If a response exceeds `ETHERSCAN_ACTIONS_MCP_MAX_CHARS`, the full result is written to a JSON artifact. The tool then returns its path, its SHA-256 and a coverage summary. Read the artifact in chunks with `etherscan_read_artifact`.
## Configuration
SDK constructor options: `profile`, `interval`, `timeout`, `proxy`, `cache`, `cache_dir`, `cache_ttl`, `node_rps`. The CLI has matching flags. The CLI and MCP server also read:
| Variable | Default | Purpose |
| --- | --- | --- |
| `ETHERSCAN_ACTIONS_PROFILE` | `chrome` | `curl_cffi` impersonation profile |
| `ETHERSCAN_ACTIONS_INTERVAL` | `1` | Minimum seconds between requests |
| `ETHERSCAN_ACTIONS_TIMEOUT` | `30` | Request timeout in seconds |
| `ETHERSCAN_ACTIONS_PROXY` | none | Explicit HTTP proxy URL |
| `ETHERSCAN_ACTIONS_CACHE_DIR` | `$XDG_CACHE_HOME/etherscan-actions` | Page cache and MCP artifacts |
| `ETHERSCAN_ACTIONS_MCP_MAX_CHARS` | `100000` | Inline MCP response budget |
## Behaviour and guarantees
- Read-only. Page requests are rate-limited, restricted to `https://etherscan.io` (other origins and off-origin redirects are rejected), and retried with bounded backoff on 429/5xx, honouring `Retry-After`. Node requests go only to `node1.web3api.com` (the node etherscan.io's own frontend uses), only for its three read methods, with an etherscan.io Origin as the frontend sends.
- Successful responses are cached in SQLite (default TTL 300 s; `--refresh` bypasses it). Finalized transaction and block pages never expire. Challenges, error documents, login walls and not-found pages are never cached.
- Failures are explicit: a Cloudflare challenge is `blocked`, a login wall is `login_required`, and a missing record is `not_found`. None of these is reported as empty data. `complete` covers only the requested public scope, so check it, along with section status and notices, before treating a listing as a full history.
## Verification
```sh
uv run pytest -q # unit + local-HTTPS end-to-end (SDK, CLI, stdio MCP)
uv run pytest -q -m live # optional requests to etherscan.io
uv run ruff check .
uv build
```
Live results from 2026-10-06 are in the [coverage doc](docs/endpoint-coverage.md#live-checks).
## Disclaimer
This is an independent project. It is not affiliated with or endorsed by Etherscan. It reads publicly visible pages at a deliberately slow default rate. You are responsible for following [Etherscan's terms of service](https://etherscan.io/terms) and for keeping your usage reasonable. If you need guaranteed, high-volume or commercial access, use the official [Etherscan API](https://docs.etherscan.io).
## Contributing and license
Contributions are welcome; see [CONTRIBUTING.md](CONTRIBUTING.md). Report security issues as described in [SECURITY.md](SECURITY.md). Released under the [MIT License](LICENSE).
This server cannot be deployed
Maintenance
ActivityMaintained
ResponsivenessNo issues