Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 19 additions & 1 deletion packages/predict/sources/expiry_market.move
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -803,6 +804,7 @@ fun mint_prepared_exact_quantity(
net_premium,
fee_amount,
penalty_amount,
clock,
ctx,
);
order_events::emit_order_minted(
Expand Down Expand Up @@ -857,6 +859,13 @@ fun redeem_live_internal(
clock: &Clock,
ctx: &mut TxContext,
): Option<u256> {
// 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,
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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();
Expand All @@ -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<DUSDC>(withdraw_amount, ctx).into_balance();
let builder_fee_payment = payment.split(builder_fee_amount);
Expand Down
37 changes: 31 additions & 6 deletions packages/predict/sources/predict_account.move
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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<PositionKey, u256>,
/// Open positions scoped by expiry market.
positions: Table<PositionKey, Position>,
/// Per-expiry aggregate trading cash flows and open position count.
expiry_summaries: Table<ID, ExpiryTradingSummary>,
/// DEEP staked and active for trading benefits, in raw units. Custody is
Expand Down Expand Up @@ -146,19 +156,34 @@ public(package) fun generate_auth_as_app(registry: &AccountRegistry): Auth {
registry.generate_auth_as_app<PredictApp>(permit<PredictApp>())
}

/// 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;
}
Expand All @@ -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.
Expand Down
157 changes: 157 additions & 0 deletions packages/predict/tests/flows/mint_redeem_guard_tests.move.disabled
Original file line number Diff line number Diff line change
@@ -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();
}
Loading
Loading