diff --git a/Cargo.lock b/Cargo.lock index bb711de280..59cace36e8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3193,6 +3193,17 @@ dependencies = [ "redox_syscall", ] +[[package]] +name = "libredox" +version = "0.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3af92c55d7d839293953fcd0fda5ecfe93297cfde6ffbdec13b41d99c0ba6607" +dependencies = [ + "bitflags 2.4.2", + "libc", + "redox_syscall", +] + [[package]] name = "link-cplusplus" version = "1.0.9" @@ -3406,6 +3417,12 @@ dependencies = [ "libc", ] +[[package]] +name = "numtoa" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8f8bdf33df195859076e54ab11ee78a1b208382d3a26ec40d142ffc1ecc49ef" + [[package]] name = "object" version = "0.32.2" @@ -3881,6 +3898,12 @@ dependencies = [ "bitflags 1.3.2", ] +[[package]] +name = "redox_termios" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20145670ba436b55d91fc92d25e71160fbfbdd57831631c8d7d36377a476f1cb" + [[package]] name = "redox_users" version = "0.4.4" @@ -3888,7 +3911,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a18479200779601e498ada4e8c1e1f50e3ee19deb0259c25825a98b5603b2cb4" dependencies = [ "getrandom", - "libredox", + "libredox 0.0.1", "thiserror", ] @@ -4722,6 +4745,7 @@ dependencies = [ "tempfile", "termcolor", "termcolor_output", + "termion", "thiserror", "tokio", "toml 0.5.11", @@ -5298,6 +5322,18 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f34dde0bb841eb3762b42bdff8db11bbdbc0a3bd7b32012955f5ce1d081f86c1" +[[package]] +name = "termion" +version = "4.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ccce68e518d1173e80876edd54760b60b792750d0cab6444a79101c6ea03848" +dependencies = [ + "libc", + "libredox 0.0.2", + "numtoa", + "redox_termios", +] + [[package]] name = "termtree" version = "0.4.1" diff --git a/cmd/crates/soroban-test/tests/it/integration/custom_types.rs b/cmd/crates/soroban-test/tests/it/integration/custom_types.rs index 6739ff3da1..668a91aa78 100644 --- a/cmd/crates/soroban-test/tests/it/integration/custom_types.rs +++ b/cmd/crates/soroban-test/tests/it/integration/custom_types.rs @@ -9,7 +9,13 @@ use super::util::invoke_with_roundtrip; fn invoke_custom(e: &TestEnv, id: &str, func: &str) -> assert_cmd::Command { let mut s = e.new_assert_cmd("contract"); - s.arg("invoke").arg("--id").arg(id).arg("--").arg(func); + s.env("RUST_LOG", "soroban_cli::log::diagnostic_event=off") + .arg("invoke") + .arg("--id") + .arg(id) + .arg("--is-view") + .arg("--") + .arg(func); s } diff --git a/cmd/crates/soroban-test/tests/it/integration/hello_world.rs b/cmd/crates/soroban-test/tests/it/integration/hello_world.rs index 16c13dd250..fc0c56bd8e 100644 --- a/cmd/crates/soroban-test/tests/it/integration/hello_world.rs +++ b/cmd/crates/soroban-test/tests/it/integration/hello_world.rs @@ -73,8 +73,8 @@ async fn invoke() { .stdout_as_str(); let dir = sandbox.dir(); let seed_phrase = std::fs::read_to_string(dir.join(".soroban/identity/test.toml")).unwrap(); - let s = toml::from_str::(&seed_phrase).unwrap(); - let secret::Secret::SeedPhrase { seed_phrase } = s else { + let s = toml::from_str::(&seed_phrase).unwrap(); + let secret::Signer::SeedPhrase { seed_phrase } = s else { panic!("Expected seed phrase") }; let id = &deploy_hello(sandbox).await; @@ -106,7 +106,7 @@ async fn invoke() { config_locator .write_identity( "testone", - &secret::Secret::SecretKey { + &secret::Signer::SecretKey { secret_key: secret_key_1.clone(), }, ) @@ -222,6 +222,7 @@ async fn invoke_auth_with_different_test_account_fail(sandbox: &TestEnv, id: &st "--hd-path=0", "--id", id, + "--fee=1000000", "--", "auth", &format!("--addr={addr}"), @@ -230,8 +231,8 @@ async fn invoke_auth_with_different_test_account_fail(sandbox: &TestEnv, id: &st .await; let e = res.unwrap_err(); assert!( - matches!(e, contract::invoke::Error::Config(_)), - "Expected config error got {e:?}" + matches!(e, contract::invoke::Error::Rpc(_)), + "Expected rpc error got {e:?}" ); } diff --git a/cmd/crates/soroban-test/tests/it/integration/tx.rs b/cmd/crates/soroban-test/tests/it/integration/tx.rs index 9f5204a8db..450c459b4a 100644 --- a/cmd/crates/soroban-test/tests/it/integration/tx.rs +++ b/cmd/crates/soroban-test/tests/it/integration/tx.rs @@ -1,4 +1,6 @@ -use soroban_sdk::xdr::{Limits, ReadXdr, TransactionEnvelope, WriteXdr}; +use soroban_sdk::xdr::{ + Limits, ReadXdr, TransactionEnvelope, TransactionV1Envelope, VecM, WriteXdr, +}; use soroban_test::{AssertExt, TestEnv}; use crate::integration::util::{deploy_contract, DeployKind, HELLO_WORLD}; @@ -30,3 +32,49 @@ async fn txn_simulate() { assembled_str ); } + +#[tokio::test] +async fn txn_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 xdr_base64 = deploy_contract(sandbox, HELLO_WORLD, DeployKind::SimOnly).await; + println!("{xdr_base64}"); + let tx_env = TransactionEnvelope::from_xdr_base64(&xdr_base64, Limits::none()).unwrap(); + let tx_env = sign(sandbox, tx_env); + + println!( + "Transaction to send:\n{}", + tx_env.to_xdr_base64(Limits::none()).unwrap() + ); + + let assembled_str = sandbox + .new_assert_cmd("tx") + .arg("send") + .arg("--source=test") + .write_stdin(tx_env.to_xdr_base64(Limits::none()).unwrap()) + .assert() + .success() + .stdout_as_str(); + println!("Transaction sent: {assembled_str}"); +} + +fn sign(sandbox: &TestEnv, tx_env: TransactionEnvelope) -> TransactionEnvelope { + TransactionEnvelope::from_xdr_base64( + sandbox + .new_assert_cmd("tx") + .arg("sign") + .arg("--source=test") + .write_stdin(tx_env.to_xdr_base64(Limits::none()).unwrap().as_bytes()) + .assert() + .success() + .stdout_as_str(), + Limits::none(), + ) + .unwrap() +} diff --git a/cmd/crates/stellar-ledger/src/hd_path.rs b/cmd/crates/stellar-ledger/src/hd_path.rs index 07ed133f17..4a207d6449 100644 --- a/cmd/crates/stellar-ledger/src/hd_path.rs +++ b/cmd/crates/stellar-ledger/src/hd_path.rs @@ -1,9 +1,8 @@ -use crate::Error; - #[derive(Clone, Copy)] pub struct HdPath(pub u32); impl HdPath { + #[must_use] pub fn depth(&self) -> u8 { let path: slip10::BIP32Path = self.into(); path.depth() @@ -23,7 +22,8 @@ impl From<&u32> for HdPath { } impl HdPath { - pub fn to_vec(&self) -> Result, Error> { + #[must_use] + pub fn to_vec(&self) -> Vec { hd_path_to_bytes(&self.into()) } } @@ -35,16 +35,11 @@ impl From<&HdPath> for slip10::BIP32Path { } } -fn hd_path_to_bytes(hd_path: &slip10::BIP32Path) -> Result, Error> { +fn hd_path_to_bytes(hd_path: &slip10::BIP32Path) -> Vec { let hd_path_indices = 0..hd_path.depth(); - let result = hd_path_indices + // Unsafe unwrap is safe because the depth is the length of interneal vector + hd_path_indices .into_iter() - .map(|index| { - Ok(hd_path - .index(index) - .ok_or_else(|| Error::Bip32PathError(format!("{hd_path}")))? - .to_be_bytes()) - }) - .collect::, Error>>()?; - Ok(result.into_iter().flatten().collect()) + .flat_map(|index| unsafe { hd_path.index(index).unwrap_unchecked().to_be_bytes() }) + .collect() } diff --git a/cmd/crates/stellar-ledger/src/lib.rs b/cmd/crates/stellar-ledger/src/lib.rs index c74365af21..69789b7351 100644 --- a/cmd/crates/stellar-ledger/src/lib.rs +++ b/cmd/crates/stellar-ledger/src/lib.rs @@ -1,8 +1,9 @@ use hd_path::HdPath; -use ledger_transport::{APDUCommand, Exchange}; +use ledger_transport::APDUCommand; +pub use ledger_transport_hid::TransportNativeHID; use ledger_transport_hid::{ hidapi::{HidApi, HidError}, - LedgerHIDError, TransportNativeHID, + LedgerHIDError, }; use soroban_env_host::xdr::{Hash, Transaction}; @@ -15,6 +16,8 @@ use stellar_xdr::curr::{ pub use crate::signer::Blob; pub mod hd_path; +pub use ledger_transport::Exchange; + mod signer; // this is from https://github.com/LedgerHQ/ledger-live/blob/36cfbf3fa3300fd99bcee2ab72e1fd8f280e6280/libs/ledgerjs/packages/hw-app-str/src/Str.ts#L181 @@ -80,6 +83,11 @@ pub struct LedgerSigner { unsafe impl Send for LedgerSigner where T: Exchange {} unsafe impl Sync for LedgerSigner where T: Exchange {} +/// Returns a new `LedgerSigner` with a native HID transport, e.i. the transport is connected to the Ledger device +/// +/// # Errors +/// +/// Returns an error if there is an issue with connecting with the device pub fn native() -> Result, Error> { Ok(LedgerSigner { transport: get_transport()?, @@ -93,11 +101,7 @@ where pub fn new(transport: T) -> Self { Self { transport } } - pub fn native() -> Result, Error> { - Ok(LedgerSigner { - transport: get_transport()?, - }) - } + /// Get the device app's configuration /// # Errors /// Returns an error if there is an issue with connecting with the device or getting the config from the device @@ -141,7 +145,7 @@ where }; let mut signature_payload_as_bytes = signature_payload.to_xdr(Limits::none())?; - let mut hd_path_to_bytes = hd_path.into().to_vec()?; + let mut hd_path_to_bytes = hd_path.into().to_vec(); let capacity = 1 + hd_path_to_bytes.len() + signature_payload_as_bytes.len(); let mut data: Vec = Vec::with_capacity(capacity); @@ -182,7 +186,9 @@ where } /// The `display_and_confirm` bool determines if the Ledger will display the public key on its screen and requires user approval to share - async fn get_public_key_with_display_flag( + /// # Errors + /// Returns an error if there is an issue with connecting with the device or getting the public key from the device + pub async fn get_public_key_with_display_flag( &self, hd_path: impl Into, display_and_confirm: bool, @@ -191,7 +197,7 @@ where // the first element of the data should be the number of elements in the path let hd_path = hd_path.into(); let hd_path_elements_count = hd_path.depth(); - let mut hd_path_to_bytes = hd_path.to_vec()?; + let mut hd_path_to_bytes = hd_path.to_vec(); hd_path_to_bytes.insert(0, hd_path_elements_count); let p2 = if display_and_confirm { @@ -241,6 +247,30 @@ where )), } } + + /// Sign a blob of data with the account on the Ledger device + /// # Errors + /// Returns an error if there is an issue with connecting with the device or signing the given tx on the device + pub async fn sign_data(&self, index: &HdPath, blob: &[u8]) -> Result, Error> { + let mut hd_path_to_bytes = index.to_vec(); + + let capacity = 1 + hd_path_to_bytes.len() + blob.len(); + let mut data: Vec = Vec::with_capacity(capacity); + + data.insert(0, HD_PATH_ELEMENTS_COUNT); + data.append(&mut hd_path_to_bytes); + data.extend_from_slice(blob); + + let command = APDUCommand { + cla: CLA, + ins: SIGN_TX_HASH, + p1: P1_SIGN_TX_HASH, + p2: P2_SIGN_TX_HASH, + data, + }; + + self.send_command_to_ledger(command).await + } } #[async_trait::async_trait] @@ -265,24 +295,7 @@ where /// # Errors /// Returns an error if there is an issue with connecting with the device or signing the given tx on the device. Or, if the device has not enabled hash signing async fn sign_blob(&self, index: &Self::Key, blob: &[u8]) -> Result, Error> { - let mut hd_path_to_bytes = index.to_vec()?; - - let capacity = 1 + hd_path_to_bytes.len() + blob.len(); - let mut data: Vec = Vec::with_capacity(capacity); - - data.insert(0, HD_PATH_ELEMENTS_COUNT); - data.append(&mut hd_path_to_bytes); - data.extend_from_slice(blob); - - let command = APDUCommand { - cla: CLA, - ins: SIGN_TX_HASH, - p1: P1_SIGN_TX_HASH, - p2: P2_SIGN_TX_HASH, - data, - }; - - self.send_command_to_ledger(command).await + self.sign_data(index, blob).await } } diff --git a/cmd/soroban-cli/Cargo.toml b/cmd/soroban-cli/Cargo.toml index 075a1ccb7f..d8eb044de0 100644 --- a/cmd/soroban-cli/Cargo.toml +++ b/cmd/soroban-cli/Cargo.toml @@ -116,6 +116,7 @@ rust-embed = { version = "8.2.0", features = ["debug-embed"] } bollard = { workspace=true } futures-util = "0.3.30" home = "0.5.9" +termion = "4.0.0" # For hyper-tls [target.'cfg(unix)'.dependencies] openssl = { version = "=0.10.55", features = ["vendored"] } diff --git a/cmd/soroban-cli/src/commands/tx/mod.rs b/cmd/soroban-cli/src/commands/tx/mod.rs index 48161e3c76..cedfaa3229 100644 --- a/cmd/soroban-cli/src/commands/tx/mod.rs +++ b/cmd/soroban-cli/src/commands/tx/mod.rs @@ -2,6 +2,8 @@ use clap::Parser; use super::global; +pub mod send; +pub mod sign; pub mod simulate; pub mod xdr; @@ -9,19 +11,28 @@ pub mod xdr; pub enum Cmd { /// Simulate a transaction envelope from stdin Simulate(simulate::Cmd), + /// Sign a transaction with a ledger or local key + Sign(sign::Cmd), + /// Send a transaction envelope to the network + Send(send::Cmd), } #[derive(thiserror::Error, Debug)] pub enum Error { - /// An error during the simulation #[error(transparent)] Simulate(#[from] simulate::Error), + #[error(transparent)] + Send(#[from] send::Error), + #[error(transparent)] + Sign(#[from] sign::Error), } impl Cmd { pub async fn run(&self, global_args: &global::Args) -> Result<(), Error> { match self { Cmd::Simulate(cmd) => cmd.run(global_args).await?, + Cmd::Sign(cmd) => cmd.run().await?, + Cmd::Send(cmd) => cmd.run(global_args).await?, }; Ok(()) } diff --git a/cmd/soroban-cli/src/commands/tx/send.rs b/cmd/soroban-cli/src/commands/tx/send.rs new file mode 100644 index 0000000000..08c32c2da7 --- /dev/null +++ b/cmd/soroban-cli/src/commands/tx/send.rs @@ -0,0 +1,53 @@ +use async_trait::async_trait; +use soroban_rpc::GetTransactionResponse; + +use crate::commands::{config, global, NetworkRunnable}; + +#[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)] + SerdeJson(#[from] serde_json::Error), +} + +#[derive(Debug, clap::Parser, Clone)] +#[group(skip)] +/// Command to send a transaction envelope to the network +/// e.g. `cat file.txt | soroban tx send` +pub struct Cmd { + #[clap(flatten)] + pub config: super::super::config::Args, +} + +impl Cmd { + pub async fn run(&self, global_args: &global::Args) -> Result<(), Error> { + let response = self + .run_against_rpc_server(Some(global_args), Some(&self.config)) + .await?; + println!("{}", serde_json::to_string_pretty(&response)?); + Ok(()) + } +} + +#[async_trait] +impl NetworkRunnable for Cmd { + type Error = Error; + + type Result = GetTransactionResponse; + async fn run_against_rpc_server( + &self, + _: Option<&global::Args>, + config: Option<&config::Args>, + ) -> Result { + let config = config.unwrap_or(&self.config); + let network = config.get_network()?; + let client = crate::rpc::Client::new(&network.rpc_url)?; + let tx_env = super::xdr::tx_envelope_from_stdin()?; + Ok(client.send_transaction_polling(&tx_env).await?) + } +} 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 0000000000..d4a201244a --- /dev/null +++ b/cmd/soroban-cli/src/commands/tx/sign.rs @@ -0,0 +1,39 @@ +use crate::xdr::{self, Limits, Transaction, TransactionEnvelope, 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)] + Xdr(#[from] xdr::Error), +} + +#[derive(Debug, clap::Parser, Clone)] +#[group(skip)] +pub struct Cmd { + #[clap(flatten)] + pub config: super::super::config::Args, +} + +impl Cmd { + #[allow(clippy::unused_async)] + pub async fn run(&self) -> Result<(), Error> { + let txn_env = super::xdr::tx_envelope_from_stdin()?; + let envelope = self.sign_env(txn_env).await?; + println!("{}", envelope.to_xdr_base64(Limits::none())?.trim()); + Ok(()) + } + + pub async fn sign(&self, tx: Transaction) -> Result { + Ok(self.config.sign(tx).await?) + } + + pub async fn sign_env( + &self, + tx_env: TransactionEnvelope, + ) -> Result { + self.sign(super::xdr::unwrap_envelope_v1(tx_env)?).await + } +} diff --git a/cmd/soroban-cli/src/signer.rs b/cmd/soroban-cli/src/signer.rs index 580a61a5e0..af865b1101 100644 --- a/cmd/soroban-cli/src/signer.rs +++ b/cmd/soroban-cli/src/signer.rs @@ -1,7 +1,8 @@ use ed25519_dalek::ed25519::signature::Signer; use sha2::{Digest, Sha256}; +use termion::{event::Key, get_tty, input::TermRead}; -use soroban_env_host::xdr::{ +use crate::xdr::{ self, AccountId, DecoratedSignature, Hash, HashIdPreimage, HashIdPreimageSorobanAuthorization, InvokeHostFunctionOp, Limits, Operation, OperationBody, PublicKey, ScAddress, ScMap, ScSymbol, ScVal, Signature, SignatureHint, SorobanAddressCredentials, SorobanAuthorizationEntry, @@ -9,6 +10,7 @@ use soroban_env_host::xdr::{ TransactionSignaturePayload, TransactionSignaturePayloadTaggedTransaction, TransactionV1Envelope, Uint256, WriteXdr, }; +use stellar_ledger::{Exchange, LedgerSigner}; #[derive(thiserror::Error, Debug)] pub enum Error { @@ -19,11 +21,11 @@ pub enum Error { #[error("Missing signing key for account {address}")] MissingSignerForAddress { address: String }, #[error(transparent)] - TryFromSlice(#[from] std::array::TryFromSliceError), - #[error("User cancelled signing, perhaps need to add -y")] - UserCancelledSigning, + Ledger(#[from] stellar_ledger::Error), #[error(transparent)] Xdr(#[from] xdr::Error), + #[error("User cancelled signing, perhaps need to remove --check")] + UserCancelledSigning, } fn requires_auth(txn: &Transaction) -> Option { @@ -41,53 +43,154 @@ fn requires_auth(txn: &Transaction) -> Option { .then(move || op.clone()) } -// Use the given source_key and signers, to sign all SorobanAuthorizationEntry's in the given -// transaction. If unable to sign, return an error. -pub fn sign_soroban_authorizations( - raw: &Transaction, - source_key: &ed25519_dalek::SigningKey, - signers: &[ed25519_dalek::SigningKey], - 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); - }; +/// A trait for signing Stellar transactions and Soroban authorization entries +#[allow(async_fn_in_trait)] +pub trait Stellar { + async fn get_public_key(&self) -> Result; - let Operation { - body: OperationBody::InvokeHostFunction(ref mut body), - .. - } = op - else { - return Ok(None); - }; + async fn sign_blob(&self, blob: &[u8]) -> Result, Error>; - let network_id = Hash(Sha256::digest(network_passphrase.as_bytes()).into()); - - let verification_key = source_key.verifying_key(); - let source_address = verification_key.as_bytes(); - - let signed_auths = body - .auth - .as_slice() - .iter() - .map(|raw_auth| { - let mut auth = raw_auth.clone(); - let SorobanAuthorizationEntry { - credentials: SorobanCredentials::Address(ref mut credentials), - .. - } = auth - else { - // Doesn't need special signing - return Ok(auth); - }; - let SorobanAddressCredentials { ref address, .. } = credentials; + /// Sign a transaction hash with the given source account + /// # Errors + /// Returns an error if the source account is not found + async fn sign_txn_hash(&self, txn: [u8; 32]) -> Result { + let source_account = self.get_public_key().await?; + eprintln!( + "{} about to sign hash: {}", + source_account.to_string(), + hex::encode(txn) + ); + let tx_signature = self.sign_blob(&txn).await?; + Ok(DecoratedSignature { + // TODO: remove this unwrap. It's safe because we know the length of the array + hint: SignatureHint(source_account.0[28..].try_into().unwrap()), + signature: Signature(tx_signature.try_into()?), + }) + } + + /// Sign a Soroban authorization entry with the given address + /// # Errors + /// Returns an error if the address is not found + async fn sign_soroban_authorization_entry( + &self, + unsigned_entry: &SorobanAuthorizationEntry, + network_passphrase: &str, + ) -> Result { + let address = self.get_public_key().await?; + 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, + signature_expiration_ledger, + .. + } = credentials; + + let preimage = HashIdPreimage::SorobanAuthorization(HashIdPreimageSorobanAuthorization { + network_id: hash(network_passphrase), + invocation: auth.root_invocation.clone(), + nonce: *nonce, + signature_expiration_ledger: *signature_expiration_ledger, + }) + .to_xdr(Limits::none())?; + + let payload = Sha256::digest(preimage); + let signature = self.sign_blob(&payload).await?; + + let map = ScMap::sorted_from(vec![ + ( + ScVal::Symbol(ScSymbol("public_key".try_into()?)), + ScVal::Bytes(address.0.to_vec().try_into()?), + ), + ( + ScVal::Symbol(ScSymbol("signature".try_into()?)), + ScVal::Bytes(signature.try_into()?), + ), + ])?; + credentials.signature = ScVal::Vec(Some(vec![ScVal::Map(Some(map))].try_into()?)); + auth.credentials = SorobanCredentials::Address(credentials.clone()); + + Ok(auth) + } + + /// 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 + async fn sign_txn( + &self, + txn: Transaction, + network_passphrase: &str, + ) -> Result { + let signature_payload = TransactionSignaturePayload { + network_id: hash(network_passphrase), + 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).await?; + 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 + async fn sign_soroban_authorizations( + &self, + raw: &Transaction, + network_passphrase: &str, + ) -> 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 mut auths = body.auth.to_vec(); + for auth in &mut auths { + *auth = self + .maybe_sign_soroban_authorization_entry(auth, network_passphrase) + .await?; + } + body.auth = 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 + async fn maybe_sign_soroban_authorization_entry( + &self, + unsigned_entry: &SorobanAuthorizationEntry, + network_passphrase: &str, + ) -> 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::Account(AccountId(PublicKey::PublicKeyTypeEd25519(Uint256(a)))) => { + stellar_strkey::ed25519::PublicKey(*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. @@ -97,121 +200,88 @@ pub fn sign_soroban_authorizations( }); } }; - let signer = if let Some(s) = signers - .iter() - .find(|s| needle == s.verifying_key().as_bytes()) - { - s - } else if needle == source_address { - // This is the source address, so we can sign it - source_key - } else { - // We don't have a signer for this address - return Err(Error::MissingSignerForAddress { - address: stellar_strkey::Strkey::PublicKeyEd25519( - stellar_strkey::ed25519::PublicKey(*needle), - ) - .to_string(), - }); + if needle == self.get_public_key().await? { + return Ok(unsigned_entry.clone()); + } + self.sign_soroban_authorization_entry(unsigned_entry, network_passphrase) + .await + } else { + Ok(unsigned_entry.clone()) + } + } +} + +fn hash(network_passphrase: &str) -> xdr::Hash { + xdr::Hash(Sha256::digest(network_passphrase.as_bytes()).into()) +} + +pub struct LocalKey { + key: ed25519_dalek::SigningKey, + prompt: bool, +} + +impl LocalKey { + pub fn new(key: ed25519_dalek::SigningKey, prompt: bool) -> Self { + Self { key, prompt } + } +} + +impl Stellar for LocalKey { + async fn sign_blob(&self, data: &[u8]) -> Result, Error> { + if self.prompt { + eprintln!("Press 'y' or 'Y' for yes, any other key for no:"); + match read_key() { + 'y' | 'Y' => { + eprintln!("Signing now..."); + } + _ => return Err(Error::UserCancelledSigning), }; + } + let sig = self.key.sign(data); + Ok(sig.to_bytes().to_vec()) + } - sign_soroban_authorization_entry( - raw_auth, - signer, - signature_expiration_ledger, - &network_id, - ) - }) - .collect::, Error>>()?; + async fn get_public_key(&self) -> Result { + Ok(stellar_strkey::ed25519::PublicKey( + self.key.verifying_key().to_bytes(), + )) + } +} - body.auth = signed_auths.try_into()?; - tx.operations = vec![op].try_into()?; - Ok(Some(tx)) +pub struct Ledger { + index: u32, + signer: LedgerSigner, } -fn sign_soroban_authorization_entry( - raw: &SorobanAuthorizationEntry, - signer: &ed25519_dalek::SigningKey, - signature_expiration_ledger: u32, - network_id: &Hash, -) -> Result { - let mut auth = raw.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: network_id.clone(), - invocation: auth.root_invocation.clone(), - nonce: *nonce, - signature_expiration_ledger, - }) - .to_xdr(Limits::none())?; - - let payload = Sha256::digest(preimage); - 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) +pub fn native(index: u32) -> Result, Error> { + let signer = stellar_ledger::native()?; + Ok(Ledger { index, signer }) } -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); - - let decorated_signature = DecoratedSignature { - hint: SignatureHint(key.verifying_key().to_bytes()[28..].try_into()?), - signature: Signature(tx_signature.to_bytes().try_into()?), - }; +impl Stellar for Ledger +where + T: Exchange, +{ + async fn get_public_key(&self) -> Result { + Ok(self + .signer + .get_public_key_with_display_flag(self.index, false) + .await?) + } - Ok(TransactionEnvelope::Tx(TransactionV1Envelope { - tx: tx.clone(), - signatures: [decorated_signature].try_into()?, - })) + async fn sign_blob(&self, blob: &[u8]) -> Result, Error> { + Ok(self.signer.sign_data(&self.index.into(), blob).await?) + } } -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 fn read_key() -> char { + let tty = get_tty().unwrap(); + if let Some(key) = tty.keys().next() { + match key.unwrap() { + Key::Char(c) => c, + _ => '_', + } + } else { + ' ' + } }