diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index cb50b4b4..f7c961ac 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -109,6 +109,8 @@ jobs: include: - package: swap test_name: happy_path + - package: swap + test_name: happy_path_alice_developer_tip - package: swap test_name: happy_path_restart_bob_after_xmr_locked - package: swap diff --git a/CHANGELOG.md b/CHANGELOG.md index 5c19a192..a584a597 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] - ORCHESTRATOR: Introduce a new `asb-tracing-logger` container within the `docker-compose.yml`. The `asb-tracing-logger` gives you access to the tracing (high verbosity) logs of your asb. Download the new `orchestrator` and run it. Then run `docker compose up -d`. The new `asb-tracing-logger` container will be created. Then run `docker compose logs -f --tail 100 asb-tracing-logger` to view detailed logging and see what is going on behind the scenes. The `asb` will continue printing less-verbose logs like before. +- ASB: You can now configure your maker to donate a small part of swaps to funding further development of the project. This is disabled by default. You can manually enable it if you choose to do so. Set `maker.developer_tip` to a number between 0 and 1. Setting `maker.developer_tip` to `0.02` will donate 2% of each swap to the [donation address](https://github.com/eigenwallet/core?tab=readme-ov-file#donations) of the project. This is defined [here](https://github.com/eigenwallet/core/blob/ce4a85bfdd3b3fd6fbdf6c4c1ab0e1c3188b7fc2/swap-env/src/defaults.rs#L9) in the code. The tip is sent by adding an additional output to the Monero lock transaction of a swap. This means this will not impact the availability of your UTXOs (unlocked funds) as it does not require an additonal transaction. Because tips are only ever sent in Monero you maintain full privacy. +- ASB + CLI + GUI (Testnet only): Bitcoin timelocks have been tripled. This has no affect for mainnet swaps. Blocktimes are too low on testnet to be able to test reliably. ## [3.0.2] - 2025-09-21 diff --git a/Cargo.lock b/Cargo.lock index ce53e369..56f273fb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2127,7 +2127,7 @@ dependencies = [ "num-traits", "serde", "wasm-bindgen", - "windows-link", + "windows-link 0.1.3", ] [[package]] @@ -2461,6 +2461,19 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "console" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b430743a6eb14e9764d4260d4c0d8123087d504eeb9c48f2b2a5e810dd369df4" +dependencies = [ + "encode_unicode", + "libc", + "once_cell", + "unicode-width 0.2.1", + "windows-sys 0.61.0", +] + [[package]] name = "const-oid" version = "0.9.6" @@ -3495,7 +3508,7 @@ version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "658bce805d770f407bc62102fca7c2c64ceef2fbcb2b8bd19d2765ce093980de" dependencies = [ - "console", + "console 0.15.11", "shell-words", "tempfile", "thiserror 1.0.69", @@ -13566,6 +13579,7 @@ dependencies = [ "anyhow", "bitcoin 0.32.7", "config", + "console 0.16.1", "dialoguer", "libp2p", "monero", @@ -13573,6 +13587,7 @@ dependencies = [ "serde", "swap-fs", "swap-serde", + "terminal_size", "thiserror 1.0.69", "time 0.3.41", "toml 0.9.5", @@ -17456,7 +17471,7 @@ dependencies = [ "windows-collections", "windows-core 0.61.2", "windows-future", - "windows-link", + "windows-link 0.1.3", "windows-numerics", ] @@ -17499,7 +17514,7 @@ checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3" dependencies = [ "windows-implement 0.60.0", "windows-interface 0.59.1", - "windows-link", + "windows-link 0.1.3", "windows-result 0.3.4", "windows-strings", ] @@ -17511,7 +17526,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc6a41e98427b19fe4b73c550f060b59fa592d7d686537eebf9385621bfbad8e" dependencies = [ "windows-core 0.61.2", - "windows-link", + "windows-link 0.1.3", "windows-threading", ] @@ -17565,6 +17580,12 @@ version = "0.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a" +[[package]] +name = "windows-link" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "45e46c0661abb7180e7b9c281db115305d49ca1709ab8242adf09666d2173c65" + [[package]] name = "windows-numerics" version = "0.2.0" @@ -17572,7 +17593,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9150af68066c4c5c07ddc0ce30421554771e528bde427614c61038bc2c92c2b1" dependencies = [ "windows-core 0.61.2", - "windows-link", + "windows-link 0.1.3", ] [[package]] @@ -17581,7 +17602,7 @@ version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5b8a9ed28765efc97bbc954883f4e6796c33a06546ebafacbabee9696967499e" dependencies = [ - "windows-link", + "windows-link 0.1.3", "windows-result 0.3.4", "windows-strings", ] @@ -17601,7 +17622,7 @@ version = "0.3.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "56f42bd332cc6c8eac5af113fc0c1fd6a8fd2aa08a0119358686e5160d0586c6" dependencies = [ - "windows-link", + "windows-link 0.1.3", ] [[package]] @@ -17610,7 +17631,7 @@ version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "56e6c93f3a0c3b36176cb1327a4958a0353d5d166c2a35cb268ace15e91d3b57" dependencies = [ - "windows-link", + "windows-link 0.1.3", ] [[package]] @@ -17658,6 +17679,15 @@ dependencies = [ "windows-targets 0.53.3", ] +[[package]] +name = "windows-sys" +version = "0.61.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e201184e40b2ede64bc2ea34968b28e33622acdbbf37104f0e4a33f7abe657aa" +dependencies = [ + "windows-link 0.2.0", +] + [[package]] name = "windows-targets" version = "0.42.2" @@ -17710,7 +17740,7 @@ version = "0.53.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d5fe6031c4041849d7c496a8ded650796e7b6ecc19df1a431c1a363342e5dc91" dependencies = [ - "windows-link", + "windows-link 0.1.3", "windows_aarch64_gnullvm 0.53.0", "windows_aarch64_msvc 0.53.0", "windows_i686_gnu 0.53.0", @@ -17727,7 +17757,7 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b66463ad2e0ea3bbf808b7f1d371311c80e115c0b71d60efc142cafbcfb057a6" dependencies = [ - "windows-link", + "windows-link 0.1.3", ] [[package]] @@ -17736,7 +17766,7 @@ version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e04a5c6627e310a23ad2358483286c7df260c964eb2d003d8efd6d0f4e79265c" dependencies = [ - "windows-link", + "windows-link 0.1.3", ] [[package]] diff --git a/dev-docs/asb/README.md b/dev-docs/asb/README.md index f09054b0..980cb7e3 100644 --- a/dev-docs/asb/README.md +++ b/dev-docs/asb/README.md @@ -120,6 +120,7 @@ max_buy_btc = 0.0001 ask_spread = 0.02 price_ticker_ws_url = "wss://ws.kraken.com" external_bitcoin_address = "bc1..." +developer_tip = 0.02 ``` The minimum and maximum amount as well as a spread, that is added on top of the price fetched from a central exchange, can be configured. @@ -127,6 +128,8 @@ The minimum and maximum amount as well as a spread, that is added on top of the `external_bitcoin_address` allows to specify the Bitcoin address that the ASB will use to redeem or punish swaps. If the option is not set, a new address from the internal wallet is used for every swap. +`developer_tip` allows configuring your maker to donate a small part of swaps to funding further development of the project. This is disabled by default. You can manually enable it if you choose to do so. Set it to a number between 0 and 1. Setting it to 0.02 will donate 2% of each swap to the donation address of the project. The tip is sent by adding an additional output to the Monero lock transaction of a swap. This means this will not impact the availability of your UTXOs (unlocked funds) as it does not require an additonal transaction. + In order to be able to trade, the ASB must define a price to be able to agree on the amounts to be swapped with a CLI. The `XMR<>BTC` price is currently determined by the price from the central exchange Kraken. Upon startup the ASB connects to the Kraken price websocket and listens on the stream for price updates. diff --git a/docs/pages/becoming_a_maker/overview.mdx b/docs/pages/becoming_a_maker/overview.mdx index ad5318c2..5d114e9a 100644 --- a/docs/pages/becoming_a_maker/overview.mdx +++ b/docs/pages/becoming_a_maker/overview.mdx @@ -118,6 +118,7 @@ max_buy_btc = 0.1 ask_spread = 0.02 price_ticker_ws_url = "wss://ws.kraken.com/" external_bitcoin_address = "bc1..." +developer_tip = 0.02 # ... ``` @@ -131,6 +132,7 @@ Below an explanation of what each option does: | `ask_spread` | The markup the asb will charge compared to the market price, as a factor. The market price is fetched via the `price_ticker_ws_url`. A value of `0.02` means the asb will charge 2% more than the market price. | | `price_ticker_ws_url` | The URL of a websocket that provides the market price. The default is the Kraken API, but you can build your own websocket server which mimics the Kraken API. | | `external_bitcoin_address` | Bitcoin address used by the asb when redeeming or punishing swaps. If omitted, a new internal address is generated for each swap. | +| `developer_tip` | Optional donation as a fraction between 0 and 1 (e.g. `0.02` = 2%). Disabled by default. Sent in Monero by adding an extra output to the Monero lock transaction, so no extra transaction and no impact on unlocked UTXOs; privacy preserved. | ### Bitcoin Section diff --git a/justfile b/justfile index c684f7af..3ea4f73e 100644 --- a/justfile +++ b/justfile @@ -60,6 +60,9 @@ tests: docker_test_happy_path: cargo test --package swap --test happy_path -- --nocapture +docker_test_happy_path_with_developer_tip: + cargo test --package swap --test happy_path_alice_developer_tip -- --nocapture + docker_test_all: cargo test --package swap --test all -- --nocapture diff --git a/monero-harness/src/lib.rs b/monero-harness/src/lib.rs index 9b1000ab..846b55ed 100644 --- a/monero-harness/src/lib.rs +++ b/monero-harness/src/lib.rs @@ -490,7 +490,7 @@ impl MoneroWallet { ); let amount = Amount::from_pico(amount_pico); self.wallet - .transfer(address, amount) + .transfer_single_destination(address, amount) .await .context("Failed to perform transfer") } @@ -515,7 +515,7 @@ impl MoneroWallet { self.balance().await?; self.wallet - .sweep_multi(addresses, ratios) + .sweep_multi_destination(addresses, ratios) .await .context("Failed to perform sweep")? .into_iter() diff --git a/monero-sys/src/bridge.h b/monero-sys/src/bridge.h index e3b7142d..fed627e8 100644 --- a/monero-sys/src/bridge.h +++ b/monero-sys/src/bridge.h @@ -156,12 +156,15 @@ namespace Monero /** * Creates a transaction that spends the unlocked balance to multiple destinations with given ratios. - * Ratiosn must sum to 1. */ inline PendingTransaction *createTransactionMultiDest( Wallet &wallet, const std::vector &dest_addresses, - const std::vector &amounts) + const std::vector &amounts, + // If set to true, the fee will be subtracted from output with the highest amount + // If set to false, the fee will be paid by the wallet and the exact amounts will be sent to the destinations + bool subtract_fee_from_outputs + ) { size_t n = dest_addresses.size(); @@ -185,9 +188,15 @@ namespace Monero // Find the highest output and choose it for subtract_fee_indices std::set subtract_fee_indices; - auto max_it = std::max_element(amounts.begin(), amounts.end()); - size_t max_index = std::distance(amounts.begin(), max_it); - subtract_fee_indices.insert(static_cast(max_index)); + + // If subtract_fee_from_outputs = false, this will not be executed and + // subtract_fee_indices will remain empty which symbolizes that the fee will be paid by the wallet + // and the exact amounts will be sent to the destinations + if (subtract_fee_from_outputs) { + auto max_it = std::max_element(amounts.begin(), amounts.end()); + size_t max_index = std::distance(amounts.begin(), max_it); + subtract_fee_indices.insert(static_cast(max_index)); + } return wallet.createTransactionMultDest( dest_addresses, diff --git a/monero-sys/src/bridge.rs b/monero-sys/src/bridge.rs index dbc0ab2d..6c925221 100644 --- a/monero-sys/src/bridge.rs +++ b/monero-sys/src/bridge.rs @@ -276,6 +276,7 @@ pub mod ffi { wallet: Pin<&mut Wallet>, dest_addresses: &CxxVector, amounts: &CxxVector, + subtract_fee_from_outputs: bool, ) -> *mut PendingTransaction; fn vector_string_push_back(v: Pin<&mut CxxVector>, s: &CxxString); diff --git a/monero-sys/src/lib.rs b/monero-sys/src/lib.rs index 26b50dea..6d934710 100644 --- a/monero-sys/src/lib.rs +++ b/monero-sys/src/lib.rs @@ -459,7 +459,7 @@ impl WalletHandle { } /// Transfer funds to an address without approval. - pub async fn transfer( + pub async fn transfer_single_destination( &self, address: &monero::Address, amount: monero::Amount, @@ -467,7 +467,7 @@ impl WalletHandle { let address = *address; retry_notify(backoff(None, None), || async { - self.call(move |wallet| wallet.transfer(&address, amount)) + self.call(move |wallet| wallet.transfer_single_destination(&address, amount)) .await .map_err(backoff::Error::transient) }, |error, duration: Duration| { @@ -477,6 +477,26 @@ impl WalletHandle { .map_err(|e| anyhow!("Failed to transfer funds after multiple attempts: {e}")) } + /// Transfer funds to multiple addresses in a single transaction without approval. + pub async fn transfer_multi_destination( + &self, + destinations: &[(monero::Address, monero::Amount)], + ) -> anyhow::Result { + let destinations = destinations.to_vec(); + + retry_notify(backoff(None, None), || async { + let destinations = destinations.clone(); + + self.call(move |wallet| wallet.transfer_multi_destination(&destinations)) + .await + .map_err(backoff::Error::transient) + }, |error, duration: Duration| { + tracing::error!(error=?error, "Failed to transfer funds to multiple destinations, retrying in {} secs", duration.as_secs()); + }) + .await + .map_err(|e| anyhow!("Failed to transfer funds to multiple destinations after multiple attempts: {e:?}")) + } + /// Sweep all funds to an address. pub async fn sweep(&self, address: &monero::Address) -> anyhow::Result> { let address = *address; @@ -486,10 +506,10 @@ impl WalletHandle { .await .map_err(backoff::Error::transient) }, |error, duration: Duration| { - tracing::error!(error=%error, "Failed to sweep funds, retrying in {} secs", duration.as_secs()); + tracing::error!(error=?error, "Failed to sweep funds, retrying in {} secs", duration.as_secs()); }) .await - .map_err(|e| anyhow!("Failed to sweep funds after multiple attempts: {e}")) + .map_err(|e| anyhow!("Failed to sweep funds after multiple attempts: {e:?}")) } /// Get the seed of the wallet. @@ -505,7 +525,7 @@ impl WalletHandle { /// Sweep all funds to a set of addresses. /// If the address is `None`, the address will be set to the primary address of the /// wallet - pub async fn sweep_multi( + pub async fn sweep_multi_destination( &self, addresses: &[monero::Address], percentages: &[f64], @@ -834,7 +854,9 @@ impl WalletHandle { let (uuid, txid, amount, fee) = self .call_with_pending_txs(move |wallet, pending_txs| { let pending_tx = match amount { - Some(amount) => wallet.create_pending_transaction(&address, amount)?, + Some(amount) => { + wallet.create_pending_transaction_single_dest(&address, amount)? + } None => wallet.create_pending_sweep_transaction(&address)?, }; @@ -1878,12 +1900,25 @@ impl FfiWallet { /// Transfer a specified amount of monero to a specified address and return a receipt containing /// the transaction id, transaction key and current blockchain height. This can be used later /// to prove the transfer or to wait for confirmations. - fn transfer( + fn transfer_single_destination( &mut self, address: &monero::Address, amount: monero::Amount, ) -> anyhow::Result { - let mut pending_tx = self.create_pending_transaction(address, amount)?; + let mut pending_tx = self.create_pending_transaction_single_dest(address, amount)?; + let result = self.publish_pending_transaction(&mut pending_tx); + self.dispose_pending_transaction(pending_tx); + result + } + + /// Transfer specified amounts of monero to multiple addresses in a single transaction and return a receipt containing + /// the transaction id, transaction key and current blockchain height. This can be used later + /// to prove the transfer or to wait for confirmations. + fn transfer_multi_destination( + &mut self, + destinations: &[(monero::Address, monero::Amount)], + ) -> anyhow::Result { + let mut pending_tx = self.create_pending_transaction_multi_dest(destinations, false)?; let result = self.publish_pending_transaction(&mut pending_tx); self.dispose_pending_transaction(pending_tx); result @@ -1891,7 +1926,7 @@ impl FfiWallet { /// Create a pending transaction without publishing it. /// Returns the pending transaction that can be inspected before publishing. - fn create_pending_transaction( + fn create_pending_transaction_single_dest( &mut self, address: &monero::Address, amount: monero::Amount, @@ -1907,6 +1942,58 @@ impl FfiWallet { Ok(pending_tx) } + /// Create a pending transaction that spends to multiple destinations without publishing it. + /// Returns the pending transaction that can be inspected before publishing. + /// + /// Destinations with zero amount are filtered out. + fn create_pending_transaction_multi_dest( + &mut self, + destinations: &[(monero::Address, monero::Amount)], + // If set to true, the fee will be subtracted from output with the highest amount + // If set to false, the fee will be paid by the wallet and the exact amounts will be sent to the destinations + subtract_fee_from_outputs: bool, + ) -> anyhow::Result { + // Filter out any destinations with zero amount + let destinations = destinations + .iter() + .filter(|(_, amount)| amount.as_pico() > 0) + .collect::>(); + + // Build a C++ vector of destination addresses + let mut cxx_addrs: UniquePtr> = CxxVector::::new(); + + // Build a C++ vector of amounts + let mut cxx_amounts: UniquePtr> = CxxVector::::new(); + + for (address, amount) in destinations { + let_cxx_string!(s = address.to_string()); + ffi::vector_string_push_back(cxx_addrs.pin_mut(), &s); + cxx_amounts.pin_mut().push(amount.as_pico()); + } + + let cxx_addrs = cxx_addrs + .as_ref() + .context("cxx_addrs was just created, should not be null")?; + let cxx_amounts = cxx_amounts + .as_ref() + .context("cxx_amounts was just created, should not be null")?; + + // Create the multi-destination pending transaction + let raw_tx = ffi::createTransactionMultiDest( + self.inner.pinned(), + cxx_addrs, + cxx_amounts, + subtract_fee_from_outputs, + ); + + if raw_tx.is_null() { + self.check_error() + .context("Failed to create multi-destination transaction")?; + } + + Ok(PendingTransaction(raw_tx)) + } + /// Create a pending sweep transaction without publishing it. /// Returns the pending transaction that can be inspected before publishing. fn create_pending_sweep_transaction( @@ -2022,8 +2109,6 @@ impl FfiWallet { addresses: &[monero::Address], ratios: &[f64], ) -> anyhow::Result> { - tracing::warn!("STARTED MULTI SWEEP"); - if addresses.is_empty() { bail!("No addresses to sweep to"); } @@ -2047,33 +2132,18 @@ impl FfiWallet { tracing::debug!(%balance, num_outputs = addresses.len(), outputs=?amounts, "Distributing funds to outputs"); - // Build a C++ vector of destination addresses - let mut cxx_addrs: UniquePtr> = CxxVector::::new(); - for addr in addresses { - let_cxx_string!(s = addr.to_string()); - ffi::vector_string_push_back(cxx_addrs.pin_mut(), &s); - } + // Build destinations vector for create_pending_transaction_multi_dest + let destinations: Vec<(monero::Address, monero::Amount)> = addresses + .iter() + .zip(amounts.iter()) + .map(|(addr, &amount)| (addr.clone(), amount)) + .collect(); - // Build a C++ vector of amounts - let mut cxx_amounts: UniquePtr> = CxxVector::::new(); - for &amount in &amounts { - cxx_amounts.pin_mut().push(amount.as_pico()); - } - - // Create the multi-sweep pending transaction - let raw_tx = ffi::createTransactionMultiDest( - self.inner.pinned(), - cxx_addrs.as_ref().unwrap(), - cxx_amounts.as_ref().unwrap(), - ); - - if raw_tx.is_null() { - self.check_error() - .context("Failed to create multi-sweep transaction")?; - anyhow::bail!("Failed to create multi-sweep transaction"); - } - - let mut pending_tx = PendingTransaction(raw_tx); + // Create the multi-sweep pending transaction using the shared function + // Use subtract_fee_from_outputs=true since we're sweeping and want to distribute the full balance + let mut pending_tx = self + .create_pending_transaction_multi_dest(&destinations, true) + .context("Failed to create multi-sweep transaction")?; // Get the txids from the pending transaction before we publish, // otherwise it might be null. diff --git a/monero-sys/tests/simple.rs b/monero-sys/tests/simple.rs index 3647201b..4994900f 100644 --- a/monero-sys/tests/simple.rs +++ b/monero-sys/tests/simple.rs @@ -62,7 +62,7 @@ async fn main() { tracing::info!("Transferring 1 XMR to ourselves"); wallet - .transfer(&wallet.main_address().await, transfer_amount) + .transfer_single_destination(&wallet.main_address().await, transfer_amount) .await .unwrap(); diff --git a/src-tauri/capabilities/desktop.json b/src-tauri/capabilities/desktop.json index d08e1376..4e26c567 100644 --- a/src-tauri/capabilities/desktop.json +++ b/src-tauri/capabilities/desktop.json @@ -1,5 +1,6 @@ { "identifier": "desktop-capability", "platforms": ["macOS", "windows", "linux"], + "windows": ["main"], "permissions": ["cli:default", "cli:allow-cli-matches"] } diff --git a/swap-asb/Dockerfile b/swap-asb/Dockerfile index 20e73d5c..4f999f79 100644 --- a/swap-asb/Dockerfile +++ b/swap-asb/Dockerfile @@ -30,7 +30,6 @@ RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \ curl \ lbzip2 \ gperf \ - g++- \ libglib2.0-dev \ # Left out as we are not compiling for Windows in this Dockerfile # mingw-w64-x86-64 \ diff --git a/swap-asb/src/main.rs b/swap-asb/src/main.rs index f724bc58..c5acaba4 100644 --- a/swap-asb/src/main.rs +++ b/swap-asb/src/main.rs @@ -20,6 +20,7 @@ use rust_decimal::prelude::FromPrimitive; use rust_decimal::Decimal; use std::convert::TryInto; use std::env; +use std::str::FromStr; use std::sync::Arc; use structopt::clap; use structopt::clap::ErrorKind; @@ -34,7 +35,7 @@ use swap::database::{open_db, AccessMode}; use swap::network::rendezvous::XmrBtcNamespace; use swap::network::swarm; use swap::protocol::alice::swap::is_complete; -use swap::protocol::alice::{run, AliceState}; +use swap::protocol::alice::{run, AliceState, TipConfig}; use swap::protocol::{Database, State}; use swap::seed::Seed; use swap::{bitcoin, monero}; @@ -170,18 +171,13 @@ pub async fn main() -> Result<()> { } => { let db = open_db(db_file, AccessMode::ReadWrite, None).await?; - // check and warn for duplicate rendezvous points - let mut rendezvous_addrs = config.network.rendezvous_point.clone(); - let prev_len = rendezvous_addrs.len(); - rendezvous_addrs.sort(); - rendezvous_addrs.dedup(); - let new_len = rendezvous_addrs.len(); - - if new_len < prev_len { - tracing::warn!( - "`rendezvous_point` config has {} duplicate entries, they are being ignored.", - prev_len - new_len + let developer_tip = config.maker.developer_tip; + if developer_tip.is_zero() { + tracing::info!( + "Not tipping the developers (maker.developer_tip = 0 or not set in config)" ); + } else { + tracing::info!(%developer_tip, "Tipping to the developers is enabled. Thank you for your support!"); } // Initialize Monero wallet @@ -241,7 +237,7 @@ pub async fn main() -> Result<()> { resume_only, env_config, namespace, - &rendezvous_addrs, + &config.network.rendezvous_point, tor_client, config.tor.register_hidden_service, config.tor.hidden_service_num_intro_points, @@ -274,6 +270,29 @@ pub async fn main() -> Result<()> { swarm.add_external_address(external_address.clone()); } + let tip_config = { + let tip_address = monero::Address::from_str(match env_config.monero_network { + monero::Network::Mainnet => { + swap_env::defaults::DEFAULT_DEVELOPER_TIP_ADDRESS_MAINNET + } + monero::Network::Stagenet => { + swap_env::defaults::DEFAULT_DEVELOPER_TIP_ADDRESS_STAGENET + } + monero::Network::Testnet => panic!("Testnet is not supported"), + }) + .expect("Hardcoded developer tip address to be valid"); + + assert_eq!( + tip_address.network, env_config.monero_network, + "Developer tip address must be on the correct Monero network" + ); + + TipConfig { + ratio: config.maker.developer_tip, + address: tip_address, + } + }; + let bitcoin_wallet = Arc::new(bitcoin_wallet); let (event_loop, mut swap_receiver, event_loop_service) = EventLoop::new( swarm, @@ -285,6 +304,7 @@ pub async fn main() -> Result<()> { config.maker.min_buy_btc, config.maker.max_buy_btc, config.maker.external_bitcoin_redeem_address, + tip_config, ) .unwrap(); diff --git a/swap-env/Cargo.toml b/swap-env/Cargo.toml index 6c32311e..88377604 100644 --- a/swap-env/Cargo.toml +++ b/swap-env/Cargo.toml @@ -7,6 +7,7 @@ edition = "2024" anyhow = { workspace = true } bitcoin = { workspace = true } config = { version = "0.14", default-features = false, features = ["toml"] } +console = "0.16" dialoguer = { workspace = true } libp2p = { workspace = true, features = ["serde"] } monero = { workspace = true } @@ -14,6 +15,7 @@ rust_decimal = { workspace = true } serde = { workspace = true } swap-fs = { path = "../swap-fs" } swap-serde = { path = "../swap-serde" } +terminal_size = "0.4" thiserror = { workspace = true } time = "0.3" toml = { workspace = true } diff --git a/swap-env/src/config.rs b/swap-env/src/config.rs index 47c675e8..60538eb5 100644 --- a/swap-env/src/config.rs +++ b/swap-env/src/config.rs @@ -101,6 +101,15 @@ pub struct Maker { pub price_ticker_ws_url: Url, #[serde(default, with = "swap_serde::bitcoin::address_serde::option")] pub external_bitcoin_redeem_address: Option, + /// Percentage (between 0.0 and 1.0) of the swap amount + // that will be donated to the project as part of the Monero lock transaction + #[serde(default = "default_developer_tip")] + pub developer_tip: Decimal, +} + +fn default_developer_tip() -> Decimal { + // By default, we do not tip + Decimal::ZERO } impl Config { @@ -184,6 +193,7 @@ pub fn query_user_for_initial_config_with_network( let max_buy = prompt::max_buy_amount()?; let ask_spread = prompt::ask_spread()?; let rendezvous_points = prompt::rendezvous_points()?; + let developer_tip = prompt::developer_tip()?; println!(); @@ -216,6 +226,7 @@ pub fn query_user_for_initial_config_with_network( ask_spread, price_ticker_ws_url: defaults.price_ticker_ws_url, external_bitcoin_redeem_address: None, + developer_tip, }, }) } diff --git a/swap-env/src/defaults.rs b/swap-env/src/defaults.rs index 83a6cb55..601f2490 100644 --- a/swap-env/src/defaults.rs +++ b/swap-env/src/defaults.rs @@ -1,11 +1,32 @@ use crate::env::{Mainnet, Testnet}; use anyhow::{Context, Result}; use libp2p::Multiaddr; +use rust_decimal::Decimal; use std::path::{Path, PathBuf}; use std::str::FromStr; use swap_fs::{system_config_dir, system_data_dir}; use url::Url; +/* +Here's the GPG signature of the donation address. + +Signed by the public key present in `utils/gpg_keys/binarybaron.asc` + +-----BEGIN PGP SIGNED MESSAGE----- +Hash: SHA512 + +87QwQmWZQwS6RvuprCqWuJgmystL8Dw6BCx8SrrCjVJhZYGc5s6kf9A2awfFfStvEGCGeNTBNqLGrHzH6d4gi7jLM2aoq9o is our donation address for Github (signed by binarybaron) +-----BEGIN PGP SIGNATURE----- + +iHUEARYKAB0WIQQ1qETX9LVbxE4YD/GZt10+FHaibgUCaJTWlQAKCRCZt10+FHai +bhasAQDGrAkZu+FFwDZDUEZzrIVS42he+GeMiS+ykpXyL5I7RQD/dXCR3f39zFsK +1A7y45B3a8ZJYTzC7bbppg6cEnCoWQE= +=j+Vz +-----END PGP SIGNATURE----- +*/ +pub const DEFAULT_DEVELOPER_TIP_ADDRESS_MAINNET: &str = "87QwQmWZQwS6RvuprCqWuJgmystL8Dw6BCx8SrrCjVJhZYGc5s6kf9A2awfFfStvEGCGeNTBNqLGrHzH6d4gi7jLM2aoq9o"; +pub const DEFAULT_DEVELOPER_TIP_ADDRESS_STAGENET: &str = "54ZYC5tgGRoKMJDLviAcJF2aHittSZGGkFZE6wCLkuAdUyHaaiQrjTxeSyfvxycn3yiexL4YNqdUmHuaReAk6JD4DQssQcF"; + pub const DEFAULT_MIN_BUY_AMOUNT: f64 = 0.002f64; pub const DEFAULT_MAX_BUY_AMOUNT: f64 = 0.02f64; pub const DEFAULT_SPREAD: f64 = 0.02f64; @@ -91,6 +112,7 @@ pub struct Defaults { pub price_ticker_ws_url: Url, pub bitcoin_confirmation_target: u16, pub use_mempool_space_fee_estimation: bool, + pub developer_tip: Decimal, } impl GetDefaults for Mainnet { @@ -105,6 +127,7 @@ impl GetDefaults for Mainnet { price_ticker_ws_url: Url::parse(KRAKEN_PRICE_TICKER_WS_URL)?, bitcoin_confirmation_target: 1, use_mempool_space_fee_estimation: true, + developer_tip: Decimal::ZERO, }; Ok(defaults) @@ -123,6 +146,7 @@ impl GetDefaults for Testnet { price_ticker_ws_url: Url::parse(KRAKEN_PRICE_TICKER_WS_URL)?, bitcoin_confirmation_target: 1, use_mempool_space_fee_estimation: true, + developer_tip: Decimal::ZERO, }; Ok(defaults) diff --git a/swap-env/src/env.rs b/swap-env/src/env.rs index 9a06ebfe..79881059 100644 --- a/swap-env/src/env.rs +++ b/swap-env/src/env.rs @@ -74,8 +74,8 @@ impl GetConfig for Testnet { bitcoin_lock_confirmed_timeout: 1.std_hours(), bitcoin_finality_confirmations: 1, bitcoin_avg_block_time: 10.std_minutes(), - bitcoin_cancel_timelock: 12, - bitcoin_punish_timelock: 24, + bitcoin_cancel_timelock: 12 * 3, + bitcoin_punish_timelock: 24 * 3, bitcoin_network: bitcoin::Network::Testnet, monero_avg_block_time: 2.std_minutes(), monero_lock_retry_timeout: 10.std_minutes(), diff --git a/swap-env/src/prompt.rs b/swap-env/src/prompt.rs index 38b95df6..6a6c30fc 100644 --- a/swap-env/src/prompt.rs +++ b/swap-env/src/prompt.rs @@ -4,6 +4,7 @@ use crate::defaults::{ default_rendezvous_points, DEFAULT_MAX_BUY_AMOUNT, DEFAULT_MIN_BUY_AMOUNT, DEFAULT_SPREAD, }; use anyhow::{bail, Context, Result}; +use console::Style; use dialoguer::Confirm; use dialoguer::{theme::ColorfulTheme, Input, Select}; use libp2p::Multiaddr; @@ -22,6 +23,7 @@ pub fn data_directory(default_data_dir: &Path) -> Result { .to_string(), ) .interact_text()?; + Ok(data_dir.as_str().parse()?) } @@ -50,13 +52,20 @@ pub fn listen_addresses(default_listen_address: &Multiaddr) -> Result) -> Result> { - println!( + let mut info_lines = vec![ "You can configure multiple Electrum servers for redundancy. At least one is required." + .to_string(), + "The following default Electrum RPC URLs are available. We recommend using them." + .to_string(), + String::new(), + ]; + info_lines.extend( + default_electrum_urls + .iter() + .enumerate() + .map(|(i, url)| format!("{}: {}", i + 1, url)), ); - println!("The following default Electrum RPC URLs are available. We recommend using them."); - for (i, url) in default_electrum_urls.iter().enumerate() { - println!("{}: {}", i + 1, url); - } + print_info_box(info_lines); // Ask if the user wants to use the default Electrum RPC URLs let mut electrum_rpc_urls = match Confirm::with_theme(&ColorfulTheme::default()) @@ -122,13 +131,12 @@ pub fn monero_daemon_url() -> Result> { /// Prompt user for Tor hidden service registration pub fn tor_hidden_service() -> Result { - println!("Your ASB needs to be reachable from the outside world to provide quotes to takers."); - println!( - "Your ASB can run a hidden service for itself. It'll be reachable at an .onion address." - ); - println!("You do not have to run a Tor daemon yourself. You do not have to manage anything."); - println!("This will hide your IP address and allow you to run from behind a firewall without opening ports."); - println!(); + print_info_box([ + "Your ASB needs to be reachable from the outside world to provide quotes to takers.", + "Your ASB can run a hidden service for itself. It'll be reachable at an .onion address.", + "You do not have to run a Tor daemon yourself. You do not have to manage anything.", + "This will hide your IP address and allow you to run from behind a firewall without opening ports.", + ]); let selection = Select::with_theme(&ColorfulTheme::default()) .with_prompt("Do you want a Tor hidden service to be created?") @@ -157,6 +165,7 @@ pub fn max_buy_amount() -> Result { .with_prompt("Enter maximum Bitcoin amount you are willing to accept per swap or hit enter to use default.") .default(DEFAULT_MAX_BUY_AMOUNT) .interact_text()?; + bitcoin::Amount::from_btc(max_buy).map_err(Into::into) } @@ -168,7 +177,10 @@ pub fn ask_spread() -> Result { .interact_text()?; if !(0.0..=1.0).contains(&ask_spread) { - bail!(format!("Invalid spread {}. For the spread value floating point number in interval [0..1] are allowed.", ask_spread)) + bail!(format!( + "Invalid spread {}. For the spread value floating point number in interval [0..1] are allowed.", + ask_spread + )) } Decimal::from_f64(ask_spread).context("Unable to parse spread") @@ -176,20 +188,23 @@ pub fn ask_spread() -> Result { /// Prompt user for rendezvous points pub fn rendezvous_points() -> Result> { - println!("Your ASB can register with multiple rendezvous nodes for discoverability."); - println!( - "They act as sort of bootstrap nodes for peer discovery within the peer-to-peer network." - ); - println!(); - println!( - "The following rendezvous points are ran by community members. We recommend using them." - ); - println!(); - let default_rendezvous_points = default_rendezvous_points(); - for (i, point) in default_rendezvous_points.iter().enumerate() { - println!("{}: {}", i + 1, point); - } + let mut info_lines = vec![ + "Your ASB can register with multiple rendezvous nodes for discoverability.".to_string(), + "They act as sort of bootstrap nodes for peer discovery within the peer-to-peer network." + .to_string(), + String::new(), + "The following rendezvous points are ran by community members. We recommend using them." + .to_string(), + String::new(), + ]; + info_lines.extend( + default_rendezvous_points + .iter() + .enumerate() + .map(|(i, point)| format!("{}: {}", i + 1, point)), + ); + print_info_box(info_lines); // Ask if the user wants to use the default rendezvous points let use_default_rendezvous_points = Select::with_theme(&ColorfulTheme::default()) @@ -203,7 +218,7 @@ pub fn rendezvous_points() -> Result> { let mut rendezvous_points = match use_default_rendezvous_points { 0 => { - println!("You can now configure additional rendezvous points."); + print_info_box(["You can now configure additional rendezvous points."]); default_rendezvous_points } _ => Vec::new(), @@ -233,3 +248,89 @@ pub fn rendezvous_points() -> Result> { Ok(rendezvous_points) } + +pub fn developer_tip() -> Result { + // We first ask if the user wants to enable developer tipping at all + // We do not select a default here as to not bias the user + // + // If not, we return 0 + // If yes, we ask for the percentage and default to 1% (0.01) + let lines = [ + "This project has been developed by a small team of volunteers since 2022", + "We rely on donations and the Monero CCS to continue our efforts.", + "", + "You can choose to donate a small part of each swap toward development.", + "", + "Donations will be used for Github bounties among other things.", + "", + "The tip is sent as an additional output of the Monero lock transaction.", + "It does not require an extra transaction and you remain fully private.", + "", + "If enabled, you'll enter the percentage in the next step.", + ]; + print_info_box(lines); + + let enable_developer_tip = Confirm::with_theme(&ColorfulTheme::default()) + .with_prompt("Do you want to enable developer tipping?") + .interact()?; + + if !enable_developer_tip { + return Ok(Decimal::ZERO); + } + + let developer_tip = Input::with_theme(&ColorfulTheme::default()) + .with_prompt("Enter developer tip percentage (in percent; value between 0.x and 1.0; 0.01 means 1% of the swap amount is donated)") + .default(Decimal::from_f64(0.01).unwrap()) + .interact_text()?; + + if !(Decimal::ZERO..=Decimal::ONE).contains(&developer_tip) { + bail!(format!( + "Invalid developer tip {}. For the developer tip value floating point number in interval [0..1] are allowed.", + developer_tip + )) + } + + let developer_tip_percentage = + developer_tip.saturating_mul(Decimal::from_u64(100).expect("100 to fit in u64")); + + print_info_box([&format!( + "You will tip {}% of each swap to the developers. Thank you for your support!", + developer_tip_percentage + )]); + + Ok(developer_tip) +} + +/// Print a boxed info message using console styling to match dialoguer output +pub fn print_info_box(lines: L) +where + L: IntoIterator, + S: AsRef, +{ + let terminal_width = terminal_size::terminal_size().map_or(200, |(width, _)| width.0 as usize); + + let border = Style::new().cyan(); + let content = Style::new().bold(); + + let mut collected: Vec = lines.into_iter().map(|s| s.as_ref().to_string()).collect(); + + if collected.is_empty() { + collected.push(String::new()); + } + + let content_width = collected + .iter() + .map(|s| s.len()) + .max() + .expect("Failed to get line width"); + let line_width = (content_width + 2).min(terminal_width); + + let top = format!("┌{}", "─".repeat(line_width.saturating_sub(1))); + let bottom = format!("└{}", "─".repeat(line_width.saturating_sub(1))); + println!(""); + println!("{}", border.apply_to(&top)); + for l in collected { + println!("{} {}", border.apply_to("│"), content.apply_to(l)); + } + println!("{}", border.apply_to(&bottom)); +} diff --git a/swap-orchestrator/src/main.rs b/swap-orchestrator/src/main.rs index 329ae0f0..c981cf96 100644 --- a/swap-orchestrator/src/main.rs +++ b/swap-orchestrator/src/main.rs @@ -135,6 +135,8 @@ fn main() { .expect("Failed to prompt for listen addresses"); let monero_node_type = prompt::monero_node_type(); let electrum_server_type = prompt::electrum_server_type(&defaults.electrum_rpc_urls); + let developer_tip = + config_prompt::developer_tip().expect("Failed to prompt for developer tip"); let electrs_url = Url::parse(&format!("tcp://electrs:{}", recipe.ports.electrs)) .expect("electrs url to be convertible to a valid url"); @@ -183,6 +185,7 @@ fn main() { ask_spread, price_ticker_ws_url: defaults.price_ticker_ws_url, external_bitcoin_redeem_address: None, + developer_tip, }, }; diff --git a/swap-serde/src/monero.rs b/swap-serde/src/monero.rs index 8849b751..e317fc78 100644 --- a/swap-serde/src/monero.rs +++ b/swap-serde/src/monero.rs @@ -104,7 +104,9 @@ pub mod address { use std::str::FromStr; #[derive(thiserror::Error, Debug, Clone, Copy, PartialEq)] - #[error("Invalid monero address provided, expected address on network {expected:?} but address provided is on {actual:?}")] + #[error( + "Invalid monero address provided, expected address on network {expected:?} but address provided is on {actual:?}" + )] pub struct MoneroAddressNetworkMismatch { pub expected: monero::Network, pub actual: monero::Network, diff --git a/swap/src/asb/event_loop.rs b/swap/src/asb/event_loop.rs index 226f49ee..9edeb26f 100644 --- a/swap/src/asb/event_loop.rs +++ b/swap/src/asb/event_loop.rs @@ -8,7 +8,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, State3, Swap, TipConfig}; use crate::protocol::{Database, State}; use crate::{bitcoin, monero}; use anyhow::{anyhow, Context, Result}; @@ -19,6 +19,7 @@ use libp2p::request_response::{OutboundFailure, OutboundRequestId, ResponseChann use libp2p::swarm::SwarmEvent; use libp2p::{PeerId, Swarm}; use moka::future::Cache; +use rust_decimal::Decimal; use std::collections::HashMap; use std::convert::TryInto; use std::fmt::Debug; @@ -45,6 +46,7 @@ where min_buy: bitcoin::Amount, max_buy: bitcoin::Amount, external_redeem_address: Option, + developer_tip: TipConfig, /// Cache for quotes quote_cache: Cache, Arc>>, @@ -135,6 +137,7 @@ where min_buy: bitcoin::Amount, max_buy: bitcoin::Amount, external_redeem_address: Option, + developer_tip: TipConfig, ) -> Result<(Self, mpsc::Receiver, EventLoopService)> { let swap_channel = MpscChannels::default(); let (outgoing_transfer_proofs_sender, outgoing_transfer_proofs_requests) = @@ -154,6 +157,7 @@ where min_buy, max_buy, external_redeem_address, + developer_tip, quote_cache, recv_encrypted_signature: Default::default(), inflight_encrypted_signatures: Default::default(), @@ -215,6 +219,7 @@ where db: self.db.clone(), state: state.try_into().expect("Alice state loaded from db"), swap_id, + developer_tip: self.developer_tip.clone(), }; match self.swap_sender.send(swap).await { @@ -256,7 +261,7 @@ where tracing::warn!(%peer, "Ignoring spot price request: {}", error); } SwarmEvent::Behaviour(OutEvent::QuoteRequested { channel, peer }) => { - match self.make_quote_or_use_cached(self.min_buy, self.max_buy).await { + match self.make_quote_or_use_cached(self.min_buy, self.max_buy, self.developer_tip.ratio).await { Ok(quote_arc) => { if self.swarm.behaviour_mut().quote.send_response(channel, *quote_arc).is_err() { tracing::debug!(%peer, "Failed to respond with quote"); @@ -517,6 +522,7 @@ where &mut self, min_buy: bitcoin::Amount, max_buy: bitcoin::Amount, + developer_tip: Decimal, ) -> Result, Arc> { // We use the min and max buy amounts to create a unique key for the cache // Although these values stay constant over the lifetime of an instance of the asb, this might change in the future @@ -559,6 +565,7 @@ where rate, get_unlocked_balance, get_reserved_items, + developer_tip, ) .await; @@ -568,7 +575,7 @@ where // If the quote failed, we log the error if let Err(err) = result.clone() { - tracing::warn!(%err, "Failed to make quote. We will retry again later."); + tracing::warn!(?err, "Failed to make quote. We will retry again later."); } // Return the computed quote @@ -595,6 +602,7 @@ where db: self.db.clone(), state: initial_state, swap_id, + developer_tip: self.developer_tip.clone(), }; match self.db.insert_peer_id(swap_id, bob_peer_id).await { @@ -794,6 +802,7 @@ mod service { mod quote { use crate::monero::Amount; use anyhow::{anyhow, Context}; + use rust_decimal::Decimal; use std::{sync::Arc, time::Duration}; use swap_feed::LatestRate; use tokio::time::timeout; @@ -817,6 +826,7 @@ mod quote { mut latest_rate: LR, get_unlocked_balance: F, get_reserved_items: I, + developer_tip: Decimal, ) -> Result, Arc> where LR: LatestRate, @@ -847,8 +857,11 @@ mod quote { .map(|item| item.reserved_monero()) .collect(); - let unreserved_xmr_balance = - unreserved_monero_balance(unlocked_balance, reserved_amounts.into_iter()); + let unreserved_xmr_balance = unreserved_monero_balance( + unlocked_balance, + reserved_amounts.into_iter(), + developer_tip, + ); let max_bitcoin_for_monero = unreserved_xmr_balance .max_bitcoin_for_price(ask_price) @@ -899,15 +912,53 @@ mod quote { pub fn unreserved_monero_balance( unlocked_balance: Amount, reserved_amounts: impl Iterator, + developer_tip: Decimal, ) -> Amount { - // Get the sum of all the individual reserved amounts - let total_reserved = reserved_amounts.fold(Amount::ZERO, |acc, amount| acc + amount); + use rust_decimal::prelude::ToPrimitive; - // 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) + let unlocked_balance_piconero = unlocked_balance.as_piconero_decimal(); + + // If a developer tip is configured, we need to account for the fact that + // to lock X XMR for a swap, we actually need X * (1 + tip_percentage) XMR + // because the tip is sent as an additional output in the same transaction + + // To find how much we can actually use for swaps, we need to solve: + // swap_amount * multiplier <= available_after_reserved + // <=> swap_amount <= available_after_reserved / multiplier + + // Calculate the effective multiplier: 1 + tip_percentage + let multiplier = Decimal::ONE + developer_tip; + + // The amount of Monero we can send somewhere if for every transaction we send, + // we send a tip within the same transaction as an additional output + // + // This does not take the fee into account. + // + // When we call `max_bitcoin_for_price`, it uses the `CONSERVATIVE_MONERO_FEE` constantdefined in `swap/src/monero.rs` + // to take into account the fee. + let unlocked_balance_piconero_after_accounting_for_tip = + unlocked_balance_piconero / multiplier; + + // Get the sum of all the individual reserved amounts + // This is the amount of Monero that is required for ongoing swaps + // + // Swaps where the Monero hasn't been locked yet but we know we will lock it soon + // + // Note: It is important that we subtract this AFTER accounting for the tip + // as these other swaps will also require a tip output + let total_reserved_piconero = reserved_amounts + .fold(Amount::ZERO, |acc, amount| acc + amount) + .as_piconero_decimal(); + + // Unlocked balance after accounting for the tip and the reserved amounts + let unreserved_unlocked_piconero_after_accounting_for_tip = + unlocked_balance_piconero_after_accounting_for_tip + .checked_sub(total_reserved_piconero) + .unwrap_or(Decimal::ZERO) + .to_u64() + .unwrap_or(0); + + Amount::from_piconero(unreserved_unlocked_piconero_after_accounting_for_tip) } /// Returns the unlocked Monero balance from the wallet @@ -967,7 +1018,8 @@ mod tests { let balance = Amount::from_monero(10.0).unwrap(); let reserved_amounts = vec![]; - let result = unreserved_monero_balance(balance, reserved_amounts.into_iter()); + let result = + unreserved_monero_balance(balance, reserved_amounts.into_iter(), Decimal::ZERO); assert_eq!(result, balance); } @@ -980,7 +1032,8 @@ mod tests { monero::Amount::from_monero(3.0).unwrap(), ]; - let result = unreserved_monero_balance(balance, reserved_amounts.into_iter()); + let result = + unreserved_monero_balance(balance, reserved_amounts.into_iter(), Decimal::ZERO); let expected = monero::Amount::from_monero(5.0).unwrap(); assert_eq!(result, expected); @@ -994,7 +1047,8 @@ mod tests { monero::Amount::from_monero(4.0).unwrap(), // Total reserved > balance ]; - let result = unreserved_monero_balance(balance, reserved_amounts.into_iter()); + let result = + unreserved_monero_balance(balance, reserved_amounts.into_iter(), Decimal::ZERO); // Should return zero when reserved > balance assert_eq!(result, monero::Amount::ZERO); @@ -1008,7 +1062,8 @@ mod tests { monero::Amount::from_monero(6.0).unwrap(), // Exactly equals balance ]; - let result = unreserved_monero_balance(balance, reserved_amounts.into_iter()); + let result = + unreserved_monero_balance(balance, reserved_amounts.into_iter(), Decimal::ZERO); assert_eq!(result, monero::Amount::ZERO); } @@ -1018,7 +1073,8 @@ mod tests { let balance = monero::Amount::ZERO; let reserved_amounts = vec![monero::Amount::from_monero(1.0).unwrap()]; - let result = unreserved_monero_balance(balance, reserved_amounts.into_iter()); + let result = + unreserved_monero_balance(balance, reserved_amounts.into_iter(), Decimal::ZERO); assert_eq!(result, monero::Amount::ZERO); } @@ -1028,7 +1084,11 @@ mod tests { let balance = monero::Amount::from_monero(5.0).unwrap(); let reserved_amounts: Vec = vec![]; - let result = unreserved_monero_balance(balance, reserved_amounts.into_iter()); + let result = unreserved_monero_balance( + balance, + reserved_amounts.into_iter().map(|item| item.reserved), + Decimal::ZERO, + ); assert_eq!(result, balance); } @@ -1038,7 +1098,8 @@ mod tests { let balance = monero::Amount::from_piconero(1_000_000_000); let reserved_amounts = vec![monero::Amount::from_piconero(300_000_000)]; - let result = unreserved_monero_balance(balance, reserved_amounts.into_iter()); + let result = + unreserved_monero_balance(balance, reserved_amounts.into_iter(), Decimal::ZERO); let expected = monero::Amount::from_piconero(700_000_000); assert_eq!(result, expected); @@ -1058,6 +1119,7 @@ mod tests { rate.clone(), || async { Ok(balance) }, || async { Ok(reserved_items) }, + Decimal::ZERO, ) .await .unwrap(); @@ -1088,6 +1150,7 @@ mod tests { rate.clone(), || async { Ok(balance) }, || async { Ok(reserved_items) }, + Decimal::ZERO, ) .await .unwrap(); @@ -1113,6 +1176,7 @@ mod tests { rate.clone(), || async { Ok(balance) }, || async { Ok(reserved_items) }, + None, ) .await .unwrap(); @@ -1136,6 +1200,7 @@ mod tests { rate.clone(), || async { Ok(balance) }, || async { Ok(reserved_items) }, + Decimal::ZERO, ) .await .unwrap(); @@ -1164,6 +1229,7 @@ mod tests { rate.clone(), || async { Ok(balance) }, || async { Ok(reserved_items) }, + Decimal::ZERO, ) .await .unwrap(); @@ -1186,6 +1252,7 @@ mod tests { rate.clone(), || async { Err(anyhow::anyhow!("Failed to get balance")) }, || async { Ok(reserved_items) }, + Decimal::ZERO, ) .await; @@ -1210,6 +1277,7 @@ mod tests { rate.clone(), || async { Ok(balance) }, || async { Ok(reserved_items) }, + Decimal::ZERO, ) .await .unwrap(); @@ -1220,6 +1288,11 @@ mod tests { assert_eq!(result.max_quantity, max_buy); } + #[tokio::test] + async fn test_make_quote_with_developer_tip() { + todo!("implement once unit tests compile again") + } + // Mock struct for testing #[derive(Debug, Clone)] struct MockReservedItem { diff --git a/swap/src/protocol/alice.rs b/swap/src/protocol/alice.rs index 388b2e8c..0c3d8db2 100644 --- a/swap/src/protocol/alice.rs +++ b/swap/src/protocol/alice.rs @@ -2,6 +2,7 @@ //! Alice holds XMR and wishes receive BTC. use crate::protocol::Database; use crate::{asb, bitcoin, monero}; +use rust_decimal::Decimal; use std::sync::Arc; use swap_env::env::Config; use uuid::Uuid; @@ -18,6 +19,19 @@ pub struct Swap { pub bitcoin_wallet: Arc, pub monero_wallet: Arc, pub env_config: Config, + pub developer_tip: TipConfig, pub swap_id: Uuid, pub db: Arc, } + +/// Configures how much the and where the user wants to send tips to +/// +/// The ratio is a number between 0 and 1 +/// +/// ratio = 0 means that no tip will be sent +/// ratio = 0.5 means that for a swap of 1 XMR, 0.5 XMR will be tipped +#[derive(Clone)] +pub struct TipConfig { + pub ratio: Decimal, + pub address: ::monero::Address, +} diff --git a/swap/src/protocol/alice/swap.rs b/swap/src/protocol/alice/swap.rs index 6ee15a8a..aa35a0cb 100644 --- a/swap/src/protocol/alice/swap.rs +++ b/swap/src/protocol/alice/swap.rs @@ -8,10 +8,11 @@ use crate::asb::{EventLoopHandle, LatestRate}; use crate::bitcoin::ExpiredTimelocks; use crate::common::retry; use crate::monero::TransferProof; -use crate::protocol::alice::{AliceState, Swap}; +use crate::protocol::alice::{AliceState, Swap, TipConfig}; use crate::{bitcoin, monero}; use ::bitcoin::consensus::encode::serialize_hex; use anyhow::{bail, Context, Result}; +use rust_decimal::Decimal; use swap_env::env::Config; use tokio::select; use tokio::time::timeout; @@ -43,6 +44,7 @@ where swap.bitcoin_wallet.as_ref(), swap.monero_wallet.clone(), &swap.env_config, + swap.developer_tip.clone(), rate_service.clone(), ) .await?; @@ -62,6 +64,7 @@ async fn next_state( bitcoin_wallet: &bitcoin::Wallet, monero_wallet: Arc, env_config: &Config, + developer_tip: TipConfig, mut rate_service: LR, ) -> Result where @@ -159,11 +162,14 @@ where .lock_xmr_transfer_request() .address_and_amount(env_config.monero_network); + let destinations = + build_transfer_destinations(address, amount, developer_tip.clone())?; + // Lock the Monero let receipt = monero_wallet .main_wallet() .await - .transfer(&address, amount) + .transfer_multi_destination(&destinations) .await .map_err(|e| tracing::error!(err=%e, "Failed to lock Monero")) .ok(); @@ -658,6 +664,43 @@ pub fn is_complete(state: &AliceState) -> bool { ) } +/// Build transfer destinations for the Monero lock transaction, optionally including a developer tip. +/// +/// If the tip.ratio > 0 and the effective tip is >= MIN_USEFUL_TIP_AMOUNT_PICONERO: +/// returns two destinations: one for the lock output, one for the tip output +/// +/// Otherwise: +/// returns one destination: for the lock output +fn build_transfer_destinations( + lock_address: ::monero::Address, + lock_amount: ::monero::Amount, + tip: TipConfig, +) -> anyhow::Result> { + use rust_decimal::prelude::ToPrimitive; + + // If the effective tip is less than this amount, we do not include the tip output + // Any values below `MIN_USEFUL_TIP_AMOUNT_PICONERO` are clamped to zero + // + // At $300/XMR, this is around one cent + const MIN_USEFUL_TIP_AMOUNT_PICONERO: u64 = 30_000_000; + + // TODO: Move this code into the impl of TipConfig + let tip_amount_piconero = tip + .ratio + .saturating_mul(Decimal::from(lock_amount.as_pico())) + .floor() + .to_u64() + .context("Developer tip amount should not overflow")?; + + if tip_amount_piconero >= MIN_USEFUL_TIP_AMOUNT_PICONERO { + let tip_amount = ::monero::Amount::from_pico(tip_amount_piconero); + + Ok(vec![(lock_address, lock_amount), (tip.address, tip_amount)]) + } else { + Ok(vec![(lock_address, lock_amount)]) + } +} + /// This function is used to check if Alice is in a state where it is clear that she has already received the encrypted signature from Bob. /// This allows us to acknowledge the encrypted signature multiple times /// If our acknowledgement does not reach Bob, he might send the encrypted signature again. @@ -669,3 +712,31 @@ pub(crate) fn has_already_processed_enc_sig(state: &AliceState) -> bool { | AliceState::BtcRedeemed ) } + +#[cfg(test)] +mod tests { + #[test] + fn test_build_transfer_destinations_without_tip() { + todo!("implement once unit tests compile again") + } + + #[test] + fn test_build_transfer_destinations_with_tip() { + todo!("implement once unit tests compile again") + } + + #[test] + fn test_build_transfer_destinations_with_small_tip() { + todo!("implement once unit tests compile again") + } + + #[test] + fn test_build_transfer_destinations_with_zero_tip() { + todo!("implement once unit tests compile again") + } + + #[test] + fn test_build_transfer_destinations_with_fractional_tip() { + todo!("implement once unit tests compile again") + } +} diff --git a/swap/src/protocol/bob/state.rs b/swap/src/protocol/bob/state.rs index 36fc4dfd..94cbe695 100644 --- a/swap/src/protocol/bob/state.rs +++ b/swap/src/protocol/bob/state.rs @@ -760,7 +760,7 @@ impl State5 { let main_address = monero_wallet.main_wallet().await.main_address().await; let tx_hashes = wallet - .sweep_multi( + .sweep_multi_destination( &monero_receive_pool.fill_empty_addresses(main_address), &monero_receive_pool.percentages(), ) diff --git a/swap/tests/alice_and_bob_refund_using_cancel_and_refund_command.rs b/swap/tests/alice_and_bob_refund_using_cancel_and_refund_command.rs index 04cabec4..568dc75e 100644 --- a/swap/tests/alice_and_bob_refund_using_cancel_and_refund_command.rs +++ b/swap/tests/alice_and_bob_refund_using_cancel_and_refund_command.rs @@ -11,7 +11,7 @@ use swap::{asb, cli}; #[tokio::test] async fn given_alice_and_bob_manually_refund_after_funds_locked_both_refund() { - harness::setup_test(FastCancelConfig, |mut ctx| async move { + harness::setup_test(FastCancelConfig, None, |mut ctx| async move { let (bob_swap, bob_join_handle) = ctx.bob_swap().await; let bob_swap_id = bob_swap.id; let bob_swap = tokio::spawn(bob::run_until(bob_swap, is_btc_locked)); diff --git a/swap/tests/alice_and_bob_refund_using_cancel_and_refund_command_timelock_not_expired.rs b/swap/tests/alice_and_bob_refund_using_cancel_and_refund_command_timelock_not_expired.rs index 1dd63cac..90a25b15 100644 --- a/swap/tests/alice_and_bob_refund_using_cancel_and_refund_command_timelock_not_expired.rs +++ b/swap/tests/alice_and_bob_refund_using_cancel_and_refund_command_timelock_not_expired.rs @@ -12,7 +12,7 @@ use swap::{asb, cli}; #[tokio::test] async fn given_alice_and_bob_manually_cancel_when_timelock_not_expired_errors() { - harness::setup_test(SlowCancelConfig, |mut ctx| async move { + harness::setup_test(SlowCancelConfig, None, |mut ctx| async move { let (bob_swap, bob_join_handle) = ctx.bob_swap().await; let swap_id = bob_swap.id; let bob_swap = tokio::spawn(bob::run_until(bob_swap, is_btc_locked)); diff --git a/swap/tests/alice_and_bob_refund_using_cancel_then_refund_command.rs b/swap/tests/alice_and_bob_refund_using_cancel_then_refund_command.rs index d1302ec6..a2c6b53e 100644 --- a/swap/tests/alice_and_bob_refund_using_cancel_then_refund_command.rs +++ b/swap/tests/alice_and_bob_refund_using_cancel_then_refund_command.rs @@ -11,7 +11,7 @@ use swap::{asb, cli}; #[tokio::test] async fn given_alice_and_bob_manually_cancel_and_refund_after_funds_locked_both_refund() { - harness::setup_test(FastCancelConfig, |mut ctx| async move { + harness::setup_test(FastCancelConfig, None, |mut ctx| async move { let (bob_swap, bob_join_handle) = ctx.bob_swap().await; let bob_swap_id = bob_swap.id; let bob_swap = tokio::spawn(bob::run_until(bob_swap, is_btc_locked)); diff --git a/swap/tests/alice_broken_wallet_rpc_after_started_btc_early_refund.rs b/swap/tests/alice_broken_wallet_rpc_after_started_btc_early_refund.rs index aefe0bea..11025165 100644 --- a/swap/tests/alice_broken_wallet_rpc_after_started_btc_early_refund.rs +++ b/swap/tests/alice_broken_wallet_rpc_after_started_btc_early_refund.rs @@ -10,7 +10,7 @@ use crate::harness::SlowCancelConfig; #[tokio::test] async fn alice_zero_xmr_refunds_bitcoin() { - harness::setup_test(SlowCancelConfig, |mut ctx| async move { + harness::setup_test(SlowCancelConfig, None, |mut ctx| async move { let (bob_swap, bob_handle) = ctx.bob_swap().await; let bob_swap = tokio::spawn(bob::run_until(bob_swap, is_btc_locked)); diff --git a/swap/tests/alice_empty_balance_after_started_btc_early_refund.rs b/swap/tests/alice_empty_balance_after_started_btc_early_refund.rs index bdaf6b1d..61d41241 100644 --- a/swap/tests/alice_empty_balance_after_started_btc_early_refund.rs +++ b/swap/tests/alice_empty_balance_after_started_btc_early_refund.rs @@ -10,7 +10,7 @@ use crate::harness::SlowCancelConfig; #[tokio::test] async fn alice_zero_xmr_refunds_bitcoin() { - harness::setup_test(SlowCancelConfig, |mut ctx| async move { + harness::setup_test(SlowCancelConfig, None, |mut ctx| async move { let (bob_swap, bob_handle) = ctx.bob_swap().await; let bob_swap = tokio::spawn(bob::run_until(bob_swap, is_btc_locked)); diff --git a/swap/tests/alice_manually_punishes_after_bob_dead.rs b/swap/tests/alice_manually_punishes_after_bob_dead.rs index aa747ec9..e3ca76f9 100644 --- a/swap/tests/alice_manually_punishes_after_bob_dead.rs +++ b/swap/tests/alice_manually_punishes_after_bob_dead.rs @@ -14,7 +14,7 @@ use swap::protocol::{alice, bob}; /// punish command. Bob then cooperates with Alice and redeems XMR with her key. #[tokio::test] async fn alice_manually_punishes_after_bob_dead() { - harness::setup_test(FastPunishConfig, |mut ctx| async move { + harness::setup_test(FastPunishConfig, None, |mut ctx| async move { let (bob_swap, bob_join_handle) = ctx.bob_swap().await; let bob_swap_id = bob_swap.id; let bob_swap = tokio::spawn(bob::run_until(bob_swap, is_btc_locked)); diff --git a/swap/tests/alice_manually_punishes_after_bob_dead_and_bob_cancels.rs b/swap/tests/alice_manually_punishes_after_bob_dead_and_bob_cancels.rs index 374d6160..6c5e8e87 100644 --- a/swap/tests/alice_manually_punishes_after_bob_dead_and_bob_cancels.rs +++ b/swap/tests/alice_manually_punishes_after_bob_dead_and_bob_cancels.rs @@ -14,7 +14,7 @@ use swap::protocol::{alice, bob}; /// punish command. Then Bob tries to refund. #[tokio::test] async fn alice_manually_punishes_after_bob_dead_and_bob_cancels() { - harness::setup_test(FastPunishConfig, |mut ctx| async move { + harness::setup_test(FastPunishConfig, None, |mut ctx| async move { let (bob_swap, bob_join_handle) = ctx.bob_swap().await; let bob_swap_id = bob_swap.id; let bob_swap = tokio::spawn(bob::run_until(bob_swap, is_btc_locked)); diff --git a/swap/tests/alice_manually_redeems_after_enc_sig_learned.rs b/swap/tests/alice_manually_redeems_after_enc_sig_learned.rs index dd79fc2f..ad2e97f5 100644 --- a/swap/tests/alice_manually_redeems_after_enc_sig_learned.rs +++ b/swap/tests/alice_manually_redeems_after_enc_sig_learned.rs @@ -11,7 +11,7 @@ use swap::protocol::{alice, bob}; /// after learning encsig from Bob #[tokio::test] async fn alice_manually_redeems_after_enc_sig_learned() { - harness::setup_test(SlowCancelConfig, |mut ctx| async move { + harness::setup_test(SlowCancelConfig, None, |mut ctx| async move { let (bob_swap, _) = ctx.bob_swap().await; let bob_swap = tokio::spawn(bob::run(bob_swap)); diff --git a/swap/tests/alice_punishes_after_restart_bob_dead.rs b/swap/tests/alice_punishes_after_restart_bob_dead.rs index 1bf140ee..a2b4db4f 100644 --- a/swap/tests/alice_punishes_after_restart_bob_dead.rs +++ b/swap/tests/alice_punishes_after_restart_bob_dead.rs @@ -12,7 +12,7 @@ use swap::protocol::{alice, bob}; /// the encsig and fail to refund or redeem. Alice cancels and punishes. Bob then cooperates with Alice and redeems XMR with her key. #[tokio::test] async fn alice_punishes_after_restart_if_bob_dead() { - harness::setup_test(FastPunishConfig, |mut ctx| async move { + harness::setup_test(FastPunishConfig, None, |mut ctx| async move { let (bob_swap, bob_join_handle) = ctx.bob_swap().await; let bob_swap_id = bob_swap.id; let bob_swap = tokio::spawn(bob::run_until(bob_swap, is_btc_locked)); diff --git a/swap/tests/alice_refunds_after_restart_bob_refunded.rs b/swap/tests/alice_refunds_after_restart_bob_refunded.rs index 1ec35e34..b79f52a2 100644 --- a/swap/tests/alice_refunds_after_restart_bob_refunded.rs +++ b/swap/tests/alice_refunds_after_restart_bob_refunded.rs @@ -10,7 +10,7 @@ use swap::protocol::{alice, bob}; /// Eventually Alice comes back online and refunds as well. #[tokio::test] async fn alice_refunds_after_restart_if_bob_already_refunded() { - harness::setup_test(FastCancelConfig, |mut ctx| async move { + harness::setup_test(FastCancelConfig, None, |mut ctx| async move { let (bob_swap, _) = ctx.bob_swap().await; let bob_swap = tokio::spawn(bob::run(bob_swap)); diff --git a/swap/tests/concurrent_bobs_after_xmr_lock_proof_sent.rs b/swap/tests/concurrent_bobs_after_xmr_lock_proof_sent.rs index 45e27819..e48683af 100644 --- a/swap/tests/concurrent_bobs_after_xmr_lock_proof_sent.rs +++ b/swap/tests/concurrent_bobs_after_xmr_lock_proof_sent.rs @@ -9,7 +9,7 @@ use swap::protocol::{alice, bob}; #[tokio::test] async fn concurrent_bobs_after_xmr_lock_proof_sent() { - harness::setup_test(SlowCancelConfig, |mut ctx| async move { + harness::setup_test(SlowCancelConfig, None, |mut ctx| async move { let (bob_swap_1, bob_join_handle_1) = ctx.bob_swap().await; let swap_id = bob_swap_1.id; diff --git a/swap/tests/concurrent_bobs_before_xmr_lock_proof_sent.rs b/swap/tests/concurrent_bobs_before_xmr_lock_proof_sent.rs index bf8adb06..775a1e2a 100644 --- a/swap/tests/concurrent_bobs_before_xmr_lock_proof_sent.rs +++ b/swap/tests/concurrent_bobs_before_xmr_lock_proof_sent.rs @@ -9,7 +9,7 @@ use swap::protocol::{alice, bob}; #[tokio::test] async fn concurrent_bobs_before_xmr_lock_proof_sent() { - harness::setup_test(SlowCancelConfig, |mut ctx| async move { + harness::setup_test(SlowCancelConfig, None, |mut ctx| async move { let (bob_swap_1, bob_join_handle_1) = ctx.bob_swap().await; let swap_id = bob_swap_1.id; diff --git a/swap/tests/ensure_same_swap_id.rs b/swap/tests/ensure_same_swap_id.rs index 28f09823..8deb1428 100644 --- a/swap/tests/ensure_same_swap_id.rs +++ b/swap/tests/ensure_same_swap_id.rs @@ -5,7 +5,7 @@ use swap::protocol::bob; #[tokio::test] async fn ensure_same_swap_id_for_alice_and_bob() { - harness::setup_test(SlowCancelConfig, |mut ctx| async move { + harness::setup_test(SlowCancelConfig, None, |mut ctx| async move { let (bob_swap, _) = ctx.bob_swap().await; let bob_swap_id = bob_swap.id; tokio::spawn(bob::run(bob_swap)); diff --git a/swap/tests/happy_path.rs b/swap/tests/happy_path.rs index 3814eb0e..da0ee150 100644 --- a/swap/tests/happy_path.rs +++ b/swap/tests/happy_path.rs @@ -7,7 +7,7 @@ use tokio::join; #[tokio::test] async fn happy_path() { - harness::setup_test(SlowCancelConfig, |mut ctx| async move { + harness::setup_test(SlowCancelConfig, None, |mut ctx| async move { let (bob_swap, _) = ctx.bob_swap().await; let bob_swap = tokio::spawn(bob::run(bob_swap)); diff --git a/swap/tests/happy_path_alice_developer_tip.rs b/swap/tests/happy_path_alice_developer_tip.rs new file mode 100644 index 00000000..4009cd67 --- /dev/null +++ b/swap/tests/happy_path_alice_developer_tip.rs @@ -0,0 +1,31 @@ +pub mod harness; + +use harness::SlowCancelConfig; +use rust_decimal::Decimal; +use swap::asb::FixedRate; +use swap::protocol::{alice, bob}; +use tokio::join; + +#[tokio::test] +async fn happy_path_alice_developer_tip() { + harness::setup_test( + SlowCancelConfig, + Some(Decimal::from_f32_retain(0.1).unwrap()), + |mut ctx| async move { + let (bob_swap, _) = ctx.bob_swap().await; + let bob_swap = tokio::spawn(bob::run(bob_swap)); + + let alice_swap = ctx.alice_next_swap().await; + let alice_swap = tokio::spawn(alice::run(alice_swap, FixedRate::default())); + + let (bob_state, alice_state) = join!(bob_swap, alice_swap); + + ctx.assert_alice_redeemed(alice_state??).await; + ctx.assert_bob_redeemed(bob_state??).await; + ctx.assert_alice_developer_tip_received().await; + + Ok(()) + }, + ) + .await; +} diff --git a/swap/tests/happy_path_bob_offline_while_alice_redeems_btc.rs b/swap/tests/happy_path_bob_offline_while_alice_redeems_btc.rs index c958c4f0..c02c7a81 100644 --- a/swap/tests/happy_path_bob_offline_while_alice_redeems_btc.rs +++ b/swap/tests/happy_path_bob_offline_while_alice_redeems_btc.rs @@ -8,7 +8,7 @@ use tokio::join; #[tokio::test] async fn given_bob_restarts_while_alice_redeems_btc() { - harness::setup_test(harness::SlowCancelConfig, |mut ctx| async move { + harness::setup_test(harness::SlowCancelConfig, None, |mut ctx| async move { let (bob_swap, bob_handle) = ctx.bob_swap().await; let swap_id = bob_swap.id; diff --git a/swap/tests/happy_path_restart_alice_after_xmr_locked.rs b/swap/tests/happy_path_restart_alice_after_xmr_locked.rs index 22a50757..1461947c 100644 --- a/swap/tests/happy_path_restart_alice_after_xmr_locked.rs +++ b/swap/tests/happy_path_restart_alice_after_xmr_locked.rs @@ -8,7 +8,7 @@ use swap::protocol::{alice, bob}; #[tokio::test] async fn given_alice_restarts_after_xmr_is_locked_resume_swap() { - harness::setup_test(SlowCancelConfig, |mut ctx| async move { + harness::setup_test(SlowCancelConfig, None, |mut ctx| async move { let (bob_swap, _) = ctx.bob_swap().await; let bob_swap = tokio::spawn(bob::run(bob_swap)); diff --git a/swap/tests/happy_path_restart_bob_after_xmr_locked.rs b/swap/tests/happy_path_restart_bob_after_xmr_locked.rs index f38c8953..07b44ebc 100644 --- a/swap/tests/happy_path_restart_bob_after_xmr_locked.rs +++ b/swap/tests/happy_path_restart_bob_after_xmr_locked.rs @@ -8,7 +8,7 @@ use swap::protocol::{alice, bob}; #[tokio::test] async fn given_bob_restarts_after_xmr_is_locked_resume_swap() { - harness::setup_test(SlowCancelConfig, |mut ctx| async move { + harness::setup_test(SlowCancelConfig, None, |mut ctx| async move { let (bob_swap, bob_join_handle) = ctx.bob_swap().await; let bob_swap_id = bob_swap.id; let bob_swap = tokio::spawn(bob::run_until(bob_swap, is_xmr_locked)); diff --git a/swap/tests/happy_path_restart_bob_before_xmr_locked.rs b/swap/tests/happy_path_restart_bob_before_xmr_locked.rs index f38c8953..07b44ebc 100644 --- a/swap/tests/happy_path_restart_bob_before_xmr_locked.rs +++ b/swap/tests/happy_path_restart_bob_before_xmr_locked.rs @@ -8,7 +8,7 @@ use swap::protocol::{alice, bob}; #[tokio::test] async fn given_bob_restarts_after_xmr_is_locked_resume_swap() { - harness::setup_test(SlowCancelConfig, |mut ctx| async move { + harness::setup_test(SlowCancelConfig, None, |mut ctx| async move { let (bob_swap, bob_join_handle) = ctx.bob_swap().await; let bob_swap_id = bob_swap.id; let bob_swap = tokio::spawn(bob::run_until(bob_swap, is_xmr_locked)); diff --git a/swap/tests/harness/mod.rs b/swap/tests/harness/mod.rs index 2208bad6..a45262b8 100644 --- a/swap/tests/harness/mod.rs +++ b/swap/tests/harness/mod.rs @@ -10,6 +10,7 @@ use libp2p::core::Multiaddr; use libp2p::PeerId; use monero_harness::{image, Monero}; use monero_sys::Daemon; +use rust_decimal::Decimal; use std::cmp::Ordering; use std::fmt; use std::path::PathBuf; @@ -25,7 +26,7 @@ use swap::monero::wallet::no_listener; use swap::monero::Wallets; use swap::network::rendezvous::XmrBtcNamespace; use swap::network::swarm; -use swap::protocol::alice::{AliceState, Swap}; +use swap::protocol::alice::{AliceState, Swap, TipConfig}; use swap::protocol::bob::BobState; use swap::protocol::{alice, bob, Database}; use swap::seed::Seed; @@ -43,7 +44,7 @@ use tokio::time::{interval, timeout}; use url::Url; use uuid::Uuid; -pub async fn setup_test(_config: C, testfn: T) +pub async fn setup_test(_config: C, developer_tip_ratio: Option, testfn: T) where T: Fn(TestContext) -> F, F: Future>, @@ -67,12 +68,41 @@ where let btc_amount = bitcoin::Amount::from_sat(1_000_000); let xmr_amount = monero::Amount::from_monero(btc_amount.to_btc() / FixedRate::RATE).unwrap(); + let electrs_rpc_port = containers.electrs.get_host_port_ipv4(electrs::RPC_PORT); + + let developer_seed = Seed::random().unwrap(); + let developer_starting_balances = + StartingBalances::new(bitcoin::Amount::ZERO, monero::Amount::ZERO, None); + let developer_tip_monero_dir = TempDir::new() + .unwrap() + .path() + .join("developer_tip-monero-wallets"); + let (_, developer_tip_monero_wallet) = init_test_wallets( + "developer_tip", + containers.bitcoind_url.clone(), + &monero, + &containers._monerod_container, + developer_tip_monero_dir, + developer_starting_balances.clone(), + electrs_rpc_port, + &developer_seed, + env_config, + ) + .await; + let developer_tip_monero_wallet_address = developer_tip_monero_wallet + .main_wallet() + .await + .main_address() + .await + .into(); + + let developer_tip = TipConfig { + ratio: developer_tip_ratio.unwrap_or(Decimal::ZERO), + address: developer_tip_monero_wallet_address, + }; let alice_starting_balances = StartingBalances::new(bitcoin::Amount::ZERO, xmr_amount, Some(10)); - - let electrs_rpc_port = containers.electrs.get_host_port_ipv4(electrs::RPC_PORT); - let alice_seed = Seed::random().unwrap(); let alice_db_path = NamedTempFile::new().unwrap().path().to_path_buf(); let alice_monero_dir = TempDir::new().unwrap().path().join("alice-monero-wallets"); @@ -101,6 +131,7 @@ where env_config, alice_bitcoin_wallet.clone(), alice_monero_wallet.clone(), + developer_tip.clone(), ) .await; @@ -109,7 +140,7 @@ where let bob_monero_dir = TempDir::new().unwrap().path().join("bob-monero-wallets"); let (bob_bitcoin_wallet, bob_monero_wallet) = init_test_wallets( MONERO_WALLET_NAME_BOB, - containers.bitcoind_url, + containers.bitcoind_url.clone(), &monero, &containers._monerod_container, bob_monero_dir, @@ -148,6 +179,8 @@ where bob_starting_balances, bob_bitcoin_wallet, bob_monero_wallet, + developer_tip_monero_wallet, + developer_tip, monerod_container_id: containers._monerod_container.id().to_string(), }; @@ -238,6 +271,7 @@ async fn start_alice( env_config: Config, bitcoin_wallet: Arc, monero_wallet: Arc, + developer_tip: TipConfig, ) -> (AliceApplicationHandle, Receiver) { if let Some(parent_dir) = db_path.parent() { ensure_directory_exists(parent_dir).unwrap(); @@ -282,6 +316,7 @@ async fn start_alice( min_buy, max_buy, None, + developer_tip, ) .unwrap(); @@ -594,6 +629,7 @@ pub struct TestContext { btc_amount: bitcoin::Amount, xmr_amount: monero::Amount, + developer_tip: TipConfig, alice_seed: Seed, alice_db_path: PathBuf, @@ -610,6 +646,8 @@ pub struct TestContext { bob_bitcoin_wallet: Arc, bob_monero_wallet: Arc, + developer_tip_monero_wallet: Arc, + // Store the container ID as String instead of reference monerod_container_id: String, } @@ -636,6 +674,7 @@ impl TestContext { self.env_config, self.alice_bitcoin_wallet.clone(), self.alice_monero_wallet.clone(), + self.developer_tip.clone(), ) .await; @@ -716,6 +755,16 @@ impl TestContext { .unwrap(); } + pub async fn assert_alice_developer_tip_received(&self) { + assert_eventual_balance( + &*self.developer_tip_monero_wallet.main_wallet().await, + Ordering::Equal, + self.developer_tip_wallet_received_xmr_balance(), + ) + .await + .unwrap(); + } + pub async fn assert_alice_punished(&self, state: AliceState) { let (cancel_fee, punish_fee) = match state { AliceState::BtcPunished { state3, .. } => (state3.tx_cancel_fee, state3.tx_punish_fee), @@ -854,6 +903,25 @@ impl TestContext { self.alice_starting_balances.xmr - self.xmr_amount } + fn developer_tip_wallet_received_xmr_balance(&self) -> monero::Amount { + use rust_decimal::prelude::ToPrimitive; + + let effective_tip_amount = monero::Amount::from_piconero( + self.developer_tip + .ratio + .saturating_mul(self.xmr_amount.as_piconero_decimal()) + .to_u64() + .unwrap(), + ); + + // This is defined in `swap/src/protocol/alice/swap.rs` in `build_transfer_destinations` + if effective_tip_amount.as_piconero() < 30_000_000 { + return monero::Amount::ZERO; + } + + effective_tip_amount + } + fn alice_refunded_btc_balance(&self) -> bitcoin::Amount { self.alice_starting_balances.btc } diff --git a/swap/tests/punish.rs b/swap/tests/punish.rs index 79d93d9d..4829c7eb 100644 --- a/swap/tests/punish.rs +++ b/swap/tests/punish.rs @@ -10,7 +10,7 @@ use swap::protocol::{alice, bob}; /// the encsig and fail to refund or redeem. Alice punishes. Bob then cooperates with Alice and redeems XMR with her key. #[tokio::test] async fn alice_punishes_if_bob_never_acts_after_fund() { - harness::setup_test(FastPunishConfig, |mut ctx| async move { + harness::setup_test(FastPunishConfig, None, |mut ctx| async move { let (bob_swap, bob_join_handle) = ctx.bob_swap().await; let bob_swap_id = bob_swap.id; let bob_swap = tokio::spawn(bob::run_until(bob_swap, is_btc_locked));