feat(twap-oracle): implement CreateOraclePriceAccount instruction

Adds the CreateOraclePriceAccount instruction to the TWAP oracle program.
The instruction initialises a canonical OraclePriceAccount PDA for a given
price source and time window. The account starts with price = 0 and
timestamp = 0 — a deliberately invalid sentinel state that signals "not
yet published". Consumers are expected to reject any account whose
timestamp is zero or stale, so the transient invalid state requires no
special on-chain enforcement.

- PDA mirrors PriceObservations: derived from (oracle_program_id,
  price_source_id, window_duration) with a distinct seed constant, so
  each (source, window) pair maps to a distinct oracle price account that
  cannot collide with its corresponding observations account.

- source_id is not a parameter: it is always set to price_source.account_id.
  Accepting it as a free parameter would allow callers to register a price
  account that claims to represent a source it does not control. Deriving
  it from the authorized price source account closes that vector entirely.

- Authorization follows the same model as CreatePriceObservations:
  is_authorized = true on the price source proves the caller controls it;
  the PDA check ensures the supplied oracle price account address is the
  one derived from that specific source and window.

- price = 0 / timestamp = 0 is the correct initial state: coupling account
  creation to first publication would require the observation account to
  already hold a full window of ticks, blocking registration for up to the
  window duration. Consumers must validate oracle prices regardless, so the
  zero sentinel falls naturally out of the staleness check they already own.

Closes #129
This commit is contained in:
r4bbit
2026-05-29 09:32:43 +02:00
parent c59316b4a2
commit bb55bd3ccd
6 changed files with 516 additions and 19 deletions
+71 -4
View File
@@ -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,30 @@ 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 price and timestamp start at zero and are populated later by a `PublishPrice`
/// instruction. Consumers must reject accounts whose `timestamp` is zero or stale.
///
/// 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.
CreateOraclePriceAccount {
/// Canonical identifier of the base asset being priced.
base_asset: AccountId,
/// Canonical identifier of the quote asset that denominates `price`.
quote_asset: AccountId,
/// 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 +180,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 +241,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,
}
@@ -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,33 @@ 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.
#[instruction]
pub fn create_oracle_price_account(
ctx: ProgramContext,
oracle_price_account: AccountWithMetadata,
price_source: AccountWithMetadata,
base_asset: AccountId,
quote_asset: AccountId,
window_duration: u64,
) -> SpelResult {
let post_states =
twap_oracle_program::create_oracle_price_account::create_oracle_price_account(
oracle_price_account,
price_source,
base_asset,
quote_asset,
window_duration,
ctx.self_program_id,
);
Ok(spel_framework::SpelOutput::execute(post_states, vec![]))
}
}
@@ -0,0 +1,369 @@
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 `price = 0`, `timestamp = 0`, and `confidence_interval = 0`.
/// These are populated later by a `PublishPrice` instruction. Consumers must reject accounts
/// whose `timestamp` is zero or stale.
///
/// 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).
pub fn create_oracle_price_account(
oracle_price_account: AccountWithMetadata,
price_source: AccountWithMetadata,
base_asset: AccountId,
quote_asset: AccountId,
window_duration: u64,
oracle_program_id: ProgramId,
) -> Vec<AccountPostState> {
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)"
);
let account = OraclePriceAccount {
base_asset,
quote_asset,
price: 0,
timestamp: 0,
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()),
]
}
#[cfg(test)]
mod tests {
use nssa_core::account::Nonce;
use super::*;
const ORACLE_PROGRAM_ID: ProgramId = [77u32; 8];
/// 24-hour window in milliseconds.
const WINDOW_24H: u64 = 24 * 60 * 60 * 1_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 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_two_post_states() {
let post_states = create_oracle_price_account(
oracle_price_account_uninit(),
price_source_authorized(),
base_asset(),
quote_asset(),
WINDOW_24H,
ORACLE_PROGRAM_ID,
);
assert_eq!(post_states.len(), 2);
}
#[test]
fn oracle_price_account_post_state_is_pda_claimed() {
let post_states = create_oracle_price_account(
oracle_price_account_uninit(),
price_source_authorized(),
base_asset(),
quote_asset(),
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_post_state_is_unchanged() {
let price_source = price_source_authorized();
let post_states = create_oracle_price_account(
oracle_price_account_uninit(),
price_source.clone(),
base_asset(),
quote_asset(),
WINDOW_24H,
ORACLE_PROGRAM_ID,
);
assert_eq!(*post_states[1].account(), price_source.account);
}
#[test]
fn account_initialised_with_zero_price_and_timestamp() {
let post_states = create_oracle_price_account(
oracle_price_account_uninit(),
price_source_authorized(),
base_asset(),
quote_asset(),
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, 0);
assert_eq!(account.timestamp, 0);
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(),
base_asset(),
quote_asset(),
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,
base_asset(),
quote_asset(),
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,
base_asset(),
quote_asset(),
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(),
base_asset(),
quote_asset(),
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(),
base_asset(),
quote_asset(),
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,
base_asset(),
quote_asset(),
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(),
base_asset(),
quote_asset(),
WINDOW_24H,
ORACLE_PROGRAM_ID,
);
}
}
+1
View File
@@ -2,4 +2,5 @@
pub use twap_oracle_core as core;
pub mod create_oracle_price_account;
pub mod create_price_observations;