diff --git a/FULL_HELP_DOCS.md b/FULL_HELP_DOCS.md index bee564d9c..efa9f84cd 100644 --- a/FULL_HELP_DOCS.md +++ b/FULL_HELP_DOCS.md @@ -38,6 +38,18 @@ Anything after the `--` double dash (the "slop") is parsed as arguments to the c stellar contract invoke --id CCR6QKTWZQYW6YUJ7UP7XXZRLWQPFRV6SWBLQS4ZQOSAF4BOUD77OTE2 --source alice --network testnet -- hello --to world +CONFIGURATION + +Most commands read their network and source account from three sources, applied in this order of precedence (highest first): + + 1. CLI flags e.g. --network, --rpc-url, --source + 2. Environment vars shell env, including values loaded from a `.env` file in the current directory (e.g. STELLAR_NETWORK, STELLAR_RPC_URL, STELLAR_ACCOUNT) + 3. `use` defaults set via `stellar network use NAME` and `stellar keys use NAME`, loaded as env vars at startup + +Run `stellar env` to see every active setting and where it came from. + +Network resolution has one extra rule: if a network name is set from any source, the named configuration is loaded from disk and any rpc-url/network-passphrase from any source is ignored. A warning is printed when this happens — pass `--quiet` to suppress it. + **Usage:** `stellar [OPTIONS] ` ###### **Subcommands:** diff --git a/cmd/crates/soroban-test/tests/it/integration/network.rs b/cmd/crates/soroban-test/tests/it/integration/network.rs index acfef5940..d3bf03097 100644 --- a/cmd/crates/soroban-test/tests/it/integration/network.rs +++ b/cmd/crates/soroban-test/tests/it/integration/network.rs @@ -143,3 +143,62 @@ async fn network_info_includes_id_in_json_output() { "baefd734b8d3e48472cff83912375fedbc7573701912fe308af730180f97d74a" ); } + +// TestEnv pre-sets STELLAR_RPC_URL and STELLAR_NETWORK_PASSPHRASE on every +// command, so any invocation that adds `--network local` already exercises the +// cross-source precedence path. The local network is bundled (no `network add` +// required) and points at the running sandbox, so the warning fires before any +// RPC call needs to succeed. +#[tokio::test] +async fn network_flag_warns_when_env_rpc_url_present() { + let sandbox = &TestEnv::new(); + + sandbox + .new_assert_cmd("network") + .args(["info", "--network", "local"]) + .assert() + .stderr(predicate::str::contains( + "--network=local takes precedence; ignoring --rpc-url / STELLAR_RPC_URL and --network-passphrase / STELLAR_NETWORK_PASSPHRASE", + )); +} + +#[tokio::test] +async fn network_flag_warning_lists_only_set_overrides() { + let sandbox = &TestEnv::new(); + + sandbox + .new_assert_cmd("network") + .args(["info", "--network", "local"]) + .env_remove("STELLAR_NETWORK_PASSPHRASE") + .assert() + .stderr( + predicate::str::contains("ignoring --rpc-url / STELLAR_RPC_URL").and( + predicate::str::contains("--network-passphrase / STELLAR_NETWORK_PASSPHRASE").not(), + ), + ); +} + +#[tokio::test] +async fn network_flag_warning_fires_for_env_only_network() { + let sandbox = &TestEnv::new(); + + sandbox + .new_assert_cmd("network") + .arg("info") + .env("STELLAR_NETWORK", "local") + .assert() + .stderr(predicate::str::contains( + "--network=local takes precedence; ignoring", + )); +} + +#[tokio::test] +async fn network_flag_warning_suppressed_by_quiet() { + let sandbox = &TestEnv::new(); + + sandbox + .new_assert_cmd("network") + .args(["--quiet", "info", "--network", "local"]) + .assert() + .stderr(predicate::str::contains("takes precedence").not()); +} diff --git a/cmd/soroban-cli/src/cli.rs b/cmd/soroban-cli/src/cli.rs index a088f069c..b439db382 100644 --- a/cmd/soroban-cli/src/cli.rs +++ b/cmd/soroban-cli/src/cli.rs @@ -9,7 +9,7 @@ use crate::commands::contract::invoke::Error::ArgParsing; use crate::commands::contract::Error::{Deploy, Invoke}; use crate::commands::Error::Contract; use crate::config::{locator::cli_config_file, Config}; -use crate::print::Print; +use crate::print::{self, Print}; use crate::upgrade_check::upgrade_check; use crate::{commands, env_vars, Root}; use std::error::Error; @@ -44,6 +44,8 @@ pub async fn main() { } }); + print::set_quiet(root.global_args.quiet); + // Now use root to setup the logger if let Some(level) = root.global_args.log_level() { let mut e_filter = EnvFilter::from_default_env() diff --git a/cmd/soroban-cli/src/commands/mod.rs b/cmd/soroban-cli/src/commands/mod.rs index 4da22942a..51cc74f9b 100644 --- a/cmd/soroban-cli/src/commands/mod.rs +++ b/cmd/soroban-cli/src/commands/mod.rs @@ -68,6 +68,18 @@ Use contracts like a CLI: Anything after the `--` double dash (the \"slop\") is parsed as arguments to the contract-specific CLI, generated on-the-fly from the contract schema. For the hello world example, with a function called `hello` that takes one string argument `to`, here's how you invoke it: stellar contract invoke --id CCR6QKTWZQYW6YUJ7UP7XXZRLWQPFRV6SWBLQS4ZQOSAF4BOUD77OTE2 --source alice --network testnet -- hello --to world + +CONFIGURATION + +Most commands read their network and source account from three sources, applied in this order of precedence (highest first): + + 1. CLI flags e.g. --network, --rpc-url, --source + 2. Environment vars shell env, including values loaded from a `.env` file in the current directory (e.g. STELLAR_NETWORK, STELLAR_RPC_URL, STELLAR_ACCOUNT) + 3. `use` defaults set via `stellar network use NAME` and `stellar keys use NAME`, loaded as env vars at startup + +Run `stellar env` to see every active setting and where it came from. + +Network resolution has one extra rule: if a network name is set from any source, the named configuration is loaded from disk and any rpc-url/network-passphrase from any source is ignored. A warning is printed when this happens — pass `--quiet` to suppress it. "; #[derive(Parser, Debug)] diff --git a/cmd/soroban-cli/src/config/network.rs b/cmd/soroban-cli/src/config/network.rs index 350b9ca46..5a1c35146 100644 --- a/cmd/soroban-cli/src/config/network.rs +++ b/cmd/soroban-cli/src/config/network.rs @@ -6,10 +6,12 @@ use serde::{Deserialize, Serialize}; use serde_json::Value; use std::collections::HashMap; use std::str::FromStr; +use std::sync::Once; use stellar_strkey::ed25519::PublicKey; use url::Url; use super::locator; +use crate::print::{self, Print}; use crate::utils::http; use crate::{ commands::HEADING_RPC, @@ -38,8 +40,6 @@ STELLAR_NETWORK, STELLAR_RPC_URL and STELLAR_NETWORK_PASSPHRASE"# "network passphrase is used but rpc-url is missing, use `--rpc-url` or `STELLAR_RPC_URL`" )] MissingRpcUrl, - #[error("cannot use both `--rpc-url` and `--network`")] - CannotUseBothRpcAndNetwork, #[error(transparent)] Rpc(#[from] rpc::Error), #[error(transparent)] @@ -96,21 +96,49 @@ pub struct Args { pub network: Option, } +// Emits the network-precedence warning at most once per process so commands +// that call `Args::get` multiple times (e.g. sign + sign_fee_bump) don't spam +// the user. Honors the global `--quiet` flag recorded in `cli::main`. +fn warn_if_overridden(network: &str, rpc_url: Option<&str>, network_passphrase: Option<&str>) { + static WARN_ONCE: Once = Once::new(); + let ignored = match (rpc_url.is_some(), network_passphrase.is_some()) { + (true, true) => { + "--rpc-url / STELLAR_RPC_URL and --network-passphrase / STELLAR_NETWORK_PASSPHRASE" + } + (true, false) => "--rpc-url / STELLAR_RPC_URL", + (false, true) => "--network-passphrase / STELLAR_NETWORK_PASSPHRASE", + (false, false) => return, + }; + WARN_ONCE.call_once(|| { + Print::new(print::is_quiet()).warnln(format!( + "--network={network} takes precedence; ignoring {ignored}" + )); + }); +} + impl Args { pub fn get(&self, locator: &locator::Args) -> Result { + // A named network always resolves via the on-disk config, ignoring any + // rpc-url / passphrase that may have been picked up from env vars. This + // is what lets `--network foo` win over a stray STELLAR_RPC_URL in + // `.env`. Conflicts typed entirely on the CLI fall through here too; + // the explicit name takes precedence. match ( self.network.as_deref(), self.rpc_url.clone(), self.network_passphrase.clone(), ) { + (Some(network), rpc_url, network_passphrase) => { + warn_if_overridden(network, rpc_url.as_deref(), network_passphrase.as_deref()); + Ok(locator.read_network(network)?) + } (None, None, None) => { // Fall back to testnet as the default network if no config default is set Ok(DEFAULTS.get(DEFAULT_NETWORK_KEY).unwrap().into()) } - (_, Some(_), None) => Err(Error::MissingNetworkPassphrase), - (_, None, Some(_)) => Err(Error::MissingRpcUrl), - (Some(network), None, None) => Ok(locator.read_network(network)?), - (_, Some(rpc_url), Some(network_passphrase)) => { + (None, Some(_), None) => Err(Error::MissingNetworkPassphrase), + (None, None, Some(_)) => Err(Error::MissingRpcUrl), + (None, Some(rpc_url), Some(network_passphrase)) => { let rpc_headers = self .rpc_headers .iter() @@ -725,4 +753,24 @@ mod tests { let bad = "not a url"; assert_eq!(redact_rpc_url(bad), bad); } + + #[tokio::test] + async fn network_name_wins_over_rpc_tuple() { + use crate::test_utils::with_env_set; + + let tmp = tempfile::tempdir().unwrap(); + with_env_set("STELLAR_CONFIG_HOME", tmp.path(), || { + let args = Args { + network: Some("testnet".to_string()), + rpc_url: Some("http://other.example.com:59999".to_string()), + network_passphrase: Some("Some Other Passphrase".to_string()), + rpc_headers: Vec::new(), + }; + + let result = args.get(&locator::Args::default()).unwrap(); + + assert_eq!(result.rpc_url, "https://soroban-testnet.stellar.org"); + assert_eq!(result.network_passphrase, passphrase::TESTNET); + }); + } } diff --git a/cmd/soroban-cli/src/print.rs b/cmd/soroban-cli/src/print.rs index 995a45a78..3ef30d84c 100644 --- a/cmd/soroban-cli/src/print.rs +++ b/cmd/soroban-cli/src/print.rs @@ -1,4 +1,5 @@ use std::io::{self, Write}; +use std::sync::OnceLock; use std::{env, fmt::Display}; use crate::xdr::{Error as XdrError, Transaction}; @@ -7,6 +8,21 @@ use crate::{ config::network::Network, utils::explorer_url_for_transaction, utils::transaction_hash, }; +static QUIET: OnceLock = OnceLock::new(); + +/// Record whether `--quiet` was passed on the command line. Called once from +/// `cli::main` after parsing so resolvers running deep in the call stack can +/// honor the global flag without it being threaded through every signature. +pub fn set_quiet(quiet: bool) { + let _ = QUIET.set(quiet); +} + +/// Read the recorded `--quiet` flag, defaulting to `false` if [`set_quiet`] +/// hasn't run yet (e.g. in unit tests that don't go through `cli::main`). +pub fn is_quiet() -> bool { + *QUIET.get().unwrap_or(&false) +} + #[derive(Clone)] pub struct Print { pub quiet: bool,