Skip to content

feat(twap-oracle): implement PublishPrice with real tick-to-price conversion#137

Open
0x-r4bbit wants to merge 1 commit into
mainfrom
feat/update-price
Open

feat(twap-oracle): implement PublishPrice with real tick-to-price conversion#137
0x-r4bbit wants to merge 1 commit into
mainfrom
feat/update-price

Conversation

@0x-r4bbit

Copy link
Copy Markdown
Collaborator

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

@0x-r4bbit 0x-r4bbit requested review from 3esmit and gravityblast June 2, 2026 16:09
@0x-r4bbit

Copy link
Copy Markdown
Collaborator Author

Needs #136

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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_core using uniswap_v3_math + alloy-primitives and 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 thread programs/twap_oracle/src/record_tick.rs Outdated
Comment thread programs/twap_oracle/src/publish_price.rs Outdated
Comment thread programs/twap_oracle/core/src/lib.rs
Comment thread programs/twap_oracle/core/src/lib.rs Outdated
Comment thread programs/twap_oracle/core/src/lib.rs
Comment thread programs/twap_oracle/core/src/lib.rs
@0x-r4bbit 0x-r4bbit force-pushed the feat/update-price branch from 51887aa to 6f91d89 Compare June 17, 2026 10:43
@0x-r4bbit 0x-r4bbit requested a review from Copilot June 17, 2026 10:58
@0x-r4bbit 0x-r4bbit changed the base branch from main to feat/record-tick June 17, 2026 10:59

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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_tick requires clock.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]

Comment thread programs/twap_oracle/src/publish_price.rs
Comment thread programs/twap_oracle/src/publish_price.rs Outdated
Comment thread programs/twap_oracle/src/publish_price.rs Outdated
Comment thread programs/twap_oracle/core/src/lib.rs Outdated
Comment thread programs/twap_oracle/methods/guest/src/bin/twap_oracle.rs
Comment thread programs/twap_oracle/src/publish_price.rs
@0x-r4bbit 0x-r4bbit force-pushed the feat/update-price branch 2 times, most recently from c35bc73 to 6e5b4b9 Compare June 17, 2026 12:48
@0x-r4bbit 0x-r4bbit changed the base branch from feat/record-tick to main June 17, 2026 12:49
@0x-r4bbit

Copy link
Copy Markdown
Collaborator Author

Closing and reopening to trigger CI

@0x-r4bbit 0x-r4bbit closed this Jun 17, 2026
@0x-r4bbit 0x-r4bbit reopened this Jun 17, 2026
…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

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 5 out of 5 changed files in this pull request and generated 2 comments.

Comment on lines +111 to +123
/// 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,
},
Comment on lines +12 to +20
/// 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 3esmit left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

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.

Integer tick-to-price conversion for publish_price

3 participants