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
24/// Type alias for the complex ClientContext type used throughout validator operations.
25/// This alias helps avoid clippy's type_complexity warnings while maintaining type safety.
26/// Uses generic Environment trait to avoid coupling to implementation details.
27#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
28pub struct Votes(pub NonZero<u64>);
29
30impl Default for Votes {
31    fn default() -> Self {
32        Self(nonzero_lit::u64!(1))
33    }
34}
35
36impl FromStr for Votes {
37    type Err = <NonZero<u64> as FromStr>::Err;
38    fn from_str(s: &str) -> Result<Self, Self::Err> {
39        Ok(Votes(s.parse()?))
40    }
41}
42
43/// Specification for a validator to add or modify.
44#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
45#[serde(rename_all = "camelCase")]
46pub struct Spec {
47    pub public_key: ValidatorPublicKey,
48    pub account_key: AccountPublicKey,
49    pub network_address: url::Url,
50    #[serde(default)]
51    pub votes: Votes,
52}
53
54/// Represents an update to a validator's configuration in batch operations.
55#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
56#[serde(rename_all = "camelCase")]
57pub struct Change {
58    pub account_key: AccountPublicKey,
59    pub address: url::Url,
60    #[serde(default)]
61    pub votes: Votes,
62}
63
64/// Structure for batch validator operations from JSON file.
65/// Maps validator public keys to their desired state:
66/// - `null` means remove the validator
67/// - `{accountKey, address, votes}` means add or modify the validator
68/// - Keys not present in the map are left unchanged
69pub type BatchFile = HashMap<ValidatorPublicKey, Option<Change>>;
70
71/// Structure for batch validator queries from JSON file.
72#[derive(Debug, Clone, Serialize, Deserialize)]
73pub struct QueryBatch {
74    pub validators: Vec<Spec>,
75}
76
77/// Validator subcommands.
78#[derive(Debug, Clone, clap::Subcommand)]
79pub enum Command {
80    Add(Add),
81    BatchQuery(BatchQuery),
82    Update(Update),
83    List(List),
84    Query(Query),
85    QueryBlock(QueryBlock),
86    Remove(Remove),
87    Sync(Sync),
88}
89
90/// Add a validator to the committee.
91///
92/// Adds a new validator with the specified public key, account key, network address,
93/// and voting weight. The validator must not already exist in the committee.
94#[derive(Debug, Clone, clap::Parser)]
95pub struct Add {
96    /// Public key of the validator to add
97    #[arg(long)]
98    public_key: ValidatorPublicKey,
99    /// Account public key for receiving payments and rewards
100    #[arg(long)]
101    account_key: AccountPublicKey,
102    /// Network address where the validator can be reached (e.g., grpcs://host:port)
103    #[arg(long)]
104    address: url::Url,
105    /// Voting weight for consensus (default: 1)
106    #[arg(long, required = false)]
107    votes: Votes,
108    /// Skip online connectivity verification before adding
109    #[arg(long)]
110    skip_online_check: bool,
111}
112
113/// Query multiple validators using a JSON specification file.
114///
115/// Reads validator specifications from a JSON file and queries their state.
116/// The JSON should contain an array of validator objects with publicKey and networkAddress.
117#[derive(Debug, Clone, clap::Parser)]
118pub struct BatchQuery {
119    /// Path to JSON file containing validator query specifications
120    file: clio::Input,
121    /// Chain ID to query (defaults to default chain)
122    #[arg(long)]
123    chain_id: Option<ChainId>,
124}
125
126/// Apply multiple validator changes from JSON input.
127///
128/// Reads a JSON object mapping validator public keys to their desired state:
129/// - Key with state object (address, votes, accountKey): add or modify validator
130/// - Key with null: remove validator
131/// - Keys not present: unchanged
132///
133/// Input can be provided via file path, stdin pipe, or shell redirect.
134#[derive(Debug, Clone, clap::Parser)]
135pub struct Update {
136    /// Path to JSON file with validator changes (omit or use "-" for stdin)
137    #[arg(required = false)]
138    file: clio::Input,
139    /// Preview changes without applying them
140    #[arg(long)]
141    dry_run: bool,
142    /// Skip confirmation prompt (use with caution)
143    #[arg(long, short = 'y')]
144    yes: bool,
145    /// Skip online connectivity checks for validators being added or modified
146    #[arg(long)]
147    skip_online_check: bool,
148}
149
150/// List all validators in the committee.
151///
152/// Displays the current validator set with their network addresses, voting weights,
153/// and connection status. Optionally filter by minimum voting weight.
154#[derive(Debug, Clone, clap::Parser)]
155pub struct List {
156    /// Chain ID to query (defaults to default chain)
157    #[arg(long)]
158    chain_id: Option<ChainId>,
159    /// Only show validators with at least this many votes
160    #[arg(long)]
161    min_votes: Option<u64>,
162}
163
164/// Query a single validator's state and connectivity.
165///
166/// Connects to a validator at the specified network address and queries its
167/// view of the blockchain state, including block height and committee information.
168#[derive(Debug, Clone, clap::Parser)]
169pub struct Query {
170    /// Network address of the validator (e.g., grpcs://host:port)
171    address: String,
172    /// Chain ID to query about (defaults to default chain)
173    #[arg(long)]
174    chain_id: Option<ChainId>,
175    /// Expected public key of the validator (for verification)
176    #[arg(long)]
177    public_key: Option<ValidatorPublicKey>,
178}
179
180/// Query a single validator for a block at a particular chain and height.
181///
182/// Connects to a validator at the specified network address and queries its
183/// view of the blockchain.
184#[derive(Debug, Clone, clap::Parser)]
185pub struct QueryBlock {
186    /// Network address of the validator (e.g., grpcs://host:port)
187    address: String,
188    /// Chain ID to query about (defaults to default chain)
189    #[arg(long)]
190    chain_id: Option<ChainId>,
191    /// Expected public key of the validator (for verification)
192    #[arg(long)]
193    public_key: Option<ValidatorPublicKey>,
194    /// Block height to query about
195    #[arg(long)]
196    height: BlockHeight,
197}
198
199/// Remove a validator from the committee.
200///
201/// Removes the validator with the specified public key from the committee.
202/// The validator will no longer participate in consensus.
203#[derive(Debug, Clone, clap::Parser)]
204pub struct Remove {
205    /// Public key of the validator to remove
206    #[arg(long)]
207    public_key: ValidatorPublicKey,
208}
209
210/// Synchronize chain state to a validator.
211///
212/// Pushes the current chain state from local storage to a validator node,
213/// ensuring the validator has up-to-date information about specified chains.
214#[derive(Debug, Clone, clap::Parser)]
215pub struct Sync {
216    /// Network address of the validator to sync (e.g., grpcs://host:port)
217    address: String,
218    /// Chain IDs to synchronize (defaults to all chains in wallet)
219    #[arg(long)]
220    chains: Vec<ChainId>,
221    /// Verify validator is online before syncing
222    #[arg(long)]
223    check_online: bool,
224}
225
226/// Parse a batch operations file or stdin.
227/// Reads from the provided clio::Input, which handles both files and stdin transparently.
228fn parse_batch_file(input: clio::Input) -> anyhow::Result<BatchFile> {
229    Ok(serde_json::from_reader(input)?)
230}
231
232/// Parse a validator query batch file.
233fn parse_query_batch_file(input: clio::Input) -> anyhow::Result<QueryBatch> {
234    Ok(serde_json::from_reader(input)?)
235}
236
237impl Command {
238    /// Main entry point for handling validator commands.
239    pub async fn run(
240        &self,
241        context: &mut ClientContext<
242            impl linera_core::Environment<ValidatorNode = linera_rpc::Client>,
243        >,
244    ) -> anyhow::Result<()> {
245        use Command::*;
246
247        match self {
248            Add(command) => command.run(context).await,
249            BatchQuery(command) => Box::pin(command.run(context)).await,
250            Update(command) => command.run(context).await,
251            List(command) => command.run(context).await,
252            Query(command) => command.run(context).await,
253            QueryBlock(command) => command.run(context).await,
254            Remove(command) => command.run(context).await,
255            Sync(command) => Box::pin(command.run(context)).await,
256        }
257    }
258}
259
260impl Add {
261    async fn run(
262        &self,
263        context: &mut ClientContext<impl linera_core::Environment>,
264    ) -> anyhow::Result<()> {
265        tracing::info!("Starting operation to add validator");
266        let time_start = std::time::Instant::now();
267
268        // Check validator is online if requested
269        if !self.skip_online_check {
270            let node = context
271                .make_node_provider()
272                .make_node(self.address.as_str())?;
273            context
274                .check_compatible_version_info(self.address.as_str(), &node)
275                .await?;
276            context
277                .check_matching_network_description(self.address.as_str(), &node)
278                .await?;
279        }
280
281        let admin_chain_id = context.admin_chain_id();
282        let chain_client = context.make_chain_client(admin_chain_id).await?;
283
284        // Synchronize the chain state
285        chain_client.synchronize_chain_state(admin_chain_id).await?;
286
287        let maybe_certificate = context
288            .apply_client_command(&chain_client, |chain_client| {
289                let me = self.clone();
290                let chain_client = chain_client.clone();
291                async move {
292                    // Create the new committee.
293                    let committee = chain_client.local_committee().await?;
294                    let policy = committee.policy().clone();
295                    let mut validators = committee.validators().clone();
296
297                    validators.insert(
298                        me.public_key,
299                        ValidatorState {
300                            network_address: me.address.to_string(),
301                            votes: me.votes.0.get(),
302                            account_public_key: me.account_key,
303                        },
304                    );
305
306                    let new_committee = Committee::new(validators, policy)?;
307                    chain_client
308                        .stage_new_committee(new_committee)
309                        .await
310                        .map(|outcome| outcome.map(Some))
311                }
312            })
313            .await
314            .context("Failed to stage committee")?;
315
316        let Some(certificate) = maybe_certificate else {
317            return Ok(());
318        };
319        tracing::info!("Created new committee:\n{:?}", certificate);
320
321        let time_total = time_start.elapsed();
322        tracing::info!("Operation confirmed after {} ms", time_total.as_millis());
323
324        Ok(())
325    }
326}
327
328impl BatchQuery {
329    async fn run(
330        &self,
331        context: &ClientContext<impl linera_core::Environment>,
332    ) -> anyhow::Result<()> {
333        let batch = parse_query_batch_file(self.file.clone())
334            .context("parsing query batch file `{file}`")?;
335        let chain_id = self.chain_id.unwrap_or_else(|| context.default_chain());
336        println!(
337            "Querying {} validators about chain {chain_id}.\n",
338            batch.validators.len()
339        );
340
341        let node_provider = context.make_node_provider();
342        let mut has_errors = false;
343
344        for spec in batch.validators {
345            let node = node_provider.make_node(spec.network_address.as_str())?;
346            let results = context
347                .query_validator(
348                    spec.network_address.as_str(),
349                    &node,
350                    chain_id,
351                    Some(&spec.public_key),
352                )
353                .await;
354
355            if !results.errors().is_empty() {
356                has_errors = true;
357                for error in results.errors() {
358                    tracing::error!("Validator {}: {}", spec.public_key, error);
359                }
360            }
361
362            results.print(
363                Some(&spec.public_key),
364                Some(spec.network_address.as_str()),
365                None,
366                None,
367            );
368        }
369
370        if has_errors {
371            anyhow::bail!("Found issues while querying validators");
372        }
373
374        Ok(())
375    }
376}
377
378impl Update {
379    async fn run(
380        &self,
381        context: &mut ClientContext<impl linera_core::Environment>,
382    ) -> anyhow::Result<()> {
383        tracing::info!("Starting batch update operation");
384        let time_start = std::time::Instant::now();
385
386        // Parse the batch file or stdin
387        let batch = parse_batch_file(self.file.clone())
388            .with_context(|| format!("parsing batch file `{}`", self.file))?;
389
390        if batch.is_empty() {
391            tracing::warn!("No validator changes specified in input.");
392            return Ok(());
393        }
394
395        // Separate operations by type for logging and validation
396        let mut adds = Vec::new();
397        let mut modifies = Vec::new();
398        let mut removes = Vec::new();
399
400        // Get current committee to determine if operation is add or modify
401        let admin_chain_id = context.client().admin_chain_id();
402        let chain_client = context.make_chain_client(admin_chain_id).await?;
403        let current_committee = chain_client.local_committee().await?;
404        let current_validators = current_committee.validators();
405
406        for (public_key, change_opt) in &batch {
407            match change_opt {
408                None => {
409                    // null = removal
410                    removes.push(*public_key);
411                }
412                Some(spec) => {
413                    if current_validators.contains_key(public_key) {
414                        modifies.push((public_key, spec));
415                    } else {
416                        adds.push((public_key, spec));
417                    }
418                }
419            }
420        }
421
422        // Display recap of changes
423        println!(
424            "\n╔══════════════════════════════════════════════════════════════════════════════╗"
425        );
426        println!(
427            "║                        VALIDATOR BATCH UPDATE RECAP                          ║"
428        );
429        println!(
430            "╚══════════════════════════════════════════════════════════════════════════════╝\n"
431        );
432
433        println!("Summary:");
434        println!("  • {} validator(s) to add", adds.len());
435        println!("  • {} validator(s) to modify", modifies.len());
436        println!("  • {} validator(s) to remove", removes.len());
437        println!();
438
439        if !adds.is_empty() {
440            println!("Validators to ADD:");
441            for (pk, spec) in &adds {
442                println!("  + {pk}");
443                println!("    Address:     {}", spec.address);
444                println!("    Account Key: {}", spec.account_key);
445                println!("    Votes:       {}", spec.votes.0.get());
446            }
447            println!();
448        }
449
450        if !modifies.is_empty() {
451            println!("Validators to MODIFY:");
452            for (pk, spec) in &modifies {
453                println!("  * {pk}");
454                println!("    New Address:     {}", spec.address);
455                println!("    New Account Key: {}", spec.account_key);
456                println!("    New Votes:       {}", spec.votes.0.get());
457            }
458            println!();
459        }
460
461        if !removes.is_empty() {
462            println!("Validators to REMOVE:");
463            for pk in &removes {
464                println!("  - {pk}");
465            }
466            println!();
467        }
468
469        if self.dry_run {
470            println!(
471                "═════════════════════════════════════════════════════════════════════════════"
472            );
473            println!("DRY RUN MODE: No changes will be applied");
474            println!(
475                "═════════════════════════════════════════════════════════════════════════════\n"
476            );
477            return Ok(());
478        }
479
480        // Confirmation prompt (unless --yes flag is set)
481        if !self.yes {
482            println!(
483                "═════════════════════════════════════════════════════════════════════════════"
484            );
485            println!("⚠️  WARNING: This operation will modify the validator committee.");
486            println!("             Changes are permanent and will be broadcast to the network.");
487            println!(
488                "═════════════════════════════════════════════════════════════════════════════\n"
489            );
490            println!("Do you want to proceed? Type 'YES' (uppercase) to confirm: ");
491
492            use std::io::{self, Write};
493            io::stdout().flush()?;
494
495            let mut input = String::new();
496            io::stdin()
497                .read_line(&mut input)
498                .context("Failed to read confirmation input")?;
499
500            let input = input.trim();
501            if input != "YES" {
502                println!("\nOperation cancelled. (Expected 'YES', got '{input}')");
503                return Ok(());
504            }
505            println!("\nConfirmed. Proceeding with batch update...\n");
506        }
507
508        // Check all validators are online if requested
509        if !self.skip_online_check {
510            let node_provider = context.make_node_provider();
511
512            tracing::info!("Checking validators are online...");
513            for (_, spec) in adds.iter().chain(modifies.iter()) {
514                let address = &spec.address;
515                let node = node_provider.make_node(address.as_str())?;
516                context
517                    .check_compatible_version_info(address.as_str(), &node)
518                    .await?;
519                context
520                    .check_matching_network_description(address.as_str(), &node)
521                    .await?;
522            }
523        }
524
525        let admin_chain_id = context.admin_chain_id();
526        let chain_client = context.make_chain_client(admin_chain_id).await?;
527
528        // Synchronize the chain state
529        chain_client.synchronize_chain_state(admin_chain_id).await?;
530
531        let batch_clone = batch.clone();
532        let maybe_certificate = context
533            .apply_client_command(&chain_client, |chain_client| {
534                let chain_client = chain_client.clone();
535                let batch = batch_clone.clone();
536                async move {
537                    // Get current committee
538                    let committee = chain_client.local_committee().await?;
539                    let policy = committee.policy().clone();
540                    let mut validators = committee.validators().clone();
541
542                    // Apply operations based on the batch specification
543                    for (public_key, change_opt) in &batch {
544                        if let Some(spec) = change_opt {
545                            // Update object - add or modify validator
546                            let address = &spec.address;
547                            let votes = spec.votes.0.get();
548                            let account_key = spec.account_key;
549
550                            let exists = validators.contains_key(public_key);
551                            validators.insert(
552                                *public_key,
553                                ValidatorState {
554                                    network_address: address.to_string(),
555                                    votes,
556                                    account_public_key: account_key,
557                                },
558                            );
559
560                            if exists {
561                                tracing::info!(
562                                    "Modified validator {} @ {} ({} votes)",
563                                    public_key,
564                                    address,
565                                    votes
566                                );
567                            } else {
568                                tracing::info!(
569                                    "Added validator {} @ {} ({} votes)",
570                                    public_key,
571                                    address,
572                                    votes
573                                );
574                            }
575                        } else {
576                            // null - remove validator
577                            if validators.remove(public_key).is_none() {
578                                tracing::warn!(
579                                    "Validator {} does not exist; skipping remove",
580                                    public_key
581                                );
582                            } else {
583                                tracing::info!("Removed validator {}", public_key);
584                            }
585                        }
586                    }
587
588                    // Create new committee
589                    let new_committee = Committee::new(validators, policy)?;
590                    chain_client
591                        .stage_new_committee(new_committee)
592                        .await
593                        .map(|outcome| outcome.map(Some))
594                }
595            })
596            .await
597            .context("Failed to stage committee")?;
598
599        let Some(certificate) = maybe_certificate else {
600            tracing::info!("No changes applied");
601            return Ok(());
602        };
603
604        tracing::info!("Created new committee:\n{:?}", certificate);
605        let time_total = time_start.elapsed();
606        tracing::info!("Batch update confirmed after {} ms", time_total.as_millis());
607
608        Ok(())
609    }
610}
611
612impl List {
613    async fn run(
614        &self,
615        context: &ClientContext<impl linera_core::Environment>,
616    ) -> anyhow::Result<()> {
617        let chain_id = self.chain_id.unwrap_or_else(|| context.default_chain());
618        println!("Querying validators about chain {chain_id}.\n");
619
620        let local_results = context.query_local_node(chain_id).await?;
621        let chain_client = context.make_chain_client(chain_id).await?;
622        tracing::info!("Querying validators about chain {}", chain_id);
623        let result = chain_client.local_committee().await;
624        context.update_wallet_from_client(&chain_client).await?;
625        let committee = result.context("Failed to get local committee")?;
626
627        tracing::info!(
628            "Using the local set of validators: {:?}",
629            committee.validators()
630        );
631
632        let node_provider = context.make_node_provider();
633        let mut validator_results = Vec::new();
634
635        for (name, state) in committee.validators() {
636            if self.min_votes.is_some_and(|votes| state.votes < votes) {
637                continue; // Skip validator with little voting weight.
638            }
639            let address = &state.network_address;
640            let node = node_provider.make_node(address)?;
641            let results = context
642                .query_validator(address, &node, chain_id, Some(name))
643                .await;
644            validator_results.push((name, address, state.votes, results));
645        }
646
647        let mut faulty_validators = std::collections::BTreeMap::<_, Vec<_>>::new();
648        for (name, address, _votes, results) in &validator_results {
649            for error in results.errors() {
650                tracing::error!("{}", error);
651                faulty_validators
652                    .entry((*name, *address))
653                    .or_default()
654                    .push(error);
655            }
656        }
657
658        // Print local node results first (everything)
659        println!("Local Node:");
660        local_results.print(None, None, None, None);
661
662        // Print validator results (only differences from local node)
663        for (name, address, votes, results) in &validator_results {
664            results.print(
665                Some(name),
666                Some(address),
667                Some(*votes),
668                Some(&local_results),
669            );
670        }
671
672        if !faulty_validators.is_empty() {
673            println!("\nFaulty validators:");
674            for ((name, address), errors) in faulty_validators {
675                println!("  {} at {}: {} error(s)", name, address, errors.len());
676            }
677            anyhow::bail!("Found faulty validators");
678        }
679
680        Ok(())
681    }
682}
683
684impl Query {
685    async fn run(
686        &self,
687        context: &ClientContext<impl linera_core::Environment>,
688    ) -> anyhow::Result<()> {
689        let node = context.make_node_provider().make_node(&self.address)?;
690        let chain_id = self.chain_id.unwrap_or_else(|| context.default_chain());
691        println!("Querying validator about chain {chain_id}.\n");
692
693        let results = context
694            .query_validator(&self.address, &node, chain_id, self.public_key.as_ref())
695            .await;
696
697        for error in results.errors() {
698            tracing::error!("{}", error);
699        }
700
701        results.print(self.public_key.as_ref(), Some(&self.address), None, None);
702
703        if !results.errors().is_empty() {
704            anyhow::bail!(
705                "Found one or several issue(s) while querying validator {}",
706                self.address
707            );
708        }
709
710        Ok(())
711    }
712}
713
714impl QueryBlock {
715    async fn run(
716        &self,
717        context: &ClientContext<impl linera_core::Environment>,
718    ) -> anyhow::Result<()> {
719        let node = context.make_node_provider().make_node(&self.address)?;
720        let chain_id = self.chain_id.unwrap_or_else(|| context.default_chain());
721        let height = self.height;
722        println!(
723            "Querying validator about the certificate for height {height} on the chain \
724            {chain_id}.\n"
725        );
726
727        let result = node
728            .download_certificates_by_heights(chain_id, vec![height])
729            .await;
730
731        match result {
732            Ok(certificates) => {
733                let confirmed_block = certificates[0].inner();
734                println!("{confirmed_block:#?}");
735            }
736            Err(error) => {
737                tracing::error!("{}", error);
738            }
739        }
740
741        Ok(())
742    }
743}
744
745impl Remove {
746    async fn run(
747        &self,
748        context: &mut ClientContext<impl linera_core::Environment>,
749    ) -> anyhow::Result<()> {
750        tracing::info!("Starting operation to remove validator");
751        let time_start = std::time::Instant::now();
752
753        let admin_chain_id = context.admin_chain_id();
754        let chain_client = context.make_chain_client(admin_chain_id).await?;
755
756        // Synchronize the chain state
757        chain_client.synchronize_chain_state(admin_chain_id).await?;
758
759        let maybe_certificate = context
760            .apply_client_command(&chain_client, |chain_client| {
761                let chain_client = chain_client.clone();
762                async move {
763                    // Create the new committee.
764                    let committee = chain_client.local_committee().await?;
765                    let policy = committee.policy().clone();
766                    let mut validators = committee.validators().clone();
767
768                    if validators.remove(&self.public_key).is_none() {
769                        tracing::error!("Validator {} does not exist; aborting.", self.public_key);
770                        return Ok(ClientOutcome::Committed(None));
771                    }
772
773                    let new_committee = Committee::new(validators, policy)?;
774                    chain_client
775                        .stage_new_committee(new_committee)
776                        .await
777                        .map(|outcome| outcome.map(Some))
778                }
779            })
780            .await
781            .context("Failed to stage committee")?;
782
783        let Some(certificate) = maybe_certificate else {
784            return Ok(());
785        };
786        tracing::info!("Created new committee:\n{:?}", certificate);
787
788        let time_total = time_start.elapsed();
789        tracing::info!("Operation confirmed after {} ms", time_total.as_millis());
790
791        Ok(())
792    }
793}
794
795impl Sync {
796    async fn run(
797        &self,
798        context: &ClientContext<impl linera_core::Environment<ValidatorNode = linera_rpc::Client>>,
799    ) -> anyhow::Result<()> {
800        tracing::info!("Starting sync operation for validator at {}", self.address);
801
802        // Check validator is online if requested
803        if self.check_online {
804            let node_provider = context.make_node_provider();
805            let node = node_provider.make_node(&self.address)?;
806            context
807                .check_compatible_version_info(&self.address, &node)
808                .await?;
809            context
810                .check_matching_network_description(&self.address, &node)
811                .await?;
812        }
813
814        // If no chains specified, use all chains from wallet
815        let chains_to_sync = if self.chains.is_empty() {
816            context.wallet().chain_ids().try_collect().await?
817        } else {
818            self.chains.clone()
819        };
820
821        tracing::info!(
822            "Syncing {} chains to validator {}",
823            chains_to_sync.len(),
824            self.address
825        );
826
827        // Create validator node
828        let node_provider = context.make_node_provider();
829        let validator = node_provider.make_node(&self.address)?;
830
831        // Sync each chain
832        for chain_id in chains_to_sync {
833            tracing::info!("Syncing chain {} to {}", chain_id, self.address);
834            let chain = context.make_chain_client(chain_id).await?;
835
836            Box::pin(chain.sync_validator(validator.clone())).await?;
837            tracing::info!("Chain {} synced successfully", chain_id);
838        }
839
840        tracing::info!("Sync operation completed successfully");
841        Ok(())
842    }
843}
844
845#[cfg(test)]
846mod tests {
847    use std::io::Write;
848
849    use tempfile::NamedTempFile;
850
851    use super::*;
852
853    #[test]
854    fn test_parse_batch_file_valid() {
855        // Generate correct JSON format using test keys
856        let pk0 = ValidatorPublicKey::test_key(0);
857        let pk1 = ValidatorPublicKey::test_key(1);
858        let pk2 = ValidatorPublicKey::test_key(2);
859
860        let mut batch = BatchFile::new();
861
862        // Add operation - validator with full spec
863        batch.insert(
864            pk0,
865            Some(Change {
866                account_key: AccountPublicKey::test_key(0),
867                address: "grpcs://validator1.example.com:443".parse().unwrap(),
868                votes: Votes(NonZero::new(100).unwrap()),
869            }),
870        );
871
872        // Modify operation - validator with full spec (would be modify if validator exists)
873        batch.insert(
874            pk1,
875            Some(Change {
876                account_key: AccountPublicKey::test_key(1),
877                address: "grpcs://validator2.example.com:443".parse().unwrap(),
878                votes: Votes(NonZero::new(150).unwrap()),
879            }),
880        );
881
882        // Remove operation - null
883        batch.insert(pk2, None);
884
885        let json = serde_json::to_string(&batch).unwrap();
886
887        let mut temp_file = NamedTempFile::new().unwrap();
888        temp_file.write_all(json.as_bytes()).unwrap();
889        temp_file.flush().unwrap();
890
891        let input = clio::Input::new(temp_file.path().to_str().unwrap()).unwrap();
892        let result = parse_batch_file(input);
893        assert!(
894            result.is_ok(),
895            "Failed to parse batch file: {:?}",
896            result.err()
897        );
898
899        let parsed_batch = result.unwrap();
900        assert_eq!(parsed_batch.len(), 3);
901
902        // Check pk0 (add)
903        assert!(parsed_batch.contains_key(&pk0));
904        let spec0 = parsed_batch.get(&pk0).unwrap().as_ref().unwrap();
905        assert_eq!(spec0.votes.0.get(), 100);
906
907        // Check pk1 (modify)
908        assert!(parsed_batch.contains_key(&pk1));
909        let spec1 = parsed_batch.get(&pk1).unwrap().as_ref().unwrap();
910        assert_eq!(spec1.votes.0.get(), 150);
911
912        // Check pk2 (remove with null)
913        assert!(parsed_batch.contains_key(&pk2));
914        assert!(parsed_batch.get(&pk2).unwrap().is_none());
915    }
916
917    #[test]
918    fn test_parse_batch_file_empty() {
919        let json = r#"{}"#;
920
921        let mut temp_file = NamedTempFile::new().unwrap();
922        temp_file.write_all(json.as_bytes()).unwrap();
923        temp_file.flush().unwrap();
924
925        let input = clio::Input::new(temp_file.path().to_str().unwrap()).unwrap();
926        let result = parse_batch_file(input);
927        assert!(result.is_ok());
928
929        let batch = result.unwrap();
930        assert_eq!(batch.len(), 0);
931    }
932
933    #[test]
934    fn test_parse_query_batch_file_valid() {
935        // Generate correct JSON format using test keys
936        let spec1 = Spec {
937            public_key: ValidatorPublicKey::test_key(0),
938            account_key: AccountPublicKey::test_key(0),
939            network_address: "grpcs://validator1.example.com:443".parse().unwrap(),
940            votes: Votes(NonZero::new(100).unwrap()),
941        };
942        let spec2 = Spec {
943            public_key: ValidatorPublicKey::test_key(1),
944            account_key: AccountPublicKey::test_key(1),
945            network_address: "grpcs://validator2.example.com:443".parse().unwrap(),
946            votes: Votes(NonZero::new(150).unwrap()),
947        };
948
949        let batch = QueryBatch {
950            validators: vec![spec1, spec2],
951        };
952
953        let json = serde_json::to_string(&batch).unwrap();
954
955        let mut temp_file = NamedTempFile::new().unwrap();
956        temp_file.write_all(json.as_bytes()).unwrap();
957        temp_file.flush().unwrap();
958
959        let result = parse_query_batch_file(temp_file.path().try_into().unwrap());
960        assert!(
961            result.is_ok(),
962            "Failed to parse query batch file: {:?}",
963            result.err()
964        );
965
966        let parsed_batch = result.unwrap();
967        assert_eq!(parsed_batch.validators.len(), 2);
968        assert_eq!(parsed_batch.validators[0].votes.0.get(), 100);
969        assert_eq!(parsed_batch.validators[1].votes.0.get(), 150);
970    }
971}