lez-programs/programs/twap_oracle/src/create_price_observations.rs
r4bbit fe9d919299 feat(twap-oracle): implement CreatePriceObservations instruction
Adds the CreatePriceObservations instruction to the TWAP oracle program.
The instruction initialises a PriceObservations PDA for a given price
source account and time window, writing the initial tick and timestamp
as the first entry.

Key design decisions:

- Per-window accounts: each (price_source, window_duration) pair maps to
  a distinct PriceObservations PDA. The window duration is baked into the
  PDA seed so a single price source can support multiple TWAP windows
  (24h, 7d, 30d) at independent sampling rates without sharing a buffer.

- window_duration not stored on struct: it is implicit in the PDA address.
  Any reader that located the account already knows the window duration
  used to derive it. Storing it would be redundant.

- Authorization is implicit: the PriceObservations PDA is derived from
  the price source account ID, so is_authorized = true on the price source
  proves the caller controls it without a redundant authority field.

- Impersonation is prevented by the PDA check: passing a controlled price
  source with a victim's observations account ID fails immediately because
  the computed PDA (from the attacker's source) does not match.

Closes #126
2026-06-09 13:23:46 +02:00

389 lines
13 KiB
Rust

use clock_core::ClockAccountData;
use nssa_core::{
account::{Account, AccountWithMetadata, Data},
program::{AccountPostState, Claim, ProgramId},
};
use twap_oracle_core::{
compute_price_observations_pda, compute_price_observations_pda_seed, ObservationEntry,
PriceObservations, OBSERVATIONS_CAPACITY,
};
/// Creates and initialises a [`PriceObservations`] for a price source account and time window.
///
/// Authorization is implicit in the PDA relationship: the price observations account is derived
/// from `price_source.account_id` and `window_duration`, so whoever controls the price source
/// controls the observations account.
///
/// # 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).
pub fn create_price_observations(
price_observations: AccountWithMetadata,
price_source: AccountWithMetadata,
clock: AccountWithMetadata,
initial_tick: i32,
window_duration: u64,
oracle_program_id: ProgramId,
) -> Vec<AccountPostState> {
let price_source_id = price_source.account_id;
assert_eq!(
price_observations.account_id,
compute_price_observations_pda(oracle_program_id, price_source_id, window_duration),
"CreatePriceObservations: price observations account ID does not match expected PDA"
);
assert_eq!(
price_observations.account,
Account::default(),
"CreatePriceObservations: price observations account must be uninitialized"
);
assert!(
price_source.is_authorized,
"CreatePriceObservations: price source account must be authorized (caller must control it via a PDA)"
);
let clock_data = ClockAccountData::from_bytes(clock.account.data.as_ref());
let capacity =
usize::try_from(OBSERVATIONS_CAPACITY).expect("OBSERVATIONS_CAPACITY fits in usize");
let mut entries = vec![ObservationEntry::default(); capacity];
*entries
.first_mut()
.expect("OBSERVATIONS_CAPACITY is non-zero") = ObservationEntry {
timestamp: clock_data.timestamp,
tick_cumulative: 0,
};
let observations = PriceObservations {
price_source_id,
write_index: 1,
total_entries: 1,
last_recorded_tick: initial_tick,
entries,
};
let mut price_observations_post = price_observations.account.clone();
price_observations_post.data = Data::from(&observations);
vec![
AccountPostState::new_claimed(
price_observations_post,
Claim::Pda(compute_price_observations_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::{AccountId, Nonce};
use super::*;
const ORACLE_PROGRAM_ID: ProgramId = [77u32; 8];
const CLOCK_PROGRAM_ID: ProgramId = [88u32; 8];
/// 24-hour window in milliseconds, used as the default window for tests.
const WINDOW_24H: u64 = 24 * 60 * 60 * 1_000;
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 price_observations_uninit() -> AccountWithMetadata {
AccountWithMetadata {
account: Account::default(),
is_authorized: false,
account_id: compute_price_observations_pda(
ORACLE_PROGRAM_ID,
price_source_id(),
WINDOW_24H,
),
}
}
// ── happy path ────────────────────────────────────────────────────────────
#[test]
fn returns_three_post_states() {
let post_states = create_price_observations(
price_observations_uninit(),
price_source_authorized(),
clock_account_with_timestamp(0),
0,
WINDOW_24H,
ORACLE_PROGRAM_ID,
);
assert_eq!(post_states.len(), 3);
}
#[test]
fn price_observations_post_state_is_pda_claimed() {
let post_states = create_price_observations(
price_observations_uninit(),
price_source_authorized(),
clock_account_with_timestamp(0),
0,
WINDOW_24H,
ORACLE_PROGRAM_ID,
);
assert_eq!(
post_states[0].required_claim(),
Some(Claim::Pda(compute_price_observations_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_with_timestamp(42_000);
let post_states = create_price_observations(
price_observations_uninit(),
price_source.clone(),
clock.clone(),
10,
WINDOW_24H,
ORACLE_PROGRAM_ID,
);
assert_eq!(*post_states[1].account(), price_source.account);
assert_eq!(*post_states[2].account(), clock.account);
}
#[test]
fn initial_observation_has_zero_cumulative_and_correct_timestamp() {
let timestamp = 123_456_789u64;
let post_states = create_price_observations(
price_observations_uninit(),
price_source_authorized(),
clock_account_with_timestamp(timestamp),
-42,
WINDOW_24H,
ORACLE_PROGRAM_ID,
);
let feed = PriceObservations::try_from(&post_states[0].account().data)
.expect("post state must contain a valid PriceObservations");
assert_eq!(feed.entries[0].tick_cumulative, 0);
assert_eq!(feed.entries[0].timestamp, timestamp);
}
#[test]
fn initial_tick_stored_as_last_recorded_tick() {
let initial_tick = -42i32;
let post_states = create_price_observations(
price_observations_uninit(),
price_source_authorized(),
clock_account_with_timestamp(0),
initial_tick,
WINDOW_24H,
ORACLE_PROGRAM_ID,
);
let feed = PriceObservations::try_from(&post_states[0].account().data)
.expect("post state must contain a valid PriceObservations");
assert_eq!(feed.last_recorded_tick, initial_tick);
}
#[test]
fn write_index_and_total_entries_start_at_one() {
let post_states = create_price_observations(
price_observations_uninit(),
price_source_authorized(),
clock_account_with_timestamp(0),
0,
WINDOW_24H,
ORACLE_PROGRAM_ID,
);
let feed = PriceObservations::try_from(&post_states[0].account().data)
.expect("post state must contain a valid PriceObservations");
assert_eq!(feed.write_index, 1);
assert_eq!(feed.total_entries, 1);
}
#[test]
fn remaining_entries_are_default() {
let post_states = create_price_observations(
price_observations_uninit(),
price_source_authorized(),
clock_account_with_timestamp(0),
0,
WINDOW_24H,
ORACLE_PROGRAM_ID,
);
let feed = PriceObservations::try_from(&post_states[0].account().data)
.expect("post state must contain a valid PriceObservations");
assert_eq!(
feed.entries.len(),
usize::try_from(OBSERVATIONS_CAPACITY).expect("OBSERVATIONS_CAPACITY fits in usize")
);
assert!(feed.entries[1..]
.iter()
.all(|e| *e == ObservationEntry::default()));
}
#[test]
fn price_source_id_stored_correctly() {
let post_states = create_price_observations(
price_observations_uninit(),
price_source_authorized(),
clock_account_with_timestamp(0),
0,
WINDOW_24H,
ORACLE_PROGRAM_ID,
);
let feed = PriceObservations::try_from(&post_states[0].account().data)
.expect("post state must contain a valid PriceObservations");
assert_eq!(feed.price_source_id, price_source_id());
}
#[test]
fn different_windows_produce_distinct_pdas() {
let window_24h = 24 * 60 * 60 * 1_000u64;
let window_7d = 7 * 24 * 60 * 60 * 1_000u64;
assert_ne!(
compute_price_observations_pda(ORACLE_PROGRAM_ID, price_source_id(), window_24h),
compute_price_observations_pda(ORACLE_PROGRAM_ID, price_source_id(), window_7d),
);
}
#[test]
fn positive_and_negative_initial_ticks_stored_as_last_recorded_tick() {
for tick in [i32::MIN, -1, 0, 1, i32::MAX] {
let post_states = create_price_observations(
price_observations_uninit(),
price_source_authorized(),
clock_account_with_timestamp(0),
tick,
WINDOW_24H,
ORACLE_PROGRAM_ID,
);
let feed = PriceObservations::try_from(&post_states[0].account().data)
.expect("post state must contain a valid PriceObservations");
assert_eq!(feed.last_recorded_tick, tick);
}
}
// ── precondition violations ───────────────────────────────────────────────
#[test]
#[should_panic(expected = "price observations account ID does not match expected PDA")]
fn wrong_price_feed_account_id_panics() {
let mut wrong_feed = price_observations_uninit();
wrong_feed.account_id = AccountId::new([0u8; 32]);
create_price_observations(
wrong_feed,
price_source_authorized(),
clock_account_with_timestamp(0),
0,
WINDOW_24H,
ORACLE_PROGRAM_ID,
);
}
/// An attacker who controls their own price source cannot register an observations account
/// that claims to be derived from a *different* (victim's) price source.
///
/// The PDA derivation ties the observations account to the price source that was passed: if
/// the caller supplies their own authorized source but the victim's observations account ID,
/// the PDA check will fail because the computed PDA (from attacker's source) won't match.
#[test]
#[should_panic(expected = "price observations account ID does not match expected PDA")]
fn cannot_register_observations_for_another_price_source() {
let victim_source_id = AccountId::new([2u8; 32]);
// The attacker passes the victim's observations PDA as the target account…
let victim_observations_pda =
compute_price_observations_pda(ORACLE_PROGRAM_ID, victim_source_id, WINDOW_24H);
let mut attacker_observations = price_observations_uninit();
attacker_observations.account_id = victim_observations_pda;
// …but only controls their own price source (price_source_id = [1u8; 32]).
create_price_observations(
attacker_observations,
price_source_authorized(),
clock_account_with_timestamp(0),
0,
WINDOW_24H,
ORACLE_PROGRAM_ID,
);
}
#[test]
#[should_panic(expected = "price observations account must be uninitialized")]
fn already_initialized_price_feed_panics() {
let mut initialized_feed = price_observations_uninit();
initialized_feed.account.data = Data::try_from(vec![1u8; 10]).expect("fits in Data");
create_price_observations(
initialized_feed,
price_source_authorized(),
clock_account_with_timestamp(0),
0,
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_price_observations(
price_observations_uninit(),
unauthorized,
clock_account_with_timestamp(0),
0,
WINDOW_24H,
ORACLE_PROGRAM_ID,
);
}
}