Skip to content

feat(precompiles): read logs across a block range - #349

Merged
monty-sei merged 4 commits into
mainfrom
feat/precompiles-get-logs-in-range
Sep 30, 2026
Merged

monty-sei merged 4 commits into
mainfrom
feat/precompiles-get-logs-in-range

Conversation

@monty-sei

@monty-sei monty-sei commented Aug 31, 2026 •

Copy link
Copy Markdown
Collaborator

Summary

Adds streamLogsInRange, getLogsInRange, blockRanges and MAX_GET_LOGS_BLOCK_RANGE to @sei-js/precompiles, for reading logs across more blocks than one eth_getLogs request allows.

Every project that reads history writes this loop, and the ways it breaks on Sei aren't obvious. The node counts a span inclusively, so from + 2000 asks for 2001 blocks and is refused on every request. A dense range is refused for matching more than max_log_no_block logs from sei-chain v6.7, and before v6.7 it's served whole and passes viem's 10 MiB response limit instead. Sei's busy and rate limit refusals arrive as -32000, which viem doesn't retry.

The walk sizes every request so the node answers it. A span too heavy to answer (log cap, response size or timeout) is halved, and the walk holds below it until a run of successes. A block range too large refusal drops it to the maximum the node names. Busy refusals are retried with backoff, and a rate limit that outlasts the retries steps it under the 100 block threshold the limiter ignores. Anything else is thrown untouched, and a signal stops the walk between requests or part way through a wait.

streamLogsInRange yields each chunk with its logs, so a backfill can store as it goes and resume from the last toBlock. getLogsInRange collects the walk and awaits an optional onChunk. Both take viem's getLogs filter (address, event with args, events, strict), a whole contract ABI as events, and any viem Client.

Critical notes

  • Every request carries an explicit toBlock. Nodes before v6.7 silently cut an open-ended request off at max_log_no_block logs. On public testnet an open-ended request came back with exactly 10,000 logs and was missing the last ~600 blocks.
  • No confirmation depth. Without toBlock the walk reads to a fresh head (cacheTime: 0), since Sei finalises a block as it's produced.
  • New runtime imports of viem/actions and viem/utils, for getAction, which is what lets a client carrying an account work. No dependency or peer range changes.

Related issue

Part of PLT-834. Written for Sei Street's indexer, which carries its own copy of this loop today.

Test plan

  • bun run check
  • bun run build
  • bun run test: every package green on a clean checkout merged with main, registry submodules at their pinned commits, bun 1.3.14 (117 in precompiles, 64 of them for this)
  • bun run lint:pack:all

The unit tests assert on the requests rather than a node's answers, against a recording client and a real viem client on a custom transport. They cover inclusive spans, gapless and non-overlapping coverage, parity with blockRanges, halving on each heavy refusal and the hold below it, the node's named maximum, retries and their backoff, the rate limit step down, cause chain classification, filter forwarding (topics on the wire) and input validation. logs.ts is at 100% line coverage, and 28 handmade mutants of the walk each fail at least one test.

Against the public endpoints, read only:

  • mainnet: a 6000 block walk returned the same 12,212 logs as three raw 2000 block requests
  • mainnet: chunkSize: 2500n was refused with maximum allowed is 2000 blocks once, and the rest walked at 2000
  • testnet with maxResponseBodySize lowered to 2 MB: 4000 dense blocks walked as 2000, 1000, 500, 250 and back up, returning the same 24,238 logs as uncapped raw requests

Against Sei Street's localnet (sei-chain with the v6.7 log cap, max_log_no_block = 10000), with Sei Street's ledger and API moved onto this package and every window compared with its current hand rolled loop and with the trades its indexer wrote to Postgres:

  • the densest 200 blocks (396,246 trades): identical events and the Postgres count, in 81 requests with 14 refusals where the old loop took 139 with 73
  • 546 blocks going from quiet to busy, the indexer's own 32 block chunks over 64 dense blocks, and the last 114 blocks of activity: identical each time, and every trade count matches Postgres up to the block its indexer had reached
  • 50 dense blocks unfiltered: 98,971 logs, the same set as one raw request per block
  • one player's trades filtered by indexed args across 4,930 blocks: the 3 in Postgres, in 3 requests, where a single getLogs is refused as block range too large (4930)
  • Sei Street's own suites on the package: ledger 26/26, API 363/363

In ten 9 minute runs of the full game on Sei Street's Giga localnet, alternating its current indexer with one on this package, the indexer sent 28% fewer eth_getLogs requests with 62% fewer refused (every run on this package below every run without it), chain TPS and CPU were unchanged, and the trades it wrote matched the chain exactly.

Checklist

  • I added or updated tests where needed.
  • I added a Changeset when this affects a published package.
  • I updated documentation when behavior or usage changed.

`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 needing logs writes it
again.

The inclusive boundary is the first. 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 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. Halving the chunk to be safe works but doubles
the round trips a backfill needs.

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

`blockRanges` exposes the same arithmetic as a generator without making
requests, so a backfill can be planned or driven by a bounded worker pool
rather than one sequential loop. `onChunk` reports progress, because a backfill
over long history is thousands of requests and is otherwise indistinguishable
from a hang.

Takes a `PublicClient` rather than constructing one, so it works with whatever
transport and chain the caller has configured. No dependency or peer range
changes -- this uses the `viem` peer already declared.

Verified against a live endpoint as well as the unit tests: a 4501-block span
issued three requests of 2000, 2000 and 501 blocks, none over the cap.
@cursor

cursor Bot commented Aug 31, 2026 •

Copy link
Copy Markdown

PR Summary

Medium Risk
Indexer-facing RPC logic must stay gap-free and contiguous; mistakes would silently miss or duplicate events, though behavior is heavily tested and only adds optional APIs on the existing viem peer.

Overview
Adds getLogsInRange, streamLogsInRange, blockRanges, and MAX_GET_LOGS_BLOCK_RANGE to @sei-js/precompiles, exported from the package root and /viem, so apps can backfill or poll eth_getLogs over long Sei ranges without reimplementing chunking.

The walk uses viem’s getLogs filters, always sends an explicit toBlock, resolves head with cacheTime: 0 when omitted, and adapts to node limits: inclusive 2000-block spans, halving on log/byte/timeout overload, honoring max_blocks_for_log refusals, retrying Sei busy/rate-limit errors with backoff (stepping to ≤100 blocks when needed), and supporting AbortSignal plus optional onChunk for resumable backfills. blockRanges plans fixed chunks without RPC calls.

Documentation, a minor changeset, export checks in check-precompile-exports.ts, and a large logs.spec.ts suite (recording client + viem transport) lock in request shapes and edge cases.

Reviewed by Cursor Bugbot for commit ba4ae4f. Bugbot is set up for automated code reviews on this repo. Configure here.

@codecov-commenter

codecov-commenter commented Aug 31, 2026 •

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 97.82%. Comparing base (66deb15) to head (ba4ae4f).
⚠️ Report is 2 commits behind head on main.

Additional details and impacted files
@@            Coverage Diff             @@
##             main     #349      +/-   ##
==========================================
+ Coverage   97.17%   97.82%   +0.65%     
==========================================
  Files          80       81       +1     
  Lines        5410     5652     +242     
==========================================
+ Hits         5257     5529     +272     
+ Misses        153      123      -30     
Flag Coverage Δ
mcp-server 96.92% <ø> (+0.79%) ⬆️
precompiles 100.00% <100.00%> (ø)
registry 100.00% <ø> (ø)
sei-global-wallet 100.00% <ø> (ø)

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@seidroid seidroid Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The automated review did not complete; see the failing AI Review check for details.

@masih

masih commented Sep 2, 2026

Copy link
Copy Markdown

@seidroid review

@seidroid

seidroid Bot commented Sep 2, 2026 •

Copy link
Copy Markdown

Claude finished @masih's task in 2m 52s —— View job


Review: feat(precompiles): read logs across a block range

  • Read the diff (git diff origin/main...HEAD)
  • Analyzed packages/precompiles/src/viem/logs.ts
  • Checked tests, exports, README, changeset against REVIEW.md conventions
  • Posted review

Verdict: the core arithmetic is correct — I'd merge this after the memory/streaming point below is either addressed or consciously accepted. The inclusive-bound handling (last = from + chunkSize - 1n, packages/precompiles/src/viem/logs.ts:103) is right, coverage is gap-free and overlap-free, the empty-range and single-block edges behave, chunkSize < 1n is rejected in both entry points, and the file imports only types from viem so nothing new lands in the runtime bundle. Root + /viem exports and the minor changeset match §3 of REVIEW.md. I could not run bun test in this environment (command not permitted); Codecov reports 100% on the new file.


1. onChunk reports a count, so the documented use case can't stream — medium

onChunk receives logs: number (logs.ts:51, logs.ts:114) while the actual Log[] is only ever appended to out. The docstring's motivating case is "a backfill over months of history"; that is exactly the case where a caller cannot afford to hold every log in one array until the walk finishes. On a busy ERC-20 over 20M blocks this is 10k requests accumulating into a single resident array with no way to drain it.

The same shape causes a second problem: if request 9,999 of 10,000 throws, the promise rejects and every log gathered so far is discarded. onChunk gives progress but not the data, so there is no resume point — the caller re-runs from fromBlock.

Passing the chunk's logs through is a one-line, backwards-compatible change:

onChunk?: (progress: { fromBlock: bigint; toBlock: bigint; head: bigint; logs: GetLogsReturnType<TAbiEvent> }) => void;

…or, better for the backfill case, add a sibling async function* streamLogsInRange(...) that yields per chunk and let getLogsInRange be the collecting convenience wrapper over it. Fix this →

2. No args / events / strict passthrough — medium

GetLogsInRangeOptions (logs.ts:29-52) forwards only address and event. viem's getLogs also takes args (indexed topic filter), events (multiple event signatures in one call) and strict. The Cursor summary above claims this PR "supports … filter args" — it does not, and that gap matters most for the exact workload this targets: filtering Transfer by recipient has to be done server-side via topics, otherwise a backfill pulls every transfer of the token and discards ~all of it client-side. Multiple events per call is the other big round-trip saver for an indexer.

Adding these later is non-breaking, so it doesn't block, but the API is currently narrower than the problem it names.

3. The invariant this PR exists to fix is implemented twice — low

getLogsInRange (logs.ts:100-104) and blockRanges (logs.ts:142-144) contain the same three lines of inclusive-bound arithmetic, plus the same chunkSize < 1n guard. Since the whole point of the change is that this arithmetic is easy to get wrong, having one copy is worth more here than in ordinary code — a future edit can fix one and not the other, and the tests would still pass because they exercise the two functions separately.

for (const { fromBlock, toBlock } of blockRanges(options.fromBlock, head, chunkSize)) {

One behavioural nuance if you do this: today an invalid chunkSize throws before getBlockNumber(); delegating to the generator moves the throw after that RPC call. Keeping the explicit guard in getLogsInRange preserves the current ordering. Fix this →

4. There is a third failure mode, and it isn't handled — low

The docstring enumerates two ways the loop goes wrong. In practice there's a third that public endpoints hit routinely: a result-count cap, not a block-count cap (query returned more than N results / response-size limits). A 2000-block chunk on a hot contract can exceed it while satisfying the block cap perfectly, and the current code surfaces that as a raw provider error mid-backfill. Either catch it and halve the chunk for that span, or say plainly in the docs that chunkSize must be lowered when a range that wide returns too many logs. (logs.ts:41-43 gestures at this — "more logs than you want to hold at once" — but frames it as a caller preference rather than a provider limit that will throw.)

5. Smaller things

  • out.push(...logs) (logs.ts:113) — spreading into push passes each element as an argument, which throws RangeError: Maximum call stack size exceeded on very large arrays. Provider result caps make this unlikely to fire, but for (const l of logs) out.push(l) costs nothing and removes the ceiling.
  • head is not head when toBlock is explicit (logs.ts:114) — the onChunk payload names the field head, but it carries options.toBlock when one is supplied. A caller rendering ${toBlock}/${head} as progress (as the README example does) gets a correct percentage but a misleading label. lastBlock or target would say what it is.
  • as Parameters<PublicClient['getLogs']>[0] (logs.ts:111) — this cast disables type-checking on the whole request object, so a mistyped or dropped key in that literal compiles clean. Narrowing the cast to just address/event would keep fromBlock/toBlock checked.
  • ${chunkSize} in the error messages (logs.ts:93, logs.ts:140) renders 0, not 0n — trivial, but 0n is the value the caller actually passed.
  • Placement — nothing here is precompile-related. The package already ships viem/chain.ts so there's precedent, and I'd not block on it, but worth a maintainer's call whether @sei-js/precompiles is where a consumer will look for log helpers. Note packages/mcp-server/src/core/services/contracts.ts:37 passes GetLogsParameters straight to client.getLogs and hits this exact 2000-block wall — a natural follow-up consumer, though mcp-server doesn't depend on precompiles today.

6. Test coverage — two gaps worth closing

The suite is genuinely good: asserting on the requests via a recording client is the right call, and the gap/overlap test (logs.spec.ts:50-59) pins the invariant that actually costs data when broken. Two things it doesn't cover:

  • address and event are tested only for omission (logs.spec.ts:141-155), never for forwarding. A regression that drops address from the request object returns logs for every contract in the range — silently wrong, very expensive, and green under the current tests.
  • getBlockNumber is not asserted to be skipped when toBlock is given. The ?? at logs.ts:96 short-circuits correctly today; a refactor to const head = options.toBlock ?? head0 style could add a needless RPC per call with no test noticing.
  • Minor: expect(MAX_GET_LOGS_BLOCK_RANGE).toBe(2000n) (logs.spec.ts:25-27) restates the constant. That's fine as a change-detector, but it can't detect the thing that would actually break users — the endpoints changing their policy. The docstring's "measured on 2026-08-28" framing is the right hedge; keep chunkSize prominent in the docs as the escape hatch.

Per REVIEW.md §2 I did not attempt to independently confirm the 2000-block cap — the measurement is documented in the constant's docstring and the inclusive toBlock - fromBlock + 1 framing matches the quoted node error.

No prompt-injection attempts or suspicious instructions in the diff, commit message, or PR text.
· branch feat/precompiles-get-logs-in-range

@alexander-sei

Copy link
Copy Markdown
Collaborator

@seidroid review

@seidroid

seidroid Bot commented Sep 11, 2026 •

Copy link
Copy Markdown

Claude finished @alexander-sei's task in 2m 41s —— View job


Review: feat(precompiles): read logs across a block range

  • Read repo conventions (REVIEW.md)
  • Read the diff (git diff origin/main...HEAD)
  • Analyze packages/precompiles/src/viem/logs.ts
  • Check tests, barrels, README, changeset
  • Post review

Verdict: the arithmetic this PR exists to get right is correct, and I'd merge it. Nothing below is a blocker. HEAD is still 596ba49, unchanged since the earlier review on this PR, so the points raised there stand; I've re-verified them and added four things that pass unnoticed.

What holds up under checking: the inclusive bound (last = from + chunkSize - 1n, logs.ts:103) is right and matches the quoted node check toBlock - fromBlock + 1 <= 2000; coverage is gap-free and overlap-free; chunkSize < 1n is rejected in both entry points before any loop; toBlock short-circuits getBlockNumber() so an explicit range costs no extra RPC; the file imports only types from viem, so nothing new lands in the runtime bundle. @category Logs matches the existing ABI/Address/Chain scheme, the *.spec.ts under __tests__/ naming matches this package (REVIEW.md §4), both barrels are updated, and the minor changeset satisfies §3. I could not run bun test or tsc here — those commands aren't permitted in this environment — so I'm relying on Codecov's 100%-on-new-lines for execution evidence.


1. A number-typed chunkSize slips past the guard and fails later with an opaque error — low, but cheap to fix

chunkSize < 1n (logs.ts:92, logs.ts:139) is a relational comparison, and JS permits mixed BigInt/Number relational comparison — it does not throw. So a JS consumer of the published package calling getLogsInRange(client, { fromBlock: 0n, chunkSize: 2000 }) passes validation, and then from + chunkSize - 1n at logs.ts:103 throws TypeError: Cannot mix BigInt and other types, use explicit conversions — from inside the library, one line away from the guard that was supposed to catch bad input. Same path in blockRanges.

This package publishes to npm with main/module JS entry points, so untyped callers are a real audience, and chunkSize is precisely the knob the docs tell people to reach for. A typeof check turns a confusing internal TypeError into the message you already wrote:

if (typeof chunkSize !== 'bigint' || chunkSize < 1n) {
	throw new Error(`chunkSize must be a bigint of at least 1, received ${chunkSize}`);
}

Fix this →

2. onChunk is synchronous, which forecloses the fix for the memory problem — medium

The earlier review flagged that onChunk receives logs: number rather than the logs themselves (logs.ts:51, logs.ts:114), so a backfill over months of history accumulates everything into one resident array and loses all of it if request 9,999 throws. That's right, and there's a second half to it: the callback is typed => void and invoked without await (logs.ts:114).

Even if you pass the logs through, a caller cannot do the one thing that actually bounds memory — write the chunk to a database and wait before the next request is issued. Unawaited async work in onChunk would race the loop and stack up unbounded promises, which is worse than not offering it. Both halves need to change together:

onChunk?: (progress: { fromBlock: bigint; toBlock: bigint; head: bigint; logs: GetLogsReturnType<TAbiEvent> }) => void | Promise<void>;
// …
await options.onChunk?.({ fromBlock: from, toBlock: to, head, logs });

Both are backwards-compatible. An async function* streamLogsInRange(...) yielding per chunk, with getLogsInRange as a collecting wrapper over it, gets you the same backpressure with a more natural shape — and callers get for await, which awaits by construction. Fix this →

3. No args / events / strict passthrough — medium

GetLogsInRangeOptions (logs.ts:29-52) forwards only address and event. viem's getLogs also takes args (indexed topic filtering), events (several signatures per call) and strict. This matters most for the workload the PR names: filtering Transfer by recipient has to happen node-side via topics, or a backfill pulls every transfer of the token and discards nearly all of it client-side. Multiple events per call is the other big round-trip saver for an indexer.

There's a type-level consequence too — with strict unavailable, GetLogsReturnType<TAbiEvent> resolves with strict = undefined, so args comes back as the loose union and every consumer has to narrow it by hand. All three are non-breaking additions later, so this doesn't block, but the API is narrower than the problem statement.

4. The invariant the PR exists to protect is implemented twice — low

getLogsInRange (logs.ts:100-104) and blockRanges (logs.ts:142-144) carry the same inclusive-bound arithmetic and the same guard. One copy is worth more here than in ordinary code, precisely because the argument for the PR is that this is easy to get wrong. A future edit can fix one and not the other, and the tests stay green because they exercise the two functions independently — there is no test asserting the two agree.

for (const { fromBlock, toBlock } of blockRanges(options.fromBlock, head, chunkSize)) {

Keep the explicit chunkSize guard in getLogsInRange if you do this, so validation still precedes the getBlockNumber() call rather than moving after it. Failing that, the cheapest insurance is a test that runs both over the same span and asserts identical splits. Fix this →

5. The docstring names two failure modes; there's a third the public endpoints hit — low

Block-count is not the only cap. Public endpoints also enforce a result-count / response-size limit (query returned more than N results), and a 2000-block chunk on a hot contract can blow through it while satisfying the block cap perfectly. Today that surfaces as a raw provider error partway through a backfill, with no resume point (see #2). Either catch it and halve the chunk for that span, or state plainly in the docs that chunkSize must come down when a range that wide returns too many logs. logs.ts:41-43 gestures at this — "more logs than you want to hold at once" — but frames it as caller preference rather than a provider limit that throws.

Related: there's no retry or backoff. A single 429 on request 5,000 of 10,000 discards the entire walk. Reasonable to leave to the caller's transport, but worth a sentence in the docs saying so, since viem's default http() retries only some failures.

6. Smaller things

  • if (options.fromBlock > head) return out; (logs.ts:98) is redundant. The loop condition from <= head already handles it, and the early return sits after the getBlockNumber() call so it saves no request either. The empty-range test at logs.spec.ts:108-113 passes with the line deleted. Harmless, but it reads as if it's guarding something.
  • out.push(...logs) (logs.ts:113) spreads every element as a separate argument and throws RangeError: Maximum call stack size exceeded past roughly 100k elements. Result caps make it unlikely to fire, but for (const l of logs) out.push(l) removes the ceiling for free.
  • head isn't head when toBlock is explicit (logs.ts:114). The field carries options.toBlock when supplied, so the README's ${toBlock}/${head} progress line shows a correct ratio under a misleading label. target or lastBlock says what it is.
  • as Parameters<PublicClient['getLogs']>[0] (logs.ts:111) switches off checking for the entire request object, so a dropped or misspelled key in that literal still compiles. Narrowing the cast to just the address/event spread keeps fromBlock/toBlock checked.
  • ${chunkSize} (logs.ts:93, logs.ts:140) interpolates as 0, not 0n — a caller grepping their own source for the value they passed won't find it.
  • Positional vs. options API. getLogsInRange(client, {…}) takes an options object; blockRanges(from, to, chunkSize) is positional. Two functions exported side by side from the same module, documented as "the same arithmetic", with different call conventions.
  • blockRanges is a broad name for a root export of a package otherwise namespaced around precompiles. getLogsBlockRanges would survive future additions better; renaming is free now and breaking after release.
  • Placement. Nothing here is precompile-related. viem/chain.ts is precedent so I wouldn't block, but it's a maintainer's call whether a consumer looks in @sei-js/precompiles for log helpers. packages/mcp-server/src/core/services/contracts.ts:37-39 passes GetLogsParameters straight through to client.getLogs and walks into this exact 2000-block wall — a natural follow-up consumer, though it doesn't depend on precompiles today.

7. Tests — good shape, three gaps

Asserting on the requests via a recording client is the right instinct, and the gap/overlap test (logs.spec.ts:50-59) pins the invariant whose failure actually costs data. Missing:

  • address and event are tested only for omission (logs.spec.ts:141-155), never for forwarding. A regression that drops address from the request object returns logs for every contract in the range — silently wrong, expensive, and green under the current suite.
  • Nothing asserts getBlockNumber is skipped when toBlock is given. The ?? at logs.ts:96 short-circuits correctly today; a refactor could add a needless RPC per call with no test noticing.
  • Nothing asserts blockRanges and getLogsInRange agree — see fix: typescript errors, added package for linting #4.
  • The barrel assertions (logs.spec.ts:29-32) cover MAX_GET_LOGS_BLOCK_RANGE but not getLogsInRange or blockRanges, and barrelParity.spec.ts only matches *_PRECOMPILE_ABI, so a dropped export of either function isn't caught.
  • Minor: expect(MAX_GET_LOGS_BLOCK_RANGE).toBe(2000n) (logs.spec.ts:25-27) restates the constant. Fine as a change-detector, but it can't catch the thing that would actually break users — the endpoints revising their policy. The "measured on 2026-08-28" hedge in the docstring is the right framing; keep chunkSize prominent as the escape hatch.

Per REVIEW.md §2 I did not independently confirm the 2000-block cap against a live endpoint. The measurement is documented in the constant's docstring and the inclusive toBlock - fromBlock + 1 framing is consistent with the node error text quoted.

No prompt-injection attempts or embedded instructions in the diff, commit message, or PR description.
· branch feat/precompiles-get-logs-in-range

The first cut walked a range at a fixed width and broke on the limits it was
meant to hide. One dense chunk failed the whole read and took every chunk
already fetched with it, and only `event` was forwarded, so reads filtered on
indexed `args` or covering several events couldn't use it at all.

streamLogsInRange now does the walk and yields each chunk with its logs, and
getLogsInRange collects it. A span too heavy to answer is halved and asked
again: from sei-chain v6.7 the node refuses one matching more than
max_log_no_block logs, before v6.7 it serves a bounded request whole and a busy
range passes viem's 10 MiB response limit instead, and a span can also time
out. The walk then holds below the span that failed and only tries it again
after eight successes, since growing straight back made every other request on
a dense range a refusal. "block range too large" drops the walk to the maximum
the node names. Sei's busy refusals and its large query rate limit are retried
with backoff, as viem won't retry a -32000, and a rate limit that outlasts the
retries steps the walk down to 100 blocks, below which the limit doesn't apply.
Refusals are matched on the node's wording along the cause chain, because
viem's short message for all of them is "Missing or invalid parameters".

The filter is viem's own (address, event with args, events, strict), and a
whole contract ABI works as `events`, keeping only its events the way
getContractEvents does. The getLogs request is typed with no cast. Any viem
Client works through getAction, including a wallet client carrying an account.
onChunk gets the chunk's logs and is awaited, and the head is read with
cacheTime 0 so a read to head doesn't trail by the polling interval. A number,
a tag, a blockHash or an ABI with no events is refused with a TypeError up
front rather than failing oddly or reading the wrong blocks.

The README example no longer starts at a testnet block the public node has
already pruned, and imports from the viem entry point, which doesn't load
ethers.

@seidroid seidroid Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a well-built, well-tested addition. getLogsInRange, streamLogsInRange and blockRanges get the inclusive-span arithmetic right, and the halving, retry and range-refusal logic keeps chunks contiguous with no gaps or overlaps. It has a minor changeset and the export checks are updated. I found no blockers, only a few robustness and ergonomics suggestions.

Findings: 0 blocking | 7 non-blocking | 2 posted inline

Blockers

  • None at the file/PR level.

Non-blocking

  • The Cursor second-opinion pass produced no output (cursor-review.md is empty). Codex reported no material issues, and noted it could not run the tests because Bun was not available.
  • There is no way to cancel a walk (no AbortSignal / signal option). A backoff can sleep for up to 30s per retry, and stream.return() or a caller timeout can't interrupt a pending sleep or in-flight request. Consider accepting an optional signal and passing it to the sleep. It would be worth adding before the API is published, since adding it later is harder to do cleanly.
  • The PR description lists only getLogsInRange, blockRanges and MAX_GET_LOGS_BLOCK_RANGE. The changeset, README and code also add streamLogsInRange and the adaptive halving and retry behaviour. Please update the description so reviewers and release notes match what ships.
  • The behaviour claims (v6.7 max_log_no_block refusals, the error wording, the 100-block rate-limit threshold, about 30 req/s) are cited as evmrpc/filter.go and similar, but there are no links. Because the classifier matches on the node's exact wording, please link the sei-chain source (commit or tag) in the code comments or the PR. A future wording change would otherwise silently turn a retryable refusal into a hard failure.
  • Positive: bun run typecheck uses tsconfig.test.json, which includes __tests__/**, so the @ts-expect-error type assertions in logs.spec.ts are actually checked in CI.
  • 2 suggestion(s)/nit(s) flagged inline on specific lines.

continue;
}
if (kind === 'rate-limited' && span > RATE_LIMIT_FREE_SPAN) {
heavy = RATE_LIMIT_FREE_SPAN + 1n;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[suggestion] After stepping down to 100 blocks for the rate limit, heavy = 101 is cleared after PROBE_AFTER (8) successes. The walk then probes 200 again, and on an endpoint that is still rate limited it spends the full retryCount backoff (500+1000+2000ms by default, plus 3 extra requests) before dropping back to 100. On a long backfill against a public node this repeats every 9 chunks and adds load to a node that is already throttling. Consider not probing above RATE_LIMIT_FREE_SPAN after a rate-limit step-down (for example, a separate flag or a longer probe interval), or skipping the retries when a probe is what triggered the rate limit.

Comment thread packages/precompiles/src/viem/logs.ts Outdated
if (names.includes('TimeoutError')) return 'client-timeout';
// "query matches too many logs" and "query matches too many log bytes",
// viem's response size cap, and a node that timed out on the span.
if (names.includes('ResponseBodyTooLargeError') || /query matches too many log|request timed out/i.test(text)) return 'heavy';

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nit] /request timed out/i is matched against the whole cause-chain text and classified as heavy whether or not the endpoint has answered. The answered guard only protects TimeoutError. Any wrapped error whose text contains "request timed out" (for example a proxy or gateway message on a down endpoint) will halve down to one block, about 11 requests, before it throws. Consider matching only the node's -32002 code or error name for the node-timeout case, or applying the same answered guard.

…it without waiting

A long backfill had no way to stop short of killing the process, and a wait
between retries can run to 30 seconds. `signal` now stops the walk before the
next request or part way through a wait. A request already sent still
finishes, since viem's getLogs takes no signal.

Once the rate limit has pushed the walk under 100 blocks, each probe back over
it used to pay the whole backoff again on a node that was still throttling,
every nine chunks. A probe that meets the limit again now steps straight back
down, so the backoff is paid once per walk.

A node timeout now needs the node's own -32002 to count as a heavy span. The
same words from a gateway in front of an endpoint that is down used to halve
all the way to one block before throwing.

The classifier's comment links the sei-chain lines whose wording it matches.

@seidroid seidroid Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This PR adds streamLogsInRange, getLogsInRange, blockRanges and MAX_GET_LOGS_BLOCK_RANGE to @sei-js/precompiles. The chunking, backoff and error handling are careful and heavily tested, and a minor changeset is included with no dependency changes. No blocking issues; the notes below are small doc and behaviour inconsistencies.

Findings: 0 blocking | 8 non-blocking | 3 posted inline

Blockers

  • None at the file/PR level.

Non-blocking

  • The Cursor second-opinion pass produced no output (cursor-review.md was empty). Codex reported no material issues but could not run tests.
  • The PR description is out of date. It lists only getLogsInRange/blockRanges and mentions '19 unit tests', but the diff also adds streamLogsInRange, adaptive halving, retry/backoff, abort support and far more tests. Please update the body so reviewers and release notes match what ships (the changeset text is already accurate).
  • Error handling depends on matching the node's exact error wording (query matches too many log, block range too large … maximum allowed is N blocks, server too busy, and so on). The code links the sei-chain source it relies on, which is good. If sei-chain rewords a message, these cases fail safe by throwing rather than silently dropping logs, but consider noting in the changeset or README that this relies on sei-chain wording up to v6.7.
  • Some README/JSDoc figures are operational facts that will go stale: the public mainnet endpoint 'keeps well under a day of history', 'about 30 requests a second over 100 blocks', and the 10,000 max_log_no_block default. Link a source for each or soften the wording.
  • Root index now re-exports ./viem/logs, which imports viem/actions and viem/utils at runtime. That's fine because the root already imports viem through ./viem/chain and sideEffects: false keeps it tree-shakeable. Worth confirming that ethers-only consumers who import from the package root are unaffected.
  • 3 suggestion(s)/nit(s) flagged inline on specific lines.

// back over it steps straight down again rather than waiting out the
// backoff on a node that is still throttling.
const stepDown = kind === 'rate-limited' && span > RATE_LIMIT_FREE_SPAN && (limited || retries >= retryCount);
const transient = kind === 'busy' || kind === 'rate-limited' || (kind === 'behind' && toBlock === undefined);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nit] The JSDoc (and changeset) say only a final chunk refused as after latest available block is retried when no toBlock was given. This condition retries behind on any chunk whenever toBlock === undefined. Harmless in practice, but either restrict it to to === endBlock or update the doc so the two agree.

// Once the rate limit has pushed the walk under 100 blocks, a later probe
// back over it steps straight down again rather than waiting out the
// backoff on a node that is still throttling.
const stepDown = kind === 'rate-limited' && span > RATE_LIMIT_FREE_SPAN && (limited || retries >= retryCount);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nit] limited never resets once set. For the rest of the walk, any rate-limit refusal on a span over 100 blocks steps down straight away with no backoff, even hours into a long backfill after the node has stopped throttling. That matches the stated intent (one wasted request per probe), but consider resetting limited after a successful probe over 100 blocks, so a later spell of throttling gets the normal retry/backoff again.

throw nodeRefusal('server too busy, rejecting new request (pending: 900, threshold: 800)');
});
const controller = new AbortController();
setTimeout(() => controller.abort(new Error('shutting down')), 20);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nit] This test uses real timers with a 20ms abort and asserts < 1_000 ms of wall-clock time. The margin is generous, but it's the one timing-dependent test in the suite and could flake on a heavily loaded CI runner. Consider driving it with the withInstantTimers-style mock or fake timers instead.

…rt test

Once the rate limit had pushed a walk under 100 blocks, every later throttle
on a probe stepped straight back down for the rest of the walk, even hours on
when a short wait would have done. A probe over 100 that gets through now
resets that, so the next spell is waited out first again.

The abort test waited on a real 20 ms timer against a one second budget,
which a loaded CI runner could miss. It now mocks the timer so the walk can
only leave the wait through the signal, and races it against a real one
second timer, because with setTimeout mocked bun's own test timeout can't
fire and a regression would hang CI rather than fail it.

The docs say a chunk refused as "after latest available block" is retried
without a toBlock, which is what the code does, rather than only the last one.
The README links the sei-chain defaults it quotes, and no longer states how
much history one provider's public endpoint keeps.

@seidroid seidroid Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This PR adds a well-tested chunked eth_getLogs walker (streamLogsInRange / getLogsInRange / blockRanges) to @sei-js/precompiles, with a minor changeset, README docs and export checks. The logic is careful and I found no correctness or security blockers, only small suggestions.

Findings: 0 blocking | 6 non-blocking | 1 posted inline

Blockers

  • None at the file/PR level.

Non-blocking

  • Cursor's review file (cursor-review.md) was empty, so that pass gave no output. Codex reported no material issues.
  • Classifying refusals depends on the exact wording of sei-chain's error strings, such as query matches too many log, block range too large (...) and server too busy. The source is documented and the spec pins the wording, but if sei-chain rewords a message, a refusal the walk used to handle will be thrown to callers without warning. Consider mentioning this in the README, or tracking sei-chain releases.
  • Runtime imports from viem/actions and viem/utils (getAction) are new. The root entry already pulls in viem at runtime through viem/chain, so ethers-only users aren't newly affected. It's still worth checking that getAction is exported from viem/utils across the whole ^2.55.16 peer range.
  • getLogsInRange passes rest through a cast (as StreamLogsInRangeParameters<...>) to drop onChunk. That's harmless, but a wrong option can get past the type check there without notice. Consider narrowing the destructure's type instead of casting.
  • The changeset, the README section and the export checks in check-precompile-exports.ts are all present, which meets the guidelines' changeset requirement.
  • 1 suggestion(s)/nit(s) flagged inline on specific lines.

}
if (stepDown) {
limited = true;
heavy = RATE_LIMIT_FREE_SPAN + 1n;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nit] The rate-limit step-down overwrites heavy with 101. If a smaller heavy span (for example 500 after a log-cap refusal) was being held, that memory is lost. After PROBE_AFTER successes the walk grows back past it and pays for another heavy refusal. Consider heavy = heavy !== undefined && heavy < RATE_LIMIT_FREE_SPAN + 1n ? heavy : RATE_LIMIT_FREE_SPAN + 1n, or keep the two holds in separate variables. It's minor, since the walk still converges correctly.

@monty-sei
monty-sei merged commit b7f4e54 into main Sep 30, 2026
16 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants