diff --git a/.changeset/precompiles-get-logs-in-range.md b/.changeset/precompiles-get-logs-in-range.md new file mode 100644 index 00000000..0638f949 --- /dev/null +++ b/.changeset/precompiles-get-logs-in-range.md @@ -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. diff --git a/packages/precompiles/README.md b/packages/precompiles/README.md index e882c20e..b6128ad1 100644 --- a/packages/precompiles/README.md +++ b/packages/precompiles/README.md @@ -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 }, … ] +``` diff --git a/packages/precompiles/src/index.ts b/packages/precompiles/src/index.ts index 8159fd16..01e602a7 100644 --- a/packages/precompiles/src/index.ts +++ b/packages/precompiles/src/index.ts @@ -1,3 +1,4 @@ export * from './ethers'; export * from './precompiles'; export * from './viem/chain'; +export * from './viem/logs'; diff --git a/packages/precompiles/src/viem/__tests__/logs.spec.ts b/packages/precompiles/src/viem/__tests__/logs.spec.ts new file mode 100644 index 00000000..254f2664 --- /dev/null +++ b/packages/precompiles/src/viem/__tests__/logs.spec.ts @@ -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 = {}; + const client = { + getBlockNumber: async () => 10n, + getLogs: async (args: Record) => { + received = args; + return []; + } + } as unknown as PublicClient; + await getLogsInRange(client, { fromBlock: 0n }); + expect('address' in received).toBe(false); + expect('event' in received).toBe(false); + }); +}); diff --git a/packages/precompiles/src/viem/index.ts b/packages/precompiles/src/viem/index.ts index 0a68546c..e171637b 100644 --- a/packages/precompiles/src/viem/index.ts +++ b/packages/precompiles/src/viem/index.ts @@ -1,2 +1,3 @@ export * from '../precompiles'; export * from './chain'; +export * from './logs'; diff --git a/packages/precompiles/src/viem/logs.ts b/packages/precompiles/src/viem/logs.ts new file mode 100644 index 00000000..0bec77e1 --- /dev/null +++ b/packages/precompiles/src/viem/logs.ts @@ -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 { + /** 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( + client: PublicClient, + options: GetLogsInRangeOptions +): Promise> { + 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; + 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[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 }; + } +}