Skip to content
Draft
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
245 changes: 205 additions & 40 deletions Cargo.lock

Large diffs are not rendered by default.

37 changes: 20 additions & 17 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -73,23 +73,26 @@ ssv_types = { path = "anchor/common/ssv_types" }
subnet_service = { path = "anchor/subnet_service" }
version = { path = "anchor/common/version" }

# Lighthouse latest from unstable (e58ec88fe)
beacon_node_fallback = { git = "https://github.com/sigp/lighthouse", rev = "e58ec88fe" }
bls = { git = "https://github.com/sigp/lighthouse", rev = "e58ec88fe" }
eth2 = { git = "https://github.com/sigp/lighthouse", rev = "e58ec88fe" }
eth2_keystore = { git = "https://github.com/sigp/lighthouse", rev = "e58ec88fe" }
eth2_network_config = { git = "https://github.com/sigp/lighthouse", rev = "e58ec88fe" }
health_metrics = { git = "https://github.com/sigp/lighthouse", rev = "e58ec88fe" }
metrics = { git = "https://github.com/sigp/lighthouse", rev = "e58ec88fe" }
network_utils = { git = "https://github.com/sigp/lighthouse", rev = "e58ec88fe" }
slashing_protection = { git = "https://github.com/sigp/lighthouse", rev = "e58ec88fe" }
slot_clock = { git = "https://github.com/sigp/lighthouse", rev = "e58ec88fe" }
task_executor = { git = "https://github.com/sigp/lighthouse", rev = "e58ec88fe" }
types = { git = "https://github.com/sigp/lighthouse", rev = "e58ec88fe" }
validator_metrics = { git = "https://github.com/sigp/lighthouse", rev = "e58ec88fe" }
validator_services = { git = "https://github.com/sigp/lighthouse", rev = "e58ec88fe" }
validator_store = { git = "https://github.com/sigp/lighthouse", rev = "e58ec88fe" }
workspace_members = { git = "https://github.com/sigp/lighthouse", rev = "e58ec88fe" }
# Lighthouse Gloas builder-API stack head, sigp/lighthouse#9807 (44f442479); re-pin to
# unstable once the #9805-#9807 stack merges
beacon_node_fallback = { git = "https://github.com/sigp/lighthouse", rev = "44f442479d628ee4dcdcbd53db04791ec368057c" }
bls = { git = "https://github.com/sigp/lighthouse", rev = "44f442479d628ee4dcdcbd53db04791ec368057c" }
builder_store = { git = "https://github.com/sigp/lighthouse", rev = "44f442479d628ee4dcdcbd53db04791ec368057c" }
builder_types = { git = "https://github.com/sigp/lighthouse", rev = "44f442479d628ee4dcdcbd53db04791ec368057c" }
eth2 = { git = "https://github.com/sigp/lighthouse", rev = "44f442479d628ee4dcdcbd53db04791ec368057c" }
eth2_keystore = { git = "https://github.com/sigp/lighthouse", rev = "44f442479d628ee4dcdcbd53db04791ec368057c" }
eth2_network_config = { git = "https://github.com/sigp/lighthouse", rev = "44f442479d628ee4dcdcbd53db04791ec368057c" }
health_metrics = { git = "https://github.com/sigp/lighthouse", rev = "44f442479d628ee4dcdcbd53db04791ec368057c" }
metrics = { git = "https://github.com/sigp/lighthouse", rev = "44f442479d628ee4dcdcbd53db04791ec368057c" }
network_utils = { git = "https://github.com/sigp/lighthouse", rev = "44f442479d628ee4dcdcbd53db04791ec368057c" }
slashing_protection = { git = "https://github.com/sigp/lighthouse", rev = "44f442479d628ee4dcdcbd53db04791ec368057c" }
slot_clock = { git = "https://github.com/sigp/lighthouse", rev = "44f442479d628ee4dcdcbd53db04791ec368057c" }
task_executor = { git = "https://github.com/sigp/lighthouse", rev = "44f442479d628ee4dcdcbd53db04791ec368057c" }
types = { git = "https://github.com/sigp/lighthouse", rev = "44f442479d628ee4dcdcbd53db04791ec368057c" }
validator_metrics = { git = "https://github.com/sigp/lighthouse", rev = "44f442479d628ee4dcdcbd53db04791ec368057c" }
validator_services = { git = "https://github.com/sigp/lighthouse", rev = "44f442479d628ee4dcdcbd53db04791ec368057c" }
validator_store = { git = "https://github.com/sigp/lighthouse", rev = "44f442479d628ee4dcdcbd53db04791ec368057c" }
workspace_members = { git = "https://github.com/sigp/lighthouse", rev = "44f442479d628ee4dcdcbd53db04791ec368057c" }

alloy = { version = "1.2.1", features = [
"sol-types",
Expand Down
1 change: 1 addition & 0 deletions anchor/client/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ path = "src/lib.rs"
[dependencies]
anchor_validator_store = { workspace = true }
beacon_node_fallback = { workspace = true }
builder_store = { workspace = true }
clap = { workspace = true }
cli = { workspace = true }
database = { workspace = true }
Expand Down
16 changes: 15 additions & 1 deletion anchor/client/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ use beacon_node_fallback::{
BeaconNodeFallback, CandidateBeaconNode, beacon_head_monitor::HeadEvent,
start_fallback_updater_service,
};
use builder_store::BuilderStore;
use config::Config;
use database::{NetworkDatabase, OwnOperatorId};
use duties_tracker::{duties_tracker::DutiesTracker, voluntary_exit_tracker::VoluntaryExitTracker};
Expand Down Expand Up @@ -64,6 +65,7 @@ use validator_services::{
payload_attestation_service::PayloadAttestationService,
preparation_service::PreparationServiceBuilder,
proposer_preferences_service::ProposerPreferencesService,
request_auth_cache::RequestAuthCache,
sync_committee_service::SyncCommitteeService,
};

Expand Down Expand Up @@ -721,12 +723,24 @@ impl Client {
.await?;
}

// `BlockServiceBuilder::build()` requires both a `BuilderStore` and a `RequestAuthCache`
// at the new Lighthouse pin. The builder definitions file starts empty, so both are inert
// today: with no builders configured, nothing ever inserts into the cache. #1279 adds
// Anchor's config surface on top, and #1280 wires the builder-preferences service, which
// will share these instances (via clones here) and is the cache's only `prune()` caller;
// enabling builders before that prune caller exists would make the cache insert-only.
let configured_builders =
BuilderStore::open_or_create(config.global_config.data_dir.builder_definitions_dir())
.map_err(|e| format!("Unable to open or create builder definitions: {e:?}"))?;

let mut block_service_builder = BlockServiceBuilder::new()
.slot_clock(slot_clock.clone())
.validator_store(validator_store.clone())
.beacon_nodes(beacon_nodes.clone())
.executor(executor.clone())
.chain_spec(spec.clone());
.chain_spec(spec.clone())
.configured_builders(configured_builders)
.request_auth_cache(RequestAuthCache::default());

// If we have proposer nodes, add them to the block service builder.
if proposer_nodes.num_total().await > 0 {
Expand Down
7 changes: 7 additions & 0 deletions anchor/common/global_config/src/data_dir.rs
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,13 @@ impl DataDir {
pub fn default_logs_dir(&self) -> PathBuf {
self.path.join("logs")
}

/// Directory holding `builder_definitions.yml`, created on first use by Lighthouse's
/// `BuilderStore`. The datadir root, mirroring Lighthouse's placement of the file directly
/// in its validators dir.
pub fn builder_definitions_dir(&self) -> PathBuf {
self.path.clone()
}
}

impl Display for DataDir {
Expand Down
1 change: 1 addition & 0 deletions anchor/validator_store/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ authors = ["Sigma Prime <contact@sigmaprime.io>"]
[dependencies]
beacon_node_fallback = { workspace = true }
bls = { workspace = true }
builder_types = { workspace = true }
database = { workspace = true }
eth2 = { workspace = true }
ethereum_ssz = { workspace = true }
Expand Down
216 changes: 212 additions & 4 deletions anchor/validator_store/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ use std::{
};

use bls::{AggregateSignature, PublicKeyBytes, SecretKey, Signature};
use builder_types::{RequestAuth, SignedRequestAuth};
use database::{NetworkDatabase, NonUniqueIndex, UniqueIndex};
use eth2::types::{BlockContents, BlockContentsTuple, FullBlockContents, PublishBlockRequest};
use fork::{Fork, ForkSchedule};
Expand Down Expand Up @@ -123,6 +124,29 @@ const SYNC_COMMITTEE_CONTRIBUTION_LOG_NAME: &str = "sync committee contribution"
/// to be reconstructed.
const PROPOSER_PREFERENCES_COLLECTION_TIMEOUT_SLOTS: u32 = 2;

/// Upper bound, in slots, on how long `sign_request_auth_v1` waits when the proposal slot is in
/// the future. Matches [`PROPOSER_PREFERENCES_COLLECTION_TIMEOUT_SLOTS`] and its rationale: the
/// Lighthouse builder-preferences service awaits each proposer sequentially in a per-slot loop,
/// so an unbounded no-quorum wait would head-of-line-block every later proposer.
const REQUEST_AUTH_COLLECTION_TIMEOUT_SLOTS: u32 = 2;

/// Upper bound on how long `sign_request_auth_v1` waits when the proposal slot is the current
/// slot. This is the block-production path: Lighthouse resolves the whole builder config before
/// requesting a block, so waiting longer costs the proposal itself, while failing fast merely
/// omits the unsignable builder and lets the proposal proceed with a local payload. Quorum here
/// is also unlikely on a cold cache: peers broadcast their partial signatures once, at
/// duty-discovery time, and their request-auth cache suppresses re-signing.
const REQUEST_AUTH_PROPOSAL_SLOT_TIMEOUT: Duration = Duration::from_secs(1);

/// Whether and how to bound a request-auth signature collection; see
/// `request_auth_collection_bound` for the slot-aware policy.
enum RequestAuthCollectionBound {
/// Run the collection, capped at the given duration.
Bounded(Duration),
/// The proposal slot has already passed: do not start a collection at all.
DeclinePastSlot,
}

/// A request to collect a committee signature for a single validator.
///
/// The shared fields (`validator`, `signing_root`) drive `collect_prepared_signatures`,
Expand Down Expand Up @@ -1360,6 +1384,87 @@ impl<T: SlotClock, E: EthSpec, C: ConsensusDecider<E> + 'static> AnchorValidator
}
}

/// Slot-aware bound for a request-auth signature collection.
///
/// - Future proposal slot (cache-warming via the builder-preferences service): allow
/// [`REQUEST_AUTH_COLLECTION_TIMEOUT_SLOTS`] for peers to sign as their own per-slot loops
/// reach the same proposer.
/// - Current slot (block-production path): fail fast within
/// [`REQUEST_AUTH_PROPOSAL_SLOT_TIMEOUT`] so the proposal proceeds with a local payload.
/// - Past slot: decline, so the collection future is never constructed and no partial
/// signature is broadcast. This is a steady-state path, not just a restart edge: the
/// Lighthouse builder-preferences service revisits every current-epoch proposer on every
/// slot tick (its published-entry dedup runs after signing) while its request-auth cache
/// prunes elapsed slots each tick, so every elapsed proposal slot re-misses the cache each
/// slot for the rest of its epoch. Declines are expected behavior and are kept out of the
/// failure reporter so they cannot pollute the divergence metric.
fn request_auth_collection_bound(
&self,
proposal_slot: Slot,
) -> Result<RequestAuthCollectionBound, Error> {
let current_slot = self.slot_clock.now().ok_or(SpecificError::SlotClock)?;
Ok(if proposal_slot > current_slot {
RequestAuthCollectionBound::Bounded(
self.spec.get_slot_duration() * REQUEST_AUTH_COLLECTION_TIMEOUT_SLOTS,
)
} else if proposal_slot == current_slot {
RequestAuthCollectionBound::Bounded(REQUEST_AUTH_PROPOSAL_SLOT_TIMEOUT)
} else {
RequestAuthCollectionBound::DeclinePastSlot
})
}

/// Classify and report a RequestAuth signature-collection failure.
///
/// `request_auth.data` is opaque builder authentication material agreed out of band; it is
/// logged only by length, never raw.
fn report_request_auth_collection_failure(
&self,
error: &Error,
validator_pubkey: &PublicKeyBytes,
request_auth: &RequestAuth,
signing_root: Hash256,
) {
match instrumentation::classify_collection_failure(error) {
CollectionFailureClass::NoSignature => {
warn!(
?validator_pubkey,
proposal_slot = %request_auth.slot,
auth_data_len = request_auth.data.len(),
?signing_root,
?error,
"Insufficient partial signatures to reconstruct SignedRequestAuth; possible \
causes: too few operators reached the threshold, partial-signature delivery \
loss, or operators diverged on the builder auth data"
);
metrics::inc_counter_vec(
&metrics::REQUEST_AUTH_RECONSTRUCTION_FAILURES,
&[metrics::REQUEST_AUTH_FAILURE_INSUFFICIENT_PARTIAL_SIGNATURES],
);
}
CollectionFailureClass::Infra => {
error!(
?validator_pubkey,
proposal_slot = %request_auth.slot,
?error,
"RequestAuth signature collection infrastructure failure"
);
metrics::inc_counter_vec(
&metrics::REQUEST_AUTH_RECONSTRUCTION_FAILURES,
&[metrics::REQUEST_AUTH_FAILURE_INFRA],
);
}
CollectionFailureClass::NonCollection => {
error!(
?validator_pubkey,
proposal_slot = %request_auth.slot,
?error,
"Failed to sign RequestAuth"
);
}
}
}

fn create_proposer_consensus_data_validator(
&self,
validator_pubkey: PublicKeyBytes,
Expand Down Expand Up @@ -4080,6 +4185,26 @@ impl<T: SlotClock, E: EthSpec, C: ConsensusDecider<E> + 'static> ValidatorStore
) -> Result<SignedProposerPreferences, Error> {
let (validator, cluster) = self.get_validator_and_cluster(validator_pubkey)?;

// Decline elapsed proposal slots, mirroring the request-auth decline below: after a
// restart the Lighthouse proposer-preferences service re-signs every unpublished
// current-epoch duty, including slots that have already passed (its
// `preferences_to_publish` filters on the published set only, which a restart empties).
// Quorum for those is unreachable (peers broadcast once and will not re-sign), so
// waiting the bounded window per elapsed duty head-of-line-blocks the sequential loop
// and each failure would pollute the divergence metric. Same outcome as a timeout for
// the caller, minus the wait, the broadcast, and the reporter.
let current_slot = self.slot_clock.now().ok_or(SpecificError::SlotClock)?;
if preferences.proposal_slot < current_slot {
debug!(
validator_index = preferences.validator_index,
proposal_slot = %preferences.proposal_slot,
"Declining proposer preferences signing for an elapsed proposal slot"
);
return Err(Error::SpecificError(
SpecificError::SignatureCollectionFailed(CollectionError::CollectionTimeout),
));
}

let epoch = preferences.proposal_slot.epoch(E::slots_per_epoch());
let domain = self.get_domain(epoch, Domain::ProposerPreferences);
let signing_root = preferences.signing_root(domain);
Expand Down Expand Up @@ -4131,6 +4256,78 @@ impl<T: SlotClock, E: EthSpec, C: ConsensusDecider<E> + 'static> ValidatorStore
signature,
})
}

async fn sign_request_auth_v1(
&self,
validator_pubkey: PublicKeyBytes,
request_auth_v1: RequestAuth,
) -> Result<SignedRequestAuth, Error> {
let (validator, cluster) = self.get_validator_and_cluster(validator_pubkey)?;

// Fixed application domain (builder-specs #165): genesis fork version, zeroed
// genesis_validators_root. Unlike `Domain::ProposerPreferences` it never varies across
// fork boundaries, so there is no epoch-keyed `get_domain` call here.
let domain_hash = self.spec.get_request_auth_domain();
let signing_root = request_auth_v1.signing_root(domain_hash);

// Envelope slot = the proposal slot the auth is for, as for ProposerPreferences: it
// becomes `PartialSignatureMessages.slot` on the wire, peers validate proposer assignment
// against it, and it keys the collector's lifetime. The slot is also tree-hashed into
// `signing_root` (as it is for ProposerPreferences), so a given root always carries the
// same slot.
let proposal_slot = request_auth_v1.slot;

let bound = match self.request_auth_collection_bound(proposal_slot)? {
RequestAuthCollectionBound::Bounded(bound) => bound,
RequestAuthCollectionBound::DeclinePastSlot => {
debug!(
?validator_pubkey,
proposal_slot = %request_auth_v1.slot,
"Declining request auth signing for an elapsed proposal slot"
);
return Err(Error::SpecificError(
SpecificError::SignatureCollectionFailed(CollectionError::CollectionTimeout),
));
}
};

let collected = Self::collect_within(
bound,
self.collect_signature(
PartialSignatureKind::RequestAuth,
Role::ProposerPreferences,
CollectionMode::SingleValidator,
&validator,
&cluster,
signing_root,
proposal_slot,
),
)
.await;

let signature = match collected {
Ok(signature) => signature,
Err(err) => {
self.report_request_auth_collection_failure(
&err,
&validator_pubkey,
&request_auth_v1,
signing_root,
);
return Err(err);
}
};

validator_metrics::inc_counter_vec(
&metrics::SIGNED_REQUEST_AUTH_TOTAL,
&[validator_metrics::SUCCESS],
);

Ok(SignedRequestAuth {
message: request_auth_v1,
signature,
})
}
}

struct PublishDecision<E: EthSpec> {
Expand Down Expand Up @@ -4713,8 +4910,8 @@ mod tests {
}
}

/// Anchors that `get_attestation_due` flips from `unaggregated_attestation_due`
/// to `unaggregated_attestation_due_gloas` at the Gloas activation boundary.
/// Anchors that `get_attestation_due` flips from the pre-Gloas attestation deadline to the
/// shorter Gloas one at the Gloas activation boundary.
/// A LH bump that changes the fork-gating logic will surface here.
#[test]
fn attestation_due_switches_at_gloas_boundary() {
Expand All @@ -4730,8 +4927,19 @@ mod tests {
let post = spec.get_attestation_due::<MainnetEthSpec>(first_gloas_slot);

assert_ne!(pre, post);
assert_eq!(pre, spec.unaggregated_attestation_due);
assert_eq!(post, spec.unaggregated_attestation_due_gloas);
// Recompute each era's expected deadline from the public basis-point inputs (the derived
// duration fields are private): each value is its bps share of the slot duration, which
// pins the polarity of the flip, not just that a flip happened.
assert_eq!(
pre,
spec.compute_slot_component_duration(spec.attestation_due_bps)
.unwrap()
);
assert_eq!(
post,
spec.compute_slot_component_duration(spec.attestation_due_bps_gloas)
.unwrap()
);
}

// ==================== SlotVote accessor tests ====================
Expand Down
Loading
Loading