Skip to main content

agent_first_pay/provider/ln/
mod.rs

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// ═══════════════════════════════════════════
16// LnBackend — internal trait for each backend
17// ═══════════════════════════════════════════
18
19#[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
103// ═══════════════════════════════════════════
104// LnProvider — PayProvider implementation
105// ═══════════════════════════════════════════
106
107fn 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    /// Find a LN wallet — if wallet_id is empty, pick the first available.
174    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        // Check balance — only allow closing zero-balance wallets
394        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                // BOLT11: amount-specific one-time invoice
468                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                // BOLT12: persistent reusable offer (phoenixd only)
477                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                // Record a local receive tx once so tx_status/history remain consistent
495                // even when backend history APIs are unavailable.
496                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        // Try backend first, fall back to local transaction log store
669        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        // Fallback to local transaction log store
709        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                // Backend fallback: scan LN wallets and query both invoice-status and payments.
726                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        // bolt12 offers should not parse as bolt11
956        assert!(parse_bolt11_amount_sats("lno1abc").is_err());
957        assert!(parse_bolt11_payment_hash("lno1abc").is_err());
958    }
959}