From 06a4824abaa2e79c79754351f292c84cec3c8101 Mon Sep 17 00:00:00 2001 From: Luke Parker Date: Mon, 10 Nov 2025 04:24:07 -0500 Subject: [PATCH] Move `bitcoin-serai` to `core-json` and feature-gate the RPC functionality --- networks/bitcoin/Cargo.toml | 15 ++-- networks/bitcoin/src/lib.rs | 2 +- networks/bitcoin/src/rpc.rs | 121 +++++++++++++++++++++---------- networks/bitcoin/tests/runner.rs | 4 +- networks/bitcoin/tests/wallet.rs | 12 +-- processor/bitcoin/Cargo.toml | 2 +- 6 files changed, 101 insertions(+), 55 deletions(-) diff --git a/networks/bitcoin/Cargo.toml b/networks/bitcoin/Cargo.toml index 0ea839f3..034f0447 100644 --- a/networks/bitcoin/Cargo.toml +++ b/networks/bitcoin/Cargo.toml @@ -30,8 +30,8 @@ k256 = { version = "^0.13.1", default-features = false, features = ["arithmetic" frost = { package = "modular-frost", path = "../../crypto/frost", version = "0.11", default-features = false, features = ["secp256k1"] } hex = { version = "0.4", default-features = false, optional = true } -serde = { version = "1", default-features = false, features = ["derive"], optional = true } -serde_json = { version = "1", default-features = false, optional = true } +core-json-traits = { version = "0.4", default-features = false, features = ["alloc"], optional = true } +core-json-derive = { version = "0.4", default-features = false, optional = true } simple-request = { path = "../../common/request", version = "0.3", default-features = false, features = ["tokio", "tls", "basic-auth"], optional = true } [dev-dependencies] @@ -52,15 +52,16 @@ std = [ "rand_core/std", "bitcoin/std", - "bitcoin/serde", "k256/std", "frost/std", - +] +rpc = [ + "std", "hex/std", - "serde/std", - "serde_json/std", + "core-json-traits", + "core-json-derive", "simple-request", ] hazmat = [] -default = ["std"] +default = ["std", "rpc"] diff --git a/networks/bitcoin/src/lib.rs b/networks/bitcoin/src/lib.rs index 7db6e024..2b0182c9 100644 --- a/networks/bitcoin/src/lib.rs +++ b/networks/bitcoin/src/lib.rs @@ -14,7 +14,7 @@ pub(crate) mod crypto; /// Wallet functionality to create transactions. pub mod wallet; /// A minimal asynchronous Bitcoin RPC client. -#[cfg(feature = "std")] +#[cfg(feature = "rpc")] pub mod rpc; #[cfg(test)] diff --git a/networks/bitcoin/src/rpc.rs b/networks/bitcoin/src/rpc.rs index 33f30f49..029a53a4 100644 --- a/networks/bitcoin/src/rpc.rs +++ b/networks/bitcoin/src/rpc.rs @@ -1,11 +1,8 @@ -use core::fmt::Debug; -use std::collections::HashSet; +use core::{str::FromStr, fmt::Debug}; +use std::{io::Read, collections::HashSet}; use thiserror::Error; -use serde::{Deserialize, de::DeserializeOwned}; -use serde_json::json; - use simple_request::{hyper, Request, TokioClient as Client}; use bitcoin::{ @@ -14,19 +11,12 @@ use bitcoin::{ Txid, Transaction, BlockHash, Block, }; -#[derive(Clone, PartialEq, Eq, Debug, Deserialize)] +#[derive(Clone, Debug)] pub struct Error { code: isize, message: String, } -#[derive(Clone, Debug, Deserialize)] -#[serde(untagged)] -enum RpcResponse { - Ok { result: T }, - Err { error: Error }, -} - /// A minimal asynchronous Bitcoin RPC client. #[derive(Clone, Debug)] pub struct Rpc { @@ -34,14 +24,14 @@ pub struct Rpc { url: String, } -#[derive(Clone, PartialEq, Eq, Debug, Error)] +#[derive(Clone, Debug, Error)] pub enum RpcError { #[error("couldn't connect to node")] ConnectionError, #[error("request had an error: {0:?}")] RequestError(Error), #[error("node replied with invalid JSON")] - InvalidJson(serde_json::error::Category), + InvalidJson, #[error("node sent an invalid response ({0})")] InvalidResponse(&'static str), #[error("node was missing expected methods")] @@ -66,7 +56,7 @@ impl Rpc { Rpc { client: Client::with_connection_pool().map_err(|_| RpcError::ConnectionError)?, url }; // Make an RPC request to verify the node is reachable and sane - let res: String = rpc.rpc_call("help", json!([])).await?; + let res: String = rpc.call("help", "[]").await?; // Verify all methods we expect are present // If we had a more expanded RPC, due to differences in RPC versions, it wouldn't make sense to @@ -103,18 +93,16 @@ impl Rpc { } /// Perform an arbitrary RPC call. - pub async fn rpc_call( + pub async fn call( &self, method: &str, - params: serde_json::Value, + params: &str, ) -> Result { let mut request = Request::from( hyper::Request::post(&self.url) .header("Content-Type", "application/json") .body( - serde_json::to_vec(&json!({ "jsonrpc": "2.0", "method": method, "params": params })) - .unwrap() - .into(), + format!(r#"{{ "method": "{method}", "params": {params} }}"#).as_bytes().to_vec().into(), ) .unwrap(), ); @@ -129,11 +117,52 @@ impl Rpc { .await .map_err(|_| RpcError::ConnectionError)?; - let res: RpcResponse = - serde_json::from_reader(&mut res).map_err(|e| RpcError::InvalidJson(e.classify()))?; + #[derive(Default, core_json_derive::JsonDeserialize)] + struct InternalError { + code: Option, + message: Option, + } + + #[derive(core_json_derive::JsonDeserialize)] + struct RpcResponse { + result: Option, + error: Option, + } + impl Default for RpcResponse { + fn default() -> Self { + Self { result: None, error: None } + } + } + + // TODO: `core_json::ReadAdapter` + let mut res_vec = vec![]; + res.read_to_end(&mut res_vec).map_err(|_| RpcError::ConnectionError)?; + let res = as core_json_traits::JsonStructure>::deserialize_structure::< + _, + core_json_traits::ConstStack<32>, + >(res_vec.as_slice()) + .map_err(|_| RpcError::InvalidJson)?; + match res { - RpcResponse::Ok { result } => Ok(result), - RpcResponse::Err { error } => Err(RpcError::RequestError(error)), + RpcResponse { result: Some(result), error: None } => Ok(result), + RpcResponse { result: None, error: Some(error) } => { + let code = + error.code.ok_or_else(|| RpcError::InvalidResponse("error was missing `code`"))?; + let code = isize::try_from(code) + .map_err(|_| RpcError::InvalidResponse("error code exceeded isize::MAX"))?; + let message = + error.message.ok_or_else(|| RpcError::InvalidResponse("error was missing `message`"))?; + Err(RpcError::RequestError(Error { code, message })) + } + // `invalidateblock` yields this edge case + RpcResponse { result: None, error: None } => { + if core::any::TypeId::of::() == core::any::TypeId::of::<()>() { + Ok(Default::default()) + } else { + Err(RpcError::InvalidResponse("response lacked both a result and an error")) + } + } + _ => Err(RpcError::InvalidResponse("response contained both a result and an error")), } } @@ -146,16 +175,17 @@ impl Rpc { // tip block of the current chain. The "height" of a block is defined as the amount of blocks // present when the block was created. Accordingly, the genesis block has height 0, and // getblockcount will return 0 when it's only the only block, despite their being one block. - self.rpc_call("getblockcount", json!([])).await + usize::try_from(self.call::("getblockcount", "[]").await?) + .map_err(|_| RpcError::InvalidResponse("latest block number exceeded usize::MAX")) } /// Get the hash of a block by the block's number. pub async fn get_block_hash(&self, number: usize) -> Result<[u8; 32], RpcError> { - let mut hash = self - .rpc_call::("getblockhash", json!([number])) - .await? - .as_raw_hash() - .to_byte_array(); + let mut hash = + BlockHash::from_str(&self.call::("getblockhash", &format!("[{number}]")).await?) + .map_err(|_| RpcError::InvalidResponse("block hash was not valid hex"))? + .as_raw_hash() + .to_byte_array(); // bitcoin stores the inner bytes in reverse order. hash.reverse(); Ok(hash) @@ -163,16 +193,25 @@ impl Rpc { /// Get a block's number by its hash. pub async fn get_block_number(&self, hash: &[u8; 32]) -> Result { - #[derive(Deserialize, Debug)] + #[derive(Default, core_json_derive::JsonDeserialize)] struct Number { - height: usize, + height: Option, } - Ok(self.rpc_call::("getblockheader", json!([hex::encode(hash)])).await?.height) + usize::try_from( + self + .call::("getblockheader", &format!(r#"["{}"]"#, hex::encode(hash))) + .await? + .height + .ok_or_else(|| { + RpcError::InvalidResponse("`getblockheader` did not include `height` field") + })?, + ) + .map_err(|_| RpcError::InvalidResponse("block number exceeded usize::MAX")) } /// Get a block by its hash. pub async fn get_block(&self, hash: &[u8; 32]) -> Result { - let hex = self.rpc_call::("getblock", json!([hex::encode(hash), 0])).await?; + let hex = self.call::("getblock", &format!(r#"["{}", 0]"#, hex::encode(hash))).await?; let bytes: Vec = FromHex::from_hex(&hex) .map_err(|_| RpcError::InvalidResponse("node didn't use hex to encode the block"))?; let block: Block = encode::deserialize(&bytes) @@ -189,8 +228,13 @@ impl Rpc { /// Publish a transaction. pub async fn send_raw_transaction(&self, tx: &Transaction) -> Result { - let txid = match self.rpc_call("sendrawtransaction", json!([encode::serialize_hex(tx)])).await { - Ok(txid) => txid, + let txid = match self + .call::("sendrawtransaction", &format!(r#"["{}"]"#, encode::serialize_hex(tx))) + .await + { + Ok(txid) => { + Txid::from_str(&txid).map_err(|_| RpcError::InvalidResponse("TXID was not valid hex"))? + } Err(e) => { // A const from Bitcoin's bitcoin/src/rpc/protocol.h const RPC_VERIFY_ALREADY_IN_CHAIN: isize = -27; @@ -211,7 +255,8 @@ impl Rpc { /// Get a transaction by its hash. pub async fn get_transaction(&self, hash: &[u8; 32]) -> Result { - let hex = self.rpc_call::("getrawtransaction", json!([hex::encode(hash)])).await?; + let hex = + self.call::("getrawtransaction", &format!(r#"["{}"]"#, hex::encode(hash))).await?; let bytes: Vec = FromHex::from_hex(&hex) .map_err(|_| RpcError::InvalidResponse("node didn't use hex to encode the transaction"))?; let tx: Transaction = encode::deserialize(&bytes) diff --git a/networks/bitcoin/tests/runner.rs b/networks/bitcoin/tests/runner.rs index 4cd6f0cf..8beb2a1b 100644 --- a/networks/bitcoin/tests/runner.rs +++ b/networks/bitcoin/tests/runner.rs @@ -14,9 +14,9 @@ pub(crate) async fn rpc() -> Rpc { // If this node has already been interacted with, clear its chain if rpc.get_latest_block_number().await.unwrap() > 0 { rpc - .rpc_call( + .call( "invalidateblock", - serde_json::json!([hex::encode(rpc.get_block_hash(1).await.unwrap())]), + &format!(r#"["{}"]"#, hex::encode(rpc.get_block_hash(1).await.unwrap())), ) .await .unwrap() diff --git a/networks/bitcoin/tests/wallet.rs b/networks/bitcoin/tests/wallet.rs index 83344048..d1004110 100644 --- a/networks/bitcoin/tests/wallet.rs +++ b/networks/bitcoin/tests/wallet.rs @@ -41,21 +41,21 @@ async fn send_and_get_output(rpc: &Rpc, scanner: &Scanner, key: ProjectivePoint) let block_number = rpc.get_latest_block_number().await.unwrap() + 1; rpc - .rpc_call::>( + .call::>( "generatetoaddress", - serde_json::json!([ - 1, + &format!( + r#"[1, "{}"]"#, Address::from_script(&p2tr_script_buf(key).unwrap(), Network::Regtest).unwrap() - ]), + ), ) .await .unwrap(); // Mine until maturity rpc - .rpc_call::>( + .call::>( "generatetoaddress", - serde_json::json!([100, Address::p2sh(Script::new(), Network::Regtest).unwrap()]), + &format!(r#"[100, "{}"]"#, Address::p2sh(Script::new(), Network::Regtest).unwrap()), ) .await .unwrap(); diff --git a/processor/bitcoin/Cargo.toml b/processor/bitcoin/Cargo.toml index e1b93cff..eea6d08d 100644 --- a/processor/bitcoin/Cargo.toml +++ b/processor/bitcoin/Cargo.toml @@ -28,7 +28,7 @@ dkg = { package = "dkg-evrf", path = "../../crypto/dkg/evrf", default-features = frost = { package = "modular-frost", path = "../../crypto/frost", default-features = false } secp256k1 = { version = "0.29", default-features = false, features = ["std", "global-context", "rand-std"] } -bitcoin-serai = { path = "../../networks/bitcoin", default-features = false, features = ["std"] } +bitcoin-serai = { path = "../../networks/bitcoin", default-features = false, features = ["std", "rpc"] } serai-primitives = { path = "../../substrate/primitives", default-features = false, features = ["std"] } serai-client-bitcoin = { path = "../../substrate/client/bitcoin", default-features = false }