From 13d17bcf8b7ffcca812e260162871b90d35f67d9 Mon Sep 17 00:00:00 2001 From: Pei Deng Date: Wed, 10 Jun 2026 17:35:14 -0400 Subject: [PATCH] Introduce predict oracle lifecycle cap --- packages/predict/simulations/src/runtime.ts | 18 ++- packages/predict/simulations/src/sim.ts | 14 ++- packages/predict/sources/expiry_market.move | 6 +- .../predict/sources/oracle/market_oracle.move | 106 ++++++++++++++-- packages/predict/sources/registry.move | 4 +- .../predict/tests/expiry_market_tests.move | 116 +++++++++++++++++- .../tests/flows/plp_rebate_flow_tests.move | 22 +++- .../market_oracle_settlement_tests.move | 73 ++++++----- .../tests/oracle/oracle_cap_tests.move | 106 +++++++++++++++- packages/predict/tests/pool/plp_tests.move | 22 +++- .../predict/tests/registry_create_tests.move | 71 +++++++++-- 11 files changed, 483 insertions(+), 75 deletions(-) diff --git a/packages/predict/simulations/src/runtime.ts b/packages/predict/simulations/src/runtime.ts index 5f6478bf6..412f51232 100644 --- a/packages/predict/simulations/src/runtime.ts +++ b/packages/predict/simulations/src/runtime.ts @@ -420,6 +420,20 @@ export function createMarketOracleWriterCapTx(recipient: string): Transaction { return tx; } +export function createMarketOracleLifecycleCapTx( + recipient: string, + oracleWriterCapId: string, + feedId: number, +): Transaction { + const tx = new Transaction(); + const cap = tx.moveCall({ + target: target("market_oracle", "create_lifecycle_cap"), + arguments: [tx.object(oracleWriterCapId), tx.pure.u32(feedId)], + }); + tx.transferObjects([cap], tx.pure.address(recipient)); + return tx; +} + export function createPythSourceTx( feedId: number, tickSize: bigint, @@ -463,7 +477,7 @@ export async function seedPythSourceAndCreateExpiryMarketTx(params: { poolVaultId: string; protocolConfigId: string; pythSourceId: string; - oracleCapId: string; + oracleLifecycleCapId: string; expiry: bigint; spot: bigint; }): Promise { @@ -482,7 +496,7 @@ export async function seedPythSourceAndCreateExpiryMarketTx(params: { tx.object(params.poolVaultId), tx.object(params.protocolConfigId), tx.object(params.pythSourceId), - tx.object(params.oracleCapId), + tx.object(params.oracleLifecycleCapId), tx.pure.u64(params.expiry), tx.object(CLOCK_ID), ], diff --git a/packages/predict/simulations/src/sim.ts b/packages/predict/simulations/src/sim.ts index cb6c6e9c4..57ab0d62e 100644 --- a/packages/predict/simulations/src/sim.ts +++ b/packages/predict/simulations/src/sim.ts @@ -30,6 +30,7 @@ import { address, createManagerTx, createMarketOracleWriterCapTx, + createMarketOracleLifecycleCapTx, createPythSourceTx, depositToManagerTx, deriveManagerId, @@ -780,6 +781,17 @@ async function setupSimulation( const oracleCapId: string = oracleCapChange.objectId; console.log(`[${ts()}] OracleWriterCap: ${oracleCapId}`); + result = await executeAndWait( + createMarketOracleLifecycleCapTx(address, oracleCapId, 1), + "create_oracle_lifecycle_cap", + ); + const oracleLifecycleCapChange = result.objectChanges.find( + (change: any) => + change.type === "created" && change.objectType.includes("MarketOracleLifecycleCap"), + ); + const oracleLifecycleCapId: string = oracleLifecycleCapChange.objectId; + console.log(`[${ts()}] OracleLifecycleCap: ${oracleLifecycleCapId}`); + result = await executeAndWait( createPythSourceTx(1, ORACLE_TICK_SIZE, expiryFeeWindowMs, expiryFeeMaxMultiplier), "create_pyth_source", @@ -805,7 +817,7 @@ async function setupSimulation( poolVaultId, protocolConfigId, pythSourceId, - oracleCapId, + oracleLifecycleCapId, expiry: EXPIRY_MS, spot: initialPythSpot, }), diff --git a/packages/predict/sources/expiry_market.move b/packages/predict/sources/expiry_market.move index 7687760d9..33f05ebc6 100644 --- a/packages/predict/sources/expiry_market.move +++ b/packages/predict/sources/expiry_market.move @@ -18,7 +18,7 @@ use deepbook_predict::{ ewma::{Self, EwmaState}, ewma_config::EwmaConfig, expiry_cash::{Self, ExpiryCash}, - market_oracle::{MarketOracle, MarketOracleWriterCap}, + market_oracle::{MarketOracle, MarketOracleLifecycleCap}, order::{Self, Order}, order_events, predict_manager::{PredictManager, PredictTradeProof}, @@ -281,12 +281,12 @@ public fun compact_storage( market: &mut ExpiryMarket, config: &ProtocolConfig, market_oracle: &MarketOracle, - cap: &MarketOracleWriterCap, + cap: &MarketOracleLifecycleCap, ) { market.assert_version_allowed(); config.assert_not_valuation_in_progress(); market.assert_market_oracle(market_oracle); - market_oracle.assert_authorized_writer_cap(cap); + market_oracle.assert_authorized_lifecycle_cap(cap); market.materialize_settled_liability(market_oracle); market.strike_exposure.destroy_live_indexes(); market.assert_cash_backing(); diff --git a/packages/predict/sources/oracle/market_oracle.move b/packages/predict/sources/oracle/market_oracle.move index 1f7043265..8d2fb85dc 100644 --- a/packages/predict/sources/oracle/market_oracle.move +++ b/packages/predict/sources/oracle/market_oracle.move @@ -41,6 +41,7 @@ const EPendingSettlement: u64 = 14; const EMarketNotSettled: u64 = 15; const EInvalidSettlementTimestamp: u64 = 16; const EPackageVersionDisabled: u64 = 17; +const EInvalidMarketOracleLifecycleCap: u64 = 18; const STATUS_ACTIVE: u8 = 1; const STATUS_PENDING_SETTLEMENT: u8 = 2; @@ -61,7 +62,7 @@ public struct SVIParams has copy, drop, store { /// Shared per-expiry oracle object storing live source data and settlement state. public struct MarketOracle has key { id: UID, - /// MarketOracleWriterCap IDs authorized to write Block Scholes data. + /// MarketOracleWriterCap IDs authorized for high-privilege oracle writes/config. authorized_writer_cap_ids: VecSet, /// Mirror of `ProtocolConfig.allowed_versions`; synced permissionlessly. allowed_versions: VecSet, @@ -87,11 +88,19 @@ public struct MarketOracle has key { settlement_update_timestamp_ms: u64, } -/// Capability authorized to write Block Scholes data and tune this oracle. +/// High-privilege capability authorized to write Block Scholes data and tune this oracle. public struct MarketOracleWriterCap has key, store { id: UID, } +/// Lifecycle capability authorized to create, settle, and compact markets without oracle writes. +public struct MarketOracleLifecycleCap has key, store { + id: UID, + writer_cap_id: ID, + pyth_lazer_feed_id: u32, + market_oracle_ids: VecSet, +} + // === Public Functions === /// Construct a Block Scholes SVI parameter set. @@ -114,6 +123,16 @@ public fun cap_id(cap: &MarketOracleWriterCap): ID { cap.id.to_inner() } +/// Return the MarketOracleLifecycleCap object ID. +public fun lifecycle_cap_id(cap: &MarketOracleLifecycleCap): ID { + cap.id.to_inner() +} + +/// Return the Pyth Lazer feed ID this lifecycle cap can create markets for. +public fun lifecycle_pyth_lazer_feed_id(cap: &MarketOracleLifecycleCap): u32 { + cap.pyth_lazer_feed_id +} + /// Return the Pyth source object bound to this oracle. public fun pyth_source_id(market: &MarketOracle): ID { market.pyth_source_id @@ -283,11 +302,11 @@ public fun settle_if_possible( market: &mut MarketOracle, config: &ProtocolConfig, pyth: &PythSource, - cap: &MarketOracleWriterCap, + cap: &MarketOracleLifecycleCap, clock: &Clock, ): bool { market.assert_version_allowed(); - market.assert_authorized_writer_cap(cap); + market.assert_authorized_lifecycle_cap(cap); config.assert_not_valuation_in_progress(); if (market.status(clock) != STATUS_PENDING_SETTLEMENT) return false; market.assert_pyth_source(pyth); @@ -371,18 +390,43 @@ public fun set_basis_bounds( market.emit_bounds_updated(); } -/// Create a new oracle writer capability. +/// Create a new high-privilege oracle writer capability. public fun create_writer_cap(_admin_cap: &AdminCap, ctx: &mut TxContext): MarketOracleWriterCap { MarketOracleWriterCap { id: object::new(ctx) } } +/// Create a new oracle lifecycle capability bound to one high-privilege oracle writer cap. +public fun create_lifecycle_cap( + oracle_writer_cap: &MarketOracleWriterCap, + pyth_lazer_feed_id: u32, + ctx: &mut TxContext, +): MarketOracleLifecycleCap { + MarketOracleLifecycleCap { + id: object::new(ctx), + writer_cap_id: oracle_writer_cap.cap_id(), + pyth_lazer_feed_id, + market_oracle_ids: vec_set::empty(), + } +} + /// Destroy a MarketOracleWriterCap the holder no longer needs. public fun destroy_writer_cap(cap: MarketOracleWriterCap) { let MarketOracleWriterCap { id } = cap; id.delete(); } -/// Authorize an additional cap to write this market oracle. +/// Destroy a MarketOracleLifecycleCap the holder no longer needs. +public fun destroy_lifecycle_cap(cap: MarketOracleLifecycleCap) { + let MarketOracleLifecycleCap { + id, + writer_cap_id: _, + pyth_lazer_feed_id: _, + market_oracle_ids: _, + } = cap; + id.delete(); +} + +/// Authorize an additional writer cap to write and tune this market oracle. public fun register_writer_cap( market: &mut MarketOracle, _admin_cap: &AdminCap, @@ -391,7 +435,7 @@ public fun register_writer_cap( market.register_writer_cap_internal(cap); } -/// Remove a cap from this market oracle's writer set. +/// Remove a writer cap from this market oracle's authorized writer set. public fun unregister_writer_cap(market: &mut MarketOracle, _admin_cap: &AdminCap, cap_id: ID) { market.unregister_writer_cap_internal(cap_id); } @@ -401,6 +445,32 @@ public fun self_unregister_writer_cap(market: &mut MarketOracle, cap: &MarketOra market.unregister_writer_cap_internal(cap.cap_id()); } +/// Authorize a lifecycle cap to settle and compact the paired expiry market. +public fun register_lifecycle_cap( + market: &MarketOracle, + _admin_cap: &AdminCap, + cap: &mut MarketOracleLifecycleCap, +) { + market.assert_version_allowed(); + let market_oracle_id = market.id(); + assert!(!cap.market_oracle_ids.contains(&market_oracle_id), EInvalidMarketOracleLifecycleCap); + cap.market_oracle_ids.insert(market_oracle_id); +} + +/// Remove a market oracle from this lifecycle cap's authorized set. +public fun unregister_lifecycle_cap( + cap: &mut MarketOracleLifecycleCap, + _admin_cap: &AdminCap, + market_oracle_id: ID, +) { + unregister_lifecycle_cap_internal(cap, market_oracle_id); +} + +/// Let a lifecycle cap holder remove one of its own market authorizations. +public fun self_unregister_lifecycle_cap(cap: &mut MarketOracleLifecycleCap, market_oracle_id: ID) { + unregister_lifecycle_cap_internal(cap, market_oracle_id); +} + // === Public-Package Functions === /// Overwrite this oracle's mirrored `allowed_versions`. The only authorized @@ -440,14 +510,14 @@ public(package) fun settlement_price(market: &MarketOracle): u64 { public(package) fun create_and_share( pyth: &PythSource, config: &MarketOracleConfig, - cap: &MarketOracleWriterCap, + cap: &mut MarketOracleLifecycleCap, expiry: u64, allowed_versions: VecSet, ctx: &mut TxContext, ): ID { - let cap_id = cap.cap_id(); + assert!(cap.pyth_lazer_feed_id == pyth.feed_id(), EInvalidMarketOracleLifecycleCap); let mut authorized_writer_cap_ids = vec_set::empty(); - authorized_writer_cap_ids.insert(cap_id); + authorized_writer_cap_ids.insert(cap.writer_cap_id); let market = MarketOracle { id: object::new(ctx), authorized_writer_cap_ids, @@ -479,6 +549,7 @@ public(package) fun create_and_share( }; let market_oracle_id = market.id(); + cap.market_oracle_ids.insert(market_oracle_id); transfer::share_object(market); market_oracle_id } @@ -506,7 +577,7 @@ public(package) fun assert_not_pending_settlement(market: &MarketOracle, clock: assert!(market.status(clock) != STATUS_PENDING_SETTLEMENT, EPendingSettlement); } -/// Abort unless the cap is authorized for this oracle. +/// Abort unless the writer cap is authorized for this oracle. public(package) fun assert_authorized_writer_cap( market: &MarketOracle, cap: &MarketOracleWriterCap, @@ -517,6 +588,14 @@ public(package) fun assert_authorized_writer_cap( ); } +/// Abort unless the lifecycle cap is authorized for this oracle. +public(package) fun assert_authorized_lifecycle_cap( + market: &MarketOracle, + cap: &MarketOracleLifecycleCap, +) { + assert!(cap.market_oracle_ids.contains(&market.id()), EInvalidMarketOracleLifecycleCap); +} + // === Private Functions === fun register_writer_cap_internal(market: &mut MarketOracle, cap: &MarketOracleWriterCap) { @@ -532,6 +611,11 @@ fun unregister_writer_cap_internal(market: &mut MarketOracle, cap_id: ID) { market.authorized_writer_cap_ids.remove(&cap_id); } +fun unregister_lifecycle_cap_internal(cap: &mut MarketOracleLifecycleCap, market_oracle_id: ID) { + assert!(cap.market_oracle_ids.contains(&market_oracle_id), EInvalidMarketOracleLifecycleCap); + cap.market_oracle_ids.remove(&market_oracle_id); +} + fun apply_block_scholes_prices( market: &mut MarketOracle, spot: u64, diff --git a/packages/predict/sources/registry.move b/packages/predict/sources/registry.move index 5183c45a8..0066669c1 100644 --- a/packages/predict/sources/registry.move +++ b/packages/predict/sources/registry.move @@ -17,7 +17,7 @@ use deepbook_predict::{ config_events, constants, expiry_market::{Self, ExpiryMarket}, - market_oracle::{Self, MarketOracle, MarketOracleWriterCap}, + market_oracle::{Self, MarketOracle, MarketOracleLifecycleCap}, plp::PoolVault, predict_manager::{Self, PredictDepositCap, PredictManager, PredictTradeCap, PredictWithdrawCap}, pricing, @@ -410,7 +410,7 @@ public fun create_expiry_market( pool_vault: &mut PoolVault, config: &ProtocolConfig, pyth: &PythSource, - cap: &MarketOracleWriterCap, + cap: &mut MarketOracleLifecycleCap, expiry: u64, clock: &Clock, ctx: &mut TxContext, diff --git a/packages/predict/tests/expiry_market_tests.move b/packages/predict/tests/expiry_market_tests.move index fafd8d81b..2da8e2ce7 100644 --- a/packages/predict/tests/expiry_market_tests.move +++ b/packages/predict/tests/expiry_market_tests.move @@ -9,12 +9,11 @@ use deepbook_predict::{ constants, expiry_market::{Self, ExpiryMarket}, i64, - market_oracle::{Self, MarketOracle, MarketOracleWriterCap}, + market_oracle::{Self, MarketOracle, MarketOracleWriterCap, MarketOracleLifecycleCap}, order, - predict_manager::PredictManager, protocol_config::{Self, ProtocolConfig}, pyth_source::{Self, PythSource}, - registry::{Self, Registry}, + registry, strike_grid, test_constants }; @@ -80,6 +79,12 @@ fun rebate_eligibility_offsets_fee_reserve_by_gross_profit() { &cap, scenario.ctx(), ); + let mut lifecycle_cap = market_oracle::create_lifecycle_cap( + &cap, + pyth.feed_id(), + scenario.ctx(), + ); + market_oracle::register_lifecycle_cap(&oracle, &admin_cap, &mut lifecycle_cap); let grid = strike_grid::new_centered(grid_center_spot(), TICK_SIZE); let expiry_id = expiry_market::create_and_share( &config, @@ -135,7 +140,7 @@ fun rebate_eligibility_offsets_fee_reserve_by_gross_profit() { scenario.next_epoch(test_constants::alice()); let mut market = scenario.take_shared_by_id(expiry_id); - settle_oracle(&mut oracle, &mut pyth, &config, &cap, &mut clock); + settle_oracle(&mut oracle, &mut pyth, &config, &lifecycle_cap, &mut clock); let balance_before_claim = manager.balance(); let residual_cash = market.claim_trading_loss_rebate( @@ -154,6 +159,7 @@ fun rebate_eligibility_offsets_fee_reserve_by_gross_profit() { destroy(manager); destroy(oracle); destroy(pyth); + market_oracle::destroy_lifecycle_cap(lifecycle_cap); market_oracle::destroy_writer_cap(cap); destroy(config); destroy(admin_cap); @@ -416,6 +422,106 @@ fun redeem_withholds_ewma_penalty_from_payout_on_gas_spike() { scenario.end(); } +#[test] +fun compact_storage_accepts_registered_lifecycle_cap() { + let mut scenario = test::begin(test_constants::alice()); + let (registry, admin_cap) = registry::new_for_testing(scenario.ctx()); + let config = protocol_config::new_for_testing(scenario.ctx()); + let cap = market_oracle::create_writer_cap(&admin_cap, scenario.ctx()); + let mut clock = clock::create_for_testing(scenario.ctx()); + clock.set_for_testing(NOW_MS); + let mut pyth = pyth_source::new_for_testing(scenario.ctx()); + let mut oracle = market_oracle::create_test_market_oracle_with_pyth( + &pyth, + EXPIRY_MS, + &cap, + scenario.ctx(), + ); + let mut lifecycle_cap = market_oracle::create_lifecycle_cap( + &cap, + pyth.feed_id(), + scenario.ctx(), + ); + market_oracle::register_lifecycle_cap(&oracle, &admin_cap, &mut lifecycle_cap); + let grid = strike_grid::new_centered(grid_center_spot(), TICK_SIZE); + let expiry_id = expiry_market::create_and_share( + &config, + vec_set::singleton(constants::current_version!()), + oracle.id(), + pyth.feed_id(), + EXPIRY_MS, + grid, + 0, + config_constants::default_expiry_fee_window_ms!(), + constants::float_scaling!(), + scenario.ctx(), + ); + + scenario.next_tx(test_constants::alice()); + let mut market = scenario.take_shared_by_id(expiry_id); + settle_oracle(&mut oracle, &mut pyth, &config, &lifecycle_cap, &mut clock); + market.compact_storage(&config, &oracle, &lifecycle_cap); + assert_eq!(market.payout_liability(), 0); + + return_shared(market); + destroy(oracle); + destroy(pyth); + market_oracle::destroy_lifecycle_cap(lifecycle_cap); + market_oracle::destroy_writer_cap(cap); + destroy(config); + destroy(admin_cap); + registry::destroy_registry_drop_for_testing(registry); + clock.destroy_for_testing(); + scenario.end(); +} + +#[test, expected_failure(abort_code = market_oracle::EInvalidMarketOracleLifecycleCap)] +fun compact_storage_rejects_unregistered_lifecycle_cap() { + let mut scenario = test::begin(test_constants::alice()); + let (_registry, admin_cap) = registry::new_for_testing(scenario.ctx()); + let config = protocol_config::new_for_testing(scenario.ctx()); + let cap = market_oracle::create_writer_cap(&admin_cap, scenario.ctx()); + let mut clock = clock::create_for_testing(scenario.ctx()); + clock.set_for_testing(NOW_MS); + let mut pyth = pyth_source::new_for_testing(scenario.ctx()); + let mut oracle = market_oracle::create_test_market_oracle_with_pyth( + &pyth, + EXPIRY_MS, + &cap, + scenario.ctx(), + ); + let unregistered_lifecycle_cap = market_oracle::create_lifecycle_cap( + &cap, + pyth.feed_id(), + scenario.ctx(), + ); + let grid = strike_grid::new_centered(grid_center_spot(), TICK_SIZE); + let expiry_id = expiry_market::create_and_share( + &config, + vec_set::singleton(constants::current_version!()), + oracle.id(), + pyth.feed_id(), + EXPIRY_MS, + grid, + 0, + config_constants::default_expiry_fee_window_ms!(), + constants::float_scaling!(), + scenario.ctx(), + ); + + scenario.next_tx(test_constants::alice()); + let mut market = scenario.take_shared_by_id(expiry_id); + let mut lifecycle_cap = market_oracle::create_lifecycle_cap( + &cap, + pyth.feed_id(), + scenario.ctx(), + ); + market_oracle::register_lifecycle_cap(&oracle, &admin_cap, &mut lifecycle_cap); + settle_oracle(&mut oracle, &mut pyth, &config, &lifecycle_cap, &mut clock); + market.compact_storage(&config, &oracle, &unregistered_lifecycle_cap); + abort 999 +} + /// Start the next transaction with `gas_price` so the per-market EWMA observes /// it. Only `gas_price` changes, so the reference gas price stays put and no /// epoch advance is required. @@ -459,7 +565,7 @@ fun settle_oracle( oracle: &mut MarketOracle, pyth: &mut PythSource, config: &ProtocolConfig, - cap: &MarketOracleWriterCap, + cap: &MarketOracleLifecycleCap, clock: &mut Clock, ) { let settlement_source_timestamp_ms = EXPIRY_MS + 1_000; diff --git a/packages/predict/tests/flows/plp_rebate_flow_tests.move b/packages/predict/tests/flows/plp_rebate_flow_tests.move index cc58e9bf9..6e8b7a37c 100644 --- a/packages/predict/tests/flows/plp_rebate_flow_tests.move +++ b/packages/predict/tests/flows/plp_rebate_flow_tests.move @@ -10,7 +10,7 @@ use deepbook_predict::{ constants::{Self, float_scaling as float}, expiry_market::{Self, ExpiryMarket}, i64, - market_oracle::{Self, MarketOracle, MarketOracleWriterCap}, + market_oracle::{Self, MarketOracle, MarketOracleWriterCap, MarketOracleLifecycleCap}, order, plp::{Self, PLP, PoolVault}, predict_manager::PredictManager, @@ -54,6 +54,7 @@ public struct Fixture { admin_cap: AdminCap, config: ProtocolConfig, cap: MarketOracleWriterCap, + lifecycle_cap: MarketOracleLifecycleCap, clock: Clock, vault_id: ID, pyth_id: ID, @@ -214,6 +215,11 @@ fun setup_pool_with_pyth(): Fixture { config.set_base_fee(&admin_cap, 1); config.set_min_ask_price(&admin_cap, 0); let cap = market_oracle::create_writer_cap(&admin_cap, scenario.ctx()); + let lifecycle_cap = market_oracle::create_lifecycle_cap( + &cap, + PYTH_FEED_ID, + scenario.ctx(), + ); let mut clock = clock::create_for_testing(scenario.ctx()); clock.set_for_testing(NOW_MS); @@ -257,6 +263,7 @@ fun setup_pool_with_pyth(): Fixture { admin_cap, config, cap, + lifecycle_cap, clock, vault_id, pyth_id, @@ -273,7 +280,7 @@ fun create_expiry(fixture: &mut Fixture, expiry: u64): (ID, ID) { &mut vault, &fixture.config, &pyth, - &fixture.cap, + &mut fixture.lifecycle_cap, expiry, &fixture.clock, fixture.scenario.ctx(), @@ -327,7 +334,14 @@ fun settle_oracle(fixture: &mut Fixture, oracle: &mut MarketOracle, pyth: &mut P settlement_source_timestamp_ms, settlement_update_timestamp_ms, ); - assert!(oracle.settle_if_possible(&fixture.config, pyth, &fixture.cap, &fixture.clock)); + assert!( + oracle.settle_if_possible( + &fixture.config, + pyth, + &fixture.lifecycle_cap, + &fixture.clock, + ), + ); } fun sync_expiry_for_testing( @@ -356,12 +370,14 @@ fun finish(fixture: Fixture) { admin_cap, config, cap, + lifecycle_cap, clock, vault_id: _, pyth_id: _, initial_plp, } = fixture; destroy(initial_plp); + market_oracle::destroy_lifecycle_cap(lifecycle_cap); market_oracle::destroy_writer_cap(cap); destroy(config); destroy(admin_cap); diff --git a/packages/predict/tests/oracle/market_oracle_settlement_tests.move b/packages/predict/tests/oracle/market_oracle_settlement_tests.move index 3eae1b498..bdacceea2 100644 --- a/packages/predict/tests/oracle/market_oracle_settlement_tests.move +++ b/packages/predict/tests/oracle/market_oracle_settlement_tests.move @@ -20,67 +20,67 @@ const FORWARD_AT_BASIS_1: u64 = 1_000_000_000_000; #[test] fun settle_if_possible_returns_false_when_active() { - let (mut market, config, cap, admin_cap, pyth, clock) = setup(ACTIVE_NOW_MS); + let (mut market, config, cap, lifecycle_cap, admin_cap, pyth, clock) = setup(ACTIVE_NOW_MS); - assert!(!market.settle_if_possible(&config, &pyth, &cap, &clock)); + assert!(!market.settle_if_possible(&config, &pyth, &lifecycle_cap, &clock)); assert!(!market.is_settled()); - cleanup(market, config, cap, admin_cap, pyth, clock); + cleanup(market, config, cap, lifecycle_cap, admin_cap, pyth, clock); } #[test] fun settle_if_possible_returns_false_when_no_valid_source() { // Pending settlement (clock > expiry) but neither Pyth nor BS has data. - let (mut market, config, cap, admin_cap, pyth, clock) = setup(NOW_MS); + let (mut market, config, cap, lifecycle_cap, admin_cap, pyth, clock) = setup(NOW_MS); assert_eq!(market.status(&clock), market_oracle::status_pending_settlement()); - assert!(!market.settle_if_possible(&config, &pyth, &cap, &clock)); + assert!(!market.settle_if_possible(&config, &pyth, &lifecycle_cap, &clock)); assert!(!market.is_settled()); - cleanup(market, config, cap, admin_cap, pyth, clock); + cleanup(market, config, cap, lifecycle_cap, admin_cap, pyth, clock); } // === settle_if_possible: Pyth path === #[test] fun settle_if_possible_uses_pyth_when_fresh() { - let (mut market, config, cap, admin_cap, mut pyth, clock) = setup(NOW_MS); + let (mut market, config, cap, lifecycle_cap, admin_cap, mut pyth, clock) = setup(NOW_MS); // Drive pyth state so its source_ts > expiry and freshness is within // settlement_freshness (3000ms default). pyth.set_state_for_testing(SPOT_1000, SETTLE_TS, NOW_MS); - assert!(market.settle_if_possible(&config, &pyth, &cap, &clock)); + assert!(market.settle_if_possible(&config, &pyth, &lifecycle_cap, &clock)); assert!(market.is_settled()); let raw = market.raw_settlement_price(); assert!(raw.is_some()); assert_eq!(*raw.borrow(), SPOT_1000); - cleanup(market, config, cap, admin_cap, pyth, clock); + cleanup(market, config, cap, lifecycle_cap, admin_cap, pyth, clock); } #[test] fun settle_if_possible_returns_false_when_pyth_stale() { // Pyth update too old relative to settlement_freshness (3000 ms default). - let (mut market, config, cap, admin_cap, mut pyth, clock) = setup(NOW_MS); + let (mut market, config, cap, lifecycle_cap, admin_cap, mut pyth, clock) = setup(NOW_MS); // freshness_timestamp = min(source_ts, update_ts) = 100_001. now - 100_001 // = 50_999 > 3_000, so Pyth fails the freshness check. pyth.set_state_for_testing(SPOT_1000, 100_001, 100_001); - assert!(!market.settle_if_possible(&config, &pyth, &cap, &clock)); + assert!(!market.settle_if_possible(&config, &pyth, &lifecycle_cap, &clock)); assert!(!market.is_settled()); - cleanup(market, config, cap, admin_cap, pyth, clock); + cleanup(market, config, cap, lifecycle_cap, admin_cap, pyth, clock); } #[test] fun settle_if_possible_returns_false_when_pyth_source_at_expiry() { // source_ts must be strictly greater than expiry, not equal. - let (mut market, config, cap, admin_cap, mut pyth, clock) = setup(NOW_MS); + let (mut market, config, cap, lifecycle_cap, admin_cap, mut pyth, clock) = setup(NOW_MS); pyth.set_state_for_testing(SPOT_1000, EXPIRY_MS, NOW_MS); - assert!(!market.settle_if_possible(&config, &pyth, &cap, &clock)); + assert!(!market.settle_if_possible(&config, &pyth, &lifecycle_cap, &clock)); - cleanup(market, config, cap, admin_cap, pyth, clock); + cleanup(market, config, cap, lifecycle_cap, admin_cap, pyth, clock); } // === settle_if_possible: Block Scholes fallback via update_block_scholes_prices === @@ -90,7 +90,7 @@ fun update_prices_at_pending_settles_via_block_scholes_when_pyth_empty() { // Pyth has no data, but a Block Scholes update lands after expiry with // source_ts > expiry. The internal settle_if_possible_internal at the end // of update_block_scholes_prices uses the BS fallback. - let (mut market, config, cap, admin_cap, pyth, clock) = setup(NOW_MS); + let (mut market, config, cap, lifecycle_cap, admin_cap, pyth, clock) = setup(NOW_MS); market.update_block_scholes_prices( &config, &pyth, @@ -106,13 +106,13 @@ fun update_prices_at_pending_settles_via_block_scholes_when_pyth_empty() { assert!(raw.is_some()); assert_eq!(*raw.borrow(), SPOT_1000); - cleanup(market, config, cap, admin_cap, pyth, clock); + cleanup(market, config, cap, lifecycle_cap, admin_cap, pyth, clock); } #[test] fun update_prices_at_pending_does_not_settle_when_source_ts_at_expiry() { // source_ts must be strictly greater than expiry to qualify as settlement data. - let (mut market, config, cap, admin_cap, pyth, clock) = setup(NOW_MS); + let (mut market, config, cap, lifecycle_cap, admin_cap, pyth, clock) = setup(NOW_MS); market.update_block_scholes_prices( &config, &pyth, @@ -125,16 +125,16 @@ fun update_prices_at_pending_does_not_settle_when_source_ts_at_expiry() { assert!(!market.is_settled()); - cleanup(market, config, cap, admin_cap, pyth, clock); + cleanup(market, config, cap, lifecycle_cap, admin_cap, pyth, clock); } // === update_block_scholes_prices aborts EMarketSettled after settle === #[test, expected_failure(abort_code = market_oracle::EMarketSettled)] fun update_prices_after_settle_aborts() { - let (mut market, config, cap, admin_cap, mut pyth, clock) = setup(NOW_MS); + let (mut market, config, cap, lifecycle_cap, admin_cap, mut pyth, clock) = setup(NOW_MS); pyth.set_state_for_testing(SPOT_1000, SETTLE_TS, NOW_MS); - market.settle_if_possible(&config, &pyth, &cap, &clock); + market.settle_if_possible(&config, &pyth, &lifecycle_cap, &clock); assert!(market.is_settled()); // Second price push is now an attempt to mutate a settled market. @@ -154,25 +154,25 @@ fun update_prices_after_settle_aborts() { #[test] fun assert_not_pending_settlement_passes_when_active() { - let (market, config, cap, admin_cap, pyth, clock) = setup(ACTIVE_NOW_MS); + let (market, config, cap, lifecycle_cap, admin_cap, pyth, clock) = setup(ACTIVE_NOW_MS); market.assert_not_pending_settlement(&clock); - cleanup(market, config, cap, admin_cap, pyth, clock); + cleanup(market, config, cap, lifecycle_cap, admin_cap, pyth, clock); } #[test] fun assert_not_pending_settlement_passes_when_settled() { - let (mut market, config, cap, admin_cap, mut pyth, clock) = setup(NOW_MS); + let (mut market, config, cap, lifecycle_cap, admin_cap, mut pyth, clock) = setup(NOW_MS); pyth.set_state_for_testing(SPOT_1000, SETTLE_TS, NOW_MS); - market.settle_if_possible(&config, &pyth, &cap, &clock); + market.settle_if_possible(&config, &pyth, &lifecycle_cap, &clock); market.assert_not_pending_settlement(&clock); - cleanup(market, config, cap, admin_cap, pyth, clock); + cleanup(market, config, cap, lifecycle_cap, admin_cap, pyth, clock); } #[test, expected_failure(abort_code = market_oracle::EPendingSettlement)] fun assert_not_pending_settlement_aborts_when_pending() { - let (market, config, cap, admin_cap, pyth, clock) = setup(NOW_MS); + let (market, config, cap, _lifecycle_cap, _admin_cap, _pyth, clock) = setup(NOW_MS); assert_eq!(market.status(&clock), market_oracle::status_pending_settlement()); market.assert_not_pending_settlement(&clock); @@ -183,20 +183,20 @@ fun assert_not_pending_settlement_aborts_when_pending() { #[test, expected_failure(abort_code = market_oracle::EMarketNotSettled)] fun settlement_price_on_unsettled_aborts() { - let (market, config, cap, admin_cap, pyth, clock) = setup(NOW_MS); + let (market, config, _cap, _lifecycle_cap, _admin_cap, _pyth, _clock) = setup(NOW_MS); let _ = market.settlement_price(); abort 999 } #[test] fun settlement_price_returns_settled_value() { - let (mut market, config, cap, admin_cap, mut pyth, clock) = setup(NOW_MS); + let (mut market, config, cap, lifecycle_cap, admin_cap, mut pyth, clock) = setup(NOW_MS); pyth.set_state_for_testing(SPOT_1000, SETTLE_TS, NOW_MS); - market.settle_if_possible(&config, &pyth, &cap, &clock); + market.settle_if_possible(&config, &pyth, &lifecycle_cap, &clock); assert_eq!(market.settlement_price(), SPOT_1000); - cleanup(market, config, cap, admin_cap, pyth, clock); + cleanup(market, config, cap, lifecycle_cap, admin_cap, pyth, clock); } // EInvalidSettlementTimestamp guards against a settled state where @@ -211,6 +211,7 @@ fun setup( market_oracle::MarketOracle, protocol_config::ProtocolConfig, market_oracle::MarketOracleWriterCap, + market_oracle::MarketOracleLifecycleCap, admin::AdminCap, pyth_source::PythSource, clock::Clock, @@ -220,22 +221,30 @@ fun setup( let cap = market_oracle::create_writer_cap(&admin_cap, ctx); let config = protocol_config::new_for_testing(ctx); let pyth = pyth_source::new_for_testing(ctx); + let mut lifecycle_cap = market_oracle::create_lifecycle_cap( + &cap, + pyth.feed_id(), + ctx, + ); let market = market_oracle::create_test_market_oracle_with_pyth(&pyth, EXPIRY_MS, &cap, ctx); + market_oracle::register_lifecycle_cap(&market, &admin_cap, &mut lifecycle_cap); let mut clock = clock::create_for_testing(ctx); clock.set_for_testing(now_ms); - (market, config, cap, admin_cap, pyth, clock) + (market, config, cap, lifecycle_cap, admin_cap, pyth, clock) } fun cleanup( market: market_oracle::MarketOracle, config: protocol_config::ProtocolConfig, cap: market_oracle::MarketOracleWriterCap, + lifecycle_cap: market_oracle::MarketOracleLifecycleCap, admin_cap: admin::AdminCap, pyth: pyth_source::PythSource, clock: clock::Clock, ) { destroy(market); destroy(config); + market_oracle::destroy_lifecycle_cap(lifecycle_cap); destroy(cap); destroy(admin_cap); destroy(pyth); diff --git a/packages/predict/tests/oracle/oracle_cap_tests.move b/packages/predict/tests/oracle/oracle_cap_tests.move index 0a5c23bc9..1baf3c3e0 100644 --- a/packages/predict/tests/oracle/oracle_cap_tests.move +++ b/packages/predict/tests/oracle/oracle_cap_tests.move @@ -7,7 +7,7 @@ module deepbook_predict::oracle_cap_tests; use deepbook_predict::{ admin, i64, - market_oracle::{Self, MarketOracle, MarketOracleWriterCap}, + market_oracle::{Self, MarketOracle, MarketOracleWriterCap, MarketOracleLifecycleCap}, protocol_config::{Self, ProtocolConfig} }; use std::unit_test::destroy; @@ -17,6 +17,7 @@ const NOW_MS: u64 = 10_000; const EXPIRY_MS: u64 = 100_000; const FIRST_SVI_SOURCE_TIMESTAMP_MS: u64 = 1_000; const SECOND_SVI_SOURCE_TIMESTAMP_MS: u64 = 2_000; +const PYTH_FEED_ID: u32 = 1; #[test] fun admin_can_create_multiple_market_oracle_caps() { @@ -31,6 +32,30 @@ fun admin_can_create_multiple_market_oracle_caps() { destroy(admin_cap); } +#[test] +fun writer_cap_can_create_multiple_market_oracle_lifecycle_caps() { + let ctx = &mut tx_context::dummy(); + let admin_cap = admin::create_admin_cap_for_testing(ctx); + let oracle_writer_cap = market_oracle::create_writer_cap(&admin_cap, ctx); + let cap_1 = market_oracle::create_lifecycle_cap( + &oracle_writer_cap, + PYTH_FEED_ID, + ctx, + ); + let cap_2 = market_oracle::create_lifecycle_cap( + &oracle_writer_cap, + PYTH_FEED_ID, + ctx, + ); + + assert!(cap_1.lifecycle_cap_id() != cap_2.lifecycle_cap_id()); + assert!(cap_1.lifecycle_pyth_lazer_feed_id() == PYTH_FEED_ID); + market_oracle::destroy_lifecycle_cap(cap_1); + market_oracle::destroy_lifecycle_cap(cap_2); + market_oracle::destroy_writer_cap(oracle_writer_cap); + destroy(admin_cap); +} + #[test] fun creator_cap_can_update_market_oracle() { let ctx = &mut tx_context::dummy(); @@ -39,7 +64,77 @@ fun creator_cap_can_update_market_oracle() { write_svi(&mut market, &config, &cap, FIRST_SVI_SOURCE_TIMESTAMP_MS, &clock); assert!(market.block_scholes_svi_source_timestamp_ms() == FIRST_SVI_SOURCE_TIMESTAMP_MS); - cleanup(market, config, vector[cap], admin_cap, clock); + cleanup(market, config, vector[cap], vector[], admin_cap, clock); +} + +#[test] +fun registered_lifecycle_cap_authorizes_market_lifecycle() { + let ctx = &mut tx_context::dummy(); + let (market, config, cap, admin_cap, clock) = setup(ctx); + let mut lifecycle_cap = market_oracle::create_lifecycle_cap( + &cap, + PYTH_FEED_ID, + ctx, + ); + + market_oracle::register_lifecycle_cap(&market, &admin_cap, &mut lifecycle_cap); + market.assert_authorized_lifecycle_cap(&lifecycle_cap); + + cleanup(market, config, vector[cap], vector[lifecycle_cap], admin_cap, clock); +} + +#[test, expected_failure(abort_code = market_oracle::EInvalidMarketOracleLifecycleCap)] +fun admin_unregistered_lifecycle_cap_loses_market_lifecycle_access() { + let ctx = &mut tx_context::dummy(); + let (market, _config, _cap, admin_cap, _clock) = setup(ctx); + let oracle_writer_cap = market_oracle::create_writer_cap(&admin_cap, ctx); + let mut lifecycle_cap = market_oracle::create_lifecycle_cap( + &oracle_writer_cap, + PYTH_FEED_ID, + ctx, + ); + let market_oracle_id = market.id(); + + market_oracle::register_lifecycle_cap(&market, &admin_cap, &mut lifecycle_cap); + market_oracle::unregister_lifecycle_cap( + &mut lifecycle_cap, + &admin_cap, + market_oracle_id, + ); + market.assert_authorized_lifecycle_cap(&lifecycle_cap); + abort 999 +} + +#[test, expected_failure(abort_code = market_oracle::EInvalidMarketOracleLifecycleCap)] +fun unregistered_lifecycle_cap_cannot_authorize_market_lifecycle() { + let ctx = &mut tx_context::dummy(); + let (market, _config, cap, _admin_cap, _clock) = setup(ctx); + let lifecycle_cap = market_oracle::create_lifecycle_cap( + &cap, + PYTH_FEED_ID, + ctx, + ); + + market.assert_authorized_lifecycle_cap(&lifecycle_cap); + abort 999 +} + +#[test, expected_failure(abort_code = market_oracle::EInvalidMarketOracleLifecycleCap)] +fun self_unregistered_lifecycle_cap_loses_market_lifecycle_access() { + let ctx = &mut tx_context::dummy(); + let (market, _config, _cap, admin_cap, _clock) = setup(ctx); + let oracle_writer_cap = market_oracle::create_writer_cap(&admin_cap, ctx); + let mut lifecycle_cap = market_oracle::create_lifecycle_cap( + &oracle_writer_cap, + PYTH_FEED_ID, + ctx, + ); + let market_oracle_id = market.id(); + + market_oracle::register_lifecycle_cap(&market, &admin_cap, &mut lifecycle_cap); + market_oracle::self_unregister_lifecycle_cap(&mut lifecycle_cap, market_oracle_id); + market.assert_authorized_lifecycle_cap(&lifecycle_cap); + abort 999 } #[test, expected_failure(abort_code = market_oracle::EInvalidMarketOracleWriterCap)] @@ -68,7 +163,7 @@ fun registered_cap_can_update_market_oracle() { write_svi(&mut market, &config, &cap_2, FIRST_SVI_SOURCE_TIMESTAMP_MS, &clock); assert!(market.block_scholes_svi_source_timestamp_ms() == FIRST_SVI_SOURCE_TIMESTAMP_MS); - cleanup(market, config, vector[cap_1, cap_2], admin_cap, clock); + cleanup(market, config, vector[cap_1, cap_2], vector[], admin_cap, clock); } #[test, expected_failure(abort_code = market_oracle::EInvalidMarketOracleWriterCap)] @@ -110,6 +205,7 @@ fun cleanup( market: MarketOracle, config: ProtocolConfig, mut caps: vector, + mut lifecycle_caps: vector, admin_cap: admin::AdminCap, clock: clock::Clock, ) { @@ -117,6 +213,10 @@ fun cleanup( market_oracle::destroy_writer_cap(caps.pop_back()); }; caps.destroy_empty(); + while (!lifecycle_caps.is_empty()) { + market_oracle::destroy_lifecycle_cap(lifecycle_caps.pop_back()); + }; + lifecycle_caps.destroy_empty(); destroy(market); destroy(config); clock.destroy_for_testing(); diff --git a/packages/predict/tests/pool/plp_tests.move b/packages/predict/tests/pool/plp_tests.move index 635052688..e72a000b6 100644 --- a/packages/predict/tests/pool/plp_tests.move +++ b/packages/predict/tests/pool/plp_tests.move @@ -9,7 +9,7 @@ use deepbook_predict::{ config_constants, constants::{Self, float_scaling as float}, expiry_market::ExpiryMarket, - market_oracle::{Self, MarketOracle, MarketOracleWriterCap}, + market_oracle::{Self, MarketOracle, MarketOracleWriterCap, MarketOracleLifecycleCap}, plp::{Self, PLP, PoolVault}, protocol_config::{Self, ProtocolConfig}, pyth_source::{Self, PythSource}, @@ -49,6 +49,7 @@ public struct Fixture { admin_cap: AdminCap, config: ProtocolConfig, cap: MarketOracleWriterCap, + lifecycle_cap: MarketOracleLifecycleCap, clock: Clock, vault_id: ID, pyth_id: ID, @@ -452,6 +453,11 @@ fun setup_pool_with_pyth(): Fixture { let mut config = protocol_config::new_for_testing(scenario.ctx()); config.set_protocol_reserve_profit_share(&admin_cap, PROTOCOL_RESERVE_SHARE); let cap = market_oracle::create_writer_cap(&admin_cap, scenario.ctx()); + let lifecycle_cap = market_oracle::create_lifecycle_cap( + &cap, + PYTH_FEED_ID, + scenario.ctx(), + ); let mut clock = clock::create_for_testing(scenario.ctx()); clock.set_for_testing(NOW_MS); @@ -495,6 +501,7 @@ fun setup_pool_with_pyth(): Fixture { admin_cap, config, cap, + lifecycle_cap, clock, vault_id, pyth_id, @@ -516,7 +523,7 @@ fun create_expiry(fixture: &mut Fixture, expiry: u64): (ID, ID) { &mut vault, &fixture.config, &pyth, - &fixture.cap, + &mut fixture.lifecycle_cap, expiry, &fixture.clock, fixture.scenario.ctx(), @@ -549,7 +556,14 @@ fun settle_oracle( settlement_source_timestamp_ms, settlement_update_timestamp_ms, ); - assert!(oracle.settle_if_possible(&fixture.config, pyth, &fixture.cap, &fixture.clock)); + assert!( + oracle.settle_if_possible( + &fixture.config, + pyth, + &fixture.lifecycle_cap, + &fixture.clock, + ), + ); } fun sync_expiry_for_testing( @@ -578,12 +592,14 @@ fun finish(fixture: Fixture) { admin_cap, config, cap, + lifecycle_cap, clock, vault_id: _, pyth_id: _, initial_plp, } = fixture; destroy(initial_plp); + market_oracle::destroy_lifecycle_cap(lifecycle_cap); market_oracle::destroy_writer_cap(cap); destroy(config); destroy(admin_cap); diff --git a/packages/predict/tests/registry_create_tests.move b/packages/predict/tests/registry_create_tests.move index 45f0dd2c7..9e341aec7 100644 --- a/packages/predict/tests/registry_create_tests.move +++ b/packages/predict/tests/registry_create_tests.move @@ -9,7 +9,7 @@ use deepbook_predict::{ config_constants, constants, expiry_market::ExpiryMarket, - market_oracle::{Self, MarketOracle, MarketOracleWriterCap}, + market_oracle::{Self, MarketOracle, MarketOracleWriterCap, MarketOracleLifecycleCap}, plp::{Self, PoolVault}, pricing, protocol_config::ProtocolConfig, @@ -217,7 +217,7 @@ fun set_pyth_feed_tick_size_unknown_feed_aborts() { #[test] fun create_expiry_market_uses_registered_tick_size() { - let (mut scenario, registry_id, pyth_id, cap) = setup_ready_expiry_creation( + let (mut scenario, registry_id, pyth_id, cap, mut lifecycle_cap) = setup_ready_expiry_creation( UPDATED_EXPIRY_TICK_SIZE, ); @@ -234,7 +234,7 @@ fun create_expiry_market_uses_registered_tick_size() { &mut vault, &config, &pyth, - &cap, + &mut lifecycle_cap, EXPIRY_MS, &clock, scenario.ctx(), @@ -252,7 +252,6 @@ fun create_expiry_market_uses_registered_tick_size() { return_shared(vault); return_shared(reg); clock.destroy_for_testing(); - destroy(cap); scenario.next_tx(test_constants::admin()); let market = scenario.take_shared_by_id(expiry_market_id); @@ -267,14 +266,18 @@ fun create_expiry_market_uses_registered_tick_size() { assert_eq!(market.expiry_fee_max_multiplier(), RAMP_MAX_MULTIPLIER); assert_eq!(market.cash_balance(), 0); assert_eq!(oracle.id(), market_oracle_id); + oracle.assert_authorized_writer_cap(&cap); + oracle.assert_authorized_lifecycle_cap(&lifecycle_cap); return_shared(oracle); return_shared(market); + market_oracle::destroy_lifecycle_cap(lifecycle_cap); + destroy(cap); scenario.end(); } #[test, expected_failure(abort_code = strike_grid::EOracleTickSizeTooLargeForSpot)] fun create_expiry_market_aborts_when_tick_size_too_large_for_spot() { - let (mut scenario, registry_id, pyth_id, cap) = setup_ready_expiry_creation( + let (mut scenario, registry_id, pyth_id, _cap, mut lifecycle_cap) = setup_ready_expiry_creation( TOO_WIDE_EXPIRY_TICK_SIZE, ); @@ -291,7 +294,7 @@ fun create_expiry_market_aborts_when_tick_size_too_large_for_spot() { &mut vault, &config, &pyth, - &cap, + &mut lifecycle_cap, EXPIRY_MS, &clock, scenario.ctx(), @@ -301,7 +304,7 @@ fun create_expiry_market_aborts_when_tick_size_too_large_for_spot() { #[test, expected_failure(abort_code = pricing::EPythSpotStale)] fun create_expiry_market_aborts_when_pyth_spot_is_stale() { - let (mut scenario, registry_id, pyth_id, cap) = setup_ready_expiry_creation( + let (mut scenario, registry_id, pyth_id, _cap, mut lifecycle_cap) = setup_ready_expiry_creation( UPDATED_EXPIRY_TICK_SIZE, ); @@ -325,7 +328,41 @@ fun create_expiry_market_aborts_when_pyth_spot_is_stale() { &mut vault, &config, &pyth, - &cap, + &mut lifecycle_cap, + EXPIRY_MS, + &clock, + scenario.ctx(), + ); + abort 999 +} + +#[test, expected_failure(abort_code = market_oracle::EInvalidMarketOracleLifecycleCap)] +fun create_expiry_market_rejects_lifecycle_cap_for_wrong_feed() { + let ( + mut scenario, + registry_id, + pyth_id, + _cap, + mut lifecycle_cap, + ) = setup_ready_expiry_creation_with_lifecycle_feed( + UPDATED_EXPIRY_TICK_SIZE, + PYTH_FEED_ETH, + ); + + scenario.next_tx(test_constants::admin()); + let mut reg = scenario.take_shared_by_id(registry_id); + let mut vault = scenario.take_shared(); + let config = scenario.take_shared(); + let pyth = scenario.take_shared_by_id(pyth_id); + let mut clock = clock::create_for_testing(scenario.ctx()); + clock.set_for_testing(NOW_MS); + + registry::create_expiry_market( + &mut reg, + &mut vault, + &config, + &pyth, + &mut lifecycle_cap, EXPIRY_MS, &clock, scenario.ctx(), @@ -333,7 +370,16 @@ fun create_expiry_market_aborts_when_pyth_spot_is_stale() { abort 999 } -fun setup_ready_expiry_creation(expiry_tick_size: u64): (Scenario, ID, ID, MarketOracleWriterCap) { +fun setup_ready_expiry_creation( + expiry_tick_size: u64, +): (Scenario, ID, ID, MarketOracleWriterCap, MarketOracleLifecycleCap) { + setup_ready_expiry_creation_with_lifecycle_feed(expiry_tick_size, PYTH_FEED_BTC) +} + +fun setup_ready_expiry_creation_with_lifecycle_feed( + expiry_tick_size: u64, + lifecycle_feed_id: u32, +): (Scenario, ID, ID, MarketOracleWriterCap, MarketOracleLifecycleCap) { let mut scenario = test::begin(test_constants::admin()); let registry_id = registry::init_for_testing(scenario.ctx()); plp::init_for_testing(scenario.ctx()); @@ -342,6 +388,11 @@ fun setup_ready_expiry_creation(expiry_tick_size: u64): (Scenario, ID, ID, Marke let mut reg = scenario.take_shared_by_id(registry_id); let admin_cap = scenario.take_from_sender(); let cap = market_oracle::create_writer_cap(&admin_cap, scenario.ctx()); + let lifecycle_cap = market_oracle::create_lifecycle_cap( + &cap, + lifecycle_feed_id, + scenario.ctx(), + ); let pyth_id = registry::create_pyth_source( &mut reg, &admin_cap, @@ -394,7 +445,7 @@ fun setup_ready_expiry_creation(expiry_tick_size: u64): (Scenario, ID, ID, Marke return_shared(config); return_shared(vault); - (scenario, registry_id, pyth_id, cap) + (scenario, registry_id, pyth_id, cap, lifecycle_cap) } // === create_manager / create_and_share_manager ===