diff --git a/artifacts/stablecoin-idl.json b/artifacts/stablecoin-idl.json index f90a1a5..83e8342 100644 --- a/artifacts/stablecoin-idl.json +++ b/artifacts/stablecoin-idl.json @@ -323,6 +323,22 @@ } ] } + }, + { + "name": "CurrentTickAccount", + "type": { + "kind": "struct", + "fields": [ + { + "name": "tick", + "type": "i32" + }, + { + "name": "last_updated", + "type": "u64" + } + ] + } } ], "types": [ diff --git a/artifacts/twap_oracle-idl.json b/artifacts/twap_oracle-idl.json index df340bc..98845e5 100644 --- a/artifacts/twap_oracle-idl.json +++ b/artifacts/twap_oracle-idl.json @@ -65,6 +65,64 @@ "type": "u64" } ] + }, + { + "name": "create_current_tick_account", + "accounts": [ + { + "name": "current_tick_account", + "writable": false, + "signer": false, + "init": false + }, + { + "name": "price_source", + "writable": false, + "signer": false, + "init": false + }, + { + "name": "clock", + "writable": false, + "signer": false, + "init": false + } + ], + "args": [ + { + "name": "initial_tick", + "type": "i32" + } + ] + }, + { + "name": "update_current_tick", + "accounts": [ + { + "name": "current_tick_account", + "writable": false, + "signer": false, + "init": false + }, + { + "name": "price_source", + "writable": false, + "signer": false, + "init": false + }, + { + "name": "clock", + "writable": false, + "signer": false, + "init": false + } + ], + "args": [ + { + "name": "tick", + "type": "i32" + } + ] } ], "accounts": [ @@ -131,6 +189,22 @@ } ] } + }, + { + "name": "CurrentTickAccount", + "type": { + "kind": "struct", + "fields": [ + { + "name": "tick", + "type": "i32" + }, + { + "name": "last_updated", + "type": "u64" + } + ] + } } ], "types": [ diff --git a/programs/twap_oracle/core/src/lib.rs b/programs/twap_oracle/core/src/lib.rs index 964593b..15bc963 100644 --- a/programs/twap_oracle/core/src/lib.rs +++ b/programs/twap_oracle/core/src/lib.rs @@ -53,6 +53,35 @@ pub enum Instruction { /// oracle price account. window_duration: u64, }, + /// Creates and initialises a [`CurrentTickAccount`] for a price source. + /// + /// Called once per price source (not per window). The account holds the latest raw tick + /// written by the price source and serves as the input to `RecordTick`. + /// + /// Required accounts (in order): + /// 1. Current tick account — uninitialized PDA derived from + /// `compute_current_tick_account_pda(self_program_id, price_source.account_id)`. + /// 2. Price source account — must be passed with `is_authorized = true`. + /// 3. Clock account — read-only; supplies the initial timestamp. + CreateCurrentTickAccount { + /// Opening tick: `floor(log_{1.0001}(reserve_b / reserve_a))` at creation time. + initial_tick: i32, + }, + /// Updates the tick stored in an existing [`CurrentTickAccount`]. + /// + /// Called by the price source (e.g. AMM) after each price-changing operation. Anyone may + /// subsequently call `RecordTick` to advance the [`PriceObservations`] accumulator using + /// the new tick. + /// + /// Required accounts (in order): + /// 1. Current tick account — initialized PDA derived from + /// `compute_current_tick_account_pda(self_program_id, price_source.account_id)`. + /// 2. Price source account — must be passed with `is_authorized = true`. + /// 3. Clock account — read-only; supplies the updated timestamp. + UpdateCurrentTick { + /// New raw tick from the price source. + tick: i32, + }, } // ────────────────────────────────────────────────────────────────────────────── @@ -266,3 +295,77 @@ impl From<&OraclePriceAccount> for Data { Self::try_from(data).expect("Oracle price account encoded data should fit into Data") } } + +// ────────────────────────────────────────────────────────────────────────────── +// Current tick account +// ────────────────────────────────────────────────────────────────────────────── + +/// Live price tick for a price source, written by the price source on every price-changing +/// operation. +/// +/// Owned by the TWAP oracle as a PDA derived from +/// `compute_current_tick_account_pda(oracle_program_id, price_source_id)`. +/// One account exists per price source; it is shared across all time windows for that source. +/// Anyone may call `RecordTick` to advance a [`PriceObservations`] accumulator using the tick +/// stored here. +#[account_type] +#[derive(Debug, PartialEq, Eq, Clone, Serialize, Deserialize, BorshSerialize, BorshDeserialize)] +pub struct CurrentTickAccount { + /// Most recent raw tick written by the price source: + /// `floor(log_{1.0001}(reserve_b / reserve_a))`. + pub tick: i32, + /// Block timestamp (milliseconds) when `tick` was last written. + pub last_updated: u64, +} + +impl TryFrom<&Data> for CurrentTickAccount { + type Error = std::io::Error; + + fn try_from(data: &Data) -> Result { + Self::try_from_slice(data.as_ref()) + } +} + +impl From<&CurrentTickAccount> for Data { + fn from(account: &CurrentTickAccount) -> Self { + let serialized_len = + borsh::object_length(account).expect("CurrentTickAccount length must be known"); + let mut data = Vec::with_capacity(serialized_len); + BorshSerialize::serialize(account, &mut data) + .expect("Serialization to Vec should not fail"); + Self::try_from(data).expect("CurrentTickAccount encoded data should fit into Data") + } +} + +const CURRENT_TICK_ACCOUNT_PDA_SEED: [u8; 32] = [4; 32]; + +/// Derives the [`AccountId`] for a price source's [`CurrentTickAccount`] PDA. +#[must_use] +pub fn compute_current_tick_account_pda( + oracle_program_id: ProgramId, + price_source_id: AccountId, +) -> AccountId { + AccountId::for_public_pda( + &oracle_program_id, + &compute_current_tick_account_pda_seed(price_source_id), + ) +} + +/// Derives the [`PdaSeed`] for a price source's [`CurrentTickAccount`]. +/// +/// Hash input: `price_source_id (32 bytes) || CURRENT_TICK_ACCOUNT_PDA_SEED (32 bytes)`. +#[must_use] +pub fn compute_current_tick_account_pda_seed(price_source_id: AccountId) -> PdaSeed { + use risc0_zkvm::sha::{Impl, Sha256}; + + let mut bytes = [0u8; 64]; + bytes[..32].copy_from_slice(&price_source_id.to_bytes()); + bytes[32..64].copy_from_slice(&CURRENT_TICK_ACCOUNT_PDA_SEED); + + PdaSeed::new( + Impl::hash_bytes(&bytes) + .as_bytes() + .try_into() + .expect("Hash output must be exactly 32 bytes long"), + ) +} diff --git a/programs/twap_oracle/methods/guest/src/bin/twap_oracle.rs b/programs/twap_oracle/methods/guest/src/bin/twap_oracle.rs index 5f3f096..86a747d 100644 --- a/programs/twap_oracle/methods/guest/src/bin/twap_oracle.rs +++ b/programs/twap_oracle/methods/guest/src/bin/twap_oracle.rs @@ -68,4 +68,53 @@ mod twap_oracle { ); Ok(spel_framework::SpelOutput::execute(post_states, vec![])) } + + /// Creates and initialises a current tick account for a price source. + /// + /// Expected accounts: + /// 1. `current_tick_account` — uninitialized PDA owned by this oracle program. + /// 2. `price_source` — account the caller controls (proven via `is_authorized = true`). + /// 3. `clock` — read-only LEZ clock account. + #[instruction] + pub fn create_current_tick_account( + ctx: ProgramContext, + current_tick_account: AccountWithMetadata, + price_source: AccountWithMetadata, + clock: AccountWithMetadata, + initial_tick: i32, + ) -> SpelResult { + let post_states = + twap_oracle_program::create_current_tick_account::create_current_tick_account( + current_tick_account, + price_source, + clock, + initial_tick, + ctx.self_program_id, + ); + Ok(spel_framework::SpelOutput::execute(post_states, vec![])) + } + + /// Updates the tick stored in an existing current tick account. + /// + /// Expected accounts: + /// 1. `current_tick_account` — initialized PDA owned by this oracle program. + /// 2. `price_source` — account the caller controls (proven via `is_authorized = true`). + /// 3. `clock` — read-only LEZ clock account. + #[instruction] + pub fn update_current_tick( + ctx: ProgramContext, + current_tick_account: AccountWithMetadata, + price_source: AccountWithMetadata, + clock: AccountWithMetadata, + tick: i32, + ) -> SpelResult { + let post_states = twap_oracle_program::update_current_tick::update_current_tick( + current_tick_account, + price_source, + clock, + tick, + ctx.self_program_id, + ); + Ok(spel_framework::SpelOutput::execute(post_states, vec![])) + } } diff --git a/programs/twap_oracle/src/create_current_tick_account.rs b/programs/twap_oracle/src/create_current_tick_account.rs new file mode 100644 index 0000000..38fa73c --- /dev/null +++ b/programs/twap_oracle/src/create_current_tick_account.rs @@ -0,0 +1,280 @@ +use clock_core::ClockAccountData; +use nssa_core::{ + account::{Account, AccountWithMetadata, Data}, + program::{AccountPostState, Claim, ProgramId}, +}; +use twap_oracle_core::{ + compute_current_tick_account_pda, compute_current_tick_account_pda_seed, CurrentTickAccount, +}; + +/// Creates and initialises a [`CurrentTickAccount`] for a price source. +/// +/// Authorization is implicit in the PDA relationship: the current tick account is derived from +/// `price_source.account_id`, so whoever controls the price source controls this account. +/// +/// # Panics +/// Panics if: +/// - `current_tick_account.account_id` does not match +/// `compute_current_tick_account_pda(oracle_program_id, price_source.account_id)`. +/// - `current_tick_account.account` is not the default (already initialised). +/// - `price_source.is_authorized` is false. +pub fn create_current_tick_account( + current_tick_account: AccountWithMetadata, + price_source: AccountWithMetadata, + clock: AccountWithMetadata, + initial_tick: i32, + oracle_program_id: ProgramId, +) -> Vec { + let price_source_id = price_source.account_id; + assert_eq!( + current_tick_account.account_id, + compute_current_tick_account_pda(oracle_program_id, price_source_id), + "CreateCurrentTickAccount: current tick account ID does not match expected PDA" + ); + assert_eq!( + current_tick_account.account, + Account::default(), + "CreateCurrentTickAccount: current tick account must be uninitialized" + ); + assert!( + price_source.is_authorized, + "CreateCurrentTickAccount: price source account must be authorized" + ); + + let clock_data = ClockAccountData::from_bytes(clock.account.data.as_ref()); + + let account = CurrentTickAccount { + tick: initial_tick, + last_updated: clock_data.timestamp, + }; + + let mut current_tick_account_post = current_tick_account.account.clone(); + current_tick_account_post.data = Data::from(&account); + + vec![ + AccountPostState::new_claimed( + current_tick_account_post, + Claim::Pda(compute_current_tick_account_pda_seed(price_source_id)), + ), + AccountPostState::new(price_source.account.clone()), + AccountPostState::new(clock.account.clone()), + ] +} + +#[cfg(test)] +mod tests { + use nssa_core::account::{AccountId, Nonce}; + + use super::*; + + const ORACLE_PROGRAM_ID: ProgramId = [77u32; 8]; + const CLOCK_PROGRAM_ID: ProgramId = [88u32; 8]; + + fn price_source_id() -> AccountId { + AccountId::new([1u8; 32]) + } + + fn clock_account_with_timestamp(timestamp: u64) -> AccountWithMetadata { + let data = ClockAccountData { + block_id: 0, + timestamp, + } + .to_bytes(); + AccountWithMetadata { + account: Account { + program_owner: CLOCK_PROGRAM_ID, + balance: 0, + data: Data::try_from(data).expect("ClockAccountData fits in Data"), + nonce: Nonce(0), + }, + is_authorized: false, + account_id: AccountId::new([99u8; 32]), + } + } + + fn price_source_authorized() -> AccountWithMetadata { + AccountWithMetadata { + account: Account { + program_owner: [42u32; 8], + balance: 0, + data: Data::default(), + nonce: Nonce(0), + }, + is_authorized: true, + account_id: price_source_id(), + } + } + + fn current_tick_account_uninit() -> AccountWithMetadata { + AccountWithMetadata { + account: Account::default(), + is_authorized: false, + account_id: compute_current_tick_account_pda(ORACLE_PROGRAM_ID, price_source_id()), + } + } + + // ── happy path ──────────────────────────────────────────────────────────── + + #[test] + fn returns_three_post_states() { + let post_states = create_current_tick_account( + current_tick_account_uninit(), + price_source_authorized(), + clock_account_with_timestamp(0), + 0, + ORACLE_PROGRAM_ID, + ); + assert_eq!(post_states.len(), 3); + } + + #[test] + fn current_tick_account_post_state_is_pda_claimed() { + let post_states = create_current_tick_account( + current_tick_account_uninit(), + price_source_authorized(), + clock_account_with_timestamp(0), + 0, + ORACLE_PROGRAM_ID, + ); + assert_eq!( + post_states[0].required_claim(), + Some(Claim::Pda(compute_current_tick_account_pda_seed( + price_source_id() + ))) + ); + } + + #[test] + fn tick_and_timestamp_stored_correctly() { + let timestamp = 123_456_789u64; + let initial_tick = -42i32; + + let post_states = create_current_tick_account( + current_tick_account_uninit(), + price_source_authorized(), + clock_account_with_timestamp(timestamp), + initial_tick, + ORACLE_PROGRAM_ID, + ); + + let account = CurrentTickAccount::try_from(&post_states[0].account().data) + .expect("post state must contain a valid CurrentTickAccount"); + + assert_eq!(account.tick, initial_tick); + assert_eq!(account.last_updated, timestamp); + } + + #[test] + fn positive_and_negative_initial_ticks_stored_correctly() { + for tick in [i32::MIN, -1, 0, 1, i32::MAX] { + let post_states = create_current_tick_account( + current_tick_account_uninit(), + price_source_authorized(), + clock_account_with_timestamp(0), + tick, + ORACLE_PROGRAM_ID, + ); + let account = CurrentTickAccount::try_from(&post_states[0].account().data) + .expect("post state must contain a valid CurrentTickAccount"); + assert_eq!(account.tick, tick); + } + } + + #[test] + fn price_source_and_clock_post_states_are_unchanged() { + let price_source = price_source_authorized(); + let clock = clock_account_with_timestamp(42_000); + + let post_states = create_current_tick_account( + current_tick_account_uninit(), + price_source.clone(), + clock.clone(), + 0, + ORACLE_PROGRAM_ID, + ); + + assert_eq!(*post_states[1].account(), price_source.account); + assert_eq!(*post_states[2].account(), clock.account); + } + + #[test] + fn different_price_sources_produce_distinct_pdas() { + let other_source_id = AccountId::new([2u8; 32]); + assert_ne!( + compute_current_tick_account_pda(ORACLE_PROGRAM_ID, price_source_id()), + compute_current_tick_account_pda(ORACLE_PROGRAM_ID, other_source_id), + ); + } + + #[test] + fn current_tick_account_pda_differs_from_price_observations_pda() { + use twap_oracle_core::compute_price_observations_pda; + let window = 24 * 60 * 60 * 1_000u64; + assert_ne!( + compute_current_tick_account_pda(ORACLE_PROGRAM_ID, price_source_id()), + compute_price_observations_pda(ORACLE_PROGRAM_ID, price_source_id(), window), + ); + } + + // ── precondition violations ─────────────────────────────────────────────── + + #[test] + #[should_panic(expected = "current tick account ID does not match expected PDA")] + fn wrong_account_id_panics() { + let mut wrong = current_tick_account_uninit(); + wrong.account_id = AccountId::new([0u8; 32]); + create_current_tick_account( + wrong, + price_source_authorized(), + clock_account_with_timestamp(0), + 0, + ORACLE_PROGRAM_ID, + ); + } + + #[test] + #[should_panic(expected = "current tick account must be uninitialized")] + fn already_initialized_account_panics() { + let mut initialized = current_tick_account_uninit(); + initialized.account.data = Data::try_from(vec![1u8; 10]).expect("fits in Data"); + create_current_tick_account( + initialized, + price_source_authorized(), + clock_account_with_timestamp(0), + 0, + ORACLE_PROGRAM_ID, + ); + } + + #[test] + #[should_panic(expected = "price source account must be authorized")] + fn unauthorized_price_source_panics() { + let mut unauthorized = price_source_authorized(); + unauthorized.is_authorized = false; + create_current_tick_account( + current_tick_account_uninit(), + unauthorized, + clock_account_with_timestamp(0), + 0, + ORACLE_PROGRAM_ID, + ); + } + + /// An attacker who controls their own price source cannot register a current tick account + /// that claims to be derived from a different (victim's) price source. + #[test] + #[should_panic(expected = "current tick account ID does not match expected PDA")] + fn cannot_register_for_another_price_source() { + let victim_source_id = AccountId::new([2u8; 32]); + let victim_pda = compute_current_tick_account_pda(ORACLE_PROGRAM_ID, victim_source_id); + let mut attacker_account = current_tick_account_uninit(); + attacker_account.account_id = victim_pda; + create_current_tick_account( + attacker_account, + price_source_authorized(), + clock_account_with_timestamp(0), + 0, + ORACLE_PROGRAM_ID, + ); + } +} diff --git a/programs/twap_oracle/src/lib.rs b/programs/twap_oracle/src/lib.rs index e029dea..d32d3f3 100644 --- a/programs/twap_oracle/src/lib.rs +++ b/programs/twap_oracle/src/lib.rs @@ -2,5 +2,7 @@ pub use twap_oracle_core as core; +pub mod create_current_tick_account; pub mod create_oracle_price_account; pub mod create_price_observations; +pub mod update_current_tick; diff --git a/programs/twap_oracle/src/update_current_tick.rs b/programs/twap_oracle/src/update_current_tick.rs new file mode 100644 index 0000000..a63338b --- /dev/null +++ b/programs/twap_oracle/src/update_current_tick.rs @@ -0,0 +1,253 @@ +use clock_core::ClockAccountData; +use nssa_core::{ + account::{AccountWithMetadata, Data}, + program::{AccountPostState, ProgramId}, +}; +use twap_oracle_core::{compute_current_tick_account_pda, CurrentTickAccount}; + +/// Updates the tick stored in an existing [`CurrentTickAccount`]. +/// +/// # Panics +/// Panics if: +/// - `current_tick_account.account_id` does not match +/// `compute_current_tick_account_pda(oracle_program_id, price_source.account_id)`. +/// - `current_tick_account.account` is not a valid, initialised [`CurrentTickAccount`]. +/// - `price_source.is_authorized` is false. +pub fn update_current_tick( + current_tick_account: AccountWithMetadata, + price_source: AccountWithMetadata, + clock: AccountWithMetadata, + tick: i32, + oracle_program_id: ProgramId, +) -> Vec { + let price_source_id = price_source.account_id; + assert_eq!( + current_tick_account.account_id, + compute_current_tick_account_pda(oracle_program_id, price_source_id), + "UpdateCurrentTick: current tick account ID does not match expected PDA" + ); + assert!( + price_source.is_authorized, + "UpdateCurrentTick: price source account must be authorized" + ); + + let mut stored = CurrentTickAccount::try_from(¤t_tick_account.account.data) + .expect("UpdateCurrentTick: current tick account must be initialized"); + + let clock_data = ClockAccountData::from_bytes(clock.account.data.as_ref()); + stored.tick = tick; + stored.last_updated = clock_data.timestamp; + + let mut current_tick_account_post = current_tick_account.account.clone(); + current_tick_account_post.data = Data::from(&stored); + + vec![ + AccountPostState::new(current_tick_account_post), + AccountPostState::new(price_source.account.clone()), + AccountPostState::new(clock.account.clone()), + ] +} + +#[cfg(test)] +mod tests { + use nssa_core::account::{Account, AccountId, Nonce}; + use twap_oracle_core::compute_current_tick_account_pda; + + use super::*; + + const ORACLE_PROGRAM_ID: ProgramId = [77u32; 8]; + const CLOCK_PROGRAM_ID: ProgramId = [88u32; 8]; + + fn price_source_id() -> AccountId { + AccountId::new([1u8; 32]) + } + + fn clock_account_with_timestamp(timestamp: u64) -> AccountWithMetadata { + let data = ClockAccountData { + block_id: 0, + timestamp, + } + .to_bytes(); + AccountWithMetadata { + account: Account { + program_owner: CLOCK_PROGRAM_ID, + balance: 0, + data: Data::try_from(data).expect("ClockAccountData fits in Data"), + nonce: Nonce(0), + }, + is_authorized: false, + account_id: AccountId::new([99u8; 32]), + } + } + + fn price_source_authorized() -> AccountWithMetadata { + AccountWithMetadata { + account: Account { + program_owner: [42u32; 8], + balance: 0, + data: Data::default(), + nonce: Nonce(0), + }, + is_authorized: true, + account_id: price_source_id(), + } + } + + fn current_tick_account_initialized(tick: i32, last_updated: u64) -> AccountWithMetadata { + let stored = CurrentTickAccount { tick, last_updated }; + AccountWithMetadata { + account: Account { + program_owner: ORACLE_PROGRAM_ID, + balance: 0, + data: Data::from(&stored), + nonce: Nonce(0), + }, + is_authorized: false, + account_id: compute_current_tick_account_pda(ORACLE_PROGRAM_ID, price_source_id()), + } + } + + // ── happy path ──────────────────────────────────────────────────────────── + + #[test] + fn returns_three_post_states() { + let post_states = update_current_tick( + current_tick_account_initialized(0, 0), + price_source_authorized(), + clock_account_with_timestamp(1_000), + 100, + ORACLE_PROGRAM_ID, + ); + assert_eq!(post_states.len(), 3); + } + + #[test] + fn tick_updated_to_new_value() { + let post_states = update_current_tick( + current_tick_account_initialized(100, 0), + price_source_authorized(), + clock_account_with_timestamp(1_000), + 200, + ORACLE_PROGRAM_ID, + ); + let account = CurrentTickAccount::try_from(&post_states[0].account().data) + .expect("post state must contain a valid CurrentTickAccount"); + assert_eq!(account.tick, 200); + } + + #[test] + fn timestamp_updated_from_clock() { + let post_states = update_current_tick( + current_tick_account_initialized(0, 0), + price_source_authorized(), + clock_account_with_timestamp(999_000), + 0, + ORACLE_PROGRAM_ID, + ); + let account = CurrentTickAccount::try_from(&post_states[0].account().data) + .expect("post state must contain a valid CurrentTickAccount"); + assert_eq!(account.last_updated, 999_000); + } + + #[test] + fn positive_and_negative_ticks_accepted() { + for tick in [i32::MIN, -1, 0, 1, i32::MAX] { + let post_states = update_current_tick( + current_tick_account_initialized(0, 0), + price_source_authorized(), + clock_account_with_timestamp(0), + tick, + ORACLE_PROGRAM_ID, + ); + let account = CurrentTickAccount::try_from(&post_states[0].account().data) + .expect("post state must contain a valid CurrentTickAccount"); + assert_eq!(account.tick, tick); + } + } + + #[test] + fn price_source_and_clock_post_states_are_unchanged() { + let price_source = price_source_authorized(); + let clock = clock_account_with_timestamp(42_000); + + let post_states = update_current_tick( + current_tick_account_initialized(0, 0), + price_source.clone(), + clock.clone(), + 0, + ORACLE_PROGRAM_ID, + ); + + assert_eq!(*post_states[1].account(), price_source.account); + assert_eq!(*post_states[2].account(), clock.account); + } + + // ── precondition violations ─────────────────────────────────────────────── + + #[test] + #[should_panic(expected = "current tick account ID does not match expected PDA")] + fn wrong_account_id_panics() { + let mut wrong = current_tick_account_initialized(0, 0); + wrong.account_id = AccountId::new([0u8; 32]); + update_current_tick( + wrong, + price_source_authorized(), + clock_account_with_timestamp(0), + 0, + ORACLE_PROGRAM_ID, + ); + } + + #[test] + #[should_panic(expected = "current tick account must be initialized")] + fn uninitialized_account_panics() { + let uninit = AccountWithMetadata { + account: Account::default(), + is_authorized: false, + account_id: compute_current_tick_account_pda(ORACLE_PROGRAM_ID, price_source_id()), + }; + update_current_tick( + uninit, + price_source_authorized(), + clock_account_with_timestamp(0), + 0, + ORACLE_PROGRAM_ID, + ); + } + + #[test] + #[should_panic(expected = "price source account must be authorized")] + fn unauthorized_price_source_panics() { + let mut unauthorized = price_source_authorized(); + unauthorized.is_authorized = false; + update_current_tick( + current_tick_account_initialized(0, 0), + unauthorized, + clock_account_with_timestamp(0), + 0, + ORACLE_PROGRAM_ID, + ); + } + + /// An attacker who controls their own price source cannot update a different (victim's) + /// current tick account. The PDA is derived from the price source ID, so presenting an + /// authorized attacker source against the victim's account ID will always fail the PDA check. + #[test] + #[should_panic(expected = "current tick account ID does not match expected PDA")] + fn cannot_update_another_price_sources_tick_account() { + let victim_source_id = AccountId::new([2u8; 32]); + let victim_account_id = + compute_current_tick_account_pda(ORACLE_PROGRAM_ID, victim_source_id); + + let mut victim_account = current_tick_account_initialized(500, 1_000); + victim_account.account_id = victim_account_id; + + update_current_tick( + victim_account, + price_source_authorized(), // attacker controls price_source_id = [1u8; 32] + clock_account_with_timestamp(2_000), + 999, + ORACLE_PROGRAM_ID, + ); + } +}