mirror of
https://github.com/logos-blockchain/lez-programs.git
synced 2026-06-29 19:49:26 +00:00
test(twap): cover RecordTick end-to-end and add zkVM cycle benchmark
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.
This commit is contained in:
parent
c528d85a2b
commit
9d5eea2b41
@ -23,7 +23,10 @@ exclude = [
|
||||
"programs/amm/methods/guest",
|
||||
"programs/ata/methods/guest",
|
||||
"programs/stablecoin/methods/guest",
|
||||
"programs/twap_oracle/methods/guest"
|
||||
"programs/twap_oracle/methods/guest",
|
||||
# Cycle benchmarks: standalone crate, kept out of --workspace builds/tests/CI. Run on demand
|
||||
# with `cargo test --manifest-path programs/benchmark/Cargo.toml -- --ignored --nocapture`.
|
||||
"programs/benchmark"
|
||||
]
|
||||
resolver = "2"
|
||||
|
||||
|
||||
5441
programs/benchmark/Cargo.lock
generated
Normal file
5441
programs/benchmark/Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
19
programs/benchmark/Cargo.toml
Normal file
19
programs/benchmark/Cargo.toml
Normal file
@ -0,0 +1,19 @@
|
||||
[package]
|
||||
name = "benchmark"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
# Its own workspace root: this crate is EXCLUDED from the repo workspace (see the root Cargo.toml
|
||||
# `exclude` list) so that `cargo {test,clippy,build} --workspace`, the Makefile targets, and CI
|
||||
# never compile it. It pulls the heavy `risc0-zkvm` `prove` feature and only exists to produce the
|
||||
# cycle benchmarks in `tests/`. Run it explicitly:
|
||||
# cargo test --manifest-path programs/benchmark/Cargo.toml -- --ignored --nocapture
|
||||
[workspace]
|
||||
|
||||
[dependencies]
|
||||
nssa = { git = "https://github.com/logos-blockchain/logos-execution-zone.git", tag = "v0.2.0-rc3" }
|
||||
nssa_core = { git = "https://github.com/logos-blockchain/logos-execution-zone.git", tag = "v0.2.0-rc3", features = ["host"] }
|
||||
twap_oracle_core = { path = "../twap_oracle/core" }
|
||||
twap-oracle-methods = { path = "../twap_oracle/methods" }
|
||||
# `prove` exposes `ExecutorImpl`/`Session` (the cycle split); we only execute, never prove.
|
||||
risc0-zkvm = { version = "=3.0.5", features = ["prove"] }
|
||||
231
programs/benchmark/README.md
Normal file
231
programs/benchmark/README.md
Normal file
@ -0,0 +1,231 @@
|
||||
# TWAP Oracle — `RecordTick` and the zkVM Cycle Budget
|
||||
|
||||
> **Status: fixed.** `OBSERVATIONS_CAPACITY` was reduced from 6396 to **2048**, bringing `RecordTick`
|
||||
> to ~16.8M cycles (~half the limit). The rest of this document is the diagnosis that led there; the
|
||||
> numbers labelled "cap 6396" are the pre-fix measurements that motivated the change.
|
||||
|
||||
At capacity 6396, `RecordTick` could not run on chain: a single call over a full-size observations
|
||||
buffer cost **~50.9M zkVM cycles**, over the **~33.5M (2²⁵)** public-execution limit, and the runtime
|
||||
aborted it. This document explains what a cycle is, why the limit exists, what the measurements
|
||||
actually show (the cause is **not** what it first looks like), and the fix.
|
||||
|
||||
It is backed by a runnable benchmark — `programs/benchmark/tests/twap_cycle_bench.rs` — and
|
||||
by two end-to-end tests in `tests/amm.rs`
|
||||
(`amm_twap_observations_accumulate_*`, `amm_twap_record_tick_sampling_guard_*`).
|
||||
|
||||
---
|
||||
|
||||
## 1. What a "cycle" is
|
||||
|
||||
LEZ programs run inside the RISC Zero zkVM — a **proven RISC-V (rv32im) virtual machine**. A *cycle*
|
||||
is one step of that virtual CPU: essentially one executed RISC-V instruction (most cost 1 cycle, a
|
||||
few cost more, plus some fixed overhead). "50M cycles" ≈ "~50 million instruction-steps," **not** a
|
||||
unit of wall-clock time.
|
||||
|
||||
The prover turns execution into an arithmetic trace with **one row per cycle**, and proving cost —
|
||||
time and memory — scales roughly **linearly with cycle count**. So the cap is fundamentally an
|
||||
**economic/latency bound on how large a computation the network will prove for one public call.**
|
||||
|
||||
The executor reports cycles in a few buckets (visible in the benchmark output):
|
||||
|
||||
- **user cycles** — the RISC-V instructions the guest logic actually runs.
|
||||
- **paging cycles** — the zkVM's memory is a Merkle-committed image of ~1 KiB pages; touching a
|
||||
page hashes it in/out. Large buffers page many pages. (In practice ~3–4% here.)
|
||||
- **reserved cycles** — padding up to the proof system's power-of-two boundaries.
|
||||
|
||||
## 2. The limit
|
||||
|
||||
`nssa` sets the executor's `session_limit` to
|
||||
`MAX_NUM_CYCLES_PUBLIC_EXECUTION = 1024 * 1024 * 32 = 33_554_432 = 2²⁵` cycles
|
||||
(`nssa/src/program.rs`). Every program invocation — public or chained — runs under this cap via the
|
||||
single `Program::execute` path; when a run reaches it, the executor aborts with
|
||||
`Session limit exceeded: 33554432 >= 33554432`, which `nssa` surfaces as `ProgramExecutionFailed`.
|
||||
|
||||
## 3. What the measurements show
|
||||
|
||||
All numbers below are total cycles from the RISC Zero executor running the **real** `twap_oracle`
|
||||
guest ELF, reproducing `Program::execute`'s exact input encoding (see the benchmark). They were
|
||||
cross-checked against accounts extracted from a live `V03State`, which match to the cycle.
|
||||
|
||||
```
|
||||
total cycles vs 2²⁵ limit
|
||||
CreatePriceObservations (cap 6396) 17_301_504 ok
|
||||
RecordTick, owned account (cap 6396) 50_855_936 OVER ← aborts on chain
|
||||
RecordTick, UNOWNED account (cap 6396) 17_825_792 ok
|
||||
```
|
||||
|
||||
The first surprise: **`RecordTick`'s Borsh work is not the problem.** With the observations account
|
||||
left *uninitialized* (`program_owner = 0`) the same instruction over the same 102,388-byte buffer
|
||||
costs only **17.8M** — comfortably under budget. The full deserialize + mutate + reserialize round
|
||||
trip is the cheap part.
|
||||
|
||||
The actual driver is **account ownership**. The benchmark holds everything else fixed and flips only
|
||||
the observations account's `program_owner`:
|
||||
|
||||
```
|
||||
obs_owner = 0 → 17_825_792
|
||||
obs_owner = oracle → 50_855_936 (+33.0M, 2.9×)
|
||||
```
|
||||
|
||||
(The `current_tick` account's owner makes no difference — it's only 12 bytes.)
|
||||
|
||||
### Why ownership costs ~33M cycles
|
||||
|
||||
When a program touches an **initialized (owned)** account, the runtime cryptographically binds that
|
||||
account's state into the proof — on both sides: the **pre-state it read** and the **post-state it
|
||||
wrote**. That work is proportional to the account's serialized size. For a max-size ~100 KiB account
|
||||
it is **~16–17M cycles per side**.
|
||||
|
||||
That single fact explains the whole picture:
|
||||
|
||||
| instruction | owned ~100 KiB account is… | owned commits | total |
|
||||
|---|---|---|---|
|
||||
| `CreatePriceObservations` | **written** only (input is uninitialized) | 1× (write) | 17.3M ✅ |
|
||||
| `RecordTick` | **read and written** | 2× (read + write) | 50.9M ❌ |
|
||||
|
||||
`CreatePriceObservations` pays the commit once and fits; `RecordTick` pays it twice and blows the
|
||||
budget. Everything else (Borsh, paging, the tick arithmetic) is secondary.
|
||||
|
||||
### Cost scales linearly with capacity
|
||||
|
||||
`OBSERVATIONS_CAPACITY = 6396` exists to fill the 100 KiB account ceiling. Because both the commit
|
||||
cost and the Borsh floor scale with account size, `RecordTick`'s cost scales ~linearly with
|
||||
capacity:
|
||||
|
||||
```
|
||||
RecordTick (owned) total cycles vs 2²⁵
|
||||
cap 512 4_259_840 ok
|
||||
cap 1024 8_388_608 ok
|
||||
cap 2048 16_777_216 ok
|
||||
cap 4096 32_538_624 ok (only ~3% headroom)
|
||||
cap 6396 50_855_936 OVER
|
||||
```
|
||||
|
||||
The largest power-of-two capacity that fits is **4096**, but only barely. **2048 (≈16.8M, ~2×
|
||||
headroom)** or smaller is a safe target.
|
||||
|
||||
### Empty buffers cost exactly the same
|
||||
|
||||
Fill level is irrelevant. An effectively-empty buffer (just created, `write_index = 1`, one used
|
||||
entry) and an all-non-zero buffer of the same capacity cost **identical** cycles:
|
||||
|
||||
```
|
||||
empty buffer total = 50_855_936
|
||||
filled buffer total = 50_855_936 (cap 6396, both 102,388 bytes)
|
||||
```
|
||||
|
||||
The account is allocated at full size the moment `CreatePriceObservations` runs (it writes all
|
||||
`OBSERVATIONS_CAPACITY` entries up front), so a brand-new, "empty" feed already pays the full cost.
|
||||
The cost tracks **allocated size**, not how much meaningful data is stored. Reducing capacity is
|
||||
therefore the only lever — you can't dodge it by keeping the buffer sparse.
|
||||
|
||||
### The overhead is per-byte, not a flat per-account tax
|
||||
|
||||
Measuring owned vs. uninitialized across account sizes, the owned-account overhead is **linear in
|
||||
size at ~320 cycles per byte** (for the read + write the account gets in `RecordTick`; ~160/byte per
|
||||
side):
|
||||
|
||||
```
|
||||
cap bytes | owned unowned delta cyc/byte
|
||||
32 564 | 524_288 262_144 262_144 465*
|
||||
256 4_148 | 2_359_296 1_048_576 1_310_720 316
|
||||
1024 16_436 | 8_388_608 3_145_728 5_242_880 319
|
||||
2048 32_820 | 16_777_216 5_767_168 11_010_048 335
|
||||
4096 65_588 | 32_538_624 11_534_336 21_004_288 320
|
||||
6396 102_388 | 50_855_936 17_825_792 33_030_144 323
|
||||
(* tiny accounts round up to the executor's 2^18-cycle segment quantum)
|
||||
```
|
||||
|
||||
So you do **not** lose a fixed slice of the 2²⁵ budget for every owned account — you lose
|
||||
~320 cycles per byte of owned account you **read-modify-write** (~160/byte if you only read, or only
|
||||
write). For ordinary accounts this is noise: a ~50-byte token holding costs ~16 K cycles
|
||||
(<0.05% of budget). It only becomes significant in the tens-of-KB range.
|
||||
|
||||
### This is a general size/cycle tension, not a TWAP quirk
|
||||
|
||||
The practical consequence: with ~320 cyc/byte for a read-modify-write plus the Borsh/IO floor, the
|
||||
**largest owned account a single instruction can read-modify-write within budget is ~65 KB** — and
|
||||
at that size there is essentially no budget left for real work. Any program attempting to
|
||||
read-modify-write a near-max-size (100 KiB) owned account hits the same wall TWAP did, needing
|
||||
~50 M cycles against a ~33.5 M cap.
|
||||
|
||||
In other words, `DATA_MAX_LENGTH = 100 KiB` and `MAX_NUM_CYCLES_PUBLIC_EXECUTION = 2²⁵` are **not
|
||||
jointly satisfiable for full-size read-modify-write**. The runtime permits 100 KiB accounts, but the
|
||||
cycle budget can't commit one on both the read and write side. The implicit design rule is: large
|
||||
accounts must be **read-only, write-only, or paged** per instruction — never fully rewritten in
|
||||
place. That's a normal ZK-rollup constraint, but it's currently unstated.
|
||||
|
||||
This is worth raising with the LEZ runtime team as a protocol-parameter question. Three levers, none
|
||||
free:
|
||||
|
||||
- **Lower `DATA_MAX_LENGTH`** to a size that is committable *and* leaves room to compute (e.g. so a
|
||||
full read-modify-write fits well under budget). Safest guarantee, but caps every program's account
|
||||
size — and read-only consumers of large accounts don't need it lowered.
|
||||
- **Raise `MAX_NUM_CYCLES_PUBLIC_EXECUTION`** so a max-size account is affordable. Directly inflates
|
||||
proving time/cost for *every* program, including ones that never touch big accounts.
|
||||
- **Leave both, document the rule** that large accounts are not full-rewrite-able in one call, and
|
||||
provide a paging pattern. Lowest blast radius; pushes complexity to programs that need big state.
|
||||
|
||||
The TWAP fix below (a smaller buffer) sidesteps the tension for this program regardless of which
|
||||
lever the protocol ultimately picks.
|
||||
|
||||
## 4. The fix
|
||||
|
||||
The budget-breaking cost is **committing the owned ~100 KiB account**, which is intrinsic to the
|
||||
account's *size*. The fix must shrink what gets committed.
|
||||
|
||||
### ✅ Reduce `OBSERVATIONS_CAPACITY` — the simple, effective fix
|
||||
|
||||
Cutting capacity reduces the committed account size, and cost falls ~linearly. Critically, **window
|
||||
coverage is unaffected**: the sampling guard derives `min_interval = window_duration / capacity`, so
|
||||
coverage = `capacity × min_interval = window_duration` regardless of capacity — only *resolution*
|
||||
(samples per window) drops. At capacity 2048 a 24 h window still samples every ~42 s; a 7 d window
|
||||
every ~5 min. That is ample for a TWAP.
|
||||
|
||||
**Applied:** `OBSERVATIONS_CAPACITY = 2048` (≈16.8M cycles, ~2× headroom). 4096 fits but leaves no
|
||||
margin for runtime variation; 2048 keeps `RecordTick` at roughly half the limit.
|
||||
|
||||
### ✅ Linked observation pages — if full resolution is required
|
||||
|
||||
Already sketched in `twap-oracle-observation-capacity.md`: keep a small fixed-size "head" account
|
||||
plus older page accounts. `RecordTick` then commits only the small head, so per-call cost is bounded
|
||||
regardless of total history. More moving parts (page PDAs, chain-walking readers); reserve it for
|
||||
when a single reduced-capacity account genuinely can't hold enough resolution.
|
||||
|
||||
### ❌ Byte-patching `RecordTick` to skip the Borsh round-trip — does NOT fix this
|
||||
|
||||
An earlier hypothesis was that the cost was the full-buffer Borsh deserialize/reserialize, and that
|
||||
patching the serialized bytes in place would make it O(1). **The measurements refute this.** The
|
||||
Borsh round trip is only part of the 17.8M *unowned* floor; the ~33M that breaks the budget is the
|
||||
owned-account commitment, which the runtime performs regardless of how the guest computes the new
|
||||
bytes. Byte-patching would shave a little off the floor and leave `RecordTick` at ~33M+ — still at
|
||||
or over the edge. Avoid this as the primary fix; it addresses the wrong cost.
|
||||
|
||||
### ❌ Raising `MAX_NUM_CYCLES_PUBLIC_EXECUTION` — not a real fix
|
||||
|
||||
It's a platform-wide `nssa` constant; raising it inflates proving cost/time for every program and
|
||||
only defers the wall, since the cost still scales with account size.
|
||||
|
||||
## 5. Reproducing
|
||||
|
||||
```sh
|
||||
# Faithful cycle benchmark (synthetic inputs; reproduces the on-chain pass/fail at 2²⁵).
|
||||
# `programs/benchmark` is a standalone crate, excluded from the workspace, so run it by manifest:
|
||||
cargo test --manifest-path programs/benchmark/Cargo.toml -- --ignored --nocapture
|
||||
|
||||
# The end-to-end TWAP tests through the real zkVM path:
|
||||
RISC0_DEV_MODE=1 cargo test -p integration_tests --test amm twap
|
||||
```
|
||||
|
||||
The benchmark uses `risc0-zkvm` with the `prove` feature to run the guest with the session limit
|
||||
lifted and read the `user/paging/reserved/total` cycle split. It only *executes* the guest — it
|
||||
never proves. It lives in the workspace-excluded `programs/benchmark` crate so normal
|
||||
builds/tests/CI never compile it.
|
||||
|
||||
## 6. Acceptance — met
|
||||
|
||||
With `OBSERVATIONS_CAPACITY = 2048`:
|
||||
|
||||
- `twap_cycle_bench` reports `RecordTick (owned, cap 2048)` at ~16.8M, under the 2²⁵ limit (and its
|
||||
sweep still shows cap 6396 aborting, guarding against bumping capacity back up).
|
||||
- The two `tests/amm.rs` TWAP tests pass through the real zkVM path (no longer `#[ignore]`d).
|
||||
5
programs/benchmark/src/lib.rs
Normal file
5
programs/benchmark/src/lib.rs
Normal file
@ -0,0 +1,5 @@
|
||||
//! Cycle benchmarks for LEZ programs.
|
||||
//!
|
||||
//! This crate has no library code of its own; it exists solely as a host for the benchmarks under
|
||||
//! `tests/`, which run the real guest ELFs through the RISC Zero executor to measure zkVM cycle
|
||||
//! costs. It is excluded from the repo workspace so normal builds/tests/CI never compile it.
|
||||
462
programs/benchmark/tests/twap_cycle_bench.rs
Normal file
462
programs/benchmark/tests/twap_cycle_bench.rs
Normal file
@ -0,0 +1,462 @@
|
||||
//! Cycle-cost benchmark for the TWAP oracle's account writes.
|
||||
//!
|
||||
//! This runs the real `twap_oracle` guest ELF directly through the RISC Zero executor (no proving)
|
||||
//! with the session limit lifted, so we can measure the zkVM cycle cost of instructions that exceed
|
||||
//! the on-chain `MAX_NUM_CYCLES_PUBLIC_EXECUTION = 32 MiCycles` budget — `RecordTick` in particular,
|
||||
//! which aborts under the normal runtime and therefore can't be measured through `nssa`'s
|
||||
//! `transition_from_public_transaction`.
|
||||
//!
|
||||
//! It reproduces `nssa::program::Program::execute`'s input encoding (four `env.write` calls:
|
||||
//! program id, caller program id, pre-states, instruction words) and reports the executor's
|
||||
//! user / paging / reserved / total cycle split per scenario.
|
||||
//!
|
||||
//! Ignored by default (it executes the guest several times and prints a report). Run with:
|
||||
//!
|
||||
//! ```sh
|
||||
//! cargo test --manifest-path programs/benchmark/Cargo.toml -- --ignored --nocapture
|
||||
//! ```
|
||||
|
||||
use nssa::CLOCK_01_PROGRAM_ACCOUNT_ID;
|
||||
use nssa_core::{
|
||||
account::{Account, AccountId, AccountWithMetadata, Data},
|
||||
program::ProgramId,
|
||||
};
|
||||
use risc0_zkvm::{default_executor, serde::to_vec, ExecutorEnv, ExecutorImpl};
|
||||
use twap_oracle_core::{
|
||||
compute_current_tick_account_pda, compute_price_observations_pda, CurrentTickAccount,
|
||||
Instruction, ObservationEntry, PriceObservations, OBSERVATIONS_CAPACITY,
|
||||
};
|
||||
|
||||
/// The on-chain public-execution cycle ceiling (`MAX_NUM_CYCLES_PUBLIC_EXECUTION` in `nssa`).
|
||||
const PUBLIC_EXECUTION_CYCLE_LIMIT: u64 = 1024 * 1024 * 32;
|
||||
|
||||
/// A 24-hour window in milliseconds; `min_interval = WINDOW / OBSERVATIONS_CAPACITY ≈ 13.5 s`.
|
||||
const WINDOW_MS: u64 = 24 * 60 * 60 * 1_000;
|
||||
|
||||
const PRICE_SOURCE_BYTES: [u8; 32] = [7u8; 32];
|
||||
/// Timestamp of the most recent observation already in the buffer.
|
||||
const LAST_OBSERVATION_TS: u64 = 1_000_000;
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
struct Cycles {
|
||||
user: u64,
|
||||
paging: u64,
|
||||
reserved: u64,
|
||||
total: u64,
|
||||
}
|
||||
|
||||
fn program_id() -> ProgramId {
|
||||
twap_oracle_methods::TWAP_ORACLE_ID
|
||||
}
|
||||
|
||||
fn price_source_id() -> AccountId {
|
||||
AccountId::new(PRICE_SOURCE_BYTES)
|
||||
}
|
||||
|
||||
/// Borsh layout of `ClockAccountData { block_id: u64, timestamp: u64 }` — two little-endian u64s.
|
||||
fn clock_account(timestamp: u64) -> AccountWithMetadata {
|
||||
let mut bytes = Vec::with_capacity(16);
|
||||
bytes.extend_from_slice(&0u64.to_le_bytes()); // block_id
|
||||
bytes.extend_from_slice(×tamp.to_le_bytes());
|
||||
AccountWithMetadata {
|
||||
account: Account {
|
||||
data: Data::try_from(bytes).expect("clock data fits"),
|
||||
..Account::default()
|
||||
},
|
||||
is_authorized: false,
|
||||
account_id: CLOCK_01_PROGRAM_ACCOUNT_ID,
|
||||
}
|
||||
}
|
||||
|
||||
/// Builds a fully-populated `PriceObservations` with `capacity` entries, as `RecordTick` would find
|
||||
/// it on-chain (the real account always holds `OBSERVATIONS_CAPACITY` entries; smaller values are
|
||||
/// synthetic, used only to trace the cost-vs-size curve).
|
||||
fn observations_account(capacity: usize) -> AccountWithMetadata {
|
||||
let mut entries = vec![ObservationEntry::default(); capacity];
|
||||
entries[0] = ObservationEntry {
|
||||
timestamp: LAST_OBSERVATION_TS,
|
||||
tick_cumulative: 0,
|
||||
};
|
||||
let observations = PriceObservations {
|
||||
price_source_id: price_source_id(),
|
||||
write_index: 1,
|
||||
total_entries: 1,
|
||||
last_recorded_tick: 100,
|
||||
entries,
|
||||
};
|
||||
AccountWithMetadata {
|
||||
account: Account {
|
||||
// Owned by the oracle: this is an *initialized* account on chain. Ownership is what
|
||||
// makes the runtime commit the full ~100 KiB to the proof — the dominant cost, and the
|
||||
// reason `RecordTick` (which reads this account) exceeds the budget while
|
||||
// `CreatePriceObservations` (whose input is uninitialized) does not.
|
||||
program_owner: program_id(),
|
||||
data: Data::from(&observations),
|
||||
..Account::default()
|
||||
},
|
||||
is_authorized: false,
|
||||
account_id: compute_price_observations_pda(program_id(), price_source_id(), WINDOW_MS),
|
||||
}
|
||||
}
|
||||
|
||||
fn current_tick_account() -> AccountWithMetadata {
|
||||
let current = CurrentTickAccount {
|
||||
tick: 250,
|
||||
last_updated: LAST_OBSERVATION_TS,
|
||||
};
|
||||
AccountWithMetadata {
|
||||
account: Account {
|
||||
program_owner: program_id(),
|
||||
data: Data::from(¤t),
|
||||
..Account::default()
|
||||
},
|
||||
is_authorized: false,
|
||||
account_id: compute_current_tick_account_pda(program_id(), price_source_id()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Same as [`observations_account`] but left *uninitialized* (`program_owner = 0`), to isolate how
|
||||
/// much of the cost is the runtime committing the owned account vs. the Borsh round-trip itself.
|
||||
fn observations_account_unowned(capacity: usize) -> AccountWithMetadata {
|
||||
let mut meta = observations_account(capacity);
|
||||
meta.account.program_owner = [0u32; 8];
|
||||
meta
|
||||
}
|
||||
|
||||
fn build_env<'a>(
|
||||
pre_states: &[AccountWithMetadata],
|
||||
instruction: &Instruction,
|
||||
session_limit: Option<u64>,
|
||||
) -> ExecutorEnv<'a> {
|
||||
let instruction_data: Vec<u32> = to_vec(instruction).expect("instruction serializes");
|
||||
|
||||
let mut builder = ExecutorEnv::builder();
|
||||
builder.write(&program_id()).expect("write program id");
|
||||
builder
|
||||
.write(&None::<ProgramId>)
|
||||
.expect("write caller program id");
|
||||
builder
|
||||
.write(&pre_states.to_vec())
|
||||
.expect("write pre-states");
|
||||
builder
|
||||
.write(&instruction_data)
|
||||
.expect("write instruction data");
|
||||
builder.session_limit(session_limit);
|
||||
builder.build().expect("env builds")
|
||||
}
|
||||
|
||||
/// Runs the guest with the session limit lifted and returns the executor's cycle split.
|
||||
fn run(pre_states: &[AccountWithMetadata], instruction: &Instruction) -> Cycles {
|
||||
let env = build_env(pre_states, instruction, None);
|
||||
let session = ExecutorImpl::from_elf(env, twap_oracle_methods::TWAP_ORACLE_ELF)
|
||||
.expect("loads ELF")
|
||||
.run()
|
||||
.expect("guest executes without panicking");
|
||||
|
||||
Cycles {
|
||||
user: session.user_cycles,
|
||||
paging: session.paging_cycles,
|
||||
reserved: session.reserved_cycles,
|
||||
total: session.total_cycles,
|
||||
}
|
||||
}
|
||||
|
||||
/// Runs the guest under a hard session limit — exactly as `nssa::program::Program::execute` does on
|
||||
/// chain — and reports whether it completed or was aborted by the limit. This is the ground-truth
|
||||
/// reproduction of the on-chain behaviour.
|
||||
fn completes_under_limit(
|
||||
pre_states: &[AccountWithMetadata],
|
||||
instruction: &Instruction,
|
||||
limit: u64,
|
||||
) -> bool {
|
||||
let env = build_env(pre_states, instruction, Some(limit));
|
||||
ExecutorImpl::from_elf(env, twap_oracle_methods::TWAP_ORACLE_ELF)
|
||||
.expect("loads ELF")
|
||||
.run()
|
||||
.is_ok()
|
||||
}
|
||||
|
||||
/// Same as [`completes_under_limit`] but via `default_executor().execute()` — the EXACT entry point
|
||||
/// `nssa::program::Program::execute` uses — to check whether the executor path (not just the input
|
||||
/// encoding) accounts for the session limit differently than [`ExecutorImpl::run`].
|
||||
fn completes_under_limit_via_default_executor(
|
||||
pre_states: &[AccountWithMetadata],
|
||||
instruction: &Instruction,
|
||||
limit: u64,
|
||||
) -> bool {
|
||||
let env = build_env(pre_states, instruction, Some(limit));
|
||||
default_executor()
|
||||
.execute(env, twap_oracle_methods::TWAP_ORACLE_ELF)
|
||||
.is_ok()
|
||||
}
|
||||
|
||||
/// `CreatePriceObservations` inputs: constructs and serializes the full buffer once (no
|
||||
/// deserialize).
|
||||
fn create_inputs() -> (Vec<AccountWithMetadata>, Instruction) {
|
||||
let observations = AccountWithMetadata {
|
||||
account: Account::default(), // must be uninitialized
|
||||
is_authorized: false,
|
||||
account_id: compute_price_observations_pda(program_id(), price_source_id(), WINDOW_MS),
|
||||
};
|
||||
let price_source = AccountWithMetadata {
|
||||
account: Account::default(),
|
||||
is_authorized: true, // caller must control the price source
|
||||
account_id: price_source_id(),
|
||||
};
|
||||
(
|
||||
vec![
|
||||
observations,
|
||||
price_source,
|
||||
clock_account(LAST_OBSERVATION_TS),
|
||||
],
|
||||
Instruction::CreatePriceObservations {
|
||||
initial_tick: 0,
|
||||
window_duration: WINDOW_MS,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/// `RecordTick` inputs over a `capacity`-entry buffer. `elapsed_ms` selects the path: below
|
||||
/// `min_interval` the sampling guard returns after deserializing only (no reserialize); above it
|
||||
/// the full deserialize + mutate + reserialize write path runs.
|
||||
fn record_inputs(capacity: usize, elapsed_ms: u64) -> (Vec<AccountWithMetadata>, Instruction) {
|
||||
(
|
||||
vec![
|
||||
observations_account(capacity),
|
||||
current_tick_account(),
|
||||
clock_account(LAST_OBSERVATION_TS + elapsed_ms),
|
||||
],
|
||||
Instruction::RecordTick {
|
||||
price_source_id: price_source_id(),
|
||||
window_duration: WINDOW_MS,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
fn pct(part: u64, whole: u64) -> f64 {
|
||||
if whole == 0 {
|
||||
0.0
|
||||
} else {
|
||||
part as f64 / whole as f64 * 100.0
|
||||
}
|
||||
}
|
||||
|
||||
fn report(label: &str, c: Cycles) {
|
||||
let over = if c.total > PUBLIC_EXECUTION_CYCLE_LIMIT {
|
||||
"OVER"
|
||||
} else {
|
||||
"ok"
|
||||
};
|
||||
println!(
|
||||
"{label:<34} total={:>10} user={:>10} ({:>4.1}%) paging={:>10} ({:>4.1}%) reserved={:>9} [{over}]",
|
||||
c.total,
|
||||
c.user,
|
||||
pct(c.user, c.total),
|
||||
c.paging,
|
||||
pct(c.paging, c.total),
|
||||
c.reserved,
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "cycle benchmark; run explicitly with --ignored --nocapture"]
|
||||
fn twap_record_tick_cycle_budget_report() {
|
||||
let cap = OBSERVATIONS_CAPACITY as usize;
|
||||
let min_interval = WINDOW_MS / u64::from(OBSERVATIONS_CAPACITY);
|
||||
let limit = PUBLIC_EXECUTION_CYCLE_LIMIT;
|
||||
|
||||
// ── Ground truth: reproduce the on-chain pass/fail under the real hard session limit. ──
|
||||
// `cap` is the live OBSERVATIONS_CAPACITY; after the capacity reduction both instructions fit.
|
||||
println!("\nUnder the on-chain hard limit (session_limit = {limit} = 2^25):\n");
|
||||
let (create_pre, create_instr) = create_inputs();
|
||||
let create_ok = completes_under_limit(&create_pre, &create_instr, limit);
|
||||
println!(" CreatePriceObservations (cap {cap}): {}", verdict(create_ok));
|
||||
|
||||
let (write_pre, write_instr) = record_inputs(cap, min_interval * 4);
|
||||
let write_ok = completes_under_limit(&write_pre, &write_instr, limit);
|
||||
println!(" RecordTick (cap {cap}): {}", verdict(write_ok));
|
||||
|
||||
// The exact nssa entry point, to expose any executor-path accounting difference.
|
||||
let create_ok_de =
|
||||
completes_under_limit_via_default_executor(&create_pre, &create_instr, limit);
|
||||
let write_ok_de = completes_under_limit_via_default_executor(&write_pre, &write_instr, limit);
|
||||
println!(
|
||||
" via default_executor(): create={} record={}",
|
||||
verdict(create_ok_de),
|
||||
verdict(write_ok_de)
|
||||
);
|
||||
|
||||
// ── Cycle breakdown (session limit lifted). ──
|
||||
println!("\nMeasured cycle split (session limit lifted):\n");
|
||||
let create = run(&create_pre, &create_instr);
|
||||
report(&format!("CreatePriceObservations (cap {cap})"), create);
|
||||
|
||||
let write = run(&write_pre, &write_instr);
|
||||
report(&format!("RecordTick, owned account (cap {cap})"), write);
|
||||
|
||||
// Same instruction, same buffer bytes, but the input account is left uninitialized: isolates
|
||||
// the cost of committing the owned account from the Borsh round-trip.
|
||||
let (unowned_pre, unowned_instr) = (
|
||||
vec![
|
||||
observations_account_unowned(cap),
|
||||
current_tick_account(),
|
||||
clock_account(LAST_OBSERVATION_TS + min_interval * 4),
|
||||
],
|
||||
Instruction::RecordTick {
|
||||
price_source_id: price_source_id(),
|
||||
window_duration: WINDOW_MS,
|
||||
},
|
||||
);
|
||||
let unowned = run(&unowned_pre, &unowned_instr);
|
||||
report(&format!("RecordTick, UNOWNED account (cap {cap})"), unowned);
|
||||
println!(
|
||||
" -> committing the owned account costs ~{} cycles ({:.1}x).",
|
||||
write.total.saturating_sub(unowned.total),
|
||||
write.total as f64 / unowned.total as f64,
|
||||
);
|
||||
|
||||
// Cost-vs-capacity curve. The list spans the reduced capacity through the original 6396, which
|
||||
// exceeds the budget — a guard against bumping OBSERVATIONS_CAPACITY back into the danger zone.
|
||||
println!("\nRecordTick (owned) cost vs buffer capacity:\n");
|
||||
for capacity in [512usize, 1024, 2048, 4096, 6396] {
|
||||
let (pre, instr) = record_inputs(capacity, WINDOW_MS / capacity as u64 * 4);
|
||||
let c = run(&pre, &instr);
|
||||
let ok = completes_under_limit(&pre, &instr, limit);
|
||||
report(&format!("RecordTick (cap {capacity}) [{}]", verdict(ok)), c);
|
||||
}
|
||||
println!();
|
||||
|
||||
// After the capacity reduction, both instructions fit the on-chain budget.
|
||||
assert!(
|
||||
create_ok,
|
||||
"expected CreatePriceObservations to complete under the on-chain limit"
|
||||
);
|
||||
assert!(
|
||||
write_ok,
|
||||
"expected RecordTick (cap {cap}) to complete under the on-chain limit; \
|
||||
OBSERVATIONS_CAPACITY may be too large"
|
||||
);
|
||||
let _ = (create_ok_de, write_ok_de); // printed above for cross-checking the executor path
|
||||
}
|
||||
|
||||
fn verdict(ok: bool) -> &'static str {
|
||||
if ok {
|
||||
"COMPLETES"
|
||||
} else {
|
||||
"ABORTED"
|
||||
}
|
||||
}
|
||||
|
||||
/// An owned observations account whose `capacity` entries are ALL non-zero (a "full" ring buffer),
|
||||
/// to test whether the commit cost depends on the data values / fill level or only on size.
|
||||
fn observations_account_filled(capacity: usize) -> AccountWithMetadata {
|
||||
let entries = (0..capacity)
|
||||
.map(|i| ObservationEntry {
|
||||
timestamp: 1_000 + i as u64,
|
||||
tick_cumulative: -(i as i64) - 1,
|
||||
})
|
||||
.collect();
|
||||
let observations = PriceObservations {
|
||||
price_source_id: price_source_id(),
|
||||
write_index: 1,
|
||||
total_entries: capacity as u64,
|
||||
last_recorded_tick: -123,
|
||||
entries,
|
||||
};
|
||||
AccountWithMetadata {
|
||||
account: Account {
|
||||
program_owner: program_id(),
|
||||
data: Data::from(&observations),
|
||||
..Account::default()
|
||||
},
|
||||
is_authorized: false,
|
||||
account_id: compute_price_observations_pda(program_id(), price_source_id(), WINDOW_MS),
|
||||
}
|
||||
}
|
||||
|
||||
/// Serialized size of a `capacity`-entry observations account (52-byte header + 16 B/entry).
|
||||
fn obs_bytes(capacity: usize) -> usize {
|
||||
52 + capacity * 16
|
||||
}
|
||||
|
||||
/// Answers the follow-up questions:
|
||||
/// a) empty vs filled ring buffer — does fill level matter, or only allocated size?
|
||||
/// b) is the owned-account overhead proportional to size (a per-byte cost) or a flat per-account
|
||||
/// tax? i.e. how much of the 2^25 budget does touching an owned account actually consume?
|
||||
/// c) what is the largest account a program can *read-modify-write* within the budget?
|
||||
#[test]
|
||||
#[ignore = "diagnostic; run with --ignored --nocapture"]
|
||||
fn twap_owned_account_commit_cost() {
|
||||
let min_interval = WINDOW_MS / u64::from(OBSERVATIONS_CAPACITY);
|
||||
let elapsed = min_interval * 4;
|
||||
let cap = OBSERVATIONS_CAPACITY as usize;
|
||||
|
||||
// (a) Same size, opposite fill: an effectively-empty buffer (write_index = 1, one used entry)
|
||||
// vs an all-non-zero buffer. Both are fully allocated once created.
|
||||
println!("\n(a) fill level, at cap {cap} (both {} bytes):", obs_bytes(cap));
|
||||
let empty = run(
|
||||
&[
|
||||
observations_account(cap), // entries all default except [0]
|
||||
current_tick_account(),
|
||||
clock_account(LAST_OBSERVATION_TS + elapsed),
|
||||
],
|
||||
&Instruction::RecordTick {
|
||||
price_source_id: price_source_id(),
|
||||
window_duration: WINDOW_MS,
|
||||
},
|
||||
);
|
||||
let filled = run(
|
||||
&[
|
||||
observations_account_filled(cap), // every entry non-zero
|
||||
current_tick_account(),
|
||||
clock_account(LAST_OBSERVATION_TS + elapsed),
|
||||
],
|
||||
&Instruction::RecordTick {
|
||||
price_source_id: price_source_id(),
|
||||
window_duration: WINDOW_MS,
|
||||
},
|
||||
);
|
||||
println!(" empty buffer total = {}", empty.total);
|
||||
println!(" filled buffer total = {}", filled.total);
|
||||
|
||||
// (b)+(c) owned vs unowned across sizes → the per-byte commit cost.
|
||||
println!("\n(b) owned vs unowned RecordTick by account size:\n");
|
||||
println!(
|
||||
"{:>5} {:>8} | {:>11} {:>11} {:>11} {:>10}",
|
||||
"cap", "bytes", "owned", "unowned", "delta", "cyc/byte"
|
||||
);
|
||||
for capacity in [32usize, 256, 1024, 2048, 4096, 6396] {
|
||||
let owned = run(
|
||||
&[
|
||||
observations_account(capacity),
|
||||
current_tick_account(),
|
||||
clock_account(LAST_OBSERVATION_TS + elapsed),
|
||||
],
|
||||
&Instruction::RecordTick {
|
||||
price_source_id: price_source_id(),
|
||||
window_duration: WINDOW_MS,
|
||||
},
|
||||
);
|
||||
let unowned = run(
|
||||
&[
|
||||
observations_account_unowned(capacity),
|
||||
current_tick_account(),
|
||||
clock_account(LAST_OBSERVATION_TS + elapsed),
|
||||
],
|
||||
&Instruction::RecordTick {
|
||||
price_source_id: price_source_id(),
|
||||
window_duration: WINDOW_MS,
|
||||
},
|
||||
);
|
||||
let bytes = obs_bytes(capacity);
|
||||
let delta = owned.total.saturating_sub(unowned.total);
|
||||
println!(
|
||||
"{capacity:>5} {bytes:>8} | {:>11} {:>11} {:>11} {:>10.0}",
|
||||
owned.total,
|
||||
unowned.total,
|
||||
delta,
|
||||
delta as f64 / bytes as f64,
|
||||
);
|
||||
}
|
||||
println!();
|
||||
}
|
||||
@ -1566,6 +1566,219 @@ fn amm_create_price_observations_without_current_tick_account_fails() {
|
||||
);
|
||||
}
|
||||
|
||||
/// Advances the canonical 1-block clock to `timestamp` by submitting a clock transaction, mirroring
|
||||
/// how the sequencer ticks the clock between blocks. `RecordTick` reads this account, so the TWAP
|
||||
/// tests use it to simulate the passage of time between observations.
|
||||
#[cfg(test)]
|
||||
fn advance_clock(state: &mut V03State, timestamp: u64) {
|
||||
let message = public_transaction::Message::try_new(
|
||||
nssa::program::Program::clock().id(),
|
||||
nssa::CLOCK_PROGRAM_ACCOUNT_IDS.to_vec(),
|
||||
vec![],
|
||||
timestamp,
|
||||
)
|
||||
.unwrap();
|
||||
let witness_set = public_transaction::WitnessSet::for_message(&message, &[]);
|
||||
let tx = PublicTransaction::new(message, witness_set);
|
||||
state.transition_from_public_transaction(&tx, 0, 0).unwrap();
|
||||
}
|
||||
|
||||
/// Calls the TWAP oracle's permissionless `RecordTick` directly (it is not wrapped by the AMM),
|
||||
/// folding the pool's current tick into its observations ring buffer for the given window.
|
||||
#[cfg(test)]
|
||||
fn execute_record_tick(state: &mut V03State, window_duration: u64) -> Result<(), NssaError> {
|
||||
let instruction = twap_oracle_core::Instruction::RecordTick {
|
||||
price_source_id: Ids::pool_definition(),
|
||||
window_duration,
|
||||
};
|
||||
|
||||
let message = public_transaction::Message::try_new(
|
||||
Ids::twap_oracle_program(),
|
||||
vec![
|
||||
Ids::price_observations(window_duration),
|
||||
Ids::current_tick_account(),
|
||||
CLOCK_01_PROGRAM_ACCOUNT_ID,
|
||||
],
|
||||
vec![],
|
||||
instruction,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let witness_set = public_transaction::WitnessSet::for_message(&message, &[]);
|
||||
let tx = PublicTransaction::new(message, witness_set);
|
||||
state.transition_from_public_transaction(&tx, 0, 0)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn read_observations(
|
||||
state: &V03State,
|
||||
window_duration: u64,
|
||||
) -> twap_oracle_core::PriceObservations {
|
||||
twap_oracle_core::PriceObservations::try_from(
|
||||
&state
|
||||
.get_account_by_id(Ids::price_observations(window_duration))
|
||||
.data,
|
||||
)
|
||||
.expect("observations account must hold a valid PriceObservations")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn read_current_tick(state: &V03State) -> i32 {
|
||||
twap_oracle_core::CurrentTickAccount::try_from(
|
||||
&state.get_account_by_id(Ids::current_tick_account()).data,
|
||||
)
|
||||
.expect("current tick account must hold a valid CurrentTickAccount")
|
||||
.tick
|
||||
}
|
||||
|
||||
/// End-to-end TWAP accumulation: a pool's price moves over time through real swaps, the oracle
|
||||
/// folds each new tick into its observations buffer via `RecordTick`, and the time-weighted average
|
||||
/// recovered from two snapshots matches the expected arithmetic mean of the intervening ticks.
|
||||
///
|
||||
/// This is the headline path the rest of the suite never exercises: every other test stops at the
|
||||
/// freshly-created buffer (`write_index == 1`), so the accumulator math and the consult subtraction
|
||||
/// are only proven here, through the zkVM-facing instruction interface.
|
||||
#[test]
|
||||
fn amm_twap_observations_accumulate_across_swaps_and_yield_time_weighted_average() {
|
||||
let mut state = state_for_amm_tests();
|
||||
let window_duration = 24 * 60 * 60 * 1_000u64;
|
||||
execute_create_price_observations(&mut state, window_duration).unwrap();
|
||||
|
||||
// The creation observation sits at index 0 with a zero cumulative and the genesis timestamp.
|
||||
let created = read_observations(&state, window_duration);
|
||||
assert_eq!(created.write_index, 1);
|
||||
assert_eq!(created.total_entries, 1);
|
||||
assert_eq!(created.entries[0].timestamp, 0);
|
||||
assert_eq!(created.entries[0].tick_cumulative, 0);
|
||||
|
||||
// Each step advances the clock by a fixed interval, moves the price with a swap, then records
|
||||
// the resulting tick. The interval (60s) is above the sampling guard's minimum
|
||||
// (window / OBSERVATIONS_CAPACITY ≈ 42s), so every record is accepted.
|
||||
let step_ms = 60_000u64;
|
||||
let mut prev_timestamp = 0u64;
|
||||
let mut prev_cumulative = 0i64;
|
||||
let mut prev_recorded_tick = created.last_recorded_tick;
|
||||
let mut snapshots: Vec<(u64, i32, i64)> = Vec::new();
|
||||
|
||||
for (step, do_swap) in [1u64, 2, 3].into_iter().zip([
|
||||
// a->b, a->b (price keeps falling), then b->a (price rebounds), so the recorded ticks vary
|
||||
// in both directions across the window.
|
||||
Swap::AtoB,
|
||||
Swap::AtoB,
|
||||
Swap::BtoA,
|
||||
]) {
|
||||
let now = step * step_ms;
|
||||
advance_clock(&mut state, now);
|
||||
match do_swap {
|
||||
Swap::AtoB => execute_swap_a_to_b(&mut state, 1_000, 1),
|
||||
Swap::BtoA => execute_swap_b_to_a(&mut state, 1_000, 1),
|
||||
}
|
||||
|
||||
let current_tick = read_current_tick(&state);
|
||||
// Keep moves within the per-observation clamp so the integrated tick equals the raw tick;
|
||||
// the clamping path itself is covered by the oracle's unit tests.
|
||||
assert!(
|
||||
(current_tick - prev_recorded_tick).abs() <= twap_oracle_core::MAX_TICK_DELTA,
|
||||
"swap move must stay within MAX_TICK_DELTA for this test's exact-equality assertions"
|
||||
);
|
||||
|
||||
execute_record_tick(&mut state, window_duration).unwrap();
|
||||
|
||||
let elapsed = i64::try_from(now - prev_timestamp).unwrap();
|
||||
let expected_cumulative = prev_cumulative + i64::from(current_tick) * elapsed;
|
||||
|
||||
let obs = read_observations(&state, window_duration);
|
||||
let written_index = usize::try_from(step).unwrap();
|
||||
assert_eq!(
|
||||
obs.total_entries,
|
||||
step + 1,
|
||||
"each accepted record appends exactly one entry"
|
||||
);
|
||||
assert_eq!(obs.write_index, u32::try_from(step + 1).unwrap());
|
||||
assert_eq!(obs.entries[written_index].timestamp, now);
|
||||
assert_eq!(
|
||||
obs.entries[written_index].tick_cumulative, expected_cumulative,
|
||||
"cumulative must advance by tick × elapsed_ms"
|
||||
);
|
||||
// last_recorded_tick tracks the raw tick for the next delta computation.
|
||||
assert_eq!(obs.last_recorded_tick, current_tick);
|
||||
|
||||
snapshots.push((now, current_tick, expected_cumulative));
|
||||
prev_timestamp = now;
|
||||
prev_cumulative = expected_cumulative;
|
||||
prev_recorded_tick = current_tick;
|
||||
}
|
||||
|
||||
// Consult the oracle the way a consumer would: the arithmetic-mean tick over [t1, t3] is the
|
||||
// difference of the two cumulative snapshots divided by the elapsed time.
|
||||
let (t1, _tick1, cum1) = snapshots[0];
|
||||
let (t3, _tick3, cum3) = snapshots[2];
|
||||
let time_weighted_tick = (cum3 - cum1) / i64::try_from(t3 - t1).unwrap();
|
||||
|
||||
// With equal 60s intervals the time-weighted mean reduces to the plain average of the ticks
|
||||
// recorded at t2 and t3 (the two increments inside the (t1, t3] window).
|
||||
let tick2 = snapshots[1].1;
|
||||
let tick3 = snapshots[2].1;
|
||||
assert_eq!(
|
||||
time_weighted_tick,
|
||||
(i64::from(tick2) + i64::from(tick3)) / 2
|
||||
);
|
||||
|
||||
// Sanity: the average lies between the extremes it was built from.
|
||||
let lo = i64::from(tick2.min(tick3));
|
||||
let hi = i64::from(tick2.max(tick3));
|
||||
assert!((lo..=hi).contains(&time_weighted_tick));
|
||||
}
|
||||
|
||||
/// `RecordTick` is permissionless and may be called on every block; its sampling guard silently
|
||||
/// skips writes until `window / OBSERVATIONS_CAPACITY` ms have elapsed. This drives that guard
|
||||
/// through the real instruction path: a too-soon call is a no-op that also leaves the delta
|
||||
/// baseline untouched, and a later call past the interval resumes recording.
|
||||
#[test]
|
||||
fn amm_twap_record_tick_sampling_guard_skips_calls_below_min_interval() {
|
||||
let mut state = state_for_amm_tests();
|
||||
let window_duration = 24 * 60 * 60 * 1_000u64;
|
||||
execute_create_price_observations(&mut state, window_duration).unwrap();
|
||||
|
||||
let baseline = read_observations(&state, window_duration);
|
||||
let min_interval = window_duration / u64::from(twap_oracle_core::OBSERVATIONS_CAPACITY);
|
||||
|
||||
// Move the price, then record well within the minimum interval: the guard must skip the write.
|
||||
advance_clock(&mut state, min_interval - 1);
|
||||
execute_swap_a_to_b(&mut state, 1_000, 1);
|
||||
execute_record_tick(&mut state, window_duration).unwrap();
|
||||
|
||||
let after_skip = read_observations(&state, window_duration);
|
||||
assert_eq!(
|
||||
after_skip.write_index, baseline.write_index,
|
||||
"a too-soon record must not advance the ring buffer"
|
||||
);
|
||||
assert_eq!(after_skip.total_entries, baseline.total_entries);
|
||||
assert_eq!(
|
||||
after_skip.last_recorded_tick, baseline.last_recorded_tick,
|
||||
"the skipped record must not move the delta baseline either"
|
||||
);
|
||||
|
||||
// Past the minimum interval the same call resumes recording.
|
||||
advance_clock(&mut state, min_interval + 1);
|
||||
let current_tick = read_current_tick(&state);
|
||||
execute_record_tick(&mut state, window_duration).unwrap();
|
||||
|
||||
let after_write = read_observations(&state, window_duration);
|
||||
assert_eq!(after_write.write_index, baseline.write_index + 1);
|
||||
assert_eq!(after_write.total_entries, baseline.total_entries + 1);
|
||||
assert_eq!(after_write.last_recorded_tick, current_tick);
|
||||
let written = usize::try_from(baseline.write_index).unwrap();
|
||||
assert_eq!(after_write.entries[written].timestamp, min_interval + 1);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[derive(Clone, Copy)]
|
||||
enum Swap {
|
||||
AtoB,
|
||||
BtoA,
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn amm_remove_liquidity() {
|
||||
let mut state = state_for_amm_tests();
|
||||
|
||||
@ -168,16 +168,14 @@ pub const MAX_TICK_DELTA: i32 = 9_116;
|
||||
|
||||
/// Number of entries in each price feed.
|
||||
///
|
||||
/// 6 396 is the maximum that fits within the `DATA_MAX_LENGTH = 100 KiB` runtime ceiling.
|
||||
/// Each [`ObservationEntry`] is 16 bytes (`timestamp` 8 + `tick_cumulative` 8); fixed overhead
|
||||
/// is 52 bytes (`price_source_id` 32 + `write_index` 4 + `total_entries` 8 +
|
||||
/// `last_recorded_tick` 4 + Borsh `Vec` length prefix 4), leaving 102 348 bytes for entries:
|
||||
/// `floor(102 348 / 16) = 6 396`.
|
||||
/// Bounded by the zkVM cycle budget, not storage: `RecordTick` commits this owned account on both
|
||||
/// read and write, so cost scales with size and a full 100 KiB buffer exceeds the public-execution
|
||||
/// limit. See `programs/benchmark/README.md` and `programs/benchmark/tests/twap_cycle_bench.rs`.
|
||||
///
|
||||
/// The effective history window depends on the `window_duration` used to derive the feed PDA
|
||||
/// and the sampling guard: `min_interval = window_duration / OBSERVATIONS_CAPACITY`. A 24 h feed
|
||||
/// samples every ~13 s; a 7 d feed every ~94 s; a 30 d feed every ~7 min.
|
||||
pub const OBSERVATIONS_CAPACITY: u32 = 6396;
|
||||
/// Capacity primarily affects resolution: `min_interval = window_duration / OBSERVATIONS_CAPACITY`
|
||||
/// (integer division), so full-buffer coverage is approximately `window_duration` (up to
|
||||
/// `< OBSERVATIONS_CAPACITY` ms shorter) — only resolution changes meaningfully.
|
||||
pub const OBSERVATIONS_CAPACITY: u32 = 2048;
|
||||
|
||||
/// A single price entry written to a [`PriceObservations`].
|
||||
#[derive(
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user