diff --git a/.github/workflows/cosmos-tx.yml b/.github/workflows/cosmos-tx.yml
index 2fc544a8..3ab0e11b 100644
--- a/.github/workflows/cosmos-tx.yml
+++ b/.github/workflows/cosmos-tx.yml
@@ -53,3 +53,4 @@ jobs:
toolchain: ${{ matrix.rust }}
override: true
- run: cargo test --release
+ - run: cargo test --release --all-features
diff --git a/cosmos-tx/Cargo.toml b/cosmos-tx/Cargo.toml
index b162f682..a65aae5b 100644
--- a/cosmos-tx/Cargo.toml
+++ b/cosmos-tx/Cargo.toml
@@ -17,7 +17,18 @@ eyre = "0.6"
k256 = { version = "0.7", features = ["ecdsa", "sha256"] }
prost = "0.7"
prost-types = "0.7"
-rand_core = "0.5"
-rust_decimal = "1.9"
-tendermint = { version = "0.19", features = ["secp256k1"] }
+rand_core = { version = "0.5", features = ["std"] }
+subtle-encoding = { version = "0.5", features = ["bech32-preview"] }
+tendermint = { version = "0.19", default-features = false, features = ["secp256k1"] }
+tendermint-rpc = { version = "0.19", optional = true, features = ["http-client"] }
thiserror = "1"
+
+[dev-dependencies]
+tokio = "1"
+
+[features]
+rpc = ["tendermint-rpc"]
+
+[package.metadata.docs.rs]
+all-features = true
+rustdoc-args = ["--cfg", "docsrs"]
diff --git a/cosmos-tx/src/bank.rs b/cosmos-tx/src/bank.rs
new file mode 100644
index 00000000..82a4b067
--- /dev/null
+++ b/cosmos-tx/src/bank.rs
@@ -0,0 +1,73 @@
+//! Bank module support
+//!
+//!
+
+use crate::{
+ tx::{Msg, MsgType},
+ AccountId, Coin, Result,
+};
+use cosmos_sdk_proto::cosmos;
+use std::convert::{TryFrom, TryInto};
+
+/// MsgSend represents a message to send coins from one account to another.
+#[derive(Clone, Debug, Eq, PartialEq, PartialOrd, Ord)]
+pub struct MsgSend {
+ /// Sender's address.
+ pub from_address: AccountId,
+
+ /// Recipient's address.
+ pub to_address: AccountId,
+
+ /// Amount to send
+ pub amount: Vec,
+}
+
+impl MsgType for MsgSend {
+ fn from_msg(msg: &Msg) -> Result {
+ cosmos::bank::v1beta1::MsgSend::from_msg(msg).and_then(TryInto::try_into)
+ }
+
+ fn to_msg(&self) -> Result {
+ cosmos::bank::v1beta1::MsgSend::from(self).to_msg()
+ }
+}
+
+impl TryFrom for MsgSend {
+ type Error = eyre::Report;
+
+ fn try_from(proto: cosmos::bank::v1beta1::MsgSend) -> Result {
+ MsgSend::try_from(&proto)
+ }
+}
+
+impl TryFrom<&cosmos::bank::v1beta1::MsgSend> for MsgSend {
+ type Error = eyre::Report;
+
+ fn try_from(proto: &cosmos::bank::v1beta1::MsgSend) -> Result {
+ Ok(MsgSend {
+ from_address: proto.from_address.parse()?,
+ to_address: proto.to_address.parse()?,
+ amount: proto
+ .amount
+ .iter()
+ .map(TryFrom::try_from)
+ .collect::>()?,
+ })
+ }
+}
+
+impl From for cosmos::bank::v1beta1::MsgSend {
+ fn from(coin: MsgSend) -> cosmos::bank::v1beta1::MsgSend {
+ cosmos::bank::v1beta1::MsgSend::from(&coin)
+ }
+}
+
+impl From<&MsgSend> for cosmos::bank::v1beta1::MsgSend {
+ fn from(msg: &MsgSend) -> cosmos::bank::v1beta1::MsgSend {
+ cosmos::bank::v1beta1::MsgSend {
+ from_address: msg.from_address.to_string(),
+ to_address: msg.to_address.to_string(),
+ amount: msg.amount.iter().map(Into::into).collect(),
+ }
+ }
+}
diff --git a/cosmos-tx/src/base.rs b/cosmos-tx/src/base.rs
new file mode 100644
index 00000000..63c106e6
--- /dev/null
+++ b/cosmos-tx/src/base.rs
@@ -0,0 +1,171 @@
+//! Base functionality.
+
+use crate::{Decimal, Error, Result};
+use cosmos_sdk_proto::cosmos;
+use std::{
+ convert::{TryFrom, TryInto},
+ fmt,
+ str::FromStr,
+};
+use subtle_encoding::bech32;
+
+/// Account identifiers
+#[derive(Clone, Eq, PartialEq, PartialOrd, Ord)]
+pub struct AccountId {
+ /// Account ID encoded as Bech32
+ bech32: String,
+
+ /// Length of the human-readable prefix of the address
+ hrp_length: usize,
+}
+
+impl AccountId {
+ /// Create an [`AccountId`] with the given human-readable prefix and
+ /// public key hash.
+ pub fn new(prefix: &str, bytes: [u8; tendermint::account::LENGTH]) -> Result {
+ let id = bech32::encode(prefix, &bytes);
+
+ // TODO(tarcieri): ensure this is the proper validation for an account prefix
+ if prefix.chars().all(|c| matches!(c, 'a'..='z')) {
+ Ok(Self {
+ bech32: id,
+ hrp_length: prefix.len(),
+ })
+ } else {
+ Err(Error::AccountId { id }.into())
+ }
+ }
+
+ /// Get the human-readable prefix of this account.
+ pub fn prefix(&self) -> &str {
+ &self.bech32[..self.hrp_length]
+ }
+
+ /// Decode an account ID from Bech32 to an inner byte value.
+ pub fn to_bytes(&self) -> [u8; tendermint::account::LENGTH] {
+ bech32::decode(&self.bech32)
+ .ok()
+ .and_then(|result| result.1.try_into().ok())
+ .expect("malformed Bech32 AccountId")
+ }
+}
+
+impl AsRef for AccountId {
+ fn as_ref(&self) -> &str {
+ &self.bech32
+ }
+}
+
+impl fmt::Debug for AccountId {
+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+ f.debug_tuple("AccountId").field(&self.as_ref()).finish()
+ }
+}
+
+impl fmt::Display for AccountId {
+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+ f.write_str(self.as_ref())
+ }
+}
+
+impl FromStr for AccountId {
+ type Err = eyre::Report;
+
+ fn from_str(s: &str) -> Result {
+ let (hrp, bytes) = bech32::decode(s)?;
+
+ if bytes.len() == tendermint::account::LENGTH {
+ Ok(Self {
+ bech32: s.to_owned(),
+ hrp_length: hrp.len(),
+ })
+ } else {
+ Err(Error::AccountId { id: s.to_owned() }.into())
+ }
+ }
+}
+
+impl From for tendermint::account::Id {
+ fn from(id: AccountId) -> tendermint::account::Id {
+ tendermint::account::Id::from(&id)
+ }
+}
+
+impl From<&AccountId> for tendermint::account::Id {
+ fn from(id: &AccountId) -> tendermint::account::Id {
+ tendermint::account::Id::new(id.to_bytes())
+ }
+}
+
+/// Coin defines a token with a denomination and an amount.
+#[derive(Clone, Debug, Eq, PartialEq, PartialOrd, Ord)]
+pub struct Coin {
+ /// Denomination
+ pub denom: Denom,
+
+ /// Amount
+ pub amount: Decimal,
+}
+
+impl TryFrom for Coin {
+ type Error = eyre::Report;
+
+ fn try_from(proto: cosmos::base::v1beta1::Coin) -> Result {
+ Coin::try_from(&proto)
+ }
+}
+
+impl TryFrom<&cosmos::base::v1beta1::Coin> for Coin {
+ type Error = eyre::Report;
+
+ fn try_from(proto: &cosmos::base::v1beta1::Coin) -> Result {
+ Ok(Coin {
+ denom: proto.denom.parse()?,
+ amount: proto.amount.parse()?,
+ })
+ }
+}
+
+impl From for cosmos::base::v1beta1::Coin {
+ fn from(coin: Coin) -> cosmos::base::v1beta1::Coin {
+ cosmos::base::v1beta1::Coin::from(&coin)
+ }
+}
+
+impl From<&Coin> for cosmos::base::v1beta1::Coin {
+ fn from(coin: &Coin) -> cosmos::base::v1beta1::Coin {
+ cosmos::base::v1beta1::Coin {
+ denom: coin.denom.to_string(),
+ amount: coin.amount.to_string(),
+ }
+ }
+}
+
+/// Denomination.
+#[derive(Clone, Debug, Eq, PartialEq, PartialOrd, Ord)]
+pub struct Denom(String);
+
+impl AsRef for Denom {
+ fn as_ref(&self) -> &str {
+ self.0.as_ref()
+ }
+}
+
+impl fmt::Display for Denom {
+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+ f.write_str(self.as_ref())
+ }
+}
+
+impl FromStr for Denom {
+ type Err = eyre::Report;
+
+ fn from_str(s: &str) -> Result {
+ // TODO(tarcieri): ensure this is the proper validation for a denom name
+ if s.chars().all(|c| matches!(c, 'a'..='z')) {
+ Ok(Denom(s.to_owned()))
+ } else {
+ Err(Error::Denom { name: s.to_owned() }.into())
+ }
+ }
+}
diff --git a/cosmos-tx/src/builder.rs b/cosmos-tx/src/builder.rs
index add3fc89..287024f5 100644
--- a/cosmos-tx/src/builder.rs
+++ b/cosmos-tx/src/builder.rs
@@ -5,13 +5,14 @@
// Copyright © 2020 Informal Systems Inc.
// Licensed under the Apache 2.0 license
-use super::msg::Msg;
-use crate::SigningKey;
-use cosmos_sdk_proto::cosmos::tx::v1beta1::{
- mode_info, AuthInfo, Fee, ModeInfo, SignDoc, SignerInfo, TxBody, TxRaw,
+use crate::{
+ prost_ext::MessageExt,
+ tx::{self, Fee},
+ SigningKey,
};
+use cosmos_sdk_proto::cosmos::tx::v1beta1::{mode_info, AuthInfo, ModeInfo, SignDoc, SignerInfo};
use eyre::Result;
-use tendermint::{block, chain};
+use tendermint::chain;
/// Protocol Buffer-encoded transaction builder
pub struct Builder {
@@ -24,9 +25,9 @@ pub struct Builder {
impl Builder {
/// Create a new transaction builder
- pub fn new(chain_id: impl Into, account_number: u64) -> Self {
+ pub fn new(chain_id: chain::Id, account_number: u64) -> Self {
Self {
- chain_id: chain_id.into(),
+ chain_id,
account_number,
}
}
@@ -44,36 +45,12 @@ impl Builder {
/// Build and sign a transaction containing the given messages
pub fn sign_tx(
&self,
+ body: tx::Body,
signing_key: &SigningKey,
sequence: u64,
- messages: &[Msg],
fee: Fee,
- memo: impl Into,
- timeout_height: block::Height,
- ) -> Result> {
- // Create TxBody
- let body = TxBody {
- messages: messages.iter().map(|msg| msg.0.clone()).collect(),
- memo: memo.into(),
- timeout_height: timeout_height.into(),
- extension_options: Default::default(),
- non_critical_extension_options: Default::default(),
- };
-
- // A protobuf serialization of a TxBody
- let mut body_buf = Vec::new();
- prost::Message::encode(&body, &mut body_buf).unwrap();
-
- let pk = signing_key.public_key();
- let mut pk_buf = Vec::new();
- prost::Message::encode(&pk.as_bytes().to_vec(), &mut pk_buf).unwrap();
-
- // TODO(tarcieri): extract proper key type
- let pk_any = prost_types::Any {
- type_url: "/cosmos.crypto.secp256k1.PubKey".to_string(),
- value: Vec::from(&pk.as_bytes()[..]),
- };
-
+ ) -> Result {
+ let public_key = signing_key.public_key();
let single = mode_info::Single { mode: 1 };
let mode = Some(ModeInfo {
@@ -81,43 +58,34 @@ impl Builder {
});
let signer_info = SignerInfo {
- public_key: Some(pk_any),
+ public_key: Some(public_key.to_any()?),
mode_info: mode,
sequence,
};
let auth_info = AuthInfo {
signer_infos: vec![signer_info],
- fee: Some(fee),
+ fee: Some(fee.into()),
};
- // Protobuf serialization of `AuthInfo`
- let mut auth_buf = Vec::new();
- prost::Message::encode(&auth_info, &mut auth_buf)?;
+ let body_bytes = body.into_bytes()?;
+ let auth_info_bytes = auth_info.to_bytes()?;
let sign_doc = SignDoc {
- body_bytes: body_buf.clone(),
- auth_info_bytes: auth_buf.clone(),
+ body_bytes: body_bytes.clone(),
+ auth_info_bytes: auth_info_bytes.clone(),
chain_id: self.chain_id.to_string(),
account_number: self.account_number,
};
- // Protobuf serialization of `SignDoc`
- let mut signdoc_buf = Vec::new();
- prost::Message::encode(&sign_doc, &mut signdoc_buf)?;
+ let sign_doc_bytes = sign_doc.to_bytes()?;
+ let signed = signing_key.sign(&sign_doc_bytes)?;
- // Sign the signdoc
- let signed = signing_key.sign(&signdoc_buf)?;
-
- let tx_raw = TxRaw {
- body_bytes: body_buf,
- auth_info_bytes: auth_buf,
+ Ok(cosmos_sdk_proto::cosmos::tx::v1beta1::TxRaw {
+ body_bytes,
+ auth_info_bytes,
signatures: vec![signed.as_ref().to_vec()],
- };
-
- let mut txraw_buf = Vec::new();
- prost::Message::encode(&tx_raw, &mut txraw_buf)?;
-
- Ok(txraw_buf)
+ }
+ .into())
}
}
diff --git a/cosmos-tx/src/decimal.rs b/cosmos-tx/src/decimal.rs
index b0460365..8df8ea58 100644
--- a/cosmos-tx/src/decimal.rs
+++ b/cosmos-tx/src/decimal.rs
@@ -2,93 +2,46 @@
//!
//! [1]: https://pkg.go.dev/github.com/cosmos/cosmos-sdk/types#Dec
-use crate::Error;
-use eyre::{Result, WrapErr};
+use crate::Result;
use std::{
- convert::{TryFrom, TryInto},
- fmt::{self, Debug, Display},
+ fmt,
+ ops::{Add, AddAssign},
str::FromStr,
};
-/// Number of decimal places required by an `sdk.Dec`
-/// See:
-pub const PRECISION: u32 = 18;
-
-/// Maximum value of the decimal part of an `sdk.Dec`
-pub const FRACTIONAL_DIGITS_MAX: u64 = 9_999_999_999_999_999_999;
-
/// Decimal type which follows Cosmos [Cosmos `sdk.Dec`][1] conventions.
///
/// [1]: https://pkg.go.dev/github.com/cosmos/cosmos-sdk/types#Dec
-#[derive(Copy, Clone, Eq, PartialEq, PartialOrd, Ord)]
-pub struct Decimal(rust_decimal::Decimal);
-
-impl Decimal {
- /// Create a new [`Decimal`] with the given whole number and decimal
- /// parts. The decimal part assumes 18 digits of precision e.g. a
- /// decimal with `(1, 1)` is `1.000000000000000001`.
- ///
- /// 18 digits required by the Cosmos SDK. See:
- /// See:
- pub fn new(integral_digits: i64, fractional_digits: u64) -> Result {
- if fractional_digits > FRACTIONAL_DIGITS_MAX {
- return Err(Error::Decimal).wrap_err_with(|| {
- format!(
- "fractional digits exceed available precision: {}",
- fractional_digits
- )
- });
- }
+#[derive(Copy, Clone, Debug, Eq, PartialEq, PartialOrd, Ord)]
+pub struct Decimal(u64);
- let integral_digits: rust_decimal::Decimal = integral_digits.into();
- let fractional_digits: rust_decimal::Decimal = fractional_digits.into();
- let precision_exp: rust_decimal::Decimal = 10u64.pow(PRECISION).into();
-
- let mut combined_decimal = (integral_digits * precision_exp) + fractional_digits;
- combined_decimal.set_scale(PRECISION)?;
- Ok(Decimal(combined_decimal))
- }
-}
+impl FromStr for Decimal {
+ type Err = eyre::Report;
-impl Debug for Decimal {
- fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
- write!(f, "{:?}", self.0)
+ fn from_str(s: &str) -> Result {
+ Ok(s.parse().map(Self)?)
}
}
-impl Display for Decimal {
+impl fmt::Display for Decimal {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.0)
}
}
-impl FromStr for Decimal {
- type Err = eyre::Report;
+impl Add for Decimal {
+ type Output = Decimal;
- fn from_str(s: &str) -> Result {
- s.parse::()?.try_into()
+ #[inline]
+ fn add(self, rhs: Decimal) -> Decimal {
+ Decimal(self.0 + rhs.0)
}
}
-impl TryFrom for Decimal {
- type Error = eyre::Report;
-
- fn try_from(mut decimal_value: rust_decimal::Decimal) -> Result {
- match decimal_value.scale() {
- 0 => {
- let exp: rust_decimal::Decimal = 10u64.pow(PRECISION).into();
- decimal_value *= exp;
- decimal_value.set_scale(PRECISION)?;
- }
- PRECISION => (),
- other => {
- return Err(Error::Decimal).wrap_err_with(|| {
- format!("invalid decimal precision: {} (must be 0 or 18)", other)
- })
- }
- }
-
- Ok(Decimal(decimal_value))
+impl AddAssign for Decimal {
+ #[inline]
+ fn add_assign(&mut self, rhs: Decimal) {
+ self.0 += rhs.0;
}
}
@@ -97,22 +50,10 @@ macro_rules! impl_from_primitive_int_for_decimal {
$(impl From<$int> for Decimal {
fn from(num: $int) -> Decimal {
#[allow(trivial_numeric_casts)]
- Decimal::new(num as i64, 0).unwrap()
+ Decimal(num.into())
}
})+
};
}
-impl_from_primitive_int_for_decimal!(i8, i16, i32, i64, isize);
-impl_from_primitive_int_for_decimal!(u8, u16, u32, u64, usize);
-
-#[cfg(test)]
-mod tests {
- use super::Decimal;
-
- #[test]
- fn string_serialization_test() {
- let num = Decimal::from(-1i8);
- assert_eq!(num.to_string(), "-1.000000000000000000")
- }
-}
+impl_from_primitive_int_for_decimal!(u8, u16, u32, u64);
diff --git a/cosmos-tx/src/error.rs b/cosmos-tx/src/error.rs
index ee8d369c..68c14e5c 100644
--- a/cosmos-tx/src/error.rs
+++ b/cosmos-tx/src/error.rs
@@ -4,10 +4,41 @@ pub use eyre::{Report, Result};
use thiserror::Error;
-/// Kinds of errors
-#[derive(Copy, Clone, Debug, Error, Eq, PartialEq)]
+/// Kinds of errors.
+#[derive(Clone, Debug, Error, Eq, PartialEq)]
pub enum Error {
- /// Invalid decimal value
- #[error("invalid decimal value")]
- Decimal,
+ /// Invalid account.
+ #[error("invalid account ID: {id:?}")]
+ AccountId {
+ /// Malformed account ID
+ id: String,
+ },
+
+ /// Cryptographic errors.
+ #[error("cryptographic error")]
+ Crypto,
+
+ /// Invalid decimal value.
+ #[error("invalid decimal value: {value:?}")]
+ Decimal {
+ /// Invalid decimal value
+ value: String,
+ },
+
+ /// Invalid denomination.
+ #[error("invalid denomination: {name:?}")]
+ Denom {
+ /// Invalid name
+ name: String,
+ },
+
+ /// Unexpected message type.
+ #[error("unexpected Msg type: {found:?}, expected {expected:?}")]
+ MsgType {
+ /// Expected type URL.
+ expected: &'static str,
+
+ /// Actual type URL found in the [`prost_types::Any`] message.
+ found: String,
+ },
}
diff --git a/cosmos-tx/src/lib.rs b/cosmos-tx/src/lib.rs
index 368425fd..5a483415 100644
--- a/cosmos-tx/src/lib.rs
+++ b/cosmos-tx/src/lib.rs
@@ -1,5 +1,6 @@
//! Transaction builder and signer for Cosmos-based blockchains
+#![cfg_attr(docsrs, feature(doc_cfg))]
#![doc(
html_logo_url = "https://raw.githubusercontent.com/cosmos/cosmos-rust/main/.images/cosmos.png",
html_root_url = "https://docs.rs/cosmos-sdk-proto/0.2.0-pre"
@@ -7,14 +8,28 @@
#![forbid(unsafe_code)]
#![warn(trivial_casts, trivial_numeric_casts, unused_import_braces)]
+pub mod bank;
+pub mod tx;
+
+mod base;
mod builder;
mod decimal;
mod error;
-mod msg;
+mod prost_ext;
+mod public_key;
mod signing_key;
pub use crate::{
- builder::Builder, decimal::Decimal, error::Error, msg::Msg, signing_key::SigningKey,
+ base::{AccountId, Coin, Denom},
+ builder::Builder,
+ decimal::Decimal,
+ error::{Error, Result},
+ public_key::PublicKey,
+ signing_key::SigningKey,
};
-pub use k256::ecdsa::{Signature, VerifyingKey};
-pub use tendermint::PublicKey;
+
+pub use k256::ecdsa::Signature;
+pub use tendermint;
+
+#[cfg(feature = "rpc")]
+pub use tendermint_rpc as rpc;
diff --git a/cosmos-tx/src/msg.rs b/cosmos-tx/src/msg.rs
deleted file mode 100644
index 0ef6b095..00000000
--- a/cosmos-tx/src/msg.rs
+++ /dev/null
@@ -1,28 +0,0 @@
-//! Transaction messages
-
-use prost_types::Any;
-
-/// Transaction messages
-pub struct Msg(pub(crate) Any);
-
-impl Msg {
- /// Create a new message type
- pub fn new(type_url: impl Into, value: impl Into>) -> Self {
- Msg(Any {
- type_url: type_url.into(),
- value: value.into(),
- })
- }
-}
-
-impl From for Msg {
- fn from(any: Any) -> Msg {
- Msg(any)
- }
-}
-
-impl From for Any {
- fn from(msg: Msg) -> Any {
- msg.0
- }
-}
diff --git a/cosmos-tx/src/prost_ext.rs b/cosmos-tx/src/prost_ext.rs
new file mode 100644
index 00000000..560a0127
--- /dev/null
+++ b/cosmos-tx/src/prost_ext.rs
@@ -0,0 +1,21 @@
+//! Prost extension traits
+
+use crate::Result;
+
+/// Extension trait for prost messages.
+// TODO(tarcieri): decide if this trait should really be sealed or if it should be public
+pub trait MessageExt: prost::Message {
+ /// Serialize this protobuf message as a byte vector.
+ fn to_bytes(&self) -> Result>;
+}
+
+impl MessageExt for M
+where
+ M: prost::Message,
+{
+ fn to_bytes(&self) -> Result> {
+ let mut bytes = Vec::new();
+ prost::Message::encode(self, &mut bytes)?;
+ Ok(bytes)
+ }
+}
diff --git a/cosmos-tx/src/public_key.rs b/cosmos-tx/src/public_key.rs
new file mode 100644
index 00000000..0d61348c
--- /dev/null
+++ b/cosmos-tx/src/public_key.rs
@@ -0,0 +1,104 @@
+//! Public keys
+
+// TODO(tarcieri): upstream this to `tendermint-rs`?
+
+use crate::{prost_ext::MessageExt, AccountId, Error, Result};
+use cosmos_sdk_proto::cosmos;
+use ecdsa::elliptic_curve::sec1::ToEncodedPoint;
+use eyre::WrapErr;
+use prost_types::Any;
+use std::convert::{TryFrom, TryInto};
+
+/// Protobuf [`Any`] type URL for Ed25519 public keys
+const ED25519_TYPE_URL: &str = "/cosmos.crypto.ed25519.PubKey";
+
+/// Protobuf [`Any`] type URL for secp256k1 public keys
+const SECP256K1_TYPE_URL: &str = "/cosmos.crypto.secp256k1.PubKey";
+
+/// Public keys
+#[derive(Copy, Clone, Debug, Eq, PartialEq)]
+pub struct PublicKey(tendermint::PublicKey);
+
+impl PublicKey {
+ /// Get the [`AccountId`] for this [`PublicKey`] (if applicable).
+ // TODO(tarcieri): upstream our `AccountId` type to tendermint-rs?
+ pub fn account_id(&self, prefix: &str) -> Result {
+ match &self.0 {
+ tendermint::PublicKey::Secp256k1(encoded_point) => {
+ let id = tendermint::account::Id::from(*encoded_point);
+ AccountId::new(prefix, id.as_bytes().try_into()?)
+ }
+ _ => Err(Error::Crypto.into()),
+ }
+ }
+
+ /// Convert this [`PublicKey`] to a Protobuf [`Any`] type.
+ pub fn to_any(&self) -> Result {
+ match self.0 {
+ tendermint::PublicKey::Ed25519(_) => {
+ let proto = cosmos::crypto::secp256k1::PubKey {
+ key: self.to_bytes(),
+ };
+
+ Ok(Any {
+ type_url: ED25519_TYPE_URL.to_owned(),
+ value: proto.to_bytes()?,
+ })
+ }
+ tendermint::PublicKey::Secp256k1(_) => {
+ let proto = cosmos::crypto::secp256k1::PubKey {
+ key: self.to_bytes(),
+ };
+
+ Ok(Any {
+ type_url: SECP256K1_TYPE_URL.to_owned(),
+ value: proto.to_bytes()?,
+ })
+ }
+ _ => Err(Error::Crypto.into()),
+ }
+ }
+
+ /// Serialize this [`PublicKey`] as a byte vector.
+ pub fn to_bytes(&self) -> Vec {
+ self.0.as_bytes().to_vec()
+ }
+}
+
+impl From for PublicKey {
+ fn from(vk: k256::ecdsa::VerifyingKey) -> PublicKey {
+ PublicKey::from(&vk)
+ }
+}
+
+impl From<&k256::ecdsa::VerifyingKey> for PublicKey {
+ fn from(vk: &k256::ecdsa::VerifyingKey) -> PublicKey {
+ PublicKey(vk.to_encoded_point(true).into())
+ }
+}
+
+impl TryFrom<&Any> for PublicKey {
+ type Error = eyre::Report;
+
+ fn try_from(any: &Any) -> Result {
+ match any.type_url.as_str() {
+ SECP256K1_TYPE_URL => tendermint::PublicKey::from_raw_secp256k1(&any.value)
+ .map(Into::into)
+ .ok_or_else(|| Error::Crypto.into()),
+ other => Err(Error::Crypto)
+ .wrap_err_with(|| format!("invalid type URL for public key: {}", other)),
+ }
+ }
+}
+
+impl From for PublicKey {
+ fn from(pk: tendermint::PublicKey) -> PublicKey {
+ PublicKey(pk)
+ }
+}
+
+impl From for tendermint::PublicKey {
+ fn from(pk: PublicKey) -> tendermint::PublicKey {
+ pk.0
+ }
+}
diff --git a/cosmos-tx/src/signing_key.rs b/cosmos-tx/src/signing_key.rs
index c1f0df9f..6c4656e1 100644
--- a/cosmos-tx/src/signing_key.rs
+++ b/cosmos-tx/src/signing_key.rs
@@ -1,21 +1,21 @@
//! Transaction signing key
use crate::{PublicKey, Signature};
-use core::convert::TryFrom;
-use ecdsa::elliptic_curve::sec1::ToEncodedPoint;
use eyre::Result;
-use rand_core::{CryptoRng, RngCore};
+use k256::ecdsa::VerifyingKey;
+use rand_core::OsRng;
+use std::convert::TryFrom;
-/// Transaction signing key.
+/// Transaction signing key (ECDSA/secp256k1)
pub struct SigningKey {
inner: Box,
}
impl SigningKey {
/// Generate a random signing key.
- pub fn random(rng: impl CryptoRng + RngCore) -> Self {
+ pub fn random() -> Self {
Self {
- inner: Box::new(k256::ecdsa::SigningKey::random(rng)),
+ inner: Box::new(k256::ecdsa::SigningKey::random(&mut OsRng)),
}
}
@@ -32,9 +32,9 @@ impl SigningKey {
Ok(self.inner.try_sign(msg)?)
}
- /// Get the Tendermint public key for this [`SigningKey`]
+ /// Get the [`PublicKey`] for this [`SigningKey`].
pub fn public_key(&self) -> PublicKey {
- self.inner.public_key()
+ self.inner.verifying_key().into()
}
}
@@ -54,8 +54,8 @@ impl TryFrom<&[u8]> for SigningKey {
/// ECDSA/secp256k1 signer
pub trait Secp256k1Signer: ecdsa::signature::Signer {
- /// Get the Tendermint public key for this signer
- fn public_key(&self) -> PublicKey;
+ /// Get the ECDSA verifying key for this signer
+ fn verifying_key(&self) -> VerifyingKey;
}
impl Secp256k1Signer for T
@@ -63,9 +63,7 @@ where
T: ecdsa::signature::Signer,
k256::ecdsa::VerifyingKey: for<'a> From<&'a T>,
{
- fn public_key(&self) -> PublicKey {
- k256::ecdsa::VerifyingKey::from(self)
- .to_encoded_point(true)
- .into()
+ fn verifying_key(&self) -> VerifyingKey {
+ self.into()
}
}
diff --git a/cosmos-tx/src/tx.rs b/cosmos-tx/src/tx.rs
new file mode 100644
index 00000000..f295437a
--- /dev/null
+++ b/cosmos-tx/src/tx.rs
@@ -0,0 +1,15 @@
+//! Transactions.
+
+mod body;
+mod fee;
+mod msg;
+mod raw;
+
+pub use tendermint::abci::Gas;
+
+pub use self::{
+ body::Body,
+ fee::Fee,
+ msg::{Msg, MsgProto, MsgType},
+ raw::Raw,
+};
diff --git a/cosmos-tx/src/tx/body.rs b/cosmos-tx/src/tx/body.rs
new file mode 100644
index 00000000..86189423
--- /dev/null
+++ b/cosmos-tx/src/tx/body.rs
@@ -0,0 +1,100 @@
+//! Transaction bodies.
+
+use super::Msg;
+use crate::{prost_ext::MessageExt, Result};
+use cosmos_sdk_proto::cosmos;
+use prost_types::Any;
+use std::convert::{TryFrom, TryInto};
+use tendermint::block;
+
+/// [`Body`] of a transaction that all signers sign over.
+///
+/// This type is known as `TxBody` in the Golang cosmos-sdk.
+#[derive(Clone, Debug)]
+pub struct Body {
+ /// `messages` is a list of messages to be executed. The required signers of
+ /// those messages define the number and order of elements in `AuthInfo`'s
+ /// signer_infos and Tx's signatures. Each required signer address is added to
+ /// the list only the first time it occurs.
+ ///
+ /// By convention, the first required signer (usually from the first message)
+ /// is referred to as the primary signer and pays the fee for the whole
+ /// transaction.
+ pub messages: Vec,
+
+ /// `memo` is any arbitrary memo to be added to the transaction.
+ pub memo: String,
+
+ /// `timeout` is the block height after which this transaction will not
+ /// be processed by the chain
+ pub timeout_height: block::Height,
+
+ /// `extension_options` are arbitrary options that can be added by chains
+ /// when the default options are not sufficient. If any of these are present
+ /// and can't be handled, the transaction will be rejected
+ pub extension_options: Vec,
+
+ /// `extension_options` are arbitrary options that can be added by chains
+ /// when the default options are not sufficient. If any of these are present
+ /// and can't be handled, they will be ignored
+ pub non_critical_extension_options: Vec,
+}
+
+impl Body {
+ /// Create a new [`Body`] from the given messages, memo, and timeout height.
+ pub fn new(
+ messages: I,
+ memo: impl Into,
+ timeout_height: impl Into,
+ ) -> Self
+ where
+ I: IntoIterator- ,
+ {
+ Body {
+ messages: messages.into_iter().map(Into::into).collect(),
+ memo: memo.into(),
+ timeout_height: timeout_height.into(),
+ extension_options: Default::default(),
+ non_critical_extension_options: Default::default(),
+ }
+ }
+
+ /// Convert the body to a Protocol Buffers representation.
+ pub fn into_proto(self) -> cosmos::tx::v1beta1::TxBody {
+ self.into()
+ }
+
+ /// Serialize this type as an encoded Protocol Buffers.
+ pub fn into_bytes(self) -> Result> {
+ self.into_proto().to_bytes()
+ }
+}
+
+impl From for cosmos::tx::v1beta1::TxBody {
+ fn from(body: Body) -> cosmos::tx::v1beta1::TxBody {
+ cosmos::tx::v1beta1::TxBody {
+ messages: body.messages.into_iter().map(Into::into).collect(),
+ memo: body.memo,
+ timeout_height: body.timeout_height.into(),
+ extension_options: body.extension_options,
+ non_critical_extension_options: body.non_critical_extension_options,
+ }
+ }
+}
+
+impl TryFrom for Body {
+ type Error = eyre::Report;
+
+ fn try_from(proto: cosmos::tx::v1beta1::TxBody) -> Result {
+ Ok(Body {
+ messages: proto.messages.into_iter().map(Into::into).collect(),
+ memo: proto.memo,
+ timeout_height: proto
+ .timeout_height
+ .try_into()
+ .map_err(|_| tendermint::error::Kind::Parse)?,
+ extension_options: proto.extension_options,
+ non_critical_extension_options: proto.non_critical_extension_options,
+ })
+ }
+}
diff --git a/cosmos-tx/src/tx/fee.rs b/cosmos-tx/src/tx/fee.rs
new file mode 100644
index 00000000..6be73de9
--- /dev/null
+++ b/cosmos-tx/src/tx/fee.rs
@@ -0,0 +1,114 @@
+//! Transaction fees
+
+use super::Gas;
+use crate::{AccountId, Coin, Result};
+use cosmos_sdk_proto::cosmos;
+use std::convert::TryFrom;
+
+/// Fee includes the amount of coins paid in fees and the maximum gas to be
+/// used by the transaction.
+///
+/// The ratio yields an effective “gasprice”, which must be above some minimum
+/// to be accepted into the mempool.
+#[derive(Clone, Debug, Eq, PartialEq, PartialOrd, Ord)]
+pub struct Fee {
+ /// Amount of coins to be paid as a fee.
+ pub amount: Vec,
+
+ /// Maximum gas that can be used in transaction processing before an out
+ /// of gas error occurs.
+ pub gas_limit: Gas,
+
+ /// Payer: if [`None`], the first signer is responsible for paying the fees.
+ ///
+ /// If [`Some`], the specified account must pay the fees. The payer must be
+ /// a tx signer (and thus have signed this field in AuthInfo).
+ ///
+ /// Setting this field does not change the ordering of required signers for
+ /// the transaction.
+ pub payer: Option,
+
+ /// Granter: if [`Some`], the fee payer (either the first signer or the
+ /// value of the payer field) requests that a fee grant be used to pay fees
+ /// instead of the fee payer’s own balance.
+ ///
+ /// If an appropriate fee grant does not exist or the chain does not
+ /// support fee grants, this will fail.
+ pub granter: Option,
+}
+
+impl Fee {
+ /// Simple constructor for a single [`Coin`] amount and the given amount
+ /// of [`Gas`].
+ pub fn from_amount_and_gas(amount: Coin, gas_limit: impl Into) -> Fee {
+ Fee {
+ amount: vec![amount],
+ gas_limit: gas_limit.into(),
+ payer: None,
+ granter: None,
+ }
+ }
+}
+
+impl TryFrom for Fee {
+ type Error = eyre::Report;
+
+ fn try_from(proto: cosmos::tx::v1beta1::Fee) -> Result {
+ Fee::try_from(&proto)
+ }
+}
+
+impl TryFrom<&cosmos::tx::v1beta1::Fee> for Fee {
+ type Error = eyre::Report;
+
+ fn try_from(proto: &cosmos::tx::v1beta1::Fee) -> Result {
+ let amount = proto
+ .amount
+ .iter()
+ .map(TryFrom::try_from)
+ .collect::>()?;
+
+ let gas_limit = proto.gas_limit.into();
+ let mut accounts = [None, None];
+
+ for (index, id) in [&proto.payer, &proto.granter].iter().enumerate() {
+ if id.is_empty() {
+ accounts[index] = None;
+ } else {
+ accounts[index] = Some(proto.payer.parse()?)
+ }
+ }
+
+ Ok(Fee {
+ amount,
+ gas_limit,
+ payer: accounts[0].take(),
+ granter: accounts[1].take(),
+ })
+ }
+}
+
+impl From for cosmos::tx::v1beta1::Fee {
+ fn from(fee: Fee) -> cosmos::tx::v1beta1::Fee {
+ cosmos::tx::v1beta1::Fee::from(&fee)
+ }
+}
+
+impl From<&Fee> for cosmos::tx::v1beta1::Fee {
+ fn from(fee: &Fee) -> cosmos::tx::v1beta1::Fee {
+ cosmos::tx::v1beta1::Fee {
+ amount: fee.amount.iter().map(Into::into).collect(),
+ gas_limit: fee.gas_limit.value(),
+ payer: fee
+ .payer
+ .as_ref()
+ .map(|id| id.to_string())
+ .unwrap_or_default(),
+ granter: fee
+ .granter
+ .as_ref()
+ .map(|id| id.to_string())
+ .unwrap_or_default(),
+ }
+ }
+}
diff --git a/cosmos-tx/src/tx/msg.rs b/cosmos-tx/src/tx/msg.rs
new file mode 100644
index 00000000..9eaada78
--- /dev/null
+++ b/cosmos-tx/src/tx/msg.rs
@@ -0,0 +1,76 @@
+//! Transaction messages
+
+use crate::{prost_ext::MessageExt, Error, Result};
+use cosmos_sdk_proto::cosmos;
+use prost_types::Any;
+
+/// Transaction messages
+#[derive(Clone, Debug)]
+pub struct Msg(pub(crate) Any);
+
+impl Msg {
+ /// Create a new message type
+ pub fn new(type_url: impl Into, value: impl Into>) -> Self {
+ Msg(Any {
+ type_url: type_url.into(),
+ value: value.into(),
+ })
+ }
+}
+
+impl From for Msg {
+ fn from(any: Any) -> Msg {
+ Msg(any)
+ }
+}
+
+impl From for Any {
+ fn from(msg: Msg) -> Any {
+ msg.0
+ }
+}
+
+/// Message types that can be converted to/from a [`Msg`].
+pub trait MsgType {
+ /// Attempt to parse this value from a [`Msg`].
+ fn from_msg(msg: &Msg) -> Result
+ where
+ Self: Sized;
+
+ /// Serialize this value as a [`Msg`].
+ fn to_msg(&self) -> Result;
+}
+
+/// Proto types which can be used as a [`Msg`].
+pub trait MsgProto: Default + MessageExt {
+ /// Type URL value
+ const TYPE_URL: &'static str;
+}
+
+impl MsgType for T
+where
+ T: MsgProto,
+{
+ fn from_msg(msg: &Msg) -> Result
+ where
+ Self: Sized,
+ {
+ if msg.0.type_url == Self::TYPE_URL {
+ Ok(Self::decode(&*msg.0.value)?)
+ } else {
+ Err(Error::MsgType {
+ expected: Self::TYPE_URL,
+ found: msg.0.type_url.clone(),
+ }
+ .into())
+ }
+ }
+
+ fn to_msg(&self) -> Result {
+ self.to_bytes().map(|bytes| Msg::new(Self::TYPE_URL, bytes))
+ }
+}
+
+impl MsgProto for cosmos::bank::v1beta1::MsgSend {
+ const TYPE_URL: &'static str = "/cosmos.bank.v1beta1.MsgSend";
+}
diff --git a/cosmos-tx/src/tx/raw.rs b/cosmos-tx/src/tx/raw.rs
new file mode 100644
index 00000000..7518c82c
--- /dev/null
+++ b/cosmos-tx/src/tx/raw.rs
@@ -0,0 +1,49 @@
+//! Raw transaction.
+
+use crate::{prost_ext::MessageExt, Result};
+use cosmos_sdk_proto::cosmos;
+
+#[cfg(feature = "rpc")]
+use crate::rpc;
+
+/// Response from `/broadcast_tx_commit`
+#[cfg(feature = "rpc")]
+pub type TxCommitResponse = rpc::endpoint::broadcast::tx_commit::Response;
+
+/// Raw transaction
+#[derive(Clone, Debug)]
+pub struct Raw(cosmos::tx::v1beta1::TxRaw);
+
+impl Raw {
+ /// Deserialize raw transaction from serialized protobuf.
+ pub fn from_bytes(bytes: &[u8]) -> Result {
+ Ok(Raw(prost::Message::decode(bytes)?))
+ }
+
+ /// Serialize raw transaction as a byte vector.
+ pub fn to_bytes(&self) -> Result> {
+ self.0.to_bytes()
+ }
+
+ /// Broadcast this transaction using the provided RPC client
+ #[cfg(feature = "rpc")]
+ #[cfg_attr(docsrs, doc(cfg(feature = "rpc")))]
+ pub async fn broadcast_commit(&self, client: &C) -> Result
+ where
+ C: rpc::Client + Send + Sync,
+ {
+ Ok(client.broadcast_tx_commit(self.to_bytes()?.into()).await?)
+ }
+}
+
+impl From for Raw {
+ fn from(tx: cosmos::tx::v1beta1::TxRaw) -> Self {
+ Raw(tx)
+ }
+}
+
+impl From for cosmos::tx::v1beta1::TxRaw {
+ fn from(tx: Raw) -> cosmos::tx::v1beta1::TxRaw {
+ tx.0
+ }
+}
diff --git a/cosmos-tx/tests/integration.rs b/cosmos-tx/tests/integration.rs
new file mode 100644
index 00000000..d44e40b8
--- /dev/null
+++ b/cosmos-tx/tests/integration.rs
@@ -0,0 +1,191 @@
+//! Integration test which submits transactions to a local `gaia` node.
+//!
+//! Requires Docker.
+
+#![cfg(feature = "rpc")]
+
+use cosmos_tx::{
+ bank::MsgSend,
+ rpc,
+ rpc::Client,
+ tx::{self, Fee, MsgType},
+ Builder, Coin, SigningKey,
+};
+use std::{ffi::OsStr, panic, process, str, time::Duration};
+use tokio::time;
+
+/// Name of the Docker image (on Docker Hub) to use
+const DOCKER_IMAGE: &str = "jackzampolin/gaiatest";
+
+/// Chain ID to use for tests
+const CHAIN_ID: &str = "cosmos-tx-test";
+
+/// RPC port
+const RPC_PORT: u16 = 26657;
+
+/// Expected account number
+const ACCOUNT_NUMBER: u64 = 1;
+
+/// Bech32 prefix for an account
+const ACCOUNT_PREFIX: &str = "cosmos";
+
+/// Denom name
+const DENOM: &str = "samoleans";
+
+/// Example memo
+const MEMO: &str = "test memo";
+
+#[test]
+fn msg_send() {
+ let sender_private_key = SigningKey::random();
+ let sender_account_id = sender_private_key
+ .public_key()
+ .account_id(ACCOUNT_PREFIX)
+ .unwrap();
+
+ let recipient_private_key = SigningKey::random();
+ let recipient_account_id = recipient_private_key
+ .public_key()
+ .account_id(ACCOUNT_PREFIX)
+ .unwrap();
+
+ let amount = Coin {
+ amount: 1u8.into(),
+ denom: DENOM.parse().unwrap(),
+ };
+
+ let msg_send = MsgSend {
+ from_address: sender_account_id.clone(),
+ to_address: recipient_account_id,
+ amount: vec![amount.clone()],
+ }
+ .to_msg()
+ .unwrap();
+
+ let chain_id = CHAIN_ID.parse().unwrap();
+ let sequence_number = 0;
+ let gas = 100_000;
+ let fee = Fee::from_amount_and_gas(amount, gas);
+ let timeout_height = 9001u16;
+ let tx_body = tx::Body::new(vec![msg_send], MEMO, timeout_height);
+
+ let tx = Builder::new(chain_id, ACCOUNT_NUMBER)
+ .sign_tx(tx_body, &sender_private_key, sequence_number, fee)
+ .unwrap();
+
+ let docker_args = [
+ "-d",
+ "-p",
+ &format!("{}:{}", RPC_PORT, RPC_PORT),
+ DOCKER_IMAGE,
+ CHAIN_ID,
+ &sender_account_id.to_string(),
+ ];
+
+ docker_run(&docker_args, || {
+ init_tokio_runtime().block_on(async {
+ let rpc_address = format!("http://localhost:{}", RPC_PORT);
+ let rpc_client = rpc::HttpClient::new(rpc_address.as_str()).unwrap();
+
+ wait_for_first_block(&rpc_client).await;
+
+ let tx_commit_response = tx.broadcast_commit(&rpc_client).await.unwrap();
+
+ if tx_commit_response.check_tx.code.is_err() {
+ panic!("check_tx failed: {:?}", tx_commit_response.check_tx);
+ }
+
+ if tx_commit_response.deliver_tx.code.is_err() {
+ panic!("deliver_tx failed: {:?}", tx_commit_response.deliver_tx);
+ }
+
+ // TODO(tarcieri): look up transaction by hash and test transaction parsing
+ });
+ });
+}
+
+/// Initialize Tokio runtime
+fn init_tokio_runtime() -> tokio::runtime::Runtime {
+ tokio::runtime::Builder::new_current_thread()
+ .enable_all()
+ .build()
+ .unwrap()
+}
+
+/// Wait for the node to produce the first block
+async fn wait_for_first_block(rpc_client: &rpc::HttpClient) {
+ rpc_client
+ .wait_until_healthy(Duration::from_secs(5))
+ .await
+ .unwrap();
+
+ let mut attempts_remaining = 25;
+
+ while let Err(e) = rpc_client.latest_block().await {
+ if e.code() != rpc::error::Code::ParseError {
+ panic!("unexpected error waiting for first block: {}", e);
+ }
+
+ if attempts_remaining == 0 {
+ panic!("timeout waiting for first block");
+ }
+
+ attempts_remaining -= 1;
+ time::sleep(Duration::from_millis(200)).await;
+ }
+}
+
+/// Invoke `docker run` with the given arguments, calling the provided function
+/// after the container has booted and terminating the container after the
+/// provided function completes, catching panics and propagating them to ensure
+/// that the container reliably shuts down.
+///
+/// Prints log output from the container in the event an error occurred.
+fn docker_run(args: A, f: F)
+where
+ A: IntoIterator
- ,
+ S: AsRef,
+ F: FnOnce() -> () + panic::UnwindSafe,
+{
+ let container_id = exec_docker_command("run", args);
+ let result = panic::catch_unwind(f);
+
+ if result.is_err() {
+ let logs = exec_docker_command("logs", &[&container_id]);
+
+ println!("\n---- docker stdout ----");
+ println!("{}", logs);
+ }
+
+ exec_docker_command("kill", &[&container_id]);
+
+ if let Err(err) = result {
+ panic::resume_unwind(err);
+ }
+}
+
+/// Execute a given `docker` command, returning what was written to stdout
+/// if the command completed successfully.
+///
+/// Panics if the `docker` process exits with an error code
+fn exec_docker_command(name: &str, args: A) -> String
+where
+ A: IntoIterator
- ,
+ S: AsRef,
+{
+ let output = process::Command::new("docker")
+ .arg(name)
+ .args(args)
+ .stdout(process::Stdio::piped())
+ .output()
+ .expect(&format!("error invoking `docker {}`", name));
+
+ if !output.status.success() {
+ panic!("`docker {}` exited with error status: {:?}", name, output);
+ }
+
+ str::from_utf8(&output.stdout)
+ .expect("UTF-8 error decoding docker output")
+ .trim_end()
+ .to_owned()
+}