feat(twap-oracle): implement CreateCurrentTickAccount and UpdateCurrentTick

Add CurrentTickAccount — an oracle-owned PDA (one per price source) that holds
the latest raw tick written by the price source and a timestamp. The price source
calls UpdateCurrentTick after each price-changing operation; anyone can then call
RecordTick (upcoming) to advance the PriceObservations accumulator without
requiring the price source to be present. PDA is derived from price_source_id
only (no window) since a single current tick serves all time windows.
This commit is contained in:
r4bbit
2026-05-29 14:44:35 +02:00
parent bb55bd3ccd
commit ab6f4e7a52
7 changed files with 777 additions and 0 deletions
+16
View File
@@ -323,6 +323,22 @@
}
]
}
},
{
"name": "CurrentTickAccount",
"type": {
"kind": "struct",
"fields": [
{
"name": "tick",
"type": "i32"
},
{
"name": "last_updated",
"type": "u64"
}
]
}
}
],
"types": [
+74
View File
@@ -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": [
+103
View File
@@ -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, Self::Error> {
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"),
)
}
@@ -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![]))
}
}
@@ -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<AccountPostState> {
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,
);
}
}
+2
View File
@@ -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;
@@ -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<AccountPostState> {
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(&current_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,
);
}
}