diff --git a/mutiny-core/src/federation.rs b/mutiny-core/src/federation.rs index fb4be88ea..662a8917b 100644 --- a/mutiny-core/src/federation.rs +++ b/mutiny-core/src/federation.rs @@ -1,22 +1,29 @@ -use crate::utils::{convert_from_fedimint_invoice, convert_to_fedimint_invoice, now, spawn}; use crate::{ error::{MutinyError, MutinyStorageError}, event::PaymentInfo, key::{create_root_child_key, ChildKey}, logging::MutinyLogger, onchain::coin_type_from_network, - storage::{list_payment_info, persist_payment_info, MutinyStorage, VersionedValue}, + storage::{ + get_transaction_details, list_payment_info, persist_payment_info, + persist_transaction_details, MutinyStorage, VersionedValue, TRANSACTION_DETAILS_PREFIX_KEY, + }, utils::sleep, HTLCStatus, MutinyInvoice, DEFAULT_PAYMENT_TIMEOUT, }; +use crate::{ + utils::{convert_from_fedimint_invoice, convert_to_fedimint_invoice, now, spawn}, + TransactionDetails, +}; use async_lock::RwLock; use async_trait::async_trait; +use bdk_chain::ConfirmationTime; use bip39::Mnemonic; -use bitcoin::secp256k1::{SecretKey, ThirtyTwoByteHash}; use bitcoin::{ bip32::{ChildNumber, DerivationPath, ExtendedPrivKey}, - secp256k1::Secp256k1, - Network, + hashes::Hash, + secp256k1::{Secp256k1, SecretKey, ThirtyTwoByteHash}, + Address, Network, Txid, }; use core::fmt; use fedimint_bip39::Bip39RootSecretStrategy; @@ -50,13 +57,16 @@ use fedimint_ln_client::{ use fedimint_ln_common::lightning_invoice::{Bolt11InvoiceDescription, Description, RoutingFees}; use fedimint_ln_common::{LightningCommonInit, LightningGateway}; use fedimint_mint_client::MintClientInit; -use fedimint_wallet_client::{WalletClientInit, WalletClientModule}; +use fedimint_wallet_client::{ + WalletClientInit, WalletClientModule, WalletCommonInit, WalletOperationMeta, WithdrawState, +}; use futures::{select, FutureExt}; use futures_util::{pin_mut, StreamExt}; use hex_conservative::{DisplayHex, FromHex}; use lightning::{log_debug, log_error, log_info, log_trace, log_warn, util::logger::Logger}; use lightning_invoice::Bolt11Invoice; use serde::{de::DeserializeOwned, Deserialize, Serialize}; +use std::time::Duration; #[cfg(not(target_arch = "wasm32"))] use std::time::Instant; use std::{ @@ -76,6 +86,9 @@ use web_time::Instant; // their internal list. const FEDIMINT_OPERATIONS_LIST_MAX: usize = 100; +// On chain peg in timeout +const PEG_IN_TIMEOUT_YEAR: Duration = Duration::from_secs(86400 * 365); + pub const FEDIMINTS_PREFIX_KEY: &str = "fedimints/"; // Default signet/mainnet federation gateway info @@ -344,6 +357,18 @@ impl FederationClient { .map(|(h, _i)| h.0), ); + // pending on chain operations + let pending_wallet_txids = self + .storage + .scan::(TRANSACTION_DETAILS_PREFIX_KEY, None)? + .into_iter() + .filter(|(_k, v)| match v.confirmation_time { + ConfirmationTime::Unconfirmed { .. } => true, // return all unconfirmed transactions + _ => false, // skip confirmed transactions + }) + .map(|(_h, i)| i.internal_id) + .collect::>(); + // go through last 100 operations let operations = self .fedimint_client @@ -353,51 +378,45 @@ impl FederationClient { // find all of the pending ones for (key, entry) in operations { - if entry.operation_module_kind() == LightningCommonInit::KIND.as_str() { + let module_type = entry.operation_module_kind(); + if module_type == LightningCommonInit::KIND.as_str() { let lightning_meta: LightningOperationMeta = entry.meta(); match lightning_meta.variant { LightningOperationMetaVariant::Pay(pay_meta) => { let hash = pay_meta.invoice.payment_hash().into_inner(); if pending_invoices.contains(&hash) { - self.subscribe_operation( - entry, - hash, - key.operation_id, - self.fedimint_client.clone(), - ); + self.subscribe_operation(entry, key.operation_id); } } LightningOperationMetaVariant::Receive { invoice, .. } => { let hash = invoice.payment_hash().into_inner(); if pending_invoices.contains(&hash) { - self.subscribe_operation( - entry, - hash, - key.operation_id, - self.fedimint_client.clone(), - ); + self.subscribe_operation(entry, key.operation_id); } } LightningOperationMetaVariant::Claim { .. } => {} } + } else if module_type == WalletCommonInit::KIND.as_str() { + let internal_id = Txid::from_slice(&key.operation_id.0) + .map_err(|_| MutinyError::ChainAccessFailed) + .expect("should convert"); + + if pending_wallet_txids.contains(&internal_id) { + self.subscribe_operation(entry, key.operation_id); + } + } else { + log_warn!(self.logger, "Unknown module type: {module_type}") } } Ok(()) } - fn subscribe_operation( - &self, - entry: OperationLogEntry, - hash: [u8; 32], - operation_id: OperationId, - fedimint_client: ClientHandleArc, - ) { + fn subscribe_operation(&self, entry: OperationLogEntry, operation_id: OperationId) { subscribe_operation_ext( entry, - hash, operation_id, - fedimint_client, + self.fedimint_client.clone(), self.logger.clone(), self.stop.clone(), self.storage.clone(), @@ -450,44 +469,69 @@ impl FederationClient { let storage_clone = self.storage.clone(); let stop = self.stop.clone(); spawn(async move { - let lightning_module = - Arc::new(fedimint_client_clone.get_first_module::()); - - let operations = fedimint_client_clone + let operation = fedimint_client_clone .operation_log() .get_operation(id) .await .expect("just created it"); - if let Some(updated_invoice) = process_operation_until_timeout( - logger_clone.clone(), - operations.meta(), - hash, + subscribe_operation_ext( + operation, id, - &lightning_module, - None, + fedimint_client_clone, + logger_clone, stop, - ) - .await - { - match maybe_update_after_checking_fedimint( - updated_invoice.clone(), - logger_clone.clone(), - storage_clone, - ) { - Ok(_) => { - log_info!(logger_clone, "updated invoice"); - } - Err(e) => { - log_error!(logger_clone, "could not check update invoice: {e}"); - } - } - } + storage_clone, + ); }); Ok(invoice.into()) } + pub(crate) async fn get_new_address( + &self, + labels: Vec, + ) -> Result { + let wallet_module = self + .fedimint_client + .get_first_module::(); + + let (op_id, address) = wallet_module + .get_deposit_address(fedimint_core::time::now() + PEG_IN_TIMEOUT_YEAR, ()) + .await?; + + let internal_id = Txid::from_slice(&op_id.0).map_err(|_| MutinyError::ChainAccessFailed)?; + + // persist the data we can while we wait for the transaction to come from fedimint + let pending_transaction_details = TransactionDetails { + transaction: None, + txid: None, + internal_id, + received: 0, + sent: 0, + fee: None, + confirmation_time: ConfirmationTime::Unconfirmed { + last_seen: now().as_secs(), + }, + labels, + }; + + persist_transaction_details(&self.storage, &pending_transaction_details)?; + + // subscribe + let operation = self + .fedimint_client + .operation_log() + .get_operation(op_id) + .await + .expect("just created it"); + self.subscribe_operation(operation, op_id); + + Ok(Address::from_str(&address.to_string()) + .expect("should convert") + .assume_checked()) + } + /// Get the balance of this federation client in sats pub(crate) async fn get_balance(&self) -> Result { Ok(self.fedimint_client.get_balance().await.msats / 1_000) @@ -536,7 +580,7 @@ impl FederationClient { fedimint_ln_client::PayType::Internal(pay_id) => { match lightning_module.subscribe_internal_pay(pay_id).await { Ok(o) => { - let o = process_outcome( + let o = process_ln_outcome( o, process_pay_state_internal, invoice.clone(), @@ -554,7 +598,7 @@ impl FederationClient { fedimint_ln_client::PayType::Lightning(pay_id) => { match lightning_module.subscribe_ln_pay(pay_id).await { Ok(o) => { - let o = process_outcome( + let o = process_ln_outcome( o, process_pay_state_ln, invoice.clone(), @@ -592,7 +636,6 @@ impl FederationClient { subscribe_operation_ext( operation, - hash, id, fedimint_client_clone, logger_clone, @@ -681,42 +724,23 @@ impl FederationClient { fn subscribe_operation_ext( entry: OperationLogEntry, - hash: [u8; 32], operation_id: OperationId, fedimint_client: ClientHandleArc, logger: Arc, stop: Arc, storage: S, ) { - let lightning_meta: LightningOperationMeta = entry.meta(); spawn(async move { - let lightning_module = - Arc::new(fedimint_client.get_first_module::()); - - if let Some(updated_invoice) = process_operation_until_timeout( + process_operation_until_timeout( logger.clone(), - lightning_meta, - hash, + entry, operation_id, - &lightning_module, + fedimint_client, + storage, None, stop, ) - .await - { - match maybe_update_after_checking_fedimint( - updated_invoice.clone(), - logger.clone(), - storage, - ) { - Ok(_) => { - log_debug!(logger, "subscribed and updated federation operation") - } - Err(e) => { - log_error!(logger, "could not update federation operation: {e}") - } - } - } + .await; }); } @@ -827,71 +851,167 @@ pub(crate) fn mnemonic_from_xpriv(xpriv: ExtendedPrivKey) -> Result( logger: Arc, - lightning_meta: LightningOperationMeta, - hash: [u8; 32], + entry: OperationLogEntry, operation_id: OperationId, - lightning_module: &Arc>, + fedimint_client: ClientHandleArc, + storage: S, timeout: Option, stop: Arc, -) -> Option { - match lightning_meta.variant { - LightningOperationMetaVariant::Pay(pay_meta) => { - let invoice = convert_from_fedimint_invoice(&pay_meta.invoice); - if invoice.payment_hash().into_32() == hash { - match lightning_module.subscribe_ln_pay(operation_id).await { - Ok(o) => Some( - process_outcome( - o, - process_pay_state_ln, - invoice, - false, - timeout, - stop, - logger, - ) - .await, - ), - Err(e) => { - log_error!(logger, "Error trying to process stream outcome: {e}"); +) { + let module_type = entry.operation_module_kind(); + if module_type == LightningCommonInit::KIND.as_str() { + let lightning_meta: LightningOperationMeta = entry.meta(); + + let lightning_module = + Arc::new(fedimint_client.get_first_module::()); - // return the latest status of the invoice even if it fails - Some(invoice.into()) + let updated_invoice = match lightning_meta.variant { + LightningOperationMetaVariant::Pay(pay_meta) => { + let hash = pay_meta.invoice.payment_hash().into_inner(); + let invoice = convert_from_fedimint_invoice(&pay_meta.invoice); + if invoice.payment_hash().into_32() == hash { + match lightning_module.subscribe_ln_pay(operation_id).await { + Ok(o) => Some( + process_ln_outcome( + o, + process_pay_state_ln, + invoice, + false, + timeout, + stop, + logger.clone(), + ) + .await, + ), + Err(e) => { + log_error!(logger, "Error trying to process stream outcome: {e}"); + + // return the latest status of the invoice even if it fails + Some(invoice.into()) + } } + } else { + None + } + } + LightningOperationMetaVariant::Receive { invoice, .. } => { + let hash = invoice.payment_hash().into_inner(); + let invoice = convert_from_fedimint_invoice(&invoice); + if invoice.payment_hash().into_32() == hash { + match lightning_module.subscribe_ln_receive(operation_id).await { + Ok(o) => Some( + process_ln_outcome( + o, + process_receive_state, + invoice, + true, + timeout, + stop, + logger.clone(), + ) + .await, + ), + Err(e) => { + log_error!(logger, "Error trying to process stream outcome: {e}"); + + // return the latest status of the invoice even if it fails + Some(invoice.into()) + } + } + } else { + None + } + } + LightningOperationMetaVariant::Claim { .. } => None, + }; + + if let Some(updated_invoice) = updated_invoice { + match maybe_update_after_checking_fedimint( + updated_invoice.clone(), + logger.clone(), + storage, + ) { + Ok(_) => { + log_debug!(logger, "subscribed and updated federation operation") + } + Err(e) => { + log_error!(logger, "could not update federation operation: {e}") } - } else { - None } } - LightningOperationMetaVariant::Receive { invoice, .. } => { - let invoice = convert_from_fedimint_invoice(&invoice); - if invoice.payment_hash().into_32() == hash { - match lightning_module.subscribe_ln_receive(operation_id).await { - Ok(o) => Some( - process_outcome( + } else if module_type == WalletCommonInit::KIND.as_str() { + let wallet_meta: WalletOperationMeta = entry.meta(); + let wallet_module = Arc::new(fedimint_client.get_first_module::()); + let internal_id = Txid::from_slice(&operation_id.0) + .map_err(|_| MutinyError::ChainAccessFailed) + .expect("should convert"); + let stored_transaction_details = get_transaction_details(&storage, internal_id, &logger); + if stored_transaction_details.is_none() { + log_warn!(logger, "could not find transaction details: {internal_id}") + } + + match wallet_meta.variant { + fedimint_wallet_client::WalletOperationMetaVariant::Deposit { + address: _, + expires_at: _, + } => { + match wallet_module.subscribe_deposit_updates(operation_id).await { + Ok(o) => { + process_onchain_deposit_outcome( o, - process_receive_state, - invoice, - true, + stored_transaction_details, + operation_id, + storage, timeout, stop, logger, ) - .await, - ), + .await + } Err(e) => { log_error!(logger, "Error trying to process stream outcome: {e}"); - - // return the latest status of the invoice even if it fails - Some(invoice.into()) + } + }; + } + fedimint_wallet_client::WalletOperationMetaVariant::Withdraw { + address: _, + amount: _, + fee: _, + change: _, + } => { + // TODO + let mut updates = wallet_module + .subscribe_withdraw_updates(operation_id) + .await + .expect("should stream") + .into_stream(); // TODO non-stream version + + while let Some(update) = updates.next().await { + match update { + WithdrawState::Succeeded(txid) => { + log_info!(logger, "Withdraw successful, txid: {txid}"); + // TODO update state + } + WithdrawState::Failed(e) => { + log_error!(logger, "Withdraw failed: {e}"); + // TODO update state + } + WithdrawState::Created => { + log_debug!(logger, "Withdraw created"); + // TODO update state + } } } - } else { - None + } + fedimint_wallet_client::WalletOperationMetaVariant::RbfWithdraw { .. } => { + // not supported yet + unimplemented!("User RBF withdrawals not supported yet") } } - LightningOperationMetaVariant::Claim { .. } => None, + } else { + log_warn!(logger, "Unknown module type: {module_type}") } } @@ -919,7 +1039,7 @@ fn process_receive_state(receive_state: LnReceiveState, invoice: &mut MutinyInvo invoice.status = receive_state.into(); } -async fn process_outcome( +async fn process_ln_outcome( stream_or_outcome: UpdateStreamOrOutcome, process_fn: F, invoice: Bolt11Invoice, @@ -1000,6 +1120,135 @@ where invoice } +async fn process_onchain_deposit_outcome( + stream_or_outcome: UpdateStreamOrOutcome, + original_transaction_details: Option, + operation_id: OperationId, + storage: S, + timeout: Option, + stop: Arc, + logger: Arc, +) { + let labels = original_transaction_details + .as_ref() + .map(|o| o.labels.clone()) + .unwrap_or(Vec::new()); + + match stream_or_outcome { + UpdateStreamOrOutcome::Outcome(outcome) => { + // TODO + log_trace!(logger, "Outcome received: {:?}", outcome); + } + UpdateStreamOrOutcome::UpdateStream(mut s) => { + // break out after sleep time or check stop signal + log_trace!(logger, "start timeout stream futures"); + loop { + let timeout_future = if let Some(t) = timeout { + sleep(t as i32) + } else { + sleep(1_000_i32) + }; + + let mut stream_fut = Box::pin(s.next()).fuse(); + let delay_fut = Box::pin(timeout_future).fuse(); + pin_mut!(delay_fut); + + select! { + outcome_option = stream_fut => { + if let Some(outcome) = outcome_option { + // TODO refactor outcome parsing into seperate method + match outcome { + fedimint_wallet_client::DepositState::WaitingForTransaction => { + // Nothing to do + log_debug!(logger, "Waiting for transaction"); + } + fedimint_wallet_client::DepositState::WaitingForConfirmation(tx) => { + // Pending state, update with info we have + log_debug!(logger, "Waiting for confirmation"); + let txid = Txid::from_slice(&tx.btc_transaction.txid()).expect("should convert"); + let internal_id = Txid::from_slice(&operation_id.0).expect("should convert"); + let output = tx.btc_transaction.output[tx.out_idx as usize].clone(); + + let updated_transaction_details = TransactionDetails { + transaction: None, + txid: Some(txid), + internal_id, + received: output.value, + sent: 0, + fee: None, + confirmation_time: ConfirmationTime::Unconfirmed { last_seen: now().as_secs() }, + labels: labels.clone(), + }; + + match persist_transaction_details(&storage, &updated_transaction_details) { + Ok(_) => { + log_info!(logger, "Transaction updated"); + }, + Err(e) => { + log_error!(logger, "Error updating transaction: {e}"); + }, + } + } + fedimint_wallet_client::DepositState::Confirmed(tx) => { + // Pending state, update with info we have + log_debug!(logger, "Transaction confirmed"); + let txid = Txid::from_slice(&tx.btc_transaction.txid()).expect("should convert"); + let internal_id = Txid::from_slice(&operation_id.0).expect("should convert"); + let output = tx.btc_transaction.output[tx.out_idx as usize].clone(); + + let updated_transaction_details = TransactionDetails { + transaction: None, + txid: Some(txid), + internal_id, + received: output.value, + sent: 0, + fee: None, + confirmation_time: ConfirmationTime::Confirmed { height: 0, time: now().as_secs() }, // FIXME: can't figure this out + labels: labels.clone(), + }; + + match persist_transaction_details(&storage, &updated_transaction_details) { + Ok(_) => { + log_info!(logger, "Transaction updated"); + }, + Err(e) => { + log_error!(logger, "Error updating transaction: {e}"); + }, + } + } + fedimint_wallet_client::DepositState::Claimed(_) => { + // Nothing really to change from confirmed to claimed + log_debug!(logger, "Transaction claimed"); + break; + } + fedimint_wallet_client::DepositState::Failed(e) => { + // TODO delete + log_error!(logger, "Transaction failed: {e}"); + break; + } + } + } + } + _ = delay_fut => { + if timeout.is_none() { + if stop.load(Ordering::Relaxed) { + break; + } + } else { + log_debug!( + logger, + "Timeout reached, exiting loop for on chain tx", + ); + break; + } + } + } + } + log_trace!(logger, "Done with stream outcome",); + } + } +} + #[derive(Clone)] pub struct FedimintStorage { pub(crate) storage: S, diff --git a/mutiny-core/src/lib.rs b/mutiny-core/src/lib.rs index 54155f6b5..a9dd3df66 100644 --- a/mutiny-core/src/lib.rs +++ b/mutiny-core/src/lib.rs @@ -47,10 +47,10 @@ 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_CLOSURE_PREFIX, CHANNEL_MANAGER_KEY, MONITORS_PREFIX_KEY}; use crate::storage::{ - get_payment_hash_from_key, list_payment_info, persist_payment_info, update_nostr_contact_list, - IndexItem, MutinyStorage, DEVICE_ID_KEY, EXPECTED_NETWORK_KEY, NEED_FULL_SYNC_KEY, - ONCHAIN_PREFIX, PAYMENT_INBOUND_PREFIX_KEY, PAYMENT_OUTBOUND_PREFIX_KEY, - SUBSCRIPTION_TIMESTAMP, + get_payment_hash_from_key, get_transaction_details, list_payment_info, persist_payment_info, + update_nostr_contact_list, IndexItem, MutinyStorage, DEVICE_ID_KEY, EXPECTED_NETWORK_KEY, + NEED_FULL_SYNC_KEY, ONCHAIN_PREFIX, PAYMENT_INBOUND_PREFIX_KEY, PAYMENT_OUTBOUND_PREFIX_KEY, + SUBSCRIPTION_TIMESTAMP, TRANSACTION_DETAILS_PREFIX_KEY, }; use crate::utils::spawn; use crate::{auth::MutinyAuthClient, hermes::HermesClient, logging::MutinyLogger}; @@ -69,7 +69,7 @@ use crate::{ }; use crate::{ lnurlauth::make_lnurl_auth_connection, - nodemanager::{ChannelClosure, MutinyBip21RawMaterials, TransactionDetails}, + nodemanager::{ChannelClosure, MutinyBip21RawMaterials}, }; use crate::{lnurlauth::AuthManager, nostr::MUTINY_PLUS_SUBSCRIPTION_LABEL}; use crate::{logging::LOGGING_KEY, nodemanager::NodeManagerBuilder}; @@ -94,9 +94,9 @@ use ::nostr::{EventBuilder, EventId, JsonUtil, Keys, Kind}; use async_lock::RwLock; use bdk_chain::ConfirmationTime; use bip39::Mnemonic; -use bitcoin::bip32::ExtendedPrivKey; use bitcoin::hashes::Hash; use bitcoin::secp256k1::{PublicKey, ThirtyTwoByteHash}; +use bitcoin::{bip32::ExtendedPrivKey, Transaction}; use bitcoin::{hashes::sha256, Network, Txid}; use fedimint_core::{api::InviteCode, config::FederationId}; use futures::{pin_mut, select, FutureExt}; @@ -217,6 +217,57 @@ pub enum ActivityItem { ChannelClosed(ChannelClosure), } +/// A wallet transaction +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)] +pub struct TransactionDetails { + /// Optional transaction + pub transaction: Option, + /// Transaction id + pub txid: Option, + /// Internal id before a transaction id is created + pub internal_id: Txid, + /// Received value (sats) + /// Sum of owned outputs of this transaction. + pub received: u64, + /// Sent value (sats) + /// Sum of owned inputs of this transaction. + pub sent: u64, + /// Fee value in sats if it was available. + pub fee: Option, + /// If the transaction is confirmed, contains height and Unix timestamp of the block containing the + /// transaction, unconfirmed transaction contains `None`. + pub confirmation_time: ConfirmationTime, + /// Labels associated with this transaction + pub labels: Vec, +} + +impl PartialOrd for TransactionDetails { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) + } +} + +impl Ord for TransactionDetails { + fn cmp(&self, other: &Self) -> core::cmp::Ordering { + match (self.confirmation_time, other.confirmation_time) { + (ConfirmationTime::Confirmed { .. }, ConfirmationTime::Confirmed { .. }) => self + .confirmation_time + .cmp(&self.confirmation_time) + .then_with(|| self.txid.cmp(&other.txid)), + (ConfirmationTime::Confirmed { .. }, ConfirmationTime::Unconfirmed { .. }) => { + core::cmp::Ordering::Less + } + (ConfirmationTime::Unconfirmed { .. }, ConfirmationTime::Confirmed { .. }) => { + core::cmp::Ordering::Greater + } + ( + ConfirmationTime::Unconfirmed { last_seen: a }, + ConfirmationTime::Unconfirmed { last_seen: b }, + ) => a.cmp(&b).then_with(|| self.txid.cmp(&other.txid)), + } + } +} + impl ActivityItem { pub fn last_updated(&self) -> Option { match self { @@ -1027,9 +1078,24 @@ impl MutinyWalletBuilder { ConfirmationTime::Confirmed { time, .. } => Some(time), ConfirmationTime::Unconfirmed { .. } => None, }, - key: format!("{ONCHAIN_PREFIX}{}", t.txid), + key: format!("{ONCHAIN_PREFIX}{}", t.internal_id), + }) + .collect::>(); + + // add any transaction details stored from fedimint + let transaction_details = self + .storage + .scan::(TRANSACTION_DETAILS_PREFIX_KEY, None)? + .into_iter() + .map(|(k, v)| { + let timestamp = match v.confirmation_time { + ConfirmationTime::Confirmed { height: _, time } => Some(time), // confirmed timestamp + ConfirmationTime::Unconfirmed { .. } => None, // unconfirmed timestamp + }; + IndexItem { timestamp, key: k } }) .collect::>(); + activity_index.extend(transaction_details); // add the channel closures to the activity index let closures = self @@ -1572,7 +1638,7 @@ impl MutinyWallet { ) }; - let Ok(address) = self.node_manager.get_new_address(labels.clone()) else { + let Ok(address) = self.create_address(labels.clone()).await else { return Err(MutinyError::WalletOperationFailed); }; @@ -1735,6 +1801,29 @@ impl MutinyWallet { Ok(Some(lsp_fee + federation_fee)) } + async fn create_address(&self, labels: Vec) -> Result { + // Attempt to create federation invoice if available + let federation_ids = self.list_federation_ids().await?; + if !federation_ids.is_empty() { + let federation_id = &federation_ids[0]; + let fedimint_client = self.federations.read().await.get(federation_id).cloned(); + + if let Some(client) = fedimint_client { + if let Ok(addr) = client.get_new_address(labels.clone()).await { + self.storage.set_address_labels(addr.clone(), labels)?; + return Ok(addr); + } + } + } + + // Fallback to node_manager address creation + let Ok(addr) = self.node_manager.get_new_address(labels.clone()) else { + return Err(MutinyError::WalletOperationFailed); + }; + + Ok(addr) + } + async fn create_lightning_invoice( &self, amount: u64, @@ -1868,12 +1957,35 @@ impl MutinyWallet { activities.push(ActivityItem::OnChain(tx_details)); } } + } else if item.key.starts_with(TRANSACTION_DETAILS_PREFIX_KEY) { + // convert keys to internal transaction id + let internal_id_str = item.key.trim_start_matches(TRANSACTION_DETAILS_PREFIX_KEY); + let internal_id: Txid = Txid::from_str(internal_id_str)?; + if let Some(tx_details) = + get_transaction_details(&self.storage, internal_id, &self.logger) + { + // make sure it is a relevant transaction + if tx_details.sent != 0 || tx_details.received != 0 { + activities.push(ActivityItem::OnChain(tx_details)); + } + } } } Ok(activities) } + pub 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) => return Ok(Some(t)), + None => { + // fall back to node manager + self.node_manager.get_transaction(txid) + } + } + } + /// Returns all the lightning activity for a given label pub async fn get_label_activity( &self, diff --git a/mutiny-core/src/nodemanager.rs b/mutiny-core/src/nodemanager.rs index 767c8a408..9251d60b0 100644 --- a/mutiny-core/src/nodemanager.rs +++ b/mutiny-core/src/nodemanager.rs @@ -1,10 +1,10 @@ -use crate::auth::MutinyAuthClient; use crate::labels::LabelStorage; use crate::ldkstorage::CHANNEL_CLOSURE_PREFIX; use crate::logging::LOGGING_KEY; use crate::utils::{sleep, spawn}; use crate::MutinyInvoice; use crate::MutinyWalletConfig; +use crate::{auth::MutinyAuthClient, TransactionDetails}; use crate::{ chain::MutinyChain, error::MutinyError, @@ -167,55 +167,6 @@ impl From<&ChannelDetails> for MutinyChannel { } } -/// A wallet transaction -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)] -pub struct TransactionDetails { - /// Optional transaction - pub transaction: Option, - /// Transaction id - pub txid: Txid, - /// Received value (sats) - /// Sum of owned outputs of this transaction. - pub received: u64, - /// Sent value (sats) - /// Sum of owned inputs of this transaction. - pub sent: u64, - /// Fee value in sats if it was available. - pub fee: Option, - /// If the transaction is confirmed, contains height and Unix timestamp of the block containing the - /// transaction, unconfirmed transaction contains `None`. - pub confirmation_time: ConfirmationTime, - /// Labels associated with this transaction - pub labels: Vec, -} - -impl PartialOrd for TransactionDetails { - fn partial_cmp(&self, other: &Self) -> Option { - Some(self.cmp(other)) - } -} - -impl Ord for TransactionDetails { - fn cmp(&self, other: &Self) -> core::cmp::Ordering { - match (self.confirmation_time, other.confirmation_time) { - (ConfirmationTime::Confirmed { .. }, ConfirmationTime::Confirmed { .. }) => self - .confirmation_time - .cmp(&self.confirmation_time) - .then_with(|| self.txid.cmp(&other.txid)), - (ConfirmationTime::Confirmed { .. }, ConfirmationTime::Unconfirmed { .. }) => { - core::cmp::Ordering::Less - } - (ConfirmationTime::Unconfirmed { .. }, ConfirmationTime::Confirmed { .. }) => { - core::cmp::Ordering::Greater - } - ( - ConfirmationTime::Unconfirmed { last_seen: a }, - ConfirmationTime::Unconfirmed { last_seen: b }, - ) => a.cmp(&b).then_with(|| self.txid.cmp(&other.txid)), - } - } -} - /// Information about a channel that was closed. #[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Hash)] pub struct ChannelClosure { @@ -879,7 +830,8 @@ impl NodeManager { let details = TransactionDetails { transaction: Some(tx.to_tx()), - txid: tx.txid, + txid: Some(tx.txid), + internal_id: tx.txid, received, sent: 0, fee: None, @@ -2041,12 +1993,12 @@ mod tests { assert_eq!(txs.len(), 1); let tx = &txs[0]; - assert_eq!(tx.txid, fake_tx.txid()); + assert_eq!(tx.txid, Some(fake_tx.txid())); assert_eq!(tx.labels, labels); assert!(tx_opt.is_some()); let tx = tx_opt.unwrap(); - assert_eq!(tx.txid, fake_tx.txid()); + assert_eq!(tx.txid, Some(fake_tx.txid())); assert_eq!(tx.labels, labels); } @@ -2213,7 +2165,8 @@ mod tests { let tx1: TransactionDetails = TransactionDetails { transaction: None, - txid: Txid::all_zeros(), + txid: Some(Txid::all_zeros()), + internal_id: Txid::all_zeros(), received: 0, sent: 0, fee: None, @@ -2223,7 +2176,8 @@ mod tests { let tx2: TransactionDetails = TransactionDetails { transaction: None, - txid: Txid::all_zeros(), + txid: Some(Txid::all_zeros()), + internal_id: Txid::all_zeros(), received: 0, sent: 0, fee: None, diff --git a/mutiny-core/src/onchain.rs b/mutiny-core/src/onchain.rs index 7cad3d3bc..d46574a07 100644 --- a/mutiny-core/src/onchain.rs +++ b/mutiny-core/src/onchain.rs @@ -25,12 +25,12 @@ use crate::error::MutinyError; use crate::fees::MutinyFeeEstimator; use crate::labels::*; use crate::logging::MutinyLogger; -use crate::nodemanager::TransactionDetails; use crate::storage::{ IndexItem, MutinyStorage, OnChainStorage, KEYCHAIN_STORE_KEY, NEED_FULL_SYNC_KEY, ONCHAIN_PREFIX, }; use crate::utils::{now, sleep}; +use crate::TransactionDetails; pub(crate) const FULL_SYNC_STOP_GAP: usize = 150; pub(crate) const RESTORE_SYNC_STOP_GAP: usize = 20; @@ -152,7 +152,7 @@ impl OnChainWallet { ConfirmationTime::Confirmed { time, .. } => Some(time), ConfirmationTime::Unconfirmed { .. } => None, }, - key: format!("{ONCHAIN_PREFIX}{}", t.txid), + key: format!("{ONCHAIN_PREFIX}{}", t.internal_id), }) .collect::>(); @@ -381,7 +381,8 @@ impl OnChainWallet { Some(TransactionDetails { transaction, - txid: tx.tx_node.txid, + txid: Some(tx.tx_node.txid), + internal_id: tx.tx_node.txid, received, sent, fee, @@ -413,7 +414,8 @@ impl OnChainWallet { let fee = wallet.calculate_fee(tx.tx_node.tx).ok(); let details = TransactionDetails { transaction: Some(tx.tx_node.tx.to_owned()), - txid, + txid: Some(txid), + internal_id: txid, received, sent, fee, diff --git a/mutiny-core/src/storage.rs b/mutiny-core/src/storage.rs index 1884ee4cf..a6be6c796 100644 --- a/mutiny-core/src/storage.rs +++ b/mutiny-core/src/storage.rs @@ -1,4 +1,3 @@ -use crate::labels::LabelStorage; use crate::nodemanager::{ChannelClosure, NodeStorage}; use crate::utils::{now, spawn}; use crate::vss::{MutinyVssClient, VssKeyValueItem}; @@ -13,11 +12,13 @@ use crate::{ event::PaymentInfo, }; use crate::{event::HTLCStatus, MutinyInvoice}; +use crate::{labels::LabelStorage, TransactionDetails}; use crate::{ldkstorage::CHANNEL_MANAGER_KEY, utils::sleep}; use async_trait::async_trait; use bdk::chain::{Append, PersistBackend}; use bip39::Mnemonic; -use bitcoin::secp256k1::ThirtyTwoByteHash; +use bitcoin::{secp256k1::ThirtyTwoByteHash, Txid}; +use fedimint_ln_common::bitcoin::hashes::hex::ToHex; use futures_util::lock::Mutex; use hex_conservative::*; use lightning::{ln::PaymentHash, util::logger::Logger}; @@ -45,6 +46,7 @@ pub const DEVICE_LOCK_KEY: &str = "device_lock"; pub(crate) const EXPECTED_NETWORK_KEY: &str = "network"; pub const PAYMENT_INBOUND_PREFIX_KEY: &str = "payment_inbound/"; pub const PAYMENT_OUTBOUND_PREFIX_KEY: &str = "payment_outbound/"; +pub const TRANSACTION_DETAILS_PREFIX_KEY: &str = "transaction_details/"; pub(crate) const ONCHAIN_PREFIX: &str = "onchain_tx/"; pub const LAST_DM_SYNC_TIME_KEY: &str = "last_dm_sync_time"; pub const LAST_HERMES_SYNC_TIME_KEY: &str = "last_hermes_sync_time"; @@ -881,6 +883,62 @@ impl MutinyStorage for () { } } +pub(crate) fn transaction_details_key(internal_id: Txid) -> String { + format!( + "{}{}", + TRANSACTION_DETAILS_PREFIX_KEY, + internal_id.to_raw_hash().to_hex(), + ) +} + +pub(crate) fn persist_transaction_details( + storage: &S, + transaction_details: &TransactionDetails, +) -> Result<(), MutinyError> { + let key = transaction_details_key(transaction_details.internal_id); + storage.set_data(key.clone(), transaction_details, None)?; + + // insert into activity index + match transaction_details.confirmation_time { + bdk_chain::ConfirmationTime::Confirmed { height: _, time } => { + let index = storage.activity_index(); + let mut index = index.try_write()?; + // remove old version + index.remove(&IndexItem { + timestamp: None, // timestamp would be None for Unconfirmed + key: key.clone(), + }); + index.insert(IndexItem { + timestamp: Some(time), + key, + }); + } + bdk_chain::ConfirmationTime::Unconfirmed { .. } => { + let index = storage.activity_index(); + let mut index = index.try_write()?; + index.insert(IndexItem { + timestamp: None, + key, + }); + } + } + + Ok(()) +} + +pub(crate) fn get_transaction_details( + storage: &S, + internal_id: Txid, + logger: &MutinyLogger, +) -> Option { + let key = transaction_details_key(internal_id); + log_trace!(logger, "Trace: checking payment key: {key}"); + match storage.get_data(&key).transpose() { + Some(Ok(v)) => Some(v), + _ => None, + } +} + pub(crate) fn payment_key(inbound: bool, payment_hash: &[u8; 32]) -> String { if inbound { format!("{}{}", PAYMENT_INBOUND_PREFIX_KEY, payment_hash.as_hex()) diff --git a/mutiny-wasm/src/lib.rs b/mutiny-wasm/src/lib.rs index 635726442..76a26b0e4 100644 --- a/mutiny-wasm/src/lib.rs +++ b/mutiny-wasm/src/lib.rs @@ -700,9 +700,7 @@ impl MutinyWallet { txid: String, ) -> Result */, MutinyJsError> { let txid = Txid::from_str(&txid)?; - Ok(JsValue::from_serde( - &self.inner.node_manager.get_transaction(txid)?, - )?) + Ok(JsValue::from_serde(&self.inner.get_transaction(txid)?)?) } /// Gets the current balance of the wallet. diff --git a/mutiny-wasm/src/models.rs b/mutiny-wasm/src/models.rs index 279632cf2..5bc00396d 100644 --- a/mutiny-wasm/src/models.rs +++ b/mutiny-wasm/src/models.rs @@ -75,7 +75,7 @@ impl From for ActivityItem { }; let id = match a { - mutiny_core::ActivityItem::OnChain(ref t) => t.txid.to_string(), + mutiny_core::ActivityItem::OnChain(ref t) => t.internal_id.to_string(), mutiny_core::ActivityItem::Lightning(ref ln) => { ln.payment_hash.into_32().to_lower_hex_string() }