From c1bb7606cfa5662de65d56bd13517c6d0a5fc0aa Mon Sep 17 00:00:00 2001 From: daywalker90 Date: Sun, 9 Aug 2026 12:12:32 +0200 Subject: [PATCH] reserve budget during in-flight payments, drop lock while paying --- src/main.rs | 11 ++ src/nwc_keysend.rs | 258 ++++++++++++++++++++++------------------ src/nwc_pay.rs | 205 ++++++++++++++++--------------- src/rpc.rs | 1 + src/structs.rs | 10 +- src/util.rs | 134 ++++++++++++++++++++- tests/test_cln-nip47.py | 47 +++++--- 7 files changed, 425 insertions(+), 241 deletions(-) diff --git a/src/main.rs b/src/main.rs index b88c6e3..10c925c 100644 --- a/src/main.rs +++ b/src/main.rs @@ -200,6 +200,17 @@ async fn load_nwcs(plugin: Plugin, rpc: &mut ClnRpc) -> Result<(), } } + // We don't keep track of inflight payments succeeding + // while the plugin is dynamically restarted + if nwc_store.reserved_msat != 0 { + log::warn!( + "Releasing {} msat leftover budget reservation for {label}", + nwc_store.reserved_msat + ); + nwc_store.reserved_msat = 0; + update_nwc_store(rpc, label, nwc_store.clone()).await?; + } + run_nwc(plugin.clone(), label.clone(), nwc_store.clone()).await?; } Ok(()) diff --git a/src/nwc_keysend.rs b/src/nwc_keysend.rs index 1ef56f3..8cc8f39 100644 --- a/src/nwc_keysend.rs +++ b/src/nwc_keysend.rs @@ -3,19 +3,23 @@ use std::{collections::HashMap, str::FromStr}; use cln_plugin::Plugin; use cln_rpc::{ ClnRpc, + RpcError, model::requests::{KeysendRequest, XkeysendRequest}, - primitives::{Amount, PublicKey, TlvEntry, TlvStream}, + primitives::{Amount, PublicKey, Secret, TlvEntry, TlvStream}, }; use nostr::nips::nip47::{self}; use crate::{ - structs::{NwcStore, PluginState}, + structs::PluginState, util::{ budget_amount_check, get_budget_msat, load_nwc_store, - update_budget_msat, - update_nwc_store, + payment_fee_reserve_msat, + refund_budget, + reserve_budget, + rpc_socket_path, + settle_budget, }, }; @@ -57,8 +61,6 @@ async fn pay_keysend( params: nip47::PayKeysendRequest, label: &str, ) -> Result { - let mut rpc = plugin.state().rpc_lock.lock().await; - if params.preimage.is_some() { return Err(nip47::NIP47Error { code: nip47::ErrorCode::Other, @@ -66,42 +68,99 @@ async fn pay_keysend( }); } - let nwc_store = load_nwc_store(&mut rpc, label) - .await - .map_err(|e| nip47::NIP47Error { - code: nip47::ErrorCode::Internal, - message: e.to_string(), - })?; - - budget_amount_check(Some(params.amount), None, get_budget_msat(&nwc_store)).map_err(|e| { - nip47::NIP47Error { - code: nip47::ErrorCode::QuotaExceeded, - message: e.to_string(), - } - })?; - let pubkey = PublicKey::from_str(¶ms.pubkey).map_err(|e| nip47::NIP47Error { code: nip47::ErrorCode::Other, message: e.to_string(), })?; - if plugin.state().config.lock().has_xkeysend { - xkeysend(&mut rpc, params, pubkey, nwc_store, label).await + let reservation = { + let mut rpc = plugin.state().rpc_lock.lock().await; + + let nwc_store = load_nwc_store(&mut rpc, label) + .await + .map_err(|e| nip47::NIP47Error { + code: nip47::ErrorCode::Internal, + message: e.to_string(), + })?; + + budget_amount_check(Some(params.amount), None, get_budget_msat(&nwc_store)).map_err( + |e| nip47::NIP47Error { + code: nip47::ErrorCode::QuotaExceeded, + message: e.to_string(), + }, + )?; + + // Reserve amount plus worst case fee so concurrent payments can never + // exceed the budget. + if get_budget_msat(&nwc_store).unwrap_or(u64::MAX) + < params + .amount + .saturating_add(payment_fee_reserve_msat(params.amount)) + { + return Err(nip47::NIP47Error { + code: nip47::ErrorCode::QuotaExceeded, + message: "Payment and estimated fees exceed the available budget".to_owned(), + }); + } + + reserve_budget(&mut rpc, label, &nwc_store, params.amount) + .await + .map_err(|e| nip47::NIP47Error { + code: nip47::ErrorCode::Internal, + message: e.to_string(), + })? + }; + + let has_xkeysend = plugin.state().config.lock().has_xkeysend; + let mut pay_rpc = + ClnRpc::new(rpc_socket_path(&plugin)) + .await + .map_err(|e| nip47::NIP47Error { + code: nip47::ErrorCode::Internal, + message: format!("Could not connect to lightningd: {e}"), + })?; + + let pay_result = if has_xkeysend { + xkeysend(&mut pay_rpc, ¶ms, pubkey).await } else { - keysend(&mut rpc, params, pubkey, nwc_store, label).await + keysend(&mut pay_rpc, ¶ms, pubkey).await + }; + + match pay_result { + Ok((amount_sent_msat, amount_msat, preimage)) => { + let mut rpc = plugin.state().rpc_lock.lock().await; + if let Err(e) = settle_budget(&mut rpc, label, reservation, amount_sent_msat).await { + log::error!("Error updating budget after successful keysend: {e}"); + } + + let preimage = hex::encode(preimage.to_vec()); + let fees_paid = amount_sent_msat.saturating_sub(amount_msat); + + Ok(nip47::PayKeysendResponse { + preimage, + fees_paid: Some(fees_paid), + }) + } + Err(e) => { + let mut rpc = plugin.state().rpc_lock.lock().await; + if let Err(refund_err) = refund_budget(&mut rpc, label, reservation).await { + log::error!( + "Error refunding budget reservation after failed keysend: {refund_err}" + ); + } + Err(map_keysend_error(&e, has_xkeysend)) + } } } async fn xkeysend( - rpc: &mut ClnRpc, - params: nip47::PayKeysendRequest, + pay_rpc: &mut ClnRpc, + params: &nip47::PayKeysendRequest, pubkey: PublicKey, - mut nwc_store: NwcStore, - label: &str, -) -> Result { +) -> Result<(u64, u64, Secret), RpcError> { let mut extratlvs = HashMap::with_capacity(params.tlv_records.len()); - for tlv in params.tlv_records { - extratlvs.insert(tlv.tlv_type.to_string(), tlv.value); + for tlv in ¶ms.tlv_records { + extratlvs.insert(tlv.tlv_type.to_string(), tlv.value.clone()); } let extratlvs = if extratlvs.is_empty() { None @@ -109,7 +168,7 @@ async fn xkeysend( Some(extratlvs) }; - match rpc + let o = pay_rpc .call_typed(&XkeysendRequest { extratlvs, label: None, @@ -120,62 +179,24 @@ async fn xkeysend( amount_msat: Amount::from_msat(params.amount), destination: pubkey, }) - .await - { - Ok(o) => { - if nwc_store.budget_msat.is_some() { - update_budget_msat(&mut nwc_store, o.amount_sent_msat.msat()); - update_nwc_store(rpc, label, nwc_store) - .await - .map_err(|e| nip47::NIP47Error { - code: nip47::ErrorCode::Internal, - message: e.to_string(), - })?; - } + .await?; - let preimage = hex::encode(o.payment_preimage.to_vec()); - - let fees_paid = o.amount_sent_msat.msat() - o.amount_msat.msat(); - - Ok(nip47::PayKeysendResponse { - preimage, - fees_paid: Some(fees_paid), - }) - } - Err(e) => match e.code { - Some(c) => match c { - 203 | 205 | 207 | 219 => Err(nip47::NIP47Error { - code: nip47::ErrorCode::PaymentFailed, - message: e.to_string(), - }), - 209 => Err(nip47::NIP47Error { - code: nip47::ErrorCode::Other, - message: e.to_string(), - }), - _ => Err(nip47::NIP47Error { - code: nip47::ErrorCode::Internal, - message: e.to_string(), - }), - }, - None => Err(nip47::NIP47Error { - code: nip47::ErrorCode::Internal, - message: e.to_string(), - }), - }, - } + Ok(( + o.amount_sent_msat.msat(), + o.amount_msat.msat(), + o.payment_preimage, + )) } async fn keysend( - rpc: &mut ClnRpc, - params: nip47::PayKeysendRequest, + pay_rpc: &mut ClnRpc, + params: &nip47::PayKeysendRequest, pubkey: PublicKey, - mut nwc_store: NwcStore, - label: &str, -) -> Result { +) -> Result<(u64, u64, Secret), RpcError> { let mut extratlvs = TlvStream { entries: Vec::new(), }; - for tlv in params.tlv_records { + for tlv in ¶ms.tlv_records { extratlvs.entries.push(TlvEntry { typ: tlv.tlv_type, value: tlv.value.as_bytes().to_owned(), @@ -187,7 +208,7 @@ async fn keysend( Some(extratlvs) }; - match rpc + let o = pay_rpc .call_typed(&KeysendRequest { exemptfee: None, extratlvs, @@ -200,47 +221,48 @@ async fn keysend( amount_msat: Amount::from_msat(params.amount), destination: pubkey, }) - .await - { - Ok(o) => { - if nwc_store.budget_msat.is_some() { - update_budget_msat(&mut nwc_store, o.amount_sent_msat.msat()); - update_nwc_store(rpc, label, nwc_store) - .await - .map_err(|e| nip47::NIP47Error { - code: nip47::ErrorCode::Internal, - message: e.to_string(), - })?; - } + .await?; - let preimage = hex::encode(o.payment_preimage.to_vec()); + Ok(( + o.amount_sent_msat.msat(), + o.amount_msat.msat(), + o.payment_preimage, + )) +} - let fees_paid = o.amount_sent_msat.msat() - o.amount_msat.msat(); +fn map_keysend_error(e: &RpcError, is_xkeysend: bool) -> nip47::NIP47Error { + let Some(c) = e.code else { + return nip47::NIP47Error { + code: nip47::ErrorCode::Internal, + message: e.to_string(), + }; + }; - Ok(nip47::PayKeysendResponse { - preimage, - fees_paid: Some(fees_paid), - }) + let failed_codes = if is_xkeysend { + vec![203, 205, 207, 219] + } else { + vec![203, 205, 210] + }; + + if failed_codes.contains(&c) { + nip47::NIP47Error { + code: nip47::ErrorCode::PaymentFailed, + message: e.to_string(), + } + } else if is_xkeysend && c == 209 { + nip47::NIP47Error { + code: nip47::ErrorCode::Other, + message: e.to_string(), + } + } else if !is_xkeysend && c == 206 { + nip47::NIP47Error { + code: nip47::ErrorCode::InsufficientBalance, + message: e.to_string(), + } + } else { + nip47::NIP47Error { + code: nip47::ErrorCode::Internal, + message: e.to_string(), } - Err(e) => match e.code { - Some(c) => match c { - 203 | 205 | 210 => Err(nip47::NIP47Error { - code: nip47::ErrorCode::PaymentFailed, - message: e.to_string(), - }), - 206 => Err(nip47::NIP47Error { - code: nip47::ErrorCode::InsufficientBalance, - message: e.to_string(), - }), - _ => Err(nip47::NIP47Error { - code: nip47::ErrorCode::Internal, - message: e.to_string(), - }), - }, - None => Err(nip47::NIP47Error { - code: nip47::ErrorCode::Internal, - message: e.to_string(), - }), - }, } } diff --git a/src/nwc_pay.rs b/src/nwc_pay.rs index ffc348a..9ff8635 100644 --- a/src/nwc_pay.rs +++ b/src/nwc_pay.rs @@ -16,8 +16,11 @@ use crate::{ budget_amount_check, get_budget_msat, load_nwc_store, - update_budget_msat, - update_nwc_store, + payment_fee_reserve_msat, + refund_budget, + reserve_budget, + rpc_socket_path, + settle_budget, }, }; @@ -53,21 +56,92 @@ async fn pay_invoice( params: nip47::PayInvoiceRequest, label: &str, ) -> Result<(nip47::PayInvoiceResponse, Option), (nip47::NIP47Error, Option)> { - let mut rpc = plugin.state().rpc_lock.lock().await; + let (id, reservation) = { + let mut rpc = plugin.state().rpc_lock.lock().await; - let decoded_invoice = decode_and_validate_invoice(&mut rpc, ¶ms).await?; + let decoded_invoice = decode_and_validate_invoice(&mut rpc, ¶ms).await?; - let id = get_payment_id(¶ms, &decoded_invoice)?; + let id = get_payment_id(¶ms, &decoded_invoice)?; - let invoice_amt_msat = get_invoice_amount_msat(&decoded_invoice, &id)?; + let invoice_amt_msat = get_invoice_amount_msat(&decoded_invoice, &id)?; - let nwc_store = - load_nwc_and_check_budget(&mut rpc, label, ¶ms, invoice_amt_msat, &id).await?; + let nwc_store = + load_nwc_and_check_budget(&mut rpc, label, ¶ms, invoice_amt_msat, &id).await?; - if plugin.state().config.lock().has_xpay { - pay_with_xpay_full(&mut rpc, params, label, nwc_store, &id).await + // Reserve the invoice amount plus the worst case fee so that no + // combination of concurrent payments can exceed the budget and so that + // balance queries during the payment reflect the reserved amount. + if get_budget_msat(&nwc_store).unwrap_or(u64::MAX) + < invoice_amt_msat.saturating_add(payment_fee_reserve_msat(invoice_amt_msat)) + { + return Err(( + nip47::NIP47Error { + code: nip47::ErrorCode::QuotaExceeded, + message: "Payment and estimated fees exceed the available budget".to_owned(), + }, + Some(id), + )); + } + + let reservation = reserve_budget(&mut rpc, label, &nwc_store, invoice_amt_msat) + .await + .map_err(|e| { + ( + nip47::NIP47Error { + code: nip47::ErrorCode::Internal, + message: e.to_string(), + }, + Some(id.clone()), + ) + })?; + + (id, reservation) + }; + + let has_xpay = plugin.state().config.lock().has_xpay; + + let mut pay_rpc = ClnRpc::new(rpc_socket_path(&plugin)).await.map_err(|e| { + ( + nip47::NIP47Error { + code: nip47::ErrorCode::Internal, + message: format!("Could not connect to lightningd: {e}"), + }, + Some(id.clone()), + ) + })?; + + let pay_result = if has_xpay { + pay_with_xpay(&mut pay_rpc, ¶ms).await } else { - pay_with_legacy_full(&mut rpc, params, label, nwc_store, &id).await + pay_with_legacy(&mut pay_rpc, ¶ms).await + }; + + match pay_result { + Ok((amount_sent_msat, amount_msat, preimage)) => { + let mut rpc = plugin.state().rpc_lock.lock().await; + if let Err(e) = settle_budget(&mut rpc, label, reservation, amount_sent_msat).await { + log::error!("Error updating budget after successful payment: {e}"); + } + + let preimage_str = hex::encode(preimage.to_vec()); + let fees_paid = amount_sent_msat.saturating_sub(amount_msat); + Ok(( + nip47::PayInvoiceResponse { + preimage: preimage_str, + fees_paid: Some(fees_paid), + }, + Some(id), + )) + } + Err(e) => { + let mut rpc = plugin.state().rpc_lock.lock().await; + if let Err(refund_err) = refund_budget(&mut rpc, label, reservation).await { + log::error!( + "Error refunding budget reservation after failed payment: {refund_err}" + ); + } + Err(map_cln_error_to_nip47(&e, &id, has_xpay)) + } } } @@ -195,41 +269,6 @@ async fn load_nwc_and_check_budget( Ok(nwc_store) } -async fn update_budget_and_create_response( - rpc: &mut ClnRpc, - label: &str, - nwc_store: &mut NwcStore, - amount_sent_msat: u64, - amount_msat: u64, - preimage: Secret, - id: &str, -) -> Result<(nip47::PayInvoiceResponse, Option), (nip47::NIP47Error, Option)> { - if nwc_store.budget_msat.is_some() { - update_budget_msat(nwc_store, amount_sent_msat); - update_nwc_store(rpc, label, nwc_store.clone()) - .await - .map_err(|e| { - ( - nip47::NIP47Error { - code: nip47::ErrorCode::Internal, - message: e.to_string(), - }, - Some(id.to_owned()), - ) - })?; - } - - let preimage_str = hex::encode(preimage.to_vec()); - let fees_paid = amount_sent_msat - amount_msat; - Ok(( - nip47::PayInvoiceResponse { - preimage: preimage_str, - fees_paid: Some(fees_paid), - }, - Some(id.to_owned()), - )) -} - fn map_cln_error_to_nip47( e: &RpcError, id: &str, @@ -292,14 +331,11 @@ fn map_cln_error_to_nip47( } } -async fn pay_with_xpay_full( - rpc: &mut ClnRpc, - params: nip47::PayInvoiceRequest, - label: &str, - mut nwc_store: NwcStore, - id: &str, -) -> Result<(nip47::PayInvoiceResponse, Option), (nip47::NIP47Error, Option)> { - let payment_result = rpc +async fn pay_with_xpay( + pay_rpc: &mut ClnRpc, + params: &nip47::PayInvoiceRequest, +) -> Result<(u64, u64, Secret), RpcError> { + let payment_result = pay_rpc .call_typed(&XpayRequest { amount_msat: params.amount.map(Amount::from_msat), maxdelay: None, @@ -307,39 +343,26 @@ async fn pay_with_xpay_full( partial_msat: None, retry_for: None, layers: None, - invstring: params.invoice, + invstring: params.invoice.clone(), payer_note: None, dev_use_shadow: None, label: None, localinvreqid: None, }) - .await - .map_err(|e| map_cln_error_to_nip47(&e, id, true))?; + .await?; - let amount_sent_msat = payment_result.amount_sent_msat.msat(); - let amount_msat = payment_result.amount_msat.msat(); - let preimage = payment_result.payment_preimage; - - update_budget_and_create_response( - rpc, - label, - &mut nwc_store, - amount_sent_msat, - amount_msat, - preimage, - id, - ) - .await + Ok(( + payment_result.amount_sent_msat.msat(), + payment_result.amount_msat.msat(), + payment_result.payment_preimage, + )) } -async fn pay_with_legacy_full( - rpc: &mut ClnRpc, - params: nip47::PayInvoiceRequest, - label: &str, - mut nwc_store: NwcStore, - id: &str, -) -> Result<(nip47::PayInvoiceResponse, Option), (nip47::NIP47Error, Option)> { - let payment_result = rpc +async fn pay_with_legacy( + pay_rpc: &mut ClnRpc, + params: &nip47::PayInvoiceRequest, +) -> Result<(u64, u64, Secret), RpcError> { + let payment_result = pay_rpc .call_typed(&PayRequest { amount_msat: params.amount.map(Amount::from_msat), description: None, @@ -353,23 +376,13 @@ async fn pay_with_legacy_full( retry_for: None, riskfactor: None, exclude: None, - bolt11: params.invoice, + bolt11: params.invoice.clone(), }) - .await - .map_err(|e| map_cln_error_to_nip47(&e, id, false))?; + .await?; - let amount_sent_msat = payment_result.amount_sent_msat.msat(); - let amount_msat = payment_result.amount_msat.msat(); - let preimage = payment_result.payment_preimage; - - update_budget_and_create_response( - rpc, - label, - &mut nwc_store, - amount_sent_msat, - amount_msat, - preimage, - id, - ) - .await + Ok(( + payment_result.amount_sent_msat.msat(), + payment_result.amount_msat.msat(), + payment_result.payment_preimage, + )) } diff --git a/src/rpc.rs b/src/rpc.rs index 1aba835..f00cc04 100644 --- a/src/rpc.rs +++ b/src/rpc.rs @@ -83,6 +83,7 @@ pub async fn nwc_create( walletkey: wallet_keys.secret_key().to_secret_hex(), budget_msat, interval_config, + reserved_msat: 0, }; rpc.call_typed(&DatastoreRequest { diff --git a/src/structs.rs b/src/structs.rs index 1ebfb85..c66de4e 100644 --- a/src/structs.rs +++ b/src/structs.rs @@ -1,9 +1,4 @@ -use std::{ - collections::{HashMap, HashSet}, - path::PathBuf, - str::FromStr, - sync::Arc, -}; +use std::{collections::HashMap, path::PathBuf, str::FromStr, sync::Arc}; use cln_rpc::ClnRpc; use nostr::{ @@ -90,6 +85,7 @@ pub struct BudgetIntervalConfig { pub interval_secs: u64, pub reset_budget_msat: u64, pub last_reset: u64, + #[serde(default)] pub spend_since_last_reset: u64, } @@ -101,4 +97,6 @@ pub struct NwcStore { pub budget_msat: Option, #[serde(skip_serializing_if = "Option::is_none")] pub interval_config: Option, + #[serde(default)] + pub reserved_msat: u64, } diff --git a/src/util.rs b/src/util.rs index 20fa53f..2467ab1 100644 --- a/src/util.rs +++ b/src/util.rs @@ -1,3 +1,8 @@ +use std::{ + cmp::max, + path::{Path, PathBuf}, +}; + use anyhow::anyhow; use cln_plugin::Plugin; use cln_rpc::{ @@ -17,20 +22,29 @@ use crate::{ structs::{ID_STORE, NwcStore, PluginState}, }; +/// CLN's default maximum fee for a payment is `max(5000msat, 1% of amount)`, +/// so reserving this much guarantees a settled payment never exceeds the budget. +pub const MIN_FEE_RESERVE_MSAT: u64 = 5_000; + +pub fn payment_fee_reserve_msat(amount_msat: u64) -> u64 { + max(MIN_FEE_RESERVE_MSAT, amount_msat.saturating_div(100)) +} + pub fn get_budget_msat(nwc_store: &NwcStore) -> Option { match nwc_store.budget_msat { Some(b) => { - if let Some(conf) = &nwc_store.interval_config { + let available = if let Some(conf) = &nwc_store.interval_config { let now = Timestamp::now().as_secs(); let spend = if now.saturating_sub(conf.last_reset) >= conf.interval_secs { 0 } else { conf.spend_since_last_reset }; - Some(conf.reset_budget_msat.saturating_sub(spend)) + conf.reset_budget_msat.saturating_sub(spend) } else { - Some(b) - } + b + }; + Some(available.saturating_sub(nwc_store.reserved_msat)) } None => None, } @@ -128,6 +142,72 @@ pub async fn update_nwc_store( Ok(()) } +pub fn rpc_socket_path(plugin: &Plugin) -> PathBuf { + Path::new(&plugin.configuration().lightning_dir).join(&plugin.configuration().rpc_file) +} + +/// Reserve the payment amount plus the worst case fee so that no combination +/// of concurrent payments can exceed the budget. Must be called while holding +/// the global rpc lock. Returns the reserved amount (0 if there is no budget). +pub async fn reserve_budget( + rpc: &mut ClnRpc, + label: &str, + nwc_store: &NwcStore, + amount_msat: u64, +) -> Result { + if nwc_store.budget_msat.is_none() { + return Ok(0); + } + + let reservation = amount_msat.saturating_add(payment_fee_reserve_msat(amount_msat)); + let mut new_store = nwc_store.clone(); + new_store.reserved_msat = new_store.reserved_msat.saturating_add(reservation); + update_nwc_store(rpc, label, new_store).await?; + + Ok(reservation) +} + +/// Release the reservation of a payment that did not succeed. Must be called +/// while holding the global rpc lock. +pub async fn refund_budget( + rpc: &mut ClnRpc, + label: &str, + reservation: u64, +) -> Result<(), anyhow::Error> { + if reservation == 0 { + return Ok(()); + } + + let mut nwc_store = load_nwc_store(rpc, label).await?; + nwc_store.reserved_msat = nwc_store.reserved_msat.saturating_sub(reservation); + update_nwc_store(rpc, label, nwc_store).await?; + + Ok(()) +} + +/// Record the actual amount spent by a successful payment and release the +/// remainder of the reservation. Must be called while holding the global rpc +/// lock. Charges the real spend first so that an error leaves the budget +/// conservatively low rather than too high. +pub async fn settle_budget( + rpc: &mut ClnRpc, + label: &str, + reservation: u64, + amount_spent_msat: u64, +) -> Result<(), anyhow::Error> { + // A zero reservation only happens when the nwc has no budget at all. + if reservation == 0 { + return Ok(()); + } + + let mut nwc_store = load_nwc_store(rpc, label).await?; + update_budget_msat(&mut nwc_store, amount_spent_msat); + nwc_store.reserved_msat = nwc_store.reserved_msat.saturating_sub(reservation); + update_nwc_store(rpc, label, nwc_store).await?; + + Ok(()) +} + pub fn is_read_only_nwc(nwc_store: &NwcStore) -> bool { if let Some(budget_msat) = nwc_store.budget_msat { if budget_msat == 0 && nwc_store.interval_config.is_none() { @@ -258,6 +338,7 @@ fn test_budget_interval_helpers() { walletkey: "test".to_owned(), budget_msat: Some(1000), interval_config: Some(conf), + reserved_msat: 0, }; assert_eq!(get_budget_msat(&store), Some(1000)); @@ -292,3 +373,48 @@ fn test_budget_interval_helpers() { ); assert_eq!(get_budget_msat(&store), Some(600)); } + +#[test] +fn test_payment_fee_reserve() { + // fee reserve is at least 5000msat + assert_eq!(payment_fee_reserve_msat(0), 5000); + assert_eq!(payment_fee_reserve_msat(1000), 5000); + assert_eq!(payment_fee_reserve_msat(499_999), 5000); + // and 1% of the amount above that + assert_eq!(payment_fee_reserve_msat(500_000), 5000); + assert_eq!(payment_fee_reserve_msat(1_000_000), 10_000); + assert_eq!(payment_fee_reserve_msat(123_456_789), 1_234_567); +} + +#[test] +fn test_budget_reservations() { + let store = NwcStore { + uri: nostr::nips::nip47::NostrWalletConnectUri::new( + nostr::key::Keys::generate().public_key(), + vec![], + nostr::key::Keys::generate().secret_key().clone(), + None, + ), + walletkey: "test".to_owned(), + budget_msat: Some(10_000), + interval_config: None, + reserved_msat: 0, + }; + + // reserving reduces the available budget + let mut stored = store.clone(); + stored.reserved_msat = 1_500; + assert_eq!(get_budget_msat(&stored), Some(8_500)); + + // a full spend plus reservation can never be under budget + let full_reservation = + stored.clone().budget_msat.unwrap() + payment_fee_reserve_msat(store.budget_msat.unwrap()); + stored.reserved_msat = full_reservation; + assert_eq!(get_budget_msat(&stored), Some(0)); + + // reservations do not affect a store without a budget + let mut no_budget = store; + no_budget.budget_msat = None; + no_budget.reserved_msat = 1_000; + assert_eq!(get_budget_msat(&no_budget), None); +} diff --git a/tests/test_cln-nip47.py b/tests/test_cln-nip47.py index 7e055ec..cb2b6dd 100644 --- a/tests/test_cln-nip47.py +++ b/tests/test_cln-nip47.py @@ -394,7 +394,7 @@ async def test_pay_keysend(nostr_relay, node_factory, get_plugin): # noqa: F811 {"log-level": "debug"}, ], ) - uri_str = l1.rpc.call("nip47-create", ["test1", 3000])["uri"] + uri_str = l1.rpc.call("nip47-create", ["test1", 8000])["uri"] LOGGER.info(uri_str) uri = NostrWalletConnectUri.parse(uri_str) client = Client() @@ -416,7 +416,7 @@ async def test_pay_keysend(nostr_relay, node_factory, get_plugin): # noqa: F811 await nwc.pay_keysend( PayKeysendRequest( id="id123", - amount=2001, + amount=7500, pubkey=l2.info["id"], preimage=None, tlv_records=[KeysendTlvRecord(tlv_type=1234, value="a5c7e3d9b")], @@ -428,7 +428,7 @@ async def test_pay_keysend(nostr_relay, node_factory, get_plugin): # noqa: F811 await nwc.pay_keysend( PayKeysendRequest( id="id123", - amount=2001, + amount=7500, pubkey=l2.info["id"], preimage="or3ijro3ijroi", tlv_records=[KeysendTlvRecord(tlv_type=1234, value="a5c7e3d9b")], @@ -957,7 +957,7 @@ async def test_pay_invoice(nostr_relay, node_factory, get_plugin): # noqa: F811 {"log-level": "debug"}, ], ) - uri_str = l1.rpc.call("nip47-create", ["test1", 3001])["uri"] + uri_str = l1.rpc.call("nip47-create", ["test1", 9000])["uri"] uri = NostrWalletConnectUri.parse(uri_str) client = Client() await client.add_relay(RelayUrl.parse(url)) @@ -985,7 +985,7 @@ async def test_pay_invoice(nostr_relay, node_factory, get_plugin): # noqa: F811 ) invoice = l2.rpc.call( "invoice", - {"label": generate_random_label(), "description": "test3", "amount_msat": 2}, + {"label": generate_random_label(), "description": "test3", "amount_msat": 6500}, ) with pytest.raises(NostrSdkError.Generic, match="Payment exceeds budget"): await nwc.pay_invoice( @@ -1009,7 +1009,7 @@ async def test_persistency(nostr_relay, node_factory, get_plugin): # noqa: F811 {"log-level": "debug"}, ], ) - uri_str = l1.rpc.call("nip47-create", ["test1", 3000])["uri"] + uri_str = l1.rpc.call("nip47-create", ["test1", 8000])["uri"] LOGGER.info(uri_str) invoice = l2.rpc.call( "invoice", @@ -1038,7 +1038,7 @@ async def test_persistency(nostr_relay, node_factory, get_plugin): # noqa: F811 invoice = l2.rpc.call( "invoice", - {"label": generate_random_label(), "description": "test1", "amount_msat": 1}, + {"label": generate_random_label(), "description": "test1", "amount_msat": 5500}, ) with pytest.raises(NostrSdkError.Generic, match="Payment exceeds budget"): await nwc.pay_invoice( @@ -1064,7 +1064,7 @@ async def test_persistency(nostr_relay, node_factory, get_plugin): # noqa: F811 revoke = l1.rpc.call("nip47-revoke", ["test1"]) assert revoke["revoked"] == "test1" - uri_str = l1.rpc.call("nip47-create", ["test1", 3000, "10sec"])["uri"] + uri_str = l1.rpc.call("nip47-create", ["test1", 8000, "10sec"])["uri"] uri = NostrWalletConnectUri.parse(uri_str) client = Client() await client.add_relay(RelayUrl.parse(url)) @@ -1078,7 +1078,11 @@ async def test_persistency(nostr_relay, node_factory, get_plugin): # noqa: F811 ) invoice_exceeded = l2.rpc.call( "invoice", - {"label": generate_random_label(), "description": "test1", "amount_msat": 3000}, + {"label": generate_random_label(), "description": "test1", "amount_msat": 5500}, + ) + invoice_fee_exceeded = l2.rpc.call( + "invoice", + {"label": generate_random_label(), "description": "test1", "amount_msat": 1}, ) result = await nwc.pay_invoice( PayInvoiceRequest(id=None, amount=None, invoice=invoice["bolt11"]) @@ -1086,17 +1090,26 @@ async def test_persistency(nostr_relay, node_factory, get_plugin): # noqa: F811 assert result.preimage is not None list = l1.rpc.call("nip47-list", ["test1"])[0] - assert list["test1"]["budget_msat"] == 0 + assert list["test1"]["budget_msat"] == 5000 with pytest.raises(NostrSdkError.Generic, match="Payment exceeds budget"): await nwc.pay_invoice( PayInvoiceRequest(id=None, amount=None, invoice=invoice_exceeded["bolt11"]) ) + with pytest.raises( + NostrSdkError.Generic, + match="Payment and estimated fees exceed the available budget", + ): + await nwc.pay_invoice( + PayInvoiceRequest( + id=None, amount=None, invoice=invoice_fee_exceeded["bolt11"] + ) + ) await asyncio.sleep(11) list = l1.rpc.call("nip47-list", ["test1"])[0] - assert list["test1"]["budget_msat"] == 3000 + assert list["test1"]["budget_msat"] == 8000 invoice = l2.rpc.call( "invoice", @@ -1108,7 +1121,7 @@ async def test_persistency(nostr_relay, node_factory, get_plugin): # noqa: F811 assert result.preimage is not None list = l1.rpc.call("nip47-list", ["test1"])[0] - assert list["test1"]["budget_msat"] == 0 + assert list["test1"]["budget_msat"] == 5000 with pytest.raises(NostrSdkError.Generic, match="Payment exceeds budget"): await nwc.pay_invoice( @@ -1136,7 +1149,7 @@ async def test_persistency(nostr_relay, node_factory, get_plugin): # noqa: F811 await asyncio.sleep(11) list = l1.rpc.call("nip47-list", ["test1"])[0] - assert list["test1"]["budget_msat"] == 3000 + assert list["test1"]["budget_msat"] == 8000 @pytest.mark.asyncio @@ -1184,9 +1197,9 @@ async def test_budget_command(nostr_relay, node_factory, get_plugin): # noqa: F PayInvoiceRequest(id=None, amount=None, invoice=invoice["bolt11"]) ) - l1.rpc.call("nip47-budget", ["test1", 5000, "15s"]) + l1.rpc.call("nip47-budget", ["test1", 10000, "15s"]) balance = await nwc.get_balance() - assert balance.balance == 5000 + assert balance.balance == 10000 with pytest.raises( RpcError, match="`budget_msat` must be greater than 0 if you use `interval`" @@ -1199,7 +1212,7 @@ async def test_budget_command(nostr_relay, node_factory, get_plugin): # noqa: F assert pay.preimage is not None balance = await nwc.get_balance() - assert balance.balance == 0 + assert balance.balance == 5000 get_info = await nwc.get_info() assert get_info.methods == [ @@ -1230,7 +1243,7 @@ async def test_budget_command(nostr_relay, node_factory, get_plugin): # noqa: F await asyncio.sleep(18) balance = await nwc.get_balance() - assert balance.balance == 5000 + assert balance.balance == 10000 l1.rpc.call("nip47-budget", ["test1", 0]) balance = await nwc.get_balance()