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 <binarybaron@protonmail.com>
This commit is contained in:
Raphael 2025-06-05 00:15:42 +02:00 committed by GitHub
parent b49b5bdbd6
commit 3b3bea8531
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 609 additions and 86 deletions

View file

@ -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

View file

@ -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

View file

@ -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<BidQuote>, Arc<anyhow::Error>> {
/// 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<LR, F, Fut, I, Fut2, T>(
min_buy: bitcoin::Amount,
max_buy: bitcoin::Amount,
mut latest_rate: LR,
get_unlocked_balance: F,
get_reserved_items: I,
) -> Result<Arc<BidQuote>, Arc<anyhow::Error>>
where
LR: LatestRate,
F: FnOnce() -> Fut,
Fut: futures::Future<Output = Result<Amount, anyhow::Error>>,
I: FnOnce() -> Fut2,
Fut2: futures::Future<Output = Result<Vec<T>, 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<Amount> = 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<Item = Amount>,
) -> 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<Mutex<monero::Wallet>>,
) -> Result<Amount, anyhow::Error> {
/// 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<T> {
sender: mpsc::Sender<T>,
@ -826,3 +897,278 @@ impl<T> Default for MpscChannels<T> {
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<Amount> = 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<MockReservedItem> = 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<MockReservedItem> = 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<MockReservedItem> = 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<MockReservedItem> = 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<MockReservedItem> = 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
}
}
}

View file

@ -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<bitcoin::Amount> {
/// 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<bitcoin::Amount> {
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<Self> {
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<Amount> 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);
}
}

View file

@ -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,
}
}
}