Protocol Mechanics
The measured gap
Every Soroban transaction runs against a resource budget. If the budget is exhausted on-chain, the transaction fails. Local tests estimate these costs, but the estimate depends on how the contract executes locally — and the error can point in either direction. Measured on the example contract's do_expensive_work(10_000), built with the standard Soroban release profile (opt-level = "z", LTO):
The raw figures and deltas are recorded in the measurements file, which is the single source of truth for empirical cost data across the project. The same page documents the methodology, the build profiles tested, and the operation types not yet measured.
The standard profile is the full [profile.release] block used by this repository:
[profile.release]
opt-level = "z"
overflow-checks = true
debug = 0
strip = "symbols"
debug-assertions = false
panic = "abort"
codegen-units = 1
lto = trueThese settings materially change the WASM being measured. opt-level = "z" and LTO optimize the binary shape, codegen-units = 1 gives LLVM a broader optimization view, panic = "abort" removes unwinding paths, strip = "symbols" and debug = 0 remove non-runtime payload, debug-assertions = false keeps release behavior, and overflow-checks = true preserves checked arithmetic. Cost figures from another release profile are not comparable: the measurements file records 901,816 local WASM CPU instructions / 756,678 testnet instructions for the size-optimized profile, versus 767,049 local / 832,006 testnet for Cargo's default release profile.
INFO
The direction of the WASM gap is not stable — see the user guide on Local vs. Network Cost Gap for practical safety margin guidance, the two build profiles compared in the existing measurements, and the SDK version calibration which tracks how the gap shifts across soroban-sdk versions.
Two conclusions drive the tool's design:
- Raw Rust estimates are useless for budget decisions. Tests must run the compiled WASM.
- Even WASM-mode local estimates can miss real network cost by double-digit percentages, in either direction depending on the build profile. The only trustworthy number is a network simulation of the exact WASM you deploy.
Tier A: Local fast fail (budget-macros)
Two attribute macros gate tests on local cost estimates. Each rewrites the test function's body so a cost check against the test's local env variable runs on every path that leaves the test.
#[budget_cpu_lt(N)] — CPU instruction assertion
Injects the equivalent of:
// ... original statements, with each `return e` rewritten to
// `return { let v = e; <check>; v }` ...
let __budget_value = /* original trailing expression, if any */;
{
let budget = env.cost_estimate().budget();
let cpu_cost = budget.cpu_instruction_cost();
assert!(cpu_cost < N, "CPU instruction cost {} exceeded limit {} - ...", cpu_cost, N);
}
__budget_value#[budget_mem_lt(N)] — Memory bytes assertion
Same shape, checks budget.memory_bytes_cost():
let budget = env.cost_estimate().budget();
let mem_cost = budget.memory_bytes_cost();
assert!(mem_cost < N, "Memory bytes cost {} exceeded limit {} - ...", mem_cost, N);Binding the trailing expression keeps it as the function's value, so fn test() -> Result<(), Error> bodies ending in Ok(())compile; rewritingreturnkeeps an early exit from skipping the check. The check stays inside the body's scope, so the limit expression still resolves against the body's own bindings. Two paths out of the body are not instrumented: a?that propagates an error (the test already fails on that error) and areturnproduced by another macro's expansion. Areturn` written directly in macro invocation tokens is a compile error rather than a silent skip — see the Reference.
Both assertions are strict (<). If the local estimate reaches the limit, the test panics and cargo test` fails, which blocks CI. This tier is fast (no network) and deterministic, so it is safe to run on every push and pull request.
Dynamic limits via environment variables
Either macro can read its limit from an environment variable at test time instead of using a hard-coded integer:
#[budget_cpu_lt(env = "MY_CPU_LIMIT")]When the variable is unset, the limit defaults to u64::MAX, making the assertion pass unconditionally; a set-but-unparsable value panics. This lets you raise or disable limits without recompiling — useful for CI matrix builds where different runners have different budgets.
Tier B: Network simulation (cargo-budget-report)
The CLI measures ground truth. One invocation walks this pipeline:
sequenceDiagram
autonumber
participant Dev as Developer
participant CLI as cargo budget-report
participant Cargo as Cargo / rustc
participant WASM as WASM Binary
participant Parser as WASM Parser
participant Stellar as Stellar CLI
participant RPC as Soroban RPC
participant Report as Budget Report
Dev->>CLI: cargo budget-report [--check] [--json]
CLI->>Cargo: cargo build -p <contract> --release<br/>--target wasm32-unknown-unknown
Cargo-->>WASM: amm_pool_contract.wasm (cdylib)
CLI->>Parser: wasmparser: scan exports
Parser-->>CLI: exported function names list
CLI->>CLI: Read budget.toml<br/>(network, source, per-function args/limits)
loop For each exported function in budget.toml
CLI->>Stellar: stellar contract invoke<br/>--function <fn> --args <args>
Stellar->>RPC: simulateTransaction (XDR)
RPC-->>Stellar: SimulateTransactionResponse<br/>(transactionData XDR)
Stellar-->>CLI: stdout JSON response
CLI->>CLI: Decode transactionData XDR<br/>Extract: CPU instructions,<br/>read bytes, write bytes
alt --check mode and limit configured
CLI->>CLI: Compare value vs cpu_limit /<br/>read_limit / write_limit
CLI-->>Report: PASS or FAIL per metric
else report-only mode
CLI-->>Report: Record value (no enforcement)
end
end
CLI->>Report: Render table (tabled) or<br/>emit JSON (--json flag)
Report-->>Dev: Console output +<br/>current_report.json artifact
alt Any limit breached or simulation failed
CLI-->>Dev: exit code 1 (CI fails)
else All checks pass
CLI-->>Dev: exit code 0 (CI passes)
end- Discover — runs
cargo metadataand selects every workspace package with acdylibtarget (i.e., every Soroban contract). - Build — compiles each contract with
cargo build --target wasm32v1-none --release, using the workspace's[profile.release]. - Scan exports — parses the
.wasmbinary withwasmparserand collects every exported function, skipping internals (names starting with_, andmemory). - Deploy — deploys the WASM to the configured network with
stellar contract deploy. - Simulate — for each exported function, builds an unsigned transaction (
stellar contract invoke --build-only, with per-function arguments frombudget.toml), then POSTs it to the Soroban RPCsimulateTransactionendpoint. - Decode — decodes the returned
SorobanTransactionDataXDR (stellar xdr decode) and extractsresources.instructions,resources.disk_read_bytes, andresources.write_bytes; on Soroban Protocol 27+, additionally readsresult.cost.memBytesfrom the JSON-RPCcostblock (see Memory Bytes below). On older protocol responses theMemory Bytesrow is simply omitted (absence is not zero). - Report — aggregates every package/function pair into one table, or JSON with
--json.
Simulated numbers vary slightly with ledger state, but they are the network's own measurement of the exact WASM you deploy, not a local approximation.
These three figures are resource amounts, and they are inputs to the non-refundable resource fee — not the fee itself and not a total cost. Rent, other refundable fees, transaction size, footprint entry counts, and the inclusion fee are outside what the tool measures. See Measurement scope for the full boundary and where to find the omitted pieces.
Memory Bytes (Protocol 27+)
The fourth reported row, Memory Bytes, does not come from the SorobanTransactionData XDR — Protocol 27 simulations return a separate result.cost JSON object alongside the XDR, and it carries per-metric human-readable cpuInsns and memBytes strings (stringified for u64 precision over JSON). The CLI reads result.cost.memBytes from the JSON-RPC response in addition to the XDR fields, accepting both integer and string forms. This is the Source for the local-vs-network memory-bytes gap measurement series (issue #122).
How the tiers work together
Tier B tells you what a function really costs on the network. Tier A pins the local estimate into your test suite: measure once, assert a limit a few percent above the measured local number, and any change that pushes execution cost past it fails CI before it reaches the network. The example contract's gated test uses exactly this pattern: local WASM estimate 2,654,615, asserted limit 2,800,000, real testnet cost (placeholder — see SDK version calibration).
Warning: The local WASM estimate shifts with soroban-sdk version. The SDK version calibration table in measurements.md should be regenerated on every SDK bump so Tier A limits are based on current numbers, not stale ones.
⚙️ 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). |