@@ -4,6 +4,7 @@ mod metrics;
44mod notifier;
55
66use 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 ;
2426use config:: Config ;
2527use database:: { NetworkDatabase , OwnOperatorId } ;
2628use duties_tracker:: { duties_tracker:: DutiesTracker , voluntary_exit_tracker:: VoluntaryExitTracker } ;
2729use 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 } ;
3133use message_receiver:: NetworkMessageReceiver ;
3234use message_sender:: { MessageSender , NetworkMessageSender , impostor:: ImpostorMessageSender } ;
3335use message_validator:: Validator ;
@@ -53,7 +55,7 @@ use tokio::{
5355 time:: { Instant , interval, sleep} ,
5456} ;
5557use tracing:: { debug, error, info, warn} ;
56- use types:: { EthSpec , Hash256 } ;
58+ use types:: { Epoch , EthSpec , Hash256 , Slot } ;
5759use validator_metrics:: set_gauge;
5860use validator_services:: {
5961 attestation_service:: AttestationServiceBuilder ,
@@ -91,6 +93,21 @@ const MAX_HEAD_EVENT_QUEUE_LEN: usize = 1_024;
9193
9294pub 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+
94111impl 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+ }
0 commit comments