From 3ce998c37c4f34cdf55fac1f9e227e77ef0c9327 Mon Sep 17 00:00:00 2001 From: r4bbit <445106+0x-r4bbit@users.noreply.github.com> Date: Tue, 9 Jun 2026 14:13:47 +0200 Subject: [PATCH] fix(twap_oracle): validate clock account Ensures user controlled clock account is validated against constraints. --- artifacts/stablecoin-idl.json | 24 ++++----- .../src/create_price_observations.rs | 51 +++++++++++++++++-- 2 files changed, 60 insertions(+), 15 deletions(-) diff --git a/artifacts/stablecoin-idl.json b/artifacts/stablecoin-idl.json index 295608c..0c06a6d 100644 --- a/artifacts/stablecoin-idl.json +++ b/artifacts/stablecoin-idl.json @@ -326,6 +326,18 @@ } ], "types": [ + { + "name": "MetadataStandard", + "kind": "enum", + "variants": [ + { + "name": "Simple" + }, + { + "name": "Expanded" + } + ] + }, { "name": "ObservationEntry", "kind": "struct", @@ -339,18 +351,6 @@ "type": "i64" } ] - }, - { - "name": "MetadataStandard", - "kind": "enum", - "variants": [ - { - "name": "Simple" - }, - { - "name": "Expanded" - } - ] } ], "instruction_type": "stablecoin_core::Instruction" diff --git a/programs/twap_oracle/src/create_price_observations.rs b/programs/twap_oracle/src/create_price_observations.rs index 9c94226..bc1617e 100644 --- a/programs/twap_oracle/src/create_price_observations.rs +++ b/programs/twap_oracle/src/create_price_observations.rs @@ -1,4 +1,4 @@ -use clock_core::ClockAccountData; +use clock_core::{ClockAccountData, CLOCK_01_PROGRAM_ACCOUNT_ID}; use nssa_core::{ account::{Account, AccountWithMetadata, Data}, program::{AccountPostState, Claim, ProgramId}, @@ -14,12 +14,17 @@ use twap_oracle_core::{ /// from `price_source.account_id` and `window_duration`, so whoever controls the price source /// controls the observations account. /// +/// The initial observation timestamp is read from `clock`, which must be the canonical 1-block +/// LEZ system clock ([`CLOCK_01_PROGRAM_ACCOUNT_ID`]). Enforcing this prevents a caller from +/// supplying an account they control to seed the TWAP with a forged base timestamp. +/// /// # Panics /// Panics if: /// - `price_observations.account_id` does not match /// `compute_price_observations_pda(oracle_program_id, price_source.account_id, window_duration)`. /// - `price_observations.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`]. pub fn create_price_observations( price_observations: AccountWithMetadata, price_source: AccountWithMetadata, @@ -43,6 +48,10 @@ pub fn create_price_observations( price_source.is_authorized, "CreatePriceObservations: price source account must be authorized (caller must control it via a PDA)" ); + assert_eq!( + clock.account_id, CLOCK_01_PROGRAM_ACCOUNT_ID, + "CreatePriceObservations: clock account must be the canonical 1-block LEZ clock account" + ); let clock_data = ClockAccountData::from_bytes(clock.account.data.as_ref()); @@ -95,7 +104,7 @@ mod tests { AccountId::new([1u8; 32]) } - fn clock_account_with_timestamp(timestamp: u64) -> AccountWithMetadata { + fn clock_account_with_id(timestamp: u64, account_id: AccountId) -> AccountWithMetadata { let data = ClockAccountData { block_id: 0, timestamp, @@ -109,10 +118,14 @@ mod tests { nonce: Nonce(0), }, is_authorized: false, - account_id: AccountId::new([99u8; 32]), + account_id, } } + fn clock_account_with_timestamp(timestamp: u64) -> AccountWithMetadata { + clock_account_with_id(timestamp, CLOCK_01_PROGRAM_ACCOUNT_ID) + } + fn price_source_authorized() -> AccountWithMetadata { AccountWithMetadata { account: Account { @@ -385,4 +398,36 @@ mod tests { ORACLE_PROGRAM_ID, ); } + + /// The coarser-cadence clock accounts (10-block, 50-block) are still 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_price_observations( + price_observations_uninit(), + price_source_authorized(), + clock_account_with_id(0, CLOCK_10_PROGRAM_ACCOUNT_ID), + 0, + 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() { + let forged_clock = clock_account_with_id(9_999_999_999, AccountId::new([7u8; 32])); + create_price_observations( + price_observations_uninit(), + price_source_authorized(), + forged_clock, + 0, + WINDOW_24H, + ORACLE_PROGRAM_ID, + ); + } }