diff --git a/mutiny-core/src/federation.rs b/mutiny-core/src/federation.rs index 662a8917b..fb93368a9 100644 --- a/mutiny-core/src/federation.rs +++ b/mutiny-core/src/federation.rs @@ -20,6 +20,7 @@ use async_trait::async_trait; use bdk_chain::ConfirmationTime; use bip39::Mnemonic; use bitcoin::{ + address::NetworkUnchecked, bip32::{ChildNumber, DerivationPath, ExtendedPrivKey}, hashes::Hash, secp256k1::{Secp256k1, SecretKey, ThirtyTwoByteHash}, @@ -33,6 +34,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, @@ -192,6 +194,7 @@ pub(crate) struct FederationClient { #[allow(dead_code)] fedimint_storage: FedimintStorage, gateway: Arc>>, + network: Network, stop: Arc, pub(crate) logger: Arc, } @@ -332,6 +335,7 @@ impl FederationClient { storage, logger, invite_code: federation_code, + network, stop, gateway, }; @@ -649,6 +653,58 @@ 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.require_network(self.network)?); + + 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, + }; + + 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); + + todo!() + } + /// Someone received a payment on our behalf, we need to claim it pub async fn claim_external_receive( &self, @@ -977,33 +1033,29 @@ async fn process_operation_until_timeout( } fedimint_wallet_client::WalletOperationMetaVariant::Withdraw { address: _, - amount: _, - fee: _, + 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 - } + match wallet_module.subscribe_withdraw_updates(operation_id).await { + Ok(o) => { + process_onchain_withdraw_outcome( + o, + stored_transaction_details, + amount, + fee.amount(), + operation_id, + storage, + timeout, + stop, + logger, + ) + .await } - } + Err(e) => { + log_error!(logger, "Error trying to process stream outcome: {e}"); + } + }; } fedimint_wallet_client::WalletOperationMetaVariant::RbfWithdraw { .. } => { // not supported yet @@ -1120,6 +1172,103 @@ where invoice } +async fn process_onchain_withdraw_outcome( + stream_or_outcome: UpdateStreamOrOutcome, + original_transaction_details: Option, + amount: fedimint_ln_common::bitcoin::Amount, + fee: fedimint_ln_common::bitcoin::Amount, + 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 { + WithdrawState::Created => { + // Nothing to do + log_debug!(logger, "Waiting for withdraw"); + }, + WithdrawState::Succeeded(txid) => { + let internal_id = Txid::from_slice(&operation_id.0).expect("should convert"); + let txid = Txid::from_slice(&txid).expect("should convert"); + let updated_transaction_details = TransactionDetails { + transaction: None, + txid: Some(txid), + internal_id, + received: amount.to_sat(), + sent: 0, + 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}"); + }, + } + + // TODO we need to get confirmations for this txid and update + }, + WithdrawState::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",); + } + } +} + async fn process_onchain_deposit_outcome( stream_or_outcome: UpdateStreamOrOutcome, original_transaction_details: Option, diff --git a/mutiny-core/src/lib.rs b/mutiny-core/src/lib.rs index a9dd3df66..86f6594d8 100644 --- a/mutiny-core/src/lib.rs +++ b/mutiny-core/src/lib.rs @@ -94,10 +94,13 @@ use ::nostr::{EventBuilder, EventId, JsonUtil, Keys, Kind}; use async_lock::RwLock; use bdk_chain::ConfirmationTime; use bip39::Mnemonic; -use bitcoin::hashes::Hash; -use bitcoin::secp256k1::{PublicKey, ThirtyTwoByteHash}; +use bitcoin::{ + address::NetworkUnchecked, + secp256k1::{PublicKey, ThirtyTwoByteHash}, +}; use bitcoin::{bip32::ExtendedPrivKey, Transaction}; use bitcoin::{hashes::sha256, Network, Txid}; +use bitcoin::{hashes::Hash, Address}; use fedimint_core::{api::InviteCode, config::FederationId}; use futures::{pin_mut, select, FutureExt}; use futures_util::join; @@ -1801,6 +1804,64 @@ 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.clone(), labels.clone()) + .await + { + Ok(t) => { + return Ok(t); + } + Err(e) => match e { + MutinyError::PaymentTimeout => return Err(e), + MutinyError::RoutingFailed => { + log_debug!( + self.logger, + "could not make payment through federation: {e}" + ); + last_federation_error = Some(e); + continue; + } + _ => { + log_warn!(self.logger, "unhandled error: {e}"); + last_federation_error = Some(e); + } + }, + } + } + // If payment fails or invoice amount is None 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)) + } + } + async fn create_address(&self, labels: Vec) -> Result { // Attempt to create federation invoice if available let federation_ids = self.list_federation_ids().await?; diff --git a/mutiny-wasm/src/lib.rs b/mutiny-wasm/src/lib.rs index 76a26b0e4..6ed48b26a 100644 --- a/mutiny-wasm/src/lib.rs +++ b/mutiny-wasm/src/lib.rs @@ -548,7 +548,6 @@ impl MutinyWallet { let send_to = Address::from_str(&destination_address)?; Ok(self .inner - .node_manager .send_to_address(send_to, amount, labels, fee_rate) .await? .to_string())