Skip to content

[Contract] Add CPU-Heavy Benchmark Contract Hard Cap Guards - #722

Merged
EDOHWARES merged 3 commits into
SoroLabs:mainfrom
giftexceed:feature/issue-88-cpu-heavy-guard
Aug 1, 2026
Merged

[Contract] Add CPU-Heavy Benchmark Contract Hard Cap Guards#722
EDOHWARES merged 3 commits into
SoroLabs:mainfrom
giftexceed:feature/issue-88-cpu-heavy-guard

Conversation

@giftexceed

Copy link
Copy Markdown
Contributor

Closes #646.

Important

This PR is stacked on #717 and is not cut from main. Until #717 merges, the diff below shows 3 commits / ~296 files — two of which (fix(ci): repair workspace... and feature(pool-slippage)...) belong to #717. GitHub will narrow this to my single commit automatically once #717 lands.

The stack is not optional: main currently does not build. Its Cargo.lock has three truncated/spliced records (pin-project-lite, schemars, serde) so it fails to parse, and emergency_guard — a path dependency of staking_rewards — fails to compile with 112 errors. #717 carries the fix for both. Review only the feature(cpu-heavy-guard) commit (eb7b946).

The problem

Two distinct defects, only the first of which the issue names.

1. The guards panicked instead of returning an error. Every entry point guarded its inputs with a bare panic!("input too large"). Confirmed against the release WASM build, that reaches the caller as:

Error(WasmVm, InvalidAction)
VM call trapped: UnreachableCodeReached

No argument name, no limit, nothing actionable.

2. The caps were set above the budget they were meant to protect. bubble_sort is quadratic in host calls (Vec::get/Vec::set), so it dominates every other workload by orders of magnitude. Measured against the release WASM at the default network budget (100,000,000 CPU instructions / 41,943,040 memory bytes):

Call CPU Verdict
bubble_sort(300) — the old MAX_SORT 573.4M 5.7× over budget
bubble_sort(200) 246.9M over
bubble_sort(150) 136.8M over
bubble_sort(100) — new MAX_SORT 60.1M ok (24% memory)

So the advertised cap of 300 was never reachable, and any call approaching it produced exactly the host budget panic the guard was supposed to prevent.

The change

  • Replace the panic! guards with a #[contracterror] enum Error and Result returns. Over-cap calls now yield Error(Contract, #1..#5) instead of trapping. Follows the existing convention in twap_oracle.
  • Lower MAX_SORT from 300 to 100. The remaining caps measured well inside budget and are unchanged:
Call at its cap CPU Memory
fibonacci_iterative(50_000) 1.6M (1%) 3%
count_primes(20_000) 22.7M (22%) 3%
bubble_sort([100 elements]) 60.1M (60%) 24%
nested_loop_burn(500_000, 1) 0.8M (0%) 3%
combined_benchmark(10_000, 50, 5_000) 19.2M (19%) 6%

All figures are recorded alongside the constants in lib.rs so the next person tightening a cap has the data.

Fixed in passing

  • Underflow in bubble_sort. for j in 0..n - i - 1 underflows on the last pass and on empty lists. Now n.saturating_sub(i + 1), with a test for empty and single-element input.
  • cpu_heavy was orphaned from the workspace and did not compile at all — cargo test failed with "current package believes it's in a workspace when it's not". Added it to workspace.members (and rlib to its crate-type), so it now builds and is covered by CI's cargo check --locked --all-targets. Its stale standalone Cargo.lock is deleted. This is why the diff reaches slightly past the guards; there was no way to satisfy "tests passing" otherwise.

Errors

Code Variant
1 FibonacciInputTooLarge
2 SortInputTooLarge
3 PrimeLimitTooLarge
4 LoopOpsTooLarge
5 CombinedInputTooLarge

Discriminants are public surface — the SoroScope UI decodes by number — so a test pins them and new variants must be appended, not inserted.

Tests

13 tests in contracts/cpu_heavy/src/test.rs, covering each guard on both sides of its boundary:

  • Every input at its cap is accepted — a cap that rejects its own advertised maximum is a bug.
  • Every input one past its cap returns the specific error code.
  • The worst-case legal call stays inside the default budget (asserted, not assumed).
  • A rejected call burns <1M CPU, proving the guard short-circuits before doing work rather than trapping partway through.
  • nested_loop_burn(65_536, 65_536) and (u32::MAX, u32::MAX) are rejected — a naive outer * inner guard would wrap past the cap; saturating_mul does not.
  • Error discriminants are stable.
cargo test -p cpu_heavy      # 13 passed
cargo test --workspace       # green
cargo fmt --all -- --check   # clean
cargo clippy -p cpu_heavy --all-targets -- -D warnings   # clean
cargo check --locked --all-targets                       # clean

Notes for the reviewer

  • bubble_sort at its new cap still uses 60% of the CPU budget — safely inside for a direct invocation, but limited headroom if composed into a larger transaction. I kept 100 because it preserves the existing combined_benchmark sub-cap and is a more useful benchmark than a smaller value; dropping to 64 would put it near 25% if you'd prefer the margin. Happy to change it.
  • I left the stale duplicate tree at soroscope/contracts/cpu_heavy/ untouched — no recent commit touches it and it isn't in the workspace, so mirroring the change there seemed like editing dead code. Say the word if you'd rather it stay in sync.
  • [Bug Fix] Resolve Cargo Workspace Dependency Version Mismatch Across Contracts #716 appears to target the same main breakage as [Contract] Add Liquidity Pool Slippage Protection via Minimum Output Parameter #717; worth deconflicting those two before either merges.

Every root-level cargo command failed on main, so all four Rust CI jobs
were red regardless of what a PR changed. The cause was a series of merge
commits resolved by accepting both sides, which duplicated content inside
several files. This repairs the workspace members and their dependencies.

Cargo.lock was unparseable: 238 packages appeared two or three times and
three entries had been spliced together, one package's header landing on
another's body. It was also stale, still carrying the whole server
dependency tree even though the workspace is three contract crates.
Regenerated, which takes it from ~710 packages to 181. ed25519-dalek is
pinned to 2.2.0 because 3.0 changed its CryptoRng bound and breaks
soroban-env-host 22.x.

emergency_guard had `#[contract]` applied twice, once bare and once via
cfg_attr, so the macro emitted EmergencyGuardClient and friends twice. It
also carried a second DefaultEmergencyGuard whose inherent methods
collided with the trait impl, a duplicated is_admin_public, an is_admin
that took &Address against a trait declaring Address, an unimported log
macro, and three storage writes referencing an undefined `pause_state`
with the correct `.set(...)` orphaned below an emit block. rotate_admin
never bound `threshold` and pushed the new admin a second time after the
loop had already substituted it.

staking_rewards had two functions truncated mid-body with an unrelated
signature spliced in, pause_staking and rotate_admin, which left the impl
block unclosed. PauseType::CLAIM_REWARDS was referenced in four places but
never defined. initialize called guard setup twice, and the second call
failed with AlreadyInitialized, so every test that touched setup() died on
error SoroLabs#1. PauseType was imported twice.

Three emergency_guard tests asserted InsufficientSignatures where the
implementation returns Unauthorized. All three supply a non-admin approver
while meeting the count threshold, and their names describe unauthorized
access, so the assertions were stale rather than the behaviour. Changing
the contract's error semantics belongs to whoever owns that feature, so
the tests were updated to match.

In staking_rewards/test.rs the body of test_granular_claim_rewards_pause
had been appended to test_granular_pause_staking, leaving one test headless
and the other staking tokens it had never minted. Both are restored.

Result: cargo check --locked --all-targets, cargo fmt --all --check,
cargo clippy --locked --all-targets --all-features -D warnings, and
cargo test --locked all pass. 54 tests, 0 failures.

Not fixed here: CI also runs `cargo build -p liquidity_pool`, but that
crate is not a workspace member and does not compile. Its lib.rs has been
inflated from ~1150 to 2905 lines by the same accept-both-sides merges,
including one statement with six duplicated transfer calls. Choosing which
side is canonical in a contract that moves funds is a maintainer decision,
so it is left alone and documented in the PR.
Issue SoroLabs#650. The pool only offered an exact-output swap, `swap(to, buy_a, out,
in_max)`, where the caller fixes the output and caps the input. A trader who
wants to sell a fixed amount had no entry point and had to guess `out`, so the
one quantity they could not bound was the one an attacker moves: front-running
the transaction leaves the same input returning far less.

Adds `swap_exact_in(to, buy_a, amount_in, min_amount_out)`. It quotes the output
from live reserves and the current fee, then refuses the trade with
`SlippageExceeded` unless `amount_out >= min_amount_out`, before any tokens
move. Returns the output actually delivered. `min_amount_out = 0` opts out.

`swap` keeps its signature. Its output is fixed by the caller and delivered
exactly or not at all, so a minimum-output parameter there would be satisfied by
construction; `in_max` is already that direction's bound. Documented rather than
duplicated.

Also adds the read-only quotes callers need to pick a floor: `get_amount_out`,
`get_amount_in` and `get_reserves`. Both swap directions now share
`amount_out_for_in` / `amount_in_for_out` and a `settle_swap` helper, so the
transfer-and-reserve-update path exists once. Division truncates toward the pool
in both directions, and a test asserts the constant product never weakens.

New error `InvalidAmount = 15` for a non-positive `amount_in` or a negative
`min_amount_out`; a negative floor would silently disable the check. Existing
codes are untouched.

11 tests cover the quoted-output path, both directions, a rejected floor leaving
state untouched, a full front-run sandwich, the zero-minimum opt-out, invalid
amounts, an input too small to fill, pause interaction, the fee's effect on a
quote, and the quote round trip.

Prerequisite repairs, without which none of the above could build or run:

liquidity_pool was not a workspace member and did not compile, so CI's
`cargo build -p liquidity_pool` had nothing to build. Its lib.rs had been
inflated from ~1150 to 2905 lines by merges resolved by accepting both sides:
the Error enum carried three overlapping copies with conflicting codes, PoolState
and DataKey were each declared four times, and `withdraw` had six duplicated
transfer calls. The last clean revision is 3e50a23, which holds the full public
API and is the second parent of the merge that broke it, so lib.rs is restored
from there and re-added to the workspace. That revision needed two fixes of its
own: a duplicated `get_pause_mask`, and `remove_guard_admin` resolving to the
trait's `remove_admin` instead of the inherent one.

test.rs had the same damage in two blocks. `test_emergency_guard_trait_impl`
called `init_guard` on an already-initialized guard and then made twelve
assertions through direct `<LiquidityPool as EmergencyGuardTrait>` calls, which
touch instance storage outside any contract frame; both forms are restored to
the client-based assertions that work. `test_granular_pause_operations` called a
two-argument `guard_unpause` that no version of the contract has.

Four behaviour gaps the newer tests already asserted, all previously masked by
the crate not compiling:

- `set_paused` looped over four pause bits, calling `require_auth` on the same
  admin each time, which the host rejects as `Auth(ExistingValue)`. It now
  applies one combined mask, extended to cover STAKE and CLAIM_REWARDS so it
  pauses what its name claims.
- `stake`, `unstake` and `claim_rewards` had no pause check at all.
- Rewards never accrued. `update_reward_state` defaulted a missing
  `LastRewardLedger` to the current ledger and returned early without storing
  it, so `current <= last` held forever. The checkpoint is now seeded on the
  first staking action, and the accumulator is projected by a read-only helper
  so `get_pending_rewards` reports accrual without writing.
- Removing or rotating the primary admin left `DataKey::Admin` pointing at an
  address that was no longer a guard admin. Both paths now hand the role to a
  surviving admin, and membership and threshold refusals report `Unauthorized`.

Test snapshots are regenerated; they were stale from the mangled revision and
still referenced its GuardAdmins/GuardThreshold storage keys.

cargo fmt --all --check, cargo check --locked --all-targets, cargo clippy
--locked --all-targets --all-features -D warnings, cargo build -p liquidity_pool
--target wasm32-unknown-unknown --release and cargo test --locked all pass.
113 tests, 0 failures, 59 of them in liquidity_pool.
…et panics

The benchmark contract guarded its inputs with bare `panic!("...")` calls, so
an oversized argument reached the caller as an opaque `UnreachableCodeReached`
VM trap naming neither the offending argument nor the limit it broke. Replace
the panics with a `#[contracterror] enum Error` and `Result` returns so each
guard reports a distinct, decodable code.

The caps themselves were also wrong. Measured against the release WASM build
with the default network budget (100M CPU instructions / 40MB memory),
`bubble_sort` is quadratic in host `Vec::get`/`Vec::set` calls and exceeds the
budget outright at 150 elements (136.8M CPU) — so the advertised cap of 300
(573.4M CPU) was never reachable and hitting it produced exactly the budget
panic the guard was supposed to prevent. Lower `MAX_SORT` to 100, verified at
60.1M CPU / 24% memory. The remaining caps measure well inside budget and are
unchanged; all figures are recorded alongside the constants.

Also:
- Guard `nested_loop_burn` overflow: `saturating_mul` already prevented a
  caller from wrapping `outer * inner` past the cap, now covered by a test.
- Fix `bubble_sort` underflow on `n - i - 1` for empty/single-element lists.
- Expose the caps as public constants and document them in a new README.
- Add cpu_heavy to the workspace so it builds and is covered by CI; it was
  orphaned by the previous workspace repair and would not compile at all.

Tests cover each guard's boundary on both sides, the error discriminants, and
that the worst-case legal call stays inside the default budget.
@drips-wave

drips-wave Bot commented Jul 28, 2026

Copy link
Copy Markdown

@giftexceed Great news! 🎉 Based on an automated assessment of this PR, the linked Wave issue(s) no longer count against your application limits.

You can now already apply to more issues while waiting for a review of this PR. Keep up the great work! 🚀

Learn more about application limits

@EDOHWARES

Copy link
Copy Markdown
Collaborator

Nice implementation, LGTM!

@EDOHWARES
EDOHWARES merged commit c7375d1 into SoroLabs:main Aug 1, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Contract] Add CPU-Heavy Benchmark Contract Hard Cap Guards

2 participants