From ff57159bb68807c8b2525ad19840edc19d921d80 Mon Sep 17 00:00:00 2001 From: Willem Wyndham Date: Mon, 23 Sep 2024 22:37:38 -0400 Subject: [PATCH] feat: add `tx sign` (#1590) Co-authored-by: Leigh McCulloch <351529+leighmcculloch@users.noreply.github.com> --- FULL_HELP_DOCS.md | 19 +++++ Makefile | 4 +- .../soroban-test/tests/it/integration/tx.rs | 32 ++++++- cmd/soroban-cli/src/commands/tx/mod.rs | 6 ++ cmd/soroban-cli/src/commands/tx/sign.rs | 45 ++++++++++ cmd/soroban-cli/src/config/locator.rs | 8 ++ cmd/soroban-cli/src/config/mod.rs | 14 ++-- cmd/soroban-cli/src/config/secret.rs | 21 ++++- cmd/soroban-cli/src/config/sign_with.rs | 53 ++++++++++++ cmd/soroban-cli/src/print.rs | 3 +- cmd/soroban-cli/src/signer.rs | 84 +++++++++++++------ cmd/soroban-cli/src/utils.rs | 30 +------ 12 files changed, 258 insertions(+), 61 deletions(-) create mode 100644 cmd/soroban-cli/src/commands/tx/sign.rs create mode 100644 cmd/soroban-cli/src/config/sign_with.rs diff --git a/FULL_HELP_DOCS.md b/FULL_HELP_DOCS.md index b7480cb5e..9ba32c35a 100644 --- a/FULL_HELP_DOCS.md +++ b/FULL_HELP_DOCS.md @@ -1291,6 +1291,7 @@ Sign, Simulate, and Send transactions * `simulate` — Simulate a transaction envelope from stdin * `hash` — Calculate the hash of a transaction envelope from stdin +* `sign` — Sign a transaction envelope appending the signature to the envelope @@ -1326,6 +1327,24 @@ Calculate the hash of a transaction envelope from stdin +## `stellar tx sign` + +Sign a transaction envelope appending the signature to the envelope + +**Usage:** `stellar tx sign [OPTIONS]` + +###### **Options:** + +* `--sign-with-key ` — Sign with a local key. Can be an identity (--sign-with-key alice), a secret key (--sign-with-key SC36…), or a seed phrase (--sign-with-key "kite urban…"). If using seed phrase, `--hd-path` defaults to the `0` path +* `--hd-path ` — If using a seed phrase to sign, sets which hierarchical deterministic path to use, e.g. `m/44'/148'/{hd_path}`. Example: `--hd-path 1`. Default: `0` +* `--rpc-url ` — RPC server endpoint +* `--network-passphrase ` — Network passphrase to sign the transaction sent to the rpc server +* `--network ` — Name of network to use from config +* `--global` — Use global config +* `--config-dir ` — Location of config directory, default is "." + + + ## `stellar xdr` Decode and encode XDR diff --git a/Makefile b/Makefile index 7e307b16c..6d55aacc4 100644 --- a/Makefile +++ b/Makefile @@ -15,6 +15,8 @@ endif REPOSITORY_BRANCH := "$(shell git rev-parse --abbrev-ref HEAD)" BUILD_TIMESTAMP ?= $(shell date '+%Y-%m-%dT%H:%M:%S') +SOROBAN_PORT?=8000 + # The following works around incompatibility between the rust and the go linkers - # the rust would generate an object file with min-version of 13.0 where-as the go # compiler would generate a binary compatible with 12.3 and up. To align these @@ -53,7 +55,7 @@ test: build-test cargo test e2e-test: - cargo test --test it -- --ignored + cargo test --features it --test it -- integration check: cargo clippy --all-targets diff --git a/cmd/crates/soroban-test/tests/it/integration/tx.rs b/cmd/crates/soroban-test/tests/it/integration/tx.rs index bcb880b18..c3d2cebd6 100644 --- a/cmd/crates/soroban-test/tests/it/integration/tx.rs +++ b/cmd/crates/soroban-test/tests/it/integration/tx.rs @@ -4,7 +4,7 @@ use soroban_test::{AssertExt, TestEnv}; use crate::integration::util::{deploy_contract, DeployKind, HELLO_WORLD}; #[tokio::test] -async fn txn_simulate() { +async fn simulate() { let sandbox = &TestEnv::new(); let xdr_base64_build_only = deploy_contract(sandbox, HELLO_WORLD, DeployKind::BuildOnly).await; let xdr_base64_sim_only = deploy_contract(sandbox, HELLO_WORLD, DeployKind::SimOnly).await; @@ -49,3 +49,33 @@ async fn txn_hash() { assert_eq!(hash.trim(), expected_hash); } + +#[tokio::test] +async fn build_simulate_sign_send() { + let sandbox = &TestEnv::new(); + sandbox + .new_assert_cmd("contract") + .arg("install") + .args(["--wasm", HELLO_WORLD.path().as_os_str().to_str().unwrap()]) + .assert() + .success(); + + let tx_simulated = deploy_contract(sandbox, HELLO_WORLD, DeployKind::SimOnly).await; + dbg!("{tx_simulated}"); + + let tx_signed = sandbox + .new_assert_cmd("tx") + .arg("sign") + .arg("--sign-with-key=test") + .write_stdin(tx_simulated.as_bytes()) + .assert() + .success() + .stdout_as_str(); + dbg!("{tx_signed}"); + + // TODO: Replace with calling tx send when that command is added. + let tx_signed = TransactionEnvelope::from_xdr_base64(tx_signed, Limits::none()).unwrap(); + let client = soroban_rpc::Client::new(&sandbox.rpc_url).unwrap(); + let rpc_result = client.send_transaction_polling(&tx_signed).await.unwrap(); + assert_eq!(rpc_result.status, "SUCCESS"); +} diff --git a/cmd/soroban-cli/src/commands/tx/mod.rs b/cmd/soroban-cli/src/commands/tx/mod.rs index 59f07228a..5f0f90c4c 100644 --- a/cmd/soroban-cli/src/commands/tx/mod.rs +++ b/cmd/soroban-cli/src/commands/tx/mod.rs @@ -3,6 +3,7 @@ use clap::Parser; use super::global; pub mod hash; +pub mod sign; pub mod simulate; pub mod xdr; @@ -12,6 +13,8 @@ pub enum Cmd { Simulate(simulate::Cmd), /// Calculate the hash of a transaction envelope from stdin Hash(hash::Cmd), + /// Sign a transaction envelope appending the signature to the envelope + Sign(sign::Cmd), } #[derive(thiserror::Error, Debug)] @@ -22,6 +25,8 @@ pub enum Error { /// An error during hash calculation #[error(transparent)] Hash(#[from] hash::Error), + #[error(transparent)] + Sign(#[from] sign::Error), } impl Cmd { @@ -29,6 +34,7 @@ impl Cmd { match self { Cmd::Simulate(cmd) => cmd.run(global_args).await?, Cmd::Hash(cmd) => cmd.run(global_args)?, + Cmd::Sign(cmd) => cmd.run(global_args).await?, }; Ok(()) } diff --git a/cmd/soroban-cli/src/commands/tx/sign.rs b/cmd/soroban-cli/src/commands/tx/sign.rs new file mode 100644 index 000000000..92fef7417 --- /dev/null +++ b/cmd/soroban-cli/src/commands/tx/sign.rs @@ -0,0 +1,45 @@ +use crate::{ + commands::global, + config::{locator, network, sign_with}, + xdr::{self, Limits, WriteXdr}, +}; + +#[derive(thiserror::Error, Debug)] +pub enum Error { + #[error(transparent)] + XdrArgs(#[from] super::xdr::Error), + #[error(transparent)] + Network(#[from] network::Error), + #[error(transparent)] + Locator(#[from] locator::Error), + #[error(transparent)] + SignWith(#[from] sign_with::Error), + #[error(transparent)] + Xdr(#[from] xdr::Error), +} + +#[derive(Debug, clap::Parser, Clone)] +#[group(skip)] +pub struct Cmd { + #[command(flatten)] + pub sign_with: sign_with::Args, + #[command(flatten)] + pub network: network::Args, + #[command(flatten)] + pub locator: locator::Args, +} + +impl Cmd { + #[allow(clippy::unused_async)] + pub async fn run(&self, global_args: &global::Args) -> Result<(), Error> { + let tx_env = super::xdr::tx_envelope_from_stdin()?; + let tx_env_signed = self.sign_with.sign_tx_env( + tx_env, + &self.locator, + &self.network.get(&self.locator)?, + global_args.quiet, + )?; + println!("{}", tx_env_signed.to_xdr_base64(Limits::none())?); + Ok(()) + } +} diff --git a/cmd/soroban-cli/src/config/locator.rs b/cmd/soroban-cli/src/config/locator.rs index 86d1004f6..bc167c977 100644 --- a/cmd/soroban-cli/src/config/locator.rs +++ b/cmd/soroban-cli/src/config/locator.rs @@ -217,6 +217,14 @@ impl Args { KeyType::Identity.read_with_global(name, &self.local_config()?) } + pub fn key(&self, key_or_name: &str) -> Result { + if let Ok(signer) = key_or_name.parse::() { + Ok(signer) + } else { + self.read_identity(key_or_name) + } + } + pub fn read_network(&self, name: &str) -> Result { let res = KeyType::Network.read_with_global(name, &self.local_config()?); if let Err(Error::ConfigMissing(_, _)) = &res { diff --git a/cmd/soroban-cli/src/config/mod.rs b/cmd/soroban-cli/src/config/mod.rs index ef286f6a0..48eb6dbe8 100644 --- a/cmd/soroban-cli/src/config/mod.rs +++ b/cmd/soroban-cli/src/config/mod.rs @@ -6,7 +6,8 @@ use serde::{Deserialize, Serialize}; use soroban_rpc::Client; use crate::{ - signer, + print::Print, + signer::{self, LocalKey, Signer, SignerKind}, xdr::{Transaction, TransactionEnvelope}, Pwd, }; @@ -18,6 +19,7 @@ pub mod data; pub mod locator; pub mod network; pub mod secret; +pub mod sign_with; pub mod upgrade_check; #[derive(thiserror::Error, Debug)] @@ -65,10 +67,12 @@ impl Args { #[allow(clippy::unused_async)] pub async fn sign(&self, tx: Transaction) -> Result { let key = self.key_pair()?; - let Network { - network_passphrase, .. - } = &self.get_network()?; - Ok(signer::sign_tx(&key, &tx, network_passphrase)?) + let network = &self.get_network()?; + let signer = Signer { + kind: SignerKind::Local(LocalKey { key }), + printer: Print::new(false), + }; + Ok(signer.sign_tx(tx, network)?) } pub async fn sign_soroban_authorizations( diff --git a/cmd/soroban-cli/src/config/secret.rs b/cmd/soroban-cli/src/config/secret.rs index b5b1dd747..ff1fb7b72 100644 --- a/cmd/soroban-cli/src/config/secret.rs +++ b/cmd/soroban-cli/src/config/secret.rs @@ -3,7 +3,11 @@ use serde::{Deserialize, Serialize}; use std::{io::Write, str::FromStr}; use stellar_strkey::ed25519::{PrivateKey, PublicKey}; -use crate::utils; +use crate::print::Print; +use crate::{ + signer::{self, LocalKey, Signer, SignerKind}, + utils, +}; #[derive(thiserror::Error, Debug)] pub enum Error { @@ -21,6 +25,8 @@ pub enum Error { Ed25519(#[from] ed25519_dalek::SignatureError), #[error("Invalid address {0}")] InvalidAddress(String), + #[error(transparent)] + Signer(#[from] signer::Error), } #[derive(Debug, clap::Args, Clone)] @@ -120,6 +126,19 @@ impl Secret { )?) } + pub fn signer(&self, index: Option, quiet: bool) -> Result { + let kind = match self { + Secret::SecretKey { .. } | Secret::SeedPhrase { .. } => { + let key = self.key_pair(index)?; + SignerKind::Local(LocalKey { key }) + } + }; + Ok(Signer { + kind, + printer: Print::new(quiet), + }) + } + pub fn key_pair(&self, index: Option) -> Result { Ok(utils::into_signing_key(&self.private_key(index)?)) } diff --git a/cmd/soroban-cli/src/config/sign_with.rs b/cmd/soroban-cli/src/config/sign_with.rs new file mode 100644 index 000000000..685945194 --- /dev/null +++ b/cmd/soroban-cli/src/config/sign_with.rs @@ -0,0 +1,53 @@ +use crate::{signer, xdr::TransactionEnvelope}; +use clap::arg; + +use super::{ + locator, + network::{self, Network}, + secret, +}; + +#[derive(thiserror::Error, Debug)] +pub enum Error { + #[error(transparent)] + Network(#[from] network::Error), + #[error(transparent)] + Signer(#[from] signer::Error), + #[error(transparent)] + Secret(#[from] secret::Error), + #[error(transparent)] + Locator(#[from] locator::Error), + #[error(transparent)] + Rpc(#[from] soroban_rpc::Error), + #[error("No sign with key provided")] + NoSignWithKey, + #[error(transparent)] + StrKey(#[from] stellar_strkey::DecodeError), +} + +#[derive(Debug, clap::Args, Clone, Default)] +#[group(skip)] +pub struct Args { + /// Sign with a local key. Can be an identity (--sign-with-key alice), a secret key (--sign-with-key SC36…), or a seed phrase (--sign-with-key "kite urban…"). If using seed phrase, `--hd-path` defaults to the `0` path. + #[arg(long, env = "STELLAR_SIGN_WITH_KEY")] + pub sign_with_key: Option, + + #[arg(long, requires = "sign_with_key")] + /// If using a seed phrase to sign, sets which hierarchical deterministic path to use, e.g. `m/44'/148'/{hd_path}`. Example: `--hd-path 1`. Default: `0` + pub hd_path: Option, +} + +impl Args { + pub fn sign_tx_env( + &self, + tx: TransactionEnvelope, + locator: &locator::Args, + network: &Network, + quiet: bool, + ) -> Result { + let key_or_name = self.sign_with_key.as_deref().ok_or(Error::NoSignWithKey)?; + let secret = locator.key(key_or_name)?; + let signer = secret.signer(self.hd_path, quiet)?; + Ok(signer.sign_tx_env(tx, network)?) + } +} diff --git a/cmd/soroban-cli/src/print.rs b/cmd/soroban-cli/src/print.rs index f772eb649..5b98687bd 100644 --- a/cmd/soroban-cli/src/print.rs +++ b/cmd/soroban-cli/src/print.rs @@ -3,8 +3,7 @@ use std::{env, fmt::Display}; use soroban_env_host::xdr::{Error as XdrError, Transaction}; use crate::{ - config::network::Network, - utils::{explorer_url_for_transaction, transaction_hash}, + config::network::Network, utils::explorer_url_for_transaction, utils::transaction_hash, }; const TERMS: &[&str] = &["Apple_Terminal", "vscode"]; diff --git a/cmd/soroban-cli/src/signer.rs b/cmd/soroban-cli/src/signer.rs index 580a61a5e..36ae55053 100644 --- a/cmd/soroban-cli/src/signer.rs +++ b/cmd/soroban-cli/src/signer.rs @@ -1,4 +1,4 @@ -use ed25519_dalek::ed25519::signature::Signer; +use ed25519_dalek::ed25519::signature::Signer as _; use sha2::{Digest, Sha256}; use soroban_env_host::xdr::{ @@ -6,10 +6,11 @@ use soroban_env_host::xdr::{ InvokeHostFunctionOp, Limits, Operation, OperationBody, PublicKey, ScAddress, ScMap, ScSymbol, ScVal, Signature, SignatureHint, SorobanAddressCredentials, SorobanAuthorizationEntry, SorobanAuthorizedFunction, SorobanCredentials, Transaction, TransactionEnvelope, - TransactionSignaturePayload, TransactionSignaturePayloadTaggedTransaction, - TransactionV1Envelope, Uint256, WriteXdr, + TransactionV1Envelope, Uint256, VecM, WriteXdr, }; +use crate::{config::network::Network, print::Print, utils::transaction_hash}; + #[derive(thiserror::Error, Debug)] pub enum Error { #[error("Contract addresses are not supported to sign auth entries {address}")] @@ -24,6 +25,8 @@ pub enum Error { UserCancelledSigning, #[error(transparent)] Xdr(#[from] xdr::Error), + #[error("Only Transaction envelope V1 type is supported")] + UnsupportedTransactionEnvelopeType, } fn requires_auth(txn: &Transaction) -> Option { @@ -189,29 +192,62 @@ fn sign_soroban_authorization_entry( Ok(auth) } -pub fn sign_tx( - key: &ed25519_dalek::SigningKey, - tx: &Transaction, - network_passphrase: &str, -) -> Result { - let tx_hash = hash(tx, network_passphrase)?; - let tx_signature = key.sign(&tx_hash); +pub struct Signer { + pub kind: SignerKind, + pub printer: Print, +} - let decorated_signature = DecoratedSignature { - hint: SignatureHint(key.verifying_key().to_bytes()[28..].try_into()?), - signature: Signature(tx_signature.to_bytes().try_into()?), - }; +#[allow(clippy::module_name_repetitions)] +pub enum SignerKind { + Local(LocalKey), +} - Ok(TransactionEnvelope::Tx(TransactionV1Envelope { - tx: tx.clone(), - signatures: [decorated_signature].try_into()?, - })) +impl Signer { + pub fn sign_tx( + &self, + tx: Transaction, + network: &Network, + ) -> Result { + let tx_env = TransactionEnvelope::Tx(TransactionV1Envelope { + tx, + signatures: VecM::default(), + }); + self.sign_tx_env(tx_env, network) + } + + pub fn sign_tx_env( + &self, + tx_env: TransactionEnvelope, + network: &Network, + ) -> Result { + match tx_env { + TransactionEnvelope::Tx(TransactionV1Envelope { tx, signatures }) => { + let tx_hash = transaction_hash(&tx, &network.network_passphrase)?; + self.printer + .infoln(format!("Signing transaction: {}", hex::encode(tx_hash),)); + let decorated_signature = match &self.kind { + SignerKind::Local(key) => key.sign_tx_hash(tx_hash)?, + }; + let mut sigs = signatures.into_vec(); + sigs.push(decorated_signature); + Ok(TransactionEnvelope::Tx(TransactionV1Envelope { + tx, + signatures: sigs.try_into()?, + })) + } + _ => Err(Error::UnsupportedTransactionEnvelopeType), + } + } } -pub fn hash(tx: &Transaction, network_passphrase: &str) -> Result<[u8; 32], xdr::Error> { - let signature_payload = TransactionSignaturePayload { - network_id: Hash(Sha256::digest(network_passphrase).into()), - tagged_transaction: TransactionSignaturePayloadTaggedTransaction::Tx(tx.clone()), - }; - Ok(Sha256::digest(signature_payload.to_xdr(Limits::none())?).into()) +pub struct LocalKey { + pub key: ed25519_dalek::SigningKey, +} + +impl LocalKey { + pub fn sign_tx_hash(&self, tx_hash: [u8; 32]) -> Result { + 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 }) + } } diff --git a/cmd/soroban-cli/src/utils.rs b/cmd/soroban-cli/src/utils.rs index f8ebb8b37..f5827f75b 100644 --- a/cmd/soroban-cli/src/utils.rs +++ b/cmd/soroban-cli/src/utils.rs @@ -1,13 +1,11 @@ -use ed25519_dalek::Signer; use phf::phf_map; use sha2::{Digest, Sha256}; use stellar_strkey::ed25519::PrivateKey; use soroban_env_host::xdr::{ - Asset, ContractIdPreimage, DecoratedSignature, Error as XdrError, Hash, HashIdPreimage, - HashIdPreimageContractId, Limits, ScMap, ScMapEntry, ScVal, Signature, SignatureHint, - Transaction, TransactionEnvelope, TransactionSignaturePayload, - TransactionSignaturePayloadTaggedTransaction, TransactionV1Envelope, WriteXdr, + Asset, ContractIdPreimage, Error as XdrError, Hash, HashIdPreimage, HashIdPreimageContractId, + Limits, ScMap, ScMapEntry, ScVal, Transaction, TransactionSignaturePayload, + TransactionSignaturePayloadTaggedTransaction, WriteXdr, }; pub use soroban_spec_tools::contract as contract_spec; @@ -49,28 +47,6 @@ pub fn explorer_url_for_contract(network: &Network, contract_id: &str) -> Option .map(|base_url| format!("{base_url}/contract/{contract_id}")) } -/// # Errors -/// -/// Might return an error -pub fn sign_transaction( - key: &ed25519_dalek::SigningKey, - tx: &Transaction, - network_passphrase: &str, -) -> Result { - let tx_hash = transaction_hash(tx, network_passphrase)?; - let tx_signature = key.sign(&tx_hash); - - let decorated_signature = DecoratedSignature { - hint: SignatureHint(key.verifying_key().to_bytes()[28..].try_into()?), - signature: Signature(tx_signature.to_bytes().try_into()?), - }; - - Ok(TransactionEnvelope::Tx(TransactionV1Envelope { - tx: tx.clone(), - signatures: vec![decorated_signature].try_into()?, - })) -} - /// # Errors /// /// Might return an error