Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
b244fd1
feat(validator): decode a blinded block from produceBlockV3
MegaRedHand Oct 26, 2026
a95ebe8
feat(validator): ask for and publish blinded blocks over the Beacon API
MegaRedHand Oct 26, 2026
a413a2d
feat(validator): publish a blinded block only on the node that produc…
MegaRedHand Oct 26, 2026
7ad2c9e
feat(validator): sign and send builder registrations
MegaRedHand Oct 26, 2026
4d72075
feat(validator): propose builder blocks behind --builder-proposals
MegaRedHand Oct 26, 2026
b60e645
test(rpc): drive the builder flow end to end with the validator client
MegaRedHand Oct 26, 2026
da6c4e9
docs: describe the validator client's builder proposals
MegaRedHand Oct 26, 2026
ff2149e
fix(validator): keep a slow relay from counting as a missed proposal
MegaRedHand Oct 26, 2026
7911c6f
fix(validator): report a late blinded refusal and delay from the publ…
MegaRedHand Oct 26, 2026
e07123f
docs: say what the keymanager's gas limit and fee recipient now reach
MegaRedHand Oct 29, 2026
7d766f0
Merge feat/beacon-builder-blocks into feat/beacon-builder-vc
MegaRedHand Nov 4, 2026
b04a1ac
Merge feat/beacon-builder-blocks into feat/beacon-builder-vc
MegaRedHand Nov 5, 2026
ee5fed8
fix(validator): address the builder proposals review
MegaRedHand Nov 5, 2026
e919655
docs(validator): say a zero --suggested-fee-recipient is reported
MegaRedHand Nov 5, 2026
7eced44
fix(validator): write out every control character in a node's error text
MegaRedHand Nov 5, 2026
25f3ca5
fix(validator): keep a running send's flag set when another is refused
MegaRedHand Nov 5, 2026
9823ed7
Merge feat/beacon-builder-blocks into feat/beacon-builder-vc
MegaRedHand Nov 5, 2026
52c9030
Merge feat/beacon-builder-blocks into feat/beacon-builder-vc
MegaRedHand Nov 5, 2026
23faacd
Merge feat/beacon-builder-blocks into feat/beacon-builder-vc
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
9 changes: 6 additions & 3 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -862,11 +862,14 @@ transitions are in `ethlambda-types`, per the section above. Nothing above
and a block at slot N packs only `(N-1, parent_root)`, replaced by the empty
aggregate when `verified_sync_aggregate` fails (see `docs/spec_deviations.md`).
`SlotClock` picks the
offsets by each slot's own fork, and `/eth/v1/config/spec` supplies them. It
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
`--validators-dir/slashing_protection.json` (`crate::slashing_protection`),
durable before every block and attestation signature; PTC votes have an
in-memory `(validator, slot)` dedup only, and envelopes are unguarded.
durable before every block (blinded ones included) and attestation signature;
PTC votes have an in-memory `(validator, slot)` dedup only, and envelopes are
unguarded.
`get_head_node` is one bottom-up walk for every fork, with
the current slot's fork selecting the payload rules and the boost gate, so a
follower on any network that schedules gloas (Sepolia does) pays work
Expand Down
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
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 |
| `--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 |

Metrics are served on `--metrics-port` (default `5064`). See
Expand Down
124 changes: 123 additions & 1 deletion bin/ethlambda/src/validator.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,8 @@
use std::path::PathBuf;

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

/// An execution address is exactly this wide.
const ADDRESS_BYTES: usize = 20;
Expand Down Expand Up @@ -79,6 +79,31 @@ pub(crate) struct ValidatorOptions {
#[arg(long, default_value = "false")]
pub(crate) enable_keymanager: bool,

/// 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.
#[arg(long, default_value = "false")]
pub(crate) builder_proposals: bool,

/// How a builder's bid is weighed against the local payload: the local
/// payload wins iff `local >= factor * bid / 100`. 100 compares them as
/// they are; 0 prefers the local payload unless it cannot be built.
#[arg(
long,
default_value = "100",
requires = "builder_proposals",
conflicts_with = "prefer_builder_proposals"
)]
pub(crate) builder_boost_factor: u64,

/// Take the builder's block whenever there is a valid bid, unless the
/// execution client asks to override the builder (sends
/// `builder_boost_factor = u64::MAX`).
#[arg(long, default_value = "false", requires = "builder_proposals")]
pub(crate) prefer_builder_proposals: bool,

/// Register every loaded key that has no slashing-protection record,
/// instead of refusing to start.
///
Expand Down Expand Up @@ -134,6 +159,21 @@ impl ValidatorOptions {
Ok(Some(H160(bytes)))
}

/// The builder configuration these flags describe, or `None` when builder
/// proposals are off.
///
/// `--prefer-builder-proposals` wins over the factor by construction:
/// clap refuses the two together, so at most one is ever set.
pub(crate) fn builder(&self) -> Option<BuilderProposals> {
self.builder_proposals.then_some(BuilderProposals {
boost_factor: if self.prefer_builder_proposals {
u64::MAX
} else {
self.builder_boost_factor
},
})
}

/// Reject a port clash before anything binds, the way the node's own
/// options do.
pub(crate) fn validate_ports(&self) -> eyre::Result<()> {
Expand All @@ -157,6 +197,7 @@ pub(crate) async fn run(options: ValidatorOptions) -> eyre::Result<()> {
options.validate_ports()?;
let graffiti = options.graffiti()?;
let suggested_fee_recipient = options.suggested_fee_recipient()?;
let builder = options.builder();
ethlambda_validator::run(ValidatorConfig {
beacon_nodes: options.beacon_nodes,
graffiti,
Expand All @@ -167,6 +208,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)),
builder,
init_slashing_protection: options.init_slashing_protection,
})
.await
Expand All @@ -188,10 +230,90 @@ mod tests {
graffiti: graffiti.to_string(),
suggested_fee_recipient: None,
enable_keymanager: false,
builder_proposals: false,
builder_boost_factor: 100,
prefer_builder_proposals: false,
init_slashing_protection: false,
}
}

/// Parse a command line the way the binary does, so `requires` and
/// `conflicts_with` are exercised rather than assumed.
fn parse(extra: &[&str]) -> Result<ValidatorOptions, clap::Error> {
use clap::Parser as _;

#[derive(clap::Parser)]
struct Cli {
#[command(flatten)]
options: ValidatorOptions,
}
let base = [
"ethlambda",
"--beacon-nodes",
"http://localhost:5052",
"--validators-dir",
"/tmp/v",
"--secrets-dir",
"/tmp/s",
];
Cli::try_parse_from(base.iter().chain(extra)).map(|cli| cli.options)
}

#[test]
fn builder_proposals_are_off_by_default() {
assert_eq!(parse(&[]).expect("parses").builder(), None);
}

#[test]
fn builder_proposals_default_to_a_factor_of_100() {
let options = parse(&["--builder-proposals"]).expect("parses");
assert_eq!(
options.builder(),
Some(BuilderProposals { boost_factor: 100 })
);
}

#[test]
fn an_explicit_factor_is_used() {
let options =
parse(&["--builder-proposals", "--builder-boost-factor", "250"]).expect("parses");
assert_eq!(
options.builder(),
Some(BuilderProposals { boost_factor: 250 })
);
}

#[test]
fn preferring_the_builder_sends_the_maximum_factor() {
let options =
parse(&["--builder-proposals", "--prefer-builder-proposals"]).expect("parses");
assert_eq!(
options.builder(),
Some(BuilderProposals {
boost_factor: u64::MAX
})
);
}

#[test]
fn the_factor_and_the_preference_need_builder_proposals() {
parse(&["--builder-boost-factor", "250"])
.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");
}

#[test]
fn the_factor_and_the_preference_conflict() {
parse(&[
"--builder-proposals",
"--builder-boost-factor",
"250",
"--prefer-builder-proposals",
])
.expect_err("the two say different things");
}

#[test]
fn the_default_graffiti_is_empty() {
let bytes = options("").graffiti().expect("valid");
Expand Down
Loading
Loading