diff --git a/CHANGELOG.md b/CHANGELOG.md index a348d2c7aeb5..bc79910429f4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -31,6 +31,8 @@ - [#5859](https://github.com/ChainSafe/forest/pull/5859) Added size metrics for zstd frame cache and made max size configurable via `FOREST_ZSTD_FRAME_CACHE_DEFAULT_MAX_SIZE` environment variable. +- [#4976](https://github.com/ChainSafe/forest/issues/4976) Add support for the `Filecoin.EthSubscribe` and `Filecoin.EthUnsubscribe` API methods to enable subscriptions to Ethereum event types: `heads` and `logs`. + ### Changed - [#5869](https://github.com/ChainSafe/forest/pull/5869) Updated `forest-cli snapshot export` to print average speed. diff --git a/Cargo.lock b/Cargo.lock index fd073aaa2dd0..87d12e7bbb50 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4894,10 +4894,12 @@ checksum = "1fba77a59c4c644fd48732367624d1bcf6f409f9c9a286fbc71d2f1fc0b2ea16" dependencies = [ "jsonrpsee-core", "jsonrpsee-http-client", + "jsonrpsee-proc-macros", "jsonrpsee-server", "jsonrpsee-types", "jsonrpsee-ws-client", "tokio", + "tracing", ] [[package]] @@ -4973,6 +4975,19 @@ dependencies = [ "url", ] +[[package]] +name = "jsonrpsee-proc-macros" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2fa4f5daed39f982a1bb9d15449a28347490ad42b212f8eaa2a2a344a0dce9e9" +dependencies = [ + "heck 0.5.0", + "proc-macro-crate 3.3.0", + "proc-macro2", + "quote", + "syn 2.0.104", +] + [[package]] name = "jsonrpsee-server" version = "0.25.1" diff --git a/Cargo.toml b/Cargo.toml index ced6fa432dbb..8564a3bf8dec 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -108,7 +108,7 @@ integer-encoding = "4.0" ipld-core = { version = "0.4", features = ["serde", "arb"] } is-terminal = "0.4" itertools = "0.14" -jsonrpsee = { version = "0.25", features = ["server", "ws-client", "http-client"] } +jsonrpsee = { version = "0.25", features = ["server", "ws-client", "http-client", "macros"] } jsonwebtoken = "9" keccak-hash = "0.11" kubert-prometheus-process = "0.2" diff --git a/src/rpc/channel.rs b/src/rpc/channel.rs index 25da40a0f422..22da36412db2 100644 --- a/src/rpc/channel.rs +++ b/src/rpc/channel.rs @@ -367,8 +367,8 @@ impl RpcModule { Ok(msg) => { match create_notif_message(&sink, &msg) { Ok(msg) => { - // This fails only if the connection is closed - if sink.send(msg).await.is_err() { + if let Err(e) = sink.send(msg).await { + tracing::error!("Failed to send message: {:?}", e); break; } } diff --git a/src/rpc/methods/chain.rs b/src/rpc/methods/chain.rs index eb71fe26d674..15cfee927ce3 100644 --- a/src/rpc/methods/chain.rs +++ b/src/rpc/methods/chain.rs @@ -16,6 +16,7 @@ use crate::lotus_json::{HasLotusJson, LotusJson, lotus_json_with_self}; #[cfg(test)] use crate::lotus_json::{assert_all_snapshots, assert_unchanged_via_json}; use crate::message::{ChainMessage, SignedMessage}; +use crate::rpc::eth::{EthLog, eth_logs_with_filter, types::ApiHeaders, types::EthFilterSpec}; use crate::rpc::types::{ApiTipsetKey, Event}; use crate::rpc::{ApiPaths, Ctx, EthEventHandler, Permission, RpcMethod, ServerError}; use crate::shim::clock::ChainEpoch; @@ -45,6 +46,82 @@ use tokio::sync::{ Mutex, broadcast::{self, Receiver as Subscriber}, }; +use tokio::task::JoinHandle; + +const HEAD_CHANNEL_CAPACITY: usize = 10; + +/// Subscribes to head changes from the chain store and broadcasts new blocks. +/// +/// # Notes +/// +/// Spawns an internal `tokio` task that can be aborted anytime via the returned `JoinHandle`, +/// allowing manual cleanup if needed. +pub(crate) fn new_heads( + data: &crate::rpc::RPCState, +) -> (Subscriber, JoinHandle<()>) { + let (sender, receiver) = broadcast::channel(HEAD_CHANNEL_CAPACITY); + + let mut subscriber = data.chain_store().publisher().subscribe(); + + let handle = tokio::spawn(async move { + while let Ok(v) = subscriber.recv().await { + let headers = match v { + HeadChange::Apply(ts) => ApiHeaders(ts.block_headers().clone().into()), + }; + if let Err(e) = sender.send(headers) { + tracing::error!("Failed to send headers: {}", e); + break; + } + } + }); + + (receiver, handle) +} + +/// Subscribes to head changes from the chain store and broadcasts new `Ethereum` logs. +/// +/// # Notes +/// +/// Spawns an internal `tokio` task that can be aborted anytime via the returned `JoinHandle`, +/// allowing manual cleanup if needed. +pub(crate) fn logs( + ctx: &Ctx, + filter: Option, +) -> (Subscriber>, JoinHandle<()>) { + let (sender, receiver) = broadcast::channel(HEAD_CHANNEL_CAPACITY); + + let mut subscriber = ctx.chain_store().publisher().subscribe(); + + let ctx = ctx.clone(); + + let handle = tokio::spawn(async move { + while let Ok(v) = subscriber.recv().await { + match v { + HeadChange::Apply(ts) => { + match eth_logs_with_filter(&ctx, &ts, filter.clone(), None).await { + Ok(logs) => { + if !logs.is_empty() { + if let Err(e) = sender.send(logs) { + tracing::error!( + "Failed to send logs for tipset {}: {}", + ts.key(), + e + ); + break; + } + } + } + Err(e) => { + tracing::error!("Failed to fetch logs for tipset {}: {}", ts.key(), e); + } + } + } + } + } + }); + + (receiver, handle) +} pub enum ChainGetMessage {} impl RpcMethod<1> for ChainGetMessage { @@ -721,7 +798,7 @@ pub(crate) fn chain_notify( _params: Params<'_>, data: &crate::rpc::RPCState, ) -> Subscriber> { - let (sender, receiver) = broadcast::channel(100); + let (sender, receiver) = broadcast::channel(HEAD_CHANNEL_CAPACITY); // As soon as the channel is created, send the current tipset let current = data.chain_store().heaviest_tipset(); diff --git a/src/rpc/methods/eth.rs b/src/rpc/methods/eth.rs index c6cce4b2e6b1..83ee71bfbd19 100644 --- a/src/rpc/methods/eth.rs +++ b/src/rpc/methods/eth.rs @@ -4,6 +4,8 @@ pub(crate) mod errors; mod eth_tx; pub mod filter; +pub mod pubsub; +pub(crate) mod pubsub_trait; mod trace; pub mod types; mod utils; @@ -1325,7 +1327,7 @@ async fn new_eth_tx_receipt( Ok(tx_receipt) } -async fn eth_logs_for_block_and_transaction( +pub async fn eth_logs_for_block_and_transaction( ctx: &Ctx, ts: &Arc, block_hash: &EthHash, @@ -1336,22 +1338,33 @@ async fn eth_logs_for_block_and_transaction( + ctx: &Ctx, + ts: &Arc, + spec: Option, + tx_hash: Option<&EthHash>, +) -> anyhow::Result> { let mut events = vec![]; EthEventHandler::collect_events( ctx, ts, - Some(&spec), + spec.as_ref(), SkipEvent::OnUnresolvedAddress, &mut events, ) .await?; let logs = eth_filter_logs_from_events(ctx, &events)?; - let out: Vec<_> = logs - .into_iter() - .filter(|log| &log.transaction_hash == tx_hash) - .collect(); - Ok(out) + Ok(match tx_hash { + Some(hash) => logs + .into_iter() + .filter(|log| &log.transaction_hash == hash) + .collect(), + None => logs, // no tx hash, keep all logs + }) } fn get_signed_message(ctx: &Ctx, message_cid: Cid) -> Result { @@ -2639,6 +2652,56 @@ impl RpcMethod<1> for EthUninstallFilter { } } +pub enum EthUnsubscribe {} +impl RpcMethod<0> for EthUnsubscribe { + const NAME: &'static str = "Filecoin.EthUnsubscribe"; + const NAME_ALIAS: Option<&'static str> = Some("eth_unsubscribe"); + const PARAM_NAMES: [&'static str; 0] = []; + const API_PATHS: BitFlags = ApiPaths::all(); + const PERMISSION: Permission = Permission::Read; + const SUBSCRIPTION: bool = true; + + type Params = (); + type Ok = (); + + // This method is a placeholder and is never actually called. + // Subscription handling is performed in [`pubsub.rs`](pubsub). + // + // We still need to implement the [`RpcMethod`] trait to expose method metadata + // like [`NAME`](Self::NAME), [`NAME_ALIAS`](Self::NAME_ALIAS), [`PERMISSION`](Self::PERMISSION), etc.. + async fn handle( + _: Ctx, + (): Self::Params, + ) -> Result { + Ok(()) + } +} + +pub enum EthSubscribe {} +impl RpcMethod<0> for EthSubscribe { + const NAME: &'static str = "Filecoin.EthSubscribe"; + const NAME_ALIAS: Option<&'static str> = Some("eth_subscribe"); + const PARAM_NAMES: [&'static str; 0] = []; + const API_PATHS: BitFlags = ApiPaths::all(); + const PERMISSION: Permission = Permission::Read; + const SUBSCRIPTION: bool = true; + + type Params = (); + type Ok = (); + + // This method is a placeholder and is never actually called. + // Subscription handling is performed in [`pubsub.rs`](pubsub). + // + // We still need to implement the [`RpcMethod`] trait to expose method metadata + // like [`NAME`](Self::NAME), [`NAME_ALIAS`](Self::NAME_ALIAS), [`PERMISSION`](Self::PERMISSION), etc.. + async fn handle( + _: Ctx, + (): Self::Params, + ) -> Result { + Ok(()) + } +} + pub enum EthAddressToFilecoinAddress {} impl RpcMethod<1> for EthAddressToFilecoinAddress { const NAME: &'static str = "Filecoin.EthAddressToFilecoinAddress"; diff --git a/src/rpc/methods/eth/pubsub.rs b/src/rpc/methods/eth/pubsub.rs new file mode 100644 index 000000000000..4b954eeb4a3a --- /dev/null +++ b/src/rpc/methods/eth/pubsub.rs @@ -0,0 +1,186 @@ +// Copyright 2019-2025 ChainSafe Systems +// SPDX-License-Identifier: Apache-2.0, MIT + +//! Official documentation for the Ethereum pubsub protocol is available at: +//! https://geth.ethereum.org/docs/interacting-with-geth/rpc/pubsub +//! +//! Note that Filecoin uses this protocol without modifications. +//! +//! The sequence diagram for an event subscription is shown below: +//! ```text +//! ┌─────────────┐ ┌─────────────┐ +//! │ WS Client │ │ Node │ +//! └─────────────┘ └─────────────┘ +//! │ │ +//! │ ┌────────────────────────────────┐ │ +//! │──┤ Subscription message ├───────────────────────────────▶ │ +//! │ │ │ │ +//! │ │{ jsonrpc:'2.0', │ │ +//! │ │ id:, │ │ +//! │ │ method:'eth_subscribe', │ │ +//! │ │ params:[] } │ │ +//! │ └────────────────────────────────┘ │ +//! │ ┌────────────────────────────────┐ │ +//! │ ◀───────────────────────────────┤ Opened subscription message ├──│ +//! │ │ │ │ +//! │ │{ jsonrpc:'2.0', │ │ +//! │ │ id:, │ │ +//! │ │ result: } │ │ +//! │ └────────────────────────────────┘ │ +//! │ │ +//! │ │ +//! │ ┌────────────────────────────────┐ │ +//! │ ◀───────────────────────────────┤ Notification message ├──│ +//! │ │ │ │ +//! │ │{ jsonrpc:'2.0', │ │ +//! │ │ method:'eth_subscription', │ │ +//! │ │ params:{ subscription:,│ │ +//! │ │ result: } } │ │ +//! │ └────────────────────────────────┘ │ +//! │ │ +//! │ │ +//! │ │ +//! │ After a few notifications │ +//! │ ┌────────────────────────────────┐ │ +//! │──┤ Cancel subscription ├───────────────────────────────▶ │ +//! │ │ │ │ +//! │ │{ jsonrpc:'2.0', │ │ +//! │ │ id:, │ │ +//! │ │ method:'eth_unsubscribe', │ │ +//! │ │ params:[] } │ │ +//! │ └────────────────────────────────┘ │ +//! │ ┌────────────────────────────────┐ │ +//! │ ◀───────────────────────────────┤ Closed subscription message ├──│ +//! │ │ │ │ +//! │ │{ jsonrpc:'2.0', │ │ +//! │ │ id:, │ │ +//! │ │ result:true } │ │ +//! │ └────────────────────────────────┘ │ +//! ``` +//! + +use crate::rpc::eth::pubsub_trait::{ + EthPubSubApiServer, LogFilter, SubscriptionKind, SubscriptionParams, +}; +use crate::rpc::{RPCState, chain}; +use fvm_ipld_blockstore::Blockstore; +use jsonrpsee::PendingSubscriptionSink; +use jsonrpsee::core::{SubscriptionError, SubscriptionResult}; +use std::sync::Arc; +use tokio::sync::broadcast::{Receiver as Subscriber, error::RecvError}; + +pub struct EthPubSub { + ctx: Arc>, +} + +impl EthPubSub { + pub fn new(ctx: Arc>) -> Self { + Self { ctx } + } +} + +#[async_trait::async_trait] +impl EthPubSubApiServer for EthPubSub +where + DB: Blockstore + Send + Sync + 'static, +{ + async fn subscribe( + &self, + pending: PendingSubscriptionSink, + kind: SubscriptionKind, + params: Option, + ) -> SubscriptionResult { + let sink = pending.accept().await?; + let ctx = self.ctx.clone(); + + match kind { + SubscriptionKind::NewHeads => self.handle_new_heads_subscription(sink, ctx).await, + SubscriptionKind::PendingTransactions => { + return Err(SubscriptionError::from( + jsonrpsee::types::ErrorObjectOwned::owned( + jsonrpsee::types::error::METHOD_NOT_FOUND_CODE, + "pendingTransactions subscription not yet implemented", + None::<()>, + ), + )); + } + SubscriptionKind::Logs => { + let filter = params.and_then(|p| p.filter); + self.handle_logs_subscription(sink, ctx, filter).await + } + } + + Ok(()) + } +} + +impl EthPubSub +where + DB: Blockstore + Send + Sync + 'static, +{ + async fn handle_new_heads_subscription( + &self, + accepted_sink: jsonrpsee::SubscriptionSink, + ctx: Arc>, + ) { + let (subscriber, handle) = chain::new_heads(&ctx); + tokio::spawn(async move { + handle_subscription(subscriber, accepted_sink, handle).await; + }); + } + + async fn handle_logs_subscription( + &self, + accepted_sink: jsonrpsee::SubscriptionSink, + ctx: Arc>, + filter_spec: Option, + ) { + let filter_spec = filter_spec.map(Into::into); + let (logs, handle) = chain::logs(&ctx, filter_spec); + tokio::spawn(async move { + handle_subscription(logs, accepted_sink, handle).await; + }); + } +} + +async fn handle_subscription( + mut subscriber: Subscriber, + sink: jsonrpsee::SubscriptionSink, + handle: tokio::task::JoinHandle<()>, +) where + T: serde::Serialize + Clone, +{ + loop { + tokio::select! { + action = subscriber.recv() => { + match action { + Ok(v) => { + match jsonrpsee::SubscriptionMessage::new(sink.method_name(), sink.subscription_id(), &v) { + Ok(msg) => { + if let Err(e) = sink.send(msg).await { + tracing::error!("Failed to send message: {:?}", e); + break; + } + } + Err(e) => { + tracing::error!("Failed to serialize message: {:?}", e); + break; + } + } + } + Err(RecvError::Closed) => { + break; + } + Err(RecvError::Lagged(_)) => { + } + } + } + _ = sink.closed() => { + break; + } + } + } + handle.abort(); + + tracing::info!("Subscription task ended (id: {:?})", sink.subscription_id()); +} diff --git a/src/rpc/methods/eth/pubsub_trait.rs b/src/rpc/methods/eth/pubsub_trait.rs new file mode 100644 index 000000000000..39bb57f4f0b1 --- /dev/null +++ b/src/rpc/methods/eth/pubsub_trait.rs @@ -0,0 +1,44 @@ +// Copyright 2019-2025 ChainSafe Systems +// SPDX-License-Identifier: Apache-2.0, MIT + +use crate::rpc::eth::types::{EthAddressList, EthTopicSpec}; +use jsonrpsee::proc_macros::rpc; +use serde::{Deserialize, Serialize}; + +#[rpc(server, namespace = "eth")] +pub trait EthPubSubApi { + /// Subscribe to Ethereum events + #[subscription( + name = "subscribe" => "subscription", + aliases = ["Filecoin.EthSubscribe"], + unsubscribe = "unsubscribe", + unsubscribe_aliases = ["Filecoin.EthUnsubscribe"], + item = serde_json::Value + )] + async fn subscribe( + &self, + kind: SubscriptionKind, + params: Option, + ) -> jsonrpsee::core::SubscriptionResult; +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum SubscriptionKind { + NewHeads, + PendingTransactions, + Logs, +} + +#[derive(Default, Serialize, Deserialize, Debug, Clone)] +#[serde(rename_all = "camelCase")] +pub struct LogFilter { + pub address: EthAddressList, + pub topics: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SubscriptionParams { + #[serde(flatten)] + pub filter: Option, +} diff --git a/src/rpc/methods/eth/types.rs b/src/rpc/methods/eth/types.rs index 9a988dad981f..29087f9a67cb 100644 --- a/src/rpc/methods/eth/types.rs +++ b/src/rpc/methods/eth/types.rs @@ -2,9 +2,14 @@ // SPDX-License-Identifier: Apache-2.0, MIT use super::*; +use crate::blocks::CachingBlockHeader; +use crate::rpc::eth::pubsub_trait::LogFilter; use anyhow::ensure; use ipld_core::serde::SerdeError; +use jsonrpsee::core::traits::IdProvider; +use jsonrpsee::types::SubscriptionId; use libsecp256k1::util::FULL_PUBLIC_KEY_SIZE; +use rand::Rng; use serde::de::{IntoDeserializer, value::StringDeserializer}; use std::{hash::Hash, ops::Deref}; @@ -385,6 +390,16 @@ pub struct FilterID(EthHash); lotus_json_with_self!(FilterID); +#[derive(Debug, Serialize, Deserialize, JsonSchema, PartialEq, Eq, Hash, Clone)] +pub struct SubscriptionID(pub String); + +lotus_json_with_self!(SubscriptionID); + +#[derive(Clone, Serialize, Deserialize, PartialEq, Debug)] +pub struct ApiHeaders(#[serde(with = "crate::lotus_json")] pub Vec); + +lotus_json_with_self!(ApiHeaders); + impl FilterID { pub fn new() -> Result { let raw_id = crate::utils::rand::new_uuid_v4(); @@ -394,6 +409,25 @@ impl FilterID { } } +#[derive(Debug, Copy, Clone)] +pub struct RandomHexStringIdProvider {} + +impl RandomHexStringIdProvider { + pub fn new() -> Self { + Self {} + } +} + +impl IdProvider for RandomHexStringIdProvider { + fn next_id(&self) -> SubscriptionId<'static> { + let mut bytes = [0u8; 32]; + let mut rng = crate::utils::rand::forest_rng(); + rng.fill(&mut bytes); + + SubscriptionId::Str(format!("{}", EthHash::from(bytes)).into()) + } +} + /// `EthHashList` represents a topic filter that can take one of two forms: /// - `List`: Matches if the hash is present in the vector. /// - `Single`: An optional hash, where: @@ -493,6 +527,18 @@ pub struct EthFilterSpec { } lotus_json_with_self!(EthFilterSpec); +impl From for EthFilterSpec { + fn from(filter: LogFilter) -> Self { + EthFilterSpec { + from_block: None, + to_block: None, + block_hash: None, + address: filter.address, + topics: filter.topics, + } + } +} + /// `EthFilterResult` represents the response from executing a filter: /// - A list of block hashes /// - A list of transaction hashes diff --git a/src/rpc/mod.rs b/src/rpc/mod.rs index d37221c23a5c..c14634d11850 100644 --- a/src/rpc/mod.rs +++ b/src/rpc/mod.rs @@ -1,6 +1,7 @@ // Copyright 2019-2025 ChainSafe Systems // SPDX-License-Identifier: Apache-2.0, MIT +use crate::rpc::methods::eth::pubsub_trait::EthPubSubApiServer; mod auth_layer; mod channel; mod client; @@ -12,6 +13,7 @@ mod request; mod segregation_layer; mod set_extension_layer; +use crate::rpc::eth::types::RandomHexStringIdProvider; use crate::shim::clock::ChainEpoch; pub use client::Client; pub use error::ServerError; @@ -121,6 +123,8 @@ macro_rules! for_each_rpc_method { $callback!($crate::rpc::eth::EthNewPendingTransactionFilter); $callback!($crate::rpc::eth::EthNewBlockFilter); $callback!($crate::rpc::eth::EthUninstallFilter); + $callback!($crate::rpc::eth::EthUnsubscribe); + $callback!($crate::rpc::eth::EthSubscribe); $callback!($crate::rpc::eth::EthSyncing); $callback!($crate::rpc::eth::EthTraceBlock); $callback!($crate::rpc::eth::EthTraceFilter); @@ -400,6 +404,7 @@ mod methods { use crate::rpc::auth_layer::AuthLayer; pub use crate::rpc::channel::CANCEL_METHOD_NAME; use crate::rpc::channel::RpcModule as FilRpcModule; +use crate::rpc::eth::pubsub::EthPubSub; use crate::rpc::metrics_layer::MetricsLayer; use crate::{chain_sync::network_context::SyncNetworkContext, key_management::KeyStore}; @@ -507,6 +512,10 @@ where let keystore = state.keystore.clone(); let mut module = create_module(state.clone()); + // register eth subscription APIs + let eth_pubsub = EthPubSub::new(state.clone()); + module.merge(eth_pubsub.into_rpc())?; + let mut pubsub_module = FilRpcModule::default(); pubsub_module.register_channel("Filecoin.ChainNotify", { @@ -526,6 +535,7 @@ where // Default size (10 MiB) is not enough for methods like `Filecoin.StateMinerActiveSectors` .max_request_body_size(MAX_REQUEST_BODY_SIZE) .max_response_body_size(MAX_RESPONSE_BODY_SIZE) + .set_id_provider(RandomHexStringIdProvider::new()) .build(), ) .set_http_middleware( @@ -642,9 +652,13 @@ where let mut module = RpcModule::from_arc(state); macro_rules! register { ($ty:ty) => { - <$ty>::register(&mut module, ParamStructure::ByPosition).unwrap(); - // Optionally register an alias for the method. - <$ty>::register_alias(&mut module).unwrap(); + // Register only non-subscription RPC methods. + // Subscription methods are registered separately in the RPC module. + if !<$ty>::SUBSCRIPTION { + <$ty>::register(&mut module, ParamStructure::ByPosition).unwrap(); + // Optionally register an alias for the method. + <$ty>::register_alias(&mut module).unwrap(); + } }; } for_each_rpc_method!(register); diff --git a/src/rpc/reflect/mod.rs b/src/rpc/reflect/mod.rs index 7810ddaad784..540edb9d27b2 100644 --- a/src/rpc/reflect/mod.rs +++ b/src/rpc/reflect/mod.rs @@ -80,6 +80,8 @@ pub trait RpcMethod { ctx: Ctx, params: Self::Params, ) -> impl Future> + Send; + /// If it a subscription method. Defaults to false. + const SUBSCRIPTION: bool = false; } /// The permission required to call an RPC method. diff --git a/src/tool/subcommands/api_cmd/test_snapshots_ignored.txt b/src/tool/subcommands/api_cmd/test_snapshots_ignored.txt index d665bb42e907..a577cfa29abe 100644 --- a/src/tool/subcommands/api_cmd/test_snapshots_ignored.txt +++ b/src/tool/subcommands/api_cmd/test_snapshots_ignored.txt @@ -18,6 +18,8 @@ Filecoin.EthGetFilterChanges Filecoin.EthGetFilterLogs Filecoin.EthSendRawTransaction Filecoin.EthSyncing +Filecoin.EthSubscribe +Filecoin.EthUnsubscribe Filecoin.F3GetCertificate Filecoin.F3GetECPowerTable Filecoin.F3GetF3PowerTable