Add container build backend and build verify command - #2525

Closed
leighmcculloch wants to merge 75 commits into
mainfrom
feat/reproducible-builds-via-docker
Closed

Add container build backend and build verify command#2525
leighmcculloch wants to merge 75 commits into
mainfrom
feat/reproducible-builds-via-docker

Conversation

@leighmcculloch

@leighmccullochleighmcculloch commented Apr 27, 2026

Copy link
Copy Markdown
Member

What

Add --backend docker[=<image>] to stellar contract build (and deploy/upload) that runs the entire build pipeline inside a container whose entrypoint is stellar. Add a stellar contract build verify subcommand that reads everything it needs from the wasm's metadata, rebuilds, and reports which (if any) rebuilt artifact is byte-identical to the original. Add a mainnet warning on stellar contract deploy when the wasm is missing the meta entries needed for independent verification.

Why

Contract builds vary across host OS, architecture, and toolchain, preventing third parties from independently confirming a deployed contract was built from given source. Pinning the build to a docker image plus the rust toolchain version makes builds reproducible, recording the source repo + commit + per-package build options lets verifiers rebuild the exact same artifact, and the new verify subcommand automates the rebuild-and-compare check.

Closes#2506.

How it works

Three parts: build-time recording, deploy-time warning, and verify-time reproduction.

Build

stellar contract build --backend local # default; host build
stellar contract build --backend docker # build inside docker.io/stellar/stellar-cli@sha256:...
stellar contract build --backend docker=stellar/stellar-cli:26.0.0
stellar contract build --backend docker=quay.io/myorg/myimage@sha256:...

For all backends (including local), the build:

  • Detects whether the workspace is a clean git checkout. If clean and there's an origin remote, embeds source_repo (URL canonicalized to https://…), source_rev (full HEAD SHA), and per-package build options (bldopt_manifest_path relative to git root, bldopt_package, bldopt_profile, optional bldopt_optimize). The manifest path is auto-inserted whether or not --manifest-path was passed on the CLI.
  • If the working tree has uncommitted changes, prints a warning and omits source_repo / source_rev / bldopt_*.
  • If not a git repo, silently omits.

For --backend docker, additionally:

  1. Resolves the requested image. Default is docker.io/stellar/stellar-cli@sha256:cb2fc3116a6ace37a77ca6bb88afb4bee57fc746cd556a4373f2c3ee95d4e917 — pinned by digest so the recorded bldimg is reproducible from day one and we sidestep the longstanding Apple Silicon docker quirk where pulling a multi-arch tag with --platform=linux/amd64 leaves RepoDigests empty after pull.
  2. Pulls the image (skipping the pull if it's already locally present, since digest-pinned references are immutable).
  3. Bind-mounts on the container:
    • <git_root or workspace_root>/source (rw, source — also where cargo writes its target dir, shared with the host)
    • host ~/.cargo/registry/usr/local/cargo/registry (rw, cached crate downloads)
  4. The container runs as the host uid:gid, so files written to the bind mount are readable/writable by the host user.
  5. Overrides the image's entrypoint to invoke stellar directly, bypassing the official image's entrypoint.sh (which launches dbus + gnome-keyring and trips when running under a host UID with no /etc/passwd entry — see Docker image's entrypoint dbus init fails when run as non-root UID #2543). contract build doesn't use the keyring, so the wrapper is irrelevant here.
  6. Runs stellar contract build --manifest-path /source/<rel> --profile <p> --locked --meta bldimg=<digest> [forwarded args] inside the container. The args use only flags that exist in published stellar/stellar-cli images today; no new flags are added, and --backend local is deliberately not passed (it's a flag added in this PR and isn't recognized by published images).
  7. The in-container cli does cargo + meta injection + spec filtering + optional wasm-opt itself; the host only orchestrates and copies outputs to --out-dir if requested.

The wasm's contractmetav0 custom section is populated with up to nine entries:

keyvalueregex (validation)injected by
cliver26.0.0#abc1234… (CLI version + git rev)^\d+\.\d+\.\d+(-[A-Za-z0-9.+-]+)?#([0-9a-f]{40}(-dirty)?)?$stellar-cli
bldimgdocker.io/stellar/stellar-cli@sha256:…^[^@\s]+@sha256:[0-9a-f]{64}$stellar-cli (this PR; only with --backend docker)
rsver1.83.0 (resolved rustc version)^\d+\.\d+\.\d+(-[A-Za-z0-9.+-]+)?$soroban-sdk
source_repohttps://github.com/user/repo (clean repo's origin)^https?://\S+$stellar-cli (this PR)
source_revfull 40-char HEAD SHA^[0-9a-f]{40}$stellar-cli (this PR)
bldopt_manifest_pathe.g. contracts/foo/Cargo.toml (relative to git)^([^/\s]+/)*Cargo\.toml$stellar-cli (this PR)
bldopt_packagecargo package name being built^[A-Za-z][A-Za-z0-9_-]*$stellar-cli (this PR)
bldopt_profilecargo profile (e.g. release)^[A-Za-z][A-Za-z0-9_-]*$stellar-cli (this PR)
bldopt_optimizetrue (only present when --optimize was used)^true$stellar-cli (this PR)

The presence of bldimg is what distinguishes a docker build from a local one — there's no separate bldbkd field. For full reproducibility from day one, pin to a specific image with --backend docker=<name>@sha256:… and commit before building.

--backend and --docker-host are also exposed on stellar contract deploy and stellar contract upload (which auto-build when no --wasm / --wasm-hash is given), so the same flags work end-to-end.

Deploy

stellar contract deploy against mainnet now warns when the wasm is missing any of cliver, bldimg, rsver, source_repo, source_rev, bldopt_manifest_path, bldopt_package, bldopt_profile:

⚠ the wasm being deployed is missing reproducibility meta entries: ["bldimg", "source_repo", "source_rev", "bldopt_manifest_path", "bldopt_package", "bldopt_profile"]. The deployed wasm may not be independently verifiable. To make it reproducible, build with `stellar contract build --backend docker` in a clean git repository.

The check is mainnet-only (matches network passphrase against Public Global Stellar Network ; September 2015); on testnet/futurenet/local the wasm deploys silently.

Verify

verify is a subcommand of build — it lives at stellar contract build verify, and works on multi-contract workspaces by rebuilding and finding the match.

stellar contract build verify --contract-id CXXX… --network mainnet
stellar contract build verify --wasm-hash <hash> --network mainnet
stellar contract build verify --wasm contract.wasm
  1. Fetches the original wasm (file path, hash, or contract id, same flags as contract info).
  2. Reads cliver, bldimg (optional), rsver, and bldopt_* (optional, best-effort) from the wasm's meta. Missing bldopt_* entries trigger a warning rather than an error and the build falls back to its defaults — verify still runs, just with the caveat that the rebuild may not be reproducible.
  3. Picks the rebuild backend from the meta:
    • bldimg present → Backend::Docker { image: bldimg }. The image's pinned digest pulls the same in-container cli that produced the original.
    • bldimg absent → Backend::Local. Best-effort rebuild on the host.
  4. Forwards the wasm's rsver to the rebuild as RUSTUP_TOOLCHAIN (in-container) or cargo +<rsver> (local). For docker the toolchain inside the image is fixed by whoever built it; passing RUSTUP_TOOLCHAIN lets rustup-managed cargo switch toolchains if the image carries multiple ones.
  5. Resolves bldopt_manifest_path against the cwd's git top-level (via git rev-parse --show-toplevel) so verify works from anywhere inside the checkout.
  6. Hashes every rebuilt artifact and looks for a match against the original. Prints ✅ on match (with the matching crate's name); ⚠ + non-zero exit on mismatch (with each rebuilt artifact's name + hash).

The user is responsible for checking out the matching commit before running verify; verify rebuilds from the working tree. (source_repo and source_rev are embedded in meta to help users find the right commit, but verify itself doesn't clone — that would add a separate trust path.)

End-to-end example

$ stellar contract build --backend dockerℹ Pulling from stellar/stellar-cli Digest: sha256:cb2fc3116a6ace37a77ca6bb88afb4bee57fc746cd556a4373f2c3ee95d4e917 Status: Image is up to date for stellar/stellar-cli@sha256:cb2fc3...ℹ contract build --manifest-path /source/contracts/foo/Cargo.toml --profile release --locked --meta bldimg=docker.io/stellar/stellar-cli@sha256:cb2fc3... Compiling foo v… Finished `release` profile [optimized] target(s) in 1.09sℹ Build Summary: Wasm File: target/wasm32v1-none/release/foo.wasm (907 bytes) Wasm Hash: 9f86d081…✅ Build Complete
$ stellar contract info meta --wasm target/wasm32v1-none/release/foo.wasmcliver=26.0.0#abc1234bldimg=docker.io/stellar/stellar-cli@sha256:cb2fc3...rsver=1.83.0source_repo=https://github.com/user/my-contractsource_rev=abc1234567890abcdef…bldopt_manifest_path=contracts/foo/Cargo.tomlbldopt_package=foobldopt_profile=release
# Later, on a different machine, with the matching commit checked out:
$ stellar contract build verify --wasm-hash <hash> --network mainnetℹ Loading contract from network...ℹ Loading meta from contract... Original wasm hash: 9f86d081… stellar-cli version: 26.0.0#abc1234 rust version: 1.83.0 Docker image: docker.io/stellar/stellar-cli@sha256:cb2fc3... Manifest path: contracts/foo/Cargo.toml Package: foo Profile: releaseℹ contract build --manifest-path /source/contracts/foo/Cargo.toml --profile release --locked --meta bldimg=docker.io/stellar/stellar-cli@sha256:cb2fc3... Compiling foo v…✅ Build Complete✅ Verified: rebuilt foo wasm matches 9f86d081…

The host CLI's version is irrelevant for verifying a docker-built wasm — whatever cli is in the image is what built (and rebuilds) the wasm.

Notes

  • Communication with the daemon: bollard's HTTP API over the docker socket (/var/run/docker.sock, or whatever --docker-host / DOCKER_HOST points at). Same connect_to_docker helper used by stellar container start/stop/logs, with the same Docker Desktop fallback ($HOME/.docker/run/docker.sock). No shell-out to the docker CLI. A podman socket exposing the Docker API would also work (untested).
  • Default image is digest-pinned: --backend docker (no =...) defaults to docker.io/stellar/stellar-cli@sha256:cb2fc3..., notstellar/stellar-cli:latest. Recording a digest immediately makes builds reproducible day one and avoids the Apple Silicon RepoDigests-after-cross-platform-pull quirk. Bumping the default is a single-line const change in build.rs (see comments there for the recipe). Users who want a different image specify --backend docker=....
  • Entrypoint override: the official stellar/stellar-cli image's entrypoint runs entrypoint.sh, which launches dbus + gnome-keyring. That setup fails when the container runs as a host UID without an /etc/passwd entry — see Docker image's entrypoint dbus init fails when run as non-root UID #2543. We override the entrypoint to point straight at the stellar binary, which is fine because contract build doesn't touch the keyring.
  • Caching: the bind-mount of host ~/.cargo/registry lets the container reuse crate downloads the host already has.
  • Wasm target installation: deferred to the image. The official image has wasm32v1-none pre-installed for its default toolchain; if RUSTUP_TOOLCHAIN selects a different one (verify on a wasm built with another rust version), the cli/cargo handle target installation themselves.
  • Toolchain pinning: verify sets RUSTUP_TOOLCHAIN=<rsver> inside the container (and cargo +<rsver> for local rebuilds) so the rust version matches whatever the original build used.
  • Image fully-qualified: bldimg is normalized to <registry>/<path>@sha256:<digest> (e.g. stellar/stellar-cli:latestdocker.io/stellar/stellar-cli@sha256:…) so verify can resolve it without relying on the local registry config.
  • Source URL canonicalization: source_repo is normalized to https://… form (e.g. git@github.com:user/repo.githttps://github.com/user/repo).
  • Build options auto-recorded: bldopt_manifest_path is recorded relative to the git repo root regardless of whether --manifest-path was passed on the CLI. Verify resolves it against the cwd's git top-level so the command works from anywhere inside the checkout.
  • No new in-container flags: the host invokes stellar contract build inside the image with only flags that exist in published stellar/stellar-cli images today (--manifest-path, --profile, --locked, --meta, --package, --features, --all-features, --no-default-features, --optimize). bldimg is forwarded via --meta bldimg=<digest>, not a new flag.
  • No bldbkd field: presence of bldimg is the only signal needed to distinguish a docker build from a local one.
  • Aborted container runs: may leave a stopped container; clean with docker container prune.

Performance/runtime caveats

Building inside an amd64 container on a non-amd64 host (Apple Silicon, Linux/arm64) runs under emulation. For small contracts the difference is negligible; for workspaces with heavy dep trees the emulated build can be substantially slower than a native host build. Container runtimes that don't ship qemu/binfmt support won't run amd64 containers on arm64 hosts at all. See #2506 (comment).

Related issues

Status

This is an experiment in validating the ideas in #2506. May or may not be destined for merging — at this moment it's an experiment in validating the approach.

@github-project-automationgithub-project-automationBot moved this to Backlog (Not Ready) in DevXApr 27, 2026
@leighmcculloch

leighmcculloch commented May 1, 2026

Copy link
Copy Markdown
MemberAuthor

Opened an issue about dbus creating problems with using the image for the build for the verification step:

@fnando

Copy link
Copy Markdown
Member

@leighmcculloch I just tried this, but I'm getting a warning, even though there are no unstaged files.

$ git statusOn branch mainnothing to commit, working tree clean
$ stellar contract build --backend docker⚠️ git working tree has uncommitted changes; source_repo/source_rev/bldopt_* not embedded in contract metadata. Commit changes for a reproducible build.

}

let backend = match bldimg {
Some(image) => build::Backend::Docker { image },

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

bldimg needs some form of allowlist, e.g. docker.io/stellar/stellar-cli@sha256:*, otherwise I can inject a docker image that bypasses verification

}
});

let build_cmd = build::Cmd {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we need feature flags here? While not extremely common, I have used them in the past to reduce code duplication between similar contracts. Ref -> https://github.com/script3/soroban-governor/tree/main/contracts/votes

Comment on lines +172 to +176
// - The official `stellar/stellar-cli` image's stock entrypoint is a
// wrapper script that launches dbus + gnome-keyring before exec-ing
// `stellar`; that setup is irrelevant for `contract build` and dbus
// refuses to start when the container runs as a host UID with no
// `/etc/passwd` entry. Skipping it keeps the host UID mapping intact.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This was cleaned up in a recent PR. Does this simplify anything?

attach_stdout: Some(true),
attach_stderr: Some(true),
host_config: Some(HostConfig {
binds: Some(binds),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Have we considered removing the host bind mounts? Given verify will build untrusted code, it might be best if we keep the build artifacts within the container, then just extract the WASM file out.

We could consider having two configurations docker-build and docker-verify, where build keeps mounts to help speed up repeated builds and verify is more black-box to provide a bit more protection.

Comment on lines +71 to +77
let cliver = find_meta(&spec.meta, "cliver").ok_or(Error::MissingMeta("cliver"))?;
let bldimg = find_meta(&spec.meta, "bldimg");
let rsver = find_meta(&spec.meta, "rsver").ok_or(Error::MissingMeta("rsver"))?;
let bldopt_manifest_path = find_meta(&spec.meta, "bldopt_manifest_path");
let bldopt_package = find_meta(&spec.meta, "bldopt_package");
let bldopt_profile = find_meta(&spec.meta, "bldopt_profile");
let bldopt_optimize = find_meta(&spec.meta, "bldopt_optimize").is_some();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we should enforce the regex here

@leighmcculloch

Copy link
Copy Markdown
MemberAuthor

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

Add --docker option and stellar contract verify for reproducible builds

4 participants

@leighmcculloch@fnando@mootz12@chadoh
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content

Add container build backend and build verify command - #2525

Closed
leighmcculloch wants to merge 75 commits into
mainfrom
feat/reproducible-builds-via-docker
Closed

Add container build backend and build verify command#2525
leighmcculloch wants to merge 75 commits into
mainfrom
feat/reproducible-builds-via-docker

Conversation

@leighmcculloch

@leighmccullochleighmcculloch commented Apr 27, 2026

Copy link
Copy Markdown
Member

What

Add --backend docker[=<image>] to stellar contract build (and deploy/upload) that runs the entire build pipeline inside a container whose entrypoint is stellar. Add a stellar contract build verify subcommand that reads everything it needs from the wasm's metadata, rebuilds, and reports which (if any) rebuilt artifact is byte-identical to the original. Add a mainnet warning on stellar contract deploy when the wasm is missing the meta entries needed for independent verification.

Why

Contract builds vary across host OS, architecture, and toolchain, preventing third parties from independently confirming a deployed contract was built from given source. Pinning the build to a docker image plus the rust toolchain version makes builds reproducible, recording the source repo + commit + per-package build options lets verifiers rebuild the exact same artifact, and the new verify subcommand automates the rebuild-and-compare check.

Closes#2506.

How it works

Three parts: build-time recording, deploy-time warning, and verify-time reproduction.

Build

stellar contract build --backend local # default; host build
stellar contract build --backend docker # build inside docker.io/stellar/stellar-cli@sha256:...
stellar contract build --backend docker=stellar/stellar-cli:26.0.0
stellar contract build --backend docker=quay.io/myorg/myimage@sha256:...

For all backends (including local), the build:

  • Detects whether the workspace is a clean git checkout. If clean and there's an origin remote, embeds source_repo (URL canonicalized to https://…), source_rev (full HEAD SHA), and per-package build options (bldopt_manifest_path relative to git root, bldopt_package, bldopt_profile, optional bldopt_optimize). The manifest path is auto-inserted whether or not --manifest-path was passed on the CLI.
  • If the working tree has uncommitted changes, prints a warning and omits source_repo / source_rev / bldopt_*.
  • If not a git repo, silently omits.

For --backend docker, additionally:

  1. Resolves the requested image. Default is docker.io/stellar/stellar-cli@sha256:cb2fc3116a6ace37a77ca6bb88afb4bee57fc746cd556a4373f2c3ee95d4e917 — pinned by digest so the recorded bldimg is reproducible from day one and we sidestep the longstanding Apple Silicon docker quirk where pulling a multi-arch tag with --platform=linux/amd64 leaves RepoDigests empty after pull.
  2. Pulls the image (skipping the pull if it's already locally present, since digest-pinned references are immutable).
  3. Bind-mounts on the container:
    • <git_root or workspace_root>/source (rw, source — also where cargo writes its target dir, shared with the host)
    • host ~/.cargo/registry/usr/local/cargo/registry (rw, cached crate downloads)
  4. The container runs as the host uid:gid, so files written to the bind mount are readable/writable by the host user.
  5. Overrides the image's entrypoint to invoke stellar directly, bypassing the official image's entrypoint.sh (which launches dbus + gnome-keyring and trips when running under a host UID with no /etc/passwd entry — see Docker image's entrypoint dbus init fails when run as non-root UID #2543). contract build doesn't use the keyring, so the wrapper is irrelevant here.
  6. Runs stellar contract build --manifest-path /source/<rel> --profile <p> --locked --meta bldimg=<digest> [forwarded args] inside the container. The args use only flags that exist in published stellar/stellar-cli images today; no new flags are added, and --backend local is deliberately not passed (it's a flag added in this PR and isn't recognized by published images).
  7. The in-container cli does cargo + meta injection + spec filtering + optional wasm-opt itself; the host only orchestrates and copies outputs to --out-dir if requested.

The wasm's contractmetav0 custom section is populated with up to nine entries:

keyvalueregex (validation)injected by
cliver26.0.0#abc1234… (CLI version + git rev)^\d+\.\d+\.\d+(-[A-Za-z0-9.+-]+)?#([0-9a-f]{40}(-dirty)?)?$stellar-cli
bldimgdocker.io/stellar/stellar-cli@sha256:…^[^@\s]+@sha256:[0-9a-f]{64}$stellar-cli (this PR; only with --backend docker)
rsver1.83.0 (resolved rustc version)^\d+\.\d+\.\d+(-[A-Za-z0-9.+-]+)?$soroban-sdk
source_repohttps://github.com/user/repo (clean repo's origin)^https?://\S+$stellar-cli (this PR)
source_revfull 40-char HEAD SHA^[0-9a-f]{40}$stellar-cli (this PR)
bldopt_manifest_pathe.g. contracts/foo/Cargo.toml (relative to git)^([^/\s]+/)*Cargo\.toml$stellar-cli (this PR)
bldopt_packagecargo package name being built^[A-Za-z][A-Za-z0-9_-]*$stellar-cli (this PR)
bldopt_profilecargo profile (e.g. release)^[A-Za-z][A-Za-z0-9_-]*$stellar-cli (this PR)
bldopt_optimizetrue (only present when --optimize was used)^true$stellar-cli (this PR)

The presence of bldimg is what distinguishes a docker build from a local one — there's no separate bldbkd field. For full reproducibility from day one, pin to a specific image with --backend docker=<name>@sha256:… and commit before building.

--backend and --docker-host are also exposed on stellar contract deploy and stellar contract upload (which auto-build when no --wasm / --wasm-hash is given), so the same flags work end-to-end.

Deploy

stellar contract deploy against mainnet now warns when the wasm is missing any of cliver, bldimg, rsver, source_repo, source_rev, bldopt_manifest_path, bldopt_package, bldopt_profile:

⚠ the wasm being deployed is missing reproducibility meta entries: ["bldimg", "source_repo", "source_rev", "bldopt_manifest_path", "bldopt_package", "bldopt_profile"]. The deployed wasm may not be independently verifiable. To make it reproducible, build with `stellar contract build --backend docker` in a clean git repository.

The check is mainnet-only (matches network passphrase against Public Global Stellar Network ; September 2015); on testnet/futurenet/local the wasm deploys silently.

Verify

verify is a subcommand of build — it lives at stellar contract build verify, and works on multi-contract workspaces by rebuilding and finding the match.

stellar contract build verify --contract-id CXXX… --network mainnet
stellar contract build verify --wasm-hash <hash> --network mainnet
stellar contract build verify --wasm contract.wasm
  1. Fetches the original wasm (file path, hash, or contract id, same flags as contract info).
  2. Reads cliver, bldimg (optional), rsver, and bldopt_* (optional, best-effort) from the wasm's meta. Missing bldopt_* entries trigger a warning rather than an error and the build falls back to its defaults — verify still runs, just with the caveat that the rebuild may not be reproducible.
  3. Picks the rebuild backend from the meta:
    • bldimg present → Backend::Docker { image: bldimg }. The image's pinned digest pulls the same in-container cli that produced the original.
    • bldimg absent → Backend::Local. Best-effort rebuild on the host.
  4. Forwards the wasm's rsver to the rebuild as RUSTUP_TOOLCHAIN (in-container) or cargo +<rsver> (local). For docker the toolchain inside the image is fixed by whoever built it; passing RUSTUP_TOOLCHAIN lets rustup-managed cargo switch toolchains if the image carries multiple ones.
  5. Resolves bldopt_manifest_path against the cwd's git top-level (via git rev-parse --show-toplevel) so verify works from anywhere inside the checkout.
  6. Hashes every rebuilt artifact and looks for a match against the original. Prints ✅ on match (with the matching crate's name); ⚠ + non-zero exit on mismatch (with each rebuilt artifact's name + hash).

The user is responsible for checking out the matching commit before running verify; verify rebuilds from the working tree. (source_repo and source_rev are embedded in meta to help users find the right commit, but verify itself doesn't clone — that would add a separate trust path.)

End-to-end example

$ stellar contract build --backend dockerℹ Pulling from stellar/stellar-cli Digest: sha256:cb2fc3116a6ace37a77ca6bb88afb4bee57fc746cd556a4373f2c3ee95d4e917 Status: Image is up to date for stellar/stellar-cli@sha256:cb2fc3...ℹ contract build --manifest-path /source/contracts/foo/Cargo.toml --profile release --locked --meta bldimg=docker.io/stellar/stellar-cli@sha256:cb2fc3... Compiling foo v… Finished `release` profile [optimized] target(s) in 1.09sℹ Build Summary: Wasm File: target/wasm32v1-none/release/foo.wasm (907 bytes) Wasm Hash: 9f86d081…✅ Build Complete
$ stellar contract info meta --wasm target/wasm32v1-none/release/foo.wasmcliver=26.0.0#abc1234bldimg=docker.io/stellar/stellar-cli@sha256:cb2fc3...rsver=1.83.0source_repo=https://github.com/user/my-contractsource_rev=abc1234567890abcdef…bldopt_manifest_path=contracts/foo/Cargo.tomlbldopt_package=foobldopt_profile=release
# Later, on a different machine, with the matching commit checked out:
$ stellar contract build verify --wasm-hash <hash> --network mainnetℹ Loading contract from network...ℹ Loading meta from contract... Original wasm hash: 9f86d081… stellar-cli version: 26.0.0#abc1234 rust version: 1.83.0 Docker image: docker.io/stellar/stellar-cli@sha256:cb2fc3... Manifest path: contracts/foo/Cargo.toml Package: foo Profile: releaseℹ contract build --manifest-path /source/contracts/foo/Cargo.toml --profile release --locked --meta bldimg=docker.io/stellar/stellar-cli@sha256:cb2fc3... Compiling foo v…✅ Build Complete✅ Verified: rebuilt foo wasm matches 9f86d081…

The host CLI's version is irrelevant for verifying a docker-built wasm — whatever cli is in the image is what built (and rebuilds) the wasm.

Notes

  • Communication with the daemon: bollard's HTTP API over the docker socket (/var/run/docker.sock, or whatever --docker-host / DOCKER_HOST points at). Same connect_to_docker helper used by stellar container start/stop/logs, with the same Docker Desktop fallback ($HOME/.docker/run/docker.sock). No shell-out to the docker CLI. A podman socket exposing the Docker API would also work (untested).
  • Default image is digest-pinned: --backend docker (no =...) defaults to docker.io/stellar/stellar-cli@sha256:cb2fc3..., notstellar/stellar-cli:latest. Recording a digest immediately makes builds reproducible day one and avoids the Apple Silicon RepoDigests-after-cross-platform-pull quirk. Bumping the default is a single-line const change in build.rs (see comments there for the recipe). Users who want a different image specify --backend docker=....
  • Entrypoint override: the official stellar/stellar-cli image's entrypoint runs entrypoint.sh, which launches dbus + gnome-keyring. That setup fails when the container runs as a host UID without an /etc/passwd entry — see Docker image's entrypoint dbus init fails when run as non-root UID #2543. We override the entrypoint to point straight at the stellar binary, which is fine because contract build doesn't touch the keyring.
  • Caching: the bind-mount of host ~/.cargo/registry lets the container reuse crate downloads the host already has.
  • Wasm target installation: deferred to the image. The official image has wasm32v1-none pre-installed for its default toolchain; if RUSTUP_TOOLCHAIN selects a different one (verify on a wasm built with another rust version), the cli/cargo handle target installation themselves.
  • Toolchain pinning: verify sets RUSTUP_TOOLCHAIN=<rsver> inside the container (and cargo +<rsver> for local rebuilds) so the rust version matches whatever the original build used.
  • Image fully-qualified: bldimg is normalized to <registry>/<path>@sha256:<digest> (e.g. stellar/stellar-cli:latestdocker.io/stellar/stellar-cli@sha256:…) so verify can resolve it without relying on the local registry config.
  • Source URL canonicalization: source_repo is normalized to https://… form (e.g. git@github.com:user/repo.githttps://github.com/user/repo).
  • Build options auto-recorded: bldopt_manifest_path is recorded relative to the git repo root regardless of whether --manifest-path was passed on the CLI. Verify resolves it against the cwd's git top-level so the command works from anywhere inside the checkout.
  • No new in-container flags: the host invokes stellar contract build inside the image with only flags that exist in published stellar/stellar-cli images today (--manifest-path, --profile, --locked, --meta, --package, --features, --all-features, --no-default-features, --optimize). bldimg is forwarded via --meta bldimg=<digest>, not a new flag.
  • No bldbkd field: presence of bldimg is the only signal needed to distinguish a docker build from a local one.
  • Aborted container runs: may leave a stopped container; clean with docker container prune.

Performance/runtime caveats

Building inside an amd64 container on a non-amd64 host (Apple Silicon, Linux/arm64) runs under emulation. For small contracts the difference is negligible; for workspaces with heavy dep trees the emulated build can be substantially slower than a native host build. Container runtimes that don't ship qemu/binfmt support won't run amd64 containers on arm64 hosts at all. See #2506 (comment).

Related issues

Status

This is an experiment in validating the ideas in #2506. May or may not be destined for merging — at this moment it's an experiment in validating the approach.

@github-project-automationgithub-project-automationBot moved this to Backlog (Not Ready) in DevXApr 27, 2026
@leighmcculloch

leighmcculloch commented May 1, 2026

Copy link
Copy Markdown
MemberAuthor

Opened an issue about dbus creating problems with using the image for the build for the verification step:

@fnando

Copy link
Copy Markdown
Member

@leighmcculloch I just tried this, but I'm getting a warning, even though there are no unstaged files.

$ git statusOn branch mainnothing to commit, working tree clean
$ stellar contract build --backend docker⚠️ git working tree has uncommitted changes; source_repo/source_rev/bldopt_* not embedded in contract metadata. Commit changes for a reproducible build.

}

let backend = match bldimg {
Some(image) => build::Backend::Docker { image },

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

bldimg needs some form of allowlist, e.g. docker.io/stellar/stellar-cli@sha256:*, otherwise I can inject a docker image that bypasses verification

}
});

let build_cmd = build::Cmd {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we need feature flags here? While not extremely common, I have used them in the past to reduce code duplication between similar contracts. Ref -> https://github.com/script3/soroban-governor/tree/main/contracts/votes

Comment on lines +172 to +176
// - The official `stellar/stellar-cli` image's stock entrypoint is a
// wrapper script that launches dbus + gnome-keyring before exec-ing
// `stellar`; that setup is irrelevant for `contract build` and dbus
// refuses to start when the container runs as a host UID with no
// `/etc/passwd` entry. Skipping it keeps the host UID mapping intact.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This was cleaned up in a recent PR. Does this simplify anything?

attach_stdout: Some(true),
attach_stderr: Some(true),
host_config: Some(HostConfig {
binds: Some(binds),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Have we considered removing the host bind mounts? Given verify will build untrusted code, it might be best if we keep the build artifacts within the container, then just extract the WASM file out.

We could consider having two configurations docker-build and docker-verify, where build keeps mounts to help speed up repeated builds and verify is more black-box to provide a bit more protection.

Comment on lines +71 to +77
let cliver = find_meta(&spec.meta, "cliver").ok_or(Error::MissingMeta("cliver"))?;
let bldimg = find_meta(&spec.meta, "bldimg");
let rsver = find_meta(&spec.meta, "rsver").ok_or(Error::MissingMeta("rsver"))?;
let bldopt_manifest_path = find_meta(&spec.meta, "bldopt_manifest_path");
let bldopt_package = find_meta(&spec.meta, "bldopt_package");
let bldopt_profile = find_meta(&spec.meta, "bldopt_profile");
let bldopt_optimize = find_meta(&spec.meta, "bldopt_optimize").is_some();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we should enforce the regex here

@leighmcculloch

Copy link
Copy Markdown
MemberAuthor

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

Add --docker option and stellar contract verify for reproducible builds

4 participants

@leighmcculloch@fnando@mootz12@chadoh
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Add container build backend and build verify command - #2525

Closed
leighmcculloch wants to merge 75 commits into
mainfrom
feat/reproducible-builds-via-docker
Closed

Add container build backend and build verify command#2525
leighmcculloch wants to merge 75 commits into
mainfrom
feat/reproducible-builds-via-docker

Conversation

@leighmcculloch

@leighmccullochleighmcculloch commented Apr 27, 2026

Copy link
Copy Markdown
Member

What

Add --backend docker[=<image>] to stellar contract build (and deploy/upload) that runs the entire build pipeline inside a container whose entrypoint is stellar. Add a stellar contract build verify subcommand that reads everything it needs from the wasm's metadata, rebuilds, and reports which (if any) rebuilt artifact is byte-identical to the original. Add a mainnet warning on stellar contract deploy when the wasm is missing the meta entries needed for independent verification.

Why

Contract builds vary across host OS, architecture, and toolchain, preventing third parties from independently confirming a deployed contract was built from given source. Pinning the build to a docker image plus the rust toolchain version makes builds reproducible, recording the source repo + commit + per-package build options lets verifiers rebuild the exact same artifact, and the new verify subcommand automates the rebuild-and-compare check.

Closes#2506.

How it works

Three parts: build-time recording, deploy-time warning, and verify-time reproduction.

Build

stellar contract build --backend local # default; host build
stellar contract build --backend docker # build inside docker.io/stellar/stellar-cli@sha256:...
stellar contract build --backend docker=stellar/stellar-cli:26.0.0
stellar contract build --backend docker=quay.io/myorg/myimage@sha256:...

For all backends (including local), the build:

  • Detects whether the workspace is a clean git checkout. If clean and there's an origin remote, embeds source_repo (URL canonicalized to https://…), source_rev (full HEAD SHA), and per-package build options (bldopt_manifest_path relative to git root, bldopt_package, bldopt_profile, optional bldopt_optimize). The manifest path is auto-inserted whether or not --manifest-path was passed on the CLI.
  • If the working tree has uncommitted changes, prints a warning and omits source_repo / source_rev / bldopt_*.
  • If not a git repo, silently omits.

For --backend docker, additionally:

  1. Resolves the requested image. Default is docker.io/stellar/stellar-cli@sha256:cb2fc3116a6ace37a77ca6bb88afb4bee57fc746cd556a4373f2c3ee95d4e917 — pinned by digest so the recorded bldimg is reproducible from day one and we sidestep the longstanding Apple Silicon docker quirk where pulling a multi-arch tag with --platform=linux/amd64 leaves RepoDigests empty after pull.
  2. Pulls the image (skipping the pull if it's already locally present, since digest-pinned references are immutable).
  3. Bind-mounts on the container:
    • <git_root or workspace_root>/source (rw, source — also where cargo writes its target dir, shared with the host)
    • host ~/.cargo/registry/usr/local/cargo/registry (rw, cached crate downloads)
  4. The container runs as the host uid:gid, so files written to the bind mount are readable/writable by the host user.
  5. Overrides the image's entrypoint to invoke stellar directly, bypassing the official image's entrypoint.sh (which launches dbus + gnome-keyring and trips when running under a host UID with no /etc/passwd entry — see Docker image's entrypoint dbus init fails when run as non-root UID #2543). contract build doesn't use the keyring, so the wrapper is irrelevant here.
  6. Runs stellar contract build --manifest-path /source/<rel> --profile <p> --locked --meta bldimg=<digest> [forwarded args] inside the container. The args use only flags that exist in published stellar/stellar-cli images today; no new flags are added, and --backend local is deliberately not passed (it's a flag added in this PR and isn't recognized by published images).
  7. The in-container cli does cargo + meta injection + spec filtering + optional wasm-opt itself; the host only orchestrates and copies outputs to --out-dir if requested.

The wasm's contractmetav0 custom section is populated with up to nine entries:

keyvalueregex (validation)injected by
cliver26.0.0#abc1234… (CLI version + git rev)^\d+\.\d+\.\d+(-[A-Za-z0-9.+-]+)?#([0-9a-f]{40}(-dirty)?)?$stellar-cli
bldimgdocker.io/stellar/stellar-cli@sha256:…^[^@\s]+@sha256:[0-9a-f]{64}$stellar-cli (this PR; only with --backend docker)
rsver1.83.0 (resolved rustc version)^\d+\.\d+\.\d+(-[A-Za-z0-9.+-]+)?$soroban-sdk
source_repohttps://github.com/user/repo (clean repo's origin)^https?://\S+$stellar-cli (this PR)
source_revfull 40-char HEAD SHA^[0-9a-f]{40}$stellar-cli (this PR)
bldopt_manifest_pathe.g. contracts/foo/Cargo.toml (relative to git)^([^/\s]+/)*Cargo\.toml$stellar-cli (this PR)
bldopt_packagecargo package name being built^[A-Za-z][A-Za-z0-9_-]*$stellar-cli (this PR)
bldopt_profilecargo profile (e.g. release)^[A-Za-z][A-Za-z0-9_-]*$stellar-cli (this PR)
bldopt_optimizetrue (only present when --optimize was used)^true$stellar-cli (this PR)

The presence of bldimg is what distinguishes a docker build from a local one — there's no separate bldbkd field. For full reproducibility from day one, pin to a specific image with --backend docker=<name>@sha256:… and commit before building.

--backend and --docker-host are also exposed on stellar contract deploy and stellar contract upload (which auto-build when no --wasm / --wasm-hash is given), so the same flags work end-to-end.

Deploy

stellar contract deploy against mainnet now warns when the wasm is missing any of cliver, bldimg, rsver, source_repo, source_rev, bldopt_manifest_path, bldopt_package, bldopt_profile:

⚠ the wasm being deployed is missing reproducibility meta entries: ["bldimg", "source_repo", "source_rev", "bldopt_manifest_path", "bldopt_package", "bldopt_profile"]. The deployed wasm may not be independently verifiable. To make it reproducible, build with `stellar contract build --backend docker` in a clean git repository.

The check is mainnet-only (matches network passphrase against Public Global Stellar Network ; September 2015); on testnet/futurenet/local the wasm deploys silently.

Verify

verify is a subcommand of build — it lives at stellar contract build verify, and works on multi-contract workspaces by rebuilding and finding the match.

stellar contract build verify --contract-id CXXX… --network mainnet
stellar contract build verify --wasm-hash <hash> --network mainnet
stellar contract build verify --wasm contract.wasm
  1. Fetches the original wasm (file path, hash, or contract id, same flags as contract info).
  2. Reads cliver, bldimg (optional), rsver, and bldopt_* (optional, best-effort) from the wasm's meta. Missing bldopt_* entries trigger a warning rather than an error and the build falls back to its defaults — verify still runs, just with the caveat that the rebuild may not be reproducible.
  3. Picks the rebuild backend from the meta:
    • bldimg present → Backend::Docker { image: bldimg }. The image's pinned digest pulls the same in-container cli that produced the original.
    • bldimg absent → Backend::Local. Best-effort rebuild on the host.
  4. Forwards the wasm's rsver to the rebuild as RUSTUP_TOOLCHAIN (in-container) or cargo +<rsver> (local). For docker the toolchain inside the image is fixed by whoever built it; passing RUSTUP_TOOLCHAIN lets rustup-managed cargo switch toolchains if the image carries multiple ones.
  5. Resolves bldopt_manifest_path against the cwd's git top-level (via git rev-parse --show-toplevel) so verify works from anywhere inside the checkout.
  6. Hashes every rebuilt artifact and looks for a match against the original. Prints ✅ on match (with the matching crate's name); ⚠ + non-zero exit on mismatch (with each rebuilt artifact's name + hash).

The user is responsible for checking out the matching commit before running verify; verify rebuilds from the working tree. (source_repo and source_rev are embedded in meta to help users find the right commit, but verify itself doesn't clone — that would add a separate trust path.)

End-to-end example

$ stellar contract build --backend dockerℹ Pulling from stellar/stellar-cli Digest: sha256:cb2fc3116a6ace37a77ca6bb88afb4bee57fc746cd556a4373f2c3ee95d4e917 Status: Image is up to date for stellar/stellar-cli@sha256:cb2fc3...ℹ contract build --manifest-path /source/contracts/foo/Cargo.toml --profile release --locked --meta bldimg=docker.io/stellar/stellar-cli@sha256:cb2fc3... Compiling foo v… Finished `release` profile [optimized] target(s) in 1.09sℹ Build Summary: Wasm File: target/wasm32v1-none/release/foo.wasm (907 bytes) Wasm Hash: 9f86d081…✅ Build Complete
$ stellar contract info meta --wasm target/wasm32v1-none/release/foo.wasmcliver=26.0.0#abc1234bldimg=docker.io/stellar/stellar-cli@sha256:cb2fc3...rsver=1.83.0source_repo=https://github.com/user/my-contractsource_rev=abc1234567890abcdef…bldopt_manifest_path=contracts/foo/Cargo.tomlbldopt_package=foobldopt_profile=release
# Later, on a different machine, with the matching commit checked out:
$ stellar contract build verify --wasm-hash <hash> --network mainnetℹ Loading contract from network...ℹ Loading meta from contract... Original wasm hash: 9f86d081… stellar-cli version: 26.0.0#abc1234 rust version: 1.83.0 Docker image: docker.io/stellar/stellar-cli@sha256:cb2fc3... Manifest path: contracts/foo/Cargo.toml Package: foo Profile: releaseℹ contract build --manifest-path /source/contracts/foo/Cargo.toml --profile release --locked --meta bldimg=docker.io/stellar/stellar-cli@sha256:cb2fc3... Compiling foo v…✅ Build Complete✅ Verified: rebuilt foo wasm matches 9f86d081…

The host CLI's version is irrelevant for verifying a docker-built wasm — whatever cli is in the image is what built (and rebuilds) the wasm.

Notes

  • Communication with the daemon: bollard's HTTP API over the docker socket (/var/run/docker.sock, or whatever --docker-host / DOCKER_HOST points at). Same connect_to_docker helper used by stellar container start/stop/logs, with the same Docker Desktop fallback ($HOME/.docker/run/docker.sock). No shell-out to the docker CLI. A podman socket exposing the Docker API would also work (untested).
  • Default image is digest-pinned: --backend docker (no =...) defaults to docker.io/stellar/stellar-cli@sha256:cb2fc3..., notstellar/stellar-cli:latest. Recording a digest immediately makes builds reproducible day one and avoids the Apple Silicon RepoDigests-after-cross-platform-pull quirk. Bumping the default is a single-line const change in build.rs (see comments there for the recipe). Users who want a different image specify --backend docker=....
  • Entrypoint override: the official stellar/stellar-cli image's entrypoint runs entrypoint.sh, which launches dbus + gnome-keyring. That setup fails when the container runs as a host UID without an /etc/passwd entry — see Docker image's entrypoint dbus init fails when run as non-root UID #2543. We override the entrypoint to point straight at the stellar binary, which is fine because contract build doesn't touch the keyring.
  • Caching: the bind-mount of host ~/.cargo/registry lets the container reuse crate downloads the host already has.
  • Wasm target installation: deferred to the image. The official image has wasm32v1-none pre-installed for its default toolchain; if RUSTUP_TOOLCHAIN selects a different one (verify on a wasm built with another rust version), the cli/cargo handle target installation themselves.
  • Toolchain pinning: verify sets RUSTUP_TOOLCHAIN=<rsver> inside the container (and cargo +<rsver> for local rebuilds) so the rust version matches whatever the original build used.
  • Image fully-qualified: bldimg is normalized to <registry>/<path>@sha256:<digest> (e.g. stellar/stellar-cli:latestdocker.io/stellar/stellar-cli@sha256:…) so verify can resolve it without relying on the local registry config.
  • Source URL canonicalization: source_repo is normalized to https://… form (e.g. git@github.com:user/repo.githttps://github.com/user/repo).
  • Build options auto-recorded: bldopt_manifest_path is recorded relative to the git repo root regardless of whether --manifest-path was passed on the CLI. Verify resolves it against the cwd's git top-level so the command works from anywhere inside the checkout.
  • No new in-container flags: the host invokes stellar contract build inside the image with only flags that exist in published stellar/stellar-cli images today (--manifest-path, --profile, --locked, --meta, --package, --features, --all-features, --no-default-features, --optimize). bldimg is forwarded via --meta bldimg=<digest>, not a new flag.
  • No bldbkd field: presence of bldimg is the only signal needed to distinguish a docker build from a local one.
  • Aborted container runs: may leave a stopped container; clean with docker container prune.

Performance/runtime caveats

Building inside an amd64 container on a non-amd64 host (Apple Silicon, Linux/arm64) runs under emulation. For small contracts the difference is negligible; for workspaces with heavy dep trees the emulated build can be substantially slower than a native host build. Container runtimes that don't ship qemu/binfmt support won't run amd64 containers on arm64 hosts at all. See #2506 (comment).

Related issues

Status

This is an experiment in validating the ideas in #2506. May or may not be destined for merging — at this moment it's an experiment in validating the approach.

@github-project-automationgithub-project-automationBot moved this to Backlog (Not Ready) in DevXApr 27, 2026
@leighmcculloch

leighmcculloch commented May 1, 2026

Copy link
Copy Markdown
MemberAuthor

Opened an issue about dbus creating problems with using the image for the build for the verification step:

@fnando

Copy link
Copy Markdown
Member

@leighmcculloch I just tried this, but I'm getting a warning, even though there are no unstaged files.

$ git statusOn branch mainnothing to commit, working tree clean
$ stellar contract build --backend docker⚠️ git working tree has uncommitted changes; source_repo/source_rev/bldopt_* not embedded in contract metadata. Commit changes for a reproducible build.

}

let backend = match bldimg {
Some(image) => build::Backend::Docker { image },

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

bldimg needs some form of allowlist, e.g. docker.io/stellar/stellar-cli@sha256:*, otherwise I can inject a docker image that bypasses verification

}
});

let build_cmd = build::Cmd {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we need feature flags here? While not extremely common, I have used them in the past to reduce code duplication between similar contracts. Ref -> https://github.com/script3/soroban-governor/tree/main/contracts/votes

Comment on lines +172 to +176
// - The official `stellar/stellar-cli` image's stock entrypoint is a
// wrapper script that launches dbus + gnome-keyring before exec-ing
// `stellar`; that setup is irrelevant for `contract build` and dbus
// refuses to start when the container runs as a host UID with no
// `/etc/passwd` entry. Skipping it keeps the host UID mapping intact.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This was cleaned up in a recent PR. Does this simplify anything?

attach_stdout: Some(true),
attach_stderr: Some(true),
host_config: Some(HostConfig {
binds: Some(binds),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Have we considered removing the host bind mounts? Given verify will build untrusted code, it might be best if we keep the build artifacts within the container, then just extract the WASM file out.

We could consider having two configurations docker-build and docker-verify, where build keeps mounts to help speed up repeated builds and verify is more black-box to provide a bit more protection.

Comment on lines +71 to +77
let cliver = find_meta(&spec.meta, "cliver").ok_or(Error::MissingMeta("cliver"))?;
let bldimg = find_meta(&spec.meta, "bldimg");
let rsver = find_meta(&spec.meta, "rsver").ok_or(Error::MissingMeta("rsver"))?;
let bldopt_manifest_path = find_meta(&spec.meta, "bldopt_manifest_path");
let bldopt_package = find_meta(&spec.meta, "bldopt_package");
let bldopt_profile = find_meta(&spec.meta, "bldopt_profile");
let bldopt_optimize = find_meta(&spec.meta, "bldopt_optimize").is_some();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we should enforce the regex here

@leighmcculloch

Copy link
Copy Markdown
MemberAuthor

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

Add --docker option and stellar contract verify for reproducible builds

4 participants

@leighmcculloch@fnando@mootz12@chadoh
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Add container build backend and build verify command - #2525

Closed
leighmcculloch wants to merge 75 commits into
mainfrom
feat/reproducible-builds-via-docker
Closed

Add container build backend and build verify command#2525
leighmcculloch wants to merge 75 commits into
mainfrom
feat/reproducible-builds-via-docker

Conversation

@leighmcculloch

@leighmccullochleighmcculloch commented Apr 27, 2026

Copy link
Copy Markdown
Member

What

Add --backend docker[=<image>] to stellar contract build (and deploy/upload) that runs the entire build pipeline inside a container whose entrypoint is stellar. Add a stellar contract build verify subcommand that reads everything it needs from the wasm's metadata, rebuilds, and reports which (if any) rebuilt artifact is byte-identical to the original. Add a mainnet warning on stellar contract deploy when the wasm is missing the meta entries needed for independent verification.

Why

Contract builds vary across host OS, architecture, and toolchain, preventing third parties from independently confirming a deployed contract was built from given source. Pinning the build to a docker image plus the rust toolchain version makes builds reproducible, recording the source repo + commit + per-package build options lets verifiers rebuild the exact same artifact, and the new verify subcommand automates the rebuild-and-compare check.

Closes#2506.

How it works

Three parts: build-time recording, deploy-time warning, and verify-time reproduction.

Build

stellar contract build --backend local # default; host build
stellar contract build --backend docker # build inside docker.io/stellar/stellar-cli@sha256:...
stellar contract build --backend docker=stellar/stellar-cli:26.0.0
stellar contract build --backend docker=quay.io/myorg/myimage@sha256:...

For all backends (including local), the build:

  • Detects whether the workspace is a clean git checkout. If clean and there's an origin remote, embeds source_repo (URL canonicalized to https://…), source_rev (full HEAD SHA), and per-package build options (bldopt_manifest_path relative to git root, bldopt_package, bldopt_profile, optional bldopt_optimize). The manifest path is auto-inserted whether or not --manifest-path was passed on the CLI.
  • If the working tree has uncommitted changes, prints a warning and omits source_repo / source_rev / bldopt_*.
  • If not a git repo, silently omits.

For --backend docker, additionally:

  1. Resolves the requested image. Default is docker.io/stellar/stellar-cli@sha256:cb2fc3116a6ace37a77ca6bb88afb4bee57fc746cd556a4373f2c3ee95d4e917 — pinned by digest so the recorded bldimg is reproducible from day one and we sidestep the longstanding Apple Silicon docker quirk where pulling a multi-arch tag with --platform=linux/amd64 leaves RepoDigests empty after pull.
  2. Pulls the image (skipping the pull if it's already locally present, since digest-pinned references are immutable).
  3. Bind-mounts on the container:
    • <git_root or workspace_root>/source (rw, source — also where cargo writes its target dir, shared with the host)
    • host ~/.cargo/registry/usr/local/cargo/registry (rw, cached crate downloads)
  4. The container runs as the host uid:gid, so files written to the bind mount are readable/writable by the host user.
  5. Overrides the image's entrypoint to invoke stellar directly, bypassing the official image's entrypoint.sh (which launches dbus + gnome-keyring and trips when running under a host UID with no /etc/passwd entry — see Docker image's entrypoint dbus init fails when run as non-root UID #2543). contract build doesn't use the keyring, so the wrapper is irrelevant here.
  6. Runs stellar contract build --manifest-path /source/<rel> --profile <p> --locked --meta bldimg=<digest> [forwarded args] inside the container. The args use only flags that exist in published stellar/stellar-cli images today; no new flags are added, and --backend local is deliberately not passed (it's a flag added in this PR and isn't recognized by published images).
  7. The in-container cli does cargo + meta injection + spec filtering + optional wasm-opt itself; the host only orchestrates and copies outputs to --out-dir if requested.

The wasm's contractmetav0 custom section is populated with up to nine entries:

keyvalueregex (validation)injected by
cliver26.0.0#abc1234… (CLI version + git rev)^\d+\.\d+\.\d+(-[A-Za-z0-9.+-]+)?#([0-9a-f]{40}(-dirty)?)?$stellar-cli
bldimgdocker.io/stellar/stellar-cli@sha256:…^[^@\s]+@sha256:[0-9a-f]{64}$stellar-cli (this PR; only with --backend docker)
rsver1.83.0 (resolved rustc version)^\d+\.\d+\.\d+(-[A-Za-z0-9.+-]+)?$soroban-sdk
source_repohttps://github.com/user/repo (clean repo's origin)^https?://\S+$stellar-cli (this PR)
source_revfull 40-char HEAD SHA^[0-9a-f]{40}$stellar-cli (this PR)
bldopt_manifest_pathe.g. contracts/foo/Cargo.toml (relative to git)^([^/\s]+/)*Cargo\.toml$stellar-cli (this PR)
bldopt_packagecargo package name being built^[A-Za-z][A-Za-z0-9_-]*$stellar-cli (this PR)
bldopt_profilecargo profile (e.g. release)^[A-Za-z][A-Za-z0-9_-]*$stellar-cli (this PR)
bldopt_optimizetrue (only present when --optimize was used)^true$stellar-cli (this PR)

The presence of bldimg is what distinguishes a docker build from a local one — there's no separate bldbkd field. For full reproducibility from day one, pin to a specific image with --backend docker=<name>@sha256:… and commit before building.

--backend and --docker-host are also exposed on stellar contract deploy and stellar contract upload (which auto-build when no --wasm / --wasm-hash is given), so the same flags work end-to-end.

Deploy

stellar contract deploy against mainnet now warns when the wasm is missing any of cliver, bldimg, rsver, source_repo, source_rev, bldopt_manifest_path, bldopt_package, bldopt_profile:

⚠ the wasm being deployed is missing reproducibility meta entries: ["bldimg", "source_repo", "source_rev", "bldopt_manifest_path", "bldopt_package", "bldopt_profile"]. The deployed wasm may not be independently verifiable. To make it reproducible, build with `stellar contract build --backend docker` in a clean git repository.

The check is mainnet-only (matches network passphrase against Public Global Stellar Network ; September 2015); on testnet/futurenet/local the wasm deploys silently.

Verify

verify is a subcommand of build — it lives at stellar contract build verify, and works on multi-contract workspaces by rebuilding and finding the match.

stellar contract build verify --contract-id CXXX… --network mainnet
stellar contract build verify --wasm-hash <hash> --network mainnet
stellar contract build verify --wasm contract.wasm
  1. Fetches the original wasm (file path, hash, or contract id, same flags as contract info).
  2. Reads cliver, bldimg (optional), rsver, and bldopt_* (optional, best-effort) from the wasm's meta. Missing bldopt_* entries trigger a warning rather than an error and the build falls back to its defaults — verify still runs, just with the caveat that the rebuild may not be reproducible.
  3. Picks the rebuild backend from the meta:
    • bldimg present → Backend::Docker { image: bldimg }. The image's pinned digest pulls the same in-container cli that produced the original.
    • bldimg absent → Backend::Local. Best-effort rebuild on the host.
  4. Forwards the wasm's rsver to the rebuild as RUSTUP_TOOLCHAIN (in-container) or cargo +<rsver> (local). For docker the toolchain inside the image is fixed by whoever built it; passing RUSTUP_TOOLCHAIN lets rustup-managed cargo switch toolchains if the image carries multiple ones.
  5. Resolves bldopt_manifest_path against the cwd's git top-level (via git rev-parse --show-toplevel) so verify works from anywhere inside the checkout.
  6. Hashes every rebuilt artifact and looks for a match against the original. Prints ✅ on match (with the matching crate's name); ⚠ + non-zero exit on mismatch (with each rebuilt artifact's name + hash).

The user is responsible for checking out the matching commit before running verify; verify rebuilds from the working tree. (source_repo and source_rev are embedded in meta to help users find the right commit, but verify itself doesn't clone — that would add a separate trust path.)

End-to-end example

$ stellar contract build --backend dockerℹ Pulling from stellar/stellar-cli Digest: sha256:cb2fc3116a6ace37a77ca6bb88afb4bee57fc746cd556a4373f2c3ee95d4e917 Status: Image is up to date for stellar/stellar-cli@sha256:cb2fc3...ℹ contract build --manifest-path /source/contracts/foo/Cargo.toml --profile release --locked --meta bldimg=docker.io/stellar/stellar-cli@sha256:cb2fc3... Compiling foo v… Finished `release` profile [optimized] target(s) in 1.09sℹ Build Summary: Wasm File: target/wasm32v1-none/release/foo.wasm (907 bytes) Wasm Hash: 9f86d081…✅ Build Complete
$ stellar contract info meta --wasm target/wasm32v1-none/release/foo.wasmcliver=26.0.0#abc1234bldimg=docker.io/stellar/stellar-cli@sha256:cb2fc3...rsver=1.83.0source_repo=https://github.com/user/my-contractsource_rev=abc1234567890abcdef…bldopt_manifest_path=contracts/foo/Cargo.tomlbldopt_package=foobldopt_profile=release
# Later, on a different machine, with the matching commit checked out:
$ stellar contract build verify --wasm-hash <hash> --network mainnetℹ Loading contract from network...ℹ Loading meta from contract... Original wasm hash: 9f86d081… stellar-cli version: 26.0.0#abc1234 rust version: 1.83.0 Docker image: docker.io/stellar/stellar-cli@sha256:cb2fc3... Manifest path: contracts/foo/Cargo.toml Package: foo Profile: releaseℹ contract build --manifest-path /source/contracts/foo/Cargo.toml --profile release --locked --meta bldimg=docker.io/stellar/stellar-cli@sha256:cb2fc3... Compiling foo v…✅ Build Complete✅ Verified: rebuilt foo wasm matches 9f86d081…

The host CLI's version is irrelevant for verifying a docker-built wasm — whatever cli is in the image is what built (and rebuilds) the wasm.

Notes

  • Communication with the daemon: bollard's HTTP API over the docker socket (/var/run/docker.sock, or whatever --docker-host / DOCKER_HOST points at). Same connect_to_docker helper used by stellar container start/stop/logs, with the same Docker Desktop fallback ($HOME/.docker/run/docker.sock). No shell-out to the docker CLI. A podman socket exposing the Docker API would also work (untested).
  • Default image is digest-pinned: --backend docker (no =...) defaults to docker.io/stellar/stellar-cli@sha256:cb2fc3..., notstellar/stellar-cli:latest. Recording a digest immediately makes builds reproducible day one and avoids the Apple Silicon RepoDigests-after-cross-platform-pull quirk. Bumping the default is a single-line const change in build.rs (see comments there for the recipe). Users who want a different image specify --backend docker=....
  • Entrypoint override: the official stellar/stellar-cli image's entrypoint runs entrypoint.sh, which launches dbus + gnome-keyring. That setup fails when the container runs as a host UID without an /etc/passwd entry — see Docker image's entrypoint dbus init fails when run as non-root UID #2543. We override the entrypoint to point straight at the stellar binary, which is fine because contract build doesn't touch the keyring.
  • Caching: the bind-mount of host ~/.cargo/registry lets the container reuse crate downloads the host already has.
  • Wasm target installation: deferred to the image. The official image has wasm32v1-none pre-installed for its default toolchain; if RUSTUP_TOOLCHAIN selects a different one (verify on a wasm built with another rust version), the cli/cargo handle target installation themselves.
  • Toolchain pinning: verify sets RUSTUP_TOOLCHAIN=<rsver> inside the container (and cargo +<rsver> for local rebuilds) so the rust version matches whatever the original build used.
  • Image fully-qualified: bldimg is normalized to <registry>/<path>@sha256:<digest> (e.g. stellar/stellar-cli:latestdocker.io/stellar/stellar-cli@sha256:…) so verify can resolve it without relying on the local registry config.
  • Source URL canonicalization: source_repo is normalized to https://… form (e.g. git@github.com:user/repo.githttps://github.com/user/repo).
  • Build options auto-recorded: bldopt_manifest_path is recorded relative to the git repo root regardless of whether --manifest-path was passed on the CLI. Verify resolves it against the cwd's git top-level so the command works from anywhere inside the checkout.
  • No new in-container flags: the host invokes stellar contract build inside the image with only flags that exist in published stellar/stellar-cli images today (--manifest-path, --profile, --locked, --meta, --package, --features, --all-features, --no-default-features, --optimize). bldimg is forwarded via --meta bldimg=<digest>, not a new flag.
  • No bldbkd field: presence of bldimg is the only signal needed to distinguish a docker build from a local one.
  • Aborted container runs: may leave a stopped container; clean with docker container prune.

Performance/runtime caveats

Building inside an amd64 container on a non-amd64 host (Apple Silicon, Linux/arm64) runs under emulation. For small contracts the difference is negligible; for workspaces with heavy dep trees the emulated build can be substantially slower than a native host build. Container runtimes that don't ship qemu/binfmt support won't run amd64 containers on arm64 hosts at all. See #2506 (comment).

Related issues

Status

This is an experiment in validating the ideas in #2506. May or may not be destined for merging — at this moment it's an experiment in validating the approach.

@github-project-automationgithub-project-automationBot moved this to Backlog (Not Ready) in DevXApr 27, 2026
@leighmcculloch

leighmcculloch commented May 1, 2026

Copy link
Copy Markdown
MemberAuthor

Opened an issue about dbus creating problems with using the image for the build for the verification step:

@fnando

Copy link
Copy Markdown
Member

@leighmcculloch I just tried this, but I'm getting a warning, even though there are no unstaged files.

$ git statusOn branch mainnothing to commit, working tree clean
$ stellar contract build --backend docker⚠️ git working tree has uncommitted changes; source_repo/source_rev/bldopt_* not embedded in contract metadata. Commit changes for a reproducible build.

}

let backend = match bldimg {
Some(image) => build::Backend::Docker { image },

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

bldimg needs some form of allowlist, e.g. docker.io/stellar/stellar-cli@sha256:*, otherwise I can inject a docker image that bypasses verification

}
});

let build_cmd = build::Cmd {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we need feature flags here? While not extremely common, I have used them in the past to reduce code duplication between similar contracts. Ref -> https://github.com/script3/soroban-governor/tree/main/contracts/votes

Comment on lines +172 to +176
// - The official `stellar/stellar-cli` image's stock entrypoint is a
// wrapper script that launches dbus + gnome-keyring before exec-ing
// `stellar`; that setup is irrelevant for `contract build` and dbus
// refuses to start when the container runs as a host UID with no
// `/etc/passwd` entry. Skipping it keeps the host UID mapping intact.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This was cleaned up in a recent PR. Does this simplify anything?

attach_stdout: Some(true),
attach_stderr: Some(true),
host_config: Some(HostConfig {
binds: Some(binds),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Have we considered removing the host bind mounts? Given verify will build untrusted code, it might be best if we keep the build artifacts within the container, then just extract the WASM file out.

We could consider having two configurations docker-build and docker-verify, where build keeps mounts to help speed up repeated builds and verify is more black-box to provide a bit more protection.

Comment on lines +71 to +77
let cliver = find_meta(&spec.meta, "cliver").ok_or(Error::MissingMeta("cliver"))?;
let bldimg = find_meta(&spec.meta, "bldimg");
let rsver = find_meta(&spec.meta, "rsver").ok_or(Error::MissingMeta("rsver"))?;
let bldopt_manifest_path = find_meta(&spec.meta, "bldopt_manifest_path");
let bldopt_package = find_meta(&spec.meta, "bldopt_package");
let bldopt_profile = find_meta(&spec.meta, "bldopt_profile");
let bldopt_optimize = find_meta(&spec.meta, "bldopt_optimize").is_some();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we should enforce the regex here

@leighmcculloch

Copy link
Copy Markdown
MemberAuthor

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

Add --docker option and stellar contract verify for reproducible builds

4 participants

@leighmcculloch@fnando@mootz12@chadoh
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content

Add container build backend and build verify command - #2525

Closed
leighmcculloch wants to merge 75 commits into
mainfrom
feat/reproducible-builds-via-docker
Closed

Add container build backend and build verify command#2525
leighmcculloch wants to merge 75 commits into
mainfrom
feat/reproducible-builds-via-docker

Conversation

@leighmcculloch

@leighmccullochleighmcculloch commented Apr 27, 2026

Copy link
Copy Markdown
Member

What

Add --backend docker[=<image>] to stellar contract build (and deploy/upload) that runs the entire build pipeline inside a container whose entrypoint is stellar. Add a stellar contract build verify subcommand that reads everything it needs from the wasm's metadata, rebuilds, and reports which (if any) rebuilt artifact is byte-identical to the original. Add a mainnet warning on stellar contract deploy when the wasm is missing the meta entries needed for independent verification.

Why

Contract builds vary across host OS, architecture, and toolchain, preventing third parties from independently confirming a deployed contract was built from given source. Pinning the build to a docker image plus the rust toolchain version makes builds reproducible, recording the source repo + commit + per-package build options lets verifiers rebuild the exact same artifact, and the new verify subcommand automates the rebuild-and-compare check.

Closes#2506.

How it works

Three parts: build-time recording, deploy-time warning, and verify-time reproduction.

Build

stellar contract build --backend local # default; host build
stellar contract build --backend docker # build inside docker.io/stellar/stellar-cli@sha256:...
stellar contract build --backend docker=stellar/stellar-cli:26.0.0
stellar contract build --backend docker=quay.io/myorg/myimage@sha256:...

For all backends (including local), the build:

  • Detects whether the workspace is a clean git checkout. If clean and there's an origin remote, embeds source_repo (URL canonicalized to https://…), source_rev (full HEAD SHA), and per-package build options (bldopt_manifest_path relative to git root, bldopt_package, bldopt_profile, optional bldopt_optimize). The manifest path is auto-inserted whether or not --manifest-path was passed on the CLI.
  • If the working tree has uncommitted changes, prints a warning and omits source_repo / source_rev / bldopt_*.
  • If not a git repo, silently omits.

For --backend docker, additionally:

  1. Resolves the requested image. Default is docker.io/stellar/stellar-cli@sha256:cb2fc3116a6ace37a77ca6bb88afb4bee57fc746cd556a4373f2c3ee95d4e917 — pinned by digest so the recorded bldimg is reproducible from day one and we sidestep the longstanding Apple Silicon docker quirk where pulling a multi-arch tag with --platform=linux/amd64 leaves RepoDigests empty after pull.
  2. Pulls the image (skipping the pull if it's already locally present, since digest-pinned references are immutable).
  3. Bind-mounts on the container:
    • <git_root or workspace_root>/source (rw, source — also where cargo writes its target dir, shared with the host)
    • host ~/.cargo/registry/usr/local/cargo/registry (rw, cached crate downloads)
  4. The container runs as the host uid:gid, so files written to the bind mount are readable/writable by the host user.
  5. Overrides the image's entrypoint to invoke stellar directly, bypassing the official image's entrypoint.sh (which launches dbus + gnome-keyring and trips when running under a host UID with no /etc/passwd entry — see Docker image's entrypoint dbus init fails when run as non-root UID #2543). contract build doesn't use the keyring, so the wrapper is irrelevant here.
  6. Runs stellar contract build --manifest-path /source/<rel> --profile <p> --locked --meta bldimg=<digest> [forwarded args] inside the container. The args use only flags that exist in published stellar/stellar-cli images today; no new flags are added, and --backend local is deliberately not passed (it's a flag added in this PR and isn't recognized by published images).
  7. The in-container cli does cargo + meta injection + spec filtering + optional wasm-opt itself; the host only orchestrates and copies outputs to --out-dir if requested.

The wasm's contractmetav0 custom section is populated with up to nine entries:

keyvalueregex (validation)injected by
cliver26.0.0#abc1234… (CLI version + git rev)^\d+\.\d+\.\d+(-[A-Za-z0-9.+-]+)?#([0-9a-f]{40}(-dirty)?)?$stellar-cli
bldimgdocker.io/stellar/stellar-cli@sha256:…^[^@\s]+@sha256:[0-9a-f]{64}$stellar-cli (this PR; only with --backend docker)
rsver1.83.0 (resolved rustc version)^\d+\.\d+\.\d+(-[A-Za-z0-9.+-]+)?$soroban-sdk
source_repohttps://github.com/user/repo (clean repo's origin)^https?://\S+$stellar-cli (this PR)
source_revfull 40-char HEAD SHA^[0-9a-f]{40}$stellar-cli (this PR)
bldopt_manifest_pathe.g. contracts/foo/Cargo.toml (relative to git)^([^/\s]+/)*Cargo\.toml$stellar-cli (this PR)
bldopt_packagecargo package name being built^[A-Za-z][A-Za-z0-9_-]*$stellar-cli (this PR)
bldopt_profilecargo profile (e.g. release)^[A-Za-z][A-Za-z0-9_-]*$stellar-cli (this PR)
bldopt_optimizetrue (only present when --optimize was used)^true$stellar-cli (this PR)

The presence of bldimg is what distinguishes a docker build from a local one — there's no separate bldbkd field. For full reproducibility from day one, pin to a specific image with --backend docker=<name>@sha256:… and commit before building.

--backend and --docker-host are also exposed on stellar contract deploy and stellar contract upload (which auto-build when no --wasm / --wasm-hash is given), so the same flags work end-to-end.

Deploy

stellar contract deploy against mainnet now warns when the wasm is missing any of cliver, bldimg, rsver, source_repo, source_rev, bldopt_manifest_path, bldopt_package, bldopt_profile:

⚠ the wasm being deployed is missing reproducibility meta entries: ["bldimg", "source_repo", "source_rev", "bldopt_manifest_path", "bldopt_package", "bldopt_profile"]. The deployed wasm may not be independently verifiable. To make it reproducible, build with `stellar contract build --backend docker` in a clean git repository.

The check is mainnet-only (matches network passphrase against Public Global Stellar Network ; September 2015); on testnet/futurenet/local the wasm deploys silently.

Verify

verify is a subcommand of build — it lives at stellar contract build verify, and works on multi-contract workspaces by rebuilding and finding the match.

stellar contract build verify --contract-id CXXX… --network mainnet
stellar contract build verify --wasm-hash <hash> --network mainnet
stellar contract build verify --wasm contract.wasm
  1. Fetches the original wasm (file path, hash, or contract id, same flags as contract info).
  2. Reads cliver, bldimg (optional), rsver, and bldopt_* (optional, best-effort) from the wasm's meta. Missing bldopt_* entries trigger a warning rather than an error and the build falls back to its defaults — verify still runs, just with the caveat that the rebuild may not be reproducible.
  3. Picks the rebuild backend from the meta:
    • bldimg present → Backend::Docker { image: bldimg }. The image's pinned digest pulls the same in-container cli that produced the original.
    • bldimg absent → Backend::Local. Best-effort rebuild on the host.
  4. Forwards the wasm's rsver to the rebuild as RUSTUP_TOOLCHAIN (in-container) or cargo +<rsver> (local). For docker the toolchain inside the image is fixed by whoever built it; passing RUSTUP_TOOLCHAIN lets rustup-managed cargo switch toolchains if the image carries multiple ones.
  5. Resolves bldopt_manifest_path against the cwd's git top-level (via git rev-parse --show-toplevel) so verify works from anywhere inside the checkout.
  6. Hashes every rebuilt artifact and looks for a match against the original. Prints ✅ on match (with the matching crate's name); ⚠ + non-zero exit on mismatch (with each rebuilt artifact's name + hash).

The user is responsible for checking out the matching commit before running verify; verify rebuilds from the working tree. (source_repo and source_rev are embedded in meta to help users find the right commit, but verify itself doesn't clone — that would add a separate trust path.)

End-to-end example

$ stellar contract build --backend dockerℹ Pulling from stellar/stellar-cli Digest: sha256:cb2fc3116a6ace37a77ca6bb88afb4bee57fc746cd556a4373f2c3ee95d4e917 Status: Image is up to date for stellar/stellar-cli@sha256:cb2fc3...ℹ contract build --manifest-path /source/contracts/foo/Cargo.toml --profile release --locked --meta bldimg=docker.io/stellar/stellar-cli@sha256:cb2fc3... Compiling foo v… Finished `release` profile [optimized] target(s) in 1.09sℹ Build Summary: Wasm File: target/wasm32v1-none/release/foo.wasm (907 bytes) Wasm Hash: 9f86d081…✅ Build Complete
$ stellar contract info meta --wasm target/wasm32v1-none/release/foo.wasmcliver=26.0.0#abc1234bldimg=docker.io/stellar/stellar-cli@sha256:cb2fc3...rsver=1.83.0source_repo=https://github.com/user/my-contractsource_rev=abc1234567890abcdef…bldopt_manifest_path=contracts/foo/Cargo.tomlbldopt_package=foobldopt_profile=release
# Later, on a different machine, with the matching commit checked out:
$ stellar contract build verify --wasm-hash <hash> --network mainnetℹ Loading contract from network...ℹ Loading meta from contract... Original wasm hash: 9f86d081… stellar-cli version: 26.0.0#abc1234 rust version: 1.83.0 Docker image: docker.io/stellar/stellar-cli@sha256:cb2fc3... Manifest path: contracts/foo/Cargo.toml Package: foo Profile: releaseℹ contract build --manifest-path /source/contracts/foo/Cargo.toml --profile release --locked --meta bldimg=docker.io/stellar/stellar-cli@sha256:cb2fc3... Compiling foo v…✅ Build Complete✅ Verified: rebuilt foo wasm matches 9f86d081…

The host CLI's version is irrelevant for verifying a docker-built wasm — whatever cli is in the image is what built (and rebuilds) the wasm.

Notes

  • Communication with the daemon: bollard's HTTP API over the docker socket (/var/run/docker.sock, or whatever --docker-host / DOCKER_HOST points at). Same connect_to_docker helper used by stellar container start/stop/logs, with the same Docker Desktop fallback ($HOME/.docker/run/docker.sock). No shell-out to the docker CLI. A podman socket exposing the Docker API would also work (untested).
  • Default image is digest-pinned: --backend docker (no =...) defaults to docker.io/stellar/stellar-cli@sha256:cb2fc3..., notstellar/stellar-cli:latest. Recording a digest immediately makes builds reproducible day one and avoids the Apple Silicon RepoDigests-after-cross-platform-pull quirk. Bumping the default is a single-line const change in build.rs (see comments there for the recipe). Users who want a different image specify --backend docker=....
  • Entrypoint override: the official stellar/stellar-cli image's entrypoint runs entrypoint.sh, which launches dbus + gnome-keyring. That setup fails when the container runs as a host UID without an /etc/passwd entry — see Docker image's entrypoint dbus init fails when run as non-root UID #2543. We override the entrypoint to point straight at the stellar binary, which is fine because contract build doesn't touch the keyring.
  • Caching: the bind-mount of host ~/.cargo/registry lets the container reuse crate downloads the host already has.
  • Wasm target installation: deferred to the image. The official image has wasm32v1-none pre-installed for its default toolchain; if RUSTUP_TOOLCHAIN selects a different one (verify on a wasm built with another rust version), the cli/cargo handle target installation themselves.
  • Toolchain pinning: verify sets RUSTUP_TOOLCHAIN=<rsver> inside the container (and cargo +<rsver> for local rebuilds) so the rust version matches whatever the original build used.
  • Image fully-qualified: bldimg is normalized to <registry>/<path>@sha256:<digest> (e.g. stellar/stellar-cli:latestdocker.io/stellar/stellar-cli@sha256:…) so verify can resolve it without relying on the local registry config.
  • Source URL canonicalization: source_repo is normalized to https://… form (e.g. git@github.com:user/repo.githttps://github.com/user/repo).
  • Build options auto-recorded: bldopt_manifest_path is recorded relative to the git repo root regardless of whether --manifest-path was passed on the CLI. Verify resolves it against the cwd's git top-level so the command works from anywhere inside the checkout.
  • No new in-container flags: the host invokes stellar contract build inside the image with only flags that exist in published stellar/stellar-cli images today (--manifest-path, --profile, --locked, --meta, --package, --features, --all-features, --no-default-features, --optimize). bldimg is forwarded via --meta bldimg=<digest>, not a new flag.
  • No bldbkd field: presence of bldimg is the only signal needed to distinguish a docker build from a local one.
  • Aborted container runs: may leave a stopped container; clean with docker container prune.

Performance/runtime caveats

Building inside an amd64 container on a non-amd64 host (Apple Silicon, Linux/arm64) runs under emulation. For small contracts the difference is negligible; for workspaces with heavy dep trees the emulated build can be substantially slower than a native host build. Container runtimes that don't ship qemu/binfmt support won't run amd64 containers on arm64 hosts at all. See #2506 (comment).

Related issues

Status

This is an experiment in validating the ideas in #2506. May or may not be destined for merging — at this moment it's an experiment in validating the approach.

@github-project-automationgithub-project-automationBot moved this to Backlog (Not Ready) in DevXApr 27, 2026
@leighmcculloch

leighmcculloch commented May 1, 2026

Copy link
Copy Markdown
MemberAuthor

Opened an issue about dbus creating problems with using the image for the build for the verification step:

@fnando

Copy link
Copy Markdown
Member

@leighmcculloch I just tried this, but I'm getting a warning, even though there are no unstaged files.

$ git statusOn branch mainnothing to commit, working tree clean
$ stellar contract build --backend docker⚠️ git working tree has uncommitted changes; source_repo/source_rev/bldopt_* not embedded in contract metadata. Commit changes for a reproducible build.

}

let backend = match bldimg {
Some(image) => build::Backend::Docker { image },

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

bldimg needs some form of allowlist, e.g. docker.io/stellar/stellar-cli@sha256:*, otherwise I can inject a docker image that bypasses verification

}
});

let build_cmd = build::Cmd {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we need feature flags here? While not extremely common, I have used them in the past to reduce code duplication between similar contracts. Ref -> https://github.com/script3/soroban-governor/tree/main/contracts/votes

Comment on lines +172 to +176
// - The official `stellar/stellar-cli` image's stock entrypoint is a
// wrapper script that launches dbus + gnome-keyring before exec-ing
// `stellar`; that setup is irrelevant for `contract build` and dbus
// refuses to start when the container runs as a host UID with no
// `/etc/passwd` entry. Skipping it keeps the host UID mapping intact.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This was cleaned up in a recent PR. Does this simplify anything?

attach_stdout: Some(true),
attach_stderr: Some(true),
host_config: Some(HostConfig {
binds: Some(binds),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Have we considered removing the host bind mounts? Given verify will build untrusted code, it might be best if we keep the build artifacts within the container, then just extract the WASM file out.

We could consider having two configurations docker-build and docker-verify, where build keeps mounts to help speed up repeated builds and verify is more black-box to provide a bit more protection.

Comment on lines +71 to +77
let cliver = find_meta(&spec.meta, "cliver").ok_or(Error::MissingMeta("cliver"))?;
let bldimg = find_meta(&spec.meta, "bldimg");
let rsver = find_meta(&spec.meta, "rsver").ok_or(Error::MissingMeta("rsver"))?;
let bldopt_manifest_path = find_meta(&spec.meta, "bldopt_manifest_path");
let bldopt_package = find_meta(&spec.meta, "bldopt_package");
let bldopt_profile = find_meta(&spec.meta, "bldopt_profile");
let bldopt_optimize = find_meta(&spec.meta, "bldopt_optimize").is_some();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we should enforce the regex here

@leighmcculloch

Copy link
Copy Markdown
MemberAuthor

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

Add --docker option and stellar contract verify for reproducible builds

4 participants

@leighmcculloch@fnando@mootz12@chadoh
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Add container build backend and build verify command - #2525

Closed
leighmcculloch wants to merge 75 commits into
mainfrom
feat/reproducible-builds-via-docker
Closed

Add container build backend and build verify command#2525
leighmcculloch wants to merge 75 commits into
mainfrom
feat/reproducible-builds-via-docker

Conversation

@leighmcculloch

@leighmccullochleighmcculloch commented Apr 27, 2026

Copy link
Copy Markdown
Member

What

Add --backend docker[=<image>] to stellar contract build (and deploy/upload) that runs the entire build pipeline inside a container whose entrypoint is stellar. Add a stellar contract build verify subcommand that reads everything it needs from the wasm's metadata, rebuilds, and reports which (if any) rebuilt artifact is byte-identical to the original. Add a mainnet warning on stellar contract deploy when the wasm is missing the meta entries needed for independent verification.

Why

Contract builds vary across host OS, architecture, and toolchain, preventing third parties from independently confirming a deployed contract was built from given source. Pinning the build to a docker image plus the rust toolchain version makes builds reproducible, recording the source repo + commit + per-package build options lets verifiers rebuild the exact same artifact, and the new verify subcommand automates the rebuild-and-compare check.

Closes#2506.

How it works

Three parts: build-time recording, deploy-time warning, and verify-time reproduction.

Build

stellar contract build --backend local # default; host build
stellar contract build --backend docker # build inside docker.io/stellar/stellar-cli@sha256:...
stellar contract build --backend docker=stellar/stellar-cli:26.0.0
stellar contract build --backend docker=quay.io/myorg/myimage@sha256:...

For all backends (including local), the build:

  • Detects whether the workspace is a clean git checkout. If clean and there's an origin remote, embeds source_repo (URL canonicalized to https://…), source_rev (full HEAD SHA), and per-package build options (bldopt_manifest_path relative to git root, bldopt_package, bldopt_profile, optional bldopt_optimize). The manifest path is auto-inserted whether or not --manifest-path was passed on the CLI.
  • If the working tree has uncommitted changes, prints a warning and omits source_repo / source_rev / bldopt_*.
  • If not a git repo, silently omits.

For --backend docker, additionally:

  1. Resolves the requested image. Default is docker.io/stellar/stellar-cli@sha256:cb2fc3116a6ace37a77ca6bb88afb4bee57fc746cd556a4373f2c3ee95d4e917 — pinned by digest so the recorded bldimg is reproducible from day one and we sidestep the longstanding Apple Silicon docker quirk where pulling a multi-arch tag with --platform=linux/amd64 leaves RepoDigests empty after pull.
  2. Pulls the image (skipping the pull if it's already locally present, since digest-pinned references are immutable).
  3. Bind-mounts on the container:
    • <git_root or workspace_root>/source (rw, source — also where cargo writes its target dir, shared with the host)
    • host ~/.cargo/registry/usr/local/cargo/registry (rw, cached crate downloads)
  4. The container runs as the host uid:gid, so files written to the bind mount are readable/writable by the host user.
  5. Overrides the image's entrypoint to invoke stellar directly, bypassing the official image's entrypoint.sh (which launches dbus + gnome-keyring and trips when running under a host UID with no /etc/passwd entry — see Docker image's entrypoint dbus init fails when run as non-root UID #2543). contract build doesn't use the keyring, so the wrapper is irrelevant here.
  6. Runs stellar contract build --manifest-path /source/<rel> --profile <p> --locked --meta bldimg=<digest> [forwarded args] inside the container. The args use only flags that exist in published stellar/stellar-cli images today; no new flags are added, and --backend local is deliberately not passed (it's a flag added in this PR and isn't recognized by published images).
  7. The in-container cli does cargo + meta injection + spec filtering + optional wasm-opt itself; the host only orchestrates and copies outputs to --out-dir if requested.

The wasm's contractmetav0 custom section is populated with up to nine entries:

keyvalueregex (validation)injected by
cliver26.0.0#abc1234… (CLI version + git rev)^\d+\.\d+\.\d+(-[A-Za-z0-9.+-]+)?#([0-9a-f]{40}(-dirty)?)?$stellar-cli
bldimgdocker.io/stellar/stellar-cli@sha256:…^[^@\s]+@sha256:[0-9a-f]{64}$stellar-cli (this PR; only with --backend docker)
rsver1.83.0 (resolved rustc version)^\d+\.\d+\.\d+(-[A-Za-z0-9.+-]+)?$soroban-sdk
source_repohttps://github.com/user/repo (clean repo's origin)^https?://\S+$stellar-cli (this PR)
source_revfull 40-char HEAD SHA^[0-9a-f]{40}$stellar-cli (this PR)
bldopt_manifest_pathe.g. contracts/foo/Cargo.toml (relative to git)^([^/\s]+/)*Cargo\.toml$stellar-cli (this PR)
bldopt_packagecargo package name being built^[A-Za-z][A-Za-z0-9_-]*$stellar-cli (this PR)
bldopt_profilecargo profile (e.g. release)^[A-Za-z][A-Za-z0-9_-]*$stellar-cli (this PR)
bldopt_optimizetrue (only present when --optimize was used)^true$stellar-cli (this PR)

The presence of bldimg is what distinguishes a docker build from a local one — there's no separate bldbkd field. For full reproducibility from day one, pin to a specific image with --backend docker=<name>@sha256:… and commit before building.

--backend and --docker-host are also exposed on stellar contract deploy and stellar contract upload (which auto-build when no --wasm / --wasm-hash is given), so the same flags work end-to-end.

Deploy

stellar contract deploy against mainnet now warns when the wasm is missing any of cliver, bldimg, rsver, source_repo, source_rev, bldopt_manifest_path, bldopt_package, bldopt_profile:

⚠ the wasm being deployed is missing reproducibility meta entries: ["bldimg", "source_repo", "source_rev", "bldopt_manifest_path", "bldopt_package", "bldopt_profile"]. The deployed wasm may not be independently verifiable. To make it reproducible, build with `stellar contract build --backend docker` in a clean git repository.

The check is mainnet-only (matches network passphrase against Public Global Stellar Network ; September 2015); on testnet/futurenet/local the wasm deploys silently.

Verify

verify is a subcommand of build — it lives at stellar contract build verify, and works on multi-contract workspaces by rebuilding and finding the match.

stellar contract build verify --contract-id CXXX… --network mainnet
stellar contract build verify --wasm-hash <hash> --network mainnet
stellar contract build verify --wasm contract.wasm
  1. Fetches the original wasm (file path, hash, or contract id, same flags as contract info).
  2. Reads cliver, bldimg (optional), rsver, and bldopt_* (optional, best-effort) from the wasm's meta. Missing bldopt_* entries trigger a warning rather than an error and the build falls back to its defaults — verify still runs, just with the caveat that the rebuild may not be reproducible.
  3. Picks the rebuild backend from the meta:
    • bldimg present → Backend::Docker { image: bldimg }. The image's pinned digest pulls the same in-container cli that produced the original.
    • bldimg absent → Backend::Local. Best-effort rebuild on the host.
  4. Forwards the wasm's rsver to the rebuild as RUSTUP_TOOLCHAIN (in-container) or cargo +<rsver> (local). For docker the toolchain inside the image is fixed by whoever built it; passing RUSTUP_TOOLCHAIN lets rustup-managed cargo switch toolchains if the image carries multiple ones.
  5. Resolves bldopt_manifest_path against the cwd's git top-level (via git rev-parse --show-toplevel) so verify works from anywhere inside the checkout.
  6. Hashes every rebuilt artifact and looks for a match against the original. Prints ✅ on match (with the matching crate's name); ⚠ + non-zero exit on mismatch (with each rebuilt artifact's name + hash).

The user is responsible for checking out the matching commit before running verify; verify rebuilds from the working tree. (source_repo and source_rev are embedded in meta to help users find the right commit, but verify itself doesn't clone — that would add a separate trust path.)

End-to-end example

$ stellar contract build --backend dockerℹ Pulling from stellar/stellar-cli Digest: sha256:cb2fc3116a6ace37a77ca6bb88afb4bee57fc746cd556a4373f2c3ee95d4e917 Status: Image is up to date for stellar/stellar-cli@sha256:cb2fc3...ℹ contract build --manifest-path /source/contracts/foo/Cargo.toml --profile release --locked --meta bldimg=docker.io/stellar/stellar-cli@sha256:cb2fc3... Compiling foo v… Finished `release` profile [optimized] target(s) in 1.09sℹ Build Summary: Wasm File: target/wasm32v1-none/release/foo.wasm (907 bytes) Wasm Hash: 9f86d081…✅ Build Complete
$ stellar contract info meta --wasm target/wasm32v1-none/release/foo.wasmcliver=26.0.0#abc1234bldimg=docker.io/stellar/stellar-cli@sha256:cb2fc3...rsver=1.83.0source_repo=https://github.com/user/my-contractsource_rev=abc1234567890abcdef…bldopt_manifest_path=contracts/foo/Cargo.tomlbldopt_package=foobldopt_profile=release
# Later, on a different machine, with the matching commit checked out:
$ stellar contract build verify --wasm-hash <hash> --network mainnetℹ Loading contract from network...ℹ Loading meta from contract... Original wasm hash: 9f86d081… stellar-cli version: 26.0.0#abc1234 rust version: 1.83.0 Docker image: docker.io/stellar/stellar-cli@sha256:cb2fc3... Manifest path: contracts/foo/Cargo.toml Package: foo Profile: releaseℹ contract build --manifest-path /source/contracts/foo/Cargo.toml --profile release --locked --meta bldimg=docker.io/stellar/stellar-cli@sha256:cb2fc3... Compiling foo v…✅ Build Complete✅ Verified: rebuilt foo wasm matches 9f86d081…

The host CLI's version is irrelevant for verifying a docker-built wasm — whatever cli is in the image is what built (and rebuilds) the wasm.

Notes

  • Communication with the daemon: bollard's HTTP API over the docker socket (/var/run/docker.sock, or whatever --docker-host / DOCKER_HOST points at). Same connect_to_docker helper used by stellar container start/stop/logs, with the same Docker Desktop fallback ($HOME/.docker/run/docker.sock). No shell-out to the docker CLI. A podman socket exposing the Docker API would also work (untested).
  • Default image is digest-pinned: --backend docker (no =...) defaults to docker.io/stellar/stellar-cli@sha256:cb2fc3..., notstellar/stellar-cli:latest. Recording a digest immediately makes builds reproducible day one and avoids the Apple Silicon RepoDigests-after-cross-platform-pull quirk. Bumping the default is a single-line const change in build.rs (see comments there for the recipe). Users who want a different image specify --backend docker=....
  • Entrypoint override: the official stellar/stellar-cli image's entrypoint runs entrypoint.sh, which launches dbus + gnome-keyring. That setup fails when the container runs as a host UID without an /etc/passwd entry — see Docker image's entrypoint dbus init fails when run as non-root UID #2543. We override the entrypoint to point straight at the stellar binary, which is fine because contract build doesn't touch the keyring.
  • Caching: the bind-mount of host ~/.cargo/registry lets the container reuse crate downloads the host already has.
  • Wasm target installation: deferred to the image. The official image has wasm32v1-none pre-installed for its default toolchain; if RUSTUP_TOOLCHAIN selects a different one (verify on a wasm built with another rust version), the cli/cargo handle target installation themselves.
  • Toolchain pinning: verify sets RUSTUP_TOOLCHAIN=<rsver> inside the container (and cargo +<rsver> for local rebuilds) so the rust version matches whatever the original build used.
  • Image fully-qualified: bldimg is normalized to <registry>/<path>@sha256:<digest> (e.g. stellar/stellar-cli:latestdocker.io/stellar/stellar-cli@sha256:…) so verify can resolve it without relying on the local registry config.
  • Source URL canonicalization: source_repo is normalized to https://… form (e.g. git@github.com:user/repo.githttps://github.com/user/repo).
  • Build options auto-recorded: bldopt_manifest_path is recorded relative to the git repo root regardless of whether --manifest-path was passed on the CLI. Verify resolves it against the cwd's git top-level so the command works from anywhere inside the checkout.
  • No new in-container flags: the host invokes stellar contract build inside the image with only flags that exist in published stellar/stellar-cli images today (--manifest-path, --profile, --locked, --meta, --package, --features, --all-features, --no-default-features, --optimize). bldimg is forwarded via --meta bldimg=<digest>, not a new flag.
  • No bldbkd field: presence of bldimg is the only signal needed to distinguish a docker build from a local one.
  • Aborted container runs: may leave a stopped container; clean with docker container prune.

Performance/runtime caveats

Building inside an amd64 container on a non-amd64 host (Apple Silicon, Linux/arm64) runs under emulation. For small contracts the difference is negligible; for workspaces with heavy dep trees the emulated build can be substantially slower than a native host build. Container runtimes that don't ship qemu/binfmt support won't run amd64 containers on arm64 hosts at all. See #2506 (comment).

Related issues

Status

This is an experiment in validating the ideas in #2506. May or may not be destined for merging — at this moment it's an experiment in validating the approach.

@github-project-automationgithub-project-automationBot moved this to Backlog (Not Ready) in DevXApr 27, 2026
@leighmcculloch

leighmcculloch commented May 1, 2026

Copy link
Copy Markdown
MemberAuthor

Opened an issue about dbus creating problems with using the image for the build for the verification step:

@fnando

Copy link
Copy Markdown
Member

@leighmcculloch I just tried this, but I'm getting a warning, even though there are no unstaged files.

$ git statusOn branch mainnothing to commit, working tree clean
$ stellar contract build --backend docker⚠️ git working tree has uncommitted changes; source_repo/source_rev/bldopt_* not embedded in contract metadata. Commit changes for a reproducible build.

}

let backend = match bldimg {
Some(image) => build::Backend::Docker { image },

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

bldimg needs some form of allowlist, e.g. docker.io/stellar/stellar-cli@sha256:*, otherwise I can inject a docker image that bypasses verification

}
});

let build_cmd = build::Cmd {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we need feature flags here? While not extremely common, I have used them in the past to reduce code duplication between similar contracts. Ref -> https://github.com/script3/soroban-governor/tree/main/contracts/votes

Comment on lines +172 to +176
// - The official `stellar/stellar-cli` image's stock entrypoint is a
// wrapper script that launches dbus + gnome-keyring before exec-ing
// `stellar`; that setup is irrelevant for `contract build` and dbus
// refuses to start when the container runs as a host UID with no
// `/etc/passwd` entry. Skipping it keeps the host UID mapping intact.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This was cleaned up in a recent PR. Does this simplify anything?

attach_stdout: Some(true),
attach_stderr: Some(true),
host_config: Some(HostConfig {
binds: Some(binds),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Have we considered removing the host bind mounts? Given verify will build untrusted code, it might be best if we keep the build artifacts within the container, then just extract the WASM file out.

We could consider having two configurations docker-build and docker-verify, where build keeps mounts to help speed up repeated builds and verify is more black-box to provide a bit more protection.

Comment on lines +71 to +77
let cliver = find_meta(&spec.meta, "cliver").ok_or(Error::MissingMeta("cliver"))?;
let bldimg = find_meta(&spec.meta, "bldimg");
let rsver = find_meta(&spec.meta, "rsver").ok_or(Error::MissingMeta("rsver"))?;
let bldopt_manifest_path = find_meta(&spec.meta, "bldopt_manifest_path");
let bldopt_package = find_meta(&spec.meta, "bldopt_package");
let bldopt_profile = find_meta(&spec.meta, "bldopt_profile");
let bldopt_optimize = find_meta(&spec.meta, "bldopt_optimize").is_some();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we should enforce the regex here

@leighmcculloch

Copy link
Copy Markdown
MemberAuthor

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

Add --docker option and stellar contract verify for reproducible builds

4 participants

@leighmcculloch@fnando@mootz12@chadoh
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Add container build backend and build verify command - #2525

Closed
leighmcculloch wants to merge 75 commits into
mainfrom
feat/reproducible-builds-via-docker
Closed

Add container build backend and build verify command#2525
leighmcculloch wants to merge 75 commits into
mainfrom
feat/reproducible-builds-via-docker

Conversation

@leighmcculloch

@leighmccullochleighmcculloch commented Apr 27, 2026

Copy link
Copy Markdown
Member

What

Add --backend docker[=<image>] to stellar contract build (and deploy/upload) that runs the entire build pipeline inside a container whose entrypoint is stellar. Add a stellar contract build verify subcommand that reads everything it needs from the wasm's metadata, rebuilds, and reports which (if any) rebuilt artifact is byte-identical to the original. Add a mainnet warning on stellar contract deploy when the wasm is missing the meta entries needed for independent verification.

Why

Contract builds vary across host OS, architecture, and toolchain, preventing third parties from independently confirming a deployed contract was built from given source. Pinning the build to a docker image plus the rust toolchain version makes builds reproducible, recording the source repo + commit + per-package build options lets verifiers rebuild the exact same artifact, and the new verify subcommand automates the rebuild-and-compare check.

Closes#2506.

How it works

Three parts: build-time recording, deploy-time warning, and verify-time reproduction.

Build

stellar contract build --backend local # default; host build
stellar contract build --backend docker # build inside docker.io/stellar/stellar-cli@sha256:...
stellar contract build --backend docker=stellar/stellar-cli:26.0.0
stellar contract build --backend docker=quay.io/myorg/myimage@sha256:...

For all backends (including local), the build:

  • Detects whether the workspace is a clean git checkout. If clean and there's an origin remote, embeds source_repo (URL canonicalized to https://…), source_rev (full HEAD SHA), and per-package build options (bldopt_manifest_path relative to git root, bldopt_package, bldopt_profile, optional bldopt_optimize). The manifest path is auto-inserted whether or not --manifest-path was passed on the CLI.
  • If the working tree has uncommitted changes, prints a warning and omits source_repo / source_rev / bldopt_*.
  • If not a git repo, silently omits.

For --backend docker, additionally:

  1. Resolves the requested image. Default is docker.io/stellar/stellar-cli@sha256:cb2fc3116a6ace37a77ca6bb88afb4bee57fc746cd556a4373f2c3ee95d4e917 — pinned by digest so the recorded bldimg is reproducible from day one and we sidestep the longstanding Apple Silicon docker quirk where pulling a multi-arch tag with --platform=linux/amd64 leaves RepoDigests empty after pull.
  2. Pulls the image (skipping the pull if it's already locally present, since digest-pinned references are immutable).
  3. Bind-mounts on the container:
    • <git_root or workspace_root>/source (rw, source — also where cargo writes its target dir, shared with the host)
    • host ~/.cargo/registry/usr/local/cargo/registry (rw, cached crate downloads)
  4. The container runs as the host uid:gid, so files written to the bind mount are readable/writable by the host user.
  5. Overrides the image's entrypoint to invoke stellar directly, bypassing the official image's entrypoint.sh (which launches dbus + gnome-keyring and trips when running under a host UID with no /etc/passwd entry — see Docker image's entrypoint dbus init fails when run as non-root UID #2543). contract build doesn't use the keyring, so the wrapper is irrelevant here.
  6. Runs stellar contract build --manifest-path /source/<rel> --profile <p> --locked --meta bldimg=<digest> [forwarded args] inside the container. The args use only flags that exist in published stellar/stellar-cli images today; no new flags are added, and --backend local is deliberately not passed (it's a flag added in this PR and isn't recognized by published images).
  7. The in-container cli does cargo + meta injection + spec filtering + optional wasm-opt itself; the host only orchestrates and copies outputs to --out-dir if requested.

The wasm's contractmetav0 custom section is populated with up to nine entries:

keyvalueregex (validation)injected by
cliver26.0.0#abc1234… (CLI version + git rev)^\d+\.\d+\.\d+(-[A-Za-z0-9.+-]+)?#([0-9a-f]{40}(-dirty)?)?$stellar-cli
bldimgdocker.io/stellar/stellar-cli@sha256:…^[^@\s]+@sha256:[0-9a-f]{64}$stellar-cli (this PR; only with --backend docker)
rsver1.83.0 (resolved rustc version)^\d+\.\d+\.\d+(-[A-Za-z0-9.+-]+)?$soroban-sdk
source_repohttps://github.com/user/repo (clean repo's origin)^https?://\S+$stellar-cli (this PR)
source_revfull 40-char HEAD SHA^[0-9a-f]{40}$stellar-cli (this PR)
bldopt_manifest_pathe.g. contracts/foo/Cargo.toml (relative to git)^([^/\s]+/)*Cargo\.toml$stellar-cli (this PR)
bldopt_packagecargo package name being built^[A-Za-z][A-Za-z0-9_-]*$stellar-cli (this PR)
bldopt_profilecargo profile (e.g. release)^[A-Za-z][A-Za-z0-9_-]*$stellar-cli (this PR)
bldopt_optimizetrue (only present when --optimize was used)^true$stellar-cli (this PR)

The presence of bldimg is what distinguishes a docker build from a local one — there's no separate bldbkd field. For full reproducibility from day one, pin to a specific image with --backend docker=<name>@sha256:… and commit before building.

--backend and --docker-host are also exposed on stellar contract deploy and stellar contract upload (which auto-build when no --wasm / --wasm-hash is given), so the same flags work end-to-end.

Deploy

stellar contract deploy against mainnet now warns when the wasm is missing any of cliver, bldimg, rsver, source_repo, source_rev, bldopt_manifest_path, bldopt_package, bldopt_profile:

⚠ the wasm being deployed is missing reproducibility meta entries: ["bldimg", "source_repo", "source_rev", "bldopt_manifest_path", "bldopt_package", "bldopt_profile"]. The deployed wasm may not be independently verifiable. To make it reproducible, build with `stellar contract build --backend docker` in a clean git repository.

The check is mainnet-only (matches network passphrase against Public Global Stellar Network ; September 2015); on testnet/futurenet/local the wasm deploys silently.

Verify

verify is a subcommand of build — it lives at stellar contract build verify, and works on multi-contract workspaces by rebuilding and finding the match.

stellar contract build verify --contract-id CXXX… --network mainnet
stellar contract build verify --wasm-hash <hash> --network mainnet
stellar contract build verify --wasm contract.wasm
  1. Fetches the original wasm (file path, hash, or contract id, same flags as contract info).
  2. Reads cliver, bldimg (optional), rsver, and bldopt_* (optional, best-effort) from the wasm's meta. Missing bldopt_* entries trigger a warning rather than an error and the build falls back to its defaults — verify still runs, just with the caveat that the rebuild may not be reproducible.
  3. Picks the rebuild backend from the meta:
    • bldimg present → Backend::Docker { image: bldimg }. The image's pinned digest pulls the same in-container cli that produced the original.
    • bldimg absent → Backend::Local. Best-effort rebuild on the host.
  4. Forwards the wasm's rsver to the rebuild as RUSTUP_TOOLCHAIN (in-container) or cargo +<rsver> (local). For docker the toolchain inside the image is fixed by whoever built it; passing RUSTUP_TOOLCHAIN lets rustup-managed cargo switch toolchains if the image carries multiple ones.
  5. Resolves bldopt_manifest_path against the cwd's git top-level (via git rev-parse --show-toplevel) so verify works from anywhere inside the checkout.
  6. Hashes every rebuilt artifact and looks for a match against the original. Prints ✅ on match (with the matching crate's name); ⚠ + non-zero exit on mismatch (with each rebuilt artifact's name + hash).

The user is responsible for checking out the matching commit before running verify; verify rebuilds from the working tree. (source_repo and source_rev are embedded in meta to help users find the right commit, but verify itself doesn't clone — that would add a separate trust path.)

End-to-end example

$ stellar contract build --backend dockerℹ Pulling from stellar/stellar-cli Digest: sha256:cb2fc3116a6ace37a77ca6bb88afb4bee57fc746cd556a4373f2c3ee95d4e917 Status: Image is up to date for stellar/stellar-cli@sha256:cb2fc3...ℹ contract build --manifest-path /source/contracts/foo/Cargo.toml --profile release --locked --meta bldimg=docker.io/stellar/stellar-cli@sha256:cb2fc3... Compiling foo v… Finished `release` profile [optimized] target(s) in 1.09sℹ Build Summary: Wasm File: target/wasm32v1-none/release/foo.wasm (907 bytes) Wasm Hash: 9f86d081…✅ Build Complete
$ stellar contract info meta --wasm target/wasm32v1-none/release/foo.wasmcliver=26.0.0#abc1234bldimg=docker.io/stellar/stellar-cli@sha256:cb2fc3...rsver=1.83.0source_repo=https://github.com/user/my-contractsource_rev=abc1234567890abcdef…bldopt_manifest_path=contracts/foo/Cargo.tomlbldopt_package=foobldopt_profile=release
# Later, on a different machine, with the matching commit checked out:
$ stellar contract build verify --wasm-hash <hash> --network mainnetℹ Loading contract from network...ℹ Loading meta from contract... Original wasm hash: 9f86d081… stellar-cli version: 26.0.0#abc1234 rust version: 1.83.0 Docker image: docker.io/stellar/stellar-cli@sha256:cb2fc3... Manifest path: contracts/foo/Cargo.toml Package: foo Profile: releaseℹ contract build --manifest-path /source/contracts/foo/Cargo.toml --profile release --locked --meta bldimg=docker.io/stellar/stellar-cli@sha256:cb2fc3... Compiling foo v…✅ Build Complete✅ Verified: rebuilt foo wasm matches 9f86d081…

The host CLI's version is irrelevant for verifying a docker-built wasm — whatever cli is in the image is what built (and rebuilds) the wasm.

Notes

  • Communication with the daemon: bollard's HTTP API over the docker socket (/var/run/docker.sock, or whatever --docker-host / DOCKER_HOST points at). Same connect_to_docker helper used by stellar container start/stop/logs, with the same Docker Desktop fallback ($HOME/.docker/run/docker.sock). No shell-out to the docker CLI. A podman socket exposing the Docker API would also work (untested).
  • Default image is digest-pinned: --backend docker (no =...) defaults to docker.io/stellar/stellar-cli@sha256:cb2fc3..., notstellar/stellar-cli:latest. Recording a digest immediately makes builds reproducible day one and avoids the Apple Silicon RepoDigests-after-cross-platform-pull quirk. Bumping the default is a single-line const change in build.rs (see comments there for the recipe). Users who want a different image specify --backend docker=....
  • Entrypoint override: the official stellar/stellar-cli image's entrypoint runs entrypoint.sh, which launches dbus + gnome-keyring. That setup fails when the container runs as a host UID without an /etc/passwd entry — see Docker image's entrypoint dbus init fails when run as non-root UID #2543. We override the entrypoint to point straight at the stellar binary, which is fine because contract build doesn't touch the keyring.
  • Caching: the bind-mount of host ~/.cargo/registry lets the container reuse crate downloads the host already has.
  • Wasm target installation: deferred to the image. The official image has wasm32v1-none pre-installed for its default toolchain; if RUSTUP_TOOLCHAIN selects a different one (verify on a wasm built with another rust version), the cli/cargo handle target installation themselves.
  • Toolchain pinning: verify sets RUSTUP_TOOLCHAIN=<rsver> inside the container (and cargo +<rsver> for local rebuilds) so the rust version matches whatever the original build used.
  • Image fully-qualified: bldimg is normalized to <registry>/<path>@sha256:<digest> (e.g. stellar/stellar-cli:latestdocker.io/stellar/stellar-cli@sha256:…) so verify can resolve it without relying on the local registry config.
  • Source URL canonicalization: source_repo is normalized to https://… form (e.g. git@github.com:user/repo.githttps://github.com/user/repo).
  • Build options auto-recorded: bldopt_manifest_path is recorded relative to the git repo root regardless of whether --manifest-path was passed on the CLI. Verify resolves it against the cwd's git top-level so the command works from anywhere inside the checkout.
  • No new in-container flags: the host invokes stellar contract build inside the image with only flags that exist in published stellar/stellar-cli images today (--manifest-path, --profile, --locked, --meta, --package, --features, --all-features, --no-default-features, --optimize). bldimg is forwarded via --meta bldimg=<digest>, not a new flag.
  • No bldbkd field: presence of bldimg is the only signal needed to distinguish a docker build from a local one.
  • Aborted container runs: may leave a stopped container; clean with docker container prune.

Performance/runtime caveats

Building inside an amd64 container on a non-amd64 host (Apple Silicon, Linux/arm64) runs under emulation. For small contracts the difference is negligible; for workspaces with heavy dep trees the emulated build can be substantially slower than a native host build. Container runtimes that don't ship qemu/binfmt support won't run amd64 containers on arm64 hosts at all. See #2506 (comment).

Related issues

Status

This is an experiment in validating the ideas in #2506. May or may not be destined for merging — at this moment it's an experiment in validating the approach.

@github-project-automationgithub-project-automationBot moved this to Backlog (Not Ready) in DevXApr 27, 2026
@leighmcculloch

leighmcculloch commented May 1, 2026

Copy link
Copy Markdown
MemberAuthor

Opened an issue about dbus creating problems with using the image for the build for the verification step:

@fnando

Copy link
Copy Markdown
Member

@leighmcculloch I just tried this, but I'm getting a warning, even though there are no unstaged files.

$ git statusOn branch mainnothing to commit, working tree clean
$ stellar contract build --backend docker⚠️ git working tree has uncommitted changes; source_repo/source_rev/bldopt_* not embedded in contract metadata. Commit changes for a reproducible build.

}

let backend = match bldimg {
Some(image) => build::Backend::Docker { image },

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

bldimg needs some form of allowlist, e.g. docker.io/stellar/stellar-cli@sha256:*, otherwise I can inject a docker image that bypasses verification

}
});

let build_cmd = build::Cmd {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we need feature flags here? While not extremely common, I have used them in the past to reduce code duplication between similar contracts. Ref -> https://github.com/script3/soroban-governor/tree/main/contracts/votes

Comment on lines +172 to +176
// - The official `stellar/stellar-cli` image's stock entrypoint is a
// wrapper script that launches dbus + gnome-keyring before exec-ing
// `stellar`; that setup is irrelevant for `contract build` and dbus
// refuses to start when the container runs as a host UID with no
// `/etc/passwd` entry. Skipping it keeps the host UID mapping intact.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This was cleaned up in a recent PR. Does this simplify anything?

attach_stdout: Some(true),
attach_stderr: Some(true),
host_config: Some(HostConfig {
binds: Some(binds),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Have we considered removing the host bind mounts? Given verify will build untrusted code, it might be best if we keep the build artifacts within the container, then just extract the WASM file out.

We could consider having two configurations docker-build and docker-verify, where build keeps mounts to help speed up repeated builds and verify is more black-box to provide a bit more protection.

Comment on lines +71 to +77
let cliver = find_meta(&spec.meta, "cliver").ok_or(Error::MissingMeta("cliver"))?;
let bldimg = find_meta(&spec.meta, "bldimg");
let rsver = find_meta(&spec.meta, "rsver").ok_or(Error::MissingMeta("rsver"))?;
let bldopt_manifest_path = find_meta(&spec.meta, "bldopt_manifest_path");
let bldopt_package = find_meta(&spec.meta, "bldopt_package");
let bldopt_profile = find_meta(&spec.meta, "bldopt_profile");
let bldopt_optimize = find_meta(&spec.meta, "bldopt_optimize").is_some();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we should enforce the regex here

@leighmcculloch

Copy link
Copy Markdown
MemberAuthor

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

Add --docker option and stellar contract verify for reproducible builds

4 participants

@leighmcculloch@fnando@mootz12@chadoh
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content

Add container build backend and build verify command - #2525

Closed
leighmcculloch wants to merge 75 commits into
mainfrom
feat/reproducible-builds-via-docker
Closed

Add container build backend and build verify command#2525
leighmcculloch wants to merge 75 commits into
mainfrom
feat/reproducible-builds-via-docker

Conversation

@leighmcculloch

@leighmccullochleighmcculloch commented Apr 27, 2026

Copy link
Copy Markdown
Member

What

Add --backend docker[=<image>] to stellar contract build (and deploy/upload) that runs the entire build pipeline inside a container whose entrypoint is stellar. Add a stellar contract build verify subcommand that reads everything it needs from the wasm's metadata, rebuilds, and reports which (if any) rebuilt artifact is byte-identical to the original. Add a mainnet warning on stellar contract deploy when the wasm is missing the meta entries needed for independent verification.

Why

Contract builds vary across host OS, architecture, and toolchain, preventing third parties from independently confirming a deployed contract was built from given source. Pinning the build to a docker image plus the rust toolchain version makes builds reproducible, recording the source repo + commit + per-package build options lets verifiers rebuild the exact same artifact, and the new verify subcommand automates the rebuild-and-compare check.

Closes#2506.

How it works

Three parts: build-time recording, deploy-time warning, and verify-time reproduction.

Build

stellar contract build --backend local # default; host build
stellar contract build --backend docker # build inside docker.io/stellar/stellar-cli@sha256:...
stellar contract build --backend docker=stellar/stellar-cli:26.0.0
stellar contract build --backend docker=quay.io/myorg/myimage@sha256:...

For all backends (including local), the build:

  • Detects whether the workspace is a clean git checkout. If clean and there's an origin remote, embeds source_repo (URL canonicalized to https://…), source_rev (full HEAD SHA), and per-package build options (bldopt_manifest_path relative to git root, bldopt_package, bldopt_profile, optional bldopt_optimize). The manifest path is auto-inserted whether or not --manifest-path was passed on the CLI.
  • If the working tree has uncommitted changes, prints a warning and omits source_repo / source_rev / bldopt_*.
  • If not a git repo, silently omits.

For --backend docker, additionally:

  1. Resolves the requested image. Default is docker.io/stellar/stellar-cli@sha256:cb2fc3116a6ace37a77ca6bb88afb4bee57fc746cd556a4373f2c3ee95d4e917 — pinned by digest so the recorded bldimg is reproducible from day one and we sidestep the longstanding Apple Silicon docker quirk where pulling a multi-arch tag with --platform=linux/amd64 leaves RepoDigests empty after pull.
  2. Pulls the image (skipping the pull if it's already locally present, since digest-pinned references are immutable).
  3. Bind-mounts on the container:
    • <git_root or workspace_root>/source (rw, source — also where cargo writes its target dir, shared with the host)
    • host ~/.cargo/registry/usr/local/cargo/registry (rw, cached crate downloads)
  4. The container runs as the host uid:gid, so files written to the bind mount are readable/writable by the host user.
  5. Overrides the image's entrypoint to invoke stellar directly, bypassing the official image's entrypoint.sh (which launches dbus + gnome-keyring and trips when running under a host UID with no /etc/passwd entry — see Docker image's entrypoint dbus init fails when run as non-root UID #2543). contract build doesn't use the keyring, so the wrapper is irrelevant here.
  6. Runs stellar contract build --manifest-path /source/<rel> --profile <p> --locked --meta bldimg=<digest> [forwarded args] inside the container. The args use only flags that exist in published stellar/stellar-cli images today; no new flags are added, and --backend local is deliberately not passed (it's a flag added in this PR and isn't recognized by published images).
  7. The in-container cli does cargo + meta injection + spec filtering + optional wasm-opt itself; the host only orchestrates and copies outputs to --out-dir if requested.

The wasm's contractmetav0 custom section is populated with up to nine entries:

keyvalueregex (validation)injected by
cliver26.0.0#abc1234… (CLI version + git rev)^\d+\.\d+\.\d+(-[A-Za-z0-9.+-]+)?#([0-9a-f]{40}(-dirty)?)?$stellar-cli
bldimgdocker.io/stellar/stellar-cli@sha256:…^[^@\s]+@sha256:[0-9a-f]{64}$stellar-cli (this PR; only with --backend docker)
rsver1.83.0 (resolved rustc version)^\d+\.\d+\.\d+(-[A-Za-z0-9.+-]+)?$soroban-sdk
source_repohttps://github.com/user/repo (clean repo's origin)^https?://\S+$stellar-cli (this PR)
source_revfull 40-char HEAD SHA^[0-9a-f]{40}$stellar-cli (this PR)
bldopt_manifest_pathe.g. contracts/foo/Cargo.toml (relative to git)^([^/\s]+/)*Cargo\.toml$stellar-cli (this PR)
bldopt_packagecargo package name being built^[A-Za-z][A-Za-z0-9_-]*$stellar-cli (this PR)
bldopt_profilecargo profile (e.g. release)^[A-Za-z][A-Za-z0-9_-]*$stellar-cli (this PR)
bldopt_optimizetrue (only present when --optimize was used)^true$stellar-cli (this PR)

The presence of bldimg is what distinguishes a docker build from a local one — there's no separate bldbkd field. For full reproducibility from day one, pin to a specific image with --backend docker=<name>@sha256:… and commit before building.

--backend and --docker-host are also exposed on stellar contract deploy and stellar contract upload (which auto-build when no --wasm / --wasm-hash is given), so the same flags work end-to-end.

Deploy

stellar contract deploy against mainnet now warns when the wasm is missing any of cliver, bldimg, rsver, source_repo, source_rev, bldopt_manifest_path, bldopt_package, bldopt_profile:

⚠ the wasm being deployed is missing reproducibility meta entries: ["bldimg", "source_repo", "source_rev", "bldopt_manifest_path", "bldopt_package", "bldopt_profile"]. The deployed wasm may not be independently verifiable. To make it reproducible, build with `stellar contract build --backend docker` in a clean git repository.

The check is mainnet-only (matches network passphrase against Public Global Stellar Network ; September 2015); on testnet/futurenet/local the wasm deploys silently.

Verify

verify is a subcommand of build — it lives at stellar contract build verify, and works on multi-contract workspaces by rebuilding and finding the match.

stellar contract build verify --contract-id CXXX… --network mainnet
stellar contract build verify --wasm-hash <hash> --network mainnet
stellar contract build verify --wasm contract.wasm
  1. Fetches the original wasm (file path, hash, or contract id, same flags as contract info).
  2. Reads cliver, bldimg (optional), rsver, and bldopt_* (optional, best-effort) from the wasm's meta. Missing bldopt_* entries trigger a warning rather than an error and the build falls back to its defaults — verify still runs, just with the caveat that the rebuild may not be reproducible.
  3. Picks the rebuild backend from the meta:
    • bldimg present → Backend::Docker { image: bldimg }. The image's pinned digest pulls the same in-container cli that produced the original.
    • bldimg absent → Backend::Local. Best-effort rebuild on the host.
  4. Forwards the wasm's rsver to the rebuild as RUSTUP_TOOLCHAIN (in-container) or cargo +<rsver> (local). For docker the toolchain inside the image is fixed by whoever built it; passing RUSTUP_TOOLCHAIN lets rustup-managed cargo switch toolchains if the image carries multiple ones.
  5. Resolves bldopt_manifest_path against the cwd's git top-level (via git rev-parse --show-toplevel) so verify works from anywhere inside the checkout.
  6. Hashes every rebuilt artifact and looks for a match against the original. Prints ✅ on match (with the matching crate's name); ⚠ + non-zero exit on mismatch (with each rebuilt artifact's name + hash).

The user is responsible for checking out the matching commit before running verify; verify rebuilds from the working tree. (source_repo and source_rev are embedded in meta to help users find the right commit, but verify itself doesn't clone — that would add a separate trust path.)

End-to-end example

$ stellar contract build --backend dockerℹ Pulling from stellar/stellar-cli Digest: sha256:cb2fc3116a6ace37a77ca6bb88afb4bee57fc746cd556a4373f2c3ee95d4e917 Status: Image is up to date for stellar/stellar-cli@sha256:cb2fc3...ℹ contract build --manifest-path /source/contracts/foo/Cargo.toml --profile release --locked --meta bldimg=docker.io/stellar/stellar-cli@sha256:cb2fc3... Compiling foo v… Finished `release` profile [optimized] target(s) in 1.09sℹ Build Summary: Wasm File: target/wasm32v1-none/release/foo.wasm (907 bytes) Wasm Hash: 9f86d081…✅ Build Complete
$ stellar contract info meta --wasm target/wasm32v1-none/release/foo.wasmcliver=26.0.0#abc1234bldimg=docker.io/stellar/stellar-cli@sha256:cb2fc3...rsver=1.83.0source_repo=https://github.com/user/my-contractsource_rev=abc1234567890abcdef…bldopt_manifest_path=contracts/foo/Cargo.tomlbldopt_package=foobldopt_profile=release
# Later, on a different machine, with the matching commit checked out:
$ stellar contract build verify --wasm-hash <hash> --network mainnetℹ Loading contract from network...ℹ Loading meta from contract... Original wasm hash: 9f86d081… stellar-cli version: 26.0.0#abc1234 rust version: 1.83.0 Docker image: docker.io/stellar/stellar-cli@sha256:cb2fc3... Manifest path: contracts/foo/Cargo.toml Package: foo Profile: releaseℹ contract build --manifest-path /source/contracts/foo/Cargo.toml --profile release --locked --meta bldimg=docker.io/stellar/stellar-cli@sha256:cb2fc3... Compiling foo v…✅ Build Complete✅ Verified: rebuilt foo wasm matches 9f86d081…

The host CLI's version is irrelevant for verifying a docker-built wasm — whatever cli is in the image is what built (and rebuilds) the wasm.

Notes

  • Communication with the daemon: bollard's HTTP API over the docker socket (/var/run/docker.sock, or whatever --docker-host / DOCKER_HOST points at). Same connect_to_docker helper used by stellar container start/stop/logs, with the same Docker Desktop fallback ($HOME/.docker/run/docker.sock). No shell-out to the docker CLI. A podman socket exposing the Docker API would also work (untested).
  • Default image is digest-pinned: --backend docker (no =...) defaults to docker.io/stellar/stellar-cli@sha256:cb2fc3..., notstellar/stellar-cli:latest. Recording a digest immediately makes builds reproducible day one and avoids the Apple Silicon RepoDigests-after-cross-platform-pull quirk. Bumping the default is a single-line const change in build.rs (see comments there for the recipe). Users who want a different image specify --backend docker=....
  • Entrypoint override: the official stellar/stellar-cli image's entrypoint runs entrypoint.sh, which launches dbus + gnome-keyring. That setup fails when the container runs as a host UID without an /etc/passwd entry — see Docker image's entrypoint dbus init fails when run as non-root UID #2543. We override the entrypoint to point straight at the stellar binary, which is fine because contract build doesn't touch the keyring.
  • Caching: the bind-mount of host ~/.cargo/registry lets the container reuse crate downloads the host already has.
  • Wasm target installation: deferred to the image. The official image has wasm32v1-none pre-installed for its default toolchain; if RUSTUP_TOOLCHAIN selects a different one (verify on a wasm built with another rust version), the cli/cargo handle target installation themselves.
  • Toolchain pinning: verify sets RUSTUP_TOOLCHAIN=<rsver> inside the container (and cargo +<rsver> for local rebuilds) so the rust version matches whatever the original build used.
  • Image fully-qualified: bldimg is normalized to <registry>/<path>@sha256:<digest> (e.g. stellar/stellar-cli:latestdocker.io/stellar/stellar-cli@sha256:…) so verify can resolve it without relying on the local registry config.
  • Source URL canonicalization: source_repo is normalized to https://… form (e.g. git@github.com:user/repo.githttps://github.com/user/repo).
  • Build options auto-recorded: bldopt_manifest_path is recorded relative to the git repo root regardless of whether --manifest-path was passed on the CLI. Verify resolves it against the cwd's git top-level so the command works from anywhere inside the checkout.
  • No new in-container flags: the host invokes stellar contract build inside the image with only flags that exist in published stellar/stellar-cli images today (--manifest-path, --profile, --locked, --meta, --package, --features, --all-features, --no-default-features, --optimize). bldimg is forwarded via --meta bldimg=<digest>, not a new flag.
  • No bldbkd field: presence of bldimg is the only signal needed to distinguish a docker build from a local one.
  • Aborted container runs: may leave a stopped container; clean with docker container prune.

Performance/runtime caveats

Building inside an amd64 container on a non-amd64 host (Apple Silicon, Linux/arm64) runs under emulation. For small contracts the difference is negligible; for workspaces with heavy dep trees the emulated build can be substantially slower than a native host build. Container runtimes that don't ship qemu/binfmt support won't run amd64 containers on arm64 hosts at all. See #2506 (comment).

Related issues

Status

This is an experiment in validating the ideas in #2506. May or may not be destined for merging — at this moment it's an experiment in validating the approach.

@github-project-automationgithub-project-automationBot moved this to Backlog (Not Ready) in DevXApr 27, 2026
@leighmcculloch

leighmcculloch commented May 1, 2026

Copy link
Copy Markdown
MemberAuthor

Opened an issue about dbus creating problems with using the image for the build for the verification step:

@fnando

Copy link
Copy Markdown
Member

@leighmcculloch I just tried this, but I'm getting a warning, even though there are no unstaged files.

$ git statusOn branch mainnothing to commit, working tree clean
$ stellar contract build --backend docker⚠️ git working tree has uncommitted changes; source_repo/source_rev/bldopt_* not embedded in contract metadata. Commit changes for a reproducible build.

}

let backend = match bldimg {
Some(image) => build::Backend::Docker { image },

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

bldimg needs some form of allowlist, e.g. docker.io/stellar/stellar-cli@sha256:*, otherwise I can inject a docker image that bypasses verification

}
});

let build_cmd = build::Cmd {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we need feature flags here? While not extremely common, I have used them in the past to reduce code duplication between similar contracts. Ref -> https://github.com/script3/soroban-governor/tree/main/contracts/votes

Comment on lines +172 to +176
// - The official `stellar/stellar-cli` image's stock entrypoint is a
// wrapper script that launches dbus + gnome-keyring before exec-ing
// `stellar`; that setup is irrelevant for `contract build` and dbus
// refuses to start when the container runs as a host UID with no
// `/etc/passwd` entry. Skipping it keeps the host UID mapping intact.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This was cleaned up in a recent PR. Does this simplify anything?

attach_stdout: Some(true),
attach_stderr: Some(true),
host_config: Some(HostConfig {
binds: Some(binds),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Have we considered removing the host bind mounts? Given verify will build untrusted code, it might be best if we keep the build artifacts within the container, then just extract the WASM file out.

We could consider having two configurations docker-build and docker-verify, where build keeps mounts to help speed up repeated builds and verify is more black-box to provide a bit more protection.

Comment on lines +71 to +77
let cliver = find_meta(&spec.meta, "cliver").ok_or(Error::MissingMeta("cliver"))?;
let bldimg = find_meta(&spec.meta, "bldimg");
let rsver = find_meta(&spec.meta, "rsver").ok_or(Error::MissingMeta("rsver"))?;
let bldopt_manifest_path = find_meta(&spec.meta, "bldopt_manifest_path");
let bldopt_package = find_meta(&spec.meta, "bldopt_package");
let bldopt_profile = find_meta(&spec.meta, "bldopt_profile");
let bldopt_optimize = find_meta(&spec.meta, "bldopt_optimize").is_some();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we should enforce the regex here

@leighmcculloch

Copy link
Copy Markdown
MemberAuthor

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

Add --docker option and stellar contract verify for reproducible builds

4 participants

@leighmcculloch@fnando@mootz12@chadoh