diff --git a/crates/floresta-node/src/json_rpc/blockchain.rs b/crates/floresta-node/src/json_rpc/blockchain.rs index a33e75484..5ecf48f7f 100644 --- a/crates/floresta-node/src/json_rpc/blockchain.rs +++ b/crates/floresta-node/src/json_rpc/blockchain.rs @@ -24,17 +24,22 @@ use serde_json::json; use serde_json::Value; use tracing::debug; +use super::res::jsonrpc_interface::JsonRpcError; use super::res::GetBlockchainInfoRes; use super::res::GetTxOutProof; -use super::res::JsonRpcError; use super::server::RpcChain; use super::server::RpcImpl; use crate::json_rpc::res::GetBlockRes; use crate::json_rpc::res::RescanConfidence; +use crate::json_rpc::server::SERIALIZATION_EXPECT; impl RpcImpl { async fn get_block_inner(&self, hash: BlockHash) -> Result { - let is_genesis = self.chain.get_block_hash(0).unwrap().eq(&hash); + let is_genesis = self + .chain + .get_block_hash(0) + .map_err(|_| JsonRpcError::Chain)? + .eq(&hash); if is_genesis { return Ok(genesis_block(self.network)); @@ -53,7 +58,11 @@ impl RpcImpl { .wallet .get_height(txid) .ok_or(JsonRpcError::TxNotFound)?; - let blockhash = self.chain.get_block_hash(height).unwrap(); + let blockhash = self + .chain + .get_block_hash(height) + .map_err(|_| JsonRpcError::BlockNotFound)?; + self.chain .get_block(&blockhash) .map_err(|_| JsonRpcError::BlockNotFound) @@ -62,17 +71,11 @@ impl RpcImpl { pub fn get_rescan_interval( &self, use_timestamp: bool, - start: Option, - stop: Option, - confidence: Option, + start: u32, + stop: u32, + confidence: RescanConfidence, ) -> Result<(u32, u32), JsonRpcError> { - let start = start.unwrap_or(0u32); - let stop = stop.unwrap_or(0u32); - if use_timestamp { - let confidence = confidence.unwrap_or(RescanConfidence::Medium); - // `get_block_height_by_timestamp` already does the time validity checks. - let start_height = self.get_block_height_by_timestamp(start, &confidence)?; let stop_height = self.get_block_height_by_timestamp(stop, &RescanConfidence::Exact)?; @@ -165,7 +168,11 @@ impl RpcImpl { // getbestblockhash pub(super) fn get_best_block_hash(&self) -> Result { - Ok(self.chain.get_best_block().unwrap().1) + Ok(self + .chain + .get_best_block() + .map_err(|_| JsonRpcError::Chain)? + .1) } // getblock @@ -244,10 +251,19 @@ impl RpcImpl { // getblockchaininfo pub(super) fn get_blockchain_info(&self) -> Result { - let (height, hash) = self.chain.get_best_block().unwrap(); - let validated = self.chain.get_validation_index().unwrap(); + let (height, hash) = self + .chain + .get_best_block() + .map_err(|_| JsonRpcError::Chain)?; + let validated = self + .chain + .get_validation_index() + .map_err(|_| JsonRpcError::Chain)?; let ibd = self.chain.is_in_ibd(); - let latest_header = self.chain.get_block_header(&hash).unwrap(); + let latest_header = self + .chain + .get_block_header(&hash) + .map_err(|_| JsonRpcError::Chain)?; let latest_work = latest_header .calculate_chain_work(&self.chain)? .to_string_hex(); @@ -262,7 +278,10 @@ impl RpcImpl { .map(|r| r.to_string()) .collect(); - let validated_blocks = self.chain.get_validation_index().unwrap(); + let validated_blocks = self + .chain + .get_validation_index() + .map_err(|_| JsonRpcError::Chain)?; let validated_percentage = if height != 0 { validated_blocks as f32 / height as f32 @@ -288,7 +307,7 @@ impl RpcImpl { // getblockcount pub(super) fn get_block_count(&self) -> Result { - Ok(self.chain.get_height().unwrap()) + self.chain.get_height().map_err(|_| JsonRpcError::Chain) } // getblockfilter @@ -600,7 +619,7 @@ impl RpcImpl { height: u32, ) -> Result { if let Some(txout) = self.wallet.get_utxo(&OutPoint { txid, vout }) { - return Ok(serde_json::to_value(txout).unwrap()); + return Ok(serde_json::to_value(txout).expect(SERIALIZATION_EXPECT)); } // if we are on IBD, we don't have any filters to find this txout. diff --git a/crates/floresta-node/src/json_rpc/control.rs b/crates/floresta-node/src/json_rpc/control.rs index 874b4a2ea..6aba49b06 100644 --- a/crates/floresta-node/src/json_rpc/control.rs +++ b/crates/floresta-node/src/json_rpc/control.rs @@ -3,7 +3,7 @@ use serde::Deserialize; use serde::Serialize; -use super::res::JsonRpcError; +use super::res::jsonrpc_interface::JsonRpcError; use super::server::RpcChain; use super::server::RpcImpl; diff --git a/crates/floresta-node/src/json_rpc/network.rs b/crates/floresta-node/src/json_rpc/network.rs index 371ab793f..c1d2035d0 100644 --- a/crates/floresta-node/src/json_rpc/network.rs +++ b/crates/floresta-node/src/json_rpc/network.rs @@ -10,7 +10,7 @@ use floresta_wire::node_interface::PeerInfo; use serde_json::json; use serde_json::Value; -use super::res::JsonRpcError; +use super::res::jsonrpc_interface::JsonRpcError; use super::server::RpcChain; use super::server::RpcImpl; diff --git a/crates/floresta-node/src/json_rpc/request.rs b/crates/floresta-node/src/json_rpc/request.rs index 2f72e9866..37d6d443b 100644 --- a/crates/floresta-node/src/json_rpc/request.rs +++ b/crates/floresta-node/src/json_rpc/request.rs @@ -19,7 +19,7 @@ pub struct RpcRequest { pub method: String, /// The parameters for the method, as an array of json values. - pub params: Vec, + pub params: Option, /// An optional identifier for the request, which can be used to match responses. pub id: Value, @@ -29,130 +29,61 @@ pub struct RpcRequest { /// methods already handle the case where the parameter is missing or has an /// unexpected type, returning an error if so. pub mod arg_parser { - use core::str::FromStr; + use serde::Deserialize; use serde_json::Value; - use crate::json_rpc::res::JsonRpcError; + use crate::json_rpc::res::jsonrpc_interface::JsonRpcError; - /// Extracts a u64 parameter from the request parameters at the specified index. - /// - /// This function checks if the parameter exists, is of type u64 and can be converted to `T`. - /// Returns an error otherwise. - pub fn get_numeric>( - params: &[Value], - index: usize, - opt_name: &str, - ) -> Result { - let v = params - .get(index) - .ok_or_else(|| JsonRpcError::MissingParameter(opt_name.to_string()))?; - - let n = v.as_u64().ok_or_else(|| { - JsonRpcError::InvalidParameterType(format!("{opt_name} must be a number")) - })?; - - T::try_from(n) - .map_err(|_| JsonRpcError::InvalidParameterType(format!("{opt_name} is out-of-range"))) - } - - /// Extracts a string parameter from the request parameters at the specified index. - /// - /// This function checks if the parameter exists and is of type string. Returns an error - /// otherwise. - pub fn get_string( - params: &[Value], - index: usize, - opt_name: &str, - ) -> Result { - let v = params - .get(index) - .ok_or_else(|| JsonRpcError::MissingParameter(opt_name.to_string()))?; - - let str = v.as_str().ok_or_else(|| { - JsonRpcError::InvalidParameterType(format!("{opt_name} must be a string")) - })?; - - Ok(str.to_string()) - } - - /// Extracts a boolean parameter from the request parameters at the specified index. - /// - /// This function checks if the parameter exists and is of type boolean. Returns an error - /// otherwise. - pub fn get_bool(params: &[Value], index: usize, opt_name: &str) -> Result { - let v = params - .get(index) - .ok_or_else(|| JsonRpcError::MissingParameter(opt_name.to_string()))?; - - v.as_bool().ok_or_else(|| { - JsonRpcError::InvalidParameterType(format!("{opt_name} must be a boolean")) - }) - } - - /// Extracts a hash parameter from the request parameters at the specified index. + /// Extracts a parameter from the request parameters at the specified index. /// /// This function can extract any type that implements `FromStr`, such as `BlockHash` or /// `Txid`. It checks if the parameter exists and is a valid string representation of the type. /// Returns an error otherwise. - pub fn get_hash( - params: &[Value], + pub fn get_at<'de, T: Deserialize<'de>>( + params: &'de Value, index: usize, - opt_name: &str, + field_name: &str, ) -> Result { - let v = params - .get(index) - .ok_or_else(|| JsonRpcError::MissingParameter(opt_name.to_string()))?; - - v.as_str().and_then(|s| s.parse().ok()).ok_or_else(|| { - JsonRpcError::InvalidParameterType(format!("{opt_name} must be a valid hash")) - }) + let v = match (params.is_array(), params.is_object()) { + (true, false) => params.get(index), + (false, true) => params.get(field_name), + _ => None, + }; + + let unwrap = v.ok_or(JsonRpcError::MissingParameter(field_name.to_string()))?; + + T::deserialize(unwrap) + .ok() + .ok_or(JsonRpcError::InvalidParameterType(format!( + "{field_name} has an invalid type" + ))) } - /// Extracts an array of hashes from the request parameters at the specified index. - /// - /// This function can extract an array of any type that implements `FromStr`, such as - /// `BlockHash` or `Txid`. It checks if the parameter exists and is an array of valid string - /// representations of the type. Returns an error otherwise. - pub fn get_hashes_array( - params: &[Value], - index: usize, - opt_name: &str, - ) -> Result, JsonRpcError> { - let v = params - .get(index) - .ok_or_else(|| JsonRpcError::MissingParameter(opt_name.to_string()))?; - - let array = v.as_array().ok_or_else(|| { - JsonRpcError::InvalidParameterType(format!("{opt_name} must be an array of hashes")) - })?; - - array - .iter() - .map(|v| { - v.as_str().and_then(|s| s.parse().ok()).ok_or_else(|| { - JsonRpcError::InvalidParameterType(format!("{opt_name} must be a valid hash")) - }) - }) - .collect() + /// Wraps a parameter extraction result so that a missing parameter yields `Ok(None)` + /// instead of an error. Other errors are propagated unchanged. + pub fn try_into_optional( + result: Result, + ) -> Result, JsonRpcError> { + match result { + Ok(t) => Ok(Some(t)), + Err(JsonRpcError::MissingParameter(_)) => Ok(None), + Err(e) => Err(e), + } } - /// Extracts an optional field from the request parameters at the specified index. - /// - /// This function checks if the parameter exists and is of the expected type. If the parameter - /// doesn't exist, it returns `None`. If it exists but is of an unexpected type, it returns an - /// error. - pub fn get_optional_field( - params: &[Value], + /// Like [`get_at`], but returns `default` when the parameter is missing instead of + /// an error. Type mismatches are still propagated as errors. + pub fn get_with_default<'de, T: Deserialize<'de>>( + v: &'de Value, index: usize, - opt_name: &str, - extractor_fn: impl Fn(&[Value], usize, &str) -> Result, - ) -> Result, JsonRpcError> { - if params.len() <= index { - return Ok(None); + field_name: &str, + default: T, + ) -> Result { + match get_at(v, index, field_name) { + Ok(t) => Ok(t), + Err(JsonRpcError::MissingParameter(_)) => Ok(default), + Err(e) => Err(e), } - - let value = extractor_fn(params, index, opt_name)?; - Ok(Some(value)) } } diff --git a/crates/floresta-node/src/json_rpc/res.rs b/crates/floresta-node/src/json_rpc/res.rs index 98a68d88e..885e45213 100644 --- a/crates/floresta-node/src/json_rpc/res.rs +++ b/crates/floresta-node/src/json_rpc/res.rs @@ -1,18 +1,460 @@ // SPDX-License-Identifier: MIT OR Apache-2.0 +//! Response types for floresta's JSON-RPC server. +//! +//! This module is split into two main sections: +//! +//! - [`jsonrpc_interface`] — Protocol-level types that implement the +//! [`JSON-RPC 2.0 specification`]: the [`Response`] / +//! [`RpcError`] envelope, standard error code constants, and the [`JsonRpcError`] enum that +//! maps every floresta-specific failure into the appropriate JSON-RPC error code and HTTP +//! status. +//! +//! - **Serialization structs** (outside the inner module) — Rust representations of the JSON +//! objects returned by individual RPC methods (`getblockchaininfo`, `getrawtransaction`, +//! `getblock`, etc.). These structs are `Serialize`/`Deserialize` and mirror the Bitcoin Core +//! JSON schema where applicable. +//! +//! [`JSON-RPC 2.0 specification`]: https://www.jsonrpc.org/specification +//! [`Response`]: jsonrpc_interface::Response +//! [`RpcError`]: jsonrpc_interface::RpcError +//! [`JsonRpcError`]: jsonrpc_interface::JsonRpcError -use core::fmt; use core::fmt::Debug; -use core::fmt::Display; -use core::fmt::Formatter; -use axum::response::IntoResponse; use corepc_types::v30::GetBlockVerboseOne; -use floresta_chain::extensions::HeaderExtError; -use floresta_common::impl_error_from; -use floresta_mempool::mempool::AcceptToMempoolError; use serde::Deserialize; use serde::Serialize; +/// Types and methods implementing the [JSON-RPC 2.0 spec](https://www.jsonrpc.org/specification), +/// tailored for floresta's RPC server. +pub mod jsonrpc_interface { + use core::fmt; + use std::fmt::Display; + use std::fmt::Formatter; + + use axum::http::StatusCode; + use floresta_chain::extensions::HeaderExtError; + use floresta_chain::BlockchainError; + use floresta_common::impl_error_from; + use floresta_mempool::mempool::AcceptToMempoolError; + use floresta_watch_only::WatchOnlyError; + use serde::Deserialize; + use serde::Serialize; + use serde_json::Value; + + use crate::json_rpc::server::SERIALIZATION_EXPECT; + + pub type RpcResult = std::result::Result; + + #[derive(Debug, Serialize)] + /// A JSON-RPC response object. + /// + /// Exactly one of `result` or `error` will be `Some`. + pub struct Response { + /// Present on success, absent on error. + pub result: Option, + + /// Present on error, absent on success. + pub error: Option, + + /// Matches the `id` from the request. `Null` for notifications. + pub id: Value, + } + + impl Response { + /// Creates a successful JSON-RPC response with the given result. + pub fn success(result: Value, id: Value) -> Self { + Self { + result: Some(result), + error: None, + id, + } + } + + /// Creates an error JSON-RPC response with the given error. + pub fn error(error: RpcError, id: Value) -> Self { + Self { + result: None, + error: Some(error), + id, + } + } + + /// Converts an [RpcResult] into a success or error response. + pub fn from_result(result: RpcResult, id: Value) -> Self { + match result { + Ok(value) => Self::success(value, id), + Err(e) => Self::error(e.rpc_error(), id), + } + } + } + + #[derive(Debug, Deserialize, Serialize)] + /// A JSON-RPC error object. + pub struct RpcError { + /// Numeric error code indicating the type of error. + pub code: i16, + + /// Short description of the error. + pub message: String, + + /// Optional additional data about the error. + pub data: Option, + } + + impl Display for RpcError { + fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { + write!( + f, + "{}", + serde_json::to_string(self).expect(SERIALIZATION_EXPECT) + ) + } + } + + /// An invalid JSON was received by the server. + pub const PARSE_ERROR: i16 = -32700; + + /// The JSON sent is not a valid Request object. + pub const INVALID_REQUEST: i16 = -32600; + + /// The method does not exist or is not available. + pub const METHOD_NOT_FOUND: i16 = -32601; + + /// Invalid method parameter(s). + pub const INVALID_METHOD_PARAMETERS: i16 = -32602; + + /// Internal JSON-RPC error (infrastructure-level, not method-level). + pub const INTERNAL_ERROR: i16 = -32603; + + /// Upper bound of the implementation-defined server error range (`-32099..=-32000`). + /// + /// Floresta maps method-level errors to codes within this range. + pub const SERVER_ERROR_MIN: i16 = -32099; + + /// Lower bound of the implementation-defined server error range (`-32099..=-32000`). + /// + /// Floresta maps method-level errors to codes within this range. + pub const SERVER_ERROR_MAX: i16 = -32000; + + // Floresta-specific server error codes within the -32099..=-32000 range. + pub const TX_NOT_FOUND: i16 = SERVER_ERROR_MIN; // -32099 + pub const BLOCK_NOT_FOUND: i16 = SERVER_ERROR_MIN + 1; // -32098 + pub const PEER_NOT_FOUND: i16 = SERVER_ERROR_MIN + 2; // -32097 + pub const NO_ADDRESSES_TO_RESCAN: i16 = SERVER_ERROR_MIN + 3; // -32096 + pub const WALLET_ERROR: i16 = SERVER_ERROR_MIN + 4; // -32095 + pub const MEMPOOL_ERROR: i16 = SERVER_ERROR_MIN + 5; // -32094 + pub const IN_INITIAL_BLOCK_DOWNLOAD: i16 = SERVER_ERROR_MIN + 6; // -32093 + pub const NO_BLOCK_FILTERS: i16 = SERVER_ERROR_MIN + 7; // -32092 + pub const NODE_ERROR: i16 = SERVER_ERROR_MIN + 8; // -32091 + pub const CHAIN_ERROR: i16 = SERVER_ERROR_MIN + 9; // -32090 + pub const FILTERS_ERROR: i16 = SERVER_ERROR_MAX; // -32000 + + #[derive(Debug)] + pub enum JsonRpcError { + /// Rescan requested but the watch-only wallet has no addresses. + NoAddressesToRescan, + + /// Rescan requested with invalid values. + InvalidRescanVal, + + /// A required parameter is missing from the request. + MissingParameter(String), + + /// A parameter have an unexpected type (e.g. number where string was expected). + InvalidParameterType(String), + + /// Verbosity level received does not fit on available values. + InvalidVerbosityLevel, + + /// Transaction not found. + TxNotFound, + + /// The provided script is invalid. + InvalidScript, + + /// The provided descriptor is invalid. + InvalidDescriptor(miniscript::Error), + + /// Block not found in the blockchain. + BlockNotFound, + + /// Chain-level error (e.g. chain not synced or invalid). + Chain, + + /// The JSON-RPC request itself is malformed. + InvalidRequest, + + /// The requested RPC method does not exist. + MethodNotFound, + + /// Failed to decode the request payload. + Decode(String), + + /// The provided network address is invalid. + InvalidAddress, + + /// Node-level error (e.g. not connected or unresponsive). + Node(String), + + /// Block filters are not enabled, but the requested RPC requires them. + NoBlockFilters, + + /// The provided hex string is invalid. + InvalidHex, + + /// The node is still performing initial block download. + InInitialBlockDownload, + + /// Invalid mode passed to `getmemoryinfo`. + InvalidMemInfoMode, + + /// Wallet error (e.g. wallet not loaded or unavailable). + Wallet(String), + + /// Block filter error (e.g. filter data unavailable or corrupt). + Filters(String), + + /// Overflow when calculating cumulative chain work. + ChainWorkOverflow, + + /// Invalid `addnode` command or parameters. + InvalidAddnodeCommand, + + /// Invalid `disconnectnode` command (both address and node ID were provided). + InvalidDisconnectNodeCommand, + + /// Peer not found in the peer list. + PeerNotFound, + + /// Timestamp argument to `rescanblockchain` is before the genesis block + /// (and not zero, which is the default). + InvalidTimestamp, + + /// Transaction was rejected by the mempool. + MempoolAccept(AcceptToMempoolError), + } + + impl_error_from!(JsonRpcError, AcceptToMempoolError, MempoolAccept); + + impl JsonRpcError { + pub fn http_code(&self) -> StatusCode { + match self { + // 400 Bad Request - client sent invalid data + JsonRpcError::InvalidHex + | JsonRpcError::InvalidAddress + | JsonRpcError::InvalidScript + | JsonRpcError::InvalidRequest + | JsonRpcError::InvalidDescriptor(_) + | JsonRpcError::InvalidVerbosityLevel + | JsonRpcError::Decode(_) + | JsonRpcError::MempoolAccept(_) + | JsonRpcError::InvalidMemInfoMode + | JsonRpcError::InvalidAddnodeCommand + | JsonRpcError::InvalidDisconnectNodeCommand + | JsonRpcError::InvalidTimestamp + | JsonRpcError::InvalidRescanVal + | JsonRpcError::NoAddressesToRescan + | JsonRpcError::InvalidParameterType(_) + | JsonRpcError::MissingParameter(_) + | JsonRpcError::Wallet(_) => StatusCode::BAD_REQUEST, + + // 404 Not Found - resource/method doesn't exist + JsonRpcError::MethodNotFound + | JsonRpcError::BlockNotFound + | JsonRpcError::TxNotFound + | JsonRpcError::PeerNotFound => StatusCode::NOT_FOUND, + + // 500 Internal Server Error - server messed up + JsonRpcError::ChainWorkOverflow => StatusCode::INTERNAL_SERVER_ERROR, + + // 503 Service Unavailable - server can't handle right now + JsonRpcError::InInitialBlockDownload + | JsonRpcError::NoBlockFilters + | JsonRpcError::Node(_) + | JsonRpcError::Chain + | JsonRpcError::Filters(_) => StatusCode::SERVICE_UNAVAILABLE, + } + } + + pub fn rpc_error(&self) -> RpcError { + match self { + // Parse error - invalid JSON received + JsonRpcError::Decode(msg) => RpcError { + code: PARSE_ERROR, + message: "Parse error".into(), + data: Some(Value::String(msg.clone())), + }, + + // Invalid request - not a valid JSON-RPC request + JsonRpcError::InvalidRequest => RpcError { + code: INVALID_REQUEST, + message: "Invalid request".into(), + data: None, + }, + + // Method not found + JsonRpcError::MethodNotFound => RpcError { + code: METHOD_NOT_FOUND, + message: "Method not found".into(), + data: None, + }, + + // Invalid params - invalid method parameters + JsonRpcError::InvalidHex => RpcError { + code: INVALID_METHOD_PARAMETERS, + message: "Invalid hex encoding".into(), + data: None, + }, + JsonRpcError::InvalidAddress => RpcError { + code: INVALID_METHOD_PARAMETERS, + message: "Invalid address".into(), + data: None, + }, + JsonRpcError::InvalidScript => RpcError { + code: INVALID_METHOD_PARAMETERS, + message: "Invalid script".into(), + data: None, + }, + JsonRpcError::InvalidDescriptor(e) => RpcError { + code: INVALID_METHOD_PARAMETERS, + message: "Invalid descriptor".into(), + data: Some(Value::String(e.to_string())), + }, + JsonRpcError::InvalidVerbosityLevel => RpcError { + code: INVALID_METHOD_PARAMETERS, + message: "Invalid verbosity level".into(), + data: None, + }, + JsonRpcError::InvalidTimestamp => RpcError { + code: INVALID_METHOD_PARAMETERS, + message: "Invalid timestamp".into(), + data: None, + }, + JsonRpcError::InvalidMemInfoMode => RpcError { + code: INVALID_METHOD_PARAMETERS, + message: "Invalid meminfo mode".into(), + data: None, + }, + JsonRpcError::InvalidAddnodeCommand => RpcError { + code: INVALID_METHOD_PARAMETERS, + message: "Invalid addnode command".into(), + data: None, + }, + JsonRpcError::InvalidDisconnectNodeCommand => RpcError { + code: INVALID_METHOD_PARAMETERS, + message: "Invalid disconnectnode command".into(), + data: None, + }, + JsonRpcError::InvalidRescanVal => RpcError { + code: INVALID_METHOD_PARAMETERS, + message: "Invalid rescan values".into(), + data: None, + }, + JsonRpcError::InvalidParameterType(param) => RpcError { + code: INVALID_METHOD_PARAMETERS, + message: "Invalid parameter type".into(), + data: Some(Value::String(param.clone())), + }, + JsonRpcError::MissingParameter(param) => RpcError { + code: INVALID_METHOD_PARAMETERS, + message: "Missing parameter".into(), + data: Some(Value::String(param.clone())), + }, + + // Internal error + JsonRpcError::ChainWorkOverflow => RpcError { + code: INTERNAL_ERROR, + message: "Chain work overflow".into(), + data: None, + }, + + // Server errors (implementation-defined: -32099..=-32000) + JsonRpcError::TxNotFound => RpcError { + code: TX_NOT_FOUND, + message: "Transaction not found".into(), + data: None, + }, + JsonRpcError::BlockNotFound => RpcError { + code: BLOCK_NOT_FOUND, + message: "Block not found".into(), + data: None, + }, + JsonRpcError::PeerNotFound => RpcError { + code: PEER_NOT_FOUND, + message: "Peer not found".into(), + data: None, + }, + JsonRpcError::NoAddressesToRescan => RpcError { + code: NO_ADDRESSES_TO_RESCAN, + message: "No addresses to rescan".into(), + data: None, + }, + JsonRpcError::Wallet(msg) => RpcError { + code: WALLET_ERROR, + message: "Wallet error".into(), + data: Some(Value::String(msg.clone())), + }, + JsonRpcError::MempoolAccept(msg) => RpcError { + code: MEMPOOL_ERROR, + message: "Mempool error".into(), + data: Some(Value::String(format!("{msg}"))), + }, + JsonRpcError::InInitialBlockDownload => RpcError { + code: IN_INITIAL_BLOCK_DOWNLOAD, + message: "Node is in initial block download".into(), + data: None, + }, + JsonRpcError::NoBlockFilters => RpcError { + code: NO_BLOCK_FILTERS, + message: "Block filters not available".into(), + data: None, + }, + JsonRpcError::Node(msg) => RpcError { + code: NODE_ERROR, + message: "Node error".into(), + data: Some(Value::String(msg.clone())), + }, + JsonRpcError::Chain => RpcError { + code: CHAIN_ERROR, + message: "Chain error".into(), + data: None, + }, + JsonRpcError::Filters(msg) => RpcError { + code: FILTERS_ERROR, + message: "Filters error".into(), + data: Some(Value::String(msg.clone())), + }, + } + } + } + + impl From for JsonRpcError { + fn from(value: HeaderExtError) -> Self { + match value { + HeaderExtError::Chain(_) => JsonRpcError::Chain, + HeaderExtError::BlockNotFound => JsonRpcError::BlockNotFound, + HeaderExtError::ChainWorkOverflow => JsonRpcError::ChainWorkOverflow, + } + } + } + + impl_error_from!(JsonRpcError, miniscript::Error, InvalidDescriptor); + impl From> for JsonRpcError { + fn from(e: WatchOnlyError) -> Self { + JsonRpcError::Wallet(e.to_string()) + } + } + + impl From for JsonRpcError { + fn from(e: BlockchainError) -> Self { + match e { + BlockchainError::BlockNotPresent => JsonRpcError::BlockNotFound, + _ => JsonRpcError::Chain, + } + } + } +} #[derive(Deserialize, Serialize)] pub struct GetBlockchainInfoRes { pub best_block: String, @@ -105,7 +547,8 @@ pub struct ScriptPubKeyJson { pub req_sigs: u32, #[serde(rename = "type")] pub type_: String, - pub address: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub address: Option, } #[derive(Deserialize, Serialize)] @@ -130,168 +573,8 @@ pub enum GetBlockRes { One(Box), } -#[derive(Debug, Deserialize, Serialize)] -pub struct RpcError { - pub code: i32, - pub message: String, - pub data: Option, -} - /// Return type for the `gettxoutproof` rpc command, the internal is /// just the hex representation of the Merkle Block, which was defined /// by btc core. #[derive(Debug, Deserialize, Serialize)] pub struct GetTxOutProof(pub Vec); - -#[derive(Debug)] -pub enum JsonRpcError { - /// There was a rescan request but we do not have any addresses in the watch-only wallet. - NoAddressesToRescan, - - /// There was a rescan request with invalid values - InvalidRescanVal, - - /// Missing parameter, e.g., if a required parameter is not provided in the request - MissingParameter(String), - - /// The provided parameter is of the wrong type, e.g., if a string is expected but a number is - /// provided - InvalidParameterType(String), - - /// Verbosity level is not 0 or 1 - InvalidVerbosityLevel, - - /// The requested transaction is not found in the blockchain - TxNotFound, - - /// The provided script is invalid, e.g., if it is not a valid P2PKH or P2SH script - InvalidScript, - - /// The provided descriptor is invalid, e.g., if it does not match the expected format - InvalidDescriptor(miniscript::Error), - - /// The requested block is not found in the blockchain - BlockNotFound, - - /// There is an error with the chain, e.g., if the chain is not synced or when the chain is not valid - Chain, - - /// The request is invalid, e.g., some parameters use an incorrect type - InvalidRequest, - - /// The requested method is not found, e.g., if the method is not implemented or when the method is not available - MethodNotFound, - - /// This error is returned when there is an error decoding the request, e.g., if the request is not valid JSON - Decode(String), - - /// The provided address is invalid, e.g., when it is not a valid IP address or hostname - InvalidAddress, - - /// This error is returned when there is an error with the node, e.g., if the node is not connected or when the node is not responding - Node(String), - - /// This error is returned when the node does not have block filters enabled, which is required for some RPC calls - NoBlockFilters, - - /// This error is returned when a hex value is invalid - InvalidHex, - - /// This error is returned when the node is in initial block download, which means it is still syncing the blockchain - InInitialBlockDownload, - - InvalidMemInfoMode, - - /// This error is returned when there is an error with the wallet, e.g., if the wallet is not loaded or when the wallet is not available - Wallet(String), - - /// This error is returned when there is an error with block filters, e.g., if the filters are not available or when there is an issue with the filter data - Filters(String), - - /// This error is returned when there is an error calculating the chain work - ChainWorkOverflow, - - /// This error is returned when the addnode command is invalid, e.g., if the command is not recognized or when the parameters are incorrect - InvalidAddnodeCommand, - - /// Invalid `disconnect` node command (both address and ID parameters are present). - InvalidDisconnectNodeCommand, - - /// Peer was not found in the peer list. - PeerNotFound, - - /// Raised if when the rescanblockchain command, with the timestamp flag activated, contains some timestamp thats less than the genesis one and not zero which is the default value for this arg. - InvalidTimestamp, - - /// Something went wrong when attempting to publish a transaction to mempool - MempoolAccept(AcceptToMempoolError), -} - -impl_error_from!(JsonRpcError, AcceptToMempoolError, MempoolAccept); - -impl Display for JsonRpcError { - fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { - match self { - JsonRpcError::InvalidTimestamp => write!(f, "Invalid timestamp, ensure that it is between the genesis and the tip."), - JsonRpcError::InvalidRescanVal => write!(f, "Your rescan request contains invalid values"), - JsonRpcError::NoAddressesToRescan => write!(f, "You do not have any address to proceed with the rescan"), - JsonRpcError::MissingParameter(opt) => write!(f, "Missing parameter: {opt}"), - JsonRpcError::InvalidParameterType(opt) => write!(f, "Invalid parameter type for: {opt}"), - JsonRpcError::InvalidRequest => write!(f, "Invalid request"), - JsonRpcError::InvalidHex => write!(f, "Invalid hex"), - JsonRpcError::MethodNotFound => write!(f, "Method not found"), - JsonRpcError::Decode(e) => write!(f, "error decoding request: {e}"), - JsonRpcError::TxNotFound => write!(f, "Transaction not found"), - JsonRpcError::InvalidDescriptor(e) => write!(f, "Invalid descriptor: {e}"), - JsonRpcError::BlockNotFound => write!(f, "Block not found"), - JsonRpcError::Chain => write!(f, "Chain error"), - JsonRpcError::InvalidAddress => write!(f, "Invalid address"), - JsonRpcError::Node(e) => write!(f, "Node error: {e}"), - JsonRpcError::NoBlockFilters => write!(f, "You don't have block filters enabled, please start florestad without --no-cfilters to run this RPC"), - JsonRpcError::InInitialBlockDownload => write!(f, "Node is in initial block download, wait until it's finished"), - JsonRpcError::InvalidScript => write!(f, "Invalid script"), - JsonRpcError::InvalidVerbosityLevel => write!(f, "Invalid verbosity level"), - JsonRpcError::InvalidMemInfoMode => write!(f, "Invalid meminfo mode, should be stats or mallocinfo"), - JsonRpcError::Wallet(e) => write!(f, "Wallet error: {e}"), - JsonRpcError::Filters(e) => write!(f, "Error with filters: {e}"), - JsonRpcError::ChainWorkOverflow => write!(f, "Overflow while calculating the chain work"), - JsonRpcError::InvalidAddnodeCommand => write!(f, "Invalid addnode command"), - JsonRpcError::InvalidDisconnectNodeCommand => write!(f, "Invalid disconnectnode command"), - JsonRpcError::PeerNotFound => write!(f, "Peer not found in the peer list"), - JsonRpcError::MempoolAccept(e) => write!(f, "Could not send transaction to mempool due to {e}"), - } - } -} - -impl IntoResponse for JsonRpcError { - fn into_response(self) -> axum::http::Response { - let body = serde_json::json!({ - "error": self.to_string(), - "result": serde_json::Value::Null, - "id": serde_json::Value::Null, - }); - axum::http::Response::builder() - .status(axum::http::StatusCode::BAD_REQUEST) - .header("Content-Type", "application/json") - .body(axum::body::Body::from(serde_json::to_vec(&body).unwrap())) - .unwrap() - } -} - -impl From for JsonRpcError { - fn from(value: HeaderExtError) -> Self { - match value { - HeaderExtError::Chain(_) => JsonRpcError::Chain, - HeaderExtError::BlockNotFound => JsonRpcError::BlockNotFound, - HeaderExtError::ChainWorkOverflow => JsonRpcError::ChainWorkOverflow, - } - } -} - -impl_error_from!(JsonRpcError, miniscript::Error, InvalidDescriptor); - -impl From> for JsonRpcError { - fn from(e: floresta_watch_only::WatchOnlyError) -> Self { - JsonRpcError::Wallet(e.to_string()) - } -} diff --git a/crates/floresta-node/src/json_rpc/server.rs b/crates/floresta-node/src/json_rpc/server.rs index 925a5af98..82c68f5a4 100644 --- a/crates/floresta-node/src/json_rpc/server.rs +++ b/crates/floresta-node/src/json_rpc/server.rs @@ -2,6 +2,7 @@ use core::net::SocketAddr; use std::collections::HashMap; +use std::result; use std::slice; use std::sync::Arc; use std::time::Instant; @@ -10,7 +11,7 @@ use axum::body::Body; use axum::body::Bytes; use axum::extract::State; use axum::http::Method; -use axum::http::Response; +use axum::http::Response as HttpResponse; use axum::http::StatusCode; use axum::routing::post; use axum::Json; @@ -44,22 +45,26 @@ use tracing::debug; use tracing::error; use tracing::info; -use super::res::JsonRpcError; +use super::res::jsonrpc_interface::JsonRpcError; use super::res::RawTxJson; -use super::res::RpcError; use super::res::ScriptPubKeyJson; use super::res::ScriptSigJson; use super::res::TxInJson; use super::res::TxOutJson; -use crate::json_rpc::request::arg_parser::get_bool; -use crate::json_rpc::request::arg_parser::get_hash; -use crate::json_rpc::request::arg_parser::get_hashes_array; -use crate::json_rpc::request::arg_parser::get_numeric; -use crate::json_rpc::request::arg_parser::get_optional_field; -use crate::json_rpc::request::arg_parser::get_string; +use crate::json_rpc::request::arg_parser::get_at; +use crate::json_rpc::request::arg_parser::get_with_default; +use crate::json_rpc::request::arg_parser::try_into_optional; use crate::json_rpc::request::RpcRequest; +use crate::json_rpc::res::jsonrpc_interface::Response; use crate::json_rpc::res::RescanConfidence; +/// Expect message for `serde_json` serialization of types that implement `Serialize`. +pub(super) const SERIALIZATION_EXPECT: &str = "types used in RPC responses implement Serialize"; + +/// Expect message for HTTP response builder with hardcoded valid headers. +pub(super) const HTTP_RESPONSE_EXPECT: &str = "HTTP response built from valid hardcoded headers"; + +/// The server holds this to tell which rpc method is awaiting to be processed and when the request were made. pub(super) struct InflightRpc { pub method: String, pub when: Instant, @@ -88,18 +93,23 @@ pub struct RpcImpl { type Result = std::result::Result; impl RpcImpl { - fn get_transaction(&self, tx_id: Txid, verbosity: Option) -> Result { - if verbosity == Some(true) { + fn get_transaction(&self, tx_id: Txid, verbosity: bool) -> Result { + if verbosity { let tx = self .wallet .get_transaction(&tx_id) - .ok_or(JsonRpcError::TxNotFound); - return tx.map(|tx| serde_json::to_value(self.make_raw_transaction(tx)).unwrap()); + .ok_or(JsonRpcError::TxNotFound)?; + let raw = self.make_raw_transaction(tx)?; + return Ok(serde_json::to_value(raw).expect(SERIALIZATION_EXPECT)); } self.wallet .get_transaction(&tx_id) - .and_then(|tx| serde_json::to_value(self.make_raw_transaction(tx)).ok()) + .and_then(|tx| { + self.make_raw_transaction(tx) + .ok() + .and_then(|v| serde_json::to_value(v).ok()) + }) .ok_or(JsonRpcError::TxNotFound) } @@ -107,28 +117,29 @@ impl RpcImpl { let desc = slice::from_ref(&descriptor); let mut parsed = parse_descriptors(desc)?; - // It's ok to unwrap because we know there is at least one element in the vector - let addresses = parsed.pop().unwrap(); + let addresses = parsed + .pop() + .expect("parse_descriptors always returns at least one element"); let addresses = (0..100) .map(|index| { let address = addresses .at_derivation_index(index) - .unwrap() + .map_err(|e| JsonRpcError::InvalidParameterType(e.to_string()))? .script_pubkey(); self.wallet.cache_address(address.clone()); - address + Ok(address) }) - .collect::>(); + .collect::>>()?; debug!("Rescanning with block filters for addresses: {addresses:?}"); let addresses = self.wallet.get_cached_addresses(); let wallet = self.wallet.clone(); - if self.block_filter_storage.is_none() { - return Err(JsonRpcError::InInitialBlockDownload); - }; - - let cfilters = self.block_filter_storage.as_ref().unwrap().clone(); + let cfilters = self + .block_filter_storage + .as_ref() + .ok_or(JsonRpcError::NoBlockFilters)? + .clone(); let node = self.node.clone(); let chain = self.chain.clone(); @@ -144,10 +155,10 @@ impl RpcImpl { fn rescan_blockchain( &self, - start: Option, - stop: Option, + start: u32, + stop: u32, use_timestamp: bool, - confidence: Option, + confidence: RescanConfidence, ) -> Result { let (start_height, stop_height) = self.get_rescan_interval(use_timestamp, start, stop, confidence)?; @@ -170,11 +181,11 @@ impl RpcImpl { let wallet = self.wallet.clone(); - if self.block_filter_storage.is_none() { - return Err(JsonRpcError::NoBlockFilters); - }; - - let cfilters = self.block_filter_storage.as_ref().unwrap().clone(); + let cfilters = self + .block_filter_storage + .as_ref() + .ok_or(JsonRpcError::NoBlockFilters)? + .clone(); let node = self.node.clone(); @@ -208,7 +219,7 @@ impl RpcImpl { async fn handle_json_rpc_request( req: RpcRequest, state: Arc>, -) -> Result { +) -> Result { let RpcRequest { jsonrpc, method, @@ -230,18 +241,104 @@ async fn handle_json_rpc_request( }, ); + // Methods that don't require params match method.as_str() { - // blockchain "getbestblockhash" => { - let hash = state.get_best_block_hash()?; - Ok(serde_json::to_value(hash).unwrap()) + return state + .get_best_block_hash() + .map(|v| serde_json::to_value(v).expect(SERIALIZATION_EXPECT)) + } + "getblockchaininfo" => { + return state + .get_blockchain_info() + .map(|v| serde_json::to_value(v).expect(SERIALIZATION_EXPECT)) + } + "getblockcount" => { + return state + .get_block_count() + .map(|v| serde_json::to_value(v).expect(SERIALIZATION_EXPECT)) + } + "getpeerinfo" => { + return state + .get_peer_info() + .await + .map(|v| serde_json::to_value(v).expect(SERIALIZATION_EXPECT)) + } + "getroots" => { + return state + .get_roots() + .map(|v| serde_json::to_value(v).expect(SERIALIZATION_EXPECT)) + } + "getrpcinfo" => { + return state + .get_rpc_info() + .await + .map(|v| serde_json::to_value(v).expect(SERIALIZATION_EXPECT)) + } + "listdescriptors" => { + return state + .list_descriptors() + .map(|v| serde_json::to_value(v).expect(SERIALIZATION_EXPECT)) + } + "ping" => { + state.ping().await?; + return Ok(serde_json::json!(null)); + } + "stop" => { + return state + .stop() + .await + .map(|v| serde_json::to_value(v).expect(SERIALIZATION_EXPECT)) + } + "uptime" => return Ok(serde_json::to_value(state.uptime()).expect(SERIALIZATION_EXPECT)), + _ => {} + } + + // Methods that do require parameters. + // + // Here we use `unwrap_or_default()` because there is methods with only optionals + // parameters. + // Therefore, even if the request is parsed and the `params` field was omitted it's nice to + // turn it to be Some(Value) so the job of gathering inputs for calling the inner + // rpc method goes to the getters under request.rs. + let params = params.unwrap_or_default(); + + match method.as_str() { + "addnode" => { + let node = get_at(¶ms, 0, "node")?; + let command = get_at(¶ms, 1, "command")?; + let v2transport = get_with_default(¶ms, 2, "V2transport", false)?; + + state + .add_node(node, command, v2transport) + .await + .map(|v| serde_json::to_value(v).expect(SERIALIZATION_EXPECT)) + } + + "disconnectnode" => { + let node_address = get_at(¶ms, 0, "node_address")?; + + let node_id = try_into_optional(get_at(¶ms, 1, "node_id"))?; + + state + .disconnect_node(node_address, node_id) + .await + .map(|v| serde_json::to_value(v).expect(SERIALIZATION_EXPECT)) + } + + "findtxout" => { + let txid = get_at(¶ms, 0, "txid")?; + let vout = get_at(¶ms, 1, "vout")?; + let script: String = get_at(¶ms, 2, "script")?; + let script = ScriptBuf::from_hex(&script).map_err(|_| JsonRpcError::InvalidScript)?; + let height = get_at(¶ms, 3, "height")?; + + state.clone().find_tx_out(txid, vout, script, height).await } "getblock" => { - let hash = get_hash(¶ms, 0, "block_hash")?; - // Default value in case of missing parameter is 1 - let verbosity: u8 = - get_optional_field(¶ms, 1, "verbosity", get_numeric)?.unwrap_or(1); + let hash = get_at(¶ms, 0, "block_hash")?; + let verbosity = get_with_default(¶ms, 1, "verbosity", 1)?; state .get_block(hash, verbosity) @@ -249,16 +346,8 @@ async fn handle_json_rpc_request( .map(|v| serde_json::to_value(v).expect("GetBlockRes implements serde")) } - "getblockchaininfo" => state - .get_blockchain_info() - .map(|v| serde_json::to_value(v).unwrap()), - - "getblockcount" => state - .get_block_count() - .map(|v| serde_json::to_value(v).unwrap()), - "getblockfrompeer" => { - let hash = get_hash(¶ms, 0, "block_hash")?; + let hash = get_at(¶ms, 0, "block_hash")?; state.get_block(hash, 0).await?; @@ -266,33 +355,49 @@ async fn handle_json_rpc_request( } "getblockhash" => { - let height = get_numeric(¶ms, 0, "block_height")?; + let height = get_at(¶ms, 0, "block_height")?; state .get_block_hash(height) - .map(|h| serde_json::to_value(h).unwrap()) + .map(|h| serde_json::to_value(h).expect(SERIALIZATION_EXPECT)) } "getblockheader" => { - let hash = get_hash(¶ms, 0, "block_hash")?; + let hash = get_at(¶ms, 0, "block_hash")?; state .get_block_header(hash) - .map(|h| serde_json::to_value(h).unwrap()) + .map(|h| serde_json::to_value(h).expect(SERIALIZATION_EXPECT)) + } + + "getmemoryinfo" => { + let mode: String = get_with_default(¶ms, 0, "mode", "stats".into())?; + + state + .get_memory_info(&mode) + .map(|v| serde_json::to_value(v).expect(SERIALIZATION_EXPECT)) + } + + "getrawtransaction" => { + let txid = get_at(¶ms, 0, "txid")?; + let verbosity = get_with_default(¶ms, 1, "verbosity", false)?; + + state + .get_transaction(txid, verbosity) + .map(|v| serde_json::to_value(v).expect(SERIALIZATION_EXPECT)) } "gettxout" => { - let txid = get_hash(¶ms, 0, "txid")?; - let vout = get_numeric(¶ms, 1, "vout")?; - let include_mempool = - get_optional_field(¶ms, 2, "include_mempool", get_bool)?.unwrap_or(false); + let txid = get_at(¶ms, 0, "txid")?; + let vout = get_at(¶ms, 1, "vout")?; + let include_mempool = get_with_default(¶ms, 2, "include_mempool", false)?; state .get_tx_out(txid, vout, include_mempool) - .map(|v| serde_json::to_value(v).unwrap()) + .map(|v| serde_json::to_value(v).expect(SERIALIZATION_EXPECT)) } "gettxoutproof" => { - let txids = get_hashes_array(¶ms, 0, "txids")?; - let block_hash = get_optional_field(¶ms, 1, "block_hash", get_hash)?; + let txids: Vec = get_at(¶ms, 0, "txids")?; + let block_hash = try_into_optional(get_at(¶ms, 1, "block_hash"))?; Ok(serde_json::to_value( state @@ -304,277 +409,77 @@ async fn handle_json_rpc_request( .expect("GetTxOutProof implements serde")) } - "getrawtransaction" => { - let txid = get_hash(¶ms, 0, "txid")?; - let verbosity = get_optional_field(¶ms, 1, "verbosity", get_bool)?; - - state - .get_transaction(txid, verbosity) - .map(|v| serde_json::to_value(v).unwrap()) - } - - "getroots" => state.get_roots().map(|v| serde_json::to_value(v).unwrap()), - - "findtxout" => { - let txid = get_hash(¶ms, 0, "txid")?; - let vout = get_numeric(¶ms, 1, "vout")?; - let script = get_string(¶ms, 2, "script")?; - let script = ScriptBuf::from_hex(&script).map_err(|_| JsonRpcError::InvalidScript)?; - let height = get_numeric(¶ms, 3, "height")?; - - let state = state.clone(); - state.find_tx_out(txid, vout, script, height).await - } - - // control - "getmemoryinfo" => { - let mode = - get_optional_field(¶ms, 0, "mode", get_string)?.unwrap_or("stats".into()); - - state - .get_memory_info(&mode) - .map(|v| serde_json::to_value(v).unwrap()) - } - - "getrpcinfo" => state - .get_rpc_info() - .await - .map(|v| serde_json::to_value(v).unwrap()), - - // help - // logging - "stop" => state.stop().await.map(|v| serde_json::to_value(v).unwrap()), - - "uptime" => { - let uptime = state.uptime(); - Ok(serde_json::to_value(uptime).unwrap()) - } - - // network - "getpeerinfo" => state - .get_peer_info() - .await - .map(|v| serde_json::to_value(v).unwrap()), - - "addnode" => { - let node = get_string(¶ms, 0, "node")?; - let command = get_string(¶ms, 1, "command")?; - let v2transport = - get_optional_field(¶ms, 2, "V2transport", get_bool)?.unwrap_or(false); - - state - .add_node(node, command, v2transport) - .await - .map(|v| serde_json::to_value(v).unwrap()) - } - - "disconnectnode" => { - let node_address = get_string(¶ms, 0, "node_address")?; - let node_id = get_optional_field(¶ms, 1, "node_id", get_numeric)?; - - state - .disconnect_node(node_address, node_id) - .await - .map(|v| serde_json::to_value(v).unwrap()) - } - - "ping" => { - state.ping().await?; - - Ok(serde_json::json!(null)) - } - - // wallet "loaddescriptor" => { - let descriptor = get_string(¶ms, 0, "descriptor")?; + let descriptor = get_at(¶ms, 0, "descriptor")?; state .load_descriptor(descriptor) - .map(|v| serde_json::to_value(v).unwrap()) + .map(|v| serde_json::to_value(v).expect(SERIALIZATION_EXPECT)) } "rescanblockchain" => { - let start_height = get_optional_field(¶ms, 0, "start_height", get_numeric)?; - let stop_height = get_optional_field(¶ms, 1, "stop_height", get_numeric)?; - let use_timestamp = - get_optional_field(¶ms, 2, "use_timestamp", get_bool)?.unwrap_or(false); - let confidence_str = get_optional_field(¶ms, 3, "confidence", get_string)? - .unwrap_or("medium".into()); - - let confidence = match confidence_str.as_str() { - "low" => RescanConfidence::Low, - "medium" => RescanConfidence::Medium, - "high" => RescanConfidence::High, - "exact" => RescanConfidence::Exact, - _ => return Err(JsonRpcError::InvalidRescanVal), - }; + let start_height = get_with_default(¶ms, 0, "start_height", 0)?; + let stop_height = get_with_default(¶ms, 1, "stop_height", 0)?; + let use_timestamp = get_with_default(¶ms, 2, "use_timestamp", false)?; + let confidence = get_with_default(¶ms, 3, "confidence", RescanConfidence::Medium)?; state - .rescan_blockchain(start_height, stop_height, use_timestamp, Some(confidence)) - .map(|v| serde_json::to_value(v).unwrap()) + .rescan_blockchain(start_height, stop_height, use_timestamp, confidence) + .map(|v| serde_json::to_value(v).expect(SERIALIZATION_EXPECT)) } "sendrawtransaction" => { - let tx = get_string(¶ms, 0, "hex")?; + let tx = get_at(¶ms, 0, "hex")?; state .send_raw_transaction(tx) .await - .map(|v| serde_json::to_value(v).unwrap()) + .map(|v| serde_json::to_value(v).expect(SERIALIZATION_EXPECT)) } - "listdescriptors" => state - .list_descriptors() - .map(|v| serde_json::to_value(v).unwrap()), - - _ => { - let error = JsonRpcError::MethodNotFound; - Err(error) - } - } -} - -fn get_http_error_code(err: &JsonRpcError) -> u16 { - match err { - // you messed up - JsonRpcError::InvalidHex - | JsonRpcError::InvalidAddress - | JsonRpcError::InvalidScript - | JsonRpcError::InvalidRequest - | JsonRpcError::InvalidDescriptor(_) - | JsonRpcError::InvalidVerbosityLevel - | JsonRpcError::Decode(_) - | JsonRpcError::NoBlockFilters - | JsonRpcError::InvalidMemInfoMode - | JsonRpcError::InvalidAddnodeCommand - | JsonRpcError::InvalidDisconnectNodeCommand - | JsonRpcError::PeerNotFound - | JsonRpcError::InvalidTimestamp - | JsonRpcError::InvalidRescanVal - | JsonRpcError::NoAddressesToRescan - | JsonRpcError::InvalidParameterType(_) - | JsonRpcError::MissingParameter(_) - | JsonRpcError::ChainWorkOverflow - | JsonRpcError::MempoolAccept(_) - | JsonRpcError::Wallet(_) => 400, - - // idunnolol - JsonRpcError::MethodNotFound | JsonRpcError::BlockNotFound | JsonRpcError::TxNotFound => { - 404 - } - - // we messed up, sowwy - JsonRpcError::InInitialBlockDownload - | JsonRpcError::Node(_) - | JsonRpcError::Chain - | JsonRpcError::Filters(_) => 503, - } -} - -fn get_json_rpc_error_code(err: &JsonRpcError) -> i32 { - match err { - // Parse Error - JsonRpcError::Decode(_) | JsonRpcError::InvalidParameterType(_) => -32700, - - // Invalid Request - JsonRpcError::InvalidHex - | JsonRpcError::MissingParameter(_) - | JsonRpcError::InvalidAddress - | JsonRpcError::InvalidScript - | JsonRpcError::MethodNotFound - | JsonRpcError::InvalidRequest - | JsonRpcError::InvalidDescriptor(_) - | JsonRpcError::InvalidVerbosityLevel - | JsonRpcError::TxNotFound - | JsonRpcError::BlockNotFound - | JsonRpcError::InvalidTimestamp - | JsonRpcError::InvalidMemInfoMode - | JsonRpcError::InvalidAddnodeCommand - | JsonRpcError::InvalidDisconnectNodeCommand - | JsonRpcError::PeerNotFound - | JsonRpcError::InvalidRescanVal - | JsonRpcError::NoAddressesToRescan - | JsonRpcError::ChainWorkOverflow - | JsonRpcError::Wallet(_) - | JsonRpcError::MempoolAccept(_) => -32600, - - // server error - JsonRpcError::InInitialBlockDownload - | JsonRpcError::Node(_) - | JsonRpcError::Chain - | JsonRpcError::NoBlockFilters - | JsonRpcError::Filters(_) => -32603, + _ => Err(JsonRpcError::MethodNotFound), } } async fn json_rpc_request( State(state): State>>, body: Bytes, -) -> Response { - let req: RpcRequest = match serde_json::from_slice(&body) { - Ok(req) => req, - Err(e) => { - let error = RpcError { - code: -32700, - message: format!("Parse error: {e}"), - data: None, - }; - let body = json!({ - "error": error, - "id": Value::Null, - }); - return Response::builder() - .status(StatusCode::BAD_REQUEST) - .header("Content-Type", "application/json") - .body(Body::from(serde_json::to_vec(&body).unwrap())) - .unwrap(); - } +) -> HttpResponse { + let Ok(req): result::Result = serde_json::from_slice(&body) else { + let error = JsonRpcError::InvalidRequest; + let body = Response::error(error.rpc_error(), Value::Null); + return HttpResponse::builder() + .status(error.http_code()) + .header("Content-Type", "application/json") + .body(Body::from( + serde_json::to_vec(&body).expect(SERIALIZATION_EXPECT), + )) + .expect(HTTP_RESPONSE_EXPECT); }; debug!("Received JSON-RPC request: {req:?}"); let id = req.id.clone(); - let res = handle_json_rpc_request(req, state.clone()).await; + let method_res = handle_json_rpc_request(req, state.clone()).await; state.inflight.write().await.remove(&id); - match res { - Ok(res) => { - let body = serde_json::json!({ - "result": res, - "id": id, - }); - - axum::http::Response::builder() - .status(axum::http::StatusCode::OK) - .header("Content-Type", "application/json") - .body(axum::body::Body::from(serde_json::to_vec(&body).unwrap())) - .unwrap() - } - - Err(e) => { - let http_error_code = get_http_error_code(&e); - let json_rpc_error_code = get_json_rpc_error_code(&e); - let error = RpcError { - code: json_rpc_error_code, - message: e.to_string(), - data: None, - }; - - let body = serde_json::json!({ - "error": error, - "id": id, - }); - - axum::http::Response::builder() - .status(axum::http::StatusCode::from_u16(http_error_code).unwrap()) - .header("Content-Type", "application/json") - .body(axum::body::Body::from(serde_json::to_vec(&body).unwrap())) - .unwrap() - } - } + let response = HttpResponse::builder() + .status(match &method_res { + Err(e) => e.http_code(), + Ok(_) => StatusCode::OK, + }) + .header("Content-Type", "application/json"); + + let body = Response::from_result(method_res, id); + + response + .body(Body::from( + serde_json::to_vec(&body).expect(SERIALIZATION_EXPECT), + )) + .expect(HTTP_RESPONSE_EXPECT) } -async fn cannot_get(_state: State>>) -> Json { +async fn cannot_get(_state: State>>) -> Json { Json(json!({ "error": "Cannot get on this route", })) @@ -597,7 +502,7 @@ impl RpcImpl { stop_height, chain.clone(), ) - .unwrap(); + .map_err(|e| JsonRpcError::Filters(e.to_string()))?; info!("rescan filter hits: {blocks:?}"); @@ -605,8 +510,8 @@ impl RpcImpl { if let Ok(Some(block)) = node.get_block(block).await { let height = chain .get_block_height(&block.block_hash()) - .unwrap() - .unwrap(); + .map_err(|_| JsonRpcError::Chain)? + .ok_or(JsonRpcError::BlockNotFound)?; wallet.block_process(&block, height); } @@ -660,9 +565,12 @@ impl RpcImpl { asm: output.script_pubkey.to_asm_string(), hex: output.script_pubkey.to_hex_string(), req_sigs: 0, // This field is deprecated + // `Address::from_script` can fail for nonstandard scripts. Bitcoin Core + // omits the `address` field entirely when `ExtractDestination` fails: + // https://github.com/bitcoin/bitcoin/blob/f50d53c84736f8ada8419346c4d1734d5a6686d4/src/core_io.cpp#L424 address: Address::from_script(&output.script_pubkey, self.network) - .map(|a| a.to_string()) - .unwrap(), + .ok() + .map(|a| a.to_string()), type_: Self::get_script_type(output.script_pubkey) .unwrap_or("nonstandard") .to_string(), @@ -670,7 +578,7 @@ impl RpcImpl { } } - fn make_raw_transaction(&self, tx: CachedTransaction) -> RawTxJson { + fn make_raw_transaction(&self, tx: CachedTransaction) -> Result { let raw_tx = tx.tx; let in_active_chain = tx.height != 0; let hex = serialize_hex(&raw_tx); @@ -679,14 +587,14 @@ impl RpcImpl { .chain .get_block_hash(tx.height) .unwrap_or(BlockHash::all_zeros()); - let tip = self.chain.get_height().unwrap(); + let tip = self.chain.get_height().map_err(|_| JsonRpcError::Chain)?; let confirmations = if in_active_chain { tip - tx.height + 1 } else { 0 }; - RawTxJson { + Ok(RawTxJson { in_active_chain, hex, txid, @@ -719,7 +627,7 @@ impl RpcImpl { .get_block_header(&block_hash) .map(|h| h.time) .unwrap_or(0), - } + }) } // TODO(@luisschwab): get rid of this once @@ -748,7 +656,7 @@ impl RpcImpl { let address = address.unwrap_or_else(|| { format!("127.0.0.1:{}", Self::get_port(&network)) .parse() - .unwrap() + .expect("hardcoded address is valid") }); let listener = match tokio::net::TcpListener::bind(address).await { diff --git a/crates/floresta-node/src/lib.rs b/crates/floresta-node/src/lib.rs index 670f0f1ec..6532132d7 100644 --- a/crates/floresta-node/src/lib.rs +++ b/crates/floresta-node/src/lib.rs @@ -11,6 +11,7 @@ mod config_file; mod error; mod florestad; #[cfg(feature = "json-rpc")] +#[deny(clippy::unwrap_used)] mod json_rpc; mod slip132; mod wallet_input; diff --git a/tests/florestad/rpcserver_request_parsing.py b/tests/florestad/rpcserver_request_parsing.py new file mode 100644 index 000000000..8cadba2eb --- /dev/null +++ b/tests/florestad/rpcserver_request_parsing.py @@ -0,0 +1,343 @@ +""" +Tests for JSON-RPC request parsing in florestad. + +Validates that the RPC server correctly handles: +- Positional (array) parameters +- Named (object) parameters +- Null / omitted parameters +- Default values for optional parameters +- Proper JSON-RPC error codes per the spec (-32700, -32600, -32601, -32602, -32603) +- HTTP status codes (400, 404, 500, 503) +- Methods that require no params vs methods that require params +""" + +from test_framework import FlorestaTestFramework +from test_framework.node import NodeType + +# JSON-RPC spec error code constants +PARSE_ERROR = -32700 +INVALID_REQUEST = -32600 +METHOD_NOT_FOUND = -32601 +INVALID_PARAMS = -32602 +INTERNAL_ERROR = -32603 + + +class RpcServerRequestParsingTest(FlorestaTestFramework): + """ + Test JSON-RPC request parsing, parameter extraction (positional and named), + error codes, and edge cases on the florestad RPC server. + """ + + def assert_success(self, resp): + """Assert that a JSON-RPC response indicates success (HTTP 200, no error).""" + self.assertEqual(resp["status_code"], 200) + self.assertIsNone(resp["body"].get("error")) + + def assert_error( + self, resp, expected_status_code=None, expected_rpcerror_code=None + ): + """ + Assert that a JSON-RPC response indicates an error (non-200, error present).""" + self.assertIsSome(resp["body"].get("error")) + + if expected_status_code is None: + self.assertNotEqual(resp["status_code"], 200) + else: + self.assertEqual(resp["status_code"], expected_status_code) + + if expected_rpcerror_code is not None: + self.assertEqual(resp["body"]["error"]["code"], expected_rpcerror_code) + + def set_test_params(self): + """Configure test parameters with a single florestad node.""" + self.node = self.add_node_default_args(NodeType.FLORESTAD) + + def run_test(self): + """Run all JSON-RPC request parsing tests.""" + self.run_node(self.node) + + self.test_noparammethods_omittedparams_succeeds() + self.test_noparammethods_nullparams_succeeds() + self.test_noparammethods_emptyarray_succeeds() + self.test_positionalparams_validargs_succeeds() + self.test_namedparams_validargs_succeeds() + self.test_optionalparams_omitted_usesdefaults() + self.test_unknownmethod_anyparams_returnsmethodnotfound() + self.test_requiredparams_missing_returnsinvalidparams() + self.test_paramtypes_wrongtype_returnsinvalidparams() + self.test_jsonrpcversion_invalid_returnsrejection() + self.test_parammethods_omittedparams_returnserror() + self.test_responsestructure_success_matchesjsonrpcspec() + self.test_responsestructure_error_matchesjsonrpcspec() + + def test_noparammethods_omittedparams_succeeds(self): + """Verify no-param methods succeed when the params field is omitted.""" + self.log("Test: no-param methods without params field") + + no_param_methods = [ + "getbestblockhash", + "getblockchaininfo", + "getblockcount", + "getroots", + "getrpcinfo", + "uptime", + "getpeerinfo", + "listdescriptors", + ] + + for method in no_param_methods: + resp = self.node.rpc.noraise_request(method) + self.assert_success(resp) + + def test_noparammethods_nullparams_succeeds(self): + """Verify no-param methods succeed when params is explicitly null.""" + self.log("Test: no-param methods with params: null") + + resp = self.node.rpc.noraise_request("getblockcount", params=None) + self.assert_success(resp) + + def test_noparammethods_emptyarray_succeeds(self): + """Verify no-param methods succeed when params is an empty array.""" + self.log("Test: no-param methods with empty array params") + + resp = self.node.rpc.noraise_request("getblockcount", params=[]) + self.assert_success(resp) + + def test_positionalparams_validargs_succeeds(self): + """Verify methods accept valid positional (array) parameters.""" + self.log("Test: positional params") + + # getblockhash with positional param: height 0 + resp = self.node.rpc.noraise_request("getblockhash", params=[0]) + self.assert_success(resp) + + genesis_hash = resp["body"]["result"] + + # getblockheader with positional param: genesis hash + resp = self.node.rpc.noraise_request("getblockheader", params=[genesis_hash]) + self.assert_success(resp) + + # getblock with positional params: hash, verbosity + resp = self.node.rpc.noraise_request("getblock", params=[genesis_hash, 1]) + self.assert_success(resp) + + def test_namedparams_validargs_succeeds(self): + """Verify methods accept valid named (object) parameters.""" + self.log("Test: named params") + + resp = self.node.rpc.noraise_request("getblockhash", params={"block_height": 0}) + self.assert_success(resp) + + genesis_hash = resp["body"]["result"] + + resp = self.node.rpc.noraise_request( + "getblockheader", params={"block_hash": genesis_hash} + ) + self.assert_success(resp) + + resp = self.node.rpc.noraise_request( + "getblock", params={"block_hash": genesis_hash, "verbosity": 0} + ) + self.assert_success(resp) + + def test_optionalparams_omitted_usesdefaults(self): + """Verify omitted optional parameters fall back to their defaults.""" + self.log("Test: optional defaults") + + genesis_hash = self.node.rpc.get_bestblockhash() + + # getblock with only the required param (verbosity defaults to 1) + resp_default = self.node.rpc.noraise_request("getblock", params=[genesis_hash]) + self.assert_success(resp_default) + + # Result should be verbose (verbosity=1): an object, not a hex string + result = resp_default["body"]["result"] + self.assertIn("hash", result) + self.assertIn("tx", result) + + # Explicit verbosity=1 should match the default + resp_explicit = self.node.rpc.noraise_request( + "getblock", params=[genesis_hash, 1] + ) + self.assert_success(resp_explicit) + self.assertEqual( + resp_default["body"]["result"], resp_explicit["body"]["result"] + ) + + # getmemoryinfo with omitted default + resp = self.node.rpc.noraise_request("getmemoryinfo") + self.assert_success(resp) + + # Named params: only required field, optional uses default + resp = self.node.rpc.noraise_request( + "getblock", params={"block_hash": genesis_hash} + ) + self.assert_success(resp) + self.assertEqual( + resp_default["body"]["result"], resp_explicit["body"]["result"] + ) + self.assertIn("hash", resp["body"]["result"]) + + def test_unknownmethod_anyparams_returnsmethodnotfound(self): + """Verify unknown methods return METHOD_NOT_FOUND (-32601).""" + self.log("Test: method not found") + + resp = self.node.rpc.noraise_request("nonexistent_method", params=[]) + self.assert_error( + resp, expected_status_code=404, expected_rpcerror_code=METHOD_NOT_FOUND + ) + + def test_requiredparams_missing_returnsinvalidparams(self): + """Verify missing required parameters return INVALID_PARAMS (-32602).""" + self.log("Test: missing required params") + + # getblockhash requires a height parameter + resp = self.node.rpc.noraise_request("getblockhash", params=[]) + self.assert_error( + resp, expected_status_code=400, expected_rpcerror_code=INVALID_PARAMS + ) + + # getblockheader requires a block_hash, not an int + resp = self.node.rpc.noraise_request("getblockheader", params=[1]) + self.assert_error( + resp, expected_status_code=400, expected_rpcerror_code=INVALID_PARAMS + ) + + # Named params: empty object means missing required fields + resp = self.node.rpc.noraise_request("getblockhash", params={}) + self.assert_error( + resp, expected_status_code=400, expected_rpcerror_code=INVALID_PARAMS + ) + + def test_paramtypes_wrongtype_returnsinvalidparams(self): + """Verify wrong parameter types return INVALID_PARAMS (-32602).""" + self.log("Test: wrong param types") + + # getblockhash expects a number, not a string + resp = self.node.rpc.noraise_request("getblockhash", params=["not_a_number"]) + self.assert_error( + resp, expected_status_code=400, expected_rpcerror_code=INVALID_PARAMS + ) + + # getblock expects a valid block hash string, not a number + resp = self.node.rpc.noraise_request("getblock", params=[12345]) + self.assert_error( + resp, expected_status_code=400, expected_rpcerror_code=INVALID_PARAMS + ) + + # getblock verbosity expects a number, not a string + genesis_hash = self.node.rpc.get_bestblockhash() + resp = self.node.rpc.noraise_request( + "getblock", params=[genesis_hash, "invalid_verbosity"] + ) + self.assert_error( + resp, expected_status_code=400, expected_rpcerror_code=INVALID_PARAMS + ) + + def test_jsonrpcversion_invalid_returnsrejection(self): + """Verify invalid jsonrpc versions are rejected and valid ones accepted.""" + self.log("Test: invalid jsonrpc version") + + resp = self.node.rpc.noraise_raw_request( + { + "jsonrpc": "3.0", + "id": "test", + "method": "getblockcount", + "params": [], + } + ) + self.assert_error( + resp, expected_status_code=400, expected_rpcerror_code=INVALID_REQUEST + ) + + # Valid versions ("1.0" and "2.0") should work + for version in ["1.0", "2.0"]: + resp = self.node.rpc.noraise_raw_request( + { + "jsonrpc": version, + "id": "test", + "method": "getblockcount", + "params": [], + } + ) + self.assert_success(resp) + + # Omitted jsonrpc field should work (pre-2.0 compat) + resp = self.node.rpc.noraise_raw_request( + { + "id": "test", + "method": "getblockcount", + } + ) + self.assert_success(resp) + + def test_parammethods_omittedparams_returnserror(self): + """Verify methods that require params fail when params are omitted.""" + self.log("Test: param methods fail without params") + + methods_needing_params = [ + "getblock", + "getblockhash", + "getblockheader", + "getblockfrompeer", + "getrawtransaction", + "gettxout", + "gettxoutproof", + "findtxout", + "addnode", + "disconnectnode", + "loaddescriptor", + "sendrawtransaction", + ] + + for method in methods_needing_params: + resp = self.node.rpc.noraise_request(method) + self.assert_error( + resp, expected_status_code=400, expected_rpcerror_code=INVALID_PARAMS + ) + + def test_responsestructure_success_matchesjsonrpcspec(self): + """Verify successful responses match the JSON-RPC 2.0 spec structure.""" + self.log("Test: success response structure") + + resp = self.node.rpc.noraise_raw_request( + { + "jsonrpc": "2.0", + "id": "struct_test", + "method": "getblockcount", + } + ) + + body = resp["body"] + self.assertIn("result", body) + self.assertIn("id", body) + self.assertEqual(body["id"], "struct_test") + self.assertIsSome(body.get("result")) + + def test_responsestructure_error_matchesjsonrpcspec(self): + """Verify error responses match the JSON-RPC 2.0 spec structure.""" + self.log("Test: error response structure") + + resp = self.node.rpc.noraise_raw_request( + { + "jsonrpc": "2.0", + "id": "struct_err", + "method": "nonexistent", + "params": [], + } + ) + + body = resp["body"] + self.assertIn("error", body) + self.assertIn("id", body) + self.assertEqual(body["id"], "struct_err") + + err = body["error"] + self.assertIn("code", err) + self.assertIn("message", err) + self.assertTrue(isinstance(err["code"], int)) + self.assertEqual(body["id"], "struct_err") + + +if __name__ == "__main__": + RpcServerRequestParsingTest().main() diff --git a/tests/test_framework/rpc/base.py b/tests/test_framework/rpc/base.py index f0d8baf2d..304cd3ac6 100644 --- a/tests/test_framework/rpc/base.py +++ b/tests/test_framework/rpc/base.py @@ -8,19 +8,19 @@ """ import json +import re import socket import time -import re +from abc import ABC, abstractmethod from datetime import datetime, timezone from typing import Any, Dict, List, Optional from urllib.parse import quote -from abc import ABC, abstractmethod from requests import post from requests.exceptions import HTTPError from requests.models import HTTPBasicAuth -from test_framework.rpc.exceptions import JSONRPCError from test_framework.rpc import ConfigRPC +from test_framework.rpc.exceptions import JSONRPCError # pylint: disable=too-many-public-methods @@ -102,26 +102,38 @@ def build_log_message( return logmsg - def build_request(self, method: str, params: List[Any]) -> Dict[str, Any]: + def _build_request_kwargs(self, timeout: int = TIMEOUT) -> Dict[str, Any]: """ - Build the request dictionary for the RPC call. + Build the common request kwargs (url, headers, auth, timeout). """ - request = { - "url": f"{self.address}", + kwargs = { + "url": self.address, "headers": {"content-type": "application/json"}, - "data": json.dumps( - { - "jsonrpc": self._jsonrpc_version, - "id": "0", - "method": method, - "params": params, - } - ), - "timeout": self.TIMEOUT, + "timeout": timeout, } + if self._config.user is not None and self._config.password is not None: - request["auth"] = HTTPBasicAuth(self._config.user, self._config.password) + kwargs["auth"] = HTTPBasicAuth(self._config.user, self._config.password) + + return kwargs + + def build_request( + self, method: str, params: List[Any] | None, request_id: str = "test" + ) -> Dict[str, Any]: + """ + Build the request dictionary for the RPC call. + """ + request = self._build_request_kwargs() + payload = { + "jsonrpc": self._jsonrpc_version, + "id": request_id, + "method": method, + } + + if params is not None: + payload["params"] = params + request["data"] = json.dumps(payload) return request # pylint: disable=unused-argument,dangerous-default-value @@ -135,25 +147,19 @@ def perform_request( and params. The params should be a list of arguments to the method. The method should be a string with the name of the method to be called. - The method will return the result of the request or raise a JSONRPCError if the request failed. """ - request = self.build_request(method, params) - # Now make the POST request to the RPC server logmsg = BaseRPC.build_log_message( - request["url"], method, params, self._config.user, self._config.password + self.address, method, params, self._config.user, self._config.password ) - self.log(logmsg) - response = post(**request) - + response = self.noraise_request(method, params) # If response isnt 200, raise an HTTPError - if response.status_code != 200: + if response.get("status_code") != 200: raise HTTPError - - result = response.json() + result = response.get("body", {}) # Error could be None or a str # If in the future this change, # cast the resulted error to str @@ -164,10 +170,28 @@ def perform_request( code=result["error"]["code"], message=result["error"]["message"], ) - self.log(result["result"]) return result["result"] + def noraise_raw_request(self, payload: dict): + """ + Send a raw JSON-RPC request (as a dict) to the node. + Does NOT raise on non-200 so callers can inspect both HTTP status and JSON body. + """ + kwargs = self._build_request_kwargs() + kwargs["data"] = json.dumps(payload) + return self._send_request(kwargs) + + def noraise_request(self, method: str, params=None, request_id: str = "test"): + """Send a standard JSON-RPC request and return the parsed response (no raise).""" + body = self.build_request(method, params, request_id) + return self._send_request(body) + + def _send_request(self, body: dict): + """Send a JSON-RPC request and return the parsed response.""" + res = post(**body) + return {"status_code": res.status_code, "body": res.json()} + def is_socket_listening(self) -> bool: """Check if the socket is listening for connections on the specified port.""" with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock: diff --git a/tests/test_runner.py b/tests/test_runner.py index 6050e5445..d784ea11f 100644 --- a/tests/test_runner.py +++ b/tests/test_runner.py @@ -24,8 +24,8 @@ import os import subprocess from collections import defaultdict -from threading import Thread from queue import Queue +from threading import Thread from time import time from test_framework.util import Utility @@ -65,6 +65,7 @@ ("florestad", "tls"), ("example", "electrum"), ("floresta-cli", "getblock"), + ("florestad", "rpcserver_request_parsing"), ("example", "functional"), ("floresta-cli", "getmemoryinfo"), ("floresta-cli", "getpeerinfo"), @@ -208,7 +209,6 @@ def run_test_worker(task_queue: Queue, results_queue: Queue, args: argparse.Name with open( test_log_name, "wt", encoding="utf-8", buffering=args.log_buffer ) as log_file: - # Avoid using 'with' for `subprocess.Popen` here, as we need the # process to start and stream output immediately for port detection # to work correctly. Using 'with' might delay output flushing,