Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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,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()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
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,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
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
47 changes: 35 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,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<Vec<u8>> =
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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!("<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 +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.
Expand Down
8 changes: 5 additions & 3 deletions src/node-control/elections/src/runner_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(()));

Expand Down Expand Up @@ -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]
Expand All @@ -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]
Expand All @@ -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]
Expand Down
Loading
Loading