Skip to main content

glsdk/
node.rs

1use crate::{credentials::Credentials, signer::Handle, util::exec, Error};
2use std::str::FromStr;
3use std::sync::atomic::{AtomicBool, Ordering};
4use gl_client::credentials::NodeIdProvider;
5use gl_client::lnurl::models::LnUrlHttpClient as _;
6use gl_client::node::{Client as GlClient, ClnClient, Node as ClientNode};
7use gl_client::pb::{self as glpb, cln as clnpb};
8use lightning_invoice::Bolt11Invoice;
9use std::sync::{Arc, Mutex};
10use tokio::sync::OnceCell;
11
12/// The `Node` is an RPC stub representing the node running in the
13/// cloud. It is the main entrypoint to interact with the node.
14#[derive(uniffi::Object)]
15#[allow(unused)]
16pub struct Node {
17    inner: ClientNode,
18    cln_client: OnceCell<ClnClient>,
19    gl_client: OnceCell<GlClient>,
20    stored_credentials: Option<Credentials>,
21    signer_handle: Option<Handle>,
22    disconnected: AtomicBool,
23    /// Background task that tails the gRPC event stream and dispatches
24    /// events to the installed listener. A single listener per node;
25    /// installing a new one aborts the previous task. Aborted on Drop.
26    event_task: Mutex<Option<tokio::task::JoinHandle<()>>>,
27    network: gl_client::bitcoin::Network,
28}
29
30impl Drop for Node {
31    fn drop(&mut self) {
32        if let Ok(mut guard) = self.event_task.lock() {
33            if let Some(handle) = guard.take() {
34                handle.abort();
35            }
36        }
37    }
38}
39
40impl Node {
41    /// Construct a signerless Node — credentials only, no SDK-side
42    /// signer running. The actual signing happens elsewhere (a paired
43    /// device, a hardware signer, the CLN node's local signer).
44    /// Operations that require signing fall through to the node side.
45    ///
46    /// **Not a UniFFI export.** UniFFI consumers reach this via
47    /// `NodeBuilder::connect(credentials, None)` (mnemonic omitted).
48    /// Sibling Rust crates (e.g. `gl-sdk-napi`) call this directly
49    /// when wrapping signerless flows into their own bindings.
50    pub fn signerless(credentials: Credentials) -> Result<Self, Error> {
51        let node_id = credentials
52            .inner
53            .node_id()
54            .map_err(|_e| Error::unparseable_creds())?;
55        let inner = ClientNode::new(node_id, credentials.inner.clone())
56            .expect("infallible client instantiation");
57
58        let cln_client = OnceCell::const_new();
59        let gl_client = OnceCell::const_new();
60        Ok(Node {
61            inner,
62            cln_client,
63            gl_client,
64            stored_credentials: Some(credentials),
65            signer_handle: None,
66            disconnected: AtomicBool::new(false),
67            event_task: Mutex::new(None),
68            network: gl_client::bitcoin::Network::Bitcoin,
69        })
70    }
71}
72
73#[uniffi::export]
74impl Node {
75
76    /// Stop the node if it is currently running.
77    pub fn stop(&self) -> Result<(), Error> {
78        self.check_connected()?;
79        let mut cln_client = exec(self.get_cln_client())?.clone();
80
81        let req = clnpb::StopRequest {};
82
83        // It's ok, the error here is expected and should just be
84        // telling us that we've lost the connection. This is to
85        // be expected on shutdown, so we clamp this to success.
86        let _ = exec(cln_client.stop(req));
87        Ok(())
88    }
89
90    /// Returns the serialized credentials for this node.
91    /// The app should persist these bytes and pass them to connect() on next launch.
92    pub fn credentials(&self) -> Result<Vec<u8>, Error> {
93        match &self.stored_credentials {
94            Some(creds) => creds.save(),
95            None => Err(Error::other(
96                "No credentials stored. Use register/recover/connect to create a Node with credentials.".to_string(),
97            )),
98        }
99    }
100
101    /// Disconnects from the node and stops the signer if running.
102    /// After disconnect, all RPC methods will return an error.
103    /// Safe to call multiple times.
104    pub fn disconnect(&self) -> Result<(), Error> {
105        self.disconnected.store(true, Ordering::Relaxed);
106        if let Some(ref handle) = self.signer_handle {
107            handle.try_stop();
108        }
109        Ok(())
110    }
111
112    /// Receive an off-chain payment.
113    ///
114    /// This method generates a request for a payment, also called an
115    /// invoice, that encodes all the information, including amount
116    /// and destination, for a prospective sender to send a lightning
117    /// payment. The invoice includes negotiation of an LSPS2 / JIT
118    /// channel, meaning that if there is no channel sufficient to
119    /// receive the requested funds, the node will negotiate an
120    /// opening, and when/if executed the payment will cause a channel
121    /// to be created, and the incoming payment to be forwarded.
122    pub fn receive(
123        &self,
124        label: String,
125        description: String,
126        amount_msat: Option<u64>,
127    ) -> Result<ReceiveResponse, Error> {
128        self.check_connected()?;
129        let mut gl_client = exec(self.get_gl_client())?.clone();
130
131        let req = gl_client::pb::LspInvoiceRequest {
132            amount_msat: amount_msat.unwrap_or_default(),
133            description: description,
134            label: label,
135            lsp_id: "".to_owned(),
136            token: "".to_owned(),
137        };
138        let res = exec(gl_client.lsp_invoice(req))
139            .map_err(|s| Error::rpc(s.to_string()))?
140            .into_inner();
141        Ok(ReceiveResponse {
142            bolt11: res.bolt11,
143            opening_fee_msat: res.opening_fee_msat,
144        })
145    }
146
147    pub fn send(&self, invoice: String, amount_msat: Option<u64>) -> Result<SendResponse, Error> {
148        self.check_connected()?;
149        let mut cln_client = exec(self.get_cln_client())?.clone();
150        let req = clnpb::PayRequest {
151            amount_msat: match amount_msat {
152                Some(a) => Some(clnpb::Amount { msat: a }),
153                None => None,
154            },
155
156            bolt11: invoice,
157            description: None,
158            exclude: vec![],
159            exemptfee: None,
160            label: None,
161            localinvreqid: None,
162            maxdelay: None,
163            maxfee: None,
164            maxfeepercent: None,
165            partial_msat: None,
166            retry_for: None,
167            riskfactor: None,
168        };
169        exec(cln_client.pay(req))
170            .map_err(|e| Error::rpc(e.to_string()))
171            .map(|r| r.into_inner().into())
172    }
173
174    /// Send bitcoin on-chain to a destination address.
175    ///
176    /// # Arguments
177    /// * `destination` — A Bitcoin address (bech32, p2sh, or p2tr).
178    /// * `amount_or_all` — Amount to send. Accepts:
179    ///   - `"50000"` or `"50000sat"` — 50,000 satoshis
180    ///   - `"50000msat"` — 50,000 millisatoshis
181    ///   - `"all"` — sweep the entire on-chain balance
182    /// * `sat_per_vbyte` — Optional fee rate in sats per virtual byte.
183    ///   Pass `None` to let the node pick. Pass the value from a prior
184    ///   `prepare_onchain_send` to reproduce the previewed fee.
185    /// * `utxos` — Optional pinned input set. Pass the `utxos` returned
186    ///   by `prepare_onchain_send` (together with the same
187    ///   `sat_per_vbyte`) to broadcast a transaction with the exact
188    ///   inputs and fee shown in the preview. Pass `None` to let the
189    ///   node coin-select.
190    ///
191    /// Returns the raw transaction, txid, and PSBT once broadcast.
192    /// The transaction is broadcast immediately — this is not a dry run.
193    pub fn onchain_send(
194        &self,
195        destination: String,
196        amount_or_all: String,
197        sat_per_vbyte: Option<u32>,
198        utxos: Option<Vec<Outpoint>>,
199    ) -> Result<OnchainSendResponse, Error> {
200        self.check_connected()?;
201        let mut cln_client = exec(self.get_cln_client())?.clone();
202
203        let satoshi = parse_amount_or_all(&amount_or_all)?;
204
205        let req = clnpb::WithdrawRequest {
206            destination,
207            minconf: None,
208            feerate: sat_per_vbyte.map(feerate_perkw_from_sat_per_vbyte),
209            satoshi: Some(satoshi),
210            utxos: utxos
211                .unwrap_or_default()
212                .into_iter()
213                .map(outpoint_to_pb)
214                .collect::<Result<Vec<_>, _>>()?,
215        };
216
217        exec(cln_client.withdraw(req))
218            .map_err(|e| Error::rpc(e.to_string()))
219            .map(|r| r.into_inner().into())
220    }
221
222    /// Preview an on-chain send without broadcasting or reserving UTXOs.
223    ///
224    /// Runs CLN's coin selection at the given fee rate and returns the
225    /// inputs that would be spent, the fee, and the amount the recipient
226    /// would receive. Safe to call repeatedly (e.g. while the user
227    /// adjusts a fee slider) — nothing is locked.
228    ///
229    /// To broadcast with the previewed values, pass the returned
230    /// `utxos` and `sat_per_vbyte` back to `onchain_send`. Identical
231    /// inputs at the same fee rate yield the same fee.
232    ///
233    /// **Use this for "Send Max" UIs.** `recipient_sat` is the only
234    /// authoritative post-fee amount the destination will receive
235    /// for a sweep. `NodeState.onchain_balance_msat` includes the
236    /// emergency reserve and the fee — neither of which leaves the
237    /// wallet with the recipient. For the entry-point button label
238    /// (a pre-fee approximation that updates without an RPC), use
239    /// `OnchainBalanceState::Available.withdrawable_sat`.
240    ///
241    /// # Arguments
242    /// * `destination` — A Bitcoin address (bech32, p2sh, or p2tr).
243    /// * `amount_or_all` — Amount to send. Accepts:
244    ///   - `"50000"` or `"50000sat"` — 50,000 satoshis
245    ///   - `"50000msat"` — 50,000 millisatoshis
246    ///   - `"all"` — sweep the entire on-chain balance
247    /// * `sat_per_vbyte` — Fee rate in sats per virtual byte. Pass
248    ///   `None` to use the node's "normal" priority feerate; the
249    ///   effective rate CLN picked is reported back in the result's
250    ///   `sat_per_vbyte` field, which can be passed to `onchain_send`
251    ///   to reproduce it.
252    pub fn prepare_onchain_send(
253        &self,
254        destination: String,
255        amount_or_all: String,
256        sat_per_vbyte: Option<u32>,
257    ) -> Result<PreparedOnchainSend, Error> {
258        self.check_connected()?;
259        let cln_client = exec(self.get_cln_client())?.clone();
260
261        let satoshi = parse_amount_or_all(&amount_or_all)?;
262        let is_sweep = matches!(satoshi.value, Some(clnpb::amount_or_all::Value::All(true)));
263
264        // `startweight` must cover everything CLN does NOT add itself
265        // during fundpsbt: the base tx overhead and the destination
266        // output. CLN accumulates per-input spend weights and the
267        // change output weight on top of this. See
268        // lightning/plugins/spender/multiwithdraw.c:339 for the
269        // canonical formula CLN uses for its own withdraw plugin.
270        let startweight = BASE_TX_CORE_WEIGHT + output_weight_for_address(&destination);
271
272        let feerate = match sat_per_vbyte {
273            Some(rate) => feerate_perkw_from_sat_per_vbyte(rate),
274            None => clnpb::Feerate {
275                style: Some(clnpb::feerate::Style::Normal(true)),
276            },
277        };
278
279        let req = clnpb::FundpsbtRequest {
280            satoshi: Some(satoshi),
281            feerate: Some(feerate),
282            startweight,
283            // `reserve = 0` is the whole point: CLN runs coin selection
284            // and returns the would-be inputs but does not lock them.
285            reserve: Some(0),
286            minconf: None,
287            locktime: None,
288            min_witness_weight: None,
289            // For non-sweep sends any leftover after the requested
290            // amount + fee becomes change. For sweeps there is no
291            // requested amount so the leftover is the recipient amount
292            // and CLN reports it via `excess_msat`.
293            excess_as_change: Some(!is_sweep),
294            nonwrapped: None,
295            opening_anchor_channel: None,
296        };
297
298        // Run fund_psbt and feerates concurrently. The latter is used
299        // only to validate the requested rate against the network's
300        // relay floor — without this check, a too-low `sat_per_vbyte`
301        // produces a confusing post-broadcast `min relay fee not met`
302        // failure instead of a clean pre-confirmation error.
303        let (fund_res, feerates_res) = exec(async {
304            let mut c_fund = cln_client.clone();
305            let mut c_rates = cln_client.clone();
306            tokio::join!(
307                c_fund.fund_psbt(req),
308                c_rates.feerates(clnpb::FeeratesRequest {
309                    style: clnpb::feerates_request::FeeratesStyle::Perkw as i32,
310                }),
311            )
312        });
313
314        // Reject below-relay rates up front when the caller specified
315        // one. If `feerates` itself failed, skip the check — a stale
316        // bitcoind connection shouldn't block a prepare.
317        if let (Some(rate), Ok(rates)) = (sat_per_vbyte, feerates_res.as_ref())
318            && let Some(perkw) = rates.get_ref().perkw.as_ref()
319        {
320            let min_sat_per_vbyte =
321                sat_per_vbyte_from_perkw(perkw.min_acceptable).max(1);
322            if (rate as u64) < min_sat_per_vbyte {
323                return Err(Error::argument(
324                    "sat_per_vbyte",
325                    format!(
326                        "{} sat/vbyte is below the network minimum of {} sat/vbyte",
327                        rate, min_sat_per_vbyte
328                    ),
329                ));
330            }
331        }
332
333        let res = fund_res
334            .map_err(|e| Error::rpc(e.to_string()))?
335            .into_inner();
336
337        // CLN only emits the `reservations` array when `reserve > 0`
338        // (see lightning/wallet/reservation.c:421 — `if (reserve)`).
339        // We deliberately pass `reserve=0` to avoid locking UTXOs, so
340        // we extract the chosen inputs from the returned PSBT instead.
341        let psbt = bitcoin::Psbt::from_str(&res.psbt)
342            .map_err(|e| Error::rpc(format!("invalid psbt from fund_psbt: {}", e)))?;
343        let utxos: Vec<Outpoint> = psbt
344            .unsigned_tx
345            .input
346            .iter()
347            .map(|tx_in| Outpoint {
348                txid: tx_in.previous_output.txid.to_string(),
349                vout: tx_in.previous_output.vout,
350            })
351            .collect();
352
353        // BIP-141: feerate_per_kw is sats per 1000 weight units, so
354        // fee_sat = weight_wu × feerate_per_kw / 1000. The proto-level
355        // `estimated_final_weight` already includes the destination
356        // output we declared via `startweight`, plus any change output.
357        let fee_sat: u64 =
358            (res.estimated_final_weight as u64 * res.feerate_per_kw as u64) / 1000;
359
360        // Sum input values directly from the PSBT. Each PSBT input
361        // carries its prevout amount in `witness_utxo` (segwit) or
362        // `non_witness_utxo` (legacy). This is the one source of truth
363        // and works for sweeps that include an emergency-reserve
364        // change output (anchor-channel wallets) — see
365        // lightning/wallet/reservation.c:443 `change_for_emergency`,
366        // which carves out `emergency_sat` even from `satoshi=All`.
367        let mut total_input_sat: u64 = 0;
368        for (i, input) in psbt.inputs.iter().enumerate() {
369            let value = if let Some(ref txout) = input.witness_utxo {
370                txout.value
371            } else if let Some(ref tx) = input.non_witness_utxo {
372                let vout = psbt.unsigned_tx.input[i].previous_output.vout as usize;
373                tx.output
374                    .get(vout)
375                    .map(|o| o.value)
376                    .ok_or_else(|| {
377                        Error::rpc("psbt non_witness_utxo missing vout")
378                    })?
379            } else {
380                return Err(Error::rpc(format!(
381                    "psbt input {} has no witness_utxo or non_witness_utxo",
382                    i
383                )));
384            };
385            total_input_sat = total_input_sat.saturating_add(value.to_sat());
386        }
387
388        let recipient_sat: u64 = if is_sweep {
389            // For `satoshi=All` CLN reports the post-fee, post-emergency
390            // leftover via `excess_msat`; that's what the recipient
391            // receives. Any difference between `total_input_sat` and
392            // `recipient_sat + fee_sat` is the emergency-reserve change
393            // CLN keeps in the wallet for anchor channels.
394            res.excess_msat.as_ref().map(|a| a.msat).unwrap_or(0) / 1000
395        } else {
396            match parse_amount_or_all(&amount_or_all)?.value {
397                Some(clnpb::amount_or_all::Value::Amount(a)) => a.msat / 1000,
398                _ => 0,
399            }
400        };
401
402        // Round up so passing this back to `onchain_send` produces a
403        // feerate at least as high as the previewed one; that way the
404        // broadcast fee is never below what the user agreed to.
405        let effective_sat_per_vbyte: u32 =
406            (res.feerate_per_kw as u64).div_ceil(250) as u32;
407
408        Ok(PreparedOnchainSend {
409            utxos,
410            total_input_sat,
411            fee_sat,
412            recipient_sat,
413            sat_per_vbyte: effective_sat_per_vbyte,
414        })
415    }
416
417    /// Classify the on-chain wallet for the withdraw entry-point UI.
418    ///
419    /// Runs three RPCs concurrently:
420    /// * `list_funds` — current confirmed/unconfirmed/immature on-chain
421    ///   balances.
422    /// * `list_peer_channels` — pending channel-close payouts that
423    ///   haven't yet hit the wallet.
424    /// * `fund_psbt(satoshi=All, reserve=0, normal feerate)` — a
425    ///   non-locking probe whose response tells us **exactly** how
426    ///   much CLN will carve as the anchor-channel emergency reserve
427    ///   for this specific node, no client-side guessing required.
428    ///   The carved amount is computed from the response as
429    ///   `total_inputs − excess − fee`, which is identical to what
430    ///   CLN would carve on a real broadcast.
431    ///
432    /// Cheaper to call than `node_state()` and answers a different
433    /// question. Wallets typically call it once per render of the
434    /// home screen.
435    ///
436    /// For the *exact* post-fee recipient amount of a withdraw, use
437    /// `prepare_onchain_send`; the `withdrawable_sat` returned here
438    /// is a pre-fee, reserve-aware figure for the entry-point label.
439    pub fn onchain_balance_state(&self) -> Result<OnchainBalanceState, Error> {
440        self.check_connected()?;
441        let cln_client = exec(self.get_cln_client())?.clone();
442
443        // Run the three RPCs concurrently. The probe is allowed to
444        // fail (e.g. empty wallet, insufficient funds for any spend);
445        // we treat that as "no reserve applicable" and let the rest
446        // of the classification proceed.
447        let (funds_res, channels_res, probe_res) = exec(async {
448            let mut c_funds = cln_client.clone();
449            let mut c_channels = cln_client.clone();
450            let mut c_probe = cln_client.clone();
451            let probe_req = clnpb::FundpsbtRequest {
452                satoshi: Some(clnpb::AmountOrAll {
453                    value: Some(clnpb::amount_or_all::Value::All(true)),
454                }),
455                feerate: Some(clnpb::Feerate {
456                    style: Some(clnpb::feerate::Style::Normal(true)),
457                }),
458                // Assume a P2WPKH destination for the probe — we don't
459                // have a real address here. The output type only
460                // affects fee estimation by a handful of weight units;
461                // it does not affect the carved emergency reserve.
462                startweight: BASE_TX_CORE_WEIGHT + 124,
463                reserve: Some(0),
464                minconf: None,
465                locktime: None,
466                min_witness_weight: None,
467                excess_as_change: Some(false),
468                nonwrapped: None,
469                opening_anchor_channel: None,
470            };
471            tokio::join!(
472                c_funds.list_funds(clnpb::ListfundsRequest { spent: None }),
473                c_channels.list_peer_channels(clnpb::ListpeerchannelsRequest { id: None }),
474                c_probe.fund_psbt(probe_req),
475            )
476        });
477
478        let funds: ListFundsResponse = funds_res
479            .map_err(|e| Error::rpc(e.to_string()))?
480            .into_inner()
481            .into();
482        let channels: ListPeerChannelsResponse = channels_res
483            .map_err(|e| Error::rpc(e.to_string()))?
484            .into_inner()
485            .into();
486
487        let mut confirmed_sat: u64 = 0;
488        let mut unconfirmed_sat: u64 = 0;
489        let mut immature_sat: u64 = 0;
490        for output in &funds.outputs {
491            if output.reserved {
492                continue;
493            }
494            let value_sat = output.amount_msat / 1000;
495            match output.status {
496                OutputStatus::Confirmed => confirmed_sat += value_sat,
497                OutputStatus::Unconfirmed => unconfirmed_sat += value_sat,
498                OutputStatus::Immature => immature_sat += value_sat,
499                OutputStatus::Spent => {}
500            }
501        }
502
503        let mut pending_close_sat: u64 = 0;
504        for ch in &channels.channels {
505            if channel_payout_still_pending(ch) {
506                pending_close_sat += ch.to_us_msat.unwrap_or(0) / 1000;
507            }
508        }
509
510        // Derive the actual emergency reserve from the probe. CLN's
511        // `change_for_emergency` runs server-side in the same
512        // `fund_psbt` call we just made; the difference between the
513        // input total and (recipient + fee) is exactly what would be
514        // carved as change on a real sweep.
515        let reserve_sat = match probe_res {
516            Ok(resp) => {
517                let resp = resp.into_inner();
518                // CLN-managed UTXOs are always segwit, so each PSBT
519                // input carries `witness_utxo` with the prevout
520                // amount. Sum to get total input value.
521                let total_input_sat = bitcoin::Psbt::from_str(&resp.psbt)
522                    .ok()
523                    .map(|p| {
524                        p.inputs
525                            .iter()
526                            .filter_map(|i| {
527                                i.witness_utxo.as_ref().map(|t| t.value.to_sat())
528                            })
529                            .sum::<u64>()
530                    })
531                    .unwrap_or(0);
532                let excess_sat = resp
533                    .excess_msat
534                    .as_ref()
535                    .map(|a| a.msat / 1000)
536                    .unwrap_or(0);
537                let fee_sat = (resp.estimated_final_weight as u64
538                    * resp.feerate_per_kw as u64)
539                    / 1000;
540                total_input_sat
541                    .saturating_sub(excess_sat)
542                    .saturating_sub(fee_sat)
543            }
544            // `fund_psbt` errors are expected on empty wallets or when
545            // every UTXO is dust-uneconomic at the chosen feerate;
546            // treat as "no reserve applicable."
547            Err(_) => 0,
548        };
549
550        Ok(classify_onchain_balance(
551            confirmed_sat,
552            reserve_sat,
553            unconfirmed_sat,
554            immature_sat,
555            pending_close_sat,
556        ))
557    }
558
559    /// On-chain fee rates, in sats per virtual byte, at several
560    /// confirmation targets.
561    ///
562    /// Sourced from the connected node's view of the network — no
563    /// 3rd-party HTTP calls. Use as the basis for a fee-picker UI;
564    /// `minimum_relay_sat_per_vbyte` is the relay floor enforced at
565    /// broadcast time and should be the lower bound of any slider.
566    pub fn onchain_fee_rates(&self) -> Result<OnchainFeeRates, Error> {
567        self.check_connected()?;
568        let mut cln_client = exec(self.get_cln_client())?.clone();
569
570        let req = clnpb::FeeratesRequest {
571            style: clnpb::feerates_request::FeeratesStyle::Perkw as i32,
572        };
573        let res = exec(cln_client.feerates(req))
574            .map_err(|e| Error::rpc(e.to_string()))?
575            .into_inner();
576        Ok(compute_fee_rates(res.perkw.as_ref()))
577    }
578
579    /// Generate a fresh on-chain Bitcoin address for receiving funds.
580    ///
581    /// Returns both a bech32 (SegWit v0) and a p2tr (Taproot) address.
582    /// Either can be shared with a sender. Deposited funds will appear
583    /// in `node_state().onchain_balance_msat` once confirmed.
584    pub fn onchain_receive(&self) -> Result<OnchainReceiveResponse, Error> {
585        self.check_connected()?;
586        let mut cln_client = exec(self.get_cln_client())?.clone();
587
588        let req = clnpb::NewaddrRequest {
589            addresstype: Some(clnpb::newaddr_request::NewaddrAddresstype::All.into()),
590        };
591
592        let res = exec(cln_client.new_addr(req))
593            .map_err(|e| Error::rpc(e.to_string()))?
594            .into_inner();
595        Ok(res.into())
596    }
597
598    /// Get information about the node.
599    ///
600    /// Returns basic information about the node including its ID,
601    /// alias, network, and channel counts.
602    pub fn get_info(&self) -> Result<GetInfoResponse, Error> {
603        self.check_connected()?;
604        let mut cln_client = exec(self.get_cln_client())?.clone();
605
606        let req = clnpb::GetinfoRequest {};
607
608        let res = exec(cln_client.getinfo(req))
609            .map_err(|e| Error::rpc(e.to_string()))?
610            .into_inner();
611        Ok(res.into())
612    }
613
614    /// List all peers connected to this node.
615    ///
616    /// Returns information about all peers including their connection
617    /// status.
618    pub fn list_peers(&self) -> Result<ListPeersResponse, Error> {
619        self.check_connected()?;
620        let mut cln_client = exec(self.get_cln_client())?.clone();
621
622        let req = clnpb::ListpeersRequest {
623            id: None,
624            level: None,
625        };
626
627        let res = exec(cln_client.list_peers(req))
628            .map_err(|e| Error::rpc(e.to_string()))?
629            .into_inner();
630        Ok(res.into())
631    }
632
633    /// List all channels with peers.
634    ///
635    /// Returns detailed information about all channels including their
636    /// state, capacity, and balances.
637    pub fn list_peer_channels(&self) -> Result<ListPeerChannelsResponse, Error> {
638        self.check_connected()?;
639        let mut cln_client = exec(self.get_cln_client())?.clone();
640
641        let req = clnpb::ListpeerchannelsRequest { id: None };
642
643        let res = exec(cln_client.list_peer_channels(req))
644            .map_err(|e| Error::rpc(e.to_string()))?
645            .into_inner();
646        Ok(res.into())
647    }
648
649    /// List all funds available to the node.
650    ///
651    /// Returns information about on-chain outputs and channel funds
652    /// that are available or pending.
653    pub fn list_funds(&self) -> Result<ListFundsResponse, Error> {
654        self.check_connected()?;
655        let mut cln_client = exec(self.get_cln_client())?.clone();
656
657        let req = clnpb::ListfundsRequest { spent: None };
658
659        let res = exec(cln_client.list_funds(req))
660            .map_err(|e| Error::rpc(e.to_string()))?
661            .into_inner();
662        Ok(res.into())
663    }
664
665    /// Get a snapshot of the node's balances, capacity, and connectivity.
666    ///
667    /// Aggregates data from multiple RPCs into a single `NodeState`.
668    /// Queries the node live on each call — not cached.
669    pub fn node_state(&self) -> Result<NodeState, Error> {
670        self.check_connected()?;
671        let cln_client = exec(self.get_cln_client())?.clone();
672
673        let (info_res, channels_res, funds_res) = exec(async {
674            let mut c_info = cln_client.clone();
675            let mut c_channels = cln_client.clone();
676            let mut c_funds = cln_client.clone();
677            tokio::join!(
678                c_info.getinfo(clnpb::GetinfoRequest {}),
679                c_channels.list_peer_channels(clnpb::ListpeerchannelsRequest { id: None }),
680                c_funds.list_funds(clnpb::ListfundsRequest { spent: None }),
681            )
682        });
683
684        let info: GetInfoResponse = info_res
685            .map_err(|e| Error::rpc(e.to_string()))?
686            .into_inner()
687            .into();
688        let channels: ListPeerChannelsResponse = channels_res
689            .map_err(|e| Error::rpc(e.to_string()))?
690            .into_inner()
691            .into();
692        let funds: ListFundsResponse = funds_res
693            .map_err(|e| Error::rpc(e.to_string()))?
694            .into_inner()
695            .into();
696
697        let mut channels_balance_msat: u64 = 0;
698        let mut max_payable_msat: u64 = 0;
699        let mut total_channel_capacity_msat: u64 = 0;
700        let mut max_receivable_single_payment_msat: u64 = 0;
701        let mut total_inbound_liquidity_msat: u64 = 0;
702        let mut pending_onchain_balance_msat: u64 = 0;
703        let mut connected_channel_peer_set: std::collections::HashSet<String> =
704            std::collections::HashSet::new();
705
706        for ch in &channels.channels {
707            if ch.state.is_open() {
708                channels_balance_msat += ch.to_us_msat.unwrap_or(0);
709                max_payable_msat += ch.spendable_msat.unwrap_or(0);
710                total_channel_capacity_msat += ch.total_msat.unwrap_or(0);
711                let receivable = ch.receivable_msat.unwrap_or(0);
712                if receivable > max_receivable_single_payment_msat {
713                    max_receivable_single_payment_msat = receivable;
714                }
715                total_inbound_liquidity_msat += receivable;
716            }
717            if channel_payout_still_pending(ch) {
718                pending_onchain_balance_msat += ch.to_us_msat.unwrap_or(0);
719            }
720            if ch.peer_connected {
721                connected_channel_peer_set.insert(ch.peer_id.clone());
722            }
723        }
724
725        let connected_channel_peers: Vec<String> =
726            connected_channel_peer_set.into_iter().collect();
727
728        let max_chan_reserve_msat =
729            channels_balance_msat.saturating_sub(max_payable_msat);
730
731        let mut onchain_balance_msat: u64 = 0;
732        let mut unconfirmed_onchain_balance_msat: u64 = 0;
733        let mut immature_onchain_balance_msat: u64 = 0;
734        let mut utxos: Vec<FundOutput> = Vec::with_capacity(funds.outputs.len());
735        for output in &funds.outputs {
736            if !matches!(output.status, OutputStatus::Spent) {
737                utxos.push(output.clone());
738            }
739            if output.reserved {
740                continue;
741            }
742            match output.status {
743                OutputStatus::Confirmed => onchain_balance_msat += output.amount_msat,
744                OutputStatus::Unconfirmed => {
745                    unconfirmed_onchain_balance_msat += output.amount_msat
746                }
747                OutputStatus::Immature => {
748                    immature_onchain_balance_msat += output.amount_msat
749                }
750                OutputStatus::Spent => {}
751            }
752        }
753
754        let total_onchain_msat = onchain_balance_msat
755            .saturating_add(unconfirmed_onchain_balance_msat)
756            .saturating_add(immature_onchain_balance_msat);
757        let total_balance_msat = channels_balance_msat
758            .saturating_add(total_onchain_msat)
759            .saturating_add(pending_onchain_balance_msat);
760        let spendable_balance_msat = max_payable_msat.saturating_add(onchain_balance_msat);
761
762
763        Ok(NodeState {
764            id: info.id,
765            block_height: info.blockheight,
766            network: info.network,
767            version: info.version,
768            alias: info.alias,
769            color: info.color,
770            num_active_channels: info.num_active_channels,
771            num_pending_channels: info.num_pending_channels,
772            num_inactive_channels: info.num_inactive_channels,
773            channels_balance_msat,
774            max_payable_msat,
775            total_channel_capacity_msat,
776            max_chan_reserve_msat,
777            onchain_balance_msat,
778            unconfirmed_onchain_balance_msat,
779            immature_onchain_balance_msat,
780            pending_onchain_balance_msat,
781            max_receivable_single_payment_msat,
782            total_inbound_liquidity_msat,
783            connected_channel_peers,
784            utxos,
785            total_onchain_msat,
786            total_balance_msat,
787            spendable_balance_msat,
788        })
789    }
790
791    /// List invoices (received payment requests).
792    /// All parameters are optional filters; pass None to fetch all.
793    pub fn list_invoices(
794        &self,
795        label: Option<String>,
796        invstring: Option<String>,
797        payment_hash: Option<Vec<u8>>,
798        offer_id: Option<String>,
799        index: Option<ListIndex>,
800        start: Option<u64>,
801        limit: Option<u32>,
802    ) -> Result<ListInvoicesResponse, Error> {
803        self.check_connected()?;
804        let mut cln_client = exec(self.get_cln_client())?.clone();
805
806        let req = clnpb::ListinvoicesRequest {
807            label,
808            invstring,
809            payment_hash,
810            offer_id,
811            index: index.map(|i| i.to_i32()),
812            start,
813            limit,
814        };
815
816        let res = exec(cln_client.list_invoices(req))
817            .map_err(|e| Error::rpc(e.to_string()))?
818            .into_inner();
819        Ok(res.into())
820    }
821
822    /// List outgoing payments.
823    /// All parameters are optional filters; pass None to fetch all.
824    pub fn list_pays(
825        &self,
826        bolt11: Option<String>,
827        payment_hash: Option<Vec<u8>>,
828        status: Option<PayStatus>,
829        index: Option<ListIndex>,
830        start: Option<u64>,
831        limit: Option<u32>,
832    ) -> Result<ListPaysResponse, Error> {
833        self.check_connected()?;
834        let mut cln_client = exec(self.get_cln_client())?.clone();
835
836        // ListpaysRequest.ListpaysStatus: PENDING=0, COMPLETE=1, FAILED=2
837        let cln_status = status.map(|s| match s {
838            PayStatus::PENDING => 0,
839            PayStatus::COMPLETE => 1,
840            PayStatus::FAILED => 2,
841        });
842
843        let req = clnpb::ListpaysRequest {
844            bolt11,
845            payment_hash,
846            status: cln_status,
847            index: index.map(|i| i.to_i32()),
848            start,
849            limit,
850        };
851
852        let res = exec(cln_client.list_pays(req))
853            .map_err(|e| Error::rpc(e.to_string()))?
854            .into_inner();
855        Ok(res.into())
856    }
857
858    /// List payments (sent and received), merged into a single timeline.
859    ///
860    /// Fetches invoices and outgoing payments from the node, merges
861    /// them into a unified list, and applies optional filters.
862    /// Use `list_invoices`/`list_pays` for direct CLN access.
863    /// Results are sorted newest-first.
864    pub fn list_payments(&self, req: ListPaymentsRequest) -> Result<Vec<Payment>, Error> {
865        self.check_connected()?;
866        let mut cln_client = exec(self.get_cln_client())?.clone();
867
868        let invoices = exec(cln_client.list_invoices(clnpb::ListinvoicesRequest::default()))
869            .map_err(|e| Error::rpc(e.to_string()))?
870            .into_inner();
871
872        let mut cln_client = exec(self.get_cln_client())?.clone();
873        let pays = exec(cln_client.list_pays(clnpb::ListpaysRequest::default()))
874            .map_err(|e| Error::rpc(e.to_string()))?
875            .into_inner();
876
877        let mut payments: Vec<Payment> = Vec::new();
878
879        // Should we include received payments?
880        let include_received = req
881            .filters
882            .as_ref()
883            .map(|f| f.is_empty() || f.iter().any(|t| matches!(t, PaymentTypeFilter::Received)))
884            .unwrap_or(true);
885
886        // Should we include sent payments?
887        let include_sent = req
888            .filters
889            .as_ref()
890            .map(|f| f.is_empty() || f.iter().any(|t| matches!(t, PaymentTypeFilter::Sent)))
891            .unwrap_or(true);
892
893        if include_received {
894            // Only paid invoices belong in payment history. Open
895            // (unpaid) and expired invoices live behind list_invoices()
896            // for callers that want to inspect them directly.
897            payments.extend(
898                invoices
899                    .invoices
900                    .into_iter()
901                    .filter(|i| {
902                        i.status()
903                            == clnpb::listinvoices_invoices::ListinvoicesInvoicesStatus::Paid
904                    })
905                    .map(|i| -> Payment { i.into() }),
906            );
907        }
908        if include_sent {
909            payments.extend(pays.pays.into_iter().map(|p| -> Payment { p.into() }));
910        }
911
912        let include_failures = req.include_failures.unwrap_or(false);
913
914        payments.retain(|p| {
915            if !include_failures && matches!(p.status, PaymentStatus::Failed) {
916                return false;
917            }
918            if let Some(from) = req.from_timestamp {
919                if p.payment_time < from {
920                    return false;
921                }
922            }
923            if let Some(to) = req.to_timestamp {
924                if p.payment_time > to {
925                    return false;
926                }
927            }
928            true
929        });
930
931        // Sort newest first
932        payments.sort_by(|a, b| b.payment_time.cmp(&a.payment_time));
933
934        // Apply pagination
935        let offset = req.offset.unwrap_or(0) as usize;
936        let limit = req.limit.unwrap_or(u32::MAX) as usize;
937        let payments = payments.into_iter().skip(offset).take(limit).collect();
938
939        Ok(payments)
940    }
941
942    /// Stream real-time events from the node.
943    ///
944    /// Returns a `NodeEventStream` iterator. Call `next()` repeatedly
945    /// to receive events as they occur (e.g., invoice payments).
946    ///
947    /// The `next()` method blocks the calling thread until an event
948    /// is available, but does not block the underlying async runtime,
949    /// so other node methods can be called concurrently from other
950    /// threads.
951    pub fn stream_node_events(&self) -> Result<Arc<NodeEventStream>, Error> {
952        self.check_connected()?;
953        let mut gl_client = exec(self.get_gl_client())?.clone();
954        let req = glpb::NodeEventsRequest {};
955        let stream = exec(gl_client.stream_node_events(req))
956            .map_err(|e| Error::rpc(e.to_string()))?
957            .into_inner();
958        Ok(Arc::new(NodeEventStream {
959            inner: Mutex::new(stream),
960        }))
961    }
962
963    /// Collect a diagnostic snapshot of the node and SDK state.
964    ///
965    /// Returns a pretty-printed JSON string with shape:
966    /// `{ "timestamp": <unix-secs>, "node": { ... }, "sdk": { "version": ..., "node_state": ... } }`.
967    /// The `node` object contains one entry per CLN RPC (`getinfo`,
968    /// `listpeerchannels`, `listfunds`); each value is the serialized
969    /// response, or `{ "error": "..." }` if that RPC failed. Payment and
970    /// invoice history are deliberately excluded to avoid leaking
971    /// preimages, payment hashes, bolt11 strings, and labels into support
972    /// dumps. Intended for support tickets.
973    pub fn generate_diagnostic_data(&self) -> Result<String, Error> {
974        self.check_connected()?;
975
976        let timestamp = std::time::SystemTime::now()
977            .duration_since(std::time::UNIX_EPOCH)
978            .map(|d| d.as_secs())
979            .unwrap_or(0);
980
981        let getinfo = render_section(self.get_info());
982        let listpeerchannels = render_section(self.list_peer_channels());
983        let listfunds = render_section(self.list_funds());
984        let node_state = render_section(self.node_state());
985
986        build_diagnostic_json(
987            timestamp,
988            env!("CARGO_PKG_VERSION"),
989            getinfo,
990            listpeerchannels,
991            listfunds,
992            node_state,
993        )
994    }
995
996    // ── LNURL methods ───────────────────────────────────────────
997
998    /// Execute an LNURL-pay flow (LUD-06).
999    ///
1000    /// Sends the chosen amount (and optional comment) to the service's
1001    /// callback, receives and validates a BOLT11 invoice, pays it, and
1002    /// processes any success action (LUD-09/10).
1003    ///
1004    /// Call the top-level `parse_input` first to obtain the
1005    /// `LnUrlPayRequestData`, then build an `LnUrlPayRequest` with the
1006    /// user's chosen amount.
1007    pub fn lnurl_pay(
1008        &self,
1009        request: crate::lnurl::LnUrlPayRequest,
1010    ) -> Result<crate::lnurl::LnUrlPayResult, Error> {
1011        self.check_connected()?;
1012        validate_lnurl_pay_input(&request)?;
1013
1014        let http_client = gl_client::lnurl::models::LnUrlHttpClearnetClient::new();
1015
1016        // Phase 1: Get invoice from service callback
1017        let comment = request.comment.as_deref();
1018        let (invoice_str, success_action) = match exec(
1019            gl_client::lnurl::pay::fetch_invoice(
1020                &http_client,
1021                &request.data.callback,
1022                request.amount_msat,
1023                comment,
1024            ),
1025        ) {
1026            Ok(v) => v,
1027            Err(e) => {
1028                let msg = e.to_string();
1029                let reason = msg
1030                    .strip_prefix(gl_client::lnurl::pay::LNURL_SERVICE_ERROR_PREFIX)
1031                    .unwrap_or(&msg)
1032                    .to_string();
1033                return Ok(crate::lnurl::LnUrlPayResult::EndpointError {
1034                    data: crate::lnurl::LnUrlErrorData { reason },
1035                });
1036            }
1037        };
1038
1039        if let Some(reason) = invoice_network_mismatch(&invoice_str, self.network) {
1040            return Ok(crate::lnurl::LnUrlPayResult::EndpointError {
1041                data: crate::lnurl::LnUrlErrorData { reason },
1042            });
1043        }
1044
1045        // Phase 2: Pay the invoice
1046        let mut cln_client = exec(self.get_cln_client())?.clone();
1047        let pay_response = match exec(cln_client.pay(clnpb::PayRequest {
1048            bolt11: invoice_str.clone(),
1049            ..Default::default()
1050        })) {
1051            Ok(r) => r.into_inner(),
1052            Err(e) => {
1053                let payment_hash = invoice_str
1054                    .parse::<Bolt11Invoice>()
1055                    .ok()
1056                    .map(|inv| inv.payment_hash().to_string())
1057                    .unwrap_or_default();
1058                return Ok(crate::lnurl::LnUrlPayResult::PayError {
1059                    data: crate::lnurl::LnUrlPayErrorData {
1060                        payment_hash,
1061                        reason: e.to_string(),
1062                    },
1063                });
1064            }
1065        };
1066
1067        // Phase 3: Process success action if present
1068        let validate_url = request.validate_success_action_url.unwrap_or(true);
1069        let processed_action = match success_action {
1070            Some(action) => {
1071                let processed = action
1072                    .process(&pay_response.payment_preimage)
1073                    .map_err(|e| Error::other(e.to_string()))?;
1074                if validate_url {
1075                    if let gl_client::lnurl::models::ProcessedSuccessAction::Url {
1076                        url, ..
1077                    } = &processed
1078                    {
1079                        if let Some(reason) =
1080                            url_action_domain_mismatch(&request.data.callback, url)
1081                        {
1082                            return Err(Error::other(reason));
1083                        }
1084                    }
1085                }
1086                Some(processed.into())
1087            }
1088            None => None,
1089        };
1090
1091        Ok(crate::lnurl::LnUrlPayResult::EndpointSuccess {
1092            data: crate::lnurl::LnUrlPaySuccessData {
1093                payment_preimage: hex::encode(&pay_response.payment_preimage),
1094                success_action: processed_action,
1095            },
1096        })
1097    }
1098
1099    /// Execute an LNURL-withdraw flow (LUD-03).
1100    ///
1101    /// Creates an invoice on this node for the requested amount, sends
1102    /// it to the service's callback URL, and the service pays it
1103    /// asynchronously.
1104    ///
1105    /// Call the top-level `parse_input` first to obtain the
1106    /// `LnUrlWithdrawRequestData`, then build an `LnUrlWithdrawRequest`
1107    /// with the user's chosen amount.
1108    pub fn lnurl_withdraw(
1109        &self,
1110        request: crate::lnurl::LnUrlWithdrawRequest,
1111    ) -> Result<crate::lnurl::LnUrlWithdrawResult, Error> {
1112        self.check_connected()?;
1113
1114        let http_client = gl_client::lnurl::models::LnUrlHttpClearnetClient::new();
1115
1116        // Step 1: Create an invoice on our node
1117        let description = request
1118            .description
1119            .unwrap_or(request.data.default_description.clone());
1120
1121        let invoice_response = self.receive(
1122            format!("lnurl-withdraw-{}", request.data.k1),
1123            description,
1124            Some(request.amount_msat),
1125        )?;
1126
1127        // Step 2: Build callback URL and submit invoice to service
1128        let callback_url = gl_client::lnurl::withdraw::build_withdraw_callback_url(
1129            &request.data.callback,
1130            &request.data.k1,
1131            &invoice_response.bolt11,
1132        )
1133        .map_err(|e| Error::other(e.to_string()))?;
1134
1135        // Step 3: Send invoice to service
1136        match exec(http_client.send_invoice_for_withdraw_request(&callback_url)) {
1137            Ok(_) => Ok(crate::lnurl::LnUrlWithdrawResult::Ok {
1138                data: crate::lnurl::LnUrlWithdrawSuccessData {
1139                    invoice: invoice_response.bolt11,
1140                },
1141            }),
1142            Err(e) => Ok(crate::lnurl::LnUrlWithdrawResult::ErrorStatus {
1143                data: crate::lnurl::LnUrlErrorData {
1144                    reason: e.to_string(),
1145                },
1146            }),
1147        }
1148    }
1149}
1150
1151fn render_section<T: serde::Serialize>(result: Result<T, Error>) -> serde_json::Value {
1152    match result {
1153        Ok(v) => serde_json::to_value(&v)
1154            .unwrap_or_else(|e| serde_json::json!({ "error": e.to_string() })),
1155        Err(e) => serde_json::json!({ "error": e.to_string() }),
1156    }
1157}
1158
1159fn build_diagnostic_json(
1160    timestamp: u64,
1161    sdk_version: &str,
1162    getinfo: serde_json::Value,
1163    listpeerchannels: serde_json::Value,
1164    listfunds: serde_json::Value,
1165    node_state: serde_json::Value,
1166) -> Result<String, Error> {
1167    let envelope = serde_json::json!({
1168        "timestamp": timestamp,
1169        "node": {
1170            "getinfo": getinfo,
1171            "listpeerchannels": listpeerchannels,
1172            "listfunds": listfunds,
1173        },
1174        "sdk": {
1175            "version": sdk_version,
1176            "node_state": node_state,
1177        }
1178    });
1179    serde_json::to_string_pretty(&envelope).map_err(|e| Error::other(e.to_string()))
1180}
1181
1182/// Returns a human-readable reason if the invoice's BOLT-11 currency
1183/// prefix does not match the node's configured network.
1184///
1185/// Not a LUD-06 requirement; this is a wallet-side safety check that
1186/// prevents attempting to pay e.g. a testnet invoice from a mainnet
1187/// wallet. The payment would fail at the node layer regardless, but
1188/// this surfaces a clean error earlier.
1189fn invoice_network_mismatch(
1190    invoice_str: &str,
1191    node_network: gl_client::bitcoin::Network,
1192) -> Option<String> {
1193    use lightning_invoice::Currency;
1194    let invoice = invoice_str.parse::<Bolt11Invoice>().ok()?;
1195    let expected = match node_network {
1196        gl_client::bitcoin::Network::Bitcoin => Currency::Bitcoin,
1197        gl_client::bitcoin::Network::Testnet => Currency::BitcoinTestnet,
1198        gl_client::bitcoin::Network::Signet => Currency::Signet,
1199        gl_client::bitcoin::Network::Regtest => Currency::Regtest,
1200        _ => return None,
1201    };
1202    if invoice.currency() == expected {
1203        None
1204    } else {
1205        Some(format!(
1206            "invoice is for {:?}, but this node is on {:?}",
1207            invoice.currency(),
1208            node_network
1209        ))
1210    }
1211}
1212
1213fn url_action_domain_mismatch(callback_url: &str, action_url: &str) -> Option<String> {
1214    let cb = url::Url::parse(callback_url).ok()?;
1215    let action = url::Url::parse(action_url).ok()?;
1216    let cb_domain = cb.domain()?;
1217    let action_domain = action.domain()?;
1218    if cb_domain == action_domain {
1219        None
1220    } else {
1221        Some(format!(
1222            "success action URL domain ({}) does not match the callback domain ({})",
1223            action_domain, cb_domain
1224        ))
1225    }
1226}
1227
1228fn validate_lnurl_pay_input(request: &crate::lnurl::LnUrlPayRequest) -> Result<(), Error> {
1229    let data = &request.data;
1230    if request.amount_msat < data.min_sendable {
1231        return Err(Error::other(format!(
1232            "amount_msat {} is below the service's min_sendable ({})",
1233            request.amount_msat, data.min_sendable
1234        )));
1235    }
1236    if request.amount_msat > data.max_sendable {
1237        return Err(Error::other(format!(
1238            "amount_msat {} is above the service's max_sendable ({})",
1239            request.amount_msat, data.max_sendable
1240        )));
1241    }
1242    if let Some(comment) = request.comment.as_deref() {
1243        if data.comment_allowed == 0 && !comment.is_empty() {
1244            return Err(Error::other(
1245                "this LNURL service does not accept comments".to_string(),
1246            ));
1247        }
1248        if (comment.len() as u64) > data.comment_allowed {
1249            return Err(Error::other(format!(
1250                "comment length {} exceeds the service's comment_allowed ({})",
1251                comment.len(),
1252                data.comment_allowed
1253            )));
1254        }
1255    }
1256    Ok(())
1257}
1258
1259// Not exported through uniffi
1260impl Node {
1261    /// Install a listener that receives real-time node events.
1262    ///
1263    /// Spawns a background task that tails the gRPC event stream and
1264    /// invokes `listener.on_event(...)` for every event. Each `Node`
1265    /// holds at most one listener — calling again replaces it. The task
1266    /// stops when the stream ends, errors, or the `Node` is dropped.
1267    ///
1268    /// Crate-private — installed via `NodeBuilder::with_event_listener`
1269    /// at construction time so events emitted during node bring-up
1270    /// aren't missed.
1271    pub(crate) fn set_event_listener(
1272        &self,
1273        listener: std::sync::Arc<dyn NodeEventListener>,
1274    ) -> Result<(), Error> {
1275        self.check_connected()?;
1276        let mut gl_client = exec(self.get_gl_client())?.clone();
1277        let req = glpb::NodeEventsRequest {};
1278        let stream = exec(gl_client.stream_node_events(req))
1279            .map_err(|e| Error::rpc(e.to_string()))?
1280            .into_inner();
1281
1282        let mut guard = self
1283            .event_task
1284            .lock()
1285            .map_err(|e| Error::other(e.to_string()))?;
1286        if let Some(prev) = guard.take() {
1287            prev.abort();
1288        }
1289
1290        let task = crate::util::get_runtime().spawn(async move {
1291            let mut stream = stream;
1292            loop {
1293                match stream.message().await {
1294                    Ok(Some(raw)) => {
1295                        if let Some(event) = node_event_from_pb(raw) {
1296                            listener.on_event(event);
1297                        }
1298                    }
1299                    Ok(None) => break,
1300                    Err(e) if e.code() == tonic::Code::Unknown => break,
1301                    Err(_) => break,
1302                }
1303            }
1304        });
1305        *guard = Some(task);
1306        Ok(())
1307    }
1308
1309    fn check_connected(&self) -> Result<(), Error> {
1310        if self.disconnected.load(Ordering::Relaxed) {
1311            return Err(Error::other("Node is disconnected".to_string()));
1312        }
1313        Ok(())
1314    }
1315
1316    /// Internal constructor used by the high-level register/recover/connect functions.
1317    /// Creates a Node with credentials and signer handle attached.
1318    pub(crate) fn with_signer(
1319        credentials: Credentials,
1320        handle: Handle,
1321        network: gl_client::bitcoin::Network,
1322    ) -> Result<Self, Error> {
1323        let node_id = credentials
1324            .inner
1325            .node_id()
1326            .map_err(|_e| Error::unparseable_creds())?;
1327        let inner = ClientNode::new(node_id, credentials.inner.clone())
1328            .expect("infallible client instantiation");
1329
1330        let cln_client = OnceCell::const_new();
1331        let gl_client = OnceCell::const_new();
1332        Ok(Node {
1333            inner,
1334            cln_client,
1335            gl_client,
1336            stored_credentials: Some(credentials),
1337            signer_handle: Some(handle),
1338            disconnected: AtomicBool::new(false),
1339            event_task: Mutex::new(None),
1340            network,
1341        })
1342    }
1343
1344    async fn get_gl_client<'a>(&'a self) -> Result<&'a GlClient, Error> {
1345        let inner = self.inner.clone();
1346        self.gl_client
1347            .get_or_try_init(|| async { inner.schedule::<GlClient>().await })
1348            .await
1349            .map_err(|e| Error::rpc(e.to_string()))
1350    }
1351
1352    async fn get_cln_client<'a>(&'a self) -> Result<&'a ClnClient, Error> {
1353        let inner = self.inner.clone();
1354
1355        self.cln_client
1356            .get_or_try_init(|| async { inner.schedule::<ClnClient>().await })
1357            .await
1358            .map_err(|e| Error::rpc(e.to_string()))
1359    }
1360}
1361
1362/// A specific on-chain output, identified by its outpoint.
1363#[derive(Clone, uniffi::Record)]
1364pub struct Outpoint {
1365    /// Transaction id as lowercase hex (64 chars).
1366    pub txid: String,
1367    /// Output index within that transaction.
1368    pub vout: u32,
1369}
1370
1371/// On-chain fee rates in sats per virtual byte at various
1372/// confirmation targets, derived from the connected node's view of
1373/// network mempool conditions. Use as the basis for a fee-picker UI.
1374#[derive(Clone, uniffi::Record)]
1375pub struct OnchainFeeRates {
1376    /// Target the next block (~10 min).
1377    pub next_block_sat_per_vbyte: u64,
1378    /// ~30 minute confirmation target (3 blocks).
1379    pub half_hour_sat_per_vbyte: u64,
1380    /// ~1 hour confirmation target (6 blocks).
1381    pub hour_sat_per_vbyte: u64,
1382    /// ~1 day confirmation target (144 blocks). Suitable for
1383    /// non-urgent sweeps.
1384    pub day_sat_per_vbyte: u64,
1385    /// Network minimum relay fee. Anything below this will be
1386    /// rejected by mempool policy at broadcast time. Use as the
1387    /// lower bound of any user-facing fee slider.
1388    pub minimum_relay_sat_per_vbyte: u64,
1389}
1390
1391/// Convert sat/kw to sat/vbyte, rounding up so we never undershoot
1392/// the relay floor when the caller submits the value back.
1393fn sat_per_vbyte_from_perkw(perkw: u32) -> u64 {
1394    (perkw as u64).div_ceil(250)
1395}
1396
1397/// Pick the smallest-blockcount estimate that is `≥ target_blocks`.
1398/// If none exists (the longest estimate is shorter than `target_blocks`),
1399/// fall back to the longest estimate available. Returns the rate in
1400/// sat per kilo-weight (perkw).
1401fn pick_perkw_for_target(
1402    estimates: &[clnpb::FeeratesPerkwEstimates],
1403    target_blocks: u32,
1404) -> Option<u32> {
1405    let above = estimates
1406        .iter()
1407        .filter(|e| e.blockcount >= target_blocks)
1408        .min_by_key(|e| e.blockcount)
1409        .map(|e| e.feerate);
1410    above.or_else(|| {
1411        estimates
1412            .iter()
1413            .max_by_key(|e| e.blockcount)
1414            .map(|e| e.feerate)
1415    })
1416}
1417
1418/// Map CLN's perkw feerates into an `OnchainFeeRates` (sat/vbyte)
1419/// for the standard 5-bucket fee-picker UI. Rounds up at the
1420/// boundary so values produced here are never below the network's
1421/// relay floor when the caller submits them.
1422fn compute_fee_rates(perkw: Option<&clnpb::FeeratesPerkw>) -> OnchainFeeRates {
1423    // Universal fallback when the node hasn't reported feerates yet
1424    // (e.g. just after startup, no connection to bitcoind).
1425    const FALLBACK_SAT_PER_VBYTE: u64 = 1;
1426
1427    let Some(p) = perkw else {
1428        return OnchainFeeRates {
1429            next_block_sat_per_vbyte: FALLBACK_SAT_PER_VBYTE,
1430            half_hour_sat_per_vbyte: FALLBACK_SAT_PER_VBYTE,
1431            hour_sat_per_vbyte: FALLBACK_SAT_PER_VBYTE,
1432            day_sat_per_vbyte: FALLBACK_SAT_PER_VBYTE,
1433            minimum_relay_sat_per_vbyte: FALLBACK_SAT_PER_VBYTE,
1434        };
1435    };
1436
1437    let minimum_relay_sat_per_vbyte =
1438        sat_per_vbyte_from_perkw(p.min_acceptable).max(FALLBACK_SAT_PER_VBYTE);
1439
1440    let bucket = |target_blocks: u32| -> u64 {
1441        pick_perkw_for_target(&p.estimates, target_blocks)
1442            .map(sat_per_vbyte_from_perkw)
1443            .unwrap_or(minimum_relay_sat_per_vbyte)
1444            .max(minimum_relay_sat_per_vbyte)
1445    };
1446
1447    OnchainFeeRates {
1448        next_block_sat_per_vbyte: bucket(1),
1449        half_hour_sat_per_vbyte: bucket(3),
1450        hour_sat_per_vbyte: bucket(6),
1451        day_sat_per_vbyte: bucket(144),
1452        minimum_relay_sat_per_vbyte,
1453    }
1454}
1455
1456/// Classifies the on-chain wallet into discrete cases that a wallet
1457/// UI can switch on to render the correct entry-point for the
1458/// withdraw flow. Derived purely from `NodeState` — no RPC.
1459#[derive(Clone, uniffi::Enum)]
1460pub enum OnchainBalanceState {
1461    /// No funds on-chain in any form (confirmed, unconfirmed,
1462    /// immature, or pending channel-close payouts are all zero).
1463    /// Don't render a withdraw entry point.
1464    Unavailable,
1465
1466    /// Funds are spendable now. Render the withdraw entry point
1467    /// enabled with `withdrawable_sat` as the headline.
1468    Available {
1469        /// `onchain_balance_sat - emergency_reserve_sat`. Use as
1470        /// the displayed amount on the entry point.
1471        withdrawable_sat: u64,
1472        /// Held back by CLN for anchor-channel safety; cannot be
1473        /// withdrawn without closing channels first.
1474        emergency_reserve_sat: u64,
1475        /// Inbound on-chain funds not yet confirmed. Informational
1476        /// only — not part of `withdrawable_sat`.
1477        unconfirmed_sat: u64,
1478    },
1479
1480    /// On-chain funds exist but are entirely locked as the
1481    /// anchor-channel emergency reserve. Render the entry point
1482    /// disabled with an explainer (e.g. "close channels to free
1483    /// these funds").
1484    ReserveOnly { reserve_sat: u64 },
1485
1486    /// Inbound on-chain funds are awaiting confirmation. Render a
1487    /// "pending" indicator instead of an enabled withdraw button.
1488    PendingConfirmation { unconfirmed_sat: u64 },
1489
1490    /// Funds exist as CSV-timelocked outputs from a recent channel
1491    /// close and can't be spent until the relative locktime
1492    /// expires. Render the entry point disabled with a
1493    /// "channel closing" explainer.
1494    Immature { immature_sat: u64 },
1495}
1496
1497/// Pure variant classifier. Given the five sat-denominated balance
1498/// figures, decide which `OnchainBalanceState` variant applies. The
1499/// public method `Node::onchain_balance_state` gathers the figures
1500/// from CLN and calls this.
1501fn classify_onchain_balance(
1502    confirmed_sat: u64,
1503    reserve_sat: u64,
1504    unconfirmed_sat: u64,
1505    immature_sat: u64,
1506    pending_close_sat: u64,
1507) -> OnchainBalanceState {
1508    let withdrawable_sat = confirmed_sat.saturating_sub(reserve_sat);
1509
1510    if confirmed_sat == 0
1511        && unconfirmed_sat == 0
1512        && immature_sat == 0
1513        && pending_close_sat == 0
1514    {
1515        return OnchainBalanceState::Unavailable;
1516    }
1517    if withdrawable_sat > ONCHAIN_DUST_THRESHOLD_SAT {
1518        return OnchainBalanceState::Available {
1519            withdrawable_sat,
1520            emergency_reserve_sat: reserve_sat,
1521            unconfirmed_sat,
1522        };
1523    }
1524    if confirmed_sat > 0 && reserve_sat > 0 {
1525        return OnchainBalanceState::ReserveOnly { reserve_sat };
1526    }
1527    if unconfirmed_sat > 0 {
1528        return OnchainBalanceState::PendingConfirmation { unconfirmed_sat };
1529    }
1530    OnchainBalanceState::Immature { immature_sat }
1531}
1532
1533/// Preview of an on-chain send: the inputs CLN would select at the
1534/// given fee rate, the resulting fee, and the amount the recipient
1535/// would receive. Inputs are NOT reserved — the wallet is free to
1536/// spend them via other paths until `onchain_send` actually broadcasts.
1537///
1538/// Pass `utxos` and `sat_per_vbyte` back to `onchain_send` to broadcast
1539/// with identical inputs and fee.
1540///
1541/// Amounts are in satoshis: on-chain transactions cannot carry sub-sat
1542/// precision, so msat denomination would be misleading here.
1543#[derive(uniffi::Record)]
1544pub struct PreparedOnchainSend {
1545    /// UTXOs that would be spent, in selection order.
1546    pub utxos: Vec<Outpoint>,
1547    /// Sum of all input UTXO values, in satoshis.
1548    pub total_input_sat: u64,
1549    /// Fee that would be paid, in satoshis.
1550    pub fee_sat: u64,
1551    /// Amount the recipient would receive, in satoshis.
1552    /// For a sweep ("all") this equals `total_input_sat - fee_sat`.
1553    /// For a fixed amount this equals the requested amount.
1554    pub recipient_sat: u64,
1555    /// Effective fee rate (sat per virtual byte) the node used to
1556    /// compute this preview. Equal to the caller's `sat_per_vbyte` if
1557    /// one was supplied; otherwise the rate the node picked at
1558    /// "normal" priority. Pass this back to `onchain_send` to
1559    /// reproduce the previewed fee.
1560    pub sat_per_vbyte: u32,
1561}
1562
1563/// Result of an on-chain send. The transaction has already been broadcast.
1564#[derive(uniffi::Record)]
1565pub struct OnchainSendResponse {
1566    /// The raw signed transaction bytes.
1567    pub tx: Vec<u8>,
1568    /// The transaction id as lowercase hex (64 chars).
1569    pub txid: String,
1570    /// The transaction as a Partially Signed Bitcoin Transaction string.
1571    pub psbt: String,
1572}
1573
1574/// Parse an `amount_or_all` argument into the protobuf `AmountOrAll`.
1575/// Accepts `"all"`, `"<n>"`, `"<n>sat"`, or `"<n>msat"`.
1576fn parse_amount_or_all(amount_or_all: &str) -> Result<clnpb::AmountOrAll, Error> {
1577    let (num, suffix): (String, String) =
1578        amount_or_all.chars().partition(|c| c.is_ascii_digit());
1579
1580    let num = if num.is_empty() {
1581        0
1582    } else {
1583        num.parse::<u64>()
1584            .map_err(|_| Error::argument("amount_or_all", amount_or_all))?
1585    };
1586
1587    match (num, suffix.as_str()) {
1588        (n, "") | (n, "sat") => Ok(clnpb::AmountOrAll {
1589            value: Some(clnpb::amount_or_all::Value::Amount(clnpb::Amount {
1590                msat: n * 1000,
1591            })),
1592        }),
1593        (n, "msat") => Ok(clnpb::AmountOrAll {
1594            value: Some(clnpb::amount_or_all::Value::Amount(clnpb::Amount { msat: n })),
1595        }),
1596        (0, "all") => Ok(clnpb::AmountOrAll {
1597            value: Some(clnpb::amount_or_all::Value::All(true)),
1598        }),
1599        _ => Err(Error::argument("amount_or_all", amount_or_all)),
1600    }
1601}
1602
1603/// Build a CLN `Feerate` from a sat/vbyte value. CLN measures rates
1604/// in sat per 1000 weight units, and 1 vbyte = 4 weight units, so
1605/// `sat/kw = sat/vbyte × 250`.
1606fn feerate_perkw_from_sat_per_vbyte(sat_per_vbyte: u32) -> clnpb::Feerate {
1607    clnpb::Feerate {
1608        style: Some(clnpb::feerate::Style::Perkw(sat_per_vbyte * 250)),
1609    }
1610}
1611
1612/// Convert a public `Outpoint` (hex txid) into the protobuf form
1613/// (raw txid bytes) used by CLN's `WithdrawRequest`.
1614fn outpoint_to_pb(o: Outpoint) -> Result<clnpb::Outpoint, Error> {
1615    let txid = hex::decode(&o.txid)
1616        .map_err(|_| Error::argument("utxos.txid", o.txid.clone()))?;
1617    Ok(clnpb::Outpoint {
1618        txid,
1619        outnum: o.vout,
1620    })
1621}
1622
1623/// Base transaction overhead in BIP-141 weight units, for a typical
1624/// segwit transaction with 1–252 inputs and 1–252 outputs:
1625/// `(version=4 + input_count_varint=1 + output_count_varint=1 +
1626/// locktime=4) × 4 + segwit_marker_flag=2 = 42 wu`. This is the
1627/// `bitcoin_tx_core_weight(1, 1)` value from CLN
1628/// (`lightning/bitcoin/tx.c:849`). At 253+ inputs/outputs the varints
1629/// grow to 3 bytes (8 wu more), which is rare enough to ignore.
1630const BASE_TX_CORE_WEIGHT: u32 = 42;
1631
1632/// Conservative dust gate for the on-chain entry-point: highest dust
1633/// threshold across common output types at Bitcoin Core's default
1634/// `DUST_RELAY_TX_FEE = 3000` (sat/kvB). P2PKH is 546 sat, P2WPKH
1635/// 294 sat, P2TR/P2WSH 330 sat. Using 546 means `Available` implies
1636/// the user can plausibly send to any common address type.
1637/// Source: Bitcoin Core `policy/policy.cpp::GetDustThreshold` and
1638/// `bitcoin::Script::minimal_non_dust` at the default relay fee.
1639const ONCHAIN_DUST_THRESHOLD_SAT: u64 = 546;
1640
1641/// Serialized weight (BIP-141 weight units) of a single output paying
1642/// to the given address. Used (with `BASE_TX_CORE_WEIGHT`) as
1643/// `startweight` for `FundPsbt`, which only accounts for inputs and
1644/// change on top of what the caller declares.
1645///
1646/// Output bytes = 8 (value) + varint(script_len) + script_pubkey.
1647/// All standard scripts are < 253 bytes so the varint is 1 byte. The
1648/// total is then × 4 since outputs are non-witness data.
1649///
1650/// Falls back to 172 wu for unparseable inputs so the fee is over-
1651/// rather than under-estimated.
1652fn output_weight_for_address(addr: &str) -> u32 {
1653    match bitcoin::Address::from_str(addr) {
1654        Ok(a) => {
1655            let spk_len = a.assume_checked().script_pubkey().len();
1656            let varint_len = if spk_len < 0xfd { 1 } else { 3 };
1657            ((8 + varint_len + spk_len) * 4) as u32
1658        }
1659        Err(_) => 172,
1660    }
1661}
1662
1663impl From<clnpb::WithdrawResponse> for OnchainSendResponse {
1664    fn from(other: clnpb::WithdrawResponse) -> Self {
1665        Self {
1666            tx: other.tx,
1667            txid: hex::encode(&other.txid),
1668            psbt: other.psbt,
1669        }
1670    }
1671}
1672
1673/// A pair of on-chain addresses for receiving funds.
1674#[derive(uniffi::Record)]
1675pub struct OnchainReceiveResponse {
1676    /// SegWit v0 (bech32) address — starts with `bc1q` on mainnet.
1677    pub bech32: String,
1678    /// Taproot (bech32m) address — starts with `bc1p` on mainnet.
1679    pub p2tr: String,
1680}
1681
1682impl From<clnpb::NewaddrResponse> for OnchainReceiveResponse {
1683    fn from(other: clnpb::NewaddrResponse) -> Self {
1684        OnchainReceiveResponse {
1685            bech32: other.bech32.unwrap_or_default(),
1686            p2tr: other.p2tr.unwrap_or_default(),
1687        }
1688    }
1689}
1690
1691#[derive(uniffi::Record)]
1692pub struct SendResponse {
1693    pub status: PayStatus,
1694    /// Payment preimage (proof of payment) as lowercase hex (64 chars).
1695    pub preimage: String,
1696    /// Payment hash as lowercase hex (64 chars).
1697    pub payment_hash: String,
1698    /// Recipient node pubkey as lowercase hex (66 chars), if known.
1699    pub destination_pubkey: Option<String>,
1700    pub amount_msat: u64,
1701    pub amount_sent_msat: u64,
1702    pub parts: u32,
1703}
1704
1705impl From<clnpb::PayResponse> for SendResponse {
1706    fn from(other: clnpb::PayResponse) -> Self {
1707        Self {
1708            status: other.status.into(),
1709            preimage: hex::encode(&other.payment_preimage),
1710            payment_hash: hex::encode(&other.payment_hash),
1711            destination_pubkey: other.destination.as_deref().map(hex::encode),
1712            amount_msat: other.amount_msat.unwrap().msat,
1713            amount_sent_msat: other.amount_sent_msat.unwrap().msat,
1714            parts: other.parts,
1715        }
1716    }
1717}
1718
1719#[derive(uniffi::Record)]
1720pub struct ReceiveResponse {
1721    pub bolt11: String,
1722    /// The fee charged by the LSP for opening a JIT channel, in
1723    /// millisatoshi. This is 0 if no JIT channel was needed.
1724    pub opening_fee_msat: u64,
1725}
1726
1727#[derive(uniffi::Enum, Clone, serde::Serialize)]
1728pub enum PayStatus {
1729    COMPLETE = 0,
1730    PENDING = 1,
1731    FAILED = 2,
1732}
1733
1734impl From<clnpb::pay_response::PayStatus> for PayStatus {
1735    fn from(other: clnpb::pay_response::PayStatus) -> Self {
1736        match other {
1737            clnpb::pay_response::PayStatus::Complete => PayStatus::COMPLETE,
1738            clnpb::pay_response::PayStatus::Failed => PayStatus::FAILED,
1739            clnpb::pay_response::PayStatus::Pending => PayStatus::PENDING,
1740        }
1741    }
1742}
1743
1744impl From<i32> for PayStatus {
1745    fn from(i: i32) -> Self {
1746        match i {
1747            0 => PayStatus::COMPLETE,
1748            1 => PayStatus::PENDING,
1749            2 => PayStatus::FAILED,
1750            o => panic!("Unknown pay_status {}", o),
1751        }
1752    }
1753}
1754
1755// ============================================================
1756// GetInfo response types
1757// ============================================================
1758
1759#[allow(unused)]
1760#[derive(Clone, serde::Serialize, uniffi::Record)]
1761pub struct GetInfoResponse {
1762    /// Node public key as lowercase hex (66 chars).
1763    pub id: String,
1764    pub alias: Option<String>,
1765    /// 3-byte RGB color as lowercase hex (6 chars).
1766    pub color: String,
1767    pub num_peers: u32,
1768    pub num_pending_channels: u32,
1769    pub num_active_channels: u32,
1770    pub num_inactive_channels: u32,
1771    pub version: String,
1772    pub lightning_dir: String,
1773    pub blockheight: u32,
1774    pub network: String,
1775    pub fees_collected_msat: u64,
1776}
1777
1778impl From<clnpb::GetinfoResponse> for GetInfoResponse {
1779    fn from(other: clnpb::GetinfoResponse) -> Self {
1780        Self {
1781            id: hex::encode(&other.id),
1782            alias: other.alias,
1783            color: hex::encode(&other.color),
1784            num_peers: other.num_peers,
1785            num_pending_channels: other.num_pending_channels,
1786            num_active_channels: other.num_active_channels,
1787            num_inactive_channels: other.num_inactive_channels,
1788            version: other.version,
1789            lightning_dir: other.lightning_dir,
1790            blockheight: other.blockheight,
1791            network: other.network,
1792            fees_collected_msat: other.fees_collected_msat.map(|a| a.msat).unwrap_or(0),
1793        }
1794    }
1795}
1796
1797// ============================================================
1798// ListPeers response types
1799// ============================================================
1800
1801#[allow(unused)]
1802#[derive(Clone, uniffi::Record)]
1803pub struct ListPeersResponse {
1804    pub peers: Vec<Peer>,
1805}
1806
1807#[allow(unused)]
1808#[derive(Clone, uniffi::Record)]
1809pub struct Peer {
1810    /// Peer node public key as lowercase hex (66 chars).
1811    pub id: String,
1812    pub connected: bool,
1813    pub num_channels: Option<u32>,
1814    pub netaddr: Vec<String>,
1815    pub remote_addr: Option<String>,
1816    pub features: Option<Vec<u8>>,
1817}
1818
1819impl From<clnpb::ListpeersResponse> for ListPeersResponse {
1820    fn from(other: clnpb::ListpeersResponse) -> Self {
1821        Self {
1822            peers: other.peers.into_iter().map(|p| p.into()).collect(),
1823        }
1824    }
1825}
1826
1827impl From<clnpb::ListpeersPeers> for Peer {
1828    fn from(other: clnpb::ListpeersPeers) -> Self {
1829        Self {
1830            id: hex::encode(&other.id),
1831            connected: other.connected,
1832            num_channels: other.num_channels,
1833            netaddr: other.netaddr,
1834            remote_addr: other.remote_addr,
1835            features: other.features,
1836        }
1837    }
1838}
1839
1840// ============================================================
1841// ListPeerChannels response types
1842// ============================================================
1843
1844#[allow(unused)]
1845#[derive(Clone, serde::Serialize, uniffi::Record)]
1846pub struct ListPeerChannelsResponse {
1847    pub channels: Vec<PeerChannel>,
1848}
1849
1850#[allow(unused)]
1851#[derive(Clone, serde::Serialize, uniffi::Record)]
1852pub struct PeerChannel {
1853    /// Peer node public key as lowercase hex (66 chars).
1854    pub peer_id: String,
1855    pub peer_connected: bool,
1856    pub state: ChannelState,
1857    pub short_channel_id: Option<String>,
1858    /// Channel id as lowercase hex (64 chars).
1859    pub channel_id: Option<String>,
1860    /// Funding transaction id as lowercase hex (64 chars).
1861    pub funding_txid: Option<String>,
1862    pub funding_outnum: Option<u32>,
1863    pub to_us_msat: Option<u64>,
1864    pub total_msat: Option<u64>,
1865    pub spendable_msat: Option<u64>,
1866    pub receivable_msat: Option<u64>,
1867    /// Which side initiated the close, if the channel is closing or closed.
1868    pub closer: Option<ChannelSide>,
1869    /// Human-readable status strings from CLN, ordered oldest to newest.
1870    /// For a channel in `Onchain` state, the last entry indicates whether
1871    /// our payout is still timelocked (`DELAYED_OUTPUT_TO_US`) or already
1872    /// available in the on-chain balance.
1873    pub status: Vec<String>,
1874}
1875
1876/// Which side of a channel performed a given action (e.g. initiated close).
1877#[derive(Clone, serde::Serialize, uniffi::Enum)]
1878pub enum ChannelSide {
1879    Local,
1880    Remote,
1881}
1882
1883impl ChannelSide {
1884    fn from_i32(value: i32) -> Option<Self> {
1885        match value {
1886            0 => Some(ChannelSide::Local),
1887            1 => Some(ChannelSide::Remote),
1888            _ => None,
1889        }
1890    }
1891}
1892
1893#[derive(Clone, serde::Serialize, uniffi::Enum)]
1894pub enum ChannelState {
1895    Openingd,
1896    ChanneldAwaitingLockin,
1897    ChanneldNormal,
1898    ChanneldShuttingDown,
1899    ClosingdSigexchange,
1900    ClosingdComplete,
1901    AwaitingUnilateral,
1902    FundingSpendSeen,
1903    Onchain,
1904    DualopendOpenInit,
1905    DualopendAwaitingLockin,
1906    DualopendOpenCommitted,
1907    DualopendOpenCommitReady,
1908    /// A state reported by the node that this SDK doesn't recognize.
1909    /// Returned when CLN introduces a new channel state after this SDK
1910    /// was built. Treated as neither open nor closing by balance math.
1911    Unknown,
1912}
1913
1914impl ChannelState {
1915    fn from_i32(value: i32) -> Self {
1916        match value {
1917            0 => ChannelState::Openingd,
1918            1 => ChannelState::ChanneldAwaitingLockin,
1919            2 => ChannelState::ChanneldNormal,
1920            3 => ChannelState::ChanneldShuttingDown,
1921            4 => ChannelState::ClosingdSigexchange,
1922            5 => ChannelState::ClosingdComplete,
1923            6 => ChannelState::AwaitingUnilateral,
1924            7 => ChannelState::FundingSpendSeen,
1925            8 => ChannelState::Onchain,
1926            9 => ChannelState::DualopendOpenInit,
1927            10 => ChannelState::DualopendAwaitingLockin,
1928            11 => ChannelState::DualopendOpenCommitted,
1929            12 => ChannelState::DualopendOpenCommitReady,
1930            _ => ChannelState::Unknown,
1931        }
1932    }
1933
1934    fn is_open(&self) -> bool {
1935        matches!(self, ChannelState::ChanneldNormal)
1936    }
1937
1938}
1939
1940/// Returns true when the channel still holds on-chain funds that have
1941/// not yet been credited to the wallet's on-chain balance.
1942///
1943/// In closing states up to `FundingSpendSeen`, the payout has not yet
1944/// appeared as a wallet UTXO and `to_us_msat` represents funds still
1945/// locked in the channel.
1946///
1947/// In `Onchain` state CLN keeps the channel around for the duration of
1948/// the close timelock. Once the close tx is mined the payout is visible
1949/// in `listfunds.outputs`, so counting `to_us_msat` again would double
1950/// it. The exception is when we initiated the close and our payout is
1951/// still timelocked (the last status entry contains `DELAYED_OUTPUT_TO_US`):
1952/// in that window the funds exist on-chain but are not yet spendable.
1953fn channel_payout_still_pending(ch: &PeerChannel) -> bool {
1954    match ch.state {
1955        ChannelState::ChanneldShuttingDown
1956        | ChannelState::ClosingdSigexchange
1957        | ChannelState::ClosingdComplete
1958        | ChannelState::AwaitingUnilateral
1959        | ChannelState::FundingSpendSeen => true,
1960        ChannelState::Onchain => {
1961            matches!(ch.closer, Some(ChannelSide::Local))
1962                && ch
1963                    .status
1964                    .last()
1965                    .is_some_and(|s| s.contains("DELAYED_OUTPUT_TO_US"))
1966        }
1967        _ => false,
1968    }
1969}
1970
1971impl From<clnpb::ListpeerchannelsResponse> for ListPeerChannelsResponse {
1972    fn from(other: clnpb::ListpeerchannelsResponse) -> Self {
1973        Self {
1974            channels: other.channels.into_iter().map(|c| c.into()).collect(),
1975        }
1976    }
1977}
1978
1979impl From<clnpb::ListpeerchannelsChannels> for PeerChannel {
1980    fn from(other: clnpb::ListpeerchannelsChannels) -> Self {
1981        let state = ChannelState::from_i32(other.state);
1982        let closer = other.closer.and_then(ChannelSide::from_i32);
1983        Self {
1984            peer_id: hex::encode(&other.peer_id),
1985            peer_connected: other.peer_connected,
1986            state,
1987            short_channel_id: other.short_channel_id,
1988            channel_id: other.channel_id.as_deref().map(hex::encode),
1989            funding_txid: other.funding_txid.as_deref().map(hex::encode),
1990            funding_outnum: other.funding_outnum,
1991            to_us_msat: other.to_us_msat.map(|a| a.msat),
1992            total_msat: other.total_msat.map(|a| a.msat),
1993            spendable_msat: other.spendable_msat.map(|a| a.msat),
1994            receivable_msat: other.receivable_msat.map(|a| a.msat),
1995            closer,
1996            status: other.status,
1997        }
1998    }
1999}
2000
2001// ============================================================
2002// ListFunds response types
2003// ============================================================
2004
2005#[allow(unused)]
2006#[derive(Clone, serde::Serialize, uniffi::Record)]
2007pub struct ListFundsResponse {
2008    pub outputs: Vec<FundOutput>,
2009    pub channels: Vec<FundChannel>,
2010}
2011
2012#[allow(unused)]
2013#[derive(Clone, serde::Serialize, uniffi::Record)]
2014pub struct FundOutput {
2015    /// Transaction id as lowercase hex (64 chars).
2016    pub txid: String,
2017    pub output: u32,
2018    pub amount_msat: u64,
2019    pub status: OutputStatus,
2020    pub address: Option<String>,
2021    pub blockheight: Option<u32>,
2022    /// True when this UTXO is currently reserved by an in-flight PSBT
2023    /// (e.g. a channel-open or fund-send that has not been broadcast or
2024    /// abandoned). Reserved UTXOs are not spendable and must be excluded
2025    /// from the wallet's spendable balance.
2026    pub reserved: bool,
2027}
2028
2029#[derive(Clone, serde::Serialize, uniffi::Enum)]
2030pub enum OutputStatus {
2031    Unconfirmed,
2032    Confirmed,
2033    Spent,
2034    Immature,
2035}
2036
2037impl OutputStatus {
2038    fn from_i32(value: i32) -> Self {
2039        match value {
2040            0 => OutputStatus::Unconfirmed,
2041            1 => OutputStatus::Confirmed,
2042            2 => OutputStatus::Spent,
2043            3 => OutputStatus::Immature,
2044            _ => OutputStatus::Unconfirmed, // Default fallback
2045        }
2046    }
2047}
2048
2049#[allow(unused)]
2050#[derive(Clone, serde::Serialize, uniffi::Record)]
2051pub struct FundChannel {
2052    /// Peer node public key as lowercase hex (66 chars).
2053    pub peer_id: String,
2054    pub our_amount_msat: u64,
2055    pub amount_msat: u64,
2056    /// Funding transaction id as lowercase hex (64 chars).
2057    pub funding_txid: String,
2058    pub funding_output: u32,
2059    pub connected: bool,
2060    pub state: ChannelState,
2061    pub short_channel_id: Option<String>,
2062    /// Channel id as lowercase hex (64 chars).
2063    pub channel_id: Option<String>,
2064}
2065
2066impl From<clnpb::ListfundsResponse> for ListFundsResponse {
2067    fn from(other: clnpb::ListfundsResponse) -> Self {
2068        Self {
2069            outputs: other.outputs.into_iter().map(|o| o.into()).collect(),
2070            channels: other.channels.into_iter().map(|c| c.into()).collect(),
2071        }
2072    }
2073}
2074
2075impl From<clnpb::ListfundsOutputs> for FundOutput {
2076    fn from(other: clnpb::ListfundsOutputs) -> Self {
2077        let status = OutputStatus::from_i32(other.status);
2078        Self {
2079            txid: hex::encode(&other.txid),
2080            output: other.output,
2081            amount_msat: other.amount_msat.map(|a| a.msat).unwrap_or(0),
2082            status,
2083            address: other.address,
2084            blockheight: other.blockheight,
2085            reserved: other.reserved,
2086        }
2087    }
2088}
2089
2090impl From<clnpb::ListfundsChannels> for FundChannel {
2091    fn from(other: clnpb::ListfundsChannels) -> Self {
2092        let state = ChannelState::from_i32(other.state);
2093        Self {
2094            peer_id: hex::encode(&other.peer_id),
2095            our_amount_msat: other.our_amount_msat.map(|a| a.msat).unwrap_or(0),
2096            amount_msat: other.amount_msat.map(|a| a.msat).unwrap_or(0),
2097            funding_txid: hex::encode(&other.funding_txid),
2098            funding_output: other.funding_output,
2099            connected: other.connected,
2100            state,
2101            short_channel_id: other.short_channel_id,
2102            channel_id: other.channel_id.as_deref().map(hex::encode),
2103        }
2104    }
2105}
2106
2107// ============================================================
2108// Shared pagination types
2109// ============================================================
2110
2111/// Index field used by CLN's paginated list RPCs.
2112#[derive(Clone, uniffi::Enum)]
2113pub enum ListIndex {
2114    CREATED,
2115    UPDATED,
2116}
2117
2118impl ListIndex {
2119    fn to_i32(&self) -> i32 {
2120        match self {
2121            ListIndex::CREATED => 0,
2122            ListIndex::UPDATED => 1,
2123        }
2124    }
2125}
2126
2127// ============================================================
2128// ListInvoices response types
2129// ============================================================
2130
2131#[derive(Clone, serde::Serialize, uniffi::Enum)]
2132pub enum InvoiceStatus {
2133    UNPAID,
2134    PAID,
2135    EXPIRED,
2136}
2137
2138impl From<i32> for InvoiceStatus {
2139    fn from(i: i32) -> Self {
2140        match i {
2141            0 => InvoiceStatus::UNPAID,
2142            1 => InvoiceStatus::PAID,
2143            2 => InvoiceStatus::EXPIRED,
2144            o => panic!("Unknown invoice status {}", o),
2145        }
2146    }
2147}
2148
2149#[derive(Clone, serde::Serialize, uniffi::Record)]
2150pub struct Invoice {
2151    pub label: String,
2152    pub description: String,
2153    /// Payment hash as lowercase hex (64 chars).
2154    pub payment_hash: String,
2155    pub status: InvoiceStatus,
2156    pub amount_msat: Option<u64>,
2157    pub amount_received_msat: Option<u64>,
2158    pub bolt11: Option<String>,
2159    pub bolt12: Option<String>,
2160    pub paid_at: Option<u64>,
2161    pub expires_at: u64,
2162    /// Payment preimage as lowercase hex (64 chars), if the invoice has been paid.
2163    pub payment_preimage: Option<String>,
2164    /// Recipient node pubkey as lowercase hex (66 chars), recovered from the bolt11.
2165    pub destination_pubkey: Option<String>,
2166}
2167
2168/// Extract the payee public key from a BOLT11 invoice string as hex.
2169fn pubkey_from_bolt11(bolt11: &str) -> Option<String> {
2170    let invoice: Bolt11Invoice = bolt11.parse().ok()?;
2171    Some(hex::encode(invoice.recover_payee_pub_key().serialize()))
2172}
2173
2174impl From<clnpb::ListinvoicesInvoices> for Invoice {
2175    fn from(other: clnpb::ListinvoicesInvoices) -> Self {
2176        let destination_pubkey = other.bolt11.as_deref().and_then(pubkey_from_bolt11);
2177        Self {
2178            label: other.label,
2179            description: other.description.unwrap_or_default(),
2180            payment_hash: hex::encode(&other.payment_hash),
2181            status: other.status.into(),
2182            amount_msat: other.amount_msat.map(|a| a.msat),
2183            amount_received_msat: other.amount_received_msat.map(|a| a.msat),
2184            bolt11: other.bolt11,
2185            bolt12: other.bolt12,
2186            paid_at: other.paid_at,
2187            expires_at: other.expires_at,
2188            payment_preimage: other.payment_preimage.as_deref().map(hex::encode),
2189            destination_pubkey,
2190        }
2191    }
2192}
2193
2194#[derive(Clone, serde::Serialize, uniffi::Record)]
2195pub struct ListInvoicesResponse {
2196    pub invoices: Vec<Invoice>,
2197}
2198
2199impl From<clnpb::ListinvoicesResponse> for ListInvoicesResponse {
2200    fn from(other: clnpb::ListinvoicesResponse) -> Self {
2201        Self {
2202            invoices: other.invoices.into_iter().map(|i| i.into()).collect(),
2203        }
2204    }
2205}
2206
2207// ============================================================
2208// ListPays response types
2209// ============================================================
2210
2211#[derive(Clone, serde::Serialize, uniffi::Record)]
2212pub struct Pay {
2213    /// Payment hash as lowercase hex (64 chars).
2214    pub payment_hash: String,
2215    pub status: PayStatus,
2216    /// Recipient node pubkey as lowercase hex (66 chars), if known.
2217    pub destination_pubkey: Option<String>,
2218    pub amount_msat: Option<u64>,
2219    pub amount_sent_msat: Option<u64>,
2220    pub label: Option<String>,
2221    pub bolt11: Option<String>,
2222    pub description: Option<String>,
2223    pub bolt12: Option<String>,
2224    /// Payment preimage as lowercase hex (64 chars), if the payment completed.
2225    pub preimage: Option<String>,
2226    pub created_at: u64,
2227    pub completed_at: Option<u64>,
2228    pub number_of_parts: Option<u64>,
2229}
2230
2231impl From<clnpb::ListpaysPays> for Pay {
2232    fn from(other: clnpb::ListpaysPays) -> Self {
2233        let status = match other.status {
2234            0 => PayStatus::PENDING,  // ListpaysPaysStatus::PENDING = 0
2235            1 => PayStatus::FAILED,   // ListpaysPaysStatus::FAILED = 1
2236            2 => PayStatus::COMPLETE, // ListpaysPaysStatus::COMPLETE = 2
2237            o => panic!("Unknown listpays status {}", o),
2238        };
2239        Self {
2240            payment_hash: hex::encode(&other.payment_hash),
2241            status,
2242            destination_pubkey: other.destination.as_deref().map(hex::encode),
2243            amount_msat: other.amount_msat.map(|a| a.msat),
2244            amount_sent_msat: other.amount_sent_msat.map(|a| a.msat),
2245            label: other.label,
2246            bolt11: other.bolt11,
2247            description: other.description,
2248            bolt12: other.bolt12,
2249            preimage: other.preimage.as_deref().map(hex::encode),
2250            created_at: other.created_at,
2251            completed_at: other.completed_at,
2252            number_of_parts: other.number_of_parts,
2253        }
2254    }
2255}
2256
2257#[derive(Clone, serde::Serialize, uniffi::Record)]
2258pub struct ListPaysResponse {
2259    pub pays: Vec<Pay>,
2260}
2261
2262impl From<clnpb::ListpaysResponse> for ListPaysResponse {
2263    fn from(other: clnpb::ListpaysResponse) -> Self {
2264        Self {
2265            pays: other.pays.into_iter().map(|p| p.into()).collect(),
2266        }
2267    }
2268}
2269
2270// ============================================================
2271// Unified list_payments request/response types
2272// ============================================================
2273
2274#[derive(Clone, Default, uniffi::Record)]
2275pub struct ListPaymentsRequest {
2276    /// Filter by payment type (Sent, Received). None or empty = all.
2277    pub filters: Option<Vec<PaymentTypeFilter>>,
2278    /// Include only payments after this epoch timestamp (seconds).
2279    pub from_timestamp: Option<u64>,
2280    /// Include only payments before this epoch timestamp (seconds).
2281    pub to_timestamp: Option<u64>,
2282    /// Include failed payments. Default: false.
2283    pub include_failures: Option<bool>,
2284    /// Pagination offset.
2285    pub offset: Option<u32>,
2286    /// Pagination limit.
2287    pub limit: Option<u32>,
2288}
2289
2290#[derive(Clone, uniffi::Enum)]
2291pub enum PaymentTypeFilter {
2292    Sent,
2293    Received,
2294}
2295
2296#[derive(Clone, uniffi::Record)]
2297pub struct Payment {
2298    pub id: String,
2299    pub payment_type: PaymentType,
2300    pub payment_time: u64,
2301    pub amount_msat: u64,
2302    pub fee_msat: u64,
2303    pub status: PaymentStatus,
2304    pub description: Option<String>,
2305    pub bolt11: Option<String>,
2306    /// Payment preimage as lowercase hex (64 chars), when known.
2307    pub preimage: Option<String>,
2308    /// Pubkey of the counterparty in the payment, as lowercase hex
2309    /// (66 chars).
2310    ///
2311    /// For `PaymentType::Sent`: the recipient node we paid (when CLN
2312    /// reports it).
2313    ///
2314    /// For `PaymentType::Received`: always `None`. Lightning's privacy
2315    /// model does not reveal the sender's pubkey to the recipient — the
2316    /// HTLC arrives via one of our channel peers, but that peer is
2317    /// usually just a router, not the original payer. The only pubkey
2318    /// derivable from a paid invoice is the *payee* (i.e. our own
2319    /// node), which is uninteresting to display per-row.
2320    pub destination: Option<String>,
2321}
2322
2323#[derive(Clone, uniffi::Enum)]
2324pub enum PaymentType {
2325    Sent,
2326    Received,
2327}
2328
2329#[derive(Clone, uniffi::Enum)]
2330pub enum PaymentStatus {
2331    Pending,
2332    Complete,
2333    Failed,
2334}
2335
2336impl From<clnpb::ListinvoicesInvoices> for Payment {
2337    fn from(inv: clnpb::ListinvoicesInvoices) -> Self {
2338        let status = match inv.status() {
2339            clnpb::listinvoices_invoices::ListinvoicesInvoicesStatus::Paid => {
2340                PaymentStatus::Complete
2341            }
2342            clnpb::listinvoices_invoices::ListinvoicesInvoicesStatus::Expired => {
2343                PaymentStatus::Failed
2344            }
2345            clnpb::listinvoices_invoices::ListinvoicesInvoicesStatus::Unpaid => {
2346                PaymentStatus::Pending
2347            }
2348        };
2349
2350        let payment_time = inv.paid_at.unwrap_or(inv.expires_at);
2351        let amount_msat = inv
2352            .amount_received_msat
2353            .or(inv.amount_msat)
2354            .map(|a| a.msat)
2355            .unwrap_or(0);
2356
2357        Payment {
2358            id: hex::encode(&inv.payment_hash),
2359            payment_type: PaymentType::Received,
2360            payment_time,
2361            amount_msat,
2362            fee_msat: 0,
2363            status,
2364            description: inv.description,
2365            bolt11: inv.bolt11,
2366            preimage: inv.payment_preimage.as_deref().map(hex::encode),
2367            destination: None,
2368        }
2369    }
2370}
2371
2372impl From<clnpb::ListpaysPays> for Payment {
2373    fn from(pay: clnpb::ListpaysPays) -> Self {
2374        let status = match pay.status() {
2375            clnpb::listpays_pays::ListpaysPaysStatus::Complete => PaymentStatus::Complete,
2376            clnpb::listpays_pays::ListpaysPaysStatus::Failed => PaymentStatus::Failed,
2377            clnpb::listpays_pays::ListpaysPaysStatus::Pending => PaymentStatus::Pending,
2378        };
2379
2380        let payment_time = pay.completed_at.unwrap_or(pay.created_at);
2381        let amount_msat = pay.amount_msat.as_ref().map(|a| a.msat).unwrap_or(0);
2382        let amount_sent_msat = pay.amount_sent_msat.as_ref().map(|a| a.msat).unwrap_or(0);
2383        let fee_msat = amount_sent_msat.saturating_sub(amount_msat);
2384
2385        Payment {
2386            id: hex::encode(&pay.payment_hash),
2387            payment_type: PaymentType::Sent,
2388            payment_time,
2389            amount_msat,
2390            fee_msat,
2391            status,
2392            description: pay.description,
2393            bolt11: pay.bolt11,
2394            preimage: pay.preimage.as_deref().map(hex::encode),
2395            destination: pay.destination.as_deref().map(hex::encode),
2396        }
2397    }
2398}
2399
2400// ============================================================
2401// NodeState — unified node snapshot
2402// ============================================================
2403
2404/// A point-in-time snapshot of the node's balances, capacity, and
2405/// connectivity. Returned by `node_state()`.
2406///
2407/// All amounts are in millisatoshis (1 sat = 1000 msat).
2408#[derive(Clone, serde::Serialize, uniffi::Record)]
2409pub struct NodeState {
2410    /// The node's public key as a lowercase hex string (66 chars).
2411    pub id: String,
2412    /// Latest block height the node has synced to.
2413    pub block_height: u32,
2414    /// The Bitcoin network this node is running on (e.g. "bitcoin", "regtest").
2415    pub network: String,
2416    /// CLN version string (e.g. "v24.11").
2417    pub version: String,
2418    /// Human-readable node alias, if set.
2419    pub alias: Option<String>,
2420    /// 3-byte RGB color of the node, as a lowercase hex string (6 chars).
2421    pub color: String,
2422    /// Number of channels that are open and operational. These are the
2423    /// channels that contribute to `channels_balance_msat`,
2424    /// `max_payable_msat`, `total_channel_capacity_msat`, and
2425    /// `total_inbound_liquidity_msat`.
2426    pub num_active_channels: u32,
2427    /// Number of channels that are being opened but not yet confirmed.
2428    /// Pending channels do not contribute to any balance or capacity
2429    /// field on this snapshot; their funds show up only after they
2430    /// transition to active.
2431    pub num_pending_channels: u32,
2432    /// Number of channels that are open but the peer is offline.
2433    /// Inactive channels hold balance but cannot be used for payments
2434    /// until the peer reconnects; they do not contribute to
2435    /// `max_payable_msat` or `total_inbound_liquidity_msat` (those are
2436    /// computed from the live `spendable_msat` / `receivable_msat`
2437    /// reported by CLN, which goes to zero when the peer is offline).
2438    pub num_inactive_channels: u32,
2439    /// Total our-side balance across all open channels, including amounts
2440    /// that protocol reserves make unspendable.
2441    ///
2442    /// This is the field a wallet's home screen should show as the
2443    /// user's "Lightning balance" — it reflects what they own off-chain,
2444    /// matching what they'd expect to see at a glance.
2445    ///
2446    /// Do **not** use this to gate a send button: some of it is locked
2447    /// in channel reserves. Use `max_payable_msat` for that.
2448    pub channels_balance_msat: u64,
2449    /// Aggregate spendable amount across all open channels. Equal to
2450    /// `channels_balance_msat - max_chan_reserve_msat`.
2451    ///
2452    /// This is the field a send screen should gate against — it is what
2453    /// the user can actually move right now over Lightning in total.
2454    ///
2455    /// Caveat: a single Lightning payment is additionally bounded by
2456    /// the largest channel's own `spendable_msat`. Reaching this full
2457    /// aggregate amount in one payment requires multi-path-payment
2458    /// support from the recipient and a working route.
2459    pub max_payable_msat: u64,
2460    /// Sum of all open channel capacities (your side + remote side).
2461    pub total_channel_capacity_msat: u64,
2462    /// Amount locked in protocol channel reserves, computed as
2463    /// `channels_balance_msat - max_payable_msat`. These sats are yours
2464    /// on paper but cannot be spent until the channel closes.
2465    pub max_chan_reserve_msat: u64,
2466    /// Confirmed on-chain balance available for spending or opening channels.
2467    pub onchain_balance_msat: u64,
2468    /// On-chain balance from transactions that have not yet been confirmed.
2469    pub unconfirmed_onchain_balance_msat: u64,
2470    /// On-chain balance confirmed but not yet spendable (e.g. coinbase
2471    /// outputs inside the 100-block maturation window).
2472    pub immature_onchain_balance_msat: u64,
2473    /// On-chain balance locked in channels that are being closed.
2474    /// These funds will become available once the close is confirmed.
2475    pub pending_onchain_balance_msat: u64,
2476    /// Largest single Lightning payment the node can receive without
2477    /// splitting across channels. Bounded by the inbound capacity of
2478    /// the largest open channel.
2479    pub max_receivable_single_payment_msat: u64,
2480    /// Total amount you can receive across all open channels combined.
2481    pub total_inbound_liquidity_msat: u64,
2482    /// Lowercase hex public keys of peers we have at least one channel
2483    /// with and are currently connected to. Peers we're connected to but
2484    /// have no channel with are not represented here; for routing-node
2485    /// use cases, query `list_peers()` directly.
2486    pub connected_channel_peers: Vec<String>,
2487    /// Unspent on-chain outputs owned by the node's wallet. Excludes
2488    /// spent outputs; includes confirmed, unconfirmed, immature, and
2489    /// reserved UTXOs (callers can filter by `status` and `reserved`).
2490    pub utxos: Vec<FundOutput>,
2491
2492    // ------------------------------------------------------------------
2493    // Aggregate balance views. All amounts in millisatoshis, matching
2494    // the rest of this struct. Callers displaying sats should divide by
2495    // 1000 on the UI side.
2496    // ------------------------------------------------------------------
2497    /// All non-pending on-chain balance buckets summed:
2498    /// `onchain_balance_msat + unconfirmed_onchain_balance_msat + immature_onchain_balance_msat`.
2499    /// Excludes funds locked in closing channels (`pending_onchain_balance_msat`)
2500    /// since those are not yet on-chain UTXOs.
2501    pub total_onchain_msat: u64,
2502    /// Everything the user owns, summed: channel balance (including
2503    /// protocol reserves) + all on-chain buckets + funds locked in
2504    /// closing channels. The "total holdings" number a wallet home
2505    /// screen typically shows.
2506    pub total_balance_msat: u64,
2507    /// What the user can spend *right now*:
2508    /// `max_payable_msat + onchain_balance_msat`. Excludes reserves,
2509    /// unconfirmed, immature, and pending amounts. The number a
2510    /// send-money screen should gate against.
2511    pub spendable_balance_msat: u64,
2512}
2513
2514// ============================================================
2515// NodeEvent streaming types
2516// ============================================================
2517
2518/// Callback interface for receiving node events.
2519///
2520/// `on_event` is invoked from the SDK's internal event-dispatch task.
2521/// Implementations should be cheap and non-blocking; to update UI,
2522/// dispatch to the main thread from inside the handler.
2523///
2524/// Installed via `NodeBuilder::with_event_listener(...)` so events
2525/// emitted during node bring-up are captured. The polling-style
2526/// `Node::stream_node_events()` API is still available for callers
2527/// that prefer to drive events themselves.
2528#[uniffi::export(callback_interface)]
2529pub trait NodeEventListener: Send + Sync {
2530    fn on_event(&self, event: NodeEvent);
2531}
2532
2533/// A stream of node events. Call `next()` to receive the next event.
2534///
2535/// The stream is backed by a gRPC streaming connection to the node.
2536/// Each call to `next()` blocks the calling thread until an event is
2537/// available, but does not block the tokio runtime - other node
2538/// operations can proceed concurrently from other threads.
2539#[derive(uniffi::Object)]
2540pub struct NodeEventStream {
2541    inner: Mutex<tonic::codec::Streaming<glpb::NodeEvent>>,
2542}
2543
2544#[uniffi::export]
2545impl NodeEventStream {
2546    /// Get the next event from the stream.
2547    ///
2548    /// Blocks the calling thread until an event is available or the
2549    /// stream ends. Returns `None` when the stream is exhausted or
2550    /// the connection is lost.
2551    pub fn next(&self) -> Result<Option<NodeEvent>, Error> {
2552        let mut stream = self.inner.lock().map_err(|e| Error::other(e.to_string()))?;
2553        // Loop over wire events, skipping any the SDK doesn't recognise,
2554        // until we either decode a known event, the stream ends, or it
2555        // errors. The public `NodeEvent` enum is a closed set —
2556        // unknown server-side events are silently dropped here.
2557        loop {
2558            match exec(stream.message()) {
2559                Ok(Some(raw)) => {
2560                    if let Some(event) = node_event_from_pb(raw) {
2561                        return Ok(Some(event));
2562                    }
2563                    // Unknown event — fall through to next iteration.
2564                }
2565                Ok(None) => return Ok(None),
2566                Err(e) if e.code() == tonic::Code::Unknown => return Ok(None),
2567                Err(e) => return Err(Error::rpc(e.to_string())),
2568            }
2569        }
2570    }
2571}
2572
2573/// A real-time event from the node.
2574#[derive(Clone, uniffi::Enum)]
2575pub enum NodeEvent {
2576    /// An invoice was paid.
2577    InvoicePaid { details: InvoicePaidEvent },
2578}
2579
2580/// Details of a paid invoice.
2581#[derive(Clone, uniffi::Record)]
2582pub struct InvoicePaidEvent {
2583    /// Payment hash of the paid invoice as lowercase hex (64 chars).
2584    pub payment_hash: String,
2585    /// The bolt11 invoice string.
2586    pub bolt11: String,
2587    /// Preimage that proves payment as lowercase hex (64 chars).
2588    pub preimage: String,
2589    /// The label assigned to the invoice.
2590    pub label: String,
2591    /// Amount received in millisatoshis.
2592    pub amount_msat: u64,
2593}
2594
2595/// Convert a wire-level `glpb::NodeEvent` into the typed SDK enum.
2596///
2597/// Returns `None` for events the SDK doesn't recognise (e.g. a future
2598/// server-side event type added after the client was built). Callers
2599/// silently skip `None` so unknown events never reach the foreign
2600/// bindings — the public `NodeEvent` is a closed set.
2601fn node_event_from_pb(other: glpb::NodeEvent) -> Option<NodeEvent> {
2602    match other.event {
2603        Some(glpb::node_event::Event::InvoicePaid(paid)) => Some(NodeEvent::InvoicePaid {
2604            details: InvoicePaidEvent {
2605                payment_hash: hex::encode(&paid.payment_hash),
2606                bolt11: paid.bolt11,
2607                preimage: hex::encode(&paid.preimage),
2608                label: paid.label,
2609                amount_msat: paid.amount_msat,
2610            },
2611        }),
2612        None => None,
2613    }
2614}
2615
2616#[cfg(test)]
2617mod tests {
2618    use super::*;
2619
2620    #[test]
2621    fn parse_amount_or_all_handles_all_variants() {
2622        let all = parse_amount_or_all("all").unwrap();
2623        assert!(matches!(all.value, Some(clnpb::amount_or_all::Value::All(true))));
2624
2625        let plain = parse_amount_or_all("50000").unwrap();
2626        assert!(matches!(
2627            plain.value,
2628            Some(clnpb::amount_or_all::Value::Amount(clnpb::Amount { msat: 50_000_000 }))
2629        ));
2630
2631        let sat = parse_amount_or_all("50000sat").unwrap();
2632        assert!(matches!(
2633            sat.value,
2634            Some(clnpb::amount_or_all::Value::Amount(clnpb::Amount { msat: 50_000_000 }))
2635        ));
2636
2637        let msat = parse_amount_or_all("50000msat").unwrap();
2638        assert!(matches!(
2639            msat.value,
2640            Some(clnpb::amount_or_all::Value::Amount(clnpb::Amount { msat: 50_000 }))
2641        ));
2642
2643        assert!(parse_amount_or_all("notanumber").is_err());
2644        assert!(parse_amount_or_all("50000btc").is_err());
2645    }
2646
2647    #[test]
2648    fn classify_onchain_balance_unavailable_when_empty() {
2649        assert!(matches!(
2650            classify_onchain_balance(0, 0, 0, 0, 0),
2651            OnchainBalanceState::Unavailable
2652        ));
2653    }
2654
2655    #[test]
2656    fn classify_onchain_balance_available_with_room_above_dust() {
2657        // confirmed=100k, reserve=25k, unconfirmed=5k → Available
2658        match classify_onchain_balance(100_000, 25_000, 5_000, 0, 0) {
2659            OnchainBalanceState::Available {
2660                withdrawable_sat,
2661                emergency_reserve_sat,
2662                unconfirmed_sat,
2663            } => {
2664                assert_eq!(withdrawable_sat, 75_000);
2665                assert_eq!(emergency_reserve_sat, 25_000);
2666                assert_eq!(unconfirmed_sat, 5_000);
2667            }
2668            other => panic!("expected Available, got {:?}", std::mem::discriminant(&other)),
2669        }
2670    }
2671
2672    #[test]
2673    fn classify_onchain_balance_reserve_only_when_balance_equals_reserve() {
2674        // 25k confirmed, 25k reserve → withdrawable = 0
2675        match classify_onchain_balance(25_000, 25_000, 0, 0, 0) {
2676            OnchainBalanceState::ReserveOnly { reserve_sat } => {
2677                assert_eq!(reserve_sat, 25_000);
2678            }
2679            other => panic!(
2680                "expected ReserveOnly, got {:?}",
2681                std::mem::discriminant(&other)
2682            ),
2683        }
2684    }
2685
2686    #[test]
2687    fn classify_onchain_balance_pending_when_only_unconfirmed() {
2688        match classify_onchain_balance(0, 0, 50_000, 0, 0) {
2689            OnchainBalanceState::PendingConfirmation { unconfirmed_sat } => {
2690                assert_eq!(unconfirmed_sat, 50_000);
2691            }
2692            other => panic!(
2693                "expected PendingConfirmation, got {:?}",
2694                std::mem::discriminant(&other)
2695            ),
2696        }
2697    }
2698
2699    #[test]
2700    fn classify_onchain_balance_immature_when_only_immature() {
2701        match classify_onchain_balance(0, 0, 0, 100_000, 0) {
2702            OnchainBalanceState::Immature { immature_sat } => {
2703                assert_eq!(immature_sat, 100_000);
2704            }
2705            other => panic!("expected Immature, got {:?}", std::mem::discriminant(&other)),
2706        }
2707    }
2708
2709    #[test]
2710    fn classify_onchain_balance_real_wallet_small_onchain_with_active_channels() {
2711        // Captured from a live mainnet wallet: 2 active channels,
2712        // ~1,228 sat confirmed on-chain, 25,000 sat reserve carved
2713        // (anchor-channel default). Withdrawable = 0 → ReserveOnly.
2714        match classify_onchain_balance(1_228, 25_000, 0, 0, 0) {
2715            OnchainBalanceState::ReserveOnly { reserve_sat } => {
2716                assert_eq!(reserve_sat, 25_000);
2717            }
2718            other => panic!(
2719                "expected ReserveOnly, got {:?}",
2720                std::mem::discriminant(&other)
2721            ),
2722        }
2723    }
2724
2725    #[test]
2726    fn classify_onchain_balance_real_wallet_onchain_just_above_reserve() {
2727        // Same wallet after a top-up: 28,228 sat confirmed, 25k
2728        // reserve → withdrawable = 3,228, well above the dust gate
2729        // → Available.
2730        match classify_onchain_balance(28_228, 25_000, 0, 0, 0) {
2731            OnchainBalanceState::Available {
2732                withdrawable_sat,
2733                emergency_reserve_sat,
2734                unconfirmed_sat,
2735            } => {
2736                assert_eq!(withdrawable_sat, 3_228);
2737                assert_eq!(emergency_reserve_sat, 25_000);
2738                assert_eq!(unconfirmed_sat, 0);
2739            }
2740            other => panic!(
2741                "expected Available, got {:?}",
2742                std::mem::discriminant(&other)
2743            ),
2744        }
2745    }
2746
2747    #[test]
2748    fn classify_onchain_balance_dust_only_above_reserve_is_not_available() {
2749        // Withdrawable would be 100 sat — below the 546 dust gate.
2750        // Falls through Available; lands on ReserveOnly.
2751        match classify_onchain_balance(25_100, 25_000, 0, 0, 0) {
2752            OnchainBalanceState::ReserveOnly { reserve_sat } => {
2753                assert_eq!(reserve_sat, 25_000);
2754            }
2755            other => panic!(
2756                "expected ReserveOnly, got {:?}",
2757                std::mem::discriminant(&other)
2758            ),
2759        }
2760    }
2761
2762    #[test]
2763    fn classify_onchain_balance_real_user_no_anchor_no_reserve() {
2764        // The user-reported case: 28,228 sat on-chain, channels open
2765        // but NOT anchor type, so probe returns 0 reserve. The
2766        // entry-point should show Available with the full balance.
2767        match classify_onchain_balance(28_228, 0, 0, 0, 0) {
2768            OnchainBalanceState::Available {
2769                withdrawable_sat,
2770                emergency_reserve_sat,
2771                unconfirmed_sat,
2772            } => {
2773                assert_eq!(withdrawable_sat, 28_228);
2774                assert_eq!(emergency_reserve_sat, 0);
2775                assert_eq!(unconfirmed_sat, 0);
2776            }
2777            other => panic!(
2778                "expected Available, got {:?}",
2779                std::mem::discriminant(&other)
2780            ),
2781        }
2782    }
2783
2784    fn perkw_with(estimates: Vec<(u32, u32)>, min_acceptable: u32) -> clnpb::FeeratesPerkw {
2785        clnpb::FeeratesPerkw {
2786            min_acceptable,
2787            max_acceptable: 0,
2788            opening: None,
2789            mutual_close: None,
2790            unilateral_close: None,
2791            unilateral_anchor_close: None,
2792            delayed_to_us: None,
2793            htlc_resolution: None,
2794            penalty: None,
2795            estimates: estimates
2796                .into_iter()
2797                .map(|(blockcount, feerate)| clnpb::FeeratesPerkwEstimates {
2798                    blockcount,
2799                    feerate,
2800                    smoothed_feerate: feerate,
2801                })
2802                .collect(),
2803            floor: None,
2804        }
2805    }
2806
2807    #[test]
2808    fn fee_rates_maps_perkw_to_buckets() {
2809        // Typical CLN response with estimates at 2/6/12/144 blocks.
2810        // perkw values: 1 sat/vbyte = 250, 5 sat/vbyte = 1250.
2811        let perkw = perkw_with(
2812            vec![(2, 5000), (6, 2000), (12, 1500), (144, 500)],
2813            253, // min_acceptable just over 1 sat/vbyte
2814        );
2815        let r = compute_fee_rates(Some(&perkw));
2816        // 5000 perkw = 20 sat/vbyte (next-block target picks blockcount=2)
2817        assert_eq!(r.next_block_sat_per_vbyte, 20);
2818        // half_hour target is 3 blocks; smallest estimate ≥3 is 6 → 2000 perkw = 8 sat/vbyte
2819        assert_eq!(r.half_hour_sat_per_vbyte, 8);
2820        // hour target is 6 blocks → 2000 perkw = 8 sat/vbyte
2821        assert_eq!(r.hour_sat_per_vbyte, 8);
2822        // day target is 144 blocks → 500 perkw = 2 sat/vbyte
2823        assert_eq!(r.day_sat_per_vbyte, 2);
2824        // min_acceptable 253 perkw rounds up to 2 sat/vbyte
2825        assert_eq!(r.minimum_relay_sat_per_vbyte, 2);
2826    }
2827
2828    #[test]
2829    fn fee_rates_fall_back_to_minimum_when_no_estimates() {
2830        let perkw = perkw_with(vec![], 750); // min ~3 sat/vbyte
2831        let r = compute_fee_rates(Some(&perkw));
2832        assert_eq!(r.minimum_relay_sat_per_vbyte, 3);
2833        // Empty estimates → all buckets fall back to minimum
2834        assert_eq!(r.next_block_sat_per_vbyte, 3);
2835        assert_eq!(r.half_hour_sat_per_vbyte, 3);
2836        assert_eq!(r.hour_sat_per_vbyte, 3);
2837        assert_eq!(r.day_sat_per_vbyte, 3);
2838    }
2839
2840    #[test]
2841    fn fee_rates_no_perkw_at_all_returns_safe_floor() {
2842        let r = compute_fee_rates(None);
2843        assert_eq!(r.minimum_relay_sat_per_vbyte, 1);
2844        assert_eq!(r.next_block_sat_per_vbyte, 1);
2845        assert_eq!(r.day_sat_per_vbyte, 1);
2846    }
2847
2848    #[test]
2849    fn fee_rates_buckets_never_below_minimum() {
2850        // 144-block estimate below min_acceptable — bucket must be
2851        // clamped to minimum so we never recommend below network relay.
2852        let perkw = perkw_with(
2853            vec![(2, 1500), (144, 250)], // 144→1 sat/vbyte, but min=1000 perkw = 4 sat/vbyte
2854            1000,
2855        );
2856        let r = compute_fee_rates(Some(&perkw));
2857        assert_eq!(r.minimum_relay_sat_per_vbyte, 4);
2858        // Even though the 144-block estimate is 1 sat/vbyte, we clamp up.
2859        assert_eq!(r.day_sat_per_vbyte, 4);
2860    }
2861
2862    #[test]
2863    fn fee_rates_target_above_all_estimates_uses_largest() {
2864        // Only short-target estimates; day(144) should fall back to
2865        // the longest estimate available.
2866        let perkw = perkw_with(vec![(2, 5000), (6, 2500)], 250);
2867        let r = compute_fee_rates(Some(&perkw));
2868        // Longest available estimate is 6 blocks → 2500 perkw = 10 sat/vbyte
2869        assert_eq!(r.day_sat_per_vbyte, 10);
2870    }
2871
2872    #[test]
2873    fn output_weight_for_address_per_script_type() {
2874        // P2WPKH — script_pubkey is 22 bytes, output = (8+1+22)*4 = 124
2875        // BIP-173 test vector.
2876        assert_eq!(
2877            output_weight_for_address("bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4"),
2878            124
2879        );
2880
2881        // P2TR — script_pubkey is 34 bytes, output = (8+1+34)*4 = 172
2882        // BIP-341 test vector.
2883        assert_eq!(
2884            output_weight_for_address(
2885                "bc1p0xlxvlhemja6c4dqv22uapctqupfhlxm9h8z3k2e72q4k9hcz7vqzk5jj0"
2886            ),
2887            172
2888        );
2889
2890        // P2SH — script_pubkey is 23 bytes, output = (8+1+23)*4 = 128
2891        assert_eq!(output_weight_for_address("3J98t1WpEZ73CNmQviecrnyiWrnqRhWNLy"), 128);
2892
2893        // P2PKH — script_pubkey is 25 bytes, output = (8+1+25)*4 = 136
2894        assert_eq!(output_weight_for_address("1BvBMSEYstWetqTFn5Au4m4GFg7xJaNVN2"), 136);
2895
2896        // Garbage falls back to the conservative 172 wu.
2897        assert_eq!(output_weight_for_address("not-an-address"), 172);
2898    }
2899}