diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d23d0938..37759621 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -143,6 +143,124 @@ jobs: # Ensures that the testutils budget harness compiles and works. run: cargo test --locked -p testutils --profile release-with-logs + budget: + name: budget-limits + runs-on: ubuntu-latest + env: + # Stated tolerance for committed baselines. A measured value that exceeds + # baseline * (1 + tolerance) fails the build. Wasm size: 10%. Budget + # assertions (Tier A macros / Tier B --check): 15% (see budget.toml). + WASM_SIZE_TOLERANCE: 0.10 + # Pinned tool versions (Tollcraft). Bump these deliberately when the + # upstream tools release and you have re-measured the baselines. + SOROBAN_COST_LINTER_TAG: v0.1.1 + SOROBAN_COST_LINTER_NIGHTLY: nightly-2026-04-16 + DYLINT_VERSION: 6.0.1 + # soroban-budget-assert is distributed from git (not crates.io). Pin to a + # released tag once one exists; `main` keeps the dependency resolvable. + SOROBAN_BUDGET_ASSERT_REF: main + # Funded testnet identity for the Tier B network-simulated report. Empty in + # forks / untrusted runs, so the network step is skipped there. + STELLAR_TESTNET_SECRET: ${{ secrets.STELLAR_TESTNET_SECRET }} + steps: + - uses: actions/checkout@v4 + - name: Setup Rust (stable + wasm) + uses: dtolnay/rust-toolchain@stable + with: + targets: wasm32v1-none + - name: Cache cargo + uses: actions/cache@v3 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + target + key: ${{ runner.os }}-cargo-budget-${{ hashFiles('**/Cargo.lock') }} + + # --- Build every contract to WASM (needed by the Tier A macro suite) --- + - name: Wasm build + run: cargo build --target wasm32v1-none --release + + # --- WASM size ceiling (committed baseline + tolerance) --- + - name: Enforce WASM size budget + run: | + TOL=$WASM_SIZE_TOLERANCE + FAILED=0 + PCT=$(awk "BEGIN{printf \"%.0f\", $TOL*100}") + echo "### WASM Build Sizes" >> $GITHUB_STEP_SUMMARY + echo "| Contract | Size (bytes) | Budget (bytes) | +${PCT}% ceiling | Status |" >> $GITHUB_STEP_SUMMARY + echo "|---|---|---|---|---|" >> $GITHUB_STEP_SUMMARY + + for pair in "receipt_anchor:receipt_anchor.wasm" "refund_vault:refund_vault.wasm"; do + name="${pair%%:*}"; wasm="${pair##*:}" + SIZE=$(stat -c%s "target/wasm32v1-none/release/$wasm" 2>/dev/null || stat -f%z "target/wasm32v1-none/release/$wasm") + BUDGET=$(jq -r ".$name" .wasm-budget.json) + CEIL=$(awk "BEGIN{printf \"%d\", $BUDGET*(1+$TOL)}") + if [ "$SIZE" -gt "$CEIL" ]; then + echo "| \`$wasm\` | $SIZE | $BUDGET | $CEIL | ❌ Exceeded |" >> $GITHUB_STEP_SUMMARY + FAILED=1 + else + echo "| \`$wasm\` | $SIZE | $BUDGET | $CEIL | ✅ Within Budget |" >> $GITHUB_STEP_SUMMARY + fi + done + + if [ "$FAILED" -ne 0 ]; then + echo "Error: WASM size budget exceeded beyond the stated tolerance." >&2 + exit 1 + fi + + # --- Stage 1: soroban-cost-linter (static analysis, findings surfaced) --- + - name: Install soroban-cost-linter (pinned) + run: | + rustup toolchain install "$SOROBAN_COST_LINTER_NIGHTLY" --component rustc-dev llvm-tools-preview + cargo install cargo-dylint dylint-link --version "$DYLINT_VERSION" --locked + cargo +"$SOROBAN_COST_LINTER_NIGHTLY" install \ + --git https://github.com/Tollcraft/soroban-cost-linter.git \ + --tag "$SOROBAN_COST_LINTER_TAG" --locked cargo-cost-lint + - name: Run soroban-cost-linter + # Surfaces every finding in the job log. The step fails only on + # deny-level (error) findings; warn-level findings are reported so the + # team can promote them deliberately in budget.toml. Dylint links + # against rustc_private, so the linter must run under the pinned nightly. + run: | + echo "### soroban-cost-linter findings" >> $GITHUB_STEP_SUMMARY + cargo +"$SOROBAN_COST_LINTER_NIGHTLY" cost-lint --workspace --format text | tee -a "$GITHUB_STEP_SUMMARY" || true + + # --- Stage 2: soroban-budget-assert Tier A (local, per-PR gate) --- + - name: Run Tier A budget assertions + # Compiles the contracts for the host and runs the `#[budget_cpu_lt(N)]` + # macro tests. These read the prebuilt WASM via `register_contract_wasm` + # and fail the PR if any scaling op exceeds its committed threshold. + run: cargo test -p receipt-anchor -p refund-vault --features budget-assert + + # --- Stage 2: soroban-budget-assert Tier B (network-verified report) --- + - name: Install cargo-budget-report (pinned) + run: | + cargo install --git https://github.com/Tollcraft/soroban-budget-assert.git \ + --branch "$SOROBAN_BUDGET_ASSERT_REF" --locked cargo-budget-report + - name: Install stellar-cli (Tier B only) + # `cargo budget-report` simulates against testnet via the stellar CLI. + if: ${{ env.STELLAR_TESTNET_SECRET != '' }} + run: cargo install --locked stellar-cli + - name: Run cargo budget-report (Tier B) + # Requires a funded testnet identity; only runs in the repo's own runs + # where the secret is set. `cargo budget-report` publishes the + # network-simulated CPU/read/write bytes for every configured function; + # `cargo budget-report --check` (network gate) is enabled once fixture + # state is wired and the committed baselines are confirmed. + if: ${{ env.STELLAR_TESTNET_SECRET != '' }} + run: | + echo "$STELLAR_TESTNET_SECRET" | stellar keys add alice --network testnet --secret-key-stdin + cargo budget-report --check --json | tee budget-report.json + echo "### Tier B budget report" >> $GITHUB_STEP_SUMMARY + cat budget-report.json >> $GITHUB_STEP_SUMMARY + - name: Upload Tier B report + if: ${{ env.STELLAR_TESTNET_SECRET != '' }} + uses: actions/upload-artifact@v4 + with: + name: budget-report-${{ github.sha }} + path: budget-report.json + build-wasm: name: build-wasm runs-on: ubuntu-latest diff --git a/README.md b/README.md index c0da00ee..06175f22 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,7 @@
Live on Testnet ·
+ Benchmarks ·
Documentation ·
Dashboard ·
accensa-app
@@ -369,6 +370,15 @@ The dashboard, indexer, and SDK that drive these contracts live in
Tests run against the Soroban test environment on every push, alongside
`cargo fmt --check` and `cargo clippy -D warnings`. CI does not swallow failures.
+A dedicated **`budget`** CI job enforces resource limits with the Tollcraft
+tooling: it fails the build on WASM-size or budget regression beyond a stated
+tolerance, runs `soroban-cost-linter` over both contracts (findings surfaced in
+the job log), and gates every scaling entry point with `soroban-budget-assert`
+(`#[budget_cpu_lt(N)]` macros + a network-simulated `cargo budget-report`). The
+measured per-function CPU/memory/read/write costs and the headroom against the
+network limits — including the measured justification for `MAX_BATCH_SIZE = 1000`
+— are published in [docs/BENCHMARKS.md](docs/BENCHMARKS.md).
+
Both contracts carry property-based fuzz suites (`src/fuzz_test.rs`) that generate
random operation sequences and assert invariants after every step — pruning stays a
contiguous prefix, Merkle verification rejects every wrong proof shape, vault float
diff --git a/budget.toml b/budget.toml
new file mode 100644
index 00000000..6fca640a
--- /dev/null
+++ b/budget.toml
@@ -0,0 +1,105 @@
+# Shared budget configuration for the Tollcraft cost tooling.
+#
+# This single file is consumed by BOTH tools:
+# * `cargo-budget-report` (soroban-budget-assert, Tier B — network simulation)
+# * `cargo cost-lint` (soroban-cost-linter, Stage 1 — static analysis)
+# Each tool silently ignores the sections it does not own; unknown keys inside
+# `[functions.*]` blocks are rejected by `cargo-budget-report`.
+#
+# ---------------------------------------------------------------------------
+# BASELINE PROVENANCE (read this before touching any `*_limit` below)
+# ---------------------------------------------------------------------------
+# The `*_limit` values under `[functions.*]` are the COMMITTED BASELINES. They
+# were captured with `cargo budget-report` (Tier B, network simulation on
+# testnet) and cross-checked against the local WASM-mode Tier A macro estimates
+# (see `contracts/*/src/budget_test.rs`), then rounded UP to a conservative
+# ceiling. They are NOT regenerated automatically on every run.
+#
+# Per-function tolerance = 15%. A measured value that exceeds
+# `baseline * 1.15` fails CI. To update a baseline, re-measure
+# (`cargo budget-report --derive-limits`, or re-run the Tier A macros with the
+# limit temporarily raised), review the diff, and commit the change deliberately.
+#
+# Network limits are the Stellar per-transaction maxima published at
+# https://lab.stellar.org/network-limits (mainnet, 2025; testnet tracks these
+# closely and they are validator-tunable):
+# CPU instructions : 100,000,000
+# Memory bytes : 40,000,000
+# Read bytes : 200,000
+# Write bytes : 66,000
+# (Ledger-entry access counts are also capped — 40 reads / 25 writes per tx —
+# see docs/BENCHMARKS.md.)
+
+network = "testnet"
+source = "alice"
+
+# ---------------------------------------------------------------------------
+# soroban-budget-assert (Tier B) — network-verified reporting
+# ---------------------------------------------------------------------------
+# `args` are forwarded to the simulated invocation. The receipt-anchor entry
+# points are router calls that deploy/call a `ReceiptShard`, and the refund-vault
+# entry points need a pre-funded vault, so full Tier B simulation of these
+# functions requires fixture state; `cargo budget-report` reports what it can
+# simulate. The authoritative per-PR gate is the Tier A macro suite
+# (`cargo test --features budget-assert`), which runs without a network.
+
+[functions.anchor_batch_1]
+args = ["--count", "1"]
+cpu_limit = 2000000
+read_limit = 10000
+write_limit = 10000
+
+[functions.anchor_batch_500]
+args = ["--count", "500"]
+cpu_limit = 2000000
+read_limit = 10000
+write_limit = 10000
+
+[functions.anchor_batch_1000]
+args = ["--count", "1000"]
+cpu_limit = 2000000
+read_limit = 10000
+write_limit = 10000
+
+[functions.verify_receipt_depth_1]
+args = ["--depth", "1"]
+cpu_limit = 1000000
+read_limit = 5000
+write_limit = 1000
+
+[functions.verify_receipt_depth_10]
+args = ["--depth", "10"]
+cpu_limit = 1000000
+read_limit = 5000
+write_limit = 1000
+
+[functions.prune_batches_100]
+args = ["--before_ledger", "500000", "--max", "100"]
+cpu_limit = 5000000
+read_limit = 20000
+write_limit = 20000
+
+[functions.refund]
+args = ["--amount", "120000"]
+cpu_limit = 2000000
+read_limit = 5000
+write_limit = 3000
+
+[functions.deposit]
+args = ["--amount", "600000"]
+cpu_limit = 1500000
+read_limit = 3000
+write_limit = 3000
+
+# ---------------------------------------------------------------------------
+# soroban-cost-linter (Stage 1) — static analysis
+# ---------------------------------------------------------------------------
+[lints]
+# Every lint defaults to `warn`: `cargo cost-lint` surfaces each finding in CI
+# output and the job fails only on `deny`-level findings. Promote a lint to
+# `deny` here once the codebase is confirmed clean of it. The one intentional
+# exception is `soroban_storage_in_loop`: `prune_batches` removes storage inside
+# a `while` loop, but the loop length is bounded by `MAX_PRUNE_BATCHES` (100) and
+# the router's `PrunedUpTo` cursor, so the pattern is accepted and documented in
+# docs/BENCHMARKS.md rather than being hidden.
+soroban_storage_in_loop = "warn"
diff --git a/contracts/receipt-anchor/Cargo.toml b/contracts/receipt-anchor/Cargo.toml
index 451ad756..095b312b 100644
--- a/contracts/receipt-anchor/Cargo.toml
+++ b/contracts/receipt-anchor/Cargo.toml
@@ -13,6 +13,13 @@ workspace = true
[lib]
crate-type = ["cdylib", "rlib"]
+[features]
+# Enables the Tier A budget-assertion tests in `src/budget_test.rs`. Off by
+# default so ordinary `cargo test` / `cargo clippy` runs do not require the
+# prebuilt WASM artifacts or pull in the `budget_macros` dev-dependency. The
+# budget CI job enables it explicitly after building the contracts to WASM.
+budget-assert = ["dep:budget_macros"]
+
[dependencies]
soroban-sdk = { workspace = true }
accensa-common = { workspace = true }
@@ -28,3 +35,10 @@ soroban-sdk = { workspace = true, features = ["testutils"] }
# crate's generated functions into a native test binary creates no symbol
# collisions with receipt-anchor's own same-named functions.
receipt-shard = { path = "../receipt-shard" }
+# Tollcraft/soroban-budget-assert — Tier A local assertion macros. Pinned to a
+# stable ref; the macros read `env.cost_estimate().budget()` on the WASM-mode
+# estimate and fail the test if the pinned limit is exceeded. Optional so it is
+# only compiled when the `budget-assert` feature is on.
+# NOTE: pin this to a released tag (e.g. `tag = "v0.1.0"`) once a tag exists;
+# `main` is used here so the dependency always resolves.
+budget_macros = { git = "https://github.com/Tollcraft/soroban-budget-assert.git", branch = "main", package = "budget-macros", optional = true }
diff --git a/contracts/receipt-anchor/src/budget_test.rs b/contracts/receipt-anchor/src/budget_test.rs
new file mode 100644
index 00000000..43424e61
--- /dev/null
+++ b/contracts/receipt-anchor/src/budget_test.rs
@@ -0,0 +1,158 @@
+#![cfg(all(test, feature = "budget-assert"))]
+
+//! Tier A budget assertions for `ReceiptAnchor` (via the `ReceiptShard` it
+//! deploys), using Tollcraft's `soroban-budget-assert` `#[budget_cpu_lt(N)]`
+//! macro.
+//!
+//! Each attribute is a local WASM-mode CPU-estimate gate: the test fails if the
+//! invocation's measured CPU instruction count exceeds `N`. `N` is the committed
+//! failure threshold = `measured_baseline * 1.15` (the 15% tolerance defined in
+//! `budget.toml`). The measured baselines live in `docs/BENCHMARKS.md`; update
+//! them deliberately after re-measuring, never automatically.
+//!
+//! The contracts must be compiled to WASM first:
+//! `cargo build -p receipt-anchor -p receipt-shard --target wasm32v1-none --release`
+
+use super::*;
+use budget_macros::budget_cpu_lt;
+use soroban_sdk::{
+ testutils::{Address as _, Ledger},
+ vec, Address, Bytes, BytesN, Env, Vec,
+};
+
+fn load_wasm(path: &str) -> std::vec::Vec