Skip to content

Per-row transaction field selection in store materialization - #1358

Merged
DZakh merged 4 commits into
mainfrom
claude/peaceful-wright-tzgdyu
Jun 29, 2026
Merged

Per-row transaction field selection in store materialization#1358
DZakh merged 4 commits into
mainfrom
claude/peaceful-wright-tzgdyu

Conversation

@DZakh

@DZakh DZakh commented Jun 29, 2026

Copy link
Copy Markdown
Member

Summary

Changes transaction field materialization from a single ecosystem-wide bitmask to per-row masks, allowing each event to decode only the transaction fields it selected. This avoids materializing expensive fields (e.g., input, accountKeys) for events that didn't request them.

Key Changes

  • Rust CLI (transaction_store.rs):

    • Renamed fill() to fill_masked() and added per-row mask support; fields are now materialized only on rows whose mask has the field's bit set
    • Updated decode_evm_columns() and decode_svm_columns() to accept masks: &[u64] instead of a single mask: u64; compute the union of all masks to determine which columns to build, but apply each row's own mask when populating cells
    • Updated materialize() method signature to accept masks: Vec<f64> with per-row validation
    • Added test decode_applies_each_rows_own_mask() verifying per-row masking behavior
  • ReScript runtime (TransactionStore.res):

    • Renamed mask() to maskFromFields() and changed signature to accept a single event's selectedTransactionFields instead of all event configs
    • Added makeMaskFn() to build a per-event mask function (closed over field codes)
    • Added orMask() helper to bitwise-OR two masks (handles unsigned conversion for bit 31+)
    • Updated materialize() external binding to accept masks: array<float> instead of single mask: float
    • Refactored materializeItems() to:
      • Accept no mask parameter; read each event's eventConfig.transactionFieldMask instead
      • Group store-backed items by (blockNumber, transactionIndex) and OR their masks together
      • Pass per-row masks to the store's materialize() call
      • Skip the materialize call entirely if no event selected any field, but still stamp empty transaction objects
  • Config and type updates:

    • Added transactionFieldMask: float field to Internal.eventConfig (precomputed per event)
    • Removed transactionFieldMask from ChainState.t (no longer ecosystem-wide)
    • Updated EventConfigBuilder to compute and cache each event's mask via Evm.eventTransactionFieldMask() or Svm.eventTransactionFieldMask()
    • Removed transactionFieldMask from Ecosystem.t (no longer needed)
    • Updated test fixtures to include the new transactionFieldMask field on event configs

Notable Implementation Details

  • Per-row masks are computed as the bitwise OR of all events sharing a (blockNumber, transactionIndex) key, so a transaction decodes the union of fields requested by its events
  • The Rust side computes the union of all row masks to determine which columns to build, then applies each row's individual mask when populating cells, yielding None for unselected fields
  • Large fields like input and accountKeys are now only touched (hex-encoded, cloned) on rows that explicitly selected them, improving performance for batches with mixed field selections
  • transactionIndex is always available from the store key regardless of mask, so it resolves correctly even when a row selects no other fields

https://claude.ai/code/session_01SbnkNd2oW3HbweJv9jdTBG

Summary by CodeRabbit

  • New Features

    • Transaction data now respects field selection on a per-row basis, so only the requested fields are included for each record.
    • Materialization now supports row-specific masks, improving handling when multiple events map to the same transaction.
  • Bug Fixes

    • Fixed transaction fields appearing for rows that did not request them.
    • Improved handling of missing selected balances and empty transaction output for store-backed items.
  • Refactor

    • Updated transaction selection plumbing across supported sources and test coverage.

Replace the chain-global transaction-field union mask with a per-event
mask carried on eventConfig. materialize now takes a per-row mask column
(block numbers, transaction indices, masks) in one napi call, so each
transaction decodes only the fields the events on it selected. A large
field such as input is materialized only on the rows that asked for it,
which avoids pulling it into the JS buffer for contract-register items
that never read it.

- transaction_store.rs: materialize takes masks: Vec<f64>; the union
  decides which columns exist and fill_masked yields None (skipping the
  decode) for rows whose mask lacks the field.
- eventConfig gains transactionFieldMask, computed once at config build;
  materializeItems reads it per item and ORs masks per (block, txIndex).
- Drop the now-dead Ecosystem.transactionFieldMask, makeMaskFn array form
  and ChainState.transactionFieldMask.

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

coderabbitai Bot commented Jun 29, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Warning

Review limit reached

You’ve reached a temporary PR review limit under our Fair Usage Limits Policy.

Your recent review volume is higher than typical usage, so adaptive limits are currently applied.

Next review available in: 10 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: f7484a67-9ec7-4514-b504-f0863b675ebd

📥 Commits

Reviewing files that changed from the base of the PR and between 044383c and 1d23f6c.

📒 Files selected for processing (9)
  • packages/cli/src/transaction_store.rs
  • packages/envio/src/ChainFetching.res
  • packages/envio/src/ChainState.res
  • packages/envio/src/ChainState.resi
  • packages/envio/src/sources/Evm.res
  • packages/envio/src/sources/TransactionStore.res
  • scenarios/test_codegen/test/SvmHyperSyncSource_test.res
  • scenarios/test_codegen/test/TransactionStore_test.res
  • scenarios/test_codegen/test/helpers/MockIndexer.res
📝 Walkthrough

Walkthrough

Transaction field masking moves from a single per-batch value held on ChainState/Ecosystem to a per-eventConfig transactionFieldMask field. TransactionStore.materializeItems groups items by (blockNumber, transactionIndex), OR-reduces masks per group, and passes a per-row masks array to the Rust store. The Rust decoders now apply each row's own mask via a new fill_masked helper.

Changes

Per-row transaction field masking

Layer / File(s) Summary
eventConfig type and mask helpers
packages/envio/src/Internal.res, packages/envio/src/sources/TransactionStore.res, packages/envio/src/sources/Evm.res, packages/envio/src/sources/Svm.res
Adds transactionFieldMask: float to eventConfig; introduces maskFromFields, makeMaskFn, orMask helpers; renames transactionFieldMaskeventTransactionFieldMask in Evm/Svm bindings.
Per-event mask population and ecosystem cleanup
packages/envio/src/Ecosystem.res, packages/envio/src/sources/Fuel.res, packages/envio/src/EventConfigBuilder.res
EventConfigBuilder populates transactionFieldMask on EVM, SVM, and Fuel event configs; Ecosystem.res removes the old aggregate transactionFieldMask field; Fuel.res removes the inline-transaction constant mask from make.
ChainState: remove stored mask
packages/envio/src/ChainState.res, packages/envio/src/ChainState.resi
Removes transactionFieldMask from the t record, make constructor, makeInternal call site, and stops passing ~mask to TransactionStore.materializeItems.
TransactionStore.res: per-row mask grouping
packages/envio/src/sources/TransactionStore.res
Updates materialize N-API binding to ~masks: array<float>; rewrites materializeItems to group by (blockNumber, transactionIndex), OR-reduce masks per group, and stamp {} when all masks are zero.
Rust: per-row masking in EVM/SVM decoders
packages/cli/src/transaction_store.rs
Adds fill_masked; reworks decode_evm_columns and decode_svm_columns to accept masks: &[u64] and gate each row's field via its own mask bit; updates materialize NAPI signature with length validation.
Tests: fixtures and assertions
packages/cli/src/transaction_store.rs, scenarios/test_codegen/test/..., scenarios/fuel_test/test/HyperFuelSource_test.res
Updates Rust EVM/SVM decoder unit tests with per-row mask vectors; adds transactionFieldMask to all Internal.eventConfig test fixtures across TransactionStore, MockIndexer, SvmHyperSyncSource, EventRouter, SourceBlockHashes, and HyperFuelSource tests.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

  • enviodev/hyperindex#1339: Introduces the per-chain TransactionStore and the initial mask-driven materialize API that this PR extends to per-row masks.
🚥 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 The title clearly summarizes the main change: moving transaction field selection to per-row masks during store materialization.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
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.

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

materializePageItems only uses the page store, not the chain state, so
remove the parameter (and the now-unused chainState arg threaded through
runContractRegistersOrThrow).

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

@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: 2

🧹 Nitpick comments (1)
packages/envio/src/sources/TransactionStore.res (1)

25-33: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Fail fast when a selected field has no mask code.

Line 30 currently drops unmapped field names on the floor. Because these sets come from typed config and hand-maintained ordered field arrays, that turns a contract drift into a partially materialized transaction instead of a loud failure.

Possible fix
   selectedTransactionFields->Utils.Set.forEach(name =>
     switch codes->Utils.Dict.dangerouslyGetNonOption(name) {
     | Some(code) => mask := mask.contents +. pow2(code)
-    | None => ()
+    | None =>
+      JsError.throwWithMessage(
+        `Unknown transaction field '${name}' while building transactionFieldMask`,
+      )
     }
   )
🤖 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/TransactionStore.res` around lines 25 - 33, The
maskFromFields helper currently ignores selected field names that are missing
from codes, which hides config drift and produces partial transactions. Update
maskFromFields to fail fast when Utils.Dict.dangerouslyGetNonOption(name)
returns None, and surface a clear error instead of silently skipping the field.
Keep the change localized to maskFromFields and its call path so any unmapped
selectedTransactionFields entry triggers an immediate failure.
🤖 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/transaction_store.rs`:
- Line 740: The materialize entry point in transaction_store.rs is blindly
coercing incoming mask values with the masks-to-u64 conversion, which can
silently accept NaN, Infinity, fractions, negatives, or out-of-range JS numbers.
Update the N-API boundary validation in materialize so only valid integer 32-bit
bitmasks are accepted before decoding, and reject invalid inputs early rather
than casting them in the masks conversion path.

In `@packages/envio/src/sources/TransactionStore.res`:
- Around line 103-110: The grouping logic in TransactionStore.res is merging
event masks with orMask and then applying that union to every payload in the
same transaction group, which causes per-event field leakage. Update the
transaction grouping flow so each event keeps its own mask when building
payloads, and avoid stamping a unionized mask across the whole group in the
finalize path after payloadGroups accumulation. Use the existing
TransactionStore grouping code around payloadGroups, masks, and the later
payload assembly logic to ensure fields like input and accountKeys are only
fetched for the specific event that requested them.

---

Nitpick comments:
In `@packages/envio/src/sources/TransactionStore.res`:
- Around line 25-33: The maskFromFields helper currently ignores selected field
names that are missing from codes, which hides config drift and produces partial
transactions. Update maskFromFields to fail fast when
Utils.Dict.dangerouslyGetNonOption(name) returns None, and surface a clear error
instead of silently skipping the field. Keep the change localized to
maskFromFields and its call path so any unmapped selectedTransactionFields entry
triggers an immediate failure.
🪄 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: 05880da7-f7d3-4d90-8f4b-58c5edbae90f

📥 Commits

Reviewing files that changed from the base of the PR and between 7a6d708 and 044383c.

📒 Files selected for processing (16)
  • packages/cli/src/transaction_store.rs
  • packages/envio/src/ChainState.res
  • packages/envio/src/ChainState.resi
  • packages/envio/src/Ecosystem.res
  • packages/envio/src/EventConfigBuilder.res
  • packages/envio/src/Internal.res
  • packages/envio/src/sources/Evm.res
  • packages/envio/src/sources/Fuel.res
  • packages/envio/src/sources/Svm.res
  • packages/envio/src/sources/TransactionStore.res
  • scenarios/fuel_test/test/HyperFuelSource_test.res
  • scenarios/test_codegen/test/EventRouter_svm_test.res
  • scenarios/test_codegen/test/SourceBlockHashes_test.res
  • scenarios/test_codegen/test/SvmHyperSyncSource_test.res
  • scenarios/test_codegen/test/TransactionStore_test.res
  • scenarios/test_codegen/test/helpers/MockIndexer.res
💤 Files with no reviewable changes (3)
  • packages/envio/src/ChainState.resi
  • packages/envio/src/sources/Fuel.res
  • packages/envio/src/Ecosystem.res

)));
}
let mask = mask as u64;
let masks: Vec<u64> = masks.iter().map(|&m| m as u64).collect();

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 | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Verify the N-API mask boundary and upstream 32-bit mask contract.
rg -n -C3 'masks: Vec<f64>|m as u64|maskFromFields|orMask|transactionFieldMask' \
  packages/cli/src/transaction_store.rs \
  packages/envio/src/sources/TransactionStore.res \
  packages/envio/src/Internal.res

Repository: enviodev/hyperindex

Length of output: 6104


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Inspect the Rust binding and nearby mask handling to judge scope and impact.
sed -n '680,790p' packages/cli/src/transaction_store.rs
printf '\n----\n'
sed -n '1,140p' packages/envio/src/sources/TransactionStore.res
printf '\n----\n'
sed -n '410,440p' packages/envio/src/Internal.res

Repository: enviodev/hyperindex

Length of output: 12316


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Check whether the Rust method is publicly exported through the JS/TS layer and whether any validation exists elsewhere.
rg -n -C2 'materialize\(|transactionFieldMask|array<float>|from_reason|is_finite|fract\(\)|u32::MAX' \
  packages/cli/src/transaction_store.rs \
  packages/envio/src/sources/TransactionStore.res \
  packages/envio/src/Internal.res \
  packages/envio/src \
  packages/cli/src

Repository: enviodev/hyperindex

Length of output: 18822


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Check whether `materialize` is only called from trusted internal code or exposed more broadly.
rg -n -C3 '\bmaterialize\b|\btransactionFieldMask\b|array<float>' \
  packages/envio/src \
  packages/cli/src

Repository: enviodev/hyperindex

Length of output: 9389


Reject non-integer masks at the N-API boundary. The current callers build 32-bit bitmasks, but this public materialize entry point still accepts arbitrary JS numbers; m as u64 will silently coerce NaN, Infinity, fractions, negatives, and out-of-range values into a different mask. Reject invalid inputs or narrow the type before decoding.

🤖 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/transaction_store.rs` at line 740, The materialize entry
point in transaction_store.rs is blindly coercing incoming mask values with the
masks-to-u64 conversion, which can silently accept NaN, Infinity, fractions,
negatives, or out-of-range JS numbers. Update the N-API boundary validation in
materialize so only valid integer 32-bit bitmasks are accepted before decoding,
and reject invalid inputs early rather than casting them in the masks conversion
path.

Comment on lines +103 to +110
let last = payloadGroups->Array.length - 1
if (
last >= 0 &&
blockNumbers->Array.getUnsafe(last) == blockNumber &&
transactionIndices->Array.getUnsafe(last) == transactionIndex
) {
payloadGroups->Array.getUnsafe(last)->Array.push(eventItem.payload)
masks->Array.setUnsafe(last, orMask(masks->Array.getUnsafe(last), mask))

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

Grouping by transaction and OR-ing masks leaks fields across events.

Line 110 unions different event masks, and Lines 124-127 then stamp that unionized transaction onto every payload in the group. An event that selected only cheap fields will still receive input/accountKeys if another event on the same transaction asked for them, which breaks per-event field selection and weakens the PR’s performance goal.

Possible fix
-  // Deduped per (blockNumber, transactionIndex); each row's mask is the OR of the
-  // masks of the events sharing that transaction.
+  // Deduped only when both the transaction key and selected mask match, so each
+  // payload still receives exactly the fields its own event selected.

@@
         if (
           last >= 0 &&
           blockNumbers->Array.getUnsafe(last) == blockNumber &&
-          transactionIndices->Array.getUnsafe(last) == transactionIndex
+          transactionIndices->Array.getUnsafe(last) == transactionIndex &&
+          masks->Array.getUnsafe(last) == mask
         ) {
           payloadGroups->Array.getUnsafe(last)->Array.push(eventItem.payload)
-          masks->Array.setUnsafe(last, orMask(masks->Array.getUnsafe(last), mask))
         } else {
           blockNumbers->Array.push(blockNumber)
           transactionIndices->Array.push(transactionIndex)
           masks->Array.push(mask)
           payloadGroups->Array.push([eventItem.payload])

Also applies to: 124-127

🤖 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/TransactionStore.res` around lines 103 - 110, The
grouping logic in TransactionStore.res is merging event masks with orMask and
then applying that union to every payload in the same transaction group, which
causes per-event field leakage. Update the transaction grouping flow so each
event keeps its own mask when building payloads, and avoid stamping a unionized
mask across the whole group in the finalize path after payloadGroups
accumulation. Use the existing TransactionStore grouping code around
payloadGroups, masks, and the later payload assembly logic to ensure fields like
input and accountKeys are only fetched for the specific event that requested
them.

- Add a ReScript test for orMask (unsigned 32-bit OR incl. the bit-31 edge)
  and a grouping test for adjacent same-tx events with differing masks.
- Hoist the duplicated Set.fromArray/magic cast to a local in the EVM and
  SVM test config helpers so the field set is built once.
- Reword the maskFromFields and materialize doc comments that claimed masks
  avoid 32-bit bitwise ops, now that orMask uses them.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SbnkNd2oW3HbweJv9jdTBG
@DZakh
DZakh merged commit 0d4e4e6 into main Jun 29, 2026
8 checks passed
@DZakh
DZakh deleted the claude/peaceful-wright-tzgdyu branch June 29, 2026 13:25
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