diff --git a/.github/.linkspector.yml b/.github/.linkspector.yml index f914de29..01cc6723 100644 --- a/.github/.linkspector.yml +++ b/.github/.linkspector.yml @@ -5,4 +5,8 @@ excludedDirs: - bolt-contracts/lib - testnets/helder -useGitIgnore: true \ No newline at end of file +useGitIgnore: true + +ignorePatterns: + - pattern: "^https://.*etherscan.io/.*$" + diff --git a/bolt-sidecar/src/chain_io/manager.rs b/bolt-sidecar/src/chain_io/manager.rs new file mode 100644 index 00000000..7c25f942 --- /dev/null +++ b/bolt-sidecar/src/chain_io/manager.rs @@ -0,0 +1,192 @@ +use std::str::FromStr; + +use alloy::{ + contract::Error as ContractError, + primitives::{Address, Bytes}, + providers::{ProviderBuilder, RootProvider}, + sol, + sol_types::SolInterface, + transports::{http::Http, TransportError}, +}; +use ethereum_consensus::primitives::BlsPublicKey; +use eyre::bail; +use reqwest::{Client, Url}; +use serde::Serialize; + +use BoltManagerContract::{BoltManagerContractErrors, BoltManagerContractInstance, ProposerStatus}; + +use crate::config::chain::Chain; + +use super::utils::{self, CompressedHash}; + +/// A wrapper over a BoltManagerContract that exposes various utility methods. +#[derive(Debug, Clone)] +pub struct BoltManager(BoltManagerContractInstance, RootProvider>>); + +impl BoltManager { + /// Creates a new BoltRegistry instance. Returns `None` if a canonical BoltManager contract is + /// not deployed on such chain. + /// + /// TODO: change after https://github.com/chainbound/bolt/issues/343 is completed + pub fn from_chain>(execution_client_url: U, chain: Chain) -> Option { + let address = chain.manager_address()?; + Some(Self::from_address(execution_client_url, address)) + } + + /// Creates a new BoltRegistry instance. + pub fn from_address>(execution_client_url: U, manager_address: Address) -> Self { + let provider = ProviderBuilder::new().on_http(execution_client_url.into()); + let registry = BoltManagerContract::new(manager_address, provider); + + Self(registry) + } + + /// Verify the provided validator public keys are registered in Bolt and are active + /// and their authorized operator is the given commitment signer public key. + /// + /// NOTE: it also checks the operator associated to the `commitment_signer_pubkey` exists. + pub async fn verify_validator_pubkeys( + &self, + keys: &[BlsPublicKey], + commitment_signer_pubkey: Address, + ) -> eyre::Result> { + let hashes = utils::pubkey_hashes(keys); + + let returndata = self.0.getProposerStatuses(hashes).call().await; + + // TODO: clean this after https://github.com/alloy-rs/alloy/issues/787 is merged + let error = match returndata.map(|data| data.statuses) { + Ok(statuses) => { + for status in &statuses { + if !status.active { + bail!( + "validator with public key hash {:?} is not active in Bolt", + status.pubkeyHash + ); + } else if status.operator != commitment_signer_pubkey { + bail!(generate_operator_keys_mismatch_error( + status.pubkeyHash, + commitment_signer_pubkey, + status.operator + )); + } + } + + return Ok(statuses); + } + Err(error) => match error { + ContractError::TransportError(TransportError::ErrorResp(err)) => { + let data = err.data.unwrap_or_default(); + let data = data.get().trim_matches('"'); + let data = Bytes::from_str(data)?; + + BoltManagerContractErrors::abi_decode(&data, true)? + } + e => return Err(e)?, + }, + }; + + match error { + BoltManagerContractErrors::ValidatorDoesNotExist(pubkey_hash) => { + bail!("validator with public key hash {:?} is not registered in Bolt", pubkey_hash); + } + BoltManagerContractErrors::InvalidQuery(_) => { + bail!("invalid zero public key hash"); + } + BoltManagerContractErrors::KeyNotFound(_) => { + bail!("operator associated with commitment signer public key {:?} is not registered in Bolt", commitment_signer_pubkey); + } + } + } +} + +fn generate_operator_keys_mismatch_error( + pubkey_hash: CompressedHash, + commitment_signer_pubkey: Address, + operator: Address, +) -> String { + format!( + "mismatch between commitment signer public key and authorized operator address for validator with public key hash {:?} in Bolt.\n - commitment signer public key: {:?}\n - authorized operator address: {:?}", + pubkey_hash, + commitment_signer_pubkey, + operator + ) +} + +sol! { + #[allow(missing_docs)] + #[sol(rpc)] + interface BoltManagerContract { + #[derive(Debug, Default, Serialize)] + struct ProposerStatus { + bytes20 pubkeyHash; + bool active; + address operator; + string operatorRPC; + address[] collaterals; + uint256[] amounts; + } + + function getProposerStatuses(bytes20[] calldata pubkeyHashes) public view returns (ProposerStatus[] memory statuses); + + function isOperator(address operator) external view returns (bool isOperator); + + error KeyNotFound(); + error InvalidQuery(); + #[derive(Debug)] + error ValidatorDoesNotExist(bytes20 pubkeyHash); + } +} + +#[cfg(test)] +mod tests { + use ::hex::FromHex; + use alloy::{hex, primitives::Address}; + use ethereum_consensus::primitives::BlsPublicKey; + use reqwest::Url; + + use crate::{ + chain_io::{manager::generate_operator_keys_mismatch_error, utils::pubkey_hash}, + config::chain::Chain, + }; + + use super::BoltManager; + + #[tokio::test] + #[ignore = "requires Chainbound tailnet"] + async fn test_verify_validator_pubkeys() { + let url = Url::parse("http://remotebeast:48545").expect("valid url"); + let manager = + BoltManager::from_chain(url, Chain::Holesky).expect("manager deployed on Holesky"); + + let operator = + Address::from_hex("725028b0b7c3db8b8242d35cd3a5779838b217b1").expect("valid address"); + + let keys = vec![BlsPublicKey::try_from([0; 48].as_ref()).expect("valid bls public key")]; + let commitment_signer_pubkey = Address::ZERO; + + let res = manager.verify_validator_pubkeys(&keys, commitment_signer_pubkey).await; + assert!(res.unwrap_err().to_string().contains("ValidatorDoesNotExist")); + + let keys = vec![ + BlsPublicKey::try_from( + hex!("87cbbfe6f08a0fd424507726cfcf5b9df2b2fd6b78a65a3d7bb6db946dca3102eb8abae32847d5a9a27e414888414c26") + .as_ref()).expect("valid bls public key")]; + let res = manager.verify_validator_pubkeys(&keys, commitment_signer_pubkey).await; + assert!( + res.unwrap_err().to_string() + == generate_operator_keys_mismatch_error( + pubkey_hash(&keys[0]), + commitment_signer_pubkey, + operator + ) + ); + + let commitment_signer_pubkey = operator; + let res = manager + .verify_validator_pubkeys(&keys, commitment_signer_pubkey) + .await + .expect("active validator and correct operator"); + assert!(res[0].active); + } +} diff --git a/bolt-sidecar/src/chain_io/mod.rs b/bolt-sidecar/src/chain_io/mod.rs new file mode 100644 index 00000000..951e507e --- /dev/null +++ b/bolt-sidecar/src/chain_io/mod.rs @@ -0,0 +1,5 @@ +/// Wrapper over the BoltManager contract +pub mod manager; + +/// Utilities and functions used in the Bolt contracts +pub mod utils; diff --git a/bolt-sidecar/src/chain_io/utils.rs b/bolt-sidecar/src/chain_io/utils.rs new file mode 100644 index 00000000..92169e4b --- /dev/null +++ b/bolt-sidecar/src/chain_io/utils.rs @@ -0,0 +1,52 @@ +use alloy::primitives::{FixedBytes, B512}; +use ethereum_consensus::primitives::BlsPublicKey; +use reth_primitives::keccak256; + +/// A 20-byte compressed hash of a BLS public key. +/// +/// Reference: https://github.com/chainbound/bolt/blob/bec46baae6d7c16dddd81e5e72710ca8e3064f82/bolt-contracts/script/holesky/validators/RegisterValidators.s.sol#L65-L69 +pub(crate) type CompressedHash = FixedBytes<20>; + +/// Hash the public keys of the proposers. This follows the same +/// implementation done on-chain in the BoltValidators contract. +pub fn pubkey_hashes(keys: &[BlsPublicKey]) -> Vec { + keys.iter().map(pubkey_hash).collect() +} + +/// Hash the public key of the proposer. This follows the same +/// implementation done on-chain in the BoltValidators contract. +/// +/// Reference: https://github.com/chainbound/bolt/blob/bec46baae6d7c16dddd81e5e72710ca8e3064f82/bolt-contracts/script/holesky/validators/RegisterValidators.s.sol#L65-L69 +pub fn pubkey_hash(key: &BlsPublicKey) -> CompressedHash { + let digest = pubkey_hash_digest(key); + let hash = keccak256(digest); + CompressedHash::from_slice(hash.get(0..20).expect("hash is longer than 20 bytes")) +} + +fn pubkey_hash_digest(key: &BlsPublicKey) -> B512 { + let mut onchain_pubkey_repr = B512::ZERO; + + // copy the pubkey bytes into the rightmost 48 bytes of the 512-bit buffer. + // the result should look like this: + // + // 0x00000000000000000000000000000000b427fd179b35ef085409e4a98fb3ab84ee29c689df5c64020eab0b20a4f91170f610177db172dc091682df627c9f4021 + // |<---------- 16 bytes ---------->||<----------------------------------------- 48 bytes ----------------------------------------->| + onchain_pubkey_repr[16..].copy_from_slice(key); + onchain_pubkey_repr +} + +#[cfg(test)] +mod tests { + use alloy::hex; + use ethereum_consensus::primitives::BlsPublicKey; + + use super::pubkey_hash; + + #[test] + fn test_public_key_hash() { + let bytes = hex!("87cbbfe6f08a0fd424507726cfcf5b9df2b2fd6b78a65a3d7bb6db946dca3102eb8abae32847d5a9a27e414888414c26").as_ref(); + let bls_public_key = BlsPublicKey::try_from(bytes).expect("valid bls public key"); + let hash = pubkey_hash(&bls_public_key); + assert_eq!(hex::encode(hash.as_slice()), "cf44d8bca49d695164be6796108cf788d8d056e1"); + } +} diff --git a/bolt-sidecar/src/config/chain.rs b/bolt-sidecar/src/config/chain.rs index b8c31b46..ccb902b9 100644 --- a/bolt-sidecar/src/config/chain.rs +++ b/bolt-sidecar/src/config/chain.rs @@ -4,6 +4,7 @@ use std::{ time::Duration, }; +use alloy::primitives::{address, Address}; use clap::{Args, ValueEnum}; use ethereum_consensus::deneb::{compute_fork_data_root, Root}; use serde::Deserialize; @@ -31,6 +32,11 @@ pub const DEFAULT_CHAIN_CONFIG: ChainConfig = ChainConfig { enable_unsafe_lookahead: false, }; +/// The address of the canonical BoltManager contract for the Holesky chain. +/// +/// https://holesky.etherscan.io/address/0x440202829b493F9FF43E730EB5e8379EEa3678CF +pub const MANAGER_ADDRESS_HOLESKY: Address = address!("440202829b493F9FF43E730EB5e8379EEa3678CF"); + /// Configuration for the chain the sidecar is running on. #[derive(Debug, Clone, Copy, Args, Deserialize)] pub struct ChainConfig { @@ -102,6 +108,14 @@ impl Chain { Chain::Kurtosis => [16, 0, 0, 56], } } + + /// Returns the address of the canonical BoltManager contract for a given chain, if present + pub const fn manager_address(&self) -> Option
{ + match self { + Chain::Holesky => Some(MANAGER_ADDRESS_HOLESKY), + _ => None, + } + } } impl Display for Chain { diff --git a/bolt-sidecar/src/crypto/ecdsa.rs b/bolt-sidecar/src/crypto/ecdsa.rs index 795890c1..3f7a63cf 100644 --- a/bolt-sidecar/src/crypto/ecdsa.rs +++ b/bolt-sidecar/src/crypto/ecdsa.rs @@ -1,6 +1,9 @@ use std::fmt::Debug; -use alloy::signers::{local::PrivateKeySigner, Signature as AlloySignature, Signer}; +use alloy::{ + primitives::Address, + signers::{local::PrivateKeySigner, Signature as AlloySignature, Signer}, +}; use secp256k1::{ecdsa::Signature, Message, PublicKey, SecretKey}; /// Trait for any types that can be signed and verified with ECDSA. @@ -57,12 +60,18 @@ impl ECDSASigner { /// A generic signing trait to generate ECDSA signatures. #[async_trait::async_trait] pub trait SignerECDSA: Send + Debug { + /// Returns the public key of the signer. + fn public_key(&self) -> Address; /// Sign the given hash and return the signature. async fn sign_hash(&self, hash: &[u8; 32]) -> eyre::Result; } #[async_trait::async_trait] impl SignerECDSA for PrivateKeySigner { + fn public_key(&self) -> Address { + self.address() + } + async fn sign_hash(&self, hash: &[u8; 32]) -> eyre::Result { Ok(Signer::sign_hash(self, hash.into()).await?) } diff --git a/bolt-sidecar/src/driver.rs b/bolt-sidecar/src/driver.rs index dd36caa3..9226f325 100644 --- a/bolt-sidecar/src/driver.rs +++ b/bolt-sidecar/src/driver.rs @@ -17,6 +17,7 @@ use tracing::{debug, error, info, warn}; use crate::{ builder::payload_fetcher::LocalPayloadFetcher, + chain_io::manager::BoltManager, commitments::{ server::{CommitmentsApiServer, Event as CommitmentEvent}, spec::Error as CommitmentError, @@ -161,6 +162,19 @@ impl SidecarDriver { commitment_signer: ECDSA, fetcher: C, ) -> eyre::Result { + // Verify the operator and validator keys with the bolt manager + if let Some(bolt_manager) = + BoltManager::from_chain(opts.execution_api_url.clone(), opts.chain.chain) + { + let commitment_signer_pubkey = commitment_signer.public_key(); + bolt_manager + .verify_validator_pubkeys( + &Vec::from_iter(constraint_signer.available_pubkeys()), + commitment_signer_pubkey, + ) + .await?; + } + let beacon_client = BeaconClient::new(opts.beacon_api_url.clone()); let execution = ExecutionState::new(fetcher, opts.limits).await?; diff --git a/bolt-sidecar/src/lib.rs b/bolt-sidecar/src/lib.rs index 53180f6c..73868ab1 100644 --- a/bolt-sidecar/src/lib.rs +++ b/bolt-sidecar/src/lib.rs @@ -48,6 +48,9 @@ pub mod state; mod signer; pub use signer::{commit_boost::CommitBoostSigner, SignerBLS}; +/// Utilities and contracts wrappers for interacting with the Bolt registry +pub mod chain_io; + /// Utilities for testing #[cfg(test)] mod test_util; diff --git a/bolt-sidecar/src/signer/commit_boost.rs b/bolt-sidecar/src/signer/commit_boost.rs index e15aec08..f9fa399e 100644 --- a/bolt-sidecar/src/signer/commit_boost.rs +++ b/bolt-sidecar/src/signer/commit_boost.rs @@ -1,6 +1,6 @@ use std::{str::FromStr, sync::Arc}; -use alloy::{rpc::types::beacon::BlsSignature, signers::Signature}; +use alloy::{primitives::Address, rpc::types::beacon::BlsSignature, signers::Signature}; use cb_common::{ commit::{client::SignerClient, error::SignerClientError, request::SignConsensusRequest}, signer::EcdsaPublicKey, @@ -142,6 +142,10 @@ impl CommitBoostSigner { #[async_trait::async_trait] impl SignerECDSA for CommitBoostSigner { + fn public_key(&self) -> Address { + Address::try_from(self.get_proxy_ecdsa_pubkey().as_ref()).expect("valid address") + } + async fn sign_hash(&self, hash: &[u8; 32]) -> eyre::Result { let request = SignProxyRequest::builder( *self.proxy_ecdsa.read().first().expect("proxy ecdsa key loaded"), diff --git a/bolt-sidecar/src/signer/keystore.rs b/bolt-sidecar/src/signer/keystore.rs index 999e1b4e..413f6951 100644 --- a/bolt-sidecar/src/signer/keystore.rs +++ b/bolt-sidecar/src/signer/keystore.rs @@ -203,8 +203,8 @@ mod tests { const KEYSTORES_DEFAULT_PATH_TEST: &str = "test_data/keys"; const KEYSTORES_SECRETS_DEFAULT_PATH_TEST: &str = "test_data/secrets"; - /// If `path` is `Some`, returns a clone of it. Otherwise, returns the path to the `fallback_relative_path` - /// starting from the root of the cargo project. + /// If `path` is `Some`, returns a clone of it. Otherwise, returns the path to the + /// `fallback_relative_path` starting from the root of the cargo project. fn make_path(relative_path: &str) -> PathBuf { let project_root = env!("CARGO_MANIFEST_DIR"); Path::new(project_root).join(relative_path) @@ -302,7 +302,8 @@ mod tests { let keystore_path = PathBuf::from(keystore_path); for test_keystore_json in tests_keystore_json { - // 1. Write the keystore in a `test-voting-keystore.json` file so we test both scrypt and PBDKF2 + // 1. Write the keystore in a `test-voting-keystore.json` file so we test both scrypt + // and PBDKF2 let mut tmp_keystore_file = File::create(keystore_path.join("test-voting-keystore.json")) diff --git a/bolt-sidecar/src/state/consensus.rs b/bolt-sidecar/src/state/consensus.rs index 7b41c4a2..b4bcae81 100644 --- a/bolt-sidecar/src/state/consensus.rs +++ b/bolt-sidecar/src/state/consensus.rs @@ -117,8 +117,8 @@ impl ConsensusState { } // If the request is for the next slot, check if it's within the commitment deadline - if req.slot == self.latest_slot + 1 - && self.latest_slot_timestamp + self.commitment_deadline_duration < Instant::now() + if req.slot == self.latest_slot + 1 && + self.latest_slot_timestamp + self.commitment_deadline_duration < Instant::now() { return Err(ConsensusError::DeadlineExceeded); } @@ -161,7 +161,8 @@ impl ConsensusState { Ok(()) } - /// Fetch proposer duties for the given epoch and the next one if the unsafe lookahead flag is set + /// Fetch proposer duties for the given epoch and the next one if the unsafe lookahead flag is + /// set async fn fetch_proposer_duties(&mut self, epoch: u64) -> Result<(), ConsensusError> { let duties = if self.unsafe_lookahead_enabled { let two_epoch_duties = join!( @@ -200,9 +201,9 @@ impl ConsensusState { /// Returns the furthest slot for which a commitment request is considered valid, whether in /// the current epoch or next epoch (if unsafe lookahead is enabled) fn furthest_slot(&self) -> u64 { - self.epoch.start_slot - + SLOTS_PER_EPOCH - + if self.unsafe_lookahead_enabled { SLOTS_PER_EPOCH } else { 0 } + self.epoch.start_slot + + SLOTS_PER_EPOCH + + if self.unsafe_lookahead_enabled { SLOTS_PER_EPOCH } else { 0 } } } @@ -324,8 +325,8 @@ mod tests { }; let epoch = - state.beacon_api_client.get_beacon_header(BlockId::Head).await?.header.message.slot - / SLOTS_PER_EPOCH; + state.beacon_api_client.get_beacon_header(BlockId::Head).await?.header.message.slot / + SLOTS_PER_EPOCH; state.fetch_proposer_duties(epoch).await?; assert_eq!(state.epoch.proposer_duties.len(), SLOTS_PER_EPOCH as usize * 2);