#![crate_name = "mutiny_core"]
#![allow(
async_fn_in_trait,
incomplete_features,
clippy::extra_unused_type_parameters,
clippy::arc_with_non_send_sync,
type_alias_bounds
)]
extern crate core;
mod background;
pub mod auth;
mod chain;
pub mod encrypt;
pub mod error;
pub mod esplora;
mod event;
mod fees;
mod gossip;
mod keymanager;
pub mod labels;
mod ldkstorage;
pub mod lnurlauth;
pub mod logging;
mod lspclient;
mod multiesplora;
mod networking;
mod node;
pub mod nodemanager;
pub mod nostr;
mod onchain;
mod peermanager;
pub mod redshift;
pub mod scorer;
pub mod storage;
mod subscription;
pub mod utils;
pub mod vss;
#[cfg(test)]
mod test_utils;
pub use crate::event::HTLCStatus;
pub use crate::gossip::{GOSSIP_SYNC_TIME_KEY, NETWORK_GRAPH_KEY, PROB_SCORER_KEY};
pub use crate::keymanager::generate_seed;
pub use crate::ldkstorage::{CHANNEL_MANAGER_KEY, MONITORS_PREFIX_KEY};
use crate::auth::MutinyAuthClient;
use crate::labels::{get_contact_key, Contact, LabelStorage};
use crate::nostr::nwc::{
BudgetPeriod, BudgetedSpendingConditions, NwcProfileTag, SpendingConditions,
};
use crate::nostr::MUTINY_PLUS_SUBSCRIPTION_LABEL;
use crate::storage::{MutinyStorage, DEVICE_ID_KEY, EXPECTED_NETWORK_KEY, NEED_FULL_SYNC_KEY};
use crate::{error::MutinyError, nostr::ReservedProfile};
use crate::{nodemanager::NodeManager, nostr::ProfileType};
use crate::{nostr::NostrManager, utils::sleep};
use ::nostr::key::XOnlyPublicKey;
use ::nostr::{Event, Kind, Metadata};
use bip39::Mnemonic;
use bitcoin::secp256k1::PublicKey;
use bitcoin::util::bip32::ExtendedPrivKey;
use bitcoin::Network;
use futures::{pin_mut, select, FutureExt};
use lightning::{log_debug, util::logger::Logger};
use lightning::{log_error, log_info, log_warn};
use lightning_invoice::Bolt11Invoice;
use nostr_sdk::{Client, RelayPoolNotification};
use serde_json::{json, Value};
use std::collections::HashMap;
use std::sync::atomic::Ordering;
use std::sync::Arc;
#[derive(Clone)]
pub struct MutinyWalletConfig {
xprivkey: ExtendedPrivKey,
#[cfg(target_arch = "wasm32")]
websocket_proxy_addr: Option<String>,
network: Network,
user_esplora_url: Option<String>,
user_rgs_url: Option<String>,
lsp_url: Option<String>,
auth_client: Option<Arc<MutinyAuthClient>>,
subscription_url: Option<String>,
scorer_url: Option<String>,
do_not_connect_peers: bool,
skip_device_lock: bool,
pub safe_mode: bool,
skip_hodl_invoices: bool,
}
impl MutinyWalletConfig {
#[allow(clippy::too_many_arguments)]
pub fn new(
xprivkey: ExtendedPrivKey,
#[cfg(target_arch = "wasm32")] websocket_proxy_addr: Option<String>,
network: Network,
user_esplora_url: Option<String>,
user_rgs_url: Option<String>,
lsp_url: Option<String>,
auth_client: Option<Arc<MutinyAuthClient>>,
subscription_url: Option<String>,
scorer_url: Option<String>,
skip_device_lock: bool,
skip_hodl_invoices: bool,
) -> Self {
Self {
xprivkey,
#[cfg(target_arch = "wasm32")]
websocket_proxy_addr,
network,
user_esplora_url,
user_rgs_url,
scorer_url,
lsp_url,
auth_client,
subscription_url,
do_not_connect_peers: false,
skip_device_lock,
safe_mode: false,
skip_hodl_invoices,
}
}
pub fn with_do_not_connect_peers(mut self) -> Self {
self.do_not_connect_peers = true;
self
}
pub fn with_safe_mode(mut self) -> Self {
self.safe_mode = true;
self.with_do_not_connect_peers()
}
}
#[derive(Clone)]
pub struct MutinyWallet<S: MutinyStorage> {
pub config: MutinyWalletConfig,
pub storage: S,
pub node_manager: Arc<NodeManager<S>>,
pub nostr: Arc<NostrManager<S>>,
}
impl<S: MutinyStorage> MutinyWallet<S> {
pub async fn new(
storage: S,
config: MutinyWalletConfig,
session_id: Option<String>,
) -> Result<MutinyWallet<S>, MutinyError> {
let expected_network = storage.get::<Network>(EXPECTED_NETWORK_KEY)?;
match expected_network {
Some(network) => {
if network != config.network {
return Err(MutinyError::NetworkMismatch);
}
}
None => storage.set_data(EXPECTED_NETWORK_KEY, config.network, None)?,
}
let node_manager =
Arc::new(NodeManager::new(config.clone(), storage.clone(), session_id).await?);
NodeManager::start_sync(node_manager.clone());
let nostr = Arc::new(NostrManager::from_mnemonic(
node_manager.xprivkey,
storage.clone(),
node_manager.logger.clone(),
)?);
if !config.skip_hodl_invoices {
log_warn!(
node_manager.logger,
"Starting with HODL invoices enabled. This is not recommended!"
);
}
let mw = Self {
config,
storage,
node_manager,
nostr,
};
#[cfg(not(test))]
{
if mw.storage.get(NEED_FULL_SYNC_KEY)?.unwrap_or_default() {
mw.node_manager.wallet.full_sync().await?;
mw.storage.delete(&[NEED_FULL_SYNC_KEY])?;
}
}
if mw.config.safe_mode {
return Ok(mw);
}
let first_node = {
match mw.node_manager.list_nodes().await?.pop() {
Some(node) => node,
None => mw.node_manager.new_node().await?.pubkey,
}
};
mw.start_nostr_wallet_connect(first_node).await;
Ok(mw)
}
pub async fn start(&mut self) -> Result<(), MutinyError> {
self.storage.start().await?;
self.node_manager =
Arc::new(NodeManager::new(self.config.clone(), self.storage.clone(), None).await?);
NodeManager::start_sync(self.node_manager.clone());
if !self.config.safe_mode {
NodeManager::start_redshifts(self.node_manager.clone());
}
Ok(())
}
pub(crate) async fn start_nostr_wallet_connect(&self, from_node: PublicKey) {
let nostr = self.nostr.clone();
let nm = self.node_manager.clone();
utils::spawn(async move {
loop {
if nm.stop.load(Ordering::Relaxed) {
break;
};
let relays = nostr.get_relays();
if relays.is_empty() {
utils::sleep(10_000).await;
continue;
}
if let Err(e) = nostr.remove_inactive_profiles() {
log_warn!(nm.logger, "Failed to clear in-active NWC profiles: {e}");
}
let node = nm.get_node(&from_node).await.expect("failed to get node");
if let Err(e) = nostr.clear_successful_single_use_profiles(&node) {
log_warn!(nm.logger, "Failed to clear in-active NWC profiles: {e}");
}
drop(node);
if let Err(e) = nostr.clear_expired_nwc_invoices().await {
log_warn!(nm.logger, "Failed to clear expired NWC invoices: {e}");
}
let client = Client::new(&nostr.primary_key);
#[cfg(target_arch = "wasm32")]
let add_relay_res = client.add_relays(nostr.get_relays()).await;
#[cfg(not(target_arch = "wasm32"))]
let add_relay_res = client
.add_relays(nostr.get_relays().into_iter().map(|s| (s, None)).collect())
.await;
add_relay_res.expect("Failed to add relays");
client.connect().await;
let mut last_filters = nostr.get_nwc_filters();
client.subscribe(last_filters.clone()).await;
let mut notifications = client.notifications();
let mut next_filter_check = crate::utils::now().as_secs() + 5;
loop {
let read_fut = notifications.recv().fuse();
let delay_fut = Box::pin(utils::sleep(1_000)).fuse();
let current_time = crate::utils::now().as_secs();
let time_until_next_filter_check =
(next_filter_check.saturating_sub(current_time)) * 1_000;
let filter_check_fut = Box::pin(utils::sleep(
time_until_next_filter_check.try_into().unwrap(),
))
.fuse();
pin_mut!(read_fut, delay_fut, filter_check_fut);
select! {
notification = read_fut => {
match notification {
Ok(RelayPoolNotification::Event(_url, event)) => {
if event.kind == Kind::WalletConnectRequest && event.verify().is_ok() {
match nostr.handle_nwc_request(event, &nm, &from_node).await {
Ok(Some(event)) => {
if let Err(e) = client.send_event(event).await {
log_warn!(nm.logger, "Error sending NWC event: {e}");
}
}
Ok(None) => {} Err(e) => {
log_error!(nm.logger, "Error handling NWC request: {e}");
}
}
}
},
Ok(RelayPoolNotification::Message(_, _)) => {}, Ok(RelayPoolNotification::Shutdown) => break, Ok(RelayPoolNotification::Stop) => {}, Ok(RelayPoolNotification::RelayStatus { .. }) => {}, Err(_) => break, }
}
_ = delay_fut => {
if nm.stop.load(Ordering::Relaxed) {
break;
}
}
_ = filter_check_fut => {
let current_filters = nostr.get_nwc_filters();
if current_filters != last_filters {
log_debug!(nm.logger, "subscribing to new nwc filters");
client.subscribe(current_filters.clone()).await;
last_filters = current_filters;
}
next_filter_check = crate::utils::now().as_secs() + 5;
}
}
}
if let Err(e) = client.disconnect().await {
log_warn!(nm.logger, "Error disconnecting from relays: {e}");
}
}
});
}
pub async fn check_subscribed(&self) -> Result<Option<u64>, MutinyError> {
if let Some(subscription_client) = self.node_manager.subscription_client.clone() {
let expired = self.node_manager.check_subscribed().await?;
if let Some(expired_time) = expired {
if expired_time + 86_400 * 3 > crate::utils::now().as_secs() {
self.ensure_mutiny_nwc_profile(subscription_client, false)
.await?;
}
}
Ok(expired)
} else {
Err(MutinyError::SubscriptionClientNotConfigured)
}
}
pub async fn pay_subscription_invoice(
&self,
inv: &Bolt11Invoice,
autopay: bool,
) -> Result<(), MutinyError> {
if let Some(subscription_client) = self.node_manager.subscription_client.clone() {
let nodes = self.node_manager.nodes.lock().await;
let first_node_pubkey = if let Some(node) = nodes.values().next() {
node.pubkey
} else {
return Err(MutinyError::WalletOperationFailed);
};
drop(nodes);
self.node_manager
.pay_invoice(
&first_node_pubkey,
inv,
None,
vec![MUTINY_PLUS_SUBSCRIPTION_LABEL.to_string()],
)
.await?;
self.ensure_mutiny_nwc_profile(subscription_client, autopay)
.await?;
Ok(())
} else {
Err(MutinyError::SubscriptionClientNotConfigured)
}
}
async fn ensure_mutiny_nwc_profile(
&self,
subscription_client: Arc<subscription::MutinySubscriptionClient>,
autopay: bool,
) -> Result<(), MutinyError> {
let nwc_profiles = self.nostr.profiles();
let reserved_profile_index = ReservedProfile::MutinySubscription.info().1;
let profile_opt = nwc_profiles
.iter()
.find(|profile| profile.index == reserved_profile_index);
match profile_opt {
None => {
let nwc = if autopay {
self.nostr
.create_new_nwc_profile(
ProfileType::Reserved(ReservedProfile::MutinySubscription),
SpendingConditions::Budget(BudgetedSpendingConditions {
budget: 21_000,
single_max: None,
payments: vec![],
period: BudgetPeriod::Month,
}),
NwcProfileTag::Subscription,
)
.await?
.nwc_uri
} else {
self.nostr
.create_new_nwc_profile(
ProfileType::Reserved(ReservedProfile::MutinySubscription),
SpendingConditions::RequireApproval,
NwcProfileTag::Subscription,
)
.await?
.nwc_uri
};
subscription_client.submit_nwc(nwc).await?;
}
Some(profile) => {
if profile.tag != NwcProfileTag::Subscription {
let mut nwc = profile.clone();
nwc.tag = NwcProfileTag::Subscription;
self.nostr.edit_profile(nwc)?;
}
}
}
match self
.node_manager
.get_contact(MUTINY_PLUS_SUBSCRIPTION_LABEL)?
{
Some(_) => {}
None => {
let key = get_contact_key(MUTINY_PLUS_SUBSCRIPTION_LABEL);
let contact = Contact {
name: MUTINY_PLUS_SUBSCRIPTION_LABEL.to_string(),
npub: None,
ln_address: None,
lnurl: None,
image_url: Some("https://void.cat/d/CZPXhnwjqRhULSjPJ3sXTE.webp".to_string()),
archived: None,
last_used: utils::now().as_secs(),
};
self.storage.set_data(key, contact, None)?;
}
}
Ok(())
}
pub async fn sync_nostr_contacts(
&self,
primal_url: Option<&str>,
npub: XOnlyPublicKey,
) -> Result<(), MutinyError> {
let body = json!(["contact_list", { "pubkey": npub } ]);
let url = primal_url.unwrap_or("https://primal-cache.mutinywallet.com/api");
let data: Vec<Value> = reqwest::Client::new()
.post(url)
.header("Content-Type", "application/json")
.body(body.to_string())
.send()
.await
.map_err(|_| MutinyError::NostrError)?
.json()
.await
.map_err(|_| MutinyError::NostrError)?;
let mut metadata = data
.into_iter()
.filter_map(|v| {
Event::from_value(v)
.ok()
.and_then(|e| Metadata::from_json(e.content).ok().map(|m| (e.pubkey, m)))
})
.collect::<HashMap<_, _>>();
let contacts = self.storage.get_contacts()?;
for (id, contact) in contacts {
if let Some(npub) = contact.npub {
let npub = XOnlyPublicKey::from_slice(&npub.serialize()).unwrap();
if let Some(meta) = metadata.get(&npub) {
let updated = contact.update_with_metadata(meta.clone());
self.storage.edit_contact(id, updated)?;
metadata.remove(&npub);
}
}
}
for (npub, meta) in metadata {
let npub = bitcoin::XOnlyPublicKey::from_slice(&npub.serialize()).unwrap();
let contact = Contact::create_from_metadata(npub, meta);
if contact.name.is_empty() {
log_debug!(
self.node_manager.logger,
"Skipping creating contact with no name: {npub}"
);
continue;
}
self.storage.create_new_contact(contact)?;
}
Ok(())
}
pub async fn stop(&self) -> Result<(), MutinyError> {
self.node_manager.stop().await
}
pub async fn change_password(
&mut self,
old: Option<String>,
new: Option<String>,
) -> Result<(), MutinyError> {
if old != self.storage.password().map(|s| s.to_owned()) {
return Err(MutinyError::IncorrectPassword);
}
if old == new {
return Err(MutinyError::SamePassword);
}
log_info!(self.node_manager.logger, "Changing password");
self.stop().await?;
self.storage.start().await?;
self.storage.change_password_and_rewrite_storage(
old.filter(|s| !s.is_empty()),
new.filter(|s| !s.is_empty()),
)?;
sleep(5_000).await;
Ok(())
}
pub async fn reset_onchain_tracker(&mut self) -> Result<(), MutinyError> {
self.node_manager.reset_onchain_tracker().await?;
utils::sleep(250).await;
self.stop().await?;
utils::sleep(250).await;
self.start().await?;
self.node_manager.wallet.full_sync().await?;
Ok(())
}
pub async fn restore_mnemonic(mut storage: S, m: Mnemonic) -> Result<(), MutinyError> {
let device_id = storage.get_device_id()?;
storage.stop();
S::clear().await?;
storage.start().await?;
storage.insert_mnemonic(m)?;
storage.set_data(NEED_FULL_SYNC_KEY, true, None)?;
storage.set_data(DEVICE_ID_KEY, device_id, None)?;
Ok(())
}
}
#[cfg(test)]
mod tests {
use crate::{
encrypt::encryption_key_from_pass, generate_seed, nodemanager::NodeManager, MutinyWallet,
MutinyWalletConfig,
};
use bitcoin::util::bip32::ExtendedPrivKey;
use bitcoin::Network;
use crate::test_utils::*;
use crate::storage::{MemoryStorage, MutinyStorage};
use wasm_bindgen_test::{wasm_bindgen_test as test, wasm_bindgen_test_configure};
wasm_bindgen_test_configure!(run_in_browser);
#[test]
async fn create_mutiny_wallet() {
let test_name = "create_mutiny_wallet";
log!("{}", test_name);
let mnemonic = generate_seed(12).unwrap();
let xpriv = ExtendedPrivKey::new_master(Network::Regtest, &mnemonic.to_seed("")).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()));
let config = MutinyWalletConfig::new(
xpriv,
#[cfg(target_arch = "wasm32")]
None,
Network::Regtest,
None,
None,
None,
None,
None,
None,
false,
true,
);
let mw = MutinyWallet::new(storage.clone(), config, None)
.await
.expect("mutiny wallet should initialize");
mw.storage.insert_mnemonic(mnemonic).unwrap();
assert!(NodeManager::has_node_manager(storage));
}
#[test]
async fn restart_mutiny_wallet() {
let test_name = "restart_mutiny_wallet";
log!("{}", test_name);
let xpriv = ExtendedPrivKey::new_master(Network::Regtest, &[0; 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()));
let config = MutinyWalletConfig::new(
xpriv,
#[cfg(target_arch = "wasm32")]
None,
Network::Regtest,
None,
None,
None,
None,
None,
None,
false,
true,
);
let mut mw = MutinyWallet::new(storage.clone(), config, None)
.await
.expect("mutiny wallet should initialize");
let first_seed = mw.node_manager.xprivkey;
assert!(mw.stop().await.is_ok());
assert!(mw.start().await.is_ok());
assert_eq!(first_seed, mw.node_manager.xprivkey);
}
#[test]
async fn restart_mutiny_wallet_with_nodes() {
let test_name = "restart_mutiny_wallet_with_nodes";
log!("{}", test_name);
let xpriv = ExtendedPrivKey::new_master(Network::Regtest, &[0; 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()));
let config = MutinyWalletConfig::new(
xpriv,
#[cfg(target_arch = "wasm32")]
None,
Network::Regtest,
None,
None,
None,
None,
None,
None,
false,
true,
);
let mut mw = MutinyWallet::new(storage.clone(), config, None)
.await
.expect("mutiny wallet should initialize");
assert_eq!(mw.node_manager.list_nodes().await.unwrap().len(), 1);
assert!(mw.node_manager.new_node().await.is_ok());
assert_eq!(mw.node_manager.list_nodes().await.unwrap().len(), 2);
assert!(mw.stop().await.is_ok());
assert!(mw.start().await.is_ok());
assert_eq!(mw.node_manager.list_nodes().await.unwrap().len(), 2);
}
#[test]
async fn restore_mutiny_mnemonic() {
let test_name = "restore_mutiny_mnemonic";
log!("{}", test_name);
let mnemonic = generate_seed(12).unwrap();
let xpriv = ExtendedPrivKey::new_master(Network::Regtest, &mnemonic.to_seed("")).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()));
let config = MutinyWalletConfig::new(
xpriv,
#[cfg(target_arch = "wasm32")]
None,
Network::Regtest,
None,
None,
None,
None,
None,
None,
false,
true,
);
let mw = MutinyWallet::new(storage.clone(), config, None)
.await
.expect("mutiny wallet should initialize");
let seed = mw.node_manager.xprivkey;
assert!(!seed.private_key.is_empty());
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()));
let xpriv = ExtendedPrivKey::new_master(Network::Regtest, &[0; 32]).unwrap();
let mut config2 = MutinyWalletConfig::new(
xpriv,
#[cfg(target_arch = "wasm32")]
None,
Network::Regtest,
None,
None,
None,
None,
None,
None,
false,
true,
);
let mw2 = MutinyWallet::new(storage2.clone(), config2.clone(), None)
.await
.expect("mutiny wallet should initialize");
let seed2 = mw2.node_manager.xprivkey;
assert_ne!(seed, seed2);
mw2.stop().await.expect("should stop");
drop(mw2);
let pass = uuid::Uuid::new_v4().to_string();
let cipher = encryption_key_from_pass(&pass).unwrap();
let storage3 = MemoryStorage::new(Some(pass), Some(cipher), None);
MutinyWallet::restore_mnemonic(storage3.clone(), mnemonic.clone())
.await
.expect("mutiny wallet should restore");
config2.xprivkey = {
let seed = storage3.get_mnemonic().unwrap().unwrap();
ExtendedPrivKey::new_master(Network::Regtest, &seed.to_seed("")).unwrap()
};
let mw2 = MutinyWallet::new(storage3, config2, None)
.await
.expect("mutiny wallet should initialize");
let restored_seed = mw2.node_manager.xprivkey;
assert_eq!(seed, restored_seed);
}
}