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