diff --git a/mutiny-core/src/error.rs b/mutiny-core/src/error.rs index 1f33e3bee..3e2113c73 100644 --- a/mutiny-core/src/error.rs +++ b/mutiny-core/src/error.rs @@ -179,6 +179,9 @@ pub enum MutinyError { /// Failed to connect to a federation. #[error("Failed to connect to a federation.")] FederationConnectionFailed, + /// A node manager has not been created yet. + #[error("A node manager has not been created yet.")] + NodeManagerRequired, #[error(transparent)] Other(anyhow::Error), } @@ -263,6 +266,7 @@ impl PartialEq for MutinyError { (Self::TokenAlreadySpent, Self::TokenAlreadySpent) => true, (Self::FederationRequired, Self::FederationRequired) => true, (Self::FederationConnectionFailed, Self::FederationConnectionFailed) => true, + (Self::NodeManagerRequired, Self::NodeManagerRequired) => true, (Self::Other(e), Self::Other(e2)) => e.to_string() == e2.to_string(), _ => false, } diff --git a/mutiny-core/src/labels.rs b/mutiny-core/src/labels.rs index 493924e21..15026702f 100644 --- a/mutiny-core/src/labels.rs +++ b/mutiny-core/src/labels.rs @@ -1,6 +1,6 @@ use crate::error::MutinyError; -use crate::nodemanager::NodeManager; use crate::storage::MutinyStorage; +use crate::MutinyWallet; use bitcoin::Address; use lightning_invoice::Bolt11Invoice; use lnurl::lightning_address::LightningAddress; @@ -445,7 +445,7 @@ impl LabelStorage for S { } } -impl LabelStorage for NodeManager { +impl LabelStorage for MutinyWallet { fn get_address_labels(&self) -> Result>, MutinyError> { self.storage.get_address_labels() } diff --git a/mutiny-core/src/lib.rs b/mutiny-core/src/lib.rs index b87684ded..472300ad6 100644 --- a/mutiny-core/src/lib.rs +++ b/mutiny-core/src/lib.rs @@ -141,6 +141,7 @@ use uuid::Uuid; use web_time::Instant; use crate::labels::LabelItem; +use crate::nodemanager::NodeIdentity; use crate::nostr::{NostrKeySource, RELAYS}; #[cfg(test)] use mockall::{automock, predicate::*}; @@ -938,20 +939,41 @@ impl MutinyWalletBuilder { let start = Instant::now(); - let mut nm_builder = NodeManagerBuilder::new(self.xprivkey, self.storage.clone()) - .with_config(config.clone()); - nm_builder.with_logger(logger.clone()); - nm_builder.with_esplora(esplora.clone()); - let node_manager = Arc::new(nm_builder.build().await?); - - log_trace!( - logger, - "NodeManager started, took: {}ms", - start.elapsed().as_millis() - ); + let node_manager = if self.storage.get_nodes()?.is_empty() { + Arc::new(RwLock::new(None)) + } else { + let mut nm_builder = NodeManagerBuilder::new(self.xprivkey, self.storage.clone()) + .with_config(config.clone()); + nm_builder.with_logger(logger.clone()); + nm_builder.with_esplora(esplora.clone()); + + let nm = nm_builder.build().await?; + + // if the node manager is unused, we don't need to start it + // the user can opt to start it later if they want + if nm.is_unused().await? { + // stop the node manager in the background + let logger_clone = logger.clone(); + spawn(async move { + if let Err(e) = nm.stop().await { + log_error!(logger_clone, "Error stopping unused node manager: {e}"); + } + }); - // start syncing node manager - NodeManager::start_sync(node_manager.clone()); + Arc::new(RwLock::new(None)) + } else { + let node_manager = Arc::new(RwLock::new(Some(nm))); + // start sync + NodeManager::start_sync(node_manager.clone(), network, stop.clone()); + + log_trace!( + logger, + "NodeManager started, took: {}ms", + start.elapsed().as_millis() + ); + node_manager + } + }; let primal_client = PrimalClient::new( config @@ -1086,18 +1108,27 @@ impl MutinyWalletBuilder { }; // populate the activity index - let mut activity_index = node_manager - .wallet - .list_transactions(false)? - .into_iter() - .map(|t| IndexItem { - timestamp: match t.confirmation_time { - ConfirmationTime::Confirmed { time, .. } => Some(time), - ConfirmationTime::Unconfirmed { .. } => None, - }, - key: format!("{ONCHAIN_PREFIX}{}", t.internal_id), - }) - .collect::>(); + let mut activity_index = { + node_manager + .read() + .await + .as_ref() + .map(|nm| { + nm.wallet + .list_transactions(false) + .unwrap() + .into_iter() + .map(|t| IndexItem { + timestamp: match t.confirmation_time { + ConfirmationTime::Confirmed { time, .. } => Some(time), + ConfirmationTime::Unconfirmed { .. } => None, + }, + key: format!("{ONCHAIN_PREFIX}{}", t.internal_id), + }) + .collect::>() + }) + .unwrap_or_default() + }; // add any transaction details stored from fedimint let transaction_details = self @@ -1195,17 +1226,6 @@ impl MutinyWalletBuilder { return Ok(mw); } - // if we don't have any nodes, create one - if mw.node_manager.list_nodes().await?.is_empty() { - let nm = mw.node_manager.clone(); - // spawn in background, this can take a while and we don't want to block - utils::spawn(async move { - if let Err(e) = nm.new_node().await { - log_error!(nm.logger, "Failed to create first node: {e}"); - } - }) - }; - // start the nostr background process mw.start_nostr().await; @@ -1242,7 +1262,7 @@ pub struct MutinyWallet { xprivkey: ExtendedPrivKey, config: MutinyWalletConfig, pub(crate) storage: S, - pub node_manager: Arc>, + pub node_manager: Arc>>>, pub nostr: Arc>, pub federation_storage: Arc>, pub(crate) federations: Arc>>>>, @@ -1263,17 +1283,26 @@ pub struct MutinyWallet { impl MutinyWallet { /// Starts up all the nodes again. - /// Not needed after [NodeManager]'s `new()` function. + /// Not needed after [MutinyWallet]'s `new()` function. pub async fn start(&mut self) -> Result<(), MutinyError> { self.storage.start().await?; + self.stop.store(false, Ordering::Relaxed); + + if self.storage.get_nodes()?.is_empty() { + return Ok(()); + } let mut nm_builder = NodeManagerBuilder::new(self.xprivkey, self.storage.clone()) .with_config(self.config.clone()); nm_builder.with_logger(self.logger.clone()); + let node_manager = nm_builder.build().await?; + + // replace the node manager + let mut lock = self.node_manager.write().await; + *lock = Some(node_manager); - // when we restart, gen a new session id - self.node_manager = Arc::new(nm_builder.build().await?); - NodeManager::start_sync(self.node_manager.clone()); + // start sync + NodeManager::start_sync(self.node_manager.clone(), self.network, self.stop.clone()); Ok(()) } @@ -1451,6 +1480,94 @@ impl MutinyWallet { }); } + /// Creates a node manager with a node and returns the node identity. + /// If a node manager already exists, it will return an error. + pub async fn create_node_manager(&self) -> Result { + if self.is_safe_mode() { + return Err(MutinyError::NotRunning); + } + + let mut lock = self.node_manager.write().await; + // if we already have a node manager, return + if let Some(node_manager) = lock.as_ref() { + // if we don't have a node, we should unarchive the first node + // otherwise, we already have a functional node manager + let nodes = node_manager.list_nodes().await?; + if nodes.is_empty() { + return node_manager.unarchive_first_node().await; + } else { + return Err(MutinyError::AlreadyRunning); + } + } + + let mut nm_builder = NodeManagerBuilder::new(self.xprivkey, self.storage.clone()) + .with_config(self.config.clone()); + nm_builder.with_logger(self.logger.clone()); + let node_manager = nm_builder.build().await?; + + // create first node + let node_identity = node_manager.create_first_node().await?; + + // replace the node manager + *lock = Some(node_manager); + + // start sync + NodeManager::start_sync(self.node_manager.clone(), self.network, self.stop.clone()); + + Ok(node_identity) + } + + /// Creates a node manager if we don't have one already. + pub async fn create_node_manager_if_needed(&self) -> Result, MutinyError> { + match self.create_node_manager().await { + Ok(ident) => Ok(Some(ident)), + Err(MutinyError::AlreadyRunning) => Ok(None), // this means we already have a node manager + Err(e) => Err(e), + } + } + + /// Removes the node manager from the wallet. If the node manager has any balances, + /// this function will fail. + pub async fn remove_node_manager(&self) -> Result<(), MutinyError> { + if self.is_safe_mode() { + return Err(MutinyError::NotRunning); + } + + let mut lock = self.node_manager.write().await; + // if we don't have a node manager, return + if lock.is_none() { + return Ok(()); + } + + let node_manager = lock.as_ref().expect("should have a node manager"); + + // if the node manager is still in use, return an error + if !node_manager.is_unused().await? { + return Err(MutinyError::InvalidArgumentsError); + } + + // archive all nodes + // this will verify that each node has no balance and every channel monitor is + // safe to be pruned. + let nodes = node_manager.list_nodes().await?; + for pk in nodes { + node_manager.archive_node(pk).await?; + } + + // save node storage + { + let mut node_storage = node_manager.node_storage.read().await.clone(); + node_storage.version += 1; + self.storage.insert_nodes(&node_storage).await?; + } + + node_manager.stop().await?; + + *lock = None; + + Ok(()) + } + /// Pays a lightning invoice from a federation (preferred) or node. /// An amount should only be provided if the invoice does not have an amount. /// Amountless invoices cannot be paid by a federation. @@ -1531,33 +1648,35 @@ impl MutinyWallet { // If any balance at all, then fallback to node manager for payment. // Take the error from the node manager as the priority. - if self - .node_manager - .nodes - .read() - .await - .iter() - .flat_map(|(_, n)| n.channel_manager.list_channels()) - .map(|c| c.balance_msat) - .sum::() - > 0 - { - let res = self - .node_manager - .pay_invoice(None, inv, amt_sats, labels) - .await?; + if let Some(node_manager) = self.node_manager.read().await.as_ref() { + if node_manager + .nodes + .read() + .await + .iter() + .flat_map(|(_, n)| n.channel_manager.list_channels()) + .map(|c| c.balance_msat) + .sum::() + > 0 + { + let res = node_manager + .pay_invoice(None, inv, amt_sats, labels) + .await?; - // spawn a task to remove the pending invoice if it exists - let nostr_clone = self.nostr.clone(); - let payment_hash = *inv.payment_hash(); - let logger = self.logger.clone(); - utils::spawn(async move { - if let Err(e) = nostr_clone.remove_pending_nwc_invoice(&payment_hash).await { - log_warn!(logger, "Failed to remove pending NWC invoice: {e}"); - } - }); + // spawn a task to remove the pending invoice if it exists + let nostr_clone = self.nostr.clone(); + let payment_hash = *inv.payment_hash(); + let logger = self.logger.clone(); + utils::spawn(async move { + if let Err(e) = nostr_clone.remove_pending_nwc_invoice(&payment_hash).await { + log_warn!(logger, "Failed to remove pending NWC invoice: {e}"); + } + }); - Ok(res) + Ok(res) + } else { + Err(last_federation_error.unwrap_or(MutinyError::InsufficientBalance)) + } } else { Err(last_federation_error.unwrap_or(MutinyError::InsufficientBalance)) } @@ -1704,6 +1823,10 @@ impl MutinyWallet { None => { // use the lightning node if no to federation selected self.node_manager + .read() + .await + .as_ref() + .ok_or(MutinyError::NodeManagerRequired)? .create_invoice(amt, labels.clone()) .await? } @@ -1745,6 +1868,10 @@ impl MutinyWallet { None => { // use the lightning node if no to federation selected self.node_manager + .read() + .await + .as_ref() + .ok_or(MutinyError::NodeManagerRequired)? .create_invoice(amt, labels.clone()) .await? } @@ -1770,6 +1897,10 @@ impl MutinyWallet { None => { // use the lightning node if no to federation selected self.node_manager + .read() + .await + .as_ref() + .ok_or(MutinyError::NodeManagerRequired)? .create_invoice(amt, labels.clone()) .await? } @@ -1831,7 +1962,13 @@ impl MutinyWallet { let incoming_fee = if to_federation_id.is_some() { 0 } else { - self.node_manager.get_lsp_fee(amt).await? + self.node_manager + .read() + .await + .as_ref() + .ok_or(MutinyError::NodeManagerRequired)? + .get_lsp_fee(amt) + .await? }; let outgoing_fee = @@ -1854,7 +1991,13 @@ impl MutinyWallet { let incoming_fee = if to_federation_id.is_some() { 0 } else { - self.node_manager.get_lsp_fee(amt).await? + self.node_manager + .read() + .await + .as_ref() + .ok_or(MutinyError::NodeManagerRequired)? + .get_lsp_fee(amt) + .await? }; let outgoing_fee = current_balance - amt; @@ -1904,13 +2047,16 @@ impl MutinyWallet { // If any balance at all, then fallback to node manager for payment. // Take the error from the node manager as the priority. - let b = self.node_manager.get_balance().await?; - if b.confirmed + b.unconfirmed > 0 { - let res = self - .node_manager - .send_to_address(send_to, amount, labels, fee_rate) - .await?; - Ok(res) + if let Some(node_manager) = self.node_manager.read().await.as_ref() { + let b = node_manager.get_balance().await?; + if b.confirmed + b.unconfirmed > 0 { + let res = node_manager + .send_to_address(send_to, amount, labels, fee_rate) + .await?; + Ok(res) + } else { + Err(last_federation_error.unwrap_or(MutinyError::InsufficientBalance)) + } } else { Err(last_federation_error.unwrap_or(MutinyError::InsufficientBalance)) } @@ -1954,13 +2100,15 @@ impl MutinyWallet { // If federation client is not found, continue to next federation } - let b = self.node_manager.get_balance().await?; - if b.confirmed + b.unconfirmed > 0 { - let res = self - .node_manager - .estimate_tx_fee(destination_address, amount, fee_rate)?; + if let Some(node_manager) = self.node_manager.read().await.as_ref() { + let b = node_manager.get_balance().await?; + if b.confirmed + b.unconfirmed > 0 { + let res = node_manager.estimate_tx_fee(destination_address, amount, fee_rate)?; - Ok(res) + Ok(res) + } else { + Err(last_federation_error.unwrap_or(MutinyError::InsufficientBalance)) + } } else { Err(last_federation_error.unwrap_or(MutinyError::InsufficientBalance)) } @@ -1996,15 +2144,17 @@ impl MutinyWallet { // If federation client is not found, continue to next federation } - let b = self.node_manager.get_balance().await?; - if b.confirmed + b.unconfirmed > 0 { - let res = self - .node_manager - .estimate_sweep_tx_fee(destination_address, fee_rate)?; + if let Some(node_manager) = self.node_manager.read().await.as_ref() { + let b = node_manager.get_balance().await?; + if b.confirmed + b.unconfirmed > 0 { + let res = node_manager.estimate_sweep_tx_fee(destination_address, fee_rate)?; - Ok(res) + Ok(res) + } else { + log_error!(self.logger, "node manager doesn't have a balance"); + Err(MutinyError::InsufficientBalance) + } } else { - log_error!(self.logger, "node manager doesn't have a balance"); Err(MutinyError::InsufficientBalance) } } @@ -2048,16 +2198,19 @@ impl MutinyWallet { // If federation client is not found, continue to next federation } - let b = self.node_manager.get_balance().await?; - if b.confirmed + b.unconfirmed > 0 { - let res = self - .node_manager - .sweep_wallet(send_to.clone(), labels, fee_rate) - .await?; + if let Some(node_manager) = self.node_manager.read().await.as_ref() { + let b = node_manager.get_balance().await?; + if b.confirmed + b.unconfirmed > 0 { + let res = node_manager + .sweep_wallet(send_to.clone(), labels, fee_rate) + .await?; - Ok(res) + Ok(res) + } else { + log_error!(self.logger, "node manager doesn't have a balance"); + Err(MutinyError::InsufficientBalance) + } } else { - log_error!(self.logger, "node manager doesn't have a balance"); Err(MutinyError::InsufficientBalance) } } @@ -2081,11 +2234,12 @@ impl MutinyWallet { } // Fallback to node_manager address creation - let Ok(addr) = self.node_manager.get_new_address(labels.clone()) else { - return Err(MutinyError::WalletOperationFailed); - }; - - Ok(addr) + if let Some(node_manager) = self.node_manager.read().await.as_ref() { + let addr = node_manager.get_new_address(labels.clone())?; + Ok(addr) + } else { + Err(MutinyError::NodeManagerRequired) + } } async fn create_lightning_invoice( @@ -2109,9 +2263,14 @@ impl MutinyWallet { } // Fallback to node_manager invoice creation if no federation invoice created - let (inv, _fee) = self.node_manager.create_invoice(amount, labels).await?; - - Ok(inv) + let node_manager = self.node_manager.read().await; + match node_manager.as_ref() { + Some(nm) => { + let (inv, _) = nm.create_invoice(amount, labels).await?; + Ok(inv) + } + None => Err(MutinyError::NodeManagerRequired), + } } /// Gets the current balance of the wallet. @@ -2119,7 +2278,10 @@ impl MutinyWallet { /// /// This will not include any funds in an unconfirmed lightning channel. pub async fn get_balance(&self) -> Result { - let ln_balance = self.node_manager.get_balance().await?; + let ln_balance: NodeBalance = match self.node_manager.read().await.as_ref() { + Some(nm) => nm.get_balance().await?, + None => Default::default(), + }; let federation_balance = self.get_total_federation_balance().await?; Ok(MutinyBalance::new(ln_balance, federation_balance)) @@ -2150,7 +2312,7 @@ impl MutinyWallet { } /// Get the sorted activity list for lightning payments, channels, and txs. - pub fn get_activity( + pub async fn get_activity( &self, limit: Option, offset: Option, @@ -2215,10 +2377,13 @@ impl MutinyWallet { // convert keys to txid let txid_str = item.key.trim_start_matches(ONCHAIN_PREFIX); let txid: Txid = Txid::from_str(txid_str)?; - if let Some(tx_details) = self.node_manager.get_transaction(txid)? { - // make sure it is a relevant transaction - if tx_details.sent != 0 || tx_details.received != 0 { - activities.push(ActivityItem::OnChain(tx_details)); + + if let Some(node_manager) = self.node_manager.read().await.as_ref() { + if let Some(tx_details) = node_manager.get_transaction(txid)? { + // make sure it is a relevant transaction + if tx_details.sent != 0 || tx_details.received != 0 { + activities.push(ActivityItem::OnChain(tx_details)); + } } } } else if item.key.starts_with(TRANSACTION_DETAILS_PREFIX_KEY) { @@ -2239,13 +2404,19 @@ impl MutinyWallet { Ok(activities) } - pub fn get_transaction(&self, txid: Txid) -> Result, MutinyError> { + pub async fn get_transaction( + &self, + txid: Txid, + ) -> Result, MutinyError> { // check our local cache/state for fedimint first match get_transaction_details(&self.storage, txid, &self.logger) { Some(t) => Ok(Some(t)), None => { // fall back to node manager - self.node_manager.get_transaction(txid) + match self.node_manager.read().await.as_ref() { + Some(node_manager) => node_manager.get_transaction(txid), + None => Ok(None), + } } } } @@ -2255,7 +2426,7 @@ impl MutinyWallet { &self, label: &String, ) -> Result, MutinyError> { - let Some(label_item) = self.node_manager.get_label(label)? else { + let Some(label_item) = self.storage.get_label(label)? else { return Ok(Vec::new()); }; @@ -2707,7 +2878,9 @@ impl MutinyWallet { pub async fn stop(&self) -> Result<(), MutinyError> { self.stop.store(true, Ordering::Relaxed); - self.node_manager.stop().await?; + if let Some(nm) = self.node_manager.read().await.as_ref() { + nm.stop().await?; + } // stop the indexeddb object to close db connection if self.storage.connected().unwrap_or(false) { @@ -2757,9 +2930,13 @@ impl MutinyWallet { /// /// This can be useful if you get stuck in a bad state. pub async fn reset_onchain_tracker(&mut self) -> Result<(), MutinyError> { - self.node_manager.reset_onchain_tracker().await?; - // sleep for 250ms to give time for the storage to write - utils::sleep(250).await; + if let Some(node_manager) = self.node_manager.read().await.as_ref() { + node_manager.reset_onchain_tracker().await?; + // sleep for 250ms to give time for the storage to write + utils::sleep(250).await; + } else { + return Err(MutinyError::NodeManagerRequired); + } self.stop().await?; @@ -2768,10 +2945,9 @@ impl MutinyWallet { self.start().await?; - self.node_manager - .wallet - .full_sync(FULL_SYNC_STOP_GAP) - .await?; + if let Some(node_manager) = self.node_manager.read().await.as_ref() { + node_manager.wallet.full_sync(FULL_SYNC_STOP_GAP).await?; + } Ok(()) } @@ -3462,8 +3638,13 @@ impl InvoiceHandler for MutinyWallet { } 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()) + if let Some(node_manager) = self.node_manager.read().await.as_ref() { + let node = node_manager.get_node_by_key_or_first(None).await?; + Ok(node.channel_manager.current_best_block()) + } else { + // if we don't have a node manager, just return the genesis block + Ok(BestBlock::from_network(self.network)) + } } async fn lookup_payment(&self, payment_hash: &[u8; 32]) -> Option { @@ -3831,7 +4012,7 @@ mod tests { let pass = uuid::Uuid::new_v4().to_string(); let cipher = encryption_key_from_pass(&pass).unwrap(); let storage = MemoryStorage::new(Some(pass), Some(cipher), None); - assert!(!NodeManager::has_node_manager(storage.clone())); + assert!(!NodeManager::is_wallet_present(storage.clone())); let config = MutinyWalletConfigBuilder::new(xpriv) .with_network(network) .build(); @@ -3841,7 +4022,7 @@ mod tests { .await .expect("mutiny wallet should initialize"); mw.storage.insert_mnemonic(mnemonic).unwrap(); - assert!(NodeManager::has_node_manager(storage)); + assert!(NodeManager::is_wallet_present(storage)); } #[test] @@ -3854,7 +4035,7 @@ mod tests { let pass = uuid::Uuid::new_v4().to_string(); let cipher = encryption_key_from_pass(&pass).unwrap(); let storage = MemoryStorage::new(Some(pass), Some(cipher), None); - assert!(!NodeManager::has_node_manager(storage.clone())); + assert!(!NodeManager::is_wallet_present(storage.clone())); let config = MutinyWalletConfigBuilder::new(xpriv) .with_network(network) .build(); @@ -3864,11 +4045,11 @@ mod tests { .await .expect("mutiny wallet should initialize"); - let first_seed = mw.node_manager.xprivkey; + let first_seed = mw.xprivkey; assert!(mw.stop().await.is_ok()); assert!(mw.start().await.is_ok()); - assert_eq!(first_seed, mw.node_manager.xprivkey); + assert_eq!(first_seed, mw.xprivkey); } #[test] @@ -3877,13 +4058,13 @@ mod tests { log!("{}", test_name); let network = Network::Regtest; - let xpriv = ExtendedPrivKey::new_master(network, &[0; 32]).unwrap(); + let xpriv = ExtendedPrivKey::new_master(network, &[42; 32]).unwrap(); let pass = uuid::Uuid::new_v4().to_string(); let cipher = encryption_key_from_pass(&pass).unwrap(); let storage = MemoryStorage::new(Some(pass), Some(cipher), None); - assert!(!NodeManager::has_node_manager(storage.clone())); + assert!(!NodeManager::is_wallet_present(storage.clone())); let config = MutinyWalletConfigBuilder::new(xpriv) .with_network(network) .build(); @@ -3893,20 +4074,33 @@ mod tests { .await .expect("mutiny wallet should initialize"); - // let storage persist - sleep(1000).await; + let ns = storage.get_nodes().unwrap(); + assert_eq!(ns.nodes.len(), 0); + assert_eq!(ns.version, 0); + + mw.create_node_manager().await.unwrap(); + let ns = storage.get_nodes().unwrap(); + assert_eq!(ns.version, 1); + assert_eq!(ns.nodes.len(), 1); - assert_eq!(mw.node_manager.list_nodes().await.unwrap().len(), 1); + let lock = mw.node_manager.read().await; + let nm = lock.as_ref().unwrap(); + assert_eq!(nm.list_nodes().await.unwrap().len(), 1); - assert!(mw.node_manager.new_node().await.is_ok()); - // let storage persist - sleep(1000).await; + nm.new_node().await.unwrap(); + let ns = storage.get_nodes().unwrap(); + assert_eq!(ns.nodes.len(), 2); - assert_eq!(mw.node_manager.list_nodes().await.unwrap().len(), 2); + assert_eq!(nm.list_nodes().await.unwrap().len(), 2); + drop(lock); assert!(mw.stop().await.is_ok()); + sleep(1000).await; // give it a second to stop assert!(mw.start().await.is_ok()); - assert_eq!(mw.node_manager.list_nodes().await.unwrap().len(), 2); + + let lock = mw.node_manager.read().await; + let nm = lock.as_ref().unwrap(); + assert_eq!(nm.list_nodes().await.unwrap().len(), 2); } #[test] @@ -3920,7 +4114,7 @@ mod tests { let pass = uuid::Uuid::new_v4().to_string(); let cipher = encryption_key_from_pass(&pass).unwrap(); let storage = MemoryStorage::new(Some(pass), Some(cipher), None); - assert!(!NodeManager::has_node_manager(storage.clone())); + assert!(!NodeManager::is_wallet_present(storage.clone())); let config = MutinyWalletConfigBuilder::new(xpriv) .with_network(network) .build(); @@ -3929,14 +4123,14 @@ mod tests { .build() .await .expect("mutiny wallet should initialize"); - let seed = mw.node_manager.xprivkey; + let seed = mw.xprivkey; assert!(!seed.private_key.secret_bytes().is_empty()); // create a second mw and make sure it has a different seed let pass = uuid::Uuid::new_v4().to_string(); let cipher = encryption_key_from_pass(&pass).unwrap(); let storage2 = MemoryStorage::new(Some(pass), Some(cipher), None); - assert!(!NodeManager::has_node_manager(storage2.clone())); + assert!(!NodeManager::is_wallet_present(storage2.clone())); let xpriv2 = ExtendedPrivKey::new_master(network, &[0; 32]).unwrap(); let config2 = MutinyWalletConfigBuilder::new(xpriv2) .with_network(network) @@ -3946,7 +4140,7 @@ mod tests { .build() .await .expect("mutiny wallet should initialize"); - let seed2 = mw2.node_manager.xprivkey; + let seed2 = mw2.xprivkey; assert_ne!(seed, seed2); // now restore the first seed into the 2nd mutiny node @@ -3971,7 +4165,7 @@ mod tests { .build() .await .expect("mutiny wallet should initialize"); - let restored_seed = mw3.node_manager.xprivkey; + let restored_seed = mw3.xprivkey; assert_eq!(seed, restored_seed); } @@ -3987,7 +4181,7 @@ mod tests { let pass = uuid::Uuid::new_v4().to_string(); let cipher = encryption_key_from_pass(&pass).unwrap(); let storage = MemoryStorage::new(Some(pass), Some(cipher), None); - assert!(!NodeManager::has_node_manager(storage.clone())); + assert!(!NodeManager::is_wallet_present(storage.clone())); let mut config_builder = MutinyWalletConfigBuilder::new(xpriv).with_network(network); config_builder.with_safe_mode(); let config = config_builder.build(); @@ -3997,12 +4191,9 @@ mod tests { .await .expect("mutiny wallet should initialize"); mw.storage.insert_mnemonic(mnemonic).unwrap(); - assert!(NodeManager::has_node_manager(storage)); + assert!(NodeManager::is_wallet_present(storage)); - let bip21 = mw.create_bip21(None, vec![]).await.unwrap(); - assert!(bip21.invoice.is_none()); - - let new_node = mw.node_manager.new_node().await; + let new_node = mw.create_node_manager().await; assert!(new_node.is_err()); } @@ -4179,18 +4370,11 @@ mod tests { .await .expect("mutiny wallet should initialize"); - loop { - if !mw.node_manager.list_nodes().await.unwrap().is_empty() { - break; - } - sleep(100).await; - } + mw.create_node_manager().await.unwrap(); + let lock = mw.node_manager.read().await; + let node_manager = lock.as_ref().unwrap(); - let node = mw - .node_manager - .get_node_by_key_or_first(None) - .await - .unwrap(); + let node = node_manager.get_node_by_key_or_first(None).await.unwrap(); let closure: ChannelClosure = ChannelClosure { user_channel_id: None, @@ -4204,7 +4388,7 @@ mod tests { .persist_channel_closure(closure_chan_id, closure.clone()) .unwrap(); - let address = mw.node_manager.get_new_address(vec![]).unwrap(); + let address = node_manager.get_new_address(vec![]).unwrap(); let output = TxOut { value: 10_000, script_pubkey: address.script_pubkey(), @@ -4215,7 +4399,7 @@ mod tests { input: vec![], output: vec![output.clone()], }; - mw.node_manager + node_manager .wallet .insert_tx( tx1.clone(), @@ -4231,7 +4415,7 @@ mod tests { input: vec![], output: vec![output], }; - mw.node_manager + node_manager .wallet .insert_tx( tx2.clone(), @@ -4390,30 +4574,35 @@ mod tests { }, ]; + drop(lock); + assert_eq!(vec.len(), expected.len()); // make sure im not dumb assert_eq!(vec, expected); - let activity = mw.get_activity(None, None).unwrap(); + let activity = mw.get_activity(None, None).await.unwrap(); assert_eq!(activity.len(), expected.len()); - let with_limit = mw.get_activity(Some(3), None).unwrap(); + let with_limit = mw.get_activity(Some(3), None).await.unwrap(); assert_eq!(with_limit.len(), 3); - let with_offset = mw.get_activity(None, Some(3)).unwrap(); + let with_offset = mw.get_activity(None, Some(3)).await.unwrap(); assert_eq!(with_offset.len(), activity.len() - 3); - let with_both = mw.get_activity(Some(3), Some(3)).unwrap(); + let with_both = mw.get_activity(Some(3), Some(3)).await.unwrap(); assert_eq!(with_limit.len(), 3); assert_ne!(with_both, with_limit); // check we handle out of bounds errors - let with_limit_oob = mw.get_activity(Some(usize::MAX), None).unwrap(); + let with_limit_oob = mw.get_activity(Some(usize::MAX), None).await.unwrap(); assert_eq!(with_limit_oob.len(), expected.len()); - let with_offset_oob = mw.get_activity(None, Some(usize::MAX)).unwrap(); + let with_offset_oob = mw.get_activity(None, Some(usize::MAX)).await.unwrap(); assert!(with_offset_oob.is_empty()); - let with_offset_oob = mw.get_activity(None, Some(expected.len())).unwrap(); + let with_offset_oob = mw.get_activity(None, Some(expected.len())).await.unwrap(); assert!(with_offset_oob.is_empty()); - let with_both_oob = mw.get_activity(Some(usize::MAX), Some(usize::MAX)).unwrap(); + let with_both_oob = mw + .get_activity(Some(usize::MAX), Some(usize::MAX)) + .await + .unwrap(); assert!(with_both_oob.is_empty()); // update an inflight payment and make sure it isn't duplicated @@ -4433,4 +4622,119 @@ mod tests { assert!(item.is_some_and(|i| i.timestamp == Some(invoice4.last_update))); // make sure timestamp got updated assert_eq!(vec.len(), expected.len()); // make sure no duplicates } + + #[test] + async fn test_optional_node_manager() { + let test_name = "test_optional_node_manager"; + log!("{}", test_name); + + 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_builder = MutinyWalletConfigBuilder::new(xpriv).with_network(network); + let config = config_builder.build(); + let mw = MutinyWalletBuilder::new(xpriv, storage.clone()) + .with_config(config.clone()) + .build() + .await + .expect("mutiny wallet should initialize"); + + // make sure we can start with no node manager + { + let lock = mw.node_manager.read().await; + assert!(lock.is_none()); + } + + // create a node manager + let ident = mw.create_node_manager().await.unwrap(); + // verify that we have a node manager + { + let lock = mw.node_manager.read().await; + assert!(lock.is_some()); + let nodes = lock.as_ref().unwrap().list_nodes().await.unwrap(); + assert_eq!(nodes.len(), 1); + assert_eq!(ident.pubkey, *nodes.first().unwrap()); + } + + // drop the wallet, when we restart it should start with no node manager + // because we didn't have any balances + mw.stop().await.expect("should stop"); + drop(mw); + let mw = MutinyWalletBuilder::new(xpriv, storage.clone()) + .with_config(config.clone()) + .build() + .await + .expect("mutiny wallet should initialize"); + + // see that we start with no node manager + { + let lock = mw.node_manager.read().await; + assert!(lock.is_none()); + } + + // recreate the node manager + let ident2 = mw.create_node_manager_if_needed().await.unwrap(); + assert_eq!(ident2.unwrap(), ident); + + // verify that we get the same node id + { + let lock = mw.node_manager.read().await; + let node_manager = lock.as_ref().unwrap(); + let nodes = node_manager.list_nodes().await.unwrap(); + assert_eq!(nodes.len(), 1); + assert_eq!(ident.pubkey, *nodes.first().unwrap()); + } + + // add a balance + let tx_amount = 10_000; + { + let lock = mw.node_manager.read().await; + let node_manager = lock.as_ref().unwrap(); + let address = node_manager.get_new_address(vec![]).unwrap(); + let output = TxOut { + value: tx_amount, + script_pubkey: address.script_pubkey(), + }; + let tx1 = Transaction { + version: 1, + lock_time: LockTime::ZERO, + input: vec![], + output: vec![output], + }; + node_manager + .wallet + .insert_tx(tx1, ConfirmationTime::Unconfirmed { last_seen: 0 }, None) + .await + .unwrap(); + } + + // now when we drop the wallet and restart it should keep the node manager + mw.stop().await.expect("should stop"); + drop(mw); + let mw = MutinyWalletBuilder::new(xpriv, storage.clone()) + .with_config(config.clone()) + .build() + .await + .expect("mutiny wallet should initialize"); + + // verify that we get the same node id + { + let lock = mw.node_manager.read().await; + let node_manager = lock.as_ref().unwrap(); + assert!(lock.is_some()); + let nodes = node_manager.list_nodes().await.unwrap(); + assert_eq!(nodes.len(), 1); + assert_eq!(ident.pubkey, *nodes.first().unwrap()); + + // we should have the same balance + assert_eq!(node_manager.get_wallet_balance().unwrap(), tx_amount); + } + + // make sure we only ever made one node at the end of this + let node_storage = storage.get_nodes().unwrap(); + assert_eq!(node_storage.nodes.len(), 1); + assert!(node_storage.nodes.contains_key(&ident.uuid)); + } } diff --git a/mutiny-core/src/nodemanager.rs b/mutiny-core/src/nodemanager.rs index 15d24e79d..630142c42 100644 --- a/mutiny-core/src/nodemanager.rs +++ b/mutiny-core/src/nodemanager.rs @@ -70,6 +70,12 @@ pub struct NodeStorage { pub version: u32, } +impl NodeStorage { + pub fn is_empty(&self) -> bool { + self.nodes.iter().all(|(_, n)| n.is_archived()) + } +} + // This is the NodeIndex reference that is saved to the DB #[derive(Debug, Serialize, Deserialize, PartialEq, Eq, Clone, Default)] pub struct NodeIndex { @@ -85,8 +91,9 @@ impl NodeIndex { } } -// This is the NodeIdentity that refer to a specific node -// Used for public facing identification. +/// This is the NodeIdentity that refer to a specific node +/// Used for public facing identification. +#[derive(Debug, Clone, Eq, PartialEq)] pub struct NodeIdentity { pub uuid: String, pub pubkey: PublicKey, @@ -230,6 +237,7 @@ impl Ord for ChannelClosure { } } +#[derive(Debug, Copy, Clone, Default)] pub struct NodeBalance { pub confirmed: u64, pub unconfirmed: u64, @@ -237,6 +245,13 @@ pub struct NodeBalance { pub force_close: u64, } +impl NodeBalance { + /// All balances are zero + pub fn is_zero(&self) -> bool { + self.confirmed == 0 && self.unconfirmed == 0 && self.lightning == 0 && self.force_close == 0 + } +} + pub struct NodeManagerBuilder { xprivkey: ExtendedPrivKey, storage: S, @@ -419,6 +434,11 @@ impl NodeManagerBuilder { let storage = self.storage.clone(); let logger_clone = logger.clone(); spawn(async move { + // skip if there are no nodes + if updated_nodes.is_empty() { + return; + } + let start = Instant::now(); if let Err(e) = storage .insert_nodes(&NodeStorage { @@ -505,7 +525,7 @@ pub struct NodeManager { impl NodeManager { /// Returns if there is a saved wallet in storage. /// This is checked by seeing if a mnemonic seed exists in storage. - pub fn has_node_manager(storage: S) -> bool { + pub fn is_wallet_present(storage: S) -> bool { storage.get_mnemonic().is_ok_and(|x| x.is_some()) } @@ -547,54 +567,33 @@ impl NodeManager { /// Creates a background process that will sync the wallet with the blockchain. /// This will also update the fee estimates every 10 minutes. - pub fn start_sync(nm: Arc>) { + pub fn start_sync( + nm: Arc>>>, + network: Network, + stop: Arc, + ) { // sync every second on regtest, this makes testing easier - let sync_interval_secs = match nm.network { + let sync_interval_secs = match network { Network::Bitcoin | Network::Testnet | Network::Signet => 60, Network::Regtest => 1, net => unreachable!("Unknown network: {net}"), }; + utils::spawn(async move { let mut synced = false; loop { // If we are stopped, don't sync - if nm.stop.load(Ordering::Relaxed) { + if stop.load(Ordering::Relaxed) { return; } - if !synced { - if let Err(e) = nm.sync_rgs().await { - log_error!(nm.logger, "Failed to sync RGS: {e}"); - } else { - log_info!(nm.logger, "RGS Synced!"); - } - - if let Err(e) = nm.sync_scorer().await { - log_error!(nm.logger, "Failed to sync scorer: {e}"); - } else { - log_info!(nm.logger, "Scorer Synced!"); - } - } - - // we don't need to re-sync fees every time - // just do it every 10 minutes - if let Err(e) = nm.fee_estimator.update_fee_estimates_if_necessary().await { - log_error!(nm.logger, "Failed to update fee estimates: {e}"); - } else { - log_info!(nm.logger, "Updated fee estimates!"); - } - - if let Err(e) = nm.sync().await { - log_error!(nm.logger, "Failed to sync: {e}"); - } else if !synced { - // if this is the first sync, set the done_first_sync flag - let _ = nm.storage.set_done_first_sync(); - synced = true; + if let Some(nm) = nm.read().await.as_ref() { + nm.do_sync_round(&mut synced).await; } // wait for next sync round, checking graceful shutdown check each second. for _ in 0..sync_interval_secs { - if nm.stop.load(Ordering::Relaxed) { + if stop.load(Ordering::Relaxed) { return; } sleep(1_000).await; @@ -603,6 +602,38 @@ impl NodeManager { }); } + pub(crate) async fn do_sync_round(&self, synced: &mut bool) { + if !*synced { + if let Err(e) = self.sync_rgs().await { + log_error!(self.logger, "Failed to sync RGS: {e}"); + } else { + log_info!(self.logger, "RGS Synced!"); + } + + if let Err(e) = self.sync_scorer().await { + log_error!(self.logger, "Failed to sync scorer: {e}"); + } else { + log_info!(self.logger, "Scorer Synced!"); + } + } + + // we don't need to re-sync fees every time + // just do it every 10 minutes + if let Err(e) = self.fee_estimator.update_fee_estimates_if_necessary().await { + log_error!(self.logger, "Failed to update fee estimates: {e}"); + } else { + log_info!(self.logger, "Updated fee estimates!"); + } + + if let Err(e) = self.sync().await { + log_error!(self.logger, "Failed to sync: {e}"); + } else if !*synced { + // if this is the first sync, set the done_first_sync flag + let _ = self.storage.set_done_first_sync(); + *synced = true; + } + } + /// Broadcast a transaction to the network. /// The transaction is broadcast through the configured esplora server. pub async fn broadcast_transaction(&self, tx: Transaction) -> Result<(), MutinyError> { @@ -619,7 +650,7 @@ impl NodeManager { pub fn get_new_address(&self, labels: Vec) -> Result { if let Ok(mut wallet) = self.wallet.wallet.try_write() { let address = wallet.try_get_address(AddressIndex::LastUnused)?.address; - self.set_address_labels(address.clone(), labels)?; + self.storage.set_address_labels(address.clone(), labels)?; return Ok(address); } @@ -842,7 +873,7 @@ impl NodeManager { last_seen: utils::now().as_secs(), }); - let address_labels = self.get_address_labels().unwrap_or_default(); + let address_labels = self.storage.get_address_labels().unwrap_or_default(); let labels = address_labels .get(&address.to_string()) .cloned() @@ -921,7 +952,7 @@ impl NodeManager { pub fn list_onchain(&self) -> Result, MutinyError> { let mut txs = self.wallet.list_transactions(true)?; txs.sort(); - let address_labels = self.get_address_labels()?; + let address_labels = self.storage.get_address_labels()?; let txs = txs .into_iter() .map(|tx| self.add_onchain_labels(&address_labels, tx)) @@ -934,7 +965,7 @@ impl NodeManager { pub fn get_transaction(&self, txid: Txid) -> Result, MutinyError> { match self.wallet.get_transaction(txid)? { Some(tx) => { - let address_labels = self.get_address_labels()?; + let address_labels = self.storage.get_address_labels()?; let tx_details = self.add_onchain_labels(&address_labels, tx); Ok(Some(tx_details)) } @@ -1166,7 +1197,6 @@ impl NodeManager { /// Archives a node so it will not be started up next time the node manager is created. /// /// If the node has any active channels it will fail to archive - #[allow(dead_code)] pub(crate) async fn archive_node(&self, pubkey: PublicKey) -> Result<(), MutinyError> { if let Some(node) = self.nodes.read().await.get(&pubkey) { // disallow archiving nodes with active channels or @@ -1186,7 +1216,6 @@ impl NodeManager { /// Archives a node so it will not be started up next time the node manager is created. /// /// If the node has any active channels it will fail to archive - #[allow(dead_code)] pub(crate) async fn archive_node_by_uuid(&self, node_uuid: String) -> Result<(), MutinyError> { let mut node_storage = self.node_storage.write().await; @@ -1204,6 +1233,104 @@ impl NodeManager { } } + /// Unarchives the first node in the node manager if we have one. + pub(crate) async fn unarchive_first_node(&self) -> Result { + // get locks + let mut node_storage = self.node_storage.write().await; + let mut nodes_map = self.nodes.write().await; + + let node = match node_storage.nodes.iter_mut().next() { + None => return Err(MutinyError::NotFound), + Some((uuid, node)) => { + // if isn't archived, just return the node + if !node.is_archived() { + let (_, node) = nodes_map + .iter() + .find(|(_, n)| &n.uuid == uuid) + .ok_or(MutinyError::NotFound)?; + return Ok(NodeIdentity { + uuid: node.uuid.clone(), + pubkey: node.pubkey, + }); + } + + node.archived = None; + + let mut node_builder = NodeBuilder::new(self.xprivkey, self.storage.clone()) + .with_uuid(uuid.clone()) + .with_node_index(node.clone()) + .with_gossip_sync(self.gossip_sync.clone()) + .with_scorer(self.scorer.clone()) + .with_chain(self.chain.clone()) + .with_fee_estimator(self.fee_estimator.clone()) + .with_wallet(self.wallet.clone()) + .with_esplora(self.esplora.clone()) + .with_network(self.network); + node_builder.with_logger(self.logger.clone()); + + #[cfg(target_arch = "wasm32")] + node_builder.with_websocket_proxy_addr(self.websocket_proxy_addr.clone()); + + if let Some(l) = self.lsp_config.clone() { + node_builder.with_lsp_config(l); + } + if self.do_not_connect_peers { + node_builder.do_not_connect_peers(); + } + + node_builder.build().await? + } + }; + + let pubkey = node + .keys_manager + .get_node_id(Recipient::Node) + .expect("Failed to get node id"); + + let uuid = node.uuid.clone(); + nodes_map.insert(pubkey, Arc::new(node)); + + node_storage.version += 1; + self.storage.insert_nodes(&node_storage).await?; + + Ok(NodeIdentity { uuid, pubkey }) + } + + /// Creates the first node for the NodeManager. This will handle if we need to create a new node + /// or unarchive the first node. + pub async fn create_first_node(&self) -> Result { + let needs_new_node = { + let node_storage = self.node_storage.read().await; + node_storage.nodes.is_empty() + }; + + // if we've never created a node, we need to create one + // otherwise we need to unarchive the first node + if needs_new_node { + self.new_node().await + } else { + self.unarchive_first_node().await + } + } + + /// Checks if the node manager is unused. This is used to determine if we are safe to remove the node manager + pub async fn is_unused(&self) -> Result { + // if we have any balances, we are not unused + if !self.get_balance().await?.is_zero() { + return Ok(false); + } + + // if we have any monitors, we are not unused + let nodes = self.nodes.read().await; + for (_, n) in nodes.iter() { + if !n.chain_monitor.get_claimable_balances(&[]).is_empty() { + return Ok(false); + } + } + + Ok(true) + } + /// Lists the pubkeys of the lightning node in the manager. pub async fn list_nodes(&self) -> Result, MutinyError> { let nodes = self.nodes.read().await; @@ -1816,9 +1943,12 @@ pub(crate) async fn create_new_node_from_node_manager( let next_node_uuid = new_node.uuid.clone(); existing_nodes.version += 1; - existing_nodes + let old = existing_nodes .nodes .insert(next_node_uuid.clone(), next_node); + + debug_assert!(old.is_none(), "Node index should not exist in storage"); + node_manager.storage.insert_nodes(&existing_nodes).await?; node_mutex.nodes = existing_nodes.nodes.clone(); @@ -1901,7 +2031,7 @@ mod tests { let cipher = encryption_key_from_pass(&pass).unwrap(); let storage = MemoryStorage::new(Some(pass), Some(cipher), None); - assert!(!NodeManager::has_node_manager(storage.clone())); + assert!(!NodeManager::is_wallet_present(storage.clone())); let c = MutinyWalletConfigBuilder::new(xpriv) .with_network(network) .build(); @@ -1911,7 +2041,7 @@ mod tests { .await .expect("node manager should initialize"); storage.insert_mnemonic(seed).unwrap(); - assert!(NodeManager::has_node_manager(storage)); + assert!(NodeManager::is_wallet_present(storage)); } #[test] diff --git a/mutiny-wasm/src/error.rs b/mutiny-wasm/src/error.rs index 3e7b70ff8..e760da21f 100644 --- a/mutiny-wasm/src/error.rs +++ b/mutiny-wasm/src/error.rs @@ -174,6 +174,9 @@ pub enum MutinyJsError { /// Failed to connect to a federation. #[error("Failed to connect to a federation.")] FederationConnectionFailed, + /// A node manager has not been created yet. + #[error("A node manager has not been created yet.")] + NodeManagerRequired, /// Unknown error. #[error("Unknown Error")] UnknownError, @@ -228,6 +231,7 @@ impl From for MutinyJsError { MutinyError::TokenAlreadySpent => MutinyJsError::TokenAlreadySpent, MutinyError::FederationRequired => MutinyJsError::FederationRequired, MutinyError::FederationConnectionFailed => MutinyJsError::FederationConnectionFailed, + MutinyError::NodeManagerRequired => MutinyJsError::NodeManagerRequired, MutinyError::Other(_) => MutinyJsError::UnknownError, MutinyError::SubscriptionClientNotConfigured => { MutinyJsError::SubscriptionClientNotConfigured diff --git a/mutiny-wasm/src/lib.rs b/mutiny-wasm/src/lib.rs index de371ae0f..b0754d940 100644 --- a/mutiny-wasm/src/lib.rs +++ b/mutiny-wasm/src/lib.rs @@ -340,7 +340,7 @@ impl MutinyWallet { /// Returns if there is a saved wallet in storage. /// This is checked by seeing if a mnemonic seed exists in storage. #[wasm_bindgen] - pub async fn has_node_manager() -> Result { + pub async fn is_wallet_present() -> Result { Ok(IndexedDbStorage::has_mnemonic().await?) } @@ -419,7 +419,7 @@ impl MutinyWallet { /// Returns after node has been stopped. #[wasm_bindgen] pub async fn stop(&self) -> Result<(), MutinyJsError> { - Ok(self.inner.node_manager.stop().await?) + Ok(self.inner.stop().await?) } /// Returns the mnemonic seed phrase for the wallet. @@ -565,12 +565,14 @@ impl MutinyWallet { // I know walia parses `pj=` and `pjos=` but payjoin::Uri parses the whole bip21 uri let pj_uri = payjoin::Uri::try_from(payjoin_uri.as_str()) .map_err(|_| MutinyJsError::InvalidArgumentsError)?; - Ok(self - .inner - .node_manager - .send_payjoin(pj_uri, amount, labels, fee_rate) - .await? - .to_string()) + if let Some(nm) = self.inner.node_manager.read().await.as_ref() { + Ok(nm + .send_payjoin(pj_uri, amount, labels, fee_rate) + .await? + .to_string()) + } else { + Err(MutinyJsError::NodeManagerRequired) + } } /// Sweeps all the funds from the wallet to the given address. @@ -620,27 +622,29 @@ impl MutinyWallet { /// Estimates the onchain fee for a opening a lightning channel. /// The amount is in satoshis and the fee rate is in sat/vbyte. - pub fn estimate_channel_open_fee( + pub async fn estimate_channel_open_fee( &self, amount: u64, fee_rate: Option, ) -> Result { - Ok(self - .inner - .node_manager - .estimate_channel_open_fee(amount, fee_rate)?) + if let Some(nm) = self.inner.node_manager.read().await.as_ref() { + Ok(nm.estimate_channel_open_fee(amount, fee_rate)?) + } else { + Err(MutinyJsError::NodeManagerRequired) + } } /// Estimates the onchain fee for sweeping our on-chain balance to open a lightning channel. /// The fee rate is in sat/vbyte. - pub fn estimate_sweep_channel_open_fee( + pub async fn estimate_sweep_channel_open_fee( &self, fee_rate: Option, ) -> Result { - Ok(self - .inner - .node_manager - .estimate_sweep_channel_open_fee(fee_rate)?) + if let Some(nm) = self.inner.node_manager.read().await.as_ref() { + Ok(nm.estimate_sweep_channel_open_fee(fee_rate)?) + } else { + Err(MutinyJsError::NodeManagerRequired) + } } /// Estimates the lightning fee for a transaction. Amount is either from the invoice @@ -667,9 +671,12 @@ impl MutinyWallet { /// the new given fee rate in sats/vbyte pub async fn bump_fee(&self, txid: String, fee_rate: f32) -> Result { let txid = Txid::from_str(&txid)?; - let result = self.inner.node_manager.bump_fee(txid, fee_rate).await?; - - Ok(result.to_string()) + if let Some(nm) = self.inner.node_manager.read().await.as_ref() { + let result = nm.bump_fee(txid, fee_rate).await?; + Ok(result.to_string()) + } else { + Err(MutinyJsError::NodeManagerRequired) + } } /// Checks if the given address has any transactions. @@ -682,19 +689,26 @@ impl MutinyWallet { address: String, ) -> Result */, MutinyJsError> { let address = Address::from_str(&address)?; - Ok(JsValue::from_serde( - &self.inner.node_manager.check_address(address).await?, - )?) + + if let Some(nm) = self.inner.node_manager.read().await.as_ref() { + let result = nm.check_address(address).await?; + Ok(JsValue::from_serde(&result)?) + } else { + // todo add fedimint + Err(MutinyJsError::NodeManagerRequired) + } } /// Gets the details of a specific on-chain transaction. #[wasm_bindgen] - pub fn get_transaction( + pub async fn get_transaction( &self, txid: String, ) -> Result */, MutinyJsError> { let txid = Txid::from_str(&txid)?; - Ok(JsValue::from_serde(&self.inner.get_transaction(txid)?)?) + Ok(JsValue::from_serde( + &self.inner.get_transaction(txid).await?, + )?) } /// Gets the current balance of the wallet. @@ -708,43 +722,85 @@ impl MutinyWallet { /// Lists all the UTXOs in the wallet. #[wasm_bindgen] - pub fn list_utxos(&self) -> Result { - Ok(JsValue::from_serde(&self.inner.node_manager.list_utxos()?)?) + pub async fn list_utxos(&self) -> Result { + if let Some(nm) = self.inner.node_manager.read().await.as_ref() { + let result = nm.list_utxos()?; + Ok(JsValue::from_serde(&result)?) + } else { + Err(MutinyJsError::NodeManagerRequired) + } } - /// Gets a fee estimate for an low priority transaction. + /// Gets a fee estimate for a low priority transaction. /// Value is in sat/vbyte. #[wasm_bindgen] - pub fn estimate_fee_low(&self) -> u32 { - self.inner.node_manager.estimate_fee_low() + pub async fn estimate_fee_low(&self) -> Result { + if let Some(nm) = self.inner.node_manager.read().await.as_ref() { + Ok(nm.estimate_fee_low()) + } else { + Err(MutinyJsError::NodeManagerRequired) + } } /// Gets a fee estimate for an average priority transaction. /// Value is in sat/vbyte. #[wasm_bindgen] - pub fn estimate_fee_normal(&self) -> u32 { - self.inner.node_manager.estimate_fee_normal() + pub async fn estimate_fee_normal(&self) -> Result { + if let Some(nm) = self.inner.node_manager.read().await.as_ref() { + Ok(nm.estimate_fee_normal()) + } else { + Err(MutinyJsError::NodeManagerRequired) + } } - /// Gets a fee estimate for an high priority transaction. + /// Gets a fee estimate for a high priority transaction. /// Value is in sat/vbyte. #[wasm_bindgen] - pub fn estimate_fee_high(&self) -> u32 { - self.inner.node_manager.estimate_fee_high() + pub async fn estimate_fee_high(&self) -> Result { + if let Some(nm) = self.inner.node_manager.read().await.as_ref() { + Ok(nm.estimate_fee_high()) + } else { + Err(MutinyJsError::NodeManagerRequired) + } } /// Creates a new lightning node and adds it to the manager. #[wasm_bindgen] pub async fn new_node(&self) -> Result { - Ok(self.inner.node_manager.new_node().await?.into()) + let lock = self.inner.node_manager.read().await; + if let Some(nm) = lock.as_ref() { + Ok(nm.new_node().await?.into()) + } else { + drop(lock); // drop the lock so we can get a write lock to create the node manager + Ok(self.inner.create_node_manager().await?.into()) + } + } + + /// Creates a node manager if we don't have one already. + #[wasm_bindgen] + pub async fn create_node_manager_if_needed(&self) -> Result<(), MutinyJsError> { + self.inner.create_node_manager_if_needed().await?; + + Ok(()) + } + + /// Removes the node manager from the wallet. If the node manager has any balances, + /// this function will fail. + #[wasm_bindgen] + pub async fn remove_node_manager(&self) -> Result<(), MutinyJsError> { + self.inner.remove_node_manager().await?; + + Ok(()) } /// Lists the pubkeys of the lightning node in the manager. #[wasm_bindgen] pub async fn list_nodes(&self) -> Result */, MutinyJsError> { - Ok(JsValue::from_serde( - &self.inner.node_manager.list_nodes().await?, - )?) + if let Some(nm) = self.inner.node_manager.read().await.as_ref() { + Ok(JsValue::from_serde(&nm.list_nodes().await?)?) + } else { + Ok(JsValue::from_serde::>(&vec![])?) + } } /// Changes all the node's LSPs to the given config. If any of the nodes have an active channel with the @@ -758,9 +814,11 @@ impl MutinyWallet { lsp_token: Option, ) -> Result<(), MutinyJsError> { let lsp_config = create_lsp_config(lsp_url, lsp_connection_string, lsp_token)?; - - self.inner.node_manager.change_lsp(lsp_config).await?; - Ok(()) + if let Some(nm) = self.inner.node_manager.read().await.as_ref() { + Ok(nm.change_lsp(lsp_config).await?) + } else { + Err(MutinyJsError::NodeManagerRequired) + } } /// Attempts to connect to a peer from the selected node. @@ -770,18 +828,22 @@ impl MutinyWallet { connection_string: String, label: Option, ) -> Result<(), MutinyJsError> { - Ok(self - .inner - .node_manager - .connect_to_peer(None, &connection_string, label) - .await?) + if let Some(nm) = self.inner.node_manager.read().await.as_ref() { + Ok(nm.connect_to_peer(None, &connection_string, label).await?) + } else { + Err(MutinyJsError::NodeManagerRequired) + } } /// Disconnects from a peer from the selected node. #[wasm_bindgen] pub async fn disconnect_peer(&self, peer: String) -> Result<(), MutinyJsError> { let peer = PublicKey::from_str(&peer)?; - Ok(self.inner.node_manager.disconnect_peer(None, peer).await?) + if let Some(nm) = self.inner.node_manager.read().await.as_ref() { + Ok(nm.disconnect_peer(None, peer).await?) + } else { + Err(MutinyJsError::NodeManagerRequired) + } } /// Deletes a peer from the selected node. @@ -790,16 +852,27 @@ impl MutinyWallet { #[wasm_bindgen] pub async fn delete_peer(&self, peer: String) -> Result<(), MutinyJsError> { let peer = NodeId::from_str(&peer).map_err(|_| MutinyJsError::InvalidArgumentsError)?; - Ok(self.inner.node_manager.delete_peer(None, &peer).await?) + if let Some(nm) = self.inner.node_manager.read().await.as_ref() { + Ok(nm.delete_peer(None, &peer).await?) + } else { + Err(MutinyJsError::NodeManagerRequired) + } } /// Sets the label of a peer from the selected node. #[wasm_bindgen] - pub fn label_peer(&self, node_id: String, label: Option) -> Result<(), MutinyJsError> { + pub async fn label_peer( + &self, + node_id: String, + label: Option, + ) -> Result<(), MutinyJsError> { let node_id = NodeId::from_str(&node_id).map_err(|_| MutinyJsError::InvalidArgumentsError)?; - self.inner.node_manager.label_peer(&node_id, label)?; - Ok(()) + if let Some(nm) = self.inner.node_manager.read().await.as_ref() { + Ok(nm.label_peer(&node_id, label)?) + } else { + Err(MutinyJsError::NodeManagerRequired) + } } /// Creates a lightning invoice. The amount should be in satoshis. @@ -846,12 +919,14 @@ impl MutinyWallet { labels: Vec, ) -> Result { let to_node = PublicKey::from_str(&to_node)?; - Ok(self - .inner - .node_manager - .keysend(None, to_node, amt_sats, message, labels) - .await? - .into()) + if let Some(nm) = self.inner.node_manager.read().await.as_ref() { + Ok(nm + .keysend(None, to_node, amt_sats, message, labels) + .await? + .into()) + } else { + Err(MutinyJsError::NodeManagerRequired) + } } /// Decodes a lightning invoice into useful information. @@ -977,12 +1052,14 @@ impl MutinyWallet { user_channel_id: String, ) -> Result { let user_channel_id: [u8; 16] = FromHex::from_hex(&user_channel_id)?; - Ok(self - .inner - .node_manager - .get_channel_closure(u128::from_be_bytes(user_channel_id)) - .await? - .into()) + if let Some(nm) = self.inner.node_manager.read().await.as_ref() { + Ok(nm + .get_channel_closure(u128::from_be_bytes(user_channel_id)) + .await? + .into()) + } else { + Err(MutinyJsError::NodeManagerRequired) + } } /// Gets all channel closures from the node manager. @@ -992,9 +1069,14 @@ impl MutinyWallet { pub async fn list_channel_closures( &self, ) -> Result */, MutinyJsError> { - let mut channel_closures = self.inner.node_manager.list_channel_closures().await?; - channel_closures.sort(); - Ok(JsValue::from_serde(&channel_closures)?) + if let Some(nm) = self.inner.node_manager.read().await.as_ref() { + let mut channel_closures = nm.list_channel_closures().await?; + channel_closures.sort(); + Ok(JsValue::from_serde(&channel_closures)?) + } else { + // just return empty list if no node manager + Ok(JsValue::from_serde::>(&vec![])?) + } } /// Opens a channel from our selected node to the given pubkey. @@ -1016,12 +1098,14 @@ impl MutinyWallet { _ => None, }; - Ok(self - .inner - .node_manager - .open_channel(None, to_pubkey, amount, fee_rate, None) - .await? - .into()) + if let Some(nm) = self.inner.node_manager.read().await.as_ref() { + Ok(nm + .open_channel(None, to_pubkey, amount, fee_rate, None) + .await? + .into()) + } else { + Err(MutinyJsError::NodeManagerRequired) + } } /// Opens a channel from our selected node to the given pubkey. @@ -1039,12 +1123,11 @@ impl MutinyWallet { _ => None, }; - Ok(self - .inner - .node_manager - .sweep_all_to_channel(to_pubkey) - .await? - .into()) + if let Some(nm) = self.inner.node_manager.read().await.as_ref() { + Ok(nm.sweep_all_to_channel(to_pubkey).await?.into()) + } else { + Err(MutinyJsError::NodeManagerRequired) + } } /// Sweep the federation balance into a lightning channel @@ -1120,27 +1203,32 @@ impl MutinyWallet { ) -> Result<(), MutinyJsError> { let outpoint: OutPoint = OutPoint::from_str(&outpoint).map_err(|_| MutinyJsError::InvalidArgumentsError)?; - Ok(self - .inner - .node_manager - .close_channel(&outpoint, None, force, abandon) - .await?) + + if let Some(nm) = self.inner.node_manager.read().await.as_ref() { + Ok(nm.close_channel(&outpoint, None, force, abandon).await?) + } else { + Err(MutinyJsError::NodeManagerRequired) + } } /// Lists all the channels for all the nodes in the node manager. #[wasm_bindgen] pub async fn list_channels(&self) -> Result */, MutinyJsError> { - Ok(JsValue::from_serde( - &self.inner.node_manager.list_channels().await?, - )?) + if let Some(nm) = self.inner.node_manager.read().await.as_ref() { + Ok(JsValue::from_serde(&nm.list_channels().await?)?) + } else { + Ok(JsValue::from_serde::>(&vec![])?) + } } /// Lists all the peers for all the nodes in the node manager. #[wasm_bindgen] pub async fn list_peers(&self) -> Result */, MutinyJsError> { - Ok(JsValue::from_serde( - &self.inner.node_manager.list_peers().await?, - )?) + if let Some(nm) = self.inner.node_manager.read().await.as_ref() { + Ok(JsValue::from_serde(&nm.list_peers().await?)?) + } else { + Ok(JsValue::from_serde::>(&vec![])?) + } } /// Returns all the on-chain and lightning activity from the wallet. @@ -1151,11 +1239,11 @@ impl MutinyWallet { offset: Option, ) -> Result */, MutinyJsError> { // get activity from the node manager - let activity = self.inner.get_activity(limit, offset)?; + let activity = self.inner.get_activity(limit, offset).await?; let mut activity: Vec = activity.into_iter().map(|a| a.into()).collect(); // add contacts to the activity - let contacts = self.inner.node_manager.get_contacts()?; + let contacts = self.inner.get_contacts()?; let follows = self.inner.nostr.get_follow_list()?; for a in activity.iter_mut() { // find labels that have a contact and add them to the item @@ -1188,7 +1276,7 @@ impl MutinyWallet { let mut activity: Vec = activity.into_iter().map(|a| a.into()).collect(); // add contact to the activity item it has one, otherwise return the activity list - let contact = match self.inner.node_manager.get_contact(&label)? { + let contact = match self.inner.get_contact(&label)? { Some(contact) => contact, None => return Ok(JsValue::from_serde(&activity)?), }; @@ -1315,9 +1403,7 @@ impl MutinyWallet { pub fn get_address_labels( &self, ) -> Result> */, MutinyJsError> { - Ok(JsValue::from_serde( - &self.inner.node_manager.get_address_labels()?, - )?) + Ok(JsValue::from_serde(&self.inner.get_address_labels()?)?) } /// Set the labels for an address, replacing any existing labels @@ -1329,18 +1415,13 @@ impl MutinyWallet { labels: Vec, ) -> Result<(), MutinyJsError> { let address = Address::from_str(&address)?.assume_checked(); - Ok(self - .inner - .node_manager - .set_address_labels(address, labels)?) + Ok(self.inner.set_address_labels(address, labels)?) } pub fn get_invoice_labels( &self, ) -> Result> */, MutinyJsError> { - Ok(JsValue::from_serde( - &self.inner.node_manager.get_invoice_labels()?, - )?) + Ok(JsValue::from_serde(&self.inner.get_invoice_labels()?)?) } /// Set the labels for an invoice, replacing any existing labels @@ -1352,10 +1433,7 @@ impl MutinyWallet { labels: Vec, ) -> Result<(), MutinyJsError> { let invoice = Bolt11Invoice::from_str(&invoice)?; - Ok(self - .inner - .node_manager - .set_invoice_labels(invoice, labels)?) + Ok(self.inner.set_invoice_labels(invoice, labels)?) } pub async fn get_contacts(&self) -> Result*/, MutinyJsError> { @@ -1363,7 +1441,6 @@ impl MutinyWallet { Ok(JsValue::from_serde( &self .inner - .node_manager .get_contacts()? .into_iter() .map(|(id, c)| { @@ -1383,7 +1460,6 @@ impl MutinyWallet { let follows = self.inner.nostr.get_follow_list()?; let mut contacts: Vec = self .inner - .node_manager .get_contacts()? .into_iter() .map(|(id, c)| { @@ -1406,7 +1482,6 @@ impl MutinyWallet { let follows = self.inner.nostr.get_follow_list()?; let mut contacts: Vec = self .inner - .node_manager .get_contacts()? .into_iter() .flat_map(|(id, c)| { @@ -1429,7 +1504,7 @@ impl MutinyWallet { } pub fn get_tag_item(&self, label: String) -> Result, MutinyJsError> { - match self.inner.node_manager.get_contact(&label)? { + match self.inner.get_contact(&label)? { Some(contact) => { let follows = self.inner.nostr.get_follow_list()?; let is_followed = contact @@ -1464,10 +1539,7 @@ impl MutinyWallet { last_used: now().as_secs(), }; - Ok(self - .inner - .node_manager - .create_contact_from_label(label, contact)?) + Ok(self.inner.create_contact_from_label(label, contact)?) } pub fn create_new_contact( @@ -1488,11 +1560,11 @@ impl MutinyWallet { image_url, last_used: now().as_secs(), }; - Ok(self.inner.node_manager.create_new_contact(contact)?) + Ok(self.inner.create_new_contact(contact)?) } pub fn delete_contact(&self, id: String) -> Result<(), MutinyJsError> { - Ok(self.inner.node_manager.delete_contact(id)?) + Ok(self.inner.delete_contact(id)?) } pub fn edit_contact( @@ -1515,7 +1587,7 @@ impl MutinyWallet { last_used: now().as_secs(), }; - Ok(self.inner.node_manager.edit_contact(id, contact)?) + Ok(self.inner.edit_contact(id, contact)?) } pub async fn get_contact_for_npub( @@ -1523,7 +1595,7 @@ impl MutinyWallet { npub: String, ) -> Result, MutinyJsError> { let npub = parse_npub(&npub)?; - let contact = self.inner.node_manager.get_contact_for_npub(npub)?; + let contact = self.inner.get_contact_for_npub(npub)?; match contact { Some((id, c)) => { @@ -1542,7 +1614,6 @@ impl MutinyWallet { pub fn get_tag_items(&self) -> Result, MutinyJsError> { let mut tags: Vec = self .inner - .node_manager .get_tag_items()? .into_iter() .map(|t| t.into()) @@ -2049,9 +2120,12 @@ impl MutinyWallet { /// 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> { - self.inner.node_manager.reset_router().await?; - // Sleep to wait for indexed db to finish writing - sleep(500).await; + if let Some(nm) = self.inner.node_manager.read().await.as_ref() { + nm.reset_router().await?; + // Sleep to wait for indexed db to finish writing + sleep(500).await; + } + Ok(()) } @@ -2206,7 +2280,7 @@ mod tests { log!("creating mutiny wallet!"); let password = Some("password".to_string()); - assert!(!MutinyWallet::has_node_manager().await.unwrap()); + assert!(!MutinyWallet::is_wallet_present().await.unwrap()); MutinyWallet::new( password.clone(), None, @@ -2234,7 +2308,7 @@ mod tests { .await .expect("mutiny wallet should initialize"); sleep(1_000).await; - assert!(MutinyWallet::has_node_manager().await.unwrap()); + assert!(MutinyWallet::is_wallet_present().await.unwrap()); IndexedDbStorage::clear() .await @@ -2271,7 +2345,7 @@ mod tests { .await .expect("mutiny wallet should initialize"); sleep(1_000).await; - assert!(MutinyWallet::has_node_manager().await.unwrap()); + assert!(MutinyWallet::is_wallet_present().await.unwrap()); uninit().await; let seed = mutiny_core::generate_seed(12).unwrap(); @@ -2318,7 +2392,7 @@ mod tests { log!("trying to create 2 mutiny wallets!"); let password = Some("password".to_string()); - assert!(!MutinyWallet::has_node_manager().await.unwrap()); + assert!(!MutinyWallet::is_wallet_present().await.unwrap()); MutinyWallet::new( password.clone(), None, @@ -2346,7 +2420,7 @@ mod tests { .await .expect("mutiny wallet should initialize"); sleep(1_000).await; - assert!(MutinyWallet::has_node_manager().await.unwrap()); + assert!(MutinyWallet::is_wallet_present().await.unwrap()); // try to create a second let result = MutinyWallet::new( @@ -2428,7 +2502,7 @@ mod tests { .unwrap(); log!("checking nm"); - assert!(MutinyWallet::has_node_manager().await.unwrap()); + assert!(MutinyWallet::is_wallet_present().await.unwrap()); log!("checking seed"); assert_eq!(seed.to_string(), nm.show_seed()); @@ -2477,7 +2551,7 @@ mod tests { .unwrap(); log!("checking nm"); - assert!(MutinyWallet::has_node_manager().await.unwrap()); + assert!(MutinyWallet::is_wallet_present().await.unwrap()); log!("checking seed"); assert_eq!(seed.to_string(), nm.show_seed()); nm.stop().await.unwrap(); @@ -2556,13 +2630,12 @@ mod tests { assert_ne!("", node_identity.uuid()); assert_ne!("", node_identity.pubkey()); - let node_identity = nm + let node_identity2 = nm .new_node() .await .expect("mutiny wallet should initialize"); - assert_ne!("", node_identity.uuid()); - assert_ne!("", node_identity.pubkey()); + assert_ne!(node_identity, node_identity2); IndexedDbStorage::clear() .await diff --git a/mutiny-wasm/src/models.rs b/mutiny-wasm/src/models.rs index 7978c2e04..98aef67ae 100644 --- a/mutiny-wasm/src/models.rs +++ b/mutiny-wasm/src/models.rs @@ -559,7 +559,7 @@ impl From for LnUrlParams { // This is the NodeIdentity that refer to a specific node // Used for public facing identification. -#[derive(Serialize, Deserialize, Clone, Eq, PartialEq)] +#[derive(Debug, Serialize, Deserialize, Clone, Eq, PartialEq)] #[wasm_bindgen] pub struct NodeIdentity { uuid: String,