From a5a793f56f1f351b0fa0bd26d51bc9546e9b7af4 Mon Sep 17 00:00:00 2001 From: Justin Kilpatrick Date: Mon, 1 Sep 2025 12:10:11 -0400 Subject: [PATCH 1/6] Add ethermint address conversions These conversions are useful, but should maybe be explicit functions rather than implicit conversions so that we can educate the user as to how it's a bad idea in some situations. --- src/address.rs | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/src/address.rs b/src/address.rs index a0fb443..ee6c871 100644 --- a/src/address.rs +++ b/src/address.rs @@ -165,6 +165,26 @@ impl Address { } .to_string() } + +} + +// Ethermint address conversion, note there's no way to determine from an address how the signers wallet is configured, if you convert Cosmos addressed +// used with a non-eth signing scheme using this method that public key goes to a completely different address and the user will never be able to retieve +// funds sent to that address, so use caution when making assumptions. +#[cfg(feature = "ethermint")] +impl TryInto for Address { + type Error = clarity::error::Error; + + fn try_into(self) -> Result { + EthAddress::from_slice(self.get_bytes()) + } +} + +#[cfg(feature = "ethermint")] +impl From for Address { + fn from(value: EthAddress) -> Self { + Address::from_slice(value.as_bytes(), DEFAULT_PREFIX).unwrap() + } } impl FromStr for Address { From ada7f045060b56a188492318ee805e3c41929e22 Mon Sep 17 00:00:00 2001 From: Justin Kilpatrick Date: Mon, 1 Sep 2025 12:17:41 -0400 Subject: [PATCH 2/6] Add re_prefix to Address This is easiser to use than the change prefix function in some contexts --- src/address.rs | 15 ++++++++++++++- src/client/send.rs | 3 ++- 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/src/address.rs b/src/address.rs index ee6c871..f42d748 100644 --- a/src/address.rs +++ b/src/address.rs @@ -144,6 +144,20 @@ impl Address { Ok(()) } + /// Changes the `prefix` field to modify the resulting Bech32 `hrp`, returns the updated Address + pub fn re_prefix>(&self, prefix: T) -> Result { + match self { + Address::Base(base_address) => Ok(Address::Base(BaseAddress { + bytes: base_address.bytes, + prefix: ArrayString::new(&prefix.into())?, + })), + Address::Derived(derived_address) => Ok(Address::Derived(DerivedAddress { + bytes: derived_address.bytes, + prefix: ArrayString::new(&prefix.into())?, + })), + } + } + /// Returns the underlying `bytes` buffer as a slice pub fn get_bytes(&self) -> &[u8] { match self { @@ -165,7 +179,6 @@ impl Address { } .to_string() } - } // Ethermint address conversion, note there's no way to determine from an address how the signers wallet is configured, if you convert Cosmos addressed diff --git a/src/client/send.rs b/src/client/send.rs index cb816c3..6bb494c 100644 --- a/src/client/send.rs +++ b/src/client/send.rs @@ -266,7 +266,8 @@ impl Contact { let fee_amount = fee_amount.unwrap_or_default(); let mut txrpc = timeout(self.get_timeout(), TxServiceClient::connect(self.get_url())).await??; - let max_gas = max_gas.map_or_else(|| 10_000_000, |v| v.clamp(0, 9223372036854775807)) as u64; + let max_gas = + max_gas.map_or_else(|| 10_000_000, |v| v.clamp(0, 9223372036854775807)) as u64; let fee_obj = Fee { amount: fee_amount.to_vec(), From 5c64014a1eff3e7741311826aa9a36bc0e2852c1 Mon Sep 17 00:00:00 2001 From: Justin Kilpatrick Date: Tue, 20 Jan 2026 14:59:01 -0500 Subject: [PATCH 3/6] Add txid to error cases By adding a txid to some error cases we can allow the caller to follow the signed transaction even if the intial publishing call fails or times out. --- src/client/send.rs | 17 ++++++++++++++--- src/error.rs | 31 ++++++++++++++++++++++++++++++- src/utils.rs | 11 ++++++++++- 3 files changed, 54 insertions(+), 5 deletions(-) diff --git a/src/client/send.rs b/src/client/send.rs index 6bb494c..ab0e5c8 100644 --- a/src/client/send.rs +++ b/src/client/send.rs @@ -10,6 +10,7 @@ use crate::error::CosmosGrpcError; use crate::msg::Msg; use crate::private_key::PrivateKey; use crate::utils::check_for_sdk_error; +use crate::utils::get_txhash; use crate::MessageArgs; #[cfg(feature = "althea")] use althea_proto::althea::microtx::v1::MsgMicrotx; @@ -92,14 +93,22 @@ impl Contact { ) -> Result { let mut txrpc = timeout(self.get_timeout(), TxServiceClient::connect(self.get_url())).await??; - let response = timeout( + + let txid = get_txhash(msg.clone()); + let response = match timeout( self.get_timeout(), txrpc.broadcast_tx(BroadcastTxRequest { tx_bytes: msg, mode: mode.into(), }), ) - .await??; + .await + { + Ok(res) => res?, + // broadcasting the signed tx, if we return here the signed tx bytes MAY have been + // published so we must return an error containing the txid for the caller to check + Err(_) => return Err(CosmosGrpcError::TimeoutErrorSigned { txid }), + }; let response = response.into_inner().tx_response.unwrap(); // checks only for sdk errors, other types will not be handled check_for_sdk_error(&response)?; @@ -438,11 +447,12 @@ impl Contact { } Err(CosmosGrpcError::RequestError { error }) => match error.code() { TonicCode::NotFound | TonicCode::Unknown | TonicCode::InvalidArgument => {} - _ => { + v => { return Err(CosmosGrpcError::TransactionFailed { tx: response.into(), time: Instant::now() - start, sdk_error: None, + tonic_code: Some(v), }); } }, @@ -454,6 +464,7 @@ impl Contact { tx: response.into(), time: timeout, sdk_error: None, + tonic_code: None, }) } } diff --git a/src/error.rs b/src/error.rs index 077dc50..c65e1f2 100644 --- a/src/error.rs +++ b/src/error.rs @@ -45,6 +45,7 @@ pub enum CosmosGrpcError { tx: TxResponse, time: Duration, sdk_error: Option, + tonic_code: Option, }, InsufficientFees { fee_info: FeeInfo, @@ -56,9 +57,29 @@ pub enum CosmosGrpcError { max: u64, required: u64, }, + /// This timeout error occurs when a transaction has been signed but we + /// have not seen it appear on chain yet. We return the txid so the caller + /// can check if it made it into the chain or not. + /// DEVELOPER: you must be cautious not to ? away a timeout error that needs the txid + TimeoutErrorSigned { + txid: String, + }, + /// This timeout error occurs when no transaction has been signed and therfore we have + /// complete confidence that no transaction has been broadcast TimeoutError, } +impl CosmosGrpcError { + /// Returns the txid if this error is associated with a signed and potential published transaction + pub fn get_txid(&self) -> Option { + match self { + CosmosGrpcError::TimeoutErrorSigned { txid } => Some(txid.clone()), + CosmosGrpcError::TransactionFailed { tx, .. } => Some(tx.txhash.clone()), + _ => None, + } + } +} + impl Display for CosmosGrpcError { fn fmt(&self, f: &mut Formatter) -> Result { match self { @@ -98,12 +119,14 @@ impl Display for CosmosGrpcError { tx, time, sdk_error, + tonic_code, } => { write!( f, - "CosmosGrpc Transaction {:?} {:?} did not enter chain in {}ms", + "CosmosGrpc Transaction {:?} {:?} {:?} did not enter chain in {}ms", tx, sdk_error, + tonic_code, time.as_millis() ) } @@ -120,6 +143,12 @@ impl Display for CosmosGrpcError { ) } CosmosGrpcError::TimeoutError => write!(f, "Timed out"), + CosmosGrpcError::TimeoutErrorSigned { txid } => { + write!( + f, + "Timed out waiting for tx to appear on chain. Txid: {txid}" + ) + } } } } diff --git a/src/utils.rs b/src/utils.rs index 6488a20..908535f 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -4,6 +4,7 @@ use bytes::BytesMut; use cosmos_sdk_proto::cosmos::base::abci::v1beta1::TxResponse; use prost::{DecodeError, Message}; use prost_types::Any; +use sha2::Digest; use std::fmt::Display; use std::fmt::Formatter; use std::fmt::Result as FmtResult; @@ -101,6 +102,13 @@ pub fn contains_non_hex_chars(input: &str) -> bool { false } +/// CosmosSDK txhashes are the sha256 hash of the signed protobuf tx bytes encoded as uppercase hex +/// Returns the txhash as a String +pub fn get_txhash(input: Vec) -> String { + let hash = sha2::Sha256::digest(&input); + bytes_to_hex_str(&hash).to_uppercase() +} + /// An enum #[derive(PartialEq, Eq, Clone, Hash, Deserialize, Serialize, Debug)] pub enum FeeInfo { @@ -155,7 +163,7 @@ pub fn determine_min_fees_and_gas(input: &TxResponse) -> Option { /// Checks a tx response code for known issues returns true if tx is good, false if the tx /// has some known error pub fn check_for_sdk_error(input: &TxResponse) -> Result<(), CosmosGrpcError> { - // check for gas errors + // check for gas errors, in this case no txid is retured because the tx never made it to the mempool if let Some(v) = determine_min_fees_and_gas(input) { return Err(CosmosGrpcError::InsufficientFees { fee_info: v }); } @@ -168,6 +176,7 @@ pub fn check_for_sdk_error(input: &TxResponse) -> Result<(), CosmosGrpcError> { tx: input.clone(), time: Duration::from_secs(0), sdk_error: Some(e), + tonic_code: None, }); } } From b13bf442de042822395e3930fe7ca5536bb15464 Mon Sep 17 00:00:00 2001 From: Justin Kilpatrick Date: Tue, 20 Jan 2026 15:20:06 -0500 Subject: [PATCH 4/6] Clippy, cargo.toml updates and doctest fixes The one thing of note here is that althea_proto was rolled back because everything in the 10 series is currently yanked. --- Cargo.toml | 14 +++++++------- src/client/gov/mod.rs | 5 +++-- src/client/send.rs | 7 +++---- src/coin.rs | 1 + src/mnemonic/mod.rs | 8 ++++---- src/private_key.rs | 26 ++++++++++++++++---------- 6 files changed, 34 insertions(+), 27 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index ce8a4f8..9340533 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -22,23 +22,23 @@ prost-types = "0.13" prost = "0.13" pbkdf2 = {version = "0.12"} hmac = {version = "0.12"} -rand = {version = "0.8"} -rust_decimal = "1.36" -secp256k1 = {version = "0.30", features = ["global-context"]} +rand = {version = "0.9"} +rust_decimal = "1.40" +secp256k1 = {version = "0.31", features = ["global-context"]} tonic = {version = "0.12", features = ["gzip"]} -bytes = "1.8" +bytes = "1.11" log = "0.4" tokio = {version = "1", features=["time"]} clarity = {version = "1.5", optional = true} sha3 = {version = "0.10", optional = true} cosmos-sdk-proto = {package = "cosmos-sdk-proto-althea", version = "0.20"} -althea_proto = {version="0.10", optional=true} +althea_proto = {version="0.9", optional=true} [dev-dependencies] -rand = "0.8" +rand = "0.9" env_logger = "0.11" -actix-rt = "2.10" +actix-rt = "2.11" [features] diff --git a/src/client/gov/mod.rs b/src/client/gov/mod.rs index e420980..6adaaa6 100644 --- a/src/client/gov/mod.rs +++ b/src/client/gov/mod.rs @@ -173,6 +173,7 @@ impl Contact { .await } + #[allow(clippy::too_many_arguments)] /// Provides an interface for submitting msg-based governance proposals pub async fn create_gov_proposal( &self, @@ -192,8 +193,8 @@ impl Contact { proposer: our_address.to_string(), initial_deposit: vec![deposit.into()], expedited: false, - summary: summary, - title: title, + summary, + title, }; let msg = Msg::new(MSG_SUBMIT_PROPOSAL_TYPE_URL, proposal); diff --git a/src/client/send.rs b/src/client/send.rs index ab0e5c8..514cc34 100644 --- a/src/client/send.rs +++ b/src/client/send.rs @@ -275,8 +275,7 @@ impl Contact { let fee_amount = fee_amount.unwrap_or_default(); let mut txrpc = timeout(self.get_timeout(), TxServiceClient::connect(self.get_url())).await??; - let max_gas = - max_gas.map_or_else(|| 10_000_000, |v| v.clamp(0, 9223372036854775807)) as u64; + let max_gas = max_gas.map_or_else(|| 10_000_000, |v| v.clamp(0, 9223372036854775807)); let fee_obj = Fee { amount: fee_amount.to_vec(), @@ -378,7 +377,7 @@ impl Contact { /// * `private_key` - A private key used to sign and send the transaction /// # Examples /// ```rust - /// use althea_proto::microtx::v1::MsgMicrotx; + /// use althea_proto::althea::microtx::v1::MsgMicrotx; /// use cosmos_sdk_proto::cosmos::tx::v1beta1::BroadcastMode; /// use deep_space::{Coin, client::Contact, Fee, MessageArgs, Msg, CosmosPrivateKey, PrivateKey, PublicKey}; /// use std::time::Duration; @@ -396,7 +395,7 @@ impl Contact { /// let contact = Contact::new("https:://your-grpc-server", Duration::from_secs(5), "prefix").unwrap(); /// let duration = Duration::from_secs(30); /// // future must be awaited in tokio runtime - /// contact.send_microtx(coin.clone(), Some(fee), address, Some(duration), private_key); + /// contact.send_microtx(coin.clone(), Some(fee), address, Some(duration), Some(5), private_key); /// ``` pub async fn send_microtx( &self, diff --git a/src/coin.rs b/src/coin.rs index 45a7003..9086df8 100644 --- a/src/coin.rs +++ b/src/coin.rs @@ -85,6 +85,7 @@ impl From for ProtoCoin { } } + /// Fee represents everything about a Cosmos transaction fee, including the gas limit /// who pays, and how much of an arbitrary number of Coin structs. #[derive(Serialize, Debug, Default, Clone, Deserialize, Eq, PartialEq, Hash)] diff --git a/src/mnemonic/mod.rs b/src/mnemonic/mod.rs index d4e3543..c507348 100644 --- a/src/mnemonic/mod.rs +++ b/src/mnemonic/mod.rs @@ -45,7 +45,7 @@ impl Mnemonic { /// Create a new [Mnemonic] in the specified language from the given entropy. /// Entropy must be a multiple of 32 bits (4 bytes) and 128-256 bits in length. pub fn from_entropy_in(language: Language, entropy: &[u8]) -> Result { - if entropy.len() % 4 != 0 { + if !entropy.len().is_multiple_of(4) { return Err(Bip39Error::BadEntropyBitCount(entropy.len() * 8)); } @@ -89,12 +89,12 @@ impl Mnemonic { /// Generate a new Mnemonic in the given language. /// For the different supported word counts, see documentation on [Mnemonoc]. pub fn generate_in(language: Language, word_count: usize) -> Result { - if word_count < 6 || word_count % 6 != 0 || word_count > 24 { + if word_count < 6 || !word_count.is_multiple_of(6) || word_count > 24 { return Err(Bip39Error::BadWordCount(word_count)); } let entropy_bytes = (word_count / 3) * 4; - let mut rng = rand::thread_rng(); + let mut rng = rand::rng(); let mut entropy = vec![0u8; entropy_bytes]; rand::RngCore::fill_bytes(&mut rng, &mut entropy); Mnemonic::from_entropy_in(language, &entropy) @@ -109,7 +109,7 @@ impl Mnemonic { /// Static method to validate a mnemonic in a given language. pub fn validate_in(language: Language, s: &str) -> Result<(), Bip39Error> { let words: Vec<&str> = s.split_whitespace().collect(); - if words.len() < 6 || words.len() % 6 != 0 || words.len() > 24 { + if words.len() < 6 || !words.len().is_multiple_of(6) || words.len() > 24 { return Err(Bip39Error::BadWordCount(words.len())); } diff --git a/src/private_key.rs b/src/private_key.rs index 4e72ac6..c9eefe9 100644 --- a/src/private_key.rs +++ b/src/private_key.rs @@ -21,6 +21,7 @@ use secp256k1::{PublicKey as PublicKeyEC, SecretKey}; use sha2::Sha512; use sha2::{Digest, Sha256}; use std::cell::RefCell; +use std::convert::TryInto; use std::str::FromStr; thread_local! { @@ -177,7 +178,7 @@ impl CosmosPrivateKey { /// Obtain a public key for a given private key pub fn to_public_key(&self, prefix: &str) -> Result { let secp256k1 = Secp256k1::new(); - let sk = SecretKey::from_slice(&self.0)?; + let sk = SecretKey::from_byte_array(self.0)?; let pkey = PublicKeyEC::from_secret_key(&secp256k1, &sk); let compressed = pkey.serialize(); Ok(CosmosPublicKey::from_bytes(compressed, prefix)?) @@ -219,11 +220,11 @@ impl CosmosPrivateKey { sign_doc.encode(&mut signdoc_buf).unwrap(); let secp256k1 = Secp256k1::new(); - let sk = SecretKey::from_slice(&self.0)?; + let sk = SecretKey::from_byte_array(self.0)?; let digest = Sha256::digest(&signdoc_buf); - let msg = CurveMessage::from_digest_slice(&digest)?; + let msg = CurveMessage::from_digest(digest.into()); // Sign the signdoc - let signed = secp256k1.sign_ecdsa(&msg, &sk); + let signed = secp256k1.sign_ecdsa(msg, &sk); let compact = signed.serialize_compact().to_vec(); // Finish the TxParts and return @@ -348,7 +349,7 @@ impl EthermintPrivateKey { self, prefix: &str, ) -> Result { - let sk = SecretKey::from_slice(&self.0)?; + let sk = SecretKey::from_byte_array(self.0)?; let pkey = SECP256K1.with(move |object| -> Result<_, PrivateKeyError> { let secp256k1 = object.borrow(); let pkey = PublicKeyEC::from_secret_key(&secp256k1, &sk); @@ -522,7 +523,7 @@ fn master_key_from_seed(seed_bytes: &[u8]) -> ([u8; 32], [u8; 32]) { master_chain_code.copy_from_slice(&hash[32..64]); // key check - let _ = SecretKey::from_slice(&master_secret_key).unwrap(); + let _ = SecretKey::from_byte_array(master_secret_key).unwrap(); (master_secret_key, master_chain_code) } @@ -547,7 +548,7 @@ fn get_child_key( hasher.update(&k_parent); } else { let scep = Secp256k1::new(); - let private_key = SecretKey::from_slice(&k_parent).unwrap(); + let private_key = SecretKey::from_byte_array(k_parent).unwrap(); let public_key = PublicKeyEC::from_secret_key(&scep, &private_key); hasher.update(&public_key.serialize()); } @@ -576,7 +577,12 @@ fn get_child_key( let k_parent = Scalar::from_be_bytes(k_parent).unwrap(); - let mut parse_i_l = SecretKey::from_slice(&l_param[0..32]).unwrap(); + let mut parse_i_l = SecretKey::from_byte_array( + l_param[0..32] + .try_into() + .expect("slice with incorrect length"), + ) + .unwrap(); parse_i_l = parse_i_l.add_tweak(&k_parent).unwrap(); let child_key = parse_i_l; @@ -827,8 +833,8 @@ fn test_vector_unhardened() { fn test_many_key_generation() { use rand::Rng; for _ in 0..1000 { - let mut rng = rand::thread_rng(); - let secret: [u8; 32] = rng.gen(); + let mut rng = rand::rng(); + let secret: [u8; 32] = rng.random(); let cosmos_key = CosmosPrivateKey::from_secret(&secret); let _cosmos_address = cosmos_key.to_public_key("cosmospub").unwrap().to_address(); } From 9ec570c0526d4b11e71eb3c1d8a2920b57c8baa1 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 20 Jan 2026 21:03:00 +0000 Subject: [PATCH 5/6] Initial plan From b2fa827d1e95f5dcf44c0b8af52aec6631f81c4e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 20 Jan 2026 21:11:09 +0000 Subject: [PATCH 6/6] Address review comments: fix spelling errors, add tests, and improve TryInto implementation Co-authored-by: jkilpatr <19688153+jkilpatr@users.noreply.github.com> --- src/address.rs | 40 ++++++++++++++++++++++++++++++++++++++-- src/error.rs | 2 +- src/utils.rs | 17 ++++++++++++++++- 3 files changed, 55 insertions(+), 4 deletions(-) diff --git a/src/address.rs b/src/address.rs index f42d748..a6d25e3 100644 --- a/src/address.rs +++ b/src/address.rs @@ -182,10 +182,10 @@ impl Address { } // Ethermint address conversion, note there's no way to determine from an address how the signers wallet is configured, if you convert Cosmos addressed -// used with a non-eth signing scheme using this method that public key goes to a completely different address and the user will never be able to retieve +// used with a non-eth signing scheme using this method that public key goes to a completely different address and the user will never be able to retrieve // funds sent to that address, so use caution when making assumptions. #[cfg(feature = "ethermint")] -impl TryInto for Address { +impl TryInto for &Address { type Error = clarity::error::Error; fn try_into(self) -> Result { @@ -329,3 +329,39 @@ fn test_address_conversion() { let eth_address = cosmos_address_to_eth_address(test).unwrap(); let _cosmos_address = eth_address_to_cosmos_address(eth_address, None).unwrap(); } + +#[cfg(feature = "ethermint")] +#[test] +fn test_trait_conversions() { + use std::convert::TryInto; + + // Test TryInto for &Address + let test: Address = "cosmos1vlms2r8f6x7yxjh3ynyzc7ckarqd8a96ckjvrp" + .parse() + .unwrap(); + let eth_address: EthAddress = (&test).try_into().unwrap(); + + // Test From for Address + let cosmos_address: Address = eth_address.into(); + + // Verify the roundtrip works + assert_eq!(test.get_bytes(), cosmos_address.get_bytes()); +} + +#[test] +fn test_re_prefix() { + let test: Address = "cosmos1vlms2r8f6x7yxjh3ynyzc7ckarqd8a96ckjvrp" + .parse() + .unwrap(); + + // Test re_prefix with a different prefix + let osmosis_address = test.re_prefix("osmo").unwrap(); + assert_eq!(osmosis_address.to_string(), "osmo1vlms2r8f6x7yxjh3ynyzc7ckarqd8a96sdpu4n"); + + // Verify the underlying bytes are the same + assert_eq!(test.get_bytes(), osmosis_address.get_bytes()); + + // Test re_prefix with the same prefix + let cosmos_address = test.re_prefix("cosmos").unwrap(); + assert_eq!(test.to_string(), cosmos_address.to_string()); +} diff --git a/src/error.rs b/src/error.rs index c65e1f2..09c6c42 100644 --- a/src/error.rs +++ b/src/error.rs @@ -64,7 +64,7 @@ pub enum CosmosGrpcError { TimeoutErrorSigned { txid: String, }, - /// This timeout error occurs when no transaction has been signed and therfore we have + /// This timeout error occurs when no transaction has been signed and therefore we have /// complete confidence that no transaction has been broadcast TimeoutError, } diff --git a/src/utils.rs b/src/utils.rs index 908535f..37c67c8 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -163,7 +163,7 @@ pub fn determine_min_fees_and_gas(input: &TxResponse) -> Option { /// Checks a tx response code for known issues returns true if tx is good, false if the tx /// has some known error pub fn check_for_sdk_error(input: &TxResponse) -> Result<(), CosmosGrpcError> { - // check for gas errors, in this case no txid is retured because the tx never made it to the mempool + // check for gas errors, in this case no txid is returned because the tx never made it to the mempool if let Some(v) = determine_min_fees_and_gas(input) { return Err(CosmosGrpcError::InsufficientFees { fee_info: v }); } @@ -248,4 +248,19 @@ mod tests { correct_output ); } + + #[test] + fn test_get_txhash() { + // Test with known input to verify the SHA256 hash is computed correctly + // Using a simple test vector + let test_bytes = vec![0x01, 0x02, 0x03, 0x04]; + let hash = get_txhash(test_bytes); + + // Expected: SHA256 of [0x01, 0x02, 0x03, 0x04] in uppercase hex + // SHA256([0x01, 0x02, 0x03, 0x04]) = 9F64A747E1B97F131FABB6B447296C9B6F0201E79FB3C5356E6C77E89B6A806A + assert_eq!(hash, "9F64A747E1B97F131FABB6B447296C9B6F0201E79FB3C5356E6C77E89B6A806A"); + + // Test that the hash is uppercase + assert_eq!(hash, hash.to_uppercase()); + } }