From 49d56bf538843efb953044482afe521d14fe088a Mon Sep 17 00:00:00 2001 From: bathord Date: Sat, 7 Nov 2026 18:44:12 +0300 Subject: [PATCH 1/4] Handle 0 deep required estimation case when execution > 0 --- packages/deeptrade-core/sources/helper.move | 32 +++++++++++++++++++-- 1 file changed, 30 insertions(+), 2 deletions(-) diff --git a/packages/deeptrade-core/sources/helper.move b/packages/deeptrade-core/sources/helper.move index 57fed4a..18f5c39 100644 --- a/packages/deeptrade-core/sources/helper.move +++ b/packages/deeptrade-core/sources/helper.move @@ -490,14 +490,42 @@ public(package) fun calculate_market_order_params( // Since `get_quantity_out` goes through order book same way as actual order placement, // we can use its `deep_req` value // - For asks: Use order_amount directly as base quantity. Since `get_quantity_out` goes through - // order book same way as actual order placement, we can use its `deep_req` value + // order book same way as actual order placement, we can use its `deep_req` value. + // Exception is the dust-bid case handled below. if (is_bid) { let (base_out, _, deep_req) = pool.get_quantity_out(0, order_amount, clock); let (_, lot_size, _) = pool.pool_book_params(); let floored_base_out = base_out - base_out % lot_size; (floored_base_out, deep_req) } else { - let (_, _, deep_req) = pool.get_quantity_out(order_amount, 0, clock); + let (base_left, _, mut deep_req) = pool.get_quantity_out(order_amount, 0, clock); + let (_, _, min_size) = pool.pool_book_params(); + + // DeepBook v9 `get_quantity_out` can return 0 DEEP required for ask that will require + // DEEP on execution. Example: min_size is 1 SUI, the sell is 10 SUI, and the only + // bid is 0.2 SUI. `get_quantity_out` will say nothing is filled, so no DEEP required, + // but the real order will fill that 0.2 SUI and take DEEP fee for that. If the balance + // manager has no DEEP, transaction aborts. + // How do we handle this? + // First, we check that `get_quantity_out` says nothing is filled (`base_left == order_amount`). + // This can be in 3 cases: empty bids, dust bids (< min_size), or order is too small (< min_size). + // So, at second, we check that the order is at least `min_size` (order_amount >= min_size). + // If both are true, we know that either bids are empty or dust. + // Third, we check that the best bid is live (best_bid.is_some()). + // If all three are true, we know that this is the dust-bid case and the order will require + // DEEP on execution. Since we know that sum of dust bids sizes is less than `min_size` + // (otherwise, `get_quantity_out` returns base_left < order_amount), we can estimate the maximum + // DEEP that can be required to fill these dust bids: `min_size * best_bid_price`. + if (base_left == order_amount && order_amount >= min_size) { + let best_bid = pool.best_bid_price(clock); + if (best_bid.is_some()) { + let (taker_deep, _) = pool.get_order_deep_required( + min_size, + best_bid.destroy_some(), + ); + deep_req = taker_deep; + } + }; (order_amount, deep_req) } } From 16434c9b935554211e84ceb4fbd324f95a983f7a Mon Sep 17 00:00:00 2001 From: bathord Date: Sat, 7 Nov 2026 18:44:26 +0300 Subject: [PATCH 2/4] Add tests for the 0 deep required estimation case --- .../calculate_market_order_params_tests.move | 214 +++++++++++++++++- .../order/create_market_order_tests.move | 111 ++++++++- 2 files changed, 322 insertions(+), 3 deletions(-) diff --git a/packages/deeptrade-core/tests/helpers/calculate_market_order_params_tests.move b/packages/deeptrade-core/tests/helpers/calculate_market_order_params_tests.move index 7126da9..0c3f2c1 100644 --- a/packages/deeptrade-core/tests/helpers/calculate_market_order_params_tests.move +++ b/packages/deeptrade-core/tests/helpers/calculate_market_order_params_tests.move @@ -2,9 +2,9 @@ module deeptrade_core::calculate_market_order_params_tests; use deepbook::balance_manager::BalanceManager; -use deepbook::balance_manager_tests::{USDC, create_acct_and_share_with_funds}; +use deepbook::balance_manager_tests::{USDC, USDT, create_acct_and_share_with_funds}; use deepbook::constants; -use deepbook::pool::Pool; +use deepbook::pool::{Self, Pool}; use deepbook::pool_tests::{ setup_test, setup_pool_with_default_fees, @@ -13,6 +13,7 @@ use deepbook::pool_tests::{ add_deep_price_point, set_time, }; +use deepbook::registry::Registry; use deeptrade_core::fee; use deeptrade_core::fee_manager::{Self, FeeManager}; use deeptrade_core::get_sui_per_deep_from_oracle_tests::{ @@ -401,3 +402,212 @@ fun ask_base_quantity_direct() { price_info::destroy(sui_price); end(scenario); } + +/// A sell larger than `min_size` against one bid smaller than `min_size` quotes the +/// taker DEEP for `min_size`, not the 0 DeepBook reports. +/// +/// The DEEP/SUI reference mid is 100 SUI per DEEP, so this SUI pool stores 0.01 DEEP +/// per SUI (`10_000_000` scaled). The pool is volatile, so the taker fee is +/// `1_000_000`. The fee is `base * 10_000_000 / 1e9 * 1_000_000 / 1e9 = base / 100_000`. +/// `min_size` is `1_000_000`, so that stand-in fee is 10. The bid left on the book is +/// `500_000`, which would cost 5, and DeepBook's own quote is 0 because `500_000 < min_size`. +#[test] +fun ask_dust_bid_quotes_min_size_deep_bound() { + let (mut scenario, _pool_id, balance_manager_id, _, reference_pool_id, deep_price, sui_price, _) = + setup_test_environment(); + let pool_id = create_wide_min_pool(reference_pool_id, &mut scenario); + leave_dust_bid(pool_id, balance_manager_id, balance_manager_id, &mut scenario); + + scenario.next_tx(ALICE); + { + let pool = scenario.take_shared_by_id>(pool_id); + let clock = scenario.take_shared(); + let sell_amount = 2_000_000; + + let (_, _, quoted_by_deepbook) = pool.get_quantity_out(sell_amount, 0, &clock); + let (base_quantity, deep_required) = helper::calculate_market_order_params( + &pool, + sell_amount, + false, + &clock, + ); + + assert_eq!(quoted_by_deepbook, 0); + assert_eq!(base_quantity, sell_amount); + // 1_000_000 / 100_000 = 10 + assert_eq!(deep_required, 10); + + return_shared(pool); + return_shared(clock); + }; + + price_info::destroy(deep_price); + price_info::destroy(sui_price); + end(scenario); +} + +/// An empty bid side has no live bid, so the sell still quotes 0 DEEP. +#[test] +fun ask_empty_book_quotes_zero_deep() { + let (mut scenario, _pool_id, _, _, reference_pool_id, deep_price, sui_price, _) = + setup_test_environment(); + let pool_id = create_wide_min_pool(reference_pool_id, &mut scenario); + + scenario.next_tx(ALICE); + { + let pool = scenario.take_shared_by_id>(pool_id); + let clock = scenario.take_shared(); + let sell_amount = 2_000_000; + + let (base_quantity, deep_required) = helper::calculate_market_order_params( + &pool, + sell_amount, + false, + &clock, + ); + + assert_eq!(base_quantity, sell_amount); + assert_eq!(deep_required, 0); + + return_shared(pool); + return_shared(clock); + }; + + price_info::destroy(deep_price); + price_info::destroy(sui_price); + end(scenario); +} + +/// A bid of at least `min_size` is a real fill. The quote's own DEEP fee is kept: +/// selling `2_000_000` costs 20, which is more than the `min_size` stand-in of 10. +#[test] +fun ask_bid_covering_min_size_quotes_fill_fee() { + let ( + mut scenario, + _pool_id, + balance_manager_id, + _, + reference_pool_id, + deep_price, + sui_price, + _, + ) = setup_test_environment(); + let pool_id = create_wide_min_pool(reference_pool_id, &mut scenario); + fund_usdt(balance_manager_id, &mut scenario); + let bid = place_limit_order( + ALICE, + pool_id, + balance_manager_id, + 1, + constants::no_restriction(), + constants::self_matching_allowed(), + 2 * constants::float_scaling(), + 2_000_000, + true, + true, + constants::max_u64(), + &mut scenario, + ); + + scenario.next_tx(ALICE); + { + let pool = scenario.take_shared_by_id>(pool_id); + let clock = scenario.take_shared(); + let sell_amount = 2_000_000; + + let (base_quantity, deep_required) = helper::calculate_market_order_params( + &pool, + sell_amount, + false, + &clock, + ); + + assert_eq!(base_quantity, sell_amount); + // 2_000_000 / 100_000 = 20 + assert_eq!(deep_required, 20); + + return_shared(pool); + return_shared(clock); + }; + + destroy(bid); + price_info::destroy(deep_price); + price_info::destroy(sui_price); + end(scenario); +} + +/// SUI/USDT pool whose `min_size` is `1_000_000`, priced from the standard DEEP/SUI reference. +/// USDT is a fresh pair: the setup already registered SUI/USDC. +public(package) fun create_wide_min_pool(reference_pool_id: ID, scenario: &mut Scenario): ID { + scenario.next_tx(ALICE); + let pool_id = { + let mut registry = scenario.take_shared(); + let pool_id = pool::create_permissionless_pool( + &mut registry, + constants::tick_size(), + constants::lot_size(), + 1_000_000, + coin::mint_for_testing(constants::pool_creation_fee(), scenario.ctx()), + scenario.ctx(), + ); + return_shared(registry); + pool_id + }; + add_deep_price_point(ALICE, pool_id, reference_pool_id, scenario); + pool_id +} + +/// Leaves a single live bid of `500_000`, which is below the wide pool's `min_size`. +/// The maker rests `1_500_000` and the taker sells `1_000_000` of it. +public(package) fun leave_dust_bid( + pool_id: ID, + maker_balance_manager_id: ID, + taker_balance_manager_id: ID, + scenario: &mut Scenario, +) { + fund_usdt(maker_balance_manager_id, scenario); + let price = 2 * constants::float_scaling(); + let bid = place_limit_order( + ALICE, + pool_id, + maker_balance_manager_id, + 1, + constants::no_restriction(), + constants::self_matching_allowed(), + price, + 1_500_000, + true, + true, + constants::max_u64(), + scenario, + ); + let taken = place_limit_order( + ALICE, + pool_id, + taker_balance_manager_id, + 2, + constants::immediate_or_cancel(), + constants::self_matching_allowed(), + price, + 1_000_000, + false, + true, + constants::max_u64(), + scenario, + ); + destroy(bid); + destroy(taken); +} + +/// The shared setup funds USDC. These tests trade USDT, so the maker needs a quote balance. +fun fund_usdt(balance_manager_id: ID, scenario: &mut Scenario) { + scenario.next_tx(ALICE); + { + let mut balance_manager = scenario.take_shared_by_id(balance_manager_id); + balance_manager.deposit( + coin::mint_for_testing(1000 * constants::float_scaling(), scenario.ctx()), + scenario.ctx(), + ); + return_shared(balance_manager); + }; +} diff --git a/packages/deeptrade-core/tests/order/create_market_order_tests.move b/packages/deeptrade-core/tests/order/create_market_order_tests.move index 3874c0e..b42e43e 100644 --- a/packages/deeptrade-core/tests/order/create_market_order_tests.move +++ b/packages/deeptrade-core/tests/order/create_market_order_tests.move @@ -2,7 +2,7 @@ module deeptrade_core::create_market_order_tests; use deepbook::balance_manager::BalanceManager; -use deepbook::balance_manager_tests::{USDC, create_acct_and_share_with_funds}; +use deepbook::balance_manager_tests::{USDC, USDT, create_acct_and_share_with_funds}; use deepbook::constants; use deepbook::pool::Pool; use deepbook::pool_tests::{ @@ -13,6 +13,7 @@ use deepbook::pool_tests::{ add_deep_price_point, set_time, }; +use deeptrade_core::calculate_market_order_params_tests::{create_wide_min_pool, leave_dust_bid}; use deeptrade_core::dt_math as math; use deeptrade_core::dt_order::{Self as order, create_market_order}; use deeptrade_core::fee::{Self, TradingFeeConfig}; @@ -300,6 +301,114 @@ fun unsupported_self_matching_option() { end(scenario); } +/// A dust ask fill charges the real DEEP fee when the wallet covers the `min_size` stand-in. +/// +/// The book has one bid of `500_000` under a `min_size` of `1_000_000`. DeepBook quotes 0 DEEP +/// for a sell of `2_000_000`. The stand-in is 10 (`1_000_000 / 100_000`) and the fill itself +/// costs 5 (`500_000 / 100_000`). The balance manager starts with no DEEP and the wallet holds +/// exactly 10, so the order completes and 5 DEEP remains. +#[test] +fun ask_dust_fill_charges_less_deep_than_the_bound() { + let ( + mut scenario, + _pool_id, + balance_manager_id, + fee_manager_id, + reference_pool_id, + deep_price, + sui_price, + ) = setup_test_environment(); + + let maker_balance_manager_id = create_acct_and_share_with_funds( + ALICE, + 1000 * constants::float_scaling(), + &mut scenario, + ); + let pool_id = create_wide_min_pool(reference_pool_id, &mut scenario); + leave_dust_bid(pool_id, maker_balance_manager_id, balance_manager_id, &mut scenario); + + scenario.next_tx(ALICE); + { + let mut balance_manager = scenario.take_shared_by_id(balance_manager_id); + let leftover_deep = balance_manager.withdraw_all(scenario.ctx()); + destroy(leftover_deep); + return_shared(balance_manager); + }; + + scenario.next_tx(ALICE); + { + let mut treasury = scenario.take_shared(); + let mut fee_manager = scenario.take_shared_by_id(fee_manager_id); + let trading_fee_config = scenario.take_shared(); + let loyalty_program = scenario.take_shared(); + let mut pool = scenario.take_shared_by_id>(pool_id); + let reference_pool = scenario.take_shared_by_id>(reference_pool_id); + let mut balance_manager = scenario.take_shared_by_id(balance_manager_id); + let clock = scenario.take_shared(); + + let base_coin = coin::mint_for_testing(0, scenario.ctx()); + let quote_coin = coin::mint_for_testing(0, scenario.ctx()); + let deep_coin = coin::mint_for_testing(10, scenario.ctx()); + let sui_coin = coin::mint_for_testing(0, scenario.ctx()); + + assert_eq!(balance_manager.balance(), 0); + + let (order_info, base_coin, quote_coin, deep_coin, sui_coin) = create_market_order< + SUI, + USDT, + DEEP, + SUI, + >( + &mut treasury, + &mut fee_manager, + &trading_fee_config, + &loyalty_program, + &mut pool, + &reference_pool, + &deep_price, + &sui_price, + &mut balance_manager, + base_coin, + quote_coin, + deep_coin, + sui_coin, + 2_000_000, + false, + constants::self_matching_allowed(), + 3, + 10, + 0, + 0, + 0, + &clock, + scenario.ctx(), + ); + + // The 500_000 bid fills. The wallet's 10 DEEP was deposited and 5 was charged. + assert_eq!(order_info.executed_quantity(), 500_000); + assert_eq!(deep_coin.value(), 0); + assert_eq!(balance_manager.balance(), 5); + + destroy(order_info); + destroy(base_coin); + destroy(quote_coin); + destroy(deep_coin); + destroy(sui_coin); + return_shared(treasury); + return_shared(fee_manager); + return_shared(trading_fee_config); + return_shared(loyalty_program); + return_shared(pool); + return_shared(reference_pool); + return_shared(balance_manager); + return_shared(clock); + }; + + destroy(deep_price); + destroy(sui_price); + end(scenario); +} + // === Helper Functions === /// Sets up a complete test environment with treasury, deepbook infrastructure, and fee components. From 141931b0c3b7ce46053c7bbee4fc8fab920dec7b Mon Sep 17 00:00:00 2001 From: bathord Date: Sat, 7 Nov 2026 18:45:15 +0300 Subject: [PATCH 3/4] Bump deepbook & token deps to v9.0.0 + upd test deps --- packages/deeptrade-core/Move.lock | 173 +++++++++++++----------------- packages/deeptrade-core/Move.toml | 7 +- 2 files changed, 79 insertions(+), 101 deletions(-) diff --git a/packages/deeptrade-core/Move.lock b/packages/deeptrade-core/Move.lock index 5a5d908..a3a42cf 100644 --- a/packages/deeptrade-core/Move.lock +++ b/packages/deeptrade-core/Move.lock @@ -1,100 +1,77 @@ -# @generated by Move, please check-in and do not edit manually. +# Generated by move; do not edit +# This file should be checked in. [move] -version = 3 -manifest_digest = "69931A1480BE3DF484F425E055C6C764F7512F1B0D6FF5EBFE31A5313B1021A4" -deps_digest = "CAFAD8A7CF51067FB4358215BECB86BD100DD64E57C2AC8A7AE7D74B688F5965" -dependencies = [ - { id = "Bridge", name = "Bridge" }, - { id = "MoveStdlib", name = "MoveStdlib" }, - { id = "Pyth", name = "Pyth" }, - { id = "Sui", name = "Sui" }, - { id = "SuiSystem", name = "SuiSystem" }, - { id = "deepbook", name = "deepbook" }, - { id = "multisig", name = "multisig" }, -] - -[[move.package]] -id = "Bridge" -source = { git = "https://github.com/MystenLabs/sui.git", rev = "4e8b6eda7d6411d80c62f39ac8a4f028e8d174c4", subdir = "crates/sui-framework/packages/bridge" } - -dependencies = [ - { id = "MoveStdlib", name = "MoveStdlib" }, - { id = "Sui", name = "Sui" }, - { id = "SuiSystem", name = "SuiSystem" }, -] - -[[move.package]] -id = "MoveStdlib" -source = { git = "https://github.com/MystenLabs/sui.git", rev = "4e8b6eda7d6411d80c62f39ac8a4f028e8d174c4", subdir = "crates/sui-framework/packages/move-stdlib" } - -[[move.package]] -id = "Pyth" -source = { git = "https://github.com/pyth-network/pyth-crosschain.git", rev = "sui-contract-mainnet", subdir = "target_chains/sui/contracts" } - -dependencies = [ - { id = "Sui", name = "Sui" }, - { id = "Wormhole", name = "Wormhole" }, -] - -[[move.package]] -id = "Sui" -source = { git = "https://github.com/MystenLabs/sui.git", rev = "4e8b6eda7d6411d80c62f39ac8a4f028e8d174c4", subdir = "crates/sui-framework/packages/sui-framework" } - -dependencies = [ - { id = "MoveStdlib", name = "MoveStdlib" }, -] - -[[move.package]] -id = "SuiSystem" -source = { git = "https://github.com/MystenLabs/sui.git", rev = "4e8b6eda7d6411d80c62f39ac8a4f028e8d174c4", subdir = "crates/sui-framework/packages/sui-system" } - -dependencies = [ - { id = "MoveStdlib", name = "MoveStdlib" }, - { id = "Sui", name = "Sui" }, -] - -[[move.package]] -id = "Wormhole" -source = { git = "https://github.com/wormhole-foundation/wormhole.git", rev = "sui/mainnet", subdir = "sui/wormhole" } - -dependencies = [ - { id = "Sui", name = "Sui" }, -] - -[[move.package]] -id = "deepbook" -source = { git = "https://github.com/MystenLabs/deepbookv3.git", rev = "v3.0.0", subdir = "packages/deepbook" } - -dependencies = [ - { id = "Sui", name = "Sui" }, - { id = "token", name = "token" }, -] - -[[move.package]] -id = "multisig" -source = { git = "https://github.com/DeeptradeProtocol/multisig-move.git", rev = "v1.0.1", subdir = "move" } - -dependencies = [ - { id = "Bridge", name = "Bridge" }, - { id = "MoveStdlib", name = "MoveStdlib" }, - { id = "Sui", name = "Sui" }, - { id = "SuiSystem", name = "SuiSystem" }, -] - -[[move.package]] -id = "token" -source = { git = "https://github.com/MystenLabs/deepbookv3.git", rev = "v3.0.0", subdir = "packages/token" } - -dependencies = [ - { id = "Sui", name = "Sui" }, -] - -[move.toolchain-version] -compiler-version = "1.62.1" -edition = "2024.beta" -flavor = "sui" - -[env] - -[env.mainnet] +version = 4 + +[pinned.mainnet.MoveStdlib] +source = { git = "https://github.com/MystenLabs/sui.git", subdir = 'crates\sui-framework\packages\move-stdlib', rev = "ae59d7718668b468ce65702ecb0440aa2330f389" } +use_environment = "mainnet" +manifest_digest = "C4FE4C91DE74CBF223B2E380AE40F592177D21870DC2D7EB6227D2D694E05363" +deps = {} + +[pinned.mainnet.MoveStdlib_1] +source = { git = "https://github.com/MystenLabs/sui.git", subdir = 'crates\sui-framework\packages\move-stdlib', rev = "041c5f2bae2fe52079e44b70514333532d69f4e6" } +use_environment = "mainnet" +manifest_digest = "C4FE4C91DE74CBF223B2E380AE40F592177D21870DC2D7EB6227D2D694E05363" +deps = {} + +[pinned.mainnet.Pyth] +source = { git = "https://github.com/pyth-network/pyth-crosschain.git", subdir = 'target_chains\sui\contracts', rev = "3bd1262dcba9518a6901aa6a15f04072799bfb37" } +use_environment = "mainnet" +manifest_digest = "F2C5AF85C4B72C8F6A8132D05DE4F787F4EB80DBFF812331ED65AE599E7DF92A" +deps = { Sui = "Sui_1", Wormhole = "Wormhole" } + +[pinned.mainnet.PythProCompatible] +source = { git = "https://github.com/pyth-network/pyth-crosschain.git", subdir = 'target_chains\sui\contracts', rev = "aa291266173173480efef77b9454f2dbc2b4fd62" } +use_environment = "mainnet" +manifest_digest = "3D2B5BBF2058F3C9DCA3527CC2486C518124687B60479CA9875E5475652B7BBC" +deps = { Sui = "Sui_1", WormholeSimpleMajority = "WormholeSimpleMajority" } + +[pinned.mainnet.Sui] +source = { git = "https://github.com/MystenLabs/sui.git", subdir = 'crates\sui-framework\packages\sui-framework', rev = "ae59d7718668b468ce65702ecb0440aa2330f389" } +use_environment = "mainnet" +manifest_digest = "CD547CB1ACCE0880C835DAED2D8FFCB91D56C833AE5240D3AA5B918398263195" +deps = { MoveStdlib = "MoveStdlib" } + +[pinned.mainnet.Sui_1] +source = { git = "https://github.com/MystenLabs/sui.git", subdir = 'crates\sui-framework\packages\sui-framework', rev = "041c5f2bae2fe52079e44b70514333532d69f4e6" } +use_environment = "mainnet" +manifest_digest = "CD547CB1ACCE0880C835DAED2D8FFCB91D56C833AE5240D3AA5B918398263195" +deps = { MoveStdlib = "MoveStdlib_1" } + +[pinned.mainnet.Wormhole] +source = { git = "https://github.com/wormhole-foundation/wormhole.git", subdir = 'sui\wormhole', rev = "b71be5cbb9537c4aac8e23e74371affa3825efcd" } +use_environment = "mainnet" +manifest_digest = "0D766A0380B75080707CFA6099AF469A2E502B0D9DC334A9E9983B391C5555D9" +deps = { Sui = "Sui_1" } + +[pinned.mainnet.WormholeSimpleMajority] +source = { git = "https://github.com/pyth-network/pyth-crosschain.git", subdir = 'target_chains\sui\vendor\wormhole_simple_majority\wormhole', rev = "aa291266173173480efef77b9454f2dbc2b4fd62" } +use_environment = "mainnet" +manifest_digest = "0D766A0380B75080707CFA6099AF469A2E502B0D9DC334A9E9983B391C5555D9" +deps = { Sui = "Sui_1" } + +[pinned.mainnet.deepbook] +source = { git = "https://github.com/MystenLabs/deepbookv3.git", subdir = 'packages\deepbook', rev = "8a46552dfac02f2225965dda243bdea1ddcbec89" } +use_environment = "mainnet" +manifest_digest = "BBE7F8BFEF088532C5358623C6F5B3153EE14B3265ABA833B0F3CF9D313264A7" +deps = { std = "MoveStdlib", sui = "Sui", token = "token" } + +[pinned.mainnet.deeptrade_core] +source = { root = true } +use_environment = "mainnet" +manifest_digest = "349A4DE56587A83933E2890B98CC6978620033B8002BE40ABC22DBFE28820C32" +deps = { deepbook = "deepbook", multisig = "multisig", pyth = "Pyth", pyth_upgraded = "PythProCompatible", std = "MoveStdlib", sui = "Sui", token = "token" } + +[pinned.mainnet.multisig] +source = { git = "https://github.com/DeeptradeProtocol/multisig-move.git", subdir = "move", rev = "263aeb17cd55115a1b2300a6cf5272e7962e7f7a" } +use_environment = "mainnet" +manifest_digest = "E41BBD67BE8940D26C79D78B028477EF5B33BA217A1282C78ACB344CF8A5ECF6" +deps = { std = "MoveStdlib", sui = "Sui" } + +[pinned.mainnet.token] +source = { git = "https://github.com/MystenLabs/deepbookv3.git", subdir = 'packages\token', rev = "8a46552dfac02f2225965dda243bdea1ddcbec89" } +use_environment = "mainnet" +manifest_digest = "E41BBD67BE8940D26C79D78B028477EF5B33BA217A1282C78ACB344CF8A5ECF6" +deps = { std = "MoveStdlib", sui = "Sui" } diff --git a/packages/deeptrade-core/Move.toml b/packages/deeptrade-core/Move.toml index 4fa3d85..87dba9c 100644 --- a/packages/deeptrade-core/Move.toml +++ b/packages/deeptrade-core/Move.toml @@ -4,8 +4,8 @@ version = "2.0.0" edition = "2024.alpha" [dependencies] -deepbook = { git = "https://github.com/MystenLabs/deepbookv3.git", subdir = "packages/deepbook", rev = "v4.0.0" } -token = { git = "https://github.com/MystenLabs/deepbookv3.git", subdir = "packages/token", rev = "main" } +deepbook = { git = "https://github.com/MystenLabs/deepbookv3.git", subdir = "packages/deepbook", rev = "v9.0.0" } +token = { git = "https://github.com/MystenLabs/deepbookv3.git", subdir = "packages/token", rev = "v9.0.0" } multisig = { git = "https://github.com/DeeptradeProtocol/multisig-move.git", subdir = "move", rev = "v1.0.1" } # Pyth dependency @@ -16,5 +16,6 @@ pyth_upgraded = { git = "https://github.com/pyth-network/pyth-crosschain.git", s # For `sui move test`, comment the production pins above and uncomment these. # Never leave both copies uncommented. -# deepbook = { git = "https://github.com/DeeptradeProtocol/deepbookv3.git", subdir = "packages/deepbook", rev = "v4-tag-fork-test-methods" } +# deepbook = { git = "https://github.com/DeeptradeProtocol/deepbookv3.git", subdir = "packages/deepbook", rev = "v9-tag-fork-test-methods" } +# token = { git = "https://github.com/DeeptradeProtocol/deepbookv3.git", subdir = "packages/token", rev = "v9-tag-fork-test-methods" } # pyth = { git = "https://github.com/DeeptradeProtocol/pyth-crosschain.git", subdir = "target_chains/sui/contracts", rev = "sui-contract-mainnet" } From d2dff8994eeebbcad39f10e4c362993f5048549b Mon Sep 17 00:00:00 2001 From: bathord Date: Sat, 7 Nov 2026 19:18:13 +0300 Subject: [PATCH 4/4] Add 4 test cases Quote-priced dust bid, Several dust bids, Order below the minimum size, Treasury reserves. --- .../calculate_market_order_params_tests.move | 249 ++++++++++++++++++ .../order/create_market_order_tests.move | 116 ++++++++ 2 files changed, 365 insertions(+) diff --git a/packages/deeptrade-core/tests/helpers/calculate_market_order_params_tests.move b/packages/deeptrade-core/tests/helpers/calculate_market_order_params_tests.move index 0c3f2c1..a495a72 100644 --- a/packages/deeptrade-core/tests/helpers/calculate_market_order_params_tests.move +++ b/packages/deeptrade-core/tests/helpers/calculate_market_order_params_tests.move @@ -4,6 +4,7 @@ module deeptrade_core::calculate_market_order_params_tests; use deepbook::balance_manager::BalanceManager; use deepbook::balance_manager_tests::{USDC, USDT, create_acct_and_share_with_funds}; use deepbook::constants; +use deepbook::deep_price::{asset_is_base, deep_per_asset}; use deepbook::pool::{Self, Pool}; use deepbook::pool_tests::{ setup_test, @@ -30,6 +31,7 @@ use pyth::price_info::{Self, PriceInfoObject}; use std::unit_test::assert_eq; use sui::clock; use sui::coin; +use std::option::is_some; use sui::sui::SUI; use sui::test_scenario::{Self, Scenario, end, return_shared}; use sui::test_utils::destroy; @@ -536,6 +538,188 @@ fun ask_bid_covering_min_size_quotes_fill_fee() { end(scenario); } +/// DEEP priced per quote. The fee is `taker_fee * quote_notional * deep_per_sui`, so the +/// `min_size` stand-in depends on the best bid's price. +/// +/// USDT/SUI puts SUI on the quote side of the DEEP/SUI reference, so the stored rate is +/// DEEP per SUI. The reference mid is 100 SUI per DEEP, which is `10_000_000` DEEP per SUI. +/// The pool is volatile, so the taker fee is `1_000_000`. One bid of `500_000` at 2 SUI sits +/// under `min_size` of `1_000_000`, and DeepBook quotes 0. The stand-in sells `min_size` at +/// that price: quote `1_000_000 * 2 = 2_000_000`, DEEP `2_000_000 * 10_000_000 / 1e9 = 20_000`, +/// fee `20_000 * 1_000_000 / 1e9 = 20`. The same bid on a base-priced pool quotes 10. +#[test] +fun ask_quote_priced_dust_bid_quotes_notional_bound() { + let (mut scenario, _, balance_manager_id, _, reference_pool_id, deep_price, sui_price, _) = + setup_test_environment(); + let pool_id = create_quote_priced_pool(reference_pool_id, &mut scenario); + fund_usdt(balance_manager_id, &mut scenario); + shave_quote_priced_bid( + pool_id, + balance_manager_id, + 1, + 2 * constants::float_scaling(), + 1_500_000, + 500_000, + &mut scenario, + ); + + scenario.next_tx(ALICE); + { + let pool = scenario.take_shared_by_id>(pool_id); + let clock = scenario.take_shared(); + let sell_amount = 2_000_000; + + let order_deep_price = pool.get_order_deep_price(); + let (_, _, quoted_by_deepbook) = pool.get_quantity_out(sell_amount, 0, &clock); + let (base_quantity, deep_required) = helper::calculate_market_order_params( + &pool, + sell_amount, + false, + &clock, + ); + + assert!(!order_deep_price.asset_is_base()); + assert_eq!(order_deep_price.deep_per_asset(), 10_000_000); + assert_eq!(quoted_by_deepbook, 0); + assert_eq!(base_quantity, sell_amount); + // 20_000 * 1_000_000 / 1e9 = 20 + assert_eq!(deep_required, 20); + + return_shared(pool); + return_shared(clock); + }; + + price_info::destroy(deep_price); + price_info::destroy(sui_price); + end(scenario); +} + +/// Three dust bids at different prices still sum to less than `min_size`, so DeepBook quotes 0. +/// The stand-in uses the best of those prices. +/// +/// Each bid is shaved to `200_000`. Worst first, and each sell is priced at its own bid, so a +/// sell never reaches the cheaper dust already on the book. `200_000 * 3 = 600_000`, under +/// `min_size` of `1_000_000`. The best price is 3 SUI: quote `3_000_000`, DEEP `30_000`, +/// fee `30_000 * 1_000_000 / 1e9 = 30`. +#[test] +fun ask_several_dust_bids_bound_uses_best_price() { + let (mut scenario, _, balance_manager_id, _, reference_pool_id, deep_price, sui_price, _) = + setup_test_environment(); + let pool_id = create_quote_priced_pool(reference_pool_id, &mut scenario); + fund_usdt(balance_manager_id, &mut scenario); + + let one_sui = constants::float_scaling(); + shave_quote_priced_bid( + pool_id, + balance_manager_id, + 1, + one_sui, + 1_200_000, + 200_000, + &mut scenario, + ); + shave_quote_priced_bid( + pool_id, + balance_manager_id, + 3, + 2 * one_sui, + 1_200_000, + 200_000, + &mut scenario, + ); + shave_quote_priced_bid( + pool_id, + balance_manager_id, + 5, + 3 * one_sui, + 1_200_000, + 200_000, + &mut scenario, + ); + + scenario.next_tx(ALICE); + { + let pool = scenario.take_shared_by_id>(pool_id); + let clock = scenario.take_shared(); + let sell_amount = 2_000_000; + + let (_, _, quoted_by_deepbook) = pool.get_quantity_out(sell_amount, 0, &clock); + let (base_quantity, deep_required) = helper::calculate_market_order_params( + &pool, + sell_amount, + false, + &clock, + ); + + assert_eq!(quoted_by_deepbook, 0); + assert_eq!(base_quantity, sell_amount); + // 30_000 * 1_000_000 / 1e9 = 30 + assert_eq!(deep_required, 30); + + return_shared(pool); + return_shared(clock); + }; + + price_info::destroy(deep_price); + price_info::destroy(sui_price); + end(scenario); +} + +/// An order smaller than `min_size` quotes 0 even with a live bid behind it. +/// DeepBook rejects the order itself, so the dust stand-in must not apply. +#[test] +fun ask_below_min_size_quotes_zero_deep() { + let (mut scenario, _, balance_manager_id, _, reference_pool_id, deep_price, sui_price, _) = + setup_test_environment(); + let pool_id = create_wide_min_pool(reference_pool_id, &mut scenario); + fund_usdt(balance_manager_id, &mut scenario); + let bid = place_limit_order( + ALICE, + pool_id, + balance_manager_id, + 1, + constants::no_restriction(), + constants::self_matching_allowed(), + 2 * constants::float_scaling(), + 2_000_000, + true, + true, + constants::max_u64(), + &mut scenario, + ); + + scenario.next_tx(ALICE); + { + let pool = scenario.take_shared_by_id>(pool_id); + let clock = scenario.take_shared(); + let sell_amount = 500_000; + let (_, _, min_size) = pool.pool_book_params(); + + let (base_left, _, quoted_by_deepbook) = pool.get_quantity_out(sell_amount, 0, &clock); + let (base_quantity, deep_required) = helper::calculate_market_order_params( + &pool, + sell_amount, + false, + &clock, + ); + + assert!(sell_amount < min_size); + assert!(pool.best_bid_price(&clock).is_some()); + assert_eq!(base_left, sell_amount); + assert_eq!(quoted_by_deepbook, 0); + assert_eq!(base_quantity, sell_amount); + assert_eq!(deep_required, 0); + + return_shared(pool); + return_shared(clock); + }; + + destroy(bid); + price_info::destroy(deep_price); + price_info::destroy(sui_price); + end(scenario); +} + /// SUI/USDT pool whose `min_size` is `1_000_000`, priced from the standard DEEP/SUI reference. /// USDT is a fresh pair: the setup already registered SUI/USDC. public(package) fun create_wide_min_pool(reference_pool_id: ID, scenario: &mut Scenario): ID { @@ -599,6 +783,71 @@ public(package) fun leave_dust_bid( destroy(taken); } +/// USDT/SUI pool whose `min_size` is `1_000_000`. SUI is the quote, so the DEEP/SUI reference +/// prices DEEP per quote (`asset_is_base` is false). +fun create_quote_priced_pool(reference_pool_id: ID, scenario: &mut Scenario): ID { + scenario.next_tx(ALICE); + let pool_id = { + let mut registry = scenario.take_shared(); + let pool_id = pool::create_permissionless_pool( + &mut registry, + constants::tick_size(), + constants::lot_size(), + 1_000_000, + coin::mint_for_testing(constants::pool_creation_fee(), scenario.ctx()), + scenario.ctx(), + ); + return_shared(registry); + pool_id + }; + add_deep_price_point(ALICE, pool_id, reference_pool_id, scenario); + pool_id +} + +/// Rests `rest_quantity` at `price` on the USDT/SUI pool, then sells the difference at the same +/// price so `leave_quantity` remains. The ask price stops the sell from matching a worse bid, +/// which is what makes shaving several prices safe when done worst price first. +fun shave_quote_priced_bid( + pool_id: ID, + balance_manager_id: ID, + client_order_id: u64, + price: u64, + rest_quantity: u64, + leave_quantity: u64, + scenario: &mut Scenario, +) { + let bid = place_limit_order( + ALICE, + pool_id, + balance_manager_id, + client_order_id, + constants::no_restriction(), + constants::self_matching_allowed(), + price, + rest_quantity, + true, + true, + constants::max_u64(), + scenario, + ); + let taken = place_limit_order( + ALICE, + pool_id, + balance_manager_id, + client_order_id + 1, + constants::immediate_or_cancel(), + constants::self_matching_allowed(), + price, + rest_quantity - leave_quantity, + false, + true, + constants::max_u64(), + scenario, + ); + destroy(bid); + destroy(taken); +} + /// The shared setup funds USDC. These tests trade USDT, so the maker needs a quote balance. fun fund_usdt(balance_manager_id: ID, scenario: &mut Scenario) { scenario.next_tx(ALICE); diff --git a/packages/deeptrade-core/tests/order/create_market_order_tests.move b/packages/deeptrade-core/tests/order/create_market_order_tests.move index b42e43e..a4be64a 100644 --- a/packages/deeptrade-core/tests/order/create_market_order_tests.move +++ b/packages/deeptrade-core/tests/order/create_market_order_tests.move @@ -409,6 +409,122 @@ fun ask_dust_fill_charges_less_deep_than_the_bound() { end(scenario); } +/// No DEEP in the wallet or the balance manager. Reserves lend the stand-in of 10, the +/// coverage fee is the SUI value of those 10, and the fill spends 5, which stays behind. +/// +/// The book is the same dust bid as `ask_dust_fill_charges_less_deep_than_the_bound`: `500_000` +/// under a `min_size` of `1_000_000`, stand-in 10, fill cost 5. Oracle DEEP/USD is 3 and +/// SUI/USD is 10, both at exponent -8, so SUI per DEEP is `300_000_000_000`. That beats the +/// reference ask of 180 SUI per DEEP. Coverage on the borrowed 10 is +/// `10 * 300_000_000_000 / 1e9 = 3000` SUI, taken from the balance manager. Coverage on the 5 +/// actually charged would be 1500. +#[test] +fun ask_dust_fill_borrows_bound_from_reserves() { + let ( + mut scenario, + _pool_id, + balance_manager_id, + fee_manager_id, + reference_pool_id, + deep_price, + sui_price, + ) = setup_test_environment(); + + let maker_balance_manager_id = create_acct_and_share_with_funds( + ALICE, + 1000 * constants::float_scaling(), + &mut scenario, + ); + let pool_id = create_wide_min_pool(reference_pool_id, &mut scenario); + leave_dust_bid(pool_id, maker_balance_manager_id, balance_manager_id, &mut scenario); + + scenario.next_tx(ALICE); + { + let mut balance_manager = scenario.take_shared_by_id(balance_manager_id); + let leftover_deep = balance_manager.withdraw_all(scenario.ctx()); + destroy(leftover_deep); + return_shared(balance_manager); + }; + + scenario.next_tx(ALICE); + { + let mut treasury = scenario.take_shared(); + let mut fee_manager = scenario.take_shared_by_id(fee_manager_id); + let trading_fee_config = scenario.take_shared(); + let loyalty_program = scenario.take_shared(); + let mut pool = scenario.take_shared_by_id>(pool_id); + let reference_pool = scenario.take_shared_by_id>(reference_pool_id); + let mut balance_manager = scenario.take_shared_by_id(balance_manager_id); + let clock = scenario.take_shared(); + + let base_coin = coin::mint_for_testing(0, scenario.ctx()); + let quote_coin = coin::mint_for_testing(0, scenario.ctx()); + let deep_coin = coin::mint_for_testing(0, scenario.ctx()); + let sui_coin = coin::mint_for_testing(0, scenario.ctx()); + let reserves_before = treasury.deep_reserves(); + + assert_eq!(balance_manager.balance(), 0); + assert_eq!(treasury.get_deep_reserves_coverage_fee_balance(), 0); + + let (order_info, base_coin, quote_coin, deep_coin, sui_coin) = create_market_order< + SUI, + USDT, + DEEP, + SUI, + >( + &mut treasury, + &mut fee_manager, + &trading_fee_config, + &loyalty_program, + &mut pool, + &reference_pool, + &deep_price, + &sui_price, + &mut balance_manager, + base_coin, + quote_coin, + deep_coin, + sui_coin, + 2_000_000, + false, + constants::self_matching_allowed(), + 3, + 10, + 0, + 3000, + 0, + &clock, + scenario.ctx(), + ); + + // Reserves lent 10 and the fill charged 5, so 5 remains. Coverage was charged on 10. + assert_eq!(order_info.executed_quantity(), 500_000); + assert_eq!(deep_coin.value(), 0); + assert_eq!(sui_coin.value(), 0); + assert_eq!(balance_manager.balance(), 5); + assert_eq!(treasury.deep_reserves(), reserves_before - 10); + assert_eq!(treasury.get_deep_reserves_coverage_fee_balance(), 3000); + + destroy(order_info); + destroy(base_coin); + destroy(quote_coin); + destroy(deep_coin); + destroy(sui_coin); + return_shared(treasury); + return_shared(fee_manager); + return_shared(trading_fee_config); + return_shared(loyalty_program); + return_shared(pool); + return_shared(reference_pool); + return_shared(balance_manager); + return_shared(clock); + }; + + destroy(deep_price); + destroy(sui_price); + end(scenario); +} + // === Helper Functions === /// Sets up a complete test environment with treasury, deepbook infrastructure, and fee components.