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
25pub const LND_GRPC_PORT: u16 = 10009;
27pub const LND_P2P_PORT: u16 = 9735;
29pub const LND_TLS_CERT_PATH: &str = "/root/.lnd/tls.cert";
31pub const LND_ADMIN_MACAROON_PATH: &str = "/root/.lnd/data/chain/bitcoin/regtest/admin.macaroon";
33pub const LND_WALLET_PASSWORD: &[u8] = b"password";
35pub 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#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
44pub struct LndConfig {
45 pub cluster_id: String,
47 pub alias: String,
49 pub node_index: usize,
51 pub image: String,
53 pub extra_args: Vec<String>,
55 pub startup_retry: RetryPolicy,
57}
58
59impl LndConfig {
60 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 pub fn image(mut self, image: impl Into<String>) -> Self {
74 self.image = image.into();
75 self
76 }
77
78 pub fn extra_arg(mut self, arg: impl Into<String>) -> Self {
80 self.extra_args.push(arg.into());
81 self
82 }
83
84 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 pub fn startup_retry_policy(mut self, policy: RetryPolicy) -> Self {
96 self.startup_retry = policy;
97 self
98 }
99}
100
101#[derive(Clone, Debug)]
103pub struct LndDaemon {
104 pub alias: String,
106 pub container: SpawnedContainer,
108 pub cert_hex: String,
110 pub macaroon_hex: String,
112 pub rpc_socket: String,
114 pub p2p_socket: String,
116 pub public_key: String,
118}
119
120impl LndDaemon {
121 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 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 pub async fn connect(&self) -> Result<LndClient, LndError> {
224 connect_authenticated(&self.cert_hex, &self.macaroon_hex, &self.rpc_socket).await
225 }
226
227 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 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 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 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 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 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 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 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 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 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 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 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#[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}