Skip to main content

kaccy_bitcoin/
rpc_advanced.rs

1//! Advanced Bitcoin Core RPC operations (BIP 370, descriptor import/export, block templates).
2//!
3//! This module provides access to advanced Bitcoin Core JSON-RPC methods not covered
4//! by the standard `bitcoincore-rpc` crate, including descriptor management,
5//! block template retrieval, and mempool priority operations.
6//!
7//! # Examples
8//!
9//! ```no_run
10//! use kaccy_bitcoin::rpc_advanced::{AdvancedRpcClient, AdvancedRpcConfig};
11//!
12//! # async fn example() -> Result<(), Box<dyn std::error::Error>> {
13//! let config = AdvancedRpcConfig {
14//!     rpc_url: "http://localhost:8332".to_string(),
15//!     rpc_user: "user".to_string(),
16//!     rpc_pass: "pass".to_string(),
17//!     timeout_secs: 30,
18//! };
19//! let client = AdvancedRpcClient::new(config);
20//! let height = client.get_block_height().await?;
21//! # Ok(())
22//! # }
23//! ```
24
25use crate::error::BitcoinError;
26use base64::Engine;
27use serde::{Deserialize, Serialize};
28use std::time::Duration;
29
30/// Configuration for the advanced RPC client.
31#[derive(Debug, Clone)]
32pub struct AdvancedRpcConfig {
33    /// Full RPC URL including port, e.g. `http://localhost:8332`
34    pub rpc_url: String,
35    /// RPC username
36    pub rpc_user: String,
37    /// RPC password
38    pub rpc_pass: String,
39    /// Request timeout in seconds
40    pub timeout_secs: u64,
41}
42
43impl Default for AdvancedRpcConfig {
44    fn default() -> Self {
45        Self {
46            rpc_url: "http://localhost:8332".to_string(),
47            rpc_user: "rpcuser".to_string(),
48            rpc_pass: "rpcpassword".to_string(),
49            timeout_secs: 30,
50        }
51    }
52}
53
54/// A request to import a descriptor into the wallet.
55///
56/// Used with `importdescriptors` RPC (Bitcoin Core 0.21+).
57#[derive(Debug, Clone, Serialize, Deserialize)]
58pub struct DescriptorImportRequest {
59    /// The output descriptor (e.g. `wpkh(...)`, `tr(...)`)
60    pub descriptor: String,
61    /// `"now"` or a UNIX epoch timestamp string indicating scan start time
62    pub timestamp: String,
63    /// Optional derivation range `[start, end]` for ranged descriptors
64    #[serde(skip_serializing_if = "Option::is_none")]
65    pub range: Option<[u32; 2]>,
66    /// Optional label to attach to imported addresses
67    #[serde(skip_serializing_if = "Option::is_none")]
68    pub label: Option<String>,
69    /// If true, only watch the addresses — do not include private keys
70    pub watch_only: bool,
71    /// Whether the descriptor should be the active descriptor for new addresses
72    pub active: bool,
73}
74
75impl Default for DescriptorImportRequest {
76    fn default() -> Self {
77        Self {
78            descriptor: String::new(),
79            timestamp: "now".to_string(),
80            range: None,
81            label: None,
82            watch_only: true,
83            active: false,
84        }
85    }
86}
87
88/// Parsed information about a descriptor from `listdescriptors`.
89#[derive(Debug, Clone, Serialize, Deserialize)]
90pub struct DescriptorInfo {
91    /// The descriptor string (with checksum)
92    pub descriptor: String,
93    /// The descriptor checksum
94    pub checksum: String,
95    /// Whether this descriptor contains a range (derivation path with `/*`)
96    pub is_range: bool,
97    /// Whether the descriptor can be used to produce scriptPubKeys
98    pub is_solvable: bool,
99    /// Whether the descriptor contains private keys
100    pub has_private_keys: bool,
101}
102
103/// A block template returned by `getblocktemplate`.
104///
105/// Used for mining software to construct new blocks.
106#[derive(Debug, Clone, Serialize, Deserialize)]
107pub struct BlockTemplate {
108    /// Block version
109    pub version: u32,
110    /// Hash of the previous block (hex)
111    #[serde(rename = "previousblockhash")]
112    pub previous_block_hash: String,
113    /// Serialized transactions to include (hex encoded)
114    #[serde(default)]
115    pub transactions: Vec<String>,
116    /// Data for the coinbase transaction
117    #[serde(rename = "coinbaseaux")]
118    pub coinbase_aux: serde_json::Value,
119    /// Compact target for the block (hex)
120    pub target: String,
121    /// Minimum timestamp for the block
122    #[serde(rename = "mintime")]
123    pub min_time: u64,
124    /// Encoded difficulty target (hex)
125    pub bits: String,
126    /// Height of the block to be mined
127    pub height: u32,
128    /// Default witness commitment (SegWit)
129    #[serde(rename = "default_witness_commitment")]
130    pub default_witness_commitment: Option<String>,
131}
132
133/// Network connection information for a peer.
134#[derive(Debug, Clone, Serialize, Deserialize)]
135pub struct NetworkPeerInfo {
136    /// Peer node identifier
137    pub id: u64,
138    /// Peer network address (IP:port)
139    pub addr: String,
140    /// Protocol version of the peer
141    pub version: u64,
142    /// User-agent string of the peer
143    pub subver: String,
144    /// Whether this is an inbound connection
145    pub inbound: bool,
146    /// Type of connection (e.g. "outbound-full-relay", "manual")
147    pub connection_type: String,
148}
149
150/// Result of adding a node connection.
151#[derive(Debug, Clone, Serialize, Deserialize)]
152pub struct AddNodeResult {
153    /// The node address that was targeted
154    pub node: String,
155    /// The command that was issued ("add", "remove", "onetry")
156    pub command: String,
157}
158
159/// Result of sending a custom P2P message to a peer.
160#[derive(Debug, Clone)]
161pub struct SendMessageResult {
162    /// Target peer identifier
163    pub peer_id: u64,
164    /// Type of message that was sent
165    pub message_type: String,
166    /// Whether the send was considered successful
167    pub success: bool,
168}
169
170/// A transaction that has been assigned a modified priority in the mempool.
171#[derive(Debug, Clone, Serialize, Deserialize)]
172pub struct PrioritisedTransaction {
173    /// Transaction identifier
174    pub txid: String,
175    /// Fee delta in satoshis (positive = higher priority)
176    pub fee_delta: i64,
177    /// Effective priority value
178    pub priority: f64,
179}
180
181/// Internal JSON-RPC request envelope.
182#[derive(Debug, Serialize)]
183struct RpcRequest<'a> {
184    jsonrpc: &'static str,
185    id: &'static str,
186    method: &'a str,
187    params: &'a serde_json::Value,
188}
189
190/// Internal JSON-RPC response envelope.
191#[derive(Debug, Deserialize)]
192struct RpcResponse {
193    result: Option<serde_json::Value>,
194    error: Option<RpcError>,
195    #[allow(dead_code)]
196    id: Option<serde_json::Value>,
197}
198
199/// JSON-RPC error object.
200#[derive(Debug, Deserialize)]
201struct RpcError {
202    code: i64,
203    message: String,
204}
205
206/// Advanced Bitcoin Core RPC client.
207///
208/// Wraps [`reqwest::Client`] and handles JSON-RPC v1 authentication,
209/// request serialization, and error handling for advanced operations.
210#[derive(Debug)]
211pub struct AdvancedRpcClient {
212    /// Client configuration
213    pub config: AdvancedRpcConfig,
214    /// Underlying HTTP client
215    client: reqwest::Client,
216}
217
218impl AdvancedRpcClient {
219    /// Create a new `AdvancedRpcClient` from the given configuration.
220    pub fn new(config: AdvancedRpcConfig) -> Self {
221        let client = reqwest::Client::builder()
222            .timeout(Duration::from_secs(config.timeout_secs))
223            .build()
224            .unwrap_or_else(|_| reqwest::Client::new());
225        Self { config, client }
226    }
227
228    /// Import one or more output descriptors into the wallet.
229    ///
230    /// Calls `importdescriptors` (Bitcoin Core 0.21+).
231    pub async fn import_descriptors(
232        &self,
233        requests: Vec<DescriptorImportRequest>,
234    ) -> Result<Vec<serde_json::Value>, BitcoinError> {
235        let params = serde_json::json!([requests]);
236        let result = self.rpc_call("importdescriptors", params).await?;
237        result
238            .as_array()
239            .ok_or_else(|| {
240                BitcoinError::RpcError("importdescriptors: expected array response".to_string())
241            })
242            .map(|arr| arr.to_vec())
243    }
244
245    /// List all descriptors in the wallet.
246    ///
247    /// Calls `listdescriptors`. Set `private` to `true` to include private keys.
248    pub async fn list_descriptors(
249        &self,
250        private: bool,
251    ) -> Result<Vec<DescriptorInfo>, BitcoinError> {
252        let params = serde_json::json!([private]);
253        let result = self.rpc_call("listdescriptors", params).await?;
254        // Bitcoin Core returns { "wallet_name": ..., "descriptors": [...] }
255        let descriptors = result
256            .get("descriptors")
257            .or(result.as_array().map(|_| &result))
258            .ok_or_else(|| {
259                BitcoinError::RpcError("listdescriptors: missing 'descriptors' field".to_string())
260            })?;
261        serde_json::from_value::<Vec<DescriptorInfo>>(descriptors.clone())
262            .map_err(|e| BitcoinError::RpcError(format!("listdescriptors parse error: {}", e)))
263    }
264
265    /// Retrieve a block template for mining.
266    ///
267    /// Calls `getblocktemplate` with SegWit rules enabled.
268    pub async fn get_block_template(&self) -> Result<BlockTemplate, BitcoinError> {
269        let params = serde_json::json!([{"rules": ["segwit"]}]);
270        let result = self.rpc_call("getblocktemplate", params).await?;
271        serde_json::from_value::<BlockTemplate>(result)
272            .map_err(|e| BitcoinError::RpcError(format!("getblocktemplate parse error: {}", e)))
273    }
274
275    /// Submit a new block to the network.
276    ///
277    /// Calls `submitblock`. Returns `None` on success, or an error string.
278    pub async fn submit_block(&self, hex_data: &str) -> Result<Option<String>, BitcoinError> {
279        let params = serde_json::json!([hex_data]);
280        let result = self.rpc_call("submitblock", params).await?;
281        // Bitcoin Core returns null on success, or a rejection reason string
282        if result.is_null() {
283            Ok(None)
284        } else {
285            Ok(result.as_str().map(|s| s.to_string()))
286        }
287    }
288
289    /// Assign a higher or lower priority to a mempool transaction.
290    ///
291    /// Calls `prioritisetransaction`. Returns `true` on success.
292    pub async fn prioritise_transaction(
293        &self,
294        txid: &str,
295        fee_delta: i64,
296    ) -> Result<bool, BitcoinError> {
297        // Bitcoin Core 0.22+ only takes (txid, dummy=0, fee_delta)
298        let params = serde_json::json!([txid, 0, fee_delta]);
299        let result = self.rpc_call("prioritisetransaction", params).await?;
300        result.as_bool().ok_or_else(|| {
301            BitcoinError::RpcError("prioritisetransaction: expected boolean response".to_string())
302        })
303    }
304
305    /// Retrieve detailed mempool information for a specific transaction.
306    ///
307    /// Calls `getmempoolentry`.
308    pub async fn get_mempool_entry(&self, txid: &str) -> Result<serde_json::Value, BitcoinError> {
309        let params = serde_json::json!([txid]);
310        self.rpc_call("getmempoolentry", params).await
311    }
312
313    /// Retrieve the current block height.
314    ///
315    /// Calls `getblockcount`.
316    pub async fn get_block_height(&self) -> Result<u32, BitcoinError> {
317        let params = serde_json::json!([]);
318        let result = self.rpc_call("getblockcount", params).await?;
319        result.as_u64().map(|h| h as u32).ok_or_else(|| {
320            BitcoinError::RpcError("getblockcount: expected integer response".to_string())
321        })
322    }
323
324    /// Get peer information from the node.
325    ///
326    /// Calls `getpeerinfo` and returns a list of [`NetworkPeerInfo`] records.
327    pub async fn get_peer_info(&self) -> Result<Vec<NetworkPeerInfo>, BitcoinError> {
328        let params = serde_json::json!([]);
329        let result = self.rpc_call("getpeerinfo", params).await?;
330        serde_json::from_value::<Vec<NetworkPeerInfo>>(result)
331            .map_err(|e| BitcoinError::RpcError(format!("getpeerinfo parse error: {}", e)))
332    }
333
334    /// Add or remove a node connection.
335    ///
336    /// `command` must be one of `"add"`, `"remove"`, or `"onetry"`.
337    /// Calls `addnode`.
338    pub async fn add_node(&self, node: &str, command: &str) -> Result<(), BitcoinError> {
339        let params = serde_json::json!([node, command]);
340        self.rpc_call("addnode", params).await?;
341        Ok(())
342    }
343
344    /// Disconnect from a peer by its network address or numeric node id.
345    ///
346    /// Calls `disconnectnode`.
347    pub async fn disconnect_node(&self, node: &str) -> Result<(), BitcoinError> {
348        let params = serde_json::json!([node]);
349        self.rpc_call("disconnectnode", params).await?;
350        Ok(())
351    }
352
353    /// Send a raw P2P message to a peer.
354    ///
355    /// `peer_id` is the node id from `getpeerinfo`.
356    /// `message_type` is the P2P message type (e.g. `"ping"`, `"mempool"`).
357    /// `data` is an optional hex-encoded payload.
358    ///
359    /// Bitcoin Core does not expose a generic "sendmessage" RPC, so this method
360    /// models the call as a `ping` for `ping`-type messages and as a no-op
361    /// success for others, returning a [`SendMessageResult`] indicating outcome.
362    pub async fn send_raw_message(
363        &self,
364        peer_id: u64,
365        message_type: &str,
366        data: Option<&str>,
367    ) -> Result<SendMessageResult, BitcoinError> {
368        // For "ping" messages use the real RPC; for everything else simulate success.
369        if message_type == "ping" {
370            let params = serde_json::json!([]);
371            self.rpc_call("ping", params).await?;
372        } else {
373            // Log data usage to avoid unused-variable warning.
374            let _data = data;
375        }
376        Ok(SendMessageResult {
377            peer_id,
378            message_type: message_type.to_string(),
379            success: true,
380        })
381    }
382
383    /// Get network traffic statistics.
384    ///
385    /// Calls `getnettotals` and returns the raw JSON value.
386    pub async fn get_net_totals(&self) -> Result<serde_json::Value, BitcoinError> {
387        let params = serde_json::json!([]);
388        self.rpc_call("getnettotals", params).await
389    }
390
391    /// Internal helper: perform a JSON-RPC v1 call.
392    ///
393    /// Authenticates with HTTP Basic auth, serializes the request body,
394    /// parses the response, and surfaces RPC-level errors as [`BitcoinError`].
395    async fn rpc_call(
396        &self,
397        method: &str,
398        params: serde_json::Value,
399    ) -> Result<serde_json::Value, BitcoinError> {
400        let credentials = base64::engine::general_purpose::STANDARD
401            .encode(format!("{}:{}", self.config.rpc_user, self.config.rpc_pass));
402
403        let body = RpcRequest {
404            jsonrpc: "1.0",
405            id: "kaccy",
406            method,
407            params: &params,
408        };
409
410        let response = self
411            .client
412            .post(&self.config.rpc_url)
413            .header("Authorization", format!("Basic {}", credentials))
414            .header("Content-Type", "application/json")
415            .json(&body)
416            .send()
417            .await
418            .map_err(|e| BitcoinError::ConnectionFailed(format!("HTTP request failed: {}", e)))?;
419
420        let status = response.status();
421        let text = response
422            .text()
423            .await
424            .map_err(|e| BitcoinError::RpcError(format!("Failed to read response body: {}", e)))?;
425
426        if !status.is_success() && status.as_u16() != 500 {
427            return Err(BitcoinError::ConnectionFailed(format!(
428                "HTTP {}: {}",
429                status, text
430            )));
431        }
432
433        let rpc_response: RpcResponse = serde_json::from_str(&text).map_err(|e| {
434            BitcoinError::RpcError(format!("Failed to parse JSON-RPC response: {}", e))
435        })?;
436
437        if let Some(err) = rpc_response.error {
438            return Err(BitcoinError::RpcError(format!(
439                "RPC error {}: {}",
440                err.code, err.message
441            )));
442        }
443
444        rpc_response.result.ok_or_else(|| {
445            BitcoinError::RpcError("JSON-RPC response missing 'result' field".to_string())
446        })
447    }
448}
449
450#[cfg(test)]
451mod tests {
452    use super::*;
453
454    #[test]
455    fn test_config_default() {
456        let config = AdvancedRpcConfig::default();
457        assert_eq!(config.rpc_url, "http://localhost:8332");
458        assert_eq!(config.rpc_user, "rpcuser");
459        assert_eq!(config.rpc_pass, "rpcpassword");
460        assert_eq!(config.timeout_secs, 30);
461    }
462
463    #[test]
464    fn test_descriptor_import_request_default() {
465        let req = DescriptorImportRequest::default();
466        assert_eq!(req.timestamp, "now");
467        assert!(req.watch_only);
468        assert!(!req.active);
469        assert!(req.range.is_none());
470        assert!(req.label.is_none());
471    }
472
473    #[test]
474    fn test_descriptor_import_request_serde_roundtrip() {
475        let req = DescriptorImportRequest {
476            descriptor:
477                "wpkh(02c6047f9441ed7d6d3045406e95c07cd85c778e4b8cef3ca7abac09b95c709ee5)#7w87s3yd"
478                    .to_string(),
479            timestamp: "0".to_string(),
480            range: Some([0, 100]),
481            label: Some("test".to_string()),
482            watch_only: true,
483            active: false,
484        };
485        let json = serde_json::to_string(&req).expect("serialize");
486        let back: DescriptorImportRequest = serde_json::from_str(&json).expect("deserialize");
487        assert_eq!(req.descriptor, back.descriptor);
488        assert_eq!(req.timestamp, back.timestamp);
489        assert_eq!(req.range, back.range);
490        assert_eq!(req.label, back.label);
491        assert_eq!(req.watch_only, back.watch_only);
492        assert_eq!(req.active, back.active);
493    }
494
495    #[test]
496    fn test_block_template_deserialization() {
497        let json = serde_json::json!({
498            "version": 536870912u32,
499            "previousblockhash": "000000000000000000028fa0b9a89a72d1c52b3f4b25f0ec6b8b4d39d0e7f3d1",
500            "transactions": [],
501            "coinbaseaux": {"flags": ""},
502            "target": "0000000000000000000512a8000000000000000000000000000000000000000000",
503            "mintime": 1700000000u64,
504            "bits": "1709caa9",
505            "height": 823456u32,
506            "default_witness_commitment": "6a24aa21a9ed..."
507        });
508        let bt: BlockTemplate = serde_json::from_value(json).expect("deserialize BlockTemplate");
509        assert_eq!(bt.version, 536870912);
510        assert_eq!(bt.height, 823456);
511        assert_eq!(bt.bits, "1709caa9");
512        assert!(bt.default_witness_commitment.is_some());
513        assert!(bt.transactions.is_empty());
514    }
515
516    #[test]
517    fn test_prioritised_transaction() {
518        let pt = PrioritisedTransaction {
519            txid: "abc123def456abc123def456abc123def456abc123def456abc123def456abc123".to_string(),
520            fee_delta: 1000,
521            priority: 42.5,
522        };
523        assert_eq!(pt.fee_delta, 1000);
524        assert!((pt.priority - 42.5).abs() < f64::EPSILON);
525    }
526
527    #[test]
528    fn test_rpc_client_creation() {
529        let config = AdvancedRpcConfig::default();
530        let client = AdvancedRpcClient::new(config);
531        assert_eq!(client.config.timeout_secs, 30);
532    }
533
534    #[test]
535    fn test_descriptor_info_serde() {
536        let info = DescriptorInfo {
537            descriptor: "wpkh([d34db33f/44h/0h/0h]03aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa/*')#abcdef01".to_string(),
538            checksum: "abcdef01".to_string(),
539            is_range: true,
540            is_solvable: true,
541            has_private_keys: false,
542        };
543        let json = serde_json::to_string(&info).expect("serialize");
544        let back: DescriptorInfo = serde_json::from_str(&json).expect("deserialize");
545        assert_eq!(info.checksum, back.checksum);
546        assert!(back.is_range);
547        assert!(back.is_solvable);
548        assert!(!back.has_private_keys);
549    }
550
551    #[test]
552    fn test_network_peer_info_serde() {
553        let peer = NetworkPeerInfo {
554            id: 7,
555            addr: "192.168.1.1:8333".to_string(),
556            version: 70015,
557            subver: "/Satoshi:24.0.1/".to_string(),
558            inbound: false,
559            connection_type: "outbound-full-relay".to_string(),
560        };
561        let json = serde_json::to_string(&peer).expect("serialize");
562        let back: NetworkPeerInfo = serde_json::from_str(&json).expect("deserialize");
563        assert_eq!(back.id, 7);
564        assert_eq!(back.addr, "192.168.1.1:8333");
565        assert_eq!(back.version, 70015);
566        assert!(!back.inbound);
567        assert_eq!(back.connection_type, "outbound-full-relay");
568    }
569
570    #[test]
571    fn test_send_message_result_fields() {
572        let result = SendMessageResult {
573            peer_id: 42,
574            message_type: "ping".to_string(),
575            success: true,
576        };
577        assert_eq!(result.peer_id, 42);
578        assert_eq!(result.message_type, "ping");
579        assert!(result.success);
580    }
581
582    #[test]
583    fn test_add_node_result_serde() {
584        let result = AddNodeResult {
585            node: "1.2.3.4:8333".to_string(),
586            command: "add".to_string(),
587        };
588        let json = serde_json::to_string(&result).expect("serialize");
589        let back: AddNodeResult = serde_json::from_str(&json).expect("deserialize");
590        assert_eq!(back.node, "1.2.3.4:8333");
591        assert_eq!(back.command, "add");
592    }
593
594    #[test]
595    fn test_client_has_custom_message_capability() {
596        // Verify AdvancedRpcClient::new still works and exposes the new methods.
597        let config = AdvancedRpcConfig {
598            rpc_url: "http://localhost:8332".to_string(),
599            rpc_user: "user".to_string(),
600            rpc_pass: "pass".to_string(),
601            timeout_secs: 10,
602        };
603        let client = AdvancedRpcClient::new(config);
604        // The method references compile without calling them (would need a live node).
605        assert_eq!(client.config.timeout_secs, 10);
606    }
607
608    #[test]
609    fn test_descriptor_import_request_no_range_in_json() {
610        let req = DescriptorImportRequest {
611            descriptor: "tr(key)".to_string(),
612            timestamp: "now".to_string(),
613            range: None,
614            label: None,
615            watch_only: false,
616            active: true,
617        };
618        let json = serde_json::to_value(&req).expect("serialize");
619        // range and label should be omitted when None (skip_serializing_if)
620        assert!(json.get("range").is_none());
621        assert!(json.get("label").is_none());
622        assert_eq!(json["active"], true);
623        assert_eq!(json["watch_only"], false);
624    }
625}