diff --git a/src/node-control/README.md b/src/node-control/README.md index 364d127..b3c697f 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_factor]`, where **`network_max_factor` 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..29f2e3b 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,10 @@ * * 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::{fetch_network_max_factor, save_config, try_create_rpc_client}, +}; use colored::Colorize; use common::{ app_config::{AppConfig, BindingStatus, ElectionsConfig, StakePolicy}, @@ -71,7 +74,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,10 +225,18 @@ 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 mut config = AppConfig::load(path)?; + config.elections.as_ref().ok_or_else(|| anyhow::anyhow!("Elections are not configured"))?; + + let rpc_client = try_create_rpc_client(&config).await?; + let network_max_factor = fetch_network_max_factor(&rpc_client).await?; + if !(1.0..=network_max_factor).contains(&self.value) { + anyhow::bail!( + "max-factor must be in range [1.0..{}] (network max_stake_factor from config param 17)", + network_max_factor + ); + } + config .elections .as_mut() 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 5a07cf5..a9bd6e6 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 @@ -9,10 +9,10 @@ use crate::commands::nodectl::{ output_format::OutputFormat, utils::{ - MASTER_WALLET_RESERVED_NAME, SEND_TIMEOUT, check_ton_api_connection, get_wallet_config, - load_config_vault, load_config_vault_rpc_client, make_wallet, save_config, - wait_for_seqno_change, wallet_address, wallet_info, warn_missing_secret, - warn_ton_api_unavailable, + MASTER_WALLET_RESERVED_NAME, SEND_TIMEOUT, check_ton_api_connection, + fetch_network_max_factor, get_wallet_config, load_config_vault, + load_config_vault_rpc_client, make_wallet, save_config, wait_for_seqno_change, + wallet_address, wallet_info, warn_missing_secret, warn_ton_api_unavailable, }, }; use anyhow::Context; @@ -121,7 +121,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, } @@ -435,11 +440,14 @@ 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_factor = fetch_network_max_factor(&rpc_client).await?; + if !(1.0..=network_max_factor).contains(&self.max_factor) { + anyhow::bail!( + "max-factor must be in range [1.0..{}] (network max_stake_factor from config param 17)", + network_max_factor + ); + } // Resolve binding → wallet, pool, node let binding = config diff --git a/src/node-control/commands/src/commands/nodectl/utils.rs b/src/node-control/commands/src/commands/nodectl/utils.rs index df78305..798195f 100644 --- a/src/node-control/commands/src/commands/nodectl/utils.rs +++ b/src/node-control/commands/src/commands/nodectl/utils.rs @@ -11,6 +11,7 @@ use colored::Colorize; use common::{ app_config::{AppConfig, WalletConfig}, task_cancellation::CancellationCtx, + ton_utils::extract_max_factor, vault_signer::VaultSigner, }; use contracts::{WalletContract, contract_provider}; @@ -32,6 +33,11 @@ pub const DEPLOY_TIMEOUT: tokio::time::Duration = tokio::time::Duration::from_se /// Logical name for the master wallet in CLI, `get_wallet_config`, and `config wallet ls`. pub const MASTER_WALLET_RESERVED_NAME: &str = "master_wallet"; +/// `max_stake_factor` from masterchain config param 17 as a float multiplier (e.g. `3.0`). +pub async fn fetch_network_max_factor(rpc_client: &ClientJsonRpc) -> anyhow::Result { + extract_max_factor(rpc_client.get_config_param(17).await?) +} + pub fn warn_missing_secret(secret_name: &str) { println!("\n{} {}", "[WARNING]".yellow().bold(), "Vault secret is missing".yellow(),); println!( diff --git a/src/node-control/common/src/app_config.rs b/src/node-control/common/src/app_config.rs index 21d38ba..6351083 100644 --- a/src/node-control/common/src/app_config.rs +++ b/src/node-control/common/src/app_config.rs @@ -506,10 +506,24 @@ 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_factor_upper_bound: Option) -> anyhow::Result<()> { + self.validate_timing_fields()?; + if self.max_factor < 1.0 { + anyhow::bail!("max_factor must be >= 1.0"); + } + if let Some(m) = max_factor_upper_bound { + if self.max_factor > m { + 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 +728,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 +770,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..6291a36 100644 --- a/src/node-control/common/src/ton_utils.rs +++ b/src/node-control/common/src/ton_utils.rs @@ -6,6 +6,8 @@ * * This software is provided "AS IS", WITHOUT WARRANTY OF ANY KIND. */ +use ton_block::ConfigParamEnum; + pub fn nanotons_to_dec_string(value: u64) -> String { value.to_string() } @@ -18,6 +20,25 @@ 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: f32 = 65536.0; + +/// 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 +} + +/// Extracts the network `max_factor` from a `ConfigParamEnum` (must be param 17; field `max_stake_factor`) as a float multiplier. +pub fn extract_max_factor(param: ConfigParamEnum) -> anyhow::Result { + match param { + ConfigParamEnum::ConfigParam17(c) => { + Ok(max_stake_factor_raw_to_multiplier(c.max_stake_factor)) + } + _ => anyhow::bail!("expected config param 17 (stakes config)"), + } +} + 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..0065d40 100644 --- a/src/node-control/elections/src/runner.rs +++ b/src/node-control/elections/src/runner.rs @@ -21,7 +21,10 @@ use common::{ }, task_cancellation::CancellationCtx, time_format, - ton_utils::{display_tons, nanotons_to_dec_string, nanotons_to_tons_f64}, + ton_utils::{ + MAX_STAKE_FACTOR_SCALE, display_tons, max_stake_factor_raw_to_multiplier, + nanotons_to_dec_string, nanotons_to_tons_f64, + }, }; use contracts::{ ElectionsInfo, ElectorWrapper, NominatorWrapper, Participant, TonWallet, @@ -391,7 +394,11 @@ impl ElectionRunner { elections_info.participants.len() ); - self.build_elections_snapshot(election_id, &cfg15, &elections_info); + // Config param 17: effective `max_factor` in snapshot; 16/17: participation (e.g. AdaptiveSplit50). + let cfg16 = self.fetch_config_param_16().await?; + let cfg17 = self.fetch_config_param_17().await?; + + self.build_elections_snapshot(election_id, &cfg15, &elections_info, &cfg17); if elections_info.finished { self.snapshot_cache.last_elections_status = ElectionsStatus::Finished; @@ -436,9 +443,6 @@ impl ElectionRunner { ); } } - // Fetch config params 16/17 - used for AdaptiveSplit50 strategy - let cfg16 = self.fetch_config_param_16().await?; - let cfg17 = self.fetch_config_param_17().await?; // walk through the nodes and try to participate in the elections let mut nodes = self.nodes.keys().cloned().collect::>(); @@ -491,8 +495,10 @@ impl ElectionRunner { election_id: u64, cfg15: &ConfigParam15, elections_info: &ElectionsInfo, + cfg17: &ConfigParam17, ) { - self.snapshot_cache.last_max_factor = Some(self.calc_max_factor()); + self.snapshot_cache.last_max_factor = + Some(self.calc_max_factor(cfg17.max_stake_factor, false).1); // It can be a validator wallet or nominator pool address. let wallet_addrs: HashSet> = @@ -549,7 +555,7 @@ 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(params.cfg17.max_stake_factor, true); let stake_ctx = StakeContext { past_elections: &self.past_elections, our_max_factor: max_factor, @@ -798,9 +804,6 @@ 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"); - } // todo: move to ElectorWrapper // validator-elect-req.fif let mut data = 0x654C5074u32.to_be_bytes().to_vec(); @@ -923,8 +926,28 @@ impl ElectionRunner { tracing::info!("elections: start={}, end={}", elections_start, elections_end); } - fn calc_max_factor(&self) -> f32 { - self.default_max_factor + /// Resolves elector `max_factor`: fixed-point `raw` for the Elector and `multiplier` for logs/UI. + /// + /// Applies configured `default_max_factor` and clamps to the chain cap + /// (`network_max_stake_factor_raw` from masterchain config param 17), in fixed-point + /// `[65536, network_max_stake_factor_raw]` (see [`MAX_STAKE_FACTOR_SCALE`]). + /// + /// When `warn_if_clamped` is true and the configured value was clamped, logs a warning. + fn calc_max_factor( + &self, + network_max_stake_factor_raw: u32, + warn_if_clamped: bool, + ) -> (u32, f32) { + let configured_raw = (self.default_max_factor * MAX_STAKE_FACTOR_SCALE) as u32; + let raw = configured_raw.clamp(MAX_STAKE_FACTOR_SCALE as u32, network_max_stake_factor_raw); + if warn_if_clamped && raw != configured_raw { + tracing::warn!( + "max_factor clamped: configured={}, used={} (network limit from cfg17)", + max_stake_factor_raw_to_multiplier(configured_raw), + max_stake_factor_raw_to_multiplier(raw), + ); + } + (raw, max_stake_factor_raw_to_multiplier(raw)) } /// Calculate stake for a node according to the stake policy. diff --git a/src/node-control/elections/src/runner_tests.rs b/src/node-control/elections/src/runner_tests.rs index 702534e..b82edb1 100644 --- a/src/node-control/elections/src/runner_tests.rs +++ b/src/node-control/elections/src/runner_tests.rs @@ -1352,6 +1352,8 @@ async fn test_elections_finished_node_not_in_participants() { let provider = &mut harness.provider_mock; provider.expect_election_parameters().returning(|| Ok(default_cfg15())); provider.expect_validator_config().returning(|| Ok(ValidatorConfig::new())); + provider.expect_config_param_16().returning(|| Ok(default_cfg16())); + provider.expect_config_param_17().returning(|| Ok(default_cfg17())); provider.expect_shutdown().returning(|| Ok(())); @@ -2382,7 +2384,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 +2393,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 +2403,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..70d9358 100644 --- a/src/node-control/service/src/runtime_config.rs +++ b/src/node-control/service/src/runtime_config.rs @@ -8,8 +8,9 @@ */ use anyhow::Context; use common::{ - app_config::{AppConfig, KeyConfig, PoolConfig, WalletConfig}, + app_config::{AppConfig, ElectionsConfig, KeyConfig, PoolConfig, WalletConfig}, time_format, + ton_utils::extract_max_factor, vault_signer::VaultSigner, }; use contracts::{ @@ -93,6 +94,9 @@ 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() { + Self::validate_elections_max_factor_vs_chain(&rpc_client, elections).await?; + } 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 +120,9 @@ 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() { + Self::validate_elections_max_factor_vs_chain(&rpc_client, elections).await?; + } let master_wallet = Self::load_master_wallet(&new_config, rpc_client.clone(), Some(vault.clone())).await?; let wallets = @@ -135,6 +142,23 @@ impl RuntimeConfigStore { Ok(()) } + async fn validate_elections_max_factor_vs_chain( + rpc_client: &ClientJsonRpc, + elections: &ElectionsConfig, + ) -> anyhow::Result<()> { + match rpc_client.get_config_param(17).await.and_then(extract_max_factor) { + Ok(network_max_factor) => elections.validate(Some(network_max_factor)), + Err(e) => { + tracing::warn!( + error = %e, + "elections max_factor: failed to read config param 17 from chain; \ + validating without network upper bound (re-check max_factor when TON HTTP API is reachable)" + ); + elections.validate(None) + } + } + } + #[cfg(test)] pub fn from_app_config(app_config: Arc) -> Self { use contracts::SmartContract;