feat(twap-oracle): implement RecordTick instruction

Add RecordTick — a permissionless instruction that reads the current tick
from a CurrentTickAccount and advances a PriceObservations ring buffer.

Authorization is implicit: both PDAs are verified against price_source_id,
so the tick can only have been written by whoever controls that price source.
A sampling guard silently no-ops if less than `window_duration /
OBSERVATIONS_CAPACITY`` ms have elapsed, allowing keepers to call blindly on
every block. Tick-delta truncation clamps the per-observation delta to
`MAX_TICK_DELTA (9 116)` before advancing tick_cumulative, with
last_recorded_tick tracking the untruncated position for the next delta.

Also switches ObservationEntry.tick_cumulative to use elapsed milliseconds
rather than seconds.

Closes #116
This commit is contained in:
r4bbit
2026-06-17 14:46:30 +02:00
parent 3285d5787e
commit e8fe634a2c
6 changed files with 1001 additions and 3 deletions
+35 -3
View File
@@ -25,7 +25,7 @@ pub enum Instruction {
/// Duration of the TWAP window this feed serves, in milliseconds.
///
/// Together with `OBSERVATIONS_CAPACITY` this determines the minimum sampling interval
/// enforced by `RecordPrice`: `min_interval = window_duration / OBSERVATIONS_CAPACITY`.
/// enforced by `RecordTick`: `min_interval = window_duration / OBSERVATIONS_CAPACITY`.
/// It is also part of the PDA seed, so each window gets a distinct account.
window_duration: u64,
},
@@ -94,12 +94,44 @@ pub enum Instruction {
/// tick on-chain.
price: u128,
},
/// Records the current tick from a [`CurrentTickAccount`] into a [`PriceObservations`]
/// ring buffer.
///
/// Permissionless — anyone may call this. Both PDAs are verified against `price_source_id`,
/// so the tick can only have been written by whoever controls that price source.
///
/// A sampling guard silently skips the write if less than
/// `window_duration / OBSERVATIONS_CAPACITY` milliseconds have elapsed since the last
/// observation. Callers may call this on every block without concern — the guard handles
/// downsampling on-chain.
///
/// 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. Current tick account — initialized PDA derived from
/// `compute_current_tick_account_pda(self_program_id, price_source_id)`.
/// 3. Clock account — read-only; supplies the current timestamp.
RecordTick {
/// ID of the price source; used to verify both PDAs.
price_source_id: AccountId,
/// Duration of the TWAP window in milliseconds; used to verify the
/// [`PriceObservations`] PDA and to compute the sampling guard interval.
window_duration: u64,
},
}
// ──────────────────────────────────────────────────────────────────────────────
// Price feed
// ──────────────────────────────────────────────────────────────────────────────
/// Maximum tick delta injected into the accumulator per observation.
///
/// Matches the Uniswap v4 truncated oracle hook reference value (~2.39× price move per block).
/// An attacker who moves the pool by more than this in one block still only injects
/// `MAX_TICK_DELTA` ticks into the cumulative — they must sustain the manipulation across
/// many blocks while arbitrage erodes their position.
pub const MAX_TICK_DELTA: i32 = 9_116;
/// Number of entries in each price feed.
///
/// 6 396 is the maximum that fits within the `DATA_MAX_LENGTH = 100 KiB` runtime ceiling.
@@ -123,7 +155,7 @@ pub struct ObservationEntry {
/// Running sum of `tick × elapsed_ms` up to this entry.
///
/// Grows without bound over time, which is why this is `i64` rather than `i32`.
/// The TWAP over any window `[t1, t2]` is computed as
/// The TWAP over any window `[t1, t2]` (timestamps in milliseconds) is computed as
/// `(tick_cumulative[t2] - tick_cumulative[t1]) / (t2 - t1)`.
pub tick_cumulative: i64,
}
@@ -135,7 +167,7 @@ pub struct ObservationEntry {
/// The window duration is not stored here — it is implicit in the PDA address. Any caller
/// that locates this account already knows the window duration used to derive it.
/// Only the account that controls `price_source_id` (proven via `is_authorized = true` at call
/// time) may append new entries via `RecordPrice`.
/// time) may append new entries via `RecordTick`.
#[account_type]
#[derive(Debug, PartialEq, Eq, Clone, Serialize, Deserialize, BorshSerialize, BorshDeserialize)]
pub struct PriceObservations {