diff --git a/packages/deepbook_core_account/sources/deepbook_core_account.move b/packages/deepbook_core_account/sources/deepbook_core_account.move index 8bb2e3b5e..9f2638f0b 100644 --- a/packages/deepbook_core_account/sources/deepbook_core_account.move +++ b/packages/deepbook_core_account/sources/deepbook_core_account.move @@ -9,6 +9,7 @@ module deepbook_core_account::deepbook_core_account; use account::{account::{Account, AccountWrapper, Auth}, account_registry::AccountRegistry}; use deepbook::{ account::Account as CoreAccount, + constants, order::Order, order_info::OrderInfo, pool::Pool, @@ -18,6 +19,8 @@ use deepbook_core_account::account_data; use sui::{accumulator::AccumulatorRoot, clock::Clock, vec_set::{Self as vec_set, VecSet}}; use token::deep::DEEP; +const EMarketOrderPriceLimitExceeded: u64 = 0; + /// Return whether this account's embedded manager has DeepBook core pool account data. public fun pool_account_exists( pool: &Pool, @@ -149,7 +152,9 @@ public fun place_limit_order( info } -/// Place a DeepBook market order using account custody. +/// Place a DeepBook market order using account custody. `price_limit` is the +/// maximum average execution price for a bid and the minimum for an ask, in +/// DeepBook's fixed-point price units. Orders with no fills skip the price check. public fun place_market_order( pool: &mut Pool, deepbook_registry: &Registry, @@ -158,6 +163,7 @@ public fun place_market_order( client_order_id: u64, self_matching_option: u8, quantity: u64, + price_limit: u64, is_bid: bool, pay_with_deep: bool, root: &AccumulatorRoot, @@ -189,6 +195,15 @@ public fun place_market_order( clock, ctx, ); + if (info.executed_quantity() > 0) { + let quote_value = + (info.cumulative_quote_quantity() as u128) * constants::float_scaling_u128(); + let limit_value = (info.executed_quantity() as u128) * (price_limit as u128); + assert!( + if (is_bid) quote_value <= limit_value else quote_value >= limit_value, + EMarketOrderPriceLimitExceeded, + ); + }; let (base_swept, quote_swept, deep_swept) = account_data::sweep_all( d, ctx, diff --git a/packages/deepbook_core_account/tests/deepbook_core_account_tests.move b/packages/deepbook_core_account/tests/deepbook_core_account_tests.move index 38cec1f16..80b30ed87 100644 --- a/packages/deepbook_core_account/tests/deepbook_core_account_tests.move +++ b/packages/deepbook_core_account/tests/deepbook_core_account_tests.move @@ -37,6 +37,12 @@ const BOB: address = @0xB0B; const BASE_AMOUNT: u64 = 5_000_000_000; const RESTING_ASK_CLIENT_ORDER_ID: u64 = 11; const FILLING_BID_CLIENT_ORDER_ID: u64 = 12; +const MARKET_ORDER_MAKER_CLIENT_ORDER_ID: u64 = 21; +const ACCOUNT_MARKET_ORDER_CLIENT_ORDER_ID: u64 = 22; +const ONE_PRICE_UNIT: u64 = 1; +const PARTIAL_FILL_MULTIPLIER: u64 = 2; +const EUnexpectedSuccess: u64 = 999; +const NO_AVAILABLE_QUANTITY: u64 = 0; const ZERO_BALANCE: u64 = 0; const NO_OPEN_ORDER_COUNT: u64 = 0; const SINGLE_OPEN_ORDER_COUNT: u64 = 1; @@ -221,6 +227,48 @@ fun funding_round_trip_sweeps_back_to_account() { scenario.end(); } +#[test] +fun bid_market_order_at_price_limit_succeeds() { + run_market_order_at_price_limit( + true, + constants::min_size(), + constants::min_size(), + ); +} + +#[test, expected_failure(abort_code = dca::EMarketOrderPriceLimitExceeded)] +fun bid_market_order_above_price_limit_aborts() { + run_market_order_beyond_price_limit(true) +} + +#[test] +fun ask_market_order_at_price_limit_succeeds() { + run_market_order_at_price_limit( + false, + constants::min_size(), + constants::min_size(), + ); +} + +#[test, expected_failure(abort_code = dca::EMarketOrderPriceLimitExceeded)] +fun ask_market_order_below_price_limit_aborts() { + run_market_order_beyond_price_limit(false) +} + +#[test] +fun partial_fill_uses_executed_quantity_for_price_limit() { + run_market_order_at_price_limit( + true, + constants::min_size(), + PARTIAL_FILL_MULTIPLIER * constants::min_size(), + ); +} + +#[test] +fun no_fill_skips_price_limit_and_preserves_custody() { + run_market_order_at_price_limit(false, NO_AVAILABLE_QUANTITY, constants::min_size()); +} + #[test] fun withdraw_settled_amounts_sweeps_maker_fill_to_account() { let (mut scenario, registry_id, pool_id, mut wrapper) = setup_whitelisted_account(); @@ -428,6 +476,142 @@ fun setup_whitelisted_account(): (Scenario, ID, ID, AccountWrapper) { setup_account_with_pool(true) } +fun run_market_order_at_price_limit( + is_bid: bool, + available_quantity: u64, + requested_quantity: u64, +) { + let execution_price = constants::float_scaling(); + let (mut scenario, registry_id, pool_id, mut wrapper) = if ( + available_quantity == NO_AVAILABLE_QUANTITY + ) { + let (mut scenario, registry_id, pool_id, wrapper) = setup_whitelisted_account(); + authorize_core_app(&mut scenario, registry_id); + (scenario, registry_id, pool_id, wrapper) + } else { + setup_market_order_account( + !is_bid, + execution_price, + available_quantity, + ) + }; + + scenario.next_tx(ALICE); + let registry = scenario.take_shared_by_id(registry_id); + let mut pool = scenario.take_shared_by_id>(pool_id); + let root = scenario.take_shared(); + let clock = scenario.take_shared(); + let info = dca::place_market_order( + &mut pool, + ®istry, + &mut wrapper, + account::generate_auth(scenario.ctx()), + ACCOUNT_MARKET_ORDER_CLIENT_ORDER_ID, + constants::self_matching_allowed(), + requested_quantity, + execution_price, + is_bid, + true, + &root, + &clock, + scenario.ctx(), + ); + let account = wrapper.load_account(); + let expected_base_balance = if (is_bid) { + BASE_AMOUNT + available_quantity + } else { + BASE_AMOUNT - available_quantity + }; + let expected_quote_balance = if (is_bid) { + BASE_AMOUNT - available_quantity + } else { + BASE_AMOUNT + available_quantity + }; + + assert_eq!(info.original_quantity(), requested_quantity); + assert_eq!(info.executed_quantity(), available_quantity); + assert_eq!(info.cumulative_quote_quantity(), available_quantity); + assert_eq!(account.balance(&root, &clock), expected_base_balance); + assert_eq!(account.balance(&root, &clock), expected_quote_balance); + if (available_quantity == NO_AVAILABLE_QUANTITY) { + assert_eq!(account.balance(&root, &clock), BASE_AMOUNT); + }; + assert_eq!(account_data::balance_manager_balance(account), ZERO_BALANCE); + assert_eq!(account_data::balance_manager_balance(account), ZERO_BALANCE); + assert_eq!(account_data::balance_manager_balance(account), ZERO_BALANCE); + + return_shared(clock); + return_shared(root); + return_shared(pool); + return_shared(registry); + destroy(wrapper); + scenario.end(); +} + +fun run_market_order_beyond_price_limit(is_bid: bool) { + let execution_price = constants::float_scaling(); + let trade_amount = constants::min_size(); + let price_limit = if (is_bid) { + execution_price - ONE_PRICE_UNIT + } else { + execution_price + ONE_PRICE_UNIT + }; + let (mut scenario, registry_id, pool_id, mut wrapper) = setup_market_order_account( + !is_bid, + execution_price, + trade_amount, + ); + + scenario.next_tx(ALICE); + let registry = scenario.take_shared_by_id(registry_id); + let mut pool = scenario.take_shared_by_id>(pool_id); + let root = scenario.take_shared(); + let clock = scenario.take_shared(); + let _ = dca::place_market_order( + &mut pool, + ®istry, + &mut wrapper, + account::generate_auth(scenario.ctx()), + ACCOUNT_MARKET_ORDER_CLIENT_ORDER_ID, + constants::self_matching_allowed(), + trade_amount, + price_limit, + is_bid, + true, + &root, + &clock, + scenario.ctx(), + ); + abort EUnexpectedSuccess +} + +fun setup_market_order_account( + maker_is_bid: bool, + maker_price: u64, + maker_quantity: u64, +): (Scenario, ID, ID, AccountWrapper) { + let (mut scenario, registry_id, pool_id, wrapper) = setup_whitelisted_account(); + authorize_core_app(&mut scenario, registry_id); + let maker_manager_id = create_balance_manager_with_funds( + &mut scenario, + BOB, + BASE_AMOUNT, + ); + let maker = place_manager_limit_order( + &mut scenario, + BOB, + pool_id, + maker_manager_id, + MARKET_ORDER_MAKER_CLIENT_ORDER_ID, + maker_price, + maker_quantity, + maker_is_bid, + ); + assert_eq!(maker.status(), constants::live()); + assert!(maker.order_inserted()); + (scenario, registry_id, pool_id, wrapper) +} + fun setup_account_with_pool(whitelisted_pool: bool): (Scenario, ID, ID, AccountWrapper) { let mut scenario = test::begin(ADMIN); @@ -504,6 +688,41 @@ fun place_manager_market_order( info } +fun place_manager_limit_order( + scenario: &mut Scenario, + trader: address, + pool_id: ID, + balance_manager_id: ID, + client_order_id: u64, + price: u64, + quantity: u64, + is_bid: bool, +): OrderInfo { + scenario.next_tx(trader); + let mut pool = scenario.take_shared_by_id>(pool_id); + let clock = scenario.take_shared(); + let mut manager = scenario.take_shared_by_id(balance_manager_id); + let proof = manager.generate_proof_as_owner(scenario.ctx()); + let info = pool.place_limit_order( + &mut manager, + &proof, + client_order_id, + constants::no_restriction(), + constants::self_matching_allowed(), + price, + quantity, + is_bid, + true, + constants::max_u64(), + &clock, + scenario.ctx(), + ); + return_shared(clock); + return_shared(pool); + return_shared(manager); + info +} + fun authorize_core_app(scenario: &mut Scenario, registry_id: ID) { scenario.next_tx(ADMIN); let admin_cap = deepbook::registry::get_admin_cap_for_testing(scenario.ctx());