From 6652ccf9e5c86946f6b7f26221e63896cf50e839 Mon Sep 17 00:00:00 2001 From: Nando Vieira Date: Fri, 8 May 2026 13:28:42 -0700 Subject: [PATCH] Add stellar contract info hash command. --- FULL_HELP_DOCS.md | 27 ++++ .../it/integration/contract/info_hash.rs | 121 ++++++++++++++++++ .../tests/it/integration/contract/mod.rs | 1 + cmd/soroban-cli/src/commands/contract/info.rs | 14 ++ .../src/commands/contract/info/hash.rs | 65 ++++++++++ cmd/soroban-cli/src/wasm.rs | 19 +++ 6 files changed, 247 insertions(+) create mode 100644 cmd/crates/soroban-test/tests/it/integration/contract/info_hash.rs create mode 100644 cmd/soroban-cli/src/commands/contract/info/hash.rs diff --git a/FULL_HELP_DOCS.md b/FULL_HELP_DOCS.md index 8f23f3b0f2..bee564d9c5 100644 --- a/FULL_HELP_DOCS.md +++ b/FULL_HELP_DOCS.md @@ -602,6 +602,7 @@ Access info about contracts - `meta` — Output the metadata stored in a contract - `env-meta` — Output the env required metadata stored in a contract - `build` — Output the contract build information, if available +- `hash` — Output the SHA-256 hash of a contract's Wasm ## `stellar contract info interface` @@ -742,6 +743,32 @@ If the contract has a meta entry like `source_repo=github:user/repo`, this comma - `--network-passphrase ` — Network passphrase to sign the transaction sent to the rpc server - `-n`, `--network ` — Name of network to use from config +## `stellar contract info hash` + +Output the SHA-256 hash of a contract's Wasm. + +The hash can be computed from a local .wasm file (`--wasm`) or read from a deployed contract (`--id`). The two flags are mutually exclusive. + +Stellar Asset Contracts have no Wasm and therefore no hash; using `--id` against a SAC will return an error. + +**Usage:** `stellar contract info hash [OPTIONS] <--wasm |--contract-id >` + +###### **Global Options:** + +- `--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:** + +- `--wasm ` — Path to a local .wasm file +- `--contract-id ` [alias: `id`] — Contract ID or alias of a deployed contract + +###### **RPC Options:** + +- `--rpc-url ` — RPC server endpoint +- `--rpc-header ` — 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 to sign the transaction sent to the rpc server +- `-n`, `--network ` — Name of network to use from config + ## `stellar contract init` Initialize a Soroban contract project. diff --git a/cmd/crates/soroban-test/tests/it/integration/contract/info_hash.rs b/cmd/crates/soroban-test/tests/it/integration/contract/info_hash.rs new file mode 100644 index 0000000000..a3a2874315 --- /dev/null +++ b/cmd/crates/soroban-test/tests/it/integration/contract/info_hash.rs @@ -0,0 +1,121 @@ +use crate::integration::util::{deploy_contract, test_address, DeployOptions, HELLO_WORLD}; + +use soroban_test::{AssertExt, TestEnv}; + +#[tokio::test] +async fn info_hash_with_wasm_file() { + let sandbox = &TestEnv::new(); + let expected = HELLO_WORLD.hash().unwrap().to_string(); + + let actual = sandbox + .new_assert_cmd("contract") + .arg("info") + .arg("hash") + .arg("--wasm") + .arg(HELLO_WORLD.path()) + .assert() + .success() + .stdout_as_str(); + + assert_eq!(actual, expected); +} + +#[tokio::test] +async fn info_hash_with_contract_id() { + let sandbox = &TestEnv::new(); + let expected = HELLO_WORLD.hash().unwrap().to_string(); + let contract_id = deploy_contract(sandbox, HELLO_WORLD, DeployOptions::default()).await; + + let actual = sandbox + .new_assert_cmd("contract") + .arg("info") + .arg("hash") + .arg("--id") + .arg(&contract_id) + .assert() + .success() + .stdout_as_str(); + + assert_eq!(actual, expected); +} + +#[tokio::test] +async fn info_hash_with_contract_alias() { + let sandbox = &TestEnv::new(); + let expected = HELLO_WORLD.hash().unwrap().to_string(); + let contract_id = deploy_contract(sandbox, HELLO_WORLD, DeployOptions::default()).await; + + sandbox + .new_assert_cmd("contract") + .arg("alias") + .arg("add") + .arg("hello") + .arg("--id") + .arg(&contract_id) + .assert() + .success(); + + let actual = sandbox + .new_assert_cmd("contract") + .arg("info") + .arg("hash") + .arg("--id") + .arg("hello") + .assert() + .success() + .stdout_as_str(); + + assert_eq!(actual, expected); +} + +#[tokio::test] +async fn info_hash_errors_on_stellar_asset_contract() { + let sandbox = &TestEnv::new(); + let issuer = test_address(sandbox); + let sac_id = sandbox + .new_assert_cmd("contract") + .arg("asset") + .arg("deploy") + .arg(format!("--asset=USDC:{issuer}")) + .assert() + .success() + .stdout_as_str(); + + sandbox + .new_assert_cmd("contract") + .arg("info") + .arg("hash") + .arg("--id") + .arg(&sac_id) + .assert() + .failure(); +} + +#[tokio::test] +async fn info_hash_requires_one_source() { + let sandbox = &TestEnv::new(); + + sandbox + .new_assert_cmd("contract") + .arg("info") + .arg("hash") + .assert() + .failure(); +} + +#[tokio::test] +async fn info_hash_wasm_and_id_are_mutually_exclusive() { + let sandbox = &TestEnv::new(); + let contract_id = deploy_contract(sandbox, HELLO_WORLD, DeployOptions::default()).await; + + sandbox + .new_assert_cmd("contract") + .arg("info") + .arg("hash") + .arg("--wasm") + .arg(HELLO_WORLD.path()) + .arg("--id") + .arg(&contract_id) + .assert() + .failure(); +} diff --git a/cmd/crates/soroban-test/tests/it/integration/contract/mod.rs b/cmd/crates/soroban-test/tests/it/integration/contract/mod.rs index e29e6a333c..5e5f4b7a00 100644 --- a/cmd/crates/soroban-test/tests/it/integration/contract/mod.rs +++ b/cmd/crates/soroban-test/tests/it/integration/contract/mod.rs @@ -1 +1,2 @@ mod fetch; +mod info_hash; diff --git a/cmd/soroban-cli/src/commands/contract/info.rs b/cmd/soroban-cli/src/commands/contract/info.rs index 09a5ade9a8..54b8ba984b 100644 --- a/cmd/soroban-cli/src/commands/contract/info.rs +++ b/cmd/soroban-cli/src/commands/contract/info.rs @@ -4,6 +4,7 @@ use crate::commands::global; pub mod build; pub mod env_meta; +pub mod hash; pub mod interface; pub mod meta; pub mod shared; @@ -56,6 +57,15 @@ pub enum Cmd { /// If the contract has a meta entry like `source_repo=github:user/repo`, this command will try /// to fetch the attestation information for the WASM file. Build(build::Cmd), + + /// Output the SHA-256 hash of a contract's Wasm. + /// + /// The hash can be computed from a local .wasm file (`--wasm`) or read from a deployed + /// contract (`--id`). The two flags are mutually exclusive. + /// + /// Stellar Asset Contracts have no Wasm and therefore no hash; using `--id` against a + /// SAC will return an error. + Hash(hash::Cmd), } #[derive(thiserror::Error, Debug)] @@ -71,6 +81,9 @@ pub enum Error { #[error(transparent)] Build(#[from] build::Error), + + #[error(transparent)] + Hash(#[from] hash::Error), } impl Cmd { @@ -80,6 +93,7 @@ impl Cmd { Cmd::Meta(meta) => meta.run(global_args).await?, Cmd::EnvMeta(env_meta) => env_meta.run(global_args).await?, Cmd::Build(build) => build.run(global_args).await?, + Cmd::Hash(hash) => hash.run(global_args).await?, } Ok(()) diff --git a/cmd/soroban-cli/src/commands/contract/info/hash.rs b/cmd/soroban-cli/src/commands/contract/info/hash.rs new file mode 100644 index 0000000000..aaa57f0a2e --- /dev/null +++ b/cmd/soroban-cli/src/commands/contract/info/hash.rs @@ -0,0 +1,65 @@ +use std::path::PathBuf; + +use clap::Parser; + +use crate::{ + commands::global, + config::{ + self, locator, + network::{self}, + }, + wasm, +}; + +#[derive(Parser, Debug, Clone)] +#[command(group( + clap::ArgGroup::new("source") + .required(true) + .args(&["wasm", "contract_id"]), +))] +#[group(skip)] +pub struct Cmd { + /// Path to a local .wasm file. + #[arg(long, conflicts_with = "contract_id")] + pub wasm: Option, + /// Contract ID or alias of a deployed contract. + #[arg( + long, + visible_alias = "id", + env = "STELLAR_CONTRACT_ID", + conflicts_with = "wasm" + )] + pub contract_id: Option, + #[command(flatten)] + pub network: network::Args, + #[command(flatten)] + pub locator: locator::Args, +} + +#[derive(thiserror::Error, Debug)] +pub enum Error { + #[error(transparent)] + Wasm(#[from] wasm::Error), + #[error(transparent)] + Network(#[from] network::Error), + #[error(transparent)] + Locator(#[from] locator::Error), +} + +impl Cmd { + pub async fn run(&self, _global_args: &global::Args) -> Result<(), Error> { + let hash = if let Some(path) = &self.wasm { + wasm::Args { wasm: path.clone() }.hash()? + } else if let Some(contract_id) = &self.contract_id { + let network = self.network.get(&self.locator)?; + let resolved = + contract_id.resolve_contract_id(&self.locator, &network.network_passphrase)?; + wasm::fetch_wasm_hash_from_contract(&resolved, &network).await? + } else { + unreachable!("clap ArgGroup guarantees one of --wasm or --contract-id is set"); + }; + + println!("{hash}"); + Ok(()) + } +} diff --git a/cmd/soroban-cli/src/wasm.rs b/cmd/soroban-cli/src/wasm.rs index 1a10976770..591edc8b09 100644 --- a/cmd/soroban-cli/src/wasm.rs +++ b/cmd/soroban-cli/src/wasm.rs @@ -143,3 +143,22 @@ pub async fn fetch_from_wasm_hash(hash: Hash, network: &Network) -> Result Result { + tracing::trace!(?network); + let client = network.rpc_client()?; + client + .verify_network_passphrase(Some(&network.network_passphrase)) + .await?; + let data_entry = client.get_contract_data(contract_id).await?; + if let ScVal::ContractInstance(contract) = &data_entry.val { + return match &contract.executable { + ContractExecutable::Wasm(hash) => Ok(hash.clone()), + ContractExecutable::StellarAsset => Err(ContractIsStellarAsset), + }; + } + Err(UnexpectedContractToken(Box::new(data_entry))) +}