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
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 @@ -45,12 +45,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 Down Expand Up @@ -170,8 +172,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
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
35 changes: 34 additions & 1 deletion 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 @@ -461,6 +466,15 @@ fn default_max_factor() -> f32 {
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 @@ -488,6 +510,15 @@ impl ElectionsConfig {
if !(1.0..=3.0).contains(&self.max_factor) {
anyhow::bail!("max_factor must be in range [1.0..3.0]");
}
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");
}
Comment on lines +513 to +521
Copy link

Copilot AI Mar 31, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Configuration parameter validation only applies when the full ElectionsConfig is validated, but sleep_period_pct and waiting_period_pct are added to the config regardless of the selected stake policy. These parameters should only be required/validated when AdaptiveSplit50 strategy is in use. Consider documenting this design choice or conditionally validating these parameters only for AdaptiveSplit50 policy.

Suggested change
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");
}
// `sleep_period_pct` and `waiting_period_pct` are only relevant for the
// AdaptiveSplit50 stake policy. We only validate them if that policy is
// used either as the default or in any per-node override.
let uses_adaptive_split50 = matches!(self.policy, StakePolicy::AdaptiveSplit50 { .. })
|| self
.policy_overrides
.values()
.any(|policy| matches!(policy, StakePolicy::AdaptiveSplit50 { .. }));
if uses_adaptive_split50 {
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");
}
}

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Skipped. Validating always is simpler and catches misconfigs early.

Ok(())
}
}
Expand All @@ -499,6 +530,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
64 changes: 63 additions & 1 deletion src/node-control/control-client/src/config_params.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,10 @@
*/
use anyhow::Context;
use std::str::FromStr;
use ton_block::{ConfigParam15, SigPubKey, UInt256, ValidatorDescr, ValidatorSet};
use ton_block::{
Coins, ConfigParam15, ConfigParam16, ConfigParam17, SigPubKey, UInt256, ValidatorDescr,
ValidatorSet,
};

pub fn parse_config_param_15(bytes: &[u8]) -> anyhow::Result<ConfigParam15> {
let param: serde_json::Value =
Expand Down Expand Up @@ -104,3 +107,62 @@ fn parse_validator_set(bytes: &[u8], key: &str) -> anyhow::Result<ValidatorSet>
}
ValidatorSet::new(utime_since, utime_until, main, list)
}

pub fn parse_config_param_16(bytes: &[u8]) -> anyhow::Result<ConfigParam16> {
let param: serde_json::Value =
serde_json::from_slice(bytes).context("config param 16 is not valid JSON")?;
let p16 = param
.get("p16")
.and_then(|v| v.as_object())
.ok_or_else(|| anyhow::anyhow!("p16 not found in JSON"))?;

let max_validators =
p16.get("max_validators")
.and_then(serde_json::Value::as_u64)
.ok_or_else(|| anyhow::anyhow!("max_validators not found"))? as u16;
let max_main_validators =
p16.get("max_main_validators")
.and_then(serde_json::Value::as_u64)
.ok_or_else(|| anyhow::anyhow!("max_main_validators not found"))? as u16;
let min_validators =
p16.get("min_validators")
.and_then(serde_json::Value::as_u64)
.ok_or_else(|| anyhow::anyhow!("min_validators not found"))? as u16;

Ok(ConfigParam16 {
max_validators: max_validators.into(),
max_main_validators: max_main_validators.into(),
min_validators: min_validators.into(),
})
}

pub fn parse_config_param_17(bytes: &[u8]) -> anyhow::Result<ConfigParam17> {
let param: serde_json::Value =
serde_json::from_slice(bytes).context("config param 17 is not valid JSON")?;
let p17 = param
.get("p17")
.and_then(|v| v.as_object())
.ok_or_else(|| anyhow::anyhow!("p17 not found in JSON"))?;

let parse_coins = |key: &str| -> anyhow::Result<Coins> {
let val = p17.get(key).ok_or_else(|| anyhow::anyhow!("{} not found", key))?;
// It can be a string (decimal) or a number
if let Some(s) = val.as_str() {
Ok(Coins::from(u64::from_str_radix(s, 10).context(format!("parse {} as u64", key))?))
} else if let Some(n) = val.as_u64() {
Ok(Coins::from(n))
} else {
anyhow::bail!("{} is not a valid coins value", key)
}
};

let min_stake = parse_coins("min_stake_dec")?;
let max_stake = parse_coins("max_stake_dec")?;
let min_total_stake = parse_coins("min_total_stake_dec")?;
let max_stake_factor =
p17.get("max_stake_factor")
.and_then(serde_json::Value::as_u64)
.ok_or_else(|| anyhow::anyhow!("max_stake_factor not found"))? as u32;

Ok(ConfigParam17 { min_stake, max_stake, min_total_stake, max_stake_factor })
}
118 changes: 118 additions & 0 deletions src/node-control/docs/staking-strategies.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
# Nodectl Staking Strategies

## AdaptiveSplit50

### Overview

AdaptiveSplit50 splits your funds in half and stakes each half into alternating election rounds, so capital is always working. If half is not enough to be selected by the Elector, it stakes everything into the current round instead.

---

### Key Terms

| Term | Description |
|---|---|
| **Elector** | TON smart contract that runs validator elections. |
| **min_eff_stake** | Minimum stake the Elector would accept. Below this — no rewards. |
| **frozen_stake** | Stake locked in the previous validation round. |
| **free_pool_balance** | Funds available for staking on the pool balance. |
| **current_stake** | Stake already submitted to the current election (0 if none). |
| **available** | `frozen_stake + free_pool_balance + current_stake` |
| **half** | `available / 2` |
| **sleep_period** | Minimum wait time (fraction of election duration) before acting. |
| **waiting_period** | Maximum wait time for enough participants before using fallback data. Must be >= `sleep_period`. |

---

### How It Works

The strategy runs on every tick (periodic check) during an election.

#### 1. Wait for the right moment

Before doing anything, the strategy waits until:

- The **sleep_period** has passed since the election started, **and**
- At least `min_validators` participants have submitted stakes.

If the **waiting_period** expires and there still aren't enough participants, the strategy stops waiting and proceeds with whatever data is available.

#### 2. Estimate min_eff_stake

The strategy needs to know the minimum stake required to be selected. It uses two sources:

- **Current election estimate** — emulates the Elector's selection algorithm on the participants who have already submitted stakes. Available only when enough participants are present.
- **Previous election data** — takes the smallest frozen stake from the last completed election. Cached per election round.

**Priority:** use the current election estimate when available. Fall back to previous election data only when the current estimate cannot be computed (not enough participants). If neither is available, the strategy skips the election with an error.

#### 3. Decide how much to stake

```
available = frozen_stake + free_pool_balance + current_stake
half = available / 2
```

- **half >= min_eff_stake** — stake half. The other half is reserved for the next round.
- **half < min_eff_stake** — stake all free funds. Splitting is pointless because the remaining half would also be below the threshold.

**Guards:**

- If `free_pool_balance` is too low to cover the required stake, the strategy skips the election and logs an error.
- If `current_stake` already meets or exceeds `min_eff_stake`, no action is taken.

#### 4. Top up on subsequent ticks

On every tick after the initial submission, the strategy re-evaluates:

- If `min_eff_stake` has risen above `current_stake` (e.g. larger stakes arrived), it tops up by the difference.
- The same half-vs-all logic applies: if the remaining funds can't cover the next round, everything goes into the current one.

---

### Configuration

| Parameter | Type | Description |
|---|---|---|
| `sleep_period` | float (0.0–1.0) | Fraction of election duration to wait before acting. |
| `waiting_period` | float (0.0–1.0) | Max fraction of election duration to wait for participants. |

---

### Decision Flowchart

```
Election starts
Wait for sleep_period AND min_validators participants
├─ Both met ──► Emulate election → curr_min_eff
└─ Timeout ───► curr_min_eff = None
Fetch prev_min_stake from past elections (cached)
min_eff_stake = curr_min_eff ?? prev_min_stake
├─ Neither available ──► Skip election (error)
half = available / 2
├─ half >= min_eff_stake ──► Stake half
└─ half < min_eff_stake ──► Stake all
┌─ On every tick: ──────────────────────────────┐
│ │
│ Re-estimate min_eff_stake │
│ │
│ If min_eff_stake > current_stake → top up │
│ │
│ Apply same half-vs-all logic │
└────────────────────────────────────────────────┘
```
Loading
Loading