feat(twap-oracle): implement PublishPrice with real tick-to-price conversion

Add PublishPrice — a permissionless instruction that computes the TWAP over a
PriceObservations buffer and writes it to the consumer-facing OraclePriceAccount.

Because each observations account is calibrated to a specific window_duration via
its sampling guard, the oldest valid entry is always the natural window start, so
the TWAP is computed over the full buffer span with no boundary search:

    t2        = most recent observation (write_index - 1, wrapping)
    t1        = oldest valid entry (0 if not full, write_index if full)
    twap_tick = (t2.tick_cumulative - t1.tick_cumulative) / (t2.ts - t1.ts)

If fewer than two observations exist the call is a silent no-op, leaving the price
account at timestamp = 0 (the uninitialized signal consumers already reject). While
the buffer is young the TWAP is computed over the available span, which may be
shorter than the requested window.

The TWAP tick is converted to an actual price ratio via the Uniswap v3 sqrtPriceX96
representation (pure integer, zkVM-safe): get_sqrt_ratio_at_tick(tick) then
sqrtPriceX96^2 / 2^128, yielding a Q64.64 fixed-point ratio stored in
OraclePriceAccount.price. The OraclePriceAccount stays source-agnostic — no tick or
Uniswap framing leaks into the standard. Out-of-range ticks clamp; ratios above 2^64
saturate at u128::MAX. Adds PRICE_FRACTIONAL_BITS = 64; removes the placeholder
TWAP_PRICE_BIAS / oracle_price_to_tick bias encoding.

Pulls in uniswap_v3_math 0.6.2 and alloy-primitives for the conversion. Pins ruint
to =1.17.0 (transitive via alloy-primitives): 1.18 raised its MSRV to rustc 1.90 but
the risc0 guest toolchain ships 1.88. Guest build verified for riscv32im.

Closes #117
This commit is contained in:
r4bbit
2026-06-02 19:52:48 +02:00
parent ee269ff22f
commit 51887aa3fb
10 changed files with 2918 additions and 178 deletions
Generated
+730 -57
View File
File diff suppressed because it is too large Load Diff
+12 -12
View File
@@ -342,6 +342,18 @@
}
],
"types": [
{
"name": "MetadataStandard",
"kind": "enum",
"variants": [
{
"name": "Simple"
},
{
"name": "Expanded"
}
]
},
{
"name": "ObservationEntry",
"kind": "struct",
@@ -355,18 +367,6 @@
"type": "i64"
}
]
},
{
"name": "MetadataStandard",
"kind": "enum",
"variants": [
{
"name": "Simple"
},
{
"name": "Expanded"
}
]
}
],
"instruction_type": "stablecoin_core::Instruction"
+33
View File
@@ -95,6 +95,39 @@
}
]
},
{
"name": "publish_price",
"accounts": [
{
"name": "price_observations",
"writable": false,
"signer": false,
"init": false
},
{
"name": "oracle_price_account",
"writable": false,
"signer": false,
"init": false
},
{
"name": "clock",
"writable": false,
"signer": false,
"init": false
}
],
"args": [
{
"name": "price_source_id",
"type": "account_id"
},
{
"name": "window_duration",
"type": "u64"
}
]
},
{
"name": "record_tick",
"accounts": [
File diff suppressed because it is too large Load Diff
+6
View File
@@ -12,3 +12,9 @@ borsh = { version = "1.5", features = ["derive"] }
serde = { version = "1.0", features = ["derive"] }
spel-framework-macros = { git = "https://github.com/logos-co/spel.git", tag = "v0.3.0", package = "spel-framework-macros" }
risc0-zkvm = { version = "=3.0.5", default-features = false }
uniswap_v3_math = "0.6.2"
alloy-primitives = { version = "1", default-features = false }
# Pin ruint (transitive via alloy-primitives) below 1.18, which raised its MSRV to rustc 1.90.
# The risc0 guest toolchain ships rustc 1.88, so 1.18+ fails the guest build. 1.17.0 (MSRV 1.85)
# is the newest compatible release. Remove this pin once the risc0 toolchain advances past 1.90.
ruint = { version = "=1.17.0", default-features = false }
+142
View File
@@ -82,6 +82,30 @@ pub enum Instruction {
/// New raw tick from the price source.
tick: i32,
},
/// Computes the TWAP over `window_duration` from the [`PriceObservations`] ring buffer and
/// writes the result to the [`OraclePriceAccount`].
///
/// Permissionless — anyone may call this. Returns all accounts unchanged (no-op) if the
/// ring buffer holds fewer than two observations. Once at least two observations exist the
/// TWAP is computed over the available history, which may be shorter than `window_duration`
/// while the buffer is young.
///
/// The resulting TWAP tick is stored in [`OraclePriceAccount::price`] via
/// [`tick_to_oracle_price`]. Consumers decode with [`oracle_price_to_tick`].
///
/// Required accounts (in order):
/// 1. Price observations account — initialized PDA derived from
/// `compute_price_observations_pda(self_program_id, price_source_id, window_duration)`.
/// 2. Oracle price account — initialized PDA derived from
/// `compute_oracle_price_account_pda(self_program_id, price_source_id, window_duration)`.
/// 3. Clock account — read-only; supplies the publication timestamp.
PublishPrice {
/// ID of the price source; used to verify both PDAs.
price_source_id: AccountId,
/// Duration of the TWAP window in milliseconds; used to verify both PDAs and to
/// locate the boundary observation in the ring buffer.
window_duration: u64,
},
/// Records the current tick from a [`CurrentTickAccount`] into a [`PriceObservations`]
/// ring buffer.
///
@@ -328,6 +352,53 @@ impl From<&OraclePriceAccount> for Data {
}
}
// ──────────────────────────────────────────────────────────────────────────────
// TWAP price encoding
// ──────────────────────────────────────────────────────────────────────────────
/// Number of fractional bits in the [`OraclePriceAccount::price`] fixed-point value.
///
/// The price is stored as a `Q64.64` ratio: `OraclePriceAccount::price / 2^PRICE_FRACTIONAL_BITS`
/// is the amount of `quote_asset` one unit of `base_asset` is worth. A consumer multiplies a
/// token amount by the price with `(amount * price) >> PRICE_FRACTIONAL_BITS`.
pub const PRICE_FRACTIONAL_BITS: u32 = 64;
/// Converts a TWAP tick into the `Q64.64` fixed-point price stored in
/// [`OraclePriceAccount::price`].
///
/// The price is `1.0001^tick`, computed via the Uniswap v3 `sqrtPriceX96` representation
/// (pure-integer, no floating point) and then squared back to a plain ratio:
///
/// ```text
/// sqrtPriceX96 = sqrt(1.0001^tick) * 2^96
/// price = sqrtPriceX96^2 / 2^128 = 1.0001^tick * 2^64 (Q64.64)
/// ```
///
/// `sqrtPriceX96^2` is computed with [`full_math::mul_div`] using a 512-bit intermediate, so it
/// never overflows for any valid tick. The tick is clamped to `[MIN_TICK, MAX_TICK]` and the
/// result saturates at `u128::MAX` for the (practically unreachable) ticks above ~443 636 whose
/// ratio would exceed `2^64`.
///
/// See `docs/twap-oracle-tick-to-price-conversion.md` for the full derivation.
#[must_use]
pub fn tick_to_oracle_price(tick: i32) -> u128 {
use alloy_primitives::U256;
use uniswap_v3_math::tick_math::{MAX_TICK, MIN_TICK};
// 2^128, used to bring sqrtPriceX96^2 (a Q128.128 square) down to Q64.64.
// Built from limbs (little-endian u64 words) to avoid arithmetic operators on U256.
const TWO_POW_128: U256 = U256::from_limbs([0, 0, 1, 0]);
let clamped_tick = tick.clamp(MIN_TICK, MAX_TICK);
let sqrt_price_x96 = uniswap_v3_math::tick_math::get_sqrt_ratio_at_tick(clamped_tick)
.expect("clamped tick is within [MIN_TICK, MAX_TICK]");
let price_q64_64 =
uniswap_v3_math::full_math::mul_div(sqrt_price_x96, sqrt_price_x96, TWO_POW_128)
.expect("1.0001^tick * 2^64 fits in U256 for any valid tick");
u128::try_from(price_q64_64).unwrap_or(u128::MAX)
}
// ──────────────────────────────────────────────────────────────────────────────
// Current tick account
// ──────────────────────────────────────────────────────────────────────────────
@@ -401,3 +472,74 @@ pub fn compute_current_tick_account_pda_seed(price_source_id: AccountId) -> PdaS
.expect("Hash output must be exactly 32 bytes long"),
)
}
#[cfg(test)]
mod tests {
use super::*;
/// `1.0` in Q64.64 is `2^64`.
const ONE_Q64_64: u128 = 1u128 << PRICE_FRACTIONAL_BITS;
#[test]
fn tick_zero_is_unit_price() {
// 1.0001^0 = 1.0 → exactly 2^64 in Q64.64.
assert_eq!(tick_to_oracle_price(0), ONE_Q64_64);
}
#[test]
fn positive_tick_is_above_unit() {
assert!(tick_to_oracle_price(1) > ONE_Q64_64);
assert!(tick_to_oracle_price(10_000) > ONE_Q64_64);
}
#[test]
fn negative_tick_is_below_unit() {
assert!(tick_to_oracle_price(-1) < ONE_Q64_64);
assert!(tick_to_oracle_price(-10_000) < ONE_Q64_64);
}
#[test]
fn price_is_monotonic_in_tick() {
let mut prev = tick_to_oracle_price(-50_000);
for tick in (-49_000..=50_000).step_by(1_000) {
let cur = tick_to_oracle_price(tick);
assert!(cur > prev, "price must increase with tick at {tick}");
prev = cur;
}
}
#[test]
fn tick_10000_matches_known_ratio() {
// 1.0001^10000 ≈ 2.71814. Check the ratio in milli-units (× 1000) lands in [2717, 2719]
// using integer math only — `price * 1000 / 2^64` ≈ 2718.
let price = tick_to_oracle_price(10_000);
let ratio_milli = price
.checked_mul(1_000)
.and_then(|scaled| scaled.checked_div(ONE_Q64_64))
.expect("price * 1000 fits in u128");
assert!(
(2_717..=2_719).contains(&ratio_milli),
"got {ratio_milli} / 1000"
);
}
#[test]
fn extreme_positive_tick_saturates() {
// 1.0001^MAX_TICK far exceeds 2^64, so the Q64.64 value saturates at u128::MAX.
let price = tick_to_oracle_price(uniswap_v3_math::tick_math::MAX_TICK);
assert_eq!(price, u128::MAX);
}
#[test]
fn ticks_beyond_bounds_are_clamped() {
// Ticks outside [MIN_TICK, MAX_TICK] must not panic; they clamp to the bound.
assert_eq!(
tick_to_oracle_price(i32::MAX),
tick_to_oracle_price(uniswap_v3_math::tick_math::MAX_TICK)
);
assert_eq!(
tick_to_oracle_price(i32::MIN),
tick_to_oracle_price(uniswap_v3_math::tick_math::MIN_TICK)
);
}
}
File diff suppressed because it is too large Load Diff
@@ -94,6 +94,33 @@ mod twap_oracle {
Ok(spel_framework::SpelOutput::execute(post_states, vec![]))
}
/// Computes the TWAP from the price observations ring buffer and writes it to the price
/// account.
///
/// Expected accounts:
/// 1. `price_observations` — initialized PDA owned by this oracle program.
/// 2. `oracle_price_account` — initialized PDA owned by this oracle program.
/// 3. `clock` — read-only LEZ clock account.
#[instruction]
pub fn publish_price(
ctx: ProgramContext,
price_observations: AccountWithMetadata,
oracle_price_account: AccountWithMetadata,
clock: AccountWithMetadata,
price_source_id: AccountId,
window_duration: u64,
) -> SpelResult {
let post_states = twap_oracle_program::publish_price::publish_price(
price_observations,
oracle_price_account,
clock,
price_source_id,
window_duration,
ctx.self_program_id,
);
Ok(spel_framework::SpelOutput::execute(post_states, vec![]))
}
/// Records the current tick into a price observations ring buffer.
///
/// Expected accounts:
+1
View File
@@ -5,5 +5,6 @@ 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 publish_price;
pub mod record_tick;
pub mod update_current_tick;
+510
View File
@@ -0,0 +1,510 @@
use clock_core::ClockAccountData;
use nssa_core::{
account::{AccountId, AccountWithMetadata, Data},
program::{AccountPostState, ProgramId},
};
use twap_oracle_core::{
compute_oracle_price_account_pda, compute_price_observations_pda, tick_to_oracle_price,
OraclePriceAccount, PriceObservations, OBSERVATIONS_CAPACITY,
};
/// Computes the TWAP over the full span of the [`PriceObservations`] ring buffer and writes
/// the result to the [`OraclePriceAccount`].
///
/// Each observations account is calibrated to a specific `window_duration` via its sampling
/// guard (`min_interval = window_duration / OBSERVATIONS_CAPACITY`), so the oldest valid entry
/// is always the natural start of the window — no boundary search is needed.
///
/// Returns all accounts unchanged when fewer than two observations are available. The price
/// account stays at `timestamp = 0` (uninitialized signal) until there is something to publish.
///
/// # Panics
/// Panics if:
/// - `price_observations.account_id` does not match
/// `compute_price_observations_pda(oracle_program_id, price_source_id, window_duration)`.
/// - `oracle_price_account.account_id` does not match
/// `compute_oracle_price_account_pda(oracle_program_id, price_source_id, window_duration)`.
/// - Either account is not a valid initialised account of its respective type.
pub fn publish_price(
price_observations: AccountWithMetadata,
oracle_price_account: AccountWithMetadata,
clock: AccountWithMetadata,
price_source_id: AccountId,
window_duration: u64,
oracle_program_id: ProgramId,
) -> Vec<AccountPostState> {
assert_eq!(
price_observations.account_id,
compute_price_observations_pda(oracle_program_id, price_source_id, window_duration),
"PublishPrice: price observations account ID does not match expected PDA"
);
assert_eq!(
oracle_price_account.account_id,
compute_oracle_price_account_pda(oracle_program_id, price_source_id, window_duration),
"PublishPrice: oracle price account ID does not match expected PDA"
);
let clock_data = ClockAccountData::from_bytes(clock.account.data.as_ref());
let now = clock_data.timestamp;
let observations = PriceObservations::try_from(&price_observations.account.data)
.expect("PublishPrice: price observations account must be initialized");
let mut price_account = OraclePriceAccount::try_from(&oracle_price_account.account.data)
.expect("PublishPrice: oracle price account must be initialized");
// No-op: need at least two observations to compute a TWAP.
if observations.total_entries < 2 {
return vec![
AccountPostState::new(price_observations.account.clone()),
AccountPostState::new(oracle_price_account.account.clone()),
AccountPostState::new(clock.account.clone()),
];
}
let capacity =
usize::try_from(OBSERVATIONS_CAPACITY).expect("OBSERVATIONS_CAPACITY fits in usize");
// t2: the most recent observation.
let t2_index = if observations.write_index == 0 {
capacity
.checked_sub(1)
.expect("OBSERVATIONS_CAPACITY is non-zero")
} else {
usize::try_from(
observations
.write_index
.checked_sub(1)
.expect("write_index > 0"),
)
.expect("write_index - 1 fits in usize")
};
// t1: the oldest valid observation. Once the buffer is full, the oldest entry sits at
// write_index (the slot about to be overwritten next). Before that, entries start at 0.
let is_full = observations.total_entries >= u64::from(OBSERVATIONS_CAPACITY);
let t1_index = if is_full {
usize::try_from(observations.write_index).expect("write_index fits in usize")
} else {
0
};
let t1 = observations
.entries
.get(t1_index)
.expect("t1_index is within bounds");
let t2 = observations
.entries
.get(t2_index)
.expect("t2_index is within bounds");
let elapsed_ms = t2
.timestamp
.checked_sub(t1.timestamp)
.expect("t2.timestamp >= t1.timestamp");
let cumulative_diff = t2
.tick_cumulative
.checked_sub(t1.tick_cumulative)
.expect("tick_cumulative difference fits in i64");
let elapsed_ms_i64 = i64::try_from(elapsed_ms).expect("elapsed_ms fits in i64");
let twap_tick_i64 = cumulative_diff
.checked_div(elapsed_ms_i64)
.expect("elapsed_ms is non-zero");
let twap_tick = i32::try_from(twap_tick_i64).expect("TWAP tick fits in i32");
price_account.price = tick_to_oracle_price(twap_tick);
price_account.timestamp = now;
let mut oracle_price_account_post = oracle_price_account.account.clone();
oracle_price_account_post.data = Data::from(&price_account);
vec![
AccountPostState::new(price_observations.account.clone()),
AccountPostState::new(oracle_price_account_post),
AccountPostState::new(clock.account.clone()),
]
}
#[cfg(test)]
mod tests {
use nssa_core::account::{Account, AccountId, Nonce};
use twap_oracle_core::{
compute_oracle_price_account_pda, compute_price_observations_pda, tick_to_oracle_price,
ObservationEntry, OraclePriceAccount, PriceObservations, OBSERVATIONS_CAPACITY,
};
use super::*;
const ORACLE_PROGRAM_ID: ProgramId = [77u32; 8];
const CLOCK_PROGRAM_ID: ProgramId = [88u32; 8];
const WINDOW_24H: u64 = 24 * 60 * 60 * 1_000;
fn price_source_id() -> AccountId {
AccountId::new([1u8; 32])
}
fn base_asset_id() -> AccountId {
AccountId::new([10u8; 32])
}
fn quote_asset_id() -> AccountId {
AccountId::new([11u8; 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]),
}
}
/// Builds a [`PriceObservations`] from `(timestamp_ms, tick_cumulative)` pairs written in
/// order starting at index 0. `write_index` is set to `entries_data.len()`.
fn make_price_observations(
entries_data: &[(u64, i64)],
last_recorded_tick: i32,
) -> AccountWithMetadata {
let capacity =
usize::try_from(OBSERVATIONS_CAPACITY).expect("OBSERVATIONS_CAPACITY fits in usize");
let mut entries = vec![ObservationEntry::default(); capacity];
for (i, &(timestamp, tick_cumulative)) in entries_data.iter().enumerate() {
*entries.get_mut(i).expect("i < capacity") = ObservationEntry {
timestamp,
tick_cumulative,
};
}
let write_index = u32::try_from(entries_data.len()).expect("entry count fits in u32");
let total_entries = u64::try_from(entries_data.len()).expect("entry count fits in u64");
let obs = PriceObservations {
price_source_id: price_source_id(),
write_index,
total_entries,
last_recorded_tick,
entries,
};
AccountWithMetadata {
account: Account {
program_owner: ORACLE_PROGRAM_ID,
balance: 0,
data: Data::from(&obs),
nonce: Nonce(0),
},
is_authorized: false,
account_id: compute_price_observations_pda(
ORACLE_PROGRAM_ID,
price_source_id(),
WINDOW_24H,
),
}
}
fn make_oracle_price_account() -> AccountWithMetadata {
let account = OraclePriceAccount {
base_asset: base_asset_id(),
quote_asset: quote_asset_id(),
price: 0,
timestamp: 0,
source_id: price_source_id(),
confidence_interval: 0,
};
AccountWithMetadata {
account: Account {
program_owner: ORACLE_PROGRAM_ID,
balance: 0,
data: Data::from(&account),
nonce: Nonce(0),
},
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 = publish_price(
make_price_observations(&[(0, 0), (WINDOW_24H, 0)], 0),
make_oracle_price_account(),
clock_account_with_timestamp(WINDOW_24H),
price_source_id(),
WINDOW_24H,
ORACLE_PROGRAM_ID,
);
assert_eq!(post_states.len(), 3);
}
#[test]
fn twap_tick_computed_and_stored_as_price() {
// Constant tick = 100 over 24 h → twap = 100
let cumulative = 100_i64
.checked_mul(i64::try_from(WINDOW_24H).expect("fits"))
.expect("100 * WINDOW_24H fits in i64");
let post_states = publish_price(
make_price_observations(&[(0, 0), (WINDOW_24H, cumulative)], 100),
make_oracle_price_account(),
clock_account_with_timestamp(WINDOW_24H),
price_source_id(),
WINDOW_24H,
ORACLE_PROGRAM_ID,
);
let account = OraclePriceAccount::try_from(&post_states[1].account().data)
.expect("valid OraclePriceAccount");
assert_eq!(account.price, tick_to_oracle_price(100));
}
#[test]
fn negative_twap_tick_stored_correctly() {
// Constant tick = -50 over 24 h → twap = -50
let cumulative = (-50_i64)
.checked_mul(i64::try_from(WINDOW_24H).expect("fits"))
.expect("-50 * WINDOW_24H fits in i64");
let post_states = publish_price(
make_price_observations(&[(0, 0), (WINDOW_24H, cumulative)], -50),
make_oracle_price_account(),
clock_account_with_timestamp(WINDOW_24H),
price_source_id(),
WINDOW_24H,
ORACLE_PROGRAM_ID,
);
let account = OraclePriceAccount::try_from(&post_states[1].account().data)
.expect("valid OraclePriceAccount");
assert_eq!(account.price, tick_to_oracle_price(-50));
}
#[test]
fn zero_twap_tick_stored_correctly() {
let post_states = publish_price(
make_price_observations(&[(0, 0), (WINDOW_24H, 0)], 0),
make_oracle_price_account(),
clock_account_with_timestamp(WINDOW_24H),
price_source_id(),
WINDOW_24H,
ORACLE_PROGRAM_ID,
);
let account = OraclePriceAccount::try_from(&post_states[1].account().data)
.expect("valid OraclePriceAccount");
assert_eq!(account.price, tick_to_oracle_price(0));
}
#[test]
fn timestamp_set_to_clock_now() {
let now = WINDOW_24H
.checked_mul(2)
.expect("WINDOW_24H * 2 fits in u64");
let post_states = publish_price(
make_price_observations(&[(0, 0), (WINDOW_24H, 0)], 0),
make_oracle_price_account(),
clock_account_with_timestamp(now),
price_source_id(),
WINDOW_24H,
ORACLE_PROGRAM_ID,
);
let account = OraclePriceAccount::try_from(&post_states[1].account().data)
.expect("valid OraclePriceAccount");
assert_eq!(account.timestamp, now);
}
#[test]
fn other_price_account_fields_preserved() {
let post_states = publish_price(
make_price_observations(&[(0, 0), (WINDOW_24H, 0)], 0),
make_oracle_price_account(),
clock_account_with_timestamp(WINDOW_24H),
price_source_id(),
WINDOW_24H,
ORACLE_PROGRAM_ID,
);
let account = OraclePriceAccount::try_from(&post_states[1].account().data)
.expect("valid OraclePriceAccount");
assert_eq!(account.base_asset, base_asset_id());
assert_eq!(account.quote_asset, quote_asset_id());
assert_eq!(account.source_id, price_source_id());
assert_eq!(account.confidence_interval, 0);
}
#[test]
fn price_observations_account_is_not_modified() {
let observations = make_price_observations(&[(0, 0), (WINDOW_24H, 0)], 0);
let post_states = publish_price(
observations.clone(),
make_oracle_price_account(),
clock_account_with_timestamp(WINDOW_24H),
price_source_id(),
WINDOW_24H,
ORACLE_PROGRAM_ID,
);
assert_eq!(*post_states[0].account(), observations.account);
}
#[test]
fn clock_account_is_not_modified() {
let clock = clock_account_with_timestamp(WINDOW_24H);
let post_states = publish_price(
make_price_observations(&[(0, 0), (WINDOW_24H, 0)], 0),
make_oracle_price_account(),
clock.clone(),
price_source_id(),
WINDOW_24H,
ORACLE_PROGRAM_ID,
);
assert_eq!(*post_states[2].account(), clock.account);
}
#[test]
fn twap_uses_oldest_and_newest_entries() {
// Three observations: tick 0 for first half, tick 200 for second half.
// t1 = entry[0] (oldest), t2 = entry[2] (newest).
// Average over full span = (0 * half + 200 * half) / full = 100.
let half = WINDOW_24H.checked_div(2).expect("fits");
let half_i64 = i64::try_from(half).expect("fits");
let full_i64 = i64::try_from(WINDOW_24H).expect("fits");
// entry[0]: t=0, cumulative=0 (tick was 0 before this)
// entry[1]: t=half, cumulative=0 (tick=0 held from 0..half, so 0*half=0)
// entry[2]: t=WINDOW_24H, cumulative=200*half (tick=200 held from half..full)
let cumulative_at_half = 0_i64;
let cumulative_at_full = 200_i64.checked_mul(half_i64).expect("fits");
let post_states = publish_price(
make_price_observations(
&[
(0, 0),
(half, cumulative_at_half),
(WINDOW_24H, cumulative_at_full),
],
200,
),
make_oracle_price_account(),
clock_account_with_timestamp(WINDOW_24H),
price_source_id(),
WINDOW_24H,
ORACLE_PROGRAM_ID,
);
let account = OraclePriceAccount::try_from(&post_states[1].account().data)
.expect("valid OraclePriceAccount");
// twap = (cumulative_at_full - 0) / (WINDOW_24H - 0) = 200*half / full = 200/2 = 100
let expected_tick = cumulative_at_full.checked_div(full_i64).expect("non-zero");
assert_eq!(
account.price,
tick_to_oracle_price(i32::try_from(expected_tick).expect("tick fits in i32"))
);
}
// ── no-op: insufficient history ───────────────────────────────────────────
#[test]
fn noop_when_only_one_observation() {
let initial = make_oracle_price_account();
let post_states = publish_price(
make_price_observations(&[(0, 0)], 0),
initial.clone(),
clock_account_with_timestamp(WINDOW_24H),
price_source_id(),
WINDOW_24H,
ORACLE_PROGRAM_ID,
);
assert_eq!(*post_states[1].account(), initial.account);
}
#[test]
fn noop_leaves_price_account_timestamp_at_zero() {
let post_states = publish_price(
make_price_observations(&[(0, 0)], 0),
make_oracle_price_account(),
clock_account_with_timestamp(WINDOW_24H),
price_source_id(),
WINDOW_24H,
ORACLE_PROGRAM_ID,
);
let account = OraclePriceAccount::try_from(&post_states[1].account().data)
.expect("valid OraclePriceAccount");
assert_eq!(account.timestamp, 0);
}
// ── precondition violations ───────────────────────────────────────────────
#[test]
#[should_panic(expected = "price observations account ID does not match expected PDA")]
fn wrong_price_observations_id_panics() {
let mut wrong = make_price_observations(&[(0, 0), (WINDOW_24H, 0)], 0);
wrong.account_id = AccountId::new([0u8; 32]);
publish_price(
wrong,
make_oracle_price_account(),
clock_account_with_timestamp(WINDOW_24H),
price_source_id(),
WINDOW_24H,
ORACLE_PROGRAM_ID,
);
}
#[test]
#[should_panic(expected = "oracle price account ID does not match expected PDA")]
fn wrong_oracle_price_account_id_panics() {
let mut wrong = make_oracle_price_account();
wrong.account_id = AccountId::new([0u8; 32]);
publish_price(
make_price_observations(&[(0, 0), (WINDOW_24H, 0)], 0),
wrong,
clock_account_with_timestamp(WINDOW_24H),
price_source_id(),
WINDOW_24H,
ORACLE_PROGRAM_ID,
);
}
#[test]
#[should_panic(expected = "price observations account must be initialized")]
fn uninitialized_price_observations_panics() {
let uninit = AccountWithMetadata {
account: Account::default(),
is_authorized: false,
account_id: compute_price_observations_pda(
ORACLE_PROGRAM_ID,
price_source_id(),
WINDOW_24H,
),
};
publish_price(
uninit,
make_oracle_price_account(),
clock_account_with_timestamp(WINDOW_24H),
price_source_id(),
WINDOW_24H,
ORACLE_PROGRAM_ID,
);
}
#[test]
#[should_panic(expected = "oracle price account must be initialized")]
fn uninitialized_oracle_price_account_panics() {
let uninit = AccountWithMetadata {
account: Account::default(),
is_authorized: false,
account_id: compute_oracle_price_account_pda(
ORACLE_PROGRAM_ID,
price_source_id(),
WINDOW_24H,
),
};
publish_price(
make_price_observations(&[(0, 0), (WINDOW_24H, 0)], 0),
uninit,
clock_account_with_timestamp(WINDOW_24H),
price_source_id(),
WINDOW_24H,
ORACLE_PROGRAM_ID,
);
}
}