From ae12078651a061d598df82f65f3c188d90987b94 Mon Sep 17 00:00:00 2001 From: benthecarman Date: Tue, 30 Jan 2024 20:50:53 +0000 Subject: [PATCH] Create index for sorted activity --- mutiny-core/src/ldkstorage.rs | 14 +- mutiny-core/src/lib.rs | 389 +++++++++++++++++++++++++++++---- mutiny-core/src/nodemanager.rs | 24 -- mutiny-core/src/onchain.rs | 43 +++- mutiny-core/src/storage.rs | 76 ++++++- mutiny-wasm/src/indexed_db.rs | 8 +- mutiny-wasm/src/lib.rs | 8 +- 7 files changed, 482 insertions(+), 80 deletions(-) diff --git a/mutiny-core/src/ldkstorage.rs b/mutiny-core/src/ldkstorage.rs index f3a9786a7..93eb2fb38 100644 --- a/mutiny-core/src/ldkstorage.rs +++ b/mutiny-core/src/ldkstorage.rs @@ -6,7 +6,7 @@ use crate::logging::MutinyLogger; use crate::node::{default_user_config, ChainMonitor}; use crate::node::{NetworkGraph, Router}; use crate::nodemanager::ChannelClosure; -use crate::storage::{MutinyStorage, VersionedValue}; +use crate::storage::{IndexItem, MutinyStorage, VersionedValue}; use crate::utils; use crate::utils::{sleep, spawn}; use crate::{chain::MutinyChain, scorer::HubPreferentialScorer}; @@ -39,7 +39,7 @@ use std::sync::Arc; pub const CHANNEL_MANAGER_KEY: &str = "manager"; pub const MONITORS_PREFIX_KEY: &str = "monitors/"; const CHANNEL_OPENING_PARAMS_PREFIX: &str = "chan_open_params/"; -const CHANNEL_CLOSURE_PREFIX: &str = "channel_closure/"; +pub const CHANNEL_CLOSURE_PREFIX: &str = "channel_closure/"; const FAILED_SPENDABLE_OUTPUT_DESCRIPTOR_KEY: &str = "failed_spendable_outputs"; pub(crate) type PhantomChannelManager = LdkChannelManager< @@ -367,7 +367,15 @@ impl MutinyNodePersister { "{CHANNEL_CLOSURE_PREFIX}{}", user_channel_id.to_be_bytes().to_hex() )); - self.storage.set_data(key, closure, None)?; + self.storage.set_data(key.clone(), &closure, None)?; + + let index = self.storage.activity_index(); + let mut index = index.try_write()?; + index.insert(IndexItem { + timestamp: Some(closure.timestamp), + key, + }); + Ok(()) } diff --git a/mutiny-core/src/lib.rs b/mutiny-core/src/lib.rs index 019f9dd12..e326eea64 100644 --- a/mutiny-core/src/lib.rs +++ b/mutiny-core/src/lib.rs @@ -44,7 +44,8 @@ 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::storage::{ - list_payment_info, MutinyStorage, DEVICE_ID_KEY, EXPECTED_NETWORK_KEY, NEED_FULL_SYNC_KEY, + list_payment_info, IndexItem, MutinyStorage, DEVICE_ID_KEY, EXPECTED_NETWORK_KEY, + NEED_FULL_SYNC_KEY, ONCHAIN_PREFIX, PAYMENT_INBOUND_PREFIX_KEY, PAYMENT_OUTBOUND_PREFIX_KEY, }; use crate::{auth::MutinyAuthClient, logging::MutinyLogger}; use crate::{error::MutinyError, nostr::ReservedProfile}; @@ -76,11 +77,11 @@ use ::nostr::{Event, EventId, JsonUtil, Kind}; use async_lock::RwLock; use bdk_chain::ConfirmationTime; use bip39::Mnemonic; -use bitcoin::hashes::hex::ToHex; +use bitcoin::hashes::hex::{FromHex, ToHex}; use bitcoin::hashes::{sha256, Hash}; use bitcoin::secp256k1::PublicKey; use bitcoin::util::bip32::ExtendedPrivKey; -use bitcoin::Network; +use bitcoin::{Network, Txid}; use fedimint_core::{api::InviteCode, config::FederationId}; use futures::{pin_mut, select, FutureExt}; use lightning::ln::PaymentHash; @@ -97,6 +98,7 @@ use std::{str::FromStr, sync::atomic::Ordering}; use uuid::Uuid; use crate::labels::LabelItem; +use crate::ldkstorage::CHANNEL_CLOSURE_PREFIX; use crate::nostr::NostrKeySource; use crate::utils::parse_profile_metadata; #[cfg(test)] @@ -756,6 +758,65 @@ impl MutinyWalletBuilder { (None, auth_manager) }; + // 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.txid), + }) + .collect::>(); + + // add the channel closures to the activity index + let closures = self + .storage + .scan::(CHANNEL_CLOSURE_PREFIX, None)? + .into_iter() + .map(|(k, v)| IndexItem { + timestamp: Some(v.timestamp), + key: k, + }) + .collect::>(); + activity_index.extend(closures); + + // add inbound invoices to the activity index + let inbound = self + .storage + .scan::(PAYMENT_INBOUND_PREFIX_KEY, None)? + .into_iter() + .filter(|(_, p)| matches!(p.status, HTLCStatus::Succeeded | HTLCStatus::InFlight)) + .map(|(k, v)| IndexItem { + timestamp: Some(v.last_update), + key: k, + }) + .collect::>(); + + let outbound = self + .storage + .scan::(PAYMENT_OUTBOUND_PREFIX_KEY, None)? + .into_iter() + .filter(|(_, p)| matches!(p.status, HTLCStatus::Succeeded | HTLCStatus::InFlight)) + .map(|(k, v)| IndexItem { + timestamp: Some(v.last_update), + key: k, + }) + .collect::>(); + + activity_index.extend(inbound); + activity_index.extend(outbound); + + // add the activity index to the storage + { + let index = self.storage.activity_index(); + let mut read = index.try_write()?; + read.extend(activity_index); + } + let mw = MutinyWallet { xprivkey: self.xprivkey, config, @@ -1164,39 +1225,96 @@ impl MutinyWallet { Ok(MutinyBalance::new(ln_balance, federation_balance)) } + fn get_invoice_internal( + &self, + key: &str, + inbound: bool, + labels_map: &HashMap>, + ) -> Result, MutinyError> { + if let Some(info) = self.storage.get_data::(key)? { + let labels = match info.bolt11.clone() { + None => vec![], + Some(i) => labels_map.get(&i).cloned().unwrap_or_default(), + }; + let prefix = match inbound { + true => PAYMENT_INBOUND_PREFIX_KEY, + false => PAYMENT_OUTBOUND_PREFIX_KEY, + }; + let payment_hash_str = key + .trim_start_matches(prefix) + .splitn(2, '_') // To support the old format that had `_{node_id}` at the end + .collect::>()[0]; + let hash: [u8; 32] = FromHex::from_hex(payment_hash_str)?; + + return MutinyInvoice::from(info, PaymentHash(hash), inbound, labels).map(Some); + }; + + Ok(None) + } + /// Get the sorted activity list for lightning payments, channels, and txs. - pub async fn get_activity(&self) -> Result, MutinyError> { - // Get activity for lightning invoices - let lightning = self - .list_invoices() - .map_err(|e| { - log_warn!(self.logger, "Failed to get lightning activity: {e}"); - e - }) - .unwrap_or_default(); + pub fn get_activity( + &self, + since: Option, + until: Option, + ) -> Result, MutinyError> { + let index = { + let index = self.storage.activity_index(); + let vec = index.try_read()?; + vec.clone() + .into_iter() + .filter(|i| { + // we use None to represent pending, so use MAX for comparison + let time = i.timestamp.unwrap_or(u64::MAX); + match (since, until) { + (Some(since), Some(until)) => time > since && time < until, + (Some(since), None) => time > since, + (None, Some(until)) => time < until, + (None, None) => true, + } + }) + .collect::>() + }; - // Get activities from node manager - let (closures, onchain) = self.node_manager.get_activity().await?; + let labels_map = self.storage.get_invoice_labels()?; - let mut activities = Vec::with_capacity(lightning.len() + onchain.len() + closures.len()); - for ln in lightning { - // Only show paid and in-flight invoices - match ln.status { - HTLCStatus::Succeeded | HTLCStatus::InFlight => { - activities.push(ActivityItem::Lightning(Box::new(ln))); + let mut activities = Vec::with_capacity(index.len()); + for item in index { + if item.key.starts_with(PAYMENT_INBOUND_PREFIX_KEY) { + if let Some(mutiny_invoice) = + self.get_invoice_internal(&item.key, true, &labels_map)? + { + activities.push(ActivityItem::Lightning(Box::new(mutiny_invoice))); + } + } else if item.key.starts_with(PAYMENT_OUTBOUND_PREFIX_KEY) { + if let Some(mutiny_invoice) = + self.get_invoice_internal(&item.key, false, &labels_map)? + { + activities.push(ActivityItem::Lightning(Box::new(mutiny_invoice))); + } + } else if item.key.starts_with(CHANNEL_CLOSURE_PREFIX) { + if let Some(mut closure) = self.storage.get_data::(&item.key)? { + if closure.user_channel_id.is_none() { + // convert keys to u128 + let user_channel_id_str = item + .key + .trim_start_matches(CHANNEL_CLOSURE_PREFIX) + .splitn(2, '_') // Channel closures have `_{node_id}` at the end + .collect::>()[0]; + let user_channel_id: [u8; 16] = FromHex::from_hex(user_channel_id_str)?; + closure.user_channel_id = Some(user_channel_id); + } + activities.push(ActivityItem::ChannelClosed(closure)); + } + } else if item.key.starts_with(ONCHAIN_PREFIX) { + // convert keys to txid + let txid_str = item.key.trim_start_matches(ONCHAIN_PREFIX); + let txid: Txid = FromHex::from_hex(txid_str)?; + if let Some(tx_details) = self.node_manager.get_transaction(txid)? { + activities.push(ActivityItem::OnChain(tx_details)); } - HTLCStatus::Pending | HTLCStatus::Failed => {} } } - for on in onchain { - activities.push(ActivityItem::OnChain(on)); - } - for chan in closures { - activities.push(ActivityItem::ChannelClosed(chan)); - } - - // Sort all activities, newest first - activities.sort_by(|a, b| b.cmp(a)); Ok(activities) } @@ -2077,21 +2195,30 @@ pub(crate) async fn create_new_federation( #[cfg(test)] mod tests { + use crate::event::{HTLCStatus, MillisatAmount, PaymentInfo}; + use crate::labels::{Contact, LabelStorage}; + use crate::ldkstorage::CHANNEL_CLOSURE_PREFIX; + use crate::nodemanager::ChannelClosure; + use crate::nostr::NostrKeySource; + use crate::storage::{ + persist_payment_info, IndexItem, MemoryStorage, MutinyStorage, ONCHAIN_PREFIX, + PAYMENT_OUTBOUND_PREFIX_KEY, + }; + use crate::test_utils::*; + use crate::utils::parse_npub; use crate::{ encrypt::encryption_key_from_pass, generate_seed, nodemanager::NodeManager, MutinyWallet, MutinyWalletBuilder, MutinyWalletConfigBuilder, }; + use bdk_chain::{BlockId, ConfirmationTime}; + use bitcoin::hashes::hex::{FromHex, ToHex}; + use bitcoin::secp256k1::PublicKey; use bitcoin::util::bip32::ExtendedPrivKey; - use bitcoin::Network; - - use crate::test_utils::*; - - use crate::labels::{Contact, LabelStorage}; - use crate::nostr::NostrKeySource; - use crate::storage::{MemoryStorage, MutinyStorage}; - use crate::utils::parse_npub; + use bitcoin::{Network, PackedLockTime, Transaction}; + use itertools::Itertools; use nostr::key::FromSkStr; use nostr::Keys; + use std::str::FromStr; use wasm_bindgen_test::{wasm_bindgen_test as test, wasm_bindgen_test_configure}; wasm_bindgen_test_configure!(run_in_browser); @@ -2387,4 +2514,190 @@ mod tests { assert_eq!(next.len(), 2); assert!(next.iter().all(|m| !messages.contains(m))) } + + #[test] + async fn test_sort_index_item() { + let storage = MemoryStorage::new(None, None, None); + let seed = generate_seed(12).expect("Failed to gen seed"); + let network = Network::Regtest; + let xpriv = ExtendedPrivKey::new_master(network, &seed.to_seed("")).unwrap(); + let c = MutinyWalletConfigBuilder::new(xpriv) + .with_network(network) + .build(); + let mw = MutinyWalletBuilder::new(xpriv, storage.clone()) + .with_config(c) + .build() + .await + .expect("mutiny wallet should initialize"); + let node = mw + .node_manager + .get_node_by_key_or_first(None) + .await + .unwrap(); + + let closure: ChannelClosure = ChannelClosure { + user_channel_id: None, + channel_id: None, + node_id: None, + reason: "".to_string(), + timestamp: 1686258926, + }; + let closure_chan_id: u128 = 6969; + node.persister + .persist_channel_closure(closure_chan_id, closure.clone()) + .unwrap(); + + let tx1 = Transaction { + version: 2, + lock_time: PackedLockTime(0), + input: vec![], + output: vec![], + }; + mw.node_manager + .wallet + .insert_tx( + tx1.clone(), + ConfirmationTime::Unconfirmed { last_seen: 0 }, + None, + ) + .await + .unwrap(); + + let tx2 = Transaction { + version: 2, + lock_time: PackedLockTime(0), + input: vec![], + output: vec![], + }; + mw.node_manager + .wallet + .insert_tx( + tx2.clone(), + ConfirmationTime::Confirmed { + height: 0, + time: 1234, + }, + Some(BlockId::default()), + ) + .await + .unwrap(); + + let pubkey = PublicKey::from_str( + "02465ed5be53d04fde66c9418ff14a5f2267723810176c9212b722e542dc1afb1b", + ) + .unwrap(); + + let payment_hash1: [u8; 32] = + FromHex::from_hex("55ecf9169a6fa07e8ba181fdddf5b0bcc7860176659fa22a7cca9da2a359a33b") + .unwrap(); + let invoice1 = PaymentInfo { + bolt11: None, + preimage: None, + payee_pubkey: Some(pubkey), + status: HTLCStatus::Succeeded, + amt_msat: MillisatAmount(Some(100 * 1_000)), + last_update: 1681781585, + secret: None, + fee_paid_msat: None, + }; + persist_payment_info(&storage, &payment_hash1, &invoice1, false).unwrap(); + + let payment_hash2: [u8; 32] = + FromHex::from_hex("661ab24752eb99fc9c90236ffe348b1f8b9da5b9c00601c711d53589d98e7919") + .unwrap(); + let invoice2 = PaymentInfo { + bolt11: None, + preimage: None, + secret: None, + payee_pubkey: Some(pubkey), + amt_msat: MillisatAmount(Some(100 * 1_000)), + last_update: 1781781585, + status: HTLCStatus::Succeeded, + fee_paid_msat: None, + }; + persist_payment_info(&storage, &payment_hash2, &invoice2, false).unwrap(); + + let payment_hash3: [u8; 32] = + FromHex::from_hex("ab98fb003849d440b49346c213bdae018468b9f2dbd731726f0aaf581fda4ad1") + .unwrap(); + let invoice3 = PaymentInfo { + bolt11: None, + preimage: None, + payee_pubkey: Some(pubkey), + amt_msat: MillisatAmount(Some(101 * 1_000)), + status: HTLCStatus::InFlight, + last_update: 1581781585, + secret: None, + fee_paid_msat: None, + }; + persist_payment_info(&storage, &payment_hash3, &invoice3, false).unwrap(); + + let payment_hash4: [u8; 32] = + FromHex::from_hex("3287bdd9c82dbb91acdffcb103b1235c74060c01b9d22b4a62184bff290e1e7e") + .unwrap(); + let invoice4 = PaymentInfo { + bolt11: None, + preimage: None, + payee_pubkey: Some(pubkey), + amt_msat: MillisatAmount(Some(102 * 1_000)), + status: HTLCStatus::InFlight, + fee_paid_msat: None, + last_update: 1581781585, + secret: None, + }; + persist_payment_info(&storage, &payment_hash4, &invoice4, false).unwrap(); + + let index = storage.activity_index(); + let vec = index.read().unwrap().clone().into_iter().collect_vec(); + + let expected = vec![ + IndexItem { + timestamp: None, + key: format!("{ONCHAIN_PREFIX}{}", tx1.txid()), + }, + IndexItem { + timestamp: None, + key: format!("{PAYMENT_OUTBOUND_PREFIX_KEY}{}", payment_hash4.to_hex()), + }, + IndexItem { + timestamp: None, + key: format!("{PAYMENT_OUTBOUND_PREFIX_KEY}{}", payment_hash3.to_hex()), + }, + IndexItem { + timestamp: Some(invoice2.last_update), + key: format!("{PAYMENT_OUTBOUND_PREFIX_KEY}{}", payment_hash2.to_hex()), + }, + IndexItem { + timestamp: Some(closure.timestamp), + key: format!( + "{CHANNEL_CLOSURE_PREFIX}{}_{}", + closure_chan_id.to_be_bytes().to_hex(), + node._uuid + ), + }, + IndexItem { + timestamp: Some(invoice1.last_update), + key: format!("{PAYMENT_OUTBOUND_PREFIX_KEY}{}", payment_hash1.to_hex()), + }, + IndexItem { + timestamp: Some(1234), + key: format!("{ONCHAIN_PREFIX}{}", tx2.txid()), + }, + ]; + + assert_eq!(vec.len(), expected.len()); // make sure im not dumb + assert_eq!(vec, expected); + + let activity = mw.get_activity(None, None).unwrap(); + assert_eq!(activity.len(), expected.len()); + + let activity = mw.get_activity(None, Some(0)).unwrap(); + assert!(activity.is_empty()); + + let activity = mw.get_activity(Some(invoice1.last_update), None).unwrap(); + assert_eq!(activity.len(), 5); + + let activity = mw.get_activity(None, Some(invoice1.last_update)).unwrap(); + assert_eq!(activity.len(), 1); + } } diff --git a/mutiny-core/src/nodemanager.rs b/mutiny-core/src/nodemanager.rs index e0a15ccb1..83edcd270 100644 --- a/mutiny-core/src/nodemanager.rs +++ b/mutiny-core/src/nodemanager.rs @@ -917,30 +917,6 @@ impl NodeManager { Ok(details_opt.map(|(d, _)| d)) } - /// Returns all the on-chain and lightning activity from the wallet. - pub(crate) async fn get_activity( - &self, - ) -> Result<(Vec, Vec), MutinyError> { - // todo add contacts to the activity - let closures = self - .list_channel_closures() - .await - .map_err(|e| { - log_warn!(self.logger, "Failed to get channel closures: {e}"); - e - }) - .unwrap_or_default(); - let onchain = self - .list_onchain() - .map_err(|e| { - log_warn!(self.logger, "Failed to get bdk history: {e}"); - e - }) - .unwrap_or_default(); - - Ok((closures, onchain)) - } - /// Returns all the on-chain and lightning activity for a given label pub async fn get_label_activity( &self, diff --git a/mutiny-core/src/onchain.rs b/mutiny-core/src/onchain.rs index 4b3a5b7da..180a71801 100644 --- a/mutiny-core/src/onchain.rs +++ b/mutiny-core/src/onchain.rs @@ -24,7 +24,7 @@ use crate::error::MutinyError; use crate::fees::MutinyFeeEstimator; use crate::labels::*; use crate::logging::MutinyLogger; -use crate::storage::{MutinyStorage, OnChainStorage}; +use crate::storage::{IndexItem, MutinyStorage, OnChainStorage, ONCHAIN_PREFIX}; use crate::utils::{now, sleep}; pub(crate) const DEFAULT_STOP_GAP: usize = 10; @@ -111,6 +111,26 @@ impl OnChainWallet { if changed { wallet.commit()?; } + drop(wallet); // drop so we can read from wallet + + // update the activity index, just get the list of transactions + // and insert them into the index, this is done in background so shouldn't + // block the wallet update + let index_items = self + .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.txid), + }) + .collect::>(); + + let index = self.storage.activity_index(); + let mut index = index.try_write()?; + index.extend(index_items); Ok(true) } @@ -244,6 +264,7 @@ impl OnChainWallet { position: ConfirmationTime, block_id: Option, ) -> Result<(), MutinyError> { + let txid = tx.txid(); match position { ConfirmationTime::Confirmed { .. } => { // if the transaction is confirmed and we have the block id, @@ -256,7 +277,9 @@ impl OnChainWallet { // if the transaction is confirmed and we don't have the block id, // we should just sync the wallet otherwise we can get an error // with the wallet being behind the blockchain - self.sync().await? + self.sync().await?; + + return Ok(()); } } ConfirmationTime::Unconfirmed { .. } => { @@ -264,20 +287,30 @@ impl OnChainWallet { let mut wallet = self.wallet.try_write()?; // if we already have the transaction, we don't need to insert it - if wallet.get_tx(tx.txid(), false).is_none() { + if wallet.get_tx(txid, false).is_none() { // insert tx and commit changes wallet.insert_tx(tx, position)?; wallet.commit()?; } else { log_debug!( self.logger, - "Tried to insert already existing transaction ({})", - tx.txid() + "Tried to insert already existing transaction ({txid})", ) } } } + // update activity index + let index = self.storage.activity_index(); + let mut index = index.try_write()?; + index.insert(IndexItem { + timestamp: match position { + ConfirmationTime::Confirmed { time, .. } => Some(time), + ConfirmationTime::Unconfirmed { .. } => None, + }, + key: format!("{ONCHAIN_PREFIX}{txid}"), + }); + Ok(()) } diff --git a/mutiny-core/src/storage.rs b/mutiny-core/src/storage.rs index 1ad82bf82..fd3d918cd 100644 --- a/mutiny-core/src/storage.rs +++ b/mutiny-core/src/storage.rs @@ -1,3 +1,4 @@ +use crate::event::HTLCStatus; use crate::ldkstorage::CHANNEL_MANAGER_KEY; use crate::logging::MutinyLogger; use crate::nodemanager::{NodeStorage, DEVICE_LOCK_INTERVAL_SECS}; @@ -21,7 +22,7 @@ use lightning::{ln::PaymentHash, util::logger::Logger}; use lightning::{log_error, log_trace}; use serde::{Deserialize, Serialize}; use serde_json::Value; -use std::collections::HashMap; +use std::collections::{BTreeSet, HashMap}; use std::sync::{Arc, RwLock}; use uuid::Uuid; @@ -37,8 +38,9 @@ pub const LAST_NWC_SYNC_TIME_KEY: &str = "last_nwc_sync_time"; pub(crate) const DEVICE_ID_KEY: &str = "device_id"; 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 PAYMENT_INBOUND_PREFIX_KEY: &str = "payment_inbound/"; +pub const PAYMENT_OUTBOUND_PREFIX_KEY: &str = "payment_outbound/"; +pub(crate) const ONCHAIN_PREFIX: &str = "onchain_tx/"; pub const LAST_DM_SYNC_TIME_KEY: &str = "last_dm_sync_time"; fn needs_encryption(key: &str) -> bool { @@ -85,6 +87,29 @@ pub fn decrypt_value( Ok(json) } +#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)] +pub struct IndexItem { + pub timestamp: Option, + pub key: String, +} + +impl PartialOrd for IndexItem { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) + } +} + +impl Ord for IndexItem { + fn cmp(&self, other: &Self) -> std::cmp::Ordering { + match (self.timestamp, other.timestamp) { + (Some(a), Some(b)) => b.cmp(&a).then_with(|| self.key.cmp(&other.key)), + (Some(_), None) => std::cmp::Ordering::Greater, + (None, Some(_)) => std::cmp::Ordering::Less, + (None, None) => self.key.cmp(&other.key), + } + } +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct VersionedValue { pub version: u32, @@ -130,6 +155,10 @@ pub trait MutinyStorage: Clone + Sized + Send + Sync + 'static { /// Get the VSS client used for storage fn vss_client(&self) -> Option>; + /// An index of the activity in the storage, this should be a list of (timestamp, key) tuples + /// This is used to for getting a sorted list of keys quickly + fn activity_index(&self) -> Arc>>; + /// Set a value in the storage, the value will already be encrypted if needed fn set(&self, items: Vec<(String, impl Serialize)>) -> Result<(), MutinyError>; @@ -477,6 +506,7 @@ pub struct MemoryStorage { pub cipher: Option, pub memory: Arc>>, pub vss_client: Option>, + pub activity_index: Arc>>, } impl MemoryStorage { @@ -490,6 +520,7 @@ impl MemoryStorage { password, memory: Arc::new(RwLock::new(HashMap::new())), vss_client, + activity_index: Arc::new(RwLock::new(BTreeSet::new())), } } @@ -533,6 +564,10 @@ impl MutinyStorage for MemoryStorage { self.vss_client.clone() } + fn activity_index(&self) -> Arc>> { + self.activity_index.clone() + } + fn set(&self, items: Vec<(String, impl Serialize)>) -> Result<(), MutinyError> { for (key, value) in items { let data = serde_json::to_value(value).map_err(|e| MutinyError::PersistenceFailed { @@ -643,6 +678,10 @@ impl MutinyStorage for () { None } + fn activity_index(&self) -> Arc>> { + Arc::new(RwLock::new(BTreeSet::new())) + } + fn set(&self, _: Vec<(String, impl Serialize)>) -> Result<(), MutinyError> { Ok(()) } @@ -714,11 +753,34 @@ pub(crate) fn persist_payment_info( payment_hash: &[u8; 32], payment_info: &PaymentInfo, inbound: bool, -) -> std::io::Result<()> { +) -> Result<(), MutinyError> { let key = payment_key(inbound, payment_hash); - storage - .set_data(key, payment_info, None) - .map_err(std::io::Error::other) + storage.set_data(key.clone(), payment_info, None)?; + + // insert into activity index + match payment_info.status { + HTLCStatus::InFlight | HTLCStatus::Succeeded => { + let index = storage.activity_index(); + let mut index = index.try_write()?; + let timestamp = if payment_info.status == HTLCStatus::InFlight { + None + } else { + Some(payment_info.last_update) + }; + index.insert(IndexItem { timestamp, key }); + } + HTLCStatus::Failed => { + let index = storage.activity_index(); + let mut index = index.try_write()?; + index.remove(&IndexItem { + timestamp: None, // timestamp would be None for InFlight + key, + }); + } + HTLCStatus::Pending => {} // don't add to index until invoice is paid + } + + Ok(()) } pub(crate) fn get_payment_info( diff --git a/mutiny-wasm/src/indexed_db.rs b/mutiny-wasm/src/indexed_db.rs index b9f1208e4..5aa72e3b0 100644 --- a/mutiny-wasm/src/indexed_db.rs +++ b/mutiny-wasm/src/indexed_db.rs @@ -17,7 +17,7 @@ use mutiny_core::{federation::FEDIMINTS_PREFIX_KEY, nodemanager::NodeStorage}; use rexie::{ObjectStore, Rexie, TransactionMode}; use serde::{Deserialize, Serialize}; use serde_json::Value; -use std::collections::HashMap; +use std::collections::{BTreeSet, HashMap}; use std::sync::{Arc, RwLock}; use wasm_bindgen::JsValue; use wasm_bindgen_futures::spawn_local; @@ -43,6 +43,7 @@ pub struct IndexedDbStorage { pub(crate) indexed_db: Arc>, vss: Option>, logger: Arc, + activity_index: Arc>>, } impl IndexedDbStorage { @@ -73,6 +74,7 @@ impl IndexedDbStorage { indexed_db, vss, logger, + activity_index: Arc::new(RwLock::new(BTreeSet::new())), }) } @@ -539,6 +541,10 @@ impl MutinyStorage for IndexedDbStorage { self.vss.clone() } + fn activity_index(&self) -> Arc>> { + self.activity_index.clone() + } + fn set(&self, items: Vec<(String, impl Serialize)>) -> Result<(), MutinyError> { let items = items .into_iter() diff --git a/mutiny-wasm/src/lib.rs b/mutiny-wasm/src/lib.rs index fd34a152b..ae831276d 100644 --- a/mutiny-wasm/src/lib.rs +++ b/mutiny-wasm/src/lib.rs @@ -1019,9 +1019,13 @@ impl MutinyWallet { /// Returns all the on-chain and lightning activity from the wallet. #[wasm_bindgen] - pub async fn get_activity(&self) -> Result */, MutinyJsError> { + pub async fn get_activity( + &self, + since: Option, + until: Option, + ) -> Result */, MutinyJsError> { // get activity from the node manager - let activity = self.inner.get_activity().await?; + let activity = self.inner.get_activity(since, until)?; let mut activity: Vec = activity.into_iter().map(|a| a.into()).collect(); // add contacts to the activity