Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
2c121da
feat(validator): fetch v2 proposer duties and submit proposer prefere…
MegaRedHand Oct 26, 2026
ecba6ab
feat(validator): sign proposer preferences under the gossip rule's do…
MegaRedHand Oct 26, 2026
6b09d6a
feat(validator): publish proposer preferences for gloas proposals
MegaRedHand Oct 26, 2026
c5fbbf3
feat(validator): publish proposer preferences every slot and weigh gl…
MegaRedHand Oct 26, 2026
efaa120
test(rpc): the validator client's proposer preferences pass this node…
MegaRedHand Oct 26, 2026
8e3fd4e
docs: describe the validator client's proposer preferences
MegaRedHand Oct 26, 2026
d5db0cf
fix(validator): send proposer preferences one per request and tighten…
MegaRedHand Oct 26, 2026
ff91d4e
fix(validator): warn once per refused proposer preference
MegaRedHand Oct 26, 2026
a54c58c
fix(validator): keep refused proposer preferences quiet per node and …
MegaRedHand Oct 26, 2026
34a914f
docs: carry the keymanager rows and the README duties into gloas
MegaRedHand Oct 29, 2026
18a907c
Merge feat/beacon-builder-vc into feat/beacon-builder-preferences
MegaRedHand Nov 4, 2026
fd1ca2c
Merge feat/beacon-builder-vc into feat/beacon-builder-preferences
MegaRedHand Nov 5, 2026
0126ba1
Merge feat/beacon-builder-vc into feat/beacon-builder-preferences
MegaRedHand Nov 5, 2026
68cb7e0
fix(validator): sign proposer preferences at the proposal epoch's for…
MegaRedHand Nov 5, 2026
7edb397
fix(validator): bound preference duties and re-send until every node …
MegaRedHand Nov 5, 2026
fa0e3a0
Merge feat/beacon-builder-vc into feat/beacon-builder-preferences
MegaRedHand Nov 5, 2026
3d3220b
docs(validator): say --builder-proposals only sets the gloas boost fa…
MegaRedHand Nov 5, 2026
2cebfc0
Merge feat/beacon-builder-vc into feat/beacon-builder-preferences
MegaRedHand Nov 5, 2026
382fa78
Merge feat/beacon-builder-vc into feat/beacon-builder-preferences
MegaRedHand Nov 5, 2026
3b1abcc
Merge feat/beacon-builder-vc into feat/beacon-builder-preferences
MegaRedHand Nov 6, 2026
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
8 changes: 6 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -864,8 +864,12 @@ transitions are in `ethlambda-types`, per the section above. Nothing above
`SlotClock` picks the
offsets by each slot's own fork, and `/eth/v1/config/spec` supplies them. With
`--builder-proposals`, pre-gloas slots register validators with the node's builder
and may sign a blinded block, published to the producing node only. It
keeps EIP-3076 minimal-watermark slashing protection in
and may sign a blinded block, published to the producing node only. From the
epoch before gloas it signs proposer preferences every slot for its proposals in
the current and next epoch (`PreferencesService`, v2 duties, signed at the
proposal epoch's fork version as consensus-specs v1.7.0-beta.4 and every other
client do), unconditionally, and `produceBlockV4` carries the boost factor.
It keeps EIP-3076 minimal-watermark slashing protection in
`--validators-dir/slashing_protection.json` (`crate::slashing_protection`),
durable before every block (blinded ones included) and attestation signature;
PTC votes have an in-memory `(validator, slot)` dedup only, and envelopes are
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -338,7 +338,7 @@ endpoints.

A separate process that holds the keys and performs the duties: attestations,
block proposals, aggregation, sync committee messages and, from Gloas on, the
payload envelope and the payload timeliness vote. It talks to a beacon node over
payload envelope, proposer preferences and the payload timeliness vote. It talks to a beacon node over
the standard Beacon API, so it works against `ethlambda beacon` (run with the
`--execution-*` flags) or any other conformant beacon node.

Expand Down
8 changes: 5 additions & 3 deletions bin/ethlambda/src/validator.rs
Original file line number Diff line number Diff line change
Expand Up @@ -81,9 +81,11 @@ pub(crate) struct ValidatorOptions {

/// Propose builder blocks: register each validator with the beacon node's
/// builder and accept a blinded block when the builder's bid wins. Requires
/// a beacon node started with a builder (ethlambda: `--builder`). Fulu and
/// earlier only; from gloas the builder market works through proposer
/// preferences instead.
/// a beacon node started with a builder (ethlambda: `--builder`). The
/// registrations and blinded blocks are fulu and earlier only. From gloas
/// the builder market works through proposer preferences, which are
/// published with or without this flag; there it only sets the boost
/// factor that weighs p2p bids in `produceBlockV4`.
#[arg(long, default_value = "false")]
pub(crate) builder_proposals: bool,

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,10 @@
//! dependent root (the whole canonical case, and a dependent block about an
//! epoch old is usually evicted from the state cache), else from the cached
//! checkpoint state of the epoch before the proposal's.
//! - The signature verifies under any of three domains, since clients disagree
//! at the fork boundary: see [`proposer_preferences_domains`].
//! - The signature verifies under the proposal epoch's domain (v1.7.0-beta.4's
//! rule, and every client's) or under either of the two that beta.2's rule
//! could give, which differ from it only in the first epoch of a fork: see
//! [`proposer_preferences_domains`].

use ethlambda_storage::CacheKey;

Expand Down Expand Up @@ -208,15 +210,22 @@ pub fn dependent_root_at(
ancestor_at(state, state_block_root, dependent_slot)
}

/// The distinct candidate signing domains, tried in order: `get_domain(
/// lookahead_state, DOMAIN_PROPOSER_PREFERENCES, Some(P))` (the specification's);
/// the schedule's fork version at `P - MIN_SEED_LOOKAHEAD` (saturating); and the
/// schedule's at `P`.
/// The distinct candidate signing domains for proposal epoch `P`, tried in
/// order.
///
/// Outside the first epoch of a fork the three coincide. Across a boundary
/// the specification's gives the version of the epoch before the proposal's,
/// while lighthouse signs with the proposal epoch's, so accepting both avoids
/// rejecting an honest client's messages around the gloas upgrade.
/// First the schedule's fork version at `P`: consensus-specs v1.7.0-beta.4's
/// `compute_fork_version(proposal_epoch)`, which `get_signed_proposer_preferences`
/// signs with and `validate_proposer_preferences_gossip` verifies with, and
/// what every client (this one's validator client included) signs under.
///
/// Then the two that beta.2's rule, `get_domain(lookahead_state,
/// DOMAIN_PROPOSER_PREFERENCES, P)`, could give: `get_domain` on the state
/// this node read the lookahead from, and the schedule's version at
/// `P - MIN_SEED_LOOKAHEAD` (saturating), the epoch beta.2 advances its state
/// to (this node may have read the head's, a later one). They are kept for a
/// client still signing by beta.2, until the fixtures move past it. Outside
/// the first epoch of a fork all three coincide, so accepting the older ones
/// costs nothing.
pub fn proposer_preferences_domains(
lookahead_state: &BeaconState,
config: &Config,
Expand All @@ -231,13 +240,13 @@ pub fn proposer_preferences_domains(
)
};
let domains = [
at(proposal_epoch),
get_domain(
lookahead_state,
DOMAIN_PROPOSER_PREFERENCES,
Some(proposal_epoch),
),
at(proposal_epoch.saturating_sub(preset::MIN_SEED_LOOKAHEAD)),
at(proposal_epoch),
];
let mut distinct: Vec<Domain> = Vec::with_capacity(domains.len());
for domain in domains {
Expand Down Expand Up @@ -595,10 +604,11 @@ mod tests {
let root = state.genesis_validators_root();
let at =
|fork| compute_domain(DOMAIN_PROPOSER_PREFERENCES, config.fork_version(fork), root);
// Proposal epoch 2 is gloas's first: the lookahead state's fork (fulu)
// and the epoch before the proposal's agree, the proposal epoch's is gloas.
// Proposal epoch 2 is gloas's first. The proposal epoch's version
// (gloas, beta.4's rule) comes first; the lookahead state's fork and
// the epoch before the proposal's (beta.2's) agree on fulu.
let domains = proposer_preferences_domains(&state, &config, 2);
assert_eq!(domains, vec![at(ForkName::Fulu), at(ForkName::Gloas)]);
assert_eq!(domains, vec![at(ForkName::Gloas), at(ForkName::Fulu)]);

// A signature under either verifies; one under neither does not.
let preferences = gloas::ProposerPreferences {
Expand Down
168 changes: 163 additions & 5 deletions crates/net/rpc/src/beacon/builder_market_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,14 +18,15 @@ use ethlambda_engine::{EngineClient, JwtSecret};
use ethlambda_network_api::RpcToP2PRef;
use ethlambda_state_transition::beacon::{
block_production::advance_to_slot,
bls,
builder_market::SharedBuilderMarket,
gloas_block_production::test_support::{
config as chain_config, parent_state, post_state, randao_reveal,
},
gossip::proposer_preferences::dependent_root_at,
helpers::{
accessors::get_domain,
misc::compute_signing_root,
misc::{compute_domain, compute_signing_root},
test_state::{secret_key_for, sign_for},
},
payload_attestation_pool::SharedPayloadAttestationPool,
Expand All @@ -35,18 +36,20 @@ use ethlambda_state_transition::beacon::{
use ethlambda_storage::Store;
use ethlambda_types::{
beacon::{
config::Config,
constants::{
BUILDER_INDEX_SELF_BUILD, DOMAIN_BEACON_BUILDER, DOMAIN_PROPOSER_PREFERENCES,
FAR_FUTURE_EPOCH, PAYLOAD_BUILDER_VERSION,
},
containers::{
BeaconState, SignedBeaconBlock,
BeaconState, Fork, SignedBeaconBlock,
gloas::{
BeaconBlock, Builder, ExecutionPayloadBid, ExecutionRequests, ProposerPreferences,
SignedExecutionPayloadBid, SignedExecutionPayloadEnvelope,
SignedProposerPreferences,
},
},
fork::ForkName,
primitives::{BlsPubkey, BlsSignature, ExecutionAddress, HashTreeRoot as _},
signing::compute_epoch_at_slot,
},
Expand Down Expand Up @@ -217,6 +220,8 @@ fn chain_state() -> BeaconState {
}

struct World {
/// The fork schedule the store runs on.
config: Config,
store: Store,
state: BeaconState,
head_root: H256,
Expand All @@ -240,7 +245,33 @@ impl World {
}

fn at_clock(clock_slot: u64) -> Self {
let state = chain_state();
Self::build(chain_config(), chain_state(), clock_slot)
}

/// [`World::new`] with fulu before gloas: gloas starts at epoch 1, the one
/// [`SLOT`] and the head are in, so a preference for [`SLOT`] is for the
/// first gloas epoch. The head state carries the fork record
/// `upgrade_to_gloas` leaves at that boundary.
fn at_the_gloas_fork() -> Self {
let config = ForkName::ALL
.into_iter()
.filter(|&fork| fork < ForkName::Gloas)
.fold(chain_config(), |config, fork| {
config.with_fork_epoch(fork, 0)
})
.with_fork_epoch(ForkName::Gloas, compute_epoch_at_slot(SLOT));
let mut state = chain_state();
*state.fork_mut() = Fork {
previous_version: config.fork_version(ForkName::Fulu),
current_version: config.fork_version(ForkName::Gloas),
epoch: compute_epoch_at_slot(SLOT),
};
Self::build(config, state, HEAD_SLOT)
}

/// The head block on `state` and `config`, on a clock where `clock_slot`
/// is running.
fn build(config: Config, state: BeaconState, clock_slot: u64) -> Self {
let genesis = genesis_block();
let genesis_root = genesis.message_hash_tree_root();
let block = gloas_beacon_block(
Expand All @@ -255,14 +286,15 @@ impl World {
let head_root = header.hash_tree_root();
let mut store = beacon_store_with_head_block(
state.clone(),
chain_config(),
config.clone(),
block,
head_root,
clock_slot,
);
store.insert_signed_block(genesis_root, genesis).unwrap();
store.insert_state(genesis_root, state.clone()).unwrap();
Self {
config,
store,
state,
head_root,
Expand Down Expand Up @@ -296,7 +328,7 @@ impl World {
}

fn advanced(&self) -> BeaconState {
advance_to_slot(&self.state, SLOT, &chain_config()).unwrap()
advance_to_slot(&self.state, SLOT, &self.config).unwrap()
}

/// What `produceBlockV4` reads off the chain for [`SLOT`].
Expand Down Expand Up @@ -1084,6 +1116,132 @@ async fn valid_preferences_are_cached_and_published_once() {
assert_eq!(world.network.proposer_preferences.lock().unwrap().len(), 1);
}

/// The validator client's own path for a proposal at [`SLOT`], end to end
/// against `world`'s node: it reads the proposer duty and its v2
/// `dependent_root` from the served duties route, signs with
/// `SigningContext::sign_proposer_preferences`, and posts through
/// `HttpBeaconNode` under the fork of the preference's slot, as
/// `PreferencesService` does. The post succeeding means the route's run of the
/// gossip rules (`validate`) accepted it; this also checks that the market
/// holds it and that it went out. Returns it with the proposer's key.
async fn submit_as_the_validator_client(world: &World) -> (SignedProposerPreferences, BlsPubkey) {
use ethlambda_blockchain::{SyncStatusController, metrics::SyncStatus};
use ethlambda_validator::{
beacon_node::{BeaconNodeApi as _, dto::parse_pubkey, http::HttpBeaconNode},
keys::ValidatorStore,
signing::SigningContext,
};

let duties_routes = crate::beacon::validator::routes()
.with_state(world.store.clone())
.layer(Extension(SyncStatusController::new(SyncStatus::Synced)));
let router = world.app(None).merge(duties_routes);
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let bound = listener.local_addr().unwrap();
tokio::spawn(async move { axum::serve(listener, router).await });
let client = HttpBeaconNode::new(format!("http://{bound}")).unwrap();

// The duty and the dependent root the way the validator client reads them.
let proposal_epoch = compute_epoch_at_slot(SLOT);
let duties = client
.proposer_duties_v2(proposal_epoch)
.await
.expect("the node serves v2 proposer duties");
let duty = duties
.duties
.iter()
.find(|duty| duty.slot == SLOT)
.expect("the slot has a proposer");
let proposer = duty.validator_index;
let message = ProposerPreferences {
dependent_root: duties.dependent_root,
proposal_slot: SLOT,
validator_index: proposer,
fee_recipient: address(0xaa),
target_gas_limit: 30_000_000,
};

let mut keys = ValidatorStore::new();
let pubkey = keys
.insert_secret("test", &secret_key_for(proposer as usize).to_bytes())
.expect("the test secret is a valid key");
assert_eq!(
pubkey,
parse_pubkey(&duty.pubkey).expect("the duty names a key"),
"the test registry's key for the proposer"
);
let context = SigningContext {
config: world.config.clone(),
genesis_validators_root: world.state.genesis_validators_root(),
};
let signature = context
.sign_proposer_preferences(&keys, &pubkey, &message)
.expect("signs");
let signed = SignedProposerPreferences { message, signature };

let fork = world.config.fork_at_epoch(proposal_epoch);
client
.submit_proposer_preferences(fork, std::slice::from_ref(&signed))
.await
.expect("the node accepts what the validator client signed");

assert_eq!(
*world.network.proposer_preferences.lock().unwrap(),
vec![signed.clone()]
);
assert_eq!(
world
.market
.preferences(SLOT, signed.message.dependent_root),
Some(signed.clone())
);
(signed, pubkey)
}

/// The validator client's preference passes this node's gossip rules. That
/// pins the duties route's dependent root as the one the preference must
/// carry, and the JSON the client posts as the one the route decodes. This
/// world has gloas at epoch 0, so no fork boundary is involved; the next test
/// has one.
#[tokio::test]
async fn preferences_signed_by_the_validator_client_pass_this_nodes_gossip_rules() {
submit_as_the_validator_client(&World::new()).await;
}

/// The same across a fulu-to-gloas boundary, for a slot in the first gloas
/// epoch: the one case where the fork of the proposal's epoch (gloas, what
/// consensus-specs v1.7.0-beta.4 and every client sign and verify with) and
/// the fork of the epoch before it (fulu, beta.2's gossip rule) differ.
///
/// This node accepts both, so its verdict alone would also pass a client still
/// signing by beta.2. The signature is therefore also checked against the two
/// domains directly: it must verify under gloas's version, which Lighthouse
/// and the others demand, and not under fulu's, which Lighthouse refused.
#[tokio::test]
async fn a_first_gloas_epoch_preference_from_the_validator_client_passes_this_nodes_gossip_rules() {
let world = World::at_the_gloas_fork();
let proposal_epoch = compute_epoch_at_slot(SLOT);
assert_eq!(
world.config.fork_at_epoch(proposal_epoch - 1),
ForkName::Fulu
);
assert_eq!(world.config.fork_at_epoch(proposal_epoch), ForkName::Gloas);

let (signed, pubkey) = submit_as_the_validator_client(&world).await;

let verifies_under = |fork| {
let domain = compute_domain(
DOMAIN_PROPOSER_PREFERENCES,
world.config.fork_version(fork),
world.state.genesis_validators_root(),
);
let root = compute_signing_root(signed.message.hash_tree_root(), domain);
bls::verify(&pubkey, root, &signed.signature)
};
assert!(verifies_under(ForkName::Gloas), "the proposal epoch's fork");
assert!(!verifies_under(ForkName::Fulu), "not the epoch before it");
}

#[tokio::test]
async fn cached_preferences_resubmitted_are_a_200_without_a_publish() {
// Needs only the market: an identical entry short-circuits the rules.
Expand Down
6 changes: 4 additions & 2 deletions crates/net/rpc/src/beacon/proposer_preferences.rs
Original file line number Diff line number Diff line change
Expand Up @@ -65,8 +65,10 @@ async fn post_proposer_preferences(
headers: HeaderMap,
body: Bytes,
) -> Response {
// A validator client submits during the epoch before gloas, when the
// consensus version it names is still fulu.
// Every validator client names gloas, even during the epoch before it:
// Prysm hardcodes it, the others (this one's too) take the fork of the
// preference's slot. Fulu is still accepted, as leniency towards a client
// that names the fork of its wall clock instead.
if let Err(err) = require_version(&headers, &[ForkName::Fulu, ForkName::Gloas]) {
return err.into_response();
}
Expand Down
14 changes: 8 additions & 6 deletions crates/net/rpc/src/beacon/validator.rs
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,9 @@ use serde::{Deserialize, Serialize};

use ethlambda_network_api::RpcToP2PRef;
use ethlambda_state_transition::beacon::{
fork_choice::{checkpoint_state, get_current_store_epoch, get_head_node},
fork_choice::{
checkpoint_state, compute_shuffling_dependent_slot, get_current_store_epoch, get_head_node,
},
gossip::attestation::compute_subnet_for_attestation,
helpers::accessors::{CommitteeCacheExt as _, get_block_root_at_slot},
helpers::altair::compute_sync_committee_period,
Expand Down Expand Up @@ -535,11 +537,11 @@ impl DependentRoot {
/// definition names the genesis block where its subtraction would
/// underflow, which is slot 0.
fn slot(self, epoch: Epoch) -> Slot {
let epoch = match self {
Self::V1 => epoch,
Self::V2 => epoch.saturating_sub(1),
};
compute_start_slot_at_epoch(epoch).saturating_sub(1)
match self {
Self::V1 => compute_start_slot_at_epoch(epoch).saturating_sub(1),
// The state transition's own definition, so the two cannot drift.
Self::V2 => compute_shuffling_dependent_slot(epoch),
}
}
}

Expand Down
Loading
Loading