diff --git a/Cargo.lock b/Cargo.lock index ee1c7cb656..84a146dde8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -7142,9 +7142,9 @@ dependencies = [ [[package]] name = "timed-map" -version = "1.4.1" +version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6f664a6b916d03d3e32c312c3b6ce31c24697c0f7ea6d87e20eb6372053ddf29" +checksum = "47e29dadb55913b13f482a3d61e45762f71cb7b145a012d8a48b8f05561eaa11" dependencies = [ "rustc-hash", "serde", diff --git a/Cargo.toml b/Cargo.toml index 019d0aacc0..ec7af89702 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -198,7 +198,7 @@ testcontainers = "0.15.0" tiny-bip39 = "0.8.0" thiserror = "1.0.40" time = "0.3.36" -timed-map = { version = "1.4", features = ["rustc-hash", "serde", "wasm"] } +timed-map = { version = "1.5", features = ["rustc-hash", "serde", "wasm"] } tokio = { version = "1.20", default-features = false } tokio-rustls = { version = "0.24", default-features = false } tokio-tungstenite-wasm = { git = "https://github.com/KomodoPlatform/tokio-tungstenite-wasm", rev = "8fc7e2f", defautl-features = false, features = ["rustls-tls-native-roots"]} diff --git a/mm2src/mm2_main/src/lp_ordermatch.rs b/mm2src/mm2_main/src/lp_ordermatch.rs index 5369f98f50..5f3b00996b 100644 --- a/mm2src/mm2_main/src/lp_ordermatch.rs +++ b/mm2src/mm2_main/src/lp_ordermatch.rs @@ -1753,6 +1753,7 @@ pub struct MakerOrder { pub swap_version: SwapVersion, #[cfg(feature = "ibc-routing-for-swaps")] order_metadata: OrderMetadata, + timeout_in_minutes: Option, } pub struct MakerOrderBuilder<'a> { @@ -1768,6 +1769,7 @@ pub struct MakerOrderBuilder<'a> { swap_version: u8, #[cfg(feature = "ibc-routing-for-swaps")] order_metadata: OrderMetadata, + timeout_in_minutes: Option, } /// Contains extra and/or optional metadata (e.g., protocol-specific information) that can @@ -1930,6 +1932,7 @@ impl<'a> MakerOrderBuilder<'a> { swap_version: SWAP_VERSION_DEFAULT, #[cfg(feature = "ibc-routing-for-swaps")] order_metadata: OrderMetadata::default(), + timeout_in_minutes: None, } } @@ -1968,6 +1971,8 @@ impl<'a> MakerOrderBuilder<'a> { self } + pub fn set_timeout(&mut self, timeout_in_minutes: u16) { self.timeout_in_minutes = Some(timeout_in_minutes); } + /// When a new [MakerOrderBuilder::new] is created, it sets [SWAP_VERSION_DEFAULT]. /// However, if user has not specified in the config to use TPU V2, /// the MakerOrderBuilder's swap_version is changed to legacy. @@ -2033,6 +2038,7 @@ impl<'a> MakerOrderBuilder<'a> { swap_version: SwapVersion::from(self.swap_version), #[cfg(feature = "ibc-routing-for-swaps")] order_metadata: self.order_metadata, + timeout_in_minutes: self.timeout_in_minutes, }) } @@ -2060,6 +2066,7 @@ impl<'a> MakerOrderBuilder<'a> { swap_version: SwapVersion::from(self.swap_version), #[cfg(feature = "ibc-routing-for-swaps")] order_metadata: self.order_metadata, + timeout_in_minutes: self.timeout_in_minutes, } } } @@ -2194,6 +2201,7 @@ impl From for MakerOrder { // TODO: Add test coverage for this once we have an integration test for this feature. #[cfg(feature = "ibc-routing-for-swaps")] order_metadata: taker_order.request.order_metadata, + timeout_in_minutes: None, }, // The "buy" taker order is recreated with reversed pair as Maker order is always considered as "sell" TakerAction::Buy => { @@ -2220,6 +2228,7 @@ impl From for MakerOrder { // TODO: Add test coverage for this once we have an integration test for this feature. #[cfg(feature = "ibc-routing-for-swaps")] order_metadata: taker_order.request.order_metadata, + timeout_in_minutes: None, } }, } @@ -2962,7 +2971,7 @@ impl OrdermatchContext { } pub struct MakerOrdersContext { - orders: HashMap>>, + orders: TimedMap>>, order_tickers: HashMap, count_by_tickers: HashMap, /// The `check_balance_update_loop` future abort handles associated stored by corresponding tickers. @@ -2976,7 +2985,7 @@ impl MakerOrdersContext { let balance_loops = ctx.abortable_system.create_subsystem()?; Ok(MakerOrdersContext { - orders: HashMap::new(), + orders: TimedMap::new_with_map_kind(timed_map::MapKind::FxHashMap), order_tickers: HashMap::new(), count_by_tickers: HashMap::new(), balance_loops, @@ -2988,7 +2997,21 @@ impl MakerOrdersContext { self.order_tickers.insert(order.uuid, order.base.clone()); *self.count_by_tickers.entry(order.base.clone()).or_insert(0) += 1; - self.orders.insert(order.uuid, Arc::new(AsyncMutex::new(order))); + + if let Some(t) = order.timeout_in_minutes { + // Use unchecked write to skip automatic cleanup as we need to handle + // expired orders manually. + self.orders.insert_expirable_unchecked( + order.uuid, + Arc::new(AsyncMutex::new(order)), + Duration::from_secs(t as u64 * 60), + ); + } else { + // Use unchecked write to skip automatic cleanup as we need to handle + // expired orders manually. + self.orders + .insert_constant_unchecked(order.uuid, Arc::new(AsyncMutex::new(order))); + } } fn get_order(&self, uuid: &Uuid) -> Option<&Arc>> { self.orders.get(uuid) } @@ -3562,6 +3585,19 @@ pub async fn lp_ordermatch_loop(ctx: MmArc) { handle_timed_out_maker_matches(ctx.clone(), &ordermatch_ctx).await; check_balance_for_maker_orders(ctx.clone(), &ordermatch_ctx).await; + let expired_orders = ordermatch_ctx.maker_orders_ctx.lock().orders.drop_expired_entries(); + + for (uuid, order_mutex) in expired_orders { + log::info!("Order '{uuid}' is expired, cancelling"); + + let order = order_mutex.lock().await; + maker_order_cancelled_p2p_notify(&ctx, &order); + delete_my_maker_order(ctx.clone(), order.clone(), MakerOrderCancellationReason::Expired) + .compat() + .await + .ok(); + } + { // remove "timed out" pubkeys states with their orders from orderbook let mut orderbook = ordermatch_ctx.orderbook.lock(); @@ -3592,9 +3628,9 @@ pub async fn lp_ordermatch_loop(ctx: MmArc) { let mut to_cancel = Vec::new(); { let orderbook = ordermatch_ctx.orderbook.lock(); - for (uuid, _) in ordermatch_ctx.maker_orders_ctx.lock().orders.iter() { - if !orderbook.order_set.contains_key(uuid) { - missing_uuids.push(*uuid); + for uuid in ordermatch_ctx.maker_orders_ctx.lock().orders.keys() { + if !orderbook.order_set.contains_key(&uuid) { + missing_uuids.push(uuid); } } } @@ -4795,6 +4831,7 @@ pub struct SetPriceReq { rel_nota: Option, #[serde(default = "get_true")] save_in_history: bool, + timeout_in_minutes: Option, } #[derive(Deserialize)] @@ -5059,6 +5096,11 @@ pub async fn create_maker_order(ctx: &MmArc, req: SetPriceReq) -> Result mpsc::Receiver { swap_version: SwapVersion::default(), #[cfg(feature = "ibc-routing-for-swaps")] order_metadata: OrderMetadata::default(), + timeout_in_minutes: None, }, None, ); @@ -1128,6 +1138,7 @@ fn prepare_for_cancel_by(ctx: &MmArc) -> mpsc::Receiver { swap_version: SwapVersion::default(), #[cfg(feature = "ibc-routing-for-swaps")] order_metadata: OrderMetadata::default(), + timeout_in_minutes: None, }, None, ); @@ -1153,6 +1164,7 @@ fn prepare_for_cancel_by(ctx: &MmArc) -> mpsc::Receiver { swap_version: SwapVersion::default(), #[cfg(feature = "ibc-routing-for-swaps")] order_metadata: OrderMetadata::default(), + timeout_in_minutes: None, }, None, ); @@ -1353,6 +1365,7 @@ fn test_maker_order_was_updated() { swap_version: SwapVersion::default(), #[cfg(feature = "ibc-routing-for-swaps")] order_metadata: OrderMetadata::default(), + timeout_in_minutes: None, }; let mut update_msg = MakerOrderUpdated::new(maker_order.uuid); update_msg.with_new_price(BigRational::from_integer(2.into())); @@ -3365,6 +3378,7 @@ fn test_maker_order_balance_loops() { swap_version: SwapVersion::default(), #[cfg(feature = "ibc-routing-for-swaps")] order_metadata: OrderMetadata::default(), + timeout_in_minutes: None, }; let morty_order = MakerOrder { @@ -3387,6 +3401,7 @@ fn test_maker_order_balance_loops() { swap_version: SwapVersion::default(), #[cfg(feature = "ibc-routing-for-swaps")] order_metadata: OrderMetadata::default(), + timeout_in_minutes: None, }; assert!(!maker_orders_ctx.balance_loop_exists(rick_ticker)); @@ -3422,6 +3437,7 @@ fn test_maker_order_balance_loops() { swap_version: SwapVersion::default(), #[cfg(feature = "ibc-routing-for-swaps")] order_metadata: OrderMetadata::default(), + timeout_in_minutes: None, }; maker_orders_ctx.add_order(ctx.weak(), rick_order_2.clone(), None); 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 b8a7164dd9..aca3f247f6 100644 --- a/mm2src/mm2_main/tests/docker_tests/docker_tests_inner.rs +++ b/mm2src/mm2_main/tests/docker_tests/docker_tests_inner.rs @@ -2229,7 +2229,7 @@ fn test_get_max_maker_vol() { let actual = block_on(max_maker_vol(&mm, "MYCOIN1")).unwrap::(); assert_eq!(actual, expected); - let res = block_on(set_price(&mm, "MYCOIN1", "MYCOIN", "1", "0", true)); + let res = block_on(set_price(&mm, "MYCOIN1", "MYCOIN", "1", "0", true, None)); assert_eq!(res.result.max_base_vol, expected_volume.to_decimal()); } diff --git a/mm2src/mm2_main/tests/docker_tests/tendermint_tests.rs b/mm2src/mm2_main/tests/docker_tests/tendermint_tests.rs index 58ad67e645..4656613683 100644 --- a/mm2src/mm2_main/tests/docker_tests/tendermint_tests.rs +++ b/mm2src/mm2_main/tests/docker_tests/tendermint_tests.rs @@ -127,10 +127,10 @@ fn test_iris_ibc_nucleus_orderbook() { let expected_address = "nuc150evuj4j7k9kgu38e453jdv9m3u0ft2n4fgzfr"; assert_eq!(response.result.address, expected_address); - let set_price_res = block_on(set_price(&mm, token, platform_coin, "1", "0.1", false)); + let set_price_res = block_on(set_price(&mm, token, platform_coin, "1", "0.1", false, None)); log!("{:?}", set_price_res); - let set_price_res = block_on(set_price(&mm, platform_coin, token, "1", "0.1", false)); + let set_price_res = block_on(set_price(&mm, platform_coin, token, "1", "0.1", false, None)); log!("{:?}", set_price_res); let orderbook = block_on(orderbook(&mm, token, platform_coin)); diff --git a/mm2src/mm2_main/tests/mm2_tests/orderbook_sync_tests.rs b/mm2src/mm2_main/tests/mm2_tests/orderbook_sync_tests.rs index 832f23cfae..30a92cac4d 100644 --- a/mm2src/mm2_main/tests/mm2_tests/orderbook_sync_tests.rs +++ b/mm2src/mm2_main/tests/mm2_tests/orderbook_sync_tests.rs @@ -1334,7 +1334,7 @@ fn test_order_cancellation_received_before_creation() { ); // Bob places maker order before Alice connects to the network so that Alice receives the order creation through IHAVE/IWANT messages. - let set_price = block_on(set_price(&mm_bob, "RICK", "MORTY", "0.9", "0.9", false)); + let set_price = block_on(set_price(&mm_bob, "RICK", "MORTY", "0.9", "0.9", false, None)); let mm_alice_conf = Mm2TestConf::light_node("alice passphrase", &coins, &[&mm_bob.ip.to_string()]); let mut mm_alice = MarketMakerIt::start(mm_alice_conf.conf, mm_alice_conf.rpc_password, None).unwrap(); @@ -1542,3 +1542,48 @@ fn zhtlc_orders_sync_alice_connected_after_creation() { .find(|ask| ask.entry.uuid == bob_set_price_res.result.uuid) .unwrap(); } + +#[test] +fn test_expirable_order() { + let coins = json!([rick_conf(), morty_conf()]); + + let bob_passphrase = "bob passphrase"; + let mm_bob_conf = Mm2TestConf::seednode(bob_passphrase, &coins); + let mm_bob = MarketMakerIt::start(mm_bob_conf.conf, mm_bob_conf.rpc_password, None).unwrap(); + + block_on(enable_electrum(&mm_bob, "RICK", false, DOC_ELECTRUM_ADDRS)); + block_on(enable_electrum(&mm_bob, "MORTY", false, MARTY_ELECTRUM_ADDRS)); + + let expiration_min = 1; + let _ = block_on(set_price( + &mm_bob, + "RICK", + "MORTY", + "0.1", + "0.1", + false, + Some(expiration_min), + )); + + let mm_alice_conf = Mm2TestConf::light_node("alice passphrase", &coins, &[&mm_bob.ip.to_string()]); + let mut mm_alice = MarketMakerIt::start(mm_alice_conf.conf, mm_alice_conf.rpc_password, None).unwrap(); + + let orderbook = block_on(orderbook_v2(&mm_alice, "RICK", "MORTY")); + let asks = orderbook["result"]["asks"].as_array().unwrap(); + // Alice should see the order in the orderbook as she got it through `GetOrderbook` p2p request. + assert_eq!(asks.len(), 1, "Alice RICK/MORTY orderbook must have exactly 1 ask"); + + // Sleep until order expires + thread::sleep(Duration::from_secs(expiration_min as u64 * 60 + 1)); + + // Make sure Alice receives the order cancellation message. + block_on(mm_alice.wait_for_log(10., |log| { + log.contains("received ordermatch message MakerOrderCancelled") + })) + .unwrap(); + + let orderbook = block_on(orderbook_v2(&mm_alice, "RICK", "MORTY")); + let asks = orderbook["result"]["asks"].as_array().unwrap(); + // Alice shouldn't find the order in the orderbook as it was expired just recently. + assert_eq!(asks.len(), 0, "Alice RICK/MORTY orderbook must have exactly 0 ask"); +} diff --git a/mm2src/mm2_test_helpers/src/for_tests.rs b/mm2src/mm2_test_helpers/src/for_tests.rs index f98fe3041c..37a8b6bfb4 100644 --- a/mm2src/mm2_test_helpers/src/for_tests.rs +++ b/mm2src/mm2_test_helpers/src/for_tests.rs @@ -3618,6 +3618,7 @@ pub async fn set_price( price: &str, vol: &str, max: bool, + timeout_in_minutes: Option, ) -> SetPriceResponse { let request = mm .rpc(&json!({ @@ -3628,6 +3629,7 @@ pub async fn set_price( "price": price, "volume": vol, "max": max, + "timeout_in_minutes": timeout_in_minutes, })) .await .unwrap();