Static analysis of shadow-stack usage for Soroban smart contracts compiled from Rust. It reads a contract .wasm, finds
the deepest call chain from every exported function, and tells you how many bytes of stack the contract can actually use
compared with how much it reserves.
- In the browser: https://orbitlens.github.io/wasm-stack-estimator/ (the file is analyzed locally and never uploaded)
- From the command line:
node wasm-frames.js contract.wasm
No dependencies and no build step. The same analysis code (stack-analysis.js) runs in Node.js and in the browser.
Warning
The results are estimates. The tool works from the compiled WASM file alone, without access to the contract source code, so its numbers may be incorrect (see Limitations). If you compile a contract with a smaller stack, test it thoroughly before deployment, including the entry points with the deepest call chains and the largest inputs.
Rust compiles to WASM through LLVM, which keeps a shadow stack in linear memory. Anything that does not fit in WASM
locals goes there: structs, arrays, Val buffers, values whose address is taken. A mutable global (__stack_pointer)
points to the top of the stack, and each function's prologue subtracts its frame size from it.
Rust's wasm targets reserve 1 MiB for this stack by default and place it at the start of linear memory, so even a tiny contract starts with 17 memory pages (1,088 KiB). Every Soroban invocation creates a fresh VM with that initial memory, and the memory is charged to the transaction's resource budget. A cross-contract call creates one more VM, with its own memory, for each contract in the chain. Most contracts need a few hundred bytes to a few KiB of stack, so nearly all of that megabyte is unused.
You can shrink the stack with a linker flag, but you need a number you can trust. If the stack is too small, the contract traps on its deepest path, possibly one that tests never reach. The estimator gives you:
- the worst-case stack usage of every exported entry point, with the call chain that produces it
- the frame size of every function
- the headroom: configured stack size divided by peak usage
- warnings for anything that makes the estimate inexact: dynamic stack-pointer adjustments, recursion, indirect calls, and a memory layout where an overflow corrupts data instead of trapping
Open the web page and drop a .wasm file onto it, or click to choose
one. Contract builds are usually at target/wasm32v1-none/release/<contract>.wasm.
The options next to the file picker work like the CLI flags (--sp, --top, --all, --explain), and the report
updates when you change them. The results have three tabs:
- Report: summary cards, warnings, an entry-point table with call chains, and the function table
- Text: the same output as the CLI, with buttons to copy it or download it as
.txt - JSON: the same output as
--json, with buttons to copy it or download it as.json
You need Node.js 18 or newer. There are no packages to install.
node wasm-frames.js target/wasm32v1-none/release/my_contract.wasm| Option | Effect |
|---|---|
--top N |
Number of functions to list (default 20). Only functions that have a frame or a hazard are listed. |
--all |
List every function. |
--explain |
Print the instructions behind functions with several SP reservations and behind unrecognized SP writes. |
--json |
Print the full result as JSON. Unbounded values are written as "Infinity". |
--sp N |
Use global N as the stack pointer instead of detecting it. |
Exit codes: 0 success, 1 usage error, 2 the file could not be analyzed.
To run it as wasm-frames from anywhere, install it globally from the repository:
pnpm add -g github:orbitlens/wasm-stack-estimatorOutput for a token-style contract built with the default 1 MiB stack (shortened):
Stack pointer: global 0 (inferred from 56 prologue patterns), initial value 1,048,576
Layout: stack-first ✓ (overflow traps), stack size 1,048,576 B
Memory: 17 initial page(s) = 1,088 KiB, static data 1,048,576–1,049,128
Peak usage: 656 B via condensate
Headroom: 1598.44× of configured stack
Entry points (exports), worst-case shadow stack:
condensate 656 B condensate(144) → f64(224) → f65(16) → f56(64) → f105(32) → f104(176)
transfer 560 B transfer(48) → f64(224) → f65(16) → f56(64) → f105(32) → f104(176)
balance 384 B balance(32) → f55(80) → f56(64) → f105(32) → f104(176)
approve 128 B approve(32) → f35(64) → f37(16) → f70(16)
decimals 96 B decimals(32) → f30(32) → f31(32)
Functions by frame size (top 20 with a frame or hazard):
frame worst path name
224 512 B f64
192 336 B collect
176 384 B f59
...
The peak is 656 bytes, but the contract reserves 1 MiB.
Use --json to fail a build when the headroom drops below a margin you choose:
node wasm-frames.js target/wasm32v1-none/release/my_contract.wasm --json | jq -e '.headroom >= 2'headroom is missing from the JSON when it cannot be computed (no stack-first layout, or an unbounded path), so the
check fails in those cases too.
import {analyzeWasm, formatReport, formatJson} from './stack-analysis.js'
const report = analyzeWasm(bytes) //Uint8Array or ArrayBuffer; pass {sp: N} to set the stack pointer global
console.log(report.peak.total, report.headroom)
console.log(formatReport(report, {top: 10, explain: true}))The result's shape is documented with JSDoc typedefs (StackReport) in stack-analysis.js.
| Line / column | Meaning |
|---|---|
| Stack pointer | Which global is the stack pointer, how it was found (name section or prologue patterns), and its initial value. |
| Layout | stack-first ✓ means the stack sits below static data, so an overflow wraps below address 0 and traps. stack AFTER data ✗ means an overflow silently overwrites static data. |
| Memory | Initial linear memory and the address range of static data. |
| Peak usage | The largest worst-case total over all entry points. |
| Headroom | Configured stack size divided by peak usage. |
| Entry points | Worst case for each export, followed by the call chain that produces it as function(frame bytes). |
| frame | Bytes the function reserves for itself. |
| worst path | The function's frame plus its deepest chain of callees. |
≥ |
Lower bound: the path contains a dynamic SP adjustment. |
∞ |
Unbounded: the path is recursive. |
Flags in the function table:
dynamic: the function moves the stack pointer by a non-constant amount (alloca, over-alignment), so its total is a lower bound.--explainshows the instructions.recursive: the function is part of a call-graph cycle, so the depth cannot be bounded statically.indirect: the function usescall_indirect. Every table function with a matching signature is counted as a possible callee, which is conservative.multi-setup: the function reserves stack in several places. The largest reservation is used.--explainalso shows the total in case all of them are live at once.
Imported functions (Soroban host functions) run natively and use none of the contract's stack, so they count as 0.
Release builds usually strip the name section, so internal functions appear as f123. Function names do not affect code
generation, so for a readable report you can build with strip = false in [profile.release] and analyze that build.
- Build the contract and run the estimator on the release
.wasm. - Resolve the warnings first. A recursive or dynamic path means the peak is not a real bound.
- Pick a size with a safety margin over the peak. It must be a multiple of 16. Linear memory is allocated in 64 KiB pages, and the stack and static data share the first pages. Choose a size that keeps stack plus static data within as few pages as possible.
- Set the size as described in Setting the stack size.
- Rebuild and run the estimator again. The
Layoutline shows the new stack size. If it still says 1,048,576 B, the flag did not reach the linker. - Test the contract thoroughly before deployment. Native unit tests do not use the WASM stack, so they cannot catch an
overflow. Run the tests against the
.wasmbuilt with the new stack size instead, for example by loading it withsoroban_sdk::contractimport!andenv.register(...), or by invoking it on a local network or testnet. Cover every entry point, especially the ones with the deepest call chains, with the largest inputs the contract accepts. - Run the estimator and the tests again whenever the contract changes. New code can create a deeper path.
stellar contract build has no option for the stack size. The size is a linker argument, -zstack-size=<bytes>, and
there are two ways to pass it.
Add a build.rs file next to the contract's Cargo.toml:
fn main() {
// Shrink the WASM shadow stack (default 1 MiB). Wasm only: native test
// binaries must not receive this linker flag.
let target = std::env::var("TARGET").unwrap_or_default();
if target.starts_with("wasm32") {
println!("cargo:rustc-link-arg=-zstack-size=65536");
}
}Cargo runs build.rs automatically, so stellar contract build and plain cargo build both pick it up. The setting
belongs to one contract crate, so each contract in a workspace can have its own size. It also leaves alone the
--remap-path-prefix flag that stellar contract build adds to keep builds reproducible.
[target.wasm32v1-none]
rustflags = ["-C", "link-arg=-zstack-size=65536"]This sets the stack size too, but target rustflags take precedence over the CARGO_BUILD_RUSTFLAGS variable that
stellar contract build uses to pass --remap-path-prefix. That flag is then dropped without a warning, so dependency
paths are not remapped and builds may not be reproducible. A RUSTFLAGS environment variable has the same effect, and
the CLI prints a warning in that case. The config file also applies to every contract built in that directory tree.
- Parse the WASM binary directly, without wabt or other tools.
- Find the stack pointer. If the name section is present, use the global named
__stack_pointer. Otherwise, pick the mutablei32global used most often in theglobal.get; i32.const N; i32.subprologue pattern. - For each function, record the largest constant subtracted from the stack pointer, including the padding added by
over-alignment (
i32.and) and leaf functions that address memory below SP without writing it back. Any other write to the stack pointer is marked as dynamic. - Build the call graph from
callandreturn_call. Resolvecall_indirectto every table function with a matching signature. - Search the graph depth-first to find the worst-case path from each function, and mark cycles as recursion.
The tool only sees the compiled WASM file. It has no access to the contract source code, so it cannot tell which code paths can actually run, and its results are estimates that may be incorrect:
- It assumes every call in the code can happen, so it can overestimate.
- It cannot bound recursion or dynamic allocations. Those paths are flagged (
∞,≥) instead of being guessed. - Indirect calls are matched to callees by signature only.
- Stack usage is recognized from the instruction patterns LLVM emits. Code produced another way, or by a future compiler version, may reserve stack in ways the analysis does not detect.
Treat the numbers as a starting point for choosing a stack size, not as proof that a size is safe. Test a contract built with a smaller stack thoroughly before deployment.