diff --git a/voyager/modules/consensus/berachain/Cargo.toml b/voyager/modules/consensus/berachain/Cargo.toml index a22fc8f6b1..766f0125dc 100644 --- a/voyager/modules/consensus/berachain/Cargo.toml +++ b/voyager/modules/consensus/berachain/Cargo.toml @@ -4,8 +4,9 @@ name = "voyager-consensus-module-berachain" version = "0.1.0" [dependencies] -alloy = { workspace = true, features = ["rpc", "rpc-types", "transports", "transport-http", "transport-ws", "reqwest", "provider-ws"] } -beacon-api-types = { workspace = true, features = ["serde", "ssz"] } +alloy = { workspace = true, features = ["rpc", "rpc-types", "transports", "transport-http", "transport-ws", "reqwest", "provider-ws"] } +beacon-api-types = { workspace = true, features = ["serde", "ssz"] } +berachain-light-client-types = { workspace = true, features = ["proto", "serde"] } cometbft-rpc = { workspace = true } dashmap = { workspace = true } enumorph = { workspace = true } @@ -19,7 +20,6 @@ protos = { workspace = true } serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true } tendermint-light-client-types = { workspace = true, features = ["proto", "serde"] } -berachain-light-client-types = { workspace = true, features = ["proto", "serde"] } thiserror = { workspace = true } tokio = { workspace = true } tracing = { workspace = true } diff --git a/voyager/modules/consensus/tendermint/src/lib.rs b/voyager/modules/consensus/tendermint/src/lib.rs new file mode 100644 index 0000000000..c1957166bb --- /dev/null +++ b/voyager/modules/consensus/tendermint/src/lib.rs @@ -0,0 +1,272 @@ +use std::{ + fmt::Debug, + num::{NonZeroU64, ParseIntError}, +}; + +use ics23::ibc_api::SDK_SPECS; +use jsonrpsee::{ + core::{async_trait, RpcResult}, + types::ErrorObject, + Extensions, +}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use tendermint_light_client_types::{ClientState, ConsensusState, Fraction}; +use tracing::{debug, error, instrument}; +use unionlabs::{ + ibc::core::{client::height::Height, commitment::merkle_root::MerkleRoot}, + option_unwrap, result_unwrap, ErrorReporter, +}; +use voyager_message::{ + core::{ChainId, ConsensusType}, + module::{ConsensusModuleInfo, ConsensusModuleServer}, + rpc::json_rpc_error_to_error_object, + ConsensusModule, +}; +use voyager_vm::BoxDynError; + +#[derive(Debug, Clone)] +pub struct Module { + pub chain_id: ChainId, + + pub tm_client: cometbft_rpc::Client, + pub chain_revision: u64, + pub grpc_url: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Config { + pub ws_url: String, + pub grpc_url: String, +} + +impl ConsensusModule for Module { + type Config = Config; + + async fn new(config: Self::Config, info: ConsensusModuleInfo) -> Result { + let tm_client = cometbft_rpc::Client::new(config.ws_url).await?; + + let chain_id = tm_client.status().await?.node_info.network.to_string(); + + info.ensure_chain_id(&chain_id)?; + info.ensure_consensus_type(ConsensusType::TENDERMINT)?; + + let chain_revision = chain_id + .split('-') + .last() + .ok_or_else(|| ChainIdParseError { + found: chain_id.clone(), + source: None, + })? + .parse() + .map_err(|err| ChainIdParseError { + found: chain_id.clone(), + source: Some(err), + })?; + + Ok(Self { + tm_client, + chain_id: ChainId::new(chain_id), + chain_revision, + grpc_url: config.grpc_url, + }) + } +} + +#[derive(Debug, thiserror::Error)] +#[error("unable to parse chain id: expected format `-`, found `{found}`")] +pub struct ChainIdParseError { + found: String, + #[source] + source: Option, +} + +impl Module { + #[must_use] + pub fn make_height(&self, height: u64) -> Height { + Height::new_with_revision(self.chain_revision, height) + } + + async fn latest_height(&self, finalized: bool) -> Result { + let commit_response = self.tm_client.commit(None).await?; + + let mut height = commit_response + .signed_header + .header + .height + .inner() + .try_into() + .expect("value is >= 0; qed;"); + + if finalized && !commit_response.canonical { + debug!( + "commit is not canonical and finalized height was requested, \ + latest finalized height is the previous block" + ); + height -= 1; + } + + debug!(height, "latest height"); + + Ok(self.make_height(height)) + } +} + +#[async_trait] +impl ConsensusModuleServer for Module { + /// Query the latest finalized height of this chain. + #[instrument(skip_all, fields(chain_id = %self.chain_id))] + async fn query_latest_height(&self, _: &Extensions, finalized: bool) -> RpcResult { + self.latest_height(finalized) + .await + // TODO: Add more context here + .map_err(|err| ErrorObject::owned(-1, ErrorReporter(err).to_string(), None::<()>)) + } + + /// Query the latest finalized timestamp of this chain. + // TODO: Use a better timestamp type here + #[instrument(skip_all, fields(chain_id = %self.chain_id))] + async fn query_latest_timestamp(&self, _: &Extensions, finalized: bool) -> RpcResult { + let mut commit_response = self + .tm_client + .commit(None) + .await + .map_err(json_rpc_error_to_error_object)?; + + if finalized && commit_response.canonical { + debug!( + "commit is not canonical and finalized timestamp was \ + requested, fetching commit at previous block" + ); + commit_response = self + .tm_client + .commit(Some( + (u64::try_from(commit_response.signed_header.header.height.inner() - 1) + .expect("should be fine")) + .try_into() + .expect("should be fine"), + )) + .await + .map_err(json_rpc_error_to_error_object)?; + + if !commit_response.canonical { + error!( + ?commit_response, + "commit for previous height is not canonical? continuing \ + anyways, but this may cause issues downstream" + ); + } + } + + Ok(commit_response + .signed_header + .header + .time + .as_unix_nanos() + .try_into() + .expect("should be fine")) + } + + #[instrument(skip_all, fields(chain_id = %self.chain_id))] + async fn self_client_state(&self, _: &Extensions, height: Height) -> RpcResult { + let params = protos::cosmos::staking::v1beta1::query_client::QueryClient::connect( + self.grpc_url.clone(), + ) + .await + .unwrap() + .params(protos::cosmos::staking::v1beta1::QueryParamsRequest {}) + .await + .unwrap() + .into_inner() + .params + .unwrap(); + + let commit = self + .tm_client + .commit(Some(height.height().try_into().unwrap())) + .await + .unwrap(); + + let height = commit.signed_header.header.height; + + let unbonding_period = std::time::Duration::new( + params + .unbonding_time + .clone() + .unwrap() + .seconds + .try_into() + .unwrap(), + params + .unbonding_time + .clone() + .unwrap() + .nanos + .try_into() + .unwrap(), + ); + + Ok(serde_json::to_value(ClientState { + chain_id: self.chain_id.to_string(), + // https://github.com/cometbft/cometbft/blob/da0e55604b075bac9e1d5866cb2e62eaae386dd9/light/verifier.go#L16 + trust_level: Fraction { + numerator: 1, + denominator: const { option_unwrap!(NonZeroU64::new(3)) }, + }, + // https://github.com/cosmos/relayer/blob/23d1e5c864b35d133cad6a0ef06970a2b1e1b03f/relayer/chains/cosmos/provider.go#L177 + trusting_period: unionlabs::google::protobuf::duration::Duration::new( + (unbonding_period * 85 / 100).as_secs().try_into().unwrap(), + (unbonding_period * 85 / 100) + .subsec_nanos() + .try_into() + .unwrap(), + ) + .unwrap(), + unbonding_period: unionlabs::google::protobuf::duration::Duration::new( + unbonding_period.as_secs().try_into().unwrap(), + unbonding_period.subsec_nanos().try_into().unwrap(), + ) + .unwrap(), + // https://github.com/cosmos/relayer/blob/23d1e5c864b35d133cad6a0ef06970a2b1e1b03f/relayer/chains/cosmos/provider.go#L177 + max_clock_drift: const { + result_unwrap!(unionlabs::google::protobuf::duration::Duration::new( + 60 * 10, + 0 + )) + }, + frozen_height: None, + latest_height: Height::new_with_revision( + self.chain_revision, + height.inner().try_into().expect("is within bounds; qed;"), + ), + proof_specs: SDK_SPECS.into(), + upgrade_path: vec!["upgrade".into(), "upgradedIBCState".into()], + }) + .unwrap()) + } + + /// The consensus state on this chain at the specified `Height`. + #[instrument(skip_all, fields(chain_id = %self.chain_id))] + async fn self_consensus_state(&self, _: &Extensions, height: Height) -> RpcResult { + let commit = self + .tm_client + .commit(Some(height.height().try_into().unwrap())) + .await + .map_err(|e| { + ErrorObject::owned( + -1, + format!("error fetching commit: {}", ErrorReporter(e)), + None::<()>, + ) + })?; + + Ok(serde_json::to_value(&ConsensusState { + root: MerkleRoot { + hash: commit.signed_header.header.app_hash.into_encoding(), + }, + next_validators_hash: commit.signed_header.header.next_validators_hash, + timestamp: commit.signed_header.header.time, + }) + .unwrap()) + } +} diff --git a/voyager/plugins/client-update/berachain/Cargo.toml b/voyager/plugins/client-update/berachain/Cargo.toml new file mode 100644 index 0000000000..8b984613af --- /dev/null +++ b/voyager/plugins/client-update/berachain/Cargo.toml @@ -0,0 +1,29 @@ +[package] +edition = "2021" +name = "voyager-client-update-plugin-berachain" +version = "0.1.0" + +[dependencies] +alloy = { workspace = true, features = ["rpc", "rpc-types", "transports", "transport-http", "transport-ws", "reqwest", "provider-ws"] } +berachain-light-client-types = { workspace = true, features = ["proto", "serde"] } +cometbft-rpc = { workspace = true } +cometbft-types.workspace = true +dashmap = { workspace = true } +enumorph = { workspace = true } +ethereum-light-client-types = { workspace = true, features = ["serde"] } +futures = { workspace = true } +ics23 = { workspace = true } +jsonrpsee = { workspace = true, features = ["macros", "server", "tracing"] } +macros = { workspace = true } +num-bigint = { workspace = true } +prost = { workspace = true } +protos = { workspace = true } +serde = { workspace = true, features = ["derive"] } +serde_json = { workspace = true } +thiserror = { workspace = true } +tokio = { workspace = true } +tracing = { workspace = true } +tracing-subscriber = { workspace = true } +unionlabs = { workspace = true } +voyager-message = { workspace = true } +voyager-vm = { workspace = true } diff --git a/voyager/plugins/client-update/berachain/src/call.rs b/voyager/plugins/client-update/berachain/src/call.rs new file mode 100644 index 0000000000..4a5a4f1f78 --- /dev/null +++ b/voyager/plugins/client-update/berachain/src/call.rs @@ -0,0 +1,15 @@ +use enumorph::Enumorph; +use macros::model; +use unionlabs::ibc::core::client::height::Height; + +#[model] +#[derive(Enumorph)] +pub enum ModuleCall { + FetchUpdate(FetchUpdate), +} + +#[model] +pub struct FetchUpdate { + pub update_from: Height, + pub update_to: Height, +} diff --git a/voyager/plugins/client-update/berachain/src/callback.rs b/voyager/plugins/client-update/berachain/src/callback.rs new file mode 100644 index 0000000000..a332e95f9a --- /dev/null +++ b/voyager/plugins/client-update/berachain/src/callback.rs @@ -0,0 +1,6 @@ +use enumorph::Enumorph; +use macros::model; + +#[model] +#[derive(Enumorph)] +pub enum ModuleCallback {} diff --git a/voyager/plugins/client-update/berachain/src/data.rs b/voyager/plugins/client-update/berachain/src/data.rs new file mode 100644 index 0000000000..f52f66d6b6 --- /dev/null +++ b/voyager/plugins/client-update/berachain/src/data.rs @@ -0,0 +1,4 @@ +use macros::model; + +#[model] +pub enum ModuleData {} diff --git a/voyager/plugins/client-update/berachain/src/main.rs b/voyager/plugins/client-update/berachain/src/main.rs new file mode 100644 index 0000000000..bc49a2ac9f --- /dev/null +++ b/voyager/plugins/client-update/berachain/src/main.rs @@ -0,0 +1,197 @@ +use std::{collections::VecDeque, fmt::Debug, num::ParseIntError}; + +use alloy::{ + providers::{Provider, ProviderBuilder, RootProvider}, + transports::BoxTransport, +}; +use berachain_light_client_types::Header; +use jsonrpsee::{ + core::{async_trait, RpcResult}, + Extensions, +}; +use serde::{Deserialize, Serialize}; +use tracing::instrument; +use unionlabs::hash::H160; +use voyager_message::{ + call::{Call, FetchUpdateHeaders, WaitForTrustedHeight}, + core::{ChainId, IbcSpecId}, + data::{Data, DecodedHeaderMeta, OrderedHeaders}, + hook::UpdateHook, + module::{PluginInfo, PluginServer}, + DefaultCmd, ExtensionsExt, Plugin, PluginMessage, RawClientId, VoyagerClient, VoyagerMessage, +}; +use voyager_vm::{call, conc, data, pass::PassResult, seq, BoxDynError, Op, Visit}; + +use crate::{ + call::{FetchUpdate, ModuleCall}, + callback::ModuleCallback, +}; + +pub mod call; +pub mod callback; + +#[tokio::main(flavor = "multi_thread")] +async fn main() { + Module::run().await +} + +#[derive(Debug, Clone)] +pub struct Module { + pub l1_client_id: u32, + pub l1_chain_id: ChainId, + pub l2_chain_id: ChainId, + pub ibc_handler_address: H160, + pub eth_provider: RootProvider, + pub tm_client: cometbft_rpc::Client, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Config { + pub l1_client_id: u32, + pub l1_chain_id: ChainId, + pub l2_chain_id: ChainId, + pub ibc_handler_address: H160, + pub comet_ws_url: String, + pub eth_rpc_api: String, +} + +impl Plugin for Module { + type Call = ModuleCall; + type Callback = ModuleCallback; + + type Config = Config; + type Cmd = DefaultCmd; + + async fn new(config: Self::Config) -> Result { + let eth_provider = ProviderBuilder::new() + .on_builtin(&config.eth_rpc_api) + .await?; + + let chain_id = ChainId::new(eth_provider.get_chain_id().await?.to_string()); + + if chain_id != config.l2_chain_id { + return Err(format!( + "incorrect chain id: expected `{}`, but found `{}`", + config.l2_chain_id, chain_id + ) + .into()); + } + + let tm_client = cometbft_rpc::Client::new(config.comet_ws_url).await?; + + Ok(Self { + l1_client_id: config.l1_client_id, + l2_chain_id: config.l2_chain_id, + l1_chain_id: config.l1_chain_id, + ibc_handler_address: config.ibc_handler_address, + eth_provider, + tm_client, + }) + } + + fn info(config: Self::Config) -> PluginInfo { + PluginInfo { + name: plugin_name(&config.l2_chain_id), + interest_filter: UpdateHook::filter(&config.l2_chain_id), + } + } + + async fn cmd(_config: Self::Config, cmd: Self::Cmd) { + match cmd {} + } +} + +fn plugin_name(chain_id: &ChainId) -> String { + pub const PLUGIN_NAME: &str = env!("CARGO_PKG_NAME"); + + format!("{PLUGIN_NAME}/{}", chain_id) +} + +impl Module { + fn plugin_name(&self) -> String { + plugin_name(&self.l2_chain_id) + } +} + +#[derive(Debug, thiserror::Error)] +#[error("unable to parse chain id: expected format `-`, found `{found}`")] +pub struct ChainIdParseError { + found: String, + #[source] + source: Option, +} + +#[async_trait] +impl PluginServer for Module { + #[instrument(skip_all, fields(chain_id = %self.l2_chain_id))] + async fn run_pass( + &self, + _: &Extensions, + msgs: Vec>, + ) -> RpcResult> { + Ok(PassResult { + optimize_further: vec![], + ready: msgs + .into_iter() + .map(|mut op| { + UpdateHook::new(&self.l2_chain_id, |fetch| { + Call::Plugin(PluginMessage::new( + self.plugin_name(), + ModuleCall::from(FetchUpdate { + update_from: fetch.update_from, + update_to: fetch.update_to, + }), + )) + }) + .visit_op(&mut op); + + op + }) + .enumerate() + .map(|(i, op)| (vec![i], op)) + .collect(), + }) + } + + #[instrument(skip_all, fields(chain_id = %self.l2_chain_id))] + async fn call(&self, ext: &Extensions, msg: ModuleCall) -> RpcResult> { + let voy_client = ext.try_get::()?; + match msg { + ModuleCall::FetchUpdate(FetchUpdate { + update_from, + update_to, + }) => { + Ok(conc([ + call(FetchUpdateHeaders { + counterparty_chain_id: self.l1_chain_id.clone(), + chain_id: self.l1_chain_id.clone(), + update_from: client_meta.height, + update_to: latest_height, + }), + seq([call(WaitForTrustedHeight { + chain_id: self.l1_chain_id, + ibc_spec_id: todo!(), + client_id: RawClientId::new(self.l1_client_id), + height: todo!(), + })]), + ])) + // Ok(data(OrderedHeaders { + // headers: vec![( + // DecodedHeaderMeta { height: update_to }, + // serde_json::to_value(header).unwrap(), + // )], + // })) + } + } + } + + #[instrument(skip_all, fields(chain_id = %self.l2_chain_id))] + async fn callback( + &self, + _: &Extensions, + callback: ModuleCallback, + _data: VecDeque, + ) -> RpcResult> { + match callback {} + } +}