Add initial basic tests for serai-client-serai

This commit is contained in:
Luke Parker
2025-11-06 20:12:37 -05:00
parent 1866bb7ae3
commit ce08fad931
7 changed files with 147 additions and 26 deletions

View File

@@ -286,6 +286,13 @@ mod substrate {
header: SubstrateHeader, header: SubstrateHeader,
transactions: Vec<Transaction>, transactions: Vec<Transaction>,
} }
impl From<SubstrateBlock> for Block {
fn from(block: SubstrateBlock) -> Self {
Self { header: (&block.header).into(), transactions: block.transactions }
}
}
impl sp_core::serde::Serialize for SubstrateBlock { impl sp_core::serde::Serialize for SubstrateBlock {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error> fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where where

View File

@@ -27,3 +27,6 @@ borsh = { version = "1", default-features = false, features = ["std"] }
serai-abi = { path = "../../abi", version = "0.1" } serai-abi = { path = "../../abi", version = "0.1" }
async-lock = "3" async-lock = "3"
[dev-dependencies]
tokio = { version = "1", default-features = false, features = ["rt", "macros"] }

View File

@@ -2,7 +2,8 @@
#![doc = include_str!("../README.md")] #![doc = include_str!("../README.md")]
#![deny(missing_docs)] #![deny(missing_docs)]
use std::io::Read; use core::future::Future;
use std::{sync::Arc, io::Read};
use thiserror::Error; use thiserror::Error;
use core_json_traits::{JsonDeserialize, JsonStructure}; use core_json_traits::{JsonDeserialize, JsonStructure};
@@ -11,7 +12,10 @@ use simple_request::{hyper, Request, TokioClient};
use borsh::BorshDeserialize; use borsh::BorshDeserialize;
pub use serai_abi as abi; pub use serai_abi as abi;
use abi::{primitives::network_id::ExternalNetworkId, Event}; use abi::{
primitives::{BlockHash, network_id::ExternalNetworkId},
Block, Event,
};
use async_lock::RwLock; use async_lock::RwLock;
@@ -23,13 +27,16 @@ pub enum RpcError {
InternalError(String), InternalError(String),
/// A failure with the connection occurred. /// A failure with the connection occurred.
#[error("failed to communicate with serai")] #[error("failed to communicate with serai")]
ConnectionError, ConnectionError(simple_request::Error),
/// The node provided an invalid response. /// The node provided an invalid response.
#[error("node is faulty: {0}")] #[error("node is faulty: {0}")]
InvalidNode(String), InvalidNode(String),
/// The response contained an error. /// The response contained an error.
#[error("error in response: {0}")] #[error("error in response: {0}")]
ErrorInResponse(String), ErrorInResponse(String),
/// The requested block wasn't finalized.
#[error("the requested block wasn't finalized")]
NotFinalized,
} }
/// An RPC client to a Serai node. /// An RPC client to a Serai node.
@@ -40,15 +47,15 @@ pub struct Serai {
} }
/// An RPC client to a Serai node, scoped to a specific block. /// An RPC client to a Serai node, scoped to a specific block.
///
/// Upon any request being made for the events emitted by this block, the entire list of events
/// from this block will be cached within this. This allows future calls for events to be done
/// cheaply.
#[derive(Clone)]
pub struct TemporalSerai<'a> { pub struct TemporalSerai<'a> {
serai: &'a Serai, serai: &'a Serai,
block: [u8; 32], block: BlockHash,
events: RwLock<Option<Vec<Event>>>, events: Arc<RwLock<Option<Vec<Event>>>>,
}
impl Clone for TemporalSerai<'_> {
fn clone(&self) -> Self {
Self { serai: self.serai, block: self.block, events: RwLock::new(None) }
}
} }
impl Serai { impl Serai {
@@ -58,7 +65,7 @@ impl Serai {
params: &str, params: &str,
) -> Result<ResponseValue, RpcError> { ) -> Result<ResponseValue, RpcError> {
let request = let request =
format!(r#"{{ "jsonrpc": "2.0", "id": 0, "method": {method}, "params": {params} }}"#); format!(r#"{{ "jsonrpc": "2.0", "id": 0, "method": "{method}", "params": {params} }}"#);
let request = hyper::Request::post(&self.url) let request = hyper::Request::post(&self.url)
.header("Content-Type", "application/json") .header("Content-Type", "application/json")
.body(request.as_bytes().to_vec().into()) .body(request.as_bytes().to_vec().into())
@@ -78,10 +85,10 @@ impl Serai {
.client .client
.request(request) .request(request)
.await .await
.map_err(|_| RpcError::ConnectionError)? .map_err(RpcError::ConnectionError)?
.body() .body()
.await .await
.map_err(|_| RpcError::ConnectionError)?; .map_err(RpcError::ConnectionError)?;
let mut response_vec = Vec::with_capacity(1024); let mut response_vec = Vec::with_capacity(1024);
response_reader.read_to_end(&mut response_vec).map_err(|_| { response_reader.read_to_end(&mut response_vec).map_err(|_| {
RpcError::InternalError("couldn't read response from `simple-request` into `Vec`".to_string()) RpcError::InternalError("couldn't read response from `simple-request` into `Vec`".to_string())
@@ -108,14 +115,20 @@ impl Serai {
/// Create a new RPC client. /// Create a new RPC client.
pub fn new(url: String) -> Result<Self, RpcError> { pub fn new(url: String) -> Result<Self, RpcError> {
let client = TokioClient::with_connection_pool().map_err(|_| RpcError::ConnectionError)?; let client = TokioClient::with_connection_pool().map_err(RpcError::ConnectionError)?;
Ok(Serai { url, client }) Ok(Serai { url, client })
} }
/// Fetch a block from the Serai blockchain. /// Fetch if a block is finalized.
pub async fn block(&self, hash: [u8; 32]) -> Result<serai_abi::Block, RpcError> { pub async fn finalized(&self, block: BlockHash) -> Result<bool, RpcError> {
let bin: String = self.call("serai_block", &format!("[{}]", hex::encode(hash))).await?; self.call("serai_isFinalized", &format!(r#"["{block}"]"#)).await
serai_abi::Block::deserialize( }
async fn block_internal(
block: impl Future<Output = Result<String, RpcError>>,
) -> Result<Block, RpcError> {
let bin = block.await?;
Block::deserialize(
&mut hex::decode(&bin) &mut hex::decode(&bin)
.map_err(|_| RpcError::InvalidNode("node returned non-hex-encoded block".to_string()))? .map_err(|_| RpcError::InvalidNode("node returned non-hex-encoded block".to_string()))?
.as_slice(), .as_slice(),
@@ -123,15 +136,36 @@ impl Serai {
.map_err(|_| RpcError::InvalidNode("node returned invalid block".to_string())) .map_err(|_| RpcError::InvalidNode("node returned invalid block".to_string()))
} }
/// Fetch a block from the Serai blockchain.
pub async fn block(&self, block: BlockHash) -> Result<Block, RpcError> {
Self::block_internal(self.call("serai_block", &format!(r#"["{block}"]"#))).await
}
/// Fetch a block from the Serai blockchain by its number.
pub async fn block_by_number(&self, block: u64) -> Result<Block, RpcError> {
Self::block_internal(self.call("serai_block", &format!("[{block}]"))).await
}
/// Scope this RPC client to the state as of specific block.
///
/// This will yield an error if the block chosen isn't finalized. This ensures, given an honest
/// node, that this scope will be available for the lifetime of this object.
pub async fn at<'a>(&'a self, block: BlockHash) -> Result<TemporalSerai<'a>, RpcError> {
if !self.finalized(block).await? {
Err(RpcError::NotFinalized)?;
}
Ok(TemporalSerai { serai: self, block, events: Arc::new(RwLock::new(None)) })
}
/// Return the P2P addresses for the validators of the specified network. /// Return the P2P addresses for the validators of the specified network.
pub async fn p2p_validators(&self, network: ExternalNetworkId) -> Result<Vec<String>, RpcError> { pub async fn p2p_validators(&self, network: ExternalNetworkId) -> Result<Vec<String>, RpcError> {
self self
.call( .call(
"p2p_validators", "p2p_validators",
match network { match network {
ExternalNetworkId::Bitcoin => "[bitcoin]", ExternalNetworkId::Bitcoin => r#"["bitcoin"]"#,
ExternalNetworkId::Ethereum => "[ethereum]", ExternalNetworkId::Ethereum => r#"["ethereum"]"#,
ExternalNetworkId::Monero => "[monero]", ExternalNetworkId::Monero => r#"["monero"]"#,
_ => Err(RpcError::InternalError("unrecognized external network ID".to_string()))?, _ => Err(RpcError::InternalError("unrecognized external network ID".to_string()))?,
}, },
) )

View File

@@ -0,0 +1,9 @@
use serai_client_serai::*;
#[tokio::test]
async fn main() {
let serai = Serai::new("http://127.0.0.1:9944".to_string()).unwrap();
let block = serai.block_by_number(0).await.unwrap();
assert_eq!(serai.block(block.header.hash()).await.unwrap(), block);
assert!(serai.finalized(block.header.hash()).await.unwrap());
}

View File

@@ -38,6 +38,7 @@ sp-core = { git = "https://github.com/serai-dex/patch-polkadot-sdk", rev = "b48e
sp-inherents = { git = "https://github.com/serai-dex/patch-polkadot-sdk", rev = "b48efb12bf49a0dba1b3633403f716010b99dc56" } sp-inherents = { git = "https://github.com/serai-dex/patch-polkadot-sdk", rev = "b48efb12bf49a0dba1b3633403f716010b99dc56" }
sp-timestamp = { git = "https://github.com/serai-dex/patch-polkadot-sdk", rev = "b48efb12bf49a0dba1b3633403f716010b99dc56" } sp-timestamp = { git = "https://github.com/serai-dex/patch-polkadot-sdk", rev = "b48efb12bf49a0dba1b3633403f716010b99dc56" }
sp-blockchain = { git = "https://github.com/serai-dex/patch-polkadot-sdk", rev = "b48efb12bf49a0dba1b3633403f716010b99dc56" } sp-blockchain = { git = "https://github.com/serai-dex/patch-polkadot-sdk", rev = "b48efb12bf49a0dba1b3633403f716010b99dc56" }
sp-consensus = { git = "https://github.com/serai-dex/patch-polkadot-sdk", rev = "b48efb12bf49a0dba1b3633403f716010b99dc56" }
sp-state-machine = { git = "https://github.com/serai-dex/patch-polkadot-sdk", rev = "b48efb12bf49a0dba1b3633403f716010b99dc56" } sp-state-machine = { git = "https://github.com/serai-dex/patch-polkadot-sdk", rev = "b48efb12bf49a0dba1b3633403f716010b99dc56" }
sp-api = { git = "https://github.com/serai-dex/patch-polkadot-sdk", rev = "b48efb12bf49a0dba1b3633403f716010b99dc56" } sp-api = { git = "https://github.com/serai-dex/patch-polkadot-sdk", rev = "b48efb12bf49a0dba1b3633403f716010b99dc56" }
sp-keystore = { git = "https://github.com/serai-dex/patch-polkadot-sdk", rev = "b48efb12bf49a0dba1b3633403f716010b99dc56" } sp-keystore = { git = "https://github.com/serai-dex/patch-polkadot-sdk", rev = "b48efb12bf49a0dba1b3633403f716010b99dc56" }
@@ -53,6 +54,7 @@ serai-runtime = { path = "../runtime", features = ["std"] }
clap = { version = "4", features = ["derive"] } clap = { version = "4", features = ["derive"] }
borsh = { version = "1", default-features = false, features = ["std"] }
futures-util = "0.3" futures-util = "0.3"
tokio = { version = "1", features = ["sync", "rt-multi-thread"] } tokio = { version = "1", features = ["sync", "rt-multi-thread"] }
jsonrpsee = { version = "0.24", features = ["server"] } jsonrpsee = { version = "0.24", features = ["server"] }

View File

@@ -4,16 +4,16 @@ use rand_core::{RngCore, OsRng};
use sp_core::Encode; use sp_core::Encode;
use sp_blockchain::{Error as BlockchainError, HeaderMetadata, HeaderBackend}; use sp_blockchain::{Error as BlockchainError, HeaderMetadata, HeaderBackend};
use sp_consensus::BlockStatus;
use sp_block_builder::BlockBuilder; use sp_block_builder::BlockBuilder;
use sp_api::ProvideRuntimeApi; use sp_api::ProvideRuntimeApi;
use sc_client_api::BlockBackend;
use serai_abi::{primitives::prelude::*, SubstrateBlock as Block}; use serai_abi::{primitives::prelude::*, SubstrateBlock as Block};
use serai_runtime::*; use serai_runtime::*;
use jsonrpsee::RpcModule; use jsonrpsee::RpcModule;
use sc_client_api::BlockBackend;
pub(crate) fn module< pub(crate) fn module<
C: 'static C: 'static
+ Send + Send
@@ -26,7 +26,8 @@ pub(crate) fn module<
client: Arc<C>, client: Arc<C>,
) -> Result<RpcModule<impl 'static + Send + Sync>, Box<dyn std::error::Error + Send + Sync>> { ) -> Result<RpcModule<impl 'static + Send + Sync>, Box<dyn std::error::Error + Send + Sync>> {
let mut module = RpcModule::new(client); let mut module = RpcModule::new(client);
module.register_async_method("serai_block", |params, client, _ext| async move {
module.register_async_method("serai_isFinalized", |params, client, _ext| async move {
let [block_hash]: [String; 1] = params.parse()?; let [block_hash]: [String; 1] = params.parse()?;
let Some(block_hash) = hex::decode(&block_hash).ok().and_then(|bytes| { let Some(block_hash) = hex::decode(&block_hash).ok().and_then(|bytes| {
<[u8; 32]>::try_from(bytes.as_slice()) <[u8; 32]>::try_from(bytes.as_slice())
@@ -39,14 +40,69 @@ pub(crate) fn module<
Option::<()>::None, Option::<()>::None,
)); ));
}; };
let Some(block) = client.block(block_hash).ok().flatten() else { let finalized = client.info().finalized_number;
let Ok(Some(number)) = client.number(block_hash) else {
return Err(jsonrpsee::types::error::ErrorObjectOwned::owned(
-2,
"failed to fetch block's number",
Option::<()>::None,
));
};
let Ok(status) = client.block_status(block_hash) else {
return Err(jsonrpsee::types::error::ErrorObjectOwned::owned(
-3,
"failed to fetch block's status",
Option::<()>::None,
));
};
Ok(
matches!(status, BlockStatus::InChainWithState | BlockStatus::InChainPruned) &&
(number <= finalized),
)
})?;
module.register_async_method("serai_block", |params, client, _ext| async move {
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 Ok(Some(block)) = client.block(block_hash) else {
return Err(jsonrpsee::types::error::ErrorObjectOwned::owned( return Err(jsonrpsee::types::error::ErrorObjectOwned::owned(
-2, -2,
"couldn't find requested block", "couldn't find requested block",
Option::<()>::None, Option::<()>::None,
)); ));
}; };
Ok(hex::encode(block.block.encode()))
Ok(hex::encode(borsh::to_vec(&serai_abi::Block::from(block.block)).unwrap()))
})?; })?;
Ok(module) Ok(module)
} }

View File

@@ -3,6 +3,7 @@
#![deny(missing_docs)] #![deny(missing_docs)]
#![cfg_attr(not(feature = "std"), no_std)] #![cfg_attr(not(feature = "std"), no_std)]
use core::fmt;
extern crate alloc; extern crate alloc;
use zeroize::Zeroize; use zeroize::Zeroize;
@@ -82,6 +83,15 @@ impl From<sp_core::H256> for BlockHash {
} }
} }
impl fmt::Display for BlockHash {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
for byte in self.0 {
write!(f, "{byte:02x}")?;
}
Ok(())
}
}
// These share encodings as 32-byte arrays // These share encodings as 32-byte arrays
#[cfg(feature = "non_canonical_scale_derivations")] #[cfg(feature = "non_canonical_scale_derivations")]
impl scale::EncodeLike<sp_core::H256> for BlockHash {} impl scale::EncodeLike<sp_core::H256> for BlockHash {}