Skip to content

Commit 037a61e

Browse files
committed
fix(validator): admit local proposer preferences
Honor exact assignments in the local duty producer when the complete tracker rejects a preference-role message, allowing already-scheduled signing work to collect peer shares. Keep envelope validation unchanged and cover the exception through a real receiver and signature-collector regression. Related to #1295.
1 parent 7caa855 commit 037a61e

10 files changed

Lines changed: 825 additions & 7 deletions

File tree

Cargo.lock

Lines changed: 14 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

anchor/client/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ path = "src/lib.rs"
1111
[dependencies]
1212
anchor_validator_store = { workspace = true }
1313
beacon_node_fallback = { workspace = true }
14+
bls = { workspace = true }
1415
clap = { workspace = true }
1516
cli = { workspace = true }
1617
database = { workspace = true }

anchor/client/src/lib.rs

Lines changed: 114 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ mod metrics;
44
mod notifier;
55

66
use std::{
7+
collections::HashMap,
78
fs::File,
89
io::Read,
910
net::SocketAddr,
@@ -21,13 +22,14 @@ use beacon_node_fallback::{
2122
BeaconNodeFallback, CandidateBeaconNode, beacon_head_monitor::HeadEvent,
2223
start_fallback_updater_service,
2324
};
25+
use bls::PublicKeyBytes;
2426
use config::Config;
2527
use database::{NetworkDatabase, OwnOperatorId};
2628
use duties_tracker::{duties_tracker::DutiesTracker, voluntary_exit_tracker::VoluntaryExitTracker};
2729
use eth::{
2830
index_sync::start_validator_index_syncer, voluntary_exit_processor::start_exit_processor,
2931
};
30-
use eth2::{BeaconNodeHttpClient, Timeouts};
32+
use eth2::{BeaconNodeHttpClient, Timeouts, types::ProposerData};
3133
use message_receiver::NetworkMessageReceiver;
3234
use message_sender::{MessageSender, NetworkMessageSender, impostor::ImpostorMessageSender};
3335
use message_validator::Validator;
@@ -53,7 +55,7 @@ use tokio::{
5355
time::{Instant, interval, sleep},
5456
};
5557
use tracing::{debug, error, info, warn};
56-
use types::{EthSpec, Hash256};
58+
use types::{Epoch, EthSpec, Hash256, Slot};
5759
use validator_metrics::set_gauge;
5860
use validator_services::{
5961
attestation_service::AttestationServiceBuilder,
@@ -91,6 +93,21 @@ const MAX_HEAD_EVENT_QUEUE_LEN: usize = 1_024;
9193

9294
pub struct Client {}
9395

96+
fn local_proposer_assignment_at_slot(
97+
proposers: &HashMap<Epoch, (Hash256, Vec<ProposerData>)>,
98+
slots_per_epoch: u64,
99+
slot: Slot,
100+
validator_pubkey: &PublicKeyBytes,
101+
) -> bool {
102+
proposers
103+
.get(&slot.epoch(slots_per_epoch))
104+
.is_some_and(|(_, duties)| {
105+
duties
106+
.iter()
107+
.any(|duty| duty.slot == slot && duty.pubkey == *validator_pubkey)
108+
})
109+
}
110+
94111
impl Client {
95112
/// Runs the Anchor Client
96113
pub async fn run<E: EthSpec>(executor: TaskExecutor, config: Config) -> Result<(), String> {
@@ -693,6 +710,20 @@ impl Client {
693710
.build()?,
694711
);
695712

713+
// Support preferences for duties the local producer sees even when the tracker disagrees.
714+
// Neither cache is guaranteed to be newer, so this supplies positive evidence only.
715+
let local_duties_service = duties_service.clone();
716+
duties_tracker
717+
.set_local_proposer_lookup(move |slot, pubkey| {
718+
local_proposer_assignment_at_slot(
719+
&local_duties_service.proposers.read(),
720+
E::slots_per_epoch(),
721+
slot,
722+
pubkey,
723+
)
724+
})
725+
.map_err(|e| e.to_string())?;
726+
696727
// Update the metrics server.
697728
if let Some(ctx) = &http_metrics_shared_state {
698729
ctx.write().genesis_time = Some(genesis_time);
@@ -995,3 +1026,84 @@ pub fn load_pem_certificate<P: AsRef<Path>>(pem_path: P) -> Result<Certificate,
9951026
.map_err(|e| format!("Unable to read certificate file: {e}"))?;
9961027
Certificate::from_pem(&buf).map_err(|e| format!("Unable to parse certificate: {e}"))
9971028
}
1029+
1030+
#[cfg(test)]
1031+
mod local_proposer_tests {
1032+
use super::*;
1033+
1034+
const SLOTS_PER_EPOCH: u64 = 32;
1035+
const DUTY_SLOT: Slot = Slot::new(SLOTS_PER_EPOCH);
1036+
1037+
#[test]
1038+
fn test_local_proposer_assignment_requires_exact_live_epoch_slot_and_pubkey() {
1039+
// Arrange: create the lookup before any local duty rows exist, as at client startup.
1040+
let proposers = Arc::new(RwLock::new(HashMap::new()));
1041+
let local_proposers = proposers.clone();
1042+
let lookup = move |slot, pubkey: &PublicKeyBytes| {
1043+
local_proposer_assignment_at_slot(
1044+
&local_proposers.read(),
1045+
SLOTS_PER_EPOCH,
1046+
slot,
1047+
pubkey,
1048+
)
1049+
};
1050+
let validator_x = bls::Keypair::random().pk.compress();
1051+
let validator_y = bls::Keypair::random().pk.compress();
1052+
let epoch = DUTY_SLOT.epoch(SLOTS_PER_EPOCH);
1053+
assert!(!lookup(DUTY_SLOT, &validator_x));
1054+
1055+
// Act: a matching row under the wrong epoch key must not count as local evidence.
1056+
proposers.write().insert(
1057+
epoch + 1,
1058+
(
1059+
Hash256::ZERO,
1060+
vec![ProposerData {
1061+
pubkey: validator_x,
1062+
validator_index: 0,
1063+
slot: DUTY_SLOT,
1064+
}],
1065+
),
1066+
);
1067+
1068+
// Assert: the query must select the duty slot's epoch before matching rows.
1069+
assert!(!lookup(DUTY_SLOT, &validator_x));
1070+
1071+
// Act: in the correct epoch, X has a different slot and Y has the requested slot.
1072+
proposers.write().insert(
1073+
epoch,
1074+
(
1075+
Hash256::ZERO,
1076+
vec![
1077+
ProposerData {
1078+
pubkey: validator_x,
1079+
validator_index: 0,
1080+
slot: DUTY_SLOT + 1,
1081+
},
1082+
ProposerData {
1083+
pubkey: validator_y,
1084+
validator_index: 1,
1085+
slot: DUTY_SLOT,
1086+
},
1087+
],
1088+
),
1089+
);
1090+
1091+
// Assert: neither row matches both the requested slot and validator pubkey.
1092+
assert!(!lookup(DUTY_SLOT, &validator_x));
1093+
1094+
// Act: a later local cache update supplies the exact row after lookup construction.
1095+
proposers
1096+
.write()
1097+
.get_mut(&epoch)
1098+
.unwrap()
1099+
.1
1100+
.push(ProposerData {
1101+
pubkey: validator_x,
1102+
validator_index: 0,
1103+
slot: DUTY_SLOT,
1104+
});
1105+
1106+
// Assert: the existing lookup reads the current cache, not a startup snapshot.
1107+
assert!(lookup(DUTY_SLOT, &validator_x));
1108+
}
1109+
}

anchor/duties_tracker/src/duties_tracker.rs

Lines changed: 57 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,7 @@
1-
use std::{future::Future, sync::Arc};
1+
use std::{
2+
future::Future,
3+
sync::{Arc, OnceLock},
4+
};
25

36
use beacon_node_fallback::BeaconNodeFallback;
47
use bls::PublicKeyBytes;
@@ -21,6 +24,8 @@ use crate::{
2124
/// Only retain `HISTORICAL_DUTIES_EPOCHS` duties prior to the current epoch.
2225
const HISTORICAL_DUTIES_EPOCHS: u64 = 2;
2326

27+
type LocalProposerLookup = dyn Fn(Slot, &PublicKeyBytes) -> bool + Send + Sync;
28+
2429
#[derive(Error, Debug)]
2530
pub enum Error {
2631
#[error("Unable to read the slot clock")]
@@ -33,6 +38,8 @@ pub enum Error {
3338
FailedToPollPtc(String),
3439
#[error("Invalid PTC duties: {0}")]
3540
InvalidPtcDuties(#[from] PtcScheduleError),
41+
#[error("Local proposer lookup has already been configured")]
42+
LocalProposerLookupAlreadySet,
3643
}
3744

3845
pub struct DutiesTracker<T: SlotClock + 'static> {
@@ -50,6 +57,8 @@ pub struct DutiesTracker<T: SlotClock + 'static> {
5057
slot_clock: T,
5158
/// The network state receiver.
5259
network_state_rx: watch::Receiver<NetworkState>,
60+
/// The local duty producer is initialized after this tracker and the network receiver.
61+
local_proposer_lookup: OnceLock<Box<LocalProposerLookup>>,
5362
}
5463

5564
impl<T: SlotClock + 'static> DutiesTracker<T> {
@@ -69,9 +78,21 @@ impl<T: SlotClock + 'static> DutiesTracker<T> {
6978
slots_per_epoch,
7079
slot_clock,
7180
network_state_rx,
81+
local_proposer_lookup: OnceLock::new(),
7282
}
7383
}
7484

85+
/// Installs the local producer's live assignment lookup once during client initialization.
86+
/// The lookup runs synchronously during message validation and must only read cached duties.
87+
pub fn set_local_proposer_lookup(
88+
&self,
89+
lookup: impl Fn(Slot, &PublicKeyBytes) -> bool + Send + Sync + 'static,
90+
) -> Result<(), Error> {
91+
self.local_proposer_lookup
92+
.set(Box::new(lookup))
93+
.map_err(|_| Error::LocalProposerLookupAlreadySet)
94+
}
95+
7596
async fn poll_sync_committee_duties(&self) -> Result<(), Error> {
7697
let sync_duties = &self.duties.sync_duties;
7798
let spec = &self.spec;
@@ -445,6 +466,16 @@ impl<T: SlotClock + 'static> DutiesProvider for DutiesTracker<T> {
445466
}
446467
}
447468

469+
fn local_proposer_assignment_at_slot(
470+
&self,
471+
slot: Slot,
472+
validator_pubkey: &PublicKeyBytes,
473+
) -> bool {
474+
self.local_proposer_lookup
475+
.get()
476+
.is_some_and(|lookup| lookup(slot, validator_pubkey))
477+
}
478+
448479
fn ptc_assignment_at_slot(
449480
&self,
450481
slot: Slot,
@@ -529,6 +560,31 @@ mod tests {
529560
)
530561
}
531562

563+
#[test]
564+
fn test_local_proposer_lookup_is_optional_and_installed_only_once() {
565+
// Arrange: the tracker starts without a local producer lookup.
566+
let tracker = tracker_with_empty_network_state();
567+
let pubkey = random_validator_pubkey();
568+
let slot = Slot::new(SLOTS_PER_EPOCH);
569+
assert!(!tracker.local_proposer_assignment_at_slot(slot, &pubkey));
570+
571+
// Act: install exact positive evidence, then attempt to replace the configured source.
572+
tracker
573+
.set_local_proposer_lookup(move |candidate_slot, candidate_pubkey| {
574+
candidate_slot == slot && *candidate_pubkey == pubkey
575+
})
576+
.unwrap();
577+
let replacement = tracker.set_local_proposer_lookup(|_, _| false);
578+
579+
// Assert: duplicate initialization is visible and preserves the original live lookup.
580+
assert!(matches!(
581+
replacement,
582+
Err(Error::LocalProposerLookupAlreadySet)
583+
));
584+
assert!(tracker.local_proposer_assignment_at_slot(slot, &pubkey));
585+
assert!(!tracker.local_proposer_assignment_at_slot(slot + 1, &pubkey));
586+
}
587+
532588
/// A `BeaconNodeFallback` with a single candidate that is never actually contacted by these
533589
/// tests. It exists only to satisfy `DutiesTracker::new`.
534590
fn dummy_beacon_node_fallback(spec: Arc<ChainSpec>) -> BeaconNodeFallback<ManualSlotClock> {

anchor/duties_tracker/src/lib.rs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -285,6 +285,16 @@ pub trait DutiesProvider: Sync + Send + 'static {
285285
validator_pubkey: &PublicKeyBytes,
286286
) -> DutyAssignment;
287287

288+
/// Additional positive evidence from the local duty producer. This does not order the two
289+
/// proposer views or change the retained complete schedule's assignments.
290+
fn local_proposer_assignment_at_slot(
291+
&self,
292+
_slot: Slot,
293+
_validator_pubkey: &PublicKeyBytes,
294+
) -> bool {
295+
false
296+
}
297+
288298
/// Unknown unless a completed PTC fetch covered this validator in the slot's epoch.
289299
fn ptc_assignment_at_slot(&self, slot: Slot, validator_index: ValidatorIndex)
290300
-> DutyAssignment;

anchor/message_receiver/Cargo.toml

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,3 +20,20 @@ thiserror = { workspace = true }
2020
tokio = { workspace = true }
2121
tracing = { workspace = true }
2222
types = { workspace = true }
23+
24+
[dev-dependencies]
25+
axum = { workspace = true }
26+
beacon_node_fallback = { workspace = true }
27+
bls = { workspace = true }
28+
bls_lagrange = { workspace = true }
29+
database = { workspace = true, features = ["test-utils"] }
30+
duties_tracker = { workspace = true }
31+
eth2 = { workspace = true }
32+
ethereum_ssz = { workspace = true }
33+
fork = { workspace = true }
34+
message_sender = { workspace = true, features = ["testing"] }
35+
openssl = { workspace = true }
36+
sensitive_url = { workspace = true }
37+
subnet_service = { workspace = true }
38+
task_executor = { workspace = true }
39+
tokio = { workspace = true, features = ["test-util"] }

anchor/message_receiver/src/lib.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,3 +24,6 @@ pub enum Error {
2424
#[error("Processor error: {0}")]
2525
Processor(#[from] processor::Error),
2626
}
27+
28+
#[cfg(test)]
29+
mod proposer_view_tests;

0 commit comments

Comments
 (0)