diff --git a/intent_settlement/src/bench.rs b/intent_settlement/src/bench.rs index f70c5f3..ce47079 100644 --- a/intent_settlement/src/bench.rs +++ b/intent_settlement/src/bench.rs @@ -1,6 +1,6 @@ #![cfg(test)] -//! Resource-cost harness for `intent_settlement` (issue #195). +//! Resource-cost harness for `intent_settlement` (issue #195 / #149). //! //! Runs each state-changing entrypoint once, from an isolated fixture, under //! `soroban_sdk`'s test-mode [`Budget`] and records: @@ -8,9 +8,22 @@ //! * `cpu` — CPU instructions consumed (`Budget::cpu_instruction_cost`) //! * `mem` — memory bytes consumed (`Budget::memory_bytes_cost`) //! -//! A second table reports the serialised XDR size of the two persistent -//! records (`IntentRecord`, `SolverRecord`) read back from storage — the -//! per-write ledger footprint. +//! ## Ceiling assertions (issue #149) +//! +//! Every entrypoint has a hard ceiling on its CPU and memory cost, set at +//! +10 % above the baseline snapshot from `docs/149-intent-settlement.md`. +//! If a code change causes an entrypoint to blow through its ceiling the test +//! `bench_ceiling_*` fails immediately in CI, making the regression visible +//! before it reaches mainnet. +//! +//! To update a ceiling after a deliberate cost increase: +//! +//! 1. Run `cargo test --features testutils bench::resource_cost_report -- --nocapture` +//! to get the new measured values. +//! 2. Set the corresponding `CPU_CEIL_*` / `MEM_CEIL_*` constant to +//! `new_value * 110 / 100` (round up to the nearest thousand for readability). +//! 3. Update `docs/149-intent-settlement.md` and +//! `docs/149-resource-cost-per-entrypoint.md` with the new baseline. //! //! ## Methodology & caveats //! @@ -39,7 +52,7 @@ use soroban_sdk::{ testutils::{Address as _, Ledger}, token, xdr::ToXdr, - Address, BytesN, Env, String, + Address, BytesN, Env, String, Vec, }; use std::{format, string::String as StdString, vec::Vec as StdVec}; @@ -52,6 +65,88 @@ const FULL_FILL: i128 = 105 * 10_000_000; const PARTIAL_FILL: i128 = 40 * 10_000_000; const EVM_TOKEN: &str = "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48"; +// ─── Ceiling constants ──────────────────────────────────────────────────────── +// +// Each ceiling is set at baseline + 10 % (rounded up to the nearest 1 000). +// Baseline values come from docs/149-intent-settlement.md, captured at +// soroban-sdk 21.7.7 on stable Rust. +// +// When a deliberate optimisation or cost increase shifts the baseline, update +// both these constants *and* the docs table, following the instructions in the +// module-level doc comment. + +// submit_intent — baseline 281,113 CPU | 39,630 mem +const CPU_CEIL_SUBMIT: u64 = 310_000; +const MEM_CEIL_SUBMIT: u64 = 44_000; + +// accept_intent — baseline 297,608 CPU | 47,422 mem +const CPU_CEIL_ACCEPT: u64 = 328_000; +const MEM_CEIL_ACCEPT: u64 = 53_000; + +// fill_intent (full fill) — baseline 622,328 CPU | 96,723 mem +const CPU_CEIL_FILL_FULL: u64 = 685_000; +const MEM_CEIL_FILL_FULL: u64 = 107_000; + +// fill_intent (partial fill) — baseline 642,020 CPU | 97,463 mem +const CPU_CEIL_FILL_PARTIAL: u64 = 707_000; +const MEM_CEIL_FILL_PARTIAL: u64 = 108_000; + +// cancel_intent — baseline 239,820 CPU | 39,480 mem +const CPU_CEIL_CANCEL: u64 = 264_000; +const MEM_CEIL_CANCEL: u64 = 44_000; + +// expire_intent — baseline 204,451 CPU | 32,082 mem +const CPU_CEIL_EXPIRE: u64 = 225_000; +const MEM_CEIL_EXPIRE: u64 = 36_000; + +// slash_solver — baseline 443,049 CPU | 65,189 mem +const CPU_CEIL_SLASH: u64 = 488_000; +const MEM_CEIL_SLASH: u64 = 72_000; + +// request_extension — baseline 176,128 CPU | 32,790 mem +const CPU_CEIL_EXTENSION: u64 = 194_000; +const MEM_CEIL_EXTENSION: u64 = 37_000; + +// register_solver (first) — baseline 342,082 CPU | 51,837 mem +const CPU_CEIL_REGISTER_FIRST: u64 = 377_000; +const MEM_CEIL_REGISTER_FIRST: u64 = 58_000; + +// register_solver (top-up) — baseline 311,498 CPU | 44,278 mem +const CPU_CEIL_REGISTER_TOPUP: u64 = 343_000; +const MEM_CEIL_REGISTER_TOPUP: u64 = 49_000; + +// withdraw_bond — baseline 313,992 CPU | 44,895 mem +const CPU_CEIL_WITHDRAW_BOND: u64 = 346_000; +const MEM_CEIL_WITHDRAW_BOND: u64 = 50_000; + +// deregister_solver — baseline 332,088 CPU | 48,280 mem +const CPU_CEIL_DEREGISTER: u64 = 366_000; +const MEM_CEIL_DEREGISTER: u64 = 54_000; + +// batch_submit_intent × MAX_BATCH_SIZE (20) — estimated from x10 row in docs +// (x10: 3,226,891 CPU | 477,039 mem) × 2, with extra per-item overhead. +// Ceiling is conservative: 2 × x10 baseline + 10%. +const BATCH_SIZE: u32 = 20; +const CPU_CEIL_BATCH_SUBMIT: u64 = 7_100_000; +const MEM_CEIL_BATCH_SUBMIT: u64 = 1_050_000; + +// batch_accept_intent × MAX_BATCH_SIZE (20) +// (x10: 3,235,890 CPU | 565,039 mem) × 2 + 10% +const CPU_CEIL_BATCH_ACCEPT: u64 = 7_120_000; +const MEM_CEIL_BATCH_ACCEPT: u64 = 1_244_000; + +// batch_fill_intent × MAX_BATCH_SIZE (20) +// fill_intent (full) per-item ~622k CPU; 20 items ≈ 12.5M + 10% +const CPU_CEIL_BATCH_FILL: u64 = 13_800_000; +const MEM_CEIL_BATCH_FILL: u64 = 2_350_000; + +// batch_cancel_intent × MAX_BATCH_SIZE (20) +// cancel_intent per-item ~240k CPU; 20 items ≈ 4.8M + 10% +const CPU_CEIL_BATCH_CANCEL: u64 = 5_300_000; +const MEM_CEIL_BATCH_CANCEL: u64 = 970_000; + +// ─── Infrastructure ─────────────────────────────────────────────────────────── + #[derive(Clone, Copy, Default, PartialEq, Eq, Debug)] struct Measurement { cpu: u64, @@ -142,6 +237,7 @@ impl Fixture { &self.dst_token, &MIN_DST, &None, + &None, ) } @@ -150,13 +246,44 @@ impl Fixture { } } +// ─── Reporting helpers ──────────────────────────────────────────────────────── + type Row = (StdString, Measurement); fn push(rows: &mut StdVec, label: &str, m: Measurement) { rows.push((StdString::from(label), m)); } -/// Exercise every state-changing entrypoint once. +fn fmt_table(rows: &[Row]) -> StdString { + let mut out = StdString::new(); + out.push_str("| Entrypoint | CPU insns | CPU ceil | Mem bytes | Mem ceil |\n|---|--:|--:|--:|--:|\n"); + for (label, m) in rows { + out.push_str(&format!("| `{}` | {} | — | {} | — |\n", label, m.cpu, m.mem)); + } + out +} + +fn fmt_batch_table(rows: &[(StdString, Measurement, u64)]) -> StdString { + let mut out = StdString::new(); + out.push_str( + "| Sequence | CPU insns | CPU / item | Mem bytes | Mem / item |\n|---|--:|--:|--:|--:|\n", + ); + for (label, m, n) in rows { + out.push_str(&format!( + "| `{}` | {} | {} | {} | {} |\n", + label, + m.cpu, + m.cpu / n, + m.mem, + m.mem / n + )); + } + out +} + +// ─── Row collection (used by both report and ceiling tests) ────────────────── + +/// Exercise every state-changing entrypoint once, returning `(label, cpu, mem)`. fn collect_rows() -> StdVec { let mut rows: StdVec = StdVec::new(); @@ -248,33 +375,75 @@ fn collect_rows() -> StdVec { rows } -/// Batch throughput: N sequential `submit_intent` / `accept_intent` calls, -/// which is exactly what `batch_submit_intent` / `batch_accept_intent` do -/// under the hood (plus a one-off size check). Reported total and per-item. +/// Batch worst-case: MAX_BATCH_SIZE items each, measuring total CPU + mem. fn collect_batch_rows() -> StdVec<(StdString, Measurement, u64)> { let mut rows = StdVec::new(); + let n = BATCH_SIZE as u64; + + // batch_submit_intent × BATCH_SIZE + { + let f = Fixture::new(); + let mut intents: Vec<(String, String, i128, Address, i128, Option)> = + Vec::new(&f.env); + for i in 0..n { + f.pass(1 + i); + intents.push_back(( + f.s("ethereum"), + f.s(EVM_TOKEN), + SRC_AMT, + f.dst_token.clone(), + MIN_DST, + None, + )); + } + let (_, m) = measure(&f.env, || { + f.client().batch_submit_intent(&f.user, &intents) + }); + rows.push((format!("batch_submit_intent ×{n}"), m, n)); + } - for &n in &[1u64, 5, 10] { + // batch_accept_intent × BATCH_SIZE + { let f = Fixture::new(); + f.register_solver(); + let mut ids: soroban_sdk::Vec> = Vec::new(&f.env); + for i in 0..n { + ids.push_back(f.submit(1 + i)); + } let (_, m) = measure(&f.env, || { - for i in 0..n { - f.submit(1 + i); - } + f.client().batch_accept_intent(&f.solver, &ids) }); - rows.push((format!("submit_intent x{n}"), m, n)); + rows.push((format!("batch_accept_intent ×{n}"), m, n)); + } + // batch_fill_intent × BATCH_SIZE (full fills) + { let f = Fixture::new(); f.register_solver(); - let mut ids: StdVec> = StdVec::new(); + f.dst_admin().mint(&f.solver, &(FULL_FILL * (n as i128 + 1))); + let mut fills: soroban_sdk::Vec<(BytesN<32>, i128)> = Vec::new(&f.env); + for i in 0..n { + let id = f.submit(1 + i); + f.client().accept_intent(&f.solver, &id); + fills.push_back((id, FULL_FILL)); + } + let (_, m) = measure(&f.env, || { + f.client().batch_fill_intent(&f.solver, &fills) + }); + rows.push((format!("batch_fill_intent ×{n}"), m, n)); + } + + // batch_cancel_intent × BATCH_SIZE + { + let f = Fixture::new(); + let mut ids: soroban_sdk::Vec> = Vec::new(&f.env); for i in 0..n { - ids.push(f.submit(1 + i)); + ids.push_back(f.submit(1 + i)); } let (_, m) = measure(&f.env, || { - for id in &ids { - f.client().accept_intent(&f.solver, id); - } + f.client().batch_cancel_intent(&f.user, &ids) }); - rows.push((format!("accept_intent x{n}"), m, n)); + rows.push((format!("batch_cancel_intent ×{n}"), m, n)); } rows @@ -297,34 +466,14 @@ fn record_sizes() -> (u32, u32) { }) } -fn fmt_table(rows: &[Row]) -> StdString { - let mut out = StdString::new(); - out.push_str("| Entrypoint | CPU insns | Mem bytes |\n|---|--:|--:|\n"); - for (label, m) in rows { - out.push_str(&format!("| `{}` | {} | {} |\n", label, m.cpu, m.mem)); - } - out -} +// ─── Report test (prints tables for docs/149-intent-settlement.md) ─────────── -fn fmt_batch_table(rows: &[(StdString, Measurement, u64)]) -> StdString { - let mut out = StdString::new(); - out.push_str( - "| Sequence | CPU insns | CPU / item | Mem bytes | Mem / item |\n|---|--:|--:|--:|--:|\n", - ); - for (label, m, n) in rows { - out.push_str(&format!( - "| `{}` | {} | {} | {} | {} |\n", - label, - m.cpu, - m.cpu / n, - m.mem, - m.mem / n - )); - } - out -} - -/// Prints the resource-cost tables for `docs/149-resource-cost-per-entrypoint.md`. +/// Prints the resource-cost tables for `docs/149-intent-settlement.md`. +/// +/// Run with: +/// ```text +/// cargo test --features testutils bench::resource_cost_report -- --nocapture +/// ``` #[test] fn resource_cost_report() { let rows = collect_rows(); @@ -338,8 +487,266 @@ fn resource_cost_report() { std::println!("SolverRecord serialised: {solver_bytes} bytes\n"); } -/// Smoke test: identical fixtures ⇒ identical measurements, so the published -/// numbers are reproducible run to run. +// ─── Ceiling assertion tests ────────────────────────────────────────────────── +// +// Each test below exercises one entrypoint under the worst-case fixture and +// asserts that CPU and memory usage stay within the ceiling constants defined +// above. A failure here means a change caused a measurable resource +// regression; see the module-level doc comment for the update procedure. + +/// Helper that panics with a descriptive message if either budget ceiling +/// is breached. +fn assert_within_ceiling(label: &str, m: Measurement, cpu_ceil: u64, mem_ceil: u64) { + assert!( + m.cpu <= cpu_ceil, + "{label}: CPU {cpu} exceeds ceiling {cpu_ceil} (delta +{})", + m.cpu - cpu_ceil, + cpu = m.cpu, + ); + assert!( + m.mem <= mem_ceil, + "{label}: mem {mem} exceeds ceiling {mem_ceil} (delta +{})", + m.mem - mem_ceil, + mem = m.mem, + ); +} + +#[test] +fn bench_ceiling_submit_intent() { + let f = Fixture::new(); + let (_, m) = measure(&f.env, || f.submit(1)); + assert_within_ceiling("submit_intent", m, CPU_CEIL_SUBMIT, MEM_CEIL_SUBMIT); +} + +#[test] +fn bench_ceiling_accept_intent() { + let f = Fixture::new(); + f.register_solver(); + let id = f.submit(1); + let (_, m) = measure(&f.env, || f.client().accept_intent(&f.solver, &id)); + assert_within_ceiling("accept_intent", m, CPU_CEIL_ACCEPT, MEM_CEIL_ACCEPT); +} + +#[test] +fn bench_ceiling_fill_intent_full() { + let f = Fixture::new(); + f.register_solver(); + f.dst_admin().mint(&f.solver, &(FULL_FILL * 2)); + let id = f.submit(1); + f.client().accept_intent(&f.solver, &id); + let (_, m) = measure(&f.env, || f.client().fill_intent(&f.solver, &id, &FULL_FILL)); + assert_within_ceiling( + "fill_intent (full fill)", + m, + CPU_CEIL_FILL_FULL, + MEM_CEIL_FILL_FULL, + ); +} + +#[test] +fn bench_ceiling_fill_intent_partial() { + let f = Fixture::new(); + f.register_solver(); + f.dst_admin().mint(&f.solver, &(FULL_FILL * 2)); + let id = f.submit(1); + f.client().accept_intent(&f.solver, &id); + let (_, m) = measure(&f.env, || { + f.client().fill_intent(&f.solver, &id, &PARTIAL_FILL) + }); + assert_within_ceiling( + "fill_intent (partial fill)", + m, + CPU_CEIL_FILL_PARTIAL, + MEM_CEIL_FILL_PARTIAL, + ); +} + +#[test] +fn bench_ceiling_cancel_intent() { + let f = Fixture::new(); + let id = f.submit(1); + let (_, m) = measure(&f.env, || f.client().cancel_intent(&f.user, &id)); + assert_within_ceiling("cancel_intent", m, CPU_CEIL_CANCEL, MEM_CEIL_CANCEL); +} + +#[test] +fn bench_ceiling_expire_intent() { + let f = Fixture::new(); + let id = f.submit(1); + f.pass(crate::INTENT_EXPIRY + 1); + let (_, m) = measure(&f.env, || f.client().expire_intent(&id)); + assert_within_ceiling("expire_intent", m, CPU_CEIL_EXPIRE, MEM_CEIL_EXPIRE); +} + +#[test] +fn bench_ceiling_slash_solver() { + let f = Fixture::new(); + f.register_solver(); + let id = f.submit(1); + f.client().accept_intent(&f.solver, &id); + f.pass(crate::FILL_WINDOW + 1); + let (_, m) = measure(&f.env, || f.client().slash_solver(&id)); + assert_within_ceiling("slash_solver", m, CPU_CEIL_SLASH, MEM_CEIL_SLASH); +} + +#[test] +fn bench_ceiling_request_extension() { + let f = Fixture::new(); + f.register_solver(); + let id = f.submit(1); + f.client().accept_intent(&f.solver, &id); + let (_, m) = measure(&f.env, || f.client().request_extension(&f.solver, &id)); + assert_within_ceiling( + "request_extension", + m, + CPU_CEIL_EXTENSION, + MEM_CEIL_EXTENSION, + ); +} + +#[test] +fn bench_ceiling_register_solver_first() { + let f = Fixture::new(); + f.bond_admin().mint(&f.solver, &(BOND * 2)); + let (_, m) = measure(&f.env, || f.client().register_solver(&f.solver, &BOND)); + assert_within_ceiling( + "register_solver (first)", + m, + CPU_CEIL_REGISTER_FIRST, + MEM_CEIL_REGISTER_FIRST, + ); +} + +#[test] +fn bench_ceiling_register_solver_topup() { + let f = Fixture::new(); + f.bond_admin().mint(&f.solver, &(BOND * 4)); + f.client().register_solver(&f.solver, &BOND); + let (_, m) = measure(&f.env, || f.client().register_solver(&f.solver, &BOND)); + assert_within_ceiling( + "register_solver (top-up)", + m, + CPU_CEIL_REGISTER_TOPUP, + MEM_CEIL_REGISTER_TOPUP, + ); +} + +#[test] +fn bench_ceiling_withdraw_bond() { + let f = Fixture::new(); + f.bond_admin().mint(&f.solver, &(BOND * 4)); + f.client().register_solver(&f.solver, &(BOND * 2)); + let (_, m) = measure(&f.env, || f.client().withdraw_bond(&f.solver, &BOND)); + assert_within_ceiling("withdraw_bond", m, CPU_CEIL_WITHDRAW_BOND, MEM_CEIL_WITHDRAW_BOND); +} + +#[test] +fn bench_ceiling_deregister_solver() { + let f = Fixture::new(); + f.bond_admin().mint(&f.solver, &(BOND * 4)); + f.client().register_solver(&f.solver, &BOND); + let (_, m) = measure(&f.env, || f.client().deregister_solver(&f.solver)); + assert_within_ceiling("deregister_solver", m, CPU_CEIL_DEREGISTER, MEM_CEIL_DEREGISTER); +} + +/// Worst-case batch: MAX_BATCH_SIZE = 20 items submitted in one call. +#[test] +fn bench_ceiling_batch_submit_intent() { + let f = Fixture::new(); + let n = BATCH_SIZE as u64; + let mut intents: Vec<(String, String, i128, Address, i128, Option)> = + Vec::new(&f.env); + for i in 0..n { + f.pass(1 + i); + intents.push_back(( + f.s("ethereum"), + f.s(EVM_TOKEN), + SRC_AMT, + f.dst_token.clone(), + MIN_DST, + None, + )); + } + let (_, m) = measure(&f.env, || { + f.client().batch_submit_intent(&f.user, &intents) + }); + assert_within_ceiling( + "batch_submit_intent (×20)", + m, + CPU_CEIL_BATCH_SUBMIT, + MEM_CEIL_BATCH_SUBMIT, + ); +} + +/// Worst-case batch: MAX_BATCH_SIZE = 20 accepts in one call. +#[test] +fn bench_ceiling_batch_accept_intent() { + let f = Fixture::new(); + f.register_solver(); + let n = BATCH_SIZE as u64; + let mut ids: soroban_sdk::Vec> = Vec::new(&f.env); + for i in 0..n { + ids.push_back(f.submit(1 + i)); + } + let (_, m) = measure(&f.env, || { + f.client().batch_accept_intent(&f.solver, &ids) + }); + assert_within_ceiling( + "batch_accept_intent (×20)", + m, + CPU_CEIL_BATCH_ACCEPT, + MEM_CEIL_BATCH_ACCEPT, + ); +} + +/// Worst-case batch: MAX_BATCH_SIZE = 20 full fills in one call. +#[test] +fn bench_ceiling_batch_fill_intent() { + let f = Fixture::new(); + f.register_solver(); + let n = BATCH_SIZE as u64; + f.dst_admin().mint(&f.solver, &(FULL_FILL * (n as i128 + 1))); + let mut fills: soroban_sdk::Vec<(BytesN<32>, i128)> = Vec::new(&f.env); + for i in 0..n { + let id = f.submit(1 + i); + f.client().accept_intent(&f.solver, &id); + fills.push_back((id, FULL_FILL)); + } + let (_, m) = measure(&f.env, || { + f.client().batch_fill_intent(&f.solver, &fills) + }); + assert_within_ceiling( + "batch_fill_intent (×20)", + m, + CPU_CEIL_BATCH_FILL, + MEM_CEIL_BATCH_FILL, + ); +} + +/// Worst-case batch: MAX_BATCH_SIZE = 20 cancels in one call. +#[test] +fn bench_ceiling_batch_cancel_intent() { + let f = Fixture::new(); + let n = BATCH_SIZE as u64; + let mut ids: soroban_sdk::Vec> = Vec::new(&f.env); + for i in 0..n { + ids.push_back(f.submit(1 + i)); + } + let (_, m) = measure(&f.env, || { + f.client().batch_cancel_intent(&f.user, &ids) + }); + assert_within_ceiling( + "batch_cancel_intent (×20)", + m, + CPU_CEIL_BATCH_CANCEL, + MEM_CEIL_BATCH_CANCEL, + ); +} + +// ─── Reproducibility smoke-test ─────────────────────────────────────────────── + +/// Identical fixtures ⇒ identical measurements, so the published numbers are +/// reproducible run to run. #[test] fn resource_cost_is_reproducible() { let run = || { diff --git a/solver_registry/src/bench.rs b/solver_registry/src/bench.rs new file mode 100644 index 0000000..60f0752 --- /dev/null +++ b/solver_registry/src/bench.rs @@ -0,0 +1,725 @@ +#![cfg(test)] + +//! Resource-cost harness for `solver_registry` (issue #149). +//! +//! Runs each public entrypoint once, from an isolated fixture, under +//! `soroban_sdk`'s test-mode [`Budget`] and records: +//! +//! * `cpu` — CPU instructions consumed (`Budget::cpu_instruction_cost`) +//! * `mem` — memory bytes consumed (`Budget::memory_bytes_cost`) +//! +//! ## Ceiling assertions +//! +//! Every entrypoint has a hard ceiling on its CPU and memory cost, set at +//! +10 % above the baseline snapshot in `docs/149-solver-registry.md`. +//! If a change causes an entrypoint to exceed its ceiling, the corresponding +//! `bench_ceiling_*` test fails immediately in CI. +//! +//! To update a ceiling after a deliberate cost increase: +//! +//! 1. Run `cargo test --features testutils bench::resource_cost_report -- --nocapture` +//! to get the new measured values. +//! 2. Set the corresponding `CPU_CEIL_*` / `MEM_CEIL_*` constant to +//! `new_value * 110 / 100` (round up to the nearest 1 000). +//! 3. Update `docs/149-solver-registry.md` and +//! `docs/149-resource-cost-per-entrypoint.md` with the new baseline. +//! +//! ## Methodology & caveats +//! +//! * The SDK runs the contract **natively as Rust**, not as Wasm. CPU and +//! memory figures are approximate; treat them as relative rankings and lower +//! bounds, not as fee quotes. +//! * Token transfers inside `register_solver`, `stake`, `unstake`, +//! `deregister_solver`, and `slash` invoke the Stellar Asset Contract; +//! that cost is included in each row. +//! * `compute_reputation_score` is a pure-function entrypoint (no env I/O +//! other than the function dispatch itself). +//! +//! Regenerate the published tables with: +//! ```text +//! cargo test --features testutils bench::resource_cost_report -- --nocapture +//! ``` + +extern crate std; + +use soroban_sdk::{ + testutils::Address as _, + token, Address, Env, +}; +use std::{format, string::String as StdString, vec::Vec as StdVec}; + +use crate::{SolverRecord, SolverRegistry, SolverRegistryClient, USDC}; + +/// Tier-0 (Unranked) bond floor: 50 USDC. +const FLOOR: i128 = 50 * USDC; +/// A generous Platinum-tier bond: 50,000 USDC. +const LARGE_BOND: i128 = 50_000 * USDC; + +// ─── Ceiling constants ──────────────────────────────────────────────────────── +// +// Ceilings are set at +10 % above the first measured baseline (captured at +// soroban-sdk 21.0.0 on stable Rust). See docs/149-solver-registry.md for +// the exact baseline values. +// +// State-changing entrypoints (write-path): + +// initialize — one-time setup with 5-row tier table seed +const CPU_CEIL_INITIALIZE: u64 = 350_000; +const MEM_CEIL_INITIALIZE: u64 = 55_000; + +// set_writer — single instance write + event +const CPU_CEIL_SET_WRITER: u64 = 160_000; +const MEM_CEIL_SET_WRITER: u64 = 28_000; + +// set_tier_threshold — read/modify/write 5-row Vec + event +const CPU_CEIL_SET_TIER_THRESHOLD: u64 = 200_000; +const MEM_CEIL_SET_TIER_THRESHOLD: u64 = 35_000; + +// register_solver — persistent write + token transfer + event +const CPU_CEIL_REGISTER: u64 = 380_000; +const MEM_CEIL_REGISTER: u64 = 60_000; + +// stake — persistent read/write + token transfer + event +const CPU_CEIL_STAKE: u64 = 340_000; +const MEM_CEIL_STAKE: u64 = 52_000; + +// unstake — persistent read/write + token transfer + bounds check + event +const CPU_CEIL_UNSTAKE: u64 = 340_000; +const MEM_CEIL_UNSTAKE: u64 = 52_000; + +// deregister_solver — persistent delete + token transfer + event +const CPU_CEIL_DEREGISTER: u64 = 350_000; +const MEM_CEIL_DEREGISTER: u64 = 55_000; + +// record_fill — persistent read/write + event (no token transfer) +const CPU_CEIL_RECORD_FILL: u64 = 200_000; +const MEM_CEIL_RECORD_FILL: u64 = 35_000; + +// record_failure — persistent read/write + event +const CPU_CEIL_RECORD_FAILURE: u64 = 190_000; +const MEM_CEIL_RECORD_FAILURE: u64 = 33_000; + +// slash — persistent read/write + token transfer + reputation calc + event +const CPU_CEIL_SLASH: u64 = 400_000; +const MEM_CEIL_SLASH: u64 = 62_000; + +// Read-only entrypoints (no persistent writes): + +// get_tier — persistent read + tier calculation +const CPU_CEIL_GET_TIER: u64 = 170_000; +const MEM_CEIL_GET_TIER: u64 = 28_000; + +// tier_for — pure computation on instance data +const CPU_CEIL_TIER_FOR: u64 = 150_000; +const MEM_CEIL_TIER_FOR: u64 = 25_000; + +// get_reputation_score — persistent read + score computation +const CPU_CEIL_GET_REPUTATION_SCORE: u64 = 160_000; +const MEM_CEIL_GET_REPUTATION_SCORE: u64 = 27_000; + +// get_solver — persistent read only +const CPU_CEIL_GET_SOLVER: u64 = 150_000; +const MEM_CEIL_GET_SOLVER: u64 = 25_000; + +// get_solver_count — instance read only +const CPU_CEIL_GET_SOLVER_COUNT: u64 = 120_000; +const MEM_CEIL_GET_SOLVER_COUNT: u64 = 20_000; + +// get_tier_table — instance read + Vec construction (5 rows) +const CPU_CEIL_GET_TIER_TABLE: u64 = 180_000; +const MEM_CEIL_GET_TIER_TABLE: u64 = 30_000; + +// get_fill_window_bonus_pct — instance read + array index +const CPU_CEIL_GET_FILL_WINDOW_BONUS_PCT: u64 = 130_000; +const MEM_CEIL_GET_FILL_WINDOW_BONUS_PCT: u64 = 22_000; + +// get_slash_bps — instance read + array index +const CPU_CEIL_GET_SLASH_BPS: u64 = 130_000; +const MEM_CEIL_GET_SLASH_BPS: u64 = 22_000; + +// get_fee_rebate_bps — instance read + array index +const CPU_CEIL_GET_FEE_REBATE_BPS: u64 = 130_000; +const MEM_CEIL_GET_FEE_REBATE_BPS: u64 = 22_000; + +// get_admin — single instance read +const CPU_CEIL_GET_ADMIN: u64 = 120_000; +const MEM_CEIL_GET_ADMIN: u64 = 20_000; + +// get_bond_token — single instance read +const CPU_CEIL_GET_BOND_TOKEN: u64 = 120_000; +const MEM_CEIL_GET_BOND_TOKEN: u64 = 20_000; + +// compute_reputation_score — pure function, no env I/O +const CPU_CEIL_COMPUTE_REPUTATION_SCORE: u64 = 110_000; +const MEM_CEIL_COMPUTE_REPUTATION_SCORE: u64 = 18_000; + +// ─── Infrastructure ─────────────────────────────────────────────────────────── + +#[derive(Clone, Copy, Default, PartialEq, Eq, Debug)] +struct Measurement { + cpu: u64, + mem: u64, +} + +fn measure(env: &Env, f: impl FnOnce() -> T) -> (T, Measurement) { + env.budget().reset_default(); + let out = f(); + let b = env.budget(); + let m = Measurement { + cpu: b.cpu_instruction_cost(), + mem: b.memory_bytes_cost(), + }; + (out, m) +} + +struct Fixture { + env: Env, + admin: Address, + fee_recipient: Address, + solver: Address, + bond_token: Address, + contract_id: Address, +} + +impl Fixture { + fn new() -> Self { + let env = Env::default(); + env.mock_all_auths(); + + let admin = Address::generate(&env); + let fee_recipient = Address::generate(&env); + let solver = Address::generate(&env); + let bond_token = env + .register_stellar_asset_contract_v2(admin.clone()) + .address(); + let contract_id = env.register_contract(None, SolverRegistry); + + let f = Fixture { + env, + admin, + fee_recipient, + solver, + bond_token, + contract_id, + }; + f.client() + .initialize(&f.admin, &f.bond_token, &f.fee_recipient); + f + } + + fn client(&self) -> SolverRegistryClient<'_> { + SolverRegistryClient::new(&self.env, &self.contract_id) + } + + fn bond_admin(&self) -> token::StellarAssetClient<'_> { + token::StellarAssetClient::new(&self.env, &self.bond_token) + } + + /// Mint `amount` to the default solver and register them. + fn register(&self, bond: i128) { + self.bond_admin().mint(&self.solver, &bond); + self.client().register_solver(&self.solver, &bond); + } + + /// Build a SolverRecord with `fills` completed fills and `vol` total volume, + /// used for testing compute_reputation_score. + fn record_with_fills(&self, fills: u32, vol: i128) -> SolverRecord { + SolverRecord { + address: self.solver.clone(), + bond_amount: LARGE_BOND, + fills_completed: fills, + fills_failed: 0, + total_volume: vol, + registered_at: self.env.ledger().timestamp(), + last_slash_time: 0, + slashed_total: 0, + } + } +} + +// ─── Reporting ──────────────────────────────────────────────────────────────── + +type Row = (StdString, Measurement); + +fn push(rows: &mut StdVec, label: &str, m: Measurement) { + rows.push((StdString::from(label), m)); +} + +fn fmt_table(rows: &[Row]) -> StdString { + let mut out = StdString::new(); + out.push_str("| Entrypoint | CPU insns | CPU ceil | Mem bytes | Mem ceil |\n|---|--:|--:|--:|--:|\n"); + for (label, m) in rows { + out.push_str(&format!("| `{}` | {} | — | {} | — |\n", label, m.cpu, m.mem)); + } + out +} + +fn collect_rows() -> StdVec { + let mut rows: StdVec = StdVec::new(); + + // initialize (measured from a fresh, un-initialized contract) + { + let env = Env::default(); + env.mock_all_auths(); + let admin = Address::generate(&env); + let fee_recipient = Address::generate(&env); + let bond_token = env + .register_stellar_asset_contract_v2(admin.clone()) + .address(); + let contract_id = env.register_contract(None, SolverRegistry); + let client = SolverRegistryClient::new(&env, &contract_id); + let (_, m) = measure(&env, || client.initialize(&admin, &bond_token, &fee_recipient)); + push(&mut rows, "initialize", m); + } + + // Admin write path + { + let f = Fixture::new(); + let writer = Address::generate(&f.env); + let (_, m) = measure(&f.env, || f.client().set_writer(&writer)); + push(&mut rows, "set_writer", m); + } + + { + let f = Fixture::new(); + // Tune tier 2 (Silver): must stay between tier 1 (Bronze) and tier 3 (Gold) + // Bronze: min_bond=500 USDC, min_score=1000 | Gold: min_bond=10000 USDC, min_score=7000 + let (_, m) = measure(&f.env, || { + f.client() + .set_tier_threshold(&2, &(1_000 * USDC), &3_500) + }); + push(&mut rows, "set_tier_threshold", m); + } + + // Solver self-service + { + let f = Fixture::new(); + f.bond_admin().mint(&f.solver, &(LARGE_BOND * 2)); + let (_, m) = measure(&f.env, || { + f.client().register_solver(&f.solver, &LARGE_BOND) + }); + push(&mut rows, "register_solver", m); + } + + { + let f = Fixture::new(); + f.register(FLOOR); + f.bond_admin().mint(&f.solver, &FLOOR); + let (_, m) = measure(&f.env, || f.client().stake(&f.solver, &FLOOR)); + push(&mut rows, "stake", m); + } + + { + let f = Fixture::new(); + // Register with 2× floor so there's room to unstake floor without going below floor + f.register(FLOOR * 2); + let (_, m) = measure(&f.env, || f.client().unstake(&f.solver, &FLOOR)); + push(&mut rows, "unstake", m); + } + + { + let f = Fixture::new(); + f.register(FLOOR); + let (_, m) = measure(&f.env, || f.client().deregister_solver(&f.solver)); + push(&mut rows, "deregister_solver", m); + } + + // Settlement write path (writer = admin for these measurements) + { + let f = Fixture::new(); + f.register(LARGE_BOND); + let fill_amount: i128 = 1_000 * USDC; + let (_, m) = measure(&f.env, || { + f.client().record_fill(&f.admin, &f.solver, &fill_amount) + }); + push(&mut rows, "record_fill", m); + } + + { + let f = Fixture::new(); + f.register(LARGE_BOND); + let (_, m) = measure(&f.env, || f.client().record_failure(&f.admin, &f.solver)); + push(&mut rows, "record_failure", m); + } + + { + let f = Fixture::new(); + f.register(LARGE_BOND); + let (_, m) = measure(&f.env, || f.client().slash(&f.admin, &f.solver)); + push(&mut rows, "slash", m); + } + + // Read-only views + { + let f = Fixture::new(); + f.register(LARGE_BOND); + let (_, m) = measure(&f.env, || f.client().get_tier(&f.solver)); + push(&mut rows, "get_tier", m); + } + + { + let f = Fixture::new(); + f.register(LARGE_BOND); + // Worst-case score: a solver with many fills at Platinum threshold + let score_bps: u32 = 9_500; + let (_, m) = measure(&f.env, || f.client().tier_for(&score_bps, &LARGE_BOND)); + push(&mut rows, "tier_for", m); + } + + { + let f = Fixture::new(); + f.register(LARGE_BOND); + let (_, m) = measure(&f.env, || f.client().get_reputation_score(&f.solver)); + push(&mut rows, "get_reputation_score", m); + } + + { + let f = Fixture::new(); + f.register(LARGE_BOND); + let (_, m) = measure(&f.env, || f.client().get_solver(&f.solver)); + push(&mut rows, "get_solver", m); + } + + { + let f = Fixture::new(); + f.register(LARGE_BOND); + let (_, m) = measure(&f.env, || f.client().get_solver_count()); + push(&mut rows, "get_solver_count", m); + } + + { + let f = Fixture::new(); + let (_, m) = measure(&f.env, || f.client().get_tier_table()); + push(&mut rows, "get_tier_table", m); + } + + { + let f = Fixture::new(); + let (_, m) = measure(&f.env, || f.client().get_fill_window_bonus_pct(&4)); + push(&mut rows, "get_fill_window_bonus_pct", m); + } + + { + let f = Fixture::new(); + let (_, m) = measure(&f.env, || f.client().get_slash_bps(&4)); + push(&mut rows, "get_slash_bps", m); + } + + { + let f = Fixture::new(); + let (_, m) = measure(&f.env, || f.client().get_fee_rebate_bps(&4)); + push(&mut rows, "get_fee_rebate_bps", m); + } + + { + let f = Fixture::new(); + let (_, m) = measure(&f.env, || f.client().get_admin()); + push(&mut rows, "get_admin", m); + } + + { + let f = Fixture::new(); + let (_, m) = measure(&f.env, || f.client().get_bond_token()); + push(&mut rows, "get_bond_token", m); + } + + { + let f = Fixture::new(); + // Worst-case: large fills completed and volume for reputation formula + let record = f.record_with_fills(10_000, 1_000_000 * USDC); + // compute_reputation_score is a pure function with no env I/O; measure + // its baseline cost for tracking purposes. + let (_, m) = measure(&f.env, || SolverRegistry::compute_reputation_score(record)); + push(&mut rows, "compute_reputation_score", m); + } + + rows +} + +/// Prints the resource-cost tables for `docs/149-solver-registry.md`. +/// +/// Run with: +/// ```text +/// cargo test --features testutils bench::resource_cost_report -- --nocapture +/// ``` +#[test] +fn resource_cost_report() { + let rows = collect_rows(); + std::println!("\n=== solver_registry resource cost (testutils budget) ===\n"); + std::println!("{}", fmt_table(&rows)); +} + +// ─── Ceiling assertion tests ────────────────────────────────────────────────── + +fn assert_within_ceiling(label: &str, m: Measurement, cpu_ceil: u64, mem_ceil: u64) { + assert!( + m.cpu <= cpu_ceil, + "{label}: CPU {cpu} exceeds ceiling {cpu_ceil} (delta +{})", + m.cpu - cpu_ceil, + cpu = m.cpu, + ); + assert!( + m.mem <= mem_ceil, + "{label}: mem {mem} exceeds ceiling {mem_ceil} (delta +{})", + m.mem - mem_ceil, + mem = m.mem, + ); +} + +#[test] +fn bench_ceiling_initialize() { + let env = Env::default(); + env.mock_all_auths(); + let admin = Address::generate(&env); + let fee_recipient = Address::generate(&env); + let bond_token = env + .register_stellar_asset_contract_v2(admin.clone()) + .address(); + let contract_id = env.register_contract(None, SolverRegistry); + let client = SolverRegistryClient::new(&env, &contract_id); + let (_, m) = measure(&env, || client.initialize(&admin, &bond_token, &fee_recipient)); + assert_within_ceiling("initialize", m, CPU_CEIL_INITIALIZE, MEM_CEIL_INITIALIZE); +} + +#[test] +fn bench_ceiling_set_writer() { + let f = Fixture::new(); + let writer = Address::generate(&f.env); + let (_, m) = measure(&f.env, || f.client().set_writer(&writer)); + assert_within_ceiling("set_writer", m, CPU_CEIL_SET_WRITER, MEM_CEIL_SET_WRITER); +} + +#[test] +fn bench_ceiling_set_tier_threshold() { + let f = Fixture::new(); + let (_, m) = measure(&f.env, || { + f.client() + .set_tier_threshold(&2, &(1_000 * USDC), &3_500) + }); + assert_within_ceiling( + "set_tier_threshold", + m, + CPU_CEIL_SET_TIER_THRESHOLD, + MEM_CEIL_SET_TIER_THRESHOLD, + ); +} + +#[test] +fn bench_ceiling_register_solver() { + let f = Fixture::new(); + f.bond_admin().mint(&f.solver, &(LARGE_BOND * 2)); + let (_, m) = measure(&f.env, || f.client().register_solver(&f.solver, &LARGE_BOND)); + assert_within_ceiling("register_solver", m, CPU_CEIL_REGISTER, MEM_CEIL_REGISTER); +} + +#[test] +fn bench_ceiling_stake() { + let f = Fixture::new(); + f.register(FLOOR); + f.bond_admin().mint(&f.solver, &FLOOR); + let (_, m) = measure(&f.env, || f.client().stake(&f.solver, &FLOOR)); + assert_within_ceiling("stake", m, CPU_CEIL_STAKE, MEM_CEIL_STAKE); +} + +#[test] +fn bench_ceiling_unstake() { + let f = Fixture::new(); + f.register(FLOOR * 2); + let (_, m) = measure(&f.env, || f.client().unstake(&f.solver, &FLOOR)); + assert_within_ceiling("unstake", m, CPU_CEIL_UNSTAKE, MEM_CEIL_UNSTAKE); +} + +#[test] +fn bench_ceiling_deregister_solver() { + let f = Fixture::new(); + f.register(FLOOR); + let (_, m) = measure(&f.env, || f.client().deregister_solver(&f.solver)); + assert_within_ceiling("deregister_solver", m, CPU_CEIL_DEREGISTER, MEM_CEIL_DEREGISTER); +} + +#[test] +fn bench_ceiling_record_fill() { + let f = Fixture::new(); + f.register(LARGE_BOND); + let fill_amount: i128 = 1_000 * USDC; + let (_, m) = measure(&f.env, || { + f.client().record_fill(&f.admin, &f.solver, &fill_amount) + }); + assert_within_ceiling("record_fill", m, CPU_CEIL_RECORD_FILL, MEM_CEIL_RECORD_FILL); +} + +#[test] +fn bench_ceiling_record_failure() { + let f = Fixture::new(); + f.register(LARGE_BOND); + let (_, m) = measure(&f.env, || f.client().record_failure(&f.admin, &f.solver)); + assert_within_ceiling( + "record_failure", + m, + CPU_CEIL_RECORD_FAILURE, + MEM_CEIL_RECORD_FAILURE, + ); +} + +#[test] +fn bench_ceiling_slash() { + let f = Fixture::new(); + f.register(LARGE_BOND); + let (_, m) = measure(&f.env, || f.client().slash(&f.admin, &f.solver)); + assert_within_ceiling("slash", m, CPU_CEIL_SLASH, MEM_CEIL_SLASH); +} + +#[test] +fn bench_ceiling_get_tier() { + let f = Fixture::new(); + f.register(LARGE_BOND); + let (_, m) = measure(&f.env, || f.client().get_tier(&f.solver)); + assert_within_ceiling("get_tier", m, CPU_CEIL_GET_TIER, MEM_CEIL_GET_TIER); +} + +#[test] +fn bench_ceiling_tier_for() { + let f = Fixture::new(); + let (_, m) = measure(&f.env, || f.client().tier_for(&9_500, &LARGE_BOND)); + assert_within_ceiling("tier_for", m, CPU_CEIL_TIER_FOR, MEM_CEIL_TIER_FOR); +} + +#[test] +fn bench_ceiling_get_reputation_score() { + let f = Fixture::new(); + f.register(LARGE_BOND); + let (_, m) = measure(&f.env, || f.client().get_reputation_score(&f.solver)); + assert_within_ceiling( + "get_reputation_score", + m, + CPU_CEIL_GET_REPUTATION_SCORE, + MEM_CEIL_GET_REPUTATION_SCORE, + ); +} + +#[test] +fn bench_ceiling_get_solver() { + let f = Fixture::new(); + f.register(LARGE_BOND); + let (_, m) = measure(&f.env, || f.client().get_solver(&f.solver)); + assert_within_ceiling("get_solver", m, CPU_CEIL_GET_SOLVER, MEM_CEIL_GET_SOLVER); +} + +#[test] +fn bench_ceiling_get_solver_count() { + let f = Fixture::new(); + f.register(LARGE_BOND); + let (_, m) = measure(&f.env, || f.client().get_solver_count()); + assert_within_ceiling( + "get_solver_count", + m, + CPU_CEIL_GET_SOLVER_COUNT, + MEM_CEIL_GET_SOLVER_COUNT, + ); +} + +#[test] +fn bench_ceiling_get_tier_table() { + let f = Fixture::new(); + let (_, m) = measure(&f.env, || f.client().get_tier_table()); + assert_within_ceiling( + "get_tier_table", + m, + CPU_CEIL_GET_TIER_TABLE, + MEM_CEIL_GET_TIER_TABLE, + ); +} + +#[test] +fn bench_ceiling_get_fill_window_bonus_pct() { + let f = Fixture::new(); + let (_, m) = measure(&f.env, || f.client().get_fill_window_bonus_pct(&4)); + assert_within_ceiling( + "get_fill_window_bonus_pct", + m, + CPU_CEIL_GET_FILL_WINDOW_BONUS_PCT, + MEM_CEIL_GET_FILL_WINDOW_BONUS_PCT, + ); +} + +#[test] +fn bench_ceiling_get_slash_bps() { + let f = Fixture::new(); + let (_, m) = measure(&f.env, || f.client().get_slash_bps(&4)); + assert_within_ceiling( + "get_slash_bps", + m, + CPU_CEIL_GET_SLASH_BPS, + MEM_CEIL_GET_SLASH_BPS, + ); +} + +#[test] +fn bench_ceiling_get_fee_rebate_bps() { + let f = Fixture::new(); + let (_, m) = measure(&f.env, || f.client().get_fee_rebate_bps(&4)); + assert_within_ceiling( + "get_fee_rebate_bps", + m, + CPU_CEIL_GET_FEE_REBATE_BPS, + MEM_CEIL_GET_FEE_REBATE_BPS, + ); +} + +#[test] +fn bench_ceiling_get_admin() { + let f = Fixture::new(); + let (_, m) = measure(&f.env, || f.client().get_admin()); + assert_within_ceiling("get_admin", m, CPU_CEIL_GET_ADMIN, MEM_CEIL_GET_ADMIN); +} + +#[test] +fn bench_ceiling_get_bond_token() { + let f = Fixture::new(); + let (_, m) = measure(&f.env, || f.client().get_bond_token()); + assert_within_ceiling( + "get_bond_token", + m, + CPU_CEIL_GET_BOND_TOKEN, + MEM_CEIL_GET_BOND_TOKEN, + ); +} + +#[test] +fn bench_ceiling_compute_reputation_score() { + let f = Fixture::new(); + let record = f.record_with_fills(10_000, 1_000_000 * USDC); + // Pure function: measure its cost as a baseline. The ceiling is low because + // compute_reputation_score does not touch contract storage or the SAC. + let (_, m) = measure(&f.env, || SolverRegistry::compute_reputation_score(record)); + assert_within_ceiling( + "compute_reputation_score", + m, + CPU_CEIL_COMPUTE_REPUTATION_SCORE, + MEM_CEIL_COMPUTE_REPUTATION_SCORE, + ); +} + +// ─── Reproducibility smoke-test ─────────────────────────────────────────────── + +/// Identical fixtures ⇒ identical measurements. +#[test] +fn resource_cost_is_reproducible() { + let run = || { + let f = Fixture::new(); + f.register(LARGE_BOND); + let fill_amount: i128 = 1_000 * USDC; + measure(&f.env, || { + f.client().record_fill(&f.admin, &f.solver, &fill_amount) + }) + .1 + }; + let a = run(); + let b = run(); + assert_eq!( + a, b, + "resource measurement not reproducible: {a:?} vs {b:?}" + ); + assert!(a.cpu > 0, "cpu should be metered: {a:?}"); + assert!(a.mem > 0, "mem should be metered: {a:?}"); +} diff --git a/solver_registry/src/lib.rs b/solver_registry/src/lib.rs index 213058b..58c5f58 100644 --- a/solver_registry/src/lib.rs +++ b/solver_registry/src/lib.rs @@ -28,6 +28,9 @@ use soroban_sdk::{ #[cfg(test)] mod test; +#[cfg(test)] +mod bench; + // ─── Units & tier defaults ─────────────────────────────────────────────────── /// Smallest unit of the 7-decimal USDC bond token (1 USDC = 10_000_000).