reserve budget during in-flight payments, drop lock while paying

This commit is contained in:
daywalker90 2026-08-09 12:12:32 +02:00
parent f79db424cd
commit c1bb7606cf
7 changed files with 425 additions and 241 deletions

View file

@ -200,6 +200,17 @@ async fn load_nwcs(plugin: Plugin<PluginState>, 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(())

View file

@ -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<nip47::PayKeysendResponse, nip47::NIP47Error> {
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(&params.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, &params, pubkey).await
} else {
keysend(&mut rpc, params, pubkey, nwc_store, label).await
keysend(&mut pay_rpc, &params, 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<nip47::PayKeysendResponse, nip47::NIP47Error> {
) -> 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 &params.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<nip47::PayKeysendResponse, nip47::NIP47Error> {
) -> Result<(u64, u64, Secret), RpcError> {
let mut extratlvs = TlvStream {
entries: Vec::new(),
};
for tlv in params.tlv_records {
for tlv in &params.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(),
}),
},
}
}

View file

@ -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<String>), (nip47::NIP47Error, Option<String>)> {
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, &params).await?;
let decoded_invoice = decode_and_validate_invoice(&mut rpc, &params).await?;
let id = get_payment_id(&params, &decoded_invoice)?;
let id = get_payment_id(&params, &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, &params, invoice_amt_msat, &id).await?;
let nwc_store =
load_nwc_and_check_budget(&mut rpc, label, &params, 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, &params).await
} else {
pay_with_legacy_full(&mut rpc, params, label, nwc_store, &id).await
pay_with_legacy(&mut pay_rpc, &params).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<String>), (nip47::NIP47Error, Option<String>)> {
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<String>), (nip47::NIP47Error, Option<String>)> {
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<String>), (nip47::NIP47Error, Option<String>)> {
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,
))
}

View file

@ -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 {

View file

@ -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<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub interval_config: Option<BudgetIntervalConfig>,
#[serde(default)]
pub reserved_msat: u64,
}

View file

@ -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<u64> {
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<PluginState>) -> 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<u64, anyhow::Error> {
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);
}

View file

@ -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()