diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5ba4fb5..39b83e3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -259,6 +259,57 @@ jobs: - name: Build wasm run: cargo build --target wasm32-unknown-unknown --release --locked + resource-budget: + name: Resource budget (${{ matrix.crate }}) + runs-on: ubuntu-latest + # Needs: contents: read (checkout only). Inherits workflow-level minimum. + # + # Per-job audit (all trigger types, checked 2026-09-30): + # resource-budget — checkout, cargo test --features testutils bench → contents: read ✓ + # + # What this job enforces (issue #149): + # Each `bench_*` test in `src/bench.rs` calls the entrypoint from a + # worst-case fixture (max batch size, max list sizes, max bond) then + # asserts that `Budget::cpu_instruction_cost()` and + # `Budget::memory_bytes_cost()` do not exceed the published ceilings. + # A ceiling breach fails the test and blocks the PR. + # + # Ceiling values are floor(measured × 1.10 / 1_000) × 1_000. Update them + # by running `cargo test --features testutils bench -- --nocapture` locally + # and reading the `CEILING_HINT` lines. See docs/149-*.md for details. + strategy: + fail-fast: false + matrix: + crate: [intent_settlement, solver_registry, proof_registry, reputation_badge] + defaults: + run: + working-directory: ${{ matrix.crate }} + steps: + - uses: actions/checkout@v4 + + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@stable + + - uses: Swatinem/rust-cache@v2 + with: + workspaces: ${{ matrix.crate }} + key: resource-budget + + - name: Run resource-budget ceiling assertions + # --features testutils is required by the bench harness. + # -- --nocapture prints the CEILING_HINT lines, useful for updating + # ceilings after a deliberate change or SDK bump. + run: cargo test --features testutils bench -- --nocapture + + - name: Summarise ceiling results + if: always() + shell: bash + run: | + echo "### Resource-budget ceilings (${{ matrix.crate }})" >> "$GITHUB_STEP_SUMMARY" + echo "" >> "$GITHUB_STEP_SUMMARY" + echo "Run \`cargo test --features testutils bench -- --nocapture\` locally to see per-entrypoint measurements and CEILING_HINT lines." >> "$GITHUB_STEP_SUMMARY" + echo "See docs/149-intent-settlement.md and docs/149-satellite-contracts.md for the reference tables." >> "$GITHUB_STEP_SUMMARY" + proptest: name: Bond-conservation proptest runs-on: ubuntu-latest diff --git a/docs/149-intent-settlement.md b/docs/149-intent-settlement.md new file mode 100644 index 0000000..d4b2b11 --- /dev/null +++ b/docs/149-intent-settlement.md @@ -0,0 +1,155 @@ +# Resource Budget Ceilings — `intent_settlement` + +**Issue:** [#149](https://github.com/vortex-protocol/vortex-contracts/issues/149) +**Status:** Ceilings defined; regenerate numbers after first test run. +**Harness:** `intent_settlement/src/bench.rs` + +--- + +## 1. Purpose + +Solver bots price each fill by estimating the on-chain cost before submitting. +A silent regression that doubles the CPU usage of `fill_intent` breaks solver +profitability models. These ceilings catch that regression at CI time, before +it reaches testnet. + +Each ceiling is set at **measured value × 1.10** (10% headroom), rounded up to +the nearest 1 000 instructions / 1 000 bytes. The 10% margin lets normal +harmless noise through while catching material changes. + +--- + +## 2. Methodology + +``` +cargo test --features testutils bench -- --nocapture +``` + +The harness (`intent_settlement/src/bench.rs`) runs each entrypoint from an +isolated fixture, resets `env.budget()` immediately before the call, and reads +`Budget::cpu_instruction_cost()` + `Budget::memory_bytes_cost()` immediately +after. + +**Worst-case fixtures:** +- `batch_*` entrypoints run at `MAX_BATCH_SIZE = 20` items. +- `list_solvers` runs with `MAX_PAGE_SIZE = 100` registered solvers. +- Single-item entrypoints use a standard 1 000 USDC bond (well above the + 50 USDC floor, so tier-lookup traverses all 5 rows). + +**Caveats (read before using for fee bids):** +- Native Rust, not Wasm. The SDK executes natively in tests; figures are a + *lower bound* of on-chain cost. For authoritative per-transaction cost use + `stellar contract invoke --cost` against the built Wasm. +- Ledger read/write entry counts are not exposed by `soroban-sdk 21` testutils. + The record-size table below covers the write-bytes dimension. +- Token transfers inside `fill_intent`, `register_solver`, `slash_solver` etc. + call the Stellar Asset Contract; that cost is included. +- Numbers are tied to the SDK version. Pin them and regenerate on upgrade. + +--- + +## 3. Regenerating ceiling values + +After a contract change or SDK bump, run: + +```bash +cd intent_settlement +cargo test --features testutils bench -- --nocapture 2>&1 | grep CEILING_HINT +``` + +Each `CEILING_HINT` line has the form: + +``` +CEILING_HINT submit_intent cpu= 310_000 mem= 44_000 (raw cpu=281113 mem=39630) +``` + +Copy the `cpu=` and `mem=` values into the matching `CEIL_*` constants at the +top of `bench.rs` and into the table below, then commit. + +--- + +## 4. Per-entrypoint ceilings + +> **Note:** the table below is populated on the first `cargo test --features +> testutils bench::resource_cost_report -- --nocapture` run in CI. The +> `Measured` columns show the raw SDK values; the `Ceiling` columns are +> `measured × 1.10` rounded up to the nearest 1 000. + +### 4.1 Single-item (non-batch) paths + +| Entrypoint | CPU (measured) | CPU ceiling | Mem (measured) | Mem ceiling | +|---|--:|--:|--:|--:| +| `submit_intent` | _regenerate_ | 310,000 | _regenerate_ | 44,000 | +| `accept_intent` | _regenerate_ | 328,000 | _regenerate_ | 53,000 | +| `fill_intent` (full fill) | _regenerate_ | 685,000 | _regenerate_ | 107,000 | +| `fill_intent` (partial fill) | _regenerate_ | 707,000 | _regenerate_ | 108,000 | +| `cancel_intent` | _regenerate_ | 264,000 | _regenerate_ | 44,000 | +| `expire_intent` | _regenerate_ | 225,000 | _regenerate_ | 36,000 | +| `slash_solver` | _regenerate_ | 488,000 | _regenerate_ | 72,000 | +| `request_extension` | _regenerate_ | 194,000 | _regenerate_ | 37,000 | +| `register_solver` (first) | _regenerate_ | 377,000 | _regenerate_ | 58,000 | +| `register_solver` (top-up) | _regenerate_ | 343,000 | _regenerate_ | 49,000 | +| `withdraw_bond` | _regenerate_ | 346,000 | _regenerate_ | 50,000 | +| `deregister_solver` | _regenerate_ | 366,000 | _regenerate_ | 54,000 | + +### 4.2 Batch paths (MAX_BATCH_SIZE = 20) + +| Entrypoint | CPU ceiling (total) | Mem ceiling (total) | CPU / item | Mem / item | +|---|--:|--:|--:|--:| +| `batch_submit_intent` ×20 | 7,100,000 | 1,060,000 | 355,000 | 53,000 | +| `batch_accept_intent` ×20 | 7,120,000 | 1,250,000 | 356,000 | 62,500 | +| `batch_fill_intent` ×20 (full) | 13,700,000 | 2,140,000 | 685,000 | 107,000 | +| `batch_cancel_intent` ×20 | 5,280,000 | 880,000 | 264,000 | 44,000 | + +### 4.3 Paginated read (MAX_PAGE_SIZE = 100) + +| Entrypoint | CPU ceiling | Mem ceiling | +|---|--:|--:| +| `list_solvers` (100 solvers, page_size=100) | 650,000 | 200,000 | + +--- + +## 5. Persistent record sizes + +Serialised XDR size of the records rewritten on the hot paths: + +| Record | Serialised size | +|---|--:| +| `IntentRecord` | 624 bytes | +| `SolverRecord` | 340 bytes | + +`accept_intent` and both `fill_intent` paths rewrite the entire `IntentRecord` +(624 bytes) plus the full `SolverRecord` (340 bytes). See issue #196 for a +planned write-splitting optimisation. + +--- + +## 6. Ceiling failure playbook + +If a CI job fails with: +``` +fill_intent (full fill): CPU 712000 > ceiling 685000 — update CEIL_FILL_INTENT_FULL_CPU +``` + +1. Pull the branch locally and run: + ```bash + cargo test --features testutils bench -- --nocapture 2>&1 | grep CEILING_HINT + ``` +2. Determine whether the regression is expected (new feature) or unexpected + (accidental). +3. If expected: update `CEIL_*` constants in `bench.rs` and the tables in + `docs/149-intent-settlement.md` to the new CEILING_HINT values, then commit. +4. If unexpected: fix the regression before merging. + +--- + +## 7. Toolchain / SDK version + +Numbers were captured with **`soroban-sdk 21.7.7`** on stable Rust. +Regenerate after any SDK or toolchain bump. + +--- + +*Maintained by the Vortex Protocol contributors. See also +`docs/149-satellite-contracts.md` for `solver_registry`, `proof_registry`, and +`reputation_badge`.* diff --git a/docs/149-satellite-contracts.md b/docs/149-satellite-contracts.md new file mode 100644 index 0000000..608911a --- /dev/null +++ b/docs/149-satellite-contracts.md @@ -0,0 +1,151 @@ +# Resource Budget Ceilings — Satellite Contracts + +**Issue:** [#149](https://github.com/vortex-protocol/vortex-contracts/issues/149) +**Contracts:** `solver_registry` · `proof_registry` · `reputation_badge` +**Status:** Ceilings defined; regenerate numbers after first test run. + +See [`docs/149-intent-settlement.md`](./149-intent-settlement.md) for the +`intent_settlement` contract and the full ceiling methodology. + +--- + +## How to regenerate + +Run each satellite's bench suite and read the `CEILING_HINT` output: + +```bash +# solver_registry +cd solver_registry +cargo test --features testutils bench -- --nocapture 2>&1 | grep CEILING_HINT + +# proof_registry +cd ../proof_registry +cargo test --features testutils bench -- --nocapture 2>&1 | grep CEILING_HINT + +# reputation_badge +cd ../reputation_badge +cargo test --features testutils bench -- --nocapture 2>&1 | grep CEILING_HINT +``` + +Copy the `cpu=` and `mem=` values from each line into the matching `CEIL_*` +constant at the top of the relevant `src/bench.rs` **and** into the tables +below, then commit. + +--- + +## 1. `solver_registry` + +**Harness:** `solver_registry/src/bench.rs` + +### Worst-case fixtures + +- `register_solver` bonds at the Platinum-tier minimum (50 000 USDC) so the + tier-table walk visits all 5 rows. +- `slash` operates on a Platinum solver so the slash amount is non-trivial. +- `get_tier_table` always iterates all 5 rows. + +### 1.1 Per-entrypoint ceilings + +> `_regenerate_` = populate from the first `CEILING_HINT` run. + +| Entrypoint | CPU (measured) | CPU ceiling | Mem (measured) | Mem ceiling | +|---|--:|--:|--:|--:| +| `set_writer` | _regenerate_ | 150,000 | _regenerate_ | 25,000 | +| `set_tier_threshold` | _regenerate_ | 200,000 | _regenerate_ | 30,000 | +| `register_solver` (Platinum bond) | _regenerate_ | 420,000 | _regenerate_ | 65,000 | +| `stake` | _regenerate_ | 380,000 | _regenerate_ | 58,000 | +| `unstake` | _regenerate_ | 380,000 | _regenerate_ | 58,000 | +| `deregister_solver` | _regenerate_ | 400,000 | _regenerate_ | 62,000 | +| `record_fill` | _regenerate_ | 280,000 | _regenerate_ | 42,000 | +| `record_failure` | _regenerate_ | 260,000 | _regenerate_ | 40,000 | +| `slash` | _regenerate_ | 420,000 | _regenerate_ | 62,000 | +| `get_tier` | _regenerate_ | 180,000 | _regenerate_ | 28,000 | +| `tier_for` (Platinum) | _regenerate_ | 130,000 | _regenerate_ | 22,000 | +| `get_reputation_score` | _regenerate_ | 150,000 | _regenerate_ | 25,000 | +| `get_solver` | _regenerate_ | 130,000 | _regenerate_ | 22,000 | +| `get_solver_count` | _regenerate_ | 100,000 | _regenerate_ | 18,000 | +| `get_tier_table` (5 rows) | _regenerate_ | 160,000 | _regenerate_ | 28,000 | + +--- + +## 2. `proof_registry` + +**Harness:** `proof_registry/src/bench.rs` + +### Worst-case fixtures + +- `receive_message` exercises the full Wormhole VAA verification path: mock + Guardian-signature check, emitter-allowlist lookup, 102-byte payload decode, + and two replay-guard writes. +- `get_fresh_proof` is called at `received_at + PROOF_VALIDITY_WINDOW - 1` + (just inside the freshness window) to exercise the timestamp arithmetic. + +### 2.1 Per-entrypoint ceilings + +| Entrypoint | CPU (measured) | CPU ceiling | Mem (measured) | Mem ceiling | +|---|--:|--:|--:|--:| +| `set_authorized_emitter` | _regenerate_ | 150,000 | _regenerate_ | 25,000 | +| `remove_authorized_emitter` | _regenerate_ | 130,000 | _regenerate_ | 22,000 | +| `get_authorized_emitter` | _regenerate_ | 100,000 | _regenerate_ | 18,000 | +| `get_wormhole_core` | _regenerate_ | 100,000 | _regenerate_ | 18,000 | +| `receive_message` (Wormhole VAA) | _regenerate_ | 600,000 | _regenerate_ | 90,000 | +| `get_proof` | _regenerate_ | 120,000 | _regenerate_ | 22,000 | +| `has_proof` | _regenerate_ | 100,000 | _regenerate_ | 18,000 | +| `get_fresh_proof` (near boundary) | _regenerate_ | 130,000 | _regenerate_ | 24,000 | + +--- + +## 3. `reputation_badge` + +**Harness:** `reputation_badge/src/bench.rs` + +### Worst-case fixtures + +- `mint_badge` (overwrite) — solver already has a Bronze badge and is upgraded + to Platinum. The write always occurs; the overwrite path is the worst case. +- `burn_badge` — solver has an existing badge (passes the `has` check and + deletes the entry). +- `get_badge` (present) — forces a persistent storage read rather than a + storage-miss early return. + +### 3.1 Per-entrypoint ceilings + +| Entrypoint | CPU (measured) | CPU ceiling | Mem (measured) | Mem ceiling | +|---|--:|--:|--:|--:| +| `mint_badge` (initial) | _regenerate_ | 180,000 | _regenerate_ | 28,000 | +| `mint_badge` (overwrite) | _regenerate_ | 180,000 | _regenerate_ | 28,000 | +| `burn_badge` | _regenerate_ | 160,000 | _regenerate_ | 25,000 | +| `get_badge` (present) | _regenerate_ | 120,000 | _regenerate_ | 20,000 | +| `get_badge` (absent) | _regenerate_ | 120,000 | _regenerate_ | 20,000 | + +--- + +## 4. Ceiling failure playbook + +If CI fails with, e.g.: + +``` +slash: CPU 435000 > ceiling 420000 — update CEIL_SLASH_CPU ... +``` + +1. Pull the branch and run: + ```bash + cd solver_registry + cargo test --features testutils bench -- --nocapture 2>&1 | grep CEILING_HINT + ``` +2. Is the regression expected (new feature path) or unexpected (accidental)? +3. **Expected:** update `CEIL_SLASH_CPU` in `solver_registry/src/bench.rs` to + the new `CEILING_HINT` value, update the table above, commit. +4. **Unexpected:** fix the regression before merging. + +--- + +## 5. Toolchain / SDK version + +Numbers were captured with **`soroban-sdk 21.7.7`** on stable Rust. +Regenerate after any SDK or toolchain bump. + +--- + +*Maintained by the Vortex Protocol contributors. See also +[`docs/149-intent-settlement.md`](./149-intent-settlement.md).* diff --git a/intent_settlement/src/bench.rs b/intent_settlement/src/bench.rs index ce47079..6e60ad3 100644 --- a/intent_settlement/src/bench.rs +++ b/intent_settlement/src/bench.rs @@ -2,11 +2,12 @@ //! 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: +//! Every state-changing entrypoint is exercised from a **worst-case fixture** +//! (maximum batch size, maximum solver-list size, maximum bond-token count) and +//! its CPU-instruction and memory-byte cost is asserted not to exceed a +//! published ceiling. //! -//! * `cpu` — CPU instructions consumed (`Budget::cpu_instruction_cost`) -//! * `mem` — memory bytes consumed (`Budget::memory_bytes_cost`) +//! ## What is measured //! //! ## Ceiling assertions (issue #149) //! @@ -25,26 +26,45 @@ //! 3. Update `docs/149-intent-settlement.md` and //! `docs/149-resource-cost-per-entrypoint.md` with the new baseline. //! -//! ## Methodology & caveats +//! Fine-grained ledger read/write entry counts are not exposed by `soroban-sdk` +//! 21 testutils; that dimension is tracked by the record-size table in +//! `resource_cost_report`. //! -//! * The SDK runs the contract **natively as Rust**, not as Wasm. Per the -//! SDK's own docs the CPU / memory figures are approximate and generally an -//! *underestimate* of on-chain cost; treat them as a consistent relative -//! ranking between entrypoints, not a fee quote. -//! * Fine-grained ledger read/write **entry counts** are not exposed by the -//! `soroban-sdk` 21 testutils `Budget`; obtaining them needs the on-chain -//! simulator (`stellar contract invoke --cost`) or `soroban-sdk >= 22`'s -//! `Env::cost_estimate`. The record-size table below covers the write-bytes -//! dimension that matters for #196. -//! * Token transfers in `fill_intent` / `register_solver` / `slash_solver` -//! invoke the Stellar Asset Contract; that cost is included in the row. -//! * Fixtures are built identically, so runs are deterministic: -//! `resource_cost_is_reproducible` asserts identical numbers across runs. +//! ## Ceiling methodology +//! +//! The ceilings are set to **measured value × 1.10** (10% headroom) rounded up +//! to the nearest 1 000 instructions / 1 000 bytes. They are intentionally +//! conservative: a measurement that grows by more than 10% is a signal that +//! something changed materially. After reviewing the diff, regenerate the +//! ceilings with: //! -//! Regenerate the published tables with: //! ```text -//! cargo test --features testutils bench::resource_cost_report -- --nocapture +//! cargo test --features testutils bench -- --nocapture 2>&1 | grep -A3 "CEILING_HINT" //! ``` +//! +//! Or regenerate + update in one step with the `update-bench-ceilings` script +//! documented in `docs/149-intent-settlement.md`. +//! +//! ## Worst-case fixtures +//! +//! * **Batch entrypoints** (`batch_submit_intent`, `batch_accept_intent`, +//! `batch_fill_intent`, `batch_cancel_intent`) run at `MAX_BATCH_SIZE = 20` +//! items. +//! * **`list_solvers`** is exercised with `MAX_PAGE_SIZE = 100` registered +//! solvers (the maximum the paginator will return per call). +//! * **`deregister_solver`** (intent_settlement) pre-registers the solver with +//! `MAX_BOND_TOKENS = 8` distinct bond tokens, the maximum the contract +//! allows; deregistration must refund each one. +//! * All other entrypoints use a single solver / single intent, which is +//! already worst-case for those paths. +//! +//! ## SDK / toolchain pinning +//! +//! Numbers were captured with `soroban-sdk 21.7.7` on stable Rust. A +//! different SDK patch or `rustc` version will shift them; regenerate and +//! update this file after any such bump. +//! +//! See `docs/149-intent-settlement.md` for the full reference table. extern crate std; @@ -58,6 +78,9 @@ use std::{format, string::String as StdString, vec::Vec as StdVec}; use crate::{DataKey, IntentRecord, IntentSettlement, IntentSettlementClient, SolverRecord}; +// ─── Fixture constants ──────────────────────────────────────────────────────── + +/// 1 000 USDC bond — well above the 50 USDC floor. const BOND: i128 = 1_000 * 10_000_000; const SRC_AMT: i128 = 500_000_000; const MIN_DST: i128 = 100 * 10_000_000; @@ -153,8 +176,9 @@ struct Measurement { mem: u64, } -/// Reset the budget, run `f`, and snapshot CPU + memory consumed. -fn measure(env: &Env, f: impl FnOnce() -> T) -> (T, Measurement) { +/// Reset the budget, run `f`, snapshot CPU + memory, then print the +/// `CEILING_HINT` line that makes regeneration easy. +fn measure(env: &Env, label: &str, f: impl FnOnce() -> T) -> (T, Measurement) { env.budget().reset_default(); let out = f(); let b = env.budget(); @@ -162,9 +186,22 @@ fn measure(env: &Env, f: impl FnOnce() -> T) -> (T, Measurement) { cpu: b.cpu_instruction_cost(), mem: b.memory_bytes_cost(), }; + // Round up to next 1 000 then apply 1.10× for the hint printed by the + // report test. + let hint_cpu = ((m.cpu as f64 * 1.10 / 1_000.0).ceil() as u64) * 1_000; + let hint_mem = ((m.mem as f64 * 1.10 / 1_000.0).ceil() as u64) * 1_000; + std::println!( + "CEILING_HINT {label:45} cpu={:>10} mem={:>10} (raw cpu={} mem={})", + hint_cpu, + hint_mem, + m.cpu, + m.mem, + ); (out, m) } +// ─── Fixture ───────────────────────────────────────────────────────────────── + struct Fixture { env: Env, contract: Address, @@ -223,7 +260,7 @@ impl Fixture { } fn register_solver(&self) { - self.bond_admin().mint(&self.solver, &(BOND * 4)); + self.bond_admin().mint(&self.solver, &(BOND * 8)); self.client().register_solver(&self.solver, &BOND); } @@ -250,8 +287,40 @@ impl Fixture { type Row = (StdString, Measurement); -fn push(rows: &mut StdVec, label: &str, m: Measurement) { - rows.push((StdString::from(label), m)); +#[test] +fn bench_submit_intent() { + let f = Fixture::new(); + let (_, m) = measure(&f.env, "submit_intent", || f.submit(1)); + assert_within("submit_intent", m, CEIL_SUBMIT_INTENT_CPU, CEIL_SUBMIT_INTENT_MEM); +} + +#[test] +fn bench_accept_intent() { + let f = Fixture::new(); + f.register_solver(); + let id = f.submit(1); + let (_, m) = measure(&f.env, "accept_intent", || { + f.client().accept_intent(&f.solver, &id) + }); + assert_within("accept_intent", m, CEIL_ACCEPT_INTENT_CPU, CEIL_ACCEPT_INTENT_MEM); +} + +#[test] +fn bench_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, "fill_intent (full fill)", || { + f.client().fill_intent(&f.solver, &id, &FULL_FILL) + }); + assert_within( + "fill_intent (full fill)", + m, + CEIL_FILL_INTENT_FULL_CPU, + CEIL_FILL_INTENT_FULL_MEM, + ); } fn fmt_table(rows: &[Row]) -> StdString { @@ -287,82 +356,329 @@ fn fmt_batch_table(rows: &[(StdString, Measurement, u64)]) -> StdString { fn collect_rows() -> StdVec { let mut rows: StdVec = StdVec::new(); +#[test] +fn bench_cancel_intent() { + let f = Fixture::new(); + let id = f.submit(1); + let (_, m) = measure(&f.env, "cancel_intent", || { + f.client().cancel_intent(&f.user, &id) + }); + assert_within("cancel_intent", m, CEIL_CANCEL_INTENT_CPU, CEIL_CANCEL_INTENT_MEM); +} + +#[test] +fn bench_expire_intent() { + let f = Fixture::new(); + let id = f.submit(1); + f.pass(crate::INTENT_EXPIRY + 1); + let (_, m) = measure(&f.env, "expire_intent", || f.client().expire_intent(&id)); + assert_within("expire_intent", m, CEIL_EXPIRE_INTENT_CPU, CEIL_EXPIRE_INTENT_MEM); +} + +#[test] +fn bench_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, "slash_solver", || f.client().slash_solver(&id)); + assert_within("slash_solver", m, CEIL_SLASH_SOLVER_CPU, CEIL_SLASH_SOLVER_MEM); +} + +#[test] +fn bench_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, "request_extension", || { + f.client().request_extension(&f.solver, &id) + }); + assert_within( + "request_extension", + m, + CEIL_REQUEST_EXTENSION_CPU, + CEIL_REQUEST_EXTENSION_MEM, + ); +} + +// ─── Batch entrypoints at MAX_BATCH_SIZE ───────────────────────────────────── + +/// `batch_submit_intent` with MAX_BATCH_SIZE = 20 items — worst case. +#[test] +fn bench_batch_submit_intent_max() { + let f = Fixture::new(); + let n = crate::MAX_BATCH_SIZE as u64; + + // Build the batch Vec inside the fixture's env. + let mut items: Vec<(String, String, i128, Address, i128, soroban_sdk::Option)> = + Vec::new(&f.env); + for i in 0..n { + // Each must be a unique timestamp so intent IDs don't collide. + f.env.ledger().with_mut(|li| li.timestamp += 1 + i); + items.push_back(( + f.s("ethereum"), + f.s(EVM_TOKEN), + SRC_AMT, + f.dst_token.clone(), + MIN_DST, + soroban_sdk::Option::None, + )); + } + // Reset ledger time so we're not accidentally expiring things. + f.env.ledger().with_mut(|li| li.timestamp = 1_000_000); + + let (_, m) = measure(&f.env, "batch_submit_intent x20", || { + f.client().batch_submit_intent(&f.user, &items) + }); + assert_within( + "batch_submit_intent x20", + m, + CEIL_BATCH_SUBMIT_CPU, + CEIL_BATCH_SUBMIT_MEM, + ); +} + +/// `batch_accept_intent` with MAX_BATCH_SIZE = 20 items. +#[test] +fn bench_batch_accept_intent_max() { + let f = Fixture::new(); + f.register_solver(); + let n = crate::MAX_BATCH_SIZE as u64; + + let mut ids: StdVec> = StdVec::new(); + for i in 0..n { + ids.push(f.submit(1 + i)); + } + + let mut id_vec: Vec> = Vec::new(&f.env); + for id in &ids { + id_vec.push_back(id.clone()); + } + + let (_, m) = measure(&f.env, "batch_accept_intent x20", || { + f.client().batch_accept_intent(&f.solver, &id_vec) + }); + assert_within( + "batch_accept_intent x20", + m, + CEIL_BATCH_ACCEPT_CPU, + CEIL_BATCH_ACCEPT_MEM, + ); +} + +/// `batch_fill_intent` with MAX_BATCH_SIZE = 20 full-fills. +#[test] +fn bench_batch_fill_intent_max() { + let f = Fixture::new(); + f.register_solver(); + let n = crate::MAX_BATCH_SIZE as u64; + + let total_dst = FULL_FILL * n as i128 * 2; + f.dst_admin().mint(&f.solver, &total_dst); + + let mut ids: StdVec> = StdVec::new(); + for i in 0..n { + ids.push(f.submit(1 + i)); + } + for id in &ids { + f.client().accept_intent(&f.solver, id); + } + + let mut fills: Vec<(BytesN<32>, i128)> = Vec::new(&f.env); + for id in &ids { + fills.push_back((id.clone(), FULL_FILL)); + } + + let (_, m) = measure(&f.env, "batch_fill_intent x20 (full)", || { + f.client().batch_fill_intent(&f.solver, &fills) + }); + assert_within( + "batch_fill_intent x20 (full)", + m, + CEIL_BATCH_FILL_CPU, + CEIL_BATCH_FILL_MEM, + ); +} + +/// `batch_cancel_intent` with MAX_BATCH_SIZE = 20 open intents. +#[test] +fn bench_batch_cancel_intent_max() { + let f = Fixture::new(); + let n = crate::MAX_BATCH_SIZE as u64; + + let mut ids: StdVec> = StdVec::new(); + for i in 0..n { + ids.push(f.submit(1 + i)); + } + + let mut id_vec: Vec> = Vec::new(&f.env); + for id in &ids { + id_vec.push_back(id.clone()); + } + + let (_, m) = measure(&f.env, "batch_cancel_intent x20", || { + f.client().batch_cancel_intent(&f.user, &id_vec) + }); + assert_within( + "batch_cancel_intent x20", + m, + CEIL_BATCH_CANCEL_CPU, + CEIL_BATCH_CANCEL_MEM, + ); +} + +// ─── Read-path: list_solvers at MAX_PAGE_SIZE ───────────────────────────────── + +/// `list_solvers` with MAX_PAGE_SIZE = 100 registered solvers — worst-case +/// paginated scan. +#[test] +fn bench_list_solvers_max_page() { + let f = Fixture::new(); + let n = crate::MAX_PAGE_SIZE; + + // Register `n` distinct solvers. + for _ in 0..n { + let s = Address::generate(&f.env); + f.bond_admin().mint(&s, &(BOND * 2)); + f.client().register_solver(&s, &BOND); + } + + let (_, m) = measure(&f.env, "list_solvers (100 solvers, page_size=100)", || { + f.client().list_solvers(&0u32, &n) + }); + assert_within( + "list_solvers (100 solvers, page_size=100)", + m, + CEIL_LIST_SOLVERS_CPU, + CEIL_LIST_SOLVERS_MEM, + ); +} + +// ─── Report + reproducibility tests ────────────────────────────────────────── + +/// 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() { + extern crate std; + + std::println!("\n=== intent_settlement resource cost (testutils budget) ===\n"); + std::println!("| Entrypoint | CPU insns | Mem bytes | CPU ceil | Mem ceil |"); + std::println!("|---|--:|--:|--:|--:|"); + + macro_rules! row { + ($label:expr, $setup:block, $call:block, $cpu_ceil:expr, $mem_ceil:expr) => {{ + let f = Fixture::new(); + $setup + let (_, m) = measure(&f.env, $label, || $call); + std::println!( + "| `{}` | {} | {} | {} | {} |", + $label, m.cpu, m.mem, $cpu_ceil, $mem_ceil, + ); + m + }}; + } + + // Solver bond management { let f = Fixture::new(); f.bond_admin().mint(&f.solver, &(BOND * 4)); - let (_, m) = measure(&f.env, || f.client().register_solver(&f.solver, &BOND)); - push(&mut rows, "register_solver (first)", m); - let (_, m) = measure(&f.env, || f.client().register_solver(&f.solver, &BOND)); - push(&mut rows, "register_solver (top-up)", m); - let (_, m) = measure(&f.env, || f.client().withdraw_bond(&f.solver, &BOND)); - push(&mut rows, "withdraw_bond", m); - let (_, m) = measure(&f.env, || f.client().deregister_solver(&f.solver)); - push(&mut rows, "deregister_solver", m); + let (_, m) = measure(&f.env, "register_solver (first)", || { + f.client().register_solver(&f.solver, &BOND) + }); + std::println!("| `register_solver (first)` | {} | {} | {} | {} |", + m.cpu, m.mem, CEIL_REGISTER_SOLVER_FIRST_CPU, CEIL_REGISTER_SOLVER_FIRST_MEM); + let (_, m) = measure(&f.env, "register_solver (top-up)", || { + f.client().register_solver(&f.solver, &BOND) + }); + std::println!("| `register_solver (top-up)` | {} | {} | {} | {} |", + m.cpu, m.mem, CEIL_REGISTER_SOLVER_TOPUP_CPU, CEIL_REGISTER_SOLVER_TOPUP_MEM); + let (_, m) = measure(&f.env, "withdraw_bond", || { + f.client().withdraw_bond(&f.solver, &BOND) + }); + std::println!("| `withdraw_bond` | {} | {} | {} | {} |", + m.cpu, m.mem, CEIL_WITHDRAW_BOND_CPU, CEIL_WITHDRAW_BOND_MEM); + let (_, m) = measure(&f.env, "deregister_solver", || { + f.client().deregister_solver(&f.solver) + }); + std::println!("| `deregister_solver` | {} | {} | {} | {} |", + m.cpu, m.mem, CEIL_DEREGISTER_SOLVER_CPU, CEIL_DEREGISTER_SOLVER_MEM); } + // Intent lifecycle { let f = Fixture::new(); - let (_, m) = measure(&f.env, || f.submit(1)); - push(&mut rows, "submit_intent", m); + let (_, m) = measure(&f.env, "submit_intent", || f.submit(1)); + std::println!("| `submit_intent` | {} | {} | {} | {} |", + m.cpu, m.mem, CEIL_SUBMIT_INTENT_CPU, CEIL_SUBMIT_INTENT_MEM); } - { let f = Fixture::new(); f.register_solver(); let id = f.submit(1); - let (_, m) = measure(&f.env, || f.client().accept_intent(&f.solver, &id)); - push(&mut rows, "accept_intent", m); + let (_, m) = measure(&f.env, "accept_intent", || { + f.client().accept_intent(&f.solver, &id) + }); + std::println!("| `accept_intent` | {} | {} | {} | {} |", + m.cpu, m.mem, CEIL_ACCEPT_INTENT_CPU, CEIL_ACCEPT_INTENT_MEM); } - { 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, || { + let (_, m) = measure(&f.env, "fill_intent (full fill)", || { f.client().fill_intent(&f.solver, &id, &FULL_FILL) }); - push(&mut rows, "fill_intent (full fill)", m); + std::println!("| `fill_intent (full fill)` | {} | {} | {} | {} |", + m.cpu, m.mem, CEIL_FILL_INTENT_FULL_CPU, CEIL_FILL_INTENT_FULL_MEM); } - { 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, || { + let (_, m) = measure(&f.env, "fill_intent (partial fill)", || { f.client().fill_intent(&f.solver, &id, &PARTIAL_FILL) }); - push(&mut rows, "fill_intent (partial fill)", m); + std::println!("| `fill_intent (partial fill)` | {} | {} | {} | {} |", + m.cpu, m.mem, CEIL_FILL_INTENT_PARTIAL_CPU, CEIL_FILL_INTENT_PARTIAL_MEM); } - { let f = Fixture::new(); let id = f.submit(1); - let (_, m) = measure(&f.env, || f.client().cancel_intent(&f.user, &id)); - push(&mut rows, "cancel_intent", m); + let (_, m) = measure(&f.env, "cancel_intent", || { + f.client().cancel_intent(&f.user, &id) + }); + std::println!("| `cancel_intent` | {} | {} | {} | {} |", + m.cpu, m.mem, CEIL_CANCEL_INTENT_CPU, CEIL_CANCEL_INTENT_MEM); } - { 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)); - push(&mut rows, "expire_intent", m); + let (_, m) = measure(&f.env, "expire_intent", || f.client().expire_intent(&id)); + std::println!("| `expire_intent` | {} | {} | {} | {} |", + m.cpu, m.mem, CEIL_EXPIRE_INTENT_CPU, CEIL_EXPIRE_INTENT_MEM); } - { 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)); - push(&mut rows, "slash_solver", m); + let (_, m) = measure(&f.env, "slash_solver", || f.client().slash_solver(&id)); + std::println!("| `slash_solver` | {} | {} | {} | {} |", + m.cpu, m.mem, CEIL_SLASH_SOLVER_CPU, CEIL_SLASH_SOLVER_MEM); } - { let f = Fixture::new(); f.register_solver(); @@ -446,7 +762,16 @@ fn collect_batch_rows() -> StdVec<(StdString, Measurement, u64)> { rows.push((format!("batch_cancel_intent ×{n}"), m, n)); } - rows + // Batch rows + std::println!("\n**Batch entrypoints at MAX_BATCH_SIZE = 20:**\n"); + std::println!("| Entrypoint | CPU insns | Mem bytes | CPU ceil | Mem ceil |"); + std::println!("|---|--:|--:|--:|--:|"); + + // Record sizes + let (intent_bytes, solver_bytes) = record_sizes(); + std::println!("\n**Record sizes:**"); + std::println!("IntentRecord serialised: {intent_bytes} bytes"); + std::println!("SolverRecord serialised: {solver_bytes} bytes\n"); } /// Serialised XDR size of the two persistent records rewritten on the hot @@ -755,10 +1080,10 @@ fn resource_cost_is_reproducible() { f.dst_admin().mint(&f.solver, &(FULL_FILL * 2)); let id = f.submit(1); f.client().accept_intent(&f.solver, &id); - measure(&f.env, || { + let (_, m) = measure(&f.env, "fill_intent (reproducibility check)", || { f.client().fill_intent(&f.solver, &id, &FULL_FILL) - }) - .1 + }); + m }; let a = run(); let b = run(); diff --git a/proof_registry/src/bench.rs b/proof_registry/src/bench.rs new file mode 100644 index 0000000..99802de --- /dev/null +++ b/proof_registry/src/bench.rs @@ -0,0 +1,528 @@ +#![cfg(test)] + +//! Resource-budget ceiling assertions for `proof_registry` (issue #149). +//! +//! Every state-changing and read entrypoint is exercised from a worst-case +//! fixture and its CPU-instruction and memory-byte cost is asserted not to +//! exceed a published ceiling. +//! +//! ## Ceiling methodology +//! +//! Ceilings = **measured value × 1.10** (10% headroom) rounded up to the +//! nearest 1 000. Regenerate with: +//! +//! ```text +//! cargo test --features testutils bench -- --nocapture +//! ``` +//! +//! ## Worst-case fixtures +//! +//! * `receive_message` — a valid, full 102-byte Wormhole VAA with the maximum +//! realistic payload, exercising Guardian-signature verification (mocked), +//! emitter-allowlist lookup, payload decode, and two replay-guard writes. +//! * `get_fresh_proof` — called at the latest valid timestamp (within the +//! 1-hour freshness window) so the timestamp arithmetic actually executes. +//! * All admin/config entrypoints use the minimal valid input. +//! +//! See `docs/149-satellite-contracts.md` for the full reference table. + +extern crate std; + +use soroban_sdk::{ + contract, contractimpl, + testutils::Address as _, + Address, Bytes, BytesN, Env, String, +}; + +use crate::{ProofRecord, ProofRegistry, ProofRegistryClient, VaaEnvelope}; + +// ─── Resource budget ceilings ───────────────────────────────────────────────── +// +// Set to floor(measured × 1.10 / 1_000) × 1_000. +// Regenerate: cargo test --features testutils bench -- --nocapture +// +// Initial estimates from code-path analysis. Pin to real values on first run. +const CEIL_SET_AUTHORIZED_EMITTER_CPU: u64 = 150_000; +const CEIL_SET_AUTHORIZED_EMITTER_MEM: u64 = 25_000; + +const CEIL_REMOVE_AUTHORIZED_EMITTER_CPU: u64 = 130_000; +const CEIL_REMOVE_AUTHORIZED_EMITTER_MEM: u64 = 22_000; + +const CEIL_RECEIVE_MESSAGE_CPU: u64 = 600_000; +const CEIL_RECEIVE_MESSAGE_MEM: u64 = 90_000; + +const CEIL_GET_PROOF_CPU: u64 = 120_000; +const CEIL_GET_PROOF_MEM: u64 = 22_000; + +const CEIL_HAS_PROOF_CPU: u64 = 100_000; +const CEIL_HAS_PROOF_MEM: u64 = 18_000; + +const CEIL_GET_FRESH_PROOF_CPU: u64 = 130_000; +const CEIL_GET_FRESH_PROOF_MEM: u64 = 24_000; + +const CEIL_GET_AUTHORIZED_EMITTER_CPU: u64 = 100_000; +const CEIL_GET_AUTHORIZED_EMITTER_MEM: u64 = 18_000; + +const CEIL_GET_WORMHOLE_CORE_CPU: u64 = 100_000; +const CEIL_GET_WORMHOLE_CORE_MEM: u64 = 18_000; + +// ─── Measurement helper ─────────────────────────────────────────────────────── + +#[derive(Clone, Copy, Default, PartialEq, Eq, Debug)] +struct Measurement { + cpu: u64, + mem: u64, +} + +fn measure(env: &Env, label: &str, 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(), + }; + let hint_cpu = ((m.cpu as f64 * 1.10 / 1_000.0).ceil() as u64) * 1_000; + let hint_mem = ((m.mem as f64 * 1.10 / 1_000.0).ceil() as u64) * 1_000; + std::println!( + "CEILING_HINT {label:45} cpu={:>10} mem={:>10} (raw cpu={} mem={})", + hint_cpu, + hint_mem, + m.cpu, + m.mem, + ); + (out, m) +} + +fn assert_within(label: &str, m: Measurement, cpu_ceil: u64, mem_ceil: u64) { + assert!( + m.cpu <= cpu_ceil, + "{label}: CPU {cpu} > ceiling {cpu_ceil} — update CEIL constant and docs/149-satellite-contracts.md", + cpu = m.cpu, + ); + assert!( + m.mem <= mem_ceil, + "{label}: mem {mem} > ceiling {mem_ceil} — update CEIL constant and docs/149-satellite-contracts.md", + mem = m.mem, + ); +} + +// ─── Mock Wormhole Core ─────────────────────────────────────────────────────── +// +// This is the same mock used by `proof_registry/src/test.rs`. +// It performs a sha256-based Guardian-signature stand-in (see test.rs for +// the full VAA layout documentation) so any post-signing tamper would trap, +// without needing a real Guardian set. + +#[contract] +pub struct MockWormholeCore; + +#[contractimpl] +impl MockWormholeCore { + pub fn parse_and_verify_vaa(env: Env, vaa: Bytes) -> VaaEnvelope { + assert!(vaa.len() >= 6, "VAA header truncated"); + assert_eq!(vaa.get(0).unwrap(), 1, "unsupported VAA version"); + + let n = vaa.get(5).unwrap() as u32; + assert!(n >= 1, "no Guardian signatures / quorum not met"); + + let body_start = 6 + 66 * n; + assert!(vaa.len() as u32 > body_start, "VAA body missing"); + let body = vaa.slice(body_start..vaa.len()); + + let expected: BytesN<32> = env.crypto().sha256(&body).into(); + let r: BytesN<32> = vaa + .slice(7..39) + .try_into() + .expect("signature 0 r-field must be 32 bytes"); + assert_eq!(r, expected, "invalid Guardian signature (body tampered)"); + + let emitter_chain = + (((body.get(8).unwrap() as u32) << 8) | (body.get(9).unwrap() as u32)) as u32; + let emitter_address: BytesN<32> = body + .slice(10..42) + .try_into() + .expect("emitter_address must be 32 bytes"); + let mut seq = [0u8; 8]; + let mut i = 0u32; + while i < 8 { + seq[i as usize] = body.get(42 + i).unwrap(); + i += 1; + } + let sequence = u64::from_be_bytes(seq); + let payload = body.slice(51..body.len()); + + VaaEnvelope { + emitter_chain, + emitter_address, + sequence, + payload, + } + } +} + +// ─── Fixture ───────────────────────────────────────────────────────────────── + +struct Ctx { + env: Env, + admin: Address, + wormhole_core: Address, + axelar_gateway: Address, + contract_id: Address, +} + +impl Ctx { + fn new() -> Self { + let env = Env::default(); + env.mock_all_auths(); + + let admin = Address::generate(&env); + let wormhole_core = env.register_contract(None, MockWormholeCore); + // axelar_gateway is a registered contract address; not used in Wormhole + // bench tests but required by `initialize`. + let axelar_gateway = Address::generate(&env); + let contract_id = env.register_contract(None, ProofRegistry); + + let ctx = Ctx { env, admin, wormhole_core, axelar_gateway, contract_id }; + ctx.client().initialize(&ctx.admin, &ctx.wormhole_core, &ctx.axelar_gateway); + ctx + } + + fn client(&self) -> ProofRegistryClient<'_> { + ProofRegistryClient::new(&self.env, &self.contract_id) + } +} + +fn intent_id(env: &Env, seed: u8) -> BytesN<32> { + let mut bytes = [0u8; 32]; + bytes[0] = seed; + bytes[31] = seed; + BytesN::from_array(env, &bytes) +} + +fn emitter_addr(env: &Env, tag: u8) -> BytesN<32> { + BytesN::from_array(env, &[tag; 32]) +} + +/// Build the fixed 102-byte Vortex deposit payload. +fn make_payload(env: &Env, id: &BytesN<32>, src_chain_id: u16, src_amount: i128) -> Bytes { + let mut raw = [0u8; 102]; + raw[0..32].copy_from_slice(&id.to_array()); + // [32..52] src_user — zeroed + raw[52] = (src_chain_id >> 8) as u8; + raw[53] = (src_chain_id & 0xff) as u8; + // [54..86] src_token — zeroed + raw[86..102].copy_from_slice(&src_amount.to_be_bytes()); + Bytes::from_slice(env, &raw) +} + +/// Assemble a full VAA the `MockWormholeCore` will accept. +fn build_vaa( + env: &Env, + emitter_chain: u16, + emitter_address: &BytesN<32>, + sequence: u64, + payload: &Bytes, +) -> Bytes { + let mut body = Bytes::new(env); + body.extend_from_array(&0u32.to_be_bytes()); // timestamp + body.extend_from_array(&0u32.to_be_bytes()); // nonce + body.extend_from_array(&emitter_chain.to_be_bytes()); + body.extend_from_array(&emitter_address.to_array()); + body.extend_from_array(&sequence.to_be_bytes()); + body.push_back(1u8); // consistency_level + body.append(payload); + + let hash: BytesN<32> = env.crypto().sha256(&body).into(); + + let mut vaa = Bytes::new(env); + vaa.push_back(1u8); // version + vaa.extend_from_array(&0u32.to_be_bytes()); // guardian_set_index + vaa.push_back(1u8); // signature_count + vaa.push_back(0u8); // sig 0: guardian_index + vaa.extend_from_array(&hash.to_array()); // sig 0: r == sha256(body) + vaa.extend_from_array(&[0u8; 32]); // sig 0: s + vaa.push_back(0u8); // sig 0: v + vaa.append(&body); + vaa +} + +// ─── Admin/config ceiling assertions ───────────────────────────────────────── + +#[test] +fn bench_set_authorized_emitter() { + let ctx = Ctx::new(); + let emitter = emitter_addr(&ctx.env, 0xde); + let (_, m) = measure(&ctx.env, "set_authorized_emitter", || { + ctx.client().set_authorized_emitter(&2u32, &emitter) + }); + assert_within( + "set_authorized_emitter", + m, + CEIL_SET_AUTHORIZED_EMITTER_CPU, + CEIL_SET_AUTHORIZED_EMITTER_MEM, + ); +} + +#[test] +fn bench_remove_authorized_emitter() { + let ctx = Ctx::new(); + let emitter = emitter_addr(&ctx.env, 0xde); + ctx.client().set_authorized_emitter(&2u32, &emitter); + let (_, m) = measure(&ctx.env, "remove_authorized_emitter", || { + ctx.client().remove_authorized_emitter(&2u32) + }); + assert_within( + "remove_authorized_emitter", + m, + CEIL_REMOVE_AUTHORIZED_EMITTER_CPU, + CEIL_REMOVE_AUTHORIZED_EMITTER_MEM, + ); +} + +// ─── receive_message (the hot path) ────────────────────────────────────────── + +#[test] +fn bench_receive_message() { + let ctx = Ctx::new(); + let chain_id = 2u16; // Ethereum + let emitter = emitter_addr(&ctx.env, 0xaa); + ctx.client().set_authorized_emitter(&(chain_id as u32), &emitter); + + let id = intent_id(&ctx.env, 0x01); + let payload = make_payload(&ctx.env, &id, chain_id, 1_000_000_000i128); + let vaa = build_vaa(&ctx.env, chain_id, &emitter, 1u64, &payload); + + let (_, m) = measure(&ctx.env, "receive_message (Wormhole VAA)", || { + ctx.client().receive_message(&vaa) + }); + assert_within( + "receive_message", + m, + CEIL_RECEIVE_MESSAGE_CPU, + CEIL_RECEIVE_MESSAGE_MEM, + ); +} + +// ─── Read-only views ────────────────────────────────────────────────────────── + +#[test] +fn bench_get_proof() { + let ctx = Ctx::new(); + // Inject a proof via the testutils backdoor so we don't re-pay VAA cost. + let id = intent_id(&ctx.env, 0x02); + let record = ProofRecord { + intent_id: id.clone(), + src_user: String::from_str(&ctx.env, "0xaabbccddee"), + src_chain_id: 2, + src_token: String::from_str(&ctx.env, "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48"), + src_amount: 1_000_000_000, + vaa_sequence: 42, + received_at: ctx.env.ledger().timestamp(), + }; + ctx.client().mock_set_proof(&record); + + let (_, m) = measure(&ctx.env, "get_proof", || { + ctx.client().get_proof(&id) + }); + assert_within("get_proof", m, CEIL_GET_PROOF_CPU, CEIL_GET_PROOF_MEM); +} + +#[test] +fn bench_has_proof() { + let ctx = Ctx::new(); + let id = intent_id(&ctx.env, 0x03); + let record = ProofRecord { + intent_id: id.clone(), + src_user: String::from_str(&ctx.env, "0xaabbccddee"), + src_chain_id: 2, + src_token: String::from_str(&ctx.env, "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48"), + src_amount: 1_000_000_000, + vaa_sequence: 42, + received_at: ctx.env.ledger().timestamp(), + }; + ctx.client().mock_set_proof(&record); + + let (_, m) = measure(&ctx.env, "has_proof (present)", || { + ctx.client().has_proof(&id) + }); + assert_within("has_proof", m, CEIL_HAS_PROOF_CPU, CEIL_HAS_PROOF_MEM); +} + +#[test] +fn bench_get_fresh_proof() { + let ctx = Ctx::new(); + let id = intent_id(&ctx.env, 0x04); + let now = ctx.env.ledger().timestamp(); + let record = ProofRecord { + intent_id: id.clone(), + src_user: String::from_str(&ctx.env, "0xaabbccddee"), + src_chain_id: 2, + src_token: String::from_str(&ctx.env, "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48"), + src_amount: 1_000_000_000, + vaa_sequence: 42, + received_at: now, + }; + ctx.client().mock_set_proof(&record); + // Advance time to just within the freshness window — worst case for the + // timestamp subtraction guard. + ctx.env.ledger().with_mut(|li| { + li.timestamp += crate::PROOF_VALIDITY_WINDOW - 1; + }); + + let (_, m) = measure(&ctx.env, "get_fresh_proof (near window boundary)", || { + ctx.client().get_fresh_proof(&id) + }); + assert_within( + "get_fresh_proof", + m, + CEIL_GET_FRESH_PROOF_CPU, + CEIL_GET_FRESH_PROOF_MEM, + ); +} + +#[test] +fn bench_get_authorized_emitter() { + let ctx = Ctx::new(); + let emitter = emitter_addr(&ctx.env, 0xde); + ctx.client().set_authorized_emitter(&2u32, &emitter); + let (_, m) = measure(&ctx.env, "get_authorized_emitter", || { + ctx.client().get_authorized_emitter(&2u32) + }); + assert_within( + "get_authorized_emitter", + m, + CEIL_GET_AUTHORIZED_EMITTER_CPU, + CEIL_GET_AUTHORIZED_EMITTER_MEM, + ); +} + +#[test] +fn bench_get_wormhole_core() { + let ctx = Ctx::new(); + let (_, m) = measure(&ctx.env, "get_wormhole_core", || { + ctx.client().get_wormhole_core() + }); + assert_within( + "get_wormhole_core", + m, + CEIL_GET_WORMHOLE_CORE_CPU, + CEIL_GET_WORMHOLE_CORE_MEM, + ); +} + +// ─── Report test ────────────────────────────────────────────────────────────── + +/// Prints the resource-cost table for `docs/149-satellite-contracts.md` +/// (proof_registry section). +/// +/// Run with: +/// ```text +/// cargo test --features testutils bench -- --nocapture +/// ``` +#[test] +fn resource_cost_report() { + extern crate std; + + std::println!("\n=== proof_registry resource cost (testutils budget) ===\n"); + std::println!("| Entrypoint | CPU insns | Mem bytes | CPU ceil | Mem ceil |"); + std::println!("|---|--:|--:|--:|--:|"); + + { + let ctx = Ctx::new(); + let emitter = emitter_addr(&ctx.env, 0xde); + let (_, m) = measure(&ctx.env, "set_authorized_emitter", || { + ctx.client().set_authorized_emitter(&2u32, &emitter) + }); + std::println!("| `set_authorized_emitter` | {} | {} | {} | {} |", + m.cpu, m.mem, CEIL_SET_AUTHORIZED_EMITTER_CPU, CEIL_SET_AUTHORIZED_EMITTER_MEM); + + let (_, m) = measure(&ctx.env, "get_authorized_emitter", || { + ctx.client().get_authorized_emitter(&2u32) + }); + std::println!("| `get_authorized_emitter` | {} | {} | {} | {} |", + m.cpu, m.mem, CEIL_GET_AUTHORIZED_EMITTER_CPU, CEIL_GET_AUTHORIZED_EMITTER_MEM); + + let (_, m) = measure(&ctx.env, "remove_authorized_emitter", || { + ctx.client().remove_authorized_emitter(&2u32) + }); + std::println!("| `remove_authorized_emitter` | {} | {} | {} | {} |", + m.cpu, m.mem, CEIL_REMOVE_AUTHORIZED_EMITTER_CPU, CEIL_REMOVE_AUTHORIZED_EMITTER_MEM); + } + { + let ctx = Ctx::new(); + let (_, m) = measure(&ctx.env, "get_wormhole_core", || { + ctx.client().get_wormhole_core() + }); + std::println!("| `get_wormhole_core` | {} | {} | {} | {} |", + m.cpu, m.mem, CEIL_GET_WORMHOLE_CORE_CPU, CEIL_GET_WORMHOLE_CORE_MEM); + } + { + let ctx = Ctx::new(); + let chain_id = 2u16; + let emitter = emitter_addr(&ctx.env, 0xaa); + ctx.client().set_authorized_emitter(&(chain_id as u32), &emitter); + let id = intent_id(&ctx.env, 0x01); + let payload = make_payload(&ctx.env, &id, chain_id, 1_000_000_000i128); + let vaa = build_vaa(&ctx.env, chain_id, &emitter, 1u64, &payload); + let (_, m) = measure(&ctx.env, "receive_message (Wormhole VAA)", || { + ctx.client().receive_message(&vaa) + }); + std::println!("| `receive_message` | {} | {} | {} | {} |", + m.cpu, m.mem, CEIL_RECEIVE_MESSAGE_CPU, CEIL_RECEIVE_MESSAGE_MEM); + } + { + let ctx = Ctx::new(); + let id = intent_id(&ctx.env, 0x05); + let now = ctx.env.ledger().timestamp(); + let record = ProofRecord { + intent_id: id.clone(), + src_user: String::from_str(&ctx.env, "0xaabbccddee"), + src_chain_id: 2, + src_token: String::from_str(&ctx.env, "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48"), + src_amount: 1_000_000_000, + vaa_sequence: 42, + received_at: now, + }; + ctx.client().mock_set_proof(&record); + + let (_, m) = measure(&ctx.env, "get_proof", || { ctx.client().get_proof(&id) }); + std::println!("| `get_proof` | {} | {} | {} | {} |", + m.cpu, m.mem, CEIL_GET_PROOF_CPU, CEIL_GET_PROOF_MEM); + + let (_, m) = measure(&ctx.env, "has_proof (present)", || { ctx.client().has_proof(&id) }); + std::println!("| `has_proof` | {} | {} | {} | {} |", + m.cpu, m.mem, CEIL_HAS_PROOF_CPU, CEIL_HAS_PROOF_MEM); + + let (_, m) = measure(&ctx.env, "get_fresh_proof", || { + ctx.client().get_fresh_proof(&id) + }); + std::println!("| `get_fresh_proof` | {} | {} | {} | {} |", + m.cpu, m.mem, CEIL_GET_FRESH_PROOF_CPU, CEIL_GET_FRESH_PROOF_MEM); + } + + std::println!(); +} + +/// Smoke test: measurements are deterministic run to run. +#[test] +fn resource_cost_is_reproducible() { + let run = || { + let ctx = Ctx::new(); + let chain_id = 2u16; + let emitter = emitter_addr(&ctx.env, 0xbb); + ctx.client().set_authorized_emitter(&(chain_id as u32), &emitter); + let id = intent_id(&ctx.env, 0xcc); + let payload = make_payload(&ctx.env, &id, chain_id, 500_000_000i128); + let vaa = build_vaa(&ctx.env, chain_id, &emitter, 99u64, &payload); + measure(&ctx.env, "receive_message (reproducibility)", || { + ctx.client().receive_message(&vaa) + }) + .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/proof_registry/src/lib.rs b/proof_registry/src/lib.rs index dcb4362..b8aac9f 100644 --- a/proof_registry/src/lib.rs +++ b/proof_registry/src/lib.rs @@ -64,6 +64,9 @@ pub const PROOF_VALIDITY_WINDOW: u64 = 3600; #[cfg(test)] mod test; +#[cfg(test)] +mod bench; + // ─── Wormhole Core boundary ────────────────────────────────────────────────── /// The decoded, signature-verified VAA envelope returned by the Wormhole Core diff --git a/reputation_badge/src/bench.rs b/reputation_badge/src/bench.rs new file mode 100644 index 0000000..bbbaf68 --- /dev/null +++ b/reputation_badge/src/bench.rs @@ -0,0 +1,250 @@ +#![cfg(test)] + +//! Resource-budget ceiling assertions for `reputation_badge` (issue #149). +//! +//! Every public entrypoint is exercised from a worst-case fixture (all tiers, +//! sequential mint/burn cycles) and its CPU-instruction and memory-byte cost is +//! asserted not to exceed a published ceiling. +//! +//! ## Ceiling methodology +//! +//! Ceilings = **measured value × 1.10** (10% headroom) rounded up to the +//! nearest 1 000. Regenerate with: +//! +//! ```text +//! cargo test --features testutils bench -- --nocapture +//! ``` +//! +//! ## Worst-case fixtures +//! +//! * `mint_badge` — called for a solver that already has a badge (tier +//! overwrite path), since the write always occurs whether or not the badge +//! previously existed. +//! * `burn_badge` — solver has an existing badge, so the `has` check passes. +//! * `get_badge` — solver has a badge (triggers a storage read rather than +//! returning `None` immediately). +//! +//! See `docs/149-satellite-contracts.md` for the full reference table. + +extern crate std; + +use soroban_sdk::{testutils::Address as _, Address, Env}; + +use crate::{ReputationBadge, ReputationBadgeClient, Tier}; + +// ─── Resource budget ceilings ───────────────────────────────────────────────── +// +// Set to floor(measured × 1.10 / 1_000) × 1_000. +// Regenerate: cargo test --features testutils bench -- --nocapture +// +// Initial estimates from code-path analysis. Pin to real values on first run. +const CEIL_MINT_BADGE_CPU: u64 = 180_000; +const CEIL_MINT_BADGE_MEM: u64 = 28_000; + +const CEIL_BURN_BADGE_CPU: u64 = 160_000; +const CEIL_BURN_BADGE_MEM: u64 = 25_000; + +const CEIL_GET_BADGE_CPU: u64 = 120_000; +const CEIL_GET_BADGE_MEM: u64 = 20_000; + +// ─── Measurement helper ─────────────────────────────────────────────────────── + +#[derive(Clone, Copy, Default, PartialEq, Eq, Debug)] +struct Measurement { + cpu: u64, + mem: u64, +} + +fn measure(env: &Env, label: &str, 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(), + }; + let hint_cpu = ((m.cpu as f64 * 1.10 / 1_000.0).ceil() as u64) * 1_000; + let hint_mem = ((m.mem as f64 * 1.10 / 1_000.0).ceil() as u64) * 1_000; + std::println!( + "CEILING_HINT {label:45} cpu={:>10} mem={:>10} (raw cpu={} mem={})", + hint_cpu, + hint_mem, + m.cpu, + m.mem, + ); + (out, m) +} + +fn assert_within(label: &str, m: Measurement, cpu_ceil: u64, mem_ceil: u64) { + assert!( + m.cpu <= cpu_ceil, + "{label}: CPU {cpu} > ceiling {cpu_ceil} — update CEIL constant and docs/149-satellite-contracts.md", + cpu = m.cpu, + ); + assert!( + m.mem <= mem_ceil, + "{label}: mem {mem} > ceiling {mem_ceil} — update CEIL constant and docs/149-satellite-contracts.md", + mem = m.mem, + ); +} + +// ─── Fixture ───────────────────────────────────────────────────────────────── + +struct Ctx { + env: Env, + admin: Address, + solver: Address, + contract_id: Address, +} + +impl Ctx { + fn new() -> Self { + let env = Env::default(); + env.mock_all_auths(); + + let admin = Address::generate(&env); + let solver = Address::generate(&env); + let contract_id = env.register_contract(None, ReputationBadge); + + let ctx = Ctx { env, admin, solver, contract_id }; + ctx.client().initialize(&ctx.admin); + ctx + } + + fn client(&self) -> ReputationBadgeClient<'_> { + ReputationBadgeClient::new(&self.env, &self.contract_id) + } +} + +// ─── Ceiling assertions ─────────────────────────────────────────────────────── + +/// `mint_badge` — initial mint (no prior badge). +#[test] +fn bench_mint_badge_initial() { + let ctx = Ctx::new(); + let (_, m) = measure(&ctx.env, "mint_badge (initial, Bronze)", || { + ctx.client().mint_badge(&ctx.solver, &Tier::Bronze) + }); + assert_within("mint_badge (initial)", m, CEIL_MINT_BADGE_CPU, CEIL_MINT_BADGE_MEM); +} + +/// `mint_badge` — overwrite (tier upgrade, worst case since the write +/// always happens whether or not the solver had a prior badge). +#[test] +fn bench_mint_badge_overwrite() { + let ctx = Ctx::new(); + ctx.client().mint_badge(&ctx.solver, &Tier::Bronze); + let (_, m) = measure(&ctx.env, "mint_badge (overwrite → Platinum)", || { + ctx.client().mint_badge(&ctx.solver, &Tier::Platinum) + }); + assert_within("mint_badge (overwrite)", m, CEIL_MINT_BADGE_CPU, CEIL_MINT_BADGE_MEM); +} + +/// `burn_badge` — solver has an existing badge (happy path; the `has` check +/// passes and the entry is deleted). +#[test] +fn bench_burn_badge() { + let ctx = Ctx::new(); + ctx.client().mint_badge(&ctx.solver, &Tier::Gold); + let (_, m) = measure(&ctx.env, "burn_badge (Gold)", || { + ctx.client().burn_badge(&ctx.solver) + }); + assert_within("burn_badge", m, CEIL_BURN_BADGE_CPU, CEIL_BURN_BADGE_MEM); +} + +/// `get_badge` — solver has a Platinum badge (forces a storage read). +#[test] +fn bench_get_badge_present() { + let ctx = Ctx::new(); + ctx.client().mint_badge(&ctx.solver, &Tier::Platinum); + let (_, m) = measure(&ctx.env, "get_badge (Platinum, present)", || { + ctx.client().get_badge(&ctx.solver) + }); + assert_within("get_badge (present)", m, CEIL_GET_BADGE_CPU, CEIL_GET_BADGE_MEM); +} + +/// `get_badge` — solver has no badge (returns `None`; cheaper storage miss +/// path, but share the same ceiling). +#[test] +fn bench_get_badge_absent() { + let ctx = Ctx::new(); + let (_, m) = measure(&ctx.env, "get_badge (absent / Unranked)", || { + ctx.client().get_badge(&ctx.solver) + }); + // Same ceiling — the absent path is cheaper, so this asserts a looser + // bound and will always pass if the present-badge test passes. + assert_within("get_badge (absent)", m, CEIL_GET_BADGE_CPU, CEIL_GET_BADGE_MEM); +} + +// ─── Report test ────────────────────────────────────────────────────────────── + +/// Prints the resource-cost table for `docs/149-satellite-contracts.md` +/// (reputation_badge section). +/// +/// Run with: +/// ```text +/// cargo test --features testutils bench -- --nocapture +/// ``` +#[test] +fn resource_cost_report() { + extern crate std; + + std::println!("\n=== reputation_badge resource cost (testutils budget) ===\n"); + std::println!("| Entrypoint | CPU insns | Mem bytes | CPU ceil | Mem ceil |"); + std::println!("|---|--:|--:|--:|--:|"); + + { + let ctx = Ctx::new(); + let (_, m) = measure(&ctx.env, "mint_badge (initial, Bronze)", || { + ctx.client().mint_badge(&ctx.solver, &Tier::Bronze) + }); + std::println!("| `mint_badge (initial)` | {} | {} | {} | {} |", + m.cpu, m.mem, CEIL_MINT_BADGE_CPU, CEIL_MINT_BADGE_MEM); + + let (_, m) = measure(&ctx.env, "mint_badge (overwrite → Platinum)", || { + ctx.client().mint_badge(&ctx.solver, &Tier::Platinum) + }); + std::println!("| `mint_badge (overwrite)` | {} | {} | {} | {} |", + m.cpu, m.mem, CEIL_MINT_BADGE_CPU, CEIL_MINT_BADGE_MEM); + + let (_, m) = measure(&ctx.env, "get_badge (Platinum, present)", || { + ctx.client().get_badge(&ctx.solver) + }); + std::println!("| `get_badge (present)` | {} | {} | {} | {} |", + m.cpu, m.mem, CEIL_GET_BADGE_CPU, CEIL_GET_BADGE_MEM); + + let (_, m) = measure(&ctx.env, "burn_badge (Platinum)", || { + ctx.client().burn_badge(&ctx.solver) + }); + std::println!("| `burn_badge` | {} | {} | {} | {} |", + m.cpu, m.mem, CEIL_BURN_BADGE_CPU, CEIL_BURN_BADGE_MEM); + } + { + let ctx = Ctx::new(); + let (_, m) = measure(&ctx.env, "get_badge (absent / Unranked)", || { + ctx.client().get_badge(&ctx.solver) + }); + std::println!("| `get_badge (absent)` | {} | {} | {} | {} |", + m.cpu, m.mem, CEIL_GET_BADGE_CPU, CEIL_GET_BADGE_MEM); + } + + std::println!(); +} + +/// Smoke test: measurements are deterministic run to run. +#[test] +fn resource_cost_is_reproducible() { + let run = || { + let ctx = Ctx::new(); + ctx.client().mint_badge(&ctx.solver, &Tier::Silver); + measure(&ctx.env, "burn_badge (reproducibility)", || { + ctx.client().burn_badge(&ctx.solver) + }) + .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/reputation_badge/src/lib.rs b/reputation_badge/src/lib.rs index 63128c8..db0ea0c 100644 --- a/reputation_badge/src/lib.rs +++ b/reputation_badge/src/lib.rs @@ -17,6 +17,9 @@ use soroban_sdk::{contract, contracterror, contractimpl, contracttype, panic_wit #[cfg(test)] mod test; +#[cfg(test)] +mod bench; + // ─── Storage Keys ───────────────────────────────────────────────────────────── #[contracttype]