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]
|
||||
|
||||
- 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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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