diff --git a/chronik/chronik-http/src/electrum.rs b/chronik/chronik-http/src/electrum.rs index 8026aa5bec..c783a77d97 100644 --- a/chronik/chronik-http/src/electrum.rs +++ b/chronik/chronik-http/src/electrum.rs @@ -1,377 +1,394 @@ // Copyright (c) 2024 The Bitcoin developers // Distributed under the MIT software license, see the accompanying // file COPYING or http://www.opensource.org/licenses/mit-license.php. //! Module for [`ChronikElectrumServer`]. use std::str::FromStr; use std::{net::SocketAddr, sync::Arc}; use abc_rust_error::Result; use bitcoinsuite_core::{ hash::{Hashed, Sha256}, tx::TxId, }; use futures::future; use itertools::izip; use karyon_jsonrpc::{RPCError, RPCMethod, RPCService, Server}; use karyon_net::{Addr, Endpoint}; use rustls::pki_types::{ pem::PemObject, {CertificateDer, PrivateKeyDer}, }; use serde_json::{json, Value}; use thiserror::Error; use crate::{ server::{ChronikIndexerRef, NodeRef}, {electrum::ChronikElectrumServerError::*, electrum_codec::ElectrumCodec}, }; /// Chronik Electrum protocol #[derive(Clone, Copy, Debug)] pub enum ChronikElectrumProtocol { /// TCP Tcp, /// TLS Tls, } /// Params defining what and where to serve for [`ChronikElectrumServer`]. #[derive(Clone, Debug)] pub struct ChronikElectrumServerParams { /// Host address (port + IP) where to serve the electrum server at. pub hosts: Vec<(SocketAddr, ChronikElectrumProtocol)>, /// Indexer to read data from pub indexer: ChronikIndexerRef, /// Access to the bitcoind node pub node: NodeRef, /// The TLS certificate chain file pub tls_cert_path: String, /// The TLS private key file pub tls_privkey_path: String, } /// Chronik Electrum server, holding all the data/handles required to serve an /// instance. #[derive(Debug)] pub struct ChronikElectrumServer { hosts: Vec<(SocketAddr, ChronikElectrumProtocol)>, indexer: ChronikIndexerRef, node: NodeRef, tls_cert_path: String, tls_privkey_path: String, } /// Errors for [`ChronikElectrumServer`]. #[derive(Debug, Eq, Error, PartialEq)] pub enum ChronikElectrumServerError { /// Binding to host address failed #[error("Chronik Electrum failed binding to {0}: {1}")] FailedBindingAddress(SocketAddr, String), /// Serving Electrum failed #[error("Chronik Electrum failed serving: {0}")] ServingFailed(String), /// Chronik Electrum TLS invalid configuration #[error("Chronik Electrum TLS configuration is invalid: {0}")] InvalidTlsConfiguration(String), /// Chronik Electrum TLS configuration failed #[error("Chronik Electrum TLS configuration failed: {0}")] TlsConfigurationFailed(String), /// Missing certificate chain file #[error( "Chronik Electrum TLS configuration requires a certificate chain file \ (see -chronikelectrumcert)" )] MissingCertificateFile, /// Certificate chain file not found #[error( "Chronik Electrum TLS configuration failed to open the certificate \ chain file {0}: {1}" )] CertificateFileNotFound(String, String), /// Missing private key file #[error( "Chronik Electrum TLS configuration requires a private key file (see \ -chronikelectrumprivkey)" )] MissingPrivateKeyFile, /// Private key file not found #[error( "Chronik Electrum TLS configuration failed to open the private key \ file {0}, {1}" )] PrivateKeyFileNotFound(String, String), } struct ChronikElectrumRPCServerEndpoint {} struct ChronikElectrumRPCBlockchainEndpoint { indexer: ChronikIndexerRef, node: NodeRef, } impl ChronikElectrumServer { /// Binds the Chronik server on the given hosts pub fn setup(params: ChronikElectrumServerParams) -> Result<Self> { Ok(ChronikElectrumServer { hosts: params.hosts, indexer: params.indexer, node: params.node, tls_cert_path: params.tls_cert_path, tls_privkey_path: params.tls_privkey_path, }) } /// Start the Chronik electrum server pub async fn serve(self) -> Result<()> { // The behavior is to bind the endpoint name to its method name like so: // endpoint.method as the name of the RPC let server_endpoint = Arc::new(ChronikElectrumRPCServerEndpoint {}); let blockchain_endpoint = Arc::new(ChronikElectrumRPCBlockchainEndpoint { indexer: self.indexer, node: self.node, }); let tls_cert_path = self.tls_cert_path.clone(); let tls_privkey_path = self.tls_privkey_path.clone(); let servers = izip!( self.hosts, std::iter::repeat(server_endpoint), std::iter::repeat(blockchain_endpoint), std::iter::repeat(tls_cert_path), std::iter::repeat(tls_privkey_path) ) .map( |( (host, protocol), server_endpoint, blockchain_endpoint, tls_cert_path, tls_privkey_path, )| { Box::pin(async move { let mut require_tls_config = false; // Don't use the karyon Endpoint parsing as it doesn't // appear to support IPv6. let endpoint = match protocol { ChronikElectrumProtocol::Tcp => { Endpoint::Tcp(Addr::Ip(host.ip()), host.port()) } ChronikElectrumProtocol::Tls => { require_tls_config = true; Endpoint::Tls(Addr::Ip(host.ip()), host.port()) } }; let mut builder = Server::builder_with_json_codec( endpoint, ElectrumCodec {}, ) .map_err(|err| { FailedBindingAddress(host, err.to_string()) })?; if require_tls_config { if tls_cert_path.is_empty() { return Err(MissingCertificateFile); } if tls_privkey_path.is_empty() { return Err(MissingPrivateKeyFile); } let certs: Vec<_> = CertificateDer::pem_file_iter( tls_cert_path.as_str(), ) .map_err(|err| { CertificateFileNotFound( tls_cert_path, err.to_string(), ) })? .map(|cert| cert.unwrap()) .collect(); let private_key = PrivateKeyDer::from_pem_file( tls_privkey_path.as_str(), ) .map_err(|err| { PrivateKeyFileNotFound( tls_privkey_path, err.to_string(), ) })?; let tls_config = rustls::ServerConfig::builder() .with_no_client_auth() .with_single_cert(certs, private_key) .map_err(|err| { InvalidTlsConfiguration(err.to_string()) })?; builder = builder.tls_config(tls_config).map_err(|err| { TlsConfigurationFailed(err.to_string()) })?; } let server = builder .service(server_endpoint) .service(blockchain_endpoint) .build() .await .map_err(|err| ServingFailed(err.to_string()))?; server.start(); let () = future::pending().await; Ok::<(), ChronikElectrumServerError>(()) }) }, ); let (result, _, _) = futures::future::select_all(servers).await; result?; Ok(()) } } impl RPCService for ChronikElectrumRPCServerEndpoint { fn name(&self) -> String { "server".to_string() } fn get_method(&self, name: &str) -> Option<RPCMethod<'_>> { match name { // TODO Create a macro to generate this or avoid duplicated code. "ping" => { Some(Box::new(move |params: Value| Box::pin(self.ping(params)))) } _ => None, } } } impl ChronikElectrumRPCServerEndpoint { async fn ping(&self, _params: Value) -> Result<Value, RPCError> { Ok(Value::Null) } } impl RPCService for ChronikElectrumRPCBlockchainEndpoint { fn name(&self) -> String { "blockchain".to_string() } fn get_method(&self, name: &str) -> Option<RPCMethod<'_>> { match name { "transaction.get" => Some(Box::new(move |params: Value| { Box::pin(self.transaction_get(params)) })), _ => None, } } } +/// Get a mandatory JSONRPC param by index or by name. +macro_rules! get_param { + ($params:expr, $index:expr, $key:expr) => {{ + match $params { + Value::Array(ref arr) => Ok(arr + .get($index) + .ok_or(RPCError::InvalidParams(concat!( + "Missing mandatory '", + $key, + "' parameter" + )))? + .clone()), + Value::Object(ref obj) => match obj.get($key) { + Some(value) => Ok(value.clone()), + None => Err(RPCError::InvalidParams(concat!( + "Missing mandatory '", + $key, + "' parameter" + ))), + }, + _ => Err(RPCError::InvalidParams( + "'params' must be an array or an object", + )), + } + }}; +} + +/// Get an optional JSONRPC param by index or by name, return the +/// provided default if the param not specified. +macro_rules! get_optional_param { + ($params:expr, $index:expr, $key:expr, $default:expr) => {{ + match $params { + Value::Array(ref arr) => match arr.get($index) { + Some(val) => Ok(val.clone()), + None => Ok($default), + }, + Value::Object(ref obj) => match obj.get($key) { + Some(value) => Ok(value.clone()), + None => Ok($default), + }, + _ => Err(RPCError::InvalidParams( + "'params' must be an array or an object", + )), + } + }}; +} + impl ChronikElectrumRPCBlockchainEndpoint { async fn transaction_get(&self, params: Value) -> Result<Value, RPCError> { - let txid_hex: Value; - let verbose: Value; - match params { - Value::Array(arr) => { - txid_hex = arr - .first() - .ok_or(RPCError::InvalidParams( - "Missing mandatory 'txid' parameter", - ))? - .clone(); - verbose = match arr.get(1) { - Some(val) => val.clone(), - None => Value::Bool(false), - }; - } - Value::Object(obj) => { - txid_hex = match obj.get("txid") { - Some(txid) => Ok(txid.clone()), - None => Err(RPCError::InvalidParams( - "Missing mandatory 'txid' parameter", - )), - }?; - verbose = match obj.get("verbose") { - Some(verbose) => verbose.clone(), - None => Value::Bool(false), - }; - } - _ => { - return Err(RPCError::InvalidParams( - "'params' must be an array or an object", - )) - } - }; + let txid_hex = get_param!(params, 0, "txid")?; + let verbose = + get_optional_param!(params, 1, "verbose", Value::Bool(false))?; let txid_hex = match txid_hex { Value::String(s) => Ok(s), _ => Err(RPCError::InvalidParams( "'txid' must be a hexadecimal string", )), }?; let txid = TxId::from_str(&txid_hex) .or(Err(RPCError::InvalidParams("Failed to parse txid")))?; let verbose = match verbose { Value::Bool(v) => Ok(v), _ => Err(RPCError::InvalidParams("'verbose' must be a boolean")), }?; let indexer = self.indexer.read().await; let query_tx = indexer.txs(&self.node); let raw_tx = hex::encode( query_tx .raw_tx_by_id(&txid) .or(Err(RPCError::InvalidRequest("Unknown transaction id")))? .raw_tx, ); if !verbose { return Ok(Value::String(raw_tx)); } let tx = query_tx .tx_by_id(txid) // The following error should be unreachable, unless raw_tx_by_id // and tx_by_id are inconsistent .or(Err(RPCError::InvalidRequest("Unknown transaction id")))?; let blockchaininfo = indexer.blocks(&self.node).blockchain_info(); if blockchaininfo.is_err() { return Err(RPCError::InternalError); } if tx.block.is_none() { // mempool transaction return Ok(json!({ "confirmations": 0, "hash": txid_hex, "hex": raw_tx, "time": tx.time_first_seen, })); } let block = tx.block.unwrap(); let blockhash = Sha256::from_le_slice(block.hash.as_ref()).unwrap(); let confirmations = blockchaininfo.ok().unwrap().tip_height - block.height + 1; // TODO: more verbose fields, inputs, outputs // (but for now only "confirmations" is used in Electrum ABC) Ok(json!({ "blockhash": blockhash.hex_be(), "blocktime": block.timestamp, "confirmations": confirmations, "hash": txid_hex, "hex": raw_tx, "time": tx.time_first_seen, })) } } diff --git a/test/functional/chronik_electrum_blockchain.py b/test/functional/chronik_electrum_blockchain.py index d7ada20ffb..cebae38944 100644 --- a/test/functional/chronik_electrum_blockchain.py +++ b/test/functional/chronik_electrum_blockchain.py @@ -1,71 +1,125 @@ # Copyright (c) 2024-present The Bitcoin developers # Distributed under the MIT software license, see the accompanying # file COPYING or http://www.opensource.org/licenses/mit-license.php. """ Test Chronik's electrum interface: blockchain.* methods """ from test_framework.blocktools import ( GENESIS_BLOCK_HASH, GENESIS_CB_SCRIPT_PUBKEY, GENESIS_CB_SCRIPT_SIG, GENESIS_CB_TXID, TIME_GENESIS_BLOCK, ) from test_framework.test_framework import BitcoinTestFramework from test_framework.util import assert_equal COINBASE_TX_HEX = ( "01000000010000000000000000000000000000000000000000000000000000000000000000ffffffff4d" + GENESIS_CB_SCRIPT_SIG.hex() + "ffffffff0100f2052a0100000043" + GENESIS_CB_SCRIPT_PUBKEY.hex() + "00000000" ) class ChronikElectrumBlockchain(BitcoinTestFramework): def set_test_params(self): self.setup_clean_chain = True self.num_nodes = 1 self.extra_args = [["-chronik"]] def skip_test_if_missing_module(self): self.skip_if_no_chronik() def run_test(self): self.client = self.nodes[0].get_chronik_electrum_client() + self.test_invalid_params() + self.test_transaction_get() + def test_invalid_params(self): + # Invalid params type + for response in ( + self.client.synchronous_request("blockchain.transaction.get", params=None), + self.client.synchronous_request("blockchain.transaction.get", params=42), + ): + assert_equal( + response.error, + {"code": -32602, "message": "'params' must be an array or an object"}, + ) + + # Missing mandatory argument in otherwise valid params + for response in ( + self.client.synchronous_request("blockchain.transaction.get", params=[]), + self.client.synchronous_request("blockchain.transaction.get", params={}), + self.client.synchronous_request( + "blockchain.transaction.get", + params={"nottxid": 32 * "ff", "verbose": False}, + ), + self.client.blockchain.transaction.get(verbose=True), + ): + assert_equal( + response.error, + {"code": -32602, "message": "Missing mandatory 'txid' parameter"}, + ) + + # Non-string json type for txid + assert_equal( + self.client.blockchain.transaction.get(txid=int(32 * "ff", 16)).error, + {"code": -32602, "message": "'txid' must be a hexadecimal string"}, + ) + + for response in ( + # non-hex characters + self.client.blockchain.transaction.get("les sanglots longs"), + # odd number of hex chars + self.client.blockchain.transaction.get(GENESIS_CB_TXID[:-1]), + # valid hex but invalid length for a txid + self.client.blockchain.transaction.get(GENESIS_CB_TXID[:-2]), + ): + assert_equal( + response.error, + {"code": -32602, "message": "Failed to parse txid"}, + ) + + # Valid txid, but no such transaction was found + assert_equal( + self.client.blockchain.transaction.get(txid=32 * "ff").error, + {"code": -32600, "message": "Unknown transaction id"}, + ) + + def test_transaction_get(self): for response in ( self.client.blockchain.transaction.get(GENESIS_CB_TXID), self.client.blockchain.transaction.get(GENESIS_CB_TXID, False), self.client.blockchain.transaction.get(txid=GENESIS_CB_TXID), self.client.blockchain.transaction.get(txid=GENESIS_CB_TXID, verbose=False), ): assert_equal(response.result, COINBASE_TX_HEX) for response in ( self.client.blockchain.transaction.get(GENESIS_CB_TXID, True), self.client.blockchain.transaction.get(txid=GENESIS_CB_TXID, verbose=True), ): assert_equal( response.result, { "blockhash": GENESIS_BLOCK_HASH, "blocktime": TIME_GENESIS_BLOCK, "confirmations": 1, "hash": GENESIS_CB_TXID, "hex": COINBASE_TX_HEX, "time": 0, }, ) self.generate(self.nodes[0], 2) assert_equal( self.client.blockchain.transaction.get( txid=GENESIS_CB_TXID, verbose=True ).result["confirmations"], 3, ) if __name__ == "__main__": ChronikElectrumBlockchain().main()