From 3b3bea853158c4d0d2b5f88d06c66098702c8582 Mon Sep 17 00:00:00 2001 From: Raphael <81313171+Einliterflasche@users.noreply.github.com> Date: Thu, 5 Jun 2025 00:15:42 +0200 Subject: [PATCH] feat(asb): Take ongoing swaps into consideration when crafting quote (#245) * Subtract monero that is reversed for ongoing swaps from the quote volume * Also reserve monero tx fee * dprint fmt * Add todo for better XMR management * fix dprint lint * Warn instead of fail, default to 0 quote when reserved funds exceed monero balance * Add more information to warning * Add changelog entry * feat(monero-sys): Initial commit. Regtest integration test. Wrapper around basic Wallet functions, depends on monero#9464 * merge master, extract logic into unreserved_monero_balance * fmt * remove shit * revert * refactor * make clippy happy, add comments * Split code into smaller portions, add unit tests * refactor, add unit tests * fmt * reorder functions * fix compile error, max timeout of 10s for mutex lock --------- Co-authored-by: Binarybaron --- CHANGELOG.md | 3 +- dev-docs/asb/README.md | 4 +- swap/src/asb/event_loop.rs | 494 ++++++++++++++++++++++++++----- swap/src/monero.rs | 167 ++++++++++- swap/src/protocol/alice/state.rs | 27 ++ 5 files changed, 609 insertions(+), 86 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2a5bfc10..a1b31e77 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +- ASB: The maker will take Monero funds needed for ongoing swaps into consideration when making a quote. A warning will be displayed if the Monero funds do not cover all ongoing swaps. + ## [1.1.7] - 2025-06-04 - ASB: Fix an issue where the asb would quote a max_swap_amount off by a couple of piconeros @@ -76,7 +78,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [1.0.0-rc.12] - 2025-01-14 - GUI: Fixed a bug where the CLI wasn't passed the correct Monero node. -- ## [1.0.0-rc.11] - 2024-12-22 diff --git a/dev-docs/asb/README.md b/dev-docs/asb/README.md index 7dd16b88..006a34a9 100644 --- a/dev-docs/asb/README.md +++ b/dev-docs/asb/README.md @@ -31,7 +31,9 @@ Consider joining the designated [Matrix chat](https://matrix.to/#/%23unstoppable ### Using Docker -Running the ASB and its required services (Bitcoin node, Monero node, wallet RPC) can be complex to set up manually. We provide a Docker Compose solution that handles all of this automatically. See our [docker-compose repository](https://github.com/UnstoppableSwap/asb-docker-compose) for setup instructions and configuration details. +Running the ASB and its required services (Bitcoin node, Monero node, wallet RPC) can be complex to set up manually. +We provide a Docker Compose solution that handles all of this automatically. +See our [docker-compose repository](https://github.com/UnstoppableSwap/asb-docker-compose) for setup instructions and configuration details. ## ASB Details diff --git a/swap/src/asb/event_loop.rs b/swap/src/asb/event_loop.rs index f9d9c3db..6e70ce4d 100644 --- a/swap/src/asb/event_loop.rs +++ b/swap/src/asb/event_loop.rs @@ -6,7 +6,7 @@ use crate::network::quote::BidQuote; use crate::network::swap_setup::alice::WalletSnapshot; use crate::network::transfer_proof; use crate::protocol::alice::swap::has_already_processed_enc_sig; -use crate::protocol::alice::{AliceState, State3, Swap}; +use crate::protocol::alice::{AliceState, ReservesMonero, State3, Swap}; use crate::protocol::{Database, State}; use crate::{bitcoin, env, kraken, monero}; use anyhow::{anyhow, Context, Result}; @@ -501,7 +501,33 @@ where // We have a cache miss, so we compute a new quote tracing::trace!("Got a request for a quote, computing new quote."); - let result = self.make_quote(self.min_buy, self.max_buy).await; + + let rate = self.latest_rate.clone(); + + let get_reserved_items = || async { + let all_swaps = self.db.all().await?; + let alice_states: Vec<_> = all_swaps + .into_iter() + .filter_map(|(_, state)| match state { + State::Alice(state) => Some(state), + _ => None, + }) + .collect(); + + Ok(alice_states) + }; + + let get_unlocked_balance = + || async { unlocked_monero_balance_with_timeout(self.monero_wallet.clone()).await }; + + let result = make_quote( + min_buy, + max_buy, + rate, + get_unlocked_balance, + get_reserved_items, + ) + .await; // Insert the computed quote into the cache // Need to clone it as insert takes ownership @@ -511,78 +537,6 @@ where result } - /// Computes a quote and returns the result wrapped in Arcs. - async fn make_quote( - &mut self, - min_buy: bitcoin::Amount, - max_buy: bitcoin::Amount, - ) -> Result, Arc> { - /// This is how long we maximally wait for the wallet lock - /// -- else the quote will be out of date and we will return - /// an error. - const MAX_WAIT_DURATION: Duration = Duration::from_secs(60); - - let ask_price = self - .latest_rate - .latest_rate() - .map_err(|e| Arc::new(anyhow!(e).context("Failed to get latest rate")))? - .ask() - .map_err(|e| Arc::new(e.context("Failed to compute asking price")))?; - - let balance = timeout(MAX_WAIT_DURATION, self.monero_wallet.lock()) - .await - .context("Timeout while waiting for lock on monero wallet while making quote")? - .get_balance() - .await - .map_err(|e| Arc::new(e.context("Failed to get Monero balance")))?; - let xmr_balance = Amount::from_piconero(balance.unlocked_balance); - - let max_bitcoin_for_monero = - xmr_balance - .max_bitcoin_for_price(ask_price) - .ok_or_else(|| { - Arc::new(anyhow!( - "Bitcoin price ({}) x Monero ({}) overflow", - ask_price, - xmr_balance - )) - })?; - - tracing::trace!(%ask_price, %xmr_balance, %max_bitcoin_for_monero, "Computed quote"); - - if min_buy > max_bitcoin_for_monero { - tracing::trace!( - "Your Monero balance is too low to initiate a swap, as your minimum swap amount is {}. You could at most swap {}", - min_buy, max_bitcoin_for_monero - ); - - return Ok(Arc::new(BidQuote { - price: ask_price, - min_quantity: bitcoin::Amount::ZERO, - max_quantity: bitcoin::Amount::ZERO, - })); - } - - if max_buy > max_bitcoin_for_monero { - tracing::trace!( - "Your Monero balance is too low to initiate a swap with the maximum swap amount {} that you have specified in your config. You can at most swap {}", - max_buy, max_bitcoin_for_monero - ); - - return Ok(Arc::new(BidQuote { - price: ask_price, - min_quantity: min_buy, - max_quantity: max_bitcoin_for_monero, - })); - } - - Ok(Arc::new(BidQuote { - price: ask_price, - min_quantity: min_buy, - max_quantity: max_buy, - })) - } - async fn handle_execution_setup_done( &mut self, bob_peer_id: PeerId, @@ -814,6 +768,123 @@ impl EventLoopHandle { } } +/// Computes a quote given the provided dependencies +pub async fn make_quote( + min_buy: bitcoin::Amount, + max_buy: bitcoin::Amount, + mut latest_rate: LR, + get_unlocked_balance: F, + get_reserved_items: I, +) -> Result, Arc> +where + LR: LatestRate, + F: FnOnce() -> Fut, + Fut: futures::Future>, + I: FnOnce() -> Fut2, + Fut2: futures::Future, anyhow::Error>>, + T: ReservesMonero, +{ + let ask_price = latest_rate + .latest_rate() + .map_err(|e| Arc::new(anyhow!(e).context("Failed to get latest rate")))? + .ask() + .map_err(|e| Arc::new(e.context("Failed to compute asking price")))?; + + // Get the unlocked balance + let unlocked_balance = get_unlocked_balance() + .await + .context("Failed to get unlocked Monero balance") + .map_err(Arc::new)?; + + // Get the reserved amounts + let reserved_amounts: Vec = get_reserved_items() + .await + .context("Failed to get reserved items") + .map_err(Arc::new)? + .into_iter() + .map(|item| item.reserved_monero()) + .collect(); + + let unreserved_xmr_balance = + unreserved_monero_balance(unlocked_balance, reserved_amounts.into_iter()); + + let max_bitcoin_for_monero = unreserved_xmr_balance + .max_bitcoin_for_price(ask_price) + .ok_or_else(|| { + Arc::new(anyhow!( + "Bitcoin price ({}) x Monero ({}) overflow", + ask_price, + unreserved_xmr_balance + )) + })?; + + tracing::trace!(%ask_price, %unreserved_xmr_balance, %max_bitcoin_for_monero, "Computed quote"); + + if min_buy > max_bitcoin_for_monero { + tracing::trace!( + "Your Monero balance is too low to initiate a swap, as your minimum swap amount is {}. You could at most swap {}", + min_buy, max_bitcoin_for_monero + ); + + return Ok(Arc::new(BidQuote { + price: ask_price, + min_quantity: bitcoin::Amount::ZERO, + max_quantity: bitcoin::Amount::ZERO, + })); + } + + if max_buy > max_bitcoin_for_monero { + tracing::trace!( + "Your Monero balance is too low to initiate a swap with the maximum swap amount {} that you have specified in your config. You can at most swap {}", + max_buy, max_bitcoin_for_monero + ); + + return Ok(Arc::new(BidQuote { + price: ask_price, + min_quantity: min_buy, + max_quantity: max_bitcoin_for_monero, + })); + } + + Ok(Arc::new(BidQuote { + price: ask_price, + min_quantity: min_buy, + max_quantity: max_buy, + })) +} + +/// Calculates the unreserved Monero balance by subtracting reserved amounts from unlocked balance +pub fn unreserved_monero_balance( + unlocked_balance: Amount, + reserved_amounts: impl Iterator, +) -> Amount { + // Get the sum of all the individual reserved amounts + let total_reserved = reserved_amounts.fold(Amount::ZERO, |acc, amount| acc + amount); + + // Check how much of our unlocked balance is left when we + // take into account the reserved amounts + unlocked_balance + .checked_sub(total_reserved) + .unwrap_or(Amount::ZERO) +} + +/// Returns the unlocked Monero balance from the wallet +async fn unlocked_monero_balance_with_timeout( + wallet: Arc>, +) -> Result { + /// This is how long we maximally wait to get access to the wallet + const MONERO_WALLET_MUTEX_LOCK_TIMEOUT: Duration = Duration::from_secs(10); + + let balance = timeout(MONERO_WALLET_MUTEX_LOCK_TIMEOUT, wallet.lock()) + .await + .context("Timeout while waiting for lock on Monero wallet")? + .get_balance() + .await + .context("Failed to get Monero balance")?; + + Ok(Amount::from_piconero(balance.unlocked_balance)) +} + #[allow(missing_debug_implementations)] struct MpscChannels { sender: mpsc::Sender, @@ -826,3 +897,278 @@ impl Default for MpscChannels { MpscChannels { sender, receiver } } } + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn test_unreserved_monero_balance_with_no_reserved_amounts() { + let balance = Amount::from_monero(10.0).unwrap(); + let reserved_amounts = vec![]; + + let result = unreserved_monero_balance(balance, reserved_amounts.into_iter()); + + assert_eq!(result, balance); + } + + #[tokio::test] + async fn test_unreserved_monero_balance_with_reserved_amounts() { + let balance = Amount::from_monero(10.0).unwrap(); + let reserved_amounts = vec![ + Amount::from_monero(2.0).unwrap(), + Amount::from_monero(3.0).unwrap(), + ]; + + let result = unreserved_monero_balance(balance, reserved_amounts.into_iter()); + + let expected = Amount::from_monero(5.0).unwrap(); + assert_eq!(result, expected); + } + + #[tokio::test] + async fn test_unreserved_monero_balance_insufficient_balance() { + let balance = Amount::from_monero(5.0).unwrap(); + let reserved_amounts = vec![ + Amount::from_monero(3.0).unwrap(), + Amount::from_monero(4.0).unwrap(), // Total reserved > balance + ]; + + let result = unreserved_monero_balance(balance, reserved_amounts.into_iter()); + + // Should return zero when reserved > balance + assert_eq!(result, Amount::ZERO); + } + + #[tokio::test] + async fn test_unreserved_monero_balance_exact_match() { + let balance = Amount::from_monero(10.0).unwrap(); + let reserved_amounts = vec![ + Amount::from_monero(4.0).unwrap(), + Amount::from_monero(6.0).unwrap(), // Exactly equals balance + ]; + + let result = unreserved_monero_balance(balance, reserved_amounts.into_iter()); + + assert_eq!(result, Amount::ZERO); + } + + #[tokio::test] + async fn test_unreserved_monero_balance_zero_balance() { + let balance = Amount::ZERO; + let reserved_amounts = vec![Amount::from_monero(1.0).unwrap()]; + + let result = unreserved_monero_balance(balance, reserved_amounts.into_iter()); + + assert_eq!(result, Amount::ZERO); + } + + #[tokio::test] + async fn test_unreserved_monero_balance_empty_reserved_amounts() { + let balance = Amount::from_monero(5.0).unwrap(); + let reserved_amounts: Vec = vec![]; + + let result = unreserved_monero_balance(balance, reserved_amounts.into_iter()); + + assert_eq!(result, balance); + } + + #[tokio::test] + async fn test_unreserved_monero_balance_large_amounts() { + let balance = Amount::from_piconero(1_000_000_000); + let reserved_amounts = vec![Amount::from_piconero(300_000_000)]; + + let result = unreserved_monero_balance(balance, reserved_amounts.into_iter()); + + let expected = Amount::from_piconero(700_000_000); + assert_eq!(result, expected); + } + + #[tokio::test] + async fn test_make_quote_successful_within_limits() { + let min_buy = bitcoin::Amount::from_sat(100_000); + let max_buy = bitcoin::Amount::from_sat(500_000); + let rate = FixedRate::default(); + let balance = Amount::from_monero(1.0).unwrap(); + let reserved_items: Vec = vec![]; + + let result = make_quote( + min_buy, + max_buy, + rate.clone(), + || async { Ok(balance) }, + || async { Ok(reserved_items) }, + ) + .await + .unwrap(); + + assert_eq!(result.price, rate.value().ask().unwrap()); + assert_eq!(result.min_quantity, min_buy); + assert_eq!(result.max_quantity, max_buy); + } + + #[tokio::test] + async fn test_make_quote_with_reserved_amounts() { + let min_buy = bitcoin::Amount::from_sat(50_000); + let max_buy = bitcoin::Amount::from_sat(300_000); + let rate = FixedRate::default(); + let balance = Amount::from_monero(1.0).unwrap(); + let reserved_items = vec![ + MockReservedItem { + reserved: Amount::from_monero(0.2).unwrap(), + }, + MockReservedItem { + reserved: Amount::from_monero(0.3).unwrap(), + }, + ]; + + let result = make_quote( + min_buy, + max_buy, + rate.clone(), + || async { Ok(balance) }, + || async { Ok(reserved_items) }, + ) + .await + .unwrap(); + + // With 1.0 XMR balance and 0.5 XMR reserved, we have 0.5 XMR available + // At rate 0.01, that's 0.005 BTC = 500,000 sats maximum + let expected_max = bitcoin::Amount::from_sat(300_000); // Limited by max_buy + assert_eq!(result.min_quantity, min_buy); + assert_eq!(result.max_quantity, expected_max); + } + + #[tokio::test] + async fn test_make_quote_insufficient_balance_for_min() { + let min_buy = bitcoin::Amount::from_sat(600_000); // More than available + let max_buy = bitcoin::Amount::from_sat(800_000); + let rate = FixedRate::default(); + let balance = Amount::from_monero(0.5).unwrap(); // Only 0.005 BTC worth at rate 0.01 + let reserved_items: Vec = vec![]; + + let result = make_quote( + min_buy, + max_buy, + rate.clone(), + || async { Ok(balance) }, + || async { Ok(reserved_items) }, + ) + .await + .unwrap(); + + // Should return zero quantities when min_buy exceeds available balance + assert_eq!(result.min_quantity, bitcoin::Amount::ZERO); + assert_eq!(result.max_quantity, bitcoin::Amount::ZERO); + } + + #[tokio::test] + async fn test_make_quote_limited_by_balance() { + let min_buy = bitcoin::Amount::from_sat(100_000); + let max_buy = bitcoin::Amount::from_sat(800_000); // More than available + let rate = FixedRate::default(); + let balance = Amount::from_monero(0.6).unwrap(); // 0.006 BTC worth at rate 0.01 + let reserved_items: Vec = vec![]; + + let result = make_quote( + min_buy, + max_buy, + rate.clone(), + || async { Ok(balance) }, + || async { Ok(reserved_items) }, + ) + .await + .unwrap(); + + // Calculate the actual max bitcoin for the given balance and rate + let expected_max = balance + .max_bitcoin_for_price(rate.value().ask().unwrap()) + .unwrap(); + assert_eq!(result.min_quantity, min_buy); + assert_eq!(result.max_quantity, expected_max); + } + + #[tokio::test] + async fn test_make_quote_all_balance_reserved() { + let min_buy = bitcoin::Amount::from_sat(100_000); + let max_buy = bitcoin::Amount::from_sat(500_000); + let rate = FixedRate::default(); + let balance = Amount::from_monero(1.0).unwrap(); + let reserved_items = vec![MockReservedItem { + reserved: Amount::from_monero(1.0).unwrap(), // All balance reserved + }]; + + let result = make_quote( + min_buy, + max_buy, + rate.clone(), + || async { Ok(balance) }, + || async { Ok(reserved_items) }, + ) + .await + .unwrap(); + + // Should return zero quantities when all balance is reserved + assert_eq!(result.min_quantity, bitcoin::Amount::ZERO); + assert_eq!(result.max_quantity, bitcoin::Amount::ZERO); + } + + #[tokio::test] + async fn test_make_quote_error_getting_balance() { + let min_buy = bitcoin::Amount::from_sat(100_000); + let max_buy = bitcoin::Amount::from_sat(500_000); + let rate = FixedRate::default(); + let reserved_items: Vec = vec![]; + + let result = make_quote( + min_buy, + max_buy, + rate.clone(), + || async { Err(anyhow::anyhow!("Failed to get balance")) }, + || async { Ok(reserved_items) }, + ) + .await; + + assert!(result.is_err()); + assert!(result + .unwrap_err() + .to_string() + .contains("Failed to get unlocked Monero balance to construct quote")); + } + + #[tokio::test] + async fn test_make_quote_empty_reserved_items() { + let min_buy = bitcoin::Amount::from_sat(100_000); + let max_buy = bitcoin::Amount::from_sat(500_000); + let rate = FixedRate::default(); + let balance = Amount::from_monero(1.0).unwrap(); + let reserved_items: Vec = vec![]; + + let result = make_quote( + min_buy, + max_buy, + rate.clone(), + || async { Ok(balance) }, + || async { Ok(reserved_items) }, + ) + .await + .unwrap(); + + // Should work normally with empty reserved items + assert_eq!(result.price, rate.value().ask().unwrap()); + assert_eq!(result.min_quantity, min_buy); + assert_eq!(result.max_quantity, max_buy); + } + + // Mock struct for testing + #[derive(Debug, Clone)] + struct MockReservedItem { + reserved: Amount, + } + + impl ReservesMonero for MockReservedItem { + fn reserved_monero(&self) -> Amount { + self.reserved + } + } +} diff --git a/swap/src/monero.rs b/swap/src/monero.rs index 69bb4bb5..751f2e0e 100644 --- a/swap/src/monero.rs +++ b/swap/src/monero.rs @@ -9,7 +9,7 @@ pub use wallet::Wallet; pub use wallet_rpc::{WalletRpc, WalletRpcProcess}; use crate::bitcoin; -use anyhow::Result; +use anyhow::{bail, Result}; use rand::{CryptoRng, RngCore}; use rust_decimal::prelude::*; use rust_decimal::Decimal; @@ -131,14 +131,34 @@ impl Amount { .expect("Conversion from piconero to XMR should not overflow f64") } - /// Calculate the maximum amount of Bitcoin that can be bought at a given - /// asking price for this amount of Monero including the median fee. - pub fn max_bitcoin_for_price(&self, ask_price: bitcoin::Amount) -> Option { + /// Calculate the conservative max giveable of Monero we can spent given [`self`] is the balance + /// of a Monero wallet + /// This is going to be LESS than we can really spent because we assume a high fee + pub fn max_conservative_giveable(&self) -> Self { let pico_minus_fee = self .as_piconero() .saturating_sub(CONSERVATIVE_MONERO_FEE.as_piconero()); - if pico_minus_fee == 0 { + Self::from_piconero(pico_minus_fee) + } + + /// Calculate the Monero balance needed to send the [`self`] Amount to another address + /// E.g: Amount(1 XMR).min_conservative_balance_to_spend() with a fee of 0.1 XMR would be 1.1 XMR + /// This is going to be MORE than we really need because we assume a high fee + pub fn min_conservative_balance_to_spend(&self) -> Self { + let pico_minus_fee = self + .as_piconero() + .saturating_add(CONSERVATIVE_MONERO_FEE.as_piconero()); + + Self::from_piconero(pico_minus_fee) + } + + /// Calculate the maximum amount of Bitcoin that can be bought at a given + /// asking price for this amount of Monero including the median fee. + pub fn max_bitcoin_for_price(&self, ask_price: bitcoin::Amount) -> Option { + let pico_minus_fee = self.max_conservative_giveable(); + + if pico_minus_fee.as_piconero() == 0 { return Some(bitcoin::Amount::ZERO); } @@ -147,7 +167,7 @@ impl Amount { let pico_per_xmr = Decimal::from(PICONERO_OFFSET); let ask_sats_per_pico = ask_sats / pico_per_xmr; - let pico = Decimal::from(pico_minus_fee); + let pico = Decimal::from(pico_minus_fee.as_piconero()); let max_sats = pico.checked_mul(ask_sats_per_pico)?; let satoshi = max_sats.to_u64()?; @@ -176,6 +196,15 @@ impl Amount { .ok_or_else(|| OverflowError(amount.to_string()))?; Ok(Amount(piconeros)) } + + /// Subtract but throw an error on underflow. + pub fn checked_sub(self, rhs: Amount) -> Result { + if self.0 < rhs.0 { + bail!("checked sub would underflow"); + } + + Ok(Amount::from_piconero(self.0 - rhs.0)) + } } impl Add for Amount { @@ -186,7 +215,7 @@ impl Add for Amount { } } -impl Sub for Amount { +impl Sub for Amount { type Output = Amount; fn sub(self, rhs: Self) -> Self::Output { @@ -511,7 +540,7 @@ mod tests { let xmr = Amount::parse_monero("10").unwrap(); let btc = xmr.max_bitcoin_for_price(ask).unwrap(); - assert_eq!(btc, bitcoin::Amount::from_sat(3_828_993)); + assert_eq!(btc, bitcoin::Amount::from_sat(3_827_851)); // example from https://github.com/comit-network/xmr-btc-swap/issues/1084 // with rate from kraken at that time @@ -519,7 +548,7 @@ mod tests { let xmr = Amount::parse_monero("0.826286435921").unwrap(); let btc = xmr.max_bitcoin_for_price(ask).unwrap(); - assert_eq!(btc, bitcoin::Amount::from_sat(566_656)); + assert_eq!(btc, bitcoin::Amount::from_sat(564_609)); } #[test] @@ -528,7 +557,7 @@ mod tests { let ask = bitcoin::Amount::from_sat(728_688); let btc = xmr.max_bitcoin_for_price(ask).unwrap(); - assert_eq!(bitcoin::Amount::from_sat(21_860_628), btc); + assert_eq!(bitcoin::Amount::from_sat(21_858_453), btc); let xmr = Amount::from_piconero(u64::MAX); let ask = bitcoin::Amount::from_sat(u64::MAX); @@ -582,4 +611,122 @@ mod tests { let decoded: MoneroAmount = serde_cbor::from_slice(&encoded).unwrap(); assert_eq!(amount, decoded); } + + #[test] + fn max_conservative_giveable_basic() { + // Test with balance larger than fee + let balance = Amount::parse_monero("1.0").unwrap(); + let giveable = balance.max_conservative_giveable(); + let expected = balance.as_piconero() - CONSERVATIVE_MONERO_FEE.as_piconero(); + assert_eq!(giveable.as_piconero(), expected); + } + + #[test] + fn max_conservative_giveable_exact_fee() { + // Test with balance exactly equal to fee + let balance = CONSERVATIVE_MONERO_FEE; + let giveable = balance.max_conservative_giveable(); + assert_eq!(giveable, Amount::ZERO); + } + + #[test] + fn max_conservative_giveable_less_than_fee() { + // Test with balance less than fee (should saturate to 0) + let balance = Amount::from_piconero(CONSERVATIVE_MONERO_FEE.as_piconero() / 2); + let giveable = balance.max_conservative_giveable(); + assert_eq!(giveable, Amount::ZERO); + } + + #[test] + fn max_conservative_giveable_zero_balance() { + // Test with zero balance + let balance = Amount::ZERO; + let giveable = balance.max_conservative_giveable(); + assert_eq!(giveable, Amount::ZERO); + } + + #[test] + fn max_conservative_giveable_large_balance() { + // Test with large balance + let balance = Amount::parse_monero("100.0").unwrap(); + let giveable = balance.max_conservative_giveable(); + let expected = balance.as_piconero() - CONSERVATIVE_MONERO_FEE.as_piconero(); + assert_eq!(giveable.as_piconero(), expected); + + // Ensure the result makes sense + assert!(giveable.as_piconero() > 0); + assert!(giveable < balance); + } + + #[test] + fn min_conservative_balance_to_spend_basic() { + // Test with 1 XMR amount to send + let amount_to_send = Amount::parse_monero("1.0").unwrap(); + let min_balance = amount_to_send.min_conservative_balance_to_spend(); + let expected = amount_to_send.as_piconero() + CONSERVATIVE_MONERO_FEE.as_piconero(); + assert_eq!(min_balance.as_piconero(), expected); + } + + #[test] + fn min_conservative_balance_to_spend_zero() { + // Test with zero amount to send + let amount_to_send = Amount::ZERO; + let min_balance = amount_to_send.min_conservative_balance_to_spend(); + assert_eq!(min_balance, CONSERVATIVE_MONERO_FEE); + } + + #[test] + fn min_conservative_balance_to_spend_small_amount() { + // Test with small amount + let amount_to_send = Amount::from_piconero(1000); + let min_balance = amount_to_send.min_conservative_balance_to_spend(); + let expected = 1000 + CONSERVATIVE_MONERO_FEE.as_piconero(); + assert_eq!(min_balance.as_piconero(), expected); + } + + #[test] + fn min_conservative_balance_to_spend_large_amount() { + // Test with large amount + let amount_to_send = Amount::parse_monero("50.0").unwrap(); + let min_balance = amount_to_send.min_conservative_balance_to_spend(); + let expected = amount_to_send.as_piconero() + CONSERVATIVE_MONERO_FEE.as_piconero(); + assert_eq!(min_balance.as_piconero(), expected); + + // Ensure the result makes sense + assert!(min_balance > amount_to_send); + assert!(min_balance > CONSERVATIVE_MONERO_FEE); + } + + #[test] + fn conservative_fee_functions_are_inverse() { + // Test that the functions are somewhat inverse of each other + let original_balance = Amount::parse_monero("5.0").unwrap(); + + // Get max giveable amount + let max_giveable = original_balance.max_conservative_giveable(); + + // Calculate min balance needed to send that amount + let min_balance_needed = max_giveable.min_conservative_balance_to_spend(); + + // The min balance needed should be equal to or slightly more than the original balance + // (due to the conservative nature of the fee estimation) + assert!(min_balance_needed >= original_balance); + + // The difference should be at most the conservative fee + let difference = min_balance_needed.as_piconero() - original_balance.as_piconero(); + assert!(difference <= CONSERVATIVE_MONERO_FEE.as_piconero()); + } + + #[test] + fn conservative_fee_edge_cases() { + // Test with maximum possible amount + let max_amount = Amount::from_piconero(u64::MAX - CONSERVATIVE_MONERO_FEE.as_piconero()); + let giveable = max_amount.max_conservative_giveable(); + assert!(giveable.as_piconero() > 0); + + // Test min balance calculation doesn't overflow + let large_amount = Amount::from_piconero(u64::MAX / 2); + let min_balance = large_amount.min_conservative_balance_to_spend(); + assert!(min_balance > large_amount); + } } diff --git a/swap/src/protocol/alice/state.rs b/swap/src/protocol/alice/state.rs index 6fcfcad4..3fb047ec 100644 --- a/swap/src/protocol/alice/state.rs +++ b/swap/src/protocol/alice/state.rs @@ -587,3 +587,30 @@ impl State3 { ) } } + +pub trait ReservesMonero { + fn reserved_monero(&self) -> monero::Amount; +} + +impl ReservesMonero for AliceState { + /// Returns the Monero amount we need to reserve for this swap + /// i.e funds we should not use for other things + fn reserved_monero(&self) -> monero::Amount { + match self { + // We haven't seen proof yet that Bob has locked the Bitcoin + // We must assume he will not lock the Bitcoin to avoid being + // susceptible to a DoS attack + AliceState::Started { .. } => monero::Amount::ZERO, + // These are the only states where we have to assume we will have to lock + // our Monero, and we haven't done so yet. + AliceState::BtcLockTransactionSeen { state3 } | AliceState::BtcLocked { state3 } => { + // We reserve as much Monero as we need for the output of the lock transaction + // and as we need for the network fee + state3.xmr.min_conservative_balance_to_spend() + } + // For all other states we either have already locked the Monero + // or we can be sure that we don't have to lock our Monero in the future + _ => monero::Amount::ZERO, + } + } +}