Skip to main content

spawn_lnd/
lnd.rs

1use hyper::Uri;
2use lnd_grpc_rust::{
3    LndClient, LndNodeConfig, MyChannel,
4    lnrpc::{
5        AddressType, Channel, ChannelPoint, ConnectPeerRequest, ConnectPeerResponse,
6        GenSeedRequest, GetInfoRequest, GetInfoResponse, InitWalletRequest, LightningAddress,
7        ListChannelsRequest, ListUnspentRequest, NewAddressRequest, OpenChannelRequest,
8        PendingChannelsRequest, PendingChannelsResponse, Utxo, WalletBalanceRequest,
9        WalletBalanceResponse, wallet_unlocker_client::WalletUnlockerClient,
10    },
11};
12use serde::{Deserialize, Serialize};
13use thiserror::Error;
14use tokio::time::{Duration, sleep};
15
16use crate::{
17    BitcoinCore, DEFAULT_LND_IMAGE, RetryPolicy,
18    bitcoin::BITCOIND_RPC_PORT,
19    docker::{
20        ContainerRole, ContainerSpec, DockerClient, DockerError, SpawnedContainer,
21        managed_container_labels,
22    },
23};
24
25/// LND gRPC port exposed inside the Docker container.
26pub const LND_GRPC_PORT: u16 = 10009;
27/// LND P2P port exposed inside the Docker container.
28pub const LND_P2P_PORT: u16 = 9735;
29/// Path to the TLS certificate inside the LND container.
30pub const LND_TLS_CERT_PATH: &str = "/root/.lnd/tls.cert";
31/// Path to the admin macaroon inside the LND container.
32pub const LND_ADMIN_MACAROON_PATH: &str = "/root/.lnd/data/chain/bitcoin/regtest/admin.macaroon";
33/// Fixed wallet password used for spawned regtest LND nodes.
34pub const LND_WALLET_PASSWORD: &[u8] = b"password";
35/// Static regtest address used for internal block generation.
36pub const DEFAULT_GENERATE_ADDRESS: &str = "2N8hwP1WmJrFF5QWABn38y63uYLhnJYJYTF";
37
38const READY_RETRY_ATTEMPTS: usize = 500;
39const READY_RETRY_INTERVAL: Duration = Duration::from_millis(100);
40const MAX_UTXO_CONFIRMATIONS: i32 = i32::MAX;
41
42/// Configuration for one spawned LND node.
43#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
44pub struct LndConfig {
45    /// Cluster identifier used in container names and labels.
46    pub cluster_id: String,
47    /// LND alias.
48    pub alias: String,
49    /// Zero-based node index in spawn order.
50    pub node_index: usize,
51    /// Docker image used for this LND container.
52    pub image: String,
53    /// Extra command-line flags appended to the LND command.
54    pub extra_args: Vec<String>,
55    /// Retry policy used while waiting for wallet init and chain sync.
56    pub startup_retry: RetryPolicy,
57}
58
59impl LndConfig {
60    /// Create an LND config using the default pinned image.
61    pub fn new(cluster_id: impl Into<String>, alias: impl Into<String>, node_index: usize) -> Self {
62        Self {
63            cluster_id: cluster_id.into(),
64            alias: alias.into(),
65            node_index,
66            image: DEFAULT_LND_IMAGE.to_string(),
67            extra_args: Vec::new(),
68            startup_retry: RetryPolicy::default(),
69        }
70    }
71
72    /// Override the LND Docker image.
73    pub fn image(mut self, image: impl Into<String>) -> Self {
74        self.image = image.into();
75        self
76    }
77
78    /// Append one extra LND command-line argument.
79    pub fn extra_arg(mut self, arg: impl Into<String>) -> Self {
80        self.extra_args.push(arg.into());
81        self
82    }
83
84    /// Append multiple extra LND command-line arguments.
85    pub fn extra_args<I, S>(mut self, args: I) -> Self
86    where
87        I: IntoIterator<Item = S>,
88        S: Into<String>,
89    {
90        self.extra_args.extend(args.into_iter().map(Into::into));
91        self
92    }
93
94    /// Override the startup retry policy.
95    pub fn startup_retry_policy(mut self, policy: RetryPolicy) -> Self {
96        self.startup_retry = policy;
97        self
98    }
99}
100
101/// A running LND container and authenticated connection material.
102#[derive(Clone, Debug)]
103pub struct LndDaemon {
104    /// LND alias.
105    pub alias: String,
106    /// Docker container metadata.
107    pub container: SpawnedContainer,
108    /// Hex-encoded TLS certificate.
109    pub cert_hex: String,
110    /// Hex-encoded admin macaroon.
111    pub macaroon_hex: String,
112    /// Host gRPC socket, usually `127.0.0.1:<port>`.
113    pub rpc_socket: String,
114    /// Host P2P socket, usually `127.0.0.1:<port>`.
115    pub p2p_socket: String,
116    /// LND identity public key.
117    pub public_key: String,
118}
119
120impl LndDaemon {
121    /// Spawn an LND container, initialize its wallet, and wait for chain sync.
122    pub async fn spawn(
123        docker: &DockerClient,
124        bitcoind: &BitcoinCore,
125        config: LndConfig,
126    ) -> Result<Self, LndError> {
127        Self::spawn_with_startup_cleanup(docker, bitcoind, config, true).await
128    }
129
130    pub(crate) async fn spawn_with_startup_cleanup(
131        docker: &DockerClient,
132        bitcoind: &BitcoinCore,
133        config: LndConfig,
134        cleanup_on_startup_failure: bool,
135    ) -> Result<Self, LndError> {
136        bitcoind
137            .rpc
138            .generate_to_address(1, DEFAULT_GENERATE_ADDRESS)
139            .await
140            .map_err(LndError::BitcoinRpc)?;
141
142        let spec = lnd_container_spec(&config, bitcoind)?;
143        let container = docker.create_and_start(spec).await?;
144        let container_id = container.id.clone();
145        let alias = config.alias;
146        let result =
147            Self::initialize_started(docker, container, alias.clone(), config.startup_retry).await;
148
149        match result {
150            Ok(daemon) => Ok(daemon),
151            Err(error) => {
152                let logs = docker.container_logs(&container_id).await.ok();
153                if cleanup_on_startup_failure {
154                    let _ = docker.rollback_containers([container_id.clone()]).await;
155                }
156                Err(LndError::Startup {
157                    alias,
158                    container_id,
159                    logs,
160                    source: Box::new(error),
161                })
162            }
163        }
164    }
165
166    async fn initialize_started(
167        docker: &DockerClient,
168        container: SpawnedContainer,
169        alias: String,
170        startup_retry: RetryPolicy,
171    ) -> Result<Self, LndError> {
172        let rpc_port =
173            container
174                .host_port(LND_GRPC_PORT)
175                .ok_or_else(|| LndError::MissingHostPort {
176                    container_id: container.id.clone(),
177                    container_port: LND_GRPC_PORT,
178                })?;
179        let p2p_port =
180            container
181                .host_port(LND_P2P_PORT)
182                .ok_or_else(|| LndError::MissingHostPort {
183                    container_id: container.id.clone(),
184                    container_port: LND_P2P_PORT,
185                })?;
186        let rpc_socket = format!("127.0.0.1:{rpc_port}");
187        let cert_bytes =
188            wait_for_file(docker, &container.id, LND_TLS_CERT_PATH, &startup_retry).await?;
189        let cert_hex = hex::encode(&cert_bytes);
190        let macaroon_hex = init_wallet_or_read_macaroon(
191            docker,
192            &container.id,
193            &cert_bytes,
194            &rpc_socket,
195            &startup_retry,
196        )
197        .await?;
198        let info =
199            wait_for_synced_get_info(&cert_hex, &macaroon_hex, &rpc_socket, &startup_retry).await?;
200
201        Ok(Self {
202            alias,
203            p2p_socket: format!("127.0.0.1:{p2p_port}"),
204            public_key: info.identity_pubkey,
205            cert_hex,
206            macaroon_hex,
207            rpc_socket,
208            container,
209        })
210    }
211
212    /// Build an `lnd_grpc_rust` connection config for this node.
213    pub fn node_config(&self) -> LndNodeConfig {
214        LndNodeConfig::new(
215            self.alias.clone(),
216            self.cert_hex.clone(),
217            self.macaroon_hex.clone(),
218            self.rpc_socket.clone(),
219        )
220    }
221
222    /// Connect to this node using its TLS certificate and admin macaroon.
223    pub async fn connect(&self) -> Result<LndClient, LndError> {
224        connect_authenticated(&self.cert_hex, &self.macaroon_hex, &self.rpc_socket).await
225    }
226
227    /// Wait until `GetInfo` reports `synced_to_chain`.
228    pub async fn wait_synced_to_chain(&self) -> Result<GetInfoResponse, LndError> {
229        self.wait_synced_to_chain_with_policy(&RetryPolicy::default())
230            .await
231    }
232
233    pub(crate) async fn wait_synced_to_chain_with_policy(
234        &self,
235        policy: &RetryPolicy,
236    ) -> Result<GetInfoResponse, LndError> {
237        wait_for_synced_get_info(&self.cert_hex, &self.macaroon_hex, &self.rpc_socket, policy).await
238    }
239
240    /// Generate a new LND wallet address.
241    pub async fn new_address(&self) -> Result<String, LndError> {
242        let mut client = self.connect().await?;
243        let response = client
244            .lightning()
245            .new_address(NewAddressRequest {
246                r#type: AddressType::WitnessPubkeyHash as i32,
247                account: String::new(),
248            })
249            .await
250            .map_err(|error| LndError::rpc(&self.rpc_socket, "NewAddress", error))?
251            .into_inner();
252
253        Ok(response.address)
254    }
255
256    /// Connect this LND node to a peer by public key and host socket.
257    pub async fn connect_peer(
258        &self,
259        public_key: impl Into<String>,
260        host: impl Into<String>,
261    ) -> Result<ConnectPeerResponse, LndError> {
262        let public_key = public_key.into();
263        let host = host.into();
264        let mut last_error = None;
265
266        for _ in 0..READY_RETRY_ATTEMPTS {
267            let mut client = match self.connect().await {
268                Ok(client) => client,
269                Err(error) if error.is_lnd_starting() => {
270                    last_error = Some(error.to_string());
271                    sleep(READY_RETRY_INTERVAL).await;
272                    continue;
273                }
274                Err(error) => return Err(error),
275            };
276            match client
277                .lightning()
278                .connect_peer(ConnectPeerRequest {
279                    addr: Some(LightningAddress {
280                        pubkey: public_key.clone(),
281                        host: host.clone(),
282                    }),
283                    perm: false,
284                    timeout: 10,
285                })
286                .await
287            {
288                Ok(response) => return Ok(response.into_inner()),
289                Err(error) => {
290                    let error = LndError::rpc(&self.rpc_socket, "ConnectPeer", error);
291                    if error.is_lnd_starting() {
292                        last_error = Some(error.to_string());
293                        sleep(READY_RETRY_INTERVAL).await;
294                        continue;
295                    }
296
297                    return Err(error);
298                }
299            }
300        }
301
302        Err(LndError::PeerConnectTimeout {
303            alias: self.alias.clone(),
304            public_key,
305            attempts: READY_RETRY_ATTEMPTS,
306            last_error,
307        })
308    }
309
310    /// Return LND wallet balance with the given minimum confirmations.
311    pub async fn wallet_balance(&self, min_confs: i32) -> Result<WalletBalanceResponse, LndError> {
312        let mut client = self.connect().await?;
313        let response = client
314            .lightning()
315            .wallet_balance(WalletBalanceRequest {
316                account: String::new(),
317                min_confs,
318            })
319            .await
320            .map_err(|error| LndError::rpc(&self.rpc_socket, "WalletBalance", error))?
321            .into_inner();
322
323        Ok(response)
324    }
325
326    /// Return wallet UTXOs matching the confirmation range.
327    pub async fn list_unspent(
328        &self,
329        min_confs: i32,
330        max_confs: i32,
331    ) -> Result<Vec<Utxo>, LndError> {
332        let mut client = self.connect().await?;
333        let response = client
334            .lightning()
335            .list_unspent(ListUnspentRequest {
336                min_confs,
337                max_confs,
338                account: String::new(),
339            })
340            .await
341            .map_err(|error| LndError::rpc(&self.rpc_socket, "ListUnspent", error))?
342            .into_inner();
343
344        Ok(response.utxos)
345    }
346
347    /// Wait until confirmed wallet balance is at least `minimum_sat`.
348    pub async fn wait_for_spendable_balance(
349        &self,
350        minimum_sat: i64,
351    ) -> Result<WalletBalanceResponse, LndError> {
352        let mut last_error = None;
353
354        for _ in 0..READY_RETRY_ATTEMPTS {
355            match self.wallet_balance(1).await {
356                Ok(balance) if balance.confirmed_balance >= minimum_sat => return Ok(balance),
357                Ok(balance) => {
358                    last_error = Some(format!(
359                        "confirmed balance {} is below required {minimum_sat}",
360                        balance.confirmed_balance
361                    ));
362                }
363                Err(error) => last_error = Some(error.to_string()),
364            }
365
366            sleep(READY_RETRY_INTERVAL).await;
367        }
368
369        Err(LndError::BalanceTimeout {
370            alias: self.alias.clone(),
371            minimum_sat,
372            attempts: READY_RETRY_ATTEMPTS,
373            last_error,
374        })
375    }
376
377    /// Wait until spendable UTXOs total at least `minimum_sat`.
378    pub async fn wait_for_spendable_utxos(&self, minimum_sat: i64) -> Result<Vec<Utxo>, LndError> {
379        let mut last_error = None;
380
381        for _ in 0..READY_RETRY_ATTEMPTS {
382            match self.list_unspent(1, MAX_UTXO_CONFIRMATIONS).await {
383                Ok(utxos) if utxo_total_sat(&utxos) >= minimum_sat => return Ok(utxos),
384                Ok(utxos) => {
385                    last_error = Some(format!(
386                        "spendable UTXO total {} is below required {minimum_sat}",
387                        utxo_total_sat(&utxos)
388                    ));
389                }
390                Err(error) => last_error = Some(error.to_string()),
391            }
392
393            sleep(READY_RETRY_INTERVAL).await;
394        }
395
396        Err(LndError::UtxoTimeout {
397            alias: self.alias.clone(),
398            minimum_sat,
399            attempts: READY_RETRY_ATTEMPTS,
400            last_error,
401        })
402    }
403
404    /// Open a public channel synchronously through LND.
405    pub async fn open_channel_sync(
406        &self,
407        remote_public_key: &str,
408        local_funding_amount_sat: i64,
409        push_sat: i64,
410    ) -> Result<ChannelPoint, LndError> {
411        let mut client = self.connect().await?;
412        let remote_public_key =
413            hex::decode(remote_public_key).map_err(|error| LndError::InvalidPublicKey {
414                public_key: remote_public_key.to_string(),
415                message: error.to_string(),
416            })?;
417        let response = client
418            .lightning()
419            .open_channel_sync(OpenChannelRequest {
420                node_pubkey: remote_public_key,
421                local_funding_amount: local_funding_amount_sat,
422                push_sat,
423                target_conf: 1,
424                private: false,
425                min_confs: 1,
426                spend_unconfirmed: false,
427                ..Default::default()
428            })
429            .await
430            .map_err(|error| LndError::rpc(&self.rpc_socket, "OpenChannelSync", error))?
431            .into_inner();
432
433        Ok(response)
434    }
435
436    /// Return LND pending channel state.
437    pub async fn pending_channels(&self) -> Result<PendingChannelsResponse, LndError> {
438        let mut client = self.connect().await?;
439        let response = client
440            .lightning()
441            .pending_channels(PendingChannelsRequest {
442                include_raw_tx: false,
443            })
444            .await
445            .map_err(|error| LndError::rpc(&self.rpc_socket, "PendingChannels", error))?
446            .into_inner();
447
448        Ok(response)
449    }
450
451    /// List channels, optionally filtered by remote public key.
452    pub async fn list_channels(
453        &self,
454        remote_public_key: Option<&str>,
455    ) -> Result<Vec<Channel>, LndError> {
456        let mut client = self.connect().await?;
457        let peer = match remote_public_key {
458            Some(public_key) => {
459                hex::decode(public_key).map_err(|error| LndError::InvalidPublicKey {
460                    public_key: public_key.to_string(),
461                    message: error.to_string(),
462                })?
463            }
464            None => Vec::new(),
465        };
466        let response = client
467            .lightning()
468            .list_channels(ListChannelsRequest {
469                active_only: false,
470                inactive_only: false,
471                public_only: false,
472                private_only: false,
473                peer,
474                peer_alias_lookup: true,
475            })
476            .await
477            .map_err(|error| LndError::rpc(&self.rpc_socket, "ListChannels", error))?
478            .into_inner();
479
480        Ok(response.channels)
481    }
482
483    /// Wait until LND reports a pending channel with the given peer and point.
484    pub async fn wait_for_pending_channel(
485        &self,
486        remote_public_key: &str,
487        channel_point: &str,
488    ) -> Result<(), LndError> {
489        let mut last_error = None;
490
491        for _ in 0..READY_RETRY_ATTEMPTS {
492            match self.pending_channels().await {
493                Ok(pending) if has_pending_channel(&pending, remote_public_key, channel_point) => {
494                    return Ok(());
495                }
496                Ok(pending) => {
497                    last_error = Some(format!(
498                        "pending channels did not include {channel_point}; count={}",
499                        pending.pending_open_channels.len()
500                    ));
501                }
502                Err(error) => last_error = Some(error.to_string()),
503            }
504
505            sleep(READY_RETRY_INTERVAL).await;
506        }
507
508        Err(LndError::PendingChannelTimeout {
509            alias: self.alias.clone(),
510            remote_public_key: remote_public_key.to_string(),
511            channel_point: channel_point.to_string(),
512            attempts: READY_RETRY_ATTEMPTS,
513            last_error,
514        })
515    }
516
517    /// Wait until LND reports an active channel with the given peer and point.
518    pub async fn wait_for_active_channel(
519        &self,
520        remote_public_key: &str,
521        channel_point: &str,
522    ) -> Result<Channel, LndError> {
523        let mut last_error = None;
524
525        for _ in 0..READY_RETRY_ATTEMPTS {
526            match self.list_channels(Some(remote_public_key)).await {
527                Ok(channels) => {
528                    if let Some(channel) = channels
529                        .iter()
530                        .find(|channel| channel.channel_point == channel_point && channel.active)
531                    {
532                        return Ok(channel.clone());
533                    }
534
535                    last_error = Some(format!(
536                        "active channels did not include {channel_point}; count={}",
537                        channels.len()
538                    ));
539                }
540                Err(error) => last_error = Some(error.to_string()),
541            }
542
543            sleep(READY_RETRY_INTERVAL).await;
544        }
545
546        Err(LndError::ActiveChannelTimeout {
547            alias: self.alias.clone(),
548            remote_public_key: remote_public_key.to_string(),
549            channel_point: channel_point.to_string(),
550            attempts: READY_RETRY_ATTEMPTS,
551            last_error,
552        })
553    }
554}
555
556/// Error returned by LND lifecycle and RPC helpers.
557#[derive(Debug, Error)]
558#[allow(missing_docs)]
559pub enum LndError {
560    #[error(transparent)]
561    Docker(#[from] DockerError),
562
563    #[error(transparent)]
564    BitcoinRpc(#[from] crate::BitcoinRpcError),
565
566    #[error("Bitcoin Core container did not expose a bridge IP address for LND")]
567    MissingBitcoindIp,
568
569    #[error("Docker container {container_id} did not publish expected LND port {container_port}")]
570    MissingHostPort {
571        container_id: String,
572        container_port: u16,
573    },
574
575    #[error("failed to connect to LND at {socket}: {message}")]
576    Connect { socket: String, message: String },
577
578    #[error("LND RPC {method} failed at {socket}: {message}")]
579    Rpc {
580        socket: String,
581        method: &'static str,
582        message: String,
583    },
584
585    #[error("invalid LND public key {public_key}: {message}")]
586    InvalidPublicKey { public_key: String, message: String },
587
588    #[error("LND node {alias} startup failed for container {container_id}; logs: {logs:?}")]
589    Startup {
590        alias: String,
591        container_id: String,
592        logs: Option<String>,
593        source: Box<LndError>,
594    },
595
596    #[error("failed to create unauthenticated LND channel to {socket}: {message}")]
597    UnauthenticatedChannel { socket: String, message: String },
598
599    #[error(
600        "LND wallet init did not complete after {attempts} attempts; last error: {last_error:?}"
601    )]
602    WalletInitTimeout {
603        attempts: usize,
604        last_error: Option<String>,
605    },
606
607    #[error(
608        "LND did not report synced_to_chain after {attempts} attempts; last error: {last_error:?}"
609    )]
610    ReadyTimeout {
611        attempts: usize,
612        last_error: Option<String>,
613    },
614
615    #[error(
616        "LND {container_id} did not produce file {path} after {attempts} attempts; last error: {last_error:?}"
617    )]
618    FileTimeout {
619        container_id: String,
620        path: String,
621        attempts: usize,
622        last_error: Option<String>,
623    },
624
625    #[error(
626        "LND node {alias} did not reach spendable balance {minimum_sat} sat after {attempts} attempts; last error: {last_error:?}"
627    )]
628    BalanceTimeout {
629        alias: String,
630        minimum_sat: i64,
631        attempts: usize,
632        last_error: Option<String>,
633    },
634
635    #[error(
636        "LND node {alias} did not report spendable UTXOs totaling {minimum_sat} sat after {attempts} attempts; last error: {last_error:?}"
637    )]
638    UtxoTimeout {
639        alias: String,
640        minimum_sat: i64,
641        attempts: usize,
642        last_error: Option<String>,
643    },
644
645    #[error(
646        "LND node {alias} did not report pending channel {channel_point} with {remote_public_key} after {attempts} attempts; last error: {last_error:?}"
647    )]
648    PendingChannelTimeout {
649        alias: String,
650        remote_public_key: String,
651        channel_point: String,
652        attempts: usize,
653        last_error: Option<String>,
654    },
655
656    #[error(
657        "LND node {alias} did not report active channel {channel_point} with {remote_public_key} after {attempts} attempts; last error: {last_error:?}"
658    )]
659    ActiveChannelTimeout {
660        alias: String,
661        remote_public_key: String,
662        channel_point: String,
663        attempts: usize,
664        last_error: Option<String>,
665    },
666
667    #[error(
668        "LND node {alias} could not connect peer {public_key} after {attempts} attempts; last error: {last_error:?}"
669    )]
670    PeerConnectTimeout {
671        alias: String,
672        public_key: String,
673        attempts: usize,
674        last_error: Option<String>,
675    },
676}
677
678impl LndError {
679    fn rpc(socket: &str, method: &'static str, error: impl std::fmt::Display) -> Self {
680        Self::Rpc {
681            socket: socket.to_string(),
682            method,
683            message: error.to_string(),
684        }
685    }
686
687    fn is_lnd_starting(&self) -> bool {
688        matches!(
689            self,
690            LndError::Connect { message, .. } | LndError::Rpc { message, .. }
691                if message.contains("server is still in the process of starting")
692        )
693    }
694}
695
696fn lnd_container_spec(
697    config: &LndConfig,
698    bitcoind: &BitcoinCore,
699) -> Result<ContainerSpec, LndError> {
700    let bitcoind_ip = bitcoind
701        .container
702        .ip_address
703        .as_deref()
704        .ok_or(LndError::MissingBitcoindIp)?;
705    let name = format!(
706        "spawn-lnd-{}-lnd-{}-{}",
707        config.cluster_id, config.node_index, config.alias
708    );
709    let labels =
710        managed_container_labels(&config.cluster_id, ContainerRole::Lnd, Some(&config.alias));
711    let mut args = lnd_args(bitcoind_ip, bitcoind);
712
713    args.extend(config.extra_args.clone());
714
715    Ok(ContainerSpec::new(name, config.image.clone())
716        .cmd(args)
717        .labels(labels)
718        .expose_ports([LND_GRPC_PORT, LND_P2P_PORT]))
719}
720
721fn lnd_args(bitcoind_ip: &str, bitcoind: &BitcoinCore) -> Vec<String> {
722    vec![
723        "--bitcoin.regtest".to_string(),
724        "--bitcoin.node=bitcoind".to_string(),
725        "--bitcoind.rpcpolling".to_string(),
726        format!("--bitcoind.rpchost={bitcoind_ip}:{BITCOIND_RPC_PORT}"),
727        format!("--bitcoind.rpcuser={}", bitcoind.auth.user),
728        format!("--bitcoind.rpcpass={}", bitcoind.auth.password),
729        "--accept-keysend".to_string(),
730        "--allow-circular-route".to_string(),
731        "--debuglevel=info".to_string(),
732        "--noseedbackup".to_string(),
733        "--listen=0.0.0.0:9735".to_string(),
734        "--rpclisten=0.0.0.0:10009".to_string(),
735    ]
736}
737
738fn utxo_total_sat(utxos: &[Utxo]) -> i64 {
739    utxos.iter().map(|utxo| utxo.amount_sat).sum()
740}
741
742pub(crate) fn channel_point_string(channel_point: &ChannelPoint) -> Result<String, LndError> {
743    let funding_txid = match channel_point.funding_txid.as_ref() {
744        Some(lnd_grpc_rust::lnrpc::channel_point::FundingTxid::FundingTxidBytes(bytes)) => {
745            let mut txid = bytes.clone();
746            txid.reverse();
747            hex::encode(txid)
748        }
749        Some(lnd_grpc_rust::lnrpc::channel_point::FundingTxid::FundingTxidStr(txid)) => {
750            txid.clone()
751        }
752        None => {
753            return Err(LndError::Rpc {
754                socket: "<open-channel>".to_string(),
755                method: "OpenChannelSync",
756                message: "response did not include funding txid".to_string(),
757            });
758        }
759    };
760
761    Ok(format!("{}:{}", funding_txid, channel_point.output_index))
762}
763
764fn has_pending_channel(
765    pending: &PendingChannelsResponse,
766    remote_public_key: &str,
767    channel_point: &str,
768) -> bool {
769    pending.pending_open_channels.iter().any(|pending| {
770        pending.channel.as_ref().is_some_and(|channel| {
771            channel.remote_node_pub == remote_public_key && channel.channel_point == channel_point
772        })
773    })
774}
775
776async fn wait_for_file(
777    docker: &DockerClient,
778    container_id: &str,
779    path: &str,
780    policy: &RetryPolicy,
781) -> Result<Vec<u8>, LndError> {
782    let mut last_error = None;
783
784    for _ in 0..policy.attempts {
785        match docker.copy_file_from_container(container_id, path).await {
786            Ok(file) => return Ok(file),
787            Err(error) => {
788                last_error = Some(error.to_string());
789                sleep(policy.interval()).await;
790            }
791        }
792    }
793
794    Err(LndError::FileTimeout {
795        container_id: container_id.to_string(),
796        path: path.to_string(),
797        attempts: policy.attempts,
798        last_error,
799    })
800}
801
802async fn init_wallet_or_read_macaroon(
803    docker: &DockerClient,
804    container_id: &str,
805    cert_bytes: &[u8],
806    socket: &str,
807    policy: &RetryPolicy,
808) -> Result<String, LndError> {
809    let mut last_error = None;
810
811    for _ in 0..policy.attempts {
812        let init_error = match init_wallet_once(cert_bytes, socket).await {
813            Ok(macaroon) if !macaroon.is_empty() => return Ok(macaroon),
814            Ok(_) => Some("InitWallet returned an empty admin macaroon".to_string()),
815            Err(error) => Some(error),
816        };
817
818        match docker
819            .copy_file_from_container(container_id, LND_ADMIN_MACAROON_PATH)
820            .await
821        {
822            Ok(macaroon) if !macaroon.is_empty() => return Ok(hex::encode(macaroon)),
823            Ok(_) => {
824                last_error = Some(format!(
825                    "{LND_ADMIN_MACAROON_PATH} was empty; wallet init: {}",
826                    init_error.as_deref().unwrap_or("no error")
827                ));
828            }
829            Err(error) => {
830                last_error = Some(format!(
831                    "failed to read {LND_ADMIN_MACAROON_PATH}: {error}; wallet init: {}",
832                    init_error.as_deref().unwrap_or("no error")
833                ));
834            }
835        }
836
837        sleep(policy.interval()).await;
838    }
839
840    Err(LndError::WalletInitTimeout {
841        attempts: policy.attempts,
842        last_error,
843    })
844}
845
846async fn init_wallet_once(cert_bytes: &[u8], socket: &str) -> Result<String, String> {
847    let channel = unauthenticated_channel(cert_bytes, socket)
848        .await
849        .map_err(|error| error.to_string())?;
850    let mut unlocker = WalletUnlockerClient::new(channel);
851    let seed = unlocker
852        .gen_seed(GenSeedRequest {
853            aezeed_passphrase: Vec::new(),
854            seed_entropy: Vec::new(),
855        })
856        .await
857        .map_err(|error| error.to_string())?
858        .into_inner()
859        .cipher_seed_mnemonic;
860    let response = unlocker
861        .init_wallet(InitWalletRequest {
862            wallet_password: LND_WALLET_PASSWORD.to_vec(),
863            cipher_seed_mnemonic: seed,
864            ..Default::default()
865        })
866        .await
867        .map_err(|error| error.to_string())?
868        .into_inner();
869
870    Ok(hex::encode(response.admin_macaroon))
871}
872
873async fn unauthenticated_channel(cert_bytes: &[u8], socket: &str) -> Result<MyChannel, LndError> {
874    let uri = format!("https://{socket}")
875        .parse::<Uri>()
876        .map_err(|error| LndError::UnauthenticatedChannel {
877            socket: socket.to_string(),
878            message: error.to_string(),
879        })?;
880
881    MyChannel::new(Some(cert_bytes.to_vec()), uri)
882        .await
883        .map_err(|error| LndError::UnauthenticatedChannel {
884            socket: socket.to_string(),
885            message: error.to_string(),
886        })
887}
888
889async fn wait_for_synced_get_info(
890    cert_hex: &str,
891    macaroon_hex: &str,
892    socket: &str,
893    policy: &RetryPolicy,
894) -> Result<GetInfoResponse, LndError> {
895    let mut last_error = None;
896
897    for _ in 0..policy.attempts {
898        match get_synced_info_once(cert_hex, macaroon_hex, socket).await {
899            Ok(info) if info.synced_to_chain => return Ok(info),
900            Ok(info) => {
901                last_error = Some(format!(
902                    "GetInfo returned synced_to_chain=false at height {}",
903                    info.block_height
904                ));
905            }
906            Err(error) => last_error = Some(error.to_string()),
907        }
908
909        sleep(policy.interval()).await;
910    }
911
912    Err(LndError::ReadyTimeout {
913        attempts: policy.attempts,
914        last_error,
915    })
916}
917
918async fn get_synced_info_once(
919    cert_hex: &str,
920    macaroon_hex: &str,
921    socket: &str,
922) -> Result<GetInfoResponse, LndError> {
923    let mut client = connect_authenticated(cert_hex, macaroon_hex, socket).await?;
924    let info = client
925        .lightning()
926        .get_info(GetInfoRequest {})
927        .await
928        .map_err(|error| LndError::Connect {
929            socket: socket.to_string(),
930            message: error.to_string(),
931        })?
932        .into_inner();
933
934    Ok(info)
935}
936
937async fn connect_authenticated(
938    cert_hex: &str,
939    macaroon_hex: &str,
940    socket: &str,
941) -> Result<LndClient, LndError> {
942    lnd_grpc_rust::connect(
943        cert_hex.to_string(),
944        macaroon_hex.to_string(),
945        socket.to_string(),
946    )
947    .await
948    .map_err(|error| LndError::Connect {
949        socket: socket.to_string(),
950        message: error.to_string(),
951    })
952}
953
954#[cfg(test)]
955mod tests {
956    use std::collections::HashMap;
957
958    use crate::{
959        BitcoinCore, BitcoinRpcAuth, BitcoinRpcClient, DEFAULT_LND_IMAGE,
960        bitcoin::{BITCOIND_P2P_PORT, BITCOIND_RPC_PORT},
961        docker::SpawnedContainer,
962    };
963
964    use lnd_grpc_rust::lnrpc::{ChannelPoint, channel_point};
965
966    use super::{
967        LND_GRPC_PORT, LND_P2P_PORT, LndConfig, channel_point_string, lnd_args, lnd_container_spec,
968    };
969
970    fn fake_bitcoind() -> BitcoinCore {
971        BitcoinCore {
972            container: SpawnedContainer {
973                id: "bitcoind".to_string(),
974                name: Some("bitcoind".to_string()),
975                ip_address: Some("172.17.0.2".to_string()),
976                host_ports: HashMap::from([(BITCOIND_RPC_PORT, 18443), (BITCOIND_P2P_PORT, 18444)]),
977            },
978            auth: BitcoinRpcAuth {
979                user: "bitcoinrpc".to_string(),
980                password: "password".to_string(),
981                rpcauth: "bitcoinrpc:salt$hmac".to_string(),
982            },
983            rpc: BitcoinRpcClient::new("127.0.0.1", 18443, "bitcoinrpc", "password"),
984            wallet_rpc: BitcoinRpcClient::new("127.0.0.1", 18443, "bitcoinrpc", "password")
985                .wallet("spawn-lnd"),
986            rpc_socket: "127.0.0.1:18443".to_string(),
987            p2p_socket: "127.0.0.1:18444".to_string(),
988        }
989    }
990
991    #[test]
992    fn default_lnd_config_uses_pinned_image() {
993        let config = LndConfig::new("cluster-1", "alice", 0);
994
995        assert_eq!(config.cluster_id, "cluster-1");
996        assert_eq!(config.alias, "alice");
997        assert_eq!(config.node_index, 0);
998        assert_eq!(config.image, DEFAULT_LND_IMAGE);
999    }
1000
1001    #[test]
1002    fn builds_lnd_args_for_bitcoind_bridge_ip() {
1003        let bitcoind = fake_bitcoind();
1004        let args = lnd_args("172.17.0.2", &bitcoind);
1005
1006        assert!(args.contains(&"--bitcoin.regtest".to_string()));
1007        assert!(args.contains(&"--bitcoin.node=bitcoind".to_string()));
1008        assert!(args.contains(&"--bitcoind.rpcpolling".to_string()));
1009        assert!(args.contains(&"--rpclisten=0.0.0.0:10009".to_string()));
1010        assert!(args.contains(&"--listen=0.0.0.0:9735".to_string()));
1011        assert!(args.contains(&"--bitcoind.rpchost=172.17.0.2:18443".to_string()));
1012        assert!(args.contains(&"--bitcoind.rpcuser=bitcoinrpc".to_string()));
1013        assert!(args.contains(&"--bitcoind.rpcpass=password".to_string()));
1014        assert!(args.contains(&"--accept-keysend".to_string()));
1015        assert!(args.contains(&"--allow-circular-route".to_string()));
1016        assert!(args.contains(&"--debuglevel=info".to_string()));
1017        assert!(args.contains(&"--noseedbackup".to_string()));
1018    }
1019
1020    #[test]
1021    fn builds_lnd_container_spec() {
1022        let bitcoind = fake_bitcoind();
1023        let config = LndConfig::new("cluster-1", "alice", 0).extra_arg("--debuglevel=info");
1024
1025        let spec = lnd_container_spec(&config, &bitcoind).expect("spec");
1026
1027        assert_eq!(spec.name, "spawn-lnd-cluster-1-lnd-0-alice");
1028        assert_eq!(spec.image, DEFAULT_LND_IMAGE);
1029        assert!(spec.cmd.contains(&"--debuglevel=info".to_string()));
1030        assert!(spec.exposed_ports.contains(&LND_GRPC_PORT));
1031        assert!(spec.exposed_ports.contains(&LND_P2P_PORT));
1032    }
1033
1034    #[test]
1035    fn formats_channel_point_string() {
1036        let channel_point = ChannelPoint {
1037            funding_txid: Some(channel_point::FundingTxid::FundingTxidStr(
1038                "txid".to_string(),
1039            )),
1040            output_index: 1,
1041        };
1042
1043        assert_eq!(
1044            channel_point_string(&channel_point).expect("channel point"),
1045            "txid:1"
1046        );
1047    }
1048
1049    #[test]
1050    fn formats_channel_point_bytes_as_display_txid() {
1051        let channel_point = ChannelPoint {
1052            funding_txid: Some(channel_point::FundingTxid::FundingTxidBytes(vec![
1053                0x01, 0x02, 0x03, 0x04,
1054            ])),
1055            output_index: 0,
1056        };
1057
1058        assert_eq!(
1059            channel_point_string(&channel_point).expect("channel point"),
1060            "04030201:0"
1061        );
1062    }
1063}