Skip to main content

Usage

This walks through indexing one event filter end to end: pick an adapter, pick a blockRangeLimit, pick a store, then stream matching logs with createRadio.

1. Pick an adapter

An adapter wraps a client/provider you already constructed — this package never creates its own. Pick the subpath matching whichever library you use; both normalize to the same LogsProvider shape.

ethereum-radio is designed browser-first — the example below routes through the connected wallet's own RPC via custom(window.ethereum) rather than a hardcoded endpoint. Looking for a node-based runner instead? Consider wighawag/etherfold.

import {createPublicClient, custom} from 'viem';
import {mainnet} from 'viem/chains';
import {createViemAdapter} from '@ethereum-radio/indexer/adapters/viem';

const publicClient = createPublicClient({chain: mainnet, transport: custom(window.ethereum!)});
const provider = createViemAdapter(publicClient);

or with ethers:

import {BrowserProvider} from 'ethers';
import {createEthersAdapter} from '@ethereum-radio/indexer/adapters/ethers';

const ethersProvider = new BrowserProvider(window.ethereum);
const provider = createEthersAdapter(ethersProvider);

2. Pick a blockRangeLimit

blockRangeLimit caps how wide a single eth_getLogs call is allowed to be — set it too high and some RPCs reject the call outright (public endpoints can cap it as low as 5–10 blocks). Rather than guessing a literal, probe the provider once with detectRpcCapabilities:

import {detectRpcCapabilities, safeBlockRangeLimit} from '@ethereum-radio/indexer/core/rpc-doctor';

const {maxBlockRange} = await detectRpcCapabilities(
provider,
'0xYourContractAddress',
await provider.getBlockNumber(),
);
const blockRangeLimit = maxBlockRange
? safeBlockRangeLimit(maxBlockRange)
: 2_000n; // fallback if every candidate width failed

safeBlockRangeLimit backs the detected max off by one block, as a fencepost guard against RPCs that enforce their range cap as a from/to difference rather than an inclusive block count. It doesn't protect against a secondary cap keyed on response size or log count — if you're indexing a contract you expect to be log-dense (e.g. USDC Transfer), pass CursorConfig's safetyPadding (e.g. 0.5) to shrink the actual scan window below blockRangeLimit and leave headroom for that.

If you already know a safe value for your RPC (your own node, or a provider's documented limit), skip the probe and just set the constant directly instead: const blockRangeLimit = 2_000n;.

Either way, compute it once and reference the blockRangeLimit binding everywhere below — every snippet on this page uses that same name rather than repeating a literal.

3. Pick a store

A SpanStore is where scan progress (which block ranges have already been checked) gets persisted, so the next run doesn't rescan from scratch. Start with the in-memory store; swap in createLocalStorageStore() once you want progress to survive a page reload.

import {createMemoryStore} from '@ethereum-radio/indexer/storage/memory';

const store = createMemoryStore();
import {createLocalStorageStore} from '@ethereum-radio/indexer/storage/local-storage';

const store = createLocalStorageStore(); // backed by window.localStorage

4. Stream logs with createRadio

import {createRadio} from '@ethereum-radio/indexer';

const radio = createRadio({
provider,
store,
key: 'mainnet:0xYourContract:Transfer', // caller-owned — this package doesn't prescribe a scheme
address: '0xYourContractAddress',
floorBlock: 18_000_000n, // e.g. the contract's deployment block
blockRangeLimit, // from step 2
safetyPadding: 1.0, // optional, default 1.0 (no padding) — see below
});

for await (const logs of radio) {
for (const log of logs) {
console.log(log.blockNumber, log.transactionHash);
}
}

safetyPadding shrinks blockRangeLimit by that ratio for the actual scan window — leave it at the default 1.0 unless you're indexing a contract you expect to be unusually log-dense (e.g. USDC Transfer), where a request that's within blockRangeLimit can still trip a response-size/log-count cap detectRpcCapabilities never saw during its own (comparatively sparse) probe. 0.5 there means every scan window is half of blockRangeLimit.

createRadio replays history in blockRangeLimit-sized chunks first — oldest scanned window last, since it walks backward from the tip — then switches to polling the tip forever once floorBlock is reached, yielding only when a chunk actually contains matching logs. breaking the loop (or aborting an AbortSignal passed as {signal} in the second argument) stops it cleanly; there's no separate subscribe/unsubscribe call to remember.

The imperative side

radio is also the underlying Cursor — every method is right there on the same object, for when you want more control than "just give me the stream":

await radio.isFullyScanned(); // caught up to floorBlock and the tip?
await radio.getCellStates(await provider.getBlockNumber()); // scan-progress grid for a UI
await radio.fetchHistory(); // manually pull one more window of history
await radio.fetchForward(); // manually advance an atBlock-anchored forward walk

This is handy for a scan-progress UI (color a grid of cells scanned/unscanned via getCellStates) or for driving the backfill on a button click instead of automatically.

5. React

If you're in a React component, useCursor wraps the same Cursor engine as plain hook state — no @tanstack/react-query, just useState/useEffect:

import {useCursor} from '@ethereum-radio/indexer/react';

function TransferFeed() {
const {scannedSpans, isFullyScanned, isLoading, error, fetchHistory} =
useCursor({
provider,
store,
key: 'mainnet:0xYourContract:Transfer',
address: '0xYourContractAddress',
floorBlock: 18_000_000n,
blockRangeLimit, // from step 2
pollIntervalMs: 10_000, // re-sync to the tip on an interval
});

// ...
}

See api/react.mdx for the full UseCursorArgs/UseCursorResult shape.

Filtering on hashed dynamic types

Solidity hashes indexed string/indexed bytes event parameters into their topic — the topic is keccak256(value), not the value itself. This trips people up because it's invisible in the ABI; only the EVM's actual event-encoding rules make it true. To filter on one, hash the value yourself before passing it in topics:

import {keccak256, toBytes} from 'viem';

const radio = createRadio({
provider,
store,
key: 'mainnet:0xYourContract:NameRegistered',
address: '0xYourContractAddress',
// topics[0] (the event signature) left `null` to match any event at this
// address; topics[2] filters to exactly `name === 'alice'`.
topics: [null, null, keccak256(toBytes('alice'))],
floorBlock: 18_000_000n,
blockRangeLimit, // from step 2
});

(ethers' equivalent is keccak256(toUtf8Bytes('alice')).) A plain indexed value type (address, uint256, …) doesn't need this — its topic is just the ABI-encoded value, so you can filter on it directly.