Skip to main content

spawn_lnd/
cluster.rs

1use std::{collections::HashMap, net::Ipv4Addr};
2
3use lnd_grpc_rust::{
4    LndConnectError, LndNodeClients, LndNodeConfig,
5    lnrpc::{Channel, ConnectPeerResponse},
6};
7use thiserror::Error;
8use tokio::time::sleep;
9use uuid::Uuid;
10
11use crate::{
12    BITCOIND_P2P_PORT, BitcoinCore, BitcoinCoreConfig, BitcoinCoreError, BitcoinRpcError,
13    CleanupReport, ConfigError, DEFAULT_GENERATE_ADDRESS, DockerClient, DockerError, LND_P2P_PORT,
14    LndConfig, LndDaemon, LndError, ManagedNetwork, NetworkSpec, NodeConfig, RetryPolicy,
15    SpawnLndConfig, lnd::channel_point_string,
16};
17
18/// Default on-chain funding amount sent to each LND node.
19pub const DEFAULT_FUNDING_AMOUNT_BTC: f64 = 1.0;
20/// Default number of blocks mined after funding transactions.
21pub const DEFAULT_FUNDING_CONFIRMATION_BLOCKS: u64 = 1;
22/// Default public channel capacity opened by [`SpawnedCluster::open_channel`].
23pub const DEFAULT_CHANNEL_CAPACITY_SAT: i64 = 100_000;
24/// Default number of blocks mined after opening a channel.
25pub const DEFAULT_CHANNEL_CONFIRMATION_BLOCKS: u64 = 6;
26const SATOSHIS_PER_BTC: f64 = 100_000_000.0;
27const GENERATED_SUBNET_ATTEMPTS: u32 = 64;
28
29/// A running regtest cluster containing Bitcoin Core and LND containers.
30#[derive(Debug)]
31pub struct SpawnedCluster {
32    docker: DockerClient,
33    config: SpawnLndConfig,
34    cluster_id: String,
35    network: ManagedNetwork,
36    bitcoinds: Vec<BitcoinCore>,
37    nodes: HashMap<String, SpawnedNode>,
38    node_order: Vec<String>,
39    shutdown: bool,
40}
41
42impl SpawnedCluster {
43    /// Spawn a cluster using a validated config and a default Docker connection.
44    pub async fn spawn(config: SpawnLndConfig) -> Result<Self, SpawnError> {
45        config.validate()?;
46        let docker = DockerClient::connect().await?;
47        Self::spawn_validated_with_docker(docker, config).await
48    }
49
50    /// Spawn a cluster using a caller-provided Docker client.
51    pub async fn spawn_with_docker(
52        docker: DockerClient,
53        config: SpawnLndConfig,
54    ) -> Result<Self, SpawnError> {
55        config.validate()?;
56        Self::spawn_validated_with_docker(docker, config).await
57    }
58
59    async fn spawn_validated_with_docker(
60        docker: DockerClient,
61        config: SpawnLndConfig,
62    ) -> Result<Self, SpawnError> {
63        let cluster_id = new_cluster_id();
64        let cleanup_docker = docker.clone();
65        let keep_containers = config.keep_containers;
66
67        match spawn_inner(docker, config, cluster_id.clone()).await {
68            Ok(cluster) => Ok(cluster),
69            Err(error) => {
70                if keep_containers {
71                    return Err(error);
72                }
73
74                cleanup_docker
75                    .cleanup_cluster(&cluster_id)
76                    .await
77                    .map_err(|source| SpawnError::StartupCleanup {
78                        cluster_id,
79                        startup_error: error.to_string(),
80                        source: Box::new(source),
81                    })?;
82                Err(error)
83            }
84        }
85    }
86
87    /// Return the generated cluster id used in Docker labels.
88    pub fn cluster_id(&self) -> &str {
89        &self.cluster_id
90    }
91
92    /// Return the config used to spawn this cluster.
93    pub fn config(&self) -> &SpawnLndConfig {
94        &self.config
95    }
96
97    /// Return the managed Docker network used by this cluster.
98    pub fn network(&self) -> &ManagedNetwork {
99        &self.network
100    }
101
102    /// Return all spawned Bitcoin Core chain groups.
103    pub fn bitcoinds(&self) -> &[BitcoinCore] {
104        &self.bitcoinds
105    }
106
107    /// Look up a spawned LND node by alias.
108    pub fn node(&self, alias: &str) -> Option<&SpawnedNode> {
109        self.nodes.get(alias)
110    }
111
112    /// Iterate spawned LND nodes in configured order.
113    pub fn nodes(&self) -> impl Iterator<Item = &SpawnedNode> {
114        self.node_order
115            .iter()
116            .filter_map(|alias| self.nodes.get(alias))
117    }
118
119    /// Iterate node aliases in configured order.
120    pub fn node_aliases(&self) -> impl Iterator<Item = &str> {
121        self.node_order.iter().map(String::as_str)
122    }
123
124    /// Build connection configs for all LND nodes.
125    pub fn node_configs(&self) -> Vec<LndNodeConfig> {
126        self.nodes().map(SpawnedNode::node_config).collect()
127    }
128
129    /// Connect to all LND nodes with `lnd_grpc_rust`.
130    pub async fn connect_nodes(&self) -> Result<LndNodeClients, SpawnError> {
131        lnd_grpc_rust::connect_nodes(self.node_configs())
132            .await
133            .map_err(SpawnError::ConnectNodes)
134    }
135
136    /// Connect one LND node to another over the Docker bridge network.
137    pub async fn connect_peer(
138        &self,
139        from_alias: &str,
140        to_alias: &str,
141    ) -> Result<PeerConnection, SpawnError> {
142        let from = self.require_node(from_alias)?;
143        let to = self.require_node(to_alias)?;
144        let host = lnd_bridge_socket(to)?;
145        let response = from
146            .daemon
147            .connect_peer(to.daemon.public_key.clone(), host.clone())
148            .await
149            .or_else(|error| already_connected_response(error, &to.daemon.public_key))
150            .map_err(|source| SpawnError::Lnd {
151                alias: from_alias.to_string(),
152                source: Box::new(source),
153            })?;
154
155        Ok(PeerConnection {
156            from_alias: from_alias.to_string(),
157            to_alias: to_alias.to_string(),
158            public_key: to.daemon.public_key.clone(),
159            socket: host,
160            status: response.status,
161        })
162    }
163
164    /// Connect every LND node to every other LND node.
165    pub async fn connect_all_peers(&self) -> Result<Vec<PeerConnection>, SpawnError> {
166        let mut connections = Vec::new();
167
168        for from_alias in &self.node_order {
169            for to_alias in &self.node_order {
170                if from_alias == to_alias {
171                    continue;
172                }
173
174                connections.push(self.connect_peer(from_alias, to_alias).await?);
175            }
176        }
177
178        Ok(connections)
179    }
180
181    /// Fund one LND node with [`DEFAULT_FUNDING_AMOUNT_BTC`].
182    pub async fn fund_node(&self, alias: &str) -> Result<FundingReport, SpawnError> {
183        self.fund_node_with_amount(alias, DEFAULT_FUNDING_AMOUNT_BTC)
184            .await
185    }
186
187    /// Fund one LND node with a caller-provided BTC amount.
188    pub async fn fund_node_with_amount(
189        &self,
190        alias: &str,
191        amount_btc: f64,
192    ) -> Result<FundingReport, SpawnError> {
193        let mut reports = self.fund_nodes_with_amount([alias], amount_btc).await?;
194        Ok(reports.remove(0))
195    }
196
197    /// Batch-fund multiple LND nodes with [`DEFAULT_FUNDING_AMOUNT_BTC`] each.
198    pub async fn fund_nodes<I, S>(&self, aliases: I) -> Result<Vec<FundingReport>, SpawnError>
199    where
200        I: IntoIterator<Item = S>,
201        S: AsRef<str>,
202    {
203        self.fund_nodes_with_amount(aliases, DEFAULT_FUNDING_AMOUNT_BTC)
204            .await
205    }
206
207    /// Batch-fund multiple LND nodes with the same caller-provided BTC amount.
208    pub async fn fund_nodes_with_amount<I, S>(
209        &self,
210        aliases: I,
211        amount_btc: f64,
212    ) -> Result<Vec<FundingReport>, SpawnError>
213    where
214        I: IntoIterator<Item = S>,
215        S: AsRef<str>,
216    {
217        let amount_sat = btc_to_sat(amount_btc)?;
218        let mut recipients = Vec::new();
219        let mut amounts = HashMap::new();
220
221        for alias in aliases {
222            let alias = alias.as_ref().to_string();
223            let node = self.require_node(&alias)?;
224            let starting_balance_sat = node
225                .daemon
226                .wallet_balance(1)
227                .await
228                .map_err(|source| SpawnError::Lnd {
229                    alias: alias.clone(),
230                    source: Box::new(source),
231                })?
232                .confirmed_balance;
233            let starting_utxos = node
234                .daemon
235                .list_unspent(1, i32::MAX)
236                .await
237                .map_err(|source| SpawnError::Lnd {
238                    alias: alias.clone(),
239                    source: Box::new(source),
240                })?;
241            let starting_utxo_total_sat: i64 =
242                starting_utxos.iter().map(|utxo| utxo.amount_sat).sum();
243            let required_balance_sat = starting_balance_sat
244                .checked_add(amount_sat)
245                .ok_or(SpawnError::InvalidFundingAmount { amount_btc })?;
246            let required_utxo_total_sat = starting_utxo_total_sat
247                .checked_add(amount_sat)
248                .ok_or(SpawnError::InvalidFundingAmount { amount_btc })?;
249            let address = node
250                .daemon
251                .new_address()
252                .await
253                .map_err(|source| SpawnError::Lnd {
254                    alias: alias.clone(),
255                    source: Box::new(source),
256                })?;
257
258            amounts.insert(address.clone(), amount_btc);
259            recipients.push(FundingRecipient {
260                alias,
261                address,
262                required_balance_sat,
263                required_utxo_total_sat,
264            });
265        }
266
267        if recipients.is_empty() {
268            return Ok(Vec::new());
269        }
270
271        let funder = &self.bitcoinds[0];
272        let txid = funder
273            .wallet_rpc
274            .send_many(&amounts)
275            .await
276            .map_err(|source| SpawnError::BitcoinRpc {
277                group_index: 0,
278                source: Box::new(source),
279            })?;
280        let confirmation_blocks = funder
281            .rpc
282            .generate_to_address(
283                DEFAULT_FUNDING_CONFIRMATION_BLOCKS,
284                DEFAULT_GENERATE_ADDRESS,
285            )
286            .await
287            .map_err(|source| SpawnError::BitcoinRpc {
288                group_index: 0,
289                source: Box::new(source),
290            })?;
291
292        wait_bitcoind_groups_synced(&self.bitcoinds, &self.config.startup_retry).await?;
293        wait_lnd_nodes_synced(&self.nodes, &self.node_order, &self.config.startup_retry).await?;
294
295        let mut reports = Vec::with_capacity(recipients.len());
296        for recipient in recipients {
297            let node = self.require_node(&recipient.alias)?;
298            let balance = node
299                .daemon
300                .wait_for_spendable_balance(recipient.required_balance_sat)
301                .await
302                .map_err(|source| SpawnError::Lnd {
303                    alias: recipient.alias.clone(),
304                    source: Box::new(source),
305                })?;
306            let utxos = node
307                .daemon
308                .wait_for_spendable_utxos(recipient.required_utxo_total_sat)
309                .await
310                .map_err(|source| SpawnError::Lnd {
311                    alias: recipient.alias.clone(),
312                    source: Box::new(source),
313                })?;
314            let spendable_utxo_total_sat = utxos.iter().map(|utxo| utxo.amount_sat).sum();
315
316            reports.push(FundingReport {
317                alias: recipient.alias,
318                address: recipient.address,
319                txid: txid.clone(),
320                amount_btc,
321                confirmation_blocks: confirmation_blocks.clone(),
322                confirmed_balance_sat: balance.confirmed_balance,
323                spendable_utxo_count: utxos.len(),
324                spendable_utxo_total_sat,
325            });
326        }
327
328        Ok(reports)
329    }
330
331    /// Open a public channel with [`DEFAULT_CHANNEL_CAPACITY_SAT`].
332    pub async fn open_channel(
333        &self,
334        from_alias: &str,
335        to_alias: &str,
336    ) -> Result<ChannelReport, SpawnError> {
337        self.open_channel_with_amount(from_alias, to_alias, DEFAULT_CHANNEL_CAPACITY_SAT)
338            .await
339    }
340
341    /// Open a public channel with a caller-provided satoshi capacity.
342    pub async fn open_channel_with_amount(
343        &self,
344        from_alias: &str,
345        to_alias: &str,
346        local_funding_amount_sat: i64,
347    ) -> Result<ChannelReport, SpawnError> {
348        let from = self.require_node(from_alias)?;
349        let to = self.require_node(to_alias)?;
350        let bitcoind = &self.bitcoinds[from.chain_group_index];
351
352        self.connect_peer(from_alias, to_alias).await?;
353
354        let channel_point = from
355            .daemon
356            .open_channel_sync(&to.daemon.public_key, local_funding_amount_sat, 0)
357            .await
358            .map_err(|source| SpawnError::Lnd {
359                alias: from_alias.to_string(),
360                source: Box::new(source),
361            })?;
362        let channel_point =
363            channel_point_string(&channel_point).map_err(|source| SpawnError::Lnd {
364                alias: from_alias.to_string(),
365                source: Box::new(source),
366            })?;
367
368        from.daemon
369            .wait_for_pending_channel(&to.daemon.public_key, &channel_point)
370            .await
371            .map_err(|source| SpawnError::Lnd {
372                alias: from_alias.to_string(),
373                source: Box::new(source),
374            })?;
375
376        let confirmation_blocks = bitcoind
377            .rpc
378            .generate_to_address(
379                DEFAULT_CHANNEL_CONFIRMATION_BLOCKS,
380                DEFAULT_GENERATE_ADDRESS,
381            )
382            .await
383            .map_err(|source| SpawnError::BitcoinRpc {
384                group_index: from.chain_group_index,
385                source: Box::new(source),
386            })?;
387
388        wait_bitcoind_groups_synced(&self.bitcoinds, &self.config.startup_retry).await?;
389        wait_lnd_nodes_synced(&self.nodes, &self.node_order, &self.config.startup_retry).await?;
390
391        let from_channel = from
392            .daemon
393            .wait_for_active_channel(&to.daemon.public_key, &channel_point)
394            .await
395            .map_err(|source| SpawnError::Lnd {
396                alias: from_alias.to_string(),
397                source: Box::new(source),
398            })?;
399        let to_channel = to
400            .daemon
401            .wait_for_active_channel(&from.daemon.public_key, &channel_point)
402            .await
403            .map_err(|source| SpawnError::Lnd {
404                alias: to_alias.to_string(),
405                source: Box::new(source),
406            })?;
407
408        Ok(ChannelReport {
409            from_alias: from_alias.to_string(),
410            to_alias: to_alias.to_string(),
411            channel_point,
412            local_funding_amount_sat,
413            confirmation_blocks,
414            from_channel,
415            to_channel,
416        })
417    }
418
419    /// Stop an LND container without removing it.
420    pub async fn stop_lnd(&self, alias: &str) -> Result<(), SpawnError> {
421        let node = self.require_node(alias)?;
422        node.daemon
423            .stop(&self.docker)
424            .await
425            .map_err(|source| SpawnError::Lnd {
426                alias: alias.to_string(),
427                source: Box::new(source),
428            })
429    }
430
431    /// Start an existing LND container and wait until it is synced to chain.
432    pub async fn start_lnd(&mut self, alias: &str) -> Result<(), SpawnError> {
433        let docker = self.docker.clone();
434        let policy = self.config.startup_retry;
435        let node = self.require_node_mut(alias)?;
436        node.daemon
437            .start(&docker, &policy)
438            .await
439            .map_err(|source| SpawnError::Lnd {
440                alias: alias.to_string(),
441                source: Box::new(source),
442            })?;
443        Ok(())
444    }
445
446    /// Restart an LND container and wait until it is synced to chain.
447    pub async fn restart_lnd(&mut self, alias: &str) -> Result<(), SpawnError> {
448        let docker = self.docker.clone();
449        let policy = self.config.startup_retry;
450        let node = self.require_node_mut(alias)?;
451        node.daemon
452            .restart(&docker, &policy)
453            .await
454            .map_err(|source| SpawnError::Lnd {
455                alias: alias.to_string(),
456                source: Box::new(source),
457            })?;
458        Ok(())
459    }
460
461    /// Stop a Bitcoin Core chain group container without removing it.
462    pub async fn stop_bitcoind(&self, group_index: usize) -> Result<(), SpawnError> {
463        let bitcoind = self.require_bitcoind(group_index)?;
464        bitcoind
465            .stop(&self.docker)
466            .await
467            .map_err(|source| SpawnError::BitcoinCore {
468                group_index,
469                source: Box::new(source),
470            })
471    }
472
473    /// Start an existing Bitcoin Core chain group and wait for dependent nodes.
474    pub async fn start_bitcoind(&mut self, group_index: usize) -> Result<(), SpawnError> {
475        self.start_bitcoind_inner(group_index, false).await
476    }
477
478    /// Restart a Bitcoin Core chain group and wait for dependent nodes.
479    pub async fn restart_bitcoind(&mut self, group_index: usize) -> Result<(), SpawnError> {
480        self.start_bitcoind_inner(group_index, true).await
481    }
482
483    /// Stop and remove all containers in this cluster unless `keep_containers` is set.
484    pub async fn shutdown(&mut self) -> Result<CleanupReport, SpawnError> {
485        if self.shutdown || self.config.keep_containers {
486            self.shutdown = true;
487            return Ok(empty_cleanup_report());
488        }
489
490        let report = self.docker.cleanup_cluster(&self.cluster_id).await?;
491        self.shutdown = true;
492        Ok(report)
493    }
494
495    async fn start_bitcoind_inner(
496        &mut self,
497        group_index: usize,
498        restart: bool,
499    ) -> Result<(), SpawnError> {
500        self.require_bitcoind(group_index)?;
501
502        let docker = self.docker.clone();
503        let policy = self.config.startup_retry;
504        let bitcoind = self
505            .bitcoinds
506            .get_mut(group_index)
507            .expect("validated bitcoind group");
508
509        let result = if restart {
510            bitcoind.restart(&docker, &policy).await
511        } else {
512            bitcoind.start(&docker, &policy).await
513        };
514        result.map_err(|source| SpawnError::BitcoinCore {
515            group_index,
516            source: Box::new(source),
517        })?;
518
519        connect_bitcoind_groups(&self.bitcoinds).await?;
520        wait_bitcoind_groups_synced(&self.bitcoinds, &self.config.startup_retry).await?;
521        wait_lnd_nodes_in_group_synced(
522            &self.nodes,
523            &self.node_order,
524            group_index,
525            &self.config.startup_retry,
526        )
527        .await?;
528
529        Ok(())
530    }
531
532    fn require_node(&self, alias: &str) -> Result<&SpawnedNode, SpawnError> {
533        self.nodes
534            .get(alias)
535            .ok_or_else(|| SpawnError::UnknownNode {
536                alias: alias.to_string(),
537            })
538    }
539
540    fn require_node_mut(&mut self, alias: &str) -> Result<&mut SpawnedNode, SpawnError> {
541        self.nodes
542            .get_mut(alias)
543            .ok_or_else(|| SpawnError::UnknownNode {
544                alias: alias.to_string(),
545            })
546    }
547
548    fn require_bitcoind(&self, group_index: usize) -> Result<&BitcoinCore, SpawnError> {
549        self.bitcoinds
550            .get(group_index)
551            .ok_or(SpawnError::UnknownBitcoindGroup { group_index })
552    }
553}
554
555impl From<DockerError> for SpawnError {
556    fn from(source: DockerError) -> Self {
557        Self::Docker(Box::new(source))
558    }
559}
560
561impl Drop for SpawnedCluster {
562    fn drop(&mut self) {
563        if !self.shutdown && !self.config.keep_containers {
564            eprintln!(
565                "spawn-lnd cluster {} dropped without shutdown(); call shutdown().await to remove managed containers",
566                self.cluster_id
567            );
568        }
569    }
570}
571
572/// A spawned LND node and its placement in the cluster.
573#[derive(Clone, Debug)]
574pub struct SpawnedNode {
575    alias: String,
576    node_index: usize,
577    chain_group_index: usize,
578    daemon: LndDaemon,
579}
580
581impl SpawnedNode {
582    fn new(node_index: usize, chain_group_index: usize, daemon: LndDaemon) -> Self {
583        Self {
584            alias: daemon.alias.clone(),
585            node_index,
586            chain_group_index,
587            daemon,
588        }
589    }
590
591    /// Return the node alias.
592    pub fn alias(&self) -> &str {
593        &self.alias
594    }
595
596    /// Return the zero-based node index in spawn order.
597    pub fn node_index(&self) -> usize {
598        self.node_index
599    }
600
601    /// Return the Bitcoin Core chain group index backing this node.
602    pub fn chain_group_index(&self) -> usize {
603        self.chain_group_index
604    }
605
606    /// Return the underlying LND daemon handle.
607    pub fn lnd(&self) -> &LndDaemon {
608        &self.daemon
609    }
610
611    /// Build an `lnd_grpc_rust` node connection config for this node.
612    pub fn node_config(&self) -> LndNodeConfig {
613        self.daemon.node_config()
614    }
615
616    /// Return the LND identity public key.
617    pub fn public_key(&self) -> &str {
618        &self.daemon.public_key
619    }
620}
621
622/// Result of connecting one LND node to another.
623#[derive(Clone, Debug, Eq, PartialEq)]
624pub struct PeerConnection {
625    /// Source node alias.
626    pub from_alias: String,
627    /// Destination node alias.
628    pub to_alias: String,
629    /// Destination node identity public key.
630    pub public_key: String,
631    /// Destination P2P socket used for the connection.
632    pub socket: String,
633    /// LND connection status string.
634    pub status: String,
635}
636
637/// Result of funding an LND wallet.
638#[derive(Clone, Debug, PartialEq)]
639pub struct FundingReport {
640    /// Funded node alias.
641    pub alias: String,
642    /// On-chain address generated by the funded node.
643    pub address: String,
644    /// Funding transaction id.
645    pub txid: String,
646    /// Funding amount in BTC.
647    pub amount_btc: f64,
648    /// Block hashes mined to confirm the funding transaction.
649    pub confirmation_blocks: Vec<String>,
650    /// Confirmed LND wallet balance after funding.
651    pub confirmed_balance_sat: i64,
652    /// Spendable UTXO count after funding.
653    pub spendable_utxo_count: usize,
654    /// Total spendable UTXO value after funding.
655    pub spendable_utxo_total_sat: i64,
656}
657
658#[derive(Clone, Debug, Eq, PartialEq)]
659struct FundingRecipient {
660    alias: String,
661    address: String,
662    required_balance_sat: i64,
663    required_utxo_total_sat: i64,
664}
665
666/// Result of opening and confirming a public Lightning channel.
667#[derive(Clone, Debug, PartialEq)]
668pub struct ChannelReport {
669    /// Channel opener alias.
670    pub from_alias: String,
671    /// Remote node alias.
672    pub to_alias: String,
673    /// Channel point in `funding_txid:output_index` form.
674    pub channel_point: String,
675    /// Local funding amount in satoshis.
676    pub local_funding_amount_sat: i64,
677    /// Block hashes mined to confirm the funding transaction.
678    pub confirmation_blocks: Vec<String>,
679    /// Channel as reported by the opener.
680    pub from_channel: Channel,
681    /// Channel as reported by the remote node.
682    pub to_channel: Channel,
683}
684
685/// Error returned by cluster lifecycle and orchestration operations.
686#[derive(Debug, Error)]
687pub enum SpawnError {
688    /// Invalid cluster configuration.
689    #[error(transparent)]
690    Config(#[from] ConfigError),
691
692    /// Docker operation failed.
693    #[error(transparent)]
694    Docker(#[from] Box<DockerError>),
695
696    /// Failed to spawn a Bitcoin Core chain group.
697    #[error("failed to spawn Bitcoin Core chain group {group_index}")]
698    BitcoinCore {
699        /// Chain group index.
700        group_index: usize,
701        /// Underlying Bitcoin Core error.
702        source: Box<BitcoinCoreError>,
703    },
704
705    /// Failed to connect two Bitcoin Core chain groups.
706    #[error("failed to connect Bitcoin Core chain group {from_group} to group {to_group}")]
707    BitcoinPeer {
708        /// Source chain group index.
709        from_group: usize,
710        /// Destination chain group index.
711        to_group: usize,
712        /// Underlying Bitcoin RPC error.
713        source: Box<BitcoinRpcError>,
714    },
715
716    /// Bitcoin Core RPC failed for a chain group.
717    #[error("Bitcoin Core RPC failed for chain group {group_index}")]
718    BitcoinRpc {
719        /// Chain group index.
720        group_index: usize,
721        /// Underlying Bitcoin RPC error.
722        source: Box<BitcoinRpcError>,
723    },
724
725    /// Bitcoin Core chain groups did not converge on a common tip.
726    #[error(
727        "Bitcoin Core chain groups did not sync to a common tip after {attempts} attempts; last tips: {last_tips:?}"
728    )]
729    BitcoinSyncTimeout {
730        /// Number of sync attempts.
731        attempts: usize,
732        /// Last observed best block hashes.
733        last_tips: Vec<String>,
734    },
735
736    /// A Bitcoin Core container had no Docker bridge IP.
737    #[error("Bitcoin Core chain group {group_index} did not expose a bridge IP address")]
738    MissingBitcoindIp {
739        /// Chain group index.
740        group_index: usize,
741    },
742
743    /// A requested LND node alias does not exist.
744    #[error("unknown LND node alias: {alias}")]
745    UnknownNode {
746        /// Missing alias.
747        alias: String,
748    },
749
750    /// A requested Bitcoin Core chain group does not exist.
751    #[error("unknown Bitcoin Core chain group: {group_index}")]
752    UnknownBitcoindGroup {
753        /// Missing chain group index.
754        group_index: usize,
755    },
756
757    /// A funding amount was not positive and finite.
758    #[error("funding amount must be positive and finite, got {amount_btc} BTC")]
759    InvalidFundingAmount {
760        /// Invalid amount in BTC.
761        amount_btc: f64,
762    },
763
764    /// An LND container had no Docker bridge IP.
765    #[error("LND node {alias} did not expose a bridge IP address")]
766    MissingLndIp {
767        /// Node alias.
768        alias: String,
769    },
770
771    /// LND operation failed.
772    #[error("failed to spawn LND node {alias}")]
773    Lnd {
774        /// Node alias.
775        alias: String,
776        /// Underlying LND error.
777        source: Box<LndError>,
778    },
779
780    /// Connecting to all LND nodes failed.
781    #[error(transparent)]
782    ConnectNodes(#[from] LndConnectError),
783
784    /// The managed Docker network did not report a usable IPv4 subnet.
785    #[error("cluster network subnet {subnet} is not usable for static IP assignment: {message}")]
786    InvalidClusterNetworkSubnet {
787        /// Docker network subnet.
788        subnet: String,
789        /// Validation failure.
790        message: String,
791    },
792
793    /// The managed Docker network subnet was too small for this cluster.
794    #[error(
795        "cluster network subnet {subnet} cannot assign static IP offset {offset}; largest usable offset is {largest_usable_offset}"
796    )]
797    StaticIpUnavailable {
798        /// Docker network subnet.
799        subnet: String,
800        /// Requested host offset.
801        offset: u32,
802        /// Largest usable host offset in the subnet.
803        largest_usable_offset: u32,
804    },
805
806    /// Startup failed and the attempted cleanup also failed.
807    #[error(
808        "startup failed for cluster {cluster_id}, then cleanup failed; startup error: {startup_error}"
809    )]
810    StartupCleanup {
811        /// Cluster id.
812        cluster_id: String,
813        /// Original startup error as text.
814        startup_error: String,
815        /// Cleanup failure.
816        source: Box<DockerError>,
817    },
818}
819
820async fn spawn_inner(
821    docker: DockerClient,
822    config: SpawnLndConfig,
823    cluster_id: String,
824) -> Result<SpawnedCluster, SpawnError> {
825    let network = create_cluster_network(&docker, &config, &cluster_id).await?;
826    let subnet = Ipv4Subnet::parse(&network.subnet).map_err(|message| {
827        SpawnError::InvalidClusterNetworkSubnet {
828            subnet: network.subnet.clone(),
829            message,
830        }
831    })?;
832
833    ensure_static_ip_capacity(&config, &subnet)?;
834
835    let bitcoinds = spawn_bitcoinds(&docker, &config, &cluster_id, &network, &subnet).await?;
836    connect_bitcoind_groups(&bitcoinds).await?;
837    prepare_primary_wallet(&bitcoinds).await?;
838    wait_bitcoind_groups_synced(&bitcoinds, &config.startup_retry).await?;
839    let (nodes, node_order) =
840        spawn_lnd_nodes(&docker, &config, &cluster_id, &network, &subnet, &bitcoinds).await?;
841    wait_bitcoind_groups_synced(&bitcoinds, &config.startup_retry).await?;
842    wait_lnd_nodes_synced(&nodes, &node_order, &config.startup_retry).await?;
843
844    Ok(SpawnedCluster {
845        docker,
846        config,
847        cluster_id,
848        network,
849        bitcoinds,
850        nodes,
851        node_order,
852        shutdown: false,
853    })
854}
855
856async fn create_cluster_network(
857    docker: &DockerClient,
858    config: &SpawnLndConfig,
859    cluster_id: &str,
860) -> Result<ManagedNetwork, SpawnError> {
861    if let Some(subnet) = &config.cluster_subnet {
862        let spec = NetworkSpec::new(cluster_id).subnet(subnet.clone());
863        return docker.create_network(spec).await.map_err(SpawnError::from);
864    }
865
866    let mut last_overlap = None;
867    for attempt in 0..GENERATED_SUBNET_ATTEMPTS {
868        let subnet = generated_cluster_subnet(cluster_id, attempt);
869        let spec = NetworkSpec::new(cluster_id).subnet(subnet);
870
871        match docker.create_network(spec).await {
872            Ok(network) => return Ok(network),
873            Err(error) if error.is_network_pool_overlap() => {
874                last_overlap = Some(error);
875            }
876            Err(error) => return Err(SpawnError::from(error)),
877        }
878    }
879
880    Err(SpawnError::from(
881        last_overlap.expect("at least one generated subnet attempt"),
882    ))
883}
884
885async fn spawn_bitcoinds(
886    docker: &DockerClient,
887    config: &SpawnLndConfig,
888    cluster_id: &str,
889    network: &ManagedNetwork,
890    subnet: &Ipv4Subnet,
891) -> Result<Vec<BitcoinCore>, SpawnError> {
892    let mut bitcoinds = Vec::with_capacity(config.chain_group_count());
893
894    for group_index in 0..config.chain_group_count() {
895        let bitcoind = BitcoinCore::spawn(
896            docker,
897            BitcoinCoreConfig::new(cluster_id, group_index)
898                .image(config.bitcoind_image.clone())
899                .startup_retry_policy(config.startup_retry)
900                .network(network.name.clone())
901                .ipv4_address(static_bitcoind_ip(subnet, group_index)?),
902        )
903        .await
904        .map_err(|source| SpawnError::BitcoinCore {
905            group_index,
906            source: Box::new(source),
907        })?;
908        bitcoinds.push(bitcoind);
909    }
910
911    Ok(bitcoinds)
912}
913
914async fn connect_bitcoind_groups(bitcoinds: &[BitcoinCore]) -> Result<(), SpawnError> {
915    for (from_group, from) in bitcoinds.iter().enumerate() {
916        for (to_group, to) in bitcoinds.iter().enumerate() {
917            if from_group == to_group {
918                continue;
919            }
920
921            let socket = bitcoind_bridge_socket(to_group, to)?;
922            from.rpc
923                .add_node(&socket)
924                .await
925                .map_err(|source| SpawnError::BitcoinPeer {
926                    from_group,
927                    to_group,
928                    source: Box::new(source),
929                })?;
930        }
931    }
932
933    Ok(())
934}
935
936async fn prepare_primary_wallet(bitcoinds: &[BitcoinCore]) -> Result<(), SpawnError> {
937    bitcoinds[0]
938        .prepare_mining_wallet()
939        .await
940        .map_err(|source| SpawnError::BitcoinCore {
941            group_index: 0,
942            source: Box::new(source),
943        })?;
944
945    Ok(())
946}
947
948async fn wait_bitcoind_groups_synced(
949    bitcoinds: &[BitcoinCore],
950    policy: &RetryPolicy,
951) -> Result<(), SpawnError> {
952    if bitcoinds.len() <= 1 {
953        return Ok(());
954    }
955
956    let mut last_tips = Vec::new();
957
958    for _ in 0..policy.attempts {
959        let mut tips = Vec::with_capacity(bitcoinds.len());
960
961        for (group_index, bitcoind) in bitcoinds.iter().enumerate() {
962            let info = bitcoind.rpc.get_blockchain_info().await.map_err(|source| {
963                SpawnError::BitcoinRpc {
964                    group_index,
965                    source: Box::new(source),
966                }
967            })?;
968            tips.push((info.blocks, info.bestblockhash));
969        }
970
971        last_tips = tips
972            .iter()
973            .map(|(height, hash)| format!("{height}:{hash}"))
974            .collect();
975
976        if let Some((target_height, target_hash)) = tips.iter().max_by_key(|(height, _)| *height)
977            && tips
978                .iter()
979                .all(|(height, hash)| height == target_height && hash == target_hash)
980        {
981            return Ok(());
982        }
983
984        sleep(policy.interval()).await;
985    }
986
987    Err(SpawnError::BitcoinSyncTimeout {
988        attempts: policy.attempts,
989        last_tips,
990    })
991}
992
993async fn spawn_lnd_nodes(
994    docker: &DockerClient,
995    config: &SpawnLndConfig,
996    cluster_id: &str,
997    network: &ManagedNetwork,
998    subnet: &Ipv4Subnet,
999    bitcoinds: &[BitcoinCore],
1000) -> Result<(HashMap<String, SpawnedNode>, Vec<String>), SpawnError> {
1001    let mut nodes = HashMap::with_capacity(config.nodes.len());
1002    let mut node_order = Vec::with_capacity(config.nodes.len());
1003
1004    for (node_index, node_config) in config.nodes.iter().enumerate() {
1005        let chain_group_index = chain_group_index(node_index, config.nodes_per_bitcoind);
1006        let bitcoind = &bitcoinds[chain_group_index];
1007        let lnd_config = lnd_config(cluster_id, node_index, node_config, config, network, subnet)?;
1008        let daemon = LndDaemon::spawn_with_startup_cleanup(
1009            docker,
1010            bitcoind,
1011            lnd_config,
1012            !config.keep_containers,
1013        )
1014        .await
1015        .map_err(|source| SpawnError::Lnd {
1016            alias: node_config.alias.clone(),
1017            source: Box::new(source),
1018        })?;
1019        wait_bitcoind_groups_synced(bitcoinds, &config.startup_retry).await?;
1020        let node = SpawnedNode::new(node_index, chain_group_index, daemon);
1021
1022        node_order.push(node.alias.clone());
1023        nodes.insert(node.alias.clone(), node);
1024    }
1025
1026    Ok((nodes, node_order))
1027}
1028
1029async fn wait_lnd_nodes_synced(
1030    nodes: &HashMap<String, SpawnedNode>,
1031    node_order: &[String],
1032    policy: &RetryPolicy,
1033) -> Result<(), SpawnError> {
1034    for alias in node_order {
1035        let node = &nodes[alias];
1036        node.daemon
1037            .wait_synced_to_chain_with_policy(policy)
1038            .await
1039            .map_err(|source| SpawnError::Lnd {
1040                alias: alias.clone(),
1041                source: Box::new(source),
1042            })?;
1043    }
1044
1045    Ok(())
1046}
1047
1048async fn wait_lnd_nodes_in_group_synced(
1049    nodes: &HashMap<String, SpawnedNode>,
1050    node_order: &[String],
1051    group_index: usize,
1052    policy: &RetryPolicy,
1053) -> Result<(), SpawnError> {
1054    for alias in node_order {
1055        let node = &nodes[alias];
1056        if node.chain_group_index != group_index {
1057            continue;
1058        }
1059
1060        node.daemon
1061            .wait_synced_to_chain_with_policy(policy)
1062            .await
1063            .map_err(|source| SpawnError::Lnd {
1064                alias: alias.clone(),
1065                source: Box::new(source),
1066            })?;
1067    }
1068
1069    Ok(())
1070}
1071
1072fn lnd_config(
1073    cluster_id: &str,
1074    node_index: usize,
1075    node_config: &NodeConfig,
1076    config: &SpawnLndConfig,
1077    network: &ManagedNetwork,
1078    subnet: &Ipv4Subnet,
1079) -> Result<LndConfig, SpawnError> {
1080    Ok(
1081        LndConfig::new(cluster_id, node_config.alias.clone(), node_index)
1082            .image(config.lnd_image.clone())
1083            .extra_args(node_config.lnd_args.clone())
1084            .startup_retry_policy(config.startup_retry)
1085            .network(network.name.clone())
1086            .ipv4_address(static_lnd_ip(
1087                subnet,
1088                config.chain_group_count(),
1089                node_index,
1090            )?),
1091    )
1092}
1093
1094fn chain_group_index(node_index: usize, nodes_per_bitcoind: usize) -> usize {
1095    node_index / nodes_per_bitcoind
1096}
1097
1098fn btc_to_sat(amount_btc: f64) -> Result<i64, SpawnError> {
1099    if !amount_btc.is_finite() || amount_btc <= 0.0 {
1100        return Err(SpawnError::InvalidFundingAmount { amount_btc });
1101    }
1102
1103    let amount_sat = (amount_btc * SATOSHIS_PER_BTC).round();
1104    if amount_sat < 1.0 || amount_sat > i64::MAX as f64 {
1105        return Err(SpawnError::InvalidFundingAmount { amount_btc });
1106    }
1107
1108    Ok(amount_sat as i64)
1109}
1110
1111#[derive(Clone, Debug, Eq, PartialEq)]
1112struct Ipv4Subnet {
1113    cidr: String,
1114    network: u32,
1115    prefix: u8,
1116}
1117
1118impl Ipv4Subnet {
1119    fn parse(cidr: &str) -> Result<Self, String> {
1120        let (address, prefix) = cidr
1121            .split_once('/')
1122            .ok_or_else(|| "missing CIDR prefix".to_string())?;
1123        let address = address
1124            .parse::<Ipv4Addr>()
1125            .map_err(|error| format!("invalid IPv4 address: {error}"))?;
1126        let prefix = prefix
1127            .parse::<u8>()
1128            .map_err(|error| format!("invalid prefix length: {error}"))?;
1129        if prefix > 30 {
1130            return Err("prefix must be 30 or less".to_string());
1131        }
1132
1133        let mask = ipv4_mask(prefix);
1134        Ok(Self {
1135            cidr: cidr.to_string(),
1136            network: u32::from(address) & mask,
1137            prefix,
1138        })
1139    }
1140
1141    fn static_ip(&self, offset: u32) -> Result<String, SpawnError> {
1142        let largest_usable_offset = self.largest_usable_offset();
1143        if offset < 2 || offset > largest_usable_offset {
1144            return Err(SpawnError::StaticIpUnavailable {
1145                subnet: self.cidr.clone(),
1146                offset,
1147                largest_usable_offset,
1148            });
1149        }
1150
1151        Ok(Ipv4Addr::from(self.network + offset).to_string())
1152    }
1153
1154    fn largest_usable_offset(&self) -> u32 {
1155        let size = 1u64 << (32 - self.prefix);
1156        size.saturating_sub(2).min(u32::MAX as u64) as u32
1157    }
1158}
1159
1160fn ipv4_mask(prefix: u8) -> u32 {
1161    if prefix == 0 {
1162        0
1163    } else {
1164        u32::MAX << (32 - prefix)
1165    }
1166}
1167
1168fn ensure_static_ip_capacity(
1169    config: &SpawnLndConfig,
1170    subnet: &Ipv4Subnet,
1171) -> Result<(), SpawnError> {
1172    let required_offset = first_static_ip_offset()
1173        .checked_add(config.chain_group_count() as u32)
1174        .and_then(|offset| offset.checked_add(config.nodes.len() as u32))
1175        .and_then(|offset| offset.checked_sub(1))
1176        .ok_or_else(|| SpawnError::StaticIpUnavailable {
1177            subnet: subnet.cidr.clone(),
1178            offset: u32::MAX,
1179            largest_usable_offset: subnet.largest_usable_offset(),
1180        })?;
1181
1182    subnet.static_ip(required_offset).map(|_| ())
1183}
1184
1185fn static_bitcoind_ip(subnet: &Ipv4Subnet, group_index: usize) -> Result<String, SpawnError> {
1186    let offset = first_static_ip_offset()
1187        .checked_add(group_index as u32)
1188        .ok_or_else(|| SpawnError::StaticIpUnavailable {
1189            subnet: subnet.cidr.clone(),
1190            offset: u32::MAX,
1191            largest_usable_offset: subnet.largest_usable_offset(),
1192        })?;
1193    subnet.static_ip(offset)
1194}
1195
1196fn static_lnd_ip(
1197    subnet: &Ipv4Subnet,
1198    chain_group_count: usize,
1199    node_index: usize,
1200) -> Result<String, SpawnError> {
1201    let offset = first_static_ip_offset()
1202        .checked_add(chain_group_count as u32)
1203        .and_then(|offset| offset.checked_add(node_index as u32))
1204        .ok_or_else(|| SpawnError::StaticIpUnavailable {
1205            subnet: subnet.cidr.clone(),
1206            offset: u32::MAX,
1207            largest_usable_offset: subnet.largest_usable_offset(),
1208        })?;
1209    subnet.static_ip(offset)
1210}
1211
1212fn first_static_ip_offset() -> u32 {
1213    10
1214}
1215
1216fn generated_cluster_subnet(cluster_id: &str, attempt: u32) -> String {
1217    let slot = fnv1a_u32(cluster_id, attempt) % (64 * 16);
1218    let second_octet = 64 + slot / 16;
1219    let third_octet = (slot % 16) * 16;
1220
1221    format!("10.{second_octet}.{third_octet}.0/20")
1222}
1223
1224fn fnv1a_u32(input: &str, attempt: u32) -> u32 {
1225    let mut hash = 0x811c9dc5u32;
1226    for byte in input
1227        .as_bytes()
1228        .iter()
1229        .copied()
1230        .chain(attempt.to_le_bytes())
1231    {
1232        hash ^= byte as u32;
1233        hash = hash.wrapping_mul(0x01000193);
1234    }
1235
1236    hash
1237}
1238
1239fn bitcoind_bridge_socket(
1240    group_index: usize,
1241    bitcoind: &BitcoinCore,
1242) -> Result<String, SpawnError> {
1243    let ip = bitcoind
1244        .container
1245        .ip_address
1246        .as_deref()
1247        .ok_or(SpawnError::MissingBitcoindIp { group_index })?;
1248
1249    Ok(format!("{ip}:{BITCOIND_P2P_PORT}"))
1250}
1251
1252fn lnd_bridge_socket(node: &SpawnedNode) -> Result<String, SpawnError> {
1253    let ip =
1254        node.daemon
1255            .container
1256            .ip_address
1257            .as_deref()
1258            .ok_or_else(|| SpawnError::MissingLndIp {
1259                alias: node.alias.clone(),
1260            })?;
1261
1262    Ok(format!("{ip}:{LND_P2P_PORT}"))
1263}
1264
1265fn already_connected_response(
1266    error: LndError,
1267    public_key: &str,
1268) -> Result<ConnectPeerResponse, LndError> {
1269    match error {
1270        LndError::Rpc { message, .. } if message.contains("already connected") => {
1271            Ok(ConnectPeerResponse {
1272                status: format!("already connected to {public_key}"),
1273            })
1274        }
1275        error => Err(error),
1276    }
1277}
1278
1279fn new_cluster_id() -> String {
1280    format!("cluster-{}", Uuid::new_v4().simple())
1281}
1282
1283fn empty_cleanup_report() -> CleanupReport {
1284    CleanupReport {
1285        matched: 0,
1286        removed: 0,
1287        failures: Vec::new(),
1288    }
1289}
1290
1291#[cfg(test)]
1292mod tests {
1293    use super::{
1294        Ipv4Subnet, SpawnedCluster, already_connected_response, btc_to_sat, chain_group_index,
1295        empty_cleanup_report, generated_cluster_subnet, lnd_config, static_bitcoind_ip,
1296        static_lnd_ip,
1297    };
1298    use crate::{DockerClient, LndError, ManagedNetwork};
1299    use crate::{NodeConfig, RetryPolicy, SpawnLndConfig};
1300
1301    #[test]
1302    fn assigns_nodes_to_chain_groups() {
1303        let groups = (0..8)
1304            .map(|node_index| chain_group_index(node_index, 3))
1305            .collect::<Vec<_>>();
1306
1307        assert_eq!(groups, [0, 0, 0, 1, 1, 1, 2, 2]);
1308    }
1309
1310    #[test]
1311    fn builds_lnd_config_from_node_config() {
1312        let node = NodeConfig::new("alice").with_lnd_args(["--alias=Alice", "--color=#3399ff"]);
1313        let spawn_config = SpawnLndConfig {
1314            nodes: vec![node.clone()],
1315            bitcoind_image: "custom/bitcoin:30".to_string(),
1316            lnd_image: "custom/lnd:v1".to_string(),
1317            nodes_per_bitcoind: 3,
1318            keep_containers: false,
1319            startup_retry: RetryPolicy::new(12, 250),
1320            cluster_subnet: None,
1321        };
1322        let network = ManagedNetwork {
1323            id: "network-id".to_string(),
1324            name: "spawn-lnd-cluster-1".to_string(),
1325            subnet: "172.28.0.0/16".to_string(),
1326        };
1327        let subnet = Ipv4Subnet::parse(&network.subnet).expect("valid subnet");
1328        let config =
1329            lnd_config("cluster-1", 2, &node, &spawn_config, &network, &subnet).expect("config");
1330
1331        assert_eq!(config.cluster_id, "cluster-1");
1332        assert_eq!(config.alias, "alice");
1333        assert_eq!(config.node_index, 2);
1334        assert_eq!(config.image, "custom/lnd:v1");
1335        assert_eq!(config.extra_args, ["--alias=Alice", "--color=#3399ff"]);
1336        assert_eq!(config.startup_retry, RetryPolicy::new(12, 250));
1337        assert_eq!(config.network.as_deref(), Some("spawn-lnd-cluster-1"));
1338        assert_eq!(config.ipv4_address.as_deref(), Some("172.28.0.13"));
1339    }
1340
1341    #[test]
1342    fn assigns_static_ips_from_network_subnet() {
1343        let subnet = Ipv4Subnet::parse("172.28.0.0/16").expect("valid subnet");
1344
1345        assert_eq!(static_bitcoind_ip(&subnet, 0).unwrap(), "172.28.0.10");
1346        assert_eq!(static_bitcoind_ip(&subnet, 1).unwrap(), "172.28.0.11");
1347        assert_eq!(static_lnd_ip(&subnet, 2, 0).unwrap(), "172.28.0.12");
1348        assert_eq!(static_lnd_ip(&subnet, 2, 1).unwrap(), "172.28.0.13");
1349    }
1350
1351    #[test]
1352    fn generates_private_cluster_subnets_with_user_configured_prefixes() {
1353        let first = generated_cluster_subnet("cluster-1", 0);
1354        let second = generated_cluster_subnet("cluster-1", 1);
1355
1356        assert_ne!(first, second);
1357        assert!(first.starts_with("10."));
1358        assert!(first.ends_with(".0/20"));
1359        assert!(Ipv4Subnet::parse(&first).is_ok());
1360    }
1361
1362    #[test]
1363    fn validates_lifecycle_targets() {
1364        let docker = DockerClient::from_bollard(
1365            bollard::Docker::connect_with_http(
1366                "http://127.0.0.1:65535",
1367                1,
1368                bollard::API_DEFAULT_VERSION,
1369            )
1370            .expect("construct Docker client"),
1371        );
1372        let cluster = SpawnedCluster {
1373            docker,
1374            config: SpawnLndConfig {
1375                nodes: vec![NodeConfig::new("alice")],
1376                bitcoind_image: "custom/bitcoin:30".to_string(),
1377                lnd_image: "custom/lnd:v1".to_string(),
1378                nodes_per_bitcoind: 3,
1379                keep_containers: false,
1380                startup_retry: RetryPolicy::default(),
1381                cluster_subnet: None,
1382            },
1383            cluster_id: "cluster-1".to_string(),
1384            network: ManagedNetwork {
1385                id: "network-id".to_string(),
1386                name: "spawn-lnd-cluster-1".to_string(),
1387                subnet: "172.28.0.0/16".to_string(),
1388            },
1389            bitcoinds: Vec::new(),
1390            nodes: Default::default(),
1391            node_order: Vec::new(),
1392            shutdown: true,
1393        };
1394
1395        assert!(matches!(
1396            cluster.require_node("alice"),
1397            Err(super::SpawnError::UnknownNode { alias }) if alias == "alice"
1398        ));
1399        assert!(matches!(
1400            cluster.require_bitcoind(0),
1401            Err(super::SpawnError::UnknownBitcoindGroup { group_index: 0 })
1402        ));
1403        assert_eq!(empty_cleanup_report().removed, 0);
1404    }
1405
1406    #[test]
1407    fn treats_already_connected_peer_as_success() {
1408        let response = already_connected_response(
1409            LndError::Rpc {
1410                socket: "127.0.0.1:10009".to_string(),
1411                method: "ConnectPeer",
1412                message: "already connected to peer".to_string(),
1413            },
1414            "pubkey",
1415        )
1416        .expect("already connected is success");
1417
1418        assert_eq!(response.status, "already connected to pubkey");
1419    }
1420
1421    #[test]
1422    fn converts_btc_amount_to_sats() {
1423        assert_eq!(btc_to_sat(1.0).expect("sats"), 100_000_000);
1424        assert_eq!(btc_to_sat(0.000_000_01).expect("sats"), 1);
1425        assert!(btc_to_sat(0.0).is_err());
1426        assert!(btc_to_sat(f64::NAN).is_err());
1427    }
1428}