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