Skip to content

Fix RPC cross-contract event-filter leak - #1372

Merged
DZakh merged 3 commits into
mainfrom
claude/rpc-wildcard-event-filters-xh3c92
Jul 1, 2026
Merged

Fix RPC cross-contract event-filter leak#1372
DZakh merged 3 commits into
mainfrom
claude/rpc-wildcard-event-filters-xh3c92

Conversation

@DZakh

@DZakh DZakh commented Jul 1, 2026

Copy link
Copy Markdown
Member

Summary

Fixes a filter-bypass bug in the RPC data source (surfaced by CodeRabbit on #1368), with a regression test.

The bug

RpcSource.getSelectionConfig pooled all non-wildcard events' topic selections and queried each against addressesByContractNameGetAll — the union of every contract's addresses. When two non-wildcard contracts share a topic0 (same event signature) but have different where filters, one contract's query fetches the other's logs:

  • ContractATransfer with where: { to: … } → selection [transferSig, [to]]
  • ContractBTransfer unfiltered → selection [transferSig]

ContractB's unfiltered query ran against {ContractA, ContractB} addresses, so it fetched ContractA's transfers. Those routed back to ContractA by address (getItemsOrThrow routes on topic0 + topicCount + address and never re-applies the topic filter), so ContractA's handler received logs its where filter should have excluded. It only affected literal-value filters — address-param filters bound to chain.Contract.addresses are re-checked by clientAddressFilter.

The fix

Group address-bound events by contract and scope each contract's selections to its own addresses (addressesByContractName->Dict.get(contractName)), mirroring how HyperSyncSource already groups per contract. Now ContractB's query only covers ContractB's addresses and can't reach ContractA's logs.

Pure-wildcard events (dependsOnAddresses == false) carry no address constraint, so they stay pooled and are resolved once — the wildcard partition still fans out to a single request.

Trade-off: distinct contracts sharing an event signature now issue one eth_getLogs each instead of a single pooled request. That's inherent to eth_getLogs taking one address list per call; a single contract (even a factory with many addresses) is still one request, and per-contract compressTopicSelections still folds its filter-less events into one topic0 OR-set.

Test

scenarios/test_codegen/test/RpcSource_test.res — an e2e regression test through getItemsOrThrow with a MockRpcServer that honors the query's address filter. ContractA filters on topic1, ContractB is unfiltered and shares topic0; the test asserts ContractB's query no longer leaks a ContractA log past ContractA's filter. It started as a it_fails reproduction and is now a plain passing assertion. All existing getSelectionConfig cases are unchanged.

🤖 Generated with Claude Code

https://claude.ai/code/session_01YHkGCPKogsoJjSGo4oKbpF

Summary by CodeRabbit

  • Bug Fixes
    • Improved how log filters are scoped when multiple contracts are selected, reducing the risk of events being matched or routed to the wrong contract.
    • Ensured event queries correctly respect each contract’s addresses and topic filters, including cases with shared event signatures.
    • Added coverage for a scenario where unrelated logs could previously slip through across contract-specific queries.

Non-wildcard selections are pooled and queried against every contract's
addresses (addressesByContractNameGetAll), so one contract's unfiltered
query fetches a sibling contract's logs, which route back by address and
bypass that contract's `where` filter (routing never re-applies it).

Reproduces end-to-end through getItemsOrThrow with a mock that honors the
`address` filter: ContractA filters on topic1, ContractB is unfiltered and
shares topic0, and ContractB's pooled query leaks a ContractA log past
ContractA's filter. Uses `it_fails` asserting the correct (no-leak)
behavior, so it stays green while the bug is present and flips red once
selections are scoped per contract.

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

coderabbitai Bot commented Jul 1, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

RpcSource.res's log selection construction was refactored to partition event filters into address-independent and per-contract (static, dynamic, dynamic wildcard) topic selection groups, replacing the prior wildcard/address decomposition and buildLogSelections implementation. A new test verifies per-contract address scoping prevents cross-contract log leakage.

Changes

RpcSource log selection scoping

Layer / File(s) Summary
Refactor selection config construction
packages/envio/src/sources/RpcSource.res
getSelectionConfig now derives evmEventConfigs directly and builds noAddressTopicSelections plus per-contract static/dynamic/wildcard dictionaries and a contractNames set, replacing the earlier entries/wildcard decomposition.
Rework log selection generation
packages/envio/src/sources/RpcSource.res
getLogSelectionsOrThrow builds selections from precomputed address-independent selections or iterates per contract to scope static/dynamic filters and fold addresses into wildcard topic queries, removing buildLogSelections and the prior fast-path optimization.
Cross-contract leakage test
scenarios/test_codegen/test/RpcSource_test.res
New test defines two event configs with differing topic1 filters, mocks eth_getLogs to conditionally return a log, and asserts getItemsOrThrow yields no items when the log doesn't match either contract's scoped query.

Estimated code review effort: 3 (Moderate) | ~25 minutes

Possibly related PRs

  • enviodev/hyperindex#1360: Both PRs affect RpcSource's eth_getLogs filtering flow, with the retrieved PR wiring address/topic parameters that this PR's refactor and tests validate for correct per-contract scoping.
🚥 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 accurately captures the main fix: preventing cross-contract RPC event-filter leakage.
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.

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

getSelectionConfig pooled all non-wildcard topic selections and queried
each against every contract's addresses, so one contract's query could
fetch a sibling contract's logs sharing the same topic0. Those routed back
to the sibling by address and bypassed its `where` filter, since routing
never re-applies the topic filter.

Group address-bound events by contract and scope each contract's selections
to its own addresses (mirroring HyperSyncSource). Pure-wildcard events carry
no address constraint, so they stay pooled and are resolved once.

Converts the reproduction test to a passing regression guard.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YHkGCPKogsoJjSGo4oKbpF
@DZakh DZakh changed the title Add e2e reproduction for RPC cross-contract event-filter leak Fix RPC cross-contract event-filter leak Jul 1, 2026

@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.

🧹 Nitpick comments (1)
scenarios/test_codegen/test/RpcSource_test.res (1)

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

Assert the scoped request shape, not only the empty result.

This regression would still pass if getLogSelectionsOrThrow accidentally returned no log selections. Also assert that two eth_getLogs requests were made and scoped to [addrA] / [addrB] without an unscoped address query.

Suggested test strengthening
-      t.expect(result.parsedQueueItems->Array.length).toEqual(0)
+      let getLogsAddressSets =
+        mock.requests->Array.filterMap(body =>
+          switch body->JSON.parseOrThrow->JSON.Decode.object {
+          | Some(obj) if obj->Dict.get("method")->Option.flatMap(JSON.Decode.string) == Some("eth_getLogs") =>
+            obj
+            ->Dict.get("params")
+            ->Option.flatMap(JSON.Decode.array)
+            ->Option.flatMap(a => a->Array.get(0))
+            ->Option.flatMap(JSON.Decode.object)
+            ->Option.map(filter =>
+              switch filter->Dict.get("address") {
+              | Some(JSON.Array(addrs)) => addrs->Array.filterMap(JSON.Decode.string)
+              | Some(JSON.String(addr)) => [addr]
+              | _ => []
+              }
+            )
+          | _ => None
+          }
+        )
+      let addrAText = addrA->Address.toString
+      let addrBText = addrB->Address.toString
+      t.expect({
+        "parsedItems": result.parsedQueueItems->Array.length,
+        "requestCount": getLogsAddressSets->Array.length,
+        "hasContractAQuery": getLogsAddressSets->Array.some(addrs => addrs == [addrAText]),
+        "hasContractBQuery": getLogsAddressSets->Array.some(addrs => addrs == [addrBText]),
+        "hasUnscopedQuery": getLogsAddressSets->Array.some(addrs => addrs == []),
+      }).toEqual({
+        "parsedItems": 0,
+        "requestCount": 2,
+        "hasContractAQuery": true,
+        "hasContractBQuery": true,
+        "hasUnscopedQuery": false,
+      })
🤖 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/RpcSource_test.res` around lines 1911 - 1914,
Strengthen the RpcSource_test.res regression by asserting the request shape in
addition to the empty parsed result. In the test around the addrA case, verify
that getLogSelectionsOrThrow produces two eth_getLogs calls, and that the
emitted requests are scoped to [addrA] and [addrB] rather than making any
unscoped address query. Use the existing result.parsedQueueItems assertion as a
secondary check, but add direct assertions on the captured log request payloads
so the test fails if log selections are missing or incorrectly scoped.
🤖 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.

Nitpick comments:
In `@scenarios/test_codegen/test/RpcSource_test.res`:
- Around line 1911-1914: Strengthen the RpcSource_test.res regression by
asserting the request shape in addition to the empty parsed result. In the test
around the addrA case, verify that getLogSelectionsOrThrow produces two
eth_getLogs calls, and that the emitted requests are scoped to [addrA] and
[addrB] rather than making any unscoped address query. Use the existing
result.parsedQueueItems assertion as a secondary check, but add direct
assertions on the captured log request payloads so the test fails if log
selections are missing or incorrectly scoped.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 636c15a9-0e8e-4175-8640-d9b130aa4556

📥 Commits

Reviewing files that changed from the base of the PR and between 3569f75 and 6f95b92.

📒 Files selected for processing (2)
  • packages/envio/src/sources/RpcSource.res
  • scenarios/test_codegen/test/RpcSource_test.res

@DZakh
DZakh merged commit 957e500 into main Jul 1, 2026
8 checks passed
@DZakh
DZakh deleted the claude/rpc-wildcard-event-filters-xh3c92 branch July 1, 2026 14:45
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