From 4deb96a3c5a7603be747a72cc8bdf7b17caaeb58 Mon Sep 17 00:00:00 2001 From: Daniel Karzel Date: Tue, 27 Apr 2021 14:51:53 +1000 Subject: [PATCH] ASB manual recovery commands Adds `cancel`, `refund`, `punish`, `redeem` and `safely-abort` commands to the ASB that can be used to trigger the specific scenario for the swap by ID. --- .github/workflows/ci.yml | 6 +- CHANGELOG.md | 6 + bors.toml | 6 +- swap/src/asb/command.rs | 37 ++++++ swap/src/bin/asb.rs | 30 ++++- swap/src/protocol/alice.rs | 4 + swap/src/protocol/alice/cancel.rs | 77 +++++++++++ swap/src/protocol/alice/refund.rs | 125 ++++++++++++++++++ swap/src/protocol/alice/state.rs | 52 +++++++- swap/src/protocol/alice/swap.rs | 37 ++---- swap/src/protocol/bob/refund.rs | 2 +- ...refund_using_cancel_and_refund_command.rs} | 43 +++++- ...and_refund_command_timelock_not_expired.rs | 109 +++++++++++++++ ...fund_command_timelock_not_expired_force.rs | 104 +++++++++++++++ ...and_refund_command_timelock_not_expired.rs | 54 -------- ...fund_command_timelock_not_expired_force.rs | 52 -------- 16 files changed, 601 insertions(+), 143 deletions(-) create mode 100644 swap/src/protocol/alice/cancel.rs create mode 100644 swap/src/protocol/alice/refund.rs rename swap/tests/{bob_refunds_using_cancel_and_refund_command.rs => alice_and_bob_refund_using_cancel_and_refund_command.rs} (59%) create mode 100644 swap/tests/alice_and_bob_refund_using_cancel_and_refund_command_timelock_not_expired.rs create mode 100644 swap/tests/alice_and_bob_refund_using_cancel_and_refund_command_timelock_not_expired_force.rs delete mode 100644 swap/tests/bob_refunds_using_cancel_and_refund_command_timelock_not_expired.rs delete mode 100644 swap/tests/bob_refunds_using_cancel_and_refund_command_timelock_not_expired_force.rs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e0ae31f7..73c717ad 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -100,9 +100,9 @@ jobs: happy_path_restart_bob_after_xmr_locked, happy_path_restart_bob_before_xmr_locked, happy_path_restart_alice_after_xmr_locked, - bob_refunds_using_cancel_and_refund_command, - bob_refunds_using_cancel_and_refund_command_timelock_not_expired, - bob_refunds_using_cancel_and_refund_command_timelock_not_expired_force, + alice_and_bob_refund_using_cancel_and_refund_command, + alice_and_bob_refund_using_cancel_and_refund_command_timelock_not_expired, + alice_and_bob_refund_using_cancel_and_refund_command_timelock_not_expired_force, punish, alice_punishes_after_restart_punish_timelock_expired, alice_refunds_after_restart_bob_refunded, diff --git a/CHANGELOG.md b/CHANGELOG.md index 37152d5a..aa66b285 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- Cancel command for the ASB that allows cancelling a specific swap by id. + Using the cancel command requires the cancel timelock to be expired, but `--force` can be used to circumvent this check. +- Refund command for the ASB that allows refunding a specific swap by id. + Using the refund command to refund the XMR locked by the ASB requires the CLI to first refund the BTC of the swap. + If the BTC was not refunded yet the command will print an error accordingly. + The command has a `--force` flag that allows executing the command without checking for cancel constraints. - Resume-only mode for the ASB. When started with `--resume-only` the ASB does not accept new, incoming swap requests but only finishes swaps that are resumed upon startup. diff --git a/bors.toml b/bors.toml index 04973080..77501d38 100644 --- a/bors.toml +++ b/bors.toml @@ -10,9 +10,9 @@ status = [ "docker_tests (happy_path_restart_bob_after_xmr_locked)", "docker_tests (happy_path_restart_alice_after_xmr_locked)", "docker_tests (happy_path_restart_bob_before_xmr_locked)", - "docker_tests (bob_refunds_using_cancel_and_refund_command)", - "docker_tests (bob_refunds_using_cancel_and_refund_command_timelock_not_expired_force)", - "docker_tests (bob_refunds_using_cancel_and_refund_command_timelock_not_expired)", + "docker_tests (alice_and_bob_refund_using_cancel_and_refund_command)", + "docker_tests (alice_and_bob_refund_using_cancel_and_refund_command_timelock_not_expired)", + "docker_tests (alice_and_bob_refund_using_cancel_and_refund_command_timelock_not_expired_force)", "docker_tests (punish)", "docker_tests (alice_punishes_after_restart_punish_timelock_expired)", "docker_tests (alice_refunds_after_restart_bob_refunded)", diff --git a/swap/src/asb/command.rs b/swap/src/asb/command.rs index 6254d6c0..19f140a1 100644 --- a/swap/src/asb/command.rs +++ b/swap/src/asb/command.rs @@ -3,6 +3,7 @@ use bitcoin::util::amount::ParseAmountError; use bitcoin::{Address, Denomination}; use rust_decimal::Decimal; use std::path::PathBuf; +use uuid::Uuid; #[derive(structopt::StructOpt, Debug)] #[structopt( @@ -58,6 +59,42 @@ pub enum Command { about = "Prints the Bitcoin and Monero balance. Requires the monero-wallet-rpc to be running." )] Balance, + #[structopt(about = "Contains sub-commands for recovering a swap manually.")] + ManualRecovery(ManualRecovery), +} + +#[derive(structopt::StructOpt, Debug)] +pub enum ManualRecovery { + #[structopt( + about = "Publishes the Bitcoin cancel transaction. By default, the cancel timelock will be enforced. A confirmed cancel transaction enables refund and punish." + )] + Cancel { + #[structopt(flatten)] + cancel_params: RecoverCommandParams, + }, + #[structopt( + about = "Publishes the Monero refund transaction. By default, a swap-state where the cancel transaction was already published will be enforced. This command requires the counterparty Bitcoin refund transaction and will error if it was not published yet. " + )] + Refund { + #[structopt(flatten)] + refund_params: RecoverCommandParams, + }, +} + +#[derive(structopt::StructOpt, Debug)] +pub struct RecoverCommandParams { + #[structopt( + long = "swap-id", + help = "The swap id can be retrieved using the history subcommand" + )] + pub swap_id: Uuid, + + #[structopt( + short, + long, + help = "Circumvents certain checks when recovering. It is recommended to run a recovery command without --force first to see what is returned." + )] + pub force: bool, } fn parse_btc(s: &str) -> Result { diff --git a/swap/src/bin/asb.rs b/swap/src/bin/asb.rs index 5d099055..9a5d920f 100644 --- a/swap/src/bin/asb.rs +++ b/swap/src/bin/asb.rs @@ -20,7 +20,7 @@ use prettytable::{row, Table}; use std::net::{IpAddr, Ipv4Addr, SocketAddr}; use std::sync::Arc; use structopt::StructOpt; -use swap::asb::command::{Arguments, Command}; +use swap::asb::command::{Arguments, Command, ManualRecovery, RecoverCommandParams}; use swap::asb::config::{ default_config_path, initial_setup, query_user_for_initial_testnet_config, read_config, Config, ConfigNotInitialized, @@ -29,6 +29,7 @@ use swap::database::Database; use swap::env::GetConfig; use swap::monero::Amount; use swap::network::swarm; +use swap::protocol::alice; use swap::protocol::alice::event_loop::KrakenRate; use swap::protocol::alice::{run, EventLoop}; use swap::seed::Seed; @@ -205,6 +206,33 @@ async fn main() -> Result<()> { tracing::info!("Current balance: {}, {}", bitcoin_balance, monero_balance); } + Command::ManualRecovery(ManualRecovery::Cancel { + cancel_params: RecoverCommandParams { swap_id, force }, + }) => { + let bitcoin_wallet = init_bitcoin_wallet(&config, &seed, env_config).await?; + + let (txid, _) = + alice::cancel(swap_id, Arc::new(bitcoin_wallet), Arc::new(db), force).await??; + + tracing::info!("Cancel transaction successfully published with id {}", txid); + } + Command::ManualRecovery(ManualRecovery::Refund { + refund_params: RecoverCommandParams { swap_id, force }, + }) => { + let bitcoin_wallet = init_bitcoin_wallet(&config, &seed, env_config).await?; + let monero_wallet = init_monero_wallet(&config, env_config).await?; + + alice::refund( + swap_id, + Arc::new(bitcoin_wallet), + Arc::new(monero_wallet), + Arc::new(db), + force, + ) + .await??; + + tracing::info!("Monero successfully refunded"); + } }; Ok(()) diff --git a/swap/src/protocol/alice.rs b/swap/src/protocol/alice.rs index 2410e289..d21617e1 100644 --- a/swap/src/protocol/alice.rs +++ b/swap/src/protocol/alice.rs @@ -7,13 +7,17 @@ use std::sync::Arc; use uuid::Uuid; pub use self::behaviour::{Behaviour, OutEvent}; +pub use self::cancel::cancel; pub use self::event_loop::{EventLoop, EventLoopHandle}; +pub use self::refund::refund; pub use self::state::*; pub use self::swap::{run, run_until}; mod behaviour; +pub mod cancel; pub mod event_loop; mod execution_setup; +pub mod refund; mod spot_price; pub mod state; pub mod swap; diff --git a/swap/src/protocol/alice/cancel.rs b/swap/src/protocol/alice/cancel.rs new file mode 100644 index 00000000..cfa1a085 --- /dev/null +++ b/swap/src/protocol/alice/cancel.rs @@ -0,0 +1,77 @@ +use crate::bitcoin::{ExpiredTimelocks, Txid, Wallet}; +use crate::database::{Database, Swap}; +use crate::protocol::alice::AliceState; +use anyhow::{bail, Result}; +use std::sync::Arc; +use uuid::Uuid; + +#[derive(Debug, thiserror::Error, Clone, Copy)] +pub enum Error { + #[error("The cancel transaction cannot be published because the cancel timelock has not expired yet. Please try again later")] + CancelTimelockNotExpiredYet, +} + +pub async fn cancel( + swap_id: Uuid, + bitcoin_wallet: Arc, + db: Arc, + force: bool, +) -> Result> { + let state = db.get_state(swap_id)?.try_into_alice()?.into(); + + let (monero_wallet_restore_blockheight, transfer_proof, state3) = match state { + + // In case no XMR has been locked, move to Safely Aborted + AliceState::Started { .. } + | AliceState::BtcLocked { .. } => bail!("Cannot cancel swap {} because it is in state {} where no XMR was locked.", swap_id, state), + + AliceState::XmrLockTransactionSent { monero_wallet_restore_blockheight, transfer_proof, state3, } + | AliceState::XmrLocked { monero_wallet_restore_blockheight, transfer_proof, state3 } + | AliceState::XmrLockTransferProofSent { monero_wallet_restore_blockheight, transfer_proof, state3 } + // in cancel mode we do not care about the fact that we could redeem, but always wait for cancellation (leading either refund or punish) + | AliceState::EncSigLearned { monero_wallet_restore_blockheight, transfer_proof, state3, .. } + | AliceState::CancelTimelockExpired { monero_wallet_restore_blockheight, transfer_proof, state3} => { + (monero_wallet_restore_blockheight, transfer_proof, state3) + } + + // The cancel tx was already published, but Alice not yet in final state + AliceState::BtcCancelled { .. } + | AliceState::BtcRefunded { .. } + | AliceState::BtcPunishable { .. } + + // Alice already in final state + | AliceState::BtcRedeemed + | AliceState::XmrRefunded + | AliceState::BtcPunished + | AliceState::SafelyAborted => bail!("Cannot cancel swap {} because it is in state {} which is not cancelable", swap_id, state), + }; + + tracing::info!(%swap_id, "Trying to manually cancel swap"); + + if !force { + tracing::debug!(%swap_id, "Checking if cancel timelock is expired"); + + if let ExpiredTimelocks::None = state3.expired_timelocks(bitcoin_wallet.as_ref()).await? { + return Ok(Err(Error::CancelTimelockNotExpiredYet)); + } + } + + let txid = if let Ok(tx) = state3.check_for_tx_cancel(bitcoin_wallet.as_ref()).await { + let txid = tx.txid(); + tracing::debug!(%swap_id, "Cancel transaction has already been published: {}", txid); + txid + } else { + state3.submit_tx_cancel(bitcoin_wallet.as_ref()).await? + }; + + let state = AliceState::BtcCancelled { + monero_wallet_restore_blockheight, + transfer_proof, + state3, + }; + let db_state = (&state).into(); + db.insert_latest_state(swap_id, Swap::Alice(db_state)) + .await?; + + Ok(Ok((txid, state))) +} diff --git a/swap/src/protocol/alice/refund.rs b/swap/src/protocol/alice/refund.rs new file mode 100644 index 00000000..2f0fe86f --- /dev/null +++ b/swap/src/protocol/alice/refund.rs @@ -0,0 +1,125 @@ +use crate::bitcoin::{self}; +use crate::database::{Database, Swap}; +use crate::monero; +use crate::protocol::alice::AliceState; +use anyhow::{bail, Result}; +use libp2p::PeerId; +use std::sync::Arc; +use uuid::Uuid; + +#[derive(Debug, thiserror::Error)] +pub enum Error { + // Errors indicating the the swap can *currently* not be refunded but might be later + #[error("Swap is not in a cancelled state. Make sure to cancel the swap before trying to refund or use --force.")] + SwapNotCancelled, + #[error( + "Counterparty {0} did not refund the BTC yet. You can try again later or try to punish." + )] + RefundTransactionNotPublishedYet(PeerId), + + // Errors indicating that the swap cannot be refunded because because it is in a abort/final + // state + #[error("Swa is in state {0} where no XMR was locked. Try aborting instead.")] + NoXmrLocked(AliceState), + #[error("Swap is in state {0} which is not refundable")] + SwapNotRefundable(AliceState), +} + +pub async fn refund( + swap_id: Uuid, + bitcoin_wallet: Arc, + monero_wallet: Arc, + db: Arc, + force: bool, +) -> Result> { + let state = db.get_state(swap_id)?.try_into_alice()?.into(); + + let (monero_wallet_restore_blockheight, transfer_proof, state3) = if force { + match state { + + // In case no XMR has been locked, move to Safely Aborted + AliceState::Started { .. } + | AliceState::BtcLocked { .. } => bail!(Error::NoXmrLocked(state)), + + // Refund potentially possible (no knowledge of cancel transaction) + AliceState::XmrLockTransactionSent { monero_wallet_restore_blockheight, transfer_proof, state3, } + | AliceState::XmrLocked { monero_wallet_restore_blockheight, transfer_proof, state3 } + | AliceState::XmrLockTransferProofSent { monero_wallet_restore_blockheight, transfer_proof, state3 } + | AliceState::EncSigLearned { monero_wallet_restore_blockheight, transfer_proof, state3, .. } + | AliceState::CancelTimelockExpired { monero_wallet_restore_blockheight, transfer_proof, state3 } + + // Refund possible due to cancel transaction already being published + | AliceState::BtcCancelled { monero_wallet_restore_blockheight, transfer_proof, state3 } + | AliceState::BtcRefunded { monero_wallet_restore_blockheight, transfer_proof, state3, .. } + | AliceState::BtcPunishable { monero_wallet_restore_blockheight, transfer_proof, state3, .. } => { + (monero_wallet_restore_blockheight, transfer_proof, state3) + } + + // Alice already in final state + AliceState::BtcRedeemed + | AliceState::XmrRefunded + | AliceState::BtcPunished + | AliceState::SafelyAborted => bail!(Error::SwapNotRefundable(state)), + } + } else { + match state { + AliceState::Started { .. } | AliceState::BtcLocked { .. } => { + bail!(Error::NoXmrLocked(state)) + } + + AliceState::BtcCancelled { + monero_wallet_restore_blockheight, + transfer_proof, + state3, + } + | AliceState::BtcRefunded { + monero_wallet_restore_blockheight, + transfer_proof, + state3, + .. + } + | AliceState::BtcPunishable { + monero_wallet_restore_blockheight, + transfer_proof, + state3, + .. + } => (monero_wallet_restore_blockheight, transfer_proof, state3), + + AliceState::BtcRedeemed + | AliceState::XmrRefunded + | AliceState::BtcPunished + | AliceState::SafelyAborted => bail!(Error::SwapNotRefundable(state)), + + _ => return Ok(Err(Error::SwapNotCancelled)), + } + }; + + tracing::info!(%swap_id, "Trying to manually refund swap"); + + let spend_key = if let Ok(published_refund_tx) = + state3.fetch_tx_refund(bitcoin_wallet.as_ref()).await + { + tracing::debug!(%swap_id, "Bitcoin refund transaction found, extracting key to refund Monero"); + state3.extract_monero_private_key(published_refund_tx)? + } else { + let bob_peer_id = db.get_peer_id(swap_id)?; + return Ok(Err(Error::RefundTransactionNotPublishedYet(bob_peer_id))); + }; + + state3 + .refund_xmr( + &monero_wallet, + monero_wallet_restore_blockheight, + swap_id.to_string(), + spend_key, + transfer_proof, + ) + .await?; + + let state = AliceState::XmrRefunded; + let db_state = (&state).into(); + db.insert_latest_state(swap_id, Swap::Alice(db_state)) + .await?; + + Ok(Ok(state)) +} diff --git a/swap/src/protocol/alice/state.rs b/swap/src/protocol/alice/state.rs index b091ebb4..3840d28f 100644 --- a/swap/src/protocol/alice/state.rs +++ b/swap/src/protocol/alice/state.rs @@ -1,5 +1,6 @@ use crate::bitcoin::{ - current_epoch, CancelTimelock, ExpiredTimelocks, PunishTimelock, TxCancel, TxPunish, TxRefund, + current_epoch, CancelTimelock, ExpiredTimelocks, PunishTimelock, Transaction, TxCancel, + TxPunish, TxRefund, Txid, }; use crate::env::Config; use crate::monero::wallet::{TransferRequest, WatchRequest}; @@ -460,6 +461,55 @@ impl State3 { ) } + pub async fn check_for_tx_cancel( + &self, + bitcoin_wallet: &bitcoin::Wallet, + ) -> Result { + let tx_cancel = self.tx_cancel(); + let tx = bitcoin_wallet.get_raw_transaction(tx_cancel.txid()).await?; + Ok(tx) + } + + pub async fn fetch_tx_refund(&self, bitcoin_wallet: &bitcoin::Wallet) -> Result { + let tx_refund = self.tx_refund(); + let tx = bitcoin_wallet.get_raw_transaction(tx_refund.txid()).await?; + Ok(tx) + } + + pub async fn submit_tx_cancel(&self, bitcoin_wallet: &bitcoin::Wallet) -> Result { + let transaction = self.signed_cancel_transaction()?; + let (tx_id, _) = bitcoin_wallet.broadcast(transaction, "cancel").await?; + Ok(tx_id) + } + + pub async fn refund_xmr( + &self, + monero_wallet: &monero::Wallet, + monero_wallet_restore_blockheight: BlockHeight, + file_name: String, + spend_key: monero::PrivateKey, + transfer_proof: TransferProof, + ) -> Result<()> { + let view_key = self.v; + + // Ensure that the XMR to be refunded are spendable by awaiting 10 confirmations + // on the lock transaction + monero_wallet + .watch_for_transfer(self.lock_xmr_watch_request(transfer_proof, 10)) + .await?; + + monero_wallet + .create_from( + file_name, + spend_key, + view_key, + monero_wallet_restore_blockheight, + ) + .await?; + + Ok(()) + } + pub fn signed_redeem_transaction( &self, sig: bitcoin::EncryptedSignature, diff --git a/swap/src/protocol/alice/swap.rs b/swap/src/protocol/alice/swap.rs index 6f97f2c0..1b90d9f7 100644 --- a/swap/src/protocol/alice/swap.rs +++ b/swap/src/protocol/alice/swap.rs @@ -229,23 +229,17 @@ async fn next_state( transfer_proof, state3, } => { - let transaction = state3.signed_cancel_transaction()?; - - // If Bob hasn't yet broadcasted the tx cancel, we do it - if bitcoin_wallet - .get_raw_transaction(transaction.txid()) - .await - .is_err() - { - if let Err(e) = bitcoin_wallet.broadcast(transaction, "cancel").await { + if state3.check_for_tx_cancel(bitcoin_wallet).await.is_err() { + // If Bob hasn't yet broadcasted the cancel transaction, Alice has to publish it + // to be able to eventually punish. Since the punish timelock is + // relative to the publication of the cancel transaction we have to ensure it + // gets published once the cancel timelock expires. + if let Err(e) = state3.submit_tx_cancel(bitcoin_wallet).await { tracing::debug!( - "Assuming transaction is already broadcasted because: {:#}", + "Assuming cancel transaction is already broadcasted because: {:#}", e ) } - - // TODO(Franck): Wait until transaction is mined and - // returned mined block height } AliceState::BtcCancelled { @@ -291,20 +285,13 @@ async fn next_state( spend_key, state3, } => { - let view_key = state3.v; - - // Ensure that the XMR to be refunded are spendable by awaiting 10 confirmations - // on the lock transaction - monero_wallet - .watch_for_transfer(state3.lock_xmr_watch_request(transfer_proof, 10)) - .await?; - - monero_wallet - .create_from( + state3 + .refund_xmr( + monero_wallet, + monero_wallet_restore_blockheight, swap_id.to_string(), spend_key, - view_key, - monero_wallet_restore_blockheight, + transfer_proof, ) .await?; diff --git a/swap/src/protocol/bob/refund.rs b/swap/src/protocol/bob/refund.rs index 492f8191..2fe324ce 100644 --- a/swap/src/protocol/bob/refund.rs +++ b/swap/src/protocol/bob/refund.rs @@ -7,7 +7,7 @@ use uuid::Uuid; #[derive(thiserror::Error, Debug, Clone, Copy)] #[error("Cannot refund because swap {0} was not cancelled yet. Make sure to cancel the swap before trying to refund.")] -pub struct SwapNotCancelledYet(Uuid); +pub struct SwapNotCancelledYet(pub Uuid); pub async fn refund( swap_id: Uuid, diff --git a/swap/tests/bob_refunds_using_cancel_and_refund_command.rs b/swap/tests/alice_and_bob_refund_using_cancel_and_refund_command.rs similarity index 59% rename from swap/tests/bob_refunds_using_cancel_and_refund_command.rs rename to swap/tests/alice_and_bob_refund_using_cancel_and_refund_command.rs index 50e99fd9..d342d8e7 100644 --- a/swap/tests/bob_refunds_using_cancel_and_refund_command.rs +++ b/swap/tests/alice_and_bob_refund_using_cancel_and_refund_command.rs @@ -1,23 +1,31 @@ pub mod harness; +use harness::alice_run_until::is_xmr_lock_transaction_sent; use harness::bob_run_until::is_btc_locked; use harness::FastCancelConfig; +use swap::protocol::alice::AliceState; use swap::protocol::bob::BobState; use swap::protocol::{alice, bob}; #[tokio::test] -async fn given_bob_manually_refunds_after_btc_locked_bob_refunds() { +async fn given_alice_and_bob_manually_refund_after_funds_locked_both_refund() { harness::setup_test(FastCancelConfig, |mut ctx| async move { let (bob_swap, bob_join_handle) = ctx.bob_swap().await; let bob_swap_id = bob_swap.id; let bob_swap = tokio::spawn(bob::run_until(bob_swap, is_btc_locked)); let alice_swap = ctx.alice_next_swap().await; - let alice_swap = tokio::spawn(alice::run(alice_swap)); + let alice_swap = tokio::spawn(alice::run_until(alice_swap, is_xmr_lock_transaction_sent)); let bob_state = bob_swap.await??; assert!(matches!(bob_state, BobState::BtcLocked { .. })); + let alice_state = alice_swap.await??; + assert!(matches!( + alice_state, + AliceState::XmrLockTransactionSent { .. } + )); + let (bob_swap, bob_join_handle) = ctx .stop_and_resume_bob_from_db(bob_join_handle, bob_swap_id) .await; @@ -52,7 +60,36 @@ async fn given_bob_manually_refunds_after_btc_locked_bob_refunds() { ctx.assert_bob_refunded(bob_state).await; - let alice_state = alice_swap.await??; + // manually cancel ALice's swap (effectively just notice that Bob already + // cancelled and record that) + ctx.restart_alice().await; + let alice_swap = ctx.alice_next_swap().await; + assert!(matches!( + alice_swap.state, + AliceState::XmrLockTransactionSent { .. } + )); + + alice::cancel( + alice_swap.swap_id, + alice_swap.bitcoin_wallet, + alice_swap.db, + false, + ) + .await??; + + // manually refund ALice's swap + ctx.restart_alice().await; + let alice_swap = ctx.alice_next_swap().await; + assert!(matches!(alice_swap.state, AliceState::BtcCancelled { .. })); + let alice_state = alice::refund( + alice_swap.swap_id, + alice_swap.bitcoin_wallet, + alice_swap.monero_wallet, + alice_swap.db, + false, + ) + .await??; + ctx.assert_alice_refunded(alice_state).await; Ok(()) diff --git a/swap/tests/alice_and_bob_refund_using_cancel_and_refund_command_timelock_not_expired.rs b/swap/tests/alice_and_bob_refund_using_cancel_and_refund_command_timelock_not_expired.rs new file mode 100644 index 00000000..bd0761e3 --- /dev/null +++ b/swap/tests/alice_and_bob_refund_using_cancel_and_refund_command_timelock_not_expired.rs @@ -0,0 +1,109 @@ +pub mod harness; + +use harness::alice_run_until::is_xmr_lock_transaction_sent; +use harness::bob_run_until::is_btc_locked; +use harness::SlowCancelConfig; +use swap::protocol::alice::AliceState; +use swap::protocol::bob::BobState; +use swap::protocol::{alice, bob}; + +#[tokio::test] +async fn given_alice_and_bob_manually_cancel_when_timelock_not_expired_errors() { + harness::setup_test(SlowCancelConfig, |mut ctx| async move { + let (bob_swap, bob_join_handle) = ctx.bob_swap().await; + let swap_id = bob_swap.id; + let bob_swap = tokio::spawn(bob::run_until(bob_swap, is_btc_locked)); + + let alice_swap = ctx.alice_next_swap().await; + let alice_swap = tokio::spawn(alice::run_until(alice_swap, is_xmr_lock_transaction_sent)); + + let bob_state = bob_swap.await??; + assert!(matches!(bob_state, BobState::BtcLocked { .. })); + + let (bob_swap, bob_join_handle) = ctx + .stop_and_resume_bob_from_db(bob_join_handle, swap_id) + .await; + assert!(matches!(bob_swap.state, BobState::BtcLocked { .. })); + + let alice_state = alice_swap.await??; + assert!(matches!( + alice_state, + AliceState::XmrLockTransactionSent { .. } + )); + + // Bob tries but fails to manually cancel + let result = bob::cancel(bob_swap.id, bob_swap.bitcoin_wallet, bob_swap.db, false) + .await? + .unwrap_err(); + assert!(matches!( + result, + bob::cancel::Error::CancelTimelockNotExpiredYet + )); + + ctx.restart_alice().await; + let alice_swap = ctx.alice_next_swap().await; + assert!(matches!( + alice_swap.state, + AliceState::XmrLockTransactionSent { .. } + )); + + // Alice tries but fails manual cancel + let result = alice::cancel( + alice_swap.swap_id, + alice_swap.bitcoin_wallet, + alice_swap.db, + false, + ) + .await? + .unwrap_err(); + assert!(matches!( + result, + alice::cancel::Error::CancelTimelockNotExpiredYet + )); + + let (bob_swap, bob_join_handle) = ctx + .stop_and_resume_bob_from_db(bob_join_handle, swap_id) + .await; + assert!(matches!(bob_swap.state, BobState::BtcLocked { .. })); + + // Bob tries but fails to manually refund + let result = bob::refund(bob_swap.id, bob_swap.bitcoin_wallet, bob_swap.db, false) + .await? + .unwrap_err(); + assert!(matches!(result, bob::refund::SwapNotCancelledYet(_))); + + let (bob_swap, _) = ctx + .stop_and_resume_bob_from_db(bob_join_handle, swap_id) + .await; + assert!(matches!(bob_swap.state, BobState::BtcLocked { .. })); + + ctx.restart_alice().await; + let alice_swap = ctx.alice_next_swap().await; + assert!(matches!( + alice_swap.state, + AliceState::XmrLockTransactionSent { .. } + )); + + // Alice tries but fails manual cancel + let result = alice::refund( + alice_swap.swap_id, + alice_swap.bitcoin_wallet, + alice_swap.monero_wallet, + alice_swap.db, + false, + ) + .await? + .unwrap_err(); + assert!(matches!(result, alice::refund::Error::SwapNotCancelled)); + + ctx.restart_alice().await; + let alice_swap = ctx.alice_next_swap().await; + assert!(matches!( + alice_swap.state, + AliceState::XmrLockTransactionSent { .. } + )); + + Ok(()) + }) + .await; +} diff --git a/swap/tests/alice_and_bob_refund_using_cancel_and_refund_command_timelock_not_expired_force.rs b/swap/tests/alice_and_bob_refund_using_cancel_and_refund_command_timelock_not_expired_force.rs new file mode 100644 index 00000000..59f5141e --- /dev/null +++ b/swap/tests/alice_and_bob_refund_using_cancel_and_refund_command_timelock_not_expired_force.rs @@ -0,0 +1,104 @@ +pub mod harness; + +use harness::alice_run_until::is_xmr_lock_transaction_sent; +use harness::bob_run_until::is_btc_locked; +use harness::SlowCancelConfig; +use swap::protocol::alice::AliceState; +use swap::protocol::bob::BobState; +use swap::protocol::{alice, bob}; + +#[tokio::test] +async fn given_alice_and_bob_manually_force_cancel_when_timelock_not_expired_errors() { + harness::setup_test(SlowCancelConfig, |mut ctx| async move { + let (bob_swap, bob_join_handle) = ctx.bob_swap().await; + let swap_id = bob_swap.id; + let bob_swap = tokio::spawn(bob::run_until(bob_swap, is_btc_locked)); + + let alice_swap = ctx.alice_next_swap().await; + let alice_swap = tokio::spawn(alice::run_until(alice_swap, is_xmr_lock_transaction_sent)); + + let bob_state = bob_swap.await??; + assert!(matches!(bob_state, BobState::BtcLocked { .. })); + + let (bob_swap, bob_join_handle) = ctx + .stop_and_resume_bob_from_db(bob_join_handle, swap_id) + .await; + assert!(matches!(bob_swap.state, BobState::BtcLocked { .. })); + + let alice_state = alice_swap.await??; + assert!(matches!( + alice_state, + AliceState::XmrLockTransactionSent { .. } + )); + + // Bob tries but fails to manually cancel + let result = bob::cancel(bob_swap.id, bob_swap.bitcoin_wallet, bob_swap.db, true).await; + assert!(matches!(result, Err(_))); + + ctx.restart_alice().await; + let alice_swap = ctx.alice_next_swap().await; + assert!(matches!( + alice_swap.state, + AliceState::XmrLockTransactionSent { .. } + )); + + // Alice tries but fails manual cancel + let is_outer_err = alice::cancel( + alice_swap.swap_id, + alice_swap.bitcoin_wallet, + alice_swap.db, + true, + ) + .await + .is_err(); + assert!(is_outer_err); + + let (bob_swap, bob_join_handle) = ctx + .stop_and_resume_bob_from_db(bob_join_handle, swap_id) + .await; + assert!(matches!(bob_swap.state, BobState::BtcLocked { .. })); + + // Bob tries but fails to manually refund + let is_outer_err = bob::refund(bob_swap.id, bob_swap.bitcoin_wallet, bob_swap.db, true) + .await + .is_err(); + assert!(is_outer_err); + + let (bob_swap, _) = ctx + .stop_and_resume_bob_from_db(bob_join_handle, swap_id) + .await; + assert!(matches!(bob_swap.state, BobState::BtcLocked { .. })); + + ctx.restart_alice().await; + let alice_swap = ctx.alice_next_swap().await; + assert!(matches!( + alice_swap.state, + AliceState::XmrLockTransactionSent { .. } + )); + + // Alice tries but fails manual cancel + let refund_tx_not_published_yet = alice::refund( + alice_swap.swap_id, + alice_swap.bitcoin_wallet, + alice_swap.monero_wallet, + alice_swap.db, + true, + ) + .await? + .unwrap_err(); + assert!(matches!( + refund_tx_not_published_yet, + alice::refund::Error::RefundTransactionNotPublishedYet(..) + )); + + ctx.restart_alice().await; + let alice_swap = ctx.alice_next_swap().await; + assert!(matches!( + alice_swap.state, + AliceState::XmrLockTransactionSent { .. } + )); + + Ok(()) + }) + .await; +} diff --git a/swap/tests/bob_refunds_using_cancel_and_refund_command_timelock_not_expired.rs b/swap/tests/bob_refunds_using_cancel_and_refund_command_timelock_not_expired.rs deleted file mode 100644 index 152c74c7..00000000 --- a/swap/tests/bob_refunds_using_cancel_and_refund_command_timelock_not_expired.rs +++ /dev/null @@ -1,54 +0,0 @@ -pub mod harness; - -use bob::cancel::Error; -use harness::bob_run_until::is_btc_locked; -use harness::SlowCancelConfig; -use swap::protocol::bob::BobState; -use swap::protocol::{alice, bob}; - -#[tokio::test] -async fn given_bob_manually_cancels_when_timelock_not_expired_errors() { - harness::setup_test(SlowCancelConfig, |mut ctx| async move { - let (bob_swap, bob_join_handle) = ctx.bob_swap().await; - let bob_swap_id = bob_swap.id; - let bob_swap = tokio::spawn(bob::run_until(bob_swap, is_btc_locked)); - - let alice_swap = ctx.alice_next_swap().await; - let _ = tokio::spawn(alice::run(alice_swap)); - - let bob_state = bob_swap.await??; - assert!(matches!(bob_state, BobState::BtcLocked { .. })); - - let (bob_swap, bob_join_handle) = ctx - .stop_and_resume_bob_from_db(bob_join_handle, bob_swap_id) - .await; - assert!(matches!(bob_swap.state, BobState::BtcLocked { .. })); - - // Bob tries but fails to manually cancel - let result = bob::cancel(bob_swap.id, bob_swap.bitcoin_wallet, bob_swap.db, false) - .await? - .err() - .unwrap(); - - assert!(matches!(result, Error::CancelTimelockNotExpiredYet)); - - let (bob_swap, bob_join_handle) = ctx - .stop_and_resume_bob_from_db(bob_join_handle, bob_swap_id) - .await; - assert!(matches!(bob_swap.state, BobState::BtcLocked { .. })); - - // Bob tries but fails to manually refund - bob::refund(bob_swap.id, bob_swap.bitcoin_wallet, bob_swap.db, false) - .await? - .err() - .unwrap(); - - let (bob_swap, _) = ctx - .stop_and_resume_bob_from_db(bob_join_handle, bob_swap_id) - .await; - assert!(matches!(bob_swap.state, BobState::BtcLocked { .. })); - - Ok(()) - }) - .await; -} diff --git a/swap/tests/bob_refunds_using_cancel_and_refund_command_timelock_not_expired_force.rs b/swap/tests/bob_refunds_using_cancel_and_refund_command_timelock_not_expired_force.rs deleted file mode 100644 index 85f4de88..00000000 --- a/swap/tests/bob_refunds_using_cancel_and_refund_command_timelock_not_expired_force.rs +++ /dev/null @@ -1,52 +0,0 @@ -pub mod harness; - -use harness::bob_run_until::is_btc_locked; -use harness::SlowCancelConfig; -use swap::protocol::bob::BobState; -use swap::protocol::{alice, bob}; - -#[tokio::test] -async fn given_bob_manually_forces_cancel_when_timelock_not_expired_errors() { - harness::setup_test(SlowCancelConfig, |mut ctx| async move { - let (bob_swap, bob_join_handle) = ctx.bob_swap().await; - let bob_swap_id = bob_swap.id; - let bob_swap = tokio::spawn(bob::run_until(bob_swap, is_btc_locked)); - - let alice_swap = ctx.alice_next_swap().await; - let _ = tokio::spawn(alice::run(alice_swap)); - - let bob_state = bob_swap.await??; - assert!(matches!(bob_state, BobState::BtcLocked { .. })); - - let (bob_swap, bob_join_handle) = ctx - .stop_and_resume_bob_from_db(bob_join_handle, bob_swap_id) - .await; - assert!(matches!(bob_swap.state, BobState::BtcLocked { .. })); - - // Bob forces a cancel that will fail - let is_error = bob::cancel(bob_swap.id, bob_swap.bitcoin_wallet, bob_swap.db, true) - .await - .is_err(); - - assert!(is_error); - - let (bob_swap, bob_join_handle) = ctx - .stop_and_resume_bob_from_db(bob_join_handle, bob_swap_id) - .await; - assert!(matches!(bob_swap.state, BobState::BtcLocked { .. })); - - // Bob forces a refund that will fail - let is_error = bob::refund(bob_swap.id, bob_swap.bitcoin_wallet, bob_swap.db, true) - .await - .is_err(); - - assert!(is_error); - let (bob_swap, _) = ctx - .stop_and_resume_bob_from_db(bob_join_handle, bob_swap_id) - .await; - assert!(matches!(bob_swap.state, BobState::BtcLocked { .. })); - - Ok(()) - }) - .await; -}