diff --git a/xmr-btc/src/alice.rs b/xmr-btc/src/alice.rs index 73d83976..95e12ed6 100644 --- a/xmr-btc/src/alice.rs +++ b/xmr-btc/src/alice.rs @@ -1,23 +1,369 @@ use crate::{ bitcoin, - bitcoin::{BroadcastSignedTransaction, WatchForRawTransaction}, + bitcoin::{poll_until_block_height_is_gte, BroadcastSignedTransaction, WatchForRawTransaction}, bob, monero, monero::{CreateWalletForOutput, Transfer}, transport::{ReceiveMessage, SendMessage}, }; use anyhow::{anyhow, Result}; +use async_trait::async_trait; use ecdsa_fun::{ adaptor::{Adaptor, EncryptedSignature}, nonce::Deterministic, }; +use futures::{ + future::{select, Either}, + pin_mut, FutureExt, +}; +use genawaiter::sync::{Gen, GenBoxed}; use rand::{CryptoRng, RngCore}; use serde::{Deserialize, Serialize}; use sha2::Sha256; -use std::convert::{TryFrom, TryInto}; +use std::{ + convert::{TryFrom, TryInto}, + sync::Arc, + time::Duration, +}; +use tokio::time::timeout; +use tracing::error; pub mod message; pub use message::{Message, Message0, Message1, Message2}; +#[derive(Debug)] +pub enum Action { + // This action also includes proving to Bob that this has happened, given that our current + // protocol requires a transfer proof to verify that the coins have been locked on Monero + LockXmr { + amount: monero::Amount, + public_spend_key: monero::PublicKey, + public_view_key: monero::PublicViewKey, + }, + RedeemBtc(bitcoin::Transaction), + CreateMoneroWalletForOutput { + spend_key: monero::PrivateKey, + view_key: monero::PrivateViewKey, + }, + CancelBtc(bitcoin::Transaction), + PunishBtc(bitcoin::Transaction), +} + +// TODO: This could be moved to the bitcoin module +#[async_trait] +pub trait ReceiveBitcoinRedeemEncsig { + async fn receive_bitcoin_redeem_encsig(&mut self) -> bitcoin::EncryptedSignature; +} + +/// Perform the on-chain protocol to swap monero and bitcoin as Alice. +/// +/// This is called post handshake, after all the keys, addresses and most of the +/// signatures have been exchanged. +/// +/// The argument `bitcoin_tx_lock_timeout` is used to determine how long we will +/// wait for Bob, the counterparty, to lock up the bitcoin. +pub fn action_generator( + mut network: N, + bitcoin_client: Arc, + // TODO: Replace this with a new, slimmer struct? + State3 { + a, + B, + s_a, + S_b_monero, + S_b_bitcoin, + v, + xmr, + refund_timelock, + punish_timelock, + refund_address, + redeem_address, + punish_address, + tx_lock, + tx_punish_sig_bob, + tx_cancel_sig_bob, + .. + }: State3, + bitcoin_tx_lock_timeout: u64, +) -> GenBoxed +where + N: ReceiveBitcoinRedeemEncsig + Send + Sync + 'static, + B: bitcoin::BlockHeight + + bitcoin::TransactionBlockHeight + + bitcoin::WatchForRawTransaction + + Send + + Sync + + 'static, +{ + #[derive(Debug)] + enum SwapFailed { + BeforeBtcLock(Reason), + AfterXmrLock { tx_lock_height: u32, reason: Reason }, + } + + /// Reason why the swap has failed. + #[derive(Debug)] + enum Reason { + /// Bob was too slow to lock the bitcoin. + InactiveBob, + /// Bob's encrypted signature on the Bitcoin redeem transaction is + /// invalid. + InvalidEncryptedSignature, + /// The refund timelock has been reached. + BtcExpired, + } + + #[derive(Debug)] + enum RefundFailed { + BtcPunishable { + tx_cancel_was_published: bool, + }, + /// Could not find Alice's signature on the refund transaction witness + /// stack. + BtcRefundSignature, + /// Could not recover secret `s_b` from Alice's refund transaction + /// signature. + SecretRecovery, + } + + Gen::new_boxed(|co| async move { + let swap_result: Result<(), SwapFailed> = async { + timeout( + Duration::from_secs(bitcoin_tx_lock_timeout), + bitcoin_client.watch_for_raw_transaction(tx_lock.txid()), + ) + .await + .map_err(|_| SwapFailed::BeforeBtcLock(Reason::InactiveBob))?; + + let tx_lock_height = bitcoin_client + .transaction_block_height(tx_lock.txid()) + .await; + let poll_until_btc_has_expired = poll_until_block_height_is_gte( + bitcoin_client.as_ref(), + tx_lock_height + refund_timelock, + ) + .shared(); + pin_mut!(poll_until_btc_has_expired); + + let S_a = monero::PublicKey::from_private_key(&monero::PrivateKey { + scalar: s_a.into_ed25519(), + }); + + co.yield_(Action::LockXmr { + amount: xmr, + public_spend_key: S_a + S_b_monero, + public_view_key: v.public(), + }) + .await; + + // TODO: Watch for LockXmr using watch-only wallet. Doing so will prevent Alice + // from cancelling/refunding unnecessarily. + + let tx_redeem_encsig = match select( + network.receive_bitcoin_redeem_encsig(), + poll_until_btc_has_expired.clone(), + ) + .await + { + Either::Left((encsig, _)) => encsig, + Either::Right(_) => { + return Err(SwapFailed::AfterXmrLock { + reason: Reason::BtcExpired, + tx_lock_height, + }) + } + }; + + let (signed_tx_redeem, tx_redeem_txid) = { + let adaptor = Adaptor::>::default(); + + let tx_redeem = bitcoin::TxRedeem::new(&tx_lock, &redeem_address); + + bitcoin::verify_encsig( + B.clone(), + s_a.into_secp256k1().into(), + &tx_redeem.digest(), + &tx_redeem_encsig, + ) + .map_err(|_| SwapFailed::AfterXmrLock { + reason: Reason::InvalidEncryptedSignature, + tx_lock_height, + })?; + + let sig_a = a.sign(tx_redeem.digest()); + let sig_b = + adaptor.decrypt_signature(&s_a.into_secp256k1(), tx_redeem_encsig.clone()); + + let tx = tx_redeem + .add_signatures(&tx_lock, (a.public(), sig_a), (B.clone(), sig_b)) + .expect("sig_{a,b} to be valid signatures for tx_redeem"); + let txid = tx.txid(); + + (tx, txid) + }; + + co.yield_(Action::RedeemBtc(signed_tx_redeem)).await; + + match select( + bitcoin_client.watch_for_raw_transaction(tx_redeem_txid), + poll_until_btc_has_expired, + ) + .await + { + Either::Left(_) => {} + Either::Right(_) => { + return Err(SwapFailed::AfterXmrLock { + reason: Reason::BtcExpired, + tx_lock_height, + }) + } + }; + + Ok(()) + } + .await; + + if let Err(ref err) = swap_result { + error!("swap failed: {:?}", err); + } + + if let Err(SwapFailed::AfterXmrLock { + reason: Reason::BtcExpired, + tx_lock_height, + }) = swap_result + { + let refund_result: Result<(), RefundFailed> = async { + let poll_until_bob_can_be_punished = poll_until_block_height_is_gte( + bitcoin_client.as_ref(), + tx_lock_height + refund_timelock + punish_timelock, + ) + .shared(); + pin_mut!(poll_until_bob_can_be_punished); + + let tx_cancel = + bitcoin::TxCancel::new(&tx_lock, refund_timelock, a.public(), B.clone()); + let signed_tx_cancel = { + let sig_a = a.sign(tx_cancel.digest()); + let sig_b = tx_cancel_sig_bob.clone(); + + tx_cancel + .clone() + .add_signatures(&tx_lock, (a.public(), sig_a), (B.clone(), sig_b)) + .expect("sig_{a,b} to be valid signatures for tx_cancel") + }; + + co.yield_(Action::CancelBtc(signed_tx_cancel)).await; + + match select( + bitcoin_client.watch_for_raw_transaction(tx_cancel.txid()), + poll_until_bob_can_be_punished.clone(), + ) + .await + { + Either::Left(_) => {} + Either::Right(_) => { + return Err(RefundFailed::BtcPunishable { + tx_cancel_was_published: false, + }) + } + }; + + let tx_refund = bitcoin::TxRefund::new(&tx_cancel, &refund_address); + let tx_refund_published = match select( + bitcoin_client.watch_for_raw_transaction(tx_refund.txid()), + poll_until_bob_can_be_punished, + ) + .await + { + Either::Left((tx, _)) => tx, + Either::Right(_) => { + return Err(RefundFailed::BtcPunishable { + tx_cancel_was_published: true, + }); + } + }; + + let s_a = monero::PrivateKey { + scalar: s_a.into_ed25519(), + }; + + let tx_refund_sig = tx_refund + .extract_signature_by_key(tx_refund_published, a.public()) + .map_err(|_| RefundFailed::BtcRefundSignature)?; + let tx_refund_encsig = a.encsign(S_b_bitcoin.clone(), tx_refund.digest()); + + let s_b = bitcoin::recover(S_b_bitcoin, tx_refund_sig, tx_refund_encsig) + .map_err(|_| RefundFailed::SecretRecovery)?; + let s_b = monero::PrivateKey::from_scalar(monero::Scalar::from_bytes_mod_order( + s_b.to_bytes(), + )); + + co.yield_(Action::CreateMoneroWalletForOutput { + spend_key: s_a + s_b, + view_key: v, + }) + .await; + + Ok(()) + } + .await; + + if let Err(ref err) = refund_result { + error!("refund failed: {:?}", err); + } + + // LIMITATION: When approaching the punish scenario, Bob could theoretically + // wake up in between Alice's publication of tx cancel and beat Alice's punish + // transaction with his refund transaction. Alice would then need to carry on + // with the refund on Monero. Doing so may be too verbose with the current, + // linear approach. A different design may be required + if let Err(RefundFailed::BtcPunishable { + tx_cancel_was_published, + }) = refund_result + { + let tx_cancel = + bitcoin::TxCancel::new(&tx_lock, refund_timelock, a.public(), B.clone()); + + if !tx_cancel_was_published { + let tx_cancel_txid = tx_cancel.txid(); + let signed_tx_cancel = { + let sig_a = a.sign(tx_cancel.digest()); + let sig_b = tx_cancel_sig_bob; + + tx_cancel + .clone() + .add_signatures(&tx_lock, (a.public(), sig_a), (B.clone(), sig_b)) + .expect("sig_{a,b} to be valid signatures for tx_cancel") + }; + + co.yield_(Action::CancelBtc(signed_tx_cancel)).await; + + let _ = bitcoin_client + .watch_for_raw_transaction(tx_cancel_txid) + .await; + } + + let tx_punish = + bitcoin::TxPunish::new(&tx_cancel, &punish_address, punish_timelock); + let tx_punish_txid = tx_punish.txid(); + let signed_tx_punish = { + let sig_a = a.sign(tx_punish.digest()); + let sig_b = tx_punish_sig_bob; + + tx_punish + .add_signatures(&tx_cancel, (a.public(), sig_a), (B, sig_b)) + .expect("sig_{a,b} to be valid signatures for tx_cancel") + }; + + co.yield_(Action::PunishBtc(signed_tx_punish)).await; + + let _ = bitcoin_client + .watch_for_raw_transaction(tx_punish_txid) + .await; + } + } + }) +} + // There are no guarantees that send_message and receive_massage do not block // the flow of execution. Therefore they must be paired between Alice/Bob, one // send to one receive in the correct order. diff --git a/xmr-btc/src/bitcoin.rs b/xmr-btc/src/bitcoin.rs index f85c0271..da75fd6f 100644 --- a/xmr-btc/src/bitcoin.rs +++ b/xmr-btc/src/bitcoin.rs @@ -190,6 +190,16 @@ pub trait WatchForRawTransaction { async fn watch_for_raw_transaction(&self, txid: Txid) -> Transaction; } +#[async_trait] +pub trait BlockHeight { + async fn block_height(&self) -> u32; +} + +#[async_trait] +pub trait TransactionBlockHeight { + async fn transaction_block_height(&self, txid: Txid) -> u32; +} + pub fn recover(S: PublicKey, sig: Signature, encsig: EncryptedSignature) -> Result { let adaptor = Adaptor::>::default(); @@ -200,3 +210,12 @@ pub fn recover(S: PublicKey, sig: Signature, encsig: EncryptedSignature) -> Resu Ok(s) } + +pub async fn poll_until_block_height_is_gte(client: &B, target: u32) +where + B: BlockHeight, +{ + while client.block_height().await < target { + tokio::time::delay_for(std::time::Duration::from_secs(1)).await; + } +} diff --git a/xmr-btc/src/bob.rs b/xmr-btc/src/bob.rs index 5f4f0f44..ac1180c9 100644 --- a/xmr-btc/src/bob.rs +++ b/xmr-btc/src/bob.rs @@ -1,28 +1,259 @@ use crate::{ alice, bitcoin::{ - self, BroadcastSignedTransaction, BuildTxLockPsbt, SignTxLock, TxCancel, - WatchForRawTransaction, + self, poll_until_block_height_is_gte, BroadcastSignedTransaction, BuildTxLockPsbt, + SignTxLock, TxCancel, WatchForRawTransaction, }, monero, serde::monero_private_key, transport::{ReceiveMessage, SendMessage}, }; use anyhow::{anyhow, Result}; +use async_trait::async_trait; use ecdsa_fun::{ adaptor::{Adaptor, EncryptedSignature}, nonce::Deterministic, Signature, }; +use futures::{ + future::{select, Either}, + pin_mut, FutureExt, +}; +use genawaiter::sync::{Gen, GenBoxed}; use rand::{CryptoRng, RngCore}; use serde::{Deserialize, Serialize}; use sha2::Sha256; -use std::convert::{TryFrom, TryInto}; +use std::{ + convert::{TryFrom, TryInto}, + sync::Arc, + time::Duration, +}; +use tokio::time::timeout; +use tracing::error; pub mod message; use crate::monero::{CreateWalletForOutput, WatchForTransfer}; pub use message::{Message, Message0, Message1, Message2, Message3}; +#[allow(clippy::large_enum_variant)] +#[derive(Debug)] +pub enum Action { + LockBtc(bitcoin::TxLock), + SendBtcRedeemEncsig(bitcoin::EncryptedSignature), + CreateXmrWalletForOutput { + spend_key: monero::PrivateKey, + view_key: monero::PrivateViewKey, + }, + CancelBtc(bitcoin::Transaction), + RefundBtc(bitcoin::Transaction), +} + +// TODO: This could be moved to the monero module +#[async_trait] +pub trait ReceiveTransferProof { + async fn receive_transfer_proof(&mut self) -> monero::TransferProof; +} + +/// Perform the on-chain protocol to swap monero and bitcoin as Bob. +/// +/// This is called post handshake, after all the keys, addresses and most of the +/// signatures have been exchanged. +/// +/// The argument `bitcoin_tx_lock_timeout` is used to determine how long we will +/// wait for Bob, the caller of this function, to lock up the bitcoin. +pub fn action_generator( + mut network: N, + monero_client: Arc, + bitcoin_client: Arc, + // TODO: Replace this with a new, slimmer struct? + State2 { + A, + b, + s_b, + S_a_monero, + S_a_bitcoin, + v, + xmr, + refund_timelock, + redeem_address, + refund_address, + tx_lock, + tx_cancel_sig_a, + tx_refund_encsig, + .. + }: State2, + bitcoin_tx_lock_timeout: u64, +) -> GenBoxed +where + N: ReceiveTransferProof + Send + Sync + 'static, + M: monero::WatchForTransfer + Send + Sync + 'static, + B: bitcoin::BlockHeight + + bitcoin::TransactionBlockHeight + + bitcoin::WatchForRawTransaction + + Send + + Sync + + 'static, +{ + #[derive(Debug)] + enum SwapFailed { + BeforeBtcLock(Reason), + AfterBtcLock(Reason), + AfterBtcRedeem(Reason), + } + + /// Reason why the swap has failed. + #[derive(Debug)] + enum Reason { + /// Bob was too slow to lock the bitcoin. + InactiveBob, + /// The refund timelock has been reached. + BtcExpired, + /// Alice did not lock up enough monero in the shared output. + InsufficientXmr(monero::InsufficientFunds), + /// Could not find Bob's signature on the redeem transaction witness + /// stack. + BtcRedeemSignature, + /// Could not recover secret `s_a` from Bob's redeem transaction + /// signature. + SecretRecovery, + } + + Gen::new_boxed(|co| async move { + let swap_result: Result<(), SwapFailed> = async { + co.yield_(Action::LockBtc(tx_lock.clone())).await; + + timeout( + Duration::from_secs(bitcoin_tx_lock_timeout), + bitcoin_client.watch_for_raw_transaction(tx_lock.txid()), + ) + .await + .map(|tx| tx.txid()) + .map_err(|_| SwapFailed::BeforeBtcLock(Reason::InactiveBob))?; + + let tx_lock_height = bitcoin_client + .transaction_block_height(tx_lock.txid()) + .await; + let poll_until_btc_has_expired = poll_until_block_height_is_gte( + bitcoin_client.as_ref(), + tx_lock_height + refund_timelock, + ) + .shared(); + pin_mut!(poll_until_btc_has_expired); + + let transfer_proof = match select( + network.receive_transfer_proof(), + poll_until_btc_has_expired.clone(), + ) + .await + { + Either::Left((proof, _)) => proof, + Either::Right(_) => return Err(SwapFailed::AfterBtcLock(Reason::BtcExpired)), + }; + + let S_b_monero = monero::PublicKey::from_private_key(&monero::PrivateKey::from_scalar( + s_b.into_ed25519(), + )); + let S = S_a_monero + S_b_monero; + + match select( + monero_client.watch_for_transfer(S, v.public(), transfer_proof, xmr, 0), + poll_until_btc_has_expired.clone(), + ) + .await + { + Either::Left((Err(e), _)) => { + return Err(SwapFailed::AfterBtcLock(Reason::InsufficientXmr(e))) + } + Either::Right(_) => return Err(SwapFailed::AfterBtcLock(Reason::BtcExpired)), + _ => {} + } + + let tx_redeem = bitcoin::TxRedeem::new(&tx_lock, &redeem_address); + let tx_redeem_encsig = b.encsign(S_a_bitcoin.clone(), tx_redeem.digest()); + + co.yield_(Action::SendBtcRedeemEncsig(tx_redeem_encsig.clone())) + .await; + + let tx_redeem_published = match select( + bitcoin_client.watch_for_raw_transaction(tx_redeem.txid()), + poll_until_btc_has_expired, + ) + .await + { + Either::Left((tx, _)) => tx, + Either::Right(_) => return Err(SwapFailed::AfterBtcLock(Reason::BtcExpired)), + }; + + let tx_redeem_sig = tx_redeem + .extract_signature_by_key(tx_redeem_published, b.public()) + .map_err(|_| SwapFailed::AfterBtcRedeem(Reason::BtcRedeemSignature))?; + let s_a = bitcoin::recover(S_a_bitcoin, tx_redeem_sig, tx_redeem_encsig) + .map_err(|_| SwapFailed::AfterBtcRedeem(Reason::SecretRecovery))?; + let s_a = monero::PrivateKey::from_scalar(monero::Scalar::from_bytes_mod_order( + s_a.to_bytes(), + )); + + let s_b = monero::PrivateKey { + scalar: s_b.into_ed25519(), + }; + + co.yield_(Action::CreateXmrWalletForOutput { + spend_key: s_a + s_b, + view_key: v, + }) + .await; + + Ok(()) + } + .await; + + if let Err(ref err) = swap_result { + error!("swap failed: {:?}", err); + } + + if let Err(SwapFailed::AfterBtcLock(_)) = swap_result { + let tx_cancel = + bitcoin::TxCancel::new(&tx_lock, refund_timelock, A.clone(), b.public()); + let tx_cancel_txid = tx_cancel.txid(); + let signed_tx_cancel = { + let sig_a = tx_cancel_sig_a.clone(); + let sig_b = b.sign(tx_cancel.digest()); + + tx_cancel + .clone() + .add_signatures(&tx_lock, (A.clone(), sig_a), (b.public(), sig_b)) + .expect("sig_{a,b} to be valid signatures for tx_cancel") + }; + + co.yield_(Action::CancelBtc(signed_tx_cancel)).await; + + let _ = bitcoin_client + .watch_for_raw_transaction(tx_cancel_txid) + .await; + + let tx_refund = bitcoin::TxRefund::new(&tx_cancel, &refund_address); + let tx_refund_txid = tx_refund.txid(); + let signed_tx_refund = { + let adaptor = Adaptor::>::default(); + + let sig_a = + adaptor.decrypt_signature(&s_b.into_secp256k1(), tx_refund_encsig.clone()); + let sig_b = b.sign(tx_refund.digest()); + + tx_refund + .add_signatures(&tx_cancel, (A.clone(), sig_a), (b.public(), sig_b)) + .expect("sig_{a,b} to be valid signatures for tx_refund") + }; + + co.yield_(Action::RefundBtc(signed_tx_refund)).await; + + let _ = bitcoin_client + .watch_for_raw_transaction(tx_refund_txid) + .await; + } + }) +} + // There are no guarantees that send_message and receive_massage do not block // the flow of execution. Therefore they must be paired between Alice/Bob, one // send to one receive in the correct order. diff --git a/xmr-btc/src/lib.rs b/xmr-btc/src/lib.rs index f2effbb5..84ca9daa 100644 --- a/xmr-btc/src/lib.rs +++ b/xmr-btc/src/lib.rs @@ -54,560 +54,3 @@ pub mod transport; pub use cross_curve_dleq; pub use curve25519_dalek; - -use async_trait::async_trait; -use ecdsa_fun::{adaptor::Adaptor, nonce::Deterministic}; -use futures::{ - future::{select, Either}, - Future, FutureExt, -}; -use genawaiter::sync::{Gen, GenBoxed}; -use sha2::Sha256; -use std::{sync::Arc, time::Duration}; -use tokio::time::timeout; -use tracing::error; - -// TODO: Replace this with something configurable, such as an function argument. -/// Time that Bob has to publish the Bitcoin lock transaction before both -/// parties will abort, in seconds. -const SECS_TO_ACT_BOB: u64 = 60; - -#[allow(clippy::large_enum_variant)] -#[derive(Debug)] -pub enum BobAction { - LockBitcoin(bitcoin::TxLock), - SendBitcoinRedeemEncsig(bitcoin::EncryptedSignature), - CreateMoneroWalletForOutput { - spend_key: monero::PrivateKey, - view_key: monero::PrivateViewKey, - }, - CancelBitcoin(bitcoin::Transaction), - RefundBitcoin(bitcoin::Transaction), -} - -// TODO: This could be moved to the monero module -#[async_trait] -pub trait ReceiveTransferProof { - async fn receive_transfer_proof(&mut self) -> monero::TransferProof; -} - -#[async_trait] -pub trait BlockHeight { - async fn block_height(&self) -> u32; -} - -#[async_trait] -pub trait TransactionBlockHeight { - async fn transaction_block_height(&self, txid: bitcoin::Txid) -> u32; -} - -/// Perform the on-chain protocol to swap monero and bitcoin as Bob. -/// -/// This is called post handshake, after all the keys, addresses and most of the -/// signatures have been exchanged. -pub fn action_generator_bob( - mut network: N, - monero_client: Arc, - bitcoin_client: Arc, - // TODO: Replace this with a new, slimmer struct? - bob::State2 { - A, - b, - s_b, - S_a_monero, - S_a_bitcoin, - v, - xmr, - refund_timelock, - redeem_address, - refund_address, - tx_lock, - tx_cancel_sig_a, - tx_refund_encsig, - .. - }: bob::State2, -) -> GenBoxed -where - N: ReceiveTransferProof + Send + Sync + 'static, - M: monero::WatchForTransfer + Send + Sync + 'static, - B: BlockHeight - + TransactionBlockHeight - + bitcoin::WatchForRawTransaction - + Send - + Sync - + 'static, -{ - #[derive(Debug)] - enum SwapFailed { - BeforeBtcLock, - AfterBtcLock(Reason), - AfterBtcRedeem(Reason), - } - - /// Reason why the swap has failed. - #[derive(Debug)] - enum Reason { - /// The refund timelock has been reached. - BtcExpired, - /// Alice did not lock up enough monero in the shared output. - InsufficientXmr(monero::InsufficientFunds), - /// Could not find Bob's signature on the redeem transaction witness - /// stack. - BtcRedeemSignature, - /// Could not recover secret `s_a` from Bob's redeem transaction - /// signature. - SecretRecovery, - } - - async fn poll_until(condition_future: impl Future + Clone) { - loop { - if condition_future.clone().await { - return; - } - - tokio::time::delay_for(std::time::Duration::from_secs(1)).await; - } - } - - async fn bitcoin_block_height_is_gte(bitcoin_client: &B, n_blocks: u32) -> bool - where - B: BlockHeight, - { - bitcoin_client.block_height().await >= n_blocks - } - - Gen::new_boxed(|co| async move { - let swap_result: Result<(), SwapFailed> = async { - co.yield_(BobAction::LockBitcoin(tx_lock.clone())).await; - - timeout( - Duration::from_secs(SECS_TO_ACT_BOB), - bitcoin_client.watch_for_raw_transaction(tx_lock.txid()), - ) - .await - .map(|tx| tx.txid()) - .map_err(|_| SwapFailed::BeforeBtcLock)?; - - let tx_lock_height = bitcoin_client - .transaction_block_height(tx_lock.txid()) - .await; - let btc_has_expired = bitcoin_block_height_is_gte( - bitcoin_client.as_ref(), - tx_lock_height + refund_timelock, - ) - .shared(); - let poll_until_btc_has_expired = poll_until(btc_has_expired).shared(); - futures::pin_mut!(poll_until_btc_has_expired); - - let transfer_proof = match select( - network.receive_transfer_proof(), - poll_until_btc_has_expired.clone(), - ) - .await - { - Either::Left((proof, _)) => proof, - Either::Right(_) => return Err(SwapFailed::AfterBtcLock(Reason::BtcExpired)), - }; - - let S_b_monero = monero::PublicKey::from_private_key(&monero::PrivateKey::from_scalar( - s_b.into_ed25519(), - )); - let S = S_a_monero + S_b_monero; - - match select( - monero_client.watch_for_transfer( - S, - v.public(), - transfer_proof, - xmr, - monero::MIN_CONFIRMATIONS, - ), - poll_until_btc_has_expired.clone(), - ) - .await - { - Either::Left((Err(e), _)) => { - return Err(SwapFailed::AfterBtcLock(Reason::InsufficientXmr(e))) - } - Either::Right(_) => return Err(SwapFailed::AfterBtcLock(Reason::BtcExpired)), - _ => {} - } - - let tx_redeem = bitcoin::TxRedeem::new(&tx_lock, &redeem_address); - let tx_redeem_encsig = b.encsign(S_a_bitcoin.clone(), tx_redeem.digest()); - - co.yield_(BobAction::SendBitcoinRedeemEncsig(tx_redeem_encsig.clone())) - .await; - - let tx_redeem_published = match select( - bitcoin_client.watch_for_raw_transaction(tx_redeem.txid()), - poll_until_btc_has_expired, - ) - .await - { - Either::Left((tx, _)) => tx, - Either::Right(_) => return Err(SwapFailed::AfterBtcLock(Reason::BtcExpired)), - }; - - let tx_redeem_sig = tx_redeem - .extract_signature_by_key(tx_redeem_published, b.public()) - .map_err(|_| SwapFailed::AfterBtcRedeem(Reason::BtcRedeemSignature))?; - let s_a = bitcoin::recover(S_a_bitcoin, tx_redeem_sig, tx_redeem_encsig) - .map_err(|_| SwapFailed::AfterBtcRedeem(Reason::SecretRecovery))?; - let s_a = monero::PrivateKey::from_scalar(monero::Scalar::from_bytes_mod_order( - s_a.to_bytes(), - )); - - let s_b = monero::PrivateKey { - scalar: s_b.into_ed25519(), - }; - - co.yield_(BobAction::CreateMoneroWalletForOutput { - spend_key: s_a + s_b, - view_key: v, - }) - .await; - - Ok(()) - } - .await; - - if let Err(err @ SwapFailed::AfterBtcLock(_)) = swap_result { - error!("Swap failed, reason: {:?}", err); - - let tx_cancel = - bitcoin::TxCancel::new(&tx_lock, refund_timelock, A.clone(), b.public()); - let tx_cancel_txid = tx_cancel.txid(); - let signed_tx_cancel = { - let sig_a = tx_cancel_sig_a.clone(); - let sig_b = b.sign(tx_cancel.digest()); - - tx_cancel - .clone() - .add_signatures(&tx_lock, (A.clone(), sig_a), (b.public(), sig_b)) - .expect("sig_{a,b} to be valid signatures for tx_cancel") - }; - - co.yield_(BobAction::CancelBitcoin(signed_tx_cancel)).await; - - let _ = bitcoin_client - .watch_for_raw_transaction(tx_cancel_txid) - .await; - - let tx_refund = bitcoin::TxRefund::new(&tx_cancel, &refund_address); - let tx_refund_txid = tx_refund.txid(); - let signed_tx_refund = { - let adaptor = Adaptor::>::default(); - - let sig_a = - adaptor.decrypt_signature(&s_b.into_secp256k1(), tx_refund_encsig.clone()); - let sig_b = b.sign(tx_refund.digest()); - - tx_refund - .add_signatures(&tx_cancel, (A.clone(), sig_a), (b.public(), sig_b)) - .expect("sig_{a,b} to be valid signatures for tx_refund") - }; - - co.yield_(BobAction::RefundBitcoin(signed_tx_refund)).await; - - let _ = bitcoin_client - .watch_for_raw_transaction(tx_refund_txid) - .await; - } - }) -} - -#[derive(Debug)] -pub enum AliceAction { - // This action also includes proving to Bob that this has happened, given that our current - // protocol requires a transfer proof to verify that the coins have been locked on Monero - LockXmr { - amount: monero::Amount, - public_spend_key: monero::PublicKey, - public_view_key: monero::PublicViewKey, - }, - RedeemBtc(bitcoin::Transaction), - CreateMoneroWalletForOutput { - spend_key: monero::PrivateKey, - view_key: monero::PrivateViewKey, - }, - CancelBtc(bitcoin::Transaction), - PunishBtc(bitcoin::Transaction), -} - -// TODO: This could be moved to the bitcoin module -#[async_trait] -pub trait ReceiveBitcoinRedeemEncsig { - async fn receive_bitcoin_redeem_encsig(&mut self) -> bitcoin::EncryptedSignature; -} - -/// Perform the on-chain protocol to swap monero and bitcoin as Alice. -/// -/// This is called post handshake, after all the keys, addresses and most of the -/// signatures have been exchanged. -pub fn action_generator_alice( - mut network: N, - bitcoin_client: Arc, - // TODO: Replace this with a new, slimmer struct? - alice::State3 { - a, - B, - s_a, - S_b_monero, - S_b_bitcoin, - v, - xmr, - refund_timelock, - punish_timelock, - refund_address, - redeem_address, - punish_address, - tx_lock, - tx_punish_sig_bob, - tx_cancel_sig_bob, - .. - }: alice::State3, -) -> GenBoxed -where - N: ReceiveBitcoinRedeemEncsig + Send + Sync + 'static, - B: BlockHeight - + TransactionBlockHeight - + bitcoin::WatchForRawTransaction - + Send - + Sync - + 'static, -{ - #[derive(Debug)] - enum SwapFailed { - BeforeBtcLock, - AfterXmrLock(Reason), - } - - /// Reason why the swap has failed. - #[derive(Debug)] - enum Reason { - /// The refund timelock has been reached. - BtcExpired, - } - - enum RefundFailed { - BtcPunishable { - tx_cancel_was_published: bool, - }, - /// Could not find Alice's signature on the refund transaction witness - /// stack. - BtcRefundSignature, - /// Could not recover secret `s_b` from Alice's refund transaction - /// signature. - SecretRecovery, - } - - async fn poll_until(condition_future: impl Future + Clone) { - loop { - if condition_future.clone().await { - return; - } - - tokio::time::delay_for(std::time::Duration::from_secs(1)).await; - } - } - - async fn bitcoin_block_height_is_gte(bitcoin_client: &B, n_blocks: u32) -> bool - where - B: BlockHeight, - { - bitcoin_client.block_height().await >= n_blocks - } - - Gen::new_boxed(|co| async move { - let swap_result: Result<(), SwapFailed> = async { - timeout( - Duration::from_secs(SECS_TO_ACT_BOB), - bitcoin_client.watch_for_raw_transaction(tx_lock.txid()), - ) - .await - .map_err(|_| SwapFailed::BeforeBtcLock)?; - - let tx_lock_height = bitcoin_client - .transaction_block_height(tx_lock.txid()) - .await; - let btc_has_expired = bitcoin_block_height_is_gte( - bitcoin_client.as_ref(), - tx_lock_height + refund_timelock, - ) - .shared(); - let poll_until_btc_has_expired = poll_until(btc_has_expired).shared(); - futures::pin_mut!(poll_until_btc_has_expired); - - let S_a = monero::PublicKey::from_private_key(&monero::PrivateKey { - scalar: s_a.into_ed25519(), - }); - - co.yield_(AliceAction::LockXmr { - amount: xmr, - public_spend_key: S_a + S_b_monero, - public_view_key: v.public(), - }) - .await; - - // TODO: Watch for LockXmr using watch-only wallet. Doing so will prevent Alice - // from cancelling/refunding unnecessarily. - - let tx_redeem_encsig = match select( - network.receive_bitcoin_redeem_encsig(), - poll_until_btc_has_expired.clone(), - ) - .await - { - Either::Left((encsig, _)) => encsig, - Either::Right(_) => return Err(SwapFailed::AfterXmrLock(Reason::BtcExpired)), - }; - - let (signed_tx_redeem, tx_redeem_txid) = { - let adaptor = Adaptor::>::default(); - - let tx_redeem = bitcoin::TxRedeem::new(&tx_lock, &redeem_address); - - let sig_a = a.sign(tx_redeem.digest()); - let sig_b = - adaptor.decrypt_signature(&s_a.into_secp256k1(), tx_redeem_encsig.clone()); - - let tx = tx_redeem - .add_signatures(&tx_lock, (a.public(), sig_a), (B.clone(), sig_b)) - .expect("sig_{a,b} to be valid signatures for tx_redeem"); - let txid = tx.txid(); - - (tx, txid) - }; - - co.yield_(AliceAction::RedeemBtc(signed_tx_redeem)).await; - - match select( - bitcoin_client.watch_for_raw_transaction(tx_redeem_txid), - poll_until_btc_has_expired, - ) - .await - { - Either::Left(_) => {} - Either::Right(_) => return Err(SwapFailed::AfterXmrLock(Reason::BtcExpired)), - }; - - Ok(()) - } - .await; - - if let Err(SwapFailed::AfterXmrLock(Reason::BtcExpired)) = swap_result { - let refund_result: Result<(), RefundFailed> = async { - let bob_can_be_punished = - bitcoin_block_height_is_gte(bitcoin_client.as_ref(), punish_timelock).shared(); - let poll_until_bob_can_be_punished = poll_until(bob_can_be_punished).shared(); - futures::pin_mut!(poll_until_bob_can_be_punished); - - let tx_cancel = - bitcoin::TxCancel::new(&tx_lock, refund_timelock, a.public(), B.clone()); - match select( - bitcoin_client.watch_for_raw_transaction(tx_cancel.txid()), - poll_until_bob_can_be_punished.clone(), - ) - .await - { - Either::Left(_) => {} - Either::Right(_) => { - return Err(RefundFailed::BtcPunishable { - tx_cancel_was_published: false, - }) - } - }; - - let tx_refund = bitcoin::TxRefund::new(&tx_cancel, &refund_address); - let tx_refund_published = match select( - bitcoin_client.watch_for_raw_transaction(tx_refund.txid()), - poll_until_bob_can_be_punished, - ) - .await - { - Either::Left((tx, _)) => tx, - Either::Right(_) => { - return Err(RefundFailed::BtcPunishable { - tx_cancel_was_published: true, - }) - } - }; - - let s_a = monero::PrivateKey { - scalar: s_a.into_ed25519(), - }; - - let tx_refund_sig = tx_refund - .extract_signature_by_key(tx_refund_published, B.clone()) - .map_err(|_| RefundFailed::BtcRefundSignature)?; - let tx_refund_encsig = a.encsign(S_b_bitcoin.clone(), tx_refund.digest()); - - let s_b = bitcoin::recover(S_b_bitcoin, tx_refund_sig, tx_refund_encsig) - .map_err(|_| RefundFailed::SecretRecovery)?; - let s_b = monero::PrivateKey::from_scalar(monero::Scalar::from_bytes_mod_order( - s_b.to_bytes(), - )); - - co.yield_(AliceAction::CreateMoneroWalletForOutput { - spend_key: s_a + s_b, - view_key: v, - }) - .await; - - Ok(()) - } - .await; - - // LIMITATION: When approaching the punish scenario, Bob could theoretically - // wake up in between Alice's publication of tx cancel and beat Alice's punish - // transaction with his refund transaction. Alice would then need to carry on - // with the refund on Monero. Doing so may be too verbose with the current, - // linear approach. A different design may be required - if let Err(RefundFailed::BtcPunishable { - tx_cancel_was_published, - }) = refund_result - { - let tx_cancel = - bitcoin::TxCancel::new(&tx_lock, refund_timelock, a.public(), B.clone()); - - if !tx_cancel_was_published { - let tx_cancel_txid = tx_cancel.txid(); - let signed_tx_cancel = { - let sig_a = a.sign(tx_cancel.digest()); - let sig_b = tx_cancel_sig_bob; - - tx_cancel - .clone() - .add_signatures(&tx_lock, (a.public(), sig_a), (B.clone(), sig_b)) - .expect("sig_{a,b} to be valid signatures for tx_cancel") - }; - - co.yield_(AliceAction::CancelBtc(signed_tx_cancel)).await; - - let _ = bitcoin_client - .watch_for_raw_transaction(tx_cancel_txid) - .await; - } - - let tx_punish = - bitcoin::TxPunish::new(&tx_cancel, &punish_address, punish_timelock); - let tx_punish_txid = tx_punish.txid(); - let signed_tx_punish = { - let sig_a = a.sign(tx_punish.digest()); - let sig_b = tx_punish_sig_bob; - - tx_punish - .add_signatures(&tx_cancel, (a.public(), sig_a), (B, sig_b)) - .expect("sig_{a,b} to be valid signatures for tx_cancel") - }; - - co.yield_(AliceAction::PunishBtc(signed_tx_punish)).await; - - let _ = bitcoin_client - .watch_for_raw_transaction(tx_punish_txid) - .await; - } - } - }) -} diff --git a/xmr-btc/tests/harness/wallet/bitcoin.rs b/xmr-btc/tests/harness/wallet/bitcoin.rs index c39ba3f7..f9d2c91d 100644 --- a/xmr-btc/tests/harness/wallet/bitcoin.rs +++ b/xmr-btc/tests/harness/wallet/bitcoin.rs @@ -6,11 +6,9 @@ use bitcoin_harness::{bitcoind_rpc::PsbtBase64, Bitcoind}; use reqwest::Url; use std::time::Duration; use tokio::time; -use xmr_btc::{ - bitcoin::{ - BroadcastSignedTransaction, BuildTxLockPsbt, SignTxLock, TxLock, WatchForRawTransaction, - }, - BlockHeight, TransactionBlockHeight, +use xmr_btc::bitcoin::{ + BlockHeight, BroadcastSignedTransaction, BuildTxLockPsbt, SignTxLock, TransactionBlockHeight, + TxLock, WatchForRawTransaction, }; #[derive(Debug)] diff --git a/xmr-btc/tests/on_chain.rs b/xmr-btc/tests/on_chain.rs index f9e1bfcf..8cac4c57 100644 --- a/xmr-btc/tests/on_chain.rs +++ b/xmr-btc/tests/on_chain.rs @@ -19,13 +19,15 @@ use rand::rngs::OsRng; use testcontainers::clients::Cli; use tracing::info; use xmr_btc::{ - action_generator_alice, action_generator_bob, alice, + alice::{self, ReceiveBitcoinRedeemEncsig}, bitcoin::{BroadcastSignedTransaction, EncryptedSignature, SignTxLock}, - bob, + bob::{self, ReceiveTransferProof}, monero::{CreateWalletForOutput, Transfer, TransferProof}, - AliceAction, BobAction, ReceiveBitcoinRedeemEncsig, ReceiveTransferProof, }; +/// Time given to Bob to get the Bitcoin lock transaction included in a block. +const BITCOIN_TX_LOCK_TIMEOUT: u64 = 5; + type AliceNetwork = Network; type BobNetwork = Network; @@ -58,6 +60,26 @@ impl ReceiveBitcoinRedeemEncsig for AliceNetwork { } } +struct AliceBehaviour { + lock_xmr: bool, + redeem_btc: bool, + cancel_btc: bool, + punish_btc: bool, + create_monero_wallet_for_output: bool, +} + +impl Default for AliceBehaviour { + fn default() -> Self { + Self { + lock_xmr: true, + redeem_btc: true, + cancel_btc: true, + punish_btc: true, + create_monero_wallet_for_output: true, + } + } +} + async fn swap_as_alice( network: AliceNetwork, // FIXME: It would be more intuitive to have a single network/transport struct instead of @@ -65,39 +87,59 @@ async fn swap_as_alice( mut sender: Sender, monero_wallet: &harness::wallet::monero::Wallet, bitcoin_wallet: Arc, + behaviour: AliceBehaviour, state: alice::State3, ) -> Result<()> { - let mut action_generator = action_generator_alice(network, bitcoin_wallet.clone(), state); + let mut action_generator = alice::action_generator( + network, + bitcoin_wallet.clone(), + state, + BITCOIN_TX_LOCK_TIMEOUT, + ); loop { let state = action_generator.async_resume().await; - info!("resumed execution of generator, got: {:?}", state); + info!("resumed execution of alice generator, got: {:?}", state); match state { - GeneratorState::Yielded(AliceAction::LockXmr { + GeneratorState::Yielded(alice::Action::LockXmr { amount, public_spend_key, public_view_key, }) => { - let (transfer_proof, _) = monero_wallet - .transfer(public_spend_key, public_view_key, amount) - .await?; + if behaviour.lock_xmr { + let (transfer_proof, _) = monero_wallet + .transfer(public_spend_key, public_view_key, amount) + .await?; - sender.send(transfer_proof).await.unwrap(); + sender.send(transfer_proof).await?; + } } - GeneratorState::Yielded(AliceAction::RedeemBtc(tx)) - | GeneratorState::Yielded(AliceAction::CancelBtc(tx)) - | GeneratorState::Yielded(AliceAction::PunishBtc(tx)) => { - let _ = bitcoin_wallet.broadcast_signed_transaction(tx).await?; + GeneratorState::Yielded(alice::Action::RedeemBtc(tx)) => { + if behaviour.redeem_btc { + let _ = bitcoin_wallet.broadcast_signed_transaction(tx).await?; + } } - GeneratorState::Yielded(AliceAction::CreateMoneroWalletForOutput { + GeneratorState::Yielded(alice::Action::CancelBtc(tx)) => { + if behaviour.cancel_btc { + let _ = bitcoin_wallet.broadcast_signed_transaction(tx).await?; + } + } + GeneratorState::Yielded(alice::Action::PunishBtc(tx)) => { + if behaviour.punish_btc { + let _ = bitcoin_wallet.broadcast_signed_transaction(tx).await?; + } + } + GeneratorState::Yielded(alice::Action::CreateMoneroWalletForOutput { spend_key, view_key, }) => { - monero_wallet - .create_and_load_wallet_for_output(spend_key, view_key) - .await?; + if behaviour.create_monero_wallet_for_output { + monero_wallet + .create_and_load_wallet_for_output(spend_key, view_key) + .await?; + } } GeneratorState::Complete(()) => return Ok(()), } @@ -111,29 +153,30 @@ async fn swap_as_bob( bitcoin_wallet: Arc, state: bob::State2, ) -> Result<()> { - let mut action_generator = action_generator_bob( + let mut action_generator = bob::action_generator( network, monero_wallet.clone(), bitcoin_wallet.clone(), state, + BITCOIN_TX_LOCK_TIMEOUT, ); loop { let state = action_generator.async_resume().await; - info!("resumed execution of generator, got: {:?}", state); + info!("resumed execution of bob generator, got: {:?}", state); match state { - GeneratorState::Yielded(BobAction::LockBitcoin(tx_lock)) => { + GeneratorState::Yielded(bob::Action::LockBtc(tx_lock)) => { let signed_tx_lock = bitcoin_wallet.sign_tx_lock(tx_lock).await?; let _ = bitcoin_wallet .broadcast_signed_transaction(signed_tx_lock) .await?; } - GeneratorState::Yielded(BobAction::SendBitcoinRedeemEncsig(tx_redeem_encsig)) => { + GeneratorState::Yielded(bob::Action::SendBtcRedeemEncsig(tx_redeem_encsig)) => { sender.send(tx_redeem_encsig).await.unwrap(); } - GeneratorState::Yielded(BobAction::CreateMoneroWalletForOutput { + GeneratorState::Yielded(bob::Action::CreateXmrWalletForOutput { spend_key, view_key, }) => { @@ -141,12 +184,12 @@ async fn swap_as_bob( .create_and_load_wallet_for_output(spend_key, view_key) .await?; } - GeneratorState::Yielded(BobAction::CancelBitcoin(tx_cancel)) => { + GeneratorState::Yielded(bob::Action::CancelBtc(tx_cancel)) => { let _ = bitcoin_wallet .broadcast_signed_transaction(tx_cancel) .await?; } - GeneratorState::Yielded(BobAction::RefundBitcoin(tx_refund)) => { + GeneratorState::Yielded(bob::Action::RefundBtc(tx_refund)) => { let _ = bitcoin_wallet .broadcast_signed_transaction(tx_refund) .await?; @@ -205,6 +248,7 @@ async fn on_chain_happy_path() { alice_sender, &alice_monero_wallet.clone(), alice_bitcoin_wallet.clone(), + AliceBehaviour::default(), alice, ), swap_as_bob( @@ -249,3 +293,92 @@ async fn on_chain_happy_path() { initial_balances.bob_xmr + swap_amounts.xmr ); } + +#[tokio::test] +async fn on_chain_both_refund_if_alice_never_redeems() { + let cli = Cli::default(); + let (monero, _container) = Monero::new(&cli).unwrap(); + let bitcoind = init_bitcoind(&cli).await; + + let (alice_state0, bob_state0, mut alice_node, mut bob_node, initial_balances, swap_amounts) = + init_test(&monero, &bitcoind, Some(10), Some(10)).await; + + // run the handshake as part of the setup + let (alice_state, bob_state) = try_join( + run_alice_until( + &mut alice_node, + alice_state0.into(), + harness::alice::is_state3, + &mut OsRng, + ), + run_bob_until( + &mut bob_node, + bob_state0.into(), + harness::bob::is_state2, + &mut OsRng, + ), + ) + .await + .unwrap(); + let alice: alice::State3 = alice_state.try_into().unwrap(); + let bob: bob::State2 = bob_state.try_into().unwrap(); + let tx_lock_txid = bob.tx_lock.txid(); + + let alice_bitcoin_wallet = Arc::new(alice_node.bitcoin_wallet); + let bob_bitcoin_wallet = Arc::new(bob_node.bitcoin_wallet); + let alice_monero_wallet = Arc::new(alice_node.monero_wallet); + let bob_monero_wallet = Arc::new(bob_node.monero_wallet); + + let (alice_network, bob_sender) = Network::::new(); + let (bob_network, alice_sender) = Network::::new(); + + try_join( + swap_as_alice( + alice_network, + alice_sender, + &alice_monero_wallet.clone(), + alice_bitcoin_wallet.clone(), + AliceBehaviour { + redeem_btc: false, + ..Default::default() + }, + alice, + ), + swap_as_bob( + bob_network, + bob_sender, + bob_monero_wallet.clone(), + bob_bitcoin_wallet.clone(), + bob, + ), + ) + .await + .unwrap(); + + let alice_final_btc_balance = alice_bitcoin_wallet.balance().await.unwrap(); + let bob_final_btc_balance = bob_bitcoin_wallet.balance().await.unwrap(); + + let lock_tx_bitcoin_fee = bob_bitcoin_wallet + .transaction_fee(tx_lock_txid) + .await + .unwrap(); + + monero.wait_for_alice_wallet_block_height().await.unwrap(); + let alice_final_xmr_balance = alice_monero_wallet.get_balance().await.unwrap(); + + let bob_final_xmr_balance = bob_monero_wallet.get_balance().await.unwrap(); + + assert_eq!(alice_final_btc_balance, initial_balances.alice_btc); + assert_eq!( + bob_final_btc_balance, + // The 2 * TX_FEE corresponds to tx_refund and tx_cancel. + initial_balances.bob_btc + - bitcoin::Amount::from_sat(2 * xmr_btc::bitcoin::TX_FEE) + - lock_tx_bitcoin_fee + ); + + // Because we create a new wallet when claiming Monero, we can only assert on + // this new wallet owning all of `xmr_amount` after refund + assert_eq!(alice_final_xmr_balance, swap_amounts.xmr); + assert_eq!(bob_final_xmr_balance, initial_balances.bob_xmr); +}