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