mirror of
https://github.com/logos-blockchain/lez-programs.git
synced 2026-06-28 11:10:08 +00:00
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. Add price_to_tick(price: u128) -> i32 to twap_oracle_core: isqrt(price << 128) -> sqrtPriceX96 -> get_tick_at_sqrt_ratio. The sqrtPriceX96 is clamped to >= MIN_SQRT_RATIO so a zero/dust price maps to MIN_TICK rather than erroring. Add a pure-integer integer_sqrt(U256) (bit-by-bit, no floating point): ruint's root is gated behind its std feature and seeds with f64, neither available in the guest. Uses wrapping_shr for the digit loop (checked_shr rejects the intended lossy shifts). Pull in uniswap_v3_math (for get_tick_at_sqrt_ratio) and alloy-primitives (U256), with ruint pinned to =1.17.0 — 1.18 raised its MSRV to rustc 1.90, above the risc0 guest toolchain's 1.88.
This commit is contained in:
parent
b0ac30039b
commit
3285d5787e
777
Cargo.lock
generated
777
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
@ -323,6 +323,22 @@
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "CurrentTickAccount",
|
||||
"type": {
|
||||
"kind": "struct",
|
||||
"fields": [
|
||||
{
|
||||
"name": "tick",
|
||||
"type": "i32"
|
||||
},
|
||||
{
|
||||
"name": "last_updated",
|
||||
"type": "u64"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
],
|
||||
"types": [
|
||||
|
||||
@ -75,6 +75,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_price",
|
||||
"type": "u128"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"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": "price",
|
||||
"type": "u128"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"accounts": [
|
||||
@ -141,6 +199,22 @@
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "CurrentTickAccount",
|
||||
"type": {
|
||||
"kind": "struct",
|
||||
"fields": [
|
||||
{
|
||||
"name": "tick",
|
||||
"type": "i32"
|
||||
},
|
||||
{
|
||||
"name": "last_updated",
|
||||
"type": "u64"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
],
|
||||
"types": [
|
||||
|
||||
778
programs/stablecoin/methods/guest/Cargo.lock
generated
778
programs/stablecoin/methods/guest/Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
@ -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 }
|
||||
|
||||
@ -60,6 +60,40 @@ 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 tick written
|
||||
/// by the price source and serves as the input to `RecordTick`. The price source reports a
|
||||
/// spot **price**; the oracle converts it to a tick via [`price_to_tick`], so the source
|
||||
/// never needs to know about ticks.
|
||||
///
|
||||
/// 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 spot price as a `Q64.64` ratio (`quote_asset` per `base_asset`), e.g. an
|
||||
/// AMM's `reserve_b / reserve_a`. Converted to a tick on-chain.
|
||||
initial_price: u128,
|
||||
},
|
||||
/// 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. The price source reports a spot **price**; the oracle converts it to a tick
|
||||
/// via [`price_to_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 spot price as a `Q64.64` ratio (`quote_asset` per `base_asset`). Converted to a
|
||||
/// tick on-chain.
|
||||
price: u128,
|
||||
},
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
@ -273,3 +307,287 @@ impl From<&OraclePriceAccount> for Data {
|
||||
Self::try_from(data).expect("Oracle price account encoded data should fit into Data")
|
||||
}
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
// Price → tick conversion
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Converts a `Q64.64` fixed-point spot price into the nearest TWAP tick.
|
||||
///
|
||||
/// Price sources report a spot price (e.g. an AMM's `reserve_b / reserve_a` as a `Q64.64`
|
||||
/// ratio); the oracle owns the conversion to its internal tick representation so producers never
|
||||
/// need to know about ticks. The price is mapped through the Uniswap `sqrtPriceX96`
|
||||
/// representation:
|
||||
///
|
||||
/// ```text
|
||||
/// price = ratio * 2^64 (Q64.64 input)
|
||||
/// sqrtPriceX96 = sqrt(ratio) * 2^96 = isqrt(price << 128)
|
||||
/// tick = get_tick_at_sqrt_ratio(sqrtPriceX96)
|
||||
/// ```
|
||||
///
|
||||
/// `isqrt` is a pure-integer square root (no floating point — deterministic in the zkVM). The
|
||||
/// `sqrtPriceX96` is clamped to at least `MIN_SQRT_RATIO` so a zero or dust price maps to
|
||||
/// `MIN_TICK` rather than erroring; the upper bound cannot be reached from a `u128` price (its
|
||||
/// max ratio `~2^64` corresponds to a tick well inside `MAX_TICK`).
|
||||
#[must_use]
|
||||
pub fn price_to_tick(price: u128) -> i32 {
|
||||
use alloy_primitives::U256;
|
||||
use uniswap_v3_math::tick_math::{get_tick_at_sqrt_ratio, MIN_SQRT_RATIO};
|
||||
|
||||
// sqrtPriceX96 = sqrt(price / 2^64) * 2^96 = sqrt(price) * 2^64 = isqrt(price << 128).
|
||||
// price < 2^128, so price << 128 < 2^256 and the shift never overflows U256.
|
||||
let shifted = U256::from(price)
|
||||
.checked_shl(128)
|
||||
.expect("price < 2^128, so price << 128 fits in U256");
|
||||
let sqrt_price_x96 = integer_sqrt(shifted).max(MIN_SQRT_RATIO);
|
||||
|
||||
get_tick_at_sqrt_ratio(sqrt_price_x96)
|
||||
.expect("sqrt_price_x96 is clamped into [MIN_SQRT_RATIO, MAX_SQRT_RATIO)")
|
||||
}
|
||||
|
||||
/// Floor of the integer square root of a 256-bit value, computed bit-by-bit with no floating
|
||||
/// point (`ruint`'s own `root` is gated behind its `std` feature and uses an `f64` seed, neither
|
||||
/// of which is available in the guest). Deterministic — suitable for the zkVM.
|
||||
fn integer_sqrt(n: alloy_primitives::U256) -> alloy_primitives::U256 {
|
||||
use alloy_primitives::U256;
|
||||
|
||||
if n.is_zero() {
|
||||
return U256::ZERO;
|
||||
}
|
||||
|
||||
// Largest power of four not exceeding `n`: `1 << (msb rounded down to an even bit index)`.
|
||||
let msb = 255usize
|
||||
.checked_sub(n.leading_zeros())
|
||||
.expect("n is non-zero, so leading_zeros <= 255");
|
||||
let start = msb & !1usize;
|
||||
let mut d = U256::ONE.checked_shl(start).expect("start < 256");
|
||||
let mut c = U256::ZERO;
|
||||
let mut x = n;
|
||||
|
||||
// `c` and `d` are shifted with `wrapping_shr`, which logically drops the low bits (the
|
||||
// algorithm intends to). `checked_shr` would return `None` whenever a set bit falls off
|
||||
// (e.g. `d = 1 >> 2`), which is not what we want here.
|
||||
while !d.is_zero() {
|
||||
let cd = c
|
||||
.checked_add(d)
|
||||
.expect("c + d stays below the root, no overflow");
|
||||
if x >= cd {
|
||||
x = x.checked_sub(cd).expect("guarded by x >= cd");
|
||||
c = c
|
||||
.wrapping_shr(1)
|
||||
.checked_add(d)
|
||||
.expect("c/2 + d stays below the root, no overflow");
|
||||
} else {
|
||||
c = c.wrapping_shr(1);
|
||||
}
|
||||
d = d.wrapping_shr(2);
|
||||
}
|
||||
|
||||
c
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
// 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"),
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// `1.0` in Q64.64 is `2^64`; its square root is `2^96 = sqrtPriceX96` at tick 0.
|
||||
const ONE_Q64_64: u128 = 1u128 << 64;
|
||||
|
||||
// ── price_to_tick ───────────────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn unit_price_maps_to_tick_zero() {
|
||||
assert_eq!(price_to_tick(ONE_Q64_64), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zero_price_maps_to_min_tick() {
|
||||
// A zero/dust price clamps to MIN_SQRT_RATIO → MIN_TICK, never panics.
|
||||
assert_eq!(price_to_tick(0), uniswap_v3_math::tick_math::MIN_TICK);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prices_above_one_are_positive_below_one_negative() {
|
||||
assert!(
|
||||
price_to_tick(ONE_Q64_64 << 1) > 0,
|
||||
"2.0 should be a positive tick"
|
||||
);
|
||||
assert!(
|
||||
price_to_tick(ONE_Q64_64 >> 1) < 0,
|
||||
"0.5 should be a negative tick"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn price_to_tick_is_monotonic_in_price() {
|
||||
// Strictly increasing Q64.64 prices spanning well below and above 1.0 must produce
|
||||
// non-decreasing ticks. Built without the forward conversion, so this is self-contained.
|
||||
let prices = [
|
||||
1u128,
|
||||
ONE_Q64_64 >> 40,
|
||||
ONE_Q64_64 >> 20,
|
||||
ONE_Q64_64 >> 10,
|
||||
ONE_Q64_64 >> 1,
|
||||
ONE_Q64_64,
|
||||
ONE_Q64_64 << 1,
|
||||
ONE_Q64_64 << 10,
|
||||
ONE_Q64_64 << 20,
|
||||
ONE_Q64_64 << 40,
|
||||
u128::MAX,
|
||||
];
|
||||
let mut prev = price_to_tick(prices[0]);
|
||||
for &price in &prices[1..] {
|
||||
let cur = price_to_tick(price);
|
||||
assert!(cur >= prev, "tick must not decrease as price increases");
|
||||
prev = cur;
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn max_price_maps_near_saturation_tick_without_panicking() {
|
||||
// The largest representable Q64.64 price maps to a large positive tick near the +443,636
|
||||
// saturation edge. Exercises checked_shl(128) and the isqrt at their maximum input — the
|
||||
// high-end counterpart to `zero_price_maps_to_min_tick`.
|
||||
let tick = price_to_tick(u128::MAX);
|
||||
assert!(
|
||||
tick > 440_000,
|
||||
"max price should map near the saturation tick, got {tick}"
|
||||
);
|
||||
assert!(tick <= uniswap_v3_math::tick_math::MAX_TICK);
|
||||
}
|
||||
|
||||
// ── integer_sqrt ────────────────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn integer_sqrt_matches_known_squares() {
|
||||
use alloy_primitives::U256;
|
||||
for v in [
|
||||
0u128,
|
||||
1,
|
||||
4,
|
||||
9,
|
||||
2,
|
||||
3,
|
||||
15,
|
||||
16,
|
||||
17,
|
||||
1_000_000,
|
||||
u128::from(u64::MAX),
|
||||
] {
|
||||
let n = U256::from(v);
|
||||
let root = integer_sqrt(n);
|
||||
// root^2 <= v < (root+1)^2
|
||||
let root_sq = root.checked_mul(root).expect("root^2 fits");
|
||||
let next = root.checked_add(U256::from(1u8)).expect("root+1 fits");
|
||||
let next_sq = next.checked_mul(next).expect("(root+1)^2 fits");
|
||||
assert!(root_sq <= n && n < next_sq, "isqrt({v}) = {root} is wrong");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn integer_sqrt_on_large_values() {
|
||||
use alloy_primitives::U256;
|
||||
// Exact roots of large perfect squares (s <= 2^127 so s^2 fits in U256). This is the
|
||||
// magnitude range price_to_tick actually exercises, well beyond u64::MAX.
|
||||
for shift in [64usize, 100, 127] {
|
||||
let s = U256::ONE.checked_shl(shift).expect("shift < 256");
|
||||
let n = s.checked_mul(s).expect("s^2 fits for s <= 2^127");
|
||||
assert_eq!(integer_sqrt(n), s, "isqrt((2^{shift})^2) should be exact");
|
||||
// One below a perfect square floors to s - 1.
|
||||
let n_minus = n.checked_sub(U256::ONE).expect("n > 0");
|
||||
let s_minus = s.checked_sub(U256::ONE).expect("s > 0");
|
||||
assert_eq!(
|
||||
integer_sqrt(n_minus),
|
||||
s_minus,
|
||||
"isqrt((2^{shift})^2 - 1) should floor to 2^{shift} - 1"
|
||||
);
|
||||
}
|
||||
|
||||
// The largest input price_to_tick can feed: u128::MAX << 128 (~2^256). Must not panic and
|
||||
// satisfy root^2 <= n. The upper bound (root+1)^2 is omitted — it would overflow U256.
|
||||
let max_input = U256::from(u128::MAX)
|
||||
.checked_shl(128)
|
||||
.expect("u128::MAX << 128 fits in U256");
|
||||
let root = integer_sqrt(max_input);
|
||||
let root_sq = root.checked_mul(root).expect("root^2 < 2^256 fits");
|
||||
assert!(root_sq <= max_input, "root^2 must not exceed n");
|
||||
assert!(
|
||||
root >= U256::ONE.checked_shl(127).expect("shift < 256"),
|
||||
"root of a ~2^256 value should be ~2^128"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
778
programs/twap_oracle/methods/guest/Cargo.lock
generated
778
programs/twap_oracle/methods/guest/Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
@ -77,4 +77,57 @@ 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.
|
||||
///
|
||||
/// `initial_price` is a `Q64.64` spot price; the oracle converts it to a tick.
|
||||
#[instruction]
|
||||
pub fn create_current_tick_account(
|
||||
ctx: ProgramContext,
|
||||
current_tick_account: AccountWithMetadata,
|
||||
price_source: AccountWithMetadata,
|
||||
clock: AccountWithMetadata,
|
||||
initial_price: u128,
|
||||
) -> SpelResult {
|
||||
let post_states =
|
||||
twap_oracle_program::create_current_tick_account::create_current_tick_account(
|
||||
current_tick_account,
|
||||
price_source,
|
||||
clock,
|
||||
initial_price,
|
||||
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.
|
||||
///
|
||||
/// `price` is a `Q64.64` spot price; the oracle converts it to a tick.
|
||||
#[instruction]
|
||||
pub fn update_current_tick(
|
||||
ctx: ProgramContext,
|
||||
current_tick_account: AccountWithMetadata,
|
||||
price_source: AccountWithMetadata,
|
||||
clock: AccountWithMetadata,
|
||||
price: u128,
|
||||
) -> SpelResult {
|
||||
let post_states = twap_oracle_program::update_current_tick::update_current_tick(
|
||||
current_tick_account,
|
||||
price_source,
|
||||
clock,
|
||||
price,
|
||||
ctx.self_program_id,
|
||||
);
|
||||
Ok(spel_framework::SpelOutput::execute(post_states, vec![]))
|
||||
}
|
||||
}
|
||||
|
||||
334
programs/twap_oracle/src/create_current_tick_account.rs
Normal file
334
programs/twap_oracle/src/create_current_tick_account.rs
Normal file
@ -0,0 +1,334 @@
|
||||
use clock_core::{ClockAccountData, CLOCK_01_PROGRAM_ACCOUNT_ID};
|
||||
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, price_to_tick,
|
||||
CurrentTickAccount,
|
||||
};
|
||||
|
||||
/// Creates and initialises a [`CurrentTickAccount`] for a price source.
|
||||
///
|
||||
/// The price source reports an opening spot **price** (`Q64.64` ratio); this function converts it
|
||||
/// to a tick via [`price_to_tick`], so the source never needs to know about ticks.
|
||||
///
|
||||
/// 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.
|
||||
///
|
||||
/// The timestamp is taken from `clock`, which must be [`CLOCK_01_PROGRAM_ACCOUNT_ID`]; it is never
|
||||
/// caller-supplied, so it cannot be forged.
|
||||
///
|
||||
/// # 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.
|
||||
/// - `clock.account_id` is not [`CLOCK_01_PROGRAM_ACCOUNT_ID`].
|
||||
pub fn create_current_tick_account(
|
||||
current_tick_account: AccountWithMetadata,
|
||||
price_source: AccountWithMetadata,
|
||||
clock: AccountWithMetadata,
|
||||
initial_price: u128,
|
||||
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"
|
||||
);
|
||||
assert_eq!(
|
||||
clock.account_id, CLOCK_01_PROGRAM_ACCOUNT_ID,
|
||||
"CreateCurrentTickAccount: clock account must be the canonical 1-block LEZ clock account"
|
||||
);
|
||||
|
||||
let clock_data = ClockAccountData::from_bytes(clock.account.data.as_ref());
|
||||
|
||||
let account = CurrentTickAccount {
|
||||
tick: price_to_tick(initial_price),
|
||||
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];
|
||||
/// `1.0` in Q64.64 — the spot price at tick 0.
|
||||
const UNIT_PRICE: u128 = 1u128 << 64;
|
||||
|
||||
fn price_source_id() -> AccountId {
|
||||
AccountId::new([1u8; 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)
|
||||
}
|
||||
|
||||
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),
|
||||
UNIT_PRICE,
|
||||
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),
|
||||
UNIT_PRICE,
|
||||
ORACLE_PROGRAM_ID,
|
||||
);
|
||||
assert_eq!(
|
||||
post_states[0].required_claim(),
|
||||
Some(Claim::Pda(compute_current_tick_account_pda_seed(
|
||||
price_source_id()
|
||||
)))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unit_price_stores_tick_zero_and_timestamp() {
|
||||
let timestamp = 123_456_789u64;
|
||||
|
||||
let post_states = create_current_tick_account(
|
||||
current_tick_account_uninit(),
|
||||
price_source_authorized(),
|
||||
clock_account_with_timestamp(timestamp),
|
||||
UNIT_PRICE,
|
||||
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, 0);
|
||||
assert_eq!(account.last_updated, timestamp);
|
||||
}
|
||||
|
||||
/// The function stores the tick the oracle derives from the price — i.e. it delegates to
|
||||
/// `price_to_tick`. The conversion's own correctness is covered by `twap_oracle_core` tests.
|
||||
#[test]
|
||||
fn initial_price_is_converted_via_price_to_tick() {
|
||||
for price in [
|
||||
1u128,
|
||||
UNIT_PRICE >> 10,
|
||||
UNIT_PRICE,
|
||||
UNIT_PRICE << 10,
|
||||
u128::MAX,
|
||||
] {
|
||||
let post_states = create_current_tick_account(
|
||||
current_tick_account_uninit(),
|
||||
price_source_authorized(),
|
||||
clock_account_with_timestamp(0),
|
||||
price,
|
||||
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, twap_oracle_core::price_to_tick(price));
|
||||
}
|
||||
}
|
||||
|
||||
#[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(),
|
||||
UNIT_PRICE,
|
||||
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,
|
||||
);
|
||||
}
|
||||
|
||||
/// The coarser-cadence clock accounts (10-block, 50-block) are rejected: the oracle must read
|
||||
/// the most fine-grained 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;
|
||||
create_current_tick_account(
|
||||
current_tick_account_uninit(),
|
||||
price_source_authorized(),
|
||||
clock_account_with_id(0, CLOCK_10_PROGRAM_ACCOUNT_ID),
|
||||
UNIT_PRICE,
|
||||
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.
|
||||
#[test]
|
||||
#[should_panic(expected = "clock account must be the canonical 1-block LEZ clock account")]
|
||||
fn forged_clock_account_panics() {
|
||||
create_current_tick_account(
|
||||
current_tick_account_uninit(),
|
||||
price_source_authorized(),
|
||||
clock_account_with_id(123_456, AccountId::new([7u8; 32])),
|
||||
UNIT_PRICE,
|
||||
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,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;
|
||||
|
||||
312
programs/twap_oracle/src/update_current_tick.rs
Normal file
312
programs/twap_oracle/src/update_current_tick.rs
Normal file
@ -0,0 +1,312 @@
|
||||
use clock_core::{ClockAccountData, CLOCK_01_PROGRAM_ACCOUNT_ID};
|
||||
use nssa_core::{
|
||||
account::{AccountWithMetadata, Data},
|
||||
program::{AccountPostState, ProgramId},
|
||||
};
|
||||
use twap_oracle_core::{compute_current_tick_account_pda, price_to_tick, CurrentTickAccount};
|
||||
|
||||
/// Updates the tick stored in an existing [`CurrentTickAccount`] from a new spot price.
|
||||
///
|
||||
/// The price source reports a spot **price** (`Q64.64` ratio); this function converts it to a
|
||||
/// tick via [`price_to_tick`], so the source never needs to know about ticks.
|
||||
///
|
||||
/// The timestamp is taken from `clock`, which must be [`CLOCK_01_PROGRAM_ACCOUNT_ID`]; it is never
|
||||
/// caller-supplied, so it cannot be forged.
|
||||
///
|
||||
/// # 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.
|
||||
/// - `clock.account_id` is not [`CLOCK_01_PROGRAM_ACCOUNT_ID`].
|
||||
pub fn update_current_tick(
|
||||
current_tick_account: AccountWithMetadata,
|
||||
price_source: AccountWithMetadata,
|
||||
clock: AccountWithMetadata,
|
||||
price: u128,
|
||||
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"
|
||||
);
|
||||
assert_eq!(
|
||||
clock.account_id, CLOCK_01_PROGRAM_ACCOUNT_ID,
|
||||
"UpdateCurrentTick: clock account must be the canonical 1-block LEZ clock account"
|
||||
);
|
||||
|
||||
let mut stored = CurrentTickAccount::try_from(¤t_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 = price_to_tick(price);
|
||||
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];
|
||||
/// `1.0` in Q64.64 — the spot price at tick 0.
|
||||
const UNIT_PRICE: u128 = 1u128 << 64;
|
||||
|
||||
fn price_source_id() -> AccountId {
|
||||
AccountId::new([1u8; 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)
|
||||
}
|
||||
|
||||
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),
|
||||
UNIT_PRICE,
|
||||
ORACLE_PROGRAM_ID,
|
||||
);
|
||||
assert_eq!(post_states.len(), 3);
|
||||
}
|
||||
|
||||
/// The function overwrites the stored tick with the one the oracle derives from the new
|
||||
/// price — i.e. it delegates to `price_to_tick`. A price above 1.0 yields a positive tick,
|
||||
/// so the stored value also changes away from the initial tick.
|
||||
#[test]
|
||||
fn price_converted_and_tick_updated() {
|
||||
let price = UNIT_PRICE << 1; // 2.0 → a positive tick
|
||||
let post_states = update_current_tick(
|
||||
current_tick_account_initialized(100, 0),
|
||||
price_source_authorized(),
|
||||
clock_account_with_timestamp(1_000),
|
||||
price,
|
||||
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, twap_oracle_core::price_to_tick(price));
|
||||
assert_ne!(account.tick, 100);
|
||||
}
|
||||
|
||||
#[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),
|
||||
UNIT_PRICE,
|
||||
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);
|
||||
}
|
||||
|
||||
/// The stored tick is whatever `price_to_tick` derives for the supplied price. The
|
||||
/// conversion's own correctness is covered by `twap_oracle_core` tests.
|
||||
#[test]
|
||||
fn prices_convert_via_price_to_tick() {
|
||||
for price in [
|
||||
1u128,
|
||||
UNIT_PRICE >> 10,
|
||||
UNIT_PRICE,
|
||||
UNIT_PRICE << 10,
|
||||
u128::MAX,
|
||||
] {
|
||||
let post_states = update_current_tick(
|
||||
current_tick_account_initialized(0, 0),
|
||||
price_source_authorized(),
|
||||
clock_account_with_timestamp(0),
|
||||
price,
|
||||
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, twap_oracle_core::price_to_tick(price));
|
||||
}
|
||||
}
|
||||
|
||||
#[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(),
|
||||
UNIT_PRICE,
|
||||
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,
|
||||
);
|
||||
}
|
||||
|
||||
/// The coarser-cadence clock accounts (10-block, 50-block) are rejected: the oracle must read
|
||||
/// the most fine-grained 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;
|
||||
update_current_tick(
|
||||
current_tick_account_initialized(0, 0),
|
||||
price_source_authorized(),
|
||||
clock_account_with_id(1_000, CLOCK_10_PROGRAM_ACCOUNT_ID),
|
||||
UNIT_PRICE,
|
||||
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.
|
||||
#[test]
|
||||
#[should_panic(expected = "clock account must be the canonical 1-block LEZ clock account")]
|
||||
fn forged_clock_account_panics() {
|
||||
update_current_tick(
|
||||
current_tick_account_initialized(0, 0),
|
||||
price_source_authorized(),
|
||||
clock_account_with_id(1_000, AccountId::new([7u8; 32])),
|
||||
UNIT_PRICE,
|
||||
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,
|
||||
);
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user