Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .github/workflows/rust.yml
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ jobs:
steps:
- uses: actions/checkout@v2
- name: Build
run: cargo build --all --verbose
run: cargo build --all --all-features --verbose

unit-tests:

Expand All @@ -27,7 +27,7 @@ jobs:
steps:
- uses: actions/checkout@v2
- name: Unit Tests
run: cargo test --all --verbose
run: cargo test --all --all-features --verbose

clippy:

Expand Down
16 changes: 8 additions & 8 deletions Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "deep_space"
version = "2.30.0"
version = "2.31.0"
authors = ["Justin Kilpatrick <[email protected]>", "Michał Papierski <[email protected]>"]
repository = "https://github.com/althea-net/deep_space"
description = "A highly portable, batteries included, transaction generation and key management library for CosmosSDK blockchains"
Expand All @@ -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]
Expand Down
83 changes: 83 additions & 0 deletions src/address.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<T: Into<String>>(&self, prefix: T) -> Result<Address, AddressError> {
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 {
Expand All @@ -167,6 +181,36 @@ 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 retrieve
// funds sent to that address, so use caution when making assumptions.
#[cfg(feature = "ethermint")]
impl TryInto<EthAddress> for &Address {
type Error = clarity::error::Error;

fn try_into(self) -> Result<EthAddress, Self::Error> {
EthAddress::from_slice(self.get_bytes())
}
}

#[cfg(feature = "ethermint")]
Copy link
Contributor

@ChristianBorst ChristianBorst Jan 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we even have this implemented if it's just going to use the default prefix? It seems like a footgun to me.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

it should probably just be a function with a prefix argument. Remembering to re-prefix when using it will be a pain.

impl From<EthAddress> for Address {
fn from(value: EthAddress) -> Self {
Address::from_slice(value.as_bytes(), DEFAULT_PREFIX).unwrap()
}
}

#[cfg(feature = "ethermint")]
impl Address {
/// From EthAddress with prefix
pub fn from_eth_address_with_prefix<T: Into<String>>(
address: EthAddress,
prefix: T,
) -> Result<Self, AddressError> {
Address::from_slice(address.as_bytes(), prefix)
}
}

impl FromStr for Address {
type Err = AddressError;

Expand Down Expand Up @@ -296,3 +340,42 @@ 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<EthAddress> for &Address
let test: Address = "cosmos1vlms2r8f6x7yxjh3ynyzc7ckarqd8a96ckjvrp"
.parse()
.unwrap();
let eth_address: EthAddress = (&test).try_into().unwrap();

// Test From<EthAddress> 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());
}
7 changes: 4 additions & 3 deletions src/client/gov/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ use tokio::time::timeout;
#[cfg(feature = "althea")]
use super::type_urls::{REGISTER_COIN_PROPOSAL_TYPE_URL, REGISTER_ERC20_PROPOSAL_TYPE_URL};
#[cfg(feature = "althea")]
use althea_proto::canto::erc20::v1::{RegisterCoinProposal, RegisterErc20Proposal};
use althea_proto::althea::erc20::v1::{RegisterCoinProposal, RegisterErc20Proposal};

impl Contact {
/// Gets a list of governance proposals, user provides filter items
Expand Down Expand Up @@ -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,
Expand All @@ -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);
Expand Down
23 changes: 17 additions & 6 deletions src/client/send.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -92,14 +93,22 @@ impl Contact {
) -> Result<TransactionResponse, CosmosGrpcError> {
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)?;
Expand Down Expand Up @@ -266,7 +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(),
Expand Down Expand Up @@ -368,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;
Expand All @@ -386,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,
Expand Down Expand Up @@ -437,11 +446,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),
});
}
},
Expand All @@ -453,6 +463,7 @@ impl Contact {
tx: response.into(),
time: timeout,
sdk_error: None,
tonic_code: None,
})
}
}
Expand Down
4 changes: 2 additions & 2 deletions src/client/type_urls.rs
Original file line number Diff line number Diff line change
Expand Up @@ -36,5 +36,5 @@ pub const SOFTWARE_UPGRADE_PROPOSAL_TYPE_URL: &str =
pub const MSG_MICROTX_TYPE_URL: &str = "/althea.microtx.v1.MsgMicrotx";

// canto proposals
pub const REGISTER_COIN_PROPOSAL_TYPE_URL: &str = "/canto.erc20.v1.RegisterCoinProposal";
pub const REGISTER_ERC20_PROPOSAL_TYPE_URL: &str = "/canto.erc20.v1.RegisterERC20Proposal";
pub const REGISTER_COIN_PROPOSAL_TYPE_URL: &str = "/althea.erc20.v1.RegisterCoinProposal";
pub const REGISTER_ERC20_PROPOSAL_TYPE_URL: &str = "/althea.erc20.v1.RegisterERC20Proposal";
31 changes: 30 additions & 1 deletion src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ pub enum CosmosGrpcError {
tx: TxResponse,
time: Duration,
sdk_error: Option<SdkErrorCode>,
tonic_code: Option<tonic::Code>,
},
InsufficientFees {
fee_info: FeeInfo,
Expand All @@ -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 therefore 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<String> {
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 {
Expand Down Expand Up @@ -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()
)
}
Expand All @@ -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}"
)
}
}
}
}
Expand Down
8 changes: 4 additions & 4 deletions src/mnemonic/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Mnemonic, Bip39Error> {
if entropy.len() % 4 != 0 {
if !entropy.len().is_multiple_of(4) {
return Err(Bip39Error::BadEntropyBitCount(entropy.len() * 8));
}

Expand Down Expand Up @@ -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<Mnemonic, Bip39Error> {
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)
Expand All @@ -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()));
}

Expand Down
Loading