diff --git a/mutiny-core/src/federation.rs b/mutiny-core/src/federation.rs index a1d5a0a63..b52a2687a 100644 --- a/mutiny-core/src/federation.rs +++ b/mutiny-core/src/federation.rs @@ -1,26 +1,33 @@ use crate::utils::{ convert_from_fedimint_invoice, convert_to_fedimint_invoice, fetch_with_timeout, now, spawn, }; +use crate::TransactionDetails; 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::{ + delete_transaction_details, get_transaction_details, list_payment_info, + persist_payment_info, persist_transaction_details, MutinyStorage, VersionedValue, + }, utils::sleep, HTLCStatus, MutinyInvoice, DEFAULT_PAYMENT_TIMEOUT, }; +use crate::{labels::LabelStorage, storage::TRANSACTION_DETAILS_PREFIX_KEY}; 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 esplora_client::AsyncClient; use fedimint_bip39::Bip39RootSecretStrategy; use fedimint_client::{ derivable_secret::DerivableSecret, @@ -28,6 +35,7 @@ use fedimint_client::{ secret::{get_default_client_secret, RootSecretStrategy}, ClientHandleArc, }; +use fedimint_core::bitcoin_migration::bitcoin30_to_bitcoin29_address; use fedimint_core::config::ClientConfig; use fedimint_core::{ api::InviteCode, @@ -52,7 +60,9 @@ 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}; @@ -60,6 +70,7 @@ use lightning::{log_debug, log_error, log_info, log_trace, log_warn, util::logge use lightning_invoice::Bolt11Invoice; use reqwest::Method; use serde::{de::DeserializeOwned, Deserialize, Serialize}; +use std::time::Duration; #[cfg(not(target_arch = "wasm32"))] use std::time::Instant; use std::{ @@ -79,6 +90,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 @@ -246,6 +260,7 @@ pub(crate) struct FederationClient { #[allow(dead_code)] fedimint_storage: FedimintStorage, gateway: Arc>>, + esplora: Arc, stop: Arc, pub(crate) logger: Arc, } @@ -257,6 +272,7 @@ impl FederationClient { federation_code: InviteCode, xprivkey: ExtendedPrivKey, storage: S, + esplora: Arc, network: Network, stop: Arc, logger: Arc, @@ -386,6 +402,7 @@ impl FederationClient { storage, logger, invite_code: federation_code, + esplora, stop, gateway, }; @@ -411,6 +428,18 @@ impl FederationClient { .map(|(h, _i)| h.0), ); + // confirmed on chain operations + let confirmed_wallet_txids = self + .storage + .scan::(TRANSACTION_DETAILS_PREFIX_KEY, None)? + .into_iter() + .filter(|(_k, v)| match v.confirmation_time { + ConfirmationTime::Unconfirmed { .. } => false, // skip unconfirmed transactions + ConfirmationTime::Confirmed { .. } => true, // return all confirmed transactions + }) + .map(|(_h, i)| i.internal_id) + .collect::>(); + // go through last 100 operations let operations = self .fedimint_client @@ -420,51 +449,47 @@ 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 already confirmed, no reason to subscribe + if !confirmed_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.esplora.clone(), self.logger.clone(), self.stop.clone(), self.storage.clone(), @@ -515,46 +540,61 @@ impl FederationClient { let fedimint_client_clone = self.fedimint_client.clone(); let logger_clone = self.logger.clone(); let storage_clone = self.storage.clone(); + let esplora_clone = self.esplora.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, + esplora_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 address = Address::from_str(&address.to_string()) + .expect("should convert") + .assume_checked(); + + // persist the labels + self.storage + .set_address_labels(address.clone(), labels.clone())?; + + // 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) + } + /// 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) @@ -603,7 +643,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(), @@ -621,7 +661,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(), @@ -649,6 +689,7 @@ impl FederationClient { let fedimint_client_clone = self.fedimint_client.clone(); let logger_clone = self.logger.clone(); let storage_clone = self.storage.clone(); + let esplora_clone = self.esplora.clone(); let stop = self.stop.clone(); spawn(async move { let operation = fedimint_client_clone @@ -659,9 +700,9 @@ impl FederationClient { subscribe_operation_ext( operation, - hash, id, fedimint_client_clone, + esplora_clone, logger_clone, stop, storage_clone, @@ -673,6 +714,108 @@ impl FederationClient { } } + /// Send on chain transaction + pub(crate) async fn send_onchain( + &self, + send_to: bitcoin::Address, + amount: u64, + labels: Vec, + ) -> Result { + let address = bitcoin30_to_bitcoin29_address(send_to.clone()); + + let btc_amount = fedimint_ln_common::bitcoin::Amount::from_sat(amount); + + let wallet_module = self + .fedimint_client + .get_first_module::(); + + let peg_out_fees = wallet_module + .get_withdraw_fees(address.clone(), btc_amount) + .await?; + + let op_id = wallet_module + .withdraw(address, btc_amount, peg_out_fees, ()) + .await?; + + let internal_id = Txid::from_slice(&op_id.0).map_err(|_| MutinyError::ChainAccessFailed)?; + + let pending_transaction_details = TransactionDetails { + transaction: None, + txid: None, + internal_id, + received: 0, + sent: amount, + fee: Some(peg_out_fees.amount().to_sat()), + confirmation_time: ConfirmationTime::Unconfirmed { + last_seen: now().as_secs(), + }, + labels: labels.clone(), + }; + + persist_transaction_details(&self.storage, &pending_transaction_details)?; + + // persist the labels + self.storage.set_address_labels(send_to, labels)?; + + // subscribe + let operation = self + .fedimint_client + .operation_log() + .get_operation(op_id) + .await + .expect("just created it"); + + // Subscribe for a little bit, just to hopefully get transaction id + process_operation_until_timeout( + self.logger.clone(), + operation, + op_id, + self.fedimint_client.clone(), + self.storage.clone(), + self.esplora.clone(), + Some(DEFAULT_PAYMENT_TIMEOUT * 1_000), + self.stop.clone(), + ) + .await; + + // now check the status of the payment from storage + if let Some(t) = get_transaction_details(&self.storage, internal_id, &self.logger) { + if t.txid.is_some() { + return Ok(internal_id); + } + } + + // keep subscribing if txid wasn't retrieved, but then return timeout + let operation = self + .fedimint_client + .operation_log() + .get_operation(op_id) + .await + .expect("just created it"); + self.subscribe_operation(operation, op_id); + + Err(MutinyError::PaymentTimeout) + } + + pub async fn estimate_tx_fee( + &self, + destination_address: bitcoin::Address, + amount: u64, + ) -> Result { + let address = bitcoin30_to_bitcoin29_address(destination_address); + let btc_amount = fedimint_ln_common::bitcoin::Amount::from_sat(amount); + + let wallet_module = self + .fedimint_client + .get_first_module::(); + + let peg_out_fees = wallet_module + .get_withdraw_fees(address.clone(), btc_amount) + .await?; + + Ok(peg_out_fees.amount().to_sat()) + } + /// Someone received a payment on our behalf, we need to claim it pub async fn claim_external_receive( &self, @@ -915,42 +1058,25 @@ fn merge_values(a: Option, b: Option) -> Option { fn subscribe_operation_ext( entry: OperationLogEntry, - hash: [u8; 32], operation_id: OperationId, fedimint_client: ClientHandleArc, + esplora: Arc, 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, + esplora, 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; }); } @@ -1071,71 +1197,177 @@ 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, + esplora: Arc, 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( +) { + 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::()); + + 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 if module_type == WalletCommonInit::KIND.as_str() { + let wallet_meta: WalletOperationMeta = entry.meta(); + let wallet_module = Arc::new(fedimint_client.get_first_module::()); + + match wallet_meta.variant { + fedimint_wallet_client::WalletOperationMetaVariant::Deposit { + address, + expires_at: _, + } => { + match wallet_module.subscribe_deposit_updates(operation_id).await { + Ok(o) => { + let labels = match storage.get_address_labels() { + Ok(l) => l.get(&address.to_string()).cloned(), + Err(e) => { + log_warn!(logger, "could not get labels: {e}"); + None + } + }; + + process_onchain_deposit_outcome( o, - process_pay_state_ln, - invoice, - false, + labels.unwrap_or_default(), + operation_id, + storage, + esplora, 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()) } - } - } 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( + fedimint_wallet_client::WalletOperationMetaVariant::Withdraw { + address, + amount, + fee, + change: _, + } => { + match wallet_module.subscribe_withdraw_updates(operation_id).await { + Ok(o) => { + let labels = match storage.get_address_labels() { + Ok(l) => l.get(&address.to_string()).cloned(), + Err(e) => { + log_warn!(logger, "could not get labels: {e}"); + None + } + }; + + process_onchain_withdraw_outcome( o, - process_receive_state, - invoice, - true, + labels.unwrap_or_default(), + amount, + fee.amount(), + operation_id, + storage, + esplora, 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()) } - } - } 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}") } } @@ -1163,7 +1395,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, @@ -1244,6 +1476,284 @@ where invoice } +// FIXME: refactor +#[allow(clippy::too_many_arguments)] +async fn process_onchain_withdraw_outcome( + stream_or_outcome: UpdateStreamOrOutcome, + labels: Vec, + amount: fedimint_ln_common::bitcoin::Amount, + fee: fedimint_ln_common::bitcoin::Amount, + operation_id: OperationId, + storage: S, + esplora: Arc, + timeout: Option, + stop: Arc, + logger: Arc, +) { + let internal_id = Txid::from_slice(&operation_id.0).expect("should convert"); + + let mut s = stream_or_outcome.into_stream(); + + // 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 { + match outcome { + WithdrawState::Created => { + // Nothing to do + log_debug!(logger, "Waiting for withdraw"); + }, + WithdrawState::Succeeded(txid) => { + log_info!(logger, "Withdraw successful: {txid}"); + + let txid = Txid::from_slice(&txid).expect("should convert"); + let updated_transaction_details = TransactionDetails { + transaction: None, + txid: Some(txid), + internal_id, + received: 0, + sent: amount.to_sat(), + fee: Some(fee.to_sat()), + 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}"); + }, + } + + // we need to get confirmations for this txid and update + subscribe_onchain_confirmation_check(storage.clone(), esplora.clone(), txid, updated_transaction_details, stop, logger.clone()).await; + + break + }, + WithdrawState::Failed(e) => { + log_error!(logger, "Transaction failed: {e}"); + + // Delete the pending tx if it failed + match delete_transaction_details(&storage, internal_id) { + Ok(_) => { + log_info!(logger, "Transaction deleted"); + }, + Err(e) => { + log_error!(logger, "Error deleting transaction: {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",); +} + +async fn subscribe_onchain_confirmation_check( + storage: S, + esplora: Arc, + txid: Txid, + mut transaction_details: TransactionDetails, + stop: Arc, + logger: Arc, +) { + spawn(async move { + loop { + if stop.load(Ordering::Relaxed) { + break; + }; + + match esplora.get_tx_status(&txid).await { + Ok(s) => { + if s.confirmed { + log_info!(logger, "Transaction confirmed"); + transaction_details.confirmation_time = ConfirmationTime::Confirmed { + height: s.block_height.expect("confirmed"), + time: s.block_time.unwrap_or(now().as_secs()), + }; + match persist_transaction_details(&storage, &transaction_details) { + Ok(_) => { + log_info!(logger, "Transaction updated"); + break; + } + Err(e) => { + log_error!(logger, "Error updating transaction: {e}"); + } + } + } + } + Err(e) => { + log_error!(logger, "Error updating transaction: {e}"); + } + } + + // wait for one minute before checking mempool again + // sleep every second to check if we need to stop + for _ in 0..60 { + if stop.load(Ordering::Relaxed) { + return; + } + sleep(1_000).await; + } + } + }); +} + +// FIXME refactor +#[allow(clippy::too_many_arguments)] +async fn process_onchain_deposit_outcome( + stream_or_outcome: UpdateStreamOrOutcome, + labels: Vec, + operation_id: OperationId, + storage: S, + esplora: Arc, + timeout: Option, + stop: Arc, + logger: Arc, +) { + let mut s = stream_or_outcome.into_stream(); + + // 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 { + 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(); + + // store as confirmed 0 block height until we can check esplora after + 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() }, + 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}"); + }, + } + + // we need to get confirmations for this txid and update + subscribe_onchain_confirmation_check(storage.clone(), esplora.clone(), txid, updated_transaction_details, stop.clone(), logger.clone()).await; + } + 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) => { + 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 374d9a568..84521a994 100644 --- a/mutiny-core/src/lib.rs +++ b/mutiny-core/src/lib.rs @@ -43,15 +43,10 @@ pub mod vss; #[cfg(test)] mod test_utils; +use crate::federation::get_federation_identity; 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}; -pub use bitcoin; -pub use fedimint_core; -pub use lightning; -pub use lightning_invoice; -pub use nostr_sdk; - use crate::utils::spawn; use crate::{auth::MutinyAuthClient, hermes::HermesClient, logging::MutinyLogger}; use crate::{blindauth::BlindAuthClient, cashu::CashuHttpClient}; @@ -60,15 +55,6 @@ use crate::{ event::{HTLCStatus, MillisatAmount, PaymentInfo}, onchain::FULL_SYNC_STOP_GAP, }; -use crate::{ - federation::get_federation_identity, - 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, - }, -}; use crate::{ federation::{ FederationClient, FederationIdentity, FederationIndex, FederationStorage, GatewayFees, @@ -78,7 +64,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}; @@ -92,6 +78,15 @@ use crate::{ storage::get_invoice_by_hash, }; use crate::{nostr::NostrManager, utils::sleep}; +use crate::{ + onchain::get_esplora_url, + storage::{ + 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 ::nostr::nips::nip47::Method; use ::nostr::nips::nip57; #[cfg(target_arch = "wasm32")] @@ -103,20 +98,25 @@ 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; +pub use bitcoin; use bitcoin::secp256k1::{PublicKey, ThirtyTwoByteHash}; +use bitcoin::{bip32::ExtendedPrivKey, Transaction}; use bitcoin::{hashes::sha256, Network, Txid}; +use bitcoin::{hashes::Hash, Address}; +use esplora_client::AsyncClient; +pub use fedimint_core; use fedimint_core::{api::InviteCode, config::FederationId}; use futures::{pin_mut, select, FutureExt}; use futures_util::join; use futures_util::lock::Mutex; use hex_conservative::{DisplayHex, FromHex}; use itertools::Itertools; +pub use lightning; use lightning::chain::BestBlock; use lightning::ln::PaymentHash; use lightning::util::logger::Logger; use lightning::{log_debug, log_error, log_info, log_trace, log_warn}; +pub use lightning_invoice; use lightning_invoice::{Bolt11Invoice, Bolt11InvoiceDescription}; use lnurl::{lnurl::LnUrl, AsyncClient as LnUrlClient, LnUrlResponse, Response}; use moksha_core::primitives::{ @@ -124,6 +124,7 @@ use moksha_core::primitives::{ PostMeltQuoteBolt11Response, }; use moksha_core::token::TokenV3; +pub use nostr_sdk; use nostr_sdk::{Client, NostrSigner, RelayPoolNotification}; use reqwest::multipart::{Form, Part}; use serde::{Deserialize, Serialize}; @@ -149,6 +150,7 @@ const BITCOIN_PRICE_CACHE_SEC: u64 = 300; const DEFAULT_PAYMENT_TIMEOUT: u64 = 30; const SWAP_LABEL: &str = "SWAP"; const MELT_CASHU_TOKEN: &str = "Cashu Token Melt"; +const DUST_LIMIT: u64 = 546; #[cfg_attr(test, automock)] pub trait InvoiceHandler { @@ -226,6 +228,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 { @@ -879,11 +932,16 @@ impl MutinyWalletBuilder { } }); + let esplora_server_url = get_esplora_url(network, config.user_esplora_url.clone()); + let esplora = esplora_client::Builder::new(&esplora_server_url).build_async()?; + let esplora = Arc::new(esplora); + 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!( @@ -931,6 +989,7 @@ impl MutinyWalletBuilder { federation_storage.clone(), &config, self.storage.clone(), + esplora.clone(), stop.clone(), &logger, ) @@ -1036,10 +1095,25 @@ 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 .storage @@ -1104,6 +1178,7 @@ impl MutinyWalletBuilder { subscription_client, blind_auth_client, hermes_client, + esplora, auth, stop, logger, @@ -1176,6 +1251,7 @@ pub struct MutinyWallet { subscription_client: Option>, blind_auth_client: Option>>, hermes_client: Option>>, + esplora: Arc, pub stop: Arc, pub logger: Arc, network: Network, @@ -1581,7 +1657,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); }; @@ -1790,6 +1866,228 @@ impl MutinyWallet { Ok(Some(lsp_fee + federation_fee)) } + pub async fn send_to_address( + &self, + send_to: Address, + amount: u64, + labels: Vec, + fee_rate: Option, + ) -> Result { + // Try each federation first + let federation_ids = self.list_federation_ids().await?; + let mut last_federation_error = None; + for federation_id in federation_ids { + if let Some(fedimint_client) = self.federations.read().await.get(&federation_id) { + // Check if the federation has enough balance + let balance = fedimint_client.get_balance().await?; + if balance >= amount / 1_000 { + match fedimint_client + .send_onchain(send_to.clone(), amount, labels.clone()) + .await + { + Ok(t) => { + return Ok(t); + } + Err(e) => match e { + MutinyError::PaymentTimeout => return Err(e), + _ => { + log_warn!(self.logger, "unhandled error: {e}"); + last_federation_error = Some(e); + } + }, + } + } + // If payment fails or balance is not sufficient, continue to next federation + } + // If federation client is not found, continue to next federation + } + + // 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) + } else { + Err(last_federation_error.unwrap_or(MutinyError::InsufficientBalance)) + } + } + + /// Estimates the onchain fee for a transaction sending to the given address. + /// The amount is in satoshis and the fee rate is in sat/vbyte. + pub async fn estimate_tx_fee( + &self, + destination_address: Address, + amount: u64, + fee_rate: Option, + ) -> Result { + if amount < DUST_LIMIT { + return Err(MutinyError::WalletOperationFailed); + } + + // Try each federation first + let federation_ids = self.list_federation_ids().await?; + let mut last_federation_error = None; + for federation_id in federation_ids { + if let Some(fedimint_client) = self.federations.read().await.get(&federation_id) { + // Check if the federation has enough balance + let balance = fedimint_client.get_balance().await?; + if balance >= amount / 1_000 { + match fedimint_client + .estimate_tx_fee(destination_address.clone(), amount) + .await + { + Ok(t) => { + return Ok(t); + } + Err(e) => { + log_warn!(self.logger, "error estimating fedimint fee: {e}"); + last_federation_error = Some(e); + } + } + } + // If estimation fails or balance is not sufficient, continue to next federation + } + // 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)?; + + Ok(res) + } else { + Err(last_federation_error.unwrap_or(MutinyError::InsufficientBalance)) + } + } + + /// Estimates the onchain fee for a transaction sweep our on-chain balance + /// to the given address. If the fedimint has a balance, sweep that first. + /// Do not sweep the on chain wallet unless that is empty. + /// + /// The fee rate is in sat/vbyte. + pub async fn estimate_sweep_tx_fee( + &self, + destination_address: Address, + fee_rate: Option, + ) -> Result { + // Try each federation first + let federation_ids = self.list_federation_ids().await?; + for federation_id in federation_ids { + if let Some(fedimint_client) = self.federations.read().await.get(&federation_id) { + // Check if the federation has enough balance + let balance = fedimint_client.get_balance().await?; + match fedimint_client + .estimate_tx_fee(destination_address.clone(), balance) + .await + { + Ok(t) => { + return Ok(t); + } + Err(e) => return Err(e), + } + // If estimation fails or balance is not sufficient, continue to next federation + } + // 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)?; + + Ok(res) + } else { + log_error!(self.logger, "node manager doesn't have a balance"); + Err(MutinyError::InsufficientBalance) + } + } + + /// Sweeps all the funds from the wallet to the given address. + /// The fee rate is in sat/vbyte. + /// + /// If a fee rate is not provided, one will be used from the fee estimator. + pub async fn sweep_wallet( + &self, + send_to: Address, + labels: Vec, + fee_rate: Option, + ) -> Result { + // Try each federation first + let federation_ids = self.list_federation_ids().await?; + for federation_id in federation_ids { + if let Some(fedimint_client) = self.federations.read().await.get(&federation_id) { + // Check if the federation has enough balance + let balance = fedimint_client.get_balance().await?; + match fedimint_client + .estimate_tx_fee(send_to.clone(), balance) + .await + { + Ok(f) => { + match fedimint_client + .send_onchain(send_to.clone(), balance - f, labels) + .await + { + Ok(t) => return Ok(t), + Err(e) => { + log_error!(self.logger, "error sending the fedimint balance"); + return Err(e); + } + } + } + Err(e) => return Err(e), + } + // If payment fails or balance is not sufficient, continue to next federation + } + // 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?; + + Ok(res) + } else { + log_error!(self.logger, "node manager doesn't have a balance"); + Err(MutinyError::InsufficientBalance) + } + } + + pub 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, @@ -1923,12 +2221,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) => 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, @@ -2507,6 +2828,7 @@ impl MutinyWallet { self.federation_storage.clone(), self.federations.clone(), self.hermes_client.clone(), + self.esplora.clone(), federation_code, self.stop.clone(), ) @@ -3116,6 +3438,11 @@ impl MutinyWallet { Ok(response.price) } + + /// Returns the network of the wallet. + pub fn get_network(&self) -> Network { + self.network + } } impl InvoiceHandler for MutinyWallet { @@ -3164,6 +3491,7 @@ async fn create_federations( federation_storage: FederationStorage, c: &MutinyWalletConfig, storage: S, + esplora: Arc, stop: Arc, logger: &Arc, ) -> Result>>>>, MutinyError> { @@ -3174,6 +3502,7 @@ async fn create_federations( federation_index.federation_code, c.xprivkey, storage.clone(), + esplora.clone(), c.network, stop.clone(), logger.clone(), @@ -3198,6 +3527,7 @@ pub(crate) async fn create_new_federation( federation_storage: Arc>, federations: Arc>>>>, hermes_client: Option>>, + esplora: Arc, federation_code: InviteCode, stop: Arc, ) -> Result { @@ -3227,6 +3557,7 @@ pub(crate) async fn create_new_federation( federation_code.clone(), xprivkey, storage.clone(), + esplora, network, stop.clone(), logger.clone(), @@ -3450,9 +3781,6 @@ mod tests { #[cfg(test)] #[cfg(target_arch = "wasm32")] mod tests { - use crate::event::{HTLCStatus, MillisatAmount, PaymentInfo}; - use crate::ldkstorage::CHANNEL_CLOSURE_PREFIX; - use crate::nodemanager::ChannelClosure; use crate::storage::{ payment_key, persist_payment_info, IndexItem, MemoryStorage, MutinyStorage, ONCHAIN_PREFIX, PAYMENT_OUTBOUND_PREFIX_KEY, @@ -3461,12 +3789,18 @@ mod tests { encrypt::encryption_key_from_pass, generate_seed, max_routing_fee_amount, nodemanager::NodeManager, MutinyWallet, MutinyWalletBuilder, MutinyWalletConfigBuilder, }; + use crate::{ + event::{HTLCStatus, MillisatAmount, PaymentInfo}, + TransactionDetails, + }; + use crate::{ldkstorage::CHANNEL_CLOSURE_PREFIX, storage::persist_transaction_details}; + use crate::{nodemanager::ChannelClosure, storage::TRANSACTION_DETAILS_PREFIX_KEY}; use bdk_chain::{BlockId, ConfirmationTime}; - use bitcoin::absolute::LockTime; use bitcoin::bip32::ExtendedPrivKey; use bitcoin::hashes::hex::FromHex; use bitcoin::hashes::Hash; use bitcoin::secp256k1::PublicKey; + use bitcoin::{absolute::LockTime, Txid}; use bitcoin::{BlockHash, Network, Transaction, TxOut}; use hex_conservative::DisplayHex; use itertools::Itertools; @@ -3979,6 +4313,20 @@ mod tests { }; persist_payment_info(&storage, &payment_hash4, &invoice4, false).unwrap(); + let transaction_details1 = TransactionDetails { + transaction: None, + txid: Some(Txid::all_zeros()), + internal_id: Txid::all_zeros(), + received: 0, + sent: 10_000, + fee: Some(100), + confirmation_time: ConfirmationTime::Unconfirmed { + last_seen: now().as_secs(), + }, + labels: vec![], + }; + persist_transaction_details(&storage, &transaction_details1).unwrap(); + let vec = { let index = storage.activity_index(); let vec = index.read().unwrap().clone().into_iter().collect_vec(); @@ -4004,6 +4352,13 @@ mod tests { payment_hash3.to_lower_hex_string() ), }, + IndexItem { + timestamp: None, + key: format!( + "{TRANSACTION_DETAILS_PREFIX_KEY}{}", + transaction_details1.internal_id + ), + }, IndexItem { timestamp: Some(invoice2.last_update), key: format!( diff --git a/mutiny-core/src/nodemanager.rs b/mutiny-core/src/nodemanager.rs index dd891a2ae..a048f692f 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 { @@ -281,6 +232,7 @@ pub struct NodeBalance { pub struct NodeManagerBuilder { xprivkey: ExtendedPrivKey, storage: S, + esplora: Option>, config: Option, stop: Option>, logger: Option>, @@ -291,6 +243,7 @@ impl NodeManagerBuilder { NodeManagerBuilder:: { xprivkey, storage, + esplora: None, config: None, stop: None, logger: None, @@ -306,6 +259,10 @@ impl NodeManagerBuilder { self.stop = Some(stop); } + pub fn with_esplora(&mut self, esplora: Arc) { + self.esplora = Some(esplora); + } + pub fn with_logger(&mut self, logger: Arc) { self.logger = Some(logger); } @@ -320,6 +277,13 @@ impl NodeManagerBuilder { .map_or_else(|| Err(MutinyError::InvalidArgumentsError), Ok)?; let logger = self.logger.unwrap_or(Arc::new(MutinyLogger::default())); let stop = self.stop.unwrap_or(Arc::new(AtomicBool::new(false))); + let esplora = if let Some(e) = self.esplora { + e + } else { + let esplora_server_url = get_esplora_url(c.network, c.user_esplora_url); + let esplora = Builder::new(&esplora_server_url).build_async()?; + Arc::new(esplora) + }; #[cfg(target_arch = "wasm32")] let websocket_proxy_addr = c @@ -329,14 +293,11 @@ impl NodeManagerBuilder { let start = Instant::now(); log_info!(logger, "Building node manager components"); - let esplora_server_url = get_esplora_url(c.network, c.user_esplora_url); - let esplora = Builder::new(&esplora_server_url).build_async()?; let tx_sync = Arc::new(EsploraSyncClient::from_client( - esplora.clone(), + esplora.as_ref().clone(), logger.clone(), )); - let esplora = Arc::new(esplora); let fee_estimator = Arc::new(MutinyFeeEstimator::new( self.storage.clone(), esplora.clone(), @@ -751,14 +712,12 @@ impl NodeManager { /// If a fee rate is not provided, one will be used from the fee estimator. pub async fn send_to_address( &self, - send_to: Address, + send_to: Address, amount: u64, labels: Vec, fee_rate: Option, ) -> Result { - let address = send_to.require_network(self.network)?; - - self.wallet.send(address, amount, labels, fee_rate).await + self.wallet.send(send_to, amount, labels, fee_rate).await } /// Sweeps all the funds from the wallet to the given address. @@ -767,18 +726,16 @@ impl NodeManager { /// If a fee rate is not provided, one will be used from the fee estimator. pub async fn sweep_wallet( &self, - send_to: Address, + send_to: Address, labels: Vec, fee_rate: Option, ) -> Result { - let address = send_to.require_network(self.network)?; - - self.wallet.sweep(address, labels, fee_rate).await + self.wallet.sweep(send_to, labels, fee_rate).await } /// Estimates the onchain fee for a transaction sending to the given address. /// The amount is in satoshis and the fee rate is in sat/vbyte. - pub fn estimate_tx_fee( + pub(crate) fn estimate_tx_fee( &self, destination_address: Address, amount: u64, @@ -792,7 +749,7 @@ impl NodeManager { /// to the given address. /// /// The fee rate is in sat/vbyte. - pub fn estimate_sweep_tx_fee( + pub(crate) fn estimate_sweep_tx_fee( &self, destination_address: Address, fee_rate: Option, @@ -885,7 +842,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, @@ -2047,12 +2005,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); } @@ -2219,7 +2177,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, @@ -2229,7 +2188,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..e5a5a41ae 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,81 @@ 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(()) +} + +// Deletes the transaction detail and removes the pending index if it exists +pub(crate) fn delete_transaction_details( + storage: &S, + txid: Txid, +) -> Result<(), MutinyError> { + let key = transaction_details_key(txid); + storage.delete(&[key.clone()])?; + + // delete the pending index item, if it exists + let index = storage.activity_index(); + let mut index = index.try_write()?; + index.remove(&IndexItem { + timestamp: None, // timestamp would be None for Unconfirmed + key: key.clone(), + }); + + 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 b0033a227..53ae731b7 100644 --- a/mutiny-wasm/src/lib.rs +++ b/mutiny-wasm/src/lib.rs @@ -476,7 +476,7 @@ impl MutinyWallet { /// Returns the network of the wallet. #[wasm_bindgen] pub fn get_network(&self) -> String { - self.inner.node_manager.get_network().to_string() + self.inner.get_network().to_string() } /// Gets a new bitcoin address from the wallet. @@ -545,10 +545,10 @@ impl MutinyWallet { labels: Vec, fee_rate: Option, ) -> Result { - let send_to = Address::from_str(&destination_address)?; + let send_to = + Address::from_str(&destination_address)?.require_network(self.inner.get_network())?; Ok(self .inner - .node_manager .send_to_address(send_to, amount, labels, fee_rate) .await? .to_string()) @@ -584,10 +584,10 @@ impl MutinyWallet { labels: Vec, fee_rate: Option, ) -> Result { - let send_to = Address::from_str(&destination_address)?; + let send_to = + Address::from_str(&destination_address)?.require_network(self.inner.get_network())?; Ok(self .inner - .node_manager .sweep_wallet(send_to, labels, fee_rate) .await? .to_string()) @@ -595,33 +595,27 @@ impl MutinyWallet { /// Estimates the onchain fee for a transaction sending to the given address. /// The amount is in satoshis and the fee rate is in sat/vbyte. - pub fn estimate_tx_fee( + pub async fn estimate_tx_fee( &self, destination_address: String, amount: u64, fee_rate: Option, ) -> Result { let addr = Address::from_str(&destination_address)?.assume_checked(); - Ok(self - .inner - .node_manager - .estimate_tx_fee(addr, amount, fee_rate)?) + Ok(self.inner.estimate_tx_fee(addr, amount, fee_rate).await?) } /// Estimates the onchain fee for a transaction sweep our on-chain balance /// to the given address. /// /// The fee rate is in sat/vbyte. - pub fn estimate_sweep_tx_fee( + pub async fn estimate_sweep_tx_fee( &self, destination_address: String, fee_rate: Option, ) -> Result { let addr = Address::from_str(&destination_address)?.assume_checked(); - Ok(self - .inner - .node_manager - .estimate_sweep_tx_fee(addr, fee_rate)?) + Ok(self.inner.estimate_sweep_tx_fee(addr, fee_rate).await?) } /// Estimates the onchain fee for a opening a lightning channel. @@ -700,9 +694,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 7bd9b79dc..75c50f493 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() }