use crate::{credentials::Credentials, signer::Handle, util::exec, Error};
use std::str::FromStr;
use std::sync::atomic::{AtomicBool, Ordering};
use gl_client::credentials::NodeIdProvider;
use gl_client::lnurl::models::LnUrlHttpClient as _;
use gl_client::node::{Client as GlClient, ClnClient, Node as ClientNode};
use gl_client::pb::{self as glpb, cln as clnpb};
use lightning_invoice::Bolt11Invoice;
use std::sync::{Arc, Mutex};
use tokio::sync::OnceCell;
#[derive(uniffi::Object)]
#[allow(unused)]
pub struct Node {
inner: ClientNode,
cln_client: OnceCell<ClnClient>,
gl_client: OnceCell<GlClient>,
stored_credentials: Option<Credentials>,
signer_handle: Option<Handle>,
disconnected: AtomicBool,
event_task: Mutex<Option<tokio::task::JoinHandle<()>>>,
network: gl_client::bitcoin::Network,
}
impl Drop for Node {
fn drop(&mut self) {
if let Ok(mut guard) = self.event_task.lock() {
if let Some(handle) = guard.take() {
handle.abort();
}
}
}
}
impl Node {
pub fn signerless(credentials: Credentials) -> Result<Self, Error> {
let node_id = credentials
.inner
.node_id()
.map_err(|_e| Error::unparseable_creds())?;
let inner = ClientNode::new(node_id, credentials.inner.clone())
.expect("infallible client instantiation");
let cln_client = OnceCell::const_new();
let gl_client = OnceCell::const_new();
Ok(Node {
inner,
cln_client,
gl_client,
stored_credentials: Some(credentials),
signer_handle: None,
disconnected: AtomicBool::new(false),
event_task: Mutex::new(None),
network: gl_client::bitcoin::Network::Bitcoin,
})
}
}
#[uniffi::export]
impl Node {
pub fn stop(&self) -> Result<(), Error> {
self.check_connected()?;
let mut cln_client = exec(self.get_cln_client())?.clone();
let req = clnpb::StopRequest {};
let _ = exec(cln_client.stop(req));
Ok(())
}
pub fn credentials(&self) -> Result<Vec<u8>, Error> {
match &self.stored_credentials {
Some(creds) => creds.save(),
None => Err(Error::other(
"No credentials stored. Use register/recover/connect to create a Node with credentials.".to_string(),
)),
}
}
pub fn disconnect(&self) -> Result<(), Error> {
self.disconnected.store(true, Ordering::Relaxed);
if let Some(ref handle) = self.signer_handle {
handle.try_stop();
}
Ok(())
}
pub fn receive(
&self,
label: String,
description: String,
amount_msat: Option<u64>,
) -> Result<ReceiveResponse, Error> {
self.check_connected()?;
let mut gl_client = exec(self.get_gl_client())?.clone();
let req = gl_client::pb::LspInvoiceRequest {
amount_msat: amount_msat.unwrap_or_default(),
description: description,
label: label,
lsp_id: "".to_owned(),
token: "".to_owned(),
};
let res = exec(gl_client.lsp_invoice(req))
.map_err(|s| Error::rpc(s.to_string()))?
.into_inner();
Ok(ReceiveResponse {
bolt11: res.bolt11,
opening_fee_msat: res.opening_fee_msat,
})
}
pub fn send(&self, invoice: String, amount_msat: Option<u64>) -> Result<SendResponse, Error> {
self.check_connected()?;
let mut cln_client = exec(self.get_cln_client())?.clone();
let req = clnpb::PayRequest {
amount_msat: match amount_msat {
Some(a) => Some(clnpb::Amount { msat: a }),
None => None,
},
bolt11: invoice,
description: None,
exclude: vec![],
exemptfee: None,
label: None,
localinvreqid: None,
maxdelay: None,
maxfee: None,
maxfeepercent: None,
partial_msat: None,
retry_for: None,
riskfactor: None,
};
exec(cln_client.pay(req))
.map_err(|e| Error::rpc(e.to_string()))
.map(|r| r.into_inner().into())
}
pub fn onchain_send(
&self,
destination: String,
amount_or_all: String,
sat_per_vbyte: Option<u32>,
utxos: Option<Vec<Outpoint>>,
) -> Result<OnchainSendResponse, Error> {
self.check_connected()?;
let mut cln_client = exec(self.get_cln_client())?.clone();
let satoshi = parse_amount_or_all(&amount_or_all)?;
let req = clnpb::WithdrawRequest {
destination,
minconf: None,
feerate: sat_per_vbyte.map(feerate_perkw_from_sat_per_vbyte),
satoshi: Some(satoshi),
utxos: utxos
.unwrap_or_default()
.into_iter()
.map(outpoint_to_pb)
.collect::<Result<Vec<_>, _>>()?,
};
exec(cln_client.withdraw(req))
.map_err(|e| Error::rpc(e.to_string()))
.map(|r| r.into_inner().into())
}
pub fn prepare_onchain_send(
&self,
destination: String,
amount_or_all: String,
sat_per_vbyte: Option<u32>,
) -> Result<PreparedOnchainSend, Error> {
self.check_connected()?;
let cln_client = exec(self.get_cln_client())?.clone();
let satoshi = parse_amount_or_all(&amount_or_all)?;
let is_sweep = matches!(satoshi.value, Some(clnpb::amount_or_all::Value::All(true)));
let startweight = BASE_TX_CORE_WEIGHT + output_weight_for_address(&destination);
let feerate = match sat_per_vbyte {
Some(rate) => feerate_perkw_from_sat_per_vbyte(rate),
None => clnpb::Feerate {
style: Some(clnpb::feerate::Style::Normal(true)),
},
};
let req = clnpb::FundpsbtRequest {
satoshi: Some(satoshi),
feerate: Some(feerate),
startweight,
reserve: Some(0),
minconf: None,
locktime: None,
min_witness_weight: None,
excess_as_change: Some(!is_sweep),
nonwrapped: None,
opening_anchor_channel: None,
};
let (fund_res, feerates_res) = exec(async {
let mut c_fund = cln_client.clone();
let mut c_rates = cln_client.clone();
tokio::join!(
c_fund.fund_psbt(req),
c_rates.feerates(clnpb::FeeratesRequest {
style: clnpb::feerates_request::FeeratesStyle::Perkw as i32,
}),
)
});
if let (Some(rate), Ok(rates)) = (sat_per_vbyte, feerates_res.as_ref())
&& let Some(perkw) = rates.get_ref().perkw.as_ref()
{
let min_sat_per_vbyte =
sat_per_vbyte_from_perkw(perkw.min_acceptable).max(1);
if (rate as u64) < min_sat_per_vbyte {
return Err(Error::argument(
"sat_per_vbyte",
format!(
"{} sat/vbyte is below the network minimum of {} sat/vbyte",
rate, min_sat_per_vbyte
),
));
}
}
let res = fund_res
.map_err(|e| Error::rpc(e.to_string()))?
.into_inner();
let psbt = bitcoin::Psbt::from_str(&res.psbt)
.map_err(|e| Error::rpc(format!("invalid psbt from fund_psbt: {}", e)))?;
let utxos: Vec<Outpoint> = psbt
.unsigned_tx
.input
.iter()
.map(|tx_in| Outpoint {
txid: tx_in.previous_output.txid.to_string(),
vout: tx_in.previous_output.vout,
})
.collect();
let fee_sat: u64 =
(res.estimated_final_weight as u64 * res.feerate_per_kw as u64) / 1000;
let mut total_input_sat: u64 = 0;
for (i, input) in psbt.inputs.iter().enumerate() {
let value = if let Some(ref txout) = input.witness_utxo {
txout.value
} else if let Some(ref tx) = input.non_witness_utxo {
let vout = psbt.unsigned_tx.input[i].previous_output.vout as usize;
tx.output
.get(vout)
.map(|o| o.value)
.ok_or_else(|| {
Error::rpc("psbt non_witness_utxo missing vout")
})?
} else {
return Err(Error::rpc(format!(
"psbt input {} has no witness_utxo or non_witness_utxo",
i
)));
};
total_input_sat = total_input_sat.saturating_add(value.to_sat());
}
let recipient_sat: u64 = if is_sweep {
res.excess_msat.as_ref().map(|a| a.msat).unwrap_or(0) / 1000
} else {
match parse_amount_or_all(&amount_or_all)?.value {
Some(clnpb::amount_or_all::Value::Amount(a)) => a.msat / 1000,
_ => 0,
}
};
let effective_sat_per_vbyte: u32 =
(res.feerate_per_kw as u64).div_ceil(250) as u32;
Ok(PreparedOnchainSend {
utxos,
total_input_sat,
fee_sat,
recipient_sat,
sat_per_vbyte: effective_sat_per_vbyte,
})
}
pub fn onchain_balance_state(&self) -> Result<OnchainBalanceState, Error> {
self.check_connected()?;
let cln_client = exec(self.get_cln_client())?.clone();
let (funds_res, channels_res, probe_res) = exec(async {
let mut c_funds = cln_client.clone();
let mut c_channels = cln_client.clone();
let mut c_probe = cln_client.clone();
let probe_req = clnpb::FundpsbtRequest {
satoshi: Some(clnpb::AmountOrAll {
value: Some(clnpb::amount_or_all::Value::All(true)),
}),
feerate: Some(clnpb::Feerate {
style: Some(clnpb::feerate::Style::Normal(true)),
}),
startweight: BASE_TX_CORE_WEIGHT + 124,
reserve: Some(0),
minconf: None,
locktime: None,
min_witness_weight: None,
excess_as_change: Some(false),
nonwrapped: None,
opening_anchor_channel: None,
};
tokio::join!(
c_funds.list_funds(clnpb::ListfundsRequest { spent: None }),
c_channels.list_peer_channels(clnpb::ListpeerchannelsRequest { id: None }),
c_probe.fund_psbt(probe_req),
)
});
let funds: ListFundsResponse = funds_res
.map_err(|e| Error::rpc(e.to_string()))?
.into_inner()
.into();
let channels: ListPeerChannelsResponse = channels_res
.map_err(|e| Error::rpc(e.to_string()))?
.into_inner()
.into();
let mut confirmed_sat: u64 = 0;
let mut unconfirmed_sat: u64 = 0;
let mut immature_sat: u64 = 0;
for output in &funds.outputs {
if output.reserved {
continue;
}
let value_sat = output.amount_msat / 1000;
match output.status {
OutputStatus::Confirmed => confirmed_sat += value_sat,
OutputStatus::Unconfirmed => unconfirmed_sat += value_sat,
OutputStatus::Immature => immature_sat += value_sat,
OutputStatus::Spent => {}
}
}
let mut pending_close_sat: u64 = 0;
for ch in &channels.channels {
if channel_payout_still_pending(ch) {
pending_close_sat += ch.to_us_msat.unwrap_or(0) / 1000;
}
}
let reserve_sat = match probe_res {
Ok(resp) => {
let resp = resp.into_inner();
let total_input_sat = bitcoin::Psbt::from_str(&resp.psbt)
.ok()
.map(|p| {
p.inputs
.iter()
.filter_map(|i| {
i.witness_utxo.as_ref().map(|t| t.value.to_sat())
})
.sum::<u64>()
})
.unwrap_or(0);
let excess_sat = resp
.excess_msat
.as_ref()
.map(|a| a.msat / 1000)
.unwrap_or(0);
let fee_sat = (resp.estimated_final_weight as u64
* resp.feerate_per_kw as u64)
/ 1000;
total_input_sat
.saturating_sub(excess_sat)
.saturating_sub(fee_sat)
}
Err(_) => 0,
};
Ok(classify_onchain_balance(
confirmed_sat,
reserve_sat,
unconfirmed_sat,
immature_sat,
pending_close_sat,
))
}
pub fn onchain_fee_rates(&self) -> Result<OnchainFeeRates, Error> {
self.check_connected()?;
let mut cln_client = exec(self.get_cln_client())?.clone();
let req = clnpb::FeeratesRequest {
style: clnpb::feerates_request::FeeratesStyle::Perkw as i32,
};
let res = exec(cln_client.feerates(req))
.map_err(|e| Error::rpc(e.to_string()))?
.into_inner();
Ok(compute_fee_rates(res.perkw.as_ref()))
}
pub fn onchain_receive(&self) -> Result<OnchainReceiveResponse, Error> {
self.check_connected()?;
let mut cln_client = exec(self.get_cln_client())?.clone();
let req = clnpb::NewaddrRequest {
addresstype: Some(clnpb::newaddr_request::NewaddrAddresstype::All.into()),
};
let res = exec(cln_client.new_addr(req))
.map_err(|e| Error::rpc(e.to_string()))?
.into_inner();
Ok(res.into())
}
pub fn get_info(&self) -> Result<GetInfoResponse, Error> {
self.check_connected()?;
let mut cln_client = exec(self.get_cln_client())?.clone();
let req = clnpb::GetinfoRequest {};
let res = exec(cln_client.getinfo(req))
.map_err(|e| Error::rpc(e.to_string()))?
.into_inner();
Ok(res.into())
}
pub fn list_peers(&self) -> Result<ListPeersResponse, Error> {
self.check_connected()?;
let mut cln_client = exec(self.get_cln_client())?.clone();
let req = clnpb::ListpeersRequest {
id: None,
level: None,
};
let res = exec(cln_client.list_peers(req))
.map_err(|e| Error::rpc(e.to_string()))?
.into_inner();
Ok(res.into())
}
pub fn list_peer_channels(&self) -> Result<ListPeerChannelsResponse, Error> {
self.check_connected()?;
let mut cln_client = exec(self.get_cln_client())?.clone();
let req = clnpb::ListpeerchannelsRequest { id: None };
let res = exec(cln_client.list_peer_channels(req))
.map_err(|e| Error::rpc(e.to_string()))?
.into_inner();
Ok(res.into())
}
pub fn list_funds(&self) -> Result<ListFundsResponse, Error> {
self.check_connected()?;
let mut cln_client = exec(self.get_cln_client())?.clone();
let req = clnpb::ListfundsRequest { spent: None };
let res = exec(cln_client.list_funds(req))
.map_err(|e| Error::rpc(e.to_string()))?
.into_inner();
Ok(res.into())
}
pub fn node_state(&self) -> Result<NodeState, Error> {
self.check_connected()?;
let cln_client = exec(self.get_cln_client())?.clone();
let (info_res, channels_res, funds_res) = exec(async {
let mut c_info = cln_client.clone();
let mut c_channels = cln_client.clone();
let mut c_funds = cln_client.clone();
tokio::join!(
c_info.getinfo(clnpb::GetinfoRequest {}),
c_channels.list_peer_channels(clnpb::ListpeerchannelsRequest { id: None }),
c_funds.list_funds(clnpb::ListfundsRequest { spent: None }),
)
});
let info: GetInfoResponse = info_res
.map_err(|e| Error::rpc(e.to_string()))?
.into_inner()
.into();
let channels: ListPeerChannelsResponse = channels_res
.map_err(|e| Error::rpc(e.to_string()))?
.into_inner()
.into();
let funds: ListFundsResponse = funds_res
.map_err(|e| Error::rpc(e.to_string()))?
.into_inner()
.into();
let mut channels_balance_msat: u64 = 0;
let mut max_payable_msat: u64 = 0;
let mut total_channel_capacity_msat: u64 = 0;
let mut max_receivable_single_payment_msat: u64 = 0;
let mut total_inbound_liquidity_msat: u64 = 0;
let mut pending_onchain_balance_msat: u64 = 0;
let mut connected_channel_peer_set: std::collections::HashSet<String> =
std::collections::HashSet::new();
for ch in &channels.channels {
if ch.state.is_open() {
channels_balance_msat += ch.to_us_msat.unwrap_or(0);
max_payable_msat += ch.spendable_msat.unwrap_or(0);
total_channel_capacity_msat += ch.total_msat.unwrap_or(0);
let receivable = ch.receivable_msat.unwrap_or(0);
if receivable > max_receivable_single_payment_msat {
max_receivable_single_payment_msat = receivable;
}
total_inbound_liquidity_msat += receivable;
}
if channel_payout_still_pending(ch) {
pending_onchain_balance_msat += ch.to_us_msat.unwrap_or(0);
}
if ch.peer_connected {
connected_channel_peer_set.insert(ch.peer_id.clone());
}
}
let connected_channel_peers: Vec<String> =
connected_channel_peer_set.into_iter().collect();
let max_chan_reserve_msat =
channels_balance_msat.saturating_sub(max_payable_msat);
let mut onchain_balance_msat: u64 = 0;
let mut unconfirmed_onchain_balance_msat: u64 = 0;
let mut immature_onchain_balance_msat: u64 = 0;
let mut utxos: Vec<FundOutput> = Vec::with_capacity(funds.outputs.len());
for output in &funds.outputs {
if !matches!(output.status, OutputStatus::Spent) {
utxos.push(output.clone());
}
if output.reserved {
continue;
}
match output.status {
OutputStatus::Confirmed => onchain_balance_msat += output.amount_msat,
OutputStatus::Unconfirmed => {
unconfirmed_onchain_balance_msat += output.amount_msat
}
OutputStatus::Immature => {
immature_onchain_balance_msat += output.amount_msat
}
OutputStatus::Spent => {}
}
}
let total_onchain_msat = onchain_balance_msat
.saturating_add(unconfirmed_onchain_balance_msat)
.saturating_add(immature_onchain_balance_msat);
let total_balance_msat = channels_balance_msat
.saturating_add(total_onchain_msat)
.saturating_add(pending_onchain_balance_msat);
let spendable_balance_msat = max_payable_msat.saturating_add(onchain_balance_msat);
Ok(NodeState {
id: info.id,
block_height: info.blockheight,
network: info.network,
version: info.version,
alias: info.alias,
color: info.color,
num_active_channels: info.num_active_channels,
num_pending_channels: info.num_pending_channels,
num_inactive_channels: info.num_inactive_channels,
channels_balance_msat,
max_payable_msat,
total_channel_capacity_msat,
max_chan_reserve_msat,
onchain_balance_msat,
unconfirmed_onchain_balance_msat,
immature_onchain_balance_msat,
pending_onchain_balance_msat,
max_receivable_single_payment_msat,
total_inbound_liquidity_msat,
connected_channel_peers,
utxos,
total_onchain_msat,
total_balance_msat,
spendable_balance_msat,
})
}
pub fn list_invoices(
&self,
label: Option<String>,
invstring: Option<String>,
payment_hash: Option<Vec<u8>>,
offer_id: Option<String>,
index: Option<ListIndex>,
start: Option<u64>,
limit: Option<u32>,
) -> Result<ListInvoicesResponse, Error> {
self.check_connected()?;
let mut cln_client = exec(self.get_cln_client())?.clone();
let req = clnpb::ListinvoicesRequest {
label,
invstring,
payment_hash,
offer_id,
index: index.map(|i| i.to_i32()),
start,
limit,
};
let res = exec(cln_client.list_invoices(req))
.map_err(|e| Error::rpc(e.to_string()))?
.into_inner();
Ok(res.into())
}
pub fn list_pays(
&self,
bolt11: Option<String>,
payment_hash: Option<Vec<u8>>,
status: Option<PayStatus>,
index: Option<ListIndex>,
start: Option<u64>,
limit: Option<u32>,
) -> Result<ListPaysResponse, Error> {
self.check_connected()?;
let mut cln_client = exec(self.get_cln_client())?.clone();
let cln_status = status.map(|s| match s {
PayStatus::PENDING => 0,
PayStatus::COMPLETE => 1,
PayStatus::FAILED => 2,
});
let req = clnpb::ListpaysRequest {
bolt11,
payment_hash,
status: cln_status,
index: index.map(|i| i.to_i32()),
start,
limit,
};
let res = exec(cln_client.list_pays(req))
.map_err(|e| Error::rpc(e.to_string()))?
.into_inner();
Ok(res.into())
}
pub fn list_payments(&self, req: ListPaymentsRequest) -> Result<Vec<Payment>, Error> {
self.check_connected()?;
let mut cln_client = exec(self.get_cln_client())?.clone();
let invoices = exec(cln_client.list_invoices(clnpb::ListinvoicesRequest::default()))
.map_err(|e| Error::rpc(e.to_string()))?
.into_inner();
let mut cln_client = exec(self.get_cln_client())?.clone();
let pays = exec(cln_client.list_pays(clnpb::ListpaysRequest::default()))
.map_err(|e| Error::rpc(e.to_string()))?
.into_inner();
let mut payments: Vec<Payment> = Vec::new();
let include_received = req
.filters
.as_ref()
.map(|f| f.is_empty() || f.iter().any(|t| matches!(t, PaymentTypeFilter::Received)))
.unwrap_or(true);
let include_sent = req
.filters
.as_ref()
.map(|f| f.is_empty() || f.iter().any(|t| matches!(t, PaymentTypeFilter::Sent)))
.unwrap_or(true);
if include_received {
payments.extend(
invoices
.invoices
.into_iter()
.filter(|i| {
i.status()
== clnpb::listinvoices_invoices::ListinvoicesInvoicesStatus::Paid
})
.map(|i| -> Payment { i.into() }),
);
}
if include_sent {
payments.extend(pays.pays.into_iter().map(|p| -> Payment { p.into() }));
}
let include_failures = req.include_failures.unwrap_or(false);
payments.retain(|p| {
if !include_failures && matches!(p.status, PaymentStatus::Failed) {
return false;
}
if let Some(from) = req.from_timestamp {
if p.payment_time < from {
return false;
}
}
if let Some(to) = req.to_timestamp {
if p.payment_time > to {
return false;
}
}
true
});
payments.sort_by(|a, b| b.payment_time.cmp(&a.payment_time));
let offset = req.offset.unwrap_or(0) as usize;
let limit = req.limit.unwrap_or(u32::MAX) as usize;
let payments = payments.into_iter().skip(offset).take(limit).collect();
Ok(payments)
}
pub fn stream_node_events(&self) -> Result<Arc<NodeEventStream>, Error> {
self.check_connected()?;
let mut gl_client = exec(self.get_gl_client())?.clone();
let req = glpb::NodeEventsRequest {};
let stream = exec(gl_client.stream_node_events(req))
.map_err(|e| Error::rpc(e.to_string()))?
.into_inner();
Ok(Arc::new(NodeEventStream {
inner: Mutex::new(stream),
}))
}
pub fn generate_diagnostic_data(&self) -> Result<String, Error> {
self.check_connected()?;
let timestamp = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
let getinfo = render_section(self.get_info());
let listpeerchannels = render_section(self.list_peer_channels());
let listfunds = render_section(self.list_funds());
let node_state = render_section(self.node_state());
build_diagnostic_json(
timestamp,
env!("CARGO_PKG_VERSION"),
getinfo,
listpeerchannels,
listfunds,
node_state,
)
}
pub fn lnurl_pay(
&self,
request: crate::lnurl::LnUrlPayRequest,
) -> Result<crate::lnurl::LnUrlPayResult, Error> {
self.check_connected()?;
validate_lnurl_pay_input(&request)?;
let http_client = gl_client::lnurl::models::LnUrlHttpClearnetClient::new();
let comment = request.comment.as_deref();
let (invoice_str, success_action) = match exec(
gl_client::lnurl::pay::fetch_invoice(
&http_client,
&request.data.callback,
request.amount_msat,
comment,
),
) {
Ok(v) => v,
Err(e) => {
let msg = e.to_string();
let reason = msg
.strip_prefix(gl_client::lnurl::pay::LNURL_SERVICE_ERROR_PREFIX)
.unwrap_or(&msg)
.to_string();
return Ok(crate::lnurl::LnUrlPayResult::EndpointError {
data: crate::lnurl::LnUrlErrorData { reason },
});
}
};
if let Some(reason) = invoice_network_mismatch(&invoice_str, self.network) {
return Ok(crate::lnurl::LnUrlPayResult::EndpointError {
data: crate::lnurl::LnUrlErrorData { reason },
});
}
let mut cln_client = exec(self.get_cln_client())?.clone();
let pay_response = match exec(cln_client.pay(clnpb::PayRequest {
bolt11: invoice_str.clone(),
..Default::default()
})) {
Ok(r) => r.into_inner(),
Err(e) => {
let payment_hash = invoice_str
.parse::<Bolt11Invoice>()
.ok()
.map(|inv| inv.payment_hash().to_string())
.unwrap_or_default();
return Ok(crate::lnurl::LnUrlPayResult::PayError {
data: crate::lnurl::LnUrlPayErrorData {
payment_hash,
reason: e.to_string(),
},
});
}
};
let validate_url = request.validate_success_action_url.unwrap_or(true);
let processed_action = match success_action {
Some(action) => {
let processed = action
.process(&pay_response.payment_preimage)
.map_err(|e| Error::other(e.to_string()))?;
if validate_url {
if let gl_client::lnurl::models::ProcessedSuccessAction::Url {
url, ..
} = &processed
{
if let Some(reason) =
url_action_domain_mismatch(&request.data.callback, url)
{
return Err(Error::other(reason));
}
}
}
Some(processed.into())
}
None => None,
};
Ok(crate::lnurl::LnUrlPayResult::EndpointSuccess {
data: crate::lnurl::LnUrlPaySuccessData {
payment_preimage: hex::encode(&pay_response.payment_preimage),
success_action: processed_action,
},
})
}
pub fn lnurl_withdraw(
&self,
request: crate::lnurl::LnUrlWithdrawRequest,
) -> Result<crate::lnurl::LnUrlWithdrawResult, Error> {
self.check_connected()?;
let http_client = gl_client::lnurl::models::LnUrlHttpClearnetClient::new();
let description = request
.description
.unwrap_or(request.data.default_description.clone());
let invoice_response = self.receive(
format!("lnurl-withdraw-{}", request.data.k1),
description,
Some(request.amount_msat),
)?;
let callback_url = gl_client::lnurl::withdraw::build_withdraw_callback_url(
&request.data.callback,
&request.data.k1,
&invoice_response.bolt11,
)
.map_err(|e| Error::other(e.to_string()))?;
match exec(http_client.send_invoice_for_withdraw_request(&callback_url)) {
Ok(_) => Ok(crate::lnurl::LnUrlWithdrawResult::Ok {
data: crate::lnurl::LnUrlWithdrawSuccessData {
invoice: invoice_response.bolt11,
},
}),
Err(e) => Ok(crate::lnurl::LnUrlWithdrawResult::ErrorStatus {
data: crate::lnurl::LnUrlErrorData {
reason: e.to_string(),
},
}),
}
}
}
fn render_section<T: serde::Serialize>(result: Result<T, Error>) -> serde_json::Value {
match result {
Ok(v) => serde_json::to_value(&v)
.unwrap_or_else(|e| serde_json::json!({ "error": e.to_string() })),
Err(e) => serde_json::json!({ "error": e.to_string() }),
}
}
fn build_diagnostic_json(
timestamp: u64,
sdk_version: &str,
getinfo: serde_json::Value,
listpeerchannels: serde_json::Value,
listfunds: serde_json::Value,
node_state: serde_json::Value,
) -> Result<String, Error> {
let envelope = serde_json::json!({
"timestamp": timestamp,
"node": {
"getinfo": getinfo,
"listpeerchannels": listpeerchannels,
"listfunds": listfunds,
},
"sdk": {
"version": sdk_version,
"node_state": node_state,
}
});
serde_json::to_string_pretty(&envelope).map_err(|e| Error::other(e.to_string()))
}
fn invoice_network_mismatch(
invoice_str: &str,
node_network: gl_client::bitcoin::Network,
) -> Option<String> {
use lightning_invoice::Currency;
let invoice = invoice_str.parse::<Bolt11Invoice>().ok()?;
let expected = match node_network {
gl_client::bitcoin::Network::Bitcoin => Currency::Bitcoin,
gl_client::bitcoin::Network::Testnet => Currency::BitcoinTestnet,
gl_client::bitcoin::Network::Signet => Currency::Signet,
gl_client::bitcoin::Network::Regtest => Currency::Regtest,
_ => return None,
};
if invoice.currency() == expected {
None
} else {
Some(format!(
"invoice is for {:?}, but this node is on {:?}",
invoice.currency(),
node_network
))
}
}
fn url_action_domain_mismatch(callback_url: &str, action_url: &str) -> Option<String> {
let cb = url::Url::parse(callback_url).ok()?;
let action = url::Url::parse(action_url).ok()?;
let cb_domain = cb.domain()?;
let action_domain = action.domain()?;
if cb_domain == action_domain {
None
} else {
Some(format!(
"success action URL domain ({}) does not match the callback domain ({})",
action_domain, cb_domain
))
}
}
fn validate_lnurl_pay_input(request: &crate::lnurl::LnUrlPayRequest) -> Result<(), Error> {
let data = &request.data;
if request.amount_msat < data.min_sendable {
return Err(Error::other(format!(
"amount_msat {} is below the service's min_sendable ({})",
request.amount_msat, data.min_sendable
)));
}
if request.amount_msat > data.max_sendable {
return Err(Error::other(format!(
"amount_msat {} is above the service's max_sendable ({})",
request.amount_msat, data.max_sendable
)));
}
if let Some(comment) = request.comment.as_deref() {
if data.comment_allowed == 0 && !comment.is_empty() {
return Err(Error::other(
"this LNURL service does not accept comments".to_string(),
));
}
if (comment.len() as u64) > data.comment_allowed {
return Err(Error::other(format!(
"comment length {} exceeds the service's comment_allowed ({})",
comment.len(),
data.comment_allowed
)));
}
}
Ok(())
}
impl Node {
pub(crate) fn set_event_listener(
&self,
listener: std::sync::Arc<dyn NodeEventListener>,
) -> Result<(), Error> {
self.check_connected()?;
let mut gl_client = exec(self.get_gl_client())?.clone();
let req = glpb::NodeEventsRequest {};
let stream = exec(gl_client.stream_node_events(req))
.map_err(|e| Error::rpc(e.to_string()))?
.into_inner();
let mut guard = self
.event_task
.lock()
.map_err(|e| Error::other(e.to_string()))?;
if let Some(prev) = guard.take() {
prev.abort();
}
let task = crate::util::get_runtime().spawn(async move {
let mut stream = stream;
loop {
match stream.message().await {
Ok(Some(raw)) => {
if let Some(event) = node_event_from_pb(raw) {
listener.on_event(event);
}
}
Ok(None) => break,
Err(e) if e.code() == tonic::Code::Unknown => break,
Err(_) => break,
}
}
});
*guard = Some(task);
Ok(())
}
fn check_connected(&self) -> Result<(), Error> {
if self.disconnected.load(Ordering::Relaxed) {
return Err(Error::other("Node is disconnected".to_string()));
}
Ok(())
}
pub(crate) fn with_signer(
credentials: Credentials,
handle: Handle,
network: gl_client::bitcoin::Network,
) -> Result<Self, Error> {
let node_id = credentials
.inner
.node_id()
.map_err(|_e| Error::unparseable_creds())?;
let inner = ClientNode::new(node_id, credentials.inner.clone())
.expect("infallible client instantiation");
let cln_client = OnceCell::const_new();
let gl_client = OnceCell::const_new();
Ok(Node {
inner,
cln_client,
gl_client,
stored_credentials: Some(credentials),
signer_handle: Some(handle),
disconnected: AtomicBool::new(false),
event_task: Mutex::new(None),
network,
})
}
async fn get_gl_client<'a>(&'a self) -> Result<&'a GlClient, Error> {
let inner = self.inner.clone();
self.gl_client
.get_or_try_init(|| async { inner.schedule::<GlClient>().await })
.await
.map_err(|e| Error::rpc(e.to_string()))
}
async fn get_cln_client<'a>(&'a self) -> Result<&'a ClnClient, Error> {
let inner = self.inner.clone();
self.cln_client
.get_or_try_init(|| async { inner.schedule::<ClnClient>().await })
.await
.map_err(|e| Error::rpc(e.to_string()))
}
}
#[derive(Clone, uniffi::Record)]
pub struct Outpoint {
pub txid: String,
pub vout: u32,
}
#[derive(Clone, uniffi::Record)]
pub struct OnchainFeeRates {
pub next_block_sat_per_vbyte: u64,
pub half_hour_sat_per_vbyte: u64,
pub hour_sat_per_vbyte: u64,
pub day_sat_per_vbyte: u64,
pub minimum_relay_sat_per_vbyte: u64,
}
fn sat_per_vbyte_from_perkw(perkw: u32) -> u64 {
(perkw as u64).div_ceil(250)
}
fn pick_perkw_for_target(
estimates: &[clnpb::FeeratesPerkwEstimates],
target_blocks: u32,
) -> Option<u32> {
let above = estimates
.iter()
.filter(|e| e.blockcount >= target_blocks)
.min_by_key(|e| e.blockcount)
.map(|e| e.feerate);
above.or_else(|| {
estimates
.iter()
.max_by_key(|e| e.blockcount)
.map(|e| e.feerate)
})
}
fn compute_fee_rates(perkw: Option<&clnpb::FeeratesPerkw>) -> OnchainFeeRates {
const FALLBACK_SAT_PER_VBYTE: u64 = 1;
let Some(p) = perkw else {
return OnchainFeeRates {
next_block_sat_per_vbyte: FALLBACK_SAT_PER_VBYTE,
half_hour_sat_per_vbyte: FALLBACK_SAT_PER_VBYTE,
hour_sat_per_vbyte: FALLBACK_SAT_PER_VBYTE,
day_sat_per_vbyte: FALLBACK_SAT_PER_VBYTE,
minimum_relay_sat_per_vbyte: FALLBACK_SAT_PER_VBYTE,
};
};
let minimum_relay_sat_per_vbyte =
sat_per_vbyte_from_perkw(p.min_acceptable).max(FALLBACK_SAT_PER_VBYTE);
let bucket = |target_blocks: u32| -> u64 {
pick_perkw_for_target(&p.estimates, target_blocks)
.map(sat_per_vbyte_from_perkw)
.unwrap_or(minimum_relay_sat_per_vbyte)
.max(minimum_relay_sat_per_vbyte)
};
OnchainFeeRates {
next_block_sat_per_vbyte: bucket(1),
half_hour_sat_per_vbyte: bucket(3),
hour_sat_per_vbyte: bucket(6),
day_sat_per_vbyte: bucket(144),
minimum_relay_sat_per_vbyte,
}
}
#[derive(Clone, uniffi::Enum)]
pub enum OnchainBalanceState {
Unavailable,
Available {
withdrawable_sat: u64,
emergency_reserve_sat: u64,
unconfirmed_sat: u64,
},
ReserveOnly { reserve_sat: u64 },
PendingConfirmation { unconfirmed_sat: u64 },
Immature { immature_sat: u64 },
}
fn classify_onchain_balance(
confirmed_sat: u64,
reserve_sat: u64,
unconfirmed_sat: u64,
immature_sat: u64,
pending_close_sat: u64,
) -> OnchainBalanceState {
let withdrawable_sat = confirmed_sat.saturating_sub(reserve_sat);
if confirmed_sat == 0
&& unconfirmed_sat == 0
&& immature_sat == 0
&& pending_close_sat == 0
{
return OnchainBalanceState::Unavailable;
}
if withdrawable_sat > ONCHAIN_DUST_THRESHOLD_SAT {
return OnchainBalanceState::Available {
withdrawable_sat,
emergency_reserve_sat: reserve_sat,
unconfirmed_sat,
};
}
if confirmed_sat > 0 && reserve_sat > 0 {
return OnchainBalanceState::ReserveOnly { reserve_sat };
}
if unconfirmed_sat > 0 {
return OnchainBalanceState::PendingConfirmation { unconfirmed_sat };
}
OnchainBalanceState::Immature { immature_sat }
}
#[derive(uniffi::Record)]
pub struct PreparedOnchainSend {
pub utxos: Vec<Outpoint>,
pub total_input_sat: u64,
pub fee_sat: u64,
pub recipient_sat: u64,
pub sat_per_vbyte: u32,
}
#[derive(uniffi::Record)]
pub struct OnchainSendResponse {
pub tx: Vec<u8>,
pub txid: String,
pub psbt: String,
}
fn parse_amount_or_all(amount_or_all: &str) -> Result<clnpb::AmountOrAll, Error> {
let (num, suffix): (String, String) =
amount_or_all.chars().partition(|c| c.is_ascii_digit());
let num = if num.is_empty() {
0
} else {
num.parse::<u64>()
.map_err(|_| Error::argument("amount_or_all", amount_or_all))?
};
match (num, suffix.as_str()) {
(n, "") | (n, "sat") => Ok(clnpb::AmountOrAll {
value: Some(clnpb::amount_or_all::Value::Amount(clnpb::Amount {
msat: n * 1000,
})),
}),
(n, "msat") => Ok(clnpb::AmountOrAll {
value: Some(clnpb::amount_or_all::Value::Amount(clnpb::Amount { msat: n })),
}),
(0, "all") => Ok(clnpb::AmountOrAll {
value: Some(clnpb::amount_or_all::Value::All(true)),
}),
_ => Err(Error::argument("amount_or_all", amount_or_all)),
}
}
fn feerate_perkw_from_sat_per_vbyte(sat_per_vbyte: u32) -> clnpb::Feerate {
clnpb::Feerate {
style: Some(clnpb::feerate::Style::Perkw(sat_per_vbyte * 250)),
}
}
fn outpoint_to_pb(o: Outpoint) -> Result<clnpb::Outpoint, Error> {
let txid = hex::decode(&o.txid)
.map_err(|_| Error::argument("utxos.txid", o.txid.clone()))?;
Ok(clnpb::Outpoint {
txid,
outnum: o.vout,
})
}
const BASE_TX_CORE_WEIGHT: u32 = 42;
const ONCHAIN_DUST_THRESHOLD_SAT: u64 = 546;
fn output_weight_for_address(addr: &str) -> u32 {
match bitcoin::Address::from_str(addr) {
Ok(a) => {
let spk_len = a.assume_checked().script_pubkey().len();
let varint_len = if spk_len < 0xfd { 1 } else { 3 };
((8 + varint_len + spk_len) * 4) as u32
}
Err(_) => 172,
}
}
impl From<clnpb::WithdrawResponse> for OnchainSendResponse {
fn from(other: clnpb::WithdrawResponse) -> Self {
Self {
tx: other.tx,
txid: hex::encode(&other.txid),
psbt: other.psbt,
}
}
}
#[derive(uniffi::Record)]
pub struct OnchainReceiveResponse {
pub bech32: String,
pub p2tr: String,
}
impl From<clnpb::NewaddrResponse> for OnchainReceiveResponse {
fn from(other: clnpb::NewaddrResponse) -> Self {
OnchainReceiveResponse {
bech32: other.bech32.unwrap_or_default(),
p2tr: other.p2tr.unwrap_or_default(),
}
}
}
#[derive(uniffi::Record)]
pub struct SendResponse {
pub status: PayStatus,
pub preimage: String,
pub payment_hash: String,
pub destination_pubkey: Option<String>,
pub amount_msat: u64,
pub amount_sent_msat: u64,
pub parts: u32,
}
impl From<clnpb::PayResponse> for SendResponse {
fn from(other: clnpb::PayResponse) -> Self {
Self {
status: other.status.into(),
preimage: hex::encode(&other.payment_preimage),
payment_hash: hex::encode(&other.payment_hash),
destination_pubkey: other.destination.as_deref().map(hex::encode),
amount_msat: other.amount_msat.unwrap().msat,
amount_sent_msat: other.amount_sent_msat.unwrap().msat,
parts: other.parts,
}
}
}
#[derive(uniffi::Record)]
pub struct ReceiveResponse {
pub bolt11: String,
pub opening_fee_msat: u64,
}
#[derive(uniffi::Enum, Clone, serde::Serialize)]
pub enum PayStatus {
COMPLETE = 0,
PENDING = 1,
FAILED = 2,
}
impl From<clnpb::pay_response::PayStatus> for PayStatus {
fn from(other: clnpb::pay_response::PayStatus) -> Self {
match other {
clnpb::pay_response::PayStatus::Complete => PayStatus::COMPLETE,
clnpb::pay_response::PayStatus::Failed => PayStatus::FAILED,
clnpb::pay_response::PayStatus::Pending => PayStatus::PENDING,
}
}
}
impl From<i32> for PayStatus {
fn from(i: i32) -> Self {
match i {
0 => PayStatus::COMPLETE,
1 => PayStatus::PENDING,
2 => PayStatus::FAILED,
o => panic!("Unknown pay_status {}", o),
}
}
}
#[allow(unused)]
#[derive(Clone, serde::Serialize, uniffi::Record)]
pub struct GetInfoResponse {
pub id: String,
pub alias: Option<String>,
pub color: String,
pub num_peers: u32,
pub num_pending_channels: u32,
pub num_active_channels: u32,
pub num_inactive_channels: u32,
pub version: String,
pub lightning_dir: String,
pub blockheight: u32,
pub network: String,
pub fees_collected_msat: u64,
}
impl From<clnpb::GetinfoResponse> for GetInfoResponse {
fn from(other: clnpb::GetinfoResponse) -> Self {
Self {
id: hex::encode(&other.id),
alias: other.alias,
color: hex::encode(&other.color),
num_peers: other.num_peers,
num_pending_channels: other.num_pending_channels,
num_active_channels: other.num_active_channels,
num_inactive_channels: other.num_inactive_channels,
version: other.version,
lightning_dir: other.lightning_dir,
blockheight: other.blockheight,
network: other.network,
fees_collected_msat: other.fees_collected_msat.map(|a| a.msat).unwrap_or(0),
}
}
}
#[allow(unused)]
#[derive(Clone, uniffi::Record)]
pub struct ListPeersResponse {
pub peers: Vec<Peer>,
}
#[allow(unused)]
#[derive(Clone, uniffi::Record)]
pub struct Peer {
pub id: String,
pub connected: bool,
pub num_channels: Option<u32>,
pub netaddr: Vec<String>,
pub remote_addr: Option<String>,
pub features: Option<Vec<u8>>,
}
impl From<clnpb::ListpeersResponse> for ListPeersResponse {
fn from(other: clnpb::ListpeersResponse) -> Self {
Self {
peers: other.peers.into_iter().map(|p| p.into()).collect(),
}
}
}
impl From<clnpb::ListpeersPeers> for Peer {
fn from(other: clnpb::ListpeersPeers) -> Self {
Self {
id: hex::encode(&other.id),
connected: other.connected,
num_channels: other.num_channels,
netaddr: other.netaddr,
remote_addr: other.remote_addr,
features: other.features,
}
}
}
#[allow(unused)]
#[derive(Clone, serde::Serialize, uniffi::Record)]
pub struct ListPeerChannelsResponse {
pub channels: Vec<PeerChannel>,
}
#[allow(unused)]
#[derive(Clone, serde::Serialize, uniffi::Record)]
pub struct PeerChannel {
pub peer_id: String,
pub peer_connected: bool,
pub state: ChannelState,
pub short_channel_id: Option<String>,
pub channel_id: Option<String>,
pub funding_txid: Option<String>,
pub funding_outnum: Option<u32>,
pub to_us_msat: Option<u64>,
pub total_msat: Option<u64>,
pub spendable_msat: Option<u64>,
pub receivable_msat: Option<u64>,
pub closer: Option<ChannelSide>,
pub status: Vec<String>,
}
#[derive(Clone, serde::Serialize, uniffi::Enum)]
pub enum ChannelSide {
Local,
Remote,
}
impl ChannelSide {
fn from_i32(value: i32) -> Option<Self> {
match value {
0 => Some(ChannelSide::Local),
1 => Some(ChannelSide::Remote),
_ => None,
}
}
}
#[derive(Clone, serde::Serialize, uniffi::Enum)]
pub enum ChannelState {
Openingd,
ChanneldAwaitingLockin,
ChanneldNormal,
ChanneldShuttingDown,
ClosingdSigexchange,
ClosingdComplete,
AwaitingUnilateral,
FundingSpendSeen,
Onchain,
DualopendOpenInit,
DualopendAwaitingLockin,
DualopendOpenCommitted,
DualopendOpenCommitReady,
Unknown,
}
impl ChannelState {
fn from_i32(value: i32) -> Self {
match value {
0 => ChannelState::Openingd,
1 => ChannelState::ChanneldAwaitingLockin,
2 => ChannelState::ChanneldNormal,
3 => ChannelState::ChanneldShuttingDown,
4 => ChannelState::ClosingdSigexchange,
5 => ChannelState::ClosingdComplete,
6 => ChannelState::AwaitingUnilateral,
7 => ChannelState::FundingSpendSeen,
8 => ChannelState::Onchain,
9 => ChannelState::DualopendOpenInit,
10 => ChannelState::DualopendAwaitingLockin,
11 => ChannelState::DualopendOpenCommitted,
12 => ChannelState::DualopendOpenCommitReady,
_ => ChannelState::Unknown,
}
}
fn is_open(&self) -> bool {
matches!(self, ChannelState::ChanneldNormal)
}
}
fn channel_payout_still_pending(ch: &PeerChannel) -> bool {
match ch.state {
ChannelState::ChanneldShuttingDown
| ChannelState::ClosingdSigexchange
| ChannelState::ClosingdComplete
| ChannelState::AwaitingUnilateral
| ChannelState::FundingSpendSeen => true,
ChannelState::Onchain => {
matches!(ch.closer, Some(ChannelSide::Local))
&& ch
.status
.last()
.is_some_and(|s| s.contains("DELAYED_OUTPUT_TO_US"))
}
_ => false,
}
}
impl From<clnpb::ListpeerchannelsResponse> for ListPeerChannelsResponse {
fn from(other: clnpb::ListpeerchannelsResponse) -> Self {
Self {
channels: other.channels.into_iter().map(|c| c.into()).collect(),
}
}
}
impl From<clnpb::ListpeerchannelsChannels> for PeerChannel {
fn from(other: clnpb::ListpeerchannelsChannels) -> Self {
let state = ChannelState::from_i32(other.state);
let closer = other.closer.and_then(ChannelSide::from_i32);
Self {
peer_id: hex::encode(&other.peer_id),
peer_connected: other.peer_connected,
state,
short_channel_id: other.short_channel_id,
channel_id: other.channel_id.as_deref().map(hex::encode),
funding_txid: other.funding_txid.as_deref().map(hex::encode),
funding_outnum: other.funding_outnum,
to_us_msat: other.to_us_msat.map(|a| a.msat),
total_msat: other.total_msat.map(|a| a.msat),
spendable_msat: other.spendable_msat.map(|a| a.msat),
receivable_msat: other.receivable_msat.map(|a| a.msat),
closer,
status: other.status,
}
}
}
#[allow(unused)]
#[derive(Clone, serde::Serialize, uniffi::Record)]
pub struct ListFundsResponse {
pub outputs: Vec<FundOutput>,
pub channels: Vec<FundChannel>,
}
#[allow(unused)]
#[derive(Clone, serde::Serialize, uniffi::Record)]
pub struct FundOutput {
pub txid: String,
pub output: u32,
pub amount_msat: u64,
pub status: OutputStatus,
pub address: Option<String>,
pub blockheight: Option<u32>,
pub reserved: bool,
}
#[derive(Clone, serde::Serialize, uniffi::Enum)]
pub enum OutputStatus {
Unconfirmed,
Confirmed,
Spent,
Immature,
}
impl OutputStatus {
fn from_i32(value: i32) -> Self {
match value {
0 => OutputStatus::Unconfirmed,
1 => OutputStatus::Confirmed,
2 => OutputStatus::Spent,
3 => OutputStatus::Immature,
_ => OutputStatus::Unconfirmed, }
}
}
#[allow(unused)]
#[derive(Clone, serde::Serialize, uniffi::Record)]
pub struct FundChannel {
pub peer_id: String,
pub our_amount_msat: u64,
pub amount_msat: u64,
pub funding_txid: String,
pub funding_output: u32,
pub connected: bool,
pub state: ChannelState,
pub short_channel_id: Option<String>,
pub channel_id: Option<String>,
}
impl From<clnpb::ListfundsResponse> for ListFundsResponse {
fn from(other: clnpb::ListfundsResponse) -> Self {
Self {
outputs: other.outputs.into_iter().map(|o| o.into()).collect(),
channels: other.channels.into_iter().map(|c| c.into()).collect(),
}
}
}
impl From<clnpb::ListfundsOutputs> for FundOutput {
fn from(other: clnpb::ListfundsOutputs) -> Self {
let status = OutputStatus::from_i32(other.status);
Self {
txid: hex::encode(&other.txid),
output: other.output,
amount_msat: other.amount_msat.map(|a| a.msat).unwrap_or(0),
status,
address: other.address,
blockheight: other.blockheight,
reserved: other.reserved,
}
}
}
impl From<clnpb::ListfundsChannels> for FundChannel {
fn from(other: clnpb::ListfundsChannels) -> Self {
let state = ChannelState::from_i32(other.state);
Self {
peer_id: hex::encode(&other.peer_id),
our_amount_msat: other.our_amount_msat.map(|a| a.msat).unwrap_or(0),
amount_msat: other.amount_msat.map(|a| a.msat).unwrap_or(0),
funding_txid: hex::encode(&other.funding_txid),
funding_output: other.funding_output,
connected: other.connected,
state,
short_channel_id: other.short_channel_id,
channel_id: other.channel_id.as_deref().map(hex::encode),
}
}
}
#[derive(Clone, uniffi::Enum)]
pub enum ListIndex {
CREATED,
UPDATED,
}
impl ListIndex {
fn to_i32(&self) -> i32 {
match self {
ListIndex::CREATED => 0,
ListIndex::UPDATED => 1,
}
}
}
#[derive(Clone, serde::Serialize, uniffi::Enum)]
pub enum InvoiceStatus {
UNPAID,
PAID,
EXPIRED,
}
impl From<i32> for InvoiceStatus {
fn from(i: i32) -> Self {
match i {
0 => InvoiceStatus::UNPAID,
1 => InvoiceStatus::PAID,
2 => InvoiceStatus::EXPIRED,
o => panic!("Unknown invoice status {}", o),
}
}
}
#[derive(Clone, serde::Serialize, uniffi::Record)]
pub struct Invoice {
pub label: String,
pub description: String,
pub payment_hash: String,
pub status: InvoiceStatus,
pub amount_msat: Option<u64>,
pub amount_received_msat: Option<u64>,
pub bolt11: Option<String>,
pub bolt12: Option<String>,
pub paid_at: Option<u64>,
pub expires_at: u64,
pub payment_preimage: Option<String>,
pub destination_pubkey: Option<String>,
}
fn pubkey_from_bolt11(bolt11: &str) -> Option<String> {
let invoice: Bolt11Invoice = bolt11.parse().ok()?;
Some(hex::encode(invoice.recover_payee_pub_key().serialize()))
}
impl From<clnpb::ListinvoicesInvoices> for Invoice {
fn from(other: clnpb::ListinvoicesInvoices) -> Self {
let destination_pubkey = other.bolt11.as_deref().and_then(pubkey_from_bolt11);
Self {
label: other.label,
description: other.description.unwrap_or_default(),
payment_hash: hex::encode(&other.payment_hash),
status: other.status.into(),
amount_msat: other.amount_msat.map(|a| a.msat),
amount_received_msat: other.amount_received_msat.map(|a| a.msat),
bolt11: other.bolt11,
bolt12: other.bolt12,
paid_at: other.paid_at,
expires_at: other.expires_at,
payment_preimage: other.payment_preimage.as_deref().map(hex::encode),
destination_pubkey,
}
}
}
#[derive(Clone, serde::Serialize, uniffi::Record)]
pub struct ListInvoicesResponse {
pub invoices: Vec<Invoice>,
}
impl From<clnpb::ListinvoicesResponse> for ListInvoicesResponse {
fn from(other: clnpb::ListinvoicesResponse) -> Self {
Self {
invoices: other.invoices.into_iter().map(|i| i.into()).collect(),
}
}
}
#[derive(Clone, serde::Serialize, uniffi::Record)]
pub struct Pay {
pub payment_hash: String,
pub status: PayStatus,
pub destination_pubkey: Option<String>,
pub amount_msat: Option<u64>,
pub amount_sent_msat: Option<u64>,
pub label: Option<String>,
pub bolt11: Option<String>,
pub description: Option<String>,
pub bolt12: Option<String>,
pub preimage: Option<String>,
pub created_at: u64,
pub completed_at: Option<u64>,
pub number_of_parts: Option<u64>,
}
impl From<clnpb::ListpaysPays> for Pay {
fn from(other: clnpb::ListpaysPays) -> Self {
let status = match other.status {
0 => PayStatus::PENDING, 1 => PayStatus::FAILED, 2 => PayStatus::COMPLETE, o => panic!("Unknown listpays status {}", o),
};
Self {
payment_hash: hex::encode(&other.payment_hash),
status,
destination_pubkey: other.destination.as_deref().map(hex::encode),
amount_msat: other.amount_msat.map(|a| a.msat),
amount_sent_msat: other.amount_sent_msat.map(|a| a.msat),
label: other.label,
bolt11: other.bolt11,
description: other.description,
bolt12: other.bolt12,
preimage: other.preimage.as_deref().map(hex::encode),
created_at: other.created_at,
completed_at: other.completed_at,
number_of_parts: other.number_of_parts,
}
}
}
#[derive(Clone, serde::Serialize, uniffi::Record)]
pub struct ListPaysResponse {
pub pays: Vec<Pay>,
}
impl From<clnpb::ListpaysResponse> for ListPaysResponse {
fn from(other: clnpb::ListpaysResponse) -> Self {
Self {
pays: other.pays.into_iter().map(|p| p.into()).collect(),
}
}
}
#[derive(Clone, Default, uniffi::Record)]
pub struct ListPaymentsRequest {
pub filters: Option<Vec<PaymentTypeFilter>>,
pub from_timestamp: Option<u64>,
pub to_timestamp: Option<u64>,
pub include_failures: Option<bool>,
pub offset: Option<u32>,
pub limit: Option<u32>,
}
#[derive(Clone, uniffi::Enum)]
pub enum PaymentTypeFilter {
Sent,
Received,
}
#[derive(Clone, uniffi::Record)]
pub struct Payment {
pub id: String,
pub payment_type: PaymentType,
pub payment_time: u64,
pub amount_msat: u64,
pub fee_msat: u64,
pub status: PaymentStatus,
pub description: Option<String>,
pub bolt11: Option<String>,
pub preimage: Option<String>,
pub destination: Option<String>,
}
#[derive(Clone, uniffi::Enum)]
pub enum PaymentType {
Sent,
Received,
}
#[derive(Clone, uniffi::Enum)]
pub enum PaymentStatus {
Pending,
Complete,
Failed,
}
impl From<clnpb::ListinvoicesInvoices> for Payment {
fn from(inv: clnpb::ListinvoicesInvoices) -> Self {
let status = match inv.status() {
clnpb::listinvoices_invoices::ListinvoicesInvoicesStatus::Paid => {
PaymentStatus::Complete
}
clnpb::listinvoices_invoices::ListinvoicesInvoicesStatus::Expired => {
PaymentStatus::Failed
}
clnpb::listinvoices_invoices::ListinvoicesInvoicesStatus::Unpaid => {
PaymentStatus::Pending
}
};
let payment_time = inv.paid_at.unwrap_or(inv.expires_at);
let amount_msat = inv
.amount_received_msat
.or(inv.amount_msat)
.map(|a| a.msat)
.unwrap_or(0);
Payment {
id: hex::encode(&inv.payment_hash),
payment_type: PaymentType::Received,
payment_time,
amount_msat,
fee_msat: 0,
status,
description: inv.description,
bolt11: inv.bolt11,
preimage: inv.payment_preimage.as_deref().map(hex::encode),
destination: None,
}
}
}
impl From<clnpb::ListpaysPays> for Payment {
fn from(pay: clnpb::ListpaysPays) -> Self {
let status = match pay.status() {
clnpb::listpays_pays::ListpaysPaysStatus::Complete => PaymentStatus::Complete,
clnpb::listpays_pays::ListpaysPaysStatus::Failed => PaymentStatus::Failed,
clnpb::listpays_pays::ListpaysPaysStatus::Pending => PaymentStatus::Pending,
};
let payment_time = pay.completed_at.unwrap_or(pay.created_at);
let amount_msat = pay.amount_msat.as_ref().map(|a| a.msat).unwrap_or(0);
let amount_sent_msat = pay.amount_sent_msat.as_ref().map(|a| a.msat).unwrap_or(0);
let fee_msat = amount_sent_msat.saturating_sub(amount_msat);
Payment {
id: hex::encode(&pay.payment_hash),
payment_type: PaymentType::Sent,
payment_time,
amount_msat,
fee_msat,
status,
description: pay.description,
bolt11: pay.bolt11,
preimage: pay.preimage.as_deref().map(hex::encode),
destination: pay.destination.as_deref().map(hex::encode),
}
}
}
#[derive(Clone, serde::Serialize, uniffi::Record)]
pub struct NodeState {
pub id: String,
pub block_height: u32,
pub network: String,
pub version: String,
pub alias: Option<String>,
pub color: String,
pub num_active_channels: u32,
pub num_pending_channels: u32,
pub num_inactive_channels: u32,
pub channels_balance_msat: u64,
pub max_payable_msat: u64,
pub total_channel_capacity_msat: u64,
pub max_chan_reserve_msat: u64,
pub onchain_balance_msat: u64,
pub unconfirmed_onchain_balance_msat: u64,
pub immature_onchain_balance_msat: u64,
pub pending_onchain_balance_msat: u64,
pub max_receivable_single_payment_msat: u64,
pub total_inbound_liquidity_msat: u64,
pub connected_channel_peers: Vec<String>,
pub utxos: Vec<FundOutput>,
pub total_onchain_msat: u64,
pub total_balance_msat: u64,
pub spendable_balance_msat: u64,
}
#[uniffi::export(callback_interface)]
pub trait NodeEventListener: Send + Sync {
fn on_event(&self, event: NodeEvent);
}
#[derive(uniffi::Object)]
pub struct NodeEventStream {
inner: Mutex<tonic::codec::Streaming<glpb::NodeEvent>>,
}
#[uniffi::export]
impl NodeEventStream {
pub fn next(&self) -> Result<Option<NodeEvent>, Error> {
let mut stream = self.inner.lock().map_err(|e| Error::other(e.to_string()))?;
loop {
match exec(stream.message()) {
Ok(Some(raw)) => {
if let Some(event) = node_event_from_pb(raw) {
return Ok(Some(event));
}
}
Ok(None) => return Ok(None),
Err(e) if e.code() == tonic::Code::Unknown => return Ok(None),
Err(e) => return Err(Error::rpc(e.to_string())),
}
}
}
}
#[derive(Clone, uniffi::Enum)]
pub enum NodeEvent {
InvoicePaid { details: InvoicePaidEvent },
}
#[derive(Clone, uniffi::Record)]
pub struct InvoicePaidEvent {
pub payment_hash: String,
pub bolt11: String,
pub preimage: String,
pub label: String,
pub amount_msat: u64,
}
fn node_event_from_pb(other: glpb::NodeEvent) -> Option<NodeEvent> {
match other.event {
Some(glpb::node_event::Event::InvoicePaid(paid)) => Some(NodeEvent::InvoicePaid {
details: InvoicePaidEvent {
payment_hash: hex::encode(&paid.payment_hash),
bolt11: paid.bolt11,
preimage: hex::encode(&paid.preimage),
label: paid.label,
amount_msat: paid.amount_msat,
},
}),
None => None,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_amount_or_all_handles_all_variants() {
let all = parse_amount_or_all("all").unwrap();
assert!(matches!(all.value, Some(clnpb::amount_or_all::Value::All(true))));
let plain = parse_amount_or_all("50000").unwrap();
assert!(matches!(
plain.value,
Some(clnpb::amount_or_all::Value::Amount(clnpb::Amount { msat: 50_000_000 }))
));
let sat = parse_amount_or_all("50000sat").unwrap();
assert!(matches!(
sat.value,
Some(clnpb::amount_or_all::Value::Amount(clnpb::Amount { msat: 50_000_000 }))
));
let msat = parse_amount_or_all("50000msat").unwrap();
assert!(matches!(
msat.value,
Some(clnpb::amount_or_all::Value::Amount(clnpb::Amount { msat: 50_000 }))
));
assert!(parse_amount_or_all("notanumber").is_err());
assert!(parse_amount_or_all("50000btc").is_err());
}
#[test]
fn classify_onchain_balance_unavailable_when_empty() {
assert!(matches!(
classify_onchain_balance(0, 0, 0, 0, 0),
OnchainBalanceState::Unavailable
));
}
#[test]
fn classify_onchain_balance_available_with_room_above_dust() {
match classify_onchain_balance(100_000, 25_000, 5_000, 0, 0) {
OnchainBalanceState::Available {
withdrawable_sat,
emergency_reserve_sat,
unconfirmed_sat,
} => {
assert_eq!(withdrawable_sat, 75_000);
assert_eq!(emergency_reserve_sat, 25_000);
assert_eq!(unconfirmed_sat, 5_000);
}
other => panic!("expected Available, got {:?}", std::mem::discriminant(&other)),
}
}
#[test]
fn classify_onchain_balance_reserve_only_when_balance_equals_reserve() {
match classify_onchain_balance(25_000, 25_000, 0, 0, 0) {
OnchainBalanceState::ReserveOnly { reserve_sat } => {
assert_eq!(reserve_sat, 25_000);
}
other => panic!(
"expected ReserveOnly, got {:?}",
std::mem::discriminant(&other)
),
}
}
#[test]
fn classify_onchain_balance_pending_when_only_unconfirmed() {
match classify_onchain_balance(0, 0, 50_000, 0, 0) {
OnchainBalanceState::PendingConfirmation { unconfirmed_sat } => {
assert_eq!(unconfirmed_sat, 50_000);
}
other => panic!(
"expected PendingConfirmation, got {:?}",
std::mem::discriminant(&other)
),
}
}
#[test]
fn classify_onchain_balance_immature_when_only_immature() {
match classify_onchain_balance(0, 0, 0, 100_000, 0) {
OnchainBalanceState::Immature { immature_sat } => {
assert_eq!(immature_sat, 100_000);
}
other => panic!("expected Immature, got {:?}", std::mem::discriminant(&other)),
}
}
#[test]
fn classify_onchain_balance_real_wallet_small_onchain_with_active_channels() {
match classify_onchain_balance(1_228, 25_000, 0, 0, 0) {
OnchainBalanceState::ReserveOnly { reserve_sat } => {
assert_eq!(reserve_sat, 25_000);
}
other => panic!(
"expected ReserveOnly, got {:?}",
std::mem::discriminant(&other)
),
}
}
#[test]
fn classify_onchain_balance_real_wallet_onchain_just_above_reserve() {
match classify_onchain_balance(28_228, 25_000, 0, 0, 0) {
OnchainBalanceState::Available {
withdrawable_sat,
emergency_reserve_sat,
unconfirmed_sat,
} => {
assert_eq!(withdrawable_sat, 3_228);
assert_eq!(emergency_reserve_sat, 25_000);
assert_eq!(unconfirmed_sat, 0);
}
other => panic!(
"expected Available, got {:?}",
std::mem::discriminant(&other)
),
}
}
#[test]
fn classify_onchain_balance_dust_only_above_reserve_is_not_available() {
match classify_onchain_balance(25_100, 25_000, 0, 0, 0) {
OnchainBalanceState::ReserveOnly { reserve_sat } => {
assert_eq!(reserve_sat, 25_000);
}
other => panic!(
"expected ReserveOnly, got {:?}",
std::mem::discriminant(&other)
),
}
}
#[test]
fn classify_onchain_balance_real_user_no_anchor_no_reserve() {
match classify_onchain_balance(28_228, 0, 0, 0, 0) {
OnchainBalanceState::Available {
withdrawable_sat,
emergency_reserve_sat,
unconfirmed_sat,
} => {
assert_eq!(withdrawable_sat, 28_228);
assert_eq!(emergency_reserve_sat, 0);
assert_eq!(unconfirmed_sat, 0);
}
other => panic!(
"expected Available, got {:?}",
std::mem::discriminant(&other)
),
}
}
fn perkw_with(estimates: Vec<(u32, u32)>, min_acceptable: u32) -> clnpb::FeeratesPerkw {
clnpb::FeeratesPerkw {
min_acceptable,
max_acceptable: 0,
opening: None,
mutual_close: None,
unilateral_close: None,
unilateral_anchor_close: None,
delayed_to_us: None,
htlc_resolution: None,
penalty: None,
estimates: estimates
.into_iter()
.map(|(blockcount, feerate)| clnpb::FeeratesPerkwEstimates {
blockcount,
feerate,
smoothed_feerate: feerate,
})
.collect(),
floor: None,
}
}
#[test]
fn fee_rates_maps_perkw_to_buckets() {
let perkw = perkw_with(
vec![(2, 5000), (6, 2000), (12, 1500), (144, 500)],
253, );
let r = compute_fee_rates(Some(&perkw));
assert_eq!(r.next_block_sat_per_vbyte, 20);
assert_eq!(r.half_hour_sat_per_vbyte, 8);
assert_eq!(r.hour_sat_per_vbyte, 8);
assert_eq!(r.day_sat_per_vbyte, 2);
assert_eq!(r.minimum_relay_sat_per_vbyte, 2);
}
#[test]
fn fee_rates_fall_back_to_minimum_when_no_estimates() {
let perkw = perkw_with(vec![], 750); let r = compute_fee_rates(Some(&perkw));
assert_eq!(r.minimum_relay_sat_per_vbyte, 3);
assert_eq!(r.next_block_sat_per_vbyte, 3);
assert_eq!(r.half_hour_sat_per_vbyte, 3);
assert_eq!(r.hour_sat_per_vbyte, 3);
assert_eq!(r.day_sat_per_vbyte, 3);
}
#[test]
fn fee_rates_no_perkw_at_all_returns_safe_floor() {
let r = compute_fee_rates(None);
assert_eq!(r.minimum_relay_sat_per_vbyte, 1);
assert_eq!(r.next_block_sat_per_vbyte, 1);
assert_eq!(r.day_sat_per_vbyte, 1);
}
#[test]
fn fee_rates_buckets_never_below_minimum() {
let perkw = perkw_with(
vec![(2, 1500), (144, 250)], 1000,
);
let r = compute_fee_rates(Some(&perkw));
assert_eq!(r.minimum_relay_sat_per_vbyte, 4);
assert_eq!(r.day_sat_per_vbyte, 4);
}
#[test]
fn fee_rates_target_above_all_estimates_uses_largest() {
let perkw = perkw_with(vec![(2, 5000), (6, 2500)], 250);
let r = compute_fee_rates(Some(&perkw));
assert_eq!(r.day_sat_per_vbyte, 10);
}
#[test]
fn output_weight_for_address_per_script_type() {
assert_eq!(
output_weight_for_address("bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4"),
124
);
assert_eq!(
output_weight_for_address(
"bc1p0xlxvlhemja6c4dqv22uapctqupfhlxm9h8z3k2e72q4k9hcz7vqzk5jj0"
),
172
);
assert_eq!(output_weight_for_address("3J98t1WpEZ73CNmQviecrnyiWrnqRhWNLy"), 128);
assert_eq!(output_weight_for_address("1BvBMSEYstWetqTFn5Au4m4GFg7xJaNVN2"), 136);
assert_eq!(output_weight_for_address("not-an-address"), 172);
}
}