From dab4a731ff3b37b4caf74f38b94400b03b237e36 Mon Sep 17 00:00:00 2001 From: Hussein Ait Lahcen Date: Tue, 17 Dec 2024 19:13:16 +0100 Subject: [PATCH] feat(voyager): introduce berachain client update plugin --- Cargo.lock | 2 + lib/cometbft-rpc/src/lib.rs | 6 + .../modules/consensus/berachain/Cargo.toml | 6 +- .../modules/consensus/tendermint/src/main.rs | 275 ++++++++++++++++- .../client-update/berachain/Cargo.toml | 31 ++ .../client-update/berachain/src/call.rs | 17 ++ .../client-update/berachain/src/callback.rs | 6 + .../client-update/berachain/src/data.rs | 4 + .../client-update/berachain/src/main.rs | 281 ++++++++++++++++++ 9 files changed, 623 insertions(+), 5 deletions(-) create mode 100644 voyager/plugins/client-update/berachain/Cargo.toml create mode 100644 voyager/plugins/client-update/berachain/src/call.rs create mode 100644 voyager/plugins/client-update/berachain/src/callback.rs create mode 100644 voyager/plugins/client-update/berachain/src/data.rs create mode 100644 voyager/plugins/client-update/berachain/src/main.rs diff --git a/Cargo.lock b/Cargo.lock index cf5489ac02..73fd8fa591 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -13452,6 +13452,7 @@ name = "voyager-client-update-plugin-berachain" version = "0.1.0" dependencies = [ "alloy", + "beacon-api-types", "berachain-light-client-types", "cometbft-rpc", "cometbft-types", @@ -13467,6 +13468,7 @@ dependencies = [ "protos", "serde", "serde_json", + "ssz", "thiserror", "tokio", "tracing", diff --git a/lib/cometbft-rpc/src/lib.rs b/lib/cometbft-rpc/src/lib.rs index 724f177645..a8b9c13ee6 100644 --- a/lib/cometbft-rpc/src/lib.rs +++ b/lib/cometbft-rpc/src/lib.rs @@ -201,6 +201,12 @@ impl Client { .await } + pub async fn block_by_hash(&self, hash: H256) -> Result { + self.inner + .request("block_by_hash", (hash.to_string(),)) + .await + } + pub async fn blockchain( &self, min_height: NonZeroU64, 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/main.rs b/voyager/modules/consensus/tendermint/src/main.rs index a779cfbc0c..f5e21aac1a 100644 --- a/voyager/modules/consensus/tendermint/src/main.rs +++ b/voyager/modules/consensus/tendermint/src/main.rs @@ -1,6 +1,277 @@ -use voyager_message::ConsensusModule; +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; #[tokio::main(flavor = "multi_thread")] async fn main() { - voyager_consensus_module_tendermint::Module::run().await + Module::run().await +} + +#[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..44af29ed26 --- /dev/null +++ b/voyager/plugins/client-update/berachain/Cargo.toml @@ -0,0 +1,31 @@ +[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"] } +beacon-api-types = { workspace = true, features = ["serde", "ssz"] } +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 } +ssz = { 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..eaba63e53d --- /dev/null +++ b/voyager/plugins/client-update/berachain/src/call.rs @@ -0,0 +1,17 @@ +use enumorph::Enumorph; +use macros::model; +use unionlabs::ibc::core::client::height::Height; +use voyager_message::core::ChainId; + +#[model] +#[derive(Enumorph)] +pub enum ModuleCall { + FetchUpdate(FetchUpdate), +} + +#[model] +pub struct FetchUpdate { + pub counterparty_chain_id: ChainId, + 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..7e9101433e --- /dev/null +++ b/voyager/plugins/client-update/berachain/src/main.rs @@ -0,0 +1,281 @@ +use std::{collections::VecDeque, fmt::Debug, num::ParseIntError}; + +use alloy::{ + providers::{Provider, ProviderBuilder, RootProvider}, + transports::BoxTransport, +}; +use beacon_api_types::{ExecutionPayloadHeaderSsz, Mainnet}; +use berachain_light_client_types::Header; +use ethereum_light_client_types::AccountProof; +use jsonrpsee::{ + core::{async_trait, RpcResult}, + types::ErrorObject, + Extensions, +}; +use serde::{Deserialize, Serialize}; +use tracing::instrument; +use unionlabs::{ + berachain::LATEST_EXECUTION_PAYLOAD_HEADER_PREFIX, + encoding::{DecodeAs, Ssz}, + hash::H160, + ibc::core::commitment::merkle_proof::MerkleProof, + ErrorReporter, +}; +use voyager_message::{ + call::{Call, FetchUpdateHeaders, WaitForTrustedHeight}, + core::{ChainId, IbcSpecId}, + data::{Data, DecodedHeaderMeta, OrderedHeaders}, + hook::UpdateHook, + into_value, + module::{PluginInfo, PluginServer}, + DefaultCmd, Plugin, PluginMessage, RawClientId, 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) + } + + // TODO: deduplicate with eth client update module? + pub async fn fetch_account_update(&self, block_number: u64) -> RpcResult { + let account_update = self + .eth_provider + .get_proof(self.ibc_handler_address.into(), vec![]) + .block_id(block_number.into()) + .await + .map_err(|e| { + ErrorObject::owned( + -1, + ErrorReporter(e).with_message("error fetching account update"), + None::<()>, + ) + })?; + + Ok(AccountProof { + storage_root: account_update.storage_hash.into(), + proof: account_update + .account_proof + .into_iter() + .map(|x| x.to_vec()) + .collect(), + }) + } +} + +#[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 { + counterparty_chain_id: fetch.counterparty_chain_id.clone(), + 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, _: &Extensions, msg: ModuleCall) -> RpcResult> { + match msg { + ModuleCall::FetchUpdate(FetchUpdate { + counterparty_chain_id, + update_from, + update_to, + }) => { + // NOTE: the implementation is simple because the + // cometbft/beacon/execution heights are guaranteed to be the + // same + let query_result = self + .tm_client + .abci_query( + "store/beacon/key", + [LATEST_EXECUTION_PAYLOAD_HEADER_PREFIX], + // proof for height H must be queried at H-1 + Some((update_to.height() as i64 - 1).try_into().unwrap()), + true, + ) + .await + .unwrap(); + + let execution_header = ExecutionPayloadHeaderSsz::::decode_as::( + query_result.response.value.expect("big trouble").as_ref(), + ) + .expect("big trouble"); + + let account_proof = self.fetch_account_update(update_to.height()).await?; + + let header = Header { + l1_height: update_to, + execution_header: execution_header.into(), + execution_header_proof: + MerkleProof::try_from(protos::ibc::core::commitment::v1::MerkleProof { + proofs: query_result + .response + .proof_ops + .unwrap() + .ops + .into_iter() + .map(|op| { + ::decode( + &*op.data, + ) + .unwrap() + }) + .collect::>(), + }).unwrap(), + account_proof, + }; + + // Recursively dispatch a L1 update before dispatching the L2 update. + Ok(conc([ + call(FetchUpdateHeaders { + counterparty_chain_id: counterparty_chain_id.clone(), + chain_id: self.l1_chain_id.clone(), + update_from, + update_to, + }), + seq([call(WaitForTrustedHeight { + chain_id: counterparty_chain_id, + ibc_spec_id: IbcSpecId::new(IbcSpecId::UNION), + // TODO: abstract away the L1 client id and read it from + // the L2 client state (l2_client_id) on the + // `counterparty_chain_id` + client_id: RawClientId::new(self.l1_client_id), + height: update_to, + })]), + data(OrderedHeaders { + headers: vec![( + DecodedHeaderMeta { height: update_to }, + into_value(header), + )], + }), + ])) + } + } + } + + #[instrument(skip_all, fields(chain_id = %self.l2_chain_id))] + async fn callback( + &self, + _: &Extensions, + callback: ModuleCallback, + _data: VecDeque, + ) -> RpcResult> { + match callback {} + } +}