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
af66de9
Scaffold stellar contract verify with metadata extraction.
fnando May 21, 2026
00a5b48
Add trust gates to stellar contract verify.
fnando May 22, 2026
5fbfb8b
Materialize source for stellar contract verify.
fnando May 22, 2026
7ceb56b
Rebuild and byte-compare in stellar contract verify.
fnando May 22, 2026
7244114
Use direct --docker-host field on contract verify.
fnando May 22, 2026
34cb10a
Use question and warn emojis on trust prompt.
fnando May 22, 2026
6a6bde7
Respect --verbose and --quiet on contract verify.
fnando May 22, 2026
ccf2abc
Force trust prompts visible even under --quiet.
fnando May 22, 2026
275b92f
Capitalize Verified result line.
fnando May 22, 2026
f2bcc25
Capitalize info, warn, and check messages on contract verify.
fnando May 22, 2026
df28f67
Expand github:user/repo source_repo before git clone.
fnando May 22, 2026
bfbfe6b
Validate retrieval channel before trust prompts.
fnando May 22, 2026
c812e67
Switch git clone to gix instead of shelling out.
fnando May 22, 2026
7a5c332
Enable rustls TLS for gix HTTP transport.
fnando May 22, 2026
756d166
Promote gix to workspace dep.
fnando May 22, 2026
ef3a5cf
Anchor verify rebuilt-wasm search at manifest-path parent.
fnando May 22, 2026
19f663d
Add stellar contract verify integration tests.
fnando May 22, 2026
eea3f0c
Add tarball-sha256 + local --tarball-url verify test.
fnando May 22, 2026
ea26133
Move verify integration tests into integration tier.
fnando May 22, 2026
2f43fc9
Restrict permissions on materialized verify source.
fnando May 22, 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
1,385 changes: 1,275 additions & 110 deletions Cargo.lock

Large diffs are not rendered by default.

3 changes: 3 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,9 @@ hex = "0.4.3"
itertools = "0.10.0"
async-trait = "0.1.76"
bollard = "0.20.2"
gix = { version = "0.83.0", default-features = false, features = ["blocking-http-transport-reqwest-rust-tls", "worktree-mutation", "sha1"] }
tar = "0.4.46"
flate2 = "1.0.30"
serde-aux = "4.1.2"
serde_json = "1.0.82"
serde = "1.0.82"
Expand Down
26 changes: 26 additions & 0 deletions FULL_HELP_DOCS.md
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,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 @@ -1104,6 +1105,31 @@ 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
- `--tarball-url <TARBALL_URL>` — Local tarball file or http(s) URL to use as the source when the WASM's recorded SEP-58 metadata has only `tarball_sha256` (no `tarball_url`). 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
3 changes: 3 additions & 0 deletions cmd/crates/soroban-test/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,9 @@ tracing = "0.1.40"
tracing-subscriber = "0.3.18"
httpmock = { workspace = true }
reqwest = { workspace = true }
gix = { workspace = true }
tar = { workspace = true }
flate2 = { workspace = true }

[features]
default = []
Expand Down
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
mod fetch;
mod info_hash;
mod verify;
255 changes: 255 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,255 @@
//! End-to-end tests for `stellar contract verify`.
//!
//! These exercise the full pipeline: build a contract verifiably against a
//! pinned bldimg + pinned source_repo, then verify the resulting wasm matches.
//! The "happy path" tests require docker + network access to GitHub + 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 pins:
//! - 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.
//! - source_repo + source_rev: a specific commit on
//! `stellar/soroban-examples`. The `hello_world` contract there is the
//! smallest, most-stable example; we build just that with `--package`.

use gix::progress::Discard;
use predicates::prelude::{predicate, PredicateBooleanExt};
use sha2::{Digest, Sha256};
use soroban_test::TestEnv;
use std::path::{Path, PathBuf};
use std::sync::atomic::AtomicBool;

const PINNED_BLDIMG: &str =
"docker.io/fnando/stellar-cli-experimental@sha256:85e76eae8bf9f47ba94391214b76f8fa2b9d7b28171774dfafaf5b8d613a74d3";
const PINNED_SOURCE_REPO: &str = "github:stellar/soroban-examples";
const PINNED_SOURCE_REV: &str = "7b168174ae1268dab91a0190d80a94ab7ff41b59";
/// `soroban-examples` has no root `Cargo.toml` — each example is its own
/// crate in a subdirectory. The cli's source-root resolver anchors the
/// bind-mount + the recorded bldopt to the clone root, so the manifest-path
/// stays portable as `hello_world/Cargo.toml` regardless of where the user
/// invoked from.
const PINNED_MANIFEST_PATH: &str = "hello_world/Cargo.toml";

/// Build a verifiable wasm for the pinned hello-world example and write it to
/// `<sandbox>/out/soroban_hello_world_contract.wasm`. Returns the on-disk path.
fn build_verifiable_hello_world(sandbox: &TestEnv) -> 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("--source-repo")
.arg(PINNED_SOURCE_REPO)
.arg("--source-rev")
.arg(PINNED_SOURCE_REV)
.arg("--manifest-path")
.arg(PINNED_MANIFEST_PATH)
.arg("--out-dir")
.arg(&out_dir)
.current_dir(prepared_source_tree(sandbox))
.assert()
.success();
out_dir.join("soroban_hello_world_contract.wasm")
}

/// Materialize the pinned `stellar/soroban-examples` source tree at `<sandbox>/soroban-examples`
/// so the verifiable build has a workspace_root to bind-mount into the
/// container. The host's source tree is what the bldimg actually compiles;
/// `source_repo` + `source_rev` recorded into the wasm only tell a future
/// verifier where to fetch from. We clone via gix to stay shell-free.
fn prepared_source_tree(sandbox: &TestEnv) -> PathBuf {
let dir = sandbox.dir().join("soroban-examples");
if dir.exists() {
return dir;
}
// Mirror what the cli's `verify::clone_git_source` does — same gix call
// sequence, same flags — so the test exercises the production code path
// a third-party verifier would hit.
let interrupt = AtomicBool::new(false);
let mut prepare = gix::prepare_clone_bare("https://github.com/stellar/soroban-examples", &dir)
.expect("prepare_clone_bare");
let (repo, _) = prepare.fetch_only(Discard, &interrupt).expect("fetch_only");
let oid = gix::ObjectId::from_hex(PINNED_SOURCE_REV.as_bytes()).expect("rev hex");
let object = repo.find_object(oid).expect("find_object");
let commit = object.peel_to_commit().expect("peel_to_commit");
let tree_id = commit.tree_id().expect("tree_id");
let index = gix::index::State::from_tree(
&tree_id,
&repo.objects,
gix::validate::path::component::Options::default(),
)
.expect("from_tree");
let mut index_file = gix::index::File::from_state(index, dir.join(".git").join("index"));
gix::worktree::state::checkout(
&mut index_file,
&dir,
repo.objects.clone().into_arc().expect("into_arc"),
&Discard,
&Discard,
&interrupt,
gix::worktree::state::checkout::Options {
destination_is_initially_empty: true,
overwrite_existing: true,
..Default::default()
},
)
.expect("checkout");
dir
}

/// Happy path: build a verifiable wasm, then verify it from the local file.
/// Asserts the cli prints `Verified:` on stdout (or stderr; we accept either
/// via `predicates`).
#[test]
fn verify_wasm_succeeds_for_freshly_built_verifiable_wasm() {
let sandbox = TestEnv::default();
let wasm = build_verifiable_hello_world(&sandbox);

sandbox
.new_assert_cmd("contract")
.arg("verify")
.arg("--wasm")
.arg(&wasm)
.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 wasm = build_verifiable_hello_world(&sandbox);
let wasm_str = wasm.to_string_lossy().to_string();

// Upload (cheaper than full deploy; verify only needs the wasm bytes, which
// upload puts on-ledger under a known hash). `--id` accepts a contract id
// OR an alias OR (via wasm_hash) any thing the network can resolve to wasm.
// The deploy path is what gives us a contract id we can pass to --id.
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("--trust")
.assert()
.success()
.stderr(predicate::str::contains("Verified:"));
}

/// Build verifiable with `--tarball-sha256` (no `tarball_url` recorded), then
/// verify by handing the cli a local file path for `--tarball-url`. Exercises
/// the local-file branch of `fetch_tarball_bytes` and the SHA-256 check in
/// `verify_tarball_sha256`.
#[test]
fn verify_wasm_succeeds_via_tarball_sha256_with_local_url() {
let sandbox = TestEnv::default();
let source = prepared_source_tree(&sandbox);

// Pack the source tree into a gzipped tarball next to the sandbox and
// record its sha256 — that becomes the `--tarball-sha256` we hand to
// build, and the same file path we hand to verify as `--tarball-url`.
let tar_path = sandbox.dir().join("source.tar.gz");
pack_source_tarball(&source, &tar_path);
let sha = format!("{:x}", Sha256::digest(std::fs::read(&tar_path).unwrap()));

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("--tarball-sha256")
.arg(&sha)
.arg("--manifest-path")
.arg(PINNED_MANIFEST_PATH)
.arg("--out-dir")
.arg(&out_dir)
.current_dir(&source)
.assert()
.success();
let wasm = out_dir.join("soroban_hello_world_contract.wasm");

sandbox
.new_assert_cmd("contract")
.arg("verify")
.arg("--wasm")
.arg(&wasm)
.arg("--tarball-url")
.arg(&tar_path)
.arg("--trust")
.assert()
.success()
.stderr(predicate::str::contains("Verified:"));
}

fn pack_source_tarball(source: &Path, out: &Path) {
use flate2::write::GzEncoder;
use flate2::Compression;

let file = std::fs::File::create(out).expect("create tarball");
let mut gz = GzEncoder::new(file, Compression::default());
let mut builder = tar::Builder::new(&mut gz);
builder.follow_symlinks(false);
builder
.append_dir_all(".", source)
.expect("append source tree to tarball");
builder.finish().expect("finish tarball");
drop(builder);
gz.finish().expect("finish gzip");
}

/// Flip a byte in a verifiable wasm and confirm `contract verify` reports the
/// mismatch (different hashes).
#[test]
fn verify_wasm_fails_on_tampered_bytes() {
let sandbox = TestEnv::default();
let wasm = build_verifiable_hello_world(&sandbox);

// Tamper: corrupt a byte somewhere in the middle of the WASM. The custom
// section that holds contractmetav0 is near the end; flipping a code byte
// changes the bytes-under-comparison without invalidating the WASM enough
// to break the cli's metadata parse.
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("--trust")
.assert()
.failure()
.stderr(predicate::str::contains("verification failed"));
}
4 changes: 3 additions & 1 deletion cmd/soroban-cli/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -111,7 +111,9 @@ 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 }
gix = { workspace = true }
bytesize = "1.3.0"
humantime = "2.1.0"
phf = { version = "0.11.2", features = ["macros"] }
Expand Down
16 changes: 8 additions & 8 deletions cmd/soroban-cli/src/commands/contract/build/verifiable.rs
Original file line number Diff line number Diff line change
Expand Up @@ -337,24 +337,24 @@ fn cross_check_source_rev_against_git(workspace_root: &Path, claimed: &str) -> R
Ok(())
}

fn bldimg_regex() -> Regex {
pub(crate) fn bldimg_regex() -> Regex {
Regex::new(r"^(?:localhost(?::\d+)?|[^\s@/]*[.:][^\s@/]*)/[^\s@]+@sha256:[0-9a-f]{64}$")
.unwrap()
}

fn source_rev_regex() -> Regex {
pub(crate) fn source_rev_regex() -> Regex {
Regex::new(r"^[0-9a-f]{40}$").unwrap()
}

fn source_repo_regex() -> Regex {
pub(crate) fn source_repo_regex() -> Regex {
Regex::new(r"^(https?://\S+|github:[^/\s]+/[^/\s]+)$").unwrap()
}

fn tarball_url_regex() -> Regex {
pub(crate) fn tarball_url_regex() -> Regex {
Regex::new(r"^https?://\S+$").unwrap()
}

fn tarball_sha256_regex() -> Regex {
pub(crate) fn tarball_sha256_regex() -> Regex {
Regex::new(r"^[0-9a-f]{64}$").unwrap()
}

Expand Down Expand Up @@ -455,7 +455,7 @@ fn build_metadata_args(image_ref: &str, ids: &SourceIds, bldopts: &[String]) ->
out
}

fn compose_container_args(forwarded: &[String], metadata: &[String]) -> Vec<String> {
pub(crate) fn compose_container_args(forwarded: &[String], metadata: &[String]) -> Vec<String> {
let mut args = vec!["contract".to_string(), "build".to_string()];
args.extend_from_slice(forwarded);
args.extend_from_slice(metadata);
Expand Down Expand Up @@ -511,7 +511,7 @@ pub async fn resolve_image(cmd: &Cmd, docker: &Docker, print: &Print) -> Result<
Ok(digest)
}

async fn pull_image(
pub(crate) async fn pull_image(
docker: &Docker,
tag: &str,
print: &Print,
Expand Down Expand Up @@ -705,7 +705,7 @@ async fn probe_cli_version(image_ref: &str, docker: &Docker) -> Result<Version,
.map_err(|e| Error::TagListUnavailable(format!("unparseable version {stdout:?}: {e}")))
}

async fn run_in_container(
pub(crate) async fn run_in_container(
image_ref: &str,
workspace_root: &Path,
container_cmd: &[String],
Expand Down
Loading
Loading