From 03251f9810b4dc04a99c5cbd39071d43b523ee3f Mon Sep 17 00:00:00 2001 From: soloking1412 Date: Sat, 18 Jul 2026 23:21:22 +0530 Subject: [PATCH 1/2] pallet-revive: implement the eth_newFilter polling filter API --- Cargo.lock | 1 + prdoc/pr_0000.prdoc | 29 ++ substrate/frame/revive/rpc/Cargo.toml | 1 + .../revive/rpc/src/apis/execution_apis.rs | 23 ++ substrate/frame/revive/rpc/src/filters.rs | 258 ++++++++++++++++++ substrate/frame/revive/rpc/src/lib.rs | 141 +++++++++- substrate/frame/revive/rpc/src/tests.rs | 103 +++++++ 7 files changed, 555 insertions(+), 1 deletion(-) create mode 100644 prdoc/pr_0000.prdoc create mode 100644 substrate/frame/revive/rpc/src/filters.rs diff --git a/Cargo.lock b/Cargo.lock index dda0d4d5356a..58b95415b281 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -14117,6 +14117,7 @@ dependencies = [ "pallet-revive-types", "parity-scale-codec", "pretty_assertions", + "rand 0.8.5", "revive-dev-node", "revive-dev-runtime", "rlp 0.6.1", diff --git a/prdoc/pr_0000.prdoc b/prdoc/pr_0000.prdoc new file mode 100644 index 000000000000..3f596ee3625e --- /dev/null +++ b/prdoc/pr_0000.prdoc @@ -0,0 +1,29 @@ +title: 'pallet-revive: implement the eth_newFilter polling filter API' + +doc: +- audience: Node Dev + description: |- + The `pallet-revive` Ethereum JSON-RPC server only offered `eth_subscribe` (a WebSocket push + API) for watching logs and new blocks, leaving the standard HTTP polling filter family + unimplemented. Tooling that cannot hold a WebSocket open — many indexers, serverless + functions, and the default transports of several Ethereum client libraries — relies on that + family and could not watch the chain. + + The following `execution-apis` methods are now supported, matching go-ethereum: + + - `eth_newFilter`: install a log filter (address/topics/range) and get an id. + - `eth_newBlockFilter`: install a filter that reports the hashes of new blocks. + - `eth_getFilterChanges`: poll a filter for the logs or block hashes seen since the last poll. + - `eth_getFilterLogs`: return all logs matching a log filter over its full range. + - `eth_uninstallFilter`: remove a filter. + + Filters are pull-based: each one only remembers the next block to report from, and the log + lookup reuses the existing `eth_getLogs` machinery. Ids are unpredictable (128-bit random) so + a client cannot enumerate or tamper with filters it does not own, and a filter that is not + polled within five minutes is evicted, mirroring go-ethereum and bounding memory use. + `eth_newPendingTransactionFilter` is intentionally out of scope, as the server does not expose + a pending-transaction pool (the same reason `eth_subscribe` has no `newPendingTransactions`). + +crates: +- name: pallet-revive-eth-rpc + bump: minor diff --git a/substrate/frame/revive/rpc/Cargo.toml b/substrate/frame/revive/rpc/Cargo.toml index d427248c77c8..e4102f2e839e 100644 --- a/substrate/frame/revive/rpc/Cargo.toml +++ b/substrate/frame/revive/rpc/Cargo.toml @@ -36,6 +36,7 @@ log = { workspace = true } pallet-revive = { workspace = true, default-features = true } pallet-revive-types = { workspace = true, features = ["std"] } prometheus-endpoint = { workspace = true, default-features = true } +rand = { workspace = true, features = ["std", "std_rng"] } rlp = { workspace = true } sc-cli = { workspace = true, default-features = true } sc-rpc = { workspace = true, default-features = true } diff --git a/substrate/frame/revive/rpc/src/apis/execution_apis.rs b/substrate/frame/revive/rpc/src/apis/execution_apis.rs index b42f421cd738..65c402eda8b5 100644 --- a/substrate/frame/revive/rpc/src/apis/execution_apis.rs +++ b/substrate/frame/revive/rpc/src/apis/execution_apis.rs @@ -99,6 +99,29 @@ pub trait EthRpc { #[method(name = "eth_getLogs")] async fn get_logs(&self, filter: Option) -> RpcResult; + /// Creates a log filter, returning its id. Use `eth_getFilterChanges` to poll it for the logs + /// that matched since the last poll. + #[method(name = "eth_newFilter")] + async fn new_filter(&self, filter: Filter) -> RpcResult; + + /// Creates a block filter, returning its id. Use `eth_getFilterChanges` to poll it for the + /// hashes of blocks added since the last poll. + #[method(name = "eth_newBlockFilter")] + async fn new_block_filter(&self) -> RpcResult; + + /// Polls the filter with the given id, returning the logs (for a log filter) or block hashes + /// (for a block filter) that occurred since the last poll. + #[method(name = "eth_getFilterChanges")] + async fn get_filter_changes(&self, filter_id: U256) -> RpcResult; + + /// Returns all logs matching the log filter with the given id, over its full range. + #[method(name = "eth_getFilterLogs")] + async fn get_filter_logs(&self, filter_id: U256) -> RpcResult; + + /// Uninstalls the filter with the given id. Returns `true` if the filter existed. + #[method(name = "eth_uninstallFilter")] + async fn uninstall_filter(&self, filter_id: U256) -> RpcResult; + /// Returns the value from a storage position at a given address. #[method(name = "eth_getStorageAt")] async fn get_storage_at( diff --git a/substrate/frame/revive/rpc/src/filters.rs b/substrate/frame/revive/rpc/src/filters.rs new file mode 100644 index 000000000000..1ff341d2156b --- /dev/null +++ b/substrate/frame/revive/rpc/src/filters.rs @@ -0,0 +1,258 @@ +// This file is part of Substrate. + +// Copyright (C) Parity Technologies (UK) Ltd. +// SPDX-License-Identifier: Apache-2.0 + +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//! In-memory registry backing the polling filter API (`eth_newFilter`, `eth_newBlockFilter`, +//! `eth_getFilterChanges`, `eth_getFilterLogs`, `eth_uninstallFilter`). +//! +//! Unlike `eth_subscribe`, these methods are pull-based: a client installs a filter and later +//! polls it for whatever changed since the previous poll. The registry therefore only needs to +//! remember, per filter, the next block to report from; the actual log lookup is served on demand +//! by reusing [`crate::client::Client::logs`]. + +use crate::{client::SubstrateBlockNumber, *}; +use std::{ + collections::HashMap, + sync::{Arc, Mutex, MutexGuard}, + time::{Duration, Instant}, +}; + +/// Filters that are not polled within this window are considered abandoned and evicted. This +/// mirrors go-ethereum's default filter deadline and bounds the memory an idle client can retain. +const FILTER_TIMEOUT: Duration = Duration::from_secs(5 * 60); + +/// What a registered filter reports. +enum RegisteredFilter { + /// A log filter. The original [`Filter`] is kept so `eth_getFilterLogs` can replay the whole + /// range and `eth_getFilterChanges` can reuse its address/topics selection. + Logs(Filter), + /// A block filter, reporting the hashes of blocks added since the last poll. + Block, +} + +/// A registered filter together with the bookkeeping needed to serve incremental polls. +struct FilterEntry { + kind: RegisteredFilter, + /// Next block number (inclusive) that a poll should start reporting from. + next_from: SubstrateBlockNumber, + /// Inclusive upper bound for a log filter that pinned a concrete `toBlock`; `None` means the + /// filter follows the chain head. + to: Option, + /// Last time the filter was polled, used to evict abandoned filters. + last_poll: Instant, +} + +/// A snapshot of a filter taken while polling, so the (async) log or block lookup can run without +/// holding the registry lock across an `.await`. +pub enum FilterPoll { + /// Poll a log filter, reporting from `from` up to the (head-capped) upper bound `to`. + Logs { filter: Filter, from: SubstrateBlockNumber, to: Option }, + /// Poll a block filter, reporting block hashes from `from` onwards. + Block { from: SubstrateBlockNumber }, +} + +/// Thread-safe registry of installed filters, shared by the RPC server across all connections. +#[derive(Clone)] +pub struct FilterManager { + inner: Arc>>, + timeout: Duration, +} + +impl Default for FilterManager { + fn default() -> Self { + Self::new() + } +} + +impl FilterManager { + /// Create an empty registry using the default filter timeout. + pub fn new() -> Self { + Self { inner: Arc::new(Mutex::new(HashMap::new())), timeout: FILTER_TIMEOUT } + } + + fn filters(&self) -> MutexGuard<'_, HashMap> { + // The guarded sections only touch a `HashMap` and never panic, so the lock cannot actually + // be poisoned; recover the guard regardless to keep the RPC handlers panic-free. + self.inner.lock().unwrap_or_else(|poisoned| poisoned.into_inner()) + } + + /// Install a log filter reporting from `next_from`, optionally bounded above by `to`. + pub fn install_logs( + &self, + filter: Filter, + next_from: SubstrateBlockNumber, + to: Option, + ) -> U256 { + self.install(FilterEntry { + kind: RegisteredFilter::Logs(filter), + next_from, + to, + last_poll: Instant::now(), + }) + } + + /// Install a block filter reporting from `next_from`. + pub fn install_block(&self, next_from: SubstrateBlockNumber) -> U256 { + self.install(FilterEntry { + kind: RegisteredFilter::Block, + next_from, + to: None, + last_poll: Instant::now(), + }) + } + + fn install(&self, entry: FilterEntry) -> U256 { + let mut filters = self.filters(); + filters.retain(|_, entry| entry.last_poll.elapsed() <= self.timeout); + // Use an unpredictable id so a client cannot enumerate or tamper with filters it does not + // own, matching go-ethereum's random filter ids. + let id = loop { + let candidate = U256::from(rand::random::()); + if !filters.contains_key(&candidate) { + break candidate; + } + }; + filters.insert(id, entry); + id + } + + /// Snapshot a filter for polling and refresh its deadline. Returns `None` (removing the filter) + /// if it is unknown or has expired. + pub fn poll(&self, id: U256) -> Option { + let mut filters = self.filters(); + if filters.get(&id)?.last_poll.elapsed() > self.timeout { + filters.remove(&id); + return None; + } + let entry = filters.get_mut(&id).expect("entry was present on the line above; qed"); + entry.last_poll = Instant::now(); + Some(match &entry.kind { + RegisteredFilter::Logs(filter) => { + FilterPoll::Logs { filter: filter.clone(), from: entry.next_from, to: entry.to } + }, + RegisteredFilter::Block => FilterPoll::Block { from: entry.next_from }, + }) + } + + /// Advance a filter's cursor after a successful poll. A no-op if the filter is gone. + pub fn advance(&self, id: U256, next_from: SubstrateBlockNumber) { + if let Some(entry) = self.filters().get_mut(&id) { + entry.next_from = next_from; + } + } + + /// Return the original [`Filter`] of a log filter and refresh its deadline. Returns `None` for + /// an unknown, expired, or non-log filter. + pub fn logs_filter(&self, id: U256) -> Option { + let mut filters = self.filters(); + if filters.get(&id)?.last_poll.elapsed() > self.timeout { + filters.remove(&id); + return None; + } + let entry = filters.get_mut(&id).expect("entry was present on the line above; qed"); + entry.last_poll = Instant::now(); + match &entry.kind { + RegisteredFilter::Logs(filter) => Some(filter.clone()), + RegisteredFilter::Block => None, + } + } + + /// Remove a filter, returning whether it existed. + pub fn uninstall(&self, id: U256) -> bool { + self.filters().remove(&id).is_some() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn manager() -> FilterManager { + FilterManager::new() + } + + #[test] + fn install_returns_unique_ids() { + let manager = manager(); + let a = manager.install_block(0); + let b = manager.install_block(0); + assert_ne!(a, b, "each installed filter should get a distinct id"); + } + + #[test] + fn poll_log_filter_reports_cursor_then_advances() { + let manager = manager(); + let id = manager.install_logs(Filter::new(), 10, Some(20)); + + let Some(FilterPoll::Logs { from, to, .. }) = manager.poll(id) else { + panic!("expected a log poll"); + }; + assert_eq!(from, 10); + assert_eq!(to, Some(20)); + + // The cursor only moves once the caller has served the range. + manager.advance(id, 21); + let Some(FilterPoll::Logs { from, .. }) = manager.poll(id) else { + panic!("expected a log poll"); + }; + assert_eq!(from, 21); + } + + #[test] + fn poll_block_filter_reports_cursor() { + let manager = manager(); + let id = manager.install_block(5); + let Some(FilterPoll::Block { from }) = manager.poll(id) else { + panic!("expected a block poll"); + }; + assert_eq!(from, 5); + } + + #[test] + fn logs_filter_returns_none_for_block_filter() { + let manager = manager(); + let id = manager.install_block(0); + assert!(manager.logs_filter(id).is_none(), "block filters have no replayable log range"); + } + + #[test] + fn unknown_filter_is_not_found() { + let manager = manager(); + assert!(manager.poll(U256::from(42u64)).is_none()); + assert!(manager.logs_filter(U256::from(42u64)).is_none()); + assert!(!manager.uninstall(U256::from(42u64))); + } + + #[test] + fn uninstall_removes_filter() { + let manager = manager(); + let id = manager.install_logs(Filter::new(), 0, None); + assert!(manager.uninstall(id)); + assert!(!manager.uninstall(id), "a filter can only be uninstalled once"); + assert!(manager.poll(id).is_none(), "a polled-after-uninstall filter is gone"); + } + + #[test] + fn expired_filter_is_evicted_on_poll() { + let manager = + FilterManager { inner: Arc::new(Mutex::new(HashMap::new())), timeout: Duration::ZERO }; + let id = manager.install_block(0); + // With a zero timeout any elapsed time counts as expired. + std::thread::sleep(Duration::from_millis(1)); + assert!(manager.poll(id).is_none(), "an expired filter should not be polled"); + assert!(!manager.uninstall(id), "an expired filter should already be evicted"); + } +} diff --git a/substrate/frame/revive/rpc/src/lib.rs b/substrate/frame/revive/rpc/src/lib.rs index a32f338fb05b..ad61555aa1db 100644 --- a/substrate/frame/revive/rpc/src/lib.rs +++ b/substrate/frame/revive/rpc/src/lib.rs @@ -18,7 +18,7 @@ #![cfg_attr(docsrs, feature(doc_cfg))] pub use alloy_rpc_types::{BlockId, BlockNumberOrTag, Filter, FilterBlockOption}; -use client::ClientError; +use client::{ClientError, SubstrateBlockNumber}; use futures::{Stream, StreamExt, TryStreamExt}; use jsonrpsee::{ PendingSubscriptionSink, SubscriptionMessage, SubscriptionSink, @@ -58,6 +58,9 @@ pub use fee_history_provider::*; mod receipt_extractor; pub use receipt_extractor::*; +mod filters; +pub use filters::*; + mod apis; pub use apis::*; @@ -79,6 +82,9 @@ pub struct EthRpcServerImpl { /// When true, estimate_gas uses Pending block if no block is specified. use_pending_for_estimate_gas: bool, + + /// Registry of installed polling filters (`eth_newFilter` / `eth_newBlockFilter`). + filters: FilterManager, } impl EthRpcServerImpl { @@ -89,6 +95,7 @@ impl EthRpcServerImpl { accounts: vec![], allow_unprotected_txs: false, use_pending_for_estimate_gas: false, + filters: FilterManager::new(), } } @@ -135,6 +142,9 @@ pub enum EthRpcError { /// Received an invalid transaction #[error("Invalid transaction {0:?}")] TransactionTypeNotSupported(Byte), + /// No filter is registered under the given id (it is unknown or has expired). + #[error("filter not found")] + FilterNotFound, } impl From for ErrorObjectOwned { @@ -150,6 +160,7 @@ impl From for ErrorObjectOwned { EthRpcError::InvalidSignature | EthRpcError::AccountNotFound(_) | EthRpcError::InvalidTransaction | + EthRpcError::FilterNotFound | EthRpcError::TransactionTypeNotSupported(_) => CALL_EXECUTION_FAILED_CODE, }; Self::owned::(code, message, None) @@ -433,6 +444,70 @@ impl EthRpcServer for EthRpcServerImpl { Ok(FilterResults::Logs(logs)) } + async fn new_filter(&self, filter: Filter) -> RpcResult { + let head = self.client.block_number().await?; + let (from, to) = self.resolve_filter_range(&filter, head).await?; + Ok(self.filters.install_logs(filter, from, to)) + } + + async fn new_block_filter(&self) -> RpcResult { + let head = self.client.block_number().await?; + // Report only blocks added after the filter is installed, matching go-ethereum. + Ok(self.filters.install_block(head.saturating_add(1))) + } + + async fn get_filter_changes(&self, filter_id: U256) -> RpcResult { + match self.filters.poll(filter_id).ok_or(EthRpcError::FilterNotFound)? { + FilterPoll::Logs { mut filter, from, to } => { + let head = self.client.block_number().await?; + let upper = to.unwrap_or(head).min(head); + if from > upper { + return Ok(FilterResults::Logs(vec![])); + } + + filter.block_option = FilterBlockOption::Range { + from_block: Some(BlockNumberOrTag::Number(from)), + to_block: Some(BlockNumberOrTag::Number(upper)), + }; + let logs = self.client.logs(Some(filter)).await?; + self.filters.advance(filter_id, upper.saturating_add(1)); + Ok(FilterResults::Logs(logs)) + }, + FilterPoll::Block { from } => { + let head = self.client.block_number().await?; + if from > head { + return Ok(FilterResults::Hashes(vec![])); + } + + let mut hashes = Vec::new(); + for number in from..=head { + let Some(block) = self + .client + .block_by_number_or_tag(&BlockNumberOrTag::Number(number)) + .await? + else { + continue; + }; + if let Some(eth_block) = self.client.evm_block(block, false).await { + hashes.push(eth_block.hash); + } + } + self.filters.advance(filter_id, head.saturating_add(1)); + Ok(FilterResults::Hashes(hashes)) + }, + } + } + + async fn get_filter_logs(&self, filter_id: U256) -> RpcResult { + let filter = self.filters.logs_filter(filter_id).ok_or(EthRpcError::FilterNotFound)?; + let logs = self.client.logs(Some(filter)).await?; + Ok(FilterResults::Logs(logs)) + } + + async fn uninstall_filter(&self, filter_id: U256) -> RpcResult { + Ok(self.filters.uninstall(filter_id)) + } + async fn get_storage_at( &self, address: H160, @@ -559,6 +634,69 @@ impl EthRpcServer for EthRpcServerImpl { } impl EthRpcServerImpl { + /// Resolve the block range of a newly installed log filter into concrete block numbers. + /// + /// Returns `(from, to)` where `from` is the first block a poll should report and `to` is the + /// inclusive upper bound (`None` to follow the chain head). A filter without an explicit + /// `fromBlock`, or one pinned to `latest`/`pending`, reports only blocks added after it was + /// installed, matching go-ethereum. + async fn resolve_filter_range( + &self, + filter: &Filter, + head: SubstrateBlockNumber, + ) -> RpcResult<(SubstrateBlockNumber, Option)> { + match &filter.block_option { + FilterBlockOption::AtBlockHash(block_hash) => { + let number = self + .resolve_ethereum_block_number(H256::from_slice(block_hash.as_slice())) + .await?; + Ok((number, Some(number))) + }, + FilterBlockOption::Range { from_block, to_block } => { + let from = match from_block { + None | Some(BlockNumberOrTag::Latest) | Some(BlockNumberOrTag::Pending) => { + head.saturating_add(1) + }, + Some(tag) => self.resolve_block_tag(tag, head).await?, + }; + let to = match to_block { + None | Some(BlockNumberOrTag::Latest) | Some(BlockNumberOrTag::Pending) => None, + Some(tag) => Some(self.resolve_block_tag(tag, head).await?), + }; + Ok((from, to)) + }, + } + } + + /// Resolve a block tag (a number, or `earliest`/`finalized`/`safe`) into a concrete block + /// number, falling back to the chain head if the tagged block cannot be located. + async fn resolve_block_tag( + &self, + tag: &BlockNumberOrTag, + head: SubstrateBlockNumber, + ) -> RpcResult { + if let BlockNumberOrTag::Number(number) = tag { + return Ok(*number); + } + match self.client.block_by_number_or_tag(tag).await? { + Some(block) => Ok(block.number()), + None => Ok(head), + } + } + + /// Resolve an Ethereum block hash to its block number, erroring if the block is unknown. + async fn resolve_ethereum_block_number( + &self, + block_hash: H256, + ) -> RpcResult { + let block = self + .client + .block_by_ethereum_hash(&block_hash) + .await? + .ok_or(ClientError::BlockNotFound)?; + Ok(block.number()) + } + async fn get_transaction_by_substrate_block_hash_and_index( &self, substrate_block_hash: H256, @@ -633,6 +771,7 @@ mod error_codes_tests { EthRpcError::TransactionTypeNotSupported(Byte::from(0x7eu8)), CALL_EXECUTION_FAILED_CODE, ), + (EthRpcError::FilterNotFound, CALL_EXECUTION_FAILED_CODE), ]; for (err, expected_code) in cases { diff --git a/substrate/frame/revive/rpc/src/tests.rs b/substrate/frame/revive/rpc/src/tests.rs index c9ad1fedbe8c..d7cf238596ed 100644 --- a/substrate/frame/revive/rpc/src/tests.rs +++ b/substrate/frame/revive/rpc/src/tests.rs @@ -377,6 +377,7 @@ async fn run_all_eth_rpc_tests_inner() -> anyhow::Result<()> { test_trace_block_returns_v1_trace_on_v1_input_and_v2_trace_on_v2_input, test_transfer, test_deploy_and_call, + test_filter_lifecycle, test_receipt_mixed_revert_and_logs_same_block, test_runtime_api_dry_run_addr_works, test_invalid_transaction, @@ -681,6 +682,108 @@ async fn test_receipt_mixed_revert_and_logs_same_block() -> anyhow::Result<()> { Ok(()) } +/// `FilterResults` is an untagged enum, so an empty JSON array (`[]`) always deserializes to its +/// first variant (`Hashes`). Treat either empty variant as "no changes". +fn is_empty_filter_results(results: &FilterResults) -> bool { + match results { + FilterResults::Hashes(hashes) => hashes.is_empty(), + FilterResults::Logs(logs) => logs.is_empty(), + } +} + +/// Exercise the polling filter API end to end: `eth_newFilter`, `eth_getFilterChanges`, +/// `eth_getFilterLogs`, `eth_newBlockFilter` and `eth_uninstallFilter`. +async fn test_filter_lifecycle() -> anyhow::Result<()> { + let client = Arc::new(SharedResources::client().await); + let account = Account::default(); + + // Deploy a contract that emits `Received(address,uint256)` when it receives value. + let (bytes, _) = pallet_revive_fixtures::compile_module_with_type( + "SimpleReceiver", + pallet_revive_fixtures::FixtureType::Solc, + )?; + let nonce = client.get_transaction_count(account.address(), Default::default()).await?; + let tx = TransactionBuilder::new(client.clone()).input(bytes.to_vec()).send().await?; + let receipt = tx.wait_for_receipt().await?; + let contract_address = create1(&account.address(), nonce.try_into().unwrap()); + assert_eq!(Some(contract_address), receipt.contract_address); + + // Emit a single event. + let value = U256::from(1_000_000_000_000u128); + let tx = TransactionBuilder::new(client.clone()) + .value(value) + .to(contract_address) + .send() + .await?; + let emit_receipt = tx.wait_for_receipt().await?; + + // A log filter scoped to this (uniquely addressed) contract, starting at the emit block. + let filter = Filter::new() + .from_block(emit_receipt.block_number.as_u64()) + .address(AlloyAddress::from_slice(contract_address.as_ref())); + let filter_id = client.new_filter(filter).await?; + + // The first poll returns the emitted log. + let logs = match client.get_filter_changes(filter_id).await? { + FilterResults::Logs(logs) => logs, + other => return Err(anyhow!("Expected Logs from eth_getFilterChanges, got: {other:?}")), + }; + assert_eq!(logs.len(), 1, "filter should report exactly the one emitted log"); + let event_signature = H256(sp_io::hashing::keccak_256(b"Received(address,uint256)")); + assert_eq!(logs[0].address, contract_address); + assert!(!logs[0].topics.is_empty(), "log should carry the event signature topic"); + assert_eq!(logs[0].topics[0], event_signature); + assert_eq!(logs[0].transaction_hash, emit_receipt.transaction_hash); + + // `eth_getFilterLogs` replays the full range and returns the same log. + let all_logs = match client.get_filter_logs(filter_id).await? { + FilterResults::Logs(logs) => logs, + other => return Err(anyhow!("Expected Logs from eth_getFilterLogs, got: {other:?}")), + }; + assert_eq!(all_logs, logs, "getFilterLogs should replay the same log"); + + // A second poll has no new logs, as the cursor advanced past the emit block. + let empty = client.get_filter_changes(filter_id).await?; + assert!(is_empty_filter_results(&empty), "a second poll should be empty, got: {empty:?}"); + + // Uninstall succeeds once; afterwards the id is unknown. + assert!( + client.uninstall_filter(filter_id).await?, + "uninstall should report the filter existed" + ); + assert!(!client.uninstall_filter(filter_id).await?, "a filter can only be uninstalled once"); + let err = client + .get_filter_changes(filter_id) + .await + .expect_err("polling a removed filter should error"); + let ClientError::Call(call) = err else { + return Err(anyhow!("Expected a Call error, got: {err:?}")); + }; + assert_eq!(call.message(), "filter not found"); + + // A block filter reports the hashes of blocks added after it was installed. + let block_filter_id = client.new_block_filter().await?; + let tx = TransactionBuilder::new(client.clone()) + .value(value) + .to(contract_address) + .send() + .await?; + tx.wait_for_receipt().await?; + let block_hashes = match client.get_filter_changes(block_filter_id).await? { + FilterResults::Hashes(hashes) => hashes, + other => return Err(anyhow!("Expected Hashes from a block filter, got: {other:?}")), + }; + assert!(!block_hashes.is_empty(), "block filter should report the newly produced block"); + let newest = block_hashes.last().expect("non-empty checked above"); + assert!( + client.get_block_by_hash(*newest, false).await?.is_some(), + "a reported block hash should resolve to a real block" + ); + assert!(client.uninstall_filter(block_filter_id).await?); + + Ok(()) +} + async fn test_runtime_api_dry_run_addr_works() -> anyhow::Result<()> { let client = Arc::new(SharedResources::client().await); let node_client = SharedResources::node_client().await; From e75a8368f52c28f7d5bb2e56679f9e89d91c9b1d Mon Sep 17 00:00:00 2001 From: soloking1412 Date: Sat, 18 Jul 2026 23:25:34 +0530 Subject: [PATCH 2/2] prdoc: rename to PR number --- prdoc/{pr_0000.prdoc => pr_12685.prdoc} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename prdoc/{pr_0000.prdoc => pr_12685.prdoc} (100%) diff --git a/prdoc/pr_0000.prdoc b/prdoc/pr_12685.prdoc similarity index 100% rename from prdoc/pr_0000.prdoc rename to prdoc/pr_12685.prdoc