Skip to content

refactor(twap-oracle): take a price (not a tick) in current-tick instructions#139

Open
0x-r4bbit wants to merge 6 commits into
mainfrom
refactor/calc-tick
Open

refactor(twap-oracle): take a price (not a tick) in current-tick instructions#139
0x-r4bbit wants to merge 6 commits into
mainfrom
refactor/calc-tick

Conversation

@0x-r4bbit

Copy link
Copy Markdown
Collaborator

A tick is the TWAP's internal representation, so price sources should not have to
compute it — just as consumers never deal with ticks. CreateCurrentTickAccount and
UpdateCurrentTick now take a Q64.64 spot price (for an AMM, reserve_b/reserve_a) and
the oracle converts it to a tick internally. This keeps producers tick-agnostic,
mirrors the consumer side, and centralises all tick knowledge in the oracle.

CreateCurrentTickAccount { initial_tick: i32 } -> { initial_price: u128 }
UpdateCurrentTick { tick: i32 } -> { price: u128 }

Add price_to_tick(price: u128) -> i32 to twap_oracle_core, the inverse of
tick_to_oracle_price: 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).

CurrentTickAccount (still stores a tick) and record_tick are unchanged.

0x-r4bbit added 6 commits May 29, 2026 09:32
Adds the CreatePriceObservations instruction to the TWAP oracle program.
The instruction initialises a PriceObservations PDA for a given price
source account and time window, writing the initial tick and timestamp
as the first entry.

Key design decisions:

- Per-window accounts: each (price_source, window_duration) pair maps to
  a distinct PriceObservations PDA. The window duration is baked into the
  PDA seed so a single price source can support multiple TWAP windows
  (24h, 7d, 30d) at independent sampling rates without sharing a buffer.

- window_duration not stored on struct: it is implicit in the PDA address.
  Any reader that located the account already knows the window duration
  used to derive it. Storing it would be redundant.

- Authorization is implicit: the PriceObservations PDA is derived from
  the price source account ID, so is_authorized = true on the price source
  proves the caller controls it without a redundant authority field.

- Impersonation is prevented by the PDA check: passing a controlled price
  source with a victim's observations account ID fails immediately because
  the computed PDA (from the attacker's source) does not match.

Closes #126
Adds the CreateOraclePriceAccount instruction to the TWAP oracle program.
The instruction initialises a canonical OraclePriceAccount PDA for a given
price source and time window. The account starts with price = 0 and
timestamp = 0 — a deliberately invalid sentinel state that signals "not
yet published". Consumers are expected to reject any account whose
timestamp is zero or stale, so the transient invalid state requires no
special on-chain enforcement.

- PDA mirrors PriceObservations: derived from (oracle_program_id,
  price_source_id, window_duration) with a distinct seed constant, so
  each (source, window) pair maps to a distinct oracle price account that
  cannot collide with its corresponding observations account.

- source_id is not a parameter: it is always set to price_source.account_id.
  Accepting it as a free parameter would allow callers to register a price
  account that claims to represent a source it does not control. Deriving
  it from the authorized price source account closes that vector entirely.

- Authorization follows the same model as CreatePriceObservations:
  is_authorized = true on the price source proves the caller controls it;
  the PDA check ensures the supplied oracle price account address is the
  one derived from that specific source and window.

- price = 0 / timestamp = 0 is the correct initial state: coupling account
  creation to first publication would require the observation account to
  already hold a full window of ticks, blocking registration for up to the
  window duration. Consumers must validate oracle prices regardless, so the
  zero sentinel falls naturally out of the staleness check they already own.

Closes #129
…ntTick

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 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
…version

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

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

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

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

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

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

Closes #117
…ructions

A tick is the TWAP's internal representation, so price sources should not have to
compute it — just as consumers never deal with ticks. CreateCurrentTickAccount and
UpdateCurrentTick now take a Q64.64 spot price (for an AMM, reserve_b/reserve_a) and
the oracle converts it to a tick internally. This keeps producers tick-agnostic,
mirrors the consumer side, and centralises all tick knowledge in the oracle.

  CreateCurrentTickAccount { initial_tick: i32 } -> { initial_price: u128 }
  UpdateCurrentTick        { tick: i32 }         -> { price: u128 }

Add price_to_tick(price: u128) -> i32 to twap_oracle_core, the inverse of
tick_to_oracle_price: 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).

CurrentTickAccount (still stores a tick) and record_tick are unchanged.
@0x-r4bbit

Copy link
Copy Markdown
Collaborator Author

This commit is not yet thoroughly reviewed, but I wanted to push it anyways.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant