diff --git a/packages/predict/sources/expiry_market.move b/packages/predict/sources/expiry_market.move index cf06810c2..5964c2feb 100644 --- a/packages/predict/sources/expiry_market.move +++ b/packages/predict/sources/expiry_market.move @@ -48,6 +48,7 @@ const EMintQuantityBelowMin: u64 = 6; const EWrongPricer: u64 = 7; const EReferenceTickObservationMissing: u64 = 8; const EReferenceTickTimestampMismatch: u64 = 9; +const EMintRedeemSameTimestamp: u64 = 10; /// Per-expiry market state. public struct ExpiryMarket has key { @@ -803,6 +804,7 @@ fun mint_prepared_exact_quantity( net_premium, fee_amount, penalty_amount, + clock, ctx, ); order_events::emit_order_minted( @@ -857,6 +859,13 @@ fun redeem_live_internal( clock: &Clock, ctx: &mut TxContext, ): Option { + // Block an atomic mint -> oracle-update -> redeem: reject closing a position in + // the same timestamp it was opened. A single transaction reads one `Clock`, so + // equal timestamps mean the mint and redeem are in the same tx. The open time is + // carried forward across partial closes, so seasoned positions stay closable. + let opened_at_ms = predict_account::position_opened_at_ms(account, market.id(), order.id()); + assert!(clock.timestamp_ms() != opened_at_ms, EMintRedeemSameTimestamp); + let active_stake = predict_account::active_stake_mut(account, ctx); let position_root_id = predict_account::remove_position( account, @@ -888,6 +897,7 @@ fun redeem_live_internal( market.id(), replacement_order_id, position_root_id, + opened_at_ms, ctx, ); option::some(replacement_order_id) @@ -967,6 +977,7 @@ fun settle_mint_payment( net_premium: u64, fee_amount: u64, penalty_amount: u64, + clock: &Clock, ctx: &mut TxContext, ): (u64, u64) { let quantity = order.quantity(); @@ -976,7 +987,14 @@ fun settle_mint_payment( let trader_fee_amount = fee_amount - fee_subsidy_amount; let withdraw_amount = net_premium + trader_fee_amount + builder_fee_amount + penalty_amount; - predict_account::add_position(account, market.id(), order.id(), order.id(), ctx); + predict_account::add_position( + account, + market.id(), + order.id(), + order.id(), + clock.timestamp_ms(), + ctx, + ); predict_account::record_gross_paid_to_expiry(account, market.id(), net_premium, ctx); let mut payment = account.withdraw(withdraw_amount, ctx).into_balance(); let builder_fee_payment = payment.split(builder_fee_amount); diff --git a/packages/predict/sources/predict_account.move b/packages/predict/sources/predict_account.move index e4e5011fe..53a530a43 100644 --- a/packages/predict/sources/predict_account.move +++ b/packages/predict/sources/predict_account.move @@ -35,6 +35,17 @@ public struct PositionKey has copy, drop, store { order_id: u256, } +/// Per-position state stored under a `PositionKey`. +public struct Position has store { + /// Root order ID, carried forward unchanged across partial-close replacements. + root_id: u256, + /// On-chain time (`clock.timestamp_ms()`) the position was opened, carried + /// forward unchanged across partial-close replacements. A live redeem in the + /// same timestamp is rejected, blocking an atomic mint -> oracle-update -> + /// redeem in one transaction. + opened_at_ms: u64, +} + /// Aggregate trading cash flow and open-position count for one expiry market. public struct ExpiryTradingSummary has store { open_position_count: u64, @@ -45,9 +56,8 @@ public struct ExpiryTradingSummary has store { /// Predict's per-account state, attached to an `Account` under `PredictApp`. public struct PredictData has store { - /// Open positions scoped by expiry market; the value is the position's root - /// order ID, carried forward unchanged across partial-close replacements. - positions: Table, + /// Open positions scoped by expiry market. + positions: Table, /// Per-expiry aggregate trading cash flows and open position count. expiry_summaries: Table, /// DEEP staked and active for trading benefits, in raw units. Custody is @@ -146,19 +156,34 @@ public(package) fun generate_auth_as_app(registry: &AccountRegistry): Auth { registry.generate_auth_as_app(permit()) } +/// Return the on-chain time (`clock.timestamp_ms()`) a held position was opened. +/// Carried forward unchanged across partial-close replacements. +public(package) fun position_opened_at_ms( + account: &Account, + expiry_market_id: ID, + order_id: u256, +): u64 { + let d = data(account); + let key = position_key(expiry_market_id, order_id); + assert!(d.positions.contains(key), EPositionNotFound); + d.positions[key].opened_at_ms +} + /// Add an order position keyed to its root order ID. At mint the root equals the /// order's own ID; a partial-close replacement passes the parent's root forward. +/// `opened_at_ms` is the original open time, also carried forward unchanged. public(package) fun add_position( account: &mut Account, expiry_market_id: ID, order_id: u256, position_root_id: u256, + opened_at_ms: u64, ctx: &mut TxContext, ) { let d = data_mut(account, ctx); let key = position_key(expiry_market_id, order_id); assert!(!d.positions.contains(key), EPositionAlreadyExists); - d.positions.add(key, position_root_id); + d.positions.add(key, Position { root_id: position_root_id, opened_at_ms }); let summary = d.summary_mut(expiry_market_id); summary.open_position_count = summary.open_position_count + 1; } @@ -173,11 +198,11 @@ public(package) fun remove_position( let d = data_mut(account, ctx); let key = position_key(expiry_market_id, order_id); assert!(d.positions.contains(key), EPositionNotFound); - let position_root_id = d.positions.remove(key); + let Position { root_id, opened_at_ms: _ } = d.positions.remove(key); let summary = d.summary_mut(expiry_market_id); assert!(summary.open_position_count > 0, EInsufficientPosition); summary.open_position_count = summary.open_position_count - 1; - position_root_id + root_id } /// Record pool trading fees paid by this account for one expiry market. diff --git a/packages/predict/tests/flows/mint_redeem_guard_tests.move.disabled b/packages/predict/tests/flows/mint_redeem_guard_tests.move.disabled new file mode 100644 index 000000000..32e5e23b2 --- /dev/null +++ b/packages/predict/tests/flows/mint_redeem_guard_tests.move.disabled @@ -0,0 +1,157 @@ +// Copyright (c) Mysten Labs, Inc. +// SPDX-License-Identifier: Apache-2.0 + +/// Flow coverage for the same-timestamp mint -> redeem guard: a live order cannot +/// be redeemed in the timestamp it was opened (the lever that would let one +/// transaction mint, push the oracle, and redeem the freshly minted order against +/// the price it just pushed), but is redeemable once the clock advances. +#[test_only] +module deepbook_predict::mint_redeem_guard_tests; + +use deepbook_predict::{constants, expiry_market, flow_test_helpers as helpers, test_constants}; +use std::unit_test::assert_eq; + +/// Lot-aligned position size minted in both tests. +const QUANTITY: u64 = 840_000_000; +/// 1x leverage in 1e9 fixed point: no floor, so no liquidation interaction. +const LEVERAGE_ONE_X: u64 = 1_000_000_000; +/// One second past the fixture's `now_ms()` open time — distinct timestamp, still +/// inside the oracle freshness window. +const REDEEM_MS: u64 = 121_000; +const REDEEM_SOURCE_TS: u64 = 120_000; + +#[test, expected_failure(abort_code = expiry_market::EMintRedeemSameTimestamp)] +fun redeem_in_mint_timestamp_aborts() { + let (mut fx, expiry_id, trader) = helpers::setup_live_market( + test_constants::default_expiry_ms(), + test_constants::default_live_price(), + ); + fx.scenario_mut().next_tx(test_constants::alice()); + let ( + pyth, + bs_spot, + bs_forward, + bs_svi, + oracle_registry, + _vault, + mut market, + config, + ) = fx.take_market(expiry_id); + let mut wrapper = fx.take_account(&trader); + let root = fx.take_root(); + + let order = fx.mint( + &config, + &oracle_registry, + &mut wrapper, + &root, + &mut market, + &pyth, + &bs_spot, + &bs_forward, + &bs_svi, + helpers::strike_tick(), + constants::pos_inf_tick!(), + QUANTITY, + LEVERAGE_ONE_X, + ); + + // Same fixture clock as the mint: the guard must reject this redeem. + fx.redeem( + &config, + &oracle_registry, + &mut wrapper, + &root, + &mut market, + &pyth, + &bs_spot, + &bs_forward, + &bs_svi, + order, + QUANTITY, + ); + + abort 999 +} + +#[test] +fun redeem_after_clock_advances_succeeds() { + let (mut fx, expiry_id, trader) = helpers::setup_live_market( + test_constants::default_expiry_ms(), + test_constants::default_live_price(), + ); + fx.scenario_mut().next_tx(test_constants::alice()); + let ( + mut pyth, + mut bs_spot, + mut bs_forward, + mut bs_svi, + oracle_registry, + vault, + mut market, + config, + ) = fx.take_market(expiry_id); + let mut wrapper = fx.take_account(&trader); + let root = fx.take_root(); + + let order = fx.mint( + &config, + &oracle_registry, + &mut wrapper, + &root, + &mut market, + &pyth, + &bs_spot, + &bs_forward, + &bs_svi, + helpers::strike_tick(), + constants::pos_inf_tick!(), + QUANTITY, + LEVERAGE_ONE_X, + ); + assert!(helpers::has_position(&wrapper, expiry_id, order)); + + // Advance to a later timestamp and re-seed a fresh live oracle, then a full + // close goes through and clears the position. + fx.set_clock_for_testing(REDEEM_MS); + fx.prepare_live_oracle_at( + &market, + &mut pyth, + &mut bs_spot, + &mut bs_forward, + &mut bs_svi, + test_constants::default_live_price(), + REDEEM_SOURCE_TS, + ); + + let (closed, replacement) = fx.redeem( + &config, + &oracle_registry, + &mut wrapper, + &root, + &mut market, + &pyth, + &bs_spot, + &bs_forward, + &bs_svi, + order, + QUANTITY, + ); + + assert_eq!(closed, order); + assert!(replacement.is_none()); + assert!(!helpers::has_position(&wrapper, expiry_id, order)); + + helpers::return_account(wrapper, root); + helpers::return_market( + pyth, + bs_spot, + bs_forward, + bs_svi, + oracle_registry, + vault, + market, + config, + ); + fx.finish(); +} diff --git a/packages/predict/tests/predict_account_tests.move b/packages/predict/tests/predict_account_tests.move index 1ce4dbc48..44ab647ee 100644 --- a/packages/predict/tests/predict_account_tests.move +++ b/packages/predict/tests/predict_account_tests.move @@ -26,6 +26,8 @@ const ORDER_A: u256 = 42; const ORDER_B: u256 = 99; const ROOT_A: u256 = 42; const ROOT_PARENT: u256 = 7; +const OPENED_AT_MS: u64 = 1_700_000_000_000; +const REPLACEMENT_OPENED_AT_MS: u64 = 1_700_000_500_000; const STAKE_1: u64 = 1_000; const STAKE_2: u64 = 2_500; @@ -45,13 +47,35 @@ fun add_then_remove_position_round_trips_root_id() { let (mut scenario, mut wrapper) = new_account(); let account = wrapper.load_account_mut(auth(&mut scenario)); // At mint the root equals the order's own id. - predict_account::add_position(account, eid(EXPIRY_A), ORDER_A, ORDER_A, scenario.ctx()); + predict_account::add_position( + account, + eid(EXPIRY_A), + ORDER_A, + ORDER_A, + OPENED_AT_MS, + scenario.ctx(), + ); assert!(predict_account::has_position(account, eid(EXPIRY_A), ORDER_A)); assert_eq!(predict_account::expiry_position_count(account, eid(EXPIRY_A)), 1); + assert_eq!( + predict_account::position_opened_at_ms(account, eid(EXPIRY_A), ORDER_A), + OPENED_AT_MS, + ); - // A partial-close replacement carries the parent root forward. - predict_account::add_position(account, eid(EXPIRY_A), ORDER_B, ROOT_PARENT, scenario.ctx()); + // A partial-close replacement carries the parent root (and its own open time) forward. + predict_account::add_position( + account, + eid(EXPIRY_A), + ORDER_B, + ROOT_PARENT, + REPLACEMENT_OPENED_AT_MS, + scenario.ctx(), + ); assert_eq!(predict_account::expiry_position_count(account, eid(EXPIRY_A)), 2); + assert_eq!( + predict_account::position_opened_at_ms(account, eid(EXPIRY_A), ORDER_B), + REPLACEMENT_OPENED_AT_MS, + ); let root = predict_account::remove_position(account, eid(EXPIRY_A), ORDER_B, scenario.ctx()); assert_eq!(root, ROOT_PARENT); @@ -64,8 +88,22 @@ fun add_then_remove_position_round_trips_root_id() { fun positions_are_scoped_per_expiry() { let (mut scenario, mut wrapper) = new_account(); let account = wrapper.load_account_mut(auth(&mut scenario)); - predict_account::add_position(account, eid(EXPIRY_A), ORDER_A, ORDER_A, scenario.ctx()); - predict_account::add_position(account, eid(EXPIRY_B), ORDER_A, ORDER_A, scenario.ctx()); + predict_account::add_position( + account, + eid(EXPIRY_A), + ORDER_A, + ORDER_A, + OPENED_AT_MS, + scenario.ctx(), + ); + predict_account::add_position( + account, + eid(EXPIRY_B), + ORDER_A, + ORDER_A, + OPENED_AT_MS, + scenario.ctx(), + ); // The same order id in two markets is two distinct positions. assert!(predict_account::has_position(account, eid(EXPIRY_A), ORDER_A)); @@ -80,8 +118,22 @@ fun positions_are_scoped_per_expiry() { fun add_duplicate_position_aborts() { let (mut scenario, mut wrapper) = new_account(); let account = wrapper.load_account_mut(auth(&mut scenario)); - predict_account::add_position(account, eid(EXPIRY_A), ORDER_A, ORDER_A, scenario.ctx()); - predict_account::add_position(account, eid(EXPIRY_A), ORDER_A, ROOT_A, scenario.ctx()); + predict_account::add_position( + account, + eid(EXPIRY_A), + ORDER_A, + ORDER_A, + OPENED_AT_MS, + scenario.ctx(), + ); + predict_account::add_position( + account, + eid(EXPIRY_A), + ORDER_A, + ROOT_A, + OPENED_AT_MS, + scenario.ctx(), + ); abort 999 } @@ -93,6 +145,24 @@ fun remove_unknown_position_aborts() { abort 999 } +#[test, expected_failure(abort_code = predict_account::EPositionNotFound)] +fun opened_at_ms_unknown_position_aborts() { + let (mut scenario, mut wrapper) = new_account(); + let account = wrapper.load_account_mut(auth(&mut scenario)); + // Attach the slot with one position so the getter reaches the missing-row guard + // rather than the empty-app-data path. + predict_account::add_position( + account, + eid(EXPIRY_A), + ORDER_A, + ORDER_A, + OPENED_AT_MS, + scenario.ctx(), + ); + predict_account::position_opened_at_ms(account, eid(EXPIRY_A), ORDER_B); + abort 999 +} + // === Trading fees === #[test]