diff --git a/Cargo.lock b/Cargo.lock index 46affa2fe..4f2b9aa65 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2367,6 +2367,7 @@ dependencies = [ "chacha20", "getrandom", "instant", + "js-sys", "negentropy", "once_cell", "reqwest", @@ -2375,6 +2376,8 @@ dependencies = [ "tracing", "url-fork", "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", ] [[package]] diff --git a/mutiny-core/Cargo.toml b/mutiny-core/Cargo.toml index ee87dc771..75f97a3d9 100644 --- a/mutiny-core/Cargo.toml +++ b/mutiny-core/Cargo.toml @@ -38,8 +38,8 @@ futures-util = { version = "0.3", default-features = false } reqwest = { version = "0.11", default-features = false, features = ["json"] } async-trait = "0.1.68" url = { version = "2.3.1", features = ["serde"] } -nostr = { version = "0.27.0", default-features = false, features = ["nip05", "nip47", "nip57"] } -nostr-sdk = { version = "0.27.0", default-features = false } +nostr = { version = "0.27.0", default-features = false, features = ["nip04", "nip05", "nip47", "nip57"] } +nostr-sdk = { version = "0.27.0", default-features = false, features = ["nip04", "nip05", "nip47", "nip57"] } cbc = { version = "0.1", features = ["alloc"] } aes = { version = "0.8" } jwt-compact = { version = "0.8.0-beta.1", features = ["es256k"] } @@ -84,6 +84,9 @@ js-sys = "0.3.65" gloo-net = { version = "0.4.0" } instant = { version = "0.1", features = ["wasm-bindgen"] } getrandom = { version = "0.2", features = ["js"] } +# add nip07 feature for wasm32 +nostr = { version = "0.27.0", default-features = false, features = ["nip04", "nip05", "nip07", "nip47", "nip57"] } +nostr-sdk = { version = "0.27.0", default-features = false, features = ["nip04", "nip05", "nip07", "nip47", "nip57"] } [target.'cfg(not(target_arch = "wasm32"))'.dependencies] tokio = { version = "1", features = ["rt"] } diff --git a/mutiny-core/src/error.rs b/mutiny-core/src/error.rs index 6a545a88e..5ac21f81f 100644 --- a/mutiny-core/src/error.rs +++ b/mutiny-core/src/error.rs @@ -143,6 +143,9 @@ pub enum MutinyError { /// Error getting nostr data #[error("Failed to get nostr data.")] NostrError, + /// Error with Nip07 Extension + #[error("Error with NIP-07 extension")] + Nip07Extension, /// Incorrect password entered. #[error("Incorrect password entered.")] IncorrectPassword, @@ -463,6 +466,13 @@ impl From for MutinyError { } } +#[cfg(target_arch = "wasm32")] +impl From for MutinyError { + fn from(_e: nostr::nips::nip07::Error) -> Self { + Self::Nip07Extension + } +} + impl From for MutinyError { fn from(_e: nostr::nips::nip57::Error) -> Self { Self::NostrError diff --git a/mutiny-core/src/lib.rs b/mutiny-core/src/lib.rs index 8b34562be..33e659dfb 100644 --- a/mutiny-core/src/lib.rs +++ b/mutiny-core/src/lib.rs @@ -69,7 +69,7 @@ use crate::{nostr::NostrManager, utils::sleep}; use ::nostr::key::XOnlyPublicKey; use ::nostr::nips::nip57; use ::nostr::prelude::ZapRequestData; -use ::nostr::{JsonUtil, Kind}; +use ::nostr::{Event, JsonUtil, Kind}; use async_lock::RwLock; use bdk_chain::ConfirmationTime; use bip39::Mnemonic; @@ -94,6 +94,7 @@ use std::{str::FromStr, sync::atomic::Ordering}; use uuid::Uuid; use crate::labels::LabelItem; +use crate::nostr::NostrKeySource; use crate::utils::parse_profile_metadata; #[cfg(test)] use mockall::{automock, predicate::*}; @@ -416,12 +417,38 @@ pub struct MutinyWalletConfigBuilder { auth_client: Option>, subscription_url: Option, scorer_url: Option, + primal_url: Option, do_not_connect_peers: bool, skip_device_lock: bool, pub safe_mode: bool, skip_hodl_invoices: bool, } +#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)] +pub struct DirectMessage { + pub from: XOnlyPublicKey, + pub to: XOnlyPublicKey, + pub message: String, + pub date: u64, +} + +impl PartialOrd for DirectMessage { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) + } +} + +impl Ord for DirectMessage { + fn cmp(&self, other: &Self) -> core::cmp::Ordering { + // order by date, then the message, the keys + self.date + .cmp(&other.date) + .then_with(|| self.message.cmp(&other.message)) + .then_with(|| self.from.cmp(&other.from)) + .then_with(|| self.to.cmp(&other.to)) + } +} + impl MutinyWalletConfigBuilder { pub fn new(xprivkey: ExtendedPrivKey) -> MutinyWalletConfigBuilder { MutinyWalletConfigBuilder { @@ -437,6 +464,7 @@ impl MutinyWalletConfigBuilder { auth_client: None, subscription_url: None, scorer_url: None, + primal_url: None, do_not_connect_peers: false, skip_device_lock: false, safe_mode: false, @@ -487,6 +515,10 @@ impl MutinyWalletConfigBuilder { self.scorer_url = Some(scorer_url); } + pub fn with_primal_url(&mut self, primal_url: String) { + self.primal_url = Some(primal_url); + } + pub fn do_not_connect_peers(&mut self) { self.do_not_connect_peers = true; } @@ -520,6 +552,7 @@ impl MutinyWalletConfigBuilder { auth_client: self.auth_client, subscription_url: self.subscription_url, scorer_url: self.scorer_url, + primal_url: self.primal_url, do_not_connect_peers: self.do_not_connect_peers, skip_device_lock: self.skip_device_lock, safe_mode: self.safe_mode, @@ -542,6 +575,7 @@ pub struct MutinyWalletConfig { auth_client: Option>, subscription_url: Option, scorer_url: Option, + primal_url: Option, do_not_connect_peers: bool, skip_device_lock: bool, pub safe_mode: bool, @@ -550,6 +584,7 @@ pub struct MutinyWalletConfig { pub struct MutinyWalletBuilder { xprivkey: ExtendedPrivKey, + nostr_key_source: NostrKeySource, storage: S, config: Option, session_id: Option, @@ -567,6 +602,7 @@ impl MutinyWalletBuilder { MutinyWalletBuilder:: { xprivkey, storage, + nostr_key_source: NostrKeySource::Derived, config: None, session_id: None, network: None, @@ -607,6 +643,10 @@ impl MutinyWalletBuilder { self.subscription_url = Some(subscription_url); } + pub fn with_nostr_key_source(&mut self, key_source: NostrKeySource) { + self.nostr_key_source = key_source; + } + pub fn do_not_connect_peers(&mut self) { self.do_not_connect_peers = true; } @@ -664,11 +704,16 @@ impl MutinyWalletBuilder { // create nostr manager let nostr = Arc::new(NostrManager::from_mnemonic( self.xprivkey, + self.nostr_key_source, self.storage.clone(), logger.clone(), stop.clone(), )?); + // connect to relays when not in tests + #[cfg(not(test))] + nostr.connect().await?; + // create federation module if any exist let federation_storage = self.storage.get_federations()?; let federations = if !federation_storage.federations.is_empty() { @@ -748,8 +793,8 @@ impl MutinyWalletBuilder { } }; - // start the nostr wallet connect background process - mw.start_nostr_wallet_connect().await; + // start the nostr background process + mw.start_nostr().await; // start the federation background processor mw.start_fedimint_background_checker().await; @@ -798,8 +843,8 @@ impl MutinyWallet { Ok(()) } - /// Starts a background process that will watch for nostr wallet connect events - pub(crate) async fn start_nostr_wallet_connect(&self) { + /// Starts a background process that will watch for nostr events + pub(crate) async fn start_nostr(&self) { let nostr = self.nostr.clone(); let logger = self.logger.clone(); let stop = self.stop.clone(); @@ -810,10 +855,9 @@ impl MutinyWallet { break; }; - // if we have no relays, then there are no nwc profiles enabled - // wait 10 seconds and see if we do again - let relays = nostr.get_relays(); - if relays.is_empty() { + // if we have no filters, then wait 10 seconds and see if we do again + let mut last_filters = nostr.get_filters().unwrap_or_default(); + if last_filters.is_empty() { utils::sleep(10_000).await; continue; } @@ -837,7 +881,7 @@ impl MutinyWallet { log_warn!(logger, "Failed to clear expired NWC invoices: {e}"); } - let client = Client::new(&nostr.primary_key); + let client = Client::new(nostr.primary_key.clone()); client .add_relays(nostr.get_relays()) @@ -845,7 +889,6 @@ impl MutinyWallet { .expect("Failed to add relays"); client.connect().await; - let mut last_filters = nostr.get_nwc_filters(); client.subscribe(last_filters.clone()).await; // handle NWC requests @@ -871,17 +914,27 @@ impl MutinyWallet { notification = read_fut => { match notification { Ok(RelayPoolNotification::Event { event, .. }) => { - if event.kind == Kind::WalletConnectRequest && event.verify().is_ok() { - match nostr.handle_nwc_request(event, &self_clone).await { - Ok(Some(event)) => { - if let Err(e) = client.send_event(event).await { - log_warn!(logger, "Error sending NWC event: {e}"); + if event.verify().is_ok() { + match event.kind { + Kind::WalletConnectRequest => { + match nostr.handle_nwc_request(event, &self_clone).await { + Ok(Some(event)) => { + if let Err(e) = client.send_event(event).await { + log_warn!(logger, "Error sending NWC event: {e}"); + } + } + Ok(None) => {} // no response + Err(e) => { + log_error!(logger, "Error handling NWC request: {e}"); + } } } - Ok(None) => {} // no response - Err(e) => { - log_error!(logger, "Error handling NWC request: {e}"); + Kind::EncryptedDirectMessage => { + if let Err(e) = nostr.handle_direct_message(event, &self_clone).await { + log_error!(logger, "Error handling dm: {e}"); + } } + kind => log_warn!(logger, "Received unexpected note of kind {kind}") } } }, @@ -899,11 +952,12 @@ impl MutinyWallet { } _ = filter_check_fut => { // Check if the filters have changed - let current_filters = nostr.get_nwc_filters(); - if current_filters != last_filters { - log_debug!(logger, "subscribing to new nwc filters"); - client.subscribe(current_filters.clone()).await; - last_filters = current_filters; + if let Ok(current_filters) = nostr.get_filters() { + if current_filters != last_filters { + log_debug!(logger, "subscribing to new nwc filters"); + client.subscribe(current_filters.clone()).await; + last_filters = current_filters; + } } // Set the time for the next filter check next_filter_check = crate::utils::now().as_secs() + 5; @@ -1348,12 +1402,12 @@ impl MutinyWallet { } /// Get contacts from the given npub and sync them to the wallet - pub async fn sync_nostr_contacts( - &self, - primal_url: Option<&str>, - npub: XOnlyPublicKey, - ) -> Result<(), MutinyError> { - let url = primal_url.unwrap_or("https://primal-cache.mutinywallet.com/api"); + pub async fn sync_nostr_contacts(&self, npub: XOnlyPublicKey) -> Result<(), MutinyError> { + let url = self + .config + .primal_url + .as_deref() + .unwrap_or("https://primal-cache.mutinywallet.com/api"); let client = reqwest::Client::new(); let body = json!(["contact_list", { "pubkey": npub } ]); @@ -1420,6 +1474,74 @@ impl MutinyWallet { Ok(()) } + /// Get dm conversation between us and given npub + /// Returns a vector of messages sorted by newest first + pub async fn get_dm_conversation( + &self, + npub: XOnlyPublicKey, + limit: u64, + until: Option, + since: Option, + ) -> Result, MutinyError> { + let url = self + .config + .primal_url + .as_deref() + .unwrap_or("https://primal-cache.mutinywallet.com/api"); + let client = reqwest::Client::new(); + + // api is a little weird, has sender and receiver but still gives full conversation + let body = match (until, since) { + (Some(until), Some(since)) => { + json!(["get_directmsgs", { "sender": npub.to_hex(), "receiver": self.nostr.public_key.to_hex(), "limit": limit, "until": until, "since": since }]) + } + (None, Some(since)) => { + json!(["get_directmsgs", { "sender": npub.to_hex(), "receiver": self.nostr.public_key.to_hex(), "limit": limit, "since": since }]) + } + (Some(until), None) => { + json!(["get_directmsgs", { "sender": npub.to_hex(), "receiver": self.nostr.public_key.to_hex(), "limit": limit, "until": until }]) + } + (None, None) => { + json!(["get_directmsgs", { "sender": npub.to_hex(), "receiver": self.nostr.public_key.to_hex(), "limit": limit, "since": 0 }]) + } + }; + let data: Vec = Self::primal_request(&client, url, body).await?; + + let mut messages = Vec::with_capacity(data.len()); + for d in data { + let event = Event::from_value(d) + .ok() + .filter(|e| e.kind == Kind::EncryptedDirectMessage); + + if let Some(event) = event { + // verify signature + if event.verify().is_err() { + continue; + } + + let message = self.nostr.decrypt_dm(npub, &event.content).await?; + + let to = if event.pubkey == npub { + self.nostr.public_key + } else { + npub + }; + let dm = DirectMessage { + from: event.pubkey, + to, + message, + date: event.created_at.as_u64(), + }; + messages.push(dm); + } + } + + // sort messages, newest first + messages.sort_by(|a, b| b.cmp(a)); + + Ok(messages) + } + /// Stops all of the nodes and background processes. /// Returns after node has been stopped. pub async fn stop(&self) -> Result<(), MutinyError> { @@ -1955,8 +2077,11 @@ mod tests { use crate::test_utils::*; use crate::labels::{Contact, LabelStorage}; + use crate::nostr::NostrKeySource; use crate::storage::{MemoryStorage, MutinyStorage}; use crate::utils::parse_npub; + use nostr::key::FromSkStr; + use nostr::Keys; use wasm_bindgen_test::{wasm_bindgen_test as test, wasm_bindgen_test_configure}; wasm_bindgen_test_configure!(run_in_browser); @@ -2165,9 +2290,7 @@ mod tests { .expect("mutiny wallet should initialize"); // sync contacts - mw.sync_nostr_contacts(None, npub) - .await - .expect("synced contacts"); + mw.sync_nostr_contacts(npub).await.expect("synced contacts"); // first sync should yield just ben's contact let contacts = mw @@ -2194,9 +2317,7 @@ mod tests { let id = mw.storage.create_new_contact(new_contact).unwrap(); // sync contacts again, tony's contact should be correct - mw.sync_nostr_contacts(None, npub) - .await - .expect("synced contacts"); + mw.sync_nostr_contacts(npub).await.expect("synced contacts"); let contacts = mw.storage.get_contacts().unwrap(); assert_eq!(contacts.len(), 2); @@ -2207,4 +2328,55 @@ mod tests { assert!(contact.ln_address.is_some()); assert_ne!(contact.name, incorrect_name); } + + #[test] + async fn get_dm_conversation_test() { + // test nsec I made and sent dms to + let nsec = + Keys::from_sk_str("nsec1w2cy7vmq8urw9ae6wjaujrmztndad7e65hja52zk0c9x4yxgk0xsfuqk6s") + .unwrap(); + let npub = + parse_npub("npub18s7md9ytv8r240jmag5j037huupk5jnsk94adykeaxtvc6lyftesuw5ydl").unwrap(); + + // create wallet + let mnemonic = generate_seed(12).unwrap(); + let network = Network::Regtest; + let xpriv = ExtendedPrivKey::new_master(network, &mnemonic.to_seed("")).unwrap(); + let storage = MemoryStorage::new(None, None, None); + let config = MutinyWalletConfigBuilder::new(xpriv) + .with_network(network) + .build(); + let mut mw = MutinyWalletBuilder::new(xpriv, storage.clone()).with_config(config); + mw.with_nostr_key_source(NostrKeySource::Imported(nsec)); + let mw = mw.build().await.expect("mutiny wallet should initialize"); + + // get messages + let limit = 5; + let messages = mw + .get_dm_conversation(npub, limit, None, None) + .await + .unwrap(); + + assert_eq!(messages.len(), 5); + + for x in &messages { + log!("{}", x.message); + } + + // get next messages + let limit = 2; + let util = messages.iter().min_by_key(|m| m.date).unwrap().date - 1; + let next = mw + .get_dm_conversation(npub, limit, Some(util), None) + .await + .unwrap(); + + for x in next.iter() { + log!("{}", x.message); + } + + // check that we got different messages + assert_eq!(next.len(), 2); + assert!(next.iter().all(|m| !messages.contains(m))) + } } diff --git a/mutiny-core/src/nostr/mod.rs b/mutiny-core/src/nostr/mod.rs index 30237685b..5d6389d3a 100644 --- a/mutiny-core/src/nostr/mod.rs +++ b/mutiny-core/src/nostr/mod.rs @@ -1,9 +1,9 @@ use crate::logging::MutinyLogger; use crate::nostr::nip49::{NIP49BudgetPeriod, NIP49URI}; use crate::nostr::nwc::{ - BudgetPeriod, BudgetedSpendingConditions, NostrWalletConnect, NwcProfile, NwcProfileTag, - PendingNwcInvoice, Profile, SingleUseSpendingConditions, SpendingConditions, - PENDING_NWC_EVENTS_KEY, + check_valid_nwc_invoice, BudgetPeriod, BudgetedSpendingConditions, NostrWalletConnect, + NwcProfile, NwcProfileTag, PendingNwcInvoice, Profile, SingleUseSpendingConditions, + SpendingConditions, PENDING_NWC_EVENTS_KEY, }; use crate::storage::MutinyStorage; use crate::{error::MutinyError, utils::get_random_bip32_child_index}; @@ -19,12 +19,14 @@ use bitcoin::{ use futures::{pin_mut, select, FutureExt}; use futures_util::lock::Mutex; use lightning::util::logger::Logger; -use lightning::{log_error, log_warn}; -use nostr::key::SecretKey; +use lightning::{log_debug, log_error, log_warn}; +use lightning_invoice::Bolt11Invoice; +use nostr::key::{SecretKey, XOnlyPublicKey}; use nostr::nips::nip47::*; use nostr::prelude::{decrypt, encrypt}; -use nostr::{Event, EventBuilder, EventId, Filter, JsonUtil, Keys, Kind, Tag}; -use nostr_sdk::{Client, RelayPoolNotification}; +use nostr::{Event, EventBuilder, EventId, Filter, JsonUtil, Keys, Kind, Tag, Timestamp}; +use nostr_sdk::{Client, ClientSigner, RelayPoolNotification}; +use std::collections::HashSet; use std::sync::{atomic::Ordering, Arc, RwLock}; use std::time::Duration; use std::{str::FromStr, sync::atomic::AtomicBool}; @@ -32,7 +34,9 @@ use std::{str::FromStr, sync::atomic::AtomicBool}; pub mod nip49; pub mod nwc; +const PROFILE_ACCOUNT_INDEX: u32 = 0; const NWC_ACCOUNT_INDEX: u32 = 1; + const USER_NWC_PROFILE_START_INDEX: u32 = 1000; const NWC_STORAGE_KEY: &str = "nwc_profiles"; @@ -62,13 +66,26 @@ pub enum ProfileType { Normal { name: String }, } +#[derive(Debug, Clone)] +pub enum NostrKeySource { + /// We derive the nostr key from our mutiny seed + Derived, + /// Import nsec from the user + Imported(Keys), + /// Get keys from NIP-07 extension + #[cfg(target_arch = "wasm32")] + Extension(XOnlyPublicKey), +} + /// Manages Nostr keys and has different utilities for nostr specific things #[derive(Clone)] pub struct NostrManager { /// Extended private key that is the root seed of the wallet xprivkey: ExtendedPrivKey, /// Primary key used for nostr, this will be used for signing events - pub primary_key: Keys, + pub(crate) primary_key: ClientSigner, + /// Primary key's public key + pub public_key: XOnlyPublicKey, /// Separate profiles for each nostr wallet connect string pub(crate) nwc: Arc>>, pub storage: S, @@ -78,9 +95,19 @@ pub struct NostrManager { pub logger: Arc, /// Atomic stop signal pub stop: Arc, + /// Nostr client + pub client: Client, } impl NostrManager { + /// Connect to the nostr relays + pub async fn connect(&self) -> Result<(), MutinyError> { + self.client.add_relays(self.get_relays()).await?; + self.client.connect().await; + + Ok(()) + } + pub fn get_relays(&self) -> Vec { let mut relays: Vec = self .nwc @@ -91,6 +118,13 @@ impl NostrManager { .map(|x| x.profile.relay.clone()) .collect(); + // add relays to pull DMs from + relays.push("wss://relay.primal.net".to_string()); + relays.push("wss://relay.damus.io".to_string()); + + // add blastr for default sending + relays.push("wss://nostr.mutinywallet.com".to_string()); + // remove duplicates relays.sort(); relays.dedup(); @@ -98,7 +132,7 @@ impl NostrManager { relays } - pub fn get_nwc_filters(&self) -> Vec { + fn get_nwc_filters(&self) -> Vec { self.nwc .read() .unwrap() @@ -108,6 +142,39 @@ impl NostrManager { .collect() } + /// Filters for getting DMs from our contacts + fn get_dm_filter(&self) -> Result { + let contacts = self.storage.get_contacts()?; + let last_sync_time = self.storage.get_dm_sync_time()?; + let npubs: HashSet = contacts.into_values().flat_map(|c| c.npub).collect(); + + // if we haven't synced before, use now and save to storage + let time_stamp = match last_sync_time { + None => { + let now = Timestamp::now(); + self.storage.set_dm_sync_time(now.as_u64())?; + now + } + Some(time) => Timestamp::from(time), + }; + + let received_dm_filter = Filter::new() + .kind(Kind::EncryptedDirectMessage) + .authors(npubs) + .pubkey(self.public_key) + .since(time_stamp); + + Ok(received_dm_filter) + } + + pub fn get_filters(&self) -> Result, MutinyError> { + let mut nwc = self.get_nwc_filters(); + let dm = self.get_dm_filter()?; + nwc.push(dm); + + Ok(nwc) + } + pub fn get_nwc_uri(&self, index: u32) -> Result, MutinyError> { let opt = self .nwc @@ -428,6 +495,8 @@ impl NostrManager { tag: NwcProfileTag, ) -> Result { let profile = self.create_new_profile(profile_type, spending_conditions, tag)?; + // add relay if needed + self.client.add_relay(profile.relay.as_str()).await?; let info_event = self.nwc.read().unwrap().iter().find_map(|nwc| { if nwc.profile.index == profile.index { @@ -438,19 +507,12 @@ impl NostrManager { }); if let Some(info_event) = info_event { - let client = Client::new(&self.primary_key); - - client - .add_relay(profile.relay.as_str()) + self.client + .send_event_to(profile.relay.as_str(), info_event) .await - .expect("Failed to add relays"); - client.connect().await; - - client.send_event(info_event).await.map_err(|e| { - MutinyError::Other(anyhow::anyhow!("Failed to send info event: {e:?}")) - })?; - - let _ = client.disconnect().await; + .map_err(|e| { + MutinyError::Other(anyhow::anyhow!("Failed to send info event: {e:?}")) + })?; } Ok(profile) @@ -498,7 +560,7 @@ impl NostrManager { }); if let Some(nwc) = nwc { - let client = Client::new(&self.primary_key); + let client = Client::new(self.primary_key.clone()); client .add_relays(vec![relay, profile.relay.to_string()]) @@ -539,7 +601,7 @@ impl NostrManager { fn find_nwc_data( &self, hash: &sha256::Hash, - ) -> Result<(NostrWalletConnect, PendingNwcInvoice), MutinyError> { + ) -> Result<(Option, PendingNwcInvoice), MutinyError> { let pending: Vec = self .storage .get_data(PENDING_NWC_EVENTS_KEY)? @@ -550,14 +612,17 @@ impl NostrManager { .find(|x| x.invoice.payment_hash() == hash) .ok_or(MutinyError::NotFound)?; - let nwc = { - let profiles = self.nwc.read().unwrap(); - profiles - .iter() - .find(|x| x.profile.index == inv.index) - .ok_or(MutinyError::NotFound)? - .clone() - }; + let nwc = inv + .index + .map(|index| { + let profiles = self.nwc.read().unwrap(); + profiles + .iter() + .find(|x| x.profile.index == index) + .ok_or(MutinyError::NotFound) + .cloned() + }) + .transpose()?; Ok((nwc, inv.to_owned())) } @@ -568,14 +633,6 @@ impl NostrManager { nwc: NostrWalletConnect, inv: PendingNwcInvoice, ) -> Result { - let client = Client::new(&self.primary_key); - - client - .add_relay(nwc.profile.relay.as_str()) - .await - .expect("Failed to add relays"); - client.connect().await; - let encrypted = encrypt( &nwc.server_key.secret_key().unwrap(), &nwc.client_pubkey(), @@ -598,13 +655,12 @@ impl NostrManager { .to_event(&nwc.server_key) .map_err(|e| MutinyError::Other(anyhow::anyhow!("Failed to create event: {e:?}")))?; - let event_id = client - .send_event(response) + let event_id = self + .client + .send_event_to(nwc.profile.relay.as_str(), response) .await .map_err(|e| MutinyError::Other(anyhow::anyhow!("Failed to send info event: {e:?}")))?; - let _ = client.disconnect().await; - Ok(event_id) } @@ -613,12 +669,32 @@ impl NostrManager { &self, hash: sha256::Hash, invoice_handler: &impl InvoiceHandler, - ) -> Result { + ) -> Result, MutinyError> { let (nwc, inv) = self.find_nwc_data(&hash)?; - let resp = nwc.pay_nwc_invoice(invoice_handler, &inv.invoice).await?; + let event_id = match nwc { + Some(nwc) => { + let resp = nwc.pay_nwc_invoice(invoice_handler, &inv.invoice).await?; + Some(self.broadcast_nwc_response(resp, nwc, inv).await?) + } + None => { + // handle dm invoice + + // find contact, tag invoice with id + let contacts = self.storage.get_contacts()?; + let label = contacts + .into_iter() + .find(|(_, c)| c.npub == Some(inv.pubkey)) + .map(|(id, _)| vec![id]) + .unwrap_or_default(); + if let Err(e) = invoice_handler.pay_invoice(&inv.invoice, None, label).await { + log_error!(invoice_handler.logger(), "failed to pay invoice: {e}"); + return Err(e); + } - let event_id = self.broadcast_nwc_response(resp, nwc, inv).await?; + None + } + }; // get lock for writing self.pending_nwc_lock.lock().await; @@ -652,7 +728,9 @@ impl NostrManager { result: None, }; let (nwc, inv) = self.find_nwc_data(&hash)?; - self.broadcast_nwc_response(resp, nwc, inv).await?; + if let Some(nwc) = nwc { + self.broadcast_nwc_response(resp, nwc, inv).await?; + } } // wait for lock @@ -684,14 +762,6 @@ impl NostrManager { // doesn't work in test environment #[cfg(not(test))] { - let client = Client::new(&self.primary_key); - - client - .add_relays(self.get_relays()) - .await - .expect("Failed to add relays"); - client.connect().await; - let invoices: Vec = self .storage .get_data(PENDING_NWC_EVENTS_KEY)? @@ -708,37 +778,37 @@ impl NostrManager { }; let (nwc, inv) = self.find_nwc_data(invoice.invoice.payment_hash())?; - let encrypted = encrypt( - &nwc.server_key.secret_key().unwrap(), - &nwc.client_pubkey(), - resp.as_json(), - ) - .unwrap(); - - let p_tag = Tag::PublicKey { - public_key: inv.pubkey, - relay_url: None, - alias: None, - uppercase: false, - }; - let e_tag = Tag::Event { - event_id: inv.event_id, - relay_url: None, - marker: None, - }; - let response = - EventBuilder::new(Kind::WalletConnectResponse, encrypted, [p_tag, e_tag]) - .to_event(&nwc.server_key) - .map_err(|e| { - MutinyError::Other(anyhow::anyhow!("Failed to create event: {e:?}")) - })?; - - client.send_event(response).await.map_err(|e| { - MutinyError::Other(anyhow::anyhow!("Failed to send info event: {e:?}")) - })?; + if let Some(nwc) = nwc { + let encrypted = encrypt( + &nwc.server_key.secret_key().unwrap(), + &nwc.client_pubkey(), + resp.as_json(), + ) + .unwrap(); + + let p_tag = Tag::PublicKey { + public_key: inv.pubkey, + relay_url: None, + alias: None, + uppercase: false, + }; + let e_tag = Tag::Event { + event_id: inv.event_id, + relay_url: None, + marker: None, + }; + let response = + EventBuilder::new(Kind::WalletConnectResponse, encrypted, [p_tag, e_tag]) + .to_event(&nwc.server_key) + .map_err(|e| { + MutinyError::Other(anyhow::anyhow!("Failed to create event: {e:?}")) + })?; + + self.client.send_event(response).await.map_err(|e| { + MutinyError::Other(anyhow::anyhow!("Failed to send info event: {e:?}")) + })?; + } } - - let _ = client.disconnect().await; } // need to define the type here, otherwise it will be ambiguous @@ -770,6 +840,72 @@ impl NostrManager { Ok(()) } + /// Handles an encrypted direct message. If it is an invoice we add it to our pending + /// invoice storage. + pub async fn handle_direct_message( + &self, + event: Event, + invoice_handler: &impl InvoiceHandler, + ) -> anyhow::Result<()> { + if event.kind != Kind::EncryptedDirectMessage { + anyhow::bail!("Not a direct message"); + } else if event.pubkey == self.public_key { + return Ok(()); // don't process our own messages + } + + log_debug!(self.logger, "processing dm: {}", event.id); + + // update sync time + self.storage.set_dm_sync_time(event.created_at.as_u64())?; + + 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(()); + } + }; + + self.save_pending_nwc_invoice(None, event.id, event.pubkey, invoice) + .await?; + + Ok(()) + } + + pub(crate) async fn save_pending_nwc_invoice( + &self, + profile_index: Option, + event_id: EventId, + event_pk: XOnlyPublicKey, + invoice: Bolt11Invoice, + ) -> anyhow::Result<()> { + let pending = PendingNwcInvoice { + index: profile_index, + invoice, + event_id, + pubkey: event_pk, + }; + self.pending_nwc_lock.lock().await; + + let mut current: Vec = self + .storage + .get_data(PENDING_NWC_EVENTS_KEY)? + .unwrap_or_default(); + + if !current.contains(&pending) { + current.push(pending); + + self.storage + .set_data(PENDING_NWC_EVENTS_KEY.to_string(), current, None)?; + } + + Ok(()) + } + pub async fn handle_nwc_request( &self, event: Event, @@ -958,6 +1094,36 @@ impl NostrManager { Ok(None) } + /// Decrypts a DM using the primary key + pub async fn decrypt_dm( + &self, + pubkey: XOnlyPublicKey, + message: &str, + ) -> Result { + // todo we should handle NIP-44 as well + match &self.primary_key { + ClientSigner::Keys(key) => { + let secret = key.secret_key().expect("must have"); + let decrypted = decrypt(&secret, &pubkey, message)?; + Ok(decrypted) + } + #[cfg(target_arch = "wasm32")] + ClientSigner::NIP07(nip07) => { + let decrypted = nip07.nip04_decrypt(pubkey, message).await?; + Ok(decrypted) + } + } + } + + pub async fn send_dm( + &self, + pubkey: XOnlyPublicKey, + message: String, + ) -> Result { + let event_id = self.client.send_direct_msg(pubkey, message, None).await?; + Ok(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( @@ -1011,14 +1177,34 @@ impl NostrManager { /// Creates a new NostrManager pub fn from_mnemonic( xprivkey: ExtendedPrivKey, + key_source: NostrKeySource, storage: S, logger: Arc, stop: Arc, ) -> Result { let context = Secp256k1::new(); - // generate the default primary key - let primary_key = Self::derive_nostr_key(&context, xprivkey, 0, None, None)?; + // use provided nsec, otherwise generate it from seed + let (primary_key, public_key) = match key_source { + NostrKeySource::Derived => { + let keys = + Self::derive_nostr_key(&context, xprivkey, PROFILE_ACCOUNT_INDEX, None, None)?; + let public_key = keys.public_key(); + let signer = ClientSigner::Keys(keys); + (signer, public_key) + } + NostrKeySource::Imported(keys) => { + let public_key = keys.public_key(); + let signer = ClientSigner::Keys(keys); + (signer, public_key) + } + #[cfg(target_arch = "wasm32")] + NostrKeySource::Extension(public_key) => { + let nip07 = nostr::prelude::Nip07Signer::new()?; + let signer = ClientSigner::NIP07(nip07); + (signer, public_key) + } + }; // get from storage let profiles: Vec = storage.get_data(NWC_STORAGE_KEY)?.unwrap_or_default(); @@ -1029,14 +1215,18 @@ impl NostrManager { .map(|profile| NostrWalletConnect::new(&context, xprivkey, profile).unwrap()) .collect(); + let client = Client::new(primary_key.clone()); + Ok(Self { xprivkey, primary_key, + public_key, nwc: Arc::new(RwLock::new(nwc)), storage, pending_nwc_lock: Arc::new(Mutex::new(())), logger, stop, + client, }) } } @@ -1068,18 +1258,25 @@ fn get_next_nwc_index( Ok((name, index, child_key_index)) } +#[cfg(not(target_arch = "wasm32"))] #[cfg(test)] mod test { use super::*; use crate::storage::MemoryStorage; + use crate::utils::now; + use crate::MockInvoiceHandler; use bip39::Mnemonic; use bitcoin::util::bip32::ExtendedPrivKey; use bitcoin::Network; use futures::executor::block_on; - use lightning_invoice::Bolt11Invoice; + use lightning::ln::PaymentSecret; + use lightning_invoice::{Bolt11Invoice, Currency, InvoiceBuilder}; + use mockall::predicate::eq; use nostr::key::XOnlyPublicKey; use std::str::FromStr; + const EXPIRED_INVOICE: &str = "lnbc923720n1pj9nr6zpp5xmvlq2u5253htn52mflh2e6gn7pk5ht0d4qyhc62fadytccxw7hqhp5l4s6qwh57a7cwr7zrcz706qx0qy4eykcpr8m8dwz08hqf362egfscqzzsxqzfvsp5pr7yjvcn4ggrf6fq090zey0yvf8nqvdh2kq7fue0s0gnm69evy6s9qyyssqjyq0fwjr22eeg08xvmz88307yqu8tqqdjpycmermks822fpqyxgshj8hvnl9mkh6srclnxx0uf4ugfq43d66ak3rrz4dqcqd23vxwpsqf7dmhm"; + fn create_nostr_manager() -> NostrManager { let mnemonic = Mnemonic::from_str("abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about").expect("could not generate"); @@ -1092,11 +1289,106 @@ mod test { let stop = Arc::new(AtomicBool::new(false)); - NostrManager::from_mnemonic(xprivkey, storage, logger, stop).unwrap() + NostrManager::from_mnemonic(xprivkey, NostrKeySource::Derived, storage, logger, stop) + .unwrap() + } + + #[tokio::test] + async fn test_process_dm() { + let nostr_manager = create_nostr_manager(); + + let mut inv_handler = MockInvoiceHandler::new(); + inv_handler + .expect_logger() + .return_const(MutinyLogger::default()); + inv_handler.expect_skip_hodl_invoices().return_const(true); + + #[allow(irrefutable_let_patterns)] // need this because enum with single variant + let nostr_keys = if let ClientSigner::Keys(ref keys) = nostr_manager.primary_key { + keys.clone() + } else { + panic!("unexpected keys") + }; + let user = Keys::generate(); + + // make sure non-invoice is not added + let dm = EventBuilder::encrypted_direct_msg( + &user, + nostr_manager.public_key, + "not an invoice", + None, + ) + .unwrap() + .to_event(&user) + .unwrap(); + block_on(nostr_manager.handle_direct_message(dm, &inv_handler)).unwrap(); + let pending = nostr_manager.get_pending_nwc_invoices().unwrap(); + assert!(pending.is_empty()); + + // make sure expired invoice is not added + let dm = EventBuilder::encrypted_direct_msg( + &user, + nostr_manager.public_key, + EXPIRED_INVOICE, + None, + ) + .unwrap() + .to_event(&user) + .unwrap(); + let pending = nostr_manager.get_pending_nwc_invoices().unwrap(); + assert!(pending.is_empty()); + block_on(nostr_manager.handle_direct_message(dm, &inv_handler)).unwrap(); + + // create invoice + let secp = Secp256k1::new(); + let sk = bitcoin::secp256k1::SecretKey::from_slice(&[2; 32]).unwrap(); + let invoice = InvoiceBuilder::new(Currency::Regtest) + .description("Dummy invoice".to_string()) + .duration_since_epoch(now()) + .payment_hash(sha256::Hash::all_zeros()) + .payment_secret(PaymentSecret([0; 32])) + .min_final_cltv_expiry_delta(144) + .amount_milli_satoshis(69_000) + .build_signed(|hash| secp.sign_ecdsa_recoverable(hash, &sk)) + .unwrap(); + + // add handling for mock + inv_handler + .expect_get_outbound_payment_status() + .with(eq(invoice.payment_hash().into_32())) + .returning(move |_| None); + + // make sure our own dms don't get added + let dm = EventBuilder::encrypted_direct_msg( + &nostr_keys, + user.public_key(), + invoice.to_string(), + None, + ) + .unwrap() + .to_event(&nostr_keys) + .unwrap(); + block_on(nostr_manager.handle_direct_message(dm, &inv_handler)).unwrap(); + let pending = nostr_manager.get_pending_nwc_invoices().unwrap(); + assert!(pending.is_empty()); + + // valid invoice dm should be added + let dm = EventBuilder::encrypted_direct_msg( + &user, + nostr_manager.public_key, + invoice.to_string(), + None, + ) + .unwrap() + .to_event(&user) + .unwrap(); + block_on(nostr_manager.handle_direct_message(dm, &inv_handler)).unwrap(); + let pending = nostr_manager.get_pending_nwc_invoices().unwrap(); + assert!(!pending.is_empty()) } - #[test] - fn test_create_profile() { + #[tokio::test] + async fn test_create_profile() { let nostr_manager = create_nostr_manager(); let name = "test".to_string(); @@ -1138,8 +1430,8 @@ mod test { assert_eq!(profiles[0].index, 1000); } - #[test] - fn test_create_reserve_profile() { + #[tokio::test] + async fn test_create_reserve_profile() { let nostr_manager = create_nostr_manager(); let name = MUTINY_PLUS_SUBSCRIPTION_LABEL.to_string(); @@ -1251,8 +1543,8 @@ mod test { assert_ne!(original_nwc_uri, changed_nwc.get_nwc_uri().unwrap()); } - #[test] - fn test_create_nwa_profile() { + #[tokio::test] + async fn test_create_nwa_profile() { let nostr_manager = create_nostr_manager(); let name = "test nwa".to_string(); @@ -1303,8 +1595,8 @@ mod test { assert_eq!(profiles[0].client_key, Some(uri.public_key)); } - #[test] - fn test_edit_profile() { + #[tokio::test] + async fn test_edit_profile() { let nostr_manager = create_nostr_manager(); let name = "test".to_string(); @@ -1344,8 +1636,8 @@ mod test { assert_eq!(profiles[0].index, 1000); } - #[test] - fn test_delete_profile() { + #[tokio::test] + async fn test_delete_profile() { let nostr_manager = create_nostr_manager(); let name = "test".to_string(); @@ -1376,8 +1668,8 @@ mod test { assert_eq!(profiles.len(), 0); } - #[test] - fn test_deny_invoice() { + #[tokio::test] + async fn test_deny_invoice() { let nostr_manager = create_nostr_manager(); let name = "test".to_string(); @@ -1391,7 +1683,7 @@ mod test { .unwrap(); let inv = PendingNwcInvoice { - index: profile.index, + index: Some(profile.index), invoice: Bolt11Invoice::from_str("lnbc923720n1pj9nrefpp5pczykgk37af5388n8dzynljpkzs7sje4melqgazlwv9y3apay8jqhp5rd8saxz3juve3eejq7z5fjttxmpaq88d7l92xv34n4h3mq6kwq2qcqzzsxqzfvsp5z0jwpehkuz9f2kv96h62p8x30nku76aj8yddpcust7g8ad0tr52q9qyyssqfy622q25helv8cj8hyxqltws4rdwz0xx2hw0uh575mn7a76cp3q4jcptmtjkjs4a34dqqxn8uy70d0qlxqleezv4zp84uk30pp5q3nqq4c9gkz").unwrap(), event_id: EventId::from_slice(&[0; 32]).unwrap(), pubkey: XOnlyPublicKey::from_str("552a9d06810f306bfc085cb1e1c26102554138a51fa3a7fdf98f5b03a945143a").unwrap(), diff --git a/mutiny-core/src/nostr/nwc.rs b/mutiny-core/src/nostr/nwc.rs index 27fba70c6..81860b69f 100644 --- a/mutiny-core/src/nostr/nwc.rs +++ b/mutiny-core/src/nostr/nwc.rs @@ -343,28 +343,9 @@ impl NostrWalletConnect { event_pk: XOnlyPublicKey, invoice: Bolt11Invoice, ) -> anyhow::Result<()> { - let pending = PendingNwcInvoice { - index: self.profile.index, - invoice, - event_id, - pubkey: event_pk, - }; - nostr_manager.pending_nwc_lock.lock().await; - - let mut current: Vec = nostr_manager - .storage - .get_data(PENDING_NWC_EVENTS_KEY)? - .unwrap_or_default(); - - if !current.contains(&pending) { - current.push(pending); - - nostr_manager - .storage - .set_data(PENDING_NWC_EVENTS_KEY.to_string(), current, None)?; - } - - Ok(()) + nostr_manager + .save_pending_nwc_invoice(Some(self.profile.index), event_id, event_pk, invoice) + .await } fn get_skipped_error_event( @@ -450,67 +431,20 @@ impl NostrWalletConnect { .map(Some); } - let invoice = match req.params { - RequestParams::PayInvoice(params) => Bolt11Invoice::from_str(¶ms.invoice) - .map_err(|_| anyhow!("Failed to parse invoice"))?, + let invoice_str = match req.params { + RequestParams::PayInvoice(params) => params.invoice, _ => return Err(anyhow!("Invalid request params for pay invoice")), }; - // if the invoice has expired, skip it - if invoice.would_expire(utils::now()) { - return self - .get_skipped_error_event( - &event, - ErrorCode::Other, - "Invoice expired".to_string(), - ) - .map(Some); - } - - // if the invoice has no amount, we cannot pay it - if invoice.amount_milli_satoshis().is_none() { - log_warn!( - node.logger(), - "NWC Invoice amount not set, cannot pay: {invoice}" - ); - return self - .get_skipped_error_event( - &event, - ErrorCode::Other, - "Invoice amount not set".to_string(), - ) - .map(Some); - } - - if node.skip_hodl_invoices() { - // Skip potential hodl invoices as they can cause force closes - if utils::HODL_INVOICE_NODES - .contains(&invoice.recover_payee_pub_key().to_hex().as_str()) - { - log_warn!( - node.logger(), - "Received potential hodl invoice, skipping..." - ); + 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, - "Paying hodl invoices disabled".to_string(), - ) - .map(Some); + .get_skipped_error_event(&event, ErrorCode::Other, err_string) + .map(Some) } - } - - // if we have already paid or are attempting to pay this invoice, skip it - if node - .get_outbound_payment_status(&invoice.payment_hash().into_32()) - .await - .is_some_and(|status| { - matches!(status, HTLCStatus::Succeeded | HTLCStatus::InFlight) - }) - { - return Ok(None); - } + }; // if we need approval, just save in the db for later match self.profile.spending_conditions.clone() { @@ -886,13 +820,15 @@ impl NwcProfile { /// An invoice received over Nostr Wallet Connect that is pending approval or rejection #[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] pub struct PendingNwcInvoice { - /// Index of the profile that received the invoice - pub index: u32, + /// Index of the profile that received the invoice. + /// None if invoice is from a DM + pub index: Option, /// The invoice that awaiting approval pub invoice: Bolt11Invoice, /// The nostr event id of the request pub event_id: EventId, /// The nostr pubkey of the request + /// If this is a DM, this is who sent us the request pub pubkey: XOnlyPublicKey, } @@ -914,6 +850,55 @@ impl PendingNwcInvoice { } } +/// Checks if it is a valid invoice +/// 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, + invoice_handler: &impl InvoiceHandler, +) -> Result, String> { + let invoice = match Bolt11Invoice::from_str(invoice) { + Ok(invoice) => invoice, + Err(_) => return Err("Invalid invoice".to_string()), + }; + + // if the invoice has expired, skip it + if invoice.would_expire(utils::now()) { + return Err("Invoice expired".to_string()); + } + + // if the invoice has no amount, we cannot pay it + if invoice.amount_milli_satoshis().is_none() { + log_warn!( + invoice_handler.logger(), + "NWC Invoice amount not set, cannot pay: {invoice}" + ); + return Err("Invoice amount not set".to_string()); + } + + if invoice_handler.skip_hodl_invoices() { + // Skip potential hodl invoices as they can cause force closes + if utils::HODL_INVOICE_NODES.contains(&invoice.recover_payee_pub_key().to_hex().as_str()) { + log_warn!( + invoice_handler.logger(), + "Received potential hodl invoice, skipping..." + ); + return Err("Paying hodl invoices disabled".to_string()); + } + } + + // 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()) + .await + .is_some_and(|status| matches!(status, HTLCStatus::Succeeded | HTLCStatus::InFlight)) + { + return Ok(None); + } + + Ok(Some(invoice)) +} + #[cfg(test)] mod test { use super::*; @@ -1190,7 +1175,7 @@ mod test { mod wasm_test { use super::*; use crate::logging::MutinyLogger; - use crate::nostr::ProfileType; + use crate::nostr::{NostrKeySource, ProfileType}; use crate::storage::MemoryStorage; use crate::test_utils::{create_dummy_invoice, create_mutiny_wallet, create_nwc_request}; use crate::MockInvoiceHandler; @@ -1236,9 +1221,14 @@ mod wasm_test { let xprivkey = ExtendedPrivKey::new_master(Network::Regtest, &[0; 64]).unwrap(); let stop = Arc::new(AtomicBool::new(false)); - let nostr_manager = - NostrManager::from_mnemonic(xprivkey, storage.clone(), mw.logger.clone(), stop) - .unwrap(); + let nostr_manager = NostrManager::from_mnemonic( + xprivkey, + NostrKeySource::Derived, + storage.clone(), + mw.logger.clone(), + stop, + ) + .unwrap(); let profile = nostr_manager .create_new_profile( @@ -1271,7 +1261,7 @@ mod wasm_test { assert_eq!(pending.len(), 1); assert_eq!(pending[0].invoice.to_string(), invoice); assert_eq!(pending[0].event_id, event.id); - assert_eq!(pending[0].index, nwc.profile.index); + assert_eq!(pending[0].index, Some(nwc.profile.index)); assert_eq!(pending[0].pubkey, event.pubkey); } @@ -1285,8 +1275,14 @@ mod wasm_test { let xprivkey = ExtendedPrivKey::new_master(Network::Regtest, &[0; 64]).unwrap(); let stop = Arc::new(AtomicBool::new(false)); - let nostr_manager = - NostrManager::from_mnemonic(xprivkey, storage.clone(), logger.clone(), stop).unwrap(); + let nostr_manager = NostrManager::from_mnemonic( + xprivkey, + NostrKeySource::Derived, + storage.clone(), + logger.clone(), + stop, + ) + .unwrap(); let profile = nostr_manager .create_new_profile( @@ -1370,7 +1366,14 @@ mod wasm_test { // test invalid invoice let event = create_nwc_request(&uri, "invalid invoice".to_string()); let result = nwc.handle_nwc_request(event, &node, &nostr_manager).await; - assert_eq!(result.unwrap_err().to_string(), "Failed to parse invoice"); + check_nwc_error_response( + result.unwrap().unwrap(), + &uri.secret, + NIP47Error { + code: ErrorCode::Other, + message: "Invalid invoice".to_string(), + }, + ); check_no_pending_invoices(&storage); // test expired invoice @@ -1455,7 +1458,7 @@ mod wasm_test { assert_eq!(pending.len(), 1); assert_eq!(pending[0].invoice, invoice); assert_eq!(pending[0].event_id, event.id); - assert_eq!(pending[0].index, nwc.profile.index); + assert_eq!(pending[0].index, Some(nwc.profile.index)); assert_eq!(pending[0].pubkey, event.pubkey); } @@ -1466,6 +1469,7 @@ mod wasm_test { let stop = Arc::new(AtomicBool::new(false)); let nostr_manager = NostrManager::from_mnemonic( xprivkey, + NostrKeySource::Derived, storage.clone(), Arc::new(MutinyLogger::default()), stop, @@ -1478,17 +1482,17 @@ mod wasm_test { // add an expired invoice let expired = PendingNwcInvoice { - index: 0, + index: Some(0), invoice: Bolt11Invoice::from_str(INVOICE).unwrap(), event_id: EventId::all_zeros(), - pubkey: nostr_manager.primary_key.public_key(), + pubkey: nostr_manager.public_key, }; // add an unexpired invoice let unexpired = PendingNwcInvoice { - index: 0, + index: Some(0), invoice: create_dummy_invoice(Some(1_000), Network::Regtest, None).0, event_id: EventId::all_zeros(), - pubkey: nostr_manager.primary_key.public_key(), + pubkey: nostr_manager.public_key, }; storage .set_data( @@ -1515,9 +1519,14 @@ mod wasm_test { let xprivkey = ExtendedPrivKey::new_master(Network::Regtest, &[0; 64]).unwrap(); let stop = Arc::new(AtomicBool::new(false)); - let nostr_manager = - NostrManager::from_mnemonic(xprivkey, storage.clone(), mw.logger.clone(), stop) - .unwrap(); + let nostr_manager = NostrManager::from_mnemonic( + xprivkey, + NostrKeySource::Derived, + storage.clone(), + mw.logger.clone(), + stop, + ) + .unwrap(); let budget = 10_000; let profile = nostr_manager @@ -1550,7 +1559,7 @@ mod wasm_test { assert_eq!(pending.len(), 1); assert_eq!(pending[0].invoice, invoice); assert_eq!(pending[0].event_id, event.id); - assert_eq!(pending[0].index, nwc.profile.index); + assert_eq!(pending[0].index, Some(nwc.profile.index)); assert_eq!(pending[0].pubkey, event.pubkey); // clear pending @@ -1567,7 +1576,7 @@ mod wasm_test { assert_eq!(pending.len(), 1); assert_eq!(pending[0].invoice, invoice); assert_eq!(pending[0].event_id, event.id); - assert_eq!(pending[0].index, nwc.profile.index); + assert_eq!(pending[0].index, Some(nwc.profile.index)); assert_eq!(pending[0].pubkey, event.pubkey); } @@ -1597,8 +1606,14 @@ mod wasm_test { let xprivkey = ExtendedPrivKey::new_master(Network::Regtest, &[0; 64]).unwrap(); let stop = Arc::new(AtomicBool::new(false)); - let nostr_manager = - NostrManager::from_mnemonic(xprivkey, storage.clone(), logger, stop).unwrap(); + let nostr_manager = NostrManager::from_mnemonic( + xprivkey, + NostrKeySource::Derived, + storage.clone(), + logger, + stop, + ) + .unwrap(); let budget = 10_000; let profile = nostr_manager diff --git a/mutiny-core/src/storage.rs b/mutiny-core/src/storage.rs index 00df95a44..59f6b285b 100644 --- a/mutiny-core/src/storage.rs +++ b/mutiny-core/src/storage.rs @@ -38,6 +38,7 @@ pub const DEVICE_LOCK_KEY: &str = "device_lock"; pub(crate) const EXPECTED_NETWORK_KEY: &str = "network"; const PAYMENT_INBOUND_PREFIX_KEY: &str = "payment_inbound/"; const PAYMENT_OUTBOUND_PREFIX_KEY: &str = "payment_outbound/"; +pub const LAST_DM_SYNC_TIME_KEY: &str = "last_dm_sync_time"; fn needs_encryption(key: &str) -> bool { match key { @@ -409,6 +410,20 @@ pub trait MutinyStorage: Clone + Sized + Send + Sync + 'static { self.set_data(FIRST_SYNC_KEY.to_string(), true, None) } + fn get_dm_sync_time(&self) -> Result, MutinyError> { + self.get_data(LAST_DM_SYNC_TIME_KEY) + } + + fn set_dm_sync_time(&self, time: u64) -> Result<(), MutinyError> { + // only update if the time is newer + let current = self.get_dm_sync_time()?.unwrap_or_default(); + if current < time { + self.set_data(LAST_DM_SYNC_TIME_KEY.to_string(), time, None) + } else { + Ok(()) + } + } + fn get_device_id(&self) -> Result { match self.get_data(DEVICE_ID_KEY)? { Some(id) => Ok(id), diff --git a/mutiny-wasm/Cargo.toml b/mutiny-wasm/Cargo.toml index 2cb4da5cc..3129cc47d 100644 --- a/mutiny-wasm/Cargo.toml +++ b/mutiny-wasm/Cargo.toml @@ -30,7 +30,7 @@ lightning-invoice = { version = "0.26.0" } thiserror = "1.0" instant = { version = "0.1", features = ["wasm-bindgen"] } lnurl-rs = { version = "0.3.1", default-features = false } -nostr = { version = "0.27.0", default-features = false } +nostr = { version = "0.27.0", default-features = false, features = ["nip04", "nip05", "nip07", "nip47", "nip57"] } wasm-logger = "0.2.0" log = "0.4.17" rexie = "0.5.0" diff --git a/mutiny-wasm/src/error.rs b/mutiny-wasm/src/error.rs index 66501dd4c..b05e715d9 100644 --- a/mutiny-wasm/src/error.rs +++ b/mutiny-wasm/src/error.rs @@ -127,6 +127,9 @@ pub enum MutinyJsError { /// Error getting nostr data #[error("Failed to get nostr data.")] NostrError, + /// Error with Nip07 Extension + #[error("Error with NIP-07 extension")] + Nip07Extension, /// Error getting the bitcoin price #[error("Failed to get the bitcoin price.")] BitcoinPriceError, @@ -202,6 +205,7 @@ impl From for MutinyJsError { MutinyError::IncorrectLnUrlFunction => MutinyJsError::IncorrectLnUrlFunction, MutinyError::BadAmountError => MutinyJsError::BadAmountError, MutinyError::NostrError => MutinyJsError::NostrError, + MutinyError::Nip07Extension => MutinyJsError::Nip07Extension, MutinyError::BitcoinPriceError => MutinyJsError::BitcoinPriceError, MutinyError::IncorrectPassword => MutinyJsError::IncorrectPassword, MutinyError::SamePassword => MutinyJsError::SamePassword, diff --git a/mutiny-wasm/src/lib.rs b/mutiny-wasm/src/lib.rs index 63bfde369..466d84f14 100644 --- a/mutiny-wasm/src/lib.rs +++ b/mutiny-wasm/src/lib.rs @@ -33,6 +33,7 @@ use mutiny_core::auth::MutinyAuthClient; use mutiny_core::lnurlauth::AuthManager; use mutiny_core::nostr::nip49::NIP49URI; use mutiny_core::nostr::nwc::{BudgetedSpendingConditions, NwcProfileTag, SpendingConditions}; +use mutiny_core::nostr::NostrKeySource; use mutiny_core::storage::{DeviceLock, MutinyStorage, DEVICE_LOCK_KEY}; use mutiny_core::utils::{now, parse_npub, parse_npub_or_nip05, sleep}; use mutiny_core::vss::MutinyVssClient; @@ -43,7 +44,8 @@ use mutiny_core::{ nodemanager::{create_lsp_config, NodeManager}, }; use mutiny_core::{logging::MutinyLogger, nostr::ProfileType}; -use nostr::ToBech32; +use nostr::key::{FromSkStr, Secp256k1, SecretKey}; +use nostr::{FromBech32, Keys, ToBech32}; use std::str::FromStr; use std::sync::Arc; use std::{ @@ -99,7 +101,16 @@ impl MutinyWallet { skip_device_lock: Option, safe_mode: Option, skip_hodl_invoices: Option, + nsec_override: Option, + nip_07_key: Option, + primal_url: Option, ) -> Result { + // if both are set throw an error + // todo default to nsec if both are for same key? + if nsec_override.is_some() && nip_07_key.is_some() { + return Err(MutinyJsError::InvalidArgumentsError); + } + utils::set_panic_hook(); let mut init = INITIALIZED.lock().await; if *init { @@ -126,6 +137,9 @@ impl MutinyWallet { skip_device_lock, safe_mode, skip_hodl_invoices, + nsec_override, + nip_07_key, + primal_url, ) .await { @@ -157,6 +171,9 @@ impl MutinyWallet { skip_device_lock: Option, safe_mode: Option, skip_hodl_invoices: Option, + nsec_override: Option, + nip_07_key: Option, + primal_url: Option, ) -> Result { let safe_mode = safe_mode.unwrap_or(false); let logger = Arc::new(MutinyLogger::default()); @@ -250,6 +267,9 @@ impl MutinyWallet { if let Some(url) = scorer_url { config_builder.with_scorer_url(url); } + if let Some(url) = primal_url { + config_builder.with_primal_url(url); + } if let Some(true) = skip_device_lock { config_builder.with_skip_device_lock(); } @@ -266,7 +286,17 @@ impl MutinyWallet { let mut mw_builder = MutinyWalletBuilder::new(xprivkey, storage).with_config(config); mw_builder.with_session_id(logger.session_id.clone()); + if let Some(nsec) = nsec_override { + let keys = + Keys::from_sk_str(&nsec).map_err(|_| MutinyJsError::InvalidArgumentsError)?; + mw_builder.with_nostr_key_source(NostrKeySource::Imported(keys)); + } + if let Some(key) = nip_07_key { + let npub = parse_npub(&key)?; + mw_builder.with_nostr_key_source(NostrKeySource::Extension(npub)); + } let inner = mw_builder.build().await?; + Ok(MutinyWallet { mnemonic, inner }) } @@ -389,6 +419,12 @@ impl MutinyWallet { self.mnemonic.to_string() } + /// Returns the npub for receiving dms + #[wasm_bindgen] + pub fn get_npub(&self) -> String { + self.inner.nostr.public_key.to_bech32().expect("bech32") + } + /// Returns the network of the wallet. #[wasm_bindgen] pub fn get_network(&self) -> String { @@ -1484,17 +1520,12 @@ impl MutinyWallet { let pending: Vec = pending .into_iter() - .flat_map(|inv| { - profiles + .flat_map(|inv| match inv.index { + Some(index) => profiles .iter() - .find_map(|p| { - if inv.index == p.index { - Some(p.name.clone()) - } else { - None - } - }) - .map(|n| (inv, n).into()) + .find(|p| p.index == index) + .map(|p| (inv, Some(p.name.clone())).into()), + None => Some((inv, None).into()), }) .collect(); @@ -1570,18 +1601,38 @@ impl MutinyWallet { } /// Get contacts from the given npub and sync them to the wallet - pub async fn sync_nostr_contacts( - &self, - primal_url: Option, - npub_str: String, - ) -> Result<(), MutinyJsError> { + pub async fn sync_nostr_contacts(&self, npub_str: String) -> Result<(), MutinyJsError> { let npub = parse_npub_or_nip05(&npub_str).await?; - self.inner - .sync_nostr_contacts(primal_url.as_deref(), npub) - .await?; + self.inner.sync_nostr_contacts(npub).await?; Ok(()) } + /// Get dm conversation between us and given npub + /// Returns a vector of messages sorted by newest first + pub async fn get_dm_conversation( + &self, + npub: String, + limit: u64, + until: Option, + since: Option, + ) -> Result */, MutinyJsError> { + let npub = parse_npub(&npub)?; + let vec = self + .inner + .get_dm_conversation(npub, limit, until, since) + .await?; + + let dms: Vec = vec.into_iter().map(|i| i.into()).collect(); + Ok(JsValue::from_serde(&dms)?) + } + + /// Sends a DM to the given npub + pub async fn send_dm(&self, npub: String, message: String) -> Result { + let npub = parse_npub(&npub)?; + let event_id = self.inner.nostr.send_dm(npub, message).await?; + Ok(event_id.to_hex()) + } + /// Resets the scorer and network graph. This can be useful if you get stuck in a bad state. #[wasm_bindgen] pub async fn reset_router(&self) -> Result<(), MutinyJsError> { @@ -1692,6 +1743,18 @@ impl MutinyWallet { bitcoin::Amount::from_sat(sats).to_btc() } + /// Convert an npub string to a hex string + #[wasm_bindgen] + pub async fn nsec_to_npub(nsec: String) -> Result { + let nsec = + SecretKey::from_bech32(nsec).map_err(|_| MutinyJsError::InvalidArgumentsError)?; + Ok(nsec + .x_only_public_key(&Secp256k1::new()) + .0 + .to_bech32() + .expect("bech32")) + } + /// Convert an npub string to a hex string #[wasm_bindgen] pub async fn npub_to_hexpub(npub: String) -> Result { @@ -1747,6 +1810,9 @@ mod tests { None, None, None, + None, + None, + None, ) .await .expect("mutiny wallet should initialize"); @@ -1779,6 +1845,9 @@ mod tests { None, None, None, + None, + None, + None, ) .await .expect("mutiny wallet should initialize"); @@ -1805,6 +1874,9 @@ mod tests { None, None, None, + None, + None, + None, ) .await; @@ -1844,6 +1916,9 @@ mod tests { None, None, None, + None, + None, + None, ) .await .expect("mutiny wallet should initialize"); @@ -1869,6 +1944,9 @@ mod tests { None, None, None, + None, + None, + None, ) .await; @@ -1915,6 +1993,9 @@ mod tests { None, None, None, + None, + None, + None, ) .await .unwrap(); @@ -1959,6 +2040,9 @@ mod tests { None, None, None, + None, + None, + None, ) .await .unwrap(); @@ -1990,6 +2074,9 @@ mod tests { None, None, None, + None, + None, + None, ) .await; @@ -2025,6 +2112,9 @@ mod tests { None, None, None, + None, + None, + None, ) .await .expect("mutiny wallet should initialize"); @@ -2090,6 +2180,9 @@ mod tests { None, None, None, + None, + None, + None, ) .await .expect("mutiny wallet should initialize"); @@ -2144,6 +2237,9 @@ mod tests { None, None, None, + None, + None, + None, ) .await .expect("mutiny wallet should initialize"); diff --git a/mutiny-wasm/src/models.rs b/mutiny-wasm/src/models.rs index 79d76bcde..cf12c9607 100644 --- a/mutiny-wasm/src/models.rs +++ b/mutiny-wasm/src/models.rs @@ -973,8 +973,11 @@ impl From for NwcProfile { #[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] #[wasm_bindgen] pub struct PendingNwcInvoice { - /// Index of the profile that received the invoice - pub index: u32, + /// Index of the profile that received the invoice. + /// None if invoice is from a DM + pub index: Option, + /// If this is a DM, this is who sent us the request + npub: Option, /// The invoice that awaiting approval invoice: String, /// The id of the invoice, this is the payment hash @@ -984,13 +987,18 @@ pub struct PendingNwcInvoice { /// The description of the invoice invoice_description: Option, /// Name of the NWC Profile that received the invoice - profile_name: String, + profile_name: Option, /// Invoice expire time in seconds since epoch pub expiry: u64, } #[wasm_bindgen] impl PendingNwcInvoice { + #[wasm_bindgen(getter)] + pub fn npub(&self) -> Option { + self.npub.clone() + } + #[wasm_bindgen(getter)] pub fn invoice(&self) -> String { self.invoice.clone() @@ -1008,23 +1016,29 @@ impl PendingNwcInvoice { /// Name of the NWC Profile that received the invoice #[wasm_bindgen(getter)] - pub fn profile_name(&self) -> String { + pub fn profile_name(&self) -> Option { self.profile_name.clone() } } -impl From<(nostr::nwc::PendingNwcInvoice, String)> for PendingNwcInvoice { - fn from((value, profile_name): (nostr::nwc::PendingNwcInvoice, String)) -> Self { +impl From<(nostr::nwc::PendingNwcInvoice, Option)> for PendingNwcInvoice { + fn from((value, profile_name): (nostr::nwc::PendingNwcInvoice, Option)) -> Self { let invoice_description = match value.invoice.description() { Bolt11InvoiceDescription::Direct(desc) => Some(desc.to_string()), Bolt11InvoiceDescription::Hash(_) => None, }; + let npub = if profile_name.is_none() { + Some(value.pubkey.to_bech32().expect("bech32")) + } else { + None + }; let timestamp = value.invoice.duration_since_epoch().as_secs(); let expiry = timestamp + value.invoice.expiry_time().as_secs(); PendingNwcInvoice { index: value.index, + npub, invoice: value.invoice.to_string(), id: value.invoice.payment_hash().to_hex(), amount_sats: value.invoice.amount_milli_satoshis().unwrap_or_default() / 1_000, @@ -1093,3 +1107,22 @@ impl TryFrom for BudgetPeriod { } } } + +#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)] +pub struct DirectMessage { + pub from: String, + pub to: String, + pub message: String, + pub date: u64, +} + +impl From for DirectMessage { + fn from(value: mutiny_core::DirectMessage) -> Self { + Self { + from: value.from.to_bech32().expect("bech32"), + to: value.to.to_bech32().expect("bech32"), + message: value.message, + date: value.date, + } + } +}