feat(twap-oracle): implement PublishPrice with real tick-to-price conversion#137
feat(twap-oracle): implement PublishPrice with real tick-to-price conversion#1370x-r4bbit wants to merge 1 commit into
Conversation
|
Needs #136 |
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.
51887aa to
6f91d89
Compare
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 5 out of 5 changed files in this pull request and generated 6 comments.
Comments suppressed due to low confidence (1)
programs/twap_oracle/methods/guest/src/bin/twap_oracle.rs:141
record_tickrequiresclock.account_id == CLOCK_01_PROGRAM_ACCOUNT_ID(see the program-side assertion), but the guest docs currently just say “read-only LEZ clock account”. Tighten this to the canonical 1-block clock so clients don't accidentally pass a different cadence clock and hit a panic.
/// Expected accounts:
/// 1. `price_observations` — initialized PDA owned by this oracle program.
/// 2. `current_tick_account` — initialized PDA owned by this oracle program.
/// 3. `clock` — read-only LEZ clock account.
#[instruction]
c35bc73 to
6e5b4b9
Compare
|
Closing and reopening to trigger CI |
…on 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.
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 buffer body needs no boundary search:
t2 = most recent observation (write_index - 1, wrapping)
t1 = oldest valid entry (0 if not full, write_index if full)
Rather than averaging only the stored span [t1, t2], 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, which always projects
the accumulator to the present:
clamped_tick = last_recorded_tick + clamp(current_tick - last_recorded_tick, ±MAX_TICK_DELTA)
cum_now = t2.tick_cumulative + clamped_tick * (now - t2.ts)
twap_tick = (cum_now - t1.tick_cumulative) / (now - t1.ts) // floor (div_euclid)
This 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 the current
time, and a republish picks up a since-reported move rather than freezing the
pre-move average. The tail tick is clamped against last_recorded_tick by
MAX_TICK_DELTA — the same anti-manipulation bound RecordTick applies — once over the
whole tail, so a long unrecorded gap followed by a large spot move can only shift the
tail by MAX_TICK_DELTA total (strictly more conservative than the equivalent RecordTick
sequence, the safe direction for an oracle). A zero-length tail (now == t2.ts) leaves
the result as 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 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.
Closes #117
6e5b4b9 to
48a7c6f
Compare
| /// 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. Clock account — read-only; supplies the publication timestamp. | ||
| PublishPrice { | ||
| /// ID of the price source; used to verify both PDAs. | ||
| price_source_id: AccountId, | ||
| /// 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, | ||
| }, |
| /// 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 | ||
| /// using the live tick from the [`CurrentTickAccount`], clamped against the observations' | ||
| /// `last_recorded_tick` by [`MAX_TICK_DELTA`] — the same anti-manipulation bound `RecordTick` | ||
| /// applies. This mirrors Uniswap's `OracleLibrary.consult`, which always projects the accumulator | ||
| /// to the present rather than reading only stored points. |
3esmit
left a comment
There was a problem hiding this comment.
publish_price ignores CurrentTickAccount::last_updated, so a tick written right before publish can affect whole tail window. So a price move right before publish can be treated as if it lasted the whole unrecorded tail, so consumers can receive an overstated fresh TWAP.
Example: the stored observations say the price stayed at tick 0 until W. Then the source updates the tick to 100 just 1 ms before publish. The current code treats tick 100 as if it lasted the full W..2W tail, so it publishes tick 50. It should only count tick 100 for that final 1 ms, which keeps the average at tick 0.
Verified with:
#[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));
}Result:
test publish_price::tests::tail_extrapolation_respects_current_tick_last_updated ... FAILED
thread 'publish_price::tests::tail_extrapolation_respects_current_tick_last_updated' panicked at programs/twap_oracle/src/publish_price.rs:618:9:
assertion `left == right` failed
left: 18539204128674405812
right: 18446744073709551616
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