diff --git a/.changelog/unreleased/improvements/3715-improve-offline-tx-handling.md b/.changelog/unreleased/improvements/3715-improve-offline-tx-handling.md new file mode 100644 index 0000000000..9a691e5495 --- /dev/null +++ b/.changelog/unreleased/improvements/3715-improve-offline-tx-handling.md @@ -0,0 +1,3 @@ +- Improve the format of dumped txs. Added command to + generate signature without requiring a network connection. + ([\#3715](https://github.com/anoma/namada/pull/3715)) \ No newline at end of file diff --git a/.github/workflows/scripts/e2e.json b/.github/workflows/scripts/e2e.json index 163a87fa99..3bf6805313 100644 --- a/.github/workflows/scripts/e2e.json +++ b/.github/workflows/scripts/e2e.json @@ -30,4 +30,4 @@ "e2e::wallet_tests::wallet_encrypted_key_cmds_env_var": 1, "e2e::wallet_tests::wallet_unencrypted_key_cmds": 1, "e2e::ledger_tests::masp_txs_and_queries": 82 -} +} \ No newline at end of file diff --git a/Cargo.lock b/Cargo.lock index 379342f7e2..0aab5e5b4e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5786,12 +5786,6 @@ dependencies = [ "num-traits 0.2.17", ] -[[package]] -name = "num-conv" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9" - [[package]] name = "num-derive" version = "0.3.3" @@ -8359,13 +8353,12 @@ dependencies = [ [[package]] name = "time" -version = "0.3.36" +version = "0.3.30" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5dfd88e563464686c916c7e46e623e520ddc6d79fa6641390f2e3fa86e83e885" +checksum = "c4a34ab300f2dee6e562c10a046fc05e358b29f9bf92277f30c3c8d82275f6f5" dependencies = [ "deranged", "itoa", - "num-conv", "powerfmt", "serde", "time-core", @@ -8380,11 +8373,10 @@ checksum = "ef927ca75afb808a4d64dd374f00a2adf8d0fcff8e7b184af886c3c87ec4a3f3" [[package]] name = "time-macros" -version = "0.2.18" +version = "0.2.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f252a68540fde3a3877aeea552b832b40ab9a69e318efd078774a01ddee1ccf" +checksum = "4ad70d68dba9e1f8aceda7aa6711965dfec1cac869f311a51bd08b3a2ccbce20" dependencies = [ - "num-conv", "time-core", ] diff --git a/crates/apps_lib/src/cli.rs b/crates/apps_lib/src/cli.rs index 9955a52797..949416cf6c 100644 --- a/crates/apps_lib/src/cli.rs +++ b/crates/apps_lib/src/cli.rs @@ -33,6 +33,7 @@ const WALLET_CMD: &str = "wallet"; const RELAYER_CMD: &str = "relayer"; pub mod cmds { + use super::args::CliTypes; use super::utils::*; use super::{ args, ArgMatches, CLIENT_CMD, NODE_CMD, RELAYER_CMD, WALLET_CMD, @@ -2458,6 +2459,7 @@ pub mod cmds { InitGenesisEstablishedAccount(InitGenesisEstablishedAccount), InitGenesisValidator(InitGenesisValidator), PkToTmAddress(PkToTmAddress), + SignOffline(SignOffline), DefaultBaseDir(DefaultBaseDir), EpochSleep(EpochSleep), ValidateGenesisTemplates(ValidateGenesisTemplates), @@ -2486,6 +2488,8 @@ pub mod cmds { SubCmd::parse(matches).map(Self::InitGenesisValidator); let pk_to_tm_address = SubCmd::parse(matches).map(Self::PkToTmAddress); + let sign_offline = + SubCmd::parse(matches).map(Self::SignOffline); let default_base_dir = SubCmd::parse(matches).map(Self::DefaultBaseDir); let epoch_sleep = SubCmd::parse(matches).map(Self::EpochSleep); @@ -2508,6 +2512,7 @@ pub mod cmds { .or(validate_genesis_templates) .or(genesis_tx) .or(parse_migrations_json) + .or(sign_offline) }) } @@ -2522,6 +2527,7 @@ pub mod cmds { .subcommand(InitGenesisEstablishedAccount::def()) .subcommand(InitGenesisValidator::def()) .subcommand(PkToTmAddress::def()) + .subcommand(SignOffline::def()) .subcommand(DefaultBaseDir::def()) .subcommand(EpochSleep::def()) .subcommand(ValidateGenesisTemplates::def()) @@ -3189,6 +3195,25 @@ pub mod cmds { } } + #[derive(Clone, Debug)] + pub struct SignOffline(pub args::SignOffline); + + impl SubCmd for SignOffline { + const CMD: &'static str = "sign-offline"; + + fn parse(matches: &ArgMatches) -> Option { + matches.subcommand_matches(Self::CMD).map(|matches| { + Self(args::SignOffline::::parse(matches)) + }) + } + + fn def() -> App { + App::new(Self::CMD) + .about(wrap!("Offlne sign a transaction.")) + .add_args::>() + } + } + #[derive(Clone, Debug)] pub struct DefaultBaseDir(pub args::DefaultBaseDir); @@ -3444,6 +3469,8 @@ pub mod args { DefaultFn(|| PortId::from_str("transfer").unwrap()), ); pub const PRE_GENESIS: ArgFlag = flag("pre-genesis"); + pub const PRIVATE_KEYS: ArgMulti = + arg_multi("secret-keys"); pub const PROPOSAL_PGF_STEWARD: ArgFlag = flag("pgf-stewards"); pub const PROPOSAL_PGF_FUNDING: ArgFlag = flag("pgf-funding"); pub const PROTOCOL_KEY: ArgOpt = arg_opt("protocol-key"); @@ -8040,6 +8067,70 @@ pub mod args { } } + #[derive(Clone, Debug)] + pub struct SignOffline { + pub tx_path: PathBuf, + pub secret_keys: Vec, + pub owner: C::Address, + pub output_folder_path: Option, + } + + impl Args for SignOffline { + fn parse(matches: &ArgMatches) -> Self { + let tx_path = DATA_PATH.parse(matches); + let secret_keys = PRIVATE_KEYS.parse(matches); + let owner = OWNER.parse(matches); + let output_folder_path = OUTPUT_FOLDER_PATH.parse(matches); + + Self { + tx_path, + secret_keys, + owner, + output_folder_path, + } + } + + fn def(app: App) -> App { + app.arg( + DATA_PATH + .def() + .help(wrap!("The path to the serialized transaction.")), + ) + .arg(PRIVATE_KEYS.def().help(wrap!( + "The set of private keys to use to sign the transaction. The \ + order matters." + ))) + .arg(OWNER.def().help(wrap!("The owner's address."))) + .arg( + OUTPUT_FOLDER_PATH + .def() + .help("Folder to where serialize the signatures"), + ) + } + } + + impl CliToSdk> for SignOffline { + type Error = std::io::Error; + + fn to_sdk( + self, + ctx: &mut Context, + ) -> Result, Self::Error> { + let chain_ctx = ctx.borrow_mut_chain_or_exit(); + + Ok(SignOffline:: { + tx_path: self.tx_path, + secret_keys: self + .secret_keys + .iter() + .map(|key| chain_ctx.get_cached(key)) + .collect(), + owner: chain_ctx.get(&self.owner), + output_folder_path: self.output_folder_path, + }) + } + } + #[derive(Clone, Debug)] pub struct DefaultBaseDir {} diff --git a/crates/apps_lib/src/cli/client.rs b/crates/apps_lib/src/cli/client.rs index fb708d235f..01a50d3860 100644 --- a/crates/apps_lib/src/cli/client.rs +++ b/crates/apps_lib/src/cli/client.rs @@ -816,6 +816,12 @@ impl CliApi { ClientUtils::PkToTmAddress(PkToTmAddress(args)) => { utils::pk_to_tm_address(global_args, args) } + ClientUtils::SignOffline(SignOffline(args)) => { + let mut ctx = cli::Context::new::(global_args) + .expect("expected to construct a context"); + let args = args.to_sdk(&mut ctx)?; + utils::sign_offline(args).await + } ClientUtils::DefaultBaseDir(DefaultBaseDir(args)) => { utils::default_base_dir(global_args, args) } diff --git a/crates/apps_lib/src/client/tx.rs b/crates/apps_lib/src/client/tx.rs index 3719951e35..fb38a0a53a 100644 --- a/crates/apps_lib/src/client/tx.rs +++ b/crates/apps_lib/src/client/tx.rs @@ -1071,8 +1071,11 @@ where transaction } else { edisplay_line!(namada.io(), "Couldn't decode the transaction."); - safe_exit(1) + return Err(error::Error::Other( + "Couldn't decode the transaction.".to_string(), + )); }; + let default_signer = Some(owner.clone()); let signing_data = aux_signing_data( namada, @@ -1110,15 +1113,11 @@ where Some(path) => path.join(filename), None => filename.into(), }; - let signature_path = File::create(&output_path) .expect("Should be able to create signature file."); + serde_json::to_writer_pretty(signature_path, &signature) + .expect("Signature should be serializable."); - serde_json::to_writer_pretty( - signature_path, - &signature.serialize(), - ) - .expect("Signature should be deserializable."); display_line!( namada.io(), "Signature for {} serialized at {}", @@ -1127,6 +1126,7 @@ where ); } } + Ok(()) } diff --git a/crates/apps_lib/src/client/utils.rs b/crates/apps_lib/src/client/utils.rs index 53826e30f0..6a227ea362 100644 --- a/crates/apps_lib/src/client/utils.rs +++ b/crates/apps_lib/src/client/utils.rs @@ -9,6 +9,7 @@ use flate2::read::GzDecoder; use flate2::write::GzEncoder; use flate2::Compression; use itertools::Either; +use namada_sdk::account::AccountPublicKeysMap; use namada_sdk::address::Address; use namada_sdk::args::DeviceTransport; use namada_sdk::chain::ChainId; @@ -16,6 +17,7 @@ use namada_sdk::dec::Dec; use namada_sdk::key::*; use namada_sdk::string_encoding::StringEncoded; use namada_sdk::token; +use namada_sdk::tx::Tx; use namada_sdk::uint::Uint; use namada_sdk::wallet::{alias, LoadStoreError, Wallet}; use namada_vm::validate_untrusted_wasm; @@ -1031,6 +1033,64 @@ pub async fn sign_genesis_tx( } } +/// Offline sign a transactions. +pub async fn sign_offline( + args::SignOffline { + tx_path, + secret_keys, + owner, + output_folder_path, + }: args::SignOffline, +) { + let tx_data = if let Ok(tx_data) = fs::read(&tx_path) { + tx_data + } else { + eprintln!("Couldn't open file at {}", tx_path.display()); + safe_exit(1) + }; + + let tx = if let Ok(transaction) = Tx::deserialize(tx_data.as_ref()) { + transaction + } else { + eprintln!("Couldn't decode the transaction."); + safe_exit(1) + }; + + let account_public_keys_map = AccountPublicKeysMap::from_iter( + secret_keys.iter().map(|sk| sk.to_public()), + ); + + let signatures = tx.compute_section_signature( + &secret_keys, + &account_public_keys_map, + Some(owner), + ); + + for signature in &signatures { + let filename = format!( + "offline_signature_{}_{}.sig", + tx.header_hash().to_string().to_lowercase(), + signature.pubkey, + ); + + let tx_path = match output_folder_path { + Some(ref path) => path.join(filename).to_string_lossy().to_string(), + None => filename, + }; + + let signature_path = File::create(&tx_path) + .expect("Should be able to create signature file."); + + serde_json::to_writer_pretty(signature_path, &signature) + .expect("Signature should be deserializable."); + + println!( + "Signature for {} serialized at {}", + signature.pubkey, tx_path + ); + } +} + /// Add a spinning wheel to a message for long running commands. /// Can be turned off for E2E tests by setting the `REDUCED_CLI_PRINTING` /// environment variable. diff --git a/crates/core/src/hash.rs b/crates/core/src/hash.rs index a2cfa4112c..bfddb087bd 100644 --- a/crates/core/src/hash.rs +++ b/crates/core/src/hash.rs @@ -10,7 +10,7 @@ use data_encoding::HEXUPPER; use namada_macros::BorshDeserializer; #[cfg(feature = "migrations")] use namada_migrations::*; -use serde::{Deserialize, Serialize}; +use serde::{de, Deserialize, Deserializer, Serialize, Serializer}; use sha2::{Digest, Sha256}; use thiserror::Error; @@ -51,12 +51,53 @@ pub type HashResult = std::result::Result; BorshDeserialize, BorshDeserializer, BorshSchema, - Serialize, - Deserialize, )] /// A hash, typically a sha-2 hash of a tx pub struct Hash(pub [u8; HASH_LENGTH]); +impl Serialize for Hash { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + // Convert the byte array to a hex string + let hex_string = HEXUPPER.encode(&self.0); + // Serialize the hex string + serializer.serialize_str(&hex_string) + } +} + +impl<'de> Deserialize<'de> for Hash { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + // Deserialize a hex string + let hex_string = + ::deserialize(deserializer)?; + // Convert the hex string back to a byte array + let bytes = HEXUPPER + .decode(hex_string.as_bytes()) + .map_err(de::Error::custom)?; + + // Ensure the byte array has the correct length + if bytes.len() != HASH_LENGTH { + return Err(de::Error::custom(format!( + "Invalid length: expected {} bytes, got {}", + HASH_LENGTH, + bytes.len() + ))); + } + + let mut output = [0u8; HASH_LENGTH]; + HEXUPPER + .decode_mut(hex_string.as_bytes(), &mut output) + .expect("Hash decoding shouldn't fail"); + + Ok(Hash(output)) + } +} + impl arse_merkle_tree::traits::Value for Hash { fn as_slice(&self) -> &[u8] { self.0.as_slice() diff --git a/crates/core/src/key/common.rs b/crates/core/src/key/common.rs index 4c6788237a..1944397165 100644 --- a/crates/core/src/key/common.rs +++ b/crates/core/src/key/common.rs @@ -1,16 +1,17 @@ //! Cryptographic keys -use std::fmt::Display; +use std::fmt::{self, Display}; use std::str::FromStr; use borsh::{BorshDeserialize, BorshSchema, BorshSerialize}; use borsh_ext::BorshSerializeExt; -use data_encoding::HEXLOWER; +use data_encoding::{HEXLOWER, HEXUPPER}; use namada_macros::BorshDeserializer; #[cfg(feature = "migrations")] use namada_migrations::*; #[cfg(any(test, feature = "rand"))] use rand::{CryptoRng, RngCore}; -use serde::{Deserialize, Serialize}; +use serde::de::{self, Visitor}; +use serde::{Deserialize, Deserializer, Serialize, Serializer}; use thiserror::Error; use super::{ @@ -309,8 +310,6 @@ impl FromStr for SecretKey { PartialOrd, Ord, Hash, - Serialize, - Deserialize, BorshSerialize, BorshDeserialize, BorshDeserializer, @@ -343,6 +342,52 @@ impl string_encoding::Format for Signature { } } +impl Serialize for CommonSignature { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + let hex_str = HEXUPPER.encode(&self.serialize_to_vec()); + serializer.serialize_str(&hex_str) + } +} + +// Implement custom deserialization +impl<'de> Deserialize<'de> for CommonSignature { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + struct SignatureVisitor; + + impl<'de> Visitor<'de> for SignatureVisitor { + type Value = CommonSignature; + + fn expecting( + &self, + formatter: &mut fmt::Formatter<'_>, + ) -> fmt::Result { + formatter.write_str( + "a hex string representing either an Ed25519 or Secp256k1 \ + signature", + ) + } + + fn visit_str(self, value: &str) -> Result + where + E: de::Error, + { + let bytes = + HEXUPPER.decode(value.as_bytes()).map_err(E::custom)?; + CommonSignature::try_from_slice(&bytes) + .map_err(|e| E::custom(e.to_string())) + } + } + + deserializer.deserialize_str(SignatureVisitor) + } +} + impl_display_and_from_str_via_format!(Signature); impl From for Signature { diff --git a/crates/core/src/key/secp256k1.rs b/crates/core/src/key/secp256k1.rs index 5e3747da22..787b448935 100644 --- a/crates/core/src/key/secp256k1.rs +++ b/crates/core/src/key/secp256k1.rs @@ -270,7 +270,7 @@ impl BorshSchema for SecretKey { impl Display for SecretKey { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!(f, "{}", HEXLOWER.encode(&self.0.to_bytes())) + write!(f, "{}", HEXLOWER.encode(self.0.to_bytes().as_ref())) } } diff --git a/crates/sdk/src/signing.rs b/crates/sdk/src/signing.rs index 1eab0842de..7c180ec9bc 100644 --- a/crates/sdk/src/signing.rs +++ b/crates/sdk/src/signing.rs @@ -215,7 +215,8 @@ where .signatures .iter() .map(|bytes| { - let sigidx = SignatureIndex::deserialize(bytes).unwrap(); + let sigidx = + serde_json::from_slice::(bytes).unwrap(); used_pubkeys.insert(sigidx.pubkey.clone()); sigidx }) diff --git a/crates/sdk/src/tx.rs b/crates/sdk/src/tx.rs index 62368278aa..f8f7c62c2b 100644 --- a/crates/sdk/src/tx.rs +++ b/crates/sdk/src/tx.rs @@ -190,14 +190,15 @@ impl ProcessTxResponse { /// Build and dump a transaction either to file or to screen pub fn dump_tx(io: &IO, args: &args::Tx, tx: Tx) { - let tx_id = tx.header_hash(); - let serialized_tx = tx.serialize(); - match args.output_folder.to_owned() { + match args.output_folder.clone() { Some(path) => { - let tx_filename = format!("{}.tx", tx_id); - let tx_path = path.join(tx_filename); - let out = File::create(&tx_path).unwrap(); - serde_json::to_writer_pretty(out, &serialized_tx) + let tx_path = path.join(format!( + "{}.tx", + tx.header_hash().to_string().to_lowercase() + )); + let out = File::create(&tx_path) + .expect("Should be able to create a file to dump tx"); + serde_json::to_writer_pretty(out, &tx) .expect("Should be able to write to file."); display_line!( io, @@ -206,6 +207,8 @@ pub fn dump_tx(io: &IO, args: &args::Tx, tx: Tx) { ); } None => { + let serialized_tx = serde_json::to_string_pretty(&tx) + .expect("Should be able to json encode the tx."); display_line!(io, "Below the serialized transaction: \n"); display_line!(io, "{}", serialized_tx) } diff --git a/crates/tests/src/e2e/helpers.rs b/crates/tests/src/e2e/helpers.rs index 381c2e57e1..47555d9205 100644 --- a/crates/tests/src/e2e/helpers.rs +++ b/crates/tests/src/e2e/helpers.rs @@ -222,7 +222,7 @@ pub fn find_keypair( .unwrap() .1; let key = format!("{}{}", sk, pk); - common::SecretKey::from_str(&key).map_err(|e| { + common::SecretKey::from_str(sk).map_err(|e| { eyre!(format!( "Key: {} parsed from {}, Error: {}\n\nOutput: {}", key, matched, e, unread diff --git a/crates/tests/src/integration/helpers.rs b/crates/tests/src/integration/helpers.rs index 3b49ccbaea..487ffab972 100644 --- a/crates/tests/src/integration/helpers.rs +++ b/crates/tests/src/integration/helpers.rs @@ -6,6 +6,7 @@ use namada_core::address::Address; use namada_node::shell::testing::client::run; use namada_node::shell::testing::node::MockNode; use namada_node::shell::testing::utils::{Bin, CapturedOutput}; +use namada_sdk::key::common; /// Query the wallet to get an address from a given alias. pub fn find_address( @@ -37,6 +38,46 @@ pub fn find_address( Ok(address) } +pub fn find_keypair( + node: &MockNode, + alias: impl AsRef, +) -> eyre::Result { + let captured = CapturedOutput::of(|| { + run( + node, + Bin::Wallet, + vec![ + "find", + "--keys", + "--alias", + alias.as_ref(), + "--decrypt", + "--unsafe-show-secret", + ], + ) + }); + assert!(captured.result.is_ok()); + let matched = captured.matches("Public key: .*").unwrap(); + let pk = strip_trailing_newline(matched) + .trim() + .rsplit_once(' ') + .unwrap() + .1; + let matched = captured.matches("Secret key: .*").unwrap(); + let sk = strip_trailing_newline(matched) + .trim() + .rsplit_once(' ') + .unwrap() + .1; + let key = format!("{}{}", sk, pk); + common::SecretKey::from_str(sk).map_err(|e| { + eyre!(format!( + "Key: {} parsed from {}, Error: {}", + key, matched, e + )) + }) +} + fn strip_trailing_newline(input: &str) -> &str { input .strip_suffix("\r\n") diff --git a/crates/tests/src/integration/ledger_tests.rs b/crates/tests/src/integration/ledger_tests.rs index 3d73f4ee73..79393b5e2f 100644 --- a/crates/tests/src/integration/ledger_tests.rs +++ b/crates/tests/src/integration/ledger_tests.rs @@ -1,5 +1,7 @@ use std::collections::BTreeSet; +use std::fs; use std::num::NonZeroU64; +use std::path::{Path, PathBuf}; use std::str::FromStr; use assert_matches::assert_matches; @@ -37,7 +39,7 @@ use crate::e2e::setup::constants::{ }; use crate::e2e::setup::{apply_use_device, ensure_hot_key}; use crate::integration::helpers::{ - find_address, prepare_steward_commission_update_data, + find_address, find_keypair, prepare_steward_commission_update_data, }; use crate::integration::setup; use crate::strings::{ @@ -1671,6 +1673,107 @@ fn change_validator_metadata() -> Result<()> { Ok(()) } +#[test] +fn offline_sign() -> Result<()> { + // This address doesn't matter for tests. But an argument is required. + let validator_one_rpc = "http://127.0.0.1:26567"; + // 1. start the ledger node + let (node, _services) = setup::setup()?; + + let output_folder = tempfile::tempdir().unwrap(); + + // 2. Dump a transfer tx + let captured = CapturedOutput::of(|| { + run( + &node, + Bin::Client, + apply_use_device(vec![ + "transparent-transfer", + "--source", + BERTHA, + "--target", + ALBERT, + "--token", + NAM, + "--amount", + "10.1", + "--gas-price", + "0.00090", + "--signing-keys", + BERTHA_KEY, + "--node", + &validator_one_rpc, + "--dump-tx", + "--output-folder-path", + &output_folder.path().to_str().unwrap(), + ]), + ) + }); + assert!(captured.result.is_ok()); + + let offline_tx = find_file_with_ext(output_folder.path(), "tx") + .unwrap() + .expect("Offline tx should be found.") + .to_path_buf() + .display() + .to_string(); + + let bertha_address = find_address(&node, BERTHA).unwrap().to_string(); + let bertha_sk = find_keypair(&node, BERTHA_KEY).unwrap().to_string(); + + // 2. Dump a transfer tx + let captured = CapturedOutput::of(|| { + run( + &node, + Bin::Client, + apply_use_device(vec![ + "utils", + "sign-offline", + "--data-path", + &offline_tx, + "--owner", + &bertha_address, + "--secret-keys", + &bertha_sk, + "--output-folder-path", + &output_folder.path().to_str().unwrap(), + ]), + ) + }); + assert!(captured.result.is_ok()); + + let offline_sig = find_file_with_ext(output_folder.path(), "sig") + .unwrap() + .expect("Offline signature should be found.") + .to_path_buf() + .display() + .to_string(); + + // 3. Offline sign a transfer tx + let captured = CapturedOutput::of(|| { + run( + &node, + Bin::Client, + vec![ + "tx", + "--owner", + BERTHA_KEY, + "--tx-path", + &offline_tx, + "--signatures", + &offline_sig, + "--node", + &validator_one_rpc, + "--gas-payer", + BERTHA_KEY, + ], + ) + }); + assert!(captured.result.is_ok()); + + Ok(()) +} + // Test that fee payment is enforced and aligned with process proposal. The test // generates a tx that subtract funds from the fee payer of a following tx. Test // that wrappers (and fee payments) are evaluated before the inner transactions. @@ -2183,3 +2286,24 @@ fn make_migration_json() -> (Hash, tempfile::NamedTempFile) { std::fs::write(file.path(), json).expect("Test failed"); (hash, file) } + +pub fn find_file_with_ext( + dir: &Path, + extension: &str, +) -> Result> { + // Read the directory entries + for entry in fs::read_dir(dir)? { + let entry = entry?; + let path = entry.path(); + + if path.is_file() { + if let Some(file_extension) = path.extension() { + if file_extension == extension { + return Ok(Some(path)); + } + } + } + } + + Ok(None) +} diff --git a/crates/tx/src/lib.rs b/crates/tx/src/lib.rs index 93402cd1cb..de7406595d 100644 --- a/crates/tx/src/lib.rs +++ b/crates/tx/src/lib.rs @@ -36,6 +36,90 @@ pub use types::{ Signer, Tx, TxCommitments, TxError, VerifySigError, }; +/// Length of the transaction sections salt +pub const SALT_LENGTH: usize = 8; + +#[allow(missing_docs)] +mod hex_salt_serde { + use data_encoding::HEXUPPER; + use serde::{de, Deserializer, Serializer}; + + use super::*; + + pub fn serialize( + salt: &[u8; SALT_LENGTH], + serializer: S, + ) -> Result + where + S: Serializer, + { + // Convert the byte array to a hex string + let hex_string = HEXUPPER.encode(salt); + serializer.serialize_str(&hex_string) + } + + pub fn deserialize<'de, D>( + deserializer: D, + ) -> Result<[u8; SALT_LENGTH], D::Error> + where + D: Deserializer<'de>, + { + // Deserialize a hex string + let hex_string = + ::deserialize(deserializer)?; + // Convert the hex string back to a byte array + let bytes = HEXUPPER + .decode(hex_string.as_bytes()) + .map_err(de::Error::custom)?; + + if bytes.len() != SALT_LENGTH { + return Err(de::Error::custom(format!( + "Invalid length: expected {} bytes, got {}", + SALT_LENGTH, + bytes.len() + ))); + } + + let mut output = [0u8; SALT_LENGTH]; + HEXUPPER + .decode_mut(hex_string.as_bytes(), &mut output) + .expect("Hash decoding shouldn't fail"); + + Ok(output) + } +} + +#[allow(missing_docs)] +mod hex_data_serde { + use data_encoding::HEXUPPER; + use serde::{de, Deserializer, Serializer}; + + pub fn serialize( + #[allow(clippy::ptr_arg)] data: &Vec, + serializer: S, + ) -> Result + where + S: Serializer, + { + // Convert the byte array to a hex string + let hex_string = HEXUPPER.encode(data); + serializer.serialize_str(&hex_string) + } + + pub fn deserialize<'de, D>(deserializer: D) -> Result, D::Error> + where + D: Deserializer<'de>, + { + // Deserialize a hex string + let hex_string = + ::deserialize(deserializer)?; + // Convert the hex string back to a byte array + HEXUPPER + .decode(hex_string.as_bytes()) + .map_err(de::Error::custom) + } +} + #[cfg(test)] mod tests { use data_encoding::HEXLOWER; diff --git a/crates/tx/src/sign.rs b/crates/tx/src/sign.rs index 4120b60e00..296778e0ae 100644 --- a/crates/tx/src/sign.rs +++ b/crates/tx/src/sign.rs @@ -2,11 +2,8 @@ use std::cmp::Ordering; -use data_encoding::HEXUPPER; use namada_core::address::Address; -use namada_core::borsh::{ - BorshDeserialize, BorshSchema, BorshSerialize, BorshSerializeExt, -}; +use namada_core::borsh::{BorshDeserialize, BorshSchema, BorshSerialize}; use namada_core::key::common; use namada_macros::BorshDeserializer; #[cfg(feature = "migrations")] @@ -64,25 +61,6 @@ impl SignatureIndex { pub fn to_vec(&self) -> Vec { vec![self.clone()] } - - /// Serialize as a string. - pub fn serialize(&self) -> String { - let signature_bytes = self.serialize_to_vec(); - HEXUPPER.encode(&signature_bytes) - } - - /// Deserialize from a string slice - pub fn deserialize(data: &[u8]) -> Result { - if let Ok(hex) = serde_json::from_slice::(data) { - match HEXUPPER.decode(hex.as_bytes()) { - Ok(bytes) => Self::try_from_slice(&bytes) - .map_err(SigIndexDecodeError::Encoding), - Err(e) => Err(SigIndexDecodeError::Hex(e)), - } - } else { - Err(SigIndexDecodeError::JsonString) - } - } } impl Ord for SignatureIndex { diff --git a/crates/tx/src/types.rs b/crates/tx/src/types.rs index 883dc03658..f2dbdab784 100644 --- a/crates/tx/src/types.rs +++ b/crates/tx/src/types.rs @@ -32,8 +32,8 @@ use thiserror::Error; use crate::data::protocol::ProtocolTx; use crate::data::{hash_tx, Fee, GasLimit, TxType, WrapperTx}; -use crate::proto; use crate::sign::SignatureIndex; +use crate::{hex_data_serde, hex_salt_serde, proto, SALT_LENGTH}; /// Represents an error in signature verification #[allow(missing_docs)] @@ -232,8 +232,10 @@ pub fn verify_standalone_sig>( )] pub struct Data { /// Salt with additional random data (usually a timestamp) - pub salt: [u8; 8], + #[serde(with = "hex_salt_serde")] + pub salt: [u8; SALT_LENGTH], /// Data bytes + #[serde(with = "hex_data_serde")] pub data: Vec, } @@ -250,7 +252,7 @@ impl Data { Self { salt: { - let mut buf = [0; 8]; + let mut buf = [0; SALT_LENGTH]; OsRng.fill_bytes(&mut buf); buf }, @@ -349,7 +351,8 @@ impl Commitment { )] pub struct Code { /// Additional random data - pub salt: [u8; 8], + #[serde(with = "hex_salt_serde")] + pub salt: [u8; SALT_LENGTH], /// Actual transaction code pub code: Commitment, /// The tag for the transaction code @@ -369,7 +372,7 @@ impl Code { Self { salt: { - let mut buf = [0; 8]; + let mut buf = [0; SALT_LENGTH]; OsRng.fill_bytes(&mut buf); buf }, @@ -387,7 +390,7 @@ impl Code { Self { salt: { - let mut buf = [0; 8]; + let mut buf = [0; SALT_LENGTH]; OsRng.fill_bytes(&mut buf); buf }, @@ -1188,14 +1191,10 @@ impl Tx { HEXUPPER.encode(&tx_bytes) } - /// Deserialize from hex encoding + /// Deserialize tx from json pub fn deserialize(data: &[u8]) -> Result { - if let Ok(hex) = serde_json::from_slice::(data) { - match HEXUPPER.decode(hex.as_bytes()) { - Ok(bytes) => Tx::try_from_slice(&bytes) - .map_err(DecodeError::InvalidEncoding), - Err(e) => Err(DecodeError::InvalidHex(e)), - } + if let Ok(tx) = serde_json::from_slice::(data) { + Ok(tx) } else { Err(DecodeError::InvalidJsonString) }