diff --git a/substrate/abi/src/block.rs b/substrate/abi/src/block.rs index 3fd18934..559b4e8f 100644 --- a/substrate/abi/src/block.rs +++ b/substrate/abi/src/block.rs @@ -286,6 +286,13 @@ mod substrate { header: SubstrateHeader, transactions: Vec, } + + impl From for Block { + fn from(block: SubstrateBlock) -> Self { + Self { header: (&block.header).into(), transactions: block.transactions } + } + } + impl sp_core::serde::Serialize for SubstrateBlock { fn serialize(&self, serializer: S) -> Result where diff --git a/substrate/client/serai/Cargo.toml b/substrate/client/serai/Cargo.toml index d16f0131..80e11258 100644 --- a/substrate/client/serai/Cargo.toml +++ b/substrate/client/serai/Cargo.toml @@ -27,3 +27,6 @@ borsh = { version = "1", default-features = false, features = ["std"] } serai-abi = { path = "../../abi", version = "0.1" } async-lock = "3" + +[dev-dependencies] +tokio = { version = "1", default-features = false, features = ["rt", "macros"] } diff --git a/substrate/client/serai/src/lib.rs b/substrate/client/serai/src/lib.rs index 597cec5f..b62a0202 100644 --- a/substrate/client/serai/src/lib.rs +++ b/substrate/client/serai/src/lib.rs @@ -2,7 +2,8 @@ #![doc = include_str!("../README.md")] #![deny(missing_docs)] -use std::io::Read; +use core::future::Future; +use std::{sync::Arc, io::Read}; use thiserror::Error; use core_json_traits::{JsonDeserialize, JsonStructure}; @@ -11,7 +12,10 @@ use simple_request::{hyper, Request, TokioClient}; use borsh::BorshDeserialize; 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; @@ -23,13 +27,16 @@ pub enum RpcError { InternalError(String), /// A failure with the connection occurred. #[error("failed to communicate with serai")] - ConnectionError, + ConnectionError(simple_request::Error), /// The node provided an invalid response. #[error("node is faulty: {0}")] InvalidNode(String), /// The response contained an error. #[error("error in response: {0}")] ErrorInResponse(String), + /// The requested block wasn't finalized. + #[error("the requested block wasn't finalized")] + NotFinalized, } /// 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. +/// +/// 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> { serai: &'a Serai, - block: [u8; 32], - events: RwLock>>, -} -impl Clone for TemporalSerai<'_> { - fn clone(&self) -> Self { - Self { serai: self.serai, block: self.block, events: RwLock::new(None) } - } + block: BlockHash, + events: Arc>>>, } impl Serai { @@ -58,7 +65,7 @@ impl Serai { params: &str, ) -> Result { 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) .header("Content-Type", "application/json") .body(request.as_bytes().to_vec().into()) @@ -78,10 +85,10 @@ impl Serai { .client .request(request) .await - .map_err(|_| RpcError::ConnectionError)? + .map_err(RpcError::ConnectionError)? .body() .await - .map_err(|_| RpcError::ConnectionError)?; + .map_err(RpcError::ConnectionError)?; let mut response_vec = Vec::with_capacity(1024); response_reader.read_to_end(&mut response_vec).map_err(|_| { RpcError::InternalError("couldn't read response from `simple-request` into `Vec`".to_string()) @@ -108,14 +115,20 @@ impl Serai { /// Create a new RPC client. pub fn new(url: String) -> Result { - let client = TokioClient::with_connection_pool().map_err(|_| RpcError::ConnectionError)?; + let client = TokioClient::with_connection_pool().map_err(RpcError::ConnectionError)?; Ok(Serai { url, client }) } - /// Fetch a block from the Serai blockchain. - pub async fn block(&self, hash: [u8; 32]) -> Result { - let bin: String = self.call("serai_block", &format!("[{}]", hex::encode(hash))).await?; - serai_abi::Block::deserialize( + /// Fetch if a block is finalized. + pub async fn finalized(&self, block: BlockHash) -> Result { + self.call("serai_isFinalized", &format!(r#"["{block}"]"#)).await + } + + async fn block_internal( + block: impl Future>, + ) -> Result { + let bin = block.await?; + Block::deserialize( &mut hex::decode(&bin) .map_err(|_| RpcError::InvalidNode("node returned non-hex-encoded block".to_string()))? .as_slice(), @@ -123,15 +136,36 @@ impl Serai { .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 { + 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 { + 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, 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. pub async fn p2p_validators(&self, network: ExternalNetworkId) -> Result, RpcError> { self .call( "p2p_validators", match network { - ExternalNetworkId::Bitcoin => "[bitcoin]", - ExternalNetworkId::Ethereum => "[ethereum]", - ExternalNetworkId::Monero => "[monero]", + ExternalNetworkId::Bitcoin => r#"["bitcoin"]"#, + ExternalNetworkId::Ethereum => r#"["ethereum"]"#, + ExternalNetworkId::Monero => r#"["monero"]"#, _ => Err(RpcError::InternalError("unrecognized external network ID".to_string()))?, }, ) diff --git a/substrate/client/serai/tests/blockchain.rs b/substrate/client/serai/tests/blockchain.rs new file mode 100644 index 00000000..6cbcbaac --- /dev/null +++ b/substrate/client/serai/tests/blockchain.rs @@ -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()); +} diff --git a/substrate/node/Cargo.toml b/substrate/node/Cargo.toml index 630d62f6..55bc455e 100644 --- a/substrate/node/Cargo.toml +++ b/substrate/node/Cargo.toml @@ -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-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-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-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" } @@ -53,6 +54,7 @@ serai-runtime = { path = "../runtime", features = ["std"] } clap = { version = "4", features = ["derive"] } +borsh = { version = "1", default-features = false, features = ["std"] } futures-util = "0.3" tokio = { version = "1", features = ["sync", "rt-multi-thread"] } jsonrpsee = { version = "0.24", features = ["server"] } diff --git a/substrate/node/src/rpc/blockchain.rs b/substrate/node/src/rpc/blockchain.rs index e189d62e..ea9b422c 100644 --- a/substrate/node/src/rpc/blockchain.rs +++ b/substrate/node/src/rpc/blockchain.rs @@ -4,16 +4,16 @@ use rand_core::{RngCore, OsRng}; use sp_core::Encode; use sp_blockchain::{Error as BlockchainError, HeaderMetadata, HeaderBackend}; +use sp_consensus::BlockStatus; use sp_block_builder::BlockBuilder; use sp_api::ProvideRuntimeApi; +use sc_client_api::BlockBackend; use serai_abi::{primitives::prelude::*, SubstrateBlock as Block}; use serai_runtime::*; use jsonrpsee::RpcModule; -use sc_client_api::BlockBackend; - pub(crate) fn module< C: 'static + Send @@ -26,7 +26,8 @@ pub(crate) fn module< client: Arc, ) -> Result, Box> { 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 Some(block_hash) = hex::decode(&block_hash).ok().and_then(|bytes| { <[u8; 32]>::try_from(bytes.as_slice()) @@ -39,14 +40,69 @@ pub(crate) fn module< 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(::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( -2, "couldn't find requested block", Option::<()>::None, )); }; - Ok(hex::encode(block.block.encode())) + + Ok(hex::encode(borsh::to_vec(&serai_abi::Block::from(block.block)).unwrap())) })?; + Ok(module) } diff --git a/substrate/primitives/src/lib.rs b/substrate/primitives/src/lib.rs index 4de37302..a550b08f 100644 --- a/substrate/primitives/src/lib.rs +++ b/substrate/primitives/src/lib.rs @@ -3,6 +3,7 @@ #![deny(missing_docs)] #![cfg_attr(not(feature = "std"), no_std)] +use core::fmt; extern crate alloc; use zeroize::Zeroize; @@ -82,6 +83,15 @@ impl From 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 #[cfg(feature = "non_canonical_scale_derivations")] impl scale::EncodeLike for BlockHash {}