From 52cde5a81073828fa67694f2785602062536ae15 Mon Sep 17 00:00:00 2001 From: Kevin Deforth <32777623+kevindeforth@users.noreply.github.com.> Date: Wed, 25 Mar 2026 15:20:17 +0200 Subject: [PATCH 1/3] feat: chain gateway block event subscriber --- Cargo.lock | 1 + crates/chain-gateway-test-contract/Cargo.toml | 1 + .../res/chain_gateway_test_contract.wasm | Bin 99359 -> 139200 bytes crates/chain-gateway-test-contract/src/lib.rs | 109 ++++- crates/chain-gateway/src/chain_gateway.rs | 104 ++++- crates/chain-gateway/src/errors.rs | 11 + crates/chain-gateway/src/event_subscriber.rs | 4 + .../src/event_subscriber/block_events.rs | 65 +++ .../src/event_subscriber/stats.rs | 58 +++ .../src/event_subscriber/streamer.rs | 39 ++ .../streamer/block_processor.rs | 184 ++++++++ .../src/event_subscriber/streamer/config.rs | 99 ++++ .../src/event_subscriber/subscriber.rs | 74 +++ crates/chain-gateway/src/lib.rs | 1 + .../src/state_viewer/subscription.rs | 2 + crates/chain-gateway/src/types.rs | 4 +- crates/chain-gateway/tests/common.rs | 2 +- crates/chain-gateway/tests/common/accounts.rs | 60 +++ crates/chain-gateway/tests/common/contract.rs | 32 -- crates/chain-gateway/tests/common/localnet.rs | 74 ++- crates/chain-gateway/tests/common/node.rs | 174 ++++--- .../tests/event_subscriber_integration.rs | 425 ++++++++++++++++++ .../chain-gateway/tests/sender_integration.rs | 50 +-- .../tests/state_viewer_integration.rs | 39 +- crates/chain-gateway/tests/test.rs | 2 + .../chain-gateway/tests/view_subscription.rs | 53 +++ docs/chain-gateway-design.md | 80 ++-- 27 files changed, 1519 insertions(+), 228 deletions(-) create mode 100644 crates/chain-gateway/src/event_subscriber.rs create mode 100644 crates/chain-gateway/src/event_subscriber/block_events.rs create mode 100644 crates/chain-gateway/src/event_subscriber/stats.rs create mode 100644 crates/chain-gateway/src/event_subscriber/streamer.rs create mode 100644 crates/chain-gateway/src/event_subscriber/streamer/block_processor.rs create mode 100644 crates/chain-gateway/src/event_subscriber/streamer/config.rs create mode 100644 crates/chain-gateway/src/event_subscriber/subscriber.rs create mode 100644 crates/chain-gateway/tests/common/accounts.rs delete mode 100644 crates/chain-gateway/tests/common/contract.rs create mode 100644 crates/chain-gateway/tests/event_subscriber_integration.rs create mode 100644 crates/chain-gateway/tests/view_subscription.rs diff --git a/Cargo.lock b/Cargo.lock index 8c722c039..c5b2486a7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1823,6 +1823,7 @@ version = "3.7.0" dependencies = [ "borsh", "near-sdk", + "serde_json", ] [[package]] diff --git a/crates/chain-gateway-test-contract/Cargo.toml b/crates/chain-gateway-test-contract/Cargo.toml index b0e09a9cc..7eae4d33e 100644 --- a/crates/chain-gateway-test-contract/Cargo.toml +++ b/crates/chain-gateway-test-contract/Cargo.toml @@ -26,6 +26,7 @@ crate-type = ["cdylib", "lib"] [dependencies] borsh = { workspace = true } near-sdk = { workspace = true } +serde_json = { workspace = true } [lints] workspace = true diff --git a/crates/chain-gateway-test-contract/res/chain_gateway_test_contract.wasm b/crates/chain-gateway-test-contract/res/chain_gateway_test_contract.wasm index 91d624b0fe70f3d8001458297c70e8aa73a0af10..fa0c1e632bf9e498381269510453dfff55785753 100644 GIT binary patch delta 65855 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 CbYy)1 delta 26530 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 diff --git a/crates/chain-gateway-test-contract/src/lib.rs b/crates/chain-gateway-test-contract/src/lib.rs index 7d29717b9..648b1d1a1 100644 --- a/crates/chain-gateway-test-contract/src/lib.rs +++ b/crates/chain-gateway-test-contract/src/lib.rs @@ -1,12 +1,55 @@ -use near_sdk::{env::log_str, near}; +use near_sdk::{ + Gas, NearToken, Promise, + env::{self, log_str}, + near, +}; pub fn compiled_wasm() -> &'static [u8] { include_bytes!("../res/chain_gateway_test_contract.wasm") } pub const DEFAULT_VALUE: &str = "hello from test"; + +// public view method pub const VIEW_METHOD: &str = "view_value"; -pub const WRITE_METHOD: &str = "set_value"; + +// public set method +pub const SET_VALUE: &str = "set_value"; +pub const SET_VALUE_TGAS: u64 = FIVE_TGAS; + +// teragas as u64. We don't use near_sdk::Gas on purpose, such that the near indexer can re-use +// these constants without depending on near_sdk. +pub const FIVE_TGAS: u64 = 5; + +// methods that spawn promises +pub const SET_VALUE_IN_PROMISE: &str = "set_value_in_promise"; +pub const SET_VALUE_IN_PROMISE_TGAS: u64 = SET_VALUE_TGAS + FIVE_TGAS; + +pub const SPAWN_PROMISE_WITH_CALLBACK: &str = "spawn_promise_with_callback"; +pub const SPAWN_PROMISE_WITH_CALLBACK_TGAS: u64 = + SET_VALUE_IN_PROMISE_TGAS + SET_VALUE_TGAS + FIVE_TGAS; + +// private method for setting value +pub const PRIVATE_SET: &str = "private_set"; +pub const PRIVATE_SET_ARGS_TGAS: u64 = 5; + +#[near(serializers=[json])] +pub struct PrivateSetArgs { + pub value: String, + pub succeeds: bool, +} + +#[near(serializers=[json])] +pub struct SetValueInPromiseArgs { + pub value: String, + pub return_error: bool, +} + +#[near(serializers=[json])] +pub struct SetValueWithMarker { + pub successfully_spawn_promise: bool, + pub end_marker: String, +} #[derive(Debug)] #[near(contract_state)] @@ -32,4 +75,66 @@ impl Contract { log_str(&format!("Setting value to: {value}")); self.stored_value = value; } + + /// Spawns a cross-contract promise to [`private_set`](Self::private_set), returning an error + /// if `return_error` is set. Used by integration tests to verify + /// `ExecutorFunctionCallSuccessWithPromise` event tracking. + #[handle_result] + pub fn set_value_in_promise(&mut self, args: SetValueInPromiseArgs) -> Result { + if args.return_error { + Err("computer says no".to_string()) + } else { + let private_set_args = PrivateSetArgs { + value: args.value, + succeeds: true, + }; + Ok(Promise::new(env::current_account_id()).function_call( + PRIVATE_SET.to_string(), + serde_json::to_vec(&serde_json::json!({"args": private_set_args})).unwrap(), + NearToken::from_near(0), + Gas::from_tgas(PRIVATE_SET_ARGS_TGAS), + )) + } + } + + /// Chains two promises: first calls [`set_value_in_promise`](Self::set_value_in_promise) + /// (which may fail based on `successfully_spawn_promise`), then a callback that writes + /// `end_marker` via [`private_set`](Self::private_set). The callback acts as a + /// synchronization marker so tests can poll [`view_value`](Self::view_value) to know the + /// full chain completed. + pub fn spawn_promise_with_callback(args: SetValueWithMarker) -> Promise { + let set_value_args = SetValueInPromiseArgs { + value: "doesn't matter".to_string(), + return_error: !args.successfully_spawn_promise, + }; + let promise = Promise::new(env::current_account_id()).function_call( + SET_VALUE_IN_PROMISE.to_string(), + serde_json::to_vec(&serde_json::json!({"args": set_value_args})).unwrap(), + NearToken::from_near(0), + Gas::from_tgas(SET_VALUE_IN_PROMISE_TGAS), + ); + let private_set_args = PrivateSetArgs { + value: args.end_marker, + succeeds: true, + }; + let callback = Promise::new(env::current_account_id()).function_call( + PRIVATE_SET.to_string(), + serde_json::to_vec(&serde_json::json!({"args": private_set_args})).unwrap(), + NearToken::from_near(0), + Gas::from_tgas(PRIVATE_SET_ARGS_TGAS), + ); + promise.then(callback) + } + + /// Can only be called by the contract itself (via a promise). + #[private] + #[handle_result] + pub fn private_set(&mut self, args: PrivateSetArgs) -> Result<(), String> { + if args.succeeds { + self.set_value(args.value); + Ok(()) + } else { + Err("intentional error for testing".to_string()) + } + } } diff --git a/crates/chain-gateway/src/chain_gateway.rs b/crates/chain-gateway/src/chain_gateway.rs index b5d71f7ea..f67090161 100644 --- a/crates/chain-gateway/src/chain_gateway.rs +++ b/crates/chain-gateway/src/chain_gateway.rs @@ -2,10 +2,15 @@ use std::path::Path; use near_account_id::AccountId; use near_async::ActorSystem; +use near_indexer::StreamerMessage; use near_indexer::near_primitives::transaction::SignedTransaction; use nearcore::NearConfig; +use tokio::sync::mpsc::Receiver; use crate::errors::{ChainGatewayError, NearClientError, NearRpcError, NearViewClientError}; +use crate::event_subscriber; +use crate::event_subscriber::block_events::BlockUpdate; +use crate::event_subscriber::subscriber::BlockEventSubscriber; use crate::near_internals_wrapper::{ NearClientActorHandle, NearRpcActorHandle, NearViewClientActorHandle, }; @@ -92,41 +97,64 @@ impl NodeHandle { } impl ChainGateway { - /// Spawns a near node with `config`. + /// Spawns a near node with `indexer_config`. /// The [`NodeHandle`] can be used to shut down the actor system for the node and liveness checks. /// The node dies if [`NodeHandle`] is dropped. + /// Returns a stream for BlockUpdates if BlockEventSubscriber is not None. pub async fn start( - config: near_indexer::IndexerConfig, - ) -> Result<(ChainGateway, NodeHandle), ChainGatewayError> { - let near_config: NearConfig = - config - .load_near_config() - .map_err(|err| ChainGatewayError::FailureLoadingConfig { - msg: err.to_string(), - })?; - let home_dir = config.home_dir; + indexer_config: near_indexer::IndexerConfig, + subscriber: Option, + ) -> Result< + ( + ChainGateway, + NodeHandle, + Option>, + ), + ChainGatewayError, + > { + let near_config: NearConfig = indexer_config.load_near_config().map_err(|err| { + ChainGatewayError::FailureLoadingConfig { + msg: err.to_string(), + } + })?; + + let home_dir = indexer_config.home_dir.clone(); + let streamer_setup = subscriber.map(|subscriber| StreamerSetup { + subscriber, + indexer_config, + near_config: near_config.clone(), + }); + let (ready_sender, ready_receiver) = tokio::sync::oneshot::channel(); let (shutdown_sender, shutdown_receiver) = tokio::sync::oneshot::channel(); let thread_handle = std::thread::spawn(move || { - // blocking method, resumes once `shutdown_sender` sends the shutdown signal - run_node(ready_sender, near_config, &home_dir, shutdown_receiver) + run_node( + ready_sender, + near_config, + &home_dir, + shutdown_receiver, + streamer_setup, + ) }); - let chain_gateway = ready_receiver.await.expect("startup thread died")?; + let (chain_gateway, stream) = ready_receiver.await.expect("startup thread died")?; let node_handle = NodeHandle { thread_handle, shutdown_sender: Some(shutdown_sender), }; - Ok((chain_gateway, node_handle)) + Ok((chain_gateway, node_handle, stream)) } } +type RunNodeResult = Result<(ChainGateway, Option>), ChainGatewayError>; + fn run_node( - ready_sender: tokio::sync::oneshot::Sender>, + ready_sender: tokio::sync::oneshot::Sender, near_config: nearcore::NearConfig, home_dir: &Path, shutdown_receiver: tokio::sync::oneshot::Receiver<()>, + streamer_setup: Option, ) { let rt = tokio::runtime::Builder::new_multi_thread() .enable_all() @@ -146,15 +174,43 @@ fn run_node( } }; + let indexer_and_params = streamer_setup.map(|s| { + let indexer = + near_indexer::Indexer::from_near_node(s.indexer_config, s.near_config, &near_node); + (indexer, s.subscriber) + }); + let view_client = NearViewClientActorHandle::new(near_node.view_client); let client = NearClientActorHandle::new(near_node.client); let rpc_handler = NearRpcActorHandle::new(near_node.rpc_handler); - let _ = ready_sender.send(Ok(ChainGateway { - view_client, - client, - rpc_handler, - })); + let stream = if let Some((indexer, streamer_config)) = indexer_and_params { + let raw_stream: Receiver = indexer.streamer(); + match event_subscriber::streamer::start( + streamer_config, + raw_stream, + view_client.clone(), + ) + .await + { + Ok(rx) => Some(rx), + Err(err) => { + let _ = ready_sender.send(Err(err)); + return; + } + } + } else { + None + }; + + let _ = ready_sender.send(Ok(( + ChainGateway { + view_client, + client, + rpc_handler, + }, + stream, + ))); match shutdown_receiver.await { Ok(()) => { @@ -168,3 +224,11 @@ fn run_node( actor_system.stop(); }); } + +/// Parameters for optionally starting the block-event streaming pipeline +/// alongside the nearcore node. +struct StreamerSetup { + subscriber: BlockEventSubscriber, + indexer_config: near_indexer::IndexerConfig, + near_config: NearConfig, +} diff --git a/crates/chain-gateway/src/errors.rs b/crates/chain-gateway/src/errors.rs index e86463196..604b944c5 100644 --- a/crates/chain-gateway/src/errors.rs +++ b/crates/chain-gateway/src/errors.rs @@ -120,4 +120,15 @@ pub enum ChainGatewayError { #[error("view client error while {op}: {message}")] ViewError { op: ChainGatewayOp, message: String }, + + #[error( + "block event channel has been full for too long, the receiver must drain messages otherwise we risk out of memory" + )] + BlockEventBufferFull, + + #[error("near indexer dropped")] + BlockEventIndexerDropped, + + #[error("block event stream has been closed or dropped by the receiver")] + BlockEventReceiverDropped, } diff --git a/crates/chain-gateway/src/event_subscriber.rs b/crates/chain-gateway/src/event_subscriber.rs new file mode 100644 index 000000000..18077ee1b --- /dev/null +++ b/crates/chain-gateway/src/event_subscriber.rs @@ -0,0 +1,4 @@ +pub mod block_events; +mod stats; +pub(super) mod streamer; +pub mod subscriber; diff --git a/crates/chain-gateway/src/event_subscriber/block_events.rs b/crates/chain-gateway/src/event_subscriber/block_events.rs new file mode 100644 index 000000000..f979fc2b7 --- /dev/null +++ b/crates/chain-gateway/src/event_subscriber/block_events.rs @@ -0,0 +1,65 @@ +use derive_more::{Deref, From}; +use near_account_id::AccountId; +use near_indexer_primitives::CryptoHash; + +use crate::types::BlockHeight; + +/// The BlockUpdate returned by the Chain indexer. +/// Similar to [`ChainBlockUpdate`](../../node/src/indexer/handler.rs) in the `mpc-node` crate. +#[derive(Debug)] +pub struct BlockUpdate { + pub context: BlockContext, + pub events: Vec, +} + +/// Context for a single block +#[derive(Debug)] +pub struct BlockContext { + pub hash: CryptoHash, + pub height: BlockHeight, + pub prev_hash: CryptoHash, + pub last_final_block: CryptoHash, + pub block_entropy: [u8; 32], + pub block_timestamp_nanosec: u64, +} + +#[derive(Debug)] +pub struct MatchedEvent { + /// this is needed such that the caller can identify the block event + pub id: BlockEventId, + /// any data associated with that event + pub event_data: EventData, +} + +/// An identifier for a block event +#[derive(Debug, Deref, From, Clone, Copy, PartialEq, Eq)] +pub struct BlockEventId(pub u64); + +/// Event data, matching a filter [`super::subscriber::BlockEventFilter`] +#[derive(Debug, PartialEq)] +pub enum EventData { + ExecutorFunctionCallSuccessWithPromise(ExecutorFunctionCallSuccessWithPromiseData), + ReceiverFunctionCall(ReceiverFunctionCallData), +} + +/// Event data for a receipt matching a [`super::subscriber::BlockEventFilter::ExecutorFunctionCallSuccessWithPromise`] +#[derive(Debug, PartialEq)] +pub struct ExecutorFunctionCallSuccessWithPromiseData { + /// the receipt_id of the receipt this event came from + pub receipt_id: CryptoHash, + /// predecessor_id who signed the transaction + pub predecessor_id: AccountId, + /// the receipt that will hold the outcome of this receipt + pub next_receipt_id: CryptoHash, + /// raw bytes used for function call + pub args_raw: Vec, +} + +/// Event data for a receipt matching a [`super::subscriber::BlockEventFilter::ReceiverFunctionCall`] +#[derive(Debug, PartialEq)] +pub struct ReceiverFunctionCallData { + /// the receipt id for the matched transaction + pub receipt_id: CryptoHash, + /// whether the execution outcome was successful + pub is_success: bool, +} diff --git a/crates/chain-gateway/src/event_subscriber/stats.rs b/crates/chain-gateway/src/event_subscriber/stats.rs new file mode 100644 index 000000000..3839eae48 --- /dev/null +++ b/crates/chain-gateway/src/event_subscriber/stats.rs @@ -0,0 +1,58 @@ +use crate::{primitives::FetchLatestFinalBlockInfo, types::BlockHeight}; + +#[derive(Debug, Clone)] +pub struct IndexerStats { + pub blocks_processed_count: u64, + pub last_processed_block_height: BlockHeight, +} + +impl IndexerStats { + pub(crate) fn new() -> Self { + IndexerStats { + blocks_processed_count: 0, + last_processed_block_height: 0.into(), + } + } +} + +/// Periodically logs indexer progress stats. +/// Based on [`indexer_logger`](../../node/src/indexer/stats.rs) in the `mpc-node` crate, +/// but uses a `watch` channel instead of a `Mutex` to read stats, since blocks are no longer +/// processed by multiple threads. +pub async fn indexer_logger( + stats_rx: tokio::sync::watch::Receiver, + info_fetcher: impl FetchLatestFinalBlockInfo, +) { + let interval_secs = 10; + let mut prev_blocks_processed_count: u64 = 0; + + loop { + tokio::time::sleep(std::time::Duration::from_secs(interval_secs)).await; + let stats_copy = stats_rx.borrow().clone(); + + let block_processing_speed: f64 = (stats_copy + .blocks_processed_count + .saturating_sub(prev_blocks_processed_count) + as f64) + / (interval_secs as f64); + + let blocks_behind = match info_fetcher.fetch_latest_final_block_info().await { + Ok(block_info) => { + let tip: u64 = block_info.observed_at.into(); + let processed: u64 = stats_copy.last_processed_block_height.into(); + format!("{}", tip.saturating_sub(processed)) + } + Err(_) => "∞".to_string(), + }; + + tracing::info!( + target: "chain gateway", + "# {} | Blocks done: {}. Bps {:.2} b/s, block remaining: {}", + stats_copy.last_processed_block_height, + stats_copy.blocks_processed_count, + block_processing_speed, + blocks_behind, + ); + prev_blocks_processed_count = stats_copy.blocks_processed_count; + } +} diff --git a/crates/chain-gateway/src/event_subscriber/streamer.rs b/crates/chain-gateway/src/event_subscriber/streamer.rs new file mode 100644 index 000000000..10c1ff5c2 --- /dev/null +++ b/crates/chain-gateway/src/event_subscriber/streamer.rs @@ -0,0 +1,39 @@ +mod block_processor; +mod config; + +use block_processor::listen_blocks; +use config::StreamerConfig; +use near_indexer::StreamerMessage; + +use crate::{errors::ChainGatewayError, primitives::FetchLatestFinalBlockInfo}; + +use super::{ + block_events::BlockUpdate, + stats::{IndexerStats, indexer_logger}, + subscriber::BlockEventSubscriber, +}; + +pub(crate) async fn start( + block_event_subscriber: BlockEventSubscriber, + stream: tokio::sync::mpsc::Receiver, + info_fetcher: impl FetchLatestFinalBlockInfo, +) -> Result, ChainGatewayError> { + let StreamerConfig { + buffer_size, + block_events, + backpressure_timeout, + } = block_event_subscriber.into(); + let (stats_tx, stats_rx) = tokio::sync::watch::channel(IndexerStats::new()); + let (block_tx, block_rx) = tokio::sync::mpsc::channel(buffer_size); + + tokio::spawn(listen_blocks( + stream, + block_events, + stats_tx, + block_tx, + backpressure_timeout, + )); + tokio::spawn(indexer_logger(stats_rx, info_fetcher)); + + Ok(block_rx) +} diff --git a/crates/chain-gateway/src/event_subscriber/streamer/block_processor.rs b/crates/chain-gateway/src/event_subscriber/streamer/block_processor.rs new file mode 100644 index 000000000..45357688b --- /dev/null +++ b/crates/chain-gateway/src/event_subscriber/streamer/block_processor.rs @@ -0,0 +1,184 @@ +use std::time::Duration; + +use near_indexer::IndexerExecutionOutcomeWithReceipt; +use near_indexer_primitives::{ + types::FunctionArgs, + views::{ActionView, ExecutionStatusView, ReceiptEnumView, ReceiptView}, +}; + +use crate::{ + errors::ChainGatewayError, + event_subscriber::{ + block_events::{ + BlockContext, BlockUpdate, EventData, ExecutorFunctionCallSuccessWithPromiseData, + MatchedEvent, ReceiverFunctionCallData, + }, + stats::IndexerStats, + }, +}; + +use super::config::{BlockEventIdsByContractIds, BlockEvents}; + +pub(super) async fn listen_blocks( + mut stream: tokio::sync::mpsc::Receiver, + block_events: BlockEvents, + stats_tx: tokio::sync::watch::Sender, + block_update_sender: tokio::sync::mpsc::Sender, + backpressure_timeout: Duration, +) -> Result<(), ChainGatewayError> { + let mut blocks_processed_count: u64 = 0; + // Note: the mpc-node indexer (handler.rs) uses `buffer_unordered` for concurrent + // block processing. We deliberately use sequential processing here because: + // 1. `process_block` is synchronous — there is no async work to overlap. + // 2. `buffer_unordered` does not preserve ordering, yet consumers expect + // block updates in block-height order. + // 3. There is no performance gain in concurrent processing here, especially if we use + // bounded channels. + loop { + let streamer_message = stream + .recv() + .await + .ok_or(ChainGatewayError::BlockEventIndexerDropped)?; + let block_height = streamer_message.block.header.height; + // TODO(#2626): we can ignore blocks that are older than a specific block height. This + // requires some care on the node side, which is why we will only do so after we integrated + // the chain-gateway struct with the node. + let block_update = process_block(streamer_message, &block_events); + // Only send if we have something the consumer is interested in. + if !block_update.events.is_empty() { + tokio::time::timeout(backpressure_timeout, block_update_sender.send(block_update)) + .await + .map_err(|_| ChainGatewayError::BlockEventBufferFull)? + .map_err(|_| ChainGatewayError::BlockEventReceiverDropped)?; + } + blocks_processed_count = blocks_processed_count.saturating_add(1); + stats_tx.send_modify(|s| { + s.blocks_processed_count = blocks_processed_count; + s.last_processed_block_height = block_height.into(); + }); + } +} + +fn filter_executor_function_calls( + res: &mut Vec, + executor_filters: &BlockEventIdsByContractIds, + outcome: &IndexerExecutionOutcomeWithReceipt, +) { + let execution_outcome = outcome.execution_outcome.clone(); + let ExecutionStatusView::SuccessReceiptId(next_receipt_id) = execution_outcome.outcome.status + else { + return; + }; + let receipt = outcome.receipt.clone(); + let executor_id = execution_outcome.outcome.executor_id; + let Some(filter_methods_for_executor) = executor_filters.filter_methods_for(&executor_id) + else { + return; + }; + let Some((args, contract_method_name)) = try_extract_function_call_args(&receipt) else { + return; + }; + let Some(filter_ids) = filter_methods_for_executor.filter_ids_for(contract_method_name) else { + return; + }; + for filter_id in filter_ids { + res.push(MatchedEvent { + id: *filter_id, + event_data: EventData::ExecutorFunctionCallSuccessWithPromise( + ExecutorFunctionCallSuccessWithPromiseData { + receipt_id: receipt.receipt_id, + predecessor_id: receipt.predecessor_id.clone(), + next_receipt_id, + args_raw: args.to_vec(), + }, + ), + }); + } +} + +fn filter_receipt_function_calls( + res: &mut Vec, + receiver_filters: &BlockEventIdsByContractIds, + outcome: &IndexerExecutionOutcomeWithReceipt, +) { + let receipt = outcome.receipt.clone(); + let Some(methods_filter) = receiver_filters.filter_methods_for(&receipt.receiver_id) else { + return; + }; + + let Some((_, contract_method_name)) = try_extract_function_call_args(&receipt) else { + return; + }; + let Some(filter_ids) = methods_filter.filter_ids_for(contract_method_name) else { + return; + }; + + let is_success = matches!( + outcome.execution_outcome.outcome.status, + ExecutionStatusView::SuccessValue(_) | ExecutionStatusView::SuccessReceiptId(_) + ); + + for filter_id in filter_ids { + res.push(MatchedEvent { + id: *filter_id, + event_data: EventData::ReceiverFunctionCall(ReceiverFunctionCallData { + receipt_id: receipt.receipt_id, + is_success, + }), + }); + } +} + +fn process_block( + streamer_message: near_indexer_primitives::StreamerMessage, + block_events: &BlockEvents, +) -> BlockUpdate { + let mut filtered_events = vec![]; + for shard in streamer_message.shards { + for outcome in &shard.receipt_execution_outcomes { + filter_executor_function_calls( + &mut filtered_events, + &block_events.executor_filters, + outcome, + ); + filter_receipt_function_calls( + &mut filtered_events, + &block_events.receipt_receiver_filters, + outcome, + ); + } + } + let context = BlockContext { + hash: streamer_message.block.header.hash, + height: streamer_message.block.header.height.into(), + prev_hash: streamer_message.block.header.prev_hash, + block_entropy: streamer_message.block.header.random_value.into(), + block_timestamp_nanosec: streamer_message.block.header.timestamp_nanosec, + last_final_block: streamer_message.block.header.last_final_block, + }; + BlockUpdate { + context, + events: filtered_events, + } +} + +fn try_extract_function_call_args(receipt: &ReceiptView) -> Option<(&FunctionArgs, &String)> { + let ReceiptEnumView::Action { ref actions, .. } = receipt.receipt else { + return None; + }; + if actions.len() != 1 { + return None; + } + let ActionView::FunctionCall { + ref method_name, + ref args, + .. + } = actions[0] + else { + return None; + }; + + tracing::debug!(target: "mpc", "found `{}` function call", method_name); + + Some((args, method_name)) +} diff --git a/crates/chain-gateway/src/event_subscriber/streamer/config.rs b/crates/chain-gateway/src/event_subscriber/streamer/config.rs new file mode 100644 index 000000000..810dc987a --- /dev/null +++ b/crates/chain-gateway/src/event_subscriber/streamer/config.rs @@ -0,0 +1,99 @@ +use std::{collections::BTreeMap, time::Duration}; + +use near_account_id::AccountId; + +use crate::event_subscriber::{ + block_events::BlockEventId, + subscriber::{BlockEventFilter, BlockEventSubscriber}, +}; + +pub(super) struct StreamerConfig { + pub(super) block_events: BlockEvents, + pub(super) buffer_size: usize, + pub(super) backpressure_timeout: Duration, +} + +// helper struct for efficient access +pub(super) struct BlockEvents { + pub(super) executor_filters: BlockEventIdsByContractIds, + pub(super) receipt_receiver_filters: BlockEventIdsByContractIds, +} + +// helper struct for efficient access +pub(super) struct BlockEventIdsByContractIds(BTreeMap); + +// helper struct for efficient access +pub(super) struct BlockEventIdsByMethodNames(BTreeMap>); + +impl BlockEventIdsByMethodNames { + pub(crate) fn filter_ids_for(&self, method_name: &str) -> Option<&Vec> { + self.0.get(method_name) + } +} + +impl BlockEventIdsByContractIds { + pub(crate) fn filter_methods_for( + &self, + contract: &AccountId, + ) -> Option<&BlockEventIdsByMethodNames> { + self.0.get(contract) + } +} + +impl From for StreamerConfig { + fn from(value: BlockEventSubscriber) -> Self { + let mut executor_filters: BTreeMap>> = + BTreeMap::new(); + let mut receipt_receiver_filters: BTreeMap>> = + BTreeMap::new(); + for (id, filter) in value.subscriptions { + match filter { + BlockEventFilter::ExecutorFunctionCallSuccessWithPromise { + transaction_outcome_executor_id, + method_name, + } => { + executor_filters + .entry(transaction_outcome_executor_id) + .or_default() + .entry(method_name) + .or_default() + .push(id); + } + BlockEventFilter::ReceiverFunctionCall { + receipt_receiver_id, + method_name, + } => { + receipt_receiver_filters + .entry(receipt_receiver_id) + .or_default() + .entry(method_name) + .or_default() + .push(id); + } + } + } + + let block_events = BlockEvents { + executor_filters: BlockEventIdsByContractIds( + executor_filters + .into_iter() + .map(|(k, v)| (k, BlockEventIdsByMethodNames(v))) + .collect(), + ), + receipt_receiver_filters: BlockEventIdsByContractIds( + receipt_receiver_filters + .into_iter() + .map(|(k, v)| (k, BlockEventIdsByMethodNames(v))) + .collect(), + ), + }; + + let buffer_size = value.buffer_size; + let backpressure_timeout = value.backpressure_timeout; + StreamerConfig { + block_events, + buffer_size, + backpressure_timeout, + } + } +} diff --git a/crates/chain-gateway/src/event_subscriber/subscriber.rs b/crates/chain-gateway/src/event_subscriber/subscriber.rs new file mode 100644 index 000000000..84135d839 --- /dev/null +++ b/crates/chain-gateway/src/event_subscriber/subscriber.rs @@ -0,0 +1,74 @@ +use std::time::Duration; + +use near_account_id::AccountId; + +use super::block_events::BlockEventId; + +const DEFAULT_SEND_TIMEOUT: Duration = Duration::from_secs(60); + +pub struct BlockEventSubscriber { + pub(super) subscriptions: Vec<(BlockEventId, BlockEventFilter)>, + next_id: BlockEventId, + pub(super) buffer_size: usize, + pub(super) backpressure_timeout: Duration, +} + +impl BlockEventSubscriber { + pub fn new(buffer_size: usize) -> Self { + BlockEventSubscriber { + subscriptions: vec![], + next_id: 0.into(), + buffer_size, + backpressure_timeout: DEFAULT_SEND_TIMEOUT, + } + } + + pub fn with_backpressure_timeout(mut self, timeout: Duration) -> Self { + self.backpressure_timeout = timeout; + self + } + + /// Add a filter and get a unique identifier for it. + /// Can be called multiple times before build(). + /// The identifier can be used to match a return value to the given filter. + pub fn subscribe(&mut self, filter: BlockEventFilter) -> BlockEventId { + let filter_id = self.next_id; + self.subscriptions.push((filter_id, filter)); + self.next_id = filter_id.overflowing_add(1).0.into(); + filter_id + } +} + +/// Filters, can be extended if necessary +pub enum BlockEventFilter { + /// Filters for executions of method `method_name` on `transaction_outcome_executor_id` + /// that spawn a promise (execution status == `SuccessReceiptId`). + /// + /// Calls to `transaction_outcome_executor_id.method_name`, that do not spawn a promise will be + /// ignored. + /// + /// If a transaction matches this filter, then [`super::block_events::ExecutorFunctionCallSuccessWithPromiseData`] will be extracted + /// and placed in the [`super::block_events::BlockUpdate`] + /// + /// When to use: + /// Use this for tracking calls across blocks. The MPC node uses this to filter out signature + /// requests and keep track of the yield index for resolving the request. + ExecutorFunctionCallSuccessWithPromise { + transaction_outcome_executor_id: AccountId, + method_name: String, + }, + + /// Filters for calls to `receipt_receiver_id.method_name`, regardless if they spawn a + /// promise, have been successful or not. + /// If a transaction matches this filter, then [`super::block_events::ReceiverFunctionCallData`] will be extracted + /// and placed in the [`super::block_events::BlockUpdate`]. + /// + /// When to use: + /// Use this if one just wants to track calls to a specific method on a specific contract + /// without the additional data of [`super::block_events::ExecutorFunctionCallSuccessWithPromiseData`]. + /// The MPC node uses this to track calls to private contract methods. + ReceiverFunctionCall { + receipt_receiver_id: AccountId, + method_name: String, + }, +} diff --git a/crates/chain-gateway/src/lib.rs b/crates/chain-gateway/src/lib.rs index 04b5af996..f50cded2a 100644 --- a/crates/chain-gateway/src/lib.rs +++ b/crates/chain-gateway/src/lib.rs @@ -1,5 +1,6 @@ pub mod chain_gateway; pub mod errors; +pub mod event_subscriber; mod near_internals_wrapper; pub mod primitives; pub mod state_viewer; diff --git a/crates/chain-gateway/src/state_viewer/subscription.rs b/crates/chain-gateway/src/state_viewer/subscription.rs index 59a2cbeca..6660e0e10 100644 --- a/crates/chain-gateway/src/state_viewer/subscription.rs +++ b/crates/chain-gateway/src/state_viewer/subscription.rs @@ -26,6 +26,8 @@ impl WatchContractState for ContractMethodSubscription where Res: DeserializeOwned + Send + Clone, { + /// The constructor marks the initial value as seen, so + /// `changed().await` will not fire until a genuinely new value arrives. async fn changed(&mut self) -> Result<(), ChainGatewayError> { self.inner .last_observed diff --git a/crates/chain-gateway/src/types.rs b/crates/chain-gateway/src/types.rs index 089b6c736..2a1eb1074 100644 --- a/crates/chain-gateway/src/types.rs +++ b/crates/chain-gateway/src/types.rs @@ -1,4 +1,4 @@ -use derive_more::{From, Into}; +use derive_more::{Display, From, Into}; use near_indexer_primitives::CryptoHash; use serde::{Deserialize, Serialize, de::DeserializeOwned}; @@ -9,7 +9,7 @@ use crate::errors::ChainGatewayError; pub struct NoArgs {} #[derive( - Into, From, Copy, Clone, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord, Debug, + Into, From, Copy, Clone, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord, Debug, Display, )] pub struct BlockHeight(u64); diff --git a/crates/chain-gateway/tests/common.rs b/crates/chain-gateway/tests/common.rs index 5f2988980..748a1b801 100644 --- a/crates/chain-gateway/tests/common.rs +++ b/crates/chain-gateway/tests/common.rs @@ -1,3 +1,3 @@ -mod contract; +pub(super) mod accounts; pub(super) mod localnet; pub(super) mod node; diff --git a/crates/chain-gateway/tests/common/accounts.rs b/crates/chain-gateway/tests/common/accounts.rs new file mode 100644 index 000000000..9414c29f6 --- /dev/null +++ b/crates/chain-gateway/tests/common/accounts.rs @@ -0,0 +1,60 @@ +use std::sync::Arc; + +use chain_gateway::transaction_sender::TransactionSigner; +use ed25519_dalek::{SigningKey, VerifyingKey}; + +fn public_key_str(signing_key: &SigningKey) -> String { + let verifying_key: VerifyingKey = signing_key.verifying_key(); + let verifying_key_vec: Vec = verifying_key.as_bytes().to_vec(); + let near_pk: near_sdk::PublicKey = + near_sdk::PublicKey::from_parts(near_sdk::CurveType::ED25519, verifying_key_vec).unwrap(); + String::from(&near_pk) +} + +#[derive(Clone)] +pub struct Contract { + pub account_id: near_account_id::AccountId, + pub signing_key: SigningKey, +} + +impl Contract { + pub fn public_key_str(&self) -> String { + public_key_str(&self.signing_key) + } +} + +pub(super) fn compiled_test_contract_wasm() -> &'static [u8] { + chain_gateway_test_contract::compiled_wasm() +} + +#[derive(Clone)] +pub struct TestAccount { + pub account_id: near_account_id::AccountId, + pub signing_key: SigningKey, + pub signer: Arc, +} + +impl TestAccount { + pub fn new(account_id: near_account_id::AccountId, signing_key: SigningKey) -> Self { + let signer = Arc::new(TransactionSigner::from_key( + account_id.clone(), + signing_key.clone(), + )); + Self { + account_id, + signing_key, + signer, + } + } + pub fn public_key_str(&self) -> String { + public_key_str(&self.signing_key) + } +} + +pub(super) fn test_contract(account_id: near_account_id::AccountId) -> Contract { + let signing_key = SigningKey::from_bytes(&[1u8; 32]); + Contract { + account_id, + signing_key, + } +} diff --git a/crates/chain-gateway/tests/common/contract.rs b/crates/chain-gateway/tests/common/contract.rs deleted file mode 100644 index aefe8b2d1..000000000 --- a/crates/chain-gateway/tests/common/contract.rs +++ /dev/null @@ -1,32 +0,0 @@ -use ed25519_dalek::{SigningKey, VerifyingKey}; - -const TEST_CONTRACT_ACCOUNT: &str = "test-contract.near"; - -#[derive(Clone)] -pub struct Contract { - pub account_id: near_account_id::AccountId, - pub signing_key: SigningKey, -} - -impl Contract { - pub fn public_key_str(&self) -> String { - let verifying_key: VerifyingKey = self.signing_key.verifying_key(); - let verifying_key_vec: Vec = verifying_key.as_bytes().to_vec(); - let near_pk: near_sdk::PublicKey = - near_sdk::PublicKey::from_parts(near_sdk::CurveType::ED25519, verifying_key_vec) - .unwrap(); - String::from(&near_pk) - } -} - -pub(super) fn test_contract() -> Contract { - let signing_key = SigningKey::from_bytes(&[1u8; 32]); - Contract { - account_id: TEST_CONTRACT_ACCOUNT.parse().unwrap(), - signing_key, - } -} - -pub(super) fn compiled_test_contract_wasm() -> &'static [u8] { - chain_gateway_test_contract::compiled_wasm() -} diff --git a/crates/chain-gateway/tests/common/localnet.rs b/crates/chain-gateway/tests/common/localnet.rs index d70ba2a07..1a3b504ea 100644 --- a/crates/chain-gateway/tests/common/localnet.rs +++ b/crates/chain-gateway/tests/common/localnet.rs @@ -1,10 +1,13 @@ use std::time::{Duration, Instant}; +use chain_gateway::event_subscriber::block_events::BlockUpdate; +use chain_gateway::event_subscriber::subscriber::BlockEventSubscriber; use chain_gateway::state_viewer::ViewMethod; use chain_gateway::types::{NoArgs, ObservedState}; use chain_gateway_test_contract::VIEW_METHOD; +use ed25519_dalek::SigningKey; -use super::contract::{Contract, compiled_test_contract_wasm, test_contract}; +use super::accounts::{Contract, TestAccount, compiled_test_contract_wasm, test_contract}; use super::node::{LocalNode, LocalNodeBuilder}; pub struct Localnet { @@ -14,23 +17,70 @@ pub struct Localnet { } impl Localnet { - /// Two-node setup for sender tests. + /// Takes the block update receiver from the observer, panics if already taken. + pub fn take_block_update_receiver(&mut self) -> tokio::sync::mpsc::Receiver { + self.observer + .block_update_receiver + .take() + .expect("block_update_receiver already taken") + } + + pub async fn new(contract_id: near_account_id::AccountId) -> Self { + LocalnetBuilder::new(contract_id).build().await + } +} + +pub struct LocalnetBuilder { + contract_id: near_account_id::AccountId, + test_account: Option, + event_subscriber: Option, +} + +impl LocalnetBuilder { + pub fn new(contract_id: near_account_id::AccountId) -> Self { + LocalnetBuilder { + contract_id, + event_subscriber: None, + test_account: None, + } + } + + pub fn with_event_subscriber(mut self, subscriber: BlockEventSubscriber) -> Self { + self.event_subscriber = Some(subscriber); + self + } + + pub fn with_test_account( + mut self, + test_account_id: near_account_id::AccountId, + ) -> (Self, TestAccount) { + let signing_key = SigningKey::from_bytes(&[3u8; 32]); + let test_account = TestAccount::new(test_account_id, signing_key); + self.test_account = Some(test_account.clone()); + (self, test_account) + } + + /// Build and start the two-node localnet. /// /// The observer is a non-validator node that syncs from the validator. /// Genesis is copied from the validator to ensure both nodes share the same chain. - pub async fn new() -> Self { + pub async fn build(self) -> Localnet { let validator_home = make_test_home_dir("validator.near"); let observer_home = make_test_home_dir("observer.near"); - let contract = test_contract(); + let contract = test_contract(self.contract_id); - // start a validator node (this is what the MPC node calls the "near indexer node") - let validator = LocalNodeBuilder::new("validator", validator_home) + // Start a validator node (this is what the MPC node calls the "near indexer node"). + let mut validator = LocalNodeBuilder::new("validator", validator_home) .with_consensus_min_peers(1) - .with_contract(contract.clone(), compiled_test_contract_wasm()) - .start() - .await; + .with_contract(contract.clone(), compiled_test_contract_wasm()); + + if let Some(test_account) = self.test_account { + validator = validator.with_account(test_account); + } + + let validator = validator.start(None).await; - // start an observer node (non-validator, just like what the MPC node would be running) + // Start an observer node (non-validator, just like what the MPC node would be running). // Copy genesis from validator so both nodes share the same chain // (indexer_init_configs embeds genesis_time = Utc::now(), so independent // genesis generation produces different genesis hashes). @@ -42,7 +92,7 @@ impl Localnet { .with_genesis_from(&validator) .without_validator_key() .with_boot_nodes(&boot_node) - .start() + .start(self.event_subscriber) .await; let localnet = Localnet { @@ -107,7 +157,7 @@ impl Localnet { } } -/// Returns a temp directory +/// Returns a temp directory. /// The returned `TempDir` is automatically deleted when dropped. fn make_test_home_dir(account_id: &str) -> tempfile::TempDir { tempfile::Builder::new() diff --git a/crates/chain-gateway/tests/common/node.rs b/crates/chain-gateway/tests/common/node.rs index d60048c15..351af2417 100644 --- a/crates/chain-gateway/tests/common/node.rs +++ b/crates/chain-gateway/tests/common/node.rs @@ -1,16 +1,20 @@ use base64::Engine; -use chain_gateway::{ChainGateway, NodeHandle}; +use chain_gateway::{ + ChainGateway, NodeHandle, + event_subscriber::{block_events::BlockUpdate, subscriber::BlockEventSubscriber}, +}; use near_indexer::near_primitives::types::Finality; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use tempfile::TempDir; -use super::contract::Contract; +use super::accounts::{Contract, TestAccount}; pub struct LocalNode { pub home_dir: TempDir, pub ports: PortsConfig, pub chain_gateway: ChainGateway, pub node_handle: NodeHandle, + pub block_update_receiver: Option>, } #[derive(Clone)] @@ -25,7 +29,7 @@ pub(crate) struct LocalNodeBuilder { } impl LocalNodeBuilder { - pub(crate) async fn start(self) -> LocalNode { + pub(crate) async fn start(self, streamer_config: Option) -> LocalNode { let indexer_config = near_indexer::IndexerConfig { home_dir: self.home_dir.path().to_path_buf(), sync_mode: near_indexer::SyncModeEnum::LatestSynced, @@ -34,8 +38,8 @@ impl LocalNodeBuilder { validate_genesis: true, }; - let (chain_gateway, node_handle) = - chain_gateway::chain_gateway::ChainGateway::start(indexer_config) + let (chain_gateway, node_handle, block_update_receiver) = + chain_gateway::chain_gateway::ChainGateway::start(indexer_config, streamer_config) .await .expect("chain_gateway::start should succeed"); @@ -46,9 +50,9 @@ impl LocalNodeBuilder { ports: ports.unwrap(), chain_gateway, node_handle, + block_update_receiver, } } - pub(crate) fn new(node_name: &str, home_dir: TempDir) -> Self { let account_id = format!("{}.near", node_name); near_indexer::indexer_init_configs( @@ -138,66 +142,26 @@ impl LocalNodeBuilder { /// and AccessKey (FullAccess). This embeds the contract directly in genesis so /// we don't need to deploy via transaction. pub(crate) fn with_contract(self, contract: Contract, wasm: &[u8]) -> Self { - let genesis_path = self.home_dir.path().join("genesis.json"); - let genesis_text = std::fs::read_to_string(&genesis_path).expect("read genesis.json"); - let mut genesis: serde_json::Value = - serde_json::from_str(&genesis_text).expect("parse genesis.json"); - let existing_total_supply: u128 = genesis - .get("total_supply") - .unwrap() - .as_str() - .expect("total supply should be a string") - .parse() - .expect("total spply should parse as u128"); - let contract_amount: u128 = 10000000000000000000000000; - let total_supply = existing_total_supply + contract_amount; - *genesis.get_mut("total_supply").unwrap() = serde_json::json!(total_supply.to_string()); - - let code_hash = near_indexer::near_primitives::hash::hash(wasm).to_string(); - let code_base64 = base64::engine::general_purpose::STANDARD.encode(wasm); - - let records = genesis - .get_mut("records") - .expect("genesis should have records") - .as_array_mut() - .expect("records should be an array"); - - // Account record - records.push(serde_json::json!({ - "Account": { - "account_id": contract.account_id, - "account": { - "amount": contract_amount.to_string(), - "locked": "0", - "code_hash": code_hash.to_string(), - "storage_usage": 0, - "version": "V1" - } - } - })); - - // Contract record (code field uses base64 encoding, matching StateRecord serde) - records.push(serde_json::json!({ - "Contract": { - "account_id": contract.account_id, - "code": code_base64 - } - })); - - // AccessKey record - records.push(serde_json::json!({ - "AccessKey": { - "account_id": contract.account_id, - "public_key": contract.public_key_str(), - "access_key": { - "nonce": 0, - "permission": "FullAccess" - } - } - })); + inject_genesis_account( + &self.home_dir.path().join("genesis.json"), + &contract.account_id, + &contract.public_key_str(), + Some(wasm), + ); + self + } - let updated = serde_json::to_string_pretty(&genesis).expect("serialize genesis.json"); - std::fs::write(&genesis_path, updated).expect("write genesis.json"); + /// Inject a plain account (no contract code) into genesis.json before the node starts. + /// + /// Adds Account and AccessKey records. Use this for user accounts that only need + /// to sign transactions, not deploy code. + pub(crate) fn with_account(self, account: TestAccount) -> Self { + inject_genesis_account( + &self.home_dir.path().join("genesis.json"), + &account.account_id, + &account.public_key_str(), + None, + ); self } @@ -242,3 +206,81 @@ impl PortsConfig { } } } + +/// Shared helper for injecting an account into genesis.json. +/// +/// When `wasm` is `Some`, the account is treated as a contract: the code hash is +/// computed from the WASM bytes and a `Contract` record is added. +/// When `wasm` is `None`, the account is a plain user account with default code hash. +fn inject_genesis_account( + genesis_path: &Path, + account_id: &near_account_id::AccountId, + public_key_str: &str, + wasm: Option<&[u8]>, +) { + let genesis_text = std::fs::read_to_string(genesis_path).expect("read genesis.json"); + let mut genesis: serde_json::Value = + serde_json::from_str(&genesis_text).expect("parse genesis.json"); + + let existing_total_supply: u128 = genesis + .get("total_supply") + .unwrap() + .as_str() + .expect("total supply should be a string") + .parse() + .expect("total supply should parse as u128"); + let amount: u128 = 10000000000000000000000000; + let total_supply = existing_total_supply + amount; + *genesis.get_mut("total_supply").unwrap() = serde_json::json!(total_supply.to_string()); + + let code_hash = match wasm { + Some(bytes) => near_indexer::near_primitives::hash::hash(bytes).to_string(), + None => "11111111111111111111111111111111".to_string(), + }; + + let records = genesis + .get_mut("records") + .expect("genesis should have records") + .as_array_mut() + .expect("records should be an array"); + + // Account record + records.push(serde_json::json!({ + "Account": { + "account_id": account_id, + "account": { + "amount": amount.to_string(), + "locked": "0", + "code_hash": code_hash, + "storage_usage": 0, + "version": "V1" + } + } + })); + + // Contract record (only for contract accounts) + if let Some(bytes) = wasm { + let code_base64 = base64::engine::general_purpose::STANDARD.encode(bytes); + records.push(serde_json::json!({ + "Contract": { + "account_id": account_id, + "code": code_base64 + } + })); + } + + // AccessKey record + records.push(serde_json::json!({ + "AccessKey": { + "account_id": account_id, + "public_key": public_key_str, + "access_key": { + "nonce": 0, + "permission": "FullAccess" + } + } + })); + + let updated = serde_json::to_string_pretty(&genesis).expect("serialize genesis.json"); + std::fs::write(genesis_path, updated).expect("write genesis.json"); +} diff --git a/crates/chain-gateway/tests/event_subscriber_integration.rs b/crates/chain-gateway/tests/event_subscriber_integration.rs new file mode 100644 index 000000000..64039c2c9 --- /dev/null +++ b/crates/chain-gateway/tests/event_subscriber_integration.rs @@ -0,0 +1,425 @@ +use std::time::Duration; + +use chain_gateway::{ + Gas, + event_subscriber::{ + block_events::{ + BlockEventId, BlockUpdate, EventData, ExecutorFunctionCallSuccessWithPromiseData, + ReceiverFunctionCallData, + }, + subscriber::{BlockEventFilter, BlockEventSubscriber}, + }, + state_viewer::{SubscribeToContractMethod, WatchContractState}, + transaction_sender::{SubmitFunctionCall, TransactionSigner}, +}; +use chain_gateway_test_contract as test_contract; +use common::localnet::Localnet; +use rstest::rstest; + +use crate::common::{accounts::TestAccount, localnet::LocalnetBuilder}; + +use super::common; + +const EVENT_TIMEOUT: Duration = Duration::from_secs(10); +struct ExecutorFunctionCallTest { + test_account: TestAccount, + contract_id: near_account_id::AccountId, + localnet: Localnet, + receiver: tokio::sync::mpsc::Receiver, + set_value_in_promise_event_id: BlockEventId, +} + +async fn setup_executor_function_call_filter() -> ExecutorFunctionCallTest { + let contract_id: near_account_id::AccountId = "test-contract.near".parse().unwrap(); + let mut subscriber = BlockEventSubscriber::new(1); + let set_value_in_promise_event_id = + subscriber.subscribe(BlockEventFilter::ExecutorFunctionCallSuccessWithPromise { + transaction_outcome_executor_id: contract_id.clone(), + method_name: test_contract::SET_VALUE_IN_PROMISE.to_string(), + }); + + let localnet = LocalnetBuilder::new(contract_id.clone()); + let (localnet, test_account) = + localnet.with_test_account("test-subscriber-sender.near".parse().unwrap()); + let mut localnet = localnet.with_event_subscriber(subscriber).build().await; + let receiver = localnet.take_block_update_receiver(); + ExecutorFunctionCallTest { + test_account, + contract_id, + localnet, + receiver, + set_value_in_promise_event_id, + } +} + +/// Spins up a two-node localnet where the observer is started with a +/// `BlockEventSubscriber` filtering for executor function calls. +/// Ensures happy path: successful calls are tracked. +#[tokio::test] +async fn test_event_subscriber_executor_function_call_success_success_calls_are_tracked() { + // Given: A subscription for tracking executions on contract_id.[`SET_VALUE_IN_PROMISE`] + let ExecutorFunctionCallTest { + test_account, + contract_id, + localnet, + mut receiver, + set_value_in_promise_event_id, + } = setup_executor_function_call_filter().await; + let observer_gw = &localnet.observer.chain_gateway; + + // When: A transaction returning a promise succeeds + let args = test_contract::SetValueInPromiseArgs { + value: "succeeded".to_string(), + return_error: false, + }; + let args = serde_json::to_vec(&serde_json::json!({ "args": args })).unwrap(); + + observer_gw + .submit_function_call_tx( + &test_account.signer, + contract_id, + test_contract::SET_VALUE_IN_PROMISE.to_string(), + args.clone(), + Gas::from_teragas(test_contract::SET_VALUE_IN_PROMISE_TGAS), + ) + .await + .unwrap(); + + // Then: expect a matching block update + let BlockUpdate { events, .. } = tokio::time::timeout(EVENT_TIMEOUT, receiver.recv()) + .await + .unwrap() + .unwrap(); + + assert_eq!(events.len(), 1); + + let matched = events + .iter() + .find(|e| e.id == set_value_in_promise_event_id) + .expect("expected executor event"); + + let EventData::ExecutorFunctionCallSuccessWithPromise( + ExecutorFunctionCallSuccessWithPromiseData { + ref predecessor_id, + ref args_raw, + .. + }, + ) = matched.event_data + else { + panic!("expected ExecutorFunctionCallSuccessWithPromise"); + }; + + assert_eq!( + *predecessor_id, test_account.account_id, + "predecessor_id should match user account" + ); + assert_eq!(*args_raw, args, "args must match"); + + localnet.shutdown().await; +} + +/// Ensures failure path: if spawning the promise fails, no executor event is logged. +#[tokio::test] +async fn test_event_subscriber_executor_function_call_success_failure_calls_are_ignored() { + // Given: A subscription for tracking executions on contract_id.[`SET_VALUE_IN_PROMISE`] + let ExecutorFunctionCallTest { + test_account, + contract_id, + localnet, + mut receiver, + set_value_in_promise_event_id: _, + } = setup_executor_function_call_filter().await; + let observer_gw = &localnet.observer.chain_gateway; + + // When: + // A transaction calls contract.SET_VALUE_IN_PROMISE but the spawned promise fails. + // Add a backmarker to not wait indefinitely or be subject to race conditions. + let end_marker: &str = "if you read this, you can be sure that the spawned promise has failed"; + let args = test_contract::SetValueWithMarker { + successfully_spawn_promise: false, + end_marker: end_marker.to_string(), + }; + let args = serde_json::to_vec(&serde_json::json!({ "args": args })).unwrap(); + + observer_gw + .submit_function_call_tx( + &test_account.signer, + contract_id.clone(), + test_contract::SPAWN_PROMISE_WITH_CALLBACK.to_string(), + args.clone(), + Gas::from_teragas(test_contract::SPAWN_PROMISE_WITH_CALLBACK_TGAS), + ) + .await + .unwrap(); + let mut watch_value = observer_gw + .subscribe_to_contract_method::(contract_id, test_contract::VIEW_METHOD) + .await; + + loop { + if watch_value + .latest() + .expect("we don't expect an error") + .value + == end_marker + { + break; + } + tokio::time::timeout(EVENT_TIMEOUT, watch_value.changed()) + .await + .unwrap() + .unwrap(); + } + + drop(watch_value); + + assert!( + receiver.is_empty(), + "expected no executor events for a failed call, found: {:?}", + receiver.recv().await.unwrap() + ); + + localnet.shutdown().await; +} + +struct ReceiverFunctionCallTest { + test_account: TestAccount, + contract_id: near_account_id::AccountId, + contract_signer: TransactionSigner, + localnet: Localnet, + receiver: tokio::sync::mpsc::Receiver, + private_set_event_id: BlockEventId, +} + +async fn setup_receiver_function_call_filter() -> ReceiverFunctionCallTest { + let contract_id: near_account_id::AccountId = "test-contract.near".parse().unwrap(); + let mut subscriber = BlockEventSubscriber::new(1); + let private_set_event_id = subscriber.subscribe(BlockEventFilter::ReceiverFunctionCall { + receipt_receiver_id: contract_id.clone(), + method_name: test_contract::PRIVATE_SET.to_string(), + }); + + let localnet = LocalnetBuilder::new(contract_id.clone()); + let (localnet, test_account) = + localnet.with_test_account("test-subscriber-sender.near".parse().unwrap()); + let mut localnet = localnet.with_event_subscriber(subscriber).build().await; + let contract_signer = + TransactionSigner::from_key(contract_id.clone(), localnet.contract.signing_key.clone()); + let receiver = localnet.take_block_update_receiver(); + ReceiverFunctionCallTest { + test_account, + contract_id, + contract_signer, + localnet, + receiver, + private_set_event_id, + } +} + +/// Ensures `ReceiverFunctionCall` registers for private methods that return success. +#[tokio::test] +#[rstest] +#[case::successful_calls_will_be_logged(true)] +#[case::failed_calls_will_be_logged(false)] +async fn test_event_subscriber_receiver(#[case] expect_success: bool) { + // Given: A subscription for tracking calls to the private contract_id.PRIVATE_SET + let ReceiverFunctionCallTest { + test_account: _, + contract_id, + contract_signer, + localnet, + mut receiver, + private_set_event_id, + } = setup_receiver_function_call_filter().await; + let observer_gw = &localnet.observer.chain_gateway; + + // When: the contract calls itself: + let args = test_contract::PrivateSetArgs { + value: "maybe it works, maybe it doesn't".to_string(), + succeeds: expect_success, + }; + let args = serde_json::to_vec(&serde_json::json!({ "args": args })).unwrap(); + + observer_gw + .submit_function_call_tx( + &contract_signer, + contract_id, + test_contract::PRIVATE_SET.to_string(), + args.clone(), + Gas::from_teragas(test_contract::PRIVATE_SET_ARGS_TGAS), + ) + .await + .unwrap(); + + // Then: expect a matching block update + let BlockUpdate { events, .. } = tokio::time::timeout(EVENT_TIMEOUT, receiver.recv()) + .await + .unwrap() + .unwrap(); + + assert_eq!(events.len(), 1); + + let matched = events + .iter() + .find(|e| e.id == private_set_event_id) + .expect("expected executor event"); + + let EventData::ReceiverFunctionCall(ReceiverFunctionCallData { is_success, .. }) = + matched.event_data + else { + panic!("expected ReceiverFunctionCall"); + }; + assert_eq!(is_success, expect_success); + + localnet.shutdown().await; +} + +/// Ensures `ReceiverFunctionCall` for private methods called by non-contract +/// are registered but have an error (NEAR rejects: predecessor != contract). +#[tokio::test] +async fn test_event_subscriber_receiver_error_if_non_private_call() { + // Given: A subscription for tracking calls to the private contract_id.PRIVATE_SET + let ReceiverFunctionCallTest { + test_account, + contract_id, + contract_signer: _, + localnet, + mut receiver, + private_set_event_id, + } = setup_receiver_function_call_filter().await; + let observer_gw = &localnet.observer.chain_gateway; + + // When: other than the contract calls it: + let args = test_contract::PrivateSetArgs { + value: "this will fail".to_string(), + succeeds: true, + }; + let args = serde_json::to_vec(&serde_json::json!({ "args": args })).unwrap(); + + observer_gw + .submit_function_call_tx( + &test_account.signer, + contract_id, + test_contract::PRIVATE_SET.to_string(), + args.clone(), + Gas::from_teragas(test_contract::PRIVATE_SET_ARGS_TGAS), + ) + .await + .unwrap(); + + // Then: expect a matching block update + let BlockUpdate { events, .. } = tokio::time::timeout(EVENT_TIMEOUT, receiver.recv()) + .await + .unwrap() + .unwrap(); + + assert_eq!(events.len(), 1); + + let matched = events + .iter() + .find(|e| e.id == private_set_event_id) + .expect("expected executor event"); + + let EventData::ReceiverFunctionCall(ReceiverFunctionCallData { is_success, .. }) = + matched.event_data + else { + panic!("expected ReceiverFunctionCall"); + }; + assert!(!is_success); + + localnet.shutdown().await; +} + +/// Verifies that the send-timeout circuit breaker works: when the consumer does not read from the +/// channel and the buffer is full, `listen_blocks` exits with `BlockEventBufferFull` and the +/// receiver channel closes. +#[tokio::test] +async fn test_event_subscriber_backpressure_buffer_full_closes_channel() { + // Given: subscribing to three events that fire in three different blocks + let contract_id: near_account_id::AccountId = + "test-backpressure-handling.near".parse().unwrap(); + let mut subscriber = + BlockEventSubscriber::new(1).with_backpressure_timeout(Duration::from_nanos(1)); + let _ = subscriber.subscribe(BlockEventFilter::ExecutorFunctionCallSuccessWithPromise { + transaction_outcome_executor_id: contract_id.clone(), + method_name: test_contract::SPAWN_PROMISE_WITH_CALLBACK.to_string(), + }); + let _ = subscriber.subscribe(BlockEventFilter::ExecutorFunctionCallSuccessWithPromise { + transaction_outcome_executor_id: contract_id.clone(), + method_name: test_contract::SET_VALUE_IN_PROMISE.to_string(), + }); + let _ = subscriber.subscribe(BlockEventFilter::ReceiverFunctionCall { + receipt_receiver_id: contract_id.clone(), + method_name: test_contract::PRIVATE_SET.to_string(), + }); + let localnet = LocalnetBuilder::new(contract_id.clone()); + let (localnet, test_account) = + localnet.with_test_account("test-subscriber-sender.near".parse().unwrap()); + let mut localnet = localnet.with_event_subscriber(subscriber).build().await; + let mut receiver = localnet.take_block_update_receiver(); + + const MARKER: &str = "race condition avoided"; + + // When: We call the method, leading to the promise chain + let args = test_contract::SetValueWithMarker { + successfully_spawn_promise: true, + end_marker: MARKER.to_string(), + }; + let args = serde_json::to_vec(&serde_json::json!({ "args": args })).unwrap(); + + let observer_gw = &localnet.observer.chain_gateway; + + observer_gw + .submit_function_call_tx( + &test_account.signer, + contract_id.clone(), + test_contract::SPAWN_PROMISE_WITH_CALLBACK.to_string(), + args.clone(), + Gas::from_teragas(test_contract::SPAWN_PROMISE_WITH_CALLBACK_TGAS), + ) + .await + .unwrap(); + + // wait for change to take effect + let mut watch_value = observer_gw + .subscribe_to_contract_method::(contract_id, test_contract::VIEW_METHOD) + .await; + + loop { + if watch_value + .latest() + .expect("we don't expect an error") + .value + == *MARKER + { + break; + } + tokio::time::timeout(EVENT_TIMEOUT, watch_value.changed()) + .await + .unwrap() + .unwrap(); + } + + drop(watch_value); + + // Then: expect the sender to drop the channel and the streamer to close + let mut received_blocks = 0u32; + let closed = tokio::time::timeout(Duration::from_secs(30), async { + // drain the only event that squeezed through before the timeout. + while receiver.recv().await.is_some() { + received_blocks += 1; + } + }) + .await; + + assert!( + closed.is_ok(), + "receiver channel should have closed (listen_blocks exited with BlockEventBufferFull)" + ); + + assert_eq!( + received_blocks, 1, + "buffer size was one, we only expect one block update before the stream closes" + ); + + localnet.shutdown().await; +} diff --git a/crates/chain-gateway/tests/sender_integration.rs b/crates/chain-gateway/tests/sender_integration.rs index 416aa7797..99b9e7dbc 100644 --- a/crates/chain-gateway/tests/sender_integration.rs +++ b/crates/chain-gateway/tests/sender_integration.rs @@ -1,13 +1,11 @@ -use std::sync::Arc; use std::time::{Duration, Instant}; -use chain_gateway::state_viewer::ViewMethod; -use chain_gateway::transaction_sender::{SubmitFunctionCall, TransactionSigner}; +use chain_gateway::Gas; use chain_gateway::types::NoArgs; -use chain_gateway_test_contract::{DEFAULT_VALUE, VIEW_METHOD}; -use common::localnet::Localnet; +use chain_gateway::{state_viewer::ViewMethod, transaction_sender::SubmitFunctionCall}; +use chain_gateway_test_contract as test_contract; -use super::common; +use crate::common::localnet::LocalnetBuilder; /// This integration test uses the `ChainGateway` struct to spin up two neard nodes /// for a localnet. One of the nodes is an observer node (what the MPC node would be running), @@ -20,39 +18,34 @@ use super::common; /// sign and route the transaction. #[tokio::test] async fn test_submit_set_value_and_read_back() { - let localnet = Localnet::new().await; - let observer = &localnet.observer; - let contract = &localnet.contract; - let contract_id = contract.account_id.clone(); + let contract_id: near_account_id::AccountId = "test-contract-sender.near".parse().unwrap(); + let localnet = LocalnetBuilder::new(contract_id.clone()); + let (localnet, user) = localnet.with_test_account("dummy_user.near".parse().unwrap()); + let signer = user.signer; + let localnet = localnet.build().await; + let observer_gw = &localnet.observer.chain_gateway; // Verify initial state: get_value should return DEFAULT_VALUE - let initial: chain_gateway::types::ObservedState = observer - .chain_gateway - .view_method(contract_id.clone(), VIEW_METHOD, &NoArgs {}) + let initial: chain_gateway::types::ObservedState = observer_gw + .view_method(contract_id.clone(), test_contract::VIEW_METHOD, &NoArgs {}) .await .expect("initial view call should succeed"); - assert_eq!(initial.value, DEFAULT_VALUE); + assert_eq!(initial.value, test_contract::DEFAULT_VALUE); - // Submit set_value transaction via the observer + // Submit set_value transaction via the observer, using a separate user account let new_value = "updated by sender test"; - let args = serde_json::json!({ "value": new_value }); - let signer = Arc::new(TransactionSigner::from_key( - contract_id.clone(), - contract.signing_key.clone(), - )); - observer - .chain_gateway + observer_gw .submit_function_call_tx( &signer, contract_id.clone(), - "set_value".to_string(), - serde_json::to_vec(&args).unwrap(), - near_indexer_primitives::types::Gas::from_teragas(30), + test_contract::SET_VALUE.to_string(), + serde_json::to_vec(&serde_json::json!({ "value": new_value })).unwrap(), + Gas::from_teragas(30), ) .await - .expect("submit_function_call_tx should succeed"); + .unwrap(); // Poll get_value until state reflects the new value. let deadline = Instant::now() + Duration::from_secs(30); @@ -60,9 +53,8 @@ async fn test_submit_set_value_and_read_back() { loop { localnet.assert_nodes_alive(); - let result: chain_gateway::types::ObservedState = observer - .chain_gateway - .view_method(contract_id.clone(), VIEW_METHOD, &NoArgs {}) + let result: chain_gateway::types::ObservedState = observer_gw + .view_method(contract_id.clone(), test_contract::VIEW_METHOD, &NoArgs {}) .await .expect("view call should succeed"); diff --git a/crates/chain-gateway/tests/state_viewer_integration.rs b/crates/chain-gateway/tests/state_viewer_integration.rs index 863dc3e33..036e2609e 100644 --- a/crates/chain-gateway/tests/state_viewer_integration.rs +++ b/crates/chain-gateway/tests/state_viewer_integration.rs @@ -4,37 +4,35 @@ use chain_gateway::state_viewer::WatchContractState; use chain_gateway::state_viewer::{SubscribeToContractMethod, ViewMethod}; use chain_gateway::types::NoArgs; use chain_gateway::types::ObservedState; -use chain_gateway_test_contract::{DEFAULT_VALUE, VIEW_METHOD}; +use chain_gateway_test_contract as test_contract; use crate::common::localnet::Localnet; /// Checks if viewing a valid contract method succeeds #[tokio::test] async fn test_view_method_contract_state() { - let localnet = Localnet::new().await; - let contract_account_id = localnet.contract.account_id.clone(); + let contract_id: near_account_id::AccountId = "test-contract-view.near".parse().unwrap(); + let localnet = Localnet::new(contract_id.clone()).await; + let observer_gw = &localnet.observer.chain_gateway; - let value: ObservedState = localnet - .observer - .chain_gateway - .view_method(contract_account_id, VIEW_METHOD, &NoArgs {}) + let value: ObservedState = observer_gw + .view_method(contract_id, test_contract::VIEW_METHOD, &NoArgs {}) .await .expect("view call should succeed"); - assert_eq!(value.value, DEFAULT_VALUE); + assert_eq!(value.value, test_contract::DEFAULT_VALUE); localnet.shutdown().await; } /// Checks if viewing an invalid contract method fails #[tokio::test] async fn test_view_method_nonexistent_method_returns_error() { - let localnet = Localnet::new().await; - let contract_account_id = localnet.contract.account_id.clone(); + let contract_id: near_account_id::AccountId = "test-contract-view-error.near".parse().unwrap(); + let localnet = Localnet::new(contract_id.clone()).await; + let observer_gw = &localnet.observer.chain_gateway; - let result = localnet - .observer - .chain_gateway - .view_method::(contract_account_id, "nonexistent", &NoArgs {}) + let result = observer_gw + .view_method::(contract_id, "nonexistent", &NoArgs {}) .await; let err = result.expect_err("calling a nonexistent method should fail"); @@ -45,18 +43,17 @@ async fn test_view_method_nonexistent_method_returns_error() { /// Checks if subscribing to the state succeeds #[tokio::test] async fn test_subscription_receives_initial_value() { - let localnet = Localnet::new().await; - let contract_account_id = localnet.contract.account_id.clone(); + let contract_id: near_account_id::AccountId = "test-contract-subscribe.near".parse().unwrap(); + let localnet = Localnet::new(contract_id.clone()).await; + let observer_gw = &localnet.observer.chain_gateway; { - let mut sub = localnet - .observer - .chain_gateway - .subscribe_to_contract_method::(contract_account_id, VIEW_METHOD) + let mut sub = observer_gw + .subscribe_to_contract_method::(contract_id, test_contract::VIEW_METHOD) .await; let res = sub.latest().expect("subscription latest should succeed"); - assert_eq!(res.value, DEFAULT_VALUE); + assert_eq!(res.value, test_contract::DEFAULT_VALUE); } localnet.shutdown().await; } diff --git a/crates/chain-gateway/tests/test.rs b/crates/chain-gateway/tests/test.rs index bd61b59b9..548a87e40 100644 --- a/crates/chain-gateway/tests/test.rs +++ b/crates/chain-gateway/tests/test.rs @@ -1,3 +1,5 @@ mod common; +mod event_subscriber_integration; mod sender_integration; mod state_viewer_integration; +mod view_subscription; diff --git a/crates/chain-gateway/tests/view_subscription.rs b/crates/chain-gateway/tests/view_subscription.rs new file mode 100644 index 000000000..43ceb272c --- /dev/null +++ b/crates/chain-gateway/tests/view_subscription.rs @@ -0,0 +1,53 @@ +use std::time::Duration; + +use chain_gateway::{ + Gas, + state_viewer::{SubscribeToContractMethod, WatchContractState}, + transaction_sender::SubmitFunctionCall, +}; +use chain_gateway_test_contract::{DEFAULT_VALUE, SET_VALUE, VIEW_METHOD}; + +use crate::common::localnet::LocalnetBuilder; + +/// Checks if subscribing to the state succeeds +#[tokio::test] +async fn test_subscription() { + let contract_id: near_account_id::AccountId = + "test-contract-subscription.near".parse().unwrap(); + let localnet = LocalnetBuilder::new(contract_id.clone()); + let (localnet, user) = localnet.with_test_account("dummy_user.near".parse().unwrap()); + let signer = user.signer; + let localnet = localnet.build().await; + let observer_gw = &localnet.observer.chain_gateway; + + let mut sub = observer_gw + .subscribe_to_contract_method::(contract_id.clone(), VIEW_METHOD) + .await; + + let res = sub.latest().expect("subscription latest should succeed"); + assert_eq!(res.value, DEFAULT_VALUE); + + // Submit set_value transaction via the observer, using a separate user account + let new_value = "updated by sender test"; + + observer_gw + .submit_function_call_tx( + &signer, + contract_id.clone(), + SET_VALUE.to_string(), + serde_json::to_vec(&serde_json::json!({ "value": new_value })).unwrap(), + Gas::from_teragas(30), + ) + .await + .unwrap(); + + tokio::time::timeout(Duration::from_secs(30), sub.changed()) + .await + .expect("expect subscription to fire on change") + .expect("expect changed to succeed"); + let result = sub.latest().unwrap(); + assert_eq!(result.value, new_value); + + drop(sub); + localnet.shutdown().await; +} diff --git a/docs/chain-gateway-design.md b/docs/chain-gateway-design.md index 65536d985..0a843671b 100644 --- a/docs/chain-gateway-design.md +++ b/docs/chain-gateway-design.md @@ -502,26 +502,20 @@ If we want this interface to be re-usable in other parts of our code, we can cre ```rust impl BlockEventSubscriber { - pub fn new(subscription_replay: SubscriptionReplay) -> Self; - - /// Configure queue size between producer and consumer. - /// we can define overflow behavior later, by default we could just stop producing (neard indexer will consume unlimited amount of memory). - pub fn buffer_size(&mut self, n: usize) -> Self; + /// Create a new subscriber with the given channel buffer size. + pub fn new(buffer_size: usize) -> Self; /// Add a subscription and get a unique identifier for it. - /// Can be called multiple times before build(). - /// the identifier can be used to match a return value to the given subscription id. - pub fn add_subscription(&mut self, filter: SubscriptionFilter) -> SubscriptionId; - - /// Finalise and start streaming. - pub async fn start(&mut self) -> Result, BuilderError>; + /// Can be called multiple times before passing the subscriber to `ChainGateway::start()`. + /// The identifier can be used to match returned events to the given subscription. + pub fn subscribe(&mut self, filter: BlockEventFilter) -> BlockEventId; } -/// an identifier for a subscription -pub struct SubscriptionId(pub u64); +/// An identifier for a subscription, returned by `subscribe()`. +pub struct BlockEventId(pub u64); -/// Filter - can be easily extended later -pub enum SubscriptionFilter { +/// Filter — can be easily extended later. +pub enum BlockEventFilter { /// Filter for events where a receipt outcome was executed by `transaction_outcome_executor_id` and called `method_name`. ExecutorFunctionCall { transaction_outcome_executor_id: AccountId, @@ -533,37 +527,38 @@ pub enum SubscriptionFilter { method_name: String, }, } +``` -/// we want to offer the possibility to re-play blocks if necessary (c.f. [#236](https://github.com/near/mpc/issues/236)) -pub enum SubscriptionReplay { - /// no replay, start once indexer has caught up to the current block height - None, - /// Start at a specific height - BlockHeight(u64), -} +> **Note:** Block replay from a specific height (`SubscriptionReplay`) is planned but not yet implemented. Replay leverages the NEAR indexer's `sync_from_block_height` config option and comes essentially for free in the chain-gateway implementation — we just need to expose it through the `BlockEventSubscriber` API. See [#236](https://github.com/near/mpc/issues/236). + +The subscriber is passed to `ChainGateway::start()`, which returns an `Option>`: +```rust +let (chain_gateway, node_handle, block_update_receiver) = + ChainGateway::start(indexer_config, Some(subscriber)).await?; ``` Example usage: ```rust +let mut subscriber = BlockEventSubscriber::new(100); -let mut subscriber = BlockEventSubscriber::new(SubscriptionReplay::None); - -let signature_requests_id = subscriber.add_subscription( - SubscriptionFilter::ExecutorFunctionCall { +let signature_requests_id = subscriber.subscribe( + BlockEventFilter::ExecutorFunctionCall { transaction_outcome_executor_id: "v1.signer".parse()?, method_name: "sign".to_string(), } ); -let ckd_request_id = subscriber.add_subscription( - SubscriptionFilter::ExecutorFunctionCall { +let ckd_request_id = subscriber.subscribe( + BlockEventFilter::ExecutorFunctionCall { transaction_outcome_executor_id: "v1.signer".parse()?, method_name: "request_app_private_key".to_string(), } ); -let mut block_stream_receiver : tokio::sync::mpsc::Receiver = subscriber.start().await?; +let (chain_gateway, node_handle, block_update_receiver) = + ChainGateway::start(indexer_config, Some(subscriber)).await?; +let mut block_stream_receiver = block_update_receiver.unwrap(); while let Some(update) = block_stream_receiver.recv().await { for matched in update.events { @@ -574,21 +569,20 @@ while let Some(update) = block_stream_receiver.recv().await { } } } - ``` Specific types (c.f. [Appendix](#current-block-update) and `indexer/handler.rs` for justification). ```rust /// The BlockUpdate returned by the Chain indexer. Similar to the current `BlockUpdate` pub struct BlockUpdate { - pub ctx: BlockContext, + pub context: BlockContext, pub events: Vec, } /// Context for a single block pub struct BlockContext { pub hash: CryptoHash, - pub height: u64, + pub height: BlockHeight, pub prev_hash: CryptoHash, pub last_final_block: CryptoHash, pub block_entropy: [u8; 32], @@ -596,34 +590,34 @@ pub struct BlockContext { } pub struct MatchedEvent { - /// this is needed such that the caller can identify the filter - pub id: SubscriptionId, - /// any data associated with that event + /// Identifies which subscription matched this event. + pub id: BlockEventId, + /// Data associated with the event. pub event_data: EventData, } -/// this can be extended if required +/// This can be extended if required. pub enum EventData { - ExecutorFunctionCall(ExecutorFunctionCallEventData), + ExecutorFunctionCall(ExecutorFunctionCallEventWithSuccessReceiptId), ReceiverFunctionCall(ReceiverFunctionCallEventData), } /// This event is associated to a transaction that matched a specific (transaction_outcome_executor_id: AccountId, method_name: String) pattern. -struct ExecutorFunctionCallEventData { +struct ExecutorFunctionCallEventWithSuccessReceiptId { /// the receipt_id of the receipt this event came from receipt_id: CryptoHash, /// predecessor_id who signed the transaction - predecessor_id : AccountId, + predecessor_id: AccountId, /// the receipt that will hold the outcome of this receipt next_receipt_id: CryptoHash, - /// raw bytes used for function call. Could probably also be a String. + /// raw bytes used for function call args_raw: Vec, } -/// This event is associated to a transaction that matched a specific SubscriptionFilter +/// This event is associated to a transaction that matched a specific BlockEventFilter. struct ReceiverFunctionCallEventData { - // the receipt id for the matched transaction - receipt_id: CrpytoHash, + /// the receipt id for the matched transaction + receipt_id: CryptoHash, } ``` From af15d73ce90d4022d57acc019b0b6551ffc6cf4f Mon Sep 17 00:00:00 2001 From: Kevin Deforth <32777623+kevindeforth@users.noreply.github.com.> Date: Fri, 27 Mar 2026 21:26:10 +0200 Subject: [PATCH 2/3] claude comments --- .../src/event_subscriber/streamer.rs | 20 ++++++++++++------- .../streamer/block_processor.rs | 10 +++++----- 2 files changed, 18 insertions(+), 12 deletions(-) diff --git a/crates/chain-gateway/src/event_subscriber/streamer.rs b/crates/chain-gateway/src/event_subscriber/streamer.rs index 10c1ff5c2..90376defe 100644 --- a/crates/chain-gateway/src/event_subscriber/streamer.rs +++ b/crates/chain-gateway/src/event_subscriber/streamer.rs @@ -26,13 +26,19 @@ pub(crate) async fn start( let (stats_tx, stats_rx) = tokio::sync::watch::channel(IndexerStats::new()); let (block_tx, block_rx) = tokio::sync::mpsc::channel(buffer_size); - tokio::spawn(listen_blocks( - stream, - block_events, - stats_tx, - block_tx, - backpressure_timeout, - )); + tokio::spawn(async move { + if let Err(err) = listen_blocks( + stream, + block_events, + stats_tx, + block_tx, + backpressure_timeout, + ) + .await + { + tracing::error!(target: "chain gateway", "block event listener stoppd: {err}"); + } + }); tokio::spawn(indexer_logger(stats_rx, info_fetcher)); Ok(block_rx) diff --git a/crates/chain-gateway/src/event_subscriber/streamer/block_processor.rs b/crates/chain-gateway/src/event_subscriber/streamer/block_processor.rs index 45357688b..7878403ad 100644 --- a/crates/chain-gateway/src/event_subscriber/streamer/block_processor.rs +++ b/crates/chain-gateway/src/event_subscriber/streamer/block_processor.rs @@ -64,14 +64,14 @@ fn filter_executor_function_calls( executor_filters: &BlockEventIdsByContractIds, outcome: &IndexerExecutionOutcomeWithReceipt, ) { - let execution_outcome = outcome.execution_outcome.clone(); + let execution_outcome = &outcome.execution_outcome; let ExecutionStatusView::SuccessReceiptId(next_receipt_id) = execution_outcome.outcome.status else { return; }; let receipt = outcome.receipt.clone(); - let executor_id = execution_outcome.outcome.executor_id; - let Some(filter_methods_for_executor) = executor_filters.filter_methods_for(&executor_id) + let executor_id = &execution_outcome.outcome.executor_id; + let Some(filter_methods_for_executor) = executor_filters.filter_methods_for(executor_id) else { return; }; @@ -101,12 +101,12 @@ fn filter_receipt_function_calls( receiver_filters: &BlockEventIdsByContractIds, outcome: &IndexerExecutionOutcomeWithReceipt, ) { - let receipt = outcome.receipt.clone(); + let receipt = &outcome.receipt; let Some(methods_filter) = receiver_filters.filter_methods_for(&receipt.receiver_id) else { return; }; - let Some((_, contract_method_name)) = try_extract_function_call_args(&receipt) else { + let Some((_, contract_method_name)) = try_extract_function_call_args(receipt) else { return; }; let Some(filter_ids) = methods_filter.filter_ids_for(contract_method_name) else { From 6a70eecc79f9db1bccd864ea10a4806ca615324b Mon Sep 17 00:00:00 2001 From: Kevin Deforth <32777623+kevindeforth@users.noreply.github.com.> Date: Fri, 27 Mar 2026 21:30:15 +0200 Subject: [PATCH 3/3] cargo fmt --- .../src/event_subscriber/streamer/block_processor.rs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/crates/chain-gateway/src/event_subscriber/streamer/block_processor.rs b/crates/chain-gateway/src/event_subscriber/streamer/block_processor.rs index 7878403ad..5a8651601 100644 --- a/crates/chain-gateway/src/event_subscriber/streamer/block_processor.rs +++ b/crates/chain-gateway/src/event_subscriber/streamer/block_processor.rs @@ -71,8 +71,7 @@ fn filter_executor_function_calls( }; let receipt = outcome.receipt.clone(); let executor_id = &execution_outcome.outcome.executor_id; - let Some(filter_methods_for_executor) = executor_filters.filter_methods_for(executor_id) - else { + let Some(filter_methods_for_executor) = executor_filters.filter_methods_for(executor_id) else { return; }; let Some((args, contract_method_name)) = try_extract_function_call_args(&receipt) else {