support receive-only NWC announcements

This commit is contained in:
daywalker90 2025-05-04 11:42:29 +02:00
parent 84c8ffd584
commit 97c89858eb
No known key found for this signature in database
14 changed files with 444 additions and 258 deletions

View file

@ -1,5 +1,10 @@
# Changelog
## [0.1.3] Unreleased
### Changed
- if your NWC does not have a renewing budget set with interval and the budget is 0 (aka "receive-only" NWC) it will now also not set any of the pay methods when announcing itself to relays, this is for services which demand a receive-only NWC (e.g. stacker.news) and determine it by the announced methods
## [0.1.2] 2025-04-18
### Added

View file

@ -14,7 +14,7 @@ use parse::read_startup_options;
use rpc::{nwc_budget, nwc_create, nwc_list, nwc_revoke};
use structs::PluginState;
use tokio::time;
use util::load_nwc_store;
use util::{load_nwc_store, update_nwc_store};
mod nwc;
mod nwc_balance;
@ -40,6 +40,24 @@ const OPT_NOTIFICATIONS: DefaultBooleanConfigOption = ConfigOption::new_bool_wit
"Enable/disable nip47-notifications. Default is `true`",
);
pub const PLUGIN_NAME: &str = "cln-nip47";
pub const WALLET_READ_METHODS: [&str; 5] = [
"make_invoice",
"lookup_invoice",
"list_transactions",
"get_balance",
"get_info",
];
pub const WALLET_ALL_METHODS: [&str; 9] = [
"pay_invoice",
"multi_pay_invoice",
"pay_keysend",
"multi_pay_keysend",
WALLET_READ_METHODS[0],
WALLET_READ_METHODS[1],
WALLET_READ_METHODS[2],
WALLET_READ_METHODS[3],
WALLET_READ_METHODS[4],
];
#[tokio::main]
async fn main() -> Result<(), anyhow::Error> {
@ -49,7 +67,7 @@ async fn main() -> Result<(), anyhow::Error> {
);
log_panics::init();
let state = PluginState::default();
let state;
let confplugin = match Builder::new(tokio::io::stdin(), tokio::io::stdout())
.option(OPT_RELAYS)
@ -66,6 +84,16 @@ async fn main() -> Result<(), anyhow::Error> {
.await?
{
Some(plugin) => {
let rpc_file = Path::new(&plugin.configuration().lightning_dir)
.join(plugin.configuration().rpc_file);
state = match PluginState::new(rpc_file).await {
Ok(state) => state,
Err(e) => {
return plugin
.disable(format!("Error connecting to cln rpc: {}", e).as_str())
.await;
}
};
match read_startup_options(&plugin, &state).await {
Ok(()) => &(),
Err(e) => return plugin.disable(format!("{}", e).as_str()).await,
@ -78,13 +106,13 @@ async fn main() -> Result<(), anyhow::Error> {
let plugin = confplugin.start(state).await?;
{
let _guard = plugin.state().rpc_lock.lock().await;
let mut rpc = plugin.state().rpc_lock.lock().await;
// Make sure incase of rapid nip47-create and plugin restarts info_events
// have a different timestamp and therefore ID so relays don't disconnect us
time::sleep(Duration::from_secs(1)).await;
match load_nwcs(plugin.clone()).await {
match load_nwcs(plugin.clone(), &mut rpc).await {
Ok(_) => log::info!("All NWC's loaded"),
Err(e) => {
println!(
@ -112,11 +140,7 @@ async fn shutdown_handler(
std::process::exit(0)
}
async fn load_nwcs(plugin: Plugin<PluginState>) -> Result<(), anyhow::Error> {
let mut rpc = ClnRpc::new(
Path::new(&plugin.configuration().lightning_dir).join(&plugin.configuration().rpc_file),
)
.await?;
async fn load_nwcs(plugin: Plugin<PluginState>, rpc: &mut ClnRpc) -> Result<(), anyhow::Error> {
let labels = rpc
.call_typed(&ListdatastoreRequest {
key: Some(vec![PLUGIN_NAME.to_owned()]),
@ -124,15 +148,18 @@ async fn load_nwcs(plugin: Plugin<PluginState>) -> Result<(), anyhow::Error> {
.await?;
for datastore in labels.datastore.into_iter() {
let label = datastore.key.last().unwrap();
let nwc_store = load_nwc_store(&mut rpc, label).await?;
let mut nwc_store = load_nwc_store(rpc, label).await?;
let client = run_nwc(plugin.clone(), label.clone(), nwc_store.clone()).await?;
// check NWC's created with cln-nip47 <= v0.1.3 for intervals with 0 reset budget
if let Some(interval_conf) = &nwc_store.interval_config {
if interval_conf.reset_budget_msat == 0 {
nwc_store.interval_config = None;
nwc_store.budget_msat = Some(0);
update_nwc_store(rpc, label, nwc_store.clone()).await?;
}
}
let mut client_handles = plugin.state().handles.lock().await;
client_handles.insert(
label.clone(),
(client, Keys::new(nwc_store.uri.secret).public_key()),
);
run_nwc(plugin.clone(), label.clone(), nwc_store.clone()).await?;
}
Ok(())
}

View file

@ -8,7 +8,9 @@ use crate::nwc_lookups::{list_transactions, lookup_invoice};
use crate::nwc_pay::{multi_pay_invoice, pay_invoice};
use crate::structs::{NwcStore, PluginState};
use crate::tasks::budget_task;
use crate::OPT_NOTIFICATIONS;
use crate::util::is_read_only_nwc;
use crate::{OPT_NOTIFICATIONS, WALLET_ALL_METHODS, WALLET_READ_METHODS};
use anyhow::anyhow;
use cln_plugin::Plugin;
use nostr_sdk::nips::*;
use nostr_sdk::Client;
@ -20,12 +22,18 @@ pub async fn run_nwc(
plugin: Plugin<PluginState>,
label: String,
nwc_store: NwcStore,
) -> Result<client::Client, client::Error> {
) -> Result<(), client::Error> {
let capabilities = if is_read_only_nwc(&nwc_store) {
WALLET_READ_METHODS.join(" ")
} else {
WALLET_ALL_METHODS.join(" ")
};
let wallet_keys = Keys::new(
SecretKey::from_hex(&nwc_store.walletkey)
.map_err(|e| client::Error::Signer(SignerError::backend(e)))?,
);
let client_pubkey = Keys::new(nwc_store.uri.secret).public_key();
let client_pubkey = Keys::new(nwc_store.uri.secret.clone()).public_key();
let client = Client::new(wallet_keys.clone());
@ -37,12 +45,12 @@ pub async fn run_nwc(
}
if nwc_store.interval_config.is_some() {
let (tx, rx) = oneshot::channel::<()>();
tokio::spawn(budget_task(rx, plugin.clone(), label.clone()));
plugin.state().budget_jobs.lock().insert(label.clone(), tx);
start_nwc_budget_job(plugin.clone(), label.clone());
}
let client_clone = client.clone();
let plugin_clone = plugin.clone();
let label_clone = label.clone();
tokio::spawn(async move {
loop {
client_clone.connect().await;
@ -68,46 +76,15 @@ pub async fn run_nwc(
continue;
}
let mut info_event_builder = EventBuilder::new(
Kind::WalletConnectInfo,
"pay_invoice multi_pay_invoice pay_keysend multi_pay_keysend make_invoice \
lookup_invoice list_transactions get_balance get_info",
if let Err(e) = send_nwc_info_event(
client_clone.clone(),
plugin_clone.option(&OPT_NOTIFICATIONS).unwrap(),
capabilities.clone(),
wallet_keys.clone(),
)
.tag(Tag::parse(vec!["encryption", "nip44_v2 nip04"]).unwrap());
if plugin.option(&OPT_NOTIFICATIONS).unwrap() {
info_event_builder = info_event_builder.tag(
Tag::parse(vec!["notifications", "payment_received payment_sent"]).unwrap(),
)
}
let info_event = match info_event_builder.sign_with_keys(&wallet_keys) {
Ok(o) => o,
Err(e) => {
log::warn!("Could not sign info_event! {}", e);
time::sleep(Duration::from_secs(5)).await;
continue;
}
};
log::debug!("info_event:{:?}", info_event);
let send_result = match client_clone.send_event(&info_event).await {
Ok(o) => o,
Err(e) => {
log::warn!("Could not send info_event! {}", e);
client_clone.disconnect().await;
time::sleep(Duration::from_secs(5)).await;
continue;
}
};
if send_result.success.is_empty() {
log::warn!(
"None of the relays received the info_event! {}",
send_result
.failed
.into_values()
.collect::<Vec<String>>()
.join(", ")
);
.await
{
log::warn!("{}", e);
client_clone.disconnect().await;
time::sleep(Duration::from_secs(5)).await;
continue;
@ -117,20 +94,19 @@ pub async fn run_nwc(
.kind(Kind::WalletConnectRequest)
.author(client_pubkey);
match client_clone.subscribe(filter, None).await {
Ok(_o) => (),
Err(e) => {
log::warn!("Could not subscribe to nwc events! {}", e);
time::sleep(Duration::from_secs(5)).await;
continue;
}
if let Err(e) = client_clone.subscribe(filter, None).await {
log::warn!("Could not subscribe to nwc events! {}", e);
client_clone.disconnect().await;
time::sleep(Duration::from_secs(5)).await;
continue;
};
let client_clone_handler = client_clone.clone();
match client_clone
.handle_notifications(|notification| {
let client_clone_handler = client_clone_handler.clone();
let plugin_clone = plugin.clone();
let label_clone = label.clone();
let plugin_clone = plugin_clone.clone();
let label_clone = label_clone.clone();
let wallet_keys_clone = wallet_keys.clone();
nwc_request_handler(
notification,
@ -144,14 +120,83 @@ pub async fn run_nwc(
.await
{
Ok(()) => {
log::info!("NWC handler for `{}` stopped", label);
log::info!("NWC handler for `{}` stopped", label_clone);
break;
}
Err(e) => log::warn!("NWC handler for `{}` had an error: {}", label, e),
Err(e) => log::warn!("NWC handler for `{}` had an error: {}", label_clone, e),
};
}
});
Ok(client)
let mut locked_handles = plugin.state().handles.lock().await;
locked_handles.insert(
label.clone(),
(client, Keys::new(nwc_store.uri.secret).public_key()),
);
Ok(())
}
pub async fn send_nwc_info_event(
client: Client,
notifications: bool,
capabilities: String,
wallet_keys: Keys,
) -> Result<(), anyhow::Error> {
let mut info_event_builder = EventBuilder::new(Kind::WalletConnectInfo, capabilities.clone())
.tag(Tag::parse(vec!["encryption", "nip44_v2 nip04"]).unwrap());
if notifications {
info_event_builder = info_event_builder
.tag(Tag::parse(vec!["notifications", "payment_received payment_sent"]).unwrap())
}
let info_event = match info_event_builder.sign_with_keys(&wallet_keys) {
Ok(o) => o,
Err(e) => {
return Err(anyhow!("Could not sign info_event! {}", e));
}
};
log::debug!("info_event:{:?}", info_event);
let send_result = match client.send_event(&info_event).await {
Ok(o) => o,
Err(e) => {
return Err(anyhow!("Could not send info_event! {}", e));
}
};
if send_result.success.is_empty() {
return Err(anyhow!(
"None of the relays received the info_event! {}",
send_result
.failed
.into_values()
.collect::<Vec<String>>()
.join(", ")
));
}
Ok(())
}
pub async fn stop_nwc(plugin: Plugin<PluginState>, label: &String) {
let mut locked_handles = plugin.state().handles.lock().await;
if let Some((client, _client_pubkey)) = locked_handles.remove(label) {
client.shutdown().await;
}
stop_nwc_budget_job(plugin.clone(), label);
}
pub fn start_nwc_budget_job(plugin: Plugin<PluginState>, label: String) {
let (tx, rx) = oneshot::channel::<()>();
tokio::spawn(budget_task(rx, plugin.clone(), label.clone()));
plugin.state().budget_jobs.lock().insert(label, tx);
}
pub fn stop_nwc_budget_job(plugin: Plugin<PluginState>, label: &String) {
let mut budget_jobs = plugin.state().budget_jobs.lock();
let job = budget_jobs.remove(label);
if let Some(j) = job {
let _ = j.send(());
}
}
async fn nwc_request_handler(
@ -358,7 +403,7 @@ async fn nwc_request_handler(
}]
}
nip47::RequestParams::GetInfo => {
vec![match get_info(plugin.clone()).await {
vec![match get_info(plugin.clone(), &label).await {
Ok(o) => (
nip47::Response {
result_type: nip47::Method::GetInfo,

View file

@ -1,7 +1,5 @@
use std::path::Path;
use cln_plugin::Plugin;
use cln_rpc::{model::requests::ListpeerchannelsRequest, primitives::ChannelState, ClnRpc};
use cln_rpc::{model::requests::ListpeerchannelsRequest, primitives::ChannelState};
use nostr_sdk::nips::*;
use crate::{structs::PluginState, util::load_nwc_store};
@ -10,14 +8,7 @@ pub async fn get_balance(
plugin: Plugin<PluginState>,
label: &String,
) -> Result<nip47::GetBalanceResponse, nip47::NIP47Error> {
let mut rpc = ClnRpc::new(
Path::new(&plugin.configuration().lightning_dir).join(&plugin.configuration().rpc_file),
)
.await
.map_err(|e| nip47::NIP47Error {
code: nip47::ErrorCode::Internal,
message: e.to_string(),
})?;
let mut rpc = plugin.state().rpc_lock.lock().await;
let nwc_store = load_nwc_store(&mut rpc, label)
.await

View file

@ -1,24 +1,19 @@
use std::{path::Path, str::FromStr};
use std::str::FromStr;
use cln_plugin::Plugin;
use cln_rpc::{model::requests::GetinfoRequest, ClnRpc};
use cln_rpc::model::requests::GetinfoRequest;
use nostr_sdk::nips::*;
use nostr_sdk::*;
use crate::structs::PluginState;
use crate::OPT_NOTIFICATIONS;
use crate::util::{is_read_only_nwc, load_nwc_store};
use crate::{OPT_NOTIFICATIONS, WALLET_ALL_METHODS, WALLET_READ_METHODS};
pub async fn get_info(
plugin: Plugin<PluginState>,
label: &String,
) -> Result<nip47::GetInfoResponse, nip47::NIP47Error> {
let mut rpc = ClnRpc::new(
Path::new(&plugin.configuration().lightning_dir).join(&plugin.configuration().rpc_file),
)
.await
.map_err(|e| nip47::NIP47Error {
code: nip47::ErrorCode::Internal,
message: e.to_string(),
})?;
let mut rpc = plugin.state().rpc_lock.lock().await;
let get_info = rpc
.call_typed(&GetinfoRequest {})
@ -46,6 +41,25 @@ pub async fn get_info(
vec![]
};
let nwc_store = load_nwc_store(&mut rpc, label)
.await
.map_err(|e| nip47::NIP47Error {
code: nip47::ErrorCode::Internal,
message: e.to_string(),
})?;
let methods = if is_read_only_nwc(&nwc_store) {
WALLET_READ_METHODS
.into_iter()
.map(|s| s.to_owned())
.collect()
} else {
WALLET_ALL_METHODS
.into_iter()
.map(|s| s.to_owned())
.collect()
};
Ok(nip47::GetInfoResponse {
alias: get_info.alias,
color: Some(get_info.color),
@ -53,17 +67,7 @@ pub async fn get_info(
network: Some(network),
block_height: Some(get_info.blockheight),
block_hash: None,
methods: vec![
"pay_invoice".to_owned(),
"multi_pay_invoice".to_owned(),
"pay_keysend".to_owned(),
"multi_pay_keysend".to_owned(),
"make_invoice".to_owned(),
"lookup_invoice".to_owned(),
"list_transactions".to_owned(),
"get_balance".to_owned(),
"get_info".to_owned(),
],
methods,
notifications,
})
}

View file

@ -1,10 +1,9 @@
use std::{path::Path, str::FromStr};
use std::str::FromStr;
use cln_plugin::Plugin;
use cln_rpc::{
model::requests::InvoiceRequest,
primitives::{Amount, AmountOrAny, Sha256},
ClnRpc,
};
use nostr_sdk::nips::*;
use uuid::Uuid;
@ -15,14 +14,7 @@ pub async fn make_invoice(
plugin: Plugin<PluginState>,
params: nip47::MakeInvoiceRequest,
) -> Result<nip47::MakeInvoiceResponse, nip47::NIP47Error> {
let mut rpc = ClnRpc::new(
Path::new(&plugin.configuration().lightning_dir).join(&plugin.configuration().rpc_file),
)
.await
.map_err(|e| nip47::NIP47Error {
code: nip47::ErrorCode::Internal,
message: e.to_string(),
})?;
let mut rpc = plugin.state().rpc_lock.lock().await;
let mut deschashonly = None;

View file

@ -1,10 +1,9 @@
use std::{path::Path, str::FromStr, time::Duration};
use std::{str::FromStr, time::Duration};
use cln_plugin::Plugin;
use cln_rpc::{
model::requests::KeysendRequest,
primitives::{Amount, PublicKey, TlvEntry, TlvStream},
ClnRpc,
};
use nostr_sdk::nips::*;
use tokio::time;
@ -19,7 +18,7 @@ pub async fn pay_keysend(
params: nip47::PayKeysendRequest,
label: &String,
) -> Result<nip47::PayKeysendResponse, nip47::NIP47Error> {
let _guard = plugin.state().rpc_lock.lock().await;
let mut rpc = plugin.state().rpc_lock.lock().await;
if params.preimage.is_some() {
return Err(nip47::NIP47Error {
@ -28,15 +27,6 @@ pub async fn pay_keysend(
});
}
let mut rpc = ClnRpc::new(
Path::new(&plugin.configuration().lightning_dir).join(&plugin.configuration().rpc_file),
)
.await
.map_err(|e| nip47::NIP47Error {
code: nip47::ErrorCode::Internal,
message: e.to_string(),
})?;
let mut nwc_store = load_nwc_store(&mut rpc, label)
.await
.map_err(|e| nip47::NIP47Error {

View file

@ -1,4 +1,4 @@
use std::{cmp::Reverse, path::Path, str::FromStr};
use std::{cmp::Reverse, str::FromStr};
use cln_plugin::Plugin;
use cln_rpc::{
@ -7,7 +7,6 @@ use cln_rpc::{
responses::{ListinvoicesInvoicesStatus, ListpaysPaysStatus},
},
primitives::Sha256,
ClnRpc,
};
use nostr_sdk::nips::*;
use nostr_sdk::*;
@ -18,14 +17,7 @@ pub async fn lookup_invoice(
plugin: Plugin<PluginState>,
params: nip47::LookupInvoiceRequest,
) -> Result<nip47::LookupInvoiceResponse, nip47::NIP47Error> {
let mut rpc = ClnRpc::new(
Path::new(&plugin.configuration().lightning_dir).join(&plugin.configuration().rpc_file),
)
.await
.map_err(|e| nip47::NIP47Error {
code: nip47::ErrorCode::Internal,
message: e.to_string(),
})?;
let mut rpc = plugin.state().rpc_lock.lock().await;
if params.payment_hash.is_none() && params.invoice.is_none() {
return Err(nip47::NIP47Error {
@ -252,14 +244,7 @@ pub async fn list_transactions(
plugin: Plugin<PluginState>,
params: nip47::ListTransactionsRequest,
) -> Result<Vec<nip47::LookupInvoiceResponse>, nip47::NIP47Error> {
let mut rpc = ClnRpc::new(
Path::new(&plugin.configuration().lightning_dir).join(&plugin.configuration().rpc_file),
)
.await
.map_err(|e| nip47::NIP47Error {
code: nip47::ErrorCode::Internal,
message: e.to_string(),
})?;
let mut rpc = plugin.state().rpc_lock.lock().await;
let (query_invoices, query_payments) = match params.transaction_type {
Some(t) => match t {

View file

@ -1,4 +1,3 @@
use std::path::Path;
use std::str::FromStr;
use anyhow::anyhow;
@ -6,7 +5,6 @@ use cln_plugin::Plugin;
use cln_rpc::model::requests::{DecodeRequest, ListinvoicesRequest, ListpaysRequest};
use cln_rpc::model::responses::ListpaysPaysStatus;
use cln_rpc::primitives::Sha256;
use cln_rpc::ClnRpc;
use crate::structs::PluginState;
use crate::OPT_NOTIFICATIONS;
@ -29,10 +27,7 @@ pub async fn payment_received_handler(
.as_str()
.ok_or_else(|| anyhow!("label not a string"))?;
let mut rpc = ClnRpc::new(
Path::new(&plugin.configuration().lightning_dir).join(&plugin.configuration().rpc_file),
)
.await?;
let mut rpc = plugin.state().rpc_lock.lock().await;
let invoice_resp = rpc
.call_typed(&ListinvoicesRequest {
@ -196,10 +191,7 @@ pub async fn payment_sent_handler(
.ok_or_else(|| anyhow!("payment_hash not a string"))?
.to_owned();
let mut rpc = ClnRpc::new(
Path::new(&plugin.configuration().lightning_dir).join(&plugin.configuration().rpc_file),
)
.await?;
let mut rpc = plugin.state().rpc_lock.lock().await;
let pays_resp = rpc
.call_typed(&ListpaysRequest {

View file

@ -1,10 +1,9 @@
use std::{path::Path, time::Duration};
use std::time::Duration;
use cln_plugin::Plugin;
use cln_rpc::{
model::requests::{DecodeRequest, PayRequest, XpayRequest},
primitives::Amount,
ClnRpc,
};
use nostr_sdk::nips::*;
use tokio::time;
@ -19,24 +18,10 @@ pub async fn pay_invoice(
params: nip47::PayInvoiceRequest,
label: &String,
) -> Result<(nip47::PayInvoiceResponse, String), (nip47::NIP47Error, String)> {
let _guard = plugin.state().rpc_lock.lock().await;
let mut rpc = plugin.state().rpc_lock.lock().await;
let id = params.id.clone().unwrap_or_default();
let mut rpc = ClnRpc::new(
Path::new(&plugin.configuration().lightning_dir).join(&plugin.configuration().rpc_file),
)
.await
.map_err(|e| {
(
nip47::NIP47Error {
code: nip47::ErrorCode::Internal,
message: e.to_string(),
},
id.clone(),
)
})?;
let invoice_decoded = rpc
.call_typed(&DecodeRequest {
string: params.invoice.clone(),

View file

@ -1,28 +1,25 @@
use std::path::Path;
use anyhow::anyhow;
use cln_plugin::Plugin;
use cln_rpc::model::requests::{
DatastoreMode, DatastoreRequest, DeldatastoreRequest, ListdatastoreRequest,
};
use cln_rpc::ClnRpc;
use nostr_sdk::nips::nip47::*;
use nostr_sdk::*;
use serde_json::json;
use tokio::sync::oneshot;
use crate::nwc::run_nwc;
use crate::nwc::{
run_nwc, send_nwc_info_event, start_nwc_budget_job, stop_nwc, stop_nwc_budget_job,
};
use crate::parse::parse_time_period;
use crate::structs::{BudgetIntervalConfig, NwcStore, PluginState};
use crate::tasks::budget_task;
use crate::util::{load_nwc_store, update_nwc_store};
use crate::PLUGIN_NAME;
use crate::util::{is_read_only_nwc, load_nwc_store, update_nwc_store};
use crate::{OPT_NOTIFICATIONS, PLUGIN_NAME, WALLET_ALL_METHODS, WALLET_READ_METHODS};
pub async fn nwc_create(
plugin: Plugin<PluginState>,
args: serde_json::Value,
) -> Result<serde_json::Value, anyhow::Error> {
let _guard = plugin.state().rpc_lock.lock().await;
let mut rpc = plugin.state().rpc_lock.lock().await;
let (label, budget_msat, interval_secs) = parse_full_args(args)?;
@ -49,11 +46,6 @@ pub async fn nwc_create(
serde_json::Value::String(client_keys.public_key().to_string()),
);
let mut rpc = ClnRpc::new(
Path::new(&plugin.configuration().lightning_dir).join(&plugin.configuration().rpc_file),
)
.await?;
let interval_config = if let Some(bgt_msat) = budget_msat {
result.insert(
"budget_msat".to_owned(),
@ -89,12 +81,8 @@ pub async fn nwc_create(
})
.await?;
let client = run_nwc(plugin.clone(), label.clone(), nwc_store.clone()).await?;
let mut locked_handles = plugin.state().handles.lock().await;
locked_handles.insert(
label.clone(),
(client, Keys::new(nwc_store.uri.secret).public_key()),
);
run_nwc(plugin.clone(), label.clone(), nwc_store.clone()).await?;
Ok(serde_json::Value::Object(result))
}
@ -102,27 +90,11 @@ pub async fn nwc_revoke(
plugin: Plugin<PluginState>,
args: serde_json::Value,
) -> Result<serde_json::Value, anyhow::Error> {
let _guard = plugin.state().rpc_lock.lock().await;
let mut rpc = plugin.state().rpc_lock.lock().await;
let label = parse_revoke_args(args)?;
{
let mut locked_handles = plugin.state().handles.lock().await;
if let Some((client, _client_pubkey)) = locked_handles.remove(&label) {
client.shutdown().await;
}
let mut budget_jobs = plugin.state().budget_jobs.lock();
let job = budget_jobs.remove(&label);
if let Some(j) = job {
let _ = j.send(());
}
}
let mut rpc = ClnRpc::new(
Path::new(&plugin.configuration().lightning_dir).join(&plugin.configuration().rpc_file),
)
.await?;
stop_nwc(plugin.clone(), &label).await;
rpc.call_typed(&DeldatastoreRequest {
generation: None,
@ -137,25 +109,16 @@ pub async fn nwc_budget(
plugin: Plugin<PluginState>,
args: serde_json::Value,
) -> Result<serde_json::Value, anyhow::Error> {
let _guard = plugin.state().rpc_lock.lock().await;
let mut rpc = plugin.state().rpc_lock.lock().await;
let (label, budget_msat, interval_secs) = parse_full_args(args)?;
let mut rpc = ClnRpc::new(
Path::new(&plugin.configuration().lightning_dir).join(&plugin.configuration().rpc_file),
)
.await?;
{
let mut budget_jobs = plugin.state().budget_jobs.lock();
let job = budget_jobs.remove(&label);
if let Some(j) = job {
let _ = j.send(());
}
}
stop_nwc_budget_job(plugin.clone(), &label);
let mut nwc_store = load_nwc_store(&mut rpc, &label).await?;
let is_old_nwc_read_only = is_read_only_nwc(&nwc_store);
if let Some(budget) = budget_msat {
nwc_store.budget_msat = Some(budget);
if let Some(interval) = interval_secs {
@ -173,13 +136,34 @@ pub async fn nwc_budget(
nwc_store.interval_config = None;
}
let is_new_nwc_read_only = is_read_only_nwc(&nwc_store);
if nwc_store.interval_config.is_some() {
let (tx, rx) = oneshot::channel::<()>();
tokio::spawn(budget_task(rx, plugin.clone(), label.clone()));
plugin.state().budget_jobs.lock().insert(label.clone(), tx);
start_nwc_budget_job(plugin.clone(), label.clone());
}
update_nwc_store(&mut rpc, &label, nwc_store).await?;
update_nwc_store(&mut rpc, &label, nwc_store.clone()).await?;
if is_old_nwc_read_only != is_new_nwc_read_only {
let wallet_keys = Keys::new(SecretKey::from_hex(&nwc_store.walletkey)?);
let capabilities = if is_new_nwc_read_only {
WALLET_READ_METHODS.join(" ")
} else {
WALLET_ALL_METHODS.join(" ")
};
let clients = plugin.state().handles.lock().await;
send_nwc_info_event(
clients
.get(&label)
.ok_or_else(|| anyhow!("No client found for label: {}", label))?
.0
.clone(),
plugin.option(&OPT_NOTIFICATIONS).unwrap(),
capabilities,
wallet_keys,
)
.await?;
}
Ok(json!({"budget_updated":label}))
}
@ -188,15 +172,10 @@ pub async fn nwc_list(
plugin: Plugin<PluginState>,
args: serde_json::Value,
) -> Result<serde_json::Value, anyhow::Error> {
let _guard = plugin.state().rpc_lock.lock().await;
let mut rpc = plugin.state().rpc_lock.lock().await;
let label = parse_list_args(args)?;
let mut rpc = ClnRpc::new(
Path::new(&plugin.configuration().lightning_dir).join(&plugin.configuration().rpc_file),
)
.await?;
let mut nwcs = Vec::new();
if let Some(lbl) = label {
@ -248,16 +227,20 @@ pub async fn nwc_list(
fn parse_full_args(
args: serde_json::Value,
) -> Result<(String, Option<u64>, Option<u64>), anyhow::Error> {
let label;
let budget_msat;
let interval_secs;
match args {
serde_json::Value::String(s) => Ok((s, None, None)),
serde_json::Value::String(s) => return Ok((s, None, None)),
serde_json::Value::Array(values) => {
let label = values
label = values
.first()
.ok_or_else(|| anyhow!("label missing"))?
.as_str()
.ok_or_else(|| anyhow!("label is not a string"))?
.to_owned();
let budget_msat = if let Some(b) = values.get(1) {
budget_msat = if let Some(b) = values.get(1) {
Some(
b.as_u64()
.ok_or_else(|| anyhow!("budget_msat is not an integer"))?,
@ -265,7 +248,7 @@ fn parse_full_args(
} else {
None
};
let interval_secs = if let Some(t) = values.get(2) {
interval_secs = if let Some(t) = values.get(2) {
Some(parse_time_period(
t.as_str()
.ok_or_else(|| anyhow!("interval is not a string"))?,
@ -273,19 +256,15 @@ fn parse_full_args(
} else {
None
};
if interval_secs.is_some() && budget_msat.is_none() {
return Err(anyhow!("Must set `budget_msat` if you use `interval`"));
}
Ok((label, budget_msat, interval_secs))
}
serde_json::Value::Object(map) => {
let label = map
label = map
.get("label")
.ok_or_else(|| anyhow!("label missing"))?
.as_str()
.ok_or_else(|| anyhow!("label is not a string"))?
.to_owned();
let budget_msat = if let Some(b) = map.get("budget_msat") {
budget_msat = if let Some(b) = map.get("budget_msat") {
Some(
b.as_u64()
.ok_or_else(|| anyhow!("budget_msat is not an integer"))?,
@ -293,7 +272,7 @@ fn parse_full_args(
} else {
None
};
let interval_secs = if let Some(t) = map.get("interval") {
interval_secs = if let Some(t) = map.get("interval") {
Some(parse_time_period(
t.as_str()
.ok_or_else(|| anyhow!("interval is not a string"))?,
@ -301,13 +280,19 @@ fn parse_full_args(
} else {
None
};
if interval_secs.is_some() && budget_msat.is_none() {
return Err(anyhow!("Must set `budget_msat` if you use `interval`"));
}
Ok((label, budget_msat, interval_secs))
}
_ => Err(anyhow!("Invalid argument type")),
_ => return Err(anyhow!("Invalid argument type")),
}
if interval_secs.is_some() && budget_msat.is_none() {
return Err(anyhow!("Must set `budget_msat` if you use `interval`"));
}
if interval_secs.is_some() && budget_msat.unwrap() == 0 {
return Err(anyhow!(
"`budget_msat` must be greater than 0 if you use `interval`"
));
}
Ok((label, budget_msat, interval_secs))
}
fn parse_revoke_args(args: serde_json::Value) -> Result<String, anyhow::Error> {

View file

@ -1,5 +1,6 @@
use std::{collections::HashMap, str::FromStr, sync::Arc};
use std::{collections::HashMap, path::PathBuf, str::FromStr, sync::Arc};
use cln_rpc::ClnRpc;
use nostr_sdk::client;
use nostr_sdk::nips::nip47;
use nostr_sdk::nostr;
@ -11,17 +12,17 @@ use tokio::sync::oneshot;
pub struct PluginState {
pub config: Arc<Mutex<Config>>,
pub handles: Arc<tokio::sync::Mutex<HashMap<String, (client::Client, nostr::PublicKey)>>>,
pub rpc_lock: Arc<tokio::sync::Mutex<()>>,
pub rpc_lock: Arc<tokio::sync::Mutex<ClnRpc>>,
pub budget_jobs: Arc<Mutex<HashMap<String, oneshot::Sender<()>>>>,
}
impl PluginState {
pub fn default() -> PluginState {
PluginState {
pub async fn new(path: PathBuf) -> Result<PluginState, anyhow::Error> {
Ok(PluginState {
config: Arc::new(Mutex::new(Config::default())),
handles: Arc::new(tokio::sync::Mutex::new(HashMap::new())),
rpc_lock: Arc::new(tokio::sync::Mutex::new(())),
rpc_lock: Arc::new(tokio::sync::Mutex::new(ClnRpc::new(path).await?)),
budget_jobs: Arc::new(Mutex::new(HashMap::new())),
}
})
}
}

View file

@ -79,6 +79,15 @@ pub async fn update_nwc_store(
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() {
return true;
}
}
false
}
pub fn at_or_above_version(my_version: &str, min_version: &str) -> Result<bool, anyhow::Error> {
let clean_start_my_version = my_version
.split_once('v')

View file

@ -100,7 +100,8 @@ async def test_get_info(node_factory, get_plugin, nostr_client): # noqa: F811
node_get_info = l1.rpc.call("getinfo", {})
uri_str = l1.rpc.call("nip47-create", ["test1", 3000])["uri"]
LOGGER.info(uri_str)
nwc = Nwc(NostrWalletConnectUri.parse(uri_str))
uri = NostrWalletConnectUri.parse(uri_str)
nwc = Nwc(uri)
get_info = await nwc.get_info()
assert get_info.alias == node_get_info["alias"]
assert get_info.block_height == node_get_info["blockheight"]
@ -149,6 +150,57 @@ async def test_get_info(node_factory, get_plugin, nostr_client): # noqa: F811
assert get_info.notifications == []
assert get_info.pubkey == node_get_info["id"]
signer = NostrSigner.keys(Keys(uri.secret()))
client = Client(signer)
await client.add_relay(f"ws://{url}")
await client.connect()
response_filter = Filter().kind(Kind(13194)).author(uri.public_key())
events = await client.fetch_events(response_filter, timeout=timedelta(seconds=10))
start_time = datetime.now()
while events.len() < 1 and (datetime.now() - start_time) < timedelta(seconds=10):
time.sleep(1)
events = await client.fetch_events(
response_filter, timeout=timedelta(seconds=1)
)
assert events.len() == 1
assert (
events.to_vec()[0].content()
== "pay_invoice multi_pay_invoice pay_keysend multi_pay_keysend make_invoice lookup_invoice list_transactions get_balance get_info"
)
uri_str = l1.rpc.call("nip47-create", ["test2", 0])["uri"]
LOGGER.info(uri_str)
uri = NostrWalletConnectUri.parse(uri_str)
nwc = Nwc(uri)
get_info = await nwc.get_info()
assert get_info.methods == [
"make_invoice",
"lookup_invoice",
"list_transactions",
"get_balance",
"get_info",
]
signer = NostrSigner.keys(Keys(uri.secret()))
client = Client(signer)
await client.add_relay(f"ws://{url}")
await client.connect()
response_filter = Filter().kind(Kind(13194)).author(uri.public_key())
events = await client.fetch_events(response_filter, timeout=timedelta(seconds=10))
start_time = datetime.now()
while events.len() < 1 and (datetime.now() - start_time) < timedelta(seconds=10):
time.sleep(1)
events = await client.fetch_events(
response_filter, timeout=timedelta(seconds=1)
)
assert events.len() == 1
assert (
events.to_vec()[0].content()
== "make_invoice lookup_invoice list_transactions get_balance get_info"
)
@pytest.mark.skipif(sys.version_info < (3, 9), reason="Requires Python 3.9 or higher")
@pytest.mark.asyncio
@ -1066,6 +1118,129 @@ async def test_persistency(node_factory, get_plugin, nostr_client): # noqa: F81
assert list["test1"]["budget_msat"] == 3000
@pytest.mark.skipif(sys.version_info < (3, 9), reason="Requires Python 3.9 or higher")
@pytest.mark.asyncio
async def test_budget_command(node_factory, get_plugin, nostr_client): # noqa: F811
nostr_client, relay_port = nostr_client
url = f"127.0.0.1:{relay_port}"
l1, l2 = node_factory.line_graph(
2,
wait_for_announce=True,
opts=[
{
"log-level": "debug",
"plugin": get_plugin,
"nip47-relays": f"ws://{url}",
},
{"log-level": "debug"},
],
)
uri_str = l1.rpc.call("nip47-create", ["test1", 3000])["uri"]
LOGGER.info(uri_str)
invoice = l2.rpc.call(
"invoice",
{"label": generate_random_label(), "description": "test1", "amount_msat": 5000},
)
uri = NostrWalletConnectUri.parse(uri_str)
nwc = Nwc(uri)
balance = await nwc.get_balance()
assert balance == 3000
with pytest.raises(NostrSdkError.Generic, match="Payment exceeds budget"):
await nwc.pay_invoice(
PayInvoiceRequest(id=None, amount=None, invoice=invoice["bolt11"])
)
l1.rpc.call("nip47-budget", ["test1", 4000])
balance = await nwc.get_balance()
assert balance == 4000
with pytest.raises(NostrSdkError.Generic, match="Payment exceeds budget"):
await nwc.pay_invoice(
PayInvoiceRequest(id=None, amount=None, invoice=invoice["bolt11"])
)
l1.rpc.call("nip47-budget", ["test1", 5000, "15s"])
balance = await nwc.get_balance()
assert balance == 5000
with pytest.raises(
RpcError, match="`budget_msat` must be greater than 0 if you use `interval`"
):
l1.rpc.call("nip47-budget", ["test1", 0, "1s"])
pay = await nwc.pay_invoice(
PayInvoiceRequest(id=None, amount=None, invoice=invoice["bolt11"])
)
assert pay.preimage is not None
balance = await nwc.get_balance()
assert balance == 0
get_info = await nwc.get_info()
assert get_info.methods == [
"pay_invoice",
"multi_pay_invoice",
"pay_keysend",
"multi_pay_keysend",
"make_invoice",
"lookup_invoice",
"list_transactions",
"get_balance",
"get_info",
]
signer = NostrSigner.keys(Keys(uri.secret()))
client = Client(signer)
await client.add_relay(f"ws://{url}")
await client.connect()
response_filter = Filter().kind(Kind(13194)).author(uri.public_key())
events = await client.fetch_events(response_filter, timeout=timedelta(seconds=10))
start_time = datetime.now()
while events.len() < 1 and (datetime.now() - start_time) < timedelta(seconds=10):
time.sleep(1)
events = await client.fetch_events(
response_filter, timeout=timedelta(seconds=1)
)
assert events.len() == 1
assert (
events.to_vec()[0].content()
== "pay_invoice multi_pay_invoice pay_keysend multi_pay_keysend make_invoice lookup_invoice list_transactions get_balance get_info"
)
time.sleep(16)
balance = await nwc.get_balance()
assert balance == 5000
l1.rpc.call("nip47-budget", ["test1", 0])
balance = await nwc.get_balance()
assert balance == 0
get_info = await nwc.get_info()
assert get_info.methods == [
"make_invoice",
"lookup_invoice",
"list_transactions",
"get_balance",
"get_info",
]
events = await client.fetch_events(response_filter, timeout=timedelta(seconds=10))
start_time = datetime.now()
while events.len() < 1 and (datetime.now() - start_time) < timedelta(seconds=10):
time.sleep(1)
events = await client.fetch_events(
response_filter, timeout=timedelta(seconds=1)
)
assert events.len() == 1
assert (
events.to_vec()[0].content()
== "make_invoice lookup_invoice list_transactions get_balance get_info"
)
@pytest_asyncio.fixture(scope="function")
async def nostr_client(nostr_relay):
port = nostr_relay