Skip to content

Move EVM event routing, decoding, and query construction to Rust - #1404

Merged
DZakh merged 7 commits into
mainfrom
claude/event-routing-rust-migration-7qf9ok
Jul 13, 2026
Merged

Move EVM event routing, decoding, and query construction to Rust#1404
DZakh merged 7 commits into
mainfrom
claude/event-routing-rust-migration-7qf9ok

Conversation

@DZakh

@DZakh DZakh commented Jul 10, 2026

Copy link
Copy Markdown
Member

Summary

Moves the EVM fetch pipeline's per-log work — routing, decoding, and query construction — from ReScript into the Rust napi clients (EvmHypersyncClient, EvmRpcClient). The clients receive the chain's full registrations once at construction; each query then crosses the boundary as just {block range, registration indexes, addressesByContractName}, and comes back as final items carrying onEventRegistrationIndex.

Key Changes

Registrations as data across the boundary

  • Internal.onEventRegistration gains index: int — its position in the chain's onEventRegistrations array, assigned when registration finishes.
  • The full per-(event, chain) registration (OnEventRegistration in Rust, mirroring the ReScript name) is passed to the clients at construction: decode metadata (sighash/topicCount/params), routing identity (index/contractName/isWildcard), and fetch state (resolvedWhere topic selections with the contract-addresses marker as Option, selected block/transaction field lists).

Rust owns routing + decoding (decode.rs)

  • DecoderCore builds a router per (topic0, topicCount) key: variant_idx_by_contract_name + wildcard_variant_idx, with duplicate/wildcard-collision validation at construction mirroring the JS-side registration checks.
  • Each log routes via the partition's address → contract-name index (contract's own variant wins, wildcard fallback), decodes with only the routed variant's param names, and returns flat params + the registration index. Unrouted logs are dropped before the boundary.
  • The RPC client normalizes log addresses (lowercase/checksum per config) so they match the routing index and the JS Address.t directly.

Rust owns query construction (new selection.rs, shared by both clients)

  • Log selections: address-free pooling with topic0 compression, per-contract address scoping (one contract's query can't fetch a sibling's logs), wildcard-by-address marker expansion into padded address topics — in registration order so query bytes stay stable for HyperSync query caching.
  • HyperSync field selection: union over the selection's registrations with the transactionIndex exclusion, plus the forced required fields.
  • The routing index is derived internally from addressesByContractName, so routing and query building can never disagree on address casing.
  • Both clients expose buildLogSelections for tests and debugging.

Items carry an index, not the registration object

  • Internal.item's Event variant carries onEventRegistrationIndex instead of the registration, so Rust-built items are final and fully serializable. Consumers resolve through the chain's registrations array — stored on ChainState.t and mirrored in a per-chain registry in Internal (getItemOnEventRegistration) for consumers without a chain state at hand (ecosystem toRawEvent/toEventLogger, FetchState's address filter, ChainFetching, EventProcessing, batch materialization).
  • Simulate appends its synthetic registrations into the run's registrationsByChainId chain arrays (the same arrays chain-state startup installs), keeping item indexes valid across startup.

Deleted from ReScript

  • The EVM half of EventRouter (getEvmEventId, fromEvmEventModsOrThrow), both sources' getSelectionConfig machinery (bucketing, materialization, WeakMap memoization), HyperSync.makeRequestBody, LogSelection's query-building half, and Rpc.GetLogs' topic-query types. What remains JS-side is what genuinely can't move: parseWhereOrThrow (runs user callbacks), FetchState's partition/address bookkeeping, and handler dispatch.

Bug fix: indexed dynamic-type event filters

  • Tuple/array where values were passed through raw (latent — they only crossed napi at query time and never in tests; passing registrations at construction surfaced it as a startup failure). They're now encoded as keccak256 of their ABI encoding, matching what the chain stores in the topic, with a directly-passed tuple tried as one value before falling back to an OR-list interpretation.

Behavior notes

  • A non-wildcard event whose params failed to decode previously raised a fatal "parsed as undefined" error in HyperSyncSource; that state is now unrepresentable (routing success implies a decode attempt, and genuine decode errors still propagate).
  • Unrouted RPC logs no longer contribute opportunistic (blockNumber, blockHash) pairs to reorg detection; boundary blocks are still recorded.
  • Mock registrations in tests need hex-decodable sighashes since the clients validate them at construction.

Tests

  • Rust: 416 unit tests including new coverage for the selection builder (ownership scoping, wildcard pooling, marker expansion, field-selection union/exclusion, selection subsetting) and the router (duplicate/collision, per-variant naming).
  • ReScript: full test_codegen suite green (706 passed / 0 failed — including 3 createTestIndexer tests that the filter-encoding fix repaired), plus fuel/svm scenarios and live HyperSync integration suites. JS selection-shape tests were rewritten against the real buildLogSelections napi path rather than deleted.

🤖 Generated with Claude Code

https://claude.ai/code/session_0145w9f63kqQmFf7mWMDWmQc


Generated by Claude Code

Summary by CodeRabbit

  • New Features
    • Added registration-based event filtering and routing for EVM RPC and HyperSync sources.
    • Added support for selecting and decoding events by registration index and contract address.
    • Added indexed topic encoding for complex ABI values, including arrays and tuples.
    • Added APIs to build log selections for selected registrations and addresses.
  • Bug Fixes
    • Improved handling of identical event signatures across different contracts.
    • Unmatched logs are now excluded instead of returning incomplete decoded data.
  • Refactor
    • Event results now consistently include the matched registration index and decoded parameters.

claude added 5 commits July 10, 2026 09:59
Give each onEventRegistration a chain-scoped sequential id (its index in
the chain's onEventRegistrations array) and pass the registrations -
id, isWildcard, sighash/topicCount, param metadata - into the Rust
EvmHypersyncClient and EvmRpcClient constructors. Rust now routes every
log to its registration (owning contract via the partition's
address -> contract-name index, wildcard fallback) before decoding:

- DecoderCore keys a per-MetaKey router (by_contract_name + wildcard)
  and decodes with only the routed variant's param names, so items carry
  flat params instead of a per-contract dict.
- get_event_items and getNextPage take the partition's
  contractNameByAddress; items return onEventRegistrationId and logs
  that route nowhere are dropped on the Rust side.
- The RPC client normalizes log addresses (lowercase/checksum) so they
  match the routing index and the JS address type directly.
- ReScript sources resolve items with
  onEventRegistrations[item.onEventRegistrationId]; EventRouter's EVM
  half (getEvmEventId, fromEvmEventModsOrThrow) is deleted and
  EvmChain.makeSources enforces the id = array index invariant.
- Registration-time duplicate/wildcard-collision validation is mirrored
  as a backstop in the Rust decoder constructor.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0145w9f63kqQmFf7mWMDWmQc
id is derived purely from push order — assign it via record spread when
the registration lands in the chain's array (HandlerRegister.finishRegistration,
EvmChain.makeSources) instead of mutating an existing field.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0145w9f63kqQmFf7mWMDWmQc
Pass the full per-(event, chain) registration to the Rust clients at
construction — EventParamsInput becomes EventRegistrationInput, gaining
dependsOnAddresses, the resolvedWhere topic selections (per-topic
Option<Vec<String>>, None = contract-addresses marker), and the
selected block/transaction field lists. A shared SelectionBuilder
(evm_hypersync_source/selection.rs) owns everything a query derives
from the partition's selection and current addresses:

- log selections: address-free pooling + topic0 compression,
  per-contract address scoping, wildcard-by-address marker expansion
  into lowercase padded address topics, in registration order so query
  bytes stay stable for caching;
- HyperSync field selection: union over the selection's registrations
  with the transactionIndex exclusion, plus the forced required fields;
- the address -> contract-name routing index, derived from the
  partition's addressesByContractName instead of being passed
  separately.

The napi query surface shrinks to the block range plus the partition's
registration ids and addressesByContractName: get_event_items takes an
EventItemsQuery and builds the HyperSync query internally; get_next_page
drops log_selections/contract_name_by_address for registration_ids/
addresses_by_contract_name. Both clients expose build_log_selections
for tests and debugging.

On the ReScript side the per-source getSelectionConfig machinery
(bucketing, materialization, WeakMap memoization) is deleted from
HyperSyncSource and RpcSource; sources just forward selection ids and
addresses. LogSelection keeps only parseWhereOrThrow and the
materialize helpers used by tests; Rpc.GetLogs drops the topic-query
types. JS selection-shape tests are rewritten against
buildLogSelections, and field-selection behavior is covered by Rust
unit tests. Mock registrations now need hex-decodable sighashes since
the client validates them at construction.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0145w9f63kqQmFf7mWMDWmQc
…ainState

Internal.item's Event variant now carries onEventRegistrationIndex (the
registration's chain-scoped array position, renamed from id) instead of
the registration object, so Rust-built items can be final and complete.
The full registration is resolved through the chain's registration
array: stored on ChainState.t and mirrored in a per-chain registry in
Internal (setOnEventRegistrations at chain-state startup,
addOnEventRegistration for simulate/test setups that synthesize items,
getItemOnEventRegistration for consumers without a chain state at hand
— ecosystem toRawEvent/toEventLogger, FetchState's clientAddressFilter,
ChainFetching, EventProcessing, batch materialization).

Simulate appends its synthetic registrations into the run's
registrationsByChainId chain arrays (the same arrays chain-state startup
installs) instead of a side registry, so item indexes stay valid after
startup replaces the per-chain entry.

Rename the napi surface to match: EventRegistrationInput.index,
registration_indexes on both query params, on_event_registration_index
on items.

Drop the parallel eventRegistrations option on HyperSyncSource/RpcSource
— the Rust registration inputs are now derived inside the sources from
onEventRegistrations via HyperSyncClient.Registration.
fromOnEventRegistrations (moved from EvmChain), removing a second field
that had to stay in lockstep with the lookup array.

Fix indexed dynamic-type event filters: tuple/array where values were
passed through raw (previously latent — they only crossed napi at query
time and never in tests; passing registrations at client construction
surfaced it as a startup failure). Encode them as keccak256 of the ABI
encoding like the chain does, trying a directly-passed tuple as one
value before falling back to an OR-list of tuples.

Remove dead code (Rust add_field/ensure_required_log_fields/
TopicSelection::has_filters, ReScript QueryTypes topic helpers) and
refactor-narration comments.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0145w9f63kqQmFf7mWMDWmQc
… field names

Match the ReScript-side naming for the registration crossing the napi
boundary, and make the decoder's routing fields say what they hold:
EventVariant.on_event_registration_index, RegisteredEvent.
wildcard_variant_idx / variant_idx_by_contract_name.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0145w9f63kqQmFf7mWMDWmQc
@coderabbitai

coderabbitai Bot commented Jul 10, 2026

Copy link
Copy Markdown
Contributor
📝 Walkthrough

Walkthrough

This change replaces EVM event-parameter inputs with indexed event registrations, adds native indexed-topic encoding, derives RPC and HyperSync selections from registrations, and routes decoded logs to registration indexes with non-optional parameters.

Changes

EVM event routing and fetching

Layer / File(s) Summary
Registration contracts and decoder routing
packages/cli/src/evm_hypersync_source/decode.rs, packages/cli/src/evm_hypersync_source/types.rs, packages/envio/src/ChainState.res, packages/envio/src/Internal.res
Registrations now carry indexes and fetch metadata; decoder variants route by contract or wildcard and return the matched registration index with decoded parameters.
Topic selection and indexed ABI encoding
packages/cli/src/evm_hypersync_source/selection.rs, packages/cli/src/evm/topic_filter.rs, packages/envio/src/EventConfigBuilder.res
Selections are expanded from registrations, contract addresses become topic filters, and indexed scalar, tuple, array, and nested values are encoded into event topics.
Rust RPC and HyperSync clients
packages/cli/src/evm_hypersync_source/*, packages/cli/src/evm_rpc_source/mod.rs, packages/envio/src/sources/HyperSyncClient.res
Client construction, pagination, log fetching, query shapes, and event item outputs now use registration indexes and address-to-contract mappings.
EVM source integration
packages/envio/src/sources/EvmChain.res, packages/envio/src/sources/HyperSyncSource.res, packages/envio/src/sources/RpcSource.res, packages/envio/src/sources/EvmRpcClient.res
Sources pass registrations into native clients and map returned routed items directly to chain-owned registrations and decoded parameters.
Runtime registration ownership
packages/envio/src/HandlerRegister.res, packages/envio/src/SimulateItems.res, scenarios/test_codegen/test/helpers/MockIndexer.res
Chain, simulated, and mocked events share indexed registration objects through construction and dispatch.
Validation and regression coverage
scenarios/test_codegen/test/*, scenarios/fuel_test/*
Tests cover selection expansion, registration routing, indexed topic encoding, source behavior, and updated client contracts.

Estimated code review effort: 5 (Critical) | ~120 minutes

Possibly related PRs

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed Title clearly summarizes the main Rust migration of EVM routing, decoding, and query construction.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 8

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
packages/envio/src/sources/SvmHyperSyncSource.res (1)

288-289: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Update the stale routing comment.

Line 289 still says registrations become each item's onEventRegistration; emitted items now carry only onEventRegistrationIndex.

As per coding guidelines, comments describing the old shape must be dropped or rewritten during refactoring.

Also applies to: 524-524

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/envio/src/sources/SvmHyperSyncSource.res` around lines 288 - 289,
Update the routing comments near the definitions/registrations logic and the
corresponding later occurrence to describe that emitted items carry
onEventRegistrationIndex, not the full onEventRegistration object; remove any
stale references to registrations being attached directly to decoded items.

Source: Coding guidelines

🧹 Nitpick comments (8)
scenarios/fuel_test/src/Indexer.res (1)

70-70: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Remove the redundant generated doc comment.

onInstructionOptions already communicates this; fix the generator template so the comment does not propagate.

As per coding guidelines, comments must not restate what the code already says.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@scenarios/fuel_test/src/Indexer.res` at line 70, Remove the redundant
generated comment preceding onInstructionOptions in the generator template,
ensuring generated Indexer.res files no longer emit it while retaining the
onInstructionOptions declaration and its meaningful documentation.

Source: Coding guidelines

scenarios/test_codegen/test/lib_tests/FetchState_test.res (1)

82-85: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add the required explicit Utils.magic type annotation.

Proposed fix
 onEventRegistrationIndex: Internal.addOnEventRegistration(
   ~chainId,
-  Utils.magic("Mock onEventRegistration in fetchstate test"),
+  "Mock onEventRegistration in fetchstate test"->(
+    Utils.magic: string => Internal.onEventRegistration
+  ),
 ),
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@scenarios/test_codegen/test/lib_tests/FetchState_test.res` around lines 82 -
85, Add the required explicit type annotation to the Utils.magic call passed as
the onEventRegistration argument in Internal.addOnEventRegistration, using the
expected on-event registration callback type.

Source: Coding guidelines

scenarios/test_codegen/test/HyperSyncSource_test.res (2)

10-11: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Remove this helper-description comment.

The helper name and signature already describe the behavior.

As per coding guidelines, **/*.res comments must not restate what a function does.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@scenarios/test_codegen/test/HyperSyncSource_test.res` around lines 10 - 11,
Remove the helper-description comment above buildLogSelections in
HyperSyncSource_test.res; the function name and signature already document its
behavior, and comments in .res files must not restate function functionality.

Source: Coding guidelines


31-51: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Assert the three address-map cases as one value.

Combine the three buildLogSelections results into a tuple/record and make one t.expect(...).toEqual(...) assertion.

As per coding guidelines, **/*_test.res files must “use single assert to check the whole value instead of multiple asserts.”

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@scenarios/test_codegen/test/HyperSyncSource_test.res` around lines 31 - 51,
Combine the three buildLogSelections calls in the test into a single tuple or
record value, then use one t.expect(...).toEqual(...) assertion with the
corresponding expected tuple or record. Preserve the existing inputs and
expected results while removing the three separate assertions.

Source: Coding guidelines

scenarios/test_codegen/test/lib_tests/SameSignatureEventDecode_test.res (1)

61-64: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Remove the routing-path narration.

The test name and whole-value assertion already show this behavior; this comment restates the implementation path rather than a non-obvious constraint.

As per coding guidelines, **/*.res files should default to no comments and must not narrate the refactor itself.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@scenarios/test_codegen/test/lib_tests/SameSignatureEventDecode_test.res`
around lines 61 - 64, Remove the implementation-focused comment above the test
in SameSignatureEventDecode_test.res; the test name and assertions already
document the expected behavior, and .res files should avoid explanatory
narration.

Source: Coding guidelines

scenarios/test_codegen/test/helpers/NativeDecoder.res (1)

5-8: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Remove the function-description comment.

It restates what decodeLogs does and what it returns; the signature and call site already communicate that.

As per coding guidelines, **/*.res comments must not restate what a function does.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@scenarios/test_codegen/test/helpers/NativeDecoder.res` around lines 5 - 8,
Remove the multi-line function-description comment immediately preceding
decodeLogs in NativeDecoder.res, leaving the function implementation unchanged.

Source: Coding guidelines

packages/envio/src/sources/EvmRpcClient.res (1)

42-43: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Remove the caller-oriented comment.

The method name and signature are self-explanatory; “for tests and debugging” only describes its callers.

As per coding guidelines, comments must not restate which callers use a value.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/envio/src/sources/EvmRpcClient.res` around lines 42 - 43, Remove the
caller-oriented comment above the query log-selection method, including
references to tests, debugging, or what a partition would fetch; retain the
method and its signature unchanged.

Source: Coding guidelines

packages/envio/src/sources/HyperSyncClient.res (1)

315-317: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Drop the refactor and caller narration.

These comments only say that Rust derives values and that the API serves tests/debugging; the types already communicate the contract.

As per coding guidelines, comments must not narrate refactors or state which callers use a value.

Also applies to: 368-369

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/envio/src/sources/HyperSyncClient.res` around lines 315 - 317, The
comments around the per-query input and the corresponding section near the later
referenced lines are unnecessary caller/refactor narration; remove them while
preserving the surrounding code and type-defined contract.

Source: Coding guidelines

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@packages/cli/src/evm_hypersync_source/decode.rs`:
- Around line 86-91: In from_registrations, validate each
OnEventRegistration.index equals its zero-based position while iterating
registrations, returning an error for sparse, negative, or reordered indexes
before populating events. Update multi-registration decoder fixtures to use
distinct sequential indexes matching their array positions.

In `@packages/cli/src/evm_hypersync_source/mod.rs`:
- Line 198: Canonicalize contract address keys before routing lookups so casing
differences cannot cause missed events. Update the builder that populates
`built.contract_name_by_address` to normalize each address consistently with
`encode_address(..., should_checksum)`/`normalized_address()`, or change the map
to use parsed 20-byte addresses and matching lookup keys; ensure both Hypersync
and RPC paths use the same representation.

In `@packages/cli/src/evm_rpc_source/mod.rs`:
- Around line 492-501: Update the routing logic in route_and_decode_napi’s
caller to distinguish decoding outcomes: propagate Err rather than converting it
with .ok(), while treating only Ok(None) as an unrouted log to skip. Preserve
the existing Option flow for successfully decoded results and ensure ABI/topic
decoding failures are returned to the caller.

In `@packages/envio/src/EventConfigBuilder.res`:
- Around line 287-305: Update the filter normalization logic in the event filter
callback to treat ABI array types like tuples: detect array ABI types, first
attempt to encode the entire raw value as one ABI value, and return it as a
single-element list when successful; on failure, normalize it as an OR-list and
encode each item. Preserve the existing behavior for non-array, non-tuple types.

In `@packages/envio/src/TopicFilter.res`:
- Around line 20-24: Replace Viem.encodeAbiParametersUnsafe in fromAbiValue with
the Solidity indexed-event encoding routine so dynamic arrays and tuples with
dynamic members are hashed without standard ABI head/length encoding; preserve
the keccak256 result and add Solidity-derived test vectors covering indexed
arrays and dynamic-member tuples.

In `@scenarios/test_codegen/test/helpers/NativeDecoder.res`:
- Around line 45-50: Correct the map inversion in the contractNameByAddress
iteration: use the address key as the Address value and the contract name value
as the contract-name key when populating addressesByContractName. Update the
callback in the relevant NativeDecoder helper so Address.unsafeFromString
receives the map key, not the contract name.

In `@scenarios/test_codegen/test/lib_tests/FetchState_onBlock_test.res`:
- Around line 79-82: Replace the inline Utils.magic-based registration in the
mockEvent setup with a single hoisted index created from baseEventConfig via
Internal.addOnEventRegistration, then reuse that index for every mockEvent
instead of mutating the chain registry per event. Ensure any remaining
Utils.magic cast declares explicit input and output types.

In `@scenarios/test_codegen/test/lib_tests/SameSignatureEventDecode_test.res`:
- Around line 82-83: Annotate both Utils.magic casts in the
SameSignatureEventDecode test with explicit source and destination types,
including typed record shapes for the from/to/value and src/dst/wad parameter
objects, so the expected parameters use typed casts rather than bare Utils.magic
calls.

---

Outside diff comments:
In `@packages/envio/src/sources/SvmHyperSyncSource.res`:
- Around line 288-289: Update the routing comments near the
definitions/registrations logic and the corresponding later occurrence to
describe that emitted items carry onEventRegistrationIndex, not the full
onEventRegistration object; remove any stale references to registrations being
attached directly to decoded items.

---

Nitpick comments:
In `@packages/envio/src/sources/EvmRpcClient.res`:
- Around line 42-43: Remove the caller-oriented comment above the query
log-selection method, including references to tests, debugging, or what a
partition would fetch; retain the method and its signature unchanged.

In `@packages/envio/src/sources/HyperSyncClient.res`:
- Around line 315-317: The comments around the per-query input and the
corresponding section near the later referenced lines are unnecessary
caller/refactor narration; remove them while preserving the surrounding code and
type-defined contract.

In `@scenarios/fuel_test/src/Indexer.res`:
- Line 70: Remove the redundant generated comment preceding onInstructionOptions
in the generator template, ensuring generated Indexer.res files no longer emit
it while retaining the onInstructionOptions declaration and its meaningful
documentation.

In `@scenarios/test_codegen/test/helpers/NativeDecoder.res`:
- Around line 5-8: Remove the multi-line function-description comment
immediately preceding decodeLogs in NativeDecoder.res, leaving the function
implementation unchanged.

In `@scenarios/test_codegen/test/HyperSyncSource_test.res`:
- Around line 10-11: Remove the helper-description comment above
buildLogSelections in HyperSyncSource_test.res; the function name and signature
already document its behavior, and comments in .res files must not restate
function functionality.
- Around line 31-51: Combine the three buildLogSelections calls in the test into
a single tuple or record value, then use one t.expect(...).toEqual(...)
assertion with the corresponding expected tuple or record. Preserve the existing
inputs and expected results while removing the three separate assertions.

In `@scenarios/test_codegen/test/lib_tests/FetchState_test.res`:
- Around line 82-85: Add the required explicit type annotation to the
Utils.magic call passed as the onEventRegistration argument in
Internal.addOnEventRegistration, using the expected on-event registration
callback type.

In `@scenarios/test_codegen/test/lib_tests/SameSignatureEventDecode_test.res`:
- Around line 61-64: Remove the implementation-focused comment above the test in
SameSignatureEventDecode_test.res; the test name and assertions already document
the expected behavior, and .res files should avoid explanatory narration.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 08157830-3fa3-4eed-b08c-553efd8f5456

📥 Commits

Reviewing files that changed from the base of the PR and between 12c9fa4 and 7aebdde.

📒 Files selected for processing (56)
  • packages/cli/src/evm_hypersync_source/decode.rs
  • packages/cli/src/evm_hypersync_source/mod.rs
  • packages/cli/src/evm_hypersync_source/selection.rs
  • packages/cli/src/evm_hypersync_source/types.rs
  • packages/cli/src/evm_rpc_source/mod.rs
  • packages/envio/src/ChainFetching.res
  • packages/envio/src/ChainState.res
  • packages/envio/src/ChainState.resi
  • packages/envio/src/EventConfigBuilder.res
  • packages/envio/src/EventProcessing.res
  • packages/envio/src/FetchState.res
  • packages/envio/src/HandlerRegister.res
  • packages/envio/src/Internal.res
  • packages/envio/src/LogSelection.res
  • packages/envio/src/RawEvent.res
  • packages/envio/src/SimulateItems.res
  • packages/envio/src/TestIndexer.res
  • packages/envio/src/TopicFilter.res
  • packages/envio/src/bindings/Viem.res
  • packages/envio/src/sources/EventRouter.res
  • packages/envio/src/sources/Evm.res
  • packages/envio/src/sources/EvmChain.res
  • packages/envio/src/sources/EvmRpcClient.res
  • packages/envio/src/sources/Fuel.res
  • packages/envio/src/sources/HyperFuelSource.res
  • packages/envio/src/sources/HyperSync.res
  • packages/envio/src/sources/HyperSync.resi
  • packages/envio/src/sources/HyperSyncClient.res
  • packages/envio/src/sources/HyperSyncSource.res
  • packages/envio/src/sources/Rpc.res
  • packages/envio/src/sources/RpcSource.res
  • packages/envio/src/sources/Svm.res
  • packages/envio/src/sources/SvmHyperSyncSource.res
  • scenarios/fuel_test/src/Indexer.res
  • scenarios/fuel_test/test/HyperFuelSource_test.res
  • scenarios/test_codegen/test/ClientAddressFilter_test.res
  • scenarios/test_codegen/test/HyperSyncClient_test.res
  • scenarios/test_codegen/test/HyperSyncSource_test.res
  • scenarios/test_codegen/test/HyperSync_test.res
  • scenarios/test_codegen/test/IndexerState_test.res
  • scenarios/test_codegen/test/RpcSource_test.res
  • scenarios/test_codegen/test/SourceBlockHashes_test.res
  • scenarios/test_codegen/test/__mocks__/MockEvents.res
  • scenarios/test_codegen/test/helpers/MockIndexer.res
  • scenarios/test_codegen/test/helpers/NativeDecoder.res
  • scenarios/test_codegen/test/lib_tests/ChainState_materialize_test.res
  • scenarios/test_codegen/test/lib_tests/CrossChainState_test.res
  • scenarios/test_codegen/test/lib_tests/EventRouter_test.res
  • scenarios/test_codegen/test/lib_tests/EvmRpcClient_test.res
  • scenarios/test_codegen/test/lib_tests/FetchState_onBlock_test.res
  • scenarios/test_codegen/test/lib_tests/FetchState_test.res
  • scenarios/test_codegen/test/lib_tests/HyperSyncDecoder_test.res
  • scenarios/test_codegen/test/lib_tests/PgStorage_test.res
  • scenarios/test_codegen/test/lib_tests/RenamedEventDecode_test.res
  • scenarios/test_codegen/test/lib_tests/SameSignatureEventDecode_test.res
  • scenarios/test_codegen/test/rollback/ChainMocking.res
💤 Files with no reviewable changes (4)
  • packages/envio/src/sources/EventRouter.res
  • scenarios/test_codegen/test/lib_tests/EventRouter_test.res
  • packages/envio/src/LogSelection.res
  • packages/envio/src/sources/Rpc.res

Comment on lines +86 to +91
pub(crate) fn from_registrations(
registrations: &[OnEventRegistration],
checksum_addresses: bool,
) -> Result<Self> {
let mut events: HashMap<MetaKey, RegisteredEvent> = HashMap::new();
for ep in event_params {
for ep in registrations {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Validate that registration indexes match their array positions.

These indexes are later used directly by Array.getUnsafe. Sparse, negative, or reordered values can resolve the wrong handler or produce undefined. Enforce index == position while constructing the shared decoder.

Proposed fix
-        for ep in registrations {
+        for (expected_index, ep) in registrations.iter().enumerate() {
+            anyhow::ensure!(
+                ep.index == expected_index as i64,
+                "Registration index {} must match its array position {} for event {}",
+                ep.index,
+                expected_index,
+                ep.event_name,
+            );

Update multi-registration decoder fixtures to use distinct sequential indexes.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
pub(crate) fn from_registrations(
registrations: &[OnEventRegistration],
checksum_addresses: bool,
) -> Result<Self> {
let mut events: HashMap<MetaKey, RegisteredEvent> = HashMap::new();
for ep in event_params {
for ep in registrations {
pub(crate) fn from_registrations(
registrations: &[OnEventRegistration],
checksum_addresses: bool,
) -> Result<Self> {
let mut events: HashMap<MetaKey, RegisteredEvent> = HashMap::new();
for (expected_index, ep) in registrations.iter().enumerate() {
anyhow::ensure!(
ep.index == expected_index as i64,
"Registration index {} must match its array position {} for event {}",
ep.index,
expected_index,
ep.event_name,
);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/cli/src/evm_hypersync_source/decode.rs` around lines 86 - 91, In
from_registrations, validate each OnEventRegistration.index equals its
zero-based position while iterating registrations, returning an error for
sparse, negative, or reordered indexes before populating events. Update
multi-registration decoder fixtures to use distinct sequential indexes matching
their array positions.

},
..Default::default()
};
let contract_name_by_address = built.contract_name_by_address;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Verify every routing-map construction and normalized-address lookup.
rg -n -C4 \
  'contract_name_by_address|encode_address|normalized_address|route_and_decode' \
  packages/cli/src/evm_hypersync_source \
  packages/cli/src/evm_rpc_source

Repository: enviodev/hyperindex

Length of output: 20734


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Inspect selection-building and lookup paths around address normalization.
sed -n '1,240p' packages/cli/src/evm_hypersync_source/selection.rs
printf '\n--- mod.rs relevant lookup ---\n'
sed -n '470,540p' packages/cli/src/evm_hypersync_source/mod.rs
printf '\n--- rpc source relevant lookup ---\n'
sed -n '470,510p' packages/cli/src/evm_rpc_source/mod.rs

Repository: enviodev/hyperindex

Length of output: 13873


🏁 Script executed:

#!/bin/bash
set -euo pipefail

rg -n -C3 'addresses_by_contract_name|contract_name_by_address' packages/cli/src packages/cli/lib.rs packages/cli/commands.rs

Repository: enviodev/hyperindex

Length of output: 14372


Canonicalize the routing map before lookup.

contract_name_by_address stores the caller’s raw address strings, but both Hypersync and RPC look up logs with encode_address(..., should_checksum)/normalized_address(). Any casing mismatch will miss the map and silently drop routed events. Normalize the keys when building contract_name_by_address or store parsed 20-byte addresses instead.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/cli/src/evm_hypersync_source/mod.rs` at line 198, Canonicalize
contract address keys before routing lookups so casing differences cannot cause
missed events. Update the builder that populates
`built.contract_name_by_address` to normalize each address consistently with
`encode_address(..., should_checksum)`/`normalized_address()`, or change the map
to use parsed 20-byte addresses and matching lookup keys; ensure both Hypersync
and RPC paths use the same representation.

Comment on lines +492 to +501
// Decode failures are skipped like unrouted logs (matching
// the pre-routing behavior where undecodable params made
// the JS side drop the item).
let routed = decoder
.route_and_decode_napi(
&raw.to_decoder_log(),
contract_name_by_address.get(&address).map(String::as_str),
)
.ok()
.flatten()?;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Do not silently discard decoding failures.

.ok().flatten()? treats genuine ABI/topic decoding errors as unrouted logs, silently losing events and diverging from HyperSync behavior. Drop only Ok(None) and propagate Err.

Proposed fix
-                    let routed = decoder
-                        .route_and_decode_napi(
-                            &raw.to_decoder_log(),
-                            contract_name_by_address.get(&address).map(String::as_str),
-                        )
-                        .ok()
-                        .flatten()?;
+                    let routed = match decoder.route_and_decode_napi(
+                        &raw.to_decoder_log(),
+                        contract_name_by_address.get(&address).map(String::as_str),
+                    ) {
+                        Ok(Some(routed)) => routed,
+                        Ok(None) => return None,
+                        Err(error) => return Some(Err(error.context("decode event params"))),
+                    };
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// Decode failures are skipped like unrouted logs (matching
// the pre-routing behavior where undecodable params made
// the JS side drop the item).
let routed = decoder
.route_and_decode_napi(
&raw.to_decoder_log(),
contract_name_by_address.get(&address).map(String::as_str),
)
.ok()
.flatten()?;
// Decode failures are skipped like unrouted logs (matching
// the pre-routing behavior where undecodable params made
// the JS side drop the item).
let routed = match decoder.route_and_decode_napi(
&raw.to_decoder_log(),
contract_name_by_address.get(&address).map(String::as_str),
) {
Ok(Some(routed)) => routed,
Ok(None) => return None,
Err(error) => return Some(Err(error.context("decode event params"))),
};
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/cli/src/evm_rpc_source/mod.rs` around lines 492 - 501, Update the
routing logic in route_and_decode_napi’s caller to distinguish decoding
outcomes: propagate Err rather than converting it with .ok(), while treating
only Ok(None) as an unrouted log to skip. Preserve the existing Option flow for
successfully decoded results and ensure ABI/topic decoding failures are returned
to the caller.

Comment thread packages/envio/src/EventConfigBuilder.res
Comment thread packages/envio/src/TopicFilter.res Outdated
Comment on lines +45 to +50
contractNameByAddress->Dict.forEachWithKey((contractName, address) => {
addressesByContractName->Utils.Dict.push(
contractName,
address->Address.unsafeFromString,
)
})

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Invert the address map in the correct direction.

contractNameByAddress is keyed by address, but these bindings treat its key as a contract name. This attempts to parse names such as TokenA as addresses and prevents the mock query from selecting the intended log.

Proposed fix
-  contractNameByAddress->Dict.forEachWithKey((contractName, address) => {
+  contractNameByAddress->Dict.forEachWithKey((address, contractName) => {
     addressesByContractName->Utils.Dict.push(
       contractName,
       address->Address.unsafeFromString,
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
contractNameByAddress->Dict.forEachWithKey((contractName, address) => {
addressesByContractName->Utils.Dict.push(
contractName,
address->Address.unsafeFromString,
)
})
contractNameByAddress->Dict.forEachWithKey((address, contractName) => {
addressesByContractName->Utils.Dict.push(
contractName,
address->Address.unsafeFromString,
)
})
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@scenarios/test_codegen/test/helpers/NativeDecoder.res` around lines 45 - 50,
Correct the map inversion in the contractNameByAddress iteration: use the
address key as the Address value and the contract name value as the
contract-name key when populating addressesByContractName. Update the callback
in the relevant NativeDecoder helper so Address.unsafeFromString receives the
map key, not the contract name.

Comment on lines +79 to +82
onEventRegistrationIndex: Internal.addOnEventRegistration(
~chainId,
Utils.magic("Mock onEventRegistration in fetchstate test"),
),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Register the typed fixture once and reuse its index.

Each mockEvent currently mutates the chain registry and stores a string disguised as a registration. Hoist one index using baseEventConfig to avoid test-order pollution and invalid downstream resolution.

Proposed fix
 let baseEventConfig = (MockIndexer.evmOnEventRegistration(
   ~id="0",
   ~contractName="Gravatar",
 ) :> Internal.onEventRegistration)
+
+let mockEventRegistrationIndex =
+  Internal.addOnEventRegistration(~chainId, baseEventConfig)

 let mockEvent = (~blockNumber, ~logIndex=0): Internal.item => Internal.Event({
   chain: ChainMap.Chain.makeUnsafe(~chainId),
   blockNumber,
-  onEventRegistrationIndex: Internal.addOnEventRegistration(
-    ~chainId,
-    Utils.magic("Mock onEventRegistration in fetchstate test"),
-  ),
+  onEventRegistrationIndex: mockEventRegistrationIndex,

As per coding guidelines, Utils.magic casts must include explicit input and output types.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
onEventRegistrationIndex: Internal.addOnEventRegistration(
~chainId,
Utils.magic("Mock onEventRegistration in fetchstate test"),
),
let baseEventConfig = (MockIndexer.evmOnEventRegistration(
~id="0",
~contractName="Gravatar",
) :> Internal.onEventRegistration)
let mockEventRegistrationIndex =
Internal.addOnEventRegistration(~chainId, baseEventConfig)
let mockEvent = (~blockNumber, ~logIndex=0): Internal.item => Internal.Event({
chain: ChainMap.Chain.makeUnsafe(~chainId),
blockNumber,
onEventRegistrationIndex: mockEventRegistrationIndex,
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@scenarios/test_codegen/test/lib_tests/FetchState_onBlock_test.res` around
lines 79 - 82, Replace the inline Utils.magic-based registration in the
mockEvent setup with a single hoisted index created from baseEventConfig via
Internal.addOnEventRegistration, then reuse that index for every mockEvent
instead of mutating the chain registry per event. Ensure any remaining
Utils.magic cast declares explicit input and output types.

Source: Coding guidelines

Comment on lines +82 to +83
(0, {"from": fromAddr, "to": toAddr, "value": value}->Utils.magic),
(1, {"src": fromAddr, "dst": toAddr, "wad": value}->Utils.magic),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Inspect the target file and nearby type definitions/usages.
git ls-files 'scenarios/test_codegen/test/lib_tests/SameSignatureEventDecode_test.res' \
  'scenarios/test_codegen/test/lib_tests/*' \
  | sed -n '1,120p'

echo
echo '--- file outline ---'
ast-grep outline scenarios/test_codegen/test/lib_tests/SameSignatureEventDecode_test.res --view expanded || true

echo
echo '--- target lines ---'
cat -n scenarios/test_codegen/test/lib_tests/SameSignatureEventDecode_test.res | sed -n '70,95p'

echo
echo '--- Utils.magic usage in same area ---'
rg -n "Utils\.magic" scenarios/test_codegen/test/lib_tests -n -A 2 -B 2 || true

Repository: enviodev/hyperindex

Length of output: 32073


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo '--- top of file ---'
cat -n scenarios/test_codegen/test/lib_tests/SameSignatureEventDecode_test.res | sed -n '1,70p'

echo
echo '--- decodeAs helper and nearby declarations ---'
cat -n scenarios/test_codegen/test/lib_tests/SameSignatureEventDecode_test.res | sed -n '1,90p'

Repository: enviodev/hyperindex

Length of output: 7044


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo '--- event params type usages ---'
rg -n "eventParams|Internal\.eventParams|params\)->\(Utils\.magic|Utils\.magic: .*eventParams" scenarios/test_codegen/test/lib_tests -A 1 -B 1 || true

echo
echo '--- similar decode test for same pattern ---'
cat -n scenarios/test_codegen/test/lib_tests/HyperSyncDecoder_test.res | sed -n '110,130p'

Repository: enviodev/hyperindex

Length of output: 1784


Annotate both Utils.magic casts on lines 82-83. Use explicit source and destination types here; the expected params should be cast with typed Utils.magic instead of bare casts.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@scenarios/test_codegen/test/lib_tests/SameSignatureEventDecode_test.res`
around lines 82 - 83, Annotate both Utils.magic casts in the
SameSignatureEventDecode test with explicit source and destination types,
including typed record shapes for the from/to/value and src/dst/wad parameter
objects, so the expected parameters use typed casts rather than bare Utils.magic
calls.

Source: Coding guidelines

* Fix event registration ownership and topic encoding

* Allow empty standalone mock source responses

* Store registration state on mock sources
getNextPage: nextPageParams => promise<nextPageResponse>,
// Exposes the query's log selections for a given registration selection and
// address index — for tests and debugging what a partition would fetch.
buildLogSelections: (

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Let's not have a test-specific code. Replace it with an end-to-end test

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

♻️ Duplicate comments (1)
packages/envio/src/EventConfigBuilder.res (1)

242-262: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Handle ABI arrays as composite filter values.

isTuple does not recognize types such as uint256[]. A single filter value like [1n, 2n] is therefore split into separate OR values, and each scalar is passed to the encoder for an array ABI type. Detect array types as well and apply the whole-value-first fallback.

Proposed fix
-  let isTuple = p.abiType->String.startsWith("(")
+  let isComposite =
+    p.abiType->String.startsWith("(") || p.abiType->String.endsWith("]")
...
-      if isTuple {
+      if isComposite {
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/envio/src/EventConfigBuilder.res` around lines 242 - 262, Update
buildTopicGetter to treat ABI array types as composite values alongside tuples:
detect array ABI types in addition to types beginning with "(" and apply the
existing whole-value-first encoder fallback for them. This must preserve single
array filters such as [1n, 2n] as one encoded value, while still normalizing and
mapping genuine OR-lists when whole-value encoding fails.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Duplicate comments:
In `@packages/envio/src/EventConfigBuilder.res`:
- Around line 242-262: Update buildTopicGetter to treat ABI array types as
composite values alongside tuples: detect array ABI types in addition to types
beginning with "(" and apply the existing whole-value-first encoder fallback for
them. This must preserve single array filters such as [1n, 2n] as one encoded
value, while still normalizing and mapping genuine OR-lists when whole-value
encoding fails.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 93160e60-a485-458b-8f44-57cc3429c3a6

📥 Commits

Reviewing files that changed from the base of the PR and between 7aebdde and b406069.

📒 Files selected for processing (32)
  • packages/cli/src/evm/mod.rs
  • packages/cli/src/evm/topic_filter.rs
  • packages/envio/src/ChainFetching.res
  • packages/envio/src/ChainState.res
  • packages/envio/src/Core.res
  • packages/envio/src/EventConfigBuilder.res
  • packages/envio/src/EventProcessing.res
  • packages/envio/src/FetchState.res
  • packages/envio/src/Internal.res
  • packages/envio/src/RawEvent.res
  • packages/envio/src/SimulateDeadInputTracker.res
  • packages/envio/src/SimulateItems.res
  • packages/envio/src/TopicFilter.res
  • packages/envio/src/bindings/Viem.res
  • packages/envio/src/sources/EvmChain.res
  • packages/envio/src/sources/HyperSyncSource.res
  • packages/envio/src/sources/RpcSource.res
  • packages/envio/src/sources/SimulateSource.res
  • packages/envio/src/sources/SvmHyperSyncSource.res
  • scenarios/test_codegen/test/ClientAddressFilter_test.res
  • scenarios/test_codegen/test/EventFilters_test.res
  • scenarios/test_codegen/test/IndexerState_test.res
  • scenarios/test_codegen/test/SvmHyperSyncSource_test.res
  • scenarios/test_codegen/test/__mocks__/MockEvents.res
  • scenarios/test_codegen/test/helpers/MockIndexer.res
  • scenarios/test_codegen/test/lib_tests/ChainState_materialize_test.res
  • scenarios/test_codegen/test/lib_tests/CrossChainState_test.res
  • scenarios/test_codegen/test/lib_tests/FetchState_onBlock_test.res
  • scenarios/test_codegen/test/lib_tests/FetchState_test.res
  • scenarios/test_codegen/test/lib_tests/HyperSyncDecoder_test.res
  • scenarios/test_codegen/test/lib_tests/PgStorage_test.res
  • scenarios/test_codegen/test/rollback/ChainMocking.res
💤 Files with no reviewable changes (3)
  • packages/envio/src/sources/EvmChain.res
  • packages/envio/src/TopicFilter.res
  • packages/envio/src/bindings/Viem.res
🚧 Files skipped from review as they are similar to previous changes (6)
  • packages/envio/src/RawEvent.res
  • packages/envio/src/FetchState.res
  • packages/envio/src/SimulateItems.res
  • packages/envio/src/sources/HyperSyncSource.res
  • scenarios/test_codegen/test/lib_tests/HyperSyncDecoder_test.res
  • packages/envio/src/sources/RpcSource.res

@DZakh
DZakh merged commit 044eaf5 into main Jul 13, 2026
8 checks passed
@DZakh
DZakh deleted the claude/event-routing-rust-migration-7qf9ok branch July 13, 2026 15:12
DZakh pushed a commit that referenced this pull request Jul 14, 2026
Resolve conflicts from #1404 (EVM routing/decoding/query construction moved to
Rust). Combine with this branch's client-address-filter-before-registration and
faithful per-query simulate source.

Replace the simulate source's delivered-key tracking with buffer-level dedup:
the source now over-fetches freely (a wildcard log can match two partitions'
overlapping ranges), and FetchState.handleQueryResult drops an event already in
the buffer, keyed by (blockNumber, logIndex, onEventRegistration.index). The
registration index distinguishes two registrations routing the same log, which
are distinct items and must both survive. Test mocks now carry a registration
index so the key resolves.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017xrChwhzSShi39DwN2iKV5
DZakh added a commit that referenced this pull request Aug 4, 2026
* fix: parse SVM accountFilters as array of AND-groups in public config (#1408)

The CLI emits accountFilters as Vec<Vec<SvmAccountFilterJson>> (AND-groups
OR-ed together, normalized from both the flat and any_of YAML shapes), and
the consumer in Config.fromPublic already maps it as nested groups. The
parse schema declared a flat array, so any SVM config using account_filters
failed to load with:

  Invalid indexer config: Failed parsing at ["svm"]["programs"][...]
  ["accountFilters"]["0"]["position"]. Reason: Expected int32,
  received undefined

Wrap the schema in one more S.array so it matches what the CLI emits and
what the consumer expects, and add a regression test.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>

* Move EVM event routing, decoding, and query construction to Rust (#1404)

* Move EVM event routing and decoding to the Rust clients

Give each onEventRegistration a chain-scoped sequential id (its index in
the chain's onEventRegistrations array) and pass the registrations -
id, isWildcard, sighash/topicCount, param metadata - into the Rust
EvmHypersyncClient and EvmRpcClient constructors. Rust now routes every
log to its registration (owning contract via the partition's
address -> contract-name index, wildcard fallback) before decoding:

- DecoderCore keys a per-MetaKey router (by_contract_name + wildcard)
  and decodes with only the routed variant's param names, so items carry
  flat params instead of a per-contract dict.
- get_event_items and getNextPage take the partition's
  contractNameByAddress; items return onEventRegistrationId and logs
  that route nowhere are dropped on the Rust side.
- The RPC client normalizes log addresses (lowercase/checksum) so they
  match the routing index and the JS address type directly.
- ReScript sources resolve items with
  onEventRegistrations[item.onEventRegistrationId]; EventRouter's EVM
  half (getEvmEventId, fromEvmEventModsOrThrow) is deleted and
  EvmChain.makeSources enforces the id = array index invariant.
- Registration-time duplicate/wildcard-collision validation is mirrored
  as a backstop in the Rust decoder constructor.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0145w9f63kqQmFf7mWMDWmQc

* Make onEventRegistration.id immutable

id is derived purely from push order — assign it via record spread when
the registration lands in the chain's array (HandlerRegister.finishRegistration,
EvmChain.makeSources) instead of mutating an existing field.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0145w9f63kqQmFf7mWMDWmQc

* Move EVM query construction to the Rust clients

Pass the full per-(event, chain) registration to the Rust clients at
construction — EventParamsInput becomes EventRegistrationInput, gaining
dependsOnAddresses, the resolvedWhere topic selections (per-topic
Option<Vec<String>>, None = contract-addresses marker), and the
selected block/transaction field lists. A shared SelectionBuilder
(evm_hypersync_source/selection.rs) owns everything a query derives
from the partition's selection and current addresses:

- log selections: address-free pooling + topic0 compression,
  per-contract address scoping, wildcard-by-address marker expansion
  into lowercase padded address topics, in registration order so query
  bytes stay stable for caching;
- HyperSync field selection: union over the selection's registrations
  with the transactionIndex exclusion, plus the forced required fields;
- the address -> contract-name routing index, derived from the
  partition's addressesByContractName instead of being passed
  separately.

The napi query surface shrinks to the block range plus the partition's
registration ids and addressesByContractName: get_event_items takes an
EventItemsQuery and builds the HyperSync query internally; get_next_page
drops log_selections/contract_name_by_address for registration_ids/
addresses_by_contract_name. Both clients expose build_log_selections
for tests and debugging.

On the ReScript side the per-source getSelectionConfig machinery
(bucketing, materialization, WeakMap memoization) is deleted from
HyperSyncSource and RpcSource; sources just forward selection ids and
addresses. LogSelection keeps only parseWhereOrThrow and the
materialize helpers used by tests; Rpc.GetLogs drops the topic-query
types. JS selection-shape tests are rewritten against
buildLogSelections, and field-selection behavior is covered by Rust
unit tests. Mock registrations now need hex-decodable sighashes since
the client validates them at construction.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0145w9f63kqQmFf7mWMDWmQc

* Store onEventRegistrationIndex on items; resolve registrations via ChainState

Internal.item's Event variant now carries onEventRegistrationIndex (the
registration's chain-scoped array position, renamed from id) instead of
the registration object, so Rust-built items can be final and complete.
The full registration is resolved through the chain's registration
array: stored on ChainState.t and mirrored in a per-chain registry in
Internal (setOnEventRegistrations at chain-state startup,
addOnEventRegistration for simulate/test setups that synthesize items,
getItemOnEventRegistration for consumers without a chain state at hand
— ecosystem toRawEvent/toEventLogger, FetchState's clientAddressFilter,
ChainFetching, EventProcessing, batch materialization).

Simulate appends its synthetic registrations into the run's
registrationsByChainId chain arrays (the same arrays chain-state startup
installs) instead of a side registry, so item indexes stay valid after
startup replaces the per-chain entry.

Rename the napi surface to match: EventRegistrationInput.index,
registration_indexes on both query params, on_event_registration_index
on items.

Drop the parallel eventRegistrations option on HyperSyncSource/RpcSource
— the Rust registration inputs are now derived inside the sources from
onEventRegistrations via HyperSyncClient.Registration.
fromOnEventRegistrations (moved from EvmChain), removing a second field
that had to stay in lockstep with the lookup array.

Fix indexed dynamic-type event filters: tuple/array where values were
passed through raw (previously latent — they only crossed napi at query
time and never in tests; passing registrations at client construction
surfaced it as a startup failure). Encode them as keccak256 of the ABI
encoding like the chain does, trying a directly-passed tuple as one
value before falling back to an OR-list of tuples.

Remove dead code (Rust add_field/ensure_required_log_fields/
TopicSelection::has_filters, ReScript QueryTypes topic helpers) and
refactor-narration comments.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0145w9f63kqQmFf7mWMDWmQc

* Rename EventRegistrationInput to OnEventRegistration; clarify decoder field names

Match the ReScript-side naming for the registration crossing the napi
boundary, and make the decoder's routing fields say what they hold:
EventVariant.on_event_registration_index, RegisteredEvent.
wildcard_variant_idx / variant_idx_by_contract_name.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0145w9f63kqQmFf7mWMDWmQc

* Fix event registration ownership and indexed topic encoding (#1412)

* Fix event registration ownership and topic encoding

* Allow empty standalone mock source responses

* Store registration state on mock sources

---------

Co-authored-by: Claude <noreply@anthropic.com>

* Replace test-only log selection API with E2E coverage (#1413)

* Add RPC source contract pin framework (#1416)

* Centralize config parsing tests around YAML (#1421)

* Centralize config parsing tests around YAML

* Explain SVM pubkey validation dependency

* Include licenses directory in published envio package (#1422)

* Fix incorrect license in envio package.json

The published envio package declared GPL-3.0, but the project ships a
proprietary SaaS EULA (licenses/LICENSE.md), not a GPL license. Mark the
package UNLICENSED to reflect its proprietary terms.

* Ship the EULA and reference it from the license field

The envio package is proprietary (licenses/LICENSE.md is a SaaS EULA), so
use the standard 'SEE LICENSE IN LICENSE.md' form instead of UNLICENSED, and
copy the EULA to the published package root so the reference resolves for
consumers. Add LICENSE.md to the artifact verifier's required files.

* Ship the full licenses directory with the envio package

The licenses/ dir holds four files: the HyperIndex software EULA (EULA.md),
the SaaS EULA (LICENSE.md), the CLA, and an overview README. The npm package
is the HyperIndex software, so point the license field at licenses/EULA.md and
copy the whole directory into the published package. Add 'licenses' to the
files allowlist (npm only force-includes a root LICENSE, not a subdirectory)
and verify every license file ships.

* Point license field at the licenses overview README

licenses/README.md is the licensing index: it explains which terms apply to
the software, generated code, and hosted service, and links the specific
EULAs. Reference it from the license field so consumers land on the overview
rather than a single EULA that only covers part of the picture.

---------

Co-authored-by: Claude <noreply@anthropic.com>

* Enable strict warning checks in ReScript configurations (#1424)

* Treat ReScript warning 23 as an error in indexer configs

Promote the "useless record with clause" warning to an error in the
generated-project template and the test scenarios. The envio runtime
package already errors on all warnings via "+a".

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DGX7HGV5nCwFHHoazo8dCM

* Enforce all ReScript warnings as errors in test scenarios

Set warnings.error to "+a" for the test_codegen, fuel_test, and svm_test
scenarios, matching the envio runtime package. Leave the user-facing
generated-project template without a warnings override.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DGX7HGV5nCwFHHoazo8dCM

---------

Co-authored-by: Claude <noreply@anthropic.com>

* Improve rollback logging and conditional event registration logging (#1425)

* Improve indexer logs for contract-register events and rollback range

Omit numContractRegisterEvents from the "Finished querying" log when it's
zero, and log the per-chain rollback block range for all affected chains
at info level so reorg rollbacks aren't limited to the reorg chain's
target block.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Xvxwf6mi6rT2AV16sx2KSL

* Emit per-chain rollback logs and quiet the batch-wait log

Drop the "Waiting for batch..." log to trace, remove the aggregate
"Rolled back chains on reorg" log, and replace the trace-level "Finished
rollback on reorg" log with a per-chain info "Rollbacked" log carrying the
chain id, from/to block range, rolled-back event count, and reorg-chain
flag.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Xvxwf6mi6rT2AV16sx2KSL

* Split rollback entity changes into a separate trace log

Restore the entity deleted/upserted detail as its own trace-level log and
drop the isReorgChain field from the per-chain "Rollbacked" info log.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Xvxwf6mi6rT2AV16sx2KSL

* Avoid chainId binding collision on rollback logs

Build the rollback logger without inheriting the reorg chain's logger,
which bound its chainId onto every line and collided with the per-chain
chainId on the "Rollbacked" logs. The reorg chain is identified by the
reorgChain param instead.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Xvxwf6mi6rT2AV16sx2KSL

---------

Co-authored-by: Claude <noreply@anthropic.com>

* Extract client-side address filtering from FetchState (#1414) (#1427)

* Filter over-fetched events before contract registration

Over-fetched events (a merged partition returning an address before its
effectiveStartBlock, or a wildcard param referencing an address registered
after the log's block) were running their contractRegister handlers and
spawning dynamic contracts before being dropped from the buffer.

Apply the client-side address filter to the contract-register set before
running the handlers. Extract the predicate as FetchState.filterByClientAddress
and expose it through ChainState so ChainFetching can gate registration; the
buffer is still filtered after registration in handleQueryResult, so events
referencing a contract registered in the same batch keep routing to handlers.


Claude-Session: https://claude.ai/code/session_017xrChwhzSShi39DwN2iKV5

* Move client address filter fully before contract registration

Follow-up to the previous commit: instead of only gating the contract-register
set, apply the client-side address filter to the whole response up front, so an
over-fetched event neither spawns dynamic contracts nor enters the buffer.

This is only correct if a non-wildcard event for an address registered in the
same batch can't appear before its registration — which a real backend
guarantees, since a query only returns logs for the addresses it was sent. The
simulate source didn't model that (it dumped every item on the first call), so
make it faithful: return only items matching the query's block range, selection,
and (for non-wildcard events) address set, delivering each once; wildcards are
over-fetched for the client filter to gate, mirroring HyperSync. A contract
registered mid-run now surfaces its events in the follow-up query the
registration triggers, exactly as in production.

Parse simulate items at the process's startBlock (not the config default) so
they land in the range the source is queried over. The dead-input tracker stays
downstream, observing processed batches, so it still reports items excluded by
any filter.


Claude-Session: https://claude.ai/code/session_017xrChwhzSShi39DwN2iKV5

* Merge buffer with a single sort-free pass instead of re-sorting

Buffer accumulation re-sorted the whole buffer on every response via
Array.sort(compareBufferItem) — an O(n log n) pass whose comparator crosses the
JS↔native boundary on each comparison — plus a Set of string keys for dedup.

Replace it with mergeIntoBuffer: the buffer is already sorted, so insertion-sort
just the (small, usually ascending) response and merge the two runs in one linear
pass, dropping duplicates as adjacent-equal. Comparison is inlined
(compareBufferItem now returns an int with explicit field compares and a
registration-index tiebreaker) with no Array.sort callback and no allocated key.
updateInternal assumes a sorted buffer (hot paths pass mutItemsSorted=true) and
normalizes arbitrary input otherwise; onBlock items are generated as their own
sorted run and merged in the same way.

~14-20x faster on realistic buffers (see packages/envio/bench). Adds a
mergeIntoBuffer correctness test.


Claude-Session: https://claude.ai/code/session_017xrChwhzSShi39DwN2iKV5

* Address review: drop bench, single onBlock merge, simplify test helper

- Delete the standalone benchmark script.
- updateInternal now folds onBlock items into the buffer with a single merge at
  the end instead of merging mid-function; block items stay their own sorted run
  so the merge remains linear.
- makeInitialWithOnBlock returns the fetch state directly (indexing addresses
  were unused by every caller).


Claude-Session: https://claude.ai/code/session_017xrChwhzSShi39DwN2iKV5

---------

Co-authored-by: Claude <noreply@anthropic.com>

* SVM: exclude failed-transaction instructions (#1428)

HyperSync serves instructions from failed Solana transactions and the
runtime delivered all of them to onInstruction handlers, silently
over-counting (~18% for SPL TransferChecked over the sampled slots).
Exclude instructions whose parent transaction did not commit, matching
EVM (reverted-tx logs never exist) and the old RPC `!tx.meta.err` pattern.

Filter client-side in SvmHyperSyncSource.getItemsOrThrow on the
`isCommitted` flag HyperSync already delivers on every instruction row (a
required column, zero extra bandwidth). The current query API cannot push
this down (InstructionSelection exposes only `is_inner`; instruction and
transaction selections union at block level rather than joining), so the
client-side check stands until HyperSync adds a server-side `is_committed`
predicate, at which point it becomes a redundant safety net.

No opt-in knob for now: keep the surface minimal and add a config option
(e.g. per-instruction `include_failed`) if and when someone needs failed
transactions. Deferring it also leaves the opt-in design open rather than
committing to a config shape prematurely.

HOS-1610

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Dmitry Zakharov <dzakh.dev@gmail.com>

* Fix rollback handling for deleted entities (#1431)

* Fix rollback handling for deleted entities

* Return rollback removed IDs directly

* Harden rollback test error handling

* Add Tron chain to fix hypersync health check (#1436)

Tron (chain_id 728126428) is served publicly by the HyperSync API but was
missing from the Network enum, causing the health check to fail.


Claude-Session: https://claude.ai/code/session_011GxCWhUKxvdy8zgg44wvMr

Co-authored-by: Claude <noreply@anthropic.com>

* Add per-chain effect caching and rate limiting (#1432)

* feat(effects): per-chain cache scoping via crossChain option

Add a `crossChain` option to the Effect API (defaults to `true`). When
`crossChain: false`, an effect's cache and rate-limit window are isolated
per chain and the handler can read `context.chain.id`.

- Public API: `crossChain?: boolean` on effect options; required
  `context.chain.id` in ReScript and TypeScript types. Reading
  `context.chain` on a cross-chain effect throws a guiding error.
- Scope model (`CrossChain | Chain(int)`) resolved from the effect config
  and the current handler chain. Nested calls follow: handler -> either;
  chain -> either; cross-chain -> cross-chain; cross-chain -> chain fails
  before cache lookup with both effect names and remediation.
- Per-scope runtime boundary: in-memory cache, in-flight dedup, rate-limit
  window/queue and active-call state are keyed by the resolved cache
  address; the canonical input key is unchanged.
- Central reversible mapping `Internal.EffectCache` between
  (effectName, scope) <-> table name <-> cache file path, used everywhere
  instead of prefix slicing. Cache metadata is keyed by the full address.
- Postgres: cross-chain tables `envio_effect_<name>`, chain-scoped
  `envio_<chainId>_effect_<name>`; discovery matches both formats.
  `.envio/cache` gains numeric per-chain subdirectories; restore rejects
  malformed chain directories and supports one directory level; dump does
  the exact reverse mapping.

Tests: address round trips / legacy / coexistence / invalid parsing,
per-chain dedup and independent rate limits, cross-chain sharing, and an
E2E covering context.chain.id, the guiding errors, and per-chain
persistence.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X47KFbCFtGSXgGqFsBrCgH

* refactor(effects): unboxed effectScope, enumerable chain getter, exact error tests

Address review feedback:
- Make `context.chain` an enumerable own getter closing over the resolved
  chain, dropping the hidden `_chainId`/`_effectName` instance fields.
- Mark `effectScope` `@unboxed` (CrossChain -> "crossChain", Chain(id) ->
  the raw id, discriminated by runtime type).
- Assert the exact cross-chain `context.chain` and nested cross-chain ->
  chain-scoped error messages in the E2E test.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X47KFbCFtGSXgGqFsBrCgH

* refactor(effects): address review — generic chainScope, resolved-table write path, prototype getter

- Rename `effectScope` -> `chainScope` (generic; reused for entities later).
- Persistence write path no longer threads effect+scope: `updatedEffectCache`
  and `setEffectCacheOrThrow` take the resolved `table` (the cache address) +
  item schema. The in-mem table now holds its built `table`, so the address is
  resolved once in `getEffectInMemTable` and reused by load/snapshot/write.
- Move the `context.chain` getter back onto the prototype (enumerable, like
  `log`), reading per-instance non-enumerable fields.
- Collapse the two MockIndexer cache-query helpers into one
  `queryEffectCache(effect, ~scope=?)`.
- Tighten the crossChain docs: concise and user-facing, no table/file internals.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X47KFbCFtGSXgGqFsBrCgH

* Add scope label to per-scope effect gauges and strict chain-id parsing (#1435)

The envio_effect_active_calls, envio_effect_cache, and envio_effect_queue
gauges are backed by per-scope state since caching became chain-scoped, so
scopes of the same effect clobbered each other's value. Label them with
scope: "crossChain" | <chain id>.

Cache directory chain ids are now parsed strictly: "1foo" and "007" are
rejected instead of being treated as chains 1 and 7 via parseInt semantics.


Claude-Session: https://claude.ai/code/session_011YLPufR6wf9LYFAsNjKz1t

Co-authored-by: Claude <noreply@anthropic.com>

* fix(effects): validate effect names and guard cache-table discovery by columns

Two review points not covered by #1435:

- Validate effect names to `[A-Za-z0-9_-]+` in createEffect. The name is used
  as a cache table name and a .envio/cache path segment, so path separators and
  traversal (`a/b`, `../evil`) must be rejected to keep the
  (name, scope) <-> table <-> path mapping reversible.
- Cache-table discovery now also requires the effect-cache column shape
  (exactly `id` + `output`), so a user entity table that matches the reserved
  name pattern is never mistaken for an effect cache.

#1435 already addressed the per-scope metric-gauge clobbering (via a scope
label) and strict chain-id parsing, so those are not duplicated here.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X47KFbCFtGSXgGqFsBrCgH

* fix(effects): preserve rate-limit budget across rollbacks; guard cache table length

- Rate-limit windows lived on the per-scope effect in-mem table, which a reorg
  wipes (beginRollbackDiff clears state.effects), refilling the budget on
  replay. Keep them in a survivor dict on IndexerState (not cleared on
  rollback), keyed by cache table name; each recreated in-mem table reuses the
  same window. Rate limiting reflects real API throughput, not indexing
  progress. + regression test.
- Reject effect cache table names longer than PostgreSQL's 63-char identifier
  limit in makeCacheTable, instead of letting PG silently truncate and diverge
  from what cache discovery reads back.
- Make the per-chain rate-limit test assert that chain 2 bypasses chain 1's
  queue (order) rather than relying on which call resolves first.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X47KFbCFtGSXgGqFsBrCgH

* revert(effects): drop the 63-char cache table name guard

An effect name long enough to overflow the scoped identifier is unrealistic;
the guard isn't worth the runtime throw.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X47KFbCFtGSXgGqFsBrCgH

* refactor(effects): encapsulate effect state in an EffectState module

Effect runtime state was two loose dicts on IndexerState with divergent
rollback lifecycles (cache wiped by beginRollbackDiff, rate-limit windows
deliberately kept), an invariant that lived only in a comment.

Introduce a nested IndexerState.EffectState module (mirroring EntityTables)
that owns both maps and exposes getTable / forEach / resetForRollback. The
rollback semantics — drop cache tables, preserve rate-limit windows — are now
enforced by resetForRollback rather than remembered. Not folded into
ChainState/CrossChainState: effect state is keyed by (effect, scope) and
cross-chain effects have no chain, so it's a separate concern from chain
fetch/coordination state.

Behavior-preserving: InMemoryStore.getEffectInMemTable and Writing now delegate
to the module; all effect/rollback tests pass unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X47KFbCFtGSXgGqFsBrCgH

* refactor(effects): address review — extract EffectState, constructor chain field, required scope

- Move the EffectState module out of IndexerState into its own EffectState.res
  / .resi file.
- context.chain: install it in the EffectContext constructor instead of a
  prototype getter — a plain data field `{ id }` for chain-scoped effects (no
  getter), and only cross-chain contexts install a shared top-level throwing
  getter (created once, not per context).
- MockIndexer.queryEffectCache: make the `~scope` argument required; pass it
  explicitly at all call sites.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X47KFbCFtGSXgGqFsBrCgH

* docs(effects): drop redundant rateLimitState comment

The option type already conveys "None when the effect has no rate limit".

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X47KFbCFtGSXgGqFsBrCgH

* fix(effects): scope effect-call timing metrics per chain

prevCallStartTimerRef and active-call state moved per (effect, scope),
but the call_seconds/call_seconds_total/call_total counters were still
keyed by effect only. Overlapping calls on different chains double-counted
wall time into one series. Give these counters the same {effect, scope}
labels as the active-calls gauge so each scope tracks its own throughput.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X47KFbCFtGSXgGqFsBrCgH

* fix(effects): allow dots in effect names

The name-validation regex rejected existing safe names like "token.metadata".
Dots round-trip fine through the (name, scope) <-> table <-> path mapping
(table names are quoted; the cache scanner strips only the ".tsv" suffix).
Allow dots mid-name while still excluding path separators and forbidding a
leading dot, so a name can never be "." / ".." or traverse out of the cache dir.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X47KFbCFtGSXgGqFsBrCgH

---------

Co-authored-by: Claude <noreply@anthropic.com>

* Replace prune throttler with smart scheduling in write loop (#1444)

* Fix history prune racing batch writes and losing rollback anchors

The stale-history prune ran on its own throttler concurrently with batch
writes. Its anchor deletion relies on "no history after the safe
checkpoint", which a concurrently committing batch falsifies: the batch's
backfill sees the anchor and skips, the prune sees no post-safe rows and
deletes the anchor, and after both commit the entity has history only
above the safe checkpoint. A later rollback then deletes the entity
instead of restoring it.

Move pruning into the write loop so it can never overlap a history write
for the same entity:

- Each write picks up to 5 pg entities not pruned for the prune interval,
  excluding entities written in the batch (rollback writes touch every
  history table, so they get none), and prunes them one at a time
  concurrently with the batch write, awaited before the next write.
- Entities starved of the concurrent prune (eg written in every batch)
  are force-pruned sequentially right after the write, once they haven't
  been pruned for 5x the interval.
- Prune failures are logged instead of failing the write loop.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UjkHvbFKZxk1L3JY6e7HCt

* Select prune targets in a single pass over entities

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UjkHvbFKZxk1L3JY6e7HCt

* Throttle failed prune retries and keep checkpoint pruning out of rollback writes

Record the prune attempt time on failure too, so a failing entity retries
on the prune interval instead of on every write. Run checkpoint pruning
only alongside a concurrent entity prune; when nothing runs concurrently
(eg a rollback write) it moves to the forced phase after the write.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UjkHvbFKZxk1L3JY6e7HCt

---------

Co-authored-by: Claude <noreply@anthropic.com>

* Refactor query sizing to water-fill budget across chains (#1392)

* Make multichain fetch scheduling chain-controlled

Replace the per-partition/per-query greedy admission scheduler with a
per-chain waterfall: CrossChainState.checkAndFetch visits chains
furthest-behind first, handing each its remaining share of the shared
buffer budget. ChainState turns that budget into a soft target block
using a new chain-wide event density (seeded from cumulative progress,
smoothed with an EMA per batch), and FetchState.getNextQuery sizes
known-density partitions against that target block while splitting
whatever budget is left across partitions with unknown density.

This concentrates fetch effort on the bottleneck chain per tick instead
of scattering a shared item budget across every chain's full candidate
query set.

* Fix probe-split eligibility and query ordering in FetchState.getNextQuery

The unknown-density probe split counted partitions with nothing left to
query (already at their endBlock/mergeBlock/knownHeight ceiling), inflating
the divisor and under-sizing eligible partitions' queries. Add a
hasEligibleRange check mirroring pushQueriesForRange's own gate to exclude
them.

Splitting partitions into known/unknown passes also broke the original
idsInAscOrder query ordering that several tests assert on positionally;
restore it by sorting the final query list back into partition order.

Update FetchState_test.res fixtures accordingly, including a case that
needed distinct expected values across three eligibility scenarios that
previously shared one fixture.

* Redesign FetchState.getNextQuery as an even per-partition water-fill

Query creation now splits the chain's range budget evenly across
in-range partitions each round, rather than sizing every known-density
partition against the full chain target while unknown-density
partitions fought over the leftover. A partition already holding more
budget than its even share (e.g. from an earlier tick's in-flight
query) sits out a round so its share flows to the others, and the
split is recomputed each round against the shrinking set of partitions
still needing more.

Also:
- Rename estResponseSize -> itemsTarget throughout, since the field is
  now both the server-side maxNumLogs-style cap and the budget
  reservation/consumption unit, not just an estimate.
- Bucket queries by partition index as they're created instead of
  sorting the whole result at the end of every tick.
- Only trust a partition's density once it has two responses
  (matching the existing chunking-heuristic gate); a single response
  is too noisy to size the next query from.
- Smooth the chain-wide density EMA as (old + new) / 2 instead of
  (2*old + new) / 3.

* Address review feedback on the water-fill scheduler: dedupe reserved-sum
walk, tighten round bound, add coverage

- getNextQuery walked every partition's mutPendingQueries twice (once
  for chainReserved, again to seed reservedByPartition per partition).
  Merge into a single pass.
- Replace the unproven roundsRef < 1000 safety cap with a provable
  bound: every active partition either finishes or advances its chunk
  count each round, capped at maxPendingChunksPerPartition, and all
  active partitions progress in lockstep (not one at a time), so no
  partition can outlive maxPendingChunksPerPartition + 1 rounds.
- Add ChainState_test.res covering the chain density seed (from
  resumed progress) and the EMA blend.
- Add a CrossChainState_test.res case pinning the waterfall's actual
  cross-chain budget flow: a chain whose real range caps its
  consumption below its share leaves the remainder for the next chain.

* Make the water-fill round's per-partition share order-independent

Each round computed ipb = rangeBudget/n once, but then capped every
partition's actual budget at min(rangeBudget, ipb - reserved) and
decremented rangeBudget after each partition — so a partition
processed earlier in the same round (e.g. one forced to overshoot its
share via the "at least one full chunk" rule) shrank the pool for
whoever came after it. Same reservations, different iteration order,
different split (and total consumption could even exceed rangeBudget
depending on order).

Fix: every partition's share for a round is ipb - reserved, fixed for
the whole round; rangeBudget is only re-derived once, from the round's
actual total consumption, after every partition has had its fixed
shot. A partition can still overshoot its own share, but it can no
longer steal from another partition in the same round.

Also fixes a SourceManager_test.res assertion that was pinned to the
old order-dependent rounding artifact (three identical partitions
splitting a budget three ways used to get 16667/16667/16666; they now
all get 16667, as they should since they're indistinguishable).

* Redistribute a filled partition's leftover budget in the water-fill (#1394)

The per-partition round budget was `ipb - reserved`, where `ipb` was an
even share of only the *remaining fresh* budget (`rangeBudget / n`) while
`reserved` accumulated each partition's full footprint (existing in-flight
+ gap-fill + this call's prior-round emissions). Those two are on different
scales, so once a chunked partition's running reservation passed a later
round's fresh share, `ipb - reserved` went negative and the partition was
dropped — leaving budget unspent even though it still had range to fetch
and a sibling had just freed its share by filling early.

Compute the round's level as a real water-fill line — (remaining fresh
budget + the still-not-filled partitions' current footprint) / count —
and top each partition up toward it. A partition already above the line
gets nothing (its head start is its whole share); the rest absorb the
leftover, so the budget is fully used and per-partition totals stay even.
The loop now runs until either the not-filled set drains or the whole
fresh budget is reserved, dropping the redundant round cap: a partition
survives a round only by advancing chunksUsedThisCall (bounded by
maxPendingChunksPerPartition) or consuming budget, so it terminates on
its own.

Add a regression test: a range-capped partition and a deep partition
splitting a 900-item budget — the deep one now absorbs the capped one's
freed share (4 chunks / 720 items) instead of stopping at 2.


Claude-Session: https://claude.ai/code/session_0134TTgxQ3ci5mWUnt928yr9

Co-authored-by: Claude <noreply@anthropic.com>

* Cap unknown-density probe query at maxItemsTarget

An unknown-density partition's open-ended probe was sized to its full even
share of the chain's budget with no ceiling. When such a chain leads the
furthest-behind waterfall, that share is the entire cross-chain buffer pool,
so its single probe consumed 100% of the remaining budget and starved any
sibling chain needing its own first probe in the same tick (e.g. multiple
chains entering the reorg threshold together).

Cap the probe at maxItemsTarget (10_000), restoring the old bounded-default
ceiling. The leftover budget flows to the next chain via checkAndFetch's
remaining subtraction, exactly as before.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GBSAcMXTNy2K8i16bkJELo

* Don't seed chain density from a zero-event batch

The resume-seed path only sets chainDensity once numEventsProcessed > 0, but
the per-batch EMA update seeded Some(0.) after any progress-only batch (blocks
advanced, no events), contradicting that documented behavior and making the
first real batch blend against 0 instead of seeding from its own density.

Guard the EMA seed on the batch having events, matching the resume path. The
Some(oldDensity) blend is unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GBSAcMXTNy2K8i16bkJELo

* Adjust itemsTarget setting logic

* Enforce reservation == server cap and stop chunking without trusted density

- Floor itemsTarget at 1 at creation (densityItemsTarget, water-fill chunk
  loop, probe) so a query's budget reservation always equals the
  maxNumLogs-style cap sent to the server; drop SourceManager's 2000-item
  fallback that let density-0 queries return up to 2000 unaccounted items.
- Emit density-priced chunks only for a trusted positive density; density-0
  and unknown-density partitions get a single open-ended probe sized at the
  even split of the tick's fresh budget (maxItemsTarget cap removed). This
  removes the chunkCost=0 path that flooded 10 free hard-bounded chunks per
  partition and froze the 1.8x range growth.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01McpcXkR3pPWfEcq4mCj9Sw

* Extract getTrustedDensity helper for water-fill chunk sizing

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01McpcXkR3pPWfEcq4mCj9Sw

* Make query itemsTarget an int and trim redundant comments

The ceil-to-int conversion now happens once at query creation, so the
reservation, the budget accounting, and the server cap all use the same
integer value; SourceManager passes it through untouched.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01McpcXkR3pPWfEcq4mCj9Sw

* Price gap-fill by trusted density with available-density fallback

Gap queries now use getTrustedDensity: chunks only on a trusted positive
density (same rule as the water-fill); a trusted-zero density prices the
whole gap as one open query, and a partition with no density signal prices
it by available density — its equal-divide budget spread over the remaining
range this tick — so a small gap reserves proportionally little instead of
a noisy one-sample estimate or a NaN from dividing by a zero range.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01McpcXkR3pPWfEcq4mCj9Sw

* Cap follower chains at the leader's target progress in the waterfall

Chains beyond the most-behind one in the budget waterfall are now capped
at that leader's target progress, mapped onto their own block range
(ChainState.progressAtBlock/blockAtProgress), so no chain runs further
ahead than the chain the shared buffer pool is prioritizing. A chain
visited after the pool is exhausted simply sits out the round — its
reservations release as responses land, so the next tick redistributes.

FetchState's dynamic-contract partition merge now inherits the sum of
its parents' trusted densities (weighted onto the merged partition's
min query range) instead of resetting to 0, so a merge with density
history doesn't regress to an unpriced probe.

Update E2E/rollback tests to the now-serialized cross-chain query
dispatch (most-behind chain queries first; siblings follow once its
response releases budget) and to give density-dependent chunking tests
a nonzero item count to trust.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01McpcXkR3pPWfEcq4mCj9Sw

* Cap a clamped chain's fresh budget at its density-priced range cost

When a chain's target block is clamped (head, endBlock, or the
cross-chain alignment cap), a known-density chain's fresh budget is now
capped at density x clamped range (in-flight reservations stay on top so
they don't crowd out new partitions). The unused remainder stays in the
waterfall's pool and flows to the next chain in the same tick, instead
of being held by an oversized probe until the response lands.

This also removes the drain loop the infinite-reorg-loop test needed:
the non-reorg chain's post-rollback refetch now reserves only its real
range cost, so the reorg chain gets budget immediately.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01McpcXkR3pPWfEcq4mCj9Sw

* Give head-bound queries 2x density headroom in the budget cap

A query clamped at the head sized exactly at density x range truncates at
the server cap whenever the range is slightly denser than the estimate,
forcing an immediate catch-up query for the last few blocks. Double the
range cost for head-bound targets so one query usually suffices; the
extra reservation releases as soon as the response lands.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01McpcXkR3pPWfEcq4mCj9Sw

* Refine chain budget caps: endBlock ceiling, 5k probe cap, 3x head headroom

- targetBlock now clamps at endBlock (when below the head) via a shared
  fetchCeiling helper, so endBlock'd chains stop sizing and aligning
  against range they'll never fetch.
- A chain with no positive density signal caps its fresh budget at 5k,
  so one unknown chain measuring its first responses no longer holds the
  whole cross-chain pool.
- Head/endBlock-bound queries get 3x (was 2x) density headroom against
  truncating at the server cap and needing a catch-up query.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01McpcXkR3pPWfEcq4mCj9Sw

* Fix clippy::useless_borrows_in_formatting across cli package

Remove redundant & references in format!/anyhow! arguments flagged by
the CI-pinned clippy (rust 1.97). Pre-existing on the base branch,
unrelated to the SourceManager/waterfall changes in this PR — fixed
here since it was blocking cargo-test from going green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01McpcXkR3pPWfEcq4mCj9Sw

* Fix clippy::to_string_in_format_args exposed by the previous fix

Removing the redundant & in anyhow!'s self.id.to_string() surfaced a
second lint on the same line: ChainId (u64) already implements Display,
so .to_string() inside the format arg is itself redundant.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01McpcXkR3pPWfEcq4mCj9Sw

---------

Co-authored-by: Claude <noreply@anthropic.com>

* Fix clippy useless_borrows_in_formatting errors blocking CI

main's cargo-test job started failing clippy (-D warnings) on pre-existing
code after a stable-toolchain drift (no rust-toolchain pin), unrelated to
this PR's scheduling changes but inherited via the origin/main merge.
Removed the redundant `&` in format!/anyhow! args across 6 files, and
dropped a now-also-flagged explicit .to_string() on a Display type in
validation.rs.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GBSAcMXTNy2K8i16bkJELo

* Pour water-fill budget at an exact level and tighten chain budget edges

- Replace the per-round mean line in FetchState.getNextQuery with an exact
  water level (sum of top-ups equals the poured budget), so uneven in-flight
  reservations can no longer inflate other partitions' allotments past the
  fresh budget
- Size unknown-density probes by their water-fill allotment instead of a
  fixed pre-round even split, so leftover budget reaches the partitions
  without reservations instead of being stranded
- Gate the 3x head headroom on the chain having caught up once (isReady)
- Blend chain density weighted by the batch's block span instead of a flat
  (old + new) / 2
- Clamp progressAtBlock at 0 for the initial -1 fetch frontier
- Skip chains with no known height in the cross-chain waterfall so they wait
  for a block instead of setting a degenerate alignment line

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KvUhc8DhbxjGPEtHoDNThJ

* Shrink density blend window to 100 blocks

Small batches (a few blocks) should barely nudge the chain density estimate,
while anything spanning 100+ blocks is a trustworthy fresh sample.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KvUhc8DhbxjGPEtHoDNThJ

* Add chunk headroom multiplier for budget-aware query sizing (#1400)

* Add chunk itemsTarget headroom and budget-driven chunk emission

Chunk reservations now carry a headroom multiplier over the density
estimate (1.5x during backfill, 3x in realtime, chosen in
CrossChainState.checkAndFetch and threaded down to
FetchState.getNextQuery), so a denser-than-expected range doesn't
truncate at the server cap. Open-ended probes stay allotment-sized.

The emit loop replaces the precomputed chunkCost/affordable estimate
with per-chunk actual itemsTarget accounting: the first chunk always
emits full-size, subsequent chunks only while they fit the budget, and
the min-one-chunk force applies once per call instead of once per
water-fill round.

Cap-hit truncations (partial response with itemsCount >= itemsTarget)
no longer update the chunk range history — they reflect our own
reservation, not server capacity. Sub-cap partials still do.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KpFcYPn8UbQfaEfW6gjant

* Restore min-one-chunk per water-fill round

A leftover re-pour forces a full chunk again, so the budget never
strands on chunk quantization; the overshoot stays bounded at one
chunk per partition per round and self-corrects via the reported
reservations. Drops the per-call emittedThisCall bookkeeping.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KpFcYPn8UbQfaEfW6gjant

---------

Co-authored-by: Claude <noreply@anthropic.com>

* Implement cold-chain targeting and density-aware query sizing (#1401)

* Contain queries to the chain target block and rework cold-start sizing

- No chunk or gap-fill query starts past chainTargetBlock; emitted chunks
  keep their full span, with endBlock/mergeBlock staying the hard bounds.
  Skipped gaps regenerate from the pending-walk and fill once the target
  reaches them.
- A chain with no density signal targets frontier + coldTargetRange
  (init 20k), doubling whenever it goes idle without producing a signal,
  capped at the fetch ceiling. The cross-chain waterfall clamps a cold
  chain to min(5k, targetBufferSize), replacing the internal probe clamp,
  and a cold leader no longer sets the alignment line.
- Query sizing uses effectiveDensity = max(processing EMA, ready-buffer
  density), so a dense buffer overrides a stale-low EMA and ready items
  alone take a chain out of cold mode.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018CiEziFbVe1P3Y6iuyfT5Z

* Replace cold-window doubling with a fixed 20k range

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018CiEziFbVe1P3Y6iuyfT5Z

* Span ready-buffer density from the processing block number

The buffer is consumed at batch creation while committed progress only
catches up after the batch commits, so mid-batch the density's numerator
shrank without the denominator following. Track the in-flight batch's
progress as processingBlockNumber (advanced in advanceAfterBatch, caught
up in applyBatchProgress, rewound on rollback) and use it as the span's
lower boundary.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018CiEziFbVe1P3Y6iuyfT5Z

* Warm the chain with seed events in the partition-merge E2E test

A chain with no density signal now targets frontier + 20k, which gates the
far DC partitions this test fetches in parallel. Seed 100 events in the
registering response so the chain has a density signal and enough range
budget for DC2's full 10-chunk pipeline; cold gating itself is covered by
unit tests.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018CiEziFbVe1P3Y6iuyfT5Z

---------

Co-authored-by: Claude <noreply@anthropic.com>

* Reserve budget at honest itemsEst and tune scheduler defaults (#1403)

* Reserve budget at honest itemsEst instead of headroomed itemsTarget

Queries now carry both itemsTarget (server-side cap, sized with the chunk
headroom multiplier) and itemsEst (raw density estimate). Reservations,
pendingBudget, and water-fill footprints use itemsEst, so headroom no longer
throttles pipeline depth. The extra 3x budget cap for caught-up chains is
dropped — truncation safety lives solely in the itemsTarget cap, keeping
realtime headroom at 3x instead of compounding to 9x. Aligned chains may now
run 5% past the leader's line to stop clamp flapping when progress tracks
closely.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QyDQ2imktXA4jL8PtWYSmd

* Raise default target buffer to 100k and chunk pipeline cap to 12

Measured on the erc20 template against real HyperSync data: at 50k the dense
chain's buffer drained to zero in a quarter of samples (processing starved on
fetching), while at 100k it almost never does and throughput matches the
processing ceiling. Beyond 100k there's no further gain — 300k only grows the
resident buffer. The chunk cap rarely binds at 12 but gives the pipeline
headroom at the larger budget.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QyDQ2imktXA4jL8PtWYSmd

---------

Co-authored-by: Claude <noreply@anthropic.com>

* Fix future end block progress alignment (#1406)

* Keep below-head chains polling instead of dropping them (NothingToQuery)

At realtime (and during backfill), when one chain falls far behind and its
query reservation drains the shared fetch-buffer budget, a chain that is below
its own head gets no query this tick. Being below head it also won't wait for a
new block, so getNextQuery returns NothingToQuery. checkAndFetch never
dispatches NothingToQuery, so that chain stops fetching AND stops polling
getHeightOrThrow — its head tracking freezes. This reproduced two production
stalls: one right before the indexer enters isReady, and one after isReady
having queried only a few items.

Dispatch such a chain as WaitingForNewBlock so it keeps polling, mirroring the
existing knownHeight == 0 guard. A chain is still left idle (undispatched) when
it is genuinely so: caught up to its head/endblock, still draining in-flight
queries, or holding ready items that batch processing will drain and
re-schedule from.

Add an E2E regression test that drives two chains to realtime, then a divergent
height jump (leader far ahead, follower just past its own head), and asserts the
near-head follower keeps polling getHeightOrThrow while the leader backfills.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GBSAcMXTNy2K8i16bkJELo

* Extract client-side address filtering from FetchState (#1414)

* Filter over-fetched events before contract registration

Over-fetched events (a merged partition returning an address before its
effectiveStartBlock, or a wildcard param referencing an address registered
after the log's block) were running their contractRegister handlers and
spawning dynamic contracts before being dropped from the buffer.

Apply the client-side address filter to the contract-register set before
running the handlers. Extract the predicate as FetchState.filterByClientAddress
and expose it through ChainState so ChainFetching can gate registration; the
buffer is still filtered after registration in handleQueryResult, so events
referencing a contract registered in the same batch keep routing to handlers.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017xrChwhzSShi39DwN2iKV5

* Move client address filter fully before contract registration

Follow-up to the previous commit: instead of only gating the contract-register
set, apply the client-side address filter to the whole response up front, so an
over-fetched event neither spawns dynamic contracts nor enters the buffer.

This is only correct if a non-wildcard event for an address registered in the
same batch can't appear before its registration — which a real backend
guarantees, since a query only returns logs for the addresses it was sent. The
simulate source didn't model that (it dumped every item on the first call), so
make it faithful: return only items matching the query's block range, selection,
and (for non-wildcard events) address set, delivering each once; wildcards are
over-fetched for the client filter to gate, mirroring HyperSync. A contract
registered mid-run now surfaces its events in the follow-up query the
registration triggers, exactly as in production.

Parse simulate items at the process's startBlock (not the config default) so
they land in the range the source is queried over. The dead-input tracker stays
downstream, observing processed batches, so it still reports items excluded by
any filter.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017xrChwhzSShi39DwN2iKV5

* Merge buffer with a single sort-free pass instead of re-sorting

Buffer accumulation re-sorted the whole buffer on every response via
Array.sort(compareBufferItem) — an O(n log n) pass whose comparator crosses the
JS↔native boundary on each comparison — plus a Set of string keys for dedup.

Replace it with mergeIntoBuffer: the buffer is already sorted, so insertion-sort
just the (small, usually ascending) response and merge the two runs in one linear
pass, dropping duplicates as adjacent-equal. Comparison is inlined
(compareBufferItem now returns an int with explicit field compares and a
registration-index tiebreaker) with no Array.sort callback and no allocated key.
updateInternal assumes a sorted buffer (hot paths pass mutItemsSorted=true) and
normalizes arbitrary input otherwise; onBlock items are generated as their own
sorted run and merged in the same way.

~14-20x faster on realistic buffers (see packages/envio/bench). Adds a
mergeIntoBuffer correctness test.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017xrChwhzSShi39DwN2iKV5

* Address review: drop bench, single onBlock merge, simplify test helper

- Delete the standalone benchmark script.
- updateInternal now folds onBlock items into the buffer with a single merge at
  the end instead of merging mid-function; block items stay their own sorted run
  so the merge remains linear.
- makeInitialWithOnBlock returns the fetch state directly (indexing addresses
  were unused by every caller).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017xrChwhzSShi39DwN2iKV5

---------

Co-authored-by: Claude <noreply@anthropic.com>

* Replace water-fill budget algorithm with greedy fromBlock-sorted pass (#1415)

* Cap open-ended probe fan-out in the fetch water-fill

When the fresh per-tick budget is thin relative to the number of
partitions, the water-fill split gives each partition a sub-item
allotment that the open-ended emit floors to a 1-item query, so a
single tick fires a burst of near-empty probes and overshoots the
budget.

Concentrate instead: serve only the neediest
ceil(rangeItemsTarget / minQueryItems) probe partitions this tick, each
taking a full ~minQueryItems-sized probe, and let the rest wait until
freed reservations grow the budget. Chunk partitions self-limit via
density-sized chunks and are never capped, so normal fan-out and
post-rollback resume are untouched.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Keqij8JHea79B2rqJX4ApZ

* Select fetch queries by fromBlock against a chain budget

Replace the per-partition water-fill (and the earlier probe-fan-out cap)
with a single budget pass:

1. Generate every candidate query for the tick with no budget check —
   gap-fill holes, plus each in-range partition's density-sized chunks or,
   for an unknown-density partition, one open-ended probe sized to its even
   share of the fresh budget (freshBudget / inRangeCount).
2. Sort all candidates by fromBlock.
3. Accept them in that order while the budget (chainTargetItems minus
   in-flight reservations) stays positive; the query that tips it negative
   is still accepted, everything after it waits for a later tick.

Selecting by fromBlock spends the budget on the earliest blocks across all
partitions first, so the frontier advances evenly and no partition is
starved by iteration order — and gap-fill, chunks, and probes all stop
together once the budget is spent. Removes waterLevel and the minQueryItems
cap.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Keqij8JHea79B2rqJX4ApZ

* Size open-ended probes by chain density over the range to the target

An open-ended probe now reserves chainDensity × (chainTargetBlock −
fromBlock + 1) / partitionCount — the events its range to the target is
expected to hold, split across partitions — instead of an even share of
the fresh budget. ChainState passes its effectiveDensity down for this.

When the chain has no density signal, or the partition is already at the
target (no range), it falls back to the even budget share so cold chains
and caught-up partitions still probe.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Keqij8JHea79B2rqJX4ApZ

* Size probes by budget-implied density over the in-range coverage

Replace the passed-in chainDensity with a rangeTargetDensity derived
inside getNextQuery: freshBudget / (chainTargetBlock − frontierCursor + 1),
where frontierCursor is the furthest-behind in-range cursor. A probe then
reserves rangeTargetDensity × (chainTargetBlock − fromBlock + 1) /
inRangeCount, so a partition covering less of the range to the target (it
sits further ahead) gets proportionally fewer items, while the furthest-
behind partition gets the full even share.

Measuring the range from the in-range frontier (not the chain buffer
frontier) keeps a lone in-range partition on the full budget instead of
having it diluted by out-of-range laggards, and drops the chainDensity
parameter ChainState was threading down.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Keqij8JHea79B2rqJX4ApZ

* Optimize greedy budget pass: fewer sweeps, bounded generation (#1418)

- Fold the chainReserved sum and partitionIndexById build into the
  Phase A partition sweep (3 full passes over partitions -> 1).
- Cap per-partition chunk generation at the fresh budget: a partition
  can be accepted at most the budget plus one overshoot, so further
  chunks can never be accepted. Shrinks the candidate set and sort cost
  when the budget is small relative to the pending-chunk cap.
- Acceptance pass: sort candidates in place and stop at the first
  candidate that can't be accepted, instead of copying via toSorted and
  scanning the whole tail with forEach.
- Hoist the loop-invariant chunk-start ceiling out of the chunk loop.
- Rename waterFillState -> partitionFillState (no water-fill left).


Claude-Session: https://claude.ai/code/session_01Cj7fN5nh9d2rLeWAXnD1d5

Co-authored-by: Claude <noreply@anthropic.com>

* Fix budget deadlock when gap-fill precedes returned query (#1419)

* Let gap fills bypass the fresh-budget gate

A gap-fill candidate was gated on the fresh forward-progress budget, so a
partition could deadlock after a partial/out-of-order chunk: chunk [101,200]
returns and lingers in mutPendingQueries behind an unfilled [51,100] hole,
its reservation already released by ChainState, yet the FetchState budget
sweep still counted it — driving freshBudget to 0 and dropping the [51,100]
gap-fill every tick, so the returned query could never be consumed.

Fix by budgeting acceptance against the full chainTargetItems and reserving
in-flight queries per-query in fromBlock order: a gap-fill, whose fromBlock
precedes the query it unblocks, claims budget ahead of that reservation.
Returned-but-unconsumed queries (fetchedBlock set) no longer count toward the
budget, matching the release ChainState already performed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S6hp9FfBBT5insJwt2e28F

* Charge same-block reservations before fresh candidates

On a fromBlock tie, order in-flight reservations ahead of fresh candidates in
the acceptance stream. A same-block candidate could otherwise be emitted while
the pool budget was already exhausted (chainTargetItems still carrying
pendingBudget), pushing total reserved work past the target buffer. Only a
strictly-earlier candidate — a gap-fill preceding the query it unblocks —
should borrow ahead of a reservation.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S6hp9FfBBT5insJwt2e28F

---------

Co-authored-by: Claude <noreply@anthropic.com>

---------

Co-authored-by: Claude <noreply@anthropic.com>

* Separate event density from source range capacity (#1423)

* Separate and smooth per-partition event density (#1426)

* Separate event density from source range capacity

* Enable strict warning checks in ReScript configurations (#1424)

* Treat ReScript warning 23 as an error in indexer configs

Promote the "useless record with clause" warning to an error in the
generated-project template and the test scenarios. The envio runtime
package already errors on all warnings via "+a".

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DGX7HGV5nCwFHHoazo8dCM

* Enforce all ReScript warnings as errors in test scenarios

Set warnings.error to "+a" for the test_codegen, fuel_test, and svm_test
scenarios, matching the envio runtime package. Leave the user-facing
generated-project template without a warnings override.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DGX7HGV5nCwFHHoazo8dCM

---------

Co-authored-by: Claude <noreply@anthropic.com>

* Smooth per-partition event density

* Fix strict ReScript warnings after main merge

* Trust event density independently from source capacity

---------

Co-authored-by: Claude <noreply@anthropic.com>

* Fix reorg-threshold cross-chain query stall (#1430)

* Add PIN test reproducing the below-head chain silence stall

Reverts the earlier fix and exploratory tests and pins the exact production
stall on the unfixed scheduler: when one chain falls far behind and its query
reservation drains the shared fetch-buffer budget, a chain below its own head
but starved of budget emits no query and (being below head) won't wait for a new
block, so getNextQuery returns NothingToQuery. checkAndFetch never dispatches
NothingToQuery, so that chain stops querying AND stops polling getHeightOrThrow
and goes silent.

The test asserts the correct behavior — the starved below-head follower keeps
polling getHeightOrThrow. It is RED on this unfixed scheduler (the follower never
re-polls) and turns green once below-head chains are dispatched as
WaitingForNewBlock instead of being dropped (the "Keep below-head chains polling"
change). Verified red without the change and green with it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VhitjdvBNbfY6tRnv6RQcw

* Fix reorg-threshold cross-chain query stall

* Deduplicate fetch progress calculation

---------

Co-authored-by: Claude <noreply@anthropic.com>

* Add minimum query admission budget (#1429)

* Add minimum query admission budget

* Keep block waiters outside query admission

* Keep block waiting in query selection

* Pause all chain actions below admission floor

* Anchor cross-chain alignment to most-behind chain's frontier (#1434)

* Anchor cross-chain alignment line at the most-behind chain's frontier

The waterfall's alignment line was only established on ticks where the
most-behind chain itself emitted a fresh query and had a density signal.
While that chain's queries were in flight (or it was still cold), every
other chain fetched unclamped to its own head, defeating the cross-chain
ordering the line exists for.

- Derive the line from the most-behind known-height chain's fetch-frontier
  progress before dispatching, so it hol…
DZakh added a commit that referenced this pull request Aug 5, 2026
* fix: parse SVM accountFilters as array of AND-groups in public config (#1408)

The CLI emits accountFilters as Vec<Vec<SvmAccountFilterJson>> (AND-groups
OR-ed together, normalized from both the flat and any_of YAML shapes), and
the consumer in Config.fromPublic already maps it as nested groups. The
parse schema declared a flat array, so any SVM config using account_filters
failed to load with:

  Invalid indexer config: Failed parsing at ["svm"]["programs"][...]
  ["accountFilters"]["0"]["position"]. Reason: Expected int32,
  received undefined

Wrap the schema in one more S.array so it matches what the CLI emits and
what the consumer expects, and add a regression test.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>

* Move EVM event routing, decoding, and query construction to Rust (#1404)

* Move EVM event routing and decoding to the Rust clients

Give each onEventRegistration a chain-scoped sequential id (its index in
the chain's onEventRegistrations array) and pass the registrations -
id, isWildcard, sighash/topicCount, param metadata - into the Rust
EvmHypersyncClient and EvmRpcClient constructors. Rust now routes every
log to its registration (owning contract via the partition's
address -> contract-name index, wildcard fallback) before decoding:

- DecoderCore keys a per-MetaKey router (by_contract_name + wildcard)
  and decodes with only the routed variant's param names, so items carry
  flat params instead of a per-contract dict.
- get_event_items and getNextPage take the partition's
  contractNameByAddress; items return onEventRegistrationId and logs
  that route nowhere are dropped on the Rust side.
- The RPC client normalizes log addresses (lowercase/checksum) so they
  match the routing index and the JS address type directly.
- ReScript sources resolve items with
  onEventRegistrations[item.onEventRegistrationId]; EventRouter's EVM
  half (getEvmEventId, fromEvmEventModsOrThrow) is deleted and
  EvmChain.makeSources enforces the id = array index invariant.
- Registration-time duplicate/wildcard-collision validation is mirrored
  as a backstop in the Rust decoder constructor.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0145w9f63kqQmFf7mWMDWmQc

* Make onEventRegistration.id immutable

id is derived purely from push order — assign it via record spread when
the registration lands in the chain's array (HandlerRegister.finishRegistration,
EvmChain.makeSources) instead of mutating an existing field.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0145w9f63kqQmFf7mWMDWmQc

* Move EVM query construction to the Rust clients

Pass the full per-(event, chain) registration to the Rust clients at
construction — EventParamsInput becomes EventRegistrationInput, gaining
dependsOnAddresses, the resolvedWhere topic selections (per-topic
Option<Vec<String>>, None = contract-addresses marker), and the
selected block/transaction field lists. A shared SelectionBuilder
(evm_hypersync_source/selection.rs) owns everything a query derives
from the partition's selection and current addresses:

- log selections: address-free pooling + topic0 compression,
  per-contract address scoping, wildcard-by-address marker expansion
  into lowercase padded address topics, in registration order so query
  bytes stay stable for caching;
- HyperSync field selection: union over the selection's registrations
  with the transactionIndex exclusion, plus the forced required fields;
- the address -> contract-name routing index, derived from the
  partition's addressesByContractName instead of being passed
  separately.

The napi query surface shrinks to the block range plus the partition's
registration ids and addressesByContractName: get_event_items takes an
EventItemsQuery and builds the HyperSync query internally; get_next_page
drops log_selections/contract_name_by_address for registration_ids/
addresses_by_contract_name. Both clients expose build_log_selections
for tests and debugging.

On the ReScript side the per-source getSelectionConfig machinery
(bucketing, materialization, WeakMap memoization) is deleted from
HyperSyncSource and RpcSource; sources just forward selection ids and
addresses. LogSelection keeps only parseWhereOrThrow and the
materialize helpers used by tests; Rpc.GetLogs drops the topic-query
types. JS selection-shape tests are rewritten against
buildLogSelections, and field-selection behavior is covered by Rust
unit tests. Mock registrations now need hex-decodable sighashes since
the client validates them at construction.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0145w9f63kqQmFf7mWMDWmQc

* Store onEventRegistrationIndex on items; resolve registrations via ChainState

Internal.item's Event variant now carries onEventRegistrationIndex (the
registration's chain-scoped array position, renamed from id) instead of
the registration object, so Rust-built items can be final and complete.
The full registration is resolved through the chain's registration
array: stored on ChainState.t and mirrored in a per-chain registry in
Internal (setOnEventRegistrations at chain-state startup,
addOnEventRegistration for simulate/test setups that synthesize items,
getItemOnEventRegistration for consumers without a chain state at hand
— ecosystem toRawEvent/toEventLogger, FetchState's clientAddressFilter,
ChainFetching, EventProcessing, batch materialization).

Simulate appends its synthetic registrations into the run's
registrationsByChainId chain arrays (the same arrays chain-state startup
installs) instead of a side registry, so item indexes stay valid after
startup replaces the per-chain entry.

Rename the napi surface to match: EventRegistrationInput.index,
registration_indexes on both query params, on_event_registration_index
on items.

Drop the parallel eventRegistrations option on HyperSyncSource/RpcSource
— the Rust registration inputs are now derived inside the sources from
onEventRegistrations via HyperSyncClient.Registration.
fromOnEventRegistrations (moved from EvmChain), removing a second field
that had to stay in lockstep with the lookup array.

Fix indexed dynamic-type event filters: tuple/array where values were
passed through raw (previously latent — they only crossed napi at query
time and never in tests; passing registrations at client construction
surfaced it as a startup failure). Encode them as keccak256 of the ABI
encoding like the chain does, trying a directly-passed tuple as one
value before falling back to an OR-list of tuples.

Remove dead code (Rust add_field/ensure_required_log_fields/
TopicSelection::has_filters, ReScript QueryTypes topic helpers) and
refactor-narration comments.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0145w9f63kqQmFf7mWMDWmQc

* Rename EventRegistrationInput to OnEventRegistration; clarify decoder field names

Match the ReScript-side naming for the registration crossing the napi
boundary, and make the decoder's routing fields say what they hold:
EventVariant.on_event_registration_index, RegisteredEvent.
wildcard_variant_idx / variant_idx_by_contract_name.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0145w9f63kqQmFf7mWMDWmQc

* Fix event registration ownership and indexed topic encoding (#1412)

* Fix event registration ownership and topic encoding

* Allow empty standalone mock source responses

* Store registration state on mock sources

---------

Co-authored-by: Claude <noreply@anthropic.com>

* Replace test-only log selection API with E2E coverage (#1413)

* Add RPC source contract pin framework (#1416)

* Centralize config parsing tests around YAML (#1421)

* Centralize config parsing tests around YAML

* Explain SVM pubkey validation dependency

* Include licenses directory in published envio package (#1422)

* Fix incorrect license in envio package.json

The published envio package declared GPL-3.0, but the project ships a
proprietary SaaS EULA (licenses/LICENSE.md), not a GPL license. Mark the
package UNLICENSED to reflect its proprietary terms.

* Ship the EULA and reference it from the license field

The envio package is proprietary (licenses/LICENSE.md is a SaaS EULA), so
use the standard 'SEE LICENSE IN LICENSE.md' form instead of UNLICENSED, and
copy the EULA to the published package root so the reference resolves for
consumers. Add LICENSE.md to the artifact verifier's required files.

* Ship the full licenses directory with the envio package

The licenses/ dir holds four files: the HyperIndex software EULA (EULA.md),
the SaaS EULA (LICENSE.md), the CLA, and an overview README. The npm package
is the HyperIndex software, so point the license field at licenses/EULA.md and
copy the whole directory into the published package. Add 'licenses' to the
files allowlist (npm only force-includes a root LICENSE, not a subdirectory)
and verify every license file ships.

* Point license field at the licenses overview README

licenses/README.md is the licensing index: it explains which terms apply to
the software, generated code, and hosted service, and links the specific
EULAs. Reference it from the license field so consumers land on the overview
rather than a single EULA that only covers part of the picture.

---------

Co-authored-by: Claude <noreply@anthropic.com>

* Enable strict warning checks in ReScript configurations (#1424)

* Treat ReScript warning 23 as an error in indexer configs

Promote the "useless record with clause" warning to an error in the
generated-project template and the test scenarios. The envio runtime
package already errors on all warnings via "+a".

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DGX7HGV5nCwFHHoazo8dCM

* Enforce all ReScript warnings as errors in test scenarios

Set warnings.error to "+a" for the test_codegen, fuel_test, and svm_test
scenarios, matching the envio runtime package. Leave the user-facing
generated-project template without a warnings override.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DGX7HGV5nCwFHHoazo8dCM

---------

Co-authored-by: Claude <noreply@anthropic.com>

* Improve rollback logging and conditional event registration logging (#1425)

* Improve indexer logs for contract-register events and rollback range

Omit numContractRegisterEvents from the "Finished querying" log when it's
zero, and log the per-chain rollback block range for all affected chains
at info level so reorg rollbacks aren't limited to the reorg chain's
target block.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Xvxwf6mi6rT2AV16sx2KSL

* Emit per-chain rollback logs and quiet the batch-wait log

Drop the "Waiting for batch..." log to trace, remove the aggregate
"Rolled back chains on reorg" log, and replace the trace-level "Finished
rollback on reorg" log with a per-chain info "Rollbacked" log carrying the
chain id, from/to block range, rolled-back event count, and reorg-chain
flag.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Xvxwf6mi6rT2AV16sx2KSL

* Split rollback entity changes into a separate trace log

Restore the entity deleted/upserted detail as its own trace-level log and
drop the isReorgChain field from the per-chain "Rollbacked" info log.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Xvxwf6mi6rT2AV16sx2KSL

* Avoid chainId binding collision on rollback logs

Build the rollback logger without inheriting the reorg chain's logger,
which bound its chainId onto every line and collided with the per-chain
chainId on the "Rollbacked" logs. The reorg chain is identified by the
reorgChain param instead.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Xvxwf6mi6rT2AV16sx2KSL

---------

Co-authored-by: Claude <noreply@anthropic.com>

* Extract client-side address filtering from FetchState (#1414) (#1427)

* Filter over-fetched events before contract registration

Over-fetched events (a merged partition returning an address before its
effectiveStartBlock, or a wildcard param referencing an address registered
after the log's block) were running their contractRegister handlers and
spawning dynamic contracts before being dropped from the buffer.

Apply the client-side address filter to the contract-register set before
running the handlers. Extract the predicate as FetchState.filterByClientAddress
and expose it through ChainState so ChainFetching can gate registration; the
buffer is still filtered after registration in handleQueryResult, so events
referencing a contract registered in the same batch keep routing to handlers.


Claude-Session: https://claude.ai/code/session_017xrChwhzSShi39DwN2iKV5

* Move client address filter fully before contract registration

Follow-up to the previous commit: instead of only gating the contract-register
set, apply the client-side address filter to the whole response up front, so an
over-fetched event neither spawns dynamic contracts nor enters the buffer.

This is only correct if a non-wildcard event for an address registered in the
same batch can't appear before its registration — which a real backend
guarantees, since a query only returns logs for the addresses it was sent. The
simulate source didn't model that (it dumped every item on the first call), so
make it faithful: return only items matching the query's block range, selection,
and (for non-wildcard events) address set, delivering each once; wildcards are
over-fetched for the client filter to gate, mirroring HyperSync. A contract
registered mid-run now surfaces its events in the follow-up query the
registration triggers, exactly as in production.

Parse simulate items at the process's startBlock (not the config default) so
they land in the range the source is queried over. The dead-input tracker stays
downstream, observing processed batches, so it still reports items excluded by
any filter.


Claude-Session: https://claude.ai/code/session_017xrChwhzSShi39DwN2iKV5

* Merge buffer with a single sort-free pass instead of re-sorting

Buffer accumulation re-sorted the whole buffer on every response via
Array.sort(compareBufferItem) — an O(n log n) pass whose comparator crosses the
JS↔native boundary on each comparison — plus a Set of string keys for dedup.

Replace it with mergeIntoBuffer: the buffer is already sorted, so insertion-sort
just the (small, usually ascending) response and merge the two runs in one linear
pass, dropping duplicates as adjacent-equal. Comparison is inlined
(compareBufferItem now returns an int with explicit field compares and a
registration-index tiebreaker) with no Array.sort callback and no allocated key.
updateInternal assumes a sorted buffer (hot paths pass mutItemsSorted=true) and
normalizes arbitrary input otherwise; onBlock items are generated as their own
sorted run and merged in the same way.

~14-20x faster on realistic buffers (see packages/envio/bench). Adds a
mergeIntoBuffer correctness test.


Claude-Session: https://claude.ai/code/session_017xrChwhzSShi39DwN2iKV5

* Address review: drop bench, single onBlock merge, simplify test helper

- Delete the standalone benchmark script.
- updateInternal now folds onBlock items into the buffer with a single merge at
  the end instead of merging mid-function; block items stay their own sorted run
  so the merge remains linear.
- makeInitialWithOnBlock returns the fetch state directly (indexing addresses
  were unused by every caller).


Claude-Session: https://claude.ai/code/session_017xrChwhzSShi39DwN2iKV5

---------

Co-authored-by: Claude <noreply@anthropic.com>

* SVM: exclude failed-transaction instructions (#1428)

HyperSync serves instructions from failed Solana transactions and the
runtime delivered all of them to onInstruction handlers, silently
over-counting (~18% for SPL TransferChecked over the sampled slots).
Exclude instructions whose parent transaction did not commit, matching
EVM (reverted-tx logs never exist) and the old RPC `!tx.meta.err` pattern.

Filter client-side in SvmHyperSyncSource.getItemsOrThrow on the
`isCommitted` flag HyperSync already delivers on every instruction row (a
required column, zero extra bandwidth). The current query API cannot push
this down (InstructionSelection exposes only `is_inner`; instruction and
transaction selections union at block level rather than joining), so the
client-side check stands until HyperSync adds a server-side `is_committed`
predicate, at which point it becomes a redundant safety net.

No opt-in knob for now: keep the surface minimal and add a config option
(e.g. per-instruction `include_failed`) if and when someone needs failed
transactions. Deferring it also leaves the opt-in design open rather than
committing to a config shape prematurely.

HOS-1610

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Dmitry Zakharov <dzakh.dev@gmail.com>

* Fix rollback handling for deleted entities (#1431)

* Fix rollback handling for deleted entities

* Return rollback removed IDs directly

* Harden rollback test error handling

* Add Tron chain to fix hypersync health check (#1436)

Tron (chain_id 728126428) is served publicly by the HyperSync API but was
missing from the Network enum, causing the health check to fail.


Claude-Session: https://claude.ai/code/session_011GxCWhUKxvdy8zgg44wvMr

Co-authored-by: Claude <noreply@anthropic.com>

* Add per-chain effect caching and rate limiting (#1432)

* feat(effects): per-chain cache scoping via crossChain option

Add a `crossChain` option to the Effect API (defaults to `true`). When
`crossChain: false`, an effect's cache and rate-limit window are isolated
per chain and the handler can read `context.chain.id`.

- Public API: `crossChain?: boolean` on effect options; required
  `context.chain.id` in ReScript and TypeScript types. Reading
  `context.chain` on a cross-chain effect throws a guiding error.
- Scope model (`CrossChain | Chain(int)`) resolved from the effect config
  and the current handler chain. Nested calls follow: handler -> either;
  chain -> either; cross-chain -> cross-chain; cross-chain -> chain fails
  before cache lookup with both effect names and remediation.
- Per-scope runtime boundary: in-memory cache, in-flight dedup, rate-limit
  window/queue and active-call state are keyed by the resolved cache
  address; the canonical input key is unchanged.
- Central reversible mapping `Internal.EffectCache` between
  (effectName, scope) <-> table name <-> cache file path, used everywhere
  instead of prefix slicing. Cache metadata is keyed by the full address.
- Postgres: cross-chain tables `envio_effect_<name>`, chain-scoped
  `envio_<chainId>_effect_<name>`; discovery matches both formats.
  `.envio/cache` gains numeric per-chain subdirectories; restore rejects
  malformed chain directories and supports one directory level; dump does
  the exact reverse mapping.

Tests: address round trips / legacy / coexistence / invalid parsing,
per-chain dedup and independent rate limits, cross-chain sharing, and an
E2E covering context.chain.id, the guiding errors, and per-chain
persistence.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X47KFbCFtGSXgGqFsBrCgH

* refactor(effects): unboxed effectScope, enumerable chain getter, exact error tests

Address review feedback:
- Make `context.chain` an enumerable own getter closing over the resolved
  chain, dropping the hidden `_chainId`/`_effectName` instance fields.
- Mark `effectScope` `@unboxed` (CrossChain -> "crossChain", Chain(id) ->
  the raw id, discriminated by runtime type).
- Assert the exact cross-chain `context.chain` and nested cross-chain ->
  chain-scoped error messages in the E2E test.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X47KFbCFtGSXgGqFsBrCgH

* refactor(effects): address review — generic chainScope, resolved-table write path, prototype getter

- Rename `effectScope` -> `chainScope` (generic; reused for entities later).
- Persistence write path no longer threads effect+scope: `updatedEffectCache`
  and `setEffectCacheOrThrow` take the resolved `table` (the cache address) +
  item schema. The in-mem table now holds its built `table`, so the address is
  resolved once in `getEffectInMemTable` and reused by load/snapshot/write.
- Move the `context.chain` getter back onto the prototype (enumerable, like
  `log`), reading per-instance non-enumerable fields.
- Collapse the two MockIndexer cache-query helpers into one
  `queryEffectCache(effect, ~scope=?)`.
- Tighten the crossChain docs: concise and user-facing, no table/file internals.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X47KFbCFtGSXgGqFsBrCgH

* Add scope label to per-scope effect gauges and strict chain-id parsing (#1435)

The envio_effect_active_calls, envio_effect_cache, and envio_effect_queue
gauges are backed by per-scope state since caching became chain-scoped, so
scopes of the same effect clobbered each other's value. Label them with
scope: "crossChain" | <chain id>.

Cache directory chain ids are now parsed strictly: "1foo" and "007" are
rejected instead of being treated as chains 1 and 7 via parseInt semantics.


Claude-Session: https://claude.ai/code/session_011YLPufR6wf9LYFAsNjKz1t

Co-authored-by: Claude <noreply@anthropic.com>

* fix(effects): validate effect names and guard cache-table discovery by columns

Two review points not covered by #1435:

- Validate effect names to `[A-Za-z0-9_-]+` in createEffect. The name is used
  as a cache table name and a .envio/cache path segment, so path separators and
  traversal (`a/b`, `../evil`) must be rejected to keep the
  (name, scope) <-> table <-> path mapping reversible.
- Cache-table discovery now also requires the effect-cache column shape
  (exactly `id` + `output`), so a user entity table that matches the reserved
  name pattern is never mistaken for an effect cache.

#1435 already addressed the per-scope metric-gauge clobbering (via a scope
label) and strict chain-id parsing, so those are not duplicated here.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X47KFbCFtGSXgGqFsBrCgH

* fix(effects): preserve rate-limit budget across rollbacks; guard cache table length

- Rate-limit windows lived on the per-scope effect in-mem table, which a reorg
  wipes (beginRollbackDiff clears state.effects), refilling the budget on
  replay. Keep them in a survivor dict on IndexerState (not cleared on
  rollback), keyed by cache table name; each recreated in-mem table reuses the
  same window. Rate limiting reflects real API throughput, not indexing
  progress. + regression test.
- Reject effect cache table names longer than PostgreSQL's 63-char identifier
  limit in makeCacheTable, instead of letting PG silently truncate and diverge
  from what cache discovery reads back.
- Make the per-chain rate-limit test assert that chain 2 bypasses chain 1's
  queue (order) rather than relying on which call resolves first.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X47KFbCFtGSXgGqFsBrCgH

* revert(effects): drop the 63-char cache table name guard

An effect name long enough to overflow the scoped identifier is unrealistic;
the guard isn't worth the runtime throw.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X47KFbCFtGSXgGqFsBrCgH

* refactor(effects): encapsulate effect state in an EffectState module

Effect runtime state was two loose dicts on IndexerState with divergent
rollback lifecycles (cache wiped by beginRollbackDiff, rate-limit windows
deliberately kept), an invariant that lived only in a comment.

Introduce a nested IndexerState.EffectState module (mirroring EntityTables)
that owns both maps and exposes getTable / forEach / resetForRollback. The
rollback semantics — drop cache tables, preserve rate-limit windows — are now
enforced by resetForRollback rather than remembered. Not folded into
ChainState/CrossChainState: effect state is keyed by (effect, scope) and
cross-chain effects have no chain, so it's a separate concern from chain
fetch/coordination state.

Behavior-preserving: InMemoryStore.getEffectInMemTable and Writing now delegate
to the module; all effect/rollback tests pass unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X47KFbCFtGSXgGqFsBrCgH

* refactor(effects): address review — extract EffectState, constructor chain field, required scope

- Move the EffectState module out of IndexerState into its own EffectState.res
  / .resi file.
- context.chain: install it in the EffectContext constructor instead of a
  prototype getter — a plain data field `{ id }` for chain-scoped effects (no
  getter), and only cross-chain contexts install a shared top-level throwing
  getter (created once, not per context).
- MockIndexer.queryEffectCache: make the `~scope` argument required; pass it
  explicitly at all call sites.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X47KFbCFtGSXgGqFsBrCgH

* docs(effects): drop redundant rateLimitState comment

The option type already conveys "None when the effect has no rate limit".

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X47KFbCFtGSXgGqFsBrCgH

* fix(effects): scope effect-call timing metrics per chain

prevCallStartTimerRef and active-call state moved per (effect, scope),
but the call_seconds/call_seconds_total/call_total counters were still
keyed by effect only. Overlapping calls on different chains double-counted
wall time into one series. Give these counters the same {effect, scope}
labels as the active-calls gauge so each scope tracks its own throughput.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X47KFbCFtGSXgGqFsBrCgH

* fix(effects): allow dots in effect names

The name-validation regex rejected existing safe names like "token.metadata".
Dots round-trip fine through the (name, scope) <-> table <-> path mapping
(table names are quoted; the cache scanner strips only the ".tsv" suffix).
Allow dots mid-name while still excluding path separators and forbidding a
leading dot, so a name can never be "." / ".." or traverse out of the cache dir.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X47KFbCFtGSXgGqFsBrCgH

---------

Co-authored-by: Claude <noreply@anthropic.com>

* Replace prune throttler with smart scheduling in write loop (#1444)

* Fix history prune racing batch writes and losing rollback anchors

The stale-history prune ran on its own throttler concurrently with batch
writes. Its anchor deletion relies on "no history after the safe
checkpoint", which a concurrently committing batch falsifies: the batch's
backfill sees the anchor and skips, the prune sees no post-safe rows and
deletes the anchor, and after both commit the entity has history only
above the safe checkpoint. A later rollback then deletes the entity
instead of restoring it.

Move pruning into the write loop so it can never overlap a history write
for the same entity:

- Each write picks up to 5 pg entities not pruned for the prune interval,
  excluding entities written in the batch (rollback writes touch every
  history table, so they get none), and prunes them one at a time
  concurrently with the batch write, awaited before the next write.
- Entities starved of the concurrent prune (eg written in every batch)
  are force-pruned sequentially right after the write, once they haven't
  been pruned for 5x the interval.
- Prune failures are logged instead of failing the write loop.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UjkHvbFKZxk1L3JY6e7HCt

* Select prune targets in a single pass over entities

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UjkHvbFKZxk1L3JY6e7HCt

* Throttle failed prune retries and keep checkpoint pruning out of rollback writes

Record the prune attempt time on failure too, so a failing entity retries
on the prune interval instead of on every write. Run checkpoint pruning
only alongside a concurrent entity prune; when nothing runs concurrently
(eg a rollback write) it moves to the forced phase after the write.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UjkHvbFKZxk1L3JY6e7HCt

---------

Co-authored-by: Claude <noreply@anthropic.com>

* Refactor query sizing to water-fill budget across chains (#1392)

* Make multichain fetch scheduling chain-controlled

Replace the per-partition/per-query greedy admission scheduler with a
per-chain waterfall: CrossChainState.checkAndFetch visits chains
furthest-behind first, handing each its remaining share of the shared
buffer budget. ChainState turns that budget into a soft target block
using a new chain-wide event density (seeded from cumulative progress,
smoothed with an EMA per batch), and FetchState.getNextQuery sizes
known-density partitions against that target block while splitting
whatever budget is left across partitions with unknown density.

This concentrates fetch effort on the bottleneck chain per tick instead
of scattering a shared item budget across every chain's full candidate
query set.

* Fix probe-split eligibility and query ordering in FetchState.getNextQuery

The unknown-density probe split counted partitions with nothing left to
query (already at their endBlock/mergeBlock/knownHeight ceiling), inflating
the divisor and under-sizing eligible partitions' queries. Add a
hasEligibleRange check mirroring pushQueriesForRange's own gate to exclude
them.

Splitting partitions into known/unknown passes also broke the original
idsInAscOrder query ordering that several tests assert on positionally;
restore it by sorting the final query list back into partition order.

Update FetchState_test.res fixtures accordingly, including a case that
needed distinct expected values across three eligibility scenarios that
previously shared one fixture.

* Redesign FetchState.getNextQuery as an even per-partition water-fill

Query creation now splits the chain's range budget evenly across
in-range partitions each round, rather than sizing every known-density
partition against the full chain target while unknown-density
partitions fought over the leftover. A partition already holding more
budget than its even share (e.g. from an earlier tick's in-flight
query) sits out a round so its share flows to the others, and the
split is recomputed each round against the shrinking set of partitions
still needing more.

Also:
- Rename estResponseSize -> itemsTarget throughout, since the field is
  now both the server-side maxNumLogs-style cap and the budget
  reservation/consumption unit, not just an estimate.
- Bucket queries by partition index as they're created instead of
  sorting the whole result at the end of every tick.
- Only trust a partition's density once it has two responses
  (matching the existing chunking-heuristic gate); a single response
  is too noisy to size the next query from.
- Smooth the chain-wide density EMA as (old + new) / 2 instead of
  (2*old + new) / 3.

* Address review feedback on the water-fill scheduler: dedupe reserved-sum
walk, tighten round bound, add coverage

- getNextQuery walked every partition's mutPendingQueries twice (once
  for chainReserved, again to seed reservedByPartition per partition).
  Merge into a single pass.
- Replace the unproven roundsRef < 1000 safety cap with a provable
  bound: every active partition either finishes or advances its chunk
  count each round, capped at maxPendingChunksPerPartition, and all
  active partitions progress in lockstep (not one at a time), so no
  partition can outlive maxPendingChunksPerPartition + 1 rounds.
- Add ChainState_test.res covering the chain density seed (from
  resumed progress) and the EMA blend.
- Add a CrossChainState_test.res case pinning the waterfall's actual
  cross-chain budget flow: a chain whose real range caps its
  consumption below its share leaves the remainder for the next chain.

* Make the water-fill round's per-partition share order-independent

Each round computed ipb = rangeBudget/n once, but then capped every
partition's actual budget at min(rangeBudget, ipb - reserved) and
decremented rangeBudget after each partition — so a partition
processed earlier in the same round (e.g. one forced to overshoot its
share via the "at least one full chunk" rule) shrank the pool for
whoever came after it. Same reservations, different iteration order,
different split (and total consumption could even exceed rangeBudget
depending on order).

Fix: every partition's share for a round is ipb - reserved, fixed for
the whole round; rangeBudget is only re-derived once, from the round's
actual total consumption, after every partition has had its fixed
shot. A partition can still overshoot its own share, but it can no
longer steal from another partition in the same round.

Also fixes a SourceManager_test.res assertion that was pinned to the
old order-dependent rounding artifact (three identical partitions
splitting a budget three ways used to get 16667/16667/16666; they now
all get 16667, as they should since they're indistinguishable).

* Redistribute a filled partition's leftover budget in the water-fill (#1394)

The per-partition round budget was `ipb - reserved`, where `ipb` was an
even share of only the *remaining fresh* budget (`rangeBudget / n`) while
`reserved` accumulated each partition's full footprint (existing in-flight
+ gap-fill + this call's prior-round emissions). Those two are on different
scales, so once a chunked partition's running reservation passed a later
round's fresh share, `ipb - reserved` went negative and the partition was
dropped — leaving budget unspent even though it still had range to fetch
and a sibling had just freed its share by filling early.

Compute the round's level as a real water-fill line — (remaining fresh
budget + the still-not-filled partitions' current footprint) / count —
and top each partition up toward it. A partition already above the line
gets nothing (its head start is its whole share); the rest absorb the
leftover, so the budget is fully used and per-partition totals stay even.
The loop now runs until either the not-filled set drains or the whole
fresh budget is reserved, dropping the redundant round cap: a partition
survives a round only by advancing chunksUsedThisCall (bounded by
maxPendingChunksPerPartition) or consuming budget, so it terminates on
its own.

Add a regression test: a range-capped partition and a deep partition
splitting a 900-item budget — the deep one now absorbs the capped one's
freed share (4 chunks / 720 items) instead of stopping at 2.


Claude-Session: https://claude.ai/code/session_0134TTgxQ3ci5mWUnt928yr9

Co-authored-by: Claude <noreply@anthropic.com>

* Cap unknown-density probe query at maxItemsTarget

An unknown-density partition's open-ended probe was sized to its full even
share of the chain's budget with no ceiling. When such a chain leads the
furthest-behind waterfall, that share is the entire cross-chain buffer pool,
so its single probe consumed 100% of the remaining budget and starved any
sibling chain needing its own first probe in the same tick (e.g. multiple
chains entering the reorg threshold together).

Cap the probe at maxItemsTarget (10_000), restoring the old bounded-default
ceiling. The leftover budget flows to the next chain via checkAndFetch's
remaining subtraction, exactly as before.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GBSAcMXTNy2K8i16bkJELo

* Don't seed chain density from a zero-event batch

The resume-seed path only sets chainDensity once numEventsProcessed > 0, but
the per-batch EMA update seeded Some(0.) after any progress-only batch (blocks
advanced, no events), contradicting that documented behavior and making the
first real batch blend against 0 instead of seeding from its own density.

Guard the EMA seed on the batch having events, matching the resume path. The
Some(oldDensity) blend is unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GBSAcMXTNy2K8i16bkJELo

* Adjust itemsTarget setting logic

* Enforce reservation == server cap and stop chunking without trusted density

- Floor itemsTarget at 1 at creation (densityItemsTarget, water-fill chunk
  loop, probe) so a query's budget reservation always equals the
  maxNumLogs-style cap sent to the server; drop SourceManager's 2000-item
  fallback that let density-0 queries return up to 2000 unaccounted items.
- Emit density-priced chunks only for a trusted positive density; density-0
  and unknown-density partitions get a single open-ended probe sized at the
  even split of the tick's fresh budget (maxItemsTarget cap removed). This
  removes the chunkCost=0 path that flooded 10 free hard-bounded chunks per
  partition and froze the 1.8x range growth.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01McpcXkR3pPWfEcq4mCj9Sw

* Extract getTrustedDensity helper for water-fill chunk sizing

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01McpcXkR3pPWfEcq4mCj9Sw

* Make query itemsTarget an int and trim redundant comments

The ceil-to-int conversion now happens once at query creation, so the
reservation, the budget accounting, and the server cap all use the same
integer value; SourceManager passes it through untouched.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01McpcXkR3pPWfEcq4mCj9Sw

* Price gap-fill by trusted density with available-density fallback

Gap queries now use getTrustedDensity: chunks only on a trusted positive
density (same rule as the water-fill); a trusted-zero density prices the
whole gap as one open query, and a partition with no density signal prices
it by available density — its equal-divide budget spread over the remaining
range this tick — so a small gap reserves proportionally little instead of
a noisy one-sample estimate or a NaN from dividing by a zero range.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01McpcXkR3pPWfEcq4mCj9Sw

* Cap follower chains at the leader's target progress in the waterfall

Chains beyond the most-behind one in the budget waterfall are now capped
at that leader's target progress, mapped onto their own block range
(ChainState.progressAtBlock/blockAtProgress), so no chain runs further
ahead than the chain the shared buffer pool is prioritizing. A chain
visited after the pool is exhausted simply sits out the round — its
reservations release as responses land, so the next tick redistributes.

FetchState's dynamic-contract partition merge now inherits the sum of
its parents' trusted densities (weighted onto the merged partition's
min query range) instead of resetting to 0, so a merge with density
history doesn't regress to an unpriced probe.

Update E2E/rollback tests to the now-serialized cross-chain query
dispatch (most-behind chain queries first; siblings follow once its
response releases budget) and to give density-dependent chunking tests
a nonzero item count to trust.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01McpcXkR3pPWfEcq4mCj9Sw

* Cap a clamped chain's fresh budget at its density-priced range cost

When a chain's target block is clamped (head, endBlock, or the
cross-chain alignment cap), a known-density chain's fresh budget is now
capped at density x clamped range (in-flight reservations stay on top so
they don't crowd out new partitions). The unused remainder stays in the
waterfall's pool and flows to the next chain in the same tick, instead
of being held by an oversized probe until the response lands.

This also removes the drain loop the infinite-reorg-loop test needed:
the non-reorg chain's post-rollback refetch now reserves only its real
range cost, so the reorg chain gets budget immediately.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01McpcXkR3pPWfEcq4mCj9Sw

* Give head-bound queries 2x density headroom in the budget cap

A query clamped at the head sized exactly at density x range truncates at
the server cap whenever the range is slightly denser than the estimate,
forcing an immediate catch-up query for the last few blocks. Double the
range cost for head-bound targets so one query usually suffices; the
extra reservation releases as soon as the response lands.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01McpcXkR3pPWfEcq4mCj9Sw

* Refine chain budget caps: endBlock ceiling, 5k probe cap, 3x head headroom

- targetBlock now clamps at endBlock (when below the head) via a shared
  fetchCeiling helper, so endBlock'd chains stop sizing and aligning
  against range they'll never fetch.
- A chain with no positive density signal caps its fresh budget at 5k,
  so one unknown chain measuring its first responses no longer holds the
  whole cross-chain pool.
- Head/endBlock-bound queries get 3x (was 2x) density headroom against
  truncating at the server cap and needing a catch-up query.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01McpcXkR3pPWfEcq4mCj9Sw

* Fix clippy::useless_borrows_in_formatting across cli package

Remove redundant & references in format!/anyhow! arguments flagged by
the CI-pinned clippy (rust 1.97). Pre-existing on the base branch,
unrelated to the SourceManager/waterfall changes in this PR — fixed
here since it was blocking cargo-test from going green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01McpcXkR3pPWfEcq4mCj9Sw

* Fix clippy::to_string_in_format_args exposed by the previous fix

Removing the redundant & in anyhow!'s self.id.to_string() surfaced a
second lint on the same line: ChainId (u64) already implements Display,
so .to_string() inside the format arg is itself redundant.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01McpcXkR3pPWfEcq4mCj9Sw

---------

Co-authored-by: Claude <noreply@anthropic.com>

* Fix clippy useless_borrows_in_formatting errors blocking CI

main's cargo-test job started failing clippy (-D warnings) on pre-existing
code after a stable-toolchain drift (no rust-toolchain pin), unrelated to
this PR's scheduling changes but inherited via the origin/main merge.
Removed the redundant `&` in format!/anyhow! args across 6 files, and
dropped a now-also-flagged explicit .to_string() on a Display type in
validation.rs.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GBSAcMXTNy2K8i16bkJELo

* Pour water-fill budget at an exact level and tighten chain budget edges

- Replace the per-round mean line in FetchState.getNextQuery with an exact
  water level (sum of top-ups equals the poured budget), so uneven in-flight
  reservations can no longer inflate other partitions' allotments past the
  fresh budget
- Size unknown-density probes by their water-fill allotment instead of a
  fixed pre-round even split, so leftover budget reaches the partitions
  without reservations instead of being stranded
- Gate the 3x head headroom on the chain having caught up once (isReady)
- Blend chain density weighted by the batch's block span instead of a flat
  (old + new) / 2
- Clamp progressAtBlock at 0 for the initial -1 fetch frontier
- Skip chains with no known height in the cross-chain waterfall so they wait
  for a block instead of setting a degenerate alignment line

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KvUhc8DhbxjGPEtHoDNThJ

* Shrink density blend window to 100 blocks

Small batches (a few blocks) should barely nudge the chain density estimate,
while anything spanning 100+ blocks is a trustworthy fresh sample.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KvUhc8DhbxjGPEtHoDNThJ

* Add chunk headroom multiplier for budget-aware query sizing (#1400)

* Add chunk itemsTarget headroom and budget-driven chunk emission

Chunk reservations now carry a headroom multiplier over the density
estimate (1.5x during backfill, 3x in realtime, chosen in
CrossChainState.checkAndFetch and threaded down to
FetchState.getNextQuery), so a denser-than-expected range doesn't
truncate at the server cap. Open-ended probes stay allotment-sized.

The emit loop replaces the precomputed chunkCost/affordable estimate
with per-chunk actual itemsTarget accounting: the first chunk always
emits full-size, subsequent chunks only while they fit the budget, and
the min-one-chunk force applies once per call instead of once per
water-fill round.

Cap-hit truncations (partial response with itemsCount >= itemsTarget)
no longer update the chunk range history — they reflect our own
reservation, not server capacity. Sub-cap partials still do.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KpFcYPn8UbQfaEfW6gjant

* Restore min-one-chunk per water-fill round

A leftover re-pour forces a full chunk again, so the budget never
strands on chunk quantization; the overshoot stays bounded at one
chunk per partition per round and self-corrects via the reported
reservations. Drops the per-call emittedThisCall bookkeeping.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KpFcYPn8UbQfaEfW6gjant

---------

Co-authored-by: Claude <noreply@anthropic.com>

* Implement cold-chain targeting and density-aware query sizing (#1401)

* Contain queries to the chain target block and rework cold-start sizing

- No chunk or gap-fill query starts past chainTargetBlock; emitted chunks
  keep their full span, with endBlock/mergeBlock staying the hard bounds.
  Skipped gaps regenerate from the pending-walk and fill once the target
  reaches them.
- A chain with no density signal targets frontier + coldTargetRange
  (init 20k), doubling whenever it goes idle without producing a signal,
  capped at the fetch ceiling. The cross-chain waterfall clamps a cold
  chain to min(5k, targetBufferSize), replacing the internal probe clamp,
  and a cold leader no longer sets the alignment line.
- Query sizing uses effectiveDensity = max(processing EMA, ready-buffer
  density), so a dense buffer overrides a stale-low EMA and ready items
  alone take a chain out of cold mode.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018CiEziFbVe1P3Y6iuyfT5Z

* Replace cold-window doubling with a fixed 20k range

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018CiEziFbVe1P3Y6iuyfT5Z

* Span ready-buffer density from the processing block number

The buffer is consumed at batch creation while committed progress only
catches up after the batch commits, so mid-batch the density's numerator
shrank without the denominator following. Track the in-flight batch's
progress as processingBlockNumber (advanced in advanceAfterBatch, caught
up in applyBatchProgress, rewound on rollback) and use it as the span's
lower boundary.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018CiEziFbVe1P3Y6iuyfT5Z

* Warm the chain with seed events in the partition-merge E2E test

A chain with no density signal now targets frontier + 20k, which gates the
far DC partitions this test fetches in parallel. Seed 100 events in the
registering response so the chain has a density signal and enough range
budget for DC2's full 10-chunk pipeline; cold gating itself is covered by
unit tests.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018CiEziFbVe1P3Y6iuyfT5Z

---------

Co-authored-by: Claude <noreply@anthropic.com>

* Reserve budget at honest itemsEst and tune scheduler defaults (#1403)

* Reserve budget at honest itemsEst instead of headroomed itemsTarget

Queries now carry both itemsTarget (server-side cap, sized with the chunk
headroom multiplier) and itemsEst (raw density estimate). Reservations,
pendingBudget, and water-fill footprints use itemsEst, so headroom no longer
throttles pipeline depth. The extra 3x budget cap for caught-up chains is
dropped — truncation safety lives solely in the itemsTarget cap, keeping
realtime headroom at 3x instead of compounding to 9x. Aligned chains may now
run 5% past the leader's line to stop clamp flapping when progress tracks
closely.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QyDQ2imktXA4jL8PtWYSmd

* Raise default target buffer to 100k and chunk pipeline cap to 12

Measured on the erc20 template against real HyperSync data: at 50k the dense
chain's buffer drained to zero in a quarter of samples (processing starved on
fetching), while at 100k it almost never does and throughput matches the
processing ceiling. Beyond 100k there's no further gain — 300k only grows the
resident buffer. The chunk cap rarely binds at 12 but gives the pipeline
headroom at the larger budget.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QyDQ2imktXA4jL8PtWYSmd

---------

Co-authored-by: Claude <noreply@anthropic.com>

* Fix future end block progress alignment (#1406)

* Keep below-head chains polling instead of dropping them (NothingToQuery)

At realtime (and during backfill), when one chain falls far behind and its
query reservation drains the shared fetch-buffer budget, a chain that is below
its own head gets no query this tick. Being below head it also won't wait for a
new block, so getNextQuery returns NothingToQuery. checkAndFetch never
dispatches NothingToQuery, so that chain stops fetching AND stops polling
getHeightOrThrow — its head tracking freezes. This reproduced two production
stalls: one right before the indexer enters isReady, and one after isReady
having queried only a few items.

Dispatch such a chain as WaitingForNewBlock so it keeps polling, mirroring the
existing knownHeight == 0 guard. A chain is still left idle (undispatched) when
it is genuinely so: caught up to its head/endblock, still draining in-flight
queries, or holding ready items that batch processing will drain and
re-schedule from.

Add an E2E regression test that drives two chains to realtime, then a divergent
height jump (leader far ahead, follower just past its own head), and asserts the
near-head follower keeps polling getHeightOrThrow while the leader backfills.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GBSAcMXTNy2K8i16bkJELo

* Extract client-side address filtering from FetchState (#1414)

* Filter over-fetched events before contract registration

Over-fetched events (a merged partition returning an address before its
effectiveStartBlock, or a wildcard param referencing an address registered
after the log's block) were running their contractRegister handlers and
spawning dynamic contracts before being dropped from the buffer.

Apply the client-side address filter to the contract-register set before
running the handlers. Extract the predicate as FetchState.filterByClientAddress
and expose it through ChainState so ChainFetching can gate registration; the
buffer is still filtered after registration in handleQueryResult, so events
referencing a contract registered in the same batch keep routing to handlers.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017xrChwhzSShi39DwN2iKV5

* Move client address filter fully before contract registration

Follow-up to the previous commit: instead of only gating the contract-register
set, apply the client-side address filter to the whole response up front, so an
over-fetched event neither spawns dynamic contracts nor enters the buffer.

This is only correct if a non-wildcard event for an address registered in the
same batch can't appear before its registration — which a real backend
guarantees, since a query only returns logs for the addresses it was sent. The
simulate source didn't model that (it dumped every item on the first call), so
make it faithful: return only items matching the query's block range, selection,
and (for non-wildcard events) address set, delivering each once; wildcards are
over-fetched for the client filter to gate, mirroring HyperSync. A contract
registered mid-run now surfaces its events in the follow-up query the
registration triggers, exactly as in production.

Parse simulate items at the process's startBlock (not the config default) so
they land in the range the source is queried over. The dead-input tracker stays
downstream, observing processed batches, so it still reports items excluded by
any filter.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017xrChwhzSShi39DwN2iKV5

* Merge buffer with a single sort-free pass instead of re-sorting

Buffer accumulation re-sorted the whole buffer on every response via
Array.sort(compareBufferItem) — an O(n log n) pass whose comparator crosses the
JS↔native boundary on each comparison — plus a Set of string keys for dedup.

Replace it with mergeIntoBuffer: the buffer is already sorted, so insertion-sort
just the (small, usually ascending) response and merge the two runs in one linear
pass, dropping duplicates as adjacent-equal. Comparison is inlined
(compareBufferItem now returns an int with explicit field compares and a
registration-index tiebreaker) with no Array.sort callback and no allocated key.
updateInternal assumes a sorted buffer (hot paths pass mutItemsSorted=true) and
normalizes arbitrary input otherwise; onBlock items are generated as their own
sorted run and merged in the same way.

~14-20x faster on realistic buffers (see packages/envio/bench). Adds a
mergeIntoBuffer correctness test.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017xrChwhzSShi39DwN2iKV5

* Address review: drop bench, single onBlock merge, simplify test helper

- Delete the standalone benchmark script.
- updateInternal now folds onBlock items into the buffer with a single merge at
  the end instead of merging mid-function; block items stay their own sorted run
  so the merge remains linear.
- makeInitialWithOnBlock returns the fetch state directly (indexing addresses
  were unused by every caller).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017xrChwhzSShi39DwN2iKV5

---------

Co-authored-by: Claude <noreply@anthropic.com>

* Replace water-fill budget algorithm with greedy fromBlock-sorted pass (#1415)

* Cap open-ended probe fan-out in the fetch water-fill

When the fresh per-tick budget is thin relative to the number of
partitions, the water-fill split gives each partition a sub-item
allotment that the open-ended emit floors to a 1-item query, so a
single tick fires a burst of near-empty probes and overshoots the
budget.

Concentrate instead: serve only the neediest
ceil(rangeItemsTarget / minQueryItems) probe partitions this tick, each
taking a full ~minQueryItems-sized probe, and let the rest wait until
freed reservations grow the budget. Chunk partitions self-limit via
density-sized chunks and are never capped, so normal fan-out and
post-rollback resume are untouched.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Keqij8JHea79B2rqJX4ApZ

* Select fetch queries by fromBlock against a chain budget

Replace the per-partition water-fill (and the earlier probe-fan-out cap)
with a single budget pass:

1. Generate every candidate query for the tick with no budget check —
   gap-fill holes, plus each in-range partition's density-sized chunks or,
   for an unknown-density partition, one open-ended probe sized to its even
   share of the fresh budget (freshBudget / inRangeCount).
2. Sort all candidates by fromBlock.
3. Accept them in that order while the budget (chainTargetItems minus
   in-flight reservations) stays positive; the query that tips it negative
   is still accepted, everything after it waits for a later tick.

Selecting by fromBlock spends the budget on the earliest blocks across all
partitions first, so the frontier advances evenly and no partition is
starved by iteration order — and gap-fill, chunks, and probes all stop
together once the budget is spent. Removes waterLevel and the minQueryItems
cap.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Keqij8JHea79B2rqJX4ApZ

* Size open-ended probes by chain density over the range to the target

An open-ended probe now reserves chainDensity × (chainTargetBlock −
fromBlock + 1) / partitionCount — the events its range to the target is
expected to hold, split across partitions — instead of an even share of
the fresh budget. ChainState passes its effectiveDensity down for this.

When the chain has no density signal, or the partition is already at the
target (no range), it falls back to the even budget share so cold chains
and caught-up partitions still probe.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Keqij8JHea79B2rqJX4ApZ

* Size probes by budget-implied density over the in-range coverage

Replace the passed-in chainDensity with a rangeTargetDensity derived
inside getNextQuery: freshBudget / (chainTargetBlock − frontierCursor + 1),
where frontierCursor is the furthest-behind in-range cursor. A probe then
reserves rangeTargetDensity × (chainTargetBlock − fromBlock + 1) /
inRangeCount, so a partition covering less of the range to the target (it
sits further ahead) gets proportionally fewer items, while the furthest-
behind partition gets the full even share.

Measuring the range from the in-range frontier (not the chain buffer
frontier) keeps a lone in-range partition on the full budget instead of
having it diluted by out-of-range laggards, and drops the chainDensity
parameter ChainState was threading down.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Keqij8JHea79B2rqJX4ApZ

* Optimize greedy budget pass: fewer sweeps, bounded generation (#1418)

- Fold the chainReserved sum and partitionIndexById build into the
  Phase A partition sweep (3 full passes over partitions -> 1).
- Cap per-partition chunk generation at the fresh budget: a partition
  can be accepted at most the budget plus one overshoot, so further
  chunks can never be accepted. Shrinks the candidate set and sort cost
  when the budget is small relative to the pending-chunk cap.
- Acceptance pass: sort candidates in place and stop at the first
  candidate that can't be accepted, instead of copying via toSorted and
  scanning the whole tail with forEach.
- Hoist the loop-invariant chunk-start ceiling out of the chunk loop.
- Rename waterFillState -> partitionFillState (no water-fill left).


Claude-Session: https://claude.ai/code/session_01Cj7fN5nh9d2rLeWAXnD1d5

Co-authored-by: Claude <noreply@anthropic.com>

* Fix budget deadlock when gap-fill precedes returned query (#1419)

* Let gap fills bypass the fresh-budget gate

A gap-fill candidate was gated on the fresh forward-progress budget, so a
partition could deadlock after a partial/out-of-order chunk: chunk [101,200]
returns and lingers in mutPendingQueries behind an unfilled [51,100] hole,
its reservation already released by ChainState, yet the FetchState budget
sweep still counted it — driving freshBudget to 0 and dropping the [51,100]
gap-fill every tick, so the returned query could never be consumed.

Fix by budgeting acceptance against the full chainTargetItems and reserving
in-flight queries per-query in fromBlock order: a gap-fill, whose fromBlock
precedes the query it unblocks, claims budget ahead of that reservation.
Returned-but-unconsumed queries (fetchedBlock set) no longer count toward the
budget, matching the release ChainState already performed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S6hp9FfBBT5insJwt2e28F

* Charge same-block reservations before fresh candidates

On a fromBlock tie, order in-flight reservations ahead of fresh candidates in
the acceptance stream. A same-block candidate could otherwise be emitted while
the pool budget was already exhausted (chainTargetItems still carrying
pendingBudget), pushing total reserved work past the target buffer. Only a
strictly-earlier candidate — a gap-fill preceding the query it unblocks —
should borrow ahead of a reservation.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S6hp9FfBBT5insJwt2e28F

---------

Co-authored-by: Claude <noreply@anthropic.com>

---------

Co-authored-by: Claude <noreply@anthropic.com>

* Separate event density from source range capacity (#1423)

* Separate and smooth per-partition event density (#1426)

* Separate event density from source range capacity

* Enable strict warning checks in ReScript configurations (#1424)

* Treat ReScript warning 23 as an error in indexer configs

Promote the "useless record with clause" warning to an error in the
generated-project template and the test scenarios. The envio runtime
package already errors on all warnings via "+a".

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DGX7HGV5nCwFHHoazo8dCM

* Enforce all ReScript warnings as errors in test scenarios

Set warnings.error to "+a" for the test_codegen, fuel_test, and svm_test
scenarios, matching the envio runtime package. Leave the user-facing
generated-project template without a warnings override.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DGX7HGV5nCwFHHoazo8dCM

---------

Co-authored-by: Claude <noreply@anthropic.com>

* Smooth per-partition event density

* Fix strict ReScript warnings after main merge

* Trust event density independently from source capacity

---------

Co-authored-by: Claude <noreply@anthropic.com>

* Fix reorg-threshold cross-chain query stall (#1430)

* Add PIN test reproducing the below-head chain silence stall

Reverts the earlier fix and exploratory tests and pins the exact production
stall on the unfixed scheduler: when one chain falls far behind and its query
reservation drains the shared fetch-buffer budget, a chain below its own head
but starved of budget emits no query and (being below head) won't wait for a new
block, so getNextQuery returns NothingToQuery. checkAndFetch never dispatches
NothingToQuery, so that chain stops querying AND stops polling getHeightOrThrow
and goes silent.

The test asserts the correct behavior — the starved below-head follower keeps
polling getHeightOrThrow. It is RED on this unfixed scheduler (the follower never
re-polls) and turns green once below-head chains are dispatched as
WaitingForNewBlock instead of being dropped (the "Keep below-head chains polling"
change). Verified red without the change and green with it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VhitjdvBNbfY6tRnv6RQcw

* Fix reorg-threshold cross-chain query stall

* Deduplicate fetch progress calculation

---------

Co-authored-by: Claude <noreply@anthropic.com>

* Add minimum query admission budget (#1429)

* Add minimum query admission budget

* Keep block waiters outside query admission

* Keep block waiting in query selection

* Pause all chain actions below admission floor

* Anchor cross-chain alignment to most-behind chain's frontier (#1434)

* Anchor cross-chain alignment line at the most-behind chain's frontier

The waterfall's alignment line was only established on ticks where the
most-behind chain itself emitted a fresh query and had a density signal.
While that chain's queries were in flight (or it was still cold), every
other chain fetched unclamped to its own head, defeating the cross-chain
ordering the line exists for.

- Derive the line from the most-behind known-height chain's fetch-frontier
  progress before dispatching, so it holds…
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.

2 participants