From 16917b357207fe690a8f582a3bd8689b8c19f485 Mon Sep 17 00:00:00 2001 From: daywalker90 <8257956+daywalker90@users.noreply.github.com> Date: Fri, 23 May 2025 16:02:01 +0200 Subject: [PATCH 1/2] add bolt12 methods and capabilities --- Cargo.lock | 27 +- Cargo.toml | 12 +- src/main.rs | 5 + src/nwc.rs | 18 ++ src/nwc_lookups.rs | 6 + src/nwc_offer.rs | 559 ++++++++++++++++++++++++++++++++++++++++ src/parse.rs | 21 ++ src/structs.rs | 2 + src/util.rs | 28 +- tests/test_cln-nip47.py | 409 ++++++++++++++++++++++++++++- 10 files changed, 1055 insertions(+), 32 deletions(-) create mode 100644 src/nwc_offer.rs diff --git a/Cargo.lock b/Cargo.lock index f2537a1..fe7af78 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -331,9 +331,9 @@ dependencies = [ [[package]] name = "crypto-common" -version = "0.1.6" +version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" dependencies = [ "generic-array", "rand_core 0.6.4", @@ -486,9 +486,9 @@ dependencies = [ [[package]] name = "generic-array" -version = "0.14.9" +version = "0.14.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4bb6743198531e02858aeaea5398fcc883e71851fcbcb5a2f773e2fb6cb1edf2" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" dependencies = [ "typenum", "version_check", @@ -806,9 +806,8 @@ checksum = "f0efe882e02d206d8d279c20eb40e03baf7cb5136a1476dc084a324fbc3ec42d" [[package]] name = "nostr" -version = "0.44.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3595fecf0e0aaacb69a0dc0101a4453f3c76eda333d6bbc49f68f64390b3d85" +version = "0.44.0" +source = "git+https://github.com/daywalker90/nostr.git?branch=bolt12#829d51d2594ef14dc1499b8961514b671c27bf05" dependencies = [ "aes", "base64", @@ -832,8 +831,7 @@ dependencies = [ [[package]] name = "nostr-database" version = "0.44.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7462c9d8ae5ef6a28d66a192d399ad2530f1f2130b13186296dbb11bdef5b3d1" +source = "git+https://github.com/daywalker90/nostr.git?branch=bolt12#829d51d2594ef14dc1499b8961514b671c27bf05" dependencies = [ "lru", "nostr", @@ -843,8 +841,7 @@ dependencies = [ [[package]] name = "nostr-gossip" version = "0.44.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ade30de16869618919c6b5efc8258f47b654a98b51541eb77f85e8ec5e3c83a6" +source = "git+https://github.com/daywalker90/nostr.git?branch=bolt12#829d51d2594ef14dc1499b8961514b671c27bf05" dependencies = [ "nostr", ] @@ -852,8 +849,7 @@ dependencies = [ [[package]] name = "nostr-relay-pool" version = "0.44.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4b1073ccfbaea5549fb914a9d52c68dab2aecda61535e5143dd73e95445a804b" +source = "git+https://github.com/daywalker90/nostr.git?branch=bolt12#829d51d2594ef14dc1499b8961514b671c27bf05" dependencies = [ "async-utility", "async-wsocket", @@ -869,9 +865,8 @@ dependencies = [ [[package]] name = "nostr-sdk" -version = "0.44.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "471732576710e779b64f04c55e3f8b5292f865fea228436daf19694f0bf70393" +version = "0.44.0" +source = "git+https://github.com/daywalker90/nostr.git?branch=bolt12#829d51d2594ef14dc1499b8961514b671c27bf05" dependencies = [ "async-utility", "nostr", diff --git a/Cargo.toml b/Cargo.toml index d27de10..7bc8291 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -19,7 +19,17 @@ cln-plugin = "0.5" parking_lot = "0.12" # nostr-sdk = { git = "https://github.com/rust-nostr/nostr.git", rev = "f7122f5", features = ["nip47", "nip04", "nip44"]} -nostr-sdk = { version = "0.44", features = ["nip47", "nip04", "nip44"] } +# nostr-sdk = { version = "0.42", features = ["nip47", "nip04", "nip44"]} +nostr-sdk = { git = "https://github.com/daywalker90/nostr.git", branch = "bolt12", features = [ + "nip47", + "nip04", + "nip44", +] } +# nostr-sdk = { path = "../nostr/crates/nostr-sdk", version = "0.42", features = [ +# "nip47", +# "nip04", +# "nip44", +# ] } uuid = { version = "1", features = ["v4"] } diff --git a/src/main.rs b/src/main.rs index 1605f77..57e54b1 100644 --- a/src/main.rs +++ b/src/main.rs @@ -24,6 +24,7 @@ mod nwc_invoice; mod nwc_keysend; mod nwc_lookups; mod nwc_notifications; +mod nwc_offer; mod nwc_pay; mod parse; mod rpc; @@ -56,6 +57,10 @@ pub const WALLET_PAY_METHODS: [nip47::Method; 4] = [ nip47::Method::PayKeysend, nip47::Method::MultiPayKeysend, ]; +pub const WALLET_OFFER_READ_METHODS: [nip47::Method; 2] = + [nip47::Method::MakeOffer, nip47::Method::GetOfferInfo]; +pub const WALLET_OFFER_PAY_METHODS: [nip47::Method; 2] = + [nip47::Method::PayOffer, nip47::Method::MultiPayOffer]; pub const WALLET_NOTIFICATIONS: [nip47::NotificationType; 2] = [ nip47::NotificationType::PaymentReceived, nip47::NotificationType::PaymentSent, diff --git a/src/nwc.rs b/src/nwc.rs index 9e9b2fb..268b089 100644 --- a/src/nwc.rs +++ b/src/nwc.rs @@ -34,6 +34,12 @@ use crate::{ nwc_invoice::make_invoice_response, nwc_keysend::{multi_pay_keysend, pay_keysend_response}, nwc_lookups::{list_transactions_response, lookup_invoice_response}, + nwc_offer::{ + get_offer_info_response, + make_offer_response, + multi_pay_offer, + pay_offer_response, + }, nwc_pay::{multi_pay_invoice, pay_invoice_response}, structs::{NwcStore, PluginState}, tasks::budget_task, @@ -289,6 +295,18 @@ async fn nwc_request_handler( nip47::RequestParams::SettleHoldInvoice(settle_hold_invoice_request) => { settle_hold_invoice_response(plugin.clone(), settle_hold_invoice_request, &label).await } + nip47::RequestParams::MakeOffer(make_offer_request) => { + make_offer_response(plugin.clone(), make_offer_request).await + } + nip47::RequestParams::PayOffer(pay_offer_request) => { + pay_offer_response(plugin.clone(), pay_offer_request, &label).await + } + nip47::RequestParams::MultiPayOffer(multi_pay_offer_request) => { + multi_pay_offer(plugin.clone(), multi_pay_offer_request, &label).await + } + nip47::RequestParams::GetOfferInfo(get_offer_info_request) => { + get_offer_info_response(plugin.clone(), get_offer_info_request).await + } }; for (response, id) in responses { let content = diff --git a/src/nwc_lookups.rs b/src/nwc_lookups.rs index 33bea1f..5bdea55 100644 --- a/src/nwc_lookups.rs +++ b/src/nwc_lookups.rs @@ -337,6 +337,9 @@ async fn make_lookup_response_from_listinvoices( settled_at: list_invoice.paid_at.map(Timestamp::from_secs), metadata: None, state: Some(state), + offer_issuer: invoice_decoded.offer_issuer, + payer_note: invoice_decoded.invreq_payer_note, + offer_id: invoice_decoded.offer_id, }) } @@ -443,6 +446,9 @@ async fn make_lookup_response_from_listpays( settled_at: list_pay.completed_at.map(Timestamp::from_secs), metadata: None, state: Some(state), + offer_issuer: None, + payer_note: None, + offer_id: None, }) } diff --git a/src/nwc_offer.rs b/src/nwc_offer.rs new file mode 100644 index 0000000..5bc41e0 --- /dev/null +++ b/src/nwc_offer.rs @@ -0,0 +1,559 @@ +use std::time::Duration; + +use cln_plugin::Plugin; +use cln_rpc::{ + model::{ + requests::{DecodeRequest, FetchinvoiceRequest, OfferRequest, PayRequest, XpayRequest}, + responses::DecodeResponse, + }, + primitives::{Amount, Secret}, + ClnRpc, + RpcError, +}; +use nostr_sdk::{nips::nip47, Timestamp}; +use tokio::time; +use uuid::Uuid; + +use crate::{ + structs::{NwcStore, PluginState}, + util::{at_or_above_version, budget_amount_check, load_nwc_store, update_nwc_store}, +}; + +pub async fn get_offer_info_response( + plugin: Plugin, + params: nip47::GetOfferInfoRequest, +) -> Vec<(nip47::Response, Option)> { + vec![match get_offer_info(plugin, params).await { + Ok(o) => ( + nip47::Response { + result_type: nip47::Method::GetOfferInfo, + error: None, + result: Some(nip47::ResponseResult::GetOfferInfo(o)), + }, + None, + ), + Err(e) => ( + nip47::Response { + result_type: nip47::Method::GetOfferInfo, + error: Some(e), + result: None, + }, + None, + ), + }] +} + +async fn get_offer_info( + plugin: Plugin, + params: nip47::GetOfferInfoRequest, +) -> Result { + let mut rpc = plugin.state().rpc_lock.lock().await; + + let not_offer_err = Err(nip47::NIP47Error { + code: nip47::ErrorCode::Other, + message: "Not an offer or invalid offer".to_owned(), + }); + + let decoded_offer = rpc + .call_typed(&DecodeRequest { + string: params.offer.clone(), + }) + .await + .map_err(|e| nip47::NIP47Error { + code: nip47::ErrorCode::Internal, + message: e.to_string(), + })?; + + if !decoded_offer.valid { + return not_offer_err; + } + + match decoded_offer.item_type { + cln_rpc::model::responses::DecodeType::BOLT12_OFFER => (), + _ => return not_offer_err, + } + + let description = decoded_offer.offer_description; + + let amount = decoded_offer.offer_amount_msat.map(|a| a.msat()); + + let expires_at = decoded_offer + .offer_absolute_expiry + .map(Timestamp::from_secs); + + Ok(nip47::GetOfferInfoResponse { + offer: params.offer, + description, + amount, + issuer: decoded_offer.offer_issuer, + expires_at, + currency: None, + currency_minor_unit: None, + }) +} + +pub async fn make_offer_response( + plugin: Plugin, + params: nip47::MakeOfferRequest, +) -> Vec<(nip47::Response, Option)> { + vec![match make_offer(plugin, params).await { + Ok(o) => ( + nip47::Response { + result_type: nip47::Method::MakeOffer, + error: None, + result: Some(nip47::ResponseResult::MakeOffer(o)), + }, + None, + ), + Err(e) => ( + nip47::Response { + result_type: nip47::Method::MakeOffer, + error: Some(e), + result: None, + }, + None, + ), + }] +} + +async fn make_offer( + plugin: Plugin, + params: nip47::MakeOfferRequest, +) -> Result { + let mut rpc = plugin.state().rpc_lock.lock().await; + + let absolute_expiry = params.absolute_expiry.map(Timestamp::from_secs); + + let single_use = if let Some(su) = params.single_use { + Some(su) + } else { + Some(false) + }; + + let offer = rpc + .call_typed(&OfferRequest { + absolute_expiry: absolute_expiry.map(|e| e.as_secs()), + description: params.description.clone(), + issuer: params.issuer.clone(), + label: Some("NWC offer -".to_owned() + Uuid::new_v4().to_string().as_str()), + quantity_max: None, + recurrence: None, + recurrence_base: None, + recurrence_limit: None, + recurrence_paywindow: None, + single_use, + amount: params.amount.map_or("any".to_owned(), |a| a.to_string()), + optional_recurrence: None, + proportional_amount: None, + }) + .await + .map_err(|e| nip47::NIP47Error { + code: nip47::ErrorCode::Internal, + message: e.to_string(), + })?; + + Ok(nip47::MakeOfferResponse { + offer: offer.bolt12, + description: params.description, + amount: params.amount, + issuer: params.issuer, + expires_at: absolute_expiry, + currency: None, + currency_minor_unit: None, + single_use: params.single_use, + }) +} + +pub async fn pay_offer_response( + plugin: Plugin, + params: nip47::PayOfferRequest, + label: &str, +) -> Vec<(nip47::Response, Option)> { + vec![match pay_offer(plugin, params, label).await { + Ok((o, id)) => ( + nip47::Response { + result_type: nip47::Method::PayOffer, + error: None, + result: Some(nip47::ResponseResult::PayOffer(o)), + }, + id, + ), + Err((e, id)) => ( + nip47::Response { + result_type: nip47::Method::PayOffer, + error: Some(e), + result: None, + }, + id, + ), + }] +} + +async fn pay_offer( + plugin: Plugin, + params: nip47::PayOfferRequest, + label: &str, +) -> Result<(nip47::PayOfferResponse, Option), (nip47::NIP47Error, Option)> { + let mut rpc = plugin.state().rpc_lock.lock().await; + + let decoded_invoice = fetch_invoice(&mut rpc, ¶ms).await?; + + let id = get_payment_id(¶ms, &decoded_invoice)?; + + 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 my_version = plugin.state().config.lock().clone().my_cln_version; + let use_xpay = check_cln_version(&my_version, &id)?; + + if use_xpay { + pay_with_xpay_full(&mut rpc, params, label, nwc_store, &id).await + } else { + pay_with_legacy_full(&mut rpc, params, label, nwc_store, &id).await + } +} + +async fn fetch_invoice( + rpc: &mut ClnRpc, + params: &nip47::PayOfferRequest, +) -> Result)> { + let bolt12_invoice = rpc + .call_typed(&FetchinvoiceRequest { + amount_msat: params.amount.map(Amount::from_msat), + bip353: None, + payer_metadata: None, + payer_note: None, + quantity: None, + recurrence_counter: None, + recurrence_label: None, + recurrence_start: None, + timeout: None, + offer: params.offer.clone(), + }) + .await + .map_err(|e| { + ( + nip47::NIP47Error { + code: nip47::ErrorCode::Internal, + message: e.to_string(), + }, + params.id.clone(), + ) + })?; + let invoice_decoded = rpc + .call_typed(&DecodeRequest { + string: bolt12_invoice.invoice.clone(), + }) + .await + .map_err(|e| { + ( + nip47::NIP47Error { + code: nip47::ErrorCode::Internal, + message: e.to_string(), + }, + params.id.clone(), + ) + })?; + + Ok(invoice_decoded) +} + +fn get_payment_id( + params: &nip47::PayOfferRequest, + decoded_offer: &DecodeResponse, +) -> Result)> { + let id = if let Some(i) = ¶ms.id { + i.clone() + } else { + decoded_offer + .invoice_payment_hash + .as_ref() + .ok_or_else(|| { + ( + nip47::NIP47Error { + code: nip47::ErrorCode::Internal, + message: "payment_hash missing in decoded bolt12 invoice".to_owned(), + }, + None, + ) + })? + .clone() + }; + + Ok(id) +} + +fn get_invoice_amount_msat( + decoded_invoice: &DecodeResponse, + id: &str, +) -> Result)> { + decoded_invoice + .invoice_amount_msat + .as_ref() + .ok_or_else(|| { + ( + nip47::NIP47Error { + code: nip47::ErrorCode::Internal, + message: "Missing amount_msat in decoded bolt12 invoice".to_owned(), + }, + Some(id.to_owned()), + ) + }) + .map(Amount::msat) +} + +async fn load_nwc_and_check_budget( + rpc: &mut ClnRpc, + label: &str, + params: &nip47::PayOfferRequest, + invoice_amt_msat: u64, + id: &str, +) -> Result)> { + let nwc_store = load_nwc_store(rpc, label).await.map_err(|e| { + ( + nip47::NIP47Error { + code: nip47::ErrorCode::Internal, + message: e.to_string(), + }, + Some(id.to_owned()), + ) + })?; + + budget_amount_check(params.amount, Some(invoice_amt_msat), nwc_store.budget_msat).map_err( + |e| { + ( + nip47::NIP47Error { + code: nip47::ErrorCode::QuotaExceeded, + message: e.to_string(), + }, + Some(id.to_owned()), + ) + }, + )?; + + Ok(nwc_store) +} + +fn check_cln_version( + my_version: &str, + id: &str, +) -> Result)> { + at_or_above_version(my_version, "24.11").map_err(|e| { + ( + nip47::NIP47Error { + code: nip47::ErrorCode::Internal, + message: e.to_string(), + }, + Some(id.to_owned()), + ) + }) +} + +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::PayOfferResponse, Option), (nip47::NIP47Error, Option)> { + if let Some(ref mut bdg) = nwc_store.budget_msat { + *bdg = bdg.saturating_sub(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::PayOfferResponse { + preimage: preimage_str, + fees_paid: Some(fees_paid), + }, + Some(id.to_owned()), + )) +} + +fn map_cln_error_to_nip47( + e: &RpcError, + id: &str, + is_xpay: bool, +) -> (nip47::NIP47Error, Option) { + match e.code { + Some(c) => { + let other_codes = if is_xpay { + vec![207, 219] + } else { + vec![201, 207, 219] + }; + let failed_codes = if is_xpay { + vec![203, 205, 209] + } else { + vec![203, 205, 209, 210] + }; + + if other_codes.contains(&c) { + ( + nip47::NIP47Error { + code: nip47::ErrorCode::Other, + message: e.to_string(), + }, + Some(id.to_owned()), + ) + } else if failed_codes.contains(&c) { + ( + nip47::NIP47Error { + code: nip47::ErrorCode::PaymentFailed, + message: e.to_string(), + }, + Some(id.to_owned()), + ) + } else if !is_xpay && c == 206 { + ( + nip47::NIP47Error { + code: nip47::ErrorCode::InsufficientBalance, + message: e.to_string(), + }, + Some(id.to_owned()), + ) + } else { + ( + nip47::NIP47Error { + code: nip47::ErrorCode::Internal, + message: e.to_string(), + }, + Some(id.to_owned()), + ) + } + } + None => ( + nip47::NIP47Error { + code: nip47::ErrorCode::Internal, + message: e.to_string(), + }, + Some(id.to_owned()), + ), + } +} + +async fn pay_with_xpay_full( + rpc: &mut ClnRpc, + params: nip47::PayOfferRequest, + label: &str, + mut nwc_store: NwcStore, + id: &str, +) -> Result<(nip47::PayOfferResponse, Option), (nip47::NIP47Error, Option)> { + let payment_result = rpc + .call_typed(&XpayRequest { + amount_msat: params.amount.map(Amount::from_msat), + maxdelay: None, + maxfee: None, + partial_msat: None, + retry_for: None, + layers: None, + invstring: params.offer, + }) + .await + .map_err(|e| map_cln_error_to_nip47(&e, id, true))?; + + 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 +} + +async fn pay_with_legacy_full( + rpc: &mut ClnRpc, + params: nip47::PayOfferRequest, + label: &str, + mut nwc_store: NwcStore, + id: &str, +) -> Result<(nip47::PayOfferResponse, Option), (nip47::NIP47Error, Option)> { + let payment_result = rpc + .call_typed(&PayRequest { + amount_msat: params.amount.map(Amount::from_msat), + description: None, + exemptfee: None, + label: None, + localinvreqid: None, + maxdelay: None, + maxfee: None, + maxfeepercent: None, + partial_msat: None, + retry_for: None, + riskfactor: None, + exclude: None, + bolt11: params.offer, + }) + .await + .map_err(|e| map_cln_error_to_nip47(&e, id, false))?; + + 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 +} + +pub async fn multi_pay_offer( + plugin: Plugin, + params: nip47::MultiPayOfferRequest, + label: &str, +) -> Vec<(nip47::Response, Option)> { + let mut responses = Vec::new(); + for pay in params.offers { + let result = pay_offer(plugin.clone(), pay, label).await; + let response_res = match result { + Ok((resp, id)) => ( + nip47::Response { + result_type: nip47::Method::MultiPayOffer, + error: None, + result: Some(nip47::ResponseResult::MultiPayOffer(resp)), + }, + id, + ), + Err((e, id)) => ( + nip47::Response { + result_type: nip47::Method::MultiPayOffer, + error: Some(e), + result: None, + }, + id, + ), + }; + responses.push(response_res); + time::sleep(Duration::from_millis(100)).await; + } + responses +} diff --git a/src/parse.rs b/src/parse.rs index 7c32966..96e54d8 100644 --- a/src/parse.rs +++ b/src/parse.rs @@ -3,9 +3,11 @@ use std::path::Path; use anyhow::anyhow; use cln_plugin::ConfiguredPlugin; use cln_rpc::{model::requests::GetinfoRequest, ClnRpc}; +use serde_json::json; use crate::{ structs::{PluginState, TimeUnit}, + util::at_or_above_version, OPT_RELAYS, }; @@ -33,8 +35,27 @@ pub async fn read_startup_options( ) .await?; let version = rpc.call_typed(&GetinfoRequest {}).await?.version; + let exp_offers: serde_json::Value = rpc + .call_raw("listconfigs", &json!({"config": "experimental-offers"})) + .await?; let mut config = state.config.lock(); config.my_cln_version = version; + if at_or_above_version(&config.my_cln_version, "24.11")? { + config.offer_support = true; + } else { + let offer_support = exp_offers + .as_object() + .ok_or_else(|| anyhow!("listconfigs not an object"))? + .get("configs") + .ok_or_else(|| anyhow!("listconfigs doesn't have `configs` object"))? + .get("experimental-offers") + .ok_or_else(|| anyhow!("listconfigs doesn't have `experimental-offers` object"))? + .get("set") + .ok_or_else(|| anyhow!("listconfigs doesn't have `set` object"))? + .as_bool() + .ok_or_else(|| anyhow!("listconfigs doesn't have `set` as a bool"))?; + config.offer_support = offer_support; + } for relay in relays_str { log::debug!("RELAY:{relay}"); config.relays.push(nostr_sdk::RelayUrl::parse(&relay)?); diff --git a/src/structs.rs b/src/structs.rs index c96db5a..9a86ad9 100644 --- a/src/structs.rs +++ b/src/structs.rs @@ -30,12 +30,14 @@ impl PluginState { pub struct Config { pub relays: Vec, pub my_cln_version: String, + pub offer_support: bool, } impl Config { pub fn default() -> Config { Config { relays: Vec::new(), my_cln_version: String::new(), + offer_support: false, } } } diff --git a/src/util.rs b/src/util.rs index 36cc484..54626ed 100644 --- a/src/util.rs +++ b/src/util.rs @@ -11,6 +11,8 @@ use crate::{ OPT_NOTIFICATIONS, PLUGIN_NAME, WALLET_NOTIFICATIONS, + WALLET_OFFER_PAY_METHODS, + WALLET_OFFER_READ_METHODS, WALLET_PAY_METHODS, WALLET_READ_METHODS, }; @@ -125,9 +127,28 @@ pub fn at_or_above_version(my_version: &str, min_version: &str) -> Result) -> (String, String) { let mut methods = WALLET_READ_METHODS.map(|m| m.to_string()).join(" "); + if plugin.state().config.lock().offer_support { + methods.push(' '); + methods.push_str( + WALLET_OFFER_READ_METHODS + .map(|m| m.to_string()) + .join(" ") + .as_str(), + ); + } if !is_read_only { methods.push(' '); methods.push_str(WALLET_PAY_METHODS.map(|m| m.to_string()).join(" ").as_str()); + + if plugin.state().config.lock().offer_support { + methods.push(' '); + methods.push_str( + WALLET_OFFER_PAY_METHODS + .map(|m| m.to_string()) + .join(" ") + .as_str(), + ); + } } let mut notifications = String::new(); @@ -143,10 +164,15 @@ pub fn build_capabilities(is_read_only: bool, plugin: &Plugin) -> ( (methods, notifications) } -pub fn build_methods_vec(is_read_only: bool, _plugin: &Plugin) -> Vec { +pub fn build_methods_vec(is_read_only: bool, plugin: &Plugin) -> Vec { let mut methods = WALLET_READ_METHODS.to_vec(); + if plugin.state().config.lock().offer_support { + methods.extend_from_slice(&WALLET_OFFER_READ_METHODS); + } if !is_read_only { methods.extend_from_slice(&WALLET_PAY_METHODS); + + methods.extend_from_slice(&WALLET_OFFER_PAY_METHODS); } methods } diff --git a/tests/test_cln-nip47.py b/tests/test_cln-nip47.py index 00aa8eb..6d1f519 100755 --- a/tests/test_cln-nip47.py +++ b/tests/test_cln-nip47.py @@ -14,7 +14,10 @@ import yaml from pyln.testing.fixtures import * # noqa: F403 from pyln.testing.utils import RpcError, wait_for -from util import generate_random_label, get_plugin # noqa: F401 +from util import ( # noqa: F401 + generate_random_label, + get_plugin, +) from nostr_sdk import ( Alphabet, @@ -112,6 +115,8 @@ async def test_get_info(node_factory, get_plugin, nostr_client): # noqa: F811 Method.MULTI_PAY_INVOICE, Method.PAY_KEYSEND, Method.MULTI_PAY_KEYSEND, + "make_offer", + "lookup_offer", ] assert get_info.network == "regtest" assert get_info.notifications == ["payment_received", "payment_sent"] @@ -131,7 +136,7 @@ async def test_get_info(node_factory, get_plugin, nostr_client): # noqa: F811 assert get_info.alias == node_get_info["alias"] assert get_info.block_height == node_get_info["blockheight"] assert get_info.color == node_get_info["color"] - assert get_info.methods == [ + assert get_info.methods.__str__ == [ Method.MAKE_INVOICE, Method.LOOKUP_INVOICE, Method.LIST_TRANSACTIONS, @@ -141,6 +146,8 @@ async def test_get_info(node_factory, get_plugin, nostr_client): # noqa: F811 Method.MULTI_PAY_INVOICE, Method.PAY_KEYSEND, Method.MULTI_PAY_KEYSEND, + "make_offer", + "lookup_offer", ] assert get_info.network == "regtest" assert get_info.notifications == [] @@ -163,7 +170,7 @@ async def test_get_info(node_factory, get_plugin, nostr_client): # noqa: F811 events_vec = events.to_vec() assert ( events_vec[0].content() - == "make_invoice lookup_invoice list_transactions get_balance get_info pay_invoice multi_pay_invoice pay_keysend multi_pay_keysend" + == "make_invoice lookup_invoice list_transactions get_balance get_info pay_invoice multi_pay_invoice pay_keysend multi_pay_keysend make_offer lookup_offer" ) assert ( events_vec[0].tags().find(TagKind.UNKNOWN("encryption")).content() @@ -182,6 +189,8 @@ async def test_get_info(node_factory, get_plugin, nostr_client): # noqa: F811 Method.LIST_TRANSACTIONS, Method.GET_BALANCE, Method.GET_INFO, + "make_offer", + "lookup_offer", ] signer = NostrSigner.keys(Keys(uri.secret())) @@ -201,7 +210,7 @@ async def test_get_info(node_factory, get_plugin, nostr_client): # noqa: F811 events_vec = events.to_vec() assert ( events_vec[0].content() - == "make_invoice lookup_invoice list_transactions get_balance get_info" + == "make_invoice lookup_invoice list_transactions get_balance get_info make_offer lookup_offer" ) assert ( events_vec[0].tags().find(TagKind.UNKNOWN("encryption")).content() @@ -291,6 +300,157 @@ async def test_make_invoice(node_factory, get_plugin, nostr_client): # noqa: F8 ) +@pytest.mark.asyncio +async def test_make_offer(node_factory, get_plugin, nostr_client): # noqa: F811 + nostr_client, relay_port = nostr_client + url = f"127.0.0.1:{relay_port}" + options = { + "log-level": "debug", + "plugin": get_plugin, + "nip47-relays": f"ws://{url}", + } + l1 = node_factory.get_node( + options=options, + ) + uri_str = l1.rpc.call("nip47-create", ["test1", 3000])["uri"] + LOGGER.info(uri_str) + uri = NostrWalletConnectUri.parse(uri_str) + content = { + "method": "make_offer", + "params": { + "absolute_expiry": 1762986599, + "amount": 3000, + "description": "test1", + "issuer": "me :)", + }, + } + content = json.dumps(content) + signer = NostrSigner.keys(Keys(uri.secret())) + encrypted_content = await signer.nip04_encrypt(uri.public_key(), content) + event = ( + await EventBuilder(Kind(23194), encrypted_content) + .tags([Tag.public_key(uri.public_key())]) + .sign(signer) + ) + client = Client(signer) + await client.add_relay(RelayUrl.parse(f"ws://{url}")) + await client.connect() + await client.send_event(event) + + response_filter = Filter().kind(Kind(23195)).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 + error_events = [] + success_events = [] + for event in events.to_vec(): + LOGGER.info(event) + content = await signer.nip04_decrypt(uri.public_key(), event.content()) + content = json.loads(content) + assert content["result_type"] == "make_offer" + if "result" in content and content["result"] is not None: + success_events.append(content) + if "error" in content and content["error"] is not None: + error_events.append(content) + + assert len(success_events) == 1 + assert len(error_events) == 0 + + node_offer = l1.rpc.call("decode", [success_events[0]["result"]["offer"]]) + assert node_offer["offer_amount_msat"] == 3000 + assert node_offer["offer_absolute_expiry"] == 1762986599 + assert node_offer["offer_description"] == "test1" + assert node_offer["offer_issuer"] == "me :)" + assert success_events[0]["result"]["amount"] == 3000 + assert success_events[0]["result"]["description"] == "test1" + assert success_events[0]["result"]["expires_at"] == 1762986599 + assert success_events[0]["result"]["issuer"] == "me :)" + + +@pytest.mark.asyncio +async def test_get_offer_info(node_factory, get_plugin, nostr_client): # noqa: F811 + nostr_client, relay_port = nostr_client + url = f"127.0.0.1:{relay_port}" + opts = [ + { + "log-level": "debug", + "plugin": get_plugin, + "nip47-relays": f"ws://{url}", + }, + {"log-level": "debug"}, + ] + l1, l2 = node_factory.get_nodes( + 2, + opts=opts, + ) + + timestamp = int(time.time()) + offer = l2.rpc.call( + "offer", + { + "amount": "1000", + "description": "test1", + "absolute_expiry": timestamp + 4000, + "issuer": "me :)", + }, + ) + uri_str = l1.rpc.call("nip47-create", ["test1", 3000])["uri"] + LOGGER.info(uri_str) + uri = NostrWalletConnectUri.parse(uri_str) + content = { + "method": "get_offer_info", + "params": { + "offer": offer["bolt12"], + }, + } + content = json.dumps(content) + signer = NostrSigner.keys(Keys(uri.secret())) + encrypted_content = await signer.nip04_encrypt(uri.public_key(), content) + event = ( + await EventBuilder(Kind(23194), encrypted_content) + .tags([Tag.public_key(uri.public_key())]) + .sign(signer) + ) + client = Client(signer) + await client.add_relay(RelayUrl.parse(f"ws://{url}")) + await client.connect() + await client.send_event(event) + + response_filter = Filter().kind(Kind(23195)).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 + error_events = [] + success_events = [] + for event in events.to_vec(): + LOGGER.info(event) + content = await signer.nip04_decrypt(uri.public_key(), event.content()) + content = json.loads(content) + assert content["result_type"] == "get_offer_info" + if "result" in content and content["result"] is not None: + success_events.append(content) + if "error" in content and content["error"] is not None: + error_events.append(content) + + assert len(success_events) == 1 + assert len(error_events) == 0 + + assert success_events[0]["result"]["amount"] == 1000 + assert success_events[0]["result"]["description"] == "test1" + assert success_events[0]["result"]["expires_at"] == timestamp + 4000 + assert success_events[0]["result"]["issuer"] == "me :)" + + @pytest.mark.asyncio async def test_pay_keysend(node_factory, get_plugin, nostr_client): # noqa: F811 nostr_client, relay_port = nostr_client @@ -966,21 +1126,238 @@ async def test_pay_invoice(node_factory, get_plugin, nostr_client): # noqa: F81 ) +@pytest.mark.asyncio +async def test_pay_offer(node_factory, get_plugin, nostr_client): # noqa: F811 + nostr_client, relay_port = nostr_client + url = f"127.0.0.1:{relay_port}" + opts = [ + { + "log-level": "debug", + "plugin": get_plugin, + "nip47-relays": f"ws://{url}", + }, + {"log-level": "debug"}, + ] + l1, l2 = node_factory.line_graph( + 2, + wait_for_announce=True, + opts=opts, + ) + uri_str = l1.rpc.call("nip47-create", ["test1", 3001])["uri"] + LOGGER.info(uri_str) + offer1 = l2.rpc.call( + "offer", + {"amount": 3000, "description": "test1"}, + ) + uri = NostrWalletConnectUri.parse(uri_str) + signer = NostrSigner.keys(Keys(uri.secret())) + client = Client(signer) + await client.add_relay(RelayUrl.parse(f"ws://{url}")) + await client.connect() + + event_count = 1 + content = { + "method": "pay_offer", + "params": { + "offer": offer1["bolt12"], + }, + } + json_content = json.dumps(content) + (success_events, error_events, event_count) = await custom_nwc_method( + json_content, uri, client, event_count + ) + assert len(success_events) == 1 + + pay = l1.rpc.call("listpays", {})["pays"][0] + assert success_events[0]["result"]["preimage"] == pay["preimage"] + + offer2 = l2.rpc.call( + "offer", + {"amount": "any", "description": "test2"}, + ) + + content_err = { + "method": "pay_offer", + "params": { + "offer": offer2["bolt12"], + }, + } + json_content_err = json.dumps(content_err) + assert event_count == 2 + (success_events, error_events, event_count) = await custom_nwc_method( + json_content_err, uri, client, event_count + ) + assert "amount_msat parameter required" in error_events[0]["error"]["message"] + + content2 = { + "method": "pay_offer", + "params": { + "offer": offer2["bolt12"], + "amount": 1, + }, + } + json_content2 = json.dumps(content2) + (success_events, error_events, event_count) = await custom_nwc_method( + json_content2, uri, client, event_count + ) + pay = l1.rpc.call("listpays", {})["pays"][1] + assert success_events[0]["result"]["preimage"] == pay["preimage"] + + (success_events, error_events, event_count) = await custom_nwc_method( + json_content2, uri, client, event_count + ) + assert "Payment exceeds budget" in error_events[0]["error"]["message"] + + (success_events, error_events, event_count) = await custom_nwc_method( + json_content, uri, client, event_count + ) + assert "Payment exceeds budget" in error_events[0]["error"]["message"] + + +@pytest.mark.asyncio +async def test_multi_pay_offer(node_factory, get_plugin, nostr_client): # noqa: F811 + nostr_client, relay_port = nostr_client + url = f"127.0.0.1:{relay_port}" + opts = [ + { + "log-level": "debug", + "plugin": get_plugin, + "nip47-relays": f"ws://{url}", + }, + {"log-level": "debug"}, + ] + l1, l2 = node_factory.line_graph( + 2, + wait_for_announce=True, + opts=opts, + ) + uri_str = l1.rpc.call("nip47-create", ["test1", 30000])["uri"] + LOGGER.info(uri_str) + uri = NostrWalletConnectUri.parse(uri_str) + offer1 = l2.rpc.call( + "offer", + {"description": "test1", "amount": 3000}, + ) + offer2 = l2.rpc.call( + "offer", + {"description": "test2", "amount": 4000}, + ) + offer3 = l2.rpc.call( + "offer", + { + "description": "test3", + "amount": 23001, + }, + ) + content = { + "method": "multi_pay_offer", + "params": { + "offers": [ + {"id": "4da52c32a1", "offer": offer1["bolt12"]}, + {"id": "3da52c32a1", "offer": offer2["bolt12"]}, + {"id": "af3g2k2o11", "offer": offer3["bolt12"]}, + ], + }, + } + content = json.dumps(content) + signer = NostrSigner.keys(Keys(uri.secret())) + encrypted_content = await signer.nip44_encrypt(uri.public_key(), content) + event = ( + await EventBuilder(Kind(23194), encrypted_content) + .tags([Tag.public_key(uri.public_key())]) + .sign(signer) + ) + client = Client(signer) + await client.add_relay(RelayUrl.parse(f"ws://{url}")) + await client.connect() + await client.send_event(event) + + response_filter = Filter().kind(Kind(23195)).author(uri.public_key()) + events = await client.fetch_events(response_filter, timeout=timedelta(seconds=10)) + start_time = datetime.now() + while events.len() < 3 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() == 3 + success_pays = [] + error_pays = [] + for event in events.to_vec(): + LOGGER.info(event) + d_tag = event.tags().find( + TagKind.SINGLE_LETTER(SingleLetterTag.lowercase(Alphabet.D)) + ) + content = await signer.nip44_decrypt(uri.public_key(), event.content()) + content = json.loads(content) + assert content["result_type"] == "multi_pay_offer" + if "result" in content and content["result"] is not None: + assert d_tag is not None + assert content["result"]["preimage"] is not None + success_pays.append(content) + if "error" in content and content["error"] is not None: + assert d_tag.content() == "af3g2k2o11" + assert content["error"]["code"] == "QUOTA_EXCEEDED" + assert content["error"]["message"] == "Payment exceeds budget!" + error_pays.append(content) + assert len(success_pays) == 2 + assert len(error_pays) == 1 + + +async def custom_nwc_method( + json_content: str, uri: NostrWalletConnectUri, client: Client, event_count: int +) -> tuple[list, list, int]: + signer = NostrSigner.keys(Keys(uri.secret())) + encrypted_content = await signer.nip04_encrypt(uri.public_key(), json_content) + event = ( + await EventBuilder(Kind(23194), encrypted_content) + .tags([Tag.public_key(uri.public_key())]) + .sign(signer) + ) + await client.send_event(event) + + response_filter = Filter().kind(Kind(23195)).author(uri.public_key()) + events = await client.fetch_events(response_filter, timeout=timedelta(seconds=10)) + start_time = datetime.now() + while events.len() < event_count 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() == event_count + error_events = [] + success_events = [] + for event in events.to_vec(): + LOGGER.info(event) + content = await signer.nip04_decrypt(uri.public_key(), event.content()) + content = json.loads(content) + if "result" in content and content["result"] is not None: + success_events.append(content) + if "error" in content and content["error"] is not None: + error_events.append(content) + + event_count += 1 + return (success_events, error_events, event_count) + + @pytest.mark.asyncio async def test_multi_pay(node_factory, get_plugin, nostr_client): # noqa: F811 nostr_client, relay_port = nostr_client url = f"127.0.0.1:{relay_port}" + opts = [ + { + "log-level": "debug", + "plugin": get_plugin, + "nip47-relays": f"ws://{url}", + }, + {"log-level": "debug"}, + ] 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"}, - ], + opts=opts, ) uri_str = l1.rpc.call("nip47-create", ["test1", 30000])["uri"] LOGGER.info(uri_str) @@ -1254,6 +1631,8 @@ async def test_budget_command(node_factory, get_plugin, nostr_client): # noqa: Method.MULTI_PAY_INVOICE, Method.PAY_KEYSEND, Method.MULTI_PAY_KEYSEND, + "make_offer", + "lookup_offer", ] signer = NostrSigner.keys(Keys(uri.secret())) @@ -1273,7 +1652,7 @@ async def test_budget_command(node_factory, get_plugin, nostr_client): # noqa: events_vec = events.to_vec() assert ( events_vec[0].content() - == "make_invoice lookup_invoice list_transactions get_balance get_info pay_invoice multi_pay_invoice pay_keysend multi_pay_keysend notifications" + == "make_invoice lookup_invoice list_transactions get_balance get_info pay_invoice multi_pay_invoice pay_keysend multi_pay_keysend make_offer lookup_offer notifications" ) assert ( events_vec[0].tags().find(TagKind.UNKNOWN("encryption")).content() @@ -1300,6 +1679,8 @@ async def test_budget_command(node_factory, get_plugin, nostr_client): # noqa: Method.LIST_TRANSACTIONS, Method.GET_BALANCE, Method.GET_INFO, + "make_offer", + "lookup_offer", ] events = await client.fetch_events(response_filter, timeout=timedelta(seconds=10)) @@ -1313,7 +1694,7 @@ async def test_budget_command(node_factory, get_plugin, nostr_client): # noqa: events_vec = events.to_vec() assert ( events_vec[0].content() - == "make_invoice lookup_invoice list_transactions get_balance get_info notifications" + == "make_invoice lookup_invoice list_transactions get_balance get_info make_offer lookup_offer notifications" ) assert ( events_vec[0].tags().find(TagKind.UNKNOWN("encryption")).content() From a5cc510060e4f4147f22870320bd1d8d05f57f40 Mon Sep 17 00:00:00 2001 From: daywalker90 <8257956+daywalker90@users.noreply.github.com> Date: Thu, 13 Nov 2025 01:00:39 +0100 Subject: [PATCH 2/2] some small payer_note and amount fixes --- Cargo.lock | 10 ++--- src/nwc_notifications.rs | 94 ++++++++++++++++++---------------------- src/nwc_offer.rs | 75 ++++++++++++++++++++++++-------- tests/test_cln-nip47.py | 6 +++ 4 files changed, 109 insertions(+), 76 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index fe7af78..37caf43 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -807,7 +807,7 @@ checksum = "f0efe882e02d206d8d279c20eb40e03baf7cb5136a1476dc084a324fbc3ec42d" [[package]] name = "nostr" version = "0.44.0" -source = "git+https://github.com/daywalker90/nostr.git?branch=bolt12#829d51d2594ef14dc1499b8961514b671c27bf05" +source = "git+https://github.com/daywalker90/nostr.git?branch=bolt12#f5acfd7b4651608a03712365dfb150855cef663c" dependencies = [ "aes", "base64", @@ -831,7 +831,7 @@ dependencies = [ [[package]] name = "nostr-database" version = "0.44.0" -source = "git+https://github.com/daywalker90/nostr.git?branch=bolt12#829d51d2594ef14dc1499b8961514b671c27bf05" +source = "git+https://github.com/daywalker90/nostr.git?branch=bolt12#f5acfd7b4651608a03712365dfb150855cef663c" dependencies = [ "lru", "nostr", @@ -841,7 +841,7 @@ dependencies = [ [[package]] name = "nostr-gossip" version = "0.44.0" -source = "git+https://github.com/daywalker90/nostr.git?branch=bolt12#829d51d2594ef14dc1499b8961514b671c27bf05" +source = "git+https://github.com/daywalker90/nostr.git?branch=bolt12#f5acfd7b4651608a03712365dfb150855cef663c" dependencies = [ "nostr", ] @@ -849,7 +849,7 @@ dependencies = [ [[package]] name = "nostr-relay-pool" version = "0.44.0" -source = "git+https://github.com/daywalker90/nostr.git?branch=bolt12#829d51d2594ef14dc1499b8961514b671c27bf05" +source = "git+https://github.com/daywalker90/nostr.git?branch=bolt12#f5acfd7b4651608a03712365dfb150855cef663c" dependencies = [ "async-utility", "async-wsocket", @@ -866,7 +866,7 @@ dependencies = [ [[package]] name = "nostr-sdk" version = "0.44.0" -source = "git+https://github.com/daywalker90/nostr.git?branch=bolt12#829d51d2594ef14dc1499b8961514b671c27bf05" +source = "git+https://github.com/daywalker90/nostr.git?branch=bolt12#f5acfd7b4651608a03712365dfb150855cef663c" dependencies = [ "async-utility", "nostr", diff --git a/src/nwc_notifications.rs b/src/nwc_notifications.rs index 7fcea01..32aefaa 100644 --- a/src/nwc_notifications.rs +++ b/src/nwc_notifications.rs @@ -167,6 +167,9 @@ fn make_payment_received_from_listinvoices( settled_at, metadata: None, state: Some(state), + offer_issuer: invoice_decoded.offer_issuer, + payer_note: invoice_decoded.invreq_payer_note, + offer_id: invoice_decoded.offer_id, }), }; @@ -236,9 +239,6 @@ async fn make_payment_sent_from_listpays( &String::new() }; - let description; - let description_hash; - let amount; let created_at = Timestamp::from_secs(pay.created_at); let preimage = hex::encode( pay.preimage @@ -247,59 +247,46 @@ async fn make_payment_sent_from_listpays( ); let settled_at = Timestamp::from_secs(pay.completed_at.unwrap()); - if invstring.is_empty() { - description = pay.description.clone(); - description_hash = None; - amount = if let Some(amt) = pay.amount_msat { - amt.msat() - } else { - // Amount missing but required - 0 - } - } else { - let invoice_decoded = rpc - .call_typed(&DecodeRequest { - string: invstring.clone(), - }) - .await?; + let invoice_decoded = rpc + .call_typed(&DecodeRequest { + string: invstring.clone(), + }) + .await?; - let not_invoice_err = Err(anyhow!(NOT_INV_ERR.to_owned())); + let not_invoice_err = Err(anyhow!(NOT_INV_ERR.to_owned())); - if !invoice_decoded.valid { - return not_invoice_err; - } + if !invoice_decoded.valid { + return not_invoice_err; + } - description = match invoice_decoded.item_type { - cln_rpc::model::responses::DecodeType::BOLT12_INVOICE => { - invoice_decoded.offer_description - } - cln_rpc::model::responses::DecodeType::BOLT11_INVOICE => invoice_decoded.description, - _ => return not_invoice_err, - }; - description_hash = match invoice_decoded.item_type { - cln_rpc::model::responses::DecodeType::BOLT12_INVOICE => None, - cln_rpc::model::responses::DecodeType::BOLT11_INVOICE => { - invoice_decoded.description_hash.map(|h| h.to_string()) - } - _ => return not_invoice_err, - }; - amount = match invoice_decoded.item_type { - cln_rpc::model::responses::DecodeType::BOLT12_INVOICE => { - invoice_decoded.invoice_amount_msat.unwrap().msat() - } - cln_rpc::model::responses::DecodeType::BOLT11_INVOICE => { - if let Some(amt) = invoice_decoded.amount_msat { - amt.msat() - } else if let Some(a) = pay.amount_msat { - a.msat() - } else { - // amount: `any` but have to put a value... - 0 - } + let description = match invoice_decoded.item_type { + cln_rpc::model::responses::DecodeType::BOLT12_INVOICE => invoice_decoded.offer_description, + cln_rpc::model::responses::DecodeType::BOLT11_INVOICE => invoice_decoded.description, + _ => return not_invoice_err, + }; + let description_hash = match invoice_decoded.item_type { + cln_rpc::model::responses::DecodeType::BOLT12_INVOICE => None, + cln_rpc::model::responses::DecodeType::BOLT11_INVOICE => { + invoice_decoded.description_hash.map(|h| h.to_string()) + } + _ => return not_invoice_err, + }; + let amount = match invoice_decoded.item_type { + cln_rpc::model::responses::DecodeType::BOLT12_INVOICE => { + invoice_decoded.invoice_amount_msat.unwrap().msat() + } + cln_rpc::model::responses::DecodeType::BOLT11_INVOICE => { + if let Some(amt) = invoice_decoded.amount_msat { + amt.msat() + } else if let Some(a) = pay.amount_msat { + a.msat() + } else { + // amount: `any` but have to put a value... + 0 } - _ => return not_invoice_err, - }; - } + } + _ => return not_invoice_err, + }; let fees_paid = if let Some(amt_sent) = pay.amount_sent_msat { amt_sent.msat() - amount @@ -329,6 +316,9 @@ async fn make_payment_sent_from_listpays( settled_at, metadata: None, state: Some(state), + offer_issuer: invoice_decoded.offer_issuer, + payer_note: invoice_decoded.invreq_payer_note, + offer_id: invoice_decoded.offer_id, }), }; diff --git a/src/nwc_offer.rs b/src/nwc_offer.rs index 5bc41e0..cdee5f6 100644 --- a/src/nwc_offer.rs +++ b/src/nwc_offer.rs @@ -4,7 +4,7 @@ use cln_plugin::Plugin; use cln_rpc::{ model::{ requests::{DecodeRequest, FetchinvoiceRequest, OfferRequest, PayRequest, XpayRequest}, - responses::DecodeResponse, + responses::{DecodeResponse, FetchinvoiceResponse}, }, primitives::{Amount, Secret}, ClnRpc, @@ -196,11 +196,13 @@ async fn pay_offer( ) -> Result<(nip47::PayOfferResponse, Option), (nip47::NIP47Error, Option)> { let mut rpc = plugin.state().rpc_lock.lock().await; - let decoded_invoice = fetch_invoice(&mut rpc, ¶ms).await?; + let fetch_invoice_response = fetch_invoice(&mut rpc, ¶ms).await?; - let id = get_payment_id(¶ms, &decoded_invoice)?; + let decoded_bolt12 = decode_bolt12_invoice(&mut rpc, ¶ms, &fetch_invoice_response).await?; - let invoice_amt_msat = get_invoice_amount_msat(&decoded_invoice, &id)?; + let id = get_payment_id(¶ms, &decoded_bolt12)?; + + let invoice_amt_msat = get_invoice_amount_msat(&decoded_bolt12, ¶ms, &id)?; let nwc_store = load_nwc_and_check_budget(&mut rpc, label, ¶ms, invoice_amt_msat, &id).await?; @@ -209,22 +211,36 @@ async fn pay_offer( let use_xpay = check_cln_version(&my_version, &id)?; if use_xpay { - pay_with_xpay_full(&mut rpc, params, label, nwc_store, &id).await + pay_with_xpay_full( + &mut rpc, + fetch_invoice_response.invoice, + label, + nwc_store, + &id, + ) + .await } else { - pay_with_legacy_full(&mut rpc, params, label, nwc_store, &id).await + pay_with_legacy_full( + &mut rpc, + fetch_invoice_response.invoice, + label, + nwc_store, + &id, + ) + .await } } async fn fetch_invoice( rpc: &mut ClnRpc, params: &nip47::PayOfferRequest, -) -> Result)> { +) -> Result)> { let bolt12_invoice = rpc .call_typed(&FetchinvoiceRequest { amount_msat: params.amount.map(Amount::from_msat), bip353: None, payer_metadata: None, - payer_note: None, + payer_note: params.payer_note.clone(), quantity: None, recurrence_counter: None, recurrence_label: None, @@ -242,9 +258,17 @@ async fn fetch_invoice( params.id.clone(), ) })?; + Ok(bolt12_invoice) +} + +async fn decode_bolt12_invoice( + rpc: &mut ClnRpc, + params: &nip47::PayOfferRequest, + fetch_invoice_resp: &FetchinvoiceResponse, +) -> Result)> { let invoice_decoded = rpc .call_typed(&DecodeRequest { - string: bolt12_invoice.invoice.clone(), + string: fetch_invoice_resp.invoice.clone(), }) .await .map_err(|e| { @@ -256,7 +280,6 @@ async fn fetch_invoice( params.id.clone(), ) })?; - Ok(invoice_decoded) } @@ -286,10 +309,11 @@ fn get_payment_id( } fn get_invoice_amount_msat( - decoded_invoice: &DecodeResponse, + invoice_decoded: &DecodeResponse, + params: &nip47::PayOfferRequest, id: &str, ) -> Result)> { - decoded_invoice + let amt = invoice_decoded .invoice_amount_msat .as_ref() .ok_or_else(|| { @@ -301,7 +325,20 @@ fn get_invoice_amount_msat( Some(id.to_owned()), ) }) - .map(Amount::msat) + .map(Amount::msat)?; + if let Some(a) = params.amount { + if amt != a { + return Err(( + nip47::NIP47Error { + code: nip47::ErrorCode::Internal, + message: "amount in decoded bolt12 invoice does not match amount in request" + .to_owned(), + }, + Some(id.to_owned()), + )); + } + } + Ok(amt) } async fn load_nwc_and_check_budget( @@ -450,20 +487,20 @@ fn map_cln_error_to_nip47( async fn pay_with_xpay_full( rpc: &mut ClnRpc, - params: nip47::PayOfferRequest, + bolt12_invoice: String, label: &str, mut nwc_store: NwcStore, id: &str, ) -> Result<(nip47::PayOfferResponse, Option), (nip47::NIP47Error, Option)> { let payment_result = rpc .call_typed(&XpayRequest { - amount_msat: params.amount.map(Amount::from_msat), + amount_msat: None, maxdelay: None, maxfee: None, partial_msat: None, retry_for: None, layers: None, - invstring: params.offer, + invstring: bolt12_invoice, }) .await .map_err(|e| map_cln_error_to_nip47(&e, id, true))?; @@ -486,14 +523,14 @@ async fn pay_with_xpay_full( async fn pay_with_legacy_full( rpc: &mut ClnRpc, - params: nip47::PayOfferRequest, + bolt12_invoice: String, label: &str, mut nwc_store: NwcStore, id: &str, ) -> Result<(nip47::PayOfferResponse, Option), (nip47::NIP47Error, Option)> { let payment_result = rpc .call_typed(&PayRequest { - amount_msat: params.amount.map(Amount::from_msat), + amount_msat: None, description: None, exemptfee: None, label: None, @@ -505,7 +542,7 @@ async fn pay_with_legacy_full( retry_for: None, riskfactor: None, exclude: None, - bolt11: params.offer, + bolt11: bolt12_invoice, }) .await .map_err(|e| map_cln_error_to_nip47(&e, id, false))?; diff --git a/tests/test_cln-nip47.py b/tests/test_cln-nip47.py index 6d1f519..10513ab 100755 --- a/tests/test_cln-nip47.py +++ b/tests/test_cln-nip47.py @@ -1160,6 +1160,8 @@ async def test_pay_offer(node_factory, get_plugin, nostr_client): # noqa: F811 "method": "pay_offer", "params": { "offer": offer1["bolt12"], + "amount": 3000, + "payer_note": "for pizza", }, } json_content = json.dumps(content) @@ -1169,6 +1171,10 @@ async def test_pay_offer(node_factory, get_plugin, nostr_client): # noqa: F811 assert len(success_events) == 1 pay = l1.rpc.call("listpays", {})["pays"][0] + decoded_pay_inv = l2.rpc.call( + "listinvoices", {"payment_hash": pay["payment_hash"]} + )["invoices"][0] + assert decoded_pay_inv["invreq_payer_note"] == "for pizza" assert success_events[0]["result"]["preimage"] == pay["preimage"] offer2 = l2.rpc.call(