Offloading KZG Proof Computation from the beacon node (#7117)

Addresses #7108

- Add EL integration for `getPayloadV5` and `getBlobsV2`
- Offload proof computation and use proofs from EL RPC APIs
This commit is contained in:
Jimmy Chen
2025-04-08 17:37:16 +10:00
committed by GitHub
parent e924264e17
commit 759b0612b3
31 changed files with 721 additions and 476 deletions

View File

@@ -1,10 +1,11 @@
use crate::engines::ForkchoiceState;
use crate::http::{
ENGINE_FORKCHOICE_UPDATED_V1, ENGINE_FORKCHOICE_UPDATED_V2, ENGINE_FORKCHOICE_UPDATED_V3,
ENGINE_GET_BLOBS_V1, ENGINE_GET_CLIENT_VERSION_V1, ENGINE_GET_PAYLOAD_BODIES_BY_HASH_V1,
ENGINE_GET_PAYLOAD_BODIES_BY_RANGE_V1, ENGINE_GET_PAYLOAD_V1, ENGINE_GET_PAYLOAD_V2,
ENGINE_GET_PAYLOAD_V3, ENGINE_GET_PAYLOAD_V4, ENGINE_GET_PAYLOAD_V5, ENGINE_NEW_PAYLOAD_V1,
ENGINE_NEW_PAYLOAD_V2, ENGINE_NEW_PAYLOAD_V3, ENGINE_NEW_PAYLOAD_V4, ENGINE_NEW_PAYLOAD_V5,
ENGINE_GET_BLOBS_V1, ENGINE_GET_BLOBS_V2, ENGINE_GET_CLIENT_VERSION_V1,
ENGINE_GET_PAYLOAD_BODIES_BY_HASH_V1, ENGINE_GET_PAYLOAD_BODIES_BY_RANGE_V1,
ENGINE_GET_PAYLOAD_V1, ENGINE_GET_PAYLOAD_V2, ENGINE_GET_PAYLOAD_V3, ENGINE_GET_PAYLOAD_V4,
ENGINE_GET_PAYLOAD_V5, ENGINE_NEW_PAYLOAD_V1, ENGINE_NEW_PAYLOAD_V2, ENGINE_NEW_PAYLOAD_V3,
ENGINE_NEW_PAYLOAD_V4, ENGINE_NEW_PAYLOAD_V5,
};
use eth2::types::{
BlobsBundle, SsePayloadAttributes, SsePayloadAttributesV1, SsePayloadAttributesV2,
@@ -553,6 +554,7 @@ pub struct EngineCapabilities {
pub get_payload_v5: bool,
pub get_client_version_v1: bool,
pub get_blobs_v1: bool,
pub get_blobs_v2: bool,
}
impl EngineCapabilities {
@@ -609,6 +611,9 @@ impl EngineCapabilities {
if self.get_blobs_v1 {
response.push(ENGINE_GET_BLOBS_V1);
}
if self.get_blobs_v2 {
response.push(ENGINE_GET_BLOBS_V2);
}
response
}

View File

@@ -61,6 +61,7 @@ pub const ENGINE_GET_CLIENT_VERSION_V1: &str = "engine_getClientVersionV1";
pub const ENGINE_GET_CLIENT_VERSION_TIMEOUT: Duration = Duration::from_secs(1);
pub const ENGINE_GET_BLOBS_V1: &str = "engine_getBlobsV1";
pub const ENGINE_GET_BLOBS_V2: &str = "engine_getBlobsV2";
pub const ENGINE_GET_BLOBS_TIMEOUT: Duration = Duration::from_secs(1);
/// This error is returned during a `chainId` call by Geth.
@@ -87,6 +88,7 @@ pub static LIGHTHOUSE_CAPABILITIES: &[&str] = &[
ENGINE_GET_PAYLOAD_BODIES_BY_RANGE_V1,
ENGINE_GET_CLIENT_VERSION_V1,
ENGINE_GET_BLOBS_V1,
ENGINE_GET_BLOBS_V2,
];
/// We opt to initialize the JsonClientVersionV1 rather than the ClientVersionV1
@@ -708,7 +710,7 @@ impl HttpJsonRpc {
}
}
pub async fn get_blobs<E: EthSpec>(
pub async fn get_blobs_v1<E: EthSpec>(
&self,
versioned_hashes: Vec<Hash256>,
) -> Result<Vec<Option<BlobAndProofV1<E>>>, Error> {
@@ -722,6 +724,20 @@ impl HttpJsonRpc {
.await
}
pub async fn get_blobs_v2<E: EthSpec>(
&self,
versioned_hashes: Vec<Hash256>,
) -> Result<Vec<Option<BlobAndProofV2<E>>>, Error> {
let params = json!([versioned_hashes]);
self.rpc_request(
ENGINE_GET_BLOBS_V2,
params,
ENGINE_GET_BLOBS_TIMEOUT * self.execution_timeout_multiplier,
)
.await
}
pub async fn get_block_by_number(
&self,
query: BlockByNumberQuery<'_>,
@@ -963,19 +979,6 @@ impl HttpJsonRpc {
.try_into()
.map_err(Error::BadResponse)
}
// TODO(fulu): remove when v5 method is ready.
ForkName::Fulu => {
let response: JsonGetPayloadResponseV5<E> = self
.rpc_request(
ENGINE_GET_PAYLOAD_V4,
params,
ENGINE_GET_PAYLOAD_TIMEOUT * self.execution_timeout_multiplier,
)
.await?;
JsonGetPayloadResponse::V5(response)
.try_into()
.map_err(Error::BadResponse)
}
_ => Err(Error::UnsupportedForkVariant(format!(
"called get_payload_v4 with {}",
fork_name
@@ -1148,6 +1151,7 @@ impl HttpJsonRpc {
get_payload_v5: capabilities.contains(ENGINE_GET_PAYLOAD_V5),
get_client_version_v1: capabilities.contains(ENGINE_GET_CLIENT_VERSION_V1),
get_blobs_v1: capabilities.contains(ENGINE_GET_BLOBS_V1),
get_blobs_v2: capabilities.contains(ENGINE_GET_BLOBS_V2),
})
}
@@ -1320,9 +1324,8 @@ impl HttpJsonRpc {
}
}
ForkName::Fulu => {
// TODO(fulu): switch to v5 when the EL is ready
if engine_capabilities.get_payload_v4 {
self.get_payload_v4(fork_name, payload_id).await
if engine_capabilities.get_payload_v5 {
self.get_payload_v5(fork_name, payload_id).await
} else {
Err(Error::RequiredMethodUnsupported("engine_getPayloadv5"))
}

View File

@@ -717,12 +717,23 @@ impl<E: EthSpec> From<JsonBlobsBundleV1<E>> for BlobsBundle<E> {
}
}
#[superstruct(
variants(V1, V2),
variant_attributes(
derive(Debug, Clone, PartialEq, Serialize, Deserialize),
serde(bound = "E: EthSpec", rename_all = "camelCase")
)
)]
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(bound = "E: EthSpec", rename_all = "camelCase")]
pub struct BlobAndProofV1<E: EthSpec> {
pub struct BlobAndProof<E: EthSpec> {
#[serde(with = "ssz_types::serde_utils::hex_fixed_vec")]
pub blob: Blob<E>,
/// KZG proof for the blob (Deneb)
#[superstruct(only(V1))]
pub proof: KzgProof,
/// KZG cell proofs for the extended blob (PeerDAS)
#[superstruct(only(V2))]
pub proofs: KzgProofs<E>,
}
#[derive(Debug, PartialEq, Clone, Serialize, Deserialize)]

View File

@@ -4,7 +4,7 @@
//! This crate only provides useful functionality for "The Merge", it does not provide any of the
//! deposit-contract functionality that the `beacon_node/eth1` crate already provides.
use crate::json_structures::BlobAndProofV1;
use crate::json_structures::{BlobAndProofV1, BlobAndProofV2};
use crate::payload_cache::PayloadCache;
use arc_swap::ArcSwapOption;
use auth::{strip_prefix, Auth, JwtKey};
@@ -16,8 +16,8 @@ pub use engine_api::*;
pub use engine_api::{http, http::deposit_methods, http::HttpJsonRpc};
use engines::{Engine, EngineError};
pub use engines::{EngineState, ForkchoiceState};
use eth2::types::FullPayloadContents;
use eth2::types::{builder_bid::SignedBuilderBid, BlobsBundle, ForkVersionedResponse};
use eth2::types::{builder_bid::SignedBuilderBid, ForkVersionedResponse};
use eth2::types::{BlobsBundle, FullPayloadContents};
use ethers_core::types::Transaction as EthersTransaction;
use fixed_bytes::UintExtended;
use fork_choice::ForkchoiceUpdateParameters;
@@ -596,13 +596,7 @@ impl<E: EthSpec> ExecutionLayer<E> {
let (payload_ref, maybe_json_blobs_bundle) = payload_and_blobs;
let payload = payload_ref.clone_from_ref();
let maybe_blobs_bundle = maybe_json_blobs_bundle
.cloned()
.map(|blobs_bundle| BlobsBundle {
commitments: blobs_bundle.commitments,
proofs: blobs_bundle.proofs,
blobs: blobs_bundle.blobs,
});
let maybe_blobs_bundle = maybe_json_blobs_bundle.cloned();
self.inner
.payload_cache
@@ -1846,7 +1840,7 @@ impl<E: EthSpec> ExecutionLayer<E> {
}
}
pub async fn get_blobs(
pub async fn get_blobs_v1(
&self,
query: Vec<Hash256>,
) -> Result<Vec<Option<BlobAndProofV1<E>>>, Error> {
@@ -1854,7 +1848,24 @@ impl<E: EthSpec> ExecutionLayer<E> {
if capabilities.get_blobs_v1 {
self.engine()
.request(|engine| async move { engine.api.get_blobs(query).await })
.request(|engine| async move { engine.api.get_blobs_v1(query).await })
.await
.map_err(Box::new)
.map_err(Error::EngineError)
} else {
Err(Error::GetBlobsNotSupported)
}
}
pub async fn get_blobs_v2(
&self,
query: Vec<Hash256>,
) -> Result<Vec<Option<BlobAndProofV2<E>>>, Error> {
let capabilities = self.get_engine_capabilities(None).await?;
if capabilities.get_blobs_v2 {
self.engine()
.request(|engine| async move { engine.api.get_blobs_v2(query).await })
.await
.map_err(Box::new)
.map_err(Error::EngineError)

View File

@@ -20,13 +20,14 @@ use tree_hash_derive::TreeHash;
use types::{
Blob, ChainSpec, EthSpec, ExecutionBlockHash, ExecutionPayload, ExecutionPayloadBellatrix,
ExecutionPayloadCapella, ExecutionPayloadDeneb, ExecutionPayloadElectra, ExecutionPayloadFulu,
ExecutionPayloadHeader, FixedBytesExtended, ForkName, Hash256, Transaction, Transactions,
Uint256,
ExecutionPayloadHeader, FixedBytesExtended, ForkName, Hash256, KzgProofs, Transaction,
Transactions, Uint256,
};
use super::DEFAULT_TERMINAL_BLOCK;
const TEST_BLOB_BUNDLE: &[u8] = include_bytes!("fixtures/mainnet/test_blobs_bundle.ssz");
const TEST_BLOB_BUNDLE_V2: &[u8] = include_bytes!("fixtures/mainnet/test_blobs_bundle_v2.ssz");
pub const DEFAULT_GAS_LIMIT: u64 = 30_000_000;
const GAS_USED: u64 = DEFAULT_GAS_LIMIT - 1;
@@ -697,15 +698,13 @@ impl<E: EthSpec> ExecutionBlockGenerator<E> {
},
};
if execution_payload.fork_name().deneb_enabled() {
let fork_name = execution_payload.fork_name();
if fork_name.deneb_enabled() {
// get random number between 0 and Max Blobs
let mut rng = self.rng.lock();
let max_blobs = self
.spec
.max_blobs_per_block_by_fork(execution_payload.fork_name())
as usize;
let max_blobs = self.spec.max_blobs_per_block_by_fork(fork_name) as usize;
let num_blobs = rng.gen::<usize>() % (max_blobs + 1);
let (bundle, transactions) = generate_blobs(num_blobs)?;
let (bundle, transactions) = generate_blobs(num_blobs, fork_name)?;
for tx in Vec::from(transactions) {
execution_payload
.transactions_mut()
@@ -721,7 +720,8 @@ impl<E: EthSpec> ExecutionBlockGenerator<E> {
}
}
pub fn load_test_blobs_bundle<E: EthSpec>() -> Result<(KzgCommitment, KzgProof, Blob<E>), String> {
pub fn load_test_blobs_bundle_v1<E: EthSpec>() -> Result<(KzgCommitment, KzgProof, Blob<E>), String>
{
let BlobsBundle::<E> {
commitments,
proofs,
@@ -745,32 +745,56 @@ pub fn load_test_blobs_bundle<E: EthSpec>() -> Result<(KzgCommitment, KzgProof,
))
}
pub fn load_test_blobs_bundle_v2<E: EthSpec>(
) -> Result<(KzgCommitment, KzgProofs<E>, Blob<E>), String> {
let BlobsBundle::<E> {
commitments,
proofs,
blobs,
} = BlobsBundle::from_ssz_bytes(TEST_BLOB_BUNDLE_V2)
.map_err(|e| format!("Unable to decode ssz: {:?}", e))?;
Ok((
commitments
.first()
.cloned()
.ok_or("commitment missing in test bundle")?,
// there's only one blob in the test bundle, hence we take all the cell proofs here.
proofs,
blobs
.first()
.cloned()
.ok_or("blob missing in test bundle")?,
))
}
pub fn generate_blobs<E: EthSpec>(
n_blobs: usize,
fork_name: ForkName,
) -> Result<(BlobsBundle<E>, Transactions<E>), String> {
let (kzg_commitment, kzg_proof, blob) = load_test_blobs_bundle::<E>()?;
let tx = static_valid_tx::<E>()
.map_err(|e| format!("error creating valid tx SSZ bytes: {:?}", e))?;
let transactions = vec![tx; n_blobs];
let mut bundle = BlobsBundle::<E>::default();
let mut transactions = vec![];
for blob_index in 0..n_blobs {
let tx = static_valid_tx::<E>()
.map_err(|e| format!("error creating valid tx SSZ bytes: {:?}", e))?;
transactions.push(tx);
bundle
.blobs
.push(blob.clone())
.map_err(|_| format!("blobs are full, blob index: {:?}", blob_index))?;
bundle
.commitments
.push(kzg_commitment)
.map_err(|_| format!("blobs are full, blob index: {:?}", blob_index))?;
bundle
.proofs
.push(kzg_proof)
.map_err(|_| format!("blobs are full, blob index: {:?}", blob_index))?;
}
let bundle = if fork_name.fulu_enabled() {
let (kzg_commitment, kzg_proofs, blob) = load_test_blobs_bundle_v2::<E>()?;
BlobsBundle {
commitments: vec![kzg_commitment; n_blobs].into(),
proofs: vec![kzg_proofs.to_vec(); n_blobs]
.into_iter()
.flatten()
.collect::<Vec<_>>()
.into(),
blobs: vec![blob; n_blobs].into(),
}
} else {
let (kzg_commitment, kzg_proof, blob) = load_test_blobs_bundle_v1::<E>()?;
BlobsBundle {
commitments: vec![kzg_commitment; n_blobs].into(),
proofs: vec![kzg_proof; n_blobs].into(),
blobs: vec![blob; n_blobs].into(),
}
};
Ok((bundle, transactions.into()))
}
@@ -905,7 +929,7 @@ pub fn generate_pow_block(
#[cfg(test)]
mod test {
use super::*;
use kzg::{trusted_setup::get_trusted_setup, TrustedSetup};
use kzg::{trusted_setup::get_trusted_setup, Bytes48, CellRef, KzgBlobRef, TrustedSetup};
use types::{MainnetEthSpec, MinimalEthSpec};
#[test]
@@ -974,20 +998,28 @@ mod test {
}
#[test]
fn valid_test_blobs() {
fn valid_test_blobs_bundle_v1() {
assert!(
validate_blob::<MainnetEthSpec>().is_ok(),
validate_blob_bundle_v1::<MainnetEthSpec>().is_ok(),
"Mainnet preset test blobs bundle should contain valid proofs"
);
assert!(
validate_blob::<MinimalEthSpec>().is_ok(),
validate_blob_bundle_v1::<MinimalEthSpec>().is_ok(),
"Minimal preset test blobs bundle should contain valid proofs"
);
}
fn validate_blob<E: EthSpec>() -> Result<(), String> {
#[test]
fn valid_test_blobs_bundle_v2() {
validate_blob_bundle_v2::<MainnetEthSpec>()
.expect("Mainnet preset test blobs bundle v2 should contain valid proofs");
validate_blob_bundle_v2::<MinimalEthSpec>()
.expect("Minimal preset test blobs bundle v2 should contain valid proofs");
}
fn validate_blob_bundle_v1<E: EthSpec>() -> Result<(), String> {
let kzg = load_kzg()?;
let (kzg_commitment, kzg_proof, blob) = load_test_blobs_bundle::<E>()?;
let (kzg_commitment, kzg_proof, blob) = load_test_blobs_bundle_v1::<E>()?;
let kzg_blob = kzg::Blob::from_bytes(blob.as_ref())
.map(Box::new)
.map_err(|e| format!("Error converting blob to kzg blob: {e:?}"))?;
@@ -995,6 +1027,26 @@ mod test {
.map_err(|e| format!("Invalid blobs bundle: {e:?}"))
}
fn validate_blob_bundle_v2<E: EthSpec>() -> Result<(), String> {
let kzg = load_kzg()?;
let (kzg_commitments, kzg_proofs, cells) =
load_test_blobs_bundle_v2::<E>().map(|(commitment, proofs, blob)| {
let kzg_blob: KzgBlobRef = blob.as_ref().try_into().unwrap();
(
vec![Bytes48::from(commitment); proofs.len()],
proofs.into_iter().map(|p| p.into()).collect::<Vec<_>>(),
kzg.compute_cells(kzg_blob).unwrap(),
)
})?;
let (cell_indices, cell_refs): (Vec<u64>, Vec<CellRef>) = cells
.iter()
.enumerate()
.map(|(cell_idx, cell)| (cell_idx as u64, CellRef::try_from(cell.as_ref()).unwrap()))
.unzip();
kzg.verify_cell_proof_batch(&cell_refs, &kzg_proofs, cell_indices, &kzg_commitments)
.map_err(|e| format!("Invalid blobs bundle: {e:?}"))
}
fn load_kzg() -> Result<Kzg, String> {
let trusted_setup: TrustedSetup =
serde_json::from_reader(get_trusted_setup().as_slice())

View File

@@ -383,9 +383,8 @@ pub async fn handle_rpc<E: EthSpec>(
== ForkName::Fulu
&& (method == ENGINE_GET_PAYLOAD_V1
|| method == ENGINE_GET_PAYLOAD_V2
|| method == ENGINE_GET_PAYLOAD_V3)
// TODO(fulu): Uncomment this once v5 method is ready for Fulu
// || method == ENGINE_GET_PAYLOAD_V4)
|| method == ENGINE_GET_PAYLOAD_V3
|| method == ENGINE_GET_PAYLOAD_V4)
{
return Err((
format!("{} called after Fulu fork!", method),
@@ -451,22 +450,6 @@ pub async fn handle_rpc<E: EthSpec>(
})
.unwrap()
}
// TODO(fulu): remove this once we switch to v5 method
JsonExecutionPayload::V5(execution_payload) => {
serde_json::to_value(JsonGetPayloadResponseV5 {
execution_payload,
block_value: Uint256::from(DEFAULT_MOCK_EL_PAYLOAD_VALUE_WEI),
blobs_bundle: maybe_blobs
.ok_or((
"No blobs returned despite V5 Payload".to_string(),
GENERIC_ERROR_CODE,
))?
.into(),
should_override_builder: false,
execution_requests: Default::default(),
})
.unwrap()
}
_ => unreachable!(),
}),
ENGINE_GET_PAYLOAD_V5 => Ok(match JsonExecutionPayload::from(response) {

View File

@@ -546,7 +546,7 @@ impl<E: EthSpec> MockBuilder<E> {
.map_err(|_| "incorrect payload variant".to_string())?
.into(),
blob_kzg_commitments: maybe_blobs_bundle
.map(|b| b.commitments)
.map(|b| b.commitments.clone())
.unwrap_or_default(),
value: self.get_bid_value(value),
pubkey: self.builder_sk.public_key().compress(),
@@ -558,7 +558,7 @@ impl<E: EthSpec> MockBuilder<E> {
.map_err(|_| "incorrect payload variant".to_string())?
.into(),
blob_kzg_commitments: maybe_blobs_bundle
.map(|b| b.commitments)
.map(|b| b.commitments.clone())
.unwrap_or_default(),
value: self.get_bid_value(value),
pubkey: self.builder_sk.public_key().compress(),
@@ -570,7 +570,7 @@ impl<E: EthSpec> MockBuilder<E> {
.map_err(|_| "incorrect payload variant".to_string())?
.into(),
blob_kzg_commitments: maybe_blobs_bundle
.map(|b| b.commitments)
.map(|b| b.commitments.clone())
.unwrap_or_default(),
value: self.get_bid_value(value),
pubkey: self.builder_sk.public_key().compress(),

View File

@@ -58,6 +58,7 @@ pub const DEFAULT_ENGINE_CAPABILITIES: EngineCapabilities = EngineCapabilities {
get_payload_v5: true,
get_client_version_v1: true,
get_blobs_v1: true,
get_blobs_v2: true,
};
pub static DEFAULT_CLIENT_VERSION: LazyLock<JsonClientVersionV1> =