Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 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
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
3 changes: 2 additions & 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 Expand Up @@ -369,6 +369,7 @@ 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 |
| `--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 |
| `--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 |
| `--enable-keymanager` | Serve the keymanager API on `--keymanager-port` (default `5062`). Off by default, since it mutates key material |

Expand Down
100 changes: 93 additions & 7 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,22 @@ 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.
#[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 +99,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 All @@ -104,6 +124,16 @@ 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.
#[arg(long, default_value_t = 0, requires = "builder_proposals")]
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 @@ -171,6 +201,7 @@ impl ValidatorOptions {
} else {
self.builder_boost_factor
},
min_bid: self.builder_min_bid,
})
}

Expand Down Expand Up @@ -208,6 +239,7 @@ 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,
init_slashing_protection: options.init_slashing_protection,
})
Expand All @@ -229,10 +261,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 @@ -259,6 +293,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 All @@ -269,7 +318,10 @@ mod tests {
let options = parse(&["--builder-proposals"]).expect("parses");
assert_eq!(
options.builder(),
Some(BuilderProposals { boost_factor: 100 })
Some(BuilderProposals {
boost_factor: 100,
min_bid: 0
})
);
}

Expand All @@ -279,7 +331,10 @@ mod tests {
parse(&["--builder-proposals", "--builder-boost-factor", "250"]).expect("parses");
assert_eq!(
options.builder(),
Some(BuilderProposals { boost_factor: 250 })
Some(BuilderProposals {
boost_factor: 250,
min_bid: 0
})
);
}

Expand All @@ -290,7 +345,8 @@ mod tests {
assert_eq!(
options.builder(),
Some(BuilderProposals {
boost_factor: u64::MAX
boost_factor: u64::MAX,
min_bid: 0
})
);
}
Expand All @@ -301,6 +357,36 @@ mod tests {
.expect_err("a factor with builder proposals off is a mistake");
parse(&["--prefer-builder-proposals"])
.expect_err("a preference with builder proposals off is a mistake");
parse(&["--builder-min-bid", "1000000"])
.expect_err("a bid floor with builder proposals off is a mistake");
}

/// The floor travels with either way of weighing bids, in gwei as given.
#[test]
fn a_min_bid_is_used_beside_the_factor_or_the_preference() {
let options =
parse(&["--builder-proposals", "--builder-min-bid", "1000000"]).expect("parses");
assert_eq!(
options.builder(),
Some(BuilderProposals {
boost_factor: 100,
min_bid: 1_000_000
})
);
let options = parse(&[
"--builder-proposals",
"--prefer-builder-proposals",
"--builder-min-bid",
"5",
])
.expect("parses");
assert_eq!(
options.builder(),
Some(BuilderProposals {
boost_factor: u64::MAX,
min_bid: 5
})
);
}

#[test]
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