#Simple Summary
Define a shared vocabulary for the build environment information needed to reproduce a Soroban contract's wasm bytes from source. The vocabulary is a small set of named fields with stable meanings; this SEP does not prescribe how the fields are produced, stored, or consumed.
Complementary to SEP-55, which uses signed CI attestations instead of independent rebuild. The two address the same trust question with different trade-offs and can coexist on the same contract.
#Dependencies
- SEP-46:
Contract Meta. Defines the
contractmetav0Wasm custom section that this SEP's fields can be embedded in (one of several possible storage venues). - SEP-55: Soroban Smart Contracts Build Verification (informative). Defines an attestation-based verification mechanism.
#Motivation
Today, when a contract is deployed to mainnet, the on-chain artifact is opaque bytes. A user wishing to evaluate the contract has no programmatic way to confirm that those bytes were built from a particular source tree. Anyone can rebuild a contract and compare hashes, but the build environment required to reproduce a build (host OS, container image, the toolchain inside it) is not well defined and is not recorded anywhere. Without a standard vocabulary about what is required to reproduce a build, two well-intentioned verifiers do not have a shared understanding for how to build the same source to reach the same conclusion.
SEP-55 addresses the same trust question via a different mechanism: the build CI signs an attestation, and verifiers check the attestation rather than rebuilding. The vocabulary defined here supports rebuild-based verification (no trust in a particular CI, but a deterministic build environment is required), while SEP-0055 supports attestation-based verification (trust in the attesting CI, but no rebuild). The two are not mutually exclusive; a contract can carry meta supporting both, and verifiers pick whichever path best fits their threat model.
#Abstract
This SEP defines and demonstrates:
- The build environment information that must be known to reliably reproduce a build.
- A set of meta entries that contracts can embed to communicate that build information.
- A set of meta entries that contracts can embed to communicate the source identity.
- How those values can be used to prepare reproducible builds with today's tooling.
Altogether these things are what a third party needs to reproduce the wasm bytes.
This SEP should inspire tooling such as the stellar-cli to automate this process, block explorers to display verification outcomes, and the creation of verification registries.
#Specification
The vocabulary has two groups: §1 captures how the contract was built, §2 captures what was built. Each field has a stable name and a value format; producers SHOULD use these names verbatim so consumers can interpret them uniformly across storage venues. Values are ASCII strings; values that fail their format regex are not conformant.
#1. Build environment fields
These fields describe the environment in which the wasm was compiled. They are the inputs a verifier needs to set up a matching environment before rebuilding.
| key | description | format regex |
|---|---|---|
bldimg | Fully-qualified container image used for the build, pinned by digest. The image bundles the rust toolchain and any other build-time dependencies, so pinning the image transitively pins the toolchain. The digest MUST reference a single-architecture manifest, not a multi-arch manifest list (a.k.a. fat manifest); a multi-arch digest resolves to different concrete images depending on the puller's host architecture, which defeats the pin. The reference MUST include an explicit registry host (with optional port), e.g. docker.io/repo/image@sha256:... or registry.example.com:5000/repo/image@sha256:...; implicit Docker-Hub-style short references like repo/image@sha256:... are not conformant. An optional tag MAY appear before the digest (e.g. registry/repo/image:tag@sha256:...) but is informational; the digest alone determines what is pulled. | ^(?:localhost(?::\d+)?|[^\s@/]*[.:][^\s@/]*)/[^\s@]+@sha256:[0-9a-f]{64}$ |
bldopt | A single shell-style flag passed verbatim as one argument to the build command, as if single-quoted in a shell (e.g. --profile=release, --features=foo,bar, --no-default-features). For flags that take a value, the = form keeps the flag and its value in one argument. The entry MAY appear multiple times, once per flag. The order of entries is not significant. | ^--[A-Za-z][A-Za-z0-9_-]*(=.+)?$ |
bldarg | A single shell-style argument injected ahead of the bldopt entries, recorded verbatim as if single-quoted in a shell (e.g. contract, build, a positional value). The entry MAY appear multiple times, once per argument; the order of entries is significant and verifiers MUST replay them in the order recorded. When no bldarg entry is present, two are assumed: contract then build. | ^.+$ |
bldimg has no default; when absent it must be supplied externally to make a
rebuild possible.
The build environment fields are not expected to evolve.
#Compatible build image
bldimg is an open field that may reference any container image. This SEP
places no restrictions on which images are permitted, but verifiers SHOULD
carefully consider which images and image sources they accept, because the
environment a contract build executes within is part of its supply chain.
Verifiers are expected to maintain either an allowlist of images or an
allowlist of image sources rather than accepting arbitrary references.
The image MUST have an entrypoint that executes stellar (the stellar-cli), so
that running the image is interchangeable with invoking the stellar-cli
directly: docker run <image> <args> MUST behave as stellar <args> would.
The image's default working directory (set via the Dockerfile WORKDIR
instruction) MUST be /source, so that a contract mounted at /source (e.g.
-v "$PWD:/source") is the directory the stellar-cli runs in, and relative
paths like --manifest-path=contracts/foo/Cargo.toml resolve against it.
Verifiers and the build invocation in Appendix A rely on this contract.
Qualities that verifiers SHOULD expect of a trustworthy image source include:
- CI provenance: image attestations (e.g. SLSA-style) proving which CI run produced the image, so the image can be traced back to a published pipeline.
- SBOM: a software bill of materials describing the contents of the image, so the toolchain and other build-time dependencies are auditable.
- Reproducible images where possible: the image itself can be rebuilt from its declared source to the same digest, reducing trust in the publisher. Not all packaging systems and base-image toolchains support bit-for-bit reproducibility today, so this quality is a goal rather than a hard requirement; image sources SHOULD pursue it where their tooling allows.
- Security maintenance: new image releases pick up security fixes in the image's dependencies (operating system, rust toolchain, system libraries, bundled tooling) rather than carrying known-vulnerable versions forward. In particular, new images SHOULD target rust versions that include applicable security fixes from upstream rust releases. Existing digests are immutable; this quality is about the cadence and hygiene of subsequent releases.
- Stable and predictable releases: a clear versioning and release cadence so consumers can anticipate change and pin deliberately.
- Rust tier-1 with host tools target: the image runs on a target listed as
[tier 1 with host tools] in the rust platform support matrix (e.g.
x86_64-unknown-linux-gnu,aarch64-unknown-linux-gnu), so the toolchain itself is on a fully supported platform with full host-tool guarantees, not just a tier-1 cross-compilation target. [tier 1 with host tools]: https://doc.rust-lang.org/nightly/rustc/platform-support.html - Self-contained build environment: beyond the stellar-cli entrypoint
required above, the image ships with the
wasm32v1-nonetarget installed and any other dependencies needed to run all stellar-cli features and to compile contracts that depend onsoroban-sdk— so a build runs without fetching or installing tooling at build time other than the dependencies builds locked in its Cargo.lock file.
#Build options
The build command is the bldarg entries followed by the bldopt entries,
passed to the build image's default entry point. bldarg entries are the
leading arguments, replayed in order; absent any bldarg, they default to
contract then build. bldopt entries are the trailing flags, order not
significant.
bldarg MAY carry any argument, including flags, so a build MAY be recorded
with bldarg alone. bldopt is retained for the common case: a build that
deviates from the default only by flags records those flags as bldopt and
omits bldarg, leaving contract build implicit. Together the two fields
cover any argument the build command supports; the SEP enumerates none, so new
arguments require no revision.
A build is unlikely to be reproducible if --locked is not present in
bldopt; without it cargo MAY re-resolve dependencies against the registry
rather than the versions pinned in Cargo.lock.
The recorded fields reconstruct the invocation as the image's entry point, then
the bldarg entries, then the bldopt entries:
<entry point> <bldarg 0> <bldarg 1> ... <bldopt 0> <bldopt 1> ...
#2. Source identification fields
These fields identify the source tree the wasm was built from. The source is
identified as a single archive file — a .tar, .tar.gz, .zip, or other
similar archive format, identified by its file extension and not otherwise
constrained by this SEP; a producer MUST package the source such that the field
values resolve to that archive.
| key | description | format regex |
|---|---|---|
source_sha256 | Required. SHA-256 of the source archive's bytes. | ^[0-9a-f]{64}$ |
source_uri | Optional. URI from which the source archive can be downloaded; https:// is expected, but the scheme is open-ended. | ^[a-zA-Z][a-zA-Z0-9+.-]*:\S+$ |
source_sha256 is the hash of the exact bytes of the one archive the producer
created. A verifier obtains that same archive (by download or
content-addressable lookup) and confirms its bytes hash to the recorded value.
A producer therefore MUST be able to provide that exact archive — either by
keeping the archive it hashed retrievable, or by regenerating its identical
bytes on demand with a reproducible archiving method (for example,
reproducible tar
settings).
source_sha256 is always present; source_uri may or may not accompany it:
source_sha256+source_uri: the verifier downloads the archive from the URI, verifies its bytes against the sha256, and extracts it.source_sha256alone: content-addressed. The archive is identified by hash and obtained by whatever means the verifier has access to (e.g. an out-of-band source mirror or content-addressable store).
source_sha256 is what this vocabulary uses to bind a wasm to its source, so a
verifier relying on these fields requires it. A verifier MAY nonetheless
support a wasm that carries no source_sha256 if it identifies the original
source by some alternative means — for example, a trusted authentication
mechanism that establishes who uploaded the wasm and obtains the source from
that uploader directly. Such mechanisms are not defined here and are out of
scope; identifying the original source by other secure means is an area
verifiers can innovate in.
The archive MUST contain its files within a single top-level directory, so that
extracting it yields exactly one directory holding the source tree. A producer
MUST construct the archive this way; a verifier extracts the archive and runs
the build commands from inside that single directory, which it treats as the
source mounted at /source per §1.
A producer can build a conformant archive with
git archive, whose --prefix supplies
the required top-level directory, then hash it:
$ git archive --format=tar.gz --prefix=my-contract-v1.0.0/ <commit> > my-contract-v1.0.0.tar.gz
$ sha256sum my-contract-v1.0.0.tar.gz
28a148275748e65c00211f3c7308e879cd246f6cc23b53b2bf7d90f8c9845063 my-contract-v1.0.0.tar.gz
#3. Where these fields live
The vocabulary is independent of storage. The same field names and meanings apply whether the values are:
- Embedded in the contract's
contractmetav0custom section (per SEP-46): on-wasm, useful when metadata travels with the artifact. - Stored in an on-chain registry contract that maps contract id (or wasm hash) to these fields: useful for retrofitting metadata onto already-deployed contracts.
- Held in an off-chain database, verification service, or community registry: useful for contracts deployed before this SEP, or when on-wasm metadata isn't desired.
Tooling MAY combine venues; verifiers fetch fields from wherever they're available, and the deployed wasm bytes are unaffected by where the metadata lives.
#Limitations
- This SEP defines a vocabulary, and a mechanism, not a complete implementation.
- The vocabulary targets the common build workflow, not every possible build; a bespoke workflow's build inputs may not be fully expressible, by design (see Design Rationale).
- It captures the build environment and source identity, not whether the source itself is trustworthy, audited, or non-malicious; that is an orthogonal concern.
- Image and source retrieval depend on registries and archive hosts whose
availability the SEP cannot guarantee. Content-addressed identifiers
(
bldimg's digest,source_sha256) protect integrity once the bytes are obtained; obtaining them is the verifier's problem.
#Design Rationale
Why target the common build workflow rather than every workflow? The vocabulary captures the common contract build workflow, not every possible build. A bespoke workflow's build inputs may not be fully expressible, and that is acceptable. If the proposal sought to satisfy every possible build workflow it would grow without bound and could not realistically be completed. The goal is for contracts to converge towards the workflow supported by this proposal, not for this proposal to expand to satisfy every build workflow. The vocabulary can expand, but it should do so when it serves the common workflow.
Why a vocabulary, not an algorithm? Verification practice is still emerging across CIs, registries, on-chain indexes, and ad-hoc scripts. Locking in one workflow would close off useful directions; a shared vocabulary lets independent implementations interoperate without prescribing how any of them work.
Why bldimg? A container is the main technical lever that keeps a build
reproducible. Without one, the build inherits whatever the host provides (libc,
system libraries, env vars, locale, paths), any of which can silently change
the output bytes; the same source on two unrelated machines routinely produces
different wasm. A container snaps that to a known state and bldimg names it.
The bldopt entries tune the build invocation inside, and bldarg supplies
alternative arguments for entry points other than the stellar-cli; the
container itself is what makes the rebuild possible at all.
Why digest-pin bldimg? A registry tag is mutable; a content digest is
not. A digest also pins everything in the image (including the rust toolchain),
so one field captures the full build-time stack. Variants (e.g. amd64 vs arm64)
are different digests, not different fields.
Why no explicit rust version field? The image bundles its rust install, so
the digest already pins it; a separate field would be redundant and could
drift. The broader principle: builds should rely on software already in the
image rather than fetching or installing at build time. Even when the nominal
version is the same, install processes drift (rustup channel manifests update,
mirrors change, installer dependencies move), producing subtly different
on-disk installs that compile to different bytes. Verifiers must also prevent
in-source toolchain selectors (e.g. rust-toolchain.toml) from silently
swapping the toolchain mid-build, typically by exporting RUSTUP_TOOLCHAIN to
the image's default before invoking cargo.
Why repeating bldarg/bldopt fields rather than per-flag fields? Build
commands have many flags — and other arguments too, such as subcommands and
positional values — and gain new ones over time. A field per argument bloats
the spec and forces a revision for each. Repeating shell-argument fields reuse
the build command's own vocabulary as the schema: producers record what they
passed, verifiers replay what they read, the SEP enumerates nothing.
Why allow source identification without a retrieval channel? Contract
developers store and distribute their code in different ways. Some can't expose
source publicly at all (initially private during pre-launch review, permanently
restricted to auditors, or hosted somewhere not publicly reachable). The
content-addressed source_sha256 form covers these cases: the hash commits to
specific source bytes without naming a retrieval channel, so an auditor with
the bytes can verify while the public sees only the hash.
Why an open-ended URI scheme, and why an archive rather than a checkout?
source_uri always names a downloadable archive, but the transport is left
open — https, ftp, ar, or whatever appears next — so the field absorbs
new retrieval mechanisms without a spec revision; consumers support the schemes
they can fetch and ignore the rest. Pointing at an archive rather than a
version-control checkout keeps both producing and verifying simple: there is
one byte stream to fetch and one byte stream to hash, with no per-VCS checkout
logic, no canonical-tree-hash algorithm, and no need to extract anything to
validate. Because the hash is over the artifact's exact bytes, the artifact
must be immutable; the forges developers already use provide durable uploaded
release assets that satisfy this, while their convenient on-the-fly source
archives are deliberately not relied upon because those bytes can change.
Why hash the archive instead of the source tree? source_sha256 hashes one
specific archive rather than a canonical hash computed over the source tree.
Hashing an archive is simple: a single byte stream, hashed with standard tools,
with no canonical-tree-hash algorithm to define or implement. Archives can be
produced reproducibly — tar, for example, emits identical bytes from the same
tree when given reproducible settings — so a producer can regenerate the exact
bytes the hash commits to rather than being forced to retain a single original
copy. The build's reproducibility ultimately depends only on the files
extracted from the archive, not on the archive's framing.
Why require a single top-level directory in the archive? The build runs
with the source mounted at /source (§1). An archive that unpacks its files
directly into the extraction directory, or into several sibling directories,
leaves the verifier guessing which path to mount. Requiring exactly one
top-level directory makes the mount point unambiguous: extract, then mount that
one directory.
Why have both this SEP and SEP-0055? They answer overlapping but distinct questions. SEP-0055 (attestation-based) asks "did a particular trusted CI compile this wasm?", useful when the verifier trusts the CI's signing infrastructure and wants to skip rebuilding. This SEP (vocabulary-based) provides what's needed to actually rebuild and compare bytes. A contract carrying both gives consumers maximum flexibility.
#Security Concerns
- Image trust. A digest pin protects integrity but not honesty: a malicious
image can emit attacker-chosen bytes from any source. Verifiers SHOULD
restrict
bldimgto an allowlist of independently vetted images rather than trust arbitrary digests. - Argument trust.
bldargandbldoptentries are producer-supplied and replayed verbatim against the image's entry point, so they can execute arbitrary stellar-cli subcommands. Verifiers SHOULD validate the recorded arguments and run the build sandboxed. It is reasonable for a verifier to support only a subset of build arguments, refusing to rebuild wasms whosebldargorbldoptentries fall outside it, in much the same way it accepts only a limited set of build images. - Source-host trust. A
source_sha256is content-addressed: the host cannot serve different bytes under the recorded hash without a SHA-256 collision. The host can, however, stop serving the archive at all — through deletion or a moved URI — leaving verifiers without retrievable source. In that casesource_uribecomes a retrieval hint rather than a trust anchor, and a verifier falls back to obtaining the archive by its hash through another channel. - Verifier honesty. A verifier can publish false-positive results. Consumers SHOULD weigh results by verifier reputation and aggregate across independent verifiers.
- Verifier non-determinism. A verifier's own toolchain is itself part of the rebuild environment. A drifting verifier produces noise indistinguishable from genuine build divergence; verifiers SHOULD pin and disclose their own environment.
- What this does not protect against. The vocabulary only enables a third party to confirm that some specific source produces some specific bytes in some specific environment. It says nothing about whether that source is correct or non-malicious.
#Appendix A: Reproducible build with docker.io/stellar/stellar-cli
This appendix shows one way to produce a wasm with the vocabulary fields
embedded, using docker.io/stellar/stellar-cli. Any image with a working rust
toolchain and stellar as the entrypoint works the same way.
Pin the image to a digest. A reproducible build needs a content digest, not
a mutable tag. The digest can be read from Docker Hub: visit the
stellar/stellar-cli tags page
and copy the digest next to the desired tag and architecture. Pick the
single-arch digest, not the multi-arch manifest list digest at the top of the
tag entry; per §1, bldimg must reference a single-arch manifest.
$ IMAGE=docker.io/stellar/stellar-cli:26.0.0@sha256:8b45455a...
Resolve the image's rust toolchain. Read the toolchain name from the image
so it can be passed back in as RUSTUP_TOOLCHAIN. Without this, a
rust-toolchain.toml in the source could cause rustup to silently switch
toolchains mid-build, defeating the image pin.
$ RUSTUP_TOOLCHAIN=$(docker run --rm --entrypoint rustup "$IMAGE" default | cut -d' ' -f1)
$ echo "$RUSTUP_TOOLCHAIN"
1.85.0-x86_64-unknown-linux-gnu
Build. Bind-mount the source at /source and invoke
stellar contract build, recording the vocabulary fields with --meta. Pass
RUSTUP_TOOLCHAIN through with -e so rustup uses the image's toolchain.
$ docker run --rm -v "$PWD:/source" -e RUSTUP_TOOLCHAIN "$IMAGE" \
contract build \
--manifest-path=contracts/foo/Cargo.toml \
--optimize \
--meta bldimg="$IMAGE" \
--meta bldopt=--manifest-path=contracts/foo/Cargo.toml \
--meta bldopt=--optimize \
--meta source_uri=https://example.com/my-contract-v1.0.0.tar.gz \
--meta source_sha256=e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855
The wasm under target/wasm32v1-none/release/ carries those --meta entries
in its contractmetav0 section, plus a bldopt entry for each flag passed.
Verify. A verifier reads the recorded fields and constructs a matching
docker run.
$ stellar contract info meta --wasm original.wasm
ℹ️ Loading contract spec from file...
Contract meta:
• rsver: 1.85.0 (Rust version)
• rssdkver: 26.0.0#abc1234... (Soroban SDK version and its commit hash)
• cliver: 26.0.0#def5678...
• bldimg: docker.io/stellar/stellar-cli@sha256:cb2fc3...
• bldopt: --manifest-path=contracts/foo/Cargo.toml
• bldopt: --optimize
• source_uri: https://example.com/my-contract-v1.0.0.tar.gz
• source_sha256: e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855
The verifier obtains the source (here, by downloading the archive from
source_uri, verifying its bytes against source_sha256, and extracting the
single top-level directory it contains) and re-runs the same docker run with
the recorded bldimg, passing every recorded field back through --meta so
the rebuilt contractmetav0 section is byte-identical. Comparing sha256 of
the rebuilt wasm to the original is the verification result.
#Appendix B: Reproducible build with a rust image and no entry point
While the stellar-cli is the way to build contracts built with the soroban-sdk,
the vocabulary is not tied to stellar contract build, nor to an image with an
entry point. The official rust image has no entry point, so the whole command
is given as arguments and docker run <image> <args> runs <args> directly.
bldarg records the leading arguments, here cargo then build, replacing
the default contract build.
Pin the image by digest and resolve its toolchain as in Appendix A, then build.
The source is mounted at /source and -w runs the build there.
$ docker run --rm -v "$PWD:/source" -w /source -e RUSTUP_TOOLCHAIN "$IMAGE" \
cargo build \
--release \
--target=wasm32v1-none \
--manifest-path=contracts/foo/Cargo.toml \
--locked
bldimg: docker.io/library/rust@sha256:...
bldarg: cargo
bldarg: build
bldopt: --release
bldopt: --target=wasm32v1-none
bldopt: --manifest-path=contracts/foo/Cargo.toml
bldopt: --locked
source_uri: https://example.com/my-contract-v1.0.0.tar.gz
source_sha256: e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855
The bldarg entries cargo then build replace the default contract build,
so a verifier replays cargo build ... rather than
stellar contract build .... The verifier obtains the source, reconstructs the
same docker run from the recorded fields, rebuilds, and compares the wasm's
sha256 to the recorded value.
Had the image instead used cargo as its entry point, cargo would be part of
the entry point rather than an argument, so only build would be recorded as a
bldarg.
#Changelog
v0.6.0- Addedbldarg, an ordered list of arguments injected ahead of thebldoptentries, defaulting tocontractthenbuildwhen absent.v0.5.0- Clarified that this proposal targets the common build workflow.v0.4.0- Replaced thesource_repo/source_rev/tarball_url/tarball_sha256source-identification fields with a requiredsource_sha256and an optionalsource_uriidentifying a single source archive.v0.3.0- Expanded best practices for images.v0.2.0- Revised to focus on the build environment information.v0.1.0- Initial draft.