← All standards
SEP-0058Source ↗

Contract Build Reproducibility for Verification

18 min · 3,917 words

#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 contractmetav0 Wasm 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:

  1. The build environment information that must be known to reliably reproduce a build.
  2. A set of meta entries that contracts can embed to communicate that build information.
  3. A set of meta entries that contracts can embed to communicate the source identity.
  4. 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.

keydescriptionformat regex
bldimgFully-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}$
bldoptA 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_-]*(=.+)?$
bldargA 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-none target installed and any other dependencies needed to run all stellar-cli features and to compile contracts that depend on soroban-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.

keydescriptionformat regex
source_sha256Required. SHA-256 of the source archive's bytes.^[0-9a-f]{64}$
source_uriOptional. 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_sha256 alone: 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 contractmetav0 custom 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 bldimg to an allowlist of independently vetted images rather than trust arbitrary digests.
  • Argument trust. bldarg and bldopt entries 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 whose bldarg or bldopt entries fall outside it, in much the same way it accepts only a limited set of build images.
  • Source-host trust. A source_sha256 is 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 case source_uri becomes 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 - Added bldarg, an ordered list of arguments injected ahead of the bldopt entries, defaulting to contract then build when absent.
  • v0.5.0 - Clarified that this proposal targets the common build workflow.
  • v0.4.0 - Replaced the source_repo/source_rev/tarball_url/tarball_sha256 source-identification fields with a required source_sha256 and an optional source_uri identifying 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.