11use crate :: duties_service:: DutiesService ;
22use beacon_node_fallback:: BeaconNodeFallback ;
3+ use bls:: PublicKeyBytes ;
34use eth2:: types:: ProposerData ;
45use slot_clock:: SlotClock ;
5- use std:: collections:: HashMap ;
6+ use std:: collections:: { HashMap , HashSet } ;
67use std:: ops:: Deref ;
78use std:: sync:: Arc ;
89use task_executor:: TaskExecutor ;
910use tokio:: time:: sleep;
1011use tracing:: { debug, error, info, warn} ;
11- use types:: { ChainSpec , Epoch , EthSpec , ForkName , Hash256 , ProposerPreferences } ;
12- use validator_store:: ValidatorStore ;
12+ use types:: { ChainSpec , Epoch , EthSpec , ForkName , Hash256 , ProposerPreferences , Slot } ;
13+ use validator_store:: { ProposalData , ValidatorStore } ;
14+
15+ /// `(validator_index, proposal_slot)` duties already published, keyed by epoch and dependent
16+ /// root. Sets for stale roots persist until their epoch is pruned, so a dependent root that
17+ /// recurs within an epoch is not re-published.
18+ type PublishedPreferences = HashMap < ( Epoch , Hash256 ) , HashSet < ( u64 , Slot ) > > ;
1319
1420pub struct Inner < S , T > {
1521 duties_service : Arc < DutiesService < S , T > > ,
@@ -68,7 +74,7 @@ impl<S: ValidatorStore + 'static, T: SlotClock + 'static> ProposerPreferencesSer
6874 let executor = self . executor . clone ( ) ;
6975
7076 let interval_fut = async move {
71- let mut published_preferences: HashMap < Epoch , Hash256 > = HashMap :: new ( ) ;
77+ let mut published_preferences = PublishedPreferences :: new ( ) ;
7278
7379 loop {
7480 let Some ( current_slot) = self . slot_clock . now ( ) else {
@@ -95,11 +101,15 @@ impl<S: ValidatorStore + 'static, T: SlotClock + 'static> ProposerPreferencesSer
95101 }
96102
97103 /// Publish proposer preferences for `current_epoch` and `current_epoch + 1`.
98- /// Will only publish preferences for a given epoch once per dependent root.
104+ ///
105+ /// Each proposer duty is published once per epoch and dependent root, so a validator added
106+ /// after an epoch's duties were first published is still picked up, and a dependent root
107+ /// change re-publishes the epoch's duties. Duties that are missing proposal data or that
108+ /// fail to sign or publish are retried on later polls without re-publishing their siblings.
99109 async fn poll_and_publish_preferences (
100110 & self ,
101111 current_epoch : Epoch ,
102- published_preferences : & mut HashMap < Epoch , Hash256 > ,
112+ published_preferences : & mut PublishedPreferences ,
103113 ) {
104114 for ( epoch, fork_name) in [
105115 (
@@ -123,63 +133,36 @@ impl<S: ValidatorStore + 'static, T: SlotClock + 'static> ProposerPreferencesSer
123133 }
124134 } ;
125135
126- if published_preferences. get ( & epoch) == Some ( & dependent_root) {
136+ let published = published_preferences
137+ . entry ( ( epoch, dependent_root) )
138+ . or_default ( ) ;
139+
140+ let preferences_to_sign =
141+ preferences_to_publish ( dependent_root, & duties, published, |pubkey| {
142+ self . validator_store . proposal_data ( pubkey)
143+ } ) ;
144+
145+ if preferences_to_sign. is_empty ( ) {
127146 continue ;
128147 }
129148
130- if self
131- . publish_proposer_preferences ( epoch, fork_name, dependent_root, duties)
132- . await
133- {
134- published_preferences. insert ( epoch, dependent_root) ;
135- }
149+ let newly_published = self
150+ . publish_proposer_preferences ( epoch, fork_name, preferences_to_sign)
151+ . await ;
152+ published. extend ( newly_published) ;
136153 }
137154
138- published_preferences. retain ( |epoch, _| * epoch >= current_epoch) ;
155+ published_preferences. retain ( |( epoch, _ ) , _| * epoch >= current_epoch) ;
139156 }
140157
158+ /// Sign and publish `preferences_to_sign`, returning the `(validator_index, slot)` pairs
159+ /// that were signed and included in a successfully published batch.
141160 async fn publish_proposer_preferences (
142161 & self ,
143162 epoch : Epoch ,
144163 fork_name : ForkName ,
145- dependent_root : Hash256 ,
146- duties : Vec < ProposerData > ,
147- ) -> bool {
148- let preferences_to_sign: Vec < _ > = {
149- let mut result = vec ! [ ] ;
150- for duty in & duties {
151- let Some ( proposal_data) = self . validator_store . proposal_data ( & duty. pubkey ) else {
152- warn ! (
153- validator = ?duty. pubkey,
154- "Missing proposal data for proposer preferences"
155- ) ;
156- continue ;
157- } ;
158- let Some ( fee_recipient) = proposal_data. fee_recipient else {
159- warn ! (
160- validator = ?duty. pubkey,
161- "Missing fee recipient for proposer preferences"
162- ) ;
163- continue ;
164- } ;
165- result. push ( (
166- duty. pubkey ,
167- ProposerPreferences {
168- dependent_root,
169- proposal_slot : duty. slot ,
170- validator_index : duty. validator_index ,
171- fee_recipient,
172- target_gas_limit : proposal_data. gas_limit ,
173- } ,
174- ) ) ;
175- }
176- result
177- } ;
178-
179- if preferences_to_sign. is_empty ( ) {
180- return false ;
181- }
182-
164+ preferences_to_sign : Vec < ( PublicKeyBytes , ProposerPreferences ) > ,
165+ ) -> Vec < ( u64 , Slot ) > {
183166 debug ! (
184167 %epoch,
185168 count = preferences_to_sign. len( ) ,
@@ -205,7 +188,7 @@ impl<S: ValidatorStore + 'static, T: SlotClock + 'static> ProposerPreferencesSer
205188 }
206189
207190 if signed. is_empty ( ) {
208- return false ;
191+ return vec ! [ ] ;
209192 }
210193
211194 let count = signed. len ( ) ;
@@ -250,16 +233,231 @@ impl<S: ValidatorStore + 'static, T: SlotClock + 'static> ProposerPreferencesSer
250233 %count,
251234 "Successfully published proposer preferences"
252235 ) ;
253- true
236+ signed
237+ . iter ( )
238+ . map ( |preferences| {
239+ (
240+ preferences. message . validator_index ,
241+ preferences. message . proposal_slot ,
242+ )
243+ } )
244+ . collect ( )
254245 }
255246 Err ( e) => {
256247 error ! (
257248 error = %e,
258249 %epoch,
259250 "Failed to publish proposer preferences"
260251 ) ;
261- false
252+ vec ! [ ]
262253 }
263254 }
264255 }
265256}
257+
258+ /// Build the proposer preferences that still need publishing: one per proposer duty whose
259+ /// `(validator_index, slot)` is not in `published` and whose proposal data is available.
260+ fn preferences_to_publish (
261+ dependent_root : Hash256 ,
262+ duties : & [ ProposerData ] ,
263+ published : & HashSet < ( u64 , Slot ) > ,
264+ proposal_data : impl Fn ( & PublicKeyBytes ) -> Option < ProposalData > ,
265+ ) -> Vec < ( PublicKeyBytes , ProposerPreferences ) > {
266+ duties
267+ . iter ( )
268+ . filter ( |duty| !published. contains ( & ( duty. validator_index , duty. slot ) ) )
269+ . filter_map ( |duty| {
270+ let Some ( proposal_data) = proposal_data ( & duty. pubkey ) else {
271+ warn ! (
272+ validator = ?duty. pubkey,
273+ "Missing proposal data for proposer preferences"
274+ ) ;
275+ return None ;
276+ } ;
277+ let Some ( fee_recipient) = proposal_data. fee_recipient else {
278+ warn ! (
279+ validator = ?duty. pubkey,
280+ "Missing fee recipient for proposer preferences"
281+ ) ;
282+ return None ;
283+ } ;
284+ Some ( (
285+ duty. pubkey ,
286+ ProposerPreferences {
287+ dependent_root,
288+ proposal_slot : duty. slot ,
289+ validator_index : duty. validator_index ,
290+ fee_recipient,
291+ target_gas_limit : proposal_data. gas_limit ,
292+ } ,
293+ ) )
294+ } )
295+ . collect ( )
296+ }
297+
298+ #[ cfg( test) ]
299+ mod tests {
300+ use super :: * ;
301+ use types:: Address ;
302+
303+ const FEE_RECIPIENT : Address = Address :: repeat_byte ( 42 ) ;
304+ const GAS_LIMIT : u64 = 30_000_000 ;
305+
306+ fn pubkey ( i : u8 ) -> PublicKeyBytes {
307+ PublicKeyBytes :: deserialize ( & [ i; 48 ] ) . unwrap ( )
308+ }
309+
310+ fn duty ( i : u8 , slot : u64 ) -> ProposerData {
311+ ProposerData {
312+ pubkey : pubkey ( i) ,
313+ validator_index : i as u64 ,
314+ slot : Slot :: new ( slot) ,
315+ }
316+ }
317+
318+ fn proposal_data ( fee_recipient : Option < Address > ) -> ProposalData {
319+ ProposalData {
320+ validator_index : None ,
321+ fee_recipient,
322+ gas_limit : GAS_LIMIT ,
323+ builder_proposals : false ,
324+ }
325+ }
326+
327+ fn default_proposal_data ( _: & PublicKeyBytes ) -> Option < ProposalData > {
328+ Some ( proposal_data ( Some ( FEE_RECIPIENT ) ) )
329+ }
330+
331+ /// Run the state transitions of one poll (select unpublished duties, then record them as
332+ /// published on success) and return how many duties were published. Mirrors the map
333+ /// bookkeeping in `poll_and_publish_preferences` around the signing effect.
334+ fn publish_round (
335+ published_preferences : & mut PublishedPreferences ,
336+ epoch : Epoch ,
337+ dependent_root : Hash256 ,
338+ duties : & [ ProposerData ] ,
339+ ) -> usize {
340+ let published = published_preferences
341+ . entry ( ( epoch, dependent_root) )
342+ . or_default ( ) ;
343+ let to_sign =
344+ preferences_to_publish ( dependent_root, duties, published, default_proposal_data) ;
345+ let count = to_sign. len ( ) ;
346+ published. extend (
347+ to_sign
348+ . iter ( )
349+ . map ( |( _, preferences) | ( preferences. validator_index , preferences. proposal_slot ) ) ,
350+ ) ;
351+ count
352+ }
353+
354+ #[ test]
355+ fn new_validator_under_same_root_yields_only_new_validator ( ) {
356+ let dependent_root = Hash256 :: repeat_byte ( 1 ) ;
357+ let duties = [ duty ( 1 , 1 ) , duty ( 2 , 2 ) , duty ( 3 , 3 ) ] ;
358+ let published = HashSet :: from ( [ ( 1 , Slot :: new ( 1 ) ) , ( 2 , Slot :: new ( 2 ) ) ] ) ;
359+
360+ let result =
361+ preferences_to_publish ( dependent_root, & duties, & published, default_proposal_data) ;
362+
363+ assert_eq ! ( result. len( ) , 1 ) ;
364+ assert_eq ! ( result[ 0 ] . 0 , pubkey( 3 ) ) ;
365+ assert_eq ! ( result[ 0 ] . 1 . proposal_slot, Slot :: new( 3 ) ) ;
366+ }
367+
368+ #[ test]
369+ fn built_preferences_carry_duty_fields ( ) {
370+ let dependent_root = Hash256 :: repeat_byte ( 7 ) ;
371+ let duties = [ duty ( 1 , 5 ) ] ;
372+
373+ let result = preferences_to_publish (
374+ dependent_root,
375+ & duties,
376+ & HashSet :: new ( ) ,
377+ default_proposal_data,
378+ ) ;
379+
380+ assert_eq ! ( result. len( ) , 1 ) ;
381+ let ( pubkey_out, preferences) = & result[ 0 ] ;
382+ assert_eq ! ( * pubkey_out, pubkey( 1 ) ) ;
383+ assert_eq ! ( preferences. dependent_root, dependent_root) ;
384+ assert_eq ! ( preferences. proposal_slot, Slot :: new( 5 ) ) ;
385+ assert_eq ! ( preferences. validator_index, 1 ) ;
386+ assert_eq ! ( preferences. fee_recipient, FEE_RECIPIENT ) ;
387+ assert_eq ! ( preferences. target_gas_limit, GAS_LIMIT ) ;
388+ }
389+
390+ #[ test]
391+ fn missing_data_excluded_and_retried ( ) {
392+ let dependent_root = Hash256 :: repeat_byte ( 1 ) ;
393+ // Validator 1 has no fee recipient yet, validator 2 is ready, validator 3 has no
394+ // proposal data at all.
395+ let duties = [ duty ( 1 , 1 ) , duty ( 2 , 2 ) , duty ( 3 , 3 ) ] ;
396+ let mut published = HashSet :: new ( ) ;
397+
398+ let result =
399+ preferences_to_publish (
400+ dependent_root,
401+ & duties,
402+ & published,
403+ |pubkey_in| match pubkey_in {
404+ pk if * pk == pubkey ( 1 ) => Some ( proposal_data ( None ) ) ,
405+ pk if * pk == pubkey ( 3 ) => None ,
406+ _ => Some ( proposal_data ( Some ( FEE_RECIPIENT ) ) ) ,
407+ } ,
408+ ) ;
409+
410+ assert_eq ! ( result. len( ) , 1 ) ;
411+ assert_eq ! ( result[ 0 ] . 0 , pubkey( 2 ) ) ;
412+
413+ // A later poll, after validator 1's fee recipient becomes available and validator 2's
414+ // preferences were published. Validator 3 still has no proposal data.
415+ published. insert ( ( 2 , Slot :: new ( 2 ) ) ) ;
416+ let result = preferences_to_publish ( dependent_root, & duties, & published, |pubkey_in| {
417+ ( * pubkey_in != pubkey ( 3 ) ) . then ( || proposal_data ( Some ( FEE_RECIPIENT ) ) )
418+ } ) ;
419+
420+ assert_eq ! ( result. len( ) , 1 ) ;
421+ assert_eq ! ( result[ 0 ] . 0 , pubkey( 1 ) ) ;
422+ }
423+
424+ #[ test]
425+ fn multi_slot_proposer_yields_one_entry_per_duty ( ) {
426+ let dependent_root = Hash256 :: repeat_byte ( 1 ) ;
427+ let duties = [ duty ( 1 , 10 ) , duty ( 1 , 11 ) ] ;
428+ let published = HashSet :: from ( [ ( 1 , Slot :: new ( 10 ) ) ] ) ;
429+
430+ let result =
431+ preferences_to_publish ( dependent_root, & duties, & published, default_proposal_data) ;
432+
433+ assert_eq ! ( result. len( ) , 1 ) ;
434+ assert_eq ! ( result[ 0 ] . 0 , pubkey( 1 ) ) ;
435+ assert_eq ! ( result[ 0 ] . 1 . proposal_slot, Slot :: new( 11 ) ) ;
436+ }
437+
438+ #[ test]
439+ fn dependent_root_flip_flop_does_not_resign ( ) {
440+ let epoch = Epoch :: new ( 0 ) ;
441+ let root_a = Hash256 :: repeat_byte ( 1 ) ;
442+ let root_b = Hash256 :: repeat_byte ( 2 ) ;
443+ let duties = [ duty ( 1 , 1 ) , duty ( 2 , 2 ) ] ;
444+ let mut published_preferences = PublishedPreferences :: new ( ) ;
445+
446+ // Publish under the original root, then re-publish under the reorged root.
447+ assert_eq ! (
448+ publish_round( & mut published_preferences, epoch, root_a, & duties) ,
449+ 2
450+ ) ;
451+ assert_eq ! (
452+ publish_round( & mut published_preferences, epoch, root_b, & duties) ,
453+ 2
454+ ) ;
455+
456+ // The head reorgs back to the original root. Its published set was retained under its own
457+ // key, so nothing is signed again.
458+ assert_eq ! (
459+ publish_round( & mut published_preferences, epoch, root_a, & duties) ,
460+ 0
461+ ) ;
462+ }
463+ }
0 commit comments