1use std::{collections::HashMap, num::NonZero, str::FromStr};
7
8use anyhow::Context as _;
9use futures::stream::TryStreamExt as _;
10use linera_base::{
11 crypto::{AccountPublicKey, ValidatorPublicKey},
12 data_types::BlockHeight,
13 identifiers::ChainId,
14};
15use linera_client::{chain_listener::ClientContext as _, client_context::ClientContext};
16use linera_core::{
17 data_types::ClientOutcome,
18 node::{ValidatorNode, ValidatorNodeProvider},
19 Wallet as _,
20};
21use linera_execution::committee::{Committee, ValidatorState};
22use serde::{Deserialize, Serialize};
23
24use crate::cli::validator_benchmark::Benchmark;
25
26#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
30pub struct Votes(pub NonZero<u64>);
31
32impl Default for Votes {
33 fn default() -> Self {
34 Self(nonzero_lit::u64!(1))
35 }
36}
37
38impl FromStr for Votes {
39 type Err = <NonZero<u64> as FromStr>::Err;
40 fn from_str(s: &str) -> Result<Self, Self::Err> {
41 Ok(Votes(s.parse()?))
42 }
43}
44
45#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
47#[serde(rename_all = "camelCase")]
48pub struct Spec {
49 pub public_key: ValidatorPublicKey,
50 pub account_key: AccountPublicKey,
51 pub network_address: url::Url,
52 #[serde(default)]
53 pub votes: Votes,
54}
55
56#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
58#[serde(rename_all = "camelCase")]
59pub struct Change {
60 pub account_key: AccountPublicKey,
61 pub address: url::Url,
62 #[serde(default)]
63 pub votes: Votes,
64}
65
66pub type BatchFile = HashMap<ValidatorPublicKey, Option<Change>>;
72
73#[derive(Debug, Clone, Serialize, Deserialize)]
75pub struct QueryBatch {
76 pub validators: Vec<Spec>,
77}
78
79#[derive(Debug, Clone, clap::Subcommand)]
81pub enum Command {
82 Add(Add),
83 BatchQuery(BatchQuery),
84 Benchmark(Benchmark),
85 Update(Update),
86 List(List),
87 Query(Query),
88 QueryBlock(QueryBlock),
89 Remove(Remove),
90 Sync(Sync),
91}
92
93#[derive(Debug, Clone, clap::Parser)]
98pub struct Add {
99 #[arg(long)]
101 public_key: ValidatorPublicKey,
102 #[arg(long)]
104 account_key: AccountPublicKey,
105 #[arg(long)]
107 address: url::Url,
108 #[arg(long, required = false)]
110 votes: Votes,
111 #[arg(long)]
113 skip_online_check: bool,
114}
115
116#[derive(Debug, Clone, clap::Parser)]
121pub struct BatchQuery {
122 file: clio::Input,
124 #[arg(long)]
126 chain_id: Option<ChainId>,
127}
128
129#[derive(Debug, Clone, clap::Parser)]
138pub struct Update {
139 #[arg(required = false)]
141 file: clio::Input,
142 #[arg(long)]
144 dry_run: bool,
145 #[arg(long, short = 'y')]
147 yes: bool,
148 #[arg(long)]
150 skip_online_check: bool,
151}
152
153#[derive(Debug, Clone, clap::Parser)]
158pub struct List {
159 #[arg(long)]
161 chain_id: Option<ChainId>,
162 #[arg(long)]
164 min_votes: Option<u64>,
165}
166
167#[derive(Debug, Clone, clap::Parser)]
172pub struct Query {
173 address: String,
175 #[arg(long)]
177 chain_id: Option<ChainId>,
178 #[arg(long)]
180 public_key: Option<ValidatorPublicKey>,
181}
182
183#[derive(Debug, Clone, clap::Parser)]
188pub struct QueryBlock {
189 address: String,
191 #[arg(long)]
193 chain_id: Option<ChainId>,
194 #[arg(long)]
196 public_key: Option<ValidatorPublicKey>,
197 #[arg(long)]
199 height: BlockHeight,
200}
201
202#[derive(Debug, Clone, clap::Parser)]
207pub struct Remove {
208 #[arg(long)]
210 public_key: ValidatorPublicKey,
211}
212
213#[derive(Debug, Clone, clap::Parser)]
218pub struct Sync {
219 address: String,
221 #[arg(long)]
223 chains: Vec<ChainId>,
224 #[arg(long)]
226 check_online: bool,
227}
228
229fn parse_batch_file(input: clio::Input) -> anyhow::Result<BatchFile> {
232 Ok(serde_json::from_reader(input)?)
233}
234
235fn parse_query_batch_file(input: clio::Input) -> anyhow::Result<QueryBatch> {
237 Ok(serde_json::from_reader(input)?)
238}
239
240impl Command {
241 pub async fn run(
243 &self,
244 context: &mut ClientContext<
245 impl linera_core::Environment<ValidatorNode = linera_rpc::Client>,
246 >,
247 ) -> anyhow::Result<()> {
248 use Command::*;
249
250 match self {
251 Add(command) => command.run(context).await,
252 BatchQuery(command) => Box::pin(command.run(context)).await,
253 Benchmark(command) => Box::pin(command.run(context)).await,
254 Update(command) => command.run(context).await,
255 List(command) => command.run(context).await,
256 Query(command) => command.run(context).await,
257 QueryBlock(command) => command.run(context).await,
258 Remove(command) => command.run(context).await,
259 Sync(command) => Box::pin(command.run(context)).await,
260 }
261 }
262}
263
264impl Add {
265 async fn run(
266 &self,
267 context: &mut ClientContext<impl linera_core::Environment>,
268 ) -> anyhow::Result<()> {
269 tracing::info!("Starting operation to add validator");
270 let time_start = std::time::Instant::now();
271
272 if !self.skip_online_check {
274 let node = context
275 .make_node_provider()
276 .make_node(self.address.as_str())?;
277 context
278 .check_compatible_version_info(self.address.as_str(), &node)
279 .await?;
280 context
281 .check_matching_network_description(self.address.as_str(), &node)
282 .await?;
283 }
284
285 let admin_chain_id = context.admin_chain_id();
286 let chain_client = context.make_chain_client(admin_chain_id).await?;
287
288 chain_client.synchronize_chain_state(admin_chain_id).await?;
290
291 let maybe_certificate = context
292 .apply_client_command(&chain_client, |chain_client| {
293 let me = self.clone();
294 let chain_client = chain_client.clone();
295 async move {
296 let committee = chain_client.local_committee().await?;
298 let policy = committee.policy().clone();
299 let mut validators = committee.validators().clone();
300
301 validators.insert(
302 me.public_key,
303 ValidatorState {
304 network_address: me.address.to_string(),
305 votes: me.votes.0.get(),
306 account_public_key: me.account_key,
307 },
308 );
309
310 let new_committee = Committee::new(validators, policy)?;
311 chain_client
312 .stage_new_committee(new_committee)
313 .await
314 .map(|outcome| outcome.map(Some))
315 }
316 })
317 .await
318 .context("Failed to stage committee")?;
319
320 let Some(certificate) = maybe_certificate else {
321 return Ok(());
322 };
323 tracing::info!("Created new committee:\n{:?}", certificate);
324
325 let time_total = time_start.elapsed();
326 tracing::info!("Operation confirmed after {} ms", time_total.as_millis());
327
328 Ok(())
329 }
330}
331
332impl BatchQuery {
333 async fn run(
334 &self,
335 context: &ClientContext<impl linera_core::Environment>,
336 ) -> anyhow::Result<()> {
337 let batch = parse_query_batch_file(self.file.clone())
338 .context("parsing query batch file `{file}`")?;
339 let chain_id = self.chain_id.unwrap_or_else(|| context.default_chain());
340 println!(
341 "Querying {} validators about chain {chain_id}.\n",
342 batch.validators.len()
343 );
344
345 let node_provider = context.make_node_provider();
346 let mut has_errors = false;
347
348 for spec in batch.validators {
349 let node = node_provider.make_node(spec.network_address.as_str())?;
350 let results = context
351 .query_validator(
352 spec.network_address.as_str(),
353 &node,
354 chain_id,
355 Some(&spec.public_key),
356 )
357 .await;
358
359 if !results.errors().is_empty() {
360 has_errors = true;
361 for error in results.errors() {
362 tracing::error!("Validator {}: {}", spec.public_key, error);
363 }
364 }
365
366 results.print(
367 Some(&spec.public_key),
368 Some(spec.network_address.as_str()),
369 None,
370 None,
371 );
372 }
373
374 if has_errors {
375 anyhow::bail!("Found issues while querying validators");
376 }
377
378 Ok(())
379 }
380}
381
382impl Update {
383 async fn run(
384 &self,
385 context: &mut ClientContext<impl linera_core::Environment>,
386 ) -> anyhow::Result<()> {
387 tracing::info!("Starting batch update operation");
388 let time_start = std::time::Instant::now();
389
390 let batch = parse_batch_file(self.file.clone())
392 .with_context(|| format!("parsing batch file `{}`", self.file))?;
393
394 if batch.is_empty() {
395 tracing::warn!("No validator changes specified in input.");
396 return Ok(());
397 }
398
399 let mut adds = Vec::new();
401 let mut modifies = Vec::new();
402 let mut removes = Vec::new();
403
404 let admin_chain_id = context.client().admin_chain_id();
406 let chain_client = context.make_chain_client(admin_chain_id).await?;
407 let current_committee = chain_client.local_committee().await?;
408 let current_validators = current_committee.validators();
409
410 for (public_key, change_opt) in &batch {
411 match change_opt {
412 None => {
413 removes.push(*public_key);
415 }
416 Some(spec) => {
417 if current_validators.contains_key(public_key) {
418 modifies.push((public_key, spec));
419 } else {
420 adds.push((public_key, spec));
421 }
422 }
423 }
424 }
425
426 println!(
428 "\n╔══════════════════════════════════════════════════════════════════════════════╗"
429 );
430 println!(
431 "║ VALIDATOR BATCH UPDATE RECAP ║"
432 );
433 println!(
434 "╚══════════════════════════════════════════════════════════════════════════════╝\n"
435 );
436
437 println!("Summary:");
438 println!(" • {} validator(s) to add", adds.len());
439 println!(" • {} validator(s) to modify", modifies.len());
440 println!(" • {} validator(s) to remove", removes.len());
441 println!();
442
443 if !adds.is_empty() {
444 println!("Validators to ADD:");
445 for (pk, spec) in &adds {
446 println!(" + {pk}");
447 println!(" Address: {}", spec.address);
448 println!(" Account Key: {}", spec.account_key);
449 println!(" Votes: {}", spec.votes.0.get());
450 }
451 println!();
452 }
453
454 if !modifies.is_empty() {
455 println!("Validators to MODIFY:");
456 for (pk, spec) in &modifies {
457 println!(" * {pk}");
458 println!(" New Address: {}", spec.address);
459 println!(" New Account Key: {}", spec.account_key);
460 println!(" New Votes: {}", spec.votes.0.get());
461 }
462 println!();
463 }
464
465 if !removes.is_empty() {
466 println!("Validators to REMOVE:");
467 for pk in &removes {
468 println!(" - {pk}");
469 }
470 println!();
471 }
472
473 if self.dry_run {
474 println!(
475 "═════════════════════════════════════════════════════════════════════════════"
476 );
477 println!("DRY RUN MODE: No changes will be applied");
478 println!(
479 "═════════════════════════════════════════════════════════════════════════════\n"
480 );
481 return Ok(());
482 }
483
484 if !self.yes {
486 println!(
487 "═════════════════════════════════════════════════════════════════════════════"
488 );
489 println!("⚠️ WARNING: This operation will modify the validator committee.");
490 println!(" Changes are permanent and will be broadcast to the network.");
491 println!(
492 "═════════════════════════════════════════════════════════════════════════════\n"
493 );
494 println!("Do you want to proceed? Type 'YES' (uppercase) to confirm: ");
495
496 use std::io::{self, Write};
497 io::stdout().flush()?;
498
499 let mut input = String::new();
500 io::stdin()
501 .read_line(&mut input)
502 .context("Failed to read confirmation input")?;
503
504 let input = input.trim();
505 if input != "YES" {
506 println!("\nOperation cancelled. (Expected 'YES', got '{input}')");
507 return Ok(());
508 }
509 println!("\nConfirmed. Proceeding with batch update...\n");
510 }
511
512 if !self.skip_online_check {
514 let node_provider = context.make_node_provider();
515
516 tracing::info!("Checking validators are online...");
517 for (_, spec) in adds.iter().chain(modifies.iter()) {
518 let address = &spec.address;
519 let node = node_provider.make_node(address.as_str())?;
520 context
521 .check_compatible_version_info(address.as_str(), &node)
522 .await?;
523 context
524 .check_matching_network_description(address.as_str(), &node)
525 .await?;
526 }
527 }
528
529 let admin_chain_id = context.admin_chain_id();
530 let chain_client = context.make_chain_client(admin_chain_id).await?;
531
532 chain_client.synchronize_chain_state(admin_chain_id).await?;
534
535 let batch_clone = batch.clone();
536 let maybe_certificate = context
537 .apply_client_command(&chain_client, |chain_client| {
538 let chain_client = chain_client.clone();
539 let batch = batch_clone.clone();
540 async move {
541 let committee = chain_client.local_committee().await?;
543 let policy = committee.policy().clone();
544 let mut validators = committee.validators().clone();
545
546 for (public_key, change_opt) in &batch {
548 if let Some(spec) = change_opt {
549 let address = &spec.address;
551 let votes = spec.votes.0.get();
552 let account_key = spec.account_key;
553
554 let exists = validators.contains_key(public_key);
555 validators.insert(
556 *public_key,
557 ValidatorState {
558 network_address: address.to_string(),
559 votes,
560 account_public_key: account_key,
561 },
562 );
563
564 if exists {
565 tracing::info!(
566 "Modified validator {} @ {} ({} votes)",
567 public_key,
568 address,
569 votes
570 );
571 } else {
572 tracing::info!(
573 "Added validator {} @ {} ({} votes)",
574 public_key,
575 address,
576 votes
577 );
578 }
579 } else {
580 if validators.remove(public_key).is_none() {
582 tracing::warn!(
583 "Validator {} does not exist; skipping remove",
584 public_key
585 );
586 } else {
587 tracing::info!("Removed validator {}", public_key);
588 }
589 }
590 }
591
592 let new_committee = Committee::new(validators, policy)?;
594 chain_client
595 .stage_new_committee(new_committee)
596 .await
597 .map(|outcome| outcome.map(Some))
598 }
599 })
600 .await
601 .context("Failed to stage committee")?;
602
603 let Some(certificate) = maybe_certificate else {
604 tracing::info!("No changes applied");
605 return Ok(());
606 };
607
608 tracing::info!("Created new committee:\n{:?}", certificate);
609 let time_total = time_start.elapsed();
610 tracing::info!("Batch update confirmed after {} ms", time_total.as_millis());
611
612 Ok(())
613 }
614}
615
616impl List {
617 async fn run(
618 &self,
619 context: &ClientContext<impl linera_core::Environment>,
620 ) -> anyhow::Result<()> {
621 let chain_id = self.chain_id.unwrap_or_else(|| context.default_chain());
622 println!("Querying validators about chain {chain_id}.\n");
623
624 let local_results = context.query_local_node(chain_id).await?;
625 let chain_client = context.make_chain_client(chain_id).await?;
626 tracing::info!("Querying validators about chain {}", chain_id);
627 let result = chain_client.local_committee().await;
628 context.update_wallet_from_client(&chain_client).await?;
629 let committee = result.context("Failed to get local committee")?;
630
631 tracing::info!(
632 "Using the local set of validators: {:?}",
633 committee.validators()
634 );
635
636 let node_provider = context.make_node_provider();
637 let mut validator_results = Vec::new();
638
639 for (name, state) in committee.validators() {
640 if self.min_votes.is_some_and(|votes| state.votes < votes) {
641 continue; }
643 let address = &state.network_address;
644 let node = node_provider.make_node(address)?;
645 let results = context
646 .query_validator(address, &node, chain_id, Some(name))
647 .await;
648 validator_results.push((name, address, state.votes, results));
649 }
650
651 let mut faulty_validators = std::collections::BTreeMap::<_, Vec<_>>::new();
652 for (name, address, _votes, results) in &validator_results {
653 for error in results.errors() {
654 tracing::error!("{}", error);
655 faulty_validators
656 .entry((*name, *address))
657 .or_default()
658 .push(error);
659 }
660 }
661
662 println!("Local Node:");
664 local_results.print(None, None, None, None);
665
666 for (name, address, votes, results) in &validator_results {
668 results.print(
669 Some(name),
670 Some(address),
671 Some(*votes),
672 Some(&local_results),
673 );
674 }
675
676 if !faulty_validators.is_empty() {
677 println!("\nFaulty validators:");
678 for ((name, address), errors) in faulty_validators {
679 println!(" {} at {}: {} error(s)", name, address, errors.len());
680 }
681 anyhow::bail!("Found faulty validators");
682 }
683
684 Ok(())
685 }
686}
687
688impl Query {
689 async fn run(
690 &self,
691 context: &ClientContext<impl linera_core::Environment>,
692 ) -> anyhow::Result<()> {
693 let node = context.make_node_provider().make_node(&self.address)?;
694 let chain_id = self.chain_id.unwrap_or_else(|| context.default_chain());
695 println!("Querying validator about chain {chain_id}.\n");
696
697 let results = context
698 .query_validator(&self.address, &node, chain_id, self.public_key.as_ref())
699 .await;
700
701 for error in results.errors() {
702 tracing::error!("{}", error);
703 }
704
705 results.print(self.public_key.as_ref(), Some(&self.address), None, None);
706
707 if !results.errors().is_empty() {
708 anyhow::bail!(
709 "Found one or several issue(s) while querying validator {}",
710 self.address
711 );
712 }
713
714 Ok(())
715 }
716}
717
718impl QueryBlock {
719 async fn run(
720 &self,
721 context: &ClientContext<impl linera_core::Environment>,
722 ) -> anyhow::Result<()> {
723 let node = context.make_node_provider().make_node(&self.address)?;
724 let chain_id = self.chain_id.unwrap_or_else(|| context.default_chain());
725 let height = self.height;
726 println!(
727 "Querying validator about the certificate for height {height} on the chain \
728 {chain_id}.\n"
729 );
730
731 let result = node
732 .download_certificates_by_heights(chain_id, vec![height])
733 .await;
734
735 match result {
736 Ok(certificates) => {
737 let confirmed_block = certificates[0].inner();
738 println!("{confirmed_block:#?}");
739 }
740 Err(error) => {
741 tracing::error!("{}", error);
742 }
743 }
744
745 Ok(())
746 }
747}
748
749impl Remove {
750 async fn run(
751 &self,
752 context: &mut ClientContext<impl linera_core::Environment>,
753 ) -> anyhow::Result<()> {
754 tracing::info!("Starting operation to remove validator");
755 let time_start = std::time::Instant::now();
756
757 let admin_chain_id = context.admin_chain_id();
758 let chain_client = context.make_chain_client(admin_chain_id).await?;
759
760 chain_client.synchronize_chain_state(admin_chain_id).await?;
762
763 let maybe_certificate = context
764 .apply_client_command(&chain_client, |chain_client| {
765 let chain_client = chain_client.clone();
766 async move {
767 let committee = chain_client.local_committee().await?;
769 let policy = committee.policy().clone();
770 let mut validators = committee.validators().clone();
771
772 if validators.remove(&self.public_key).is_none() {
773 tracing::error!("Validator {} does not exist; aborting.", self.public_key);
774 return Ok(ClientOutcome::Committed(None));
775 }
776
777 let new_committee = Committee::new(validators, policy)?;
778 chain_client
779 .stage_new_committee(new_committee)
780 .await
781 .map(|outcome| outcome.map(Some))
782 }
783 })
784 .await
785 .context("Failed to stage committee")?;
786
787 let Some(certificate) = maybe_certificate else {
788 return Ok(());
789 };
790 tracing::info!("Created new committee:\n{:?}", certificate);
791
792 let time_total = time_start.elapsed();
793 tracing::info!("Operation confirmed after {} ms", time_total.as_millis());
794
795 Ok(())
796 }
797}
798
799impl Sync {
800 async fn run(
801 &self,
802 context: &ClientContext<impl linera_core::Environment<ValidatorNode = linera_rpc::Client>>,
803 ) -> anyhow::Result<()> {
804 tracing::info!("Starting sync operation for validator at {}", self.address);
805
806 if self.check_online {
808 let node_provider = context.make_node_provider();
809 let node = node_provider.make_node(&self.address)?;
810 context
811 .check_compatible_version_info(&self.address, &node)
812 .await?;
813 context
814 .check_matching_network_description(&self.address, &node)
815 .await?;
816 }
817
818 let chains_to_sync = if self.chains.is_empty() {
820 context.wallet().chain_ids().try_collect().await?
821 } else {
822 self.chains.clone()
823 };
824
825 tracing::info!(
826 "Syncing {} chains to validator {}",
827 chains_to_sync.len(),
828 self.address
829 );
830
831 let node_provider = context.make_node_provider();
833 let validator = node_provider.make_node(&self.address)?;
834
835 for chain_id in chains_to_sync {
837 tracing::info!("Syncing chain {} to {}", chain_id, self.address);
838 let chain = context.make_chain_client(chain_id).await?;
839
840 Box::pin(chain.sync_validator(validator.clone())).await?;
841 tracing::info!("Chain {} synced successfully", chain_id);
842 }
843
844 tracing::info!("Sync operation completed successfully");
845 Ok(())
846 }
847}
848
849#[cfg(test)]
850mod tests {
851 use std::io::Write;
852
853 use tempfile::NamedTempFile;
854
855 use super::*;
856
857 #[test]
858 fn test_parse_batch_file_valid() {
859 let pk0 = ValidatorPublicKey::test_key(0);
861 let pk1 = ValidatorPublicKey::test_key(1);
862 let pk2 = ValidatorPublicKey::test_key(2);
863
864 let mut batch = BatchFile::new();
865
866 batch.insert(
868 pk0,
869 Some(Change {
870 account_key: AccountPublicKey::test_key(0),
871 address: "grpcs://validator1.example.com:443".parse().unwrap(),
872 votes: Votes(NonZero::new(100).unwrap()),
873 }),
874 );
875
876 batch.insert(
878 pk1,
879 Some(Change {
880 account_key: AccountPublicKey::test_key(1),
881 address: "grpcs://validator2.example.com:443".parse().unwrap(),
882 votes: Votes(NonZero::new(150).unwrap()),
883 }),
884 );
885
886 batch.insert(pk2, None);
888
889 let json = serde_json::to_string(&batch).unwrap();
890
891 let mut temp_file = NamedTempFile::new().unwrap();
892 temp_file.write_all(json.as_bytes()).unwrap();
893 temp_file.flush().unwrap();
894
895 let input = clio::Input::new(temp_file.path().to_str().unwrap()).unwrap();
896 let result = parse_batch_file(input);
897 assert!(
898 result.is_ok(),
899 "Failed to parse batch file: {:?}",
900 result.err()
901 );
902
903 let parsed_batch = result.unwrap();
904 assert_eq!(parsed_batch.len(), 3);
905
906 assert!(parsed_batch.contains_key(&pk0));
908 let spec0 = parsed_batch.get(&pk0).unwrap().as_ref().unwrap();
909 assert_eq!(spec0.votes.0.get(), 100);
910
911 assert!(parsed_batch.contains_key(&pk1));
913 let spec1 = parsed_batch.get(&pk1).unwrap().as_ref().unwrap();
914 assert_eq!(spec1.votes.0.get(), 150);
915
916 assert!(parsed_batch.contains_key(&pk2));
918 assert!(parsed_batch.get(&pk2).unwrap().is_none());
919 }
920
921 #[test]
922 fn test_parse_batch_file_empty() {
923 let json = r#"{}"#;
924
925 let mut temp_file = NamedTempFile::new().unwrap();
926 temp_file.write_all(json.as_bytes()).unwrap();
927 temp_file.flush().unwrap();
928
929 let input = clio::Input::new(temp_file.path().to_str().unwrap()).unwrap();
930 let result = parse_batch_file(input);
931 assert!(result.is_ok());
932
933 let batch = result.unwrap();
934 assert_eq!(batch.len(), 0);
935 }
936
937 #[test]
938 fn test_parse_query_batch_file_valid() {
939 let spec1 = Spec {
941 public_key: ValidatorPublicKey::test_key(0),
942 account_key: AccountPublicKey::test_key(0),
943 network_address: "grpcs://validator1.example.com:443".parse().unwrap(),
944 votes: Votes(NonZero::new(100).unwrap()),
945 };
946 let spec2 = Spec {
947 public_key: ValidatorPublicKey::test_key(1),
948 account_key: AccountPublicKey::test_key(1),
949 network_address: "grpcs://validator2.example.com:443".parse().unwrap(),
950 votes: Votes(NonZero::new(150).unwrap()),
951 };
952
953 let batch = QueryBatch {
954 validators: vec![spec1, spec2],
955 };
956
957 let json = serde_json::to_string(&batch).unwrap();
958
959 let mut temp_file = NamedTempFile::new().unwrap();
960 temp_file.write_all(json.as_bytes()).unwrap();
961 temp_file.flush().unwrap();
962
963 let result = parse_query_batch_file(temp_file.path().try_into().unwrap());
964 assert!(
965 result.is_ok(),
966 "Failed to parse query batch file: {:?}",
967 result.err()
968 );
969
970 let parsed_batch = result.unwrap();
971 assert_eq!(parsed_batch.validators.len(), 2);
972 assert_eq!(parsed_batch.validators[0].votes.0.get(), 100);
973 assert_eq!(parsed_batch.validators[1].votes.0.get(), 150);
974 }
975}