diff --git a/Cargo.lock b/Cargo.lock index b32d4af86..84ebdf6de 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2846,6 +2846,31 @@ dependencies = [ "tokio-stream", ] +[[package]] +name = "beefy-verifier" +version = "0.1.0" +dependencies = [ + "alloy-sol-types 1.5.7", + "anyhow", + "beefy-prover", + "beefy-verifier-primitives", + "ckb-merkle-mountain-range", + "futures", + "ismp", + "k256", + "log", + "parity-scale-codec", + "polkadot-sdk", + "primitive-types 0.13.1", + "rs_merkle 1.5.0", + "sp1-verifier 6.0.2 (git+https://github.com/dharjeezy/sp1.git?branch=dami%2Fsp1-verifier-wasm-compatible-changes)", + "subxt 0.42.1", + "subxt-core 0.42.1", + "subxt-utils", + "thiserror 2.0.18", + "tokio", +] + [[package]] name = "beefy-verifier-primitives" version = "0.1.1" @@ -9218,7 +9243,7 @@ dependencies = [ [[package]] name = "hyperbridge" -version = "1.4.2" +version = "1.4.3" dependencies = [ "clap", "futures", @@ -10345,6 +10370,21 @@ dependencies = [ "scale-info", ] +[[package]] +name = "ismp-beefy" +version = "0.1.0" +dependencies = [ + "anyhow", + "beefy-verifier", + "beefy-verifier-primitives", + "ismp", + "pallet-ismp", + "parity-scale-codec", + "polkadot-sdk", + "primitive-types 0.13.1", + "substrate-state-machine", +] + [[package]] name = "ismp-bsc" version = "0.1.1" @@ -15237,6 +15277,8 @@ dependencies = [ "alloy-primitives 1.5.7", "alloy-sol-types 1.5.7", "anyhow", + "beefy-prover", + "beefy-verifier-primitives", "bls_on_arkworks", "ckb-merkle-mountain-range", "crypto-utils", @@ -15252,6 +15294,7 @@ dependencies = [ "ismp", "ismp-bsc", "ismp-grandpa", + "ismp-parachain", "ismp-pharos", "ismp-solidity-abi", "ismp-sync-committee", @@ -15288,6 +15331,7 @@ dependencies = [ "sp-core", "substrate-state-machine", "subxt 0.42.1", + "subxt-core 0.42.1", "subxt-utils", "token-gateway-primitives", "tokio", @@ -26074,7 +26118,7 @@ dependencies = [ "sp1-recursion-executor", "sp1-recursion-gnark-ffi", "sp1-recursion-machine", - "sp1-verifier", + "sp1-verifier 6.0.2 (registry+https://github.com/rust-lang/crates.io-index)", "static_assertions", "sysinfo", "tempfile", @@ -26212,7 +26256,7 @@ dependencies = [ "sp1-hypercube", "sp1-primitives", "sp1-recursion-compiler", - "sp1-verifier", + "sp1-verifier 6.0.2 (registry+https://github.com/rust-lang/crates.io-index)", "tempfile", "tracing", ] @@ -26291,7 +26335,7 @@ dependencies = [ "sp1-prover-types", "sp1-recursion-executor", "sp1-recursion-gnark-ffi", - "sp1-verifier", + "sp1-verifier 6.0.2 (registry+https://github.com/rust-lang/crates.io-index)", "strum 0.27.2", "tempfile", "thiserror 1.0.69", @@ -26329,6 +26373,20 @@ dependencies = [ "thiserror 2.0.18", ] +[[package]] +name = "sp1-verifier" +version = "6.0.2" +source = "git+https://github.com/dharjeezy/sp1.git?branch=dami%2Fsp1-verifier-wasm-compatible-changes#e8fa31ddb4f8796a8c2b838d99f40abb6e7d9cce" +dependencies = [ + "blake3", + "cfg-if", + "dirs", + "hex", + "sha2 0.10.9", + "substrate-bn-succinct-rs", + "thiserror 2.0.18", +] + [[package]] name = "spin" version = "0.5.2" diff --git a/Cargo.toml b/Cargo.toml index 1f0af96dc..765b51a53 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -43,6 +43,7 @@ members = [ "modules/ismp/clients/ismp-optimism", "modules/ismp/clients/polygon", "modules/ismp/clients/tendermint", + "modules/ismp/clients/beefy", "modules/pallets/collator-manager", # cryptography @@ -51,6 +52,7 @@ members = [ "modules/consensus/sync-committee/primitives", "modules/consensus/beefy/primitives", "modules/consensus/beefy/prover", + "modules/consensus/beefy/verifier", "modules/consensus/geth-primitives", "modules/consensus/bsc/verifier", "modules/consensus/bsc/prover", @@ -232,6 +234,7 @@ reqwest-middleware = "0.2.4" reqwest = { version="0.11.14", features=["json"]} prost = { version = "0.13.0", default-features = false } impl-trait-for-tuples = "0.2.3" +k256 = { version = "0.13.3", default-features = false, features = ["ecdsa"] } # arkworks ark-ec = { version = "0.4.2", default-features = false } @@ -276,6 +279,7 @@ subxt-utils = { path = "modules/utils/subxt", default-features = false } # consensus provers & verifiers beefy-verifier-primitives = { path = "./modules/consensus/beefy/primitives", default-features = false } beefy-prover = { path = "./modules/consensus/beefy/prover" } +beefy-verifier = { path = "./modules/consensus/beefy/verifier", default-features = false } bsc-prover = { path = "./modules/consensus/bsc/prover" } bsc-verifier = { path = "./modules/consensus/bsc/verifier", default-features = false } geth-primitives = { path = "./modules/consensus/geth-primitives", default-features = false } @@ -323,6 +327,7 @@ pallet-intents-coprocessor = { path = "modules/pallets/intents-coprocessor", def pallet-intents-rpc = { path = "modules/pallets/intents-coprocessor/rpc" } pallet-token-gateway-inspector = { path = "modules/pallets/token-gateway-inspector", default-features = false } pallet-bridge-airdrop = { path = "modules/pallets/bridge-drop", default-features = false } +ismp-beefy = { path = "modules/ismp/clients/beefy", default-features = false } # merkle trees ethereum-triedb = { version = "0.1.1", path = "./modules/trees/ethereum", default-features = false } @@ -404,4 +409,6 @@ features = ["derive"] version = "0.5.0" default-features = false -[patch.crates-io] +[workspace.dependencies.rs_merkle] +version = "1.2.0" +default-features = false diff --git a/modules/consensus/beefy/primitives/Cargo.toml b/modules/consensus/beefy/primitives/Cargo.toml index 7ffae68a5..30203121f 100644 --- a/modules/consensus/beefy/primitives/Cargo.toml +++ b/modules/consensus/beefy/primitives/Cargo.toml @@ -22,7 +22,6 @@ features = [ "sp-core", "sp-consensus-beefy", "sp-mmr-primitives", - "sp-io", ] [features] diff --git a/modules/consensus/beefy/primitives/src/lib.rs b/modules/consensus/beefy/primitives/src/lib.rs index 50e30c801..89b8e43a2 100644 --- a/modules/consensus/beefy/primitives/src/lib.rs +++ b/modules/consensus/beefy/primitives/src/lib.rs @@ -89,25 +89,27 @@ pub struct PartialMmrLeaf { pub beefy_next_authority_set: BeefyAuthoritySet, } -#[derive(sp_std::fmt::Debug, Clone, PartialEq, Eq)] +#[derive(sp_std::fmt::Debug, Clone, PartialEq, Eq, Encode, Decode)] /// Parachain header and metadata needed for merkle inclusion proof pub struct ParachainHeader { /// scale encoded parachain header pub header: Vec, /// leaf index for parachain heads proof - pub index: usize, + pub index: u32, /// ParaId for parachain pub para_id: u32, } -#[derive(sp_std::fmt::Debug, Clone, PartialEq, Eq)] +#[derive(sp_std::fmt::Debug, Clone, PartialEq, Eq, Encode, Decode)] /// Parachain proofs definition pub struct ParachainProof { /// List of parachains we have a proof for pub parachains: Vec, /// Proof for parachain header inclusion in the parachain headers root - pub proof: Vec>, + pub proof: Vec>, + /// Total leaves count for the proof + pub total_leaves: u32, } #[derive(sp_std::fmt::Debug, Clone, PartialEq, Eq)] @@ -119,6 +121,86 @@ pub struct ConsensusMessage { pub mmr: MmrProof, } +/// Represents a node in a Merkle proof, containing a hash and its index at a specific layer. +#[derive(sp_std::fmt::Debug, Clone, PartialEq, Eq, Encode, Decode)] +pub struct Node { + /// The positional index of the node in its layer of the Merkle tree. + pub index: u32, + /// The hash of the node. + pub hash: H256, +} + +/// Represents a canonical BEEFY Merkle Mountain Range (MMR) leaf. +/// +/// This struct contains the essential data about a finalized block that is committed to the MMR. +#[derive(sp_std::fmt::Debug, Clone, PartialEq, Eq, Encode, Decode)] +pub struct BeefyMmrLeaf { + /// The version of the MMR leaf format. + pub version: MmrLeafVersion, + /// A tuple containing the block number and hash of the parent block. + pub parent_block_and_hash: (u32, H256), + /// The authority set that will be active in the next BEEFY session. + pub beefy_next_authority_set: BeefyAuthoritySet, + /// The sequential index of this leaf in the MMR. + pub leaf_index: u32, + /// An extra data field + pub extra: H256, +} + +/// Represents the proof components for verifying the relay chain's consensus state +#[derive(sp_std::fmt::Debug, Clone, PartialEq, Eq, Encode, Decode)] +pub struct RelaychainProof { + /// Signed commitment + pub signed_commitment: SignedCommitment, + /// Latest leaf added to mmr + pub latest_mmr_leaf: BeefyMmrLeaf, + /// Proof for the latest mmr leaf + pub mmr_proof: Vec, + /// Proof for authorities in current/next session + pub proof: Vec>, +} + +/// Represents a complete BEEFY consensus proof. +/// +/// This proof contains all the necessary data to verify a BEEFY finality proof from the relay chain +/// and to prove the inclusion of specific parachain headers within that finalized block. +#[derive(sp_std::fmt::Debug, Clone, PartialEq, Eq, Encode, Decode)] +pub struct BeefyConsensusProof { + /// The proof items for the relay chain consensus + pub relay: RelaychainProof, + /// The proof items for parachain headers + pub parachain: ParachainProof, +} + +/// Proof type identifier for naive proofs +pub const PROOF_TYPE_NAIVE: u8 = 0x00; + +/// Proof type identifier for SP1 ZK proofs +pub const PROOF_TYPE_SP1: u8 = 0x01; + +/// SP1 BEEFY proof +/// The proof bytes are prefixed with PROOF_TYPE_SP1 (0x01) by the prover. +#[derive(sp_std::fmt::Debug, Clone, PartialEq, Eq, Encode, Decode)] +pub struct Sp1BeefyProof { + /// BEEFY commitment (block number + validator set ID) + pub commitment: Sp1MiniCommitment, + /// Latest MMR leaf data + pub mmr_leaf: BeefyMmrLeaf, + /// Parachain headers finalized by this proof + pub parachain: ParachainProof, + /// SP1 proof bytes + pub proof: Vec, +} + +/// Minimal BEEFY commitment for SP1 proofs +#[derive(sp_std::fmt::Debug, Clone, PartialEq, Eq, Encode, Decode)] +pub struct Sp1MiniCommitment { + /// Relay chain block number + pub block_number: u32, + /// Validator set ID that signed the commitment + pub validator_set_id: u64, +} + #[cfg(feature = "std")] #[derive(Clone, serde::Serialize, serde::Deserialize)] /// finality proof diff --git a/modules/consensus/beefy/prover/src/lib.rs b/modules/consensus/beefy/prover/src/lib.rs index 151193b09..4023c4627 100644 --- a/modules/consensus/beefy/prover/src/lib.rs +++ b/modules/consensus/beefy/prover/src/lib.rs @@ -255,26 +255,39 @@ impl Prover { ) .await?; - let (parachains, indices): (Vec<_>, Vec<_>) = heads - .clone() - .into_iter() - .enumerate() - .filter_map(|(index, (para_id, header))| { - if self.para_ids.contains(¶_id) { - Some((ParachainHeader { header, index, para_id }, index)) - } else { - None - } + let (parachains, indices): (Vec<_>, Vec<_>) = self + .para_ids + .iter() + .map(|id| { + let index = heads.iter().position(|(i, _)| *i == *id).expect("ParaId should exist"); + ( + ParachainHeader { + header: heads[index].1.clone(), + index: index as u32, + para_id: heads[index].0, + }, + index, + ) }) .unzip(); - let proof = if parachains.len() > 0 { - let leaves = heads.iter().map(|pair| keccak_256(&pair.encode())).collect::>(); - util::merkle_proof(&leaves, &indices) - } else { - vec![] - }; - let parachain = ParachainProof { parachains, proof }; + let leaves = heads.iter().map(|pair| keccak_256(&pair.encode())).collect::>(); + let proof = util::merkle_proof(&leaves, &indices); + + let proof = proof + .into_iter() + .map(|layer| { + layer + .into_iter() + .map(|(index, hash)| beefy_verifier_primitives::Node { + index: index as u32, + hash: H256::from(hash), + }) + .collect() + }) + .collect(); + + let parachain = ParachainProof { parachains, proof, total_leaves: leaves.len() as u32 }; Ok((ConsensusMessage { mmr, parachain }, bitmap)) } @@ -342,26 +355,39 @@ impl Prover { ) .await?; - let (parachains, indices): (Vec<_>, Vec<_>) = heads - .clone() - .into_iter() - .enumerate() - .filter_map(|(index, (para_id, header))| { - if self.para_ids.contains(¶_id) { - Some((ParachainHeader { header, index, para_id }, index)) - } else { - None - } + let (parachains, indices): (Vec<_>, Vec<_>) = self + .para_ids + .iter() + .map(|id| { + let index = heads.iter().position(|(i, _)| *i == *id).expect("ParaId should exist"); + ( + ParachainHeader { + header: heads[index].1.clone(), + index: index as u32, + para_id: heads[index].0, + }, + index, + ) }) .unzip(); - let proof = if parachains.len() > 0 { - let leaves = heads.iter().map(|pair| keccak_256(&pair.encode())).collect::>(); - util::merkle_proof(&leaves, &indices) - } else { - vec![] - }; - let parachain = ParachainProof { parachains, proof }; + let leaves = heads.iter().map(|pair| keccak_256(&pair.encode())).collect::>(); + let proof = util::merkle_proof(&leaves, &indices); + + let proof = proof + .into_iter() + .map(|layer| { + layer + .into_iter() + .map(|(index, hash)| beefy_verifier_primitives::Node { + index: index as u32, + hash: H256::from(hash), + }) + .collect() + }) + .collect(); + + let parachain = ParachainProof { parachains, proof, total_leaves: leaves.len() as u32 }; Ok(ConsensusMessage { mmr, parachain }) } diff --git a/modules/consensus/beefy/verifier/Cargo.toml b/modules/consensus/beefy/verifier/Cargo.toml new file mode 100644 index 000000000..82aedc1f9 --- /dev/null +++ b/modules/consensus/beefy/verifier/Cargo.toml @@ -0,0 +1,57 @@ +[package] +name = "beefy-verifier" +version = "0.1.0" +edition = "2024" +authors = ["Polytope Labs "] +description = "Verifier for the BEEFY consensus client" +publish = false + +[package.metadata.docs.rs] +targets = ["x86_64-unknown-linux-gnu"] + +[dependencies] +log = { workspace = true } +anyhow = { workspace = true, default-features = false } +primitive-types = { workspace = true, default-features = false, features = ["codec"] } +codec = { workspace = true, features = ["derive"], default-features = false } +beefy-verifier-primitives = { workspace = true, default-features = false } +ismp = { workspace = true, default-features = false } +merkle-mountain-range = {workspace = true, default-features = false} +rs_merkle = {workspace = true, default-features = false} +thiserror = { workspace = true } +sp1-verifier = { git = "https://github.com/dharjeezy/sp1.git", branch = "dami/sp1-verifier-wasm-compatible-changes", default-features = false } +alloy-sol-types = { workspace = true, default-features = false } + +[dependencies.polkadot-sdk] +workspace = true +features = [ + "sp-consensus-beefy", + "sp-core", + "sp-runtime" +] + +[dev-dependencies] +k256 = { workspace = true } +beefy-prover = { workspace = true } +subxt = { workspace = true, default-features = true } +subxt-core = { workspace = true, default-features = true } +subxt-utils = { workspace = true, default-features = true } +futures = { workspace = true } +tokio = { workspace = true } + + +[features] +default = ["std"] +std = [ + "log/std", + "anyhow/std", + "codec/std", + "ismp/std", + "primitive-types/std", + "beefy-verifier-primitives/std", + "polkadot-sdk/std", + "merkle-mountain-range/std", + "rs_merkle/std", + "sp1-verifier/std", + "alloy-sol-types/std", +] \ No newline at end of file diff --git a/modules/consensus/beefy/verifier/src/error.rs b/modules/consensus/beefy/verifier/src/error.rs new file mode 100644 index 000000000..0a817eb43 --- /dev/null +++ b/modules/consensus/beefy/verifier/src/error.rs @@ -0,0 +1,28 @@ +use alloc::string::String; +use thiserror::Error; + +#[derive(Error, Debug)] +pub enum Error { + #[error("Stale height: trusted height {trusted_height} >= current_height {current_height}")] + StaleHeight { trusted_height: u32, current_height: u32 }, + #[error("Super majority of signatures required")] + SuperMajorityRequired, + #[error("Unkown authority set id {id}")] + UnknownAuthoritySet { id: u64 }, + #[error("MMR root hash is missing from commitment payload")] + MmrRootHashMissing, + #[error("Invalid MMR root hash length: expected 32, found {len}")] + InvalidMmrRootHashLength { len: usize }, + #[error("Failed to recover public key from signature")] + FailedToRecoverPublicKey, + #[error("Invalid authorities proof")] + InvalidAuthoritiesProof, + #[error("MMR verification failed during calculation: {0}")] + MmrVerificationFailed(String), + #[error("Invalid MMR proof: calculated root does not match provided root")] + InvalidMmrProof, + #[error("Invalid parachain header proof: merkle proof verification failed")] + InvalidParachainProof, + #[error("SP1 proof verification failed")] + Sp1VerificationFailed, +} diff --git a/modules/consensus/beefy/verifier/src/lib.rs b/modules/consensus/beefy/verifier/src/lib.rs new file mode 100644 index 000000000..117395963 --- /dev/null +++ b/modules/consensus/beefy/verifier/src/lib.rs @@ -0,0 +1,278 @@ +// Copyright (C) Polytope Labs Ltd. +// SPDX-License-Identifier: Apache-2.0 + +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +#![cfg_attr(not(feature = "std"), no_std)] + +extern crate alloc; + +mod error; +pub mod sp1; +#[cfg(test)] +mod test; + +use alloc::{ + format, + string::{String, ToString}, + vec, + vec::Vec, +}; +use core::marker::PhantomData; + +use crate::error::Error; +use beefy_verifier_primitives::{ + BeefyConsensusProof, ConsensusState, ParachainHeader, ParachainProof, RelaychainProof, +}; +use codec::Encode; +use ismp::messaging::Keccak256; +use merkle_mountain_range::{ + Error as MmrError, Merge as MmrMerge, MerkleProof as MmrMerkleProof, leaf_index_to_mmr_size, + leaf_index_to_pos, +}; +use primitive_types::H256; +use rs_merkle::{Hasher, MerkleProof}; + +/// The payload ID for the MMR root hash in a BEEFY commitment +const MMR_ROOT_PAYLOAD_ID: [u8; 2] = *b"mh"; + +/// A trait for recovering secp256k1 public keys from ECDSA signatures. +/// This allows the verifier to be generic. +pub trait EcdsaRecover { + /// Recover the uncompressed public key (64 bytes, without 0x04 prefix) from a 32-byte + /// prehash and 65-byte signature. Signature format: [r (32) | s (32) | v (1)] + fn secp256k1_recover(prehash: &[u8; 32], signature: &[u8; 65]) -> anyhow::Result<[u8; 64]>; +} + +/// A hasher implementation for rs_merkle, generic over the hash function +pub struct MerkleHasher(PhantomData); + +impl Clone for MerkleHasher { + fn clone(&self) -> Self { + Self(PhantomData) + } +} + +impl Hasher for MerkleHasher { + type Hash = [u8; 32]; + + fn hash(data: &[u8]) -> Self::Hash { + H::keccak256(data).into() + } +} + +/// Merge strategy for the merkle mountain range crate, generic over the hash function +struct KeccakMerge(PhantomData); + +impl MmrMerge for KeccakMerge { + type Item = [u8; 32]; + + fn merge(left: &Self::Item, right: &Self::Item) -> Result { + let mut data = [0u8; 64]; + data[..32].copy_from_slice(left); + data[32..].copy_from_slice(right); + Ok(H::keccak256(&data).into()) + } +} + +/// Verify the consensus proof and return the new trusted consensus state and verified parachain +/// headers +pub fn verify_consensus( + trusted_state: ConsensusState, + proof: BeefyConsensusProof, +) -> anyhow::Result<(Vec, Vec)> { + let (state, heads_root) = verify_mmr_update_proof::(trusted_state, proof.relay)?; + let verified_headers = verify_parachain_headers::(heads_root, proof.parachain)?; + Ok((state.encode(), verified_headers)) +} + +/// Verifies a new Mmr root update, the relay chain accumulates it's blocks into a merkle mountain +/// range tree which light clients can use as a source for log_2(n) ancestry proofs. This new mmr +/// root hash is signed by the relay chain authority set and we can verify the membership of the +/// authorities that signed this new root using a merkle multi proof and a merkle commitment to the +/// total authorities +fn verify_mmr_update_proof( + mut trusted_state: ConsensusState, + relay_proof: RelaychainProof, +) -> Result<(ConsensusState, H256), Error> { + let signatures_length = relay_proof.signed_commitment.signatures.len(); + let latest_height = relay_proof.signed_commitment.commitment.block_number; + + if trusted_state.latest_beefy_height >= latest_height { + return Err(Error::StaleHeight { + trusted_height: trusted_state.latest_beefy_height, + current_height: latest_height, + }) + } + + if !check_participation_threshold( + signatures_length as u32, + trusted_state.current_authorities.len, + ) && !check_participation_threshold( + signatures_length as u32, + trusted_state.next_authorities.len, + ) { + return Err(Error::SuperMajorityRequired); + } + + let commitment = relay_proof.signed_commitment.commitment.clone(); + + if commitment.validator_set_id != trusted_state.current_authorities.id && + commitment.validator_set_id != trusted_state.next_authorities.id + { + return Err(Error::UnknownAuthoritySet { id: commitment.validator_set_id }); + } + + let is_current_authorities = + commitment.validator_set_id == trusted_state.current_authorities.id; + + let mmr_root_data = commitment + .payload + .get_raw(&MMR_ROOT_PAYLOAD_ID) + .ok_or(Error::MmrRootHashMissing)?; + + if mmr_root_data.len() != 32 { + return Err(Error::InvalidMmrRootHashLength { len: mmr_root_data.len() }); + } + let mmr_root = H256::from_slice(mmr_root_data); + + let commitment_hash = H::keccak256(&commitment.encode()); + let mut authority_leaves: Vec<[u8; 32]> = Vec::new(); + let mut authority_indices = Vec::new(); + + for sig in relay_proof.signed_commitment.signatures.iter() { + let uncompressed = H::secp256k1_recover(&commitment_hash.0, &sig.signature) + .map_err(|_| Error::FailedToRecoverPublicKey)?; + + let hashed_uncompressed = H::keccak256(&uncompressed); + + let mut eth_address = [0u8; 20]; + eth_address.copy_from_slice(&hashed_uncompressed.as_ref()[12..]); + + let authority_address_hash = H::keccak256(ð_address); + + authority_leaves.push(authority_address_hash.into()); + authority_indices.push(sig.index as usize); + } + + let proof_hashes: Vec<[u8; 32]> = + relay_proof.proof.iter().flatten().map(|node| node.hash.into()).collect(); + let merkle_proof = MerkleProof::>::new(proof_hashes); + + let valid = if is_current_authorities { + merkle_proof.verify( + trusted_state.current_authorities.keyset_commitment.into(), + &authority_indices, + &authority_leaves, + trusted_state.current_authorities.len as usize, + ) + } else { + merkle_proof.verify( + trusted_state.next_authorities.keyset_commitment.into(), + &authority_indices, + &authority_leaves, + trusted_state.next_authorities.len as usize, + ) + }; + + if !valid { + return Err(Error::InvalidAuthoritiesProof) + } + + verify_mmr_leaf::(&relay_proof, mmr_root)?; + + if relay_proof.latest_mmr_leaf.beefy_next_authority_set.id > trusted_state.next_authorities.id { + trusted_state.current_authorities = trusted_state.next_authorities.clone(); + trusted_state.next_authorities = + relay_proof.latest_mmr_leaf.beefy_next_authority_set.clone(); + } + + trusted_state.latest_beefy_height = latest_height; + + Ok((trusted_state, relay_proof.latest_mmr_leaf.extra)) +} + +/// Verifies the inclusion of parachain headers in the parachain heads root via a merkle multi proof +pub fn verify_parachain_headers( + heads_root: H256, + parachain_proof: ParachainProof, +) -> Result, Error> { + if parachain_proof.parachains.is_empty() { + return Ok(vec![]); + } + + let mut indexed_leaf_hashes = Vec::with_capacity(parachain_proof.parachains.len()); + + for para_header in ¶chain_proof.parachains { + let leaf = (para_header.para_id, para_header.header.clone()); + let hash: [u8; 32] = H::keccak256(&leaf.encode()).into(); + indexed_leaf_hashes.push((para_header.index as usize, hash)); + } + + indexed_leaf_hashes.sort_by_key(|(index, _)| *index); + + let (leaf_indices, leaf_hashes): (Vec, Vec<[u8; 32]>) = + indexed_leaf_hashes.into_iter().unzip(); + let proof_hashes: Vec<[u8; 32]> = + parachain_proof.proof.iter().flatten().map(|node| node.hash.into()).collect(); + let merkle_proof = MerkleProof::>::new(proof_hashes); + let valid = merkle_proof.verify( + heads_root.0, + &leaf_indices, + &leaf_hashes, + parachain_proof.total_leaves as usize, + ); + + if !valid { + return Err(Error::InvalidParachainProof); + } + + Ok(parachain_proof.parachains) +} + +fn verify_mmr_leaf( + relay: &RelaychainProof, + mmr_root: H256, +) -> Result<(), Error> { + use polkadot_sdk::sp_consensus_beefy::mmr::MmrLeaf; + + let mmr_leaf = MmrLeaf:: { + version: relay.latest_mmr_leaf.version.clone(), + parent_number_and_hash: relay.latest_mmr_leaf.parent_block_and_hash, + beefy_next_authority_set: relay.latest_mmr_leaf.beefy_next_authority_set.clone(), + leaf_extra: relay.latest_mmr_leaf.extra, + }; + let leaf_hash = H::keccak256(&mmr_leaf.encode()); + let mmr_size = leaf_index_to_mmr_size(relay.latest_mmr_leaf.leaf_index as u64); + + let mmr_proof = MmrMerkleProof::<[u8; 32], KeccakMerge>::new( + mmr_size, + relay.mmr_proof.iter().map(|h| (*h).into()).collect(), + ); + let leaf_pos = leaf_index_to_pos(relay.latest_mmr_leaf.leaf_index as u64); + let leaf = (leaf_pos, leaf_hash.into()); + let valid = mmr_proof + .verify(mmr_root.into(), vec![leaf]) + .map_err(|e| Error::MmrVerificationFailed(e.to_string()))?; + + if !valid { + return Err(Error::InvalidMmrProof) + } + + Ok(()) +} + +/// Checks for supermajority participation +fn check_participation_threshold(len: u32, total: u32) -> bool { + len >= ((2 * total) / 3) + 1 +} diff --git a/modules/consensus/beefy/verifier/src/sp1.rs b/modules/consensus/beefy/verifier/src/sp1.rs new file mode 100644 index 000000000..973727d33 --- /dev/null +++ b/modules/consensus/beefy/verifier/src/sp1.rs @@ -0,0 +1,110 @@ +// Copyright (C) Polytope Labs Ltd. +// SPDX-License-Identifier: Apache-2.0 + +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//! SP1 BEEFY proof verification + +use crate::{error::Error, verify_parachain_headers}; +use alloc::vec::Vec; +use alloy_sol_types::{SolValue, sol}; +use beefy_verifier_primitives::{ConsensusState, ParachainHeader, Sp1BeefyProof}; +use codec::Encode; +use ismp::messaging::Keccak256; + +sol! { + struct PublicInputs { + bytes32 authorities_root; + uint256 authorities_len; + bytes32 leaf_hash; + bytes32[] headers; + } +} + +/// Verify an SP1 BEEFY consensus proof and return the updated consensus state +/// and verified parachain headers. +/// +/// 1. Check proof is not stale +/// 2. Match validator_set_id against known authority sets +/// 3. Build public inputs (authority root, len, leaf hash, header hashes) +/// 4. Verify SP1 proof +/// 5. Update authority sets if epoch changed +pub fn verify_sp1_consensus( + trusted_state: ConsensusState, + sp1_proof: Sp1BeefyProof, + vkey_hash: &str, +) -> Result<(Vec, Vec), Error> { + if trusted_state.latest_beefy_height >= sp1_proof.commitment.block_number { + return Err(Error::StaleHeight { + trusted_height: trusted_state.latest_beefy_height, + current_height: sp1_proof.commitment.block_number, + }); + } + + let authority = if sp1_proof.commitment.validator_set_id == trusted_state.next_authorities.id { + &trusted_state.next_authorities + } else if sp1_proof.commitment.validator_set_id == trusted_state.current_authorities.id { + &trusted_state.current_authorities + } else { + return Err(Error::UnknownAuthoritySet { id: sp1_proof.commitment.validator_set_id }); + }; + + let public_inputs = + build_sp1_public_inputs::(&sp1_proof, authority.keyset_commitment.into(), authority.len); + + sp1_verifier::PlonkVerifier::verify( + &sp1_proof.proof, + &public_inputs, + vkey_hash, + sp1_verifier::PLONK_VK_BYTES, + ) + .map_err(|_| Error::Sp1VerificationFailed)?; + + let verified_headers = + verify_parachain_headers::(sp1_proof.mmr_leaf.extra, sp1_proof.parachain)?; + + let mut new_state = trusted_state; + if sp1_proof.mmr_leaf.beefy_next_authority_set.id > new_state.next_authorities.id { + new_state.current_authorities = new_state.next_authorities.clone(); + new_state.next_authorities = sp1_proof.mmr_leaf.beefy_next_authority_set; + } + new_state.latest_beefy_height = sp1_proof.commitment.block_number; + + Ok((new_state.encode(), verified_headers)) +} + +fn build_sp1_public_inputs( + proof: &Sp1BeefyProof, + authority_root: [u8; 32], + authority_len: u32, +) -> Vec { + use alloy_sol_types::private::FixedBytes; + + let leaf_hash: [u8; 32] = H::keccak256(&proof.mmr_leaf.encode()).into(); + + let headers: Vec> = proof + .parachain + .parachains + .iter() + .map(|h| FixedBytes::from(Into::<[u8; 32]>::into(H::keccak256(&h.header)))) + .collect(); + + let inputs = PublicInputs { + authorities_root: FixedBytes::from(authority_root), + authorities_len: alloy_sol_types::private::U256::from(authority_len), + leaf_hash: FixedBytes::from(leaf_hash), + headers, + }; + + inputs.abi_encode() +} diff --git a/modules/consensus/beefy/verifier/src/test.rs b/modules/consensus/beefy/verifier/src/test.rs new file mode 100644 index 000000000..a7e44e91b --- /dev/null +++ b/modules/consensus/beefy/verifier/src/test.rs @@ -0,0 +1,256 @@ +// Copyright (C) Polytope Labs Ltd. +// SPDX-License-Identifier: Apache-2.0 + +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +use codec::{Decode, Encode}; +use polkadot_sdk::{sp_consensus_beefy::VersionedFinalityProof, *}; +use sp_core::H256; +use sp_io::hashing::keccak_256; +use subxt::{PolkadotConfig, backend::legacy::LegacyRpcMethods, ext::subxt_rpcs::rpc_params}; + +use beefy_prover::{ + Prover, + relay::{fetch_mmr_proof, paras_parachains}, + util::{hash_authority_addresses, merkle_proof}, +}; +use beefy_verifier_primitives::{ + BeefyConsensusProof, BeefyMmrLeaf, ParachainHeader, ParachainProof, RelaychainProof, + SignatureWithAuthorityIndex, +}; +use ismp::messaging::Keccak256; +use k256::ecdsa::{RecoveryId, Signature as K256Signature, VerifyingKey}; +use polkadot_sdk::sp_consensus_beefy::ecdsa_crypto::Signature; + +use crate::{EcdsaRecover, verify_consensus}; + +struct TestKeccak256; + +impl Keccak256 for TestKeccak256 { + fn keccak256(bytes: &[u8]) -> H256 { + sp_core::hashing::keccak_256(bytes).into() + } +} + +impl EcdsaRecover for TestKeccak256 { + fn secp256k1_recover(prehash: &[u8; 32], signature: &[u8; 65]) -> anyhow::Result<[u8; 64]> { + let recovery_id = RecoveryId::from_byte(signature[64]) + .ok_or_else(|| anyhow::anyhow!("Invalid recovery id"))?; + let k256_sig = K256Signature::from_slice(&signature[0..64]) + .map_err(|e| anyhow::anyhow!("Invalid signature format: {e}"))?; + let recovered_verifying_key = + VerifyingKey::recover_from_prehash(prehash, &k256_sig, recovery_id) + .map_err(|e| anyhow::anyhow!("Failed to recover public key: {e}"))?; + let uncompressed_point = recovered_verifying_key.to_encoded_point(false); + let uncompressed_bytes = &uncompressed_point.as_bytes()[1..]; + let mut result = [0u8; 64]; + result.copy_from_slice(uncompressed_bytes); + Ok(result) + } +} + +#[tokio::test] +async fn test_verify_consensus() { + let max_rpc_payload_size = 15 * 1024 * 1024; + + let relay_ws_url = + std::env::var("RELAY_WS_URL").unwrap_or("wss://rpc.ibp.network/polkadot".to_string()); + let para_ws_url = + std::env::var("PARA_WS_URL").unwrap_or("wss://nexus.dotters.network".to_string()); + + let (relay_client, relay_rpc_client) = + subxt_utils::client::ws_client::(&relay_ws_url, max_rpc_payload_size) + .await + .unwrap(); + let relay_rpc = LegacyRpcMethods::::new(relay_rpc_client.clone()); + + let (para_client, para_rpc_client) = + subxt_utils::client::ws_client::(¶_ws_url, max_rpc_payload_size) + .await + .unwrap(); + let para_rpc = LegacyRpcMethods::::new(para_rpc_client.clone()); + + let prover = Prover { + beefy_activation_block: 0, + relay: relay_client.clone(), + relay_rpc: relay_rpc.clone(), + relay_rpc_client: relay_rpc_client.clone(), + para: para_client.clone(), + para_rpc, + para_rpc_client, + para_ids: vec![], + query_batch_size: Some(100), + }; + + println!("Finding latest and previous beefy blocks..."); + let latest_beefy_hash: H256 = + relay_rpc_client.request("beefy_getFinalizedHead", rpc_params!()).await.unwrap(); + + let mut previous_beefy_hash = H256::default(); + let mut current_hash = latest_beefy_hash; + for _ in 0..1000 { + let header = relay_rpc.chain_get_header(Some(current_hash.into())).await.unwrap().unwrap(); + let parent_hash: H256 = header.parent_hash.into(); + + if parent_hash.is_zero() { + panic!("Reached genesis block without finding a previous beefy block."); + } + + let block = relay_rpc.chain_get_block(Some(parent_hash.into())).await.unwrap().unwrap(); + + if let Some(justifications) = block.justifications { + if justifications.iter().any(|j| j.0 == sp_consensus_beefy::BEEFY_ENGINE_ID) { + previous_beefy_hash = parent_hash; + break; + } + } + current_hash = parent_hash; + } + + if previous_beefy_hash.is_zero() { + panic!("Could not find a previous BEEFY block to initialize the state."); + } + + println!("Getting initial consensus state from block: {:?}", previous_beefy_hash); + let trusted_state = + prover.get_initial_consensus_state(Some(previous_beefy_hash)).await.unwrap(); + + let (signed_commitment_raw, block_hash) = { + let block = relay_rpc + .chain_get_block(Some(latest_beefy_hash.into())) + .await + .unwrap() + .unwrap(); + let justifications = + block.justifications.expect("Latest beefy block must have justifications"); + let beefy_justification = justifications + .into_iter() + .find_map(|j| (j.0 == sp_consensus_beefy::BEEFY_ENGINE_ID).then_some(j.1)) + .expect("Latest beefy block must have a beefy justification"); + + let VersionedFinalityProof::V1(signed_commitment) = + VersionedFinalityProof::::decode(&mut &*beefy_justification) + .expect("Beefy justification should decode correctly"); + (signed_commitment, latest_beefy_hash) + }; + + let block_number = signed_commitment_raw.commitment.block_number; + + println!("Generating the relay chain proof for block #{}", block_number); + let (mmr_leaf_proof, latest_leaf) = + fetch_mmr_proof(&prover.relay_rpc, block_number, None).await.unwrap(); + + let signatures = signed_commitment_raw + .signatures + .iter() + .enumerate() + .filter_map(|(index, sig)| { + sig.as_ref().map(|s: &Signature| { + let slice: &[u8] = s.as_ref(); + let signature_array: [u8; 65] = + slice.try_into().expect("Signature should be 65 bytes long"); + SignatureWithAuthorityIndex { index: index as u32, signature: signature_array } + }) + }) + .collect::>(); + + let current_authorities = prover.beefy_authorities(Some(block_hash)).await.unwrap(); + let authority_address_hashes = + hash_authority_addresses(current_authorities.into_iter().map(|x| x.encode()).collect()) + .unwrap(); + + let authority_indices = signatures.iter().map(|x| x.index as usize).collect::>(); + let authority_proof_2d = merkle_proof(&authority_address_hashes, &authority_indices); + let authority_proof_nodes = authority_proof_2d + .into_iter() + .map(|layer| { + layer + .into_iter() + .map(|(index, hash)| beefy_verifier_primitives::Node { + index: index as u32, + hash: H256::from(hash), + }) + .collect() + }) + .collect(); + + let signed_commitment = beefy_verifier_primitives::SignedCommitment { + commitment: signed_commitment_raw.commitment.clone(), + signatures, + }; + + let beefy_mmr_leaf = BeefyMmrLeaf { + version: latest_leaf.version.clone(), + parent_block_and_hash: ( + latest_leaf.parent_number_and_hash.0, + latest_leaf.parent_number_and_hash.1, + ), + beefy_next_authority_set: latest_leaf.beefy_next_authority_set.clone(), + leaf_index: mmr_leaf_proof.leaf_indices[0] as u32, + extra: latest_leaf.leaf_extra, + }; + + let relay_proof = RelaychainProof { + signed_commitment, + latest_mmr_leaf: beefy_mmr_leaf, + mmr_proof: mmr_leaf_proof.items, + proof: authority_proof_nodes, + }; + + println!("Generating the parachain proof"); + let heads = paras_parachains( + &prover.relay_rpc, + Some( + H256::decode(&mut &*latest_leaf.parent_number_and_hash.1.encode()) + .unwrap() + .into(), + ), + ) + .await + .unwrap(); + + let (parachains, indices): (Vec<_>, Vec<_>) = if !heads.is_empty() { + let first_head = &heads[0]; + ( + vec![ParachainHeader { header: first_head.1.clone(), index: 0, para_id: first_head.0 }], + vec![0], + ) + } else { + (vec![], vec![]) + }; + + let leaves = heads.iter().map(|pair| keccak_256(&pair.encode())).collect::>(); + let proof_2d = merkle_proof(&leaves, &indices); + let proof = proof_2d + .into_iter() + .map(|layer| { + layer + .into_iter() + .map(|(index, hash)| beefy_verifier_primitives::Node { + index: index as u32, + hash: H256::from(hash), + }) + .collect() + }) + .collect(); + let parachain_proof = ParachainProof { parachains, proof, total_leaves: leaves.len() as u32 }; + + println!("Assembling final proof for verification"); + let consensus_proof = BeefyConsensusProof { relay: relay_proof, parachain: parachain_proof }; + + let result = verify_consensus::(trusted_state, consensus_proof); + + assert!(result.is_ok(), "Consensus verification failed: {:?}", result.err()); + + println!("Successfully verified beefy justification for block #{}", block_number); +} diff --git a/modules/ismp/clients/beefy/Cargo.toml b/modules/ismp/clients/beefy/Cargo.toml new file mode 100644 index 000000000..368418856 --- /dev/null +++ b/modules/ismp/clients/beefy/Cargo.toml @@ -0,0 +1,38 @@ +[package] +name = "ismp-beefy" +version = "0.1.0" +edition = "2024" + +[dependencies] +anyhow = { workspace = true, default-features = false } +codec = { workspace = true, features = ["derive"], default-features = false } +primitive-types = { workspace = true, default-features = false } + +ismp = { workspace = true, default-features = false } +beefy-verifier = { workspace = true, default-features = false } +pallet-ismp = { workspace = true, default-features = false } +substrate-state-machine = { workspace = true, default-features = false } +beefy-verifier-primitives = { workspace = true, default-features = false } + +[dependencies.polkadot-sdk] +workspace = true +features = [ + "sp-io", + "sp-runtime", + "sp-core", +] + + +[features] +default = ["std"] +std = [ + "anyhow/std", + "codec/std", + "ismp/std", + "polkadot-sdk/std", + "primitive-types/std", + "pallet-ismp/std", + "beefy-verifier-primitives/std", + "beefy-verifier/std", + "substrate-state-machine/std", +] diff --git a/modules/ismp/clients/beefy/src/consensus.rs b/modules/ismp/clients/beefy/src/consensus.rs new file mode 100644 index 000000000..8f9391f11 --- /dev/null +++ b/modules/ismp/clients/beefy/src/consensus.rs @@ -0,0 +1,258 @@ +// Copyright (C) Polytope Labs Ltd. +// SPDX-License-Identifier: Apache-2.0 + +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +use alloc::{boxed::Box, collections::BTreeMap, format, string::ToString, vec, vec::Vec}; +use beefy_verifier::verify_consensus; +use beefy_verifier_primitives::{ + BeefyConsensusProof, ConsensusState, PROOF_TYPE_NAIVE, PROOF_TYPE_SP1, ParachainProof, + RelaychainProof, Sp1BeefyProof, +}; +use codec::{Decode, Encode}; +use core::marker::PhantomData; +use ismp::{ + Error, + consensus::{ + ConsensusClient, ConsensusClientId, ConsensusStateId, StateCommitment, StateMachineClient, + StateMachineId, VerifiedCommitments, + }, + host::{IsmpHost, StateMachine}, + messaging::StateCommitmentHeight, +}; +use pallet_ismp::{ConsensusDigest, ISMP_ID, ISMP_TIMESTAMP_ID, TimestampDigest}; +use polkadot_sdk::*; +use primitive_types::H256; +use sp_runtime::{ + DigestItem, + generic::Header, + traits::{BlakeTwo256, Header as _}, +}; +use substrate_state_machine::SubstrateStateMachine; + +use crate::{BeefyClientConfig, SubstrateCrypto}; + +pub const BEEFY_CONSENSUS_ID: ConsensusClientId = *b"BEEF"; + +/// Beefy consensus client implementation +pub struct BeefyConsensusClient>(PhantomData<(H, C, S)>); + +impl< + H: IsmpHost + Send + Sync + Default + 'static, + C: BeefyClientConfig + 'static, + S: StateMachineClient + From + 'static, +> Default for BeefyConsensusClient +{ + fn default() -> Self { + Self(PhantomData) + } +} + +impl ConsensusClient for BeefyConsensusClient +where + H: IsmpHost + Send + Sync + Default + 'static, + C: BeefyClientConfig + 'static, + S: StateMachineClient + From + 'static, +{ + fn verify_consensus( + &self, + host: &dyn IsmpHost, + consensus_state_id: ConsensusStateId, + trusted_consensus_state: Vec, + proof: Vec, + ) -> Result<(Vec, VerifiedCommitments), Error> { + let consensus_state: ConsensusState = + codec::Decode::decode(&mut &trusted_consensus_state[..]).map_err(|e| { + Error::Custom(format!( + "Cannot decode consensus state from trusted consensus state: {e:?}" + )) + })?; + + let proof_type = proof.first().ok_or_else(|| Error::Custom("Empty proof".into()))?; + let payload = &proof[1..]; + + let (new_state, verified_parachains) = match *proof_type { + PROOF_TYPE_NAIVE => { + let consensus_proof: BeefyConsensusProof = codec::Decode::decode(&mut &payload[..]) + .map_err(|e| Error::Custom(format!("Cannot decode naive proof: {e:?}")))?; + verify_consensus::(consensus_state, consensus_proof).map_err( + |e| Error::Custom(format!("Error verifying naive consensus update: {e:?}")), + )? + }, + PROOF_TYPE_SP1 => { + let sp1_proof: Sp1BeefyProof = codec::Decode::decode(&mut &payload[..]) + .map_err(|e| Error::Custom(format!("Cannot decode SP1 proof: {e:?}")))?; + let vkey_bytes = C::sp1_vkey_hash(); + let vkey_hash = core::str::from_utf8(&vkey_bytes) + .map_err(|_| Error::Custom("Invalid SP1 vkey hash encoding".into()))?; + beefy_verifier::sp1::verify_sp1_consensus::( + consensus_state, + sp1_proof, + vkey_hash, + ) + .map_err(|e| { + Error::Custom(format!("Error verifying SP1 consensus update: {e:?}")) + })? + }, + _ => return Err(Error::Custom(format!("Unknown proof type: {proof_type}"))), + }; + + if verified_parachains.is_empty() { + return Ok((new_state, BTreeMap::new())); + } + + let mut intermediates = BTreeMap::new(); + for para_header in verified_parachains { + // Skip parachains not tracked by this consensus client + if !C::is_parachain_tracked(para_header.para_id) { + continue; + } + + let header = Header::::decode(&mut &*para_header.header) + .map_err(|e| Error::Custom(format!("Error decoding parachain header: {e}")))?; + + let mut state_commitments_vec = Vec::new(); + let (mut timestamp, mut overlay_root) = (0, H256::default()); + + for digest in header.digest().logs.iter() { + match digest { + DigestItem::Consensus(consensus_engine_id, value) + if *consensus_engine_id == ISMP_TIMESTAMP_ID => + { + let timestamp_digest = + TimestampDigest::decode(&mut &value[..]).map_err(|e| { + Error::Custom(format!("Failed to decode timestamp digest: {e:?}")) + })?; + timestamp = timestamp_digest.timestamp; + }, + DigestItem::Consensus(consensus_engine_id, value) + if *consensus_engine_id == ISMP_ID => + { + let log = ConsensusDigest::decode(&mut &value[..]); + if let Ok(log) = log { + overlay_root = log.child_trie_root; + } else { + Err(Error::Custom( + "Header contains an invalid ismp consensus log".into(), + ))? + } + }, + _ => {}, + }; + } + if timestamp == 0 { + Err(Error::Custom("Timestamp not found".into()))? + } + + let state_id = match host.host_state_machine() { + StateMachine::Kusama(_) => StateMachine::Kusama(para_header.para_id), + StateMachine::Polkadot(_) => StateMachine::Polkadot(para_header.para_id), + _ => Err(Error::Custom("Host state machine should be a parachain".into()))?, + }; + + let height: u32 = (*header.number()).into(); + let intermediate = StateCommitmentHeight { + commitment: StateCommitment { + timestamp, + overlay_root: Some(overlay_root), + state_root: header.state_root, + }, + height: height.into(), + }; + + state_commitments_vec.push(intermediate); + intermediates + .insert(StateMachineId { state_id, consensus_state_id }, state_commitments_vec); + } + + Ok((new_state, intermediates)) + } + + fn verify_fraud_proof( + &self, + _host: &dyn IsmpHost, + trusted_consensus_state: Vec, + proof_1: Vec, + proof_2: Vec, + ) -> Result<(), Error> { + let consensus_state: ConsensusState = + codec::Decode::decode(&mut &trusted_consensus_state[..]).map_err(|e| { + Error::Custom(format!( + "Cannot decode consensus state from trusted consensus state: {e:?}" + )) + })?; + + let first_proof: RelaychainProof = + codec::Decode::decode(&mut &proof_1[..]).map_err(|e| { + Error::Custom(format!( + "Cannot decode first relay chain proof from proof_1 bytes: {e:?}" + )) + })?; + + let second_proof: RelaychainProof = + codec::Decode::decode(&mut &proof_2[..]).map_err(|e| { + Error::Custom(format!( + "Cannot decode second relay chain proof from proof_2 bytes: {e:?}" + )) + })?; + + let first_commitment = &first_proof.signed_commitment.commitment; + let second_commitment = &second_proof.signed_commitment.commitment; + + if first_commitment.block_number != second_commitment.block_number { + return Err(Error::Custom("Fraud proofs must be for the same block number".to_string())) + } + + if first_commitment.encode() == second_commitment.encode() { + return Err(Error::Custom( + "Fraud proofs have identical commitments, no equivocation".to_string(), + )) + } + + let empty_parachain_proof = + ParachainProof { parachains: vec![], proof: vec![], total_leaves: 0 }; + + verify_consensus::( + consensus_state.clone(), + BeefyConsensusProof { relay: first_proof, parachain: empty_parachain_proof.clone() }, + ) + .map_err(|e| Error::Custom(format!("First proof verification failed: {e:?}")))?; + + verify_consensus::( + consensus_state, + BeefyConsensusProof { relay: second_proof, parachain: empty_parachain_proof }, + ) + .map_err(|e| Error::Custom(format!("Second proof verification failed: {e:?}")))?; + + Ok(()) + } + + fn consensus_client_id(&self) -> ConsensusClientId { + BEEFY_CONSENSUS_ID + } + + fn state_machine(&self, id: StateMachine) -> Result, Error> { + let para_id = match id { + StateMachine::Polkadot(id) | StateMachine::Kusama(id) => id, + _ => Err(Error::Custom( + "State Machine is not supported by this consensus client".to_string(), + ))?, + }; + + if !C::is_parachain_tracked(para_id) { + Err(Error::Custom(format!("Parachain with id {para_id} not registered")))? + } + + Ok(Box::new(S::from(id))) + } +} diff --git a/modules/ismp/clients/beefy/src/lib.rs b/modules/ismp/clients/beefy/src/lib.rs new file mode 100644 index 000000000..50d932897 --- /dev/null +++ b/modules/ismp/clients/beefy/src/lib.rs @@ -0,0 +1,47 @@ +// Copyright (C) Polytope Labs Ltd. +// SPDX-License-Identifier: Apache-2.0 + +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +#![cfg_attr(not(feature = "std"), no_std)] + +extern crate alloc; +extern crate core; +pub mod consensus; + +use polkadot_sdk::*; + +/// Crypto implementation using substrate host functions +pub struct SubstrateCrypto; + +impl ismp::messaging::Keccak256 for SubstrateCrypto { + fn keccak256(bytes: &[u8]) -> primitive_types::H256 { + sp_io::hashing::keccak_256(bytes).into() + } +} + +impl beefy_verifier::EcdsaRecover for SubstrateCrypto { + fn secp256k1_recover(prehash: &[u8; 32], signature: &[u8; 65]) -> anyhow::Result<[u8; 64]> { + sp_io::crypto::secp256k1_ecdsa_recover(signature, prehash) + .map_err(|_| anyhow::anyhow!("Failed to recover secp256k1 public key")) + } +} + +/// Provides parachain tracking and SP1 vkey data to the BEEFY consensus client. +pub trait BeefyClientConfig { + /// Returns true if the given parachain id is tracked by this consensus client. + fn is_parachain_tracked(para_id: u32) -> bool; + + /// Returns the SP1 verification key hash bytes. + fn sp1_vkey_hash() -> alloc::vec::Vec; +} diff --git a/modules/ismp/clients/parachain/client/src/weights.rs b/modules/ismp/clients/parachain/client/src/weights.rs index b1b340587..c0e8b424a 100644 --- a/modules/ismp/clients/parachain/client/src/weights.rs +++ b/modules/ismp/clients/parachain/client/src/weights.rs @@ -26,3 +26,17 @@ pub trait WeightInfo { /// Weight for updating a parachain's consensus fn update_parachain_consensus() -> Weight; } + +impl WeightInfo for () { + fn add_parachain(n: u32) -> Weight { + Weight::from_parts(10_000_000, 0) + } + + fn remove_parachain(n: u32) -> Weight { + Weight::from_parts(10_000_000, 0) + } + + fn update_parachain_consensus() -> Weight { + Weight::from_parts(10_000_000, 0) + } +} diff --git a/modules/pallets/testsuite/Cargo.toml b/modules/pallets/testsuite/Cargo.toml index cfa674ce1..5da85a40b 100644 --- a/modules/pallets/testsuite/Cargo.toml +++ b/modules/pallets/testsuite/Cargo.toml @@ -61,6 +61,12 @@ crypto-utils = { workspace = true, default-features = true } rs_merkle = { version = "1.5.0"} log = { workspace = true } primitive-types = { workspace = true } +ismp-parachain = { workspace = true, default-features = true } +beefy-prover = { workspace = true } +beefy-verifier-primitives = { workspace = true } +subxt-core = { workspace = true, default-features = true } +futures = { workspace = true } +tokio = { workspace = true } ismp-solidity-abi = { workspace = true } [dependencies.alloy] @@ -104,6 +110,7 @@ features = [ "pallet-authorship", "cumulus-primitives-parachain-inherent", "xcm-emulator", + "sp-consensus-beefy" ] diff --git a/modules/pallets/testsuite/src/runtime.rs b/modules/pallets/testsuite/src/runtime.rs index 05fa88cb4..13fbdc389 100644 --- a/modules/pallets/testsuite/src/runtime.rs +++ b/modules/pallets/testsuite/src/runtime.rs @@ -112,6 +112,7 @@ frame_support::construct_runtime!( CollatorManager: pallet_collator_manager, MsgQueue: mock_message_queue, Authorship: pallet_authorship, + IsmpParachain: ismp_parachain, } ); @@ -258,6 +259,7 @@ impl pallet_ismp::Config for Test { Test, HyperbridgeClientMachine, >, + ismp_parachain::ParachainConsensusClient, ismp_pharos::PharosClient, ); type OffchainDB = Mmr; @@ -421,6 +423,16 @@ impl ismp_grandpa::Config for Test { type RootOrigin = EnsureRoot; } +impl ismp_parachain::Config for Test { + type IsmpHost = Ismp; + type WeightInfo = (); + type RootOrigin = EnsureRoot; +} + +impl ismp_beefy::Config for Test { + type IsmpHost = Ismp; +} + parameter_types! { pub const TreasuryAccount: PalletId = PalletId(*b"treasury"); } diff --git a/modules/pallets/testsuite/src/tests/pallet_ismp_beefy.rs b/modules/pallets/testsuite/src/tests/pallet_ismp_beefy.rs new file mode 100644 index 000000000..7de293395 --- /dev/null +++ b/modules/pallets/testsuite/src/tests/pallet_ismp_beefy.rs @@ -0,0 +1,299 @@ +use std::convert::TryInto; + +use codec::{Decode, Encode}; +use polkadot_sdk::{ + sp_consensus_beefy::VersionedFinalityProof, sp_core::H256, sp_io::hashing::keccak_256, *, +}; +use sp_consensus_beefy::ecdsa_crypto::Signature; +use subxt::{backend::legacy::LegacyRpcMethods, ext::subxt_rpcs::rpc_params, PolkadotConfig}; + +use beefy_prover::{ + relay::{fetch_mmr_proof, paras_parachains}, + util::{hash_authority_addresses, merkle_proof}, + Prover, +}; +use beefy_verifier_primitives::{ + BeefyConsensusProof, BeefyMmrLeaf, ConsensusState, Node, ParachainHeader, ParachainProof, + RelaychainProof, SignatureWithAuthorityIndex, PROOF_TYPE_NAIVE, +}; +use ismp::{ + consensus::{ConsensusClient, StateMachineId}, + host::{IsmpHost, StateMachine}, + messaging::Keccak256, +}; +use ismp_beefy::{consensus::BEEFY_CONSENSUS_ID, Config}; +use ismp_parachain::Parachains; + +use crate::runtime::*; + +struct TestKeccak256; + +impl Keccak256 for TestKeccak256 { + fn keccak256(bytes: &[u8]) -> H256 { + sp_core::hashing::keccak_256(bytes).into() + } +} + +async fn setup() -> (ConsensusState, BeefyConsensusProof) { + let max_rpc_payload_size = 15 * 1024 * 1024; + + let relay_ws_url = + std::env::var("RELAY_WS_URL").unwrap_or("wss://rpc.ibp.network/polkadot".to_string()); + let para_ws_url = + std::env::var("PARA_WS_URL").unwrap_or("wss://nexus.dotters.network".to_string()); + + let (relay_client, relay_rpc_client) = + subxt_utils::client::ws_client::(&relay_ws_url, max_rpc_payload_size) + .await + .unwrap(); + let relay_rpc = LegacyRpcMethods::::new(relay_rpc_client.clone()); + + let (para_client, para_rpc_client) = + subxt_utils::client::ws_client::(¶_ws_url, max_rpc_payload_size) + .await + .unwrap(); + let para_rpc = LegacyRpcMethods::::new(para_rpc_client.clone()); + + let prover = Prover { + beefy_activation_block: 0, + relay: relay_client.clone(), + relay_rpc: relay_rpc.clone(), + relay_rpc_client: relay_rpc_client.clone(), + para: para_client.clone(), + para_rpc, + para_rpc_client, + para_ids: vec![3367], + query_batch_size: Some(100), + }; + + let latest_beefy_hash: H256 = + relay_rpc_client.request("beefy_getFinalizedHead", rpc_params!()).await.unwrap(); + + let mut previous_beefy_hash = H256::default(); + let mut current_hash = latest_beefy_hash; + for _ in 0..1000 { + let header = relay_rpc.chain_get_header(Some(current_hash.into())).await.unwrap().unwrap(); + let parent_hash: H256 = header.parent_hash.into(); + let block = relay_rpc.chain_get_block(Some(parent_hash.into())).await.unwrap().unwrap(); + + if let Some(justifications) = block.justifications { + if justifications.iter().any(|j| j.0 == sp_consensus_beefy::BEEFY_ENGINE_ID) { + previous_beefy_hash = parent_hash; + break; + } + } + current_hash = parent_hash; + } + + let initial_state = prover + .get_initial_consensus_state(Some(previous_beefy_hash.into())) + .await + .unwrap(); + + let (signed_commitment_raw, block_hash) = { + let block = relay_rpc + .chain_get_block(Some(latest_beefy_hash.into())) + .await + .unwrap() + .unwrap(); + let justifications = + block.justifications.expect("Latest beefy block must have justifications"); + let beefy_justification = justifications + .into_iter() + .find_map(|j| (j.0 == sp_consensus_beefy::BEEFY_ENGINE_ID).then_some(j.1)) + .expect("Latest beefy block must have a beefy justification"); + + let VersionedFinalityProof::V1(signed_commitment) = + VersionedFinalityProof::::decode(&mut &*beefy_justification) + .expect("Beefy justification should decode correctly"); + (signed_commitment, latest_beefy_hash) + }; + + let (mmr_leaf_proof, latest_leaf) = + fetch_mmr_proof(&prover.relay_rpc, signed_commitment_raw.commitment.block_number, None) + .await + .unwrap(); + + let signatures = signed_commitment_raw + .signatures + .iter() + .enumerate() + .filter_map(|(index, sig)| { + sig.as_ref().map(|s| { + let slice: &[u8] = s.as_ref(); + let signature_array: [u8; 65] = + slice.try_into().expect("Signature should be 65 bytes long"); + SignatureWithAuthorityIndex { index: index as u32, signature: signature_array } + }) + }) + .collect::>(); + + let current_authorities = prover.beefy_authorities(Some(block_hash)).await.unwrap(); + let authority_address_hashes = + hash_authority_addresses(current_authorities.into_iter().map(|x| x.encode()).collect()) + .unwrap(); + let authority_indices = signatures.iter().map(|x| x.index as usize).collect::>(); + let authority_proof_2d = merkle_proof(&authority_address_hashes, &authority_indices); + + let authority_proof_nodes = authority_proof_2d + .into_iter() + .flatten() + .map(|(_, hash)| H256::from(hash)) + .collect(); + + let signed_commitment = beefy_verifier_primitives::SignedCommitment { + commitment: signed_commitment_raw.commitment.clone(), + signatures, + }; + + let beefy_mmr_leaf = BeefyMmrLeaf { + version: latest_leaf.version.clone(), + parent_block_and_hash: ( + latest_leaf.parent_number_and_hash.0, + latest_leaf.parent_number_and_hash.1, + ), + beefy_next_authority_set: latest_leaf.beefy_next_authority_set.clone(), + k_index: 0, + leaf_index: mmr_leaf_proof.leaf_indices[0] as u32, + extra: latest_leaf.leaf_extra, + }; + + let relay_proof = RelaychainProof { + signed_commitment, + latest_mmr_leaf: beefy_mmr_leaf, + mmr_proof: mmr_leaf_proof.items, + proof: authority_proof_nodes, + }; + + let heads = paras_parachains( + &prover.relay_rpc, + Some( + H256::decode(&mut &*latest_leaf.parent_number_and_hash.1.encode()) + .unwrap() + .into(), + ), + ) + .await + .unwrap(); + + let (parachains, indices): (Vec<_>, Vec<_>) = prover + .para_ids + .iter() + .map(|id| { + let index = heads.iter().position(|(i, _)| *i == *id).expect("ParaId should exist"); + ( + ParachainHeader { + header: heads[index].1.clone(), + index: index as u32, + para_id: heads[index].0, + }, + index, + ) + }) + .unzip(); + + let leaves = heads.iter().map(|pair| keccak_256(&pair.encode())).collect::>(); + let proof_2d = merkle_proof(&leaves, &indices); + let proof = proof_2d.into_iter().flatten().map(|level| level.1).collect(); + dbg!(&leaves.len()); + let parachain_proof = ParachainProof { parachains, proof, total_leaves: leaves.len() as u32 }; + + let beefy_consensus_proof = + BeefyConsensusProof { relay: relay_proof, parachain: parachain_proof }; + + (initial_state, beefy_consensus_proof) +} + +#[tokio::test] +async fn test_verify_consensus() { + let (initial_state, beefy_consensus_proof) = setup().await; + let mut ext = new_test_ext(); + ext.execute_with(|| { + Parachains::::insert(3367, 12000); + + let host = Ismp::default(); + let consensus_client = host.consensus_client(BEEFY_CONSENSUS_ID).unwrap(); + let consensus_state_id = b"BEEF".to_vec(); + let trusted_consensus_state = initial_state.encode(); + let proof = [&[PROOF_TYPE_NAIVE], beefy_consensus_proof.encode().as_slice()].concat(); + + let result = consensus_client.verify_consensus( + &host, + consensus_state_id.try_into().unwrap(), + trusted_consensus_state, + proof, + ); + + assert!(result.is_ok(), "Consensus verification failed: {:?}", result.err()); + + let (new_state, commitments) = result.unwrap(); + let new_consensus_state = ConsensusState::decode(&mut &*new_state).unwrap(); + + assert!(new_consensus_state.latest_beefy_height > initial_state.latest_beefy_height); + assert!(!commitments.is_empty()); + + let (state_machine, state_commitments) = commitments.into_iter().next().unwrap(); + assert_eq!( + state_machine, + StateMachineId { + state_id: StateMachine::Kusama(3367), + consensus_state_id: b"BEEF".to_vec().try_into().unwrap() + } + ); + assert!(!state_commitments.is_empty()); + dbg!(state_commitments); + println!("Successfully verified beefy justification and extracted parachain commitments"); + }); +} + +#[test] +fn test_unknown_proof_type_rejected() { + let mut ext = new_test_ext(); + ext.execute_with(|| { + let host = Ismp::default(); + let consensus_client = host.consensus_client(BEEFY_CONSENSUS_ID).unwrap(); + let consensus_state = ConsensusState { + latest_beefy_height: 0, + beefy_activation_block: 0, + mmr_root_hash: H256::default(), + current_authorities: Default::default(), + next_authorities: Default::default(), + }; + + // Prefix with 0xFF — unknown proof type + let proof = [&[0xFF], &[0u8; 32][..]].concat(); + + let result = + consensus_client.verify_consensus(&host, *b"BEEF", consensus_state.encode(), proof); + + assert!(result.is_err()); + let err = result.unwrap_err().to_string(); + assert!( + err.contains("Unknown proof type"), + "Expected unknown proof type error, got: {err}" + ); + }); +} + +#[test] +fn test_empty_proof_rejected() { + let mut ext = new_test_ext(); + ext.execute_with(|| { + let host = Ismp::default(); + let consensus_client = host.consensus_client(BEEFY_CONSENSUS_ID).unwrap(); + let consensus_state = ConsensusState { + latest_beefy_height: 0, + beefy_activation_block: 0, + mmr_root_hash: H256::default(), + current_authorities: Default::default(), + next_authorities: Default::default(), + }; + + let result = + consensus_client.verify_consensus(&host, *b"BEEF", consensus_state.encode(), vec![]); + + assert!(result.is_err()); + let err = result.unwrap_err().to_string(); + assert!(err.contains("Empty proof"), "Expected empty proof error, got: {err}"); + }); +} diff --git a/parachain/runtimes/gargantua/src/lib.rs b/parachain/runtimes/gargantua/src/lib.rs index 9bc17bf01..3b7bafc4f 100644 --- a/parachain/runtimes/gargantua/src/lib.rs +++ b/parachain/runtimes/gargantua/src/lib.rs @@ -834,6 +834,7 @@ mod runtime { pub type IsmpTendermint = ismp_tendermint::pallet; #[runtime::pallet_index(255)] pub type IsmpGrandpa = ismp_grandpa; + } #[cfg(feature = "runtime-benchmarks")]