diff --git a/Cargo.lock b/Cargo.lock index 8e9749d2e..46d186f1b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1692,12 +1692,6 @@ dependencies = [ "serde", ] -[[package]] -name = "as-any" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b0f477b951e452a0b6b4a10b53ccd569042d1d01729b519e02074a9c0958a063" - [[package]] name = "ascii" version = "0.9.3" @@ -3237,10 +3231,14 @@ name = "bls-utils" version = "0.1.0" dependencies = [ "bls_on_arkworks", + "ethereum_ssz", + "ethereum_ssz_derive", "parity-scale-codec", "serde", "serde-hex-utils", - "ssz-rs", + "ssz_types", + "tree_hash", + "tree_hash_derive", ] [[package]] @@ -3608,12 +3606,13 @@ dependencies = [ "anyhow", "bsc-verifier", "dotenv", + "ethereum_ssz", "futures", "geth-primitives", "ismp", "polkadot-sdk", "primitive-types 0.13.1", - "ssz-rs", + "ssz_types", "sync-committee-primitives", "tokio", "tracing", @@ -3629,13 +3628,14 @@ dependencies = [ "anyhow", "bls-utils", "bls_on_arkworks", + "ethereum_ssz", "geth-primitives", "ismp", "log", "parity-scale-codec", "polkadot-sdk", "primitive-types 0.13.1", - "ssz-rs", + "ssz_types", "sync-committee-primitives", "thiserror 2.0.18", ] @@ -5858,6 +5858,16 @@ dependencies = [ "darling_macro 0.21.3", ] +[[package]] +name = "darling" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" +dependencies = [ + "darling_core 0.23.0", + "darling_macro 0.23.0", +] + [[package]] name = "darling_core" version = "0.13.4" @@ -5901,6 +5911,19 @@ dependencies = [ "syn 2.0.116", ] +[[package]] +name = "darling_core" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" +dependencies = [ + "ident_case", + "proc-macro2", + "quote", + "strsim 0.11.1", + "syn 2.0.116", +] + [[package]] name = "darling_macro" version = "0.13.4" @@ -5934,6 +5957,17 @@ dependencies = [ "syn 2.0.116", ] +[[package]] +name = "darling_macro" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" +dependencies = [ + "darling_core 0.23.0", + "quote", + "syn 2.0.116", +] + [[package]] name = "dashmap" version = "5.5.3" @@ -7166,6 +7200,54 @@ dependencies = [ "uint 0.10.0", ] +[[package]] +name = "ethereum_hashing" +version = "0.8.0" +source = "git+https://github.com/polytope-labs/ethereum_hashing?rev=777b16da793573d5a0dd37d33d77f24ce4b624e7#777b16da793573d5a0dd37d33d77f24ce4b624e7" +dependencies = [ + "cpufeatures 0.2.17", + "sha2 0.10.9", + "spin 0.9.8", +] + +[[package]] +name = "ethereum_serde_utils" +version = "0.8.1" +source = "git+https://github.com/polytope-labs/ethereum_serde_utils?rev=b67233cce50833e2082ebcb16a9971df063c7ecb#b67233cce50833e2082ebcb16a9971df063c7ecb" +dependencies = [ + "alloy-primitives 1.5.7", + "hex", + "serde", + "serde_derive", + "serde_json", +] + +[[package]] +name = "ethereum_ssz" +version = "0.10.4" +source = "git+https://github.com/polytope-labs/ethereum_ssz?rev=be67c8cf3fb9ad34597db52d242f213536eca9a6#be67c8cf3fb9ad34597db52d242f213536eca9a6" +dependencies = [ + "alloy-primitives 1.5.7", + "ethereum_serde_utils", + "itertools 0.14.0", + "parity-scale-codec", + "serde", + "serde_derive", + "smallvec", + "typenum", +] + +[[package]] +name = "ethereum_ssz_derive" +version = "0.10.4" +source = "git+https://github.com/polytope-labs/ethereum_ssz?rev=be67c8cf3fb9ad34597db52d242f213536eca9a6#be67c8cf3fb9ad34597db52d242f213536eca9a6" +dependencies = [ + "darling 0.23.0", + "proc-macro2", + "quote", + "syn 2.0.116", +] + [[package]] name = "event-listener" version = "2.5.3" @@ -26952,29 +27034,19 @@ dependencies = [ ] [[package]] -name = "ssz-rs" -version = "0.8.0" -source = "git+https://github.com/polytope-labs/ssz-rs?branch=main#428ee5ea7fcf5895f43c52921b815f6082ecc3ea" +name = "ssz_types" +version = "0.14.1" +source = "git+https://github.com/polytope-labs/ssz_types?rev=16e1e2a2a50952283af74b6fe2c6f8af8367199f#16e1e2a2a50952283af74b6fe2c6f8af8367199f" dependencies = [ - "as-any", - "bitvec", - "hex", - "itertools 0.10.5", - "num-bigint 0.4.6", + "ethereum_serde_utils", + "ethereum_ssz", + "itertools 0.14.0", "parity-scale-codec", "serde", - "sha2 0.9.9", - "ssz-rs-derive", -] - -[[package]] -name = "ssz-rs-derive" -version = "0.8.0" -source = "git+https://github.com/polytope-labs/ssz-rs?branch=main#428ee5ea7fcf5895f43c52921b815f6082ecc3ea" -dependencies = [ - "proc-macro2", - "quote", - "syn 1.0.109", + "serde_derive", + "smallvec", + "tree_hash", + "typenum", ] [[package]] @@ -27987,15 +28059,22 @@ dependencies = [ name = "sync-committee-primitives" version = "0.1.1" dependencies = [ + "alloy-primitives 1.5.7", + "alloy-rlp", + "alloy-rlp-derive", "anyhow", "bls-utils", + "ethereum_ssz", + "ethereum_ssz_derive", "hex", "hex-literal 0.4.1", "parity-scale-codec", "primitive-types 0.13.1", "serde", "serde-hex-utils", - "ssz-rs", + "ssz_types", + "tree_hash", + "tree_hash_derive", ] [[package]] @@ -28008,6 +28087,7 @@ dependencies = [ "bls_on_arkworks", "dotenv", "env_logger 0.10.2", + "ethereum_ssz", "hex", "log", "parity-scale-codec", @@ -28018,12 +28098,13 @@ dependencies = [ "reqwest-middleware 0.2.5", "serde", "serde_json", - "ssz-rs", + "ssz_types", "sync-committee-primitives", "sync-committee-verifier", "tokio", "tokio-stream", "tracing", + "tree_hash", ] [[package]] @@ -28033,13 +28114,16 @@ dependencies = [ "anyhow", "ark-ec 0.4.2", "bls_on_arkworks", + "ethereum_ssz", "hex", "hex-literal 0.4.1", "ismp", "log", - "ssz-rs", + "primitive-types 0.13.1", + "ssz_types", "sync-committee-primitives", "thiserror 2.0.18", + "tree_hash", ] [[package]] @@ -28519,6 +28603,7 @@ dependencies = [ "bsc-prover", "bsc-verifier", "dotenv", + "ethereum_ssz", "futures", "geth-primitives", "hex", @@ -28533,7 +28618,7 @@ dependencies = [ "serde_json", "sp-core", "sp-crypto-hashing", - "ssz-rs", + "ssz_types", "tesseract-evm", "tesseract-primitives", "tokio", @@ -30039,6 +30124,29 @@ dependencies = [ "strength_reduce", ] +[[package]] +name = "tree_hash" +version = "0.12.1" +source = "git+https://github.com/polytope-labs/tree_hash?rev=a22ca828bfa199585b0e2f8275a8214305fc336f#a22ca828bfa199585b0e2f8275a8214305fc336f" +dependencies = [ + "alloy-primitives 1.5.7", + "ethereum_hashing", + "ethereum_ssz", + "smallvec", + "typenum", +] + +[[package]] +name = "tree_hash_derive" +version = "0.12.1" +source = "git+https://github.com/polytope-labs/tree_hash?rev=a22ca828bfa199585b0e2f8275a8214305fc336f#a22ca828bfa199585b0e2f8275a8214305fc336f" +dependencies = [ + "darling 0.23.0", + "proc-macro2", + "quote", + "syn 2.0.116", +] + [[package]] name = "trie-db" version = "0.31.0" diff --git a/Cargo.toml b/Cargo.toml index a215d2d1c..e7409a03c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -160,6 +160,11 @@ lto = "thin" strip = true [workspace.dependencies] +ssz = { package = "ethereum_ssz", version = "0.10", default-features = false } +ssz_derive = { package = "ethereum_ssz_derive", version = "0.10" } +tree_hash = { version = "0.12", default-features = false } +tree_hash_derive = { version = "0.12" } +ssz_types = { version = "0.14", default-features = false } polkadot-sdk = { version = "=2606.0.0", default-features = false } rocksdb = { version = "0.24.0", features = ["bindgen-runtime"] } @@ -406,3 +411,12 @@ features = ["derive"] [workspace.dependencies.reconnecting-jsonrpsee-ws-client] version = "0.5.0" default-features = false + +[patch.crates-io] +ethereum_ssz = { git = "https://github.com/polytope-labs/ethereum_ssz", rev = "be67c8cf3fb9ad34597db52d242f213536eca9a6" } +ethereum_ssz_derive = { git = "https://github.com/polytope-labs/ethereum_ssz", rev = "be67c8cf3fb9ad34597db52d242f213536eca9a6" } +tree_hash = { git = "https://github.com/polytope-labs/tree_hash", rev = "a22ca828bfa199585b0e2f8275a8214305fc336f" } +tree_hash_derive = { git = "https://github.com/polytope-labs/tree_hash", rev = "a22ca828bfa199585b0e2f8275a8214305fc336f" } +ssz_types = { git = "https://github.com/polytope-labs/ssz_types", rev = "16e1e2a2a50952283af74b6fe2c6f8af8367199f" } +ethereum_serde_utils = { git = "https://github.com/polytope-labs/ethereum_serde_utils", rev = "b67233cce50833e2082ebcb16a9971df063c7ecb" } +ethereum_hashing = { git = "https://github.com/polytope-labs/ethereum_hashing", rev = "777b16da793573d5a0dd37d33d77f24ce4b624e7" } diff --git a/modules/consensus/bsc/prover/Cargo.toml b/modules/consensus/bsc/prover/Cargo.toml index f6567f8b3..cb7c707be 100644 --- a/modules/consensus/bsc/prover/Cargo.toml +++ b/modules/consensus/bsc/prover/Cargo.toml @@ -25,5 +25,6 @@ features = ["sp-core", "sp-crypto-hashing"] [dev-dependencies] tokio = { workspace = true, features = ["macros"] } dotenv = "0.15.0" -ssz-rs = { git = "https://github.com/polytope-labs/ssz-rs", branch = "main", default-features = false, features = ["std"] } +ssz = { workspace = true, features = ["scale"] } +ssz_types = { workspace = true, features = ["scale"] } futures = { workspace = true } diff --git a/modules/consensus/bsc/prover/src/test.rs b/modules/consensus/bsc/prover/src/test.rs index 9151915a1..a87b3d5ce 100644 --- a/modules/consensus/bsc/prover/src/test.rs +++ b/modules/consensus/bsc/prover/src/test.rs @@ -20,7 +20,8 @@ use bsc_verifier::{ }; use ismp::messaging::Keccak256; use polkadot_sdk::*; -use ssz_rs::{Bitvector, Deserialize}; +use ssz::Decode; +use ssz_types::BitVector; use std::time::Duration; use crate::{get_rotation_block, BscPosProver, UpdateParams}; @@ -131,7 +132,7 @@ async fn verify_bsc_pos_headers() { // Reject updates with insufficient BLS participation from the current set. let extra_data = parse_extra::(&update.attested_header) .expect("infallible: prover already parsed extra data"); - let validators_bit_set = Bitvector::::deserialize( + let validators_bit_set = BitVector::::from_ssz_bytes( extra_data.vote_address_set.to_le_bytes().to_vec().as_slice(), ) .expect("infallible: prover already parsed extra data"); @@ -241,7 +242,7 @@ async fn verify_bsc_pos_headers() { let extra_data = parse_extra::(&update.attested_header) .expect("infallible: prover already parsed extra data"); - let validators_bit_set = Bitvector::::deserialize( + let validators_bit_set = BitVector::::from_ssz_bytes( extra_data.vote_address_set.to_le_bytes().to_vec().as_slice(), ) .expect("infallible: prover already parsed extra data"); diff --git a/modules/consensus/bsc/verifier/Cargo.toml b/modules/consensus/bsc/verifier/Cargo.toml index 1cd93f67a..c22c9fa76 100644 --- a/modules/consensus/bsc/verifier/Cargo.toml +++ b/modules/consensus/bsc/verifier/Cargo.toml @@ -21,7 +21,8 @@ geth-primitives = { workspace = true, default-features = false } bls-utils = { workspace = true, default-features = false } sync-committee-primitives = { workspace = true, default-features = false } bls = { workspace = true } -ssz-rs = { git = "https://github.com/polytope-labs/ssz-rs", branch = "main", default-features = false } +ssz = { workspace = true, features = ["scale"] } +ssz_types = { workspace = true, features = ["scale"] } [dependencies.polkadot-sdk] workspace = true @@ -42,5 +43,6 @@ std = [ "bls-utils/std", "sync-committee-primitives/std", "geth-primitives/std", - "ssz-rs/std", + "ssz/std", + "ssz_types/std", ] diff --git a/modules/consensus/bsc/verifier/src/lib.rs b/modules/consensus/bsc/verifier/src/lib.rs index d67ad773b..4892e6726 100644 --- a/modules/consensus/bsc/verifier/src/lib.rs +++ b/modules/consensus/bsc/verifier/src/lib.rs @@ -24,7 +24,8 @@ use geth_primitives::{CodecHeader, Header}; use ismp::messaging::Keccak256; use primitives::{compute_epoch, parse_extra, BscClientUpdate, Config, VALIDATOR_BIT_SET_SIZE}; use sp_core::H256; -use ssz_rs::{Bitvector, Deserialize}; +use ssz::Decode; +use ssz_types::BitVector; use sync_committee_primitives::constants::BlsPublicKey; pub mod error; @@ -60,7 +61,7 @@ pub fn verify_bsc_header( Err(Error::EmptyVoteData)? } - let validators_bit_set = Bitvector::::deserialize( + let validators_bit_set = BitVector::::from_ssz_bytes( extra_data.vote_address_set.to_le_bytes().to_vec().as_slice(), ) .map_err(|_| Error::DeserializeVoteAddressSet)?; @@ -73,7 +74,7 @@ pub fn verify_bsc_header( if validators_bit_set .iter() .enumerate() - .any(|(i, bit)| i >= current_validators.len() && *bit) + .any(|(i, bit)| i >= current_validators.len() && bit) { Err(Error::VoteAddressSetBeyondValidatorCount)? } @@ -83,7 +84,7 @@ pub fn verify_bsc_header( let participant_count = validators_bit_set .iter() .take(current_validators.len()) - .filter(|bit| **bit) + .filter(|bit| *bit) .count(); if participant_count < ((2 * current_validators.len()) / 3) { Err(Error::NotEnoughParticipants)? @@ -113,7 +114,7 @@ pub fn verify_bsc_header( let participants: Vec = current_validators .iter() .zip(validators_bit_set.iter()) - .filter_map(|(validator, bit)| if *bit { Some(validator.clone()) } else { None }) + .filter_map(|(validator, bit)| if bit { Some(validator.clone()) } else { None }) .collect(); let aggregate_public_key = aggregate_public_keys(&participants) diff --git a/modules/consensus/sync-committee/primitives/Cargo.toml b/modules/consensus/sync-committee/primitives/Cargo.toml index 42a356249..9f5e81d55 100644 --- a/modules/consensus/sync-committee/primitives/Cargo.toml +++ b/modules/consensus/sync-committee/primitives/Cargo.toml @@ -18,14 +18,25 @@ anyhow = { workspace = true, default-features = false } serde-hex-utils = { workspace = true, default-features = false } bls-utils = { workspace = true, default-features = false } -ssz-rs = { git = "https://github.com/polytope-labs/ssz-rs", branch = "main", default-features = false } +ssz = { workspace = true, features = ["scale"] } +ssz_derive = { workspace = true } +ssz_types = { workspace = true, features = ["scale"] } +tree_hash = { workspace = true, features = ["sha2"] } +tree_hash_derive = { workspace = true } + +# Always compiled: the execution header path (`execution_header.rs`) is now part of the +# fork-agnostic verifier, so these can no longer be gated behind `glamsterdam`. +alloy-primitives = { workspace = true } +alloy-rlp = { workspace = true } +alloy-rlp-derive = { workspace = true } [features] default = ["std"] std = [ - "ssz-rs/default", - "ssz-rs/serde", + "ssz/std", + "ssz_types/std", + "tree_hash/std", 'codec/std', "primitive-types/std", "anyhow/std", @@ -33,5 +44,12 @@ std = [ "serde", "serde-hex-utils/std", "bls-utils/std", + "alloy-primitives/std", + "alloy-primitives/serde", + "alloy-rlp/std", ] -nofulu = [] +# Gloas (EIP-7732) consensus types for the prover's `BeaconState`/`BeaconBlockBody` ssz layout. +# The verifier no longer needs this: it dispatches the execution path at runtime on the enum in +# `ExecutionPayloadProof`, so only the prover, whose ssz layout is fixed at compile time, still +# selects the fork here. +glamsterdam = [] diff --git a/modules/consensus/sync-committee/primitives/src/consensus_types.rs b/modules/consensus/sync-committee/primitives/src/consensus_types.rs index 5c9ed41dc..54458c2b1 100644 --- a/modules/consensus/sync-committee/primitives/src/consensus_types.rs +++ b/modules/consensus/sync-committee/primitives/src/consensus_types.rs @@ -1,17 +1,41 @@ +#[cfg(not(feature = "glamsterdam"))] +use crate::deneb::KzgCommitment; use crate::{ constants::{ BlsPublicKey, BlsSignature, Bytes32, Epoch, ExecutionAddress, Gwei, Hash32, ParticipationFlags, Root, Slot, ValidatorIndex, Version, WithdrawalIndex, DEPOSIT_PROOF_LENGTH, JUSTIFICATION_BITS_LENGTH, }, - deneb::KzgCommitment, electra::*, ssz::{ByteList, ByteVector}, }; use alloc::{vec, vec::Vec}; -use ssz_rs::{prelude::*, Deserialize, List, Vector}; +#[cfg(feature = "glamsterdam")] +use ssz_types::ProgressiveList; +use ssz_types::{typenum::Unsigned, BitList, BitVector, FixedVector, VariableList}; -#[derive(Default, Debug, SimpleSerialize, Clone, PartialEq, Eq, codec::Encode, codec::Decode)] +/// The state lists EIP-7688 made progressive at Gloas. +/// +/// Before Gloas these are ordinary bounded lists, merkleized into a tree padded out to the bound. +/// From Gloas they grow with the data instead, so the bound goes away and only the element type +/// carries over. The alias keeps both shapes on one field declaration. +#[cfg(not(feature = "glamsterdam"))] +pub type StateList = VariableList; +#[cfg(feature = "glamsterdam")] +pub use crate::ssz::StateList; + +#[cfg(feature = "glamsterdam")] +use crate::{ + constants::{BUILDER_PENDING_PAYMENTS_LIMIT, PTC_WINDOW_LIMIT}, + deneb::MAX_BLOB_COMMITMENTS_PER_BLOCK, + gloas::{ + Builder, BuilderIndex, BuilderPendingPayment, BuilderPendingWithdrawal, + ExecutionPayloadBid, PayloadAttestation, SignedExecutionPayloadBid, + MAX_PAYLOAD_ATTESTATIONS, PTC_SIZE, + }, +}; + +#[derive(Default, Debug, ssz_derive::Encode, ssz_derive::Decode, tree_hash_derive::TreeHash, Clone, PartialEq, Eq, codec::Encode, codec::Decode)] #[cfg_attr(feature = "std", derive(serde::Serialize, serde::Deserialize))] pub struct BeaconBlockHeader { #[cfg_attr(feature = "std", serde(with = "serde_hex_utils::as_string"))] @@ -23,7 +47,7 @@ pub struct BeaconBlockHeader { pub body_root: Root, } -#[derive(Default, Clone, Debug, SimpleSerialize, PartialEq, Eq, codec::Encode, codec::Decode)] +#[derive(Default, Clone, Debug, ssz_derive::Encode, ssz_derive::Decode, tree_hash_derive::TreeHash, PartialEq, Eq, codec::Encode, codec::Decode)] #[cfg_attr(feature = "std", derive(serde::Serialize, serde::Deserialize))] pub struct Checkpoint { #[cfg_attr(feature = "std", serde(with = "serde_hex_utils::as_string"))] @@ -31,7 +55,7 @@ pub struct Checkpoint { pub root: Root, } -#[derive(Default, Debug, SimpleSerialize, Clone, PartialEq, Eq, codec::Encode, codec::Decode)] +#[derive(Default, Debug, ssz_derive::Encode, ssz_derive::Decode, tree_hash_derive::TreeHash, Clone, PartialEq, Eq, codec::Encode, codec::Decode)] #[cfg_attr(feature = "std", derive(serde::Serialize, serde::Deserialize))] pub struct Eth1Data { pub deposit_root: Root, @@ -40,7 +64,7 @@ pub struct Eth1Data { pub block_hash: Hash32, } -#[derive(Default, Debug, SimpleSerialize, Clone, PartialEq, Eq, codec::Encode, codec::Decode)] +#[derive(Default, Debug, ssz_derive::Encode, ssz_derive::Decode, tree_hash_derive::TreeHash, Clone, PartialEq, Eq, codec::Encode, codec::Decode)] #[cfg_attr(feature = "std", derive(serde::Serialize, serde::Deserialize))] pub struct Validator { #[cfg_attr(feature = "std", serde(rename = "pubkey"))] @@ -60,30 +84,31 @@ pub struct Validator { pub withdrawable_epoch: Epoch, } -#[derive(Default, Debug, SimpleSerialize, Clone, PartialEq, Eq, codec::Encode, codec::Decode)] +#[derive(Default, Debug, ssz_derive::Encode, ssz_derive::Decode, tree_hash_derive::TreeHash, Clone, PartialEq, Eq, codec::Encode, codec::Decode)] #[cfg_attr(feature = "std", derive(serde::Serialize, serde::Deserialize))] pub struct ProposerSlashing { pub signed_header_1: SignedBeaconBlockHeader, pub signed_header_2: SignedBeaconBlockHeader, } -#[derive(Default, Debug, SimpleSerialize, Clone, PartialEq, Eq, codec::Encode, codec::Decode)] +#[derive(Default, Debug, ssz_derive::Encode, ssz_derive::Decode, tree_hash_derive::TreeHash, Clone, PartialEq, Eq, codec::Encode, codec::Decode)] #[cfg_attr(feature = "std", derive(serde::Serialize, serde::Deserialize))] pub struct SignedBeaconBlockHeader { pub message: BeaconBlockHeader, pub signature: BlsSignature, } -#[derive(Default, Debug, SimpleSerialize, Clone, PartialEq, Eq, codec::Encode, codec::Decode)] +#[derive(Default, Debug, ssz_derive::Encode, ssz_derive::Decode, tree_hash_derive::TreeHash, Clone, PartialEq, Eq, codec::Encode, codec::Decode)] #[cfg_attr(feature = "std", derive(serde::Serialize, serde::Deserialize))] -pub struct IndexedAttestation { +#[cfg_attr(feature = "std", serde(bound = ""))] +pub struct IndexedAttestation { #[cfg_attr(feature = "std", serde(with = "serde_hex_utils::seq_of_str"))] - pub attesting_indices: List, + pub attesting_indices: VariableList, pub data: AttestationData, pub signature: BlsSignature, } -#[derive(Default, Clone, Debug, SimpleSerialize, PartialEq, Eq, codec::Encode, codec::Decode)] +#[derive(Default, Clone, Debug, ssz_derive::Encode, ssz_derive::Decode, tree_hash_derive::TreeHash, PartialEq, Eq, codec::Encode, codec::Decode)] #[cfg_attr(feature = "std", derive(serde::Serialize, serde::Deserialize))] pub struct AttestationData { #[cfg_attr(feature = "std", serde(with = "serde_hex_utils::as_string"))] @@ -95,33 +120,35 @@ pub struct AttestationData { pub target: Checkpoint, } -#[derive(Default, Debug, SimpleSerialize, Clone, PartialEq, Eq, codec::Encode, codec::Decode)] +#[derive(Default, Debug, ssz_derive::Encode, ssz_derive::Decode, tree_hash_derive::TreeHash, Clone, PartialEq, Eq, codec::Encode, codec::Decode)] #[cfg_attr(feature = "std", derive(serde::Serialize, serde::Deserialize))] -pub struct AttesterSlashing { +#[cfg_attr(feature = "std", serde(bound = ""))] +pub struct AttesterSlashing { pub attestation_1: IndexedAttestation, pub attestation_2: IndexedAttestation, } -#[derive(Default, Debug, SimpleSerialize, codec::Encode, codec::Decode, Clone, PartialEq, Eq)] +#[derive(Default, Debug, ssz_derive::Encode, ssz_derive::Decode, tree_hash_derive::TreeHash, codec::Encode, codec::Decode, Clone, PartialEq, Eq)] #[cfg_attr(feature = "std", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "std", serde(bound = ""))] pub struct Attestation< - const MAX_VALIDATORS_PER_COMMITTEE: usize, - const MAX_COMMITTEES_PER_SLOT: usize, + MAX_VALIDATORS_PER_COMMITTEE: Unsigned, + MAX_COMMITTEES_PER_SLOT: Unsigned, > { - pub aggregation_bits: Bitlist, + pub aggregation_bits: BitList, pub data: AttestationData, pub signature: BlsSignature, - pub committee_bits: Bitvector, + pub committee_bits: BitVector, } -#[derive(Default, Debug, SimpleSerialize, codec::Encode, codec::Decode, Clone, PartialEq, Eq)] +#[derive(Default, Debug, ssz_derive::Encode, ssz_derive::Decode, tree_hash_derive::TreeHash, codec::Encode, codec::Decode, Clone, PartialEq, Eq)] #[cfg_attr(feature = "std", derive(serde::Serialize, serde::Deserialize))] pub struct Deposit { - pub proof: Vector, + pub proof: FixedVector, pub data: DepositData, } -#[derive(Default, Debug, Clone, SimpleSerialize, codec::Encode, codec::Decode, PartialEq, Eq)] +#[derive(Default, Debug, Clone, ssz_derive::Encode, ssz_derive::Decode, tree_hash_derive::TreeHash, codec::Encode, codec::Decode, PartialEq, Eq)] #[cfg_attr(feature = "std", derive(serde::Serialize, serde::Deserialize))] pub struct DepositData { #[cfg_attr(feature = "std", serde(rename = "pubkey"))] @@ -132,7 +159,7 @@ pub struct DepositData { pub signature: BlsSignature, } -#[derive(Default, Debug, SimpleSerialize, codec::Encode, codec::Decode, Clone, PartialEq, Eq)] +#[derive(Default, Debug, ssz_derive::Encode, ssz_derive::Decode, tree_hash_derive::TreeHash, codec::Encode, codec::Decode, Clone, PartialEq, Eq)] #[cfg_attr(feature = "std", derive(serde::Serialize, serde::Deserialize))] pub struct VoluntaryExit { #[cfg_attr(feature = "std", serde(with = "serde_hex_utils::as_string"))] @@ -141,30 +168,32 @@ pub struct VoluntaryExit { pub validator_index: u64, } -#[derive(Default, Debug, SimpleSerialize, codec::Encode, codec::Decode, Clone, PartialEq, Eq)] +#[derive(Default, Debug, ssz_derive::Encode, ssz_derive::Decode, tree_hash_derive::TreeHash, codec::Encode, codec::Decode, Clone, PartialEq, Eq)] #[cfg_attr(feature = "std", derive(serde::Serialize, serde::Deserialize))] pub struct SignedVoluntaryExit { pub message: VoluntaryExit, pub signature: BlsSignature, } -#[derive(Default, Debug, Clone, SimpleSerialize, codec::Encode, codec::Decode, PartialEq, Eq)] +#[derive(Default, Debug, Clone, ssz_derive::Encode, ssz_derive::Decode, tree_hash_derive::TreeHash, codec::Encode, codec::Decode, PartialEq, Eq)] #[cfg_attr(feature = "std", derive(serde::Serialize, serde::Deserialize))] -pub struct SyncAggregate { - pub sync_committee_bits: Bitvector, +#[cfg_attr(feature = "std", serde(bound = ""))] +pub struct SyncAggregate { + pub sync_committee_bits: BitVector, pub sync_committee_signature: BlsSignature, } -#[derive(Default, Debug, SimpleSerialize, Clone, PartialEq, Eq, codec::Encode, codec::Decode)] +#[derive(Default, Debug, ssz_derive::Encode, ssz_derive::Decode, tree_hash_derive::TreeHash, Clone, PartialEq, Eq, codec::Encode, codec::Decode)] #[cfg_attr(feature = "std", derive(serde::Serialize, serde::Deserialize))] -pub struct SyncCommittee { +#[cfg_attr(feature = "std", serde(bound = ""))] +pub struct SyncCommittee { #[cfg_attr(feature = "std", serde(rename = "pubkeys"))] - pub public_keys: Vector, + pub public_keys: FixedVector, #[cfg_attr(feature = "std", serde(rename = "aggregate_pubkey"))] pub aggregate_public_key: BlsPublicKey, } -#[derive(Default, Debug, Clone, SimpleSerialize, PartialEq, Eq, codec::Encode, codec::Decode)] +#[derive(Default, Debug, Clone, ssz_derive::Encode, ssz_derive::Decode, tree_hash_derive::TreeHash, PartialEq, Eq, codec::Encode, codec::Decode)] #[cfg_attr(feature = "std", derive(serde::Serialize, serde::Deserialize))] pub struct Withdrawal { #[cfg_attr(feature = "std", serde(with = "serde_hex_utils::as_string"))] @@ -176,7 +205,7 @@ pub struct Withdrawal { pub amount: Gwei, } -#[derive(Default, Debug, Clone, SimpleSerialize, PartialEq, Eq, codec::Encode, codec::Decode)] +#[derive(Default, Debug, Clone, ssz_derive::Encode, ssz_derive::Decode, tree_hash_derive::TreeHash, PartialEq, Eq, codec::Encode, codec::Decode)] #[cfg_attr(feature = "std", derive(serde::Serialize, serde::Deserialize))] pub struct BlsToExecutionChange { #[cfg_attr(feature = "std", serde(with = "serde_hex_utils::as_string"))] @@ -186,23 +215,24 @@ pub struct BlsToExecutionChange { pub to_execution_address: ExecutionAddress, } -#[derive(Default, Debug, Clone, SimpleSerialize, PartialEq, Eq, codec::Encode, codec::Decode)] +#[derive(Default, Debug, Clone, ssz_derive::Encode, ssz_derive::Decode, tree_hash_derive::TreeHash, PartialEq, Eq, codec::Encode, codec::Decode)] #[cfg_attr(feature = "std", derive(serde::Serialize, serde::Deserialize))] pub struct SignedBlsToExecutionChange { message: BlsToExecutionChange, signature: BlsSignature, } -pub type Transaction = ByteList; +pub type Transaction = ByteList; -#[derive(Default, Debug, Clone, SimpleSerialize, PartialEq, Eq, codec::Encode, codec::Decode)] +#[derive(Default, Debug, Clone, ssz_derive::Encode, ssz_derive::Decode, tree_hash_derive::TreeHash, PartialEq, Eq, codec::Encode, codec::Decode)] #[cfg_attr(feature = "std", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "std", serde(bound = ""))] pub struct ExecutionPayload< - const BYTES_PER_LOGS_BLOOM: usize, - const MAX_EXTRA_DATA_BYTES: usize, - const MAX_BYTES_PER_TRANSACTION: usize, - const MAX_TRANSACTIONS_PER_PAYLOAD: usize, - const MAX_WITHDRAWALS_PER_PAYLOAD: usize, + BYTES_PER_LOGS_BLOOM: Unsigned, + MAX_EXTRA_DATA_BYTES: Unsigned, + MAX_BYTES_PER_TRANSACTION: Unsigned, + MAX_TRANSACTIONS_PER_PAYLOAD: Unsigned, + MAX_WITHDRAWALS_PER_PAYLOAD: Unsigned, > { pub parent_hash: Hash32, pub fee_recipient: ExecutionAddress, @@ -219,21 +249,22 @@ pub struct ExecutionPayload< #[cfg_attr(feature = "std", serde(with = "serde_hex_utils::as_string"))] pub timestamp: u64, pub extra_data: ByteList, - pub base_fee_per_gas: U256, + pub base_fee_per_gas: crate::ssz::U256, pub block_hash: Hash32, - pub transactions: List, MAX_TRANSACTIONS_PER_PAYLOAD>, - pub withdrawals: List, + pub transactions: VariableList, MAX_TRANSACTIONS_PER_PAYLOAD>, + pub withdrawals: VariableList, #[cfg_attr(feature = "std", serde(with = "serde_hex_utils::as_string"))] pub blob_gas_used: u64, #[cfg_attr(feature = "std", serde(with = "serde_hex_utils::as_string"))] pub excess_blob_gas: u64, } -#[derive(Default, Debug, Clone, SimpleSerialize, PartialEq, Eq, codec::Encode, codec::Decode)] +#[derive(Default, Debug, Clone, ssz_derive::Encode, ssz_derive::Decode, tree_hash_derive::TreeHash, PartialEq, Eq, codec::Encode, codec::Decode)] #[cfg_attr(feature = "std", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "std", serde(bound = ""))] pub struct ExecutionPayloadHeader< - const BYTES_PER_LOGS_BLOOM: usize, - const MAX_EXTRA_DATA_BYTES: usize, + BYTES_PER_LOGS_BLOOM: Unsigned, + MAX_EXTRA_DATA_BYTES: Unsigned, > { pub parent_hash: Hash32, pub fee_recipient: ExecutionAddress, @@ -250,7 +281,7 @@ pub struct ExecutionPayloadHeader< #[cfg_attr(feature = "std", serde(with = "serde_hex_utils::as_string"))] pub timestamp: u64, pub extra_data: ByteList, - pub base_fee_per_gas: U256, + pub base_fee_per_gas: crate::ssz::U256, pub block_hash: Hash32, pub transactions_root: Root, pub withdrawals_root: Root, @@ -260,39 +291,41 @@ pub struct ExecutionPayloadHeader< pub excess_blob_gas: u64, } -#[derive(Default, Debug, Clone, SimpleSerialize, PartialEq, Eq, codec::Encode, codec::Decode)] +#[derive(Default, Debug, Clone, ssz_derive::Encode, ssz_derive::Decode, tree_hash_derive::TreeHash, PartialEq, Eq, codec::Encode, codec::Decode)] #[cfg_attr(feature = "std", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "std", serde(bound = ""))] pub struct BeaconBlockBody< - const MAX_PROPOSER_SLASHINGS: usize, - const MAX_VALIDATORS_PER_COMMITTEE: usize, - const MAX_ATTESTER_SLASHINGS: usize, - const MAX_ATTESTATIONS: usize, - const MAX_DEPOSITS: usize, - const MAX_VOLUNTARY_EXITS: usize, - const SYNC_COMMITTEE_SIZE: usize, - const BYTES_PER_LOGS_BLOOM: usize, - const MAX_EXTRA_DATA_BYTES: usize, - const MAX_BYTES_PER_TRANSACTION: usize, - const MAX_TRANSACTIONS_PER_PAYLOAD: usize, - const MAX_WITHDRAWALS_PER_PAYLOAD: usize, - const MAX_BLS_TO_EXECUTION_CHANGES: usize, - const MAX_BLOB_COMMITMENTS_PER_BLOCK: usize, - const MAX_COMMITTEES_PER_SLOT: usize, - const MAX_DEPOSIT_REQUESTS_PER_PAYLOAD: usize, - const MAX_WITHDRAWAL_REQUESTS_PER_PAYLOAD: usize, - const MAX_CONSOLIDATION_REQUESTS_PER_PAYLOAD: usize, + MAX_PROPOSER_SLASHINGS: Unsigned, + MAX_VALIDATORS_PER_COMMITTEE: Unsigned, + MAX_ATTESTER_SLASHINGS: Unsigned, + MAX_ATTESTATIONS: Unsigned, + MAX_DEPOSITS: Unsigned, + MAX_VOLUNTARY_EXITS: Unsigned, + SYNC_COMMITTEE_SIZE: Unsigned, + BYTES_PER_LOGS_BLOOM: Unsigned, + MAX_EXTRA_DATA_BYTES: Unsigned, + MAX_BYTES_PER_TRANSACTION: Unsigned, + MAX_TRANSACTIONS_PER_PAYLOAD: Unsigned, + MAX_WITHDRAWALS_PER_PAYLOAD: Unsigned, + MAX_BLS_TO_EXECUTION_CHANGES: Unsigned, + MAX_BLOB_COMMITMENTS_PER_BLOCK: Unsigned, + MAX_COMMITTEES_PER_SLOT: Unsigned, + MAX_DEPOSIT_REQUESTS_PER_PAYLOAD: Unsigned, + MAX_WITHDRAWAL_REQUESTS_PER_PAYLOAD: Unsigned, + MAX_CONSOLIDATION_REQUESTS_PER_PAYLOAD: Unsigned, > { pub randao_reveal: BlsSignature, pub eth1_data: Eth1Data, pub graffiti: Bytes32, - pub proposer_slashings: List, + pub proposer_slashings: VariableList, pub attester_slashings: - List, MAX_ATTESTER_SLASHINGS>, + VariableList, MAX_ATTESTER_SLASHINGS>, pub attestations: - List, MAX_ATTESTATIONS>, - pub deposits: List, - pub voluntary_exits: List, + VariableList, MAX_ATTESTATIONS>, + pub deposits: VariableList, + pub voluntary_exits: VariableList, pub sync_aggregate: SyncAggregate, + #[cfg(not(feature = "glamsterdam"))] pub execution_payload: ExecutionPayload< BYTES_PER_LOGS_BLOOM, MAX_EXTRA_DATA_BYTES, @@ -300,36 +333,68 @@ pub struct BeaconBlockBody< MAX_TRANSACTIONS_PER_PAYLOAD, MAX_WITHDRAWALS_PER_PAYLOAD, >, - pub bls_to_execution_changes: List, - pub blob_kzg_commitments: List, + pub bls_to_execution_changes: VariableList, + #[cfg(not(feature = "glamsterdam"))] + pub blob_kzg_commitments: VariableList, + #[cfg(not(feature = "glamsterdam"))] pub execution_requests: ExecutionRequests< MAX_DEPOSIT_REQUESTS_PER_PAYLOAD, MAX_WITHDRAWAL_REQUESTS_PER_PAYLOAD, MAX_CONSOLIDATION_REQUESTS_PER_PAYLOAD, >, + // [New in Gloas:EIP7732] the payload is no longer in the body. The builder's bid commits to + // the execution block hash and the payload itself is revealed separately. + #[cfg(feature = "glamsterdam")] + pub signed_execution_payload_bid: SignedExecutionPayloadBid, + #[cfg(feature = "glamsterdam")] + pub payload_attestations: VariableList, + #[cfg(feature = "glamsterdam")] + pub parent_execution_requests: ExecutionRequests< + MAX_DEPOSIT_REQUESTS_PER_PAYLOAD, + MAX_WITHDRAWAL_REQUESTS_PER_PAYLOAD, + MAX_CONSOLIDATION_REQUESTS_PER_PAYLOAD, + >, + /// Gloas moves the execution payload out of the block body, so the payload bounds no longer + /// appear in any field. An unused type parameter is an error where an unused const parameter + /// was not, so they are parked here. Every derive skips this field, so it never reaches the + /// wire and does not affect the hash tree root. + #[cfg(feature = "glamsterdam")] + #[ssz(skip_serializing, skip_deserializing)] + #[tree_hash(skip_hashing)] + #[codec(skip)] + #[cfg_attr(feature = "std", serde(skip))] + pub phantom: core::marker::PhantomData<( + BYTES_PER_LOGS_BLOOM, + MAX_EXTRA_DATA_BYTES, + MAX_BYTES_PER_TRANSACTION, + MAX_TRANSACTIONS_PER_PAYLOAD, + MAX_WITHDRAWALS_PER_PAYLOAD, + MAX_BLOB_COMMITMENTS_PER_BLOCK, + )>, } -#[derive(Default, Debug, Clone, PartialEq, Eq, SimpleSerialize, codec::Encode, codec::Decode)] +#[derive(Default, Debug, Clone, PartialEq, Eq, ssz_derive::Encode, ssz_derive::Decode, tree_hash_derive::TreeHash, codec::Encode, codec::Decode)] #[cfg_attr(feature = "std", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "std", serde(bound = ""))] pub struct BeaconBlock< - const MAX_PROPOSER_SLASHINGS: usize, - const MAX_VALIDATORS_PER_COMMITTEE: usize, - const MAX_ATTESTER_SLASHINGS: usize, - const MAX_ATTESTATIONS: usize, - const MAX_DEPOSITS: usize, - const MAX_VOLUNTARY_EXITS: usize, - const SYNC_COMMITTEE_SIZE: usize, - const BYTES_PER_LOGS_BLOOM: usize, - const MAX_EXTRA_DATA_BYTES: usize, - const MAX_BYTES_PER_TRANSACTION: usize, - const MAX_TRANSACTIONS_PER_PAYLOAD: usize, - const MAX_WITHDRAWALS_PER_PAYLOAD: usize, - const MAX_BLS_TO_EXECUTION_CHANGES: usize, - const MAX_BLOB_COMMITMENTS_PER_BLOCK: usize, - const MAX_COMMITTEES_PER_SLOT: usize, - const MAX_DEPOSIT_REQUESTS_PER_PAYLOAD: usize, - const MAX_WITHDRAWAL_REQUESTS_PER_PAYLOAD: usize, - const MAX_CONSOLIDATION_REQUESTS_PER_PAYLOAD: usize, + MAX_PROPOSER_SLASHINGS: Unsigned, + MAX_VALIDATORS_PER_COMMITTEE: Unsigned, + MAX_ATTESTER_SLASHINGS: Unsigned, + MAX_ATTESTATIONS: Unsigned, + MAX_DEPOSITS: Unsigned, + MAX_VOLUNTARY_EXITS: Unsigned, + SYNC_COMMITTEE_SIZE: Unsigned, + BYTES_PER_LOGS_BLOOM: Unsigned, + MAX_EXTRA_DATA_BYTES: Unsigned, + MAX_BYTES_PER_TRANSACTION: Unsigned, + MAX_TRANSACTIONS_PER_PAYLOAD: Unsigned, + MAX_WITHDRAWALS_PER_PAYLOAD: Unsigned, + MAX_BLS_TO_EXECUTION_CHANGES: Unsigned, + MAX_BLOB_COMMITMENTS_PER_BLOCK: Unsigned, + MAX_COMMITTEES_PER_SLOT: Unsigned, + MAX_DEPOSIT_REQUESTS_PER_PAYLOAD: Unsigned, + MAX_WITHDRAWAL_REQUESTS_PER_PAYLOAD: Unsigned, + MAX_CONSOLIDATION_REQUESTS_PER_PAYLOAD: Unsigned, > { #[cfg_attr(feature = "std", serde(with = "serde_hex_utils::as_string"))] pub slot: Slot, @@ -357,8 +422,25 @@ pub struct BeaconBlock< MAX_WITHDRAWAL_REQUESTS_PER_PAYLOAD, MAX_CONSOLIDATION_REQUESTS_PER_PAYLOAD, >, + /// Gloas moves the execution payload out of the block body, so the payload bounds no longer + /// appear in any field. An unused type parameter is an error where an unused const parameter + /// was not, so they are parked here. Every derive skips this field, so it never reaches the + /// wire and does not affect the hash tree root. + #[cfg(feature = "glamsterdam")] + #[ssz(skip_serializing, skip_deserializing)] + #[tree_hash(skip_hashing)] + #[codec(skip)] + #[cfg_attr(feature = "std", serde(skip))] + pub phantom: core::marker::PhantomData<( + BYTES_PER_LOGS_BLOOM, + MAX_EXTRA_DATA_BYTES, + MAX_BYTES_PER_TRANSACTION, + MAX_TRANSACTIONS_PER_PAYLOAD, + MAX_WITHDRAWALS_PER_PAYLOAD, + MAX_BLOB_COMMITMENTS_PER_BLOCK, + )>, } -#[derive(Default, Debug, SimpleSerialize, Clone, PartialEq, Eq, codec::Encode, codec::Decode)] +#[derive(Default, Debug, ssz_derive::Encode, ssz_derive::Decode, tree_hash_derive::TreeHash, Clone, PartialEq, Eq, codec::Encode, codec::Decode)] #[cfg_attr(feature = "std", derive(serde::Serialize, serde::Deserialize))] pub struct Fork { #[cfg_attr(feature = "std", serde(with = "serde_hex_utils::as_hex"))] @@ -369,7 +451,7 @@ pub struct Fork { pub epoch: Epoch, } -#[derive(Default, Debug, SimpleSerialize, Clone, codec::Encode, codec::Decode)] +#[derive(Default, Debug, ssz_derive::Encode, ssz_derive::Decode, tree_hash_derive::TreeHash, Clone, codec::Encode, codec::Decode)] #[cfg_attr(feature = "std", derive(serde::Serialize, serde::Deserialize))] pub struct ForkData { #[cfg_attr(feature = "std", serde(with = "serde_hex_utils::as_hex"))] @@ -377,29 +459,36 @@ pub struct ForkData { pub genesis_validators_root: Root, } -#[derive(Default, Debug, SimpleSerialize, Clone, PartialEq, Eq, codec::Encode, codec::Decode)] +#[derive(Default, Debug, ssz_derive::Encode, ssz_derive::Decode, tree_hash_derive::TreeHash, Clone, PartialEq, Eq, codec::Encode, codec::Decode)] #[cfg_attr(feature = "std", derive(serde::Serialize, serde::Deserialize))] pub struct HistoricalSummary { pub block_summary_root: Root, pub state_summary_root: Root, } -#[derive(Default, Debug, SimpleSerialize, Clone, PartialEq, Eq, codec::Encode, codec::Decode)] +#[derive(Default, Debug, ssz_derive::Encode, ssz_derive::Decode, tree_hash_derive::TreeHash, Clone, PartialEq, Eq, codec::Encode, codec::Decode)] #[cfg_attr(feature = "std", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "std", serde(bound = ""))] +// [Modified in Gloas:EIP7688] the state hashes as a progressive container, which is what keeps a +// field's generalized index from moving when a later fork adds or drops one. +#[cfg_attr( + feature = "glamsterdam", + tree_hash(struct_behaviour = "progressive_container", active_fields(1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1)) +)] pub struct BeaconState< - const SLOTS_PER_HISTORICAL_ROOT: usize, - const HISTORICAL_ROOTS_LIMIT: usize, - const ETH1_DATA_VOTES_BOUND: usize, - const VALIDATOR_REGISTRY_LIMIT: usize, - const EPOCHS_PER_HISTORICAL_VECTOR: usize, - const EPOCHS_PER_SLASHINGS_VECTOR: usize, - const SYNC_COMMITTEE_SIZE: usize, - const BYTES_PER_LOGS_BLOOM: usize, - const MAX_EXTRA_DATA_BYTES: usize, - const PENDING_DEPOSITS_LIMIT: usize, - const PENDING_CONSOLIDATIONS_LIMIT: usize, - const PENDING_PARTIAL_WITHDRAWALS_LIMIT: usize, - const PROPOSER_LOOK_AHEAD_LIMIT: usize, + SLOTS_PER_HISTORICAL_ROOT: Unsigned, + HISTORICAL_ROOTS_LIMIT: Unsigned, + ETH1_DATA_VOTES_BOUND: Unsigned, + VALIDATOR_REGISTRY_LIMIT: Unsigned, + EPOCHS_PER_HISTORICAL_VECTOR: Unsigned, + EPOCHS_PER_SLASHINGS_VECTOR: Unsigned, + SYNC_COMMITTEE_SIZE: Unsigned, + BYTES_PER_LOGS_BLOOM: Unsigned, + MAX_EXTRA_DATA_BYTES: Unsigned, + PENDING_DEPOSITS_LIMIT: Unsigned, + PENDING_CONSOLIDATIONS_LIMIT: Unsigned, + PENDING_PARTIAL_WITHDRAWALS_LIMIT: Unsigned, + PROPOSER_LOOK_AHEAD_LIMIT: Unsigned, > { #[cfg_attr(feature = "std", serde(with = "serde_hex_utils::as_string"))] pub genesis_time: u64, @@ -408,38 +497,43 @@ pub struct BeaconState< pub slot: Slot, pub fork: Fork, pub latest_block_header: BeaconBlockHeader, - pub block_roots: Vector, - pub state_roots: Vector, - pub historical_roots: List, + pub block_roots: FixedVector, + pub state_roots: FixedVector, + pub historical_roots: VariableList, pub eth1_data: Eth1Data, - pub eth1_data_votes: List, + pub eth1_data_votes: VariableList, #[cfg_attr(feature = "std", serde(with = "serde_hex_utils::as_string"))] pub eth1_deposit_index: u64, - pub validators: List, + pub validators: StateList, #[cfg_attr(feature = "std", serde(with = "serde_hex_utils::seq_of_str"))] - pub balances: List, - pub randao_mixes: Vector, + pub balances: StateList, + pub randao_mixes: FixedVector, #[cfg_attr(feature = "std", serde(with = "serde_hex_utils::seq_of_str"))] - pub slashings: Vector, + pub slashings: FixedVector, #[cfg_attr(feature = "std", serde(with = "serde_hex_utils::seq_of_u8_str_or_hex"))] - pub previous_epoch_participation: List, + pub previous_epoch_participation: StateList, #[cfg_attr(feature = "std", serde(with = "serde_hex_utils::seq_of_u8_str_or_hex"))] - pub current_epoch_participation: List, - pub justification_bits: Bitvector, + pub current_epoch_participation: StateList, + pub justification_bits: BitVector, pub previous_justified_checkpoint: Checkpoint, pub current_justified_checkpoint: Checkpoint, pub finalized_checkpoint: Checkpoint, #[cfg_attr(feature = "std", serde(with = "serde_hex_utils::seq_of_str"))] - pub inactivity_scores: List, + pub inactivity_scores: StateList, pub current_sync_committee: SyncCommittee, pub next_sync_committee: SyncCommittee, + #[cfg(not(feature = "glamsterdam"))] pub latest_execution_payload_header: ExecutionPayloadHeader, + // [New in Gloas:EIP7732] takes over the slot the payload header used to occupy, so the + // generalized index of the execution leaf is unchanged. + #[cfg(feature = "glamsterdam")] + pub latest_block_hash: Hash32, #[cfg_attr(feature = "std", serde(with = "serde_hex_utils::as_string"))] pub next_withdrawal_index: WithdrawalIndex, #[cfg_attr(feature = "std", serde(with = "serde_hex_utils::as_string"))] pub next_withdrawal_validator_index: ValidatorIndex, - pub historical_summaries: List, + pub historical_summaries: VariableList, #[cfg_attr(feature = "std", serde(with = "serde_hex_utils::as_string"))] pub deposit_requests_start_index: u64, #[cfg_attr(feature = "std", serde(with = "serde_hex_utils::as_string"))] @@ -452,11 +546,41 @@ pub struct BeaconState< pub consolidation_balance_to_consume: Gwei, #[cfg_attr(feature = "std", serde(with = "serde_hex_utils::as_string"))] pub earliest_consolidation_epoch: Epoch, - pending_deposits: List, - pending_partial_withdrawals: List, - pending_consolidations: List, + pending_deposits: StateList, + pending_partial_withdrawals: + StateList, + pending_consolidations: StateList, // [New in Fulu:EIP7917] - #[cfg(not(feature = "nofulu"))] #[cfg_attr(feature = "std", serde(with = "serde_hex_utils::seq_of_str"))] - proposer_lookahead: Vector, + proposer_lookahead: FixedVector, + // [New in Gloas:EIP7732] the builder registry and payload timeliness bookkeeping. Nothing + // here is proven, but the fields are part of the container, so they have to be present for + // the state to hash to the root the sync committee signed over. + #[cfg(feature = "glamsterdam")] + builders: ProgressiveList, + #[cfg(feature = "glamsterdam")] + #[cfg_attr(feature = "std", serde(with = "serde_hex_utils::as_string"))] + next_withdrawal_builder_index: BuilderIndex, + #[cfg(feature = "glamsterdam")] + execution_payload_availability: BitVector, + #[cfg(feature = "glamsterdam")] + builder_pending_payments: FixedVector, + #[cfg(feature = "glamsterdam")] + builder_pending_withdrawals: ProgressiveList, + #[cfg(feature = "glamsterdam")] + latest_execution_payload_bid: ExecutionPayloadBid, + #[cfg(feature = "glamsterdam")] + payload_expected_withdrawals: ProgressiveList, + #[cfg(feature = "glamsterdam")] + #[cfg_attr(feature = "std", serde(with = "serde_hex_utils::seq_of_seq_of_str"))] + ptc_window: FixedVector, PTC_WINDOW_LIMIT>, + /// Gloas replaces the execution payload header with a block hash, so the payload bounds no + /// longer appear in any field. Skipped by every derive, so it does not reach the wire and the + /// progressive `active_fields` count is unaffected. + #[cfg(feature = "glamsterdam")] + #[ssz(skip_serializing, skip_deserializing)] + #[tree_hash(skip_hashing)] + #[codec(skip)] + #[cfg_attr(feature = "std", serde(skip))] + pub phantom: core::marker::PhantomData<(BYTES_PER_LOGS_BLOOM, MAX_EXTRA_DATA_BYTES)>, } diff --git a/modules/consensus/sync-committee/primitives/src/constants.rs b/modules/consensus/sync-committee/primitives/src/constants.rs index 96654d677..143e49ace 100644 --- a/modules/consensus/sync-committee/primitives/src/constants.rs +++ b/modules/consensus/sync-committee/primitives/src/constants.rs @@ -1,12 +1,13 @@ +use tree_hash::Hash256; use crate::domains::DomainType; -use ssz_rs::Node; + pub type BlsPublicKey = ByteVector; pub type BlsSignature = ByteVector; pub type Epoch = u64; pub type Slot = u64; -pub type Root = Node; +pub type Root = Bytes32; pub type ParticipationFlags = u8; pub type CommitteeIndex = u64; @@ -19,13 +20,13 @@ pub type Version = [u8; 4]; pub type ForkDigest = [u8; 4]; pub type Domain = [u8; 32]; -pub type ExecutionAddress = ByteVector<20>; +pub type ExecutionAddress = ByteVector; pub type ChainId = usize; pub type NetworkId = usize; pub type RandaoReveal = BlsSignature; -pub type Bytes32 = ByteVector<32>; +pub type Bytes32 = ByteVector; pub const BLS_PUBLIC_KEY_BYTES_LEN: usize = 48; pub const BLS_SECRET_KEY_BYTES_LEN: usize = 32; @@ -85,6 +86,68 @@ pub const PENDING_CONSOLIDATIONS_LIMIT: usize = 2usize.saturating_pow(18); pub const PROPOSER_LOOK_AHEAD_LIMIT_ETHEREUM: usize = 64; pub const PROPOSER_LOOK_AHEAD_LIMIT_GNO: usize = 32; +/// Marks a fork that has not been scheduled yet. +pub const FAR_FUTURE_EPOCH: Epoch = u64::MAX; + +/// `builder_pending_payments` holds `2 * SLOTS_PER_EPOCH` entries and `ptc_window` holds +/// `(2 + MIN_SEED_LOOKAHEAD) * SLOTS_PER_EPOCH`, with MIN_SEED_LOOKAHEAD of 1. Both are sized for +/// the ethereum preset, which is every network Gloas runs on today; gnosis halves SLOTS_PER_EPOCH +/// and has not published a gloas preset, so it will need these threaded through as const generics +/// the way PROPOSER_LOOK_AHEAD_LIMIT is when it schedules the fork. +pub const BUILDER_PENDING_PAYMENTS_LIMIT: usize = 64; +pub const PTC_WINDOW_LIMIT: usize = 96; + + +/// Type level counterparts of the SSZ bounds above. +/// +/// `ssz_types` takes its capacities as type level integers rather than `const` values, so every +/// bound needs a type as well as a constant. Rust keeps types and values in separate namespaces, +/// so these deliberately reuse the constants' names: `VariableList` picks up the +/// type and `vec![0; MAX_DEPOSITS]` picks up the constant, and no use site has to change. +pub mod bounds { + use ssz_types::typenum::*; + + pub type BLS_PUBLIC_KEY_BYTES_LEN = U48; + pub type BLS_SECRET_KEY_BYTES_LEN = U32; + pub type BLS_SIGNATURE_BYTES_LEN = U96; + pub type SYNC_COMMITTEE_SIZE = U512; + pub type MAX_WITHDRAWALS_PER_PAYLOAD = U16; + pub type MAX_BLS_TO_EXECUTION_CHANGES = U16; + pub type MAX_VALIDATORS_PER_WITHDRAWALS_SWEEP = U16384; + pub type MAX_COMMITTEES_PER_SLOT = U64; + pub type MAX_VALIDATORS_PER_COMMITTEE = U131072; + pub type SLOTS_PER_HISTORICAL_ROOT = U8192; + pub type EPOCHS_PER_HISTORICAL_VECTOR = U65536; + pub type EPOCHS_PER_SLASHINGS_VECTOR = U8192; + pub type HISTORICAL_ROOTS_LIMIT = U16777216; + pub type VALIDATOR_REGISTRY_LIMIT = U1099511627776; + pub type MAX_PROPOSER_SLASHINGS = U16; + pub type MAX_ATTESTER_SLASHINGS = U1; + pub type MAX_ATTESTATIONS = U8; + pub type MAX_DEPOSITS = U16; + pub type MAX_VOLUNTARY_EXITS = U16; + pub type JUSTIFICATION_BITS_LENGTH = U4; + pub type MAX_BYTES_PER_TRANSACTION = U1073741824; + pub type MAX_TRANSACTIONS_PER_PAYLOAD = U1048576; + pub type BYTES_PER_LOGS_BLOOM = U256; + pub type MAX_EXTRA_DATA_BYTES = U32; + pub type DEPOSIT_PROOF_LENGTH = U33; + pub type ETH1_DATA_VOTES_BOUND_ETH = U2048; + pub type ETH1_DATA_VOTES_BOUND_GNO = U1024; + pub type MAX_DEPOSIT_REQUESTS_PER_PAYLOAD = U8192; + pub type MAX_WITHDRAWAL_REQUESTS_PER_PAYLOAD = U65536; + pub type MAX_CONSOLIDATION_REQUESTS_PER_PAYLOAD = U8; + pub type PENDING_DEPOSITS_LIMIT = U134217728; + pub type PENDING_PARTIAL_WITHDRAWALS_LIMIT = U134217728; + pub type PENDING_CONSOLIDATIONS_LIMIT = U262144; + pub type PROPOSER_LOOK_AHEAD_LIMIT_ETHEREUM = U64; + pub type PROPOSER_LOOK_AHEAD_LIMIT_GNO = U32; + pub type BUILDER_PENDING_PAYMENTS_LIMIT = U64; + pub type PTC_WINDOW_LIMIT = U96; +} + +pub use bounds::*; + pub trait Config { const SLOTS_PER_EPOCH: Slot; const GENESIS_VALIDATORS_ROOT: [u8; 32]; @@ -111,6 +174,12 @@ pub trait Config { const ELECTRA_FORK_EPOCH: Epoch; const FULU_FORK_VERSION: Version; const FULU_FORK_EPOCH: Epoch; + /// Gloas has not been scheduled on mainnet or any public testnet. Until it is, the epoch is + /// [`FAR_FUTURE_EPOCH`] and the version is a placeholder continuing the network's sequence, + /// so it is never selected by `compute_fork_version`. Both must be set once the fork is + /// announced. + const GLOAS_FORK_VERSION: Version; + const GLOAS_FORK_EPOCH: Epoch; const ID: [u8; 4]; } @@ -150,6 +219,8 @@ pub mod sepolia { const ELECTRA_FORK_EPOCH: Epoch = 222464; const FULU_FORK_EPOCH: Epoch = 272640; const FULU_FORK_VERSION: Version = hex_literal::hex!("90000075"); + const GLOAS_FORK_EPOCH: Epoch = FAR_FUTURE_EPOCH; + const GLOAS_FORK_VERSION: Version = hex_literal::hex!("90000076"); const ID: [u8; 4] = BEACON_CONSENSUS_ID; } } @@ -187,6 +258,8 @@ pub mod mainnet { const ELECTRA_FORK_EPOCH: Epoch = 364032; const FULU_FORK_EPOCH: Epoch = 411392; const FULU_FORK_VERSION: Version = hex_literal::hex!("06000000"); + const GLOAS_FORK_EPOCH: Epoch = FAR_FUTURE_EPOCH; + const GLOAS_FORK_VERSION: Version = hex_literal::hex!("07000000"); const ID: [u8; 4] = BEACON_CONSENSUS_ID; } } @@ -224,6 +297,8 @@ pub mod gnosis { const ELECTRA_FORK_EPOCH: Epoch = 1337856; const FULU_FORK_EPOCH: Epoch = 1714688; const FULU_FORK_VERSION: Version = hex_literal::hex!("06000064"); + const GLOAS_FORK_EPOCH: Epoch = FAR_FUTURE_EPOCH; + const GLOAS_FORK_VERSION: Version = hex_literal::hex!("07000064"); const ID: [u8; 4] = GNOSIS_CONSENSUS_ID; } @@ -257,6 +332,8 @@ pub mod gnosis { const ELECTRA_FORK_EPOCH: Epoch = 948224; const FULU_FORK_EPOCH: Epoch = 1353216; const FULU_FORK_VERSION: Version = hex_literal::hex!("0600006f"); + const GLOAS_FORK_EPOCH: Epoch = FAR_FUTURE_EPOCH; + const GLOAS_FORK_VERSION: Version = hex_literal::hex!("0700006f"); const ID: [u8; 4] = GNOSIS_CONSENSUS_ID; } } @@ -289,6 +366,8 @@ pub mod devnet { const DENEB_FORK_EPOCH: Epoch = 0; const ELECTRA_FORK_EPOCH: Epoch = 0; const FULU_FORK_EPOCH: Epoch = 0; + const GLOAS_FORK_VERSION: Version = hex!("80000038"); + const GLOAS_FORK_EPOCH: Epoch = FAR_FUTURE_EPOCH; const EPOCHS_PER_SYNC_COMMITTEE_PERIOD: Epoch = 256; const EXECUTION_PAYLOAD_STATE_ROOT_INDEX: u64 = 34; const EXECUTION_PAYLOAD_BLOCK_NUMBER_INDEX: u64 = 38; @@ -301,4 +380,49 @@ pub mod devnet { const NEXT_SYNC_COMMITTEE_INDEX_LOG2: u64 = 6; const ID: [u8; 4] = BEACON_CONSENSUS_ID; } + + /// Config for the ethpandaops glamsterdam devnets, the only networks running Gloas today. The + /// genesis root and fork versions come from the devnet's `/eth/v1/beacon/genesis` and + /// `/eth/v1/config/spec`. Gloas activates at epoch 30 rather than genesis, and the + /// generalized indices are unchanged from Electra because `latest_block_hash` reuses the + /// field slot the payload header gave up. + #[cfg(feature = "glamsterdam")] + #[derive(Default)] + pub struct GlamsterdamDevnet; + + #[cfg(feature = "glamsterdam")] + impl Config for GlamsterdamDevnet { + const SLOTS_PER_EPOCH: Slot = 32; + const GENESIS_VALIDATORS_ROOT: [u8; 32] = + hex_literal::hex!("bb4a1a9e3f7f4e10edcd734e4acc3b5ffd4f830efe0af2748fa458cfee5d2658"); + const GENESIS_FORK_VERSION: Version = hex!("10733183"); + const ALTAIR_FORK_VERSION: Version = hex!("20733183"); + const BELLATRIX_FORK_VERSION: Version = hex!("30733183"); + const CAPELLA_FORK_VERSION: Version = hex!("40733183"); + const DENEB_FORK_VERSION: Version = hex!("50733183"); + const ELECTRA_FORK_VERSION: Version = hex!("60733183"); + const FULU_FORK_VERSION: Version = hex!("70733183"); + const GLOAS_FORK_VERSION: Version = hex!("80733183"); + const ALTAIR_FORK_EPOCH: Epoch = 0; + const BELLATRIX_FORK_EPOCH: Epoch = 0; + const CAPELLA_FORK_EPOCH: Epoch = 0; + const DENEB_FORK_EPOCH: Epoch = 0; + const ELECTRA_FORK_EPOCH: Epoch = 0; + const FULU_FORK_EPOCH: Epoch = 0; + const GLOAS_FORK_EPOCH: Epoch = 1536; + const EPOCHS_PER_SYNC_COMMITTEE_PERIOD: Epoch = 256; + const EXECUTION_PAYLOAD_STATE_ROOT_INDEX: u64 = 34; + const EXECUTION_PAYLOAD_BLOCK_NUMBER_INDEX: u64 = 38; + const EXECUTION_PAYLOAD_TIMESTAMP_INDEX: u64 = 41; + // The state is a progressive container from Gloas, so a field sits at the index its + // position on the spine gives it rather than at `64 + position` in a padded tree, and the + // branches are no longer all the same length. + const EXECUTION_PAYLOAD_INDEX: u64 = 2947; + const NEXT_SYNC_COMMITTEE_INDEX: u64 = 2946; + const FINALIZED_ROOT_INDEX: u64 = 367; + const FINALIZED_ROOT_INDEX_LOG2: u64 = 8; + const EXECUTION_PAYLOAD_INDEX_LOG2: u64 = 11; + const NEXT_SYNC_COMMITTEE_INDEX_LOG2: u64 = 11; + const ID: [u8; 4] = BEACON_CONSENSUS_ID; + } } diff --git a/modules/consensus/sync-committee/primitives/src/deneb.rs b/modules/consensus/sync-committee/primitives/src/deneb.rs index f8d49fefb..b13c68230 100644 --- a/modules/consensus/sync-committee/primitives/src/deneb.rs +++ b/modules/consensus/sync-committee/primitives/src/deneb.rs @@ -2,4 +2,13 @@ use crate::ssz::ByteVector; pub const MAX_BLOB_COMMITMENTS_PER_BLOCK: usize = 4096; pub const BYTES_PER_COMMITMENT: usize = 48; -pub type KzgCommitment = ByteVector; +pub type KzgCommitment = ByteVector; + +/// Type level counterparts of the bounds above, sharing their names. See `constants::bounds`. +#[allow(non_camel_case_types)] +mod bounds { + pub type MAX_BLOB_COMMITMENTS_PER_BLOCK = ssz_types::typenum::U4096; + pub type BYTES_PER_COMMITMENT = ssz_types::typenum::U48; +} + +pub use bounds::*; diff --git a/modules/consensus/sync-committee/primitives/src/electra.rs b/modules/consensus/sync-committee/primitives/src/electra.rs index bfa7be1dd..f44e3da1d 100644 --- a/modules/consensus/sync-committee/primitives/src/electra.rs +++ b/modules/consensus/sync-committee/primitives/src/electra.rs @@ -2,9 +2,9 @@ use crate::constants::{ BlsPublicKey, BlsSignature, Bytes32, Epoch, ExecutionAddress, Gwei, Slot, ValidatorIndex, }; use alloc::{vec, vec::Vec}; -use ssz_rs::{prelude::*, Deserialize}; +use ssz_types::{typenum::Unsigned, BitVector, FixedVector, VariableList}; -#[derive(Default, Debug, SimpleSerialize, codec::Encode, codec::Decode, Clone, PartialEq, Eq)] +#[derive(Default, Debug, ssz_derive::Encode, ssz_derive::Decode, tree_hash_derive::TreeHash, codec::Encode, codec::Decode, Clone, PartialEq, Eq)] #[cfg_attr(feature = "std", derive(serde::Serialize, serde::Deserialize))] pub struct DepositRequest { #[cfg_attr(feature = "std", serde(rename = "pubkey"))] @@ -17,7 +17,7 @@ pub struct DepositRequest { pub index: u64, } -#[derive(Default, Debug, SimpleSerialize, codec::Encode, codec::Decode, Clone, PartialEq, Eq)] +#[derive(Default, Debug, ssz_derive::Encode, ssz_derive::Decode, tree_hash_derive::TreeHash, codec::Encode, codec::Decode, Clone, PartialEq, Eq)] #[cfg_attr(feature = "std", derive(serde::Serialize, serde::Deserialize))] pub struct WithdrawalRequest { pub source_address: ExecutionAddress, @@ -26,7 +26,7 @@ pub struct WithdrawalRequest { pub amount: Gwei, } -#[derive(Default, Debug, SimpleSerialize, codec::Encode, codec::Decode, Clone, PartialEq, Eq)] +#[derive(Default, Debug, ssz_derive::Encode, ssz_derive::Decode, tree_hash_derive::TreeHash, codec::Encode, codec::Decode, Clone, PartialEq, Eq)] #[cfg_attr(feature = "std", derive(serde::Serialize, serde::Deserialize))] pub struct ConsolidationRequest { pub source_address: ExecutionAddress, @@ -34,19 +34,20 @@ pub struct ConsolidationRequest { pub target_pubkey: BlsPublicKey, } -#[derive(Default, Debug, SimpleSerialize, codec::Encode, codec::Decode, Clone, PartialEq, Eq)] +#[derive(Default, Debug, ssz_derive::Encode, ssz_derive::Decode, tree_hash_derive::TreeHash, codec::Encode, codec::Decode, Clone, PartialEq, Eq)] #[cfg_attr(feature = "std", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "std", serde(bound = ""))] pub struct ExecutionRequests< - const MAX_DEPOSIT_REQUESTS_PER_PAYLOAD: usize, - const MAX_WITHDRAWAL_REQUESTS_PER_PAYLOAD: usize, - const MAX_CONSOLIDATION_REQUESTS_PER_PAYLOAD: usize, + MAX_DEPOSIT_REQUESTS_PER_PAYLOAD: Unsigned, + MAX_WITHDRAWAL_REQUESTS_PER_PAYLOAD: Unsigned, + MAX_CONSOLIDATION_REQUESTS_PER_PAYLOAD: Unsigned, > { - pub deposits: List, - pub withdrawals: List, - pub consolidations: List, + pub deposits: VariableList, + pub withdrawals: VariableList, + pub consolidations: VariableList, } -#[derive(Default, Debug, Clone, SimpleSerialize, PartialEq, Eq, codec::Encode, codec::Decode)] +#[derive(Default, Debug, Clone, ssz_derive::Encode, ssz_derive::Decode, tree_hash_derive::TreeHash, PartialEq, Eq, codec::Encode, codec::Decode)] #[cfg_attr(feature = "std", derive(serde::Serialize, serde::Deserialize))] pub struct PendingPartialWithdrawal { #[cfg_attr(feature = "std", serde(with = "serde_hex_utils::as_string"))] @@ -57,7 +58,7 @@ pub struct PendingPartialWithdrawal { pub withdrawable_epoch: Epoch, } -#[derive(Default, Debug, Clone, SimpleSerialize, PartialEq, Eq, codec::Encode, codec::Decode)] +#[derive(Default, Debug, Clone, ssz_derive::Encode, ssz_derive::Decode, tree_hash_derive::TreeHash, PartialEq, Eq, codec::Encode, codec::Decode)] #[cfg_attr(feature = "std", derive(serde::Serialize, serde::Deserialize))] pub struct PendingConsolidation { #[cfg_attr(feature = "std", serde(with = "serde_hex_utils::as_string"))] @@ -66,7 +67,7 @@ pub struct PendingConsolidation { pub target_index: ValidatorIndex, } -#[derive(Default, Debug, Clone, SimpleSerialize, PartialEq, Eq, codec::Encode, codec::Decode)] +#[derive(Default, Debug, Clone, ssz_derive::Encode, ssz_derive::Decode, tree_hash_derive::TreeHash, PartialEq, Eq, codec::Encode, codec::Decode)] #[cfg_attr(feature = "std", derive(serde::Serialize, serde::Deserialize))] pub struct PendingDeposit { pub pubkey: BlsPublicKey, diff --git a/modules/consensus/sync-committee/primitives/src/execution_header.rs b/modules/consensus/sync-committee/primitives/src/execution_header.rs new file mode 100644 index 000000000..27c780bd0 --- /dev/null +++ b/modules/consensus/sync-committee/primitives/src/execution_header.rs @@ -0,0 +1,75 @@ +//! The execution block header, as it is laid out after Glamsterdam. +//! +//! After ePBS the beacon state no longer carries the execution payload header, only the execution +//! `block_hash`. Since a block hash is defined as `keccak256(rlp(header))`, handing the verifier +//! the header itself is enough to recover the execution state root: hash the bytes, check they +//! match the block hash consensus already vouched for, and read the fields off the header. +//! +//! The prover encodes this type and the verifier decodes it, so the field order below is the +//! single definition of the header layout. Glamsterdam appends `block_access_list_hash` +//! (EIP-7928) and `slot_number` to what Prague had, and a header encoded without them hashes to +//! the wrong block hash. + +use alloy_primitives::{keccak256, Address, Bloom, Bytes, B256, B64, U256}; +use alloy_rlp_derive::{RlpDecodable, RlpEncodable}; + +/// Recover the block hash from the rlp encoded header. The bytes are hashed exactly as supplied, +/// which is what makes the hash a binding commitment to every field inside them. +pub fn execution_block_hash(rlp: &[u8]) -> [u8; 32] { + keccak256(rlp).0 +} + +#[derive(Debug, Clone, PartialEq, Eq, RlpEncodable, RlpDecodable)] +#[cfg_attr(feature = "std", derive(serde::Deserialize))] +#[cfg_attr(feature = "std", serde(rename_all = "camelCase"))] +pub struct ExecutionHeader { + pub parent_hash: B256, + #[cfg_attr(feature = "std", serde(rename = "sha3Uncles"))] + pub ommers_hash: B256, + #[cfg_attr(feature = "std", serde(rename = "miner"))] + pub beneficiary: Address, + pub state_root: B256, + pub transactions_root: B256, + pub receipts_root: B256, + pub logs_bloom: Bloom, + pub difficulty: alloy_primitives::U256, + #[cfg_attr(feature = "std", serde(with = "serde_hex_utils::as_hex_quantity"))] + pub number: u64, + #[cfg_attr(feature = "std", serde(with = "serde_hex_utils::as_hex_quantity"))] + pub gas_limit: u64, + #[cfg_attr(feature = "std", serde(with = "serde_hex_utils::as_hex_quantity"))] + pub gas_used: u64, + #[cfg_attr(feature = "std", serde(with = "serde_hex_utils::as_hex_quantity"))] + pub timestamp: u64, + pub extra_data: Bytes, + pub mix_hash: B256, + pub nonce: B64, + #[cfg_attr(feature = "std", serde(with = "serde_hex_utils::as_hex_quantity"))] + pub base_fee_per_gas: u64, + pub withdrawals_root: B256, + #[cfg_attr(feature = "std", serde(with = "serde_hex_utils::as_hex_quantity"))] + pub blob_gas_used: u64, + #[cfg_attr(feature = "std", serde(with = "serde_hex_utils::as_hex_quantity"))] + pub excess_blob_gas: u64, + pub parent_beacon_block_root: B256, + pub requests_hash: B256, + pub block_access_list_hash: B256, + #[cfg_attr(feature = "std", serde(with = "serde_hex_utils::as_hex_quantity"))] + pub slot_number: u64, +} + +impl ExecutionHeader { + /// Decode an rlp encoded execution header. Callers must have already checked the bytes hash to + /// a block hash they trust, otherwise the fields inside mean nothing. + pub fn decode(rlp: &[u8]) -> Result { + ::decode(&mut &rlp[..]) + } + + /// Rlp encode the header. The encoding must round trip to the block hash the beacon state + /// committed to, so every field the fork defines has to be present. + pub fn encode(&self) -> alloc::vec::Vec { + let mut out = alloc::vec::Vec::new(); + alloy_rlp::Encodable::encode(self, &mut out); + out + } +} diff --git a/modules/consensus/sync-committee/primitives/src/gloas.rs b/modules/consensus/sync-committee/primitives/src/gloas.rs new file mode 100644 index 000000000..56d6aa037 --- /dev/null +++ b/modules/consensus/sync-committee/primitives/src/gloas.rs @@ -0,0 +1,130 @@ +//! Types introduced by the Gloas fork (EIP-7732, enshrined proposer builder separation). +//! +//! ePBS moves the execution payload out of the beacon block and beacon state. What is left +//! behind is a builder's bid committing to an execution `block_hash`, plus a builder registry +//! and the payload timeliness committee. None of these are proven by this client, but they are +//! part of the `BeaconState` and `BeaconBlockBody` containers, so they have to be modelled for +//! the ssz hash tree roots to come out right. + +use crate::{ + constants::{ + BlsPublicKey, BlsSignature, Bytes32, Epoch, ExecutionAddress, Gwei, Hash32, Root, Slot, + ValidatorIndex, + }, + deneb::KzgCommitment, +}; +use alloc::{vec, vec::Vec}; +use ssz_types::{typenum::Unsigned, BitVector, FixedVector, ProgressiveList, VariableList}; + +/// Index into the builder registry. +pub type BuilderIndex = u64; + +/// Size of the payload timeliness committee. +pub const PTC_SIZE: usize = 512; + +/// Maximum payload attestations in a single block body. +pub const MAX_PAYLOAD_ATTESTATIONS: usize = 4; + +pub const BUILDER_REGISTRY_LIMIT: usize = 2usize.saturating_pow(40); +pub const BUILDER_PENDING_WITHDRAWALS_LIMIT: usize = 2usize.saturating_pow(20); + +/// Type level counterparts of the bounds above, sharing their names. See `constants::bounds`. +#[allow(non_camel_case_types)] +mod bounds { + pub type PTC_SIZE = ssz_types::typenum::U512; + pub type MAX_PAYLOAD_ATTESTATIONS = ssz_types::typenum::U4; + pub type BUILDER_REGISTRY_LIMIT = ssz_types::typenum::U1099511627776; + pub type BUILDER_PENDING_WITHDRAWALS_LIMIT = ssz_types::typenum::U1048576; +} + +pub use bounds::*; + +#[derive(Default, Debug, ssz_derive::Encode, ssz_derive::Decode, tree_hash_derive::TreeHash, codec::Encode, codec::Decode, Clone, PartialEq, Eq)] +#[cfg_attr(feature = "std", derive(serde::Serialize, serde::Deserialize))] +pub struct Builder { + #[cfg_attr(feature = "std", serde(rename = "pubkey"))] + pub pub_key: BlsPublicKey, + #[cfg_attr(feature = "std", serde(with = "serde_hex_utils::as_hex_quantity"))] + pub version: u8, + pub execution_address: ExecutionAddress, + #[cfg_attr(feature = "std", serde(with = "serde_hex_utils::as_string"))] + pub balance: Gwei, + #[cfg_attr(feature = "std", serde(with = "serde_hex_utils::as_string"))] + pub deposit_epoch: Epoch, + #[cfg_attr(feature = "std", serde(with = "serde_hex_utils::as_string"))] + pub withdrawable_epoch: Epoch, +} + +#[derive(Default, Debug, ssz_derive::Encode, ssz_derive::Decode, tree_hash_derive::TreeHash, codec::Encode, codec::Decode, Clone, PartialEq, Eq)] +#[cfg_attr(feature = "std", derive(serde::Serialize, serde::Deserialize))] +pub struct BuilderPendingWithdrawal { + pub fee_recipient: ExecutionAddress, + #[cfg_attr(feature = "std", serde(with = "serde_hex_utils::as_string"))] + pub amount: Gwei, + #[cfg_attr(feature = "std", serde(with = "serde_hex_utils::as_string"))] + pub builder_index: BuilderIndex, +} + +#[derive(Default, Debug, ssz_derive::Encode, ssz_derive::Decode, tree_hash_derive::TreeHash, codec::Encode, codec::Decode, Clone, PartialEq, Eq)] +#[cfg_attr(feature = "std", derive(serde::Serialize, serde::Deserialize))] +pub struct BuilderPendingPayment { + #[cfg_attr(feature = "std", serde(with = "serde_hex_utils::as_string"))] + pub weight: Gwei, + pub withdrawal: BuilderPendingWithdrawal, + #[cfg_attr(feature = "std", serde(with = "serde_hex_utils::as_string"))] + pub proposer_index: ValidatorIndex, +} + +/// A builder's commitment to produce an execution payload with a given `block_hash`. The payload +/// itself, and with it the execution state root, is revealed later and out of band. +#[derive(Default, Debug, ssz_derive::Encode, ssz_derive::Decode, tree_hash_derive::TreeHash, codec::Encode, codec::Decode, Clone, PartialEq, Eq)] +#[cfg_attr(feature = "std", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "std", serde(bound = ""))] +#[tree_hash(struct_behaviour = "progressive_container", active_fields(1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1))] +pub struct ExecutionPayloadBid { + pub parent_block_hash: Hash32, + pub parent_block_root: Root, + pub block_hash: Hash32, + pub prev_randao: Bytes32, + pub fee_recipient: ExecutionAddress, + #[cfg_attr(feature = "std", serde(with = "serde_hex_utils::as_string"))] + pub gas_limit: u64, + #[cfg_attr(feature = "std", serde(with = "serde_hex_utils::as_string"))] + pub builder_index: BuilderIndex, + #[cfg_attr(feature = "std", serde(with = "serde_hex_utils::as_string"))] + pub slot: Slot, + #[cfg_attr(feature = "std", serde(with = "serde_hex_utils::as_string"))] + pub value: Gwei, + #[cfg_attr(feature = "std", serde(with = "serde_hex_utils::as_string"))] + pub execution_payment: Gwei, + pub blob_kzg_commitments: ProgressiveList, + pub execution_requests_root: Root, +} + +#[derive(Default, Debug, ssz_derive::Encode, ssz_derive::Decode, tree_hash_derive::TreeHash, codec::Encode, codec::Decode, Clone, PartialEq, Eq)] +#[cfg_attr(feature = "std", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "std", serde(bound = ""))] +pub struct SignedExecutionPayloadBid { + pub message: ExecutionPayloadBid, + pub signature: BlsSignature, +} + +#[derive(Default, Debug, ssz_derive::Encode, ssz_derive::Decode, tree_hash_derive::TreeHash, codec::Encode, codec::Decode, Clone, PartialEq, Eq)] +#[cfg_attr(feature = "std", derive(serde::Serialize, serde::Deserialize))] +pub struct PayloadAttestationData { + pub beacon_block_root: Root, + #[cfg_attr(feature = "std", serde(with = "serde_hex_utils::as_string"))] + pub slot: Slot, + pub payload_present: bool, + pub blob_data_available: bool, +} + +/// The payload timeliness committee's vote on whether a payload was revealed in time. It says +/// nothing about the payload's contents, which is why this client does not rely on it. +#[derive(Default, Debug, ssz_derive::Encode, ssz_derive::Decode, tree_hash_derive::TreeHash, codec::Encode, codec::Decode, Clone, PartialEq, Eq)] +#[cfg_attr(feature = "std", derive(serde::Serialize, serde::Deserialize))] +pub struct PayloadAttestation { + pub aggregation_bits: BitVector, + pub data: PayloadAttestationData, + pub signature: BlsSignature, +} diff --git a/modules/consensus/sync-committee/primitives/src/lib.rs b/modules/consensus/sync-committee/primitives/src/lib.rs index 9bc8eba22..aa73f9f89 100644 --- a/modules/consensus/sync-committee/primitives/src/lib.rs +++ b/modules/consensus/sync-committee/primitives/src/lib.rs @@ -11,6 +11,13 @@ pub mod deneb; pub mod domains; pub mod electra; pub mod error; +// Always compiled: the verifier recovers the Gloas execution state root from this rlp header at +// runtime, so it can no longer live behind the `glamsterdam` feature. +pub mod execution_header; +// Gloas `BeaconState`/`BeaconBlockBody` component types, needed only by the prover's compile-time +// ssz layout. +#[cfg(feature = "glamsterdam")] +pub mod gloas; mod ssz; pub mod types; pub mod util; diff --git a/modules/consensus/sync-committee/primitives/src/ssz/byte_list.rs b/modules/consensus/sync-committee/primitives/src/ssz/byte_list.rs index 9fa982bb1..26163c117 100644 --- a/modules/consensus/sync-committee/primitives/src/ssz/byte_list.rs +++ b/modules/consensus/sync-committee/primitives/src/ssz/byte_list.rs @@ -1,3 +1,4 @@ +use tree_hash::Hash256; use super::write_bytes_to_lower_hex; use alloc::{vec, vec::Vec}; use core::{ @@ -5,68 +6,134 @@ use core::{ hash::{Hash, Hasher}, ops::{Deref, DerefMut}, }; -use ssz_rs::prelude::*; +use ssz_types::{typenum::Unsigned, FixedVector, VariableList}; -#[derive(Default, Clone, Eq, SimpleSerialize, codec::Encode, codec::Decode)] -#[cfg_attr(feature = "std", derive(serde::Serialize, serde::Deserialize))] -pub struct ByteList( - #[cfg_attr(feature = "serde", serde(with = "serde_hex_utils::as_hex"))] List, -); +#[derive(Default, Clone, codec::Encode, codec::Decode)] +pub struct ByteList(VariableList); -impl TryFrom<&[u8]> for ByteList { - type Error = ssz_rs::DeserializeError; +// Derived `Eq` would demand `N: Eq`, but `N` is a type level integer that never appears in a value. +impl Eq for ByteList {} + +/// SSZ and merkleization delegate to the inner list, which is what the derive would have produced +/// were tuple structs supported. +impl ssz::Encode for ByteList { + fn is_ssz_fixed_len() -> bool { + as ssz::Encode>::is_ssz_fixed_len() + } + + fn ssz_fixed_len() -> usize { + as ssz::Encode>::ssz_fixed_len() + } + + fn ssz_bytes_len(&self) -> usize { + self.0.ssz_bytes_len() + } + + fn ssz_append(&self, buf: &mut Vec) { + self.0.ssz_append(buf) + } +} + +impl ssz::Decode for ByteList { + fn is_ssz_fixed_len() -> bool { + as ssz::Decode>::is_ssz_fixed_len() + } + + fn ssz_fixed_len() -> usize { + as ssz::Decode>::ssz_fixed_len() + } + + fn from_ssz_bytes(bytes: &[u8]) -> Result { + VariableList::from_ssz_bytes(bytes).map(Self) + } +} + +impl tree_hash::TreeHash for ByteList { + fn tree_hash_type() -> tree_hash::TreeHashType { + as tree_hash::TreeHash>::tree_hash_type() + } + + fn tree_hash_packed_encoding(&self) -> tree_hash::PackedEncoding { + self.0.tree_hash_packed_encoding() + } + + fn tree_hash_packing_factor() -> usize { + as tree_hash::TreeHash>::tree_hash_packing_factor() + } + + fn tree_hash_root(&self) -> tree_hash::Hash256 { + self.0.tree_hash_root() + } +} + +#[cfg(feature = "std")] +impl serde::Serialize for ByteList { + fn serialize(&self, serializer: S) -> Result { + serde_hex_utils::as_hex::serialize(self, serializer) + } +} + +#[cfg(feature = "std")] +impl<'de, N: Unsigned> serde::Deserialize<'de> for ByteList { + fn deserialize>(deserializer: D) -> Result { + serde_hex_utils::as_hex::deserialize(deserializer) + } +} + +impl TryFrom<&[u8]> for ByteList { + type Error = ssz_types::Error; fn try_from(bytes: &[u8]) -> Result { - ByteList::::deserialize(bytes) + VariableList::new(bytes.to_vec()).map(Self) } } // impl here to satisfy clippy -impl PartialEq for ByteList { +impl PartialEq for ByteList { fn eq(&self, other: &Self) -> bool { self.0 == other.0 } } -impl Hash for ByteList { +impl Hash for ByteList { fn hash(&self, state: &mut H) { self.as_ref().hash(state); } } -impl fmt::LowerHex for ByteList { +impl fmt::LowerHex for ByteList { fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { write_bytes_to_lower_hex(f, self) } } -impl fmt::Debug for ByteList { +impl fmt::Debug for ByteList { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!(f, "ByteList<{N}>(len={})({:#x})", self.len(), self) + write!(f, "ByteList<{}>(len={})({:#x})", N::to_usize(), self.len(), self) } } -impl fmt::Display for ByteList { +impl fmt::Display for ByteList { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { write!(f, "{self:#x}") } } -impl AsRef<[u8]> for ByteList { +impl AsRef<[u8]> for ByteList { fn as_ref(&self) -> &[u8] { &self.0 } } -impl Deref for ByteList { - type Target = List; +impl Deref for ByteList { + type Target = VariableList; fn deref(&self) -> &Self::Target { &self.0 } } -impl DerefMut for ByteList { +impl DerefMut for ByteList { fn deref_mut(&mut self) -> &mut Self::Target { &mut self.0 } @@ -79,10 +146,18 @@ mod tests { #[test] fn test_byte_list_serde() { let list = ByteList::<32>::try_from([255u8, 255u8].as_ref()).unwrap(); - let encoding = ssz_rs::serialize(&list).unwrap(); + let encoding = ssz::serialize(&list).unwrap(); assert_eq!(encoding, [255, 255]); let recovered_list = ByteList::<32>::deserialize(&encoding).unwrap(); assert_eq!(list, recovered_list); } } + +impl TryFrom> for ByteList { + type Error = ssz_types::Error; + + fn try_from(bytes: Vec) -> Result { + VariableList::new(bytes).map(Self) + } +} diff --git a/modules/consensus/sync-committee/primitives/src/ssz/mod.rs b/modules/consensus/sync-committee/primitives/src/ssz/mod.rs index dd733305f..5d9480f77 100644 --- a/modules/consensus/sync-committee/primitives/src/ssz/mod.rs +++ b/modules/consensus/sync-committee/primitives/src/ssz/mod.rs @@ -11,5 +11,11 @@ fn write_bytes_to_lower_hex>(f: &mut fmt::Formatter<'_>, data: T) Ok(()) } -pub use byte_list::ByteList; pub use bls_utils::ByteVector; +#[cfg(feature = "glamsterdam")] +mod state_list; +#[cfg(feature = "glamsterdam")] +pub use state_list::StateList; +mod uint256; +pub use uint256::U256; +pub use byte_list::ByteList; diff --git a/modules/consensus/sync-committee/primitives/src/ssz/state_list.rs b/modules/consensus/sync-committee/primitives/src/ssz/state_list.rs new file mode 100644 index 000000000..b60ede16c --- /dev/null +++ b/modules/consensus/sync-committee/primitives/src/ssz/state_list.rs @@ -0,0 +1,168 @@ +// Copyright (C) Polytope Labs Ltd. +// SPDX-License-Identifier: Apache-2.0 + +//! The Gloas shape of the state's lists. +//! +//! Before Gloas these are bounded `VariableList`s. From Gloas, EIP-7688 makes them +//! `ProgressiveList`s, which carry no capacity at all. +//! +//! A plain type alias cannot express that: `type StateList = ProgressiveList` leaves `N` +//! unused, which is `E0091`. It worked under `ssz-rs` only because the bound was a `const` +//! parameter there, and const parameters are exempt from the unused check where type parameters +//! are not. So the progressive shape is a newtype that keeps `N` in a `PhantomData`, letting every +//! field declaration stay identical across both forks. +//! +//! The phantom never reaches the wire. Every impl below delegates to the inner list, so the ssz +//! encoding and hash tree root are exactly `ProgressiveList`'s. + +use alloc::vec::Vec; +use core::marker::PhantomData; +use ssz_types::ProgressiveList; + +pub struct StateList(ProgressiveList, PhantomData); + +impl StateList { + /// The underlying progressive list. + pub fn inner(&self) -> &ProgressiveList { + &self.0 + } +} + +impl Default for StateList { + fn default() -> Self { + Self(ProgressiveList::empty(), PhantomData) + } +} + +impl Clone for StateList { + fn clone(&self) -> Self { + Self(self.0.clone(), PhantomData) + } +} + +impl core::fmt::Debug for StateList { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + self.0.fmt(f) + } +} + +impl PartialEq for StateList { + fn eq(&self, other: &Self) -> bool { + self.0 == other.0 + } +} + +impl Eq for StateList {} + +impl From> for StateList { + fn from(vec: Vec) -> Self { + Self(ProgressiveList::new(vec), PhantomData) + } +} + +impl core::ops::Deref for StateList { + type Target = [T]; + + fn deref(&self) -> &[T] { + &self.0 + } +} + +impl AsRef<[T]> for StateList { + fn as_ref(&self) -> &[T] { + &self.0 + } +} + +impl ssz::Encode for StateList +where + T: ssz::Encode, +{ + fn is_ssz_fixed_len() -> bool { + as ssz::Encode>::is_ssz_fixed_len() + } + + fn ssz_fixed_len() -> usize { + as ssz::Encode>::ssz_fixed_len() + } + + fn ssz_bytes_len(&self) -> usize { + self.0.ssz_bytes_len() + } + + fn ssz_append(&self, buf: &mut Vec) { + self.0.ssz_append(buf) + } +} + +impl ssz::Decode for StateList +where + T: ssz::Decode, +{ + fn is_ssz_fixed_len() -> bool { + as ssz::Decode>::is_ssz_fixed_len() + } + + fn from_ssz_bytes(bytes: &[u8]) -> Result { + ProgressiveList::from_ssz_bytes(bytes).map(|list| Self(list, PhantomData)) + } +} + +impl tree_hash::TreeHash for StateList +where + T: tree_hash::TreeHash, +{ + fn tree_hash_type() -> tree_hash::TreeHashType { + as tree_hash::TreeHash>::tree_hash_type() + } + + fn tree_hash_packed_encoding(&self) -> tree_hash::PackedEncoding { + self.0.tree_hash_packed_encoding() + } + + fn tree_hash_packing_factor() -> usize { + as tree_hash::TreeHash>::tree_hash_packing_factor() + } + + fn tree_hash_root(&self) -> tree_hash::Hash256 { + self.0.tree_hash_root() + } +} + +impl codec::Encode for StateList +where + T: codec::Encode, +{ + fn encode_to(&self, dest: &mut O) { + self.0.encode_to(dest) + } +} + +impl codec::Decode for StateList +where + T: codec::Decode, +{ + fn decode(input: &mut I) -> Result { + ProgressiveList::decode(input).map(|list| Self(list, PhantomData)) + } +} + +#[cfg(feature = "std")] +impl serde::Serialize for StateList +where + T: serde::Serialize, +{ + fn serialize(&self, serializer: S) -> Result { + self.0.serialize(serializer) + } +} + +#[cfg(feature = "std")] +impl<'de, T, N> serde::Deserialize<'de> for StateList +where + T: serde::Deserialize<'de>, +{ + fn deserialize>(deserializer: D) -> Result { + ProgressiveList::deserialize(deserializer).map(|list| Self(list, PhantomData)) + } +} diff --git a/modules/consensus/sync-committee/primitives/src/ssz/uint256.rs b/modules/consensus/sync-committee/primitives/src/ssz/uint256.rs new file mode 100644 index 000000000..390c91b53 --- /dev/null +++ b/modules/consensus/sync-committee/primitives/src/ssz/uint256.rs @@ -0,0 +1,114 @@ +// Copyright (C) Polytope Labs Ltd. +// SPDX-License-Identifier: Apache-2.0 + +//! A 256 bit unsigned integer carrying both SSZ and SCALE. +//! +//! `ssz-rs` shipped its own `U256` implementing both. `alloy_primitives::U256` has SSZ and tree +//! hashing through `ethereum_ssz`, but no SCALE, and the orphan rule stops us adding it: neither +//! the trait nor the type is ours. So the pairing lives in a newtype here, exactly as it did +//! before, with SSZ delegating to alloy and SCALE going through the little endian bytes. + +use alloc::vec::Vec; +use alloy_primitives::U256 as AlloyU256; + +#[derive(Default, Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct U256(pub AlloyU256); + +impl From for U256 { + fn from(value: AlloyU256) -> Self { + Self(value) + } +} + +impl From for AlloyU256 { + fn from(value: U256) -> Self { + value.0 + } +} + +impl core::ops::Deref for U256 { + type Target = AlloyU256; + + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +impl codec::Encode for U256 { + fn encode_to(&self, dest: &mut T) { + // SSZ serializes a uint256 little endian, so SCALE uses the same byte order to keep the + // two representations from disagreeing. + self.0.to_le_bytes::<32>().encode_to(dest) + } +} + +impl codec::Decode for U256 { + fn decode(input: &mut I) -> Result { + let bytes = <[u8; 32]>::decode(input)?; + Ok(Self(AlloyU256::from_le_bytes(bytes))) + } +} + +impl ssz::Encode for U256 { + fn is_ssz_fixed_len() -> bool { + ::is_ssz_fixed_len() + } + + fn ssz_fixed_len() -> usize { + ::ssz_fixed_len() + } + + fn ssz_bytes_len(&self) -> usize { + self.0.ssz_bytes_len() + } + + fn ssz_append(&self, buf: &mut Vec) { + self.0.ssz_append(buf) + } +} + +impl ssz::Decode for U256 { + fn is_ssz_fixed_len() -> bool { + ::is_ssz_fixed_len() + } + + fn ssz_fixed_len() -> usize { + ::ssz_fixed_len() + } + + fn from_ssz_bytes(bytes: &[u8]) -> Result { + AlloyU256::from_ssz_bytes(bytes).map(Self) + } +} + +impl tree_hash::TreeHash for U256 { + fn tree_hash_type() -> tree_hash::TreeHashType { + ::tree_hash_type() + } + + fn tree_hash_packed_encoding(&self) -> tree_hash::PackedEncoding { + self.0.tree_hash_packed_encoding() + } + + fn tree_hash_packing_factor() -> usize { + ::tree_hash_packing_factor() + } + + fn tree_hash_root(&self) -> tree_hash::Hash256 { + self.0.tree_hash_root() + } +} + +#[cfg(feature = "std")] +impl serde::Serialize for U256 { + fn serialize(&self, serializer: S) -> Result { + self.0.serialize(serializer) + } +} + +#[cfg(feature = "std")] +impl<'de> serde::Deserialize<'de> for U256 { + fn deserialize>(deserializer: D) -> Result { + AlloyU256::deserialize(deserializer).map(Self) + } +} diff --git a/modules/consensus/sync-committee/primitives/src/types.rs b/modules/consensus/sync-committee/primitives/src/types.rs index 0d466f4d7..e39c1ea81 100644 --- a/modules/consensus/sync-committee/primitives/src/types.rs +++ b/modules/consensus/sync-committee/primitives/src/types.rs @@ -1,27 +1,105 @@ +use tree_hash::Hash256; +use crate::constants::Root; use crate::{ consensus_types::{BeaconBlockHeader, SyncAggregate, SyncCommittee}, constants::{Slot, SYNC_COMMITTEE_SIZE}, + execution_header::ExecutionHeader, }; use alloc::vec::Vec; use primitive_types::H256; -use ssz_rs::Node; + /// This holds the relevant data required to prove the state root in the execution payload. +/// +/// How the verifier is convinced of the execution fields differs by fork, and that lives in +/// [`ExecutionProof`]. Both variants are always compiled, so one binary verifies either fork and +/// picks the path at runtime rather than at build time. #[derive(Debug, Clone, PartialEq, Eq, Default, codec::Encode, codec::Decode)] pub struct ExecutionPayloadProof { + /// merkle proof for the `ExecutionPayload` in the [`BeaconBlockBody`]. + pub execution_payload_branch: Vec, + /// The fork-specific material the execution fields are recovered from. + pub proof: ExecutionProof, +} + +/// The fork-specific execution proof carried by [`ExecutionPayloadProof`]. +#[derive(Debug, Clone, PartialEq, Eq, codec::Encode, codec::Decode)] +pub enum ExecutionProof { + /// Pre-Gloas: the execution payload header lives in the beacon state, so `state_root`, + /// `block_number` and `timestamp` are proven directly by an ssz multi proof over it. + Legacy { + /// The state root in the `ExecutionPayload` which represents the commitment to + /// the ethereum world state in the yellow paper. + state_root: H256, + /// the block number of the execution header. + block_number: u64, + /// timestamp + timestamp: u64, + /// merkle multi proof for the state_root, block_number & timestamp in the + /// [`ExecutionPayload`]. + multi_proof: Vec, + }, + /// Post-Gloas (EIP-7732 ePBS): the beacon state keeps only the execution block hash, so the + /// relayer ships the rlp encoded execution block header and the verifier recovers the three + /// fields from this preimage after checking `keccak256(header)` matches the block hash + /// consensus vouched for. The verifier is what decides whether the fields are honest. + Gloas { + /// the rlp encoded execution block header. + execution_header: Vec, + }, +} + +impl Default for ExecutionProof { + fn default() -> Self { + ExecutionProof::Legacy { + state_root: H256::zero(), + block_number: 0, + timestamp: 0, + multi_proof: Vec::new(), + } + } +} + +/// The execution fields the verifier read out of a proof it accepted. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct VerifiedExecutionPayload { /// The state root in the `ExecutionPayload` which represents the commitment to /// the ethereum world state in the yellow paper. pub state_root: H256, /// the block number of the execution header. pub block_number: u64, - /// merkle mutli proof for the state_root & block_number in the [`ExecutionPayload`]. - pub multi_proof: Vec, - /// merkle proof for the `ExecutionPayload` in the [`BeaconBlockBody`]. - pub execution_payload_branch: Vec, /// timestamp pub timestamp: u64, } +impl ExecutionPayloadProof { + /// The rlp encoded Gloas execution header, or `None` when this is a legacy proof. + pub fn execution_header(&self) -> Option<&Vec> { + match &self.proof { + ExecutionProof::Gloas { execution_header } => Some(execution_header), + ExecutionProof::Legacy { .. } => None, + } + } + + /// Mutable access to the rlp encoded Gloas execution header, or `None` for a legacy proof. + pub fn execution_header_mut(&mut self) -> Option<&mut Vec> { + match &mut self.proof { + ExecutionProof::Gloas { execution_header } => Some(execution_header), + ExecutionProof::Legacy { .. } => None, + } + } + + /// The block number the proof carries, whichever fork it came from. Nothing has verified it + /// yet, so it is only good for deciding whether an update is worth submitting. + pub fn claimed_block_number(&self) -> Option { + match &self.proof { + ExecutionProof::Legacy { block_number, .. } => Some(*block_number), + ExecutionProof::Gloas { execution_header } => + ExecutionHeader::decode(execution_header).ok().map(|header| header.number), + } + } +} + /// Holds the neccessary proofs required to verify a header in the `block_roots` field /// either in [`BeaconState`] or [`HistoricalBatch`]. #[derive(Debug, Clone, PartialEq, Eq, codec::Encode, codec::Decode)] @@ -29,7 +107,7 @@ pub struct BlockRootsProof { /// Generalized index of the header in the `block_roots` list. pub block_header_index: u64, /// The proof for the header, needed to reconstruct `hash_tree_root(state.block_roots)` - pub block_header_branch: Vec, + pub block_header_branch: Vec, } /// The block header ancestry proof, this is an enum because the header may either exist in @@ -41,7 +119,7 @@ pub enum AncestryProof { /// Proof for the header in `state.block_roots` block_roots_proof: BlockRootsProof, /// The proof for the reconstructed `hash_tree_root(state.block_roots)` in [`BeaconState`] - block_roots_branch: Vec, + block_roots_branch: Vec, }, /// This variant defines the neccessary proofs for a beacon chain header in the /// `state.historical_roots`. @@ -50,14 +128,14 @@ pub enum AncestryProof { block_roots_proof: BlockRootsProof, /// The proof for the `historical_batch.block_roots`, needed to reconstruct /// `hash_tree_root(historical_batch)` - historical_batch_proof: Vec, + historical_batch_proof: Vec, /// The proof for the `hash_tree_root(historical_batch)` in `state.historical_roots` - historical_roots_proof: Vec, + historical_roots_proof: Vec, /// The generalized index for the historical_batch in `state.historical_roots`. historical_roots_index: u64, /// The proof for the reconstructed `hash_tree_root(state.historical_roots)` in /// [`BeaconState`] - historical_roots_branch: Vec, + historical_roots_branch: Vec, }, } @@ -80,7 +158,7 @@ pub struct SyncCommitteeUpdate { /// actual sync committee pub next_sync_committee: SyncCommittee, /// next sync committee, ssz merkle proof. - pub next_sync_committee_branch: Vec, + pub next_sync_committee_branch: Vec, } /// Minimum state required by the light client to validate new sync committee attestations @@ -104,7 +182,7 @@ pub struct FinalityProof { /// The latest finalized epoch pub epoch: u64, /// Finalized header proof - pub finality_branch: Vec, + pub finality_branch: Vec, } /// Data required to advance the state of the light client. diff --git a/modules/consensus/sync-committee/primitives/src/util.rs b/modules/consensus/sync-committee/primitives/src/util.rs index ff7559653..169797109 100644 --- a/modules/consensus/sync-committee/primitives/src/util.rs +++ b/modules/consensus/sync-committee/primitives/src/util.rs @@ -1,3 +1,4 @@ +use tree_hash::TreeHash; use crate::{ consensus_types::ForkData, constants::{Config, Domain, Root, Version}, @@ -5,7 +6,7 @@ use crate::{ }; use alloc::{vec, vec::Vec}; use anyhow::anyhow; -use ssz_rs::prelude::*; +use ssz_types::{typenum::Unsigned, FixedVector, VariableList}; /// Returns true if sync committee update is required pub fn should_have_sync_committee_update(state_period: u64, signature_period: u64) -> bool { @@ -24,6 +25,13 @@ pub fn compute_epoch_at_slot(slot: u64) -> u64 { /// Return the fork version at the given ``epoch``. pub fn compute_fork_version(epoch: u64) -> [u8; 4] { + // The verifier resolves the fork version for any slot it is handed, including post-Gloas ones, + // so this branch is always compiled. Pre-Gloas networks set `GLOAS_FORK_EPOCH` to the far + // future, which keeps this inert until the fork is actually scheduled. + if epoch >= C::GLOAS_FORK_EPOCH { + return C::GLOAS_FORK_VERSION; + } + if epoch >= C::FULU_FORK_EPOCH { C::FULU_FORK_VERSION } else if epoch >= C::ELECTRA_FORK_EPOCH { @@ -56,29 +64,32 @@ pub fn compute_domain( Ok(domain) } -#[derive(Default, Debug, SimpleSerialize)] +#[derive(Default, Debug, ssz_derive::Encode, ssz_derive::Decode, tree_hash_derive::TreeHash)] pub struct SigningData { pub object_root: Root, pub domain: Domain, } -pub fn compute_signing_root( +pub fn compute_signing_root( ssz_object: &mut T, domain: Domain, ) -> Result { - let object_root = ssz_object.hash_tree_root().map_err(|e| anyhow!("{:?}", e))?; + // `tree_hash_root` is infallible, where ssz-rs's `hash_tree_root` returned a `Result`. + let object_root = Root::try_from(ssz_object.tree_hash_root().as_slice()) + .map_err(|e| anyhow!("{:?}", e))?; - let mut s = SigningData { object_root, domain }; - s.hash_tree_root().map_err(|e| anyhow!("{:?}", e)) + let s = SigningData { object_root, domain }; + Root::try_from(s.tree_hash_root().as_slice()).map_err(|e| anyhow!("{:?}", e)) } pub fn compute_fork_data_root( current_version: Version, genesis_validators_root: Root, ) -> Result { - ForkData { current_version, genesis_validators_root } - .hash_tree_root() - .map_err(|e| anyhow!("{:?}", e)) + Root::try_from( + ForkData { current_version, genesis_validators_root }.tree_hash_root().as_slice(), + ) + .map_err(|e| anyhow!("{:?}", e)) } /// Return the sync committee period at ``slot`` diff --git a/modules/consensus/sync-committee/prover/Cargo.toml b/modules/consensus/sync-committee/prover/Cargo.toml index d23c2f98e..cba63c2de 100644 --- a/modules/consensus/sync-committee/prover/Cargo.toml +++ b/modules/consensus/sync-committee/prover/Cargo.toml @@ -16,7 +16,9 @@ hex = { workspace = true, default-features = true } json = { workspace = true, default-features = true } primitive-types = { workspace = true, default-features = true, features = ["serde_no_std", "impl-codec"] } -ssz-rs = { git = "https://github.com/polytope-labs/ssz-rs", branch = "main" } +ssz = { workspace = true, features = ["scale"] } +ssz_types = { workspace = true, features = ["scale"] } +tree_hash = { workspace = true, features = ["sha2"] } reqwest = { version="0.11.14", features=["json"]} reqwest-middleware = "0.2.4" reqwest-chain = "0.1.0" @@ -26,6 +28,11 @@ bls = { workspace = true, default-features = true } async-trait = "0.1.77" tracing = "0.1.40" +[features] +# Selects the prover's compile-time Gloas `BeaconState` ssz layout. The verifier is fork-agnostic +# and no longer has this feature. +glamsterdam = ["sync-committee-primitives/glamsterdam"] + [dev-dependencies] env_logger = "0.10.0" sync-committee-primitives = { path= "../primitives" } diff --git a/modules/consensus/sync-committee/prover/src/gloas_test.rs b/modules/consensus/sync-committee/prover/src/gloas_test.rs new file mode 100644 index 000000000..a937ce81c --- /dev/null +++ b/modules/consensus/sync-committee/prover/src/gloas_test.rs @@ -0,0 +1,200 @@ +//! Tests against a beacon chain that has already forked to Gloas. Point `CONSENSUS_NODE_URL` and +//! `EXECUTION_NODE_URL` at an ethpandaops glamsterdam devnet, or a local devnet running the same +//! preset, and run with `--features glamsterdam --ignored`. + +use super::*; +use tree_hash::{ + proof::{is_valid_merkle_branch, TreeHashFields}, + Hash256, TreeHash, +}; +use sync_committee_primitives::{ + constants::{ + devnet::GlamsterdamDevnet, ETH1_DATA_VOTES_BOUND_ETH, PROPOSER_LOOK_AHEAD_LIMIT_ETHEREUM, + }, + execution_header::{execution_block_hash, ExecutionHeader}, + util::compute_epoch_at_slot, +}; +use sync_committee_verifier::{error::Error, verify_sync_committee_attestation}; + +fn setup_prover() -> SyncCommitteeProver< + GlamsterdamDevnet, + ETH1_DATA_VOTES_BOUND_ETH, + PROPOSER_LOOK_AHEAD_LIMIT_ETHEREUM, +> { + dotenv::dotenv().ok(); + let consensus_url = + std::env::var("CONSENSUS_NODE_URL").unwrap_or("http://localhost:53001".to_string()); + let execution_url = + std::env::var("EXECUTION_NODE_URL").unwrap_or("http://localhost:8545".to_string()); + + SyncCommitteeProver::< + GlamsterdamDevnet, + ETH1_DATA_VOTES_BOUND_ETH, + PROPOSER_LOOK_AHEAD_LIMIT_ETHEREUM, + >::new(vec![consensus_url], execution_url) +} + +/// The whole point of this one is the container layout. If a single field of the Gloas +/// `BeaconState` is out of order, wrongly sized or missing, the root will not match and every +/// proof the prover generates would be rejected on chain. +#[tokio::test] +#[ignore] +async fn beacon_state_hashes_to_the_signed_header() { + let prover = setup_prover(); + let mut state = prover.fetch_beacon_state("finalized").await.unwrap(); + let header = prover.fetch_header(&state.slot.to_string()).await.unwrap(); + + assert_eq!(state.tree_hash_root(), Hash256::from(&header.state_root)); +} + +/// The execution state root is no longer proven directly, so this walks the path that replaces it: +/// the beacon state commits to a block hash, the block hash is the keccak of the header, and the +/// header carries the state root. +#[tokio::test] +#[ignore] +async fn execution_header_recovers_the_execution_state_root() { + let prover = setup_prover(); + let mut finalized_state = prover.fetch_beacon_state("finalized").await.unwrap(); + let finalized_header = prover.fetch_header(&finalized_state.slot.to_string()).await.unwrap(); + + let block_hash = H256::from_slice(finalized_state.latest_block_hash.as_ref()); + let header = prover.fetch_execution_header(block_hash).await.unwrap(); + + let proof = prove_execution_payload::< + GlamsterdamDevnet, + ETH1_DATA_VOTES_BOUND_ETH, + PROPOSER_LOOK_AHEAD_LIMIT_ETHEREUM, + >(&mut finalized_state, header.clone()) + .unwrap(); + + let execution_header = proof.execution_header().expect("gloas proof carries the rlp header"); + + // the header we ship is the preimage of the block hash the beacon state committed to + assert_eq!(execution_block_hash(execution_header), block_hash.0); + + // and that block hash really does sit inside the state the sync committee signed over + let payload_branch: Vec = + proof.execution_payload_branch.iter().map(Into::into).collect(); + assert!(is_valid_merkle_branch( + Hash256::from(execution_block_hash(execution_header)), + &payload_branch, + GlamsterdamDevnet::EXECUTION_PAYLOAD_INDEX, + Hash256::from(&finalized_header.state_root), + )); + + // so the fields the bridge consumes can be read straight off the header + let decoded = ExecutionHeader::decode(execution_header).unwrap(); + assert_eq!(decoded, header); +} + +/// Bootstrap a trusted state from a finalized checkpoint a few epochs back and produce the real +/// update that advances to the current finalized checkpoint. The older checkpoint is looked up via +/// the state endpoint, which tolerates skipped slots, and its block is fetched by root, which does +/// not 404, so this is one shot rather than polling for a fresh finalization. +async fn bootstrap_trusted_state_and_update( + prover: &SyncCommitteeProver< + GlamsterdamDevnet, + ETH1_DATA_VOTES_BOUND_ETH, + PROPOSER_LOOK_AHEAD_LIMIT_ETHEREUM, + >, +) -> anyhow::Result<(VerifierState, VerifierStateUpdate)> { + let block_id = |root: Root| format!("0x{}", hex::encode(root.as_ref())); + + let current = prover.fetch_finalized_checkpoint(None).await?.finalized; + let current_header = prover.fetch_header(&block_id(current.root.clone())).await?; + + // A few epochs back, comfortably inside the same sync committee period. + let trusted_slot = current_header.slot.saturating_sub(3 * GlamsterdamDevnet::SLOTS_PER_EPOCH); + let trusted = prover + .fetch_finalized_checkpoint(Some(&trusted_slot.to_string())) + .await? + .finalized; + let trusted_header = prover.fetch_header(&block_id(trusted.root)).await?; + let trusted_state_state = prover.fetch_beacon_state(&trusted_header.slot.to_string()).await?; + + let trusted_state = VerifierState { + finalized_header: trusted_header.clone(), + latest_finalized_epoch: compute_epoch_at_slot::(trusted_header.slot), + current_sync_committee: trusted_state_state.current_sync_committee, + next_sync_committee: trusted_state_state.next_sync_committee, + state_period: compute_sync_committee_period_at_slot::( + trusted_header.slot, + ), + }; + + let update = prover + .fetch_light_client_update(trusted_state.clone(), current, None) + .await? + .ok_or_else(|| { + anyhow::anyhow!("no update produced between the two finalized checkpoints") + })?; + + Ok((trusted_state, update)) +} + +/// The one that runs the code that actually ships. The two tests above check the pieces in +/// isolation; this drives a real Gloas update through `verify_sync_committee_attestation`, which +/// is where the block hash branch and the keccak preimage check run on chain. +#[tokio::test] +#[ignore] +async fn verifier_accepts_a_real_gloas_update() -> anyhow::Result<()> { + let prover = setup_prover(); + let (trusted_state, update) = bootstrap_trusted_state_and_update(&prover).await?; + + let (new_state, execution_payload) = + verify_sync_committee_attestation::(trusted_state, update.clone()) + .map_err(|e| anyhow::anyhow!("verifier rejected a valid gloas update: {e:?}"))?; + + assert_eq!(new_state.finalized_header, update.finalized_header); + + // the fields the caller gets back are the header's own + let header = + ExecutionHeader::decode(update.execution_payload.execution_header().expect("gloas proof"))?; + assert_eq!(execution_payload.state_root.as_bytes(), header.state_root.as_slice()); + assert_eq!(execution_payload.block_number, header.number); + assert_eq!(execution_payload.timestamp, header.timestamp); + Ok(()) +} + +/// The security of the whole approach rests on keccak binding the execution header to the block +/// hash the sync committee signed. This tampers with a real, otherwise valid update to make sure +/// that binding rejects, both for a header that no longer decodes and for a well formed one that +/// describes a different block. +#[tokio::test] +#[ignore] +async fn verifier_rejects_tampered_gloas_updates() -> anyhow::Result<()> { + let prover = setup_prover(); + let (trusted_state, update) = bootstrap_trusted_state_and_update(&prover).await?; + + // Flipping a byte of the header changes its keccak, so it no longer matches the block hash the + // branch proves against. + let mut tampered_header = update.clone(); + tampered_header.execution_payload.execution_header_mut().expect("gloas proof")[0] ^= 0xff; + assert!( + matches!( + verify_sync_committee_attestation::( + trusted_state.clone(), + tampered_header, + ), + Err(Error::InvalidMerkleBranch(_)) + ), + "a header whose keccak does not match the block hash must be rejected", + ); + + // Lying about the state root means re-encoding the header. The bytes still decode, but they + // hash to a different block, so the branch catches it. + let mut tampered_root = update.clone(); + let header_bytes = tampered_root.execution_payload.execution_header_mut().expect("gloas proof"); + let mut header = ExecutionHeader::decode(&header_bytes[..])?; + header.state_root = Default::default(); + *header_bytes = header.encode(); + assert!( + matches!( + verify_sync_committee_attestation::(trusted_state, tampered_root), + Err(Error::InvalidMerkleBranch(_)) + ), + "a state root that disagrees with the block hash must be rejected", + ); + + Ok(()) +} diff --git a/modules/consensus/sync-committee/prover/src/lib.rs b/modules/consensus/sync-committee/prover/src/lib.rs index 71fea5ae4..fdd739444 100644 --- a/modules/consensus/sync-committee/prover/src/lib.rs +++ b/modules/consensus/sync-committee/prover/src/lib.rs @@ -7,9 +7,15 @@ use primitive_types::H256; use reqwest::{Client, Url}; use reqwest_chain::ChainMiddleware; use reqwest_middleware::{ClientBuilder, ClientWithMiddleware}; -use ssz_rs::{Merkleized, Node}; +use ssz_types::typenum::Unsigned; +use tree_hash::{ + proof::{generate_multiproof, ContainerFields, TreeHashFields}, + TreeHash, +}; use tracing::instrument; +#[cfg(feature = "glamsterdam")] +use sync_committee_primitives::execution_header::{execution_block_hash, ExecutionHeader}; use sync_committee_primitives::{ consensus_types::{BeaconBlock, BeaconBlockHeader, BeaconState, Checkpoint, Validator}, constants::{ @@ -25,7 +31,7 @@ use sync_committee_primitives::{ }, deneb::MAX_BLOB_COMMITMENTS_PER_BLOCK, types::{ - ExecutionPayloadProof, FinalityProof, SyncCommitteeUpdate, VerifierState, + ExecutionPayloadProof, ExecutionProof, FinalityProof, SyncCommitteeUpdate, VerifierState, VerifierStateUpdate, }, util::{compute_sync_committee_period_at_slot, should_have_sync_committee_update}, @@ -47,12 +53,17 @@ pub mod middleware; pub mod responses; pub mod routes; -#[cfg(test)] +// The ssz layout is fixed at compile time, so the pre Gloas tests and the Gloas ones cannot both +// be built from the same binary. +#[cfg(all(test, not(feature = "glamsterdam")))] mod test; +#[cfg(all(test, feature = "glamsterdam"))] +mod gloas_test; + pub type BeaconStateType< - const ETH1_DATA_VOTES_BOUND: usize, - const PROPOSER_LOOK_AHEAD_LIMIT: usize, + ETH1_DATA_VOTES_BOUND: Unsigned, + PROPOSER_LOOK_AHEAD_LIMIT: Unsigned, > = BeaconState< SLOTS_PER_HISTORICAL_ROOT, HISTORICAL_ROOTS_LIMIT, @@ -71,16 +82,20 @@ pub type BeaconStateType< pub struct SyncCommitteeProver< C: Config, - const ETH1_DATA_VOTES_BOUND: usize, - const PROPOSER_LOOK_AHEAD_LIMIT: usize, + ETH1_DATA_VOTES_BOUND: Unsigned, + PROPOSER_LOOK_AHEAD_LIMIT: Unsigned, > { pub primary_url: String, pub providers: Vec, pub client: ClientWithMiddleware, - pub phantom: PhantomData, + /// Execution rpc, needed after Gloas because the execution header no longer reaches us + /// through the beacon state. + #[cfg(feature = "glamsterdam")] + pub el_rpc_url: String, + pub phantom: PhantomData<(C, ETH1_DATA_VOTES_BOUND, PROPOSER_LOOK_AHEAD_LIMIT)>, } -impl Clone +impl Clone for SyncCommitteeProver { fn clone(&self) -> Self { @@ -88,15 +103,17 @@ impl +impl SyncCommitteeProver { - pub fn new(providers: Vec) -> Self { + pub fn new(providers: Vec, #[cfg(feature = "glamsterdam")] el_rpc_url: String) -> Self { let client = ClientBuilder::new(Client::new()) .with(ChainMiddleware::new(SwitchProviderMiddleware::_new(providers.clone()))) .build(); @@ -105,9 +122,46 @@ impl Result { + #[derive(serde::Deserialize)] + struct Response { + result: Option, + } + + let request = json::json!({ + "jsonrpc": "2.0", + "id": 1, + "method": "eth_getBlockByHash", + "params": [format!("{block_hash:?}"), false], + }); + + let response = self + .client + .post(&self.el_rpc_url) + .json(&request) + .send() + .await? + .json::() + .await?; + + response.result.ok_or_else(|| { + anyhow!("Execution block {block_hash:?} not found on {}", self.el_rpc_url) + }) + } #[instrument(level = "trace", target = "sync-committee-prover", skip(self))] pub async fn fetch_finalized_checkpoint( &self, @@ -273,7 +327,7 @@ impl, ) -> Result, anyhow::Error> { - if finality_checkpoint.root == Node::default() || + if finality_checkpoint.root == Root::default() || client_state.latest_finalized_epoch >= finality_checkpoint.epoch { trace!(target: "sync-committee-prover", "No new epoch finalized yet {}", finality_checkpoint.epoch); @@ -283,9 +337,9 @@ impl(block.slot); @@ -321,11 +375,11 @@ impl(&mut attested_state)?, }; + #[cfg(not(feature = "glamsterdam"))] let execution_payload_proof = prove_execution_payload::( &mut finalized_state, )?; + #[cfg(feature = "glamsterdam")] + let execution_payload_proof = { + let block_hash = H256::from_slice(finalized_state.latest_block_hash.as_ref()); + let header = self.fetch_execution_header(block_hash).await?; + prove_execution_payload::( + &mut finalized_state, + header, + )? + }; + let signature_period = compute_sync_committee_period_at_slot::(block.slot); - let client_state_next_sync_committee_root = - client_state.next_sync_committee.hash_tree_root()?; - let attested_state_current_sync_committee_root = - attested_state.current_sync_committee.hash_tree_root()?; + let client_state_next_sync_committee_root: Root = + client_state.next_sync_committee.tree_hash_root().into(); + let attested_state_current_sync_committee_root: Root = + attested_state.current_sync_committee.tree_hash_root().into(); let sync_committee_update = // We must make sure we switch the sync comittee only when the finalized header has changed sync committees if should_have_sync_committee_update(state_period, signature_period) && client_state_next_sync_committee_root == attested_state_current_sync_committee_root { @@ -405,17 +470,17 @@ impl= min_signatures { break; } - let parent_root = block.parent_root; + let parent_root = block.parent_root.clone(); let parent_block_id = get_block_id(parent_root); let parent_block = self.fetch_block(&parent_block_id).await?; @@ -426,8 +491,8 @@ impl(&mut attested_state)?, }; + #[cfg(not(feature = "glamsterdam"))] let execution_payload_proof = prove_execution_payload::( &mut finalized_state, )?; + #[cfg(feature = "glamsterdam")] + let execution_payload_proof = { + let block_hash = H256::from_slice(finalized_state.latest_block_hash.as_ref()); + let header = self.fetch_execution_header(block_hash).await?; + prove_execution_payload::( + &mut finalized_state, + header, + )? + }; + let sync_committee_update = { let sync_committee_proof = prove_sync_committee_update::( @@ -472,68 +548,121 @@ impl( beacon_state: &mut BeaconStateType, ) -> anyhow::Result { trace!(target: "sync-committee-prover", "Proving execution payload"); let indices = [ - C::EXECUTION_PAYLOAD_STATE_ROOT_INDEX as usize, - C::EXECUTION_PAYLOAD_BLOCK_NUMBER_INDEX as usize, - C::EXECUTION_PAYLOAD_TIMESTAMP_INDEX as usize, + C::EXECUTION_PAYLOAD_STATE_ROOT_INDEX, + C::EXECUTION_PAYLOAD_BLOCK_NUMBER_INDEX, + C::EXECUTION_PAYLOAD_TIMESTAMP_INDEX, ]; // generate multi proofs - let multi_proof = ssz_rs::generate_proof( - &mut beacon_state.latest_execution_payload_header, - indices.as_slice(), - )?; + let multi_proof: Vec = generate_multiproof( + &beacon_state.latest_execution_payload_header.field_roots(), + &indices, + )? + .into_iter() + .map(Into::into) + .collect(); trace!(target: "sync-committee-prover", "finished proving execution payload"); Ok(ExecutionPayloadProof { - state_root: H256::from_slice( - beacon_state.latest_execution_payload_header.state_root.as_slice(), - ), - block_number: beacon_state.latest_execution_payload_header.block_number, - timestamp: beacon_state.latest_execution_payload_header.timestamp, - multi_proof, - execution_payload_branch: ssz_rs::generate_proof( - beacon_state, - &[C::EXECUTION_PAYLOAD_INDEX as usize], - )?, + execution_payload_branch: prove_state_field(beacon_state, C::EXECUTION_PAYLOAD_INDEX)?, + proof: ExecutionProof::Legacy { + state_root: H256::from_slice( + beacon_state.latest_execution_payload_header.state_root.as_ref(), + ), + block_number: beacon_state.latest_execution_payload_header.block_number, + timestamp: beacon_state.latest_execution_payload_header.timestamp, + multi_proof, + }, }) } +/// After Gloas the beacon state holds the execution block hash rather than the payload header, so +/// the proof carries the header itself and the verifier recovers the state root from it. The +/// branch is still taken at the same generalized index, because `latest_block_hash` took over the +/// slot the payload header vacated. +#[cfg(feature = "glamsterdam")] +#[instrument(level = "trace", target = "sync-committee-prover", skip_all)] +pub fn prove_execution_payload< + C: Config, + ETH1_DATA_VOTES_BOUND: Unsigned, + PROPOSER_LOOK_AHEAD_LIMIT: Unsigned, +>( + beacon_state: &mut BeaconStateType, + header: ExecutionHeader, +) -> anyhow::Result { + trace!(target: "sync-committee-prover", "Proving execution payload"); + + let execution_header = header.encode(); + let block_hash = execution_block_hash(&execution_header); + + // A mismatch here means our rlp layout has drifted from the chain's, which would produce a + // proof no verifier can accept. Fail now, with something a reader can act on. + if block_hash.as_slice() != beacon_state.latest_block_hash.as_ref() { + return Err(anyhow!( + "Encoded execution header hashes to {:?}, but the beacon state committed to {:?}", + H256::from_slice(&block_hash), + H256::from_slice(beacon_state.latest_block_hash.as_ref()), + )); + } + + trace!(target: "sync-committee-prover", "finished proving execution payload"); + + Ok(ExecutionPayloadProof { + execution_payload_branch: prove_state_field(beacon_state, C::EXECUTION_PAYLOAD_INDEX)?, + proof: ExecutionProof::Gloas { execution_header }, + }) +} + + +/// Prove a single beacon state field, addressed by generalized index. +/// +/// The state is a plain container before Gloas and a progressive one from Gloas, and the two +/// merkleize differently, so the branch has to be built the matching way. +fn prove_state_field( + state: &BeaconStateType, + gindex: u64, +) -> anyhow::Result> { + #[cfg(feature = "glamsterdam")] + let branch = state.prove_gindex(gindex)?.1; + #[cfg(not(feature = "glamsterdam"))] + let branch = generate_multiproof(&state.field_roots(), &[gindex])?; + + Ok(branch.into_iter().map(Into::into).collect()) +} + #[instrument(level = "trace", target = "sync-committee-prover", skip_all)] pub fn prove_sync_committee_update< C: Config, - const ETH1_DATA_VOTES_BOUND: usize, - const PROPOSER_LOOK_AHEAD_LIMIT: usize, + ETH1_DATA_VOTES_BOUND: Unsigned, + PROPOSER_LOOK_AHEAD_LIMIT: Unsigned, >( state: &mut BeaconStateType, -) -> anyhow::Result> { +) -> anyhow::Result> { trace!(target: "sync-committee-prover", "Proving sync committee update"); - let proof = ssz_rs::generate_proof(state, &[C::NEXT_SYNC_COMMITTEE_INDEX as usize])?; - Ok(proof) + prove_state_field(state, C::NEXT_SYNC_COMMITTEE_INDEX) } #[instrument(level = "trace", target = "sync-committee-prover", skip_all)] pub fn prove_finalized_header< C: Config, - const ETH1_DATA_VOTES_BOUND: usize, - const PROPOSER_LOOK_AHEAD_LIMIT: usize, + ETH1_DATA_VOTES_BOUND: Unsigned, + PROPOSER_LOOK_AHEAD_LIMIT: Unsigned, >( state: &mut BeaconStateType, -) -> anyhow::Result> { +) -> anyhow::Result> { trace!(target: "sync-committee-prover", "Proving finalized head"); - let indices = [C::FINALIZED_ROOT_INDEX as usize]; - let proof = ssz_rs::generate_proof(state, indices.as_slice())?; - - Ok(proof) + prove_state_field(state, C::FINALIZED_ROOT_INDEX) } pub fn eth_aggregate_public_keys(points: &[BlsPublicKey]) -> anyhow::Result { diff --git a/modules/consensus/sync-committee/prover/src/responses/beacon_block_response.rs b/modules/consensus/sync-committee/prover/src/responses/beacon_block_response.rs index f6a60940e..b89387a25 100644 --- a/modules/consensus/sync-committee/prover/src/responses/beacon_block_response.rs +++ b/modules/consensus/sync-committee/prover/src/responses/beacon_block_response.rs @@ -18,7 +18,10 @@ pub struct Response { execution_optimistic: bool, } +// The bounds inside `BeaconBlock` are type level integers, so serde's derived bounds on them are +// spurious. #[derive(Default, Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +#[serde(bound = "")] pub struct ResponseData { pub(crate) message: BeaconBlock< MAX_PROPOSER_SLASHINGS, diff --git a/modules/consensus/sync-committee/prover/src/responses/beacon_state_response.rs b/modules/consensus/sync-committee/prover/src/responses/beacon_state_response.rs index f6e9f13b5..246dde339 100644 --- a/modules/consensus/sync-committee/prover/src/responses/beacon_state_response.rs +++ b/modules/consensus/sync-committee/prover/src/responses/beacon_state_response.rs @@ -1,7 +1,11 @@ +use ssz_types::typenum::Unsigned; use crate::BeaconStateType; +// The bounds are type level integers that never appear in a field, so serde's derived +// `T: Deserialize` bounds are spurious and have to be switched off. #[derive(Default, Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] -pub struct Response { +#[serde(bound = "")] +pub struct Response { version: String, pub(crate) data: BeaconStateType, } diff --git a/modules/consensus/sync-committee/prover/src/test.rs b/modules/consensus/sync-committee/prover/src/test.rs index ac9ba75ee..e5803be29 100644 --- a/modules/consensus/sync-committee/prover/src/test.rs +++ b/modules/consensus/sync-committee/prover/src/test.rs @@ -1,7 +1,13 @@ use super::*; use reqwest_eventsource::EventSource; -use ssz_rs::{calculate_multi_merkle_root, is_valid_merkle_branch, GeneralizedIndex, Merkleized}; +use tree_hash::{ + proof::{ + generate_multiproof, is_valid_merkle_branch, multiproof::calculate_multi_merkle_root, + ContainerFields, + }, + Hash256, TreeHash, +}; use sync_committee_primitives::{ constants::{Root, ETH1_DATA_VOTES_BOUND_ETH, PROPOSER_LOOK_AHEAD_LIMIT_ETHEREUM}, types::VerifierState, @@ -60,9 +66,9 @@ async fn state_root_and_block_header_root_matches() { assert!(block_header.is_ok()); let block_header = block_header.unwrap(); - let hash_tree_root = beacon_state.hash_tree_root(); + let hash_tree_root = beacon_state.tree_hash_root(); - assert_eq!(block_header.state_root, hash_tree_root.unwrap()); + assert_eq!(Hash256::from(&block_header.state_root), hash_tree_root); } #[allow(non_snake_case)] @@ -82,24 +88,16 @@ async fn test_finalized_header() { let mut state = sync_committee_prover.fetch_beacon_state("head").await.unwrap(); let proof = - ssz_rs::generate_proof(&mut state, &vec![KurtosisDevnet::FINALIZED_ROOT_INDEX as usize]) - .unwrap(); - - let leaves = vec![Node::from_bytes( - state - .finalized_checkpoint - .hash_tree_root() - .unwrap() - .as_ref() - .try_into() - .unwrap(), - )]; + generate_multiproof(&state.field_roots(), &[KurtosisDevnet::FINALIZED_ROOT_INDEX]).unwrap(); + + let leaves = vec![state.finalized_checkpoint.tree_hash_root()]; let root = calculate_multi_merkle_root( &leaves, &proof, - &[GeneralizedIndex(KurtosisDevnet::FINALIZED_ROOT_INDEX as usize)], - ); - assert_eq!(root, state.hash_tree_root().unwrap()); + &[KurtosisDevnet::FINALIZED_ROOT_INDEX], + ) + .unwrap(); + assert_eq!(root, state.tree_hash_root()); } #[allow(non_snake_case)] @@ -120,38 +118,42 @@ async fn test_execution_payload_proof() { let finalized_header = sync_committee_prover.fetch_header(&block_id).await.unwrap(); // verify the associated execution header of the finalized beacon header. - let mut execution_payload = execution_payload_proof.clone(); - let multi_proof_vec = execution_payload.multi_proof; + let execution_payload = execution_payload_proof.clone(); + let ExecutionProof::Legacy { state_root, block_number, timestamp, multi_proof } = + execution_payload.proof.clone() + else { + panic!("expected a legacy execution proof") + }; let execution_payload_root = calculate_multi_merkle_root( &[ - Node::from_bytes(execution_payload.state_root.as_ref().try_into().unwrap()), - execution_payload.block_number.hash_tree_root().unwrap(), - execution_payload.timestamp.hash_tree_root().unwrap(), + Hash256::from_slice(state_root.as_ref()), + block_number.tree_hash_root(), + timestamp.tree_hash_root(), ], - &multi_proof_vec, + &multi_proof.iter().map(Into::into).collect::>(), &[ - GeneralizedIndex(KurtosisDevnet::EXECUTION_PAYLOAD_STATE_ROOT_INDEX as usize), - GeneralizedIndex(KurtosisDevnet::EXECUTION_PAYLOAD_BLOCK_NUMBER_INDEX as usize), - GeneralizedIndex(KurtosisDevnet::EXECUTION_PAYLOAD_TIMESTAMP_INDEX as usize), + KurtosisDevnet::EXECUTION_PAYLOAD_STATE_ROOT_INDEX, + KurtosisDevnet::EXECUTION_PAYLOAD_BLOCK_NUMBER_INDEX, + KurtosisDevnet::EXECUTION_PAYLOAD_TIMESTAMP_INDEX, ], - ); + ) + .unwrap(); let execution_payload_hash_tree_root = finalized_state .latest_execution_payload_header .clone() - .hash_tree_root() - .unwrap(); + .tree_hash_root(); assert_eq!(execution_payload_root, execution_payload_hash_tree_root); - let execution_payload_branch = execution_payload.execution_payload_branch.iter(); + let execution_payload_branch: Vec = + execution_payload.execution_payload_branch.iter().map(Into::into).collect(); let is_merkle_branch_valid = is_valid_merkle_branch( - &execution_payload_root, - execution_payload_branch, - KurtosisDevnet::EXECUTION_PAYLOAD_INDEX_LOG2 as usize, - KurtosisDevnet::EXECUTION_PAYLOAD_INDEX as usize, - &finalized_header.state_root, + execution_payload_root, + &execution_payload_branch, + KurtosisDevnet::EXECUTION_PAYLOAD_INDEX, + Hash256::from(&finalized_header.state_root), ); assert!(is_merkle_branch_valid); @@ -179,19 +181,21 @@ async fn test_sync_committee_update_proof() { let mut sync_committee = finalized_state.next_sync_committee; let calculated_finalized_root = calculate_multi_merkle_root( - &[sync_committee.hash_tree_root().unwrap()], - &sync_committee_proof, - &[GeneralizedIndex(KurtosisDevnet::NEXT_SYNC_COMMITTEE_INDEX as usize)], - ); + &[sync_committee.tree_hash_root()], + &sync_committee_proof.iter().map(Into::into).collect::>(), + &[KurtosisDevnet::NEXT_SYNC_COMMITTEE_INDEX], + ) + .unwrap(); - assert_eq!(calculated_finalized_root.as_bytes(), finalized_header.state_root.as_bytes()); + assert_eq!(calculated_finalized_root, Hash256::from(&finalized_header.state_root)); + let sync_committee_branch: Vec = + sync_committee_proof.iter().map(Into::into).collect(); let is_merkle_branch_valid = is_valid_merkle_branch( - &sync_committee.hash_tree_root().unwrap(), - sync_committee_proof.iter(), - KurtosisDevnet::NEXT_SYNC_COMMITTEE_INDEX_LOG2 as usize, - KurtosisDevnet::NEXT_SYNC_COMMITTEE_INDEX as usize, - &finalized_header.state_root, + sync_committee.tree_hash_root(), + &sync_committee_branch, + KurtosisDevnet::NEXT_SYNC_COMMITTEE_INDEX, + Hash256::from(&finalized_header.state_root), ); assert!(is_merkle_branch_valid); @@ -269,7 +273,7 @@ async fn test_prover() { let decoded = VerifierStateUpdate::decode(&mut &*encoded).unwrap(); assert_eq!(light_client_update, decoded); - client_state = verify_sync_committee_attestation::( + (client_state, _) = verify_sync_committee_attestation::( client_state.clone(), light_client_update, ) diff --git a/modules/consensus/sync-committee/verifier/Cargo.toml b/modules/consensus/sync-committee/verifier/Cargo.toml index 7d2f8c68b..a4af56be8 100644 --- a/modules/consensus/sync-committee/verifier/Cargo.toml +++ b/modules/consensus/sync-committee/verifier/Cargo.toml @@ -8,19 +8,25 @@ publish = false [dependencies] sync-committee-primitives = { workspace = true, default-features = false } ismp = { workspace = true, default-features = false } +primitive-types = { workspace = true, default-features = false } log = { workspace = true, default-features = false } anyhow = { workspace = true, default-features = false } thiserror = { workspace = true } -ssz-rs = { git = "https://github.com/polytope-labs/ssz-rs", branch = "main", default-features = false } +ssz = { workspace = true, features = ["scale"] } +ssz_types = { workspace = true, features = ["scale"] } +tree_hash = { workspace = true, features = ["sha2"] } bls = { workspace = true } ark-ec = { workspace = true } [features] default = ["std"] std = [ - "ssz-rs/std", + "ssz/std", + "ssz_types/std", + "tree_hash/std", "log/std", "sync-committee-primitives/std", + "primitive-types/std", "log/std", "anyhow/std", "thiserror/std", diff --git a/modules/consensus/sync-committee/verifier/src/lib.rs b/modules/consensus/sync-committee/verifier/src/lib.rs index 0bb94e243..2e678eb78 100644 --- a/modules/consensus/sync-committee/verifier/src/lib.rs +++ b/modules/consensus/sync-committee/verifier/src/lib.rs @@ -10,14 +10,19 @@ use crate::error::Error; use alloc::vec::Vec; use ark_ec::CurveGroup; use crypto::subtract_points_from_aggregate; -use ssz_rs::{ - GeneralizedIndex, Merkleized, Node, calculate_multi_merkle_root, get_helper_indices, - prelude::is_valid_merkle_branch, +use primitive_types::H256; +use tree_hash::{ + proof::{ + is_valid_merkle_branch, + multiproof::{calculate_multi_merkle_root, get_helper_indices}, + }, + Hash256, TreeHash, }; use sync_committee_primitives::{ consensus_types::Checkpoint, - constants::{Config, DOMAIN_SYNC_COMMITTEE, Root}, - types::{VerifierState, VerifierStateUpdate}, + constants::{Config, Root, DOMAIN_SYNC_COMMITTEE}, + execution_header::{execution_block_hash, ExecutionHeader}, + types::{ExecutionProof, VerifiedExecutionPayload, VerifierState, VerifierStateUpdate}, util::{ compute_domain, compute_epoch_at_slot, compute_fork_version, compute_signing_root, compute_sync_committee_period_at_slot, should_have_sync_committee_update, @@ -25,10 +30,11 @@ use sync_committee_primitives::{ }; /// This function simply verifies a sync committee's attestation & it's finalized counterpart. +/// Returns the new verifier state alongside the execution fields the proof established. pub fn verify_sync_committee_attestation( trusted_state: VerifierState, mut update: VerifierStateUpdate, -) -> Result { +) -> Result<(VerifierState, VerifiedExecutionPayload), Error> { // The finality branch is always required; validate it independently of the optional // sync-committee update. The previous combined `&&` chain only triggered when ALL three // subconditions held, so a malformed finality branch was accepted whenever the update @@ -68,7 +74,8 @@ pub fn verify_sync_committee_attestation( // Accepting such an update would stage the previous-period committee again and brick // future updates once the chain enters `state_period + 2`. if should_have_sync_committee_update(state_period, update_signature_period) { - let attested_period = compute_sync_committee_period_at_slot::(update.attested_header.slot); + let attested_period = + compute_sync_committee_period_at_slot::(update.attested_header.slot); if attested_period != update_signature_period { Err(Error::InvalidUpdate( "Attested header is not in the same sync-committee period as the signature".into(), @@ -102,13 +109,13 @@ pub fn verify_sync_committee_attestation( if sync_committee_bits .iter() .enumerate() - .any(|(i, bit)| i >= committee_size && *bit) + .any(|(i, bit)| i >= committee_size && bit) { Err(Error::InvalidUpdate("Sync committee bits set beyond committee size".into()))? } let sync_aggregate_participants: u64 = - sync_committee_bits.iter().take(committee_size).filter(|b| **b).count() as u64; + sync_committee_bits.iter().take(committee_size).filter(|b| *b).count() as u64; if sync_aggregate_participants < ((2 * committee_size as u64) / 3) + 1 { Err(Error::SyncCommitteeParticipantsTooLow)? @@ -117,7 +124,7 @@ pub fn verify_sync_committee_attestation( let non_participant_pubkeys = sync_committee_bits .iter() .zip(sync_committee_pubkeys.iter()) - .filter_map(|(bit, key)| if !(*bit) { Some(key.clone()) } else { None }) + .filter_map(|(bit, key)| if !bit { Some(key.clone()) } else { None }) .collect::>(); let fork_version = compute_fork_version::(compute_epoch_at_slot::(update.signature_slot)); @@ -125,7 +132,7 @@ pub fn verify_sync_committee_attestation( let domain = compute_domain( DOMAIN_SYNC_COMMITTEE, Some(fork_version), - Some(Root::from_bytes(C::GENESIS_VALIDATORS_ROOT.try_into().expect("Infallible"))), + Some(Root::try_from(C::GENESIS_VALIDATORS_ROOT.as_slice()).expect("Infallible")), C::GENESIS_FORK_VERSION, ) .map_err(|_| Error::InvalidUpdate("Failed to compute domain".into()))?; @@ -140,8 +147,8 @@ pub fn verify_sync_committee_attestation( let verify = bls::verify( &bls::point_to_pubkey(aggregate.into_affine()), - &signing_root.as_bytes().to_vec(), - &update.sync_aggregate.sync_committee_signature, + &signing_root.to_vec(), + &update.sync_aggregate.sync_committee_signature.to_vec(), &bls::DST_ETHEREUM.as_bytes().to_vec(), ); @@ -152,83 +159,133 @@ pub fn verify_sync_committee_attestation( // Verify that the `finality_branch` confirms `finalized_header` // to match the finalized checkpoint root saved in the state of `attested_header`. // Note that the genesis finalized checkpoint root is represented as a zero hash. - let mut finalized_checkpoint = Checkpoint { + let finalized_checkpoint = Checkpoint { epoch: update.finality_proof.epoch, - root: update - .finalized_header - .hash_tree_root() - .map_err(|_| Error::MerkleizationError("Error hashing finalized header".into()))?, + root: update.finalized_header.tree_hash_root().into(), }; + let finality_branch: Vec = + update.finality_proof.finality_branch.iter().map(Into::into).collect(); let is_merkle_branch_valid = is_valid_merkle_branch( - &finalized_checkpoint - .hash_tree_root() - .map_err(|_| Error::MerkleizationError("Failed to hash finality checkpoint".into()))?, - update.finality_proof.finality_branch.iter(), - C::FINALIZED_ROOT_INDEX_LOG2 as usize, - C::FINALIZED_ROOT_INDEX as usize, - &update.attested_header.state_root, + finalized_checkpoint.tree_hash_root(), + &finality_branch, + C::FINALIZED_ROOT_INDEX, + (&update.attested_header.state_root).into(), ); if !is_merkle_branch_valid { Err(Error::InvalidMerkleBranch("Finality branch".into()))?; } - // verify the associated execution header of the finalized beacon header. - let mut execution_payload = update.execution_payload; - let execution_payload_indices = [ - GeneralizedIndex(C::EXECUTION_PAYLOAD_STATE_ROOT_INDEX as usize), - GeneralizedIndex(C::EXECUTION_PAYLOAD_BLOCK_NUMBER_INDEX as usize), - GeneralizedIndex(C::EXECUTION_PAYLOAD_TIMESTAMP_INDEX as usize), - ]; - // `calculate_multi_merkle_root` panics on a short `multi_proof` because its final - // `objects.get(&GeneralizedIndex(1)).unwrap()` cannot reconstruct the root. Reject - // proofs whose helper-node count does not match what the algorithm requires so an - // attacker-controlled `multi_proof` cannot panic the runtime via the public unsigned - // consensus update path. - if execution_payload.multi_proof.len() != get_helper_indices(&execution_payload_indices).len() - { - Err(Error::InvalidMerkleBranch("Execution payload multiproof length".into()))?; - } - let execution_payload_root = calculate_multi_merkle_root( - &[ - Node::from_bytes(execution_payload.state_root.as_ref().try_into().expect("Infallible")), - execution_payload.block_number.hash_tree_root().map_err(|_| { - Error::MerkleizationError("Failed to hash execution payload".into()) - })?, - execution_payload - .timestamp - .hash_tree_root() - .map_err(|_| Error::MerkleizationError("Failed to hash timestamp".into()))?, - ], - &execution_payload.multi_proof, - &execution_payload_indices, - ); - - let is_merkle_branch_valid = is_valid_merkle_branch( - &execution_payload_root, - execution_payload.execution_payload_branch.iter(), - C::EXECUTION_PAYLOAD_INDEX_LOG2 as usize, - C::EXECUTION_PAYLOAD_INDEX as usize, - &update.finalized_header.state_root, - ); - - if !is_merkle_branch_valid { - Err(Error::InvalidMerkleBranch("Execution payload branch".into()))?; - } + // Verify the associated execution header of the finalized beacon header. Which proof shape is + // valid is decided by the fork the finalized header belongs to, not by the variant the update + // happens to carry, so an update cannot select its own code path: the slot the sync committee + // signed over is the arbiter. Both paths are compiled into every binary, so one verifier + // handles either side of the fork without a rebuild. + let execution_payload = &update.execution_payload; + let finalized_epoch = compute_epoch_at_slot::(update.finalized_header.slot); + let verified_execution_payload = + match (finalized_epoch >= C::GLOAS_FORK_EPOCH, &execution_payload.proof) { + // Pre-Gloas: the execution payload header lives in the beacon state, so its state_root, + // block_number and timestamp are proven directly by an ssz multi proof. + ( + false, + ExecutionProof::Legacy { state_root, block_number, timestamp, multi_proof }, + ) => { + let execution_payload_indices = [ + C::EXECUTION_PAYLOAD_STATE_ROOT_INDEX, + C::EXECUTION_PAYLOAD_BLOCK_NUMBER_INDEX, + C::EXECUTION_PAYLOAD_TIMESTAMP_INDEX, + ]; + // `calculate_multi_merkle_root` panics on a short `multi_proof` because its final + // `objects.get(&GeneralizedIndex(1)).unwrap()` cannot reconstruct the root. Reject + // proofs whose helper-node count does not match what the algorithm requires so an + // attacker-controlled `multi_proof` cannot panic the runtime via the public + // unsigned consensus update path. + if multi_proof.len() != get_helper_indices(&execution_payload_indices).len() { + Err(Error::InvalidMerkleBranch("Execution payload multiproof length".into()))?; + } + let multi_proof_nodes: Vec = + multi_proof.iter().map(Into::into).collect(); + let execution_payload_root = calculate_multi_merkle_root( + &[ + Hash256::from_slice(state_root.as_ref()), + block_number.tree_hash_root(), + timestamp.tree_hash_root(), + ], + &multi_proof_nodes, + &execution_payload_indices, + ) + .map_err(|_| { + Error::InvalidMerkleBranch("Execution payload multiproof".into()) + })?; + + let payload_branch: Vec = + execution_payload.execution_payload_branch.iter().map(Into::into).collect(); + let is_merkle_branch_valid = is_valid_merkle_branch( + execution_payload_root, + &payload_branch, + C::EXECUTION_PAYLOAD_INDEX, + (&update.finalized_header.state_root).into(), + ); + + if !is_merkle_branch_valid { + Err(Error::InvalidMerkleBranch("Execution payload branch".into()))?; + } + + VerifiedExecutionPayload { + state_root: *state_root, + block_number: *block_number, + timestamp: *timestamp, + } + }, + // Gloas leaves only the execution block hash in the beacon state, so the state root is + // recovered from the block header instead of being proven directly. Keccak binds the + // header to the hash one for one, which makes the header's contents as trustworthy as + // the hash the sync committee signed over. + (true, ExecutionProof::Gloas { execution_header }) => { + let block_hash = execution_block_hash(execution_header); + + let is_merkle_branch_valid = is_valid_merkle_branch( + Hash256::from(block_hash), + &execution_payload + .execution_payload_branch + .iter() + .map(Into::into) + .collect::>(), + C::EXECUTION_PAYLOAD_INDEX, + (&update.finalized_header.state_root).into(), + ); + + if !is_merkle_branch_valid { + Err(Error::InvalidMerkleBranch("Execution block hash branch".into()))?; + } + + let header = ExecutionHeader::decode(execution_header) + .map_err(|_| Error::InvalidUpdate("Malformed execution header".into()))?; + + VerifiedExecutionPayload { + state_root: H256::from(header.state_root.0), + block_number: header.number, + timestamp: header.timestamp, + } + }, + // The proof variant does not match the fork the finalized header belongs to. + _ => Err(Error::InvalidUpdate( + "Execution proof variant does not match the fork at the finalized header".into(), + ))?, + }; - if let Some(mut sync_committee_update) = update.sync_committee_update.clone() { - let sync_root = sync_committee_update - .next_sync_committee - .hash_tree_root() - .map_err(|_| Error::MerkleizationError("Failed to hash next sync committee".into()))?; + if let Some(sync_committee_update) = update.sync_committee_update.clone() { + let sync_root = sync_committee_update.next_sync_committee.tree_hash_root(); + let sync_branch: Vec = + sync_committee_update.next_sync_committee_branch.iter().map(Into::into).collect(); let is_merkle_branch_valid = is_valid_merkle_branch( - &sync_root, - sync_committee_update.next_sync_committee_branch.iter(), - C::NEXT_SYNC_COMMITTEE_INDEX_LOG2 as usize, - C::NEXT_SYNC_COMMITTEE_INDEX as usize, - &update.attested_header.state_root, + sync_root, + &sync_branch, + C::NEXT_SYNC_COMMITTEE_INDEX, + (&update.attested_header.state_root).into(), ); if !is_merkle_branch_valid { @@ -257,7 +314,7 @@ pub fn verify_sync_committee_attestation( } }; - Ok(verifier_state) + Ok((verifier_state, verified_execution_payload)) } #[cfg(test)] @@ -265,7 +322,7 @@ mod supermajority_tests { use super::*; use sync_committee_primitives::{ consensus_types::{BeaconBlockHeader, SyncAggregate, SyncCommittee}, - constants::{BLS_SIGNATURE_BYTES_LEN, BlsSignature, SYNC_COMMITTEE_SIZE, sepolia::Sepolia}, + constants::{sepolia::Sepolia, BlsSignature, BLS_SIGNATURE_BYTES_LEN, SYNC_COMMITTEE_SIZE}, types::{ExecutionPayloadProof, FinalityProof, VerifierState, VerifierStateUpdate}, }; @@ -299,10 +356,10 @@ mod supermajority_tests { // finality-branch length check passes; the node contents don't matter for // these tests because the supermajority gate fires before any merkle // verification. - finality_branch: vec![Node::default(); Sepolia::FINALIZED_ROOT_INDEX_LOG2 as usize], + finality_branch: vec![Root::default(); Sepolia::FINALIZED_ROOT_INDEX.ilog2() as usize], }, sync_aggregate: SyncAggregate { - sync_committee_bits: ssz_rs::Bitvector::default(), + sync_committee_bits: Default::default(), sync_committee_signature: BlsSignature::try_from(vec![ 0u8; BLS_SIGNATURE_BYTES_LEN diff --git a/modules/ismp/clients/sync-committee/src/beacon_client.rs b/modules/ismp/clients/sync-committee/src/beacon_client.rs index a484e51e8..6cab88db5 100644 --- a/modules/ismp/clients/sync-committee/src/beacon_client.rs +++ b/modules/ismp/clients/sync-committee/src/beacon_client.rs @@ -90,22 +90,21 @@ impl< let consensus_state = ConsensusState::decode(&mut &trusted_consensus_state[..]) .map_err(|_| SyncCommitteeError::DecodeConsensusState)?; - let new_light_client_state = + let (new_light_client_state, execution_payload) = sync_committee_verifier::verify_sync_committee_attestation::( consensus_state.light_client_state, - consensus_update.clone(), - ) - ?; + consensus_update, + )?; let mut state_machine_map: BTreeMap> = BTreeMap::new(); - let state_root = consensus_update.execution_payload.state_root; + let state_root = execution_payload.state_root; let intermediate_state = construct_intermediate_state( StateMachine::Evm(consensus_state.chain_id), consensus_state_id.clone(), - consensus_update.execution_payload.block_number, - consensus_update.execution_payload.timestamp, + execution_payload.block_number, + execution_payload.timestamp, &state_root[..], )?; diff --git a/modules/utils/bls-utils/Cargo.toml b/modules/utils/bls-utils/Cargo.toml index 8db723f99..2f612fa04 100644 --- a/modules/utils/bls-utils/Cargo.toml +++ b/modules/utils/bls-utils/Cargo.toml @@ -11,7 +11,11 @@ publish = false [dependencies] codec = { workspace = true } serde = { workspace = true, optional = true } -ssz-rs = { git = "https://github.com/polytope-labs/ssz-rs", branch = "main", default-features = false } +ssz = { workspace = true, features = ["scale"] } +ssz_derive = { workspace = true } +ssz_types = { workspace = true, features = ["scale"] } +tree_hash = { workspace = true, features = ["sha2"] } +tree_hash_derive = { workspace = true } serde-hex-utils = { workspace = true, default-features = false } bls = { workspace = true, default-features = false } @@ -21,8 +25,9 @@ std = [ "codec/std", "serde", "serde/std", - "ssz-rs/default", - "ssz-rs/serde", + "ssz/std", + "ssz_types/std", + "tree_hash/std", "serde-hex-utils/std", "bls/std", ] diff --git a/modules/utils/bls-utils/src/bls.rs b/modules/utils/bls-utils/src/bls.rs index e9ff9c268..cec8b5a8a 100644 --- a/modules/utils/bls-utils/src/bls.rs +++ b/modules/utils/bls-utils/src/bls.rs @@ -26,10 +26,10 @@ pub const BLS_PUBLIC_KEY_BYTES_LEN: usize = 48; pub const BLS_SIGNATURE_BYTES_LEN: usize = 96; /// A BLS12-381 public key (48 bytes compressed). -pub type BlsPublicKey = ByteVector; +pub type BlsPublicKey = ByteVector; /// A BLS12-381 signature (96 bytes compressed). -pub type BlsSignature = ByteVector; +pub type BlsSignature = ByteVector; /// Convert a compressed BLS public key to a projective point. pub fn pubkey_to_projective(compressed_key: &BlsPublicKey) -> Result { diff --git a/modules/utils/bls-utils/src/ssz/byte_vector.rs b/modules/utils/bls-utils/src/ssz/byte_vector.rs index 1f6918fa5..f65449b65 100644 --- a/modules/utils/bls-utils/src/ssz/byte_vector.rs +++ b/modules/utils/bls-utils/src/ssz/byte_vector.rs @@ -14,127 +14,199 @@ // limitations under the License. use super::write_bytes_to_lower_hex; -use alloc::{vec, vec::Vec}; +use alloc::vec::Vec; use core::{ cmp::Ordering, fmt, hash::{Hash, Hasher}, ops::{Deref, DerefMut}, }; -use ssz_rs::prelude::*; +use ssz_types::{typenum::Unsigned, FixedVector}; -#[derive(Default, Clone, Eq, SimpleSerialize, codec::Encode)] -#[cfg_attr(feature = "std", derive(serde::Serialize, serde::Deserialize))] -pub struct ByteVector( - #[cfg_attr(feature = "serde", serde(with = "serde_hex_utils::as_hex"))] Vector, -); - -/// Length-checked SCALE decoding. -/// -/// A derived `Decode` here delegates to the inner `Vector`, which carries its own derive and -/// so accepts any length — leaving a `ByteVector` holding something other than `N` bytes. -/// That matters because the SSZ hash root is not sensitive to trailing zero bytes: a value -/// packs into `ceil(N / 32)` chunks with the tail already zero-padded, so appending zeros up -/// to the next chunk boundary consumes padding that was already there and leaves the root -/// unchanged. A consumer that authenticates only by `hash_tree_root` would therefore accept an -/// over-length value and persist it, and the length would not be caught until some later -/// operation — for a BLS key, a point decompression far away from this decode. -/// -/// Reject at the boundary instead, reusing the same `deserialize` the byte-slice conversions -/// above go through so there is one definition of the length rule. +/// SCALE decoding is length checked. /// -/// The wire format is unchanged: `Vector`'s SCALE representation is just its inner `Vec`, as -/// its merkle cache is `codec(skip)`. -impl codec::Decode for ByteVector { - fn decode(input: &mut I) -> Result { - let bytes = Vec::::decode(input)?; - if bytes.len() != N { - return Err(codec::Error::from("ByteVector: decoded length does not equal N")); - } - ByteVector::::try_from(bytes) - .map_err(|_| codec::Error::from("ByteVector: SSZ deserialization failed")) +/// The derived `Decode` delegates to `FixedVector`, whose own `Decode` rejects any value that is +/// not exactly `N` elements. That check matters: the SSZ hash root is not sensitive to trailing +/// zero bytes, since a value packs into `ceil(N / 32)` chunks whose tail is already zero padded. +/// An over-length value therefore hashes identically and a consumer authenticating only by +/// `hash_tree_root` would accept it, with the length going unnoticed until some later operation. +/// `decode_length_tests` below pins the behaviour here, where it is relied upon. +#[derive(Default, Clone, codec::Encode, codec::Decode)] +pub struct ByteVector(FixedVector); + +// Derived `Eq` would demand `N: Eq`, but `N` is a type level integer that never appears in a +// value, so the bound is spurious. +impl Eq for ByteVector {} + +// Hex in, hex out, matching what the beacon API emits. The `serde(with)` attribute cannot be used +// on the field any more because it applies to `FixedVector`, which is not `AsRef<[u8]>`. +#[cfg(feature = "std")] +impl serde::Serialize for ByteVector { + fn serialize(&self, serializer: S) -> Result { + serde_hex_utils::as_hex::serialize(self, serializer) + } +} + +#[cfg(feature = "std")] +impl<'de, N: Unsigned> serde::Deserialize<'de> for ByteVector { + fn deserialize>(deserializer: D) -> Result { + serde_hex_utils::as_hex::deserialize(deserializer) + } +} + +/// SSZ and merkleization delegate straight to the inner vector, which is what the derive would +/// have produced were tuple structs supported. +impl ssz::Encode for ByteVector { + fn is_ssz_fixed_len() -> bool { + as ssz::Encode>::is_ssz_fixed_len() + } + + fn ssz_fixed_len() -> usize { + as ssz::Encode>::ssz_fixed_len() + } + + fn ssz_bytes_len(&self) -> usize { + self.0.ssz_bytes_len() + } + + fn ssz_append(&self, buf: &mut Vec) { + self.0.ssz_append(buf) + } +} + +impl ssz::Decode for ByteVector { + fn is_ssz_fixed_len() -> bool { + as ssz::Decode>::is_ssz_fixed_len() + } + + fn ssz_fixed_len() -> usize { + as ssz::Decode>::ssz_fixed_len() + } + + fn from_ssz_bytes(bytes: &[u8]) -> Result { + FixedVector::from_ssz_bytes(bytes).map(Self) + } +} + +impl tree_hash::TreeHash for ByteVector { + fn tree_hash_type() -> tree_hash::TreeHashType { + as tree_hash::TreeHash>::tree_hash_type() + } + + fn tree_hash_packed_encoding(&self) -> tree_hash::PackedEncoding { + self.0.tree_hash_packed_encoding() + } + + fn tree_hash_packing_factor() -> usize { + as tree_hash::TreeHash>::tree_hash_packing_factor() + } + + fn tree_hash_root(&self) -> tree_hash::Hash256 { + self.0.tree_hash_root() } } -impl TryFrom<&[u8]> for ByteVector { - type Error = ssz_rs::DeserializeError; +impl TryFrom<&[u8]> for ByteVector { + type Error = ssz_types::Error; fn try_from(bytes: &[u8]) -> Result { - ByteVector::::deserialize(bytes) + FixedVector::new(bytes.to_vec()).map(Self) } } -impl TryFrom> for ByteVector { - type Error = ssz_rs::DeserializeError; +impl TryFrom> for ByteVector { + type Error = ssz_types::Error; fn try_from(bytes: Vec) -> Result { - ByteVector::::deserialize(&bytes) + FixedVector::new(bytes).map(Self) } } // impl here to satisfy clippy -impl PartialEq for ByteVector { +impl PartialEq for ByteVector { fn eq(&self, other: &Self) -> bool { self.0 == other.0 } } -impl PartialOrd for ByteVector { +impl PartialOrd for ByteVector { fn partial_cmp(&self, other: &Self) -> Option { Some(self.cmp(other)) } } -impl Ord for ByteVector { +impl Ord for ByteVector { fn cmp(&self, other: &Self) -> Ordering { self.as_ref().cmp(other.as_ref()) } } -impl Hash for ByteVector { +impl Hash for ByteVector { fn hash(&self, state: &mut H) { self.as_ref().hash(state); } } -impl fmt::LowerHex for ByteVector { +impl fmt::LowerHex for ByteVector { fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { write_bytes_to_lower_hex(f, self) } } -impl fmt::Debug for ByteVector { +impl fmt::Debug for ByteVector { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!(f, "ByteVector<{N}>({self:#x})") + write!(f, "ByteVector<{}>({self:#x})", N::to_usize()) } } -impl fmt::Display for ByteVector { +impl fmt::Display for ByteVector { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { write!(f, "{self:#x}") } } -impl AsRef<[u8]> for ByteVector { +impl AsRef<[u8]> for ByteVector { fn as_ref(&self) -> &[u8] { &self.0 } } -impl Deref for ByteVector { - type Target = Vector; +impl Deref for ByteVector { + type Target = FixedVector; fn deref(&self) -> &Self::Target { &self.0 } } -impl DerefMut for ByteVector { +impl DerefMut for ByteVector { fn deref_mut(&mut self) -> &mut Self::Target { &mut self.0 } } +/// Conversions to and from `tree_hash`'s root type. +/// +/// Merkleization deals in `Hash256`, while the consensus types carry 32 byte roots that also need +/// SCALE, so the two representations meet here rather than at every call site. +impl From for ByteVector { + fn from(hash: tree_hash::Hash256) -> Self { + Self(FixedVector::new(hash.as_slice().to_vec()).expect("a hash is exactly 32 bytes")) + } +} + +impl From<&ByteVector> for tree_hash::Hash256 { + fn from(bytes: &ByteVector) -> Self { + tree_hash::Hash256::from_slice(bytes.as_ref()) + } +} + +impl From> for tree_hash::Hash256 { + fn from(bytes: ByteVector) -> Self { + tree_hash::Hash256::from_slice(bytes.as_ref()) + } +} + #[cfg(test)] mod decode_length_tests { use super::*; @@ -142,7 +214,7 @@ mod decode_length_tests { /// BLS public keys are the case that motivated this: 48 bytes, i.e. two SSZ chunks whose /// second is half padding. - const N: usize = 48; + type N = ssz_types::typenum::U48; fn encoded(len: usize) -> Vec { vec![7u8; len].encode() @@ -150,24 +222,24 @@ mod decode_length_tests { #[test] fn accepts_exactly_n_bytes_and_round_trips() { - let original = ByteVector::::try_from(vec![7u8; N]).expect("N bytes is valid"); + let original = ByteVector::::try_from(vec![7u8; 48]).expect("N bytes is valid"); let decoded = ByteVector::::decode(&mut &original.encode()[..]).expect("round trip"); assert_eq!(decoded, original); - assert_eq!(decoded.as_ref().len(), N); + assert_eq!(decoded.as_ref().len(), 48); } /// The reported vector: one trailing byte past `N`. The SSZ root is unchanged by it, so the /// decode boundary is the only place this can be caught. #[test] fn rejects_one_byte_over() { - assert!(ByteVector::::decode(&mut &encoded(N + 1)[..]).is_err()); + assert!(ByteVector::::decode(&mut &encoded(49)[..]).is_err()); } /// Every length up to the next chunk boundary shares the 48-byte root, so each one has to /// be rejected — not just the first. #[test] fn rejects_every_length_sharing_the_hash_root() { - for len in (N + 1)..=64 { + for len in 49..=64 { assert!( ByteVector::::decode(&mut &encoded(len)[..]).is_err(), "over-length value of {len} bytes was accepted", @@ -177,7 +249,7 @@ mod decode_length_tests { #[test] fn rejects_under_length() { - for len in [0usize, 1, N - 1] { + for len in [0usize, 1, 47] { assert!( ByteVector::::decode(&mut &encoded(len)[..]).is_err(), "under-length value of {len} bytes was accepted", @@ -203,7 +275,7 @@ mod decode_length_tests { tail: u32, } - let good = Wire { key: vec![7u8; N], tail: 1 }.encode(); + let good = Wire { key: vec![7u8; 48], tail: 1 }.encode(); assert!(Parsed::decode(&mut &good[..]).is_ok()); let bad = Wire { key: vec![7u8; N + 1], tail: 1 }.encode(); diff --git a/modules/utils/serde/src/lib.rs b/modules/utils/serde/src/lib.rs index 865e4e318..228a67819 100644 --- a/modules/utils/serde/src/lib.rs +++ b/modules/utils/serde/src/lib.rs @@ -191,6 +191,57 @@ pub mod seq_of_hex { } } +/// Integer that may arrive as a hex quantity (`0x1a138`) or a plain decimal string. The execution +/// rpc always uses the `0x` form, and the beacon api is inconsistent for a few gloas fields: +/// different consensus clients behind the same endpoint serve `builder.version` as either `"0x00"` +/// or `"0"`. The `0x` prefix decides the radix, which reads both without ambiguity. +pub mod as_hex_quantity { + use super::*; + use alloc::format; + use serde::de::{Deserializer, Error}; + + /// Serialize an integer into a hex quantity + pub fn serialize(data: &T, serializer: S) -> Result + where + S: serde::Serializer, + T: Copy + Into, + { + let value: u64 = (*data).into(); + serializer.collect_str(&format!("{HEX_ENCODING_PREFIX}{value:x}")) + } + + /// Deserialize an integer from a hex quantity, a decimal string, or a bare integer + pub fn deserialize<'de, D, T>(deserializer: D) -> Result + where + D: Deserializer<'de>, + T: TryFrom, + { + struct QuantityVisitor; + + impl serde::de::Visitor<'_> for QuantityVisitor { + type Value = u64; + + fn expecting(&self, f: &mut core::fmt::Formatter) -> core::fmt::Result { + f.write_str("a hex quantity, a decimal string, or an integer") + } + + fn visit_str(self, v: &str) -> Result { + match v.strip_prefix(HEX_ENCODING_PREFIX) { + Some(digits) => u64::from_str_radix(digits, 16).map_err(Error::custom), + None => v.parse::().map_err(Error::custom), + } + } + + fn visit_u64(self, v: u64) -> Result { + Ok(v) + } + } + + let value = deserializer.deserialize_any(QuantityVisitor)?; + T::try_from(value).map_err(|_| Error::custom("quantity is out of range")) + } +} + /// String serializer and deserializer pub mod as_string { use alloc::{ @@ -323,6 +374,137 @@ pub mod seq_of_str { } } +/// Sequence of sequences of strings, as the beacon api returns the gloas ptc window. +pub mod seq_of_seq_of_str { + use super::*; + use alloc::{format, string::String, vec::Vec}; + use core::{fmt, str::FromStr}; + use serde::{ + de::{Deserialize, Deserializer, Error}, + ser::SerializeSeq, + }; + + /// Serialize a sequence of sequences into sequences of strings + pub fn serialize(data: T, serializer: S) -> Result + where + S: serde::Serializer, + T: AsRef<[I]>, + I: AsRef<[U]>, + U: fmt::Display, + { + let mut seq = serializer.serialize_seq(None)?; + for inner in data.as_ref().iter() { + let rendered = inner.as_ref().iter().map(|elem| format!("{elem}")).collect::>(); + seq.serialize_element(&rendered)?; + } + seq.end() + } + + /// A single scalar, rendered to a string. Clients disagree on whether validator indices are + /// quoted (`"6418"`) or bare JSON integers (`6418`), so accept both. + struct Cell(String); + + impl<'de> Deserialize<'de> for Cell { + fn deserialize>(deserializer: D) -> Result { + struct CellVisitor; + + impl<'de> serde::de::Visitor<'de> for CellVisitor { + type Value = String; + + fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result { + f.write_str("a string or an integer") + } + + fn visit_str(self, v: &str) -> Result { + Ok(v.into()) + } + + fn visit_u64(self, v: u64) -> Result { + Ok(v.to_string()) + } + + fn visit_i64(self, v: i64) -> Result { + Ok(v.to_string()) + } + } + + deserializer.deserialize_any(CellVisitor).map(Cell) + } + } + + /// A single inner sequence. Consensus clients also disagree on how to render the gloas + /// `ptc_window` rows: some serve each as a bare list, others wrap it in an object with a single + /// field. Both carry the same data, so we accept either. + struct Row(Vec); + + impl<'de> Deserialize<'de> for Row { + fn deserialize>(deserializer: D) -> Result { + struct RowVisitor; + + impl<'de> serde::de::Visitor<'de> for RowVisitor { + type Value = Vec; + + fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result { + f.write_str("a sequence, or an object wrapping one") + } + + fn visit_seq>( + self, + mut seq: S, + ) -> Result { + let mut out = Vec::with_capacity(seq.size_hint().unwrap_or(0)); + while let Some(Cell(elem)) = seq.next_element::()? { + out.push(elem); + } + Ok(out) + } + + fn visit_map>( + self, + mut map: M, + ) -> Result { + let mut row = None; + while let Some(_key) = map.next_key::()? { + row = Some(map.next_value::>()?); + } + row.map(|cells| cells.into_iter().map(|Cell(s)| s).collect()) + .ok_or_else(|| Error::custom("empty ptc window row object")) + } + } + + deserializer.deserialize_any(RowVisitor).map(Row) + } + } + + /// Deserialize a sequence of sequences from sequences of strings + pub fn deserialize<'de, D, T, I, U>(deserializer: D) -> Result + where + D: Deserializer<'de>, + T: TryFrom>, + I: TryFrom>, + U: FromStr, + { + let raw = Vec::::deserialize(deserializer)?; + + let mut outer = Vec::with_capacity(raw.len()); + for Row(row) in raw { + let mut inner = Vec::with_capacity(row.len()); + for elem in row { + let parsed = U::from_str(&elem).map_err(|_| { + Error::custom("failure to parse element of sequence from string") + })?; + inner.push(parsed); + } + outer.push( + I::try_from(inner) + .map_err(|_| Error::custom("failure to parse inner collection"))?, + ); + } + + T::try_from(outer).map_err(|_| Error::custom("failure to parse collection")) + } +} + /// Deserializer needed to fix edge case with deserializing current_epoch_participation and /// next_epoch_participation Erigon's beacon state pub mod seq_of_u8_str_or_hex { @@ -526,6 +708,56 @@ mod test { assert_eq!(state_machine_updated, deserialized); } + // Consensus clients behind a gloas devnet endpoint render the same integer in different ways, + // so the deserializers below have to read every form we have seen from a real node. + #[derive(Deserialize, Debug, PartialEq, Eq)] + struct Quantity { + #[serde(with = "super::as_hex_quantity")] + value: u8, + } + + #[test] + fn as_hex_quantity_reads_hex_decimal_and_integer() { + let hex: Quantity = serde_json::from_str(r#"{"value":"0x2a"}"#).unwrap(); + let decimal: Quantity = serde_json::from_str(r#"{"value":"42"}"#).unwrap(); + let integer: Quantity = serde_json::from_str(r#"{"value":42}"#).unwrap(); + + assert_eq!(hex.value, 42); + assert_eq!(decimal.value, 42); + assert_eq!(integer.value, 42); + } + + #[test] + fn as_hex_quantity_rejects_out_of_range() { + // 256 does not fit in a u8, whichever way it is written. + assert!(serde_json::from_str::(r#"{"value":"0x100"}"#).is_err()); + assert!(serde_json::from_str::(r#"{"value":256}"#).is_err()); + } + + #[derive(Deserialize, Debug, PartialEq, Eq)] + struct PtcWindow { + #[serde(with = "super::seq_of_seq_of_str")] + rows: Vec>, + } + + #[test] + fn ptc_window_reads_every_client_shape() { + // bare rows of quoted strings + let strings: PtcWindow = serde_json::from_str(r#"{"rows":[["1","2"],["3"]]}"#).unwrap(); + // bare rows of unquoted integers + let integers: PtcWindow = serde_json::from_str(r#"{"rows":[[1,2],[3]]}"#).unwrap(); + // rows wrapped in an object under a named field + let wrapped: PtcWindow = serde_json::from_str( + r#"{"rows":[{"validator_indices":["1","2"]},{"validator_indices":[3]}]}"#, + ) + .unwrap(); + + let expected = PtcWindow { rows: vec![vec![1, 2], vec![3]] }; + assert_eq!(strings, expected); + assert_eq!(integers, expected); + assert_eq!(wrapped, expected); + } + // `as_utf8_string` deserializes into a fixed `[u8; 4]`. It used to `copy_from_slice` // straight from the input, which panics on any length mismatch — and it runs on // untrusted RPC input (`consensus_state_id`), so the panic aborted the node's `rpc` diff --git a/tesseract/consensus/bsc/Cargo.toml b/tesseract/consensus/bsc/Cargo.toml index 152b7b4eb..f91cfae72 100644 --- a/tesseract/consensus/bsc/Cargo.toml +++ b/tesseract/consensus/bsc/Cargo.toml @@ -27,8 +27,9 @@ base2 = "0.3.1" primitive-types = { workspace = true, features = ["impl-codec"] } alloy = { workspace = true, features = ["providers", "reqwest-default-tls"] } sp-core = { workspace = true } +ssz = { workspace = true, features = ["scale"] } +ssz_types = { workspace = true, features = ["scale"] } sp-crypto-hashing = { workspace = true } -ssz-rs = { git = "https://github.com/polytope-labs/ssz-rs", branch = "main", default-features = false } tesseract-primitives = { workspace = true } diff --git a/tesseract/consensus/bsc/src/host.rs b/tesseract/consensus/bsc/src/host.rs index 3c44ed138..fd17cb195 100644 --- a/tesseract/consensus/bsc/src/host.rs +++ b/tesseract/consensus/bsc/src/host.rs @@ -29,7 +29,8 @@ use std::{cmp::max, sync::Arc, time::Duration}; use crate::{notification::consensus_notification, BscPosHost, KeccakHasher}; use bsc_prover::get_rotation_block; -use ssz_rs::{Bitvector, Deserialize}; +use ssz::Decode as _; +use ssz_types::BitVector; use tesseract_primitives::{IsmpHost, IsmpProvider}; /// Maximum number of consensus messages bundled into a single transaction while @@ -333,12 +334,12 @@ async fn next_consensus_update( let extra_data = parse_extra::(&update.attested_header) .expect("Infallible, was parsed before update was generated"); - let validators_bit_set = Bitvector::::deserialize( + let validators_bit_set = BitVector::::from_ssz_bytes( extra_data.vote_address_set.to_le_bytes().to_vec().as_slice(), ) .expect("Infallible, was parsed before update was generated"); - if validators_bit_set.iter().as_bitslice().count_ones() < + if validators_bit_set.num_set_bits() < (2 * next_validators.validators.len() / 3) { log::trace!( @@ -434,12 +435,12 @@ async fn next_consensus_update( let extra_data = parse_extra::(&update.attested_header) .expect("Infallible, was parsed before update was generated"); - let validators_bit_set = Bitvector::::deserialize( + let validators_bit_set = BitVector::::from_ssz_bytes( extra_data.vote_address_set.to_le_bytes().to_vec().as_slice(), ) .expect("Infallible, was parsed before update was generated"); - if validators_bit_set.iter().as_bitslice().count_ones() < + if validators_bit_set.num_set_bits() < (2 * consensus_state.current_validators.len() / 3) { log::trace!( diff --git a/tesseract/consensus/sync-committee/Cargo.toml b/tesseract/consensus/sync-committee/Cargo.toml index 52712b43f..1338d4fdc 100644 --- a/tesseract/consensus/sync-committee/Cargo.toml +++ b/tesseract/consensus/sync-committee/Cargo.toml @@ -48,3 +48,9 @@ anyhow = "1.0.75" [features] testing = [] +# Selects the prover's compile-time Gloas `BeaconState` ssz layout. The verifier and ismp client +# are fork-agnostic and no longer carry this feature. +glamsterdam = [ + "sync-committee-prover/glamsterdam", + "sync-committee-primitives/glamsterdam", +] diff --git a/tesseract/consensus/sync-committee/src/lib.rs b/tesseract/consensus/sync-committee/src/lib.rs index b60456340..654ac2be7 100644 --- a/tesseract/consensus/sync-committee/src/lib.rs +++ b/tesseract/consensus/sync-committee/src/lib.rs @@ -153,7 +153,20 @@ impl, ) -> Result { + #[cfg(not(feature = "glamsterdam"))] let prover = SyncCommitteeProver::new(host.beacon_http_urls.clone()); + + // Gloas keeps only the execution block hash in the beacon state, so the prover needs an + // execution rpc of its own to fetch the header that hash commits to. + #[cfg(feature = "glamsterdam")] + let prover = SyncCommitteeProver::new( + host.beacon_http_urls.clone(), + evm.rpc_urls + .first() + .ok_or_else(|| anyhow::anyhow!("An execution rpc url is required"))? + .clone(), + ); + let el = tesseract_evm::create_provider(&evm.rpc_urls)?; let provider = Arc::new(EvmClient::new(evm.clone()).await?); diff --git a/tesseract/consensus/sync-committee/src/notification.rs b/tesseract/consensus/sync-committee/src/notification.rs index dde3af4fd..d7b13fa5d 100644 --- a/tesseract/consensus/sync-committee/src/notification.rs +++ b/tesseract/consensus/sync-committee/src/notification.rs @@ -52,7 +52,13 @@ pub async fn consensus_notification< return Ok(None); }; - if consensus_update.execution_payload.block_number <= execution_layer_height && + let Some(claimed_block_number) = consensus_update.execution_payload.claimed_block_number() + else { + trace!(target: "sync-committee-prover", "could not read an execution block number out of the update's payload proof"); + return Ok(None); + }; + + if claimed_block_number <= execution_layer_height && consensus_update.sync_committee_update.is_none() || consensus_update.attested_header.slot <= light_client_state.finalized_header.slot {