From 509bd58f4e99e87c8b124cc41fa9eff18160c17c Mon Sep 17 00:00:00 2001 From: Luke Parker Date: Thu, 13 Nov 2025 04:50:54 -0500 Subject: [PATCH] Add method to fetch a block's events to the RPC --- substrate/client/serai/src/lib.rs | 48 ++++++- substrate/client/serai/src/validator_sets.rs | 50 +++++++ substrate/client/serai/tests/blockchain.rs | 12 ++ .../client/serai/tests/validator_sets.rs | 131 ++++++++++++++++++ substrate/coins/src/mock.rs | 12 -- substrate/coins/src/tests.rs | 2 + substrate/core/src/lib.rs | 15 +- substrate/node/src/rpc/blockchain.rs | 106 ++++++++------ substrate/runtime/src/lib.rs | 15 +- substrate/runtime/src/wasm/mod.rs | 3 + 10 files changed, 324 insertions(+), 70 deletions(-) create mode 100644 substrate/client/serai/src/validator_sets.rs create mode 100644 substrate/client/serai/tests/validator_sets.rs diff --git a/substrate/client/serai/src/lib.rs b/substrate/client/serai/src/lib.rs index 9020a1b4..8b766ffe 100644 --- a/substrate/client/serai/src/lib.rs +++ b/substrate/client/serai/src/lib.rs @@ -2,7 +2,7 @@ #![doc = include_str!("../README.md")] #![deny(missing_docs)] -use core::future::Future; +use core::{ops::Deref, convert::AsRef, future::Future}; use std::{sync::Arc, io::Read}; use thiserror::Error; @@ -19,6 +19,10 @@ use abi::{ use async_lock::RwLock; +/// RPC client functionality for the validator sets module. +pub mod validator_sets; +use validator_sets::*; + /// An error from the RPC. #[derive(Debug, Error)] pub enum RpcError { @@ -177,3 +181,45 @@ impl Serai { .await } } + +impl<'a> TemporalSerai<'a> { + /// Fetch the events for this block. + /// + /// The returned `Option` will always be `Some(_)`. + async fn events(&self) -> Result>>, RpcError> { + let mut events = self.events.read().await; + if events.is_none() { + drop(events); + { + let mut events_mut = self.events.write().await; + if events_mut.is_none() { + *events_mut = Some( + self + .serai + .call::>("serai_events", &format!(r#"["{}"]"#, self.block)) + .await? + .into_iter() + .map(|event| { + Event::deserialize( + &mut hex::decode(&event) + .map_err(|_| { + RpcError::InvalidNode("node returned non-hex-encoded event".to_string()) + })? + .as_slice(), + ) + .map_err(|_| RpcError::InvalidNode("node returned invalid event".to_string())) + }) + .collect::>()?, + ); + } + } + events = self.events.read().await; + } + Ok(events) + } + + /// Scope to the validator sets module. + pub fn validator_sets(&self) -> ValidatorSets<'_> { + ValidatorSets(self) + } +} diff --git a/substrate/client/serai/src/validator_sets.rs b/substrate/client/serai/src/validator_sets.rs new file mode 100644 index 00000000..2bd0a77c --- /dev/null +++ b/substrate/client/serai/src/validator_sets.rs @@ -0,0 +1,50 @@ +pub use serai_abi::validator_sets::Event; +use crate::{RpcError, TemporalSerai}; + +/// A `TemporalSerai` scoped to the validator sets module. +#[derive(Clone)] +pub struct ValidatorSets<'a>(pub(super) &'a TemporalSerai<'a>); + +impl<'a> ValidatorSets<'a> { + /// The events from the validator sets module. + pub async fn events(&self) -> Result, RpcError> { + Ok( + self + .0 + .events() + .await? + .as_ref() + .expect("`TemporalSerai::events` returned None") + .iter() + .filter_map(|event| match event { + serai_abi::Event::ValidatorSets(event) => Some(event.clone()), + _ => None, + }) + .collect(), + ) + } + + /// The `SetDecided` events from the validator sets module. + pub async fn set_decided_events(&self) -> Result, RpcError> { + Ok( + self + .events() + .await? + .into_iter() + .filter(|event| matches!(event, Event::SetDecided { .. })) + .collect(), + ) + } + + /// The `AcceptedHandover` events from the validator sets module. + pub async fn accepted_handover_events(&self) -> Result, RpcError> { + Ok( + self + .events() + .await? + .into_iter() + .filter(|event| matches!(event, Event::AcceptedHandover { .. })) + .collect(), + ) + } +} diff --git a/substrate/client/serai/tests/blockchain.rs b/substrate/client/serai/tests/blockchain.rs index bfc315da..78866aa3 100644 --- a/substrate/client/serai/tests/blockchain.rs +++ b/substrate/client/serai/tests/blockchain.rs @@ -22,6 +22,18 @@ async fn blockchain() { .run_async(async |ops| { let serai = serai_substrate_tests::rpc(&ops, handle).await; + 'outer: { + for _ in 0 .. (5 * 10) { + tokio::time::sleep(core::time::Duration::from_secs(6)).await; + + let latest_finalized = serai.latest_finalized_block_number().await.unwrap(); + if latest_finalized > 0 { + break 'outer; + } + } + panic!("finalized block remained the genesis block for over five minutes"); + }; + // Check the sanity of fetching a block let test_finalized_block = |number| { let serai = &serai; diff --git a/substrate/client/serai/tests/validator_sets.rs b/substrate/client/serai/tests/validator_sets.rs new file mode 100644 index 00000000..a7458681 --- /dev/null +++ b/substrate/client/serai/tests/validator_sets.rs @@ -0,0 +1,131 @@ +use serai_abi::{ + primitives::{ + network_id::{ExternalNetworkId, NetworkId}, + validator_sets::{Session, ValidatorSet}, + }, + validator_sets::Event, +}; + +use serai_client_serai::*; + +#[tokio::test] +async fn validator_sets() { + let mut test = dockertest::DockerTest::new(); + let (composition, handle) = serai_substrate_tests::composition( + "alice", + serai_docker_tests::fresh_logs_folder(true, "serai-client/validator_sets"), + ); + test.provide_container( + composition + .replace_cmd( + ["serai-node", "--unsafe-rpc-external", "--rpc-cors", "all", "--dev"] + .into_iter() + .map(str::to_owned) + .collect(), + ) + .replace_env([("RUST_LOG".to_string(), "runtime=debug".to_string())].into()), + ); + + test + .run_async(async |ops| { + let serai = serai_substrate_tests::rpc(&ops, handle).await; + + 'outer: { + for _ in 0 .. (5 * 10) { + tokio::time::sleep(core::time::Duration::from_secs(6)).await; + + let latest_finalized = serai.latest_finalized_block_number().await.unwrap(); + if latest_finalized > 0 { + break 'outer; + } + } + panic!("finalized block remained the genesis block for over five minutes"); + }; + + // The genesis block should have the expected events + { + { + let mut events = serai + .as_of(serai.block_by_number(0).await.unwrap().header.hash()) + .await + .unwrap() + .validator_sets() + .set_decided_events() + .await + .unwrap(); + events.sort_by_key(|event| borsh::to_vec(event).unwrap()); + let mut expected = vec![ + Event::SetDecided { + set: ValidatorSet { network: NetworkId::Serai, session: Session(0) }, + }, + Event::SetDecided { + set: ValidatorSet { network: NetworkId::Serai, session: Session(1) }, + }, + Event::SetDecided { + set: ValidatorSet { + network: NetworkId::External(ExternalNetworkId::Bitcoin), + session: Session(0), + }, + }, + Event::SetDecided { + set: ValidatorSet { + network: NetworkId::External(ExternalNetworkId::Ethereum), + session: Session(0), + }, + }, + Event::SetDecided { + set: ValidatorSet { + network: NetworkId::External(ExternalNetworkId::Monero), + session: Session(0), + }, + }, + ]; + expected.sort_by_key(|event| borsh::to_vec(event).unwrap()); + assert_eq!(events, expected); + } + + assert_eq!( + serai + .as_of(serai.block_by_number(0).await.unwrap().header.hash()) + .await + .unwrap() + .validator_sets() + .accepted_handover_events() + .await + .unwrap(), + vec![Event::AcceptedHandover { + set: ValidatorSet { network: NetworkId::Serai, session: Session(0) } + }] + ); + } + + // The next block should not have these events + { + assert_eq!( + serai + .as_of(serai.block_by_number(1).await.unwrap().header.hash()) + .await + .unwrap() + .validator_sets() + .set_decided_events() + .await + .unwrap(), + vec![], + ); + assert_eq!( + serai + .as_of(serai.block_by_number(1).await.unwrap().header.hash()) + .await + .unwrap() + .validator_sets() + .accepted_handover_events() + .await + .unwrap(), + vec![], + ); + } + + println!("Finished `serai-client/blockchain` test"); + }) + .await; +} diff --git a/substrate/coins/src/mock.rs b/substrate/coins/src/mock.rs index 67cdcf5c..a9e7bb61 100644 --- a/substrate/coins/src/mock.rs +++ b/substrate/coins/src/mock.rs @@ -28,18 +28,6 @@ impl crate::Config for Test { type AllowMint = crate::AlwaysAllowMint; } -impl TryFrom for serai_abi::Event { - type Error = (); - fn try_from(event: RuntimeEvent) -> Result { - match event { - RuntimeEvent::Core(serai_core_pallet::Event::Event(event)) => { - Ok(serai_abi::Event::deserialize_reader(&mut event.as_slice()).unwrap()) - } - _ => Err(()), - } - } -} - pub(crate) fn new_test_ext() -> sp_io::TestExternalities { let mut storage = frame_system::GenesisConfig::::default().build_storage().unwrap(); diff --git a/substrate/coins/src/tests.rs b/substrate/coins/src/tests.rs index 974ece88..f6cdc8f4 100644 --- a/substrate/coins/src/tests.rs +++ b/substrate/coins/src/tests.rs @@ -29,6 +29,7 @@ fn mint() { // test events let mint_events = Core::events() .iter() + .map(|event| borsh::from_slice::(event.as_slice()).unwrap()) .filter_map(|event| { if let serai_abi::Event::Coins(e) = &event { if matches!(e, CoinsEvent::Mint { .. }) { @@ -82,6 +83,7 @@ fn burn_with_instruction() { let burn_events = Core::events() .iter() + .map(|event| borsh::from_slice::(event.as_slice()).unwrap()) .filter_map(|event| { if let serai_abi::Event::Coins(e) = &event { if matches!(e, CoinsEvent::BurnWithInstruction { .. }) { diff --git a/substrate/core/src/lib.rs b/substrate/core/src/lib.rs index 9b88713a..40c2ad2c 100644 --- a/substrate/core/src/lib.rs +++ b/substrate/core/src/lib.rs @@ -132,15 +132,16 @@ pub mod pallet { /// Fetch all of Serai's events. /// - /// This MUST only be used for testing purposes. - #[cfg(any(feature = "std", feature = "runtime-benchmarks", test))] - pub fn events() -> Vec + /// This MUST NOT be called during a transaction/block's execution. + pub fn events() -> Vec> where - serai_abi::Event: TryFrom, + T::RuntimeEvent: TryInto>, { - frame_system::Pallet::::events() - .into_iter() - .filter_map(|e| serai_abi::Event::try_from(e.event).ok()) + frame_system::Pallet::::read_events_no_consensus() + .filter_map(|e| match e.event.try_into() { + Ok(Event::Event(bytes)) => Some(bytes), + _ => None, + }) .collect() } } diff --git a/substrate/node/src/rpc/blockchain.rs b/substrate/node/src/rpc/blockchain.rs index 375b7820..a2d7a9af 100644 --- a/substrate/node/src/rpc/blockchain.rs +++ b/substrate/node/src/rpc/blockchain.rs @@ -11,27 +11,20 @@ use sc_client_api::BlockBackend; use serai_abi::{primitives::prelude::*, SubstrateBlock as Block}; +use serai_runtime::SeraiApi; + use jsonrpsee::RpcModule; -pub(crate) fn module< - C: 'static - + Send - + Sync - + HeaderMetadata +fn block_hash< + C: HeaderMetadata + HeaderBackend + BlockBackend + ProvideRuntimeApi, >( - client: Arc, -) -> Result, Box> { - let mut module = RpcModule::new(client); - - module.register_method("serai_latestFinalizedBlockNumber", |_params, client, _ext| { - client.info().finalized_number - }); - - module.register_method("serai_isFinalized", |params, client, _ext| { - let [block_hash]: [String; 1] = params.parse()?; + client: &C, + params: &jsonrpsee::types::params::Params, +) -> Result<::Hash, jsonrpsee::types::error::ErrorObjectOwned> { + Ok(if let Ok(block_hash) = params.sequence().next::() { let Some(block_hash) = hex::decode(&block_hash).ok().and_then(|bytes| { <[u8; 32]>::try_from(bytes.as_slice()) .map(::Hash::from) @@ -43,6 +36,45 @@ pub(crate) fn module< Option::<()>::None, )); }; + block_hash + } else { + let Ok(block_number) = params.sequence().next::() else { + return Err(jsonrpsee::types::error::ErrorObjectOwned::owned( + -1, + "requested block wasn't a valid hash nor number", + Option::<()>::None, + )); + }; + let Ok(Some(block_hash)) = client.block_hash(block_number) else { + return Err(jsonrpsee::types::error::ErrorObjectOwned::owned( + -2, + "couldn't find requested block's hash", + Option::<()>::None, + )); + }; + block_hash + }) +} + +pub(crate) fn module< + C: 'static + + Send + + Sync + + HeaderMetadata + + HeaderBackend + + BlockBackend + + ProvideRuntimeApi>, +>( + client: Arc, +) -> Result, Box> { + let mut module = RpcModule::new(client); + + module.register_method("serai_latestFinalizedBlockNumber", |_params, client, _ext| { + client.info().finalized_number + }); + + module.register_method("serai_isFinalized", |params, client, _ext| { + let block_hash = block_hash(&**client, ¶ms)?; let finalized = client.info().finalized_number; let Ok(Some(number)) = client.number(block_hash) else { return Err(jsonrpsee::types::error::ErrorObjectOwned::owned( @@ -65,37 +97,7 @@ pub(crate) fn module< })?; module.register_method("serai_block", |params, client, _ext| { - let block_hash = if let Ok([block_hash]) = params.parse::<[String; 1]>() { - let Some(block_hash) = hex::decode(&block_hash).ok().and_then(|bytes| { - <[u8; 32]>::try_from(bytes.as_slice()) - .map(::Hash::from) - .ok() - }) else { - return Err(jsonrpsee::types::error::ErrorObjectOwned::owned( - -1, - "requested block hash wasn't a valid hash", - Option::<()>::None, - )); - }; - block_hash - } else { - let Ok([block_number]) = params.parse::<[u64; 1]>() else { - return Err(jsonrpsee::types::error::ErrorObjectOwned::owned( - -1, - "requested block wasn't a valid hash nor number", - Option::<()>::None, - )); - }; - let Ok(Some(block_hash)) = client.block_hash(block_number) else { - return Err(jsonrpsee::types::error::ErrorObjectOwned::owned( - -2, - "couldn't find requested block's hash", - Option::<()>::None, - )); - }; - block_hash - }; - + let block_hash = block_hash(&**client, ¶ms)?; let Ok(Some(block)) = client.block(block_hash) else { return Err(jsonrpsee::types::error::ErrorObjectOwned::owned( -2, @@ -107,5 +109,17 @@ pub(crate) fn module< Ok(hex::encode(borsh::to_vec(&serai_abi::Block::from(block.block)).unwrap())) })?; + module.register_method("serai_events", |params, client, _ext| { + let block_hash = block_hash(&**client, ¶ms)?; + let Ok(events) = client.runtime_api().events(block_hash) else { + return Err(jsonrpsee::types::error::ErrorObjectOwned::owned( + -2, + "couldn't fetch the events for the requested block", + Option::<()>::None, + )); + }; + Ok(events.into_iter().map(hex::encode).collect::>()) + })?; + Ok(module) } diff --git a/substrate/runtime/src/lib.rs b/substrate/runtime/src/lib.rs index 37a8c8c2..e2cd527c 100644 --- a/substrate/runtime/src/lib.rs +++ b/substrate/runtime/src/lib.rs @@ -4,10 +4,13 @@ extern crate alloc; use alloc::vec::Vec; -use serai_abi::primitives::{ - crypto::{Public, SignedEmbeddedEllipticCurveKeys}, - network_id::NetworkId, - balance::Balance, +use serai_abi::{ + primitives::{ + crypto::{Public, SignedEmbeddedEllipticCurveKeys}, + network_id::NetworkId, + balance::Balance, + }, + Event, }; #[cfg(feature = "std")] @@ -30,6 +33,7 @@ sp_api::decl_runtime_apis! { fn build(genesis: GenesisConfig); } pub trait SeraiApi { + fn events() -> Vec>; fn validators(network_id: NetworkId) -> Vec; } } @@ -169,6 +173,9 @@ mod apis { } impl crate::SeraiApi for Runtime { + fn events() -> Vec> { + unimplemented!("runtime is only implemented when WASM") + } fn validators( network: serai_abi::primitives::network_id::NetworkId ) -> Vec { diff --git a/substrate/runtime/src/wasm/mod.rs b/substrate/runtime/src/wasm/mod.rs index 4bcc244c..05a7dc9f 100644 --- a/substrate/runtime/src/wasm/mod.rs +++ b/substrate/runtime/src/wasm/mod.rs @@ -505,6 +505,9 @@ sp_api::impl_runtime_apis! { } impl crate::SeraiApi for Runtime { + fn events() -> Vec> { + Core::events() + } fn validators(network: NetworkId) -> Vec { // Returning the latest-decided, not latest and active, means the active set // may fail to peer find if there isn't sufficient overlap. If a large amount reboot,