Skip to main content

halfin/
lib.rs

1//! # Halfin
2//!
3//! A bitcoin node running utility for integration testing.
4//!
5//! > A {regtest} bitcoin node runner 🏃‍♂️
6//!
7//! This crate makes it simple to run regtest [`bitcoind`], [`utreexod`],
8//! and [`electrs`] instances from Rust code, useful in integration test contexts.
9//!
10//! ## Supported Implementations
11//!
12//! | Implementation | Version   | Feature Flag     | Default Feature |
13//! |----------------|-----------|----------------- | --------------- |
14//! | `bitcoind`     | `v31.0`   | `bitcoind_31_0`  | Yes             |
15//! |                |           |                  |                 |
16//! | `utreexod`     | `v0.5.2`  | `utreexod_0_5_2` | Yes             |
17//! |                |           |                  |                 |
18//! | `electrs`      | `v0.11.1` | `electrs_0_11_1` | No              |
19//!
20//! ## Example
21//!
22//! ```rust,no_run
23//! use halfin::connect;
24//! use halfin::bitcoind::BitcoinD;
25//! use halfin::utreexod::UtreexoD;
26//!
27//! let bitcoind = BitcoinD::new().unwrap();
28//! bitcoind.generate(10).unwrap();
29//! assert_eq!(bitcoind.get_chain_tip().unwrap(), 10);
30//!
31//! let utreexod = UtreexoD::new().unwrap();
32//! utreexod.generate(10).unwrap();
33//! assert_eq!(utreexod.get_chain_tip().unwrap(), 10);
34//!
35//! connect(&bitcoind, &utreexod).unwrap();
36//! ```
37//!
38//! [`bitcoind`]: <https://github.com/bitcoin/bitcoin>
39//! [`utreexod`]: <https://github.com/utreexo/utreexod>
40//! [`electrs`]: <https://github.com/romanz/electrs>
41
42use core::error;
43use core::fmt;
44use core::net::Ipv4Addr;
45use core::net::SocketAddr;
46use corepc_client::bitcoin::BlockHash;
47use std::io::BufRead;
48use std::io::BufReader;
49use std::io::Read;
50use std::net::TcpListener;
51use std::path::PathBuf;
52use std::thread;
53use std::time::Duration;
54use std::time::Instant;
55use tempfile::TempDir;
56use tracing::debug;
57use tracing::trace;
58
59pub use serde_json;
60
61#[allow(unused)]
62pub(crate) use bitcoind::BitcoinD;
63#[allow(unused)]
64pub(crate) use utreexod::UtreexoD;
65
66pub mod bitcoind;
67pub mod electrsd;
68pub mod utreexod;
69
70/// The IPv4 localhost address.
71const IPV4_LOCALHOST: Ipv4Addr = Ipv4Addr::new(127, 0, 0, 1);
72
73/// The maximum number of attempts at instantiating a [`BitcoinD`]/[`UtreexoD`].
74pub const NODE_BUILDING_MAX_RETRIES: u8 = 5;
75
76/// The [`Duration`] between attempts at instantiating a [`Node`].
77pub const NODE_BUILDING_INTERVAL: Duration = Duration::from_millis(500);
78
79/// The [`Duration`] interval between polls for [`connect`] and [`wait_for_height`].
80pub const POLL_INTERVAL: Duration = Duration::from_millis(100);
81
82/// The timeout [`Duration`] for [`connect`] and [`wait_for_height`].
83pub const WAIT_TIMEOUT: Duration = Duration::from_secs(10);
84
85/// The interval [`Duration`] between successive attempts of node connection.
86pub const CONNECTION_INTERVAL: Duration = Duration::from_millis(150);
87
88/// The timeout [`Duration`] for node connection.
89pub const CONNECTION_TIMEOUT: Duration = Duration::from_secs(5);
90
91/// Common interface across all node implementations ([`BitcoinD`]/[`UtreexoD`]).
92pub trait Node {
93    /// The [`Node`]'s human-readable name.
94    fn get_name() -> &'static str;
95
96    /// The [`Node`]'s binary name.
97    fn get_bin_name() -> &'static str;
98
99    /// Get the [`Node`]'s current chain height.
100    fn get_chain_tip(&self) -> Result<u32, Error>;
101
102    /// Get the [`Node`]'s current CBF height.
103    fn get_filter_tip(&self) -> Result<u32, Error>;
104
105    // Get the [`BlockHash`] of the block at `height`.
106    fn get_block_hash(&self, height: u32) -> Result<BlockHash, Error>;
107
108    /// Call a JSON-RPC `method` with the given `args` list.
109    ///
110    /// Response deserialization is not implemented for this method.
111    ///
112    /// It's up to the caller to parse the returned
113    /// [`Value`](serde_json::Value) into a meaningful type.
114    fn call(&self, method: &str, args: &[serde_json::Value]) -> Result<serde_json::Value, Error>;
115
116    /// Get the [`Node`]'s P2P [`SocketAddr`].
117    fn get_p2p_socket(&self) -> SocketAddr;
118
119    /// Check whether the [`Node`] is connected to a peer with a specific [`SocketAddr`].
120    fn has_peer(&self, socket: SocketAddr) -> Result<bool, Error>;
121
122    /// Connect this [`Node`] to a peer at `socket` over P2P.
123    fn add_peer(&self, socket: SocketAddr) -> Result<(), Error>;
124
125    /// Get this [`Node`]' s peer count.
126    fn get_peer_count(&self) -> Result<u32, Error>;
127
128    /// How long to sleep between `get_height` RPC calls.
129    ///
130    /// Defaults to [`POLL_INTERVAL`].
131    ///
132    /// Override for nodes that need a longer settling time between RPC calls.
133    fn poll_interval() -> Duration {
134        POLL_INTERVAL
135    }
136
137    /// How long `wait_for_height` will poll before giving up.
138    ///
139    /// Defaults to [`WAIT_TIMEOUT`].
140    ///
141    /// Override for nodes that need more time to process blocks
142    /// (e.g. [`UtreexoD`] needs more time to build the Merkle forest).
143    fn wait_timeout() -> Duration {
144        WAIT_TIMEOUT
145    }
146}
147
148/// Connect [`Node`] A to [`Node`] B.
149pub fn connect<A: Node, B: Node>(a: &A, b: &B) -> Result<(), Error> {
150    let socket_a = a.get_p2p_socket();
151    let socket_b = b.get_p2p_socket();
152
153    debug!("Connecting socket={} to socket={}", socket_a, socket_b);
154
155    a.add_peer(socket_b)?;
156
157    let is_connected =
158        || -> Result<bool, Error> { Ok(a.has_peer(socket_b)? || b.has_peer(socket_a)?) };
159
160    // Wait for either side to confirm the connection by listening port.
161    // We check both because `utreexod` does not expose the peer's listening
162    // port in `getpeerinfo` for inbound connections, so only one side may
163    // be able to verify by socket address.
164    let start = Instant::now();
165    while start.elapsed() < CONNECTION_TIMEOUT {
166        if is_connected()? {
167            // Allow time for v2 transport negotiation to settle,
168            // or for v1 fallback to complete if v2 fails, then re-verify.
169            thread::sleep(CONNECTION_INTERVAL * 4);
170            if is_connected()? {
171                debug!("Connected socket={} to socket={}", socket_a, socket_b);
172                return Ok(());
173            }
174        }
175        thread::sleep(CONNECTION_INTERVAL);
176    }
177
178    Err(Error::ConnectionTimeout(CONNECTION_TIMEOUT))
179}
180
181/// Connect [`Node`] A to [`Node`] B and wait for them to synchronize chains.
182pub fn connect_and_sync<A: Node, B: Node>(a: &A, b: &B) -> Result<(), Error> {
183    connect(a, b)?;
184
185    let height_a = a.get_chain_tip()?;
186    let height_b = b.get_chain_tip()?;
187
188    let max_height = std::cmp::max(height_a, height_b);
189    wait_for_height(a, max_height)?;
190    wait_for_height(b, max_height)?;
191
192    Ok(())
193}
194
195/// Poll a [`Node`] until its chain reaches `height`.
196///
197/// Throws an error if the node does not reach `height` within [`Node::wait_timeout`].
198pub fn wait_for_height<N: Node>(node: &N, height: u32) -> Result<(), Error> {
199    debug!("Waiting for {} to reach height={}", N::get_name(), height);
200
201    let start = Instant::now();
202    while start.elapsed() < N::wait_timeout() {
203        if node.get_chain_tip().unwrap_or(0) >= height {
204            return Ok(());
205        }
206        thread::sleep(N::poll_interval());
207    }
208
209    let curr_height = node.get_chain_tip().unwrap_or(0);
210    Err(Error::ChainSyncTimeOut((
211        height,
212        curr_height,
213        N::wait_timeout(),
214    )))
215}
216
217/// Poll a [`Node`] until its chain reaches `height` with a custom `timeout`.
218///
219/// Throws an error if the node does not reach `height` within `timeout`.
220pub fn wait_for_height_with_timeout<N: Node>(
221    node: &N,
222    height: u32,
223    timeout: Duration,
224) -> Result<(), Error> {
225    debug!(
226        "Waiting for {} to reach height={} (timeout={:?})",
227        N::get_name(),
228        height,
229        timeout
230    );
231
232    let start = Instant::now();
233    while start.elapsed() < timeout {
234        if node.get_chain_tip().unwrap_or(0) >= height {
235            return Ok(());
236        }
237        thread::sleep(N::poll_interval());
238    }
239
240    let curr_height = node.get_chain_tip().unwrap_or(0);
241    Err(Error::ChainSyncTimeOut((height, curr_height, timeout)))
242}
243
244/// Poll a [`Node`] until its Compact Block Filters reach `height`.
245///
246/// Throws an error if the node does not reach `filter_height` within [`Node::wait_timeout`].
247pub fn wait_for_filter_height<N: Node>(node: &N, filter_height: u32) -> Result<(), Error> {
248    debug!(
249        "Waiting for {} to reach filter height={}",
250        N::get_name(),
251        filter_height
252    );
253
254    let start = Instant::now();
255    while start.elapsed() < N::wait_timeout() {
256        if node.get_filter_tip().unwrap_or(0) >= filter_height {
257            return Ok(());
258        }
259        thread::sleep(N::poll_interval());
260    }
261
262    let curr_filter_height = node.get_filter_tip().unwrap_or(0);
263    Err(Error::ChainSyncTimeOut((
264        filter_height,
265        curr_filter_height,
266        N::wait_timeout(),
267    )))
268}
269
270/// Spawn a background thread that reads `reader` line by line and re-emits
271/// each line as a [`trace!`] event, prefixed with `source`.
272///
273/// Used to pipe a child [`BitcoinD`]/[`UtreexoD`] process's `stdout`/`stderr`
274/// into [`tracing`]. The thread exits on EOF, which happens when the process
275/// dies and its pipe is closed.
276pub(crate) fn pipe_to_tracing<R: Read + Send + 'static>(reader: R, source: &'static str) {
277    thread::spawn(move || {
278        let mut lines = BufReader::new(reader).lines();
279        while let Some(Ok(line)) = lines.next() {
280            // Skip blank lines so the trace stream mirrors the node's output.
281            if !line.trim().is_empty() {
282                trace!("{source}: {line}");
283            }
284        }
285    });
286}
287
288/// Ask the OS for an available port, immediately unbind and return it.
289///
290/// Inlining is needed to curb TOCTOU race conditions.
291#[inline]
292pub fn get_available_port() -> u16 {
293    TcpListener::bind((IPV4_LOCALHOST, 0))
294        .unwrap()
295        .local_addr()
296        .unwrap()
297        .port()
298}
299
300/// Owns a node's working directory, either as a temporary or a persistent path.
301///
302/// * [`DataDir::Temporary`]: backed by a [`TempDir`]; the directory is
303///   deleted automatically when this value is dropped.
304/// * [`DataDir::Persistent`]: backed by a plain [`PathBuf`]; the directory
305///   survives the process and is never cleaned up automatically.
306#[derive(Debug)]
307pub enum DataDir {
308    /// A persistent directory that is **not** cleaned up on drop.
309    Persistent(PathBuf),
310    /// A temporary directory that is deleted when this value is dropped.
311    Temporary(TempDir),
312}
313
314impl DataDir {
315    /// Return the underlying filesystem path regardless of variant.
316    pub fn path(&self) -> PathBuf {
317        match self {
318            Self::Persistent(path) => path.to_owned(),
319            Self::Temporary(tmp_dir) => tmp_dir.path().to_path_buf(),
320        }
321    }
322}
323
324#[derive(Debug)]
325pub enum Error {
326    /// The binary path is not absolute.
327    BinaryPathNotAbsolute { bin_name: String, path: String },
328
329    /// The binary path is not a file.
330    BinaryPathNotFile { bin_name: String, path: String },
331
332    /// The binary was not found at the expected location.
333    BinaryNotFound((String, PathBuf)),
334
335    /// Failed to spawn a [process](std::process::Child) for a [`Node`] or Electrum Server.
336    FailedToSpawn(std::io::Error),
337
338    /// Failed to instantiate a node or indexer after [`NODE_BUILDING_MAX_RETRIES`] attempts.
339    ExhaustedNodeBuildingRetries(u8),
340
341    /// Failed to stop [`BitcoinD`] or [`UtreexoD`] over JSON-RPC (e.g. `bitcoin-cli -regtest stop`).
342    FailedToStop(corepc_client::client_sync::Error),
343
344    /// I/O errors.
345    Io(std::io::Error),
346
347    /// JSON-RPC Errors.
348    JsonRpc(corepc_client::client_sync::Error),
349
350    /// Timed out whilst waiting for peer connection to succeed.
351    PeerConnectionTimeout((SocketAddr, SocketAddr)),
352
353    /// Both `tmpdir` and `workdir` were specified.
354    BothDirsSpecified,
355
356    /// [`BitcoinD`] is unresponsive (it's probably not running).
357    UnresponsiveBitcoinD(corepc_client::client_sync::Error),
358
359    /// [`UtreexoD`] is unresponsive (it's probably not running).
360    UnresponsiveUtreexoD(corepc_client::client_sync::Error),
361
362    /// [`electrsd::ElectrsD`] is unresponsive (it's probably not running).
363    UnresponsiveElectrsD(electrum_client::Error),
364
365    /// Timed out whilst waiting for [`electrsd::ElectrsD`] to index expected data.
366    ElectrsDIndexTimeout((String, Duration)),
367
368    /// Timed out whilst waiting for the cookie file to be generated.
369    CookieFileTimeout(PathBuf),
370
371    /// Timed out whilst waiting for the JSON-RPC client to be ready.
372    RpcClientSetupTimeout,
373
374    /// Received an unexpected response from the JSON-RPC server
375    UnexpectedResponse(String),
376
377    /// Timed out whilst waiting for the [`Node`]'s chain to synchronize up to `height`
378    ChainSyncTimeOut((u32, u32, Duration)), // (current_height, target_height, timeout)
379
380    /// Timed out whilst waiting for the [`Node`]'s to connect to each other.
381    ConnectionTimeout(Duration),
382}
383
384#[rustfmt::skip]
385impl fmt::Display for Error {
386    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
387        use Error::*;
388
389        match self {
390            BinaryPathNotAbsolute { bin_name, path } => write!(f, "The `{}` binary path is not absolute (path={})", bin_name, path),
391            BinaryPathNotFile { bin_name, path } => write!(f, "The `{}` binary path is not a file (path={})", bin_name, path),
392            BinaryNotFound((bin_name, path)) => write!(f, "The `{}` binary was not found at the expected location={}", bin_name, path.display()),
393            FailedToSpawn(err) => write!(f, "Failed to spawn a process for the node: {err:?}"),
394            ExhaustedNodeBuildingRetries(retries) => write!(f, "Failed to instantiate the node after {} attempts", retries),
395            FailedToStop(err) => write!(f, "Failed to stop the node over JSON-RPC: {err:?}"),
396            Io(err) => write!(f, "I/O Error: {err:?}"),
397            JsonRpc(err) => write!(f, "JSON-RPC Error: {err:?}"),
398            PeerConnectionTimeout((local_socket, remote_socket)) => write!(f, "Timed out whilst waiting for connection between local={local_socket} and remote={remote_socket}"),
399            BothDirsSpecified => write!(f, "Both `tempdir` and `workdir` were specified. You must choose one and only one"),
400            UnresponsiveBitcoinD(err) => write!(f, "`BitcoinD` is unresponsive to JSON-RPC calls: {err:?}"),
401            UnresponsiveUtreexoD(err) => write!(f, "`UtreexoD` is unresponsive to JSON-RPC calls: {err:?}"),
402            UnresponsiveElectrsD(err) => write!(f, "`ElectrsD` is unresponsive to Electrum requests: {err:?}"),
403            ElectrsDIndexTimeout((description, timeout)) => write!(f, "Timed out after {} seconds whilst waiting for `ElectrsD` to index {description}", timeout.as_secs()),
404            CookieFileTimeout(cookie_path) => write!(f, "Timed out whilst waiting for the cookie={} to be generated", cookie_path.display()),
405            RpcClientSetupTimeout => write!(f, "Timed out whilst waiting for the JSON-RPC client to be ready"),
406            UnexpectedResponse(err) => write!(f, "Received an unexpected response from the JSON-RPC server: {err:?}"),
407            ChainSyncTimeOut((target_height, current_height, timeout)) => write!(
408                f,
409                "Timed out after {} seconds whilst waiting for the node's chain to synchronize to height={} (current height={})",
410                target_height, current_height, timeout.as_secs()
411            ),
412            ConnectionTimeout(timeout) => write!(
413                f,
414                "Timed out after {} seconds whilst waiting for the nodes to connect to each other",
415                timeout.as_secs()
416            ),
417        }
418    }
419}
420
421impl error::Error for Error {}