mirror of
https://github.com/comit-network/xmr-btc-swap.git
synced 2025-11-25 10:23:20 -05:00
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:
parent
b49b5bdbd6
commit
3b3bea8531
5 changed files with 609 additions and 86 deletions
|
|
@ -7,6 +7,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||||
|
|
||||||
## [Unreleased]
|
## [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
|
## [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
|
- 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
|
## [1.0.0-rc.12] - 2025-01-14
|
||||||
|
|
||||||
- GUI: Fixed a bug where the CLI wasn't passed the correct Monero node.
|
- GUI: Fixed a bug where the CLI wasn't passed the correct Monero node.
|
||||||
-
|
|
||||||
|
|
||||||
## [1.0.0-rc.11] - 2024-12-22
|
## [1.0.0-rc.11] - 2024-12-22
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -31,7 +31,9 @@ Consider joining the designated [Matrix chat](https://matrix.to/#/%23unstoppable
|
||||||
|
|
||||||
### Using Docker
|
### 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
|
## ASB Details
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@ use crate::network::quote::BidQuote;
|
||||||
use crate::network::swap_setup::alice::WalletSnapshot;
|
use crate::network::swap_setup::alice::WalletSnapshot;
|
||||||
use crate::network::transfer_proof;
|
use crate::network::transfer_proof;
|
||||||
use crate::protocol::alice::swap::has_already_processed_enc_sig;
|
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::protocol::{Database, State};
|
||||||
use crate::{bitcoin, env, kraken, monero};
|
use crate::{bitcoin, env, kraken, monero};
|
||||||
use anyhow::{anyhow, Context, Result};
|
use anyhow::{anyhow, Context, Result};
|
||||||
|
|
@ -501,7 +501,33 @@ where
|
||||||
|
|
||||||
// We have a cache miss, so we compute a new quote
|
// We have a cache miss, so we compute a new quote
|
||||||
tracing::trace!("Got a request for a quote, computing 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
|
// Insert the computed quote into the cache
|
||||||
// Need to clone it as insert takes ownership
|
// Need to clone it as insert takes ownership
|
||||||
|
|
@ -511,78 +537,6 @@ where
|
||||||
result
|
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(
|
async fn handle_execution_setup_done(
|
||||||
&mut self,
|
&mut self,
|
||||||
bob_peer_id: PeerId,
|
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)]
|
#[allow(missing_debug_implementations)]
|
||||||
struct MpscChannels<T> {
|
struct MpscChannels<T> {
|
||||||
sender: mpsc::Sender<T>,
|
sender: mpsc::Sender<T>,
|
||||||
|
|
@ -826,3 +897,278 @@ impl<T> Default for MpscChannels<T> {
|
||||||
MpscChannels { sender, receiver }
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@ pub use wallet::Wallet;
|
||||||
pub use wallet_rpc::{WalletRpc, WalletRpcProcess};
|
pub use wallet_rpc::{WalletRpc, WalletRpcProcess};
|
||||||
|
|
||||||
use crate::bitcoin;
|
use crate::bitcoin;
|
||||||
use anyhow::Result;
|
use anyhow::{bail, Result};
|
||||||
use rand::{CryptoRng, RngCore};
|
use rand::{CryptoRng, RngCore};
|
||||||
use rust_decimal::prelude::*;
|
use rust_decimal::prelude::*;
|
||||||
use rust_decimal::Decimal;
|
use rust_decimal::Decimal;
|
||||||
|
|
@ -131,14 +131,34 @@ impl Amount {
|
||||||
.expect("Conversion from piconero to XMR should not overflow f64")
|
.expect("Conversion from piconero to XMR should not overflow f64")
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Calculate the maximum amount of Bitcoin that can be bought at a given
|
/// Calculate the conservative max giveable of Monero we can spent given [`self`] is the balance
|
||||||
/// asking price for this amount of Monero including the median fee.
|
/// of a Monero wallet
|
||||||
pub fn max_bitcoin_for_price(&self, ask_price: bitcoin::Amount) -> Option<bitcoin::Amount> {
|
/// 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
|
let pico_minus_fee = self
|
||||||
.as_piconero()
|
.as_piconero()
|
||||||
.saturating_sub(CONSERVATIVE_MONERO_FEE.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);
|
return Some(bitcoin::Amount::ZERO);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -147,7 +167,7 @@ impl Amount {
|
||||||
let pico_per_xmr = Decimal::from(PICONERO_OFFSET);
|
let pico_per_xmr = Decimal::from(PICONERO_OFFSET);
|
||||||
let ask_sats_per_pico = ask_sats / pico_per_xmr;
|
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 max_sats = pico.checked_mul(ask_sats_per_pico)?;
|
||||||
let satoshi = max_sats.to_u64()?;
|
let satoshi = max_sats.to_u64()?;
|
||||||
|
|
||||||
|
|
@ -176,6 +196,15 @@ impl Amount {
|
||||||
.ok_or_else(|| OverflowError(amount.to_string()))?;
|
.ok_or_else(|| OverflowError(amount.to_string()))?;
|
||||||
Ok(Amount(piconeros))
|
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 {
|
impl Add for Amount {
|
||||||
|
|
@ -186,7 +215,7 @@ impl Add for Amount {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Sub for Amount {
|
impl Sub<Amount> for Amount {
|
||||||
type Output = Amount;
|
type Output = Amount;
|
||||||
|
|
||||||
fn sub(self, rhs: Self) -> Self::Output {
|
fn sub(self, rhs: Self) -> Self::Output {
|
||||||
|
|
@ -511,7 +540,7 @@ mod tests {
|
||||||
let xmr = Amount::parse_monero("10").unwrap();
|
let xmr = Amount::parse_monero("10").unwrap();
|
||||||
let btc = xmr.max_bitcoin_for_price(ask).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
|
// example from https://github.com/comit-network/xmr-btc-swap/issues/1084
|
||||||
// with rate from kraken at that time
|
// with rate from kraken at that time
|
||||||
|
|
@ -519,7 +548,7 @@ mod tests {
|
||||||
let xmr = Amount::parse_monero("0.826286435921").unwrap();
|
let xmr = Amount::parse_monero("0.826286435921").unwrap();
|
||||||
let btc = xmr.max_bitcoin_for_price(ask).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]
|
#[test]
|
||||||
|
|
@ -528,7 +557,7 @@ mod tests {
|
||||||
let ask = bitcoin::Amount::from_sat(728_688);
|
let ask = bitcoin::Amount::from_sat(728_688);
|
||||||
let btc = xmr.max_bitcoin_for_price(ask).unwrap();
|
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 xmr = Amount::from_piconero(u64::MAX);
|
||||||
let ask = bitcoin::Amount::from_sat(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();
|
let decoded: MoneroAmount = serde_cbor::from_slice(&encoded).unwrap();
|
||||||
assert_eq!(amount, decoded);
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue