diff --git a/artifacts/stablecoin-idl.json b/artifacts/stablecoin-idl.json index 0c06a6d..f90a1a5 100644 --- a/artifacts/stablecoin-idl.json +++ b/artifacts/stablecoin-idl.json @@ -315,7 +315,7 @@ }, { "name": "source_id", - "type": "string" + "type": "account_id" }, { "name": "confidence_interval", @@ -326,18 +326,6 @@ } ], "types": [ - { - "name": "MetadataStandard", - "kind": "enum", - "variants": [ - { - "name": "Simple" - }, - { - "name": "Expanded" - } - ] - }, { "name": "ObservationEntry", "kind": "struct", @@ -351,6 +339,18 @@ "type": "i64" } ] + }, + { + "name": "MetadataStandard", + "kind": "enum", + "variants": [ + { + "name": "Simple" + }, + { + "name": "Expanded" + } + ] } ], "instruction_type": "stablecoin_core::Instruction" diff --git a/artifacts/twap_oracle-idl.json b/artifacts/twap_oracle-idl.json index ee7ff93..028ab53 100644 --- a/artifacts/twap_oracle-idl.json +++ b/artifacts/twap_oracle-idl.json @@ -34,6 +34,47 @@ "type": "u64" } ] + }, + { + "name": "create_oracle_price_account", + "accounts": [ + { + "name": "oracle_price_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": "base_asset", + "type": "account_id" + }, + { + "name": "quote_asset", + "type": "account_id" + }, + { + "name": "initial_price", + "type": "u128" + }, + { + "name": "window_duration", + "type": "u64" + } + ] } ], "accounts": [ @@ -92,7 +133,7 @@ }, { "name": "source_id", - "type": "string" + "type": "account_id" }, { "name": "confidence_interval", diff --git a/programs/twap_oracle/core/src/lib.rs b/programs/twap_oracle/core/src/lib.rs index b4b275b..c194e16 100644 --- a/programs/twap_oracle/core/src/lib.rs +++ b/programs/twap_oracle/core/src/lib.rs @@ -9,10 +9,10 @@ use spel_framework_macros::account_type; /// TWAP Oracle Program Instruction. #[derive(Debug, Serialize, Deserialize)] pub enum Instruction { - /// Creates and initialises a price feed account for a price source and time window. + /// Creates and initialises a price observations account for a price source and time window. /// /// Required accounts (in order): - /// 1. Price feed account — uninitialized PDA derived from + /// 1. Price observations account — uninitialized PDA derived from /// `compute_price_observations_pda(self_program_id, price_source.account_id, /// window_duration)`. /// 2. Price source account — the account whose ID acts as the feed identifier (e.g. an AMM @@ -29,6 +29,37 @@ pub enum Instruction { /// It is also part of the PDA seed, so each window gets a distinct account. window_duration: u64, }, + /// Creates and initialises a canonical [`OraclePriceAccount`] for a price source and time + /// window. + /// + /// The account is initialised with the non-zero `initial_price` and the timestamp read from + /// the canonical 1-block clock. A zero price or zero timestamp is the "no valid price" + /// sentinel consumers reject, so an account is never created in that state. + /// + /// Required accounts (in order): + /// 1. Oracle price account — uninitialized PDA derived from + /// `compute_oracle_price_account_pda(self_program_id, price_source.account_id, + /// window_duration)`. + /// 2. Price source account — must be passed with `is_authorized = true` to prove the caller + /// controls it. Its ID ties this price account to the same source as the corresponding + /// [`PriceObservations`] account for the same window. + /// 3. Clock account — the canonical 1-block LEZ clock; supplies the initial timestamp. + CreateOraclePriceAccount { + /// Canonical identifier of the base asset being priced. + base_asset: AccountId, + /// Canonical identifier of the quote asset that denominates `price`. + quote_asset: AccountId, + /// Initial price as a `Q64.64` fixed-point value (real price = `initial_price / 2^64`). + /// + /// Must be non-zero; the caller is responsible for supplying a correctly-scaled + /// fixed-point value rather than a plain integer. + initial_price: u128, + /// Duration of the TWAP window this price account serves, in milliseconds. + /// + /// Part of the PDA seed, so each `(price_source, window)` pair maps to a distinct + /// oracle price account. + window_duration: u64, + }, } // ────────────────────────────────────────────────────────────────────────────── @@ -156,6 +187,48 @@ pub fn compute_price_observations_pda_seed( ) } +const ORACLE_PRICE_ACCOUNT_PDA_SEED: [u8; 32] = [3; 32]; + +/// Derives the [`AccountId`] for a price source's [`OraclePriceAccount`] PDA. +/// +/// The `window_duration` is included in the seed so that each `(price_source, window)` pair +/// maps to a distinct account, mirroring the [`PriceObservations`] PDA derivation. +#[must_use] +pub fn compute_oracle_price_account_pda( + oracle_program_id: ProgramId, + price_source_id: AccountId, + window_duration: u64, +) -> AccountId { + AccountId::for_public_pda( + &oracle_program_id, + &compute_oracle_price_account_pda_seed(price_source_id, window_duration), + ) +} + +/// Derives the [`PdaSeed`] for a price source's [`OraclePriceAccount`]. +/// +/// Hash input: `price_source_id (32 bytes) || window_duration_le (8 bytes) || +/// ORACLE_PRICE_ACCOUNT_PDA_SEED (32 bytes)`. +#[must_use] +pub fn compute_oracle_price_account_pda_seed( + price_source_id: AccountId, + window_duration: u64, +) -> PdaSeed { + use risc0_zkvm::sha::{Impl, Sha256}; + + let mut bytes = [0u8; 72]; + bytes[..32].copy_from_slice(&price_source_id.to_bytes()); + bytes[32..40].copy_from_slice(&window_duration.to_le_bytes()); + bytes[40..72].copy_from_slice(&ORACLE_PRICE_ACCOUNT_PDA_SEED); + + PdaSeed::new( + Impl::hash_bytes(&bytes) + .as_bytes() + .try_into() + .expect("Hash output must be exactly 32 bytes long"), + ) +} + /// Canonical oracle price account consumed by LEZ programs. /// /// Oracle producers own how this account is written; consumers only read and @@ -175,8 +248,9 @@ pub struct OraclePriceAccount { /// Price observation timestamp. Consumers choose the time unit by matching this with /// `max_age`. pub timestamp: u64, - /// Identifier of the source that populated this account, such as a TWAP or external adaptor. - pub source_id: String, + /// Identifier of the source account that populated this account, such as a TWAP program or + /// external adaptor. + pub source_id: AccountId, /// Source-provided confidence interval, or zero when the source does not provide one. pub confidence_interval: u128, } 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 83be8a2..4028256 100644 --- a/programs/twap_oracle/methods/guest/src/bin/twap_oracle.rs +++ b/programs/twap_oracle/methods/guest/src/bin/twap_oracle.rs @@ -1,6 +1,6 @@ #![cfg_attr(not(test), no_main)] -use nssa_core::account::AccountWithMetadata; +use nssa_core::account::{AccountId, AccountWithMetadata}; use spel_framework::context::ProgramContext; use spel_framework::prelude::*; @@ -39,4 +39,42 @@ mod twap_oracle { ); Ok(spel_framework::SpelOutput::execute(post_states, vec![])) } + + /// Creates and initialises a canonical oracle price account for a price source and time + /// window. + /// + /// Expected accounts: + /// 1. `oracle_price_account` — uninitialized PDA owned by this oracle program. + /// 2. `price_source` — account the caller controls (proven via `is_authorized = true`); + /// its ID ties this price account to the same source as the corresponding + /// `PriceObservations` account for the same window. + /// 3. `clock` — canonical 1-block LEZ clock account; supplies the initial timestamp. + #[expect( + clippy::too_many_arguments, + reason = "instruction interface requires explicit price account, source, and clock accounts alongside the asset pair, initial price, and window" + )] + #[instruction] + pub fn create_oracle_price_account( + ctx: ProgramContext, + oracle_price_account: AccountWithMetadata, + price_source: AccountWithMetadata, + clock: AccountWithMetadata, + base_asset: AccountId, + quote_asset: AccountId, + initial_price: u128, + window_duration: u64, + ) -> SpelResult { + let post_states = + twap_oracle_program::create_oracle_price_account::create_oracle_price_account( + oracle_price_account, + price_source, + clock, + base_asset, + quote_asset, + initial_price, + window_duration, + ctx.self_program_id, + ); + Ok(spel_framework::SpelOutput::execute(post_states, vec![])) + } } diff --git a/programs/twap_oracle/src/create_oracle_price_account.rs b/programs/twap_oracle/src/create_oracle_price_account.rs new file mode 100644 index 0000000..8735ef3 --- /dev/null +++ b/programs/twap_oracle/src/create_oracle_price_account.rs @@ -0,0 +1,524 @@ +use clock_core::{ClockAccountData, CLOCK_01_PROGRAM_ACCOUNT_ID}; +use nssa_core::{ + account::{Account, AccountId, AccountWithMetadata, Data}, + program::{AccountPostState, Claim, ProgramId}, +}; +use twap_oracle_core::{ + compute_oracle_price_account_pda, compute_oracle_price_account_pda_seed, OraclePriceAccount, +}; + +/// Creates and initialises an [`OraclePriceAccount`] for a price source account and time window. +/// +/// The account is initialised with a non-zero `initial_price` and the timestamp read from the +/// canonical 1-block LEZ clock. Both must be non-zero: a zero price or zero timestamp is the +/// sentinel consumers treat as "no valid price", so an account must never be created in that +/// state. `confidence_interval` starts at zero (the source may not provide one). +/// +/// `initial_price` is a `Q64.64` fixed-point value: the real price is `initial_price / 2^64`, so +/// `1.0` is `1 << 64`. The non-zero check rejects the sentinel but cannot validate scale — a +/// caller is responsible for supplying a correctly-scaled fixed-point price, not a plain integer. +/// +/// The timestamp is taken from `clock`, which must be [`CLOCK_01_PROGRAM_ACCOUNT_ID`]; it is never +/// caller-supplied, so it cannot be forged. +/// +/// Authorization is implicit in the PDA relationship: the oracle price account is derived from +/// `price_source.account_id` and `window_duration`, so whoever controls the price source +/// controls this account. +/// +/// # Panics +/// Panics if: +/// - `oracle_price_account.account_id` does not match +/// `compute_oracle_price_account_pda(oracle_program_id, price_source.account_id, +/// window_duration)`. +/// - `oracle_price_account.account` is not the default (already initialised). +/// - `price_source.is_authorized` is false (caller does not control the price source account). +/// - `clock.account_id` is not [`CLOCK_01_PROGRAM_ACCOUNT_ID`]. +/// - `initial_price` is zero. +/// - the clock timestamp is zero. +#[expect( + clippy::too_many_arguments, + reason = "instruction surface passes explicit account inputs alongside the asset pair, initial price, and window" +)] +pub fn create_oracle_price_account( + oracle_price_account: AccountWithMetadata, + price_source: AccountWithMetadata, + clock: AccountWithMetadata, + base_asset: AccountId, + quote_asset: AccountId, + initial_price: u128, + window_duration: u64, + oracle_program_id: ProgramId, +) -> Vec { + let price_source_id = price_source.account_id; + assert_eq!( + oracle_price_account.account_id, + compute_oracle_price_account_pda(oracle_program_id, price_source_id, window_duration), + "CreateOraclePriceAccount: oracle price account ID does not match expected PDA" + ); + assert_eq!( + oracle_price_account.account, + Account::default(), + "CreateOraclePriceAccount: oracle price account must be uninitialized" + ); + assert!( + price_source.is_authorized, + "CreateOraclePriceAccount: price source account must be authorized (caller must control it via a PDA)" + ); + assert_eq!( + clock.account_id, CLOCK_01_PROGRAM_ACCOUNT_ID, + "CreateOraclePriceAccount: clock account must be the canonical 1-block LEZ clock account" + ); + + let timestamp = ClockAccountData::from_bytes(clock.account.data.as_ref()).timestamp; + + assert!( + initial_price != 0, + "CreateOraclePriceAccount: initial price must be non-zero" + ); + assert!( + timestamp != 0, + "CreateOraclePriceAccount: clock timestamp must be non-zero" + ); + + let account = OraclePriceAccount { + base_asset, + quote_asset, + price: initial_price, + timestamp, + source_id: price_source_id, + confidence_interval: 0, + }; + + let mut oracle_price_account_post = oracle_price_account.account.clone(); + oracle_price_account_post.data = Data::from(&account); + + vec![ + AccountPostState::new_claimed( + oracle_price_account_post, + Claim::Pda(compute_oracle_price_account_pda_seed( + price_source_id, + window_duration, + )), + ), + AccountPostState::new(price_source.account.clone()), + AccountPostState::new(clock.account.clone()), + ] +} + +#[cfg(test)] +mod tests { + use nssa_core::account::Nonce; + + use super::*; + + const ORACLE_PROGRAM_ID: ProgramId = [77u32; 8]; + const CLOCK_PROGRAM_ID: ProgramId = [88u32; 8]; + /// 24-hour window in milliseconds. + const WINDOW_24H: u64 = 24 * 60 * 60 * 1_000; + /// A representative non-zero initialisation price. Prices are `Q64.64` fixed point + /// (`price / 2^64`), so this is `1.0`. + const INITIAL_PRICE: u128 = 1u128 << 64; + /// A representative non-zero clock timestamp (milliseconds since the Unix epoch). + const TIMESTAMP: u64 = 1_700_000_000_000; + + fn price_source_id() -> AccountId { + AccountId::new([1u8; 32]) + } + + fn base_asset() -> AccountId { + AccountId::new([10u8; 32]) + } + + fn quote_asset() -> AccountId { + AccountId::new([11u8; 32]) + } + + fn clock_account_with_id(timestamp: u64, account_id: AccountId) -> 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, + } + } + + fn clock_account(timestamp: u64) -> AccountWithMetadata { + clock_account_with_id(timestamp, CLOCK_01_PROGRAM_ACCOUNT_ID) + } + + 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 oracle_price_account_uninit() -> AccountWithMetadata { + AccountWithMetadata { + account: Account::default(), + is_authorized: false, + account_id: compute_oracle_price_account_pda( + ORACLE_PROGRAM_ID, + price_source_id(), + WINDOW_24H, + ), + } + } + + // ── happy path ──────────────────────────────────────────────────────────── + + #[test] + fn returns_three_post_states() { + let post_states = create_oracle_price_account( + oracle_price_account_uninit(), + price_source_authorized(), + clock_account(TIMESTAMP), + base_asset(), + quote_asset(), + INITIAL_PRICE, + WINDOW_24H, + ORACLE_PROGRAM_ID, + ); + assert_eq!(post_states.len(), 3); + } + + #[test] + fn oracle_price_account_post_state_is_pda_claimed() { + let post_states = create_oracle_price_account( + oracle_price_account_uninit(), + price_source_authorized(), + clock_account(TIMESTAMP), + base_asset(), + quote_asset(), + INITIAL_PRICE, + WINDOW_24H, + ORACLE_PROGRAM_ID, + ); + assert_eq!( + post_states[0].required_claim(), + Some(Claim::Pda(compute_oracle_price_account_pda_seed( + price_source_id(), + WINDOW_24H, + ))) + ); + } + + #[test] + fn price_source_and_clock_post_states_are_unchanged() { + let price_source = price_source_authorized(); + let clock = clock_account(TIMESTAMP); + let post_states = create_oracle_price_account( + oracle_price_account_uninit(), + price_source.clone(), + clock.clone(), + base_asset(), + quote_asset(), + INITIAL_PRICE, + WINDOW_24H, + ORACLE_PROGRAM_ID, + ); + assert_eq!(*post_states[1].account(), price_source.account); + assert_eq!(*post_states[2].account(), clock.account); + } + + #[test] + fn account_initialised_with_price_and_clock_timestamp() { + let post_states = create_oracle_price_account( + oracle_price_account_uninit(), + price_source_authorized(), + clock_account(TIMESTAMP), + base_asset(), + quote_asset(), + INITIAL_PRICE, + WINDOW_24H, + ORACLE_PROGRAM_ID, + ); + let account = OraclePriceAccount::try_from(&post_states[0].account().data) + .expect("post state must contain a valid OraclePriceAccount"); + assert_eq!(account.price, INITIAL_PRICE); + assert_eq!(account.timestamp, TIMESTAMP); + assert_eq!(account.confidence_interval, 0); + } + + #[test] + fn assets_and_source_id_stored_correctly() { + let post_states = create_oracle_price_account( + oracle_price_account_uninit(), + price_source_authorized(), + clock_account(TIMESTAMP), + base_asset(), + quote_asset(), + INITIAL_PRICE, + WINDOW_24H, + ORACLE_PROGRAM_ID, + ); + let account = OraclePriceAccount::try_from(&post_states[0].account().data) + .expect("post state must contain a valid OraclePriceAccount"); + assert_eq!(account.base_asset, base_asset()); + assert_eq!(account.quote_asset, quote_asset()); + assert_eq!(account.source_id, price_source_id()); + } + + /// `source_id` must always equal the price source's `account_id`, regardless of which + /// price source is used. This test uses a distinct source ID to make the invariant explicit. + #[test] + fn source_id_equals_price_source_account_id() { + let other_source_id = AccountId::new([99u8; 32]); + let other_source = AccountWithMetadata { + account: Account { + program_owner: [42u32; 8], + balance: 0, + data: Data::default(), + nonce: Nonce(0), + }, + is_authorized: true, + account_id: other_source_id, + }; + let other_price_account = AccountWithMetadata { + account: Account::default(), + is_authorized: false, + account_id: compute_oracle_price_account_pda( + ORACLE_PROGRAM_ID, + other_source_id, + WINDOW_24H, + ), + }; + let post_states = create_oracle_price_account( + other_price_account, + other_source, + clock_account(TIMESTAMP), + base_asset(), + quote_asset(), + INITIAL_PRICE, + WINDOW_24H, + ORACLE_PROGRAM_ID, + ); + let account = OraclePriceAccount::try_from(&post_states[0].account().data) + .expect("post state must contain a valid OraclePriceAccount"); + assert_eq!(account.source_id, other_source_id); + } + + #[test] + fn different_price_sources_produce_distinct_pdas() { + let other_source_id = AccountId::new([2u8; 32]); + assert_ne!( + compute_oracle_price_account_pda(ORACLE_PROGRAM_ID, price_source_id(), WINDOW_24H), + compute_oracle_price_account_pda(ORACLE_PROGRAM_ID, other_source_id, WINDOW_24H), + ); + } + + #[test] + fn different_windows_produce_distinct_pdas() { + let window_7d = 7 * 24 * 60 * 60 * 1_000u64; + assert_ne!( + compute_oracle_price_account_pda(ORACLE_PROGRAM_ID, price_source_id(), WINDOW_24H), + compute_oracle_price_account_pda(ORACLE_PROGRAM_ID, price_source_id(), window_7d), + ); + } + + #[test] + fn oracle_price_account_pda_differs_from_price_observations_pda() { + use twap_oracle_core::compute_price_observations_pda; + assert_ne!( + compute_oracle_price_account_pda(ORACLE_PROGRAM_ID, price_source_id(), WINDOW_24H), + compute_price_observations_pda(ORACLE_PROGRAM_ID, price_source_id(), WINDOW_24H), + ); + } + + /// A plain wallet account (no program owner, no data) can act as the price source just as + /// well as a program-owned PDA. Authorization is conveyed via `is_authorized = true` + /// regardless of account type. + #[test] + fn wallet_account_as_price_source_works() { + let wallet_id = AccountId::new([55u8; 32]); + let wallet = AccountWithMetadata { + account: Account { + program_owner: [0u32; 8], + balance: 1_000, + data: Data::default(), + nonce: Nonce(0), + }, + is_authorized: true, + account_id: wallet_id, + }; + let price_account = AccountWithMetadata { + account: Account::default(), + is_authorized: false, + account_id: compute_oracle_price_account_pda(ORACLE_PROGRAM_ID, wallet_id, WINDOW_24H), + }; + let post_states = create_oracle_price_account( + price_account, + wallet, + clock_account(TIMESTAMP), + base_asset(), + quote_asset(), + INITIAL_PRICE, + WINDOW_24H, + ORACLE_PROGRAM_ID, + ); + let account = OraclePriceAccount::try_from(&post_states[0].account().data) + .expect("post state must contain a valid OraclePriceAccount"); + assert_eq!(account.source_id, wallet_id); + assert_eq!(account.base_asset, base_asset()); + assert_eq!(account.quote_asset, quote_asset()); + } + + // ── precondition violations ─────────────────────────────────────────────── + + #[test] + #[should_panic(expected = "oracle price account ID does not match expected PDA")] + fn wrong_oracle_price_account_id_panics() { + let mut wrong = oracle_price_account_uninit(); + wrong.account_id = AccountId::new([0u8; 32]); + create_oracle_price_account( + wrong, + price_source_authorized(), + clock_account(TIMESTAMP), + base_asset(), + quote_asset(), + INITIAL_PRICE, + WINDOW_24H, + ORACLE_PROGRAM_ID, + ); + } + + #[test] + #[should_panic(expected = "oracle price account must be uninitialized")] + fn already_initialized_oracle_price_account_panics() { + let mut initialized = oracle_price_account_uninit(); + initialized.account.data = Data::try_from(vec![1u8; 10]).expect("fits in Data"); + create_oracle_price_account( + initialized, + price_source_authorized(), + clock_account(TIMESTAMP), + base_asset(), + quote_asset(), + INITIAL_PRICE, + WINDOW_24H, + 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_oracle_price_account( + oracle_price_account_uninit(), + unauthorized, + clock_account(TIMESTAMP), + base_asset(), + quote_asset(), + INITIAL_PRICE, + WINDOW_24H, + ORACLE_PROGRAM_ID, + ); + } + + /// An attacker who controls their own price source cannot register an oracle price account + /// that claims to be derived from a different (victim's) price source. + #[test] + #[should_panic(expected = "oracle price account ID does not match expected PDA")] + fn cannot_register_price_account_for_another_price_source() { + let victim_source_id = AccountId::new([2u8; 32]); + let victim_pda = + compute_oracle_price_account_pda(ORACLE_PROGRAM_ID, victim_source_id, WINDOW_24H); + let mut attacker_account = oracle_price_account_uninit(); + attacker_account.account_id = victim_pda; + create_oracle_price_account( + attacker_account, + price_source_authorized(), + clock_account(TIMESTAMP), + base_asset(), + quote_asset(), + INITIAL_PRICE, + WINDOW_24H, + ORACLE_PROGRAM_ID, + ); + } + + /// A zero initial price is the consumer-side "no valid price" sentinel and must never be + /// written at creation time. + #[test] + #[should_panic(expected = "initial price must be non-zero")] + fn zero_initial_price_panics() { + create_oracle_price_account( + oracle_price_account_uninit(), + price_source_authorized(), + clock_account(TIMESTAMP), + base_asset(), + quote_asset(), + 0, + WINDOW_24H, + ORACLE_PROGRAM_ID, + ); + } + + /// A zero timestamp is the consumer-side "no valid price" sentinel and must never be written + /// at creation time, even when the clock account itself reports zero. + #[test] + #[should_panic(expected = "clock timestamp must be non-zero")] + fn zero_clock_timestamp_panics() { + create_oracle_price_account( + oracle_price_account_uninit(), + price_source_authorized(), + clock_account(0), + base_asset(), + quote_asset(), + INITIAL_PRICE, + WINDOW_24H, + ORACLE_PROGRAM_ID, + ); + } + + /// The coarser-cadence clock accounts (10-block, 50-block) are rejected: the oracle must read + /// the most fine-grained 1-block clock. + #[test] + #[should_panic(expected = "clock account must be the canonical 1-block LEZ clock account")] + fn non_canonical_clock_account_id_panics() { + use clock_core::CLOCK_10_PROGRAM_ACCOUNT_ID; + create_oracle_price_account( + oracle_price_account_uninit(), + price_source_authorized(), + clock_account_with_id(TIMESTAMP, CLOCK_10_PROGRAM_ACCOUNT_ID), + base_asset(), + quote_asset(), + INITIAL_PRICE, + WINDOW_24H, + ORACLE_PROGRAM_ID, + ); + } + + /// An attacker cannot supply an account they control — even one whose data deserializes as a + /// valid [`ClockAccountData`] with a forged timestamp — in place of the system clock. + #[test] + #[should_panic(expected = "clock account must be the canonical 1-block LEZ clock account")] + fn forged_clock_account_panics() { + create_oracle_price_account( + oracle_price_account_uninit(), + price_source_authorized(), + clock_account_with_id(TIMESTAMP, AccountId::new([7u8; 32])), + base_asset(), + quote_asset(), + INITIAL_PRICE, + WINDOW_24H, + ORACLE_PROGRAM_ID, + ); + } +} diff --git a/programs/twap_oracle/src/lib.rs b/programs/twap_oracle/src/lib.rs index 2e60510..e029dea 100644 --- a/programs/twap_oracle/src/lib.rs +++ b/programs/twap_oracle/src/lib.rs @@ -2,4 +2,5 @@ pub use twap_oracle_core as core; +pub mod create_oracle_price_account; pub mod create_price_observations;