Skip to main content

halfin/
lib.rs

1//! # Halfin
2//!
3//! A bitcoin node running utility for integration testing.
4//!
5//! ## Supported Implementations and Versions
6//!
7//! | Implementation | Version  | Feature Flag     |
8//! |----------------|----------|----------------- |
9//! | [`bitcoind`]   | `v30.2`  | `bitcoind_30_2`  |
10//! | [`utreexod`]   | `v0.5.0` | `utreexod_0_5_0` |
11//!
12//! ## Example
13//!
14//! ```rust,no_run
15//! use halfin::bitcoind::BitcoinD;
16//! use halfin::utreexod::UtreexoD;
17//!
18//! let bitcoind = BitcoinD::new().unwrap();
19//! bitcoind.generate(10).unwrap();
20//! assert_eq!(bitcoind.get_height().unwrap(), 10);
21//!
22//! let utreexod = UtreexoD::new().unwrap();
23//! utreexod.generate(10).unwrap();
24//! assert_eq!(utreexod.get_height().unwrap(), 10);
25//! ```
26//!
27//! [`bitcoind`]: <https://github.com/bitcoin/bitcoin>
28//! [`utreexod`]: <https://github.com/utreexo/utreexod>
29
30use core::error;
31use core::fmt;
32use core::net::Ipv4Addr;
33use core::net::SocketAddr;
34use std::net::TcpListener;
35use std::path::PathBuf;
36use std::thread;
37use std::time::Duration;
38use std::time::Instant;
39use tempfile::TempDir;
40
41pub use bitcoind::BitcoinD;
42pub use utreexod::UtreexoD;
43
44pub mod bitcoind;
45pub mod utreexod;
46
47/// IPv4 Localhost address.
48const LOCALHOST: Ipv4Addr = Ipv4Addr::new(127, 0, 0, 1);
49
50/// The maximum number of attempts at instantiating a [`BitcoinD`]/[`UtreexoD`].
51pub const NODE_BUILDING_MAX_RETRIES: u8 = 5;
52
53/// The [`Duration`] between polls for `wait_for_height`.
54pub const POLL_INTERVAL: Duration = Duration::from_millis(100);
55
56/// The timeout [`Duration`] for `wait_for_height`.
57pub const WAIT_TIMEOUT: Duration = Duration::from_secs(10);
58
59/// Common interface across all node implementations ([`BitcoinD`]/[`UtreexoD`]).
60pub trait Node {
61    /// A human-readable name for the [`Node`].
62    fn get_name() -> &'static str;
63
64    /// Get the [`Node`]'s current chain height.
65    fn get_height(&self) -> Result<u32, Error>;
66
67    /// How long to sleep between `get_height` RPC calls.
68    ///
69    /// Defaults to [`POLL_INTERVAL`].
70    ///
71    /// Override for nodes that need a longer settling time between RPC calls.
72    fn poll_interval() -> Duration {
73        POLL_INTERVAL
74    }
75
76    /// How long `wait_for_height` will poll before giving up.
77    ///
78    /// Defaults to [`WAIT_TIMEOUT`].
79    ///
80    /// Override for nodes that need more time to process blocks
81    /// (e.g. [`UtreexoD`] needs more time to build the Merkle forest).
82    fn wait_timeout() -> Duration {
83        WAIT_TIMEOUT
84    }
85}
86
87#[rustfmt::skip]
88impl Node for BitcoinD {
89    fn get_name() -> &'static str { "bitcoind" }
90    fn get_height(&self) -> Result<u32, Error> { self.get_height() }
91}
92
93#[rustfmt::skip]
94impl Node for UtreexoD {
95    fn get_name() -> &'static str { "utreexod" }
96    fn get_height(&self) -> Result<u32, Error> { self.get_height() }
97    fn poll_interval() -> Duration { 2 * POLL_INTERVAL }
98    fn wait_timeout() -> Duration { 2 * WAIT_TIMEOUT }
99}
100
101/// Poll a [`Node`] until its chain height reaches `height`, then return.
102///
103/// Panics if the node does not reach `height` within [`Node::wait_timeout`].
104pub fn wait_for_height<N: Node>(node: &N, height: u32) -> Result<(), Error> {
105    let start = Instant::now();
106    while start.elapsed() < N::wait_timeout() {
107        if node.get_height().unwrap() >= height {
108            return Ok(());
109        }
110        thread::sleep(N::poll_interval());
111    }
112
113    let curr_height = node.get_height().unwrap();
114    Err(Error::ChainSyncTimeOut((
115        height,
116        curr_height,
117        N::wait_timeout(),
118    )))
119}
120
121/// Poll a [`Node`] until its chain height reaches `height`, then return.
122///
123/// Panics if the node does not reach `height` within `timeout`.
124pub fn wait_for_height_with_timeout<N: Node>(
125    node: &N,
126    height: u32,
127    timeout: Duration,
128) -> Result<(), Error> {
129    let start = Instant::now();
130    while start.elapsed() < timeout {
131        if node.get_height().unwrap() >= height {
132            return Ok(());
133        }
134        thread::sleep(N::poll_interval());
135    }
136
137    let curr_height = node.get_height().unwrap();
138    Err(Error::ChainSyncTimeOut((height, curr_height, timeout)))
139}
140
141/// Ask the OS for an available port, immediately unbind and return it.
142///
143/// Inlining is needed to curb TOCTOU race conditions.
144#[inline]
145pub fn get_available_port() -> u16 {
146    TcpListener::bind((LOCALHOST, 0))
147        .unwrap()
148        .local_addr()
149        .unwrap()
150        .port()
151}
152
153/// Owns a node's working directory, either as a temporary or a persistent path.
154///
155/// * [`DataDir::Temporary`]: backed by a [`TempDir`]; the directory is
156///   deleted automatically when this value is dropped.
157/// * [`DataDir::Persistent`]: backed by a plain [`PathBuf`]; the directory
158///   survives the process and is never cleaned up automatically.
159#[derive(Debug)]
160pub enum DataDir {
161    /// A persistent directory that is **not** cleaned up on drop.
162    Persistent(PathBuf),
163    /// A temporary directory that is deleted when this value is dropped.
164    Temporary(TempDir),
165}
166
167impl DataDir {
168    /// Return the underlying filesystem path regardless of variant.
169    pub fn path(&self) -> PathBuf {
170        match self {
171            Self::Persistent(path) => path.to_owned(),
172            Self::Temporary(tmp_dir) => tmp_dir.path().to_path_buf(),
173        }
174    }
175}
176
177#[derive(Debug)]
178pub enum Error {
179    /// The binary was not found at the expected location.
180    BinaryNotFound((String, PathBuf)),
181    /// Failed to spawn a [process](std::process::Child) for [`BitcoinD`]/[`UtreexoD`].
182    FailedToSpawn(std::io::Error),
183    /// Failed to instantiate a [`BitcoinD`]/[`UtreexoD`] after [`NODE_BUILDING_MAX_RETRIES`] attempts.
184    ExhaustedNodeBuildingRetries,
185    /// Failed to stop [`BitcoinD`] or [`UtreexoD`] over JSON-RPC (e.g. `bitcoin-cli -regtest stop`).
186    FailedToStop(corepc_client::client_sync::Error),
187    /// I/O errors.
188    Io(std::io::Error),
189    /// JSON-RPC Errors.
190    JsonRpc(corepc_client::client_sync::Error),
191    /// Timed out whilst waiting for peer connection to succeed.
192    PeerConnectionTimeout((SocketAddr, SocketAddr)),
193    /// Both `tmpdir` and `workdir` were specified.
194    BothDirsSpecified,
195    /// [`BitcoinD`] is unresponsive (it's probably not running).
196    UnresponsiveBitcoinD(corepc_client::client_sync::Error),
197    /// [`UtreexoD`] is unresponsive (it's probably not running).
198    UnresponsiveUtreexoD(corepc_client::client_sync::Error),
199    /// Timed out whilst waiting for the cookie file to be generated.
200    CookieFileTimeout(PathBuf),
201    /// Timed out whilst waiting for the JSON-RPC client to be ready.
202    RpcClientSetupTimeout,
203    /// Received an unexpected response from the JSON-RPC server
204    UnexpectedResponse,
205    /// Timed out whilst waiting for the [`Node`]'s chain to synchronize up to `height`
206    ChainSyncTimeOut((u32, u32, Duration)), // (current_height, target_height, timeout)
207}
208
209#[rustfmt::skip]
210impl fmt::Display for Error {
211    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
212        use Error::*;
213        match self {
214            BinaryNotFound((bin_name, path)) => write!(f, "The `{}` binary was not found at the expected location={}", bin_name, path.display()),
215            FailedToSpawn(err) => write!(f, "Failed to spawn a process for the node: {err:?}"),
216            ExhaustedNodeBuildingRetries => write!(f, "Failed to instantiate the node after {} attempts", NODE_BUILDING_MAX_RETRIES),
217            FailedToStop(err) => write!(f, "Failed to stop the node over JSON-RPC: {err:?}"),
218            Io(err) => write!(f, "I/O Error: {err:?}"),
219            JsonRpc(err) => write!(f, "JSON-RPC Error: {err:?}"),
220            PeerConnectionTimeout((local_socket, remote_socket)) => write!(f, "Timed out whilst waiting for connection between local={local_socket} and remote={remote_socket}"),
221            BothDirsSpecified => write!(f, "Both `tempdir` and `workdir` were specified. You must choose one and only one"),
222            UnresponsiveBitcoinD(err) => write!(f, "`BitcoinD` is unresponsive to JSON-RPC calls: {err:?}"),
223            UnresponsiveUtreexoD(err) => write!(f, "`UtreexoD` is unresponsive to JSON-RPC calls: {err:?}"),
224            CookieFileTimeout(cookie_path) => write!(f, "Timed out whilst waiting for the cookie={} to be generated", cookie_path.display()),
225            RpcClientSetupTimeout => write!(f, "Timed out whilst waiting for the JSON-RPC client to be ready"),
226            UnexpectedResponse => write!(f, "Received an unexpected response from the JSON-RPC server"),
227            ChainSyncTimeOut((target_height, current_height, t)) => write!(
228                f,
229                "Timed out after {} seconds whilst waiting for the node's chain to synchronize to height={} (current height={})",
230                target_height, current_height, t.as_secs()
231            ),
232        }
233    }
234}
235
236impl error::Error for Error {}