You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
wow-btc-swap/swap/src/protocol/alice/swap.rs

353 lines
14 KiB

//! Run an XMR/BTC swap in the role of Alice.
//! Alice holds XMR and wishes receive BTC.
use crate::bitcoin::ExpiredTimelocks;
use crate::env::Config;
use crate::protocol::alice::event_loop::EventLoopHandle;
use crate::protocol::alice::{AliceState, Swap};
use crate::{bitcoin, database, monero};
use anyhow::{bail, Context, Result};
use tokio::select;
use tokio::time::timeout;
use tracing::{error, info};
use uuid::Uuid;
pub async fn run(swap: Swap) -> Result<AliceState> {
run_until(swap, |_| false).await
}
#[tracing::instrument(name = "swap", skip(swap,exit_early), fields(id = %swap.swap_id), err)]
pub async fn run_until(mut swap: Swap, exit_early: fn(&AliceState) -> bool) -> Result<AliceState> {
let mut current_state = swap.state;
while !is_complete(&current_state) && !exit_early(&current_state) {
current_state = next_state(
swap.swap_id,
current_state,
&mut swap.event_loop_handle,
swap.bitcoin_wallet.as_ref(),
swap.monero_wallet.as_ref(),
&swap.env_config,
)
.await?;
let db_state = (&current_state).into();
swap.db
.insert_latest_state(swap.swap_id, database::Swap::Alice(db_state))
.await?;
}
Ok(current_state)
}
async fn next_state(
swap_id: Uuid,
state: AliceState,
event_loop_handle: &mut EventLoopHandle,
bitcoin_wallet: &bitcoin::Wallet,
monero_wallet: &monero::Wallet,
env_config: &Config,
) -> Result<AliceState> {
info!("Current state: {}", state);
Ok(match state {
AliceState::Started { state3 } => {
let tx_lock_status = bitcoin_wallet.subscribe_to(state3.tx_lock.clone()).await;
match timeout(
env_config.bitcoin_lock_confirmed_timeout,
tx_lock_status.wait_until_final(),
)
.await
{
Err(_) => {
tracing::info!(
"TxLock lock did not get {} confirmations in {} minutes",
env_config.bitcoin_finality_confirmations,
env_config.bitcoin_lock_confirmed_timeout.as_secs_f64() / 60.0
);
AliceState::SafelyAborted
}
Ok(res) => {
res?;
AliceState::BtcLocked { state3 }
}
}
}
AliceState::BtcLocked { state3 } => {
match state3.expired_timelocks(bitcoin_wallet).await? {
ExpiredTimelocks::None => {
// Record the current monero wallet block height so we don't have to scan from
// block 0 for scenarios where we create a refund wallet.
let monero_wallet_restore_blockheight = monero_wallet.block_height().await?;
let transfer_proof = monero_wallet
.transfer(state3.lock_xmr_transfer_request())
.await?;
AliceState::XmrLockTransactionSent {
monero_wallet_restore_blockheight,
transfer_proof,
state3,
}
}
_ => AliceState::SafelyAborted,
}
}
AliceState::XmrLockTransactionSent {
monero_wallet_restore_blockheight,
transfer_proof,
state3,
} => match state3.expired_timelocks(bitcoin_wallet).await? {
ExpiredTimelocks::None => {
monero_wallet
.watch_for_transfer(state3.lock_xmr_watch_request(transfer_proof.clone(), 1))
.await
.with_context(|| {
format!(
"Failed to watch for transfer of XMR in transaction {}",
transfer_proof.tx_hash()
)
})?;
AliceState::XmrLocked {
monero_wallet_restore_blockheight,
transfer_proof,
state3,
}
}
_ => AliceState::CancelTimelockExpired {
monero_wallet_restore_blockheight,
transfer_proof,
state3,
},
},
AliceState::XmrLocked {
monero_wallet_restore_blockheight,
transfer_proof,
state3,
} => {
let tx_lock_status = bitcoin_wallet.subscribe_to(state3.tx_lock.clone()).await;
tokio::select! {
result = event_loop_handle.send_transfer_proof(transfer_proof.clone()) => {
result?;
AliceState::XmrLockTransferProofSent {
monero_wallet_restore_blockheight,
transfer_proof,
state3,
}
},
_ = tx_lock_status.wait_until_confirmed_with(state3.cancel_timelock) => {
AliceState::CancelTimelockExpired {
monero_wallet_restore_blockheight,
transfer_proof,
state3,
}
}
}
}
AliceState::XmrLockTransferProofSent {
monero_wallet_restore_blockheight,
transfer_proof,
state3,
} => {
let tx_lock_status = bitcoin_wallet.subscribe_to(state3.tx_lock.clone()).await;
select! {
biased; // make sure the cancel timelock expiry future is polled first
_ = tx_lock_status.wait_until_confirmed_with(state3.cancel_timelock) => {
AliceState::CancelTimelockExpired {
monero_wallet_restore_blockheight,
transfer_proof,
state3,
}
}
enc_sig = event_loop_handle.recv_encrypted_signature() => {
tracing::info!("Received encrypted signature");
AliceState::EncSigLearned {
monero_wallet_restore_blockheight,
transfer_proof,
encrypted_signature: Box::new(enc_sig?),
state3,
}
}
}
}
AliceState::EncSigLearned {
monero_wallet_restore_blockheight,
transfer_proof,
encrypted_signature,
state3,
} => match state3.expired_timelocks(bitcoin_wallet).await? {
ExpiredTimelocks::None => {
let tx_lock_status = bitcoin_wallet.subscribe_to(state3.tx_lock.clone()).await;
match state3.signed_redeem_transaction(*encrypted_signature) {
Ok(tx) => match bitcoin_wallet.broadcast(tx, "redeem").await {
Ok((_, subscription)) => match subscription.wait_until_final().await {
Ok(_) => AliceState::BtcRedeemed,
Err(e) => {
bail!("Waiting for Bitcoin transaction finality failed with {}! The redeem transaction was published, but it is not ensured that the transaction was included! You're screwed.", e)
}
},
Err(e) => {
error!("Publishing the redeem transaction failed with {}, attempting to wait for cancellation now. If you restart the application before the timelock is expired publishing the redeem transaction will be retried.", e);
tx_lock_status
.wait_until_confirmed_with(state3.cancel_timelock)
.await?;
AliceState::CancelTimelockExpired {
monero_wallet_restore_blockheight,
transfer_proof,
state3,
}
}
},
Err(e) => {
error!("Constructing the redeem transaction failed with {}, attempting to wait for cancellation now.", e);
tx_lock_status
.wait_until_confirmed_with(state3.cancel_timelock)
.await?;
AliceState::CancelTimelockExpired {
monero_wallet_restore_blockheight,
transfer_proof,
state3,
}
}
}
}
_ => AliceState::CancelTimelockExpired {
monero_wallet_restore_blockheight,
transfer_proof,
state3,
},
},
AliceState::CancelTimelockExpired {
monero_wallet_restore_blockheight,
transfer_proof,
state3,
} => {
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 cancel transaction is already broadcasted because: {:#}",
e
)
}
}
AliceState::BtcCancelled {
monero_wallet_restore_blockheight,
transfer_proof,
state3,
}
}
AliceState::BtcCancelled {
monero_wallet_restore_blockheight,
transfer_proof,
state3,
} => {
let tx_refund_status = bitcoin_wallet.subscribe_to(state3.tx_refund()).await;
let tx_cancel_status = bitcoin_wallet.subscribe_to(state3.tx_cancel()).await;
select! {
seen_refund = tx_refund_status.wait_until_seen() => {
seen_refund.context("Failed to monitor refund transaction")?;
let published_refund_tx = bitcoin_wallet.get_raw_transaction(state3.tx_refund().txid()).await?;
let spend_key = state3.extract_monero_private_key(published_refund_tx)?;
AliceState::BtcRefunded {
monero_wallet_restore_blockheight,
transfer_proof,
spend_key,
state3,
}
}
_ = tx_cancel_status.wait_until_confirmed_with(state3.punish_timelock) => {
AliceState::BtcPunishable {
monero_wallet_restore_blockheight,
transfer_proof,
state3,
}
}
}
}
AliceState::BtcRefunded {
monero_wallet_restore_blockheight,
transfer_proof,
spend_key,
state3,
} => {
state3
.refund_xmr(
monero_wallet,
monero_wallet_restore_blockheight,
swap_id.to_string(),
spend_key,
transfer_proof,
)
.await?;
AliceState::XmrRefunded
}
AliceState::BtcPunishable {
monero_wallet_restore_blockheight,
transfer_proof,
state3,
} => {
let punish = state3.punish_btc(bitcoin_wallet).await;
match punish {
Ok(_) => AliceState::BtcPunished,
Err(e) => {
tracing::warn!(
"Falling back to refund because punish transaction failed with {:#}",
e
);
// Upon punish failure we assume that the refund tx was included but we
// missed seeing it. In case we fail to fetch the refund tx we fail
// with no state update because it is unclear what state we should transition
// to. It does not help to race punish and refund inclusion,
// because a punish tx failure is not recoverable (besides re-trying) if the
// refund tx was not included.
let published_refund_tx = bitcoin_wallet
.get_raw_transaction(state3.tx_refund().txid())
.await?;
let spend_key = state3.extract_monero_private_key(published_refund_tx)?;
AliceState::BtcRefunded {
monero_wallet_restore_blockheight,
transfer_proof,
spend_key,
state3,
}
}
}
}
AliceState::XmrRefunded => AliceState::XmrRefunded,
AliceState::BtcRedeemed => AliceState::BtcRedeemed,
AliceState::BtcPunished => AliceState::BtcPunished,
AliceState::SafelyAborted => AliceState::SafelyAborted,
})
}
fn is_complete(state: &AliceState) -> bool {
matches!(
state,
AliceState::XmrRefunded
| AliceState::BtcRedeemed
| AliceState::BtcPunished
| AliceState::SafelyAborted
)
}