diff --git a/.github/workflows/move_test.yml b/.github/workflows/move_test.yml index 03ca6e7..b03af3c 100644 --- a/.github/workflows/move_test.yml +++ b/.github/workflows/move_test.yml @@ -20,7 +20,7 @@ jobs: test: runs-on: ubuntu-latest env: - SUI_VERSION: "1.80.1" # Parameterized Sui version + SUI_VERSION: "1.83.1" # Parameterized Sui version steps: - uses: actions/checkout@v6 @@ -67,8 +67,10 @@ jobs: TOML=packages/deeptrade-core/Move.toml # Comment production pins, then uncomment the test-fork lines (no duplicate keys). perl -i -pe 's/^(deepbook = \{ git = "https:\/\/github.com\/MystenLabs\/deepbookv3.git".*\})/# $1/' "$TOML" + perl -i -pe 's/^(token = \{ git = "https:\/\/github.com\/MystenLabs\/deepbookv3.git".*\})/# $1/' "$TOML" perl -i -pe 's/^(pyth = \{ git = "https:\/\/github.com\/pyth-network\/pyth-crosschain.git".*\})/# $1/' "$TOML" perl -i -pe 's/^#\s*(deepbook = \{ git = "https:\/\/github.com\/DeeptradeProtocol\/deepbookv3.git".*\})/$1/' "$TOML" + perl -i -pe 's/^#\s*(token = \{ git = "https:\/\/github.com\/DeeptradeProtocol\/deepbookv3.git".*\})/$1/' "$TOML" perl -i -pe 's/^#\s*(pyth = \{ git = "https:\/\/github.com\/DeeptradeProtocol\/pyth-crosschain.git".*\})/$1/' "$TOML" echo "Move.toml after changes:" cat "$TOML" diff --git a/.github/workflows/upgrade_package.yml b/.github/workflows/upgrade_package.yml index 68be7a5..bc9b2b9 100644 --- a/.github/workflows/upgrade_package.yml +++ b/.github/workflows/upgrade_package.yml @@ -28,7 +28,7 @@ jobs: runs-on: ubuntu-latest env: # Keep in sync with .github/workflows/move_test.yml (SUI_VERSION). - SUI_VERSION: "1.80.1" + SUI_VERSION: "1.83.1" PACKAGE_PATH: "packages/deeptrade-core" # Gas budget for the unsigned upgrade PTB (MIST). CLI auto-selects coins from sender. GAS_BUDGET: "1000000000" diff --git a/docs/upgrade-provenance.md b/docs/upgrade-provenance.md index 08a9091..31f3c1c 100644 --- a/docs/upgrade-provenance.md +++ b/docs/upgrade-provenance.md @@ -86,7 +86,7 @@ This rebuilds with `toolchain-version` from `Published.toml` and compares root p If metadata is incomplete, override explicitly: ```bash -sui client verify-source --toolchain-version 1.80.1 +sui client verify-source --toolchain-version 1.83.1 ``` ## What each check proves diff --git a/packages/deeptrade-core/Move.lock b/packages/deeptrade-core/Move.lock index ba825e5..da3f877 100644 --- a/packages/deeptrade-core/Move.lock +++ b/packages/deeptrade-core/Move.lock @@ -5,18 +5,12 @@ version = 4 [pinned.mainnet.MoveStdlib] -source = { git = "https://github.com/MystenLabs/sui.git", subdir = "crates/sui-framework/packages/move-stdlib", rev = "ae59d7718668b468ce65702ecb0440aa2330f389" } +source = { git = "https://github.com/MystenLabs/sui.git", subdir = "crates/sui-framework/packages/move-stdlib", rev = "b0535f1f3a3310e71790e90d8ae4e8ca840c897e" } 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 = "62b9f371e37e618b7048417e2d232d6cd678c967" } -use_environment = "mainnet" -manifest_digest = "C4FE4C91DE74CBF223B2E380AE40F592177D21870DC2D7EB6227D2D694E05363" -deps = {} - -[pinned.mainnet.MoveStdlib_2] source = { git = "https://github.com/MystenLabs/sui.git", subdir = "crates/sui-framework/packages/move-stdlib", rev = "041c5f2bae2fe52079e44b70514333532d69f4e6" } use_environment = "mainnet" manifest_digest = "C4FE4C91DE74CBF223B2E380AE40F592177D21870DC2D7EB6227D2D694E05363" @@ -26,54 +20,48 @@ deps = {} 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_2", Wormhole = "Wormhole" } +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_2", WormholeSimpleMajority = "WormholeSimpleMajority" } +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" } +source = { git = "https://github.com/MystenLabs/sui.git", subdir = "crates/sui-framework/packages/sui-framework", rev = "b0535f1f3a3310e71790e90d8ae4e8ca840c897e" } 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 = "62b9f371e37e618b7048417e2d232d6cd678c967" } -use_environment = "mainnet" -manifest_digest = "CD547CB1ACCE0880C835DAED2D8FFCB91D56C833AE5240D3AA5B918398263195" -deps = { MoveStdlib = "MoveStdlib_1" } - -[pinned.mainnet.Sui_2] 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_2" } +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_2" } +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_2" } +deps = { Sui = "Sui_1" } [pinned.mainnet.deepbook] -source = { git = "https://github.com/MystenLabs/deepbookv3.git", subdir = "packages/deepbook", rev = "bf14d55d8aeabcd06f30713a2c105532c01d3d9a" } +source = { git = "https://github.com/MystenLabs/deepbookv3.git", subdir = "packages/deepbook", rev = "8a46552dfac02f2225965dda243bdea1ddcbec89" } use_environment = "mainnet" -manifest_digest = "FCFED6D27C18F9FA855BD4A8D57673B0DBC5654AC7B60BCC86ACB4BBAC23EEDF" -deps = { Sui = "Sui_1", token = "token" } +manifest_digest = "BBE7F8BFEF088532C5358623C6F5B3153EE14B3265ABA833B0F3CF9D313264A7" +deps = { std = "MoveStdlib", sui = "Sui", token = "token" } [pinned.mainnet.deeptrade_core] source = { root = true } use_environment = "mainnet" -manifest_digest = "146698FE91735D6E150640D3554540F22780B51F153C7DFABB7BFF8DFBC757C4" +manifest_digest = "349A4DE56587A83933E2890B98CC6978620033B8002BE40ABC22DBFE28820C32" deps = { deepbook = "deepbook", multisig = "multisig", pyth = "Pyth", pyth_upgraded = "PythProCompatible", std = "MoveStdlib", sui = "Sui", token = "token" } [pinned.mainnet.multisig] @@ -83,7 +71,7 @@ manifest_digest = "E41BBD67BE8940D26C79D78B028477EF5B33BA217A1282C78ACB344CF8A5E deps = { std = "MoveStdlib", sui = "Sui" } [pinned.mainnet.token] -source = { git = "https://github.com/MystenLabs/deepbookv3.git", subdir = "packages/token", rev = "bf14d55d8aeabcd06f30713a2c105532c01d3d9a" } +source = { git = "https://github.com/MystenLabs/deepbookv3.git", subdir = "packages/token", rev = "8a46552dfac02f2225965dda243bdea1ddcbec89" } use_environment = "mainnet" -manifest_digest = "CF8E7F9892A27676E1B4120F63AB9B10842B35CF5D395B321061CDFBCFE084B3" -deps = { Sui = "Sui_1" } +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" } diff --git a/packages/deeptrade-core/Published.toml b/packages/deeptrade-core/Published.toml index a373afc..05a8182 100644 --- a/packages/deeptrade-core/Published.toml +++ b/packages/deeptrade-core/Published.toml @@ -13,5 +13,5 @@ published-at = "0xda5b27f23ac02c1f9acec07a32baf6523a381d7df9b00d99c99e3f9b41b128 original-id = "0xc10d536b6580d809711b9bb8eee3945d5e96f92a346c84d74ff7a0697e664695" version = 2 upgrade-capability = "0x331c41b3587619223c8ccf44b2aa9ad683fae7b536d6b5ed96fc94fe9a8d4278" -toolchain-version = "1.80.1" +toolchain-version = "1.83.1" build-config = { flavor = "sui", edition = "2024.alpha" } 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) } } 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..8858229 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,10 @@ 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::deep_price::{asset_is_base, deep_per_asset}; +use deepbook::pool::{Self, Pool}; use deepbook::pool_tests::{ setup_test, setup_pool_with_default_fees, @@ -13,6 +14,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::{ @@ -26,6 +28,7 @@ use deeptrade_core::treasury; use deeptrade_core::update_multisig_config_tests::setup_with_initialized_config; use multisig::multisig_test_utils::get_test_multisig_address; use pyth::price_info::{Self, PriceInfoObject}; +use std::option::is_some; use std::unit_test::assert_eq; use sui::clock; use sui::coin; @@ -401,3 +404,499 @@ 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); +} + +/// 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 { + 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); +} + +/// 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); + { + 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..a4be64a 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,230 @@ 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); +} + +/// 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.