Skip to main content

linera_service/cli/
validator.rs

1// Copyright (c) Zefchain Labs, Inc.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Validator management commands.
5
6use 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/// Type alias for the complex ClientContext type used throughout validator operations.
27/// This alias helps avoid clippy's type_complexity warnings while maintaining type safety.
28/// Uses generic Environment trait to avoid coupling to implementation details.
29#[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/// Specification for a validator to add or modify.
46#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
47#[serde(rename_all = "camelCase")]
48pub struct Spec {
49    /// The validator's public key, identifying it on the network.
50    pub public_key: ValidatorPublicKey,
51    /// The public key of the validator's chain account.
52    pub account_key: AccountPublicKey,
53    /// The network address at which the validator can be reached.
54    pub network_address: url::Url,
55    /// The voting weight assigned to the validator.
56    #[serde(default)]
57    pub votes: Votes,
58}
59
60/// Represents an update to a validator's configuration in batch operations.
61#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
62#[serde(rename_all = "camelCase")]
63pub struct Change {
64    /// The new public key for the validator's chain account.
65    pub account_key: AccountPublicKey,
66    /// The new network address at which the validator can be reached.
67    pub address: url::Url,
68    /// The new voting weight assigned to the validator.
69    #[serde(default)]
70    pub votes: Votes,
71}
72
73/// Structure for batch validator operations from JSON file.
74/// Maps validator public keys to their desired state:
75/// - `null` means remove the validator
76/// - `{accountKey, address, votes}` means add or modify the validator
77/// - Keys not present in the map are left unchanged
78pub type BatchFile = HashMap<ValidatorPublicKey, Option<Change>>;
79
80/// Structure for batch validator queries from JSON file.
81#[derive(Debug, Clone, Serialize, Deserialize)]
82pub struct QueryBatch {
83    /// The validators to query.
84    pub validators: Vec<Spec>,
85}
86
87/// Validator subcommands.
88// Each variant delegates to a documented args struct; giving the variant its own
89// doc comment would shadow that struct's richer `--help` text, so `missing_docs`
90// is allowed here rather than duplicating those docs.
91#[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/// Add a validator to the committee.
106///
107/// Adds a new validator with the specified public key, account key, network address,
108/// and voting weight. The validator must not already exist in the committee.
109#[derive(Debug, Clone, clap::Parser)]
110pub struct Add {
111    /// Public key of the validator to add
112    #[arg(long)]
113    public_key: ValidatorPublicKey,
114    /// Account public key for receiving payments and rewards
115    #[arg(long)]
116    account_key: AccountPublicKey,
117    /// Network address where the validator can be reached (e.g., grpcs:host:port)
118    #[arg(long)]
119    address: url::Url,
120    /// Voting weight for consensus (default: 1)
121    #[arg(long, required = false)]
122    votes: Votes,
123    /// Skip online connectivity verification before adding
124    #[arg(long)]
125    skip_online_check: bool,
126}
127
128/// Query multiple validators using a JSON specification file.
129///
130/// Reads validator specifications from a JSON file and queries their state.
131/// The JSON should contain an array of validator objects with publicKey and networkAddress.
132#[derive(Debug, Clone, clap::Parser)]
133pub struct BatchQuery {
134    /// Path to JSON file containing validator query specifications
135    file: clio::Input,
136    /// Chain ID to query (defaults to default chain)
137    #[arg(long)]
138    chain_id: Option<ChainId>,
139}
140
141/// Apply multiple validator changes from JSON input.
142///
143/// Reads a JSON object mapping validator public keys to their desired state:
144/// - Key with state object (address, votes, accountKey): add or modify validator
145/// - Key with null: remove validator
146/// - Keys not present: unchanged
147///
148/// Input can be provided via file path, stdin pipe, or shell redirect.
149#[derive(Debug, Clone, clap::Parser)]
150pub struct Update {
151    /// Path to JSON file with validator changes (omit or use "-" for stdin)
152    #[arg(required = false)]
153    file: clio::Input,
154    /// Preview changes without applying them
155    #[arg(long)]
156    dry_run: bool,
157    /// Skip confirmation prompt (use with caution)
158    #[arg(long, short = 'y')]
159    yes: bool,
160    /// Skip online connectivity checks for validators being added or modified
161    #[arg(long)]
162    skip_online_check: bool,
163}
164
165/// List all validators in the committee.
166///
167/// Displays the current validator set with their network addresses, voting weights,
168/// and connection status. Optionally filter by minimum voting weight.
169#[derive(Debug, Clone, clap::Parser)]
170pub struct List {
171    /// Chain ID to query (defaults to default chain)
172    #[arg(long)]
173    chain_id: Option<ChainId>,
174    /// Only show validators with at least this many votes
175    #[arg(long)]
176    min_votes: Option<u64>,
177}
178
179/// Query a single validator's state and connectivity.
180///
181/// Connects to a validator at the specified network address and queries its
182/// view of the blockchain state, including block height and committee information.
183#[derive(Debug, Clone, clap::Parser)]
184pub struct Query {
185    /// Network address of the validator (e.g., grpcs:host:port)
186    address: String,
187    /// Chain ID to query about (defaults to default chain)
188    #[arg(long)]
189    chain_id: Option<ChainId>,
190    /// Expected public key of the validator (for verification)
191    #[arg(long)]
192    public_key: Option<ValidatorPublicKey>,
193}
194
195/// Query a single validator for a block at a particular chain and height.
196///
197/// Connects to a validator at the specified network address and queries its
198/// view of the blockchain.
199#[derive(Debug, Clone, clap::Parser)]
200pub struct QueryBlock {
201    /// Network address of the validator (e.g., grpcs:host:port)
202    address: String,
203    /// Chain ID to query about (defaults to default chain)
204    #[arg(long)]
205    chain_id: Option<ChainId>,
206    /// Expected public key of the validator (for verification)
207    #[arg(long)]
208    public_key: Option<ValidatorPublicKey>,
209    /// Block height to query about
210    #[arg(long)]
211    height: BlockHeight,
212}
213
214/// Remove a validator from the committee.
215///
216/// Removes the validator with the specified public key from the committee.
217/// The validator will no longer participate in consensus.
218#[derive(Debug, Clone, clap::Parser)]
219pub struct Remove {
220    /// Public key of the validator to remove
221    #[arg(long)]
222    public_key: ValidatorPublicKey,
223}
224
225/// Synchronize chain state to a validator.
226///
227/// Pushes the current chain state from local storage to a validator node,
228/// ensuring the validator has up-to-date information about specified chains.
229#[derive(Debug, Clone, clap::Parser)]
230pub struct Sync {
231    /// Network address of the validator to sync (e.g., grpcs:host:port)
232    address: String,
233    /// Chain IDs to synchronize (defaults to all chains in wallet)
234    #[arg(long)]
235    chains: Vec<ChainId>,
236    /// Verify validator is online before syncing
237    #[arg(long)]
238    check_online: bool,
239}
240
241/// Parse a batch operations file or stdin.
242/// Reads from the provided clio::Input, which handles both files and stdin transparently.
243fn parse_batch_file(input: clio::Input) -> anyhow::Result<BatchFile> {
244    Ok(serde_json::from_reader(input)?)
245}
246
247/// Parse a validator query batch file.
248fn parse_query_batch_file(input: clio::Input) -> anyhow::Result<QueryBatch> {
249    Ok(serde_json::from_reader(input)?)
250}
251
252impl Command {
253    /// Main entry point for handling validator commands.
254    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        // Check validator is online if requested
285        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        // Synchronize the chain state
301        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                    // Create the new committee.
309                    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        // Parse the batch file or stdin
403        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        // Separate operations by type for logging and validation
412        let mut adds = Vec::new();
413        let mut modifies = Vec::new();
414        let mut removes = Vec::new();
415
416        // Get current committee to determine if operation is add or modify
417        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                    // null = removal
426                    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        // Display recap of changes
439        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        // Confirmation prompt (unless --yes flag is set)
497        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        // Check all validators are online if requested
525        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        // Synchronize the chain state
545        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                    // Get current committee
554                    let committee = chain_client.local_committee().await?;
555                    let policy = committee.policy().clone();
556                    let mut validators = committee.validators().clone();
557
558                    // Apply operations based on the batch specification
559                    for (public_key, change_opt) in &batch {
560                        if let Some(spec) = change_opt {
561                            // Update object - add or modify validator
562                            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                            // null - remove validator
593                            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                    // Create new committee
605                    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; // Skip validator with little voting weight.
654            }
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        // Print local node results first (everything)
675        println!("Local Node:");
676        local_results.print(None, None, None, None);
677
678        // Print validator results (only differences from local node)
679        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        // Synchronize the chain state
773        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                    // Create the new committee.
780                    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        // Check validator is online if requested
819        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        // If no chains specified, use all chains from wallet
831        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        // Create validator node
844        let node_provider = context.make_node_provider();
845        let validator = node_provider.make_node(&self.address)?;
846
847        // Sync each chain
848        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        // Generate correct JSON format using test keys
872        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        // Add operation - validator with full spec
879        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        // Modify operation - validator with full spec (would be modify if validator exists)
889        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        // Remove operation - null
899        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        // Check pk0 (add)
919        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        // Check pk1 (modify)
924        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        // Check pk2 (remove with null)
929        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        // Generate correct JSON format using test keys
952        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}