bitcoin_rpc_midas/node/
mod.rs

1//! Node module for Bitcoin RPC testing
2//!
3//! This module provides utilities for managing Bitcoin nodes in test environments.
4
5use std::sync::Arc;
6use std::time::{Duration, Instant};
7
8use anyhow::Result;
9use async_trait::async_trait;
10use tempfile::TempDir;
11use tokio::io::AsyncBufReadExt;
12use tokio::process::{Child, Command};
13use tokio::sync::{Mutex, RwLock};
14use tracing::{debug, error, info};
15use std::process::Stdio;
16
17use crate::test_config::TestConfig;
18
19/// Represents the state of a Bitcoin node
20#[derive(Debug, Default, Clone)]
21pub struct NodeState {
22    pub is_running: bool,
23}
24
25/// Configuration for port selection behavior
26#[derive(Debug, Clone)]
27pub enum PortSelection {
28    /// Use the specified port number
29    Fixed(u16),
30    /// Let the OS assign an available port
31    Dynamic,
32    /// Use port 0 (not recommended, may cause bitcoind to fail)
33    Zero,
34}
35
36/// Trait defining the interface for a Bitcoin node manager
37#[async_trait]
38pub trait NodeManager: Send + Sync + std::any::Any + std::fmt::Debug {
39    async fn start(&self) -> Result<()>;
40    async fn stop(&mut self) -> Result<()>;
41    async fn get_state(&self) -> Result<NodeState>;
42    /// Return the RPC port this manager was configured with
43    fn rpc_port(&self) -> u16;
44}
45
46/// Implementation of the Bitcoin node manager
47#[derive(Debug)]
48pub struct BitcoinNodeManager {
49    state: Arc<RwLock<NodeState>>,
50    child: Arc<Mutex<Option<Child>>>,
51    pub rpc_port: u16,
52    config: TestConfig,
53    _datadir: Option<TempDir>,
54}
55
56impl BitcoinNodeManager {
57    pub fn new() -> Result<Self> { Self::new_with_config(&TestConfig::default()) }
58
59    pub fn new_with_config(config: &TestConfig) -> Result<Self> {
60        let datadir = TempDir::new()?;
61
62        // Handle automatic port selection:
63        // When rpc_port is 0, we need to find an available port dynamically.
64        // This is important because:
65        // 1. It allows multiple test instances to run in parallel without port conflicts
66        // 2. It prevents the "Invalid port specified in -rpcport: '0'" error from bitcoind
67        // 3. It makes the code more robust by not requiring manual port selection
68        let rpc_port = if config.rpc_port == 0 {
69            // Bind to port 0 to let the OS assign an available port
70            let listener = std::net::TcpListener::bind(("127.0.0.1", 0))?;
71            let port = listener.local_addr()?.port();
72            drop(listener);
73            port
74        } else {
75            config.rpc_port
76        };
77
78        Ok(Self {
79            state: Arc::new(RwLock::new(NodeState::default())),
80            child: Arc::new(Mutex::new(None)),
81            rpc_port,
82            config: config.clone(),
83            _datadir: Some(datadir),
84        })
85    }
86
87    pub fn rpc_port(&self) -> u16 { self.rpc_port }
88}
89
90#[async_trait]
91impl NodeManager for BitcoinNodeManager {
92    async fn start(&self) -> Result<()> {
93        let mut state = self.state.write().await;
94        if state.is_running {
95            return Ok(());
96        }
97
98        let datadir = self._datadir.as_ref().unwrap().path();
99        let mut cmd = Command::new("bitcoind");
100
101        let chain = format!("-chain={}", self.config.as_chain_str());
102        let data_dir = format!("-datadir={}", datadir.display());
103        let rpc_port = format!("-rpcport={}", self.rpc_port);
104        let rpc_bind = format!("-rpcbind=127.0.0.1:{}", self.rpc_port);
105        let rpc_user = format!("-rpcuser={}", self.config.rpc_username);
106        let rpc_password = format!("-rpcpassword={}", self.config.rpc_password);
107
108        let mut args = vec![
109            &chain,
110            "-listen=0",
111            &data_dir,
112            &rpc_port,
113            &rpc_bind,
114            "-rpcallowip=127.0.0.1",
115            "-fallbackfee=0.0002",
116            "-server=1",
117            "-prune=1",
118            &rpc_user,
119            &rpc_password,
120        ];
121
122        for arg in &self.config.extra_args {
123            args.push(arg);
124        }
125
126        cmd.args(&args);
127
128        // Capture both stdout and stderr for better error reporting
129        cmd.stderr(Stdio::piped());
130        cmd.stdout(Stdio::piped());
131
132        let mut child = cmd.spawn()?;
133
134        // Read stderr in a separate task
135        let stderr = child.stderr.take().unwrap();
136        let stderr_reader = tokio::io::BufReader::new(stderr);
137        tokio::spawn(async move {
138            let mut lines = stderr_reader.lines();
139            while let Ok(Some(line)) = lines.next_line().await {
140                error!("bitcoind stderr: {}", line);
141            }
142        });
143
144        // Read stdout in a separate task
145        let stdout = child.stdout.take().unwrap();
146        let stdout_reader = tokio::io::BufReader::new(stdout);
147        tokio::spawn(async move {
148            let mut lines = stdout_reader.lines();
149            while let Ok(Some(line)) = lines.next_line().await {
150                info!("bitcoind stdout: {}", line);
151            }
152        });
153
154        // Store the child process
155        let mut child_guard = self.child.lock().await;
156        *child_guard = Some(child);
157
158        info!("Waiting for Bitcoin node to initialize...");
159        tokio::time::sleep(Duration::from_millis(150)).await;
160
161        // Wait for node to be ready
162        let deadline = Instant::now() + Duration::from_secs(10);
163        let mut attempts = 0;
164        while Instant::now() < deadline {
165            if let Some(child) = child_guard.as_mut() {
166                if let Ok(Some(status)) = child.try_wait() {
167                    let error = format!("Bitcoin node exited early with status: {}", status);
168                    error!("{}", error);
169                    anyhow::bail!(error);
170                }
171            }
172
173            // Try to connect to RPC
174            let client = reqwest::Client::new();
175            match client
176                .post(format!("http://127.0.0.1:{}/", self.rpc_port))
177                .basic_auth(&self.config.rpc_username, Some(&self.config.rpc_password))
178                .json(&serde_json::json!({
179                    "jsonrpc": "2.0",
180                    "method": "getnetworkinfo",
181                    "params": [],
182                    "id": 1
183                }))
184                .send()
185                .await
186            {
187                Ok(response) =>
188                    if response.status().is_success() {
189                        state.is_running = true;
190                        info!("Bitcoin node started successfully on port {}", self.rpc_port);
191                        return Ok(());
192                    } else {
193                        debug!(
194                            "RPC request failed with status {} (attempt {})",
195                            response.status(),
196                            attempts
197                        );
198                    },
199                Err(e) => {
200                    debug!("Failed to connect to RPC (attempt {}): {}", attempts, e);
201                }
202            }
203
204            attempts += 1;
205            tokio::time::sleep(Duration::from_millis(200)).await;
206        }
207
208        let error = format!(
209            "Timed out waiting for Bitcoin node to start on port {} after {} attempts",
210            self.rpc_port, attempts
211        );
212        error!("{}", error);
213        anyhow::bail!(error);
214    }
215
216    async fn stop(&mut self) -> Result<()> {
217        let mut state = self.state.write().await;
218        if !state.is_running {
219            return Ok(());
220        }
221
222        let child = self.child.lock().await.take();
223        if let Some(mut child) = child {
224            std::mem::drop(child.kill());
225            std::mem::drop(child.wait());
226        }
227
228        state.is_running = false;
229        Ok(())
230    }
231
232    async fn get_state(&self) -> Result<NodeState> { Ok(self.state.read().await.clone()) }
233
234    fn rpc_port(&self) -> u16 { self.rpc_port }
235}
236
237impl Drop for BitcoinNodeManager {
238    fn drop(&mut self) {
239        if let Some(mut child) = self.child.try_lock().ok().and_then(|mut guard| guard.take()) {
240            std::mem::drop(child.kill());
241            std::mem::drop(child.wait());
242        }
243    }
244}
245
246impl Default for BitcoinNodeManager {
247    fn default() -> Self {
248        Self::new_with_config(&TestConfig::default())
249            .expect("Failed to create default BitcoinNodeManager")
250    }
251}
252
253#[cfg(test)]
254mod tests {
255    use super::*;
256
257    #[test]
258    fn test_extra_args() {
259        let config = crate::test_config::TestConfig {
260            extra_args: vec!["-debug=1".to_string()],
261            ..crate::test_config::TestConfig::default()
262        };
263
264        let node_manager = BitcoinNodeManager::new_with_config(&config)
265            .expect("Failed to create node manager with extra args");
266
267        assert_eq!(node_manager.config.extra_args[0], "-debug=1");
268    }
269}