Skip to content

Soroban Cost Rationale: Metered Resources and Dominant Operations ​

This page explains what Soroban charges for and which operations dominate a contract's budget. It is the shared reference behind every lint in this repository. Written for contract developers, not compiler engineers.


Budgets and Limits ​

Every Soroban transaction runs against a per-transaction resource budget. If execution exhausts the budget, the transaction fails — no partial state is committed. The budget is defined by network-wide limits (set by validator consensus) and reported in two dimensions[1]:

Resource dimensionLimit typeCharged in fee?
CPU instructionsHard capYes
Memory (RAM)Hard capNo (but enforced)
Ledger entry readsHard capYes
Ledger entry writesHard capYes
Ledger I/O (bytes read/written)Hard capYes
Transaction size (bytes)Hard capYes

INFO

Current per-transaction limits are published on the Stellar Lab under Resource Limits & Fees[2]. These values change only through validator consensus.


Metered Resources ​

Soroban's fee model is multidimensional: the resource fee is the sum of charges for several independent resource types[1].

1. CPU Instructions ​

Every Wasm instruction the guest executes and every host function the contract calls is metered as CPU instructions. The host environment tracks more than 85 distinct cost types (ContractCostType[3]), each with its own calibrated cost model of the form y = a + bx, where x is a runtime input size.

Key cost types that matter for linting:

Cost typeWhat it models
WasmInsnExecBaseline cost of one Wasm instruction
DispatchHostFunctionOverhead of crossing from Wasm into the host environment
MemAlloc / MemCpy / MemCmpMemory-management operations
VisitObjectAccessing a host object from storage

The per-instruction cost is not uniform. A ComputeSha256Hash call costs orders of magnitude more CPU than a WasmInsnExec — but every cost type is ultimately summed into the same instruction counter[3].

2. Memory (RAM) ​

Memory is metered in bytes but not included in the resource fee. It is still subject to a hard per-transaction cap. If a contract allocates beyond the limit, execution is terminated.

For linting purposes, memory is a secondary concern: storage and CPU dominate the fee.

3. Storage: Ledger Entry Accesses and Ledger I/O ​

Storage operations are the single most expensive resource a typical Soroban contract can consume[4]. The fee model charges in two sub-dimensions[1]:

  • Ledger entry accesses — each distinct storage key read or written counts as one access, regardless of its size.
  • Ledger I/O — the total number of bytes read from or written to the ledger.

A single env.storage().instance().set(&key, &val) therefore incurs: one write access + size_of(val) write bytes + any CPU instructions for serialization. Repeating this inside a loop multiplies every dimension.

4. Transaction Size & Bandwidth ​

The size of the submitted transaction envelope (in bytes) is charged for network propagation and historical storage. This is rarely a linting concern — most structural anti-patterns exhaust the CPU or storage budget long before the bandwidth cap.

5. Events and Return Values ​

Events emitted by the contract and the top-level return value are included in transaction metadata and contribute to the resource fee. These costs are refundable: the network charges the declared maximum up front and refunds the unused portion after execution[1].

6. Ledger Space Rent ​

Every ledger entry a contract creates or extends has a Time-To-Live (TTL). Extending TTL or increasing entry size incurs a rent payment, priced dynamically based on ledger size. This is a refundable fee component and depends on the state of the network, not just the contract's code structure.

Entry Lifecycle & Durability: Temporary vs. Persistent vs. Instance ​

How an entry behaves when its TTL hits zero depends entirely on which storage type it lives in[7]:

Storage typeBehavior on TTL expiryCostIntended for
TemporaryPermanently deleted, cannot be recoveredCheapestTime-bounded, recreatable data (oracles, signatures, allowances, cache)
PersistentArchived, restorable (usually automatically)Most expensiveData that must survive (e.g. token balances)
InstanceArchived (shares the contract-instance TTL)Most expensiveSmall, fixed, contract-level data

Temporary entries are never moved to archival storage: when a TemporaryEntry expires it is deleted forever and cannot be recovered. The SDK documentation states explicitly that this storage type is "best for entries that are only relevant for short periods of time or for entries that can be arbitrarily recreated", and warns that "it is unsafe to rely on the extensions to preserve data — there is always a risk of losing temporary data."

By contrast, Persistent and Instance entries are archived on expiry and can be restored, so they "behave as if stored forever". The consequence for contract authors: a value that must remain available forever must go into persistent() or instance() storage. Relying on a temporary entry to persist (e.g. reading it back with .unwrap() after writing it) is relying on data that can quietly vanish — this is exactly the anti-pattern the temporary_storage_for_persistent_data lint detects.


What Dominates ​

For the patterns this linter catches, the resource hierarchy is:

RankOperationPrimary resource consumed
1 (most expensive)Storage writesLedger entry writes + I/O bytes
2Storage readsLedger entry reads + I/O bytes
3Host function callsCPU (dispatch + function work)
4Wasm arithmetic / control flowCPU (WasmInsnExec)
5 (least expensive)Memory operationsRAM (capped, not charged)

Storage writes dominate because they consume four resources simultaneously: a ledger entry access, write I/O bytes, the serialization CPU cost, and (for new entries) space rent. A single storage write in a loop can cost more than the rest of the loop body combined.

Host function calls (e.g., env.ledger().sequence()) are cheaper than storage but still expensive relative to pure Wasm because they pay the DispatchHostFunction overhead plus whatever work the host performs[3]. Calling a constant-returning host function inside a loop is pure waste.


The Local-vs-Network Gap ​

One of the most surprising results from empirical measurement is how much local estimates can differ from real network costs. Measured on the example contract from the sibling repository soroban-budget-assert[5]:

Execution modeCPU instructionsGap vs. testnet
Raw Rust (native test)143,887Underestimates by ~81%
Local WASM (register_contract_wasm)901,816Overestimates by ~19%
Testnet simulation (simulateTransaction)756,678Ground truth

WARNING

Raw Rust test estimates are unreliable for budget decisions — they can miss real network cost by over 80%. Even WASM-mode local estimates can be off by double-digit percentages, and the direction (over vs. under) depends on the build profile.[5]

What this means for linting: The linter catches structural anti-patterns that are expensive regardless of the local/network gap. A storage write in a loop is expensive everywhere. But the magnitude of savings from fixing it can only be known by running the compiled WASM against a network simulation — which is the purpose of the sibling project soroban-budget-assert.


Per-Lint Resource Summary ​

Each lint in this repository targets a specific resource dimension. Every lint is assigned to one of the five lint categories, and the table below shows the resource each one targets:

LintPrimary resource targetedWhy it matters
soroban_storage_in_loopStorage (ledger entry accesses + I/O bytes)Storage writes are the #1 cost driver; multiplying them by loop count is the most expensive pattern this tool detects.
unnecessary_host_function_callCPU (host function dispatch)Host calls are expensive relative to pure Wasm; repeating a constant-result call inside a loop wastes CPU.
signature_verification_in_loopCPU (elliptic-curve cryptographic host functions)Signature verification is one of the most expensive host functions available; verifying one at a time in a loop is a sign that a batch/aggregate scheme should be used instead.
redundant_env_cloneCPU (memory + dispatch overhead)Cloning Env triggers MemAlloc/MemCpy and unnecessary object visits; the clone is never needed.
contract_call_in_loopCPU (cross-contract VM instantiation + dispatch)Each invoke_contract call spins up a new VM context; repeating it per iteration multiplies that overhead by the loop count.
excessive_vec_capacityMemory (guest linear memory, hard-capped)A large hard-coded capacity is charged against the guest's memory cap the moment it's allocated, whether or not it's ever filled.
extend_ttl_in_loopStorage (ledger space rent)extend_ttl is a metered host call that also pays rent; calling it once per iteration multiplies both costs by the loop count instead of batching the extension.
temporary_storage_for_persistent_dataEntry Lifecycle (temporary-storage durability)A temporary entry is permanently deleted when its TTL expires; reading it back with .unwrap()/.expect() assumes it persists and panics (or worse, compounds) once the entry is gone, wasting every metered call before the failure.

What We Don't Yet Know ​

  • Exact per-instruction CPU costs for every ContractCostType — the calibrated model parameters (a, b for each cost type) are set by network consensus and are not published in developer-facing documentation. They can be inspected in the rs-soroban-env source repository[6].
  • Decomposed storage costs — the ratio of "ledger entry access fee" to "I/O byte fee" is not specified independently. The total storage fee is what matters for linting, but measuring the split requires network simulation.

INFO

Local measurements are available in the cost_benchmarks crate. Run cargo test -- --nocapture to see before/after budget deltas for each lint pattern on Env::default(). These numbers are directional (they show relative savings) but are subject to the Local-vs-Network Gap described above.


References ​

Built for the Stellar & Soroban ecosystem.