From 5f496b56372d3fc8e90a029b36463b694ded55af Mon Sep 17 00:00:00 2001 From: einliterflasche Date: Tue, 7 Jan 2025 12:54:32 +0100 Subject: [PATCH 01/20] Subtract monero that is reversed for ongoing swaps from the quote volume --- swap/src/asb/event_loop.rs | 38 ++++++++++++++++++++++++++++---------- swap/src/monero.rs | 13 +++++++++++-- 2 files changed, 39 insertions(+), 12 deletions(-) diff --git a/swap/src/asb/event_loop.rs b/swap/src/asb/event_loop.rs index e880c8703..8d9cd073a 100644 --- a/swap/src/asb/event_loop.rs +++ b/swap/src/asb/event_loop.rs @@ -479,16 +479,34 @@ where // use unlocked monero balance for quote 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(|| { - anyhow!( - "Bitcoin price ({}) x Monero ({}) overflow", - ask_price, - xmr_balance - ) - })?; + // From our full balance we need to subtract any Monero that is 'reserved' for ongoing swaps + // (where the Bitcoin has been (or is being) locked but we haven't sent the Monero yet). + + 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), + _ => None, + }) + .fold(Amount::ZERO, |acc, amount| acc + amount); + + let free_monero_balance = xmr_balance + .checked_sub(reserved) + .context("reserved more monero than we've got")?; + + let max_bitcoin_for_monero = free_monero_balance + .max_bitcoin_for_price(ask_price) + .ok_or_else(|| { + anyhow!( + "Bitcoin price ({}) x Monero ({}) overflow", + ask_price, + xmr_balance + ) + })?; tracing::trace!(%ask_price, %xmr_balance, %max_bitcoin_for_monero, "Computed quote"); diff --git a/swap/src/monero.rs b/swap/src/monero.rs index d96c21446..d399699da 100644 --- a/swap/src/monero.rs +++ b/swap/src/monero.rs @@ -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; @@ -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 { + if self.0 < rhs.0 { + bail!("checked sub would underflow"); + } + + Ok(Amount::from_piconero(self.0 - rhs.0)) + } } impl Add for Amount { @@ -174,7 +183,7 @@ impl Add for Amount { } } -impl Sub for Amount { +impl Sub for Amount { type Output = Amount; fn sub(self, rhs: Self) -> Self::Output { From 89e4a63dbda1165ee39a9044e326d47d06b02c1f Mon Sep 17 00:00:00 2001 From: einliterflasche Date: Tue, 7 Jan 2025 12:55:40 +0100 Subject: [PATCH 02/20] Also reserve monero tx fee --- swap/src/asb/event_loop.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/swap/src/asb/event_loop.rs b/swap/src/asb/event_loop.rs index 8d9cd073a..a021c1137 100644 --- a/swap/src/asb/event_loop.rs +++ b/swap/src/asb/event_loop.rs @@ -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; @@ -489,7 +489,7 @@ where .iter() .filter_map(|(_, state)| match state { State::Alice(AliceState::BtcLockTransactionSeen { state3 }) - | State::Alice(AliceState::BtcLocked { state3 }) => Some(state3.xmr), + | State::Alice(AliceState::BtcLocked { state3 }) => Some(state3.xmr + MONERO_FEE), _ => None, }) .fold(Amount::ZERO, |acc, amount| acc + amount); From b3badc7d9b81ddeceaf6786bd8900afd3b843324 Mon Sep 17 00:00:00 2001 From: einliterflasche Date: Tue, 7 Jan 2025 13:14:48 +0100 Subject: [PATCH 03/20] dprint fmt --- dev-docs/asb/README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/dev-docs/asb/README.md b/dev-docs/asb/README.md index d53ee2c70..9c2f43211 100644 --- a/dev-docs/asb/README.md +++ b/dev-docs/asb/README.md @@ -32,6 +32,7 @@ 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. + ## ASB Details The ASB is a long running daemon that acts as the trading partner to the swap CLI. From 0e36b894ebbab8cecf9c1dace9a4ee40c1e915ee Mon Sep 17 00:00:00 2001 From: einliterflasche Date: Tue, 14 Jan 2025 15:05:56 +0100 Subject: [PATCH 04/20] Add todo for better XMR management --- swap/src/asb/event_loop.rs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/swap/src/asb/event_loop.rs b/swap/src/asb/event_loop.rs index a021c1137..5aac68ae6 100644 --- a/swap/src/asb/event_loop.rs +++ b/swap/src/asb/event_loop.rs @@ -481,6 +481,10 @@ where // From our full balance we need to subtract any Monero that is 'reserved' for ongoing swaps // (where the Bitcoin has been (or is being) locked but we haven't sent the Monero yet). + // + // TODO: Better manage monero funds. Currently we store all Monero in one UTXO, then the change + // address is blocked for 10 blocks before we can use it again. + // We should distribute the monero funds into multiple UTXOs, to avoid blocking large amounts of the total balance. let reserved: Amount = self .db From fa9195e4744e0cafe14519f354af6e1430568130 Mon Sep 17 00:00:00 2001 From: einliterflasche Date: Tue, 14 Jan 2025 15:07:55 +0100 Subject: [PATCH 05/20] fix dprint lint --- dev-docs/asb/README.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/dev-docs/asb/README.md b/dev-docs/asb/README.md index 9c2f43211..7cc6adc25 100644 --- a/dev-docs/asb/README.md +++ b/dev-docs/asb/README.md @@ -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 From 52b96b02fe948e2f7c4432a5c05814c222bf3ca5 Mon Sep 17 00:00:00 2001 From: einliterflasche Date: Mon, 20 Jan 2025 19:01:13 +0100 Subject: [PATCH 06/20] Warn instead of fail, default to 0 quote when reserved funds exceed monero balance --- swap/src/asb/event_loop.rs | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/swap/src/asb/event_loop.rs b/swap/src/asb/event_loop.rs index 5aac68ae6..b180aaabf 100644 --- a/swap/src/asb/event_loop.rs +++ b/swap/src/asb/event_loop.rs @@ -498,9 +498,10 @@ where }) .fold(Amount::ZERO, |acc, amount| acc + amount); - let free_monero_balance = xmr_balance - .checked_sub(reserved) - .context("reserved more monero than we've got")?; + let free_monero_balance = xmr_balance.checked_sub(reserved).unwrap_or_else(|_| { + tracing::warn!("Monero funds needed for ongoing swaps exceed current balance."); + Amount::ZERO + }); let max_bitcoin_for_monero = free_monero_balance .max_bitcoin_for_price(ask_price) From 0102f61459660a1e9c6b40480581fd4edabeb40f Mon Sep 17 00:00:00 2001 From: einliterflasche Date: Tue, 21 Jan 2025 14:15:03 +0100 Subject: [PATCH 07/20] Add more information to warning --- swap/src/asb/event_loop.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/swap/src/asb/event_loop.rs b/swap/src/asb/event_loop.rs index b180aaabf..5ec421930 100644 --- a/swap/src/asb/event_loop.rs +++ b/swap/src/asb/event_loop.rs @@ -499,7 +499,7 @@ where .fold(Amount::ZERO, |acc, amount| acc + amount); let free_monero_balance = xmr_balance.checked_sub(reserved).unwrap_or_else(|_| { - tracing::warn!("Monero funds needed for ongoing swaps exceed current balance."); + tracing::warn!(%xmr_balance, missing=%(reserved - xmr_balance), "Monero balance is too low for ongoing swaps, need more funds to complete all ongoing swaps."); Amount::ZERO }); From bc9c1bc46f654a1612dbb3a0594715fd4c6e39fd Mon Sep 17 00:00:00 2001 From: einliterflasche Date: Tue, 21 Jan 2025 14:20:07 +0100 Subject: [PATCH 08/20] Add changelog entry --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index b08008922..2517fba1c 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 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.11] - 2024-12-22 - 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. From 14a5b4c348a109d2524657ffeba306422458ea44 Mon Sep 17 00:00:00 2001 From: Binarybaron Date: Sat, 26 Apr 2025 17:24:19 +0200 Subject: [PATCH 09/20] feat(monero-sys): Initial commit. Regtest integration test. Wrapper around basic Wallet functions, depends on monero#9464 --- Cargo.toml | 2 +- monero-harness/src/lib.rs | 24 +++ monero-sys/.gitignore | 9 ++ monero-sys/.gitmodules | 3 + monero-sys/CLAUDE.md | 70 +++++++++ monero-sys/Cargo.toml | 28 ++++ monero-sys/README.md | 19 +++ monero-sys/build.rs | 171 ++++++++++++++++++++ monero-sys/monero | 1 + monero-sys/src/bridge.h | 33 ++++ monero-sys/src/bridge.rs | 123 +++++++++++++++ monero-sys/src/lib.rs | 262 +++++++++++++++++++++++++++++++ monero-sys/tests/harness_test.rs | 196 +++++++++++++++++++++++ monero-sys/tests/simple.rs | 88 +++++++++++ 14 files changed, 1028 insertions(+), 1 deletion(-) create mode 100644 monero-sys/.gitignore create mode 100644 monero-sys/.gitmodules create mode 100644 monero-sys/CLAUDE.md create mode 100644 monero-sys/Cargo.toml create mode 100644 monero-sys/README.md create mode 100644 monero-sys/build.rs create mode 160000 monero-sys/monero create mode 100644 monero-sys/src/bridge.h create mode 100644 monero-sys/src/bridge.rs create mode 100644 monero-sys/src/lib.rs create mode 100644 monero-sys/tests/harness_test.rs create mode 100644 monero-sys/tests/simple.rs diff --git a/Cargo.toml b/Cargo.toml index d46abd08d..d0d2ed9ce 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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 diff --git a/monero-harness/src/lib.rs b/monero-harness/src/lib.rs index 61c12a275..c621caf56 100644 --- a/monero-harness/src/lib.rs +++ b/monero-harness/src/lib.rs @@ -171,6 +171,29 @@ impl<'c> Monero { Ok(()) } + /// Funds a specific wallet address with XMR + /// + /// This function is useful when you want to fund an address that isn't managed by + /// a wallet in the testcontainer setup, like an external wallet address. + pub async fn fund_address(&self, address: &str, amount: u64) -> Result<()> { + let monerod = &self.monerod; + + // Make sure miner has funds by generating blocks + monerod + .client() + .generateblocks(120, address.to_string()) + .await?; + + // Mine more blocks to confirm the transaction + monerod + .client() + .generateblocks(10, address.to_string()) + .await?; + + tracing::info!("Successfully funded address with {} piconero", amount); + Ok(()) + } + pub async fn start_miner(&self) -> Result<()> { let miner_wallet = self.wallet("miner")?; let miner_address = miner_wallet.address().await?.address; @@ -246,6 +269,7 @@ impl<'c> Monerod { pub fn client(&self) -> &monerod::Client { &self.client } + /// Spawns a task to mine blocks in a regular interval to the provided /// address diff --git a/monero-sys/.gitignore b/monero-sys/.gitignore new file mode 100644 index 000000000..d5ebb44ee --- /dev/null +++ b/monero-sys/.gitignore @@ -0,0 +1,9 @@ +# IDE specific files +.vscode/ +.idea/ + +# Cargo specific +.cargo/ +/target/ +**/*.rs.bk +Cargo.lock diff --git a/monero-sys/.gitmodules b/monero-sys/.gitmodules new file mode 100644 index 000000000..fc6562c22 --- /dev/null +++ b/monero-sys/.gitmodules @@ -0,0 +1,3 @@ +[submodule "monero"] + path = monero + url = https://github.com/monero-project/monero diff --git a/monero-sys/CLAUDE.md b/monero-sys/CLAUDE.md new file mode 100644 index 000000000..9641e2fc9 --- /dev/null +++ b/monero-sys/CLAUDE.md @@ -0,0 +1,70 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Build/Test Commands +- Build: `cargo build` +- Test all: `cargo test` + +## Development Notes +- In src/bridge.rs we mirror some functions from monero/src/wallet/api/wallet2_api.h. CXX automatically generates the bindings from the header file. +- When you want to add a new function to the bridge, you need to copy its definition from the monero/src/wallet/api/wallet2_api.h header file into the bridge.rs file, and then add it some wrapping logic in src/lib.rs. + +## Code Conventions +- Rust 2021 edition +- Use `unsafe` only for FFI interactions with Monero C++ code +- The cmake build target we need is wallet_api. We need to link libwallet.a and libwallet_api.a. + +## Important Development Guidelines +- Always verify method signatures in the Monero C++ headers before adding them to the Rust bridge +- Check wallet2_api.h for the correct function names, parameters, and return types +- When implementing new wrapper functions: + 1. First locate the function in the original C++ header file (wallet2_api.h) + 2. Copy the exact method signature to bridge.rs + 3. Implement the Rust wrapper in lib.rs + 4. Run the build to ensure everything compiles correctly +- Remember that some C++ methods may be overloaded or have different names than expected + +## Bridge Architecture + +The bridge between Rust and the Monero C++ code works as follows: + +1. **CXX Interface (bridge.rs)**: + - Defines the FFI interface using the `cxx::bridge` macro + - Declares C++ types and functions that will be accessed from Rust + - Special considerations in the interface: + - Static C++ methods are wrapped as free functions in bridge.h + - String returns use std::unique_ptr to bridge the language boundary + +2. **C++ Adapter (bridge.h)**: + - Contains helper functions to work around CXX limitations + - Provides wrappers for static methods (like `getWalletManager()`) + - Handles string returns with `std::unique_ptr` + +3. **Rust Wrapper (lib.rs)**: + - Provides idiomatic Rust interfaces to the C++ code + - Uses wrapper types (WalletManager, Wallet) with safer interfaces + - Handles memory management and safety concerns: + - Raw pointers are never exposed to users of the library + - Implements `Send` and `Sync` for wrapper types + - Uses `Pin` for C++ objects that require stable memory addresses + +4. **Build Process (build.rs)**: + - Compiles the Monero C++ code with CMake targeting wallet_api + - Sets up appropriate include paths and library linking + - Configures CXX to build the bridge between Rust and C++ + - Links numerous static and dynamic libraries required by Monero + +5. **Memory Safety Model**: + - Raw pointers are wrapped in safe Rust types + - `unsafe` is only used at the FFI boundary + - Proper deref implementations for wrapper types + - The `OnceLock` pattern ensures WalletManager is a singleton + +6. **Adding New Functionality**: + 1. Find the desired function in wallet2_api.h + 2. Add its declaration to the `unsafe extern "C++"` block in bridge.rs + 3. Create a corresponding Rust wrapper method in lib.rs + 4. For functions returning strings or with other CXX limitations, add helper functions in bridge.h + +This architecture ensures memory safety while providing idiomatic access to the Monero wallet functionality from Rust. \ No newline at end of file diff --git a/monero-sys/Cargo.toml b/monero-sys/Cargo.toml new file mode 100644 index 000000000..721e36992 --- /dev/null +++ b/monero-sys/Cargo.toml @@ -0,0 +1,28 @@ +[package] +name = "monero-wallet-sys" +version = "0.1.0" +edition = "2021" + +[dependencies] +cxx = "1.0.137" +tracing = "0.1" +uuid = { version = "1.16.0", features = ["v4"] } + +[build-dependencies] +pkg-config = "0.3" +cmake = "0.1" +cxx-build = "1.0.137" + +[dev-dependencies] +tempfile = "3.19.1" +tracing-subscriber = "0.3" +tokio = { version = "1", features = ["rt-multi-thread", "macros", "time"] } +anyhow = "1" +testcontainers = "0.15" +reqwest = { version = "0.12", default-features = false, features = ["json"] } +monero-harness = { path = "../monero-harness" } + +[patch.crates-io] +# Match the patches from the core workspace +jsonrpc_client = { git = "https://github.com/delta1/rust-jsonrpc-client.git", rev = "3b6081697cd616c952acb9c2f02d546357d35506" } +monero = { git = "https://github.com/comit-network/monero-rs", rev = "818f38b" } diff --git a/monero-sys/README.md b/monero-sys/README.md new file mode 100644 index 000000000..2ce543d07 --- /dev/null +++ b/monero-sys/README.md @@ -0,0 +1,19 @@ +# monero-wallet-sys + +This is a (statically-linked) wrapper around [`wallet2_api.h`](./monero/src/wallet/api/wallet2_api.h) from the official Monero codebase. + +Since we statically link the Monero codebase, we need to build it. +That requires build dependencies, for a complete and up-to-date list see the Monero [README](./monero/README.md#dependencies). +Missing dependencies will currently result in obscure CMake or linker errors. +If you get obscure linker CMake or linker errors, check whether you correctly installed the dependencies. + +Since we build the Monero codebase from source, building this crate for the first time might take a while. + +## Contributing + +Make sure to load the Monero submodule: + +```bash +git submodule update --init --recursive +``` + diff --git a/monero-sys/build.rs b/monero-sys/build.rs new file mode 100644 index 000000000..e603877b9 --- /dev/null +++ b/monero-sys/build.rs @@ -0,0 +1,171 @@ +use cmake::Config; + +fn main() { + // Only rerun this when the bridge.rs or static_bridge.h file changes. + println!("cargo:rerun-if-changed=src/bridge.rs"); + println!("cargo:rerun-if-changed=src/bridge.h"); + + // Build with the monero library all dependencies required + let mut config = Config::new("monero"); + let output_directory = config + .build_target("wallet_api") + .define("CMAKE_RELEASE_TYPE", "Release") + .define("STATIC", "ON") + .build_arg("-j") + .build(); + + let monero_build_dir = output_directory.join("build"); + + println!( + "cargo:warning=Build directory: {}", + output_directory.display() + ); + + // Add output directories to the link search path + println!( + "cargo:rustc-link-search=native={}", + monero_build_dir.join("lib").display() + ); + + // Add additional link search paths for libraries in different directories + println!( + "cargo:rustc-link-search=native={}", + monero_build_dir.join("contrib/epee/src").display() + ); + println!( + "cargo:rustc-link-search=native={}", + monero_build_dir.join("external/easylogging++").display() + ); + println!( + "cargo:rustc-link-search=native={}", + monero_build_dir + .join("external/db_drivers/liblmdb") + .display() + ); + println!( + "cargo:rustc-link-search=native={}", + monero_build_dir.join("external/randomx").display() + ); + println!( + "cargo:rustc-link-search=native={}", + monero_build_dir.join("src").display() + ); + println!( + "cargo:rustc-link-search=native={}", + monero_build_dir.join("src/crypto").display() + ); + println!( + "cargo:rustc-link-search=native={}", + monero_build_dir.join("src/net").display() + ); + println!( + "cargo:rustc-link-search=native={}", + monero_build_dir.join("src/ringct").display() + ); + println!( + "cargo:rustc-link-search=native={}", + monero_build_dir.join("src/checkpoints").display() + ); + println!( + "cargo:rustc-link-search=native={}", + monero_build_dir.join("src/multisig").display() + ); + println!( + "cargo:rustc-link-search=native={}", + monero_build_dir.join("src/cryptonote_basic").display() + ); + println!( + "cargo:rustc-link-search=native={}", + monero_build_dir.join("src/common").display() + ); + println!( + "cargo:rustc-link-search=native={}", + monero_build_dir.join("src/cryptonote_core").display() + ); + println!( + "cargo:rustc-link-search=native={}", + monero_build_dir.join("src/hardforks").display() + ); + println!( + "cargo:rustc-link-search=native={}", + monero_build_dir.join("src/blockchain_db").display() + ); + println!( + "cargo:rustc-link-search=native={}", + monero_build_dir.join("src/device").display() + ); + println!( + "cargo:rustc-link-search=native={}", + monero_build_dir.join("src/device_trezor").display() + ); + println!( + "cargo:rustc-link-search=native={}", + monero_build_dir.join("src/mnemonics").display() + ); + println!( + "cargo:rustc-link-search=native={}", + monero_build_dir.join("src/rpc").display() + ); + + #[cfg(target_os = "macos")] + { + // add homebrew search paths/ + println!("cargo:rustc-link-search=native=/opt/homebrew/lib"); + } + + // Link libwallet and libwallet_api statically + println!("cargo:rustc-link-lib=static=wallet"); + println!("cargo:rustc-link-lib=static=wallet_api"); + + // Link additional required libraries + println!("cargo:rustc-link-lib=static=epee"); + println!("cargo:rustc-link-lib=static=easylogging"); + println!("cargo:rustc-link-lib=static=lmdb"); + println!("cargo:rustc-link-lib=static=randomx"); + println!("cargo:rustc-link-lib=static=cncrypto"); + println!("cargo:rustc-link-lib=static=net"); + println!("cargo:rustc-link-lib=static=ringct"); + println!("cargo:rustc-link-lib=static=ringct_basic"); + println!("cargo:rustc-link-lib=static=checkpoints"); + println!("cargo:rustc-link-lib=static=multisig"); + println!("cargo:rustc-link-lib=static=version"); + println!("cargo:rustc-link-lib=static=cryptonote_basic"); + println!("cargo:rustc-link-lib=static=cryptonote_format_utils_basic"); + println!("cargo:rustc-link-lib=static=common"); + println!("cargo:rustc-link-lib=static=cryptonote_core"); + println!("cargo:rustc-link-lib=static=hardforks"); + println!("cargo:rustc-link-lib=static=blockchain_db"); + println!("cargo:rustc-link-lib=static=device"); + println!("cargo:rustc-link-lib=static=device_trezor"); + println!("cargo:rustc-link-lib=static=mnemonics"); + println!("cargo:rustc-link-lib=static=rpc_base"); + + // Link required system libraries dynamically + println!("cargo:rustc-link-lib=dylib=hidapi"); + println!("cargo:rustc-link-lib=dylib=usb-1.0"); + println!("cargo:rustc-link-lib=dylib=unbound"); + println!("cargo:rustc-link-lib=dylib=boost_serialization"); + println!("cargo:rustc-link-lib=dylib=protobuf"); + println!("cargo:rustc-link-lib=dylib=sodium"); + println!("cargo:rustc-link-lib=dylib=boost_filesystem"); + println!("cargo:rustc-link-lib=dylib=boost_thread"); + println!("cargo:rustc-link-lib=dylib=boost_chrono"); + println!("cargo:rustc-link-lib=dylib=absl_base"); + println!("cargo:rustc-link-lib=dylib=absl_log_sink"); + println!("cargo:rustc-link-lib=dylib=absl_strings"); + println!("cargo:rustc-link-lib=dylib=absl_log_entry"); + println!("cargo:rustc-link-lib=dylib=absl_log_severity"); + println!("cargo:rustc-link-lib=dylib=absl_log_internal_message"); + println!("cargo:rustc-link-lib=dylib=absl_raw_logging_internal"); + println!("cargo:rustc-link-lib=dylib=absl_log_internal_check_op"); + println!("cargo:rustc-link-lib=dylib=absl_log_internal_nullguard"); + println!("cargo:rustc-link-lib=dylib=ssl"); + println!("cargo:rustc-link-lib=dylib=crypto"); + + // Build the CXX bridge + let mut build = cxx_build::bridge("src/bridge.rs"); + build + .flag_if_supported("-std=c++17") + .include("monero/src") + .compile("monero-wallet-sys"); +} diff --git a/monero-sys/monero b/monero-sys/monero new file mode 160000 index 000000000..3ed468970 --- /dev/null +++ b/monero-sys/monero @@ -0,0 +1 @@ +Subproject commit 3ed468970276543910676988b160f16d25908c9e diff --git a/monero-sys/src/bridge.h b/monero-sys/src/bridge.h new file mode 100644 index 000000000..40a44e323 --- /dev/null +++ b/monero-sys/src/bridge.h @@ -0,0 +1,33 @@ +#pragma once + +#include + +#include "../monero/src/wallet/api/wallet2_api.h" + +namespace Monero +{ + /** + * CXX doesn't support static methods as yet, so we define free functions here that simply + * call the appropriate static methods. + */ + inline WalletManager *getWalletManager() + { + // This causes the wallet to print some logging to stdout + // This is useful for debugging + // TODO: Only enable in debug releases or expose setLogLevel as a FFI function + WalletManagerFactory::setLogLevel(2); + + return WalletManagerFactory::getWalletManager(); + } + + /** + * CXX also doesn't support returning strings by value from C++ to Rust, so we wrap those + * in a unique_ptr. + */ + inline std::unique_ptr address(const Wallet &wallet, uint32_t account_index, uint32_t address_index) + { + auto addr = wallet.address(account_index, address_index); + return std::make_unique(addr); + } + +} \ No newline at end of file diff --git a/monero-sys/src/bridge.rs b/monero-sys/src/bridge.rs new file mode 100644 index 000000000..14484df02 --- /dev/null +++ b/monero-sys/src/bridge.rs @@ -0,0 +1,123 @@ +#[cxx::bridge(namespace = "Monero")] +pub mod ffi { + /// The type of the network. + enum NetworkType { + #[rust_name = "Mainnet"] + MAINNET, + #[rust_name = "Testnet"] + TESTNET, + #[rust_name = "Stagenet"] + STAGENET, + } + + unsafe extern "C++" { + include!("monero-wallet-sys/monero/src/wallet/api/wallet2_api.h"); + include!("monero-wallet-sys/src/bridge.h"); + + /// A manager for multiple wallets. + type WalletManager; + + /// A single wallet. + type Wallet; + + /// The type of the network. + type NetworkType; + + /// An unsigned transaction. + type UnsignedTransaction; + + /// A pending transaction. + type PendingTransaction; + + /// Get the wallet manager. + unsafe fn getWalletManager() -> *mut WalletManager; + + /// Create a new wallet. + unsafe fn createWallet( + self: Pin<&mut WalletManager>, + path: &CxxString, + password: &CxxString, + language: &CxxString, + network_type: NetworkType, + kdf_rounds: u64, + ) -> *mut Wallet; + + /// Recover a wallet from a mnemonic seed (electrum seed). + unsafe fn recoveryWallet( + self: Pin<&mut WalletManager>, + path: &CxxString, + password: &CxxString, + mnemonic: &CxxString, + network_type: NetworkType, + restore_height: u64, + kdf_rounds: u64, + seed_offset: &CxxString, + ) -> *mut Wallet; + + /// Get the current blockchain height. + unsafe fn blockchainHeight(self: Pin<&mut WalletManager>) -> u64; + + /// Set the address of the remote node ("daemon"). + unsafe fn setDaemonAddress(self: Pin<&mut WalletManager>, address: &CxxString); + + /// Check if the wallet manager is connected to the configured daemon. + unsafe fn connected(self: Pin<&mut WalletManager>, version: *mut u32) -> bool; + + /// Get the status of the wallet and an error string if there is one. + unsafe fn statusWithErrorString( + self: &Wallet, + status: &mut i32, + error_string: Pin<&mut CxxString>, + ); + + /// Address for the given account and address index. + /// address(0, 0) is the main address. + unsafe fn address( + wallet: &Wallet, + account_index: u32, + address_index: u32, + ) -> UniquePtr; + + /// Initialize the wallet by connecting to the specified remote node (daemon). + #[allow(clippy::too_many_arguments)] + unsafe fn init( + self: Pin<&mut Wallet>, + daemon_address: &CxxString, + upper_transaction_size_limit: u64, + daemon_username: &CxxString, + daemon_password: &CxxString, + use_ssl: bool, + light_wallet: bool, + proxy_address: &CxxString, + ) -> bool; + + /// Refresh the wallet once. + unsafe fn refresh(self: Pin<&mut Wallet>) -> bool; + + /// Start the background refresh thread (refreshes every 10 seconds). + unsafe fn startRefresh(self: Pin<&mut Wallet>); + + /// Refresh the wallet asynchronously. + unsafe fn refreshAsync(self: Pin<&mut Wallet>); + + /// Get the current blockchain height. + unsafe fn blockChainHeight(self: &Wallet) -> u64; + + /// Get the daemon's blockchain height. + unsafe fn daemonBlockChainHeight(self: &Wallet) -> u64; + + /// Check if wallet was ever synchronized. + unsafe fn synchronized(self: &Wallet) -> bool; + + /// Get the status of a pending transaction. + unsafe fn status(self: &PendingTransaction) -> i32; + + /// Get the total balance across all accounts in atomic units. + unsafe fn balanceAll(self: &Wallet) -> u64; + + /// Get the total unlocked balance across all accounts in atomic units. + unsafe fn unlockedBalanceAll(self: &Wallet) -> u64; + + unsafe fn setAllowMismatchedDaemonVersion(self: Pin<&mut Wallet>, allow_mismatch: bool); + } +} diff --git a/monero-sys/src/lib.rs b/monero-sys/src/lib.rs new file mode 100644 index 000000000..d720cca35 --- /dev/null +++ b/monero-sys/src/lib.rs @@ -0,0 +1,262 @@ +mod bridge; + +use std::{ + ops::{Deref, DerefMut}, + pin::Pin, + sync::{Arc, Mutex, OnceLock}, +}; + +use cxx::let_cxx_string; + +use bridge::ffi; + +pub use bridge::ffi::NetworkType; + +static WALLET_MANAGER: OnceLock>> = OnceLock::new(); + +pub struct WalletManager { + inner: RawWalletManager, +} + +pub struct RawWalletManager(*mut ffi::WalletManager); + +unsafe impl Send for RawWalletManager {} +unsafe impl Sync for RawWalletManager {} + +impl WalletManager { + /// Get the wallet manager instance. + pub fn get() -> Arc> { + WALLET_MANAGER + .get_or_init(|| { + let manager = unsafe { ffi::getWalletManager() }; + + Arc::new(Mutex::new(Self { + inner: RawWalletManager(manager), + })) + }) + .clone() + } + + /// Create a new wallet. + pub fn create_wallet( + &mut self, + path: &str, + password: &str, + language: &str, + network_type: ffi::NetworkType, + kdf_rounds: u64, + ) -> Wallet { + let_cxx_string!(path = path); + let_cxx_string!(password = password); + let_cxx_string!(language = language); + + let wallet_pointer = unsafe { + self.inner + .pinned() + .createWallet(&path, &password, &language, network_type, kdf_rounds) + }; + + Wallet::new(wallet_pointer) + } + + /// Recover a wallet from a mnemonic seed (electrum seed). + /// + /// # Arguments + /// + /// * `path` - Name of wallet file to be created + /// * `password` - Password of wallet file + /// * `mnemonic` - Mnemonic seed (25 words electrum seed) + /// * `network_type` - Network type (MAINNET, TESTNET, STAGENET) + /// * `restore_height` - Restore from start height (0 to start from the beginning) + /// * `kdf_rounds` - Number of rounds for key derivation function + /// * `seed_offset` - Optional passphrase used to derive the seed + /// + /// # Returns + /// + /// A new Wallet instance. Call wallet.status() to check if recovered successfully. + pub fn recover_wallet( + &mut self, + path: &str, + password: &str, + mnemonic: &str, + network_type: ffi::NetworkType, + restore_height: u64, + kdf_rounds: u64, + seed_offset: &str, + ) -> Wallet { + let_cxx_string!(path = path); + let_cxx_string!(password = password); + let_cxx_string!(mnemonic = mnemonic); + let_cxx_string!(seed_offset = seed_offset); + + let wallet_pointer = unsafe { + self.inner + .pinned() + .recoveryWallet(&path, &password, &mnemonic, network_type, restore_height, kdf_rounds, &seed_offset) + }; + + Wallet::new(wallet_pointer) + } + + /// Set the address of the remote node ("daemon"). + pub fn set_daemon_address(&mut self, address: &str) { + tracing::debug!(%address, "Connecting wallet manager to remote node"); + + let_cxx_string!(address = address); + unsafe { + self.inner.pinned().setDaemonAddress(&address); + } + } + + /// Check if the wallet manager is connected to the configured daemon. + pub fn connected(&mut self) -> bool { + unsafe { self.inner.pinned().connected(std::ptr::null_mut()) } + } +} + +impl RawWalletManager { + /// Get a pinned reference to the inner (c++) wallet manager. + /// This is a convenience function necessary because + /// the ffi interface mostly takes a Pin<&mut T> but + /// we haven't figured out how to hold that in the struct. + pub fn pinned(&mut self) -> Pin<&mut ffi::WalletManager> { + unsafe { + Pin::new_unchecked( + self.0 + .as_mut() + .expect("wallet manager pointer not to be null"), + ) + } + } +} + +/// A single Monero wallet. +pub struct Wallet { + inner: RawWallet, +} + +pub struct RawWallet(*mut ffi::Wallet); + +unsafe impl Send for RawWallet {} +unsafe impl Sync for RawWallet {} + +impl Wallet { + /// Create a new wallet from a raw C++ wallet pointer. + fn new(inner: *mut ffi::Wallet) -> Self { + Self { + inner: RawWallet(inner), + } + } + + /// Get the address for the given account and address index. + /// address(0, 0) is the main address. + pub fn address(&self, account_index: u32, address_index: u32) -> String { + let address = unsafe { ffi::address(&self.inner, account_index, address_index) }; + address.to_string() + } + + /// Initialize the wallet by connecting to the specified remote node (daemon). + pub fn init(&mut self, daemon_address: &str, ssl: bool) -> bool { + let_cxx_string!(daemon_address = daemon_address); + let_cxx_string!(daemon_username = ""); + let_cxx_string!(daemon_password = ""); + let_cxx_string!(proxy_address = ""); + unsafe { + self.inner.pinned().init( + &daemon_address, + 0, + &daemon_username, + &daemon_password, + ssl, + false, + &proxy_address, + ) + } + } + + /// Start the background refresh thread (refreshes every 10 seconds). + pub fn start_refresh(&mut self) { + unsafe { self.inner.pinned().startRefresh() } + } + + /// Refresh the wallet asynchronously. + pub fn refresh_async(&mut self) { + unsafe { self.inner.pinned().refreshAsync() } + } + + /// Refresh the wallet once. + pub fn refresh(&mut self) -> bool { + unsafe { self.inner.pinned().refresh() } + } + + /// Get the current blockchain height. + pub fn blockchain_height(&self) -> u64 { + unsafe { self.inner.blockChainHeight() } + } + + /// Get the daemon's blockchain height. + /// + /// Returns the height of the blockchain from the connected daemon. + /// Returns 0 if there's an error communicating with the daemon. + pub fn daemon_blockchain_height(&self) -> u64 { + unsafe { self.inner.daemonBlockChainHeight() } + } + + /// Get the total balance across all accounts in atomic units. + pub fn balance_all(&self) -> u64 { + unsafe { self.inner.balanceAll() } + } + + /// Get the total unlocked balance across all accounts in atomic units. + pub fn unlocked_balance_all(&self) -> u64 { + unsafe { self.inner.unlockedBalanceAll() } + } + + /// Check if wallet was ever synchronized. + /// + /// Returns true if the wallet has been synchronized at least once, + /// false otherwise. + pub fn synchronized(&self) -> bool { + unsafe { self.inner.synchronized() } + } + + /// Set the allow mismatched daemon version flag. + pub fn set_allow_mismatched_daemon_version(&mut self, allow_mismatch: bool) { + unsafe { self.inner.pinned().setAllowMismatchedDaemonVersion(allow_mismatch) } + } +} + +impl RawWallet { + /// Get a pinned reference to the inner (c++) wallet. + pub fn pinned(&mut self) -> Pin<&mut ffi::Wallet> { + unsafe { Pin::new_unchecked(self.0.as_mut().expect("wallet pointer not to be null")) } + } +} + +impl Deref for Wallet { + type Target = RawWallet; + + fn deref(&self) -> &RawWallet { + &self.inner + } +} + +impl DerefMut for Wallet { + fn deref_mut(&mut self) -> &mut RawWallet { + &mut self.inner + } +} + +impl Deref for RawWallet { + type Target = ffi::Wallet; + + fn deref(&self) -> &ffi::Wallet { + unsafe { self.0.as_ref().expect("wallet pointer not to be null") } + } +} + +impl DerefMut for RawWallet { + fn deref_mut(&mut self) -> &mut ffi::Wallet { + unsafe { self.0.as_mut().expect("wallet pointer not to be null") } + } +} diff --git a/monero-sys/tests/harness_test.rs b/monero-sys/tests/harness_test.rs new file mode 100644 index 000000000..2e016937e --- /dev/null +++ b/monero-sys/tests/harness_test.rs @@ -0,0 +1,196 @@ +use monero_harness::{image::Monerod, Monero}; +use monero_wallet_sys::{NetworkType, WalletManager}; +use std::{time::Duration, sync::OnceLock}; +use tempfile::{tempdir, TempDir}; +use testcontainers::{clients::Cli, Container}; +use tokio::time::sleep; +use tracing::info; +use uuid::Uuid; + +const KDF_ROUNDS: u64 = 1; +const PASSWORD: &str = "test"; +const SEED_OFFSET: &str = ""; + +// Amount to fund the wallet with (in piconero) +const FUND_AMOUNT: u64 = 1_000_000_000_000; + +// Global temporary directory for all wallet files +static GLOBAL_TEMP_DIR: OnceLock = OnceLock::new(); + +#[tokio::test] +async fn test_monero_wrapper_with_harness() { + tracing_subscriber::fmt() + .with_env_filter("warn,test=debug,monero_harness=debug,monero_rpc=debug,harness_test=debug") + .with_test_writer() + .init(); + + // Step 1: Create a wallet with monero-wrapper using the global temp directory + let wallet_path = get_temp_wallet_path(); + let (address, wallet_seed) = create_monero_wrapper_wallet(&wallet_path).await; + + info!("Created monero-wrapper wallet with address: {}", address); + info!("Wallet seed: {}", wallet_seed); + + // Step 2: Set up monero-harness and fund the address + let tc = Cli::default(); + let (monero, monerod_container, _wallet_containers) = + Monero::new(&tc, vec![]).await.expect("Failed to create Monero containers"); + + let daemon_address = get_daemon_address(&monerod_container); + + // Initialize miner + info!("Initializing miner wallet"); + monero.init_miner().await.expect("Failed to initialize miner"); + + // Start mining continuously to generate blocks + info!("Starting continuous mining"); + monero.start_miner().await.expect("Failed to start miner"); + + // Fund the address created by monero-wrapper + info!("Funding the test wallet address: {}", address); + init_to_wallet_address(&monero, &address, FUND_AMOUNT).await + .expect("Failed to fund wallet address"); + + // Step 3: Connect the wrapper wallet to the daemon and check balance + info!("Connecting to daemon at: {}", daemon_address); + + let wallet_balance = connect_and_check_balance(wallet_seed, daemon_address).await; + + // Step 4: Verify the balance + info!("Wallet balance: {}", wallet_balance); + assert!(wallet_balance > 0, "Wallet balance should be greater than 0"); + + info!("Test passed! Wallet successfully received and detected funds"); +} + +async fn create_monero_wrapper_wallet(wallet_path: &str) -> (String, String) { + // Get wallet manager + let wallet_manager_mutex = WalletManager::get(); + let mut wallet_manager = wallet_manager_mutex + .lock() + .expect("Failed to lock wallet manager"); + + // Define a fixed seed to use for reproducible tests + let seed = "echo ourselves ruined oven masterful wives enough addicted future cottage illness adopt lucky movement tiger taboo imbalance antics iceberg hobby oval aloof tuesday uttered oval"; + + // Create wallet from the seed - we'll use 'recover' since we have a seed + let wallet = wallet_manager.recover_wallet( + wallet_path, + PASSWORD, + seed, + // Regtest uses Mainnet addresses + NetworkType::Mainnet, + 1, + KDF_ROUNDS, + SEED_OFFSET, + ); + + // Get the main address + let address = wallet.address(0, 0); + + (address, seed.to_string()) +} + +async fn init_to_wallet_address(monero: &Monero, address: &str, amount: u64) -> anyhow::Result<()> { + info!("Funding address {} with {} piconero", address, amount); + + // Generate some blocks to ensure miner has funds + monero.fund_address(address, amount).await?; + + info!("Successfully funded address with {} piconero", amount); + Ok(()) +} + +fn get_temp_wallet_path() -> String { + // Get or initialize the global temp directory + let temp_dir = GLOBAL_TEMP_DIR.get_or_init(|| { + // Create a directory that won't be deleted until the program exits + info!("Creating global temporary directory for wallet files"); + tempdir().expect("Failed to create global temporary directory") + }); + + // Generate a unique wallet filename using UUID + let uuid = Uuid::new_v4(); // This is the correct method to generate a random UUID + let wallet_filename = format!("wallet_{}", uuid); + let wallet_path = temp_dir.path().join(wallet_filename); + + info!("Generated wallet path: {}", wallet_path.display()); + wallet_path.to_str().unwrap().to_string() +} + +/// As we are not running the monero-wrapper inside the Docker network, we need to connect to the locally exposed port +/// Docker maps the port from inside the container (18081) to a random port on the host +/// This function extracts the port and constructs the address as "localhost:" +fn get_daemon_address(monerod_container: &Container<'_, Monerod>) -> String { + let local_daemon_rpc_port = monerod_container.ports().map_to_host_port_ipv4(monero_harness::image::RPC_PORT); + let local_daemon_rpc_port = local_daemon_rpc_port.expect("monerod should have a mapping to the host for the default RPC port"); + + format!("localhost:{}", local_daemon_rpc_port) +} + +async fn connect_and_check_balance(seed: String, daemon_address: String) -> u64 { + // Get wallet manager + let wallet_manager_mutex = WalletManager::get(); + let mut wallet_manager = wallet_manager_mutex + .lock() + .expect("Failed to lock wallet manager"); + + // Set daemon address + wallet_manager.set_daemon_address(&daemon_address); + + // Check connection + let connected = wallet_manager.connected(); + info!("Connected to daemon: {}", connected); + assert!(connected, "Should be connected to daemon"); + + // Get a unique wallet path from the global temp directory + let wallet_path = get_temp_wallet_path(); + tracing::info!("Recovering wallet from seed to {}", wallet_path); + + // Recover wallet from seed + let mut wallet = wallet_manager.recover_wallet( + &wallet_path, + PASSWORD, + &seed, + NetworkType::Mainnet, // Regtest uses Mainnet addresses + 1, // Restore height (start from beginning) + KDF_ROUNDS, + SEED_OFFSET, + ); + + // Initialize wallet + wallet.init(&daemon_address, false); + + tracing::info!("Starting testing of wallet with address: {}", wallet.address(0, 0)); + + // We need to allow mismatched daemon versions for the Regtest network + // to be accepted by wallet2 + wallet.set_allow_mismatched_daemon_version(true); + + // Start background refresh + wallet.start_refresh(); + + // Wait for wallet to sync + info!("Waiting for wallet to sync..."); + while !wallet.synchronized() { + let wallet_height = wallet.blockchain_height(); + let daemon_height = wallet.daemon_blockchain_height(); + + info!( + "Wallet height: {}, Daemon height: {}", + wallet_height, daemon_height + ); + + sleep(Duration::from_secs(1)).await; + } + + // Manual refresh to ensure we have the latest state + info!("Performing final refresh"); + let refresh_result = wallet.refresh(); + info!("Final refresh result: {}", refresh_result); + + // Check balance + let balance = wallet.balance_all(); + info!("Final balance check: {}", balance); + balance +} \ No newline at end of file diff --git a/monero-sys/tests/simple.rs b/monero-sys/tests/simple.rs new file mode 100644 index 000000000..163b6fbf5 --- /dev/null +++ b/monero-sys/tests/simple.rs @@ -0,0 +1,88 @@ +use monero_wallet_sys::{NetworkType, WalletManager}; + +const KDF_ROUNDS: u64 = 1; +const PASSWORD: &str = "test"; + +// No seed offset for now. This is the default +// TODO: Let lib.rs take an Option<_> and if None pass in an empty string +const SEED_OFFSET: &str = ""; + +const STAGENET_REMOTE_NODE: &str = "node.sethforprivacy.com:38089"; +const STAGENET_WALLET_SEED: &str = "echo ourselves ruined oven masterful wives enough addicted future cottage illness adopt lucky movement tiger taboo imbalance antics iceberg hobby oval aloof tuesday uttered oval"; +const STAGENET_WALLET_RESTORE_HEIGHT: u64 = 1728128; + +#[test] +fn main() { + tracing_subscriber::fmt::init(); + + let wallet_manager_mutex = WalletManager::get(); + let mut wallet_manager = wallet_manager_mutex + .lock() + .expect("failed to lock wallet manager"); + + tracing::info!("Setting daemon address"); + wallet_manager.set_daemon_address(STAGENET_REMOTE_NODE); + + tracing::info!("Connected: {}", wallet_manager.connected()); + + let temp_dir = tempfile::tempdir().unwrap(); + + let wallet_name = "recovered_wallet"; + let wallet_path = temp_dir.path().join(wallet_name); + + tracing::info!("Recovering wallet from seed"); + let mut wallet = wallet_manager.recover_wallet( + wallet_path.to_str().unwrap(), + PASSWORD, + STAGENET_WALLET_SEED, + NetworkType::Stagenet, + STAGENET_WALLET_RESTORE_HEIGHT, + KDF_ROUNDS, + SEED_OFFSET, + ); + + // TODO: Here we'd need to call status() I believe to check if the creation was successful + + wallet.init(STAGENET_REMOTE_NODE, true); + + tracing::info!("Primary address: {}", wallet.address(0, 0)); + + // Start background refresh + tracing::info!("Starting background refresh"); + wallet.start_refresh(); + + // Wait for a while to let the wallet sync, checking sync status + tracing::info!("Waiting for wallet to sync..."); + + // TODO: lib.rs should provide an async method that does this for us + while !wallet.synchronized() { + let wallet_height = wallet.blockchain_height(); + let daemon_height = wallet.daemon_blockchain_height(); + let is_synced = wallet.synchronized(); + + // Calculate sync percentage if daemon height is available + let sync_percentage = if daemon_height > 0 && daemon_height >= wallet_height { + (wallet_height as f64 / daemon_height as f64 * 100.0).round() + } else { + 0.0 + }; + + tracing::info!( + "Wallet height: {}, Daemon height: {}, Sync: {}%, Synchronized: {} (iteration {})", + wallet_height, daemon_height, sync_percentage, is_synced, i + ); + + std::thread::sleep(std::time::Duration::from_secs(3)); + } + + tracing::info!("Wallet is synchronized!"); + + // Manual refresh one more time + tracing::info!(result=%wallet.refresh(), "Manual refresh"); + + let balance = wallet.balance_all(); + tracing::info!("Balance: {}", balance); + + let unlocked_balance = wallet.unlocked_balance_all(); + tracing::info!("Unlocked balance: {}", unlocked_balance); +} From f621aaa3e6dee66a24998f023e8af66d4519bfa5 Mon Sep 17 00:00:00 2001 From: Binarybaron Date: Mon, 28 Apr 2025 12:40:03 +0200 Subject: [PATCH 10/20] merge master, extract logic into unreserved_monero_balance --- swap/src/asb/event_loop.rs | 60 +++++++++++++++++++++----------------- 1 file changed, 33 insertions(+), 27 deletions(-) diff --git a/swap/src/asb/event_loop.rs b/swap/src/asb/event_loop.rs index 6588b5896..19dea8e87 100644 --- a/swap/src/asb/event_loop.rs +++ b/swap/src/asb/event_loop.rs @@ -511,39 +511,30 @@ 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> { + /// 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> { /// 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); - // From our full balance we need to subtract any Monero that is 'reserved' for ongoing swaps - // (where the Bitcoin has been (or is being) locked but we haven't sent the Monero yet). - // - // TODO: Better manage monero funds. Currently we store all Monero in one UTXO, then the change - // address is blocked for 10 blocks before we can use it again. - // We should distribute the monero funds into multiple UTXOs, to avoid blocking large amounts of the total 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() @@ -556,22 +547,37 @@ where }) .fold(Amount::ZERO, |acc, amount| acc + amount); - let free_monero_balance = xmr_balance.checked_sub(reserved).unwrap_or_else(|_| { - tracing::warn!(%xmr_balance, missing=%(reserved - xmr_balance), "Monero balance is too low for ongoing swaps, need more funds to complete all ongoing swaps."); - Amount::ZERO - }); + 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> { + 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 unreserved_xmr_balance = self.unreserved_monero_balance().await?; - let max_bitcoin_for_monero = free_monero_balance + 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, - xmr_balance + unreserved_xmr_balance )) })?; - tracing::trace!(%ask_price, %xmr_balance, %max_bitcoin_for_monero, "Computed quote"); + tracing::trace!(%ask_price, %unreserved_xmr_balance, %max_bitcoin_for_monero, "Computed quote"); if min_buy > max_bitcoin_for_monero { tracing::trace!( From 0e29a7e218c6b69134874f4ed7d5a45fb74d6103 Mon Sep 17 00:00:00 2001 From: Binarybaron Date: Mon, 28 Apr 2025 12:42:20 +0200 Subject: [PATCH 11/20] fmt --- swap/src/asb/event_loop.rs | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/swap/src/asb/event_loop.rs b/swap/src/asb/event_loop.rs index 19dea8e87..160acc9cf 100644 --- a/swap/src/asb/event_loop.rs +++ b/swap/src/asb/event_loop.rs @@ -514,9 +514,7 @@ where /// 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> { - /// 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. + /// 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()) From e59df2fce3b12370c712634de732fb1c084bd15a Mon Sep 17 00:00:00 2001 From: Binarybaron Date: Mon, 28 Apr 2025 12:54:55 +0200 Subject: [PATCH 12/20] remove shit --- monero-sys/.gitignore | 9 -- monero-sys/.gitmodules | 3 - monero-sys/CLAUDE.md | 70 --------- monero-sys/Cargo.toml | 28 ---- monero-sys/README.md | 19 --- monero-sys/build.rs | 171 -------------------- monero-sys/monero | 1 - monero-sys/src/bridge.h | 33 ---- monero-sys/src/bridge.rs | 123 --------------- monero-sys/src/lib.rs | 262 ------------------------------- monero-sys/tests/harness_test.rs | 196 ----------------------- monero-sys/tests/simple.rs | 88 ----------- 12 files changed, 1003 deletions(-) delete mode 100644 monero-sys/.gitignore delete mode 100644 monero-sys/.gitmodules delete mode 100644 monero-sys/CLAUDE.md delete mode 100644 monero-sys/Cargo.toml delete mode 100644 monero-sys/README.md delete mode 100644 monero-sys/build.rs delete mode 160000 monero-sys/monero delete mode 100644 monero-sys/src/bridge.h delete mode 100644 monero-sys/src/bridge.rs delete mode 100644 monero-sys/src/lib.rs delete mode 100644 monero-sys/tests/harness_test.rs delete mode 100644 monero-sys/tests/simple.rs diff --git a/monero-sys/.gitignore b/monero-sys/.gitignore deleted file mode 100644 index d5ebb44ee..000000000 --- a/monero-sys/.gitignore +++ /dev/null @@ -1,9 +0,0 @@ -# IDE specific files -.vscode/ -.idea/ - -# Cargo specific -.cargo/ -/target/ -**/*.rs.bk -Cargo.lock diff --git a/monero-sys/.gitmodules b/monero-sys/.gitmodules deleted file mode 100644 index fc6562c22..000000000 --- a/monero-sys/.gitmodules +++ /dev/null @@ -1,3 +0,0 @@ -[submodule "monero"] - path = monero - url = https://github.com/monero-project/monero diff --git a/monero-sys/CLAUDE.md b/monero-sys/CLAUDE.md deleted file mode 100644 index 9641e2fc9..000000000 --- a/monero-sys/CLAUDE.md +++ /dev/null @@ -1,70 +0,0 @@ -# CLAUDE.md - -This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. - -## Build/Test Commands -- Build: `cargo build` -- Test all: `cargo test` - -## Development Notes -- In src/bridge.rs we mirror some functions from monero/src/wallet/api/wallet2_api.h. CXX automatically generates the bindings from the header file. -- When you want to add a new function to the bridge, you need to copy its definition from the monero/src/wallet/api/wallet2_api.h header file into the bridge.rs file, and then add it some wrapping logic in src/lib.rs. - -## Code Conventions -- Rust 2021 edition -- Use `unsafe` only for FFI interactions with Monero C++ code -- The cmake build target we need is wallet_api. We need to link libwallet.a and libwallet_api.a. - -## Important Development Guidelines -- Always verify method signatures in the Monero C++ headers before adding them to the Rust bridge -- Check wallet2_api.h for the correct function names, parameters, and return types -- When implementing new wrapper functions: - 1. First locate the function in the original C++ header file (wallet2_api.h) - 2. Copy the exact method signature to bridge.rs - 3. Implement the Rust wrapper in lib.rs - 4. Run the build to ensure everything compiles correctly -- Remember that some C++ methods may be overloaded or have different names than expected - -## Bridge Architecture - -The bridge between Rust and the Monero C++ code works as follows: - -1. **CXX Interface (bridge.rs)**: - - Defines the FFI interface using the `cxx::bridge` macro - - Declares C++ types and functions that will be accessed from Rust - - Special considerations in the interface: - - Static C++ methods are wrapped as free functions in bridge.h - - String returns use std::unique_ptr to bridge the language boundary - -2. **C++ Adapter (bridge.h)**: - - Contains helper functions to work around CXX limitations - - Provides wrappers for static methods (like `getWalletManager()`) - - Handles string returns with `std::unique_ptr` - -3. **Rust Wrapper (lib.rs)**: - - Provides idiomatic Rust interfaces to the C++ code - - Uses wrapper types (WalletManager, Wallet) with safer interfaces - - Handles memory management and safety concerns: - - Raw pointers are never exposed to users of the library - - Implements `Send` and `Sync` for wrapper types - - Uses `Pin` for C++ objects that require stable memory addresses - -4. **Build Process (build.rs)**: - - Compiles the Monero C++ code with CMake targeting wallet_api - - Sets up appropriate include paths and library linking - - Configures CXX to build the bridge between Rust and C++ - - Links numerous static and dynamic libraries required by Monero - -5. **Memory Safety Model**: - - Raw pointers are wrapped in safe Rust types - - `unsafe` is only used at the FFI boundary - - Proper deref implementations for wrapper types - - The `OnceLock` pattern ensures WalletManager is a singleton - -6. **Adding New Functionality**: - 1. Find the desired function in wallet2_api.h - 2. Add its declaration to the `unsafe extern "C++"` block in bridge.rs - 3. Create a corresponding Rust wrapper method in lib.rs - 4. For functions returning strings or with other CXX limitations, add helper functions in bridge.h - -This architecture ensures memory safety while providing idiomatic access to the Monero wallet functionality from Rust. \ No newline at end of file diff --git a/monero-sys/Cargo.toml b/monero-sys/Cargo.toml deleted file mode 100644 index 721e36992..000000000 --- a/monero-sys/Cargo.toml +++ /dev/null @@ -1,28 +0,0 @@ -[package] -name = "monero-wallet-sys" -version = "0.1.0" -edition = "2021" - -[dependencies] -cxx = "1.0.137" -tracing = "0.1" -uuid = { version = "1.16.0", features = ["v4"] } - -[build-dependencies] -pkg-config = "0.3" -cmake = "0.1" -cxx-build = "1.0.137" - -[dev-dependencies] -tempfile = "3.19.1" -tracing-subscriber = "0.3" -tokio = { version = "1", features = ["rt-multi-thread", "macros", "time"] } -anyhow = "1" -testcontainers = "0.15" -reqwest = { version = "0.12", default-features = false, features = ["json"] } -monero-harness = { path = "../monero-harness" } - -[patch.crates-io] -# Match the patches from the core workspace -jsonrpc_client = { git = "https://github.com/delta1/rust-jsonrpc-client.git", rev = "3b6081697cd616c952acb9c2f02d546357d35506" } -monero = { git = "https://github.com/comit-network/monero-rs", rev = "818f38b" } diff --git a/monero-sys/README.md b/monero-sys/README.md deleted file mode 100644 index 2ce543d07..000000000 --- a/monero-sys/README.md +++ /dev/null @@ -1,19 +0,0 @@ -# monero-wallet-sys - -This is a (statically-linked) wrapper around [`wallet2_api.h`](./monero/src/wallet/api/wallet2_api.h) from the official Monero codebase. - -Since we statically link the Monero codebase, we need to build it. -That requires build dependencies, for a complete and up-to-date list see the Monero [README](./monero/README.md#dependencies). -Missing dependencies will currently result in obscure CMake or linker errors. -If you get obscure linker CMake or linker errors, check whether you correctly installed the dependencies. - -Since we build the Monero codebase from source, building this crate for the first time might take a while. - -## Contributing - -Make sure to load the Monero submodule: - -```bash -git submodule update --init --recursive -``` - diff --git a/monero-sys/build.rs b/monero-sys/build.rs deleted file mode 100644 index e603877b9..000000000 --- a/monero-sys/build.rs +++ /dev/null @@ -1,171 +0,0 @@ -use cmake::Config; - -fn main() { - // Only rerun this when the bridge.rs or static_bridge.h file changes. - println!("cargo:rerun-if-changed=src/bridge.rs"); - println!("cargo:rerun-if-changed=src/bridge.h"); - - // Build with the monero library all dependencies required - let mut config = Config::new("monero"); - let output_directory = config - .build_target("wallet_api") - .define("CMAKE_RELEASE_TYPE", "Release") - .define("STATIC", "ON") - .build_arg("-j") - .build(); - - let monero_build_dir = output_directory.join("build"); - - println!( - "cargo:warning=Build directory: {}", - output_directory.display() - ); - - // Add output directories to the link search path - println!( - "cargo:rustc-link-search=native={}", - monero_build_dir.join("lib").display() - ); - - // Add additional link search paths for libraries in different directories - println!( - "cargo:rustc-link-search=native={}", - monero_build_dir.join("contrib/epee/src").display() - ); - println!( - "cargo:rustc-link-search=native={}", - monero_build_dir.join("external/easylogging++").display() - ); - println!( - "cargo:rustc-link-search=native={}", - monero_build_dir - .join("external/db_drivers/liblmdb") - .display() - ); - println!( - "cargo:rustc-link-search=native={}", - monero_build_dir.join("external/randomx").display() - ); - println!( - "cargo:rustc-link-search=native={}", - monero_build_dir.join("src").display() - ); - println!( - "cargo:rustc-link-search=native={}", - monero_build_dir.join("src/crypto").display() - ); - println!( - "cargo:rustc-link-search=native={}", - monero_build_dir.join("src/net").display() - ); - println!( - "cargo:rustc-link-search=native={}", - monero_build_dir.join("src/ringct").display() - ); - println!( - "cargo:rustc-link-search=native={}", - monero_build_dir.join("src/checkpoints").display() - ); - println!( - "cargo:rustc-link-search=native={}", - monero_build_dir.join("src/multisig").display() - ); - println!( - "cargo:rustc-link-search=native={}", - monero_build_dir.join("src/cryptonote_basic").display() - ); - println!( - "cargo:rustc-link-search=native={}", - monero_build_dir.join("src/common").display() - ); - println!( - "cargo:rustc-link-search=native={}", - monero_build_dir.join("src/cryptonote_core").display() - ); - println!( - "cargo:rustc-link-search=native={}", - monero_build_dir.join("src/hardforks").display() - ); - println!( - "cargo:rustc-link-search=native={}", - monero_build_dir.join("src/blockchain_db").display() - ); - println!( - "cargo:rustc-link-search=native={}", - monero_build_dir.join("src/device").display() - ); - println!( - "cargo:rustc-link-search=native={}", - monero_build_dir.join("src/device_trezor").display() - ); - println!( - "cargo:rustc-link-search=native={}", - monero_build_dir.join("src/mnemonics").display() - ); - println!( - "cargo:rustc-link-search=native={}", - monero_build_dir.join("src/rpc").display() - ); - - #[cfg(target_os = "macos")] - { - // add homebrew search paths/ - println!("cargo:rustc-link-search=native=/opt/homebrew/lib"); - } - - // Link libwallet and libwallet_api statically - println!("cargo:rustc-link-lib=static=wallet"); - println!("cargo:rustc-link-lib=static=wallet_api"); - - // Link additional required libraries - println!("cargo:rustc-link-lib=static=epee"); - println!("cargo:rustc-link-lib=static=easylogging"); - println!("cargo:rustc-link-lib=static=lmdb"); - println!("cargo:rustc-link-lib=static=randomx"); - println!("cargo:rustc-link-lib=static=cncrypto"); - println!("cargo:rustc-link-lib=static=net"); - println!("cargo:rustc-link-lib=static=ringct"); - println!("cargo:rustc-link-lib=static=ringct_basic"); - println!("cargo:rustc-link-lib=static=checkpoints"); - println!("cargo:rustc-link-lib=static=multisig"); - println!("cargo:rustc-link-lib=static=version"); - println!("cargo:rustc-link-lib=static=cryptonote_basic"); - println!("cargo:rustc-link-lib=static=cryptonote_format_utils_basic"); - println!("cargo:rustc-link-lib=static=common"); - println!("cargo:rustc-link-lib=static=cryptonote_core"); - println!("cargo:rustc-link-lib=static=hardforks"); - println!("cargo:rustc-link-lib=static=blockchain_db"); - println!("cargo:rustc-link-lib=static=device"); - println!("cargo:rustc-link-lib=static=device_trezor"); - println!("cargo:rustc-link-lib=static=mnemonics"); - println!("cargo:rustc-link-lib=static=rpc_base"); - - // Link required system libraries dynamically - println!("cargo:rustc-link-lib=dylib=hidapi"); - println!("cargo:rustc-link-lib=dylib=usb-1.0"); - println!("cargo:rustc-link-lib=dylib=unbound"); - println!("cargo:rustc-link-lib=dylib=boost_serialization"); - println!("cargo:rustc-link-lib=dylib=protobuf"); - println!("cargo:rustc-link-lib=dylib=sodium"); - println!("cargo:rustc-link-lib=dylib=boost_filesystem"); - println!("cargo:rustc-link-lib=dylib=boost_thread"); - println!("cargo:rustc-link-lib=dylib=boost_chrono"); - println!("cargo:rustc-link-lib=dylib=absl_base"); - println!("cargo:rustc-link-lib=dylib=absl_log_sink"); - println!("cargo:rustc-link-lib=dylib=absl_strings"); - println!("cargo:rustc-link-lib=dylib=absl_log_entry"); - println!("cargo:rustc-link-lib=dylib=absl_log_severity"); - println!("cargo:rustc-link-lib=dylib=absl_log_internal_message"); - println!("cargo:rustc-link-lib=dylib=absl_raw_logging_internal"); - println!("cargo:rustc-link-lib=dylib=absl_log_internal_check_op"); - println!("cargo:rustc-link-lib=dylib=absl_log_internal_nullguard"); - println!("cargo:rustc-link-lib=dylib=ssl"); - println!("cargo:rustc-link-lib=dylib=crypto"); - - // Build the CXX bridge - let mut build = cxx_build::bridge("src/bridge.rs"); - build - .flag_if_supported("-std=c++17") - .include("monero/src") - .compile("monero-wallet-sys"); -} diff --git a/monero-sys/monero b/monero-sys/monero deleted file mode 160000 index 3ed468970..000000000 --- a/monero-sys/monero +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 3ed468970276543910676988b160f16d25908c9e diff --git a/monero-sys/src/bridge.h b/monero-sys/src/bridge.h deleted file mode 100644 index 40a44e323..000000000 --- a/monero-sys/src/bridge.h +++ /dev/null @@ -1,33 +0,0 @@ -#pragma once - -#include - -#include "../monero/src/wallet/api/wallet2_api.h" - -namespace Monero -{ - /** - * CXX doesn't support static methods as yet, so we define free functions here that simply - * call the appropriate static methods. - */ - inline WalletManager *getWalletManager() - { - // This causes the wallet to print some logging to stdout - // This is useful for debugging - // TODO: Only enable in debug releases or expose setLogLevel as a FFI function - WalletManagerFactory::setLogLevel(2); - - return WalletManagerFactory::getWalletManager(); - } - - /** - * CXX also doesn't support returning strings by value from C++ to Rust, so we wrap those - * in a unique_ptr. - */ - inline std::unique_ptr address(const Wallet &wallet, uint32_t account_index, uint32_t address_index) - { - auto addr = wallet.address(account_index, address_index); - return std::make_unique(addr); - } - -} \ No newline at end of file diff --git a/monero-sys/src/bridge.rs b/monero-sys/src/bridge.rs deleted file mode 100644 index 14484df02..000000000 --- a/monero-sys/src/bridge.rs +++ /dev/null @@ -1,123 +0,0 @@ -#[cxx::bridge(namespace = "Monero")] -pub mod ffi { - /// The type of the network. - enum NetworkType { - #[rust_name = "Mainnet"] - MAINNET, - #[rust_name = "Testnet"] - TESTNET, - #[rust_name = "Stagenet"] - STAGENET, - } - - unsafe extern "C++" { - include!("monero-wallet-sys/monero/src/wallet/api/wallet2_api.h"); - include!("monero-wallet-sys/src/bridge.h"); - - /// A manager for multiple wallets. - type WalletManager; - - /// A single wallet. - type Wallet; - - /// The type of the network. - type NetworkType; - - /// An unsigned transaction. - type UnsignedTransaction; - - /// A pending transaction. - type PendingTransaction; - - /// Get the wallet manager. - unsafe fn getWalletManager() -> *mut WalletManager; - - /// Create a new wallet. - unsafe fn createWallet( - self: Pin<&mut WalletManager>, - path: &CxxString, - password: &CxxString, - language: &CxxString, - network_type: NetworkType, - kdf_rounds: u64, - ) -> *mut Wallet; - - /// Recover a wallet from a mnemonic seed (electrum seed). - unsafe fn recoveryWallet( - self: Pin<&mut WalletManager>, - path: &CxxString, - password: &CxxString, - mnemonic: &CxxString, - network_type: NetworkType, - restore_height: u64, - kdf_rounds: u64, - seed_offset: &CxxString, - ) -> *mut Wallet; - - /// Get the current blockchain height. - unsafe fn blockchainHeight(self: Pin<&mut WalletManager>) -> u64; - - /// Set the address of the remote node ("daemon"). - unsafe fn setDaemonAddress(self: Pin<&mut WalletManager>, address: &CxxString); - - /// Check if the wallet manager is connected to the configured daemon. - unsafe fn connected(self: Pin<&mut WalletManager>, version: *mut u32) -> bool; - - /// Get the status of the wallet and an error string if there is one. - unsafe fn statusWithErrorString( - self: &Wallet, - status: &mut i32, - error_string: Pin<&mut CxxString>, - ); - - /// Address for the given account and address index. - /// address(0, 0) is the main address. - unsafe fn address( - wallet: &Wallet, - account_index: u32, - address_index: u32, - ) -> UniquePtr; - - /// Initialize the wallet by connecting to the specified remote node (daemon). - #[allow(clippy::too_many_arguments)] - unsafe fn init( - self: Pin<&mut Wallet>, - daemon_address: &CxxString, - upper_transaction_size_limit: u64, - daemon_username: &CxxString, - daemon_password: &CxxString, - use_ssl: bool, - light_wallet: bool, - proxy_address: &CxxString, - ) -> bool; - - /// Refresh the wallet once. - unsafe fn refresh(self: Pin<&mut Wallet>) -> bool; - - /// Start the background refresh thread (refreshes every 10 seconds). - unsafe fn startRefresh(self: Pin<&mut Wallet>); - - /// Refresh the wallet asynchronously. - unsafe fn refreshAsync(self: Pin<&mut Wallet>); - - /// Get the current blockchain height. - unsafe fn blockChainHeight(self: &Wallet) -> u64; - - /// Get the daemon's blockchain height. - unsafe fn daemonBlockChainHeight(self: &Wallet) -> u64; - - /// Check if wallet was ever synchronized. - unsafe fn synchronized(self: &Wallet) -> bool; - - /// Get the status of a pending transaction. - unsafe fn status(self: &PendingTransaction) -> i32; - - /// Get the total balance across all accounts in atomic units. - unsafe fn balanceAll(self: &Wallet) -> u64; - - /// Get the total unlocked balance across all accounts in atomic units. - unsafe fn unlockedBalanceAll(self: &Wallet) -> u64; - - unsafe fn setAllowMismatchedDaemonVersion(self: Pin<&mut Wallet>, allow_mismatch: bool); - } -} diff --git a/monero-sys/src/lib.rs b/monero-sys/src/lib.rs deleted file mode 100644 index d720cca35..000000000 --- a/monero-sys/src/lib.rs +++ /dev/null @@ -1,262 +0,0 @@ -mod bridge; - -use std::{ - ops::{Deref, DerefMut}, - pin::Pin, - sync::{Arc, Mutex, OnceLock}, -}; - -use cxx::let_cxx_string; - -use bridge::ffi; - -pub use bridge::ffi::NetworkType; - -static WALLET_MANAGER: OnceLock>> = OnceLock::new(); - -pub struct WalletManager { - inner: RawWalletManager, -} - -pub struct RawWalletManager(*mut ffi::WalletManager); - -unsafe impl Send for RawWalletManager {} -unsafe impl Sync for RawWalletManager {} - -impl WalletManager { - /// Get the wallet manager instance. - pub fn get() -> Arc> { - WALLET_MANAGER - .get_or_init(|| { - let manager = unsafe { ffi::getWalletManager() }; - - Arc::new(Mutex::new(Self { - inner: RawWalletManager(manager), - })) - }) - .clone() - } - - /// Create a new wallet. - pub fn create_wallet( - &mut self, - path: &str, - password: &str, - language: &str, - network_type: ffi::NetworkType, - kdf_rounds: u64, - ) -> Wallet { - let_cxx_string!(path = path); - let_cxx_string!(password = password); - let_cxx_string!(language = language); - - let wallet_pointer = unsafe { - self.inner - .pinned() - .createWallet(&path, &password, &language, network_type, kdf_rounds) - }; - - Wallet::new(wallet_pointer) - } - - /// Recover a wallet from a mnemonic seed (electrum seed). - /// - /// # Arguments - /// - /// * `path` - Name of wallet file to be created - /// * `password` - Password of wallet file - /// * `mnemonic` - Mnemonic seed (25 words electrum seed) - /// * `network_type` - Network type (MAINNET, TESTNET, STAGENET) - /// * `restore_height` - Restore from start height (0 to start from the beginning) - /// * `kdf_rounds` - Number of rounds for key derivation function - /// * `seed_offset` - Optional passphrase used to derive the seed - /// - /// # Returns - /// - /// A new Wallet instance. Call wallet.status() to check if recovered successfully. - pub fn recover_wallet( - &mut self, - path: &str, - password: &str, - mnemonic: &str, - network_type: ffi::NetworkType, - restore_height: u64, - kdf_rounds: u64, - seed_offset: &str, - ) -> Wallet { - let_cxx_string!(path = path); - let_cxx_string!(password = password); - let_cxx_string!(mnemonic = mnemonic); - let_cxx_string!(seed_offset = seed_offset); - - let wallet_pointer = unsafe { - self.inner - .pinned() - .recoveryWallet(&path, &password, &mnemonic, network_type, restore_height, kdf_rounds, &seed_offset) - }; - - Wallet::new(wallet_pointer) - } - - /// Set the address of the remote node ("daemon"). - pub fn set_daemon_address(&mut self, address: &str) { - tracing::debug!(%address, "Connecting wallet manager to remote node"); - - let_cxx_string!(address = address); - unsafe { - self.inner.pinned().setDaemonAddress(&address); - } - } - - /// Check if the wallet manager is connected to the configured daemon. - pub fn connected(&mut self) -> bool { - unsafe { self.inner.pinned().connected(std::ptr::null_mut()) } - } -} - -impl RawWalletManager { - /// Get a pinned reference to the inner (c++) wallet manager. - /// This is a convenience function necessary because - /// the ffi interface mostly takes a Pin<&mut T> but - /// we haven't figured out how to hold that in the struct. - pub fn pinned(&mut self) -> Pin<&mut ffi::WalletManager> { - unsafe { - Pin::new_unchecked( - self.0 - .as_mut() - .expect("wallet manager pointer not to be null"), - ) - } - } -} - -/// A single Monero wallet. -pub struct Wallet { - inner: RawWallet, -} - -pub struct RawWallet(*mut ffi::Wallet); - -unsafe impl Send for RawWallet {} -unsafe impl Sync for RawWallet {} - -impl Wallet { - /// Create a new wallet from a raw C++ wallet pointer. - fn new(inner: *mut ffi::Wallet) -> Self { - Self { - inner: RawWallet(inner), - } - } - - /// Get the address for the given account and address index. - /// address(0, 0) is the main address. - pub fn address(&self, account_index: u32, address_index: u32) -> String { - let address = unsafe { ffi::address(&self.inner, account_index, address_index) }; - address.to_string() - } - - /// Initialize the wallet by connecting to the specified remote node (daemon). - pub fn init(&mut self, daemon_address: &str, ssl: bool) -> bool { - let_cxx_string!(daemon_address = daemon_address); - let_cxx_string!(daemon_username = ""); - let_cxx_string!(daemon_password = ""); - let_cxx_string!(proxy_address = ""); - unsafe { - self.inner.pinned().init( - &daemon_address, - 0, - &daemon_username, - &daemon_password, - ssl, - false, - &proxy_address, - ) - } - } - - /// Start the background refresh thread (refreshes every 10 seconds). - pub fn start_refresh(&mut self) { - unsafe { self.inner.pinned().startRefresh() } - } - - /// Refresh the wallet asynchronously. - pub fn refresh_async(&mut self) { - unsafe { self.inner.pinned().refreshAsync() } - } - - /// Refresh the wallet once. - pub fn refresh(&mut self) -> bool { - unsafe { self.inner.pinned().refresh() } - } - - /// Get the current blockchain height. - pub fn blockchain_height(&self) -> u64 { - unsafe { self.inner.blockChainHeight() } - } - - /// Get the daemon's blockchain height. - /// - /// Returns the height of the blockchain from the connected daemon. - /// Returns 0 if there's an error communicating with the daemon. - pub fn daemon_blockchain_height(&self) -> u64 { - unsafe { self.inner.daemonBlockChainHeight() } - } - - /// Get the total balance across all accounts in atomic units. - pub fn balance_all(&self) -> u64 { - unsafe { self.inner.balanceAll() } - } - - /// Get the total unlocked balance across all accounts in atomic units. - pub fn unlocked_balance_all(&self) -> u64 { - unsafe { self.inner.unlockedBalanceAll() } - } - - /// Check if wallet was ever synchronized. - /// - /// Returns true if the wallet has been synchronized at least once, - /// false otherwise. - pub fn synchronized(&self) -> bool { - unsafe { self.inner.synchronized() } - } - - /// Set the allow mismatched daemon version flag. - pub fn set_allow_mismatched_daemon_version(&mut self, allow_mismatch: bool) { - unsafe { self.inner.pinned().setAllowMismatchedDaemonVersion(allow_mismatch) } - } -} - -impl RawWallet { - /// Get a pinned reference to the inner (c++) wallet. - pub fn pinned(&mut self) -> Pin<&mut ffi::Wallet> { - unsafe { Pin::new_unchecked(self.0.as_mut().expect("wallet pointer not to be null")) } - } -} - -impl Deref for Wallet { - type Target = RawWallet; - - fn deref(&self) -> &RawWallet { - &self.inner - } -} - -impl DerefMut for Wallet { - fn deref_mut(&mut self) -> &mut RawWallet { - &mut self.inner - } -} - -impl Deref for RawWallet { - type Target = ffi::Wallet; - - fn deref(&self) -> &ffi::Wallet { - unsafe { self.0.as_ref().expect("wallet pointer not to be null") } - } -} - -impl DerefMut for RawWallet { - fn deref_mut(&mut self) -> &mut ffi::Wallet { - unsafe { self.0.as_mut().expect("wallet pointer not to be null") } - } -} diff --git a/monero-sys/tests/harness_test.rs b/monero-sys/tests/harness_test.rs deleted file mode 100644 index 2e016937e..000000000 --- a/monero-sys/tests/harness_test.rs +++ /dev/null @@ -1,196 +0,0 @@ -use monero_harness::{image::Monerod, Monero}; -use monero_wallet_sys::{NetworkType, WalletManager}; -use std::{time::Duration, sync::OnceLock}; -use tempfile::{tempdir, TempDir}; -use testcontainers::{clients::Cli, Container}; -use tokio::time::sleep; -use tracing::info; -use uuid::Uuid; - -const KDF_ROUNDS: u64 = 1; -const PASSWORD: &str = "test"; -const SEED_OFFSET: &str = ""; - -// Amount to fund the wallet with (in piconero) -const FUND_AMOUNT: u64 = 1_000_000_000_000; - -// Global temporary directory for all wallet files -static GLOBAL_TEMP_DIR: OnceLock = OnceLock::new(); - -#[tokio::test] -async fn test_monero_wrapper_with_harness() { - tracing_subscriber::fmt() - .with_env_filter("warn,test=debug,monero_harness=debug,monero_rpc=debug,harness_test=debug") - .with_test_writer() - .init(); - - // Step 1: Create a wallet with monero-wrapper using the global temp directory - let wallet_path = get_temp_wallet_path(); - let (address, wallet_seed) = create_monero_wrapper_wallet(&wallet_path).await; - - info!("Created monero-wrapper wallet with address: {}", address); - info!("Wallet seed: {}", wallet_seed); - - // Step 2: Set up monero-harness and fund the address - let tc = Cli::default(); - let (monero, monerod_container, _wallet_containers) = - Monero::new(&tc, vec![]).await.expect("Failed to create Monero containers"); - - let daemon_address = get_daemon_address(&monerod_container); - - // Initialize miner - info!("Initializing miner wallet"); - monero.init_miner().await.expect("Failed to initialize miner"); - - // Start mining continuously to generate blocks - info!("Starting continuous mining"); - monero.start_miner().await.expect("Failed to start miner"); - - // Fund the address created by monero-wrapper - info!("Funding the test wallet address: {}", address); - init_to_wallet_address(&monero, &address, FUND_AMOUNT).await - .expect("Failed to fund wallet address"); - - // Step 3: Connect the wrapper wallet to the daemon and check balance - info!("Connecting to daemon at: {}", daemon_address); - - let wallet_balance = connect_and_check_balance(wallet_seed, daemon_address).await; - - // Step 4: Verify the balance - info!("Wallet balance: {}", wallet_balance); - assert!(wallet_balance > 0, "Wallet balance should be greater than 0"); - - info!("Test passed! Wallet successfully received and detected funds"); -} - -async fn create_monero_wrapper_wallet(wallet_path: &str) -> (String, String) { - // Get wallet manager - let wallet_manager_mutex = WalletManager::get(); - let mut wallet_manager = wallet_manager_mutex - .lock() - .expect("Failed to lock wallet manager"); - - // Define a fixed seed to use for reproducible tests - let seed = "echo ourselves ruined oven masterful wives enough addicted future cottage illness adopt lucky movement tiger taboo imbalance antics iceberg hobby oval aloof tuesday uttered oval"; - - // Create wallet from the seed - we'll use 'recover' since we have a seed - let wallet = wallet_manager.recover_wallet( - wallet_path, - PASSWORD, - seed, - // Regtest uses Mainnet addresses - NetworkType::Mainnet, - 1, - KDF_ROUNDS, - SEED_OFFSET, - ); - - // Get the main address - let address = wallet.address(0, 0); - - (address, seed.to_string()) -} - -async fn init_to_wallet_address(monero: &Monero, address: &str, amount: u64) -> anyhow::Result<()> { - info!("Funding address {} with {} piconero", address, amount); - - // Generate some blocks to ensure miner has funds - monero.fund_address(address, amount).await?; - - info!("Successfully funded address with {} piconero", amount); - Ok(()) -} - -fn get_temp_wallet_path() -> String { - // Get or initialize the global temp directory - let temp_dir = GLOBAL_TEMP_DIR.get_or_init(|| { - // Create a directory that won't be deleted until the program exits - info!("Creating global temporary directory for wallet files"); - tempdir().expect("Failed to create global temporary directory") - }); - - // Generate a unique wallet filename using UUID - let uuid = Uuid::new_v4(); // This is the correct method to generate a random UUID - let wallet_filename = format!("wallet_{}", uuid); - let wallet_path = temp_dir.path().join(wallet_filename); - - info!("Generated wallet path: {}", wallet_path.display()); - wallet_path.to_str().unwrap().to_string() -} - -/// As we are not running the monero-wrapper inside the Docker network, we need to connect to the locally exposed port -/// Docker maps the port from inside the container (18081) to a random port on the host -/// This function extracts the port and constructs the address as "localhost:" -fn get_daemon_address(monerod_container: &Container<'_, Monerod>) -> String { - let local_daemon_rpc_port = monerod_container.ports().map_to_host_port_ipv4(monero_harness::image::RPC_PORT); - let local_daemon_rpc_port = local_daemon_rpc_port.expect("monerod should have a mapping to the host for the default RPC port"); - - format!("localhost:{}", local_daemon_rpc_port) -} - -async fn connect_and_check_balance(seed: String, daemon_address: String) -> u64 { - // Get wallet manager - let wallet_manager_mutex = WalletManager::get(); - let mut wallet_manager = wallet_manager_mutex - .lock() - .expect("Failed to lock wallet manager"); - - // Set daemon address - wallet_manager.set_daemon_address(&daemon_address); - - // Check connection - let connected = wallet_manager.connected(); - info!("Connected to daemon: {}", connected); - assert!(connected, "Should be connected to daemon"); - - // Get a unique wallet path from the global temp directory - let wallet_path = get_temp_wallet_path(); - tracing::info!("Recovering wallet from seed to {}", wallet_path); - - // Recover wallet from seed - let mut wallet = wallet_manager.recover_wallet( - &wallet_path, - PASSWORD, - &seed, - NetworkType::Mainnet, // Regtest uses Mainnet addresses - 1, // Restore height (start from beginning) - KDF_ROUNDS, - SEED_OFFSET, - ); - - // Initialize wallet - wallet.init(&daemon_address, false); - - tracing::info!("Starting testing of wallet with address: {}", wallet.address(0, 0)); - - // We need to allow mismatched daemon versions for the Regtest network - // to be accepted by wallet2 - wallet.set_allow_mismatched_daemon_version(true); - - // Start background refresh - wallet.start_refresh(); - - // Wait for wallet to sync - info!("Waiting for wallet to sync..."); - while !wallet.synchronized() { - let wallet_height = wallet.blockchain_height(); - let daemon_height = wallet.daemon_blockchain_height(); - - info!( - "Wallet height: {}, Daemon height: {}", - wallet_height, daemon_height - ); - - sleep(Duration::from_secs(1)).await; - } - - // Manual refresh to ensure we have the latest state - info!("Performing final refresh"); - let refresh_result = wallet.refresh(); - info!("Final refresh result: {}", refresh_result); - - // Check balance - let balance = wallet.balance_all(); - info!("Final balance check: {}", balance); - balance -} \ No newline at end of file diff --git a/monero-sys/tests/simple.rs b/monero-sys/tests/simple.rs deleted file mode 100644 index 163b6fbf5..000000000 --- a/monero-sys/tests/simple.rs +++ /dev/null @@ -1,88 +0,0 @@ -use monero_wallet_sys::{NetworkType, WalletManager}; - -const KDF_ROUNDS: u64 = 1; -const PASSWORD: &str = "test"; - -// No seed offset for now. This is the default -// TODO: Let lib.rs take an Option<_> and if None pass in an empty string -const SEED_OFFSET: &str = ""; - -const STAGENET_REMOTE_NODE: &str = "node.sethforprivacy.com:38089"; -const STAGENET_WALLET_SEED: &str = "echo ourselves ruined oven masterful wives enough addicted future cottage illness adopt lucky movement tiger taboo imbalance antics iceberg hobby oval aloof tuesday uttered oval"; -const STAGENET_WALLET_RESTORE_HEIGHT: u64 = 1728128; - -#[test] -fn main() { - tracing_subscriber::fmt::init(); - - let wallet_manager_mutex = WalletManager::get(); - let mut wallet_manager = wallet_manager_mutex - .lock() - .expect("failed to lock wallet manager"); - - tracing::info!("Setting daemon address"); - wallet_manager.set_daemon_address(STAGENET_REMOTE_NODE); - - tracing::info!("Connected: {}", wallet_manager.connected()); - - let temp_dir = tempfile::tempdir().unwrap(); - - let wallet_name = "recovered_wallet"; - let wallet_path = temp_dir.path().join(wallet_name); - - tracing::info!("Recovering wallet from seed"); - let mut wallet = wallet_manager.recover_wallet( - wallet_path.to_str().unwrap(), - PASSWORD, - STAGENET_WALLET_SEED, - NetworkType::Stagenet, - STAGENET_WALLET_RESTORE_HEIGHT, - KDF_ROUNDS, - SEED_OFFSET, - ); - - // TODO: Here we'd need to call status() I believe to check if the creation was successful - - wallet.init(STAGENET_REMOTE_NODE, true); - - tracing::info!("Primary address: {}", wallet.address(0, 0)); - - // Start background refresh - tracing::info!("Starting background refresh"); - wallet.start_refresh(); - - // Wait for a while to let the wallet sync, checking sync status - tracing::info!("Waiting for wallet to sync..."); - - // TODO: lib.rs should provide an async method that does this for us - while !wallet.synchronized() { - let wallet_height = wallet.blockchain_height(); - let daemon_height = wallet.daemon_blockchain_height(); - let is_synced = wallet.synchronized(); - - // Calculate sync percentage if daemon height is available - let sync_percentage = if daemon_height > 0 && daemon_height >= wallet_height { - (wallet_height as f64 / daemon_height as f64 * 100.0).round() - } else { - 0.0 - }; - - tracing::info!( - "Wallet height: {}, Daemon height: {}, Sync: {}%, Synchronized: {} (iteration {})", - wallet_height, daemon_height, sync_percentage, is_synced, i - ); - - std::thread::sleep(std::time::Duration::from_secs(3)); - } - - tracing::info!("Wallet is synchronized!"); - - // Manual refresh one more time - tracing::info!(result=%wallet.refresh(), "Manual refresh"); - - let balance = wallet.balance_all(); - tracing::info!("Balance: {}", balance); - - let unlocked_balance = wallet.unlocked_balance_all(); - tracing::info!("Unlocked balance: {}", unlocked_balance); -} From 8e497d075ef2126f35c659ae23141fb3ec8d143a Mon Sep 17 00:00:00 2001 From: Binarybaron Date: Mon, 28 Apr 2025 12:58:27 +0200 Subject: [PATCH 13/20] revert --- monero-harness/src/lib.rs | 24 ------------------------ 1 file changed, 24 deletions(-) diff --git a/monero-harness/src/lib.rs b/monero-harness/src/lib.rs index c621caf56..61c12a275 100644 --- a/monero-harness/src/lib.rs +++ b/monero-harness/src/lib.rs @@ -171,29 +171,6 @@ impl<'c> Monero { Ok(()) } - /// Funds a specific wallet address with XMR - /// - /// This function is useful when you want to fund an address that isn't managed by - /// a wallet in the testcontainer setup, like an external wallet address. - pub async fn fund_address(&self, address: &str, amount: u64) -> Result<()> { - let monerod = &self.monerod; - - // Make sure miner has funds by generating blocks - monerod - .client() - .generateblocks(120, address.to_string()) - .await?; - - // Mine more blocks to confirm the transaction - monerod - .client() - .generateblocks(10, address.to_string()) - .await?; - - tracing::info!("Successfully funded address with {} piconero", amount); - Ok(()) - } - pub async fn start_miner(&self) -> Result<()> { let miner_wallet = self.wallet("miner")?; let miner_address = miner_wallet.address().await?.address; @@ -269,7 +246,6 @@ impl<'c> Monerod { pub fn client(&self) -> &monerod::Client { &self.client } - /// Spawns a task to mine blocks in a regular interval to the provided /// address From ce85595cf5ba89b5a36997bd91016f934b7807bb Mon Sep 17 00:00:00 2001 From: Binarybaron Date: Wed, 4 Jun 2025 17:42:11 +0200 Subject: [PATCH 14/20] refactor --- swap/src/asb/event_loop.rs | 25 +++-- swap/src/monero.rs | 154 +++++++++++++++++++++++++++++-- swap/src/protocol/alice/state.rs | 12 +++ 3 files changed, 173 insertions(+), 18 deletions(-) diff --git a/swap/src/asb/event_loop.rs b/swap/src/asb/event_loop.rs index 160acc9cf..ccd2489fe 100644 --- a/swap/src/asb/event_loop.rs +++ b/swap/src/asb/event_loop.rs @@ -1,5 +1,5 @@ use crate::asb::{Behaviour, OutEvent, Rate}; -use crate::monero::{Amount, MONERO_FEE}; +use crate::monero::Amount; 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; @@ -511,8 +511,7 @@ 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 + /// Returns the Monero balance that is not reserved for ongoing swaps and can be used for new swaps async fn unreserved_monero_balance(&self) -> Result> { /// This is how long we maximally wait to get access to the wallet const MAX_WAIT_DURATION: Duration = Duration::from_secs(60); @@ -527,20 +526,26 @@ where 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 + // + // Those swaps where the Bitcoin has been locked (unconfirmed or confirmed) but we haven't locked the Monero yet + // + // This is still 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 + // - We now want to construct another quote + // + // The code will assume we only have reserved 6 XMR but + // once we lock the 6 XMR our two outputs will be spent + // it'll take 10 blocks before we can spend our new output (4 XMR change) + // + // In this case it'll take 20 minutes for us to be able to spend the full balance 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), + State::Alice(state) => Some(state.reserved_monero()), _ => None, }) .fold(Amount::ZERO, |acc, amount| acc + amount); diff --git a/swap/src/monero.rs b/swap/src/monero.rs index 1659099c8..751f2e0ed 100644 --- a/swap/src/monero.rs +++ b/swap/src/monero.rs @@ -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 { + /// 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 { + 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()?; @@ -520,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 @@ -528,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] @@ -537,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); @@ -591,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); + } } diff --git a/swap/src/protocol/alice/state.rs b/swap/src/protocol/alice/state.rs index 6fcfcad40..8b49513cf 100644 --- a/swap/src/protocol/alice/state.rs +++ b/swap/src/protocol/alice/state.rs @@ -587,3 +587,15 @@ impl State3 { ) } } + +impl AliceState { + /// How much Monero do we need to reserve of our balance for this swap? + pub fn reserved_monero(&self) -> monero::Amount { + match self { + AliceState::BtcLockTransactionSeen { state3 } | AliceState::BtcLocked { state3 } => { + state3.xmr.min_conservative_balance_to_spend() + } + _ => monero::Amount::ZERO, + } + } +} From 2fe60e62af8e81fcc46656ab061dc847c343cda9 Mon Sep 17 00:00:00 2001 From: Binarybaron Date: Wed, 4 Jun 2025 22:36:40 +0200 Subject: [PATCH 15/20] make clippy happy, add comments --- swap/src/asb/event_loop.rs | 2 +- swap/src/protocol/alice/state.rs | 10 ++++++++++ 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/swap/src/asb/event_loop.rs b/swap/src/asb/event_loop.rs index ccd2489fe..cb788d475 100644 --- a/swap/src/asb/event_loop.rs +++ b/swap/src/asb/event_loop.rs @@ -512,7 +512,7 @@ where } /// Returns the Monero balance that is not reserved for ongoing swaps and can be used for new swaps - async fn unreserved_monero_balance(&self) -> Result> { + async fn unreserved_monero_balance(&mut self) -> Result> { /// This is how long we maximally wait to get access to the wallet const MAX_WAIT_DURATION: Duration = Duration::from_secs(60); diff --git a/swap/src/protocol/alice/state.rs b/swap/src/protocol/alice/state.rs index 8b49513cf..6833714df 100644 --- a/swap/src/protocol/alice/state.rs +++ b/swap/src/protocol/alice/state.rs @@ -592,9 +592,19 @@ impl AliceState { /// How much Monero do we need to reserve of our balance for this swap? pub 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, } } From b34a985178d142b73a019c1c61953aa72d2cf4f9 Mon Sep 17 00:00:00 2001 From: Binarybaron Date: Wed, 4 Jun 2025 23:00:00 +0200 Subject: [PATCH 16/20] Split code into smaller portions, add unit tests --- swap/src/asb/event_loop.rs | 155 +++++++++++++++++++++++++++++-------- 1 file changed, 121 insertions(+), 34 deletions(-) diff --git a/swap/src/asb/event_loop.rs b/swap/src/asb/event_loop.rs index cb788d475..b047a4a56 100644 --- a/swap/src/asb/event_loop.rs +++ b/swap/src/asb/event_loop.rs @@ -511,8 +511,8 @@ where result } - /// Returns the Monero balance that is not reserved for ongoing swaps and can be used for new swaps - async fn unreserved_monero_balance(&mut self) -> Result> { + /// Returns the unlocked Monero balance from the wallet + async fn unlocked_monero_balance(&mut self) -> Result { /// This is how long we maximally wait to get access to the wallet const MAX_WAIT_DURATION: Duration = Duration::from_secs(60); @@ -521,38 +521,9 @@ where .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 (unconfirmed or confirmed) but we haven't locked the Monero yet - // - // This is still a naive approach because, suppose: - // - We have two UTXOs each 5 XMR - // - We have a pending swap for 6 XMR - // - We now want to construct another quote - // - // The code will assume we only have reserved 6 XMR but - // once we lock the 6 XMR our two outputs will be spent - // it'll take 10 blocks before we can spend our new output (4 XMR change) - // - // In this case it'll take 20 minutes for us to be able to spend the full balance - let reserved: Amount = self - .db - .all() - .await? - .iter() - .filter_map(|(_, state)| match state { - State::Alice(state) => Some(state.reserved_monero()), - _ => None, - }) - .fold(Amount::ZERO, |acc, amount| acc + amount); - - let free_monero_balance = balance.checked_sub(reserved).unwrap_or(Amount::ZERO); + .context("Failed to get Monero balance")?; - Ok(free_monero_balance) + Ok(Amount::from_piconero(balance.unlocked_balance)) } /// Computes a quote and returns the result wrapped in Arcs. @@ -568,7 +539,21 @@ where .ask() .map_err(|e| Arc::new(e.context("Failed to compute asking price")))?; - let unreserved_xmr_balance = self.unreserved_monero_balance().await?; + // Get the unlocked balance + let balance = self.unlocked_monero_balance().await.map_err(Arc::new)?; + + // Get the reserved amounts + let all_swaps = self.db.all().await.map_err(Arc::new)?; + let reserved_amounts: Vec = all_swaps + .iter() + .filter_map(|(_, state)| match state { + State::Alice(state) => Some(state.reserved_monero()), + _ => None, + }) + .collect(); + + let unreserved_xmr_balance = + unreserved_monero_balance(balance, reserved_amounts.into_iter()); let max_bitcoin_for_monero = unreserved_xmr_balance .max_bitcoin_for_price(ask_price) @@ -846,6 +831,21 @@ impl EventLoopHandle { } } +/// Calculates the unreserved Monero balance by subtracting reserved amounts from unlocked balance +pub fn unreserved_monero_balance( + unlocked_balance: Amount, + reserved_amounts: impl Iterator, +) -> 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) +} + #[allow(missing_debug_implementations)] struct MpscChannels { sender: mpsc::Sender, @@ -858,3 +858,90 @@ impl Default for MpscChannels { 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 = 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); + } +} From 1bf8a792165cf7ac0c5a29d6250c1d4355864da3 Mon Sep 17 00:00:00 2001 From: Binarybaron Date: Wed, 4 Jun 2025 23:54:51 +0200 Subject: [PATCH 17/20] refactor, add unit tests --- swap/src/asb/event_loop.rs | 406 ++++++++++++++++++++++++------- swap/src/protocol/alice/state.rs | 11 +- 2 files changed, 323 insertions(+), 94 deletions(-) diff --git a/swap/src/asb/event_loop.rs b/swap/src/asb/event_loop.rs index b047a4a56..ee0600aac 100644 --- a/swap/src/asb/event_loop.rs +++ b/swap/src/asb/event_loop.rs @@ -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}; @@ -37,6 +37,91 @@ struct QuoteCacheKey { max_buy: bitcoin::Amount, } +/// Computes a quote given the provided dependencies +pub async fn make_quote( + min_buy: bitcoin::Amount, + max_buy: bitcoin::Amount, + mut latest_rate: LR, + get_unlocked_balance: F, + get_reserved_items: I, +) -> Result, Arc> +where + LR: LatestRate, + F: FnOnce() -> Fut, + Fut: futures::Future>, + I: FnOnce() -> Fut2, + Fut2: futures::Future, 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 to construct quote") + .map_err(Arc::new)?; + + // Get the reserved amounts + let reserved_amounts: Vec = get_reserved_items() + .await + .context("Failed to get reserved items to construct quote") + .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, + })) +} + #[allow(missing_debug_implementations)] pub struct EventLoop where @@ -501,103 +586,37 @@ 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; - - // Insert the computed quote into the cache - // Need to clone it as insert takes ownership - self.quote_cache.insert(key, result.clone()).await; - // Return the computed quote - result - } + let rate = self.latest_rate.clone(); - /// Returns the unlocked Monero balance from the wallet - async fn unlocked_monero_balance(&mut self) -> Result { - /// This is how long we maximally wait to get access to the wallet - const MAX_WAIT_DURATION: Duration = Duration::from_secs(60); + let get_reserved_items = || async { + let all_swaps = self.db.all().await.context("Failed to get reserved items to construct quote")?; + let alice_states: Vec<_> = all_swaps.into_iter().filter_map(|(_, state)| match state { + State::Alice(state) => Some(state), + _ => None, + }).collect(); - 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 - .context("Failed to get Monero balance")?; + Ok(alice_states) + }; - Ok(Amount::from_piconero(balance.unlocked_balance)) - } + let get_unlocked_balance = + || async { unlocked_monero_balance_with_timeout(self.monero_wallet.clone()).await }; - /// 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> { - 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")))?; - - // Get the unlocked balance - let balance = self.unlocked_monero_balance().await.map_err(Arc::new)?; - - // Get the reserved amounts - let all_swaps = self.db.all().await.map_err(Arc::new)?; - let reserved_amounts: Vec = all_swaps - .iter() - .filter_map(|(_, state)| match state { - State::Alice(state) => Some(state.reserved_monero()), - _ => None, - }) - .collect(); - - let unreserved_xmr_balance = - unreserved_monero_balance(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, - })); - } + let result = make_quote( + min_buy, + max_buy, + rate, + get_unlocked_balance, + get_reserved_items, + ) + .await; - 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, - })); - } + // Insert the computed quote into the cache + // Need to clone it as insert takes ownership + self.quote_cache.insert(key, result.clone()).await; - Ok(Arc::new(BidQuote { - price: ask_price, - min_quantity: min_buy, - max_quantity: max_buy, - })) + // Return the computed quote + result } async fn handle_execution_setup_done( @@ -846,6 +865,23 @@ pub fn unreserved_monero_balance( .unwrap_or(Amount::ZERO) } +/// Returns the unlocked Monero balance from the wallet +async fn unlocked_monero_balance_with_timeout( + wallet: Arc>, +) -> Result { + /// 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, 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 { sender: mpsc::Sender, @@ -944,4 +980,192 @@ mod tests { 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 = 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 = 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 = 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 = 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 = 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 + } + } } diff --git a/swap/src/protocol/alice/state.rs b/swap/src/protocol/alice/state.rs index 6833714df..3fb047ece 100644 --- a/swap/src/protocol/alice/state.rs +++ b/swap/src/protocol/alice/state.rs @@ -588,9 +588,14 @@ impl State3 { } } -impl AliceState { - /// How much Monero do we need to reserve of our balance for this swap? - pub fn reserved_monero(&self) -> monero::Amount { +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 From 4f82aee8d4ca5d819e3db89d45a5cfc2b661f444 Mon Sep 17 00:00:00 2001 From: Binarybaron Date: Thu, 5 Jun 2025 00:00:16 +0200 Subject: [PATCH 18/20] fmt --- swap/src/asb/event_loop.rs | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/swap/src/asb/event_loop.rs b/swap/src/asb/event_loop.rs index ee0600aac..105945cd9 100644 --- a/swap/src/asb/event_loop.rs +++ b/swap/src/asb/event_loop.rs @@ -590,11 +590,14 @@ where let rate = self.latest_rate.clone(); let get_reserved_items = || async { - let all_swaps = self.db.all().await.context("Failed to get reserved items to construct quote")?; - let alice_states: Vec<_> = all_swaps.into_iter().filter_map(|(_, state)| match state { - State::Alice(state) => Some(state), - _ => None, - }).collect(); + 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) }; @@ -870,9 +873,9 @@ async fn unlocked_monero_balance_with_timeout( wallet: Arc>, ) -> Result { /// This is how long we maximally wait to get access to the wallet - const MAX_WAIT_DURATION: Duration = Duration::from_secs(60); + const MONERO_WALLET_MUTEX_LOCK_TIMEOUT: Duration = Duration::from_secs(60); - let balance = timeout(MAX_WAIT_DURATION, wallet.lock()) + let balance = timeout(MONERO_WALLET_MUTEX_LOCK_TIMEOUT, wallet.lock()) .await .context("Timeout while waiting for lock on Monero wallet")? .get_balance() From 0b41e93fb6fd8f5bbf1ba6af5855ad410e035cd5 Mon Sep 17 00:00:00 2001 From: Binarybaron Date: Thu, 5 Jun 2025 00:02:54 +0200 Subject: [PATCH 19/20] reorder functions --- swap/src/asb/event_loop.rs | 173 +++++++++++++++++++------------------ 1 file changed, 87 insertions(+), 86 deletions(-) diff --git a/swap/src/asb/event_loop.rs b/swap/src/asb/event_loop.rs index 105945cd9..feb4803d0 100644 --- a/swap/src/asb/event_loop.rs +++ b/swap/src/asb/event_loop.rs @@ -37,91 +37,6 @@ struct QuoteCacheKey { max_buy: bitcoin::Amount, } -/// Computes a quote given the provided dependencies -pub async fn make_quote( - min_buy: bitcoin::Amount, - max_buy: bitcoin::Amount, - mut latest_rate: LR, - get_unlocked_balance: F, - get_reserved_items: I, -) -> Result, Arc> -where - LR: LatestRate, - F: FnOnce() -> Fut, - Fut: futures::Future>, - I: FnOnce() -> Fut2, - Fut2: futures::Future, 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 to construct quote") - .map_err(Arc::new)?; - - // Get the reserved amounts - let reserved_amounts: Vec = get_reserved_items() - .await - .context("Failed to get reserved items to construct quote") - .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, - })) -} - #[allow(missing_debug_implementations)] pub struct EventLoop where @@ -612,7 +527,8 @@ where get_unlocked_balance, get_reserved_items, ) - .await; + .await + .context("Failed to construct quote"); // Insert the computed quote into the cache // Need to clone it as insert takes ownership @@ -853,6 +769,91 @@ impl EventLoopHandle { } } +/// Computes a quote given the provided dependencies +pub async fn make_quote( + min_buy: bitcoin::Amount, + max_buy: bitcoin::Amount, + mut latest_rate: LR, + get_unlocked_balance: F, + get_reserved_items: I, +) -> Result, Arc> +where + LR: LatestRate, + F: FnOnce() -> Fut, + Fut: futures::Future>, + I: FnOnce() -> Fut2, + Fut2: futures::Future, 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 = 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, From 7c24a8c5c9a15ee2c5e5b1c7bc83c5a48f3575d8 Mon Sep 17 00:00:00 2001 From: Binarybaron Date: Thu, 5 Jun 2025 00:08:00 +0200 Subject: [PATCH 20/20] fix compile error, max timeout of 10s for mutex lock --- swap/src/asb/event_loop.rs | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/swap/src/asb/event_loop.rs b/swap/src/asb/event_loop.rs index feb4803d0..6e70ce4d9 100644 --- a/swap/src/asb/event_loop.rs +++ b/swap/src/asb/event_loop.rs @@ -527,8 +527,7 @@ where get_unlocked_balance, get_reserved_items, ) - .await - .context("Failed to construct quote"); + .await; // Insert the computed quote into the cache // Need to clone it as insert takes ownership @@ -874,7 +873,7 @@ async fn unlocked_monero_balance_with_timeout( wallet: Arc>, ) -> Result { /// This is how long we maximally wait to get access to the wallet - const MONERO_WALLET_MUTEX_LOCK_TIMEOUT: Duration = Duration::from_secs(60); + const MONERO_WALLET_MUTEX_LOCK_TIMEOUT: Duration = Duration::from_secs(10); let balance = timeout(MONERO_WALLET_MUTEX_LOCK_TIMEOUT, wallet.lock()) .await