From 6210edfd0ccdc798d1ec3842cce834100b60a5da Mon Sep 17 00:00:00 2001 From: NikitaM <21960067+Keshoid@users.noreply.github.com> Date: Thu, 9 Apr 2026 19:15:59 +0300 Subject: [PATCH 1/4] feat(voting): add nodectl vote command for config proposals MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add `nodectl vote` with subcommands to manage config proposal voting: - `vote ls` — list active on-chain proposals, mark tracked ones - `vote inspect ` — show full proposal details - `vote add [--hash]` — add proposal to voting config (interactive selection or direct hash) - `vote rm [--hash]` — remove proposal from voting config The voting task automatically votes for proposals listed in the config. SMA-68 --- src/Cargo.lock | 1 + src/node-control/commands/Cargo.toml | 1 + .../commands/src/command_manager.rs | 5 + .../commands/src/commands/cli_cmd.rs | 5 +- .../commands/src/commands/nodectl/mod.rs | 1 + .../commands/src/commands/nodectl/vote_cmd.rs | 413 ++++++++++++++++++ 6 files changed, 425 insertions(+), 1 deletion(-) create mode 100644 src/node-control/commands/src/commands/nodectl/vote_cmd.rs diff --git a/src/Cargo.lock b/src/Cargo.lock index 0b18404..6d34324 100644 --- a/src/Cargo.lock +++ b/src/Cargo.lock @@ -875,6 +875,7 @@ dependencies = [ "contracts", "control-client", "elections", + "hex", "reqwest", "rpassword", "scopeguard", diff --git a/src/node-control/commands/Cargo.toml b/src/node-control/commands/Cargo.toml index 0fb3ba8..b3464d9 100644 --- a/src/node-control/commands/Cargo.toml +++ b/src/node-control/commands/Cargo.toml @@ -12,6 +12,7 @@ clap = { version = "4.4", features = ["derive", "env"] } tracing = "0.1" base64 = "0.22.1" colored = "2.0" +hex = "0.4" reqwest = { version = "0.12.24", default-features = false, features = [ "json", "rustls-tls", diff --git a/src/node-control/commands/src/command_manager.rs b/src/node-control/commands/src/command_manager.rs index 3f83952..e9d1714 100644 --- a/src/node-control/commands/src/command_manager.rs +++ b/src/node-control/commands/src/command_manager.rs @@ -51,6 +51,11 @@ impl CommandManager { } // Service Commands::Service(cmd) => Ok(Some(cmd.run(cancellation_ctx).await?)), + // Voting + Commands::Vote(cmd) => { + cmd.run().await?; + Ok(None) + } } } } diff --git a/src/node-control/commands/src/commands/cli_cmd.rs b/src/node-control/commands/src/commands/cli_cmd.rs index 92830f4..99670c0 100644 --- a/src/node-control/commands/src/commands/cli_cmd.rs +++ b/src/node-control/commands/src/commands/cli_cmd.rs @@ -9,7 +9,7 @@ use crate::commands::{ nodectl::{ auth_cmd::AuthCmd, config_cmd::ConfigCmd, deploy_cmd::DeployCmd, key_cmd::KeyCmd, - service_api_cmd::ApiCmd, service_cmd::ServiceCmd, + service_api_cmd::ApiCmd, service_cmd::ServiceCmd, vote_cmd::VoteCmd, }, ton_http_api::get_config_param_cmd::GetConfigParamCmd, }; @@ -37,4 +37,7 @@ pub enum Commands { // Start as service #[command(name = "service")] Service(ServiceCmd), + // Config proposals voting + #[command(name = "vote")] + Vote(VoteCmd), } diff --git a/src/node-control/commands/src/commands/nodectl/mod.rs b/src/node-control/commands/src/commands/nodectl/mod.rs index 2afb520..3ca5c80 100644 --- a/src/node-control/commands/src/commands/nodectl/mod.rs +++ b/src/node-control/commands/src/commands/nodectl/mod.rs @@ -22,3 +22,4 @@ pub(crate) mod output_format; pub(crate) mod service_api_cmd; pub(crate) mod service_cmd; mod utils; +pub(crate) mod vote_cmd; diff --git a/src/node-control/commands/src/commands/nodectl/vote_cmd.rs b/src/node-control/commands/src/commands/nodectl/vote_cmd.rs new file mode 100644 index 0000000..64172ac --- /dev/null +++ b/src/node-control/commands/src/commands/nodectl/vote_cmd.rs @@ -0,0 +1,413 @@ +/* + * Copyright (C) 2025-2026 RSquad Blockchain Lab. + * + * Licensed under the GNU General Public License v3.0. + * See the LICENSE file in the root of this repository. + * + * This software is provided "AS IS", WITHOUT WARRANTY OF ANY KIND. + */ +use crate::commands::nodectl::{ + output_format::OutputFormat, + utils::{load_config_vault_rpc_client, save_config}, +}; +use anyhow::Context; +use base64::Engine; +use colored::Colorize; +use common::app_config::{AppConfig, VotingConfig}; +use contracts::{ConfigContractImpl, ConfigContractWrapper, ConfigProposal, contract_provider}; +use std::{io::Write, path::Path}; +use ton_block::write_boc; + +#[derive(clap::Args, Clone)] +#[command(about = "Config proposals voting")] +pub struct VoteCmd { + #[arg( + short = 'c', + long = "config", + help = "Path to the configuration file", + default_value = "nodectl-config.json", + env = "CONFIG_PATH", + global = true + )] + config: String, + + #[command(subcommand)] + action: VoteAction, +} + +#[derive(clap::Subcommand, Clone)] +enum VoteAction { + /// List active config proposals + Ls(VoteLsCmd), + /// Inspect a specific proposal + Inspect(VoteInspectCmd), + /// Add a proposal to the voting config + Add(VoteAddCmd), + /// Remove a proposal from the voting config + Rm(VoteRmCmd), +} + +impl VoteCmd { + pub async fn run(&self) -> anyhow::Result<()> { + match &self.action { + VoteAction::Ls(cmd) => cmd.run(&self.config).await, + VoteAction::Inspect(cmd) => cmd.run(&self.config).await, + VoteAction::Add(cmd) => cmd.run(&self.config).await, + VoteAction::Rm(cmd) => cmd.run(&self.config), + } + } +} + +// ── ls ────────────────────────────────────────────────────────────────────── + +#[derive(clap::Args, Clone)] +struct VoteLsCmd { + #[arg(long = "format", default_value = "table")] + format: OutputFormat, +} + +#[derive(serde::Serialize)] +struct ProposalRow { + hash: String, + param_id: i32, + is_critical: bool, + expires: u32, + voters_count: usize, + weight_remaining: i64, + rounds_remaining: u8, + wins: u8, + losses: u8, + tracked: bool, +} + +fn proposal_to_row(p: &ConfigProposal, tracked_hashes: &[String]) -> ProposalRow { + let hash = hex::encode(p.hash); + ProposalRow { + tracked: tracked_hashes.contains(&hash), + hash, + param_id: p.param.id, + is_critical: p.is_critical, + expires: p.expires, + voters_count: p.voters.len(), + weight_remaining: p.weight_remaining, + rounds_remaining: p.rounds_remaining, + wins: p.wins, + losses: p.losses, + } +} + +impl VoteLsCmd { + async fn run(&self, config_path: &str) -> anyhow::Result<()> { + let (config, _vault, rpc_client) = + load_config_vault_rpc_client(Path::new(config_path)).await?; + let config_contract = ConfigContractImpl::new(contract_provider!(rpc_client)); + + let proposals = config_contract.list_proposals().await.context("list_proposals")?; + + if proposals.is_empty() { + println!("No active proposals"); + return Ok(()); + } + + let tracked = config.voting.as_ref().map(|v| v.proposals.clone()).unwrap_or_default(); + let rows: Vec = + proposals.iter().map(|p| proposal_to_row(p, &tracked)).collect(); + + match self.format { + OutputFormat::Json => { + println!("{}", serde_json::to_string_pretty(&rows)?); + } + OutputFormat::Table => { + println!( + "\n {:<3} {:<66} {:<7} {:<9} {:<12} {:<7} {:<7} {}", + "".bold(), + "Hash".bold(), + "Param".bold(), + "Critical".bold(), + "Expires".bold(), + "Voters".bold(), + "Rounds".bold(), + "W/L".bold(), + ); + println!(" {}", "\u{2500}".repeat(124).dimmed()); + for row in &rows { + let marker = if row.tracked { "*" } else { " " }; + println!( + " {:<3} {:<66} p{:<6} {:<9} {:<12} {:<7} {:<7} {}/{}", + marker.green().bold(), + row.hash, + row.param_id, + if row.is_critical { "yes" } else { "no" }, + row.expires, + row.voters_count, + row.rounds_remaining, + row.wins, + row.losses, + ); + } + if tracked.iter().any(|h| rows.iter().any(|r| r.hash == *h)) { + println!("\n {} tracked by voting task", "*".green().bold()); + } + println!(); + } + } + + Ok(()) + } +} + +// ── inspect ───────────────────────────────────────────────────────────────── + +#[derive(clap::Args, Clone)] +struct VoteInspectCmd { + /// Proposal hash (hex) + hash: String, + + #[arg(long = "format", default_value = "table")] + format: OutputFormat, +} + +#[derive(serde::Serialize)] +struct ProposalDetail { + hash: String, + param_id: i32, + param_hash: Option, + param_cell_boc: Option, + is_critical: bool, + expires: u32, + voters: Vec, + weight_remaining: i64, + vset_id: String, + rounds_remaining: u8, + wins: u8, + losses: u8, +} + +impl From<&ConfigProposal> for ProposalDetail { + fn from(p: &ConfigProposal) -> Self { + Self { + hash: hex::encode(p.hash), + param_id: p.param.id, + param_hash: p.param.hash.map(hex::encode), + param_cell_boc: p.param.cell.as_ref().and_then(|c| { + write_boc(c).ok().map(|boc| base64::engine::general_purpose::STANDARD.encode(&boc)) + }), + is_critical: p.is_critical, + expires: p.expires, + voters: p.voters.clone(), + weight_remaining: p.weight_remaining, + vset_id: hex::encode(p.vset_id), + rounds_remaining: p.rounds_remaining, + wins: p.wins, + losses: p.losses, + } + } +} + +impl VoteInspectCmd { + async fn run(&self, config_path: &str) -> anyhow::Result<()> { + let phash = parse_proposal_hash(&self.hash)?; + let (_config, _vault, rpc_client) = + load_config_vault_rpc_client(Path::new(config_path)).await?; + let config_contract = ConfigContractImpl::new(contract_provider!(rpc_client)); + + let proposal = config_contract + .get_proposal(phash) + .await + .context("get_proposal")? + .ok_or_else(|| anyhow::anyhow!("proposal not found"))?; + + let detail = ProposalDetail::from(&proposal); + + match self.format { + OutputFormat::Json => { + println!("{}", serde_json::to_string_pretty(&detail)?); + } + OutputFormat::Table => { + println!("\n{}", "Proposal Details".bold()); + println!("{}", "\u{2500}".repeat(80).dimmed()); + println!(" {:<20} {}", "Hash:".bold(), detail.hash); + println!(" {:<20} p{}", "Param ID:".bold(), detail.param_id); + println!( + " {:<20} {}", + "Critical:".bold(), + if detail.is_critical { "yes" } else { "no" } + ); + println!(" {:<20} {}", "Expires:".bold(), detail.expires); + println!(" {:<20} {}", "Rounds remaining:".bold(), detail.rounds_remaining); + println!(" {:<20} {}", "Wins:".bold(), detail.wins); + println!(" {:<20} {}", "Losses:".bold(), detail.losses); + println!(" {:<20} {}", "Weight remaining:".bold(), detail.weight_remaining); + println!(" {:<20} {}", "Vset ID:".bold(), &detail.vset_id[..16]); + println!(" {:<20} {:?}", "Voters:".bold(), detail.voters); + if let Some(ref boc) = detail.param_cell_boc { + println!(" {:<20} {}", "Param cell (b64):".bold(), boc); + } + if let Some(ref h) = detail.param_hash { + println!(" {:<20} {}", "Param hash:".bold(), h); + } + println!(); + } + } + + Ok(()) + } +} + +// ── add ───────────────────────────────────────────────────────────────────── + +#[derive(clap::Args, Clone)] +struct VoteAddCmd { + /// Proposal hash (hex). If omitted, shows interactive selection. + #[arg(long = "hash")] + hash: Option, +} + +impl VoteAddCmd { + async fn run(&self, config_path: &str) -> anyhow::Result<()> { + let path = Path::new(config_path); + let (mut config, _vault, rpc_client) = load_config_vault_rpc_client(path).await?; + let config_contract = ConfigContractImpl::new(contract_provider!(rpc_client)); + + let proposals = config_contract.list_proposals().await.context("list_proposals")?; + if proposals.is_empty() { + anyhow::bail!("no active proposals on-chain"); + } + + let tracked = config.voting.as_ref().map(|v| v.proposals.clone()).unwrap_or_default(); + + let selected_hash = match &self.hash { + Some(h) => { + // Validate the hash exists on-chain + let phash = parse_proposal_hash(h)?; + if !proposals.iter().any(|p| p.hash == phash) { + anyhow::bail!("proposal {} not found on-chain", h); + } + h.clone() + } + None => select_proposal(&proposals, &tracked)?, + }; + + if tracked.contains(&selected_hash) { + println!("Proposal {} is already tracked", selected_hash); + return Ok(()); + } + + add_proposal_to_config(&mut config, &selected_hash); + save_config(&config, path)?; + + println!("{} proposal {} added to voting config", "OK".green().bold(), selected_hash); + Ok(()) + } +} + +// ── rm ────────────────────────────────────────────────────────────────────── + +#[derive(clap::Args, Clone)] +struct VoteRmCmd { + /// Proposal hash (hex). If omitted, shows interactive selection. + #[arg(long = "hash")] + hash: Option, +} + +impl VoteRmCmd { + fn run(&self, config_path: &str) -> anyhow::Result<()> { + let path = Path::new(config_path); + let mut config = AppConfig::load(path)?; + + let tracked = config.voting.as_ref().map(|v| v.proposals.clone()).unwrap_or_default(); + + if tracked.is_empty() { + println!("No proposals in voting config"); + return Ok(()); + } + + let selected_hash = match &self.hash { + Some(h) => { + if !tracked.contains(h) { + anyhow::bail!("proposal {} is not in voting config", h); + } + h.clone() + } + None => select_tracked_proposal(&tracked)?, + }; + + let voting = config.voting.as_mut().unwrap(); + voting.proposals.retain(|h| h != &selected_hash); + save_config(&config, path)?; + + println!("{} proposal {} removed from voting config", "OK".green().bold(), selected_hash); + Ok(()) + } +} + +// ── helpers ───────────────────────────────────────────────────────────────── + +fn parse_proposal_hash(s: &str) -> anyhow::Result<[u8; 32]> { + let bytes = hex::decode(s).context("invalid hex")?; + if bytes.len() != 32 { + anyhow::bail!("proposal hash must be 32 bytes, got {}", bytes.len()); + } + let mut out = [0u8; 32]; + out.copy_from_slice(&bytes); + Ok(out) +} + +fn select_proposal(proposals: &[ConfigProposal], tracked: &[String]) -> anyhow::Result { + println!("\n Active proposals:\n"); + for (i, p) in proposals.iter().enumerate() { + let hash = hex::encode(p.hash); + let marker = if tracked.contains(&hash) { "*" } else { " " }; + println!( + " {}{} [{}] p{} critical={} expires={} voters={}", + marker.green().bold(), + format!(" {}", i + 1).bold(), + &hash[..16], + p.param.id, + if p.is_critical { "yes" } else { "no" }, + p.expires, + p.voters.len(), + ); + } + if tracked.iter().any(|h| proposals.iter().any(|p| hex::encode(p.hash) == *h)) { + println!("\n {} already tracked", "*".green().bold()); + } + + let idx = prompt_selection(proposals.len())?; + Ok(hex::encode(proposals[idx].hash)) +} + +fn select_tracked_proposal(tracked: &[String]) -> anyhow::Result { + println!("\n Tracked proposals:\n"); + for (i, hash) in tracked.iter().enumerate() { + println!(" {} {}", format!(" {}", i + 1).bold(), hash); + } + + let idx = prompt_selection(tracked.len())?; + Ok(tracked[idx].clone()) +} + +fn prompt_selection(count: usize) -> anyhow::Result { + print!("\n Select [1-{}]: ", count); + std::io::stdout().flush()?; + + let mut input = String::new(); + std::io::stdin().read_line(&mut input)?; + let n: usize = input.trim().parse().context("invalid number")?; + if n < 1 || n > count { + anyhow::bail!("selection out of range"); + } + Ok(n - 1) +} + +fn add_proposal_to_config(config: &mut AppConfig, hash: &str) { + match config.voting.as_mut() { + Some(voting) => { + voting.proposals.push(hash.to_string()); + } + None => { + config.voting = + Some(VotingConfig { proposals: vec![hash.to_string()], tick_interval: 40 }); + } + } +} From 9d80f005578d35482c1f19bf3dbfffb9daa4c4c8 Mon Sep 17 00:00:00 2001 From: NikitaM <21960067+Keshoid@users.noreply.github.com> Date: Thu, 9 Apr 2026 19:49:19 +0300 Subject: [PATCH 2/4] refactor(voting): improve UX and fix hash case sensitivity --- .../commands/src/commands/nodectl/vote_cmd.rs | 115 +++++++++++++----- 1 file changed, 86 insertions(+), 29 deletions(-) diff --git a/src/node-control/commands/src/commands/nodectl/vote_cmd.rs b/src/node-control/commands/src/commands/nodectl/vote_cmd.rs index 64172ac..b8e0155 100644 --- a/src/node-control/commands/src/commands/nodectl/vote_cmd.rs +++ b/src/node-control/commands/src/commands/nodectl/vote_cmd.rs @@ -6,17 +6,20 @@ * * This software is provided "AS IS", WITHOUT WARRANTY OF ANY KIND. */ -use crate::commands::nodectl::{ - output_format::OutputFormat, - utils::{load_config_vault_rpc_client, save_config}, -}; +use crate::commands::nodectl::{output_format::OutputFormat, utils::save_config}; use anyhow::Context; use base64::Engine; use colored::Colorize; use common::app_config::{AppConfig, VotingConfig}; use contracts::{ConfigContractImpl, ConfigContractWrapper, ConfigProposal, contract_provider}; -use std::{io::Write, path::Path}; +use std::{ + io::{IsTerminal, Write}, + path::Path, + sync::Arc, + time::SystemTime, +}; use ton_block::write_boc; +use ton_http_api_client::v2::client_json_rpc::ClientJsonRpc; #[derive(clap::Args, Clone)] #[command(about = "Config proposals voting")] @@ -72,6 +75,7 @@ struct ProposalRow { param_id: i32, is_critical: bool, expires: u32, + expires_in: String, voters_count: usize, weight_remaining: i64, rounds_remaining: u8, @@ -88,6 +92,7 @@ fn proposal_to_row(p: &ConfigProposal, tracked_hashes: &[String]) -> ProposalRow param_id: p.param.id, is_critical: p.is_critical, expires: p.expires, + expires_in: format_expires(p.expires), voters_count: p.voters.len(), weight_remaining: p.weight_remaining, rounds_remaining: p.rounds_remaining, @@ -98,8 +103,7 @@ fn proposal_to_row(p: &ConfigProposal, tracked_hashes: &[String]) -> ProposalRow impl VoteLsCmd { async fn run(&self, config_path: &str) -> anyhow::Result<()> { - let (config, _vault, rpc_client) = - load_config_vault_rpc_client(Path::new(config_path)).await?; + let (config, rpc_client) = load_config_rpc(config_path)?; let config_contract = ConfigContractImpl::new(contract_provider!(rpc_client)); let proposals = config_contract.list_proposals().await.context("list_proposals")?; @@ -109,7 +113,7 @@ impl VoteLsCmd { return Ok(()); } - let tracked = config.voting.as_ref().map(|v| v.proposals.clone()).unwrap_or_default(); + let tracked = tracked_proposals(&config); let rows: Vec = proposals.iter().map(|p| proposal_to_row(p, &tracked)).collect(); @@ -119,7 +123,7 @@ impl VoteLsCmd { } OutputFormat::Table => { println!( - "\n {:<3} {:<66} {:<7} {:<9} {:<12} {:<7} {:<7} {}", + "\n {:<3} {:<66} {:<7} {:<9} {:<14} {:<7} {:<7} {}", "".bold(), "Hash".bold(), "Param".bold(), @@ -129,23 +133,23 @@ impl VoteLsCmd { "Rounds".bold(), "W/L".bold(), ); - println!(" {}", "\u{2500}".repeat(124).dimmed()); + println!(" {}", "\u{2500}".repeat(126).dimmed()); for row in &rows { let marker = if row.tracked { "*" } else { " " }; println!( - " {:<3} {:<66} p{:<6} {:<9} {:<12} {:<7} {:<7} {}/{}", + " {:<3} {:<66} p{:<6} {:<9} {:<14} {:<7} {:<7} {}/{}", marker.green().bold(), row.hash, row.param_id, if row.is_critical { "yes" } else { "no" }, - row.expires, + row.expires_in, row.voters_count, row.rounds_remaining, row.wins, row.losses, ); } - if tracked.iter().any(|h| rows.iter().any(|r| r.hash == *h)) { + if rows.iter().any(|r| r.tracked) { println!("\n {} tracked by voting task", "*".green().bold()); } println!(); @@ -175,6 +179,7 @@ struct ProposalDetail { param_cell_boc: Option, is_critical: bool, expires: u32, + expires_in: String, voters: Vec, weight_remaining: i64, vset_id: String, @@ -194,6 +199,7 @@ impl From<&ConfigProposal> for ProposalDetail { }), is_critical: p.is_critical, expires: p.expires, + expires_in: format_expires(p.expires), voters: p.voters.clone(), weight_remaining: p.weight_remaining, vset_id: hex::encode(p.vset_id), @@ -207,8 +213,7 @@ impl From<&ConfigProposal> for ProposalDetail { impl VoteInspectCmd { async fn run(&self, config_path: &str) -> anyhow::Result<()> { let phash = parse_proposal_hash(&self.hash)?; - let (_config, _vault, rpc_client) = - load_config_vault_rpc_client(Path::new(config_path)).await?; + let (_config, rpc_client) = load_config_rpc(config_path)?; let config_contract = ConfigContractImpl::new(contract_provider!(rpc_client)); let proposal = config_contract @@ -233,12 +238,16 @@ impl VoteInspectCmd { "Critical:".bold(), if detail.is_critical { "yes" } else { "no" } ); - println!(" {:<20} {}", "Expires:".bold(), detail.expires); + println!(" {:<20} {} ({})", "Expires:".bold(), detail.expires, detail.expires_in); println!(" {:<20} {}", "Rounds remaining:".bold(), detail.rounds_remaining); println!(" {:<20} {}", "Wins:".bold(), detail.wins); println!(" {:<20} {}", "Losses:".bold(), detail.losses); println!(" {:<20} {}", "Weight remaining:".bold(), detail.weight_remaining); - println!(" {:<20} {}", "Vset ID:".bold(), &detail.vset_id[..16]); + println!( + " {:<20} {}...", + "Vset ID:".bold(), + &detail.vset_id[..detail.vset_id.len().min(16)] + ); println!(" {:<20} {:?}", "Voters:".bold(), detail.voters); if let Some(ref boc) = detail.param_cell_boc { println!(" {:<20} {}", "Param cell (b64):".bold(), boc); @@ -266,7 +275,7 @@ struct VoteAddCmd { impl VoteAddCmd { async fn run(&self, config_path: &str) -> anyhow::Result<()> { let path = Path::new(config_path); - let (mut config, _vault, rpc_client) = load_config_vault_rpc_client(path).await?; + let (mut config, rpc_client) = load_config_rpc(config_path)?; let config_contract = ConfigContractImpl::new(contract_provider!(rpc_client)); let proposals = config_contract.list_proposals().await.context("list_proposals")?; @@ -274,18 +283,20 @@ impl VoteAddCmd { anyhow::bail!("no active proposals on-chain"); } - let tracked = config.voting.as_ref().map(|v| v.proposals.clone()).unwrap_or_default(); + let tracked = tracked_proposals(&config); let selected_hash = match &self.hash { Some(h) => { - // Validate the hash exists on-chain let phash = parse_proposal_hash(h)?; if !proposals.iter().any(|p| p.hash == phash) { anyhow::bail!("proposal {} not found on-chain", h); } - h.clone() + hex::encode(phash) + } + None => { + require_interactive()?; + select_proposal(&proposals, &tracked)? } - None => select_proposal(&proposals, &tracked)?, }; if tracked.contains(&selected_hash) { @@ -315,7 +326,7 @@ impl VoteRmCmd { let path = Path::new(config_path); let mut config = AppConfig::load(path)?; - let tracked = config.voting.as_ref().map(|v| v.proposals.clone()).unwrap_or_default(); + let tracked = tracked_proposals(&config); if tracked.is_empty() { println!("No proposals in voting config"); @@ -324,12 +335,16 @@ impl VoteRmCmd { let selected_hash = match &self.hash { Some(h) => { - if !tracked.contains(h) { + let normalized = h.to_lowercase(); + if !tracked.contains(&normalized) { anyhow::bail!("proposal {} is not in voting config", h); } - h.clone() + normalized + } + None => { + require_interactive()?; + select_tracked_proposal(&tracked)? } - None => select_tracked_proposal(&tracked)?, }; let voting = config.voting.as_mut().unwrap(); @@ -343,6 +358,22 @@ impl VoteRmCmd { // ── helpers ───────────────────────────────────────────────────────────────── +fn load_config_rpc(config_path: &str) -> anyhow::Result<(AppConfig, Arc)> { + let config = AppConfig::load(Path::new(config_path))?; + let rpc_client = Arc::new( + ClientJsonRpc::connect_many( + config.ton_http_api.resolved_endpoints(), + config.ton_http_api.api_key.clone(), + ) + .context("ClientJsonRpc")?, + ); + Ok((config, rpc_client)) +} + +fn tracked_proposals(config: &AppConfig) -> Vec { + config.voting.as_ref().map(|v| v.proposals.clone()).unwrap_or_default() +} + fn parse_proposal_hash(s: &str) -> anyhow::Result<[u8; 32]> { let bytes = hex::decode(s).context("invalid hex")?; if bytes.len() != 32 { @@ -353,19 +384,45 @@ fn parse_proposal_hash(s: &str) -> anyhow::Result<[u8; 32]> { Ok(out) } +fn format_expires(expires: u32) -> String { + let now = SystemTime::now().duration_since(SystemTime::UNIX_EPOCH).unwrap_or_default().as_secs() + as u32; + if expires <= now { + return "expired".to_string(); + } + let diff = expires - now; + let days = diff / 86400; + let hours = (diff % 86400) / 3600; + let mins = (diff % 3600) / 60; + if days > 0 { + format!("in {}d {}h", days, hours) + } else if hours > 0 { + format!("in {}h {}m", hours, mins) + } else { + format!("in {}m", mins) + } +} + +fn require_interactive() -> anyhow::Result<()> { + if !std::io::stdin().is_terminal() { + anyhow::bail!("--hash is required in non-interactive mode"); + } + Ok(()) +} + fn select_proposal(proposals: &[ConfigProposal], tracked: &[String]) -> anyhow::Result { println!("\n Active proposals:\n"); for (i, p) in proposals.iter().enumerate() { let hash = hex::encode(p.hash); let marker = if tracked.contains(&hash) { "*" } else { " " }; println!( - " {}{} [{}] p{} critical={} expires={} voters={}", + " {}{} [{}] p{} critical={} {} voters={}", marker.green().bold(), format!(" {}", i + 1).bold(), &hash[..16], p.param.id, if p.is_critical { "yes" } else { "no" }, - p.expires, + format_expires(p.expires), p.voters.len(), ); } @@ -394,7 +451,7 @@ fn prompt_selection(count: usize) -> anyhow::Result { let mut input = String::new(); std::io::stdin().read_line(&mut input)?; let n: usize = input.trim().parse().context("invalid number")?; - if n < 1 || n > count { + if n == 0 || n > count { anyhow::bail!("selection out of range"); } Ok(n - 1) From f5438658b9e58322f80cc90daab63d041363065a Mon Sep 17 00:00:00 2001 From: NikitaM <21960067+Keshoid@users.noreply.github.com> Date: Fri, 10 Apr 2026 00:36:21 +0300 Subject: [PATCH 3/4] style(voting): format table header with cyan color for better readability --- .../commands/src/commands/nodectl/vote_cmd.rs | 45 ++++++++++--------- 1 file changed, 23 insertions(+), 22 deletions(-) diff --git a/src/node-control/commands/src/commands/nodectl/vote_cmd.rs b/src/node-control/commands/src/commands/nodectl/vote_cmd.rs index b8e0155..6448aff 100644 --- a/src/node-control/commands/src/commands/nodectl/vote_cmd.rs +++ b/src/node-control/commands/src/commands/nodectl/vote_cmd.rs @@ -125,13 +125,13 @@ impl VoteLsCmd { println!( "\n {:<3} {:<66} {:<7} {:<9} {:<14} {:<7} {:<7} {}", "".bold(), - "Hash".bold(), - "Param".bold(), - "Critical".bold(), - "Expires".bold(), - "Voters".bold(), - "Rounds".bold(), - "W/L".bold(), + "Hash".cyan().bold(), + "Param".cyan().bold(), + "Critical".cyan().bold(), + "Expires".cyan().bold(), + "Voters".cyan().bold(), + "Rounds".cyan().bold(), + "W/L".cyan().bold(), ); println!(" {}", "\u{2500}".repeat(126).dimmed()); for row in &rows { @@ -229,31 +229,32 @@ impl VoteInspectCmd { println!("{}", serde_json::to_string_pretty(&detail)?); } OutputFormat::Table => { - println!("\n{}", "Proposal Details".bold()); + println!("\n{}", "Proposal Details".cyan().bold()); println!("{}", "\u{2500}".repeat(80).dimmed()); - println!(" {:<20} {}", "Hash:".bold(), detail.hash); - println!(" {:<20} p{}", "Param ID:".bold(), detail.param_id); + println!(" {:<20} {}", "Hash:".cyan().bold(), detail.hash); + println!(" {:<20} p{}", "Param ID:".cyan().bold(), detail.param_id); println!( " {:<20} {}", - "Critical:".bold(), + "Critical:".cyan().bold(), if detail.is_critical { "yes" } else { "no" } ); - println!(" {:<20} {} ({})", "Expires:".bold(), detail.expires, detail.expires_in); - println!(" {:<20} {}", "Rounds remaining:".bold(), detail.rounds_remaining); - println!(" {:<20} {}", "Wins:".bold(), detail.wins); - println!(" {:<20} {}", "Losses:".bold(), detail.losses); - println!(" {:<20} {}", "Weight remaining:".bold(), detail.weight_remaining); println!( - " {:<20} {}...", - "Vset ID:".bold(), - &detail.vset_id[..detail.vset_id.len().min(16)] + " {:<20} {} ({})", + "Expires:".cyan().bold(), + detail.expires, + detail.expires_in ); - println!(" {:<20} {:?}", "Voters:".bold(), detail.voters); + println!(" {:<20} {}", "Rounds remaining:".cyan().bold(), detail.rounds_remaining); + println!(" {:<20} {}", "Wins:".cyan().bold(), detail.wins); + println!(" {:<20} {}", "Losses:".cyan().bold(), detail.losses); + println!(" {:<20} {}", "Weight remaining:".cyan().bold(), detail.weight_remaining); + println!(" {:<20} {}", "Vset ID:".cyan().bold(), &detail.vset_id); + println!(" {:<20} {:?}", "Voters:".cyan().bold(), detail.voters); if let Some(ref boc) = detail.param_cell_boc { - println!(" {:<20} {}", "Param cell (b64):".bold(), boc); + println!(" {:<20} {}", "Param cell (b64):".cyan().bold(), boc); } if let Some(ref h) = detail.param_hash { - println!(" {:<20} {}", "Param hash:".bold(), h); + println!(" {:<20} {}", "Param hash:".cyan().bold(), h); } println!(); } From 93cbda5193b7c7b89099977947dc876221af23cf Mon Sep 17 00:00:00 2001 From: NikitaM <21960067+Keshoid@users.noreply.github.com> Date: Fri, 10 Apr 2026 00:37:11 +0300 Subject: [PATCH 4/4] refactor(service): treat not a validator as a warning but not error --- src/node-control/service/src/voting/voting_task.rs | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/src/node-control/service/src/voting/voting_task.rs b/src/node-control/service/src/voting/voting_task.rs index c9d0b13..a30ac9c 100644 --- a/src/node-control/service/src/voting/voting_task.rs +++ b/src/node-control/service/src/voting/voting_task.rs @@ -183,7 +183,11 @@ impl VotingRunner { vset: &ValidatorSet, ) -> anyhow::Result<()> { let node = self.nodes.get_mut(node_id).expect("node not found"); - let (validator_idx, validator_entry) = Self::find_validator_entry(node, vset).await?; + let Some((validator_idx, validator_entry)) = Self::find_validator_entry(node, vset).await? + else { + tracing::warn!(target: "voting", "node [{}] voting skipped: not a validator", node_id); + return Ok(()); + }; if proposal.voters.contains(&validator_idx) { tracing::info!(target: "voting", @@ -250,7 +254,7 @@ impl VotingRunner { async fn find_validator_entry( node: &mut Node, vset: &ValidatorSet, - ) -> anyhow::Result<(u16, ValidatorEntry)> { + ) -> anyhow::Result> { let config = node .api .validator_config() @@ -281,10 +285,10 @@ impl VotingRunner { .position(|item| item.public_key.as_slice() == &key) .map(|idx| (idx as u16, entry.clone())); if let Some((idx, entry)) = vset_entry { - return Ok((idx, entry)); + return Ok(Some((idx, entry))); } } - anyhow::bail!("not a validator") + Ok(None) } async fn shutdown(&mut self) -> anyhow::Result<()> {