diff --git a/mutiny-core/src/lib.rs b/mutiny-core/src/lib.rs index c047eec4d..99540f569 100644 --- a/mutiny-core/src/lib.rs +++ b/mutiny-core/src/lib.rs @@ -46,6 +46,7 @@ pub use crate::ldkstorage::{CHANNEL_MANAGER_KEY, MONITORS_PREFIX_KEY}; use crate::auth::MutinyAuthClient; use crate::labels::{Contact, LabelStorage}; +use crate::nostr::nwc::SpendingConditions; use crate::storage::MutinyStorage; use crate::{error::MutinyError, nostr::ReservedProfile}; use crate::{nodemanager::NodeManager, nostr::ProfileType}; @@ -296,7 +297,7 @@ impl MutinyWallet { .nostr .create_new_nwc_profile( ProfileType::Reserved(ReservedProfile::MutinySubscription), - 21_000, + SpendingConditions::RequireApproval, ) .await?; profile.nwc_uri diff --git a/mutiny-core/src/nostr/mod.rs b/mutiny-core/src/nostr/mod.rs index 14003b496..b61edacfa 100644 --- a/mutiny-core/src/nostr/mod.rs +++ b/mutiny-core/src/nostr/mod.rs @@ -1,7 +1,8 @@ use crate::error::MutinyError; use crate::nodemanager::NodeManager; use crate::nostr::nwc::{ - NostrWalletConnect, NwcProfile, PendingNwcInvoice, Profile, PENDING_NWC_EVENTS_KEY, + NostrWalletConnect, NwcProfile, PendingNwcInvoice, Profile, SingleUseSpendingConditions, + SpendingConditions, PENDING_NWC_EVENTS_KEY, }; use crate::storage::MutinyStorage; use bitcoin::hashes::sha256; @@ -110,6 +111,7 @@ impl NostrManager { .read() .unwrap() .iter() + .filter(|x| !x.profile.archived) .map(|x| x.nwc_profile()) .collect() } @@ -143,7 +145,7 @@ impl NostrManager { pub(crate) fn create_new_profile( &self, profile_type: ProfileType, - max_single_amt_sats: u64, + spending_conditions: SpendingConditions, ) -> Result { let mut profiles = self.nwc.write().unwrap(); @@ -166,10 +168,10 @@ impl NostrManager { let profile = Profile { name, index, - max_single_amt_sats, relay: "wss://nostr.mutinywallet.com".to_string(), enabled: true, - require_approval: true, + archived: false, + spending_conditions, }; let nwc = NostrWalletConnect::new(&Secp256k1::new(), self.xprivkey, profile)?; @@ -193,9 +195,9 @@ impl NostrManager { pub async fn create_new_nwc_profile( &self, profile_type: ProfileType, - max_single_amt_sats: u64, + spending_conditions: SpendingConditions, ) -> Result { - let profile = self.create_new_profile(profile_type, max_single_amt_sats)?; + let profile = self.create_new_profile(profile_type, spending_conditions)?; let info_event = self.nwc.read().unwrap().iter().find_map(|nwc| { if nwc.profile.index == profile.index { @@ -227,6 +229,21 @@ impl NostrManager { Ok(profile) } + pub async fn create_single_use_nwc( + &self, + name: String, + amount_sats: u64, + ) -> Result { + let profile = ProfileType::Normal { name }; + + let spending_conditions = SpendingConditions::SingleUse(SingleUseSpendingConditions { + amount_sats, + spent: false, + }); + self.create_new_nwc_profile(profile, spending_conditions) + .await + } + /// Lists all pending NWC invoices pub fn get_pending_nwc_invoices(&self) -> Result, MutinyError> { Ok(self @@ -405,8 +422,8 @@ impl NostrManager { .cloned() }; - if let Some(nwc) = nwc { - let event = nwc + if let Some(mut nwc) = nwc { + let (event, needs_save) = nwc .handle_nwc_request( event, node_manager, @@ -414,12 +431,77 @@ impl NostrManager { self.pending_nwc_lock.deref(), ) .await?; + + // update the profile if needed + if needs_save { + let mut vec = self.nwc.write().unwrap(); + + // update the profile + for item in vec.iter_mut() { + if item.profile.index == nwc.profile.index { + item.profile = nwc.profile; + break; + } + } + + let profiles = vec.iter().map(|x| x.profile.clone()).collect::>(); + drop(vec); // drop the lock, no longer needed + + self.storage.set_data(NWC_STORAGE_KEY, profiles, None)?; + } + Ok(event) } else { Ok(None) } } + pub async fn claim_single_use_nwc( + &self, + amount_sats: u64, + nwc_uri: &str, + node_manager: &NodeManager, + ) -> anyhow::Result { + let nwc = NostrWalletConnectURI::from_str(nwc_uri)?; + let secret = Keys::new(nwc.secret); + let client = Client::new(&secret); + + #[cfg(target_arch = "wasm32")] + let add_relay_res = client.add_relay(nwc.relay_url.as_str()).await; + + #[cfg(not(target_arch = "wasm32"))] + let add_relay_res = client.add_relay(nwc.relay_url.as_str(), None).await; + + add_relay_res.expect("Failed to add relays"); + client.connect().await; + + let invoice = node_manager + .create_invoice(Some(amount_sats), vec!["Gift".to_string()]) + .await?; + + let req = Request { + method: Method::PayInvoice, + params: RequestParams { + invoice: invoice.bolt11.unwrap().to_string(), + }, + }; + let encrypted = encrypt(&nwc.secret, &nwc.public_key, req.as_json())?; + let p_tag = Tag::PubKey(nwc.public_key, None); + let request_event = + EventBuilder::new(Kind::WalletConnectRequest, encrypted, &[p_tag]).to_event(&secret)?; + + client + .send_event(request_event.clone()) + .await + .map_err(|e| { + MutinyError::Other(anyhow::anyhow!("Failed to send request event: {e:?}")) + })?; + + client.disconnect().await?; + + Ok(request_event.id) + } + /// Derives the client and server keys for Nostr Wallet Connect given a profile index /// The left key is the client key and the right key is the server key pub(crate) fn derive_nwc_keys( @@ -524,24 +606,21 @@ mod test { let nostr_manager = create_nostr_manager(); let name = "test".to_string(); - let max_single_amt_sats = 1_000; let profile = nostr_manager .create_new_profile( ProfileType::Normal { name: name.clone() }, - max_single_amt_sats, + SpendingConditions::default(), ) .unwrap(); assert_eq!(profile.name, name); assert_eq!(profile.index, 1000); - assert_eq!(profile.max_single_amt_sats, max_single_amt_sats); let profiles = nostr_manager.profiles(); assert_eq!(profiles.len(), 1); assert_eq!(profiles[0].name, name); assert_eq!(profiles[0].index, 1000); - assert_eq!(profiles[0].max_single_amt_sats, max_single_amt_sats); let profiles: Vec = nostr_manager .storage @@ -552,7 +631,6 @@ mod test { assert_eq!(profiles.len(), 1); assert_eq!(profiles[0].name, name); assert_eq!(profiles[0].index, 1000); - assert_eq!(profiles[0].max_single_amt_sats, max_single_amt_sats); } #[test] @@ -560,24 +638,21 @@ mod test { let nostr_manager = create_nostr_manager(); let name = "Mutiny+ Subscription".to_string(); - let max_single_amt_sats = 1_000; let profile = nostr_manager .create_new_profile( ProfileType::Reserved(ReservedProfile::MutinySubscription), - max_single_amt_sats, + SpendingConditions::default(), ) .unwrap(); assert_eq!(profile.name, name); assert_eq!(profile.index, 0); - assert_eq!(profile.max_single_amt_sats, max_single_amt_sats); let profiles = nostr_manager.profiles(); assert_eq!(profiles.len(), 1); assert_eq!(profiles[0].name, name); assert_eq!(profiles[0].index, 0); - assert_eq!(profiles[0].max_single_amt_sats, max_single_amt_sats); let profiles: Vec = nostr_manager .storage @@ -588,22 +663,19 @@ mod test { assert_eq!(profiles.len(), 1); assert_eq!(profiles[0].name, name); assert_eq!(profiles[0].index, 0); - assert_eq!(profiles[0].max_single_amt_sats, max_single_amt_sats); // now create normal profile let name = "test".to_string(); - let max_single_amt_sats = 1_000; let profile = nostr_manager .create_new_profile( ProfileType::Normal { name: name.clone() }, - max_single_amt_sats, + SpendingConditions::default(), ) .unwrap(); assert_eq!(profile.name, name); assert_eq!(profile.index, 1000); - assert_eq!(profile.max_single_amt_sats, max_single_amt_sats); } #[test] @@ -611,19 +683,17 @@ mod test { let nostr_manager = create_nostr_manager(); let name = "test".to_string(); - let max_single_amt_sats = 1_000; let mut profile = nostr_manager .create_new_profile( ProfileType::Normal { name: name.clone() }, - max_single_amt_sats, + SpendingConditions::default(), ) .unwrap(); assert_eq!(profile.name, name); assert_eq!(profile.index, 1000); assert!(profile.enabled); - assert_eq!(profile.max_single_amt_sats, max_single_amt_sats); profile.enabled = false; @@ -634,7 +704,6 @@ mod test { assert_eq!(profiles[0].name, name); assert_eq!(profiles[0].index, 1000); assert!(!profiles[0].enabled); - assert_eq!(profiles[0].max_single_amt_sats, max_single_amt_sats); let profiles: Vec = nostr_manager .storage @@ -646,7 +715,6 @@ mod test { assert_eq!(profiles[0].name, name); assert_eq!(profiles[0].index, 1000); assert!(!profiles[0].enabled); - assert_eq!(profiles[0].max_single_amt_sats, max_single_amt_sats); } #[test] @@ -654,10 +722,9 @@ mod test { let nostr_manager = create_nostr_manager(); let name = "test".to_string(); - let max_single_amt_sats = 1_000; let profile = nostr_manager - .create_new_profile(ProfileType::Normal { name }, max_single_amt_sats) + .create_new_profile(ProfileType::Normal { name }, SpendingConditions::default()) .unwrap(); let inv = PendingNwcInvoice { diff --git a/mutiny-core/src/nostr/nwc.rs b/mutiny-core/src/nostr/nwc.rs index cafc7e1f6..5c0cb0d70 100644 --- a/mutiny-core/src/nostr/nwc.rs +++ b/mutiny-core/src/nostr/nwc.rs @@ -20,21 +20,41 @@ use std::str::FromStr; pub(crate) const PENDING_NWC_EVENTS_KEY: &str = "pending_nwc_events"; +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub struct SingleUseSpendingConditions { + pub spent: bool, + pub amount_sats: u64, +} + +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub enum SpendingConditions { + SingleUse(SingleUseSpendingConditions), + /// Require approval before sending a payment + RequireApproval, +} + +impl Default for SpendingConditions { + fn default() -> Self { + Self::RequireApproval + } +} + #[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] pub(crate) struct Profile { pub name: String, pub index: u32, - /// Maximum amount of sats that can be sent in a single payment - pub max_single_amt_sats: u64, pub relay: String, pub enabled: bool, + /// Archived profiles will not be displayed + #[serde(default)] + pub archived: bool, /// Require approval before sending a payment #[serde(default)] - pub require_approval: bool, + pub spending_conditions: SpendingConditions, } impl PartialOrd for Profile { - fn partial_cmp(&self, other: &Self) -> Option { + fn partial_cmp(&self, other: &Self) -> Option { self.index.partial_cmp(&other.index) } } @@ -107,7 +127,6 @@ impl NostrWalletConnect { from_node: &PublicKey, invoice: &Bolt11Invoice, ) -> Result { - // todo we could get the author of the event we zapping and use that as the label let labels = vec![self.profile.name.clone()]; match node_manager .pay_invoice(from_node, invoice, None, labels) @@ -129,15 +148,18 @@ impl NostrWalletConnect { } } - /// Handle a Nostr Wallet Connect request, returns a response event if one is needed + /// Handle a Nostr Wallet Connect request + /// + /// Returns a response event if one is needed and if the profile needs to be saved to disk pub async fn handle_nwc_request( - &self, + &mut self, event: Event, node_manager: &NodeManager, from_node: &PublicKey, pending_nwc_lock: &Mutex<()>, - ) -> anyhow::Result> { + ) -> anyhow::Result<(Option, bool)> { let client_pubkey = self.client_key.public_key(); + let mut needs_save = false; if self.profile.enabled && event.kind == Kind::WalletConnectRequest && event.pubkey == client_pubkey @@ -149,7 +171,7 @@ impl NostrWalletConnect { // only respond to pay invoice requests if req.method != Method::PayInvoice { - return Ok(None); + return Ok((None, needs_save)); } let invoice = Bolt11Invoice::from_str(&req.params.invoice) @@ -157,7 +179,7 @@ impl NostrWalletConnect { // if the invoice has expired, skip it if invoice.would_expire(utils::now()) { - return Ok(None); + return Ok((None, needs_save)); } // if the invoice has no amount, we cannot pay it @@ -166,99 +188,117 @@ impl NostrWalletConnect { node_manager.logger, "NWC Invoice amount not set, cannot pay: {invoice}" ); - return Ok(None); + return Ok((None, needs_save)); } // if we have already paid this invoice, skip it let node = node_manager.get_node(from_node).await?; if node.get_invoice(&invoice).is_ok_and(|i| i.paid) { - return Ok(None); + return Ok((None, needs_save)); } drop(node); // if we need approval, just save in the db for later - if self.profile.require_approval { - let pending = PendingNwcInvoice { - index: self.profile.index, - invoice, - event_id: event.id, - pubkey: event.pubkey, - }; - pending_nwc_lock.lock().await; - - let mut current: Vec = node_manager - .storage - .get_data(PENDING_NWC_EVENTS_KEY)? - .unwrap_or_default(); - - current.push(pending); - current.sort(); - current.dedup(); - - node_manager - .storage - .set_data(PENDING_NWC_EVENTS_KEY, current, None)?; - - return Ok(None); - } else { - let msats = invoice.amount_milli_satoshis().unwrap(); - - // verify amount is under our limit - let content = if msats <= self.profile.max_single_amt_sats * 1_000 { - match self - .pay_nwc_invoice(node_manager, from_node, &invoice) - .await - { - Ok(resp) => resp, - Err(e) => Response { + match self.profile.spending_conditions.clone() { + SpendingConditions::SingleUse(mut single_use) => { + // check if we have already spent + if single_use.spent { + return Ok((None, needs_save)); + } + + let msats = invoice.amount_milli_satoshis().unwrap(); + + // verify amount is under our limit + let content = if msats <= single_use.amount_sats * 1_000 { + match self + .pay_nwc_invoice(node_manager, from_node, &invoice) + .await + { + Ok(resp) => { + // mark as spent and disable profile + single_use.spent = true; + self.profile.spending_conditions = + SpendingConditions::SingleUse(single_use); + self.profile.enabled = false; + self.profile.archived = true; + needs_save = true; + resp + } + Err(e) => { + // todo handle timeout errors + Response { + result_type: Method::PayInvoice, + error: Some(NIP47Error { + code: ErrorCode::InsufficantBalance, + message: format!("Failed to pay invoice: {e}"), + }), + result: None, + } + } + } + } else { + log_warn!( + node_manager.logger, + "Invoice amount too high: {msats} msats" + ); + + Response { result_type: Method::PayInvoice, error: Some(NIP47Error { - code: ErrorCode::InsufficantBalance, - message: format!("Failed to pay invoice: {e}"), + code: ErrorCode::QuotaExceeded, + message: format!("Invoice amount too high: {msats} msats"), }), result: None, - }, - } - } else { - log_warn!( - node_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, - } - }; - - let encrypted = encrypt(&server_key, &client_pubkey, content.as_json())?; - - let p_tag = Tag::PubKey(event.pubkey, None); - let e_tag = Tag::Event(event.id, None, None); - let response = - EventBuilder::new(Kind::WalletConnectResponse, encrypted, &[p_tag, e_tag]) - .to_event(&self.server_key)?; - - return Ok(Some(response)); + } + }; + + let encrypted = encrypt(&server_key, &client_pubkey, content.as_json())?; + + let p_tag = Tag::PubKey(event.pubkey, None); + let e_tag = Tag::Event(event.id, None, None); + let response = + EventBuilder::new(Kind::WalletConnectResponse, encrypted, &[p_tag, e_tag]) + .to_event(&self.server_key)?; + + return Ok((Some(response), needs_save)); + } + SpendingConditions::RequireApproval => { + let pending = PendingNwcInvoice { + index: self.profile.index, + invoice, + event_id: event.id, + pubkey: event.pubkey, + }; + pending_nwc_lock.lock().await; + + let mut current: Vec = node_manager + .storage + .get_data(PENDING_NWC_EVENTS_KEY)? + .unwrap_or_default(); + + current.push(pending); + + node_manager + .storage + .set_data(PENDING_NWC_EVENTS_KEY, current, None)?; + + return Ok((None, needs_save)); + } } } - Ok(None) + Ok((None, needs_save)) } pub fn nwc_profile(&self) -> NwcProfile { NwcProfile { name: self.profile.name.clone(), index: self.profile.index, - max_single_amt_sats: self.profile.max_single_amt_sats, relay: self.profile.relay.clone(), enabled: self.profile.enabled, - require_approval: self.profile.require_approval, + archived: self.profile.archived, nwc_uri: self.get_nwc_uri().expect("failed to get nwc uri"), + spending_conditions: self.profile.spending_conditions.clone(), } } } @@ -268,13 +308,13 @@ impl NostrWalletConnect { pub struct NwcProfile { pub name: String, pub index: u32, - /// Maximum amount of sats that can be sent in a single payment - pub max_single_amt_sats: u64, pub relay: String, pub enabled: bool, - /// Require approval before sending a payment - pub require_approval: bool, + #[serde(default)] + pub archived: bool, pub nwc_uri: String, + #[serde(default)] + pub spending_conditions: SpendingConditions, } impl NwcProfile { @@ -282,10 +322,10 @@ impl NwcProfile { Profile { name: self.name.clone(), index: self.index, - max_single_amt_sats: self.max_single_amt_sats, relay: self.relay.clone(), - require_approval: self.require_approval, + archived: self.archived, enabled: self.enabled, + spending_conditions: self.spending_conditions.clone(), } } } diff --git a/mutiny-wasm/Cargo.toml b/mutiny-wasm/Cargo.toml index 5e0b485f9..41424fa7b 100644 --- a/mutiny-wasm/Cargo.toml +++ b/mutiny-wasm/Cargo.toml @@ -40,6 +40,7 @@ web-sys = { version = "0.3.60", features = ["console"] } bip39 = { version = "2.0.0" } getrandom = { version = "0.2", features = ["js"] } futures = "0.3.25" +urlencoding = "2.1.2" # The `console_error_panic_hook` crate provides better debugging of panics by # logging them with `console.error`. This is great for development, but requires diff --git a/mutiny-wasm/src/lib.rs b/mutiny-wasm/src/lib.rs index 4208120d6..26f971d95 100644 --- a/mutiny-wasm/src/lib.rs +++ b/mutiny-wasm/src/lib.rs @@ -26,6 +26,7 @@ use lightning_invoice::Bolt11Invoice; use lnurl::lnurl::LnUrl; use mutiny_core::auth::MutinyAuthClient; use mutiny_core::lnurlauth::AuthManager; +use mutiny_core::nostr::nwc::SpendingConditions; use mutiny_core::redshift::RedshiftManager; use mutiny_core::redshift::RedshiftRecipient; use mutiny_core::scb::EncryptedSCB; @@ -1066,12 +1067,11 @@ impl MutinyWallet { pub async fn create_nwc_profile( &self, name: String, - max_single_amt_sats: u64, ) -> Result { Ok(self .inner .nostr - .create_new_nwc_profile(ProfileType::Normal { name }, max_single_amt_sats) + .create_new_nwc_profile(ProfileType::Normal { name }, SpendingConditions::default()) .await? .into()) } @@ -1089,6 +1089,37 @@ impl MutinyWallet { Ok(self.inner.nostr.edit_profile(profile)?.into()) } + /// Create a single use nostr wallet connect profile + #[wasm_bindgen] + pub async fn create_single_use_nwc( + &self, + name: String, + amount_sats: u64, + ) -> Result { + Ok(self + .inner + .nostr + .create_single_use_nwc(name, amount_sats) + .await? + .into()) + } + + /// Create a single use nostr wallet connect profile + #[wasm_bindgen] + pub async fn claim_single_use_nwc( + &self, + amount_sats: u64, + nwc_uri: String, + ) -> Result { + Ok(self + .inner + .nostr + .claim_single_use_nwc(amount_sats, &nwc_uri, self.inner.node_manager.as_ref()) + .await + .map_err(|_| MutinyJsError::UnknownError)? + .to_hex()) + } + /// Get nostr wallet connect URI #[wasm_bindgen] pub fn get_nwc_uri(&self, index: u32) -> Result { diff --git a/mutiny-wasm/src/models.rs b/mutiny-wasm/src/models.rs index 4d5aefc86..1b5c7d8cc 100644 --- a/mutiny-wasm/src/models.rs +++ b/mutiny-wasm/src/models.rs @@ -8,6 +8,7 @@ use lightning_invoice::{Bolt11Invoice, Bolt11InvoiceDescription}; use lnurl::lightning_address::LightningAddress; use lnurl::lnurl::LnUrl; use mutiny_core::labels::Contact as MutinyContact; +use mutiny_core::nostr::nwc::SpendingConditions; use mutiny_core::redshift::{RedshiftRecipient, RedshiftStatus}; use mutiny_core::*; use serde::{Deserialize, Serialize}; @@ -830,6 +831,7 @@ pub struct NwcProfile { pub enabled: bool, /// Require approval before sending a payment pub require_approval: bool, + spending_conditions: SpendingConditions, nwc_uri: String, } @@ -854,17 +856,43 @@ impl NwcProfile { pub fn nwc_uri(&self) -> String { self.nwc_uri.clone() } + + #[wasm_bindgen] + pub fn sharable_url(&self, url_prefix: String) -> Option { + match self.spending_conditions { + SpendingConditions::SingleUse(ref single_use) => { + let encoded = urlencoding::encode(&self.nwc_uri); + Some(format!( + "{}/gift?amount={}&nwc_uri={}", + url_prefix, single_use.amount_sats, encoded + )) + } + SpendingConditions::RequireApproval => None, + } + } } impl From for NwcProfile { fn from(value: nostr::nwc::NwcProfile) -> Self { + let (require_approval, max_single_amt_sats) = match value.spending_conditions.clone() { + SpendingConditions::SingleUse(single) => { + if single.spent { + (false, 0) + } else { + (false, single.amount_sats) + } + } + SpendingConditions::RequireApproval => (true, 0), + }; + NwcProfile { name: value.name, index: value.index, - max_single_amt_sats: value.max_single_amt_sats, relay: value.relay, + max_single_amt_sats, enabled: value.enabled, - require_approval: value.require_approval, + require_approval, + spending_conditions: value.spending_conditions, nwc_uri: value.nwc_uri, } }