Skip to content
6 changes: 3 additions & 3 deletions src/node-control/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
|----------|-------------|
Expand Down Expand Up @@ -1403,7 +1403,7 @@ Automatic elections task configuration:
- `"minimum"` — use minimum required stake
- `{ "fixed": <amount> }` — 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)
Expand Down Expand Up @@ -1720,7 +1720,7 @@ nodectl config wallet stake -b <BINDING> -a <AMOUNT> [-m <MAX_FACTOR>]
|------|------|----------|---------|-------------|
| `-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:

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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},
Expand Down Expand Up @@ -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,
}

Expand Down Expand Up @@ -220,15 +225,17 @@ 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
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?;
let elections = config
.elections
.as_mut()
.ok_or_else(|| anyhow::anyhow!("Elections are not configured"))?
.max_factor = self.value;
.ok_or_else(|| anyhow::anyhow!("Elections are not configured"))?;
elections.max_factor = self.value;
elections.validate(Some(network_max_factor))?;
save_config(&config, path)?;
println!("{} Max factor set to {}", "OK".green().bold(), self.value);
Ok(())
Expand Down
24 changes: 14 additions & 10 deletions src/node-control/commands/src/commands/nodectl/config_wallet_cmd.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,17 +9,17 @@
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;
use colored::Colorize;
use common::{
TonWalletVersion,
app_config::{AppConfig, KeyConfig, PoolConfig, WalletConfig},
app_config::{AppConfig, ElectionsConfig, KeyConfig, PoolConfig, WalletConfig},
task_cancellation::CancellationCtx,
time_format,
ton_utils::{display_tons, tons_f64_to_nanotons},
Expand Down Expand Up @@ -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,
}

Expand Down Expand Up @@ -435,11 +440,10 @@ 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?;
ElectionsConfig { max_factor: self.max_factor, ..Default::default() }
.validate(Some(network_max_factor))?;

// Resolve binding → wallet, pool, node
let binding = config
Expand Down
6 changes: 6 additions & 0 deletions src/node-control/commands/src/commands/nodectl/utils.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand All @@ -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<f32> {
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!(
Expand Down
41 changes: 37 additions & 4 deletions src/node-control/common/src/app_config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<f32>) -> 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]");
}
Expand Down Expand Up @@ -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(())
}
}
Expand Down Expand Up @@ -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;
Expand Down
21 changes: 21 additions & 0 deletions src/node-control/common/src/ton_utils.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}
Expand All @@ -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<f32> {
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')
Expand Down
46 changes: 34 additions & 12 deletions src/node-control/elections/src/runner.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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::<Vec<String>>();
Expand Down Expand Up @@ -491,8 +495,9 @@ 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).1);

// It can be a validator wallet or nominator pool address.
let wallet_addrs: HashSet<Vec<u8>> =
Expand Down Expand Up @@ -549,7 +554,15 @@ impl ElectionRunner {
election_id: u64,
params: &ConfigParams<'_>,
) -> anyhow::Result<()> {
let max_factor = (self.calc_max_factor() * 65536.0) as u32;
let configured_raw = self.configured_max_factor_raw();
let (max_factor, _) = self.calc_max_factor(params.cfg17.max_stake_factor);
if max_factor != 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(max_factor),
);
}
let stake_ctx = StakeContext {
past_elections: &self.past_elections,
our_max_factor: max_factor,
Expand Down Expand Up @@ -798,9 +811,6 @@ impl ElectionRunner {
participant.adnl_addr.as_slice()
)
);
if !(1.0..=3.0).contains(&(participant.max_factor as f32 / 65536.0)) {
anyhow::bail!("<max-factor> must be a real number 1..3");
}
// todo: move to ElectorWrapper
// validator-elect-req.fif
let mut data = 0x654C5074u32.to_be_bytes().to_vec();
Expand Down Expand Up @@ -923,8 +933,20 @@ impl ElectionRunner {
tracing::info!("elections: start={}, end={}", elections_start, elections_end);
}

fn calc_max_factor(&self) -> f32 {
self.default_max_factor
#[inline]
fn configured_max_factor_raw(&self) -> u32 {
(self.default_max_factor * MAX_STAKE_FACTOR_SCALE) as u32
}

/// 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`]).
fn calc_max_factor(&self, network_max_stake_factor_raw: u32) -> (u32, f32) {
let configured_raw = self.configured_max_factor_raw();
let raw = configured_raw.clamp(MAX_STAKE_FACTOR_SCALE as u32, network_max_stake_factor_raw);
(raw, max_stake_factor_raw_to_multiplier(raw))
}

/// Calculate stake for a node according to the stake policy.
Expand Down
Loading