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
18pub const DEFAULT_FUNDING_AMOUNT_BTC: f64 = 1.0;
20pub const DEFAULT_FUNDING_CONFIRMATION_BLOCKS: u64 = 1;
22pub const DEFAULT_CHANNEL_CAPACITY_SAT: i64 = 100_000;
24pub const DEFAULT_CHANNEL_CONFIRMATION_BLOCKS: u64 = 6;
26const SATOSHIS_PER_BTC: f64 = 100_000_000.0;
27const GENERATED_SUBNET_ATTEMPTS: u32 = 64;
28
29#[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 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 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 pub fn cluster_id(&self) -> &str {
89 &self.cluster_id
90 }
91
92 pub fn config(&self) -> &SpawnLndConfig {
94 &self.config
95 }
96
97 pub fn network(&self) -> &ManagedNetwork {
99 &self.network
100 }
101
102 pub fn bitcoinds(&self) -> &[BitcoinCore] {
104 &self.bitcoinds
105 }
106
107 pub fn node(&self, alias: &str) -> Option<&SpawnedNode> {
109 self.nodes.get(alias)
110 }
111
112 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 pub fn node_aliases(&self) -> impl Iterator<Item = &str> {
121 self.node_order.iter().map(String::as_str)
122 }
123
124 pub fn node_configs(&self) -> Vec<LndNodeConfig> {
126 self.nodes().map(SpawnedNode::node_config).collect()
127 }
128
129 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 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 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 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 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 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 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 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 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 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 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 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 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 pub async fn start_bitcoind(&mut self, group_index: usize) -> Result<(), SpawnError> {
475 self.start_bitcoind_inner(group_index, false).await
476 }
477
478 pub async fn restart_bitcoind(&mut self, group_index: usize) -> Result<(), SpawnError> {
480 self.start_bitcoind_inner(group_index, true).await
481 }
482
483 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#[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 pub fn alias(&self) -> &str {
593 &self.alias
594 }
595
596 pub fn node_index(&self) -> usize {
598 self.node_index
599 }
600
601 pub fn chain_group_index(&self) -> usize {
603 self.chain_group_index
604 }
605
606 pub fn lnd(&self) -> &LndDaemon {
608 &self.daemon
609 }
610
611 pub fn node_config(&self) -> LndNodeConfig {
613 self.daemon.node_config()
614 }
615
616 pub fn public_key(&self) -> &str {
618 &self.daemon.public_key
619 }
620}
621
622#[derive(Clone, Debug, Eq, PartialEq)]
624pub struct PeerConnection {
625 pub from_alias: String,
627 pub to_alias: String,
629 pub public_key: String,
631 pub socket: String,
633 pub status: String,
635}
636
637#[derive(Clone, Debug, PartialEq)]
639pub struct FundingReport {
640 pub alias: String,
642 pub address: String,
644 pub txid: String,
646 pub amount_btc: f64,
648 pub confirmation_blocks: Vec<String>,
650 pub confirmed_balance_sat: i64,
652 pub spendable_utxo_count: usize,
654 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#[derive(Clone, Debug, PartialEq)]
668pub struct ChannelReport {
669 pub from_alias: String,
671 pub to_alias: String,
673 pub channel_point: String,
675 pub local_funding_amount_sat: i64,
677 pub confirmation_blocks: Vec<String>,
679 pub from_channel: Channel,
681 pub to_channel: Channel,
683}
684
685#[derive(Debug, Error)]
687pub enum SpawnError {
688 #[error(transparent)]
690 Config(#[from] ConfigError),
691
692 #[error(transparent)]
694 Docker(#[from] Box<DockerError>),
695
696 #[error("failed to spawn Bitcoin Core chain group {group_index}")]
698 BitcoinCore {
699 group_index: usize,
701 source: Box<BitcoinCoreError>,
703 },
704
705 #[error("failed to connect Bitcoin Core chain group {from_group} to group {to_group}")]
707 BitcoinPeer {
708 from_group: usize,
710 to_group: usize,
712 source: Box<BitcoinRpcError>,
714 },
715
716 #[error("Bitcoin Core RPC failed for chain group {group_index}")]
718 BitcoinRpc {
719 group_index: usize,
721 source: Box<BitcoinRpcError>,
723 },
724
725 #[error(
727 "Bitcoin Core chain groups did not sync to a common tip after {attempts} attempts; last tips: {last_tips:?}"
728 )]
729 BitcoinSyncTimeout {
730 attempts: usize,
732 last_tips: Vec<String>,
734 },
735
736 #[error("Bitcoin Core chain group {group_index} did not expose a bridge IP address")]
738 MissingBitcoindIp {
739 group_index: usize,
741 },
742
743 #[error("unknown LND node alias: {alias}")]
745 UnknownNode {
746 alias: String,
748 },
749
750 #[error("unknown Bitcoin Core chain group: {group_index}")]
752 UnknownBitcoindGroup {
753 group_index: usize,
755 },
756
757 #[error("funding amount must be positive and finite, got {amount_btc} BTC")]
759 InvalidFundingAmount {
760 amount_btc: f64,
762 },
763
764 #[error("LND node {alias} did not expose a bridge IP address")]
766 MissingLndIp {
767 alias: String,
769 },
770
771 #[error("failed to spawn LND node {alias}")]
773 Lnd {
774 alias: String,
776 source: Box<LndError>,
778 },
779
780 #[error(transparent)]
782 ConnectNodes(#[from] LndConnectError),
783
784 #[error("cluster network subnet {subnet} is not usable for static IP assignment: {message}")]
786 InvalidClusterNetworkSubnet {
787 subnet: String,
789 message: String,
791 },
792
793 #[error(
795 "cluster network subnet {subnet} cannot assign static IP offset {offset}; largest usable offset is {largest_usable_offset}"
796 )]
797 StaticIpUnavailable {
798 subnet: String,
800 offset: u32,
802 largest_usable_offset: u32,
804 },
805
806 #[error(
808 "startup failed for cluster {cluster_id}, then cleanup failed; startup error: {startup_error}"
809 )]
810 StartupCleanup {
811 cluster_id: String,
813 startup_error: String,
815 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}