Skip to content
Open
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
1 change: 1 addition & 0 deletions .claude/rules/move.md
Original file line number Diff line number Diff line change
Expand Up @@ -193,6 +193,7 @@ Then call as `self.id.exists_(key)`, `self.id.add(key, value)`, `self.id.borrow(
- Dynamic allocation resize is live-market-only. Settled, pending-settlement, or compacted markets should not grow or shrink; settled cleanup should happen through compaction.
- Predict sells binary (digital) contracts — European cash-or-nothing range digitals — not a separate spot contract plus an external debt overlay. A contract's live value is its range probability value minus its deterministic floor value, floored at zero; 1x orders are the special case where the floor is zero.
- Terminology: docs use options/structured-product vocabulary (canonical glossary: `packages/predict/docs/glossary.md`). Mint-economics identifiers are `entry_value`, `net_premium`, `financed_amount`, `min_net_premium` (and the `OrderMinted.net_premium` event field). The `floor_*` family (`floor_shares`, `floor_index`, `terminal_floor_index`, `floor_amount`) is the payoff primitive — never rename it to financing/debt vocabulary; the glossary bridges the two. The EWMA congestion surcharge keeps core's `penalty` vocabulary in code.
- For Predict mint slippage, spell out what the bound controls. `mint`'s `max_cost` caps the all-in manager withdrawal (`net_premium + trading_fee + builder_fee + EWMA penalty`), while fixed-premium flows cap only `net_premium` and use `min_quantity` as the slippage guard. `OrderMinted` emits payment components separately, not a total. If the check is just summing local payment components, keep the sum in the assert instead of adding a one-use helper.
- Leveraged Predict economics are part of the contract terms. Leverage changes the deterministic floor schedule of the contract over time, so pricing, NAV, payout, and settlement accounting should model one contract with a time-varying floor rather than bolting on separate leverage-specific scans when the value can be derived from indexed contract terms.
- Contract floors should track only the atomic values needed by each index. NAV uses aggregate floor shares with the current floor index. Payout backing uses exact terminal payout plus a conservative static max-live backing payout so live backing can be read without scanning or passing a clock.
- An index may aggregate raw order atoms or derived terms. If it aggregates derived terms, removal must subtract bit-equal what insertion added, so the term derivation must be a single owned function (the canonical evaluator, `strike_exposure_config::index_terms`) called by mint insert, remove, reinsert, and any settlement recompute — never re-implemented per call site. The evaluator must stay pure (snapshotted config plus caller atoms only; no clock, no oracle, no policy asserts — mint admission stays in the mint-only wrapper), every atom it reads must round-trip losslessly through the packed order ID (a lossy repack of `quantity`, `floor_shares`, or `opened_at_ms` is an accounting bug, not a precision nit), and the config fields it reads (`terminal_floor_index`, floor-curve inputs) must never gain live-expiry setters. Re-calling the evaluator or one of its sub-primitives is free and safe; re-expressing its formula anywhere is the violation. Flow policy (e.g. mint admission) validates its bounds *before* evaluating terms, so the evaluator's own aborts always mean a broken atom round-trip, never bad flow input.
Expand Down
2 changes: 1 addition & 1 deletion packages/predict/docs/concepts/fees-and-rebates.md
Original file line number Diff line number Diff line change
Expand Up @@ -176,7 +176,7 @@ Cash routing at trade time:
| Congestion surcharge | add-on / withheld | expiry cash surplus | No | No |
| Trading-loss rebate | funded from trading fees | reserved on-chain; resolved/paid off-chain | (drawn from reserve) | No |

At **mint**, the trader's withdrawal is `net_premium + trading_fee + builder_fee + congestion_surcharge`. At **live redeem**, the trading fee, builder fee, and surcharge are withheld from the gross redeem amount, each capped so the payout cannot go negative (the trading fee is capped at the redeem amount, the builder fee at what remains after the fee, the surcharge at what remains after both). At **settled redeem**, the winning payout is paid in full with no per-trade fee; the trading-loss rebate is resolved off-chain rather than claimed on-chain.
At **mint**, the trader's withdrawal is `net_premium + trading_fee + builder_fee + congestion_surcharge`. The `mint` entrypoint's `max_cost` argument caps this full withdrawal; callers that accept any final cost can pass `std::u64::max_value!()`. The `mint_with_premium` entrypoint instead fixes the `net_premium` budget and pays trading fees, builder fees, and congestion surcharge on top. At **live redeem**, the trading fee, builder fee, and surcharge are withheld from the gross redeem amount, each capped so the payout cannot go negative (the trading fee is capped at the redeem amount, the builder fee at what remains after the fee, the surcharge at what remains after both). At **settled redeem**, the winning payout is paid in full with no per-trade fee; the trading-loss rebate is resolved off-chain rather than claimed on-chain.

## Related reading

Expand Down
4 changes: 3 additions & 1 deletion packages/predict/docs/concepts/markets-and-positions.md
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,9 @@ stateDiagram-v2

### Mint

`mint` creates a live position. It requires: the package version allowed for the market, per-market minting not paused, global trading enabled, no pool valuation in progress, a valid `PredictTradeProof`, current canonical Propbook feeds with a fresh surface, and enough expiry cash to back the post-mint payout liability plus rebate reserve. Leveraged mints additionally must satisfy leverage-tier policy, sit above the liquidation threshold at entry, and keep the order's terminal floor strictly below `quantity × liquidation_ltv`. The flow takes the `(lower_tick, higher_tick)` pair, quotes the entry range probability, derives the net premium and floor terms, allocates an `Order` (assigning the next expiry-local sequence), inserts it into the strike-exposure and liquidation indexes, and settles payment (net premium + trading fee + optional builder fee + EWMA congestion penalty). It emits **`OrderMinted`** and returns the order ID. Mint gating (surface freshness, mint pause, range validity) connects to [pricing and oracles](./pricing-and-oracles.md).
`mint` creates a live position. It requires: the package version allowed for the market, per-market minting not paused, global trading enabled, no pool valuation in progress, a valid `PredictTradeProof`, current canonical Propbook feeds with a fresh surface, and enough expiry cash to back the post-mint payout liability plus rebate reserve. Leveraged mints additionally must satisfy leverage-tier policy, sit above the liquidation threshold at entry, and keep the order's terminal floor strictly below `quantity × liquidation_ltv`. The flow takes the `(lower_tick, higher_tick)` pair, quotes the entry range probability, derives the net premium and floor terms, allocates an `Order` (assigning the next expiry-local sequence), inserts it into the strike-exposure and liquidation indexes, and settles payment (net premium + trading fee + optional builder fee + EWMA congestion penalty). Its `max_cost` argument is an all-in slippage cap on that payment; pass `std::u64::max_value!()` for no cap. It emits **`OrderMinted`** and returns the order ID. Mint gating (surface freshness, mint pause, range validity) connects to [pricing and oracles](./pricing-and-oracles.md).

`mint_with_premium` is the fixed-premium variant. Instead of fixing `quantity`, the caller fixes the net premium budget; the market computes the largest lot-rounded quantity whose `net_premium` fits that budget, then aborts if it is below `min_quantity`. Trading fee, optional builder fee, and EWMA congestion penalty are still charged on top of the premium budget.

### Live redeem (full, or partial as cancel-and-replace)

Expand Down
55 changes: 48 additions & 7 deletions packages/predict/sources/config/strike_exposure_config.move
Original file line number Diff line number Diff line change
Expand Up @@ -158,17 +158,15 @@ public(package) fun trading_fee(
math::mul(config.fee_rate(expiry_ms, probability, timestamp_ms), quantity)
}

/// Assert mint price, leverage, and net-premium policy and return derived mint economics.
///
/// Returns `(net_premium, financed_amount)`.
public(package) fun assert_mint_admission_policy(
/// Assert that a mint quote clears ask-price bounds and uses a leverage tier
/// allowed at the quoted entry probability.
public(package) fun assert_mint_price_and_leverage_policy(
config: &StrikeExposureConfig,
expiry_ms: u64,
opened_at_ms: u64,
entry_probability: u64,
quantity: u64,
leverage: u64,
): (u64, u64) {
) {
let fee_rate = config.fee_rate(expiry_ms, entry_probability, opened_at_ms);
let execution_price = entry_probability + fee_rate;
assert!(
Expand All @@ -189,9 +187,52 @@ public(package) fun assert_mint_admission_policy(
} else if (entry_probability < constants::leverage_two_x_max_price_threshold!()) {
assert!(leverage <= LEVERAGE_TWO_X, EInvalidLeverageTier);
};
}

/// Return the trader-paid net premium from an already-computed entry value and
/// selected premium leverage.
public(package) fun mint_net_premium(entry_value: u64, leverage: u64): u64 {
math::div(entry_value, leverage)
}

/// Return the largest raw quantity whose derived net premium is at most `net_premium`.
public(package) fun max_quantity_for_net_premium(
entry_probability: u64,
net_premium: u64,
leverage: u64,
): u64 {
if (entry_probability == 0 || net_premium == 0) return 0;

let scaling = math::float_scaling!() as u128;
let max_entry_value = (((net_premium as u128) + 1) * (leverage as u128) - 1) / scaling;
let max_quantity = ((max_entry_value + 1) * scaling - 1) / (entry_probability as u128);
if (max_quantity > (std::u64::max_value!() as u128)) {
std::u64::max_value!()
} else {
max_quantity as u64
}
}

/// Assert mint price, leverage, and net-premium policy and return derived mint economics.
///
/// Returns `(net_premium, financed_amount)`.
public(package) fun assert_mint_admission_policy(
config: &StrikeExposureConfig,
expiry_ms: u64,
opened_at_ms: u64,
entry_probability: u64,
quantity: u64,
leverage: u64,
): (u64, u64) {
config.assert_mint_price_and_leverage_policy(
expiry_ms,
opened_at_ms,
entry_probability,
leverage,
);

let entry_value = math::mul(entry_probability, quantity);
let net_premium = math::div(entry_value, leverage);
let net_premium = mint_net_premium(entry_value, leverage);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

any specific reason why we want to extract this div as a function?

assert!(net_premium >= constants::min_net_premium!(), ENetPremiumBelowMinimum);
let financed_amount = entry_value - net_premium;

Expand Down
162 changes: 159 additions & 3 deletions packages/predict/sources/expiry_market.move
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,8 @@ use deepbook_predict::{
predict_manager::{PredictManager, PredictTradeProof},
pricing,
protocol_config::ProtocolConfig,
strike_exposure::{Self, StrikeExposure}
strike_exposure::{Self, StrikeExposure},
strike_exposure_config
};
use dusdc::dusdc::DUSDC;
use fixed_math::math;
Expand All @@ -42,6 +43,7 @@ const EFullCloseRequired: u64 = 5;
const EProofRequiredForLiveRedeem: u64 = 6;
const EWrongPythFeed: u64 = 7;
const EMintCostAboveMax: u64 = 8;
const EMintQuantityBelowMin: u64 = 9;

/// Per-expiry market state.
public struct ExpiryMarket has key {
Expand Down Expand Up @@ -229,6 +231,47 @@ public fun mint(
)
}

/// Mint the largest lot-rounded live position whose net premium fits inside
/// `premium`, aborting if that quantity is below `min_quantity`.
///
/// Fees are charged on top of the selected premium by the normal mint payment
/// path. Any unspent premium dust remains in the manager because order quantity
/// must be an integer number of `position_lot_size` lots.
public fun mint_with_premium(
market: &mut ExpiryMarket,
manager: &mut PredictManager,
proof: &PredictTradeProof,
config: &ProtocolConfig,
propbook_registry: &OracleRegistry,
pyth: &PythFeed,
bs: &BlockScholesFeed,
root: &AccumulatorRoot,
lower_tick: u64,
higher_tick: u64,
premium: u64,
min_quantity: u64,
leverage: u64,
clock: &Clock,
ctx: &mut TxContext,
): u256 {
manager.settle<DUSDC>(root, ctx);
market.mint_internal_with_premium(
manager,
proof,
config,
propbook_registry,
pyth,
bs,
lower_tick,
higher_tick,
premium,
min_quantity,
leverage,
clock,
ctx,
)
}

/// Redeem an order you hold trade authority over (the manager owner or a
/// `PredictTradeCap` holder), authorized by a `PredictTradeProof`. Works in any
/// order state: a live order is priced and closed (partial or full); a settled
Expand Down Expand Up @@ -679,6 +722,76 @@ public(package) fun mint_internal(
clock: &Clock,
ctx: &mut TxContext,
): u256 {
let pricer = market.prepare_mint(manager, config, propbook_registry, pyth, bs, clock, ctx);
market.ewma.update(config.ewma_config(), clock, ctx);
market.mint_prepared_fixed_quantity(
manager,
proof,
config,
&pricer,
lower_tick,
higher_tick,
quantity,
leverage,
max_cost,
clock,
ctx,
)
}

/// Mint the largest lot-rounded quantity that fits inside a fixed net premium.
public(package) fun mint_internal_with_premium(
market: &mut ExpiryMarket,
manager: &mut PredictManager,
proof: &PredictTradeProof,
config: &ProtocolConfig,
propbook_registry: &OracleRegistry,
pyth: &PythFeed,
bs: &BlockScholesFeed,
lower_tick: u64,
higher_tick: u64,
premium: u64,
min_quantity: u64,
leverage: u64,
clock: &Clock,
ctx: &mut TxContext,
): u256 {
let pricer = market.prepare_mint(manager, config, propbook_registry, pyth, bs, clock, ctx);
market.ewma.update(config.ewma_config(), clock, ctx);
let quantity = market.max_mint_quantity_for_premium(
&pricer,
lower_tick,
higher_tick,
premium,
leverage,
clock,
);
assert!(quantity >= min_quantity, EMintQuantityBelowMin);
market.mint_prepared_fixed_quantity(
manager,
proof,
config,
&pricer,
lower_tick,
higher_tick,
quantity,
leverage,
std::u64::max_value!(),
clock,
ctx,
)
}

fun prepare_mint(
market: &mut ExpiryMarket,
manager: &mut PredictManager,
config: &ProtocolConfig,
propbook_registry: &OracleRegistry,
pyth: &PythFeed,
bs: &BlockScholesFeed,
clock: &Clock,
ctx: &TxContext,
): pricing::Pricer {
market.assert_version_allowed();
assert!(!market.mint_paused, EMintPaused);
config.assert_trading_allowed();
Expand All @@ -692,11 +805,27 @@ public(package) fun mint_internal(
config.trade_liquidation_budget(),
clock,
);
pricer
}

fun mint_prepared_fixed_quantity(
market: &mut ExpiryMarket,
manager: &mut PredictManager,
proof: &PredictTradeProof,
config: &ProtocolConfig,
pricer: &pricing::Pricer,
lower_tick: u64,
higher_tick: u64,
quantity: u64,
leverage: u64,
max_cost: u64,
clock: &Clock,
ctx: &mut TxContext,
): u256 {
let (minted_order, entry_probability, net_premium) = market
.strike_exposure
.allocate_mint_order(
&pricer,
pricer,
lower_tick,
higher_tick,
quantity,
Expand All @@ -707,7 +836,7 @@ public(package) fun mint_internal(
let fee_amount = config
.stake_config()
.fee_amount_after_discount(raw_fee_amount, manager.active_stake());
let penalty_amount = market.ewma_penalty(config.ewma_config(), quantity, clock, ctx);
let penalty_amount = market.ewma.penalty_fee(config.ewma_config(), quantity, ctx);
let builder_code_id = manager.builder_code_id();
let quoted_builder_fee_amount = builder_fee_amount(&builder_code_id, fee_amount, quantity);
assert!(
Expand Down Expand Up @@ -738,6 +867,33 @@ public(package) fun mint_internal(
minted_order.id()
}

fun max_mint_quantity_for_premium(
market: &ExpiryMarket,
pricer: &pricing::Pricer,
lower_tick: u64,
higher_tick: u64,
premium: u64,
leverage: u64,
clock: &Clock,
): u64 {
let entry_probability = market
.strike_exposure
.quote_mint_entry_probability(
pricer,
lower_tick,
higher_tick,
leverage,
clock,
);
let quantity = strike_exposure_config::max_quantity_for_net_premium(
entry_probability,
premium,
leverage,
);
let lots = (quantity / constants::position_lot_size!()).min(order::max_quantity_lots());
lots * constants::position_lot_size!()
}

fun redeem_live_internal(
market: &mut ExpiryMarket,
manager: &mut PredictManager,
Expand Down
4 changes: 4 additions & 0 deletions packages/predict/sources/order.move
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,10 @@ public(package) fun assert_valid_quantity(quantity: u64) {
assert!(quantity / lot_size <= U32_MASK as u64, EInvalidQuantity);
}

public(package) fun max_quantity_lots(): u64 {
U32_MASK as u64
}

public(package) fun is_leveraged(order: &Order): bool {
order.floor_shares() > 0
}
Expand Down
27 changes: 27 additions & 0 deletions packages/predict/sources/strike_exposure/strike_exposure.move
Original file line number Diff line number Diff line change
Expand Up @@ -259,6 +259,33 @@ public(package) fun allocate_mint_order(
(allocated_order, entry_probability, net_premium)
}

/// Quote immutable mint price terms without mutating the exposure book.
public(package) fun quote_mint_entry_probability(
exposure: &StrikeExposure,
pricer: &Pricer,
lower_tick: u64,
higher_tick: u64,
leverage: u64,
clock: &Clock,
): u64 {
let (lower, higher) = range_codec::strikes_from_ticks(
lower_tick,
higher_tick,
exposure.tick_size,
);
let entry_probability = pricer.range_price(lower, higher);
let opened_at_ms = clock.timestamp_ms();
exposure
.config
.assert_mint_price_and_leverage_policy(
exposure.expiry_ms,
opened_at_ms,
entry_probability,
leverage,
);
entry_probability
}

/// Close live indexed quantity and return redeem terms.
///
/// Returns `(resulting_order, redeem_amount, range_probability)`.
Expand Down
Loading
Loading