Add payload attestation validator duty (#9178)

Co-Authored-By: Eitan Seri-Levi <eserilev@ucsc.edu>

Co-Authored-By: Jimmy Chen <jchen.tc@gmail.com>
This commit is contained in:
Eitan Seri-Levi
2026-04-27 17:13:35 +02:00
committed by GitHub
parent 6ab48a76f0
commit 028b5a42a9
11 changed files with 618 additions and 16 deletions

View File

@@ -21,11 +21,12 @@ use tracing::{Instrument, debug, error, info, info_span, instrument, warn};
use types::{
AbstractExecPayload, Address, AggregateAndProof, Attestation, BeaconBlock, BlindedPayload,
ChainSpec, ContributionAndProof, Domain, Epoch, EthSpec, ExecutionPayloadEnvelope, Fork,
FullPayload, Graffiti, Hash256, SelectionProof, SignedAggregateAndProof, SignedBeaconBlock,
SignedContributionAndProof, SignedExecutionPayloadEnvelope, SignedRoot,
SignedValidatorRegistrationData, SignedVoluntaryExit, Slot, SyncAggregatorSelectionData,
SyncCommitteeContribution, SyncCommitteeMessage, SyncSelectionProof, SyncSubnetId,
ValidatorRegistrationData, VoluntaryExit, graffiti::GraffitiString,
FullPayload, Graffiti, Hash256, PayloadAttestationData, PayloadAttestationMessage,
SelectionProof, SignedAggregateAndProof, SignedBeaconBlock, SignedContributionAndProof,
SignedExecutionPayloadEnvelope, SignedRoot, SignedValidatorRegistrationData,
SignedVoluntaryExit, Slot, SyncAggregatorSelectionData, SyncCommitteeContribution,
SyncCommitteeMessage, SyncSelectionProof, SyncSubnetId, ValidatorRegistrationData,
VoluntaryExit, graffiti::GraffitiString,
};
use validator_store::{
AggregateToSign, AttestationToSign, ContributionToSign, DoppelgangerStatus,
@@ -1423,6 +1424,37 @@ impl<T: SlotClock + 'static, E: EthSpec> ValidatorStore for LighthouseValidatorS
})
}
async fn sign_payload_attestation(
&self,
validator_pubkey: PublicKeyBytes,
data: PayloadAttestationData,
) -> Result<PayloadAttestationMessage, Error> {
let signing_context =
self.signing_context(Domain::PTCAttester, data.slot.epoch(E::slots_per_epoch()));
let validator_index = self
.validator_index(&validator_pubkey)
.ok_or(ValidatorStoreError::UnknownPubkey(validator_pubkey))?;
let signing_method = self.doppelganger_bypassed_signing_method(validator_pubkey)?;
let signature = signing_method
.get_signature::<E, FullPayload<E>>(
SignableMessage::PayloadAttestationData(&data),
signing_context,
&self.spec,
&self.task_executor,
)
.await
.map_err(Error::SpecificError)?;
Ok(PayloadAttestationMessage {
validator_index,
data,
signature,
})
}
/// Sign an `ExecutionPayloadEnvelope` for Gloas (local building).
/// The proposer acts as the builder and signs with the BeaconBuilder domain.
async fn sign_execution_payload_envelope(

View File

@@ -50,6 +50,7 @@ pub enum SignableMessage<'a, E: EthSpec, Payload: AbstractExecPayload<E> = FullP
ValidatorRegistration(&'a ValidatorRegistrationData),
VoluntaryExit(&'a VoluntaryExit),
ExecutionPayloadEnvelope(&'a ExecutionPayloadEnvelope<E>),
PayloadAttestationData(&'a PayloadAttestationData),
}
impl<E: EthSpec, Payload: AbstractExecPayload<E>> SignableMessage<'_, E, Payload> {
@@ -72,6 +73,7 @@ impl<E: EthSpec, Payload: AbstractExecPayload<E>> SignableMessage<'_, E, Payload
SignableMessage::ValidatorRegistration(v) => v.signing_root(domain),
SignableMessage::VoluntaryExit(exit) => exit.signing_root(domain),
SignableMessage::ExecutionPayloadEnvelope(e) => e.signing_root(domain),
SignableMessage::PayloadAttestationData(d) => d.signing_root(domain),
}
}
}
@@ -238,6 +240,9 @@ impl SigningMethod {
SignableMessage::ExecutionPayloadEnvelope(e) => {
Web3SignerObject::ExecutionPayloadEnvelope(e)
}
SignableMessage::PayloadAttestationData(d) => {
Web3SignerObject::PayloadAttestationData(d)
}
};
// Determine the Web3Signer message type.

View File

@@ -21,6 +21,7 @@ pub enum MessageType {
ValidatorRegistration,
// TODO(gloas) verify w/ web3signer specs
ExecutionPayloadEnvelope,
PayloadAttestation,
}
#[derive(Debug, PartialEq, Copy, Clone, Serialize)]
@@ -78,6 +79,7 @@ pub enum Web3SignerObject<'a, E: EthSpec, Payload: AbstractExecPayload<E>> {
ContributionAndProof(&'a ContributionAndProof<E>),
ValidatorRegistration(&'a ValidatorRegistrationData),
ExecutionPayloadEnvelope(&'a ExecutionPayloadEnvelope<E>),
PayloadAttestationData(&'a PayloadAttestationData),
}
impl<'a, E: EthSpec, Payload: AbstractExecPayload<E>> Web3SignerObject<'a, E, Payload> {
@@ -144,6 +146,7 @@ impl<'a, E: EthSpec, Payload: AbstractExecPayload<E>> Web3SignerObject<'a, E, Pa
}
Web3SignerObject::ValidatorRegistration(_) => MessageType::ValidatorRegistration,
Web3SignerObject::ExecutionPayloadEnvelope(_) => MessageType::ExecutionPayloadEnvelope,
Web3SignerObject::PayloadAttestationData(_) => MessageType::PayloadAttestation,
}
}
}

View File

@@ -45,6 +45,7 @@ use validator_services::{
block_service::{BlockService, BlockServiceBuilder},
duties_service::{self, DutiesService, DutiesServiceBuilder},
latency_service,
payload_attestation_service::PayloadAttestationService,
preparation_service::{PreparationService, PreparationServiceBuilder},
sync_committee_service::SyncCommitteeService,
};
@@ -83,6 +84,7 @@ pub struct ProductionValidatorClient<E: EthSpec> {
block_service: BlockService<ValidatorStore<E>, SystemTimeSlotClock>,
attestation_service: AttestationService<ValidatorStore<E>, SystemTimeSlotClock>,
sync_committee_service: SyncCommitteeService<ValidatorStore<E>, SystemTimeSlotClock>,
payload_attestation_service: PayloadAttestationService<ValidatorStore<E>, SystemTimeSlotClock>,
doppelganger_service: Option<Arc<DoppelgangerService>>,
preparation_service: PreparationService<ValidatorStore<E>, SystemTimeSlotClock>,
validator_store: Arc<ValidatorStore<E>>,
@@ -552,12 +554,22 @@ impl<E: EthSpec> ProductionValidatorClient<E> {
context.executor.clone(),
);
let payload_attestation_service = PayloadAttestationService::new(
duties_service.clone(),
validator_store.clone(),
slot_clock.clone(),
beacon_nodes.clone(),
context.executor.clone(),
context.eth2_config.spec.clone(),
);
Ok(Self {
context,
duties_service,
block_service,
attestation_service,
sync_committee_service,
payload_attestation_service,
doppelganger_service,
preparation_service,
validator_store,
@@ -629,6 +641,13 @@ impl<E: EthSpec> ProductionValidatorClient<E> {
.start_update_service(&self.context.eth2_config.spec)
.map_err(|e| format!("Unable to start sync committee service: {}", e))?;
if self.context.eth2_config.spec.is_gloas_scheduled() {
self.payload_attestation_service
.clone()
.start_update_service()
.map_err(|e| format!("Unable to start payload attestation service: {}", e))?;
}
self.preparation_service
.clone()
.start_update_service(&self.context.eth2_config.spec)

View File

@@ -3,6 +3,7 @@ pub mod block_service;
pub mod duties_service;
pub mod latency_service;
pub mod notifier_service;
pub mod payload_attestation_service;
pub mod preparation_service;
pub mod sync;
pub mod sync_committee_service;

View File

@@ -0,0 +1,238 @@
use crate::duties_service::DutiesService;
use beacon_node_fallback::BeaconNodeFallback;
use logging::crit;
use slot_clock::SlotClock;
use std::ops::Deref;
use std::sync::Arc;
use task_executor::TaskExecutor;
use tokio::time::sleep;
use tracing::{debug, error, info};
use types::{ChainSpec, EthSpec};
use validator_store::ValidatorStore;
pub struct Inner<S, T> {
duties_service: Arc<DutiesService<S, T>>,
validator_store: Arc<S>,
slot_clock: T,
beacon_nodes: Arc<BeaconNodeFallback<T>>,
executor: TaskExecutor,
chain_spec: Arc<ChainSpec>,
}
pub struct PayloadAttestationService<S, T> {
inner: Arc<Inner<S, T>>,
}
impl<S, T> Clone for PayloadAttestationService<S, T> {
fn clone(&self) -> Self {
Self {
inner: self.inner.clone(),
}
}
}
impl<S, T> Deref for PayloadAttestationService<S, T> {
type Target = Inner<S, T>;
fn deref(&self) -> &Self::Target {
self.inner.deref()
}
}
impl<S: ValidatorStore + 'static, T: SlotClock + 'static> PayloadAttestationService<S, T> {
pub fn new(
duties_service: Arc<DutiesService<S, T>>,
validator_store: Arc<S>,
slot_clock: T,
beacon_nodes: Arc<BeaconNodeFallback<T>>,
executor: TaskExecutor,
chain_spec: Arc<ChainSpec>,
) -> Self {
Self {
inner: Arc::new(Inner {
duties_service,
validator_store,
slot_clock,
beacon_nodes,
executor,
chain_spec,
}),
}
}
pub fn start_update_service(self) -> Result<(), String> {
let slot_duration = self.chain_spec.get_slot_duration();
let payload_attestation_due = self.chain_spec.get_payload_attestation_due();
info!(
payload_attestation_due_ms = payload_attestation_due.as_millis(),
"Payload attestation service started"
);
let executor = self.executor.clone();
let interval_fut = async move {
loop {
let Some(duration_to_next_slot) = self.slot_clock.duration_to_next_slot() else {
error!("Failed to read slot clock");
sleep(slot_duration).await;
continue;
};
let Some(current_slot) = self.slot_clock.now() else {
error!("Failed to read slot clock after trigger");
continue;
};
if !self
.chain_spec
.fork_name_at_slot::<S::E>(current_slot)
.gloas_enabled()
{
let duration_to_next_epoch = self
.slot_clock
.duration_to_next_epoch(S::E::slots_per_epoch())
.unwrap_or_else(|| {
self.chain_spec.get_slot_duration() * S::E::slots_per_epoch() as u32
});
sleep(duration_to_next_epoch).await;
continue;
}
sleep(duration_to_next_slot + payload_attestation_due).await;
let service = self.clone();
self.executor.spawn(
async move {
service.produce_and_publish(current_slot).await;
},
"payload_attestation_producer",
);
}
};
executor.spawn(interval_fut, "payload_attestation_service");
Ok(())
}
async fn produce_and_publish(&self, slot: types::Slot) {
let duties = self.duties_service.get_ptc_duties_for_slot(slot);
if duties.is_empty() {
return;
}
debug!(
%slot,
duty_count = duties.len(),
"Producing payload attestations"
);
let attestation_data = match self
.beacon_nodes
.first_success(|beacon_node| async move {
beacon_node
.get_validator_payload_attestation_data(slot)
.await
.map_err(|e| format!("Failed to get payload attestation data: {e:?}"))
.map(|resp| resp.into_data())
})
.await
{
Ok(data) => data,
Err(e) => {
crit!(
error = %e,
%slot,
"Failed to produce payload attestation data"
);
return;
}
};
debug!(
%slot,
beacon_block_root = ?attestation_data.beacon_block_root,
payload_present = attestation_data.payload_present,
"Received payload attestation data"
);
let mut messages = Vec::with_capacity(duties.len());
for duty in &duties {
match self
.validator_store
.sign_payload_attestation(duty.pubkey, attestation_data.clone())
.await
{
Ok(message) => {
messages.push(message);
}
Err(e) => {
crit!(
error = ?e,
validator = ?duty.pubkey,
%slot,
"Failed to sign payload attestation"
);
}
}
}
if messages.is_empty() {
return;
}
let count = messages.len();
let fork_name = self.chain_spec.fork_name_at_slot::<S::E>(slot);
let result = self
.beacon_nodes
.first_success(|beacon_node| {
let messages = messages.clone();
async move {
beacon_node
.post_beacon_pool_payload_attestations_ssz(&messages, fork_name)
.await
.map_err(|e| format!("Failed to publish payload attestations (SSZ): {e:?}"))
}
})
.await;
let result = match result {
Ok(()) => Ok(()),
Err(_) => {
debug!(%slot, "SSZ publish failed, falling back to JSON");
self.beacon_nodes
.first_success(|beacon_node| {
let messages = messages.clone();
async move {
beacon_node
.post_beacon_pool_payload_attestations(&messages, fork_name)
.await
.map_err(|e| {
format!("Failed to publish payload attestations (JSON): {e:?}")
})
}
})
.await
}
};
match result {
Ok(()) => {
info!(
%slot,
%count,
"Successfully published payload attestations"
);
}
Err(e) => {
crit!(
error = %e,
%slot,
"Failed to publish payload attestations"
);
}
}
}
}

View File

@@ -7,10 +7,11 @@ use std::future::Future;
use std::sync::Arc;
use types::{
Address, Attestation, AttestationError, BlindedBeaconBlock, Epoch, EthSpec,
ExecutionPayloadEnvelope, Graffiti, Hash256, SelectionProof, SignedAggregateAndProof,
SignedBlindedBeaconBlock, SignedContributionAndProof, SignedExecutionPayloadEnvelope,
SignedValidatorRegistrationData, Slot, SyncCommitteeContribution, SyncCommitteeMessage,
SyncSelectionProof, SyncSubnetId, ValidatorRegistrationData,
ExecutionPayloadEnvelope, Graffiti, Hash256, PayloadAttestationData, PayloadAttestationMessage,
SelectionProof, SignedAggregateAndProof, SignedBlindedBeaconBlock, SignedContributionAndProof,
SignedExecutionPayloadEnvelope, SignedValidatorRegistrationData, Slot,
SyncCommitteeContribution, SyncCommitteeMessage, SyncSelectionProof, SyncSubnetId,
ValidatorRegistrationData,
};
#[derive(Debug, PartialEq, Clone)]
@@ -205,6 +206,13 @@ pub trait ValidatorStore: Send + Sync {
envelope: ExecutionPayloadEnvelope<Self::E>,
) -> impl Future<Output = Result<SignedExecutionPayloadEnvelope<Self::E>, Error<Self::Error>>> + Send;
/// Sign a `PayloadAttestationData` for the PTC.
fn sign_payload_attestation(
&self,
validator_pubkey: PublicKeyBytes,
data: PayloadAttestationData,
) -> impl Future<Output = Result<PayloadAttestationMessage, Error<Self::Error>>> + Send;
/// Returns `ProposalData` for the provided `pubkey` if it exists in `InitializedValidators`.
/// `ProposalData` fields include defaulting logic described in `get_fee_recipient_defaulting`,
/// `get_gas_limit_defaulting`, and `get_builder_proposals_defaulting`.