End-User Guide
This guide is for Soroban developers who want budget assertions in an existing contract workspace. The workflow: measure real costs once (Tier B), then pin them into tests that run on every CI push (Tier A).
Prerequisites
- Rust with the
wasm32v1-nonetarget (rustup target add wasm32v1-none) - The
stellarCLI - A funded testnet identity:
stellar keys generate alice --network testnet --fund
Step 1: Install the CLI
From this repository's root:
cargo install --path cargo-budget-reportStep 2: Configure your workspace
Create budget.toml in your workspace root. Supply arguments for any contract function that requires them — functions are discovered and simulated automatically, but the tool can't invent argument values:
network = "testnet"
source = "alice"
[functions.do_expensive_work]
args = ["--n", "10000"]Add the release profile used for reproducible Soroban cost measurements to the same workspace root Cargo.toml:
[profile.release]
opt-level = "z"
overflow-checks = true
debug = 0
strip = "symbols"
debug-assertions = false
panic = "abort"
codegen-units = 1
lto = trueWARNING
The release profile is part of the measurement. cargo budget-report builds the WASM with --release, and these settings change the binary that is deployed, simulated, and loaded by local WASM tests. Size optimization, LTO, and single-codegen-unit builds change generated instructions; aborting panics removes unwinding code; stripping symbols and disabling debug info change artifact size; release assertions match production behavior; and overflow checks keep arithmetic checks explicit. Numbers measured under another profile are not comparable to this project's published figures.
Step 3: Measure network resource usage
cargo budget-reportThe CLI finds every contract in the workspace, builds it to WASM, deploys to testnet, simulates every exported function, and prints one table of CPU instructions, read bytes, and write bytes. Use --json if you want to feed the numbers to a script.
If this step fails partway through — friendbot funding, deploy, or simulation — see the Testnet Troubleshooting Guide for what each failure means and how to resolve it.
DANGER
This command deploys a contract and funds a source account. Against testnet, futurenet, or a local network that costs nothing. If network in budget.toml (or --network) resolves to Stellar Mainnet, the run stops before building anything and tells you so — deploying there would spend real funds. An unrecognised network is refused the same way rather than assumed safe. Pass --allow-mainnet only if you deliberately mean to target such a network. See Mainnet guard in the Tool Reference.
WARNING
This is not your transaction fee. The three metrics are inputs to the non-refundable resource fee; rent, refundable fees, transaction size, footprint entry counts, and the inclusion fee are not measured. If you are budgeting what users will actually pay — especially for a contract that writes persistent state, where rent often dominates — read Measurement scope first.
Step 4: Pin the costs into tests
Add the macro crate to your contract's dev-dependencies, then gate a test. The macro asserts the local WASM estimate, so set the limit from a local measurement: run the test once unlimited, note the printed cost, and pin ~5% above it. Keep the Step 3 network number alongside it in a comment — local and network costs can differ by double-digit percentages in either direction, and the network number is the one that decides whether your transaction succeeds. For detailed guidance on choosing safety margins and understanding operational gaps, see Local vs. Network Cost Gap.
use budget_macros::budget_cpu_lt;
use soroban_sdk::Env;
#[test]
#[budget_cpu_lt(950000)] // local WASM ~901,816; testnet ~756,678
fn test_expensive_function_budget() {
let env = Env::default();
let wasm = std::fs::read(
"../target/wasm32v1-none/release/my_contract.wasm",
)
.expect("WASM file not found — build the contract first");
// `Env::register` accepts raw WASM bytes: `Register` is implemented for
// `&[u8]` in soroban-sdk 22.x, and it drives the same host path the
// deprecated `register_contract_wasm` used. Raw WASM registration (rather
// than linked-in Rust) is required for accurate CPU/memory budget
// measurements, since Rust-level estimates undercount costs.
let contract_id = env.register(wasm.as_slice(), ());
// Replace `MyContractClient` with the generated client type for your
// contract, e.g. `MyContractClient::new(&env, &contract_id)`.
let _client = MyContractClient::new(&env, &contract_id);
env.cost_estimate().budget().reset_unlimited();
_client.do_expensive_work(&10_000);
}Two details matter:
WARNING
- Local estimates differ from network costs. A local check passing in CI does not guarantee network success. Read Local vs. Network Cost Gap to understand operation gaps and safety margins.
- Run the WASM, not raw Rust. Raw Rust estimates ran ~81% below real network cost in our measurements; a limit asserted against them protects nothing.
reset_unlimited()before the call, so the default test budget doesn't cap the measurement.
Re-measure (Steps 3–4) whenever you change the release profile or bump the SDK — both shift local and network costs, and not by the same amount. A useful follow-up for the tool would be to warn when a workspace lacks the release profile above; this guide only documents the requirement.
Step 5: Block regressions in CI
Build the WASM, then run the tests, on every push and pull request:
- name: Build contracts
run: cargo build -p my-contract --release --target wasm32v1-none
- name: Budget assertions
run: cargo testIf a change pushes a function past its asserted budget, the test fails with the actual cost and the limit in the message. Re-run cargo budget-report to re-measure, then either optimize the function or consciously raise the limit.
Step 6 (optional): Catch regressions on the workspace with a baseline
The Tier A macros above catch local estimation regressions on a single function at test time. To catch network-cost regressions across the whole workspace (without requiring a unit test per function), record a baseline on your trunk branch and check against it on PRs.
On main (or whatever trunk you want to gate against), record the baseline:
cargo budget-report --record-baselineCommit the resulting budget-baseline.toml. It looks like:
[amm-pool-contract.do_expensive_work]
cpu_instructions = 756678
read_bytes = 2048
write_bytes = 4096Section headers are sorted alphabetically; the three metric lines inside each block always appear in the same order, so a PR diff against this file only shows the values that actually moved.
In CI on every pull request:
cargo budget-report --check-baselineThe run exits non-zero when any metric exceeds its allowed budget under the tolerance. The default tolerance is 10% — chosen for testnet-side variability, since simulations drift with ledger state. Tighten it per function in budget.toml:
tolerance = 0.10 # global default
[functions.do_expensive_work]
args = ["--n", "10000"]
tolerance = 0.05 # tighter override for a known-sensitive callA single bad commit can no longer ride the --check-baseline gate; the rest of the workflow (tier-A macros, the textual report, --json for scripts) is unchanged.
To surface the comparison in the PR's checks, append a Markdown diff table to the job summary:
cargo budget-report --check-baseline --markdown >> "$GITHUB_STEP_SUMMARY"It shows baseline, current, absolute and percentage change per metric, with a text direction marker (no colour) and a status that separates a real tolerance breach from a value that merely moved. Add --hide-unchanged to keep it to the rows that changed. See Baseline comparison output.
Step 7 (optional): Watch mode for iterative development
When iterating on a function that is over budget, you want to know whether each change moved the number — without running a full cargo budget-report by hand every time. Watch mode automates this loop:
cargo budget-report --watchWhat it does
- Watches the workspace for changes to source files (
.rs,.toml, etc.). - On each change, rebuilds and re-measures only the affected packages. A change confined to one package does not re-deploy every other package in the workspace.
- Prints a delta comparing the current measurements against the previous run, so you can see the impact of your edit immediately.
- Coalesces rapid edits — saving four times in ten seconds triggers one re-measurement, not four.
- Handles build failures gracefully — if a build fails, it prints the error and keeps watching.
- Exits cleanly on Ctrl-C without leaving deployed contracts or temp files behind.
Restrictions
- Interactive terminals only: watch mode refuses to start when stdout is not a terminal (e.g. in CI). Run without
--watchfor CI. - Not usable in pipelines: the progress output goes to stderr so it does not pollute stdout, but the tool is designed for human-in-the-loop use.
What counts as a "relevant change"
Only source files under workspace package directories trigger a re-measurement. The following are always excluded:
target/(build output — including the tool's own WASM builds).git/(version control metadata)node_modules/(JavaScript dependencies).github/(CI configuration)
This prevents the tool from retriggering on its own build output or on unrelated files.
Example session
$ cargo budget-report --watch
Watch mode active. Watching for source changes...
Press Ctrl-C to stop.
Discovering workspace members...
Building package 'amm-pool-contract' for wasm32...
Contract deployed at: C...
Simulating function 'do_expensive_work'...
=== WORKSPACE BUDGET REPORT ===
amm-pool-contract::do_expensive_work [CPU Instructions] = 756,678 inst.
amm-pool-contract::do_expensive_work [Read Bytes] = 2,048 B
amm-pool-contract::do_expensive_work [Write Bytes] = 4,096 B
Watching for changes...
── Re-measuring workspace ──────────────────────────
amm-pool-contract::do_expensive_work [cpu_instructions] 756678 -> 720100 (-36578, -5%)
=== WORKSPACE BUDGET REPORT ===
amm-pool-contract::do_expensive_work [CPU Instructions] = 720,100 inst.
amm-pool-contract::do_expensive_work [Read Bytes] = 2,048 B
amm-pool-contract::do_expensive_work [Write Bytes] = 4,096 B
Watching for changes...The delta line shows the direction and percentage of the change, so you can see at a glance whether your optimization moved the needle.
⚙️ Supported Versions & Compatibility
- Supported SDK Version:
soroban-sdk="27.0.3"(specifically tested/resolved to27.0.6inCargo.lock) - Supported XDR Version:
stellar-xdr="27.0.0"(used for decoding transaction simulation responses) - Corresponding Stellar Protocol: Protocol 27
Compatibility Matrix
| SDK Version | Protocol Version | Status | Notes |
|---|---|---|---|
| < 22.0.0` | < 22` | Untested | Older protocols may use different transaction/resource schemas. |
22.0.x | 22 | Untested | Previously supported; superseded by SDK 27 workspace baseline. |
23.0.x – 26.0.x | 23 – 26 | Untested | Not pinned in the workspace; may work but are untested. |
27.0.x | 27 | Supported | Matches pinned manifest dependencies (soroban-sdk 27.0.3, stellar-xdr 27.0.0). |