diff --git a/src/node-control/README.md b/src/node-control/README.md index 364d127..c596b8c 100644 --- a/src/node-control/README.md +++ b/src/node-control/README.md @@ -518,7 +518,7 @@ nodectl config elections tick-interval 60 ##### `config elections max-factor` -Set the maximum factor for elections. Must be in the range [1.0..3.0]. +Set the maximum stake factor for elections. The value must be between **1.0** and the network’s **maximum stake factor** from masterchain **config param 17** (`max_stake_factor`). nodectl does not use a hardcoded upper bound (e.g. 3.0): the CLI reads the current limit from the chain when validating and saving. | Argument | Description | |----------|-------------| @@ -1403,7 +1403,7 @@ Automatic elections task configuration: - `"minimum"` — use minimum required stake - `{ "fixed": }` — fixed stake amount in nanoTON - `policy_overrides` — per-node stake policy overrides (node name -> policy). When a node has an entry here, it takes precedence over the default `policy`. Example: `{ "node0": { "fixed": 500000000000 } }` -- `max_factor` — max factor for elections (default: 3.0, must be in range [1.0..3.0]) +- `max_factor` — maximum stake factor (default `3.0` in generated configs). Valid values lie in `[1.0, network_max]`, where **`network_max` comes from masterchain config param 17** (`max_stake_factor`); the CLI and stake command validate against the live network when TON HTTP API is available - `tick_interval` — interval between election checks in seconds (default: `40`) #### `voting` (optional) @@ -1720,7 +1720,7 @@ nodectl config wallet stake -b -a [-m ] |------|------|----------|---------|-------------| | `-b` | `--binding` | Yes | — | Binding name (node-wallet-pool triple) | | `-a` | `--amount` | Yes | — | Stake amount in TON | -| `-m` | `--max-factor` | No | `3.0` | Max factor (`1.0`–`3.0`) | +| `-m` | `--max-factor` | No | `3.0` | Max factor: from `1.0` up to the network limit (**config param 17**), validated against the chain | Example: diff --git a/src/node-control/commands/src/commands/nodectl/config_elections_cmd.rs b/src/node-control/commands/src/commands/nodectl/config_elections_cmd.rs index 4dae1cd..49d355d 100644 --- a/src/node-control/commands/src/commands/nodectl/config_elections_cmd.rs +++ b/src/node-control/commands/src/commands/nodectl/config_elections_cmd.rs @@ -6,7 +6,11 @@ * * This software is provided "AS IS", WITHOUT WARRANTY OF ANY KIND. */ -use crate::commands::nodectl::{output_format::OutputFormat, utils::save_config}; +use crate::commands::nodectl::{ + output_format::OutputFormat, + utils::{load_config_vault_rpc_client, save_config}, +}; +use anyhow::Context; use colored::Colorize; use common::{ app_config::{AppConfig, BindingStatus, ElectionsConfig, StakePolicy}, @@ -71,7 +75,9 @@ pub struct TickIntervalCmd { #[derive(clap::Args, Clone)] pub struct MaxFactorCmd { - #[arg(help = "Max factor (1.0..3.0)")] + #[arg( + help = "Max factor: from 1.0 up to the network limit (config param 17 max_stake_factor)" + )] value: f32, } @@ -220,8 +226,16 @@ impl TickIntervalCmd { impl MaxFactorCmd { pub async fn run(&self, path: &Path) -> anyhow::Result<()> { - if !(1.0..=3.0).contains(&self.value) { - anyhow::bail!("max-factor must be in range [1.0..3.0]"); + let (_app, _vault, rpc_client) = load_config_vault_rpc_client(path).await?; + let network_max = rpc_client + .network_max_stake_factor_multiplier() + .await + .context("read max_stake_factor from chain (config param 17)")?; + if !(1.0..=network_max).contains(&self.value) { + anyhow::bail!( + "max-factor must be in range [1.0..{}] (network max_stake_factor from config param 17)", + network_max + ); } let mut config = AppConfig::load(path)?; config diff --git a/src/node-control/commands/src/commands/nodectl/config_wallet_cmd.rs b/src/node-control/commands/src/commands/nodectl/config_wallet_cmd.rs index 2d57bc2..5619613 100644 --- a/src/node-control/commands/src/commands/nodectl/config_wallet_cmd.rs +++ b/src/node-control/commands/src/commands/nodectl/config_wallet_cmd.rs @@ -120,7 +120,12 @@ pub struct WalletStakeCmd { binding: String, #[arg(short = 'a', long = "amount", help = "Stake amount in TONs")] amount: f64, - #[arg(short = 'm', long = "max-factor", default_value = "3.0", help = "Max factor (1.0..3.0)")] + #[arg( + short = 'm', + long = "max-factor", + default_value = "3.0", + help = "Max factor from 1.0 up to the network limit (config param 17)" + )] max_factor: f32, } @@ -430,11 +435,17 @@ impl WalletSendCmd { impl WalletStakeCmd { pub async fn run(&self, path: &Path, cancellation_ctx: CancellationCtx) -> anyhow::Result<()> { - if !(1.0..=3.0).contains(&self.max_factor) { - anyhow::bail!("max-factor must be between 1.0 and 3.0"); - } - let (config, vault, rpc_client) = load_config_vault_rpc_client(path).await?; + let network_max = rpc_client + .network_max_stake_factor_multiplier() + .await + .context("read max_stake_factor from chain (config param 17)")?; + if !(1.0..=network_max).contains(&self.max_factor) { + anyhow::bail!( + "max-factor must be in range [1.0..{}] (network max_stake_factor from config param 17)", + network_max + ); + } // Resolve binding → wallet, pool, node let binding = config diff --git a/src/node-control/common/src/app_config.rs b/src/node-control/common/src/app_config.rs index 21d38ba..080db4c 100644 --- a/src/node-control/common/src/app_config.rs +++ b/src/node-control/common/src/app_config.rs @@ -459,7 +459,7 @@ fn default_workchain() -> i32 { -1 } -fn default_max_factor() -> f32 { +pub fn default_max_factor() -> f32 { 3.0 } @@ -506,10 +506,28 @@ impl ElectionsConfig { self.policy_overrides.get(node_id).unwrap_or(&self.policy) } - pub fn validate(&self) -> anyhow::Result<()> { - if !(1.0..=3.0).contains(&self.max_factor) { - anyhow::bail!("max_factor must be in range [1.0..3.0]"); + /// Validates elections settings. + /// + /// - `None`: only checks `max_factor >= 1.0` (e.g. [`AppConfig::load`] without RPC). No upper bound. + /// - `Some(m)`: `max_factor` must be in `[1.0, m]` where `m` is from config param 17 (service startup). + pub fn validate(&self, max_stake_factor_upper_bound: Option) -> anyhow::Result<()> { + self.validate_timing_fields()?; + match max_stake_factor_upper_bound { + None => { + if self.max_factor < 1.0 { + anyhow::bail!("max_factor must be >= 1.0"); + } + } + Some(m) => { + if !(1.0..=m).contains(&self.max_factor) { + anyhow::bail!("max_factor must be in range [1.0..{}]", m); + } + } } + Ok(()) + } + + fn validate_timing_fields(&self) -> anyhow::Result<()> { if !(0.0..=1.0).contains(&self.sleep_period_pct) { anyhow::bail!("sleep_period_pct must be in range [0.0..1.0]"); } @@ -714,7 +732,7 @@ impl AppConfig { } fn validate(&self) -> anyhow::Result<()> { - self.elections.as_ref().map(|e| e.validate()).transpose()?; + self.elections.as_ref().map(|e| e.validate(None)).transpose()?; Ok(()) } } @@ -756,6 +774,25 @@ mod tests { assert_eq!(stake, 10); } + #[test] + fn test_elections_validate_max_factor_respects_network_cap() { + let mut c = ElectionsConfig::default(); + c.max_factor = 5.0; + assert!(c.validate(Some(default_max_factor())).is_err()); + assert!(c.validate(Some(5.0)).is_ok()); + c.max_factor = 2.0; + assert!(c.validate(Some(default_max_factor())).is_ok()); + } + + #[test] + fn test_elections_validate_none_allows_max_factor_above_default_cap() { + let mut c = ElectionsConfig::default(); + c.max_factor = 25.0; + assert!(c.validate(None).is_ok()); + assert!(c.validate(Some(3.0)).is_err()); + assert!(c.validate(Some(30.0)).is_ok()); + } + #[test] fn test_calculate_stake_split50_ok() { let policy = StakePolicy::Split50; diff --git a/src/node-control/common/src/ton_utils.rs b/src/node-control/common/src/ton_utils.rs index 8837072..ebff8c8 100644 --- a/src/node-control/common/src/ton_utils.rs +++ b/src/node-control/common/src/ton_utils.rs @@ -18,6 +18,15 @@ pub fn nanotons_to_tons_f64(nanotons: u64) -> f64 { nanotons as f64 / 1_000_000_000.0 } +/// Elector uses fixed-point `max_stake_factor`: raw value is multiplier × 65536 (e.g. 3× → `3 * 65536`). +pub const MAX_STAKE_FACTOR_SCALE: u32 = 65536; + +/// Converts chain `max_stake_factor` (raw) to float multiplier (e.g. `196608` → `3.0`). +#[inline] +pub fn max_stake_factor_raw_to_multiplier(raw: u32) -> f32 { + raw as f32 / MAX_STAKE_FACTOR_SCALE as f32 +} + pub fn display_tons(nanotons: u64) -> String { format!("{:.4}", nanotons_to_tons_f64(nanotons)) .trim_end_matches('0') diff --git a/src/node-control/elections/src/runner.rs b/src/node-control/elections/src/runner.rs index a8394a4..e57d1a0 100644 --- a/src/node-control/elections/src/runner.rs +++ b/src/node-control/elections/src/runner.rs @@ -549,7 +549,8 @@ impl ElectionRunner { election_id: u64, params: &ConfigParams<'_>, ) -> anyhow::Result<()> { - let max_factor = (self.calc_max_factor() * 65536.0) as u32; + let max_factor = ((self.calc_max_factor() * 65536.0) as u32) + .clamp(common::ton_utils::MAX_STAKE_FACTOR_SCALE, params.cfg17.max_stake_factor); let stake_ctx = StakeContext { past_elections: &self.past_elections, our_max_factor: max_factor, @@ -691,7 +692,7 @@ impl ElectionRunner { max_factor, }); node.key_id = key_id; - Self::send_stake(node_id, node, stake).await?; + Self::send_stake(node_id, node, stake, params.cfg17.max_stake_factor).await?; Ok(()) } Some(entry) => { @@ -717,7 +718,8 @@ impl ElectionRunner { nanotons_to_tons_f64(old_stake + stake), nanotons_to_tons_f64(stake), ); - Self::send_stake(node_id, node, stake).await?; + Self::send_stake(node_id, node, stake, params.cfg17.max_stake_factor) + .await?; node.participant.as_mut().map(|p| p.stake += stake); } } @@ -726,7 +728,8 @@ impl ElectionRunner { if let Some(p) = node.participant.as_mut() { p.stake = stake; } - Self::send_stake(node_id, node, stake).await?; + Self::send_stake(node_id, node, stake, params.cfg17.max_stake_factor) + .await?; } } Ok(()) @@ -734,9 +737,15 @@ impl ElectionRunner { } } - async fn send_stake(node_id: &str, node: &mut Node, stake: u64) -> anyhow::Result<()> { + async fn send_stake( + node_id: &str, + node: &mut Node, + stake: u64, + network_max_stake_factor: u32, + ) -> anyhow::Result<()> { tracing::info!("node [{}] build stake message", node_id); - let payload = Self::build_new_stake_payload(node_id, node, stake).await?; + let payload = + Self::build_new_stake_payload(node_id, node, stake, network_max_stake_factor).await?; // For simplicity we always assume that the node has nominator pool. let fee = ELECTOR_STAKE_FEE + NPOOL_COMPUTE_FEE; let stake_balance = node.stake_balance(fee).await?; @@ -782,6 +791,7 @@ impl ElectionRunner { node_id: &str, node: &mut Node, stake: u64, + network_max_stake_factor: u32, ) -> anyhow::Result { let Some(participant) = &mut node.participant else { anyhow::bail!("node [{}] no participant info", node_id); @@ -798,8 +808,12 @@ impl ElectionRunner { participant.adnl_addr.as_slice() ) ); - if !(1.0..=3.0).contains(&(participant.max_factor as f32 / 65536.0)) { - anyhow::bail!(" must be a real number 1..3"); + let scale = common::ton_utils::MAX_STAKE_FACTOR_SCALE; + if participant.max_factor < scale || participant.max_factor > network_max_stake_factor { + anyhow::bail!( + " must be between 1.0 and {} (network max_stake_factor from config param 17)", + common::ton_utils::max_stake_factor_raw_to_multiplier(network_max_stake_factor) + ); } // todo: move to ElectorWrapper // validator-elect-req.fif diff --git a/src/node-control/elections/src/runner_tests.rs b/src/node-control/elections/src/runner_tests.rs index 702534e..13bd9d7 100644 --- a/src/node-control/elections/src/runner_tests.rs +++ b/src/node-control/elections/src/runner_tests.rs @@ -2382,7 +2382,7 @@ fn test_elections_config_validate_sleep_gt_waiting() { waiting_period_pct: 0.3, // sleep > waiting → invalid ..ElectionsConfig::default() }; - assert!(config.validate().is_err()); + assert!(config.validate(None).is_err()); } #[test] @@ -2391,7 +2391,7 @@ fn test_elections_config_validate_sleep_out_of_range() { sleep_period_pct: 1.5, // > 1.0 → invalid ..ElectionsConfig::default() }; - assert!(config.validate().is_err()); + assert!(config.validate(None).is_err()); } #[test] @@ -2401,7 +2401,7 @@ fn test_elections_config_validate_valid() { waiting_period_pct: 0.5, ..ElectionsConfig::default() }; - assert!(config.validate().is_ok()); + assert!(config.validate(None).is_ok()); } #[test] diff --git a/src/node-control/service/src/runtime_config.rs b/src/node-control/service/src/runtime_config.rs index 45be384..1f77613 100644 --- a/src/node-control/service/src/runtime_config.rs +++ b/src/node-control/service/src/runtime_config.rs @@ -93,6 +93,15 @@ impl RuntimeConfigStore { let vault = Some(SecretVaultBuilder::from_env().await?); let rpc_client = Self::load_rpc_client(&app_cfg).await?; + if let Some(elections) = app_cfg.elections.as_ref() { + let network_max = rpc_client + .network_max_stake_factor_multiplier() + .await + .context("read max_stake_factor for elections config validation")?; + elections + .validate(Some(network_max)) + .context("elections max_factor vs chain (config param 17)")?; + } let master_wallet = Self::load_master_wallet(&app_cfg, rpc_client.clone(), vault.clone()).await?; let wallets = Self::load_wallets(&app_cfg, rpc_client.clone(), vault.clone()).await?; @@ -116,6 +125,15 @@ impl RuntimeConfigStore { async fn reload(&self, new_config: AppConfig) -> anyhow::Result<()> { let vault = SecretVaultBuilder::from_env().await.context("failed to reopen vault")?; let rpc_client = Self::load_rpc_client(&new_config).await?; + if let Some(elections) = new_config.elections.as_ref() { + let network_max = rpc_client + .network_max_stake_factor_multiplier() + .await + .context("read max_stake_factor for elections config validation")?; + elections + .validate(Some(network_max)) + .context("elections max_factor vs chain (config param 17)")?; + } let master_wallet = Self::load_master_wallet(&new_config, rpc_client.clone(), Some(vault.clone())).await?; let wallets = diff --git a/src/node-control/ton-http-api-client/src/v2/client_json_rpc.rs b/src/node-control/ton-http-api-client/src/v2/client_json_rpc.rs index 246eb48..1031819 100644 --- a/src/node-control/ton-http-api-client/src/v2/client_json_rpc.rs +++ b/src/node-control/ton-http-api-client/src/v2/client_json_rpc.rs @@ -177,6 +177,20 @@ impl ClientJsonRpc { Ok(config_param) } + /// Global `max_stake_factor` from config param 17 (raw fixed-point, multiplier ×65536). + pub async fn network_max_stake_factor_raw(&self) -> anyhow::Result { + match self.get_config_param(17).await? { + ConfigParamEnum::ConfigParam17(c) => Ok(c.max_stake_factor), + _ => anyhow::bail!("expected config param 17 (stakes config)"), + } + } + + /// Same as [`Self::network_max_stake_factor_raw`], as float multiplier (e.g. `3.0`). + pub async fn network_max_stake_factor_multiplier(&self) -> anyhow::Result { + let raw = self.network_max_stake_factor_raw().await?; + Ok(common::ton_utils::max_stake_factor_raw_to_multiplier(raw)) + } + pub async fn run_get_method( &self, args: &RunGetMethodParams,