mirror of
https://github.com/daywalker90/cln-nip47.git
synced 2026-08-13 12:33:43 +02:00
reserve budget during in-flight payments, drop lock while paying
This commit is contained in:
parent
f79db424cd
commit
c1bb7606cf
7 changed files with 425 additions and 241 deletions
11
src/main.rs
11
src/main.rs
|
|
@ -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(())
|
||||
|
|
|
|||
|
|
@ -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(¶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<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 ¶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<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 ¶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(),
|
||||
}),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
|
|||
205
src/nwc_pay.rs
205
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<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, ¶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<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,
|
||||
))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
|
|
|
|||
134
src/util.rs
134
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<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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue