From eab3ddc29ad485b0d6c255300cc1c10529060af4 Mon Sep 17 00:00:00 2001 From: elnosh Date: Sun, 21 Apr 2024 12:39:52 -0500 Subject: [PATCH] add list_transactions nwc command --- mutiny-core/src/lib.rs | 81 ++++- mutiny-core/src/node.rs | 18 +- mutiny-core/src/nodemanager.rs | 6 +- mutiny-core/src/nostr/mod.rs | 1 - mutiny-core/src/nostr/nwc.rs | 554 +++++++++++++++++++++------------ mutiny-wasm/src/lib.rs | 12 +- mutiny-wasm/src/models.rs | 7 + 7 files changed, 458 insertions(+), 221 deletions(-) diff --git a/mutiny-core/src/lib.rs b/mutiny-core/src/lib.rs index 2394f08b3..50b611a20 100644 --- a/mutiny-core/src/lib.rs +++ b/mutiny-core/src/lib.rs @@ -84,7 +84,7 @@ use ::nostr::nips::nip47::Method; use ::nostr::nips::nip57; #[cfg(target_arch = "wasm32")] use ::nostr::prelude::rand::rngs::OsRng; -use ::nostr::prelude::ZapRequestData; +use ::nostr::prelude::{LookupInvoiceResponseResult, TransactionType, ZapRequestData}; #[cfg(target_arch = "wasm32")] use ::nostr::Tag; use ::nostr::{EventBuilder, EventId, JsonUtil, Keys, Kind}; @@ -145,6 +145,7 @@ pub trait InvoiceHandler { fn get_network(&self) -> Network; async fn get_best_block(&self) -> Result; async fn lookup_payment(&self, payment_hash: &[u8; 32]) -> Option; + async fn get_payments_by_label(&self, label: &str) -> Result, MutinyError>; async fn pay_invoice( &self, invoice: &Bolt11Invoice, @@ -161,7 +162,7 @@ pub trait InvoiceHandler { &self, to_node: PublicKey, amt_sats: u64, - message: Option, + custom_tlvs: Vec, labels: Vec, preimage: Option<[u8; 32]>, ) -> Result; @@ -173,6 +174,11 @@ pub struct LnUrlParams { pub tag: String, } +pub struct CustomTLV { + pub tlv_type: u64, + pub value: String, +} + /// Plan is a subscription plan for Mutiny+ #[derive(Serialize, Deserialize, Clone, Debug)] pub struct Plan { @@ -426,6 +432,60 @@ impl From for MutinyInvoice { } } +impl From for LookupInvoiceResponseResult { + fn from(invoice: MutinyInvoice) -> Self { + let transaction_type = if invoice.inbound { + Some(TransactionType::Incoming) + } else { + Some(TransactionType::Outgoing) + }; + + let (description, description_hash) = match invoice.bolt11.as_ref() { + None => (None, None), + Some(invoice) => match invoice.description() { + Bolt11InvoiceDescription::Direct(desc) => (Some(desc.to_string()), None), + Bolt11InvoiceDescription::Hash(hash) => (None, Some(hash.0.to_string())), + }, + }; + + // try to get created_at from invoice, + // if it is not set, use last_updated as that's our closest approximation + let created_at = invoice + .bolt11 + .as_ref() + .map(|b| b.duration_since_epoch().as_secs()) + .unwrap_or(invoice.last_updated); + + let settled_at = if invoice.status == HTLCStatus::Succeeded { + Some(invoice.last_updated) + } else { + None + }; + + // only reveal preimage if it is settled + let preimage = if invoice.status == HTLCStatus::Succeeded { + invoice.preimage + } else { + None + }; + + LookupInvoiceResponseResult { + transaction_type, + invoice: invoice.bolt11.map(|i| i.to_string()), + description, + description_hash, + preimage, + payment_hash: invoice.payment_hash.into_32().to_lower_hex_string(), + amount: invoice.amount_sats.map(|a| a * 1_000).unwrap_or(0), + fees_paid: invoice.fees_paid.map(|a| a * 1_000).unwrap_or(0), + created_at, + expires_at: invoice.expire, + settled_at, + metadata: Default::default(), + } + } +} + impl From for PaymentInfo { fn from(invoice: MutinyInvoice) -> Self { let preimage: Option<[u8; 32]> = invoice @@ -3104,6 +3164,19 @@ impl InvoiceHandler for MutinyWallet { .ok() } + async fn get_payments_by_label(&self, label: &str) -> Result, MutinyError> { + let label_activity = self.get_label_activity(&label.to_string()).await?; + let mut invoices: Vec = Vec::with_capacity(label.len()); + + for item in label_activity { + if let ActivityItem::Lightning(mutiny_invoice) = item { + invoices.push(*mutiny_invoice); + } + } + + Ok(invoices) + } + async fn pay_invoice( &self, invoice: &Bolt11Invoice, @@ -3125,12 +3198,12 @@ impl InvoiceHandler for MutinyWallet { &self, to_node: PublicKey, amt_sats: u64, - message: Option, + custom_tlvs: Vec, labels: Vec, preimage: Option<[u8; 32]>, ) -> Result { self.node_manager - .keysend(None, to_node, amt_sats, message, labels, preimage) + .keysend(None, to_node, amt_sats, custom_tlvs, labels, preimage) .await } } diff --git a/mutiny-core/src/node.rs b/mutiny-core/src/node.rs index e031f0b9b..9f6cb7f66 100644 --- a/mutiny-core/src/node.rs +++ b/mutiny-core/src/node.rs @@ -19,7 +19,7 @@ use crate::{ onchain::OnChainWallet, peermanager::{GossipMessageHandler, PeerManagerImpl}, utils::{self, sleep}, - MutinyInvoice, PrivacyLevel, + CustomTLV, MutinyInvoice, PrivacyLevel, }; use crate::{fees::P2WSH_OUTPUT_SIZE, peermanager::connect_peer_if_necessary}; use crate::{keymanager::PhantomKeysManager, scorer::HubPreferentialScorer}; @@ -1614,7 +1614,7 @@ impl Node { &self, to_node: PublicKey, amt_sats: u64, - message: Option, + custom_tlvs: Vec, labels: Vec, payment_id: PaymentId, preimage: Option<[u8; 32]>, @@ -1673,10 +1673,14 @@ impl Node { max_total_routing_fee_msat: None, }; - let recipient_onion = if let Some(msg) = message { - // keysend messages are encoded as TLV type 34349334 + let recipient_onion = if !custom_tlvs.is_empty() { + let custom_tlvs: Vec<(u64, Vec)> = custom_tlvs + .into_iter() + .map(|tlv| (tlv.tlv_type, tlv.value.encode())) + .collect(); + RecipientOnionFields::secret_only(payment_secret) - .with_custom_tlvs(vec![(34349334, msg.encode())]) + .with_custom_tlvs(custom_tlvs) .map_err(|_| { log_error!(self.logger, "could not encode keysend message"); MutinyError::InvoiceCreationFailed @@ -1739,7 +1743,7 @@ impl Node { &self, to_node: PublicKey, amt_sats: u64, - message: Option, + custom_tlvs: Vec, labels: Vec, timeout_secs: Option, preimage: Option<[u8; 32]>, @@ -1753,7 +1757,7 @@ impl Node { .init_keysend_payment( to_node, amt_sats, - message, + custom_tlvs, labels.clone(), payment_id, preimage, diff --git a/mutiny-core/src/nodemanager.rs b/mutiny-core/src/nodemanager.rs index dc707f5bb..20abeee7a 100644 --- a/mutiny-core/src/nodemanager.rs +++ b/mutiny-core/src/nodemanager.rs @@ -3,7 +3,6 @@ use crate::labels::LabelStorage; use crate::ldkstorage::CHANNEL_CLOSURE_PREFIX; use crate::logging::LOGGING_KEY; use crate::utils::{sleep, spawn}; -use crate::MutinyInvoice; use crate::MutinyWalletConfig; use crate::{ chain::MutinyChain, @@ -23,6 +22,7 @@ use crate::{ node::NodeBuilder, storage::{MutinyStorage, DEVICE_ID_KEY, KEYCHAIN_STORE_KEY, NEED_FULL_SYNC_KEY}, }; +use crate::{CustomTLV, MutinyInvoice}; use anyhow::anyhow; use async_lock::RwLock; use bdk::chain::{BlockId, ConfirmationTime}; @@ -1406,13 +1406,13 @@ impl NodeManager { self_node_pubkey: Option<&PublicKey>, to_node: PublicKey, amt_sats: u64, - message: Option, + custom_tlvs: Vec, labels: Vec, preimage: Option<[u8; 32]>, ) -> Result { let node = self.get_node_by_key_or_first(self_node_pubkey).await?; log_debug!(self.logger, "Keysending to {to_node}"); - node.keysend_with_timeout(to_node, amt_sats, message, labels, None, preimage) + node.keysend_with_timeout(to_node, amt_sats, custom_tlvs, labels, None, preimage) .await } diff --git a/mutiny-core/src/nostr/mod.rs b/mutiny-core/src/nostr/mod.rs index 553879424..42f4c88c3 100644 --- a/mutiny-core/src/nostr/mod.rs +++ b/mutiny-core/src/nostr/mod.rs @@ -1541,7 +1541,6 @@ impl NostrManager { self.storage.set_nwc_sync_time(event.created_at.as_u64())?; - // TODO: handle nwc response here if let Some(mut nwc) = nwc { nwc.handle_nwc_request(event, invoice_handler, self).await?; } diff --git a/mutiny-core/src/nostr/nwc.rs b/mutiny-core/src/nostr/nwc.rs index 982221f71..3dbec4167 100644 --- a/mutiny-core/src/nostr/nwc.rs +++ b/mutiny-core/src/nostr/nwc.rs @@ -5,8 +5,8 @@ use crate::nostr::nip49::NIP49Confirmation; use crate::nostr::primal::PrimalApi; use crate::nostr::{derive_nwc_keys, NostrManager}; use crate::storage::MutinyStorage; -use crate::utils; -use crate::InvoiceHandler; +use crate::{utils, MutinyInvoice}; +use crate::{CustomTLV, InvoiceHandler}; use anyhow::anyhow; use bitcoin::bip32::ExtendedPrivKey; use bitcoin::hashes::hex::FromHex; @@ -21,7 +21,7 @@ use itertools::Itertools; use lightning::ln::{PaymentHash, PaymentPreimage}; use lightning::util::logger::Logger; use lightning::{log_error, log_info, log_warn}; -use lightning_invoice::{Bolt11Invoice, Bolt11InvoiceDescription}; +use lightning_invoice::Bolt11Invoice; use nostr::nips::nip04::{decrypt, encrypt}; use nostr::nips::nip47::*; use nostr::{Event, EventBuilder, EventId, Filter, JsonUtil, Keys, Kind, Tag, Timestamp}; @@ -146,6 +146,11 @@ pub enum NwcResponse { MultiEvent(Vec), } +struct PayInvoiceRequest { + params: PayInvoiceRequestParams, + is_multi_pay: bool, +} + /// Type of Nostr Wallet Connect profile #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] pub enum NwcProfileTag { @@ -504,19 +509,23 @@ impl NostrWalletConnect { .await? } RequestParams::MakeInvoice(params) => { - self.handle_make_invoice_request(event, node, nostr_manager, params) + self.handle_make_invoice_request(event, node, &nostr_manager.client, params) .await? } RequestParams::LookupInvoice(params) => { - self.handle_lookup_invoice_request(event, node, nostr_manager, params) + self.handle_lookup_invoice_request(event, node, &nostr_manager.client, params) + .await? + } + RequestParams::ListTransactions(params) => { + self.handle_list_transactions(event, node, &nostr_manager.client, params) .await? } RequestParams::GetBalance => { - self.handle_get_balance_request(event, nostr_manager) + self.handle_get_balance_request(event, &nostr_manager.client) .await? } RequestParams::GetInfo => { - self.handle_get_info_request(event, node, nostr_manager) + self.handle_get_info_request(event, node, &nostr_manager.client) .await? } RequestParams::MultiPayInvoice(params) => { @@ -538,8 +547,6 @@ impl NostrWalletConnect { self.handle_multi_pay_keysend_request(event, node, nostr_manager, params) .await? } - // TODO: list_transactions - _ => return Err(anyhow!("Invalid request params for {}", req.method)), }; } @@ -552,11 +559,11 @@ impl NostrWalletConnect { Ok(result) } - async fn handle_get_info_request( + async fn handle_get_info_request( &self, event: Event, node: &impl InvoiceHandler, - nostr_manager: &NostrManager, + client: &impl NostrClient, ) -> anyhow::Result> { let network = match node.get_network() { Network::Bitcoin => "mainnet", @@ -590,20 +597,16 @@ impl NostrWalletConnect { }; let response_event = self.build_nwc_response_event(event, content, None)?; - if let Err(e) = nostr_manager - .client - .send_event(response_event.clone()) - .await - { + if let Err(e) = client.send_event(response_event.clone()).await { return Err(anyhow!("Error sending NWC event: {}", e)); } Ok(Some(NwcResponse::SingleEvent(response_event))) } - async fn handle_get_balance_request( + async fn handle_get_balance_request( &mut self, event: Event, - nostr_manager: &NostrManager, + client: &impl NostrClient, ) -> anyhow::Result> { // Just return our current budget amount, don't leak our actual wallet balance let balance_sats = match &self.profile.spending_conditions { @@ -627,21 +630,93 @@ impl NostrWalletConnect { }; let response_event = self.build_nwc_response_event(event, content, None)?; - if let Err(e) = nostr_manager - .client - .send_event(response_event.clone()) - .await - { + if let Err(e) = client.send_event(response_event.clone()).await { return Err(anyhow!("Error sending NWC event: {}", e)); } Ok(Some(NwcResponse::SingleEvent(response_event))) } - async fn handle_make_invoice_request( + async fn handle_list_transactions( &mut self, event: Event, node: &impl InvoiceHandler, - nostr_manager: &NostrManager, + client: &impl NostrClient, + params: ListTransactionsRequestParams, + ) -> anyhow::Result> { + let label = self + .profile + .label + .clone() + .unwrap_or(self.profile.name.clone()); + let invoices = node.get_payments_by_label(&label).await?; + + let from = params.from.unwrap_or(0); + let until = params.until.unwrap_or(utils::now().as_secs()); + let unpaid = params.unpaid.unwrap_or(false); + + let mut invoices: Vec = invoices + .into_iter() + .filter(|invoice| { + let created_at = invoice + .bolt11 + .as_ref() + .map(|b| b.duration_since_epoch().as_secs()) + .unwrap_or(invoice.last_updated); + + if unpaid { + created_at > from && created_at < until + } else { + created_at > from + && created_at < until + && invoice.status == HTLCStatus::Succeeded + } + }) + .collect(); + + if params.transaction_type.is_some() { + let incoming = params.transaction_type.unwrap() == TransactionType::Incoming; + invoices.retain(|invoice| invoice.inbound == incoming); + } + + let mut transactions = Vec::with_capacity(invoices.len()); + for invoice in invoices { + let transaction: LookupInvoiceResponseResult = invoice.into(); + transactions.push(transaction); + } + // sort in descending order by creation time + transactions.sort_by(|a, b| b.created_at.cmp(&a.created_at)); + + if params.limit.is_some() { + let limit = params.limit.unwrap() as usize; + if limit - 1 < transactions.len() { + transactions = transactions[..limit].to_vec(); + } + } + if params.offset.is_some() { + let offset = params.offset.unwrap() as usize; + if offset - 1 < transactions.len() { + transactions = transactions[offset..].to_vec(); + } + } + + let content = Response { + result_type: Method::ListTransactions, + error: None, + result: Some(ResponseResult::ListTransactions(transactions)), + }; + + let response_event = self.build_nwc_response_event(event, content, None)?; + if let Err(e) = client.send_event(response_event.clone()).await { + return Err(anyhow!("Error sending NWC event: {}", e)); + } + Ok(Some(NwcResponse::SingleEvent(response_event))) + } + + async fn handle_make_invoice_request( + &mut self, + event: Event, + node: &impl InvoiceHandler, + client: &impl NostrClient, params: MakeInvoiceRequestParams, ) -> anyhow::Result> { // FIXME currently we are ignoring the description and expiry params @@ -665,21 +740,17 @@ impl NostrWalletConnect { }; let response_event = self.build_nwc_response_event(event, content, None)?; - if let Err(e) = nostr_manager - .client - .send_event(response_event.clone()) - .await - { + if let Err(e) = client.send_event(response_event.clone()).await { return Err(anyhow!("Error sending NWC event: {}", e)); } Ok(Some(NwcResponse::SingleEvent(response_event))) } - async fn handle_lookup_invoice_request( + async fn handle_lookup_invoice_request( &mut self, event: Event, node: &impl InvoiceHandler, - nostr_manager: &NostrManager, + client: &impl NostrClient, params: LookupInvoiceRequestParams, ) -> anyhow::Result> { let invoice = match params.payment_hash { @@ -708,55 +779,7 @@ impl NostrWalletConnect { result: None, }, Some(invoice) => { - let transaction_type = if invoice.inbound { - Some(TransactionType::Incoming) - } else { - Some(TransactionType::Outgoing) - }; - - let (description, description_hash) = match invoice.bolt11.as_ref() { - None => (None, None), - Some(invoice) => match invoice.description() { - Bolt11InvoiceDescription::Direct(desc) => (Some(desc.to_string()), None), - Bolt11InvoiceDescription::Hash(hash) => (None, Some(hash.0.to_string())), - }, - }; - - // try to get created_at from invoice, - // if it is not set, use last_updated as that's our closest approximation - let created_at = invoice - .bolt11 - .as_ref() - .map(|b| b.duration_since_epoch().as_secs()) - .unwrap_or(invoice.last_updated); - - let settled_at = if invoice.status == HTLCStatus::Succeeded { - Some(invoice.last_updated) - } else { - None - }; - - // only reveal preimage if it is settled - let preimage = if invoice.status == HTLCStatus::Succeeded { - invoice.preimage - } else { - None - }; - - let result = LookupInvoiceResponseResult { - transaction_type, - invoice: invoice.bolt11.map(|i| i.to_string()), - description, - description_hash, - preimage, - payment_hash: invoice.payment_hash.into_32().to_lower_hex_string(), - amount: invoice.amount_sats.map(|a| a * 1_000).unwrap_or(0), - fees_paid: invoice.fees_paid.map(|a| a * 1_000).unwrap_or(0), - created_at, - expires_at: invoice.expire, - settled_at, - metadata: Default::default(), - }; + let result: LookupInvoiceResponseResult = invoice.into(); Response { result_type: Method::LookupInvoice, @@ -767,11 +790,7 @@ impl NostrWalletConnect { }; let response_event = self.build_nwc_response_event(event, content, None)?; - if let Err(e) = nostr_manager - .client - .send_event(response_event.clone()) - .await - { + if let Err(e) = client.send_event(response_event.clone()).await { return Err(anyhow!("Error sending NWC event: {}", e)); } Ok(Some(NwcResponse::SingleEvent(response_event))) @@ -836,18 +855,17 @@ impl NostrWalletConnect { event: Event, node: &impl InvoiceHandler, nostr_manager: &NostrManager, - params: PayInvoiceRequestParams, + request: PayInvoiceRequest, needs_delete: &mut bool, needs_save: &mut bool, - is_multi_pay: bool, ) -> anyhow::Result> { - let method = if is_multi_pay { + let method = if request.is_multi_pay { Method::MultiPayInvoice } else { Method::PayInvoice }; - let invoice: Bolt11Invoice = match check_valid_nwc_invoice(¶ms, node).await { + let invoice: Bolt11Invoice = match check_valid_nwc_invoice(&request.params, node).await { Ok(Some(invoice)) => invoice, Ok(None) => return Ok(None), Err(err_string) => { @@ -918,7 +936,7 @@ impl NostrWalletConnect { event.id, event.pubkey, invoice, - params.id.clone(), + request.params.id.clone(), ) .await? } @@ -971,7 +989,8 @@ impl NostrWalletConnect { nostr_manager.save_nwc_profile(self.clone())?; } - let response_event = self.build_nwc_response_event(event, content, params.id)?; + let response_event = + self.build_nwc_response_event(event, content, request.params.id)?; Ok(Some(response_event)) } // if we need approval, just save in the db for later @@ -981,7 +1000,7 @@ impl NostrWalletConnect { event.id, event.pubkey, invoice, - params.id, + request.params.id, ) .await?; @@ -1005,7 +1024,7 @@ impl NostrWalletConnect { event.id, event.pubkey, invoice, - params.id.clone(), + request.params.id.clone(), ) .await?; Response { @@ -1074,7 +1093,7 @@ impl NostrWalletConnect { event.id, event.pubkey, invoice, - params.id.clone(), + request.params.id.clone(), ) .await? } @@ -1093,7 +1112,8 @@ impl NostrWalletConnect { } }; - let response_event = self.build_nwc_response_event(event, content, params.id)?; + let response_event = + self.build_nwc_response_event(event, content, request.params.id)?; Ok(Some(response_event)) } } @@ -1108,15 +1128,19 @@ impl NostrWalletConnect { needs_delete: &mut bool, needs_save: &mut bool, ) -> anyhow::Result> { + let pay_invoice_request = PayInvoiceRequest { + params, + is_multi_pay: false, + }; + match self .handle_nwc_invoice_payment( event.clone(), node, nostr_manager, - params.clone(), + pay_invoice_request, needs_delete, needs_save, - false, ) .await { @@ -1147,15 +1171,18 @@ impl NostrWalletConnect { let mut response_events: Vec = Vec::with_capacity(params.invoices.len()); for param in params.invoices { + let pay_invoice_request = PayInvoiceRequest { + params: param, + is_multi_pay: true, + }; match self .handle_nwc_invoice_payment( event.clone(), node, nostr_manager, - param.clone(), + pay_invoice_request, needs_delete, needs_save, - true, ) .await { @@ -1212,13 +1239,13 @@ impl NostrWalletConnect { Ok(None) } SpendingConditions::RequireApproval => { - // respond with payment failed error + // respond with error // only do keysend payments for budgeted profiles let content = Response { result_type: method, error: Some(NIP47Error { - code: ErrorCode::PaymentFailed, - message: String::from("not enough balance to make payment"), + code: ErrorCode::Unauthorized, + message: String::from("Keysend only supported for profiles with a budget"), }), result: None, }; @@ -1228,14 +1255,18 @@ impl NostrWalletConnect { } SpendingConditions::Budget(mut budget) => { // generate deterministic preimage for keysend payment from event info - let input = match params.id.clone() { - Some(mut id) => { - id.push_str(event.id.to_hex().as_str()); - id + let preimage = match params.preimage { + Some(preimage) => PaymentPreimage(Sha256::hash(preimage.as_bytes()).into_32()), + None => { + let server_key = self.server_key.secret_key()?; + let mut input = event.id.to_string(); + input.push_str(&server_key.to_string()); + if let Some(ref id) = params.id { + input.push_str(id.as_str()); + }; + PaymentPreimage(Sha256::hash(input.as_bytes()).into_32()) } - None => event.id.to_string(), }; - let preimage = PaymentPreimage(Sha256::hash(input.as_bytes()).into_32()); let payment_hash = PaymentHash(Sha256::hash(&preimage.0).into_32()); // skip if keysend payment has already been done or in-flight @@ -1287,9 +1318,19 @@ impl NostrWalletConnect { .label .clone() .unwrap_or(self.profile.name.clone()); + + let tlvs = params + .tlv_records + .into_iter() + .map(|record| CustomTLV { + tlv_type: record.tlv_type, + value: record.value, + }) + .collect(); + // attempt keysend payment now match node - .keysend(to_node_pubkey, sats, None, vec![label], Some(preimage.0)) + .keysend(to_node_pubkey, sats, tlvs, vec![label], Some(preimage.0)) .await { Ok(_) => Response { @@ -1335,7 +1376,7 @@ impl NostrWalletConnect { params: PayKeysendRequestParams, ) -> anyhow::Result> { match self - .handle_nwc_keysend_payment(event.clone(), node, nostr_manager, params.clone(), false) + .handle_nwc_keysend_payment(event, node, nostr_manager, params, false) .await { Ok(Some(response_event)) => { @@ -1976,6 +2017,13 @@ mod wasm_test { ) .unwrap(); + let event_id = EventId::all_zeros(); + nostr_manager + .client + .expect_send_event() + .times(7) + .returning(move |_| Ok(event_id)); + let secp = Secp256k1::new(); let mut nwc = NostrWalletConnect::new(&secp, xprivkey, profile.profile()).unwrap(); let uri = nwc.get_nwc_uri().unwrap().unwrap(); @@ -2006,12 +2054,6 @@ mod wasm_test { .unwrap() }; - let event_id = EventId::all_zeros(); - nostr_manager - .client - .expect_send_event() - .once() - .returning(move |_| Ok(event_id)); let result = nwc.handle_nwc_request(event, &node, &nostr_manager).await; if let NwcResponse::SingleEvent(event) = result.unwrap().unwrap() { check_nwc_error_response( @@ -2044,12 +2086,6 @@ mod wasm_test { .unwrap() }; - let event_id = EventId::all_zeros(); - nostr_manager - .client - .expect_send_event() - .once() - .returning(move |_| Ok(event_id)); let result = nwc.handle_nwc_request(event, &node, &nostr_manager).await; if let NwcResponse::SingleEvent(event) = result.unwrap().unwrap() { check_nwc_error_response( @@ -2065,12 +2101,6 @@ mod wasm_test { // test invalid invoice let event = create_nwc_request(&uri, "invalid invoice".to_string()); - let event_id = EventId::all_zeros(); - nostr_manager - .client - .expect_send_event() - .once() - .returning(move |_| Ok(event_id)); let result = nwc.handle_nwc_request(event, &node, &nostr_manager).await; if let NwcResponse::SingleEvent(event) = result.unwrap().unwrap() { check_nwc_error_response( @@ -2086,12 +2116,6 @@ mod wasm_test { // test expired invoice let event = create_nwc_request(&uri, INVOICE.to_string()); - let event_id = EventId::all_zeros(); - nostr_manager - .client - .expect_send_event() - .once() - .returning(move |_| Ok(event_id)); let result = nwc.handle_nwc_request(event, &node, &nostr_manager).await; if let NwcResponse::SingleEvent(event) = result.unwrap().unwrap() { check_nwc_error_response( @@ -2108,12 +2132,6 @@ mod wasm_test { // test amount-less invoice let (invoice, _) = create_dummy_invoice(None, Network::Regtest, None); let event = create_nwc_request(&uri, invoice.to_string()); - let event_id = EventId::all_zeros(); - nostr_manager - .client - .expect_send_event() - .once() - .returning(move |_| Ok(event_id)); let result = nwc.handle_nwc_request(event, &node, &nostr_manager).await; if let NwcResponse::SingleEvent(event) = result.unwrap().unwrap() { check_nwc_error_response( @@ -2136,12 +2154,6 @@ mod wasm_test { .0 .to_string(); let event = create_nwc_request(&uri, invoice); - let event_id = EventId::all_zeros(); - nostr_manager - .client - .expect_send_event() - .once() - .returning(move |_| Ok(event_id)); let result = nwc.handle_nwc_request(event, &node, &nostr_manager).await; if let NwcResponse::SingleEvent(event) = result.unwrap().unwrap() { check_nwc_error_response( @@ -2208,20 +2220,14 @@ mod wasm_test { // test keysend payment - require approval let event = create_pay_keysend_nwc_request(&uri, 1_000, "dummy".to_string()); - let event_id = EventId::all_zeros(); - nostr_manager - .client - .expect_send_event() - .once() - .returning(move |_| Ok(event_id)); let result = nwc.handle_nwc_request(event, &node, &nostr_manager).await; if let NwcResponse::SingleEvent(event) = result.unwrap().unwrap() { check_nwc_error_response( event, &uri.secret, NIP47Error { - code: ErrorCode::PaymentFailed, - message: "not enough balance to make payment".to_string(), + code: ErrorCode::Unauthorized, + message: "Keysend only supported for profiles with a budget".to_string(), }, ); } @@ -2349,15 +2355,16 @@ mod wasm_test { let mut nwc = NostrWalletConnect::new(&secp, xprivkey, profile.profile()).unwrap(); let uri = nwc.get_nwc_uri().unwrap().unwrap(); - // test failed payment goes to pending, we have no channels so it will fail - let (invoice, _) = create_dummy_invoice(Some(10), Network::Regtest, None); - let event = create_nwc_request(&uri, invoice.to_string()); let event_id = EventId::all_zeros(); nostr_manager .client .expect_send_event() - .once() + .times(5) .returning(move |_| Ok(event_id)); + + // test failed payment goes to pending, we have no channels so it will fail + let (invoice, _) = create_dummy_invoice(Some(10), Network::Regtest, None); + let event = create_nwc_request(&uri, invoice.to_string()); let result = nwc .handle_nwc_request(event.clone(), &mw, &nostr_manager) .await; @@ -2375,12 +2382,6 @@ mod wasm_test { // test over budget payment goes to pending let (invoice, _) = create_dummy_invoice(Some(budget + 1), Network::Regtest, None); let event = create_nwc_request(&uri, invoice.to_string()); - let event_id = EventId::all_zeros(); - nostr_manager - .client - .expect_send_event() - .once() - .returning(move |_| Ok(event_id)); let result = nwc .handle_nwc_request(event.clone(), &mw, &nostr_manager) .await; @@ -2404,8 +2405,8 @@ mod wasm_test { let uri = nwc.get_nwc_uri().unwrap().unwrap(); // test multi invoice over budget after 1st invoice. 2nd goes to pending - let (invoice_1, preimage_1) = create_dummy_invoice(Some(8000_000), Network::Regtest, None); - let (invoice_2, _) = create_dummy_invoice(Some(4000_000), Network::Regtest, None); + let (invoice_1, preimage_1) = create_dummy_invoice(Some(8_000_000), Network::Regtest, None); + let (invoice_2, _) = create_dummy_invoice(Some(4_000_000), Network::Regtest, None); let invoices = vec![invoice_1.to_string(), invoice_2.to_string()]; node.expect_pay_invoice() .once() @@ -2419,12 +2420,6 @@ mod wasm_test { }); let event = create_multi_invoice_nwc_request(&uri, invoices); - let event_id = EventId::all_zeros(); - nostr_manager - .client - .expect_send_event() - .times(2) - .returning(move |_| Ok(event_id)); let result = nwc .handle_nwc_request(event.clone(), &node, &nostr_manager) .await; @@ -2446,12 +2441,6 @@ mod wasm_test { let event = create_pay_keysend_nwc_request(&uri, budget * 1000 + 1_000, pubkey.to_string()); let mut node = MockInvoiceHandler::new(); node.expect_lookup_payment().return_const(None); - let event_id = EventId::all_zeros(); - nostr_manager - .client - .expect_send_event() - .once() - .returning(move |_| Ok(event_id)); let result = nwc.handle_nwc_request(event, &mw, &nostr_manager).await; if let NwcResponse::SingleEvent(event) = result.unwrap().unwrap() { check_nwc_error_response( @@ -2502,6 +2491,13 @@ mod wasm_test { .await .unwrap(); + let event_id = EventId::all_zeros(); + nostr_manager + .client + .expect_send_event() + .times(2) + .returning(move |_| Ok(event_id)); + let budget = 10_000; let profile = nostr_manager .create_new_nwc_profile_internal( @@ -2525,12 +2521,6 @@ mod wasm_test { // test successful payment let event = create_nwc_request(&uri, invoice.to_string()); - let event_id = EventId::all_zeros(); - nostr_manager - .client - .expect_send_event() - .once() - .returning(move |_| Ok(event_id)); let result = nwc .handle_nwc_request(event.clone(), &node, &nostr_manager) .await; @@ -2586,7 +2576,7 @@ mod wasm_test { description: None, payment_hash, preimage: Some(payment_preimage), - payee_pubkey: Some(pubkey.clone()), + payee_pubkey: Some(pubkey), amount_sats: None, expire: 0, status: HTLCStatus::Succeeded, @@ -2601,16 +2591,13 @@ mod wasm_test { let keysend_amount_msat = 5_000; let event = create_pay_keysend_nwc_request(&uri, keysend_amount_msat, pubkey.to_string()); - nostr_manager - .client - .expect_send_event() - .once() - .returning(move |_| Ok(event_id)); let result = nwc .handle_nwc_request(event.clone(), &node, &nostr_manager) .await; - let preimage = PaymentPreimage(Sha256::hash(event.id.to_string().as_bytes()).into_32()); + let mut input = event.id.to_string(); + input.push_str(&nwc.server_key.secret_key().unwrap().to_string()); + let preimage = PaymentPreimage(Sha256::hash(input.as_bytes()).into_32()); let event = if let NwcResponse::SingleEvent(nwc_event) = result.unwrap().unwrap() { nwc_event @@ -3011,6 +2998,13 @@ mod wasm_test { ) .unwrap(); + let event_id = EventId::all_zeros(); + nostr_manager + .client + .expect_send_event() + .times(2) + .returning(move |_| Ok(event_id)); + let secp = Secp256k1::new(); let mut nwc = NostrWalletConnect::new(&secp, xprivkey, profile.profile()).unwrap(); let uri = nwc.get_nwc_uri().unwrap().unwrap(); @@ -3025,12 +3019,6 @@ mod wasm_test { bolt11: Some("lntbs1m1pjrmuu3pp52hk0j956d7s8azaps87amadshnrcvqtkvk06y2nue2w69g6e5vasdqqcqzpgxqyz5vqsp5wu3py6257pa3yzarw0et2200c08r5fu6k3u94yfwmlnc8skdkc9s9qyyssqc783940p82c64qq9pu3xczt4tdxzex9wpjn54486y866aayft2cxxusl9eags4cs3kcmuqdrvhvs0gudpj5r2a6awu4wcq29crpesjcqhdju55".to_string()), }), ); - let event_id = EventId::all_zeros(); - nostr_manager - .client - .expect_send_event() - .once() - .returning(move |_| Ok(event_id)); let result = nwc .handle_nwc_request(event.clone(), &node, &nostr_manager) .await; @@ -3061,12 +3049,6 @@ mod wasm_test { bolt11: Some(invoice.to_string()), }), ); - let event_id = EventId::all_zeros(); - nostr_manager - .client - .expect_send_event() - .once() - .returning(move |_| Ok(event_id)); let result = nwc .handle_nwc_request(event.clone(), &node, &nostr_manager) .await; @@ -3111,4 +3093,168 @@ mod wasm_test { .await; assert!(result.is_err()); } + + #[test] + async fn test_list_transactions() { + let storage = MemoryStorage::default(); + + let xprivkey = ExtendedPrivKey::new_master(Network::Regtest, &[0; 64]).unwrap(); + let stop = Arc::new(AtomicBool::new(false)); + let mut nostr_manager = NostrManager::from_mnemonic( + xprivkey, + NostrKeySource::Derived, + storage.clone(), + MockPrimalApi::new(), + get_mock_nostr_client(), + Arc::new(MutinyLogger::default()), + stop, + ) + .await + .unwrap(); + + let profile = nostr_manager + .create_new_nwc_profile_internal( + ProfileType::Normal { + name: "test".to_string(), + }, + SpendingConditions::RequireApproval, + NwcProfileTag::General, + vec![Method::ListTransactions], + ) + .unwrap(); + + let event_id = EventId::all_zeros(); + nostr_manager + .client + .expect_send_event() + .times(3) + .returning(move |_| Ok(event_id)); + + let secp = Secp256k1::new(); + let mut nwc = NostrWalletConnect::new(&secp, xprivkey, profile.profile()).unwrap(); + let uri = nwc.get_nwc_uri().unwrap().unwrap(); + + let invoice_1: MutinyInvoice = create_dummy_invoice(Some(69696969), Network::Regtest, None) + .0 + .clone() + .into(); + let invoice_2: MutinyInvoice = create_dummy_invoice(Some(42_000), Network::Regtest, None) + .0 + .clone() + .into(); + let invoice_3: MutinyInvoice = create_dummy_invoice(Some(42069), Network::Regtest, None) + .0 + .clone() + .into(); + let mut invoice_4: MutinyInvoice = + create_dummy_invoice(Some(21_000), Network::Regtest, None) + .0 + .clone() + .into(); + invoice_4.status = HTLCStatus::Succeeded; + + let invoices: Vec = vec![ + invoice_1.clone(), + invoice_2.clone(), + invoice_3.clone(), + invoice_4.clone(), + ]; + + let mut node = MockInvoiceHandler::new(); + node.expect_get_payments_by_label() + .times(3) + .returning(move |_| Ok(invoices.clone())); + + // only paid ones + let time = utils::now().as_secs() + 2000000; + let event = sign_nwc_request( + &uri, + Request::list_transactions(ListTransactionsRequestParams { + from: None, + until: Some(time), + limit: None, + offset: None, + unpaid: None, + transaction_type: None, + }), + ); + + let result = nwc + .handle_nwc_request(event.clone(), &node, &nostr_manager) + .await; + + let event = if let NwcResponse::SingleEvent(nwc_event) = result.unwrap().unwrap() { + nwc_event + } else { + panic!("invalid nwc response") + }; + let content = decrypt(&uri.secret, &event.pubkey, &event.content).unwrap(); + let response: Response = Response::from_json(content).unwrap(); + let result = response.to_list_transactions().unwrap(); + + assert_eq!(result.len(), 1); + assert_eq!(result[0].amount, 21_000); + assert_eq!( + result[0].invoice, + Some(invoice_4.bolt11.unwrap().to_string()) + ); + + // with limit + let time = utils::now().as_secs() + 2000000; + let event = sign_nwc_request( + &uri, + Request::list_transactions(ListTransactionsRequestParams { + from: None, + until: Some(time), + limit: Some(2), + offset: None, + unpaid: Some(true), + transaction_type: None, + }), + ); + + let result = nwc + .handle_nwc_request(event.clone(), &node, &nostr_manager) + .await; + + let event = if let NwcResponse::SingleEvent(nwc_event) = result.unwrap().unwrap() { + nwc_event + } else { + panic!("invalid nwc response") + }; + let content = decrypt(&uri.secret, &event.pubkey, &event.content).unwrap(); + let response: Response = Response::from_json(content).unwrap(); + let result = response.to_list_transactions().unwrap(); + + assert_eq!(result.len(), 2); + + // outgoing + let time = utils::now().as_secs() + 2000000; + let event = sign_nwc_request( + &uri, + Request::list_transactions(ListTransactionsRequestParams { + from: None, + until: Some(time), + limit: None, + offset: None, + unpaid: None, + transaction_type: Some(TransactionType::Outgoing), + }), + ); + + let result = nwc + .handle_nwc_request(event.clone(), &node, &nostr_manager) + .await; + + let event = if let NwcResponse::SingleEvent(nwc_event) = result.unwrap().unwrap() { + nwc_event + } else { + panic!("invalid nwc response") + }; + let content = decrypt(&uri.secret, &event.pubkey, &event.content).unwrap(); + let response: Response = Response::from_json(content).unwrap(); + let result = response.to_list_transactions().unwrap(); + + assert_eq!(result.len(), 0); + } } diff --git a/mutiny-wasm/src/lib.rs b/mutiny-wasm/src/lib.rs index 81b4f50c8..29be7694d 100644 --- a/mutiny-wasm/src/lib.rs +++ b/mutiny-wasm/src/lib.rs @@ -850,14 +850,22 @@ impl MutinyWallet { &self, to_node: String, amt_sats: u64, - message: Option, + custom_tlvs: Vec, labels: Vec, ) -> Result { let to_node = PublicKey::from_str(&to_node)?; + let custom_tlvs = custom_tlvs + .into_iter() + .map(|tlv| mutiny_core::CustomTLV { + tlv_type: tlv.tlv_type, + value: tlv.value, + }) + .collect(); + Ok(self .inner .node_manager - .keysend(None, to_node, amt_sats, message, labels, None) + .keysend(None, to_node, amt_sats, custom_tlvs, labels, None) .await? .into()) } diff --git a/mutiny-wasm/src/models.rs b/mutiny-wasm/src/models.rs index 279632cf2..6d53b0d74 100644 --- a/mutiny-wasm/src/models.rs +++ b/mutiny-wasm/src/models.rs @@ -1197,3 +1197,10 @@ impl From for DirectMessage { } } } + +#[wasm_bindgen] +pub struct CustomTLV { + pub tlv_type: u64, + #[wasm_bindgen(skip)] + pub value: String, +}