Skip to content

Local testing

A local run executes a contract in a real Soroban host, on this machine, with no network involved. It is the default and the mode everything else is built on.

The three targets

--contract accepts three things, and they are resolved differently:

Spelling What it is
fixture:<name> One of this repository's own contracts, registered from its Rust type.
<path>.wasm A compiled contract artifact, deployed from its bytes.
<contract-id> --network <net> A deployed contract. Its WebAssembly is read over RPC and then measured locally; see docs/testnet-testing.md.

fixture: targets exist so that a run can be attempted before you have written anything. The fixtures are the reference token with exactly one defect injected each; an unknown name is refused by name and the refusal lists the fixtures that exist, rather than silently defaulting to the conforming one.

Building a contract to measure

A Soroban contract is compiled for WebAssembly, and a normal host build will not load:

$ cargo build --target wasm32v1-none --release -p my-token
$ ls target/wasm32v1-none/release/my_token.wasm

wasm32v1-none is the current target for Soroban contracts. On older toolchains the same artifact comes from wasm32-unknown-unknown. rust-toolchain.toml in this repository pins wasm32v1-none, so that a checkout can build a contract for these examples without adding the target yourself. Nothing in the runner's own workspace is built for it — a fixture is registered from its Rust type, which is explained under The local host is not a mock — but one fixture is: see A compiled fixture below.

The runner reads the contract's interface from the artifact's contractspecv0 section before deploying it. An artifact that publishes no spec section is refused with a contract resolution error, rather than deployed and then asked what it exposes: an artifact with no declared interface cannot be measured against a profile's interface requirements at all, and reporting an empty interface would claim a reading that never happened.

What limits a .wasm run, and why

A vector declares the world its operation runs against — opening balances, allowances, total supply. Against a fixture, the runner establishes that world by calling the fixture's own setup entry points.

It does not assume your contract publishes equivalent entry points, and it does not write your contract's storage directly. So a vector that declares an opening balance or an allowance against a .wasm target is reported as skipped, with a diagnostic naming the reason:

• transfer-moves-exact-amount (skipped)
    · seeding-unavailable the vector declares an opening balance or allowance and a
      compiled artifact is not assumed to publish Estamora's fixture setup entry
      points, so a vector whose world declares no opening balance or allowance can be
      prepared and one that declares either cannot; the requirement was not exercised
      and this vector contributes nothing to the verdict

A skipped vector is not a pass and not a failure, and what the run concludes depends on whether anything else was decided:

What was decided Status Exit
Some vectors decided, at least one required vector skipped INCONCLUSIVE 2
Nothing decided at all EXECUTION_ERROR 4

The second row is the ordinary result of measuring a deployed artifact: no state can be established in one, so no vector can be decided, and the honest answer is not a conformance result but an environment failure. What a run must never do is report CONFORMANT because the vectors it could not prepare simply did not fail. The alternative — measuring the contract against a world it was never put into — would produce a verdict that looks exactly like a real one and is not one.

This is a real limitation and it is worth stating plainly: against an arbitrary .wasm, Estamora can measure the vectors whose starting world needs no opening state. For SEP-41 that is unknown-account-balance-is-zero and the metadata reads. Everything else needs the contract to hold a balance to begin with, and SEP-41 has no method that establishes one — minting is outside the interface the standard defines, precisely because who may mint is the issuer's decision and not the standard's.

Establishing opening state for an arbitrary contract therefore needs a declaration the specification format does not currently have: a way for a vector to say call this method, with this authorization, to reach this state, and for a profile to declare which methods may be called that way. That is a change to the specification — which is where it belongs, since this repository has no authority to decide it — and it is not something this runner will invent on its own. Until it exists, a contract being measured locally is measured through the fixture target, or through a profile whose vectors need no opening state.

The second limit: a constructor that takes arguments

A .wasm file is deployed by registering it, and registering an artifact runs its __constructor. The environment has no arguments to give it, and a constructor's arguments are the deployer's decision — an admin address, a fee recipient, a decimal scale. The runner will not invent them, because a contract placed in a state the runner made up is no longer the contract anybody deployed, and every requirement measured against it would be measured against that invention.

So an artifact whose constructor declares arguments is refused, by name and with the arguments it wanted:

CONTRACT_RESOLUTION_ERROR: the artifact ./my_token.wasm declares a constructor
 taking admin: address, decimal: u32, and a constructor can only be run by whoever
 deployed the contract: only the deployer knew what to pass it. The runner will not
 invent arguments, because doing so would fabricate the very state the vectors are
 then measured against. No instance can be created locally, so nothing about this
 contract's behaviour was observed
  reason: constructor-needs-arguments

That is a resolution failure — exit 4, the environment at fault — and not a verdict about the contract. An artifact with no constructor, or one that takes no arguments, is registered and measured normally.

This limit belongs to the .wasm path specifically. A contract read from a network is never registered: its deployed instance ledger entry is placed in the ledger instead, so its constructor — whatever its arguments — is never run. See docs/testnet-testing.md.

The local host is not a mock

The hosts are real and so is the contract code. fixtures/contracts/ are written against the same Soroban SDK a user's contract is, and they are executed by the same host that executes a deployed contract — the runner never substitutes a mock for it. What is pinned is time: the ledger sequence, the close time and every account's authorization are fixed by the vector, which is what makes a run reproducible. Two runs of the same vector produce the same events, the same balances and the same verdict.

A fixture is registered from its Rust type rather than deployed from WebAssembly, which is a real difference from the other two targets and has two consequences worth knowing. Its code is compiled natively rather than to Wasm, so it does not pass through the VM a deployed contract runs in; and it has no artifact to inspect, so it declares its own interface in the same shape that inspection produces from a real one — the reasoning is in fixtures/contracts/conformance-token/src/interface.rs. That is why cargo test --workspace needs neither a network nor a WebAssembly build: the fixtures are Rust crates in the workspace and the tests that measure them compile them as part of the build.

What that does not change is the execution environment. It is the same host with time and randomness pinned, and it is why a stored report can be re-read and re-rendered a year later and produce the same document — which integration-tests/ asserts against a report whose timestamp is pinned. docs/testnet-testing.md covers what changes when a real network is involved.

Two facts about the host shape the design and are worth knowing before reading the source:

  • A refused call unwinds the host's record of what was authenticated. There is nothing left to compare an authorization requirement against after a refusal, which is why the authorization dimension reports a finding rather than a pass for a vector that expects a refusal. See docs/execution-engine.md.
  • Reading a contract's interface is a prerequisite for a call. The runner reads the spec section before anything else, so a call that later aborts can be attributed to the contract rather than to a mis-typed invocation. The interface is read first, and the invocation is typed against what was read.

A compiled fixture

The suite measures one contract as a compiled artifact as well as from its Rust type: fixtures/contracts/measurable-token declares its own workspace so that it can be built for WebAssembly, and scripts/build-fixture-wasm.sh produces the artifact under fixtures/wasm/ that the tests deploy. That artifact is committed, so measuring it needs no build step, and CI rebuilds it and fails if the committed copy differs.

It exists because of what could not otherwise be tested. A registered Rust type has no contractspecv0 section, so interface inspection reads a declared interface instead of a real one, and a .wasm target has no artifact at all. Measuring this one exercises both, and pins the two answers a run against a deployed contract has to get right: the digest a report records is the digest of the bytes, and a run that could not prepare a single scenario reports an environment failure rather than a verdict.

A local run, end to end

# Point at a specification checkout.
$ export ESTAMORA_SPEC_REPO=/path/to/estamora-conformance-spec

# Say what a profile requires, without executing anything.
$ estamora profile --profile sep-41@1.0

# Check that the profile and its corpus are usable.
$ estamora validate --profile sep-41@1.0

# Read a contract's interface.
$ estamora inspect --contract ./target/wasm32v1-none/release/my_token.wasm

# Measure it.
$ estamora run \
    --profile sep-41@1.0 \
    --contract ./target/wasm32v1-none/release/my_token.wasm \
    --report report.json

If you have no contract yet, --contract fixture:none runs the whole pipeline against the reference token. That is what examples/local-contract/ walks through, including the real output.

Running the test suite

$ cargo test --workspace

The workspace suite needs no network and no contract to be built by hand: the fixture contracts are Rust crates in the workspace, so the tests that measure them compile them as part of the build. Nothing in cargo test --workspace reaches a network, and nothing requires a specification checkout. The tests that measure the real SEP-41 profile run only when ESTAMORA_SPEC_REPO is set and the checkout actually contains it, and they skip themselves rather than fail when it is absent.

$ cargo test -p estamora-integration-tests      # the end-to-end suite
$ cargo test -p estamora-soroban                # the execution layer
$ cargo test -p estamora-assertions             # the rule evaluators

Where to look when something is wrong

docs/troubleshooting.md maps each error class to what it means and what to do about it. The short version: the class name is the diagnosis, and the four classes never share an exit code, so a pipeline can tell a broken profile from a broken contract from a broken environment without parsing a message.