From 53e563f8e3ea8373c66e03b130d9dbd5255c004d Mon Sep 17 00:00:00 2001 From: r4bbit <445106+0x-r4bbit@users.noreply.github.com> Date: Tue, 23 Jun 2026 17:28:22 +0200 Subject: [PATCH] feat(amm): create TWAP oracle price account on behalf of the pool Add a CreateOraclePriceAccount instruction mirroring CreatePriceObservations: anyone can register the consumer-facing OraclePriceAccount for a pool feed, and the AMM authorizes the pool as the price source via its pool PDA seed through a single chained call to the configured TWAP oracle program. --- artifacts/amm-idl.json | 35 ++ programs/amm/core/src/lib.rs | 24 + programs/amm/methods/guest/src/bin/amm.rs | 30 ++ .../amm/src/create_oracle_price_account.rs | 447 ++++++++++++++++++ programs/amm/src/lib.rs | 1 + programs/integration_tests/tests/amm.rs | 96 ++++ .../src/create_oracle_price_account.rs | 69 +++ 7 files changed, 702 insertions(+) create mode 100644 programs/amm/src/create_oracle_price_account.rs diff --git a/artifacts/amm-idl.json b/artifacts/amm-idl.json index 62605f1..0e8a8f3 100644 --- a/artifacts/amm-idl.json +++ b/artifacts/amm-idl.json @@ -105,6 +105,41 @@ } ] }, + { + "name": "create_oracle_price_account", + "accounts": [ + { + "name": "config", + "writable": false, + "signer": false, + "init": false + }, + { + "name": "pool", + "writable": false, + "signer": false, + "init": false + }, + { + "name": "oracle_price_account", + "writable": false, + "signer": false, + "init": false + }, + { + "name": "clock", + "writable": false, + "signer": false, + "init": false + } + ], + "args": [ + { + "name": "window_duration", + "type": "u64" + } + ] + }, { "name": "new_definition", "accounts": [ diff --git a/programs/amm/core/src/lib.rs b/programs/amm/core/src/lib.rs index 69efbc2..905e097 100644 --- a/programs/amm/core/src/lib.rs +++ b/programs/amm/core/src/lib.rs @@ -81,6 +81,30 @@ pub enum Instruction { window_duration: u64, }, + /// Creates a TWAP oracle price account for a pool over a time window, on behalf of the AMM, + /// via a chained call to the configured TWAP oracle program. + /// + /// The pool acts as the price source: the AMM authorizes it (via its pool PDA seed) so the + /// oracle ties the price account to this pool. The base/quote assets are the pool's token + /// definitions and the initial price is the pool's current spot price + /// (`reserve_b / reserve_a` as a Q64.64), read from the validated pool rather than supplied by + /// the caller — so the account cannot be seeded at a forged price. The account is overwritten + /// by `PublishPrice` once the feed has observations. Rejects if the price account already + /// exists. The clock must be the canonical 1-block LEZ clock. + /// + /// Required accounts: + /// - AMM Config Account (initialized) + /// - AMM Pool (initialized; acts as the price source) + /// - Oracle Price Account, uninitialized TWAP PDA derived as + /// `compute_oracle_price_account_pda(twap_oracle_program_id, pool.account_id, + /// window_duration)` + /// - Clock Account (the canonical 1-block LEZ clock) + CreateOraclePriceAccount { + /// Duration of the TWAP window this price account serves, in milliseconds. Part of the + /// price-account PDA seed, so each window gets a distinct account. + window_duration: u64, + }, + /// Initializes a new Pool (or re-initializes an existing zero-supply Pool). /// /// On initialization, `MINIMUM_LIQUIDITY` LP tokens are permanently locked diff --git a/programs/amm/methods/guest/src/bin/amm.rs b/programs/amm/methods/guest/src/bin/amm.rs index b9fa420..5669731 100644 --- a/programs/amm/methods/guest/src/bin/amm.rs +++ b/programs/amm/methods/guest/src/bin/amm.rs @@ -100,6 +100,36 @@ mod amm { Ok(spel_framework::SpelOutput::execute(post_states, chained_calls)) } + /// Creates a TWAP oracle price account for a pool over a time window, on behalf of the AMM, via + /// a chained call to the configured TWAP oracle program. + /// + /// Expected accounts: + /// 1. `config` — initialized AMM config account. + /// 2. `pool` — initialized AMM pool; acts as the (authorized) price source and supplies the + /// asset pair and the initial (spot) price. + /// 3. `oracle_price_account` — uninitialized TWAP price-account PDA for `(pool, window_duration)`. + /// 4. `clock` — the canonical 1-block LEZ clock account. + #[instruction] + pub fn create_oracle_price_account( + ctx: ProgramContext, + config: AccountWithMetadata, + pool: AccountWithMetadata, + oracle_price_account: AccountWithMetadata, + clock: AccountWithMetadata, + window_duration: u64, + ) -> SpelResult { + let (post_states, chained_calls) = + amm_program::create_oracle_price_account::create_oracle_price_account( + config, + pool, + oracle_price_account, + clock, + window_duration, + ctx.self_program_id, + ); + Ok(spel_framework::SpelOutput::execute(post_states, chained_calls)) + } + /// Initializes a new Pool (or re-initializes an existing zero-supply Pool). /// A fresh user LP holding must be explicitly authorized by the caller. #[expect( diff --git a/programs/amm/src/create_oracle_price_account.rs b/programs/amm/src/create_oracle_price_account.rs new file mode 100644 index 0000000..f0c4a34 --- /dev/null +++ b/programs/amm/src/create_oracle_price_account.rs @@ -0,0 +1,447 @@ +use amm_core::{ + compute_config_pda, compute_pool_pda, compute_pool_pda_seed, spot_price_q64_64, AmmConfig, + PoolDefinition, +}; +use clock_core::CLOCK_01_PROGRAM_ACCOUNT_ID; +use nssa_core::{ + account::{Account, AccountWithMetadata}, + program::{AccountPostState, ChainedCall, ProgramId}, +}; +use twap_oracle_core::{compute_oracle_price_account_pda, OBSERVATIONS_CAPACITY}; + +/// Creates a TWAP oracle price account for `pool` over a time window, on behalf of the AMM. +/// +/// Mirrors [`create_price_observations`](super::create_price_observations): the pool acts as the +/// price source, authorized via its pool PDA seed, and the work is delegated to the configured TWAP +/// oracle program through a single chained call to its `CreateOraclePriceAccount` instruction, +/// which claims and initialises the price-account PDA. +/// +/// Neither the asset pair nor the initial price is caller-supplied: the base/quote assets are the +/// pool's token definitions and the initial price is the pool's current spot price +/// (`reserve_b / reserve_a` as a Q64.64), read from the validated pool — so the account cannot be +/// seeded at a forged price. The seed is a placeholder until `PublishPrice` writes the first TWAP. +/// +/// The TWAP oracle program ID is read from the AMM config account (the initialization gate). The +/// clock must be the canonical 1-block LEZ system clock, and the price account must not yet exist — +/// both are checked here so the call is rejected early with an AMM-level error, in addition to the +/// oracle's own checks. +/// +/// # Panics +/// Panics if: +/// - `config.account_id` does not match `compute_config_pda(amm_program_id)`, or the config is +/// uninitialized (the AMM Program has not been initialized). +/// - `clock.account_id` is not [`CLOCK_01_PROGRAM_ACCOUNT_ID`]. +/// - `pool.account` does not hold a valid [`PoolDefinition`], or `pool.account_id` does not match +/// its pool PDA. +/// - `oracle_price_account.account_id` does not match the expected TWAP PDA for `(pool, +/// window_duration)`, or `oracle_price_account.account` already exists. +/// - `pool.account` has a zero token-A reserve (no spot price is defined). +/// - the pool's spot price is zero (`reserve_b` is zero or negligible relative to `reserve_a`); +/// zero is the no-price sentinel, so the account must never be seeded with it. +/// - `window_duration` is smaller than [`OBSERVATIONS_CAPACITY`]. Such a window can never have a +/// matching `PriceObservations` account, so the price account could never be updated by +/// `PublishPrice`. Checked here for an early AMM-level error, in addition to the oracle's own +/// check. +pub fn create_oracle_price_account( + config: AccountWithMetadata, + pool: AccountWithMetadata, + oracle_price_account: AccountWithMetadata, + clock: AccountWithMetadata, + window_duration: u64, + amm_program_id: ProgramId, +) -> (Vec, Vec) { + // Config gate: validate the config PDA and read the TWAP oracle program ID from it. + assert_eq!( + config.account_id, + compute_config_pda(amm_program_id), + "Create oracle price account: AMM config Account ID does not match PDA" + ); + let twap_oracle_program_id = AmmConfig::try_from(&config.account.data) + .expect("Create oracle price account: AMM Program must be initialized before use") + .twap_oracle_program_id; + + // The clock must be the canonical 1-block LEZ system clock; otherwise a caller could seed the + // price account with a forged base timestamp. + assert_eq!( + clock.account_id, CLOCK_01_PROGRAM_ACCOUNT_ID, + "Create oracle price account: clock account must be the canonical 1-block LEZ clock account" + ); + + // A window smaller than the observations capacity can never have a matching PriceObservations + // account, so PublishPrice could never update the price account. Reject early with an AMM-level + // error; the oracle enforces the same bound. + assert!( + window_duration >= u64::from(OBSERVATIONS_CAPACITY), + "Create oracle price account: window_duration must be >= OBSERVATIONS_CAPACITY so a matching \ + PriceObservations account can exist and PublishPrice can update this price account" + ); + + // The pool is the price source. Verify it is a genuine AMM pool PDA so we only ever authorize a + // real pool as the source, and derive the asset pair and initial price from its validated + // state. + let pool_def = PoolDefinition::try_from(&pool.account.data) + .expect("Create oracle price account: AMM Program expects a valid Pool Definition Account"); + assert_eq!( + pool.account_id, + compute_pool_pda( + amm_program_id, + pool_def.definition_token_a_id, + pool_def.definition_token_b_id, + ), + "Create oracle price account: Pool Account ID does not match PDA" + ); + + // Initial price is the pool's current spot price (quote per base), not caller-supplied. + let initial_price = spot_price_q64_64(pool_def.reserve_a, pool_def.reserve_b); + // A zero spot price is the sentinel consumers treat as "no valid price", so the account must + // never be seeded with it. This happens when `reserve_b` is zero or so small relative to + // `reserve_a` that the Q64.64 division floors to zero. The oracle enforces the same bound. + assert!( + initial_price != 0, + "Create oracle price account: pool spot price must be non-zero (zero is the no-price \ + sentinel; pool reserve_b is zero or negligible relative to reserve_a)" + ); + + // Verify the price account is the expected TWAP PDA for this (pool, window) pair and reject if + // it already exists. + assert_eq!( + oracle_price_account.account_id, + compute_oracle_price_account_pda(twap_oracle_program_id, pool.account_id, window_duration), + "Create oracle price account: oracle price Account ID does not match PDA" + ); + assert_eq!( + oracle_price_account.account, + Account::default(), + "Create oracle price account: oracle price account already exists" + ); + + // Authorize the pool as the price source so the oracle ties the account to this pool. The AMM + // proves control of the pool PDA via its seed. + let mut pool_price_source = pool.clone(); + pool_price_source.is_authorized = true; + + let chained_call = ChainedCall::new( + twap_oracle_program_id, + vec![ + oracle_price_account.clone(), + pool_price_source, + clock.clone(), + ], + &twap_oracle_core::Instruction::CreateOraclePriceAccount { + base_asset: pool_def.definition_token_a_id, + quote_asset: pool_def.definition_token_b_id, + initial_price, + window_duration, + }, + ) + .with_pda_seeds(vec![compute_pool_pda_seed( + pool_def.definition_token_a_id, + pool_def.definition_token_b_id, + )]); + + let post_states = vec![ + AccountPostState::new(config.account.clone()), + AccountPostState::new(pool.account.clone()), + AccountPostState::new(oracle_price_account.account.clone()), + AccountPostState::new(clock.account.clone()), + ]; + + (post_states, vec![chained_call]) +} + +#[cfg(test)] +mod tests { + use amm_core::compute_pool_pda_seed; + use nssa_core::account::{Account, AccountId, Data, Nonce}; + + use super::*; + + const AMM_PROGRAM_ID: ProgramId = [42; 8]; + const TOKEN_PROGRAM_ID: ProgramId = [15; 8]; + const TWAP_ORACLE_PROGRAM_ID: ProgramId = [77; 8]; + /// 24-hour window in milliseconds. + const WINDOW_24H: u64 = 24 * 60 * 60 * 1_000; + const RESERVE_A: u128 = 5_000; + const RESERVE_B: u128 = 2_500; + + fn token_a_id() -> AccountId { + AccountId::new([3; 32]) + } + + fn token_b_id() -> AccountId { + AccountId::new([4; 32]) + } + + fn pool_id() -> AccountId { + compute_pool_pda(AMM_PROGRAM_ID, token_a_id(), token_b_id()) + } + + fn config_init() -> AccountWithMetadata { + AccountWithMetadata { + account: Account { + program_owner: AMM_PROGRAM_ID, + balance: 0, + data: Data::from(&AmmConfig { + token_program_id: TOKEN_PROGRAM_ID, + twap_oracle_program_id: TWAP_ORACLE_PROGRAM_ID, + authority: AccountId::new([9; 32]), + }), + nonce: Nonce(0), + }, + is_authorized: false, + account_id: compute_config_pda(AMM_PROGRAM_ID), + } + } + + fn pool_with_reserves(reserve_a: u128, reserve_b: u128) -> AccountWithMetadata { + AccountWithMetadata { + account: Account { + program_owner: AMM_PROGRAM_ID, + balance: 0, + data: Data::from(&PoolDefinition { + definition_token_a_id: token_a_id(), + definition_token_b_id: token_b_id(), + vault_a_id: AccountId::new([5; 32]), + vault_b_id: AccountId::new([6; 32]), + liquidity_pool_id: AccountId::new([7; 32]), + liquidity_pool_supply: 5_000, + reserve_a, + reserve_b, + fees: amm_core::FEE_TIER_BPS_30, + }), + nonce: Nonce(0), + }, + is_authorized: false, + account_id: pool_id(), + } + } + + fn pool() -> AccountWithMetadata { + pool_with_reserves(RESERVE_A, RESERVE_B) + } + + fn oracle_price_account_uninit() -> AccountWithMetadata { + AccountWithMetadata { + account: Account::default(), + is_authorized: false, + account_id: compute_oracle_price_account_pda( + TWAP_ORACLE_PROGRAM_ID, + pool_id(), + WINDOW_24H, + ), + } + } + + fn clock() -> AccountWithMetadata { + AccountWithMetadata { + account: Account::default(), + is_authorized: false, + account_id: CLOCK_01_PROGRAM_ACCOUNT_ID, + } + } + + fn call() -> (Vec, Vec) { + create_oracle_price_account( + config_init(), + pool(), + oracle_price_account_uninit(), + clock(), + WINDOW_24H, + AMM_PROGRAM_ID, + ) + } + + // ── happy path ──────────────────────────────────────────────────────────── + + #[test] + fn returns_four_post_states_unchanged() { + let (post_states, _) = call(); + assert_eq!(post_states.len(), 4); + assert_eq!(*post_states[0].account(), config_init().account); + assert_eq!(*post_states[1].account(), pool().account); + assert_eq!( + *post_states[2].account(), + oracle_price_account_uninit().account + ); + assert_eq!(*post_states[3].account(), clock().account); + } + + #[test] + fn seeds_chained_call_with_pool_assets_and_spot_price() { + let (_, chained_calls) = call(); + assert_eq!(chained_calls.len(), 1); + + // The chained call must carry the pool's asset pair and the spot price derived from the + // pool's reserves (not a caller-supplied value), and authorize the pool as the price + // source. + let mut pool_authorized = pool(); + pool_authorized.is_authorized = true; + let expected = ChainedCall::new( + TWAP_ORACLE_PROGRAM_ID, + vec![oracle_price_account_uninit(), pool_authorized, clock()], + &twap_oracle_core::Instruction::CreateOraclePriceAccount { + base_asset: token_a_id(), + quote_asset: token_b_id(), + initial_price: spot_price_q64_64(RESERVE_A, RESERVE_B), + window_duration: WINDOW_24H, + }, + ) + .with_pda_seeds(vec![compute_pool_pda_seed(token_a_id(), token_b_id())]); + + assert_eq!(chained_calls[0], expected); + } + + // ── precondition violations ─────────────────────────────────────────────── + + #[test] + #[should_panic(expected = "AMM config Account ID does not match PDA")] + fn wrong_config_pda_panics() { + let mut config = config_init(); + config.account_id = AccountId::new([0; 32]); + create_oracle_price_account( + config, + pool(), + oracle_price_account_uninit(), + clock(), + WINDOW_24H, + AMM_PROGRAM_ID, + ); + } + + #[test] + #[should_panic(expected = "AMM Program must be initialized before use")] + fn uninitialized_config_panics() { + let config = AccountWithMetadata { + account: Account::default(), + is_authorized: false, + account_id: compute_config_pda(AMM_PROGRAM_ID), + }; + create_oracle_price_account( + config, + pool(), + oracle_price_account_uninit(), + clock(), + WINDOW_24H, + AMM_PROGRAM_ID, + ); + } + + #[test] + #[should_panic(expected = "clock account must be the canonical 1-block LEZ clock account")] + fn non_canonical_clock_panics() { + let mut clock = clock(); + clock.account_id = AccountId::new([9; 32]); + create_oracle_price_account( + config_init(), + pool(), + oracle_price_account_uninit(), + clock, + WINDOW_24H, + AMM_PROGRAM_ID, + ); + } + + #[test] + #[should_panic(expected = "Pool Account ID does not match PDA")] + fn forged_pool_account_panics() { + let mut pool = pool(); + pool.account_id = AccountId::new([8; 32]); + create_oracle_price_account( + config_init(), + pool, + oracle_price_account_uninit(), + clock(), + WINDOW_24H, + AMM_PROGRAM_ID, + ); + } + + #[test] + #[should_panic(expected = "oracle price Account ID does not match PDA")] + fn wrong_oracle_price_account_pda_panics() { + let mut price_account = oracle_price_account_uninit(); + price_account.account_id = AccountId::new([1; 32]); + create_oracle_price_account( + config_init(), + pool(), + price_account, + clock(), + WINDOW_24H, + AMM_PROGRAM_ID, + ); + } + + #[test] + #[should_panic(expected = "oracle price account already exists")] + fn already_existing_oracle_price_account_panics() { + let mut price_account = oracle_price_account_uninit(); + price_account.account.data = Data::try_from(vec![1u8; 8]).expect("fits in Data"); + create_oracle_price_account( + config_init(), + pool(), + price_account, + clock(), + WINDOW_24H, + AMM_PROGRAM_ID, + ); + } + + /// A pool with a zero quote reserve has a spot price of zero, which is the no-price sentinel + /// and must be rejected rather than seeded into the price account. + #[test] + #[should_panic(expected = "pool spot price must be non-zero")] + fn zero_quote_reserve_panics() { + create_oracle_price_account( + config_init(), + pool_with_reserves(RESERVE_A, 0), + oracle_price_account_uninit(), + clock(), + WINDOW_24H, + AMM_PROGRAM_ID, + ); + } + + /// A quote reserve so small relative to the base reserve that the Q64.64 division floors to + /// zero is also rejected: `reserve_b << 64 < reserve_a` yields a zero spot price. + #[test] + #[should_panic(expected = "pool spot price must be non-zero")] + fn negligible_quote_reserve_floors_to_zero_and_panics() { + let reserve_a = 1u128 << 65; + create_oracle_price_account( + config_init(), + pool_with_reserves(reserve_a, 1), + oracle_price_account_uninit(), + clock(), + WINDOW_24H, + AMM_PROGRAM_ID, + ); + } + + /// A window smaller than `OBSERVATIONS_CAPACITY` can never have a matching `PriceObservations` + /// account, so the price account could never be updated by `PublishPrice`; it is rejected early + /// with an AMM-level error before the pool is even decoded. + #[test] + #[should_panic(expected = "window_duration must be >= OBSERVATIONS_CAPACITY")] + fn window_duration_below_capacity_panics() { + let small_window = u64::from(OBSERVATIONS_CAPACITY) + .checked_sub(1) + .expect("OBSERVATIONS_CAPACITY is non-zero"); + create_oracle_price_account( + config_init(), + pool(), + oracle_price_account_uninit(), + clock(), + small_window, + AMM_PROGRAM_ID, + ); + } + + #[test] + fn different_windows_produce_distinct_price_account_pdas() { + let window_7d = 7 * 24 * 60 * 60 * 1_000u64; + assert_ne!( + compute_oracle_price_account_pda(TWAP_ORACLE_PROGRAM_ID, pool_id(), WINDOW_24H), + compute_oracle_price_account_pda(TWAP_ORACLE_PROGRAM_ID, pool_id(), window_7d), + ); + } +} diff --git a/programs/amm/src/lib.rs b/programs/amm/src/lib.rs index 6cdebc1..787d0b0 100644 --- a/programs/amm/src/lib.rs +++ b/programs/amm/src/lib.rs @@ -3,6 +3,7 @@ pub use amm_core as core; pub mod add; +pub mod create_oracle_price_account; pub mod create_price_observations; pub mod initialize; pub mod new_definition; diff --git a/programs/integration_tests/tests/amm.rs b/programs/integration_tests/tests/amm.rs index bcc0787..2069207 100644 --- a/programs/integration_tests/tests/amm.rs +++ b/programs/integration_tests/tests/amm.rs @@ -71,6 +71,14 @@ impl Ids { ) } + fn oracle_price_account(window_duration: u64) -> AccountId { + twap_oracle_core::compute_oracle_price_account_pda( + Self::twap_oracle_program(), + Self::pool_definition(), + window_duration, + ) + } + fn token_a_definition() -> AccountId { AccountId::new([3; 32]) } @@ -1296,6 +1304,31 @@ fn execute_create_price_observations( state.transition_from_public_transaction(&tx, 0, 0) } +#[cfg(test)] +fn execute_create_oracle_price_account( + state: &mut V03State, + window_duration: u64, +) -> Result<(), NssaError> { + let instruction = amm_core::Instruction::CreateOraclePriceAccount { window_duration }; + + let message = public_transaction::Message::try_new( + Ids::amm_program(), + vec![ + Ids::config(), + Ids::pool_definition(), + Ids::oracle_price_account(window_duration), + CLOCK_01_PROGRAM_ACCOUNT_ID, + ], + vec![], + instruction, + ) + .unwrap(); + + let witness_set = public_transaction::WitnessSet::for_message(&message, &[]); + let tx = PublicTransaction::new(message, witness_set); + state.transition_from_public_transaction(&tx, 0, 0) +} + #[cfg(test)] fn execute_sync_reserves(state: &mut V03State) { let message = public_transaction::Message::try_new( @@ -1522,6 +1555,69 @@ fn amm_creates_price_observations_on_twap_oracle() { ); } +#[test] +fn amm_creates_oracle_price_account_on_twap_oracle() { + let mut state = state_with_pool_created_via_new_definition(); + let window_duration = 24 * 60 * 60 * 1_000u64; + + // CreateOraclePriceAccount rejects a zero clock timestamp, so advance the clock first. + let now = 1_700_000_000_000u64; + advance_clock(&mut state, now); + + // The price-account PDA does not exist before the AMM creates it. + assert_eq!( + state.get_account_by_id(Ids::oracle_price_account(window_duration)), + Account::default() + ); + + execute_create_oracle_price_account(&mut state, window_duration).unwrap(); + + // The price account now exists, is owned by the TWAP oracle program, and is seeded with the + // pool as its source, the pool's asset pair, and the pool's current spot price (reserve_b / + // reserve_a as a Q64.64) — all derived on-chain, none caller-supplied — stamped with the clock. + let account = state.get_account_by_id(Ids::oracle_price_account(window_duration)); + assert_ne!(account, Account::default()); + assert_eq!(account.program_owner, Ids::twap_oracle_program()); + + let price = twap_oracle_core::OraclePriceAccount::try_from(&account.data) + .expect("price account must hold a valid OraclePriceAccount"); + assert_eq!(price.source_id, Ids::pool_definition()); + assert_eq!(price.base_asset, Ids::token_a_definition()); + assert_eq!(price.quote_asset, Ids::token_b_definition()); + assert_eq!( + price.price, + amm_core::spot_price_q64_64(Balances::vault_a_init(), Balances::vault_b_init()) + ); + assert_eq!(price.timestamp, now); + assert_eq!(price.confidence_interval, 0); + + // The AMM config and pool are left unchanged by the operation. + assert_eq!(state.get_account_by_id(Ids::config()), Accounts::config()); + assert_eq!( + state.get_account_by_id(Ids::pool_definition()), + Accounts::pool_definition_new_init() + ); +} + +#[test] +fn amm_create_oracle_price_account_rejects_existing_account() { + let mut state = state_with_pool_created_via_new_definition(); + let window_duration = 24 * 60 * 60 * 1_000u64; + advance_clock(&mut state, 1_700_000_000_000u64); + + // First creation succeeds. + execute_create_oracle_price_account(&mut state, window_duration).unwrap(); + let after_first = state.get_account_by_id(Ids::oracle_price_account(window_duration)); + + // A second creation for the same (pool, window) is rejected and leaves the account intact. + let result = execute_create_oracle_price_account(&mut state, window_duration); + assert!(matches!(result, Err(NssaError::ProgramExecutionFailed(_)))); + assert_eq!( + state.get_account_by_id(Ids::oracle_price_account(window_duration)), + after_first + ); +} + #[test] fn amm_create_price_observations_rejects_existing_account() { let mut state = state_with_pool_created_via_new_definition(); diff --git a/programs/twap_oracle/src/create_oracle_price_account.rs b/programs/twap_oracle/src/create_oracle_price_account.rs index 8735ef3..dbc492a 100644 --- a/programs/twap_oracle/src/create_oracle_price_account.rs +++ b/programs/twap_oracle/src/create_oracle_price_account.rs @@ -5,6 +5,7 @@ use nssa_core::{ }; use twap_oracle_core::{ compute_oracle_price_account_pda, compute_oracle_price_account_pda_seed, OraclePriceAccount, + OBSERVATIONS_CAPACITY, }; /// Creates and initialises an [`OraclePriceAccount`] for a price source account and time window. @@ -35,6 +36,12 @@ use twap_oracle_core::{ /// - `clock.account_id` is not [`CLOCK_01_PROGRAM_ACCOUNT_ID`]. /// - `initial_price` is zero. /// - the clock timestamp is zero. +/// - `window_duration` is smaller than [`OBSERVATIONS_CAPACITY`]. Such a window can never have a +/// matching [`PriceObservations`] account (`CreatePriceObservations` rejects it for the same +/// reason), so `PublishPrice` could never update this account — it would be created frozen at its +/// seed price and squat its PDA forever. Rejecting it here keeps both creation paths symmetric. +/// +/// [`PriceObservations`]: twap_oracle_core::PriceObservations #[expect( clippy::too_many_arguments, reason = "instruction surface passes explicit account inputs alongside the asset pair, initial price, and window" @@ -68,6 +75,11 @@ pub fn create_oracle_price_account( clock.account_id, CLOCK_01_PROGRAM_ACCOUNT_ID, "CreateOraclePriceAccount: clock account must be the canonical 1-block LEZ clock account" ); + assert!( + window_duration >= u64::from(OBSERVATIONS_CAPACITY), + "CreateOraclePriceAccount: window_duration must be >= OBSERVATIONS_CAPACITY so a matching \ + PriceObservations account can exist and PublishPrice can update this price account" + ); let timestamp = ClockAccountData::from_bytes(clock.account.data.as_ref()).timestamp; @@ -521,4 +533,61 @@ mod tests { ORACLE_PROGRAM_ID, ); } + + /// A window smaller than `OBSERVATIONS_CAPACITY` can never have a matching `PriceObservations` + /// account, so `PublishPrice` could never update the price account; it must be rejected at + /// creation. The uninitialised account is built at the small window's PDA so the window check — + /// not the PDA check — is what fires. + #[test] + #[should_panic(expected = "window_duration must be >= OBSERVATIONS_CAPACITY")] + fn window_duration_below_capacity_panics() { + let small_window = u64::from(OBSERVATIONS_CAPACITY) + .checked_sub(1) + .expect("OBSERVATIONS_CAPACITY is non-zero"); + let uninit = AccountWithMetadata { + account: Account::default(), + is_authorized: false, + account_id: compute_oracle_price_account_pda( + ORACLE_PROGRAM_ID, + price_source_id(), + small_window, + ), + }; + create_oracle_price_account( + uninit, + price_source_authorized(), + clock_account(TIMESTAMP), + base_asset(), + quote_asset(), + INITIAL_PRICE, + small_window, + ORACLE_PROGRAM_ID, + ); + } + + /// A window exactly equal to `OBSERVATIONS_CAPACITY` is the minimum accepted value. + #[test] + fn window_duration_equal_to_capacity_is_accepted() { + let window = u64::from(OBSERVATIONS_CAPACITY); + let uninit = AccountWithMetadata { + account: Account::default(), + is_authorized: false, + account_id: compute_oracle_price_account_pda( + ORACLE_PROGRAM_ID, + price_source_id(), + window, + ), + }; + let post_states = create_oracle_price_account( + uninit, + price_source_authorized(), + clock_account(TIMESTAMP), + base_asset(), + quote_asset(), + INITIAL_PRICE, + window, + ORACLE_PROGRAM_ID, + ); + assert_eq!(post_states.len(), 3); + } }