diff --git a/node_modules/.yarn-integrity b/node_modules/.yarn-integrity new file mode 100644 index 00000000..9d4d6a80 --- /dev/null +++ b/node_modules/.yarn-integrity @@ -0,0 +1,10 @@ +{ + "systemParams": "win32-x64-127", + "modulesFolders": [], + "flags": [], + "linkedModules": [], + "topLevelPatterns": [], + "lockfileEntries": {}, + "files": [], + "artifacts": {} +} \ No newline at end of file diff --git a/src-gui/src/renderer/components/modal/swap/BitcoinTransactionInfoBox.tsx b/src-gui/src/renderer/components/modal/swap/BitcoinTransactionInfoBox.tsx index b2861a60..7c40df93 100644 --- a/src-gui/src/renderer/components/modal/swap/BitcoinTransactionInfoBox.tsx +++ b/src-gui/src/renderer/components/modal/swap/BitcoinTransactionInfoBox.tsx @@ -1,23 +1,18 @@ -import { ReactNode } from "react"; import BitcoinIcon from "renderer/components/icons/BitcoinIcon"; import { isTestnet } from "store/config"; import { getBitcoinTxExplorerUrl } from "utils/conversionUtils"; -import TransactionInfoBox from "./TransactionInfoBox"; - -type Props = { - title: string; - txId: string; - additionalContent: ReactNode; - loading: boolean; -}; - -export default function BitcoinTransactionInfoBox({ txId, ...props }: Props) { - const explorerUrl = getBitcoinTxExplorerUrl(txId, isTestnet()); +import TransactionInfoBox, { + TransactionInfoBoxProps, +} from "./TransactionInfoBox"; +export default function BitcoinTransactionInfoBox({ + txId, + ...props +}: Omit) { return ( getBitcoinTxExplorerUrl(txId, isTestnet())} icon={} {...props} /> diff --git a/src-gui/src/renderer/components/modal/swap/MoneroTransactionInfoBox.tsx b/src-gui/src/renderer/components/modal/swap/MoneroTransactionInfoBox.tsx index 9f37d6cb..92c8d09b 100644 --- a/src-gui/src/renderer/components/modal/swap/MoneroTransactionInfoBox.tsx +++ b/src-gui/src/renderer/components/modal/swap/MoneroTransactionInfoBox.tsx @@ -1,23 +1,18 @@ -import { ReactNode } from "react"; import MoneroIcon from "renderer/components/icons/MoneroIcon"; import { isTestnet } from "store/config"; import { getMoneroTxExplorerUrl } from "utils/conversionUtils"; -import TransactionInfoBox from "./TransactionInfoBox"; - -type Props = { - title: string; - txId: string; - additionalContent: ReactNode; - loading: boolean; -}; - -export default function MoneroTransactionInfoBox({ txId, ...props }: Props) { - const explorerUrl = getMoneroTxExplorerUrl(txId, isTestnet()); +import TransactionInfoBox, { + TransactionInfoBoxProps, +} from "./TransactionInfoBox"; +export default function MoneroTransactionInfoBox({ + txId, + ...props +}: Omit) { return ( getMoneroTxExplorerUrl(txid, isTestnet())} icon={} {...props} /> diff --git a/src-gui/src/renderer/components/modal/swap/TransactionInfoBox.tsx b/src-gui/src/renderer/components/modal/swap/TransactionInfoBox.tsx index 25c2d035..593323bd 100644 --- a/src-gui/src/renderer/components/modal/swap/TransactionInfoBox.tsx +++ b/src-gui/src/renderer/components/modal/swap/TransactionInfoBox.tsx @@ -2,10 +2,10 @@ import { Link, Typography } from "@material-ui/core"; import { ReactNode } from "react"; import InfoBox from "./InfoBox"; -type TransactionInfoBoxProps = { +export type TransactionInfoBoxProps = { title: string; - txId: string; - explorerUrl: string; + txId: string | null; + explorerUrlCreator: ((txId: string) => string) | null; additionalContent: ReactNode; loading: boolean; icon: JSX.Element; @@ -14,24 +14,31 @@ type TransactionInfoBoxProps = { export default function TransactionInfoBox({ title, txId, - explorerUrl, additionalContent, icon, loading, + explorerUrlCreator, }: TransactionInfoBoxProps) { return ( {txId}} + mainContent={ + + {txId ?? "Transaction ID not available"} + + } loading={loading} additionalContent={ <> {additionalContent} - - - View on explorer - - + {explorerUrlCreator != null && + txId != null && ( // Only show the link if the txId is not null and we have a creator for the explorer URL + + + View on explorer + + + )} } icon={icon} diff --git a/src-gui/src/renderer/components/modal/swap/pages/done/XmrRedeemInMempoolPage.tsx b/src-gui/src/renderer/components/modal/swap/pages/done/XmrRedeemInMempoolPage.tsx index d499d33a..b0178d1b 100644 --- a/src-gui/src/renderer/components/modal/swap/pages/done/XmrRedeemInMempoolPage.tsx +++ b/src-gui/src/renderer/components/modal/swap/pages/done/XmrRedeemInMempoolPage.tsx @@ -3,16 +3,10 @@ import { TauriSwapProgressEventContent } from "models/tauriModelExt"; import FeedbackInfoBox from "../../../../pages/help/FeedbackInfoBox"; import MoneroTransactionInfoBox from "../../MoneroTransactionInfoBox"; -export default function XmrRedeemInMempoolPage({ - xmr_redeem_address, - xmr_redeem_txid, -}: TauriSwapProgressEventContent<"XmrRedeemInMempool">) { - // TODO: Reimplement this using Tauri - //const additionalContent = swap - // ? `This transaction transfers ${getSwapXmrAmount(swap).toFixed(6)} XMR to ${ - // state?.bobXmrRedeemAddress - // }` - // : null; +export default function XmrRedeemInMempoolPage( + state: TauriSwapProgressEventContent<"XmrRedeemInMempool">, +) { + const xmr_redeem_txid = state.xmr_redeem_txids[0] ?? null; return ( @@ -30,7 +24,7 @@ export default function XmrRedeemInMempoolPage({ diff --git a/swap/src/bitcoin/timelocks.rs b/swap/src/bitcoin/timelocks.rs index 62e11e92..2dbf5a4b 100644 --- a/swap/src/bitcoin/timelocks.rs +++ b/swap/src/bitcoin/timelocks.rs @@ -46,3 +46,12 @@ pub enum ExpiredTimelocks { Cancel { blocks_left: u32 }, Punish, } + +impl ExpiredTimelocks { + /// Check whether the timelock on the cancel transaction has expired. + /// + /// Retuns `true` even if the swap has already been canceled or punished. + pub fn cancel_timelock_expired(&self) -> bool { + !matches!(self, ExpiredTimelocks::None { .. }) + } +} diff --git a/swap/src/cli/api/tauri_bindings.rs b/swap/src/cli/api/tauri_bindings.rs index ce373a1a..9172dc3c 100644 --- a/swap/src/cli/api/tauri_bindings.rs +++ b/swap/src/cli/api/tauri_bindings.rs @@ -135,8 +135,8 @@ pub enum TauriSwapProgressEvent { XmrLocked, BtcRedeemed, XmrRedeemInMempool { - #[typeshare(serialized_as = "string")] - xmr_redeem_txid: monero::TxHash, + #[typeshare(serialized_as = "Vec")] + xmr_redeem_txids: Vec, #[typeshare(serialized_as = "string")] xmr_redeem_address: monero::Address, }, diff --git a/swap/src/monero/wallet.rs b/swap/src/monero/wallet.rs index e3db3416..1017525a 100644 --- a/swap/src/monero/wallet.rs +++ b/swap/src/monero/wallet.rs @@ -6,7 +6,9 @@ use ::monero::{Address, Network, PrivateKey, PublicKey}; use anyhow::{Context, Result}; use monero_rpc::wallet::{BlockHeight, MoneroWalletRpc as _, Refreshed}; use monero_rpc::{jsonrpc, wallet}; +use std::future::Future; use std::ops::Div; +use std::pin::Pin; use std::str::FromStr; use std::time::Duration; use tokio::sync::Mutex; @@ -215,7 +217,18 @@ impl Wallet { )) } + /// Wait until the specified transfer has been completed or failed. pub async fn watch_for_transfer(&self, request: WatchRequest) -> Result<(), InsufficientFunds> { + self.watch_for_transfer_with(request, None).await + } + + /// Wait until the specified transfer has been completed or failed and listen to each new confirmation. + #[allow(clippy::too_many_arguments)] + pub async fn watch_for_transfer_with( + &self, + request: WatchRequest, + listener: Option, + ) -> Result<(), InsufficientFunds> { let WatchRequest { conf_target, public_view_key, @@ -236,7 +249,7 @@ impl Wallet { let check_interval = tokio::time::interval(self.sync_interval.div(10)); - wait_for_confirmations( + wait_for_confirmations_with( &self.inner, transfer_proof, address, @@ -244,6 +257,7 @@ impl Wallet { conf_target, check_interval, self.name.clone(), + listener, ) .await?; @@ -332,7 +346,13 @@ pub struct WatchRequest { pub expected: Amount, } -async fn wait_for_confirmations + Sync>( +type ConfirmationListener = + Box Pin + Send + 'static>> + Send + 'static>; + +#[allow(clippy::too_many_arguments)] +async fn wait_for_confirmations_with< + C: monero_rpc::wallet::MoneroWalletRpc + Sync, +>( client: &Mutex, transfer_proof: TransferProof, to_address: Address, @@ -340,6 +360,7 @@ async fn wait_for_confirmations, ) -> Result<(), InsufficientFunds> { let mut seen_confirmations = 0u64; @@ -405,6 +426,11 @@ async fn wait_for_confirmations + Sync, + >( + client: &Mutex, + transfer_proof: TransferProof, + to_address: Address, + expected: Amount, + conf_target: u64, + check_interval: Interval, + wallet_name: String, + ) -> Result<(), InsufficientFunds> { + wait_for_confirmations_with( + client, + transfer_proof, + to_address, + expected, + conf_target, + check_interval, + wallet_name, + None, + ) + .await + } + #[tokio::test] async fn given_exact_confirmations_does_not_fetch_tx_again() { let client = Mutex::new(DummyClient::new(vec![Ok(CheckTxKey { @@ -435,7 +485,7 @@ mod tests { Amount::from_piconero(100), 10, tokio::time::interval(Duration::from_millis(10)), - "foo-wallet".to_owned() + "foo-wallet".to_owned(), ) .await; @@ -533,7 +583,7 @@ mod tests { Amount::from_piconero(100), 5, tokio::time::interval(Duration::from_millis(10)), - "foo-wallet".to_owned() + "foo-wallet".to_owned(), ) .await .unwrap(); diff --git a/swap/src/protocol/bob/state.rs b/swap/src/protocol/bob/state.rs index 8fe5ca32..238683d5 100644 --- a/swap/src/protocol/bob/state.rs +++ b/swap/src/protocol/bob/state.rs @@ -3,8 +3,8 @@ use crate::bitcoin::{ self, current_epoch, CancelTimelock, ExpiredTimelocks, PunishTimelock, Transaction, TxCancel, TxLock, Txid, }; -use crate::monero; use crate::monero::wallet::WatchRequest; +use crate::monero::{self, TxHash}; use crate::monero::{monero_private_key, TransferProof}; use crate::monero_ext::ScalarExt; use crate::protocol::{Message0, Message1, Message2, Message3, Message4, CROSS_CURVE_PROOF_SYSTEM}; @@ -627,7 +627,7 @@ impl State5 { monero_wallet: &monero::Wallet, wallet_file_name: std::string::String, monero_receive_address: monero::Address, - ) -> Result<()> { + ) -> Result> { let (spend_key, view_key) = self.xmr_keys(); tracing::info!(%wallet_file_name, "Generating and opening Monero wallet from the extracted keys to redeem the Monero"); @@ -652,12 +652,15 @@ impl State5 { // Ensure that the generated wallet is synced so we have a proper balance monero_wallet.refresh(20).await?; - // Sweep (transfer all funds) to the given address + + // Sweep (transfer all funds) to the Bobs Monero redeem address let tx_hashes = monero_wallet.sweep_all(monero_receive_address).await?; - for tx_hash in tx_hashes { + + for tx_hash in &tx_hashes { tracing::info!(%monero_receive_address, txid=%tx_hash.0, "Successfully transferred XMR to wallet"); } - Ok(()) + + Ok(tx_hashes) } } diff --git a/swap/src/protocol/bob/swap.rs b/swap/src/protocol/bob/swap.rs index 4ed0025e..4ff41bc6 100644 --- a/swap/src/protocol/bob/swap.rs +++ b/swap/src/protocol/bob/swap.rs @@ -141,8 +141,8 @@ async fn next_state( monero_wallet_restore_blockheight, } } - // Bob has locked Btc - // Watch for Alice to Lock Xmr or for cancel timelock to elapse + // Bob has locked Bitcoin + // Watch for Alice to lock Monero or for cancel timelock to elapse BobState::BtcLocked { state3, monero_wallet_restore_blockheight, @@ -151,59 +151,66 @@ async fn next_state( swap_id, TauriSwapProgressEvent::BtcLockTxInMempool { btc_lock_txid: state3.tx_lock_id(), - // TODO: Replace this with the actual confirmations btc_lock_confirmations: 0, }, ); let tx_lock_status = bitcoin_wallet.subscribe_to(state3.tx_lock.clone()).await; - if let ExpiredTimelocks::None { .. } = state3.expired_timelock(bitcoin_wallet).await? { - tracing::info!("Waiting for Alice to lock Monero"); + // Check whether we can cancel the swap, and do so if possible + if state3 + .expired_timelock(bitcoin_wallet) + .await? + .cancel_timelock_expired() + { + let state4 = state3.cancel(monero_wallet_restore_blockheight); + return Ok(BobState::CancelTimelockExpired(state4)); + }; - let buffered_transfer_proof = db - .get_buffered_transfer_proof(swap_id) - .await - .context("Failed to get buffered transfer proof")?; + tracing::info!("Waiting for Alice to lock Monero"); + + // Check if we have already buffered the XMR transfer proof + if let Some(transfer_proof) = db + .get_buffered_transfer_proof(swap_id) + .await + .context("Failed to get buffered transfer proof")? + { + tracing::debug!(txid = %transfer_proof.tx_hash(), "Found buffered transfer proof"); + tracing::info!(txid = %transfer_proof.tx_hash(), "Alice locked Monero"); + + return Ok(BobState::XmrLockProofReceived { + state: state3, + lock_transfer_proof: transfer_proof, + monero_wallet_restore_blockheight, + }); + } + + // Wait for either Alice to send the XMR transfer proof or until we can cancel the swap + let transfer_proof_watcher = event_loop_handle.recv_transfer_proof(); + let cancel_timelock_expires = + tx_lock_status.wait_until_confirmed_with(state3.cancel_timelock); + + select! { + // Alice sent us the transfer proof for the Monero she locked + transfer_proof = transfer_proof_watcher => { + let transfer_proof = transfer_proof?; - if let Some(transfer_proof) = buffered_transfer_proof { - tracing::debug!(txid = %transfer_proof.tx_hash(), "Found buffered transfer proof"); tracing::info!(txid = %transfer_proof.tx_hash(), "Alice locked Monero"); - return Ok(BobState::XmrLockProofReceived { + BobState::XmrLockProofReceived { state: state3, lock_transfer_proof: transfer_proof, - monero_wallet_restore_blockheight, - }); - } + monero_wallet_restore_blockheight + } + }, + // The cancel timelock expired before Alice locked her Monero + result = cancel_timelock_expires => { + result?; + tracing::info!("Alice took too long to lock Monero, cancelling the swap"); - let transfer_proof_watcher = event_loop_handle.recv_transfer_proof(); - let cancel_timelock_expires = - tx_lock_status.wait_until_confirmed_with(state3.cancel_timelock); - - select! { - transfer_proof = transfer_proof_watcher => { - let transfer_proof = transfer_proof?; - - tracing::info!(txid = %transfer_proof.tx_hash(), "Alice locked Monero"); - - BobState::XmrLockProofReceived { - state: state3, - lock_transfer_proof: transfer_proof, - monero_wallet_restore_blockheight - } - }, - result = cancel_timelock_expires => { - result?; - tracing::info!("Alice took too long to lock Monero, cancelling the swap"); - - let state4 = state3.cancel(monero_wallet_restore_blockheight); - BobState::CancelTimelockExpired(state4) - }, - } - } else { - let state4 = state3.cancel(monero_wallet_restore_blockheight); - BobState::CancelTimelockExpired(state4) + let state4 = state3.cancel(monero_wallet_restore_blockheight); + BobState::CancelTimelockExpired(state4) + }, } } BobState::XmrLockProofReceived { @@ -221,31 +228,66 @@ async fn next_state( let tx_lock_status = bitcoin_wallet.subscribe_to(state.tx_lock.clone()).await; - if let ExpiredTimelocks::None { .. } = state.expired_timelock(bitcoin_wallet).await? { - let watch_request = state.lock_xmr_watch_request(lock_transfer_proof); + // Check if the cancel timelock has expired + // If it has, we have to cancel the swap + if state + .expired_timelock(bitcoin_wallet) + .await? + .cancel_timelock_expired() + { + return Ok(BobState::CancelTimelockExpired( + state.cancel(monero_wallet_restore_blockheight), + )); + }; - select! { - received_xmr = monero_wallet.watch_for_transfer(watch_request) => { - match received_xmr { - Ok(()) => BobState::XmrLocked(state.xmr_locked(monero_wallet_restore_blockheight)), - Err(monero::InsufficientFunds { expected, actual }) => { - tracing::warn!(%expected, %actual, "Insufficient Monero have been locked!"); - tracing::info!(timelock = %state.cancel_timelock, "Waiting for cancel timelock to expire"); + // Clone these so that we can move them into the listener closure + let tauri_clone = event_emitter.clone(); + let transfer_proof_clone = lock_transfer_proof.clone(); + let watch_request = state.lock_xmr_watch_request(lock_transfer_proof); - tx_lock_status.wait_until_confirmed_with(state.cancel_timelock).await?; + // We pass a listener to the function that get's called everytime a new confirmation is spotted. + let watch_future = monero_wallet.watch_for_transfer_with( + watch_request, + Some(Box::new(move |confirmations| { + // Clone them again so that we can move them again + let tranfer = transfer_proof_clone.clone(); + let tauri = tauri_clone.clone(); - BobState::CancelTimelockExpired(state.cancel(monero_wallet_restore_blockheight)) + // Emit an event to notify about the new confirmation + Box::pin(async move { + tauri.emit_swap_progress_event( + swap_id, + TauriSwapProgressEvent::XmrLockTxInMempool { + xmr_lock_txid: tranfer.tx_hash(), + xmr_lock_tx_confirmations: confirmations, }, - } - } - // TODO: Send Tauri event here everytime we receive a new confirmation - result = tx_lock_status.wait_until_confirmed_with(state.cancel_timelock) => { - result?; - BobState::CancelTimelockExpired(state.cancel(monero_wallet_restore_blockheight)) + ); + }) + })), + ); + + select! { + received_xmr = watch_future => { + match received_xmr { + Ok(()) => + BobState::XmrLocked(state.xmr_locked(monero_wallet_restore_blockheight)), + Err(monero::InsufficientFunds { expected, actual }) => { + // Alice locked insufficient Monero + tracing::warn!(%expected, %actual, "Insufficient Monero have been locked!"); + tracing::info!(timelock = %state.cancel_timelock, "Waiting for cancel timelock to expire"); + + // We wait for the cancel timelock to expire before we cancel the swap + // because there's no way of recovering from this state + tx_lock_status.wait_until_confirmed_with(state.cancel_timelock).await?; + + BobState::CancelTimelockExpired(state.cancel(monero_wallet_restore_blockheight)) + }, } } - } else { - BobState::CancelTimelockExpired(state.cancel(monero_wallet_restore_blockheight)) + result = tx_lock_status.wait_until_confirmed_with(state.cancel_timelock) => { + result?; + BobState::CancelTimelockExpired(state.cancel(monero_wallet_restore_blockheight)) + } } } BobState::XmrLocked(state) => { @@ -260,25 +302,29 @@ async fn next_state( let tx_lock_status = bitcoin_wallet.subscribe_to(state.tx_lock.clone()).await; - if let ExpiredTimelocks::None { .. } = state.expired_timelock(bitcoin_wallet).await? { - // Alice has locked Xmr - // Bob sends Alice his key + // Check whether we can cancel the swap and do so if possible. + if state + .expired_timelock(bitcoin_wallet) + .await? + .cancel_timelock_expired() + { + return Ok(BobState::CancelTimelockExpired(state.cancel())); + } - select! { - result = event_loop_handle.send_encrypted_signature(state.tx_redeem_encsig()) => { - match result { - Ok(_) => BobState::EncSigSent(state), - Err(bmrng::error::RequestError::RecvError | bmrng::error::RequestError::SendError(_)) => bail!("Failed to communicate encrypted signature through event loop channel"), - Err(bmrng::error::RequestError::RecvTimeoutError) => unreachable!("We construct the channel with no timeout"), - } - }, - result = tx_lock_status.wait_until_confirmed_with(state.cancel_timelock) => { - result?; - BobState::CancelTimelockExpired(state.cancel()) + // Alice has locked their Monero + // Bob sends Alice the encrypted signature which allows her to sign and broadcast the Bitcoin redeem transaction + select! { + result = event_loop_handle.send_encrypted_signature(state.tx_redeem_encsig()) => { + match result { + Ok(_) => BobState::EncSigSent(state), + Err(bmrng::error::RequestError::RecvError | bmrng::error::RequestError::SendError(_)) => bail!("Failed to communicate encrypted signature through event loop channel"), + Err(bmrng::error::RequestError::RecvTimeoutError) => unreachable!("We construct the channel with no timeout"), } + }, + result = tx_lock_status.wait_until_confirmed_with(state.cancel_timelock) => { + result?; + BobState::CancelTimelockExpired(state.cancel()) } - } else { - BobState::CancelTimelockExpired(state.cancel()) } } BobState::EncSigSent(state) => { @@ -291,32 +337,35 @@ async fn next_state( let tx_lock_status = bitcoin_wallet.subscribe_to(state.tx_lock.clone()).await; - if let ExpiredTimelocks::None { .. } = state.expired_timelock(bitcoin_wallet).await? { - select! { - state5 = state.watch_for_redeem_btc(bitcoin_wallet) => { - BobState::BtcRedeemed(state5?) - }, - result = tx_lock_status.wait_until_confirmed_with(state.cancel_timelock) => { - result?; - BobState::CancelTimelockExpired(state.cancel()) - } + if state + .expired_timelock(bitcoin_wallet) + .await? + .cancel_timelock_expired() + { + return Ok(BobState::CancelTimelockExpired(state.cancel())); + } + + select! { + state5 = state.watch_for_redeem_btc(bitcoin_wallet) => { + BobState::BtcRedeemed(state5?) + }, + result = tx_lock_status.wait_until_confirmed_with(state.cancel_timelock) => { + result?; + BobState::CancelTimelockExpired(state.cancel()) } - } else { - BobState::CancelTimelockExpired(state.cancel()) } } BobState::BtcRedeemed(state) => { event_emitter.emit_swap_progress_event(swap_id, TauriSwapProgressEvent::BtcRedeemed); - state + let xmr_redeem_txids = state .redeem_xmr(monero_wallet, swap_id.to_string(), monero_receive_address) .await?; event_emitter.emit_swap_progress_event( swap_id, TauriSwapProgressEvent::XmrRedeemInMempool { - // TODO: Replace this with the actual txid - xmr_redeem_txid: monero::TxHash("placeholder".to_string()), + xmr_redeem_txids, xmr_redeem_address: monero_receive_address, }, ); @@ -402,11 +451,11 @@ async fn next_state( .redeem_xmr(monero_wallet, swap_id.to_string(), monero_receive_address) .await { - Ok(_) => { + Ok(xmr_redeem_txids) => { event_emitter.emit_swap_progress_event( swap_id, TauriSwapProgressEvent::XmrRedeemInMempool { - xmr_redeem_txid: monero::TxHash("placeholder".to_string()), + xmr_redeem_txids, xmr_redeem_address: monero_receive_address, }, ); @@ -466,11 +515,12 @@ async fn next_state( } BobState::SafelyAborted => BobState::SafelyAborted, BobState::XmrRedeemed { tx_lock_id } => { - // TODO: Replace this with the actual txid event_emitter.emit_swap_progress_event( swap_id, TauriSwapProgressEvent::XmrRedeemInMempool { - xmr_redeem_txid: monero::TxHash("placeholder".to_string()), + // We don't have the txids of the redeem transaction here, so we can't emit them + // We return an empty array instead + xmr_redeem_txids: vec![], xmr_redeem_address: monero_receive_address, }, );