Add the first end-to-end coverage of the oracle's RecordTick path, which
previously existed only as native unit tests:
- amm_twap_observations_accumulate_across_swaps_and_yield_time_weighted_average:
drives swaps + RecordTick across simulated time, then checks the cumulative
accumulator and the consulted time-weighted average.
- amm_twap_record_tick_sampling_guard_skips_calls_below_min_interval: exercises
the min-interval sampling guard through the real instruction path.
Running RecordTick through the zkVM surfaced that committing the oracle-owned
~100 KiB observations account costs ~50.9M cycles — over the 2^25 (~33.5M)
public-execution limit — so the instruction aborted on chain. Reduce
OBSERVATIONS_CAPACITY 6396 -> 2048 (~16.8M cycles, ~half the limit); window
coverage is unchanged, only sampling resolution.
Add programs/benchmark, a standalone crate (excluded from the workspace so CI
and the Makefile skip it) that runs the guest ELF through the RISC Zero
executor and reports the per-instruction cycle split, reproducing the on-chain
pass/fail at the limit. Its cost-vs-capacity sweep still spans to 6396, guarding
against bumping capacity back into the over-budget range.
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.
The stored body averages [t1, t2] (t1 = oldest valid entry, t2 = most recent),
needing no boundary search since each buffer is calibrated to one window_duration.
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. This keeps the published timestamp = now truthful: an
unchanged price yields a fresh stamp and the correct value, and a republish picks
up a since-reported move instead of freezing the pre-move average.
The live tick is only credited since it was written, so the tail is split at the
current tick's last_updated:
boundary = clamp(current_tick.last_updated, t2.ts, now)
clamped_tick = last_recorded_tick + clamp(current_tick - last_recorded_tick, ±MAX_TICK_DELTA)
cum_now = t2.tick_cumulative
+ last_recorded_tick * (boundary - t2.ts) // before the live tick took effect
+ clamped_tick * (now - boundary) // live tick, only since last_updated
twap_tick = (cum_now - t1.tick_cumulative) / (now - t1.ts) // floor (div_euclid)
Splitting at last_updated stops a tick written moments before publish from being
smeared across a stale gap and inflating a supposedly fresh TWAP. The live-tick
segment is clamped against last_recorded_tick by MAX_TICK_DELTA — the same bound
RecordTick applies — capping how far a current-tick move can shift the result. A
zero-length tail (now == t2.ts) leaves 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 reject). While young,
the TWAP covers the available span, which may be shorter than the window.
The TWAP tick is converted to a price ratio via the Uniswap v3 sqrtPriceX96
representation (pure integer, zkVM-safe), stored as a Q64.64 in
OraclePriceAccount.price — source-agnostic, no tick 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 encoding.
Closes#117
Replace the hardcoded numeric byte-stream seeds ([0; 32], [1; 32], ...)
used for domain separation in PDA derivation with descriptive byte-string
constants, mirroring the AMM config account's existing b"CONFIG" seed.
amm: [0; 32] -> b"LIQUIDITY_TOKEN"
[1; 32] -> b"LP_LOCK_HOLDING"
stablecoin: [0; 32] -> b"POSITION"
[1; 32] -> b"POSITION_VAULT"
twap_oracle: [2; 32] -> b"PRICE_OBSERVATIONS"
[3; 32] -> b"ORACLE_PRICE_ACCOUNT"
[4; 32] -> b"CURRENT_TICK_ACCOUNT"
Since the seeds are now variable-length, each compute_*_pda_seed function
builds its hash input with a Vec and extend_from_slice instead of a
fixed-size buffer with offset writes.
Closes#146
Make swap_exact_input, swap_exact_output, add_liquidity, remove_liquidity,
and sync_reserves keep the pool's TWAP current tick in sync with its
reserves. Each now takes the current-tick and clock accounts, reads the
TWAP program ID from the config account, validates the clock account and
the current-tick PDA, and after computing the post-op pool chains an
UpdateCurrentTick to the oracle carrying the post-op spot price, with the
pool passed as the authorized price source via its pool PDA seed.
sync_reserves additionally now takes the config account so it can resolve
the TWAP program ID and gate on initialization, consistent with the other
instructions.
The invariant current_tick == tick(reserves) therefore holds after every
operation. Proportional add/remove preserve the price, so the tick is
unchanged for them, but the refresh still runs and lands on the correct
value.
Extend new_definition to also create the pool's TWAP current-tick account
via a chained CreateCurrentTickAccount, so a pool and its price feed are
born together. The opening tick is derived on-chain from the pool's own
reserves (reserve_b / reserve_a as Q64.64), not caller-supplied, so it
cannot be forged. The pool is passed in its post-claim state and authorized
as the price source via its pool PDA seed.
Add spot_price_q64_64 to amm_core (not the oracle): the reserves -> price
mapping is the price source's concern; the oracle only converts price to a
tick.
Add a `CreatePriceObservations` instruction that registers a TWAP
price-observations account for a pool over a time window, via a chained
call to the configured TWAP oracle program. The pool acts as the price
source: the AMM authorizes it with its pool PDA seed so the oracle ties
the feed to that pool.
The feed's initial tick is read from the pool's authoritative
`CurrentTickAccount` (validated against its pool-derived PDA) rather than
being supplied by the caller, so the feed cannot be seeded at a forged
price — mirroring what `RecordTick` does. The clock is verified to be the
canonical 1-block LEZ clock, and creation is rejected if the observations
account already exists.
To support the chained call, `AmmConfig` and the `Initialize` instruction
are extended with a `twap_oracle_program_id` that the instruction reads.
Add an admin authority to the AMM config so configuration can be changed
after initialization. AmmConfig gains an `authority` field, set by
Initialize, and a new UpdateConfig instruction lets that admin change
config values.
UpdateConfig is access-controlled: the authority account must equal the
stored config.authority and be passed authorized (signed). Both fields are
optional — token_program_id updates the chained-call token program, and
new_authority transfers admin control to a different account. Without this
gate any caller could repoint the AMM at a malicious token program.
Introduce a singleton AMM configuration account, a PDA derived from the
constant "CONFIG" seed, created once via a new `Initialize` instruction.
The config stores the Token Program ID the AMM issues every chained call
to, replacing the previous behavior of trusting the program owner of a
caller-supplied holding.
The config account's existence is the Program's initialization gate: the
chained-call instructions (new_definition, add_liquidity, remove_liquidity,
swap_exact_input, swap_exact_output) now take the config as their first
account, validate it against `compute_config_pda(self_program_id)`, and
read the Token Program ID from it on demand — rejecting calls until the
Program is initialized. Vaults and user holdings are asserted to match the
configured Token Program. sync_reserves is left ungated, as it cannot act
on a pool that could not have existed before initialization.
- amm_core: AmmConfig type, compute_config_pda/_seed, Initialize variant
- amm: initialize.rs + config threading through chained-call instructions
- guest: initialize instruction; config + self_program_id on gated calls
- tests: config fixtures, init-gate unit tests, end-to-end Initialize VM test
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
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.
Adds the CreateOraclePriceAccount instruction to the TWAP oracle program.
The instruction initialises a canonical OraclePriceAccount PDA for a given
price source and time window, seeding it with a non-zero initial price and
the current block timestamp so the account is immediately valid to consumers.
- 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.
- The initial timestamp is read from the canonical 1-block LEZ clock
(CLOCK_01_PROGRAM_ACCOUNT_ID), never from a caller-supplied value. The clock
account_id is asserted, so a caller cannot substitute an account they
control to forge the seeding timestamp.
- A zero price or zero timestamp is rejected at creation. Both are the
"no valid price" sentinel consumers treat as unset, so an account must never
be created in that state; the instruction asserts a non-zero initial_price
and a non-zero clock timestamp.
- initial_price is a Q64.64 fixed-point value (real price = initial_price /
2^64), matching the oracle price representation. The non-zero check rejects
the sentinel but cannot validate scale — supplying a correctly-scaled value
is the caller's responsibility.
Closes#129
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