diff --git a/FULL_HELP_DOCS.md b/FULL_HELP_DOCS.md index 42f753f00b..05ff78a7eb 100644 --- a/FULL_HELP_DOCS.md +++ b/FULL_HELP_DOCS.md @@ -4145,7 +4145,7 @@ Encode a transaction envelope from JSON to XDR Decode and encode XDR -**Usage:** `stellar xdr [CHANNEL] ` +**Usage:** `stellar xdr ` ###### **Subcommands:** @@ -4158,14 +4158,6 @@ Decode and encode XDR - `xfile` — Preprocess XDR .x files - `version` — Print version information -###### **Arguments:** - -- `` — Channel of XDR to operate on - - Default value: `+curr` - - Possible values: `+curr`, `+next` - ## `stellar xdr types` View information about types diff --git a/cmd/crates/soroban-test/tests/it/main.rs b/cmd/crates/soroban-test/tests/it/main.rs index e96558af5e..ec4b213f47 100644 --- a/cmd/crates/soroban-test/tests/it/main.rs +++ b/cmd/crates/soroban-test/tests/it/main.rs @@ -11,5 +11,6 @@ mod message; mod plugin; mod rpc_provider; mod strkey; +mod tx; mod util; mod version; diff --git a/cmd/crates/soroban-test/tests/it/tx.rs b/cmd/crates/soroban-test/tests/it/tx.rs new file mode 100644 index 0000000000..c51ba464b1 --- /dev/null +++ b/cmd/crates/soroban-test/tests/it/tx.rs @@ -0,0 +1,51 @@ +use soroban_test::{AssertExt, TestEnv}; + +// Transaction envelope from https://github.com/stellar/stellar-cli/issues/2455. +const TX_ENVELOPE: &str = "AAAAAgAAAAAnEWb4nxMsQhdnS16MqBxwItF3X/JNRkfxu8eyEQyfegAAAGQAAAAAAAAAAQAAAAAAAAAAAAAAAQAAAAAAAAABAAAAABk++lrW4HlZwaWwYdYvJPCil1ibyI/VTH6WKda2sOC+AAAAAAAAAAAAAABkAAAAAAAAAAA="; +const SECRET_KEY: &str = "SAKICEVQLYWGSOJS4WW7HZJWAHZVEEBS527LHK5V4MLJALYKICQCJXMW"; + +// `tx sign` only mixes the network passphrase into the transaction hash, so it +// must not require an RPC URL (regression test for #2455). +#[tokio::test] +async fn tx_sign_requires_only_network_passphrase() { + let sandbox = &TestEnv::new(); + + let output = sandbox + .new_assert_cmd("tx") + .args([ + "sign", + TX_ENVELOPE, + "--network-passphrase", + "specified manually", + "--sign-with-key", + SECRET_KEY, + ]) + .assert() + .success() + .stdout_as_str(); + + // A signed envelope is longer than the unsigned one it was built from. + assert!(output.trim().len() > TX_ENVELOPE.len()); +} + +// Same expectation for `tx hash`, which also only needs the passphrase. +#[tokio::test] +async fn tx_hash_requires_only_network_passphrase() { + let sandbox = &TestEnv::new(); + + let output = sandbox + .new_assert_cmd("tx") + .args([ + "hash", + TX_ENVELOPE, + "--network-passphrase", + "specified manually", + ]) + .assert() + .success() + .stdout_as_str(); + + let hash = output.trim(); + assert_eq!(hash.len(), 64, "expected a 32-byte hex hash, got: {hash}"); + assert!(hash.chars().all(|c| c.is_ascii_hexdigit())); +} diff --git a/cmd/soroban-cli/src/commands/tx/hash.rs b/cmd/soroban-cli/src/commands/tx/hash.rs index 0715496837..54d472d8f5 100644 --- a/cmd/soroban-cli/src/commands/tx/hash.rs +++ b/cmd/soroban-cli/src/commands/tx/hash.rs @@ -29,7 +29,7 @@ pub struct Cmd { impl Cmd { pub fn run(&self, global_args: &global::Args) -> Result<(), Error> { let tx = super::xdr::unwrap_envelope_v1(super::xdr::tx_envelope_from_input(&self.tx_xdr)?)?; - let network = &self.network.get(&global_args.locator)?; + let network = &self.network.get_no_rpc(&global_args.locator)?; println!( "{}", hex::encode(transaction_hash(&tx, &network.network_passphrase)?) diff --git a/cmd/soroban-cli/src/commands/tx/sign.rs b/cmd/soroban-cli/src/commands/tx/sign.rs index 4012151cfc..17368aa35d 100644 --- a/cmd/soroban-cli/src/commands/tx/sign.rs +++ b/cmd/soroban-cli/src/commands/tx/sign.rs @@ -42,7 +42,7 @@ impl Cmd { .sign_tx_env( &tx_env, &self.locator, - &self.network.get(&self.locator)?, + &self.network.get_no_rpc(&self.locator)?, global_args.quiet, None, ) diff --git a/cmd/soroban-cli/src/config/network.rs b/cmd/soroban-cli/src/config/network.rs index b0a0590328..a4eb02d407 100644 --- a/cmd/soroban-cli/src/config/network.rs +++ b/cmd/soroban-cli/src/config/network.rs @@ -98,6 +98,19 @@ pub struct Args { impl Args { pub fn get(&self, locator: &locator::Args) -> Result { + self.resolve(locator, /* require_rpc */ true) + } + + /// Resolve the network for commands that only need the passphrase + /// (e.g. `tx sign`, `tx hash`) and never contact an RPC server. + /// + /// When only a passphrase is supplied, the returned `Network` has an empty + /// `rpc_url`; callers of this method MUST NOT use `rpc_client()`. + pub fn get_no_rpc(&self, locator: &locator::Args) -> Result { + self.resolve(locator, /* require_rpc */ false) + } + + fn resolve(&self, locator: &locator::Args, require_rpc: bool) -> Result { match ( self.network.as_deref(), self.rpc_url.clone(), @@ -108,6 +121,13 @@ impl Args { Ok(DEFAULTS.get(DEFAULT_NETWORK_KEY).unwrap().into()) } (_, Some(_), None) => Err(Error::MissingNetworkPassphrase), + // Signing-only commands don't need an RPC URL, so accept a + // passphrase on its own and leave `rpc_url` empty. + (_, None, Some(network_passphrase)) if !require_rpc => Ok(Network { + rpc_url: String::new(), + rpc_headers: Vec::new(), + network_passphrase, + }), (_, None, Some(_)) => Err(Error::MissingRpcUrl), (Some(network), None, None) => Ok(locator.read_network(network)?), (_, Some(rpc_url), Some(network_passphrase)) => { @@ -549,6 +569,77 @@ mod tests { assert_eq!(network.rpc_url, "https://soroban-testnet.stellar.org"); } + #[test] + fn test_get_no_rpc_accepts_passphrase_only() { + use super::super::locator; + + let args = Args { + rpc_url: None, + rpc_headers: Vec::new(), + network_passphrase: Some("specified manually".to_string()), + network: None, + }; + + let network = args + .get_no_rpc(&locator::Args::default()) + .expect("passphrase-only network should resolve for signing-only commands"); + assert_eq!(network.network_passphrase, "specified manually"); + assert_eq!(network.rpc_url, ""); + assert!(network.rpc_headers.is_empty()); + } + + #[test] + fn test_get_no_rpc_still_requires_passphrase_when_rpc_given() { + use super::super::locator; + + let args = Args { + rpc_url: Some("https://example.com".to_string()), + rpc_headers: Vec::new(), + network_passphrase: None, + network: None, + }; + + let err = args.get_no_rpc(&locator::Args::default()).expect_err( + "rpc without passphrase should still error, even for signing-only commands", + ); + assert!(matches!(err, Error::MissingNetworkPassphrase)); + } + + #[test] + fn test_get_no_rpc_preserves_rpc_url_when_both_given() { + use super::super::locator; + + let args = Args { + rpc_url: Some("https://example.com".to_string()), + rpc_headers: Vec::new(), + network_passphrase: Some("specified manually".to_string()), + network: None, + }; + + let network = args.get_no_rpc(&locator::Args::default()).unwrap(); + assert_eq!(network.rpc_url, "https://example.com"); + assert_eq!(network.network_passphrase, "specified manually"); + } + + #[test] + fn test_get_strict_still_requires_rpc_url_with_passphrase_only() { + use super::super::locator; + + let args = Args { + rpc_url: None, + rpc_headers: Vec::new(), + network_passphrase: Some("specified manually".to_string()), + network: None, + }; + + // The strict resolver used by RPC commands must keep rejecting a + // passphrase-only invocation. + let err = args + .get(&locator::Args::default()) + .expect_err("strict get() must still require an rpc-url with passphrase-only args"); + assert!(matches!(err, Error::MissingRpcUrl)); + } + #[tokio::test] async fn test_user_config_default_overrides_automatic_testnet() { use super::super::locator;