Skip to content

Commit e0169e0

Browse files
authored
Gloas fix proposer preferences for validators added mid-epoch (sigp#9660)
## Issue Addressed Proposer preferences were treated as published for an entire epoch and dependent root. A validator added mid-epoch, for example through the keymanager API or by a downstream DVT client, can appear in refreshed proposer duties under the same dependent root but would not have its preferences published. ## Proposed Changes Track successfully published proposer duties individually, allowing newly discovered validators and duties that failed to sign or publish to be retried without re-publishing successful duties.
1 parent 92b983b commit e0169e0

1 file changed

Lines changed: 253 additions & 55 deletions

File tree

validator_client/validator_services/src/proposer_preferences_service.rs

Lines changed: 253 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,21 @@
11
use crate::duties_service::DutiesService;
22
use beacon_node_fallback::BeaconNodeFallback;
3+
use bls::PublicKeyBytes;
34
use eth2::types::ProposerData;
45
use slot_clock::SlotClock;
5-
use std::collections::HashMap;
6+
use std::collections::{HashMap, HashSet};
67
use std::ops::Deref;
78
use std::sync::Arc;
89
use task_executor::TaskExecutor;
910
use tokio::time::sleep;
1011
use 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

1420
pub 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

Comments
 (0)