diff --git a/chronik/chronik-http/src/electrum.rs b/chronik/chronik-http/src/electrum.rs --- a/chronik/chronik-http/src/electrum.rs +++ b/chronik/chronik-http/src/electrum.rs @@ -4,9 +4,14 @@ //! 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}; @@ -15,7 +20,7 @@ pem::PemObject, {CertificateDer, PrivateKeyDer}, }; -use serde_json::Value; +use serde_json::{json, Value}; use thiserror::Error; use crate::{ @@ -52,8 +57,8 @@ #[derive(Debug)] pub struct ChronikElectrumServer { hosts: Vec<(SocketAddr, ChronikElectrumProtocol)>, - _indexer: ChronikIndexerRef, - _node: NodeRef, + indexer: ChronikIndexerRef, + node: NodeRef, tls_cert_path: String, tls_privkey_path: String, } @@ -108,14 +113,18 @@ 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, - // FIXME below params are unused but will be used in the future - _indexer: params.indexer, - _node: params.node, + indexer: params.indexer, + node: params.node, tls_cert_path: params.tls_cert_path, tls_privkey_path: params.tls_privkey_path, }) @@ -126,6 +135,11 @@ // 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(); @@ -133,6 +147,7 @@ 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) ) @@ -140,6 +155,7 @@ |( (host, protocol), server_endpoint, + blockchain_endpoint, tls_cert_path, tls_privkey_path, )| { @@ -208,6 +224,7 @@ let server = builder .service(server_endpoint) + .service(blockchain_endpoint) .build() .await .map_err(|err| ServingFailed(err.to_string()))?; @@ -247,3 +264,114 @@ 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, + } + } +} + +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 = 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 new file mode 100644 --- /dev/null +++ b/test/functional/chronik_electrum_blockchain.py @@ -0,0 +1,71 @@ +# 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() + + 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()