From 210f33b1a91b4f86fa8016cbeb1d426aedfffe0a Mon Sep 17 00:00:00 2001 From: Tony Arcieri Date: Mon, 5 Apr 2021 18:23:58 -0700 Subject: [PATCH 1/2] cosmos-tx: MsgSend support w\ integration test Basic support for `MsgSend` with end-to-end integration test. Adds the following: - Traits for simplifying Protobuf serialization: - `prost_ext::MessageExt` for Protobuf encoding - `msg::MsgType` and `msg::MsgProto` for decoding/encoding `Msg` types as Protobuf `Any` messages. - Domain types which model the following: - `Body`: body of a transaction - `Coin`: amount to send and a denom - `Denom`: name of a particular denomination - `Fee`: transaction fees - `MsgSend`: transaction message for performing a simple send Additionally includes an end-to-end test which uses Docker to spawn a single-node `gaia` and send a transaction. --- .github/workflows/cosmos-tx.yml | 1 + cosmos-tx/Cargo.toml | 17 ++- cosmos-tx/src/bank.rs | 73 ++++++++++++ cosmos-tx/src/base.rs | 171 ++++++++++++++++++++++++++++ cosmos-tx/src/builder.rs | 80 ++++--------- cosmos-tx/src/decimal.rs | 101 ++++------------- cosmos-tx/src/error.rs | 41 ++++++- cosmos-tx/src/lib.rs | 23 +++- cosmos-tx/src/msg.rs | 28 ----- cosmos-tx/src/prost_ext.rs | 21 ++++ cosmos-tx/src/public_key.rs | 104 +++++++++++++++++ cosmos-tx/src/signing_key.rs | 26 ++--- cosmos-tx/src/tx.rs | 15 +++ cosmos-tx/src/tx/body.rs | 100 +++++++++++++++++ cosmos-tx/src/tx/fee.rs | 114 +++++++++++++++++++ cosmos-tx/src/tx/msg.rs | 76 +++++++++++++ cosmos-tx/src/tx/raw.rs | 49 ++++++++ cosmos-tx/tests/integration.rs | 191 ++++++++++++++++++++++++++++++++ 18 files changed, 1041 insertions(+), 190 deletions(-) create mode 100644 cosmos-tx/src/bank.rs create mode 100644 cosmos-tx/src/base.rs delete mode 100644 cosmos-tx/src/msg.rs create mode 100644 cosmos-tx/src/prost_ext.rs create mode 100644 cosmos-tx/src/public_key.rs create mode 100644 cosmos-tx/src/tx.rs create mode 100644 cosmos-tx/src/tx/body.rs create mode 100644 cosmos-tx/src/tx/fee.rs create mode 100644 cosmos-tx/src/tx/msg.rs create mode 100644 cosmos-tx/src/tx/raw.rs create mode 100644 cosmos-tx/tests/integration.rs 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..249579bd --- /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}; + +/// 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(); + + rpc_client + .wait_until_healthy(Duration::from_secs(5)) + .await + .unwrap(); + + // Workaround for what appears to be a Tendermint race condition. + // There is a short period after the RPC health check succeeds + // and the `/genesis` endpoint returns valid data where + // transactions fail due to an internal SDK panic in the + // "undefined" code space: + // + // TxResult { + // code: Err(111222), data: None, log: Log("panic"), info: Info(""), + // gas_wanted: Gas(0), gas_used: Gas(0), events: [], + // codespace: Codespace("undefined") + // } + // + // This should ideally get fixed upstream, however there is + // presently no tracking issue for this bug. + // + // If we see it pop up locally or in CI, perhaps we could find + // some way of polling readiness via RPC, or retrying when this + // specific error is encountered. + // TODO(tarcieri): open upstream issue and remove this hack when fixed + tokio::time::sleep(Duration::from_secs(2)).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() +} + +/// 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() +} From a07a212463afad8a1c696471398a0893610354f6 Mon Sep 17 00:00:00 2001 From: Tony Arcieri Date: Fri, 9 Apr 2021 11:57:58 -0700 Subject: [PATCH 2/2] CI: have integration test wait for first block to be produced --- cosmos-tx/tests/integration.rs | 50 +++++++++++++++++----------------- 1 file changed, 25 insertions(+), 25 deletions(-) diff --git a/cosmos-tx/tests/integration.rs b/cosmos-tx/tests/integration.rs index 249579bd..d44e40b8 100644 --- a/cosmos-tx/tests/integration.rs +++ b/cosmos-tx/tests/integration.rs @@ -12,6 +12,7 @@ use cosmos_tx::{ 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"; @@ -86,31 +87,7 @@ fn msg_send() { let rpc_address = format!("http://localhost:{}", RPC_PORT); let rpc_client = rpc::HttpClient::new(rpc_address.as_str()).unwrap(); - rpc_client - .wait_until_healthy(Duration::from_secs(5)) - .await - .unwrap(); - - // Workaround for what appears to be a Tendermint race condition. - // There is a short period after the RPC health check succeeds - // and the `/genesis` endpoint returns valid data where - // transactions fail due to an internal SDK panic in the - // "undefined" code space: - // - // TxResult { - // code: Err(111222), data: None, log: Log("panic"), info: Info(""), - // gas_wanted: Gas(0), gas_used: Gas(0), events: [], - // codespace: Codespace("undefined") - // } - // - // This should ideally get fixed upstream, however there is - // presently no tracking issue for this bug. - // - // If we see it pop up locally or in CI, perhaps we could find - // some way of polling readiness via RPC, or retrying when this - // specific error is encountered. - // TODO(tarcieri): open upstream issue and remove this hack when fixed - tokio::time::sleep(Duration::from_secs(2)).await; + wait_for_first_block(&rpc_client).await; let tx_commit_response = tx.broadcast_commit(&rpc_client).await.unwrap(); @@ -135,6 +112,29 @@ fn init_tokio_runtime() -> tokio::runtime::Runtime { .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