Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 51 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
155 changes: 155 additions & 0 deletions docs/149-intent-settlement.md
Original file line number Diff line number Diff line change
@@ -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`.*
151 changes: 151 additions & 0 deletions docs/149-satellite-contracts.md
Original file line number Diff line number Diff line change
@@ -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).*
Loading
Loading