Skip to content
Closed
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]`, 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)
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
14 changes: 10 additions & 4 deletions src/node-control/commands/src/commands/nodectl/config_cmd.rs
Original file line number Diff line number Diff line change
Expand Up @@ -78,12 +78,14 @@ pub struct GenerateCmd {

#[derive(clap::Args, Clone)]
pub struct StakePolicyCmd {
#[arg(long = "fixed", conflicts_with_all = ["split50", "minimum"], help = "Fixed stake amount in TON")]
#[arg(long = "fixed", conflicts_with_all = ["split50", "minimum", "adaptive_split50"], help = "Fixed stake amount in TON")]
fixed: Option<f64>,
#[arg(long = "split50", conflicts_with_all = ["fixed", "minimum"], help = "Use 50% of available balance")]
#[arg(long = "split50", conflicts_with_all = ["fixed", "minimum", "adaptive_split50"], help = "Use 50% of available balance")]
split50: bool,
#[arg(long = "minimum", conflicts_with_all = ["fixed", "split50"], help = "Use minimum required stake")]
#[arg(long = "minimum", conflicts_with_all = ["fixed", "split50", "adaptive_split50"], help = "Use minimum required stake")]
minimum: bool,
#[arg(long = "adaptive-split50", conflicts_with_all = ["fixed", "split50", "minimum"], help = "Adaptive split: splits when half exceeds effective minimum, otherwise stakes all")]
adaptive_split50: bool,
#[arg(
short = 'n',
long = "node",
Expand Down Expand Up @@ -179,8 +181,12 @@ impl StakePolicyCmd {
StakePolicy::Split50
} else if self.minimum {
StakePolicy::Minimum
} else if self.adaptive_split50 {
StakePolicy::AdaptiveSplit50
} else {
anyhow::bail!("No policy specified. Use --fixed, --split50, or --minimum");
anyhow::bail!(
"No policy specified. Use --fixed, --split50, --minimum, or --adaptive-split50"
);
};

// Update elections config
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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},
Expand Down Expand Up @@ -45,12 +49,14 @@ pub struct ShowCmd {

#[derive(clap::Args, Clone)]
pub struct StakePolicySetCmd {
#[arg(long = "fixed", conflicts_with_all = ["split50", "minimum"], help = "Fixed stake amount in TON")]
#[arg(long = "fixed", conflicts_with_all = ["split50", "minimum", "adaptive_split50"], help = "Fixed stake amount in TON")]
fixed: Option<f64>,
#[arg(long = "split50", conflicts_with_all = ["fixed", "minimum"], help = "Use 50% of available balance")]
#[arg(long = "split50", conflicts_with_all = ["fixed", "minimum", "adaptive_split50"], help = "Use 50% of available balance")]
split50: bool,
#[arg(long = "minimum", conflicts_with_all = ["fixed", "split50"], help = "Use minimum required stake")]
#[arg(long = "minimum", conflicts_with_all = ["fixed", "split50", "adaptive_split50"], help = "Use minimum required stake")]
minimum: bool,
#[arg(long = "adaptive-split50", conflicts_with_all = ["fixed", "split50", "minimum"], help = "Adaptive split: splits when half exceeds effective minimum, otherwise stakes all")]
adaptive_split50: bool,
#[arg(
short = 'n',
long = "node",
Expand All @@ -69,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,
}

Expand Down Expand Up @@ -170,8 +178,12 @@ impl StakePolicySetCmd {
StakePolicy::Split50
} else if self.minimum {
StakePolicy::Minimum
} else if self.adaptive_split50 {
StakePolicy::AdaptiveSplit50
} else {
anyhow::bail!("No policy specified. Use --fixed, --split50, or --minimum");
anyhow::bail!(
"No policy specified. Use --fixed, --split50, --minimum, or --adaptive-split50"
);
};

if let Some(elections) = &mut config.elections {
Expand Down Expand Up @@ -214,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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}

Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,8 @@ pub struct StakePolicyCmd {
split50: bool,
#[arg(long = "minimum")]
minimum: bool,
#[arg(long = "adaptive-split50")]
adaptive_split50: bool,
#[arg(
short = 'n',
long = "node",
Expand Down Expand Up @@ -367,6 +369,9 @@ impl StakePolicyCmd {
if self.minimum {
return Some(StakePolicy::Minimum);
}
if self.adaptive_split50 {
return Some(StakePolicy::AdaptiveSplit50);
}
None
}
}
Expand Down
82 changes: 76 additions & 6 deletions src/node-control/common/src/app_config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -413,6 +413,8 @@ pub enum StakePolicy {
Split50,
#[serde(rename = "minimum")]
Minimum,
#[serde(rename = "adaptive_split50")]
AdaptiveSplit50,
}

impl std::fmt::Display for StakePolicy {
Expand All @@ -428,6 +430,7 @@ impl std::fmt::Display for StakePolicy {
}
StakePolicy::Split50 => write!(f, "split50"),
StakePolicy::Minimum => write!(f, "minimum"),
StakePolicy::AdaptiveSplit50 => write!(f, "adaptive_split50"),
}
}
}
Expand All @@ -444,7 +447,9 @@ impl StakePolicy {
let stake = match self {
StakePolicy::Fixed(v) => v.to_owned().max(min_stake).min(available_stake),
StakePolicy::Minimum => min_stake,
StakePolicy::Split50 => (available_stake / 2).max(min_stake),
StakePolicy::Split50 | StakePolicy::AdaptiveSplit50 => {
(available_stake / 2).max(min_stake)
}
};
Ok(stake)
}
Expand All @@ -454,13 +459,22 @@ fn default_workchain() -> i32 {
-1
}

fn default_max_factor() -> f32 {
pub fn default_max_factor() -> f32 {
3.0
}

fn default_tick_interval() -> u64 {
40
}

fn default_waiting_pct() -> f64 {
0.4
}

fn default_sleep_pct() -> f64 {
0.2
}

#[derive(serde::Serialize, serde::Deserialize, Clone)]
pub struct ElectionsConfig {
#[serde(default)]
Expand All @@ -475,6 +489,14 @@ pub struct ElectionsConfig {
/// Interval for elections runner in seconds
#[serde(default = "default_tick_interval")]
pub tick_interval: u64,
/// Minimum wait time as fraction of election duration (0.0 - 1.0).
/// Algorithm waits at least this long from election start, even if min_validators is reached.
#[serde(default = "default_sleep_pct")]
pub sleep_period_pct: f64,
/// Maximum wait time as fraction of election duration (0.0 - 1.0).
/// If min_validators is not reached within this period, proceed without waiting.
#[serde(default = "default_waiting_pct")]
pub waiting_period_pct: f64,
}

impl ElectionsConfig {
Expand All @@ -484,9 +506,36 @@ 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<f32>) -> 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]");
}
if !(0.0..=1.0).contains(&self.waiting_period_pct) {
anyhow::bail!("waiting_period_pct must be in range [0.0..1.0]");
}
if self.sleep_period_pct > self.waiting_period_pct {
anyhow::bail!("sleep_period_pct must be <= waiting_period_pct");
}
Ok(())
}
Expand All @@ -499,6 +548,8 @@ impl Default for ElectionsConfig {
policy_overrides: HashMap::new(),
max_factor: default_max_factor(),
tick_interval: default_tick_interval(),
sleep_period_pct: default_sleep_pct(),
waiting_period_pct: default_waiting_pct(),
}
}
}
Expand Down Expand Up @@ -681,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(())
}
}
Expand Down Expand Up @@ -723,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;
Expand Down
9 changes: 9 additions & 0 deletions src/node-control/common/src/ton_utils.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand Down
Loading
Loading