From 7f1282be52fc8544d2c626279d4426057fb32472 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 17 Jul 2026 10:04:55 +0000 Subject: [PATCH 01/10] Fan EVM log routing out to all matching registrations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Routing in the Rust DecoderCore previously picked a single registration per log (owning contract, else wildcard fallback) and rejected duplicate signatures per contract and multiple wildcards at construction. Now every matching registration gets its own item: wildcards always match, contract- bound registrations match iff the log's address is owned by the contract, with no fallback tier. The log is decoded once and each registration's own param names are applied. Since one registration's broader selection can now fetch logs for a narrower sibling, routing is scoped to the query's registration indexes (threaded into process_response and the RPC page fetch) and re-applies each registration's static topic filters and startBlock gate; ContractAddresses markers stay match-any in Rust — the JS clientAddressFilter owns that check. startBlock now crosses the napi boundary on the registration. The RPC page dedup key gains the registration index so fan-out items survive, and selection compression dedupes repeated topic0s and identical filtered selections that multiple same-signature registrations produce. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01WN93NjbyYQhuiNgdpjQFFu --- .../cli/src/evm_hypersync_source/decode.rs | 392 ++++++++++++++---- packages/cli/src/evm_hypersync_source/mod.rs | 17 +- .../cli/src/evm_hypersync_source/selection.rs | 63 ++- .../cli/src/evm_hypersync_source/types.rs | 4 + packages/cli/src/evm_rpc_source/mod.rs | 78 ++-- .../envio/src/sources/HyperSyncClient.res | 20 +- .../test/HyperSyncClient_test.res | 2 + .../test_codegen/test/HyperSync_test.res | 1 + .../test/lib_tests/EvmRpcClient_test.res | 1 + .../test/lib_tests/HyperSyncDecoder_test.res | 4 + .../lib_tests/RenamedEventDecode_test.res | 1 + 11 files changed, 463 insertions(+), 120 deletions(-) diff --git a/packages/cli/src/evm_hypersync_source/decode.rs b/packages/cli/src/evm_hypersync_source/decode.rs index 4df30d8d7..1c943b82b 100644 --- a/packages/cli/src/evm_hypersync_source/decode.rs +++ b/packages/cli/src/evm_hypersync_source/decode.rs @@ -1,5 +1,5 @@ use std::collections::hash_map::Entry; -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use std::sync::Arc; use alloy_dyn_abi::{DecodedEvent, DynSolEvent, DynSolType}; @@ -8,6 +8,7 @@ use anyhow::{Context, Result}; use hypersync_client::format::{Data, Hex, LogArgument}; use hypersync_client::simple_types; +use crate::evm_hypersync_source::selection::TopicSelectionInput; use crate::evm_hypersync_source::types::{ sol_value_to_param, Log, OnEventRegistration, ParamMeta, ParamValue, }; @@ -51,29 +52,40 @@ impl MetaKey { } } -/// One contract's naming for an event. Several contracts collapse to the same -/// `MetaKey` when they emit the same-signature event; the positional decode is -/// shared, the param names are not. +/// One topic position's static constraint: `None` matches any value — either +/// the position is unfiltered, or it carries a `ContractAddresses` marker +/// whose temporal check stays on the JS `clientAddressFilter`. +type StaticTopicFilter = Option>; + +/// One registration's routing metadata for an event. Several registrations +/// collapse to the same `MetaKey` when they select the same-signature event; +/// the positional decode is shared, the param names and filters are not. struct EventVariant { on_event_registration_index: i64, params: Vec, + start_block: Option, + /// The registration's resolved `where` in DNF (outer Vec is OR); a log + /// matches when any selection's four positions all match. Empty means the + /// registration puts no static topic constraint on its logs. + topic_filters: Vec<[StaticTopicFilter; 4]>, } -/// One positional decoder plus the per-contract namings layered over it. Two -/// events sharing a `MetaKey` but indexing different params (same type list, -/// same indexed count, different positions) can't be told apart by (topic0, -/// topic count), so the first variant's layout backs the shared `decoder` and -/// `apply_names` keys names off each variant. +/// One positional decoder plus the per-registration namings layered over it. +/// Two events sharing a `MetaKey` but indexing different params (same type +/// list, same indexed count, different positions) can't be told apart by +/// (topic0, topic count), so the first variant's layout backs the shared +/// `decoder` and `apply_names` keys names off each variant. /// -/// `wildcard_variant_idx`/`variant_idx_by_contract_name` index into `variants` -/// and route a log to its registration: the log's address resolves to a -/// contract name (via the partition's address index), the contract's own -/// variant wins, and anything else falls back to the wildcard variant. +/// `wildcard_variant_idxs`/`variant_idxs_by_contract_name` index into +/// `variants` and fan a log out to its registrations: wildcard registrations +/// always match, contract-bound registrations match iff the log's address is +/// owned by that contract (via the partition's address index) — there is no +/// fallback tier between them. struct RegisteredEvent { decoder: DynSolEvent, variants: Vec, - wildcard_variant_idx: Option, - variant_idx_by_contract_name: HashMap, + wildcard_variant_idxs: Vec, + variant_idxs_by_contract_name: HashMap>, } #[derive(Clone)] @@ -99,8 +111,8 @@ impl DecoderCore { e.insert(RegisteredEvent { decoder, variants: Vec::new(), - wildcard_variant_idx: None, - variant_idx_by_contract_name: HashMap::new(), + wildcard_variant_idxs: Vec::new(), + variant_idxs_by_contract_name: HashMap::new(), }) } }; @@ -119,35 +131,22 @@ impl DecoderCore { ep.event_name, ); } - // Routing backstop mirroring the registration-time validation on - // the JS side: one variant per contract per key, and at most one - // wildcard variant per key. let variant_idx = event.variants.len(); - if event - .variant_idx_by_contract_name - .insert(ep.contract_name.clone(), variant_idx) - .is_some() - { - anyhow::bail!( - "Duplicate event detected: {} for contract {} shares the same topic0 and \ - topic count with another event of the contract", - ep.event_name, - ep.contract_name, - ); - } if ep.is_wildcard { - anyhow::ensure!( - event.wildcard_variant_idx.is_none(), - "Another event is already registered with the same signature that would \ - interfere with wildcard filtering: {} for contract {}", - ep.event_name, - ep.contract_name, - ); - event.wildcard_variant_idx = Some(variant_idx); + event.wildcard_variant_idxs.push(variant_idx); + } else { + event + .variant_idxs_by_contract_name + .entry(ep.contract_name.clone()) + .or_default() + .push(variant_idx); } event.variants.push(EventVariant { on_event_registration_index: ep.index, params: ep.params.clone(), + start_block: ep.start_block, + topic_filters: parse_topic_filters(&ep.topic_selections) + .with_context(|| format!("parse topic filters for {}", ep.event_name))?, }); } @@ -165,7 +164,8 @@ impl DecoderCore { &self, log: &Log, contract_name: Option<&str>, - ) -> Result> { + active_registrations: &HashSet, + ) -> Result> { let topics: Vec> = log .topics .iter() @@ -178,47 +178,82 @@ impl DecoderCore { .context("decode topics")?; let data = log.data.as_ref().context("get log.data")?; let data = Data::decode_hex(data).context("decode data")?; - self.route_and_decode(&topics, &data, contract_name) + self.route_and_decode( + &topics, + &data, + contract_name, + log.block_number, + active_registrations, + ) } pub(crate) fn route_and_decode_simple( &self, log: &simple_types::Log, contract_name: Option<&str>, - ) -> Result> { + active_registrations: &HashSet, + ) -> Result> { let data = log.data.as_ref().context("get log.data")?; - self.route_and_decode(&log.topics, data, contract_name) + let block_number = log + .block_number + .map(|n| i64::try_from(u64::from(n)).context("log.blockNumber overflow")) + .transpose()?; + self.route_and_decode( + &log.topics, + data, + contract_name, + block_number, + active_registrations, + ) } - /// Routes a log to its registration and decodes with that registration's - /// param names. `contract_name` is the log address's owning contract per - /// the partition's address index; the contract's own variant wins, anything - /// else falls back to the key's wildcard variant. `Ok(None)` means the log - /// routes nowhere — unknown signature or no matching variant — and is - /// dropped by the caller. + /// Fans a log out to every matching registration and decodes once, applying + /// each registration's own param names. `contract_name` is the log + /// address's owning contract per the partition's address index: wildcard + /// registrations always match, contract-bound registrations match iff the + /// address is owned — there is no fallback tier. Only registrations in the + /// query's `active_registrations` selection participate, and each match + /// re-applies the registration's static topic filters and `startBlock` + /// gate, since another registration's broader selection may have fetched + /// the log. An empty result means the log routes nowhere and is dropped by + /// the caller. fn route_and_decode( &self, topics: &[Option], data: &Data, contract_name: Option<&str>, - ) -> Result> { + block_number: Option, + active_registrations: &HashSet, + ) -> Result> { let event = match self.events.get(&MetaKey::from_topics(topics)?) { Some(e) => e, - None => return Ok(None), + None => return Ok(Vec::new()), }; - let variant_idx = match contract_name { - Some(name) => event - .variant_idx_by_contract_name - .get(name) - .copied() - .or(event.wildcard_variant_idx), - None => event.wildcard_variant_idx, - }; - let variant = match variant_idx { - Some(idx) => &event.variants[idx], - None => return Ok(None), - }; + let owned_idxs = contract_name + .and_then(|name| event.variant_idxs_by_contract_name.get(name)) + .map(Vec::as_slice) + .unwrap_or_default(); + let mut variant_idxs: Vec = event + .wildcard_variant_idxs + .iter() + .chain(owned_idxs) + .copied() + .filter(|&idx| { + let variant = &event.variants[idx]; + active_registrations.contains(&variant.on_event_registration_index) + && match (variant.start_block, block_number) { + (Some(start), Some(number)) => number >= start, + _ => true, + } + && matches_topic_filters(&variant.topic_filters, topics) + }) + .collect(); + if variant_idxs.is_empty() { + return Ok(Vec::new()); + } + // Deterministic item order per log, independent of wildcard/owned split. + variant_idxs.sort_unstable_by_key(|&idx| event.variants[idx].on_event_registration_index); let decoded = event .decoder @@ -231,17 +266,83 @@ impl DecoderCore { ) .context("decode log")?; - Ok(Some(RoutedEvent { - index: variant.on_event_registration_index, - params: ParamValue::Obj(apply_names( - decoded, - &variant.params, - self.checksummed_addresses, - )?), - })) + variant_idxs + .iter() + .map(|&idx| { + let variant = &event.variants[idx]; + Ok(RoutedEvent { + index: variant.on_event_registration_index, + params: ParamValue::Obj(apply_names( + decoded.clone(), + &variant.params, + self.checksummed_addresses, + )?), + }) + }) + .collect() } } +fn parse_topic_filters( + topic_selections: &[TopicSelectionInput], +) -> Result> { + let parse_values = |values: &[String]| -> Result> { + values + .iter() + .map(|v| { + LogArgument::decode_hex(v) + .map(|arg| **arg) + .with_context(|| format!("decode topic filter value {v}")) + }) + .collect() + }; + // An empty value list means match-any (mirroring query semantics), same as + // a `ContractAddresses` marker (`None` input) — the marker's check stays on + // the JS `clientAddressFilter`. + let parse_position = |input: &Option>| -> Result { + match input { + Some(values) if !values.is_empty() => Ok(Some(parse_values(values)?)), + _ => Ok(None), + } + }; + topic_selections + .iter() + .map(|ts| { + Ok([ + if ts.topic0.is_empty() { + None + } else { + Some(parse_values(&ts.topic0)?) + }, + parse_position(&ts.topic1)?, + parse_position(&ts.topic2)?, + parse_position(&ts.topic3)?, + ]) + }) + .collect() +} + +fn matches_topic_filters( + filters: &[[StaticTopicFilter; 4]], + topics: &[Option], +) -> bool { + if filters.is_empty() { + return true; + } + filters.iter().any(|selection| { + selection + .iter() + .enumerate() + .all(|(i, filter)| match filter { + None => true, + Some(values) => topics + .get(i) + .and_then(Option::as_ref) + .is_some_and(|topic| values.iter().any(|v| v == &***topic)), + }) + }) +} + pub(crate) struct RoutedEvent { pub index: i64, pub params: ParamValue, @@ -363,6 +464,7 @@ mod tests { contract_name: "TestContract".to_string(), is_wildcard: false, depends_on_addresses: false, + start_block: None, topic_selections: vec![], block_fields: vec![], transaction_fields: vec![], @@ -396,9 +498,12 @@ mod tests { ..Default::default() }; - let routed = core - .route_and_decode_napi(&log, Some("TestContract")) - .unwrap() + let mut routed = core + .route_and_decode_napi(&log, Some("TestContract"), &HashSet::from([7])) + .unwrap(); + assert_eq!(routed.len(), 1); + let routed = routed + .pop() .expect("renamed event must decode under its real sighash"); assert_eq!(routed.index, 7); @@ -424,6 +529,141 @@ mod tests { } } + // One Transfer(address,uint256)-shaped registration; anonymous topic-count-1 + // key so logs are easy to fabricate. + fn transfer_reg( + index: i64, + contract_name: &str, + is_wildcard: bool, + sighash: &str, + ) -> OnEventRegistration { + OnEventRegistration { + index, + sighash: sighash.to_string(), + topic_count: 1, + event_name: "E".to_string(), + contract_name: contract_name.to_string(), + is_wildcard, + depends_on_addresses: false, + start_block: None, + topic_selections: vec![], + block_fields: vec![], + transaction_fields: vec![], + params: vec![pm("value", "uint256", false)], + } + } + + fn value_log(sighash: &str) -> Log { + use alloy_dyn_abi::DynSolValue; + use alloy_primitives::{hex, U256}; + let data = DynSolValue::Tuple(vec![DynSolValue::Uint(U256::from(1u64), 256)]).abi_encode(); + Log { + topics: vec![Some(sighash.to_string())], + data: Some(format!("0x{}", hex::encode(data))), + ..Default::default() + } + } + + fn routed_indexes(routed: &[RoutedEvent]) -> Vec { + routed.iter().map(|r| r.index).collect() + } + + #[test] + fn fans_out_to_wildcards_and_owned_contract_without_fallback_tier() { + let core = DecoderCore::from_registrations( + &[ + transfer_reg(0, "Owned", false, VALID_SIGHASH), + transfer_reg(1, "W1", true, VALID_SIGHASH), + transfer_reg(2, "W2", true, VALID_SIGHASH), + transfer_reg(3, "Other", false, VALID_SIGHASH), + ], + false, + ) + .unwrap(); + let active = HashSet::from([0, 1, 2, 3]); + let log = value_log(VALID_SIGHASH); + + // Owned address: the contract's registration plus every wildcard. + let owned = core + .route_and_decode_napi(&log, Some("Owned"), &active) + .unwrap(); + assert_eq!(routed_indexes(&owned), vec![0, 1, 2]); + + // Unowned address: wildcards only — no fallback into contract-bound + // registrations. + let unowned = core.route_and_decode_napi(&log, None, &active).unwrap(); + assert_eq!(routed_indexes(&unowned), vec![1, 2]); + } + + #[test] + fn routing_scoped_to_active_registrations() { + let core = DecoderCore::from_registrations( + &[ + transfer_reg(0, "Owned", false, VALID_SIGHASH), + transfer_reg(1, "W1", true, VALID_SIGHASH), + ], + false, + ) + .unwrap(); + let log = value_log(VALID_SIGHASH); + let routed = core + .route_and_decode_napi(&log, Some("Owned"), &HashSet::from([0])) + .unwrap(); + assert_eq!(routed_indexes(&routed), vec![0]); + } + + #[test] + fn start_block_gates_each_registration_independently() { + let mut early = transfer_reg(0, "W1", true, VALID_SIGHASH); + early.start_block = Some(5); + let mut late = transfer_reg(1, "W2", true, VALID_SIGHASH); + late.start_block = Some(100); + let core = DecoderCore::from_registrations(&[early, late], false).unwrap(); + let active = HashSet::from([0, 1]); + let mut log = value_log(VALID_SIGHASH); + log.block_number = Some(50); + let routed = core.route_and_decode_napi(&log, None, &active).unwrap(); + assert_eq!(routed_indexes(&routed), vec![0]); + } + + #[test] + fn static_topic_filters_reapplied_per_registration() { + const TOPIC1_A: &str = "0x00000000000000000000000000000000000000000000000000000000000000aa"; + const TOPIC1_B: &str = "0x00000000000000000000000000000000000000000000000000000000000000bb"; + let selection = |topic1| crate::evm_hypersync_source::selection::TopicSelectionInput { + topic0: vec![VALID_SIGHASH.to_string()], + topic1, + topic2: Some(vec![]), + topic3: Some(vec![]), + }; + // Three same-signature wildcards: one filtering topic1 to A, one to B, + // and one with a ContractAddresses marker (None) that Rust must treat + // as match-any (the JS clientAddressFilter owns that check). + let mut filtered_a = transfer_reg(0, "WA", true, VALID_SIGHASH); + filtered_a.topic_count = 2; + filtered_a.params = vec![pm("who", "address", true), pm("value", "uint256", false)]; + filtered_a.topic_selections = vec![selection(Some(vec![TOPIC1_A.to_string()]))]; + let mut filtered_b = transfer_reg(1, "WB", true, VALID_SIGHASH); + filtered_b.topic_count = 2; + filtered_b.params = filtered_a.params.clone(); + filtered_b.topic_selections = vec![selection(Some(vec![TOPIC1_B.to_string()]))]; + let mut marker = transfer_reg(2, "WC", true, VALID_SIGHASH); + marker.topic_count = 2; + marker.params = filtered_a.params.clone(); + marker.topic_selections = vec![selection(None)]; + + let core = + DecoderCore::from_registrations(&[filtered_a, filtered_b, marker], false).unwrap(); + let active = HashSet::from([0, 1, 2]); + let log = Log { + topics: vec![Some(VALID_SIGHASH.to_string()), Some(TOPIC1_A.to_string())], + data: value_log(VALID_SIGHASH).data, + ..Default::default() + }; + let routed = core.route_and_decode_napi(&log, None, &active).unwrap(); + assert_eq!(routed_indexes(&routed), vec![0, 2]); + } + #[test] fn rejects_metakey_collision_with_different_indexed_layout() { let sighash = alloy_json_abi::Event::parse("Foo(uint256 a, uint256 b)") @@ -438,6 +678,7 @@ mod tests { contract_name: contract.to_string(), is_wildcard: false, depends_on_addresses: false, + start_block: None, topic_selections: vec![], block_fields: vec![], transaction_fields: vec![], @@ -477,6 +718,7 @@ mod tests { contract_name: contract.to_string(), is_wildcard: false, depends_on_addresses: false, + start_block: None, topic_selections: vec![], block_fields: vec![], transaction_fields: vec![], diff --git a/packages/cli/src/evm_hypersync_source/mod.rs b/packages/cli/src/evm_hypersync_source/mod.rs index 68450392b..ce4ba3839 100644 --- a/packages/cli/src/evm_hypersync_source/mod.rs +++ b/packages/cli/src/evm_hypersync_source/mod.rs @@ -202,6 +202,8 @@ impl EvmHypersyncClient { let transaction_store = TransactionStore::new_evm(self.enable_checksum_addresses); let block_store = BlockStore::new_evm(self.enable_checksum_addresses); + let active_registrations: HashSet = + params.registration_indexes.iter().copied().collect(); let (items, blocks) = tokio::task::block_in_place(|| { process_response( response.data.blocks, @@ -214,6 +216,7 @@ impl EvmHypersyncClient { &transaction_store, &block_store, &contract_name_by_address, + &active_registrations, ) }) .map_err(convert_error_to_napi)?; @@ -380,6 +383,7 @@ fn process_response( transaction_store: &TransactionStore, block_store: &BlockStore, contract_name_by_address: &std::collections::HashMap, + active_registrations: &HashSet, ) -> std::result::Result<(Vec, Vec), ConvertError> { // The server returns one block per number; items reference them by number, // so keep them owned and track which numbers are present for coverage. @@ -490,12 +494,13 @@ fn process_response( contract_name_by_address .get(&src_address) .map(String::as_str), + active_registrations, ) .context("decode event params")?; - if let Some(routed) = routed { + for routed in routed { items.push(EventItem { log_index, - src_address, + src_address: src_address.clone(), block_number, transaction_index, on_event_registration_index: routed.index, @@ -693,6 +698,7 @@ mod tests { contract_name: "Zero".to_string(), is_wildcard: true, depends_on_addresses: false, + start_block: None, topic_selections: vec![], block_fields: vec![], transaction_fields: vec![], @@ -730,6 +736,7 @@ mod tests { &TransactionStore::new_evm(false), &BlockStore::new_evm(false), &Default::default(), + &HashSet::from([0]), ) .err() .expect("expected MissingFields error"); @@ -758,6 +765,7 @@ mod tests { &TransactionStore::new_evm(false), &BlockStore::new_evm(false), &Default::default(), + &HashSet::from([0]), ) .err() .expect("expected MissingFields error"); @@ -791,6 +799,7 @@ mod tests { &TransactionStore::new_evm(false), &BlockStore::new_evm(false), &Default::default(), + &HashSet::from([0]), ) .err() .expect("expected MissingFields error"); @@ -828,6 +837,7 @@ mod tests { &TransactionStore::new_evm(false), &BlockStore::new_evm(false), &Default::default(), + &HashSet::from([0]), ) .expect("expected success when only nullable fields are absent"); assert_eq!(items.len(), 1); @@ -855,6 +865,7 @@ mod tests { &TransactionStore::new_evm(false), &BlockStore::new_evm(false), &Default::default(), + &HashSet::from([0]), ) .err() .expect("expected MissingFields error"); @@ -886,6 +897,7 @@ mod tests { &TransactionStore::new_evm(false), &BlockStore::new_evm(false), &Default::default(), + &HashSet::from([0]), ) .err() .expect("expected MissingFields error"); @@ -924,6 +936,7 @@ mod tests { &store, &BlockStore::new_evm(false), &Default::default(), + &HashSet::from([0]), ) .expect("expected success when block and transaction join"); diff --git a/packages/cli/src/evm_hypersync_source/selection.rs b/packages/cli/src/evm_hypersync_source/selection.rs index dec31ca8b..fe8f32c6f 100644 --- a/packages/cli/src/evm_hypersync_source/selection.rs +++ b/packages/cli/src/evm_hypersync_source/selection.rs @@ -69,6 +69,7 @@ impl TopicSelection { } } +#[derive(PartialEq, Eq)] struct MaterializedTopicSelection { topic0: Vec, topic1: Vec, @@ -109,14 +110,23 @@ fn address_to_topic(address: &str) -> Result { /// Fold selections without topic1..3 filters into one selection combining /// their topic0s, keeping the common case at a single log selection/request. +/// Repeated topic0s and identical filtered selections are deduplicated — +/// several registrations may select the same signature now that routing fans +/// one log out to all of them. fn compress(selections: Vec) -> Vec { let mut filterless_topic0s: Vec = Vec::new(); - let mut with_filters = Vec::new(); + let mut with_filters: Vec = Vec::new(); for selection in selections { if selection.has_filters() { - with_filters.push(selection); + if !with_filters.contains(&selection) { + with_filters.push(selection); + } } else { - filterless_topic0s.extend(selection.topic0); + for topic0 in selection.topic0 { + if !filterless_topic0s.contains(&topic0) { + filterless_topic0s.push(topic0); + } + } } } let mut result = Vec::with_capacity(with_filters.len() + 1); @@ -329,6 +339,7 @@ mod tests { contract_name: contract_name.to_string(), is_wildcard, depends_on_addresses, + start_block: None, params: vec![], topic_selections: vec![TopicSelectionInput { topic0: vec![sighash.to_string()], @@ -425,6 +436,52 @@ mod tests { ); } + #[test] + fn compress_dedupes_repeated_topic0s_and_identical_filtered_selections() { + let builder = SelectionBuilder::from_registrations(&[ + // Two registrations selecting the same signature with no filters, + // plus two with an identical topic1 filter. + reg(0, SIGHASH_A, "C", true, false, Some(vec![])), + reg(1, SIGHASH_A, "D", true, false, Some(vec![])), + reg( + 2, + SIGHASH_B, + "C", + true, + false, + Some(vec![ADDR_TOPIC.to_string()]), + ), + reg( + 3, + SIGHASH_B, + "D", + true, + false, + Some(vec![ADDR_TOPIC.to_string()]), + ), + ]) + .unwrap(); + let built = builder.build(&[0, 1, 2, 3], &HashMap::new()).unwrap(); + assert_eq!( + built.log_selections, + vec![ + BuiltLogSelection { + addresses: vec![], + topics: vec![vec![SIGHASH_A.to_string()], vec![], vec![], vec![]], + }, + BuiltLogSelection { + addresses: vec![], + topics: vec![ + vec![SIGHASH_B.to_string()], + vec![ADDR_TOPIC.to_string()], + vec![], + vec![], + ], + }, + ] + ); + } + #[test] fn contract_without_addresses_is_skipped() { let builder = SelectionBuilder::from_registrations(&[reg( diff --git a/packages/cli/src/evm_hypersync_source/types.rs b/packages/cli/src/evm_hypersync_source/types.rs index 596e49c79..3f7c122b5 100644 --- a/packages/cli/src/evm_hypersync_source/types.rs +++ b/packages/cli/src/evm_hypersync_source/types.rs @@ -306,6 +306,10 @@ pub struct OnEventRegistration { /// Whether the query for this event must be scoped to (or derived from) /// the contract's registered addresses. pub depends_on_addresses: bool, + /// Final start block (contract/chain config, overridden by a + /// `where.block.number._gte`); routing drops this registration's logs + /// below it. + pub start_block: Option, pub params: Vec, /// The registration's resolved `where` in disjunctive normal form (outer /// array is OR). Empty means the event is never fetched. diff --git a/packages/cli/src/evm_rpc_source/mod.rs b/packages/cli/src/evm_rpc_source/mod.rs index 406133e67..1f82067dc 100644 --- a/packages/cli/src/evm_rpc_source/mod.rs +++ b/packages/cli/src/evm_rpc_source/mod.rs @@ -42,6 +42,7 @@ pub struct EvmRpcClientConfig { // by the decoder on the Rust side (see `to_decoder_log`) and `removed` is unused, // so neither is carried here. #[napi(object)] +#[derive(Clone)] pub struct RpcLog { pub address: String, pub topics: Vec, @@ -106,6 +107,11 @@ impl RawLog { DecoderLog { data: Some(self.data.clone()), topics: self.topics.iter().map(|t| Some(t.clone())).collect(), + // Routing's startBlock gate reads this; a malformed quantity is + // left unset here and surfaces as an error in `into_rpc_log`. + block_number: parse_hex_u64(&self.block_number) + .ok() + .and_then(|v| i64::try_from(v).ok()), ..Default::default() } } @@ -261,6 +267,8 @@ impl EvmRpcClient { .map_err(map_err)?; let log_selections = built.log_selections; let contract_name_by_address = std::sync::Arc::new(built.contract_name_by_address); + let active_registrations: std::sync::Arc> = + std::sync::Arc::new(params.registration_indexes.iter().copied().collect()); let timeout = Duration::from_millis(self.sync_config.query_timeout_millis); let page_result = tokio::time::timeout( timeout, @@ -269,6 +277,7 @@ impl EvmRpcClient { to_block, &log_selections, &contract_name_by_address, + &active_registrations, ), ) .await; @@ -377,17 +386,20 @@ impl EvmRpcClient { } /// Fans out one `eth_getLogs` per selection concurrently, deduping the - /// merged results by `(blockNumber, logIndex)` — a log can satisfy more - /// than one selection when a single event's `where` is an OR of param - /// groups. Waits for every selection to settle (unlike `Promise.all`'s - /// fail-fast) so every request's timing is still captured for - /// `requestStats` even when one of them errors. + /// merged results by `(blockNumber, logIndex, registrationIndex)` — a log + /// can satisfy more than one selection (an event's `where` OR-groups, or + /// several registrations sharing a signature) and routing fans one log out + /// to several registrations, so only exact repeats are dropped. Waits for + /// every selection to settle (unlike `Promise.all`'s fail-fast) so every + /// request's timing is still captured for `requestStats` even when one of + /// them errors. async fn fetch_page( &self, from_block: u64, to_block: u64, selections: &[BuiltLogSelection], contract_name_by_address: &std::sync::Arc>, + active_registrations: &std::sync::Arc>, ) -> Result<(Vec, Vec), (RpcError, Vec)> { if selections.is_empty() { return Ok((Vec::new(), Vec::new())); @@ -401,6 +413,7 @@ impl EvmRpcClient { to_block as i64, selection, contract_name_by_address.clone(), + active_registrations.clone(), ) .await; (result, started.elapsed().as_secs_f64()) @@ -409,7 +422,7 @@ impl EvmRpcClient { let mut items = Vec::new(); let mut stats = Vec::with_capacity(results.len()); - let mut seen: HashSet<(i64, i64)> = HashSet::new(); + let mut seen: HashSet<(i64, i64, i64)> = HashSet::new(); let mut first_err = None; for (result, seconds) in results { stats.push(RequestStat { @@ -419,7 +432,11 @@ impl EvmRpcClient { match result { Ok(page_items) => { for item in page_items { - if seen.insert((item.log.block_number, item.log.log_index)) { + if seen.insert(( + item.log.block_number, + item.log.log_index, + item.on_event_registration_index, + )) { items.push(item); } } @@ -443,6 +460,7 @@ impl EvmRpcClient { to_block: i64, selection: &BuiltLogSelection, contract_name_by_address: std::sync::Arc>, + active_registrations: std::sync::Arc>, ) -> Result, RpcError> { // eth_getLogs topic filters: `null` matches any value at a position; // trailing match-any positions are trimmed entirely. @@ -475,30 +493,32 @@ impl EvmRpcClient { // Decoding is CPU-bound ABI work; keep it off the libuv async thread. tokio::task::spawn_blocking(move || { let should_checksum = decoder.checksummed_addresses(); - raw_logs - .into_iter() - .filter_map(|raw| { - let address = match raw.normalized_address(should_checksum) { - Ok(address) => address, - Err(e) => return Some(Err(e)), - }; - // 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()?; - Some(raw.into_rpc_log(address).map(|log| RpcEventItem { - log, + let mut items = Vec::new(); + for raw in raw_logs { + let address = raw.normalized_address(should_checksum)?; + // 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), + &active_registrations, + ) + .unwrap_or_default(); + if routed.is_empty() { + continue; + } + let log = raw.into_rpc_log(address)?; + for routed in routed { + items.push(RpcEventItem { + log: log.clone(), on_event_registration_index: routed.index, params: routed.params, - })) - }) - .collect::>>() + }); + } + } + Ok(items) }) .await .map_err(|e| { diff --git a/packages/envio/src/sources/HyperSyncClient.res b/packages/envio/src/sources/HyperSyncClient.res index 24776942f..d2463846d 100644 --- a/packages/envio/src/sources/HyperSyncClient.res +++ b/packages/envio/src/sources/HyperSyncClient.res @@ -254,6 +254,9 @@ module Registration = { contractName: string, isWildcard: bool, dependsOnAddresses: bool, + // Final start block; routing on the Rust side drops this registration's + // logs below it. + startBlock: option, params: array, topicSelections: array, // Capitalized field names matching the Rust BlockField/TransactionField @@ -269,8 +272,8 @@ module Registration = { } let fromOnEventRegistrations = ( - onEventRegistrations: array, - ): array => { + onEventRegistrations: array, + ): array => { onEventRegistrations->Array.map(reg => { let event = reg.eventConfig->(Utils.magic: Internal.eventConfig => Internal.evmEventConfig) { @@ -281,10 +284,9 @@ module Registration = { contractName: event.contractName, isWildcard: reg.isWildcard, dependsOnAddresses: reg.dependsOnAddresses, + startBlock: reg.startBlock, params: event.paramsMetadata, - topicSelections: reg.resolvedWhere.topicSelections->Array.map(( - ts - ): topicSelectionInput => { + topicSelections: reg.resolvedWhere.topicSelections->Array.map((ts): topicSelectionInput => { topic0: ts.topic0->EvmTypes.Hex.toStrings, topic1: ts.topic1->toTopicFilterInput, topic2: ts.topic2->toTopicFilterInput, @@ -360,12 +362,8 @@ type t = { } @send -external classNew: ( - Core.evmHypersyncClientCtor, - cfg, - string, - array, -) => t = "new" +external classNew: (Core.evmHypersyncClientCtor, cfg, string, array) => t = + "new" let makeWithAgent = (cfg, ~userAgent, ~eventRegistrations) => Core.getAddon().evmHypersyncClient->classNew(cfg, userAgent, eventRegistrations) diff --git a/scenarios/test_codegen/test/HyperSyncClient_test.res b/scenarios/test_codegen/test/HyperSyncClient_test.res index d34e453fb..489cb9174 100644 --- a/scenarios/test_codegen/test/HyperSyncClient_test.res +++ b/scenarios/test_codegen/test/HyperSyncClient_test.res @@ -23,6 +23,7 @@ let transferEventRegistration: HyperSyncClient.Registration.input = { contractName: "ERC20", isWildcard: false, dependsOnAddresses: true, + startBlock: None, params: transferParams, topicSelections: [ { @@ -126,6 +127,7 @@ describe("HyperSync client getEventItems (live)", () => { contractName: "Unrelated", isWildcard: true, dependsOnAddresses: false, + startBlock: None, params: [], topicSelections: [ { diff --git a/scenarios/test_codegen/test/HyperSync_test.res b/scenarios/test_codegen/test/HyperSync_test.res index bd02952d6..07d34c5d8 100644 --- a/scenarios/test_codegen/test/HyperSync_test.res +++ b/scenarios/test_codegen/test/HyperSync_test.res @@ -22,6 +22,7 @@ describe_skip("Test Hyperliquid broken transaction response", () => { contractName: "ERC20", isWildcard: true, dependsOnAddresses: false, + startBlock: None, params: [], topicSelections: [ { diff --git a/scenarios/test_codegen/test/lib_tests/EvmRpcClient_test.res b/scenarios/test_codegen/test/lib_tests/EvmRpcClient_test.res index 72e853cda..2cf5704e5 100644 --- a/scenarios/test_codegen/test/lib_tests/EvmRpcClient_test.res +++ b/scenarios/test_codegen/test/lib_tests/EvmRpcClient_test.res @@ -183,6 +183,7 @@ describe("EvmRpcClient - getNextPage via napi", () => { contractName: "ERC20", isWildcard, dependsOnAddresses, + startBlock: None, params, topicSelections: [ { diff --git a/scenarios/test_codegen/test/lib_tests/HyperSyncDecoder_test.res b/scenarios/test_codegen/test/lib_tests/HyperSyncDecoder_test.res index 13ab20d85..45406d86b 100644 --- a/scenarios/test_codegen/test/lib_tests/HyperSyncDecoder_test.res +++ b/scenarios/test_codegen/test/lib_tests/HyperSyncDecoder_test.res @@ -37,6 +37,7 @@ let decodeSingle = async ( contractName: "TestContract", isWildcard: false, dependsOnAddresses: true, + startBlock: None, params, topicSelections: [ {topic0: [sighash], topic1: Some([]), topic2: Some([]), topic3: Some([])}, @@ -84,6 +85,7 @@ describe("EVM event decoding via EvmRpcClient.getLogs", () => { contractName: "TestContract", isWildcard: false, dependsOnAddresses: true, + startBlock: None, topicSelections, blockFields: [], transactionFields: [], @@ -101,6 +103,7 @@ describe("EVM event decoding via EvmRpcClient.getLogs", () => { contractName: "TestContract", isWildcard: false, dependsOnAddresses: true, + startBlock: None, topicSelections, blockFields: [], transactionFields: [], @@ -160,6 +163,7 @@ describe("EVM event decoding via EvmRpcClient.getLogs", () => { contractName: "TestContract", isWildcard: false, dependsOnAddresses: true, + startBlock: None, topicSelections: [ { topic0: [toEventSelector("event Empty()")], diff --git a/scenarios/test_codegen/test/lib_tests/RenamedEventDecode_test.res b/scenarios/test_codegen/test/lib_tests/RenamedEventDecode_test.res index 057856ec9..7e8917b90 100644 --- a/scenarios/test_codegen/test/lib_tests/RenamedEventDecode_test.res +++ b/scenarios/test_codegen/test/lib_tests/RenamedEventDecode_test.res @@ -35,6 +35,7 @@ describe("Renamed event decoding (issue #1285)", () => { contractName: "TestContract", isWildcard: false, dependsOnAddresses: true, + startBlock: None, topicSelections: [ { topic0: [onChainSighash], From fb012a47db377b5cd14ec81103ec0c246770339c Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 17 Jul 2026 11:41:42 +0000 Subject: [PATCH 02/10] Drop the Rust-side startBlock gate from log routing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A log can only route to registrations belonging to the query selection that fetched it — registration_indexes scoping plus the re-applied static topic filters already guarantee that, and the JS side's partitioning by start block keeps a query from ranging below its registrations' start blocks. The per-log startBlock check was redundant, so the field no longer crosses the napi boundary. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01WN93NjbyYQhuiNgdpjQFFu --- .../cli/src/evm_hypersync_source/decode.rs | 58 +++---------------- packages/cli/src/evm_hypersync_source/mod.rs | 1 - .../cli/src/evm_hypersync_source/selection.rs | 1 - .../cli/src/evm_hypersync_source/types.rs | 4 -- packages/cli/src/evm_rpc_source/mod.rs | 5 -- .../envio/src/sources/HyperSyncClient.res | 4 -- .../test/HyperSyncClient_test.res | 2 - .../test_codegen/test/HyperSync_test.res | 1 - .../test/lib_tests/EvmRpcClient_test.res | 1 - .../test/lib_tests/HyperSyncDecoder_test.res | 4 -- .../lib_tests/RenamedEventDecode_test.res | 1 - 11 files changed, 9 insertions(+), 73 deletions(-) diff --git a/packages/cli/src/evm_hypersync_source/decode.rs b/packages/cli/src/evm_hypersync_source/decode.rs index 1c943b82b..ae3d0d0fa 100644 --- a/packages/cli/src/evm_hypersync_source/decode.rs +++ b/packages/cli/src/evm_hypersync_source/decode.rs @@ -63,7 +63,6 @@ type StaticTopicFilter = Option>; struct EventVariant { on_event_registration_index: i64, params: Vec, - start_block: Option, /// The registration's resolved `where` in DNF (outer Vec is OR); a log /// matches when any selection's four positions all match. Empty means the /// registration puts no static topic constraint on its logs. @@ -144,7 +143,6 @@ impl DecoderCore { event.variants.push(EventVariant { on_event_registration_index: ep.index, params: ep.params.clone(), - start_block: ep.start_block, topic_filters: parse_topic_filters(&ep.topic_selections) .with_context(|| format!("parse topic filters for {}", ep.event_name))?, }); @@ -178,13 +176,7 @@ impl DecoderCore { .context("decode topics")?; let data = log.data.as_ref().context("get log.data")?; let data = Data::decode_hex(data).context("decode data")?; - self.route_and_decode( - &topics, - &data, - contract_name, - log.block_number, - active_registrations, - ) + self.route_and_decode(&topics, &data, contract_name, active_registrations) } pub(crate) fn route_and_decode_simple( @@ -194,35 +186,25 @@ impl DecoderCore { active_registrations: &HashSet, ) -> Result> { let data = log.data.as_ref().context("get log.data")?; - let block_number = log - .block_number - .map(|n| i64::try_from(u64::from(n)).context("log.blockNumber overflow")) - .transpose()?; - self.route_and_decode( - &log.topics, - data, - contract_name, - block_number, - active_registrations, - ) + self.route_and_decode(&log.topics, data, contract_name, active_registrations) } /// Fans a log out to every matching registration and decodes once, applying /// each registration's own param names. `contract_name` is the log /// address's owning contract per the partition's address index: wildcard /// registrations always match, contract-bound registrations match iff the - /// address is owned — there is no fallback tier. Only registrations in the - /// query's `active_registrations` selection participate, and each match - /// re-applies the registration's static topic filters and `startBlock` - /// gate, since another registration's broader selection may have fetched - /// the log. An empty result means the log routes nowhere and is dropped by - /// the caller. + /// address is owned — there is no fallback tier. A log only routes to + /// registrations whose selection fetched it: registrations outside the + /// query's `active_registrations` never participate, and each match + /// re-applies the registration's static topic filters, since another + /// registration's broader selection in the same query may have fetched + /// the log. An empty result means the log routes nowhere and is dropped + /// by the caller. fn route_and_decode( &self, topics: &[Option], data: &Data, contract_name: Option<&str>, - block_number: Option, active_registrations: &HashSet, ) -> Result> { let event = match self.events.get(&MetaKey::from_topics(topics)?) { @@ -242,10 +224,6 @@ impl DecoderCore { .filter(|&idx| { let variant = &event.variants[idx]; active_registrations.contains(&variant.on_event_registration_index) - && match (variant.start_block, block_number) { - (Some(start), Some(number)) => number >= start, - _ => true, - } && matches_topic_filters(&variant.topic_filters, topics) }) .collect(); @@ -464,7 +442,6 @@ mod tests { contract_name: "TestContract".to_string(), is_wildcard: false, depends_on_addresses: false, - start_block: None, topic_selections: vec![], block_fields: vec![], transaction_fields: vec![], @@ -545,7 +522,6 @@ mod tests { contract_name: contract_name.to_string(), is_wildcard, depends_on_addresses: false, - start_block: None, topic_selections: vec![], block_fields: vec![], transaction_fields: vec![], @@ -612,20 +588,6 @@ mod tests { assert_eq!(routed_indexes(&routed), vec![0]); } - #[test] - fn start_block_gates_each_registration_independently() { - let mut early = transfer_reg(0, "W1", true, VALID_SIGHASH); - early.start_block = Some(5); - let mut late = transfer_reg(1, "W2", true, VALID_SIGHASH); - late.start_block = Some(100); - let core = DecoderCore::from_registrations(&[early, late], false).unwrap(); - let active = HashSet::from([0, 1]); - let mut log = value_log(VALID_SIGHASH); - log.block_number = Some(50); - let routed = core.route_and_decode_napi(&log, None, &active).unwrap(); - assert_eq!(routed_indexes(&routed), vec![0]); - } - #[test] fn static_topic_filters_reapplied_per_registration() { const TOPIC1_A: &str = "0x00000000000000000000000000000000000000000000000000000000000000aa"; @@ -678,7 +640,6 @@ mod tests { contract_name: contract.to_string(), is_wildcard: false, depends_on_addresses: false, - start_block: None, topic_selections: vec![], block_fields: vec![], transaction_fields: vec![], @@ -718,7 +679,6 @@ mod tests { contract_name: contract.to_string(), is_wildcard: false, depends_on_addresses: false, - start_block: None, topic_selections: vec![], block_fields: vec![], transaction_fields: vec![], diff --git a/packages/cli/src/evm_hypersync_source/mod.rs b/packages/cli/src/evm_hypersync_source/mod.rs index ce4ba3839..02c77401a 100644 --- a/packages/cli/src/evm_hypersync_source/mod.rs +++ b/packages/cli/src/evm_hypersync_source/mod.rs @@ -698,7 +698,6 @@ mod tests { contract_name: "Zero".to_string(), is_wildcard: true, depends_on_addresses: false, - start_block: None, topic_selections: vec![], block_fields: vec![], transaction_fields: vec![], diff --git a/packages/cli/src/evm_hypersync_source/selection.rs b/packages/cli/src/evm_hypersync_source/selection.rs index fe8f32c6f..bbf87e717 100644 --- a/packages/cli/src/evm_hypersync_source/selection.rs +++ b/packages/cli/src/evm_hypersync_source/selection.rs @@ -339,7 +339,6 @@ mod tests { contract_name: contract_name.to_string(), is_wildcard, depends_on_addresses, - start_block: None, params: vec![], topic_selections: vec![TopicSelectionInput { topic0: vec![sighash.to_string()], diff --git a/packages/cli/src/evm_hypersync_source/types.rs b/packages/cli/src/evm_hypersync_source/types.rs index 3f7c122b5..596e49c79 100644 --- a/packages/cli/src/evm_hypersync_source/types.rs +++ b/packages/cli/src/evm_hypersync_source/types.rs @@ -306,10 +306,6 @@ pub struct OnEventRegistration { /// Whether the query for this event must be scoped to (or derived from) /// the contract's registered addresses. pub depends_on_addresses: bool, - /// Final start block (contract/chain config, overridden by a - /// `where.block.number._gte`); routing drops this registration's logs - /// below it. - pub start_block: Option, pub params: Vec, /// The registration's resolved `where` in disjunctive normal form (outer /// array is OR). Empty means the event is never fetched. diff --git a/packages/cli/src/evm_rpc_source/mod.rs b/packages/cli/src/evm_rpc_source/mod.rs index 1f82067dc..eef7dfdda 100644 --- a/packages/cli/src/evm_rpc_source/mod.rs +++ b/packages/cli/src/evm_rpc_source/mod.rs @@ -107,11 +107,6 @@ impl RawLog { DecoderLog { data: Some(self.data.clone()), topics: self.topics.iter().map(|t| Some(t.clone())).collect(), - // Routing's startBlock gate reads this; a malformed quantity is - // left unset here and surfaces as an error in `into_rpc_log`. - block_number: parse_hex_u64(&self.block_number) - .ok() - .and_then(|v| i64::try_from(v).ok()), ..Default::default() } } diff --git a/packages/envio/src/sources/HyperSyncClient.res b/packages/envio/src/sources/HyperSyncClient.res index d2463846d..454a25afa 100644 --- a/packages/envio/src/sources/HyperSyncClient.res +++ b/packages/envio/src/sources/HyperSyncClient.res @@ -254,9 +254,6 @@ module Registration = { contractName: string, isWildcard: bool, dependsOnAddresses: bool, - // Final start block; routing on the Rust side drops this registration's - // logs below it. - startBlock: option, params: array, topicSelections: array, // Capitalized field names matching the Rust BlockField/TransactionField @@ -284,7 +281,6 @@ module Registration = { contractName: event.contractName, isWildcard: reg.isWildcard, dependsOnAddresses: reg.dependsOnAddresses, - startBlock: reg.startBlock, params: event.paramsMetadata, topicSelections: reg.resolvedWhere.topicSelections->Array.map((ts): topicSelectionInput => { topic0: ts.topic0->EvmTypes.Hex.toStrings, diff --git a/scenarios/test_codegen/test/HyperSyncClient_test.res b/scenarios/test_codegen/test/HyperSyncClient_test.res index 489cb9174..d34e453fb 100644 --- a/scenarios/test_codegen/test/HyperSyncClient_test.res +++ b/scenarios/test_codegen/test/HyperSyncClient_test.res @@ -23,7 +23,6 @@ let transferEventRegistration: HyperSyncClient.Registration.input = { contractName: "ERC20", isWildcard: false, dependsOnAddresses: true, - startBlock: None, params: transferParams, topicSelections: [ { @@ -127,7 +126,6 @@ describe("HyperSync client getEventItems (live)", () => { contractName: "Unrelated", isWildcard: true, dependsOnAddresses: false, - startBlock: None, params: [], topicSelections: [ { diff --git a/scenarios/test_codegen/test/HyperSync_test.res b/scenarios/test_codegen/test/HyperSync_test.res index 07d34c5d8..bd02952d6 100644 --- a/scenarios/test_codegen/test/HyperSync_test.res +++ b/scenarios/test_codegen/test/HyperSync_test.res @@ -22,7 +22,6 @@ describe_skip("Test Hyperliquid broken transaction response", () => { contractName: "ERC20", isWildcard: true, dependsOnAddresses: false, - startBlock: None, params: [], topicSelections: [ { diff --git a/scenarios/test_codegen/test/lib_tests/EvmRpcClient_test.res b/scenarios/test_codegen/test/lib_tests/EvmRpcClient_test.res index 2cf5704e5..72e853cda 100644 --- a/scenarios/test_codegen/test/lib_tests/EvmRpcClient_test.res +++ b/scenarios/test_codegen/test/lib_tests/EvmRpcClient_test.res @@ -183,7 +183,6 @@ describe("EvmRpcClient - getNextPage via napi", () => { contractName: "ERC20", isWildcard, dependsOnAddresses, - startBlock: None, params, topicSelections: [ { diff --git a/scenarios/test_codegen/test/lib_tests/HyperSyncDecoder_test.res b/scenarios/test_codegen/test/lib_tests/HyperSyncDecoder_test.res index 45406d86b..13ab20d85 100644 --- a/scenarios/test_codegen/test/lib_tests/HyperSyncDecoder_test.res +++ b/scenarios/test_codegen/test/lib_tests/HyperSyncDecoder_test.res @@ -37,7 +37,6 @@ let decodeSingle = async ( contractName: "TestContract", isWildcard: false, dependsOnAddresses: true, - startBlock: None, params, topicSelections: [ {topic0: [sighash], topic1: Some([]), topic2: Some([]), topic3: Some([])}, @@ -85,7 +84,6 @@ describe("EVM event decoding via EvmRpcClient.getLogs", () => { contractName: "TestContract", isWildcard: false, dependsOnAddresses: true, - startBlock: None, topicSelections, blockFields: [], transactionFields: [], @@ -103,7 +101,6 @@ describe("EVM event decoding via EvmRpcClient.getLogs", () => { contractName: "TestContract", isWildcard: false, dependsOnAddresses: true, - startBlock: None, topicSelections, blockFields: [], transactionFields: [], @@ -163,7 +160,6 @@ describe("EVM event decoding via EvmRpcClient.getLogs", () => { contractName: "TestContract", isWildcard: false, dependsOnAddresses: true, - startBlock: None, topicSelections: [ { topic0: [toEventSelector("event Empty()")], diff --git a/scenarios/test_codegen/test/lib_tests/RenamedEventDecode_test.res b/scenarios/test_codegen/test/lib_tests/RenamedEventDecode_test.res index 7e8917b90..057856ec9 100644 --- a/scenarios/test_codegen/test/lib_tests/RenamedEventDecode_test.res +++ b/scenarios/test_codegen/test/lib_tests/RenamedEventDecode_test.res @@ -35,7 +35,6 @@ describe("Renamed event decoding (issue #1285)", () => { contractName: "TestContract", isWildcard: false, dependsOnAddresses: true, - startBlock: None, topicSelections: [ { topic0: [onChainSighash], From 4df4473f04b41f510171c85d0d30662a21c94aea Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 17 Jul 2026 12:45:08 +0000 Subject: [PATCH 03/10] Support different decode layouts per MetaKey collision Registrations sharing (topic0, topic count) were forced onto one positional decoder, so a collision with a different indexed/body split was rejected at construction. Now each distinct layout in the group gets its own decoder (same-layout variants still share one) and a log decodes lazily, once per matched layout, under each registration's own declaration. A layout the log's bytes don't decode under contributes no items; the failure only surfaces as an error when no matched layout decodes, so genuinely malformed data still can't disappear silently. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01WN93NjbyYQhuiNgdpjQFFu --- .../cli/src/evm_hypersync_source/decode.rs | 252 +++++++++++++----- 1 file changed, 186 insertions(+), 66 deletions(-) diff --git a/packages/cli/src/evm_hypersync_source/decode.rs b/packages/cli/src/evm_hypersync_source/decode.rs index ae3d0d0fa..b165189f4 100644 --- a/packages/cli/src/evm_hypersync_source/decode.rs +++ b/packages/cli/src/evm_hypersync_source/decode.rs @@ -63,17 +63,22 @@ type StaticTopicFilter = Option>; struct EventVariant { on_event_registration_index: i64, params: Vec, + /// Index into the group's `decoders`; variants with the same positional + /// layout share a decoder so a log is decoded once per layout, not per + /// registration. + decoder_idx: usize, /// The registration's resolved `where` in DNF (outer Vec is OR); a log /// matches when any selection's four positions all match. Empty means the /// registration puts no static topic constraint on its logs. topic_filters: Vec<[StaticTopicFilter; 4]>, } -/// One positional decoder plus the per-registration namings layered over it. -/// Two events sharing a `MetaKey` but indexing different params (same type -/// list, same indexed count, different positions) can't be told apart by -/// (topic0, topic count), so the first variant's layout backs the shared -/// `decoder` and `apply_names` keys names off each variant. +/// The registrations colliding on one `MetaKey`, with the per-registration +/// namings layered over the positional decoders. Registrations sharing a key +/// may still split indexed/body params differently (same type list, same +/// indexed count, different positions) — such layouts can't be told apart by +/// (topic0, topic count), so each distinct layout gets its own decoder and +/// every matched variant decodes under its registration's own layout. /// /// `wildcard_variant_idxs`/`variant_idxs_by_contract_name` index into /// `variants` and fan a log out to its registrations: wildcard registrations @@ -81,7 +86,7 @@ struct EventVariant { /// owned by that contract (via the partition's address index) — there is no /// fallback tier between them. struct RegisteredEvent { - decoder: DynSolEvent, + decoders: Vec, variants: Vec, wildcard_variant_idxs: Vec, variant_idxs_by_contract_name: HashMap>, @@ -104,32 +109,29 @@ impl DecoderCore { .with_context(|| format!("parse meta key for {}", ep.event_name))?; let event = match events.entry(key) { Entry::Occupied(e) => e.into_mut(), - Entry::Vacant(e) => { + Entry::Vacant(e) => e.insert(RegisteredEvent { + decoders: Vec::new(), + variants: Vec::new(), + wildcard_variant_idxs: Vec::new(), + variant_idxs_by_contract_name: HashMap::new(), + }), + }; + // Reuse an earlier same-layout variant's decoder; differing param + // *names* don't matter (`apply_names` applies each variant's own), + // only the indexed/body split and types do. + let decoder_idx = match event + .variants + .iter() + .find(|v| same_decode_layout(&v.params, &ep.params)) + { + Some(v) => v.decoder_idx, + None => { let decoder = build_event_decoder(&key, &ep.params) .with_context(|| format!("build decoder for {}", ep.event_name))?; - e.insert(RegisteredEvent { - decoder, - variants: Vec::new(), - wildcard_variant_idxs: Vec::new(), - variant_idxs_by_contract_name: HashMap::new(), - }) + event.decoders.push(decoder); + event.decoders.len() - 1 } }; - // The shared decoder is built from the first variant's layout. A - // later variant colliding on this MetaKey but splitting indexed/body - // differently would be silently mis-typed, so reject it. Config - // parsing should already prevent this; this is the decoder-side - // backstop. Differing param *names* are fine — `apply_names` applies - // each variant's own. - if let Some(first) = event.variants.first() { - anyhow::ensure!( - same_decode_layout(&first.params, &ep.params), - "ABI layout mismatch for {}: another event with the same topic0 and topic \ - count but a different indexed/type layout is already registered; they can't \ - share a positional decoder", - ep.event_name, - ); - } let variant_idx = event.variants.len(); if ep.is_wildcard { event.wildcard_variant_idxs.push(variant_idx); @@ -143,6 +145,7 @@ impl DecoderCore { event.variants.push(EventVariant { on_event_registration_index: ep.index, params: ep.params.clone(), + decoder_idx, topic_filters: parse_topic_filters(&ep.topic_selections) .with_context(|| format!("parse topic filters for {}", ep.event_name))?, }); @@ -233,31 +236,52 @@ impl DecoderCore { // Deterministic item order per log, independent of wildcard/owned split. variant_idxs.sort_unstable_by_key(|&idx| event.variants[idx].on_event_registration_index); - let decoded = event - .decoder - .decode_log_parts( - topics - .iter() - .take_while(|t| t.is_some()) - .map(|t| t.as_ref().unwrap().into()), - data, - ) - .context("decode log")?; - - variant_idxs - .iter() - .map(|&idx| { - let variant = &event.variants[idx]; - Ok(RoutedEvent { - index: variant.on_event_registration_index, - params: ParamValue::Obj(apply_names( - decoded.clone(), - &variant.params, - self.checksummed_addresses, - )?), - }) - }) - .collect() + // Decode lazily, once per distinct layout among the matched variants. + // Same-key registrations may split indexed/body differently, and the + // log's bytes need not be valid under every layout — a layout that + // fails to decode just contributes no items. Only when NO matched + // layout decodes is the failure surfaced as an error: the log was + // fetched for these registrations, so silently dropping it would hide + // genuinely malformed data or a wrong ABI. + let mut decoded_by_idx: Vec> = Vec::new(); + decoded_by_idx.resize_with(event.decoders.len(), || None); + let mut first_decode_err = None; + let mut routed = Vec::new(); + for &idx in &variant_idxs { + let variant = &event.variants[idx]; + if decoded_by_idx[variant.decoder_idx].is_none() { + match event.decoders[variant.decoder_idx].decode_log_parts( + topics + .iter() + .take_while(|t| t.is_some()) + .map(|t| t.as_ref().unwrap().into()), + data, + ) { + Ok(decoded) => decoded_by_idx[variant.decoder_idx] = Some(decoded), + Err(e) => { + if first_decode_err.is_none() { + first_decode_err = Some(anyhow::Error::new(e).context("decode log")); + } + continue; + } + } + } + let decoded = decoded_by_idx[variant.decoder_idx] + .clone() + .expect("decoded layout just checked/inserted"); + routed.push(RoutedEvent { + index: variant.on_event_registration_index, + params: ParamValue::Obj(apply_names( + decoded, + &variant.params, + self.checksummed_addresses, + )?), + }); + } + match (routed.is_empty(), first_decode_err) { + (true, Some(err)) => Err(err), + _ => Ok(routed), + } } } @@ -376,10 +400,10 @@ fn build_event_decoder(key: &MetaKey, params: &[ParamMeta]) -> Result bool { @@ -627,41 +651,137 @@ mod tests { } #[test] - fn rejects_metakey_collision_with_different_indexed_layout() { + fn metakey_collision_with_different_indexed_layout_decodes_per_layout() { + use alloy_dyn_abi::DynSolValue; + use alloy_primitives::{hex, U256}; + let sighash = alloy_json_abi::Event::parse("Foo(uint256 a, uint256 b)") .unwrap() .selector() .to_string(); - let variant = |contract: &str, params| OnEventRegistration { - index: 0, + let variant = |index, contract: &str, params| OnEventRegistration { + index, sighash: sighash.clone(), topic_count: 2, event_name: "Foo".to_string(), contract_name: contract.to_string(), - is_wildcard: false, + is_wildcard: true, depends_on_addresses: false, topic_selections: vec![], block_fields: vec![], transaction_fields: vec![], params, }; - - let err = DecoderCore::from_registrations( + let core = DecoderCore::from_registrations( &[ variant( + 0, "C1", vec![pm("a", "uint256", true), pm("b", "uint256", false)], ), variant( + 1, "C2", vec![pm("a", "uint256", false), pm("b", "uint256", true)], ), ], false, ) - .err() - .expect("expected an ABI layout mismatch error"); - assert!(format!("{err}").contains("ABI layout mismatch")); + .expect("different indexed layouts on one key must register"); + + // A log emitted with `a` indexed: topic1 = 7, data = (8,). + let data = DynSolValue::Tuple(vec![DynSolValue::Uint(U256::from(8u64), 256)]).abi_encode(); + let log = Log { + topics: vec![Some(sighash.clone()), Some(format!("0x{:064x}", 7))], + data: Some(format!("0x{}", hex::encode(data))), + ..Default::default() + }; + let routed = core + .route_and_decode_napi(&log, None, &HashSet::from([0, 1])) + .unwrap(); + // Both layouts decode this log (same word-sized types either way), each + // reading the topic/body split its own registration declared. + let values: Vec<(i64, Vec)> = routed + .iter() + .map(|r| { + let fields = match &r.params { + ParamValue::Obj(fields) => { + fields.iter().map(|(name, _)| name.clone()).collect() + } + _ => panic!("expected an object of params"), + }; + (r.index, fields) + }) + .collect(); + assert_eq!( + values, + vec![ + (0, vec!["a".to_string(), "b".to_string()]), + (1, vec!["a".to_string(), "b".to_string()]), + ] + ); + } + + #[test] + fn layout_that_fails_to_decode_drops_only_its_own_registration() { + use alloy_dyn_abi::DynSolValue; + use alloy_primitives::{hex, U256}; + + let sighash = alloy_json_abi::Event::parse("Foo(string a, uint256 b)") + .unwrap() + .selector() + .to_string(); + let variant = |index, contract: &str, params| OnEventRegistration { + index, + sighash: sighash.clone(), + topic_count: 2, + event_name: "Foo".to_string(), + contract_name: contract.to_string(), + is_wildcard: true, + depends_on_addresses: false, + topic_selections: vec![], + block_fields: vec![], + transaction_fields: vec![], + params, + }; + let core = DecoderCore::from_registrations( + &[ + variant( + 0, + "C1", + vec![pm("a", "string", true), pm("b", "uint256", false)], + ), + variant( + 1, + "C2", + vec![pm("a", "string", false), pm("b", "uint256", true)], + ), + ], + false, + ) + .unwrap(); + + // Emitted under C1's layout: topic1 = keccak(a), body = (8,). C2's + // layout reads the body as a string tuple — word 8 as an offset past + // the data — which fails to decode; only C1's item survives. + let data = DynSolValue::Tuple(vec![DynSolValue::Uint(U256::from(8u64), 256)]).abi_encode(); + let log = Log { + topics: vec![Some(sighash.clone()), Some(format!("0x{:064x}", 7))], + data: Some(format!("0x{}", hex::encode(data))), + ..Default::default() + }; + let routed = core + .route_and_decode_napi(&log, None, &HashSet::from([0, 1])) + .unwrap(); + assert_eq!(routed_indexes(&routed), vec![0]); + + // With only the failing layout matched, the decode error surfaces + // instead of the log silently disappearing. + let err = core + .route_and_decode_napi(&log, None, &HashSet::from([1])) + .err() + .expect("expected a decode error when no matched layout decodes"); + assert!(format!("{err:#}").contains("decode log")); } #[test] From f1151ddbcfab3fa04224afc8cc18e5e3e8a7ce68 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 17 Jul 2026 14:01:16 +0000 Subject: [PATCH 04/10] Flatten log routing into a per-query registration scan MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The decoder's MetaKey lookup table (signature map, per-contract variant index, wildcard index, per-query active set) existed to answer one question per log: which of the query's registrations match it. Answer it literally instead: DecoderCore now just holds all registrations by index, a query resolves its selection once into a SelectionDecoder, and routing is a straight scan over that selection — match on signature, emitter (wildcard or owning contract), and static topic filters, then decode under the matching registration's own declaration. This removes the MetaKey/RegisteredEvent/EventVariant structure, the decoder-sharing bookkeeping (each registration owns its decoder), and the per-log HashSet membership checks. A selection is one partition's events, small enough that the scan is also cheaper than the hashed lookups it replaces. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01WN93NjbyYQhuiNgdpjQFFu --- .../cli/src/evm_hypersync_source/decode.rs | 679 ++++++++---------- packages/cli/src/evm_hypersync_source/mod.rs | 33 +- packages/cli/src/evm_rpc_source/mod.rs | 19 +- 3 files changed, 315 insertions(+), 416 deletions(-) diff --git a/packages/cli/src/evm_hypersync_source/decode.rs b/packages/cli/src/evm_hypersync_source/decode.rs index b165189f4..4f45e9ffd 100644 --- a/packages/cli/src/evm_hypersync_source/decode.rs +++ b/packages/cli/src/evm_hypersync_source/decode.rs @@ -1,5 +1,4 @@ -use std::collections::hash_map::Entry; -use std::collections::{HashMap, HashSet}; +use std::collections::HashMap; use std::sync::Arc; use alloy_dyn_abi::{DecodedEvent, DynSolEvent, DynSolType}; @@ -13,88 +12,77 @@ use crate::evm_hypersync_source::types::{ sol_value_to_param, Log, OnEventRegistration, ParamMeta, ParamValue, }; -#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)] -struct MetaKey { +/// One topic position's static constraint: `None` matches any value — either +/// the position is unfiltered, or it carries a `ContractAddresses` marker +/// whose temporal check stays on the JS `clientAddressFilter`. +type StaticTopicFilter = Option>; + +/// Everything needed to match a log against one registration and decode it +/// under that registration's own ABI declaration. Registrations sharing an +/// event signature stay fully independent — each carries its own decoder, so +/// they may name params differently and even split indexed/body params +/// differently. +struct EventRegistration { + index: i64, sighash: [u8; 32], topic_count: u8, + contract_name: String, + is_wildcard: bool, + /// The registration's resolved `where` in DNF (outer Vec is OR); a log + /// matches when any selection's four positions all match. Empty means the + /// registration puts no static topic constraint on its logs. + topic_filters: Vec<[StaticTopicFilter; 4]>, + params: Vec, + decoder: DynSolEvent, } -impl MetaKey { - fn parse(sighash: &str, topic_count: i32) -> Result { - let bytes = LogArgument::decode_hex(sighash).context("decode sighash hex")?; - let topic_count: u8 = u8::try_from(topic_count).context("topic_count out of u8 range")?; +impl EventRegistration { + fn parse(ep: &OnEventRegistration) -> Result { + let sighash = LogArgument::decode_hex(&ep.sighash).context("decode sighash hex")?; + let topic_count: u8 = + u8::try_from(ep.topic_count).context("topic_count out of u8 range")?; anyhow::ensure!( (1..=4).contains(&topic_count), "topic_count must be 1..=4, got {topic_count}", ); Ok(Self { - sighash: **bytes, + index: ep.index, + sighash: **sighash, topic_count, + contract_name: ep.contract_name.clone(), + is_wildcard: ep.is_wildcard, + topic_filters: parse_topic_filters(&ep.topic_selections) + .context("parse topic filters")?, + params: ep.params.clone(), + decoder: build_event_decoder(**sighash, &ep.params).context("build decoder")?, }) } - fn from_topics(topics: &[Option]) -> Result { - let topic0 = topics - .first() - .context("get topic0")? - .as_ref() - .context("topic0 is null")?; - let topic_count: u8 = topics - .iter() - .rposition(|t| t.is_some()) - .map_or(0, |i| i + 1) - .try_into() - .context("topic_count overflow")?; - Ok(Self { - sighash: ***topic0, - topic_count, - }) + /// Whether a log belongs to this registration: same event signature + /// (topic0 + topic count), an allowed emitter (wildcard registrations + /// accept any address, contract-bound registrations only their own + /// contract's — no fallback tier between them), and the registration's + /// static topic filters. + fn matches( + &self, + topic0: &[u8; 32], + topic_count: u8, + contract_name: Option<&str>, + topics: &[Option], + ) -> bool { + self.sighash == *topic0 + && self.topic_count == topic_count + && (self.is_wildcard || contract_name == Some(self.contract_name.as_str())) + && matches_topic_filters(&self.topic_filters, topics) } } -/// One topic position's static constraint: `None` matches any value — either -/// the position is unfiltered, or it carries a `ContractAddresses` marker -/// whose temporal check stays on the JS `clientAddressFilter`. -type StaticTopicFilter = Option>; - -/// One registration's routing metadata for an event. Several registrations -/// collapse to the same `MetaKey` when they select the same-signature event; -/// the positional decode is shared, the param names and filters are not. -struct EventVariant { - on_event_registration_index: i64, - params: Vec, - /// Index into the group's `decoders`; variants with the same positional - /// layout share a decoder so a log is decoded once per layout, not per - /// registration. - decoder_idx: usize, - /// The registration's resolved `where` in DNF (outer Vec is OR); a log - /// matches when any selection's four positions all match. Empty means the - /// registration puts no static topic constraint on its logs. - topic_filters: Vec<[StaticTopicFilter; 4]>, -} - -/// The registrations colliding on one `MetaKey`, with the per-registration -/// namings layered over the positional decoders. Registrations sharing a key -/// may still split indexed/body params differently (same type list, same -/// indexed count, different positions) — such layouts can't be told apart by -/// (topic0, topic count), so each distinct layout gets its own decoder and -/// every matched variant decodes under its registration's own layout. -/// -/// `wildcard_variant_idxs`/`variant_idxs_by_contract_name` index into -/// `variants` and fan a log out to its registrations: wildcard registrations -/// always match, contract-bound registrations match iff the log's address is -/// owned by that contract (via the partition's address index) — there is no -/// fallback tier between them. -struct RegisteredEvent { - decoders: Vec, - variants: Vec, - wildcard_variant_idxs: Vec, - variant_idxs_by_contract_name: HashMap>, -} - +/// All registrations passed at client construction, keyed by their +/// chain-scoped index. Holds no routing state itself — a query resolves its +/// own selection into a `SelectionDecoder` via `selection`. #[derive(Clone)] pub(crate) struct DecoderCore { - events: Arc>, + registrations: Arc>>, checksummed_addresses: bool, } @@ -103,60 +91,55 @@ impl DecoderCore { registrations: &[OnEventRegistration], checksum_addresses: bool, ) -> Result { - let mut events: HashMap = HashMap::new(); + let mut map = HashMap::new(); for ep in registrations { - let key = MetaKey::parse(&ep.sighash, ep.topic_count) - .with_context(|| format!("parse meta key for {}", ep.event_name))?; - let event = match events.entry(key) { - Entry::Occupied(e) => e.into_mut(), - Entry::Vacant(e) => e.insert(RegisteredEvent { - decoders: Vec::new(), - variants: Vec::new(), - wildcard_variant_idxs: Vec::new(), - variant_idxs_by_contract_name: HashMap::new(), - }), - }; - // Reuse an earlier same-layout variant's decoder; differing param - // *names* don't matter (`apply_names` applies each variant's own), - // only the indexed/body split and types do. - let decoder_idx = match event - .variants - .iter() - .find(|v| same_decode_layout(&v.params, &ep.params)) - { - Some(v) => v.decoder_idx, - None => { - let decoder = build_event_decoder(&key, &ep.params) - .with_context(|| format!("build decoder for {}", ep.event_name))?; - event.decoders.push(decoder); - event.decoders.len() - 1 - } - }; - let variant_idx = event.variants.len(); - if ep.is_wildcard { - event.wildcard_variant_idxs.push(variant_idx); - } else { - event - .variant_idxs_by_contract_name - .entry(ep.contract_name.clone()) - .or_default() - .push(variant_idx); - } - event.variants.push(EventVariant { - on_event_registration_index: ep.index, - params: ep.params.clone(), - decoder_idx, - topic_filters: parse_topic_filters(&ep.topic_selections) - .with_context(|| format!("parse topic filters for {}", ep.event_name))?, - }); + let parsed = EventRegistration::parse(ep) + .with_context(|| format!("parse registration for {}", ep.event_name))?; + anyhow::ensure!( + map.insert(ep.index, Arc::new(parsed)).is_none(), + "Duplicate registration index {} for event {}", + ep.index, + ep.event_name, + ); } - Ok(Self { - events: Arc::new(events), + registrations: Arc::new(map), checksummed_addresses: checksum_addresses, }) } + /// Resolves a query's registration selection into the decoder its response + /// logs route through, so a log can only ever route to a registration + /// belonging to the selection that fetched it. + pub(crate) fn selection(&self, registration_indexes: &[i64]) -> Result { + let mut registrations = registration_indexes + .iter() + .map(|id| { + self.registrations + .get(id) + .cloned() + .with_context(|| format!("Unknown registration index {id} in query selection")) + }) + .collect::>>()?; + // Deterministic item order per log, independent of the selection's + // index order. + registrations.sort_unstable_by_key(|reg| reg.index); + Ok(SelectionDecoder { + registrations, + checksummed_addresses: self.checksummed_addresses, + }) + } +} + +/// One query selection's registrations, in registration order. Routing is a +/// straight scan over them — a selection is small (one partition's events), +/// which also keeps the scan cheaper than a keyed lookup. +pub(crate) struct SelectionDecoder { + registrations: Vec>, + checksummed_addresses: bool, +} + +impl SelectionDecoder { pub(crate) fn checksummed_addresses(&self) -> bool { self.checksummed_addresses } @@ -165,7 +148,6 @@ impl DecoderCore { &self, log: &Log, contract_name: Option<&str>, - active_registrations: &HashSet, ) -> Result> { let topics: Vec> = log .topics @@ -179,101 +161,74 @@ impl DecoderCore { .context("decode topics")?; let data = log.data.as_ref().context("get log.data")?; let data = Data::decode_hex(data).context("decode data")?; - self.route_and_decode(&topics, &data, contract_name, active_registrations) + self.route_and_decode(&topics, &data, contract_name) } pub(crate) fn route_and_decode_simple( &self, log: &simple_types::Log, contract_name: Option<&str>, - active_registrations: &HashSet, ) -> Result> { let data = log.data.as_ref().context("get log.data")?; - self.route_and_decode(&log.topics, data, contract_name, active_registrations) + self.route_and_decode(&log.topics, data, contract_name) } - /// Fans a log out to every matching registration and decodes once, applying - /// each registration's own param names. `contract_name` is the log - /// address's owning contract per the partition's address index: wildcard - /// registrations always match, contract-bound registrations match iff the - /// address is owned — there is no fallback tier. A log only routes to - /// registrations whose selection fetched it: registrations outside the - /// query's `active_registrations` never participate, and each match - /// re-applies the registration's static topic filters, since another - /// registration's broader selection in the same query may have fetched - /// the log. An empty result means the log routes nowhere and is dropped - /// by the caller. + /// Fans a log out to every registration of the selection it matches + /// (see `EventRegistration::matches`), decoding it under each match's own + /// ABI declaration. `contract_name` is the log address's owning contract + /// per the partition's address index. + /// + /// Same-signature registrations may declare different indexed/body + /// splits, and the log's bytes need not be valid under every declaration + /// — a match that fails to decode just contributes no item. Only when no + /// match decodes is the failure surfaced as an error: the log was fetched + /// for these registrations, so silently dropping it would hide genuinely + /// malformed data or a wrong ABI. An empty result means the log routes + /// nowhere and is dropped by the caller. fn route_and_decode( &self, topics: &[Option], data: &Data, contract_name: Option<&str>, - active_registrations: &HashSet, ) -> Result> { - let event = match self.events.get(&MetaKey::from_topics(topics)?) { - Some(e) => e, - None => return Ok(Vec::new()), - }; - - let owned_idxs = contract_name - .and_then(|name| event.variant_idxs_by_contract_name.get(name)) - .map(Vec::as_slice) - .unwrap_or_default(); - let mut variant_idxs: Vec = event - .wildcard_variant_idxs + let topic0 = topics + .first() + .context("get topic0")? + .as_ref() + .context("topic0 is null")?; + let topic_count: u8 = topics .iter() - .chain(owned_idxs) - .copied() - .filter(|&idx| { - let variant = &event.variants[idx]; - active_registrations.contains(&variant.on_event_registration_index) - && matches_topic_filters(&variant.topic_filters, topics) - }) - .collect(); - if variant_idxs.is_empty() { - return Ok(Vec::new()); - } - // Deterministic item order per log, independent of wildcard/owned split. - variant_idxs.sort_unstable_by_key(|&idx| event.variants[idx].on_event_registration_index); - - // Decode lazily, once per distinct layout among the matched variants. - // Same-key registrations may split indexed/body differently, and the - // log's bytes need not be valid under every layout — a layout that - // fails to decode just contributes no items. Only when NO matched - // layout decodes is the failure surfaced as an error: the log was - // fetched for these registrations, so silently dropping it would hide - // genuinely malformed data or a wrong ABI. - let mut decoded_by_idx: Vec> = Vec::new(); - decoded_by_idx.resize_with(event.decoders.len(), || None); - let mut first_decode_err = None; + .rposition(|t| t.is_some()) + .map_or(0, |i| i + 1) + .try_into() + .context("topic_count overflow")?; + let mut routed = Vec::new(); - for &idx in &variant_idxs { - let variant = &event.variants[idx]; - if decoded_by_idx[variant.decoder_idx].is_none() { - match event.decoders[variant.decoder_idx].decode_log_parts( - topics - .iter() - .take_while(|t| t.is_some()) - .map(|t| t.as_ref().unwrap().into()), - data, - ) { - Ok(decoded) => decoded_by_idx[variant.decoder_idx] = Some(decoded), - Err(e) => { - if first_decode_err.is_none() { - first_decode_err = Some(anyhow::Error::new(e).context("decode log")); - } - continue; + let mut first_decode_err = None; + for reg in &self.registrations { + if !reg.matches(topic0, topic_count, contract_name, topics) { + continue; + } + let decoded = match reg.decoder.decode_log_parts( + topics + .iter() + .take_while(|t| t.is_some()) + .map(|t| t.as_ref().unwrap().into()), + data, + ) { + Ok(decoded) => decoded, + Err(e) => { + if first_decode_err.is_none() { + first_decode_err = Some(anyhow::Error::new(e).context("decode log")); } + continue; } - } - let decoded = decoded_by_idx[variant.decoder_idx] - .clone() - .expect("decoded layout just checked/inserted"); + }; routed.push(RoutedEvent { - index: variant.on_event_registration_index, + index: reg.index, params: ParamValue::Obj(apply_names( decoded, - &variant.params, + ®.params, self.checksummed_addresses, )?), }); @@ -375,12 +330,12 @@ fn apply_names( .collect() } -/// Build the positional decoder for one MetaKey. The decoder's topic0 is pinned -/// to the on-chain sighash the MetaKey carries rather than derived from a -/// signature string, so an event surfaced to handlers under a different `name:` -/// (display name != on-chain name) still matches its real log (issue #1285). -/// The event name plays no part in decoding — only the param types do. -fn build_event_decoder(key: &MetaKey, params: &[ParamMeta]) -> Result { +/// Build the positional decoder for one registration. The decoder's topic0 is +/// pinned to the on-chain sighash the registration carries rather than derived +/// from a signature string, so an event surfaced to handlers under a different +/// `name:` (display name != on-chain name) still matches its real log (issue +/// #1285). The event name plays no part in decoding — only the param types do. +fn build_event_decoder(sighash: [u8; 32], params: &[ParamMeta]) -> Result { let mut indexed = Vec::new(); let mut body = Vec::new(); for param in params { @@ -392,31 +347,8 @@ fn build_event_decoder(key: &MetaKey, params: &[ParamMeta]) -> Result bool { - a.len() == b.len() - && a.iter().zip(b).all(|(x, y)| { - x.indexed == y.indexed - && x.abi_type == y.abi_type - && match (&x.components, &y.components) { - (None, None) => true, - (Some(xc), Some(yc)) => same_decode_layout(xc, yc), - _ => false, - } - }) + DynSolEvent::new(Some(B256::from(sighash)), indexed, DynSolType::Tuple(body)) + .context("construct event decoder") } #[cfg(test)] @@ -426,22 +358,97 @@ mod tests { const VALID_SIGHASH: &str = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; + fn pm(name: &str, abi_type: &str, indexed: bool) -> ParamMeta { + ParamMeta { + name: name.to_string(), + abi_type: abi_type.to_string(), + indexed, + components: None, + } + } + + // A one-body-param registration with an arbitrary sighash and topic count 1, + // so logs are easy to fabricate. + fn value_reg( + index: i64, + contract_name: &str, + is_wildcard: bool, + sighash: &str, + ) -> OnEventRegistration { + OnEventRegistration { + index, + sighash: sighash.to_string(), + topic_count: 1, + event_name: "E".to_string(), + contract_name: contract_name.to_string(), + is_wildcard, + depends_on_addresses: false, + topic_selections: vec![], + block_fields: vec![], + transaction_fields: vec![], + params: vec![pm("value", "uint256", false)], + } + } + + fn value_log(sighash: &str) -> Log { + use alloy_dyn_abi::DynSolValue; + use alloy_primitives::{hex, U256}; + let data = DynSolValue::Tuple(vec![DynSolValue::Uint(U256::from(1u64), 256)]).abi_encode(); + Log { + topics: vec![Some(sighash.to_string())], + data: Some(format!("0x{}", hex::encode(data))), + ..Default::default() + } + } + + fn routed_indexes(routed: &[RoutedEvent]) -> Vec { + routed.iter().map(|r| r.index).collect() + } + + #[test] + fn registration_rejects_zero_topics() { + let mut reg = value_reg(0, "C", false, VALID_SIGHASH); + reg.topic_count = 0; + let err = DecoderCore::from_registrations(&[reg], false).err().unwrap(); + assert!(format!("{err:#}").contains("topic_count must be 1..=4")); + } + #[test] - fn parse_meta_key_rejects_zero_topics() { - let err = MetaKey::parse(VALID_SIGHASH, 0).unwrap_err(); - assert!(format!("{err}").contains("topic_count must be 1..=4")); + fn registration_rejects_five_topics() { + let mut reg = value_reg(0, "C", false, VALID_SIGHASH); + reg.topic_count = 5; + let err = DecoderCore::from_registrations(&[reg], false).err().unwrap(); + assert!(format!("{err:#}").contains("topic_count must be 1..=4")); } #[test] - fn parse_meta_key_rejects_five_topics() { - let err = MetaKey::parse(VALID_SIGHASH, 5).unwrap_err(); - assert!(format!("{err}").contains("topic_count must be 1..=4")); + fn registration_accepts_boundary_topic_counts() { + let mut one = value_reg(0, "C", false, VALID_SIGHASH); + one.topic_count = 1; + let mut four = value_reg(1, "C", false, VALID_SIGHASH); + four.topic_count = 4; + assert!(DecoderCore::from_registrations(&[one, four], false).is_ok()); } #[test] - fn parse_meta_key_accepts_boundary_values() { - assert!(MetaKey::parse(VALID_SIGHASH, 1).is_ok()); - assert!(MetaKey::parse(VALID_SIGHASH, 4).is_ok()); + fn duplicate_registration_index_errors() { + let err = DecoderCore::from_registrations( + &[ + value_reg(0, "C", false, VALID_SIGHASH), + value_reg(0, "D", false, VALID_SIGHASH), + ], + false, + ) + .err() + .unwrap(); + assert!(format!("{err:#}").contains("Duplicate registration index 0")); + } + + #[test] + fn unknown_registration_index_errors() { + let core = DecoderCore::from_registrations(&[], false).unwrap(); + let err = core.selection(&[7]).err().unwrap(); + assert!(format!("{err:#}").contains("Unknown registration index 7")); } // Regression for issue #1285: an event surfaced to handlers under a name @@ -469,20 +476,7 @@ mod tests { topic_selections: vec![], block_fields: vec![], transaction_fields: vec![], - params: vec![ - ParamMeta { - name: "owner".to_string(), - abi_type: "address".to_string(), - indexed: false, - components: None, - }, - ParamMeta { - name: "value".to_string(), - abi_type: "uint256".to_string(), - indexed: false, - components: None, - }, - ], + params: vec![pm("owner", "address", false), pm("value", "uint256", false)], }], false, ) @@ -500,7 +494,9 @@ mod tests { }; let mut routed = core - .route_and_decode_napi(&log, Some("TestContract"), &HashSet::from([7])) + .selection(&[7]) + .unwrap() + .route_and_decode_napi(&log, Some("TestContract")) .unwrap(); assert_eq!(routed.len(), 1); let routed = routed @@ -521,93 +517,46 @@ mod tests { } } - fn pm(name: &str, abi_type: &str, indexed: bool) -> ParamMeta { - ParamMeta { - name: name.to_string(), - abi_type: abi_type.to_string(), - indexed, - components: None, - } - } - - // One Transfer(address,uint256)-shaped registration; anonymous topic-count-1 - // key so logs are easy to fabricate. - fn transfer_reg( - index: i64, - contract_name: &str, - is_wildcard: bool, - sighash: &str, - ) -> OnEventRegistration { - OnEventRegistration { - index, - sighash: sighash.to_string(), - topic_count: 1, - event_name: "E".to_string(), - contract_name: contract_name.to_string(), - is_wildcard, - depends_on_addresses: false, - topic_selections: vec![], - block_fields: vec![], - transaction_fields: vec![], - params: vec![pm("value", "uint256", false)], - } - } - - fn value_log(sighash: &str) -> Log { - use alloy_dyn_abi::DynSolValue; - use alloy_primitives::{hex, U256}; - let data = DynSolValue::Tuple(vec![DynSolValue::Uint(U256::from(1u64), 256)]).abi_encode(); - Log { - topics: vec![Some(sighash.to_string())], - data: Some(format!("0x{}", hex::encode(data))), - ..Default::default() - } - } - - fn routed_indexes(routed: &[RoutedEvent]) -> Vec { - routed.iter().map(|r| r.index).collect() - } - #[test] fn fans_out_to_wildcards_and_owned_contract_without_fallback_tier() { let core = DecoderCore::from_registrations( &[ - transfer_reg(0, "Owned", false, VALID_SIGHASH), - transfer_reg(1, "W1", true, VALID_SIGHASH), - transfer_reg(2, "W2", true, VALID_SIGHASH), - transfer_reg(3, "Other", false, VALID_SIGHASH), + value_reg(0, "Owned", false, VALID_SIGHASH), + value_reg(1, "W1", true, VALID_SIGHASH), + value_reg(2, "W2", true, VALID_SIGHASH), + value_reg(3, "Other", false, VALID_SIGHASH), ], false, ) .unwrap(); - let active = HashSet::from([0, 1, 2, 3]); + let decoder = core.selection(&[0, 1, 2, 3]).unwrap(); let log = value_log(VALID_SIGHASH); // Owned address: the contract's registration plus every wildcard. - let owned = core - .route_and_decode_napi(&log, Some("Owned"), &active) - .unwrap(); + let owned = decoder.route_and_decode_napi(&log, Some("Owned")).unwrap(); assert_eq!(routed_indexes(&owned), vec![0, 1, 2]); // Unowned address: wildcards only — no fallback into contract-bound // registrations. - let unowned = core.route_and_decode_napi(&log, None, &active).unwrap(); + let unowned = decoder.route_and_decode_napi(&log, None).unwrap(); assert_eq!(routed_indexes(&unowned), vec![1, 2]); } #[test] - fn routing_scoped_to_active_registrations() { + fn routing_scoped_to_query_selection() { let core = DecoderCore::from_registrations( &[ - transfer_reg(0, "Owned", false, VALID_SIGHASH), - transfer_reg(1, "W1", true, VALID_SIGHASH), + value_reg(0, "Owned", false, VALID_SIGHASH), + value_reg(1, "W1", true, VALID_SIGHASH), ], false, ) .unwrap(); let log = value_log(VALID_SIGHASH); let routed = core - .route_and_decode_napi(&log, Some("Owned"), &HashSet::from([0])) + .selection(&[0]) + .unwrap() + .route_and_decode_napi(&log, Some("Owned")) .unwrap(); assert_eq!(routed_indexes(&routed), vec![0]); } @@ -616,7 +565,7 @@ mod tests { fn static_topic_filters_reapplied_per_registration() { const TOPIC1_A: &str = "0x00000000000000000000000000000000000000000000000000000000000000aa"; const TOPIC1_B: &str = "0x00000000000000000000000000000000000000000000000000000000000000bb"; - let selection = |topic1| crate::evm_hypersync_source::selection::TopicSelectionInput { + let selection = |topic1| TopicSelectionInput { topic0: vec![VALID_SIGHASH.to_string()], topic1, topic2: Some(vec![]), @@ -625,33 +574,36 @@ mod tests { // Three same-signature wildcards: one filtering topic1 to A, one to B, // and one with a ContractAddresses marker (None) that Rust must treat // as match-any (the JS clientAddressFilter owns that check). - let mut filtered_a = transfer_reg(0, "WA", true, VALID_SIGHASH); + let mut filtered_a = value_reg(0, "WA", true, VALID_SIGHASH); filtered_a.topic_count = 2; filtered_a.params = vec![pm("who", "address", true), pm("value", "uint256", false)]; filtered_a.topic_selections = vec![selection(Some(vec![TOPIC1_A.to_string()]))]; - let mut filtered_b = transfer_reg(1, "WB", true, VALID_SIGHASH); + let mut filtered_b = value_reg(1, "WB", true, VALID_SIGHASH); filtered_b.topic_count = 2; filtered_b.params = filtered_a.params.clone(); filtered_b.topic_selections = vec![selection(Some(vec![TOPIC1_B.to_string()]))]; - let mut marker = transfer_reg(2, "WC", true, VALID_SIGHASH); + let mut marker = value_reg(2, "WC", true, VALID_SIGHASH); marker.topic_count = 2; marker.params = filtered_a.params.clone(); marker.topic_selections = vec![selection(None)]; let core = DecoderCore::from_registrations(&[filtered_a, filtered_b, marker], false).unwrap(); - let active = HashSet::from([0, 1, 2]); let log = Log { topics: vec![Some(VALID_SIGHASH.to_string()), Some(TOPIC1_A.to_string())], data: value_log(VALID_SIGHASH).data, ..Default::default() }; - let routed = core.route_and_decode_napi(&log, None, &active).unwrap(); + let routed = core + .selection(&[0, 1, 2]) + .unwrap() + .route_and_decode_napi(&log, None) + .unwrap(); assert_eq!(routed_indexes(&routed), vec![0, 2]); } #[test] - fn metakey_collision_with_different_indexed_layout_decodes_per_layout() { + fn same_signature_with_different_indexed_layout_decodes_per_registration() { use alloy_dyn_abi::DynSolValue; use alloy_primitives::{hex, U256}; @@ -659,18 +611,11 @@ mod tests { .unwrap() .selector() .to_string(); - let variant = |index, contract: &str, params| OnEventRegistration { - index, - sighash: sighash.clone(), - topic_count: 2, - event_name: "Foo".to_string(), - contract_name: contract.to_string(), - is_wildcard: true, - depends_on_addresses: false, - topic_selections: vec![], - block_fields: vec![], - transaction_fields: vec![], - params, + let variant = |index, contract: &str, params| { + let mut reg = value_reg(index, contract, true, &sighash); + reg.topic_count = 2; + reg.params = params; + reg }; let core = DecoderCore::from_registrations( &[ @@ -687,7 +632,7 @@ mod tests { ], false, ) - .expect("different indexed layouts on one key must register"); + .expect("different indexed layouts on one signature must register"); // A log emitted with `a` indexed: topic1 = 7, data = (8,). let data = DynSolValue::Tuple(vec![DynSolValue::Uint(U256::from(8u64), 256)]).abi_encode(); @@ -697,10 +642,13 @@ mod tests { ..Default::default() }; let routed = core - .route_and_decode_napi(&log, None, &HashSet::from([0, 1])) + .selection(&[0, 1]) + .unwrap() + .route_and_decode_napi(&log, None) .unwrap(); - // Both layouts decode this log (same word-sized types either way), each - // reading the topic/body split its own registration declared. + // Both declarations decode this log (same word-sized types either + // way), each reading the topic/body split its own registration + // declared. let values: Vec<(i64, Vec)> = routed .iter() .map(|r| { @@ -723,7 +671,7 @@ mod tests { } #[test] - fn layout_that_fails_to_decode_drops_only_its_own_registration() { + fn declaration_that_fails_to_decode_drops_only_its_own_registration() { use alloy_dyn_abi::DynSolValue; use alloy_primitives::{hex, U256}; @@ -731,18 +679,11 @@ mod tests { .unwrap() .selector() .to_string(); - let variant = |index, contract: &str, params| OnEventRegistration { - index, - sighash: sighash.clone(), - topic_count: 2, - event_name: "Foo".to_string(), - contract_name: contract.to_string(), - is_wildcard: true, - depends_on_addresses: false, - topic_selections: vec![], - block_fields: vec![], - transaction_fields: vec![], - params, + let variant = |index, contract: &str, params| { + let mut reg = value_reg(index, contract, true, &sighash); + reg.topic_count = 2; + reg.params = params; + reg }; let core = DecoderCore::from_registrations( &[ @@ -761,9 +702,10 @@ mod tests { ) .unwrap(); - // Emitted under C1's layout: topic1 = keccak(a), body = (8,). C2's - // layout reads the body as a string tuple — word 8 as an offset past - // the data — which fails to decode; only C1's item survives. + // Emitted under C1's declaration: topic1 = keccak(a), body = (8,). + // C2's declaration reads the body as a string tuple — word 8 as an + // offset past the data — which fails to decode; only C1's item + // survives. let data = DynSolValue::Tuple(vec![DynSolValue::Uint(U256::from(8u64), 256)]).abi_encode(); let log = Log { topics: vec![Some(sighash.clone()), Some(format!("0x{:064x}", 7))], @@ -771,61 +713,20 @@ mod tests { ..Default::default() }; let routed = core - .route_and_decode_napi(&log, None, &HashSet::from([0, 1])) + .selection(&[0, 1]) + .unwrap() + .route_and_decode_napi(&log, None) .unwrap(); assert_eq!(routed_indexes(&routed), vec![0]); - // With only the failing layout matched, the decode error surfaces + // With only the failing declaration matched, the decode error surfaces // instead of the log silently disappearing. let err = core - .route_and_decode_napi(&log, None, &HashSet::from([1])) + .selection(&[1]) + .unwrap() + .route_and_decode_napi(&log, None) .err() - .expect("expected a decode error when no matched layout decodes"); + .expect("expected a decode error when no matched declaration decodes"); assert!(format!("{err:#}").contains("decode log")); } - - #[test] - fn accepts_metakey_collision_with_same_layout_different_names() { - let sighash = - alloy_json_abi::Event::parse("Transfer(address from, address to, uint256 value)") - .unwrap() - .selector() - .to_string(); - let variant = |contract: &str, params| OnEventRegistration { - index: 0, - sighash: sighash.clone(), - topic_count: 3, - event_name: "Transfer".to_string(), - contract_name: contract.to_string(), - is_wildcard: false, - depends_on_addresses: false, - topic_selections: vec![], - block_fields: vec![], - transaction_fields: vec![], - params, - }; - - DecoderCore::from_registrations( - &[ - variant( - "TokenA", - vec![ - pm("from", "address", true), - pm("to", "address", true), - pm("value", "uint256", false), - ], - ), - variant( - "TokenB", - vec![ - pm("src", "address", true), - pm("dst", "address", true), - pm("wad", "uint256", false), - ], - ), - ], - false, - ) - .expect("same-layout variants with different names must register"); - } } diff --git a/packages/cli/src/evm_hypersync_source/mod.rs b/packages/cli/src/evm_hypersync_source/mod.rs index 02c77401a..7799d22fe 100644 --- a/packages/cli/src/evm_hypersync_source/mod.rs +++ b/packages/cli/src/evm_hypersync_source/mod.rs @@ -17,7 +17,7 @@ pub(crate) mod types; use std::collections::HashMap; use config::ClientConfig; -use decode::DecoderCore; +use decode::{DecoderCore, SelectionDecoder}; use query::{BlockField, LogField, LogFilter, LogSelection, Query, TransactionField}; use selection::{BuiltLogSelection, SelectionBuilder}; use types::{ @@ -126,6 +126,10 @@ impl EvmHypersyncClient { ¶ms.addresses_by_contract_name, ) .map_err(map_err)?; + let selection_decoder = self + .decoder + .selection(¶ms.registration_indexes) + .map_err(map_err)?; let requested_transaction_fields = built.transaction_fields; let mut block_fields = built.block_fields; @@ -202,21 +206,18 @@ impl EvmHypersyncClient { let transaction_store = TransactionStore::new_evm(self.enable_checksum_addresses); let block_store = BlockStore::new_evm(self.enable_checksum_addresses); - let active_registrations: HashSet = - params.registration_indexes.iter().copied().collect(); let (items, blocks) = tokio::task::block_in_place(|| { process_response( response.data.blocks, response.data.transactions, response.data.logs, - &self.decoder, + &selection_decoder, self.enable_checksum_addresses, &validated_block_fields, &requested_transaction_fields, &transaction_store, &block_store, &contract_name_by_address, - &active_registrations, ) }) .map_err(convert_error_to_napi)?; @@ -376,14 +377,13 @@ fn process_response( blocks: Vec>, transactions: Vec>, logs: Vec>, - decoder: &DecoderCore, + decoder: &SelectionDecoder, should_checksum: bool, validated_block_fields: &[BlockField], requested_transaction_fields: &[TransactionField], transaction_store: &TransactionStore, block_store: &BlockStore, contract_name_by_address: &std::collections::HashMap, - active_registrations: &HashSet, ) -> std::result::Result<(Vec, Vec), ConvertError> { // The server returns one block per number; items reference them by number, // so keep them owned and track which numbers are present for coverage. @@ -494,7 +494,6 @@ fn process_response( contract_name_by_address .get(&src_address) .map(String::as_str), - active_registrations, ) .context("decode event params")?; for routed in routed { @@ -681,14 +680,17 @@ mod tests { use super::*; use hypersync_client::simple_types; - fn empty_decoder() -> DecoderCore { - DecoderCore::from_registrations(&[], false).unwrap() + fn empty_decoder() -> SelectionDecoder { + DecoderCore::from_registrations(&[], false) + .unwrap() + .selection(&[]) + .unwrap() } // Routes `full_log` (zero topic0, one topic, empty data) to a wildcard // registration so success-path tests still produce an item now that // unrouted logs are dropped. - fn zero_event_decoder() -> DecoderCore { + fn zero_event_decoder() -> SelectionDecoder { DecoderCore::from_registrations( &[crate::evm_hypersync_source::types::OnEventRegistration { index: 0, @@ -706,6 +708,8 @@ mod tests { false, ) .unwrap() + .selection(&[0]) + .unwrap() } fn full_log(block_number: u64) -> simple_types::Log { @@ -735,7 +739,6 @@ mod tests { &TransactionStore::new_evm(false), &BlockStore::new_evm(false), &Default::default(), - &HashSet::from([0]), ) .err() .expect("expected MissingFields error"); @@ -764,7 +767,6 @@ mod tests { &TransactionStore::new_evm(false), &BlockStore::new_evm(false), &Default::default(), - &HashSet::from([0]), ) .err() .expect("expected MissingFields error"); @@ -798,7 +800,6 @@ mod tests { &TransactionStore::new_evm(false), &BlockStore::new_evm(false), &Default::default(), - &HashSet::from([0]), ) .err() .expect("expected MissingFields error"); @@ -836,7 +837,6 @@ mod tests { &TransactionStore::new_evm(false), &BlockStore::new_evm(false), &Default::default(), - &HashSet::from([0]), ) .expect("expected success when only nullable fields are absent"); assert_eq!(items.len(), 1); @@ -864,7 +864,6 @@ mod tests { &TransactionStore::new_evm(false), &BlockStore::new_evm(false), &Default::default(), - &HashSet::from([0]), ) .err() .expect("expected MissingFields error"); @@ -896,7 +895,6 @@ mod tests { &TransactionStore::new_evm(false), &BlockStore::new_evm(false), &Default::default(), - &HashSet::from([0]), ) .err() .expect("expected MissingFields error"); @@ -935,7 +933,6 @@ mod tests { &store, &BlockStore::new_evm(false), &Default::default(), - &HashSet::from([0]), ) .expect("expected success when block and transaction join"); diff --git a/packages/cli/src/evm_rpc_source/mod.rs b/packages/cli/src/evm_rpc_source/mod.rs index eef7dfdda..0daa4b9f1 100644 --- a/packages/cli/src/evm_rpc_source/mod.rs +++ b/packages/cli/src/evm_rpc_source/mod.rs @@ -9,7 +9,7 @@ mod classify; mod client; mod interval; -use crate::evm_hypersync_source::decode::DecoderCore; +use crate::evm_hypersync_source::decode::{DecoderCore, SelectionDecoder}; use crate::evm_hypersync_source::selection::{BuiltLogSelection, SelectionBuilder}; use crate::evm_hypersync_source::types::{ encode_address, Log as DecoderLog, OnEventRegistration, ParamValue, @@ -262,8 +262,11 @@ impl EvmRpcClient { .map_err(map_err)?; let log_selections = built.log_selections; let contract_name_by_address = std::sync::Arc::new(built.contract_name_by_address); - let active_registrations: std::sync::Arc> = - std::sync::Arc::new(params.registration_indexes.iter().copied().collect()); + let selection_decoder = std::sync::Arc::new( + self.decoder + .selection(¶ms.registration_indexes) + .map_err(map_err)?, + ); let timeout = Duration::from_millis(self.sync_config.query_timeout_millis); let page_result = tokio::time::timeout( timeout, @@ -272,7 +275,7 @@ impl EvmRpcClient { to_block, &log_selections, &contract_name_by_address, - &active_registrations, + &selection_decoder, ), ) .await; @@ -394,7 +397,7 @@ impl EvmRpcClient { to_block: u64, selections: &[BuiltLogSelection], contract_name_by_address: &std::sync::Arc>, - active_registrations: &std::sync::Arc>, + selection_decoder: &std::sync::Arc, ) -> Result<(Vec, Vec), (RpcError, Vec)> { if selections.is_empty() { return Ok((Vec::new(), Vec::new())); @@ -408,7 +411,7 @@ impl EvmRpcClient { to_block as i64, selection, contract_name_by_address.clone(), - active_registrations.clone(), + selection_decoder.clone(), ) .await; (result, started.elapsed().as_secs_f64()) @@ -455,7 +458,7 @@ impl EvmRpcClient { to_block: i64, selection: &BuiltLogSelection, contract_name_by_address: std::sync::Arc>, - active_registrations: std::sync::Arc>, + decoder: std::sync::Arc, ) -> Result, RpcError> { // eth_getLogs topic filters: `null` matches any value at a position; // trailing match-any positions are trimmed entirely. @@ -484,7 +487,6 @@ impl EvmRpcClient { let raw_logs: Vec = self.inner.request("eth_getLogs", json!([filter])).await?; - let decoder = self.decoder.clone(); // Decoding is CPU-bound ABI work; keep it off the libuv async thread. tokio::task::spawn_blocking(move || { let should_checksum = decoder.checksummed_addresses(); @@ -498,7 +500,6 @@ impl EvmRpcClient { .route_and_decode_napi( &raw.to_decoder_log(), contract_name_by_address.get(&address).map(String::as_str), - &active_registrations, ) .unwrap_or_default(); if routed.is_empty() { From 59d5167846d421bee21c9a2a71bcb42c0bcaec8f Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 17 Jul 2026 14:45:50 +0000 Subject: [PATCH 05/10] Rename decoder registration types and encapsulate topic filters MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit OnEventRegistration (the napi input DTO) becomes OnEventRegistrationInput, freeing the name for the decoder's parsed form (was EventRegistration) — matching the JS side's onEventRegistration naming for the same concept. The static topic filter DNF moves behind a TopicFilters type owning its parse and matches, so the registration struct and matching logic no longer carry the raw nested shape. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01WN93NjbyYQhuiNgdpjQFFu --- .../cli/src/evm_hypersync_source/decode.rs | 165 +++++++++--------- packages/cli/src/evm_hypersync_source/mod.rs | 33 ++-- .../cli/src/evm_hypersync_source/selection.rs | 8 +- .../cli/src/evm_hypersync_source/types.rs | 2 +- packages/cli/src/evm_rpc_source/mod.rs | 4 +- 5 files changed, 106 insertions(+), 106 deletions(-) diff --git a/packages/cli/src/evm_hypersync_source/decode.rs b/packages/cli/src/evm_hypersync_source/decode.rs index 4f45e9ffd..1d2a9193a 100644 --- a/packages/cli/src/evm_hypersync_source/decode.rs +++ b/packages/cli/src/evm_hypersync_source/decode.rs @@ -9,35 +9,88 @@ use hypersync_client::simple_types; use crate::evm_hypersync_source::selection::TopicSelectionInput; use crate::evm_hypersync_source::types::{ - sol_value_to_param, Log, OnEventRegistration, ParamMeta, ParamValue, + sol_value_to_param, Log, OnEventRegistrationInput, ParamMeta, ParamValue, }; -/// One topic position's static constraint: `None` matches any value — either -/// the position is unfiltered, or it carries a `ContractAddresses` marker -/// whose temporal check stays on the JS `clientAddressFilter`. -type StaticTopicFilter = Option>; +/// A registration's static topic constraints — its resolved `where` in DNF: +/// the outer Vec is an OR of alternatives, each alternative constrains the +/// four topic positions. Per position, `None` matches any value: the position +/// is either unfiltered or carries a `ContractAddresses` marker whose +/// temporal check stays on the JS `clientAddressFilter`. An empty DNF puts no +/// constraint on the registration's logs. +struct TopicFilters(Vec<[Option>; 4]>); + +impl TopicFilters { + fn parse(topic_selections: &[TopicSelectionInput]) -> Result { + let parse_values = |values: &[String]| -> Result> { + values + .iter() + .map(|v| { + LogArgument::decode_hex(v) + .map(|arg| **arg) + .with_context(|| format!("decode topic filter value {v}")) + }) + .collect() + }; + // An empty value list means match-any (mirroring query semantics), + // same as a `ContractAddresses` marker (`None` input). + let parse_position = |values: Option<&Vec>| -> Result>> { + match values { + Some(values) if !values.is_empty() => Ok(Some(parse_values(values)?)), + _ => Ok(None), + } + }; + let alternatives = topic_selections + .iter() + .map(|ts| { + Ok([ + parse_position(Some(&ts.topic0))?, + parse_position(ts.topic1.as_ref())?, + parse_position(ts.topic2.as_ref())?, + parse_position(ts.topic3.as_ref())?, + ]) + }) + .collect::>()?; + Ok(Self(alternatives)) + } + + fn matches(&self, topics: &[Option]) -> bool { + if self.0.is_empty() { + return true; + } + self.0.iter().any(|alternative| { + alternative + .iter() + .enumerate() + .all(|(position, filter)| match filter { + None => true, + Some(values) => topics + .get(position) + .and_then(Option::as_ref) + .is_some_and(|topic| values.iter().any(|v| v == &***topic)), + }) + }) + } +} /// Everything needed to match a log against one registration and decode it /// under that registration's own ABI declaration. Registrations sharing an /// event signature stay fully independent — each carries its own decoder, so /// they may name params differently and even split indexed/body params /// differently. -struct EventRegistration { +struct OnEventRegistration { index: i64, sighash: [u8; 32], topic_count: u8, contract_name: String, is_wildcard: bool, - /// The registration's resolved `where` in DNF (outer Vec is OR); a log - /// matches when any selection's four positions all match. Empty means the - /// registration puts no static topic constraint on its logs. - topic_filters: Vec<[StaticTopicFilter; 4]>, + topic_filters: TopicFilters, params: Vec, decoder: DynSolEvent, } -impl EventRegistration { - fn parse(ep: &OnEventRegistration) -> Result { +impl OnEventRegistration { + fn parse(ep: &OnEventRegistrationInput) -> Result { let sighash = LogArgument::decode_hex(&ep.sighash).context("decode sighash hex")?; let topic_count: u8 = u8::try_from(ep.topic_count).context("topic_count out of u8 range")?; @@ -51,7 +104,7 @@ impl EventRegistration { topic_count, contract_name: ep.contract_name.clone(), is_wildcard: ep.is_wildcard, - topic_filters: parse_topic_filters(&ep.topic_selections) + topic_filters: TopicFilters::parse(&ep.topic_selections) .context("parse topic filters")?, params: ep.params.clone(), decoder: build_event_decoder(**sighash, &ep.params).context("build decoder")?, @@ -73,7 +126,7 @@ impl EventRegistration { self.sighash == *topic0 && self.topic_count == topic_count && (self.is_wildcard || contract_name == Some(self.contract_name.as_str())) - && matches_topic_filters(&self.topic_filters, topics) + && self.topic_filters.matches(topics) } } @@ -82,18 +135,18 @@ impl EventRegistration { /// own selection into a `SelectionDecoder` via `selection`. #[derive(Clone)] pub(crate) struct DecoderCore { - registrations: Arc>>, + registrations: Arc>>, checksummed_addresses: bool, } impl DecoderCore { pub(crate) fn from_registrations( - registrations: &[OnEventRegistration], + registrations: &[OnEventRegistrationInput], checksum_addresses: bool, ) -> Result { let mut map = HashMap::new(); for ep in registrations { - let parsed = EventRegistration::parse(ep) + let parsed = OnEventRegistration::parse(ep) .with_context(|| format!("parse registration for {}", ep.event_name))?; anyhow::ensure!( map.insert(ep.index, Arc::new(parsed)).is_none(), @@ -135,7 +188,7 @@ impl DecoderCore { /// straight scan over them — a selection is small (one partition's events), /// which also keeps the scan cheaper than a keyed lookup. pub(crate) struct SelectionDecoder { - registrations: Vec>, + registrations: Vec>, checksummed_addresses: bool, } @@ -174,7 +227,7 @@ impl SelectionDecoder { } /// Fans a log out to every registration of the selection it matches - /// (see `EventRegistration::matches`), decoding it under each match's own + /// (see `OnEventRegistration::matches`), decoding it under each match's own /// ABI declaration. `contract_name` is the log address's owning contract /// per the partition's address index. /// @@ -240,66 +293,6 @@ impl SelectionDecoder { } } -fn parse_topic_filters( - topic_selections: &[TopicSelectionInput], -) -> Result> { - let parse_values = |values: &[String]| -> Result> { - values - .iter() - .map(|v| { - LogArgument::decode_hex(v) - .map(|arg| **arg) - .with_context(|| format!("decode topic filter value {v}")) - }) - .collect() - }; - // An empty value list means match-any (mirroring query semantics), same as - // a `ContractAddresses` marker (`None` input) — the marker's check stays on - // the JS `clientAddressFilter`. - let parse_position = |input: &Option>| -> Result { - match input { - Some(values) if !values.is_empty() => Ok(Some(parse_values(values)?)), - _ => Ok(None), - } - }; - topic_selections - .iter() - .map(|ts| { - Ok([ - if ts.topic0.is_empty() { - None - } else { - Some(parse_values(&ts.topic0)?) - }, - parse_position(&ts.topic1)?, - parse_position(&ts.topic2)?, - parse_position(&ts.topic3)?, - ]) - }) - .collect() -} - -fn matches_topic_filters( - filters: &[[StaticTopicFilter; 4]], - topics: &[Option], -) -> bool { - if filters.is_empty() { - return true; - } - filters.iter().any(|selection| { - selection - .iter() - .enumerate() - .all(|(i, filter)| match filter { - None => true, - Some(values) => topics - .get(i) - .and_then(Option::as_ref) - .is_some_and(|topic| values.iter().any(|v| v == &***topic)), - }) - }) -} - pub(crate) struct RoutedEvent { pub index: i64, pub params: ParamValue, @@ -374,8 +367,8 @@ mod tests { contract_name: &str, is_wildcard: bool, sighash: &str, - ) -> OnEventRegistration { - OnEventRegistration { + ) -> OnEventRegistrationInput { + OnEventRegistrationInput { index, sighash: sighash.to_string(), topic_count: 1, @@ -409,7 +402,9 @@ mod tests { fn registration_rejects_zero_topics() { let mut reg = value_reg(0, "C", false, VALID_SIGHASH); reg.topic_count = 0; - let err = DecoderCore::from_registrations(&[reg], false).err().unwrap(); + let err = DecoderCore::from_registrations(&[reg], false) + .err() + .unwrap(); assert!(format!("{err:#}").contains("topic_count must be 1..=4")); } @@ -417,7 +412,9 @@ mod tests { fn registration_rejects_five_topics() { let mut reg = value_reg(0, "C", false, VALID_SIGHASH); reg.topic_count = 5; - let err = DecoderCore::from_registrations(&[reg], false).err().unwrap(); + let err = DecoderCore::from_registrations(&[reg], false) + .err() + .unwrap(); assert!(format!("{err:#}").contains("topic_count must be 1..=4")); } @@ -465,7 +462,7 @@ mod tests { .to_string(); let core = DecoderCore::from_registrations( - &[OnEventRegistration { + &[OnEventRegistrationInput { index: 7, sighash: real_sighash.clone(), topic_count: 1, diff --git a/packages/cli/src/evm_hypersync_source/mod.rs b/packages/cli/src/evm_hypersync_source/mod.rs index 7799d22fe..cc9637b92 100644 --- a/packages/cli/src/evm_hypersync_source/mod.rs +++ b/packages/cli/src/evm_hypersync_source/mod.rs @@ -21,7 +21,8 @@ use decode::{DecoderCore, SelectionDecoder}; use query::{BlockField, LogField, LogFilter, LogSelection, Query, TransactionField}; use selection::{BuiltLogSelection, SelectionBuilder}; use types::{ - encode_address, map_hex_string, map_i64, Block, OnEventRegistration, ParamValue, RollbackGuard, + encode_address, map_hex_string, map_i64, Block, OnEventRegistrationInput, ParamValue, + RollbackGuard, }; static LOGGER_INIT: Once = Once::new(); @@ -55,7 +56,7 @@ impl EvmHypersyncClient { pub fn new( cfg: ClientConfig, user_agent: String, - event_registrations: Vec, + event_registrations: Vec, ) -> napi::Result { init_logger(cfg.log_level.as_deref()); @@ -692,19 +693,21 @@ mod tests { // unrouted logs are dropped. fn zero_event_decoder() -> SelectionDecoder { DecoderCore::from_registrations( - &[crate::evm_hypersync_source::types::OnEventRegistration { - index: 0, - sighash: format!("0x{}", "00".repeat(32)), - topic_count: 1, - event_name: "Zero".to_string(), - contract_name: "Zero".to_string(), - is_wildcard: true, - depends_on_addresses: false, - topic_selections: vec![], - block_fields: vec![], - transaction_fields: vec![], - params: vec![], - }], + &[ + crate::evm_hypersync_source::types::OnEventRegistrationInput { + index: 0, + sighash: format!("0x{}", "00".repeat(32)), + topic_count: 1, + event_name: "Zero".to_string(), + contract_name: "Zero".to_string(), + is_wildcard: true, + depends_on_addresses: false, + topic_selections: vec![], + block_fields: vec![], + transaction_fields: vec![], + params: vec![], + }, + ], false, ) .unwrap() diff --git a/packages/cli/src/evm_hypersync_source/selection.rs b/packages/cli/src/evm_hypersync_source/selection.rs index bbf87e717..bef7c9f11 100644 --- a/packages/cli/src/evm_hypersync_source/selection.rs +++ b/packages/cli/src/evm_hypersync_source/selection.rs @@ -167,7 +167,7 @@ pub(crate) struct SelectionBuilder { impl SelectionBuilder { pub(crate) fn from_registrations( - registrations: &[super::types::OnEventRegistration], + registrations: &[super::types::OnEventRegistrationInput], ) -> Result { let mut map = HashMap::new(); for reg in registrations { @@ -316,7 +316,7 @@ impl SelectionBuilder { #[cfg(test)] mod tests { use super::*; - use crate::evm_hypersync_source::types::OnEventRegistration; + use crate::evm_hypersync_source::types::OnEventRegistrationInput; const SIGHASH_A: &str = "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; const SIGHASH_B: &str = "0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; @@ -330,8 +330,8 @@ mod tests { is_wildcard: bool, depends_on_addresses: bool, topic1: Option>, - ) -> OnEventRegistration { - OnEventRegistration { + ) -> OnEventRegistrationInput { + OnEventRegistrationInput { index: id, sighash: sighash.to_string(), topic_count: 1, diff --git a/packages/cli/src/evm_hypersync_source/types.rs b/packages/cli/src/evm_hypersync_source/types.rs index 596e49c79..24f9a2d8b 100644 --- a/packages/cli/src/evm_hypersync_source/types.rs +++ b/packages/cli/src/evm_hypersync_source/types.rs @@ -294,7 +294,7 @@ pub struct ParamMeta { /// routing identity (`id`/`contract_name`/`is_wildcard`), and the fetch state /// queries are built from (`topic_selections`, field selections). #[napi(object)] -pub struct OnEventRegistration { +pub struct OnEventRegistrationInput { /// Chain-scoped sequential registration index; returned on every routed /// item so JS resolves the registration by array index. pub index: i64, diff --git a/packages/cli/src/evm_rpc_source/mod.rs b/packages/cli/src/evm_rpc_source/mod.rs index 0daa4b9f1..1270c8d27 100644 --- a/packages/cli/src/evm_rpc_source/mod.rs +++ b/packages/cli/src/evm_rpc_source/mod.rs @@ -12,7 +12,7 @@ mod interval; use crate::evm_hypersync_source::decode::{DecoderCore, SelectionDecoder}; use crate::evm_hypersync_source::selection::{BuiltLogSelection, SelectionBuilder}; use crate::evm_hypersync_source::types::{ - encode_address, Log as DecoderLog, OnEventRegistration, ParamValue, + encode_address, Log as DecoderLog, OnEventRegistrationInput, ParamValue, }; use classify::{is_response_too_large_message, suggested_block_interval_from_message}; use client::{parse_hex_u64, JsonRpcClient, RpcError}; @@ -154,7 +154,7 @@ impl EvmRpcClient { #[napi(factory)] pub fn new( cfg: EvmRpcClientConfig, - event_registrations: Vec, + event_registrations: Vec, checksum_addresses: bool, ) -> napi::Result { let http_req_timeout_millis = cfg From 15fe901deb4ea221bdff64229e1d0caf2a66b7cd Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 17 Jul 2026 15:36:32 +0000 Subject: [PATCH 06/10] Move Fuel and SVM selection building and routing into Rust MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Match the EVM client's shape on both remaining HyperSync sources: the whole per-(event, chain) registration set crosses the napi boundary once at client construction, and a query names only its block range, registration indexes, and current addresses. Fuel: - HyperfuelClient::new takes registrations (index, contractName, kind, logId, isWildcard); a new selection builder ports the receipt-selection grouping from HyperFuelSource.getSelectionConfig, requesting only the receipt columns the selection's kinds read. - get_event_items routes receipts by (rb | receiptType) with fan-out to every matching registration (wildcards plus the owning contract), flattens kind-specific columns onto items (normalising TransferOut's toAddress into to), validates kind-required columns and the block join via the MissingFields protocol, and drops receipts that route nowhere. LogData param decoding stays in JS against the contract ABI. SVM: - SvmHypersyncClient::new takes registrations carrying discriminator, account filters, isInner, includeLogs, field selections, and the Borsh schema pieces — absorbing the programSchemas config option. - get_event_items ports buildInstructionSelections (per-AND-group fan-out, deduplicated) and the per-selection block/transaction/log/tokenBalance field unions, then routes instructions by discriminator longest-first (d8/d4/d2/d1, program-wide fallback) with fan-out and per-registration re-application of account filters and the isInner constraint. Borsh decoding and instruction-scoped log attachment happen inline. Both sources shrink to index-mapped item building, EventRouter.res is deleted, and its duplicate/wildcard-collision validation is inlined into HandlerRegister.finishRegistration. The JS selection/routing tests move to Rust unit tests alongside the new builders. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Myo6vsGNF9ACsAbBV6PXSk --- packages/cli/src/fuel_hypersync_source/mod.rs | 406 ++++++++- .../cli/src/fuel_hypersync_source/query.rs | 102 --- .../src/fuel_hypersync_source/selection.rs | 524 +++++++++++ .../cli/src/fuel_hypersync_source/types.rs | 78 +- .../src/svm_hypersync_source/borsh_decoder.rs | 63 +- .../cli/src/svm_hypersync_source/config.rs | 4 - packages/cli/src/svm_hypersync_source/mod.rs | 614 +++++++++++-- .../cli/src/svm_hypersync_source/selection.rs | 722 ++++++++++++++++ .../cli/src/svm_hypersync_source/types.rs | 4 +- packages/envio/src/ChainState.res | 7 +- packages/envio/src/FetchState.res | 6 +- packages/envio/src/HandlerRegister.res | 45 +- packages/envio/src/sources/EventRouter.res | 165 ---- packages/envio/src/sources/HyperFuel.res | 143 +-- packages/envio/src/sources/HyperFuel.resi | 24 +- .../envio/src/sources/HyperFuelClient.res | 163 ++-- .../envio/src/sources/HyperFuelSource.res | 339 +------- .../envio/src/sources/SvmHyperSyncClient.res | 239 ++--- .../envio/src/sources/SvmHyperSyncSource.res | 500 ++--------- .../fuel_test/test/HyperFuelHeight_test.res | 2 + .../fuel_test/test/HyperFuelSource_test.res | 814 ------------------ scenarios/svm_flow_xray/src/indexer.test.ts | 2 +- .../svm_metaplex_demo/src/indexer.test.ts | 2 +- .../test/EventRouter_svm_test.res | 95 -- .../test/SourceBlockHashes_test.res | 2 +- .../test/SvmHyperSyncSource_test.res | 458 ++++------ .../test/lib_tests/EventRouter_test.res | 207 ----- 27 files changed, 2819 insertions(+), 2911 deletions(-) delete mode 100644 packages/cli/src/fuel_hypersync_source/query.rs create mode 100644 packages/cli/src/fuel_hypersync_source/selection.rs create mode 100644 packages/cli/src/svm_hypersync_source/selection.rs delete mode 100644 packages/envio/src/sources/EventRouter.res delete mode 100644 scenarios/fuel_test/test/HyperFuelSource_test.res delete mode 100644 scenarios/test_codegen/test/EventRouter_svm_test.res delete mode 100644 scenarios/test_codegen/test/lib_tests/EventRouter_test.res diff --git a/packages/cli/src/fuel_hypersync_source/mod.rs b/packages/cli/src/fuel_hypersync_source/mod.rs index 62a2c4238..e94be8e35 100644 --- a/packages/cli/src/fuel_hypersync_source/mod.rs +++ b/packages/cli/src/fuel_hypersync_source/mod.rs @@ -1,29 +1,44 @@ +use std::collections::{HashMap, HashSet}; + use anyhow::Context; +use napi::bindgen_prelude::BigInt; use napi_derive::napi; mod config; -mod query; +mod selection; mod types; use config::ClientConfig; -use query::Query; -use types::{convert_response, ConvertError, QueryResponse}; +use hyperfuel_client::net_types; +use selection::{BuiltSelection, FuelEventKind, FuelOnEventRegistrationInput, SelectionBuilder}; +use types::{convert_response, Block, ConvertError, RawReceipt}; #[napi] pub struct HyperfuelClient { inner: hyperfuel_client::Client, + selection_builder: SelectionBuilder, } #[napi] impl HyperfuelClient { #[napi(factory)] - pub fn new(cfg: ClientConfig, user_agent: String) -> napi::Result { + pub fn new( + cfg: ClientConfig, + user_agent: String, + event_registrations: Vec, + ) -> napi::Result { + let selection_builder = SelectionBuilder::from_registrations(&event_registrations) + .context("build selection builder") + .map_err(map_err)?; let client_config: hyperfuel_client::ClientConfig = cfg.try_into().context("build config").map_err(map_err)?; let inner = hyperfuel_client::Client::new_with_agent(client_config, user_agent) .context("build client") .map_err(map_err)?; - Ok(HyperfuelClient { inner }) + Ok(HyperfuelClient { + inner, + selection_builder, + }) } #[napi] @@ -37,16 +52,241 @@ impl HyperfuelClient { } #[napi] - pub async fn get_selected_data(&self, query: Query) -> napi::Result { - let query: hyperfuel_client::net_types::Query = - query.try_into().context("parse query").map_err(map_err)?; + pub async fn get_event_items( + &self, + params: EventItemsQuery, + ) -> napi::Result { + let built = self + .selection_builder + .build( + ¶ms.registration_indexes, + ¶ms.addresses_by_contract_name, + ) + .map_err(map_err)?; + + let query = build_query(¶ms, &built) + .context("build query") + .map_err(map_err)?; let res = self .inner .get_arrow(&query) .await .map_err(|e| request_err("Failed to get data from HyperFuel", e))?; - convert_response(res).map_err(convert_error_to_napi) + let raw = convert_response(res).map_err(convert_error_to_napi)?; + + let items = + route_receipts(raw.receipts, &raw.blocks, &built).map_err(convert_error_to_napi)?; + + Ok(EventItemsResponse { + archive_height: raw.archive_height, + next_block: raw.next_block, + blocks: raw.blocks, + items, + }) + } +} + +/// The whole per-query input for `get_event_items`: the block range, the +/// partition's registration selection (by index), and its current addresses. +/// Receipt selections, field selection, and routing are all derived internally +/// from the registrations passed at construction. +#[napi(object)] +pub struct EventItemsQuery { + pub from_block: i64, + /// Inclusive; `None` queries to the end of available data. + pub to_block: Option, + pub registration_indexes: Vec, + pub addresses_by_contract_name: HashMap>, +} + +/// One routed receipt. The receipt's kind-specific columns are flattened so +/// JS builds params without a tagged receipt union: LogData carries `data` +/// (decoded in JS against the contract ABI), Mint/Burn carry `val`/`subId`, +/// Transfer/TransferOut/Call carry `amount`/`assetId`/`to` — with +/// TransferOut's wallet recipient normalised into `to`. +#[napi(object)] +pub struct EventItem { + /// The registration this receipt routed to, as passed to the client + /// constructor. Receipts that route nowhere never cross the boundary. + pub on_event_registration_index: i64, + pub receipt_index: i64, + pub tx_id: String, + /// Height of the block this receipt belongs to. The block itself is + /// carried once, deduplicated, in `EventItemsResponse.blocks`. + pub block_height: i64, + pub src_address: String, + pub data: Option, + pub sub_id: Option, + pub val: Option, + pub amount: Option, + pub asset_id: Option, + pub to: Option, +} + +#[napi(object)] +pub struct EventItemsResponse { + pub archive_height: Option, + pub next_block: i64, + /// The page's blocks, one per height. Items reference them by + /// `block_height`; presence for every routed item is validated here. + pub blocks: Vec, + pub items: Vec, +} + +fn build_query( + params: &EventItemsQuery, + built: &BuiltSelection, +) -> anyhow::Result { + let from_block = u64::try_from(params.from_block).context("from_block must be >= 0")?; + let to_block = params + .to_block + // Inclusive on the boundary, exclusive on the wire. + .map(|b| u64::try_from(b + 1).context("to_block must be >= 0")) + .transpose()?; + + let mut receipt_fields = vec![ + "receipt_index", + "receipt_type", + "root_contract_id", + "tx_id", + "block_height", + ]; + if built.needs_log_data { + receipt_fields.extend(["data", "rb"]); + } + if built.needs_supply { + receipt_fields.extend(["val", "sub_id"]); + } + if built.needs_transfer || built.needs_call { + receipt_fields.extend(["amount", "asset_id", "to"]); + } + if built.needs_transfer { + receipt_fields.push("to_address"); + } + + Ok(net_types::Query { + from_block, + to_block, + receipts: built.receipt_selections.clone(), + field_selection: net_types::FieldSelection { + block: ["id", "height", "time"].map(str::to_string).into(), + receipt: receipt_fields.into_iter().map(str::to_string).collect(), + ..Default::default() + }, + ..Default::default() + }) +} + +fn push_unique(missing: &mut Vec, name: &str) { + if !missing.iter().any(|m| m == name) { + missing.push(name.to_string()); + } +} + +/// Fans each receipt out to every registration of the selection it matches +/// and flattens the kind-specific columns onto the items. A receipt without a +/// `root_contract_id` (no contract context for `srcAddress`) is dropped, as is +/// one that routes to no registration. Kind-required columns the source +/// omitted surface as `MissingFields` — never as garbage params. +fn route_receipts( + receipts: Vec, + blocks: &[Block], + built: &BuiltSelection, +) -> Result, ConvertError> { + let present_block_heights: HashSet = blocks.iter().map(|b| b.height).collect(); + let mut items = Vec::with_capacity(receipts.len()); + let mut missing: Vec = Vec::new(); + + for receipt in receipts { + let src_address = match &receipt.root_contract_id { + Some(address) => address.clone(), + None => continue, + }; + let contract_name = built + .contract_name_by_address + .get(&src_address) + .map(String::as_str); + + for reg in &built.registrations { + if !reg.matches(receipt.receipt_type, receipt.rb, contract_name) { + continue; + } + // Every routed item's block must resolve — JS reads it + // unconditionally onto the payload. + if !present_block_heights.contains(&receipt.block_height) { + push_unique(&mut missing, "block"); + } + let require_hex = |value: &Option, name: &str, missing: &mut Vec| { + if value.is_none() { + push_unique(missing, name); + } + value.clone() + }; + let require_u64 = |value: Option, name: &str, missing: &mut Vec| { + if value.is_none() { + push_unique(missing, name); + } + value.map(BigInt::from) + }; + let item = match reg.kind { + FuelEventKind::LogData => EventItem { + on_event_registration_index: reg.index, + receipt_index: receipt.receipt_index, + tx_id: receipt.tx_id.clone(), + block_height: receipt.block_height, + src_address: src_address.clone(), + data: require_hex(&receipt.data, "receipt.data", &mut missing), + sub_id: None, + val: None, + amount: None, + asset_id: None, + to: None, + }, + FuelEventKind::Mint | FuelEventKind::Burn => EventItem { + on_event_registration_index: reg.index, + receipt_index: receipt.receipt_index, + tx_id: receipt.tx_id.clone(), + block_height: receipt.block_height, + src_address: src_address.clone(), + data: None, + sub_id: require_hex(&receipt.sub_id, "receipt.subId", &mut missing), + val: require_u64(receipt.val, "receipt.val", &mut missing), + amount: None, + asset_id: None, + to: None, + }, + FuelEventKind::Transfer | FuelEventKind::Call => { + // TransferOut receipts carry the wallet recipient in + // `to_address`; everything else uses `to`. + let (recipient, recipient_name) = + if receipt.receipt_type == selection::RECEIPT_TRANSFER_OUT { + (&receipt.to_address, "receipt.toAddress") + } else { + (&receipt.to, "receipt.to") + }; + EventItem { + on_event_registration_index: reg.index, + receipt_index: receipt.receipt_index, + tx_id: receipt.tx_id.clone(), + block_height: receipt.block_height, + src_address: src_address.clone(), + data: None, + sub_id: None, + val: None, + amount: require_u64(receipt.amount, "receipt.amount", &mut missing), + asset_id: require_hex(&receipt.asset_id, "receipt.assetId", &mut missing), + to: require_hex(recipient, recipient_name, &mut missing), + } + } + }; + items.push(item); + } + } + + if !missing.is_empty() { + return Err(ConvertError::MissingFields(missing)); } + Ok(items) } /// The client embeds a `{:?}` debug dump in its error message; keep only the @@ -93,4 +333,152 @@ mod tests { assert_eq!(parsed["fields"][0], "receipt.txId"); assert_eq!(parsed["fields"][1], "block.time"); } + + const ADDR: &str = "0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcde1"; + + fn reg_input( + index: i64, + contract_name: &str, + kind: FuelEventKind, + is_wildcard: bool, + log_id: Option<&str>, + ) -> FuelOnEventRegistrationInput { + FuelOnEventRegistrationInput { + index, + event_name: format!("E{index}"), + contract_name: contract_name.to_string(), + is_wildcard, + kind, + log_id: log_id.map(str::to_string), + } + } + + fn raw_receipt(receipt_type: u8) -> RawReceipt { + RawReceipt { + receipt_index: 3, + root_contract_id: Some(ADDR.to_string()), + tx_id: "0xtx".to_string(), + block_height: 42, + receipt_type, + data: Some("0x01".to_string()), + rb: Some(7), + val: Some(100), + sub_id: Some("0xsub".to_string()), + amount: Some(5), + asset_id: Some("0xasset".to_string()), + to: Some("0xto".to_string()), + to_address: Some("0xwallet".to_string()), + } + } + + fn block_42() -> Block { + Block { + id: "0xblock".to_string(), + height: 42, + time: 1745179292, + } + } + + fn build( + registrations: &[FuelOnEventRegistrationInput], + indexes: &[i64], + addresses: &[(&str, &[&str])], + ) -> BuiltSelection { + let addresses: HashMap> = addresses + .iter() + .map(|(name, addrs)| { + ( + name.to_string(), + addrs.iter().map(|a| a.to_string()).collect(), + ) + }) + .collect(); + SelectionBuilder::from_registrations(registrations) + .unwrap() + .build(indexes, &addresses) + .unwrap() + } + + #[test] + fn routes_transfer_out_recipient_into_to() { + let built = build( + &[reg_input(0, "C", FuelEventKind::Transfer, true, None)], + &[0], + &[], + ); + let items = route_receipts(vec![raw_receipt(8)], &[block_42()], &built).unwrap(); + assert_eq!( + items + .iter() + .map(|i| (i.on_event_registration_index, i.to.as_deref())) + .collect::>(), + vec![(0, Some("0xwallet"))] + ); + } + + #[test] + fn fans_out_to_wildcard_and_owned_registration() { + let built = build( + &[ + reg_input(0, "Owned", FuelEventKind::Mint, false, None), + reg_input(1, "W", FuelEventKind::Mint, true, None), + ], + &[0, 1], + &[("Owned", &[ADDR])], + ); + let items = route_receipts(vec![raw_receipt(11)], &[block_42()], &built).unwrap(); + assert_eq!( + items + .iter() + .map(|i| i.on_event_registration_index) + .collect::>(), + vec![0, 1] + ); + } + + #[test] + fn unrouted_receipt_is_dropped() { + let built = build( + &[reg_input(0, "C", FuelEventKind::LogData, true, Some("9"))], + &[0], + &[], + ); + // rb 7 doesn't match the registration's logId 9. + let items = route_receipts(vec![raw_receipt(6)], &[block_42()], &built).unwrap(); + assert!(items.is_empty()); + } + + #[test] + fn missing_kind_required_column_is_typed_error() { + let built = build( + &[reg_input(0, "C", FuelEventKind::Mint, true, None)], + &[0], + &[], + ); + let mut receipt = raw_receipt(11); + receipt.val = None; + match route_receipts(vec![receipt], &[block_42()], &built) { + Err(ConvertError::MissingFields(fields)) => { + assert_eq!(fields, vec!["receipt.val".to_string()]) + } + Err(ConvertError::Other(e)) => panic!("unexpected ConvertError::Other: {e:?}"), + Ok(_) => panic!("expected MissingFields, got Ok"), + } + } + + #[test] + fn missing_block_for_routed_receipt_is_typed_error() { + let built = build( + &[reg_input(0, "C", FuelEventKind::Mint, true, None)], + &[0], + &[], + ); + match route_receipts(vec![raw_receipt(11)], &[], &built) { + Err(ConvertError::MissingFields(fields)) => { + assert_eq!(fields, vec!["block".to_string()]) + } + Err(ConvertError::Other(e)) => panic!("unexpected ConvertError::Other: {e:?}"), + Ok(_) => panic!("expected MissingFields, got Ok"), + } + } } diff --git a/packages/cli/src/fuel_hypersync_source/query.rs b/packages/cli/src/fuel_hypersync_source/query.rs deleted file mode 100644 index 997be45c2..000000000 --- a/packages/cli/src/fuel_hypersync_source/query.rs +++ /dev/null @@ -1,102 +0,0 @@ -use anyhow::{anyhow, Context, Result}; -use hyperfuel_client::format::{Hash, Hex}; -use hyperfuel_client::net_types; -use napi::bindgen_prelude::BigInt; -use napi_derive::napi; - -/// Query for retrieving Fuel receipts and their blocks. -#[napi(object)] -#[derive(Default)] -pub struct Query { - pub from_block: i64, - #[napi(js_name = "toBlock")] - pub to_block_exclusive: Option, - pub receipts: Option>, - pub field_selection: FieldSelection, -} - -#[napi(object)] -#[derive(Default)] -pub struct ReceiptSelection { - pub root_contract_id: Option>, - pub receipt_type: Option>, - pub rb: Option>, - pub tx_status: Option>, -} - -#[napi(object)] -#[derive(Default)] -pub struct FieldSelection { - pub block: Option>, - pub receipt: Option>, -} - -fn parse_hashes(v: Option>) -> Result> { - v.unwrap_or_default() - .into_iter() - .map(|s| Hash::decode_hex(&s).map_err(|e| anyhow!("failed to parse hash {s}: {e:?}"))) - .collect() -} - -fn bigints_to_u64(v: Option>) -> Result> { - v.unwrap_or_default() - .into_iter() - .map(|b| { - let (sign_bit, value, lossless) = b.get_u64(); - if sign_bit || !lossless { - anyhow::bail!("rb filter value must be an unsigned 64-bit integer"); - } - Ok(value) - }) - .collect() -} - -impl TryFrom for net_types::ReceiptSelection { - type Error = anyhow::Error; - - fn try_from(s: ReceiptSelection) -> Result { - Ok(net_types::ReceiptSelection { - root_contract_id: parse_hashes(s.root_contract_id)?, - receipt_type: s.receipt_type.unwrap_or_default(), - rb: bigints_to_u64(s.rb).context("parse rb filter")?, - tx_status: s.tx_status.unwrap_or_default(), - ..Default::default() - }) - } -} - -impl From for net_types::FieldSelection { - fn from(f: FieldSelection) -> Self { - net_types::FieldSelection { - block: f.block.unwrap_or_default().into_iter().collect(), - receipt: f.receipt.unwrap_or_default().into_iter().collect(), - ..Default::default() - } - } -} - -impl TryFrom for net_types::Query { - type Error = anyhow::Error; - - fn try_from(q: Query) -> Result { - let from_block = u64::try_from(q.from_block).context("from_block must be >= 0")?; - let to_block = q - .to_block_exclusive - .map(|b| u64::try_from(b).context("toBlock must be >= 0")) - .transpose()?; - let receipts = q - .receipts - .unwrap_or_default() - .into_iter() - .map(TryInto::try_into) - .collect::>>()?; - - Ok(net_types::Query { - from_block, - to_block, - receipts, - field_selection: q.field_selection.into(), - ..Default::default() - }) - } -} diff --git a/packages/cli/src/fuel_hypersync_source/selection.rs b/packages/cli/src/fuel_hypersync_source/selection.rs new file mode 100644 index 000000000..e0f9fa888 --- /dev/null +++ b/packages/cli/src/fuel_hypersync_source/selection.rs @@ -0,0 +1,524 @@ +use std::collections::HashMap; + +use anyhow::{anyhow, Context, Result}; +use hyperfuel_client::format::{Hash, Hex}; +use hyperfuel_client::net_types; +use napi_derive::napi; + +// FuelVM receipt type codes (see FuelSDK.receiptType on the JS side). +const RECEIPT_CALL: u8 = 0; +const RECEIPT_LOG_DATA: u8 = 6; +const RECEIPT_TRANSFER: u8 = 7; +pub(crate) const RECEIPT_TRANSFER_OUT: u8 = 8; +const RECEIPT_MINT: u8 = 11; +const RECEIPT_BURN: u8 = 12; + +// Only receipts from successful transactions are indexed. +const TX_STATUS_SUCCESS: u8 = 1; + +/// Receipt kind of a registration, mirroring `Internal.fuelEventKind`. +/// `Transfer` covers both `Transfer` (to a contract) and `TransferOut` +/// (to a wallet address) receipts. +#[napi(string_enum)] +#[derive(Clone, Copy, PartialEq, Eq)] +pub enum FuelEventKind { + LogData, + Mint, + Burn, + Transfer, + Call, +} + +impl FuelEventKind { + fn receipt_types(&self) -> &'static [u8] { + match self { + FuelEventKind::LogData => &[RECEIPT_LOG_DATA], + FuelEventKind::Mint => &[RECEIPT_MINT], + FuelEventKind::Burn => &[RECEIPT_BURN], + FuelEventKind::Transfer => &[RECEIPT_TRANSFER, RECEIPT_TRANSFER_OUT], + FuelEventKind::Call => &[RECEIPT_CALL], + } + } +} + +/// The full per-(event, chain) registration crossing the boundary once at +/// client construction: routing identity plus the receipt-selection state +/// queries are built from. +#[napi(object)] +pub struct FuelOnEventRegistrationInput { + /// Chain-scoped sequential registration index; returned on every routed + /// item so JS resolves the registration by array index. + pub index: i64, + pub event_name: String, + pub contract_name: String, + pub is_wildcard: bool, + pub kind: FuelEventKind, + /// The LogData `rb` value as a decimal string (u64). Required for + /// `LogData`, ignored otherwise. + pub log_id: Option, +} + +pub(crate) struct Registration { + pub index: i64, + pub contract_name: String, + pub is_wildcard: bool, + pub kind: FuelEventKind, + /// Parsed `log_id`; `Some` exactly for LogData registrations. + pub rb: Option, +} + +impl Registration { + /// Whether a receipt belongs to this registration: matching receipt type + /// (and `rb` for LogData), emitted by an allowed contract — wildcard + /// registrations accept any contract, contract-bound ones only their own. + pub(crate) fn matches( + &self, + receipt_type: u8, + rb: Option, + contract_name: Option<&str>, + ) -> bool { + let kind_matches = match self.kind { + FuelEventKind::LogData => receipt_type == RECEIPT_LOG_DATA && rb == self.rb, + kind => kind.receipt_types().contains(&receipt_type), + }; + kind_matches && (self.is_wildcard || contract_name == Some(self.contract_name.as_str())) + } +} + +/// Everything a source query needs that depends on the partition's selection +/// and current addresses. +pub(crate) struct BuiltSelection { + pub receipt_selections: Vec, + /// Inverted address index for routing (1:1 — each address belongs to one + /// contract). + pub contract_name_by_address: HashMap, + /// The selection's registrations sorted by index, for routing. + pub registrations: Vec>, + /// Which receipt columns the selection's kinds read, so the field + /// selection only requests what item building needs. + pub needs_log_data: bool, + pub needs_supply: bool, + pub needs_transfer: bool, + pub needs_call: bool, +} + +fn parse_root_contract_ids(addresses: &[String]) -> Result> { + addresses + .iter() + .map(|a| Hash::decode_hex(a).map_err(|e| anyhow!("failed to parse contract id {a}: {e:?}"))) + .collect() +} + +fn push_unique(values: &mut Vec, value: T) { + if !values.contains(&value) { + values.push(value); + } +} + +/// Builds per-query receipt selections from the registrations passed at client +/// construction. Registrations are keyed by their chain-scoped sequential +/// index; a query names the indexes of its partition's selection plus the +/// partition's current addresses per contract. +pub(crate) struct SelectionBuilder { + registrations: HashMap>, +} + +impl SelectionBuilder { + pub(crate) fn from_registrations( + registrations: &[FuelOnEventRegistrationInput], + ) -> Result { + let mut map = HashMap::new(); + for reg in registrations { + let rb = match reg.kind { + FuelEventKind::LogData => { + let log_id = reg.log_id.as_ref().with_context(|| { + format!("LogData registration {} is missing logId", reg.event_name) + })?; + Some(log_id.parse::().with_context(|| { + format!("parse logId {} for event {}", log_id, reg.event_name) + })?) + } + FuelEventKind::Call => { + anyhow::ensure!( + reg.is_wildcard, + "Call receipt indexing currently supported only in wildcard mode" + ); + None + } + _ => None, + }; + let parsed = Registration { + index: reg.index, + contract_name: reg.contract_name.clone(), + is_wildcard: reg.is_wildcard, + kind: reg.kind, + rb, + }; + anyhow::ensure!( + map.insert(reg.index, std::sync::Arc::new(parsed)).is_none(), + "Duplicate registration index {} for event {}", + reg.index, + reg.event_name, + ); + } + Ok(Self { registrations: map }) + } + + pub(crate) fn build( + &self, + registration_indexes: &[i64], + addresses_by_contract_name: &HashMap>, + ) -> Result { + // Wildcard registrations pool into address-free selections; the rest + // group per contract so one contract's query can't fetch a sibling's + // receipts. + let mut wildcard_receipt_types: Vec = Vec::new(); + let mut wildcard_rbs: Vec = Vec::new(); + let mut receipt_types_by_contract: HashMap<&str, Vec> = HashMap::new(); + let mut rbs_by_contract: HashMap<&str, Vec> = HashMap::new(); + // First-appearance order of address-bound contracts, so the built + // query is stable across calls. + let mut ordered_contracts: Vec<&str> = Vec::new(); + + let mut registrations = Vec::with_capacity(registration_indexes.len()); + let mut needs_log_data = false; + let mut needs_supply = false; + let mut needs_transfer = false; + let mut needs_call = false; + + for id in registration_indexes { + let reg = self + .registrations + .get(id) + .with_context(|| format!("Unknown registration index {id} in query selection"))?; + registrations.push(reg.clone()); + match reg.kind { + FuelEventKind::LogData => needs_log_data = true, + FuelEventKind::Mint | FuelEventKind::Burn => needs_supply = true, + FuelEventKind::Transfer => needs_transfer = true, + FuelEventKind::Call => needs_call = true, + } + match (reg.kind, reg.is_wildcard) { + (FuelEventKind::LogData, true) => { + push_unique(&mut wildcard_rbs, reg.rb.unwrap_or_default()) + } + (FuelEventKind::LogData, false) => push_unique( + rbs_by_contract + .entry(reg.contract_name.as_str()) + .or_default(), + reg.rb.unwrap_or_default(), + ), + (kind, true) => { + for &receipt_type in kind.receipt_types() { + push_unique(&mut wildcard_receipt_types, receipt_type); + } + } + (kind, false) => { + let bucket = receipt_types_by_contract + .entry(reg.contract_name.as_str()) + .or_default(); + for &receipt_type in kind.receipt_types() { + push_unique(bucket, receipt_type); + } + } + } + if !reg.is_wildcard && !ordered_contracts.contains(®.contract_name.as_str()) { + ordered_contracts.push(reg.contract_name.as_str()); + } + } + // Deterministic item order per receipt, independent of the selection's + // index order. + registrations.sort_unstable_by_key(|reg| reg.index); + + let mut receipt_selections: Vec = Vec::new(); + if !wildcard_receipt_types.is_empty() { + receipt_selections.push(net_types::ReceiptSelection { + receipt_type: wildcard_receipt_types, + tx_status: vec![TX_STATUS_SUCCESS], + ..Default::default() + }); + } + if !wildcard_rbs.is_empty() { + receipt_selections.push(net_types::ReceiptSelection { + receipt_type: vec![RECEIPT_LOG_DATA], + rb: wildcard_rbs, + tx_status: vec![TX_STATUS_SUCCESS], + ..Default::default() + }); + } + for contract_name in ordered_contracts { + let addresses = match addresses_by_contract_name.get(contract_name) { + None => continue, + Some(addresses) if addresses.is_empty() => continue, + Some(addresses) => parse_root_contract_ids(addresses)?, + }; + if let Some(receipt_types) = receipt_types_by_contract.remove(contract_name) { + receipt_selections.push(net_types::ReceiptSelection { + root_contract_id: addresses.clone(), + receipt_type: receipt_types, + tx_status: vec![TX_STATUS_SUCCESS], + ..Default::default() + }); + } + if let Some(rbs) = rbs_by_contract.remove(contract_name) { + receipt_selections.push(net_types::ReceiptSelection { + root_contract_id: addresses, + receipt_type: vec![RECEIPT_LOG_DATA], + rb: rbs, + tx_status: vec![TX_STATUS_SUCCESS], + ..Default::default() + }); + } + } + + // Routing needs the whole partition index, including contracts with no + // selection in this query (their receipts still fall back to wildcards). + let mut contract_name_by_address = HashMap::new(); + for (contract_name, addresses) in addresses_by_contract_name { + for address in addresses { + contract_name_by_address.insert(address.clone(), contract_name.clone()); + } + } + + Ok(BuiltSelection { + receipt_selections, + contract_name_by_address, + registrations, + needs_log_data, + needs_supply, + needs_transfer, + needs_call, + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const ADDR_1: &str = "0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcde1"; + const ADDR_2: &str = "0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcde2"; + const ADDR_3: &str = "0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcde3"; + + fn reg( + index: i64, + contract_name: &str, + kind: FuelEventKind, + is_wildcard: bool, + log_id: Option<&str>, + ) -> FuelOnEventRegistrationInput { + FuelOnEventRegistrationInput { + index, + event_name: format!("E{index}"), + contract_name: contract_name.to_string(), + is_wildcard, + kind, + log_id: log_id.map(str::to_string), + } + } + + fn addresses(entries: &[(&str, &[&str])]) -> HashMap> { + entries + .iter() + .map(|(name, addrs)| { + ( + name.to_string(), + addrs.iter().map(|a| a.to_string()).collect(), + ) + }) + .collect() + } + + fn selection_view(s: &net_types::ReceiptSelection) -> (Vec, Vec, Vec) { + ( + s.root_contract_id.iter().map(Hex::encode_hex).collect(), + s.receipt_type.clone(), + s.rb.clone(), + ) + } + + #[test] + fn groups_receipt_types_and_rbs_per_contract() { + let builder = SelectionBuilder::from_registrations(&[ + reg(0, "C1", FuelEventKind::LogData, false, Some("1")), + reg(1, "C1", FuelEventKind::Mint, false, None), + reg(2, "C1", FuelEventKind::Burn, false, None), + reg(3, "C1", FuelEventKind::Transfer, false, None), + reg(4, "C2", FuelEventKind::LogData, false, Some("3")), + reg(5, "C2", FuelEventKind::Burn, false, None), + ]) + .unwrap(); + let built = builder + .build( + &[0, 1, 2, 3, 4, 5], + &addresses(&[("C1", &[ADDR_1, ADDR_2]), ("C2", &[ADDR_3])]), + ) + .unwrap(); + assert_eq!( + built + .receipt_selections + .iter() + .map(selection_view) + .collect::>(), + vec![ + ( + vec![ADDR_1.to_string(), ADDR_2.to_string()], + vec![ + RECEIPT_MINT, + RECEIPT_BURN, + RECEIPT_TRANSFER, + RECEIPT_TRANSFER_OUT + ], + vec![], + ), + ( + vec![ADDR_1.to_string(), ADDR_2.to_string()], + vec![RECEIPT_LOG_DATA], + vec![1], + ), + (vec![ADDR_3.to_string()], vec![RECEIPT_BURN], vec![]), + (vec![ADDR_3.to_string()], vec![RECEIPT_LOG_DATA], vec![3]), + ] + ); + assert_eq!( + built.contract_name_by_address.get(ADDR_3), + Some(&"C2".to_string()) + ); + } + + #[test] + fn wildcard_selections_stay_address_free() { + let builder = SelectionBuilder::from_registrations(&[ + reg(0, "C1", FuelEventKind::Call, true, None), + reg(1, "C1", FuelEventKind::LogData, true, Some("2")), + reg(2, "C2", FuelEventKind::LogData, true, Some("3")), + ]) + .unwrap(); + let built = builder.build(&[0, 1, 2], &HashMap::new()).unwrap(); + assert_eq!( + built + .receipt_selections + .iter() + .map(selection_view) + .collect::>(), + vec![ + (vec![], vec![RECEIPT_CALL], vec![]), + (vec![], vec![RECEIPT_LOG_DATA], vec![2, 3]), + ] + ); + } + + #[test] + fn contract_without_addresses_is_skipped() { + let builder = + SelectionBuilder::from_registrations(&[reg(0, "C1", FuelEventKind::Mint, false, None)]) + .unwrap(); + let built = builder.build(&[0], &addresses(&[("C1", &[])])).unwrap(); + assert!(built.receipt_selections.is_empty()); + } + + #[test] + fn selection_subset_excludes_other_registrations() { + let builder = SelectionBuilder::from_registrations(&[ + reg(0, "C1", FuelEventKind::Mint, false, None), + reg(1, "C1", FuelEventKind::Burn, false, None), + ]) + .unwrap(); + let built = builder + .build(&[1], &addresses(&[("C1", &[ADDR_1])])) + .unwrap(); + assert_eq!( + built + .receipt_selections + .iter() + .map(selection_view) + .collect::>(), + vec![(vec![ADDR_1.to_string()], vec![RECEIPT_BURN], vec![])] + ); + } + + #[test] + fn non_wildcard_call_errors() { + let err = + SelectionBuilder::from_registrations(&[reg(0, "C1", FuelEventKind::Call, false, None)]) + .err() + .unwrap(); + assert!(format!("{err:#}") + .contains("Call receipt indexing currently supported only in wildcard mode")); + } + + #[test] + fn log_data_without_log_id_errors() { + let err = SelectionBuilder::from_registrations(&[reg( + 0, + "C1", + FuelEventKind::LogData, + false, + None, + )]) + .err() + .unwrap(); + assert!(format!("{err:#}").contains("missing logId")); + } + + #[test] + fn routing_fans_out_to_wildcards_and_owned_contract() { + let builder = SelectionBuilder::from_registrations(&[ + reg(0, "Owned", FuelEventKind::Mint, false, None), + reg(1, "W", FuelEventKind::Mint, true, None), + reg(2, "Other", FuelEventKind::Mint, false, None), + ]) + .unwrap(); + let built = builder + .build(&[0, 1, 2], &addresses(&[("Owned", &[ADDR_1])])) + .unwrap(); + let route = |contract_name: Option<&str>| -> Vec { + built + .registrations + .iter() + .filter(|reg| reg.matches(RECEIPT_MINT, None, contract_name)) + .map(|reg| reg.index) + .collect() + }; + assert_eq!((route(Some("Owned")), route(None)), (vec![0, 1], vec![1])); + } + + #[test] + fn transfer_matches_both_transfer_and_transfer_out() { + let builder = SelectionBuilder::from_registrations(&[reg( + 0, + "C", + FuelEventKind::Transfer, + true, + None, + )]) + .unwrap(); + let built = builder.build(&[0], &HashMap::new()).unwrap(); + let reg = &built.registrations[0]; + assert_eq!( + ( + reg.matches(RECEIPT_TRANSFER, None, None), + reg.matches(RECEIPT_TRANSFER_OUT, None, None), + reg.matches(RECEIPT_MINT, None, None), + ), + (true, true, false) + ); + } + + #[test] + fn log_data_routes_by_rb() { + let builder = SelectionBuilder::from_registrations(&[ + reg(0, "C", FuelEventKind::LogData, true, Some("1")), + reg(1, "C", FuelEventKind::LogData, true, Some("2")), + ]) + .unwrap(); + let built = builder.build(&[0, 1], &HashMap::new()).unwrap(); + let routed: Vec = built + .registrations + .iter() + .filter(|reg| reg.matches(RECEIPT_LOG_DATA, Some(2), None)) + .map(|reg| reg.index) + .collect(); + assert_eq!(routed, vec![1]); + } +} diff --git a/packages/cli/src/fuel_hypersync_source/types.rs b/packages/cli/src/fuel_hypersync_source/types.rs index 9ad718579..e81df6529 100644 --- a/packages/cli/src/fuel_hypersync_source/types.rs +++ b/packages/cli/src/fuel_hypersync_source/types.rs @@ -1,45 +1,40 @@ use anyhow::{Context, Result}; use hyperfuel_client::{ArrowBatch, ArrowResponse}; -use napi::bindgen_prelude::BigInt; use napi_derive::napi; use polars_arrow::array::{BinaryArray, Int64Array, StaticArray, UInt64Array, UInt8Array}; #[napi(object)] -pub struct QueryResponse { - pub archive_height: Option, - pub next_block: i64, - pub total_execution_time: i64, - pub data: QueryResponseData, -} - -#[napi(object)] -pub struct QueryResponseData { - pub receipts: Vec, - pub blocks: Vec, +#[derive(Clone)] +pub struct Block { + pub id: String, + pub height: i64, + pub time: i64, } -#[napi(object)] -pub struct Receipt { +/// The page's receipt rows, decoded from Arrow but not yet routed — `get_event_items` +/// turns each one into zero or more `EventItem`s. Numeric u64 columns stay raw +/// here; they become JS BigInts only on the routed items. +pub(crate) struct RawReceipt { pub receipt_index: i64, pub root_contract_id: Option, pub tx_id: String, pub block_height: i64, - pub receipt_type: i64, + pub receipt_type: u8, pub data: Option, - pub rb: Option, - pub val: Option, + pub rb: Option, + pub val: Option, pub sub_id: Option, - pub amount: Option, + pub amount: Option, pub asset_id: Option, pub to: Option, pub to_address: Option, } -#[napi(object)] -pub struct Block { - pub id: String, - pub height: i64, - pub time: i64, +pub(crate) struct RawResponse { + pub archive_height: Option, + pub next_block: i64, + pub receipts: Vec, + pub blocks: Vec, } /// `MissingFields` is the shape the JS side recognizes (via the JSON payload @@ -69,17 +64,13 @@ fn u64_at(arr: &Option<&UInt64Array>, idx: usize) -> Option { arr.and_then(|a| a.get(idx)) } -fn bigint_at(arr: &Option<&UInt64Array>, idx: usize) -> Option { - u64_at(arr, idx).map(BigInt::from) -} - fn i64_field(arr: &Option<&UInt64Array>, idx: usize, name: &str) -> Result> { u64_at(arr, idx) .map(|v| v.try_into().with_context(|| format!("{name} overflow"))) .transpose() } -fn receipts_from_arrow(batches: &[ArrowBatch]) -> Result, ConvertError> { +pub(crate) fn receipts_from_arrow(batches: &[ArrowBatch]) -> Result, ConvertError> { let mut out = Vec::new(); for batch in batches { let receipt_index = batch.column::("receipt_index").ok(); @@ -120,17 +111,17 @@ fn receipts_from_arrow(batches: &[ArrowBatch]) -> Result, ConvertEr return Err(ConvertError::MissingFields(missing)); } - out.push(Receipt { + out.push(RawReceipt { receipt_index: receipt_index_val.unwrap(), root_contract_id: hex_at(&root_contract_id, idx), tx_id: tx_id_val.unwrap(), block_height: block_height_val.unwrap(), - receipt_type: receipt_type_val.unwrap() as i64, + receipt_type: receipt_type_val.unwrap(), data: hex_at(&data, idx), - rb: bigint_at(&rb, idx), - val: bigint_at(&val, idx), + rb: u64_at(&rb, idx), + val: u64_at(&val, idx), sub_id: hex_at(&sub_id, idx), - amount: bigint_at(&amount, idx), + amount: u64_at(&amount, idx), asset_id: hex_at(&asset_id, idx), to: hex_at(&to, idx), to_address: hex_at(&to_address, idx), @@ -140,7 +131,7 @@ fn receipts_from_arrow(batches: &[ArrowBatch]) -> Result, ConvertEr Ok(out) } -fn blocks_from_arrow(batches: &[ArrowBatch]) -> Result, ConvertError> { +pub(crate) fn blocks_from_arrow(batches: &[ArrowBatch]) -> Result, ConvertError> { let mut out = Vec::new(); for batch in batches { let id = batch.column::>("id").ok(); @@ -175,8 +166,8 @@ fn blocks_from_arrow(batches: &[ArrowBatch]) -> Result, ConvertError> Ok(out) } -pub(crate) fn convert_response(res: ArrowResponse) -> Result { - Ok(QueryResponse { +pub(crate) fn convert_response(res: ArrowResponse) -> Result { + Ok(RawResponse { archive_height: res .archive_height .map(i64::try_from) @@ -188,15 +179,8 @@ pub(crate) fn convert_response(res: ArrowResponse) -> Result, - pub instructions: Vec, -} - -#[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct InstructionSchemaJson { +/// One instruction's schema piece, assembled from a registration's +/// `accounts`/`args` at client creation. +#[derive(Debug)] +pub(crate) struct InstructionSchemaInput { pub name: String, /// Hex (`0x`-prefixed or bare) — the bytes the dispatcher matches against /// the head of `instruction.data`. pub discriminator: String, - #[serde(default)] pub accounts: Vec, - #[serde(default)] pub args: Vec, } -/// Parse a JSON descriptor into an upstream `ProgramSchema`. The Solana client -/// calls this once per configured program at creation time; the resulting -/// schema's `program_id` is the key the client decodes against. -pub(crate) fn parse_program_schema(descriptor_json: &str) -> Result { - let descriptor: ProgramSchemaJson = - serde_json::from_str(descriptor_json).context("parse program schema descriptor")?; - build_program_schema(descriptor).context("build program schema") -} - /// Decode a raw instruction against a resolved schema. Called inline by the -/// Solana client's `get`, so decoded instructions ride back on the query -/// response instead of crossing the napi boundary one instruction at a time. +/// Solana client's `get_event_items`, so decoded instructions ride back on the +/// query response instead of crossing the napi boundary one instruction at a +/// time. /// /// POC policy: any decode failure (unknown discriminator, account-count /// mismatch, trailing bytes, unresolved type) yields `None` so the indexer @@ -112,9 +90,12 @@ impl From for DecodedInstructionJson { } } -fn build_program_schema(d: ProgramSchemaJson) -> Result { - let defined_types: BTreeMap = d - .defined_types +pub(crate) fn build_program_schema( + program_id: String, + defined_types: &BTreeMap, + instruction_inputs: Vec, +) -> Result { + let defined_types: BTreeMap = defined_types .iter() .map(|(name, ty)| { arg_type_to_field_type(ty) @@ -124,7 +105,7 @@ fn build_program_schema(d: ProgramSchemaJson) -> Result { .collect::>()?; let mut instructions: BTreeMap, UpstreamIxSchema> = BTreeMap::new(); - for ix in d.instructions { + for ix in instruction_inputs { let discriminator = hex_to_bytes(&ix.discriminator) .with_context(|| format!("instruction '{}' discriminator", ix.name))?; let accounts = ix @@ -165,7 +146,7 @@ fn build_program_schema(d: ProgramSchemaJson) -> Result { } Ok(UpstreamSchema::build( - d.program_id, + program_id, instructions, defined_types, )) diff --git a/packages/cli/src/svm_hypersync_source/config.rs b/packages/cli/src/svm_hypersync_source/config.rs index e233ec720..726036ee6 100644 --- a/packages/cli/src/svm_hypersync_source/config.rs +++ b/packages/cli/src/svm_hypersync_source/config.rs @@ -12,10 +12,6 @@ pub struct SvmClientConfig { pub max_num_retries: Option, pub retry_base_ms: Option, pub retry_ceiling_ms: Option, - /// Per-program Borsh schema descriptors (JSON, one per program). Built into - /// decoders at client creation; the client decodes each returned - /// instruction whose `program_id` matches one of them, attaching `decoded`. - pub program_schemas: Option>, } impl From for hypersync_client_solana::config::ClientConfig { diff --git a/packages/cli/src/svm_hypersync_source/mod.rs b/packages/cli/src/svm_hypersync_source/mod.rs index 21fe72796..6c817a8e3 100644 --- a/packages/cli/src/svm_hypersync_source/mod.rs +++ b/packages/cli/src/svm_hypersync_source/mod.rs @@ -1,12 +1,13 @@ -use std::collections::HashMap; +use std::collections::{BTreeMap, HashMap}; use std::sync::Arc; -use anyhow::Context; +use anyhow::{Context, Result}; use napi_derive::napi; mod borsh_decoder; mod config; mod query; +mod selection; pub(crate) mod types; /// Local hex helpers. Lives here so `decoder.rs` can pull them via @@ -31,12 +32,17 @@ pub(crate) mod mod_helpers { use hypersync_client_solana::decode::ProgramSchema as UpstreamSchema; use hypersync_client_solana::simple_types as simple; +use hypersync_solana_net_types::field_selection::SolanaFieldSelection; +use hypersync_solana_net_types::query::SolanaQuery; use crate::block_store::BlockStore; +use crate::config_parsing::human_config::svm::{ArgDef, ArgType}; use crate::transaction_store::TransactionStore; +use borsh_decoder::{DecodedInstructionJson, InstructionSchemaInput}; use config::SvmClientConfig; use query::SvmQuery; -use types::QueryResponse; +use selection::{route_instruction, SelectionBuilder, SvmOnEventRegistrationInput}; +use types::{opt_hex, to_hex, QueryResponse}; /// Move the response's transactions and token balances into a /// `TransactionStore`, keyed by `(slot, transactionIndex)`. Kept in Rust so @@ -54,20 +60,102 @@ fn build_svm_store( store } +/// Per-program Borsh schemas from the registrations that carry schema pieces +/// (`accounts`/`args`), keyed by base58 program id. A registration without a +/// schema (or without a discriminator to dispatch on) contributes nothing; +/// the program's `definedTypes` come from its first schema-carrying +/// registration — every registration of a program duplicates the same +/// registry. +fn build_schemas( + registrations: &[SvmOnEventRegistrationInput], +) -> Result> { + struct ProgramParts { + defined_types: BTreeMap, + instructions: Vec, + } + let mut parts_by_program: Vec<(String, ProgramParts)> = Vec::new(); + + for reg in registrations { + if reg.program_id.is_empty() { + continue; + } + let has_schema = !reg.accounts.is_empty() || reg.args_json.is_some(); + let discriminator = reg.discriminator.as_deref().unwrap_or_default(); + if !has_schema || discriminator.is_empty() { + continue; + } + let args: Vec = reg + .args_json + .as_deref() + .map(|json| { + serde_json::from_str(json) + .with_context(|| format!("parse args schema for {}", reg.instruction_name)) + }) + .transpose()? + .unwrap_or_default(); + let instruction = InstructionSchemaInput { + name: reg.instruction_name.clone(), + discriminator: discriminator.to_string(), + accounts: reg.accounts.clone(), + args, + }; + match parts_by_program + .iter_mut() + .find(|(program_id, _)| program_id == ®.program_id) + { + Some((_, parts)) => parts.instructions.push(instruction), + None => { + let defined_types: BTreeMap = reg + .defined_types_json + .as_deref() + .map(|json| { + serde_json::from_str(json).with_context(|| { + format!("parse defined types for {}", reg.instruction_name) + }) + }) + .transpose()? + .unwrap_or_default(); + parts_by_program.push(( + reg.program_id.clone(), + ProgramParts { + defined_types, + instructions: vec![instruction], + }, + )); + } + } + } + + parts_by_program + .into_iter() + .map(|(program_id, parts)| { + let schema = borsh_decoder::build_program_schema( + program_id.clone(), + &parts.defined_types, + parts.instructions, + ) + .with_context(|| format!("build program schema for {program_id}"))?; + Ok((program_id, schema)) + }) + .collect() +} + #[napi] pub struct SvmHypersyncClient { inner: Arc, - /// Per-program Borsh schemas, keyed by base58 program id, built once from - /// the config at client creation. `get` decodes matching instructions - /// against these. schemas: HashMap, + selection_builder: SelectionBuilder, } #[napi] impl SvmHypersyncClient { #[napi(constructor)] - pub fn new(cfg: SvmClientConfig, user_agent: String) -> napi::Result { - Self::from_config(cfg, user_agent) + pub fn new( + cfg: SvmClientConfig, + user_agent: String, + event_registrations: Vec, + ) -> napi::Result { + Self::from_config(cfg, user_agent, event_registrations) } /// Factory taking a custom user agent, mirroring EVM's `new_with_agent`. @@ -77,18 +165,21 @@ impl SvmHypersyncClient { pub fn from_config( cfg: SvmClientConfig, user_agent: String, + event_registrations: Vec, ) -> napi::Result { - let mut schemas = HashMap::new(); - for descriptor_json in cfg.program_schemas.clone().unwrap_or_default() { - let schema = borsh_decoder::parse_program_schema(&descriptor_json).map_err(map_err)?; - schemas.insert(schema.program_id.clone(), schema); - } + let schemas = build_schemas(&event_registrations) + .context("build program schemas") + .map_err(map_err)?; + let selection_builder = SelectionBuilder::from_registrations(&event_registrations) + .context("build selection builder") + .map_err(map_err)?; let inner = hypersync_client_solana::Client::new_with_agent(cfg.into(), user_agent) .context("build solana client") .map_err(map_err)?; Ok(SvmHypersyncClient { inner: Arc::new(inner), schemas, + selection_builder, }) } @@ -100,16 +191,17 @@ impl SvmHypersyncClient { .map_err(map_err) } - /// Single-window query (no client-side pagination). The hyperindex source - /// layer paginates by chunking the slot range itself, so the napi binding - /// must NOT call `collect` (which spins up parallel batched requests under - /// `StreamConfig::default()` and can DoS the server on multi-day windows). + /// Single-window query (no client-side pagination), used for block-hash + /// range queries. The hyperindex source layer paginates by chunking the + /// slot range itself, so the napi binding must NOT call `collect` (which + /// spins up parallel batched requests under `StreamConfig::default()` and + /// can DoS the server on multi-day windows). #[napi] pub async fn get( &self, query: SvmQuery, ) -> napi::Result<(QueryResponse, TransactionStore, BlockStore)> { - let q: hypersync_solana_net_types::query::SolanaQuery = query + let q: SolanaQuery = query .try_into() .context("parse solana query") .map_err(map_err)?; @@ -120,62 +212,299 @@ impl SvmHypersyncClient { .context("solana get") .map_err(map_err)?; - // Decode each matching instruction inline against the client's - // configured schemas — Borsh decoding happens here, in Rust, rather - // than per-instruction over the napi boundary, mirroring how the EVM - // client returns pre-decoded params. Skipped when no schemas exist. - let decoded: Vec> = if self.schemas.is_empty() - { - Vec::new() - } else { - resp.instructions - .iter() - .map(|ix| { - self.schemas.get(&ix.program_id).and_then(|schema| { - borsh_decoder::decode_with_schema( - schema, - ix.accounts.clone(), - ix.data.clone(), - ) - }) - }) - .collect() - }; - - // Retain raw transactions + token balances in Rust; ReScript builds - // items from instructions and the store materialises the parent - // transaction (selected fields only) at batch prep. + // Retain raw transactions + token balances in Rust; the store + // materialises the parent transaction (selected fields only) at batch + // prep. let store = build_svm_store( std::mem::take(&mut resp.transactions), std::mem::take(&mut resp.token_balances), ); - // Take the raw blocks out before the response conversion consumes them, - // build the lean per-slot header from a borrow, then move the owned raw - // blocks into the store — avoiding a full raw-`Block` clone per block - // (mirrors the EVM source's borrow-then-move header pattern). - let raw_blocks = std::mem::take(&mut resp.blocks); - let block_headers: Vec = raw_blocks - .iter() - .map(types::Block::from_raw) - .collect::>>() - .context("mapping solana block headers") - .map_err(map_err)?; - - // slot/time/hash decode from the store like any other field, so every - // response block needs a store entry. - let block_store = BlockStore::new_svm(); - block_store.insert_svm_blocks(raw_blocks); + let (block_headers, block_store) = take_blocks(&mut resp).map_err(map_err)?; let mut out = QueryResponse::try_from(resp) .context("convert solana response") .map_err(map_err)?; out.data.blocks = block_headers; - for (instr, d) in out.data.instructions.iter_mut().zip(decoded) { - instr.decoded = d; - } Ok((out, store, block_store)) } + + #[napi] + pub async fn get_event_items( + &self, + params: EventItemsQuery, + ) -> napi::Result<(EventItemsResponse, TransactionStore, BlockStore)> { + let built = self + .selection_builder + .build(¶ms.registration_indexes) + .map_err(map_err)?; + + let mut field_selection = SolanaFieldSelection { + block: parse_columns(&built.block_columns).map_err(map_err)?, + // Instructions keep the server's full column set — everything + // item building reads (data, accounts, dN, addresses, flags). + ..Default::default() + }; + // Under the server's default merge mode, requesting a table's columns + // is what opts the matched result set into that join — a table with an + // empty field list returns no rows (instructions and blocks are + // exempt), so each opted-into table needs its columns spelled out. + if !built.transaction_columns.is_empty() { + field_selection.transaction = + parse_columns(&built.transaction_columns).map_err(map_err)?; + } + if built.needs_logs { + field_selection.log = parse_columns(&[ + "slot", + "transaction_index", + "instruction_address", + "kind", + "message", + ]) + .map_err(map_err)?; + } + if built.needs_token_balances { + // The store keys balance rows by account regardless of what the + // consumer selected, so `account` always rides along. + field_selection.token_balance = parse_columns(&[ + "slot", + "transaction_index", + "account", + "mint", + "owner", + "pre_amount", + "post_amount", + ]) + .map_err(map_err)?; + } + + let query = SolanaQuery { + from_slot: u64::try_from(params.from_slot) + .context("from_slot must be non-negative") + .map_err(map_err)?, + // Inclusive on the boundary, exclusive on the wire. + to_slot: params + .to_slot + .map(|b| u64::try_from(b + 1).context("to_slot must be non-negative")) + .transpose() + .map_err(map_err)?, + instructions: built.instruction_selections.clone(), + field_selection, + max_num_instructions: usize::try_from(params.max_num_instructions).ok(), + ..Default::default() + }; + + let mut resp = self + .inner + .get(&query) + .await + .context("solana get") + .map_err(map_err)?; + + let store = build_svm_store( + std::mem::take(&mut resp.transactions), + std::mem::take(&mut resp.token_balances), + ); + let (block_headers, block_store) = take_blocks(&mut resp).map_err(map_err)?; + + let mut contract_name_by_address: HashMap = HashMap::new(); + for (contract_name, addresses) in ¶ms.addresses_by_contract_name { + for address in addresses { + contract_name_by_address.insert(address.clone(), contract_name.clone()); + } + } + + let items = build_event_items( + &resp.instructions, + std::mem::take(&mut resp.logs), + &built, + &self.schemas, + &contract_name_by_address, + ) + .map_err(map_err)?; + + let response = EventItemsResponse { + next_slot: i64::try_from(resp.next_slot) + .context("next_slot overflow") + .map_err(map_err)?, + blocks: block_headers, + items, + }; + Ok((response, store, block_store)) + } +} + +/// The whole per-query input for `get_event_items`: the slot range, the +/// partition's registration selection (by index), and its current addresses +/// (program ids per program name). Instruction selections, field selection, +/// and routing are all derived internally from the registrations passed at +/// construction. +#[napi(object)] +pub struct EventItemsQuery { + pub from_slot: i64, + /// Inclusive; `None` queries to the end of available data. + pub to_slot: Option, + pub max_num_instructions: i64, + pub registration_indexes: Vec, + pub addresses_by_contract_name: HashMap>, +} + +#[napi(object)] +#[derive(Clone)] +pub struct LogItem { + pub kind: String, + pub message: String, +} + +/// One routed instruction. Carries everything JS needs to build the handler +/// payload; the parent transaction and block are materialised from the +/// per-chain stores at batch prep. +#[napi(object)] +pub struct EventItem { + /// The registration this instruction routed to, as passed to the client + /// constructor. Instructions that route nowhere never cross the boundary. + pub on_event_registration_index: i64, + pub slot: i64, + pub transaction_index: i64, + pub instruction_address: Vec, + pub program_id: String, + pub accounts: Vec, + /// Raw instruction data, `0x`-prefixed hex; decoded params ride on + /// `decoded` when the registration carries a Borsh schema. + pub data: String, + pub d1: Option, + pub d2: Option, + pub d4: Option, + pub d8: Option, + pub is_inner: bool, + pub decoded: Option, + /// Logs scoped to this instruction; `Some` only when the routed + /// registration opted in via `includeLogs`. + pub logs: Option>, +} + +#[napi(object)] +pub struct EventItemsResponse { + pub next_slot: i64, + /// The page's lean block headers, one per slot; used for reorg detection + /// and the batch's latest timestamp. The full blocks live in the + /// `BlockStore` returned alongside this response. + pub blocks: Vec, + pub items: Vec, +} + +/// Fans each committed instruction out to the registrations it routes to. +/// Logs group per (slot, transactionIndex, instructionAddress) and attach only +/// to items whose registration opted in via `includeLogs`; logs without an +/// instruction address attach to no instruction (rare; usually only system +/// messages). Borsh decoding runs once per instruction against its program's +/// schema, when one exists. +fn build_event_items( + instructions: &[simple::Instruction], + logs: Vec, + built: &selection::BuiltSelection, + schemas: &HashMap, + contract_name_by_address: &HashMap, +) -> Result> { + let mut logs_by_key: HashMap<(u64, u32, Vec), Vec> = HashMap::new(); + for log in logs { + if let (Some(transaction_index), Some(instruction_address)) = + (log.transaction_index, log.instruction_address) + { + logs_by_key + .entry((log.slot, transaction_index, instruction_address)) + .or_default() + .push(LogItem { + kind: log.kind.unwrap_or_default(), + message: log.message.unwrap_or_default(), + }); + } + } + + let mut items: Vec = Vec::with_capacity(instructions.len()); + for instr in instructions { + // Instructions from failed transactions are excluded. HyperSync has no + // server-side predicate to filter instructions by parent-transaction + // success, so the client filters on the `isCommitted` flag it already + // delivers on every instruction row. + if !instr.is_committed { + continue; + } + let contract_name = contract_name_by_address + .get(&instr.program_id) + .map(String::as_str); + let routed = route_instruction(&built.registrations, instr, contract_name); + if routed.is_empty() { + continue; + } + let decoded = schemas.get(&instr.program_id).and_then(|schema| { + borsh_decoder::decode_with_schema(schema, instr.accounts.clone(), instr.data.clone()) + }); + let logs = if routed.iter().any(|reg| reg.include_logs) { + logs_by_key + .get(&( + instr.slot, + instr.transaction_index, + instr.instruction_address.clone(), + )) + .cloned() + } else { + None + }; + let slot = i64::try_from(instr.slot).context("instruction.slot overflow")?; + for reg in routed { + items.push(EventItem { + on_event_registration_index: reg.index, + slot, + transaction_index: i64::from(instr.transaction_index), + instruction_address: instr + .instruction_address + .iter() + .map(|&v| i64::from(v)) + .collect(), + program_id: instr.program_id.clone(), + accounts: instr.accounts.clone(), + data: to_hex(&instr.data), + d1: opt_hex(&instr.d1), + d2: opt_hex(&instr.d2), + d4: opt_hex(&instr.d4), + d8: opt_hex(&instr.d8), + is_inner: instr.is_inner, + decoded: decoded.clone(), + logs: if reg.include_logs { logs.clone() } else { None }, + }); + } + } + Ok(items) +} + +fn parse_columns(columns: &[&str]) -> Result> +where + F: std::str::FromStr, +{ + columns + .iter() + .map(|name| { + name.parse::() + .map_err(|_| anyhow::anyhow!("unknown field name {name:?}")) + }) + .collect() +} + +/// Take the raw blocks out of the response, build the lean per-slot header +/// from a borrow, then move the owned raw blocks into the store — avoiding a +/// full raw-`Block` clone per block. slot/time/hash decode from the store like +/// any other field, so every response block needs a store entry. +fn take_blocks(resp: &mut simple::SolanaResponse) -> Result<(Vec, BlockStore)> { + let raw_blocks = std::mem::take(&mut resp.blocks); + let block_headers: Vec = raw_blocks + .iter() + .map(types::Block::from_raw) + .collect::>>() + .context("mapping solana block headers")?; + let block_store = BlockStore::new_svm(); + block_store.insert_svm_blocks(raw_blocks); + Ok((block_headers, block_store)) } pub(crate) fn map_err(e: anyhow::Error) -> napi::Error { @@ -200,6 +529,7 @@ mod tests { ..Default::default() }, "hyperindex-test".into(), + vec![], ) .expect("build client"); @@ -236,4 +566,164 @@ mod tests { assert!(ix.data.len() > 2, "data should not be empty hex"); } } + + fn reg_input( + index: i64, + discriminator: &str, + include_logs: bool, + ) -> SvmOnEventRegistrationInput { + SvmOnEventRegistrationInput { + index, + instruction_name: format!("I{index}"), + contract_name: "TokenMetadata".to_string(), + program_id: TOKEN_METADATA_PROGRAM.to_string(), + is_wildcard: true, + discriminator: Some(discriminator.to_string()), + discriminator_byte_len: 1, + is_inner: None, + include_logs, + account_filters: vec![], + transaction_fields: vec![], + block_fields: vec![], + accounts: vec![], + args_json: None, + defined_types_json: None, + } + } + + fn committed_instruction(data: &[u8]) -> simple::Instruction { + simple::Instruction { + program_id: TOKEN_METADATA_PROGRAM.to_string(), + data: data.to_vec(), + slot: 42, + transaction_index: 7, + instruction_address: vec![1], + is_committed: true, + ..Default::default() + } + } + + #[test] + fn uncommitted_instructions_are_dropped() { + let built = SelectionBuilder::from_registrations(&[reg_input(0, "0x21", false)]) + .unwrap() + .build(&[0]) + .unwrap(); + let committed = committed_instruction(&[0x21]); + let mut uncommitted = committed_instruction(&[0x21]); + uncommitted.is_committed = false; + uncommitted.transaction_index = 8; + let items = build_event_items( + &[committed, uncommitted], + vec![], + &built, + &HashMap::new(), + &HashMap::new(), + ) + .unwrap(); + assert_eq!( + items + .iter() + .map(|i| ( + i.on_event_registration_index, + i.transaction_index, + i.data.as_str() + )) + .collect::>(), + vec![(0, 7, "0x21")] + ); + } + + #[test] + fn logs_attach_only_to_opted_in_registrations() { + // Two registrations fan out from the same instruction; only the + // includeLogs one carries the instruction-scoped log. + let built = SelectionBuilder::from_registrations(&[reg_input(0, "0x21", true), { + let mut with_different_contract = reg_input(1, "0x21", false); + with_different_contract.contract_name = "Other".to_string(); + with_different_contract + }]) + .unwrap() + .build(&[0, 1]) + .unwrap(); + let instr = committed_instruction(&[0x21]); + let log = simple::Log { + slot: 42, + transaction_index: Some(7), + instruction_address: Some(vec![1]), + kind: Some("data".to_string()), + message: Some("hello".to_string()), + ..Default::default() + }; + let unscoped_log = simple::Log { + slot: 42, + transaction_index: Some(7), + instruction_address: None, + ..Default::default() + }; + let items = build_event_items( + &[instr], + vec![log, unscoped_log], + &built, + &HashMap::new(), + &HashMap::new(), + ) + .unwrap(); + let views: Vec<(i64, Option>)> = items + .iter() + .map(|i| { + ( + i.on_event_registration_index, + i.logs.as_ref().map(|logs| { + logs.iter() + .map(|l| (l.kind.clone(), l.message.clone())) + .collect() + }), + ) + }) + .collect(); + assert_eq!( + views, + vec![ + (0, Some(vec![("data".to_string(), "hello".to_string())])), + (1, None), + ] + ); + } + + #[test] + fn schemas_group_instructions_per_program_and_skip_schemaless() { + let with_schema = + |index: i64, name: &str, discriminator: &str| selection::SvmOnEventRegistrationInput { + index, + instruction_name: name.to_string(), + contract_name: "TokenMetadata".to_string(), + program_id: TOKEN_METADATA_PROGRAM.to_string(), + is_wildcard: false, + discriminator: Some(discriminator.to_string()), + discriminator_byte_len: 1, + is_inner: None, + include_logs: false, + account_filters: vec![], + transaction_fields: vec![], + block_fields: vec![], + accounts: vec!["metadata".to_string()], + args_json: Some(r#"[{"name":"amount","type":"u64"}]"#.to_string()), + defined_types_json: None, + }; + let mut schemaless = with_schema(2, "NoSchema", "0x03"); + schemaless.accounts = vec![]; + schemaless.args_json = None; + + let schemas = build_schemas(&[ + with_schema(0, "CreateV1", "0x21"), + with_schema(1, "UpdateV1", "0x0f"), + schemaless, + ]) + .unwrap(); + assert_eq!( + schemas.keys().collect::>(), + vec![TOKEN_METADATA_PROGRAM] + ); + } } diff --git a/packages/cli/src/svm_hypersync_source/selection.rs b/packages/cli/src/svm_hypersync_source/selection.rs new file mode 100644 index 000000000..2e282ff54 --- /dev/null +++ b/packages/cli/src/svm_hypersync_source/selection.rs @@ -0,0 +1,722 @@ +use std::collections::HashMap; +use std::sync::Arc; + +use anyhow::{Context, Result}; +use hypersync_client_solana::simple_types as simple; +use hypersync_solana_net_types::query as net; +use napi_derive::napi; + +use super::mod_helpers::hex_to_bytes; + +#[napi(object)] +#[derive(Clone)] +pub struct SvmAccountFilterInput { + /// Positional account index (`a0`..`a9` on the wire). + pub position: i64, + /// Base58 pubkeys; the account at `position` must be one of them. + pub values: Vec, +} + +/// The full per-(instruction, chain) registration crossing the boundary once +/// at client construction: routing identity, the fetch state queries are +/// built from, and the Borsh schema used for inline decoding. +#[napi(object)] +pub struct SvmOnEventRegistrationInput { + /// Chain-scoped sequential registration index; returned on every routed + /// item so JS resolves the registration by array index. + pub index: i64, + pub instruction_name: String, + /// Program name (the config's contract name). + pub contract_name: String, + /// Base58 program id. Empty means the config carries no real program + /// (placeholder); such a registration is never fetched or routed. + pub program_id: String, + pub is_wildcard: bool, + /// Hex-encoded discriminator. `None` matches every instruction in the + /// program (lowest routing priority). + pub discriminator: Option, + /// Discriminator length in bytes (1/2/4/8); 0 when no discriminator. + pub discriminator_byte_len: i64, + /// `None` matches both outer and inner (CPI-invoked) instructions. + pub is_inner: Option, + pub include_logs: bool, + /// Disjunctive normal form: outer array is OR of AND-groups. + pub account_filters: Vec>, + /// Selected transaction fields, camelCase (`Internal.svmTransactionField`). + pub transaction_fields: Vec, + /// Selected block fields, camelCase (`Internal.svmBlockField`). + pub block_fields: Vec, + /// Positional account names from the Borsh schema, in declared order. + /// Empty (with `args_json` absent) means no schema for this instruction. + pub accounts: Vec, + /// Borsh args layout as `Vec` JSON. Absent means no schema. + pub args_json: Option, + /// Program-level nominal-type registry (`BTreeMap` JSON), + /// duplicated on every instruction of the program. + pub defined_types_json: Option, +} + +/// Maps a selected transaction field to the extra query column it needs. +/// `transactionIndex` is always fetched as the store key, and `tokenBalances` +/// lives in a separate table, so neither adds a transaction column. +fn transaction_field_column(field: &str) -> Result> { + Ok(match field { + "transactionIndex" | "tokenBalances" => None, + "signatures" => Some("signatures"), + "feePayer" => Some("fee_payer"), + "success" => Some("success"), + "err" => Some("err"), + "fee" => Some("fee"), + "computeUnitsConsumed" => Some("compute_units_consumed"), + "accountKeys" => Some("account_keys"), + "recentBlockhash" => Some("recent_blockhash"), + "version" => Some("version"), + other => anyhow::bail!("unknown transaction field {other:?}"), + }) +} + +/// Maps a selected block field to its query column. slot/time/hash are always +/// fetched (as slot/block_time/blockhash), so they add no extra column. +fn block_field_column(field: &str) -> Result> { + Ok(match field { + "slot" | "time" | "hash" => None, + "height" => Some("block_height"), + "parentSlot" => Some("parent_slot"), + "parentHash" => Some("parent_blockhash"), + other => anyhow::bail!("unknown block field {other:?}"), + }) +} + +pub(crate) struct Registration { + pub index: i64, + pub contract_name: String, + pub program_id: String, + pub is_wildcard: bool, + /// Decoded discriminator bytes; `None` = program-wide. + pub discriminator: Option>, + /// Original hex value for the query's `dN` filter. + pub discriminator_hex: Option, + pub byte_len: usize, + pub is_inner: Option, + pub include_logs: bool, + pub account_filters: Vec)>>, + pub transaction_columns: Vec<&'static str>, + pub needs_token_balances: bool, + pub block_columns: Vec<&'static str>, +} + +impl Registration { + fn parse(input: &SvmOnEventRegistrationInput) -> Result { + let discriminator = input + .discriminator + .as_deref() + .filter(|d| !d.is_empty()) + .map(|d| hex_to_bytes(d).context("decode discriminator hex")) + .transpose()?; + let byte_len = usize::try_from(input.discriminator_byte_len) + .context("discriminator_byte_len out of range")?; + if let Some(bytes) = &discriminator { + anyhow::ensure!( + matches!(byte_len, 1 | 2 | 4 | 8) && bytes.len() == byte_len, + "discriminator byte length must be 1/2/4/8 and match the value, got {} bytes declared as {}", + bytes.len(), + byte_len, + ); + } + let account_filters = input + .account_filters + .iter() + .map(|group| { + group + .iter() + .map(|filter| { + let position = usize::try_from(filter.position) + .ok() + .filter(|p| *p <= 9) + .with_context(|| { + format!("account filter position {} out of a0..a9", filter.position) + })?; + Ok((position, filter.values.clone())) + }) + .collect::>>() + }) + .collect::>>()?; + let mut transaction_columns = Vec::new(); + let mut needs_token_balances = false; + for field in &input.transaction_fields { + if field == "tokenBalances" { + needs_token_balances = true; + } + if let Some(column) = transaction_field_column(field)? { + if !transaction_columns.contains(&column) { + transaction_columns.push(column); + } + } + } + let mut block_columns = Vec::new(); + for field in &input.block_fields { + if let Some(column) = block_field_column(field)? { + if !block_columns.contains(&column) { + block_columns.push(column); + } + } + } + Ok(Self { + index: input.index, + contract_name: input.contract_name.clone(), + program_id: input.program_id.clone(), + is_wildcard: input.is_wildcard, + discriminator, + discriminator_hex: input.discriminator.clone().filter(|d| !d.is_empty()), + byte_len, + is_inner: input.is_inner, + include_logs: input.include_logs, + account_filters, + transaction_columns, + needs_token_balances, + block_columns, + }) + } + + /// Whether an instruction belongs to this registration, discriminator + /// aside: same program, an allowed owner (wildcard registrations accept + /// any program address, program-bound ones only their own contract), the + /// `isInner` constraint, and the registration's account filters — the + /// filters are re-applied here so an instruction fetched for a sibling + /// selection can't leak into a registration whose own filter rejects it. + fn matches_scope(&self, instr: &simple::Instruction, contract_name: Option<&str>) -> bool { + self.program_id == instr.program_id + && (self.is_wildcard || contract_name == Some(self.contract_name.as_str())) + && self + .is_inner + .is_none_or(|is_inner| is_inner == instr.is_inner) + && (self.account_filters.is_empty() + || self.account_filters.iter().any(|group| { + group.iter().all(|(position, values)| { + instr + .accounts + .get(*position) + .is_some_and(|account| values.contains(account)) + }) + })) + } + + fn matches_discriminator(&self, data: &[u8]) -> bool { + match &self.discriminator { + Some(bytes) => { + data.len() >= self.byte_len && &data[..self.byte_len] == bytes.as_slice() + } + None => false, + } + } +} + +/// One instruction selection of a built query, before conversion to the wire +/// type; `PartialEq` so identical selections from same-signature +/// registrations are deduplicated. +#[derive(Debug, Clone, PartialEq, Eq, Default)] +struct BuiltInstructionSelection { + program_id: String, + discriminator_hex: Option, + byte_len: usize, + accounts: [Option>; 10], + is_inner: Option, +} + +impl BuiltInstructionSelection { + fn into_net(self) -> net::InstructionSelection { + let mut selection = net::InstructionSelection { + program_id: vec![self.program_id], + is_inner: self.is_inner, + ..Default::default() + }; + if let Some(d) = self.discriminator_hex { + match self.byte_len { + 1 => selection.d1 = vec![d], + 2 => selection.d2 = vec![d], + 4 => selection.d4 = vec![d], + 8 => selection.d8 = vec![d], + _ => {} + } + } + let [a0, a1, a2, a3, a4, a5, a6, a7, a8, a9] = self.accounts; + selection.a0 = a0.unwrap_or_default(); + selection.a1 = a1.unwrap_or_default(); + selection.a2 = a2.unwrap_or_default(); + selection.a3 = a3.unwrap_or_default(); + selection.a4 = a4.unwrap_or_default(); + selection.a5 = a5.unwrap_or_default(); + selection.a6 = a6.unwrap_or_default(); + selection.a7 = a7.unwrap_or_default(); + selection.a8 = a8.unwrap_or_default(); + selection.a9 = a9.unwrap_or_default(); + selection + } +} + +/// Everything a source query needs that depends on the partition's selection. +pub(crate) struct BuiltSelection { + pub instruction_selections: Vec, + /// Union over the selection's registrations; always contains the + /// slot/blockhash/block_time trio. + pub block_columns: Vec<&'static str>, + /// Union over the selection's registrations; non-empty only when a stored + /// transaction record is actually read (then it carries the + /// slot/transaction_index store key too). + pub transaction_columns: Vec<&'static str>, + pub needs_token_balances: bool, + pub needs_logs: bool, + /// The selection's registrations sorted by index, for routing. + pub registrations: Vec>, +} + +/// Builds per-query instruction selections and field unions from the +/// registrations passed at client construction, and routes returned +/// instructions back to them. +pub(crate) struct SelectionBuilder { + registrations: HashMap>, +} + +impl SelectionBuilder { + pub(crate) fn from_registrations( + registrations: &[SvmOnEventRegistrationInput], + ) -> Result { + let mut map = HashMap::new(); + for reg in registrations { + let parsed = Registration::parse(reg) + .with_context(|| format!("parse registration for {}", reg.instruction_name))?; + anyhow::ensure!( + map.insert(reg.index, Arc::new(parsed)).is_none(), + "Duplicate registration index {} for instruction {}", + reg.index, + reg.instruction_name, + ); + } + Ok(Self { registrations: map }) + } + + pub(crate) fn build(&self, registration_indexes: &[i64]) -> Result { + let mut selections: Vec = Vec::new(); + // The always-fetched trio: `slot` keys the page's blocks, and the + // consumer reads time/hash off every block (reorg detection, item + // timestamps). + let mut block_columns = vec!["slot", "blockhash", "block_time"]; + let mut transaction_columns: Vec<&'static str> = Vec::new(); + let mut needs_token_balances = false; + let mut needs_logs = false; + let mut registrations = Vec::with_capacity(registration_indexes.len()); + + for id in registration_indexes { + let reg = self + .registrations + .get(id) + .with_context(|| format!("Unknown registration index {id} in query selection"))?; + registrations.push(reg.clone()); + + for &column in ®.block_columns { + if !block_columns.contains(&column) { + block_columns.push(column); + } + } + for &column in ®.transaction_columns { + if !transaction_columns.contains(&column) { + transaction_columns.push(column); + } + } + needs_token_balances = needs_token_balances || reg.needs_token_balances; + needs_logs = needs_logs || reg.include_logs; + + // Placeholder configs carry no real program — skip rather than + // ship a degenerate match-all selection. + if reg.program_id.is_empty() { + continue; + } + // Each AND-group becomes its own selection; groups sharing the + // same `(programId, dN)` are OR-ed by the wire protocol. An empty + // outer array emits one selection with no account filtering. + let groups: &[Vec<(usize, Vec)>] = if reg.account_filters.is_empty() { + &[Vec::new()] + } else { + ®.account_filters + }; + for group in groups { + let mut selection = BuiltInstructionSelection { + program_id: reg.program_id.clone(), + discriminator_hex: reg.discriminator_hex.clone(), + byte_len: reg.byte_len, + is_inner: reg.is_inner, + ..Default::default() + }; + for (position, values) in group { + selection.accounts[*position] = Some(values.clone()); + } + if !selections.contains(&selection) { + selections.push(selection); + } + } + } + // The transaction table is fetched only when a selected field is read + // off a stored transaction record; the store key columns ride along. + if !transaction_columns.is_empty() { + transaction_columns.splice(0..0, ["slot", "transaction_index"]); + } + // Deterministic item order per instruction, independent of the + // selection's index order. + registrations.sort_unstable_by_key(|reg| reg.index); + + Ok(BuiltSelection { + instruction_selections: selections + .into_iter() + .map(BuiltInstructionSelection::into_net) + .collect(), + block_columns, + transaction_columns, + needs_token_balances, + needs_logs, + registrations, + }) + } +} + +/// Routes an instruction to the selection's registrations, probing declared +/// discriminator byte lengths longest-first (d8/d4/d2/d1): the first length +/// with any full match wins and the instruction fans out to every +/// registration matching at that length. Program-wide registrations (no +/// discriminator) are the final fallback when no discriminator-keyed +/// registration matched. +pub(crate) fn route_instruction( + registrations: &[Arc], + instr: &simple::Instruction, + contract_name: Option<&str>, +) -> Vec> { + for byte_len in [8usize, 4, 2, 1] { + let matched: Vec> = registrations + .iter() + .filter(|reg| { + reg.byte_len == byte_len + && reg.matches_discriminator(&instr.data) + && reg.matches_scope(instr, contract_name) + }) + .cloned() + .collect(); + if !matched.is_empty() { + return matched; + } + } + registrations + .iter() + .filter(|reg| reg.discriminator.is_none() && reg.matches_scope(instr, contract_name)) + .cloned() + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + const PROG_A: &str = "metaqbxxUerdq28cj1RbAWkYQm3ybzjb6a8bt518x1s"; + const PROG_B: &str = "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA"; + const ACCOUNT_1: &str = "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v"; + const ACCOUNT_2: &str = "So11111111111111111111111111111111111111112"; + + fn reg( + index: i64, + program_id: &str, + discriminator: Option<&str>, + byte_len: i64, + is_wildcard: bool, + ) -> SvmOnEventRegistrationInput { + SvmOnEventRegistrationInput { + index, + instruction_name: format!("I{index}"), + contract_name: format!("P_{program_id}"), + program_id: program_id.to_string(), + is_wildcard, + discriminator: discriminator.map(str::to_string), + discriminator_byte_len: byte_len, + is_inner: None, + include_logs: false, + account_filters: vec![], + transaction_fields: vec![], + block_fields: vec![], + accounts: vec![], + args_json: None, + defined_types_json: None, + } + } + + fn instruction(program_id: &str, data: &[u8]) -> simple::Instruction { + simple::Instruction { + program_id: program_id.to_string(), + data: data.to_vec(), + is_committed: true, + ..Default::default() + } + } + + fn route_indexes( + built: &BuiltSelection, + instr: &simple::Instruction, + contract_name: Option<&str>, + ) -> Vec { + route_instruction(&built.registrations, instr, contract_name) + .iter() + .map(|reg| reg.index) + .collect() + } + + #[test] + fn discriminator_becomes_the_matching_dn_filter() { + let builder = SelectionBuilder::from_registrations(&[ + reg(0, PROG_A, Some("0x21"), 1, false), + reg(1, PROG_A, Some("0x0102030405060708"), 8, false), + ]) + .unwrap(); + let built = builder.build(&[0, 1]).unwrap(); + let views: Vec<(Vec, Vec, Vec)> = built + .instruction_selections + .iter() + .map(|s| (s.program_id.clone(), s.d1.clone(), s.d8.clone())) + .collect(); + assert_eq!( + views, + vec![ + (vec![PROG_A.to_string()], vec!["0x21".to_string()], vec![]), + ( + vec![PROG_A.to_string()], + vec![], + vec!["0x0102030405060708".to_string()] + ), + ] + ); + } + + #[test] + fn account_filter_groups_fan_out_to_separate_selections() { + let mut input = reg(0, PROG_A, Some("0x0c"), 1, false); + input.account_filters = vec![ + vec![SvmAccountFilterInput { + position: 1, + values: vec![ACCOUNT_1.to_string()], + }], + vec![SvmAccountFilterInput { + position: 2, + values: vec![ACCOUNT_2.to_string()], + }], + ]; + let builder = SelectionBuilder::from_registrations(&[input]).unwrap(); + let built = builder.build(&[0]).unwrap(); + let views: Vec<(Vec, Vec)> = built + .instruction_selections + .iter() + .map(|s| (s.a1.clone(), s.a2.clone())) + .collect(); + assert_eq!( + views, + vec![ + (vec![ACCOUNT_1.to_string()], vec![]), + (vec![], vec![ACCOUNT_2.to_string()]), + ] + ); + } + + #[test] + fn empty_program_id_emits_no_selection() { + let builder = + SelectionBuilder::from_registrations(&[reg(0, "", Some("0x21"), 1, false)]).unwrap(); + let built = builder.build(&[0]).unwrap(); + assert!(built.instruction_selections.is_empty()); + } + + #[test] + fn identical_selections_are_deduplicated() { + let builder = SelectionBuilder::from_registrations(&[ + reg(0, PROG_A, Some("0x21"), 1, false), + reg(1, PROG_A, Some("0x21"), 1, true), + ]) + .unwrap(); + let built = builder.build(&[0, 1]).unwrap(); + assert_eq!(built.instruction_selections.len(), 1); + } + + #[test] + fn field_unions_and_flags() { + let mut a = reg(0, PROG_A, Some("0x21"), 1, false); + a.transaction_fields = vec!["signatures".to_string(), "transactionIndex".to_string()]; + a.block_fields = vec!["height".to_string(), "slot".to_string()]; + a.include_logs = true; + let mut b = reg(1, PROG_A, Some("0x22"), 1, false); + b.transaction_fields = vec!["tokenBalances".to_string()]; + let builder = SelectionBuilder::from_registrations(&[a, b]).unwrap(); + let built = builder.build(&[0, 1]).unwrap(); + assert_eq!( + ( + built.block_columns.clone(), + built.transaction_columns.clone(), + built.needs_token_balances, + built.needs_logs, + ), + ( + vec!["slot", "blockhash", "block_time", "block_height"], + vec!["slot", "transaction_index", "signatures"], + true, + true, + ) + ); + } + + #[test] + fn token_balances_or_transaction_index_alone_fetch_no_transaction_columns() { + let mut input = reg(0, PROG_A, Some("0x21"), 1, false); + input.transaction_fields = + vec!["transactionIndex".to_string(), "tokenBalances".to_string()]; + let builder = SelectionBuilder::from_registrations(&[input]).unwrap(); + let built = builder.build(&[0]).unwrap(); + assert_eq!( + ( + built.transaction_columns.clone(), + built.needs_token_balances + ), + (vec![], true) + ); + } + + #[test] + fn routes_longest_discriminator_first() { + // A d1 registration (0x0f) and a d8 registration starting with 0x0f: + // an instruction carrying the full 8-byte prefix routes to the d8 + // registration only. + let builder = SelectionBuilder::from_registrations(&[ + reg(0, PROG_A, Some("0x0f"), 1, true), + reg(1, PROG_A, Some("0x0fffffffffffffff"), 8, true), + ]) + .unwrap(); + let built = builder.build(&[0, 1]).unwrap(); + let long = instruction(PROG_A, &[0x0f, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff]); + let short = instruction(PROG_A, &[0x0f, 0x00]); + assert_eq!( + ( + route_indexes(&built, &long, None), + route_indexes(&built, &short, None), + ), + (vec![1], vec![0]) + ); + } + + #[test] + fn program_wide_registration_is_the_fallback() { + let builder = SelectionBuilder::from_registrations(&[ + reg(0, PROG_A, Some("0x21"), 1, true), + reg(1, PROG_A, None, 0, true), + ]) + .unwrap(); + let built = builder.build(&[0, 1]).unwrap(); + let keyed = instruction(PROG_A, &[0x21]); + let other = instruction(PROG_A, &[0x22]); + assert_eq!( + ( + route_indexes(&built, &keyed, None), + route_indexes(&built, &other, None), + ), + (vec![0], vec![1]) + ); + } + + #[test] + fn fans_out_to_wildcard_and_owned_registration() { + let mut owned = reg(0, PROG_A, Some("0x21"), 1, false); + owned.contract_name = "Owned".to_string(); + let wildcard = reg(1, PROG_A, Some("0x21"), 1, true); + let mut other = reg(2, PROG_A, Some("0x21"), 1, false); + other.contract_name = "Other".to_string(); + let builder = SelectionBuilder::from_registrations(&[owned, wildcard, other]).unwrap(); + let built = builder.build(&[0, 1, 2]).unwrap(); + let instr = instruction(PROG_A, &[0x21]); + assert_eq!( + ( + route_indexes(&built, &instr, Some("Owned")), + route_indexes(&built, &instr, None), + ), + (vec![0, 1], vec![1]) + ); + } + + #[test] + fn routing_scoped_to_program() { + let builder = SelectionBuilder::from_registrations(&[ + reg(0, PROG_A, Some("0x21"), 1, true), + reg(1, PROG_B, Some("0x21"), 1, true), + ]) + .unwrap(); + let built = builder.build(&[0, 1]).unwrap(); + let instr = instruction(PROG_B, &[0x21]); + assert_eq!(route_indexes(&built, &instr, None), vec![1]); + } + + #[test] + fn account_filters_reapplied_in_routing() { + let mut filtered = reg(0, PROG_A, Some("0x21"), 1, true); + filtered.account_filters = vec![vec![SvmAccountFilterInput { + position: 1, + values: vec![ACCOUNT_1.to_string()], + }]]; + let builder = SelectionBuilder::from_registrations(&[filtered]).unwrap(); + let built = builder.build(&[0]).unwrap(); + let mut matching = instruction(PROG_A, &[0x21]); + matching.accounts = vec![ACCOUNT_2.to_string(), ACCOUNT_1.to_string()]; + let mut rejected = instruction(PROG_A, &[0x21]); + rejected.accounts = vec![ACCOUNT_1.to_string(), ACCOUNT_2.to_string()]; + assert_eq!( + ( + route_indexes(&built, &matching, None), + route_indexes(&built, &rejected, None), + ), + (vec![0], vec![]) + ); + } + + #[test] + fn is_inner_constraint_reapplied_in_routing() { + let mut outer_only = reg(0, PROG_A, Some("0x21"), 1, true); + outer_only.is_inner = Some(false); + let builder = SelectionBuilder::from_registrations(&[outer_only]).unwrap(); + let built = builder.build(&[0]).unwrap(); + let outer = instruction(PROG_A, &[0x21]); + let mut inner = instruction(PROG_A, &[0x21]); + inner.is_inner = true; + assert_eq!( + ( + route_indexes(&built, &outer, None), + route_indexes(&built, &inner, None), + ), + (vec![0], vec![]) + ); + } + + #[test] + fn selection_subset_excludes_other_registrations() { + let builder = SelectionBuilder::from_registrations(&[ + reg(0, PROG_A, Some("0x21"), 1, true), + reg(1, PROG_A, Some("0x22"), 1, true), + ]) + .unwrap(); + let built = builder.build(&[1]).unwrap(); + let instr = instruction(PROG_A, &[0x21]); + assert_eq!(route_indexes(&built, &instr, None), Vec::::new()); + } + + #[test] + fn unknown_registration_index_errors() { + let builder = SelectionBuilder::from_registrations(&[]).unwrap(); + let err = builder.build(&[7]).err().unwrap(); + assert!(format!("{err:#}").contains("Unknown registration index 7")); + } + + #[test] + fn mismatched_discriminator_byte_len_errors() { + let err = SelectionBuilder::from_registrations(&[reg(0, PROG_A, Some("0x2122"), 1, true)]) + .err() + .unwrap(); + assert!(format!("{err:#}").contains("discriminator byte length")); + } +} diff --git a/packages/cli/src/svm_hypersync_source/types.rs b/packages/cli/src/svm_hypersync_source/types.rs index a1f53f477..4a6a3a09f 100644 --- a/packages/cli/src/svm_hypersync_source/types.rs +++ b/packages/cli/src/svm_hypersync_source/types.rs @@ -100,7 +100,7 @@ pub struct QueryResponse { pub data: QueryResponseData, } -fn to_hex(bytes: &[u8]) -> String { +pub(crate) fn to_hex(bytes: &[u8]) -> String { let mut s = String::with_capacity(2 + bytes.len() * 2); s.push_str("0x"); for b in bytes { @@ -109,7 +109,7 @@ fn to_hex(bytes: &[u8]) -> String { s } -fn opt_hex(bytes: &Option>) -> Option { +pub(crate) fn opt_hex(bytes: &Option>) -> Option { bytes.as_deref().map(to_hex) } diff --git a/packages/envio/src/ChainState.res b/packages/envio/src/ChainState.res index 66efd23c4..5d78491dc 100644 --- a/packages/envio/src/ChainState.res +++ b/packages/envio/src/ChainState.res @@ -206,7 +206,12 @@ let makeInternal = ( ~lowercaseAddresses, ) | Config.FuelSourceConfig({hypersync}) => [ - HyperFuelSource.make({chain, endpointUrl: hypersync, apiToken: Env.envioApiToken}), + HyperFuelSource.make({ + chain, + endpointUrl: hypersync, + apiToken: Env.envioApiToken, + onEventRegistrations, + }), ] | Config.SvmSourceConfig({hypersync, rpc}) => switch (hypersync, rpc) { diff --git a/packages/envio/src/FetchState.res b/packages/envio/src/FetchState.res index 1f9828fba..2c7c3bb10 100644 --- a/packages/envio/src/FetchState.res +++ b/packages/envio/src/FetchState.res @@ -953,8 +953,8 @@ OptimizedPartitions.t => { // Addresses with different start blocks within range share a partition; // events before each address's effectiveStartBlock are dropped on the - // client side (EventRouter for the srcAddress, the event's - // clientAddressFilter for address-valued params). + // client side (the event's clientAddressFilter for address-valued + // params). if shouldJoinCurrentStartBlock { addressesRef := addressesRef.contents->Array.concat(byStartBlock->Dict.getUnsafe(nextStartBlockKey)) @@ -1358,7 +1358,7 @@ let registerDynamicContracts = ( // Drop events an address-param filter rejects. A merged partition may // over-fetch a wildcard event whose indexed address param references an // address registered after the log's block; `clientAddressFilter` is the -// param-level analogue of EventRouter's srcAddress effectiveStartBlock check. +// param-level analogue of the srcAddress effectiveStartBlock check. let filterByClientAddress = ( items: array, ~indexingAddresses: IndexingAddresses.t, diff --git a/packages/envio/src/HandlerRegister.res b/packages/envio/src/HandlerRegister.res index 39b430878..0a584d961 100644 --- a/packages/envio/src/HandlerRegister.res +++ b/packages/envio/src/HandlerRegister.res @@ -600,11 +600,39 @@ let finishRegistration = (~config: Config.t): registrationsByChainId => { let key = chainConfig.id->Int.toString let pending = r.registrationsByChainId->Utils.Dict.dangerouslyGetNonOption(key) - // We don't need the router itself, but only validation logic, - // since now event router is created for selection of events - // and validation doesn't work correctly in routers. - // Ideally to split it into two different parts. - let eventRouter = EventRouter.empty() + // Per-eventId dispatch validation: two events on one contract can't + // share a dispatch id, and only one wildcard may claim it — Rust-side + // routing fans a log/receipt/instruction out by these ids, so a + // collision would double-deliver. + let contractNamesByEventId: dict> = Dict.make() + let wildcardEventIds = Utils.Set.make() + let validateEventIdOrThrow = (~eventId, ~contractName, ~eventName, ~isWildcard) => { + let chainSuffix = `on chain ${chainConfig.id->Int.toString}` + let contractNames = switch contractNamesByEventId->Utils.Dict.dangerouslyGetNonOption( + eventId, + ) { + | Some(contractNames) => contractNames + | None => { + let contractNames = Utils.Set.make() + contractNamesByEventId->Dict.set(eventId, contractNames) + contractNames + } + } + if contractNames->Utils.Set.has(contractName) { + JsError.throwWithMessage( + `Duplicate event detected: ${eventName} for contract ${contractName} ${chainSuffix}`, + ) + } + if isWildcard && wildcardEventIds->Utils.Set.has(eventId) { + JsError.throwWithMessage( + `Another event is already registered with the same signature that would interfer with wildcard filtering: ${eventName} for contract ${contractName} ${chainSuffix}`, + ) + } + if isWildcard { + wildcardEventIds->Utils.Set.add(eventId)->ignore + } + contractNames->Utils.Set.add(contractName)->ignore + } let onEventRegistrations: array = [] @@ -622,12 +650,9 @@ let finishRegistration = (~config: Config.t): registrationsByChainId => { ), ) - // Should validate the events - eventRouter->EventRouter.addOrThrow( - eventConfig.id, - (), + validateEventIdOrThrow( + ~eventId=eventConfig.id, ~contractName, - ~chain=ChainMap.Chain.makeUnsafe(~chainId=chainConfig.id), ~eventName, ~isWildcard=switch registration { | Some(registration) => registration.isWildcard diff --git a/packages/envio/src/sources/EventRouter.res b/packages/envio/src/sources/EventRouter.res deleted file mode 100644 index 25b63c682..000000000 --- a/packages/envio/src/sources/EventRouter.res +++ /dev/null @@ -1,165 +0,0 @@ -exception EventDuplicate -exception WildcardCollision - -module Group = { - type t<'a> = { - mutable wildcard: option<'a>, - byContractName: dict<'a>, - } - - let empty = () => { - wildcard: None, - byContractName: Dict.make(), - } - - let addOrThrow = (group: t<'a>, event, ~contractName, ~isWildcard) => { - let {byContractName, wildcard} = group - switch byContractName->Utils.Dict.dangerouslyGetNonOption(contractName) { - | Some(_) => throw(EventDuplicate) - | None => - if isWildcard && wildcard->Option.isSome { - throw(WildcardCollision) - } else { - if isWildcard { - group.wildcard = Some(event) - } - byContractName->Dict.set(contractName, event) - } - } - } - - // Ownership only: resolve the owning contract from the partition's reverse - // index (the partition that fetched the log), not a chain-wide snapshot. The - // `effectiveStartBlock` temporal gate now lives in `clientAddressFilter`. The - // wildcard partition has an empty index → every log falls back to `wildcard`, - // so it can never claim an address-bound contract's logs. - let get = (group: t<'a>, ~contractAddress, ~contractNameByAddress: dict) => - switch group { - | {wildcard, byContractName} => - switch contractNameByAddress->Utils.Dict.dangerouslyGetNonOption( - contractAddress->Address.toString, - ) { - | Some(contractName) => - switch byContractName->Utils.Dict.dangerouslyGetNonOption(contractName) { - // Fall back to the wildcard handler when the owning contract has no - // matching event for this tag. This covers addresses registered for - // contracts without events (persisted for future config changes) as - // well as addresses whose contract has other events but not this one. - | None => wildcard - | Some(_) as event => event - } - | None => wildcard - } - } -} - -type t<'a> = dict> - -let empty = () => Dict.make() - -let addOrThrow = ( - router: t<'a>, - eventId, - event, - ~contractName, - ~isWildcard, - ~eventName, - ~chain, -) => { - let group = switch router->Utils.Dict.dangerouslyGetNonOption(eventId) { - | None => - let group = Group.empty() - router->Dict.set(eventId, group) - group - | Some(group) => group - } - try group->Group.addOrThrow(event, ~contractName, ~isWildcard) catch { - | EventDuplicate => - JsError.throwWithMessage( - `Duplicate event detected: ${eventName} for contract ${contractName} on chain ${chain->ChainMap.Chain.toString}`, - ) - | WildcardCollision => - JsError.throwWithMessage( - `Another event is already registered with the same signature that would interfer with wildcard filtering: ${eventName} for contract ${contractName} on chain ${chain->ChainMap.Chain.toString}`, - ) - } -} - -let get = (router: t<'a>, ~tag, ~contractAddress, ~contractNameByAddress) => { - switch router->Utils.Dict.dangerouslyGetNonOption(tag) { - | None => None - | Some(group) => group->Group.get(~contractAddress, ~contractNameByAddress) - } -} - -/** Dispatch key for SVM instructions. `None` matches any instruction in the - program (lowest priority). */ -let getSvmEventId = (~programId: SvmTypes.Pubkey.t, ~discriminator: option) => - switch discriminator { - | None => programId->SvmTypes.Pubkey.toString ++ "_none" - | Some(d) => programId->SvmTypes.Pubkey.toString ++ "_" ++ d - } - -/** Discriminator byte-lengths declared by a program, sorted descending. The - source uses this to probe `(programId, dN)` keys longest-first when routing - a returned instruction to a handler — matching the locked Q1 answer. */ -type svmProgramOrdering = { - programId: SvmTypes.Pubkey.t, - /** Byte lengths in descending order, deduplicated. Includes `0` only when - a handler is registered with no discriminator (program-wide match). */ - byteLengthsDesc: array, -} - -let fromSvmEventConfigsOrThrow = (events: array, ~chain): ( - t, - array, -) => { - let router = empty() - events->Array.forEach(config => { - let eventConfig = - config.eventConfig->(Utils.magic: Internal.eventConfig => Internal.svmInstructionEventConfig) - // The router tag must include the programId so two programs declaring the - // same discriminator coexist. The source-side lookup uses the same shape - // via `getSvmEventId(~programId, ~discriminator)`. - let routerTag = getSvmEventId( - ~programId=eventConfig.programId, - ~discriminator=eventConfig.discriminator, - ) - router->addOrThrow( - routerTag, - config, - ~contractName=eventConfig.contractName, - ~eventName=eventConfig.name, - ~chain, - ~isWildcard=config.isWildcard, - ) - }) - - // Per-program list of declared discriminator byte lengths, sorted desc. - let byProgram: dict> = Dict.make() - events->Array.forEach(config => { - let eventConfig = - config.eventConfig->(Utils.magic: Internal.eventConfig => Internal.svmInstructionEventConfig) - let key = eventConfig.programId->SvmTypes.Pubkey.toString - let set = switch byProgram->Utils.Dict.dangerouslyGetNonOption(key) { - | Some(s) => s - | None => - let s = Utils.Set.make() - byProgram->Dict.set(key, s) - s - } - let _ = set->Utils.Set.add(eventConfig.discriminatorByteLen) - }) - let ordering = - byProgram - ->Dict.toArray - ->Array.map(((programIdString, lens)) => { - let sorted = lens->Utils.Set.toArray->Array.toSorted((a, b) => (b - a)->Int.toFloat) - { - programId: programIdString->SvmTypes.Pubkey.fromStringUnsafe, - byteLengthsDesc: sorted, - } - }) - - (router, ordering) -} diff --git a/packages/envio/src/sources/HyperFuel.res b/packages/envio/src/sources/HyperFuel.res index a1a356deb..edc35ecb0 100644 --- a/packages/envio/src/sources/HyperFuel.res +++ b/packages/envio/src/sources/HyperFuel.res @@ -1,25 +1,11 @@ -type hyperSyncPage<'item> = { - items: array<'item>, +type logsQueryPage = { + items: array, + // Blocks referenced by `items`, one per height. + blocks: array, nextBlock: int, archiveHeight: int, } -type block = { - id: string, - time: int, - height: int, -} - -type item = { - transactionId: string, - contractId: Address.t, - receipt: FuelSDK.Receipt.t, - receiptIndex: int, - block: block, -} - -type logsQueryPage = hyperSyncPage - module GetLogs = { type error = | UnexpectedMissingParams({missingParams: array}) @@ -52,117 +38,21 @@ module GetLogs = { } } - let makeRequestBody = ( - ~fromBlock, - ~toBlockInclusive, - ~recieptsSelection, - ): HyperFuelClient.QueryTypes.query => { - { - fromBlock, - toBlockExclusive: ?switch toBlockInclusive { - | Some(toBlockInclusive) => Some(toBlockInclusive + 1) - | None => None - }, - receipts: recieptsSelection, - fieldSelection: { - receipt: [ - TxId, - BlockHeight, - RootContractId, - Data, - ReceiptIndex, - ReceiptType, - Rb, - // TODO: Include them only when there's a mint/burn/transferOut receipt selection - SubId, - Val, - Amount, - ToAddress, - AssetId, - To, - ], - block: [Id, Height, Time], - }, - } - } - - let getParam = (param, name) => { - switch param { - | Some(v) => v - | None => - throw( - Error( - UnexpectedMissingParams({ - missingParams: [name], - }), - ), - ) - } - } - - //Note this function can throw an error - let decodeLogQueryPageItems = (response_data: HyperFuelClient.queryResponseDataTyped): array< - item, - > => { - let {receipts, blocks} = response_data - - let blocksDict = Dict.make() - blocks->Array.forEach(block => { - blocksDict->Dict.set(block.height->(Utils.magic: int => string), block) - }) - - let items = [] - - receipts->Array.forEach(receipt => { - switch receipt.rootContractId { - | None => () - | Some(contractId) => { - let block = - blocksDict - ->Utils.Dict.dangerouslyGetNonOption(receipt.blockHeight->(Utils.magic: int => string)) - ->getParam("Failed to find block associated to receipt") - items - ->Array.push({ - transactionId: receipt.txId, - block: { - height: block.height, - id: block.id, - time: block.time, - }, - contractId, - receipt: receipt->(Utils.magic: HyperFuelClient.FuelTypes.receipt => FuelSDK.Receipt.t), - receiptIndex: receipt.receiptIndex, - }) - ->ignore - } - } - }) - items - } - - let convertResponse = (res: HyperFuelClient.queryResponseTyped): logsQueryPage => { - let {nextBlock, ?archiveHeight} = res - let page: logsQueryPage = { - items: res.data->decodeLogQueryPageItems, - nextBlock, - archiveHeight: archiveHeight->Option.getOr(0), // TODO: FIXME: Shouldn't have a default here - } - page - } - let query = async ( ~client: HyperFuelClient.t, ~fromBlock, ~toBlock, - ~recieptsSelection, + ~registrationIndexes, + ~addressesByContractName, ): logsQueryPage => { - let query: HyperFuelClient.QueryTypes.query = makeRequestBody( - ~fromBlock, - ~toBlockInclusive=toBlock, - ~recieptsSelection, - ) + let query: HyperFuelClient.EventItems.query = { + fromBlock, + toBlock, + registrationIndexes, + addressesByContractName, + } - let res = switch await client->HyperFuelClient.getSelectedData(query) { + let res = switch await client->HyperFuelClient.getEventItems(query) { | res => res | exception exn => switch exn->extractMissingParams { @@ -174,6 +64,11 @@ module GetLogs = { // Might happen when /height response was from another instance of HyperFuel throw(Error(WrongInstance)) } - res->convertResponse + { + items: res.items, + blocks: res.blocks, + nextBlock: res.nextBlock, + archiveHeight: res.archiveHeight->Option.getOr(0), // TODO: FIXME: Shouldn't have a default here + } } } diff --git a/packages/envio/src/sources/HyperFuel.resi b/packages/envio/src/sources/HyperFuel.resi index 81f7d4232..5797955f1 100644 --- a/packages/envio/src/sources/HyperFuel.resi +++ b/packages/envio/src/sources/HyperFuel.resi @@ -1,25 +1,10 @@ -type hyperSyncPage<'item> = { - items: array<'item>, +type logsQueryPage = { + items: array, + blocks: array, nextBlock: int, archiveHeight: int, } -type block = { - id: string, - time: int, - height: int, -} - -type item = { - transactionId: string, - contractId: Address.t, - receipt: FuelSDK.Receipt.t, - receiptIndex: int, - block: block, -} - -type logsQueryPage = hyperSyncPage - module GetLogs: { type error = | UnexpectedMissingParams({missingParams: array}) @@ -31,6 +16,7 @@ module GetLogs: { ~client: HyperFuelClient.t, ~fromBlock: int, ~toBlock: option, - ~recieptsSelection: array, + ~registrationIndexes: array, + ~addressesByContractName: dict>, ) => promise } diff --git a/packages/envio/src/sources/HyperFuelClient.res b/packages/envio/src/sources/HyperFuelClient.res index 2fb7f8b96..436385477 100644 --- a/packages/envio/src/sources/HyperFuelClient.res +++ b/packages/envio/src/sources/HyperFuelClient.res @@ -4,85 +4,81 @@ type cfg = { url: string, apiToken: string, } -module QueryTypes = { - type blockFieldOptions = - | @as("id") Id - | @as("height") Height - | @as("time") Time - type blockFieldSelection = array +module Registration = { + type kind = + | @as("LogData") LogData + | @as("Mint") Mint + | @as("Burn") Burn + | @as("Transfer") Transfer + | @as("Call") Call - type receiptFieldOptions = - | @as("tx_id") TxId - | @as("block_height") BlockHeight - | @as("to_address") ToAddress - | @as("amount") Amount - | @as("asset_id") AssetId - | @as("val") Val - | @as("rb") Rb - | @as("receipt_type") ReceiptType - | @as("receipt_index") ReceiptIndex - | @as("data") Data - | @as("root_contract_id") RootContractId - | @as("sub_id") SubId - | @as("to") To - - type receiptFieldSelection = array - - type fieldSelection = { - block?: blockFieldSelection, - receipt?: receiptFieldSelection, + // The full per-(event, chain) registration passed to the Rust client at + // construction: routing identity plus the receipt-selection state queries + // are built from. + type input = { + // Chain-scoped sequential registration index, echoed back on routed items. + index: int, + eventName: string, + contractName: string, + isWildcard: bool, + kind: kind, + // The LogData `rb` value as a decimal string; absent for other kinds. + logId?: string, } - type receiptSelection = { - rootContractId?: array, - receiptType?: array, - rb?: array, - txStatus?: array, - } + let fromOnEventRegistrations = ( + onEventRegistrations: array, + ): array => + onEventRegistrations->Array.map(reg => { + let eventConfig = + reg.eventConfig->(Utils.magic: Internal.eventConfig => Internal.fuelEventConfig) + let (kind, logId) = switch eventConfig.kind { + | LogData({logId}) => (LogData, Some(logId)) + | Mint => (Mint, None) + | Burn => (Burn, None) + | Transfer => (Transfer, None) + | Call => (Call, None) + } + { + index: reg.index, + eventName: eventConfig.name, + contractName: eventConfig.contractName, + isWildcard: reg.isWildcard, + kind, + ?logId, + } + }) +} +module EventItems = { + // The whole per-query input: block range, the partition's registration + // selection (by index), and its current addresses. Receipt selections, + // field selection, and routing are derived on the Rust side. type query = { - /** The block to start the query from */ fromBlock: int, - /** - * The block to end the query at. If not specified, the query will go until the - * end of data. Exclusive, the returned range will be [from_block..to_block). - * - * The query will return before it reaches this target block if it hits the time limit - * configured on the server. The user should continue their query by putting the - * next_block field in the response into from_block field of their next query. This implements - * pagination. - */ - @as("toBlock") - toBlockExclusive?: int, - /** - * List of receipt selections, the query will return receipts that match any of these selections and - * it will return receipts that are related to the returned objects. - */ - receipts?: array, - /** - * Field selection. The user can select which fields they are interested in, requesting less fields will improve - * query execution time and reduce the payload size so the user should always use a minimal number of fields. - */ - fieldSelection: fieldSelection, + // Inclusive; None queries to the end of available data. + toBlock: option, + registrationIndexes: array, + addressesByContractName: dict>, } -} -module FuelTypes = { - type receipt = { + // One routed receipt with its kind-specific columns flattened: LogData + // carries `data` (decoded here in JS), Mint/Burn carry `val`/`subId`, + // Transfer/TransferOut/Call carry `amount`/`assetId`/`to` (TransferOut's + // wallet recipient normalised into `to`). + type item = { + onEventRegistrationIndex: int, receiptIndex: int, - rootContractId?: Address.t, txId: string, blockHeight: int, - receiptType: FuelSDK.receiptType, + srcAddress: Address.t, data?: string, - rb?: bigint, - val?: bigint, subId?: string, + val?: bigint, amount?: bigint, assetId?: string, to?: string, - toAddress?: string, } type block = { @@ -90,38 +86,35 @@ module FuelTypes = { height: int, time: int, } -} -type queryResponseDataTyped = { - receipts: array, - blocks: array, -} - -type queryResponseTyped = { - /** Current height of the source HyperFuel instance */ - archiveHeight?: int, - /** - * Next block to query for, the responses are paginated so - * the caller should continue the query from this block if they - * didn't get responses up to the to_block they specified in the Query. - */ - nextBlock: int, - /** Total time it took the HyperFuel instance to execute the query. */ - totalExecutionTime: int, - /** Response data */ - data: queryResponseDataTyped, + type response = { + archiveHeight: option, + nextBlock: int, + // One block per height; items reference them by `blockHeight`. + blocks: array, + items: array, + } } @send -external classNew: (Core.hyperfuelClientCtor, cfg, ~userAgent: string) => t = "new" +external classNew: ( + Core.hyperfuelClientCtor, + cfg, + ~userAgent: string, + array, +) => t = "new" -let make = (cfg: cfg) => { +let make = (cfg: cfg, ~eventRegistrations) => { let envioVersion = Utils.EnvioPackage.value.version - Core.getAddon().hyperfuelClient->classNew(cfg, ~userAgent=`hyperindex/${envioVersion}`) + Core.getAddon().hyperfuelClient->classNew( + cfg, + ~userAgent=`hyperindex/${envioVersion}`, + eventRegistrations, + ) } @send -external getSelectedData: (t, QueryTypes.query) => promise = "getSelectedData" +external getEventItems: (t, EventItems.query) => promise = "getEventItems" @send external getHeight: t => promise = "getHeight" diff --git a/packages/envio/src/sources/HyperFuelSource.res b/packages/envio/src/sources/HyperFuelSource.res index d22f8bd0f..4b8b09935 100644 --- a/packages/envio/src/sources/HyperFuelSource.res +++ b/packages/envio/src/sources/HyperFuelSource.res @@ -1,219 +1,16 @@ open Source -exception EventRoutingFailed - let isUnauthorizedError = (message: string) => message->String.includes("401 Unauthorized") -let mintEventTag = "mint" -let burnEventTag = "burn" -let transferEventTag = "transfer" -let callEventTag = "call" - -type selectionConfig = { - getRecieptsSelection: ( - ~addressesByContractName: dict>, - ) => array, - eventRouter: EventRouter.t, -} - -let logDataReceiptTypeSelection: array = [LogData] - -// only transactions with status 1 (success) -let txStatusSelection = [1] - -let makeGetNormalRecieptsSelection = ( - ~nonWildcardLogDataRbsByContract, - ~nonLogDataReceiptTypesByContract, - ~contractNames, -) => { - (~addressesByContractName) => { - let selection: array = [] - - //Instantiate each time to add new registered contract addresses - contractNames->Utils.Set.forEach(contractName => { - switch addressesByContractName->Utils.Dict.dangerouslyGetNonOption(contractName) { - | None - | Some([]) => () - | Some(addresses) => { - switch nonLogDataReceiptTypesByContract->Utils.Dict.dangerouslyGetNonOption( - contractName, - ) { - | Some(receiptTypes) => - selection - ->Array.push({ - rootContractId: addresses, - receiptType: receiptTypes, - txStatus: txStatusSelection, - }) - ->ignore - | None => () - } - switch nonWildcardLogDataRbsByContract->Utils.Dict.dangerouslyGetNonOption(contractName) { - | None - | Some([]) => () - | Some(nonWildcardLogDataRbs) => - selection - ->Array.push({ - rootContractId: addresses, - receiptType: logDataReceiptTypeSelection, - txStatus: txStatusSelection, - rb: nonWildcardLogDataRbs, - }) - ->ignore - } - } - } - }) - - selection - } -} - -let makeWildcardRecieptsSelection = (~wildcardLogDataRbs, ~nonLogDataWildcardReceiptTypes) => { - let selection: array = [] - - switch nonLogDataWildcardReceiptTypes { - | [] => () - | nonLogDataWildcardReceiptTypes => - selection - ->Array.push( - ( - { - receiptType: nonLogDataWildcardReceiptTypes, - txStatus: txStatusSelection, - }: HyperFuelClient.QueryTypes.receiptSelection - ), - ) - ->ignore - } - - switch wildcardLogDataRbs { - | [] => () - | wildcardLogDataRbs => - selection - ->Array.push( - ( - { - receiptType: logDataReceiptTypeSelection, - txStatus: txStatusSelection, - rb: wildcardLogDataRbs, - }: HyperFuelClient.QueryTypes.receiptSelection - ), - ) - ->ignore - } - - selection -} - -let getSelectionConfig = (selection: FetchState.selection, ~chain) => { - let eventRouter = EventRouter.empty() - let nonWildcardLogDataRbsByContract = Dict.make() - let wildcardLogDataRbs = [] - - // This is for non-LogData events, since they don't have rb filter and can be grouped - let nonLogDataReceiptTypesByContract = Dict.make() - let nonLogDataWildcardReceiptTypes = [] - - let addNonLogDataWildcardReceiptTypes = (receiptType: FuelSDK.receiptType) => { - nonLogDataWildcardReceiptTypes->Array.push(receiptType)->ignore - } - let addNonLogDataReceiptType = (contractName, receiptType: FuelSDK.receiptType) => { - switch nonLogDataReceiptTypesByContract->Utils.Dict.dangerouslyGetNonOption(contractName) { - | None => nonLogDataReceiptTypesByContract->Dict.set(contractName, [receiptType]) - | Some(receiptTypes) => receiptTypes->Array.push(receiptType)->ignore // Duplication prevented by EventRouter - } - } - - let contractNames = Utils.Set.make() - - selection.onEventRegistrations->Array.forEach(reg => { - let eventConfig = - reg.eventConfig->(Utils.magic: Internal.eventConfig => Internal.fuelEventConfig) - let contractName = eventConfig.contractName - let isWildcard = reg.isWildcard - if !isWildcard { - let _ = contractNames->Utils.Set.add(contractName) - } - eventRouter->EventRouter.addOrThrow( - eventConfig.id, - reg, - ~contractName, - ~eventName=eventConfig.name, - ~chain, - ~isWildcard, - ) - - switch (eventConfig.kind, isWildcard) { - | (Mint, true) => addNonLogDataWildcardReceiptTypes(Mint) - | (Mint, false) => addNonLogDataReceiptType(contractName, Mint) - | (Burn, true) => addNonLogDataWildcardReceiptTypes(Burn) - | (Burn, false) => addNonLogDataReceiptType(contractName, Burn) - | (Transfer, true) => { - addNonLogDataWildcardReceiptTypes(Transfer) - addNonLogDataWildcardReceiptTypes(TransferOut) - } - | (Transfer, false) => { - addNonLogDataReceiptType(contractName, Transfer) - addNonLogDataReceiptType(contractName, TransferOut) - } - | (Call, true) => addNonLogDataWildcardReceiptTypes(Call) - | (Call, false) => - JsError.throwWithMessage("Call receipt indexing currently supported only in wildcard mode") - | (LogData({logId}), _) => { - let rb = logId->BigInt.fromStringOrThrow - if isWildcard { - wildcardLogDataRbs->Array.push(rb)->ignore - } else { - switch nonWildcardLogDataRbsByContract->Utils.Dict.dangerouslyGetNonOption(contractName) { - | Some(arr) => arr->Array.push(rb) - | None => nonWildcardLogDataRbsByContract->Dict.set(contractName, [rb]) - } - } - } - } - }) - - { - getRecieptsSelection: switch selection.dependsOnAddresses { - | false => { - let recieptsSelection = makeWildcardRecieptsSelection( - ~wildcardLogDataRbs, - ~nonLogDataWildcardReceiptTypes, - ) - (~addressesByContractName as _) => recieptsSelection - } - | true => - makeGetNormalRecieptsSelection( - ~nonWildcardLogDataRbsByContract, - ~nonLogDataReceiptTypesByContract, - ~contractNames, - ) - }, - eventRouter, - } -} - -let memoGetSelectionConfig = (~chain) => { - let cache = Utils.WeakMap.make() - selection => - switch cache->Utils.WeakMap.get(selection) { - | Some(c) => c - | None => { - let c = selection->getSelectionConfig(~chain) - let _ = cache->Utils.WeakMap.set(selection, c) - c - } - } -} - type options = { chain: ChainMap.Chain.t, endpointUrl: string, apiToken: option, + // The chain's registrations, indexed by their sequential `index`. + onEventRegistrations: array, } -let make = ({chain, endpointUrl, apiToken}: options): t => { +let make = ({chain, endpointUrl, apiToken, onEventRegistrations}: options): t => { let name = "HyperFuel" let apiToken = switch apiToken { @@ -224,19 +21,20 @@ Set the ENVIO_API_TOKEN environment variable in your .env file. Learn more or get a free Envio API token at: https://envio.dev/app/api-tokens`) } - let client = switch HyperFuelClient.make({url: endpointUrl, apiToken}) { + let client = switch HyperFuelClient.make( + {url: endpointUrl, apiToken}, + ~eventRegistrations=HyperFuelClient.Registration.fromOnEventRegistrations(onEventRegistrations), + ) { | client => client | exception exn => exn->ErrorHandling.mkLogAndRaise(~msg="Failed to instantiate the HyperFuel client") } - let getSelectionConfig = memoGetSelectionConfig(~chain) - let getItemsOrThrow = async ( ~fromBlock, ~toBlock, ~addressesByContractName, - ~contractNameByAddress, + ~contractNameByAddress as _, ~knownHeight, ~partitionId as _, ~selection: FetchState.selection, @@ -246,9 +44,6 @@ Learn more or get a free Envio API token at: https://envio.dev/app/api-tokens`) ) => { let totalTimeRef = Performance.now() - let selectionConfig = getSelectionConfig(selection) - let recieptsSelection = selectionConfig.getRecieptsSelection(~addressesByContractName) - let startFetchingBatchTimeRef = Performance.now() //fetch batch @@ -256,7 +51,8 @@ Learn more or get a free Envio API token at: https://envio.dev/app/api-tokens`) ~client, ~fromBlock, ~toBlock, - ~recieptsSelection, + ~registrationIndexes=selection.onEventRegistrations->Array.map(reg => reg.index), + ~addressesByContractName, ) catch { | HyperFuel.GetLogs.Error(error) => throw( @@ -315,56 +111,36 @@ Learn more or get a free Envio API token at: https://envio.dev/app/api-tokens`) let parsingTimeRef = Performance.now() - let parsedQueueItems = pageUnsafe.items->Array.map(item => { - let {contractId: contractAddress, receipt, block, receiptIndex} = item + // Blocks are returned once per height; items reference them by blockHeight. + let blocksByHeight = Utils.Map.make() + pageUnsafe.blocks->Array.forEach(block => { + blocksByHeight->Utils.Map.set(block.height, block)->ignore + }) - let chainId = chain->ChainMap.Chain.toChainId - let eventId = switch receipt { - | LogData({rb}) => BigInt.toString(rb) - | Mint(_) => mintEventTag - | Burn(_) => burnEventTag - | Transfer(_) - | TransferOut(_) => transferEventTag - | Call(_) => callEventTag - } - - let onEventRegistration = switch selectionConfig.eventRouter->EventRouter.get( - ~tag=eventId, - ~contractNameByAddress, - ~contractAddress, - ) { - | None => { - let logger = Logging.createChildFrom( - ~logger, - ~params={ - "chainId": chainId, - "blockNumber": block.height, - "logIndex": receiptIndex, - "contractAddress": contractAddress, - "eventId": eventId, - }, - ) - EventRoutingFailed->ErrorHandling.mkLogAndRaise( - ~msg="Failed to route registered event", - ~logger, - ) - } - | Some(onEventRegistration) => onEventRegistration - } + let chainId = chain->ChainMap.Chain.toChainId + let parsedQueueItems = pageUnsafe.items->Array.map(item => { + // Routing happened in Rust; the item references its registration by + // chain-scoped index. + let onEventRegistration = onEventRegistrations->Array.getUnsafe(item.onEventRegistrationIndex) let eventConfig = onEventRegistration.eventConfig->( Utils.magic: Internal.eventConfig => Internal.fuelEventConfig ) - - let params = switch (eventConfig, receipt) { - | ({kind: LogData({decode})}, LogData({data})) => + // Presence of every routed item's block is validated in Rust. + let block = blocksByHeight->Utils.Map.unsafeGet(item.blockHeight) + + let params = switch eventConfig.kind { + | LogData({decode}) => + // Kind-required columns are validated present in Rust before the item + // crosses the boundary. + let data = item.data->Option.getOr("") try decode(data) catch { | exn => { let params = { "chainId": chainId, - "blockNumber": block.height, - "logIndex": receiptIndex, + "blockNumber": item.blockHeight, + "logIndex": item.receiptIndex, } let logger = Logging.createChildFrom(~logger, ~params) exn->ErrorHandling.mkLogAndRaise( @@ -373,47 +149,28 @@ Learn more or get a free Envio API token at: https://envio.dev/app/api-tokens`) ) } } - | (_, Mint({val, subId})) - | (_, Burn({val, subId})) => + | Mint | Burn => ( { - subId, - amount: val, + subId: item.subId->Option.getOr(""), + amount: item.val->Option.getOr(0n), }: Internal.fuelSupplyParams )->Obj.magic - | (_, Transfer({amount, assetId, to})) => - ( - { - to: to->Address.unsafeFromString, - assetId, - amount, - }: Internal.fuelTransferParams - )->Obj.magic - | (_, TransferOut({amount, assetId, toAddress})) => - ( - { - to: toAddress->Address.unsafeFromString, - assetId, - amount, - }: Internal.fuelTransferParams - )->Obj.magic - | (_, Call({amount, assetId, to})) => + | Transfer | Call => ( { - to: to->Address.unsafeFromString, - assetId, - amount, + to: item.to->Option.getOr("")->Address.unsafeFromString, + assetId: item.assetId->Option.getOr(""), + amount: item.amount->Option.getOr(0n), }: Internal.fuelTransferParams )->Obj.magic - // This should never happen unless there's a bug in the routing logic - | _ => JsError.throwWithMessage("Unexpected bug in the event routing logic") } Internal.Event({ - onEventRegistration: (onEventRegistration :> Internal.onEventRegistration), + onEventRegistration, chain, - blockNumber: block.height, - logIndex: receiptIndex, + blockNumber: item.blockHeight, + logIndex: item.receiptIndex, // Fuel carries the transaction inline on the payload; the store key is // unused (Fuel identifies transactions by hash, kept on the payload). transactionIndex: 0, @@ -423,11 +180,11 @@ Learn more or get a free Envio API token at: https://envio.dev/app/api-tokens`) chainId, params, transaction: { - "id": item.transactionId, + "id": item.txId, }->Obj.magic, // TODO: Obj.magic needed until the field selection types are not configurable for Fuel and Evm separately block: block->Obj.magic, - srcAddress: contractAddress, - logIndex: receiptIndex, + srcAddress: item.srcAddress, + logIndex: item.receiptIndex, }->Fuel.fromPayload, }) }) @@ -436,16 +193,14 @@ Learn more or get a free Envio API token at: https://envio.dev/app/api-tokens`) // Fuel never rolls back on reorg, so block hashes here are purely informational // for detect-only logging via ReorgDetection. - let blockHashes = pageUnsafe.items->Array.map(({block}) => { + let blockHashes = pageUnsafe.blocks->Array.map(block => { ReorgDetection.blockNumber: block.height, blockHash: block.id, }) - let latestFetchedBlockTimestamp = switch pageUnsafe.items->Array.get( - pageUnsafe.items->Array.length - 1, - ) { - | Some({block}) if block.height == heighestBlockQueried => block.time - | _ => 0 + let latestFetchedBlockTimestamp = switch blocksByHeight->Utils.Map.get(heighestBlockQueried) { + | Some(block) => block.time + | None => 0 } let totalTimeElapsed = totalTimeRef->Performance.secondsSince diff --git a/packages/envio/src/sources/SvmHyperSyncClient.res b/packages/envio/src/sources/SvmHyperSyncClient.res index 8d06c85a2..bcafcb1a4 100644 --- a/packages/envio/src/sources/SvmHyperSyncClient.res +++ b/packages/envio/src/sources/SvmHyperSyncClient.res @@ -7,10 +7,78 @@ type cfg = { maxNumRetries?: int, retryBaseMs?: int, retryCeilingMs?: int, - /// Per-program Borsh schema descriptors (JSON, one per program). The Rust - /// client builds these into decoders at creation and decodes matching - /// instructions inline on `get`. - programSchemas?: array, +} + +module Registration = { + type accountFilter = { + position: int, + values: array, + } + + // The full per-(instruction, chain) registration passed to the Rust client + // at construction: routing identity, the fetch state queries are built + // from, and the Borsh schema pieces the client builds decoders from. + type input = { + // Chain-scoped sequential registration index, echoed back on routed items. + index: int, + instructionName: string, + contractName: string, + programId: string, + isWildcard: bool, + discriminator?: string, + discriminatorByteLen: int, + isInner?: bool, + includeLogs: bool, + // DNF: outer array is OR of AND-groups. + accountFilters: array>, + // camelCase Internal.svmTransactionField / svmBlockField names. + transactionFields: array, + blockFields: array, + // Borsh schema pieces; empty accounts + absent argsJson = no schema. + accounts: array, + argsJson?: string, + definedTypesJson?: string, + } + + let fromOnEventRegistrations = ( + onEventRegistrations: array, + ): array => + onEventRegistrations->Array.map(reg => { + let eventConfig = + reg.eventConfig->(Utils.magic: Internal.eventConfig => Internal.svmInstructionEventConfig) + { + index: reg.index, + instructionName: eventConfig.name, + contractName: eventConfig.contractName, + programId: eventConfig.programId->SvmTypes.Pubkey.toString, + isWildcard: reg.isWildcard, + discriminator: ?eventConfig.discriminator, + discriminatorByteLen: eventConfig.discriminatorByteLen, + isInner: ?eventConfig.isInner, + includeLogs: eventConfig.includeLogs, + accountFilters: eventConfig.accountFilters->Array.map(group => + group->Array.map( + (filter): accountFilter => { + position: filter.position, + values: filter.values->SvmTypes.Pubkey.toStrings, + }, + ) + ), + transactionFields: eventConfig.selectedTransactionFields->Utils.Set.toArray, + blockFields: eventConfig.selectedBlockFields + ->(Utils.magic: Utils.Set.t => Utils.Set.t) + ->Utils.Set.toArray, + accounts: eventConfig.accounts, + argsJson: ?switch eventConfig.args { + | JSON.Null => None + | args => Some(args->JSON.stringify) + }, + definedTypesJson: ?switch eventConfig.definedTypes { + | JSON.Null => None + | definedTypes => Some(definedTypes->JSON.stringify) + }, + } + }) } module QueryTypes = { @@ -37,54 +105,7 @@ module QueryTypes = { | @as("loaded_addresses_writable") LoadedAddressesWritable | @as("loaded_addresses_readonly") LoadedAddressesReadonly - type instructionField = - | @as("slot") Slot - | @as("transaction_index") TransactionIndex - | @as("instruction_address") InstructionAddress - | @as("program_id") ProgramId - | @as("accounts") Accounts - | @as("data") Data - | @as("d1") D1 - | @as("d2") D2 - | @as("d4") D4 - | @as("d8") D8 - | @as("a0") A0 - | @as("a1") A1 - | @as("a2") A2 - | @as("a3") A3 - | @as("a4") A4 - | @as("a5") A5 - | @as("a6") A6 - | @as("a7") A7 - | @as("a8") A8 - | @as("a9") A9 - | @as("is_inner") IsInner - | @as("is_committed") IsCommitted - - type logField = - | @as("slot") Slot - | @as("transaction_index") TransactionIndex - | @as("instruction_address") InstructionAddress - | @as("program_id") ProgramId - | @as("kind") Kind - | @as("message") Message - - type tokenBalanceField = - | @as("slot") Slot - | @as("transaction_index") TransactionIndex - | @as("account") Account - | @as("mint") Mint - | @as("owner") Owner - | @as("pre_amount") PreAmount - | @as("post_amount") PostAmount - - type fieldSelection = { - block?: array, - transaction?: array, - instruction?: array, - log?: array, - tokenBalance?: array, - } + type fieldSelection = {block?: array, transaction?: array} /** Filter for selecting instructions. All non-empty fields are AND-ed: an instruction must match at least one value in every non-empty field. @@ -97,43 +118,19 @@ module QueryTypes = { d2?: array, d4?: array, d8?: array, - a0?: array, - a1?: array, - a2?: array, - a3?: array, - a4?: array, - a5?: array, - a6?: array, - a7?: array, - a8?: array, - a9?: array, isInner?: bool, } - type transactionSelection = { - feePayer?: array, - success?: bool, - } - - type logSelection = { - programId?: array, - kind?: array, - } - + // The `get` query surface, used only for block-data range queries; event + // fetching goes through `getEventItems`, which builds its query in Rust. type query = { fromSlot: int, toSlot?: int, instructions?: array, - transactions?: array, - logs?: array, includeAllBlocks?: bool, - includeTokenBalances?: bool, fields?: fieldSelection, maxNumBlocks?: int, - maxNumTransactions?: int, maxNumInstructions?: int, - maxNumLogs?: int, - maxNumTokenBalances?: int, } } @@ -148,13 +145,6 @@ module ResponseTypes = { /// Borsh-decoded view attached by the Rust client. `argsJson`/`accountsJson` /// are stringified to side-step napi-rs's lack of native JSON passthrough. - /** Solana instruction record. - - `data` is the raw instruction byte buffer, hex-encoded with a `0x` prefix. - `d1`..`d8` are the same byte prefix as `data` but truncated to N bytes - (only `Some` when the instruction is at least that long), exposed for - handler-dispatch convenience. - `accounts` is the full positional account list in base58. */ type decodedInstruction = { name: string, argsJson: string, @@ -175,22 +165,11 @@ module ResponseTypes = { d8?: string, isInner: bool, isCommitted: bool, - decoded?: decodedInstruction, - } - - type log = { - slot: int, - transactionIndex?: int, - instructionAddress?: array, - programId?: string, - kind?: string, - message?: string, } type queryResponseData = { blocks: array, instructions: array, - logs: array, } type queryResponse = { @@ -200,18 +179,76 @@ module ResponseTypes = { } } +module EventItems = { + // The whole per-query input: slot range, the partition's registration + // selection (by index), and its current addresses (program ids per program + // name). Instruction selections, field selection, and routing are derived + // on the Rust side. + type query = { + fromSlot: int, + // Inclusive; None queries to the end of available data. + toSlot: option, + maxNumInstructions: int, + registrationIndexes: array, + addressesByContractName: dict>, + } + + type log = { + kind: string, + message: string, + } + + // One routed instruction; `block` and `transaction` are materialised from + // the per-chain stores at batch prep. + type item = { + onEventRegistrationIndex: int, + slot: int, + transactionIndex: int, + instructionAddress: array, + programId: string, + accounts: array, + data: string, + d1?: string, + d2?: string, + d4?: string, + d8?: string, + isInner: bool, + decoded?: ResponseTypes.decodedInstruction, + // Present only when the routed registration opted in via `includeLogs`. + logs?: array, + } + + type response = { + nextSlot: int, + // One lean header per slot referenced by `items`; the full blocks live in + // the block store returned alongside. + blocks: array, + items: array, + } +} + type query = QueryTypes.query type queryResponse = ResponseTypes.queryResponse type t = { getHeight: unit => promise, - // Returns the response plus pages of raw transactions and blocks (kept in - // Rust), keyed by (slot, transactionIndex) / slot, materialised at batch prep. + // Block-data range queries only; the store pages it returns are empty. get: (~query: query) => promise<(queryResponse, TransactionStore.t, BlockStore.t)>, + // Returns the routed items plus pages of raw transactions and blocks (kept + // in Rust), keyed by (slot, transactionIndex) / slot, materialised at batch + // prep. + getEventItems: ( + ~query: EventItems.query, + ) => promise<(EventItems.response, TransactionStore.t, BlockStore.t)>, } @send -external classFromConfig: (Core.svmHypersyncClientCtor, cfg, string) => t = "fromConfig" +external classFromConfig: ( + Core.svmHypersyncClientCtor, + cfg, + string, + array, +) => t = "fromConfig" let make = ( ~url, @@ -220,7 +257,7 @@ let make = ( ~maxNumRetries=?, ~retryBaseMs=?, ~retryCeilingMs=?, - ~programSchemas=?, + ~eventRegistrations=[], ) => { let envioVersion = Utils.EnvioPackage.value.version Core.getAddon().svmHypersyncClient->classFromConfig( @@ -231,8 +268,8 @@ let make = ( ?maxNumRetries, ?retryBaseMs, ?retryCeilingMs, - ?programSchemas, }, `hyperindex/${envioVersion}`, + eventRegistrations, ) } diff --git a/packages/envio/src/sources/SvmHyperSyncSource.res b/packages/envio/src/sources/SvmHyperSyncSource.res index 420d6e094..55315746f 100644 --- a/packages/envio/src/sources/SvmHyperSyncSource.res +++ b/packages/envio/src/sources/SvmHyperSyncSource.res @@ -8,159 +8,15 @@ type options = { clientTimeoutMillis: int, } -// Build HyperSync InstructionSelections from event configs. Each AND-group in -// `cfg.accountFilters` becomes its own selection; selections sharing the same -// `(programId, dN)` are OR-ed by the wire protocol. Empty outer array emits -// one selection with no `aN` set (no account filtering). -// -// Empty programId means the config carries no real program (placeholder), in -// which case we skip — better to over-fetch nothing than ship a degenerate -// query. -let buildInstructionSelections = (eventConfigs: array): array< - SvmHyperSyncClient.QueryTypes.instructionSelection, -> => { - eventConfigs->Array.flatMap(cfg => { - let programIdString = cfg.programId->SvmTypes.Pubkey.toString - if programIdString === "" { - [] - } else { - // Each instruction owns exactly one dN field — the one matching its - // declared byte length. The server-side filter is `d{N} IN [..]`. - let (d1, d2, d4, d8) = switch (cfg.discriminator, cfg.discriminatorByteLen) { - | (Some(d), 1) => (Some([d]), None, None, None) - | (Some(d), 2) => (None, Some([d]), None, None) - | (Some(d), 4) => (None, None, Some([d]), None) - | (Some(d), 8) => (None, None, None, Some([d])) - | _ => (None, None, None, None) - } - let groups = switch cfg.accountFilters { - | [] => [[]] - | gs => gs - } - groups->Array.map(group => { - let pick = position => - group - ->Array.filterMap( - f => f.position == position ? Some(f.values->SvmTypes.Pubkey.toStrings) : None, - ) - ->Array.get(0) - - ( - { - programId: [programIdString], - ?d1, - ?d2, - ?d4, - ?d8, - a0: ?pick(0), - a1: ?pick(1), - a2: ?pick(2), - a3: ?pick(3), - a4: ?pick(4), - a5: ?pick(5), - isInner: ?cfg.isInner, - }: SvmHyperSyncClient.QueryTypes.instructionSelection - ) - }) - } - }) -} - // Synthesize a stable logIndex for an SVM instruction so the FetchState // ordering machinery (which compares by `(blockNumber, logIndex)`) sorts // instructions deterministically within a slot. The bit packing fits inside // JS's 53-bit safe-integer range: transactionIndex ≤ ~10k per slot, // instruction position ≤ 1000 per tx, depth ≤ ~10. Outer-only instructions // land at `tx * 65536`; inner ones append depth-weighted offsets. -let synthLogIndex = (instr: SvmHyperSyncClient.ResponseTypes.instruction) => { - let tx = instr.transactionIndex - let addrSum = instr.instructionAddress->Array.reduce(0, (acc, n) => acc * 1024 + n + 1) - tx * 65536 + addrSum -} - -let serializeInstructionAddress = (addr: array) => - addr->Array.map(n => n->Int.toString)->Array.joinUnsafe(",") - -// Build per-program schema descriptors by grouping eventConfigs by programId, -// returning one descriptor JSON per program. These are handed to the Solana -// client at creation; it builds them into decoders and decodes matching -// instructions inline on `get`. -let buildProgramSchemas = (eventConfigs: array): array< - string, -> => { - // Group by programId base58 string. Skip events that carry no schema - // (accounts == [] && args is JSON.Null && definedTypes is JSON.Null — - // the resolved-empty case from system_config.rs). - let descriptorsByProgram: dict<{ - "programId": string, - "definedTypes": JSON.t, - "instructions": array<{ - "name": string, - "discriminator": string, - "accounts": array, - "args": JSON.t, - }>, - }> = Dict.make() - - eventConfigs->Array.forEach(ec => { - let programIdString = ec.programId->SvmTypes.Pubkey.toString - if programIdString === "" { - // Stage 4 placeholder pattern: skip empty program ids. - () - } else { - let hasSchema = ec.accounts->Array.length > 0 || ec.args !== JSON.Null - let discriminator = ec.discriminator->Option.getOr("") - if hasSchema && discriminator !== "" { - // Inline-schema programs declare no custom types, so `definedTypes` - // arrives as JSON.Null; the Rust descriptor's `#[serde(default)]` only - // covers an absent field, not an explicit null, so coalesce here. - let definedTypes = switch ec.definedTypes { - | JSON.Null => JSON.Object(Dict.make()) - | other => other - } - let existing = descriptorsByProgram->Dict.get(programIdString) - let descriptor = switch existing { - | Some(d) => d - | None => { - "programId": programIdString, - "definedTypes": definedTypes, - "instructions": [], - } - } - let instruction = { - "name": ec.name, - "discriminator": discriminator, - "accounts": ec.accounts, - "args": ec.args, - } - descriptorsByProgram->Dict.set( - programIdString, - { - "programId": descriptor["programId"], - "definedTypes": descriptor["definedTypes"], - "instructions": descriptor["instructions"]->Array.concat([instruction]), - }, - ) - } - } - }) - - descriptorsByProgram - ->Dict.valuesToArray - ->Array.map(descriptor => - descriptor - ->(Utils.magic: { - "programId": string, - "definedTypes": JSON.t, - "instructions": array<{ - "name": string, - "discriminator": string, - "accounts": array, - "args": JSON.t, - }>, - } => JSON.t) - ->JSON.stringify - ) +let synthLogIndex = (~transactionIndex, ~instructionAddress) => { + let addrSum = instructionAddress->Array.reduce(0, (acc, n) => acc * 1024 + n + 1) + transactionIndex * 65536 + addrSum } // Parse the Rust-decoded instruction (args/accounts arrive as JSON strings to @@ -186,234 +42,70 @@ let parseDecoded = ( // `block` is omitted; it's materialised from the block store at batch prep. let toSvmInstruction = ( - instr: SvmHyperSyncClient.ResponseTypes.instruction, + item: SvmHyperSyncClient.EventItems.item, ~programName, ~instructionName, - ~logs, ): Envio.svmInstruction => { programName, instructionName, - programId: instr.programId->SvmTypes.Pubkey.fromStringUnsafe, - data: instr.data, - accounts: instr.accounts->SvmTypes.Pubkey.fromStringsUnsafe, - instructionAddress: instr.instructionAddress, - isInner: instr.isInner, - d1: ?instr.d1, - d2: ?instr.d2, - d4: ?instr.d4, - d8: ?instr.d8, - params: ?(instr.decoded->Option.map(parseDecoded)), - ?logs, -} - -// Probe the discriminator byte-length ordering longest-first. Stops at the -// first router hit. Falls back to the `_none` key (program-wide handler) when -// no discriminator-keyed handler matches. -let probeRouter = ( - router: EventRouter.t, - programId: SvmTypes.Pubkey.t, - instr: SvmHyperSyncClient.ResponseTypes.instruction, - byteLengthsDesc: array, - ~contractAddress, - ~contractNameByAddress, -) => { - let probe = (dN: option) => { - let tag = EventRouter.getSvmEventId(~programId, ~discriminator=dN) - router->EventRouter.get(~tag, ~contractAddress, ~contractNameByAddress) - } - - let result = byteLengthsDesc->Array.reduce(None, (acc, len) => - switch acc { - | Some(_) => acc - | None => - let candidate = switch len { - | 8 => instr.d8 - | 4 => instr.d4 - | 2 => instr.d2 - | 1 => instr.d1 - | _ => None - } - switch candidate { - | Some(_) as d => probe(d) - | None => None - } - } - ) - - switch result { - | Some(_) as hit => hit - | None => probe(None) // program-wide fallback - } + programId: item.programId->SvmTypes.Pubkey.fromStringUnsafe, + data: item.data, + accounts: item.accounts->SvmTypes.Pubkey.fromStringsUnsafe, + instructionAddress: item.instructionAddress, + isInner: item.isInner, + d1: ?item.d1, + d2: ?item.d2, + d4: ?item.d4, + d8: ?item.d8, + params: ?(item.decoded->Option.map(parseDecoded)), + logs: ?( + item.logs->Option.map(logs => + logs->Array.map((log): Envio.svmLog => {kind: log.kind, message: log.message}) + ) + ), } -// Map a selected transaction field to the extra query-side column it needs. -// `transactionIndex` is always fetched as the store key, and `tokenBalances` -// lives in a separate table (requested via `needsTokenBalances`), so neither -// adds a transaction column here. -let toQueryTxField = (field: Internal.svmTransactionField): option< - SvmHyperSyncClient.QueryTypes.transactionField, -> => - switch field { - | TransactionIndex => None - | Signatures => Some(Signatures) - | FeePayer => Some(FeePayer) - | Success => Some(Success) - | Err => Some(Err) - | Fee => Some(Fee) - | ComputeUnitsConsumed => Some(ComputeUnitsConsumed) - | AccountKeys => Some(AccountKeys) - | RecentBlockhash => Some(RecentBlockhash) - | Version => Some(Version) - | TokenBalances => None - } - -// Map a selected block field to its HyperSync query column. Slot/Blockhash/ -// BlockTime are requested unconditionally (needed for reorg detection and the -// item's slot/timestamp), so selecting `slot`/`time`/`hash` adds no extra column. -let toQueryBlockField = (field: Internal.svmBlockField): option< - SvmHyperSyncClient.QueryTypes.blockField, -> => - switch field { - | Slot | Time | Hash => None - | Height => Some(BlockHeight) - | ParentSlot => Some(ParentSlot) - | ParentHash => Some(ParentBlockhash) - } - let make = ( {chain, endpointUrl, apiToken, onEventRegistrations, clientTimeoutMillis}: options, ): t => { let name = "SvmHyperSync" - // Definitions drive query/decode building; registrations drive routing and - // are attached directly to decoded runtime items. - let eventConfigs = - onEventRegistrations->Array.map(reg => - reg.eventConfig->(Utils.magic: Internal.eventConfig => Internal.svmInstructionEventConfig) - ) - - // Built once at startup and handed to the client so `get` decodes matching - // instructions in Rust rather than per-instruction over the napi boundary. - let programSchemas = buildProgramSchemas(eventConfigs) + // The whole per-(instruction, chain) registration set crosses the boundary + // once at construction; the client derives instruction selections, field + // selections, Borsh decoders, and the routing index from it. let client = SvmHyperSyncClient.make( ~url=endpointUrl, ~apiToken?, ~httpReqTimeoutMillis=clientTimeoutMillis, - ~programSchemas=?switch programSchemas { - | [] => None - | arr => Some(arr) - }, + ~eventRegistrations=SvmHyperSyncClient.Registration.fromOnEventRegistrations( + onEventRegistrations, + ), ) - let (eventRouter, programOrderings) = EventRouter.fromSvmEventConfigsOrThrow( - onEventRegistrations, - ~chain, - ) - - // programId.toString -> sorted-desc byte lengths - let orderingByProgram = Dict.make() - programOrderings->Array.forEach(o => - orderingByProgram->Dict.set(o.programId->SvmTypes.Pubkey.toString, o.byteLengthsDesc) - ) - - let needsLogs = eventConfigs->Array.some(cfg => cfg.includeLogs) - - // Union of selected transaction fields across the chain's events. Drives both - // the query column selection (fetch only what's used) and the materialisation - // mask. `slot`/`transactionIndex` are always fetched as the store key. - let selectedTxFields = Utils.Set.make() - eventConfigs->Array.forEach(cfg => - cfg.selectedTransactionFields - ->(Utils.magic: Utils.Set.t => Utils.Set.t) - ->Utils.Set.forEach(field => selectedTxFields->Utils.Set.add(field)->ignore) - ) - let needsTokenBalances = selectedTxFields->Utils.Set.has(Internal.TokenBalances) - let txQueryFields = { - // Slot + TransactionIndex are always fetched so the store can be keyed by - // (slot, transactionIndex). - let fields: array = [Slot, TransactionIndex] - selectedTxFields - ->Utils.Set.toArray - ->Array.forEach(field => - switch toQueryTxField(field) { - | Some(queryField) => fields->Array.push(queryField) - | None => () - } - ) - fields - } - // The transaction table is fetched only when a selected field is actually read - // off a stored transaction record. `transactionIndex` materialises from the - // store key and `tokenBalances` lives in its own table, so neither requires it. - let needsTransactions = - selectedTxFields - ->Utils.Set.toArray - ->Array.some(field => - switch field { - | Internal.TransactionIndex | Internal.TokenBalances => false - | _ => true - } - ) - - // Union of selected block fields across the chain's events. `slot`/`time`/ - // `hash` are always fetched (as Slot/BlockTime/Blockhash); the rest are added - // only when an instruction selected them. - let blockQueryFields = { - let fields: array = [Slot, Blockhash, BlockTime] - let selected = Utils.Set.make() - eventConfigs->Array.forEach(cfg => - cfg.selectedBlockFields->Utils.Set.forEach(field => selected->Utils.Set.add(field)->ignore) - ) - selected->Utils.Set.forEach(field => - switch field->toQueryBlockField { - | Some(queryField) => fields->Array.push(queryField)->ignore - | None => () - } - ) - fields - } - let getItemsOrThrow = async ( ~fromBlock, ~toBlock, - ~addressesByContractName as _, - ~contractNameByAddress, + ~addressesByContractName, + ~contractNameByAddress as _, ~knownHeight, ~partitionId as _, - ~selection as _, + ~selection: FetchState.selection, ~itemsTarget, ~retry, - ~logger, + ~logger as _, ) => { let totalTimeRef = Performance.now() let pageFetchRef = Performance.now() - let instructionSelections = buildInstructionSelections(eventConfigs) - // Under the server's default merge mode, requesting a table's columns is - // what opts the matched result set into that join — a table with an empty - // field list returns no rows (instructions and blocks are exempt), so each - // opted-into table needs its columns spelled out here. - let fields: SvmHyperSyncClient.QueryTypes.fieldSelection = { - block: blockQueryFields, - transaction: ?(needsTransactions ? Some(txQueryFields) : None), - log: ?(needsLogs ? Some([Slot, TransactionIndex, InstructionAddress, Kind, Message]) : None), - tokenBalance: ?( - needsTokenBalances - ? Some([Slot, TransactionIndex, Account, Mint, Owner, PreAmount, PostAmount]) - : None - ), - } - // `toBlock` is inclusive, but `toSlot` is exclusive on the wire — without - // the +1 a bounded range stalls one slot short of its end. - let query: SvmHyperSyncClient.query = { + let query: SvmHyperSyncClient.EventItems.query = { fromSlot: fromBlock, - toSlot: ?(toBlock->Option.map(toBlock => toBlock + 1)), - instructions: instructionSelections, - fields, + toSlot: toBlock, maxNumInstructions: itemsTarget, + registrationIndexes: selection.onEventRegistrations->Array.map(reg => reg.index), + addressesByContractName, } - let (resp, transactionStore, blockStore) = try await client.get(~query) catch { + let (resp, transactionStore, blockStore) = try await client.getEventItems(~query) catch { | exn => throw( Source.GetItemsError( @@ -440,110 +132,38 @@ let make = ( // batch's `latestFetchedBlockTimestamp`. Slots without a block row (rare; // usually skipped slots) fall back to `None`. let blockTimeBySlot = Dict.make() - resp.data.blocks->Array.forEach(b => { + resp.blocks->Array.forEach(b => { switch b.blockTime { | Some(t) => blockTimeBySlot->Dict.set(b.slot->Int.toString, t) | None => () } }) - // Per (slot, transaction_index, instruction_address) lookup for logs - // scoped to a single instruction. `instructionAddress: None` logs are - // attached to no instruction (rare; usually only system messages). - let logsByKey = Dict.make() - resp.data.logs->Array.forEach(log => { - switch (log.transactionIndex, log.instructionAddress) { - | (Some(txIdx), Some(addr)) => - let key = - log.slot->Int.toString ++ - ":" ++ - txIdx->Int.toString ++ - ":" ++ - serializeInstructionAddress(addr) - switch logsByKey->Dict.get(key) { - | Some(existing) => existing->Array.push(log) - | None => logsByKey->Dict.set(key, [log]) - } - | _ => () - } - }) - - let parsedQueueItems = [] - resp.data.instructions->Array.forEach(instr => { - let programId = instr.programId->SvmTypes.Pubkey.fromStringUnsafe - let byteLengths = - orderingByProgram - ->Utils.Dict.dangerouslyGetNonOption(instr.programId) - ->Option.getOr([]) - - let contractAddress = instr.programId->Address.unsafeFromString - let maybeConfig = probeRouter( - eventRouter, - programId, - instr, - byteLengths, - ~contractAddress, - ~contractNameByAddress, - ) - - switch maybeConfig { - | None => () - // Exclude instructions from failed transactions. HyperSync has no - // server-side predicate to filter instructions by parent-transaction - // success (`InstructionSelection` only exposes `is_inner`, and - // instruction/transaction selections union at block level rather than - // joining), so we filter client-side on the `isCommitted` flag HyperSync - // already delivers on every instruction row (a required column, zero extra - // bandwidth). When HyperSync adds a server-side `is_committed` predicate the - // query can push this down and the client-side check becomes a redundant - // safety net. - | Some(_) if !instr.isCommitted => () - | Some(onEventRegistration) => - let eventConfig = - onEventRegistration.eventConfig->( - Utils.magic: Internal.eventConfig => Internal.svmInstructionEventConfig - ) - let logKey = - instr.slot->Int.toString ++ - ":" ++ - instr.transactionIndex->Int.toString ++ - ":" ++ - serializeInstructionAddress(instr.instructionAddress) - let maybeLogs = - logsByKey - ->Utils.Dict.dangerouslyGetNonOption(logKey) - ->Option.map(logs => - logs->Array.map( - (log): Envio.svmLog => { - kind: log.kind->Option.getOr(""), - message: log.message->Option.getOr(""), - }, - ) - ) - - let payload = toSvmInstruction( - instr, - ~programName=eventConfig.contractName, - ~instructionName=eventConfig.name, - ~logs=eventConfig.includeLogs ? maybeLogs : None, + let parsedQueueItems = resp.items->Array.map(item => { + // Routing happened in Rust; the item references its registration by + // chain-scoped index. + let onEventRegistration = onEventRegistrations->Array.getUnsafe(item.onEventRegistrationIndex) + let eventConfig = + onEventRegistration.eventConfig->( + Utils.magic: Internal.eventConfig => Internal.svmInstructionEventConfig ) - - parsedQueueItems - ->Array.push( - Internal.Event({ - onEventRegistration: (onEventRegistration :> Internal.onEventRegistration), - chain, - blockNumber: instr.slot, - logIndex: synthLogIndex(instr), - // The parent transaction is materialised from the store at batch prep. - transactionIndex: instr.transactionIndex, - payload: payload->(Utils.magic: Envio.svmInstruction => Internal.eventPayload), - }), - ) - ->ignore - } - - let _ = logger + let payload = toSvmInstruction( + item, + ~programName=eventConfig.contractName, + ~instructionName=eventConfig.name, + ) + Internal.Event({ + onEventRegistration, + chain, + blockNumber: item.slot, + logIndex: synthLogIndex( + ~transactionIndex=item.transactionIndex, + ~instructionAddress=item.instructionAddress, + ), + // The parent transaction is materialised from the store at batch prep. + transactionIndex: item.transactionIndex, + payload: payload->(Utils.magic: Envio.svmInstruction => Internal.eventPayload), + }) }) let parsingTimeElapsed = parsingRef->Performance.secondsSince @@ -556,7 +176,7 @@ let make = ( // Best-effort (slot, blockhash) pairs from the blocks the server returned // for this range. Gaps (skipped slots, or slots without matched data) are // fine — reorg detection only compares hashes for slots it has observed. - let blockHashes = resp.data.blocks->Array.map((b): ReorgDetection.blockData => { + let blockHashes = resp.blocks->Array.map((b): ReorgDetection.blockData => { blockNumber: b.slot, blockHash: b.blockhash, }) diff --git a/scenarios/fuel_test/test/HyperFuelHeight_test.res b/scenarios/fuel_test/test/HyperFuelHeight_test.res index cb986c07a..4e69d64bb 100644 --- a/scenarios/fuel_test/test/HyperFuelHeight_test.res +++ b/scenarios/fuel_test/test/HyperFuelHeight_test.res @@ -54,6 +54,7 @@ describe("HyperFuelSource - getHeightOrThrow", () => { chain, endpointUrl, apiToken: Some(apiToken), + onEventRegistrations: [], }) let {height} = await source.getHeightOrThrow() @@ -79,6 +80,7 @@ describe("HyperFuelSource - getHeightOrThrow", () => { chain, endpointUrl, apiToken: Some(apiToken), + onEventRegistrations: [], }) let result = await Promise.race([ source.getHeightOrThrow()->Promise.thenResolve(_ => "resolved"), diff --git a/scenarios/fuel_test/test/HyperFuelSource_test.res b/scenarios/fuel_test/test/HyperFuelSource_test.res deleted file mode 100644 index aa6157509..000000000 --- a/scenarios/fuel_test/test/HyperFuelSource_test.res +++ /dev/null @@ -1,814 +0,0 @@ -open Vitest - -// Test-only contract shape: `events` carries full registrations (definition + -// isWildcard/handler/etc.), unlike `Internal.fuelContractConfig.events` which -// is bare definitions. -type mockContract = {name: string, events: array} - -// Builds a per-event registration for the `mock` helpers below. All test -// cases here use filterByAddresses=false and startBlock/handler/contractRegister=None, -// and dependsOnAddresses always follows the shared `!isWildcard` formula since -// none of these mocked events filter by addresses. -let mkRegistration = ( - ~id, - ~name, - ~contractName, - ~kind, - ~isWildcard=false, -): Internal.fuelOnEventRegistration => { - index: -1, - eventConfig: ({ - id, - name, - contractName, - kind, - paramsRawEventSchema: %raw(`"Not relevat"`), - simulateParamsSchema: %raw(`"Not relevat"`), - selectedTransactionFields: Utils.Set.make(), - transactionFieldMask: 0., - blockFieldMask: 0., - }: Internal.fuelEventConfig :> Internal.eventConfig), - isWildcard, - filterByAddresses: false, - dependsOnAddresses: !isWildcard, - startBlock: None, - handler: None, - contractRegister: None, -} - -describe("HyperFuelSource - getNormalRecieptsSelection", () => { - let contractName1 = "TestContract" - let contractName2 = "TestContract2" - let chain = ChainMap.Chain.makeUnsafe(~chainId=0) - let address1 = Address.unsafeFromString("0x1234567890abcdef1234567890abcdef1234567890abcde1") - let address2 = Address.unsafeFromString("0x1234567890abcdef1234567890abcdef1234567890abcde2") - let address3 = Address.unsafeFromString("0x1234567890abcdef1234567890abcdef1234567890abcde3") - - let mock = (~contracts: array) => { - let selectionConfig = { - dependsOnAddresses: true, - onEventRegistrations: contracts->Array.flatMap(c => { - c.events->Array.filterMap( - e => { - if e.isWildcard { - None - } else { - Some((e :> Internal.onEventRegistration)) - } - }, - ) - }), - }->HyperFuelSource.getSelectionConfig(~chain) - selectionConfig.getRecieptsSelection - } - - let mockAddressesByContractName = () => { - Dict.fromArray([(contractName1, [address1, address2]), (contractName2, [address3])]) - } - - it("Receipts Selection with no contracts", t => { - let getNormalRecieptsSelection = mock(~contracts=[]) - t.expect( - getNormalRecieptsSelection(~addressesByContractName=mockAddressesByContractName()), - ).toEqual([]) - }) - - it("Receipts Selection with no events", t => { - let getNormalRecieptsSelection = mock( - ~contracts=[ - { - name: "TestContract", - events: [], - }, - ], - ) - t.expect( - getNormalRecieptsSelection(~addressesByContractName=mockAddressesByContractName()), - ).toEqual([]) - }) - - it("Receipts Selection with single non-wildcard log event", t => { - let getNormalRecieptsSelection = mock( - ~contracts=[ - { - name: "TestContract", - events: [ - mkRegistration( - ~id="1", - ~name="StrLog", - ~contractName="TestContract", - ~kind=LogData({ - logId: "1", - decode: _ => %raw(`null`), - }), - ), - ], - }, - ], - ) - t.expect( - getNormalRecieptsSelection(~addressesByContractName=mockAddressesByContractName()), - ).toEqual([ - { - rb: [1n], - receiptType: [LogData], - rootContractId: [address1, address2], - txStatus: [1], - }, - ]) - }) - - it( - "Receipts Selection with non-wildcard transfer event - catches both TRANSFER and TRANSFER_OUT receipts", - t => { - let getNormalRecieptsSelection = mock( - ~contracts=[ - { - name: "TestContract", - events: [ - mkRegistration( - ~id="Transfer", - ~name="Transfer", - ~contractName="TestContract", - ~kind=Transfer, - ), - ], - }, - { - name: "TestContract2", - events: [ - mkRegistration( - ~id="Transfer", - ~name="Transfer", - ~contractName="TestContract2", - ~kind=Transfer, - ), - ], - }, - ], - ) - t.expect( - getNormalRecieptsSelection(~addressesByContractName=mockAddressesByContractName()), - ).toEqual([ - { - receiptType: [Transfer, TransferOut], - rootContractId: [address1, address2], - txStatus: [1], - }, - { - receiptType: [Transfer, TransferOut], - rootContractId: [address3], - txStatus: [1], - }, - ]) - }, - ) - - it("Receipts Selection with non-wildcard mint event", t => { - let getNormalRecieptsSelection = mock( - ~contracts=[ - { - name: "TestContract", - events: [ - mkRegistration(~id="Mint", ~name="Mint", ~contractName="TestContract", ~kind=Mint), - ], - }, - { - name: "TestContract2", - events: [ - mkRegistration(~id="Mint", ~name="Mint", ~contractName="TestContract2", ~kind=Mint), - ], - }, - ], - ) - t.expect( - getNormalRecieptsSelection(~addressesByContractName=mockAddressesByContractName()), - ).toEqual([ - { - receiptType: [Mint], - rootContractId: [address1, address2], - txStatus: [1], - }, - { - receiptType: [Mint], - rootContractId: [address3], - txStatus: [1], - }, - ]) - }) - - it("Receipts Selection with non-wildcard burn event", t => { - let getNormalRecieptsSelection = mock( - ~contracts=[ - { - name: "TestContract", - events: [ - mkRegistration(~id="Burn", ~name="Burn", ~contractName="TestContract", ~kind=Burn), - ], - }, - { - name: "TestContract2", - events: [ - mkRegistration(~id="Burn", ~name="Burn", ~contractName="TestContract2", ~kind=Burn), - ], - }, - ], - ) - t.expect( - getNormalRecieptsSelection(~addressesByContractName=mockAddressesByContractName()), - ).toEqual([ - { - receiptType: [Burn], - rootContractId: [address1, address2], - txStatus: [1], - }, - { - receiptType: [Burn], - rootContractId: [address3], - txStatus: [1], - }, - ]) - }) - - it("Receipts Selection with all possible events together", t => { - let getNormalRecieptsSelection = mock( - ~contracts=[ - { - name: "TestContract", - events: [ - mkRegistration( - ~id="1", - ~name="StrLog", - ~contractName="TestContract", - ~kind=LogData({ - logId: "1", - decode: _ => %raw(`null`), - }), - ), - mkRegistration( - ~id="2", - ~name="BoolLog", - ~contractName="TestContract", - ~kind=LogData({ - logId: "2", - decode: _ => %raw(`null`), - }), - ~isWildcard=true, - ), - mkRegistration(~id="Mint", ~name="Mint", ~contractName="TestContract", ~kind=Mint), - mkRegistration(~id="Burn", ~name="Burn", ~contractName="TestContract", ~kind=Burn), - mkRegistration( - ~id="Transfer", - ~name="Transfer", - ~contractName="TestContract", - ~kind=Transfer, - ), - mkRegistration( - ~id="Call", - ~name="Call", - ~contractName="TestContract", - ~kind=Call, - ~isWildcard=true, - ), - ], - }, - { - name: "TestContract2", - events: [ - mkRegistration( - ~id="UnitLog", - ~name="UnitLog", - ~contractName="TestContract2", - ~kind=LogData({ - logId: "3", - decode: _ => %raw(`null`), - }), - ), - mkRegistration(~id="Burn", ~name="Burn", ~contractName="TestContract2", ~kind=Burn), - ], - }, - ], - ) - t.expect( - getNormalRecieptsSelection(~addressesByContractName=mockAddressesByContractName()), - ~message=`Note that non-wildcard events should be skipped`, - ).toEqual([ - { - receiptType: [Mint, Burn, Transfer, TransferOut], - rootContractId: [address1, address2], - txStatus: [1], - }, - { - rb: [1n], - receiptType: [LogData], - rootContractId: [address1, address2], - txStatus: [1], - }, - { - receiptType: [Burn], - rootContractId: [address3], - txStatus: [1], - }, - { - rb: [3n], - receiptType: [LogData], - rootContractId: [address3], - txStatus: [1], - }, - ]) - }) - - it("Fails with non-wildcard Call event", t => { - t.expect( - () => { - mock( - ~contracts=[ - { - name: "TestContract", - events: [ - mkRegistration(~id="Call", ~name="Call", ~contractName="TestContract", ~kind=Call), - ], - }, - ], - ) - }, - ).toThrowError("Call receipt indexing currently supported only in wildcard mode") - }) - - it("Fails when contract has multiple mint events", t => { - t.expect( - () => { - mock( - ~contracts=[ - { - name: "TestContract", - events: [ - mkRegistration( - ~id="Mint", - ~name="MyEvent", - ~contractName="TestContract", - ~kind=Mint, - ), - mkRegistration( - ~id="Mint", - ~name="MyEvent2", - ~contractName="TestContract", - ~kind=Mint, - ), - ], - }, - ], - ) - }, - ).toThrowError("Duplicate event detected: MyEvent2 for contract TestContract on chain 0") - }) - - it("Fails when contract has multiple burn events", t => { - t.expect( - () => { - mock( - ~contracts=[ - { - name: "TestContract", - events: [ - mkRegistration( - ~id="Burn", - ~name="MyEvent", - ~contractName="TestContract", - ~kind=Burn, - ), - mkRegistration( - ~id="Burn", - ~name="MyEvent2", - ~contractName="TestContract", - ~kind=Burn, - ), - ], - }, - ], - ) - }, - ).toThrowError("Duplicate event detected: MyEvent2 for contract TestContract on chain 0") - }) - - it( - "Shouldn't fail with contracts having the same wildcard and non-wildcard event. This should be handled when we create FetchState", - t => { - let getNormalRecieptsSelection = mock( - ~contracts=[ - { - name: "TestContract", - events: [ - mkRegistration( - ~id="Mint", - ~name="WildcardMint", - ~contractName="TestContract", - ~kind=Mint, - ~isWildcard=true, - ), - mkRegistration(~id="Mint", ~name="Mint", ~contractName="TestContract", ~kind=Mint), - ], - }, - ], - ) - t.expect( - getNormalRecieptsSelection(~addressesByContractName=mockAddressesByContractName()), - ).toEqual([ - { - receiptType: [Mint], - rootContractId: [address1, address2], - txStatus: [1], - }, - ]) - }, - ) - - it("Works with wildcard mint and non-wildcard mint together in different contract", t => { - let getNormalRecieptsSelection = mock( - ~contracts=[ - { - name: "TestContract2", - events: [ - mkRegistration(~id="Mint", ~name="Mint", ~contractName="TestContract2", ~kind=Mint), - ], - }, - { - name: "TestContract", - events: [ - mkRegistration( - ~id="Mint", - ~name="Mint", - ~contractName="TestContract", - ~kind=Mint, - ~isWildcard=true, - ), - ], - }, - ], - ) - t.expect( - getNormalRecieptsSelection(~addressesByContractName=mockAddressesByContractName()), - ).toEqual([ - { - receiptType: [Mint], - rootContractId: [address3], - txStatus: [1], - }, - ]) - - // The same but with different event registration order - let getNormalRecieptsSelection = mock( - ~contracts=[ - { - name: "TestContract", - events: [ - mkRegistration( - ~id="Mint", - ~name="Mint", - ~contractName="TestContract", - ~kind=Mint, - ~isWildcard=true, - ), - ], - }, - { - name: "TestContract2", - events: [ - mkRegistration(~id="Mint", ~name="Mint", ~contractName="TestContract2", ~kind=Mint), - ], - }, - ], - ) - t.expect( - getNormalRecieptsSelection(~addressesByContractName=mockAddressesByContractName()), - ).toEqual([ - { - receiptType: [Mint], - rootContractId: [address3], - txStatus: [1], - }, - ]) - }) - - it("Works with wildcard burn and non-wildcard burn together in different contract", t => { - let getNormalRecieptsSelection = mock( - ~contracts=[ - { - name: "TestContract2", - events: [ - mkRegistration(~id="Burn", ~name="Burn", ~contractName="TestContract2", ~kind=Burn), - ], - }, - { - name: "TestContract", - events: [ - mkRegistration( - ~id="Burn", - ~name="Burn", - ~contractName="TestContract", - ~kind=Burn, - ~isWildcard=true, - ), - ], - }, - ], - ) - t.expect( - getNormalRecieptsSelection(~addressesByContractName=mockAddressesByContractName()), - ).toEqual([ - { - receiptType: [Burn], - rootContractId: [address3], - txStatus: [1], - }, - ]) - - // The same but with different event registration order - let getNormalRecieptsSelection = mock( - ~contracts=[ - { - name: "TestContract", - events: [ - mkRegistration( - ~id="Burn", - ~name="Burn", - ~contractName="TestContract", - ~kind=Burn, - ~isWildcard=true, - ), - ], - }, - { - name: "TestContract2", - events: [ - mkRegistration(~id="Burn", ~name="Burn", ~contractName="TestContract2", ~kind=Burn), - ], - }, - ], - ) - t.expect( - getNormalRecieptsSelection(~addressesByContractName=mockAddressesByContractName()), - ).toEqual([ - { - receiptType: [Burn], - rootContractId: [address3], - txStatus: [1], - }, - ]) - }) -}) - -describe("HyperFuelSource - makeWildcardRecieptsSelection", () => { - let chain = ChainMap.Chain.makeUnsafe(~chainId=0) - - let mock = (~contracts: array) => { - let selectionConfig = { - dependsOnAddresses: false, - onEventRegistrations: contracts->Array.flatMap(c => { - c.events->Array.filterMap( - e => { - if e.isWildcard { - Some((e :> Internal.onEventRegistration)) - } else { - None - } - }, - ) - }), - }->HyperFuelSource.getSelectionConfig(~chain) - selectionConfig.getRecieptsSelection(~addressesByContractName=Dict.make()) - } - - it("Receipts Selection with no contracts", t => { - let wildcardReceiptsSelection = mock(~contracts=[]) - t.expect( - wildcardReceiptsSelection, - ~message=`It should never happen, since the partition like this wouldn't exist`, - ).toEqual([]) - }) - - it("Receipts Selection with no events", t => { - let wildcardReceiptsSelection = mock( - ~contracts=[ - { - name: "TestContract", - events: [], - }, - ], - ) - t.expect( - wildcardReceiptsSelection, - ~message=`It should never happen, since the partition like this wouldn't exist`, - ).toEqual([]) - }) - - it("Receipts Selection with all possible events together", t => { - let wildcardReceiptsSelection = mock( - ~contracts=[ - { - name: "TestContract", - events: [ - mkRegistration( - ~id="1", - ~name="StrLog", - ~contractName="TestContract", - ~kind=LogData({ - logId: "1", - decode: _ => %raw(`null`), - }), - ), - mkRegistration( - ~id="2", - ~name="BoolLog", - ~contractName="TestContract", - ~kind=LogData({ - logId: "2", - decode: _ => %raw(`null`), - }), - ~isWildcard=true, - ), - mkRegistration(~id="Mint", ~name="Mint", ~contractName="TestContract", ~kind=Mint), - mkRegistration(~id="Burn", ~name="Burn", ~contractName="TestContract", ~kind=Burn), - mkRegistration( - ~id="Transfer", - ~name="Transfer", - ~contractName="TestContract", - ~kind=Transfer, - ), - mkRegistration( - ~id="Call", - ~name="Call", - ~contractName="TestContract", - ~kind=Call, - ~isWildcard=true, - ), - ], - }, - { - name: "TestContract2", - events: [ - mkRegistration( - ~id="3", - ~name="UnitLog", - ~contractName="TestContract2", - ~kind=LogData({ - logId: "3", - decode: _ => %raw(`null`), - }), - ), - mkRegistration(~id="Burn", ~name="Burn", ~contractName="TestContract2", ~kind=Burn), - ], - }, - ], - ) - t.expect( - wildcardReceiptsSelection, - ~message=`Note that wildcard events should be skipped`, - ).toEqual([ - { - receiptType: [Call], - txStatus: [1], - }, - { - rb: [2n], - receiptType: [LogData], - txStatus: [1], - }, - ]) - }) - - it("Works with wildcard mint and non-wildcard mint together in different contract", t => { - let wildcardReceiptsSelection = mock( - ~contracts=[ - { - name: "TestContract2", - events: [ - mkRegistration(~id="Mint", ~name="Mint", ~contractName="TestContract2", ~kind=Mint), - ], - }, - { - name: "TestContract", - events: [ - mkRegistration( - ~id="Mint", - ~name="Mint", - ~contractName="TestContract", - ~kind=Mint, - ~isWildcard=true, - ), - ], - }, - ], - ) - t.expect(wildcardReceiptsSelection).toEqual([ - { - receiptType: [Mint], - txStatus: [1], - }, - ]) - }) - - it("Receipts Selection with wildcard mint event", t => { - let wildcardReceiptsSelection = mock( - ~contracts=[ - { - name: "TestContract", - events: [ - mkRegistration( - ~id="Mint", - ~name="Mint", - ~contractName="TestContract", - ~kind=Mint, - ~isWildcard=true, - ), - ], - }, - ], - ) - t.expect(wildcardReceiptsSelection).toEqual([ - { - receiptType: [Mint], - txStatus: [1], - }, - ]) - }) - - it("Receipts Selection with wildcard burn event", t => { - let wildcardReceiptsSelection = mock( - ~contracts=[ - { - name: "TestContract", - events: [ - mkRegistration( - ~id="Burn", - ~name="Burn", - ~contractName="TestContract", - ~kind=Burn, - ~isWildcard=true, - ), - ], - }, - ], - ) - t.expect(wildcardReceiptsSelection).toEqual([ - { - receiptType: [Burn], - txStatus: [1], - }, - ]) - }) - - it("Receipts Selection with multiple wildcard log event", t => { - let wildcardReceiptsSelection = mock( - ~contracts=[ - { - name: "TestContract", - events: [ - mkRegistration( - ~id="1", - ~name="StrLog", - ~contractName="TestContract", - ~kind=LogData({ - logId: "1", - decode: _ => %raw(`null`), - }), - ~isWildcard=true, - ), - mkRegistration( - ~id="2", - ~name="BoolLog", - ~contractName="TestContract", - ~kind=LogData({ - logId: "2", - decode: _ => %raw(`null`), - }), - ~isWildcard=true, - ), - ], - }, - { - name: "TestContract2", - events: [ - mkRegistration( - ~id="3", - ~name="UnitLog", - ~contractName="TestContract2", - ~kind=LogData({ - logId: "3", - decode: _ => %raw(`null`), - }), - ~isWildcard=true, - ), - ], - }, - ], - ) - t.expect(wildcardReceiptsSelection).toEqual([ - { - rb: [1n, 2n, 3n], - receiptType: [LogData], - txStatus: [1], - }, - ]) - }) -}) diff --git a/scenarios/svm_flow_xray/src/indexer.test.ts b/scenarios/svm_flow_xray/src/indexer.test.ts index 570695a9f..d6041dd12 100644 --- a/scenarios/svm_flow_xray/src/indexer.test.ts +++ b/scenarios/svm_flow_xray/src/indexer.test.ts @@ -1,5 +1,5 @@ // Live E2E test against solana.hypersync.xyz. Drives the SVM stack end-to-end: -// SvmHyperSyncSource -> EventRouter -> indexer.onInstruction dispatch -> +// SvmHyperSyncSource -> Rust-side routing -> indexer.onInstruction dispatch -> // entity writes. The slot window is pinned in config.test.yaml. process.env.ENVIO_CONFIG = "config.test.yaml"; diff --git a/scenarios/svm_metaplex_demo/src/indexer.test.ts b/scenarios/svm_metaplex_demo/src/indexer.test.ts index a50bc7d99..286a44a04 100644 --- a/scenarios/svm_metaplex_demo/src/indexer.test.ts +++ b/scenarios/svm_metaplex_demo/src/indexer.test.ts @@ -1,5 +1,5 @@ // Live E2E test against `solana.hypersync.xyz`. Drives the SVM stack -// end-to-end: SvmHyperSyncSource → EventRouter → `indexer.onInstruction` +// end-to-end: SvmHyperSyncSource → Rust-side routing → `indexer.onInstruction` // dispatch → entity writes. `config.yaml` interpolates `ENVIO_METAPLEX_END_BLOCK` // into `end_block` to pin a finite window here; the live demo leaves it unset // for continuous tailing. diff --git a/scenarios/test_codegen/test/EventRouter_svm_test.res b/scenarios/test_codegen/test/EventRouter_svm_test.res deleted file mode 100644 index 4e0d19439..000000000 --- a/scenarios/test_codegen/test/EventRouter_svm_test.res +++ /dev/null @@ -1,95 +0,0 @@ -open Vitest - -let pubkey = SvmTypes.Pubkey.fromStringUnsafe - -describe("EventRouter SVM helpers", () => { - it("getSvmEventId builds discriminator-keyed tag and falls back to _none", t => { - let programId = pubkey("metaqbxxUerdq28cj1RbAWkYQm3ybzjb6a8bt518x1s") - let actual = { - "withDiscriminator": EventRouter.getSvmEventId( - ~programId, - ~discriminator=Some("0x0f"), - ), - "withoutDiscriminator": EventRouter.getSvmEventId( - ~programId, - ~discriminator=None, - ), - } - t.expect(actual).toEqual({ - "withDiscriminator": "metaqbxxUerdq28cj1RbAWkYQm3ybzjb6a8bt518x1s_0x0f", - "withoutDiscriminator": "metaqbxxUerdq28cj1RbAWkYQm3ybzjb6a8bt518x1s_none", - }) - }) - - it("fromSvmEventConfigsOrThrow precomputes per-program discriminator-length ordering desc", t => { - let chain = ChainMap.Chain.makeUnsafe(~chainId=0) - let progA = pubkey("metaqbxxUerdq28cj1RbAWkYQm3ybzjb6a8bt518x1s") - let progB = pubkey("11111111111111111111111111111111") - let mk = ( - ~name, - ~contractName, - ~programId, - ~discriminator, - ~discriminatorByteLen, - ): Internal.svmOnEventRegistration => { - let eventConfig: Internal.svmInstructionEventConfig = { - id: discriminator->Option.getOr("none"), - name, - contractName, - paramsRawEventSchema: %raw(`null`), - simulateParamsSchema: %raw(`null`), - programId, - discriminator, - discriminatorByteLen, - includeLogs: false, - selectedTransactionFields: Utils.Set.make(), - transactionFieldMask: 0., - selectedBlockFields: Utils.Set.make(), - blockFieldMask: 0., - accountFilters: [], - isInner: None, - accounts: [], - args: JSON.Null, - definedTypes: JSON.Null, - } - EventConfigBuilder.buildSvmOnEventRegistration( - ~eventConfig, - ~isWildcard=false, - ~handler=None, - ~contractRegister=None, - ) - } - let configs = [ - mk( - ~name="One", - ~contractName="ProgA", - ~programId=progA, - ~discriminator=Some("0x0f"), - ~discriminatorByteLen=1, - ), - mk( - ~name="Eight", - ~contractName="ProgA", - ~programId=progA, - ~discriminator=Some("0x0fffffffffffffff"), - ~discriminatorByteLen=8, - ), - mk( - ~name="Wide", - ~contractName="ProgB", - ~programId=progB, - ~discriminator=None, - ~discriminatorByteLen=0, - ), - ] - let (_router, ordering) = EventRouter.fromSvmEventConfigsOrThrow(configs, ~chain) - let byProgram = - ordering - ->Array.map(o => (o.programId->SvmTypes.Pubkey.toString, o.byteLengthsDesc)) - ->Array.toSorted(((a, _), (b, _)) => String.compare(a, b)) - t.expect(byProgram).toEqual([ - ("11111111111111111111111111111111", [0]), - ("metaqbxxUerdq28cj1RbAWkYQm3ybzjb6a8bt518x1s", [8, 1]), - ]) - }) -}) diff --git a/scenarios/test_codegen/test/SourceBlockHashes_test.res b/scenarios/test_codegen/test/SourceBlockHashes_test.res index b90e59aeb..26778b580 100644 --- a/scenarios/test_codegen/test/SourceBlockHashes_test.res +++ b/scenarios/test_codegen/test/SourceBlockHashes_test.res @@ -13,7 +13,7 @@ let chain = ChainMap.Chain.makeUnsafe(~chainId=1) let pairCreatedTopic0 = "0x0d3648bd0f6ba80134a33ba9275ac585d9d315f0ad8355cddefde31afa28d0e9" let pairCreatedEventId = pairCreatedTopic0 ++ "_3" -// Lowercase address so EventRouter lookup matches regardless of whether the +// Lowercase address so routing lookups match regardless of whether the // source returns checksummed or lowercase addresses. let uniswapV2FactoryAddress = "0x5c69bee701ef814a2b6a3edd4b1652cb9cc5aa6f"->Address.unsafeFromString diff --git a/scenarios/test_codegen/test/SvmHyperSyncSource_test.res b/scenarios/test_codegen/test/SvmHyperSyncSource_test.res index 7ca8a5220..6d371d39b 100644 --- a/scenarios/test_codegen/test/SvmHyperSyncSource_test.res +++ b/scenarios/test_codegen/test/SvmHyperSyncSource_test.res @@ -1,13 +1,14 @@ open Vitest // Regression coverage for SvmHyperSyncSource.getItemsOrThrow response -// parsing, driven through a mocked napi client (no network): -// 1. `instruction.block` is omitted on the item; it's materialised from the -// block store at batch prep, which this test doesn't exercise — see -// BlockStore_test.res. -// 2. The query must spell out transaction/log columns when an event config -// opts in — the server returns no rows for a table whose field list is -// empty, so omitting them silently drops `instruction.transaction`. +// parsing, driven through a mocked napi client (no network). Query building, +// routing, and the isCommitted filter live in Rust now (covered by the +// svm_hypersync_source unit tests); this test asserts: +// 1. The per-query input passed to the client: registration indexes, +// addresses, and the inclusive slot range. +// 2. Item building: registrations resolved by index, `block` omitted on the +// payload (materialised from the block store at batch prep), synthesized +// logIndex, and Rust-decoded params parsed from JSON strings. let metaplexProgramId = "metaqbxxUerdq28cj1RbAWkYQm3ybzjb6a8bt518x1s" let chain = ChainMap.Chain.makeUnsafe(~chainId=0) @@ -18,17 +19,7 @@ let blockHash = "99K5yyU2jLxLDeRCJ9YSSMy6VBJTNcnePWUH9uCHAWCB" let makeEventConfig = ( ~selectedBlockFields: array=[], - ~selectedTransactionFields=[ - Internal.Signatures, - FeePayer, - Success, - Err, - Fee, - ComputeUnitsConsumed, - AccountKeys, - RecentBlockhash, - Version, - ], + ~selectedTransactionFields: array=[], ): Internal.svmInstructionEventConfig => { let selectedTransactionFields = Utils.Set.fromArray(selectedTransactionFields)->( @@ -60,51 +51,58 @@ let makeEventConfig = ( } } -let makeReg = (~eventConfig=makeEventConfig()) => - EventConfigBuilder.buildSvmOnEventRegistration( +let makeReg = (~eventConfig=makeEventConfig(), ~index=0) => { + let reg = EventConfigBuilder.buildSvmOnEventRegistration( ~eventConfig, ~isWildcard=false, ~handler=None, ~contractRegister=None, ) + {...reg, Internal.index: index} +} -let mockResponse: SvmHyperSyncClient.ResponseTypes.queryResponse = { +let mockResponse: SvmHyperSyncClient.EventItems.response = { nextSlot: slot + 1, - responseBytes: 0, - data: { - blocks: [ - { - slot, - blockhash: blockHash, - blockTime, - }, - ], - instructions: [ - { - slot, - transactionIndex: 965, - instructionAddress: [1], - programId: metaplexProgramId, - accounts: [], - data: "0x21", - d1: "0x21", - isInner: false, - isCommitted: true, + blocks: [ + { + slot, + blockhash: blockHash, + blockTime, + }, + ], + items: [ + { + onEventRegistrationIndex: 0, + slot, + transactionIndex: 965, + instructionAddress: [1], + programId: metaplexProgramId, + accounts: [], + data: "0x21", + d1: "0x21", + isInner: false, + decoded: { + name: "CreateMetadataAccountV3", + argsJson: `{"amount":"1"}`, + accountsJson: `{"metadata":"${metaplexProgramId}"}`, + extraAccounts: [], }, - ], - logs: [], - }, + }, + ], } -let capturedQueries: array = [] +let capturedQueries: array = [] +let capturedRegistrationInputs: array> = [] let makeMockClient = (~response=mockResponse): SvmHyperSyncClient.t => { getHeight: () => Promise.resolve(slot + 1000), - get: (~query) => { + get: (~query as _) => + JsError.throwWithMessage("get should only be used for block-data queries in tests"), + getEventItems: (~query) => { capturedQueries->Array.push(query) // The real Rust client builds the stores from raw transactions/blocks; the // mock returns empty pages (materialisation is covered by the Rust unit - // tests). This test asserts the item shape and the query columns. + // tests). Promise.resolve(( response, TransactionStore.make(~ecosystem=Ecosystem.Svm, ~shouldChecksum=false), @@ -115,48 +113,6 @@ let makeMockClient = (~response=mockResponse): SvmHyperSyncClient.t => { let mockClient = makeMockClient() -// Two instructions matching the same config, one from a committed transaction -// and one from a failed (uncommitted) transaction. HyperSync delivers both; -// the runtime drops the failed one. -let mixedCommittedResponse: SvmHyperSyncClient.ResponseTypes.queryResponse = { - nextSlot: slot + 1, - responseBytes: 0, - data: { - blocks: [ - { - slot, - blockhash: blockHash, - blockTime, - }, - ], - instructions: [ - { - slot, - transactionIndex: 965, - instructionAddress: [1], - programId: metaplexProgramId, - accounts: [], - data: "0x21", - d1: "0x21", - isInner: false, - isCommitted: true, - }, - { - slot, - transactionIndex: 966, - instructionAddress: [2], - programId: metaplexProgramId, - accounts: [], - data: "0x21", - d1: "0x21", - isInner: false, - isCommitted: false, - }, - ], - logs: [], - }, -} - // The source captures its client at construction, so the mock addon only // needs to be in place for the `make` call; restore the previous addon right // after to avoid leaking the mock into other tests. @@ -166,7 +122,14 @@ let makeSource = (~onEventRegistrations=[makeReg()], ~client=mockClient) => { Some( { "SvmHypersyncClient": { - "fromConfig": (_: SvmHyperSyncClient.cfg, _: string) => client, + "fromConfig": ( + _: SvmHyperSyncClient.cfg, + _: string, + registrations: array, + ) => { + capturedRegistrationInputs->Array.push(registrations) + client + }, }, }->(Utils.magic: {..} => Core.addon), ) @@ -188,226 +151,151 @@ let makeSource = (~onEventRegistrations=[makeReg()], ~client=mockClient) => { let contractNameByAddress = Dict.fromArray([(metaplexProgramId, "TokenMetadata")]) describe("SvmHyperSyncSource.getItemsOrThrow (mocked client)", () => { - Async.it("omits block on the item and requests opted-in table columns", async t => { - let reg = makeReg() - let source = makeSource(~onEventRegistrations=[reg]) - - let response = await source.getItemsOrThrow( - ~fromBlock=slot - 10, - ~toBlock=Some(slot + 10), - ~addressesByContractName=Dict.fromArray([ - ("TokenMetadata", [metaplexProgramId->Address.unsafeFromString]), - ]), - ~contractNameByAddress, - ~knownHeight=slot + 1000, - ~partitionId="0", - ~itemsTarget=5000, - ~selection={ - onEventRegistrations: [reg], - dependsOnAddresses: true, - }, - ~retry=0, - ~logger=Logging.createChild(~params={"test": "SvmHyperSyncSource"}), - ) - - let item = switch response.parsedQueueItems { - | [Internal.Event({blockNumber, payload, onEventRegistration})] => - let instruction = payload->(Utils.magic: Internal.eventPayload => Envio.svmInstruction) - Some({ - "blockNumber": blockNumber, - "block": instruction.block, - "usesSourceRegistration": onEventRegistration === (reg :> Internal.onEventRegistration), - }) - | _ => None - } - - t.expect({ - "item": item, - "query": capturedQueries->Array.getUnsafe(0), - }).toEqual({ - "item": Some({ - "blockNumber": slot, - // `block` is omitted here; it's materialised from the store at batch - // prep, which this test doesn't run. - "block": None, - "usesSourceRegistration": true, - }), - // Default merge mode: requesting a table's columns opts the matched - // result set into that join, so selections carry no include flags. - "query": ( - { - fromSlot: slot - 10, - // Inclusive `toBlock` becomes exclusive `toSlot` on the wire (+1). - toSlot: slot + 11, - instructions: [{programId: [metaplexProgramId], d1: ["0x21"]}], - maxNumInstructions: 5000, - fields: { - block: [Slot, Blockhash, BlockTime], - transaction: [ - Slot, - TransactionIndex, - Signatures, - FeePayer, - Success, - Err, - Fee, - ComputeUnitsConsumed, - AccountKeys, - RecentBlockhash, - Version, - ], - }, - }: SvmHyperSyncClient.query - ), - }) - }) - - // `transactionIndex` is materialised from the store key (the instruction's - // own index), not from a stored transaction record, so selecting it alone does - // not need the transaction table fetched. Async.it( - "does not fetch the transaction table when only transactionIndex is selected", + "passes the selection to the client and builds items by registration index", async t => { - let reg = makeReg(~eventConfig=makeEventConfig(~selectedTransactionFields=[TransactionIndex])) + let reg = makeReg() let source = makeSource(~onEventRegistrations=[reg]) - let _ = await source.getItemsOrThrow( - ~fromBlock=slot - 10, - ~toBlock=Some(slot + 10), - ~addressesByContractName=Dict.fromArray([ - ("TokenMetadata", [metaplexProgramId->Address.unsafeFromString]), - ]), - ~contractNameByAddress, - ~knownHeight=slot + 1000, - ~partitionId="0", - ~selection={ - onEventRegistrations: [reg], - dependsOnAddresses: true, - }, - ~itemsTarget=5000, - ~retry=0, - ~logger=Logging.createChild(~params={"test": "SvmHyperSyncSource"}), - ) - - let query = capturedQueries->Array.getUnsafe(capturedQueries->Array.length - 1) - t.expect(query.fields->Option.flatMap(fields => fields.transaction)).toEqual(None) - }, - ) - - // Selected block fields are added to the query's block columns on top of the - // always-fetched slot/blockhash/blockTime trio. - Async.it("requests the selected block fields' columns", async t => { - let reg = makeReg(~eventConfig=makeEventConfig(~selectedBlockFields=[Height, ParentHash])) - let source = makeSource(~onEventRegistrations=[reg]) - - let _ = await source.getItemsOrThrow( - ~fromBlock=slot - 10, - ~toBlock=Some(slot + 10), - ~addressesByContractName=Dict.fromArray([ + let addressesByContractName = Dict.fromArray([ ("TokenMetadata", [metaplexProgramId->Address.unsafeFromString]), - ]), - ~contractNameByAddress, - ~knownHeight=slot + 1000, - ~partitionId="0", - ~selection={ - onEventRegistrations: [reg], - dependsOnAddresses: true, - }, - ~itemsTarget=5000, - ~retry=0, - ~logger=Logging.createChild(~params={"test": "SvmHyperSyncSource"}), - ) - - let query = capturedQueries->Array.getUnsafe(capturedQueries->Array.length - 1) - let fields: SvmHyperSyncClient.QueryTypes.fieldSelection = query.fields->Option.getUnsafe - t.expect(fields.block).toEqual(Some([Slot, Blockhash, BlockTime, BlockHeight, ParentBlockhash])) - }) - - // Token balances are keyed by account in the store, so the query must always - // carry `account` alongside whatever columns opted the table in — matches - // the Rust store's field_table.rs Table<(slot, txIndex, account)> key. - Async.it( - "requests tokenBalance columns with account included, and skips the transaction table", - async t => { - let reg = makeReg(~eventConfig=makeEventConfig(~selectedTransactionFields=[TokenBalances])) - let source = makeSource(~onEventRegistrations=[reg]) - - let _ = await source.getItemsOrThrow( + ]) + let response = await source.getItemsOrThrow( ~fromBlock=slot - 10, ~toBlock=Some(slot + 10), - ~addressesByContractName=Dict.fromArray([ - ("TokenMetadata", [metaplexProgramId->Address.unsafeFromString]), - ]), + ~addressesByContractName, ~contractNameByAddress, ~knownHeight=slot + 1000, ~partitionId="0", + ~itemsTarget=5000, ~selection={ onEventRegistrations: [reg], dependsOnAddresses: true, }, - ~itemsTarget=5000, ~retry=0, ~logger=Logging.createChild(~params={"test": "SvmHyperSyncSource"}), ) - let query = capturedQueries->Array.getUnsafe(capturedQueries->Array.length - 1) - let fields: SvmHyperSyncClient.QueryTypes.fieldSelection = query.fields->Option.getUnsafe - let expectedTokenBalance: option> = Some([ - Slot, - TransactionIndex, - Account, - Mint, - Owner, - PreAmount, - PostAmount, - ]) + let item = switch response.parsedQueueItems { + | [Internal.Event({blockNumber, logIndex, transactionIndex, payload, onEventRegistration})] => + let instruction = payload->(Utils.magic: Internal.eventPayload => Envio.svmInstruction) + Some({ + "blockNumber": blockNumber, + // tx * 65536 + depth-weighted instruction address offset. + "logIndex": logIndex, + "transactionIndex": transactionIndex, + // `block` is omitted here; it's materialised from the store at batch + // prep, which this test doesn't run. + "block": instruction.block, + "params": instruction.params, + "usesSourceRegistration": onEventRegistration === (reg :> Internal.onEventRegistration), + }) + | _ => None + } + t.expect({ - "tokenBalance": fields.tokenBalance, - "transaction": fields.transaction, + "item": item, + "query": capturedQueries->Array.getUnsafe(0), + "latestFetchedBlockTimestamp": response.latestFetchedBlockTimestamp, + "blockHashes": response.blockHashes, }).toEqual({ - "tokenBalance": expectedTokenBalance, - "transaction": None, + "item": Some({ + "blockNumber": slot, + "logIndex": 965 * 65536 + 2, + "transactionIndex": 965, + "block": None, + "params": Some( + ( + { + name: "CreateMetadataAccountV3", + args: %raw(`{"amount": "1"}`), + accounts: Dict.fromArray([("metadata", metaplexProgramId)]), + extraAccounts: [], + }: Envio.svmInstructionParams + ), + ), + "usesSourceRegistration": true, + }), + // The slot range stays inclusive on the boundary; Rust converts to the + // wire's exclusive `toSlot`. + "query": ( + { + fromSlot: slot - 10, + toSlot: Some(slot + 10), + maxNumInstructions: 5000, + registrationIndexes: [0], + addressesByContractName, + }: SvmHyperSyncClient.EventItems.query + ), + "latestFetchedBlockTimestamp": blockTime, + "blockHashes": [{ReorgDetection.blockNumber: slot, blockHash}], }) }, ) - let deliveredInstructionAddresses = (response: Source.blockRangeFetchResponse) => - response.parsedQueueItems->Array.filterMap(item => - switch item { - | Internal.Event({payload}) => - let instruction = payload->(Utils.magic: Internal.eventPayload => Envio.svmInstruction) - Some(instruction.instructionAddress) - | _ => None - } - ) - - // The failed-tx instruction (address [2], isCommitted: false) is dropped - // before dispatch; only the committed instruction (address [1]) is delivered. - Async.it("drops instructions from failed transactions", async t => { - let reg = makeReg() - let source = makeSource( - ~onEventRegistrations=[reg], - ~client=makeMockClient(~response=mixedCommittedResponse), - ) - - let response = await source.getItemsOrThrow( - ~fromBlock=slot - 10, - ~toBlock=Some(slot + 10), - ~addressesByContractName=Dict.fromArray([ - ("TokenMetadata", [metaplexProgramId->Address.unsafeFromString]), - ]), - ~contractNameByAddress, - ~knownHeight=slot + 1000, - ~partitionId="0", - ~selection={ - onEventRegistrations: [reg], - dependsOnAddresses: true, + // The whole registration set crosses the boundary once at construction — + // selections, field unions, decoders, and routing derive from it in Rust. + it("builds registration inputs from the event configs", t => { + let _ = makeSource(~onEventRegistrations=[makeReg()]) + let inputs = + capturedRegistrationInputs->Array.getUnsafe(capturedRegistrationInputs->Array.length - 1) + t.expect(inputs).toEqual([ + { + index: 0, + instructionName: "CreateMetadataAccountV3", + contractName: "TokenMetadata", + programId: metaplexProgramId, + isWildcard: false, + discriminator: "0x21", + discriminatorByteLen: 1, + includeLogs: false, + accountFilters: [], + transactionFields: [], + blockFields: [], + accounts: [], }, - ~itemsTarget=5000, - ~retry=0, - ~logger=Logging.createChild(~params={"test": "SvmHyperSyncSource"}), - ) + ]) + }) - t.expect(deliveredInstructionAddresses(response)).toEqual([[1]]) + it("stringifies schema pieces and field selections onto registration inputs", t => { + let eventConfig = makeEventConfig( + ~selectedBlockFields=[Height, ParentHash], + ~selectedTransactionFields=[Signatures, TransactionIndex], + ) + let eventConfig = { + ...eventConfig, + accounts: ["metadata", "mint"], + args: %raw(`[{"name": "amount", "type": "u64"}]`), + isInner: Some(false), + accountFilters: [ + [ + { + Internal.position: 1, + values: [metaplexProgramId->SvmTypes.Pubkey.fromStringUnsafe], + }, + ], + ], + } + let _ = makeSource(~onEventRegistrations=[makeReg(~eventConfig)]) + let inputs = + capturedRegistrationInputs->Array.getUnsafe(capturedRegistrationInputs->Array.length - 1) + let input = inputs->Array.getUnsafe(0) + t.expect({ + "accountFilters": input.accountFilters, + "isInner": input.isInner, + "transactionFields": input.transactionFields->Array.toSorted(String.compare), + "blockFields": input.blockFields->Array.toSorted(String.compare), + "accounts": input.accounts, + "argsJson": input.argsJson, + "definedTypesJson": input.definedTypesJson, + }).toEqual({ + "accountFilters": [[{SvmHyperSyncClient.Registration.position: 1, values: [metaplexProgramId]}]], + "isInner": Some(false), + "transactionFields": ["signatures", "transactionIndex"], + "blockFields": ["height", "parentHash"], + "accounts": ["metadata", "mint"], + "argsJson": Some(`[{"name":"amount","type":"u64"}]`), + "definedTypesJson": None, + }) }) }) diff --git a/scenarios/test_codegen/test/lib_tests/EventRouter_test.res b/scenarios/test_codegen/test/lib_tests/EventRouter_test.res deleted file mode 100644 index d8b9e5988..000000000 --- a/scenarios/test_codegen/test/lib_tests/EventRouter_test.res +++ /dev/null @@ -1,207 +0,0 @@ -open Vitest - -let mockChain = ChainMap.Chain.makeUnsafe(~chainId=1) -let mockAddress1 = Envio.TestHelpers.Addresses.mockAddresses[0]->Option.getOrThrow -let mockAddress2 = Envio.TestHelpers.Addresses.mockAddresses[1]->Option.getOrThrow - -let mockFromArray = (array): EventRouter.t<'a> => { - Dict.fromArray(array) -} - -describe("EventRouter", () => { - it("Succeeds on unique insertions", t => { - let router: EventRouter.t = EventRouter.empty() - - router->EventRouter.addOrThrow( - "test-event-tag", - 1, - ~contractName="Contract1", - ~eventName="Event1", - ~chain=mockChain, - ~isWildcard=false, - ) - router->EventRouter.addOrThrow( - "test-event-tag", - 2, - ~contractName="Contract2", - ~eventName="Event1", - ~chain=mockChain, - ~isWildcard=false, - ) - - t.expect(router).toEqual( - mockFromArray([ - ( - "test-event-tag", - { - wildcard: None, - byContractName: Dict.fromArray([("Contract1", 1), ("Contract2", 2)]), - }, - ), - ]), - ) - }) - - it("Fails on duplicate insertions", t => { - let router = EventRouter.empty() - - router->EventRouter.addOrThrow( - "test-event-tag", - 1, - ~contractName="Contract1", - ~eventName="Event1", - ~chain=mockChain, - ~isWildcard=false, - ) - - t.expect( - () => { - router->EventRouter.addOrThrow( - "test-event-tag", - 1, - ~contractName="Contract1", - ~eventName="Event1", - ~chain=mockChain, - ~isWildcard=false, - ) - }, - ).toThrowError("Duplicate event detected: Event1 for contract Contract1 on chain 1") - }) - - it("Fails on duplicate wildcard insertions", t => { - let router = EventRouter.empty() - - router->EventRouter.addOrThrow( - "test-event-tag", - 1, - ~contractName="Contract1", - ~eventName="Event1", - ~chain=mockChain, - ~isWildcard=true, - ) - - t.expect( - () => { - router->EventRouter.addOrThrow( - "test-event-tag", - 1, - ~contractName="Contract2", - ~eventName="Event1", - ~chain=mockChain, - ~isWildcard=true, - ) - }, - ).toThrowError( - "Another event is already registered with the same signature that would interfer with wildcard filtering: Event1 for contract Contract2 on chain 1", - ) - }) - - it("get doesn't returns the correct eventMod without address in mapping if unique", t => { - let router = EventRouter.empty() - - router->EventRouter.addOrThrow( - "test-event-tag", - 1, - ~contractName="Contract1", - ~eventName="Event1", - ~chain=mockChain, - ~isWildcard=false, - ) - - t.expect( - router->EventRouter.get( - ~tag="test-event-tag", - ~contractAddress=mockAddress1, - ~contractNameByAddress=Dict.make(), - ), - ~message=`The address isn't owned by this partition, so it falls back to wildcard (here None)`, - ).toEqual(None) - }) - - it( - "get returns correct event mod with multiple contracts for both wildcard and non wildcard", - t => { - let wildcardContractAddress = mockAddress1 - let nonWildcardContractAddress = mockAddress2 - let nonWildcardContractName = "Contract2" - - let router = EventRouter.empty() - - router->EventRouter.addOrThrow( - "test-event-tag", - "wildcard", - ~contractName="Contract1", - ~eventName="Event1", - ~chain=mockChain, - ~isWildcard=true, - ) - router->EventRouter.addOrThrow( - "test-event-tag", - "non-wildcard", - ~contractName=nonWildcardContractName, - ~eventName="Event1", - ~chain=mockChain, - ~isWildcard=false, - ) - - let contractNameByAddress = Dict.make() - contractNameByAddress->Dict.set( - nonWildcardContractAddress->Address.toString, - nonWildcardContractName, - ) - - t.expect( - router->EventRouter.get( - ~tag="test-event-tag", - ~contractAddress=nonWildcardContractAddress, - ~contractNameByAddress, - ), - ~message="Should return the non wildcard event", - ).toEqual(Some("non-wildcard")) - - t.expect( - router->EventRouter.get( - ~tag="test-event-tag", - ~contractAddress=wildcardContractAddress, - ~contractNameByAddress, - ), - ~message="Should return the wildcard event", - ).toEqual(Some("wildcard")) - }, - ) - - it( - "get falls back to wildcard when the address is indexed for a contract without a matching event", - t => { - // Covers the case where contractNameByAddress maps an address to a - // contract that has no events (persisted for future config changes). - // A wildcard event at that address should still fire. - let indexedAddress = mockAddress1 - let noEventsContractName = "UnknownContract" - - let router = EventRouter.empty() - - router->EventRouter.addOrThrow( - "test-event-tag", - "wildcard", - ~contractName="WildcardContract", - ~eventName="Event1", - ~chain=mockChain, - ~isWildcard=true, - ) - - let contractNameByAddress = Dict.make() - contractNameByAddress->Dict.set(indexedAddress->Address.toString, noEventsContractName) - - t.expect( - router->EventRouter.get( - ~tag="test-event-tag", - ~contractAddress=indexedAddress, - ~contractNameByAddress, - ), - ~message="Should fall back to the wildcard handler when the contract has no registered event for this tag", - ).toEqual(Some("wildcard")) - }, - ) - -}) From a097ab335f1fe14cedeaf9911f241b21379d0ff8 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 21 Jul 2026 14:26:56 +0000 Subject: [PATCH 07/10] Rename HyperSync sources/clients to consistent {Ecosystem}HyperSync naming MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Unify the naming across all three ecosystems to `{Evm|Fuel|Svm}HyperSync{Client|Source}`, fixing the casing drift left after the Fuel/SVM port: - napi client classes: EvmHypersyncClient -> EvmHyperSyncClient, SvmHypersyncClient -> SvmHyperSyncClient, HyperfuelClient -> FuelHyperSyncClient (Rust structs + Core.res @as bindings/field/ctor names + test mock keys). - ReScript modules: HyperSyncSource -> EvmHyperSyncSource, HyperFuelSource -> FuelHyperSyncSource, HyperFuelClient -> FuelHyperSyncClient, HyperFuel -> FuelHyperSync (with .resi). Only code identifiers are renamed; the "HyperFuel"/"HyperSync" product-name strings in error messages, URLs, and source `name` fields are left as the real service names. Dead-code pass: the six Rust "never used" warnings (evm/svm block+transaction field-name fns, encode_indexed_topic, the shared field_names helper) are all `#[napi]` exports consumed from ReScript (drift-protection tests, EventConfigBuilder), so they are live cross-boundary API, not dead code — left in place. ReScript's `warnings.error = "+a"` makes the compiler reject any unused binding, so the ReScript side is dead-code-free by construction. No removals were warranted. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01Myo6vsGNF9ACsAbBV6PXSk --- packages/cli/src/evm_hypersync_source/mod.rs | 8 ++++---- packages/cli/src/fuel_hypersync_source/mod.rs | 8 ++++---- packages/cli/src/svm_hypersync_source/mod.rs | 12 ++++++------ packages/envio/src/ChainState.res | 2 +- packages/envio/src/Core.res | 18 +++++++++--------- packages/envio/src/Internal.res | 2 +- packages/envio/src/sources/EvmChain.res | 2 +- ...erSyncSource.res => EvmHyperSyncSource.res} | 0 .../{HyperFuel.res => FuelHyperSync.res} | 10 +++++----- .../{HyperFuel.resi => FuelHyperSync.resi} | 6 +++--- ...rFuelClient.res => FuelHyperSyncClient.res} | 4 ++-- ...rFuelSource.res => FuelHyperSyncSource.res} | 12 ++++++------ packages/envio/src/sources/HyperSyncClient.res | 4 ++-- .../envio/src/sources/SvmHyperSyncClient.res | 4 ++-- .../fuel_test/test/HyperFuelHeight_test.res | 6 +++--- .../test_codegen/test/HyperSyncClient_test.res | 6 +++--- .../test/SourceBlockHashes_test.res | 2 +- .../test/SvmHyperSyncSource_test.res | 2 +- 18 files changed, 54 insertions(+), 54 deletions(-) rename packages/envio/src/sources/{HyperSyncSource.res => EvmHyperSyncSource.res} (100%) rename packages/envio/src/sources/{HyperFuel.res => FuelHyperSync.res} (87%) rename packages/envio/src/sources/{HyperFuel.resi => FuelHyperSync.resi} (73%) rename packages/envio/src/sources/{HyperFuelClient.res => FuelHyperSyncClient.res} (97%) rename packages/envio/src/sources/{HyperFuelSource.res => FuelHyperSyncSource.res} (93%) diff --git a/packages/cli/src/evm_hypersync_source/mod.rs b/packages/cli/src/evm_hypersync_source/mod.rs index 6a351eee0..7a8b44246 100644 --- a/packages/cli/src/evm_hypersync_source/mod.rs +++ b/packages/cli/src/evm_hypersync_source/mod.rs @@ -43,7 +43,7 @@ fn make_rate_limit_err(info: &hypersync_client::RateLimitInfo) -> napi::Error { } #[napi] -pub struct EvmHypersyncClient { +pub struct EvmHyperSyncClient { inner: hypersync_client::Client, enable_checksum_addresses: bool, decoder: Decoder, @@ -51,13 +51,13 @@ pub struct EvmHypersyncClient { } #[napi] -impl EvmHypersyncClient { +impl EvmHyperSyncClient { #[napi(factory)] pub fn new( cfg: ClientConfig, user_agent: String, event_registrations: Vec, - ) -> napi::Result { + ) -> napi::Result { init_logger(cfg.log_level.as_deref()); let enable_checksum_addresses = cfg.enable_checksum_addresses.unwrap_or_default(); @@ -75,7 +75,7 @@ impl EvmHypersyncClient { .context("build client") .map_err(map_err)?; - Ok(EvmHypersyncClient { + Ok(EvmHyperSyncClient { inner, enable_checksum_addresses, decoder, diff --git a/packages/cli/src/fuel_hypersync_source/mod.rs b/packages/cli/src/fuel_hypersync_source/mod.rs index e94be8e35..540058a19 100644 --- a/packages/cli/src/fuel_hypersync_source/mod.rs +++ b/packages/cli/src/fuel_hypersync_source/mod.rs @@ -14,19 +14,19 @@ use selection::{BuiltSelection, FuelEventKind, FuelOnEventRegistrationInput, Sel use types::{convert_response, Block, ConvertError, RawReceipt}; #[napi] -pub struct HyperfuelClient { +pub struct FuelHyperSyncClient { inner: hyperfuel_client::Client, selection_builder: SelectionBuilder, } #[napi] -impl HyperfuelClient { +impl FuelHyperSyncClient { #[napi(factory)] pub fn new( cfg: ClientConfig, user_agent: String, event_registrations: Vec, - ) -> napi::Result { + ) -> napi::Result { let selection_builder = SelectionBuilder::from_registrations(&event_registrations) .context("build selection builder") .map_err(map_err)?; @@ -35,7 +35,7 @@ impl HyperfuelClient { let inner = hyperfuel_client::Client::new_with_agent(client_config, user_agent) .context("build client") .map_err(map_err)?; - Ok(HyperfuelClient { + Ok(FuelHyperSyncClient { inner, selection_builder, }) diff --git a/packages/cli/src/svm_hypersync_source/mod.rs b/packages/cli/src/svm_hypersync_source/mod.rs index 6c817a8e3..8efa0252c 100644 --- a/packages/cli/src/svm_hypersync_source/mod.rs +++ b/packages/cli/src/svm_hypersync_source/mod.rs @@ -141,20 +141,20 @@ fn build_schemas( } #[napi] -pub struct SvmHypersyncClient { +pub struct SvmHyperSyncClient { inner: Arc, schemas: HashMap, selection_builder: SelectionBuilder, } #[napi] -impl SvmHypersyncClient { +impl SvmHyperSyncClient { #[napi(constructor)] pub fn new( cfg: SvmClientConfig, user_agent: String, event_registrations: Vec, - ) -> napi::Result { + ) -> napi::Result { Self::from_config(cfg, user_agent, event_registrations) } @@ -166,7 +166,7 @@ impl SvmHypersyncClient { cfg: SvmClientConfig, user_agent: String, event_registrations: Vec, - ) -> napi::Result { + ) -> napi::Result { let schemas = build_schemas(&event_registrations) .context("build program schemas") .map_err(map_err)?; @@ -176,7 +176,7 @@ impl SvmHypersyncClient { let inner = hypersync_client_solana::Client::new_with_agent(cfg.into(), user_agent) .context("build solana client") .map_err(map_err)?; - Ok(SvmHypersyncClient { + Ok(SvmHyperSyncClient { inner: Arc::new(inner), schemas, selection_builder, @@ -523,7 +523,7 @@ mod tests { #[tokio::test] #[ignore] async fn live_query_token_metadata() { - let client = SvmHypersyncClient::new( + let client = SvmHyperSyncClient::new( SvmClientConfig { url: "https://solana.hypersync.xyz".into(), ..Default::default() diff --git a/packages/envio/src/ChainState.res b/packages/envio/src/ChainState.res index dabb9aa59..ed1cab375 100644 --- a/packages/envio/src/ChainState.res +++ b/packages/envio/src/ChainState.res @@ -222,7 +222,7 @@ let makeInternal = ( ~lowercaseAddresses, ) | Config.FuelSourceConfig({hypersync}) => [ - HyperFuelSource.make({ + FuelHyperSyncSource.make({ chain, endpointUrl: hypersync, apiToken: Env.envioApiToken, diff --git a/packages/envio/src/Core.res b/packages/envio/src/Core.res index de98af457..23d028a5a 100644 --- a/packages/envio/src/Core.res +++ b/packages/envio/src/Core.res @@ -4,10 +4,10 @@ // NAPI encodes Rust `Option` as `null | T` (never `undefined`), so the // tighter `Null.t` captures the exact boundary shape. -type evmHypersyncClientCtor +type evmHyperSyncClientCtor type evmRpcClientCtor -type svmHypersyncClientCtor -type hyperfuelClientCtor +type svmHyperSyncClientCtor +type fuelHyperSyncClientCtor type transactionStoreCtor type blockStoreCtor type parseConfigYamlOptions = { @@ -22,14 +22,14 @@ type addon = { encodeIndexedTopic: (~abiType: string, ~value: unknown) => EvmTypes.Hex.t, parseConfigYaml: (string, parseConfigYamlOptions) => string, runCli: (~args: array, ~envioPackageDir: Null.t) => promise>, - @as("EvmHypersyncClient") - evmHypersyncClient: evmHypersyncClientCtor, + @as("EvmHyperSyncClient") + evmHyperSyncClient: evmHyperSyncClientCtor, @as("EvmRpcClient") evmRpcClient: evmRpcClientCtor, - @as("SvmHypersyncClient") - svmHypersyncClient: svmHypersyncClientCtor, - @as("HyperfuelClient") - hyperfuelClient: hyperfuelClientCtor, + @as("SvmHyperSyncClient") + svmHyperSyncClient: svmHyperSyncClientCtor, + @as("FuelHyperSyncClient") + fuelHyperSyncClient: fuelHyperSyncClientCtor, @as("TransactionStore") transactionStore: transactionStoreCtor, @as("BlockStore") diff --git a/packages/envio/src/Internal.res b/packages/envio/src/Internal.res index c04efcc53..2a96915bf 100644 --- a/packages/envio/src/Internal.res +++ b/packages/envio/src/Internal.res @@ -179,7 +179,7 @@ type svmBlockField = let allSvmBlockFields: array = [Height, ParentSlot, ParentHash] let svmBlockFieldSchema = S.enum(allSvmBlockFields) -// Static sets of nullable field names — used by RpcSource and HyperSyncSource to wrap schemas with S.nullable +// Static sets of nullable field names — used by RpcSource and EvmHyperSyncSource to wrap schemas with S.nullable let evmNullableBlockFields = Utils.Set.fromArray( ( [ diff --git a/packages/envio/src/sources/EvmChain.res b/packages/envio/src/sources/EvmChain.res index b924576d7..82a1c421f 100644 --- a/packages/envio/src/sources/EvmChain.res +++ b/packages/envio/src/sources/EvmChain.res @@ -48,7 +48,7 @@ let makeSources = ( ) => { let sources = switch hyperSync { | Some(endpointUrl) => [ - HyperSyncSource.make({ + EvmHyperSyncSource.make({ chain, endpointUrl, onEventRegistrations, diff --git a/packages/envio/src/sources/HyperSyncSource.res b/packages/envio/src/sources/EvmHyperSyncSource.res similarity index 100% rename from packages/envio/src/sources/HyperSyncSource.res rename to packages/envio/src/sources/EvmHyperSyncSource.res diff --git a/packages/envio/src/sources/HyperFuel.res b/packages/envio/src/sources/FuelHyperSync.res similarity index 87% rename from packages/envio/src/sources/HyperFuel.res rename to packages/envio/src/sources/FuelHyperSync.res index edc35ecb0..ae894cb8e 100644 --- a/packages/envio/src/sources/HyperFuel.res +++ b/packages/envio/src/sources/FuelHyperSync.res @@ -1,7 +1,7 @@ type logsQueryPage = { - items: array, + items: array, // Blocks referenced by `items`, one per height. - blocks: array, + blocks: array, nextBlock: int, archiveHeight: int, } @@ -39,20 +39,20 @@ module GetLogs = { } let query = async ( - ~client: HyperFuelClient.t, + ~client: FuelHyperSyncClient.t, ~fromBlock, ~toBlock, ~registrationIndexes, ~addressesByContractName, ): logsQueryPage => { - let query: HyperFuelClient.EventItems.query = { + let query: FuelHyperSyncClient.EventItems.query = { fromBlock, toBlock, registrationIndexes, addressesByContractName, } - let res = switch await client->HyperFuelClient.getEventItems(query) { + let res = switch await client->FuelHyperSyncClient.getEventItems(query) { | res => res | exception exn => switch exn->extractMissingParams { diff --git a/packages/envio/src/sources/HyperFuel.resi b/packages/envio/src/sources/FuelHyperSync.resi similarity index 73% rename from packages/envio/src/sources/HyperFuel.resi rename to packages/envio/src/sources/FuelHyperSync.resi index 5797955f1..43dc92636 100644 --- a/packages/envio/src/sources/HyperFuel.resi +++ b/packages/envio/src/sources/FuelHyperSync.resi @@ -1,6 +1,6 @@ type logsQueryPage = { - items: array, - blocks: array, + items: array, + blocks: array, nextBlock: int, archiveHeight: int, } @@ -13,7 +13,7 @@ module GetLogs: { exception Error(error) let query: ( - ~client: HyperFuelClient.t, + ~client: FuelHyperSyncClient.t, ~fromBlock: int, ~toBlock: option, ~registrationIndexes: array, diff --git a/packages/envio/src/sources/HyperFuelClient.res b/packages/envio/src/sources/FuelHyperSyncClient.res similarity index 97% rename from packages/envio/src/sources/HyperFuelClient.res rename to packages/envio/src/sources/FuelHyperSyncClient.res index 436385477..bfc3a3f92 100644 --- a/packages/envio/src/sources/HyperFuelClient.res +++ b/packages/envio/src/sources/FuelHyperSyncClient.res @@ -98,7 +98,7 @@ module EventItems = { @send external classNew: ( - Core.hyperfuelClientCtor, + Core.fuelHyperSyncClientCtor, cfg, ~userAgent: string, array, @@ -106,7 +106,7 @@ external classNew: ( let make = (cfg: cfg, ~eventRegistrations) => { let envioVersion = Utils.EnvioPackage.value.version - Core.getAddon().hyperfuelClient->classNew( + Core.getAddon().fuelHyperSyncClient->classNew( cfg, ~userAgent=`hyperindex/${envioVersion}`, eventRegistrations, diff --git a/packages/envio/src/sources/HyperFuelSource.res b/packages/envio/src/sources/FuelHyperSyncSource.res similarity index 93% rename from packages/envio/src/sources/HyperFuelSource.res rename to packages/envio/src/sources/FuelHyperSyncSource.res index 4b8b09935..d79be286f 100644 --- a/packages/envio/src/sources/HyperFuelSource.res +++ b/packages/envio/src/sources/FuelHyperSyncSource.res @@ -21,9 +21,9 @@ Set the ENVIO_API_TOKEN environment variable in your .env file. Learn more or get a free Envio API token at: https://envio.dev/app/api-tokens`) } - let client = switch HyperFuelClient.make( + let client = switch FuelHyperSyncClient.make( {url: endpointUrl, apiToken}, - ~eventRegistrations=HyperFuelClient.Registration.fromOnEventRegistrations(onEventRegistrations), + ~eventRegistrations=FuelHyperSyncClient.Registration.fromOnEventRegistrations(onEventRegistrations), ) { | client => client | exception exn => @@ -47,14 +47,14 @@ Learn more or get a free Envio API token at: https://envio.dev/app/api-tokens`) let startFetchingBatchTimeRef = Performance.now() //fetch batch - let pageUnsafe = try await HyperFuel.GetLogs.query( + let pageUnsafe = try await FuelHyperSync.GetLogs.query( ~client, ~fromBlock, ~toBlock, ~registrationIndexes=selection.onEventRegistrations->Array.map(reg => reg.index), ~addressesByContractName, ) catch { - | HyperFuel.GetLogs.Error(error) => + | FuelHyperSync.GetLogs.Error(error) => throw( Source.GetItemsError( Source.FailedGettingItems({ @@ -67,7 +67,7 @@ Learn more or get a free Envio API token at: https://envio.dev/app/api-tokens`) | _ => 500 * retry } WithBackoff({ - message: `Block #${fromBlock->Int.toString} not found in HyperFuel. HyperFuel has multiple instances and it's possible that they drift independently slightly from the head. Indexing should continue correctly after retrying the query in ${backoffMillis->Int.toString}ms.`, + message: `Block #${fromBlock->Int.toString} not found in FuelHyperSync. HyperFuel has multiple instances and it's possible that they drift independently slightly from the head. Indexing should continue correctly after retrying the query in ${backoffMillis->Int.toString}ms.`, backoffMillis, }) | UnexpectedMissingParams({missingParams}) => @@ -238,7 +238,7 @@ Learn more or get a free Envio API token at: https://envio.dev/app/api-tokens`) poweredByHyperSync: true, getHeightOrThrow: async () => { let timerRef = Performance.now() - let height = try await client->HyperFuelClient.getHeight catch { + let height = try await client->FuelHyperSyncClient.getHeight catch { | JsExn(e) => switch e->JsExn.message { | Some(message) if message->isUnauthorizedError => diff --git a/packages/envio/src/sources/HyperSyncClient.res b/packages/envio/src/sources/HyperSyncClient.res index 454a25afa..a448ba2cd 100644 --- a/packages/envio/src/sources/HyperSyncClient.res +++ b/packages/envio/src/sources/HyperSyncClient.res @@ -358,11 +358,11 @@ type t = { } @send -external classNew: (Core.evmHypersyncClientCtor, cfg, string, array) => t = +external classNew: (Core.evmHyperSyncClientCtor, cfg, string, array) => t = "new" let makeWithAgent = (cfg, ~userAgent, ~eventRegistrations) => - Core.getAddon().evmHypersyncClient->classNew(cfg, userAgent, eventRegistrations) + Core.getAddon().evmHyperSyncClient->classNew(cfg, userAgent, eventRegistrations) type logLevel = [#trace | #debug | #info | #warn | #error] let logLevelSchema: S.t = S.enum([#trace, #debug, #info, #warn, #error]) diff --git a/packages/envio/src/sources/SvmHyperSyncClient.res b/packages/envio/src/sources/SvmHyperSyncClient.res index bcafcb1a4..598dcfbbe 100644 --- a/packages/envio/src/sources/SvmHyperSyncClient.res +++ b/packages/envio/src/sources/SvmHyperSyncClient.res @@ -244,7 +244,7 @@ type t = { @send external classFromConfig: ( - Core.svmHypersyncClientCtor, + Core.svmHyperSyncClientCtor, cfg, string, array, @@ -260,7 +260,7 @@ let make = ( ~eventRegistrations=[], ) => { let envioVersion = Utils.EnvioPackage.value.version - Core.getAddon().svmHypersyncClient->classFromConfig( + Core.getAddon().svmHyperSyncClient->classFromConfig( { url, ?apiToken, diff --git a/scenarios/fuel_test/test/HyperFuelHeight_test.res b/scenarios/fuel_test/test/HyperFuelHeight_test.res index 4e69d64bb..f83df1255 100644 --- a/scenarios/fuel_test/test/HyperFuelHeight_test.res +++ b/scenarios/fuel_test/test/HyperFuelHeight_test.res @@ -37,7 +37,7 @@ let withServer = async (handler, body) => { } } -describe("HyperFuelSource - getHeightOrThrow", () => { +describe("FuelHyperSyncSource - getHeightOrThrow", () => { let chain = ChainMap.Chain.makeUnsafe(~chainId=0) // The native client validates that the token is a UUID before sending requests. @@ -50,7 +50,7 @@ describe("HyperFuelSource - getHeightOrThrow", () => { res->writeHead(200) res->endWith(`{"height": 123}`) }, async endpointUrl => { - let source = HyperFuelSource.make({ + let source = FuelHyperSyncSource.make({ chain, endpointUrl, apiToken: Some(apiToken), @@ -76,7 +76,7 @@ describe("HyperFuelSource - getHeightOrThrow", () => { res->writeHead(401) res->endWith("Unauthorized") }, async endpointUrl => { - let source = HyperFuelSource.make({ + let source = FuelHyperSyncSource.make({ chain, endpointUrl, apiToken: Some(apiToken), diff --git a/scenarios/test_codegen/test/HyperSyncClient_test.res b/scenarios/test_codegen/test/HyperSyncClient_test.res index d34e453fb..e1376936a 100644 --- a/scenarios/test_codegen/test/HyperSyncClient_test.res +++ b/scenarios/test_codegen/test/HyperSyncClient_test.res @@ -147,10 +147,10 @@ describe("HyperSync client getEventItems (live)", () => { describe("HyperSync client getHeight with corrupted token", () => { // A corrupted token makes the server reply 401, so getHeight throws. The error - // must keep matching HyperSyncSource.isUnauthorizedError, otherwise + // must keep matching EvmHyperSyncSource.isUnauthorizedError, otherwise // getHeightOrThrow's block-forever guard silently stops working. Async.it( - "is detected by HyperSyncSource.isUnauthorizedError", + "is detected by EvmHyperSyncSource.isUnauthorizedError", async t => { let client = HyperSyncClient.make( ~url="https://eth.hypersync.xyz", @@ -164,7 +164,7 @@ describe("HyperSync client getHeight with corrupted token", () => { let _ = await client.getHeight() false } catch { - | JsExn(e) => e->JsExn.message->Option.getOr("")->HyperSyncSource.isUnauthorizedError + | JsExn(e) => e->JsExn.message->Option.getOr("")->EvmHyperSyncSource.isUnauthorizedError | _ => false } diff --git a/scenarios/test_codegen/test/SourceBlockHashes_test.res b/scenarios/test_codegen/test/SourceBlockHashes_test.res index 26778b580..0d17caf56 100644 --- a/scenarios/test_codegen/test/SourceBlockHashes_test.res +++ b/scenarios/test_codegen/test/SourceBlockHashes_test.res @@ -91,7 +91,7 @@ let makeSelection = (): FetchState.selection => { } let makeHyperSyncSource = () => - HyperSyncSource.make({ + EvmHyperSyncSource.make({ chain, endpointUrl: "https://eth.hypersync.xyz", onEventRegistrations: [pairCreatedRegistration], diff --git a/scenarios/test_codegen/test/SvmHyperSyncSource_test.res b/scenarios/test_codegen/test/SvmHyperSyncSource_test.res index 6d371d39b..547a97d8c 100644 --- a/scenarios/test_codegen/test/SvmHyperSyncSource_test.res +++ b/scenarios/test_codegen/test/SvmHyperSyncSource_test.res @@ -121,7 +121,7 @@ let makeSource = (~onEventRegistrations=[makeReg()], ~client=mockClient) => { Core.addonRef := Some( { - "SvmHypersyncClient": { + "SvmHyperSyncClient": { "fromConfig": ( _: SvmHyperSyncClient.cfg, _: string, From e40f49ed6934128e5ee7e1fa06ffc6aa3f1bc208 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 21 Jul 2026 15:18:26 +0000 Subject: [PATCH 08/10] Address PR review: SVM validation key, comment, typo - Scope the SVM wildcard/duplicate validation key by (programId, discriminator) instead of the discriminator alone. SVM eventConfig.id is only the discriminator (or "none"), so two wildcard handlers on different programs sharing a discriminator were falsely rejected as a wildcard collision even though Rust routing scopes matches by program_id (Codex P2). - Drop the caller list from the nullable-field-names comment in Internal.res. - Fix "interfer" -> "interfere" in the wildcard-collision error message. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01Myo6vsGNF9ACsAbBV6PXSk --- packages/envio/src/HandlerRegister.res | 19 +++++++++++++++++-- packages/envio/src/Internal.res | 2 +- 2 files changed, 18 insertions(+), 3 deletions(-) diff --git a/packages/envio/src/HandlerRegister.res b/packages/envio/src/HandlerRegister.res index 0a584d961..5a77b0214 100644 --- a/packages/envio/src/HandlerRegister.res +++ b/packages/envio/src/HandlerRegister.res @@ -625,7 +625,7 @@ let finishRegistration = (~config: Config.t): registrationsByChainId => { } if isWildcard && wildcardEventIds->Utils.Set.has(eventId) { JsError.throwWithMessage( - `Another event is already registered with the same signature that would interfer with wildcard filtering: ${eventName} for contract ${contractName} ${chainSuffix}`, + `Another event is already registered with the same signature that would interfere with wildcard filtering: ${eventName} for contract ${contractName} ${chainSuffix}`, ) } if isWildcard { @@ -650,8 +650,23 @@ let finishRegistration = (~config: Config.t): registrationsByChainId => { ), ) + // SVM dispatch is keyed by (programId, discriminator), not the + // discriminator alone — `eventConfig.id` is only the + // discriminator (or "none"). Scope the SVM validation key by + // programId so two wildcard handlers on different programs that + // share a discriminator aren't rejected as a false collision + // (Rust routing already scopes matches by program_id). + let eventId = switch config.ecosystem.name { + | Svm => + let svmEventConfig = + eventConfig->( + Utils.magic: Internal.eventConfig => Internal.svmInstructionEventConfig + ) + `${svmEventConfig.programId->SvmTypes.Pubkey.toString}_${eventConfig.id}` + | Evm | Fuel => eventConfig.id + } validateEventIdOrThrow( - ~eventId=eventConfig.id, + ~eventId, ~contractName, ~eventName, ~isWildcard=switch registration { diff --git a/packages/envio/src/Internal.res b/packages/envio/src/Internal.res index 2a96915bf..cd0846270 100644 --- a/packages/envio/src/Internal.res +++ b/packages/envio/src/Internal.res @@ -179,7 +179,7 @@ type svmBlockField = let allSvmBlockFields: array = [Height, ParentSlot, ParentHash] let svmBlockFieldSchema = S.enum(allSvmBlockFields) -// Static sets of nullable field names — used by RpcSource and EvmHyperSyncSource to wrap schemas with S.nullable +// Static sets of field names whose source schemas must be wrapped with S.nullable. let evmNullableBlockFields = Utils.Set.fromArray( ( [ From 8b1c124dcb5f0e57251be5038aa84311c9329dc6 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 22 Jul 2026 10:05:05 +0000 Subject: [PATCH 09/10] Address review: rb typing, panic guards, restored validation tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fuel: replace `Registration.kind: FuelEventKind` + `rb: Option` with an internal `RegistrationKind` enum whose `LogData` variant carries `rb`, making the invalid states unrepresentable and dropping the `unwrap_or_default`. - Guard integer overflow on the inclusive→exclusive `to_block`/`to_slot` conversion (checked_add), and make SVM `hex_to_bytes` slice on char boundaries so a malformed discriminator returns an error instead of panicking. - Extract the per-chain dispatch-id validation out of `finishRegistration` into `makeEventIdValidator`/`validateEventIdOrThrow` and restore unit tests for duplicate-event and wildcard-collision detection (replacing the deleted EventRouter tests), including program-scoped SVM ids. - Restore `tx_status = [1]` coverage in the Fuel selection tests and add a focused test asserting every built selection filters to successful txs. - Rename the stale HyperFuelHeight test file to FuelHyperSyncSourceHeight. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01E887S2x7EfB1hcVvvVLVz3 --- packages/cli/src/fuel_hypersync_source/mod.rs | 17 ++- .../src/fuel_hypersync_source/selection.rs | 124 +++++++++++++----- packages/cli/src/svm_hypersync_source/mod.rs | 12 +- .../test/HandlerRegisterValidation_test.res | 55 ++++++++ packages/envio/src/HandlerRegister.res | 86 +++++++----- packages/envio/src/HandlerRegister.resi | 11 ++ ...res => FuelHyperSyncSourceHeight_test.res} | 0 7 files changed, 231 insertions(+), 74 deletions(-) create mode 100644 packages/envio-tests/test/HandlerRegisterValidation_test.res rename scenarios/fuel_test/test/{HyperFuelHeight_test.res => FuelHyperSyncSourceHeight_test.res} (100%) diff --git a/packages/cli/src/fuel_hypersync_source/mod.rs b/packages/cli/src/fuel_hypersync_source/mod.rs index 540058a19..ba399d5fa 100644 --- a/packages/cli/src/fuel_hypersync_source/mod.rs +++ b/packages/cli/src/fuel_hypersync_source/mod.rs @@ -10,7 +10,9 @@ mod types; use config::ClientConfig; use hyperfuel_client::net_types; -use selection::{BuiltSelection, FuelEventKind, FuelOnEventRegistrationInput, SelectionBuilder}; +use selection::{ + BuiltSelection, FuelEventKind, FuelOnEventRegistrationInput, RegistrationKind, SelectionBuilder, +}; use types::{convert_response, Block, ConvertError, RawReceipt}; #[napi] @@ -141,7 +143,12 @@ fn build_query( let to_block = params .to_block // Inclusive on the boundary, exclusive on the wire. - .map(|b| u64::try_from(b + 1).context("to_block must be >= 0")) + .map(|b| { + u64::try_from(b) + .context("to_block must be >= 0")? + .checked_add(1) + .context("to_block overflow") + }) .transpose()?; let mut receipt_fields = vec![ @@ -229,7 +236,7 @@ fn route_receipts( value.map(BigInt::from) }; let item = match reg.kind { - FuelEventKind::LogData => EventItem { + RegistrationKind::LogData { .. } => EventItem { on_event_registration_index: reg.index, receipt_index: receipt.receipt_index, tx_id: receipt.tx_id.clone(), @@ -242,7 +249,7 @@ fn route_receipts( asset_id: None, to: None, }, - FuelEventKind::Mint | FuelEventKind::Burn => EventItem { + RegistrationKind::Mint | RegistrationKind::Burn => EventItem { on_event_registration_index: reg.index, receipt_index: receipt.receipt_index, tx_id: receipt.tx_id.clone(), @@ -255,7 +262,7 @@ fn route_receipts( asset_id: None, to: None, }, - FuelEventKind::Transfer | FuelEventKind::Call => { + RegistrationKind::Transfer | RegistrationKind::Call => { // TransferOut receipts carry the wallet recipient in // `to_address`; everything else uses `to`. let (recipient, recipient_name) = diff --git a/packages/cli/src/fuel_hypersync_source/selection.rs b/packages/cli/src/fuel_hypersync_source/selection.rs index e0f9fa888..3aa883e3c 100644 --- a/packages/cli/src/fuel_hypersync_source/selection.rs +++ b/packages/cli/src/fuel_hypersync_source/selection.rs @@ -29,14 +29,28 @@ pub enum FuelEventKind { Call, } -impl FuelEventKind { +/// Internal per-registration kind. Unlike the `FuelEventKind` boundary enum, +/// the `LogData` variant carries its parsed `rb`, so a LogData registration +/// can't exist without one and no other kind can carry a stray rb — the +/// invalid states the napi input's `kind`+`log_id` pair could express are +/// resolved once, at construction. +#[derive(Clone, Copy)] +pub(crate) enum RegistrationKind { + LogData { rb: u64 }, + Mint, + Burn, + Transfer, + Call, +} + +impl RegistrationKind { fn receipt_types(&self) -> &'static [u8] { match self { - FuelEventKind::LogData => &[RECEIPT_LOG_DATA], - FuelEventKind::Mint => &[RECEIPT_MINT], - FuelEventKind::Burn => &[RECEIPT_BURN], - FuelEventKind::Transfer => &[RECEIPT_TRANSFER, RECEIPT_TRANSFER_OUT], - FuelEventKind::Call => &[RECEIPT_CALL], + RegistrationKind::LogData { .. } => &[RECEIPT_LOG_DATA], + RegistrationKind::Mint => &[RECEIPT_MINT], + RegistrationKind::Burn => &[RECEIPT_BURN], + RegistrationKind::Transfer => &[RECEIPT_TRANSFER, RECEIPT_TRANSFER_OUT], + RegistrationKind::Call => &[RECEIPT_CALL], } } } @@ -62,9 +76,7 @@ pub(crate) struct Registration { pub index: i64, pub contract_name: String, pub is_wildcard: bool, - pub kind: FuelEventKind, - /// Parsed `log_id`; `Some` exactly for LogData registrations. - pub rb: Option, + pub kind: RegistrationKind, } impl Registration { @@ -78,7 +90,9 @@ impl Registration { contract_name: Option<&str>, ) -> bool { let kind_matches = match self.kind { - FuelEventKind::LogData => receipt_type == RECEIPT_LOG_DATA && rb == self.rb, + RegistrationKind::LogData { rb: reg_rb } => { + receipt_type == RECEIPT_LOG_DATA && rb == Some(reg_rb) + } kind => kind.receipt_types().contains(&receipt_type), }; kind_matches && (self.is_wildcard || contract_name == Some(self.contract_name.as_str())) @@ -129,30 +143,32 @@ impl SelectionBuilder { ) -> Result { let mut map = HashMap::new(); for reg in registrations { - let rb = match reg.kind { + let kind = match reg.kind { FuelEventKind::LogData => { let log_id = reg.log_id.as_ref().with_context(|| { format!("LogData registration {} is missing logId", reg.event_name) })?; - Some(log_id.parse::().with_context(|| { + let rb = log_id.parse::().with_context(|| { format!("parse logId {} for event {}", log_id, reg.event_name) - })?) + })?; + RegistrationKind::LogData { rb } } FuelEventKind::Call => { anyhow::ensure!( reg.is_wildcard, "Call receipt indexing currently supported only in wildcard mode" ); - None + RegistrationKind::Call } - _ => None, + FuelEventKind::Mint => RegistrationKind::Mint, + FuelEventKind::Burn => RegistrationKind::Burn, + FuelEventKind::Transfer => RegistrationKind::Transfer, }; let parsed = Registration { index: reg.index, contract_name: reg.contract_name.clone(), is_wildcard: reg.is_wildcard, - kind: reg.kind, - rb, + kind, }; anyhow::ensure!( map.insert(reg.index, std::sync::Arc::new(parsed)).is_none(), @@ -193,20 +209,18 @@ impl SelectionBuilder { .with_context(|| format!("Unknown registration index {id} in query selection"))?; registrations.push(reg.clone()); match reg.kind { - FuelEventKind::LogData => needs_log_data = true, - FuelEventKind::Mint | FuelEventKind::Burn => needs_supply = true, - FuelEventKind::Transfer => needs_transfer = true, - FuelEventKind::Call => needs_call = true, + RegistrationKind::LogData { .. } => needs_log_data = true, + RegistrationKind::Mint | RegistrationKind::Burn => needs_supply = true, + RegistrationKind::Transfer => needs_transfer = true, + RegistrationKind::Call => needs_call = true, } match (reg.kind, reg.is_wildcard) { - (FuelEventKind::LogData, true) => { - push_unique(&mut wildcard_rbs, reg.rb.unwrap_or_default()) - } - (FuelEventKind::LogData, false) => push_unique( + (RegistrationKind::LogData { rb }, true) => push_unique(&mut wildcard_rbs, rb), + (RegistrationKind::LogData { rb }, false) => push_unique( rbs_by_contract .entry(reg.contract_name.as_str()) .or_default(), - reg.rb.unwrap_or_default(), + rb, ), (kind, true) => { for &receipt_type in kind.receipt_types() { @@ -329,11 +343,14 @@ mod tests { .collect() } - fn selection_view(s: &net_types::ReceiptSelection) -> (Vec, Vec, Vec) { + fn selection_view( + s: &net_types::ReceiptSelection, + ) -> (Vec, Vec, Vec, Vec) { ( s.root_contract_id.iter().map(Hex::encode_hex).collect(), s.receipt_type.clone(), s.rb.clone(), + s.tx_status.clone(), ) } @@ -370,14 +387,26 @@ mod tests { RECEIPT_TRANSFER_OUT ], vec![], + vec![TX_STATUS_SUCCESS], ), ( vec![ADDR_1.to_string(), ADDR_2.to_string()], vec![RECEIPT_LOG_DATA], vec![1], + vec![TX_STATUS_SUCCESS], + ), + ( + vec![ADDR_3.to_string()], + vec![RECEIPT_BURN], + vec![], + vec![TX_STATUS_SUCCESS], + ), + ( + vec![ADDR_3.to_string()], + vec![RECEIPT_LOG_DATA], + vec![3], + vec![TX_STATUS_SUCCESS], ), - (vec![ADDR_3.to_string()], vec![RECEIPT_BURN], vec![]), - (vec![ADDR_3.to_string()], vec![RECEIPT_LOG_DATA], vec![3]), ] ); assert_eq!( @@ -402,8 +431,13 @@ mod tests { .map(selection_view) .collect::>(), vec![ - (vec![], vec![RECEIPT_CALL], vec![]), - (vec![], vec![RECEIPT_LOG_DATA], vec![2, 3]), + (vec![], vec![RECEIPT_CALL], vec![], vec![TX_STATUS_SUCCESS]), + ( + vec![], + vec![RECEIPT_LOG_DATA], + vec![2, 3], + vec![TX_STATUS_SUCCESS], + ), ] ); } @@ -433,7 +467,12 @@ mod tests { .iter() .map(selection_view) .collect::>(), - vec![(vec![ADDR_1.to_string()], vec![RECEIPT_BURN], vec![])] + vec![( + vec![ADDR_1.to_string()], + vec![RECEIPT_BURN], + vec![], + vec![TX_STATUS_SUCCESS] + )] ); } @@ -521,4 +560,25 @@ mod tests { .collect(); assert_eq!(routed, vec![1]); } + + #[test] + fn every_selection_filters_to_successful_tx_status() { + // Only receipts from successful transactions are indexed, so every + // built selection — wildcard, contract-bound receipt types, and + // contract-bound rb — must carry `tx_status = [1]`. + let builder = SelectionBuilder::from_registrations(&[ + reg(0, "C1", FuelEventKind::Mint, false, None), + reg(1, "C1", FuelEventKind::LogData, false, Some("1")), + reg(2, "W", FuelEventKind::Call, true, None), + reg(3, "W", FuelEventKind::LogData, true, Some("2")), + ]) + .unwrap(); + let built = builder + .build(&[0, 1, 2, 3], &addresses(&[("C1", &[ADDR_1])])) + .unwrap(); + assert!(built + .receipt_selections + .iter() + .all(|s| s.tx_status == vec![TX_STATUS_SUCCESS])); + } } diff --git a/packages/cli/src/svm_hypersync_source/mod.rs b/packages/cli/src/svm_hypersync_source/mod.rs index 8efa0252c..86ef3df8a 100644 --- a/packages/cli/src/svm_hypersync_source/mod.rs +++ b/packages/cli/src/svm_hypersync_source/mod.rs @@ -23,8 +23,9 @@ pub(crate) mod mod_helpers { (0..s.len()) .step_by(2) .map(|i| { - u8::from_str_radix(&s[i..i + 2], 16) - .map_err(|_| anyhow!("invalid hex byte at offset {i} in '{input}'")) + s.get(i..i + 2) + .and_then(|byte| u8::from_str_radix(byte, 16).ok()) + .ok_or_else(|| anyhow!("invalid hex byte at offset {i} in '{input}'")) }) .collect() } @@ -285,7 +286,12 @@ impl SvmHyperSyncClient { // Inclusive on the boundary, exclusive on the wire. to_slot: params .to_slot - .map(|b| u64::try_from(b + 1).context("to_slot must be non-negative")) + .map(|b| { + u64::try_from(b) + .context("to_slot must be non-negative")? + .checked_add(1) + .context("to_slot overflow") + }) .transpose() .map_err(map_err)?, instructions: built.instruction_selections.clone(), diff --git a/packages/envio-tests/test/HandlerRegisterValidation_test.res b/packages/envio-tests/test/HandlerRegisterValidation_test.res new file mode 100644 index 000000000..e0d9c87cc --- /dev/null +++ b/packages/envio-tests/test/HandlerRegisterValidation_test.res @@ -0,0 +1,55 @@ +open Vitest + +// Covers `HandlerRegister.validateEventIdOrThrow`, the per-chain dispatch-id +// guard `finishRegistration` runs so a single log/receipt/instruction can't fan +// out to two events on one contract, or to two wildcards sharing a signature — +// Rust-side routing dispatches by these ids, so a collision double-delivers. +// Replaces the deleted EventRouter unit tests now that routing lives in Rust. + +describe("HandlerRegister.validateEventIdOrThrow", () => { + let add = (validator, ~eventId, ~contractName, ~isWildcard) => + validator->HandlerRegister.validateEventIdOrThrow( + ~eventId, + ~contractName, + ~eventName="Event1", + ~isWildcard, + ~chainId=1, + ) + + it("accepts the same eventId across different contracts", t => { + let validator = HandlerRegister.makeEventIdValidator() + validator->add(~eventId="0xsig", ~contractName="Contract1", ~isWildcard=false) + t.expect( + () => validator->add(~eventId="0xsig", ~contractName="Contract2", ~isWildcard=false), + ).not.toThrow() + }) + + it("throws on a duplicate event for the same contract", t => { + let validator = HandlerRegister.makeEventIdValidator() + validator->add(~eventId="0xsig", ~contractName="Contract1", ~isWildcard=false) + t.expect( + () => validator->add(~eventId="0xsig", ~contractName="Contract1", ~isWildcard=false), + ).toThrowError("Duplicate event detected: Event1 for contract Contract1 on chain 1") + }) + + it("throws when a second wildcard claims the same eventId", t => { + let validator = HandlerRegister.makeEventIdValidator() + validator->add(~eventId="0xsig", ~contractName="Contract1", ~isWildcard=true) + t.expect( + () => validator->add(~eventId="0xsig", ~contractName="Contract2", ~isWildcard=true), + ).toThrowError( + "Another event is already registered with the same signature that would interfere with wildcard filtering: Event1 for contract Contract2 on chain 1", + ) + }) + + it("accepts two wildcards whose eventIds differ (e.g. SVM program-scoped ids)", t => { + // finishRegistration scopes the SVM key by programId (`${programId}_${id}`), + // so two wildcard instructions sharing a discriminator on different programs + // reach the validator as distinct eventIds and must not collide. + let validator = HandlerRegister.makeEventIdValidator() + validator->add(~eventId="progA_0x0f", ~contractName="ProgA", ~isWildcard=true) + t.expect( + () => validator->add(~eventId="progB_0x0f", ~contractName="ProgB", ~isWildcard=true), + ).not.toThrow() + }) +}) diff --git a/packages/envio/src/HandlerRegister.res b/packages/envio/src/HandlerRegister.res index 5a77b0214..dfae00a37 100644 --- a/packages/envio/src/HandlerRegister.res +++ b/packages/envio/src/HandlerRegister.res @@ -588,6 +588,55 @@ let registerOnBlock = ( }) } +// Per-eventId dispatch validation state: two events on one contract can't +// share a dispatch id, and only one wildcard may claim it — Rust-side routing +// fans a log/receipt/instruction out by these ids, so a collision would +// double-deliver. Scoped per chain (a fresh validator per chain iteration). +type eventIdValidator = { + contractNamesByEventId: dict>, + wildcardEventIds: Utils.Set.t, +} + +let makeEventIdValidator = (): eventIdValidator => { + contractNamesByEventId: Dict.make(), + wildcardEventIds: Utils.Set.make(), +} + +let validateEventIdOrThrow = ( + validator: eventIdValidator, + ~eventId, + ~contractName, + ~eventName, + ~isWildcard, + ~chainId, +) => { + let chainSuffix = `on chain ${chainId->Int.toString}` + let contractNames = switch validator.contractNamesByEventId->Utils.Dict.dangerouslyGetNonOption( + eventId, + ) { + | Some(contractNames) => contractNames + | None => { + let contractNames = Utils.Set.make() + validator.contractNamesByEventId->Dict.set(eventId, contractNames) + contractNames + } + } + if contractNames->Utils.Set.has(contractName) { + JsError.throwWithMessage( + `Duplicate event detected: ${eventName} for contract ${contractName} ${chainSuffix}`, + ) + } + if isWildcard && validator.wildcardEventIds->Utils.Set.has(eventId) { + JsError.throwWithMessage( + `Another event is already registered with the same signature that would interfere with wildcard filtering: ${eventName} for contract ${contractName} ${chainSuffix}`, + ) + } + if isWildcard { + validator.wildcardEventIds->Utils.Set.add(eventId)->ignore + } + contractNames->Utils.Set.add(contractName)->ignore +} + let finishRegistration = (~config: Config.t): registrationsByChainId => { switch getActiveRegistration() { | Some(r) => { @@ -600,39 +649,7 @@ let finishRegistration = (~config: Config.t): registrationsByChainId => { let key = chainConfig.id->Int.toString let pending = r.registrationsByChainId->Utils.Dict.dangerouslyGetNonOption(key) - // Per-eventId dispatch validation: two events on one contract can't - // share a dispatch id, and only one wildcard may claim it — Rust-side - // routing fans a log/receipt/instruction out by these ids, so a - // collision would double-deliver. - let contractNamesByEventId: dict> = Dict.make() - let wildcardEventIds = Utils.Set.make() - let validateEventIdOrThrow = (~eventId, ~contractName, ~eventName, ~isWildcard) => { - let chainSuffix = `on chain ${chainConfig.id->Int.toString}` - let contractNames = switch contractNamesByEventId->Utils.Dict.dangerouslyGetNonOption( - eventId, - ) { - | Some(contractNames) => contractNames - | None => { - let contractNames = Utils.Set.make() - contractNamesByEventId->Dict.set(eventId, contractNames) - contractNames - } - } - if contractNames->Utils.Set.has(contractName) { - JsError.throwWithMessage( - `Duplicate event detected: ${eventName} for contract ${contractName} ${chainSuffix}`, - ) - } - if isWildcard && wildcardEventIds->Utils.Set.has(eventId) { - JsError.throwWithMessage( - `Another event is already registered with the same signature that would interfere with wildcard filtering: ${eventName} for contract ${contractName} ${chainSuffix}`, - ) - } - if isWildcard { - wildcardEventIds->Utils.Set.add(eventId)->ignore - } - contractNames->Utils.Set.add(contractName)->ignore - } + let eventIdValidator = makeEventIdValidator() let onEventRegistrations: array = [] @@ -665,7 +682,7 @@ let finishRegistration = (~config: Config.t): registrationsByChainId => { `${svmEventConfig.programId->SvmTypes.Pubkey.toString}_${eventConfig.id}` | Evm | Fuel => eventConfig.id } - validateEventIdOrThrow( + eventIdValidator->validateEventIdOrThrow( ~eventId, ~contractName, ~eventName, @@ -673,6 +690,7 @@ let finishRegistration = (~config: Config.t): registrationsByChainId => { | Some(registration) => registration.isWildcard | None => isWildcard(~contractName, ~eventName) }, + ~chainId=chainConfig.id, ) let registration = switch registration { diff --git a/packages/envio/src/HandlerRegister.resi b/packages/envio/src/HandlerRegister.resi index 7014ebd42..401050fac 100644 --- a/packages/envio/src/HandlerRegister.resi +++ b/packages/envio/src/HandlerRegister.resi @@ -9,6 +9,17 @@ let isPendingRegistration: unit => bool let finishRegistration: (~config: Config.t) => registrationsByChainId let throwIfFinishedRegistration: (~methodName: string) => unit +type eventIdValidator +let makeEventIdValidator: unit => eventIdValidator +let validateEventIdOrThrow: ( + eventIdValidator, + ~eventId: string, + ~contractName: string, + ~eventName: string, + ~isWildcard: bool, + ~chainId: int, +) => unit + let buildOnEventRegistration: ( ~config: Config.t, ~chainId: int, diff --git a/scenarios/fuel_test/test/HyperFuelHeight_test.res b/scenarios/fuel_test/test/FuelHyperSyncSourceHeight_test.res similarity index 100% rename from scenarios/fuel_test/test/HyperFuelHeight_test.res rename to scenarios/fuel_test/test/FuelHyperSyncSourceHeight_test.res From 9112715de7a9383c394fdca97ab47a476fdd6672 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 22 Jul 2026 10:14:47 +0000 Subject: [PATCH 10/10] Fix clippy unused-import failure; drop refactor-history test comment - fuel_hypersync_source/mod.rs: FuelEventKind is only used by the #[cfg(test)] module now that non-test routing uses RegistrationKind, so move it out of the crate-level import into the test module. Fixes `cargo clippy -- -D warnings`. - HandlerRegisterValidation_test.res: drop the refactor-history line per the no-narrate-the-refactor guideline (keep the collision-risk rationale). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01Myo6vsGNF9ACsAbBV6PXSk --- packages/cli/src/evm_hypersync_source/decode.rs | 8 ++------ packages/cli/src/evm_hypersync_source/mod.rs | 7 +++---- packages/cli/src/fuel_hypersync_source/mod.rs | 5 ++--- .../envio-tests/test/HandlerRegisterValidation_test.res | 1 - 4 files changed, 7 insertions(+), 14 deletions(-) diff --git a/packages/cli/src/evm_hypersync_source/decode.rs b/packages/cli/src/evm_hypersync_source/decode.rs index 44a9a7ecd..078b40587 100644 --- a/packages/cli/src/evm_hypersync_source/decode.rs +++ b/packages/cli/src/evm_hypersync_source/decode.rs @@ -467,9 +467,7 @@ mod tests { fn registration_rejects_zero_topics() { let mut reg = value_reg(0, "C", false, VALID_SIGHASH); reg.topic_count = 0; - let err = Decoder::from_registrations(&[reg], false) - .err() - .unwrap(); + let err = Decoder::from_registrations(&[reg], false).err().unwrap(); assert!(format!("{err:#}").contains("topic_count must be 1..=4")); } @@ -477,9 +475,7 @@ mod tests { fn registration_rejects_five_topics() { let mut reg = value_reg(0, "C", false, VALID_SIGHASH); reg.topic_count = 5; - let err = Decoder::from_registrations(&[reg], false) - .err() - .unwrap(); + let err = Decoder::from_registrations(&[reg], false).err().unwrap(); assert!(format!("{err:#}").contains("topic_count must be 1..=4")); } diff --git a/packages/cli/src/evm_hypersync_source/mod.rs b/packages/cli/src/evm_hypersync_source/mod.rs index 7a8b44246..29a2ce5e8 100644 --- a/packages/cli/src/evm_hypersync_source/mod.rs +++ b/packages/cli/src/evm_hypersync_source/mod.rs @@ -62,10 +62,9 @@ impl EvmHyperSyncClient { let enable_checksum_addresses = cfg.enable_checksum_addresses.unwrap_or_default(); - let decoder = - Decoder::from_registrations(&event_registrations, enable_checksum_addresses) - .context("build decoder") - .map_err(map_err)?; + let decoder = Decoder::from_registrations(&event_registrations, enable_checksum_addresses) + .context("build decoder") + .map_err(map_err)?; let selection_builder = SelectionBuilder::from_registrations(&event_registrations) .context("build selection builder") diff --git a/packages/cli/src/fuel_hypersync_source/mod.rs b/packages/cli/src/fuel_hypersync_source/mod.rs index ba399d5fa..ed0926d81 100644 --- a/packages/cli/src/fuel_hypersync_source/mod.rs +++ b/packages/cli/src/fuel_hypersync_source/mod.rs @@ -10,9 +10,7 @@ mod types; use config::ClientConfig; use hyperfuel_client::net_types; -use selection::{ - BuiltSelection, FuelEventKind, FuelOnEventRegistrationInput, RegistrationKind, SelectionBuilder, -}; +use selection::{BuiltSelection, FuelOnEventRegistrationInput, RegistrationKind, SelectionBuilder}; use types::{convert_response, Block, ConvertError, RawReceipt}; #[napi] @@ -327,6 +325,7 @@ fn map_err(e: anyhow::Error) -> napi::Error { #[cfg(test)] mod tests { + use super::selection::FuelEventKind; use super::*; #[test] diff --git a/packages/envio-tests/test/HandlerRegisterValidation_test.res b/packages/envio-tests/test/HandlerRegisterValidation_test.res index e0d9c87cc..88e7b18f0 100644 --- a/packages/envio-tests/test/HandlerRegisterValidation_test.res +++ b/packages/envio-tests/test/HandlerRegisterValidation_test.res @@ -4,7 +4,6 @@ open Vitest // guard `finishRegistration` runs so a single log/receipt/instruction can't fan // out to two events on one contract, or to two wildcards sharing a signature — // Rust-side routing dispatches by these ids, so a collision double-delivers. -// Replaces the deleted EventRouter unit tests now that routing lives in Rust. describe("HandlerRegister.validateEventIdOrThrow", () => { let add = (validator, ~eventId, ~contractName, ~isWildcard) =>