Skip to main content

spawn_lnd/
bitcoin.rs

1use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
2use hmac::{Hmac, KeyInit, Mac};
3use serde::{Deserialize, Serialize, de::DeserializeOwned};
4use serde_json::{Value, json};
5use sha2::Sha256;
6use thiserror::Error;
7use tokio::time::sleep;
8
9use crate::{
10    DEFAULT_BITCOIND_IMAGE, RetryPolicy,
11    docker::{
12        ContainerRole, ContainerSpec, DockerClient, DockerError, SpawnedContainer,
13        managed_container_labels,
14    },
15};
16
17/// Default RPC user configured for spawned Bitcoin Core nodes.
18pub const DEFAULT_BITCOIN_RPC_USER: &str = "bitcoinrpc";
19/// Default wallet name used for mining and funding operations.
20pub const DEFAULT_BITCOIN_WALLET_NAME: &str = "spawn-lnd";
21/// Number of blocks mined to mature the default wallet's coinbase outputs.
22pub const DEFAULT_BITCOIN_WALLET_MATURITY_BLOCKS: u64 = 150;
23/// Regtest RPC port exposed by Bitcoin Core inside the Docker container.
24pub const BITCOIND_RPC_PORT: u16 = 18443;
25/// Regtest P2P port exposed by Bitcoin Core inside the Docker container.
26pub const BITCOIND_P2P_PORT: u16 = 18444;
27
28type HmacSha256 = Hmac<Sha256>;
29
30/// Configuration for one spawned Bitcoin Core regtest backend.
31#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
32pub struct BitcoinCoreConfig {
33    /// Cluster identifier used in container names and labels.
34    pub cluster_id: String,
35    /// Zero-based chain group index.
36    pub group_index: usize,
37    /// Docker image used for this Bitcoin Core container.
38    pub image: String,
39    /// Retry policy used while waiting for RPC readiness.
40    pub startup_retry: RetryPolicy,
41    /// Optional Docker network name.
42    pub network: Option<String>,
43    /// Optional static IPv4 address on the configured Docker network.
44    pub ipv4_address: Option<String>,
45}
46
47impl BitcoinCoreConfig {
48    /// Create a Bitcoin Core config using the default pinned image.
49    pub fn new(cluster_id: impl Into<String>, group_index: usize) -> Self {
50        Self {
51            cluster_id: cluster_id.into(),
52            group_index,
53            image: DEFAULT_BITCOIND_IMAGE.to_string(),
54            startup_retry: RetryPolicy::default(),
55            network: None,
56            ipv4_address: None,
57        }
58    }
59
60    /// Override the Bitcoin Core Docker image.
61    pub fn image(mut self, image: impl Into<String>) -> Self {
62        self.image = image.into();
63        self
64    }
65
66    /// Override the readiness retry policy.
67    pub fn startup_retry_policy(mut self, policy: RetryPolicy) -> Self {
68        self.startup_retry = policy;
69        self
70    }
71
72    /// Attach this Bitcoin Core container to a Docker network.
73    pub fn network(mut self, network: impl Into<String>) -> Self {
74        self.network = Some(network.into());
75        self
76    }
77
78    /// Assign a static IPv4 address on the configured Docker network.
79    pub fn ipv4_address(mut self, ip: impl Into<String>) -> Self {
80        self.ipv4_address = Some(ip.into());
81        self
82    }
83}
84
85/// A running Bitcoin Core container and its RPC handles.
86#[derive(Clone, Debug)]
87pub struct BitcoinCore {
88    /// Docker container metadata.
89    pub container: SpawnedContainer,
90    /// RPC authentication generated for the node.
91    pub auth: BitcoinRpcAuth,
92    /// RPC client for node-level methods.
93    pub rpc: BitcoinRpcClient,
94    /// RPC client scoped to the default wallet.
95    pub wallet_rpc: BitcoinRpcClient,
96    /// Host RPC socket, usually `127.0.0.1:<port>`.
97    pub rpc_socket: String,
98    /// Host P2P socket, usually `127.0.0.1:<port>`.
99    pub p2p_socket: String,
100}
101
102impl BitcoinCore {
103    /// Spawn a Bitcoin Core container and wait until RPC is ready.
104    pub async fn spawn(
105        docker: &DockerClient,
106        config: BitcoinCoreConfig,
107    ) -> Result<Self, BitcoinCoreError> {
108        let auth = BitcoinRpcAuth::random();
109        let spec = bitcoind_container_spec(&config, &auth);
110        let container = docker.create_and_start(spec).await?;
111        let container_id = container.id.clone();
112        let core = match Self::from_container(container, auth) {
113            Ok(core) => core,
114            Err(error) => {
115                let logs = docker.container_logs(&container_id).await.ok();
116                let _ = docker.rollback_containers([container_id.clone()]).await;
117                return Err(BitcoinCoreError::Startup {
118                    container_id,
119                    logs,
120                    source: Box::new(error),
121                });
122            }
123        };
124
125        if let Err(source) = core.wait_ready_with_policy(&config.startup_retry).await {
126            let logs = docker.container_logs(&core.container.id).await.ok();
127            let container_id = core.container.id.clone();
128            let _ = docker.rollback_containers([container_id.clone()]).await;
129            return Err(BitcoinCoreError::Startup {
130                container_id,
131                logs,
132                source: Box::new(source),
133            });
134        }
135
136        Ok(core)
137    }
138
139    fn from_container(
140        container: SpawnedContainer,
141        auth: BitcoinRpcAuth,
142    ) -> Result<Self, BitcoinCoreError> {
143        let rpc_port = container.host_port(BITCOIND_RPC_PORT).ok_or_else(|| {
144            BitcoinCoreError::MissingHostPort {
145                container_id: container.id.clone(),
146                container_port: BITCOIND_RPC_PORT,
147            }
148        })?;
149        let p2p_port = container.host_port(BITCOIND_P2P_PORT).ok_or_else(|| {
150            BitcoinCoreError::MissingHostPort {
151                container_id: container.id.clone(),
152                container_port: BITCOIND_P2P_PORT,
153            }
154        })?;
155        let rpc = BitcoinRpcClient::new("127.0.0.1", rpc_port, &auth.user, &auth.password);
156        let wallet_rpc = rpc.wallet(DEFAULT_BITCOIN_WALLET_NAME);
157
158        Ok(Self {
159            rpc_socket: format!("127.0.0.1:{rpc_port}"),
160            p2p_socket: format!("127.0.0.1:{p2p_port}"),
161            container,
162            auth,
163            rpc,
164            wallet_rpc,
165        })
166    }
167
168    fn refresh_from_container(
169        &mut self,
170        container: SpawnedContainer,
171    ) -> Result<(), BitcoinCoreError> {
172        let updated = Self::from_container(container, self.auth.clone())?;
173        *self = updated;
174        Ok(())
175    }
176
177    /// Stop the Bitcoin Core container without removing it.
178    pub async fn stop(&self, docker: &DockerClient) -> Result<(), BitcoinCoreError> {
179        docker.stop_container(&self.container.id).await?;
180        Ok(())
181    }
182
183    /// Start the Bitcoin Core container and wait until RPC is ready.
184    pub async fn start(
185        &mut self,
186        docker: &DockerClient,
187        policy: &RetryPolicy,
188    ) -> Result<BlockchainInfo, BitcoinCoreError> {
189        let container = docker.start_container(&self.container.id).await?;
190        self.refresh_from_container(container)?;
191        self.wait_ready_with_policy(policy).await
192    }
193
194    /// Restart the Bitcoin Core container and wait until RPC is ready.
195    pub async fn restart(
196        &mut self,
197        docker: &DockerClient,
198        policy: &RetryPolicy,
199    ) -> Result<BlockchainInfo, BitcoinCoreError> {
200        let container = docker.restart_container(&self.container.id).await?;
201        self.refresh_from_container(container)?;
202        self.wait_ready_with_policy(policy).await
203    }
204
205    /// Wait for `getblockchaininfo` to succeed using the default retry policy.
206    pub async fn wait_ready(&self) -> Result<BlockchainInfo, BitcoinCoreError> {
207        self.wait_ready_with_policy(&RetryPolicy::default()).await
208    }
209
210    async fn wait_ready_with_policy(
211        &self,
212        policy: &RetryPolicy,
213    ) -> Result<BlockchainInfo, BitcoinCoreError> {
214        let mut last_error = None;
215
216        for _ in 0..policy.attempts {
217            match self.rpc.get_blockchain_info().await {
218                Ok(info) => return Ok(info),
219                Err(error) => {
220                    last_error = Some(error);
221                    sleep(policy.interval()).await;
222                }
223            }
224        }
225
226        Err(BitcoinCoreError::ReadyTimeout {
227            attempts: policy.attempts,
228            last_error: last_error.map(|error| error.to_string()),
229        })
230    }
231
232    /// Create/load the default wallet and mine enough blocks to mature coinbase funds.
233    pub async fn prepare_mining_wallet(&self) -> Result<Vec<String>, BitcoinCoreError> {
234        self.rpc
235            .ensure_wallet(DEFAULT_BITCOIN_WALLET_NAME)
236            .await
237            .map_err(BitcoinCoreError::BitcoinRpc)?;
238        let address = self
239            .wallet_rpc
240            .get_new_address()
241            .await
242            .map_err(BitcoinCoreError::BitcoinRpc)?;
243
244        self.rpc
245            .generate_to_address(DEFAULT_BITCOIN_WALLET_MATURITY_BLOCKS, &address)
246            .await
247            .map_err(BitcoinCoreError::BitcoinRpc)
248    }
249}
250
251/// RPC credentials for Bitcoin Core.
252#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
253pub struct BitcoinRpcAuth {
254    /// RPC username.
255    pub user: String,
256    /// RPC password.
257    pub password: String,
258    /// Value suitable for Bitcoin Core's `-rpcauth` setting.
259    pub rpcauth: String,
260}
261
262impl BitcoinRpcAuth {
263    /// Generate random credentials with [`DEFAULT_BITCOIN_RPC_USER`].
264    pub fn random() -> Self {
265        Self::random_with_user(DEFAULT_BITCOIN_RPC_USER)
266    }
267
268    /// Generate random credentials for the given RPC user.
269    pub fn random_with_user(user: impl Into<String>) -> Self {
270        let user = user.into();
271        let password = random_password();
272        let salt = hex::encode(rand::random::<[u8; 16]>());
273        let rpcauth = bitcoin_core_rpcauth(&user, &password, &salt);
274
275        Self {
276            user,
277            password,
278            rpcauth,
279        }
280    }
281}
282
283/// Minimal async JSON-RPC client for Bitcoin Core regtest nodes.
284#[derive(Clone, Debug)]
285pub struct BitcoinRpcClient {
286    endpoint: String,
287    user: String,
288    password: String,
289    client: reqwest::Client,
290}
291
292impl BitcoinRpcClient {
293    /// Create a client for a host, port, and RPC credentials.
294    pub fn new(
295        host: impl AsRef<str>,
296        port: u16,
297        user: impl Into<String>,
298        password: impl Into<String>,
299    ) -> Self {
300        Self {
301            endpoint: format!("http://{}:{port}/", host.as_ref()),
302            user: user.into(),
303            password: password.into(),
304            client: reqwest::Client::new(),
305        }
306    }
307
308    /// Return the HTTP endpoint used by this client.
309    pub fn endpoint(&self) -> &str {
310        &self.endpoint
311    }
312
313    /// Return a client scoped to a named Bitcoin Core wallet.
314    pub fn wallet(&self, wallet_name: &str) -> Self {
315        Self {
316            endpoint: format!(
317                "{}/wallet/{wallet_name}",
318                self.endpoint.trim_end_matches('/')
319            ),
320            user: self.user.clone(),
321            password: self.password.clone(),
322            client: self.client.clone(),
323        }
324    }
325
326    /// Call `getblockchaininfo`.
327    pub async fn get_blockchain_info(&self) -> Result<BlockchainInfo, BitcoinRpcError> {
328        self.call("getblockchaininfo", json!([])).await
329    }
330
331    /// Call `listwallets`.
332    pub async fn list_wallets(&self) -> Result<Vec<String>, BitcoinRpcError> {
333        self.call("listwallets", json!([])).await
334    }
335
336    /// Call `createwallet`.
337    pub async fn create_wallet(&self, wallet_name: &str) -> Result<CreateWallet, BitcoinRpcError> {
338        self.call("createwallet", json!([wallet_name])).await
339    }
340
341    /// Call `loadwallet`.
342    pub async fn load_wallet(&self, wallet_name: &str) -> Result<LoadWallet, BitcoinRpcError> {
343        self.call("loadwallet", json!([wallet_name])).await
344    }
345
346    /// Ensure a wallet is loaded, creating it if it does not already exist.
347    pub async fn ensure_wallet(&self, wallet_name: &str) -> Result<(), BitcoinRpcError> {
348        if self
349            .list_wallets()
350            .await?
351            .iter()
352            .any(|loaded| loaded == wallet_name)
353        {
354            return Ok(());
355        }
356
357        match self.load_wallet(wallet_name).await {
358            Ok(_) => Ok(()),
359            Err(BitcoinRpcError::Rpc { .. }) => {
360                self.create_wallet(wallet_name).await?;
361                Ok(())
362            }
363            Err(error) => Err(error),
364        }
365    }
366
367    /// Call `getnewaddress`.
368    pub async fn get_new_address(&self) -> Result<String, BitcoinRpcError> {
369        self.call("getnewaddress", json!([])).await
370    }
371
372    /// Mine `count` regtest blocks to `address`.
373    pub async fn generate_to_address(
374        &self,
375        count: u64,
376        address: &str,
377    ) -> Result<Vec<String>, BitcoinRpcError> {
378        self.call("generatetoaddress", json!([count, address]))
379            .await
380    }
381
382    /// Call `getblock` with verbosity `1`.
383    pub async fn get_block(&self, hash: &str) -> Result<BlockInfo, BitcoinRpcError> {
384        self.call("getblock", json!([hash, 1])).await
385    }
386
387    /// Call `addnode <socket> add`.
388    pub async fn add_node(&self, socket: &str) -> Result<(), BitcoinRpcError> {
389        self.call_value("addnode", json!([socket, "add"])).await?;
390        Ok(())
391    }
392
393    /// Call `sendtoaddress` and return the transaction id.
394    pub async fn send_to_address(
395        &self,
396        address: &str,
397        amount_btc: f64,
398    ) -> Result<String, BitcoinRpcError> {
399        self.call("sendtoaddress", json!([address, amount_btc]))
400            .await
401    }
402
403    /// Call `sendmany` and return the transaction id.
404    pub async fn send_many(
405        &self,
406        amounts: &std::collections::HashMap<String, f64>,
407    ) -> Result<String, BitcoinRpcError> {
408        self.call("sendmany", json!(["", amounts])).await
409    }
410
411    /// Call a JSON-RPC method and deserialize the `result` field.
412    pub async fn call<T>(&self, method: &str, params: Value) -> Result<T, BitcoinRpcError>
413    where
414        T: DeserializeOwned,
415    {
416        let response = self.call_value(method, params).await?;
417
418        serde_json::from_value(response).map_err(|source| BitcoinRpcError::DecodeResult {
419            method: method.to_string(),
420            source,
421        })
422    }
423
424    /// Call a JSON-RPC method and return the raw JSON `result` field.
425    pub async fn call_value(&self, method: &str, params: Value) -> Result<Value, BitcoinRpcError> {
426        let response = self
427            .client
428            .post(&self.endpoint)
429            .basic_auth(&self.user, Some(&self.password))
430            .json(&JsonRpcRequest {
431                jsonrpc: "1.0",
432                id: "spawn-lnd",
433                method,
434                params,
435            })
436            .send()
437            .await
438            .map_err(|source| BitcoinRpcError::Request {
439                method: method.to_string(),
440                source,
441            })?;
442
443        let status = response.status();
444        let body = response
445            .text()
446            .await
447            .map_err(|source| BitcoinRpcError::ReadBody {
448                method: method.to_string(),
449                source,
450            })?;
451
452        let response: JsonRpcResponse =
453            serde_json::from_str(&body).map_err(|source| BitcoinRpcError::Decode {
454                method: method.to_string(),
455                body,
456                source,
457            })?;
458
459        if let Some(error) = response.error {
460            return Err(BitcoinRpcError::Rpc {
461                method: method.to_string(),
462                code: error.code,
463                message: error.message,
464            });
465        }
466
467        if !status.is_success() {
468            return Err(BitcoinRpcError::HttpStatus {
469                method: method.to_string(),
470                status: status.as_u16(),
471            });
472        }
473
474        Ok(response.result)
475    }
476}
477
478/// Subset of Bitcoin Core `getblockchaininfo` used by this crate.
479#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
480pub struct BlockchainInfo {
481    /// Chain name, expected to be `regtest`.
482    pub chain: String,
483    /// Current validated block height.
484    pub blocks: u64,
485    /// Current header height.
486    pub headers: u64,
487    /// Best block hash.
488    pub bestblockhash: String,
489}
490
491/// Subset of Bitcoin Core `getblock` response used by this crate.
492#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
493pub struct BlockInfo {
494    /// Block hash.
495    pub hash: String,
496    /// Confirmation count when known.
497    pub confirmations: Option<u64>,
498    /// Block height when known.
499    pub height: Option<u64>,
500    /// Transaction ids included in the block.
501    pub tx: Vec<String>,
502}
503
504/// Response from Bitcoin Core `createwallet`.
505#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
506pub struct CreateWallet {
507    /// Created wallet name.
508    pub name: String,
509    /// Optional warning text returned by Bitcoin Core.
510    #[serde(default)]
511    pub warning: String,
512}
513
514/// Response from Bitcoin Core `loadwallet`.
515#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
516pub struct LoadWallet {
517    /// Loaded wallet name.
518    pub name: String,
519    /// Optional warning text returned by Bitcoin Core.
520    #[serde(default)]
521    pub warning: String,
522}
523
524/// Error returned by the Bitcoin Core JSON-RPC client.
525#[derive(Debug, Error)]
526pub enum BitcoinRpcError {
527    /// The HTTP request failed before a response was received.
528    #[error("Bitcoin Core RPC request failed for method {method}")]
529    Request {
530        /// RPC method name.
531        method: String,
532        /// Underlying HTTP client error.
533        source: reqwest::Error,
534    },
535
536    /// The response body could not be read.
537    #[error("failed to read Bitcoin Core RPC response body for method {method}")]
538    ReadBody {
539        /// RPC method name.
540        method: String,
541        /// Underlying HTTP client error.
542        source: reqwest::Error,
543    },
544
545    /// The response body was not a valid Bitcoin Core JSON-RPC response.
546    #[error("failed to decode Bitcoin Core RPC response for method {method}: {body}")]
547    Decode {
548        /// RPC method name.
549        method: String,
550        /// Raw response body.
551        body: String,
552        /// JSON decoding error.
553        source: serde_json::Error,
554    },
555
556    /// The JSON-RPC `result` field could not be decoded into the requested type.
557    #[error("failed to decode Bitcoin Core RPC result for method {method}")]
558    DecodeResult {
559        /// RPC method name.
560        method: String,
561        /// JSON decoding error.
562        source: serde_json::Error,
563    },
564
565    /// Bitcoin Core returned a non-success HTTP status.
566    #[error("Bitcoin Core RPC method {method} returned HTTP status {status}")]
567    HttpStatus {
568        /// RPC method name.
569        method: String,
570        /// HTTP status code.
571        status: u16,
572    },
573
574    /// Bitcoin Core returned a JSON-RPC error object.
575    #[error("Bitcoin Core RPC method {method} failed with code {code}: {message}")]
576    Rpc {
577        /// RPC method name.
578        method: String,
579        /// JSON-RPC error code.
580        code: i64,
581        /// JSON-RPC error message.
582        message: String,
583    },
584}
585
586/// Error returned while spawning or preparing Bitcoin Core.
587#[derive(Debug, Error)]
588pub enum BitcoinCoreError {
589    /// Docker operation failed.
590    #[error(transparent)]
591    Docker(#[from] DockerError),
592
593    /// Docker did not publish an expected port.
594    #[error("Docker container {container_id} did not publish expected port {container_port}")]
595    MissingHostPort {
596        /// Docker container id.
597        container_id: String,
598        /// Expected container port.
599        container_port: u16,
600    },
601
602    /// Bitcoin Core RPC did not become ready before timeout.
603    #[error(
604        "Bitcoin Core did not become ready after {attempts} attempts; last error: {last_error:?}"
605    )]
606    ReadyTimeout {
607        /// Number of readiness attempts.
608        attempts: usize,
609        /// Last RPC error seen while waiting.
610        last_error: Option<String>,
611    },
612
613    /// Bitcoin Core RPC failed.
614    #[error(transparent)]
615    BitcoinRpc(#[from] BitcoinRpcError),
616
617    /// Container startup failed; logs are included when available.
618    #[error("Bitcoin Core startup failed for container {container_id}; logs: {logs:?}")]
619    Startup {
620        /// Docker container id.
621        container_id: String,
622        /// Tail of container logs when available.
623        logs: Option<String>,
624        /// Underlying startup failure.
625        source: Box<BitcoinCoreError>,
626    },
627}
628
629#[derive(Serialize)]
630struct JsonRpcRequest<'a> {
631    jsonrpc: &'a str,
632    id: &'a str,
633    method: &'a str,
634    params: Value,
635}
636
637#[derive(Deserialize)]
638struct JsonRpcResponse {
639    result: Value,
640    error: Option<JsonRpcErrorObject>,
641}
642
643#[derive(Deserialize)]
644struct JsonRpcErrorObject {
645    code: i64,
646    message: String,
647}
648
649/// Build the value for Bitcoin Core's `-rpcauth` flag.
650pub fn bitcoin_core_rpcauth(user: &str, password: &str, salt: &str) -> String {
651    let hmac = bitcoin_core_auth_hmac(password, salt);
652    format!("{user}:{salt}${hmac}")
653}
654
655/// Compute Bitcoin Core's HMAC-SHA256 `rpcauth` digest.
656pub fn bitcoin_core_auth_hmac(password: &str, salt: &str) -> String {
657    let mut mac = HmacSha256::new_from_slice(salt.as_bytes()).expect("HMAC accepts any key length");
658    mac.update(password.as_bytes());
659    hex::encode(mac.finalize().into_bytes())
660}
661
662fn random_password() -> String {
663    URL_SAFE_NO_PAD.encode(rand::random::<[u8; 32]>())
664}
665
666fn bitcoind_container_spec(config: &BitcoinCoreConfig, auth: &BitcoinRpcAuth) -> ContainerSpec {
667    let name = format!(
668        "spawn-lnd-{}-bitcoind-{}",
669        config.cluster_id, config.group_index
670    );
671    let labels = managed_container_labels(&config.cluster_id, ContainerRole::Bitcoind, None);
672
673    let mut spec = ContainerSpec::new(name, config.image.clone())
674        .cmd(bitcoind_args(auth))
675        .labels(labels)
676        .expose_ports([BITCOIND_RPC_PORT, BITCOIND_P2P_PORT]);
677
678    if let Some(network) = &config.network {
679        spec = spec.network(network.clone());
680    }
681    if let Some(ipv4_address) = &config.ipv4_address {
682        spec = spec.ipv4_address(ipv4_address.clone());
683    }
684
685    spec
686}
687
688fn bitcoind_args(auth: &BitcoinRpcAuth) -> Vec<String> {
689    vec![
690        "-regtest".to_string(),
691        "-printtoconsole".to_string(),
692        "-rpcbind=0.0.0.0".to_string(),
693        "-rpcallowip=0.0.0.0/0".to_string(),
694        "-fallbackfee=0.00001".to_string(),
695        "-server".to_string(),
696        "-txindex".to_string(),
697        "-blockfilterindex".to_string(),
698        "-coinstatsindex".to_string(),
699        format!("-rpcuser={}", auth.user),
700        format!("-rpcpassword={}", auth.password),
701    ]
702}
703
704#[cfg(test)]
705mod tests {
706    use super::{
707        BITCOIND_P2P_PORT, BITCOIND_RPC_PORT, BitcoinCoreConfig, BitcoinRpcAuth, BitcoinRpcClient,
708        DEFAULT_BITCOIN_RPC_USER, bitcoin_core_auth_hmac, bitcoin_core_rpcauth, bitcoind_args,
709        bitcoind_container_spec,
710    };
711    use crate::DEFAULT_BITCOIND_IMAGE;
712
713    #[test]
714    fn derives_bitcoin_core_auth_hmac() {
715        assert_eq!(
716            bitcoin_core_auth_hmac("password", "salt"),
717            "84ec44c7d6fc41917953a1dafca3c7d7856f7a9d0328b991b76f0d36be1224b9"
718        );
719    }
720
721    #[test]
722    fn derives_bitcoin_core_rpcauth() {
723        assert_eq!(
724            bitcoin_core_rpcauth("bitcoinrpc", "password", "salt"),
725            "bitcoinrpc:salt$84ec44c7d6fc41917953a1dafca3c7d7856f7a9d0328b991b76f0d36be1224b9"
726        );
727    }
728
729    #[test]
730    fn random_auth_uses_default_user_and_rpcauth_shape() {
731        let auth = BitcoinRpcAuth::random();
732        let prefix = format!("{}:", DEFAULT_BITCOIN_RPC_USER);
733
734        assert_eq!(auth.user, DEFAULT_BITCOIN_RPC_USER);
735        assert!(!auth.password.is_empty());
736        assert!(auth.rpcauth.starts_with(&prefix));
737        assert!(auth.rpcauth.contains('$'));
738    }
739
740    #[test]
741    fn builds_rpc_endpoint() {
742        let client = BitcoinRpcClient::new("127.0.0.1", 18443, "user", "pass");
743
744        assert_eq!(client.endpoint(), "http://127.0.0.1:18443/");
745    }
746
747    #[test]
748    fn builds_wallet_rpc_endpoint() {
749        let client = BitcoinRpcClient::new("127.0.0.1", 18443, "user", "pass");
750        let wallet = client.wallet("spawn-lnd");
751
752        assert_eq!(wallet.endpoint(), "http://127.0.0.1:18443/wallet/spawn-lnd");
753    }
754
755    #[test]
756    fn default_bitcoin_core_config_uses_pinned_image() {
757        let config = BitcoinCoreConfig::new("cluster-1", 2);
758
759        assert_eq!(config.cluster_id, "cluster-1");
760        assert_eq!(config.group_index, 2);
761        assert_eq!(config.image, DEFAULT_BITCOIND_IMAGE);
762    }
763
764    #[test]
765    fn builds_bitcoind_regtest_args() {
766        let auth = BitcoinRpcAuth {
767            user: "bitcoinrpc".to_string(),
768            password: "password".to_string(),
769            rpcauth: bitcoin_core_rpcauth("bitcoinrpc", "password", "salt"),
770        };
771
772        let args = bitcoind_args(&auth);
773
774        assert!(args.contains(&"-regtest".to_string()));
775        assert!(args.contains(&"-printtoconsole".to_string()));
776        assert!(args.contains(&"-rpcbind=0.0.0.0".to_string()));
777        assert!(args.contains(&"-rpcallowip=0.0.0.0/0".to_string()));
778        assert!(args.contains(&"-server".to_string()));
779        assert!(args.contains(&"-txindex".to_string()));
780        assert!(args.contains(&"-fallbackfee=0.00001".to_string()));
781        assert!(args.contains(&"-blockfilterindex".to_string()));
782        assert!(args.contains(&"-coinstatsindex".to_string()));
783        assert!(args.contains(&format!("-rpcuser={}", auth.user)));
784        assert!(args.contains(&format!("-rpcpassword={}", auth.password)));
785    }
786
787    #[test]
788    fn builds_bitcoind_container_spec() {
789        let config = BitcoinCoreConfig::new("cluster-1", 0);
790        let auth = BitcoinRpcAuth {
791            user: "bitcoinrpc".to_string(),
792            password: "password".to_string(),
793            rpcauth: bitcoin_core_rpcauth("bitcoinrpc", "password", "salt"),
794        };
795
796        let spec = bitcoind_container_spec(&config, &auth);
797
798        assert_eq!(spec.name, "spawn-lnd-cluster-1-bitcoind-0");
799        assert_eq!(spec.image, DEFAULT_BITCOIND_IMAGE);
800        assert!(spec.exposed_ports.contains(&BITCOIND_RPC_PORT));
801        assert!(spec.exposed_ports.contains(&BITCOIND_P2P_PORT));
802    }
803}