diff --git a/FULL_HELP_DOCS.md b/FULL_HELP_DOCS.md index 059bd90f73..79ec1a364a 100644 --- a/FULL_HELP_DOCS.md +++ b/FULL_HELP_DOCS.md @@ -458,15 +458,23 @@ If no keys are specified the contract itself is extended. Deploy a wasm contract -**Usage:** `stellar contract deploy [OPTIONS] --source-account <--wasm |--wasm-hash > [-- ...]` +**Usage:** `stellar contract deploy [OPTIONS] --source-account [-- ...]` ###### **Arguments:** - `` — If provided, will be passed to the contract's `__constructor` function with provided arguments for that function as `--arg-name value` +###### **Build Options:** + +- `--package ` — Package to build when auto-building without --wasm + +###### **Metadata:** + +- `--meta ` — Add key-value to contract meta (adds the meta to the `contractmetav0` custom section) + ###### **Options:** -- `--wasm ` — WASM file to deploy +- `--wasm ` — WASM file to deploy. When neither --wasm nor --wasm-hash is provided inside a Cargo workspace, builds the project automatically. One of --wasm or --wasm-hash is required when outside a Cargo workspace - `--wasm-hash ` — Hash of the already installed/deployed WASM file - `--salt ` — Custom salt 32-byte salt for the token id - `-s`, `--source-account ` [alias: `source`] — Account that where transaction originates from. Alias `source`. Can be an identity (--source alice), a public key (--source GDKW...), a muxed account (--source MDA…), a secret key (--source SC36…), or a seed phrase (--source "kite urban…"). If `--build-only` was NOT provided, this key will also be used to sign the final transaction. In that case, trying to sign with public key will fail @@ -482,6 +490,7 @@ Deploy a wasm contract - `--alias ` — The alias that will be used to save the contract's id. Whenever used, `--alias` will always overwrite the existing contract id configuration without asking for confirmation - `--build-only` — Build the transaction and only write the base64 xdr to stdout +- `--optimize` — Optimize the generated wasm ###### **Options (Global):** @@ -788,7 +797,15 @@ This command will create a Cargo workspace project and add a sample Stellar cont Install a WASM file to the ledger without creating a contract instance -**Usage:** `stellar contract upload [OPTIONS] --source-account --wasm ` +**Usage:** `stellar contract upload [OPTIONS] --source-account ` + +###### **Build Options:** + +- `--package ` — Package to build when --wasm is not provided + +###### **Metadata:** + +- `--meta ` — Add key-value to contract meta (adds the meta to the `contractmetav0` custom section) ###### **Options:** @@ -799,12 +816,13 @@ Install a WASM file to the ledger without creating a contract instance - `--sign-with-ledger` — Sign with a ledger wallet - `--fee ` — ⚠️ Deprecated, use `--inclusion-fee`. Fee amount for transaction, in stroops. 1 stroop = 0.0000001 xlm - `--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 -- `--wasm ` — Path to wasm binary +- `--wasm ` — Path to wasm binary. When omitted inside a Cargo workspace, builds the project automatically. Required when outside a Cargo workspace - `-i`, `--ignore-checks` — Whether to ignore safety checks when deploying contracts Default value: `false` - `--build-only` — Build the transaction and only write the base64 xdr to stdout +- `--optimize` — Optimize the generated wasm ###### **Options (Global):** @@ -826,7 +844,15 @@ Install a WASM file to the ledger without creating a contract instance ⚠️ Deprecated, use `contract upload`. Install a WASM file to the ledger without creating a contract instance -**Usage:** `stellar contract install [OPTIONS] --source-account --wasm ` +**Usage:** `stellar contract install [OPTIONS] --source-account ` + +###### **Build Options:** + +- `--package ` — Package to build when --wasm is not provided + +###### **Metadata:** + +- `--meta ` — Add key-value to contract meta (adds the meta to the `contractmetav0` custom section) ###### **Options:** @@ -837,12 +863,13 @@ Install a WASM file to the ledger without creating a contract instance - `--sign-with-ledger` — Sign with a ledger wallet - `--fee ` — ⚠️ Deprecated, use `--inclusion-fee`. Fee amount for transaction, in stroops. 1 stroop = 0.0000001 xlm - `--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 -- `--wasm ` — Path to wasm binary +- `--wasm ` — Path to wasm binary. When omitted inside a Cargo workspace, builds the project automatically. Required when outside a Cargo workspace - `-i`, `--ignore-checks` — Whether to ignore safety checks when deploying contracts Default value: `false` - `--build-only` — Build the transaction and only write the base64 xdr to stdout +- `--optimize` — Optimize the generated wasm ###### **Options (Global):** diff --git a/cmd/crates/soroban-test/tests/it/integration.rs b/cmd/crates/soroban-test/tests/it/integration.rs index 386eca2665..8e5cf0b9fb 100644 --- a/cmd/crates/soroban-test/tests/it/integration.rs +++ b/cmd/crates/soroban-test/tests/it/integration.rs @@ -1,3 +1,4 @@ +mod auto_build; mod bindings; mod constructor; mod contract; diff --git a/cmd/crates/soroban-test/tests/it/integration/auto_build.rs b/cmd/crates/soroban-test/tests/it/integration/auto_build.rs new file mode 100644 index 0000000000..79820a1a0b --- /dev/null +++ b/cmd/crates/soroban-test/tests/it/integration/auto_build.rs @@ -0,0 +1,249 @@ +use soroban_test::{AssertExt, TestEnv}; +use std::path::PathBuf; + +#[tokio::test] +async fn deploy_without_wasm_auto_builds() { + let sandbox = TestEnv::new(); + let cargo_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")); + let fixture_path = cargo_dir.join("tests/fixtures/test-wasms/hello_world"); + + // Deploy without --wasm flag should auto-build the contract + let contract_id = sandbox + .new_assert_cmd("contract") + .current_dir(&fixture_path) + .arg("deploy") + .arg("--source-account") + .arg("test") + .assert() + .success() + .stdout_as_str(); + + // Verify contract was deployed by invoking a function + sandbox + .invoke_with_test(&["--id", &contract_id, "--", "hello", "--world=world"]) + .await + .unwrap(); +} + +#[tokio::test] +async fn deploy_workspace_without_package_builds_all() { + let sandbox = TestEnv::new(); + let cargo_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")); + let fixture_path = cargo_dir.join("tests/fixtures/workspace"); + + // Deploy in workspace without --wasm and without --package builds and deploys all contracts + sandbox + .new_assert_cmd("contract") + .current_dir(&fixture_path) + .arg("deploy") + .arg("--source-account") + .arg("test") + .assert() + .success() + .stderr(predicates::str::contains("Build Complete")); +} + +#[tokio::test] +async fn deploy_workspace_with_package_auto_builds() { + let sandbox = TestEnv::new(); + let cargo_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")); + let fixture_path = cargo_dir.join("tests/fixtures/workspace"); + + // Deploy in workspace with --package should auto-build the specified contract + let contract_id = sandbox + .new_assert_cmd("contract") + .current_dir(&fixture_path) + .arg("deploy") + .arg("--source-account") + .arg("test") + .arg("--package") + .arg("add") + .assert() + .success() + .stdout_as_str(); + + // Verify contract was deployed + assert!(!contract_id.is_empty(), "Expected contract ID"); +} + +#[tokio::test] +async fn upload_without_wasm_auto_builds() { + let sandbox = TestEnv::new(); + let cargo_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")); + let fixture_path = cargo_dir.join("tests/fixtures/test-wasms/hello_world"); + + // Upload without --wasm flag should auto-build the contract + let wasm_hash = sandbox + .new_assert_cmd("contract") + .current_dir(&fixture_path) + .arg("upload") + .arg("--source-account") + .arg("test") + .assert() + .success() + .stdout_as_str(); + + // Verify a hash was returned + assert_eq!(wasm_hash.len(), 64, "Expected 64-character hex hash"); +} + +#[tokio::test] +async fn upload_workspace_without_package_builds_all() { + let sandbox = TestEnv::new(); + let cargo_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")); + let fixture_path = cargo_dir.join("tests/fixtures/workspace"); + + // Upload in workspace without --wasm and without --package builds and uploads all contracts + sandbox + .new_assert_cmd("contract") + .current_dir(&fixture_path) + .arg("upload") + .arg("--source-account") + .arg("test") + .assert() + .success() + .stderr(predicates::str::contains("Build Complete")); +} + +#[tokio::test] +async fn upload_workspace_with_package_auto_builds() { + let sandbox = TestEnv::new(); + let cargo_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")); + let fixture_path = cargo_dir.join("tests/fixtures/workspace"); + + // Upload in workspace with --package should auto-build the specified contract + let wasm_hash = sandbox + .new_assert_cmd("contract") + .current_dir(&fixture_path) + .arg("upload") + .arg("--source-account") + .arg("test") + .arg("--package") + .arg("call") + .assert() + .success() + .stdout_as_str(); + + // Verify a hash was returned + assert_eq!(wasm_hash.len(), 64, "Expected 64-character hex hash"); +} + +#[tokio::test] +async fn deploy_outside_cargo_project_requires_wasm() { + let sandbox = TestEnv::new(); + + // Deploy outside a Cargo project without --wasm should fail + sandbox + .new_assert_cmd("contract") + .arg("deploy") + .arg("--source-account") + .arg("test") + .assert() + .failure() + .stderr(predicates::str::contains( + "--wasm or --wasm-hash is required when not in a Cargo workspace", + )); +} + +#[tokio::test] +async fn upload_outside_cargo_project_requires_wasm() { + let sandbox = TestEnv::new(); + + // Upload outside a Cargo project without --wasm should fail + sandbox + .new_assert_cmd("contract") + .arg("upload") + .arg("--source-account") + .arg("test") + .assert() + .failure() + .stderr(predicates::str::contains( + "--wasm is required when not in a Cargo workspace", + )); +} + +#[tokio::test] +async fn deploy_build_only_rejected_without_wasm() { + let sandbox = TestEnv::new(); + let cargo_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")); + let fixture_path = cargo_dir.join("tests/fixtures/test-wasms/hello_world"); + + // --build-only should fail when auto-building (no --wasm or --wasm-hash) + sandbox + .new_assert_cmd("contract") + .current_dir(&fixture_path) + .arg("deploy") + .arg("--source-account") + .arg("test") + .arg("--build-only") + .assert() + .failure() + .stderr(predicates::str::contains( + "--build-only is not supported without --wasm or --wasm-hash", + )); +} + +#[tokio::test] +async fn upload_build_only_rejected_without_wasm() { + let sandbox = TestEnv::new(); + let cargo_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")); + let fixture_path = cargo_dir.join("tests/fixtures/test-wasms/hello_world"); + + // --build-only should fail when auto-building (no --wasm) + sandbox + .new_assert_cmd("contract") + .current_dir(&fixture_path) + .arg("upload") + .arg("--source-account") + .arg("test") + .arg("--build-only") + .assert() + .failure() + .stderr(predicates::str::contains( + "--build-only is not supported without --wasm", + )); +} + +#[tokio::test] +async fn deploy_auto_build_with_meta() { + let sandbox = TestEnv::new(); + let cargo_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")); + let fixture_path = cargo_dir.join("tests/fixtures/test-wasms/hello_world"); + + // Deploy with --meta should pass metadata through to build + let contract_id = sandbox + .new_assert_cmd("contract") + .current_dir(&fixture_path) + .arg("deploy") + .arg("--source-account") + .arg("test") + .arg("--meta") + .arg("key=value") + .assert() + .success() + .stdout_as_str(); + + assert!(!contract_id.is_empty(), "Expected contract ID"); +} + +#[tokio::test] +async fn upload_auto_build_with_meta() { + let sandbox = TestEnv::new(); + let cargo_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")); + let fixture_path = cargo_dir.join("tests/fixtures/test-wasms/hello_world"); + + // Upload with --meta should pass metadata through to build + let wasm_hash = sandbox + .new_assert_cmd("contract") + .current_dir(&fixture_path) + .arg("upload") + .arg("--source-account") + .arg("test") + .arg("--meta") + .arg("key=value") + .assert() + .success() + .stdout_as_str(); + + assert_eq!(wasm_hash.len(), 64, "Expected 64-character hex hash"); +} diff --git a/cmd/soroban-cli/src/commands/contract/build.rs b/cmd/soroban-cli/src/commands/contract/build.rs index 56f2fd730e..e588258e3e 100644 --- a/cmd/soroban-cli/src/commands/contract/build.rs +++ b/cmd/soroban-cli/src/commands/contract/build.rs @@ -25,6 +25,15 @@ use crate::{ wasm, }; +/// A built WASM artifact with its package name and file path. +#[derive(Debug, Clone)] +pub struct BuiltContract { + /// The Cargo package name (e.g. "my-contract"). + pub name: String, + /// The path to the built WASM file. + pub path: PathBuf, +} + /// Build a contract from source /// /// Builds all crates that are referenced by the cargo manifest (Cargo.toml) @@ -83,6 +92,13 @@ pub struct Cmd { #[arg(long, conflicts_with = "out_dir", help_heading = "Other")] pub print_commands_only: bool, + #[command(flatten)] + pub build_args: BuildArgs, +} + +/// Shared build options for meta and optimization, reused by deploy and upload. +#[derive(Parser, Debug, Clone, Default)] +pub struct BuildArgs { /// Add key-value to contract meta (adds the meta to the `contractmetav0` custom section) #[arg(long, num_args=1, value_parser=parse_meta_arg, action=clap::ArgAction::Append, help_heading = "Metadata")] pub meta: Vec<(String, String)>, @@ -93,7 +109,7 @@ pub struct Cmd { pub optimize: bool, } -fn parse_meta_arg(s: &str) -> Result<(String, String), Error> { +pub fn parse_meta_arg(s: &str) -> Result<(String, String), Error> { let parts = s.splitn(2, '='); let (key, value) = parts @@ -171,9 +187,26 @@ const WASM_TARGET: &str = "wasm32v1-none"; const WASM_TARGET_OLD: &str = "wasm32-unknown-unknown"; const META_CUSTOM_SECTION_NAME: &str = "contractmetav0"; +impl Default for Cmd { + fn default() -> Self { + Self { + manifest_path: None, + package: None, + profile: "release".to_string(), + features: None, + all_features: false, + no_default_features: false, + out_dir: None, + print_commands_only: false, + build_args: BuildArgs::default(), + } + } +} + impl Cmd { + /// Builds the project and returns the built WASM artifacts. #[allow(clippy::too_many_lines)] - pub fn run(&self, global_args: &global::Args) -> Result<(), Error> { + pub fn run(&self, global_args: &global::Args) -> Result, Error> { let print = Print::new(global_args.quiet); let working_dir = env::current_dir().map_err(Error::GettingCurrentDir)?; let metadata = self.metadata()?; @@ -194,6 +227,7 @@ impl Cmd { } let wasm_target = get_wasm_target()?; + let mut built_contracts = Vec::new(); for p in packages { let mut cmd = Command::new("cargo"); @@ -279,7 +313,7 @@ impl Cmd { let mut optimized_wasm_bytes: Vec = Vec::new(); #[cfg(feature = "additional-libs")] - if self.optimize { + if self.build_args.optimize { let mut path = final_path.clone(); path.set_extension("optimized.wasm"); optimize::optimize(true, vec![final_path.clone()], Some(path.clone()))?; @@ -290,15 +324,19 @@ impl Cmd { } #[cfg(not(feature = "additional-libs"))] - if self.optimize { + if self.build_args.optimize { return Err(Error::OptimizeFeatureNotEnabled); } Self::print_build_summary(&print, &final_path, wasm_bytes, optimized_wasm_bytes); + built_contracts.push(BuiltContract { + name: p.name.clone(), + path: final_path, + }); } } - Ok(()) + Ok(built_contracts) } fn features(&self) -> Option> { @@ -390,7 +428,7 @@ impl Cmd { new_meta.push(cli_meta_entry); // Add args provided meta - for (k, v) in self.meta.clone() { + for (k, v) in self.build_args.meta.clone() { let key: StringM = k .clone() .try_into() diff --git a/cmd/soroban-cli/src/commands/contract/deploy/wasm.rs b/cmd/soroban-cli/src/commands/contract/deploy/wasm.rs index bf094d71aa..7bab1a3bde 100644 --- a/cmd/soroban-cli/src/commands/contract/deploy/wasm.rs +++ b/cmd/soroban-cli/src/commands/contract/deploy/wasm.rs @@ -1,10 +1,14 @@ -use crate::commands::contract::deploy::utils::alias_validator; -use crate::resources; use std::array::TryFromSliceError; use std::ffi::OsString; use std::fmt::Debug; use std::num::ParseIntError; +use clap::Parser; +use rand::Rng; +use soroban_spec_tools::contract as contract_spec; + +use crate::commands::contract::deploy::utils::alias_validator; +use crate::resources; use crate::xdr::{ AccountId, ContractExecutable, ContractIdPreimage, ContractIdPreimageFromAddress, CreateContractArgs, CreateContractArgsV2, Error as XdrError, Hash, HostFunction, @@ -12,14 +16,12 @@ use crate::xdr::{ Preconditions, PublicKey, ScAddress, SequenceNumber, Transaction, TransactionExt, Uint256, VecM, WriteXdr, }; -use clap::Parser; -use rand::Rng; use crate::commands::tx::fetch; use crate::{ assembled::simulate_and_assemble_transaction, commands::{ - contract::{self, arg_parsing, id::wasm::get_contract_id, upload}, + contract::{self, arg_parsing, build, id::wasm::get_contract_id, upload}, global, txn_result::{TxnEnvelopeResult, TxnResult}, }, @@ -29,19 +31,20 @@ use crate::{ utils::{self, rpc::get_remote_wasm_from_hash}, wasm, }; -use soroban_spec_tools::contract as contract_spec; pub const CONSTRUCTOR_FUNCTION_NAME: &str = "__constructor"; #[derive(Parser, Debug, Clone)] #[command(group( clap::ArgGroup::new("wasm_src") - .required(true) + .required(false) .args(&["wasm", "wasm_hash"]), ))] #[group(skip)] pub struct Cmd { - /// WASM file to deploy + /// WASM file to deploy. When neither --wasm nor --wasm-hash is provided + /// inside a Cargo workspace, builds the project automatically. One of + /// --wasm or --wasm-hash is required when outside a Cargo workspace. #[arg(long, group = "wasm_src")] pub wasm: Option, /// Hash of the already installed/deployed WASM file @@ -68,6 +71,11 @@ pub struct Cmd { /// If provided, will be passed to the contract's `__constructor` function with provided arguments for that function as `--arg-name value` #[arg(last = true, id = "CONTRACT_CONSTRUCTOR_ARGS")] pub slop: Vec, + /// Package to build when auto-building without --wasm + #[arg(long, help_heading = "Build Options", conflicts_with = "wasm_src")] + pub package: Option, + #[command(flatten)] + pub build_args: build::BuildArgs, } #[derive(thiserror::Error, Debug)] @@ -102,7 +110,7 @@ pub enum Error { error: stellar_strkey::DecodeError, }, - #[error("Must provide either --wasm or --wash-hash")] + #[error("Must provide either --wasm or --wasm-hash")] WasmNotProvided, #[error(transparent)] @@ -146,21 +154,89 @@ pub enum Error { #[error(transparent)] Fetch(#[from] fetch::Error), + + #[error(transparent)] + Build(#[from] build::Error), + + #[error("no buildable contracts found in workspace (no packages with crate-type cdylib)")] + NoBuildableContracts, + + #[error("--alias is not supported when deploying multiple contracts; aliases are derived from package names automatically")] + AliasNotSupported, + + #[error("--salt is not supported when deploying multiple contracts")] + SaltNotSupported, + + #[error("constructor arguments are not supported when deploying multiple contracts")] + ConstructorArgsNotSupported, + + #[error("--build-only is not supported without --wasm or --wasm-hash")] + BuildOnlyNotSupported, + + #[error( + "--wasm or --wasm-hash is required when not in a Cargo workspace; no Cargo.toml found" + )] + NotInCargoProject, } impl Cmd { pub async fn run(&self, global_args: &global::Args) -> Result<(), Error> { - let res = self - .execute(&self.config, global_args.quiet, global_args.no_cache) + if self.build_only && self.wasm.is_none() && self.wasm_hash.is_none() { + return Err(Error::BuildOnlyNotSupported); + } + + let built_contracts = self.resolve_contracts(global_args)?; + + // When --wasm-hash is used, no built contracts are returned. + // Deploy directly with the hash. + if built_contracts.is_empty() { + Self::run_single(self, global_args).await?; + } else { + if built_contracts.len() > 1 { + if self.alias.is_some() { + return Err(Error::AliasNotSupported); + } + + if self.salt.is_some() { + return Err(Error::SaltNotSupported); + } + + if !self.slop.is_empty() { + return Err(Error::ConstructorArgsNotSupported); + } + } + + for contract in &built_contracts { + let mut cmd = self.clone(); + cmd.wasm = Some(contract.path.clone()); + + // When auto-building and no explicit --alias, use the + // package name as alias. + if cmd.alias.is_none() && !contract.name.is_empty() { + cmd.alias = Some(contract.name.clone()); + } + + Self::run_single(&cmd, global_args).await?; + } + } + Ok(()) + } + + async fn run_single(cmd: &Cmd, global_args: &global::Args) -> Result<(), Error> { + let res = cmd + .execute(&cmd.config, global_args.quiet, global_args.no_cache) .await? .to_envelope(); + match res { - TxnEnvelopeResult::TxnEnvelope(tx) => println!("{}", tx.to_xdr_base64(Limits::none())?), + TxnEnvelopeResult::TxnEnvelope(tx) => { + println!("{}", tx.to_xdr_base64(Limits::none())?); + } TxnEnvelopeResult::Res(contract) => { - let network = self.config.get_network()?; + let network = cmd.config.get_network()?; - if let Some(alias) = self.alias.clone() { - if let Some(existing_contract) = self + if let Some(alias) = cmd.alias.clone() { + if let Some(existing_contract) = cmd .config .locator .get_contract_id(&alias, &network.network_passphrase)? @@ -171,7 +247,7 @@ impl Cmd { )); } - self.config.locator.save_contract_id( + cmd.config.locator.save_contract_id( &network.network_passphrase, &contract, &alias, @@ -184,6 +260,41 @@ impl Cmd { Ok(()) } + fn resolve_contracts( + &self, + global_args: &global::Args, + ) -> Result, Error> { + // If --wasm is explicitly provided, use it (no package name available) + if let Some(wasm) = &self.wasm { + return Ok(vec![build::BuiltContract { + name: String::new(), + path: wasm.clone(), + }]); + } + + // If --wasm-hash is provided, no WASM file paths needed + if self.wasm_hash.is_some() { + return Ok(vec![]); + } + + // Neither provided: auto-build + let build_cmd = build::Cmd { + package: self.package.clone(), + build_args: self.build_args.clone(), + ..build::Cmd::default() + }; + let contracts = build_cmd.run(global_args).map_err(|e| match e { + build::Error::Metadata(_) => Error::NotInCargoProject, + other => other.into(), + })?; + + if contracts.is_empty() { + return Err(Error::NoBuildableContracts); + } + + Ok(contracts) + } + #[allow(clippy::too_many_lines)] #[allow(unused_variables)] pub async fn execute( @@ -199,11 +310,13 @@ impl Cmd { wasm::Args { wasm: wasm.clone() }.hash()? } else { upload::Cmd { - wasm: wasm::Args { wasm: wasm.clone() }, + wasm: Some(wasm.clone()), config: config.clone(), resources: self.resources.clone(), ignore_checks: self.ignore_checks, build_only: is_build, + package: None, + build_args: build::BuildArgs::default(), } .execute(config, quiet, no_cache) .await? diff --git a/cmd/soroban-cli/src/commands/contract/mod.rs b/cmd/soroban-cli/src/commands/contract/mod.rs index 1517158b29..bc9cc80066 100644 --- a/cmd/soroban-cli/src/commands/contract/mod.rs +++ b/cmd/soroban-cli/src/commands/contract/mod.rs @@ -151,7 +151,9 @@ impl Cmd { match &self { Cmd::Asset(asset) => asset.run(global_args).await?, Cmd::Bindings(bindings) => bindings.run().await?, - Cmd::Build(build) => build.run(global_args)?, + Cmd::Build(build) => { + build.run(global_args)?; + } Cmd::Extend(extend) => extend.run(global_args).await?, Cmd::Alias(alias) => alias.run(global_args)?, Cmd::Deploy(deploy) => deploy.run(global_args).await?, diff --git a/cmd/soroban-cli/src/commands/contract/upload.rs b/cmd/soroban-cli/src/commands/contract/upload.rs index 8c584c8d78..cb5a8e5b4f 100644 --- a/cmd/soroban-cli/src/commands/contract/upload.rs +++ b/cmd/soroban-cli/src/commands/contract/upload.rs @@ -1,6 +1,7 @@ use std::array::TryFromSliceError; use std::fmt::Debug; use std::num::ParseIntError; +use std::path::{Path, PathBuf}; use crate::xdr::{ self, ContractCodeEntryExt, Error as XdrError, Hash, HostFunction, InvokeHostFunctionOp, @@ -9,7 +10,7 @@ use crate::xdr::{ }; use clap::Parser; -use super::restore; +use super::{build, restore}; use crate::commands::tx::fetch; use crate::{ assembled::simulate_and_assemble_transaction, @@ -37,8 +38,10 @@ pub struct Cmd { #[command(flatten)] pub resources: crate::resources::Args, - #[command(flatten)] - pub wasm: wasm::Args, + /// Path to wasm binary. When omitted inside a Cargo workspace, builds the + /// project automatically. Required when outside a Cargo workspace. + #[arg(long)] + pub wasm: Option, #[arg(long, short = 'i', default_value = "false")] /// Whether to ignore safety checks when deploying contracts @@ -47,6 +50,12 @@ pub struct Cmd { /// Build the transaction and only write the base64 xdr to stdout #[arg(long)] pub build_only: bool, + + /// Package to build when --wasm is not provided + #[arg(long, help_heading = "Build Options", conflicts_with = "wasm")] + pub package: Option, + #[command(flatten)] + pub build_args: build::BuildArgs, } #[derive(thiserror::Error, Debug)] @@ -104,21 +113,54 @@ pub enum Error { #[error(transparent)] Fetch(#[from] fetch::Error), + + #[error(transparent)] + Build(#[from] build::Error), + + #[error("no buildable contracts found in workspace (no packages with crate-type cdylib)")] + NoBuildableContracts, + + #[error("no WASM file specified; use --wasm to provide a contract file")] + WasmNotProvided, + + #[error("--build-only is not supported without --wasm")] + BuildOnlyNotSupported, + + #[error("--wasm is required when not in a Cargo workspace; no Cargo.toml found")] + NotInCargoProject, } impl Cmd { pub async fn run(&self, global_args: &global::Args) -> Result<(), Error> { - let res = self - .execute(&self.config, global_args.quiet, global_args.no_cache) - .await? - .to_envelope(); - match res { - TxnEnvelopeResult::TxnEnvelope(tx) => println!("{}", tx.to_xdr_base64(Limits::none())?), - TxnEnvelopeResult::Res(hash) => println!("{}", hex::encode(hash)), + if self.build_only && self.wasm.is_none() { + return Err(Error::BuildOnlyNotSupported); + } + + let wasm_paths = self.resolve_wasm_paths(global_args)?; + + for wasm_path in &wasm_paths { + let res = self + .upload_wasm( + wasm_path, + &self.config, + global_args.quiet, + global_args.no_cache, + ) + .await? + .to_envelope(); + + match res { + TxnEnvelopeResult::TxnEnvelope(tx) => { + println!("{}", tx.to_xdr_base64(Limits::none())?); + } + TxnEnvelopeResult::Res(hash) => println!("{}", hex::encode(hash)), + } } Ok(()) } + /// Programmatic API for uploading a single WASM file. + /// Expects `self.wasm` to be set. Used by deploy command internally. #[allow(clippy::too_many_lines)] #[allow(unused_variables)] pub async fn execute( @@ -126,16 +168,55 @@ impl Cmd { config: &config::Args, quiet: bool, no_cache: bool, + ) -> Result, Error> { + let wasm_path = self.wasm.clone().ok_or(Error::WasmNotProvided)?; + self.upload_wasm(&wasm_path, config, quiet, no_cache).await + } + + fn resolve_wasm_paths(&self, global_args: &global::Args) -> Result, Error> { + if let Some(wasm) = &self.wasm { + Ok(vec![wasm.clone()]) + } else { + let build_cmd = build::Cmd { + package: self.package.clone(), + build_args: self.build_args.clone(), + ..build::Cmd::default() + }; + let contracts = build_cmd.run(global_args).map_err(|e| match e { + build::Error::Metadata(_) => Error::NotInCargoProject, + other => other.into(), + })?; + + if contracts.is_empty() { + return Err(Error::NoBuildableContracts); + } + + Ok(contracts.into_iter().map(|c| c.path).collect()) + } + } + + #[allow(clippy::too_many_lines)] + #[allow(unused_variables)] + async fn upload_wasm( + &self, + wasm_path: &Path, + config: &config::Args, + quiet: bool, + no_cache: bool, ) -> Result, Error> { let print = Print::new(quiet); - let contract = self.wasm.read()?; + let wasm_path = wasm_path.to_path_buf(); + let wasm_args = wasm::Args { + wasm: wasm_path.clone(), + }; + let contract = wasm_args.read()?; let network = config.get_network()?; let client = network.rpc_client()?; client .verify_network_passphrase(Some(&network.network_passphrase)) .await?; - let wasm_spec = &self.wasm.parse().map_err(|e| Error::CannotParseWasm { - wasm: self.wasm.wasm.clone(), + let wasm_spec = &wasm_args.parse().map_err(|e| Error::CannotParseWasm { + wasm: wasm_path.clone(), error: e, })?; @@ -146,13 +227,13 @@ impl Cmd { && network.network_passphrase == PUBLIC_NETWORK_PASSPHRASE { return Err(Error::ContractCompiledWithReleaseCandidateSdk { - wasm: self.wasm.wasm.clone(), + wasm: wasm_path.clone(), version: rs_sdk_ver, }); } else if rs_sdk_ver.contains("rc") && network.network_passphrase == PUBLIC_NETWORK_PASSPHRASE { - tracing::warn!("the deployed smart contract {path} was built with Soroban Rust SDK v{rs_sdk_ver}, a release candidate version not intended for use with the Stellar Public Network", path = self.wasm.wasm.display()); + tracing::warn!("the deployed smart contract {path} was built with Soroban Rust SDK v{rs_sdk_ver}, a release candidate version not intended for use with the Stellar Public Network", path = wasm_path.display()); } } @@ -241,7 +322,7 @@ impl Cmd { contract_id: None, key: None, key_xdr: None, - wasm: Some(self.wasm.wasm.clone()), + wasm: Some(wasm_path.clone()), wasm_hash: None, durability: super::Durability::Persistent, },