diff --git a/cmd/soroban-cli/src/commands/mod.rs b/cmd/soroban-cli/src/commands/mod.rs index f904c465f..df28339bd 100644 --- a/cmd/soroban-cli/src/commands/mod.rs +++ b/cmd/soroban-cli/src/commands/mod.rs @@ -12,8 +12,8 @@ pub mod global; pub mod keys; pub mod network; pub mod plugin; +pub mod txn; pub mod version; - pub mod txn_result; pub const HEADING_RPC: &str = "Options (RPC)"; @@ -101,6 +101,7 @@ impl Root { Cmd::Network(network) => network.run().await?, Cmd::Version(version) => version.run(), Cmd::Keys(id) => id.run().await?, + Cmd::Txn(tx) => tx.run().await?, Cmd::Cache(data) => data.run()?, }; Ok(()) @@ -135,6 +136,9 @@ pub enum Cmd { Network(network::Cmd), /// Print version information Version(version::Cmd), + /// Sign, Simulate, and Send transactions + #[command(subcommand)] + Txn(txn::Cmd), /// Cache for tranasctions and contract specs #[command(subcommand)] Cache(cache::Cmd), @@ -158,6 +162,8 @@ pub enum Error { #[error(transparent)] Network(#[from] network::Error), #[error(transparent)] + Txn(#[from] txn::Error), + #[error(transparent)] Cache(#[from] cache::Error), } diff --git a/cmd/soroban-cli/src/commands/txn/mod.rs b/cmd/soroban-cli/src/commands/txn/mod.rs new file mode 100644 index 000000000..9de360fcc --- /dev/null +++ b/cmd/soroban-cli/src/commands/txn/mod.rs @@ -0,0 +1,48 @@ +use clap::Parser; + +pub mod send; +pub mod sign; +pub mod simulate; +pub mod xdr; + +use stellar_xdr::cli as xdr_cli; + +#[derive(Debug, Parser)] +pub enum Cmd { + /// Add a new identity (keypair, ledger, macOS keychain) + Inspect(xdr_cli::Root), + /// Given an identity return its address (public key) + Sign(sign::Cmd), + /// Submit a transaction to the network + Send(send::Cmd), + /// Simulate a transaction + Simulate(simulate::Cmd), +} + +#[derive(thiserror::Error, Debug)] +pub enum Error { + /// An error during the simulation + #[error(transparent)] + Simulate(#[from] simulate::Error), + /// An error during the inspect + #[error(transparent)] + Inspect(#[from] xdr_cli::Error), + /// An error during the sign + #[error(transparent)] + Sign(#[from] sign::Error), + /// An error during the send + #[error(transparent)] + Send(#[from] send::Error), +} + +impl Cmd { + pub async fn run(&self) -> Result<(), Error> { + match self { + Cmd::Inspect(cmd) => cmd.run()?, + Cmd::Sign(cmd) => cmd.run().await?, + Cmd::Send(cmd) => cmd.run().await?, + Cmd::Simulate(cmd) => cmd.run().await?, + }; + Ok(()) + } +} diff --git a/cmd/soroban-cli/src/commands/txn/send.rs b/cmd/soroban-cli/src/commands/txn/send.rs new file mode 100644 index 000000000..1cf3f8478 --- /dev/null +++ b/cmd/soroban-cli/src/commands/txn/send.rs @@ -0,0 +1,35 @@ +use soroban_rpc::GetTransactionResponse; + +#[derive(thiserror::Error, Debug)] +pub enum Error { + #[error(transparent)] + XdrArgs(#[from] super::xdr::Error), + #[error(transparent)] + Config(#[from] super::super::config::Error), + #[error(transparent)] + Rpc(#[from] crate::rpc::Error), +} + +#[derive(Debug, clap::Parser, Clone)] +#[group(skip)] +pub struct Cmd { + #[clap(flatten)] + pub xdr_args: super::xdr::Args, + #[clap(flatten)] + pub config: super::super::config::Args, +} + +impl Cmd { + pub async fn run(&self) -> Result<(), Error> { + let response = self.send().await?; + println!("{response:#?}"); + Ok(()) + } + + pub async fn send(&self) -> Result { + let txn_env = self.xdr_args.txn_envelope()?; + let network = self.config.get_network()?; + let client = crate::rpc::Client::new(&network.rpc_url)?; + Ok(client.send_transaction(&txn_env).await?) + } +} diff --git a/cmd/soroban-cli/src/commands/txn/sign.rs b/cmd/soroban-cli/src/commands/txn/sign.rs new file mode 100644 index 000000000..ec4bb1155 --- /dev/null +++ b/cmd/soroban-cli/src/commands/txn/sign.rs @@ -0,0 +1,97 @@ +use std::io; + +// use crossterm::{ +// event::{self, Event, KeyCode, KeyEvent, KeyModifiers}, +// execute, +// terminal::{self, EnterAlternateScreen, LeaveAlternateScreen}, +// }; +use soroban_sdk::xdr::{self, Limits, TransactionEnvelope, WriteXdr}; + +use crate::signer::{self, InMemory, Stellar}; + +#[derive(thiserror::Error, Debug)] +pub enum Error { + #[error(transparent)] + XdrArgs(#[from] super::xdr::Error), + #[error(transparent)] + Signer(#[from] signer::Error), + #[error(transparent)] + Config(#[from] super::super::config::Error), + #[error(transparent)] + StellarStrkey(#[from] stellar_strkey::DecodeError), + #[error(transparent)] + Xdr(#[from] xdr::Error), + #[error(transparent)] + Io(#[from] io::Error), + #[error("User cancelled signing, perhaps need to add -y")] + UserCancelledSigning, +} + +#[derive(Debug, clap::Parser, Clone)] +#[group(skip)] +pub struct Cmd { + /// Confirm that a signature can be signed by the given keypair automatically. + #[arg(long, short = 'y')] + yes: bool, + #[clap(flatten)] + pub xdr_args: super::xdr::Args, + #[clap(flatten)] + pub config: super::super::config::Args, +} + +impl Cmd { + #[allow(clippy::unused_async)] + pub async fn run(&self) -> Result<(), Error> { + let envelope = self.sign()?; + println!("{}", envelope.to_xdr_base64(Limits::none())?.trim()); + Ok(()) + } + + pub fn sign(&self) -> Result { + let source = &self.config.source_account; + tracing::debug!("signing transaction with source account {}", source); + let txn = self.xdr_args.txn()?; + let key = self.config.key_pair()?; + let address = + stellar_strkey::ed25519::PublicKey::from_payload(key.verifying_key().as_bytes())?; + let in_memory = InMemory { + network_passphrase: self.config.get_network()?.network_passphrase, + keypairs: vec![key], + }; + self.prompt_user()?; + Ok(in_memory.sign_txn(txn, &stellar_strkey::Strkey::PublicKeyEd25519(address))?) + } + + pub fn prompt_user(&self) -> Result<(), Error> { + if self.yes { + return Ok(()); + } + Err(Error::UserCancelledSigning) + // TODO use crossterm to prompt user for confirmation + // // Set up the terminal + // let mut stdout = io::stdout(); + // execute!(stdout, EnterAlternateScreen)?; + // terminal::enable_raw_mode()?; + + // println!("Press 'y' or 'Y' for yes, any other key for no:"); + + // loop { + // if let Event::Key(KeyEvent { + // code, + // modifiers: KeyModifiers::NONE, + // .. + // }) = event::read()? + // { + // match code { + // KeyCode::Char('y' | 'Y') => break, + // _ => return Err(Error::UserCancelledSigning), + // } + // } + // } + + // // Clean up the terminal + // terminal::disable_raw_mode()?; + // execute!(stdout, LeaveAlternateScreen)?; + // Ok(()) + } +} diff --git a/cmd/soroban-cli/src/commands/txn/simulate.rs b/cmd/soroban-cli/src/commands/txn/simulate.rs new file mode 100644 index 000000000..5fbae28c0 --- /dev/null +++ b/cmd/soroban-cli/src/commands/txn/simulate.rs @@ -0,0 +1,38 @@ +use soroban_rpc::Assembled; +use soroban_sdk::xdr::{self, WriteXdr}; + +#[derive(thiserror::Error, Debug)] +pub enum Error { + #[error(transparent)] + XdrArgs(#[from] super::xdr::Error), + #[error(transparent)] + Config(#[from] super::super::config::Error), + #[error(transparent)] + Rpc(#[from] crate::rpc::Error), + #[error(transparent)] + Xdr(#[from] xdr::Error), +} + +#[derive(Debug, clap::Parser, Clone)] +#[group(skip)] +pub struct Cmd { + #[clap(flatten)] + pub xdr_args: super::xdr::Args, + #[clap(flatten)] + pub config: super::super::config::Args, +} + +impl Cmd { + pub async fn run(&self) -> Result<(), Error> { + let res = self.simulate().await?; + println!("{}", res.transaction().to_xdr_base64(xdr::Limits::none())?); + Ok(()) + } + + pub async fn simulate(&self) -> Result { + let tx = self.xdr_args.txn()?; + let network = self.config.get_network()?; + let client = crate::rpc::Client::new(&network.rpc_url)?; + Ok(client.create_assembled_transaction(&tx).await?) + } +} diff --git a/cmd/soroban-cli/src/commands/txn/xdr.rs b/cmd/soroban-cli/src/commands/txn/xdr.rs new file mode 100644 index 000000000..180c12e6b --- /dev/null +++ b/cmd/soroban-cli/src/commands/txn/xdr.rs @@ -0,0 +1,67 @@ +use std::{ + io::{stdin, Read}, + path::PathBuf, +}; + +use soroban_env_host::xdr::ReadXdr; +use soroban_sdk::xdr::{Limits, Transaction, TransactionEnvelope}; + +#[derive(Debug, thiserror::Error)] +pub enum Error { + #[error("failed to decode XDR from base64")] + Base64Decode, + #[error("failed to decode XDR from file: {0}")] + FileDecode(PathBuf), + #[error("failed to decode XDR from stdin")] + StdinDecode, + #[error(transparent)] + Io(#[from] std::io::Error), +} + +/// XDR input, either base64 encoded or file path and stdin if neither is provided +#[derive(Debug, clap::Args, Clone)] +#[group(skip)] +pub struct Args { + /// Base64 encoded XDR transaction + #[arg( + long = "xdr-base64", + env = "STELLAR_TXN_XDR_BASE64", + conflicts_with = "xdr_file" + )] + pub xdr_base64: Option, + //// File containing Binary encoded data + #[arg( + long = "xdr-file", + env = "STELLAR_TXN_XDR_FILE", + conflicts_with = "xdr_base64" + )] + pub xdr_file: Option, +} + +impl Args { + pub fn xdr(&self) -> Result { + match (self.xdr_base64.as_ref(), self.xdr_file.as_ref()) { + (Some(xdr_base64), None) => { + T::from_xdr_base64(xdr_base64, Limits::none()).map_err(|_| Error::Base64Decode) + } + (_, Some(xdr_file)) => T::from_xdr(std::fs::read(xdr_file)?, Limits::none()) + .map_err(|_| Error::FileDecode(xdr_file.clone())), + + _ => { + let mut buf = String::new(); + let _ = stdin() + .read_to_string(&mut buf) + .map_err(|_| Error::StdinDecode)?; + T::from_xdr_base64(buf.trim(), Limits::none()).map_err(|_| Error::StdinDecode) + } + } + } + + pub fn txn(&self) -> Result { + self.xdr::() + } + + pub fn txn_envelope(&self) -> Result { + self.xdr::() + } +} diff --git a/cmd/soroban-cli/src/lib.rs b/cmd/soroban-cli/src/lib.rs index 5cde45436..9a9900605 100644 --- a/cmd/soroban-cli/src/lib.rs +++ b/cmd/soroban-cli/src/lib.rs @@ -8,11 +8,13 @@ use std::path::Path; pub(crate) use soroban_env_host::xdr; pub(crate) use soroban_rpc as rpc; + pub mod commands; pub mod fee; pub mod key; pub mod log; pub mod toid; +pub mod signer; pub mod utils; pub mod wasm; diff --git a/cmd/soroban-cli/src/signer.rs b/cmd/soroban-cli/src/signer.rs new file mode 100644 index 000000000..bd5605598 --- /dev/null +++ b/cmd/soroban-cli/src/signer.rs @@ -0,0 +1,277 @@ +use ed25519_dalek::Signer; +use sha2::{Digest, Sha256}; + +use soroban_env_host::xdr::{ + self, AccountId, DecoratedSignature, Hash, HashIdPreimage, HashIdPreimageSorobanAuthorization, + InvokeHostFunctionOp, Limits, Operation, OperationBody, PublicKey, ScAddress, ScMap, ScSymbol, + ScVal, Signature, SignatureHint, SorobanAddressCredentials, SorobanAuthorizationEntry, + SorobanAuthorizedFunction, SorobanCredentials, Transaction, TransactionEnvelope, + TransactionSignaturePayload, TransactionSignaturePayloadTaggedTransaction, + TransactionV1Envelope, Uint256, WriteXdr, +}; + +#[derive(thiserror::Error, Debug)] +pub enum Error { + #[error(transparent)] + Xdr(#[from] xdr::Error), + #[error("Error signing transaction {address}")] + MissingSignerForAddress { address: String }, +} + +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()) +} + +/// A trait for signing Stellar transactions and Soroban authorization entries +pub trait Stellar { + /// The type of the options that can be passed when creating a new signer + type Init; + /// Create a new signer with the given network passphrase and options + fn new(network_passphrase: &str, options: Option) -> Self; + + /// Get the network hash + fn network_hash(&self) -> xdr::Hash; + + /// Sign a transaction hash with the given source account + /// # Errors + /// Returns an error if the source account is not found + fn sign_txn_hash( + &self, + txn: [u8; 32], + source_account: &stellar_strkey::Strkey, + ) -> Result; + + /// Sign a Soroban authorization entry with the given address + /// # Errors + /// Returns an error if the address is not found + fn sign_soroban_authorization_entry( + &self, + unsigned_entry: &SorobanAuthorizationEntry, + signature_expiration_ledger: u32, + address: &[u8; 32], + ) -> Result; + + /// Sign a Stellar transaction with the given source account + /// This is a default implementation that signs the transaction hash and returns a decorated signature + /// # Errors + /// Returns an error if the source account is not found + fn sign_txn( + &self, + txn: Transaction, + source_account: &stellar_strkey::Strkey, + ) -> Result { + let signature_payload = TransactionSignaturePayload { + network_id: self.network_hash(), + tagged_transaction: TransactionSignaturePayloadTaggedTransaction::Tx(txn.clone()), + }; + let hash = Sha256::digest(signature_payload.to_xdr(Limits::none())?).into(); + let decorated_signature = self.sign_txn_hash(hash, source_account)?; + Ok(TransactionEnvelope::Tx(TransactionV1Envelope { + tx: txn, + signatures: vec![decorated_signature].try_into()?, + })) + } + + /// Sign a Soroban authorization entries for a given transaction and set the expiration ledger + /// # Errors + /// Returns an error if the address is not found + fn sign_soroban_authorizations( + &self, + raw: &Transaction, + signature_expiration_ledger: u32, + ) -> Result, Error> { + let mut tx = raw.clone(); + let Some(mut op) = requires_auth(&tx) else { + return Ok(None); + }; + + let xdr::Operation { + body: OperationBody::InvokeHostFunction(ref mut body), + .. + } = op + else { + return Ok(None); + }; + + let signed_auths = body + .auth + .as_slice() + .iter() + .map(|raw_auth| { + self.maybe_sign_soroban_authorization_entry(raw_auth, signature_expiration_ledger) + }) + .collect::, Error>>()?; + + body.auth = signed_auths.try_into()?; + tx.operations = vec![op].try_into()?; + Ok(Some(tx)) + } + + /// Sign a Soroban authorization entry if the address is public key + /// # Errors + /// Returns an error if the address in entry is a contract + fn maybe_sign_soroban_authorization_entry( + &self, + unsigned_entry: &SorobanAuthorizationEntry, + signature_expiration_ledger: u32, + ) -> Result { + if let SorobanAuthorizationEntry { + credentials: SorobanCredentials::Address(SorobanAddressCredentials { ref address, .. }), + .. + } = unsigned_entry + { + // See if we have a signer for this authorizationEntry + // If not, then we Error + let needle = match address { + ScAddress::Account(AccountId(PublicKey::PublicKeyTypeEd25519(Uint256(ref a)))) => a, + ScAddress::Contract(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. + return Err(Error::MissingSignerForAddress { + address: stellar_strkey::Strkey::Contract(stellar_strkey::Contract(*c)) + .to_string(), + }); + } + }; + self.sign_soroban_authorization_entry( + unsigned_entry, + signature_expiration_ledger, + needle, + ) + } else { + Ok(unsigned_entry.clone()) + } + } +} + +use std::fmt::Debug; +#[derive(Debug)] +pub struct InMemory { + pub network_passphrase: String, + pub keypairs: Vec, +} + +impl InMemory { + pub fn get_key( + &self, + key: &stellar_strkey::Strkey, + ) -> Result<&ed25519_dalek::SigningKey, Error> { + match key { + stellar_strkey::Strkey::PublicKeyEd25519(stellar_strkey::ed25519::PublicKey(bytes)) => { + self.keypairs + .iter() + .find(|k| k.verifying_key().to_bytes() == *bytes) + } + _ => None, + } + .ok_or_else(|| Error::MissingSignerForAddress { + address: key.to_string(), + }) + } +} + +impl Stellar for InMemory { + type Init = Vec; + fn new(network_passphrase: &str, options: Option>) -> Self { + InMemory { + network_passphrase: network_passphrase.to_string(), + keypairs: options.unwrap_or_default(), + } + } + + fn sign_txn_hash( + &self, + txn: [u8; 32], + source_account: &stellar_strkey::Strkey, + ) -> Result { + let source_account = self.get_key(source_account)?; + let tx_signature = source_account.sign(&txn); + Ok(DecoratedSignature { + // TODO: remove this unwrap. It's safe because we know the length of the array + hint: SignatureHint( + source_account.verifying_key().to_bytes()[28..] + .try_into() + .unwrap(), + ), + signature: Signature(tx_signature.to_bytes().try_into()?), + }) + } + + fn sign_soroban_authorization_entry( + &self, + unsigned_entry: &SorobanAuthorizationEntry, + signature_expiration_ledger: u32, + signer: &[u8; 32], + ) -> Result { + let mut auth = unsigned_entry.clone(); + let SorobanAuthorizationEntry { + credentials: SorobanCredentials::Address(ref mut credentials), + .. + } = auth + else { + // Doesn't need special signing + return Ok(auth); + }; + let SorobanAddressCredentials { nonce, .. } = credentials; + + let preimage = HashIdPreimage::SorobanAuthorization(HashIdPreimageSorobanAuthorization { + network_id: self.network_hash(), + invocation: auth.root_invocation.clone(), + nonce: *nonce, + signature_expiration_ledger, + }) + .to_xdr(Limits::none())?; + + let strkey = stellar_strkey::ed25519::PublicKey(*signer); + let payload = Sha256::digest(preimage); + let signer = self.get_key(&stellar_strkey::Strkey::PublicKeyEd25519(strkey))?; + let signature = signer.sign(&payload); + + let map = ScMap::sorted_from(vec![ + ( + ScVal::Symbol(ScSymbol("public_key".try_into()?)), + ScVal::Bytes( + signer + .verifying_key() + .to_bytes() + .to_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)?, + )); + credentials.signature_expiration_ledger = signature_expiration_ledger; + auth.credentials = SorobanCredentials::Address(credentials.clone()); + + Ok(auth) + } + + fn network_hash(&self) -> xdr::Hash { + xdr::Hash(Sha256::digest(self.network_passphrase.as_bytes()).into()) + } +}