From 3efe4a1a3f9ff274b1df8ef5b4664c051e59d659 Mon Sep 17 00:00:00 2001 From: benthecarman Date: Thu, 22 Feb 2024 13:26:57 +0000 Subject: [PATCH 1/8] Refactor NWC for supporting mulitple commands --- mutiny-core/src/nostr/mod.rs | 50 ++- mutiny-core/src/nostr/nwc.rs | 654 +++++++++++++++++++---------------- 2 files changed, 388 insertions(+), 316 deletions(-) diff --git a/mutiny-core/src/nostr/mod.rs b/mutiny-core/src/nostr/mod.rs index c5498e12d..13ab60d37 100644 --- a/mutiny-core/src/nostr/mod.rs +++ b/mutiny-core/src/nostr/mod.rs @@ -433,6 +433,7 @@ impl NostrManager { uri: NIP49URI, budget: Option, tag: NwcProfileTag, + commands: Vec, ) -> Result { let spending_conditions = match uri.budget { None => match budget { @@ -486,6 +487,7 @@ impl NostrManager { enabled: None, archived: None, spending_conditions, + commands: Some(commands), tag, label, }; @@ -530,6 +532,7 @@ impl NostrManager { tag, client_key: None, label: None, + commands: Some(vec![Method::PayInvoice]), }; let nwc = NostrWalletConnect::new(&Secp256k1::new(), self.xprivkey, profile)?; @@ -616,7 +619,7 @@ impl NostrManager { let secret = uri.secret.clone(); let relay = uri.relay_url.to_string(); - let profile = self.nostr_wallet_auth(profile_type, uri, budget, tag)?; + let profile = self.nostr_wallet_auth(profile_type, uri, budget, tag, commands.clone())?; let nwc = self.nwc.try_read()?.iter().find_map(|nwc| { if nwc.profile.index == profile.index { @@ -709,7 +712,12 @@ impl NostrManager { let p_tag = Tag::public_key(inv.pubkey); let e_tag = Tag::event(inv.event_id); - let response = EventBuilder::new(Kind::WalletConnectResponse, encrypted, [p_tag, e_tag]) + let tags = match inv.identifier { + Some(id) => vec![p_tag, e_tag, Tag::Identifier(id)], + None => vec![p_tag, e_tag], + }; + + let response = EventBuilder::new(Kind::WalletConnectResponse, encrypted, tags) .to_event(&nwc.server_key) .map_err(|e| MutinyError::Other(anyhow::anyhow!("Failed to create event: {e:?}")))?; @@ -909,17 +917,22 @@ impl NostrManager { let decrypted = self.decrypt_dm(event.pubkey, &event.content).await?; - let invoice: Bolt11Invoice = - match check_valid_nwc_invoice(&decrypted, invoice_handler).await { - Ok(Some(invoice)) => invoice, - Ok(None) => return Ok(()), - Err(msg) => { - log_debug!(self.logger, "Not adding DM'd invoice: {msg}"); - return Ok(()); - } - }; + // handle it like a pay invoice NWC request, to see if it is valid + let params = PayInvoiceRequestParams { + id: None, + invoice: decrypted, + amount: None, + }; + let invoice: Bolt11Invoice = match check_valid_nwc_invoice(¶ms, invoice_handler).await { + Ok(Some(invoice)) => invoice, + Ok(None) => return Ok(()), + Err(msg) => { + log_debug!(self.logger, "Not adding DM'd invoice: {msg}"); + return Ok(()); + } + }; - self.save_pending_nwc_invoice(None, event.id, event.pubkey, invoice) + self.save_pending_nwc_invoice(None, event.id, event.pubkey, invoice, None) .await?; Ok(()) @@ -931,12 +944,14 @@ impl NostrManager { event_id: EventId, event_pk: nostr::PublicKey, invoice: Bolt11Invoice, + identifier: Option, ) -> anyhow::Result<()> { let pending = PendingNwcInvoice { index: profile_index, invoice, event_id, pubkey: event_pk, + identifier, }; self.pending_nwc_lock.lock().await; @@ -1743,11 +1758,12 @@ mod test { .unwrap(); let inv = PendingNwcInvoice { - index: Some(profile.index), - invoice: Bolt11Invoice::from_str("lnbc923720n1pj9nrefpp5pczykgk37af5388n8dzynljpkzs7sje4melqgazlwv9y3apay8jqhp5rd8saxz3juve3eejq7z5fjttxmpaq88d7l92xv34n4h3mq6kwq2qcqzzsxqzfvsp5z0jwpehkuz9f2kv96h62p8x30nku76aj8yddpcust7g8ad0tr52q9qyyssqfy622q25helv8cj8hyxqltws4rdwz0xx2hw0uh575mn7a76cp3q4jcptmtjkjs4a34dqqxn8uy70d0qlxqleezv4zp84uk30pp5q3nqq4c9gkz").unwrap(), - event_id: EventId::from_slice(&[0; 32]).unwrap(), - pubkey: nostr::PublicKey::from_str("552a9d06810f306bfc085cb1e1c26102554138a51fa3a7fdf98f5b03a945143a").unwrap(), - }; + index: Some(profile.index), + invoice: Bolt11Invoice::from_str("lnbc923720n1pj9nrefpp5pczykgk37af5388n8dzynljpkzs7sje4melqgazlwv9y3apay8jqhp5rd8saxz3juve3eejq7z5fjttxmpaq88d7l92xv34n4h3mq6kwq2qcqzzsxqzfvsp5z0jwpehkuz9f2kv96h62p8x30nku76aj8yddpcust7g8ad0tr52q9qyyssqfy622q25helv8cj8hyxqltws4rdwz0xx2hw0uh575mn7a76cp3q4jcptmtjkjs4a34dqqxn8uy70d0qlxqleezv4zp84uk30pp5q3nqq4c9gkz").unwrap(), + event_id: EventId::from_slice(&[0; 32]).unwrap(), + pubkey: nostr::PublicKey::from_str("552a9d06810f306bfc085cb1e1c26102554138a51fa3a7fdf98f5b03a945143a").unwrap(), + identifier: None, + }; // add dummy to storage nostr_manager diff --git a/mutiny-core/src/nostr/nwc.rs b/mutiny-core/src/nostr/nwc.rs index 2e84e9c81..38896eb6b 100644 --- a/mutiny-core/src/nostr/nwc.rs +++ b/mutiny-core/src/nostr/nwc.rs @@ -175,6 +175,8 @@ pub(crate) struct Profile { /// Require approval before sending a payment #[serde(default)] pub spending_conditions: SpendingConditions, + /// Allowed commands for this profile + pub(crate) commands: Option>, /// index to use to derive nostr keys for child index /// set to Option so that we keep using `index` for reserved + existing #[serde(default)] @@ -194,6 +196,15 @@ impl Profile { (None, None) => true, } } + + /// Returns the available commands for this profile + pub fn available_commands(&self) -> &[Method] { + // if None this is an old profile and we should only allow pay invoice + match self.commands.as_ref() { + None => &[Method::PayInvoice], + Some(cmds) => cmds, + } + } } impl PartialOrd for Profile { @@ -337,22 +348,30 @@ impl NostrWalletConnect { event_id: EventId, event_pk: nostr::PublicKey, invoice: Bolt11Invoice, + identifier: Option, ) -> anyhow::Result<()> { nostr_manager - .save_pending_nwc_invoice(Some(self.profile.index), event_id, event_pk, invoice) + .save_pending_nwc_invoice( + Some(self.profile.index), + event_id, + event_pk, + invoice, + identifier, + ) .await } fn get_skipped_error_event( &self, event: &Event, + result_type: Method, error_code: ErrorCode, message: String, ) -> anyhow::Result { let server_key = self.server_key.secret_key()?; let client_pubkey = self.client_key.public_key(); let content = Response { - result_type: Method::PayInvoice, + result_type, error: Some(NIP47Error { code: error_code, message, @@ -362,17 +381,8 @@ impl NostrWalletConnect { let encrypted = encrypt(server_key, &client_pubkey, content.as_json())?; - let p_tag = Tag::PublicKey { - public_key: event.pubkey, - relay_url: None, - alias: None, - uppercase: false, - }; - let e_tag = Tag::Event { - event_id: event.id, - relay_url: None, - marker: None, - }; + let p_tag = Tag::public_key(event.pubkey); + let e_tag = Tag::event(event.id); let response = EventBuilder::new(Kind::WalletConnectResponse, encrypted, [p_tag, e_tag]) .to_event(&self.server_key)?; @@ -391,6 +401,7 @@ impl NostrWalletConnect { let client_pubkey = self.client_key.public_key(); let mut needs_save = false; let mut needs_delete = false; + let mut result = None; if self.profile.active() && event.kind == Kind::WalletConnectRequest && event.pubkey == client_pubkey @@ -408,6 +419,7 @@ impl NostrWalletConnect { return self .get_skipped_error_event( &event, + Method::PayInvoice, // most likely it's a pay invoice request ErrorCode::NotImplemented, "Failed to parse request.".to_string(), ) @@ -415,343 +427,372 @@ impl NostrWalletConnect { } }; - // only respond to pay invoice requests - if req.method != Method::PayInvoice { + // only respond to commands that are allowed by the profile + if !self.profile.available_commands().contains(&req.method) { return self .get_skipped_error_event( &event, + req.method, ErrorCode::NotImplemented, "Command is not supported.".to_string(), ) .map(Some); } - let invoice_str = match req.params { - RequestParams::PayInvoice(params) => params.invoice, - _ => return Err(anyhow!("Invalid request params for pay invoice")), - }; - - let invoice: Bolt11Invoice = match check_valid_nwc_invoice(&invoice_str, node).await { - Ok(Some(invoice)) => invoice, - Ok(None) => return Ok(None), - Err(err_string) => { - return self - .get_skipped_error_event(&event, ErrorCode::Other, err_string) - .map(Some); + result = match req.params { + RequestParams::PayInvoice(params) => { + self.handle_pay_invoice_request( + event, + node, + nostr_manager, + params, + &mut needs_delete, + &mut needs_save, + ) + .await? } + _ => return Err(anyhow!("Invalid request params for {}", req.method)), }; + } - // if we need approval, just save in the db for later - match self.profile.spending_conditions.clone() { - SpendingConditions::SingleUse(mut single_use) => { - let msats = invoice.amount_milli_satoshis().unwrap(); - - // get the status of the previous payment attempt, if one exists - let prev_status: Option = match single_use.payment_hash { - Some(payment_hash) => { - let hash: [u8; 32] = - FromHex::from_hex(&payment_hash).expect("invalid hash"); - node.get_outbound_payment_status(&hash).await - } - None => None, - }; + if needs_delete { + nostr_manager.delete_nwc_profile(self.profile.index)?; + } else if needs_save { + nostr_manager.save_nwc_profile(self.clone())?; + } - // check if we have already spent - let content = match prev_status { - Some(HTLCStatus::Succeeded) => { - needs_delete = true; - Response { - result_type: Method::PayInvoice, - error: Some(NIP47Error { - code: ErrorCode::QuotaExceeded, - message: "Already Claimed".to_string(), - }), - result: None, - } + Ok(result) + } + + async fn handle_pay_invoice_request( + &mut self, + event: Event, + node: &impl InvoiceHandler, + nostr_manager: &NostrManager, + params: PayInvoiceRequestParams, + needs_delete: &mut bool, + needs_save: &mut bool, + ) -> anyhow::Result> { + let invoice: Bolt11Invoice = match check_valid_nwc_invoice(¶ms, node).await { + Ok(Some(invoice)) => invoice, + Ok(None) => return Ok(None), + Err(err_string) => { + return self + .get_skipped_error_event( + &event, + Method::PayInvoice, + ErrorCode::Other, + err_string, + ) + .map(Some); + } + }; + + // if we need approval, just save in the db for later + match self.profile.spending_conditions.clone() { + SpendingConditions::SingleUse(mut single_use) => { + let msats = invoice.amount_milli_satoshis().unwrap(); + + // get the status of the previous payment attempt, if one exists + let prev_status: Option = match single_use.payment_hash { + Some(payment_hash) => { + let hash: [u8; 32] = + FromHex::from_hex(&payment_hash).expect("invalid hash"); + node.get_outbound_payment_status(&hash).await + } + None => None, + }; + + // check if we have already spent + let content = match prev_status { + Some(HTLCStatus::Succeeded) => { + *needs_delete = true; + Response { + result_type: Method::PayInvoice, + error: Some(NIP47Error { + code: ErrorCode::QuotaExceeded, + message: "Already Claimed".to_string(), + }), + result: None, } - None | Some(HTLCStatus::Failed) => { - if msats <= single_use.amount_sats * 1_000 { - match self.pay_nwc_invoice(node, &invoice).await { - Ok(resp) => { - // after it is spent, delete the profile - // so that it cannot be used again - needs_delete = true; - resp + } + None | Some(HTLCStatus::Failed) => { + if msats <= single_use.amount_sats * 1_000 { + match self.pay_nwc_invoice(node, &invoice).await { + Ok(resp) => { + // after it is spent, delete the profile + // so that it cannot be used again + *needs_delete = true; + resp + } + Err(e) => { + let mut code = ErrorCode::InsufficientBalance; + if let MutinyError::PaymentTimeout = e { + // if a payment times out, we should save the payment_hash + // and track if the payment settles or not. If it does not + // we can try again later. + single_use.payment_hash = Some( + invoice.payment_hash().into_32().to_lower_hex_string(), + ); + self.profile.spending_conditions = + SpendingConditions::SingleUse(single_use); + *needs_save = true; + + log_error!( + nostr_manager.logger, + "Payment timeout, saving profile for later" + ); + code = ErrorCode::Internal; + } else { + // for non-timeout errors, add to manual approval list + self.save_pending_nwc_invoice( + nostr_manager, + event.id, + event.pubkey, + invoice, + params.id.clone(), + ) + .await? } - Err(e) => { - let mut code = ErrorCode::InsufficientBalance; - if let MutinyError::PaymentTimeout = e { - // if a payment times out, we should save the payment_hash - // and track if the payment settles or not. If it does not - // we can try again later. - single_use.payment_hash = Some( - invoice - .payment_hash() - .into_32() - .to_lower_hex_string(), - ); - self.profile.spending_conditions = - SpendingConditions::SingleUse(single_use); - needs_save = true; - - log_error!( - nostr_manager.logger, - "Payment timeout, saving profile for later" - ); - code = ErrorCode::Internal; - } else { - // for non-timeout errors, add to manual approval list - self.save_pending_nwc_invoice( - nostr_manager, - event.id, - event.pubkey, - invoice, - ) - .await? - } - Response { - result_type: Method::PayInvoice, - error: Some(NIP47Error { - code, - message: format!("Failed to pay invoice: {e}"), - }), - result: None, - } + Response { + result_type: Method::PayInvoice, + error: Some(NIP47Error { + code, + message: format!("Failed to pay invoice: {e}"), + }), + result: None, } } - } else { - log_warn!( - nostr_manager.logger, - "Invoice amount too high: {msats} msats" - ); - - Response { - result_type: Method::PayInvoice, - error: Some(NIP47Error { - code: ErrorCode::QuotaExceeded, - message: format!("Invoice amount too high: {msats} msats"), - }), - result: None, - } } - } - Some(HTLCStatus::Pending) | Some(HTLCStatus::InFlight) => { + } else { log_warn!( nostr_manager.logger, - "Previous NWC payment still in flight, cannot pay: {invoice}" + "Invoice amount too high: {msats} msats" ); Response { result_type: Method::PayInvoice, error: Some(NIP47Error { - code: ErrorCode::RateLimited, - message: "Previous payment still in flight, cannot pay" - .to_string(), + code: ErrorCode::QuotaExceeded, + message: format!("Invoice amount too high: {msats} msats"), }), result: None, } } - }; - - let encrypted = encrypt(server_key, &client_pubkey, content.as_json())?; - - let p_tag = Tag::PublicKey { - public_key: event.pubkey, - relay_url: None, - alias: None, - uppercase: false, - }; - let e_tag = Tag::Event { - event_id: event.id, - relay_url: None, - marker: None, - }; - let response = - EventBuilder::new(Kind::WalletConnectResponse, encrypted, [p_tag, e_tag]) - .to_event(&self.server_key)?; - - if needs_delete { - nostr_manager.delete_nwc_profile(self.profile.index)?; - } else if needs_save { - nostr_manager.save_nwc_profile(self.clone())?; } + Some(HTLCStatus::Pending) | Some(HTLCStatus::InFlight) => { + log_warn!( + nostr_manager.logger, + "Previous NWC payment still in flight, cannot pay: {invoice}" + ); + + Response { + result_type: Method::PayInvoice, + error: Some(NIP47Error { + code: ErrorCode::RateLimited, + message: "Previous payment still in flight, cannot pay".to_string(), + }), + result: None, + } + } + }; + + let encrypted = encrypt( + self.server_key.secret_key()?, + &self.client_key.public_key(), + content.as_json(), + )?; + + let p_tag = Tag::public_key(event.pubkey); + let e_tag = Tag::event(event.id); + let tags = match params.id { + Some(id) => vec![p_tag, e_tag, Tag::Identifier(id)], + None => vec![p_tag, e_tag], + }; + + let response = EventBuilder::new(Kind::WalletConnectResponse, encrypted, tags) + .to_event(&self.server_key)?; + + if *needs_delete { + nostr_manager.delete_nwc_profile(self.profile.index)?; + } else if *needs_save { + nostr_manager.save_nwc_profile(self.clone())?; + } - return Ok(Some(response)); + Ok(Some(response)) + } + SpendingConditions::RequireApproval => { + self.save_pending_nwc_invoice( + nostr_manager, + event.id, + event.pubkey, + invoice, + params.id, + ) + .await?; + + if *needs_save { + nostr_manager.save_nwc_profile(self.clone())?; } - SpendingConditions::RequireApproval => { - self.save_pending_nwc_invoice(nostr_manager, event.id, event.pubkey, invoice) - .await?; - if needs_save { - nostr_manager.save_nwc_profile(self.clone())?; + Ok(None) + } + SpendingConditions::Budget(mut budget) => { + let sats = invoice.amount_milli_satoshis().unwrap() / 1_000; + + let budget_err = if budget.single_max.is_some_and(|max| sats > max) { + Some("Invoice amount too high.") + } else if budget.sum_payments() + sats > budget.budget { + // budget might not actually be exceeded, we should verify that the payments + // all went through, and if not, remove them from the budget + let mut indices_to_remove = Vec::new(); + for (index, p) in budget.payments.iter().enumerate() { + let hash: [u8; 32] = FromHex::from_hex(&p.hash)?; + indices_to_remove.push((index, hash)); } - return Ok(None); - } - SpendingConditions::Budget(mut budget) => { - let sats = invoice.amount_milli_satoshis().unwrap() / 1_000; - - let budget_err = if budget.single_max.is_some_and(|max| sats > max) { - Some("Invoice amount too high.") - } else if budget.sum_payments() + sats > budget.budget { - // budget might not actually be exceeded, we should verify that the payments - // all went through, and if not, remove them from the budget - let mut indices_to_remove = Vec::new(); - for (index, p) in budget.payments.iter().enumerate() { - let hash: [u8; 32] = FromHex::from_hex(&p.hash)?; - indices_to_remove.push((index, hash)); - } - - let futures: Vec<_> = indices_to_remove - .iter() - .map(|(index, hash)| async move { - match node.get_outbound_payment_status(hash).await { - Some(HTLCStatus::Failed) => Some(*index), - _ => None, - } - }) - .collect(); + let futures: Vec<_> = indices_to_remove + .iter() + .map(|(index, hash)| async move { + match node.get_outbound_payment_status(hash).await { + Some(HTLCStatus::Failed) => Some(*index), + _ => None, + } + }) + .collect(); - let results = futures::future::join_all(futures).await; + let results = futures::future::join_all(futures).await; - // Remove failed payments - for index in results.into_iter().flatten().rev() { - budget.payments.remove(index); - } + // Remove failed payments + for index in results.into_iter().flatten().rev() { + budget.payments.remove(index); + } - // update budget with removed payments - self.profile.spending_conditions = - SpendingConditions::Budget(budget.clone()); + // update budget with removed payments + self.profile.spending_conditions = SpendingConditions::Budget(budget.clone()); - // try again with cleaned budget - if budget.sum_payments() + sats > budget.budget { - Some("Budget exceeded.") - } else { - None - } + // try again with cleaned budget + if budget.sum_payments() + sats > budget.budget { + Some("Budget exceeded.") } else { None - }; - - let content = match budget_err { - Some(err) => { - log_warn!(nostr_manager.logger, "Attempted to exceed budget: {err}"); - // add to manual approval list - self.save_pending_nwc_invoice( - nostr_manager, - event.id, - event.pubkey, - invoice, - ) - .await?; - Response { - result_type: Method::PayInvoice, - error: Some(NIP47Error { - code: ErrorCode::QuotaExceeded, - message: err.to_string(), - }), - result: None, - } + } + } else { + None + }; + + let content = match budget_err { + Some(err) => { + log_warn!(nostr_manager.logger, "Attempted to exceed budget: {err}"); + // add to manual approval list + self.save_pending_nwc_invoice( + nostr_manager, + event.id, + event.pubkey, + invoice, + params.id.clone(), + ) + .await?; + Response { + result_type: Method::PayInvoice, + error: Some(NIP47Error { + code: ErrorCode::QuotaExceeded, + message: err.to_string(), + }), + result: None, } - None => { - // add payment to budget - budget.add_payment(&invoice); - self.profile.spending_conditions = - SpendingConditions::Budget(budget.clone()); - // persist budget before payment to protect against it not saving after - nostr_manager.save_nwc_profile(self.clone())?; - - // attempt to pay invoice - match self.pay_nwc_invoice(node, &invoice).await { - Ok(resp) => resp, - Err(e) => { - // remove payment if it failed - match e { - MutinyError::PaymentTimeout => { - log_warn!( - nostr_manager.logger, - "Payment timeout, not removing payment from budget" - ); - } - MutinyError::NonUniquePaymentHash => { - log_warn!( - nostr_manager.logger, - "Already paid invoice, removing payment from budget" - ); - budget.remove_payment(&invoice); - self.profile.spending_conditions = - SpendingConditions::Budget(budget); - - nostr_manager.save_nwc_profile(self.clone())?; + } + None => { + // add payment to budget + budget.add_payment(&invoice); + self.profile.spending_conditions = + SpendingConditions::Budget(budget.clone()); + // persist budget before payment to protect against it not saving after + nostr_manager.save_nwc_profile(self.clone())?; - // don't save to pending list, we already paid it - return Ok(None); - } - _ => { - log_warn!( + // attempt to pay invoice + match self.pay_nwc_invoice(node, &invoice).await { + Ok(resp) => resp, + Err(e) => { + // remove payment if it failed + match e { + MutinyError::PaymentTimeout => { + log_warn!( + nostr_manager.logger, + "Payment timeout, not removing payment from budget" + ); + } + MutinyError::NonUniquePaymentHash => { + log_warn!( + nostr_manager.logger, + "Already paid invoice, removing payment from budget" + ); + budget.remove_payment(&invoice); + self.profile.spending_conditions = + SpendingConditions::Budget(budget); + + nostr_manager.save_nwc_profile(self.clone())?; + + // don't save to pending list, we already paid it + return Ok(None); + } + _ => { + log_warn!( nostr_manager.logger, "Failed to pay invoice: {e}, removing payment from budget, adding to manual approval list" ); - budget.remove_payment(&invoice); - self.profile.spending_conditions = - SpendingConditions::Budget(budget.clone()); - - nostr_manager.save_nwc_profile(self.clone())?; - - // for non-timeout errors, add to manual approval list - self.save_pending_nwc_invoice( - nostr_manager, - event.id, - event.pubkey, - invoice, - ) - .await? - } + budget.remove_payment(&invoice); + self.profile.spending_conditions = + SpendingConditions::Budget(budget.clone()); + + nostr_manager.save_nwc_profile(self.clone())?; + + // for non-timeout errors, add to manual approval list + self.save_pending_nwc_invoice( + nostr_manager, + event.id, + event.pubkey, + invoice, + params.id.clone(), + ) + .await? } + } - Response { - result_type: Method::PayInvoice, - error: Some(NIP47Error { - code: ErrorCode::InsufficientBalance, - message: format!("Failed to pay invoice: {e}"), - }), - result: None, - } + Response { + result_type: Method::PayInvoice, + error: Some(NIP47Error { + code: ErrorCode::InsufficientBalance, + message: format!("Failed to pay invoice: {e}"), + }), + result: None, } } } - }; - - let encrypted = encrypt(server_key, &client_pubkey, content.as_json())?; - - let p_tag = Tag::PublicKey { - public_key: event.pubkey, - relay_url: None, - alias: None, - uppercase: false, - }; - let e_tag = Tag::Event { - event_id: event.id, - relay_url: None, - marker: None, - }; - let response = - EventBuilder::new(Kind::WalletConnectResponse, encrypted, [p_tag, e_tag]) - .to_event(&self.server_key)?; - - return Ok(Some(response)); - } - } - } + } + }; - if needs_delete { - nostr_manager.delete_nwc_profile(self.profile.index)?; - } else if needs_save { - nostr_manager.save_nwc_profile(self.clone())?; - } + let encrypted = encrypt( + self.server_key.secret_key()?, + &self.client_key.public_key(), + content.as_json(), + )?; - Ok(None) + let p_tag = Tag::public_key(event.pubkey); + let e_tag = Tag::event(event.id); + + let tags = match params.id { + Some(id) => vec![p_tag, e_tag, Tag::Identifier(id)], + None => vec![p_tag, e_tag], + }; + + let response = EventBuilder::new(Kind::WalletConnectResponse, encrypted, tags) + .to_event(&self.server_key)?; + + Ok(Some(response)) + } + } } pub fn nwc_profile(&self) -> NwcProfile { @@ -767,6 +808,7 @@ impl NostrWalletConnect { .expect("failed to get nwc uri") .map(|uri| uri.to_string()), spending_conditions: self.profile.spending_conditions.clone(), + commands: self.profile.commands.clone(), child_key_index: self.profile.child_key_index, tag: self.profile.tag, label: self.profile.label.clone(), @@ -791,6 +833,8 @@ pub struct NwcProfile { pub nwc_uri: Option, #[serde(default)] pub spending_conditions: SpendingConditions, + /// Allowed commands for this profile + pub commands: Option>, #[serde(default)] pub child_key_index: Option, #[serde(default)] @@ -809,6 +853,7 @@ impl NwcProfile { archived: self.archived, enabled: self.enabled, spending_conditions: self.spending_conditions.clone(), + commands: self.commands.clone(), child_key_index: self.child_key_index, tag: self.tag, label: self.label.clone(), @@ -829,6 +874,9 @@ pub struct PendingNwcInvoice { /// The nostr pubkey of the request /// If this is a DM, this is who sent us the request pub pubkey: nostr::PublicKey, + /// `id` parameter given in the original request + /// This is normally only given for MultiPayInvoice requests + pub identifier: Option, } impl PartialOrd for PendingNwcInvoice { @@ -853,10 +901,10 @@ impl PendingNwcInvoice { /// Return an error string if invalid /// Otherwise returns an optional invoice that should be processed pub(crate) async fn check_valid_nwc_invoice( - invoice: &str, + params: &PayInvoiceRequestParams, invoice_handler: &impl InvoiceHandler, ) -> Result, String> { - let invoice = match Bolt11Invoice::from_str(invoice) { + let invoice = match Bolt11Invoice::from_str(¶ms.invoice) { Ok(invoice) => invoice, Err(_) => return Err("Invalid invoice".to_string()), }; @@ -872,7 +920,13 @@ pub(crate) async fn check_valid_nwc_invoice( invoice_handler.logger(), "NWC Invoice amount not set, cannot pay: {invoice}" ); - return Err("Invoice amount not set".to_string()); + + if params.amount.is_none() { + return Err("Invoice amount not set".to_string()); + } + + // TODO we cannot pay invoices with msat values so for now return an error + return Err("Paying 0 amount invoices is not supported yet".to_string()); } if invoice_handler.skip_hodl_invoices() { @@ -1493,6 +1547,7 @@ mod wasm_test { invoice: Bolt11Invoice::from_str(INVOICE).unwrap(), event_id: EventId::all_zeros(), pubkey: nostr_manager.public_key, + identifier: None, }; // add an unexpired invoice let unexpired = PendingNwcInvoice { @@ -1500,6 +1555,7 @@ mod wasm_test { invoice: create_dummy_invoice(Some(1_000), Network::Regtest, None).0, event_id: EventId::all_zeros(), pubkey: nostr_manager.public_key, + identifier: None, }; storage .set_data( From dd090d6e73a1d680f008dedd81355ed5e8195b76 Mon Sep 17 00:00:00 2001 From: benthecarman Date: Thu, 22 Feb 2024 13:56:16 +0000 Subject: [PATCH 2/8] Allow creating profiles with multiple commands --- mutiny-core/src/lib.rs | 3 +++ mutiny-core/src/nostr/mod.rs | 23 +++++++++++++++++++---- mutiny-core/src/nostr/nwc.rs | 15 +++++++++++++-- mutiny-wasm/src/lib.rs | 27 ++++++++++++++++++++++++++- 4 files changed, 61 insertions(+), 7 deletions(-) diff --git a/mutiny-core/src/lib.rs b/mutiny-core/src/lib.rs index 441a87d32..d834bf86c 100644 --- a/mutiny-core/src/lib.rs +++ b/mutiny-core/src/lib.rs @@ -78,6 +78,7 @@ use ::nostr::nips::nip57; #[cfg(target_arch = "wasm32")] use ::nostr::prelude::rand::rngs::OsRng; use ::nostr::prelude::ZapRequestData; +use ::nostr::nips::nip47::Method; use ::nostr::{EventBuilder, EventId, JsonUtil, Kind}; #[cfg(target_arch = "wasm32")] use ::nostr::{Keys, Tag}; @@ -1653,6 +1654,7 @@ impl MutinyWallet { period: BudgetPeriod::Month, }), NwcProfileTag::Subscription, + vec![Method::PayInvoice], // subscription only needs pay invoice ) .await? .nwc_uri @@ -1662,6 +1664,7 @@ impl MutinyWallet { ProfileType::Reserved(ReservedProfile::MutinySubscription), SpendingConditions::RequireApproval, NwcProfileTag::Subscription, + vec![Method::PayInvoice], // subscription only needs pay invoice ) .await? .nwc_uri diff --git a/mutiny-core/src/nostr/mod.rs b/mutiny-core/src/nostr/mod.rs index 13ab60d37..89c2627ca 100644 --- a/mutiny-core/src/nostr/mod.rs +++ b/mutiny-core/src/nostr/mod.rs @@ -516,6 +516,7 @@ impl NostrManager { profile_type: ProfileType, spending_conditions: SpendingConditions, tag: NwcProfileTag, + commands: Vec, ) -> Result { let mut profiles = self.nwc.try_write()?; @@ -532,7 +533,7 @@ impl NostrManager { tag, client_key: None, label: None, - commands: Some(vec![Method::PayInvoice]), + commands: Some(commands), }; let nwc = NostrWalletConnect::new(&Secp256k1::new(), self.xprivkey, profile)?; @@ -559,9 +560,10 @@ impl NostrManager { profile_type: ProfileType, spending_conditions: SpendingConditions, tag: NwcProfileTag, + commands: Vec, ) -> Result { let profile = - self.create_new_nwc_profile_internal(profile_type, spending_conditions, tag)?; + self.create_new_nwc_profile_internal(profile_type, spending_conditions, tag, commands)?; // add relay if needed let needs_connect = self.client.add_relay(profile.relay.as_str()).await?; if needs_connect { @@ -599,8 +601,13 @@ impl NostrManager { amount_sats, payment_hash: None, }); - self.create_new_nwc_profile(profile, spending_conditions, NwcProfileTag::Gift) - .await + self.create_new_nwc_profile( + profile, + spending_conditions, + NwcProfileTag::Gift, + vec![Method::PayInvoice], // gifting only needs pay invoice + ) + .await } /// Approves a nostr wallet auth request. @@ -1473,6 +1480,7 @@ mod test { ProfileType::Normal { name: name.clone() }, SpendingConditions::default(), Default::default(), + vec![Method::PayInvoice], ) .unwrap(); @@ -1516,6 +1524,7 @@ mod test { ProfileType::Reserved(ReservedProfile::MutinySubscription), SpendingConditions::default(), Default::default(), + vec![Method::PayInvoice], ) .unwrap(); @@ -1549,6 +1558,7 @@ mod test { ProfileType::Normal { name: name.clone() }, SpendingConditions::default(), Default::default(), + vec![Method::PayInvoice], ) .unwrap(); @@ -1566,6 +1576,7 @@ mod test { archived: None, child_key_index: None, spending_conditions: Default::default(), + commands: None, tag: Default::default(), label: None, }; @@ -1632,6 +1643,7 @@ mod test { uri.clone(), None, Default::default(), + vec![Method::PayInvoice], ) .unwrap(); @@ -1681,6 +1693,7 @@ mod test { ProfileType::Normal { name: name.clone() }, SpendingConditions::default(), Default::default(), + vec![Method::PayInvoice], ) .unwrap(); @@ -1722,6 +1735,7 @@ mod test { ProfileType::Normal { name: name.clone() }, SpendingConditions::default(), Default::default(), + vec![Method::PayInvoice], ) .unwrap(); @@ -1754,6 +1768,7 @@ mod test { ProfileType::Normal { name }, SpendingConditions::default(), Default::default(), + vec![Method::PayInvoice], ) .unwrap(); diff --git a/mutiny-core/src/nostr/nwc.rs b/mutiny-core/src/nostr/nwc.rs index 38896eb6b..2a341f203 100644 --- a/mutiny-core/src/nostr/nwc.rs +++ b/mutiny-core/src/nostr/nwc.rs @@ -12,6 +12,7 @@ use bitcoin::secp256k1::{Secp256k1, Signing, ThirtyTwoByteHash}; use chrono::{DateTime, Datelike, Duration, NaiveDateTime, Utc}; use core::fmt; use hex_conservative::DisplayHex; +use itertools::Itertools; use lightning::util::logger::Logger; use lightning::{log_error, log_warn}; use lightning_invoice::Bolt11Invoice; @@ -281,8 +282,14 @@ impl NostrWalletConnect { /// Create Nostr Wallet Connect Info event pub fn create_nwc_info_event(&self) -> anyhow::Result { - let info = EventBuilder::new(Kind::WalletConnectInfo, "pay_invoice".to_string(), []) - .to_event(&self.server_key)?; + let commands = self + .profile + .available_commands() + .iter() + .map(|c| c.to_string()) + .join(" "); + let info = + EventBuilder::new(Kind::WalletConnectInfo, commands, []).to_event(&self.server_key)?; Ok(info) } @@ -1290,6 +1297,7 @@ mod wasm_test { }, SpendingConditions::RequireApproval, NwcProfileTag::General, + vec![Method::PayInvoice], ) .unwrap(); @@ -1348,6 +1356,7 @@ mod wasm_test { }, SpendingConditions::RequireApproval, NwcProfileTag::General, + vec![Method::PayInvoice], ) .unwrap(); @@ -1605,6 +1614,7 @@ mod wasm_test { period: BudgetPeriod::Seconds(10), }), NwcProfileTag::General, + vec![Method::PayInvoice], ) .unwrap(); @@ -1693,6 +1703,7 @@ mod wasm_test { period: BudgetPeriod::Seconds(10), }), NwcProfileTag::General, + vec![Method::PayInvoice], ) .unwrap(); diff --git a/mutiny-wasm/src/lib.rs b/mutiny-wasm/src/lib.rs index 86beb167f..fd6fceed8 100644 --- a/mutiny-wasm/src/lib.rs +++ b/mutiny-wasm/src/lib.rs @@ -49,6 +49,7 @@ use mutiny_core::{ nodemanager::{create_lsp_config, NodeManager}, }; use mutiny_core::{logging::MutinyLogger, nostr::ProfileType}; +use nostr::prelude::Method; use nostr::{Keys, ToBech32}; use std::collections::HashMap; use std::str::FromStr; @@ -1402,7 +1403,16 @@ impl MutinyWallet { pub async fn create_nwc_profile( &self, name: String, + commands: Option>, ) -> Result { + let commands = match commands { + None => vec![Method::PayInvoice], + Some(strs) => strs + .into_iter() + .map(|s| Method::from_str(&s)) + .collect::>() + .map_err(|_| MutinyJsError::InvalidArgumentsError)?, + }; Ok(self .inner .nostr @@ -1410,6 +1420,7 @@ impl MutinyWallet { ProfileType::Normal { name }, SpendingConditions::default(), NwcProfileTag::General, + commands, ) .await? .into()) @@ -1423,7 +1434,16 @@ impl MutinyWallet { budget: u64, period: BudgetPeriod, single_max: Option, + commands: Option>, ) -> Result { + let commands = match commands { + None => vec![Method::PayInvoice], + Some(strs) => strs + .into_iter() + .map(|s| Method::from_str(&s)) + .collect::>() + .map_err(|_| MutinyJsError::InvalidArgumentsError)?, + }; let budget = BudgetedSpendingConditions { budget, period: period.into(), @@ -1435,7 +1455,12 @@ impl MutinyWallet { Ok(self .inner .nostr - .create_new_nwc_profile(ProfileType::Normal { name }, sp, NwcProfileTag::General) + .create_new_nwc_profile( + ProfileType::Normal { name }, + sp, + NwcProfileTag::General, + commands, + ) .await? .into()) } From 8e91f41b32b381b7d47f548c2768b6c3f74566fc Mon Sep 17 00:00:00 2001 From: benthecarman Date: Thu, 22 Feb 2024 14:01:09 +0000 Subject: [PATCH 3/8] Support get balance command --- mutiny-core/src/nostr/nwc.rs | 188 +++++++++++++++++++++++++++++++++- mutiny-core/src/test_utils.rs | 4 + 2 files changed, 191 insertions(+), 1 deletion(-) diff --git a/mutiny-core/src/nostr/nwc.rs b/mutiny-core/src/nostr/nwc.rs index 2a341f203..39f710bab 100644 --- a/mutiny-core/src/nostr/nwc.rs +++ b/mutiny-core/src/nostr/nwc.rs @@ -458,6 +458,7 @@ impl NostrWalletConnect { ) .await? } + RequestParams::GetBalance => self.handle_get_balance_request(event).await?, _ => return Err(anyhow!("Invalid request params for {}", req.method)), }; } @@ -471,6 +472,42 @@ impl NostrWalletConnect { Ok(result) } + async fn handle_get_balance_request(&self, event: Event) -> anyhow::Result> { + // Just return our current budget amount, don't leak our actual wallet balance + let balance_sats = match &self.profile.spending_conditions { + SpendingConditions::SingleUse(single_use) => { + // if this nwc is used, we have no balance remaining + match single_use.payment_hash { + Some(_) => 0, + None => single_use.amount_sats, + } + } + SpendingConditions::Budget(budget) => budget.budget_remaining(), + SpendingConditions::RequireApproval => 0, + }; + + let content = Response { + result_type: Method::GetBalance, + error: None, + result: Some(ResponseResult::GetBalance(GetBalanceResponseResult { + balance: balance_sats * 1_000, // return in msats + })), + }; + + let encrypted = encrypt( + self.server_key.secret_key()?, + &self.client_key.public_key(), + content.as_json(), + )?; + + let p_tag = Tag::public_key(event.pubkey); + let e_tag = Tag::event(event.id); + let response = EventBuilder::new(Kind::WalletConnectResponse, encrypted, [p_tag, e_tag]) + .to_event(&self.server_key)?; + + Ok(Some(response)) + } + async fn handle_pay_invoice_request( &mut self, event: Event, @@ -1237,7 +1274,9 @@ mod wasm_test { use crate::logging::MutinyLogger; use crate::nostr::{NostrKeySource, ProfileType}; use crate::storage::MemoryStorage; - use crate::test_utils::{create_dummy_invoice, create_mutiny_wallet, create_nwc_request}; + use crate::test_utils::{ + create_dummy_invoice, create_mutiny_wallet, create_nwc_request, sign_nwc_request, + }; use crate::MockInvoiceHandler; use crate::MutinyInvoice; use bitcoin::Network; @@ -1743,4 +1782,151 @@ mod wasm_test { _ => panic!("wrong spending conditions"), } } + + #[test] + async fn test_get_balance_require_approval() { + let storage = MemoryStorage::default(); + let mw = create_mutiny_wallet(storage.clone()).await; + + let xprivkey = ExtendedPrivKey::new_master(Network::Regtest, &[0; 64]).unwrap(); + let stop = Arc::new(AtomicBool::new(false)); + let nostr_manager = NostrManager::from_mnemonic( + xprivkey, + NostrKeySource::Derived, + storage.clone(), + None, + mw.logger.clone(), + stop, + ) + .unwrap(); + + let profile = nostr_manager + .create_new_nwc_profile_internal( + ProfileType::Normal { + name: "test".to_string(), + }, + SpendingConditions::RequireApproval, + NwcProfileTag::General, + vec![Method::GetBalance], + ) + .unwrap(); + + let secp = Secp256k1::new(); + let mut nwc = NostrWalletConnect::new(&secp, xprivkey, profile.profile()).unwrap(); + let uri = nwc.get_nwc_uri().unwrap().unwrap(); + + // test get_balance + + let event = sign_nwc_request(&uri, Request::get_balance()); + let result = nwc + .handle_nwc_request(event.clone(), &mw, &nostr_manager) + .await; + let event = result.unwrap().unwrap(); + let content = decrypt(&uri.secret, &event.pubkey, &event.content).unwrap(); + let response: Response = Response::from_json(content).unwrap(); + let balance = response.to_get_balance().unwrap(); + assert_eq!(balance.balance, 0); + } + + #[test] + async fn test_get_balance_budget() { + let storage = MemoryStorage::default(); + let mw = create_mutiny_wallet(storage.clone()).await; + + let xprivkey = ExtendedPrivKey::new_master(Network::Regtest, &[0; 64]).unwrap(); + let stop = Arc::new(AtomicBool::new(false)); + let nostr_manager = NostrManager::from_mnemonic( + xprivkey, + NostrKeySource::Derived, + storage.clone(), + None, + mw.logger.clone(), + stop, + ) + .unwrap(); + + let budget = 10_000; + + let profile = nostr_manager + .create_new_nwc_profile_internal( + ProfileType::Normal { + name: "test".to_string(), + }, + SpendingConditions::Budget(BudgetedSpendingConditions { + budget, + single_max: None, + payments: vec![], + period: BudgetPeriod::Day, + }), + NwcProfileTag::General, + vec![Method::GetBalance], + ) + .unwrap(); + + let secp = Secp256k1::new(); + let mut nwc = NostrWalletConnect::new(&secp, xprivkey, profile.profile()).unwrap(); + let uri = nwc.get_nwc_uri().unwrap().unwrap(); + + // test get_balance + + let event = sign_nwc_request(&uri, Request::get_balance()); + let result = nwc + .handle_nwc_request(event.clone(), &mw, &nostr_manager) + .await; + let event = result.unwrap().unwrap(); + let content = decrypt(&uri.secret, &event.pubkey, &event.content).unwrap(); + let response: Response = Response::from_json(content).unwrap(); + let balance = response.to_get_balance().unwrap(); + assert_eq!(balance.balance, budget * 1_000); // convert to msats + } + + #[test] + async fn test_get_balance_single_use() { + let storage = MemoryStorage::default(); + let mw = create_mutiny_wallet(storage.clone()).await; + + let xprivkey = ExtendedPrivKey::new_master(Network::Regtest, &[0; 64]).unwrap(); + let stop = Arc::new(AtomicBool::new(false)); + let nostr_manager = NostrManager::from_mnemonic( + xprivkey, + NostrKeySource::Derived, + storage.clone(), + None, + mw.logger.clone(), + stop, + ) + .unwrap(); + + let budget = 10_000; + + let profile = nostr_manager + .create_new_nwc_profile_internal( + ProfileType::Normal { + name: "test".to_string(), + }, + SpendingConditions::SingleUse(SingleUseSpendingConditions { + payment_hash: None, + amount_sats: budget, + }), + NwcProfileTag::General, + vec![Method::GetBalance], + ) + .unwrap(); + + let secp = Secp256k1::new(); + let mut nwc = NostrWalletConnect::new(&secp, xprivkey, profile.profile()).unwrap(); + let uri = nwc.get_nwc_uri().unwrap().unwrap(); + + // test get_balance + + let event = sign_nwc_request(&uri, Request::get_balance()); + let result = nwc + .handle_nwc_request(event.clone(), &mw, &nostr_manager) + .await; + let event = result.unwrap().unwrap(); + let content = decrypt(&uri.secret, &event.pubkey, &event.content).unwrap(); + let response: Response = Response::from_json(content).unwrap(); + let balance = response.to_get_balance().unwrap(); + assert_eq!(balance.balance, budget * 1_000); // convert to msats + } } diff --git a/mutiny-core/src/test_utils.rs b/mutiny-core/src/test_utils.rs index 33c2d44d2..4a11b2169 100644 --- a/mutiny-core/src/test_utils.rs +++ b/mutiny-core/src/test_utils.rs @@ -47,6 +47,10 @@ pub fn create_nwc_request(nwc: &NostrWalletConnectURI, invoice: String) -> Event }), }; + sign_nwc_request(nwc, req) +} + +pub fn sign_nwc_request(nwc: &NostrWalletConnectURI, req: Request) -> Event { let encrypted = encrypt(&nwc.secret, &nwc.public_key, req.as_json()).unwrap(); let p_tag = Tag::PublicKey { public_key: nwc.public_key, From a943264c11162c4581704819f08beb995ffcf034 Mon Sep 17 00:00:00 2001 From: benthecarman Date: Thu, 22 Feb 2024 14:24:45 +0000 Subject: [PATCH 4/8] Support get info command --- mutiny-core/src/lib.rs | 12 ++++ mutiny-core/src/nostr/nwc.rs | 118 ++++++++++++++++++++++++++++++++++- 2 files changed, 129 insertions(+), 1 deletion(-) diff --git a/mutiny-core/src/lib.rs b/mutiny-core/src/lib.rs index d834bf86c..182bde277 100644 --- a/mutiny-core/src/lib.rs +++ b/mutiny-core/src/lib.rs @@ -95,6 +95,7 @@ use futures_util::join; use hex_conservative::{DisplayHex, FromHex}; #[cfg(target_arch = "wasm32")] use instant::Instant; +use lightning::chain::BestBlock; use lightning::ln::PaymentHash; use lightning::util::logger::Logger; use lightning::{log_debug, log_error, log_info, log_trace, log_warn}; @@ -131,6 +132,8 @@ const MELT_CASHU_TOKEN: &str = "Cashu Token Melt"; pub trait InvoiceHandler { fn logger(&self) -> &MutinyLogger; fn skip_hodl_invoices(&self) -> bool; + fn get_network(&self) -> Network; + async fn get_best_block(&self) -> Result; async fn get_outbound_payment_status(&self, payment_hash: &[u8; 32]) -> Option; async fn pay_invoice( &self, @@ -2452,6 +2455,15 @@ impl InvoiceHandler for MutinyWallet { self.skip_hodl_invoices } + fn get_network(&self) -> Network { + self.network + } + + async fn get_best_block(&self) -> Result { + let node = self.node_manager.get_node_by_key_or_first(None).await?; + Ok(node.channel_manager.current_best_block()) + } + async fn get_outbound_payment_status(&self, payment_hash: &[u8; 32]) -> Option { self.get_invoice_by_hash(&sha256::Hash::from_byte_array(*payment_hash)) .await diff --git a/mutiny-core/src/nostr/nwc.rs b/mutiny-core/src/nostr/nwc.rs index 39f710bab..03106194f 100644 --- a/mutiny-core/src/nostr/nwc.rs +++ b/mutiny-core/src/nostr/nwc.rs @@ -9,6 +9,7 @@ use anyhow::anyhow; use bitcoin::bip32::ExtendedPrivKey; use bitcoin::hashes::hex::FromHex; use bitcoin::secp256k1::{Secp256k1, Signing, ThirtyTwoByteHash}; +use bitcoin::Network; use chrono::{DateTime, Datelike, Duration, NaiveDateTime, Utc}; use core::fmt; use hex_conservative::DisplayHex; @@ -459,6 +460,7 @@ impl NostrWalletConnect { .await? } RequestParams::GetBalance => self.handle_get_balance_request(event).await?, + RequestParams::GetInfo => self.handle_get_info_request(event, node).await?, _ => return Err(anyhow!("Invalid request params for {}", req.method)), }; } @@ -472,6 +474,56 @@ impl NostrWalletConnect { Ok(result) } + async fn handle_get_info_request( + &self, + event: Event, + node: &impl InvoiceHandler, + ) -> anyhow::Result> { + let network = match node.get_network() { + Network::Bitcoin => "mainnet", + Network::Testnet => "testnet", + Network::Signet => "signet", + Network::Regtest => "regtest", + net => unreachable!("Unknown network: {net}"), + }; + + let block = node.get_best_block().await?; + + let content = Response { + result_type: Method::GetInfo, + error: None, + result: Some(ResponseResult::GetInfo(GetInfoResponseResult { + alias: "Mutiny".to_string(), + color: "000000".to_string(), + // give an arbitrary pubkey, no need to leak ours + pubkey: "02cae09cf2c8842ace44068a5bf3117a494ebbf69a99e79712483c36f97cdb7b54" + .to_string(), + network: network.to_string(), + block_height: block.height(), + block_hash: block.block_hash().to_string(), + methods: self + .profile + .available_commands() + .iter() + .map(|c| c.to_string()) + .collect(), + })), + }; + + let encrypted = encrypt( + self.server_key.secret_key()?, + &self.client_key.public_key(), + content.as_json(), + )?; + + let p_tag = Tag::public_key(event.pubkey); + let e_tag = Tag::event(event.id); + let response = EventBuilder::new(Kind::WalletConnectResponse, encrypted, [p_tag, e_tag]) + .to_event(&self.server_key)?; + + Ok(Some(response)) + } + async fn handle_get_balance_request(&self, event: Event) -> anyhow::Result> { // Just return our current budget amount, don't leak our actual wallet balance let balance_sats = match &self.profile.spending_conditions { @@ -1279,7 +1331,8 @@ mod wasm_test { }; use crate::MockInvoiceHandler; use crate::MutinyInvoice; - use bitcoin::Network; + use bitcoin::{BlockHash, Network}; + use lightning::chain::BestBlock; use mockall::predicate::eq; use nostr::key::SecretKey; use serde_json::json; @@ -1929,4 +1982,67 @@ mod wasm_test { let balance = response.to_get_balance().unwrap(); assert_eq!(balance.balance, budget * 1_000); // convert to msats } + + #[test] + async fn test_get_info() { + let storage = MemoryStorage::default(); + + let xprivkey = ExtendedPrivKey::new_master(Network::Regtest, &[0; 64]).unwrap(); + let stop = Arc::new(AtomicBool::new(false)); + let nostr_manager = NostrManager::from_mnemonic( + xprivkey, + NostrKeySource::Derived, + storage.clone(), + None, + Arc::new(MutinyLogger::default()), + stop, + ) + .unwrap(); + + let best_block = BestBlock::new( + BlockHash::from_str("000000000000000000017dfbca2b8c975abcf0f86a6b19f38b3e4cafeabf56b0") + .unwrap(), + 6969, + ); + + let mut node = MockInvoiceHandler::new(); + node.expect_logger().return_const(MutinyLogger::default()); + node.expect_get_network().return_const(Network::Regtest); + node.expect_get_best_block() + .returning(move || Ok(best_block)); + + let profile = nostr_manager + .create_new_nwc_profile_internal( + ProfileType::Normal { + name: "test".to_string(), + }, + SpendingConditions::RequireApproval, + NwcProfileTag::General, + vec![Method::GetInfo], + ) + .unwrap(); + + let secp = Secp256k1::new(); + let mut nwc = NostrWalletConnect::new(&secp, xprivkey, profile.profile()).unwrap(); + let uri = nwc.get_nwc_uri().unwrap().unwrap(); + + // test get_info + + let event = sign_nwc_request(&uri, Request::get_info()); + let result = nwc + .handle_nwc_request(event.clone(), &node, &nostr_manager) + .await; + let event = result.unwrap().unwrap(); + let content = decrypt(&uri.secret, &event.pubkey, &event.content).unwrap(); + let response: Response = Response::from_json(content).unwrap(); + let info = response.to_get_info().unwrap(); + + assert_eq!(info.network, "regtest"); + assert_eq!( + info.block_hash, + "000000000000000000017dfbca2b8c975abcf0f86a6b19f38b3e4cafeabf56b0" + ); + assert_eq!(info.block_height, best_block.height()); + assert_eq!(info.methods, vec!["get_info"]); + } } From d450acd2e233db61fe1942bd507c1bd382ad9e62 Mon Sep 17 00:00:00 2001 From: benthecarman Date: Thu, 22 Feb 2024 14:48:40 +0000 Subject: [PATCH 5/8] Support make invoice command --- mutiny-core/src/nostr/nwc.rs | 109 +++++++++++++++++++++++++++++++++++ 1 file changed, 109 insertions(+) diff --git a/mutiny-core/src/nostr/nwc.rs b/mutiny-core/src/nostr/nwc.rs index 03106194f..d0908ce1a 100644 --- a/mutiny-core/src/nostr/nwc.rs +++ b/mutiny-core/src/nostr/nwc.rs @@ -459,6 +459,10 @@ impl NostrWalletConnect { ) .await? } + RequestParams::MakeInvoice(params) => { + self.handle_make_invoice_request(event, node, params) + .await? + } RequestParams::GetBalance => self.handle_get_balance_request(event).await?, RequestParams::GetInfo => self.handle_get_info_request(event, node).await?, _ => return Err(anyhow!("Invalid request params for {}", req.method)), @@ -560,6 +564,46 @@ impl NostrWalletConnect { Ok(Some(response)) } + async fn handle_make_invoice_request( + &mut self, + event: Event, + node: &impl InvoiceHandler, + params: MakeInvoiceRequestParams, + ) -> anyhow::Result> { + // FIXME currently we are ignoring the description and expiry params + let amount_sats = params.amount / 1_000; + + let label = self + .profile + .label + .clone() + .unwrap_or(self.profile.name.clone()); + let invoice = node.create_invoice(amount_sats, vec![label]).await?; + let bolt11 = invoice.bolt11.expect("just made"); + + let content = Response { + result_type: Method::MakeInvoice, + error: None, + result: Some(ResponseResult::MakeInvoice(MakeInvoiceResponseResult { + invoice: bolt11.to_string(), + payment_hash: bolt11.payment_hash().to_string(), + })), + }; + + let encrypted = encrypt( + self.server_key.secret_key()?, + &self.client_key.public_key(), + content.as_json(), + )?; + + let p_tag = Tag::public_key(event.pubkey); + let e_tag = Tag::event(event.id); + let response = EventBuilder::new(Kind::WalletConnectResponse, encrypted, [p_tag, e_tag]) + .to_event(&self.server_key)?; + + Ok(Some(response)) + } + async fn handle_pay_invoice_request( &mut self, event: Event, @@ -2045,4 +2089,69 @@ mod wasm_test { assert_eq!(info.block_height, best_block.height()); assert_eq!(info.methods, vec!["get_info"]); } + + #[test] + async fn test_make_invoice() { + let storage = MemoryStorage::default(); + + let xprivkey = ExtendedPrivKey::new_master(Network::Regtest, &[0; 64]).unwrap(); + let stop = Arc::new(AtomicBool::new(false)); + let nostr_manager = NostrManager::from_mnemonic( + xprivkey, + NostrKeySource::Derived, + storage.clone(), + None, + Arc::new(MutinyLogger::default()), + stop, + ) + .unwrap(); + + let amount = 69696969; + let invoice = create_dummy_invoice(Some(amount), Network::Regtest, None).0; + + let mut node = MockInvoiceHandler::new(); + let mutiny_inv: MutinyInvoice = invoice.clone().into(); + node.expect_create_invoice() + .return_once(|_, _| Ok(mutiny_inv)); + + let profile = nostr_manager + .create_new_nwc_profile_internal( + ProfileType::Normal { + name: "test".to_string(), + }, + SpendingConditions::RequireApproval, + NwcProfileTag::General, + vec![Method::MakeInvoice], + ) + .unwrap(); + + let secp = Secp256k1::new(); + let mut nwc = NostrWalletConnect::new(&secp, xprivkey, profile.profile()).unwrap(); + let uri = nwc.get_nwc_uri().unwrap().unwrap(); + + // test make_invoice + + let event = sign_nwc_request( + &uri, + Request::make_invoice(MakeInvoiceRequestParams { + amount, + description: None, + description_hash: None, + expiry: None, + }), + ); + let result = nwc + .handle_nwc_request(event.clone(), &node, &nostr_manager) + .await; + let event = result.unwrap().unwrap(); + let content = decrypt(&uri.secret, &event.pubkey, &event.content).unwrap(); + let response: Response = Response::from_json(content).unwrap(); + let result = response.to_make_invoice().unwrap(); + + assert_eq!(result.invoice, invoice.to_string()); + assert_eq!( + result.payment_hash, + invoice.payment_hash().into_32().to_lower_hex_string() + ); + } } From 6c19078a4838ff2ac2cbc65dd63318ba8a1de01d Mon Sep 17 00:00:00 2001 From: benthecarman Date: Thu, 22 Feb 2024 15:25:15 +0000 Subject: [PATCH 6/8] Add lookup payment to InvoiceHandler --- mutiny-core/src/lib.rs | 26 +++++++++++++++++++++++--- mutiny-core/src/nostr/mod.rs | 14 +++++++++----- mutiny-core/src/nostr/nwc.rs | 29 ++++++++++++++++++++--------- 3 files changed, 52 insertions(+), 17 deletions(-) diff --git a/mutiny-core/src/lib.rs b/mutiny-core/src/lib.rs index 182bde277..149cea8dd 100644 --- a/mutiny-core/src/lib.rs +++ b/mutiny-core/src/lib.rs @@ -134,7 +134,7 @@ pub trait InvoiceHandler { fn skip_hodl_invoices(&self) -> bool; fn get_network(&self) -> Network; async fn get_best_block(&self) -> Result; - async fn get_outbound_payment_status(&self, payment_hash: &[u8; 32]) -> Option; + async fn lookup_payment(&self, payment_hash: &[u8; 32]) -> Option; async fn pay_invoice( &self, invoice: &Bolt11Invoice, @@ -341,6 +341,27 @@ pub struct MutinyInvoice { pub last_updated: u64, } +#[cfg(test)] +impl Default for MutinyInvoice { + fn default() -> Self { + MutinyInvoice { + bolt11: None, + description: None, + payment_hash: sha256::Hash::all_zeros(), + preimage: None, + payee_pubkey: None, + amount_sats: None, + expire: 0, + status: HTLCStatus::Pending, + privacy_level: PrivacyLevel::NotAvailable, + fees_paid: None, + inbound: false, + labels: vec![], + last_updated: 0, + } + } +} + impl MutinyInvoice { pub fn paid(&self) -> bool { self.status == HTLCStatus::Succeeded @@ -2464,11 +2485,10 @@ impl InvoiceHandler for MutinyWallet { Ok(node.channel_manager.current_best_block()) } - async fn get_outbound_payment_status(&self, payment_hash: &[u8; 32]) -> Option { + async fn lookup_payment(&self, payment_hash: &[u8; 32]) -> Option { self.get_invoice_by_hash(&sha256::Hash::from_byte_array(*payment_hash)) .await .ok() - .map(|p| p.status) } async fn pay_invoice( diff --git a/mutiny-core/src/nostr/mod.rs b/mutiny-core/src/nostr/mod.rs index 89c2627ca..0dadd205f 100644 --- a/mutiny-core/src/nostr/mod.rs +++ b/mutiny-core/src/nostr/mod.rs @@ -320,7 +320,11 @@ impl NostrManager { let futures: Vec<_> = indices_to_remove .into_iter() .map(|(index, hash)| async move { - match invoice_handler.get_outbound_payment_status(&hash).await { + match invoice_handler + .lookup_payment(&hash) + .await + .map(|x| x.status) + { Some(HTLCStatus::Succeeded) => Some(index), _ => None, } @@ -1097,11 +1101,11 @@ impl NostrManager { // check if the invoice has been paid, if so, return, otherwise continue // checking for response event - if let Some(status) = invoice_handler - .get_outbound_payment_status(&bolt11.payment_hash().into_32()) + if let Some(inv) = invoice_handler + .lookup_payment(&bolt11.payment_hash().into_32()) .await { - if status == HTLCStatus::Succeeded { + if inv.status == HTLCStatus::Succeeded { break; } } @@ -1436,7 +1440,7 @@ mod test { // add handling for mock inv_handler - .expect_get_outbound_payment_status() + .expect_lookup_payment() .with(eq(invoice.payment_hash().into_32())) .returning(move |_| None); diff --git a/mutiny-core/src/nostr/nwc.rs b/mutiny-core/src/nostr/nwc.rs index d0908ce1a..246b2f5b6 100644 --- a/mutiny-core/src/nostr/nwc.rs +++ b/mutiny-core/src/nostr/nwc.rs @@ -638,7 +638,7 @@ impl NostrWalletConnect { Some(payment_hash) => { let hash: [u8; 32] = FromHex::from_hex(&payment_hash).expect("invalid hash"); - node.get_outbound_payment_status(&hash).await + node.lookup_payment(&hash).await.map(|i| i.status) } None => None, }; @@ -794,7 +794,7 @@ impl NostrWalletConnect { let futures: Vec<_> = indices_to_remove .iter() .map(|(index, hash)| async move { - match node.get_outbound_payment_status(hash).await { + match node.lookup_payment(hash).await.map(|i| i.status) { Some(HTLCStatus::Failed) => Some(*index), _ => None, } @@ -1082,8 +1082,9 @@ pub(crate) async fn check_valid_nwc_invoice( // if we have already paid or are attempting to pay this invoice, skip it if invoice_handler - .get_outbound_payment_status(&invoice.payment_hash().into_32()) + .lookup_payment(&invoice.payment_hash().into_32()) .await + .map(|i| i.status) .is_some_and(|status| matches!(status, HTLCStatus::Succeeded | HTLCStatus::InFlight)) { return Ok(None); @@ -1627,9 +1628,14 @@ mod wasm_test { // test in-flight payment let (invoice, _) = create_dummy_invoice(Some(1_000), Network::Regtest, None); - node.expect_get_outbound_payment_status() + node.expect_lookup_payment() .with(eq(invoice.payment_hash().into_32())) - .returning(move |_| Some(HTLCStatus::InFlight)); + .returning(move |_| { + Some(MutinyInvoice { + status: HTLCStatus::InFlight, + ..Default::default() + }) + }); let event = create_nwc_request(&uri, invoice.to_string()); let result = nwc.handle_nwc_request(event, &node, &nostr_manager).await; assert_eq!(result.unwrap(), None); @@ -1637,9 +1643,14 @@ mod wasm_test { // test completed payment let (invoice, _) = create_dummy_invoice(Some(1_000), Network::Regtest, None); - node.expect_get_outbound_payment_status() + node.expect_lookup_payment() .with(eq(invoice.payment_hash().into_32())) - .returning(move |_| Some(HTLCStatus::Succeeded)); + .returning(move |_| { + Some(MutinyInvoice { + status: HTLCStatus::Succeeded, + ..Default::default() + }) + }); let event = create_nwc_request(&uri, invoice.to_string()); let result = nwc.handle_nwc_request(event, &node, &nostr_manager).await; assert_eq!(result.unwrap(), None); @@ -1647,7 +1658,7 @@ mod wasm_test { // test it goes to pending let (invoice, _) = create_dummy_invoice(Some(1_000), Network::Regtest, None); - node.expect_get_outbound_payment_status() + node.expect_lookup_payment() .with(eq(invoice.payment_hash().into_32())) .returning(move |_| None); let event = create_nwc_request(&uri, invoice.to_string()); @@ -1802,7 +1813,7 @@ mod wasm_test { node.expect_skip_hodl_invoices().once().returning(|| true); node.expect_logger().return_const(MutinyLogger::default()); - node.expect_get_outbound_payment_status().return_const(None); + node.expect_lookup_payment().return_const(None); node.expect_pay_invoice() .once() .returning(move |inv, _, _| { From 3fa008614f670d6e58e4e1863cc749cfc5806d2c Mon Sep 17 00:00:00 2001 From: benthecarman Date: Thu, 22 Feb 2024 15:57:21 +0000 Subject: [PATCH 7/8] Add lookup invoice command --- mutiny-core/src/nostr/nwc.rs | 192 ++++++++++++++++++++++++++++++++++- 1 file changed, 191 insertions(+), 1 deletion(-) diff --git a/mutiny-core/src/nostr/nwc.rs b/mutiny-core/src/nostr/nwc.rs index 246b2f5b6..90232d826 100644 --- a/mutiny-core/src/nostr/nwc.rs +++ b/mutiny-core/src/nostr/nwc.rs @@ -16,7 +16,7 @@ use hex_conservative::DisplayHex; use itertools::Itertools; use lightning::util::logger::Logger; use lightning::{log_error, log_warn}; -use lightning_invoice::Bolt11Invoice; +use lightning_invoice::{Bolt11Invoice, Bolt11InvoiceDescription}; use nostr::nips::nip04::{decrypt, encrypt}; use nostr::nips::nip47::*; use nostr::{Event, EventBuilder, EventId, Filter, JsonUtil, Keys, Kind, Tag, Timestamp}; @@ -463,6 +463,10 @@ impl NostrWalletConnect { self.handle_make_invoice_request(event, node, params) .await? } + RequestParams::LookupInvoice(params) => { + self.handle_lookup_invoice_request(event, node, params) + .await? + } RequestParams::GetBalance => self.handle_get_balance_request(event).await?, RequestParams::GetInfo => self.handle_get_info_request(event, node).await?, _ => return Err(anyhow!("Invalid request params for {}", req.method)), @@ -604,6 +608,109 @@ impl NostrWalletConnect { Ok(Some(response)) } + async fn handle_lookup_invoice_request( + &mut self, + event: Event, + node: &impl InvoiceHandler, + params: LookupInvoiceRequestParams, + ) -> anyhow::Result> { + let invoice = match params.payment_hash { + Some(payment_hash) => { + let hash: [u8; 32] = FromHex::from_hex(&payment_hash).expect("invalid hash"); + node.lookup_payment(&hash).await + } + None => match params.bolt11 { + Some(bolt11) => { + let invoice = Bolt11Invoice::from_str(&bolt11)?; + let hash = invoice.payment_hash().into_32(); + node.lookup_payment(&hash).await + } + None => return Err(anyhow!("No payment_hash or bolt11 provided")), + }, + }; + + let content = match invoice { + None => Response { + result_type: Method::LookupInvoice, + error: Some(NIP47Error { + code: ErrorCode::NotFound, + message: "Invoice not found".to_string(), + }), + 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(), + }; + + Response { + result_type: Method::LookupInvoice, + error: None, + result: Some(ResponseResult::LookupInvoice(result)), + } + } + }; + + let encrypted = encrypt( + self.server_key.secret_key()?, + &self.client_key.public_key(), + content.as_json(), + )?; + + let p_tag = Tag::public_key(event.pubkey); + let e_tag = Tag::event(event.id); + let response = EventBuilder::new(Kind::WalletConnectResponse, encrypted, [p_tag, e_tag]) + .to_event(&self.server_key)?; + + Ok(Some(response)) + } + async fn handle_pay_invoice_request( &mut self, event: Event, @@ -2165,4 +2272,87 @@ mod wasm_test { invoice.payment_hash().into_32().to_lower_hex_string() ); } + + #[test] + async fn test_lookup_invoice() { + let storage = MemoryStorage::default(); + + let xprivkey = ExtendedPrivKey::new_master(Network::Regtest, &[0; 64]).unwrap(); + let stop = Arc::new(AtomicBool::new(false)); + let nostr_manager = NostrManager::from_mnemonic( + xprivkey, + NostrKeySource::Derived, + storage.clone(), + None, + Arc::new(MutinyLogger::default()), + stop, + ) + .unwrap(); + + let mut node = MockInvoiceHandler::new(); + node.expect_lookup_payment().once().returning(|_| None); + + let profile = nostr_manager + .create_new_nwc_profile_internal( + ProfileType::Normal { + name: "test".to_string(), + }, + SpendingConditions::RequireApproval, + NwcProfileTag::General, + vec![Method::LookupInvoice], + ) + .unwrap(); + + let secp = Secp256k1::new(); + let mut nwc = NostrWalletConnect::new(&secp, xprivkey, profile.profile()).unwrap(); + let uri = nwc.get_nwc_uri().unwrap().unwrap(); + + // test lookup_invoice + + // test missing invoice + let event = sign_nwc_request( + &uri, + Request::lookup_invoice(LookupInvoiceRequestParams { + payment_hash: None, + bolt11: Some("lntbs1m1pjrmuu3pp52hk0j956d7s8azaps87amadshnrcvqtkvk06y2nue2w69g6e5vasdqqcqzpgxqyz5vqsp5wu3py6257pa3yzarw0et2200c08r5fu6k3u94yfwmlnc8skdkc9s9qyyssqc783940p82c64qq9pu3xczt4tdxzex9wpjn54486y866aayft2cxxusl9eags4cs3kcmuqdrvhvs0gudpj5r2a6awu4wcq29crpesjcqhdju55".to_string()), + }), + ); + let result = nwc + .handle_nwc_request(event.clone(), &node, &nostr_manager) + .await; + let event = result.unwrap().unwrap(); + let content = decrypt(&uri.secret, &event.pubkey, &event.content).unwrap(); + let response: Response = Response::from_json(content).unwrap(); + let error = response.error.unwrap(); + assert_eq!(error.message, "Invoice not found"); + assert!(matches!(error.code, ErrorCode::NotFound)); + assert_eq!(response.result_type, Method::LookupInvoice); + + // test found invoice + let invoice = create_dummy_invoice(Some(69696969), Network::Regtest, None).0; + let mutiny_inv: MutinyInvoice = invoice.clone().into(); + node.expect_lookup_payment() + .once() + .returning(move |_| Some(mutiny_inv.clone())); + + let event = sign_nwc_request( + &uri, + Request::lookup_invoice(LookupInvoiceRequestParams { + payment_hash: None, + bolt11: Some(invoice.to_string()), + }), + ); + let result = nwc + .handle_nwc_request(event.clone(), &node, &nostr_manager) + .await; + let event = result.unwrap().unwrap(); + let content = decrypt(&uri.secret, &event.pubkey, &event.content).unwrap(); + let response: Response = Response::from_json(content).unwrap(); + let result = response.to_lookup_invoice().unwrap(); + + assert_eq!(result.invoice, Some(invoice.to_string())); + assert_eq!(result.transaction_type, Some(TransactionType::Incoming)); + assert_eq!(result.preimage, None); + assert_ne!(result.created_at, 0); // make sure we properly set this + } } From 3168e8644f74b9fa719c1b205bb41208fbb93f55 Mon Sep 17 00:00:00 2001 From: benthecarman Date: Mon, 18 Mar 2024 16:29:04 -0500 Subject: [PATCH 8/8] Default to all commands supported --- mutiny-core/src/lib.rs | 2 +- mutiny-wasm/src/lib.rs | 16 ++++++++++++++-- 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/mutiny-core/src/lib.rs b/mutiny-core/src/lib.rs index 149cea8dd..19e335210 100644 --- a/mutiny-core/src/lib.rs +++ b/mutiny-core/src/lib.rs @@ -74,11 +74,11 @@ use crate::{ subscription::MutinySubscriptionClient, }; use crate::{nostr::NostrManager, utils::sleep}; +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::nips::nip47::Method; use ::nostr::{EventBuilder, EventId, JsonUtil, Kind}; #[cfg(target_arch = "wasm32")] use ::nostr::{Keys, Tag}; diff --git a/mutiny-wasm/src/lib.rs b/mutiny-wasm/src/lib.rs index fd6fceed8..5944db060 100644 --- a/mutiny-wasm/src/lib.rs +++ b/mutiny-wasm/src/lib.rs @@ -1406,7 +1406,13 @@ impl MutinyWallet { commands: Option>, ) -> Result { let commands = match commands { - None => vec![Method::PayInvoice], + None => vec![ + Method::PayInvoice, + Method::GetInfo, + Method::GetBalance, + Method::LookupInvoice, + Method::MakeInvoice, + ], Some(strs) => strs .into_iter() .map(|s| Method::from_str(&s)) @@ -1437,7 +1443,13 @@ impl MutinyWallet { commands: Option>, ) -> Result { let commands = match commands { - None => vec![Method::PayInvoice], + None => vec![ + Method::PayInvoice, + Method::GetInfo, + Method::GetBalance, + Method::LookupInvoice, + Method::MakeInvoice, + ], Some(strs) => strs .into_iter() .map(|s| Method::from_str(&s))