diff --git a/mm2src/mm2_main/src/lp_ordermatch.rs b/mm2src/mm2_main/src/lp_ordermatch.rs index f874eac21b..187c250a2b 100644 --- a/mm2src/mm2_main/src/lp_ordermatch.rs +++ b/mm2src/mm2_main/src/lp_ordermatch.rs @@ -1666,7 +1666,22 @@ impl TakerOrder { || self.base_orderbook_ticker.as_ref() == Some(&reserved.rel)) && (self.request.rel == reserved.base || self.rel_orderbook_ticker.as_ref() == Some(&reserved.base)); - if match_ticker && my_base_amount == other_rel_amount && my_rel_amount <= other_base_amount { + + // Reject if any common conditions are unmet + if !match_ticker || my_base_amount != other_rel_amount { + return MatchReservedResult::NotMatched; + } + + let other_base_amount = if self.request.swap_version.is_legacy() || reserved.swap_version.is_legacy() { + other_base_amount.clone() + } else { + let premium = &reserved.premium.clone().unwrap_or_default(); + let other_price = &(my_base_amount - premium) / other_base_amount; + // In match_with_request function, we allowed maker to send fewer coins for taker sell action + other_base_amount + &(premium / &other_price) + }; + + if my_rel_amount <= &other_base_amount { MatchReservedResult::Matched } else { MatchReservedResult::NotMatched @@ -1754,6 +1769,9 @@ pub struct MakerOrder { #[cfg(feature = "ibc-routing-for-swaps")] order_metadata: OrderMetadata, timeout_in_minutes: Option, + /// Fixed extra amount of maker rel coin, requested by the maker and paid by the taker + #[serde(default, skip_serializing_if = "Option::is_none")] + premium: Option, } pub struct MakerOrderBuilder<'a> { @@ -1770,6 +1788,7 @@ pub struct MakerOrderBuilder<'a> { #[cfg(feature = "ibc-routing-for-swaps")] order_metadata: OrderMetadata, timeout_in_minutes: Option, + premium: Option, } /// Contains extra and/or optional metadata (e.g., protocol-specific information) that can @@ -1933,6 +1952,7 @@ impl<'a> MakerOrderBuilder<'a> { #[cfg(feature = "ibc-routing-for-swaps")] order_metadata: OrderMetadata::default(), timeout_in_minutes: None, + premium: Default::default(), } } @@ -1979,6 +1999,11 @@ impl<'a> MakerOrderBuilder<'a> { /// In the future alls users will be using TPU V2 by default without "use_trading_proto_v2" configuration. pub fn set_legacy_swap_v(&mut self) { self.swap_version = legacy_swap_version() } + pub fn with_premium(mut self, premium: Option) -> Self { + self.premium = premium; + self + } + /// Build MakerOrder #[allow(clippy::result_large_err)] pub fn build(self) -> Result { @@ -2039,6 +2064,7 @@ impl<'a> MakerOrderBuilder<'a> { #[cfg(feature = "ibc-routing-for-swaps")] order_metadata: self.order_metadata, timeout_in_minutes: self.timeout_in_minutes, + premium: self.premium, }) } @@ -2067,6 +2093,7 @@ impl<'a> MakerOrderBuilder<'a> { #[cfg(feature = "ibc-routing-for-swaps")] order_metadata: self.order_metadata, timeout_in_minutes: self.timeout_in_minutes, + premium: Default::default(), } } } @@ -2105,20 +2132,42 @@ impl MakerOrder { return OrderMatchResult::NotMatched; } + let is_legacy = self.swap_version.is_legacy() || taker.swap_version.is_legacy(); + let premium = self.premium.clone().unwrap_or_default(); + match taker.action { TakerAction::Buy => { let ticker_match = (self.base == taker.base || self.base_orderbook_ticker.as_ref() == Some(&taker.base)) && (self.rel == taker.rel || self.rel_orderbook_ticker.as_ref() == Some(&taker.rel)); + // taker_base_amount: the amount taker desires to buy (input.volume from SellBuyRequest) + // taker_rel_amount: the amount taker is willing to pay (input.volume * input.price, where input is SellBuyRequest) + // taker_price: the effective price offered by the taker let taker_price = taker_rel_amount / taker_base_amount; - if ticker_match - && taker_base_amount <= &self.available_amount() - && taker_base_amount >= &self.min_base_vol - && taker_price >= self.price - { - OrderMatchResult::Matched((taker_base_amount.clone(), taker_base_amount * &self.price)) + + // Reject if any basic conditions are not satisfied + let base_amount_exceeds = taker_base_amount > &self.available_amount(); + let below_min_volume = taker_base_amount < &self.min_base_vol; + let price_too_low = taker_price < self.price; + + if !ticker_match || base_amount_exceeds || below_min_volume || price_too_low { + return OrderMatchResult::NotMatched; + } + + let result_rel_amount = taker_base_amount * &self.price; + + if is_legacy { + // Legacy mode: use maker's price to calculate rel amount + OrderMatchResult::Matched((taker_base_amount.clone(), result_rel_amount)) } else { - OrderMatchResult::NotMatched + // taker_rel_amount must cover the premium requested by maker + let required_rel_amount = result_rel_amount + premium; + if taker_rel_amount >= &required_rel_amount { + // TPU mode: treat buy as a limit order using taker's base amount and required_rel_amount + OrderMatchResult::Matched((taker_base_amount.clone(), required_rel_amount)) + } else { + OrderMatchResult::NotMatched + } } }, TakerAction::Sell => { @@ -2126,10 +2175,28 @@ impl MakerOrder { && (self.rel == taker.base || self.rel_orderbook_ticker.as_ref() == Some(&taker.base)); let taker_price = taker_base_amount / taker_rel_amount; - // Calculate the resulting base amount using the Maker's price instead of the Taker's. - let matched_base_amount = taker_base_amount / &self.price; - let matched_rel_amount = taker_base_amount.clone(); + // Determine the matched amounts depending on version + let (matched_base_amount, matched_rel_amount) = if is_legacy { + // Legacy: calculate the resulting base amount using the Maker's price instead of the Taker's. + (taker_base_amount / &self.price, taker_base_amount.clone()) + } else { + // this check prevents division by zero + if taker_base_amount <= &premium { + return OrderMatchResult::NotMatched; + } + // Calculate the resulting base amount using the maker's price instead of the taker's. + // For TPU, in the taker sell action the maker wants to "take" an additional portion of rel as a premium, + // so we reduce the base amount the maker gives by (premium / price). + let result_base_amount = &(taker_base_amount - &premium) / &self.price; + let real_price_for_taker = taker_base_amount / &result_base_amount; + // Ensure the taker doesn't end up paying a higher price (including premium) + if real_price_for_taker > taker_price { + return OrderMatchResult::NotMatched; + } + (result_base_amount, taker_base_amount.clone()) + }; + // Match if all common conditions are met if ticker_match && matched_base_amount <= self.available_amount() && matched_base_amount >= self.min_base_vol @@ -2202,6 +2269,7 @@ impl From for MakerOrder { #[cfg(feature = "ibc-routing-for-swaps")] order_metadata: taker_order.request.order_metadata, timeout_in_minutes: None, + premium: Default::default(), }, // The "buy" taker order is recreated with reversed pair as Maker order is always considered as "sell" TakerAction::Buy => { @@ -2229,6 +2297,7 @@ impl From for MakerOrder { #[cfg(feature = "ibc-routing-for-swaps")] order_metadata: taker_order.request.order_metadata, timeout_in_minutes: None, + premium: Default::default(), } }, } @@ -2283,6 +2352,11 @@ pub struct MakerReserved { pub swap_version: SwapVersion, #[cfg(feature = "ibc-routing-for-swaps")] order_metadata: OrderMetadata, + /// Note: `std::default::Default` is not implemented for `num_rational::Ratio` + /// in the [new_protocol::MakerReserved] structure. As a result, we use `Option` there. + /// It is preferable to follow this same approach in the current structure for consistency. + #[serde(default, skip_serializing_if = "Option::is_none")] + premium: Option, } impl MakerReserved { @@ -2314,6 +2388,7 @@ impl MakerReserved { // TODO: Support the new protocol types. #[cfg(feature = "ibc-routing-for-swaps")] order_metadata: OrderMetadata::default(), + premium: message.premium.map(MmNumber::from), } } } @@ -2331,6 +2406,7 @@ impl From for new_protocol::OrdermatchMessage { base_protocol_info: maker_reserved.base_protocol_info, rel_protocol_info: maker_reserved.rel_protocol_info, swap_version: maker_reserved.swap_version, + premium: maker_reserved.premium.map(|p| p.to_ratio()), }) } } @@ -3071,6 +3147,7 @@ struct StateMachineParams<'a> { locktime: &'a u64, maker_amount: &'a MmNumber, taker_amount: &'a MmNumber, + taker_premium: &'a MmNumber, } #[allow(unreachable_code, unused_variables)] // TODO: remove with `ibc-routing-for-swaps` feature removal. @@ -3196,6 +3273,7 @@ fn lp_connect_start_bob(ctx: MmArc, maker_match: MakerMatch, maker_order: MakerO locktime: &lock_time, maker_amount: &maker_amount, taker_amount: &taker_amount, + taker_premium: &maker_order.premium.clone().unwrap_or_default(), }; let taker_p2p_pubkey = match taker_p2p_pubkey { PublicKey::Secp256k1(pubkey) => pubkey.into(), @@ -3298,7 +3376,7 @@ async fn start_maker_swap_state_machine< secret: *secret, taker_coin: taker_coin.clone(), taker_volume: params.taker_amount.clone(), - taker_premium: Default::default(), + taker_premium: params.taker_premium.clone(), conf_settings: *params.my_conf_settings, p2p_topic: swap_v2_topic(params.uuid), uuid: *params.uuid, @@ -3442,6 +3520,7 @@ fn lp_connected_alice(ctx: MmArc, taker_order: TakerOrder, taker_match: TakerMat locktime: &locktime, maker_amount: &maker_amount, taker_amount: &taker_amount, + taker_premium: &taker_match.reserved.premium.unwrap_or_default(), }; let maker_p2p_pubkey = match maker_p2p_pubkey { PublicKey::Secp256k1(pubkey) => pubkey.into(), @@ -3551,7 +3630,7 @@ async fn start_taker_swap_state_machine< maker_volume: params.maker_amount.clone(), taker_coin: taker_coin.clone(), taker_volume: params.taker_amount.clone(), - taker_premium: Default::default(), + taker_premium: params.taker_premium.clone(), secret_hash_algo: *params.secret_hash_algo, conf_settings: *params.my_conf_settings, p2p_topic: swap_v2_topic(params.uuid), @@ -4098,6 +4177,7 @@ async fn process_taker_request(ctx: MmArc, from_pubkey: H256Json, taker_request: swap_version: order.swap_version, #[cfg(feature = "ibc-routing-for-swaps")] order_metadata: order.order_metadata.clone(), + premium: order.premium.clone(), }; let topic = order.orderbook_topic(); log::debug!("Request matched sending reserved {:?}", reserved); @@ -4834,6 +4914,8 @@ pub struct SetPriceReq { #[serde(default = "get_true")] save_in_history: bool, timeout_in_minutes: Option, + #[serde(default)] + premium: Option, } #[derive(Deserialize)] @@ -5097,7 +5179,8 @@ pub async fn create_maker_order(ctx: &MmArc, req: SetPriceReq) -> Result Result>, #[serde(default, skip_serializing_if = "SwapVersion::is_legacy")] pub swap_version: SwapVersion, + /// Note: `std::default::Default` is not implemented for `num_rational::Ratio`, that's why it's preferable to use Optional + #[serde(default, skip_serializing_if = "Option::is_none")] + pub premium: Option, } #[derive(Clone, Debug, Deserialize, Serialize)] diff --git a/mm2src/mm2_main/src/lp_ordermatch/simple_market_maker.rs b/mm2src/mm2_main/src/lp_ordermatch/simple_market_maker.rs index 5baceef2f2..19c36911a1 100644 --- a/mm2src/mm2_main/src/lp_ordermatch/simple_market_maker.rs +++ b/mm2src/mm2_main/src/lp_ordermatch/simple_market_maker.rs @@ -606,6 +606,7 @@ async fn create_single_order( rel_nota: cfg.rel_nota, save_in_history: true, timeout_in_minutes: None, + premium: None, }; let resp = create_maker_order(&ctx, req) diff --git a/mm2src/mm2_main/src/lp_swap/taker_swap_v2.rs b/mm2src/mm2_main/src/lp_swap/taker_swap_v2.rs index b3ee43a6a1..f8373d806d 100644 --- a/mm2src/mm2_main/src/lp_swap/taker_swap_v2.rs +++ b/mm2src/mm2_main/src/lp_swap/taker_swap_v2.rs @@ -875,7 +875,8 @@ impl mpsc::Receiver { #[cfg(feature = "ibc-routing-for-swaps")] order_metadata: OrderMetadata::default(), timeout_in_minutes: None, + premium: Default::default(), }, None, ); @@ -1139,6 +1161,7 @@ fn prepare_for_cancel_by(ctx: &MmArc) -> mpsc::Receiver { #[cfg(feature = "ibc-routing-for-swaps")] order_metadata: OrderMetadata::default(), timeout_in_minutes: None, + premium: Default::default(), }, None, ); @@ -1165,6 +1188,7 @@ fn prepare_for_cancel_by(ctx: &MmArc) -> mpsc::Receiver { #[cfg(feature = "ibc-routing-for-swaps")] order_metadata: OrderMetadata::default(), timeout_in_minutes: None, + premium: Default::default(), }, None, ); @@ -1322,6 +1346,7 @@ fn test_taker_order_match_by() { swap_version: SwapVersion::default(), #[cfg(feature = "ibc-routing-for-swaps")] order_metadata: OrderMetadata::default(), + premium: Default::default(), }; assert_eq!(MatchReservedResult::NotMatched, order.match_reserved(&reserved)); @@ -1366,6 +1391,7 @@ fn test_maker_order_was_updated() { #[cfg(feature = "ibc-routing-for-swaps")] order_metadata: OrderMetadata::default(), timeout_in_minutes: None, + premium: Default::default(), }; let mut update_msg = MakerOrderUpdated::new(maker_order.uuid); update_msg.with_new_price(BigRational::from_integer(2.into())); @@ -3379,6 +3405,7 @@ fn test_maker_order_balance_loops() { #[cfg(feature = "ibc-routing-for-swaps")] order_metadata: OrderMetadata::default(), timeout_in_minutes: None, + premium: Default::default(), }; let morty_order = MakerOrder { @@ -3402,6 +3429,7 @@ fn test_maker_order_balance_loops() { #[cfg(feature = "ibc-routing-for-swaps")] order_metadata: OrderMetadata::default(), timeout_in_minutes: None, + premium: Default::default(), }; assert!(!maker_orders_ctx.balance_loop_exists(rick_ticker)); @@ -3438,6 +3466,7 @@ fn test_maker_order_balance_loops() { #[cfg(feature = "ibc-routing-for-swaps")] order_metadata: OrderMetadata::default(), timeout_in_minutes: None, + premium: Default::default(), }; maker_orders_ctx.add_order(ctx.weak(), rick_order_2.clone(), None); diff --git a/mm2src/mm2_main/src/wasm_tests.rs b/mm2src/mm2_main/src/wasm_tests.rs index a8768b675b..33533d6ce7 100644 --- a/mm2src/mm2_main/src/wasm_tests.rs +++ b/mm2src/mm2_main/src/wasm_tests.rs @@ -10,8 +10,8 @@ use mm2_test_helpers::electrums::{doc_electrums, marty_electrums}; use mm2_test_helpers::for_tests::{check_recent_swaps, delete_wallet, enable_electrum_json, enable_utxo_v2_electrum, enable_z_coin_light, get_wallet_names, morty_conf, pirate_conf, rick_conf, start_swaps, test_qrc20_history_impl, wait_for_swaps_finish_and_check_status, - MarketMakerIt, Mm2InitPrivKeyPolicy, Mm2TestConf, Mm2TestConfForSwap, ARRR, MORTY, - PIRATE_ELECTRUMS, PIRATE_LIGHTWALLETD_URLS, RICK}; + MarketMakerIt, Mm2InitPrivKeyPolicy, Mm2TestConf, Mm2TestConfForSwap, TakerMethod, + ARRR, MORTY, PIRATE_ELECTRUMS, PIRATE_LIGHTWALLETD_URLS, RICK}; use mm2_test_helpers::get_passphrase; use mm2_test_helpers::structs::{Bip44Chain, EnableCoinBalance, HDAccountAddressId}; use serde_json::{json, Value as Json}; @@ -64,7 +64,17 @@ async fn test_mm2_stops_impl(pairs: &[(&'static str, &'static str)], maker_price let rc = enable_electrum_json(&mm_alice, MORTY, true, marty_electrums()).await; log!("enable MORTY (bob): {:?}", rc); - start_swaps(&mut mm_bob, &mut mm_alice, pairs, maker_price, taker_price, volume).await; + start_swaps( + &mut mm_bob, + &mut mm_alice, + pairs, + maker_price, + taker_price, + volume, + None, + TakerMethod::Buy, + ) + .await; mm_alice .stop_and_wait_for_ctx_is_dropped(STOP_TIMEOUT_MS) @@ -111,7 +121,17 @@ async fn trade_base_rel_electrum( let rc = enable_utxo_v2_electrum(&mm_alice, "MORTY", marty_electrums(), alice_path_to_address, 60, None).await; log!("enable MORTY (alice): {:?}", rc); - let uuids = start_swaps(&mut mm_bob, &mut mm_alice, pairs, maker_price, taker_price, volume).await; + let uuids = start_swaps( + &mut mm_bob, + &mut mm_alice, + pairs, + maker_price, + taker_price, + volume, + None, + TakerMethod::Buy, + ) + .await; wait_for_swaps_finish_and_check_status(&mut mm_bob, &mut mm_alice, &uuids, volume, maker_price).await; diff --git a/mm2src/mm2_main/tests/docker_tests/docker_tests_inner.rs b/mm2src/mm2_main/tests/docker_tests/docker_tests_inner.rs index aca3f247f6..1b182a43bb 100644 --- a/mm2src/mm2_main/tests/docker_tests/docker_tests_inner.rs +++ b/mm2src/mm2_main/tests/docker_tests/docker_tests_inner.rs @@ -23,7 +23,7 @@ use mm2_test_helpers::for_tests::{check_my_swap_status_amounts, disable_coin, di enable_eth_with_tokens_v2, erc20_dev_conf, eth_dev_conf, get_locked_amount, kmd_conf, max_maker_vol, mm_dump, mycoin1_conf, mycoin_conf, set_price, start_swaps, wait_for_swap_contract_negotiation, wait_for_swap_negotiation_failure, - MarketMakerIt, Mm2TestConf, DEFAULT_RPC_PASSWORD}; + MarketMakerIt, Mm2TestConf, TakerMethod, DEFAULT_RPC_PASSWORD}; use mm2_test_helpers::{get_passphrase, structs::*}; use serde_json::Value as Json; use std::collections::{HashMap, HashSet}; @@ -3586,6 +3586,8 @@ fn test_locked_amount() { 1., 1., 777., + None, + TakerMethod::Buy, )); let locked_bob = block_on(get_locked_amount(&mm_bob, "MYCOIN")); @@ -3816,6 +3818,8 @@ fn test_eth_swap_contract_addr_negotiation_same_fallback() { 1., 1., 0.0001, + None, + TakerMethod::Buy, )); // give few seconds for swap statuses to be saved @@ -3909,6 +3913,8 @@ fn test_eth_swap_negotiation_fails_maker_no_fallback() { 1., 1., 0.0001, + None, + TakerMethod::Buy, )); // give few seconds for swap statuses to be saved diff --git a/mm2src/mm2_main/tests/docker_tests/eth_docker_tests.rs b/mm2src/mm2_main/tests/docker_tests/eth_docker_tests.rs index 81fe6d4a10..a669752484 100644 --- a/mm2src/mm2_main/tests/docker_tests/eth_docker_tests.rs +++ b/mm2src/mm2_main/tests/docker_tests/eth_docker_tests.rs @@ -35,7 +35,7 @@ use mm2_test_helpers::for_tests::{account_balance, active_swaps, coins_needed_fo enable_erc20_token_v2, enable_eth_coin_v2, enable_eth_with_tokens_v2, erc20_dev_conf, eth1_dev_conf, eth_dev_conf, get_locked_amount, get_new_address, get_token_info, mm_dump, my_swap_status, nft_dev_conf, start_swaps, MarketMakerIt, - Mm2TestConf, SwapV2TestContracts, TestNode, ETH_SEPOLIA_CHAIN_ID}; + Mm2TestConf, SwapV2TestContracts, TakerMethod, TestNode, ETH_SEPOLIA_CHAIN_ID}; #[cfg(any(feature = "sepolia-maker-swap-v2-tests", feature = "sepolia-taker-swap-v2-tests"))] use mm2_test_helpers::for_tests::{eth_sepolia_conf, sepolia_erc20_dev_conf}; use mm2_test_helpers::structs::{Bip44Chain, EnableCoinBalanceMap, EthWithTokensActivationResult, HDAccountAddressId, @@ -2825,7 +2825,16 @@ fn test_v2_eth_eth_kickstart() { enable_coins(&mm_bob, &[ETH, ETH1]); enable_coins(&mm_alice, &[ETH, ETH1]); - let uuids = block_on(start_swaps(&mut mm_bob, &mut mm_alice, &[(ETH, ETH1)], 1.0, 1.0, 77.)); + let uuids = block_on(start_swaps( + &mut mm_bob, + &mut mm_alice, + &[(ETH, ETH1)], + 1.0, + 1.0, + 77., + None, + TakerMethod::Buy, + )); log!("{:?}", uuids); let parsed_uuids: Vec = uuids.iter().map(|u| u.parse().unwrap()).collect(); diff --git a/mm2src/mm2_main/tests/docker_tests/swap_proto_v2_tests.rs b/mm2src/mm2_main/tests/docker_tests/swap_proto_v2_tests.rs index 7816d62028..1919ef3c5b 100644 --- a/mm2src/mm2_main/tests/docker_tests/swap_proto_v2_tests.rs +++ b/mm2src/mm2_main/tests/docker_tests/swap_proto_v2_tests.rs @@ -13,7 +13,7 @@ use mm2_number::MmNumber; use mm2_test_helpers::for_tests::{active_swaps, check_recent_swaps, coins_needed_for_kickstart, disable_coin, disable_coin_err, enable_native, get_locked_amount, mm_dump, my_swap_status, mycoin1_conf, mycoin_conf, start_swaps, wait_for_swap_finished, - wait_for_swap_status, MarketMakerIt, Mm2TestConf}; + wait_for_swap_status, MarketMakerIt, Mm2TestConf, TakerMethod}; use mm2_test_helpers::structs::MmNumberMultiRepr; use script::{Builder, Opcode}; use serialization::serialize; @@ -621,21 +621,27 @@ fn send_and_refund_maker_payment_taker_secret() { log!("{:02x}", refund_tx.tx_hash_as_bytes()); } -#[test] -fn test_v2_swap_utxo_utxo() { test_v2_swap_utxo_utxo_impl(); } - -// test a swap when taker is burn pubkey (no dex fee should be paid) -#[test] -fn test_v2_swap_utxo_utxo_burnkey_as_alice() { - SET_BURN_PUBKEY_TO_ALICE.set(true); - test_v2_swap_utxo_utxo_impl(); +/// A struct capturing parameters to run an UTXO-UTXO swap v2 test +struct UtxoSwapV2TestParams { + maker_price: f64, + taker_price: f64, + volume: f64, + premium: Option, + taker_method: TakerMethod, + /// Expected locked amount on Bob’s side before maker payment is sent + expected_bob_locked_amount: &'static str, + /// Expected locked amount on Alice’s side before taker funding is sent + expected_alice_locked_amount: &'static str, } -fn test_v2_swap_utxo_utxo_impl() { +/// This function unifies the logic for testing TPU using UTXO pair of coins +fn test_v2_swap_utxo_utxo_impl_common(params: UtxoSwapV2TestParams) { + // 1) Generate Bob and Alice UTXO coins let (_ctx, _, bob_priv_key) = generate_utxo_coin_with_random_privkey(MYCOIN, 1000.into()); let (_ctx, _, alice_priv_key) = generate_utxo_coin_with_random_privkey(MYCOIN1, 1000.into()); let coins = json!([mycoin_conf(1000), mycoin1_conf(1000)]); + // 2) Possibly push the burn pubkey into envs let alice_pubkey_str = hex::encode( key_pair_from_secret(&alice_priv_key) .expect("valid test key pair") @@ -647,6 +653,7 @@ fn test_v2_swap_utxo_utxo_impl() { envs.push(("TEST_BURN_ADDR_RAW_PUBKEY", alice_pubkey_str.as_str())); } + // 3) Start Bob (seednode) and Alice (light node) let bob_conf = Mm2TestConf::seednode_trade_v2(&format!("0x{}", hex::encode(bob_priv_key)), &coins); let mut mm_bob = block_on(MarketMakerIt::start_with_envs( bob_conf.conf, @@ -672,92 +679,148 @@ fn test_v2_swap_utxo_utxo_impl() { let (_alice_dump_log, _alice_dump_dashboard) = mm_dump(&mm_alice.log_path); log!("Alice log path: {}", mm_alice.log_path.display()); + // 4) Enable coins log!("{:?}", block_on(enable_native(&mm_bob, MYCOIN, &[], None))); log!("{:?}", block_on(enable_native(&mm_bob, MYCOIN1, &[], None))); log!("{:?}", block_on(enable_native(&mm_alice, MYCOIN, &[], None))); log!("{:?}", block_on(enable_native(&mm_alice, MYCOIN1, &[], None))); + // 5) Start swaps let uuids = block_on(start_swaps( &mut mm_bob, &mut mm_alice, &[(MYCOIN, MYCOIN1)], - 1.0, - 1.0, - 777., + params.maker_price, + params.taker_price, + params.volume, + params.premium, + params.taker_method, )); - log!("{:?}", uuids); + log!("Started swaps with uuids: {:?}", uuids); + // 6) Validate active swaps let parsed_uuids: Vec = uuids.iter().map(|u| u.parse().unwrap()).collect(); - let active_swaps_bob = block_on(active_swaps(&mm_bob)); assert_eq!(active_swaps_bob.uuids, parsed_uuids); let active_swaps_alice = block_on(active_swaps(&mm_alice)); assert_eq!(active_swaps_alice.uuids, parsed_uuids); - // disabling coins used in active swaps must not work - let err = block_on(disable_coin_err(&mm_bob, MYCOIN, false)); - assert_eq!(err.active_swaps, parsed_uuids); - - let err = block_on(disable_coin_err(&mm_bob, MYCOIN1, false)); - assert_eq!(err.active_swaps, parsed_uuids); + // 7) Disabling coins that are in active swaps must not work + for coin in [MYCOIN, MYCOIN1] { + let err = block_on(disable_coin_err(&mm_bob, MYCOIN, false)); + assert_eq!(err.active_swaps, parsed_uuids); - let err = block_on(disable_coin_err(&mm_alice, MYCOIN, false)); - assert_eq!(err.active_swaps, parsed_uuids); - - let err = block_on(disable_coin_err(&mm_alice, MYCOIN1, false)); - assert_eq!(err.active_swaps, parsed_uuids); + let err = block_on(disable_coin_err(&mm_alice, coin, false)); + assert_eq!(err.active_swaps, parsed_uuids); + } - // coins must be virtually locked until swap transactions are sent + // 8) Coins must be virtually locked until swap transactions are sent + // Bob’s side let locked_bob = block_on(get_locked_amount(&mm_bob, MYCOIN)); assert_eq!(locked_bob.coin, MYCOIN); - let expected: MmNumberMultiRepr = MmNumber::from("777.00001").into(); - assert_eq!(locked_bob.locked_amount, expected); + let expected_bob_before: MmNumberMultiRepr = MmNumber::from(params.expected_bob_locked_amount).into(); + assert_eq!(locked_bob.locked_amount, expected_bob_before); + // Alice’s side let locked_alice = block_on(get_locked_amount(&mm_alice, MYCOIN1)); assert_eq!(locked_alice.coin, MYCOIN1); - let expected: MmNumberMultiRepr = if SET_BURN_PUBKEY_TO_ALICE.get() { - MmNumber::from("777.00001").into() // no dex fee if dex pubkey is alice - } else { - MmNumber::from("778.00001").into() - }; - assert_eq!(locked_alice.locked_amount, expected); - - // amount must unlocked after funding tx is sent - block_on(mm_alice.wait_for_log(20., |log| log.contains("Sent taker funding"))).unwrap(); - let locked_alice = block_on(get_locked_amount(&mm_alice, MYCOIN1)); - assert_eq!(locked_alice.coin, MYCOIN1); - let expected: MmNumberMultiRepr = MmNumber::from("0").into(); - assert_eq!(locked_alice.locked_amount, expected); - - // amount must unlocked after maker payment is sent - block_on(mm_bob.wait_for_log(20., |log| log.contains("Sent maker payment"))).unwrap(); - let locked_bob = block_on(get_locked_amount(&mm_bob, MYCOIN)); - assert_eq!(locked_bob.coin, MYCOIN); - let expected: MmNumberMultiRepr = MmNumber::from("0").into(); - assert_eq!(locked_bob.locked_amount, expected); - - for uuid in uuids { - block_on(wait_for_swap_finished(&mm_bob, &uuid, 60)); - block_on(wait_for_swap_finished(&mm_alice, &uuid, 30)); + let expected_alice_before: MmNumberMultiRepr = MmNumber::from(params.expected_alice_locked_amount).into(); + assert_eq!(locked_alice.locked_amount, expected_alice_before); + + // 9) After taker funding is sent, the amount must be unlocked, Alice’s locked amount should be zero + block_on(mm_alice.wait_for_log(20., |log| log.contains("Sent taker funding"))) + .expect("Timeout waiting for taker to send funding"); + let locked_alice_after = block_on(get_locked_amount(&mm_alice, MYCOIN1)); + assert_eq!(locked_alice_after.coin, MYCOIN1); + assert_eq!(locked_alice_after.locked_amount, MmNumber::from("0").into()); + + // 10) After maker payment is sent, the amount must be unlocked, Bob’s locked amount should be zero + block_on(mm_bob.wait_for_log(20., |log| log.contains("Sent maker payment"))) + .expect("Timeout waiting for maker to send payment"); + let locked_bob_after = block_on(get_locked_amount(&mm_bob, MYCOIN)); + assert_eq!(locked_bob_after.coin, MYCOIN); + assert_eq!(locked_bob_after.locked_amount, MmNumber::from("0").into()); + + // 11) Wait for the swaps to finish and check statuses + for uuid in uuids.iter() { + block_on(wait_for_swap_finished(&mm_bob, uuid, 60)); + block_on(wait_for_swap_finished(&mm_alice, uuid, 30)); - let maker_swap_status = block_on(my_swap_status(&mm_bob, &uuid)); - log!("{:?}", maker_swap_status); + let maker_swap_status = block_on(my_swap_status(&mm_bob, uuid)); + log!("Maker swap status for {}: {:?}", uuid, maker_swap_status); - let taker_swap_status = block_on(my_swap_status(&mm_alice, &uuid)); - log!("{:?}", taker_swap_status); + let taker_swap_status = block_on(my_swap_status(&mm_alice, uuid)); + log!("Taker swap status for {}: {:?}", uuid, taker_swap_status); } + // 12) Check the recent swaps block_on(check_recent_swaps(&mm_bob, 1)); block_on(check_recent_swaps(&mm_alice, 1)); - // Disabling coins on both nodes should be successful at this point + // 13) Disabling coins on both nodes should be successful at this point block_on(disable_coin(&mm_bob, MYCOIN, false)); block_on(disable_coin(&mm_bob, MYCOIN1, false)); block_on(disable_coin(&mm_alice, MYCOIN, false)); block_on(disable_coin(&mm_alice, MYCOIN1, false)); } +#[test] +fn test_v2_swap_utxo_utxo() { + test_v2_swap_utxo_utxo_impl_common(UtxoSwapV2TestParams { + maker_price: 1.0, + taker_price: 1.001, // the price in rel the taker is willing to pay per one unit of the base coin + volume: 777., + premium: Some(0.777), + taker_method: TakerMethod::Buy, + expected_bob_locked_amount: "777.00001", + expected_alice_locked_amount: "778.77801", + }); +} + +// test a swap when taker buys and taker is burn pubkey (no dex fee should be paid) +#[test] +fn test_v2_swap_utxo_utxo_burnkey_as_alice() { + SET_BURN_PUBKEY_TO_ALICE.set(true); + test_v2_swap_utxo_utxo_impl_common(UtxoSwapV2TestParams { + maker_price: 1.0, + taker_price: 1.001, + volume: 777.0, + premium: Some(0.777), + taker_method: TakerMethod::Buy, + expected_bob_locked_amount: "777.00001", + expected_alice_locked_amount: "777.77701", // no dex fee if dex pubkey is alice + }); +} + +#[test] +fn test_v2_swap_utxo_utxo_sell() { + test_v2_swap_utxo_utxo_impl_common(UtxoSwapV2TestParams { + maker_price: 1.0, + taker_price: 0.98, // the price in rel the taker is willing to receive per one unit of the base coin + volume: 777.0, + premium: Some(0.00001), + taker_method: TakerMethod::Sell, + expected_bob_locked_amount: "777", + expected_alice_locked_amount: "778.00001", + }); +} + +// test a swap when taker sells and taker is burn pubkey (no dex fee should be paid) +#[test] +fn test_v2_swap_utxo_utxo_sell_burnkey_as_alice() { + SET_BURN_PUBKEY_TO_ALICE.set(true); + test_v2_swap_utxo_utxo_impl_common(UtxoSwapV2TestParams { + maker_price: 1.0, + taker_price: 0.98, + volume: 777.0, + premium: Some(0.00001), + taker_method: TakerMethod::Sell, + expected_bob_locked_amount: "777", + expected_alice_locked_amount: "777.00001", // no dex fee if dex pubkey is alice + }); +} + #[test] fn test_v2_swap_utxo_utxo_kickstart() { let (_ctx, _, bob_priv_key) = generate_utxo_coin_with_random_privkey(MYCOIN, 1000.into()); @@ -789,6 +852,8 @@ fn test_v2_swap_utxo_utxo_kickstart() { 1.0, 1.0, 777., + Some(0.), + TakerMethod::Buy, )); log!("{:?}", uuids); @@ -904,6 +969,8 @@ fn test_v2_swap_utxo_utxo_file_lock() { 1.0, 1.0, 100., + Some(0.), + TakerMethod::Buy, )); log!("{:?}", uuids); diff --git a/mm2src/mm2_main/tests/docker_tests/swap_watcher_tests.rs b/mm2src/mm2_main/tests/docker_tests/swap_watcher_tests.rs index cfd29cbecc..4cfa0a952f 100644 --- a/mm2src/mm2_main/tests/docker_tests/swap_watcher_tests.rs +++ b/mm2src/mm2_main/tests/docker_tests/swap_watcher_tests.rs @@ -23,7 +23,7 @@ use mm2_number::BigDecimal; use mm2_number::MmNumber; use mm2_test_helpers::for_tests::{enable_eth_coin, erc20_dev_conf, eth_dev_conf, eth_jst_testnet_conf, mm_dump, my_balance, my_swap_status, mycoin1_conf, mycoin_conf, start_swaps, - wait_for_swaps_finish_and_check_status, MarketMakerIt, Mm2TestConf, + wait_for_swaps_finish_and_check_status, MarketMakerIt, Mm2TestConf, TakerMethod, DEFAULT_RPC_PASSWORD}; use mm2_test_helpers::get_passphrase; use mm2_test_helpers::structs::WatcherConf; @@ -218,6 +218,8 @@ fn start_swaps_and_get_balances( maker_price, taker_price, volume, + None, + TakerMethod::Buy, )); if matches!(swap_flow, SwapFlow::WatcherRefundsTakerPayment) { @@ -418,6 +420,8 @@ fn test_taker_saves_the_swap_as_successful_after_restart_panic_at_wait_for_taker 25., 25., 2., + None, + TakerMethod::Buy, )); let (_alice_dump_log, _alice_dump_dashboard) = mm_alice.mm_dump(); log!("Alice log path: {}", mm_alice.log_path.display()); @@ -479,6 +483,8 @@ fn test_taker_saves_the_swap_as_successful_after_restart_panic_at_maker_payment_ 25., 25., 2., + None, + TakerMethod::Buy, )); let (_alice_dump_log, _alice_dump_dashboard) = mm_alice.mm_dump(); log!("Alice log path: {}", mm_alice.log_path.display()); @@ -537,6 +543,8 @@ fn test_taker_saves_the_swap_as_finished_after_restart_taker_payment_refunded_pa 25., 25., 2., + None, + TakerMethod::Buy, )); let (_alice_dump_log, _alice_dump_dashboard) = mm_alice.mm_dump(); log!("Alice log path: {}", mm_alice.log_path.display()); @@ -599,6 +607,8 @@ fn test_taker_saves_the_swap_as_finished_after_restart_taker_payment_refunded_pa 25., 25., 2., + None, + TakerMethod::Buy, )); let (_alice_dump_log, _alice_dump_dashboard) = mm_alice.mm_dump(); log!("Alice log path: {}", mm_alice.log_path.display()); @@ -650,6 +660,8 @@ fn test_taker_completes_swap_after_restart() { 25., 25., 2., + None, + TakerMethod::Buy, )); block_on(mm_alice.wait_for_log(120., |log| log.contains(WATCHER_MESSAGE_SENT_LOG))).unwrap(); @@ -695,6 +707,8 @@ fn test_taker_completes_swap_after_taker_payment_spent_while_offline() { 25., 25., 2., + None, + TakerMethod::Buy, )); // stop taker after taker payment sent @@ -1184,7 +1198,16 @@ fn test_two_watchers_spend_maker_payment_eth_erc20() { let watcher1_eth_balance_before = block_on(my_balance(&mm_watcher1, "ETH")).balance; let watcher2_eth_balance_before = block_on(my_balance(&mm_watcher2, "ETH")).balance; - block_on(start_swaps(&mut mm_bob, &mut mm_alice, &[("ETH", "JST")], 1., 1., 0.01)); + block_on(start_swaps( + &mut mm_bob, + &mut mm_alice, + &[("ETH", "JST")], + 1., + 1., + 0.01, + None, + TakerMethod::Buy, + )); block_on(mm_alice.wait_for_log(180., |log| log.contains(WATCHER_MESSAGE_SENT_LOG))).unwrap(); block_on(mm_alice.stop()).unwrap(); diff --git a/mm2src/mm2_main/tests/mm2_tests/lightning_tests.rs b/mm2src/mm2_main/tests/mm2_tests/lightning_tests.rs index 7b7c15688f..7c07891e94 100644 --- a/mm2src/mm2_main/tests/mm2_tests/lightning_tests.rs +++ b/mm2src/mm2_main/tests/mm2_tests/lightning_tests.rs @@ -6,7 +6,8 @@ use gstuff::now_ms; use http::StatusCode; use mm2_number::BigDecimal; use mm2_test_helpers::for_tests::{disable_coin, init_lightning, init_lightning_status, my_balance, sign_message, - start_swaps, verify_message, wait_for_swaps_finish_and_check_status, MarketMakerIt}; + start_swaps, verify_message, wait_for_swaps_finish_and_check_status, MarketMakerIt, + TakerMethod}; use mm2_test_helpers::structs::{InitLightningStatus, InitTaskResult, LightningActivationResult, RpcV2Response, SignatureResponse, VerificationResponse}; use serde_json::{self as json, json, Value as Json}; @@ -714,6 +715,8 @@ fn test_lightning_swaps() { price, price, volume, + None, + TakerMethod::Buy, )); block_on(wait_for_swaps_finish_and_check_status( &mut mm_node_1, @@ -739,6 +742,8 @@ fn test_lightning_swaps() { price, price, volume, + None, + TakerMethod::Buy, )); block_on(wait_for_swaps_finish_and_check_status( &mut mm_node_1, @@ -799,6 +804,8 @@ fn test_lightning_taker_swap_mpp() { price, price, volume, + None, + TakerMethod::Buy, )); block_on(wait_for_swaps_finish_and_check_status( &mut mm_node_1, @@ -858,6 +865,8 @@ fn test_lightning_maker_swap_mpp() { price, price, volume, + None, + TakerMethod::Buy, )); block_on(wait_for_swaps_finish_and_check_status( &mut mm_node_2, @@ -911,6 +920,8 @@ fn test_lightning_taker_gets_swap_preimage_onchain() { price, price, volume, + None, + TakerMethod::Buy, )); block_on(mm_node_1.wait_for_log(60., |log| log.contains(PAYMENT_CLAIMABLE_LOG))).unwrap(); @@ -974,6 +985,8 @@ fn test_lightning_taker_claims_mpp() { price, price, volume, + None, + TakerMethod::Buy, )); block_on(mm_node_1.wait_for_log(60., |log| log.contains(PAYMENT_CLAIMABLE_LOG))).unwrap(); diff --git a/mm2src/mm2_main/tests/mm2_tests/mm2_tests_inner.rs b/mm2src/mm2_main/tests/mm2_tests/mm2_tests_inner.rs index 439467c334..61d19bf476 100644 --- a/mm2src/mm2_main/tests/mm2_tests/mm2_tests_inner.rs +++ b/mm2src/mm2_main/tests/mm2_tests/mm2_tests_inner.rs @@ -20,9 +20,9 @@ use mm2_test_helpers::for_tests::{account_balance, btc_segwit_conf, btc_with_spv test_qrc20_history_impl, tqrc20_conf, verify_message, wait_for_swaps_finish_and_check_status, wait_till_history_has_records, MarketMakerIt, Mm2InitPrivKeyPolicy, Mm2TestConf, Mm2TestConfForSwap, RaiiDump, - DOC_ELECTRUM_ADDRS, ETH_MAINNET_NODES, ETH_MAINNET_SWAP_CONTRACT, ETH_SEPOLIA_NODES, - ETH_SEPOLIA_SWAP_CONTRACT, MARTY_ELECTRUM_ADDRS, MORTY, QRC20_ELECTRUMS, RICK, - RICK_ELECTRUM_ADDRS, TBTC_ELECTRUMS, T_BCH_ELECTRUMS}; + TakerMethod, DOC_ELECTRUM_ADDRS, ETH_MAINNET_NODES, ETH_MAINNET_SWAP_CONTRACT, + ETH_SEPOLIA_NODES, ETH_SEPOLIA_SWAP_CONTRACT, MARTY_ELECTRUM_ADDRS, MORTY, + QRC20_ELECTRUMS, RICK, RICK_ELECTRUM_ADDRS, TBTC_ELECTRUMS, T_BCH_ELECTRUMS}; use mm2_test_helpers::get_passphrase; use mm2_test_helpers::structs::*; use serde_json::{self as json, json, Value as Json}; @@ -767,7 +767,17 @@ async fn trade_base_rel_electrum( let rc = enable_utxo_v2_electrum(&mm_alice, "MORTY", marty_electrums(), alice_path_to_address, 600, None).await; log!("enable MORTY (alice): {:?}", rc); - let uuids = start_swaps(&mut mm_bob, &mut mm_alice, pairs, maker_price, taker_price, volume).await; + let uuids = start_swaps( + &mut mm_bob, + &mut mm_alice, + pairs, + maker_price, + taker_price, + volume, + None, + TakerMethod::Buy, + ) + .await; #[cfg(not(target_arch = "wasm32"))] for uuid in uuids.iter() { diff --git a/mm2src/mm2_test_helpers/src/for_tests.rs b/mm2src/mm2_test_helpers/src/for_tests.rs index 37a8b6bfb4..7a67411ae7 100644 --- a/mm2src/mm2_test_helpers/src/for_tests.rs +++ b/mm2src/mm2_test_helpers/src/for_tests.rs @@ -3637,6 +3637,29 @@ pub async fn set_price( json::from_str(&request.1).unwrap() } +pub enum TakerMethod { + Buy, + Sell, +} + +impl TakerMethod { + /// Convert enum variant to the corresponding RPC method name. + fn as_str(&self) -> &'static str { + match self { + TakerMethod::Buy => "buy", + TakerMethod::Sell => "sell", + } + } + + /// Depending on the method, we either leave `base`/`rel` as is (buy) or swap them (sell). + fn taker_pair<'a>(&self, base: &'a str, rel: &'a str) -> (&'a str, &'a str) { + match self { + TakerMethod::Buy => (base, rel), + TakerMethod::Sell => (rel, base), + } + } +} + pub async fn start_swaps( maker: &mut MarketMakerIt, taker: &mut MarketMakerIt, @@ -3644,6 +3667,8 @@ pub async fn start_swaps( maker_price: f64, taker_price: f64, volume: f64, + premium: Option, + taker_method: TakerMethod, ) -> Vec { let mut uuids = vec![]; @@ -3657,7 +3682,8 @@ pub async fn start_swaps( "base": base, "rel": rel, "price": maker_price, - "volume": volume + "volume": volume, + "premium": premium })) .await .unwrap(); @@ -3684,19 +3710,23 @@ pub async fn start_swaps( .unwrap(); assert!(rc.0.is_success(), "!orderbook: {}", rc.1); Timer::sleep(1.).await; - common::log::info!("Issue taker {}/{} buy request", base, rel); + + // Get the correct base/rel for the taker side, depending on buy or sell + let (taker_base, taker_rel) = taker_method.taker_pair(base, rel); + + common::log::info!("Issue taker {}/{} {} request", base, rel, taker_method.as_str()); let rc = taker .rpc(&json!({ "userpass": taker.userpass, - "method": "buy", - "base": base, - "rel": rel, + "method": taker_method.as_str(), + "base": taker_base, + "rel": taker_rel, "volume": volume, "price": taker_price })) .await .unwrap(); - assert!(rc.0.is_success(), "!buy: {}", rc.1); + assert!(rc.0.is_success(), "!{}: {}",taker_method.as_str(), rc.1); let buy_json: Json = serde_json::from_str(&rc.1).unwrap(); uuids.push(buy_json["result"]["uuid"].as_str().unwrap().to_owned()); }