diff --git a/.github/actions/local-devnet/action.yml b/.github/actions/local-devnet/action.yml index d9b8f5ef..ada2574a 100644 --- a/.github/actions/local-devnet/action.yml +++ b/.github/actions/local-devnet/action.yml @@ -30,6 +30,11 @@ runs: "cache-from": ["type=gha,scope=bridge-1"], "cache-to": ["type=gha,mode=max,scope=bridge-1"], "output": ["type=docker"] + }, + "grpcwebproxy": { + "cache-from": ["type=gha,scope=grpcwebproxy"], + "cache-to": ["type=gha,mode=max,scope=grpcwebproxy"], + "output": ["type=docker"] } } } diff --git a/Cargo.lock b/Cargo.lock index ba86599e..acea15be 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1,6 +1,6 @@ # This file is automatically @generated by Cargo. # It is not intended for manual editing. -version = 3 +version = 4 [[package]] name = "addr2line" @@ -844,21 +844,28 @@ dependencies = [ name = "celestia-grpc" version = "0.1.0" dependencies = [ - "anyhow", + "bytes", "celestia-grpc-macros", "celestia-proto", "celestia-types", - "dotenvy", + "futures", "getrandom", + "gloo-timers 0.3.0", "hex", + "http-body 1.0.1", "k256", "prost", + "rand_core", + "send_wrapper 0.6.0", "serde", "tendermint", "tendermint-proto", "thiserror 1.0.69", "tokio", "tonic", + "tonic-web-wasm-client", + "wasm-bindgen-futures", + "wasm-bindgen-test", ] [[package]] @@ -5867,6 +5874,31 @@ dependencies = [ "syn 2.0.90", ] +[[package]] +name = "tonic-web-wasm-client" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef5ca6e7bdd0042c440d36b6df97c1436f1d45871ce18298091f114004b1beb4" +dependencies = [ + "base64", + "byteorder", + "bytes", + "futures-util", + "http 1.2.0", + "http-body 1.0.1", + "http-body-util", + "httparse", + "js-sys", + "pin-project", + "thiserror 1.0.69", + "tonic", + "tower-service", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-streams", + "web-sys", +] + [[package]] name = "tower" version = "0.4.13" @@ -6380,6 +6412,19 @@ dependencies = [ "syn 2.0.90", ] +[[package]] +name = "wasm-streams" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15053d8d85c7eccdbefef60f06769760a563c7f0a9d6902a13d35c7800b0ad65" +dependencies = [ + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + [[package]] name = "web-sys" version = "0.3.76" diff --git a/ci/Dockerfile.grpcwebproxy b/ci/Dockerfile.grpcwebproxy new file mode 100644 index 00000000..c623e4d4 --- /dev/null +++ b/ci/Dockerfile.grpcwebproxy @@ -0,0 +1,10 @@ +FROM docker.io/alpine:3.19.1 + +RUN apk update && apk add --no-cache wget unzip + +RUN wget -O grpcwebproxy.zip https://github.com/improbable-eng/grpc-web/releases/download/v0.15.0/grpcwebproxy-v0.15.0-linux-x86_64.zip && \ + unzip grpcwebproxy.zip && \ + mv dist/grpcwebproxy* /usr/local/bin/grpcwebproxy && \ + rm grpcwebproxy.zip + +ENTRYPOINT ["/usr/local/bin/grpcwebproxy"] diff --git a/ci/credentials/.gitignore b/ci/credentials/.gitignore index a68d087b..539ec330 100644 --- a/ci/credentials/.gitignore +++ b/ci/credentials/.gitignore @@ -1,2 +1,4 @@ -/* +* !/.gitignore +!/bridge-0.* +/bridge-0.jwt diff --git a/ci/credentials/bridge-0.addr b/ci/credentials/bridge-0.addr new file mode 100644 index 00000000..340fdd62 --- /dev/null +++ b/ci/credentials/bridge-0.addr @@ -0,0 +1 @@ +celestia1t52q7uqgnjfzdh3wx5m5phvma3umrq8k6tq2p9 diff --git a/ci/credentials/bridge-0.key b/ci/credentials/bridge-0.key new file mode 100644 index 00000000..f2ec8840 --- /dev/null +++ b/ci/credentials/bridge-0.key @@ -0,0 +1,9 @@ +-----BEGIN TENDERMINT PRIVATE KEY----- +type: secp256k1 +kdf: bcrypt +salt: 2D070635EBDE45AD8845CE82FB6D5A89 + +PboW9MooV09RX733cy55wuciTKhveZdY2H5NhJ0DIhfHxfyR11viqxy4wJ917rkG +OfsQph8JPYp315ZRYq7vUIsbTreMgnlRSdqPmL0= +=SLpn +-----END TENDERMINT PRIVATE KEY----- diff --git a/ci/credentials/bridge-0.plaintext-key b/ci/credentials/bridge-0.plaintext-key new file mode 100644 index 00000000..81e9bfdb --- /dev/null +++ b/ci/credentials/bridge-0.plaintext-key @@ -0,0 +1 @@ +393fdb5def075819de55756b45c9e2c8531a8c78dd6eede483d3440e9457d839 diff --git a/ci/docker-compose.yml b/ci/docker-compose.yml index 19fe82a7..d8ae5ea4 100644 --- a/ci/docker-compose.yml +++ b/ci/docker-compose.yml @@ -14,6 +14,16 @@ services: - credentials:/credentials - genesis:/genesis + grpcwebproxy: + image: grpcwebproxy + platform: "linux/amd64" + build: + context: . + dockerfile: Dockerfile.grpcwebproxy + command: --backend_addr=validator:9090 --run_tls_server=false --allow_all_origins + ports: + - 18080:8080 + bridge-0: image: bridge platform: "linux/amd64" diff --git a/grpc/Cargo.toml b/grpc/Cargo.toml index 6373347d..622f802b 100644 --- a/grpc/Cargo.toml +++ b/grpc/Cargo.toml @@ -26,19 +26,34 @@ prost.workspace = true tendermint-proto.workspace = true tendermint.workspace = true +bytes = "1.8" hex = "0.4.3" +http-body = "1" k256 = "0.13.4" serde = "1.0.215" thiserror = "1.0.61" +tokio = { version = "1.38.0", features = ["sync"] } tonic = { version = "0.12.3", default-features = false, features = [ "codegen", "prost" ]} [target.'cfg(not(target_arch = "wasm32"))'.dependencies] -anyhow = "1.0.86" -dotenvy = "0.15.7" -tokio = { version = "1.38.0", features = ["rt", "macros"] } -tonic = { version = "0.12.3", optional = true, default-features = false, features = [ "transport" ] } +tokio = { version = "1.38.0", features = ["time"] } +tonic = { version = "0.12.3", default-features = false, features = [ "transport" ] } [target.'cfg(target_arch = "wasm32")'.dependencies] +futures = "0.3.30" getrandom = { version = "0.2.15", features = ["js"] } +gloo-timers = { version = "0.3.0", features = ["futures"] } +send_wrapper = { version = "0.6.0", features = ["futures"] } +tonic-web-wasm-client = "0.6" + +[dev-dependencies] +rand_core = "0.6.4" + +[target.'cfg(not(target_arch = "wasm32"))'.dev-dependencies] +tokio = { version = "1.38.0", features = ["rt", "macros"] } + +[target.'cfg(target_arch = "wasm32")'.dev-dependencies] +wasm-bindgen-futures = "0.4.43" +wasm-bindgen-test.workspace = true diff --git a/grpc/grpc-macros/src/lib.rs b/grpc/grpc-macros/src/lib.rs index 1f648dfa..5640d2af 100644 --- a/grpc/grpc-macros/src/lib.rs +++ b/grpc/grpc-macros/src/lib.rs @@ -58,13 +58,14 @@ impl GrpcMethod { let method = quote! { #doc_hash #doc_group pub #signature { - let mut client = #grpc_client_struct :: with_interceptor( - self.grpc_channel.clone(), - self.auth_interceptor.clone(), + let mut client = #grpc_client_struct :: new( + self.transport.clone(), ); - let request = ::tonic::Request::new(( #( #params ),* ).into_parameter()); + + let param = crate::grpc::IntoGrpcParam::into_parameter(( #( #params ),* )); + let request = ::tonic::Request::new(param); let response = client. #grpc_method_name (request).await; - response?.into_inner().try_from_response() + crate::grpc::FromGrpcResponse::try_from_response(response?.into_inner()) } }; diff --git a/grpc/src/client.rs b/grpc/src/client.rs deleted file mode 100644 index 80ce1a4b..00000000 --- a/grpc/src/client.rs +++ /dev/null @@ -1,109 +0,0 @@ -use prost::Message; -use tonic::service::Interceptor; -use tonic::transport::Channel; - -use celestia_grpc_macros::grpc_method; -use celestia_proto::celestia::blob::v1::query_client::QueryClient as BlobQueryClient; -use celestia_proto::cosmos::auth::v1beta1::query_client::QueryClient as AuthQueryClient; -use celestia_proto::cosmos::base::node::v1beta1::service_client::ServiceClient as ConfigServiceClient; -use celestia_proto::cosmos::base::tendermint::v1beta1::service_client::ServiceClient as TendermintServiceClient; -use celestia_proto::cosmos::tx::v1beta1::service_client::ServiceClient as TxServiceClient; -use celestia_proto::cosmos::tx::v1beta1::Tx as RawTx; -use celestia_types::blob::{Blob, BlobParams, RawBlobTx}; -use celestia_types::block::Block; -use celestia_types::state::auth::AuthParams; -use celestia_types::state::{Address, TxResponse}; - -use crate::types::auth::Account; -use crate::types::tx::GetTxResponse; -use crate::types::{FromGrpcResponse, IntoGrpcParam}; -use crate::Error; - -pub use celestia_proto::cosmos::tx::v1beta1::BroadcastMode; - -/// Struct wrapping all the tonic types and doing type conversion behind the scenes. -pub struct GrpcClient -where - I: Interceptor, -{ - grpc_channel: Channel, - auth_interceptor: I, -} - -impl GrpcClient -where - I: Interceptor + Clone, -{ - /// Create a new client out of channel and optional auth - pub fn new(grpc_channel: Channel, auth_interceptor: I) -> Self { - Self { - grpc_channel, - auth_interceptor, - } - } - - /// Get Minimum Gas price - #[grpc_method(ConfigServiceClient::config)] - async fn get_min_gas_price(&mut self) -> Result; - - /// Get latest block - #[grpc_method(TendermintServiceClient::get_latest_block)] - async fn get_latest_block(&mut self) -> Result; - - /// Get block by height - #[grpc_method(TendermintServiceClient::get_block_by_height)] - async fn get_block_by_height(&mut self, height: i64) -> Result; - - /// Get blob params - #[grpc_method(BlobQueryClient::params)] - async fn get_blob_params(&mut self) -> Result; - - /// Get auth params - #[grpc_method(AuthQueryClient::params)] - async fn get_auth_params(&mut self) -> Result; - - /// Get account - #[grpc_method(AuthQueryClient::account)] - async fn get_account(&mut self, account: &Address) -> Result; - - // TODO: pagination? - /// Get accounts - #[grpc_method(AuthQueryClient::accounts)] - async fn get_accounts(&mut self) -> Result, Error>; - - /// Broadcast prepared and serialised transaction - #[grpc_method(TxServiceClient::broadcast_tx)] - async fn broadcast_tx( - &mut self, - tx_bytes: Vec, - mode: BroadcastMode, - ) -> Result; - - /// Broadcast blob transaction - pub async fn broadcast_blob_tx( - &mut self, - tx: RawTx, - blobs: Vec, - mode: BroadcastMode, - ) -> Result { - // From https://github.com/celestiaorg/celestia-core/blob/v1.43.0-tm-v0.34.35/pkg/consts/consts.go#L19 - const BLOB_TX_TYPE_ID: &str = "BLOB"; - - if blobs.is_empty() { - return Err(Error::TxEmptyBlobList); - } - - let blobs = blobs.into_iter().map(Into::into).collect(); - let blob_tx = RawBlobTx { - tx: tx.encode_to_vec(), - blobs, - type_id: BLOB_TX_TYPE_ID.to_string(), - }; - - self.broadcast_tx(blob_tx.encode_to_vec(), mode).await - } - - /// Get Tx - #[grpc_method(TxServiceClient::get_tx)] - async fn get_tx(&mut self, hash: String) -> Result; -} diff --git a/grpc/src/error.rs b/grpc/src/error.rs index a3f35cb7..18065083 100644 --- a/grpc/src/error.rs +++ b/grpc/src/error.rs @@ -1,9 +1,10 @@ +use celestia_types::{hash::Hash, state::ErrorCode}; use tonic::Status; /// Alias for a `Result` with the error type [`celestia_tonic::Error`]. /// /// [`celestia_tonic::Error`]: crate::Error -pub type Result = std::result::Result; +pub type Result = std::result::Result; /// Representation of all the errors that can occur when interacting with [`celestia_tonic`]. /// @@ -14,6 +15,10 @@ pub enum Error { #[error(transparent)] TonicError(#[from] Status), + /// Transport error + #[error("Transport: {0}")] + TransportError(String), + /// Tendermint Error #[error(transparent)] TendermintError(#[from] tendermint::Error), @@ -37,4 +42,24 @@ pub enum Error { /// Empty blob submission list #[error("Attempted to submit blob transaction with empty blob list")] TxEmptyBlobList, + + /// Broadcasting transaction failed + #[error("Broadcasting transaction {0} failed; code: {1}, error: {2}")] + TxBroadcastFailed(Hash, ErrorCode, String), + + /// Executing transaction failed + #[error("Transaction {0} execution failed; code: {1}, error: {2}")] + TxExecutionFailed(Hash, ErrorCode, String), + + /// Transaction was evicted from the mempool + #[error("Transaction {0} was evicted from the mempool")] + TxEvicted(Hash), + + /// Transaction wasn't found, it was likely rejected + #[error("Transaction {0} wasn't found, it was likely rejected")] + TxNotFound(Hash), + + /// Provided public key differs from one associated with account + #[error("Provided public key differs from one associated with account")] + PublicKeyMismatch, } diff --git a/grpc/src/grpc.rs b/grpc/src/grpc.rs new file mode 100644 index 00000000..c774dbfc --- /dev/null +++ b/grpc/src/grpc.rs @@ -0,0 +1,192 @@ +//! Types and client for the celestia grpc + +use std::fmt; + +use bytes::Bytes; +use celestia_grpc_macros::grpc_method; +use celestia_proto::celestia::blob::v1::query_client::QueryClient as BlobQueryClient; +use celestia_proto::celestia::core::v1::tx::tx_client::TxClient as TxStatusClient; +use celestia_proto::cosmos::auth::v1beta1::query_client::QueryClient as AuthQueryClient; +use celestia_proto::cosmos::bank::v1beta1::query_client::QueryClient as BankQueryClient; +use celestia_proto::cosmos::base::abci::v1beta1::GasInfo; +use celestia_proto::cosmos::base::node::v1beta1::service_client::ServiceClient as ConfigServiceClient; +use celestia_proto::cosmos::base::tendermint::v1beta1::service_client::ServiceClient as TendermintServiceClient; +use celestia_proto::cosmos::tx::v1beta1::service_client::ServiceClient as TxServiceClient; +use celestia_types::blob::BlobParams; +use celestia_types::block::Block; +use celestia_types::hash::Hash; +use celestia_types::state::auth::AuthParams; +use celestia_types::state::{Address, Coin, TxResponse}; +use http_body::Body; +use tonic::body::BoxBody; +use tonic::client::GrpcService; + +use crate::Result; + +// cosmos.auth +mod auth; +// cosmos.bank +mod bank; +// cosmos.base.node +mod node; +// cosmos.base.tendermint +mod tendermint; +// celestia.core.tx +mod celestia_tx; +// celestia.blob +mod blob; +// cosmos.tx +mod cosmos_tx; + +pub use crate::grpc::auth::Account; +pub use crate::grpc::celestia_tx::{TxStatus, TxStatusResponse}; +pub use crate::grpc::cosmos_tx::{BroadcastMode, GetTxResponse}; + +/// Error convertible to std, used by grpc transports +pub type StdError = Box; + +/// Struct wrapping all the tonic types and doing type conversion behind the scenes. +pub struct GrpcClient { + transport: T, +} + +impl GrpcClient { + /// Get the underlying transport. + pub fn into_inner(self) -> T { + self.transport + } +} + +impl GrpcClient +where + T: GrpcService + Clone, + T::Error: Into, + T::ResponseBody: Body + Send + 'static, + ::Error: Into + Send, +{ + /// Create a new client wrapping given transport + pub fn new(transport: T) -> Self { + Self { transport } + } + + // cosmos.auth + + /// Get auth params + #[grpc_method(AuthQueryClient::params)] + async fn get_auth_params(&self) -> Result; + + /// Get account + #[grpc_method(AuthQueryClient::account)] + async fn get_account(&self, account: &Address) -> Result; + + /// Get accounts + #[grpc_method(AuthQueryClient::accounts)] + async fn get_accounts(&self) -> Result>; + + // cosmos.bank + + /// Get balance of coins with given denom + #[grpc_method(BankQueryClient::balance)] + async fn get_balance(&self, address: &Address, denom: impl Into) -> Result; + + /// Get balance of all coins + #[grpc_method(BankQueryClient::all_balances)] + async fn get_all_balances(&self, address: &Address) -> Result>; + + /// Get balance of all spendable coins + #[grpc_method(BankQueryClient::spendable_balances)] + async fn get_spendable_balances(&self, address: &Address) -> Result>; + + /// Get total supply + #[grpc_method(BankQueryClient::total_supply)] + async fn get_total_supply(&self) -> Result>; + + // cosmos.base.node + + /// Get Minimum Gas price + #[grpc_method(ConfigServiceClient::config)] + async fn get_min_gas_price(&self) -> Result; + + // cosmos.base.tendermint + + /// Get latest block + #[grpc_method(TendermintServiceClient::get_latest_block)] + async fn get_latest_block(&self) -> Result; + + /// Get block by height + #[grpc_method(TendermintServiceClient::get_block_by_height)] + async fn get_block_by_height(&self, height: i64) -> Result; + + // cosmos.tx + + /// Broadcast prepared and serialised transaction + #[grpc_method(TxServiceClient::broadcast_tx)] + async fn broadcast_tx(&self, tx_bytes: Vec, mode: BroadcastMode) -> Result; + + /// Get Tx + #[grpc_method(TxServiceClient::get_tx)] + async fn get_tx(&self, hash: Hash) -> Result; + + /// Broadcast prepared and serialised transaction + #[grpc_method(TxServiceClient::simulate)] + async fn simulate(&self, tx_bytes: Vec) -> Result; + + // celestia.blob + + /// Get blob params + #[grpc_method(BlobQueryClient::params)] + async fn get_blob_params(&self) -> Result; + + // celestia.core.tx + + /// Get status of the transaction + #[grpc_method(TxStatusClient::tx_status)] + async fn tx_status(&self, hash: Hash) -> Result; +} + +#[cfg(not(target_arch = "wasm32"))] +impl GrpcClient { + /// Create a new client connected to the given `url` with default + /// settings of [`tonic::transport::Channel`]. + pub fn with_url(url: impl Into) -> Result { + let channel = tonic::transport::Endpoint::from_shared(url.into())?.connect_lazy(); + Ok(Self { transport: channel }) + } +} + +#[cfg(target_arch = "wasm32")] +impl GrpcClient { + /// Create a new client connected to the given `url` with default + /// settings of [`tonic_web_wasm_client::Client`]. + pub fn with_grpcweb_url(url: impl Into) -> Self { + Self { + transport: tonic_web_wasm_client::Client::new(url.into()), + } + } +} + +impl fmt::Debug for GrpcClient { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str("GrpcClient { .. }") + } +} + +pub(crate) trait FromGrpcResponse { + fn try_from_response(self) -> Result; +} + +pub(crate) trait IntoGrpcParam { + fn into_parameter(self) -> T; +} + +macro_rules! make_empty_params { + ($request_type:ident) => { + impl crate::grpc::IntoGrpcParam<$request_type> for () { + fn into_parameter(self) -> $request_type { + $request_type {} + } + } + }; +} + +pub(crate) use make_empty_params; diff --git a/grpc/src/types/auth.rs b/grpc/src/grpc/auth.rs similarity index 76% rename from grpc/src/types/auth.rs rename to grpc/src/grpc/auth.rs index da7aecb8..b8afcf36 100644 --- a/grpc/src/types/auth.rs +++ b/grpc/src/grpc/auth.rs @@ -10,9 +10,8 @@ use celestia_types::state::auth::{ use celestia_types::state::Address; use tendermint_proto::google::protobuf::Any; -use crate::types::make_empty_params; -use crate::types::{FromGrpcResponse, IntoGrpcParam}; -use crate::Error; +use crate::grpc::{make_empty_params, FromGrpcResponse, IntoGrpcParam}; +use crate::{Error, Result}; /// Enum representing different types of account #[derive(Debug, PartialEq)] @@ -23,18 +22,28 @@ pub enum Account { Module(ModuleAccount), } -impl Account { - /// Return [`BaseAccount`] reference, if it exists, from either Base or Module account - pub fn base_account_ref(&self) -> Option<&BaseAccount> { +impl std::ops::Deref for Account { + type Target = BaseAccount; + + fn deref(&self) -> &Self::Target { + match self { + Account::Base(base) => base, + Account::Module(module) => &module.base_account, + } + } +} + +impl std::ops::DerefMut for Account { + fn deref_mut(&mut self) -> &mut Self::Target { match self { - Account::Base(acct) => Some(acct), - Account::Module(acct) => acct.base_account.as_ref(), + Account::Base(base) => base, + Account::Module(module) => &mut module.base_account, } } } impl FromGrpcResponse for QueryAuthParamsResponse { - fn try_from_response(self) -> Result { + fn try_from_response(self) -> Result { let params = self.params.ok_or(Error::FailedToParseResponse)?; Ok(AuthParams { max_memo_characters: params.max_memo_characters, @@ -47,13 +56,13 @@ impl FromGrpcResponse for QueryAuthParamsResponse { } impl FromGrpcResponse for QueryAccountResponse { - fn try_from_response(self) -> Result { + fn try_from_response(self) -> Result { account_from_any(self.account.ok_or(Error::FailedToParseResponse)?) } } impl FromGrpcResponse> for QueryAccountsResponse { - fn try_from_response(self) -> Result, Error> { + fn try_from_response(self) -> Result> { self.accounts.into_iter().map(account_from_any).collect() } } @@ -74,7 +83,7 @@ impl IntoGrpcParam for () { } } -fn account_from_any(any: Any) -> Result { +fn account_from_any(any: Any) -> Result { let account = if any.type_url == RawBaseAccount::type_url() { let base_account = RawBaseAccount::decode(&*any.value).map_err(|_| Error::FailedToParseResponse)?; diff --git a/grpc/src/grpc/bank.rs b/grpc/src/grpc/bank.rs new file mode 100644 index 00000000..7a198943 --- /dev/null +++ b/grpc/src/grpc/bank.rs @@ -0,0 +1,84 @@ +use celestia_proto::cosmos::bank::v1beta1::{ + QueryAllBalancesRequest, QueryAllBalancesResponse, QueryBalanceRequest, QueryBalanceResponse, + QuerySpendableBalancesRequest, QuerySpendableBalancesResponse, QueryTotalSupplyRequest, + QueryTotalSupplyResponse, +}; +use celestia_types::state::{Address, Coin}; + +use crate::grpc::{FromGrpcResponse, IntoGrpcParam}; +use crate::{Error, Result}; + +impl IntoGrpcParam for (&Address, I) +where + I: Into, +{ + fn into_parameter(self) -> QueryBalanceRequest { + QueryBalanceRequest { + address: self.0.to_string(), + denom: self.1.into(), + } + } +} + +impl FromGrpcResponse for QueryBalanceResponse { + fn try_from_response(self) -> Result { + Ok(self + .balance + .ok_or(Error::FailedToParseResponse)? + .try_into()?) + } +} + +impl IntoGrpcParam for &Address { + fn into_parameter(self) -> QueryAllBalancesRequest { + QueryAllBalancesRequest { + address: self.to_string(), + pagination: None, + } + } +} + +impl FromGrpcResponse> for QueryAllBalancesResponse { + fn try_from_response(self) -> Result> { + Ok(self + .balances + .into_iter() + .map(|coin| coin.try_into()) + .collect::>()?) + } +} + +impl IntoGrpcParam for &Address { + fn into_parameter(self) -> QuerySpendableBalancesRequest { + QuerySpendableBalancesRequest { + address: self.to_string(), + pagination: None, + } + } +} + +impl FromGrpcResponse> for QuerySpendableBalancesResponse { + fn try_from_response(self) -> Result> { + Ok(self + .balances + .into_iter() + .map(|coin| coin.try_into()) + .collect::>()?) + } +} + +impl IntoGrpcParam for () { + fn into_parameter(self) -> QueryTotalSupplyRequest { + QueryTotalSupplyRequest { pagination: None } + } +} + +impl FromGrpcResponse> for QueryTotalSupplyResponse { + fn try_from_response(self) -> Result> { + Ok(self + .supply + .into_iter() + .map(|coin| coin.try_into()) + .collect::>()?) + } +} diff --git a/grpc/src/grpc/blob.rs b/grpc/src/grpc/blob.rs new file mode 100644 index 00000000..e708ca7e --- /dev/null +++ b/grpc/src/grpc/blob.rs @@ -0,0 +1,19 @@ +use celestia_proto::celestia::blob::v1::{ + QueryParamsRequest as QueryBlobParamsRequest, QueryParamsResponse as QueryBlobParamsResponse, +}; +use celestia_types::blob::BlobParams; + +use crate::grpc::{make_empty_params, FromGrpcResponse}; +use crate::{Error, Result}; + +impl FromGrpcResponse for QueryBlobParamsResponse { + fn try_from_response(self) -> Result { + let params = self.params.ok_or(Error::FailedToParseResponse)?; + Ok(BlobParams { + gas_per_blob_byte: params.gas_per_blob_byte, + gov_max_square_size: params.gov_max_square_size, + }) + } +} + +make_empty_params!(QueryBlobParamsRequest); diff --git a/grpc/src/grpc/celestia_tx.rs b/grpc/src/grpc/celestia_tx.rs new file mode 100644 index 00000000..b5f918c4 --- /dev/null +++ b/grpc/src/grpc/celestia_tx.rs @@ -0,0 +1,96 @@ +use std::fmt; +use std::str::FromStr; + +use celestia_proto::celestia::core::v1::tx::{ + TxStatusRequest as RawTxStatusRequest, TxStatusResponse as RawTxStatusResponse, +}; +use celestia_types::hash::Hash; +use celestia_types::state::ErrorCode; +use celestia_types::Height; + +use crate::grpc::{FromGrpcResponse, IntoGrpcParam}; +use crate::{Error, Result}; + +/// Response to a tx status query +#[derive(Debug, Clone)] +pub struct TxStatusResponse { + /// Height of the block in which the transaction was committed. + pub height: Height, + /// Index of the transaction in block. + pub index: u32, + /// Execution_code is returned when the transaction has been committed + /// and returns whether it was successful or errored. A non zero + /// execution code indicates an error. + pub execution_code: ErrorCode, + /// Error log, if transaction failed. + pub error: String, + /// Status of the transaction. + pub status: TxStatus, +} + +/// Represents state of the transaction in the mempool +#[derive(Debug, Copy, Clone)] +pub enum TxStatus { + /// The transaction is not known to the node, it could be never sent. + Unknown, + /// The transaction is still pending. + Pending, + /// The transaction was evicted from the mempool. + Evicted, + /// The transaction was committed into the block. + Committed, +} + +impl fmt::Display for TxStatus { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let s = match self { + TxStatus::Unknown => "UNKNOWN", + TxStatus::Pending => "PENDING", + TxStatus::Evicted => "EVICTED", + TxStatus::Committed => "COMMITTED", + }; + write!(f, "{s}") + } +} + +impl FromStr for TxStatus { + type Err = Error; + + fn from_str(s: &str) -> Result { + match s { + "UNKNOWN" => Ok(TxStatus::Unknown), + "PENDING" => Ok(TxStatus::Pending), + "EVICTED" => Ok(TxStatus::Evicted), + "COMMITTED" => Ok(TxStatus::Committed), + _ => Err(Error::FailedToParseResponse), + } + } +} + +impl TryFrom for TxStatusResponse { + type Error = Error; + + fn try_from(value: RawTxStatusResponse) -> Result { + Ok(TxStatusResponse { + height: value.height.try_into()?, + index: value.index, + execution_code: value.execution_code.try_into()?, + error: value.error, + status: value.status.parse()?, + }) + } +} + +impl IntoGrpcParam for Hash { + fn into_parameter(self) -> RawTxStatusRequest { + RawTxStatusRequest { + tx_id: self.to_string(), + } + } +} + +impl FromGrpcResponse for RawTxStatusResponse { + fn try_from_response(self) -> Result { + self.try_into() + } +} diff --git a/grpc/src/grpc/cosmos_tx.rs b/grpc/src/grpc/cosmos_tx.rs new file mode 100644 index 00000000..7d977072 --- /dev/null +++ b/grpc/src/grpc/cosmos_tx.rs @@ -0,0 +1,91 @@ +use celestia_proto::cosmos::base::abci::v1beta1::GasInfo; +use celestia_types::hash::Hash; + +use celestia_proto::cosmos::tx::v1beta1::{ + BroadcastTxRequest, BroadcastTxResponse, GetTxRequest as RawGetTxRequest, + GetTxResponse as RawGetTxResponse, SimulateRequest, SimulateResponse, +}; +use celestia_types::state::{Tx, TxResponse}; + +use crate::grpc::{FromGrpcResponse, IntoGrpcParam}; +use crate::{Error, Result}; + +pub use celestia_proto::cosmos::tx::v1beta1::BroadcastMode; + +/// Response to GetTx +#[derive(Debug)] +pub struct GetTxResponse { + /// Response Transaction + pub tx: Tx, + + /// TxResponse to a Query + pub tx_response: TxResponse, +} + +impl IntoGrpcParam for (Vec, BroadcastMode) { + fn into_parameter(self) -> BroadcastTxRequest { + let (tx_bytes, mode) = self; + + BroadcastTxRequest { + tx_bytes, + mode: mode.into(), + } + } +} + +impl FromGrpcResponse for BroadcastTxResponse { + fn try_from_response(self) -> Result { + Ok(self + .tx_response + .ok_or(Error::FailedToParseResponse)? + .try_into()?) + } +} + +impl IntoGrpcParam for Hash { + fn into_parameter(self) -> RawGetTxRequest { + RawGetTxRequest { + hash: self.to_string(), + } + } +} + +impl FromGrpcResponse for RawGetTxResponse { + fn try_from_response(self) -> Result { + let tx_response = self + .tx_response + .ok_or(Error::FailedToParseResponse)? + .try_into()?; + + let tx = self.tx.ok_or(Error::FailedToParseResponse)?; + + let cosmos_tx = Tx { + body: tx.body.ok_or(Error::FailedToParseResponse)?.try_into()?, + auth_info: tx + .auth_info + .ok_or(Error::FailedToParseResponse)? + .try_into()?, + signatures: tx.signatures, + }; + + Ok(GetTxResponse { + tx: cosmos_tx, + tx_response, + }) + } +} + +impl IntoGrpcParam for Vec { + fn into_parameter(self) -> SimulateRequest { + SimulateRequest { + tx_bytes: self, + ..SimulateRequest::default() + } + } +} + +impl FromGrpcResponse for SimulateResponse { + fn try_from_response(self) -> Result { + self.gas_info.ok_or(Error::FailedToParseResponse) + } +} diff --git a/grpc/src/grpc/node.rs b/grpc/src/grpc/node.rs new file mode 100644 index 00000000..7fd0451e --- /dev/null +++ b/grpc/src/grpc/node.rs @@ -0,0 +1,22 @@ +use celestia_proto::cosmos::base::node::v1beta1::{ConfigRequest, ConfigResponse}; + +use crate::grpc::{make_empty_params, FromGrpcResponse}; +use crate::{Error, Result}; + +impl FromGrpcResponse for ConfigResponse { + fn try_from_response(self) -> Result { + const UNITS_SUFFIX: &str = "utia"; + + let min_gas_price_with_suffix = self.minimum_gas_price; + let min_gas_price_str = min_gas_price_with_suffix + .strip_suffix(UNITS_SUFFIX) + .ok_or(Error::FailedToParseResponse)?; + let min_gas_price = min_gas_price_str + .parse::() + .map_err(|_| Error::FailedToParseResponse)?; + + Ok(min_gas_price) + } +} + +make_empty_params!(ConfigRequest); diff --git a/grpc/src/grpc/tendermint.rs b/grpc/src/grpc/tendermint.rs new file mode 100644 index 00000000..48191f27 --- /dev/null +++ b/grpc/src/grpc/tendermint.rs @@ -0,0 +1,28 @@ +use celestia_proto::cosmos::base::tendermint::v1beta1::{ + GetBlockByHeightRequest, GetBlockByHeightResponse, GetLatestBlockRequest, + GetLatestBlockResponse, +}; +use celestia_types::block::Block; + +use crate::grpc::{make_empty_params, FromGrpcResponse, IntoGrpcParam}; +use crate::{Error, Result}; + +impl FromGrpcResponse for GetBlockByHeightResponse { + fn try_from_response(self) -> Result { + Ok(self.block.ok_or(Error::FailedToParseResponse)?.try_into()?) + } +} + +impl FromGrpcResponse for GetLatestBlockResponse { + fn try_from_response(self) -> Result { + Ok(self.block.ok_or(Error::FailedToParseResponse)?.try_into()?) + } +} + +impl IntoGrpcParam for i64 { + fn into_parameter(self) -> GetBlockByHeightRequest { + GetBlockByHeightRequest { height: self } + } +} + +make_empty_params!(GetLatestBlockRequest); diff --git a/grpc/src/lib.rs b/grpc/src/lib.rs index 9e68cbfa..a58084a6 100644 --- a/grpc/src/lib.rs +++ b/grpc/src/lib.rs @@ -1,9 +1,10 @@ #![doc = include_str!("../README.md")] -#![cfg(not(target_arch = "wasm32"))] -mod client; mod error; -pub mod types; +pub mod grpc; +mod tx; +mod utils; -pub use crate::client::GrpcClient; pub use crate::error::{Error, Result}; +pub use crate::grpc::GrpcClient; +pub use crate::tx::{TxClient, TxConfig}; diff --git a/grpc/src/tx.rs b/grpc/src/tx.rs new file mode 100644 index 00000000..3a0617d2 --- /dev/null +++ b/grpc/src/tx.rs @@ -0,0 +1,559 @@ +use std::fmt; +use std::ops::Deref; +use std::sync::RwLock; +use std::time::Duration; + +use bytes::Bytes; +use celestia_proto::cosmos::crypto::secp256k1; +use celestia_proto::cosmos::tx::v1beta1::SignDoc; +use celestia_types::blob::{Blob, MsgPayForBlobs, RawBlobTx, RawMsgPayForBlobs}; +use celestia_types::consts::appconsts; +use celestia_types::hash::Hash; +use celestia_types::state::auth::BaseAccount; +use celestia_types::state::{ + Address, AuthInfo, ErrorCode, Fee, ModeInfo, RawTx, RawTxBody, SignerInfo, Sum, +}; +use celestia_types::{AppVersion, Height}; +use http_body::Body; +use k256::ecdsa::signature::Signer; +use k256::ecdsa::{Signature, VerifyingKey}; +use prost::{Message, Name}; +use tendermint::chain::Id; +use tendermint::PublicKey; +use tendermint_proto::google::protobuf::Any; +use tendermint_proto::Protobuf; +use tokio::sync::{Mutex, MutexGuard}; +use tonic::body::BoxBody; +use tonic::client::GrpcService; + +use crate::grpc::{Account, BroadcastMode, GrpcClient, StdError, TxStatus}; +use crate::utils::Interval; +use crate::{Error, Result}; + +// source https://github.com/celestiaorg/celestia-app/blob/v3.0.2/x/blob/types/payforblob.go#L21 +// PFBGasFixedCost is a rough estimate for the "fixed cost" in the gas cost +// formula: gas cost = gas per byte * bytes per share * shares occupied by +// blob + "fixed cost". In this context, "fixed cost" accounts for the gas +// consumed by operations outside the blob's GasToConsume function (i.e. +// signature verification, tx size, read access to accounts). +// +// Since the gas cost of these operations is not easy to calculate, linear +// regression was performed on a set of observed data points to derive an +// approximate formula for gas cost. Assuming gas per byte = 8 and bytes per +// share = 512, we can solve for "fixed cost" and arrive at 65,000. gas cost +// = 8 * 512 * number of shares occupied by the blob + 65,000 has a +// correlation coefficient of 0.996. To be conservative, we round up "fixed +// cost" to 75,000 because the first tx always takes up 10,000 more gas than +// subsequent txs. +const PFB_GAS_FIXED_COST: u64 = 75000; +// BytesPerBlobInfo is a rough estimation for the amount of extra bytes in +// information a blob adds to the size of the underlying transaction. +const BYTES_PER_BLOB_INFO: u64 = 70; +const DEFAULT_GAS_MULTIPLIER: f64 = 1.1; +// source https://github.com/celestiaorg/celestia-core/blob/v1.43.0-tm-v0.34.35/pkg/consts/consts.go#L19 +const BLOB_TX_TYPE_ID: &str = "BLOB"; + +/// A result of correctly submitted transaction. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct TxInfo { + /// Hash of the transaction. + pub hash: Hash, + /// Height at which transaction was submitted. + pub height: Height, +} + +/// Configuration for the transaction. +#[derive(Debug, Default, Copy, Clone, PartialEq)] +pub struct TxConfig { + /// Custom gas limit for the transaction. + pub gas_limit: Option, + /// Custom gas price for fee calculation. + pub gas_price: Option, +} + +impl TxConfig { + /// Attach gas limit to this config. + pub fn with_gas_limit(mut self, gas_limit: u64) -> Self { + self.gas_limit = Some(gas_limit); + self + } + + /// Attach gas price to this config. + pub fn with_gas_price(mut self, gas_price: f64) -> Self { + self.gas_price = Some(gas_price); + self + } +} + +/// A client for submitting messages and transactions to celestia. +/// +/// Client handles management of the accounts sequence (nonce), thus +/// it should be the only party submitting transactions signed with +/// given account. Using e.g. two distinct clients with the same account +/// will make them invalidate each others nonces. +pub struct TxClient { + client: GrpcClient, + + // NOTE: in future we might want a map of accounts + // and something like .add_account() + account: Mutex, + pubkey: VerifyingKey, + signer: S, + + app_version: AppVersion, + chain_id: Id, + gas_price: RwLock, +} + +impl TxClient +where + T: GrpcService + Clone, + T::Error: Into, + T::ResponseBody: Body + Send + 'static, + ::Error: Into + Send, + S: Signer, +{ + /// Create a new transaction client. + pub async fn new( + transport: T, + account_address: &Address, + account_pubkey: VerifyingKey, + signer: S, + ) -> Result { + let client = GrpcClient::new(transport); + let account = client.get_account(account_address).await?; + if let Some(pubkey) = account.pub_key { + if pubkey != PublicKey::Secp256k1(account_pubkey) { + return Err(Error::PublicKeyMismatch); + } + }; + let account = Mutex::new(account); + let gas_price = client.get_min_gas_price().await?; + + let block = client.get_latest_block().await?; + let app_version = block.header.version.app; + let app_version = AppVersion::from_u64(app_version) + .ok_or(celestia_types::Error::UnsupportedAppVersion(app_version))?; + let chain_id = block.header.chain_id; + + Ok(Self { + client, + signer, + account, + pubkey: account_pubkey, + app_version, + chain_id, + gas_price: RwLock::new(gas_price), + }) + } + + /// Submit given message to celestia network. + /// + /// When no gas price is specified through config, it will automatically + /// handle updating client's gas price when consensus updates minimal + /// gas price. + /// + /// # Example + /// ```no_run + /// # async fn docs() { + /// use celestia_grpc::{TxClient, TxConfig}; + /// use celestia_proto::cosmos::bank::v1beta1::MsgSend; + /// use celestia_types::state::{AccAddress, Coin}; + /// use tendermint::crypto::default::ecdsa_secp256k1::SigningKey; + /// + /// let signing_key = SigningKey::random(&mut rand_core::OsRng); + /// let public_key = *signing_key.verifying_key(); + /// let address = AccAddress::new(public_key.into()).into(); + /// let grpc_url = "public-celestia-mocha4-consensus.numia.xyz:9090"; + /// + /// let tx_client = TxClient::with_url(grpc_url, &address, public_key, signing_key) + /// .await + /// .unwrap(); + /// + /// let msg = MsgSend { + /// from_address: address.to_string(), + /// to_address: "celestia169s50psyj2f4la9a2235329xz7rk6c53zhw9mm".to_string(), + /// amount: vec![Coin::utia(12345).into()], + /// }; + /// + /// tx_client + /// .submit_message(msg.clone(), TxConfig::default()) + /// .await + /// .unwrap(); + /// # } + /// ``` + pub async fn submit_message(&self, message: M, cfg: TxConfig) -> Result + where + M: Name, + { + let tx_body = RawTxBody { + messages: vec![into_any(message)], + ..RawTxBody::default() + }; + + let mut retries = 0; + let (tx_hash, sequence) = loop { + match self.sign_and_broadcast_tx(tx_body.clone(), cfg).await { + Ok(resp) => break resp, + Err(Error::TxBroadcastFailed(_, ErrorCode::InsufficientFee, _)) + if retries < 3 && cfg.gas_price.is_none() => + { + retries += 1; + continue; + } + Err(e) => return Err(e), + } + }; + self.confirm_tx(tx_hash, sequence).await + } + + /// Submit given blobs to celestia network. + /// + /// When no gas price is specified through config, it will automatically + /// handle updating client's gas price when consensus updates minimal + /// gas price. + /// + /// # Example + /// ```no_run + /// # async fn docs() { + /// use celestia_grpc::{TxClient, TxConfig}; + /// use celestia_types::state::{AccAddress, Coin}; + /// use celestia_types::{AppVersion, Blob}; + /// use celestia_types::nmt::Namespace; + /// use tendermint::crypto::default::ecdsa_secp256k1::SigningKey; + /// + /// let signing_key = SigningKey::random(&mut rand_core::OsRng); + /// let public_key = *signing_key.verifying_key(); + /// let address = AccAddress::new(public_key.into()).into(); + /// let grpc_url = "public-celestia-mocha4-consensus.numia.xyz:9090"; + /// + /// let tx_client = TxClient::with_url(grpc_url, &address, public_key, signing_key) + /// .await + /// .unwrap(); + /// + /// let ns = Namespace::new_v0(b"abcd").unwrap(); + /// let blob = Blob::new(ns, "some data".into(), AppVersion::V3).unwrap(); + /// + /// tx_client + /// .submit_blobs(&[blob], TxConfig::default()) + /// .await + /// .unwrap(); + /// # } + /// ``` + pub async fn submit_blobs(&self, blobs: &[Blob], cfg: TxConfig) -> Result { + if blobs.is_empty() { + return Err(Error::TxEmptyBlobList); + } + for blob in blobs { + blob.validate(self.app_version)?; + } + + let mut retries = 0; + let (tx_hash, sequence) = loop { + match self.sign_and_broadcast_blobs(blobs.to_vec(), cfg).await { + Ok(resp) => break resp, + Err(Error::TxBroadcastFailed(_, ErrorCode::InsufficientFee, _)) + if retries < 3 && cfg.gas_price.is_none() => + { + retries += 1; + continue; + } + Err(e) => return Err(e), + } + }; + self.confirm_tx(tx_hash, sequence).await + } + + /// Get most recent minimal gas price seen by the client + pub fn last_gas_price(&self) -> f64 { + *self.gas_price.read().expect("lock poisoned") + } + + /// Set current gas price used by the client + async fn update_gas_price(&self) -> Result { + let gas_price = self.client.get_min_gas_price().await?; + *self.gas_price.write().expect("lock poisoned") = gas_price; + Ok(gas_price) + } + + /// Get client's chain id + pub fn chain_id(&self) -> &Id { + &self.chain_id + } + + /// Get client's app version + pub fn app_version(&self) -> AppVersion { + self.app_version + } + + async fn sign_and_broadcast_tx(&self, tx: RawTxBody, cfg: TxConfig) -> Result<(Hash, u64)> { + let account = self.account.lock().await; + let sign_tx = |tx, gas, fee| { + sign_tx( + tx, + self.chain_id.clone(), + &account, + &self.pubkey, + &self.signer, + gas, + fee, + ) + }; + + let gas_limit = if let Some(gas_limit) = cfg.gas_limit { + gas_limit + } else { + // simulate the gas that would be used by transaction + // fee should be at least 1 as it affects calculation + let tx = sign_tx(tx.clone(), 0, 1); + let gas_info = self.client.simulate(tx.encode_to_vec()).await?; + (gas_info.gas_used as f64 * DEFAULT_GAS_MULTIPLIER) as u64 + }; + + let gas_price = if let Some(gas_price) = cfg.gas_price { + gas_price + } else { + self.update_gas_price().await? + }; + let fee = (gas_limit as f64 * gas_price).ceil(); + let tx = sign_tx(tx, gas_limit, fee as u64); + + self.broadcast_tx_with_account(tx.encode_to_vec(), account) + .await + } + + async fn sign_and_broadcast_blobs( + &self, + blobs: Vec, + cfg: TxConfig, + ) -> Result<(Hash, u64)> { + // lock the account; tx signing and broadcast must be atomic + // because node requires all transactions to be sequenced by account.sequence + let account = self.account.lock().await; + + let pfb = MsgPayForBlobs::new(&blobs, account.address.clone())?; + let pfb = RawTxBody { + messages: vec![into_any(RawMsgPayForBlobs::from(pfb))], + ..RawTxBody::default() + }; + + let gas_limit = cfg + .gas_limit + .unwrap_or_else(|| estimate_gas(&blobs, self.app_version, DEFAULT_GAS_MULTIPLIER)); + let gas_price = if let Some(gas_price) = cfg.gas_price { + gas_price + } else { + self.update_gas_price().await? + }; + let fee = (gas_limit as f64 * gas_price).ceil() as u64; + let tx = sign_tx( + pfb, + self.chain_id.clone(), + &account, + &self.pubkey, + &self.signer, + gas_limit, + fee, + ); + + let blobs = blobs.into_iter().map(Into::into).collect(); + let blob_tx = RawBlobTx { + tx: tx.encode_to_vec(), + blobs, + type_id: BLOB_TX_TYPE_ID.to_string(), + }; + + self.broadcast_tx_with_account(blob_tx.encode_to_vec(), account) + .await + } + + async fn broadcast_tx_with_account( + &self, + tx: Vec, + mut account: MutexGuard<'_, Account>, + ) -> Result<(Hash, u64)> { + let resp = self.client.broadcast_tx(tx, BroadcastMode::Sync).await?; + + if resp.code != ErrorCode::Success { + return Err(Error::TxBroadcastFailed( + resp.txhash, + resp.code, + resp.raw_log, + )); + } + + let tx_sequence = account.sequence; + account.sequence += 1; + + Ok((resp.txhash, tx_sequence)) + } + + async fn confirm_tx(&self, hash: Hash, sequence: u64) -> Result { + let mut interval = Interval::new(Duration::from_millis(500)).await; + + loop { + let tx_status = self.client.tx_status(hash).await?; + match tx_status.status { + TxStatus::Pending => interval.tick().await, + TxStatus::Committed => { + if tx_status.execution_code == ErrorCode::Success { + return Ok(TxInfo { + hash, + height: tx_status.height, + }); + } else { + return Err(Error::TxExecutionFailed( + hash, + tx_status.execution_code, + tx_status.error, + )); + } + } + // node will treat this transaction like if it never happened, so + // we need to revert the account's sequence to the one of evicted tx. + // all transactions that were already submitted after this one will fail + // due to incorrect sequence number. + TxStatus::Evicted => { + let mut acc = self.account.lock().await; + acc.sequence = sequence; + return Err(Error::TxEvicted(hash)); + } + // this case should never happen for node that accepted a broadcast + // however we handle it the same as evicted for extra safety + TxStatus::Unknown => { + let mut acc = self.account.lock().await; + acc.sequence = sequence; + return Err(Error::TxNotFound(hash)); + } + } + } + } +} + +#[cfg(not(target_arch = "wasm32"))] +impl TxClient +where + S: Signer, +{ + /// Create a new client connected to the given `url` with default + /// settings of [`tonic::transport::Channel`]. + pub async fn with_url( + url: impl Into, + account_address: &Address, + account_pubkey: VerifyingKey, + signer: S, + ) -> Result { + let transport = tonic::transport::Endpoint::from_shared(url.into()) + .map_err(|e| Error::TransportError(e.to_string()))? + .connect_lazy(); + Self::new(transport, account_address, account_pubkey, signer).await + } +} + +#[cfg(target_arch = "wasm32")] +impl TxClient +where + S: Signer, +{ + /// Create a new client connected to the given `url` with default + /// settings of [`tonic_web_wasm_client::Client`]. + pub async fn with_grpcweb_url( + url: impl Into, + account_address: &Address, + account_pubkey: VerifyingKey, + signer: S, + ) -> Result { + let transport = tonic_web_wasm_client::Client::new(url.into()); + Self::new(transport, account_address, account_pubkey, signer).await + } +} + +impl Deref for TxClient { + type Target = GrpcClient; + + fn deref(&self) -> &Self::Target { + &self.client + } +} + +impl fmt::Debug for TxClient { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str("TxClient { .. }") + } +} + +/// Sign `tx_body` and the transaction metadata as the `base_account` using `signer` +pub fn sign_tx( + tx_body: RawTxBody, + chain_id: Id, + base_account: &BaseAccount, + verifying_key: &VerifyingKey, + signer: &impl Signer, + gas_limit: u64, + fee: u64, +) -> RawTx { + // From https://github.com/celestiaorg/cosmos-sdk/blob/v1.25.0-sdk-v0.46.16/proto/cosmos/tx/signing/v1beta1/signing.proto#L24 + const SIGNING_MODE_INFO: ModeInfo = ModeInfo { + sum: Sum::Single { mode: 1 }, + }; + + let public_key = secp256k1::PubKey { + key: verifying_key.to_encoded_point(true).as_bytes().to_vec(), + }; + let public_key_as_any = Any { + type_url: secp256k1::PubKey::type_url(), + value: public_key.encode_to_vec(), + }; + + let mut fee = Fee::new(fee, gas_limit); + fee.payer = Some(base_account.address.clone()); + + let auth_info = AuthInfo { + signer_infos: vec![SignerInfo { + public_key: Some(public_key_as_any), + mode_info: SIGNING_MODE_INFO, + sequence: base_account.sequence, + }], + fee, + }; + + let bytes_to_sign = SignDoc { + body_bytes: tx_body.encode_to_vec(), + auth_info_bytes: auth_info.clone().encode_vec(), + chain_id: chain_id.into(), + account_number: base_account.account_number, + } + .encode_to_vec(); + + let signature = signer.sign(&bytes_to_sign); + + RawTx { + auth_info: Some(auth_info.into()), + body: Some(tx_body), + signatures: vec![signature.to_bytes().to_vec()], + } +} + +fn estimate_gas(blobs: &[Blob], app_version: AppVersion, gas_multiplier: f64) -> u64 { + let gas_per_blob_byte = appconsts::gas_per_blob_byte(app_version); + let tx_size_cost_per_byte = appconsts::tx_size_cost_per_byte(app_version); + + let blobs_bytes = + blobs.iter().map(Blob::shares_len).sum::() as u64 * appconsts::SHARE_SIZE as u64; + + let gas = blobs_bytes * gas_per_blob_byte + + (tx_size_cost_per_byte * BYTES_PER_BLOB_INFO * blobs.len() as u64) + + PFB_GAS_FIXED_COST; + (gas as f64 * gas_multiplier) as u64 +} + +// Any::from_msg is infallible, but it yet returns result +fn into_any(msg: M) -> Any +where + M: Name, +{ + Any { + type_url: M::type_url(), + value: msg.encode_to_vec(), + } +} diff --git a/grpc/src/types.rs b/grpc/src/types.rs deleted file mode 100644 index 4a107ce1..00000000 --- a/grpc/src/types.rs +++ /dev/null @@ -1,87 +0,0 @@ -//! Custom types and wrappers needed by gRPC - -use celestia_proto::celestia::blob::v1::{ - QueryParamsRequest as QueryBlobParamsRequest, QueryParamsResponse as QueryBlobParamsResponse, -}; -use celestia_proto::cosmos::base::node::v1beta1::{ConfigRequest, ConfigResponse}; -use celestia_proto::cosmos::base::tendermint::v1beta1::{ - GetBlockByHeightRequest, GetBlockByHeightResponse, GetLatestBlockRequest, - GetLatestBlockResponse, -}; -use celestia_types::blob::BlobParams; -use celestia_types::block::Block; - -use crate::Error; - -/// types related to authorisation -pub mod auth; -/// types related to transaction querying and submission -pub mod tx; - -macro_rules! make_empty_params { - ($request_type:ident) => { - impl IntoGrpcParam<$request_type> for () { - fn into_parameter(self) -> $request_type { - $request_type {} - } - } - }; -} - -pub(crate) use make_empty_params; - -pub(crate) trait FromGrpcResponse { - fn try_from_response(self) -> Result; -} - -pub(crate) trait IntoGrpcParam { - fn into_parameter(self) -> T; -} - -impl FromGrpcResponse for QueryBlobParamsResponse { - fn try_from_response(self) -> Result { - let params = self.params.ok_or(Error::FailedToParseResponse)?; - Ok(BlobParams { - gas_per_blob_byte: params.gas_per_blob_byte, - gov_max_square_size: params.gov_max_square_size, - }) - } -} - -impl FromGrpcResponse for GetBlockByHeightResponse { - fn try_from_response(self) -> Result { - Ok(self.block.ok_or(Error::FailedToParseResponse)?.try_into()?) - } -} - -impl FromGrpcResponse for GetLatestBlockResponse { - fn try_from_response(self) -> Result { - Ok(self.block.ok_or(Error::FailedToParseResponse)?.try_into()?) - } -} - -impl FromGrpcResponse for ConfigResponse { - fn try_from_response(self) -> Result { - const UNITS_SUFFIX: &str = "utia"; - - let min_gas_price_with_suffix = self.minimum_gas_price; - let min_gas_price_str = min_gas_price_with_suffix - .strip_suffix(UNITS_SUFFIX) - .ok_or(Error::FailedToParseResponse)?; - let min_gas_price = min_gas_price_str - .parse::() - .map_err(|_| Error::FailedToParseResponse)?; - - Ok(min_gas_price) - } -} - -impl IntoGrpcParam for i64 { - fn into_parameter(self) -> GetBlockByHeightRequest { - GetBlockByHeightRequest { height: self } - } -} - -make_empty_params!(GetLatestBlockRequest); -make_empty_params!(ConfigRequest); -make_empty_params!(QueryBlobParamsRequest); diff --git a/grpc/src/types/tx.rs b/grpc/src/types/tx.rs deleted file mode 100644 index aa66fcfc..00000000 --- a/grpc/src/types/tx.rs +++ /dev/null @@ -1,130 +0,0 @@ -use k256::ecdsa::{signature::Signer, Signature}; -use prost::{Message, Name}; - -use celestia_proto::cosmos::crypto::secp256k1; -use celestia_proto::cosmos::tx::v1beta1::{ - BroadcastTxRequest, BroadcastTxResponse, GetTxRequest as RawGetTxRequest, - GetTxResponse as RawGetTxResponse, SignDoc, -}; -use celestia_types::state::auth::BaseAccount; -use celestia_types::state::{ - AuthInfo, Fee, ModeInfo, RawTx, RawTxBody, SignerInfo, Sum, Tx, TxResponse, -}; -use tendermint::public_key::Secp256k1 as VerifyingKey; -use tendermint_proto::google::protobuf::Any; -use tendermint_proto::Protobuf; - -use crate::types::{FromGrpcResponse, IntoGrpcParam}; -use crate::Error; - -pub use celestia_proto::cosmos::tx::v1beta1::BroadcastMode; - -/// Response to GetTx -#[derive(Debug)] -pub struct GetTxResponse { - /// Response Transaction - pub tx: Tx, - - /// TxResponse to a Query - pub tx_response: TxResponse, -} - -impl FromGrpcResponse for BroadcastTxResponse { - fn try_from_response(self) -> Result { - Ok(self - .tx_response - .ok_or(Error::FailedToParseResponse)? - .try_into()?) - } -} - -impl FromGrpcResponse for RawGetTxResponse { - fn try_from_response(self) -> Result { - let tx_response = self - .tx_response - .ok_or(Error::FailedToParseResponse)? - .try_into()?; - - let tx = self.tx.ok_or(Error::FailedToParseResponse)?; - - let cosmos_tx = Tx { - body: tx.body.ok_or(Error::FailedToParseResponse)?.try_into()?, - auth_info: tx - .auth_info - .ok_or(Error::FailedToParseResponse)? - .try_into()?, - signatures: tx.signatures, - }; - - Ok(GetTxResponse { - tx: cosmos_tx, - tx_response, - }) - } -} - -impl IntoGrpcParam for (Vec, BroadcastMode) { - fn into_parameter(self) -> BroadcastTxRequest { - let (tx_bytes, mode) = self; - - BroadcastTxRequest { - tx_bytes, - mode: mode.into(), - } - } -} - -impl IntoGrpcParam for String { - fn into_parameter(self) -> RawGetTxRequest { - RawGetTxRequest { hash: self } - } -} - -/// Sign `tx_body` and the transaction metadata as the `base_account` using `signer` -pub fn sign_tx( - tx_body: RawTxBody, - chain_id: String, - base_account: &BaseAccount, - verifying_key: VerifyingKey, - signer: impl Signer, - gas_limit: u64, - fee: u64, -) -> RawTx { - // From https://github.com/celestiaorg/cosmos-sdk/blob/v1.25.0-sdk-v0.46.16/proto/cosmos/tx/signing/v1beta1/signing.proto#L24 - const SIGNING_MODE_INFO: ModeInfo = ModeInfo { - sum: Sum::Single { mode: 1 }, - }; - - let public_key = secp256k1::PubKey { - key: verifying_key.to_encoded_point(true).as_bytes().to_vec(), - }; - let public_key_as_any = Any { - type_url: secp256k1::PubKey::type_url(), - value: public_key.encode_to_vec(), - }; - - let auth_info = AuthInfo { - signer_infos: vec![SignerInfo { - public_key: Some(public_key_as_any), - mode_info: SIGNING_MODE_INFO, - sequence: base_account.sequence, - }], - fee: Fee::new(fee, gas_limit), - }; - - let bytes_to_sign = SignDoc { - body_bytes: tx_body.encode_to_vec(), - auth_info_bytes: auth_info.clone().encode_vec(), - chain_id, - account_number: base_account.account_number, - } - .encode_to_vec(); - - let signature: Signature = signer.sign(&bytes_to_sign); - - RawTx { - auth_info: Some(auth_info.into()), - body: Some(tx_body), - signatures: vec![signature.to_bytes().to_vec()], - } -} diff --git a/grpc/src/utils.rs b/grpc/src/utils.rs new file mode 100644 index 00000000..bb200749 --- /dev/null +++ b/grpc/src/utils.rs @@ -0,0 +1,49 @@ +pub(crate) use imp::*; + +#[cfg(not(target_arch = "wasm32"))] +mod imp { + use std::time::Duration; + use tokio::time::interval; + pub(crate) struct Interval(tokio::time::Interval); + + impl Interval { + pub(crate) async fn new(dur: Duration) -> Self { + let mut inner = interval(dur); + + // In Tokio the first tick returns immediately, so we + // consume to it to create an identical cross-platform + // behavior. + inner.tick().await; + + Interval(inner) + } + + pub(crate) async fn tick(&mut self) { + self.0.tick().await; + } + } +} + +#[cfg(target_arch = "wasm32")] +mod imp { + use futures::StreamExt; + use gloo_timers::future::IntervalStream; + use send_wrapper::SendWrapper; + use std::time::Duration; + + pub(crate) struct Interval(SendWrapper); + + impl Interval { + pub(crate) async fn new(dur: Duration) -> Self { + // If duration was less than a millisecond, then make + // it 1 millisecond. + let millis = u32::try_from(dur.as_millis().max(1)).unwrap_or(u32::MAX); + + Interval(SendWrapper::new(IntervalStream::new(millis))) + } + + pub(crate) async fn tick(&mut self) { + self.0.next().await; + } + } +} diff --git a/grpc/tests/tonic.rs b/grpc/tests/tonic.rs index 94e66181..4272ce77 100644 --- a/grpc/tests/tonic.rs +++ b/grpc/tests/tonic.rs @@ -1,36 +1,27 @@ -#![cfg(not(target_arch = "wasm32"))] +use std::sync::Arc; -use celestia_grpc::types::auth::Account; -use celestia_grpc::types::tx::sign_tx; -use celestia_proto::cosmos::tx::v1beta1::BroadcastMode; -use celestia_types::blob::MsgPayForBlobs; +use celestia_grpc::{Error, TxConfig}; +use celestia_proto::cosmos::bank::v1beta1::MsgSend; use celestia_types::nmt::Namespace; +use celestia_types::state::{Coin, ErrorCode}; use celestia_types::{AppVersion, Blob}; +use utils::{load_account, TestAccount}; pub mod utils; -use crate::utils::{load_account, new_test_client}; +use crate::utils::{new_grpc_client, new_tx_client, spawn}; -const BRIDGE_0_ACCOUNT_DATA: &str = "../ci/credentials/bridge-0"; +#[cfg(not(target_arch = "wasm32"))] +use tokio::test as async_test; +#[cfg(target_arch = "wasm32")] +use wasm_bindgen_test::wasm_bindgen_test as async_test; -#[tokio::test] -async fn get_min_gas_price() { - let mut client = new_test_client().await.unwrap(); - let gas_price = client.get_min_gas_price().await.unwrap(); - assert!(gas_price > 0.0); -} +#[cfg(target_arch = "wasm32")] +wasm_bindgen_test::wasm_bindgen_test_configure!(run_in_browser); -#[tokio::test] -async fn get_blob_params() { - let mut client = new_test_client().await.unwrap(); - let params = client.get_blob_params().await.unwrap(); - assert!(params.gas_per_blob_byte > 0); - assert!(params.gov_max_square_size > 0); -} - -#[tokio::test] +#[async_test] async fn get_auth_params() { - let mut client = new_test_client().await.unwrap(); + let client = new_grpc_client(); let params = client.get_auth_params().await.unwrap(); assert!(params.max_memo_characters > 0); assert!(params.tx_sig_limit > 0); @@ -39,9 +30,53 @@ async fn get_auth_params() { assert!(params.sig_verify_cost_secp256k1 > 0); } -#[tokio::test] +#[async_test] +async fn get_account() { + let client = new_grpc_client(); + + let accounts = client.get_accounts().await.unwrap(); + + let first_account = accounts.first().expect("account to exist"); + let account = client.get_account(&first_account.address).await.unwrap(); + + assert_eq!(&account, first_account); +} + +#[async_test] +async fn get_balance() { + let account = load_account(); + let client = new_grpc_client(); + + let coin = client.get_balance(&account.address, "utia").await.unwrap(); + assert_eq!("utia", &coin.denom); + assert!(coin.amount > 0); + + let all_coins = client.get_all_balances(&account.address).await.unwrap(); + assert!(!all_coins.is_empty()); + assert!(all_coins.iter().map(|c| c.amount).sum::() > 0); + + let spendable_coins = client + .get_spendable_balances(&account.address) + .await + .unwrap(); + assert!(!spendable_coins.is_empty()); + assert!(spendable_coins.iter().map(|c| c.amount).sum::() > 0); + + let total_supply = client.get_total_supply().await.unwrap(); + assert!(!total_supply.is_empty()); + assert!(total_supply.iter().map(|c| c.amount).sum::() > 0); +} + +#[async_test] +async fn get_min_gas_price() { + let client = new_grpc_client(); + let gas_price = client.get_min_gas_price().await.unwrap(); + assert!(gas_price > 0.0); +} + +#[async_test] async fn get_block() { - let mut client = new_test_client().await.unwrap(); + let client = new_grpc_client(); let latest_block = client.get_latest_block().await.unwrap(); let height = latest_block.header.height.value() as i64; @@ -50,61 +85,139 @@ async fn get_block() { assert_eq!(block.header, latest_block.header); } -#[tokio::test] -async fn get_account() { - let mut client = new_test_client().await.unwrap(); +#[async_test] +async fn get_blob_params() { + let client = new_grpc_client(); + let params = client.get_blob_params().await.unwrap(); + assert!(params.gas_per_blob_byte > 0); + assert!(params.gov_max_square_size > 0); +} - let accounts = client.get_accounts().await.unwrap(); +#[async_test] +async fn submit_and_get_tx() { + let (_lock, tx_client) = new_tx_client().await; - let first_account = accounts.first().expect("account to exist"); + let namespace = Namespace::new_v0(&[1, 2, 3]).unwrap(); + let blobs = vec![Blob::new(namespace, "bleb".into(), AppVersion::V3).unwrap()]; - let address = match first_account { - Account::Base(acct) => acct.address.clone(), - Account::Module(acct) => acct.base_account.as_ref().unwrap().address.clone(), - }; + let tx = tx_client + .submit_blobs(&blobs, TxConfig::default()) + .await + .unwrap(); + let tx2 = tx_client.get_tx(tx.hash).await.unwrap(); - let account = client.get_account(&address).await.unwrap(); + assert_eq!(tx.hash, tx2.tx_response.txhash); +} - assert_eq!(&account, first_account); +#[async_test] +async fn submit_blobs_parallel() { + let (_lock, tx_client) = new_tx_client().await; + let tx_client = Arc::new(tx_client); + + let futs = (0..100) + .map(|n| { + let tx_client = tx_client.clone(); + spawn(async move { + let namespace = Namespace::new_v0(&[1, 2, n]).unwrap(); + let blobs = + vec![Blob::new(namespace, format!("bleb{n}").into(), AppVersion::V3).unwrap()]; + + let response = tx_client + .submit_blobs(&blobs, TxConfig::default()) + .await + .unwrap(); + + assert!(response.height.value() > 3) + }) + }) + .collect::>(); + + for fut in futs { + fut.await.unwrap(); + } } -#[tokio::test] -async fn submit_blob() { - let mut client = new_test_client().await.unwrap(); +#[async_test] +async fn submit_blobs_insufficient_gas_price_and_limit() { + let (_lock, tx_client) = new_tx_client().await; - let account_credentials = load_account(BRIDGE_0_ACCOUNT_DATA); let namespace = Namespace::new_v0(&[1, 2, 3]).unwrap(); - let blobs = vec![Blob::new(namespace, "Hello, World!".into(), AppVersion::V3).unwrap()]; - let chain_id = "private".to_string(); - let account = client - .get_account(&account_credentials.address) + let blobs = vec![Blob::new(namespace, "bleb".into(), AppVersion::V3).unwrap()]; + + let err = tx_client + .submit_blobs(&blobs, TxConfig::default().with_gas_limit(10000)) + .await + .unwrap_err(); + assert!(matches!( + err, + Error::TxBroadcastFailed(_, ErrorCode::OutOfGas, _) + )); + + let err = tx_client + .submit_blobs(&blobs, TxConfig::default().with_gas_price(0.0005)) + .await + .unwrap_err(); + assert!(matches!( + err, + Error::TxBroadcastFailed(_, ErrorCode::InsufficientFee, _) + )); +} + +#[async_test] +async fn submit_message() { + let account = load_account(); + let other_account = TestAccount::random(); + let amount = Coin::utia(12345); + let (_lock, tx_client) = new_tx_client().await; + + let msg = MsgSend { + from_address: account.address.to_string(), + to_address: other_account.address.to_string(), + amount: vec![amount.clone().into()], + }; + + tx_client + .submit_message(msg, TxConfig::default()) .await .unwrap(); - // gas and fees are overestimated for simplicity - let gas_limit = 100000; - let fee = 5000; - - let msg_pay_for_blobs = MsgPayForBlobs::new(&blobs, account_credentials.address).unwrap(); - - let tx = sign_tx( - msg_pay_for_blobs.into(), - chain_id, - account.base_account_ref().unwrap(), - account_credentials.verifying_key, - account_credentials.signing_key, - gas_limit, - fee, - ); - - let response = client - .broadcast_blob_tx(tx, blobs, BroadcastMode::Sync) + + let coins = tx_client + .get_all_balances(&other_account.address) .await .unwrap(); - tokio::time::sleep(std::time::Duration::from_secs(8)).await; + assert_eq!(coins.len(), 1); + assert_eq!(amount, coins[0]); +} - let _submitted_tx = client - .get_tx(response.txhash) +#[async_test] +async fn submit_message_insufficient_gas_price_and_limit() { + let account = load_account(); + let other_account = TestAccount::random(); + let amount = Coin::utia(12345); + let (_lock, tx_client) = new_tx_client().await; + + let msg = MsgSend { + from_address: account.address.to_string(), + to_address: other_account.address.to_string(), + amount: vec![amount.clone().into()], + }; + + let err = tx_client + .submit_message(msg.clone(), TxConfig::default().with_gas_limit(10000)) + .await + .unwrap_err(); + assert!(matches!( + err, + Error::TxBroadcastFailed(_, ErrorCode::OutOfGas, _) + )); + + let err = tx_client + .submit_message(msg, TxConfig::default().with_gas_price(0.0005)) .await - .expect("get to be successful"); + .unwrap_err(); + assert!(matches!( + err, + Error::TxBroadcastFailed(_, ErrorCode::InsufficientFee, _) + )); } diff --git a/grpc/tests/utils/mod.rs b/grpc/tests/utils/mod.rs index 0ac2acdf..a527f036 100644 --- a/grpc/tests/utils/mod.rs +++ b/grpc/tests/utils/mod.rs @@ -1,19 +1,8 @@ -#![cfg(not(target_arch = "wasm32"))] - -use std::{env, fs}; - -use anyhow::Result; -use tonic::metadata::{Ascii, MetadataValue}; -use tonic::service::Interceptor; -use tonic::transport::Channel; -use tonic::{Request, Status}; - -use celestia_grpc::GrpcClient; -use celestia_types::state::Address; +use celestia_types::state::{AccAddress, Address}; use tendermint::crypto::default::ecdsa_secp256k1::SigningKey; use tendermint::public_key::Secp256k1 as VerifyingKey; -const CELESTIA_GRPC_URL: &str = "http://localhost:19090"; +pub use imp::*; /// [`TestAccount`] stores celestia account credentials and information, for cases where we don't /// mind jusk keeping the plaintext secret key in memory @@ -27,58 +16,119 @@ pub struct TestAccount { pub signing_key: SigningKey, } -// -#[derive(Clone)] -pub struct TestAuthInterceptor { - token: Option>, -} +impl TestAccount { + pub fn random() -> Self { + let signing_key = SigningKey::random(&mut rand_core::OsRng); + let verifying_key = *signing_key.verifying_key(); -impl Interceptor for TestAuthInterceptor { - fn call(&mut self, mut request: Request<()>) -> Result, Status> { - if let Some(token) = &self.token { - request - .metadata_mut() - .insert("authorization", token.clone()); + Self { + address: AccAddress::new(verifying_key.into()).into(), + verifying_key, + signing_key, } - Ok(request) } } -impl TestAuthInterceptor { - pub fn new(bearer_token: Option) -> Result { - let token = bearer_token.map(|token| token.parse()).transpose()?; - Ok(Self { token }) +pub fn load_account() -> TestAccount { + let address = include_str!("../../../ci/credentials/bridge-0.addr"); + let hex_key = include_str!("../../../ci/credentials/bridge-0.plaintext-key"); + + let signing_key = + SigningKey::from_slice(&hex::decode(hex_key.trim()).expect("valid hex representation")) + .expect("valid key material"); + + TestAccount { + address: address.trim().parse().expect("valid address"), + verifying_key: *signing_key.verifying_key(), + signing_key, } } -pub fn env_or(var_name: &str, or_value: &str) -> String { - env::var(var_name).unwrap_or_else(|_| or_value.to_owned()) -} +#[cfg(not(target_arch = "wasm32"))] +mod imp { + use std::{future::Future, sync::OnceLock}; + + use celestia_grpc::{GrpcClient, TxClient}; + use tokio::sync::{Mutex, MutexGuard}; + use tonic::transport::Channel; + + use super::*; + + pub const CELESTIA_GRPC_URL: &str = "http://localhost:19090"; + + pub fn new_grpc_client() -> GrpcClient { + GrpcClient::with_url(CELESTIA_GRPC_URL).expect("creating client failed") + } -pub async fn new_test_client() -> Result> { - let _ = dotenvy::dotenv(); - let url = env_or("CELESTIA_GRPC_URL", CELESTIA_GRPC_URL); - let grpc_channel = Channel::from_shared(url)?.connect().await?; + // we have to sequence the tests which submits transactions. + // multiple independent tx clients don't work well in parallel + // as they break each other's account.sequence + pub async fn new_tx_client() -> (MutexGuard<'static, ()>, TxClient) { + static LOCK: OnceLock> = OnceLock::new(); + let lock = LOCK.get_or_init(|| Mutex::new(())).lock().await; - let auth_interceptor = TestAuthInterceptor::new(None)?; - Ok(GrpcClient::new(grpc_channel, auth_interceptor)) + let creds = load_account(); + let client = TxClient::with_url( + CELESTIA_GRPC_URL, + &creds.address, + creds.verifying_key, + creds.signing_key, + ) + .await + .unwrap(); + + (lock, client) + } + + pub fn spawn(future: F) -> tokio::task::JoinHandle<()> + where + F: Future + Send + 'static, + { + tokio::spawn(future) + } } -pub fn load_account(path: &str) -> TestAccount { - let account_file = format!("{path}.addr"); - let key_file = format!("{path}.plaintext-key"); +#[cfg(target_arch = "wasm32")] +mod imp { + use std::future::Future; - let account = fs::read_to_string(account_file).expect("file with account name to exists"); - let hex_encoded_key = fs::read_to_string(key_file).expect("file with plaintext key to exists"); + use celestia_grpc::{GrpcClient, TxClient}; + use tokio::sync::oneshot; + use tonic_web_wasm_client::Client; + use wasm_bindgen_futures::spawn_local; - let signing_key = SigningKey::from_slice( - &hex::decode(hex_encoded_key.trim()).expect("valid hex representation"), - ) - .expect("valid key material"); + use super::*; - TestAccount { - address: account.trim().parse().expect("valid address"), - verifying_key: *signing_key.verifying_key(), - signing_key, + const CELESTIA_GRPCWEB_PROXY_URL: &str = "http://localhost:18080"; + + pub fn new_grpc_client() -> GrpcClient { + GrpcClient::with_grpcweb_url(CELESTIA_GRPCWEB_PROXY_URL) + } + + pub async fn new_tx_client() -> ((), TxClient) { + let creds = load_account(); + let client = TxClient::with_grpcweb_url( + CELESTIA_GRPCWEB_PROXY_URL, + &creds.address, + creds.verifying_key, + creds.signing_key, + ) + .await + .unwrap(); + + ((), client) + } + + pub fn spawn(future: F) -> oneshot::Receiver<()> + where + F: Future + 'static, + { + let (tx, rx) = oneshot::channel(); + spawn_local(async move { + future.await; + let _ = tx.send(()); + }); + + rx } } diff --git a/proto/Cargo.toml b/proto/Cargo.toml index 06a0b01d..01222b8c 100644 --- a/proto/Cargo.toml +++ b/proto/Cargo.toml @@ -30,10 +30,6 @@ prost-types.workspace = true protox = "0.7.1" tonic-build = { version = "0.12.3", default-features = false, optional = true, features = [ "prost" ]} -[target.'cfg(not(target_arch = "wasm32"))'.dependencies] -tonic = { version = "0.12.3", optional = true, default-features = false, features = [ "transport" ] } -tonic-build = { version = "0.12.3", optional = true, default-features = false, features = ["transport"] } - [target.'cfg(target_arch = "wasm32")'.dev-dependencies] wasm-bindgen-test.workspace = true diff --git a/proto/build.rs b/proto/build.rs index 77980156..50014fe2 100644 --- a/proto/build.rs +++ b/proto/build.rs @@ -115,8 +115,13 @@ const PROTO_FILES: &[&str] = &[ "vendor/celestia/blob/v1/tx.proto", "vendor/celestia/core/v1/da/data_availability_header.proto", "vendor/celestia/core/v1/proof/proof.proto", + "vendor/celestia/core/v1/tx/tx.proto", "vendor/cosmos/auth/v1beta1/auth.proto", "vendor/cosmos/auth/v1beta1/query.proto", + "vendor/cosmos/bank/v1beta1/bank.proto", + "vendor/cosmos/bank/v1beta1/genesis.proto", + "vendor/cosmos/bank/v1beta1/query.proto", + "vendor/cosmos/bank/v1beta1/tx.proto", "vendor/cosmos/base/abci/v1beta1/abci.proto", "vendor/cosmos/base/node/v1beta1/query.proto", "vendor/cosmos/base/tendermint/v1beta1/query.proto", @@ -194,7 +199,6 @@ fn tonic_build(fds: FileDescriptorSet) { .include_file("mod.rs") .build_client(true) .build_server(false) - .client_mod_attribute(".", "#[cfg(not(target_arch=\"wasm32\"))]") .use_arc_self(true) .compile_well_known_types(true) .skip_protoc_run() diff --git a/proto/vendor/cosmos/bank/v1beta1/authz.proto b/proto/vendor/cosmos/bank/v1beta1/authz.proto new file mode 100644 index 00000000..4f58b15e --- /dev/null +++ b/proto/vendor/cosmos/bank/v1beta1/authz.proto @@ -0,0 +1,19 @@ +syntax = "proto3"; +package cosmos.bank.v1beta1; + +import "gogoproto/gogo.proto"; +import "cosmos_proto/cosmos.proto"; +import "cosmos/base/v1beta1/coin.proto"; + +option go_package = "github.com/cosmos/cosmos-sdk/x/bank/types"; + +// SendAuthorization allows the grantee to spend up to spend_limit coins from +// the granter's account. +// +// Since: cosmos-sdk 0.43 +message SendAuthorization { + option (cosmos_proto.implements_interface) = "Authorization"; + + repeated cosmos.base.v1beta1.Coin spend_limit = 1 + [(gogoproto.nullable) = false, (gogoproto.castrepeated) = "github.com/cosmos/cosmos-sdk/types.Coins"]; +} diff --git a/proto/vendor/cosmos/bank/v1beta1/bank.proto b/proto/vendor/cosmos/bank/v1beta1/bank.proto new file mode 100644 index 00000000..7bc9819d --- /dev/null +++ b/proto/vendor/cosmos/bank/v1beta1/bank.proto @@ -0,0 +1,108 @@ +syntax = "proto3"; +package cosmos.bank.v1beta1; + +import "gogoproto/gogo.proto"; +import "cosmos_proto/cosmos.proto"; +import "cosmos/base/v1beta1/coin.proto"; +import "cosmos/msg/v1/msg.proto"; + +option go_package = "github.com/cosmos/cosmos-sdk/x/bank/types"; + +// Params defines the parameters for the bank module. +message Params { + option (gogoproto.goproto_stringer) = false; + repeated SendEnabled send_enabled = 1; + bool default_send_enabled = 2; +} + +// SendEnabled maps coin denom to a send_enabled status (whether a denom is +// sendable). +message SendEnabled { + option (gogoproto.equal) = true; + option (gogoproto.goproto_stringer) = false; + string denom = 1; + bool enabled = 2; +} + +// Input models transaction input. +message Input { + option (cosmos.msg.v1.signer) = "address"; + + option (gogoproto.equal) = false; + option (gogoproto.goproto_getters) = false; + + string address = 1 [(cosmos_proto.scalar) = "cosmos.AddressString"]; + repeated cosmos.base.v1beta1.Coin coins = 2 + [(gogoproto.nullable) = false, (gogoproto.castrepeated) = "github.com/cosmos/cosmos-sdk/types.Coins"]; +} + +// Output models transaction outputs. +message Output { + option (gogoproto.equal) = false; + option (gogoproto.goproto_getters) = false; + + string address = 1 [(cosmos_proto.scalar) = "cosmos.AddressString"]; + repeated cosmos.base.v1beta1.Coin coins = 2 + [(gogoproto.nullable) = false, (gogoproto.castrepeated) = "github.com/cosmos/cosmos-sdk/types.Coins"]; +} + +// Supply represents a struct that passively keeps track of the total supply +// amounts in the network. +// This message is deprecated now that supply is indexed by denom. +message Supply { + option deprecated = true; + + option (gogoproto.equal) = true; + option (gogoproto.goproto_getters) = false; + + option (cosmos_proto.implements_interface) = "*github.com/cosmos/cosmos-sdk/x/bank/migrations/v040.SupplyI"; + + repeated cosmos.base.v1beta1.Coin total = 1 + [(gogoproto.nullable) = false, (gogoproto.castrepeated) = "github.com/cosmos/cosmos-sdk/types.Coins"]; +} + +// DenomUnit represents a struct that describes a given +// denomination unit of the basic token. +message DenomUnit { + // denom represents the string name of the given denom unit (e.g uatom). + string denom = 1; + // exponent represents power of 10 exponent that one must + // raise the base_denom to in order to equal the given DenomUnit's denom + // 1 denom = 10^exponent base_denom + // (e.g. with a base_denom of uatom, one can create a DenomUnit of 'atom' with + // exponent = 6, thus: 1 atom = 10^6 uatom). + uint32 exponent = 2; + // aliases is a list of string aliases for the given denom + repeated string aliases = 3; +} + +// Metadata represents a struct that describes +// a basic token. +message Metadata { + string description = 1; + // denom_units represents the list of DenomUnit's for a given coin + repeated DenomUnit denom_units = 2; + // base represents the base denom (should be the DenomUnit with exponent = 0). + string base = 3; + // display indicates the suggested denom that should be + // displayed in clients. + string display = 4; + // name defines the name of the token (eg: Cosmos Atom) + // + // Since: cosmos-sdk 0.43 + string name = 5; + // symbol is the token symbol usually shown on exchanges (eg: ATOM). This can + // be the same as the display. + // + // Since: cosmos-sdk 0.43 + string symbol = 6; + // URI to a document (on or off-chain) that contains additional information. Optional. + // + // Since: cosmos-sdk 0.46 + string uri = 7 [(gogoproto.customname) = "URI"]; + // URIHash is a sha256 hash of a document pointed by URI. It's used to verify that + // the document didn't change. Optional. + // + // Since: cosmos-sdk 0.46 + string uri_hash = 8 [(gogoproto.customname) = "URIHash"]; +} diff --git a/proto/vendor/cosmos/bank/v1beta1/genesis.proto b/proto/vendor/cosmos/bank/v1beta1/genesis.proto new file mode 100644 index 00000000..aa35790b --- /dev/null +++ b/proto/vendor/cosmos/bank/v1beta1/genesis.proto @@ -0,0 +1,40 @@ +syntax = "proto3"; +package cosmos.bank.v1beta1; + +import "gogoproto/gogo.proto"; +import "cosmos/base/v1beta1/coin.proto"; +import "cosmos/bank/v1beta1/bank.proto"; +import "cosmos_proto/cosmos.proto"; + +option go_package = "github.com/cosmos/cosmos-sdk/x/bank/types"; + +// GenesisState defines the bank module's genesis state. +message GenesisState { + // params defines all the paramaters of the module. + Params params = 1 [(gogoproto.nullable) = false]; + + // balances is an array containing the balances of all the accounts. + repeated Balance balances = 2 [(gogoproto.nullable) = false]; + + // supply represents the total supply. If it is left empty, then supply will be calculated based on the provided + // balances. Otherwise, it will be used to validate that the sum of the balances equals this amount. + repeated cosmos.base.v1beta1.Coin supply = 3 + [(gogoproto.castrepeated) = "github.com/cosmos/cosmos-sdk/types.Coins", (gogoproto.nullable) = false]; + + // denom_metadata defines the metadata of the differents coins. + repeated Metadata denom_metadata = 4 [(gogoproto.nullable) = false]; +} + +// Balance defines an account address and balance pair used in the bank module's +// genesis state. +message Balance { + option (gogoproto.equal) = false; + option (gogoproto.goproto_getters) = false; + + // address is the address of the balance holder. + string address = 1 [(cosmos_proto.scalar) = "cosmos.AddressString"]; + + // coins defines the different coins this balance holds. + repeated cosmos.base.v1beta1.Coin coins = 2 + [(gogoproto.castrepeated) = "github.com/cosmos/cosmos-sdk/types.Coins", (gogoproto.nullable) = false]; +} diff --git a/proto/vendor/cosmos/bank/v1beta1/query.proto b/proto/vendor/cosmos/bank/v1beta1/query.proto new file mode 100644 index 00000000..635471c4 --- /dev/null +++ b/proto/vendor/cosmos/bank/v1beta1/query.proto @@ -0,0 +1,243 @@ +syntax = "proto3"; +package cosmos.bank.v1beta1; + +import "cosmos/base/query/v1beta1/pagination.proto"; +import "gogoproto/gogo.proto"; +import "google/api/annotations.proto"; +import "cosmos/base/v1beta1/coin.proto"; +import "cosmos/bank/v1beta1/bank.proto"; +import "cosmos_proto/cosmos.proto"; + +option go_package = "github.com/cosmos/cosmos-sdk/x/bank/types"; + +// Query defines the gRPC querier service. +service Query { + // Balance queries the balance of a single coin for a single account. + rpc Balance(QueryBalanceRequest) returns (QueryBalanceResponse) { + option (google.api.http).get = "/cosmos/bank/v1beta1/balances/{address}/by_denom"; + } + + // AllBalances queries the balance of all coins for a single account. + rpc AllBalances(QueryAllBalancesRequest) returns (QueryAllBalancesResponse) { + option (google.api.http).get = "/cosmos/bank/v1beta1/balances/{address}"; + } + + // SpendableBalances queries the spenable balance of all coins for a single + // account. + // + // Since: cosmos-sdk 0.46 + rpc SpendableBalances(QuerySpendableBalancesRequest) returns (QuerySpendableBalancesResponse) { + option (google.api.http).get = "/cosmos/bank/v1beta1/spendable_balances/{address}"; + } + + // TotalSupply queries the total supply of all coins. + rpc TotalSupply(QueryTotalSupplyRequest) returns (QueryTotalSupplyResponse) { + option (google.api.http).get = "/cosmos/bank/v1beta1/supply"; + } + + // SupplyOf queries the supply of a single coin. + rpc SupplyOf(QuerySupplyOfRequest) returns (QuerySupplyOfResponse) { + option (google.api.http).get = "/cosmos/bank/v1beta1/supply/by_denom"; + } + + // Params queries the parameters of x/bank module. + rpc Params(QueryParamsRequest) returns (QueryParamsResponse) { + option (google.api.http).get = "/cosmos/bank/v1beta1/params"; + } + + // DenomsMetadata queries the client metadata of a given coin denomination. + rpc DenomMetadata(QueryDenomMetadataRequest) returns (QueryDenomMetadataResponse) { + option (google.api.http).get = "/cosmos/bank/v1beta1/denoms_metadata/{denom}"; + } + + // DenomsMetadata queries the client metadata for all registered coin + // denominations. + rpc DenomsMetadata(QueryDenomsMetadataRequest) returns (QueryDenomsMetadataResponse) { + option (google.api.http).get = "/cosmos/bank/v1beta1/denoms_metadata"; + } + + // DenomOwners queries for all account addresses that own a particular token + // denomination. + // + // Since: cosmos-sdk 0.46 + rpc DenomOwners(QueryDenomOwnersRequest) returns (QueryDenomOwnersResponse) { + option (google.api.http).get = "/cosmos/bank/v1beta1/denom_owners/{denom}"; + } +} + +// QueryBalanceRequest is the request type for the Query/Balance RPC method. +message QueryBalanceRequest { + option (gogoproto.equal) = false; + option (gogoproto.goproto_getters) = false; + + // address is the address to query balances for. + string address = 1 [(cosmos_proto.scalar) = "cosmos.AddressString"]; + + // denom is the coin denom to query balances for. + string denom = 2; +} + +// QueryBalanceResponse is the response type for the Query/Balance RPC method. +message QueryBalanceResponse { + // balance is the balance of the coin. + cosmos.base.v1beta1.Coin balance = 1; +} + +// QueryBalanceRequest is the request type for the Query/AllBalances RPC method. +message QueryAllBalancesRequest { + option (gogoproto.equal) = false; + option (gogoproto.goproto_getters) = false; + + // address is the address to query balances for. + string address = 1 [(cosmos_proto.scalar) = "cosmos.AddressString"]; + + // pagination defines an optional pagination for the request. + cosmos.base.query.v1beta1.PageRequest pagination = 2; +} + +// QueryAllBalancesResponse is the response type for the Query/AllBalances RPC +// method. +message QueryAllBalancesResponse { + // balances is the balances of all the coins. + repeated cosmos.base.v1beta1.Coin balances = 1 + [(gogoproto.nullable) = false, (gogoproto.castrepeated) = "github.com/cosmos/cosmos-sdk/types.Coins"]; + + // pagination defines the pagination in the response. + cosmos.base.query.v1beta1.PageResponse pagination = 2; +} + +// QuerySpendableBalancesRequest defines the gRPC request structure for querying +// an account's spendable balances. +// +// Since: cosmos-sdk 0.46 +message QuerySpendableBalancesRequest { + option (gogoproto.equal) = false; + option (gogoproto.goproto_getters) = false; + + // address is the address to query spendable balances for. + string address = 1 [(cosmos_proto.scalar) = "cosmos.AddressString"]; + + // pagination defines an optional pagination for the request. + cosmos.base.query.v1beta1.PageRequest pagination = 2; +} + +// QuerySpendableBalancesResponse defines the gRPC response structure for querying +// an account's spendable balances. +// +// Since: cosmos-sdk 0.46 +message QuerySpendableBalancesResponse { + // balances is the spendable balances of all the coins. + repeated cosmos.base.v1beta1.Coin balances = 1 + [(gogoproto.nullable) = false, (gogoproto.castrepeated) = "github.com/cosmos/cosmos-sdk/types.Coins"]; + + // pagination defines the pagination in the response. + cosmos.base.query.v1beta1.PageResponse pagination = 2; +} + +// QueryTotalSupplyRequest is the request type for the Query/TotalSupply RPC +// method. +message QueryTotalSupplyRequest { + option (gogoproto.equal) = false; + option (gogoproto.goproto_getters) = false; + + // pagination defines an optional pagination for the request. + // + // Since: cosmos-sdk 0.43 + cosmos.base.query.v1beta1.PageRequest pagination = 1; +} + +// QueryTotalSupplyResponse is the response type for the Query/TotalSupply RPC +// method +message QueryTotalSupplyResponse { + // supply is the supply of the coins + repeated cosmos.base.v1beta1.Coin supply = 1 + [(gogoproto.nullable) = false, (gogoproto.castrepeated) = "github.com/cosmos/cosmos-sdk/types.Coins"]; + + // pagination defines the pagination in the response. + // + // Since: cosmos-sdk 0.43 + cosmos.base.query.v1beta1.PageResponse pagination = 2; +} + +// QuerySupplyOfRequest is the request type for the Query/SupplyOf RPC method. +message QuerySupplyOfRequest { + // denom is the coin denom to query balances for. + string denom = 1; +} + +// QuerySupplyOfResponse is the response type for the Query/SupplyOf RPC method. +message QuerySupplyOfResponse { + // amount is the supply of the coin. + cosmos.base.v1beta1.Coin amount = 1 [(gogoproto.nullable) = false]; +} + +// QueryParamsRequest defines the request type for querying x/bank parameters. +message QueryParamsRequest {} + +// QueryParamsResponse defines the response type for querying x/bank parameters. +message QueryParamsResponse { + Params params = 1 [(gogoproto.nullable) = false]; +} + +// QueryDenomsMetadataRequest is the request type for the Query/DenomsMetadata RPC method. +message QueryDenomsMetadataRequest { + // pagination defines an optional pagination for the request. + cosmos.base.query.v1beta1.PageRequest pagination = 1; +} + +// QueryDenomsMetadataResponse is the response type for the Query/DenomsMetadata RPC +// method. +message QueryDenomsMetadataResponse { + // metadata provides the client information for all the registered tokens. + repeated Metadata metadatas = 1 [(gogoproto.nullable) = false]; + + // pagination defines the pagination in the response. + cosmos.base.query.v1beta1.PageResponse pagination = 2; +} + +// QueryDenomMetadataRequest is the request type for the Query/DenomMetadata RPC method. +message QueryDenomMetadataRequest { + // denom is the coin denom to query the metadata for. + string denom = 1; +} + +// QueryDenomMetadataResponse is the response type for the Query/DenomMetadata RPC +// method. +message QueryDenomMetadataResponse { + // metadata describes and provides all the client information for the requested token. + Metadata metadata = 1 [(gogoproto.nullable) = false]; +} + +// QueryDenomOwnersRequest defines the request type for the DenomOwners RPC query, +// which queries for a paginated set of all account holders of a particular +// denomination. +message QueryDenomOwnersRequest { + // denom defines the coin denomination to query all account holders for. + string denom = 1; + + // pagination defines an optional pagination for the request. + cosmos.base.query.v1beta1.PageRequest pagination = 2; +} + +// DenomOwner defines structure representing an account that owns or holds a +// particular denominated token. It contains the account address and account +// balance of the denominated token. +// +// Since: cosmos-sdk 0.46 +message DenomOwner { + // address defines the address that owns a particular denomination. + string address = 1 [(cosmos_proto.scalar) = "cosmos.AddressString"]; + + // balance is the balance of the denominated coin for an account. + cosmos.base.v1beta1.Coin balance = 2 [(gogoproto.nullable) = false]; +} + +// QueryDenomOwnersResponse defines the RPC response of a DenomOwners RPC query. +// +// Since: cosmos-sdk 0.46 +message QueryDenomOwnersResponse { + repeated DenomOwner denom_owners = 1; + + // pagination defines the pagination in the response. + cosmos.base.query.v1beta1.PageResponse pagination = 2; +} diff --git a/proto/vendor/cosmos/bank/v1beta1/tx.proto b/proto/vendor/cosmos/bank/v1beta1/tx.proto new file mode 100644 index 00000000..22e62cbf --- /dev/null +++ b/proto/vendor/cosmos/bank/v1beta1/tx.proto @@ -0,0 +1,48 @@ +syntax = "proto3"; +package cosmos.bank.v1beta1; + +import "gogoproto/gogo.proto"; +import "cosmos/base/v1beta1/coin.proto"; +import "cosmos/bank/v1beta1/bank.proto"; +import "cosmos_proto/cosmos.proto"; +import "cosmos/msg/v1/msg.proto"; + +option go_package = "github.com/cosmos/cosmos-sdk/x/bank/types"; + +// Msg defines the bank Msg service. +service Msg { + // Send defines a method for sending coins from one account to another account. + rpc Send(MsgSend) returns (MsgSendResponse); + + // MultiSend defines a method for sending coins from some accounts to other accounts. + rpc MultiSend(MsgMultiSend) returns (MsgMultiSendResponse); +} + +// MsgSend represents a message to send coins from one account to another. +message MsgSend { + option (cosmos.msg.v1.signer) = "from_address"; + + option (gogoproto.equal) = false; + option (gogoproto.goproto_getters) = false; + + string from_address = 1 [(cosmos_proto.scalar) = "cosmos.AddressString"]; + string to_address = 2 [(cosmos_proto.scalar) = "cosmos.AddressString"]; + repeated cosmos.base.v1beta1.Coin amount = 3 + [(gogoproto.nullable) = false, (gogoproto.castrepeated) = "github.com/cosmos/cosmos-sdk/types.Coins"]; +} + +// MsgSendResponse defines the Msg/Send response type. +message MsgSendResponse {} + +// MsgMultiSend represents an arbitrary multi-in, multi-out send message. +message MsgMultiSend { + option (cosmos.msg.v1.signer) = "inputs"; + + option (gogoproto.equal) = false; + + repeated Input inputs = 1 [(gogoproto.nullable) = false]; + repeated Output outputs = 2 [(gogoproto.nullable) = false]; +} + +// MsgMultiSendResponse defines the Msg/MultiSend response type. +message MsgMultiSendResponse {} diff --git a/proto/vendor/cosmos/msg/v1/msg.proto b/proto/vendor/cosmos/msg/v1/msg.proto new file mode 100644 index 00000000..89bdf312 --- /dev/null +++ b/proto/vendor/cosmos/msg/v1/msg.proto @@ -0,0 +1,22 @@ +syntax = "proto3"; + +package cosmos.msg.v1; + +import "google/protobuf/descriptor.proto"; + +// TODO(fdymylja): once we fully migrate to protov2 the go_package needs to be updated. +// We need this right now because gogoproto codegen needs to import the extension. +option go_package = "github.com/cosmos/cosmos-sdk/types/msgservice"; + +extend google.protobuf.MessageOptions { + // signer must be used in cosmos messages in order + // to signal to external clients which fields in a + // given cosmos message must be filled with signer + // information (address). + // The field must be the protobuf name of the message + // field extended with this MessageOption. + // The field must either be of string kind, or of message + // kind in case the signer information is contained within + // a message inside the cosmos message. + repeated string signer = 11110000; +} \ No newline at end of file diff --git a/tools/update-proto-vendor.sh b/tools/update-proto-vendor.sh index bf518b6c..af9a0477 100755 --- a/tools/update-proto-vendor.sh +++ b/tools/update-proto-vendor.sh @@ -48,7 +48,7 @@ cp -r ../target/proto-vendor-src/go-square-main/proto vendor/go-square rm -rf vendor/cosmos mkdir -p vendor/cosmos -cp -r ../target/proto-vendor-src/cosmos-sdk-release-v0.46.x-celestia/proto/cosmos/{auth,base,staking,crypto,tx} vendor/cosmos +cp -r ../target/proto-vendor-src/cosmos-sdk-release-v0.46.x-celestia/proto/cosmos/{auth,bank,base,msg,staking,crypto,tx} vendor/cosmos rm -rf vendor/cosmos_proto cp -r ../target/proto-vendor-src/cosmos-proto-1.0.0-alpha7/proto/cosmos_proto vendor diff --git a/types/Cargo.toml b/types/Cargo.toml index e230b4b8..113233fc 100644 --- a/types/Cargo.toml +++ b/types/Cargo.toml @@ -36,7 +36,7 @@ multihash = "0.19.1" rand = { version = "0.8.5", optional = true } ruint = { version = "1.12.3", features = ["serde"] } serde = { version = "1.0.203", features = ["derive"] } -serde_repr = { version = "0.1.19", optional = true } +serde_repr = "0.1.19" sha2 = "0.10.6" thiserror = "1.0.61" time = { version = "0.3.36", default-features = false } @@ -61,7 +61,7 @@ wasm-bindgen-test.workspace = true [features] default = ["p2p"] -p2p = ["dep:libp2p-identity", "dep:multiaddr", "dep:serde_repr"] +p2p = ["dep:libp2p-identity", "dep:multiaddr"] test-utils = ["dep:ed25519-consensus", "dep:rand"] tonic = ["celestia-proto/tonic"] wasm-bindgen = ["dep:js-sys", "dep:serde-wasm-bindgen", "dep:wasm-bindgen", "nmt-rs/serde", "time/wasm-bindgen"] diff --git a/types/src/blob.rs b/types/src/blob.rs index 1bb423f9..99018e9a 100644 --- a/types/src/blob.rs +++ b/types/src/blob.rs @@ -313,6 +313,33 @@ impl Blob { Ok(blobs) } + + /// Get the amount of shares needed to encode this blob. + /// + /// # Example + /// + /// ``` + /// use celestia_types::{AppVersion, Blob}; + /// # use celestia_types::nmt::Namespace; + /// # let namespace = Namespace::new_v0(&[1, 2, 3, 4, 5]).expect("Invalid namespace"); + /// + /// let blob = Blob::new(namespace, b"foo".to_vec(), AppVersion::V3).unwrap(); + /// let shares_len = blob.shares_len(); + /// + /// let blob_shares = blob.to_shares().unwrap(); + /// + /// assert_eq!(shares_len, blob_shares.len()); + /// ``` + pub fn shares_len(&self) -> usize { + let Some(without_first_share) = self + .data + .len() + .checked_sub(appconsts::FIRST_SPARSE_SHARE_CONTENT_SIZE) + else { + return 1; + }; + 1 + without_first_share.div_ceil(appconsts::CONTINUATION_SPARSE_SHARE_CONTENT_SIZE) + } } impl From for RawBlob { diff --git a/types/src/blob/msg_pay_for_blobs.rs b/types/src/blob/msg_pay_for_blobs.rs index 914486ef..04029f90 100644 --- a/types/src/blob/msg_pay_for_blobs.rs +++ b/types/src/blob/msg_pay_for_blobs.rs @@ -1,9 +1,5 @@ -use celestia_proto::celestia::blob::v1::MsgPayForBlobs as RawMsgPayForBlobs; -use celestia_proto::cosmos::tx::v1beta1::TxBody as RawTxBody; -use prost::Name; use serde::{Deserialize, Serialize}; use tendermint::merkle::Hash; -use tendermint_proto::google::protobuf::Any; use tendermint_proto::Protobuf; use crate::blob::{Blob, Commitment}; @@ -11,6 +7,8 @@ use crate::nmt::Namespace; use crate::state::Address; use crate::{Error, Result}; +pub use celestia_proto::celestia::blob::v1::MsgPayForBlobs as RawMsgPayForBlobs; + /// MsgPayForBlobs pays for the inclusion of a blob in the block. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct MsgPayForBlobs { @@ -57,20 +55,6 @@ impl MsgPayForBlobs { } } -impl From for RawTxBody { - fn from(msg: MsgPayForBlobs) -> Self { - let msg_pay_for_blobs_as_any = Any { - type_url: RawMsgPayForBlobs::type_url(), - value: msg.encode_vec(), - }; - - RawTxBody { - messages: vec![msg_pay_for_blobs_as_any], - ..RawTxBody::default() - } - } -} - impl From for RawMsgPayForBlobs { fn from(msg: MsgPayForBlobs) -> Self { let namespaces = msg diff --git a/types/src/consts.rs b/types/src/consts.rs index 6ee4d471..9f1d9f72 100644 --- a/types/src/consts.rs +++ b/types/src/consts.rs @@ -175,18 +175,20 @@ pub mod appconsts { } /// Cost of each byte in a transaction (in units of gas). - pub const fn tx_size_cost_per_byte(app_version: AppVersion) -> Option { + pub const fn tx_size_cost_per_byte(app_version: AppVersion) -> u64 { + // v1 and v2 don't have this constant because it was taken from cosmos-sdk before. + // The value was the same as in v3 tho, so fall back to it. match app_version { - AppVersion::V1 | AppVersion::V2 => None, - AppVersion::V3 => Some(v3::TX_SIZE_COST_PER_BYTE), + AppVersion::V1 | AppVersion::V2 | AppVersion::V3 => v3::TX_SIZE_COST_PER_BYTE, } } /// Cost of each byte in blob (in units of gas). - pub const fn gas_per_blob_byte(app_version: AppVersion) -> Option { + pub const fn gas_per_blob_byte(app_version: AppVersion) -> u64 { + // In v1 and v2 this const was in appconsts/initial_consts.go rather than being versioned. + // The value was the same as in v3 tho, so fall back to it. match app_version { - AppVersion::V1 | AppVersion::V2 => None, - AppVersion::V3 => Some(v3::GAS_PER_BLOB_BYTE), + AppVersion::V1 | AppVersion::V2 | AppVersion::V3 => v3::GAS_PER_BLOB_BYTE, } } diff --git a/types/src/state.rs b/types/src/state.rs index 562e679b..90606f9b 100644 --- a/types/src/state.rs +++ b/types/src/state.rs @@ -13,8 +13,8 @@ pub use self::query_delegation::{ QueryDelegationResponse, QueryRedelegationsResponse, QueryUnbondingDelegationResponse, }; pub use self::tx::{ - AuthInfo, Coin, Fee, ModeInfo, RawTx, RawTxBody, RawTxResponse, SignerInfo, Sum, Tx, TxBody, - TxResponse, BOND_DENOM, + AuthInfo, Coin, ErrorCode, Fee, ModeInfo, RawTx, RawTxBody, RawTxResponse, SignerInfo, Sum, Tx, + TxBody, TxResponse, BOND_DENOM, }; /// A 256-bit unsigned integer. diff --git a/types/src/state/auth.rs b/types/src/state/auth.rs index cef72dfd..2d1ed7e6 100644 --- a/types/src/state/auth.rs +++ b/types/src/state/auth.rs @@ -8,6 +8,7 @@ use tendermint_proto::google::protobuf::Any; use tendermint_proto::Protobuf; use crate::state::Address; +use crate::validation_error; use crate::Error; pub use celestia_proto::cosmos::auth::v1beta1::BaseAccount as RawBaseAccount; @@ -40,7 +41,7 @@ pub struct BaseAccount { #[derive(Debug, Clone, PartialEq)] pub struct ModuleAccount { /// [`BaseAccount`] specification of this module account. - pub base_account: Option, + pub base_account: BaseAccount, /// Name of the module. pub name: String, /// Permissions associated with this module account. @@ -74,7 +75,7 @@ impl TryFrom for BaseAccount { impl From for RawModuleAccount { fn from(account: ModuleAccount) -> Self { - let base_account = account.base_account.map(BaseAccount::into); + let base_account = Some(account.base_account.into()); RawModuleAccount { base_account, name: account.name, @@ -89,8 +90,8 @@ impl TryFrom for ModuleAccount { fn try_from(account: RawModuleAccount) -> Result { let base_account = account .base_account - .map(RawBaseAccount::try_into) - .transpose()?; + .ok_or_else(|| validation_error!("base account missing"))? + .try_into()?; Ok(ModuleAccount { base_account, name: account.name, diff --git a/types/src/state/tx.rs b/types/src/state/tx.rs index 624fd69b..fd0bbd11 100644 --- a/types/src/state/tx.rs +++ b/types/src/state/tx.rs @@ -1,10 +1,17 @@ +use std::fmt; + use celestia_proto::cosmos::base::abci::v1beta1::AbciMessageLog; use serde::{Deserialize, Serialize}; +use serde_repr::Deserialize_repr; +use serde_repr::Serialize_repr; use tendermint_proto::google::protobuf::Any; use tendermint_proto::v0_34::abci::Event; use tendermint_proto::Protobuf; +use crate::bail_validation; +use crate::hash::Hash; use crate::state::bit_array::BitVector; +use crate::state::Address; use crate::Error; use crate::Height; @@ -76,13 +83,14 @@ pub struct TxResponse { pub height: Height, /// The transaction hash. - pub txhash: String, + #[serde(with = "crate::serializers::hash")] + pub txhash: Hash, /// Namespace for the Code pub codespace: String, /// Response code. - pub code: u32, + pub code: ErrorCode, /// Result bytes, if any. pub data: String, @@ -104,9 +112,6 @@ pub struct TxResponse { pub gas_used: i64, /// The request transaction bytes. - #[serde(skip)] - // caused by prost_types/pbjson_types::Any conditional compilation, should be - // removed once we align on tendermint::Any pub tx: Option, /// Time of the previous block. For heights > 1, it's the weighted median of @@ -197,20 +202,11 @@ pub struct Fee { /// if unset, the first signer is responsible for paying the fees. If set, 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: String, + pub payer: Option
, /// if set, 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: String, -} - -/// Coin defines a token with a denomination and an amount. -#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)] -pub struct Coin { - /// Coin denomination - pub denom: String, - /// Coin amount - pub amount: u64, + pub granter: Option
, } impl Fee { @@ -229,6 +225,175 @@ impl Fee { } } +/// Coin defines a token with a denomination and an amount. +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)] +pub struct Coin { + /// Coin denomination + pub denom: String, + /// Coin amount + pub amount: u64, +} + +impl Coin { + /// Create a coin with given amount of `utia`. + pub fn utia(amount: u64) -> Self { + Self { + denom: "utia".into(), + amount, + } + } +} + +/// Error codes associated with transaction responses. +// source https://github.com/celestiaorg/cosmos-sdk/blob/v1.25.1-sdk-v0.46.16/types/errors/errors.go#L38 +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize_repr, Deserialize_repr)] +#[repr(u32)] +pub enum ErrorCode { + /// No error + Success = 0, + /// Cannot parse a transaction + TxDecode = 2, + /// Sequence number (nonce) is incorrect for the signature + InvalidSequence = 3, + /// Request without sufficient authorization is handled + Unauthorized = 4, + /// Account cannot pay requested amount + InsufficientFunds = 5, + /// Request is unknown + UnknownRequest = 6, + /// Address is invalid + InvalidAddress = 7, + /// Pubkey is invalid + InvalidPubKey = 8, + /// Address is unknown + UnknownAddress = 9, + /// Coin is invalid + InvalidCoins = 10, + /// Gas exceeded + OutOfGas = 11, + /// Memo too large + MemoTooLarge = 12, + /// Fee is insufficient + InsufficientFee = 13, + /// Too many signatures + TooManySignatures = 14, + /// No signatures in transaction + NoSignatures = 15, + /// Error converting to json + JSONMarshal = 16, + /// Error converting from json + JSONUnmarshal = 17, + /// Request contains invalid data + InvalidRequest = 18, + /// Tx already exists in the mempool + TxInMempoolCache = 19, + /// Mempool is full + MempoolIsFull = 20, + /// Tx is too large + TxTooLarge = 21, + /// Key doesn't exist + KeyNotFound = 22, + /// Key password is invalid + WrongPassword = 23, + /// Tx intended signer does not match the given signer + InvalidSigner = 24, + /// Invalid gas adjustment + InvalidGasAdjustment = 25, + /// Invalid height + InvalidHeight = 26, + /// Invalid version + InvalidVersion = 27, + /// Chain-id is invalid + InvalidChainID = 28, + /// Invalid type + InvalidType = 29, + /// Tx rejected due to an explicitly set timeout height + TxTimeoutHeight = 30, + /// Unknown extension options. + UnknownExtensionOptions = 31, + /// Account sequence defined in the signer info doesn't match the account's actual sequence + WrongSequence = 32, + /// Packing a protobuf message to Any failed + PackAny = 33, + /// Unpacking a protobuf message from Any failed + UnpackAny = 34, + /// Internal logic error, e.g. an invariant or assertion that is violated + Logic = 35, + /// Conflict error, e.g. when two goroutines try to access the same resource and one of them fails + Conflict = 36, + /// Called a branch of a code which is currently not supported + NotSupported = 37, + /// Requested entity doesn't exist in the state + NotFound = 38, + /// Internal errors caused by external operation + IO = 39, + /// Min-gas-prices field in BaseConfig is empty + AppConfig = 40, + /// Invalid GasWanted value is supplied + InvalidGasLimit = 41, + /// Node recovered from panic + Panic = 111222, +} + +impl fmt::Display for ErrorCode { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> Result<(), fmt::Error> { + write!(f, "{:?}", self) + } +} + +impl TryFrom for ErrorCode { + type Error = Error; + + fn try_from(value: u32) -> Result { + let error_code = match value { + 0 => ErrorCode::Success, + 2 => ErrorCode::TxDecode, + 3 => ErrorCode::InvalidSequence, + 4 => ErrorCode::Unauthorized, + 5 => ErrorCode::InsufficientFunds, + 6 => ErrorCode::UnknownRequest, + 7 => ErrorCode::InvalidAddress, + 8 => ErrorCode::InvalidPubKey, + 9 => ErrorCode::UnknownAddress, + 10 => ErrorCode::InvalidCoins, + 11 => ErrorCode::OutOfGas, + 12 => ErrorCode::MemoTooLarge, + 13 => ErrorCode::InsufficientFee, + 14 => ErrorCode::TooManySignatures, + 15 => ErrorCode::NoSignatures, + 16 => ErrorCode::JSONMarshal, + 17 => ErrorCode::JSONUnmarshal, + 18 => ErrorCode::InvalidRequest, + 19 => ErrorCode::TxInMempoolCache, + 20 => ErrorCode::MempoolIsFull, + 21 => ErrorCode::TxTooLarge, + 22 => ErrorCode::KeyNotFound, + 23 => ErrorCode::WrongPassword, + 24 => ErrorCode::InvalidSigner, + 25 => ErrorCode::InvalidGasAdjustment, + 26 => ErrorCode::InvalidHeight, + 27 => ErrorCode::InvalidVersion, + 28 => ErrorCode::InvalidChainID, + 29 => ErrorCode::InvalidType, + 30 => ErrorCode::TxTimeoutHeight, + 31 => ErrorCode::UnknownExtensionOptions, + 32 => ErrorCode::WrongSequence, + 33 => ErrorCode::PackAny, + 34 => ErrorCode::UnpackAny, + 35 => ErrorCode::Logic, + 36 => ErrorCode::Conflict, + 37 => ErrorCode::NotSupported, + 38 => ErrorCode::NotFound, + 39 => ErrorCode::IO, + 40 => ErrorCode::AppConfig, + 41 => ErrorCode::InvalidGasLimit, + 111222 => ErrorCode::Panic, + _ => bail_validation!("error code ({}) unknown", value), + }; + Ok(error_code) + } +} + impl TryFrom for TxBody { type Error = Error; @@ -289,9 +454,9 @@ impl TryFrom for TxResponse { fn try_from(response: RawTxResponse) -> Result { Ok(TxResponse { height: response.height.try_into()?, - txhash: response.txhash, + txhash: response.txhash.parse()?, codespace: response.codespace, - code: response.code, + code: response.code.try_into()?, data: response.data, raw_log: response.raw_log, logs: response.logs, @@ -336,11 +501,16 @@ impl TryFrom for Fee { .into_iter() .map(TryInto::try_into) .collect::>()?; + Ok(Fee { amount, gas_limit: value.gas_limit, - payer: value.payer, - granter: value.granter, + payer: (!value.payer.is_empty()) + .then(|| value.payer.parse()) + .transpose()?, + granter: (!value.granter.is_empty()) + .then(|| value.granter.parse()) + .transpose()?, }) } } @@ -351,8 +521,8 @@ impl From for RawFee { RawFee { amount, gas_limit: value.gas_limit, - payer: value.payer, - granter: value.granter, + payer: value.payer.map(|acc| acc.to_string()).unwrap_or_default(), + granter: value.granter.map(|acc| acc.to_string()).unwrap_or_default(), } } }