Repository navigation
Description
Summary
With static filters enabled — EXPERIMENTAL_STATIC_FILTERS=true, or automatically once a subgraph has more than GRAPH_STATIC_FILTERS_THRESHOLD (default 10,000) hosts — a blockHandler with filter: { kind: call } declared in a template never fires for the data sources created from that template. In default mode it fires. Nothing is logged: the subgraph stays healthy while its entities and PoI differ from a node running in default mode. A subgraph that crosses the threshold silently stops getting these triggers from the restart at which it switches to static filters.
Affected: v0.45.0 and current master (cc31bc7); the relevant code dates back to #3277 (static filters, 2022). The threshold was introduced in #4008 (v0.29.0), defaults to 10,000 since #4631 (v0.31.0), and since #5328 (v0.35.1) a subgraph switches to static filters at runtime as soon as it crosses it. Reproduced with RPC ingestion; by code reading Firehose is affected too (no CallToFilter is sent for these handlers, see below).
Root cause (master cc31bc7)
core/src/subgraph/runner/mod.rsl. 202-214: in static mode the filter is built from the manifest data sources plusextend_with_template, and l. 1143-1144 do not restart the block stream when data sources are created, so the addresses of created data sources never enter the filter.chain/ethereum/src/adapter.rsl. 962-968:EthereumBlockFilter::from_mappingsetstrigger_every_blockfor any block handler and leavescontract_addressesempty. Hencerequires_traces()is false (l. 1055-1057) and noCallToFilteris sent to Firehose (l. 315-345). Inethereum_adapter.rs,blocks_with_triggers(l. 1847) andparse_block_triggers(l. 2083-2131) never emitBlockTrigger::WithCallTo.chain/ethereum/src/data_source.rsl. 534-570: theStart/Endtriggers emitted instead never match aCallblock handler.
The doc comment on from_mapping (#3277) expects the instance manager to filter a broad stream. That works for polling/once block handlers (via End/Start) and for call handlers (wildcard signatures, fixed for RPC in #4063 after #4060), but not for call-filtered block handlers.
Reproduction (both reproduced on master cc31bc7)
1. Unit test in chain/ethereum/src/adapter.rs (diff below). It builds both filters exactly as SubgraphRunner::build_filter does, feeds a block containing a call to the created data source's address through parse_block_triggers and the data source's match_and_decode, and asserts that both modes fire the same handlers. It fails on master:
DEFAULT: requires_traces=true block.contract_addresses={(1, 0x…a1)} trigger_every_block=false triggers=["Block(#2, WithCallTo(0x…a1))"] dds_handlers=["handleCallToDds"]
STATIC: requires_traces=false block.contract_addresses={} trigger_every_block=true triggers=["Block(#2, Start)", "Block(#2, End)"] dds_handlers=[]
assertion failed: left: [] right: ["handleCallToDds"]
Run with cargo test -p graph-chain-ethereum --lib static_filters_fire_call_block_handler_of_dds.
Unit test diff
diff --git a/chain/ethereum/src/adapter.rs b/chain/ethereum/src/adapter.rs
index d37d5ba81f..ed129a5d81 100644
--- a/chain/ethereum/src/adapter.rs
+++ b/chain/ethereum/src/adapter.rs
@@ -2337,3 +2337,165 @@ fn requires_transaction_receipt_has_or_semantics_across_handlers() {
);
}
}
+
+/// Probe: a template whose mapping has a `blockHandler` with `filter: { kind: call }`.
+/// A DDS is created from it, then a block contains a call to the DDS address.
+///
+/// The two filters below are built exactly as `SubgraphRunner::build_filter`
+/// (core/src/subgraph/runner/mod.rs) builds them:
+/// - default mode: `TriggerFilter::from_data_sources(onchain_data_sources())`,
+/// i.e. static data sources plus every created DDS;
+/// - static-filters mode: `TriggerFilter::from_data_sources(static data sources)`
+/// followed by `extend_with_template(templates)`; no restart happens on DDS
+/// creation in this mode, so this filter stays in force after block N.
+///
+/// The block triggers are then parsed by `parse_block_triggers`, the function used
+/// by `triggers_in_block` (chain.rs) and mirrored by `blocks_with_triggers`
+/// (ethereum_adapter.rs), and handed to the DDS's `match_and_decode`.
+#[cfg(test)]
+mod static_filters_call_block_handler {
+ use super::*;
+ use crate::data_source::{
+ BlockHandlerFilter, DataSource, DataSourceTemplate, MappingBlockHandler, TemplateSource,
+ };
+ use crate::ethereum_adapter::parse_block_triggers;
+ use graph::blockchain::{DataSource as _, TriggerFilter as _};
+ use graph::components::subgraph::{InstanceDSTemplate, InstanceDSTemplateInfo};
+ use graph::prelude::alloy::primitives::Bytes;
+ use graph::prelude::{
+ EthereumBlock, EthereumBlockWithCalls, EthereumCall, LightEthereumBlock,
+ create_minimal_block_for_test,
+ };
+ use std::sync::Arc;
+
+ fn template() -> DataSourceTemplate {
+ let mut mapping = receipt_merge_test_mapping(vec![]);
+ mapping.block_handlers = vec![MappingBlockHandler {
+ handler: "handleCallToDds".to_string(),
+ filter: Some(BlockHandlerFilter::Call),
+ }];
+ DataSourceTemplate {
+ kind: "ethereum/contract".to_string(),
+ network: Some("test".to_string()),
+ name: "CallTemplate".to_string(),
+ manifest_idx: 1,
+ source: TemplateSource {
+ abi: "mock_abi".to_string(),
+ },
+ mapping,
+ }
+ }
+
+ /// Handlers of the DDS fired by the block triggers that `filter` yields
+ /// for `block`.
+ fn fired(
+ filter: &TriggerFilter,
+ dds: &DataSource,
+ block: &crate::chain::BlockFinality,
+ ) -> (Vec<String>, Vec<String>) {
+ let crate::chain::BlockFinality::NonFinal(full) = block else {
+ unreachable!()
+ };
+ let triggers = parse_block_triggers(&filter.block, full);
+ // Trait `blockchain::DataSource::match_and_decode`, as called by the runner.
+ let arc_block = Arc::new(block.clone());
+ let logger = graph::log::discard();
+ let handlers = triggers
+ .iter()
+ .filter_map(|t| {
+ dds.match_and_decode(t, &arc_block, &logger)
+ .unwrap()
+ .map(|th| th.handler_name().to_string())
+ })
+ .collect();
+ (
+ triggers.iter().map(|t| format!("{t:?}")).collect(),
+ handlers,
+ )
+ }
+
+ #[test]
+ fn static_filters_fire_call_block_handler_of_dds() {
+ let static_addr = receipt_merge_test_addr(0xa0);
+ let dds_addr = receipt_merge_test_addr(0xa1);
+ let creation_block = 1;
+
+ // Static data source: one event handler, nothing else.
+ let static_ds = receipt_merge_test_data_source(
+ Some(static_addr),
+ vec![receipt_merge_test_event_handler(
+ receipt_merge_test_sig(1),
+ None,
+ None,
+ None,
+ false,
+ )],
+ );
+
+ // Template + DDS created from it, through the real constructor.
+ let tpl = template();
+ let ds_tpl = graph::data_source::DataSourceTemplate::<crate::Chain>::Onchain(tpl.clone());
+ let dds = DataSource::from_template_info(
+ InstanceDSTemplateInfo {
+ template: InstanceDSTemplate::from(&ds_tpl),
+ params: vec![format!("{dds_addr:x}")],
+ context: None,
+ creation_block,
+ },
+ &ds_tpl,
+ )
+ .unwrap();
+ assert_eq!(dds.address, Some(dds_addr));
+
+ // Default mode (runner/mod.rs build_filter, `!static_filters` branch).
+ let default_filter = TriggerFilter::from_data_sources([&static_ds, &dds].into_iter());
+
+ // Static-filters mode (runner/mod.rs build_filter, static branch).
+ let mut static_filter = TriggerFilter::from_data_sources([&static_ds].into_iter());
+ static_filter.extend_with_template([tpl].into_iter());
+
+ // Block N+1 (after creation) containing a call to the DDS address.
+ // The calls are given to both modes, even though in static mode
+ // `requires_traces()` is false and the RPC path would not fetch them.
+ let raw = create_minimal_block_for_test(2, B256::repeat_byte(2));
+ let full = crate::chain::BlockFinality::NonFinal(EthereumBlockWithCalls {
+ ethereum_block: EthereumBlock {
+ block: Arc::new(LightEthereumBlock::new(raw)),
+ ..Default::default()
+ },
+ calls: Some(vec![EthereumCall {
+ to: dds_addr,
+ block_number: 2,
+ block_hash: B256::repeat_byte(2),
+ input: Bytes::from(vec![1; 36]),
+ ..Default::default()
+ }]),
+ });
+
+ let (dt, dh) = fired(&default_filter, &dds, &full);
+ let (st, sh) = fired(&static_filter, &dds, &full);
+ println!(
+ "DEFAULT: requires_traces={} block.contract_addresses={:?} trigger_every_block={} triggers={:?} dds_handlers={:?}",
+ default_filter.requires_traces(),
+ default_filter.block.contract_addresses,
+ default_filter.block.trigger_every_block,
+ dt,
+ dh
+ );
+ println!(
+ "STATIC: requires_traces={} block.contract_addresses={:?} trigger_every_block={} triggers={:?} dds_handlers={:?}",
+ static_filter.requires_traces(),
+ static_filter.block.contract_addresses,
+ static_filter.block.trigger_every_block,
+ st,
+ sh
+ );
+
+ assert_eq!(
+ dh,
+ vec!["handleCallToDds".to_string()],
+ "default mode fires"
+ );
+ assert_eq!(sh, dh, "static-filters mode must fire the same handlers as default mode");
+ }
+}2. End-to-end (integration tests, Anvil 1.7.1). A CallFactory contract creates a CallChild (emitting ChildCreated), then calls child.ping(x) twice through pingChild, each in its own block. The CallChild template has a call-filtered blockHandler and, as a control, a callHandler on the same function:
Template
templates:
- kind: ethereum/contract
name: CallChild
network: test
source:
abi: CallChild
mapping:
kind: ethereum/events
apiVersion: 0.0.7
language: wasm/assemblyscript
abis:
- name: CallChild
file: ./abis/CallChild.abi
entities:
- Counter
# The case under test: a call-filtered block handler declared in a template.
blockHandlers:
- handler: handleCallToChild
filter:
kind: call
# Control: a call handler on the same template. Under static filters it
# becomes a wildcard call filter, which still fires (fixed by #4063).
# Remove it to get the pure case (then `requires_traces()` is false too).
callHandlers:
- function: ping(uint256)
handler: handlePing
file: ./src/mapping.tsSame binary, fresh Anvil with deterministic timestamps (anvil_setBlockTimestampInterval 1, so block hashes are identical across runs), same blocks; only the environment changes. The data source is created at block 27, pingChild is called at blocks 28 and 29. PoIs are read from the index-node API with the zero indexer address:
| mode | childCreated |
pingCall (callHandler, control) |
callBlock (call-filtered blockHandler) |
PoI at block 27 | PoI at block 28 | PoI at block 29 |
|---|---|---|---|---|---|---|
| default | 1 | 2 | 2 | 0x7d849779…d579 |
0x0c8211e8…90d2 |
0x866ef1bf…29d9 |
EXPERIMENTAL_STATIC_FILTERS=true |
1 | 2 | null | same as default | 0xa5d28061…b082 |
0x91e3d585…0267 |
GRAPH_STATIC_FILTERS_THRESHOLD=0 |
1 | 2 | null | same as default | same as static | same as static |
GRAPH_STATIC_FILTERS_THRESHOLD=1 |
1 | 2 | null | same as default | same as static | same as static |
- The PoI is identical up to block 27 and diverges from block 28, the first block with a call to the created data source.
- With
GRAPH_STATIC_FILTERS_THRESHOLD=1the subgraph starts in default mode (1 host) and switches to static filters on its own when the data source is created (2 hosts > 1;runner/mod.rsl. 150). The node logsforcing subgraph to use static filters.right afterhandleChildCreatedat block 27, and the triggers are lost from block 28 on. This is the production scenario of a subgraph crossing the default threshold of 10,000.
The node logs show handleCallToChild at blocks 28 and 29 in default mode, and never in static mode. The control proves traces are served (trace_filter works on Anvil). Happy to open a PR with this test case.
Suggested fixes
- Do not use static filters when an on-chain template has a call-filtered block handler (
is_static_filters_enabled()returns false, with awarn!). Smallest and obviously correct; those subgraphs keep restarting on data source creation. - Add an "any address" mode to the block call filter, mirroring
wildcard_signaturesfor call handlers: request traces, emitWithCallTofor every call and letmatches_trigger_addressfilter. Keeps the benefit of static filters; costs traces for every block (Firehose semantics of an emptyCallToFilterto be confirmed).
Either way, the PoI of affected deployments that were indexed in static mode will change; they would need to be rewound to before the switch.
Related
#4060 / #4063 (same symptom, for call handlers), #3277, #4008, #4631, #5328. I searched open and closed issues and PRs and found no duplicate.
Activity