diff --git a/Cargo.lock b/Cargo.lock index c0cfd55bdc..3daee9538e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -852,9 +852,9 @@ checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" [[package]] name = "bytes" -version = "1.11.1" +version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" +checksum = "d71b6127be86fdcfddb610f7182ac57211d4b18a3e9c82eb2d17662f2227ad6a" [[package]] name = "bytes-lit" @@ -3370,9 +3370,9 @@ dependencies = [ [[package]] name = "num-conv" -version = "0.2.0" +version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf97ec579c3c42f953ef76dbf8d55ac91fb219dde70e49aa4a6b7d74e9919050" +checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9" [[package]] name = "num-derive" @@ -5460,6 +5460,34 @@ dependencies = [ "tokio", ] +[[package]] +name = "stellar-sign-auth-multisig" +version = "25.1.0" +dependencies = [ + "clap", + "ed25519-dalek", + "hex", + "serde_json", + "sha2 0.10.9", + "stellar-strkey 0.0.15", + "stellar-xdr", + "thiserror 1.0.69", +] + +[[package]] +name = "stellar-signer-multisig" +version = "25.1.0" +dependencies = [ + "ed25519-dalek", + "hex", + "serde", + "serde_json", + "sha2 0.10.9", + "stellar-strkey 0.0.15", + "stellar-xdr", + "thiserror 1.0.69", +] + [[package]] name = "stellar-strkey" version = "0.0.8" @@ -5910,30 +5938,29 @@ dependencies = [ [[package]] name = "time" -version = "0.3.47" +version = "0.3.43" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" +checksum = "83bde6f1ec10e72d583d91623c939f623002284ef622b87de38cfd546cbf2031" dependencies = [ "deranged", - "itoa", "num-conv", "powerfmt", - "serde_core", + "serde", "time-core", "time-macros", ] [[package]] name = "time-core" -version = "0.1.8" +version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" +checksum = "40868e7c1d2f0b8d73e4a8c7f0ff63af4f6d19be117e90bd73eb1d62cf831c6b" [[package]] name = "time-macros" -version = "0.2.27" +version = "0.2.24" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215" +checksum = "30cfb0125f12d9c277f35663a0a33f8c30190f4e4574868a330595412d34ebf3" dependencies = [ "num-conv", "time-core", diff --git a/Makefile b/Makefile index a84cf70f22..b83b04ca79 100644 --- a/Makefile +++ b/Makefile @@ -85,5 +85,11 @@ typescript-bindings-fixtures: build-test-wasms --overwrite +use-signing-plugin: + cargo install --force --locked --path ./cmd/crates/stellar-multisig-plugin --debug + +use-old-signing-plugin: + cargo install --force --locked --path ./cmd/crates/stellar-old-multisig-plugin --debug + # PHONY lists all the targets that aren't file names, so that make would skip the timestamp based check. -.PHONY: publish clean fmt watch check rpc-test test build-test-wasms install build build-snapshot typescript-bindings-fixtures +.PHONY: publish clean fmt watch check rpc-test test build-test-wasms install build build-snapshot typescript-bindings-fixtures use-signing-plugin use-old-signing-plugin diff --git a/cmd/crates/soroban-test/src/lib.rs b/cmd/crates/soroban-test/src/lib.rs index 259f606a54..f797a30a46 100644 --- a/cmd/crates/soroban-test/src/lib.rs +++ b/cmd/crates/soroban-test/src/lib.rs @@ -281,6 +281,8 @@ impl TestEnv { hd_path: None, sign_with_lab: false, sign_with_ledger: false, + plugin_arg: vec![], + sign_with_plugin: vec![], }, fee: None, inclusion_fee: None, diff --git a/cmd/crates/soroban-test/tests/it/integration.rs b/cmd/crates/soroban-test/tests/it/integration.rs index 8e5cf0b9fb..7f749a7674 100644 --- a/cmd/crates/soroban-test/tests/it/integration.rs +++ b/cmd/crates/soroban-test/tests/it/integration.rs @@ -5,8 +5,7 @@ mod contract; mod cookbook; mod custom_types; mod dotenv; -mod fee_args; -mod fee_stats; +mod fees; mod hello_world; mod init; mod keys; diff --git a/cmd/crates/soroban-test/tests/it/integration/fee_stats.rs b/cmd/crates/soroban-test/tests/it/integration/fee_stats.rs deleted file mode 100644 index 61d606703b..0000000000 --- a/cmd/crates/soroban-test/tests/it/integration/fee_stats.rs +++ /dev/null @@ -1,32 +0,0 @@ -use soroban_rpc::GetFeeStatsResponse; -use soroban_test::{AssertExt, TestEnv}; - -#[tokio::test] -async fn fee_stats_text_output() { - let sandbox = &TestEnv::new(); - sandbox - .new_assert_cmd("fees") - .arg("stats") - .arg("--output") - .arg("text") - .assert() - .success() - .stdout(predicates::str::contains("Max Soroban Inclusion Fee:")) - .stdout(predicates::str::contains("Max Inclusion Fee:")) - .stdout(predicates::str::contains("Latest Ledger:")); -} - -#[tokio::test] -async fn fee_stats_json_output() { - let sandbox = &TestEnv::new(); - let output = sandbox - .new_assert_cmd("fees") - .arg("stats") - .arg("--output") - .arg("json") - .assert() - .success() - .stdout_as_str(); - let fee_stats_response: GetFeeStatsResponse = serde_json::from_str(&output).unwrap(); - assert!(matches!(fee_stats_response, GetFeeStatsResponse { .. })) -} diff --git a/cmd/crates/soroban-test/tests/it/integration/fee_args.rs b/cmd/crates/soroban-test/tests/it/integration/fees.rs similarity index 62% rename from cmd/crates/soroban-test/tests/it/integration/fee_args.rs rename to cmd/crates/soroban-test/tests/it/integration/fees.rs index 76d249b1d0..ecc76e8805 100644 --- a/cmd/crates/soroban-test/tests/it/integration/fee_args.rs +++ b/cmd/crates/soroban-test/tests/it/integration/fees.rs @@ -1,8 +1,9 @@ use predicates::prelude::predicate; use soroban_cli::xdr::{self, Limits, ReadXdr}; +use soroban_rpc::GetFeeStatsResponse; use soroban_test::{AssertExt, TestEnv}; -use super::util::deploy_hello; +use super::util::{deploy_hello, HELLO_WORLD}; fn get_inclusion_fee_from_xdr(tx_xdr: &str) -> u32 { let tx = xdr::TransactionEnvelope::from_xdr_base64(tx_xdr, Limits::none()).unwrap(); @@ -13,6 +14,36 @@ fn get_inclusion_fee_from_xdr(tx_xdr: &str) -> u32 { } } +#[tokio::test] +async fn fee_stats_text_output() { + let sandbox = &TestEnv::new(); + sandbox + .new_assert_cmd("fees") + .arg("stats") + .arg("--output") + .arg("text") + .assert() + .success() + .stdout(predicates::str::contains("Max Soroban Inclusion Fee:")) + .stdout(predicates::str::contains("Max Inclusion Fee:")) + .stdout(predicates::str::contains("Latest Ledger:")); +} + +#[tokio::test] +async fn fee_stats_json_output() { + let sandbox = &TestEnv::new(); + let output = sandbox + .new_assert_cmd("fees") + .arg("stats") + .arg("--output") + .arg("json") + .assert() + .success() + .stdout_as_str(); + let fee_stats_response: GetFeeStatsResponse = serde_json::from_str(&output).unwrap(); + assert!(matches!(fee_stats_response, GetFeeStatsResponse { .. })) +} + #[tokio::test] async fn inclusion_fee_arg() { let sandbox = &TestEnv::new(); @@ -138,3 +169,45 @@ async fn inclusion_fee_arg() { .stdout_as_str(); assert_eq!(get_inclusion_fee_from_xdr(&tx_xdr), 100u32); } + +#[tokio::test] +async fn large_fee_transactions_use_fee_bump() { + let sandbox = &TestEnv::new(); + + // install HELLO_WORLD + // don't test fee bump here as other integration tests upload WASMs, so this + // might be a no-op + let wasm_hash = sandbox + .new_assert_cmd("contract") + .arg("upload") + .arg("--wasm") + .arg(HELLO_WORLD.path().to_string_lossy().to_string()) + .assert() + .success() + .stdout_as_str(); + + // deploy HELLO_WORLD with a high inclusion fee to trigger fee-bump wrapping + let id = sandbox + .new_assert_cmd("contract") + .arg("deploy") + .args(["--wasm-hash", wasm_hash.trim()]) + .args(["--inclusion-fee", &(u32::MAX - 50).to_string()]) + .assert() + .success() + .stdout_as_str(); + + // invoke HELLO_WORLD with a high resource fee to trigger fee-bump wrapping + let std_err = sandbox + .new_assert_cmd("contract") + .arg("invoke") + .args(["--id", &id.to_string()]) + .args(["--resource-fee", &(u64::from(u32::MAX) + 1).to_string()]) + .arg("--") + .arg("inc") + .assert() + .success() + .stderr_as_str(); + + // validate log output indicates fee bump was used + assert!(std_err.contains("Signing fee bump transaction")); +} diff --git a/cmd/crates/stellar-multisig-plugin/Cargo.toml b/cmd/crates/stellar-multisig-plugin/Cargo.toml new file mode 100644 index 0000000000..a362c23c3f --- /dev/null +++ b/cmd/crates/stellar-multisig-plugin/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "stellar-signer-multisig" +version.workspace = true +rust-version.workspace = true +edition = "2021" +publish = false + +[dependencies] +stellar-xdr = { workspace = true, features = ["curr", "std", "serde", "base64"] } +stellar-strkey = { workspace = true } +ed25519-dalek = { workspace = true } +sha2 = { workspace = true } +hex = { workspace = true } +serde_json = { workspace = true } +serde = { workspace = true, features = ["derive"] } +thiserror = { workspace = true } diff --git a/cmd/crates/stellar-multisig-plugin/src/main.rs b/cmd/crates/stellar-multisig-plugin/src/main.rs new file mode 100644 index 0000000000..73aa33b02a --- /dev/null +++ b/cmd/crates/stellar-multisig-plugin/src/main.rs @@ -0,0 +1,293 @@ +use std::collections::HashMap; +use std::io::Read; +use std::process; + +use ed25519_dalek::{Signer, SigningKey, VerifyingKey}; +use serde::Deserialize; +use sha2::{Digest, Sha256}; +use stellar_strkey::Strkey; +use stellar_xdr::curr::{ + DecoratedSignature, Hash, HashIdPreimage, HashIdPreimageSorobanAuthorization, Limits, + ReadXdr as _, ScMap, ScSymbol, ScVal, Signature, SignatureHint, SorobanAuthorizedInvocation, + TransactionEnvelope, TransactionSignaturePayload, TransactionSignaturePayloadTaggedTransaction, + WriteXdr as _, +}; + +#[derive(thiserror::Error, Debug)] +enum Error { + #[error("No signers provided. Supply via --plugin-arg multisig:signers=S...,S... or set STELLAR_MULTISIG_SIGNERS")] + NoSigners, + #[error("Unknown mode: {0}. Expected 'sign_auth' or 'sign_tx'")] + UnknownMode(String), + #[error( + "Payload validation failed: recomputed hash {computed} does not match provided {provided}" + )] + PayloadMismatch { computed: String, provided: String }, + #[error("Invalid secret key '{key}': {details}")] + InvalidSecretKey { key: String, details: String }, + #[error(transparent)] + Xdr(#[from] stellar_xdr::curr::Error), + #[error(transparent)] + Json(#[from] serde_json::Error), + #[error(transparent)] + Hex(#[from] hex::FromHexError), + #[error(transparent)] + Io(#[from] std::io::Error), + #[error(transparent)] + Ed25519(#[from] ed25519_dalek::SignatureError), + #[error(transparent)] + Strkey(#[from] stellar_strkey::DecodeError), +} + +#[derive(Deserialize)] +struct PluginInput { + mode: String, + #[serde(default)] + args: HashMap, + + // sign_auth fields + #[serde(default)] + payload: Option, + #[serde(default)] + network_passphrase: Option, + #[serde(default)] + nonce: Option, + #[serde(default)] + signature_expiration_ledger: Option, + #[serde(default)] + root_invocation: Option, + + // sign_tx fields + #[serde(default)] + tx_env_xdr: Option, + #[serde(default)] + tx_hash: Option, +} + +fn main() { + if let Err(e) = run() { + eprintln!("stellar-signer-multisig: {e}"); + process::exit(1); + } +} + +fn run() -> Result<(), Error> { + let mut input_buf = String::new(); + std::io::stdin().read_to_string(&mut input_buf)?; + let input: PluginInput = serde_json::from_str(&input_buf)?; + + let signing_keys = resolve_signers(&input.args)?; + + match input.mode.as_str() { + "sign_auth" => handle_sign_auth(&input, &signing_keys), + "sign_tx" => handle_sign_tx(&input, &signing_keys), + other => Err(Error::UnknownMode(other.to_string())), + } +} + +/// Resolve signing keys from plugin args or environment variable. +/// +/// Checks `args["signers"]` first, then `STELLAR_MULTISIG_SIGNERS` env var. +/// The value is a comma-separated list of Stellar secret keys (S...). +fn resolve_signers(args: &HashMap) -> Result, Error> { + let signers_str = args + .get("signers") + .cloned() + .or_else(|| std::env::var("STELLAR_MULTISIG_SIGNERS").ok()) + .unwrap_or_default(); + + if signers_str.is_empty() { + return Err(Error::NoSigners); + } + + signers_str + .split(',') + .map(|s| { + let s = s.trim(); + match Strkey::from_string(s) { + Ok(Strkey::PrivateKeyEd25519(secret)) => Ok(SigningKey::from_bytes(&secret.0)), + Ok(_) => Err(Error::InvalidSecretKey { + key: s.to_string(), + details: "Not a secret key (S...). Provide ed25519 secret keys.".to_string(), + }), + Err(e) => Err(Error::InvalidSecretKey { + key: s.to_string(), + details: e.to_string(), + }), + } + }) + .collect() +} + +// ── sign_auth ────────────────────────────────────────────────────────────── + +fn handle_sign_auth(input: &PluginInput, signing_keys: &[SigningKey]) -> Result<(), Error> { + let payload_hex = input + .payload + .as_deref() + .expect("sign_auth requires 'payload'"); + let network_passphrase = input + .network_passphrase + .as_deref() + .expect("sign_auth requires 'network_passphrase'"); + let nonce = input.nonce.expect("sign_auth requires 'nonce'"); + let signature_expiration_ledger = input + .signature_expiration_ledger + .expect("sign_auth requires 'signature_expiration_ledger'"); + let root_invocation_b64 = input + .root_invocation + .as_deref() + .expect("sign_auth requires 'root_invocation'"); + + // Decode root invocation from base64 XDR + let root_invocation = + SorobanAuthorizedInvocation::from_xdr_base64(root_invocation_b64, Limits::none())?; + + // Recompute the preimage hash and validate against the provided payload + let network_id = Hash(Sha256::digest(network_passphrase.as_bytes()).into()); + let preimage = HashIdPreimage::SorobanAuthorization(HashIdPreimageSorobanAuthorization { + network_id, + invocation: root_invocation, + nonce, + signature_expiration_ledger, + }) + .to_xdr(Limits::none())?; + + let computed_hash = Sha256::digest(preimage); + let computed_hex = hex::encode(computed_hash); + + if computed_hex != payload_hex { + return Err(Error::PayloadMismatch { + computed: computed_hex, + provided: payload_hex.to_string(), + }); + } + + let payload: [u8; 32] = computed_hash.into(); + + // Sort signing keys by public key bytes (ascending) — Stellar contracts + // require the signature entries to be ordered by public key. + let mut sorted_keys: Vec<&SigningKey> = signing_keys.iter().collect(); + sorted_keys.sort_by_key(|k| k.verifying_key().to_bytes()); + + // Sign with each key and build the ScVal::Vec of Map({public_key, signature}) + let mut sig_maps: Vec = Vec::with_capacity(sorted_keys.len()); + for key in sorted_keys { + let sig = key.sign(&payload); + let verifying_key = key.verifying_key(); + + let map = ScMap::sorted_from(vec![ + ( + ScVal::Symbol(ScSymbol("public_key".try_into()?)), + ScVal::Bytes( + verifying_key + .to_bytes() + .to_vec() + .try_into() + .map_err(stellar_xdr::curr::Error::from)?, + ), + ), + ( + ScVal::Symbol(ScSymbol("signature".try_into()?)), + ScVal::Bytes( + sig.to_bytes() + .to_vec() + .try_into() + .map_err(stellar_xdr::curr::Error::from)?, + ), + ), + ]) + .map_err(stellar_xdr::curr::Error::from)?; + sig_maps.push(ScVal::Map(Some(map))); + } + + let result = ScVal::Vec(Some( + sig_maps + .try_into() + .map_err(stellar_xdr::curr::Error::from)?, + )); + + // Write base64 XDR ScVal to stdout + let output = result.to_xdr_base64(Limits::none())?; + print!("{output}"); + Ok(()) +} + +// ── sign_tx ──────────────────────────────────────────────────────────────── + +fn handle_sign_tx(input: &PluginInput, signing_keys: &[SigningKey]) -> Result<(), Error> { + let tx_env_xdr_b64 = input + .tx_env_xdr + .as_deref() + .expect("sign_tx requires 'tx_env_xdr'"); + let tx_hash_hex = input + .tx_hash + .as_deref() + .expect("sign_tx requires 'tx_hash'"); + let network_passphrase = input + .network_passphrase + .as_deref() + .expect("sign_tx requires 'network_passphrase'"); + + // Decode the TransactionEnvelope and extract the inner transaction + let tx_env = TransactionEnvelope::from_xdr_base64(tx_env_xdr_b64, Limits::none())?; + + // Build the correct TransactionSignaturePayload based on envelope type + let network_id = Hash(Sha256::digest(network_passphrase.as_bytes()).into()); + let tagged_transaction = match &tx_env { + TransactionEnvelope::Tx(v1) => { + TransactionSignaturePayloadTaggedTransaction::Tx(v1.tx.clone()) + } + TransactionEnvelope::TxFeeBump(fb) => { + TransactionSignaturePayloadTaggedTransaction::TxFeeBump(fb.tx.clone()) + } + TransactionEnvelope::TxV0(_) => { + return Err(Error::UnknownMode( + "V0 transaction envelopes are not supported".to_string(), + )); + } + }; + let sig_payload = TransactionSignaturePayload { + network_id, + tagged_transaction, + }; + let preimage = sig_payload.to_xdr(Limits::none())?; + let computed_hash = Sha256::digest(preimage); + let computed_hex = hex::encode(computed_hash); + + if computed_hex != tx_hash_hex { + return Err(Error::PayloadMismatch { + computed: computed_hex, + provided: tx_hash_hex.to_string(), + }); + } + + let hash_bytes: [u8; 32] = computed_hash.into(); + + // Sign with each key and build DecoratedSignature entries + let mut sigs: Vec = Vec::with_capacity(signing_keys.len()); + for key in signing_keys { + let sig = key.sign(&hash_bytes); + let decorated = build_decorated_signature(&key.verifying_key(), &sig); + let b64 = decorated.to_xdr_base64(Limits::none())?; + sigs.push(b64); + } + + // Write JSON array of base64 XDR DecoratedSignature strings to stdout + let output = serde_json::to_string(&sigs)?; + print!("{output}"); + Ok(()) +} + +fn build_decorated_signature( + verifying_key: &VerifyingKey, + signature: &ed25519_dalek::Signature, +) -> DecoratedSignature { + let key_bytes = verifying_key.to_bytes(); + let hint = SignatureHint(key_bytes[28..32].try_into().expect("4 bytes")); + let sig = Signature(signature.to_bytes().to_vec().try_into().expect("64 bytes")); + DecoratedSignature { + hint, + signature: sig, + } +} diff --git a/cmd/crates/stellar-old-multisig-plugin/Cargo.toml b/cmd/crates/stellar-old-multisig-plugin/Cargo.toml new file mode 100644 index 0000000000..50cad05716 --- /dev/null +++ b/cmd/crates/stellar-old-multisig-plugin/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "stellar-sign-auth-multisig" +version.workspace = true +rust-version.workspace = true +edition = "2021" +publish = false + +[dependencies] +stellar-xdr = { workspace = true, features = ["curr", "std", "serde", "base64"] } +stellar-strkey = { workspace = true } +ed25519-dalek = { workspace = true } +sha2 = { workspace = true } +hex = { workspace = true } +serde_json = { workspace = true } +clap = { workspace = true } +thiserror = { workspace = true } diff --git a/cmd/crates/stellar-old-multisig-plugin/src/main.rs b/cmd/crates/stellar-old-multisig-plugin/src/main.rs new file mode 100644 index 0000000000..4bae303b9d --- /dev/null +++ b/cmd/crates/stellar-old-multisig-plugin/src/main.rs @@ -0,0 +1,379 @@ +//! A traditional stellar-cli plugin that signs Soroban auth entries and/or the transaction +//! envelope for multi-sig Stellar accounts. +//! +//! This plugin is discovered as a CLI subcommand `stellar sign-auth-multisig` via the binary +//! name `stellar-sign-auth-multisig` on `$PATH`. +//! +//! ## Usage (auth entry signing) +//! +//! ```bash +//! stellar contract invoke --source feepayer --id $CONTRACT --build-only -- my_fn --arg1 val1 \ +//! | stellar tx simulate \ +//! | stellar sign-auth-multisig \ +//! --signers S...,S... \ +//! --signature-expiration-ledger 12345 \ +//! --network-passphrase "Test SDF Network ; September 2015" \ +//! | stellar tx simulate \ +//! | stellar tx sign --sign-with-key feepayer \ +//! | stellar tx send +//! ``` +//! +//! ## Usage (as source account) +//! +//! ```bash +//! stellar contract invoke --id $CONTRACT --build-only -- my_fn --arg1 val1 \ +//! | stellar tx simulate \ +//! | stellar sign-auth-multisig \ +//! --signers S...,S... \ +//! --signature-expiration-ledger 12345 \ +//! --network-passphrase "Test SDF Network ; September 2015" \ +//! --sign-tx \ +//! | stellar tx send +//! ``` +//! +//! It reads a base64 `TransactionEnvelope` from stdin, signs all `SorobanAddressCredentials` +//! auth entries with the provided keys (ordered by public key ascending), and optionally signs +//! the transaction envelope itself. The modified envelope is written to stdout. + +use std::io::{self, Read}; + +use clap::Parser; +use ed25519_dalek::{Signer, SigningKey, VerifyingKey}; +use sha2::{Digest, Sha256}; +use stellar_strkey::Strkey; +use stellar_xdr::curr::{ + DecoratedSignature, Hash, HashIdPreimage, HashIdPreimageSorobanAuthorization, Limited, Limits, + Operation, OperationBody, ReadXdr, ScMap, ScSymbol, ScVal, Signature, SignatureHint, + SorobanAddressCredentials, SorobanAuthorizationEntry, SorobanCredentials, TransactionEnvelope, + TransactionSignaturePayload, TransactionSignaturePayloadTaggedTransaction, + TransactionV1Envelope, WriteXdr, +}; + +#[derive(thiserror::Error, Debug)] +enum Error { + #[error("No signers provided. Use --signers S...,S... or set STELLAR_MULTISIG_SIGNERS")] + NoSigners, + #[error("Invalid secret key '{key}': {details}")] + InvalidSecretKey { key: String, details: String }, + #[error("Unsupported transaction envelope type (V0)")] + UnsupportedEnvelopeType, + #[error("No InvokeHostFunction operation found in transaction")] + NoInvokeHostFunction, + #[error(transparent)] + Xdr(#[from] stellar_xdr::curr::Error), + #[error(transparent)] + Io(#[from] io::Error), +} + +#[derive(Parser, Debug, Clone)] +#[command( + name = "sign-auth-multisig", + about = "Sign Soroban auth entries with multiple ed25519 keys for multi-sig accounts" +)] +struct Cli { + /// Comma-separated list of Stellar secret keys (S...) to sign with. + /// Can also be set via STELLAR_MULTISIG_SIGNERS env var. + #[arg(long, env = "STELLAR_MULTISIG_SIGNERS")] + signers: String, + + /// The ledger sequence at which the auth signatures expire. + #[arg(long)] + signature_expiration_ledger: u32, + + /// Network passphrase (defaults to testnet). + #[arg(long, default_value = "Test SDF Network ; September 2015")] + network_passphrase: String, + + /// Also sign the transaction envelope itself (for multi-sig source accounts). + #[arg(long, default_value_t = false)] + sign_tx: bool, +} + +fn main() { + if let Err(e) = run() { + eprintln!("stellar-sign-auth-multisig: {e}"); + std::process::exit(1); + } +} + +fn run() -> Result<(), Box> { + let cli = Cli::parse(); + + let signing_keys = resolve_signers(&cli.signers)?; + if signing_keys.is_empty() { + return Err(Error::NoSigners.into()); + } + + // Sort signing keys by public key bytes ascending (required by Stellar auth check) + let mut sorted_keys: Vec<&SigningKey> = signing_keys.iter().collect(); + sorted_keys.sort_by_key(|k| k.verifying_key().to_bytes()); + + let network_id = Hash(Sha256::digest(cli.network_passphrase.as_bytes()).into()); + + // Read the transaction envelope from stdin (skip whitespace) + let mut txe = TransactionEnvelope::read_xdr_base64_to_end(&mut Limited::new( + SkipWhitespace::new(io::stdin()), + Limits::none(), + ))?; + + // Sign auth entries + sign_auth_entries( + &mut txe, + &sorted_keys, + &network_id, + cli.signature_expiration_ledger, + )?; + + // Optionally sign the transaction envelope itself + if cli.sign_tx { + sign_transaction_envelope( + &mut txe, + &signing_keys, // tx signing doesn't require public key ordering + &network_id, + )?; + } + + // Output the modified transaction envelope to stdout + println!("{}", txe.to_xdr_base64(Limits::none())?); + + Ok(()) +} + +/// Parse a comma-separated list of S... secret keys into SigningKeys. +fn resolve_signers(signers_str: &str) -> Result, Error> { + if signers_str.is_empty() { + return Err(Error::NoSigners); + } + + signers_str + .split(',') + .map(|s| { + let s = s.trim(); + match Strkey::from_string(s) { + Ok(Strkey::PrivateKeyEd25519(secret)) => Ok(SigningKey::from_bytes(&secret.0)), + Ok(_) => Err(Error::InvalidSecretKey { + key: s.to_string(), + details: "Not a secret key (S...). Provide ed25519 secret keys.".to_string(), + }), + Err(e) => Err(Error::InvalidSecretKey { + key: s.to_string(), + details: e.to_string(), + }), + } + }) + .collect() +} + +/// Sign all SorobanAddressCredentials auth entries in the transaction. +fn sign_auth_entries( + txe: &mut TransactionEnvelope, + sorted_keys: &[&SigningKey], + network_id: &Hash, + signature_expiration_ledger: u32, +) -> Result<(), Box> { + let tx = match txe { + TransactionEnvelope::Tx(TransactionV1Envelope { ref mut tx, .. }) => tx, + TransactionEnvelope::TxV0(_) => return Err(Error::UnsupportedEnvelopeType.into()), + TransactionEnvelope::TxFeeBump(_) => { + // Fee bump transactions don't contain soroban operations directly + eprintln!("Warning: Fee bump transactions don't contain auth entries to sign"); + return Ok(()); + } + }; + + // VecM doesn't implement DerefMut, so we need to work with the operations + // by converting to a Vec, modifying, and converting back. + let mut ops = tx.operations.to_vec(); + let op = match ops.as_mut_slice() { + [op @ Operation { + body: OperationBody::InvokeHostFunction(_), + .. + }] => op, + _ => return Err(Error::NoInvokeHostFunction.into()), + }; + + let OperationBody::InvokeHostFunction(ref mut body) = op.body else { + return Err(Error::NoInvokeHostFunction.into()); + }; + + let mut signed_auths: Vec = Vec::with_capacity(body.auth.len()); + + for auth in body.auth.as_slice() { + let SorobanAuthorizationEntry { + credentials: SorobanCredentials::Address(ref credentials), + .. + } = auth + else { + // Not address credentials — pass through unchanged + signed_auths.push(auth.clone()); + continue; + }; + + let SorobanAddressCredentials { nonce, .. } = credentials; + + eprintln!( + "Signing auth entry:\n{}", + serde_json::to_string_pretty(&auth.root_invocation)? + ); + + // Build the payload that the network expects to be signed + let preimage = HashIdPreimage::SorobanAuthorization(HashIdPreimageSorobanAuthorization { + network_id: network_id.clone(), + nonce: *nonce, + signature_expiration_ledger, + invocation: auth.root_invocation.clone(), + }) + .to_xdr(Limits::none())?; + + let payload_hash = Sha256::digest(preimage); + eprintln!("Payload Hash: {}", hex::encode(payload_hash)); + + let payload: [u8; 32] = payload_hash.into(); + + // Sign with each key (sorted by public key) and build the credential signature + let mut sig_maps: Vec = Vec::with_capacity(sorted_keys.len()); + for key in sorted_keys { + let sig = key.sign(&payload); + let verifying_key = key.verifying_key(); + + let map = ScMap::sorted_from(vec![ + ( + ScVal::Symbol(ScSymbol("public_key".try_into()?)), + ScVal::Bytes( + verifying_key + .to_bytes() + .to_vec() + .try_into() + .map_err(stellar_xdr::curr::Error::from)?, + ), + ), + ( + ScVal::Symbol(ScSymbol("signature".try_into()?)), + ScVal::Bytes( + sig.to_bytes() + .to_vec() + .try_into() + .map_err(stellar_xdr::curr::Error::from)?, + ), + ), + ]) + .map_err(stellar_xdr::curr::Error::from)?; + sig_maps.push(ScVal::Map(Some(map))); + } + + let signature_scval = ScVal::Vec(Some( + sig_maps + .try_into() + .map_err(stellar_xdr::curr::Error::from)?, + )); + + // Reassemble the auth entry with the new signature + let mut signed_auth = auth.clone(); + if let SorobanCredentials::Address(ref mut creds) = signed_auth.credentials { + creds.signature_expiration_ledger = signature_expiration_ledger; + creds.signature = signature_scval; + } + + eprintln!( + "Authorized:\n{}", + serde_json::to_string_pretty(&signed_auth.credentials)? + ); + + signed_auths.push(signed_auth); + } + + body.auth = signed_auths.try_into()?; + tx.operations = ops.try_into()?; + Ok(()) +} + +/// Sign the transaction envelope itself with all provided keys. +fn sign_transaction_envelope( + txe: &mut TransactionEnvelope, + signing_keys: &[SigningKey], + network_id: &Hash, +) -> Result<(), Box> { + let tagged_transaction = match txe { + TransactionEnvelope::Tx(TransactionV1Envelope { ref tx, .. }) => { + TransactionSignaturePayloadTaggedTransaction::Tx(tx.clone()) + } + TransactionEnvelope::TxFeeBump(ref fb) => { + TransactionSignaturePayloadTaggedTransaction::TxFeeBump(fb.tx.clone()) + } + TransactionEnvelope::TxV0(_) => return Err(Error::UnsupportedEnvelopeType.into()), + }; + + let sig_payload = TransactionSignaturePayload { + network_id: network_id.clone(), + tagged_transaction, + }; + let preimage = sig_payload.to_xdr(Limits::none())?; + let tx_hash = Sha256::digest(preimage); + let hash_bytes: [u8; 32] = tx_hash.into(); + + eprintln!("Signing transaction: {}", hex::encode(hash_bytes)); + + let mut new_sigs: Vec = Vec::with_capacity(signing_keys.len()); + for key in signing_keys { + let sig = key.sign(&hash_bytes); + new_sigs.push(build_decorated_signature(&key.verifying_key(), &sig)); + } + + // Append signatures to the envelope + match txe { + TransactionEnvelope::Tx(TransactionV1Envelope { + ref mut signatures, .. + }) => { + let mut sigs = signatures.clone().into_vec(); + sigs.extend(new_sigs); + *signatures = sigs.try_into()?; + } + TransactionEnvelope::TxFeeBump(ref mut fb) => { + let mut sigs = fb.signatures.clone().into_vec(); + sigs.extend(new_sigs); + fb.signatures = sigs.try_into()?; + } + TransactionEnvelope::TxV0(_) => return Err(Error::UnsupportedEnvelopeType.into()), + } + + Ok(()) +} + +fn build_decorated_signature( + verifying_key: &VerifyingKey, + signature: &ed25519_dalek::Signature, +) -> DecoratedSignature { + let key_bytes = verifying_key.to_bytes(); + let hint = SignatureHint(key_bytes[28..32].try_into().expect("4 bytes")); + let sig = Signature(signature.to_bytes().to_vec().try_into().expect("64 bytes")); + DecoratedSignature { + hint, + signature: sig, + } +} + +/// A wrapper around a `Read` that strips ASCII whitespace, allowing base64 XDR +/// to be read from stdin even when piped with newlines. +struct SkipWhitespace { + inner: R, +} + +impl SkipWhitespace { + fn new(inner: R) -> Self { + SkipWhitespace { inner } + } +} + +impl Read for SkipWhitespace { + fn read(&mut self, buf: &mut [u8]) -> io::Result { + let n = self.inner.read(buf)?; + let mut written = 0; + for read in 0..n { + if !buf[read].is_ascii_whitespace() { + buf[written] = buf[read]; + written += 1; + } + } + Ok(written) + } +} diff --git a/cmd/soroban-cli/src/assembled.rs b/cmd/soroban-cli/src/assembled.rs index 02097e8138..d441529e02 100644 --- a/cmd/soroban-cli/src/assembled.rs +++ b/cmd/soroban-cli/src/assembled.rs @@ -1,15 +1,12 @@ use sha2::{Digest, Sha256}; use stellar_xdr::curr::{ - self as xdr, ExtensionPoint, Hash, InvokeHostFunctionOp, LedgerFootprint, Limits, Memo, - Operation, OperationBody, Preconditions, ReadXdr, RestoreFootprintOp, - SorobanAuthorizationEntry, SorobanAuthorizedFunction, SorobanResources, SorobanTransactionData, - Transaction, TransactionEnvelope, TransactionExt, TransactionSignaturePayload, + self as xdr, Hash, LedgerFootprint, Limits, OperationBody, ReadXdr, SorobanAuthorizationEntry, + SorobanAuthorizedFunction, SorobanResources, SorobanTransactionData, Transaction, + TransactionEnvelope, TransactionExt, TransactionSignaturePayload, TransactionSignaturePayloadTaggedTransaction, TransactionV1Envelope, VecM, WriteXdr, }; -use soroban_rpc::{ - Error, LogEvents, LogResources, ResourceConfig, RestorePreamble, SimulateTransactionResponse, -}; +use soroban_rpc::{Error, LogEvents, LogResources, ResourceConfig, SimulateTransactionResponse}; pub async fn simulate_and_assemble_transaction( client: &soroban_rpc::Client, @@ -43,6 +40,7 @@ pub async fn simulate_and_assemble_transaction( pub struct Assembled { pub(crate) txn: Transaction, pub(crate) sim_res: SimulateTransactionResponse, + pub(crate) fee_bump_fee: Option, } /// Represents an assembled transaction ready to be signed and submitted to the network. @@ -64,8 +62,7 @@ impl Assembled { sim_res: SimulateTransactionResponse, resource_fee: Option, ) -> Result { - let txn = assemble(txn, &sim_res, resource_fee)?; - Ok(Self { txn, sim_res }) + assemble(txn, sim_res, resource_fee) } /// @@ -86,17 +83,6 @@ impl Assembled { Ok(Sha256::digest(signature_payload.to_xdr(Limits::none())?).into()) } - /// Create a transaction for restoring any data in the `restore_preamble` field of the `SimulateTransactionResponse`. - /// - /// # Errors - pub fn restore_txn(&self) -> Result, Error> { - if let Some(restore_preamble) = &self.sim_res.restore_preamble { - restore(self.transaction(), restore_preamble).map(Option::Some) - } else { - Ok(None) - } - } - /// Returns a reference to the original transaction. #[must_use] pub fn transaction(&self) -> &Transaction { @@ -109,6 +95,11 @@ impl Assembled { &self.sim_res } + #[must_use] + pub fn fee_bump_fee(&self) -> Option { + self.fee_bump_fee + } + #[must_use] pub fn bump_seq_num(mut self) -> Self { self.txn.seq_num.0 += 1; @@ -156,11 +147,6 @@ impl Assembled { Ok(()) } - #[must_use] - pub fn requires_auth(&self) -> bool { - requires_auth(&self.txn).is_some() - } - #[must_use] pub fn is_view(&self) -> bool { let TransactionExt::V1(SorobanTransactionData { @@ -202,9 +188,9 @@ impl Assembled { /// # Errors fn assemble( raw: &Transaction, - simulation: &SimulateTransactionResponse, + simulation: SimulateTransactionResponse, resource_fee: Option, -) -> Result { +) -> Result { let mut tx = raw.clone(); // Right now simulate.results is one-result-per-function, and assumes there is only one @@ -268,50 +254,25 @@ fn assemble( // Update the transaction fee to be the sum of the inclusion fee and the // minimum resource fee from simulation. let total_fee: u64 = u64::from(raw.fee) + min_resource_fee; - tx.fee = u32::try_from(total_fee).map_err(|_| Error::LargeFee(total_fee))?; + let mut fee_bump_fee: Option = None; + if let Ok(tx_fee) = u32::try_from(total_fee) { + tx.fee = tx_fee; + } else { + // Transaction needs a fee bump wrapper. Set the fee to 0 and assign the required fee + // to the fee_bump_fee field, which will be used later when constructing the FeeBumpTransaction. + // => fee_bump_fee = 2 * inclusion_fee + resource_fee + tx.fee = 0; + let fee_bump_fee_u64 = total_fee + u64::from(raw.fee); + fee_bump_fee = + Some(i64::try_from(fee_bump_fee_u64).map_err(|_| Error::LargeFee(fee_bump_fee_u64))?); + } tx.operations = vec![op].try_into()?; tx.ext = TransactionExt::V1(transaction_data); - Ok(tx) -} - -fn requires_auth(txn: &Transaction) -> Option { - let [op @ Operation { - body: OperationBody::InvokeHostFunction(InvokeHostFunctionOp { auth, .. }), - .. - }] = txn.operations.as_slice() - else { - return None; - }; - matches!( - auth.first().map(|x| &x.root_invocation.function), - Some(&SorobanAuthorizedFunction::ContractFn(_)) - ) - .then(move || op.clone()) -} - -fn restore(parent: &Transaction, restore: &RestorePreamble) -> Result { - let transaction_data = - SorobanTransactionData::from_xdr_base64(&restore.transaction_data, Limits::none())?; - let fee = u32::try_from(restore.min_resource_fee) - .map_err(|_| Error::LargeFee(restore.min_resource_fee))?; - Ok(Transaction { - source_account: parent.source_account.clone(), - fee: parent - .fee - .checked_add(fee) - .ok_or(Error::LargeFee(restore.min_resource_fee))?, - seq_num: parent.seq_num.clone(), - cond: Preconditions::None, - memo: Memo::None, - operations: vec![Operation { - source_account: None, - body: OperationBody::RestoreFootprint(RestoreFootprintOp { - ext: ExtensionPoint::V0, - }), - }] - .try_into()?, - ext: TransactionExt::V1(transaction_data), + Ok(Assembled { + txn: tx, + sim_res: simulation, + fee_bump_fee, }) } @@ -412,29 +373,29 @@ mod tests { fn test_assemble_transaction_updates_tx_data_from_simulation_response() { let sim = simulation_response(); let txn = single_contract_fn_transaction(); - let Ok(result) = assemble(&txn, &sim, None) else { + let Ok(result) = assemble(&txn, sim, None) else { panic!("assemble failed"); }; // validate it auto updated the tx fees from sim response fees // since it was greater than tx.fee - assert_eq!(215, result.fee); + assert_eq!(215, result.txn.fee); // validate it updated sorobantransactiondata block in the tx ext - assert_eq!(TransactionExt::V1(transaction_data()), result.ext); + assert_eq!(TransactionExt::V1(transaction_data()), result.txn.ext); } #[test] fn test_assemble_transaction_adds_the_auth_to_the_host_function() { let sim = simulation_response(); let txn = single_contract_fn_transaction(); - let Ok(result) = assemble(&txn, &sim, None) else { + let Ok(result) = assemble(&txn, sim, None) else { panic!("assemble failed"); }; - assert_eq!(1, result.operations.len()); - let OperationBody::InvokeHostFunction(ref op) = result.operations[0].body else { - panic!("unexpected operation type: {:#?}", result.operations[0]); + assert_eq!(1, result.txn.operations.len()); + let OperationBody::InvokeHostFunction(ref op) = result.txn.operations[0].body else { + panic!("unexpected operation type: {:#?}", result.txn.operations[0]); }; assert_eq!(1, op.auth.len()); @@ -486,7 +447,7 @@ mod tests { let result = assemble( &txn, - &SimulateTransactionResponse { + SimulateTransactionResponse { min_resource_fee: 115, transaction_data: transaction_data().to_xdr_base64(Limits::none()).unwrap(), latest_ledger: 3, @@ -507,7 +468,7 @@ mod tests { let result = assemble( &txn, - &SimulateTransactionResponse { + SimulateTransactionResponse { min_resource_fee: 115, transaction_data: transaction_data().to_xdr_base64(Limits::none()).unwrap(), latest_ledger: 3, @@ -520,7 +481,8 @@ mod tests { Err(Error::UnexpectedSimulateTransactionResultSize { length }) => { assert_eq!(0, length); } - r => panic!("expected UnexpectedSimulateTransactionResultSize error, got: {r:#?}"), + Ok(_) => panic!("expected error, got success"), + Err(e) => panic!("expected UnexpectedSimulateTransactionResultSize error, got: {e:#?}"), } } @@ -530,52 +492,68 @@ mod tests { sim.min_resource_fee = 12345; let mut txn = single_contract_fn_transaction(); txn.fee = 10000; - let Ok(result) = assemble(&txn, &sim, None) else { + let Ok(result) = assemble(&txn, sim, None) else { panic!("assemble failed"); }; - assert_eq!(12345 + 10000, result.fee); + assert_eq!(12345 + 10000, result.txn.fee); + assert_eq!(None, result.fee_bump_fee); + // validate it updated sorobantransactiondata block in the tx ext let expected_tx_data = transaction_data(); - assert_eq!(TransactionExt::V1(expected_tx_data), result.ext); + assert_eq!(TransactionExt::V1(expected_tx_data), result.txn.ext); } #[test] - fn test_assemble_transaction_overflow_behavior() { - // - // Test two separate cases: + fn test_assemble_transaction_fee_bump_fee_behavior() { + // Test three separate cases: // // 1. Given a near-max (u32::MAX - 100) resource fee make sure the tx - // fee does not overflow after adding the base inclusion fee (100). + // does not require a fee bump after adding the base inclusion fee (100). // 2. Given a large resource fee that WILL exceed u32::MAX with the - // base inclusion fee, ensure the overflow is caught with an error - // rather than silently ignored. - let txn = single_contract_fn_transaction(); + // base inclusion fee, ensure the fee is set to zero and the correct + // fee_bump_fee is set on the Assembled struct. + // 3. Given a total fee over i64::MAX, ensure an error is returned. + let mut txn = single_contract_fn_transaction(); let mut response = simulation_response(); - // sanity check so these can be adjusted if the above helper changes - assert_eq!(txn.fee, 100, "modified txn.fee: update the math below"); + let inclusion_fee: u32 = 500; + let inclusion_fee_i64: i64 = i64::from(inclusion_fee); + txn.fee = inclusion_fee; // 1: wiggle room math overflows but result fits - response.min_resource_fee = (u32::MAX - 100).into(); + response.min_resource_fee = (u32::MAX - inclusion_fee).into(); - match assemble(&txn, &response, None) { - Ok(asstxn) => { - let expected = u32::MAX; - assert_eq!(asstxn.fee, expected); + match assemble(&txn, response.clone(), None) { + Ok(assembled) => { + assert_eq!(assembled.txn.fee, u32::MAX); + assert_eq!(assembled.fee_bump_fee, None); } - r => panic!("expected success, got: {r:#?}"), + Err(e) => panic!("expected success, got error: {e:#?}"), } - // 2: combo overflows, should throw - response.min_resource_fee = (u32::MAX - 99).into(); + // 2: combo over u32::MAX, should set fee to 0 and fee_bump_fee to total + response.min_resource_fee = (u32::MAX - inclusion_fee + 1).into(); + match assemble(&txn, response.clone(), None) { + Ok(assembled) => { + assert_eq!(assembled.txn.fee, 0); + assert_eq!( + assembled.fee_bump_fee, + Some(i64::try_from(response.min_resource_fee).unwrap() + inclusion_fee_i64 * 2) + ); + } + Err(e) => panic!("expected success, got error: {e:#?}"), + } - match assemble(&txn, &response, None) { + // 3: total fee exceeds i64::MAX, should error + response.min_resource_fee = u64::try_from(i64::MAX - (2 * inclusion_fee_i64) + 1).unwrap(); + match assemble(&txn, response, None) { Err(Error::LargeFee(fee)) => { - let expected = u64::from(u32::MAX) + 1; + let expected = i64::MAX as u64 + 1; assert_eq!(expected, fee, "expected {expected} != {fee} actual"); } - r => panic!("expected LargeFee error, got: {r:#?}"), + Ok(_) => panic!("expected error, got success"), + Err(e) => panic!("expected success, got error: {e:#?}"), } } @@ -585,18 +563,19 @@ mod tests { let mut txn = single_contract_fn_transaction(); txn.fee = 500; let resource_fee = 12345i64; - let Ok(result) = assemble(&txn, &sim, Some(resource_fee)) else { + let Ok(result) = assemble(&txn, sim, Some(resource_fee)) else { panic!("assemble failed"); }; // validate the assembled tx fee is the sum of the inclusion fee (txn.fee) // and the resource fee - assert_eq!(12345 + 500, result.fee); + assert_eq!(12345 + 500, result.txn.fee); + assert_eq!(None, result.fee_bump_fee); // validate it updated sorobantransactiondata block in the tx ext let mut expected_tx_data = transaction_data(); expected_tx_data.resource_fee = resource_fee; - assert_eq!(TransactionExt::V1(expected_tx_data), result.ext); + assert_eq!(TransactionExt::V1(expected_tx_data), result.txn.ext); } // This should never occur, as resource fee is validated before being passed into @@ -608,47 +587,60 @@ mod tests { let mut txn = single_contract_fn_transaction(); txn.fee = 500; let resource_fee = -1; - let result = assemble(&txn, &sim, Some(resource_fee)); + let result = assemble(&txn, sim, Some(resource_fee)); assert!(result.is_err()); } #[test] - fn test_assemble_transaction_with_resource_fee_overflow_behavior() { - // - // Test two separate cases: + fn test_assemble_transaction_with_resource_fee_fee_bump_behavior() { + // Test three separate cases: // // 1. Given a near-max (u32::MAX - 100) resource fee make sure the tx - // fee does not overflow after adding the base inclusion fee (100). + // does not require a fee bump after adding the base inclusion fee (100). // 2. Given a large resource fee that WILL exceed u32::MAX with the - // base inclusion fee, ensure the overflow is caught with an error - // rather than silently ignored. - let txn = single_contract_fn_transaction(); + // base inclusion fee, ensure the fee is set to zero and the correct + // fee_bump_fee is set on the Assembled struct. + // 3. Given a total fee over i64::MAX, ensure an error is returned. + let mut txn = single_contract_fn_transaction(); let response = simulation_response(); - // sanity check so these can be adjusted if the above helper changes - assert_eq!(txn.fee, 100, "modified txn.fee: update the math below"); + let inclusion_fee: u32 = 500; + let inclusion_fee_i64: i64 = i64::from(inclusion_fee); + txn.fee = inclusion_fee; // 1: wiggle room math overflows but result fits - let resource_fee: i64 = (u32::MAX - 100).into(); - - match assemble(&txn, &response, Some(resource_fee)) { - Ok(asstxn) => { - let expected = u32::MAX; - assert_eq!(asstxn.fee, expected); + let resource_fee: i64 = (u32::MAX - inclusion_fee).into(); + match assemble(&txn, response.clone(), Some(resource_fee)) { + Ok(assembled) => { + assert_eq!(assembled.txn.fee, u32::MAX); + assert_eq!(assembled.fee_bump_fee, None); } - r => panic!("expected success, got: {r:#?}"), + Err(e) => panic!("expected success, got error: {e:#?}"), } - // 2: combo overflows, should throw - let resource_fee: i64 = (u32::MAX - 99).into(); + // 2: combo over u32::MAX, should set fee to 0 and fee_bump_fee to total + let resource_fee: i64 = (u32::MAX - inclusion_fee + 1).into(); + match assemble(&txn, response.clone(), Some(resource_fee)) { + Ok(assembled) => { + assert_eq!(assembled.txn.fee, 0); + assert_eq!( + assembled.fee_bump_fee, + Some(resource_fee + inclusion_fee_i64 * 2) + ); + } + Err(e) => panic!("expected success, got error: {e:#?}"), + } - match assemble(&txn, &response, Some(resource_fee)) { + // 3: total fee exceeds i64::MAX, should error + let resource_fee: i64 = i64::MAX - (2 * inclusion_fee_i64) + 1; + match assemble(&txn, response, Some(resource_fee)) { Err(Error::LargeFee(fee)) => { - let expected = u64::from(u32::MAX) + 1; + let expected = i64::MAX as u64 + 1; assert_eq!(expected, fee, "expected {expected} != {fee} actual"); } - r => panic!("expected LargeFee error, got: {r:#?}"), + Ok(_) => panic!("expected error, got success"), + Err(e) => panic!("expected success, got error: {e:#?}"), } } } diff --git a/cmd/soroban-cli/src/commands/contract/deploy/asset.rs b/cmd/soroban-cli/src/commands/contract/deploy/asset.rs index 0864952dcd..dfd8088229 100644 --- a/cmd/soroban-cli/src/commands/contract/deploy/asset.rs +++ b/cmd/soroban-cli/src/commands/contract/deploy/asset.rs @@ -1,5 +1,6 @@ use crate::config::locator; use crate::print::Print; +use crate::tx::sim_sign_and_send_tx; use crate::xdr::{ Asset, ContractDataDurability, ContractExecutable, ContractIdPreimage, CreateContractArgs, Error as XdrError, Hash, HostFunction, InvokeHostFunctionOp, LedgerKey::ContractData, @@ -12,7 +13,6 @@ use std::{array::TryFromSliceError, fmt::Debug, num::ParseIntError}; use crate::commands::tx::fetch; use crate::{ - assembled::simulate_and_assemble_transaction, commands::{ global, txn_result::{TxnEnvelopeResult, TxnResult}, @@ -168,25 +168,9 @@ impl Cmd { return Ok(TxnResult::Txn(Box::new(tx))); } - let assembled = simulate_and_assemble_transaction( - &client, - &tx, - self.resources.resource_config(), - self.resources.resource_fee, - ) - .await?; - let assembled = self.resources.apply_to_assembled_txn(assembled); - let txn = assembled.transaction().clone(); - let get_txn_resp = client - .send_transaction_polling(&self.config.sign(txn, quiet).await?) + sim_sign_and_send_tx::(&client, &tx, config, &self.resources, &[], quiet, no_cache) .await?; - self.resources.print_cost_info(&get_txn_resp)?; - - if !no_cache { - data::write(get_txn_resp.clone().try_into()?, &network.rpc_uri()?)?; - } - Ok(TxnResult::Res(stellar_strkey::Contract(contract_id.0))) } } diff --git a/cmd/soroban-cli/src/commands/contract/deploy/wasm.rs b/cmd/soroban-cli/src/commands/contract/deploy/wasm.rs index 7bab1a3bde..d09455fbf2 100644 --- a/cmd/soroban-cli/src/commands/contract/deploy/wasm.rs +++ b/cmd/soroban-cli/src/commands/contract/deploy/wasm.rs @@ -9,6 +9,7 @@ use soroban_spec_tools::contract as contract_spec; use crate::commands::contract::deploy::utils::alias_validator; use crate::resources; +use crate::tx::sim_sign_and_send_tx; use crate::xdr::{ AccountId, ContractExecutable, ContractIdPreimage, ContractIdPreimageFromAddress, CreateContractArgs, CreateContractArgsV2, Error as XdrError, Hash, HostFunction, @@ -19,7 +20,6 @@ use crate::xdr::{ use crate::commands::tx::fetch; use crate::{ - assembled::simulate_and_assemble_transaction, commands::{ contract::{self, arg_parsing, build, id::wasm::get_contract_id, upload}, global, @@ -415,29 +415,8 @@ impl Cmd { return Ok(TxnResult::Txn(txn)); } - print.infoln("Simulating deploy transaction…"); - - let assembled = simulate_and_assemble_transaction( - &client, - &txn, - self.resources.resource_config(), - self.resources.resource_fee, - ) - .await?; - let assembled = self.resources.apply_to_assembled_txn(assembled); - let txn = Box::new(assembled.transaction().clone()); - - print.log_transaction(&txn, &network, true)?; - let signed_txn = &config.sign(*txn, quiet).await?; - print.globeln("Submitting deploy transaction…"); - - let get_txn_resp = client.send_transaction_polling(signed_txn).await?; - - self.resources.print_cost_info(&get_txn_resp)?; - - if !no_cache { - data::write(get_txn_resp.clone().try_into()?, &network.rpc_uri()?)?; - } + sim_sign_and_send_tx::(&client, &txn, config, &self.resources, &[], 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 874c7db517..3ae6aaad41 100644 --- a/cmd/soroban-cli/src/commands/contract/extend.rs +++ b/cmd/soroban-cli/src/commands/contract/extend.rs @@ -4,6 +4,7 @@ use crate::{ log::extract_events, print::Print, resources, + tx::sim_sign_and_send_tx, xdr::{ ConfigSettingEntry, ConfigSettingId, Error as XdrError, ExtendFootprintTtlOp, ExtensionPoint, LedgerEntry, LedgerEntryChange, LedgerEntryData, LedgerFootprint, @@ -17,7 +18,6 @@ use clap::Parser; use crate::commands::tx::fetch; use crate::{ - assembled::simulate_and_assemble_transaction, commands::{ global, txn_result::{TxnEnvelopeResult, TxnResult}, @@ -236,24 +236,18 @@ impl Cmd { if self.build_only { return Ok(TxnResult::Txn(tx)); } - let assembled = simulate_and_assemble_transaction( + + let res = sim_sign_and_send_tx::( &client, &tx, - self.resources.resource_config(), - self.resources.resource_fee, + config, + &self.resources, + &[], + quiet, + no_cache, ) .await?; - let tx = assembled.transaction().clone(); - let res = client - .send_transaction_polling(&config.sign(tx, quiet).await?) - .await?; - self.resources.print_cost_info(&res)?; - - if !no_cache { - data::write(res.clone().try_into()?, &network.rpc_uri()?)?; - } - let meta = res.result_meta.ok_or(Error::MissingOperationResult)?; let events = extract_events(&meta); diff --git a/cmd/soroban-cli/src/commands/contract/invoke.rs b/cmd/soroban-cli/src/commands/contract/invoke.rs index 357ef9b732..ba770acf5e 100644 --- a/cmd/soroban-cli/src/commands/contract/invoke.rs +++ b/cmd/soroban-cli/src/commands/contract/invoke.rs @@ -13,8 +13,10 @@ use super::super::events; use super::arg_parsing; use crate::assembled::Assembled; use crate::commands::tx::fetch; +use crate::config::{address, secret, UnresolvedMuxedAccount}; use crate::log::extract_events; use crate::print::Print; +use crate::tx::sim_sign_and_send_tx; use crate::utils::deprecate_message; use crate::{ assembled::simulate_and_assemble_transaction, @@ -71,6 +73,13 @@ pub struct Cmd { /// Build the transaction and only write the base64 xdr to stdout #[arg(long)] pub build_only: bool, + + /// Additional signers for authorization entries. Supplements auto-discovered signers + /// from address-type function arguments. Can be an identity name or a secret key. + /// Useful when an auth entry's address is not a visible function argument + /// (e.g., a sub-invocation authorizer). + #[arg(long)] + pub auth_signer: Vec, } impl FromStr for Cmd { @@ -161,6 +170,12 @@ pub enum Error { #[error(transparent)] Fetch(#[from] fetch::Error), + + #[error(transparent)] + Address(#[from] address::Error), + + #[error(transparent)] + Secret(#[from] secret::Error), } impl From for Error { @@ -296,7 +311,13 @@ impl Cmd { let params = build_host_function_parameters(&contract_id, &self.slop, &spec_entries, config).await?; - let (function, spec, host_function_params, signers) = params; + let (function, spec, host_function_params, mut signers) = params; + + // Add explicit --auth-signer entries to the auto-discovered signers + for auth_signer_name in &self.auth_signer { + let auth_secret = auth_signer_name.resolve_secret(&config.locator)?; + signers.push(auth_secret.signer(None, print.clone()).await?); + } // `self.build_only` will be checked again below and the fn will return a TxnResult::Txn // if the user passed the --build-only flag @@ -355,35 +376,16 @@ impl Cmd { return Ok(TxnResult::Txn(tx)); } - let txn = simulate_and_assemble_transaction( + let res = sim_sign_and_send_tx::( &client, &tx, - self.resources.resource_config(), - self.resources.resource_fee, + config, + &self.resources, + &signers, + quiet, + no_cache, ) .await?; - let assembled = self.resources.apply_to_assembled_txn(txn); - let mut txn = Box::new(assembled.transaction().clone()); - let sim_res = assembled.sim_response(); - - if !no_cache { - data::write(sim_res.clone().into(), &network.rpc_uri()?)?; - } - - // Need to sign all auth entries - if let Some(tx) = config.sign_soroban_authorizations(&txn, &signers).await? { - *txn = tx; - } - - let res = client - .send_transaction_polling(&config.sign(*txn, quiet).await?) - .await?; - - self.resources.print_cost_info(&res)?; - - if !no_cache { - data::write(res.clone().try_into()?, &network.rpc_uri()?)?; - } let return_value = res.return_value()?; let events = extract_events(&res.result_meta.unwrap_or_default()); diff --git a/cmd/soroban-cli/src/commands/contract/restore.rs b/cmd/soroban-cli/src/commands/contract/restore.rs index d39f0a626e..54d72abf10 100644 --- a/cmd/soroban-cli/src/commands/contract/restore.rs +++ b/cmd/soroban-cli/src/commands/contract/restore.rs @@ -2,6 +2,7 @@ use std::{fmt::Debug, path::Path, str::FromStr}; use crate::{ log::extract_events, + tx::sim_sign_and_send_tx, xdr::{ Error as XdrError, ExtensionPoint, LedgerEntry, LedgerEntryChange, LedgerEntryData, LedgerFootprint, Limits, Memo, Operation, OperationBody, Preconditions, RestoreFootprintOp, @@ -15,7 +16,6 @@ use stellar_strkey::DecodeError; use crate::commands::tx::fetch; use crate::{ - assembled::simulate_and_assemble_transaction, commands::{ contract::extend, global, @@ -203,22 +203,18 @@ impl Cmd { if self.build_only { return Ok(TxnResult::Txn(tx)); } - let assembled = simulate_and_assemble_transaction( + + let res = sim_sign_and_send_tx::( &client, &tx, - self.resources.resource_config(), - self.resources.resource_fee, + config, + &self.resources, + &[], + quiet, + no_cache, ) .await?; - let tx = assembled.transaction().clone(); - let res = client - .send_transaction_polling(&config.sign(tx, quiet).await?) - .await?; - self.resources.print_cost_info(&res)?; - if !no_cache { - data::write(res.clone().try_into()?, &network.rpc_uri()?)?; - } let meta = res .result_meta .as_ref() diff --git a/cmd/soroban-cli/src/commands/contract/upload.rs b/cmd/soroban-cli/src/commands/contract/upload.rs index cb5a8e5b4f..f6ef295b9d 100644 --- a/cmd/soroban-cli/src/commands/contract/upload.rs +++ b/cmd/soroban-cli/src/commands/contract/upload.rs @@ -13,7 +13,6 @@ use clap::Parser; use super::{build, restore}; use crate::commands::tx::fetch; use crate::{ - assembled::simulate_and_assemble_transaction, commands::{ global, txn_result::{TxnEnvelopeResult, TxnResult}, @@ -22,7 +21,10 @@ use crate::{ key, print::Print, rpc, - tx::builder::{self, TxExt}, + tx::{ + builder::{self, TxExt}, + sim_sign_and_send_tx, + }, utils, wasm, }; @@ -291,24 +293,16 @@ impl Cmd { print.infoln("Simulating install transaction…"); - let assembled = simulate_and_assemble_transaction( + let txn_resp = sim_sign_and_send_tx::( &client, &tx_without_preflight, - self.resources.resource_config(), - self.resources.resource_fee, + config, + &self.resources, + &[], + quiet, + no_cache, ) .await?; - let assembled = self.resources.apply_to_assembled_txn(assembled); - let txn = Box::new(assembled.transaction().clone()); - let signed_txn = &self.config.sign(*txn, quiet).await?; - - print.globeln("Submitting install transaction…"); - let txn_resp = client.send_transaction_polling(signed_txn).await?; - self.resources.print_cost_info(&txn_resp)?; - - if !no_cache { - data::write(txn_resp.clone().try_into().unwrap(), &network.rpc_uri()?)?; - } // Currently internal errors are not returned if the contract code is expired if let Some(TransactionResult { diff --git a/cmd/soroban-cli/src/commands/tx/simulate.rs b/cmd/soroban-cli/src/commands/tx/simulate.rs index 194e6cc4ba..d80e058401 100644 --- a/cmd/soroban-cli/src/commands/tx/simulate.rs +++ b/cmd/soroban-cli/src/commands/tx/simulate.rs @@ -1,5 +1,6 @@ use crate::{ assembled::{simulate_and_assemble_transaction, Assembled}, + print, xdr::{self, TransactionEnvelope, WriteXdr}, }; use std::ffi::OsString; @@ -38,14 +39,19 @@ pub struct Cmd { } impl Cmd { - pub async fn run(&self, _global_args: &global::Args) -> Result<(), Error> { - let res = self.execute(&self.config).await?; + pub async fn run(&self, global_args: &global::Args) -> Result<(), Error> { + let res = self.execute(global_args, &self.config).await?; let tx_env: TransactionEnvelope = res.transaction().clone().into(); println!("{}", tx_env.to_xdr_base64(xdr::Limits::none())?); Ok(()) } - pub async fn execute(&self, config: &config::Args) -> Result { + pub async fn execute( + &self, + global_args: &global::Args, + config: &config::Args, + ) -> Result { + let print = print::Print::new(global_args.quiet); let network = config.get_network()?; let client = network.rpc_client()?; let tx = super::xdr::unwrap_envelope_v1(super::xdr::tx_envelope_from_input(&self.tx_xdr)?)?; @@ -53,6 +59,9 @@ impl Cmd { .instruction_leeway .map(|instruction_leeway| soroban_rpc::ResourceConfig { instruction_leeway }); let tx = simulate_and_assemble_transaction(&client, &tx, resource_config, None).await?; + if let Some(fee_bump_fee) = tx.fee_bump_fee() { + print.warnln(format!("The transaction fee of {fee_bump_fee} is too large and needs to be wrapped in a fee bump transaction.")); + } Ok(tx) } } diff --git a/cmd/soroban-cli/src/config/mod.rs b/cmd/soroban-cli/src/config/mod.rs index cd86dc1324..27f7e57b31 100644 --- a/cmd/soroban-cli/src/config/mod.rs +++ b/cmd/soroban-cli/src/config/mod.rs @@ -8,7 +8,10 @@ use crate::{ print::Print, signer::{self, Signer}, utils::deprecate_message, - xdr::{self, SequenceNumber, Transaction, TransactionEnvelope, TransactionV1Envelope, VecM}, + xdr::{ + self, FeeBumpTransaction, FeeBumpTransactionEnvelope, SequenceNumber, Transaction, + TransactionEnvelope, TransactionV1Envelope, VecM, + }, Pwd, }; use network::Network; @@ -116,20 +119,44 @@ impl Args { .await?) } + pub async fn sign_fee_bump( + &self, + tx: FeeBumpTransaction, + quiet: bool, + ) -> Result { + let tx_env = TransactionEnvelope::TxFeeBump(FeeBumpTransactionEnvelope { + tx, + signatures: VecM::default(), + }); + Ok(self + .sign_with + .sign_tx_env( + &tx_env, + &self.locator, + &self.network.get(&self.locator)?, + quiet, + Some(&self.source_account), + ) + .await?) + } + pub async fn sign_soroban_authorizations( &self, tx: &Transaction, signers: &[Signer], ) -> Result, Error> { let network = self.get_network()?; - let source_signer = self.source_signer().await?; + let locator = &self.locator; let client = network.rpc_client()?; let latest_ledger = client.get_latest_ledger().await?.sequence; let seq_num = latest_ledger + 60; // ~ 5 min + let plugin_signers = self + .sign_with + .build_plugin_signers(locator, &network.network_passphrase)?; Ok(signer::sign_soroban_authorizations( tx, - &source_signer, signers, + &plugin_signers, seq_num, &network.network_passphrase, )?) diff --git a/cmd/soroban-cli/src/config/secret.rs b/cmd/soroban-cli/src/config/secret.rs index 6784f03fa1..379f53887e 100644 --- a/cmd/soroban-cli/src/config/secret.rs +++ b/cmd/soroban-cli/src/config/secret.rs @@ -150,10 +150,9 @@ impl Secret { .expect("uszie bigger than u32"); SignerKind::Ledger(ledger::new(hd_path).await?) } - Secret::SecureStore { entry_name } => SignerKind::SecureStore(SecureStoreEntry { - name: entry_name.clone(), - hd_path, - }), + Secret::SecureStore { entry_name } => { + SignerKind::SecureStore(SecureStoreEntry::new(entry_name.clone(), hd_path)?) + } }; Ok(Signer { kind, print }) } diff --git a/cmd/soroban-cli/src/config/sign_with.rs b/cmd/soroban-cli/src/config/sign_with.rs index 8fbfdab842..7b461af73e 100644 --- a/cmd/soroban-cli/src/config/sign_with.rs +++ b/cmd/soroban-cli/src/config/sign_with.rs @@ -1,7 +1,9 @@ +use std::{collections::HashMap, str::FromStr}; + use crate::{ - config::UnresolvedMuxedAccount, + config::{UnresolvedMuxedAccount, UnresolvedScAddress}, print::Print, - signer::{self, ledger, Signer, SignerKind}, + signer::{self, ledger, PluginSigner, Signer, SignerKind}, xdr::{self, TransactionEnvelope}, }; @@ -31,6 +33,12 @@ pub enum Error { Xdr(#[from] xdr::Error), #[error(transparent)] Ledger(#[from] signer::ledger::Error), + #[error("Invalid --sign-with-plugin format '{value}'. Expected 'plugin-name=address'.")] + InvalidPluginArg { value: String }, + #[error("Invalid --plugin-arg format '{value}'. Expected 'plugin-name:key=value'.")] + InvalidPluginArgExtra { value: String }, + #[error(transparent)] + UnresolvedScAddress(#[from] crate::config::sc_address::Error), } #[derive(Debug, clap::Args, Clone, Default)] @@ -57,9 +65,64 @@ pub struct Args { env = "STELLAR_SIGN_WITH_LEDGER" )] pub sign_with_ledger: bool, + + /// Sign auth entries with an external signing plugin. Format: `plugin-name=address`. + /// Maps the given address (G.../C.../M.../alias) to a plugin binary to execute signing (`stellar-signer-{plugin-name}` on PATH). + /// Can be specified multiple times to map different addresses to different plugins. + /// Example: `--sign-with-plugin multisig=CDLDY...` uses `stellar-signer-multisig` for that address. + #[arg(long, num_args = 1)] + pub sign_with_plugin: Vec, + + /// Pass extra arguments to a signing plugin. Format: `plugin-name:key=value`. + /// These are forwarded to the plugin in the `args` JSON field. + /// Can be specified multiple times. It is recommended to pass sensitive values via environment + /// variables within the plugin instead. + /// Example: `--plugin-arg multisig:signer_1=S...` + #[arg(long, num_args = 1)] + pub plugin_arg: Vec, } impl Args { + /// Parse `--sign-with-plugin` and `--plugin-arg` flags into a map of address → `PluginSigner`. + pub fn build_plugin_signers( + &self, + locator: &locator::Args, + network_passphrase: &str, + ) -> Result, Error> { + // First, collect plugin-args grouped by plugin name + let mut plugin_args: HashMap> = HashMap::new(); + for arg in &self.plugin_arg { + let (plugin_name, kv) = arg + .split_once(':') + .ok_or_else(|| Error::InvalidPluginArgExtra { value: arg.clone() })?; + let (key, value) = kv + .split_once('=') + .ok_or_else(|| Error::InvalidPluginArgExtra { value: arg.clone() })?; + plugin_args + .entry(plugin_name.to_string()) + .or_default() + .insert(key.to_string(), value.to_string()); + } + + // Then, build PluginSigner for each --sign-with-plugin entry + let mut signers: Vec = Vec::new(); + for entry in &self.sign_with_plugin { + let (plugin_name, address) = + entry + .split_once('=') + .ok_or_else(|| Error::InvalidPluginArg { + value: entry.clone(), + })?; + let unresolved_sc_address = UnresolvedScAddress::from_str(address)?; + let sc_address = unresolved_sc_address.resolve(locator, network_passphrase)?; + let args = plugin_args.remove(plugin_name).unwrap_or_default(); + let plugin = PluginSigner::new(plugin_name, sc_address, args)?; + signers.push(plugin); + } + + Ok(signers) + } + // when a default_signer_account is provided, it will be used as the tx signer if the user does not specify a signer. The default signer should be the tx's source_account. pub async fn sign_tx_env( &self, @@ -70,6 +133,41 @@ impl Args { default_signer_account: Option<&UnresolvedMuxedAccount>, ) -> Result { let print = Print::new(quiet); + + // Check if the tx source account matches an account specified in --sign-with-plugin + // If so, use the corresponding plugin signer to sign the transaction and return early. + if !self.sign_with_plugin.is_empty() { + let source_account = match tx { + TransactionEnvelope::Tx(tx) => &tx.tx.source_account, + TransactionEnvelope::TxFeeBump(fb) => &fb.tx.fee_source, + TransactionEnvelope::TxV0(tx) => { + &xdr::MuxedAccount::Ed25519(tx.tx.source_account_ed25519.clone()) + } + }; + let sc_address = match source_account { + xdr::MuxedAccount::Ed25519(ed25519) => xdr::ScAddress::Account(xdr::AccountId( + xdr::PublicKey::PublicKeyTypeEd25519(ed25519.clone()), + )), + xdr::MuxedAccount::MuxedEd25519(muxed_ed25519) => { + xdr::ScAddress::MuxedAccount(xdr::MuxedEd25519Account { + id: muxed_ed25519.id, + ed25519: muxed_ed25519.ed25519.clone(), + }) + } + }; + if let Some(plugin_signer) = self + .build_plugin_signers(locator, &network.network_passphrase)? + .into_iter() + .find(|p| p.sc_address == sc_address) + { + let signer = Signer { + kind: SignerKind::Plugin(plugin_signer), + print, + }; + return Ok(signer.sign_tx_env(tx, network).await?); + } + } + let signer = if self.sign_with_lab { Signer { kind: SignerKind::Lab, diff --git a/cmd/soroban-cli/src/resources.rs b/cmd/soroban-cli/src/resources.rs index 8945f5b6fd..d227b4dbff 100644 --- a/cmd/soroban-cli/src/resources.rs +++ b/cmd/soroban-cli/src/resources.rs @@ -11,7 +11,7 @@ use crate::commands::HEADING_RPC; #[group(skip)] pub struct Args { /// Set the fee for smart contract resource consumption, in stroops. 1 stroop = 0.0000001 xlm. Overrides the simulated resource fee - #[arg(long, env = "STELLAR_RESOURCE_FEE", value_parser = clap::value_parser!(i64).range(0..u32::MAX.into()), help_heading = HEADING_RPC)] + #[arg(long, env = "STELLAR_RESOURCE_FEE", value_parser = clap::value_parser!(i64).range(0..i64::MAX), help_heading = HEADING_RPC)] pub resource_fee: Option, /// ⚠️ Deprecated, use `--instruction-leeway` to increase instructions. Number of instructions to allocate for the transaction #[arg(long, help_heading = HEADING_RPC)] diff --git a/cmd/soroban-cli/src/signer/ledger.rs b/cmd/soroban-cli/src/signer/ledger.rs index 534cebec06..1b3427366f 100644 --- a/cmd/soroban-cli/src/signer/ledger.rs +++ b/cmd/soroban-cli/src/signer/ledger.rs @@ -68,7 +68,7 @@ mod ledger_impl { pub async fn sign_transaction_hash( &self, tx_hash: &[u8; 32], - ) -> Result { + ) -> Result, Error> { let key = self.public_key().await?; let hint = SignatureHint(key.0[28..].try_into()?); let signature = Signature( @@ -77,14 +77,14 @@ mod ledger_impl { .await? .try_into()?, ); - Ok(DecoratedSignature { hint, signature }) + Ok(vec![DecoratedSignature { hint, signature }]) } pub async fn sign_transaction( &self, tx: Transaction, network_passphrase: &str, - ) -> Result { + ) -> Result, Error> { let network_id = Hash(Sha256::digest(network_passphrase).into()); let signature = self .signer @@ -93,7 +93,7 @@ mod ledger_impl { let key = self.public_key().await?; let hint = SignatureHint(key.0[28..].try_into()?); let signature = Signature(signature.try_into()?); - Ok(DecoratedSignature { hint, signature }) + Ok(vec![DecoratedSignature { hint, signature }]) } pub async fn public_key(&self) -> Result { @@ -125,7 +125,7 @@ mod ledger_impl { pub async fn sign_transaction_hash( &self, _tx_hash: &[u8; 32], - ) -> Result { + ) -> Result, Error> { Err(Error::FeatureNotEnabled) } @@ -134,7 +134,7 @@ mod ledger_impl { &self, _tx: Transaction, _network_passphrase: &str, - ) -> Result { + ) -> Result, Error> { Err(Error::FeatureNotEnabled) } diff --git a/cmd/soroban-cli/src/signer/mod.rs b/cmd/soroban-cli/src/signer/mod.rs index a55c3a73a5..d9daba2dbb 100644 --- a/cmd/soroban-cli/src/signer/mod.rs +++ b/cmd/soroban-cli/src/signer/mod.rs @@ -1,12 +1,21 @@ -use crate::xdr::{ - self, AccountId, DecoratedSignature, Hash, HashIdPreimage, HashIdPreimageSorobanAuthorization, - InvokeHostFunctionOp, Limits, Operation, OperationBody, PublicKey, ScAddress, ScMap, ScSymbol, - ScVal, Signature, SignatureHint, SorobanAddressCredentials, SorobanAuthorizationEntry, - SorobanAuthorizedFunction, SorobanCredentials, Transaction, TransactionEnvelope, - TransactionV1Envelope, Uint256, VecM, WriteXdr, +use std::collections::HashMap; +use std::io::Write as _; +use std::path::PathBuf; +use std::process::{Command, Stdio}; + +use crate::{ + utils::fee_bump_transaction_hash, + xdr::{ + self, AccountId, DecoratedSignature, FeeBumpTransactionEnvelope, Hash, HashIdPreimage, + HashIdPreimageSorobanAuthorization, Limits, Operation, OperationBody, PublicKey, ReadXdr, + ScAddress, ScMap, ScSymbol, ScVal, Signature, SignatureHint, SorobanAddressCredentials, + SorobanAuthorizationEntry, SorobanCredentials, Transaction, TransactionEnvelope, + TransactionV1Envelope, Uint256, VecM, WriteXdr, + }, }; use ed25519_dalek::{ed25519::signature::Signer as _, Signature as Ed25519Signature}; use sha2::{Digest, Sha256}; +use which::which; use crate::{config::network::Network, print::Print, utils::transaction_hash}; @@ -30,7 +39,7 @@ pub enum Error { UserCancelledSigning, #[error(transparent)] Xdr(#[from] xdr::Error), - #[error("Only Transaction envelope V1 type is supported")] + #[error("Transaction envelope type not supported")] UnsupportedTransactionEnvelopeType, #[error(transparent)] Url(#[from] url::ParseError), @@ -44,87 +53,109 @@ pub enum Error { Ledger(#[from] ledger::Error), #[error(transparent)] Decode(#[from] stellar_strkey::DecodeError), + #[error("Signing plugin '{name}' not found on PATH. Expected binary 'stellar-signer-{name}'")] + PluginNotFound { name: String }, + #[error("Signing plugin '{name}' failed with exit code {code}")] + PluginFailed { name: String, code: i32 }, + #[error("Signing plugin '{name}' returned invalid output: {details}")] + PluginInvalidOutput { name: String, details: String }, + #[error("Signing plugin '{name}' error: {details}")] + PluginError { name: String, details: String }, + #[error(transparent)] + SerdeJson(#[from] serde_json::Error), } -fn requires_auth(txn: &Transaction) -> Option { - let [op @ Operation { - body: OperationBody::InvokeHostFunction(InvokeHostFunctionOp { auth, .. }), - .. - }] = txn.operations.as_slice() - else { - return None; - }; - matches!( - auth.first().map(|x| &x.root_invocation.function), - Some(&SorobanAuthorizedFunction::ContractFn(_)) - ) - .then(move || op.clone()) +/// Convert an `ScAddress` to a Stellar strkey string for plugin signer lookup. +fn sc_address_to_string(address: &ScAddress) -> Result { + match address { + ScAddress::Account(AccountId(PublicKey::PublicKeyTypeEd25519(Uint256(bytes)))) => Ok( + stellar_strkey::Strkey::PublicKeyEd25519(stellar_strkey::ed25519::PublicKey(*bytes)) + .to_string(), + ), + ScAddress::Contract(stellar_xdr::curr::ContractId(Hash(bytes))) => { + Ok(stellar_strkey::Strkey::Contract(stellar_strkey::Contract(*bytes)).to_string()) + } + ScAddress::MuxedAccount(muxed) => Ok(muxed.to_string()), + _ => Err(Error::MissingSignerForAddress { + address: format!("{address:?}"), + }), + } } -// Use the given source_key and signers, to sign all SorobanAuthorizationEntry's in the given -// transaction. If unable to sign, return an error. +/// Use the given signers to sign all `SorobanAuthorizationEntry`s in the given transaction. +/// +/// Signers are checked in this order for each auth entry: +/// 1. Plugin signers (matched by address string) +/// 2. Local signers (matched by ed25519 public key bytes) +/// +/// Plugin signers can handle any address type (G.../C.../M...). +/// +/// If no `SorobanAuthorizationEntry`s need signing (including if none exist), return `Ok(None)`. +/// +/// If a `SorobanAuthorizationEntry` needs signing, but a signature cannot be produced for it, +/// return an Error. pub fn sign_soroban_authorizations( raw: &Transaction, - source_signer: &Signer, signers: &[Signer], + plugin_signers: &[PluginSigner], signature_expiration_ledger: u32, network_passphrase: &str, ) -> Result, Error> { - let mut tx = raw.clone(); - let Some(mut op) = requires_auth(&tx) else { - return Ok(None); - }; - - let Operation { - body: OperationBody::InvokeHostFunction(ref mut body), + // Check if we have exactly one operation and it's InvokeHostFunction + let [op @ Operation { + body: OperationBody::InvokeHostFunction(body), .. - } = op + }] = raw.operations.as_slice() else { return Ok(None); }; let network_id = Hash(Sha256::digest(network_passphrase.as_bytes()).into()); + let mut has_non_source_auth = false; let mut signed_auths = Vec::with_capacity(body.auth.len()); for raw_auth in body.auth.as_slice() { - let mut auth = raw_auth.clone(); + let auth = raw_auth.clone(); let SorobanAuthorizationEntry { - credentials: SorobanCredentials::Address(ref mut credentials), + credentials: SorobanCredentials::Address(ref credentials), .. } = auth else { - // Doesn't need special signing + // Doesn't need special signing (e.g., source account credentials) signed_auths.push(auth); continue; }; + has_non_source_auth = true; let SorobanAddressCredentials { ref address, .. } = credentials; - // See if we have a signer for this authorizationEntry - // If not, then we Error let needle: &[u8; 32] = match address { - ScAddress::MuxedAccount(_) => todo!("muxed accounts are not supported"), - ScAddress::ClaimableBalance(_) => todo!("claimable balance not supported"), - ScAddress::LiquidityPool(_) => todo!("liquidity pool not supported"), ScAddress::Account(AccountId(PublicKey::PublicKeyTypeEd25519(Uint256(ref a)))) => a, - ScAddress::Contract(stellar_xdr::curr::ContractId(Hash(c))) => { - // This address is for a contract. This means we're using a custom - // smart-contract account. Currently the CLI doesn't support that yet. + // Non-account addresses without a plugin cannot be signed locally + other => { return Err(Error::MissingSignerForAddress { - address: stellar_strkey::Strkey::Contract(stellar_strkey::Contract(*c)) - .to_string(), + address: sc_address_to_string(other).unwrap_or_else(|_| format!("{other:?}")), }); } }; + let plugin_signer: Signer; let mut signer: Option<&Signer> = None; - for s in signers { - if needle == &s.get_public_key()?.0 { - signer = Some(s); + // 1. Check for a plugin signer mapped to this address + // 2. If no plugin signer, check for a local signer with a matching public key + if let Some(plugin) = plugin_signers.iter().find(|p| p.sc_address == *address) { + plugin_signer = Signer { + kind: SignerKind::Plugin(plugin.clone()), + print: Print::new(false), + }; + signer = Some(&plugin_signer); + } else if let Some(s) = signers.iter().find(|s| { + if let Ok(pk) = s.get_public_key() { + pk.0 == *needle + } else { + false } - } - - if needle == &source_signer.get_public_key()?.0 { - signer = Some(source_signer); + }) { + signer = Some(s); } match signer { @@ -134,6 +165,7 @@ pub fn sign_soroban_authorizations( signer, signature_expiration_ledger, &network_id, + network_passphrase, )?; signed_auths.push(signed_entry); } @@ -148,9 +180,21 @@ pub fn sign_soroban_authorizations( } } - body.auth = signed_auths.try_into()?; - tx.operations = vec![op].try_into()?; - Ok(Some(tx)) + // No signatures were made, return None to indicate no change to the transaction + if signed_auths.is_empty() || !has_non_source_auth { + return Ok(None); + } + + // Build updated transaction with signed auth entries + let mut updated_op = op.clone(); + if let OperationBody::InvokeHostFunction(ref mut updated_body) = updated_op.body { + let mut tx = raw.clone(); + updated_body.auth = signed_auths.try_into()?; + tx.operations = vec![updated_op].try_into()?; + Ok(Some(tx)) + } else { + Ok(None) + } } fn sign_soroban_authorization_entry( @@ -158,6 +202,7 @@ fn sign_soroban_authorization_entry( signer: &Signer, signature_expiration_ledger: u32, network_id: &Hash, + network_passphrase: &str, ) -> Result { let mut auth = raw.clone(); let SorobanAuthorizationEntry { @@ -170,39 +215,14 @@ fn sign_soroban_authorization_entry( }; let SorobanAddressCredentials { nonce, .. } = credentials; - let preimage = HashIdPreimage::SorobanAuthorization(HashIdPreimageSorobanAuthorization { - network_id: network_id.clone(), - invocation: auth.root_invocation.clone(), - nonce: *nonce, + let signature_scval = signer.sign_auth_entry( + &auth.root_invocation, + *nonce, signature_expiration_ledger, - }) - .to_xdr(Limits::none())?; - - let payload = Sha256::digest(preimage); - let p: [u8; 32] = payload.as_slice().try_into()?; - let signature = signer.sign_payload(p)?; - let public_key_vec = signer.get_public_key()?.0.to_vec(); - - let map = ScMap::sorted_from(vec![ - ( - ScVal::Symbol(ScSymbol("public_key".try_into()?)), - ScVal::Bytes(public_key_vec.try_into().map_err(Error::Xdr)?), - ), - ( - ScVal::Symbol(ScSymbol("signature".try_into()?)), - ScVal::Bytes( - signature - .to_bytes() - .to_vec() - .try_into() - .map_err(Error::Xdr)?, - ), - ), - ]) - .map_err(Error::Xdr)?; - credentials.signature = ScVal::Vec(Some( - vec![ScVal::Map(Some(map))].try_into().map_err(Error::Xdr)?, - )); + network_id.clone(), + network_passphrase, + )?; + credentials.signature = signature_scval; credentials.signature_expiration_ledger = signature_expiration_ledger; auth.credentials = SorobanCredentials::Address(credentials.clone()); Ok(auth) @@ -219,6 +239,7 @@ pub enum SignerKind { Ledger(ledger::LedgerType), Lab, SecureStore(SecureStoreEntry), + Plugin(PluginSigner), } // It is advised to use the sign_with module, which handles creating a Signer with the appropriate SignerKind @@ -245,20 +266,29 @@ impl Signer { let tx_hash = transaction_hash(tx, &network.network_passphrase)?; self.print .infoln(format!("Signing transaction: {}", hex::encode(tx_hash),)); - let decorated_signature = match &self.kind { - SignerKind::Local(key) => key.sign_tx_hash(tx_hash)?, - SignerKind::Lab => Lab::sign_tx_env(tx_env, network, &self.print)?, - SignerKind::Ledger(ledger) => ledger.sign_transaction_hash(&tx_hash).await?, - SignerKind::SecureStore(entry) => entry.sign_tx_hash(tx_hash)?, - }; + let decorated_signature = self.sign_tx_hash(tx_hash, tx_env, network).await?; let mut sigs = signatures.clone().into_vec(); - sigs.push(decorated_signature); + sigs.extend(decorated_signature); Ok(TransactionEnvelope::Tx(TransactionV1Envelope { tx: tx.clone(), signatures: sigs.try_into()?, })) } - _ => Err(Error::UnsupportedTransactionEnvelopeType), + TransactionEnvelope::TxFeeBump(FeeBumpTransactionEnvelope { tx, signatures }) => { + let tx_hash = fee_bump_transaction_hash(tx, &network.network_passphrase)?; + self.print.infoln(format!( + "Signing fee bump transaction: {}", + hex::encode(tx_hash), + )); + let decorated_signature = self.sign_tx_hash(tx_hash, tx_env, network).await?; + let mut sigs = signatures.clone().into_vec(); + sigs.extend(decorated_signature); + Ok(TransactionEnvelope::TxFeeBump(FeeBumpTransactionEnvelope { + tx: tx.clone(), + signatures: sigs.try_into()?, + })) + } + TransactionEnvelope::TxV0(_) => Err(Error::UnsupportedTransactionEnvelopeType), } } @@ -270,7 +300,25 @@ impl Signer { )?), SignerKind::Ledger(_ledger) => todo!("ledger device is not implemented"), SignerKind::Lab => Err(Error::ReturningSignatureFromLab), - SignerKind::SecureStore(secure_store_entry) => secure_store_entry.get_public_key(), + SignerKind::SecureStore(secure_store_entry) => Ok(secure_store_entry.public_key), + SignerKind::Plugin(_) => Err(Error::PluginError { + name: "plugin".to_string(), + details: "Plugins do not expose a public key directly".to_string(), + }), + } + } + + pub fn get_sc_address(&self) -> Result { + match &self.kind { + SignerKind::Local(_) | SignerKind::SecureStore(_) => { + let pk = self.get_public_key()?; + Ok(ScAddress::Account(AccountId( + PublicKey::PublicKeyTypeEd25519(Uint256(pk.0)), + ))) + } + SignerKind::Ledger(_ledger) => todo!("ledger device is not implemented"), + SignerKind::Lab => Err(Error::ReturningSignatureFromLab), + SignerKind::Plugin(plugin_signer) => Ok(plugin_signer.sc_address.clone()), } } @@ -281,6 +329,85 @@ impl Signer { SignerKind::Ledger(_ledger) => todo!("ledger device is not implemented"), SignerKind::Lab => Err(Error::ReturningSignatureFromLab), SignerKind::SecureStore(secure_store_entry) => secure_store_entry.sign_payload(payload), + SignerKind::Plugin(plugin_signer) => Err(Error::PluginError { + name: plugin_signer.name.clone(), + details: "sign payload is not supported".to_string(), + }), + } + } + + pub fn sign_auth_entry( + &self, + root_invocation: &xdr::SorobanAuthorizedInvocation, + nonce: i64, + signature_expiration_ledger: u32, + network_id: Hash, + network_passphrase: &str, + ) -> Result { + let preimage = HashIdPreimage::SorobanAuthorization(HashIdPreimageSorobanAuthorization { + network_id: network_id.clone(), + invocation: root_invocation.clone(), + nonce, + signature_expiration_ledger, + }) + .to_xdr(Limits::none())?; + + let payload = Sha256::digest(preimage); + let p: [u8; 32] = payload.as_slice().try_into()?; + + if let SignerKind::Plugin(plugin_signer) = &self.kind { + plugin_signer.sign_auth_entry( + p, + root_invocation, + nonce, + signature_expiration_ledger, + network_passphrase, + ) + } else { + // for local signers, sign the payload directly and build the ScVal signature + let signature = self.sign_payload(p)?; + let public_key_vec = self.get_public_key()?.0.to_vec(); + + let map = ScMap::sorted_from(vec![ + ( + ScVal::Symbol(ScSymbol("public_key".try_into()?)), + ScVal::Bytes(public_key_vec.try_into().map_err(Error::Xdr)?), + ), + ( + ScVal::Symbol(ScSymbol("signature".try_into()?)), + ScVal::Bytes( + signature + .to_bytes() + .to_vec() + .try_into() + .map_err(Error::Xdr)?, + ), + ), + ]) + .map_err(Error::Xdr)?; + Ok(ScVal::Vec(Some( + vec![ScVal::Map(Some(map))].try_into().map_err(Error::Xdr)?, + ))) + } + } + + async fn sign_tx_hash( + &self, + tx_hash: [u8; 32], + tx_env: &TransactionEnvelope, + network: &Network, + ) -> Result, Error> { + match &self.kind { + SignerKind::Local(key) => key.sign_tx_hash(tx_hash), + SignerKind::Lab => Lab::sign_tx_env(tx_env, network, &self.print), + SignerKind::Ledger(ledger) => ledger + .sign_transaction_hash(&tx_hash) + .await + .map_err(Error::from), + SignerKind::SecureStore(entry) => entry.sign_tx_hash(tx_hash), + SignerKind::Plugin(plugin) => { + plugin.sign_tx_hash(tx_env, tx_hash, &network.network_passphrase) + } } } } @@ -290,10 +417,10 @@ pub struct LocalKey { } impl LocalKey { - pub fn sign_tx_hash(&self, tx_hash: [u8; 32]) -> Result { + pub fn sign_tx_hash(&self, tx_hash: [u8; 32]) -> Result, Error> { let hint = SignatureHint(self.key.verifying_key().to_bytes()[28..].try_into()?); let signature = Signature(self.key.sign(&tx_hash).to_bytes().to_vec().try_into()?); - Ok(DecoratedSignature { hint, signature }) + Ok(vec![DecoratedSignature { hint, signature }]) } pub fn sign_payload(&self, payload: [u8; 32]) -> Result { @@ -310,7 +437,7 @@ impl Lab { tx_env: &TransactionEnvelope, network: &Network, printer: &Print, - ) -> Result { + ) -> Result, Error> { let xdr = tx_env.to_xdr_base64(Limits::none())?; let mut url = url::Url::parse(Self::URL)?; @@ -329,22 +456,26 @@ impl Lab { pub struct SecureStoreEntry { pub name: String, pub hd_path: Option, + pub public_key: stellar_strkey::ed25519::PublicKey, } impl SecureStoreEntry { - pub fn get_public_key(&self) -> Result { - Ok(secure_store::get_public_key(&self.name, self.hd_path)?) + pub fn new(name: String, hd_path: Option) -> Result { + let public_key = secure_store::get_public_key(&name, hd_path)?; + Ok(Self { + name, + hd_path, + public_key, + }) } - pub fn sign_tx_hash(&self, tx_hash: [u8; 32]) -> Result { - let hint = SignatureHint( - secure_store::get_public_key(&self.name, self.hd_path)?.0[28..].try_into()?, - ); + pub fn sign_tx_hash(&self, tx_hash: [u8; 32]) -> Result, Error> { + let hint = SignatureHint(self.public_key.0[28..].try_into()?); let signed_tx_hash = secure_store::sign_tx_data(&self.name, self.hd_path, &tx_hash)?; let signature = Signature(signed_tx_hash.clone().try_into()?); - Ok(DecoratedSignature { hint, signature }) + Ok(vec![DecoratedSignature { hint, signature }]) } pub fn sign_payload(&self, payload: [u8; 32]) -> Result { @@ -353,3 +484,183 @@ impl SecureStoreEntry { Ok(sig) } } + +/// A signing plugin that delegates signing to an external binary. +/// +/// The plugin binary is discovered on `$PATH` as `stellar-signer-{name}`. +/// It communicates via JSON on stdin/stdout: +/// +/// **Auth entry signing** (`sign_auth` mode): +/// - Input: `{ "mode": "sign_auth", "network_passphrase", "address", "nonce", +/// "signature_expiration_ledger", "root_invocation" (base64 XDR), "args": {...} }` +/// - Output: `Base64 XDR string` of the ScVal representing the signature credential for the auth entry +/// +/// **Transaction signing** (`sign_tx` mode): +/// - Input: `{ "mode": "sign_tx", "network_passphrase", "tx_env_xdr" (base64 XDR TransactionEnvelope), "tx_hash", "args": {...} }` +/// - Output: `JSON array of base64 XDR strings` representing DecoratedSignatures to add to the transaction envelope +/// +/// The plugin's stderr is inherited so it can print prompts, progress, or open browsers. +#[derive(Clone)] +pub struct PluginSigner { + /// Name of the plugin (resolved to binary `stellar-signer-{name}` on PATH). + pub name: String, + /// The resolved path to the plugin binary. + pub bin_path: PathBuf, + /// The Stellar address this plugin is mapped to (G.../C.../M...). + pub sc_address: ScAddress, + /// Extra key-value arguments forwarded to the plugin in the `args` JSON field. + pub args: HashMap, +} + +impl PluginSigner { + /// Create a new `PluginSigner` by resolving the plugin binary on `$PATH`. + pub fn new( + name: &str, + sc_address: ScAddress, + args: HashMap, + ) -> Result { + let bin_path = find_signer_plugin_bin(name)?; + + Ok(Self { + name: name.to_string(), + bin_path, + sc_address, + args, + }) + } + + /// Sign a single `SorobanAuthorizationEntry` by invoking the plugin in `sign_auth` mode. + /// + /// The plugin receives the auth entry context as JSON and returns the `ScVal` credential + /// signature. The CLI handles all transaction envelope parsing and reassembly. + pub fn sign_auth_entry( + &self, + payload: [u8; 32], + root_invocation: &xdr::SorobanAuthorizedInvocation, + nonce: i64, + signature_expiration_ledger: u32, + network_passphrase: &str, + ) -> Result { + let input = serde_json::json!({ + "mode": "sign_auth", + "payload": hex::encode(payload), + "network_passphrase": network_passphrase, + "address": self.sc_address.to_xdr_base64(Limits::none())?, + "nonce": nonce, + "signature_expiration_ledger": signature_expiration_ledger, + "root_invocation": root_invocation.to_xdr_base64(Limits::none())?, + "args": self.args, + }); + + let output = self.invoke_plugin(&input)?; + + // Decode the ScVal from base64 XDR + ScVal::from_xdr_base64(output.trim_ascii_end(), Limits::none()).map_err(|e| { + Error::PluginInvalidOutput { + name: self.name.clone(), + details: format!("Failed to decode ScVal from base64 XDR: {e}"), + } + }) + } + + /// Sign a transaction hash by invoking the plugin in `sign_tx` mode. + /// + /// Returns a `Vec` for inclusion in the transaction envelope. + pub fn sign_tx_hash( + &self, + tx_env: &TransactionEnvelope, + tx_hash: [u8; 32], + network_passphrase: &str, + ) -> Result, Error> { + let tx_env_xdr = tx_env.to_xdr_base64(Limits::none())?; + let input = serde_json::json!({ + "mode": "sign_tx", + "tx_env_xdr": tx_env_xdr, + "tx_hash": hex::encode(tx_hash), + "network_passphrase": network_passphrase, + "args": self.args, + }); + + let output = self.invoke_plugin(&input)?; + let output_str = std::str::from_utf8(&output).map_err(|e| Error::PluginInvalidOutput { + name: self.name.clone(), + details: format!("Plugin output is not valid UTF-8: {e}"), + })?; + let sig_strings: Vec = + serde_json::from_str(output_str).map_err(|e| Error::PluginInvalidOutput { + name: self.name.clone(), + details: format!( + "Expected JSON array of base64 XDR DecoratedSignature strings: {e}" + ), + })?; + sig_strings + .iter() + .map(|s| { + DecoratedSignature::from_xdr_base64(s.trim(), Limits::none()).map_err(|e| { + Error::PluginInvalidOutput { + name: self.name.clone(), + details: format!( + "Failed to decode DecoratedSignature from base64 XDR: {e}" + ), + } + }) + }) + .collect() + } + + /// Spawn the plugin process, write JSON to its stdin, and read the response from stdout. + /// The plugin's stderr is inherited so it can print prompts or progress to the terminal. + fn invoke_plugin(&self, input: &serde_json::Value) -> Result, Error> { + let mut child = Command::new(&self.bin_path) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::inherit()) // Plugin can print prompts, open browsers, etc. + .spawn() + .map_err(|e| Error::PluginError { + name: self.name.clone(), + details: format!("Failed to spawn plugin: {e}"), + })?; + + // Write JSON input to stdin + if let Some(mut stdin) = child.stdin.take() { + let input_bytes = serde_json::to_vec(input)?; + stdin + .write_all(&input_bytes) + .map_err(|e| Error::PluginError { + name: self.name.clone(), + details: format!("Failed to write to plugin stdin: {e}"), + })?; + // stdin is dropped here, closing the pipe + } + + // Wait for the plugin to finish and read stdout + let output = child.wait_with_output().map_err(|e| Error::PluginError { + name: self.name.clone(), + details: format!("Failed to wait for plugin: {e}"), + })?; + + if !output.status.success() { + let code = output.status.code().unwrap_or(-1); + return Err(Error::PluginFailed { + name: self.name.clone(), + code, + }); + } + + Ok(output.stdout) + } +} + +/// Find a signer plugin binary on `$PATH` by name. +/// Looks for `stellar-signer-{name}` first, then `soroban-signer-{name}`. +fn find_signer_plugin_bin(name: &str) -> Result { + if let Ok(path) = which(format!("stellar-signer-{name}")) { + Ok(path) + } else if let Ok(path) = which(format!("soroban-signer-{name}")) { + Ok(path) + } else { + Err(Error::PluginNotFound { + name: name.to_string(), + }) + } +} diff --git a/cmd/soroban-cli/src/tx.rs b/cmd/soroban-cli/src/tx.rs index 940b673058..5e70e62e82 100644 --- a/cmd/soroban-cli/src/tx.rs +++ b/cmd/soroban-cli/src/tx.rs @@ -1,4 +1,112 @@ +use std::str::FromStr; + +use crate::{ + assembled::simulate_and_assemble_transaction, + commands::tx::fetch, + config::{self, data, network}, + resources, + signer::{self, Signer}, + xdr::{ + self, FeeBumpTransaction, FeeBumpTransactionExt, FeeBumpTransactionInnerTx, Transaction, + TransactionEnvelope, + }, +}; +use soroban_rpc::GetTransactionResponse; +use url::Url; + pub mod builder; /// 10,000,000 stroops in 1 XLM pub const ONE_XLM: i64 = 10_000_000; + +/// Simulates, signs, and sends a transaction to the network. +/// +/// Returns the `GetTransactionResponse` from the network. +pub async fn sim_sign_and_send_tx( + client: &soroban_rpc::Client, + tx: &Transaction, + config: &config::Args, + resources: &resources::Args, + auth_signers: &[Signer], + quiet: bool, + no_cache: bool, +) -> Result +where + E: From + + From + + From + + From + + From + + From, +{ + // cache user set inclusion fee + let inclusion_fee = tx.fee; + let assembled_resp = simulate_and_assemble_transaction( + client, + tx, + resources.resource_config(), + resources.resource_fee, + ) + .await?; + let assembled = resources.apply_to_assembled_txn(assembled_resp); + let mut txn = Box::new(assembled.transaction().clone()); + let sim_res = assembled.sim_response(); + + let rpc_uri = Url::from_str(client.base_url()) + .map_err(|_| config::network::Error::InvalidUrl(client.base_url().to_string()))?; + if !no_cache { + data::write(sim_res.clone().into(), &rpc_uri)?; + } + + // Need to sign all auth entries + if let Some(mut tx) = config + .sign_soroban_authorizations(&txn, auth_signers) + .await? + { + // if we added signatures to auth entries, we need to re-simulate to correctly account + // for resource usage when validating the auth entry signatures. + tx.fee = inclusion_fee; // reset inclusion fee to ensure assembled fee is correct + let new_assembled_resp = simulate_and_assemble_transaction( + client, + &tx, + resources.resource_config(), + resources.resource_fee, + ) + .await?; + let new_assembled = resources.apply_to_assembled_txn(new_assembled_resp); + *txn = new_assembled.transaction().clone(); + } + + let mut signed_tx = config.sign(*txn, quiet).await?; + + // If the simulation detected the need for a fee bump, + // wrap the transaction in a fee bump with the appropriate fee amount + if let Some(fee_bump_fee) = assembled.fee_bump_fee() { + let fee_bump_inner = match signed_tx { + TransactionEnvelope::Tx(tx_env) => FeeBumpTransactionInnerTx::Tx(tx_env), + _ => { + return Err(config::Error::Signer( + signer::Error::UnsupportedTransactionEnvelopeType, + ) + .into()) + } + }; + let fee_bump = FeeBumpTransaction { + fee_source: tx.source_account.clone(), + fee: fee_bump_fee, + inner_tx: fee_bump_inner, + ext: FeeBumpTransactionExt::V0, + }; + signed_tx = config.sign_fee_bump(fee_bump, quiet).await?; + } + + let res = client.send_transaction_polling(&signed_tx).await?; + + resources.print_cost_info(&res)?; + + if !no_cache { + data::write(res.clone().try_into()?, &rpc_uri)?; + } + + Ok(res) +} diff --git a/cmd/soroban-cli/src/utils.rs b/cmd/soroban-cli/src/utils.rs index 2e5351af32..5f5c58e6b2 100644 --- a/cmd/soroban-cli/src/utils.rs +++ b/cmd/soroban-cli/src/utils.rs @@ -36,6 +36,22 @@ pub fn transaction_hash( Ok(Sha256::digest(signature_payload.to_xdr(Limits::none())?).into()) } +/// # Errors +/// +/// Might return an error +pub fn fee_bump_transaction_hash( + fee_bump_tx: &xdr::FeeBumpTransaction, + network_passphrase: &str, +) -> Result<[u8; 32], xdr::Error> { + let signature_payload = TransactionSignaturePayload { + network_id: Hash(Sha256::digest(network_passphrase).into()), + tagged_transaction: TransactionSignaturePayloadTaggedTransaction::TxFeeBump( + fee_bump_tx.clone(), + ), + }; + Ok(Sha256::digest(signature_payload.to_xdr(Limits::none())?).into()) +} + static EXPLORERS: phf::Map<&'static str, &'static str> = phf_map! { "Test SDF Network ; September 2015" => "https://stellar.expert/explorer/testnet", "Public Global Stellar Network ; September 2015" => "https://stellar.expert/explorer/public",