diff --git a/FULL_HELP_DOCS.md b/FULL_HELP_DOCS.md index 9f567c7b9..40ee512e1 100644 --- a/FULL_HELP_DOCS.md +++ b/FULL_HELP_DOCS.md @@ -496,6 +496,12 @@ Deploy a wasm contract - `--instructions ` — ⚠️ Deprecated, use `--instruction-leeway` to increase instructions. Number of instructions to allocate for the transaction - `--instruction-leeway ` — Allow this many extra instructions when budgeting resources with transaction simulation - `--cost` — Output the cost execution to stderr +- `--auth-mode ` — Set the authorization mode for transaction simulation. When unset, the RPC default is used: record with the root mode if no authorization entries exist, otherwise enforce the provided entries. Should only be set for `InvokeHostFunction` transactions + + Possible values: + - `enforce`: Validate the authorization entries already on the transaction + - `root`: Record authorization entries, requiring each to be rooted at the transaction's top-level operation + - `non-root`: Record all authorization entries, including non-root entries ###### **Signing Options:** @@ -864,6 +870,12 @@ Install a WASM file to the ledger without creating a contract instance - `--instructions ` — ⚠️ Deprecated, use `--instruction-leeway` to increase instructions. Number of instructions to allocate for the transaction - `--instruction-leeway ` — Allow this many extra instructions when budgeting resources with transaction simulation - `--cost` — Output the cost execution to stderr +- `--auth-mode ` — Set the authorization mode for transaction simulation. When unset, the RPC default is used: record with the root mode if no authorization entries exist, otherwise enforce the provided entries. Should only be set for `InvokeHostFunction` transactions + + Possible values: + - `enforce`: Validate the authorization entries already on the transaction + - `root`: Record authorization entries, requiring each to be rooted at the transaction's top-level operation + - `non-root`: Record all authorization entries, including non-root entries ###### **Signing Options:** @@ -921,6 +933,12 @@ Install a WASM file to the ledger without creating a contract instance - `--instructions ` — ⚠️ Deprecated, use `--instruction-leeway` to increase instructions. Number of instructions to allocate for the transaction - `--instruction-leeway ` — Allow this many extra instructions when budgeting resources with transaction simulation - `--cost` — Output the cost execution to stderr +- `--auth-mode ` — Set the authorization mode for transaction simulation. When unset, the RPC default is used: record with the root mode if no authorization entries exist, otherwise enforce the provided entries. Should only be set for `InvokeHostFunction` transactions + + Possible values: + - `enforce`: Validate the authorization entries already on the transaction + - `root`: Record authorization entries, requiring each to be rooted at the transaction's top-level operation + - `non-root`: Record all authorization entries, including non-root entries ###### **Signing Options:** @@ -978,6 +996,12 @@ stellar contract invoke ... -- --help - `--instructions ` — ⚠️ Deprecated, use `--instruction-leeway` to increase instructions. Number of instructions to allocate for the transaction - `--instruction-leeway ` — Allow this many extra instructions when budgeting resources with transaction simulation - `--cost` — Output the cost execution to stderr +- `--auth-mode ` — Set the authorization mode for transaction simulation. When unset, the RPC default is used: record with the root mode if no authorization entries exist, otherwise enforce the provided entries. Should only be set for `InvokeHostFunction` transactions + + Possible values: + - `enforce`: Validate the authorization entries already on the transaction + - `root`: Record authorization entries, requiring each to be rooted at the transaction's top-level operation + - `non-root`: Record all authorization entries, including non-root entries ###### **Signing Options:** @@ -3916,6 +3940,12 @@ Simulate a transaction envelope from stdin - `--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 +- `--auth-mode ` — Set the authorization mode for transaction simulation. When unset, the RPC default is used: record with the root mode if no authorization entries exist, otherwise enforce the provided entries. Should only be set for `InvokeHostFunction` transactions + + Possible values: + - `enforce`: Validate the authorization entries already on the transaction + - `root`: Record authorization entries, requiring each to be rooted at the transaction's top-level operation + - `non-root`: Record all authorization entries, including non-root entries ###### **Signing Options:** diff --git a/cmd/crates/soroban-test/tests/it/integration/auth.rs b/cmd/crates/soroban-test/tests/it/integration/auth.rs index 2740dc5dd..580342c92 100644 --- a/cmd/crates/soroban-test/tests/it/integration/auth.rs +++ b/cmd/crates/soroban-test/tests/it/integration/auth.rs @@ -99,8 +99,7 @@ async fn non_root_auth_with_authorized_subcall() { .failure() .stderr(predicates::str::contains("Auth, InvalidAction")); - // with source signer - expect failure - // TODO: this should pass once CLI supports non-root auth + // with source signer - expect failure due to default root auth mode sandbox .new_assert_cmd("contract") .arg("invoke") @@ -117,6 +116,76 @@ async fn non_root_auth_with_authorized_subcall() { .stderr(predicates::str::contains("Auth, InvalidAction")); } +#[tokio::test] +async fn non_root_auth_mode_signs_non_root_subcall() { + let sandbox = &TestEnv::new(); + new_account(sandbox, "signer"); + + let (id1, id2) = deploy_auth_contracts(sandbox).await; + + // with non-root auth mode, non-source signer, and auto-sign - expect success + sandbox + .new_assert_cmd("contract") + .arg("invoke") + .arg("--source=test") + .arg("--id") + .arg(&id1) + .arg("--auth-mode=non-root") + .arg("--auto-sign") + .arg("--") + .arg("no-auth-sub-auth") + .arg("--addr=signer") + .arg("--val=hello") + .arg(&format!("--subcall={id2}")) + .assert() + .success() + .stdout("\"hello\"\n"); + + // with non-root auth mode, source signer, and no auto-sign - expect success + // -> signature is covered by the envelope signature, no explicit signature needed + sandbox + .new_assert_cmd("contract") + .arg("invoke") + .arg("--source=test") + .arg("--id") + .arg(&id1) + .arg("--auth-mode=non-root") + .arg("--") + .arg("no-auth-sub-auth") + .arg("--addr=test") + .arg("--val=hello") + .arg(&format!("--subcall={id2}")) + .assert() + .success() + .stdout("\"hello\"\n"); +} + +#[tokio::test] +async fn non_root_auth_mode_via_env_var() { + let sandbox = &TestEnv::new(); + new_account(sandbox, "signer"); + + let (id1, id2) = deploy_auth_contracts(sandbox).await; + + // `STELLAR_AUTH_MODE` is the env-var equivalent of `--auth-mode`. + sandbox + .new_assert_cmd("contract") + .env("STELLAR_AUTH_MODE", "non-root") + .arg("invoke") + .arg("--source=test") + .arg("--id") + .arg(&id1) + .arg("--auto-sign") + .arg("--") + .arg("no-auth-sub-auth") + .arg("--addr=signer") + .arg("--val=hello") + .arg(&format!("--subcall={id2}")) + .assert() + .success() + .stdout("\"hello\"\n"); +} + #[tokio::test] async fn partial_auth_with_authorized_subcall() { let sandbox = &TestEnv::new(); diff --git a/cmd/crates/soroban-test/tests/it/integration/tx/general.rs b/cmd/crates/soroban-test/tests/it/integration/tx/general.rs index 3e73d2286..8c380b6eb 100644 --- a/cmd/crates/soroban-test/tests/it/integration/tx/general.rs +++ b/cmd/crates/soroban-test/tests/it/integration/tx/general.rs @@ -30,7 +30,7 @@ async fn simulate() { .assert() .success() .stdout_as_str(); - let assembled = simulate_and_assemble_transaction(&sandbox.client(), &tx, None, None) + let assembled = simulate_and_assemble_transaction(&sandbox.client(), &tx, None, None, None) .await .unwrap(); let txn_env: TransactionEnvelope = assembled.transaction().clone().into(); @@ -40,6 +40,48 @@ async fn simulate() { ); } +#[tokio::test] +async fn simulate_auth_modes() { + let sandbox = &TestEnv::new(); + let xdr_base64_build_only = deploy_contract( + sandbox, + HELLO_WORLD, + DeployOptions { + kind: DeployKind::BuildOnly, + salt: Some(String::from("B")), + ..Default::default() + }, + ) + .await; + + // The unset default and the recording modes assemble the deployer + // authorization the CreateContract op requires. + for args in [ + &[][..], + &["--auth-mode=root"][..], + &["--auth-mode=non-root"][..], + ] { + sandbox + .new_assert_cmd("tx") + .arg("simulate") + .args(args) + .write_stdin(xdr_base64_build_only.as_bytes()) + .assert() + .success(); + } + + // `enforce` only validates authorization already present on the envelope. + // The build-only envelope has none, so it cannot authorize the deploy. + sandbox + .new_assert_cmd("tx") + .arg("simulate") + .arg("--auth-mode=enforce") + .write_stdin(xdr_base64_build_only.as_bytes()) + .assert() + .failure() + .stderr(predicates::str::contains("Auth, InvalidAction")); +} + fn test_tx_string(sandbox: &TestEnv) -> String { sandbox .new_assert_cmd("contract") diff --git a/cmd/soroban-cli/src/assembled.rs b/cmd/soroban-cli/src/assembled.rs index e006f079b..6c08d8618 100644 --- a/cmd/soroban-cli/src/assembled.rs +++ b/cmd/soroban-cli/src/assembled.rs @@ -6,13 +6,16 @@ use stellar_xdr::curr::{ TransactionSignaturePayloadTaggedTransaction, TransactionV1Envelope, VecM, WriteXdr, }; -use soroban_rpc::{Error, LogEvents, LogResources, ResourceConfig, SimulateTransactionResponse}; +use soroban_rpc::{ + AuthMode, Error, LogEvents, LogResources, ResourceConfig, SimulateTransactionResponse, +}; pub async fn simulate_and_assemble_transaction( client: &soroban_rpc::Client, tx: &Transaction, resource_config: Option, resource_fee: Option, + auth_mode: Option, ) -> Result { let envelope = TransactionEnvelope::Tx(TransactionV1Envelope { tx: tx.clone(), @@ -25,7 +28,7 @@ pub async fn simulate_and_assemble_transaction( ); let sim_res = client - .next_simulate_transaction_envelope(&envelope, None, resource_config) + .next_simulate_transaction_envelope(&envelope, auth_mode, resource_config) .await?; tracing::trace!("{sim_res:#?}"); diff --git a/cmd/soroban-cli/src/auth_mode.rs b/cmd/soroban-cli/src/auth_mode.rs new file mode 100644 index 000000000..2575029de --- /dev/null +++ b/cmd/soroban-cli/src/auth_mode.rs @@ -0,0 +1,94 @@ +//! Auth mode for Soroban transaction simulation. +//! +//! Selects how the RPC handles authorization entries while simulating a +//! transaction. The variants map onto the RPC `simulateTransaction` `authMode` +//! parameter; leaving the argument unset omits the parameter and uses the RPC +//! default. + +use clap::ValueEnum; + +#[derive(Clone, Copy, Debug, PartialEq, Eq, ValueEnum)] +pub enum AuthMode { + /// Validate the authorization entries already on the transaction. + Enforce, + /// Record authorization entries, requiring each to be rooted at the + /// transaction's top-level operation. + Root, + /// Record all authorization entries, including non-root entries. + #[value(name = "non-root")] + NonRoot, +} + +impl AuthMode { + /// Map to the RPC `simulateTransaction` `authMode` parameter. + pub fn to_rpc(self) -> soroban_rpc::AuthMode { + match self { + AuthMode::Enforce => soroban_rpc::AuthMode::Enforce, + AuthMode::Root => soroban_rpc::AuthMode::Record, + AuthMode::NonRoot => soroban_rpc::AuthMode::RecordAllowNonRoot, + } + } +} + +/// Shared `--auth-mode` argument for commands that simulate Soroban +/// transactions. +/// +/// The argument is optional: when unset, no `authMode` is sent and the RPC uses +/// its default (record with root mode if no authorization entries exist, +/// otherwise enforce the provided entries). This is also the only safe behavior +/// for envelopes whose operation is not `InvokeHostFunction`, since the RPC +/// rejects `authMode` for those. +#[derive(Debug, clap::Args, Clone, Default)] +#[group(skip)] +pub struct Args { + /// Set the authorization mode for transaction simulation. When unset, the RPC + /// default is used: record with the root mode if no authorization entries + /// exist, otherwise enforce the provided entries. Should only be set for + /// `InvokeHostFunction` transactions. + #[arg( + long, + env = "STELLAR_AUTH_MODE", + help_heading = crate::commands::HEADING_RPC, + )] + pub auth_mode: Option, +} + +impl Args { + pub fn to_rpc(&self) -> Option { + self.auth_mode.map(AuthMode::to_rpc) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn unset_omits_rpc_auth_mode() { + assert!(Args::default().to_rpc().is_none()); + } + + #[test] + fn enforce_maps_to_enforce() { + assert!(matches!( + AuthMode::Enforce.to_rpc(), + soroban_rpc::AuthMode::Enforce + )); + } + + #[test] + fn root_maps_to_record() { + assert!(matches!( + AuthMode::Root.to_rpc(), + soroban_rpc::AuthMode::Record + )); + } + + #[test] + fn non_root_maps_to_record_allow_non_root() { + assert!(matches!( + AuthMode::NonRoot.to_rpc(), + soroban_rpc::AuthMode::RecordAllowNonRoot + )); + } +} diff --git a/cmd/soroban-cli/src/commands/contract/deploy/asset.rs b/cmd/soroban-cli/src/commands/contract/deploy/asset.rs index 5aa4c408b..17d53e0d5 100644 --- a/cmd/soroban-cli/src/commands/contract/deploy/asset.rs +++ b/cmd/soroban-cli/src/commands/contract/deploy/asset.rs @@ -171,8 +171,17 @@ impl Cmd { return Ok(TxnResult::Txn(Box::new(tx))); } - sim_sign_and_send_tx::(&client, &tx, config, &self.resources, &[], quiet, no_cache) - .await?; + sim_sign_and_send_tx::( + &client, + &tx, + config, + &self.resources, + &[], + None, + quiet, + no_cache, + ) + .await?; if let Some(url) = utils::lab_url_for_contract(&network, &contract_id) { print.linkln(url); diff --git a/cmd/soroban-cli/src/commands/contract/deploy/wasm.rs b/cmd/soroban-cli/src/commands/contract/deploy/wasm.rs index 49292baee..b9b3dd1a5 100644 --- a/cmd/soroban-cli/src/commands/contract/deploy/wasm.rs +++ b/cmd/soroban-cli/src/commands/contract/deploy/wasm.rs @@ -66,6 +66,8 @@ pub struct Cmd { pub alias: Option, #[command(flatten)] pub resources: resources::Args, + #[command(flatten)] + pub auth_mode: crate::auth_mode::Args, /// Build the transaction and only write the base64 xdr to stdout #[arg(long, help_heading = HEADING_TRANSACTION)] pub build_only: bool, @@ -314,6 +316,7 @@ impl Cmd { wasm: Some(wasm.clone()), config: config.clone(), resources: self.resources.clone(), + auth_mode: self.auth_mode.clone(), ignore_checks: self.ignore_checks, build_only: is_build, package: None, @@ -415,8 +418,17 @@ impl Cmd { return Ok(TxnResult::Txn(txn)); } - sim_sign_and_send_tx::(&client, &txn, config, &self.resources, &[], quiet, no_cache) - .await?; + sim_sign_and_send_tx::( + &client, + &txn, + config, + &self.resources, + &[], + self.auth_mode.to_rpc(), + quiet, + no_cache, + ) + .await?; if let Some(url) = utils::lab_url_for_contract(&network, &contract_id) { print.linkln(url); diff --git a/cmd/soroban-cli/src/commands/contract/extend.rs b/cmd/soroban-cli/src/commands/contract/extend.rs index 3d0cff87c..9145eece6 100644 --- a/cmd/soroban-cli/src/commands/contract/extend.rs +++ b/cmd/soroban-cli/src/commands/contract/extend.rs @@ -247,6 +247,9 @@ impl Cmd { config, &self.resources, &[], + // Footprint extend is not an InvokeHostFunction op, so the RPC does + // not accept an auth mode. + None, quiet, no_cache, ) diff --git a/cmd/soroban-cli/src/commands/contract/invoke.rs b/cmd/soroban-cli/src/commands/contract/invoke.rs index be9a2a8f1..c78bbc329 100644 --- a/cmd/soroban-cli/src/commands/contract/invoke.rs +++ b/cmd/soroban-cli/src/commands/contract/invoke.rs @@ -66,6 +66,9 @@ pub struct Cmd { #[command(flatten)] pub resources: crate::resources::Args, + #[command(flatten)] + pub auth_mode: crate::auth_mode::Args, + /// Whether or not to send a transaction #[arg(long, value_enum, default_value_t, env = "STELLAR_SEND")] pub send: Send, @@ -246,6 +249,7 @@ impl Cmd { &tx, self.resources.resource_config(), self.resources.resource_fee, + self.auth_mode.to_rpc(), ) .await?) } @@ -362,6 +366,7 @@ impl Cmd { config, &self.resources, &signers, + self.auth_mode.to_rpc(), quiet, no_cache, ) diff --git a/cmd/soroban-cli/src/commands/contract/restore.rs b/cmd/soroban-cli/src/commands/contract/restore.rs index 2b71b56ba..c4516c627 100644 --- a/cmd/soroban-cli/src/commands/contract/restore.rs +++ b/cmd/soroban-cli/src/commands/contract/restore.rs @@ -214,6 +214,9 @@ impl Cmd { config, &self.resources, &[], + // Footprint restore is not an InvokeHostFunction op, so the RPC does + // not accept an auth mode. + None, quiet, no_cache, ) diff --git a/cmd/soroban-cli/src/commands/contract/upload.rs b/cmd/soroban-cli/src/commands/contract/upload.rs index 463adf5d2..163303f11 100644 --- a/cmd/soroban-cli/src/commands/contract/upload.rs +++ b/cmd/soroban-cli/src/commands/contract/upload.rs @@ -41,6 +41,9 @@ pub struct Cmd { #[command(flatten)] pub resources: crate::resources::Args, + #[command(flatten)] + pub auth_mode: crate::auth_mode::Args, + /// Path to wasm binary. When omitted inside a Cargo workspace, builds the /// project automatically. Required when outside a Cargo workspace. #[arg(long)] @@ -295,6 +298,7 @@ impl Cmd { config, &self.resources, &[], + self.auth_mode.to_rpc(), quiet, no_cache, ) diff --git a/cmd/soroban-cli/src/commands/tx/simulate.rs b/cmd/soroban-cli/src/commands/tx/simulate.rs index 3e8e1f88f..c89a0f9a6 100644 --- a/cmd/soroban-cli/src/commands/tx/simulate.rs +++ b/cmd/soroban-cli/src/commands/tx/simulate.rs @@ -36,6 +36,9 @@ pub struct Cmd { /// Allow this many extra instructions when budgeting resources during transaction simulation #[arg(long)] pub instruction_leeway: Option, + + #[command(flatten)] + pub auth_mode: crate::auth_mode::Args, } impl Cmd { @@ -58,7 +61,14 @@ impl Cmd { let resource_config = self .instruction_leeway .map(|instruction_leeway| soroban_rpc::ResourceConfig { instruction_leeway }); - let tx = simulate_and_assemble_transaction(&client, &tx, resource_config, None).await?; + let tx = simulate_and_assemble_transaction( + &client, + &tx, + resource_config, + None, + self.auth_mode.to_rpc(), + ) + .await?; if let Some(fee_bump_fee) = tx.fee_bump_fee() { print.warnln(format!("The transaction fee of {} is too large and needs to be wrapped in a fee bump transaction.", print::format_number(fee_bump_fee, 7))); } diff --git a/cmd/soroban-cli/src/lib.rs b/cmd/soroban-cli/src/lib.rs index ae411d63a..6a9353d08 100644 --- a/cmd/soroban-cli/src/lib.rs +++ b/cmd/soroban-cli/src/lib.rs @@ -12,6 +12,7 @@ mod cli; pub use cli::main; pub mod assembled; +pub mod auth_mode; pub mod color; pub mod commands; pub mod config; diff --git a/cmd/soroban-cli/src/tx.rs b/cmd/soroban-cli/src/tx.rs index 4ab48bfc1..6a5baba42 100644 --- a/cmd/soroban-cli/src/tx.rs +++ b/cmd/soroban-cli/src/tx.rs @@ -30,12 +30,14 @@ pub const ONE_XLM: i64 = 10_000_000; /// /// # Errors /// If any step of the process fails (simulation, signing, sending) +#[allow(clippy::too_many_arguments)] pub async fn sim_sign_and_send_tx( client: &soroban_rpc::Client, tx: &Transaction, config: &config::Args, resources: &resources::Args, auth_signers: &[Signer], + auth_mode: Option, quiet: bool, no_cache: bool, ) -> Result @@ -55,6 +57,7 @@ where tx, resources.resource_config(), resources.resource_fee, + auth_mode, ) .await?; let assembled = resources.apply_to_assembled_txn(txn);