Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
7d8c775
Scaffold stellar contract verify with metadata extraction.
fnando May 21, 2026
dfc21c9
Add trust gates to stellar contract verify.
fnando May 22, 2026
8176d65
Materialize source for stellar contract verify.
fnando May 22, 2026
4b3825b
Rebuild and byte-compare in stellar contract verify.
fnando May 22, 2026
36f9c77
Use direct --docker-host field on contract verify.
fnando May 22, 2026
1090011
Use question and warn emojis on trust prompt.
fnando May 22, 2026
a5c04fc
Respect --verbose and --quiet on contract verify.
fnando May 22, 2026
d8f2937
Force trust prompts visible even under --quiet.
fnando May 22, 2026
56bfa5a
Capitalize Verified result line.
fnando May 22, 2026
f1b1f1a
Capitalize info, warn, and check messages on contract verify.
fnando May 22, 2026
9cb9b16
Expand github:user/repo source_repo before git clone.
fnando May 22, 2026
2faa4c2
Validate retrieval channel before trust prompts.
fnando May 22, 2026
55e4201
Anchor verify rebuilt-wasm search at manifest-path parent.
fnando May 22, 2026
ec95d1a
Add stellar contract verify integration tests.
fnando May 22, 2026
96c463a
Add tarball-sha256 + local --tarball-url verify test.
fnando May 22, 2026
79dc60e
Move verify integration tests into integration tier.
fnando May 22, 2026
7fb44ca
Restrict permissions on materialized verify source.
fnando May 22, 2026
a550f47
Share build logic and fix contract verify rebuild.
fnando Jun 17, 2026
fca405e
Add --wasm-hash to stellar contract verify.
fnando Jun 17, 2026
93e1b16
Stop requiring source hash when setting source URI.
fnando Jun 19, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,8 @@ hex = "0.4.3"
itertools = "0.10.0"
async-trait = "0.1.76"
bollard = "0.20.2"
tar = "0.4.46"
flate2 = "1.0.30"
serde-aux = "4.1.2"
serde_json = "1.0.82"
serde = "1.0.82"
Expand Down
39 changes: 29 additions & 10 deletions FULL_HELP_DOCS.md
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,7 @@ Tools for smart contract developers
- `optimize` — ⚠️ Deprecated, use `build --optimize`. Optimize a WASM file
- `read` — Print the current value of a contract-data ledger entry
- `restore` — Restore an evicted value for a contract-data legder entry
- `verify` — Verify that a contract's WASM reproduces from the build metadata it records, per SEP-58. Either pass a contract id/alias via `--id` (the WASM is fetched from the network) or a local file via `--wasm`

## `stellar contract asset`

Expand Down Expand Up @@ -412,7 +413,7 @@ To view the commands that will be executed, without executing them, use the --pr
- `--verifiable` — Build inside a trusted Docker container and record SEP-58 metadata (`bldimg`, `source_uri`, `source_sha256`, `bldopt`) so the resulting WASM can be reproduced and verified by third parties. Implies `--locked`. Requires a clean git working tree
- `--image <IMAGE>` — Override the auto-selected container image used by `--verifiable`. Must be digest-pinned, e.g. `docker.io/stellar/stellar-cli@sha256:...`. Tag-only refs are rejected because SEP-58 requires content addressing
- `--source-sha256 <SOURCE_SHA256>` — SEP-58 source identification: SHA-256 of the source archive (recorded as the `source_sha256` meta entry). Optional with `--verifiable`: the archive is always generated and its SHA-256 computed for you. When supplied it's treated as a pin — the build fails if it doesn't match the generated archive
- `--source-uri <SOURCE_URI>` — SEP-58 source identification: URI where the source can be obtained, e.g. `https://example.com/src.tar.gz` (recorded as the `source_uri` meta entry). Optional; when set it must accompany `--source-sha256`
- `--source-uri <SOURCE_URI>` — SEP-58 source identification: URI where the source can be obtained, e.g. `https://example.com/src.tar.gz` (recorded as the `source_uri` meta entry). Optional with `--verifiable`; the recorded `source_sha256` is computed from the generated archive, unless `--source-sha256` is explicitly set
- `-d`, `--docker-host <DOCKER_HOST>` — Override the default docker host used by `--verifiable`

## `stellar contract extend`
Expand Down Expand Up @@ -1151,6 +1152,32 @@ If no keys are specificed the contract itself is restored.
- `--inclusion-fee <INCLUSION_FEE>` — Maximum fee amount for transaction inclusion, in stroops. 1 stroop = 0.0000001 xlm. Defaults to 100 if no arg, env, or config value is provided
- `--build-only` — Build the transaction and only write the base64 xdr to stdout

## `stellar contract verify`

Verify that a contract's WASM reproduces from the build metadata it records, per SEP-58. Either pass a contract id/alias via `--id` (the WASM is fetched from the network) or a local file via `--wasm`

**Usage:** `stellar contract verify [OPTIONS]`

###### **Global Options:**

- `--config-dir <CONFIG_DIR>` — Location of config directory. By default, it uses `$XDG_CONFIG_HOME/stellar` if set, falling back to `~/.config/stellar` otherwise. Contains configuration files, aliases, and other persistent settings

###### **Options:**

- `--id <CONTRACT_ID>` — Contract id or alias to fetch the WASM from the network
- `--wasm <WASM>` — Local WASM file to verify, instead of fetching from the network
- `--wasm-hash <WASM_HASH>` — WASM hash (hex) to fetch the WASM from the network
- `--source-uri <SOURCE_URI>` — Local source code file or http(s) URL to use as the source when the WASM's recorded SEP-58 metadata has only `source_sha256` (no `source_uri`). Accepts http(s) URLs or local file paths
- `--trust` — Bypass interactive confirmation when the WASM's bldimg is not in the default trust list, or when the source is a tarball (tarballs are never default-trusted)
- `-d`, `--docker-host <DOCKER_HOST>` — Override the default docker host used by the rebuild

###### **RPC Options:**

- `--rpc-url <RPC_URL>` — RPC server endpoint
- `--rpc-header <RPC_HEADERS>` — RPC Header(s) to include in requests to the RPC provider, example: "X-API-Key: abc123". Multiple headers can be added by passing the option multiple times
- `--network-passphrase <NETWORK_PASSPHRASE>` — Network passphrase to sign the transaction sent to the rpc server
- `-n`, `--network <NETWORK>` — Name of network to use from config

## `stellar doctor`

Diagnose and troubleshoot CLI and network issues
Expand Down Expand Up @@ -4170,7 +4197,7 @@ Encode a transaction envelope from JSON to XDR

Decode and encode XDR

**Usage:** `stellar xdr [CHANNEL] <COMMAND>`
**Usage:** `stellar xdr <COMMAND>`

###### **Subcommands:**

Expand All @@ -4183,14 +4210,6 @@ Decode and encode XDR
- `xfile` — Preprocess XDR .x files
- `version` — Print version information

###### **Arguments:**

- `<CHANNEL>` — Channel of XDR to operate on

Default value: `+curr`

Possible values: `+curr`, `+next`

## `stellar xdr types`

View information about types
Expand Down
30 changes: 28 additions & 2 deletions cmd/crates/soroban-test/tests/it/build.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1289,15 +1289,41 @@ fn verifiable_source_uri_format_errors() {
.arg("--verifiable")
.arg("--image")
.arg(ZERO_DIGEST)
.arg("--source-sha256")
.arg("a".repeat(64))
.arg("--source-uri")
.arg("not a uri")
.assert()
.failure()
.stderr(predicate::str::contains("source_uri format"));
}

// `--source-uri` does not require `--source-sha256`: the archive is generated
// and its source_sha256 computed regardless, so a source-uri-only build gets
// past validation and writes the archive (then fails reaching a real image).
#[test]
fn verifiable_source_uri_without_sha256_is_allowed() {
let sandbox = TestEnv::default();
let (_temp, workspace) = fresh_workspace();
git_in(&workspace, &["init", "-q", "-b", "main"]);
git_in(&workspace, &["add", "-A"]);
git_in(&workspace, &["commit", "-q", "-m", "init"]);

sandbox
.new_assert_cmd("contract")
.current_dir(workspace.join("contracts").join("add"))
.arg("build")
.arg("--verifiable")
.arg("--image")
.arg(ZERO_DIGEST)
.arg("--source-uri")
.arg("https://example.com/src.tar.gz")
.assert()
.failure()
.stderr(
predicate::str::contains("Wrote source archive")
.and(predicate::str::contains("source_sha256")),
);
}

// A dirty git tree is a hard fail under `--verifiable` (the recorded
// source_sha256 would not describe the bytes built).
#[test]
Expand Down
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
mod fetch;
mod info_hash;
mod verify;
167 changes: 167 additions & 0 deletions cmd/crates/soroban-test/tests/it/integration/contract/verify.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
//! End-to-end tests for `stellar contract verify`.
//!
//! Pipeline, entirely through the cli (no git/network clone needed):
//! 1. `contract init` scaffolds a workspace + `hello-world` contract.
//! 2. `contract build --verifiable` builds it against a pinned bldimg and
//! records `source_sha256` in the wasm's SEP-58 metadata.
//! 3. `contract archive` regenerates the *same* source tarball (same
//! `build_source_archive` the verifiable build used), so its sha256 matches
//! the recorded `source_sha256`.
//! 4. `contract verify --source-uri <that archive>` materializes the source,
//! rebuilds in the bldimg, and byte-compares.
//!
//! The happy-path tests require docker + the pinned bldimg pullable from Docker
//! Hub. They are always-run by convention (per the project's "no #[ignore]"
//! rule) — failures there flag a regression or pinned-resource drift loudly.
//!
//! Fixture pin:
//! - bldimg: `docker.io/fnando/stellar-cli-experimental@sha256:85e76e…`.
//! TODO: swap to `docker.io/stellar/stellar-cli@sha256:<…>` once
//! `stellar/stellar-cli-docker` publishes a canonical tag matching the
//! cli version under test.

use predicates::prelude::{predicate, PredicateBooleanExt};
use soroban_test::TestEnv;
use std::path::{Path, PathBuf};

const PINNED_BLDIMG: &str =
"docker.io/fnando/stellar-cli-experimental@sha256:85e76eae8bf9f47ba94391214b76f8fa2b9d7b28171774dfafaf5b8d613a74d3";

/// Scaffold a workspace with the default `hello-world` contract under
/// `<sandbox>/proj`. The scaffolded tree is not a git repo, so the verifiable
/// build archives the working directory directly.
fn init_project(sandbox: &TestEnv) -> PathBuf {
let proj = sandbox.dir().join("proj");
sandbox
.new_assert_cmd("contract")
.arg("init")
.arg(&proj)
.assert()
.success();
proj
}

/// Build the scaffolded contract verifiably and generate the matching source
/// archive. Returns `(wasm_path, archive_path)`.
///
/// The archive is produced *after* the verifiable build on purpose: the build's
/// host-side `cargo metadata` writes `Cargo.lock` into the workspace, and
/// `contract archive` then captures that same tree — so the archive's sha256
/// equals the `source_sha256` the build recorded into the wasm.
fn build_and_archive(sandbox: &TestEnv, proj: &Path) -> (PathBuf, PathBuf) {
let out_dir = sandbox.dir().join("out");
std::fs::create_dir_all(&out_dir).unwrap();
sandbox
.new_assert_cmd("contract")
.arg("build")
.arg("--verifiable")
.arg("--image")
.arg(PINNED_BLDIMG)
.arg("--out-dir")
.arg(&out_dir)
.current_dir(proj)
.assert()
.success();

let archive = sandbox.dir().join("source.tar.gz");
sandbox
.new_assert_cmd("contract")
.arg("archive")
.arg("--out-file")
.arg(&archive)
.current_dir(proj)
.assert()
.success();

(out_dir.join("hello_world.wasm"), archive)
}

/// Happy path: build a verifiable wasm, then verify it from the local file,
/// handing the cli the matching source archive via `--source-uri`. Asserts the
/// cli prints `Verified:` on stderr.
#[test]
fn verify_wasm_succeeds_for_freshly_built_verifiable_wasm() {
let sandbox = TestEnv::default();
let proj = init_project(&sandbox);
let (wasm, archive) = build_and_archive(&sandbox, &proj);

sandbox
.new_assert_cmd("contract")
.arg("verify")
.arg("--wasm")
.arg(&wasm)
.arg("--source-uri")
.arg(&archive)
.arg("--trust")
.assert()
.success()
.stderr(predicate::str::contains("Verified:"));
}

/// Build verifiable → upload to local network → verify by `--id`. Exercises the
/// `wasm::fetch_from_contract` path through the verify command.
#[tokio::test]
async fn verify_id_succeeds_after_upload() {
let sandbox = TestEnv::new();
let proj = init_project(&sandbox);
let (wasm, archive) = build_and_archive(&sandbox, &proj);
let wasm_str = wasm.to_string_lossy().to_string();

// Deploy gives us a contract id `--id` can resolve to the on-ledger wasm.
let id = sandbox
.new_assert_cmd("contract")
.arg("deploy")
.arg("--wasm")
.arg(&wasm_str)
.arg("--alias")
.arg("verify_e2e")
.arg("--ignore-checks")
.assert()
.success()
.stdout(predicate::str::is_empty().not())
.get_output()
.stdout
.clone();
let id = String::from_utf8(id).unwrap().trim().to_string();

sandbox
.new_assert_cmd("contract")
.arg("verify")
.arg("--id")
.arg(&id)
.arg("--source-uri")
.arg(&archive)
.arg("--trust")
.assert()
.success()
.stderr(predicate::str::contains("Verified:"));
}

/// Flip a byte in a verifiable wasm and confirm `contract verify` reports the
/// mismatch. The flipped byte is in the middle (code) so the trailing
/// `contractmetav0` section still parses; the rebuild reproduces the original
/// bytes, and the byte comparison fails.
#[test]
fn verify_wasm_fails_on_tampered_bytes() {
let sandbox = TestEnv::default();
let proj = init_project(&sandbox);
let (wasm, archive) = build_and_archive(&sandbox, &proj);

let mut bytes = std::fs::read(&wasm).unwrap();
let mid = bytes.len() / 2;
bytes[mid] = bytes[mid].wrapping_add(1);
let tampered = sandbox.dir().join("tampered.wasm");
std::fs::write(&tampered, &bytes).unwrap();

sandbox
.new_assert_cmd("contract")
.arg("verify")
.arg("--wasm")
.arg(&tampered)
.arg("--source-uri")
.arg(&archive)
.arg("--trust")
.assert()
.failure()
.stderr(predicate::str::contains("verification failed"));
}
4 changes: 2 additions & 2 deletions cmd/soroban-cli/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -111,7 +111,8 @@ bollard = { workspace = true }
futures-util = "0.3.30"
futures = "0.3.30"
home = "0.5.9"
flate2 = "1.0.30"
flate2 = { workspace = true }
tar = { workspace = true }
bytesize = "1.3.0"
humantime = "2.1.0"
phf = { version = "0.11.2", features = ["macros"] }
Expand All @@ -126,7 +127,6 @@ keyring = { version = "3", features = ["apple-native", "windows-native", "sync-s
whoami = "1.5.2"
serde_with = "3.11.0"
rustc_version = "0.4.1"
tar = "0.4.40"
walkdir = "2.5.0"

[build-dependencies]
Expand Down
11 changes: 4 additions & 7 deletions cmd/soroban-cli/src/commands/contract/build.rs
Original file line number Diff line number Diff line change
Expand Up @@ -122,13 +122,10 @@ pub struct Cmd {

/// SEP-58 source identification: URI where the source can be obtained, e.g.
/// `https://example.com/src.tar.gz` (recorded as the `source_uri` meta
/// entry). Optional; when set it must accompany `--source-sha256`.
#[arg(
long,
requires = "verifiable",
requires = "source_sha256",
help_heading = "Verifiable"
)]
/// entry). Optional with `--verifiable`; the recorded `source_sha256` is
/// computed from the generated archive, unless `--source-sha256` is
/// explicitly set.
#[arg(long, requires = "verifiable", help_heading = "Verifiable")]
pub source_uri: Option<String>,

/// Override the default docker host used by `--verifiable`.
Expand Down
35 changes: 35 additions & 0 deletions cmd/soroban-cli/src/commands/contract/build/source_archive.rs
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ use std::{

use walkdir::WalkDir;

use crate::config::{data, locator::enforce_hardened_tree};
use crate::print::Print;

/// Top-level names excluded when archiving a non-git working directory (we have
Expand Down Expand Up @@ -73,6 +74,9 @@ pub enum Error {

#[error("could not extract source archive: {0}")]
ArchiveExtract(std::io::Error),

#[error(transparent)]
Data(#[from] data::Error),
}

/// Pick the anchor for the source tree: the directory whose `.git` parent we
Expand Down Expand Up @@ -290,6 +294,37 @@ pub(crate) fn unpack_targz(bytes: &[u8], dest: &Path) -> Result<(), Error> {
.map_err(Error::ArchiveExtract)
}

/// Create a fresh temp directory, unpack the gzipped source tarball `bytes` into
/// it, harden its permissions, and return the guard (the tree lives at its
/// `path()`). Shared by `build --verifiable` (builds from the extracted copy)
/// and `verify` (rebuilds from it); `prefix` names the dir so the two are
/// distinguishable on disk.
///
/// The temp dir is created under `<data dir>/tmp`, NOT the OS temp dir: on macOS
/// `$TMPDIR` lives under /var/folders, which container VMs (Docker Desktop,
/// Colima, …) don't share by default, so a bind mount of it would be empty
/// inside the container. The data dir lives under the user's home, which is
/// shared. Corralling every extraction under a single `tmp/` keeps a leftover
/// from an interrupted run isolated in one obviously-disposable place rather
/// than loose alongside `archives/`.
pub(crate) fn extract_into_hardened_tempdir(
bytes: &[u8],
prefix: &str,
) -> Result<tempfile::TempDir, Error> {
let base = data::data_local_dir()?.join("tmp");
std::fs::create_dir_all(&base).map_err(|source| Error::ArchiveWrite {
path: base.clone(),
source,
})?;
let tmp = tempfile::Builder::new()
.prefix(prefix)
.tempdir_in(&base)
.map_err(Error::ArchiveExtract)?;
unpack_targz(bytes, tmp.path())?;
enforce_hardened_tree(tmp.path()).map_err(Error::ArchiveExtract)?;
Ok(tmp)
}

#[cfg(test)]
mod tests {
use super::*;
Expand Down
Loading
Loading