Skip to main content

Core

Root import: import {...} from '@ethereum-radio/indexer' (or the equivalent @ethereum-radio/indexer/core/* subpath for any individual module). Everything on this page is dependency-free — no viem, ethers, react, or helia required.

createRadio

The primary entry point. A Cursor that's also directly AsyncIterable<RawLog[]>.

function createRadio(config: CursorConfig, options?: RadioOptions): Radio;

interface Radio extends Cursor {
[Symbol.asyncIterator](): AsyncGenerator<RawLog[]>;
}

for await (const logs of radio) replays scanned history in blockRangeLimit-sized chunks (newest-first, walking backward from the tip) until floorBlock is reached, then switches to polling the tip forever, yielding only non-empty chunks. Every Cursor method is also available directly on the returned object — streaming and imperative control aren't an either/or.

A plain factory, not a class — consistent with every other constructor in this package (createCursor, createViemAdapter, createMemoryStore, …).

radio

The lower-level generator createRadio is built on, for when you already have a Cursor (e.g. one built by createCursor directly) and don't want the combined Radio object:

function radio(cursor: Cursor, options?: RadioOptions): AsyncGenerator<RawLog[]>;

interface RadioOptions {
/** Delay between live-tail sync() polls once history is fully replayed. Default: 4000ms. */
pollIntervalMs?: number;
signal?: AbortSignal;
/** Caps how long the history-replay loop runs without yielding a macrotask tick back to the environment. Default: 50ms. */
yieldEveryMs?: number;
}

Cancellation is the standard for await...of + break pattern, or pass signal to cancel from outside the loop body (e.g. a React effect's cleanup).

yieldEveryMs matters when blockRangeLimit is small relative to how far back floorBlock sits — replaying history can then take hundreds or thousands of fetchHistory() iterations, each a genuinely async await. If those calls resolve quickly (a fast RPC, an empty stretch with no logs to yield), back-to-back promise resolutions with no macrotask boundary between them can still starve a browser's paint cycle even though nothing is technically blocking. radio() inserts a setTimeout(0) tick whenever more than yieldEveryMs has elapsed since the last one, regardless of whether a chunk had any logs.

Cursor

interface Cursor {
scanRange(fromBlock: bigint, toBlock: bigint): Promise<RawLog[]>;
sync(): Promise<RawLog[]>;
fetchHistory(): Promise<RawLog[]>;
fetchForward(): Promise<RawLog[]>;
getScannedSpans(): Promise<Span[]>;
getCellStates(tip: bigint): Promise<Cell[]>;
isFullyScanned(): Promise<boolean>;
getLiveSpan(): Promise<Span | undefined>;
getEarliestSpan(): Promise<Span | undefined>;
checkForReorg(fromBlock: bigint, toBlock: bigint): Promise<ReorgCheckResult>;
}
  • scanRange(fromBlock, toBlock) — the one primitive everything else delegates to: fetch logs in that window (chunked under the hood to respect blockRangeLimit), merge the window into the store's scanned spans, and return the logs found.
  • sync() — extend from wherever the live (tip-tailing) span currently ends up to the real chain tip, seeding a shallow initial window if nothing has been scanned yet. This is what createRadio/radio() call both first and on every live-tail poll.
  • fetchHistory() — extend the earliest known span one blockRangeLimit window further back toward floorBlock. A no-op once the floor is reached.
  • fetchForward() — only meaningful with atBlock configured: extend that anchor's span one window forward, toward the live span. A no-op if no atBlock was given, or once the anchored span has merged into the live span.
  • getCellStates(tip) — every blockRangeLimit-sized window between floorBlock and tip, tagged scanned/unscanned — the data a scan-progress grid renders from.
  • isFullyScanned() — true once exactly one span covers floorBlock through the live tip (an island span that merely touches the floor without having merged into the live span reports false).
  • checkForReorg(fromBlock, toBlock) — a manual, per-chunk reorg check, meant to sit next to a scan-progress grid as a "check for reorgs" action on whatever chunk the user clicks. See below for how it works and what it requires.

checkForReorg

type ReorgCheckResult =
| {status: 'unchecked'}
| {status: 'ok'}
| {status: 'reorged'; logs: RawLog[]};

A mined block's hash commits to every block below it via parentHash-chaining, so comparing one block's current hash against a previously recorded one for the same block number is enough to tell whether that block — or anything at or below it — was replaced. checkForReorg(fromBlock, toBlock) compares the current hash of toBlock against whatever was last recorded for it:

  • 'unchecked' — no baseline was recorded yet for this chunk (e.g. it was scanned before reorg-checking was wired up). One is established now; there's nothing to report until the next check.
  • 'ok' — the hash still matches. Nothing to do.
  • 'reorged' — the hash changed. The chunk has already been invalidated and reindexed by the time this resolves, and logs holds whatever scanRange(fromBlock, toBlock) found on the reindex.

This only reindexes the checked chunk itself — it deliberately doesn't cascade to chunks above it, matching a manual per-chunk audit workflow (a button on a scan-map grid) rather than continuous, automatic reorg tracking. If you suspect a deeper reorg, check the chunks above it too.

Requires two things CursorConfig doesn't need for anything else:

  • A provider whose getBlockHash is implemented — both createViemAdapter and createEthersAdapter provide it (see Adapters).
  • A checkpointStore: Store<Checkpoint[]> in CursorConfig — a plain Store, independent of the scan-progress store. createMemoryStore<Checkpoint>()/createLocalStorageStore<Checkpoint>()/createIndexedDbStore<Checkpoint>() all work, since every store factory is generic over what it persists.

Calling it without either throws — it's opt-in, not a requirement for every Cursor.

detectRpcCapabilities

Probes a LogsProvider to find the widest eth_getLogs range it actually supports — what step 2 of Usage uses instead of guessing a blockRangeLimit literal.

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

function detectRpcCapabilities(
provider: LogsProvider,
address: string,
toBlock: bigint,
onUpdate?: (results: RpcDoctorResults) => void,
): Promise<RpcDoctorResults>;

type RpcDoctorTestStatus = 'idle' | 'running' | 'pass' | 'fail';

interface RpcDoctorResults {
getLogs: RpcDoctorTestStatus;
maxBlockRange: bigint | null;
logErrors: string[];
}

function safeBlockRangeLimit(maxBlockRange: bigint): bigint;

Two tests run in sequence, each patching results and — if onUpdate is passed — calling it immediately rather than only resolving once the whole step-down finishes. Against a slow or rate-limited public RPC that step-down can take several seconds, and a UI that goes silent for that whole span reads as hung:

  1. getLogs — one sanity call over the last ~10 blocks. 'fail' here stops immediately (a provider that can't make one small eth_getLogs call at all can't do wider ones either) and maxBlockRange stays null.
  2. maxBlockRange — steps down through [2_000_000n, 500_000n, 100_000n, 50_000n, 10_000n, 5_000n, 1_000n, 500n, 100n, 10n, 5n, 1n], calling getLogs at each width until one succeeds. Every failed width's error lands in logErrors (so you can see exactly why — e.g. the RPC's real "block range too large" message) even once a narrower width has passed.

Full usage, destructuring everything it returns:

const {getLogs, maxBlockRange, logErrors} = await detectRpcCapabilities(
provider,
'0xYourContractAddress',
await provider.getBlockNumber(),
(progress) => console.log('probing…', progress), // optional: incremental updates
);

if (getLogs === 'fail') {
throw new Error(`This RPC can't serve eth_getLogs at all: ${logErrors.at(-1)}`);
}

const blockRangeLimit = maxBlockRange
? safeBlockRangeLimit(maxBlockRange) // backs off one block as a fencepost guard
: 2_000n; // every candidate width failed — fall back to a conservative default

safeBlockRangeLimit only adjusts for the range boundary itself — it doesn't protect against a secondary RPC cap keyed on response size or log count, which can still reject an in-range request against a denser event than whatever address detectRpcCapabilities probed with. If you expect heavy log volume, use CursorConfig's safetyPadding to shrink the actual scan window instead.

Why there's no eth_newFilter/eth_getFilterLogs check: LogsProvider only ever exposes a stateless getLogs — that's the one primitive chunkedFetchLogs/Cursor actually call, deliberately (see Adapters): a filter handle can silently go stale against a round-robin/load-balanced RPC backend if a later call lands on a different node than the one that created it. A one-shot capability probe couldn't detect that failure mode anyway — it's a statefulness bug that only shows up across multiple calls hitting different backend nodes over time, not something a single request can observe. Since this package never calls filter methods in the first place, there's nothing here to gate a decision on even if it could.

createCursor

function createCursor(config: CursorConfig): Cursor;

interface CursorConfig {
provider: LogsProvider;
store: SpanStore;
/** Storage key, caller-owned (e.g. `${chainId}:${address}:${topic0}`). */
key: string;
address: string | string[];
topics?: (string | string[] | null)[];
/** A floor below which logs can't exist (e.g. a contract's deployment block). */
floorBlock: bigint;
blockRangeLimit: bigint;
/** Optional multiplier (0–1] shrinking the actual scan window below blockRangeLimit. Default: 1.0 (no padding). */
safetyPadding?: number;
/** Optional forward-scan anchor — enables fetchForward() extending from a known block. */
atBlock?: bigint;
/** Required for checkForReorg() — where recorded header hashes persist. */
checkpointStore?: Store<Checkpoint[]>;
}

safetyPadding — a second, independent knob from safeBlockRangeLimit's fencepost adjustment: detectRpcCapabilities probes with whatever address you give it, but a real scan against a much busier contract (e.g. USDC's Transfer) can pack far more logs into the same block width than the probe ever saw, and some RPCs cap eth_getLogs by response size or log count rather than pure block width — a request that's technically in-range can still get rejected. Setting safetyPadding: 0.5, for example, uses half of blockRangeLimit as the actual window width, trading more getLogs calls for headroom against that. Leave it unset unless you have a specific reason to expect dense logs — most contracts don't need it.

createRadio/radio() compose a Cursor for you; reach for createCursor directly when you want the imperative interface without a stream — e.g. driving a scan-progress UI where the user clicks "load more history" instead of an always-on for await.

No caching between calls — every method re-reads the store fresh rather than trusting possibly-stale in-memory state, so it stays correct if you have two calls in flight at once (e.g. fetchHistory() and fetchForward() both running).

LogsProvider

interface LogsProvider {
getBlockNumber(): Promise<bigint>;
getLogs(params: GetLogsParams): Promise<RawLog[]>;
/** Optional — required only for checkForReorg(). Both shipped adapters implement it. */
getBlockHash?(blockNumber: bigint): Promise<string>;
}

interface GetLogsParams {
address: string | string[];
topics?: (string | string[] | null)[];
fromBlock: bigint;
toBlock: bigint;
}

interface RawLog {
address: string;
topics: string[];
data: string;
blockNumber: bigint;
transactionHash: string;
logIndex: number;
blockHash: string;
transactionIndex: number;
removed?: boolean;
}

The minimal client-agnostic surface everything in this package is built on. See Adapters for the createViemAdapter/createEthersAdapter factories that produce one from a client/provider you already constructed. Decoding topics/data into typed event arguments is intentionally out of scope — see Installation.

Internal building blocks

A few smaller pieces the above compose, exported (via the ./core/* subpath) mainly so their pure logic is independently testable — most consumers won't reach for these directly:

  • Span {fromBlock, toBlock}, mergeSpan(spans, span), subtractSpan(spans, span), cellStates(spans, floor, tip, blockRangeLimit) (@ethereum-radio/indexer/core/spans) — the sparse "what's already been scanned" representation Cursor is built on. subtractSpan is what checkForReorg uses to carve an invalidated chunk back out.
  • Checkpoint {blockNumber, hash}, recordCheckpoint, findCheckpoint, removeCheckpointsFrom (@ethereum-radio/indexer/core/checkpoints) — the sparse "last known-canonical hash per block" record checkForReorg reads and writes.
  • clampFromBlock, chunkedFetchLogs, fetchAllLogs (@ethereum-radio/indexer/core/chunked-logs) — splits a wide range into blockRangeLimit-sized getLogs calls.