Add method to fetch a block's events to the RPC

This commit is contained in:
Luke Parker
2025-11-13 04:50:54 -05:00
parent 367a5769e8
commit 509bd58f4e
10 changed files with 324 additions and 70 deletions

View File

@@ -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<async_lock::RwLockReadGuard<'_, Option<Vec<Event>>>, 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::<Vec<String>>("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::<Result<_, _>>()?,
);
}
}
events = self.events.read().await;
}
Ok(events)
}
/// Scope to the validator sets module.
pub fn validator_sets(&self) -> ValidatorSets<'_> {
ValidatorSets(self)
}
}

View File

@@ -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<Vec<Event>, 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<Vec<Event>, 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<Vec<Event>, RpcError> {
Ok(
self
.events()
.await?
.into_iter()
.filter(|event| matches!(event, Event::AcceptedHandover { .. }))
.collect(),
)
}
}

View File

@@ -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;

View File

@@ -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;
}

View File

@@ -28,18 +28,6 @@ impl crate::Config<CoinsInstance> for Test {
type AllowMint = crate::AlwaysAllowMint;
}
impl TryFrom<RuntimeEvent> for serai_abi::Event {
type Error = ();
fn try_from(event: RuntimeEvent) -> Result<serai_abi::Event, ()> {
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::<Test>::default().build_storage().unwrap();

View File

@@ -29,6 +29,7 @@ fn mint() {
// test events
let mint_events = Core::events()
.iter()
.map(|event| borsh::from_slice::<serai_abi::Event>(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::<serai_abi::Event>(event.as_slice()).unwrap())
.filter_map(|event| {
if let serai_abi::Event::Coins(e) = &event {
if matches!(e, CoinsEvent::BurnWithInstruction { .. }) {

View File

@@ -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<serai_abi::Event>
/// This MUST NOT be called during a transaction/block's execution.
pub fn events() -> Vec<Vec<u8>>
where
serai_abi::Event: TryFrom<T::RuntimeEvent>,
T::RuntimeEvent: TryInto<Event<T>>,
{
frame_system::Pallet::<T>::events()
.into_iter()
.filter_map(|e| serai_abi::Event::try_from(e.event).ok())
frame_system::Pallet::<T>::read_events_no_consensus()
.filter_map(|e| match e.event.try_into() {
Ok(Event::Event(bytes)) => Some(bytes),
_ => None,
})
.collect()
}
}

View File

@@ -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<Block, Error = BlockchainError>
fn block_hash<
C: HeaderMetadata<Block, Error = BlockchainError>
+ HeaderBackend<Block>
+ BlockBackend<Block>
+ ProvideRuntimeApi<Block>,
>(
client: Arc<C>,
) -> Result<RpcModule<impl 'static + Send + Sync>, Box<dyn std::error::Error + Send + Sync>> {
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<<Block as sp_runtime::traits::Block>::Hash, jsonrpsee::types::error::ErrorObjectOwned> {
Ok(if let Ok(block_hash) = params.sequence().next::<String>() {
let Some(block_hash) = hex::decode(&block_hash).ok().and_then(|bytes| {
<[u8; 32]>::try_from(bytes.as_slice())
.map(<Block as sp_runtime::traits::Block>::Hash::from)
@@ -43,6 +36,45 @@ pub(crate) fn module<
Option::<()>::None,
));
};
block_hash
} else {
let Ok(block_number) = params.sequence().next::<u64>() 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<Block, Error = BlockchainError>
+ HeaderBackend<Block>
+ BlockBackend<Block>
+ ProvideRuntimeApi<Block, Api: SeraiApi<Block>>,
>(
client: Arc<C>,
) -> Result<RpcModule<impl 'static + Send + Sync>, Box<dyn std::error::Error + Send + Sync>> {
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, &params)?;
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(<Block as sp_runtime::traits::Block>::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, &params)?;
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, &params)?;
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::<Vec<String>>())
})?;
Ok(module)
}

View File

@@ -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<Vec<u8>>;
fn validators(network_id: NetworkId) -> Vec<Public>;
}
}
@@ -169,6 +173,9 @@ mod apis {
}
impl crate::SeraiApi<Block> for Runtime {
fn events() -> Vec<Vec<u8>> {
unimplemented!("runtime is only implemented when WASM")
}
fn validators(
network: serai_abi::primitives::network_id::NetworkId
) -> Vec<serai_abi::primitives::crypto::Public> {

View File

@@ -505,6 +505,9 @@ sp_api::impl_runtime_apis! {
}
impl crate::SeraiApi<Block> for Runtime {
fn events() -> Vec<Vec<u8>> {
Core::events()
}
fn validators(network: NetworkId) -> Vec<serai_abi::primitives::crypto::Public> {
// 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,