Skip to content

Commit 5744ec3

Browse files
sdelocursoragent
andauthored
Block same-timestamp Predict mint and redeem (DBU-472) (#1085)
* Block same-timestamp Predict mint and redeem Record each position's open time (clock.timestamp_ms()) at mint and reject a live redeem whose timestamp equals it. A single transaction reads one Clock, so this closes the atomic mint -> oracle-update -> redeem arbitrage that would round-trip a freshly minted order against a price the same tx pushed. The open time is carried forward across partial-close replacements, so seasoned positions stay closable in multiple steps. DPU-472 Co-authored-by: Cursor <cursoragent@cursor.com> * Unit-test the same-timestamp redeem guard Move the open-time comparison into a factual predict_account assertion (assert_not_opened_at, owning the open-time state) so the abort path is covered by running unit tests instead of only the disabled flow test: the same-timestamp abort (ESameTimestampRedeem), the missing-position abort, and the pass case that reads back the stored open time. Co-authored-by: Cursor <cursoragent@cursor.com> * Keep redeem-timestamp policy in expiry_market predict_account is position state only: expose a position_opened_at_ms getter and move the same-timestamp redeem assertion back into expiry_market::redeem_live_internal (EMintRedeemSameTimestamp), where the redeem flow owns the policy. The getter keeps its missing-position unit coverage; the abort path is covered by the disabled flow test. Co-authored-by: Cursor <cursoragent@cursor.com> --------- Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 542280e commit 5744ec3

4 files changed

Lines changed: 284 additions & 14 deletions

File tree

packages/predict/sources/expiry_market.move

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ const EMintQuantityBelowMin: u64 = 6;
4848
const EWrongPricer: u64 = 7;
4949
const EReferenceTickObservationMissing: u64 = 8;
5050
const EReferenceTickTimestampMismatch: u64 = 9;
51+
const EMintRedeemSameTimestamp: u64 = 10;
5152

5253
/// Per-expiry market state.
5354
public struct ExpiryMarket has key {
@@ -803,6 +804,7 @@ fun mint_prepared_exact_quantity(
803804
net_premium,
804805
fee_amount,
805806
penalty_amount,
807+
clock,
806808
ctx,
807809
);
808810
order_events::emit_order_minted(
@@ -857,6 +859,13 @@ fun redeem_live_internal(
857859
clock: &Clock,
858860
ctx: &mut TxContext,
859861
): Option<u256> {
862+
// Block an atomic mint -> oracle-update -> redeem: reject closing a position in
863+
// the same timestamp it was opened. A single transaction reads one `Clock`, so
864+
// equal timestamps mean the mint and redeem are in the same tx. The open time is
865+
// carried forward across partial closes, so seasoned positions stay closable.
866+
let opened_at_ms = predict_account::position_opened_at_ms(account, market.id(), order.id());
867+
assert!(clock.timestamp_ms() != opened_at_ms, EMintRedeemSameTimestamp);
868+
860869
let active_stake = predict_account::active_stake_mut(account, ctx);
861870
let position_root_id = predict_account::remove_position(
862871
account,
@@ -888,6 +897,7 @@ fun redeem_live_internal(
888897
market.id(),
889898
replacement_order_id,
890899
position_root_id,
900+
opened_at_ms,
891901
ctx,
892902
);
893903
option::some(replacement_order_id)
@@ -967,6 +977,7 @@ fun settle_mint_payment(
967977
net_premium: u64,
968978
fee_amount: u64,
969979
penalty_amount: u64,
980+
clock: &Clock,
970981
ctx: &mut TxContext,
971982
): (u64, u64) {
972983
let quantity = order.quantity();
@@ -976,7 +987,14 @@ fun settle_mint_payment(
976987
let trader_fee_amount = fee_amount - fee_subsidy_amount;
977988
let withdraw_amount = net_premium + trader_fee_amount + builder_fee_amount + penalty_amount;
978989

979-
predict_account::add_position(account, market.id(), order.id(), order.id(), ctx);
990+
predict_account::add_position(
991+
account,
992+
market.id(),
993+
order.id(),
994+
order.id(),
995+
clock.timestamp_ms(),
996+
ctx,
997+
);
980998
predict_account::record_gross_paid_to_expiry(account, market.id(), net_premium, ctx);
981999
let mut payment = account.withdraw<DUSDC>(withdraw_amount, ctx).into_balance();
9821000
let builder_fee_payment = payment.split(builder_fee_amount);

packages/predict/sources/predict_account.move

Lines changed: 31 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,17 @@ public struct PositionKey has copy, drop, store {
3535
order_id: u256,
3636
}
3737

38+
/// Per-position state stored under a `PositionKey`.
39+
public struct Position has store {
40+
/// Root order ID, carried forward unchanged across partial-close replacements.
41+
root_id: u256,
42+
/// On-chain time (`clock.timestamp_ms()`) the position was opened, carried
43+
/// forward unchanged across partial-close replacements. A live redeem in the
44+
/// same timestamp is rejected, blocking an atomic mint -> oracle-update ->
45+
/// redeem in one transaction.
46+
opened_at_ms: u64,
47+
}
48+
3849
/// Aggregate trading cash flow and open-position count for one expiry market.
3950
public struct ExpiryTradingSummary has store {
4051
open_position_count: u64,
@@ -45,9 +56,8 @@ public struct ExpiryTradingSummary has store {
4556

4657
/// Predict's per-account state, attached to an `Account` under `PredictApp`.
4758
public struct PredictData has store {
48-
/// Open positions scoped by expiry market; the value is the position's root
49-
/// order ID, carried forward unchanged across partial-close replacements.
50-
positions: Table<PositionKey, u256>,
59+
/// Open positions scoped by expiry market.
60+
positions: Table<PositionKey, Position>,
5161
/// Per-expiry aggregate trading cash flows and open position count.
5262
expiry_summaries: Table<ID, ExpiryTradingSummary>,
5363
/// 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 {
146156
registry.generate_auth_as_app<PredictApp>(permit<PredictApp>())
147157
}
148158

159+
/// Return the on-chain time (`clock.timestamp_ms()`) a held position was opened.
160+
/// Carried forward unchanged across partial-close replacements.
161+
public(package) fun position_opened_at_ms(
162+
account: &Account,
163+
expiry_market_id: ID,
164+
order_id: u256,
165+
): u64 {
166+
let d = data(account);
167+
let key = position_key(expiry_market_id, order_id);
168+
assert!(d.positions.contains(key), EPositionNotFound);
169+
d.positions[key].opened_at_ms
170+
}
171+
149172
/// Add an order position keyed to its root order ID. At mint the root equals the
150173
/// order's own ID; a partial-close replacement passes the parent's root forward.
174+
/// `opened_at_ms` is the original open time, also carried forward unchanged.
151175
public(package) fun add_position(
152176
account: &mut Account,
153177
expiry_market_id: ID,
154178
order_id: u256,
155179
position_root_id: u256,
180+
opened_at_ms: u64,
156181
ctx: &mut TxContext,
157182
) {
158183
let d = data_mut(account, ctx);
159184
let key = position_key(expiry_market_id, order_id);
160185
assert!(!d.positions.contains(key), EPositionAlreadyExists);
161-
d.positions.add(key, position_root_id);
186+
d.positions.add(key, Position { root_id: position_root_id, opened_at_ms });
162187
let summary = d.summary_mut(expiry_market_id);
163188
summary.open_position_count = summary.open_position_count + 1;
164189
}
@@ -173,11 +198,11 @@ public(package) fun remove_position(
173198
let d = data_mut(account, ctx);
174199
let key = position_key(expiry_market_id, order_id);
175200
assert!(d.positions.contains(key), EPositionNotFound);
176-
let position_root_id = d.positions.remove(key);
201+
let Position { root_id, opened_at_ms: _ } = d.positions.remove(key);
177202
let summary = d.summary_mut(expiry_market_id);
178203
assert!(summary.open_position_count > 0, EInsufficientPosition);
179204
summary.open_position_count = summary.open_position_count - 1;
180-
position_root_id
205+
root_id
181206
}
182207

183208
/// Record pool trading fees paid by this account for one expiry market.
Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,157 @@
1+
// Copyright (c) Mysten Labs, Inc.
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
/// Flow coverage for the same-timestamp mint -> redeem guard: a live order cannot
5+
/// be redeemed in the timestamp it was opened (the lever that would let one
6+
/// transaction mint, push the oracle, and redeem the freshly minted order against
7+
/// the price it just pushed), but is redeemable once the clock advances.
8+
#[test_only]
9+
module deepbook_predict::mint_redeem_guard_tests;
10+
11+
use deepbook_predict::{constants, expiry_market, flow_test_helpers as helpers, test_constants};
12+
use std::unit_test::assert_eq;
13+
14+
/// Lot-aligned position size minted in both tests.
15+
const QUANTITY: u64 = 840_000_000;
16+
/// 1x leverage in 1e9 fixed point: no floor, so no liquidation interaction.
17+
const LEVERAGE_ONE_X: u64 = 1_000_000_000;
18+
/// One second past the fixture's `now_ms()` open time — distinct timestamp, still
19+
/// inside the oracle freshness window.
20+
const REDEEM_MS: u64 = 121_000;
21+
const REDEEM_SOURCE_TS: u64 = 120_000;
22+
23+
#[test, expected_failure(abort_code = expiry_market::EMintRedeemSameTimestamp)]
24+
fun redeem_in_mint_timestamp_aborts() {
25+
let (mut fx, expiry_id, trader) = helpers::setup_live_market(
26+
test_constants::default_expiry_ms(),
27+
test_constants::default_live_price(),
28+
);
29+
fx.scenario_mut().next_tx(test_constants::alice());
30+
let (
31+
pyth,
32+
bs_spot,
33+
bs_forward,
34+
bs_svi,
35+
oracle_registry,
36+
_vault,
37+
mut market,
38+
config,
39+
) = fx.take_market(expiry_id);
40+
let mut wrapper = fx.take_account(&trader);
41+
let root = fx.take_root();
42+
43+
let order = fx.mint(
44+
&config,
45+
&oracle_registry,
46+
&mut wrapper,
47+
&root,
48+
&mut market,
49+
&pyth,
50+
&bs_spot,
51+
&bs_forward,
52+
&bs_svi,
53+
helpers::strike_tick(),
54+
constants::pos_inf_tick!(),
55+
QUANTITY,
56+
LEVERAGE_ONE_X,
57+
);
58+
59+
// Same fixture clock as the mint: the guard must reject this redeem.
60+
fx.redeem(
61+
&config,
62+
&oracle_registry,
63+
&mut wrapper,
64+
&root,
65+
&mut market,
66+
&pyth,
67+
&bs_spot,
68+
&bs_forward,
69+
&bs_svi,
70+
order,
71+
QUANTITY,
72+
);
73+
74+
abort 999
75+
}
76+
77+
#[test]
78+
fun redeem_after_clock_advances_succeeds() {
79+
let (mut fx, expiry_id, trader) = helpers::setup_live_market(
80+
test_constants::default_expiry_ms(),
81+
test_constants::default_live_price(),
82+
);
83+
fx.scenario_mut().next_tx(test_constants::alice());
84+
let (
85+
mut pyth,
86+
mut bs_spot,
87+
mut bs_forward,
88+
mut bs_svi,
89+
oracle_registry,
90+
vault,
91+
mut market,
92+
config,
93+
) = fx.take_market(expiry_id);
94+
let mut wrapper = fx.take_account(&trader);
95+
let root = fx.take_root();
96+
97+
let order = fx.mint(
98+
&config,
99+
&oracle_registry,
100+
&mut wrapper,
101+
&root,
102+
&mut market,
103+
&pyth,
104+
&bs_spot,
105+
&bs_forward,
106+
&bs_svi,
107+
helpers::strike_tick(),
108+
constants::pos_inf_tick!(),
109+
QUANTITY,
110+
LEVERAGE_ONE_X,
111+
);
112+
assert!(helpers::has_position(&wrapper, expiry_id, order));
113+
114+
// Advance to a later timestamp and re-seed a fresh live oracle, then a full
115+
// close goes through and clears the position.
116+
fx.set_clock_for_testing(REDEEM_MS);
117+
fx.prepare_live_oracle_at(
118+
&market,
119+
&mut pyth,
120+
&mut bs_spot,
121+
&mut bs_forward,
122+
&mut bs_svi,
123+
test_constants::default_live_price(),
124+
REDEEM_SOURCE_TS,
125+
);
126+
127+
let (closed, replacement) = fx.redeem(
128+
&config,
129+
&oracle_registry,
130+
&mut wrapper,
131+
&root,
132+
&mut market,
133+
&pyth,
134+
&bs_spot,
135+
&bs_forward,
136+
&bs_svi,
137+
order,
138+
QUANTITY,
139+
);
140+
141+
assert_eq!(closed, order);
142+
assert!(replacement.is_none());
143+
assert!(!helpers::has_position(&wrapper, expiry_id, order));
144+
145+
helpers::return_account(wrapper, root);
146+
helpers::return_market(
147+
pyth,
148+
bs_spot,
149+
bs_forward,
150+
bs_svi,
151+
oracle_registry,
152+
vault,
153+
market,
154+
config,
155+
);
156+
fx.finish();
157+
}

0 commit comments

Comments
 (0)