Skip to content

About

Static shadow-stack usage estimator for Soroban WASM smart contracts

Resources

Stars

1 star

Watchers

0 watching

Forks

Latest commit

 

History

2 Commits

Folders and files

Repository files navigation

WASM Stack Estimator

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.

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.

Why it is useful

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

Using it in the browser

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

Using it from the command line

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-estimator

Example

Output 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.

In CI

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.

As a library

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.

Reading the report

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. --explain shows the instructions.
  • recursive: the function is part of a call-graph cycle, so the depth cannot be bounded statically.
  • indirect: the function uses call_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. --explain also 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.

Choosing a stack size

  1. Build the contract and run the estimator on the release .wasm.
  2. Resolve the warnings first. A recursive or dynamic path means the peak is not a real bound.
  3. 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.
  4. Set the size as described in Setting the stack size.
  5. Rebuild and run the estimator again. The Layout line shows the new stack size. If it still says 1,048,576 B, the flag did not reach the linker.
  6. 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 .wasm built with the new stack size instead, for example by loading it with soroban_sdk::contractimport! and env.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.
  7. Run the estimator and the tests again whenever the contract changes. New code can create a deeper path.

Setting the stack size

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.

Build script (recommended)

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.

.cargo/config.toml

[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.

How it works

  1. Parse the WASM binary directly, without wabt or other tools.
  2. Find the stack pointer. If the name section is present, use the global named __stack_pointer. Otherwise, pick the mutable i32 global used most often in the global.get; i32.const N; i32.sub prologue pattern.
  3. 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.
  4. Build the call graph from call and return_call. Resolve call_indirect to every table function with a matching signature.
  5. Search the graph depth-first to find the worst-case path from each function, and mark cycles as recursion.

Limitations

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.

About

Static shadow-stack usage estimator for Soroban WASM smart contracts

Resources

Stars

1 star

Watchers

0 watching

Forks

Contributors

Languages