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    /// Public key identifying the validator.
50    pub public_key: ValidatorPublicKey,
51    /// Account public key for receiving payments and rewards.
52    pub account_key: AccountPublicKey,
53    /// Network address where the validator can be reached.
54    pub network_address: url::Url,
55    /// Voting weight for consensus.
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    /// Account public key for receiving payments and rewards.
65    pub account_key: AccountPublicKey,
66    /// Network address where the validator can be reached.
67    pub address: url::Url,
68    /// Voting weight for consensus.
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 validator specifications 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    /// Public key of the validator, used to verify its responses. Defaults to the key
240    /// registered for this network address in the current committee; required if the
241    /// validator is not (yet) a committee member.
242    #[arg(long)]
243    public_key: Option<ValidatorPublicKey>,
244}
245
246/// Parse a batch operations file or stdin.
247/// Reads from the provided clio::Input, which handles both files and stdin transparently.
248fn parse_batch_file(input: clio::Input) -> anyhow::Result<BatchFile> {
249    Ok(serde_json::from_reader(input)?)
250}
251
252/// Parse a validator query batch file.
253fn parse_query_batch_file(input: clio::Input) -> anyhow::Result<QueryBatch> {
254    Ok(serde_json::from_reader(input)?)
255}
256
257impl Command {
258    /// Main entry point for handling validator commands.
259    pub async fn run(
260        &self,
261        context: &mut ClientContext<
262            impl linera_core::Environment<ValidatorNode = linera_rpc::Client>,
263        >,
264    ) -> anyhow::Result<()> {
265        use Command::*;
266
267        match self {
268            Add(command) => command.run(context).await,
269            BatchQuery(command) => Box::pin(command.run(context)).await,
270            Benchmark(command) => Box::pin(command.run(context)).await,
271            Update(command) => command.run(context).await,
272            List(command) => command.run(context).await,
273            Query(command) => command.run(context).await,
274            QueryBlock(command) => command.run(context).await,
275            Remove(command) => command.run(context).await,
276            Sync(command) => Box::pin(command.run(context)).await,
277        }
278    }
279}
280
281impl Add {
282    async fn run(
283        &self,
284        context: &mut ClientContext<impl linera_core::Environment>,
285    ) -> anyhow::Result<()> {
286        tracing::info!("Starting operation to add validator");
287        let time_start = std::time::Instant::now();
288
289        // Check validator is online if requested
290        if !self.skip_online_check {
291            let node = context
292                .make_node_provider()
293                .make_node(self.address.as_str())?;
294            context
295                .check_compatible_version_info(self.address.as_str(), &node)
296                .await?;
297            context
298                .check_matching_network_description(self.address.as_str(), &node)
299                .await?;
300        }
301
302        let admin_chain_id = context.admin_chain_id();
303        let chain_client = context.make_chain_client(admin_chain_id).await?;
304
305        // Synchronize the chain state
306        chain_client.synchronize_chain_state(admin_chain_id).await?;
307
308        let maybe_certificate = context
309            .apply_client_command(&chain_client, |chain_client| {
310                let me = self.clone();
311                let chain_client = chain_client.clone();
312                async move {
313                    // Create the new committee.
314                    let committee = chain_client.local_committee().await?;
315                    let policy = committee.policy().clone();
316                    let mut validators = committee.validators().clone();
317
318                    validators.insert(
319                        me.public_key,
320                        ValidatorState {
321                            network_address: me.address.to_string(),
322                            votes: me.votes.0.get(),
323                            account_public_key: me.account_key,
324                        },
325                    );
326
327                    let new_committee = Committee::new(validators, policy)?;
328                    chain_client
329                        .stage_new_committee(new_committee)
330                        .await
331                        .map(|outcome| outcome.map(Some))
332                }
333            })
334            .await
335            .context("Failed to stage committee")?;
336
337        let Some(certificate) = maybe_certificate else {
338            return Ok(());
339        };
340        tracing::info!("Created new committee:\n{:?}", certificate);
341
342        let time_total = time_start.elapsed();
343        tracing::info!("Operation confirmed after {} ms", time_total.as_millis());
344
345        Ok(())
346    }
347}
348
349impl BatchQuery {
350    async fn run(
351        &self,
352        context: &ClientContext<impl linera_core::Environment>,
353    ) -> anyhow::Result<()> {
354        let batch = parse_query_batch_file(self.file.clone())
355            .context("parsing query batch file `{file}`")?;
356        let chain_id = self.chain_id.unwrap_or_else(|| context.default_chain());
357        println!(
358            "Querying {} validators about chain {chain_id}.\n",
359            batch.validators.len()
360        );
361
362        let node_provider = context.make_node_provider();
363        let mut has_errors = false;
364
365        for spec in batch.validators {
366            let node = node_provider.make_node(spec.network_address.as_str())?;
367            let results = context
368                .query_validator(
369                    spec.network_address.as_str(),
370                    &node,
371                    chain_id,
372                    Some(&spec.public_key),
373                )
374                .await;
375
376            if !results.errors().is_empty() {
377                has_errors = true;
378                for error in results.errors() {
379                    tracing::error!("Validator {}: {}", spec.public_key, error);
380                }
381            }
382
383            results.print(
384                Some(&spec.public_key),
385                Some(spec.network_address.as_str()),
386                None,
387                None,
388            );
389        }
390
391        if has_errors {
392            anyhow::bail!("Found issues while querying validators");
393        }
394
395        Ok(())
396    }
397}
398
399impl Update {
400    async fn run(
401        &self,
402        context: &mut ClientContext<impl linera_core::Environment>,
403    ) -> anyhow::Result<()> {
404        tracing::info!("Starting batch update operation");
405        let time_start = std::time::Instant::now();
406
407        // Parse the batch file or stdin
408        let batch = parse_batch_file(self.file.clone())
409            .with_context(|| format!("parsing batch file `{}`", self.file))?;
410
411        if batch.is_empty() {
412            tracing::warn!("No validator changes specified in input.");
413            return Ok(());
414        }
415
416        // Separate operations by type for logging and validation
417        let mut adds = Vec::new();
418        let mut modifies = Vec::new();
419        let mut removes = Vec::new();
420
421        // Get current committee to determine if operation is add or modify
422        let admin_chain_id = context.client().admin_chain_id();
423        let chain_client = context.make_chain_client(admin_chain_id).await?;
424        let current_committee = chain_client.local_committee().await?;
425        let current_validators = current_committee.validators();
426
427        for (public_key, change_opt) in &batch {
428            match change_opt {
429                None => {
430                    // null = removal
431                    removes.push(*public_key);
432                }
433                Some(spec) => {
434                    if current_validators.contains_key(public_key) {
435                        modifies.push((public_key, spec));
436                    } else {
437                        adds.push((public_key, spec));
438                    }
439                }
440            }
441        }
442
443        // Display recap of changes
444        println!(
445            "\n╔══════════════════════════════════════════════════════════════════════════════╗"
446        );
447        println!(
448            "║                        VALIDATOR BATCH UPDATE RECAP                          ║"
449        );
450        println!(
451            "╚══════════════════════════════════════════════════════════════════════════════╝\n"
452        );
453
454        println!("Summary:");
455        println!("  • {} validator(s) to add", adds.len());
456        println!("  • {} validator(s) to modify", modifies.len());
457        println!("  • {} validator(s) to remove", removes.len());
458        println!();
459
460        if !adds.is_empty() {
461            println!("Validators to ADD:");
462            for (pk, spec) in &adds {
463                println!("  + {pk}");
464                println!("    Address:     {}", spec.address);
465                println!("    Account Key: {}", spec.account_key);
466                println!("    Votes:       {}", spec.votes.0.get());
467            }
468            println!();
469        }
470
471        if !modifies.is_empty() {
472            println!("Validators to MODIFY:");
473            for (pk, spec) in &modifies {
474                println!("  * {pk}");
475                println!("    New Address:     {}", spec.address);
476                println!("    New Account Key: {}", spec.account_key);
477                println!("    New Votes:       {}", spec.votes.0.get());
478            }
479            println!();
480        }
481
482        if !removes.is_empty() {
483            println!("Validators to REMOVE:");
484            for pk in &removes {
485                println!("  - {pk}");
486            }
487            println!();
488        }
489
490        if self.dry_run {
491            println!(
492                "═════════════════════════════════════════════════════════════════════════════"
493            );
494            println!("DRY RUN MODE: No changes will be applied");
495            println!(
496                "═════════════════════════════════════════════════════════════════════════════\n"
497            );
498            return Ok(());
499        }
500
501        // Confirmation prompt (unless --yes flag is set)
502        if !self.yes {
503            println!(
504                "═════════════════════════════════════════════════════════════════════════════"
505            );
506            println!("⚠️  WARNING: This operation will modify the validator committee.");
507            println!("             Changes are permanent and will be broadcast to the network.");
508            println!(
509                "═════════════════════════════════════════════════════════════════════════════\n"
510            );
511            println!("Do you want to proceed? Type 'YES' (uppercase) to confirm: ");
512
513            use std::io::{self, Write};
514            io::stdout().flush()?;
515
516            let mut input = String::new();
517            io::stdin()
518                .read_line(&mut input)
519                .context("Failed to read confirmation input")?;
520
521            let input = input.trim();
522            if input != "YES" {
523                println!("\nOperation cancelled. (Expected 'YES', got '{input}')");
524                return Ok(());
525            }
526            println!("\nConfirmed. Proceeding with batch update...\n");
527        }
528
529        // Check all validators are online if requested
530        if !self.skip_online_check {
531            let node_provider = context.make_node_provider();
532
533            tracing::info!("Checking validators are online...");
534            for (_, spec) in adds.iter().chain(modifies.iter()) {
535                let address = &spec.address;
536                let node = node_provider.make_node(address.as_str())?;
537                context
538                    .check_compatible_version_info(address.as_str(), &node)
539                    .await?;
540                context
541                    .check_matching_network_description(address.as_str(), &node)
542                    .await?;
543            }
544        }
545
546        let admin_chain_id = context.admin_chain_id();
547        let chain_client = context.make_chain_client(admin_chain_id).await?;
548
549        // Synchronize the chain state
550        chain_client.synchronize_chain_state(admin_chain_id).await?;
551
552        let batch_clone = batch.clone();
553        let maybe_certificate = context
554            .apply_client_command(&chain_client, |chain_client| {
555                let chain_client = chain_client.clone();
556                let batch = batch_clone.clone();
557                async move {
558                    // Get current committee
559                    let committee = chain_client.local_committee().await?;
560                    let policy = committee.policy().clone();
561                    let mut validators = committee.validators().clone();
562
563                    // Apply operations based on the batch specification
564                    for (public_key, change_opt) in &batch {
565                        if let Some(spec) = change_opt {
566                            // Update object - add or modify validator
567                            let address = &spec.address;
568                            let votes = spec.votes.0.get();
569                            let account_key = spec.account_key;
570
571                            let exists = validators.contains_key(public_key);
572                            validators.insert(
573                                *public_key,
574                                ValidatorState {
575                                    network_address: address.to_string(),
576                                    votes,
577                                    account_public_key: account_key,
578                                },
579                            );
580
581                            if exists {
582                                tracing::info!(
583                                    "Modified validator {} @ {} ({} votes)",
584                                    public_key,
585                                    address,
586                                    votes
587                                );
588                            } else {
589                                tracing::info!(
590                                    "Added validator {} @ {} ({} votes)",
591                                    public_key,
592                                    address,
593                                    votes
594                                );
595                            }
596                        } else {
597                            // null - remove validator
598                            if validators.remove(public_key).is_none() {
599                                tracing::warn!(
600                                    "Validator {} does not exist; skipping remove",
601                                    public_key
602                                );
603                            } else {
604                                tracing::info!("Removed validator {}", public_key);
605                            }
606                        }
607                    }
608
609                    // Create new committee
610                    let new_committee = Committee::new(validators, policy)?;
611                    chain_client
612                        .stage_new_committee(new_committee)
613                        .await
614                        .map(|outcome| outcome.map(Some))
615                }
616            })
617            .await
618            .context("Failed to stage committee")?;
619
620        let Some(certificate) = maybe_certificate else {
621            tracing::info!("No changes applied");
622            return Ok(());
623        };
624
625        tracing::info!("Created new committee:\n{:?}", certificate);
626        let time_total = time_start.elapsed();
627        tracing::info!("Batch update confirmed after {} ms", time_total.as_millis());
628
629        Ok(())
630    }
631}
632
633impl List {
634    async fn run(
635        &self,
636        context: &ClientContext<impl linera_core::Environment>,
637    ) -> anyhow::Result<()> {
638        let chain_id = self.chain_id.unwrap_or_else(|| context.default_chain());
639        println!("Querying validators about chain {chain_id}.\n");
640
641        let local_results = context.query_local_node(chain_id).await?;
642        let chain_client = context.make_chain_client(chain_id).await?;
643        tracing::info!("Querying validators about chain {}", chain_id);
644        let result = chain_client.local_committee().await;
645        context.update_wallet_from_client(&chain_client).await?;
646        let committee = result.context("Failed to get local committee")?;
647
648        tracing::info!(
649            "Using the local set of validators: {:?}",
650            committee.validators()
651        );
652
653        let node_provider = context.make_node_provider();
654        let mut validator_results = Vec::new();
655
656        for (name, state) in committee.validators() {
657            if self.min_votes.is_some_and(|votes| state.votes < votes) {
658                continue; // Skip validator with little voting weight.
659            }
660            let address = &state.network_address;
661            let node = node_provider.make_node(address)?;
662            let results = context
663                .query_validator(address, &node, chain_id, Some(name))
664                .await;
665            validator_results.push((name, address, state.votes, results));
666        }
667
668        let mut faulty_validators = std::collections::BTreeMap::<_, Vec<_>>::new();
669        for (name, address, _votes, results) in &validator_results {
670            for error in results.errors() {
671                tracing::error!("{}", error);
672                faulty_validators
673                    .entry((*name, *address))
674                    .or_default()
675                    .push(error);
676            }
677        }
678
679        // Print local node results first (everything)
680        println!("Local Node:");
681        local_results.print(None, None, None, None);
682
683        // Print validator results (only differences from local node)
684        for (name, address, votes, results) in &validator_results {
685            results.print(
686                Some(name),
687                Some(address),
688                Some(*votes),
689                Some(&local_results),
690            );
691        }
692
693        if !faulty_validators.is_empty() {
694            println!("\nFaulty validators:");
695            for ((name, address), errors) in faulty_validators {
696                println!("  {} at {}: {} error(s)", name, address, errors.len());
697            }
698            anyhow::bail!("Found faulty validators");
699        }
700
701        Ok(())
702    }
703}
704
705impl Query {
706    async fn run(
707        &self,
708        context: &ClientContext<impl linera_core::Environment>,
709    ) -> anyhow::Result<()> {
710        let node = context.make_node_provider().make_node(&self.address)?;
711        let chain_id = self.chain_id.unwrap_or_else(|| context.default_chain());
712        println!("Querying validator about chain {chain_id}.\n");
713
714        let results = context
715            .query_validator(&self.address, &node, chain_id, self.public_key.as_ref())
716            .await;
717
718        for error in results.errors() {
719            tracing::error!("{}", error);
720        }
721
722        results.print(self.public_key.as_ref(), Some(&self.address), None, None);
723
724        if !results.errors().is_empty() {
725            anyhow::bail!(
726                "Found one or several issue(s) while querying validator {}",
727                self.address
728            );
729        }
730
731        Ok(())
732    }
733}
734
735impl QueryBlock {
736    async fn run(
737        &self,
738        context: &ClientContext<impl linera_core::Environment>,
739    ) -> anyhow::Result<()> {
740        let node = context.make_node_provider().make_node(&self.address)?;
741        let chain_id = self.chain_id.unwrap_or_else(|| context.default_chain());
742        let height = self.height;
743        println!(
744            "Querying validator about the certificate for height {height} on the chain \
745            {chain_id}.\n"
746        );
747
748        let result = node
749            .download_certificates_by_heights(chain_id, vec![height])
750            .await;
751
752        match result {
753            Ok(certificates) => {
754                let confirmed_block = certificates[0].inner();
755                println!("{confirmed_block:#?}");
756            }
757            Err(error) => {
758                tracing::error!("{}", error);
759            }
760        }
761
762        Ok(())
763    }
764}
765
766impl Remove {
767    async fn run(
768        &self,
769        context: &mut ClientContext<impl linera_core::Environment>,
770    ) -> anyhow::Result<()> {
771        tracing::info!("Starting operation to remove validator");
772        let time_start = std::time::Instant::now();
773
774        let admin_chain_id = context.admin_chain_id();
775        let chain_client = context.make_chain_client(admin_chain_id).await?;
776
777        // Synchronize the chain state
778        chain_client.synchronize_chain_state(admin_chain_id).await?;
779
780        let maybe_certificate = context
781            .apply_client_command(&chain_client, |chain_client| {
782                let chain_client = chain_client.clone();
783                async move {
784                    // Create the new committee.
785                    let committee = chain_client.local_committee().await?;
786                    let policy = committee.policy().clone();
787                    let mut validators = committee.validators().clone();
788
789                    if validators.remove(&self.public_key).is_none() {
790                        tracing::error!("Validator {} does not exist; aborting.", self.public_key);
791                        return Ok(ClientOutcome::Committed(None));
792                    }
793
794                    let new_committee = Committee::new(validators, policy)?;
795                    chain_client
796                        .stage_new_committee(new_committee)
797                        .await
798                        .map(|outcome| outcome.map(Some))
799                }
800            })
801            .await
802            .context("Failed to stage committee")?;
803
804        let Some(certificate) = maybe_certificate else {
805            return Ok(());
806        };
807        tracing::info!("Created new committee:\n{:?}", certificate);
808
809        let time_total = time_start.elapsed();
810        tracing::info!("Operation confirmed after {} ms", time_total.as_millis());
811
812        Ok(())
813    }
814}
815
816impl Sync {
817    async fn run(
818        &self,
819        context: &ClientContext<impl linera_core::Environment<ValidatorNode = linera_rpc::Client>>,
820    ) -> anyhow::Result<()> {
821        tracing::info!("Starting sync operation for validator at {}", self.address);
822
823        // Check validator is online if requested
824        if self.check_online {
825            let node_provider = context.make_node_provider();
826            let node = node_provider.make_node(&self.address)?;
827            context
828                .check_compatible_version_info(&self.address, &node)
829                .await?;
830            context
831                .check_matching_network_description(&self.address, &node)
832                .await?;
833        }
834
835        // If no chains specified, use all chains from wallet
836        let chains_to_sync = if self.chains.is_empty() {
837            context.wallet().chain_ids().try_collect().await?
838        } else {
839            self.chains.clone()
840        };
841
842        tracing::info!(
843            "Syncing {} chains to validator {}",
844            chains_to_sync.len(),
845            self.address
846        );
847
848        // Create validator node
849        let node_provider = context.make_node_provider();
850        let validator = node_provider.make_node(&self.address)?;
851
852        // The validator's public key, to verify its responses: either given explicitly
853        // or looked up in the current committee by network address.
854        let public_key = match self.public_key {
855            Some(public_key) => public_key,
856            None => {
857                let admin_chain = context.make_chain_client(context.admin_chain_id()).await?;
858                let (_, committee) = admin_chain.admin_committee().await?;
859                let public_key = committee
860                    .validator_addresses()
861                    .find(|(_, address)| *address == self.address)
862                    .map(|(public_key, _)| public_key);
863                public_key.with_context(|| {
864                    format!(
865                        "validator {} is not in the current committee; \
866                         use --public-key to sync it",
867                        self.address
868                    )
869                })?
870            }
871        };
872
873        // Sync each chain
874        for chain_id in chains_to_sync {
875            tracing::info!("Syncing chain {} to {}", chain_id, self.address);
876            let chain = context.make_chain_client(chain_id).await?;
877
878            Box::pin(chain.sync_validator(public_key, validator.clone())).await?;
879            tracing::info!("Chain {} synced successfully", chain_id);
880        }
881
882        tracing::info!("Sync operation completed successfully");
883        Ok(())
884    }
885}
886
887#[cfg(test)]
888mod tests {
889    use std::io::Write;
890
891    use tempfile::NamedTempFile;
892
893    use super::*;
894
895    #[test]
896    fn test_parse_batch_file_valid() {
897        // Generate correct JSON format using test keys
898        let pk0 = ValidatorPublicKey::test_key(0);
899        let pk1 = ValidatorPublicKey::test_key(1);
900        let pk2 = ValidatorPublicKey::test_key(2);
901
902        let mut batch = BatchFile::new();
903
904        // Add operation - validator with full spec
905        batch.insert(
906            pk0,
907            Some(Change {
908                account_key: AccountPublicKey::test_key(0),
909                address: "grpcs://validator1.example.com:443".parse().unwrap(),
910                votes: Votes(NonZero::new(100).unwrap()),
911            }),
912        );
913
914        // Modify operation - validator with full spec (would be modify if validator exists)
915        batch.insert(
916            pk1,
917            Some(Change {
918                account_key: AccountPublicKey::test_key(1),
919                address: "grpcs://validator2.example.com:443".parse().unwrap(),
920                votes: Votes(NonZero::new(150).unwrap()),
921            }),
922        );
923
924        // Remove operation - null
925        batch.insert(pk2, None);
926
927        let json = serde_json::to_string(&batch).unwrap();
928
929        let mut temp_file = NamedTempFile::new().unwrap();
930        temp_file.write_all(json.as_bytes()).unwrap();
931        temp_file.flush().unwrap();
932
933        let input = clio::Input::new(temp_file.path().to_str().unwrap()).unwrap();
934        let result = parse_batch_file(input);
935        assert!(
936            result.is_ok(),
937            "Failed to parse batch file: {:?}",
938            result.err()
939        );
940
941        let parsed_batch = result.unwrap();
942        assert_eq!(parsed_batch.len(), 3);
943
944        // Check pk0 (add)
945        assert!(parsed_batch.contains_key(&pk0));
946        let spec0 = parsed_batch.get(&pk0).unwrap().as_ref().unwrap();
947        assert_eq!(spec0.votes.0.get(), 100);
948
949        // Check pk1 (modify)
950        assert!(parsed_batch.contains_key(&pk1));
951        let spec1 = parsed_batch.get(&pk1).unwrap().as_ref().unwrap();
952        assert_eq!(spec1.votes.0.get(), 150);
953
954        // Check pk2 (remove with null)
955        assert!(parsed_batch.contains_key(&pk2));
956        assert!(parsed_batch.get(&pk2).unwrap().is_none());
957    }
958
959    #[test]
960    fn test_parse_batch_file_empty() {
961        let json = r#"{}"#;
962
963        let mut temp_file = NamedTempFile::new().unwrap();
964        temp_file.write_all(json.as_bytes()).unwrap();
965        temp_file.flush().unwrap();
966
967        let input = clio::Input::new(temp_file.path().to_str().unwrap()).unwrap();
968        let result = parse_batch_file(input);
969        assert!(result.is_ok());
970
971        let batch = result.unwrap();
972        assert_eq!(batch.len(), 0);
973    }
974
975    #[test]
976    fn test_parse_query_batch_file_valid() {
977        // Generate correct JSON format using test keys
978        let spec1 = Spec {
979            public_key: ValidatorPublicKey::test_key(0),
980            account_key: AccountPublicKey::test_key(0),
981            network_address: "grpcs://validator1.example.com:443".parse().unwrap(),
982            votes: Votes(NonZero::new(100).unwrap()),
983        };
984        let spec2 = Spec {
985            public_key: ValidatorPublicKey::test_key(1),
986            account_key: AccountPublicKey::test_key(1),
987            network_address: "grpcs://validator2.example.com:443".parse().unwrap(),
988            votes: Votes(NonZero::new(150).unwrap()),
989        };
990
991        let batch = QueryBatch {
992            validators: vec![spec1, spec2],
993        };
994
995        let json = serde_json::to_string(&batch).unwrap();
996
997        let mut temp_file = NamedTempFile::new().unwrap();
998        temp_file.write_all(json.as_bytes()).unwrap();
999        temp_file.flush().unwrap();
1000
1001        let result = parse_query_batch_file(temp_file.path().try_into().unwrap());
1002        assert!(
1003            result.is_ok(),
1004            "Failed to parse query batch file: {:?}",
1005            result.err()
1006        );
1007
1008        let parsed_batch = result.unwrap();
1009        assert_eq!(parsed_batch.validators.len(), 2);
1010        assert_eq!(parsed_batch.validators[0].votes.0.get(), 100);
1011        assert_eq!(parsed_batch.validators[1].votes.0.get(), 150);
1012    }
1013}