Getting Started
Why this exists
Every block explorer answers the same questions and none of them agree on how. Etherscan wants a chain id and a key in the query, Blockscout wants neither, Koios wants the address in a POST body, TONAPI answers in nanotons and Arweave in winstons, and Solscan and Helius disagree about which Solana questions exist at all. Keep one client per explorer in a script or an agent and you have thirteen ways to misread a number.
So @agntn/explorers puts one abstract Provider in front of all of it. Two required reads, six optional ones, one Balance, one Transaction, one TokenBalance, one GasData, one BlockInfo. Amounts are strings in the chain's smallest unit, so nothing is rounded before you decide it should be. And when you do not say which provider, it picks one from the keys you have.
Install
pnpm add @agntn/explorers
Node.js 24 or newer.
First call
Blockscout needs no key, so this works with no config at all:
import { create, resolveEns, resolveProvider } from "@agntn/explorers";
const provider = await create(resolveProvider(undefined, "ethereum"));
const address = await resolveEns("vitalik.eth");
if (!address) throw new Error("ENS name did not resolve");
const balance = await provider.getBalance(address, "ethereum");
const transactions = await provider.getTxHistory(address, "ethereum", { limit: 10 });
console.log(`${balance.balanceFormatted} ${balance.symbol}`);
console.log(transactions.map((transaction) => transaction.hash));
Set ETHERSCAN_API_KEY and the same two lines go through Etherscan instead. Nothing else changes. The whole ranking is in Provider selection.
Same call, any chain
const mempool = await create("mempool");
await mempool.getBalance("1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa", "bitcoin");
const arweave = await create("arweave");
await arweave.getBalance("FPjbN_btYKzcf8QASjs30v5C0FPv7XpwKXENBW8dqVw", "arweave");
The Explorer runs these exact calls against the docs worker, so you see what comes back before writing a line.
Optional operations
Required operations live on Provider. Optional ones stay absent when a backend cannot serve them, so check both the flag and the method:
if (provider.capabilities.contractInfo && provider.getContractInfo) {
const contract = await provider.getContractInfo(
"0x1f9840a85d5aF5bf1D1762F925BDADdC4201F984",
"ethereum",
);
console.log(contract.isVerified, contract.name);
}
There is no stub that returns convincing nonsense. A provider without contract metadata has no getContractInfo, full stop.
What ships
| Operation | Method | Where |
|---|---|---|
| native balance | getBalance | every provider except Helius and Aptos |
| transaction history | getTxHistory | every provider except Aptos |
| one transaction | getTxDetail | all but TONAPI, Blockberry and Aptos |
| contract metadata | getContractInfo | Etherscan, Blockscout |
| token holdings | getTokenBalances | Etherscan, Blockscout, Helius, Koios |
| token transfers | getTokenTransfers | Etherscan, Blockscout |
| gas suggestions | getGasData | Etherscan, Blockscout, Mempool on Bitcoin and Litecoin |
| one block | getBlockInfo | all but TONAPI, Helius, Blockberry, Koios and Aptos |
The per provider detail, with the traps, is on the provider pages. The per chain view is on Chains.
One provider, no registry
import { Mempool } from "@agntn/explorers/providers/mempool";
const mempool = new Mempool({ timeout: 30_000 });
A sub path import gives you the class and leaves the other twelve out of your bundle. create() is asynchronous for the same reason: it imports the one provider it was asked for and nothing else. Everything the registry answers without an instance, providers(), has(), supportsChain(), supportsCapability(), getDefaultURL() and resolveProvider(), stays synchronous and reads only metadata.
Next
- CLI: the same reads from a terminal,
explorers vitalik.ethand done. - Provider selection: how a provider is picked and when a read is retried.
- Balances, Transactions, Tokens, Contracts, Gas and blocks.
- Errors: one hierarchy, keys stripped from every message.
- Agents: nine tools over MCP, Pi and OMP.