From b7929ca0c415b9d2904c5c596563500269e6d4aa Mon Sep 17 00:00:00 2001 From: binarybaron <86064887+binarybaron@users.noreply.github.com> Date: Fri, 20 Dec 2024 12:00:56 +0100 Subject: [PATCH] feat(asb): Print more information when history command is invoked (#218) --- CHANGELOG.md | 2 + swap/src/asb/command.rs | 21 +++-- swap/src/bin/asb.rs | 157 ++++++++++++++++++++++++++++--- swap/src/monero.rs | 12 +++ swap/src/protocol/alice/state.rs | 4 +- swap/src/protocol/alice/swap.rs | 2 +- 6 files changed, 175 insertions(+), 23 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ef21bdae9..ec7ce931d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +- ASB: The `history` command will now display additional information about each swap such as the amounts involved, the current state and the txid of the Bitcoin lock transaction. + ## [1.0.0-rc.10] - 2024-12-05 - GUI: Release .deb installer for Debian-based systems diff --git a/swap/src/asb/command.rs b/swap/src/asb/command.rs index f23c4ebad..91223cff9 100644 --- a/swap/src/asb/command.rs +++ b/swap/src/asb/command.rs @@ -31,12 +31,12 @@ where env_config: env_config(testnet), cmd: Command::Start { resume_only }, }, - RawCommand::History => Arguments { + RawCommand::History { only_unfinished } => Arguments { testnet, json, config_path: config_path(config, testnet)?, env_config: env_config(testnet), - cmd: Command::History, + cmd: Command::History { only_unfinished }, }, RawCommand::Logs { logs_dir: dir_path, @@ -197,7 +197,9 @@ pub enum Command { Start { resume_only: bool, }, - History, + History { + only_unfinished: bool, + }, Config, Logs { logs_dir: Option, @@ -295,7 +297,10 @@ pub enum RawCommand { swap_id: Option, }, #[structopt(about = "Prints swap-id and the state of each swap ever made.")] - History, + History { + #[structopt(long = "only-unfinished", help = "Only print in progress swaps")] + only_unfinished: bool, + }, #[structopt(about = "Prints the current config")] Config, #[structopt(about = "Allows withdrawing BTC from the internal Bitcoin wallet.")] @@ -411,7 +416,9 @@ mod tests { json: false, config_path: default_mainnet_conf_path, env_config: mainnet_env_config, - cmd: Command::History, + cmd: Command::History { + only_unfinished: false, + }, }; let args = parse_args(raw_ars).unwrap(); assert_eq!(expected_args, args); @@ -586,7 +593,9 @@ mod tests { json: false, config_path: default_testnet_conf_path, env_config: testnet_env_config, - cmd: Command::History, + cmd: Command::History { + only_unfinished: false, + }, }; let args = parse_args(raw_ars).unwrap(); assert_eq!(expected_args, args); diff --git a/swap/src/bin/asb.rs b/swap/src/bin/asb.rs index 19e3f29c0..72a4e6370 100644 --- a/swap/src/bin/asb.rs +++ b/swap/src/bin/asb.rs @@ -15,6 +15,8 @@ use anyhow::{bail, Context, Result}; use comfy_table::Table; use libp2p::Swarm; +use rust_decimal::prelude::FromPrimitive; +use rust_decimal::Decimal; use std::convert::TryInto; use std::env; use std::sync::Arc; @@ -31,10 +33,13 @@ use swap::common::{self, get_logs, warn_if_outdated}; use swap::database::{open_db, AccessMode}; use swap::network::rendezvous::XmrBtcNamespace; use swap::network::swarm; +use swap::protocol::alice::swap::is_complete; use swap::protocol::alice::{run, AliceState}; +use swap::protocol::{Database, State}; use swap::seed::Seed; use swap::{bitcoin, kraken, monero}; use tracing_subscriber::filter::LevelFilter; +use uuid::Uuid; const DEFAULT_WALLET_NAME: &str = "asb-wallet"; @@ -95,9 +100,11 @@ pub async fn main() -> Result<()> { let seed = Seed::from_file_or_generate(&config.data.dir).expect("Could not retrieve/initialize seed"); + let db_file = config.data.dir.join("sqlite"); + match cmd { Command::Start { resume_only } => { - let db = open_db(config.data.dir.join("sqlite"), AccessMode::ReadWrite, None).await?; + let db = open_db(db_file, AccessMode::ReadWrite, None).await?; // check and warn for duplicate rendezvous points let mut rendezvous_addrs = config.network.rendezvous_point.clone(); @@ -228,19 +235,49 @@ pub async fn main() -> Result<()> { event_loop.run().await; } - Command::History => { - let db = open_db(config.data.dir.join("sqlite"), AccessMode::ReadOnly, None).await?; - + Command::History { only_unfinished } => { + let db = open_db(db_file, AccessMode::ReadOnly, None).await?; let mut table = Table::new(); - table.set_header(vec!["SWAP ID", "STATE"]); + table.set_header(vec![ + "Swap ID", + "Start Date", + "State", + "Bitcoin Lock TxId", + "BTC Amount", + "XMR Amount", + "Exchange Rate", + "Taker Peer ID", + "Completed", + ]); + + let all_swaps = db.all().await?; + for (swap_id, state) in all_swaps { + let state: AliceState = state + .try_into() + .expect("Alice database only has Alice states"); + + if only_unfinished && is_complete(&state) { + continue; + } - for (swap_id, state) in db.all().await? { - let state: AliceState = state.try_into()?; - table.add_row(vec![swap_id.to_string(), state.to_string()]); + match SwapDetails::from_db_state(swap_id, state, &db).await { + Ok(details) => { + if json { + details.log_info(); + } else { + table.add_row(details.to_table_row()); + } + } + Err(e) => { + tracing::error!(swap_id = %swap_id, error = %e, "Failed to get swap details"); + } + } } - println!("{}", table); + if !json { + println!("{}", table); + } } Command::Config => { let config_json = serde_json::to_string_pretty(&config)?; @@ -289,7 +326,7 @@ pub async fn main() -> Result<()> { tracing::info!(%bitcoin_balance, %monero_balance, "Current balance"); } Command::Cancel { swap_id } => { - let db = open_db(config.data.dir.join("sqlite"), AccessMode::ReadWrite, None).await?; + let db = open_db(db_file, AccessMode::ReadWrite, None).await?; let bitcoin_wallet = init_bitcoin_wallet(&config, &seed, env_config).await?; @@ -298,7 +335,7 @@ pub async fn main() -> Result<()> { tracing::info!("Cancel transaction successfully published with id {}", txid); } Command::Refund { swap_id } => { - let db = open_db(config.data.dir.join("sqlite"), AccessMode::ReadWrite, None).await?; + let db = open_db(db_file, AccessMode::ReadWrite, None).await?; let bitcoin_wallet = init_bitcoin_wallet(&config, &seed, env_config).await?; let monero_wallet = init_monero_wallet(&config, env_config).await?; @@ -314,7 +351,7 @@ pub async fn main() -> Result<()> { tracing::info!("Monero successfully refunded"); } Command::Punish { swap_id } => { - let db = open_db(config.data.dir.join("sqlite"), AccessMode::ReadWrite, None).await?; + let db = open_db(db_file, AccessMode::ReadWrite, None).await?; let bitcoin_wallet = init_bitcoin_wallet(&config, &seed, env_config).await?; @@ -323,7 +360,7 @@ pub async fn main() -> Result<()> { tracing::info!("Punish transaction successfully published with id {}", txid); } Command::SafelyAbort { swap_id } => { - let db = open_db(config.data.dir.join("sqlite"), AccessMode::ReadWrite, None).await?; + let db = open_db(db_file, AccessMode::ReadWrite, None).await?; safely_abort(swap_id, db).await?; @@ -333,7 +370,7 @@ pub async fn main() -> Result<()> { swap_id, do_not_await_finality, } => { - let db = open_db(config.data.dir.join("sqlite"), AccessMode::ReadWrite, None).await?; + let db = open_db(db_file, AccessMode::ReadWrite, None).await?; let bitcoin_wallet = init_bitcoin_wallet(&config, &seed, env_config).await?; @@ -393,3 +430,95 @@ async fn init_monero_wallet( Ok(wallet) } + +/// This struct is used to extract swap details from the database and print them in a table format +#[derive(Debug)] +struct SwapDetails { + swap_id: String, + start_date: String, + state: String, + btc_lock_txid: String, + btc_amount: String, + xmr_amount: String, + exchange_rate: String, + peer_id: String, + completed: bool, +} + +impl SwapDetails { + async fn from_db_state( + swap_id: Uuid, + latest_state: AliceState, + db: &Arc, + ) -> Result { + let completed = is_complete(&latest_state); + + let all_states = db.get_states(swap_id).await?; + let state3 = all_states + .iter() + .find_map(|s| match s { + State::Alice(AliceState::BtcLockTransactionSeen { state3 }) => Some(state3), + _ => None, + }) + .context("Failed to get \"BtcLockTransactionSeen\" state")?; + + let exchange_rate = Self::calculate_exchange_rate(state3.btc, state3.xmr)?; + let start_date = db.get_swap_start_date(swap_id).await?; + let btc_lock_txid = state3.tx_lock.txid(); + let peer_id = db.get_peer_id(swap_id).await?; + + Ok(Self { + swap_id: swap_id.to_string(), + start_date: start_date.to_string(), + state: latest_state.to_string(), + btc_lock_txid: btc_lock_txid.to_string(), + btc_amount: state3.btc.to_string(), + xmr_amount: state3.xmr.to_string(), + exchange_rate, + peer_id: peer_id.to_string(), + completed, + }) + } + + fn calculate_exchange_rate(btc: bitcoin::Amount, xmr: monero::Amount) -> Result { + let btc_decimal = Decimal::from_f64(btc.to_btc()) + .ok_or_else(|| anyhow::anyhow!("Failed to convert BTC amount to Decimal"))?; + let xmr_decimal = Decimal::from_f64(xmr.as_xmr()) + .ok_or_else(|| anyhow::anyhow!("Failed to convert XMR amount to Decimal"))?; + + let rate = btc_decimal + .checked_div(xmr_decimal) + .ok_or_else(|| anyhow::anyhow!("Division by zero or overflow"))?; + + Ok(format!("{} XMR/BTC", rate.round_dp(8))) + } + + fn to_table_row(&self) -> Vec { + vec![ + self.swap_id.clone(), + self.start_date.clone(), + self.state.clone(), + self.btc_lock_txid.clone(), + self.btc_amount.clone(), + self.xmr_amount.clone(), + self.exchange_rate.clone(), + self.peer_id.clone(), + self.completed.to_string(), + ] + } + + fn log_info(&self) { + tracing::info!( + swap_id = %self.swap_id, + swap_start_date = %self.start_date, + latest_state = %self.state, + btc_lock_txid = %self.btc_lock_txid, + btc_amount = %self.btc_amount, + xmr_amount = %self.xmr_amount, + exchange_rate = %self.exchange_rate, + taker_peer_id = %self.peer_id, + completed = self.completed, + "Found swap in database" + ); + } +} diff --git a/swap/src/monero.rs b/swap/src/monero.rs index f23254ff9..d96c21446 100644 --- a/swap/src/monero.rs +++ b/swap/src/monero.rs @@ -109,6 +109,18 @@ impl Amount { self.0 } + /// Return Monero Amount as XMR. + pub fn as_xmr(&self) -> f64 { + let amount_decimal = Decimal::from(self.0); + let offset_decimal = Decimal::from(PICONERO_OFFSET); + let result = amount_decimal / offset_decimal; + + // Convert to f64 only at the end, after the division + result + .to_f64() + .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 { diff --git a/swap/src/protocol/alice/state.rs b/swap/src/protocol/alice/state.rs index f0acab232..7627a5295 100644 --- a/swap/src/protocol/alice/state.rs +++ b/swap/src/protocol/alice/state.rs @@ -384,8 +384,8 @@ pub struct State3 { S_b_bitcoin: bitcoin::PublicKey, pub v: monero::PrivateViewKey, #[serde(with = "::bitcoin::util::amount::serde::as_sat")] - btc: bitcoin::Amount, - xmr: monero::Amount, + pub btc: bitcoin::Amount, + pub xmr: monero::Amount, pub cancel_timelock: CancelTimelock, pub punish_timelock: PunishTimelock, refund_address: bitcoin::Address, diff --git a/swap/src/protocol/alice/swap.rs b/swap/src/protocol/alice/swap.rs index 544a71583..d5cf3868e 100644 --- a/swap/src/protocol/alice/swap.rs +++ b/swap/src/protocol/alice/swap.rs @@ -485,7 +485,7 @@ where }) } -pub(crate) fn is_complete(state: &AliceState) -> bool { +pub fn is_complete(state: &AliceState) -> bool { matches!( state, AliceState::XmrRefunded