Skip to content

Commit bebe638

Browse files
NicoMoliclaude
andcommitted
fix: allow tx sign and tx hash without an RPC URL (#2455)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011WsVE7zXVYWKQkxMmhiqVV
1 parent 91c00f5 commit bebe638

5 files changed

Lines changed: 145 additions & 2 deletions

File tree

cmd/crates/soroban-test/tests/it/main.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,5 +11,6 @@ mod message;
1111
mod plugin;
1212
mod rpc_provider;
1313
mod strkey;
14+
mod tx;
1415
mod util;
1516
mod version;
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
use soroban_test::{AssertExt, TestEnv};
2+
3+
// Transaction envelope from https://github.com/stellar/stellar-cli/issues/2455.
4+
const TX_ENVELOPE: &str = "AAAAAgAAAAAnEWb4nxMsQhdnS16MqBxwItF3X/JNRkfxu8eyEQyfegAAAGQAAAAAAAAAAQAAAAAAAAAAAAAAAQAAAAAAAAABAAAAABk++lrW4HlZwaWwYdYvJPCil1ibyI/VTH6WKda2sOC+AAAAAAAAAAAAAABkAAAAAAAAAAA=";
5+
const SECRET_KEY: &str = "SAKICEVQLYWGSOJS4WW7HZJWAHZVEEBS527LHK5V4MLJALYKICQCJXMW";
6+
7+
// `tx sign` only mixes the network passphrase into the transaction hash, so it
8+
// must not require an RPC URL (regression test for #2455).
9+
#[tokio::test]
10+
async fn tx_sign_requires_only_network_passphrase() {
11+
let sandbox = &TestEnv::new();
12+
13+
let output = sandbox
14+
.new_assert_cmd("tx")
15+
.args([
16+
"sign",
17+
TX_ENVELOPE,
18+
"--network-passphrase",
19+
"specified manually",
20+
"--sign-with-key",
21+
SECRET_KEY,
22+
])
23+
.assert()
24+
.success()
25+
.stdout_as_str();
26+
27+
// A signed envelope is longer than the unsigned one it was built from.
28+
assert!(output.trim().len() > TX_ENVELOPE.len());
29+
}
30+
31+
// Same expectation for `tx hash`, which also only needs the passphrase.
32+
#[tokio::test]
33+
async fn tx_hash_requires_only_network_passphrase() {
34+
let sandbox = &TestEnv::new();
35+
36+
let output = sandbox
37+
.new_assert_cmd("tx")
38+
.args([
39+
"hash",
40+
TX_ENVELOPE,
41+
"--network-passphrase",
42+
"specified manually",
43+
])
44+
.assert()
45+
.success()
46+
.stdout_as_str();
47+
48+
let hash = output.trim();
49+
assert_eq!(hash.len(), 64, "expected a 32-byte hex hash, got: {hash}");
50+
assert!(hash.chars().all(|c| c.is_ascii_hexdigit()));
51+
}

cmd/soroban-cli/src/commands/tx/hash.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ pub struct Cmd {
2929
impl Cmd {
3030
pub fn run(&self, global_args: &global::Args) -> Result<(), Error> {
3131
let tx = super::xdr::unwrap_envelope_v1(super::xdr::tx_envelope_from_input(&self.tx_xdr)?)?;
32-
let network = &self.network.get(&global_args.locator)?;
32+
let network = &self.network.get_no_rpc(&global_args.locator)?;
3333
println!(
3434
"{}",
3535
hex::encode(transaction_hash(&tx, &network.network_passphrase)?)

cmd/soroban-cli/src/commands/tx/sign.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ impl Cmd {
4242
.sign_tx_env(
4343
&tx_env,
4444
&self.locator,
45-
&self.network.get(&self.locator)?,
45+
&self.network.get_no_rpc(&self.locator)?,
4646
global_args.quiet,
4747
None,
4848
)

cmd/soroban-cli/src/config/network.rs

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,19 @@ pub struct Args {
9898

9999
impl Args {
100100
pub fn get(&self, locator: &locator::Args) -> Result<Network, Error> {
101+
self.resolve(locator, /* require_rpc */ true)
102+
}
103+
104+
/// Resolve the network for commands that only need the passphrase
105+
/// (e.g. `tx sign`, `tx hash`) and never contact an RPC server.
106+
///
107+
/// When only a passphrase is supplied, the returned `Network` has an empty
108+
/// `rpc_url`; callers of this method MUST NOT use `rpc_client()`.
109+
pub fn get_no_rpc(&self, locator: &locator::Args) -> Result<Network, Error> {
110+
self.resolve(locator, /* require_rpc */ false)
111+
}
112+
113+
fn resolve(&self, locator: &locator::Args, require_rpc: bool) -> Result<Network, Error> {
101114
match (
102115
self.network.as_deref(),
103116
self.rpc_url.clone(),
@@ -108,6 +121,13 @@ impl Args {
108121
Ok(DEFAULTS.get(DEFAULT_NETWORK_KEY).unwrap().into())
109122
}
110123
(_, Some(_), None) => Err(Error::MissingNetworkPassphrase),
124+
// Signing-only commands don't need an RPC URL, so accept a
125+
// passphrase on its own and leave `rpc_url` empty.
126+
(_, None, Some(network_passphrase)) if !require_rpc => Ok(Network {
127+
rpc_url: String::new(),
128+
rpc_headers: Vec::new(),
129+
network_passphrase,
130+
}),
111131
(_, None, Some(_)) => Err(Error::MissingRpcUrl),
112132
(Some(network), None, None) => Ok(locator.read_network(network)?),
113133
(_, Some(rpc_url), Some(network_passphrase)) => {
@@ -549,6 +569,77 @@ mod tests {
549569
assert_eq!(network.rpc_url, "https://soroban-testnet.stellar.org");
550570
}
551571

572+
#[test]
573+
fn test_get_no_rpc_accepts_passphrase_only() {
574+
use super::super::locator;
575+
576+
let args = Args {
577+
rpc_url: None,
578+
rpc_headers: Vec::new(),
579+
network_passphrase: Some("specified manually".to_string()),
580+
network: None,
581+
};
582+
583+
let network = args
584+
.get_no_rpc(&locator::Args::default())
585+
.expect("passphrase-only network should resolve for signing-only commands");
586+
assert_eq!(network.network_passphrase, "specified manually");
587+
assert_eq!(network.rpc_url, "");
588+
assert!(network.rpc_headers.is_empty());
589+
}
590+
591+
#[test]
592+
fn test_get_no_rpc_still_requires_passphrase_when_rpc_given() {
593+
use super::super::locator;
594+
595+
let args = Args {
596+
rpc_url: Some("https://example.com".to_string()),
597+
rpc_headers: Vec::new(),
598+
network_passphrase: None,
599+
network: None,
600+
};
601+
602+
let err = args.get_no_rpc(&locator::Args::default()).expect_err(
603+
"rpc without passphrase should still error, even for signing-only commands",
604+
);
605+
assert!(matches!(err, Error::MissingNetworkPassphrase));
606+
}
607+
608+
#[test]
609+
fn test_get_no_rpc_preserves_rpc_url_when_both_given() {
610+
use super::super::locator;
611+
612+
let args = Args {
613+
rpc_url: Some("https://example.com".to_string()),
614+
rpc_headers: Vec::new(),
615+
network_passphrase: Some("specified manually".to_string()),
616+
network: None,
617+
};
618+
619+
let network = args.get_no_rpc(&locator::Args::default()).unwrap();
620+
assert_eq!(network.rpc_url, "https://example.com");
621+
assert_eq!(network.network_passphrase, "specified manually");
622+
}
623+
624+
#[test]
625+
fn test_get_strict_still_requires_rpc_url_with_passphrase_only() {
626+
use super::super::locator;
627+
628+
let args = Args {
629+
rpc_url: None,
630+
rpc_headers: Vec::new(),
631+
network_passphrase: Some("specified manually".to_string()),
632+
network: None,
633+
};
634+
635+
// The strict resolver used by RPC commands must keep rejecting a
636+
// passphrase-only invocation.
637+
let err = args
638+
.get(&locator::Args::default())
639+
.expect_err("strict get() must still require an rpc-url with passphrase-only args");
640+
assert!(matches!(err, Error::MissingRpcUrl));
641+
}
642+
552643
#[tokio::test]
553644
async fn test_user_config_default_overrides_automatic_testnet() {
554645
use super::super::locator;

0 commit comments

Comments
 (0)