From b2a3b0c098263da05a06caa573b72b9d311065a4 Mon Sep 17 00:00:00 2001 From: owen Date: Tue, 25 Aug 2026 11:33:02 +0100 Subject: [PATCH] Wire submitBuilderPreferences to per-proposer per-slot storage on the auctioneer --- .../proposer/submit_builder_preferences.rs | 24 ++++- .../src/auctioneer/builder_preferences.rs | 94 +++++++++++++++++++ crates/relay/src/auctioneer/context.rs | 4 + crates/relay/src/auctioneer/handle.rs | 24 ++++- crates/relay/src/auctioneer/mod.rs | 23 +++++ crates/relay/src/auctioneer/types.rs | 10 ++ 6 files changed, 173 insertions(+), 6 deletions(-) create mode 100644 crates/relay/src/auctioneer/builder_preferences.rs diff --git a/crates/relay/src/api/proposer/submit_builder_preferences.rs b/crates/relay/src/api/proposer/submit_builder_preferences.rs index f164932d9..6ae951fd6 100644 --- a/crates/relay/src/api/proposer/submit_builder_preferences.rs +++ b/crates/relay/src/api/proposer/submit_builder_preferences.rs @@ -7,7 +7,7 @@ use helix_common::{ use helix_types::{BuilderPreferencesRequest, ForkName}; use hyper::StatusCode; use ssz::Decode; -use tracing::info; +use tracing::{info, warn}; use super::{ProposerApi, get_payload::fork_name_from_header}; use crate::api::{Api, proposer::error::ProposerApiError}; @@ -41,11 +41,25 @@ impl ProposerApi { proposer_pubkey = ?params.proposer_pubkey, slot = request.auth.message.slot, max_execution_payment = request.preferences.max_execution_payment, - "validated submitBuilderPreferences request (not yet persisted)" + "validated submitBuilderPreferences request" ); - // TODO(gloas): reject stale slots, store preferences per proposer per slot, and honor - // max_execution_payment when serving bids. - Ok(StatusCode::ACCEPTED) + let Ok(rx) = proposer_api.auctioneer_handle.submit_builder_preferences( + params.proposer_pubkey, + request.auth.message.slot, + request.preferences.max_execution_payment, + ) else { + return Err(ProposerApiError::InternalServerError); + }; + + // TODO(gloas): honor max_execution_payment when getExecutionPayloadBid serves a real + // bid; not consumed anywhere yet. + match rx.await { + Ok(res) => res.map(|()| StatusCode::ACCEPTED), + Err(err) => { + warn!(%err, "failed to store builder preferences"); + Err(ProposerApiError::InternalServerError) + } + } } } diff --git a/crates/relay/src/auctioneer/builder_preferences.rs b/crates/relay/src/auctioneer/builder_preferences.rs new file mode 100644 index 000000000..0926fc651 --- /dev/null +++ b/crates/relay/src/auctioneer/builder_preferences.rs @@ -0,0 +1,94 @@ +use helix_types::BlsPublicKeyBytes; +use rustc_hash::FxHashMap; + +/// Per-proposer-per-slot `max_execution_payment` preferences, submitted via +/// `submitBuilderPreferences` up to an epoch ahead of the slot they apply to. Lives on `Context` +/// (not `SlotContext`), since entries must survive across slot transitions until their own slot +/// arrives or passes. +#[derive(Default)] +pub struct BuilderPreferencesStore { + by_proposer_slot: FxHashMap<(BlsPublicKeyBytes, u64), u64>, +} + +impl BuilderPreferencesStore { + pub fn store( + &mut self, + proposer_pubkey: BlsPublicKeyBytes, + slot: u64, + max_execution_payment: u64, + ) { + self.by_proposer_slot.insert((proposer_pubkey, slot), max_execution_payment); + } + + pub fn max_execution_payment( + &self, + proposer_pubkey: &BlsPublicKeyBytes, + slot: u64, + ) -> Option { + self.by_proposer_slot.get(&(*proposer_pubkey, slot)).copied() + } + + /// Drops entries for slots that have already passed, so a proposer's own resubmissions + /// (or one that never proposes) don't grow this unboundedly. + pub fn on_new_slot(&mut self, bid_slot: u64) { + self.by_proposer_slot.retain(|(_, slot), _| *slot >= bid_slot); + } + + #[cfg(test)] + pub fn len(&self) -> usize { + self.by_proposer_slot.len() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn pubkey(byte: u8) -> BlsPublicKeyBytes { + BlsPublicKeyBytes::repeat_byte(byte) + } + + #[test] + fn stores_and_looks_up_per_proposer_per_slot() { + let mut store = BuilderPreferencesStore::default(); + let alice = pubkey(1); + let bob = pubkey(2); + + store.store(alice, 100, 500); + store.store(bob, 100, 900); + store.store(alice, 101, 700); + + assert_eq!(store.max_execution_payment(&alice, 100), Some(500)); + assert_eq!(store.max_execution_payment(&bob, 100), Some(900)); + assert_eq!(store.max_execution_payment(&alice, 101), Some(700)); + assert_eq!(store.max_execution_payment(&alice, 102), None); + } + + #[test] + fn resubmission_overwrites_the_previous_value() { + let mut store = BuilderPreferencesStore::default(); + let alice = pubkey(1); + + store.store(alice, 100, 500); + store.store(alice, 100, 600); + + assert_eq!(store.max_execution_payment(&alice, 100), Some(600)); + } + + #[test] + fn on_new_slot_prunes_entries_for_slots_already_passed() { + let mut store = BuilderPreferencesStore::default(); + let alice = pubkey(1); + + store.store(alice, 100, 500); + store.store(alice, 101, 600); + store.store(alice, 102, 700); + + store.on_new_slot(101); + + assert_eq!(store.max_execution_payment(&alice, 100), None); + assert_eq!(store.max_execution_payment(&alice, 101), Some(600)); + assert_eq!(store.max_execution_payment(&alice, 102), Some(700)); + assert_eq!(store.len(), 2); + } +} diff --git a/crates/relay/src/auctioneer/context.rs b/crates/relay/src/auctioneer/context.rs index f7f91c52b..0995f5603 100644 --- a/crates/relay/src/auctioneer/context.rs +++ b/crates/relay/src/auctioneer/context.rs @@ -37,6 +37,7 @@ use crate::{ bid_adjustor::BidAdjustor, bid_sorter::BidSorter, block_merger::BlockMerger, + builder_preferences::BuilderPreferencesStore, types::{PayloadEntry, PendingPayload, SubmissionRef}, }, simulator::{SimRequest, tile::ValidationResult}, @@ -76,6 +77,7 @@ pub struct Context { pub failsafe_triggered: Arc, pub alert_manager: Arc, pub operator_api: Option>, + pub builder_preferences: BuilderPreferencesStore, } const EXPECTED_PAYLOADS_PER_SLOT: usize = 5000; @@ -146,6 +148,7 @@ impl Context { failsafe_triggered, alert_manager, operator_api, + builder_preferences: BuilderPreferencesStore::default(), } } @@ -257,6 +260,7 @@ impl Context { self.block_merger.on_new_slot(bid_slot.as_u64()); self.bid_adjustor.on_new_slot(bid_slot.as_u64()); + self.builder_preferences.on_new_slot(bid_slot.as_u64()); self.auctioneer_handle.clear_inflight_payloads(); self.decoded.clear(); diff --git a/crates/relay/src/auctioneer/handle.rs b/crates/relay/src/auctioneer/handle.rs index 048840e12..8db373629 100644 --- a/crates/relay/src/auctioneer/handle.rs +++ b/crates/relay/src/auctioneer/handle.rs @@ -16,7 +16,10 @@ use tracing::trace; use crate::{ api::proposer::{ProposerApiError, get_payload::ProposerApiVersion}, - auctioneer::types::{Event, GetExecutionPayloadBidResult, GetHeaderResult, GetPayloadResult}, + auctioneer::types::{ + Event, GetExecutionPayloadBidResult, GetHeaderResult, GetPayloadResult, + SubmitBuilderPreferencesResult, + }, gossip::BroadcastPayloadParams, }; @@ -85,6 +88,25 @@ impl AuctioneerHandle { Ok(rx) } + pub fn submit_builder_preferences( + &self, + proposer_pubkey: BlsPublicKeyBytes, + slot: u64, + max_execution_payment: u64, + ) -> Result, ChannelFull> { + let (tx, rx) = oneshot::channel(); + trace!("sending to auctioneer"); + self.auctioneer + .try_send(Event::SubmitBuilderPreferences { + proposer_pubkey, + slot, + max_execution_payment, + res_tx: tx, + }) + .map_err(|_| ChannelFull)?; + Ok(rx) + } + pub fn get_payload( &self, chain_info: &ChainInfo, diff --git a/crates/relay/src/auctioneer/mod.rs b/crates/relay/src/auctioneer/mod.rs index a0b388b17..af2fad8a1 100644 --- a/crates/relay/src/auctioneer/mod.rs +++ b/crates/relay/src/auctioneer/mod.rs @@ -1,6 +1,7 @@ mod bid_adjustor; mod bid_sorter; mod block_merger; +mod builder_preferences; mod context; mod get_execution_payload_bid; mod get_header; @@ -669,6 +670,28 @@ impl State { ) => { ctx.handle_builder_demotion(slot, builder_pubkey, block_hash, reason, false); } + + // submit_builder_preferences (Gloas), valid regardless of state + ( + State::Slot { .. } | State::Sorting(_) | State::Broadcasting { .. }, + Event::SubmitBuilderPreferences { + proposer_pubkey, + slot, + max_execution_payment, + res_tx, + }, + ) => { + let bid_slot = self.bid_slot(); + if slot < bid_slot { + let _ = res_tx.send(Err(ProposerApiError::RequestForPastSlot { + request_slot: slot.into(), + head_slot: bid_slot.into(), + })); + } else { + ctx.builder_preferences.store(proposer_pubkey, slot, max_execution_payment); + let _ = res_tx.send(Ok(())); + } + } } } diff --git a/crates/relay/src/auctioneer/types.rs b/crates/relay/src/auctioneer/types.rs index 3f43a41e0..9b9069053 100644 --- a/crates/relay/src/auctioneer/types.rs +++ b/crates/relay/src/auctioneer/types.rs @@ -52,6 +52,7 @@ pub enum SubmissionRef { pub type GetHeaderResult = Result; pub type GetPayloadResult = Result; pub type GetExecutionPayloadBidResult = Result; +pub type SubmitBuilderPreferencesResult = Result<(), ProposerApiError>; #[derive(Debug, Clone, Copy)] #[repr(C)] @@ -441,6 +442,14 @@ pub enum Event { res_tx: oneshot::Sender, span: tracing::Span, }, + /// Gloas (ePBS) `submitBuilderPreferences`: a proposer's per-builder `max_execution_payment` + /// for a future slot, valid regardless of the current auctioneer state. + SubmitBuilderPreferences { + proposer_pubkey: BlsPublicKeyBytes, + slot: u64, + max_execution_payment: u64, + res_tx: oneshot::Sender, + }, // Receive multiple of these potentially, assume some light validation GetPayload { block_hash: B256, @@ -467,6 +476,7 @@ impl Event { Event::Submission { .. } => "Submission", Event::GetHeader { .. } => "GetHeader", Event::GetExecutionPayloadBid { .. } => "GetExecutionPayloadBid", + Event::SubmitBuilderPreferences { .. } => "SubmitBuilderPreferences", Event::GetPayload { .. } => "GetPayload", Event::GossipPayload(_) => "GossipPayload", Event::SimResult(_) => "SimResult",