Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions .changeset/precompiles-get-logs-in-range.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
---
'@sei-js/precompiles': minor
---

Add `getLogsInRange`, `blockRanges` and `MAX_GET_LOGS_BLOCK_RANGE` for reading logs across a block range.

`eth_getLogs` is capped per call, so reading any history longer than the cap means walking it in chunks. That loop is short but has two failure modes that both look like a working indexer, and every project that needs logs writes it again.

The first is the inclusive boundary. The public endpoints allow 2000 blocks and apply the check as `toBlock - fromBlock + 1 <= 2000`, so a range built as `from + 2000` asks for 2001 blocks and is rejected on every chunk with `block range too large (2001), maximum allowed is 2000 blocks`. Measured on both networks: 2000 succeeds, 2001 does not. Writing the loop conservatively at half the cap works but doubles the round trips a backfill needs.

The second is the confirmation depth most EVM indexing code carries by default. Sei finalises a block as it is produced, so there is no reorg window to wait out; a default lag copied from an Ethereum-shaped library is latency with nothing behind it. `getLogsInRange` reads to head, and a caller who wants to lag head passes an explicit `toBlock`.

`blockRanges` exposes the same arithmetic as a generator without making requests, so a caller can plan a backfill or drive a bounded worker pool instead of one sequential loop. `onChunk` reports progress, because a backfill over long history is thousands of requests and is otherwise indistinguishable from a hang.

No dependency or peer range changes: this uses the `viem` peer already declared, and `PublicClient` is accepted rather than constructed so it works with whatever transport and chain the caller has configured.
40 changes: 40 additions & 0 deletions packages/precompiles/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,3 +69,43 @@ Import the canonical Sei mainnet and testnet definitions from the package root o
import { sei, seiTestnet } from '@sei-js/precompiles';
// or: import { sei, seiTestnet } from '@sei-js/precompiles/viem';
```

## Reading logs across a block range

`eth_getLogs` is capped per call, so any history longer than the cap has to be
walked in chunks. `getLogsInRange` does that walk with ranges the node accepts:

```ts
import { createPublicClient, http, parseAbiItem } from 'viem';
import { getLogsInRange, seiTestnet } from '@sei-js/precompiles';

const client = createPublicClient({ chain: seiTestnet, transport: http() });

const logs = await getLogsInRange(client, {
address: '0x…',
event: parseAbiItem('event Transfer(address indexed from, address indexed to, uint256 value)'),
fromBlock: 267_000_000n,
onChunk: ({ toBlock, head }) => console.log(`${toBlock}/${head}`)
});
```

`MAX_GET_LOGS_BLOCK_RANGE` is `2000n`, which is what the public endpoints
enforce on both networks. **The range is inclusive of both ends**, so the check
the node applies is `toBlock - fromBlock + 1 <= 2000`; a 2000-block span
succeeds and a 2001-block span is rejected with `block range too large (2001),
maximum allowed is 2000 blocks`. Pass a smaller `chunkSize` for a provider with
a tighter limit.

Reads run to the current head. Sei finalises a block as it is produced, so
there is no reorg window to wait out and no confirmation depth to subtract —
pass an explicit `toBlock` if you want to lag head deliberately.

`blockRanges` exposes the same arithmetic without making requests, for planning
a backfill or driving a bounded worker pool:

```ts
import { blockRanges } from '@sei-js/precompiles';

const chunks = [...blockRanges(1_000_000n, 1_006_000n)];
// [{ fromBlock: 1000000n, toBlock: 1001999n }, … ]
```
1 change: 1 addition & 0 deletions packages/precompiles/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
export * from './ethers';
export * from './precompiles';
export * from './viem/chain';
export * from './viem/logs';
156 changes: 156 additions & 0 deletions packages/precompiles/src/viem/__tests__/logs.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
import type { PublicClient } from 'viem';
import * as packageRoot from '../../index';
import * as viemEntryPoint from '../index';
import { blockRanges, getLogsInRange, MAX_GET_LOGS_BLOCK_RANGE } from '../logs';

/**
* A client that records the ranges it was asked for and returns one log per
* call, so the tests assert on the REQUESTS rather than on a node's answers.
* The behaviour worth pinning here is the chunking arithmetic, which is where
* this goes wrong in practice.
*/
function recordingClient(head: bigint) {
const calls: Array<{ fromBlock: bigint; toBlock: bigint }> = [];
const client = {
getBlockNumber: async () => head,
getLogs: async (args: { fromBlock: bigint; toBlock: bigint }) => {
calls.push({ fromBlock: args.fromBlock, toBlock: args.toBlock });
return [{ blockNumber: args.fromBlock }];
}
} as unknown as PublicClient;
return { client, calls };
}

describe('MAX_GET_LOGS_BLOCK_RANGE', () => {
it('is the 2000 the public endpoints enforce', () => {
expect(MAX_GET_LOGS_BLOCK_RANGE).toBe(2000n);
});

it('is exported from the viem entry point and the package root', () => {
expect(viemEntryPoint.MAX_GET_LOGS_BLOCK_RANGE).toBe(MAX_GET_LOGS_BLOCK_RANGE);
expect(packageRoot.MAX_GET_LOGS_BLOCK_RANGE).toBe(MAX_GET_LOGS_BLOCK_RANGE);
});
});

describe('blockRanges', () => {
it('spans are INCLUSIVE, so a full chunk is exactly the maximum', () => {
// The node checks `toBlock - fromBlock + 1 <= 2000`. A range built as
// `from + MAX` asks for 2001 blocks and every request is rejected.
const [first] = [...blockRanges(0n, 10_000n)];
expect(first).toEqual({ fromBlock: 0n, toBlock: 1999n });
expect(first!.toBlock - first!.fromBlock + 1n).toBe(MAX_GET_LOGS_BLOCK_RANGE);
});

it('never emits a range wider than the chunk size', () => {
for (const r of blockRanges(0n, 10_005n)) {
expect(r.toBlock - r.fromBlock + 1n).toBeLessThanOrEqual(MAX_GET_LOGS_BLOCK_RANGE);
}
});

it('covers the whole span with no gaps and no overlap', () => {
// A gap silently drops logs; an overlap silently duplicates them. Both
// look like a working indexer.
const ranges = [...blockRanges(100n, 5_100n)];
expect(ranges[0]!.fromBlock).toBe(100n);
expect(ranges[ranges.length - 1]!.toBlock).toBe(5_100n);
for (let i = 1; i < ranges.length; i++) {
expect(ranges[i]!.fromBlock).toBe(ranges[i - 1]!.toBlock + 1n);
}
});

it('handles a single block', () => {
expect([...blockRanges(7n, 7n)]).toEqual([{ fromBlock: 7n, toBlock: 7n }]);
});

it('yields nothing when the range is empty', () => {
expect([...blockRanges(10n, 9n)]).toEqual([]);
});

it('rejects a chunk size below one rather than looping forever', () => {
expect(() => [...blockRanges(0n, 10n, 0n)]).toThrow(/at least 1/);
});
});

describe('getLogsInRange', () => {
it('requests spans the node will accept', async () => {
const { client, calls } = recordingClient(4_500n);
await getLogsInRange(client, { fromBlock: 0n });
expect(calls).toEqual([
{ fromBlock: 0n, toBlock: 1999n },
{ fromBlock: 2000n, toBlock: 3999n },
{ fromBlock: 4000n, toBlock: 4500n }
]);
for (const c of calls) {
expect(c.toBlock - c.fromBlock + 1n).toBeLessThanOrEqual(MAX_GET_LOGS_BLOCK_RANGE);
}
});

it('reads to head when no toBlock is given', async () => {
// Sei finalises as it produces, so there is no reorg window to wait out
// and no confirmation depth to subtract.
const { client, calls } = recordingClient(1_234n);
await getLogsInRange(client, { fromBlock: 0n });
expect(calls[calls.length - 1]!.toBlock).toBe(1_234n);
});

it('stops at an explicit toBlock rather than at head', async () => {
const { client, calls } = recordingClient(9_999n);
await getLogsInRange(client, { fromBlock: 0n, toBlock: 100n });
expect(calls).toEqual([{ fromBlock: 0n, toBlock: 100n }]);
});

it('concatenates the logs from every chunk', async () => {
const { client } = recordingClient(4_500n);
const logs = await getLogsInRange(client, { fromBlock: 0n });
expect(logs.length).toBe(3);
});

it('makes no request when the range is empty', async () => {
const { client, calls } = recordingClient(100n);
const logs = await getLogsInRange(client, { fromBlock: 500n });
expect(calls).toEqual([]);
expect(logs).toEqual([]);
});

it('reports progress per chunk', async () => {
// A backfill over long history is thousands of requests; with no signal
// it is indistinguishable from a hang.
const { client } = recordingClient(4_500n);
const seen: Array<{ toBlock: bigint; head: bigint; logs: number }> = [];
await getLogsInRange(client, {
fromBlock: 0n,
onChunk: ({ toBlock, head, logs }) => seen.push({ toBlock, head, logs })
});
expect(seen.length).toBe(3);
expect(seen[0]).toEqual({ toBlock: 1999n, head: 4500n, logs: 1 });
expect(seen[seen.length - 1]!.toBlock).toBe(4_500n);
});

it('honours a smaller chunk size for a stricter provider', async () => {
const { client, calls } = recordingClient(2_500n);
await getLogsInRange(client, { fromBlock: 0n, chunkSize: 1_000n });
expect(calls.length).toBe(3);
expect(calls[0]).toEqual({ fromBlock: 0n, toBlock: 999n });
});

it('rejects a chunk size below one', async () => {
const { client } = recordingClient(10n);
await expect(getLogsInRange(client, { fromBlock: 0n, chunkSize: 0n })).rejects.toThrow(/at least 1/);
});

it('omits address and event from the request when not given', async () => {
// Passing `address: undefined` through to eth_getLogs is not the same as
// omitting it on every provider.
let received: Record<string, unknown> = {};
const client = {
getBlockNumber: async () => 10n,
getLogs: async (args: Record<string, unknown>) => {
received = args;
return [];
}
} as unknown as PublicClient;
await getLogsInRange(client, { fromBlock: 0n });
expect('address' in received).toBe(false);
expect('event' in received).toBe(false);
});
});
1 change: 1 addition & 0 deletions packages/precompiles/src/viem/index.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
export * from '../precompiles';
export * from './chain';
export * from './logs';
146 changes: 146 additions & 0 deletions packages/precompiles/src/viem/logs.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
import type { AbiEvent, Address, GetLogsReturnType, PublicClient } from 'viem';

/**
* The largest `fromBlock`..`toBlock` span Sei's public EVM RPC accepts for a
* single `eth_getLogs` call.
*
* The range is **inclusive of both ends**, so the check the node applies is
* `toBlock - fromBlock + 1 <= 2000`. Measured against both public endpoints on
* 2026-08-28: a 2000-block span succeeds and a 2001-block span is rejected with
*
* ```
* block range too large (2001), maximum allowed is 2000 blocks
* ```
*
* The inclusive boundary is the part worth encoding. Writing the loop as
* `to = from + MAX` rather than `from + MAX - 1` asks for 2001 blocks and fails
* on every chunk, and writing it as `from + 1000` works but doubles the number
* of round trips a backfill needs.
*
* @category Logs
*/
export const MAX_GET_LOGS_BLOCK_RANGE = 2000n;

/**
* Options for {@link getLogsInRange}.
*
* @category Logs
*/
export interface GetLogsInRangeOptions<TAbiEvent extends AbiEvent | undefined = undefined> {
/** Contract to read. Omit to read every address, as `eth_getLogs` does. */
address?: Address | Address[];
/** A single event to decode. Omit for raw logs. */
event?: TAbiEvent;
/** First block to include. Inclusive. */
fromBlock: bigint;
/** Last block to include. Inclusive. Defaults to the current head. */
toBlock?: bigint;
/**
* Blocks per request. Defaults to {@link MAX_GET_LOGS_BLOCK_RANGE}.
*
* Lower it for a provider with a smaller cap than the public endpoints, or
* when a range that wide returns more logs than you want to hold at once.
*/
chunkSize?: bigint;
/**
* Called after each chunk, for progress on a long backfill.
*
* A backfill over months of history is thousands of requests; without a
* progress signal it is indistinguishable from a hang.
*/
onChunk?: (progress: { fromBlock: bigint; toBlock: bigint; head: bigint; logs: number }) => void;
}

/**
* Read logs across a block range, in requests the node will accept.
*
* `eth_getLogs` is capped per call, so any history longer than the cap has to
* be walked in chunks. That loop is small but easy to get wrong in two ways
* that both look like a working indexer: an off-by-one on the inclusive range
* that fails every request, and a chunk size that silently truncates on a
* provider with a tighter limit.
*
* Sei needs **no confirmation depth**. Its consensus finalises a block as it is
* produced, so there is no reorg window to wait out and reading to head is
* safe. Most EVM indexing code carries a `confirmations` setting defaulted to
* something like 12 because Ethereum needs it; on Sei that is pure added
* latency. This reads to head, and a caller who wants a lag can pass an
* explicit `toBlock`.
*
* @example
* ```ts
* import { createPublicClient, http, parseAbiItem } from 'viem';
* import { getLogsInRange, seiTestnet } from '@sei-js/precompiles';
*
* const client = createPublicClient({ chain: seiTestnet, transport: http() });
*
* const logs = await getLogsInRange(client, {
* address: '0x…',
* event: parseAbiItem('event Transfer(address indexed from, address indexed to, uint256 value)'),
* fromBlock: 267_000_000n,
* onChunk: ({ toBlock, head }) => console.log(`${toBlock}/${head}`)
* });
* ```
*
* @category Logs
*/
export async function getLogsInRange<TAbiEvent extends AbiEvent | undefined = undefined>(
client: PublicClient,
options: GetLogsInRangeOptions<TAbiEvent>
): Promise<GetLogsReturnType<TAbiEvent>> {
const chunkSize = options.chunkSize ?? MAX_GET_LOGS_BLOCK_RANGE;
if (chunkSize < 1n) {
throw new Error(`chunkSize must be at least 1, received ${chunkSize}`);
}

const head = options.toBlock ?? (await client.getBlockNumber());
const out = [] as unknown as GetLogsReturnType<TAbiEvent>;
if (options.fromBlock > head) return out;

for (let from = options.fromBlock; from <= head; from += chunkSize) {
// INCLUSIVE on both ends, which is why this is `- 1n`. Without it every
// request asks for chunkSize + 1 blocks and the node rejects all of them.
const last = from + chunkSize - 1n;
const to = last > head ? head : last;

const logs = await client.getLogs({
...(options.address === undefined ? {} : { address: options.address }),
...(options.event === undefined ? {} : { event: options.event }),
fromBlock: from,
toBlock: to
} as Parameters<PublicClient['getLogs']>[0]);

out.push(...(logs as typeof out));
options.onChunk?.({ fromBlock: from, toBlock: to, head, logs: logs.length });
}

return out;
}

/**
* The block ranges {@link getLogsInRange} would request, without making any.
*
* Useful for planning a backfill — how many requests it will take, or to drive
* a bounded worker pool rather than one sequential loop.
*
* @example
* ```ts
* const chunks = [...blockRanges(1_000_000n, 1_006_000n)];
* // [{ fromBlock: 1000000n, toBlock: 1001999n }, … ]
* ```
*
* @category Logs
*/
export function* blockRanges(
fromBlock: bigint,
toBlock: bigint,
chunkSize: bigint = MAX_GET_LOGS_BLOCK_RANGE
): Generator<{ fromBlock: bigint; toBlock: bigint }> {
if (chunkSize < 1n) {
throw new Error(`chunkSize must be at least 1, received ${chunkSize}`);
}
for (let from = fromBlock; from <= toBlock; from += chunkSize) {
const last = from + chunkSize - 1n;
yield { fromBlock: from, toBlock: last > toBlock ? toBlock : last };
}
}
Loading