mirror of
https://github.com/logos-blockchain/lez-programs.git
synced 2026-06-28 19:19:25 +00:00
feat(twap-oracle): implement PublishPrice with tick-to-price conversion and tail extrapolation
Add PublishPrice — a permissionless instruction that computes the TWAP over a
PriceObservations buffer, extrapolated to the current time, and writes it to the
consumer-facing OraclePriceAccount.
The stored body averages [t1, t2] (t1 = oldest valid entry, t2 = most recent),
needing no boundary search since each buffer is calibrated to one window_duration.
The final segment from t2 to `now` is extrapolated from the live tick in the
CurrentTickAccount (added as a fourth account), mirroring Uniswap's
OracleLibrary.consult. This keeps the published timestamp = now truthful: an
unchanged price yields a fresh stamp and the correct value, and a republish picks
up a since-reported move instead of freezing the pre-move average.
The live tick is only credited since it was written, so the tail is split at the
current tick's last_updated:
boundary = clamp(current_tick.last_updated, t2.ts, now)
clamped_tick = last_recorded_tick + clamp(current_tick - last_recorded_tick, ±MAX_TICK_DELTA)
cum_now = t2.tick_cumulative
+ last_recorded_tick * (boundary - t2.ts) // before the live tick took effect
+ clamped_tick * (now - boundary) // live tick, only since last_updated
twap_tick = (cum_now - t1.tick_cumulative) / (now - t1.ts) // floor (div_euclid)
Splitting at last_updated stops a tick written moments before publish from being
smeared across a stale gap and inflating a supposedly fresh TWAP. The live-tick
segment is clamped against last_recorded_tick by MAX_TICK_DELTA — the same bound
RecordTick applies — capping how far a current-tick move can shift the result. A
zero-length tail (now == t2.ts) leaves the pure stored-window average.
If fewer than two observations exist the call is a silent no-op, leaving the price
account at timestamp = 0 (the uninitialized signal consumers reject). While young,
the TWAP covers the available span, which may be shorter than the window.
The TWAP tick is converted to a price ratio via the Uniswap v3 sqrtPriceX96
representation (pure integer, zkVM-safe), stored as a Q64.64 in
OraclePriceAccount.price — source-agnostic, no tick 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 encoding.
Closes #117
This commit is contained in:
parent
03345db803
commit
c528d85a2b
@ -105,6 +105,45 @@
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "publish_price",
|
||||||
|
"accounts": [
|
||||||
|
{
|
||||||
|
"name": "price_observations",
|
||||||
|
"writable": false,
|
||||||
|
"signer": false,
|
||||||
|
"init": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "oracle_price_account",
|
||||||
|
"writable": false,
|
||||||
|
"signer": false,
|
||||||
|
"init": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "current_tick_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",
|
"name": "record_tick",
|
||||||
"accounts": [
|
"accounts": [
|
||||||
|
|||||||
@ -94,6 +94,40 @@ pub enum Instruction {
|
|||||||
/// tick on-chain.
|
/// tick on-chain.
|
||||||
price: u128,
|
price: u128,
|
||||||
},
|
},
|
||||||
|
/// 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 converted to a `Q64.64` price ratio via [`tick_to_oracle_price`]
|
||||||
|
/// and stored in [`OraclePriceAccount::price`]. The stored value is a plain price ratio, not a
|
||||||
|
/// tick encoding, so consumers read it directly (`price / 2^PRICE_FRACTIONAL_BITS`) and the
|
||||||
|
/// account stays source-agnostic — a non-tick source (e.g. RedStone, Pyth) writes the same
|
||||||
|
/// field.
|
||||||
|
///
|
||||||
|
/// 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. Current tick account — initialized PDA derived from
|
||||||
|
/// `compute_current_tick_account_pda(self_program_id, price_source_id)`. Supplies the live
|
||||||
|
/// tick used to extend the average from the newest stored observation up to `now`.
|
||||||
|
/// 4. Clock account — read-only; supplies the publication timestamp.
|
||||||
|
PublishPrice {
|
||||||
|
/// ID of the price source; used to verify all three PDAs.
|
||||||
|
price_source_id: AccountId,
|
||||||
|
/// Duration of the TWAP window in milliseconds. Used only to verify the price
|
||||||
|
/// observations and oracle price account PDAs (the current tick account PDA does not
|
||||||
|
/// depend on it). No boundary search is performed: the oldest valid observation is the
|
||||||
|
/// ring buffer's natural start (entry 0 while partially filled, else the slot at
|
||||||
|
/// `write_index`), because each observations account is calibrated to one window via its
|
||||||
|
/// sampling guard.
|
||||||
|
window_duration: u64,
|
||||||
|
},
|
||||||
/// Records the current tick from a [`CurrentTickAccount`] into a [`PriceObservations`]
|
/// Records the current tick from a [`CurrentTickAccount`] into a [`PriceObservations`]
|
||||||
/// ring buffer.
|
/// ring buffer.
|
||||||
///
|
///
|
||||||
@ -418,6 +452,53 @@ fn integer_sqrt(n: alloy_primitives::U256) -> alloy_primitives::U256 {
|
|||||||
c
|
c
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ──────────────────────────────────────────────────────────────────────────────
|
||||||
|
// 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
|
// Current tick account
|
||||||
// ──────────────────────────────────────────────────────────────────────────────
|
// ──────────────────────────────────────────────────────────────────────────────
|
||||||
@ -622,4 +703,69 @@ mod tests {
|
|||||||
"root of a ~2^256 value should be ~2^128"
|
"root of a ~2^256 value should be ~2^128"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── tick_to_oracle_price ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[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)
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -105,6 +105,37 @@ mod twap_oracle {
|
|||||||
Ok(spel_framework::SpelOutput::execute(post_states, vec![]))
|
Ok(spel_framework::SpelOutput::execute(post_states, vec![]))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Computes the TWAP from the price observations ring buffer (extrapolated to `now` using the
|
||||||
|
/// current tick) 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. `current_tick_account` — initialized PDA owned by this oracle program; supplies the live
|
||||||
|
/// tick used to extend the average from the newest stored observation up to `now`.
|
||||||
|
/// 4. `clock` — read-only LEZ clock account.
|
||||||
|
#[instruction]
|
||||||
|
pub fn publish_price(
|
||||||
|
ctx: ProgramContext,
|
||||||
|
price_observations: AccountWithMetadata,
|
||||||
|
oracle_price_account: AccountWithMetadata,
|
||||||
|
current_tick_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,
|
||||||
|
current_tick_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.
|
/// Records the current tick into a price observations ring buffer.
|
||||||
///
|
///
|
||||||
/// Expected accounts:
|
/// Expected accounts:
|
||||||
|
|||||||
@ -5,5 +5,6 @@ pub use twap_oracle_core as core;
|
|||||||
pub mod create_current_tick_account;
|
pub mod create_current_tick_account;
|
||||||
pub mod create_oracle_price_account;
|
pub mod create_oracle_price_account;
|
||||||
pub mod create_price_observations;
|
pub mod create_price_observations;
|
||||||
|
pub mod publish_price;
|
||||||
pub mod record_tick;
|
pub mod record_tick;
|
||||||
pub mod update_current_tick;
|
pub mod update_current_tick;
|
||||||
|
|||||||
917
programs/twap_oracle/src/publish_price.rs
Normal file
917
programs/twap_oracle/src/publish_price.rs
Normal file
@ -0,0 +1,917 @@
|
|||||||
|
use clock_core::{ClockAccountData, CLOCK_01_PROGRAM_ACCOUNT_ID};
|
||||||
|
use nssa_core::{
|
||||||
|
account::{AccountId, AccountWithMetadata, Data},
|
||||||
|
program::{AccountPostState, ProgramId},
|
||||||
|
};
|
||||||
|
use twap_oracle_core::{
|
||||||
|
compute_current_tick_account_pda, compute_oracle_price_account_pda,
|
||||||
|
compute_price_observations_pda, tick_to_oracle_price, CurrentTickAccount, OraclePriceAccount,
|
||||||
|
PriceObservations, MAX_TICK_DELTA, OBSERVATIONS_CAPACITY,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Computes the TWAP over the span from the oldest stored observation up to `now` and writes the
|
||||||
|
/// result to the [`OraclePriceAccount`].
|
||||||
|
///
|
||||||
|
/// The *body* of the average comes from the [`PriceObservations`] ring buffer (oldest valid entry
|
||||||
|
/// `t1` through newest stored entry `t2`). The *final segment* from `t2` to `now` is extrapolated
|
||||||
|
/// from the [`CurrentTickAccount`], mirroring Uniswap's `OracleLibrary.consult`, which always
|
||||||
|
/// projects the accumulator to the present rather than reading only stored points.
|
||||||
|
///
|
||||||
|
/// Projecting to `now` is what makes the published `timestamp = now` truthful even when no tick has
|
||||||
|
/// been recorded for a while: a price that has simply not changed yields a *fresh* timestamp and
|
||||||
|
/// the correct (unchanged) value, instead of a stale window stamped with a current time. If the
|
||||||
|
/// price *did* move since `t2` and the move was reported to the current tick account, the tail
|
||||||
|
/// carries it forward (clamped) rather than publishing a frozen pre-move average.
|
||||||
|
///
|
||||||
|
/// The tail is split at the current tick's `last_updated`: the live tick is only known to have held
|
||||||
|
/// since then, so it is integrated only over `[last_updated, now]`, while `[t2, last_updated]`
|
||||||
|
/// carries the tick stored at t2 (`last_recorded_tick`). Without this split a tick written moments
|
||||||
|
/// before publish would be smeared across the whole unrecorded gap, letting a late spot move
|
||||||
|
/// dominate a long stale tail and inflating a supposedly fresh TWAP. The live-tick segment is
|
||||||
|
/// clamped against `last_recorded_tick` by [`MAX_TICK_DELTA`] — the same anti-manipulation bound
|
||||||
|
/// `RecordTick` applies — bounding how far an attacker who can move the current tick can shift the
|
||||||
|
/// published average.
|
||||||
|
///
|
||||||
|
/// 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.
|
||||||
|
///
|
||||||
|
/// The publication timestamp is taken from `clock`, which must be [`CLOCK_01_PROGRAM_ACCOUNT_ID`].
|
||||||
|
/// It becomes the consumer-facing freshness signal on the [`OraclePriceAccount`], so a forged or
|
||||||
|
/// caller-controlled clock could make a stale price look current; it must never be caller-supplied.
|
||||||
|
///
|
||||||
|
/// # 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)`.
|
||||||
|
/// - `current_tick_account.account_id` does not match
|
||||||
|
/// `compute_current_tick_account_pda(oracle_program_id, price_source_id)`.
|
||||||
|
/// - `clock.account_id` is not [`CLOCK_01_PROGRAM_ACCOUNT_ID`].
|
||||||
|
/// - Any account is not a valid initialised account of its respective type.
|
||||||
|
pub fn publish_price(
|
||||||
|
price_observations: AccountWithMetadata,
|
||||||
|
oracle_price_account: AccountWithMetadata,
|
||||||
|
current_tick_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"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
current_tick_account.account_id,
|
||||||
|
compute_current_tick_account_pda(oracle_program_id, price_source_id),
|
||||||
|
"PublishPrice: current tick account ID does not match expected PDA"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
clock.account_id, CLOCK_01_PROGRAM_ACCOUNT_ID,
|
||||||
|
"PublishPrice: clock account must be the canonical 1-block LEZ clock account"
|
||||||
|
);
|
||||||
|
|
||||||
|
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");
|
||||||
|
let current_tick_data = CurrentTickAccount::try_from(¤t_tick_account.account.data)
|
||||||
|
.expect("PublishPrice: current tick 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(current_tick_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");
|
||||||
|
|
||||||
|
// Extrapolate the accumulator from the newest stored observation (t2) to `now`. The live tick
|
||||||
|
// is only known to have held since `current_tick_data.last_updated`; before that, the best
|
||||||
|
// estimate for the unrecorded gap is the tick stored at t2 (`last_recorded_tick`). Splitting
|
||||||
|
// the tail at that boundary stops a tick written moments before publish from being smeared
|
||||||
|
// across the whole gap — which would let a late price move dominate a long stale tail. The
|
||||||
|
// live-tick segment is clamped against last_recorded_tick by MAX_TICK_DELTA — the same bound
|
||||||
|
// RecordTick applies. With no elapsed tail (`now == t2.timestamp`) both segments are empty and
|
||||||
|
// the TWAP is exactly the stored-window average.
|
||||||
|
let current_tick = current_tick_data.tick;
|
||||||
|
let delta = current_tick.saturating_sub(observations.last_recorded_tick);
|
||||||
|
let clamped_delta = delta.clamp(-MAX_TICK_DELTA, MAX_TICK_DELTA);
|
||||||
|
let clamped_tick = observations
|
||||||
|
.last_recorded_tick
|
||||||
|
.saturating_add(clamped_delta);
|
||||||
|
|
||||||
|
// `now` comes from the canonical clock and is monotonic, so it is never before the newest
|
||||||
|
// stored observation. The boundary is clamped into `[t2.timestamp, now]`: a `last_updated` at
|
||||||
|
// or before t2 means the live tick provably held for the whole tail (pre-boundary segment
|
||||||
|
// empty); one at or after `now` means the live tick has accrued no elapsed time yet
|
||||||
|
// (post-boundary segment empty). The clamp also keeps a degenerate clock from underflowing the
|
||||||
|
// saturating subtractions into a huge tail.
|
||||||
|
let boundary = current_tick_data.last_updated.clamp(t2.timestamp, now);
|
||||||
|
let pre_ms = boundary.saturating_sub(t2.timestamp);
|
||||||
|
let post_ms = now.saturating_sub(boundary);
|
||||||
|
let pre_ms_i64 = i64::try_from(pre_ms).expect("pre_ms fits in i64");
|
||||||
|
let post_ms_i64 = i64::try_from(post_ms).expect("post_ms fits in i64");
|
||||||
|
|
||||||
|
// Pre-boundary segment carries `last_recorded_tick` (the tick at t2); post-boundary segment
|
||||||
|
// carries the clamped live tick.
|
||||||
|
let pre_cumulative = i64::from(observations.last_recorded_tick)
|
||||||
|
.checked_mul(pre_ms_i64)
|
||||||
|
.expect("pre-boundary tail cumulative fits in i64");
|
||||||
|
let post_cumulative = i64::from(clamped_tick)
|
||||||
|
.checked_mul(post_ms_i64)
|
||||||
|
.expect("post-boundary tail cumulative fits in i64");
|
||||||
|
let cum_now = t2
|
||||||
|
.tick_cumulative
|
||||||
|
.checked_add(pre_cumulative)
|
||||||
|
.and_then(|acc| acc.checked_add(post_cumulative))
|
||||||
|
.expect("extrapolated tick_cumulative fits in i64");
|
||||||
|
|
||||||
|
// Average over [t1, now]. `now > t1.timestamp` because `now >= t2.timestamp > t1.timestamp`
|
||||||
|
// (the sampling guard makes stored timestamps strictly increasing), so the divisor is positive.
|
||||||
|
let elapsed_ms = now.checked_sub(t1.timestamp).expect("now >= t1.timestamp");
|
||||||
|
let elapsed_ms_i64 = i64::try_from(elapsed_ms).expect("elapsed_ms fits in i64");
|
||||||
|
let cumulative_diff = cum_now
|
||||||
|
.checked_sub(t1.tick_cumulative)
|
||||||
|
.expect("tick_cumulative difference fits in i64");
|
||||||
|
// Floor division (round toward −∞), matching Uniswap's `OracleLibrary.consult`. The divisor is
|
||||||
|
// always positive, so `div_euclid` is exactly the floor. Plain truncating division would round
|
||||||
|
// toward zero, biasing negative TWAPs upward by one tick.
|
||||||
|
let twap_tick_i64 = cumulative_diff
|
||||||
|
.checked_div_euclid(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(current_tick_account.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, compute_oracle_price_account_pda,
|
||||||
|
compute_price_observations_pda, tick_to_oracle_price, CurrentTickAccount, ObservationEntry,
|
||||||
|
OraclePriceAccount, PriceObservations, MAX_TICK_DELTA, 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_id(timestamp: u64, account_id: AccountId) -> 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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn clock_account_with_timestamp(timestamp: u64) -> AccountWithMetadata {
|
||||||
|
clock_account_with_id(timestamp, CLOCK_01_PROGRAM_ACCOUNT_ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Builds a [`CurrentTickAccount`] at the canonical PDA for [`price_source_id`].
|
||||||
|
fn make_current_tick_account(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()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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_four_post_states() {
|
||||||
|
let post_states = publish_price(
|
||||||
|
make_price_observations(&[(0, 0), (WINDOW_24H, 0)], 0),
|
||||||
|
make_oracle_price_account(),
|
||||||
|
make_current_tick_account(0, WINDOW_24H),
|
||||||
|
clock_account_with_timestamp(WINDOW_24H),
|
||||||
|
price_source_id(),
|
||||||
|
WINDOW_24H,
|
||||||
|
ORACLE_PROGRAM_ID,
|
||||||
|
);
|
||||||
|
assert_eq!(post_states.len(), 4);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn twap_tick_computed_and_stored_as_price() {
|
||||||
|
// Constant tick = 100 over 24 h, published exactly at the newest observation (zero tail) →
|
||||||
|
// 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(),
|
||||||
|
make_current_tick_account(100, WINDOW_24H),
|
||||||
|
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 (zero tail) → 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(),
|
||||||
|
make_current_tick_account(-50, WINDOW_24H),
|
||||||
|
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));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// When the average tick is negative and the division has a remainder, the result must round
|
||||||
|
/// down (toward −∞), like Uniswap's oracle. The cumulative here is `-(50·W + 1)` over a span of
|
||||||
|
/// `W` (zero tail), so the exact average is just below −50 and must floor to **−51**.
|
||||||
|
/// Truncating division would instead round toward zero and give −50 — one tick too high.
|
||||||
|
#[test]
|
||||||
|
fn negative_twap_with_remainder_floors_toward_negative_infinity() {
|
||||||
|
let elapsed_i64 = i64::try_from(WINDOW_24H).expect("fits");
|
||||||
|
let cumulative_diff = 50_i64
|
||||||
|
.checked_mul(elapsed_i64)
|
||||||
|
.and_then(|v| v.checked_add(1))
|
||||||
|
.and_then(i64::checked_neg)
|
||||||
|
.expect("-(50 * WINDOW_24H + 1) fits in i64");
|
||||||
|
let post_states = publish_price(
|
||||||
|
make_price_observations(&[(0, 0), (WINDOW_24H, cumulative_diff)], -50),
|
||||||
|
make_oracle_price_account(),
|
||||||
|
make_current_tick_account(-50, WINDOW_24H),
|
||||||
|
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(-51),
|
||||||
|
"must floor to -51"
|
||||||
|
);
|
||||||
|
assert_ne!(
|
||||||
|
account.price,
|
||||||
|
tick_to_oracle_price(-50),
|
||||||
|
"truncating toward zero (-50) would be wrong"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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(),
|
||||||
|
make_current_tick_account(0, WINDOW_24H),
|
||||||
|
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(),
|
||||||
|
make_current_tick_account(0, WINDOW_24H),
|
||||||
|
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(),
|
||||||
|
make_current_tick_account(0, WINDOW_24H),
|
||||||
|
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(),
|
||||||
|
make_current_tick_account(0, WINDOW_24H),
|
||||||
|
clock_account_with_timestamp(WINDOW_24H),
|
||||||
|
price_source_id(),
|
||||||
|
WINDOW_24H,
|
||||||
|
ORACLE_PROGRAM_ID,
|
||||||
|
);
|
||||||
|
assert_eq!(*post_states[0].account(), observations.account);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn current_tick_account_is_not_modified() {
|
||||||
|
let current_tick = make_current_tick_account(123, WINDOW_24H);
|
||||||
|
let post_states = publish_price(
|
||||||
|
make_price_observations(&[(0, 0), (WINDOW_24H, 0)], 0),
|
||||||
|
make_oracle_price_account(),
|
||||||
|
current_tick.clone(),
|
||||||
|
clock_account_with_timestamp(WINDOW_24H),
|
||||||
|
price_source_id(),
|
||||||
|
WINDOW_24H,
|
||||||
|
ORACLE_PROGRAM_ID,
|
||||||
|
);
|
||||||
|
assert_eq!(*post_states[2].account(), current_tick.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(),
|
||||||
|
make_current_tick_account(0, WINDOW_24H),
|
||||||
|
clock.clone(),
|
||||||
|
price_source_id(),
|
||||||
|
WINDOW_24H,
|
||||||
|
ORACLE_PROGRAM_ID,
|
||||||
|
);
|
||||||
|
assert_eq!(*post_states[3].account(), clock.account);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn twap_uses_oldest_and_newest_entries() {
|
||||||
|
// Three observations: tick 0 for first half, tick 200 for second half, published at the
|
||||||
|
// newest observation (zero tail). 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");
|
||||||
|
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(),
|
||||||
|
make_current_tick_account(200, WINDOW_24H),
|
||||||
|
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");
|
||||||
|
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"))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── tail extrapolation to `now` ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// The motivating case: the price has not changed and no tick has been recorded for a full
|
||||||
|
/// window, but the current tick account still reports the same tick. Publishing must extend the
|
||||||
|
/// average all the way to `now`, yielding the (unchanged) price and a *fresh* `now` timestamp —
|
||||||
|
/// not a stale window. The newest stored observation is one whole window old.
|
||||||
|
#[test]
|
||||||
|
fn extrapolates_constant_price_to_now_with_fresh_timestamp() {
|
||||||
|
let tick = 100_i32;
|
||||||
|
let t2_time = WINDOW_24H;
|
||||||
|
let cumulative_at_t2 = i64::from(tick)
|
||||||
|
.checked_mul(i64::try_from(t2_time).expect("fits"))
|
||||||
|
.expect("fits");
|
||||||
|
let now = t2_time.checked_mul(2).expect("fits"); // one window past the last observation
|
||||||
|
let post_states = publish_price(
|
||||||
|
make_price_observations(&[(0, 0), (t2_time, cumulative_at_t2)], tick),
|
||||||
|
make_oracle_price_account(),
|
||||||
|
make_current_tick_account(tick, t2_time),
|
||||||
|
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");
|
||||||
|
// Constant tick extended through the tail → average is still exactly the constant tick.
|
||||||
|
assert_eq!(account.price, tick_to_oracle_price(tick));
|
||||||
|
// The timestamp is `now` and it is honest: the average genuinely runs to `now`.
|
||||||
|
assert_eq!(account.timestamp, now);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Proves the tail is actually integrated (not just relabeled): stored history is flat at tick
|
||||||
|
/// 0 over `[0, W]`, then the price moved to tick 100 and the move reached the current tick
|
||||||
|
/// account but was *not* yet recorded into observations. Publishing at `2W` must blend
|
||||||
|
/// `0 over [0, W]` with `100 over [W, 2W]` → average 50. Without extrapolation this would be 0.
|
||||||
|
#[test]
|
||||||
|
fn tail_segment_extrapolated_with_current_tick() {
|
||||||
|
let t2_time = WINDOW_24H;
|
||||||
|
let now = t2_time.checked_mul(2).expect("fits");
|
||||||
|
let post_states = publish_price(
|
||||||
|
make_price_observations(&[(0, 0), (t2_time, 0)], 0),
|
||||||
|
make_oracle_price_account(),
|
||||||
|
make_current_tick_account(100, t2_time),
|
||||||
|
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");
|
||||||
|
// (0 * W + 100 * W) / 2W = 50
|
||||||
|
assert_eq!(account.price, tick_to_oracle_price(50));
|
||||||
|
assert_ne!(
|
||||||
|
account.price,
|
||||||
|
tick_to_oracle_price(0),
|
||||||
|
"ignoring the tail (old behavior) would publish 0"
|
||||||
|
);
|
||||||
|
assert_eq!(account.timestamp, now);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A large spot jump in the unrecorded tail is clamped to `MAX_TICK_DELTA`, bounding how much
|
||||||
|
/// an attacker who can move the current tick can shift the published average. History is
|
||||||
|
/// flat at tick 0 over `[0, W]`; the current tick jumps to `10 * MAX_TICK_DELTA`;
|
||||||
|
/// publishing at `2W` integrates the *clamped* tick over the tail → average `MAX_TICK_DELTA
|
||||||
|
/// / 2`.
|
||||||
|
#[test]
|
||||||
|
fn tail_extrapolation_clamps_large_tick_jump() {
|
||||||
|
let t2_time = WINDOW_24H;
|
||||||
|
let now = t2_time.checked_mul(2).expect("fits");
|
||||||
|
let huge = MAX_TICK_DELTA.checked_mul(10).expect("fits in i32");
|
||||||
|
let post_states = publish_price(
|
||||||
|
make_price_observations(&[(0, 0), (t2_time, 0)], 0),
|
||||||
|
make_oracle_price_account(),
|
||||||
|
make_current_tick_account(huge, t2_time),
|
||||||
|
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");
|
||||||
|
// (0 * W + MAX_TICK_DELTA * W) / 2W = MAX_TICK_DELTA / 2
|
||||||
|
let expected_tick = MAX_TICK_DELTA.checked_div(2).expect("non-zero");
|
||||||
|
assert_eq!(account.price, tick_to_oracle_price(expected_tick));
|
||||||
|
assert_eq!(account.timestamp, now);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A downward move in the tail is integrated and floors toward −∞: history flat at tick 0 over
|
||||||
|
/// `[0, W]`, current tick `-100`, published at `2W` → `(0·W + (−100)·W) / 2W = −50`.
|
||||||
|
#[test]
|
||||||
|
fn negative_tail_segment_extrapolated() {
|
||||||
|
let t2_time = WINDOW_24H;
|
||||||
|
let now = t2_time.checked_mul(2).expect("fits");
|
||||||
|
let post_states = publish_price(
|
||||||
|
make_price_observations(&[(0, 0), (t2_time, 0)], 0),
|
||||||
|
make_oracle_price_account(),
|
||||||
|
make_current_tick_account(-100, t2_time),
|
||||||
|
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.price, tick_to_oracle_price(-50));
|
||||||
|
assert_eq!(account.timestamp, now);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A spot move reported to the current tick account *just before* publish must not be smeared
|
||||||
|
/// across the whole unrecorded tail. History is flat at tick 0 over `[0, W]`; the tail runs
|
||||||
|
/// `[W, 2W]` but the current tick only moved to 100 at `2W - 1` ms. The move may be integrated
|
||||||
|
/// only over its 1 ms of validity, leaving the average effectively at tick 0 — not the tick 50
|
||||||
|
/// that ignoring `last_updated` and extrapolating 100 over the full tail would produce.
|
||||||
|
#[test]
|
||||||
|
fn tail_extrapolation_respects_current_tick_last_updated() {
|
||||||
|
let t2_time = WINDOW_24H;
|
||||||
|
let now = t2_time.checked_mul(2).expect("fits");
|
||||||
|
let tick_update_time = now.checked_sub(1).expect("now > 0");
|
||||||
|
let post_states = publish_price(
|
||||||
|
make_price_observations(&[(0, 0), (t2_time, 0)], 0),
|
||||||
|
make_oracle_price_account(),
|
||||||
|
make_current_tick_account(100, tick_update_time),
|
||||||
|
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.price, tick_to_oracle_price(0));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Publishing exactly at the newest observation leaves no tail, so the live tick does not
|
||||||
|
/// affect the result even if it has moved: the published price is purely the stored-window
|
||||||
|
/// average.
|
||||||
|
#[test]
|
||||||
|
fn zero_tail_ignores_current_tick() {
|
||||||
|
let cumulative = 100_i64
|
||||||
|
.checked_mul(i64::try_from(WINDOW_24H).expect("fits"))
|
||||||
|
.expect("fits");
|
||||||
|
let post_states = publish_price(
|
||||||
|
make_price_observations(&[(0, 0), (WINDOW_24H, cumulative)], 100),
|
||||||
|
make_oracle_price_account(),
|
||||||
|
// Current tick wildly different, but the tail is zero-length so it is irrelevant.
|
||||||
|
make_current_tick_account(5_000, WINDOW_24H),
|
||||||
|
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));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 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(),
|
||||||
|
make_current_tick_account(0, 0),
|
||||||
|
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(),
|
||||||
|
make_current_tick_account(0, 0),
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn noop_returns_four_post_states() {
|
||||||
|
let post_states = publish_price(
|
||||||
|
make_price_observations(&[(0, 0)], 0),
|
||||||
|
make_oracle_price_account(),
|
||||||
|
make_current_tick_account(0, 0),
|
||||||
|
clock_account_with_timestamp(WINDOW_24H),
|
||||||
|
price_source_id(),
|
||||||
|
WINDOW_24H,
|
||||||
|
ORACLE_PROGRAM_ID,
|
||||||
|
);
|
||||||
|
assert_eq!(post_states.len(), 4);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── clock validation ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// The coarser-cadence clock accounts (10-block, 50-block) are rejected: the published
|
||||||
|
/// timestamp must come from the canonical 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;
|
||||||
|
publish_price(
|
||||||
|
make_price_observations(&[(0, 0), (WINDOW_24H, 0)], 0),
|
||||||
|
make_oracle_price_account(),
|
||||||
|
make_current_tick_account(0, WINDOW_24H),
|
||||||
|
clock_account_with_id(WINDOW_24H, CLOCK_10_PROGRAM_ACCOUNT_ID),
|
||||||
|
price_source_id(),
|
||||||
|
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. The
|
||||||
|
/// published timestamp is the consumer-facing freshness signal, so a forged clock could make a
|
||||||
|
/// stale price look current.
|
||||||
|
#[test]
|
||||||
|
#[should_panic(expected = "clock account must be the canonical 1-block LEZ clock account")]
|
||||||
|
fn forged_clock_account_panics() {
|
||||||
|
publish_price(
|
||||||
|
make_price_observations(&[(0, 0), (WINDOW_24H, 0)], 0),
|
||||||
|
make_oracle_price_account(),
|
||||||
|
make_current_tick_account(0, WINDOW_24H),
|
||||||
|
clock_account_with_id(WINDOW_24H, AccountId::new([7u8; 32])),
|
||||||
|
price_source_id(),
|
||||||
|
WINDOW_24H,
|
||||||
|
ORACLE_PROGRAM_ID,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 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(),
|
||||||
|
make_current_tick_account(0, WINDOW_24H),
|
||||||
|
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,
|
||||||
|
make_current_tick_account(0, WINDOW_24H),
|
||||||
|
clock_account_with_timestamp(WINDOW_24H),
|
||||||
|
price_source_id(),
|
||||||
|
WINDOW_24H,
|
||||||
|
ORACLE_PROGRAM_ID,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
#[should_panic(expected = "current tick account ID does not match expected PDA")]
|
||||||
|
fn wrong_current_tick_account_id_panics() {
|
||||||
|
let mut wrong = make_current_tick_account(0, WINDOW_24H);
|
||||||
|
wrong.account_id = AccountId::new([0u8; 32]);
|
||||||
|
publish_price(
|
||||||
|
make_price_observations(&[(0, 0), (WINDOW_24H, 0)], 0),
|
||||||
|
make_oracle_price_account(),
|
||||||
|
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(),
|
||||||
|
make_current_tick_account(0, WINDOW_24H),
|
||||||
|
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,
|
||||||
|
make_current_tick_account(0, WINDOW_24H),
|
||||||
|
clock_account_with_timestamp(WINDOW_24H),
|
||||||
|
price_source_id(),
|
||||||
|
WINDOW_24H,
|
||||||
|
ORACLE_PROGRAM_ID,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
#[should_panic(expected = "current tick account must be initialized")]
|
||||||
|
fn uninitialized_current_tick_account_panics() {
|
||||||
|
let uninit = AccountWithMetadata {
|
||||||
|
account: Account::default(),
|
||||||
|
is_authorized: false,
|
||||||
|
account_id: compute_current_tick_account_pda(ORACLE_PROGRAM_ID, price_source_id()),
|
||||||
|
};
|
||||||
|
publish_price(
|
||||||
|
make_price_observations(&[(0, 0), (WINDOW_24H, 0)], 0),
|
||||||
|
make_oracle_price_account(),
|
||||||
|
uninit,
|
||||||
|
clock_account_with_timestamp(WINDOW_24H),
|
||||||
|
price_source_id(),
|
||||||
|
WINDOW_24H,
|
||||||
|
ORACLE_PROGRAM_ID,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user