mirror of
https://github.com/logos-blockchain/lez-programs.git
synced 2026-06-30 03:59:38 +00:00
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.
463 lines
17 KiB
Rust
463 lines
17 KiB
Rust
//! 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!();
|
|
}
|