Skip to content

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:

toml
[profile.release]
opt-level = "z"
overflow-checks = true
debug = 0
strip = "symbols"
debug-assertions = false
panic = "abort"
codegen-units = 1
lto = true

These 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:

  1. Raw Rust estimates are useless for budget decisions. Tests must run the compiled WASM.
  2. 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:

rust
// ... 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():

rust
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&lt;(), Error&gt; 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:

rust
#[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:

mermaid
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
  1. Discover — runs cargo metadata and selects every workspace package with a cdylib target (i.e., every Soroban contract).
  2. Build — compiles each contract with cargo build --target wasm32v1-none --release, using the workspace's [profile.release].
  3. Scan exports — parses the .wasm binary with wasmparser and collects every exported function, skipping internals (names starting with _, and memory).
  4. Deploy — deploys the WASM to the configured network with stellar contract deploy.
  5. Simulate — for each exported function, builds an unsigned transaction (stellar contract invoke --build-only, with per-function arguments from budget.toml), then POSTs it to the Soroban RPC simulateTransaction endpoint.
  6. Decode — decodes the returned SorobanTransactionData XDR (stellar xdr decode) and extracts resources.instructions, resources.disk_read_bytes, and resources.write_bytes; on Soroban Protocol 27+, additionally reads result.cost.memBytes from the JSON-RPC cost block (see Memory Bytes below). On older protocol responses the Memory Bytes row is simply omitted (absence is not zero).
  7. 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 to 27.0.6 in Cargo.lock)
  • Supported XDR Version: stellar-xdr = "27.0.0" (used for decoding transaction simulation responses)
  • Corresponding Stellar Protocol: Protocol 27

Compatibility Matrix ​

SDK VersionProtocol VersionStatusNotes
< 22.0.0`< 22`UntestedOlder protocols may use different transaction/resource schemas.
22.0.x22UntestedPreviously supported; superseded by SDK 27 workspace baseline.
23.0.x – 26.0.x23 – 26UntestedNot pinned in the workspace; may work but are untested.
27.0.x27SupportedMatches pinned manifest dependencies (soroban-sdk 27.0.3, stellar-xdr 27.0.0).

Built for the Stellar & Soroban ecosystem.