@@ -77,9 +77,13 @@ struct CliOptions {
7777 /// use the admin endpoint to rotate duties (hot-standby model).
7878 #[ arg( long, default_value = "false" ) ]
7979 is_aggregator : bool ,
80- /// Number of attestation committees (subnets) per slot
81- #[ arg( long, default_value = "1" , value_parser = clap:: value_parser!( u64 ) . range( 1 ..) ) ]
82- attestation_committee_count : u64 ,
80+ /// Number of attestation committees (subnets) per slot.
81+ ///
82+ /// If unset, falls back to `config.attestation_committee_count` from
83+ /// `validator-config.yaml` in the network config dir, or `1` if that
84+ /// field is also absent.
85+ #[ arg( long, value_parser = clap:: value_parser!( u64 ) . range( 1 ..) ) ]
86+ attestation_committee_count : Option < u64 > ,
8387 /// Subnet IDs this aggregator should subscribe to (comma-separated).
8488 /// Requires --is-aggregator. Defaults to the subnets of the node's validators.
8589 #[ arg( long, value_delimiter = ',' , requires = "is_aggregator" ) ]
@@ -103,9 +107,6 @@ async fn main() -> eyre::Result<()> {
103107 ethlambda_blockchain:: metrics:: init ( ) ;
104108 ethlambda_blockchain:: metrics:: set_node_info ( "ethlambda" , version:: CLIENT_VERSION ) ;
105109 ethlambda_blockchain:: metrics:: set_node_start_time ( ) ;
106- ethlambda_blockchain:: metrics:: set_attestation_committee_count (
107- options. attestation_committee_count ,
108- ) ;
109110
110111 let api_socket = SocketAddr :: new ( options. http_address , options. api_port ) ;
111112 let metrics_socket = SocketAddr :: new ( options. http_address , options. metrics_port ) ;
@@ -142,7 +143,26 @@ async fn main() -> eyre::Result<()> {
142143 "Loaded genesis configuration"
143144 ) ;
144145
145- populate_name_registry ( & validator_config) ;
146+ let validator_config_file = read_validator_config_file ( & validator_config) ;
147+ populate_name_registry ( & validator_config_file) ;
148+
149+ // Resolve attestation_committee_count: CLI flag > validator-config.yaml > 1.
150+ // The CLI path is bounded by clap's `range(1..)`; enforce the same lower
151+ // bound here so a YAML value of 0 cannot bypass it.
152+ let attestation_committee_count = options
153+ . attestation_committee_count
154+ . or ( validator_config_file. config . attestation_committee_count )
155+ . unwrap_or ( 1 ) ;
156+ eyre:: ensure!(
157+ attestation_committee_count >= 1 ,
158+ "attestation_committee_count must be >= 1 (got {attestation_committee_count})"
159+ ) ;
160+ info ! (
161+ attestation_committee_count,
162+ "Loaded attestation committee count"
163+ ) ;
164+ ethlambda_blockchain:: metrics:: set_attestation_committee_count ( attestation_committee_count) ;
165+
146166 let bootnodes = read_bootnodes ( & bootnodes_path) ;
147167
148168 let validator_keys =
@@ -182,7 +202,7 @@ async fn main() -> eyre::Result<()> {
182202 bootnodes,
183203 listening_socket : p2p_socket,
184204 validator_ids,
185- attestation_committee_count : options . attestation_committee_count ,
205+ attestation_committee_count,
186206 is_aggregator : options. is_aggregator ,
187207 aggregate_subnet_ids : options. aggregate_subnet_ids ,
188208 } )
@@ -258,25 +278,40 @@ async fn main() -> eyre::Result<()> {
258278 Ok ( ( ) )
259279}
260280
261- fn populate_name_registry ( validator_config : impl AsRef < Path > ) {
262- #[ derive( Deserialize ) ]
263- struct Validator {
264- name : String ,
265- privkey : H256 ,
266- }
267- #[ derive( Deserialize ) ]
268- struct Config {
269- validators : Vec < Validator > ,
270- }
271- let config_yaml =
272- std:: fs:: read_to_string ( & validator_config) . expect ( "Failed to read validator config file" ) ;
273- let config: Config =
274- serde_yaml_ng:: from_str ( & config_yaml) . expect ( "Failed to parse validator config file" ) ;
281+ /// Subset of `validator-config.yaml` consumed by ethlambda.
282+ ///
283+ /// The `config` block is a network-wide settings bag shared across clients;
284+ /// only fields ethlambda actually reads are deserialized. The `validators`
285+ /// list feeds the metrics name registry.
286+ #[ derive( Debug , Deserialize ) ]
287+ struct ValidatorConfigFile {
288+ #[ serde( default ) ]
289+ config : ValidatorConfigBlock ,
290+ validators : Vec < ValidatorConfigEntry > ,
291+ }
292+
293+ #[ derive( Debug , Default , Deserialize ) ]
294+ struct ValidatorConfigBlock {
295+ #[ serde( default ) ]
296+ attestation_committee_count : Option < u64 > ,
297+ }
298+
299+ #[ derive( Debug , Deserialize ) ]
300+ struct ValidatorConfigEntry {
301+ name : String ,
302+ privkey : H256 ,
303+ }
304+
305+ fn read_validator_config_file ( path : impl AsRef < Path > ) -> ValidatorConfigFile {
306+ let yaml = std:: fs:: read_to_string ( & path) . expect ( "Failed to read validator config file" ) ;
307+ serde_yaml_ng:: from_str ( & yaml) . expect ( "Failed to parse validator config file" )
308+ }
275309
276- let names_and_privkeys = config
310+ fn populate_name_registry ( file : & ValidatorConfigFile ) {
311+ let names_and_privkeys = file
277312 . validators
278- . into_iter ( )
279- . map ( |v| ( v. name , v. privkey ) )
313+ . iter ( )
314+ . map ( |v| ( v. name . clone ( ) , v. privkey ) )
280315 . collect ( ) ;
281316
282317 // Populates a dictionary used for labeling metrics with node names
@@ -513,3 +548,96 @@ async fn fetch_initial_state(
513548 // Store the anchor state and header, without body
514549 Ok ( Store :: from_anchor_state ( backend, state) )
515550}
551+
552+ #[ cfg( test) ]
553+ mod tests {
554+ use super :: * ;
555+
556+ /// Validator-config snippet matching `lean-quickstart`'s ansible-devnet
557+ /// (devnet-4) where networks share a non-default committee count.
558+ const VC_WITH_COMMITTEE_COUNT : & str = r#"
559+ shuffle: roundrobin
560+ deployment_mode: ansible
561+ config:
562+ activeEpoch: 18
563+ keyType: "hash-sig"
564+ attestation_committee_count: 2
565+ validators:
566+ - name: "ethlambda_0"
567+ privkey: "299550529a79bc2dce003747c52fb0639465c893e00b0440ac66144d625e066a"
568+ enrFields:
569+ ip: "127.0.0.1"
570+ quic: 9001
571+ metricsPort: 9095
572+ apiPort: 5055
573+ subnet: 0
574+ isAggregator: false
575+ count: 1
576+ "# ;
577+
578+ /// Local-devnet snippet without the optional field — committee count is
579+ /// expected to fall back to the binary default.
580+ const VC_WITHOUT_COMMITTEE_COUNT : & str = r#"
581+ shuffle: roundrobin
582+ deployment_mode: local
583+ config:
584+ activeEpoch: 18
585+ keyType: "hash-sig"
586+ validators:
587+ - name: "ethlambda_0"
588+ privkey: "299550529a79bc2dce003747c52fb0639465c893e00b0440ac66144d625e066a"
589+ enrFields:
590+ ip: "127.0.0.1"
591+ quic: 9001
592+ metricsPort: 8087
593+ apiPort: 5055
594+ isAggregator: false
595+ count: 1
596+ "# ;
597+
598+ #[ test]
599+ fn parses_committee_count_when_present ( ) {
600+ let file: ValidatorConfigFile = serde_yaml_ng:: from_str ( VC_WITH_COMMITTEE_COUNT ) . unwrap ( ) ;
601+ assert_eq ! ( file. config. attestation_committee_count, Some ( 2 ) ) ;
602+ assert_eq ! ( file. validators. len( ) , 1 ) ;
603+ assert_eq ! ( file. validators[ 0 ] . name, "ethlambda_0" ) ;
604+ }
605+
606+ #[ test]
607+ fn defaults_to_none_when_field_absent ( ) {
608+ let file: ValidatorConfigFile =
609+ serde_yaml_ng:: from_str ( VC_WITHOUT_COMMITTEE_COUNT ) . unwrap ( ) ;
610+ assert_eq ! ( file. config. attestation_committee_count, None ) ;
611+ }
612+
613+ #[ test]
614+ fn cli_overrides_file_value ( ) {
615+ let file: ValidatorConfigFile = serde_yaml_ng:: from_str ( VC_WITH_COMMITTEE_COUNT ) . unwrap ( ) ;
616+ let cli_override: Option < u64 > = Some ( 5 ) ;
617+ let resolved = cli_override
618+ . or ( file. config . attestation_committee_count )
619+ . unwrap_or ( 1 ) ;
620+ assert_eq ! ( resolved, 5 ) ;
621+ }
622+
623+ #[ test]
624+ fn falls_back_to_file_when_cli_absent ( ) {
625+ let file: ValidatorConfigFile = serde_yaml_ng:: from_str ( VC_WITH_COMMITTEE_COUNT ) . unwrap ( ) ;
626+ let cli_override: Option < u64 > = None ;
627+ let resolved = cli_override
628+ . or ( file. config . attestation_committee_count )
629+ . unwrap_or ( 1 ) ;
630+ assert_eq ! ( resolved, 2 ) ;
631+ }
632+
633+ #[ test]
634+ fn falls_back_to_default_when_neither_set ( ) {
635+ let file: ValidatorConfigFile =
636+ serde_yaml_ng:: from_str ( VC_WITHOUT_COMMITTEE_COUNT ) . unwrap ( ) ;
637+ let cli_override: Option < u64 > = None ;
638+ let resolved = cli_override
639+ . or ( file. config . attestation_committee_count )
640+ . unwrap_or ( 1 ) ;
641+ assert_eq ! ( resolved, 1 ) ;
642+ }
643+ }
0 commit comments