feat(twap-oracle): implement PublishPrice with real tick-to-price conversion#137
Open
0x-r4bbit wants to merge 5 commits into
Open
feat(twap-oracle): implement PublishPrice with real tick-to-price conversion#1370x-r4bbit wants to merge 5 commits into
0x-r4bbit wants to merge 5 commits into
Conversation
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
Collaborator
Author
|
Needs #136 |
…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
e053f64 to
51887aa
Compare
There was a problem hiding this comment.
Pull request overview
Implements the TWAP-oracle flow end-to-end: create per-source/per-window observation and price PDAs, record tick observations into a ring buffer, and permissionlessly publish a consumer-facing OraclePriceAccount price by converting the TWAP tick to a Q64.64 ratio using Uniswap v3 sqrtPriceX96 math (integer-only for zkVM compatibility).
Changes:
- Add oracle instructions and program modules for creating PDAs, updating the current tick, recording observations, and publishing a TWAP-derived price.
- Implement tick→price conversion in
twap_oracle_coreusinguniswap_v3_math+alloy-primitivesand expose Q64.64 encoding (PRICE_FRACTIONAL_BITS = 64). - Update guest method entrypoint, generated IDLs, and dependency lockfiles to include the new instruction set and math dependencies.
Reviewed changes
Copilot reviewed 15 out of 21 changed files in this pull request and generated 6 comments.
Show a summary per file
| File | Description |
|---|---|
| programs/twap_oracle/src/update_current_tick.rs | Adds instruction logic + tests for updating the per-source current tick PDA. |
| programs/twap_oracle/src/record_tick.rs | Adds instruction logic + tests for recording observations into the ring buffer. |
| programs/twap_oracle/src/publish_price.rs | Adds instruction logic + tests for computing TWAP and writing to OraclePriceAccount. |
| programs/twap_oracle/src/noop.rs | Removes the old no-op instruction. |
| programs/twap_oracle/src/lib.rs | Exposes new instruction modules from the program crate. |
| programs/twap_oracle/src/create_price_observations.rs | Adds initializer for PriceObservations PDA (+ tests). |
| programs/twap_oracle/src/create_oracle_price_account.rs | Adds initializer for OraclePriceAccount PDA (+ tests). |
| programs/twap_oracle/src/create_current_tick_account.rs | Adds initializer for CurrentTickAccount PDA (+ tests). |
| programs/twap_oracle/methods/guest/src/bin/twap_oracle.rs | Replaces no-op guest entry with real instruction handlers. |
| programs/twap_oracle/methods/guest/Cargo.toml | Adds guest dependency on clock_core. |
| programs/twap_oracle/methods/guest/Cargo.lock | Pulls in new dependency graph for math conversion and clock. |
| programs/twap_oracle/core/src/lib.rs | Defines accounts/PDAs/instructions and implements tick→Q64.64 price conversion. |
| programs/twap_oracle/core/Cargo.toml | Adds math deps + pins ruint for guest MSRV compatibility. |
| programs/twap_oracle/Cargo.toml | Adds clock_core dependency and enables workspace lints. |
| programs/token/methods/guest/Cargo.lock | Lockfile refresh due to workspace dependency changes. |
| programs/ata/methods/guest/Cargo.lock | Lockfile refresh due to workspace dependency changes. |
| programs/amm/methods/guest/Cargo.lock | Lockfile refresh due to workspace dependency changes. |
| artifacts/twap_oracle-idl.json | Updates IDL to reflect the new instruction set and account types. |
| artifacts/stablecoin-idl.json | Updates IDL types impacted by shared account type definitions. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
Comment on lines
+94
to
+104
| // Tick-delta truncation. | ||
| 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); | ||
|
|
||
| // Advance cumulative (tick × elapsed milliseconds). | ||
| let elapsed_ms_i64 = i64::try_from(elapsed_ms).expect("elapsed_ms fits in i64"); | ||
| let new_cumulative = i64::from(clamped_delta) | ||
| .checked_mul(elapsed_ms_i64) | ||
| .and_then(|product| last_cumulative.checked_add(product)) | ||
| .expect("tick_cumulative fits in i64"); |
Comment on lines
+100
to
+112
| let elapsed_ms = t2 | ||
| .timestamp | ||
| .checked_sub(t1.timestamp) | ||
| .expect("t2.timestamp >= t1.timestamp"); | ||
| let cumulative_diff = t2 | ||
| .tick_cumulative | ||
| .checked_sub(t1.tick_cumulative) | ||
| .expect("tick_cumulative difference fits in i64"); | ||
| let elapsed_ms_i64 = i64::try_from(elapsed_ms).expect("elapsed_ms fits in i64"); | ||
| let twap_tick_i64 = cumulative_diff | ||
| .checked_div(elapsed_ms_i64) | ||
| .expect("elapsed_ms is non-zero"); | ||
| let twap_tick = i32::try_from(twap_tick_i64).expect("TWAP tick fits in i32"); |
Comment on lines
+27
to
+29
| /// Together with `OBSERVATIONS_CAPACITY` this determines the minimum sampling interval | ||
| /// enforced by `RecordPrice`: `min_interval = window_duration / OBSERVATIONS_CAPACITY`. | ||
| /// It is also part of the PDA seed, so each window gets a distinct account. |
Comment on lines
+93
to
+94
| /// The resulting TWAP tick is stored in [`OraclePriceAccount::price`] via | ||
| /// [`tick_to_oracle_price`]. Consumers decode with [`oracle_price_to_tick`]. |
Comment on lines
+105
to
+107
| /// Duration of the TWAP window in milliseconds; used to verify both PDAs and to | ||
| /// locate the boundary observation in the ring buffer. | ||
| window_duration: u64, |
Comment on lines
+179
to
+183
| /// 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`. | ||
| #[account_type] |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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:
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