Skip to content

feat(asb): Take ongoing swaps into consideration when crafting quote #245

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 14 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.0.0-rc.17] - 2025-04-18

- GUI: The user will now be asked to approve the swap offer again before the Bitcoin lock transaction is published. Makers should take care to only assume a swap has been accepted by the taker if the Bitcoin lock transaction is detected (`Advancing state state=bitcoin lock transaction in mempool ...`). Swaps that have been safely aborted will not be displayed in the GUI anymore.
Expand All @@ -30,7 +32,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

Expand Down
2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[workspace]
resolver = "2"
members = [ "monero-harness", "monero-rpc", "swap", "monero-wallet", "src-tauri" ]
members = [ "monero-harness", "monero-rpc", "swap", "monero-wallet", "src-tauri", "monero-sys" ]

[patch.crates-io]
# patch until new release https://github.com/thomaseizinger/rust-jsonrpc-client/pull/51
Expand Down
4 changes: 3 additions & 1 deletion dev-docs/asb/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
79 changes: 53 additions & 26 deletions swap/src/asb/event_loop.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
use crate::asb::{Behaviour, OutEvent, Rate};
use crate::monero::Amount;
use crate::monero::{Amount, MONERO_FEE};
use crate::network::cooperative_xmr_redeem_after_punish::CooperativeXmrRedeemRejectReason;
use crate::network::cooperative_xmr_redeem_after_punish::Response::{Fullfilled, Rejected};
use crate::network::quote::BidQuote;
Expand Down Expand Up @@ -511,44 +511,71 @@ where
result
}

/// Returns the unreserved Monero balance
/// Meaning the Monero balance that is not reserved for ongoing swaps and can be used for new swaps
async fn unreserved_monero_balance(&self) -> Result<Amount, Arc<anyhow::Error>> {
/// This is how long we maximally wait to get access to the wallet
const MAX_WAIT_DURATION: Duration = Duration::from_secs(60);

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 balance = Amount::from_piconero(balance.unlocked_balance);

// From our full balance we need to subtract any Monero that is 'reserved' for ongoing swaps
// Those swaps where the Bitcoin has been locked (may be unconfirmed or confirmed) but we haven't locked the Monero yet
// This is a naive approach because:
// - Suppose we have two UTXOs each 5 XMR
// - We have a pending swap for 6 XMR
// The code will assume we only have reserved 6 XMR but once we lock the 6 XMR our two outputs will be spent
// and it'll take 10 blocks before we can use our new output (4 XMR change) again
let reserved: Amount = self
.db
.all()
.await?
.iter()
.filter_map(|(_, state)| match state {
State::Alice(AliceState::BtcLockTransactionSeen { state3 })
| State::Alice(AliceState::BtcLocked { state3 }) => Some(state3.xmr + MONERO_FEE),
_ => None,
})
.fold(Amount::ZERO, |acc, amount| acc + amount);

let free_monero_balance = balance.checked_sub(reserved).unwrap_or(Amount::ZERO);

Ok(free_monero_balance)
}

/// 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");
let unreserved_xmr_balance = self.unreserved_monero_balance().await?;

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!(
Expand Down
13 changes: 11 additions & 2 deletions swap/src/monero.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -164,6 +164,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 {
Expand All @@ -174,7 +183,7 @@ impl Add for Amount {
}
}

impl Sub for Amount {
impl Sub<Amount> for Amount {
type Output = Amount;

fn sub(self, rhs: Self) -> Self::Output {
Expand Down
Loading