1use crate::provider::{HistorySyncStats, PayError, PayProvider};
2use crate::store::wallet::{self, WalletMetadata};
3use crate::store::{PayStore, StorageBackend};
4use crate::types::*;
5use async_trait::async_trait;
6use std::sync::Arc;
7
8#[cfg(feature = "ln-lnbits")]
9mod lnbits;
10#[cfg(feature = "ln-nwc")]
11mod nwc;
12#[cfg(feature = "ln-phoenixd")]
13mod phoenixd;
14
15#[derive(Debug, Clone)]
20pub(crate) struct LnPayResult {
21 pub confirmed_amount_sats: u64,
22 pub fee_msats: Option<u64>,
23 pub preimage: Option<String>,
24}
25
26#[derive(Debug, Clone)]
27pub(crate) struct LnInvoiceResult {
28 pub bolt11: String,
29 pub payment_hash: String,
30}
31
32#[derive(Debug, Clone, Copy, PartialEq, Eq)]
33#[allow(dead_code)]
34pub(crate) enum LnPaymentStatus {
35 Pending,
36 Paid,
37 Failed,
38 Unknown,
39}
40
41#[derive(Debug, Clone, Copy, PartialEq, Eq)]
42#[allow(dead_code)]
43pub(crate) enum LnInvoiceStatus {
44 Pending,
45 Paid { confirmed_amount_sats: u64 },
46 Failed,
47 Unknown,
48}
49
50#[derive(Debug, Clone)]
51pub(crate) struct LnPaymentInfo {
52 pub payment_hash: String,
53 pub amount_msats: u64,
54 pub is_outgoing: bool,
55 pub status: LnPaymentStatus,
56 pub created_at_epoch_s: u64,
57 pub memo: Option<String>,
58 pub preimage: Option<String>,
59}
60
61#[async_trait]
62pub(crate) trait LnBackend: Send + Sync {
63 async fn pay_invoice(
64 &self,
65 bolt11: &str,
66 amount_msats: Option<u64>,
67 ) -> Result<LnPayResult, PayError>;
68
69 async fn create_invoice(
70 &self,
71 amount_sats: u64,
72 memo: Option<&str>,
73 ) -> Result<LnInvoiceResult, PayError>;
74
75 async fn invoice_status(&self, payment_hash: &str) -> Result<LnInvoiceStatus, PayError>;
76
77 async fn get_balance(&self) -> Result<BalanceInfo, PayError>;
78
79 async fn list_payments(
80 &self,
81 limit: usize,
82 offset: usize,
83 ) -> Result<Vec<LnPaymentInfo>, PayError>;
84
85 async fn get_default_offer(&self) -> Result<String, PayError> {
86 Err(PayError::NotImplemented(
87 "bolt12 offers not supported by this backend".to_string(),
88 ))
89 }
90
91 async fn pay_offer(
92 &self,
93 _offer: &str,
94 _amount_sats: u64,
95 _message: Option<&str>,
96 ) -> Result<LnPayResult, PayError> {
97 Err(PayError::NotImplemented(
98 "bolt12 offers not supported by this backend".to_string(),
99 ))
100 }
101}
102
103fn ln_wallet_summary(m: &WalletMetadata) -> WalletSummary {
108 let backend = m.backend.clone().unwrap_or_else(|| "unknown".to_string());
109 WalletSummary {
110 id: m.id.clone(),
111 network: Network::Ln,
112 label: m.label.clone(),
113 address: format!("ln:{backend}"),
114 backend: Some(backend),
115 mint_url: None,
116 rpc_endpoints: None,
117 chain_id: None,
118 created_at_epoch_s: m.created_at_epoch_s,
119 }
120}
121
122pub struct LnProvider {
123 _data_dir: String,
124 store: Arc<StorageBackend>,
125}
126
127impl LnProvider {
128 pub fn new(data_dir: &str, store: Arc<StorageBackend>) -> Self {
129 Self {
130 _data_dir: data_dir.to_string(),
131 store,
132 }
133 }
134
135 fn resolve_backend(&self, meta: &WalletMetadata) -> Result<Box<dyn LnBackend>, PayError> {
136 let backend_name = meta.backend.as_deref().ok_or_else(|| {
137 PayError::InternalError("ln wallet missing backend field".to_string())
138 })?;
139
140 #[cfg(feature = "ln-nwc")]
141 if backend_name == "nwc" {
142 let secret = meta.seed_secret.as_deref().unwrap_or("");
143 return Ok(Box::new(nwc::NwcBackend::new(secret)?));
144 }
145 #[cfg(feature = "ln-phoenixd")]
146 if backend_name == "phoenixd" {
147 let endpoint = meta.mint_url.as_deref().unwrap_or("");
148 let secret = meta.seed_secret.as_deref().unwrap_or("");
149 return Ok(Box::new(phoenixd::PhoenixdBackend::new(endpoint, secret)));
150 }
151 #[cfg(feature = "ln-lnbits")]
152 if backend_name == "lnbits" {
153 let endpoint = meta.mint_url.as_deref().unwrap_or("");
154 let secret = meta.seed_secret.as_deref().unwrap_or("");
155 return Ok(Box::new(lnbits::LnbitsBackend::new(endpoint, secret)));
156 }
157
158 Err(PayError::NotImplemented(format!(
159 "ln backend '{backend_name}' not enabled"
160 )))
161 }
162
163 fn load_ln_wallet(&self, wallet_id: &str) -> Result<WalletMetadata, PayError> {
164 let meta = self.store.load_wallet_metadata(wallet_id)?;
165 if meta.network != Network::Ln {
166 return Err(PayError::WalletNotFound(format!(
167 "{wallet_id} is not a ln wallet"
168 )));
169 }
170 Ok(meta)
171 }
172
173 fn resolve_wallet_id(&self, wallet_id: &str) -> Result<String, PayError> {
175 if !wallet_id.is_empty() {
176 return Ok(wallet_id.to_string());
177 }
178 let wallets = self.store.list_wallet_metadata(Some(Network::Ln))?;
179 wallets
180 .first()
181 .map(|w| w.id.clone())
182 .ok_or_else(|| PayError::WalletNotFound("no ln wallet found".to_string()))
183 }
184
185 fn validate_backend_enabled(backend: LnWalletBackend) -> Result<(), PayError> {
186 #[allow(unreachable_patterns)]
187 let enabled = match backend {
188 #[cfg(feature = "ln-nwc")]
189 LnWalletBackend::Nwc => true,
190 #[cfg(feature = "ln-phoenixd")]
191 LnWalletBackend::Phoenixd => true,
192 #[cfg(feature = "ln-lnbits")]
193 LnWalletBackend::Lnbits => true,
194 _ => false,
195 };
196 if !enabled {
197 return Err(PayError::NotImplemented(format!(
198 "backend '{}' not compiled; rebuild with --features {}",
199 backend.as_str(),
200 backend.as_str()
201 )));
202 }
203 Ok(())
204 }
205
206 fn has_value(value: Option<&str>) -> bool {
207 value.map(|v| !v.trim().is_empty()).unwrap_or(false)
208 }
209
210 fn require_field(
211 backend: LnWalletBackend,
212 field_name: &str,
213 value: Option<String>,
214 ) -> Result<String, PayError> {
215 if Self::has_value(value.as_deref()) {
216 return Ok(value.unwrap_or_default());
217 }
218 Err(PayError::InvalidAmount(format!(
219 "{} backend requires --{}",
220 backend.as_str(),
221 field_name
222 )))
223 }
224
225 fn reject_field(
226 backend: LnWalletBackend,
227 field_name: &str,
228 value: Option<&str>,
229 ) -> Result<(), PayError> {
230 if Self::has_value(value) {
231 return Err(PayError::InvalidAmount(format!(
232 "{} backend does not accept --{}",
233 backend.as_str(),
234 field_name
235 )));
236 }
237 Ok(())
238 }
239
240 async fn validate_backend_credentials(
241 &self,
242 backend: LnWalletBackend,
243 endpoint: Option<String>,
244 secret: Option<String>,
245 label: Option<String>,
246 ) -> Result<(), PayError> {
247 let probe_meta = WalletMetadata {
248 id: "__probe__".to_string(),
249 network: Network::Ln,
250 label,
251 mint_url: endpoint,
252 sol_rpc_endpoints: None,
253 evm_rpc_endpoints: None,
254 evm_chain_id: None,
255 seed_secret: secret,
256 backend: Some(backend.as_str().to_string()),
257 btc_esplora_url: None,
258 btc_network: None,
259 btc_address_type: None,
260 btc_core_url: None,
261 btc_core_auth_secret: None,
262 btc_electrum_url: None,
263 custom_tokens: None,
264 created_at_epoch_s: 0,
265 error: None,
266 };
267 let backend_impl = self.resolve_backend(&probe_meta)?;
268 backend_impl.get_balance().await.map(|_| ()).map_err(|e| {
269 PayError::NetworkError(format!(
270 "{} backend validation failed: {}",
271 backend.as_str(),
272 e
273 ))
274 })
275 }
276}
277
278#[async_trait]
279impl PayProvider for LnProvider {
280 fn network(&self) -> Network {
281 Network::Ln
282 }
283
284 fn writes_locally(&self) -> bool {
285 true
286 }
287
288 async fn create_wallet(&self, _request: &WalletCreateRequest) -> Result<WalletInfo, PayError> {
289 Err(PayError::InvalidAmount(
290 "ln wallets must be created with ln_wallet_create parameters".to_string(),
291 ))
292 }
293
294 async fn create_ln_wallet(
295 &self,
296 request: LnWalletCreateRequest,
297 ) -> Result<WalletInfo, PayError> {
298 Self::validate_backend_enabled(request.backend)?;
299
300 let backend = request.backend;
301 let label = request.label.as_deref().unwrap_or("default").trim();
302 let wallet_label = if label.is_empty() || label == "default" {
303 None
304 } else {
305 Some(label.to_string())
306 };
307
308 let (endpoint, secret) = match backend {
309 LnWalletBackend::Nwc => {
310 Self::reject_field(backend, "endpoint", request.endpoint.as_deref())?;
311 Self::reject_field(
312 backend,
313 "password-secret",
314 request.password_secret.as_deref(),
315 )?;
316 Self::reject_field(
317 backend,
318 "admin-key-secret",
319 request.admin_key_secret.as_deref(),
320 )?;
321 let nwc_uri =
322 Self::require_field(backend, "nwc-uri-secret", request.nwc_uri_secret)?;
323 (None, Some(nwc_uri))
324 }
325 LnWalletBackend::Phoenixd => {
326 Self::reject_field(backend, "nwc-uri-secret", request.nwc_uri_secret.as_deref())?;
327 Self::reject_field(
328 backend,
329 "admin-key-secret",
330 request.admin_key_secret.as_deref(),
331 )?;
332 let endpoint = Self::require_field(backend, "endpoint", request.endpoint)?;
333 let password =
334 Self::require_field(backend, "password-secret", request.password_secret)?;
335 (Some(endpoint), Some(password))
336 }
337 LnWalletBackend::Lnbits => {
338 Self::reject_field(backend, "nwc-uri-secret", request.nwc_uri_secret.as_deref())?;
339 Self::reject_field(
340 backend,
341 "password-secret",
342 request.password_secret.as_deref(),
343 )?;
344 let endpoint = Self::require_field(backend, "endpoint", request.endpoint)?;
345 let admin_key =
346 Self::require_field(backend, "admin-key-secret", request.admin_key_secret)?;
347 (Some(endpoint), Some(admin_key))
348 }
349 };
350
351 self.validate_backend_credentials(
352 backend,
353 endpoint.clone(),
354 secret.clone(),
355 wallet_label.clone(),
356 )
357 .await?;
358
359 let id = wallet::generate_wallet_identifier()?;
360 let meta = WalletMetadata {
361 id: id.clone(),
362 network: Network::Ln,
363 label: wallet_label,
364 mint_url: endpoint,
365 sol_rpc_endpoints: None,
366 evm_rpc_endpoints: None,
367 evm_chain_id: None,
368 seed_secret: secret,
369 backend: Some(backend.as_str().to_string()),
370 btc_esplora_url: None,
371 btc_network: None,
372 btc_address_type: None,
373 btc_core_url: None,
374 btc_core_auth_secret: None,
375 btc_electrum_url: None,
376 custom_tokens: None,
377 created_at_epoch_s: wallet::now_epoch_seconds(),
378 error: None,
379 };
380 self.store.save_wallet_metadata(&meta)?;
381
382 Ok(WalletInfo {
383 id,
384 network: Network::Ln,
385 address: format!("ln:{}", backend.as_str()),
386 label: meta.label,
387 mnemonic: None,
388 })
389 }
390
391 async fn close_wallet(&self, wallet_id: &str) -> Result<(), PayError> {
392 let meta = self.load_ln_wallet(wallet_id)?;
393 let backend = self.resolve_backend(&meta)?;
395 let balance = backend.get_balance().await?;
396 let non_zero_components = balance.non_zero_components();
397 if !non_zero_components.is_empty() {
398 let component_list = non_zero_components
399 .iter()
400 .map(|(name, value)| format!("{name}={value}sats"))
401 .collect::<Vec<_>>()
402 .join(", ");
403 return Err(PayError::InvalidAmount(format!(
404 "wallet {wallet_id} has non-zero balance components ({component_list}); withdraw first"
405 )));
406 }
407 self.store.delete_wallet_metadata(wallet_id)?;
408 Ok(())
409 }
410
411 async fn list_wallets(&self) -> Result<Vec<WalletSummary>, PayError> {
412 let wallets = self.store.list_wallet_metadata(Some(Network::Ln))?;
413 Ok(wallets.iter().map(ln_wallet_summary).collect())
414 }
415
416 async fn balance(&self, wallet_id: &str) -> Result<BalanceInfo, PayError> {
417 let meta = self.load_ln_wallet(wallet_id)?;
418 let backend = self.resolve_backend(&meta)?;
419 backend.get_balance().await
420 }
421
422 async fn balance_all(&self) -> Result<Vec<WalletBalanceItem>, PayError> {
423 let wallets = self.store.list_wallet_metadata(Some(Network::Ln))?;
424 let mut items = Vec::new();
425 for meta in &wallets {
426 let (balance, error) = match self.resolve_backend(meta) {
427 Ok(backend) => match backend.get_balance().await {
428 Ok(balance) => (Some(balance), None),
429 Err(error) => (None, Some(error.to_string())),
430 },
431 Err(error) => (None, Some(error.to_string())),
432 };
433 items.push(WalletBalanceItem {
434 wallet: ln_wallet_summary(meta),
435 balance,
436 error,
437 });
438 }
439 Ok(items)
440 }
441
442 async fn receive_info(
443 &self,
444 wallet_id: &str,
445 amount: Option<Amount>,
446 ) -> Result<ReceiveInfo, PayError> {
447 let resolved_wallet_id = if wallet_id.trim().is_empty() {
448 let wallets = self.store.list_wallet_metadata(Some(Network::Ln))?;
449 match wallets.len() {
450 0 => return Err(PayError::WalletNotFound("no ln wallet found".to_string())),
451 1 => wallets[0].id.clone(),
452 _ => {
453 return Err(PayError::InvalidAmount(
454 "multiple ln wallets found; pass --wallet".to_string(),
455 ))
456 }
457 }
458 } else {
459 wallet_id.to_string()
460 };
461
462 let meta = self.load_ln_wallet(&resolved_wallet_id)?;
463 let backend = self.resolve_backend(&meta)?;
464
465 match amount.as_ref().map(|a| a.value) {
466 Some(amount_sats) => {
467 let result = backend.create_invoice(amount_sats, None).await?;
469 Ok(ReceiveInfo {
470 address: None,
471 invoice: Some(result.bolt11),
472 quote_id: Some(result.payment_hash),
473 })
474 }
475 None => {
476 let offer = backend.get_default_offer().await?;
478 Ok(ReceiveInfo {
479 address: Some(offer),
480 invoice: None,
481 quote_id: None,
482 })
483 }
484 }
485 }
486
487 async fn receive_claim(&self, wallet_id: &str, quote_id: &str) -> Result<u64, PayError> {
488 let meta = self.load_ln_wallet(wallet_id)?;
489 let backend = self.resolve_backend(&meta)?;
490 match backend.invoice_status(quote_id).await? {
491 LnInvoiceStatus::Paid {
492 confirmed_amount_sats,
493 } => {
494 if self
497 .store
498 .find_transaction_record_by_id(quote_id)?
499 .is_none()
500 {
501 let now = wallet::now_epoch_seconds();
502 let record = HistoryRecord {
503 transaction_id: quote_id.to_string(),
504 wallet: wallet_id.to_string(),
505 network: Network::Ln,
506 direction: Direction::Receive,
507 amount: Amount {
508 value: confirmed_amount_sats,
509 token: "sats".to_string(),
510 },
511 status: TxStatus::Confirmed,
512 onchain_memo: Some("ln receive".to_string()),
513 local_memo: None,
514 remote_addr: None,
515 preimage: None,
516 created_at_epoch_s: now,
517 confirmed_at_epoch_s: Some(now),
518 fee: None,
519 reference_keys: None,
520 };
521 let _ = self.store.append_transaction_record(&record);
522 }
523 Ok(confirmed_amount_sats)
524 }
525 LnInvoiceStatus::Pending => {
526 Err(PayError::NetworkError("invoice not yet paid".to_string()))
527 }
528 LnInvoiceStatus::Failed => {
529 Err(PayError::NetworkError("invoice payment failed".to_string()))
530 }
531 LnInvoiceStatus::Unknown => {
532 Err(PayError::NetworkError("invoice status unknown".to_string()))
533 }
534 }
535 }
536
537 async fn cashu_send(
538 &self,
539 _wallet: &str,
540 _amount: Amount,
541 _memo: Option<&str>,
542 _mints: Option<&[String]>,
543 ) -> Result<CashuSendResult, PayError> {
544 Err(PayError::NotImplemented(
545 "ln does not support bearer-token send; use `ln send --to <bolt11>`".to_string(),
546 ))
547 }
548
549 async fn cashu_receive(
550 &self,
551 _wallet: &str,
552 _token: &str,
553 ) -> Result<CashuReceiveResult, PayError> {
554 Err(PayError::NotImplemented(
555 "ln does not support token receive; use `ln receive --amount-sats <amount>`"
556 .to_string(),
557 ))
558 }
559
560 async fn send_quote(
561 &self,
562 wallet_id: &str,
563 to: &str,
564 _mints: Option<&[String]>,
565 ) -> Result<SendQuoteInfo, PayError> {
566 let resolved = self.resolve_wallet_id(wallet_id)?;
567 if is_bolt12_offer(to) {
568 return Err(PayError::InvalidAmount(
569 "bolt12 offers do not embed an amount; pass --amount-sats when sending to an offer"
570 .to_string(),
571 ));
572 }
573 let amount_sats = parse_bolt11_amount_sats(to)?;
574 let fee_estimate = (amount_sats / 100).max(1);
575 Ok(SendQuoteInfo {
576 wallet: resolved,
577 amount_native: amount_sats,
578 fee_estimate_native: fee_estimate,
579 fee_unit: "sats".to_string(),
580 })
581 }
582
583 async fn send(
584 &self,
585 wallet_id: &str,
586 to: &str,
587 onchain_memo: Option<&str>,
588 _mints: Option<&[String]>,
589 ) -> Result<SendResult, PayError> {
590 let resolved = self.resolve_wallet_id(wallet_id)?;
591 let meta = self.load_ln_wallet(&resolved)?;
592 let backend = self.resolve_backend(&meta)?;
593
594 let result = if is_bolt12_offer(to) {
595 let (offer, amount_opt) = parse_bolt12_offer_parts(to);
596 let amount_sats = amount_opt.ok_or_else(|| {
597 PayError::InvalidAmount(
598 "amount-sats is required when sending to a bolt12 offer (use --amount)"
599 .to_string(),
600 )
601 })?;
602 backend.pay_offer(&offer, amount_sats, None).await?
603 } else {
604 backend.pay_invoice(to, None).await?
605 };
606
607 let transaction_id = if is_bolt12_offer(to) {
608 wallet::generate_transaction_identifier().unwrap_or_else(|_| "tx_unknown".to_string())
609 } else {
610 parse_bolt11_payment_hash(to).unwrap_or_else(|_| {
611 wallet::generate_transaction_identifier()
612 .unwrap_or_else(|_| "tx_unknown".to_string())
613 })
614 };
615
616 if result.confirmed_amount_sats == 0 {
617 return Err(PayError::NetworkError(
618 "backend did not return confirmed payment amount".to_string(),
619 ));
620 }
621
622 let fee_sats = result.fee_msats.map(|f| f / 1000);
623 let amount = Amount {
624 value: result.confirmed_amount_sats,
625 token: "sats".to_string(),
626 };
627
628 let fee_amount = fee_sats.filter(|&f| f > 0).map(|f| Amount {
629 value: f,
630 token: "sats".to_string(),
631 });
632 let record = HistoryRecord {
633 transaction_id: transaction_id.clone(),
634 wallet: resolved.clone(),
635 network: Network::Ln,
636 direction: Direction::Send,
637 amount: amount.clone(),
638 status: TxStatus::Confirmed,
639 onchain_memo: onchain_memo
640 .map(|s| s.to_string())
641 .or(Some("ln send".to_string())),
642 local_memo: None,
643 remote_addr: Some(to.to_string()),
644 preimage: result.preimage.clone(),
645 created_at_epoch_s: wallet::now_epoch_seconds(),
646 confirmed_at_epoch_s: Some(wallet::now_epoch_seconds()),
647 fee: fee_amount.clone(),
648 reference_keys: None,
649 };
650 let _ = self.store.append_transaction_record(&record);
651
652 Ok(SendResult {
653 wallet: resolved,
654 transaction_id,
655 amount,
656 fee: fee_amount,
657 preimage: result.preimage,
658 })
659 }
660
661 async fn history_list(
662 &self,
663 wallet_id: &str,
664 limit: usize,
665 offset: usize,
666 ) -> Result<Vec<HistoryRecord>, PayError> {
667 let meta = self.load_ln_wallet(wallet_id)?;
668 if let Ok(backend) = self.resolve_backend(&meta) {
670 if let Ok(payments) = backend.list_payments(limit, offset).await {
671 return Ok(payments
672 .into_iter()
673 .map(|p| HistoryRecord {
674 transaction_id: p.payment_hash.clone(),
675 wallet: wallet_id.to_string(),
676 network: Network::Ln,
677 direction: if p.is_outgoing {
678 Direction::Send
679 } else {
680 Direction::Receive
681 },
682 amount: Amount {
683 value: p.amount_msats / 1000,
684 token: "sats".to_string(),
685 },
686 status: match p.status {
687 LnPaymentStatus::Paid => TxStatus::Confirmed,
688 LnPaymentStatus::Pending => TxStatus::Pending,
689 LnPaymentStatus::Failed => TxStatus::Failed,
690 LnPaymentStatus::Unknown => TxStatus::Pending,
691 },
692 onchain_memo: p.memo,
693 local_memo: None,
694 remote_addr: None,
695 preimage: p.preimage,
696 created_at_epoch_s: p.created_at_epoch_s,
697 confirmed_at_epoch_s: if p.status == LnPaymentStatus::Paid {
698 Some(p.created_at_epoch_s)
699 } else {
700 None
701 },
702 fee: None,
703 reference_keys: None,
704 })
705 .collect());
706 }
707 }
708 let all = self.store.load_wallet_transaction_records(wallet_id)?;
710 let end = all.len().min(offset + limit);
711 let start = all.len().min(offset);
712 Ok(all[start..end].to_vec())
713 }
714
715 async fn history_status(&self, transaction_id: &str) -> Result<HistoryStatusInfo, PayError> {
716 match self.store.find_transaction_record_by_id(transaction_id)? {
717 Some(rec) => Ok(HistoryStatusInfo {
718 transaction_id: rec.transaction_id.clone(),
719 status: rec.status,
720 confirmations: None,
721 preimage: rec.preimage.clone(),
722 item: Some(rec),
723 }),
724 None => {
725 let wallets = self.store.list_wallet_metadata(Some(Network::Ln))?;
727 for w in &wallets {
728 let meta = self.load_ln_wallet(&w.id)?;
729 let backend = match self.resolve_backend(&meta) {
730 Ok(b) => b,
731 Err(_) => continue,
732 };
733 match backend.invoice_status(transaction_id).await {
734 Ok(LnInvoiceStatus::Paid { .. }) => {
735 return Ok(HistoryStatusInfo {
736 transaction_id: transaction_id.to_string(),
737 status: TxStatus::Confirmed,
738 confirmations: None,
739 preimage: None,
740 item: None,
741 });
742 }
743 Ok(LnInvoiceStatus::Pending) => {
744 return Ok(HistoryStatusInfo {
745 transaction_id: transaction_id.to_string(),
746 status: TxStatus::Pending,
747 confirmations: None,
748 preimage: None,
749 item: None,
750 });
751 }
752 Ok(LnInvoiceStatus::Failed) => {
753 return Ok(HistoryStatusInfo {
754 transaction_id: transaction_id.to_string(),
755 status: TxStatus::Failed,
756 confirmations: None,
757 preimage: None,
758 item: None,
759 });
760 }
761 Ok(LnInvoiceStatus::Unknown) | Err(_) => {}
762 }
763
764 if let Ok(payments) = backend.list_payments(200, 0).await {
765 if let Some(p) = payments
766 .into_iter()
767 .find(|p| p.payment_hash == transaction_id)
768 {
769 let status = match p.status {
770 LnPaymentStatus::Paid => TxStatus::Confirmed,
771 LnPaymentStatus::Pending | LnPaymentStatus::Unknown => {
772 TxStatus::Pending
773 }
774 LnPaymentStatus::Failed => TxStatus::Failed,
775 };
776 let item = HistoryRecord {
777 transaction_id: p.payment_hash.clone(),
778 wallet: w.id.clone(),
779 network: Network::Ln,
780 direction: if p.is_outgoing {
781 Direction::Send
782 } else {
783 Direction::Receive
784 },
785 amount: Amount {
786 value: p.amount_msats / 1000,
787 token: "sats".to_string(),
788 },
789 status,
790 onchain_memo: p.memo.clone(),
791 local_memo: None,
792 remote_addr: None,
793 preimage: p.preimage.clone(),
794 created_at_epoch_s: p.created_at_epoch_s,
795 confirmed_at_epoch_s: if p.status == LnPaymentStatus::Paid {
796 Some(p.created_at_epoch_s)
797 } else {
798 None
799 },
800 fee: None,
801 reference_keys: None,
802 };
803 return Ok(HistoryStatusInfo {
804 transaction_id: transaction_id.to_string(),
805 status,
806 confirmations: None,
807 preimage: p.preimage,
808 item: Some(item),
809 });
810 }
811 }
812 }
813 Err(PayError::WalletNotFound(format!(
814 "transaction {transaction_id} not found"
815 )))
816 }
817 }
818 }
819
820 async fn history_sync(
821 &self,
822 wallet_id: &str,
823 limit: usize,
824 ) -> Result<HistorySyncStats, PayError> {
825 let resolved = self.resolve_wallet_id(wallet_id)?;
826 let meta = self.load_ln_wallet(&resolved)?;
827 let backend = self.resolve_backend(&meta)?;
828 let payments = backend.list_payments(limit, 0).await?;
829
830 let mut stats = HistorySyncStats {
831 records_scanned: payments.len(),
832 records_added: 0,
833 records_updated: 0,
834 };
835
836 for payment in payments {
837 let status = match payment.status {
838 LnPaymentStatus::Paid => TxStatus::Confirmed,
839 LnPaymentStatus::Pending | LnPaymentStatus::Unknown => TxStatus::Pending,
840 LnPaymentStatus::Failed => TxStatus::Failed,
841 };
842 let confirmed_at_epoch_s = if status == TxStatus::Confirmed {
843 Some(payment.created_at_epoch_s)
844 } else {
845 None
846 };
847
848 match self
849 .store
850 .find_transaction_record_by_id(&payment.payment_hash)?
851 {
852 Some(existing) => {
853 if existing.status != status
854 || existing.confirmed_at_epoch_s != confirmed_at_epoch_s
855 {
856 let _ = self.store.update_transaction_record_status(
857 &payment.payment_hash,
858 status,
859 confirmed_at_epoch_s,
860 );
861 stats.records_updated = stats.records_updated.saturating_add(1);
862 }
863 }
864 None => {
865 let record = HistoryRecord {
866 transaction_id: payment.payment_hash.clone(),
867 wallet: resolved.clone(),
868 network: Network::Ln,
869 direction: if payment.is_outgoing {
870 Direction::Send
871 } else {
872 Direction::Receive
873 },
874 amount: Amount {
875 value: payment.amount_msats / 1000,
876 token: "sats".to_string(),
877 },
878 status,
879 onchain_memo: payment.memo.clone(),
880 local_memo: None,
881 remote_addr: None,
882 preimage: payment.preimage.clone(),
883 created_at_epoch_s: payment.created_at_epoch_s,
884 confirmed_at_epoch_s,
885 fee: None,
886 reference_keys: None,
887 };
888 let _ = self.store.append_transaction_record(&record);
889 stats.records_added = stats.records_added.saturating_add(1);
890 }
891 }
892 }
893
894 Ok(stats)
895 }
896}
897
898pub(crate) fn parse_bolt11_amount_sats(bolt11: &str) -> Result<u64, PayError> {
899 let invoice: lightning_invoice::Bolt11Invoice = bolt11
900 .parse()
901 .map_err(|e| PayError::InvalidAmount(format!("invalid bolt11 invoice: {e}")))?;
902 let amount_msats = invoice.amount_milli_satoshis().ok_or_else(|| {
903 PayError::InvalidAmount("bolt11 invoice does not include amount".to_string())
904 })?;
905 Ok(amount_msats.saturating_add(999) / 1000)
906}
907
908pub(crate) fn parse_bolt11_payment_hash(bolt11: &str) -> Result<String, PayError> {
909 let invoice: lightning_invoice::Bolt11Invoice = bolt11
910 .parse()
911 .map_err(|e| PayError::InvalidAmount(format!("invalid bolt11 invoice: {e}")))?;
912 Ok(invoice.payment_hash().to_string())
913}
914
915#[cfg(test)]
916mod tests {
917 use super::*;
918
919 #[test]
920 fn reject_field_detects_wrong_parameter() {
921 let err =
922 LnProvider::reject_field(LnWalletBackend::Phoenixd, "admin-key-secret", Some("x"))
923 .expect_err("phoenixd should reject admin-key-secret");
924 assert!(err
925 .to_string()
926 .contains("does not accept --admin-key-secret"));
927 }
928
929 #[test]
930 fn parse_bolt11_payment_hash_invalid() {
931 assert!(parse_bolt11_payment_hash("not-an-invoice").is_err());
932 }
933
934 #[test]
935 fn bolt12_offer_detected_case_insensitive() {
936 assert!(is_bolt12_offer("lno1qgsqvgjwcf6qqz9"));
937 assert!(is_bolt12_offer("LNO1QGSQVGJWCF6QQZ9"));
938 assert!(is_bolt12_offer("lno1abc?amount=100"));
939 assert!(!is_bolt12_offer("lnbc1qgsq"));
940 }
941
942 #[test]
943 fn bolt12_offer_parts_split() {
944 let (offer, amt) = parse_bolt12_offer_parts("lno1abc?amount=500");
945 assert_eq!(offer, "lno1abc");
946 assert_eq!(amt, Some(500));
947
948 let (offer, amt) = parse_bolt12_offer_parts("lno1abc");
949 assert_eq!(offer, "lno1abc");
950 assert_eq!(amt, None);
951 }
952
953 #[test]
954 fn bolt12_not_bolt11() {
955 assert!(parse_bolt11_amount_sats("lno1abc").is_err());
957 assert!(parse_bolt11_payment_hash("lno1abc").is_err());
958 }
959}