Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
32 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
3641695
Merge feat/beacon-builder-vc into feat/beacon-builder-preferences
MegaRedHand Nov 11, 2026
9fed8e4
fix(validator): re-send proposer preferences once an epoch, all at once
MegaRedHand Nov 12, 2026
af7caeb
feat(validator): --gas-limit for the default target gas limit
MegaRedHand Nov 12, 2026
320eb7b
feat(validator): --builder-min-bid for gloas's p2p bid floor
MegaRedHand Nov 12, 2026
027c7ad
Merge feat/beacon-builder-vc into feat/beacon-builder-preferences
MegaRedHand Nov 13, 2026
e7decb6
fix(validator): refuse a zero keymanager gas limit; name the 200M net…
MegaRedHand Nov 13, 2026
ebc5a76
docs(validator): a gloas builder bid is signed without --builder-prop…
MegaRedHand Nov 13, 2026
86d3b17
fix(validator,rpc): proposer preference follow-ups from the third review
MegaRedHand Nov 13, 2026
e3faee8
fix(validator): start the preferences' re-send round a slot early
MegaRedHand Nov 13, 2026
dd1580b
fix(validator): take --builder-min-bid without --builder-proposals
MegaRedHand Nov 13, 2026
ac35921
Merge feat/beacon-builder-vc into feat/beacon-builder-preferences
MegaRedHand Nov 13, 2026
bb06703
test(rpc): a first-gloas-epoch preference on a fulu head; doc follow-ups
MegaRedHand Nov 13, 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
11 changes: 9 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -864,8 +864,15 @@ 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, re-sending each kept one once an epoch (a node
holds them in memory only), and `produceBlockV4` carries the boost factor and
`--builder-min-bid`. `--gas-limit` sets the target gas limit the preferences
carry, which the node's own gloas payload is built toward.
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
10 changes: 6 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -338,9 +338,10 @@ 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
the standard Beacon API, so it works against `ethlambda beacon` (run with the
`--execution-*` flags) or any other conformant beacon node.
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.

> **Slashing protection:** every block and attestation is checked against a
> durable per-validator record in `--validators-dir` before it is signed, and a
Expand Down Expand Up @@ -369,7 +370,8 @@ the standard Beacon API, so it works against `ethlambda beacon` (run with the
| `--secrets-dir` | One password file per keystore, named after the validator's public key |
| `--suggested-fee-recipient` | Execution address for block rewards, `0x`-prefixed. Optional, but without it the beacon node picks an address that is not yours |
| `--graffiti` | Text for proposed blocks, at most 32 bytes |
| `--builder-proposals` | Propose builder blocks before gloas: register each validator with the beacon node's builder (`--builder` on an ethlambda node) and sign a blinded block when the builder's bid wins. Off by default |
| `--gas-limit` | The gas limit validators target (default `60000000`). From gloas the beacon node builds this client's own payloads toward it, so set it on a network whose limit is elsewhere. Sepolia and Platåberget schedule `200000000`: pass `--gas-limit 200000000` there (until [#795](https://github.com/lambdaclass/ethlambda/issues/795)) |
| `--builder-proposals` | Propose builder blocks before gloas: register each validator with the beacon node's builder (`--builder` on an ethlambda node) and sign a blinded block when the builder's bid wins. Off by default. From gloas, with or without it, a node that cannot build the payload itself may build on a builder's p2p bid, and that block is signed |
| `--enable-keymanager` | Serve the keymanager API on `--keymanager-port` (default `5062`). Off by default, since it mutates key material |

Metrics are served on `--metrics-port` (default `5064`). See
Expand Down
99 changes: 94 additions & 5 deletions bin/ethlambda/src/validator.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,9 @@
use std::path::PathBuf;

use ethlambda_types::beacon::primitives::{Bytes32, ExecutionAddress, H160};
use ethlambda_validator::proposer_settings::{GRAFFITI_BYTES, graffiti_from_text};
use ethlambda_validator::proposer_settings::{
DEFAULT_GAS_LIMIT, GRAFFITI_BYTES, graffiti_from_text,
};
use ethlambda_validator::{BuilderProposals, ValidatorConfig};

/// An execution address is exactly this wide.
Expand Down Expand Up @@ -70,6 +72,24 @@ pub(crate) struct ValidatorOptions {
#[arg(long)]
pub(crate) suggested_fee_recipient: Option<String>,

/// The gas limit every validator targets, unless the keymanager API gives
/// it its own.
///
/// Before gloas it goes into builder registrations (with
/// `--builder-proposals`). From gloas it goes into the proposer
/// preferences, published for every proposal, and the beacon node builds
/// this client's own payloads toward it too, so on a network whose gas
/// limit is not the default, set it or every proposal pulls the limit
/// toward 60M. Sepolia and Platåberget schedule 200000000: pass
/// `--gas-limit 200000000` there. The network config's
/// `GAS_LIMIT_SCHEDULE` is not read yet (#795).
#[arg(
long,
default_value_t = DEFAULT_GAS_LIMIT,
value_parser = clap::value_parser!(u64).range(1..)
)]
pub(crate) gas_limit: u64,

/// Serve the keymanager API: keystores, plus each validator's own fee
/// recipient, graffiti and gas limit, which are held in memory only.
///
Expand All @@ -81,9 +101,14 @@ 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`. Without it the factor
/// is 0, the local payload unless it cannot be built: a node whose
/// execution client fails at the slot may still build on a p2p bid, and
/// that block is signed.
#[arg(long, default_value = "false")]
pub(crate) builder_proposals: bool,

Expand All @@ -105,6 +130,20 @@ pub(crate) struct ValidatorOptions {
#[arg(long, default_value = "false", requires = "builder_proposals")]
pub(crate) prefer_builder_proposals: bool,

/// The smallest builder bid, in gwei, a gloas block may be built on: the
/// beacon node takes no p2p bid whose value plus execution payment is
/// below it, however the boost factor weighs it.
///
/// Sent as `produceBlockV4`'s `min_bid`, so gloas only. A bid floor
/// matters most with `--prefer-builder-proposals`, which otherwise takes
/// a 1-gwei bid over the local payload. `0` sets no floor.
///
/// Taken with or without `--builder-proposals`: without it, a node whose
/// execution client fails at the slot may still build on a pooled bid,
/// and this is the floor that bid must clear.
#[arg(long, default_value_t = 0)]
pub(crate) builder_min_bid: u64,

/// Register every loaded key that has no slashing-protection record,
/// instead of refusing to start.
///
Expand Down Expand Up @@ -209,7 +248,9 @@ pub(crate) async fn run(options: ValidatorOptions) -> eyre::Result<()> {
keymanager: options
.enable_keymanager
.then(|| std::net::SocketAddr::new(options.http_address, options.keymanager_port)),
gas_limit: options.gas_limit,
builder,
builder_min_bid: options.builder_min_bid,
init_slashing_protection: options.init_slashing_protection,
})
.await
Expand All @@ -230,10 +271,12 @@ mod tests {
metrics_port: 5064,
graffiti: graffiti.to_string(),
suggested_fee_recipient: None,
gas_limit: DEFAULT_GAS_LIMIT,
enable_keymanager: false,
builder_proposals: false,
builder_boost_factor: 100,
prefer_builder_proposals: false,
builder_min_bid: 0,
init_slashing_protection: false,
}
}
Expand All @@ -260,6 +303,21 @@ mod tests {
Cli::try_parse_from(base.iter().chain(extra)).map(|cli| cli.options)
}

#[test]
fn the_gas_limit_defaults_to_60m_and_takes_any_positive_value() {
assert_eq!(parse(&[]).expect("parses").gas_limit, DEFAULT_GAS_LIMIT);
let options = parse(&["--gas-limit", "200000000"]).expect("parses");
assert_eq!(options.gas_limit, 200_000_000);
parse(&["--gas-limit", "0"]).expect_err("a zero gas limit is a mistake");
}

/// From gloas the gas limit is published whether or not builder proposals
/// are on, so it does not require them.
#[test]
fn the_gas_limit_does_not_need_builder_proposals() {
parse(&["--gas-limit", "30000000"]).expect("parses alone");
}

#[test]
fn builder_proposals_are_off_by_default() {
assert_eq!(parse(&[]).expect("parses").builder(), None);
Expand Down Expand Up @@ -291,7 +349,7 @@ mod tests {
assert_eq!(
options.builder(),
Some(BuilderProposals {
boost_factor: u64::MAX
boost_factor: u64::MAX,
})
);
}
Expand All @@ -304,6 +362,37 @@ mod tests {
.expect_err("a preference with builder proposals off is a mistake");
}

/// The floor travels with either way of weighing bids, in gwei as given,
/// and without builder proposals too: a node whose local build fails may
/// still build on a pooled bid, which the floor then applies to.
#[test]
fn a_min_bid_is_taken_with_or_without_builder_proposals() {
let options =
parse(&["--builder-proposals", "--builder-min-bid", "1000000"]).expect("parses");
assert_eq!(options.builder_min_bid, 1_000_000);
assert_eq!(
options.builder(),
Some(BuilderProposals { boost_factor: 100 })
);
let options = parse(&[
"--builder-proposals",
"--prefer-builder-proposals",
"--builder-min-bid",
"5",
])
.expect("parses");
assert_eq!(options.builder_min_bid, 5);
assert_eq!(
options.builder(),
Some(BuilderProposals {
boost_factor: u64::MAX
})
);
let options = parse(&["--builder-min-bid", "1000000"]).expect("parses");
assert_eq!(options.builder_min_bid, 1_000_000);
assert_eq!(options.builder(), None);
}

#[test]
fn the_factor_and_the_preference_conflict() {
parse(&[
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
Loading
Loading