From f2c9ecf85ce9c6c238ff8504e3c7e1065d7b0908 Mon Sep 17 00:00:00 2001 From: NikitaM <21960067+Keshoid@users.noreply.github.com> Date: Thu, 26 Mar 2026 15:29:12 +0300 Subject: [PATCH 01/11] feat(nodectl): impl adaptive-split50 strategy --- .../src/commands/nodectl/config_cmd.rs | 14 +- .../commands/nodectl/config_elections_cmd.rs | 14 +- .../src/commands/nodectl/service_api_cmd.rs | 5 + src/node-control/common/src/app_config.rs | 31 +- .../control-client/src/config_params.rs | 64 +- src/node-control/docs/staking-strategies.md | 154 ++++ .../elections/src/election_emulator.rs | 359 ++++++++ src/node-control/elections/src/lib.rs | 1 + .../elections/src/providers/default.rs | 18 +- .../elections/src/providers/traits.rs | 11 +- src/node-control/elections/src/runner.rs | 433 +++++++++- .../elections/src/runner_tests.rs | 803 +++++++++++++++++- 12 files changed, 1849 insertions(+), 58 deletions(-) create mode 100644 src/node-control/docs/staking-strategies.md create mode 100644 src/node-control/elections/src/election_emulator.rs diff --git a/src/node-control/commands/src/commands/nodectl/config_cmd.rs b/src/node-control/commands/src/commands/nodectl/config_cmd.rs index 720d354..a8255b6 100644 --- a/src/node-control/commands/src/commands/nodectl/config_cmd.rs +++ b/src/node-control/commands/src/commands/nodectl/config_cmd.rs @@ -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, - #[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", @@ -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 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 aed6b0b..4dae1cd 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 @@ -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, - #[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", @@ -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 { diff --git a/src/node-control/commands/src/commands/nodectl/service_api_cmd.rs b/src/node-control/commands/src/commands/nodectl/service_api_cmd.rs index 6a9b44b..91c577f 100644 --- a/src/node-control/commands/src/commands/nodectl/service_api_cmd.rs +++ b/src/node-control/commands/src/commands/nodectl/service_api_cmd.rs @@ -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", @@ -367,6 +369,9 @@ impl StakePolicyCmd { if self.minimum { return Some(StakePolicy::Minimum); } + if self.adaptive_split50 { + return Some(StakePolicy::AdaptiveSplit50); + } None } } diff --git a/src/node-control/common/src/app_config.rs b/src/node-control/common/src/app_config.rs index 4fe7c2e..260044a 100644 --- a/src/node-control/common/src/app_config.rs +++ b/src/node-control/common/src/app_config.rs @@ -413,6 +413,8 @@ pub enum StakePolicy { Split50, #[serde(rename = "minimum")] Minimum, + #[serde(rename = "adaptive_split50")] + AdaptiveSplit50, } impl std::fmt::Display for StakePolicy { @@ -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"), } } } @@ -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) } @@ -461,6 +466,11 @@ fn default_max_factor() -> f32 { fn default_tick_interval() -> u64 { 40 } + +fn default_adaptive_waiting_pct() -> f64 { + 0.3 +} + #[derive(serde::Serialize, serde::Deserialize, Clone)] pub struct ElectionsConfig { #[serde(default)] @@ -475,6 +485,14 @@ pub struct ElectionsConfig { /// Interval for elections runner in seconds #[serde(default = "default_tick_interval")] pub tick_interval: u64, + /// AdaptiveSplit50: 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)] + pub adaptive_sleep_period_pct: f64, + /// AdaptiveSplit50: 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_adaptive_waiting_pct")] + pub adaptive_waiting_period_pct: f64, } impl ElectionsConfig { @@ -488,6 +506,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.adaptive_sleep_period_pct) { + anyhow::bail!("adaptive_sleep_period_pct must be in range [0.0..1.0]"); + } + if !(0.0..=1.0).contains(&self.adaptive_waiting_period_pct) { + anyhow::bail!("adaptive_waiting_period_pct must be in range [0.0..1.0]"); + } + if self.adaptive_sleep_period_pct > self.adaptive_waiting_period_pct { + anyhow::bail!("adaptive_sleep_period_pct must be <= adaptive_waiting_period_pct"); + } Ok(()) } } @@ -499,6 +526,8 @@ impl Default for ElectionsConfig { policy_overrides: HashMap::new(), max_factor: default_max_factor(), tick_interval: default_tick_interval(), + adaptive_sleep_period_pct: 0.0, + adaptive_waiting_period_pct: default_adaptive_waiting_pct(), } } } diff --git a/src/node-control/control-client/src/config_params.rs b/src/node-control/control-client/src/config_params.rs index 3c79f48..ba0e5d7 100644 --- a/src/node-control/control-client/src/config_params.rs +++ b/src/node-control/control-client/src/config_params.rs @@ -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 { let param: serde_json::Value = @@ -104,3 +107,62 @@ fn parse_validator_set(bytes: &[u8], key: &str) -> anyhow::Result } ValidatorSet::new(utime_since, utime_until, main, list) } + +pub fn parse_config_param_16(bytes: &[u8]) -> anyhow::Result { + 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 { + 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 { + 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 }) +} diff --git a/src/node-control/docs/staking-strategies.md b/src/node-control/docs/staking-strategies.md new file mode 100644 index 0000000..2ca1df9 --- /dev/null +++ b/src/node-control/docs/staking-strategies.md @@ -0,0 +1,154 @@ +# Nodectl Staking Strategies + +## AdaptiveSplit50 + +### Overview + +AdaptiveSplit50 is a staking strategy designed to maximize capital efficiency across validation rounds. The core idea is simple: split all available funds in half and stake each half into alternating election rounds, so that capital is always working. However, this only makes sense when each half is large enough to be selected by the Elector — otherwise the funds sit idle and earn nothing. + +**Key principle:** If half of the available funds exceeds the minimum effective stake (the threshold below which the Elector will not select a validator), the strategy stakes half per round. If not, it stakes everything into a single round to avoid leaving idle capital. + +--- + +### Definitions + +| Term | Description | +|---|---| +| **Elector** | The TON smart contract that runs validator elections and selects which stakes participate in validation. | +| **Election round** | A time-bounded period during which validators submit stakes. At the end, the Elector selects the validator set. | +| **min_eff_stake** | The minimum effective stake — the lowest stake that the Elector would accept into the validator set. Stakes below this threshold are not selected and earn no rewards. | +| **frozen_stake** | A stake submitted in a previous election that is currently locked (frozen) for the duration of the active validation round. | +| **free_pool_balance** | Uncommitted funds sitting on the nominator pool balance, available for staking. | +| **current_stake** | The stake already submitted to the current election round (0 if nothing has been submitted yet). | +| **available** | Total capital the strategy can work with: `frozen_stake + free_pool_balance + current_stake`. | +| **half** | Half of the available capital: `available / 2`. | +| **config16** | TON blockchain configuration parameter that defines validator set constraints, including `min_validators` — the minimum number of validators required to form a set. | +| **config15** | TON blockchain configuration parameter that defines election timing, including the total election duration. | +| **sleep_period** | A configurable minimum delay (from the start of elections) before the strategy takes any action. Expressed as a fraction of the total election duration. | +| **waiting_period** | A configurable maximum time the strategy will wait for enough participants to appear before falling back to historical data. Expressed as a fraction of the total election duration. `sleep_period <= waiting_period`. | +| **tick** | One iteration of the strategy's main loop. The strategy runs periodically and re-evaluates its position on each tick. | + +--- + +### Algorithm + +The algorithm executes in four steps each time a new election round begins. + +#### Step 1 — Estimate min_eff_stake from the current election + +**Goal:** Determine the minimum effective stake for the current election by emulating the Elector's selection algorithm on the participants who have already submitted their stakes. + +1. When a new election starts, the strategy begins monitoring the list of participants who have submitted stakes to the Elector. + +2. The strategy waits until **both** of the following conditions are met: + - At least `config16.min_validators` participants have submitted their stakes (the minimum needed to emulate a meaningful election). + - The `sleep_period` has elapsed since the start of the election. + +3. **Timeout:** If the `waiting_period` elapses and fewer than `min_validators` participants have appeared, the strategy stops waiting and proceeds to Step 2 without a current-election estimate. This prevents the strategy from stalling indefinitely. + +4. Once both conditions are satisfied, the strategy emulates an election: it takes the current list of participants, adds its own potential stake (`half = available / 2`) to the list, and runs the Elector's selection algorithm. The result is `curr_min_eff_stake` — the estimated minimum effective stake for this election. + +#### Step 2 — Estimate min_eff_stake from the previous election + +**Goal:** Obtain a baseline minimum effective stake from historical data, independent of the current election's progress. + +1. The strategy calls the Elector's `past_elections` get-method to retrieve the participant map from the most recent completed election. + +2. It emulates the Elector's selection algorithm on that historical participant list to compute `prev_min_eff_stake`. + +3. This value is **cached** so it does not need to be recomputed on every tick (it does not change within a single election round). + +> **Note:** This step always produces a result, unlike Step 1 which may time out. This ensures the strategy always has at least one min_eff_stake estimate to work with. + +#### Step 3 — Decide the stake amount and submit + +**Goal:** Choose the optimal stake amount and submit it to the Elector. + +1. **Pick the conservative estimate.** If both `curr_min_eff_stake` (from Step 1) and `prev_min_eff_stake` (from Step 2) are available, the strategy uses the **smaller** of the two. If only `prev_min_eff_stake` is available (Step 1 timed out), it uses that. This conservative approach reduces the risk of submitting a stake that is too low. + +2. **Calculate available funds:** + ``` + available = frozen_stake + free_pool_balance + current_stake + ``` + +3. **Calculate half:** + ``` + half = available / 2 + ``` + +4. **Submit the stake:** + - If `half >= min_eff_stake` → submit `half` to the Elector. The expectation is that the remaining half will be sufficient for the next round. However, this is **not guaranteed** — the stake distribution may change in the next election, shifting the min_eff_stake up or down. Since the future state is unpredictable, the strategy uses the current estimate as the best available approximation. + - If `half < min_eff_stake` → submit **all available free funds** (`free_pool_balance`) to the Elector. Since `half < min_eff_stake` implies `available < 2 × min_eff_stake`, the remainder after staking any amount would be less than `min_eff_stake` — not enough to participate in the next round. Rather than leaving idle capital that cannot earn rewards, the strategy commits everything to the current round. + +5. **Insufficient funds guard:** Before submitting, the strategy checks whether `free_pool_balance >= min_eff_stake`. If the pool does not have enough free funds to cover the required stake, the strategy **skips the election entirely** and logs an error indicating that the election will be missed due to insufficient funds. No stake is submitted in this case. + +#### Step 4 — Continuously adjust the stake + +**Goal:** After the initial submission, keep monitoring the election and top up the stake if conditions change. + +On every subsequent tick during the election: + +1. **Re-emulate the election** using the full current participant list to get an updated `min_eff_stake`. + +2. **Top-up if outbid:** If `min_eff_stake > current_stake`, the strategy sends an additional stake equal to the difference: + ``` + topup = min_eff_stake - current_stake + current_stake += topup + ``` + This ensures the node remains above the selection threshold even as new, larger stakes arrive. + +3. **Go all-in if next round is unviable:** The strategy checks whether the remaining funds (not staked in this round) would be enough to participate in the next election: + ``` + remaining = (frozen_stake + free_pool_balance) - current_stake + ``` + If `remaining < min_eff_stake`, it means the leftover funds won't be sufficient to enter the next validator set anyway. In this case, the strategy stakes the entire remaining balance into the current election to maximize returns from this round rather than leaving funds idle. + +--- + +### Configuration Parameters + +| Parameter | Type | Description | +|---|---|---| +| `sleep_period` | float (0.0–1.0) | Minimum fraction of the election duration to wait before acting, even if enough participants are present. | +| `waiting_period` | float (0.0–1.0) | Maximum fraction of the election duration to wait for `min_validators` participants. Must be >= `sleep_period`. | + +--- + +### Summary: Decision Flowchart + +``` +Election starts + │ + ▼ + Wait for sleep_period AND min_validators participants + │ + ├─ Both met ──► Emulate election → curr_min_eff_stake + │ + └─ Timeout ───► curr_min_eff_stake = None + │ + ▼ + Fetch past_elections → prev_min_eff_stake (cached) + │ + ▼ + min_eff_stake = min(curr_min_eff_stake, prev_min_eff_stake) + │ + ▼ + available = frozen_stake + free_pool_balance + current_stake + half = available / 2 + │ + ├─ half >= min_eff_stake ──► Stake half + │ + └─ half < min_eff_stake ──► Stake all (next round unviable anyway) + │ + ▼ + ┌─ On every tick: ──────────────────────────────────┐ + │ │ + │ Re-emulate election → updated min_eff_stake │ + │ │ + │ If min_eff_stake > current_stake: │ + │ top up by (min_eff_stake - current_stake) │ + │ │ + │ If remaining funds < min_eff_stake: │ + │ stake all remaining into current round │ + └────────────────────────────────────────────────────┘ +``` diff --git a/src/node-control/elections/src/election_emulator.rs b/src/node-control/elections/src/election_emulator.rs new file mode 100644 index 0000000..bcd7abd --- /dev/null +++ b/src/node-control/elections/src/election_emulator.rs @@ -0,0 +1,359 @@ +/* + * Copyright (C) 2025-2026 RSquad Blockchain Lab. + * + * Licensed under the GNU General Public License v3.0. + * See the LICENSE file in the root of this repository. + * + * This software is provided "AS IS", WITHOUT WARRANTY OF ANY KIND. + */ + +/// A participant's stake and max_factor for election emulation. +#[derive(Clone)] +pub struct ParticipantStake { + /// Stake in nanotons. + pub stake: u64, + /// Per-participant max_factor (multiplied by 65536). + pub max_factor: u32, +} + +/// Election context for emulating TON elector's validator selection algorithm. +pub struct ElectionContext { + /// Existing participants (NOT including our own stake). + pub participants: Vec, + /// Maximum number of validators (from ConfigParam16). + pub max_validators: u16, + /// Minimum number of validators (from ConfigParam16). + pub min_validators: u16, + /// Global max_factor from ConfigParam17 (multiplied by 65536). + pub global_max_factor: u32, + /// Minimum stake from ConfigParam17. A validator set is only valid if the weakest + /// validator's stake >= min_stake. + pub min_stake: u64, + /// Maximum stake from ConfigParam17. Each participant's stake is capped at this value. + pub max_stake: u64, +} + +/// Result of election emulation. +pub struct EmulationResult { + /// The effective minimum stake to enter the validator set. + pub effective_min_stake: u64, + /// Number of validators that would be elected. + pub elected_count: u16, +} + +/// Sorted participant for internal use (descending by stake). +#[derive(Clone)] +struct SortedEntry { + stake: u64, + /// Effective max_factor = min(participant_max_factor, global_max_stake_factor). + max_factor: u32, +} + +/// Emulates the TON elector's `try_elect` algorithm to determine +/// the effective minimum stake to enter the validator set. +/// +/// `our_stake` is the hypothetical stake we would submit. +/// `our_max_factor` is our participant max_factor (raw u32). +/// Returns `None` if fewer than `min_validators` participants exist (including us). +/// +/// Reference: elector-code.fc `try_elect` + `compute_total_stake`. +pub fn emulate_election( + ctx: &ElectionContext, + our_stake: u64, + our_max_factor: u32, +) -> Option { + let global_factor = ctx.global_max_factor; + + // Build sorted list (descending by stake), capping each stake at max_stake. + // Apply min(participant_max_factor, global_max_stake_factor) per the elector logic. + let mut entries: Vec = ctx + .participants + .iter() + .map(|p| SortedEntry { + stake: p.stake.min(ctx.max_stake), + max_factor: p.max_factor.min(global_factor), + }) + .collect(); + + // Add our entry (skip if our_stake is 0 to avoid a phantom participant). + if our_stake > 0 { + entries.push(SortedEntry { + stake: our_stake.min(ctx.max_stake), + max_factor: our_max_factor.min(global_factor), + }); + } + + if entries.len() < ctx.min_validators as usize { + return None; + } + + entries.sort_unstable_by(|a, b| b.stake.cmp(&a.stake)); // descending by stake + + let max_n = std::cmp::min(ctx.max_validators as usize, entries.len()); + let min_n = ctx.min_validators as usize; + + let mut best_total_effective = 0u128; + let mut best_n = 0usize; + + for n in min_n..=max_n { + // m_stake = stake of the weakest validator in the top-n set + let m_stake = entries[n - 1].stake; + + if m_stake < ctx.min_stake { + // No need to check further, as the weakest validator is below the minimum stake + break; + } + + // compute_total_stake: for each of top-n participants, + // effective = min(stake_i, (max_factor_i * m_stake) >> 16) + let total_effective = entries[..n] + .iter() + .map(|e| { + let cap = ((e.max_factor as u128) * (m_stake as u128)) >> 16; + std::cmp::min(e.stake as u128, cap) + }) + .sum(); + + if total_effective > best_total_effective { + best_total_effective = total_effective; + best_n = n; + } + } + + if best_n == 0 { + return None; + } + + let effective_min_stake = entries[best_n - 1].stake; + + Some(EmulationResult { effective_min_stake, elected_count: best_n as u16 }) +} + +#[cfg(test)] +mod tests { + use super::*; + + const NANO: u64 = 1_000_000_000; + const FACTOR_3X: u32 = 3 * 65536; + const MAX_STAKE: u64 = u64::MAX; + + fn simple_ctx(stakes: Vec, max_validators: u16, min_validators: u16) -> ElectionContext { + ElectionContext { + participants: stakes + .into_iter() + .map(|s| ParticipantStake { stake: s, max_factor: FACTOR_3X }) + .collect(), + max_validators, + min_validators, + global_max_factor: FACTOR_3X, + min_stake: 0, + max_stake: MAX_STAKE, + } + } + + #[test] + fn test_single_participant_is_elected() { + let ctx = simple_ctx(vec![], 100, 1); + let result = emulate_election(&ctx, 1_000_000, FACTOR_3X).unwrap(); + assert_eq!(result.effective_min_stake, 1_000_000); + assert_eq!(result.elected_count, 1); + } + + #[test] + fn test_fewer_than_min_validators_returns_none() { + let ctx = simple_ctx(vec![], 100, 5); + assert!(emulate_election(&ctx, 1_000_000, FACTOR_3X).is_none()); + } + + #[test] + fn test_kiln_scenario_half_below_effective_min() { + // 400 participants with ~700k TON each, max_validators=400 (set is FULL) + // Half (650k) is below the weakest validator, can't displace anyone + let stakes: Vec = (0..400).map(|i| (700_000 + i * 100) * NANO).collect(); + let ctx = ElectionContext { + participants: stakes + .into_iter() + .map(|s| ParticipantStake { stake: s, max_factor: FACTOR_3X }) + .collect(), + max_validators: 400, + min_validators: 13, + global_max_factor: FACTOR_3X, + min_stake: 0, + max_stake: MAX_STAKE, + }; + + let half = 650_000 * NANO; + let result = emulate_election(&ctx, half, FACTOR_3X).unwrap(); + assert!( + result.effective_min_stake > half, + "effective_min={} should be above half={}", + result.effective_min_stake, + half + ); + assert_eq!(result.elected_count, 400); + } + + #[test] + fn test_kiln_scenario_full_stake_works() { + // Same setup: 400 validators ~700k (set full), we stake full 1.3M + let stakes: Vec = (0..400).map(|i| (700_000 + i * 100) * NANO).collect(); + let ctx = ElectionContext { + participants: stakes + .into_iter() + .map(|s| ParticipantStake { stake: s, max_factor: FACTOR_3X }) + .collect(), + max_validators: 400, + min_validators: 13, + global_max_factor: FACTOR_3X, + min_stake: 0, + max_stake: MAX_STAKE, + }; + + let full = 1_300_000 * NANO; + let result = emulate_election(&ctx, full, FACTOR_3X).unwrap(); + assert!(result.effective_min_stake <= full); + assert_eq!(result.elected_count, 400); + } + + #[test] + fn test_effective_min_with_unfilled_set() { + // 100 participants with 700k each, max_validators=400 (set NOT full) + // With factor 3.0, effective min ≈ 700k / 3 ≈ 233k + let ctx = simple_ctx(vec![700_000 * NANO; 100], 400, 13); + + // 234k should be enough to join the set (room for more validators) + let our_stake = 234_000 * NANO; + let result = emulate_election(&ctx, our_stake, FACTOR_3X).unwrap(); + assert!( + result.effective_min_stake <= our_stake, + "234k should be sufficient: effective_min={}", + result.effective_min_stake as f64 / NANO as f64 + ); + assert_eq!(result.elected_count, 101); + + // 230k should NOT be enough (below 700k/3 ≈ 233k threshold) + let too_small = 230_000 * NANO; + let result = emulate_election(&ctx, too_small, FACTOR_3X).unwrap(); + assert_eq!( + result.elected_count, 100, + "230k should be excluded: elected_count should be 100" + ); + } + + #[test] + fn test_split_works_when_half_above_effective_min() { + // 50 participants with ~300k TON each, max_validators=400 (set NOT full) + // Half (650k) should be well above effective min (~300k/3 = 100k) + let ctx = simple_ctx(vec![300_000 * NANO; 50], 400, 13); + + let half = 650_000 * NANO; + let result = emulate_election(&ctx, half, FACTOR_3X).unwrap(); + assert!(result.effective_min_stake <= half); + assert_eq!(result.elected_count, 51); + } + + #[test] + fn test_factor_one_clamps_effective_stakes() { + // With factor 1.0, effective = min(stake, m_stake * 1.0) = m_stake for all above it + // Sorted desc: [1000, 800, 500, 200] + // n=2: m_stake=800, total = 800+800 = 1600 + // n=3: m_stake=500, total = 500+500+500 = 1500 + // n=4: m_stake=200, total = 200*4 = 800 + // n=2 wins → only top 2 elected + let ctx = ElectionContext { + participants: vec![1000, 500, 200] + .into_iter() + .map(|s| ParticipantStake { stake: s, max_factor: 65536 }) + .collect(), + max_validators: 10, + min_validators: 1, + global_max_factor: 65536, + min_stake: 0, + max_stake: MAX_STAKE, + }; + + let result = emulate_election(&ctx, 800, 65536).unwrap(); + assert_eq!(result.elected_count, 2); + assert_eq!(result.effective_min_stake, 800); + } + + #[test] + fn test_equal_stakes() { + let ctx = simple_ctx(vec![500_000 * NANO; 100], 400, 13); + + let result = emulate_election(&ctx, 500_000 * NANO, FACTOR_3X).unwrap(); + assert_eq!(result.effective_min_stake, 500_000 * NANO); + assert_eq!(result.elected_count, 101); + } + + #[test] + fn test_max_validators_limit() { + // 200 participants but max_validators = 50 + let stakes: Vec = (0..200).map(|i| (1_000_000 - i * 1000) * NANO).collect(); + let ctx = ElectionContext { + participants: stakes + .into_iter() + .map(|s| ParticipantStake { stake: s, max_factor: FACTOR_3X }) + .collect(), + max_validators: 50, + min_validators: 13, + global_max_factor: FACTOR_3X, + min_stake: 0, + max_stake: MAX_STAKE, + }; + + let result = emulate_election(&ctx, 900_000 * NANO, FACTOR_3X).unwrap(); + assert!(result.elected_count <= 50); + } + + #[test] + fn test_per_participant_max_factor() { + // Two participants: one with factor 1.0, one with factor 3.0 + // With m_stake = 100, participant with factor 1.0 gets capped at 100, + // participant with factor 3.0 gets capped at 300 + let ctx = ElectionContext { + participants: vec![ + ParticipantStake { stake: 500, max_factor: 65536 }, // factor 1.0 + ParticipantStake { stake: 500, max_factor: 3 * 65536 }, // factor 3.0 + ], + max_validators: 10, + min_validators: 1, + global_max_factor: 3 * 65536, + min_stake: 0, + max_stake: MAX_STAKE, + }; + + let result = emulate_election(&ctx, 100, 3 * 65536).unwrap(); + // m_stake = 100 (our stake, the weakest) + // Participant 1 (500, factor 1.0): effective = min(500, 100 * 1.0) = 100 + // Participant 2 (500, factor 3.0): effective = min(500, 100 * 3.0) = 300 + // Us (100, factor 3.0): effective = min(100, 100 * 3.0) = 100 + // Total with n=3: 100 + 300 + 100 = 500 + // Without us (n=2): m_stake = 500, effective per each = 500, total = 1000 + // 1000 > 500, so n=2 is better → we're excluded + assert_eq!(result.elected_count, 2); + } + + #[test] + fn test_min_stake_threshold() { + // ConfigParam17 min_stake = 100. Participants below this can't form a valid set. + let ctx = ElectionContext { + participants: vec![ParticipantStake { stake: 50, max_factor: FACTOR_3X }], + max_validators: 10, + min_validators: 1, + global_max_factor: FACTOR_3X, + min_stake: 100, + max_stake: MAX_STAKE, + }; + + // Both participants below min_stake → no valid election + assert!(emulate_election(&ctx, 50, FACTOR_3X).is_none()); + + // One participant above min_stake + let result = emulate_election(&ctx, 150, FACTOR_3X).unwrap(); + // n=1 with our 150: m_stake=150 >= 100 ✓, but only if we're the weakest in top-1 + // Sorted: [150, 50]. n=1: m_stake=150, total=150. n=2: m_stake=50 < 100, skip. + assert_eq!(result.elected_count, 1); + assert_eq!(result.effective_min_stake, 150); + } +} diff --git a/src/node-control/elections/src/lib.rs b/src/node-control/elections/src/lib.rs index 87a5764..f3bb221 100644 --- a/src/node-control/elections/src/lib.rs +++ b/src/node-control/elections/src/lib.rs @@ -6,6 +6,7 @@ * * This software is provided "AS IS", WITHOUT WARRANTY OF ANY KIND. */ +pub mod election_emulator; pub mod election_task; pub mod providers; pub(crate) mod runner; diff --git a/src/node-control/elections/src/providers/default.rs b/src/node-control/elections/src/providers/default.rs index 52f94c7..73c3202 100644 --- a/src/node-control/elections/src/providers/default.rs +++ b/src/node-control/elections/src/providers/default.rs @@ -16,10 +16,16 @@ use control_client::{ AddAdnlAddressRq, AddValidatorAdnlAddrRq, AddValidatorPermKeyRq, AddValidatorTempKeyRq, ClientAPI, SignRq, }, - config_params::{parse_config_param_15, parse_config_param_34, parse_config_param_36}, + config_params::{ + parse_config_param_15, parse_config_param_16, parse_config_param_17, parse_config_param_34, + parse_config_param_36, + }, }; use std::collections::HashMap; -use ton_block::{ConfigParam15, ValidatorSet}; +use ton_block::{ + ConfigParam15, ValidatorSet, + config_params::{ConfigParam16, ConfigParam17}, +}; pub struct DefaultElectionsProvider { client: ControlClientAdnl, @@ -129,6 +135,14 @@ impl ElectionsProvider for DefaultElectionsProvider { let bytes = self.client.get_config_param(34).await?; parse_config_param_34(&bytes) } + async fn config_param_16(&mut self) -> anyhow::Result { + let bytes = self.client.get_config_param(16).await?; + parse_config_param_16(&bytes) + } + async fn config_param_17(&mut self) -> anyhow::Result { + let bytes = self.client.get_config_param(17).await?; + parse_config_param_17(&bytes) + } async fn get_next_vset(&mut self) -> anyhow::Result> { match self.client.get_config_param(36).await { Ok(bytes) => Ok(Some(parse_config_param_36(&bytes)?)), diff --git a/src/node-control/elections/src/providers/traits.rs b/src/node-control/elections/src/providers/traits.rs index 60a4d54..32029d8 100644 --- a/src/node-control/elections/src/providers/traits.rs +++ b/src/node-control/elections/src/providers/traits.rs @@ -8,7 +8,10 @@ */ use control_client::client_api::Account as ControlClientAccount; use std::collections::HashMap; -use ton_block::{ValidatorSet, config_params::ConfigParam15}; +use ton_block::{ + ValidatorSet, + config_params::{ConfigParam15, ConfigParam16, ConfigParam17}, +}; fn serialize_hex(bytes: &Vec, serializer: S) -> Result where @@ -94,5 +97,11 @@ pub trait ElectionsProvider: Send + Sync { async fn account(&mut self, address: &str) -> anyhow::Result; async fn export_public_key(&mut self, key_id: &[u8]) -> anyhow::Result>; async fn get_current_vset(&mut self) -> anyhow::Result; + async fn config_param_16(&mut self) -> anyhow::Result { + anyhow::bail!("config_param_16 not implemented") + } + async fn config_param_17(&mut self) -> anyhow::Result { + anyhow::bail!("config_param_17 not implemented") + } async fn get_next_vset(&mut self) -> anyhow::Result>; } diff --git a/src/node-control/elections/src/runner.rs b/src/node-control/elections/src/runner.rs index 67fae96..1ad9116 100644 --- a/src/node-control/elections/src/runner.rs +++ b/src/node-control/elections/src/runner.rs @@ -6,7 +6,10 @@ * * This software is provided "AS IS", WITHOUT WARRANTY OF ANY KIND. */ -use crate::providers::{ElectionsProvider, ValidatorConfig, ValidatorEntry}; +use crate::{ + election_emulator::{self, ElectionContext}, + providers::{ElectionsProvider, ValidatorConfig, ValidatorEntry}, +}; use anyhow::Context as _; use common::{ app_config::{BindingStatus, ElectionsConfig, NodeBinding, StakePolicy}, @@ -17,7 +20,7 @@ use common::{ }, task_cancellation::CancellationCtx, time_format, - ton_utils::nanotons_to_dec_string, + ton_utils::{nanotons_to_dec_string, nanotons_to_tons_f64}, }; use contracts::{ ElectionsInfo, ElectorWrapper, NominatorWrapper, Participant, TonWallet, @@ -29,7 +32,9 @@ use std::{ time::Duration, }; use ton_block::{ - Cell, ConfigParam15, MsgAddressInt, UnixTime, ValidatorDescr, ValidatorSet, write_boc, + Cell, ConfigParam15, MsgAddressInt, UnixTime, ValidatorDescr, ValidatorSet, + config_params::{ConfigParam16, ConfigParam17}, + write_boc, }; #[cfg(test)] @@ -159,8 +164,17 @@ pub(crate) struct ElectionRunner { default_max_factor: f32, default_stake_policy: StakePolicy, past_elections: Vec, + /// Election ID for which `past_elections` and `cached_prev_min_eff` were fetched. + /// Used to avoid redundant RPC calls within the same election round. + past_elections_cache_id: u64, + /// Cached prev_min_eff_stake computed from past_elections. + cached_prev_min_eff: Option, // Snapshot cache updated during tick execution and published to SnapshotStore in run_loop(). snapshot_cache: SnapshotCache, + /// AdaptiveSplit50: minimum wait fraction of election duration. + adaptive_sleep_pct: f64, + /// AdaptiveSplit50: maximum wait fraction of election duration. + adaptive_waiting_pct: f64, } #[derive(Default)] @@ -221,7 +235,7 @@ impl ElectionRunner { return None; } }; - let pool = pools.get(&node_id).map(|p| p.clone()); + let pool = pools.get(&node_id).cloned(); let binding = bindings.get(&node_id); let excluded = !binding.map(|b| b.enable).unwrap_or(false); let binding_status = binding.map(|b| b.status).unwrap_or(BindingStatus::Idle); @@ -257,6 +271,10 @@ impl ElectionRunner { elector, snapshot_cache: SnapshotCache::default(), past_elections: vec![], + past_elections_cache_id: 0, + cached_prev_min_eff: None, + adaptive_sleep_pct: elections_config.adaptive_sleep_period_pct, + adaptive_waiting_pct: elections_config.adaptive_waiting_period_pct, } } @@ -379,7 +397,29 @@ impl ElectionRunner { self.snapshot_cache.last_elections_status = ElectionsStatus::Postponed; } - self.past_elections = self.elector.past_elections().await.context("past_elections")?; + // Fetch past_elections only when election_id changes (cache across ticks). + if self.past_elections_cache_id != election_id { + self.past_elections = self.elector.past_elections().await.context("past_elections")?; + self.cached_prev_min_eff = self + .past_elections + .first() + .and_then(|pe| pe.frozen_map.values().min_by_key(|f| f.stake).map(|f| f.stake)); + self.past_elections_cache_id = election_id; + if let Some(prev) = self.cached_prev_min_eff { + tracing::info!( + "prev_min_eff_stake from past elections: {} TON", + nanotons_to_tons_f64(prev) + ); + } + } + // 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?; + + // Cap prev_min_eff to cfg17.max_stake as a sanity bound against outliers. + let prev_min_eff_stake = + self.cached_prev_min_eff.map(|v| v.min(cfg17.max_stake.as_u64().unwrap_or(u64::MAX))); + // walk through the nodes and try to participate in the elections let mut nodes = self.nodes.keys().cloned().collect::>(); nodes.sort(); @@ -389,7 +429,9 @@ impl ElectionRunner { let recover_amount = match self.recover_stake(&node_id).await { Ok(amount) => amount, Err(e) => { - self.nodes.get_mut(&node_id).map(|node| node.last_error = Some(e.to_string())); + if let Some(node) = self.nodes.get_mut(&node_id) { + node.last_error = Some(e.to_string()); + } tracing::error!("node [{}] recover stake error: {}", node_id, e); continue; } @@ -408,8 +450,21 @@ impl ElectionRunner { } tracing::info!("node [{}] participate in elections: id={}", node_id, election_id); - if let Err(e) = self.participate(&node_id, election_id, &elections_info, &cfg15).await { - self.nodes.get_mut(&node_id).map(|node| node.last_error = Some(format!("{:#}", e))); + if let Err(e) = self + .participate( + &node_id, + election_id, + &elections_info, + &cfg15, + &cfg16, + &cfg17, + prev_min_eff_stake, + ) + .await + { + if let Some(node) = self.nodes.get_mut(&node_id) { + node.last_error = Some(format!("{:#}", e)); + } tracing::error!("node [{}] participate error: {:#}", node_id, e); } } @@ -479,29 +534,45 @@ impl ElectionRunner { election_id: u64, elections_info: &ElectionsInfo, cfg15: &ConfigParam15, + cfg16: &ConfigParam16, + cfg17: &ConfigParam17, + prev_min_eff_stake: Option, ) -> anyhow::Result<()> { let max_factor = (self.calc_max_factor() * 65536.0) as u32; - let mut node = self.nodes.get_mut(node_id).expect("node not found"); + let node = self.nodes.get_mut(node_id).expect("node not found"); // Find validator key for current elections in the validator config let validator_key = node.find_election_key(election_id).await; // Find participant in the elections info by validator public key let participant = validator_key.as_ref().and_then(|entry| { - elections_info - .participants - .iter() - .find(|p| p.pub_key == entry.public_key) - .map(|p| p.clone()) + elections_info.participants.iter().find(|p| p.pub_key == entry.public_key).cloned() }); + // If the elector already has our stake, mark it accepted early + // so that calc_stake uses the correct current_stake (not 0). + if participant.is_some() { + node.stake_accepted = true; + } let stake = Self::calc_stake( - &mut node, - &node_id, + node, + node_id, &self.past_elections, participant.as_ref().map(|p| p.stake).unwrap_or(0), - elections_info.min_stake, + elections_info, + cfg15, + cfg16, + cfg17, + max_factor, + self.adaptive_sleep_pct, + self.adaptive_waiting_pct, + prev_min_eff_stake, ) .await .context("stake calculation error")?; + if stake == 0 { + tracing::info!("node [{}] skipping elections this tick (stake=0)", node_id); + return Ok(()); + } + tracing::info!( "node [{}] max_factor={}, stake={} TON, strategy={}", node_id, @@ -586,9 +657,21 @@ impl ElectionRunner { ), participant.election_id ); + node.accepted_stake_amount = Some(participant.stake); node.participant = Some(participant.clone()); node.stake_accepted = true; - node.accepted_stake_amount = Some(participant.stake); + if matches!(node.stake_policy, StakePolicy::AdaptiveSplit50) && stake > 0 { + let old_stake = node.participant.as_ref().map(|p| p.stake).unwrap_or(0); + tracing::info!( + "node [{}] adaptive top-up: {} TON → {} TON (delta={} TON)", + node_id, + nanotons_to_tons_f64(old_stake), + nanotons_to_tons_f64(old_stake + stake), + nanotons_to_tons_f64(stake), + ); + Self::send_stake(node_id, node, stake).await?; + node.participant.as_mut().map(|p| p.stake += stake); + } } None => { tracing::warn!("node [{}] stake not found in elector", node_id); @@ -629,7 +712,7 @@ impl ElectionRunner { async fn send_stake(node_id: &str, node: &mut Node, stake: u64) -> anyhow::Result<()> { tracing::info!("node [{}] build stake message", node_id); - let payload = Self::build_new_stake_payload(node_id, node).await?; + let payload = Self::build_new_stake_payload(node_id, node, stake).await?; // For simplicity we always assume that the node has nominator pool. let fee = ELECTOR_STAKE_FEE + NPOOL_COMPUTE_FEE; let stake_balance = node.stake_balance(fee).await?; @@ -671,7 +754,11 @@ impl ElectionRunner { Ok(()) } - async fn build_new_stake_payload(node_id: &str, node: &mut Node) -> anyhow::Result { + async fn build_new_stake_payload( + node_id: &str, + node: &mut Node, + stake: u64, + ) -> anyhow::Result { let Some(participant) = &mut node.participant else { anyhow::bail!("node [{}] no participant info", node_id); }; @@ -680,7 +767,7 @@ impl ElectionRunner { node_id, participant.election_id, participant.max_factor, - participant.stake as f64 / 1_000_000_000.0, + stake as f64 / 1_000_000_000.0, hex::encode(participant.pub_key.as_slice()), base64::Engine::encode( &base64::engine::general_purpose::STANDARD, @@ -701,7 +788,7 @@ impl ElectionRunner { let signature = node.api.sign(node.key_id.clone(), data).await?; let body = nominator::new_stake(&nominator::NewStakeParams { query_id: UnixTime::now(), - stake_amount: participant.stake, + stake_amount: stake, validator_pubkey: participant.pub_key.as_slice(), stake_at: participant.election_id as u32, max_factor: participant.max_factor, @@ -777,6 +864,30 @@ impl ElectionRunner { anyhow::bail!("get election parameters: all nodes failed"); } + async fn fetch_config_param_16(&mut self) -> anyhow::Result { + for (node_id, node) in self.nodes.iter_mut() { + match node.api.config_param_16().await { + Ok(cfg) => return Ok(cfg), + Err(e) => { + tracing::warn!("node [{}] get config param 16 error: {:#}", node_id, e) + } + } + } + anyhow::bail!("get config param 16: all nodes failed"); + } + + async fn fetch_config_param_17(&mut self) -> anyhow::Result { + for (node_id, node) in self.nodes.iter_mut() { + match node.api.config_param_17().await { + Ok(cfg) => return Ok(cfg), + Err(e) => { + tracing::warn!("node [{}] get config param 17 error: {:#}", node_id, e) + } + } + } + anyhow::bail!("get config param 17: all nodes failed"); + } + fn print_election_cycle(cfg15: &ConfigParam15, election_id: u64) { let validation_start = time_format::format_ts(election_id - cfg15.validators_elected_for as u64); @@ -797,9 +908,17 @@ impl ElectionRunner { node: &mut Node, node_id: &str, past_elections: &[PastElections], - elections_stake: u64, // stake sent to the elections but not yet accepted by the elector - min_stake: u64, + elections_stake: u64, // stake sent to the elections but not yet frozen by the elector + elections_info: &ElectionsInfo, + cfg15: &ConfigParam15, + cfg16: &ConfigParam16, + cfg17: &ConfigParam17, + our_max_factor: u32, + adaptive_sleep_pct: f64, + adaptive_waiting_pct: f64, + prev_min_eff_stake: Option, ) -> anyhow::Result { + let min_stake = elections_info.min_stake; tracing::info!("node [{}] calc stake", node_id); let fee = ELECTOR_STAKE_FEE + NPOOL_COMPUTE_FEE; let mut frozen_stake = 0; @@ -818,7 +937,8 @@ impl ElectionRunner { // Get pool free balance let pool_free_balance = node.stake_balance(fee).await?; - let total_balance = frozen_stake + pool_free_balance + elections_stake; + let total_balance = + frozen_stake.saturating_add(pool_free_balance).saturating_add(elections_stake); tracing::info!( "node [{}] frozen_stake={} TON, pool_balance={} TON, elections_stake={} TON, total_balance={} TON", node_id, @@ -834,7 +954,237 @@ impl ElectionRunner { min_stake as f64 / 1_000_000_000.0 ); } - node.stake_policy.calculate_stake(min_stake, total_balance) + + match &node.stake_policy { + StakePolicy::AdaptiveSplit50 => { + // AdaptiveSplit50 wait logic: defer staking until enough participants + // AND minimum sleep period has passed. + if !node.stake_accepted { + let min_validators = cfg16.min_validators.as_u16() as usize; + let participants_count = elections_info.participants.len(); + let election_duration = + cfg15.elections_start_before.saturating_sub(cfg15.elections_end_before) + as u64; + + // Skip wait/sleep logic if election_duration is 0 (misconfigured). + if election_duration == 0 { + tracing::warn!( + "node [{}] adaptive_split50: election_duration=0, skipping wait logic", + node_id + ); + } else { + let election_start = + elections_info.elect_close.saturating_sub(election_duration); + let sleep_deadline = + election_start + (election_duration as f64 * adaptive_sleep_pct) as u64; + let wait_deadline = election_start + + (election_duration as f64 * adaptive_waiting_pct) as u64; + let now = time_format::now(); + + // Wait if sleep period hasn't passed yet + if now < sleep_deadline { + tracing::info!( + "node [{}] adaptive_split50 - sleep period: now < sleep_deadline={}", + node_id, + time_format::format_ts(sleep_deadline) + ); + return Ok(0); // defer staking + } + + // Wait if not enough participants and waiting period hasn't expired + if participants_count < min_validators && now < wait_deadline { + tracing::info!( + "node [{}] adaptive_split50 - waiting for participants: ({}/{}), deadline={}", + node_id, + participants_count, + min_validators, + time_format::format_ts(wait_deadline) + ); + return Ok(0); // defer staking + } + } // else election_duration > 0 + } + let current_stake = if node.stake_accepted { elections_stake } else { 0 }; + Self::calc_adaptive_stake( + node_id, + total_balance, + pool_free_balance, + current_stake, + our_max_factor, + elections_info, + cfg16, + cfg17, + prev_min_eff_stake, + ) + } + other => other.calculate_stake(min_stake, total_balance), + } + } + + /// Calculate stake for AdaptiveSplit50 policy. + /// + /// Determines min_eff_stake from current emulation and/or past elections, + /// then decides whether to split funds (stake half) or stake min_eff_stake. + /// + /// Note: On subsequent ticks, tops up if min_eff_stake grew above current_stake. + /// If the remainder for the next round would be below min_eff_stake, stakes everything. + fn calc_adaptive_stake( + node_id: &str, + total_balance: u64, + free_balance: u64, + current_stake: u64, + our_max_factor: u32, + elections_info: &ElectionsInfo, + cfg16: &ConfigParam16, + cfg17: &ConfigParam17, + prev_min_eff_stake: Option, + ) -> anyhow::Result { + let min_validators = cfg16.min_validators.as_u16(); + let max_validators = cfg16.max_validators.as_u16(); + let max_stake_factor = cfg17.max_stake_factor; + let cfg17_min_stake = cfg17.min_stake.as_u64().unwrap_or(0); + let cfg17_max_stake = cfg17.max_stake.as_u64().unwrap_or(u64::MAX); + let half = total_balance / 2; + + // Compute curr_min_eff_stake from current participants (if enough). + tracing::info!( + "node [{}] adaptive_split50: emulate elections on {} participants", + node_id, + elections_info.participants.len() + ); + let participants = elections_info + .participants + .iter() + .map(|p| election_emulator::ParticipantStake { + stake: p.stake, + max_factor: p.max_factor, + }) + .collect(); + + let ctx = ElectionContext { + participants, + max_validators, + min_validators, + global_max_factor: max_stake_factor, + min_stake: cfg17_min_stake, + max_stake: cfg17_max_stake, + }; + + // If we already have elections stake, don't add our stake to the emulation, + // because it's already in the participants list. + let emulated_stake = if current_stake > 0 { 0 } else { half }; + // Only trust emulation if there are real participants (not just our own stake). + let has_real_participants = !elections_info.participants.is_empty(); + let curr_min_eff = if has_real_participants || current_stake > 0 { + election_emulator::emulate_election(&ctx, emulated_stake, our_max_factor) + .map(|r| r.effective_min_stake) + } else { + None + }; + + // Step 3.1: Choose the smallest min_eff_stake from curr and prev. + let min_eff_stake = match (curr_min_eff, prev_min_eff_stake) { + (Some(curr), Some(prev)) => { + tracing::info!( + "node [{}] adaptive_split50: curr_min_eff={} TON, prev_min_eff={} TON, using min={} TON", + node_id, + nanotons_to_tons_f64(curr), + nanotons_to_tons_f64(prev), + nanotons_to_tons_f64(curr.min(prev)) + ); + curr.min(prev) + } + (Some(curr), None) => { + tracing::info!( + "node [{}] adaptive_split50: curr_min_eff={} TON (no past elections data)", + node_id, + nanotons_to_tons_f64(curr) + ); + curr + } + (None, Some(prev)) => { + tracing::info!( + "node [{}] adaptive_split50: prev_min_eff={} TON (not enough current participants < {})", + node_id, + nanotons_to_tons_f64(prev), + min_validators + ); + prev + } + (None, None) => { + anyhow::bail!( + "node [{}] adaptive_split50: cannot determine min effective stake \ + (not enough participants and no past elections)", + node_id + ); + } + }; + + // If we already have enough stake, no need to top-up. + if current_stake >= min_eff_stake { + tracing::debug!( + "node [{}] adaptive_split50: stake={} TON >= min_eff={} TON, no top-up needed", + node_id, + nanotons_to_tons_f64(current_stake), + nanotons_to_tons_f64(min_eff_stake) + ); + return Ok(0); + } + + // Insufficient funds guard — if the pool doesn't have enough free + // funds to cover min_eff_stake, skip the election entirely. + // On the initial submission (current_stake == 0) we need at least min_eff_stake + // free; on top-ups we need at least the delta. + let required = min_eff_stake.saturating_sub(current_stake); + if free_balance < required { + tracing::error!( + "node [{}] adaptive_split50: insufficient funds free_balance={} TON < required={} TON (min_eff={} TON), skipping election", + node_id, + nanotons_to_tons_f64(free_balance), + nanotons_to_tons_f64(required), + nanotons_to_tons_f64(min_eff_stake), + ); + return Ok(0); + } + + // Decide between staking half or min_eff_stake. + if half >= min_eff_stake { + // Step 3.4: half is enough — stake half. + let stake = half.saturating_sub(current_stake); + tracing::info!( + "node [{}] adaptive_split50 - stake half: current_stake={} TON, left_to_stake={} TON, half={} TON >= min_eff={} TON", + node_id, + nanotons_to_tons_f64(current_stake), + nanotons_to_tons_f64(stake), + nanotons_to_tons_f64(half), + nanotons_to_tons_f64(min_eff_stake), + ); + if stake > free_balance { + // Not enough free funds to stake half. Skip and let the operator top up. + tracing::error!( + "node [{}] adaptive_split50 - insufficient free balance: need {} TON to stake half, \ + but only {} TON available. Consider topping up the pool.", + node_id, + nanotons_to_tons_f64(stake), + nanotons_to_tons_f64(free_balance), + ); + return Ok(0); + } + Ok(stake) + } else { + // half < min_eff — splitting is not viable. + // Since half < min_eff, it follows that total < 2 * min_eff, + // so the remainder after staking min_eff would also be < min_eff. + // The next round won't have enough funds anyway — stake everything. + tracing::info!( + "node [{}] adaptive_split50 - stake all: half={} TON < min_eff={} TON, staking all free_balance={} TON", + node_id, + nanotons_to_tons_f64(half), + nanotons_to_tons_f64(min_eff_stake), + nanotons_to_tons_f64(free_balance), + ); + Ok(free_balance) + } } fn build_participants_snapshot( @@ -924,7 +1274,7 @@ impl ElectionRunner { self.snapshot_cache.last_elections.as_ref().map(|snapshot| snapshot.election_id); let mut node_ids = self.nodes.keys().cloned().collect::>(); - node_ids.sort_by(|a, b| a.cmp(b)); + node_ids.sort(); let mut controlled_nodes = Vec::new(); for node_id in node_ids { @@ -1227,10 +1577,10 @@ impl ElectionRunner { let last_binding_statuses = &mut self.snapshot_cache.last_binding_statuses; for (node_id, node) in self.nodes.iter() { let last_status = last_binding_statuses - .insert(node_id.clone(), node.binding_status.clone()) + .insert(node_id.clone(), node.binding_status) .unwrap_or(BindingStatus::Idle); if node.binding_status != last_status { - changes.insert(node_id.clone(), node.binding_status.clone()); + changes.insert(node_id.clone(), node.binding_status); } } changes @@ -1260,22 +1610,19 @@ async fn find_validator_entries( let mut key = [0u8; 32]; key.copy_from_slice(&public_key); - if current_entry.is_none() { - if let Some(vset) = current_vset { - if let Some(idx) = - vset.list().iter().position(|item| item.public_key.as_slice() == &key) - { - current_entry = Some((u16::try_from(idx)?, vset.list()[idx].clone())); - } - } + if current_entry.is_none() + && let Some(vset) = current_vset + && let Some(idx) = + vset.list().iter().position(|item| item.public_key.as_slice() == &key) + { + current_entry = Some((u16::try_from(idx)?, vset.list()[idx].clone())); } - if !is_in_next { - if let Some(vset) = next_vset { - if vset.list().iter().any(|item| item.public_key.as_slice() == &key) { - is_in_next = true; - } - } + if !is_in_next + && let Some(vset) = next_vset + && vset.list().iter().any(|item| item.public_key.as_slice() == &key) + { + is_in_next = true; } if current_entry.is_some() && is_in_next { diff --git a/src/node-control/elections/src/runner_tests.rs b/src/node-control/elections/src/runner_tests.rs index 415dc07..85701f9 100644 --- a/src/node-control/elections/src/runner_tests.rs +++ b/src/node-control/elections/src/runner_tests.rs @@ -21,8 +21,10 @@ use contracts::{ use mockall::mock; use std::{collections::HashMap, sync::Arc, time::Duration}; use ton_block::{ - BuilderData, Cell, Coins, ConfigParam15, Deserializable, MsgAddressInt, SliceData, - ValidatorSet, read_single_root_boc, + BuilderData, Cell, Coins, ConfigParam15, Deserializable, MsgAddressInt, Number16, SliceData, + ValidatorSet, + config_params::{ConfigParam16, ConfigParam17}, + read_single_root_boc, }; // ---- Address helpers ---- @@ -54,6 +56,23 @@ fn default_cfg15() -> ConfigParam15 { } } +fn default_cfg16() -> ConfigParam16 { + ConfigParam16 { + max_validators: Number16::from(400u16), + max_main_validators: Number16::from(100u16), + min_validators: Number16::from(13u16), + } +} + +fn default_cfg17() -> ConfigParam17 { + ConfigParam17 { + min_stake: Coins::from(10_000_000_000_000u64), // 10,000 TON + max_stake: Coins::from(10_000_000_000_000_000u64), // 10,000,000 TON + min_total_stake: Coins::from(100_000_000_000_000u64), // 100,000 TON + max_stake_factor: 3 * 65536, // 3x + } +} + fn dummy_cell() -> Cell { BuilderData::new().into_cell().unwrap() } @@ -90,6 +109,8 @@ mock! { async fn account(&mut self, address: &str) -> anyhow::Result; async fn export_public_key(&mut self, key_id: &[u8]) -> anyhow::Result>; async fn get_current_vset(&mut self) -> anyhow::Result; + async fn config_param_16(&mut self) -> anyhow::Result; + async fn config_param_17(&mut self) -> anyhow::Result; async fn get_next_vset(&mut self) -> anyhow::Result>; } } @@ -337,6 +358,8 @@ impl TestHarness { policy_overrides: HashMap::new(), max_factor: 3.0, tick_interval: 10, + adaptive_sleep_period_pct: 0.0, + adaptive_waiting_period_pct: 0.3, }, bindings: HashMap::new(), } @@ -426,6 +449,12 @@ fn setup_default_provider( // send_boc provider.expect_send_boc().returning(|_boc| Ok(())); + // config_param_16 + provider.expect_config_param_16().returning(|| Ok(default_cfg16())); + + // config_param_17 + provider.expect_config_param_17().returning(|| Ok(default_cfg17())); + // shutdown provider.expect_shutdown().returning(|| Ok(())); } @@ -651,6 +680,8 @@ async fn test_stake_already_accepted() { provider.expect_export_public_key().returning(|_| Ok(PUB_KEY.to_vec())); provider.expect_account().returning(|_| Ok(fake_account(WALLET_BALANCE))); + provider.expect_config_param_16().returning(|| Ok(default_cfg16())); + provider.expect_config_param_17().returning(|| Ok(default_cfg17())); provider.expect_shutdown().returning(|| Ok(())); let mut runner = harness.build(node_id); @@ -686,6 +717,8 @@ async fn test_recover_stake_returns_funds() { provider.expect_account().returning(|_| Ok(fake_account(WALLET_BALANCE))); // Expect send_boc to be called for recover provider.expect_send_boc().times(1).returning(|_| Ok(())); + provider.expect_config_param_16().returning(|| Ok(default_cfg16())); + provider.expect_config_param_17().returning(|| Ok(default_cfg17())); provider.expect_shutdown().returning(|| Ok(())); let mut runner = harness.build(node_id); @@ -717,6 +750,8 @@ async fn test_recover_stake_low_wallet_balance() { provider.expect_validator_config().returning(|| Ok(ValidatorConfig::new())); provider.expect_account().returning(move |_| Ok(fake_account(low_wallet_balance))); + provider.expect_config_param_16().returning(|| Ok(default_cfg16())); + provider.expect_config_param_17().returning(|| Ok(default_cfg17())); provider.expect_shutdown().returning(|| Ok(())); let mut runner = harness.build(node_id); @@ -809,6 +844,8 @@ async fn test_excluded_node_skips_elections() { provider.expect_export_public_key().returning(|_| Ok(PUB_KEY.to_vec())); provider.expect_account().returning(|_| Ok(fake_account(WALLET_BALANCE))); + provider.expect_config_param_16().returning(|| Ok(default_cfg16())); + provider.expect_config_param_17().returning(|| Ok(default_cfg17())); provider.expect_shutdown().returning(|| Ok(())); let mut runner = harness.build(node_id); @@ -1094,6 +1131,8 @@ async fn test_low_stake_balance() { provider.expect_account().returning(move |_| Ok(fake_account(low_balance))); provider.expect_new_validator_key().returning(|_, _| Ok((KEY_ID.to_vec(), PUB_KEY.to_vec()))); provider.expect_new_adnl_addr().returning(|_, _| Ok(ADNL_ADDR.to_vec())); + provider.expect_config_param_16().returning(|| Ok(default_cfg16())); + provider.expect_config_param_17().returning(|| Ok(default_cfg17())); provider.expect_shutdown().returning(|| Ok(())); @@ -1151,6 +1190,8 @@ async fn test_multiple_nodes_one_excluded() { policy_overrides: HashMap::new(), max_factor: 3.0, tick_interval: 10, + adaptive_sleep_period_pct: 0.0, + adaptive_waiting_period_pct: 0.3, }; let mut bindings = HashMap::new(); @@ -1195,6 +1236,8 @@ async fn test_multiple_nodes_one_excluded() { provider2.expect_get_next_vset().returning(|| Ok(None)); provider2.expect_export_public_key().returning(|_| Ok(PUB_KEY.to_vec())); provider2.expect_account().returning(|_| Ok(fake_account(WALLET_BALANCE))); + provider2.expect_config_param_16().returning(|| Ok(default_cfg16())); + provider2.expect_config_param_17().returning(|| Ok(default_cfg17())); provider2.expect_shutdown().returning(|| Ok(())); let mut providers: HashMap> = HashMap::new(); @@ -1369,6 +1412,8 @@ async fn test_new_validator_key_failure() { provider.expect_export_public_key().returning(|_| Ok(PUB_KEY.to_vec())); provider.expect_account().returning(|_| Ok(fake_account(WALLET_BALANCE))); provider.expect_new_validator_key().returning(|_, _| Err(anyhow::anyhow!("keygen failed"))); + provider.expect_config_param_16().returning(|| Ok(default_cfg16())); + provider.expect_config_param_17().returning(|| Ok(default_cfg17())); provider.expect_shutdown().returning(|| Ok(())); let mut runner = harness.build(node_id); @@ -1407,6 +1452,8 @@ async fn test_send_boc_failure() { provider.expect_new_adnl_addr().returning(|_, _| Ok(ADNL_ADDR.to_vec())); provider.expect_send_boc().returning(|_| Err(anyhow::anyhow!("broadcast failed"))); + provider.expect_config_param_16().returning(|| Ok(default_cfg16())); + provider.expect_config_param_17().returning(|| Ok(default_cfg17())); provider.expect_shutdown().returning(|| Ok(())); provider.expect_sign().returning(|_key, _data| Ok(SIGNATURE.to_vec())); @@ -1581,6 +1628,8 @@ async fn test_node_without_wallet_skipped() { policy_overrides: HashMap::new(), max_factor: 3.0, tick_interval: 10, + adaptive_sleep_period_pct: 0.0, + adaptive_waiting_period_pct: 0.3, }; let mut bindings = HashMap::new(); @@ -1881,6 +1930,756 @@ fn test_compute_status_idle_when_enabled_no_recover_no_participant() { assert_eq!(status, BindingStatus::Idle); } +// ===================================================== +// AdaptiveSplit50: calc_adaptive_stake unit tests +// ===================================================== + +const NANO: u64 = 1_000_000_000; +const FACTOR_3X: u32 = 3 * 65536; + +/// Build an ElectionsInfo with `n` participants each staking `stake_per` nanotons. +fn elections_info_with_participants(n: usize, stake_per: u64) -> ElectionsInfo { + let participants = (0..n) + .map(|i| { + let mut pubkey = [0u8; 32]; + pubkey[0] = i as u8; + pubkey[1] = (i >> 8) as u8; + Participant { + pub_key: pubkey.to_vec(), + adnl_addr: [0xEE; 32].to_vec(), + wallet_addr: pubkey.to_vec(), + stake: stake_per, + max_factor: FACTOR_3X, + election_id: ELECTION_ID, + stake_message_boc: None, + } + }) + .collect(); + ElectionsInfo { + election_id: ELECTION_ID, + elect_close: ELECTION_ID + 600, + min_stake: MIN_STAKE, + total_stake: n as u64 * stake_per, + failed: false, + finished: false, + participants, + } +} + +// ---- Step 3.4: half >= min_eff → stake half ---- + +#[test] +fn test_adaptive_stake_half_when_above_min_eff() { + // 50 participants with 300k TON each, max_validators=400 (set NOT full). + // effective_min is ~100k TON (300k / factor 3). + // total_balance = 1_300_000 TON, half = 650_000 TON >> effective_min. + // Expected: stake half = 650_000 TON. + let total_balance = 1_300_000 * NANO; + let free_balance = total_balance; // no frozen, no current + let current_stake = 0; + + let elections_info = elections_info_with_participants(50, 300_000 * NANO); + + let result = ElectionRunner::calc_adaptive_stake( + "node-1", + total_balance, + free_balance, + current_stake, + FACTOR_3X, + &elections_info, + &default_cfg16(), + &default_cfg17(), + None, + ) + .unwrap(); + + let half = total_balance / 2; + assert_eq!(result, half, "should stake half"); +} + +// ---- Step 3.5: half < min_eff → stake all ---- + +#[test] +fn test_adaptive_stake_all_when_half_below_min_eff() { + // 400 participants with ~700k TON each (set FULL, max_validators=400). + // effective_min ~700k TON. Our total = 1_300_000 TON, half = 650_000 < 700_000. + // Expected: stake all free_balance. + let total_balance = 1_300_000 * NANO; + let free_balance = total_balance; + let current_stake = 0; + + let stakes: Vec = (0..400) + .map(|i| { + let mut pubkey = [0u8; 32]; + pubkey[0] = i as u8; + pubkey[1] = (i >> 8) as u8; + Participant { + pub_key: pubkey.to_vec(), + adnl_addr: [0xEE; 32].to_vec(), + wallet_addr: pubkey.to_vec(), + stake: (700_000 + i as u64 * 100) * NANO, + max_factor: FACTOR_3X, + election_id: ELECTION_ID, + stake_message_boc: None, + } + }) + .collect(); + let elections_info = ElectionsInfo { + election_id: ELECTION_ID, + elect_close: ELECTION_ID + 600, + min_stake: MIN_STAKE, + total_stake: stakes.iter().map(|p| p.stake).sum(), + failed: false, + finished: false, + participants: stakes, + }; + + let result = ElectionRunner::calc_adaptive_stake( + "node-1", + total_balance, + free_balance, + current_stake, + FACTOR_3X, + &elections_info, + &default_cfg16(), + &default_cfg17(), + None, + ) + .unwrap(); + + assert_eq!(result, free_balance, "should stake all free_balance when half < min_eff"); +} + +// ---- Step 4.1: current_stake >= min_eff → no top-up ---- + +#[test] +fn test_adaptive_no_topup_when_stake_sufficient() { + // 50 participants with 300k TON. effective_min ~100k. + // current_stake = 650_000 TON >> effective_min. + // Expected: return 0 (no top-up). + let total_balance = 1_300_000 * NANO; + let free_balance = 0; // all staked or frozen + let current_stake = 650_000 * NANO; + + let elections_info = elections_info_with_participants(50, 300_000 * NANO); + + let result = ElectionRunner::calc_adaptive_stake( + "node-1", + total_balance, + free_balance, + current_stake, + FACTOR_3X, + &elections_info, + &default_cfg16(), + &default_cfg17(), + None, + ) + .unwrap(); + + assert_eq!(result, 0, "should return 0 when current_stake >= min_eff"); +} + +// ---- Step 3.5: insufficient funds guard ---- + +#[test] +fn test_adaptive_skip_when_insufficient_funds() { + // 50 participants with 300k TON. effective_min ~100k. + // total_balance is high (due to frozen), but free_balance < required. + // Expected: return 0 (skip). + let frozen = 900_000 * NANO; + let free_balance = 50_000 * NANO; // less than effective_min (~100k) + let current_stake = 0; + let total_balance = frozen + free_balance + current_stake; + + let elections_info = elections_info_with_participants(50, 300_000 * NANO); + + let result = ElectionRunner::calc_adaptive_stake( + "node-1", + total_balance, + free_balance, + current_stake, + FACTOR_3X, + &elections_info, + &default_cfg16(), + &default_cfg17(), + None, + ) + .unwrap(); + + assert_eq!(result, 0, "should skip when free_balance < min_eff_stake"); +} + +// ---- Cap to free_balance when half > free_balance ---- + +#[test] +fn test_adaptive_skip_when_half_exceeds_free_balance() { + // Use few participants (< min_validators) so emulation returns None. + // prev_min_eff = 50k controls the effective_min. + // total = 1_300_000, half = 650_000 > 50k → half branch. + // free_balance = 200_000 < half(650k) → skip (not enough to stake half). + // free_balance (200k) > prev_min_eff (50k) → passes insufficient funds guard. + let frozen = 1_100_000 * NANO; + let free_balance = 200_000 * NANO; + let current_stake = 0; + let total_balance = frozen + free_balance + current_stake; + let prev_min_eff = Some(50_000 * NANO); + + let elections_info = elections_info_with_participants(5, 300_000 * NANO); + + let result = ElectionRunner::calc_adaptive_stake( + "node-1", + total_balance, + free_balance, + current_stake, + FACTOR_3X, + &elections_info, + &default_cfg16(), + &default_cfg17(), + prev_min_eff, + ) + .unwrap(); + + assert_eq!(result, 0, "should skip when free_balance < half (operator should top up pool)"); +} + +// ---- min(curr, prev) selection ---- + +#[test] +fn test_adaptive_uses_min_of_curr_and_prev() { + // 50 participants with 300k TON. curr_min_eff ~100k. + // prev_min_eff = 80k < curr → should use prev (80k). + // total = 200k, half = 100k >= prev_min_eff (80k) → stake half = 100k. + let total_balance = 200_000 * NANO; + let free_balance = total_balance; + let current_stake = 0; + let prev_min_eff = Some(80_000 * NANO); + + let elections_info = elections_info_with_participants(50, 300_000 * NANO); + + let result = ElectionRunner::calc_adaptive_stake( + "node-1", + total_balance, + free_balance, + current_stake, + FACTOR_3X, + &elections_info, + &default_cfg16(), + &default_cfg17(), + prev_min_eff, + ) + .unwrap(); + + let half = total_balance / 2; + assert_eq!(result, half, "should use min(curr, prev) and stake half"); +} + +// ---- prev only (curr = None, fewer than min_validators) ---- + +#[test] +fn test_adaptive_fallback_to_prev_when_not_enough_participants() { + // Only 5 participants (< min_validators=13) → emulation returns None. + // prev_min_eff = 50k. + // total = 200k, half = 100k >= 50k → stake half. + let total_balance = 200_000 * NANO; + let free_balance = total_balance; + let current_stake = 0; + let prev_min_eff = Some(50_000 * NANO); + + let elections_info = elections_info_with_participants(5, 300_000 * NANO); + + let result = ElectionRunner::calc_adaptive_stake( + "node-1", + total_balance, + free_balance, + current_stake, + FACTOR_3X, + &elections_info, + &default_cfg16(), + &default_cfg17(), + prev_min_eff, + ) + .unwrap(); + + let half = total_balance / 2; + assert_eq!(result, half, "should fallback to prev_min_eff when not enough participants"); +} + +// ---- Both None → error ---- + +#[test] +fn test_adaptive_error_when_no_min_eff_available() { + // Fewer than min_validators (5 < 13) AND no prev_min_eff → error. + let total_balance = 200_000 * NANO; + let free_balance = total_balance; + let current_stake = 0; + + let elections_info = elections_info_with_participants(5, 300_000 * NANO); + + let result = ElectionRunner::calc_adaptive_stake( + "node-1", + total_balance, + free_balance, + current_stake, + FACTOR_3X, + &elections_info, + &default_cfg16(), + &default_cfg17(), + None, + ); + + assert!(result.is_err(), "should fail when both curr and prev min_eff are unavailable"); +} + +// ---- Top-up: half branch, partial top-up ---- + +#[test] +fn test_adaptive_topup_to_half() { + // Use few participants so emulation returns None; prev_min_eff controls effective. + // prev_min_eff = 600k. current_stake = 500k < 600k → need top-up. + // total = 1_300_000, half = 650_000 > 600k → half branch. + // stake = half - current = 650k - 500k = 150k. + let total_balance = 1_300_000 * NANO; + let free_balance = 200_000 * NANO; + let current_stake = 500_000 * NANO; + let prev_min_eff = Some(600_000 * NANO); + + // current_stake > 0 → emulation uses our_stake = 0 (already in list). + // With < min_validators participants, emulation returns None → uses prev_min_eff. + let elections_info = elections_info_with_participants(5, 300_000 * NANO); + + let result = ElectionRunner::calc_adaptive_stake( + "node-1", + total_balance, + free_balance, + current_stake, + FACTOR_3X, + &elections_info, + &default_cfg16(), + &default_cfg17(), + prev_min_eff, + ) + .unwrap(); + + let expected = total_balance / 2 - current_stake; + assert_eq!(result, expected, "should top up to half"); +} + +// ===================================================== +// AdaptiveSplit50: wait/sleep integration tests +// ===================================================== + +/// Helper: set up elector with a future elect_close and given participants. +/// past_elections_factory: a closure that produces Vec (since PastElections is not Clone). +fn setup_adaptive_elector( + elector: &mut MockElectorWrapperImpl, + election_id: u64, + elect_close: u64, + participants: Vec, + past_elections_factory: impl Fn() -> Vec + Send + 'static, +) { + elector.expect_address().returning(|| elector_address()); + elector.expect_get_active_election_id().returning(move || Ok(election_id)); + + let total_stake: u64 = participants.iter().map(|p| p.stake).sum(); + elector.expect_elections_info().returning(move || { + Ok(ElectionsInfo { + election_id, + elect_close, + min_stake: MIN_STAKE, + total_stake, + failed: false, + finished: false, + participants: participants.clone(), + }) + }); + + elector.expect_past_elections().returning(move || Ok(past_elections_factory())); + elector.expect_compute_returned_stake().returning(|_| Ok(0)); +} + +#[tokio::test] +async fn test_adaptive_wait_for_participants() { + // Elections just opened. Only 5 participants (< min_validators=13). + // elect_close is far in the future → within waiting_period. + // Expected: stake=0, node defers. + let node_id = "node-1"; + let mut harness = TestHarness::new(); + harness.elections_config.policy = StakePolicy::AdaptiveSplit50; + harness.elections_config.adaptive_sleep_period_pct = 0.0; + harness.elections_config.adaptive_waiting_period_pct = 0.3; + + // elect_close far in the future (now + 10_000s) so we're early in the election. + let now = common::time_format::now(); + let elect_close = now + 10_000; + let participants = (0..5u8) + .map(|i| Participant { + pub_key: vec![i; 32], + adnl_addr: vec![0xEE; 32], + wallet_addr: vec![i; 32], + stake: 300_000 * NANO, + max_factor: FACTOR_3X, + election_id: ELECTION_ID, + stake_message_boc: None, + }) + .collect(); + + setup_adaptive_elector( + &mut harness.elector_mock, + ELECTION_ID, + elect_close, + participants, + || vec![], + ); + setup_default_provider(&mut harness.provider_mock, WALLET_BALANCE, None); + setup_wallet(&mut harness.wallet_mock); + + let mut runner = harness.build(node_id); + let result = runner.run().await; + assert!(result.is_ok(), "run() failed: {:?}", result.err()); + + // Node should NOT have participated (deferred). + let node = runner.nodes.get(node_id).unwrap(); + assert!(node.participant.is_none(), "should defer staking when not enough participants"); +} + +#[tokio::test] +async fn test_adaptive_proceed_after_wait_timeout() { + // Elections almost over. Only 5 participants (< min_validators=13). + // elect_close is very close (now + 10s) → waiting_period has expired. + // prev_min_eff available from past elections. + // Expected: proceeds to stake despite few participants. + let node_id = "node-1"; + let mut harness = TestHarness::new(); + harness.elections_config.policy = StakePolicy::AdaptiveSplit50; + harness.elections_config.adaptive_sleep_period_pct = 0.0; + harness.elections_config.adaptive_waiting_period_pct = 0.3; + + let now = common::time_format::now(); + let elect_close = now + 10; // almost closed + + let participants = (0..5u8) + .map(|i| Participant { + pub_key: vec![i; 32], + adnl_addr: vec![0xEE; 32], + wallet_addr: vec![i; 32], + stake: 300_000 * NANO, + max_factor: FACTOR_3X, + election_id: ELECTION_ID, + stake_message_boc: None, + }) + .collect(); + + // Provide past elections with a known frozen stake so prev_min_eff is available. + // prev_min_eff = 10_000 TON (well below free_balance ~49k). + setup_adaptive_elector( + &mut harness.elector_mock, + ELECTION_ID, + elect_close, + participants, + || { + let mut frozen_map = HashMap::new(); + frozen_map.insert( + [0xAA; 32], + FrozenParticipant { + wallet_addr: [0xBB; 32], + weight: 1, + stake: 10_000 * NANO, + banned: false, + }, + ); + vec![PastElections { + election_id: ELECTION_ID - 3600, + unfreeze_at: ELECTION_ID, + stake_held: 7200, + vset_hash: vec![], + frozen_map, + total_stake: 10_000 * NANO, + bonuses: 0, + }] + }, + ); + setup_default_provider(&mut harness.provider_mock, WALLET_BALANCE, None); + setup_wallet(&mut harness.wallet_mock); + + let mut runner = harness.build(node_id); + let result = runner.run().await; + assert!(result.is_ok(), "run() failed: {:?}", result.err()); + + // Node SHOULD have participated (timeout expired, fallback to prev). + let node = runner.nodes.get(node_id).unwrap(); + assert!(node.participant.is_some(), "should proceed to stake after waiting_period expires"); +} + +#[tokio::test] +async fn test_adaptive_sleep_period_delays_even_with_enough_participants() { + // Enough participants (20 > min_validators=13) but sleep_period = 0.99 + // (almost the entire election duration) and election just started. + // Expected: defers despite having enough participants. + let node_id = "node-1"; + let mut harness = TestHarness::new(); + harness.elections_config.policy = StakePolicy::AdaptiveSplit50; + harness.elections_config.adaptive_sleep_period_pct = 0.99; + harness.elections_config.adaptive_waiting_period_pct = 0.99; + + let now = common::time_format::now(); + let elect_close = now + 10_000; // election just started + + let participants: Vec = (0..20u8) + .map(|i| Participant { + pub_key: vec![i; 32], + adnl_addr: vec![0xEE; 32], + wallet_addr: vec![i; 32], + stake: 300_000 * NANO, + max_factor: FACTOR_3X, + election_id: ELECTION_ID, + stake_message_boc: None, + }) + .collect(); + + setup_adaptive_elector( + &mut harness.elector_mock, + ELECTION_ID, + elect_close, + participants, + || vec![], + ); + setup_default_provider(&mut harness.provider_mock, WALLET_BALANCE, None); + setup_wallet(&mut harness.wallet_mock); + + let mut runner = harness.build(node_id); + let result = runner.run().await; + assert!(result.is_ok(), "run() failed: {:?}", result.err()); + + let node = runner.nodes.get(node_id).unwrap(); + assert!( + node.participant.is_none(), + "should defer staking during sleep_period even with enough participants" + ); +} + +// ===================================================== +// AdaptiveSplit50: config validation tests +// ===================================================== + +// ===================================================== +// AdaptiveSplit50: three-tick top-up integration test +// ===================================================== + +#[tokio::test] +async fn test_adaptive_topup_three_ticks() { + // Tick 1: No participants, prev_min_eff=20k → stakes half (~25k). + // Tick 2: Our stake in elector → stake_accepted=true, re-stakes (current_stake=0 + // because stake_accepted was false at calc_stake time). + // Tick 3: stake_accepted=true from tick 2. prev_min_eff rises to 40k + // which is > our current_stake. Triggers actual top-up with current_stake > 0. + + let node_id = "node-1"; + let mut harness = TestHarness::new(); + harness.elections_config.policy = StakePolicy::AdaptiveSplit50; + let wallet_addr = addr_bytes(&wallet_address()); + + let fee = ELECTOR_STAKE_FEE + NPOOL_COMPUTE_FEE; + let pool_free_balance = WALLET_BALANCE - fee - MIN_NANOTON_FOR_STORAGE; + // Tick 1: prev_min_eff = 30k. half = pool_free/2 ≈ 25k < 30k → stake all. + let initial_stake = pool_free_balance; // stake all free_balance + + // Tick 2: stake_accepted set early, current_stake = initial_stake. + // total = pool_free + initial, half = total / 2. + // prev_min_eff = 30k (cached from tick 1). + // current_stake (≈50k) >= min_eff (30k) → no top-up. stake unchanged. + let stake_after_tick2 = initial_stake; + + // --- Elector: elections_info varies per tick --- + let ei_count = std::sync::Arc::new(std::sync::atomic::AtomicU32::new(0)); + let ei_cc = ei_count.clone(); + let wallet_addr_clone = wallet_addr.clone(); + harness.elector_mock.expect_elections_info().times(3).returning(move || { + let n = ei_cc.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + if n == 0 { + // Tick 1: no participants + Ok(ElectionsInfo { + election_id: ELECTION_ID, + elect_close: ELECTION_ID - 300, + min_stake: MIN_STAKE, + total_stake: 0, + failed: false, + finished: false, + participants: vec![], + }) + } else { + // Tick 2 & 3: our stake in elector. On tick 3 the elector + // reports the updated stake (after tick-2 re-stake). + let stake = if n == 1 { initial_stake } else { stake_after_tick2 }; + Ok(ElectionsInfo { + election_id: ELECTION_ID, + elect_close: ELECTION_ID - 300, + min_stake: MIN_STAKE, + total_stake: stake, + failed: false, + finished: false, + participants: vec![Participant { + pub_key: PUB_KEY.to_vec(), + adnl_addr: ADNL_ADDR.to_vec(), + wallet_addr: wallet_addr_clone.clone(), + stake, + max_factor: FACTOR_3X, + election_id: ELECTION_ID, + stake_message_boc: None, + }], + }) + } + }); + + // --- Elector: past_elections fetched twice (tick 1, tick 3; tick 2 uses cache) --- + // prev_min_eff on tick 1 must be > initial_stake so tick 2 triggers top-up. + // initial_stake ≈ 25k, so we use 30k. + let pe_count = std::sync::Arc::new(std::sync::atomic::AtomicU32::new(0)); + let pe_cc = pe_count.clone(); + harness.elector_mock.expect_past_elections().times(2).returning(move || { + let n = pe_cc.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + // Call 0 (tick 1): 30k (> initial_stake ~25k). Call 1 (tick 3): raised above stake_after_tick2. + let prev_min = if n == 0 { 30_000 * NANO } else { stake_after_tick2 + 5_000 * NANO }; + let mut frozen_map = HashMap::new(); + frozen_map.insert( + [0xAA; 32], + FrozenParticipant { + wallet_addr: [0xBB; 32], + weight: 1, + stake: prev_min, + banned: false, + }, + ); + Ok(vec![PastElections { + election_id: ELECTION_ID - 3600, + unfreeze_at: ELECTION_ID, + stake_held: 7200, + vset_hash: vec![], + frozen_map, + total_stake: prev_min, + bonuses: 0, + }]) + }); + + // --- Provider: validator_config varies per tick --- + let vc_count = std::sync::Arc::new(std::sync::atomic::AtomicU32::new(0)); + let vcc = vc_count.clone(); + harness.provider_mock.expect_validator_config().times(3).returning(move || { + let n = vcc.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + if n == 0 { + Ok(ValidatorConfig::new()) + } else { + let mut keys = HashMap::new(); + keys.insert( + ELECTION_ID, + ValidatorEntry { + key_id: KEY_ID.to_vec(), + public_key: vec![], + adnl_addrs: vec![(ADNL_ADDR.to_vec(), ELECTION_ID + 7200)], + expired_at: ELECTION_ID + 7200, + }, + ); + Ok(ValidatorConfig { keys }) + } + }); + + // --- Rest of elector/provider/wallet setup --- + setup_default_elector(&mut harness.elector_mock, ELECTION_ID, 0); + setup_default_provider(&mut harness.provider_mock, WALLET_BALANCE, None); + setup_wallet(&mut harness.wallet_mock); + + let mut runner = harness.build(node_id); + + // === Tick 1: initial stake (half) === + runner.refresh_validator_configs().await; + runner.refresh_validator_set().await; + let r1 = runner.run().await; + assert!(r1.is_ok(), "tick 1 failed: {:?}", r1.err()); + let node = runner.nodes.get(node_id).unwrap(); + assert!(node.participant.is_some(), "should participate after tick 1"); + assert!(!node.stake_accepted, "stake not yet accepted after tick 1"); + assert_eq!(node.participant.as_ref().unwrap().stake, initial_stake); + + // === Tick 2: elector recognizes our stake, stake_accepted → true === + // current_stake = initial_stake (~50k) >= prev_min_eff (30k) → no top-up. + runner.refresh_validator_configs().await; + runner.refresh_validator_set().await; + let r2 = runner.run().await; + assert!(r2.is_ok(), "tick 2 failed: {:?}", r2.err()); + let node = runner.nodes.get(node_id).unwrap(); + assert!(node.stake_accepted, "stake should be accepted on tick 2"); + let tick2_stake = node.participant.as_ref().unwrap().stake; + assert_eq!(tick2_stake, initial_stake, "tick 2: no top-up needed (current_stake >= min_eff)"); + + // === Tick 3: actual top-up with current_stake > 0 === + // Invalidate past_elections cache so tick 3 re-fetches with raised prev_min_eff. + // prev_min_eff now = stake_after_tick2 + 5k > current_stake → triggers top-up. + runner.past_elections_cache_id = 0; + runner.refresh_validator_configs().await; + runner.refresh_validator_set().await; + let r3 = runner.run().await; + assert!(r3.is_ok(), "tick 3 failed: {:?}", r3.err()); + let node = runner.nodes.get(node_id).unwrap(); + assert!(node.stake_accepted, "stake should still be accepted on tick 3"); + let tick3_stake = node.participant.as_ref().unwrap().stake; + + // On tick 3: stake_accepted=true → current_stake = stake_after_tick2. + // prev_min_eff = stake_after_tick2 + 5k → current_stake < min_eff → top-up. + // total = pool_free + stake_after_tick2, half = total/2. + let min_eff_tick3 = stake_after_tick2 + 5_000 * NANO; + let total_tick3 = pool_free_balance + stake_after_tick2; + let half_tick3 = total_tick3 / 2; + assert!( + tick3_stake > tick2_stake, + "tick 3: stake should increase via top-up: tick2={}, tick3={}", + tick2_stake, + tick3_stake + ); + if half_tick3 >= min_eff_tick3 { + // half branch: topup = half - current_stake + let topup = half_tick3 - stake_after_tick2; + assert_eq!( + tick3_stake, + tick2_stake + topup, + "tick 3: participant.stake = old + (half - current_stake)" + ); + } +} + +#[test] +fn test_elections_config_validate_sleep_gt_waiting() { + let config = ElectionsConfig { + adaptive_sleep_period_pct: 0.5, + adaptive_waiting_period_pct: 0.3, // sleep > waiting → invalid + ..ElectionsConfig::default() + }; + assert!(config.validate().is_err()); +} + +#[test] +fn test_elections_config_validate_sleep_out_of_range() { + let config = ElectionsConfig { + adaptive_sleep_period_pct: 1.5, // > 1.0 → invalid + ..ElectionsConfig::default() + }; + assert!(config.validate().is_err()); +} + +#[test] +fn test_elections_config_validate_valid() { + let config = ElectionsConfig { + adaptive_sleep_period_pct: 0.1, + adaptive_waiting_period_pct: 0.5, + ..ElectionsConfig::default() + }; + assert!(config.validate().is_ok()); +} + +#[test] +fn test_elections_config_defaults() { + let config = ElectionsConfig::default(); + assert_eq!(config.adaptive_sleep_period_pct, 0.0); + assert_eq!(config.adaptive_waiting_period_pct, 0.3); +} // Participation status transitions across election lifecycle // Simulates: Idle → Participating → Submitted → Accepted → Elected → Validating // Also verifies that stale election flags don't leak after elections close. From d4c19a6e3f87c43b917542e2bdb3319f1e15b6df Mon Sep 17 00:00:00 2001 From: NikitaM <21960067+Keshoid@users.noreply.github.com> Date: Fri, 27 Mar 2026 12:44:18 +0300 Subject: [PATCH 02/11] feat(election): add compute_min_effective_stake + tests --- .../elections/src/election_emulator.rs | 350 ++++++++++++++++++ 1 file changed, 350 insertions(+) diff --git a/src/node-control/elections/src/election_emulator.rs b/src/node-control/elections/src/election_emulator.rs index bcd7abd..296d182 100644 --- a/src/node-control/elections/src/election_emulator.rs +++ b/src/node-control/elections/src/election_emulator.rs @@ -129,6 +129,106 @@ pub fn emulate_election( Some(EmulationResult { effective_min_stake, elected_count: best_n as u16 }) } +/// Computes the minimum effective stake required to enter the validator set using a two-phase search. +/// +/// **Phase 1 (coarse):** starting from an initial estimate (`max_participant_stake / +/// effective_max_factor`), step down by `big_step = ctx.min_stake / 10` while included +/// in the elected set. If the initial estimate is too low, step up by `big_step` first. +/// +/// **Phase 2 (fine):** on the first exclusion after an inclusion, compute +/// `fine_step = (last_included_stake - current) / 10` (at least 1) and step up by +/// `fine_step` until included again. Returns the `effective_min_stake` from that election. +/// +/// Returns `None` if there are fewer than `min_validators` existing participants, +/// if `min_stake` is zero, or if no valid set can be formed. +/// +/// # Example +/// +/// ```text +/// participants (stake, max-factor) = [(1000k,3x), (1000k,2x), (800k,3x), (800k,3x), (600k,3x)] +/// min_validators = 5, min_stake = 100k, our_max_factor = 3x +/// big_step = 100k / 10 = 10k +/// +/// 1) initial estimate = 1000k / 2.0 = 500k +/// 2) Phase 1 (step down by 10k): 500k(in) → 490k(in) → ... → 340k(in) → 330k(out) +/// 3) Phase 2: fine_step = max(1, (340k - 330k) / 10) = 1k +/// 331k(out) → 332k(out) → 333k(out) → 334k(in, eff_min=334k) +/// 4) return Some(334k) +/// ``` +pub fn compute_min_effective_stake(ctx: &ElectionContext, our_max_factor: u32) -> Option { + // Not enough participants to estimate. + if ctx.participants.len() < ctx.min_validators as usize { + return None; + } + + // Step size would be zero. + if ctx.min_stake == 0 { + return None; + } + + let big_step = (ctx.min_stake / 10).max(1); // Phase 1 (coarse) step + let mut current = initial_stake_estimate(ctx); + let mut last_submitted = 0u64; // last stake where we were included + let mut last_eff: Option = None; // effective_min_stake at last inclusion + let mut fine_phase = false; + let mut fine_step = 1u64; // Phase 2 step, set on phase transition + + loop { + let Some(res) = emulate_election(ctx, current, our_max_factor) else { + return last_eff; // no valid election possible, return last known good + }; + + if current >= res.effective_min_stake { + // Included: record and keep stepping down. + last_submitted = current; + last_eff = Some(res.effective_min_stake); + if fine_phase { + return last_eff; // Phase 2 converged. + } + current = current.saturating_sub(big_step); + } else if last_eff.is_some() { + // Excluded after inclusion: switch to fine upward search (Phase 2). + if !fine_phase { + fine_step = (last_submitted.saturating_sub(current) / 10).max(1); + fine_phase = true; + } + let next = current.saturating_add(fine_step); + if next == current { + return last_eff; // overflow guard + } + current = next; + } else { + // No success yet: step up coarsely. + let next = current.saturating_add(big_step); + if next == current { + return None; // overflow guard + } + current = next; + } + } +} + +/// Initial stake estimate: max participant stake divided by their smallest effective max_factor. +fn initial_stake_estimate(ctx: &ElectionContext) -> u64 { + let max_s = ctx + .participants + .iter() + .map(|p| p.stake.min(ctx.max_stake)) + .max() + .unwrap_or(0); + if max_s == 0 { + return ctx.min_stake; + } + let min_factor = ctx + .participants + .iter() + .filter(|p| p.stake.min(ctx.max_stake) == max_s) + .map(|p| p.max_factor.min(ctx.global_max_factor)) + .min() + .unwrap_or(ctx.global_max_factor); + if min_factor == 0 { max_s } else { ((max_s as u128 * 65536) / min_factor as u128) as u64 } +} + #[cfg(test)] mod tests { use super::*; @@ -356,4 +456,254 @@ mod tests { assert_eq!(result.elected_count, 1); assert_eq!(result.effective_min_stake, 150); } + + // ---- compute_min_effective_stake tests ---- + + #[test] + fn test_compute_min_effective_stake_comment_example() { + // Scenario from the function comment. + // participants: [(50,3x),(50,2x),(40,3x),(40,3x),(30,3x)], min_stake=5 + // big_step = max(1, 5/10) = 1 + // initial_estimate = 50 * 65536 / (2 * 65536) = 25 + // + // Coarse phase (step-1 descent): at S=17, n=6 total=211 > n=5 total=210 → included. + // At S=16, n=5 wins (total=206<210), eff_min=30, excluded. + // Fine phase: fine_step=max(1,(17-16)/10)=1. S=17 → included, eff_min=17. + let ctx = ElectionContext { + participants: vec![ + ParticipantStake { stake: 50, max_factor: 3 * 65536 }, + ParticipantStake { stake: 50, max_factor: 2 * 65536 }, + ParticipantStake { stake: 40, max_factor: 3 * 65536 }, + ParticipantStake { stake: 40, max_factor: 3 * 65536 }, + ParticipantStake { stake: 30, max_factor: 3 * 65536 }, + ], + max_validators: 100, + min_validators: 5, + global_max_factor: 3 * 65536, + min_stake: 5, + max_stake: MAX_STAKE, + }; + let result = compute_min_effective_stake(&ctx, 3 * 65536); + assert_eq!(result, Some(17)); + // Self-consistency: emulate at the returned stake must include us. + let r = emulate_election(&ctx, 17, 3 * 65536).unwrap(); + assert!(17 >= r.effective_min_stake); + } + + #[test] + fn test_compute_min_effective_stake_not_enough_participants() { + // Fewer than min_validators existing participants → None (not enough data). + let ctx = ElectionContext { + participants: vec![ + ParticipantStake { stake: 1000, max_factor: FACTOR_3X }, + ParticipantStake { stake: 1000, max_factor: FACTOR_3X }, + ], + max_validators: 10, + min_validators: 5, + global_max_factor: FACTOR_3X, + min_stake: 10, + max_stake: MAX_STAKE, + }; + assert!(compute_min_effective_stake(&ctx, FACTOR_3X).is_none()); + + // Exactly min_validators - 1 existing participants → also None. + let ctx2 = ElectionContext { + participants: vec![ParticipantStake { stake: 1000, max_factor: FACTOR_3X }; 4], + max_validators: 10, + min_validators: 5, + global_max_factor: FACTOR_3X, + min_stake: 10, + max_stake: MAX_STAKE, + }; + assert!(compute_min_effective_stake(&ctx2, FACTOR_3X).is_none()); + } + + #[test] + fn test_compute_min_effective_stake_sole_validator() { + // No existing participants → fewer than min_validators → None (not enough data). + let ctx = ElectionContext { + participants: vec![], + max_validators: 10, + min_validators: 1, + global_max_factor: FACTOR_3X, + min_stake: 100, + max_stake: MAX_STAKE, + }; + assert_eq!(compute_min_effective_stake(&ctx, FACTOR_3X), None); + } + + #[test] + fn test_compute_min_effective_stake_full_set() { + // 10 participants at 1000 each, max_validators=10 (set exactly full). + // To displace the weakest we must match the weakest stake (1000). + // big_step = max(1, 100/10) = 10. initial ≈ 333. + // Coarse up: 333→…→1003 (first inclusion, eff_min=1000), then down to 993 (excluded). + // Fine phase: fine_step=max(1,(1003-993)/10)=1. 994…1000 → included, return 1000. + let ctx = ElectionContext { + participants: vec![ParticipantStake { stake: 1000, max_factor: FACTOR_3X }; 10], + max_validators: 10, + min_validators: 5, + global_max_factor: FACTOR_3X, + min_stake: 100, + max_stake: MAX_STAKE, + }; + assert_eq!(compute_min_effective_stake(&ctx, FACTOR_3X), Some(1000)); + } + + #[test] + fn test_compute_min_effective_stake_equal_stakes_unfilled_set() { + // 5 participants at 500 each, max_validators=100, min_stake=50. + // big_step = max(1, 50/10) = 5. initial = 500/3 = 166. + // n=6 total = 16*S (for S ≤ 166). n=5 total = 2500. + // 16S > 2500 iff S ≥ 157. So min entry stake = 157. + let ctx = ElectionContext { + participants: vec![ParticipantStake { stake: 500, max_factor: FACTOR_3X }; 5], + max_validators: 100, + min_validators: 5, + global_max_factor: FACTOR_3X, + min_stake: 50, + max_stake: MAX_STAKE, + }; + assert_eq!(compute_min_effective_stake(&ctx, FACTOR_3X), Some(157)); + // Verify boundary: S=157 included, S=156 excluded. + let r_in = emulate_election(&ctx, 157, FACTOR_3X).unwrap(); + assert!(157 >= r_in.effective_min_stake); + let r_out = emulate_election(&ctx, 156, FACTOR_3X).unwrap(); + assert!(156 < r_out.effective_min_stake); + } + + #[test] + fn test_compute_min_effective_stake_real_mainnet_data() { + // Top-75 stakes from mainnet validation cycle 1774538504. + // All validators share max_factor = 3 * 65536 (196608). + // Config17: min_stake = 100_000 TON, max_stake = unlimited, max_stake_factor = 3x. + // Config16: max_validators = 1000, min_validators = 75. + // + // big_step = min_stake / 10 = 10_000 TON. + // fine_step = big_step / 10 = 1_000 TON. + // The returned value should be the minimum stake (± 1_000 TON) to enter the set. + const STAKES: &[u64] = &[ + 2060445595410000, 2060445595410000, 2060445595410000, 2060445595410000, + 2060445595410000, 2060445595410000, 2060445595410000, 2060445595410000, + 2060445595410000, 2043172198470000, 2043172198470000, 2043171198470000, + 2043171198470000, 2043167198470000, 2041919198470000, 2041919198470000, + 2041919198470000, 2041919198470000, 2041919198470000, 2041623198470000, + 2041623198470000, 2041623198470000, 2041623198470000, 2041621198470000, + 2041534198470000, 2041534198470000, 2041534198470000, 2041534198470000, + 2041534198470000, 2041534198470000, 2041534198470000, 2041534198470000, + 2041528198470000, 2040298198470000, 2040298198470000, 2040298198470000, + 2040298198470000, 2040298198470000, 2040298198470000, 2040298198470000, + 2040298198470000, 2040298198470000, 2040298198470000, 2040298198470000, + 2040298198470000, 2040298198470000, 2040298198470000, 2039455198470000, + 2039285198470000, 2035393202020000, 2033875202020000, 2017259202020000, + 2003210617967562, 2003204176979680, 2000435064702159, 1907320760429904, + 1907320760429904, 1907320760429723, 1907320760425920, 1905457988889466, + 1900457012649501, 1900418452888393, 1900418452886194, 1900418452885461, + 1900417444657521, 1890781198470000, 1800418623166831, 1769587198470000, + 1716359202020000, 1698999585994571, 1697132198470000, 1693715198470000, + 1693713198470000, 1693257198470000, 1604101270271938, + ]; + let ctx = ElectionContext { + participants: STAKES + .iter() + .map(|&s| ParticipantStake { stake: s, max_factor: 3 * 65536 }) + .collect(), + max_validators: 1000, + min_validators: 75, + global_max_factor: 3 * 65536, + min_stake: 100_000_000_000_000, // 100_000 TON + max_stake: u64::MAX, + }; + + let result = compute_min_effective_stake(&ctx, 3 * 65536); + assert!(result.is_some(), "expected a valid entry stake with 309 participants"); + let min_s = result.unwrap(); + + // At min_s we must be included. + let r_in = emulate_election(&ctx, min_s, 3 * 65536).unwrap(); + assert!( + min_s >= r_in.effective_min_stake, + "should be included at {}k TON (eff_min={}k TON)", + min_s / NANO / 1000, + r_in.effective_min_stake / NANO / 1000, + ); + + // fine_step = big_step / 10 = 10_000 TON / 10 = 1_000 TON. + // By algorithm design, at min_s - 1_000 TON we must NOT be included. + let fine_step = 1_000 * NANO; + let r_out = emulate_election(&ctx, min_s - fine_step, 3 * 65536).unwrap(); + assert!( + min_s - fine_step < r_out.effective_min_stake, + "should be excluded at {}k TON (eff_min={}k TON)", + (min_s - fine_step) / NANO / 1000, + r_out.effective_min_stake / NANO / 1000, + ); + } + + #[test] + fn test_compute_min_effective_stake_initial_estimate_below_threshold() { + // All participants have high stakes. Our initial estimate may be below the entry + // threshold so the coarse phase must raise before it can lower. + // 5 participants at 900, factor=3x, max_validators=10, min_stake=30, min_validators=5. + // initial = 900/3 = 300. + // n=5 total at m=900 = 900*5 = 4500. + // n=6 total at m=S = 16S (for S ≤ 300). 16S > 4500 iff S > 281.25, i.e. S ≥ 282. + // So initial=300 is already above the threshold → coarse phase descends immediately. + let ctx = ElectionContext { + participants: vec![ParticipantStake { stake: 900, max_factor: FACTOR_3X }; 5], + max_validators: 10, + min_validators: 5, + global_max_factor: FACTOR_3X, + min_stake: 30, + max_stake: MAX_STAKE, + }; + let result = compute_min_effective_stake(&ctx, FACTOR_3X); + assert!(result.is_some()); + let min_s = result.unwrap(); + // Returned value must include us. + let r = emulate_election(&ctx, min_s, FACTOR_3X).unwrap(); + assert!(min_s >= r.effective_min_stake); + // One step below must exclude us. + let r2 = emulate_election(&ctx, min_s - 1, FACTOR_3X).unwrap(); + assert!(min_s - 1 < r2.effective_min_stake); + } + + #[test] + fn test_compute_min_effective_stake_zero_min_stake() { + // min_stake = 0 → big_step would be 0 → None early return. + let ctx = ElectionContext { + participants: vec![ParticipantStake { stake: 1000, max_factor: FACTOR_3X }; 5], + max_validators: 100, + min_validators: 5, + global_max_factor: FACTOR_3X, + min_stake: 0, + max_stake: MAX_STAKE, + }; + assert_eq!(compute_min_effective_stake(&ctx, FACTOR_3X), None); + } + + #[test] + fn test_compute_min_effective_stake_emulate_returns_none_mid_loop() { + // Phase 1 steps current below min_stake → emulate_election returns None + // → function returns last_eff from the previous successful iteration. + // + // 5 participants at 200, min_stake = 150, big_step = 15. + // initial = 200/3 = 66 < min_stake=150 → excluded, step up by 15. + // At some point we cross 150 and get included (emulate succeeds). + // Then stepping down eventually goes below 150 → emulate returns None → return last_eff. + let ctx = ElectionContext { + participants: vec![ParticipantStake { stake: 200, max_factor: FACTOR_3X }; 5], + max_validators: 100, + min_validators: 5, + global_max_factor: FACTOR_3X, + min_stake: 150, + max_stake: MAX_STAKE, + }; + let result = compute_min_effective_stake(&ctx, FACTOR_3X); + assert!(result.is_some()); + let min_s = result.unwrap(); + // Verify we're included at the returned stake. + let r = emulate_election(&ctx, min_s, FACTOR_3X).unwrap(); + assert!(min_s >= r.effective_min_stake); + } } From 6c46471937cf29dc4a46d28616b705e94570e893 Mon Sep 17 00:00:00 2001 From: NikitaM <21960067+Keshoid@users.noreply.github.com> Date: Mon, 30 Mar 2026 12:18:49 +0300 Subject: [PATCH 03/11] refactor(elections): extract some stake funcs to a separate module --- .../elections/src/adaptive_strategy.rs | 226 ++++++++++++ .../elections/src/election_emulator.rs | 101 ++++-- src/node-control/elections/src/lib.rs | 1 + src/node-control/elections/src/runner.rs | 338 ++++-------------- .../elections/src/runner_tests.rs | 19 +- 5 files changed, 379 insertions(+), 306 deletions(-) create mode 100644 src/node-control/elections/src/adaptive_strategy.rs diff --git a/src/node-control/elections/src/adaptive_strategy.rs b/src/node-control/elections/src/adaptive_strategy.rs new file mode 100644 index 0000000..c1c8f3b --- /dev/null +++ b/src/node-control/elections/src/adaptive_strategy.rs @@ -0,0 +1,226 @@ +/* + * Copyright (C) 2025-2026 RSquad Blockchain Lab. + * + * Licensed under the GNU General Public License v3.0. + * See the LICENSE file in the root of this repository. + * + * This software is provided "AS IS", WITHOUT WARRANTY OF ANY KIND. + */ + +use crate::election_emulator::{self, ElectionContext}; +use common::ton_utils::nanotons_to_tons_f64; +use contracts::ElectionsInfo; +use ton_block::config_params::{ConfigParam16, ConfigParam17}; + +/// AdaptiveSplit50 wait logic: check whether enough time has passed and enough +/// participants have joined before proceeding with stake calculation. +/// +/// Returns `true` if staking should proceed, `false` if we should defer (return 0). +pub(crate) fn is_adaptive_split50_ready( + node_id: &str, + stake_accepted: bool, + elections_info: &ElectionsInfo, + cfg15_start_before: u32, + cfg15_end_before: u32, + cfg16: &ConfigParam16, + adaptive_sleep_pct: f64, + adaptive_waiting_pct: f64, +) -> bool { + if stake_accepted { + return true; + } + + let min_validators = cfg16.min_validators.as_u16() as usize; + let participants_count = elections_info.participants.len(); + let election_duration = cfg15_start_before.saturating_sub(cfg15_end_before) as u64; + + if election_duration == 0 { + tracing::warn!( + "node [{}] adaptive_split50: election_duration=0, skipping wait logic", + node_id + ); + return true; + } + + let election_start = elections_info.elect_close.saturating_sub(election_duration); + let sleep_deadline = election_start + (election_duration as f64 * adaptive_sleep_pct) as u64; + let wait_deadline = election_start + (election_duration as f64 * adaptive_waiting_pct) as u64; + let now = common::time_format::now(); + + // Wait if sleep period hasn't passed yet + if now < sleep_deadline { + tracing::info!( + "node [{}] adaptive_split50 - sleep period: now < sleep_deadline={}", + node_id, + common::time_format::format_ts(sleep_deadline) + ); + return false; + } + + // Wait if not enough participants and waiting period hasn't expired + if participants_count < min_validators && now < wait_deadline { + tracing::info!( + "node [{}] adaptive_split50 - waiting for participants: ({}/{}), deadline={}", + node_id, + participants_count, + min_validators, + common::time_format::format_ts(wait_deadline) + ); + return false; + } + + true +} + +/// Calculate stake for AdaptiveSplit50 policy. +/// +/// Determines min_eff_stake from current emulation and/or past elections, +/// then decides whether to split funds (stake half) or stake min_eff_stake. +/// +/// Note: On subsequent ticks, tops up if min_eff_stake grew above current_stake. +/// If the remainder for the next round would be below min_eff_stake, stakes everything. +pub(crate) fn calc_adaptive_stake( + node_id: &str, + total_balance: u64, + free_balance: u64, + current_stake: u64, + our_max_factor: u32, + elections_info: &ElectionsInfo, + cfg16: &ConfigParam16, + cfg17: &ConfigParam17, + prev_min_eff_stake: Option, +) -> anyhow::Result { + let min_validators = cfg16.min_validators.as_u16(); + let max_validators = cfg16.max_validators.as_u16(); + let max_stake_factor = cfg17.max_stake_factor; + let cfg17_min_stake = cfg17.min_stake.as_u64().unwrap_or(0); + let cfg17_max_stake = cfg17.max_stake.as_u64().unwrap_or(u64::MAX); + let half = total_balance / 2; + + // Compute curr_min_eff_stake from current participants. + tracing::info!( + "node [{}] adaptive_split50: emulate elections on {} participants", + node_id, + elections_info.participants.len() + ); + let participants = elections_info + .participants + .iter() + .map(|p| election_emulator::ParticipantStake { stake: p.stake, max_factor: p.max_factor }) + .collect(); + + let ctx = ElectionContext { + participants, + max_validators, + min_validators, + global_max_factor: max_stake_factor, + min_stake: cfg17_min_stake, + max_stake: cfg17_max_stake, + }; + + let curr_min_eff = election_emulator::compute_min_effective_stake(&ctx, our_max_factor); + + // Choose the smallest min_eff_stake from curr and prev. + let min_eff_stake = match (curr_min_eff, prev_min_eff_stake) { + (Some(curr), Some(prev)) => { + tracing::info!( + "node [{}] adaptive_split50: curr_min_eff={} TON, prev_min_eff={} TON, using min={} TON", + node_id, + nanotons_to_tons_f64(curr), + nanotons_to_tons_f64(prev), + nanotons_to_tons_f64(curr.min(prev)) + ); + curr.min(prev) + } + (Some(curr), None) => { + tracing::info!( + "node [{}] adaptive_split50: curr_min_eff={} TON (no past elections data)", + node_id, + nanotons_to_tons_f64(curr) + ); + curr + } + (None, Some(prev)) => { + tracing::info!( + "node [{}] adaptive_split50: prev_min_eff={} TON (not enough current participants < {})", + node_id, + nanotons_to_tons_f64(prev), + min_validators + ); + prev + } + (None, None) => { + anyhow::bail!( + "node [{}] adaptive_split50: cannot determine min effective stake \ + (not enough participants and no past elections)", + node_id + ); + } + }; + + // If we already have enough stake, no need to top-up. + if current_stake >= min_eff_stake { + tracing::debug!( + "node [{}] adaptive_split50: stake={} TON >= min_eff={} TON, no top-up needed", + node_id, + nanotons_to_tons_f64(current_stake), + nanotons_to_tons_f64(min_eff_stake) + ); + return Ok(0); + } + + // Insufficient funds guard — if the pool doesn't have enough free + // funds to cover min_eff_stake, skip the election entirely. + // On the initial submission (current_stake == 0) we need at least min_eff_stake + // free; on top-ups we need at least the delta. + let required = min_eff_stake.saturating_sub(current_stake); + if free_balance < required { + tracing::error!( + "node [{}] adaptive_split50: insufficient funds free_balance={} TON < required={} TON (min_eff={} TON), skipping election", + node_id, + nanotons_to_tons_f64(free_balance), + nanotons_to_tons_f64(required), + nanotons_to_tons_f64(min_eff_stake), + ); + return Ok(0); + } + + // Decide between staking half or min_eff_stake. + if half >= min_eff_stake { + // half is enough — stake half. + let stake = half.saturating_sub(current_stake); + tracing::info!( + "node [{}] adaptive_split50 - stake half: current_stake={} TON, left_to_stake={} TON, half={} TON >= min_eff={} TON", + node_id, + nanotons_to_tons_f64(current_stake), + nanotons_to_tons_f64(stake), + nanotons_to_tons_f64(half), + nanotons_to_tons_f64(min_eff_stake), + ); + if stake > free_balance { + // Not enough free funds to stake half. Skip and let the operator top up. + tracing::error!( + "node [{}] adaptive_split50 - insufficient free balance: need {} TON to stake half, \ + but only {} TON available. Consider topping up the pool.", + node_id, + nanotons_to_tons_f64(stake), + nanotons_to_tons_f64(free_balance), + ); + return Ok(0); + } + Ok(stake) + } else { + // half < min_eff — splitting is not viable. + // Since half < min_eff, it follows that total < 2 * min_eff, + // so the remainder after staking min_eff would also be < min_eff. + // The next round won't have enough funds anyway — stake everything. + tracing::info!( + "node [{}] adaptive_split50 - stake all: half={} TON < min_eff={} TON, staking all free_balance={} TON", + node_id, + nanotons_to_tons_f64(half), + nanotons_to_tons_f64(min_eff_stake), + nanotons_to_tons_f64(free_balance), + ); + Ok(free_balance) + } +} diff --git a/src/node-control/elections/src/election_emulator.rs b/src/node-control/elections/src/election_emulator.rs index 296d182..0e57069 100644 --- a/src/node-control/elections/src/election_emulator.rs +++ b/src/node-control/elections/src/election_emulator.rs @@ -210,12 +210,7 @@ pub fn compute_min_effective_stake(ctx: &ElectionContext, our_max_factor: u32) - /// Initial stake estimate: max participant stake divided by their smallest effective max_factor. fn initial_stake_estimate(ctx: &ElectionContext) -> u64 { - let max_s = ctx - .participants - .iter() - .map(|p| p.stake.min(ctx.max_stake)) - .max() - .unwrap_or(0); + let max_s = ctx.participants.iter().map(|p| p.stake.min(ctx.max_stake)).max().unwrap_or(0); if max_s == 0 { return ctx.min_stake; } @@ -583,25 +578,81 @@ mod tests { // fine_step = big_step / 10 = 1_000 TON. // The returned value should be the minimum stake (± 1_000 TON) to enter the set. const STAKES: &[u64] = &[ - 2060445595410000, 2060445595410000, 2060445595410000, 2060445595410000, - 2060445595410000, 2060445595410000, 2060445595410000, 2060445595410000, - 2060445595410000, 2043172198470000, 2043172198470000, 2043171198470000, - 2043171198470000, 2043167198470000, 2041919198470000, 2041919198470000, - 2041919198470000, 2041919198470000, 2041919198470000, 2041623198470000, - 2041623198470000, 2041623198470000, 2041623198470000, 2041621198470000, - 2041534198470000, 2041534198470000, 2041534198470000, 2041534198470000, - 2041534198470000, 2041534198470000, 2041534198470000, 2041534198470000, - 2041528198470000, 2040298198470000, 2040298198470000, 2040298198470000, - 2040298198470000, 2040298198470000, 2040298198470000, 2040298198470000, - 2040298198470000, 2040298198470000, 2040298198470000, 2040298198470000, - 2040298198470000, 2040298198470000, 2040298198470000, 2039455198470000, - 2039285198470000, 2035393202020000, 2033875202020000, 2017259202020000, - 2003210617967562, 2003204176979680, 2000435064702159, 1907320760429904, - 1907320760429904, 1907320760429723, 1907320760425920, 1905457988889466, - 1900457012649501, 1900418452888393, 1900418452886194, 1900418452885461, - 1900417444657521, 1890781198470000, 1800418623166831, 1769587198470000, - 1716359202020000, 1698999585994571, 1697132198470000, 1693715198470000, - 1693713198470000, 1693257198470000, 1604101270271938, + 2060445595410000, + 2060445595410000, + 2060445595410000, + 2060445595410000, + 2060445595410000, + 2060445595410000, + 2060445595410000, + 2060445595410000, + 2060445595410000, + 2043172198470000, + 2043172198470000, + 2043171198470000, + 2043171198470000, + 2043167198470000, + 2041919198470000, + 2041919198470000, + 2041919198470000, + 2041919198470000, + 2041919198470000, + 2041623198470000, + 2041623198470000, + 2041623198470000, + 2041623198470000, + 2041621198470000, + 2041534198470000, + 2041534198470000, + 2041534198470000, + 2041534198470000, + 2041534198470000, + 2041534198470000, + 2041534198470000, + 2041534198470000, + 2041528198470000, + 2040298198470000, + 2040298198470000, + 2040298198470000, + 2040298198470000, + 2040298198470000, + 2040298198470000, + 2040298198470000, + 2040298198470000, + 2040298198470000, + 2040298198470000, + 2040298198470000, + 2040298198470000, + 2040298198470000, + 2040298198470000, + 2039455198470000, + 2039285198470000, + 2035393202020000, + 2033875202020000, + 2017259202020000, + 2003210617967562, + 2003204176979680, + 2000435064702159, + 1907320760429904, + 1907320760429904, + 1907320760429723, + 1907320760425920, + 1905457988889466, + 1900457012649501, + 1900418452888393, + 1900418452886194, + 1900418452885461, + 1900417444657521, + 1890781198470000, + 1800418623166831, + 1769587198470000, + 1716359202020000, + 1698999585994571, + 1697132198470000, + 1693715198470000, + 1693713198470000, + 1693257198470000, + 1604101270271938, ]; let ctx = ElectionContext { participants: STAKES diff --git a/src/node-control/elections/src/lib.rs b/src/node-control/elections/src/lib.rs index f3bb221..a669173 100644 --- a/src/node-control/elections/src/lib.rs +++ b/src/node-control/elections/src/lib.rs @@ -6,6 +6,7 @@ * * This software is provided "AS IS", WITHOUT WARRANTY OF ANY KIND. */ +pub(crate) mod adaptive_strategy; pub mod election_emulator; pub mod election_task; pub mod providers; diff --git a/src/node-control/elections/src/runner.rs b/src/node-control/elections/src/runner.rs index 1ad9116..f6fcd10 100644 --- a/src/node-control/elections/src/runner.rs +++ b/src/node-control/elections/src/runner.rs @@ -7,7 +7,7 @@ * This software is provided "AS IS", WITHOUT WARRANTY OF ANY KIND. */ use crate::{ - election_emulator::{self, ElectionContext}, + adaptive_strategy, providers::{ElectionsProvider, ValidatorConfig, ValidatorEntry}, }; use anyhow::Context as _; @@ -213,6 +213,23 @@ impl SnapshotCache { } } +struct ConfigParams<'a> { + elections_info: &'a ElectionsInfo, + cfg15: &'a ConfigParam15, + cfg16: &'a ConfigParam16, + cfg17: &'a ConfigParam17, +} + +/// Context needed by [`ElectionRunner::calc_stake`]. +/// Built from individual `ElectionRunner` fields to avoid borrow conflicts. +struct StakeContext<'a> { + past_elections: &'a [PastElections], + our_max_factor: u32, + adaptive_sleep_pct: f64, + adaptive_waiting_pct: f64, + prev_min_eff_stake: Option, +} + impl ElectionRunner { pub(crate) fn new( elections_config: &ElectionsConfig, @@ -416,10 +433,6 @@ impl ElectionRunner { let cfg16 = self.fetch_config_param_16().await?; let cfg17 = self.fetch_config_param_17().await?; - // Cap prev_min_eff to cfg17.max_stake as a sanity bound against outliers. - let prev_min_eff_stake = - self.cached_prev_min_eff.map(|v| v.min(cfg17.max_stake.as_u64().unwrap_or(u64::MAX))); - // walk through the nodes and try to participate in the elections let mut nodes = self.nodes.keys().cloned().collect::>(); nodes.sort(); @@ -450,18 +463,13 @@ impl ElectionRunner { } tracing::info!("node [{}] participate in elections: id={}", node_id, election_id); - if let Err(e) = self - .participate( - &node_id, - election_id, - &elections_info, - &cfg15, - &cfg16, - &cfg17, - prev_min_eff_stake, - ) - .await - { + let config_params = ConfigParams { + elections_info: &elections_info, + cfg15: &cfg15, + cfg16: &cfg16, + cfg17: &cfg17, + }; + if let Err(e) = self.participate(&node_id, election_id, &config_params).await { if let Some(node) = self.nodes.get_mut(&node_id) { node.last_error = Some(format!("{:#}", e)); } @@ -532,41 +540,37 @@ impl ElectionRunner { &mut self, node_id: &str, election_id: u64, - elections_info: &ElectionsInfo, - cfg15: &ConfigParam15, - cfg16: &ConfigParam16, - cfg17: &ConfigParam17, - prev_min_eff_stake: Option, + params: &ConfigParams<'_>, ) -> anyhow::Result<()> { let max_factor = (self.calc_max_factor() * 65536.0) as u32; + let stake_ctx = StakeContext { + past_elections: &self.past_elections, + our_max_factor: max_factor, + adaptive_sleep_pct: self.adaptive_sleep_pct, + adaptive_waiting_pct: self.adaptive_waiting_pct, + prev_min_eff_stake: self.cached_prev_min_eff, + }; let node = self.nodes.get_mut(node_id).expect("node not found"); // Find validator key for current elections in the validator config let validator_key = node.find_election_key(election_id).await; // Find participant in the elections info by validator public key let participant = validator_key.as_ref().and_then(|entry| { - elections_info.participants.iter().find(|p| p.pub_key == entry.public_key).cloned() + params + .elections_info + .participants + .iter() + .find(|p| p.pub_key == entry.public_key) + .cloned() }); // If the elector already has our stake, mark it accepted early // so that calc_stake uses the correct current_stake (not 0). if participant.is_some() { node.stake_accepted = true; } - let stake = Self::calc_stake( - node, - node_id, - &self.past_elections, - participant.as_ref().map(|p| p.stake).unwrap_or(0), - elections_info, - cfg15, - cfg16, - cfg17, - max_factor, - self.adaptive_sleep_pct, - self.adaptive_waiting_pct, - prev_min_eff_stake, - ) - .await - .context("stake calculation error")?; + let elections_stake = participant.as_ref().map(|p| p.stake).unwrap_or(0); + let stake = Self::calc_stake(node, node_id, elections_stake, params, &stake_ctx) + .await + .context("stake calculation error")?; if stake == 0 { tracing::info!("node [{}] skipping elections this tick (stake=0)", node_id); @@ -590,7 +594,7 @@ impl ElectionRunner { election_id ); let key_expired_at = - election_id + cfg15.validators_elected_for as u64 + EXPIRED_LAG; + election_id + params.cfg15.validators_elected_for as u64 + EXPIRED_LAG; let (key_id, pub_key) = node .api .new_validator_key(election_id, key_expired_at) @@ -907,29 +911,20 @@ impl ElectionRunner { async fn calc_stake( node: &mut Node, node_id: &str, - past_elections: &[PastElections], - elections_stake: u64, // stake sent to the elections but not yet frozen by the elector - elections_info: &ElectionsInfo, - cfg15: &ConfigParam15, - cfg16: &ConfigParam16, - cfg17: &ConfigParam17, - our_max_factor: u32, - adaptive_sleep_pct: f64, - adaptive_waiting_pct: f64, - prev_min_eff_stake: Option, + elections_stake: u64, + configs: &ConfigParams<'_>, + ctx: &StakeContext<'_>, ) -> anyhow::Result { - let min_stake = elections_info.min_stake; + let min_stake = configs.elections_info.min_stake; tracing::info!("node [{}] calc stake", node_id); let fee = ELECTOR_STAKE_FEE + NPOOL_COMPUTE_FEE; let mut frozen_stake = 0; // Calculate frozen stake from past elections - for election in past_elections { + for election in ctx.past_elections { let validator_entry = node.find_election_key(election.election_id).await; - // If validator key is found in the past election, add frozen stake to the total if let Some(entry) = validator_entry { let mut pubkey_array = [0u8; 32]; pubkey_array.copy_from_slice(&entry.public_key); - // Fetch frozen stake from the past election by validator public key frozen_stake += election.frozen_map.get(&pubkey_array).map(|frozen| frozen.stake).unwrap_or(0); } @@ -957,236 +952,35 @@ impl ElectionRunner { match &node.stake_policy { StakePolicy::AdaptiveSplit50 => { - // AdaptiveSplit50 wait logic: defer staking until enough participants - // AND minimum sleep period has passed. - if !node.stake_accepted { - let min_validators = cfg16.min_validators.as_u16() as usize; - let participants_count = elections_info.participants.len(); - let election_duration = - cfg15.elections_start_before.saturating_sub(cfg15.elections_end_before) - as u64; - - // Skip wait/sleep logic if election_duration is 0 (misconfigured). - if election_duration == 0 { - tracing::warn!( - "node [{}] adaptive_split50: election_duration=0, skipping wait logic", - node_id - ); - } else { - let election_start = - elections_info.elect_close.saturating_sub(election_duration); - let sleep_deadline = - election_start + (election_duration as f64 * adaptive_sleep_pct) as u64; - let wait_deadline = election_start - + (election_duration as f64 * adaptive_waiting_pct) as u64; - let now = time_format::now(); - - // Wait if sleep period hasn't passed yet - if now < sleep_deadline { - tracing::info!( - "node [{}] adaptive_split50 - sleep period: now < sleep_deadline={}", - node_id, - time_format::format_ts(sleep_deadline) - ); - return Ok(0); // defer staking - } - - // Wait if not enough participants and waiting period hasn't expired - if participants_count < min_validators && now < wait_deadline { - tracing::info!( - "node [{}] adaptive_split50 - waiting for participants: ({}/{}), deadline={}", - node_id, - participants_count, - min_validators, - time_format::format_ts(wait_deadline) - ); - return Ok(0); // defer staking - } - } // else election_duration > 0 + if !adaptive_strategy::is_adaptive_split50_ready( + node_id, + node.stake_accepted, + configs.elections_info, + configs.cfg15.elections_start_before, + configs.cfg15.elections_end_before, + configs.cfg16, + ctx.adaptive_sleep_pct, + ctx.adaptive_waiting_pct, + ) { + return Ok(0); } let current_stake = if node.stake_accepted { elections_stake } else { 0 }; - Self::calc_adaptive_stake( + adaptive_strategy::calc_adaptive_stake( node_id, total_balance, pool_free_balance, current_stake, - our_max_factor, - elections_info, - cfg16, - cfg17, - prev_min_eff_stake, + ctx.our_max_factor, + configs.elections_info, + configs.cfg16, + configs.cfg17, + ctx.prev_min_eff_stake, ) } other => other.calculate_stake(min_stake, total_balance), } } - /// Calculate stake for AdaptiveSplit50 policy. - /// - /// Determines min_eff_stake from current emulation and/or past elections, - /// then decides whether to split funds (stake half) or stake min_eff_stake. - /// - /// Note: On subsequent ticks, tops up if min_eff_stake grew above current_stake. - /// If the remainder for the next round would be below min_eff_stake, stakes everything. - fn calc_adaptive_stake( - node_id: &str, - total_balance: u64, - free_balance: u64, - current_stake: u64, - our_max_factor: u32, - elections_info: &ElectionsInfo, - cfg16: &ConfigParam16, - cfg17: &ConfigParam17, - prev_min_eff_stake: Option, - ) -> anyhow::Result { - let min_validators = cfg16.min_validators.as_u16(); - let max_validators = cfg16.max_validators.as_u16(); - let max_stake_factor = cfg17.max_stake_factor; - let cfg17_min_stake = cfg17.min_stake.as_u64().unwrap_or(0); - let cfg17_max_stake = cfg17.max_stake.as_u64().unwrap_or(u64::MAX); - let half = total_balance / 2; - - // Compute curr_min_eff_stake from current participants (if enough). - tracing::info!( - "node [{}] adaptive_split50: emulate elections on {} participants", - node_id, - elections_info.participants.len() - ); - let participants = elections_info - .participants - .iter() - .map(|p| election_emulator::ParticipantStake { - stake: p.stake, - max_factor: p.max_factor, - }) - .collect(); - - let ctx = ElectionContext { - participants, - max_validators, - min_validators, - global_max_factor: max_stake_factor, - min_stake: cfg17_min_stake, - max_stake: cfg17_max_stake, - }; - - // If we already have elections stake, don't add our stake to the emulation, - // because it's already in the participants list. - let emulated_stake = if current_stake > 0 { 0 } else { half }; - // Only trust emulation if there are real participants (not just our own stake). - let has_real_participants = !elections_info.participants.is_empty(); - let curr_min_eff = if has_real_participants || current_stake > 0 { - election_emulator::emulate_election(&ctx, emulated_stake, our_max_factor) - .map(|r| r.effective_min_stake) - } else { - None - }; - - // Step 3.1: Choose the smallest min_eff_stake from curr and prev. - let min_eff_stake = match (curr_min_eff, prev_min_eff_stake) { - (Some(curr), Some(prev)) => { - tracing::info!( - "node [{}] adaptive_split50: curr_min_eff={} TON, prev_min_eff={} TON, using min={} TON", - node_id, - nanotons_to_tons_f64(curr), - nanotons_to_tons_f64(prev), - nanotons_to_tons_f64(curr.min(prev)) - ); - curr.min(prev) - } - (Some(curr), None) => { - tracing::info!( - "node [{}] adaptive_split50: curr_min_eff={} TON (no past elections data)", - node_id, - nanotons_to_tons_f64(curr) - ); - curr - } - (None, Some(prev)) => { - tracing::info!( - "node [{}] adaptive_split50: prev_min_eff={} TON (not enough current participants < {})", - node_id, - nanotons_to_tons_f64(prev), - min_validators - ); - prev - } - (None, None) => { - anyhow::bail!( - "node [{}] adaptive_split50: cannot determine min effective stake \ - (not enough participants and no past elections)", - node_id - ); - } - }; - - // If we already have enough stake, no need to top-up. - if current_stake >= min_eff_stake { - tracing::debug!( - "node [{}] adaptive_split50: stake={} TON >= min_eff={} TON, no top-up needed", - node_id, - nanotons_to_tons_f64(current_stake), - nanotons_to_tons_f64(min_eff_stake) - ); - return Ok(0); - } - - // Insufficient funds guard — if the pool doesn't have enough free - // funds to cover min_eff_stake, skip the election entirely. - // On the initial submission (current_stake == 0) we need at least min_eff_stake - // free; on top-ups we need at least the delta. - let required = min_eff_stake.saturating_sub(current_stake); - if free_balance < required { - tracing::error!( - "node [{}] adaptive_split50: insufficient funds free_balance={} TON < required={} TON (min_eff={} TON), skipping election", - node_id, - nanotons_to_tons_f64(free_balance), - nanotons_to_tons_f64(required), - nanotons_to_tons_f64(min_eff_stake), - ); - return Ok(0); - } - - // Decide between staking half or min_eff_stake. - if half >= min_eff_stake { - // Step 3.4: half is enough — stake half. - let stake = half.saturating_sub(current_stake); - tracing::info!( - "node [{}] adaptive_split50 - stake half: current_stake={} TON, left_to_stake={} TON, half={} TON >= min_eff={} TON", - node_id, - nanotons_to_tons_f64(current_stake), - nanotons_to_tons_f64(stake), - nanotons_to_tons_f64(half), - nanotons_to_tons_f64(min_eff_stake), - ); - if stake > free_balance { - // Not enough free funds to stake half. Skip and let the operator top up. - tracing::error!( - "node [{}] adaptive_split50 - insufficient free balance: need {} TON to stake half, \ - but only {} TON available. Consider topping up the pool.", - node_id, - nanotons_to_tons_f64(stake), - nanotons_to_tons_f64(free_balance), - ); - return Ok(0); - } - Ok(stake) - } else { - // half < min_eff — splitting is not viable. - // Since half < min_eff, it follows that total < 2 * min_eff, - // so the remainder after staking min_eff would also be < min_eff. - // The next round won't have enough funds anyway — stake everything. - tracing::info!( - "node [{}] adaptive_split50 - stake all: half={} TON < min_eff={} TON, staking all free_balance={} TON", - node_id, - nanotons_to_tons_f64(half), - nanotons_to_tons_f64(min_eff_stake), - nanotons_to_tons_f64(free_balance), - ); - Ok(free_balance) - } - } - fn build_participants_snapshot( elections_info: &ElectionsInfo, wallet_addrs: &HashSet>, diff --git a/src/node-control/elections/src/runner_tests.rs b/src/node-control/elections/src/runner_tests.rs index 85701f9..634d49b 100644 --- a/src/node-control/elections/src/runner_tests.rs +++ b/src/node-control/elections/src/runner_tests.rs @@ -7,6 +7,7 @@ * This software is provided "AS IS", WITHOUT WARRANTY OF ANY KIND. */ use super::*; +use crate::adaptive_strategy::calc_adaptive_stake; use common::{ app_config::{ElectionsConfig, NodeBinding, StakePolicy}, snapshot::SnapshotStore, @@ -1980,7 +1981,7 @@ fn test_adaptive_stake_half_when_above_min_eff() { let elections_info = elections_info_with_participants(50, 300_000 * NANO); - let result = ElectionRunner::calc_adaptive_stake( + let result = calc_adaptive_stake( "node-1", total_balance, free_balance, @@ -2034,7 +2035,7 @@ fn test_adaptive_stake_all_when_half_below_min_eff() { participants: stakes, }; - let result = ElectionRunner::calc_adaptive_stake( + let result = calc_adaptive_stake( "node-1", total_balance, free_balance, @@ -2063,7 +2064,7 @@ fn test_adaptive_no_topup_when_stake_sufficient() { let elections_info = elections_info_with_participants(50, 300_000 * NANO); - let result = ElectionRunner::calc_adaptive_stake( + let result = calc_adaptive_stake( "node-1", total_balance, free_balance, @@ -2093,7 +2094,7 @@ fn test_adaptive_skip_when_insufficient_funds() { let elections_info = elections_info_with_participants(50, 300_000 * NANO); - let result = ElectionRunner::calc_adaptive_stake( + let result = calc_adaptive_stake( "node-1", total_balance, free_balance, @@ -2126,7 +2127,7 @@ fn test_adaptive_skip_when_half_exceeds_free_balance() { let elections_info = elections_info_with_participants(5, 300_000 * NANO); - let result = ElectionRunner::calc_adaptive_stake( + let result = calc_adaptive_stake( "node-1", total_balance, free_balance, @@ -2156,7 +2157,7 @@ fn test_adaptive_uses_min_of_curr_and_prev() { let elections_info = elections_info_with_participants(50, 300_000 * NANO); - let result = ElectionRunner::calc_adaptive_stake( + let result = calc_adaptive_stake( "node-1", total_balance, free_balance, @@ -2187,7 +2188,7 @@ fn test_adaptive_fallback_to_prev_when_not_enough_participants() { let elections_info = elections_info_with_participants(5, 300_000 * NANO); - let result = ElectionRunner::calc_adaptive_stake( + let result = calc_adaptive_stake( "node-1", total_balance, free_balance, @@ -2215,7 +2216,7 @@ fn test_adaptive_error_when_no_min_eff_available() { let elections_info = elections_info_with_participants(5, 300_000 * NANO); - let result = ElectionRunner::calc_adaptive_stake( + let result = calc_adaptive_stake( "node-1", total_balance, free_balance, @@ -2247,7 +2248,7 @@ fn test_adaptive_topup_to_half() { // With < min_validators participants, emulation returns None → uses prev_min_eff. let elections_info = elections_info_with_participants(5, 300_000 * NANO); - let result = ElectionRunner::calc_adaptive_stake( + let result = calc_adaptive_stake( "node-1", total_balance, free_balance, From 5ba952a2c089ba24177717c846c5c4f16175e2ec Mon Sep 17 00:00:00 2001 From: NikitaM <21960067+Keshoid@users.noreply.github.com> Date: Tue, 31 Mar 2026 11:03:11 +0300 Subject: [PATCH 04/11] feat(elections): always use curr eff stake instead of min(curr, prev) - split runner & strategy tests --- .../elections/src/adaptive_strategy.rs | 406 +++++++++++++++++- .../elections/src/runner_tests.rs | 332 -------------- 2 files changed, 395 insertions(+), 343 deletions(-) diff --git a/src/node-control/elections/src/adaptive_strategy.rs b/src/node-control/elections/src/adaptive_strategy.rs index c1c8f3b..3396b0c 100644 --- a/src/node-control/elections/src/adaptive_strategy.rs +++ b/src/node-control/elections/src/adaptive_strategy.rs @@ -18,7 +18,6 @@ use ton_block::config_params::{ConfigParam16, ConfigParam17}; /// Returns `true` if staking should proceed, `false` if we should defer (return 0). pub(crate) fn is_adaptive_split50_ready( node_id: &str, - stake_accepted: bool, elections_info: &ElectionsInfo, cfg15_start_before: u32, cfg15_end_before: u32, @@ -26,9 +25,6 @@ pub(crate) fn is_adaptive_split50_ready( adaptive_sleep_pct: f64, adaptive_waiting_pct: f64, ) -> bool { - if stake_accepted { - return true; - } let min_validators = cfg16.min_validators.as_u16() as usize; let participants_count = elections_info.participants.len(); @@ -88,7 +84,7 @@ pub(crate) fn calc_adaptive_stake( elections_info: &ElectionsInfo, cfg16: &ConfigParam16, cfg17: &ConfigParam17, - prev_min_eff_stake: Option, + prev_min_stake: Option, ) -> anyhow::Result { let min_validators = cfg16.min_validators.as_u16(); let max_validators = cfg16.max_validators.as_u16(); @@ -120,17 +116,16 @@ pub(crate) fn calc_adaptive_stake( let curr_min_eff = election_emulator::compute_min_effective_stake(&ctx, our_max_factor); - // Choose the smallest min_eff_stake from curr and prev. - let min_eff_stake = match (curr_min_eff, prev_min_eff_stake) { + // Calculate estimated curr effective stake. If failed to calculate, use previous elections min_stake. + let min_eff_stake = match (curr_min_eff, prev_min_stake) { (Some(curr), Some(prev)) => { tracing::info!( - "node [{}] adaptive_split50: curr_min_eff={} TON, prev_min_eff={} TON, using min={} TON", + "node [{}] adaptive_split50: curr_min_eff={} TON, prev_min={} TON", node_id, nanotons_to_tons_f64(curr), nanotons_to_tons_f64(prev), - nanotons_to_tons_f64(curr.min(prev)) ); - curr.min(prev) + curr } (Some(curr), None) => { tracing::info!( @@ -142,7 +137,7 @@ pub(crate) fn calc_adaptive_stake( } (None, Some(prev)) => { tracing::info!( - "node [{}] adaptive_split50: prev_min_eff={} TON (not enough current participants < {})", + "node [{}] adaptive_split50: prev_min={} TON (not enough current participants < {})", node_id, nanotons_to_tons_f64(prev), min_validators @@ -224,3 +219,392 @@ pub(crate) fn calc_adaptive_stake( Ok(free_balance) } } + +#[cfg(test)] +mod tests { + use super::*; + use contracts::{ElectionsInfo, Participant}; + use ton_block::{ + Coins, Number16, + config_params::{ConfigParam16, ConfigParam17}, + }; + + const NANO: u64 = 1_000_000_000; + const FACTOR_3X: u32 = 3 * 65536; + const ELECTION_ID: u64 = 1_700_000_000; + const MIN_STAKE: u64 = 10_000_000_000_000; // 10_000 TON + + fn default_cfg16() -> ConfigParam16 { + ConfigParam16 { + max_validators: Number16::from(400u16), + max_main_validators: Number16::from(100u16), + min_validators: Number16::from(13u16), + } + } + + fn default_cfg17() -> ConfigParam17 { + ConfigParam17 { + min_stake: Coins::from(10_000_000_000_000u64), // 10,000 TON + max_stake: Coins::from(10_000_000_000_000_000u64), // 10,000,000 TON + min_total_stake: Coins::from(100_000_000_000_000u64), // 100,000 TON + max_stake_factor: 3 * 65536, // 3x + } + } + + /// Build an ElectionsInfo with `n` participants each staking `stake_per` nanotons. + fn elections_info_with_participants(n: usize, stake_per: u64) -> ElectionsInfo { + let participants = (0..n) + .map(|i| { + let mut pubkey = [0u8; 32]; + pubkey[0] = i as u8; + pubkey[1] = (i >> 8) as u8; + Participant { + pub_key: pubkey.to_vec(), + adnl_addr: [0xEE; 32].to_vec(), + wallet_addr: pubkey.to_vec(), + stake: stake_per, + max_factor: FACTOR_3X, + election_id: ELECTION_ID, + stake_message_boc: None, + } + }) + .collect(); + ElectionsInfo { + election_id: ELECTION_ID, + elect_close: ELECTION_ID + 600, + min_stake: MIN_STAKE, + total_stake: n as u64 * stake_per, + failed: false, + finished: false, + participants, + } + } + + // ---- half >= min_eff → stake half ---- + + #[test] + fn test_adaptive_stake_half_when_above_min_eff() { + // 50 participants with 300k TON each, max_validators=400 (set NOT full). + // effective_min is ~100k TON (300k / factor 3). + // total_balance = 1_300_000 TON, half = 650_000 TON >> effective_min. + // Expected: stake half = 650_000 TON. + let total_balance = 1_300_000 * NANO; + let free_balance = total_balance; // no frozen, no current + let current_stake = 0; + + let elections_info = elections_info_with_participants(50, 300_000 * NANO); + + let result = calc_adaptive_stake( + "node-1", + total_balance, + free_balance, + current_stake, + FACTOR_3X, + &elections_info, + &default_cfg16(), + &default_cfg17(), + None, + ) + .unwrap(); + + let half = total_balance / 2; + assert_eq!(result, half, "should stake half"); + } + + // ---- half < min_eff → stake all ---- + + #[test] + fn test_adaptive_stake_all_when_half_below_min_eff() { + // 400 participants with ~700k TON each (set FULL, max_validators=400). + // effective_min ~700k TON. Our total = 1_300_000 TON, half = 650_000 < 700_000. + // Expected: stake all free_balance. + let total_balance = 1_300_000 * NANO; + let free_balance = total_balance; + let current_stake = 0; + + let stakes: Vec = (0..400) + .map(|i| { + let mut pubkey = [0u8; 32]; + pubkey[0] = i as u8; + pubkey[1] = (i >> 8) as u8; + Participant { + pub_key: pubkey.to_vec(), + adnl_addr: [0xEE; 32].to_vec(), + wallet_addr: pubkey.to_vec(), + stake: (700_000 + i as u64 * 100) * NANO, + max_factor: FACTOR_3X, + election_id: ELECTION_ID, + stake_message_boc: None, + } + }) + .collect(); + let elections_info = ElectionsInfo { + election_id: ELECTION_ID, + elect_close: ELECTION_ID + 600, + min_stake: MIN_STAKE, + total_stake: stakes.iter().map(|p| p.stake).sum(), + failed: false, + finished: false, + participants: stakes, + }; + + let result = calc_adaptive_stake( + "node-1", + total_balance, + free_balance, + current_stake, + FACTOR_3X, + &elections_info, + &default_cfg16(), + &default_cfg17(), + None, + ) + .unwrap(); + + assert_eq!(result, free_balance, "should stake all free_balance when half < min_eff"); + } + + // ---- current_stake >= min_eff → no top-up ---- + + #[test] + fn test_adaptive_no_topup_when_stake_sufficient() { + // 50 participants with 300k TON. effective_min ~100k. + // current_stake = 650_000 TON >> effective_min. + // Expected: return 0 (no top-up). + let total_balance = 1_300_000 * NANO; + let free_balance = 0; // all staked or frozen + let current_stake = 650_000 * NANO; + + let elections_info = elections_info_with_participants(50, 300_000 * NANO); + + let result = calc_adaptive_stake( + "node-1", + total_balance, + free_balance, + current_stake, + FACTOR_3X, + &elections_info, + &default_cfg16(), + &default_cfg17(), + None, + ) + .unwrap(); + + assert_eq!(result, 0, "should return 0 when current_stake >= min_eff"); + } + + // ---- insufficient funds guard ---- + + #[test] + fn test_adaptive_skip_when_insufficient_funds() { + // 50 participants with 300k TON. effective_min ~100k. + // total_balance is high (due to frozen), but free_balance < required. + // Expected: return 0 (skip). + let frozen = 900_000 * NANO; + let free_balance = 50_000 * NANO; // less than effective_min (~100k) + let current_stake = 0; + let total_balance = frozen + free_balance + current_stake; + + let elections_info = elections_info_with_participants(50, 300_000 * NANO); + + let result = calc_adaptive_stake( + "node-1", + total_balance, + free_balance, + current_stake, + FACTOR_3X, + &elections_info, + &default_cfg16(), + &default_cfg17(), + None, + ) + .unwrap(); + + assert_eq!(result, 0, "should skip when free_balance < min_eff_stake"); + } + + // ---- cap to free_balance when half > free_balance ---- + + #[test] + fn test_adaptive_skip_when_half_exceeds_free_balance() { + // Use few participants (< min_validators) so emulation returns None. + // prev_min_eff = 50k controls the effective_min. + // total = 1_300_000, half = 650_000 > 50k → half branch. + // free_balance = 200_000 < half(650k) → skip (not enough to stake half). + // free_balance (200k) > prev_min_eff (50k) → passes insufficient funds guard. + let frozen = 1_100_000 * NANO; + let free_balance = 200_000 * NANO; + let current_stake = 0; + let total_balance = frozen + free_balance + current_stake; + let prev_min_eff = Some(50_000 * NANO); + + let elections_info = elections_info_with_participants(5, 300_000 * NANO); + + let result = calc_adaptive_stake( + "node-1", + total_balance, + free_balance, + current_stake, + FACTOR_3X, + &elections_info, + &default_cfg16(), + &default_cfg17(), + prev_min_eff, + ) + .unwrap(); + + assert_eq!(result, 0, "should skip when free_balance < half (operator should top up pool)"); + } + + // ---- curr vs prev selection ---- + + #[test] + fn test_adaptive_uses_curr_when_both_available() { + // 50 participants with 300k TON. curr_min_eff ~99.4k TON (≈ 300k / factor 3). + // prev_min_eff = 80k < curr → should use curr (~99.4k), not prev. + // total = 200k, half = 100k >= curr (~99.4k) → stake half = 100k. + let total_balance = 200_000 * NANO; + let free_balance = total_balance; + let current_stake = 0; + let prev_min_eff = Some(80_000 * NANO); + + let elections_info = elections_info_with_participants(50, 300_000 * NANO); + + let result = calc_adaptive_stake( + "node-1", + total_balance, + free_balance, + current_stake, + FACTOR_3X, + &elections_info, + &default_cfg16(), + &default_cfg17(), + prev_min_eff, + ) + .unwrap(); + + let half = total_balance / 2; + assert_eq!(result, half, "should use curr_min_eff and stake half"); + } + + #[test] + fn test_adaptive_ignores_prev_when_curr_available() { + // 50 participants with 300k TON. curr_min_eff ~99.4k TON. + // prev_min_eff = 80k < curr. total = 190k, half = 95k. + // half (95k) is between prev (80k) and curr (~99.4k): + // - old behavior (min of curr and prev): min_eff = 80k → half ≥ 80k → stake half (95k) + // - new behavior (use curr): min_eff = curr (~99.4k) → half < curr → stake all (190k) + let total_balance = 190_000 * NANO; + let free_balance = total_balance; + let current_stake = 0; + let prev_min_eff = Some(80_000 * NANO); + + let elections_info = elections_info_with_participants(50, 300_000 * NANO); + + let result = calc_adaptive_stake( + "node-1", + total_balance, + free_balance, + current_stake, + FACTOR_3X, + &elections_info, + &default_cfg16(), + &default_cfg17(), + prev_min_eff, + ) + .unwrap(); + + assert_eq!(result, free_balance, "should use curr_min_eff (not prev) and stake all when half < curr"); + } + + // ---- prev only (curr = None, fewer than min_validators) ---- + + #[test] + fn test_adaptive_fallback_to_prev_when_not_enough_participants() { + // Only 5 participants (< min_validators=13) → emulation returns None. + // prev_min_eff = 50k. + // total = 200k, half = 100k >= 50k → stake half. + let total_balance = 200_000 * NANO; + let free_balance = total_balance; + let current_stake = 0; + let prev_min_eff = Some(50_000 * NANO); + + let elections_info = elections_info_with_participants(5, 300_000 * NANO); + + let result = calc_adaptive_stake( + "node-1", + total_balance, + free_balance, + current_stake, + FACTOR_3X, + &elections_info, + &default_cfg16(), + &default_cfg17(), + prev_min_eff, + ) + .unwrap(); + + let half = total_balance / 2; + assert_eq!(result, half, "should fallback to prev_min_eff when not enough participants"); + } + + // ---- both None → error ---- + + #[test] + fn test_adaptive_error_when_no_min_eff_available() { + // Fewer than min_validators (5 < 13) AND no prev_min_eff → error. + let total_balance = 200_000 * NANO; + let free_balance = total_balance; + let current_stake = 0; + + let elections_info = elections_info_with_participants(5, 300_000 * NANO); + + let result = calc_adaptive_stake( + "node-1", + total_balance, + free_balance, + current_stake, + FACTOR_3X, + &elections_info, + &default_cfg16(), + &default_cfg17(), + None, + ); + + assert!(result.is_err(), "should fail when both curr and prev min_eff are unavailable"); + } + + // ---- top-up: half branch, partial top-up ---- + + #[test] + fn test_adaptive_topup_to_half() { + // Use few participants so emulation returns None; prev_min_eff controls effective. + // prev_min_eff = 600k. current_stake = 500k < 600k → need top-up. + // total = 1_300_000, half = 650_000 > 600k → half branch. + // stake = half - current = 650k - 500k = 150k. + let total_balance = 1_300_000 * NANO; + let free_balance = 200_000 * NANO; + let current_stake = 500_000 * NANO; + let prev_min_eff = Some(600_000 * NANO); + + // current_stake > 0 → emulation uses our_stake = 0 (already in list). + // With < min_validators participants, emulation returns None → uses prev_min_eff. + let elections_info = elections_info_with_participants(5, 300_000 * NANO); + + let result = calc_adaptive_stake( + "node-1", + total_balance, + free_balance, + current_stake, + FACTOR_3X, + &elections_info, + &default_cfg16(), + &default_cfg17(), + prev_min_eff, + ) + .unwrap(); + + let expected = total_balance / 2 - current_stake; + assert_eq!(result, expected, "should top up to half"); + } +} diff --git a/src/node-control/elections/src/runner_tests.rs b/src/node-control/elections/src/runner_tests.rs index 634d49b..7508bd5 100644 --- a/src/node-control/elections/src/runner_tests.rs +++ b/src/node-control/elections/src/runner_tests.rs @@ -7,7 +7,6 @@ * This software is provided "AS IS", WITHOUT WARRANTY OF ANY KIND. */ use super::*; -use crate::adaptive_strategy::calc_adaptive_stake; use common::{ app_config::{ElectionsConfig, NodeBinding, StakePolicy}, snapshot::SnapshotStore, @@ -1931,340 +1930,9 @@ fn test_compute_status_idle_when_enabled_no_recover_no_participant() { assert_eq!(status, BindingStatus::Idle); } -// ===================================================== -// AdaptiveSplit50: calc_adaptive_stake unit tests -// ===================================================== - const NANO: u64 = 1_000_000_000; const FACTOR_3X: u32 = 3 * 65536; -/// Build an ElectionsInfo with `n` participants each staking `stake_per` nanotons. -fn elections_info_with_participants(n: usize, stake_per: u64) -> ElectionsInfo { - let participants = (0..n) - .map(|i| { - let mut pubkey = [0u8; 32]; - pubkey[0] = i as u8; - pubkey[1] = (i >> 8) as u8; - Participant { - pub_key: pubkey.to_vec(), - adnl_addr: [0xEE; 32].to_vec(), - wallet_addr: pubkey.to_vec(), - stake: stake_per, - max_factor: FACTOR_3X, - election_id: ELECTION_ID, - stake_message_boc: None, - } - }) - .collect(); - ElectionsInfo { - election_id: ELECTION_ID, - elect_close: ELECTION_ID + 600, - min_stake: MIN_STAKE, - total_stake: n as u64 * stake_per, - failed: false, - finished: false, - participants, - } -} - -// ---- Step 3.4: half >= min_eff → stake half ---- - -#[test] -fn test_adaptive_stake_half_when_above_min_eff() { - // 50 participants with 300k TON each, max_validators=400 (set NOT full). - // effective_min is ~100k TON (300k / factor 3). - // total_balance = 1_300_000 TON, half = 650_000 TON >> effective_min. - // Expected: stake half = 650_000 TON. - let total_balance = 1_300_000 * NANO; - let free_balance = total_balance; // no frozen, no current - let current_stake = 0; - - let elections_info = elections_info_with_participants(50, 300_000 * NANO); - - let result = calc_adaptive_stake( - "node-1", - total_balance, - free_balance, - current_stake, - FACTOR_3X, - &elections_info, - &default_cfg16(), - &default_cfg17(), - None, - ) - .unwrap(); - - let half = total_balance / 2; - assert_eq!(result, half, "should stake half"); -} - -// ---- Step 3.5: half < min_eff → stake all ---- - -#[test] -fn test_adaptive_stake_all_when_half_below_min_eff() { - // 400 participants with ~700k TON each (set FULL, max_validators=400). - // effective_min ~700k TON. Our total = 1_300_000 TON, half = 650_000 < 700_000. - // Expected: stake all free_balance. - let total_balance = 1_300_000 * NANO; - let free_balance = total_balance; - let current_stake = 0; - - let stakes: Vec = (0..400) - .map(|i| { - let mut pubkey = [0u8; 32]; - pubkey[0] = i as u8; - pubkey[1] = (i >> 8) as u8; - Participant { - pub_key: pubkey.to_vec(), - adnl_addr: [0xEE; 32].to_vec(), - wallet_addr: pubkey.to_vec(), - stake: (700_000 + i as u64 * 100) * NANO, - max_factor: FACTOR_3X, - election_id: ELECTION_ID, - stake_message_boc: None, - } - }) - .collect(); - let elections_info = ElectionsInfo { - election_id: ELECTION_ID, - elect_close: ELECTION_ID + 600, - min_stake: MIN_STAKE, - total_stake: stakes.iter().map(|p| p.stake).sum(), - failed: false, - finished: false, - participants: stakes, - }; - - let result = calc_adaptive_stake( - "node-1", - total_balance, - free_balance, - current_stake, - FACTOR_3X, - &elections_info, - &default_cfg16(), - &default_cfg17(), - None, - ) - .unwrap(); - - assert_eq!(result, free_balance, "should stake all free_balance when half < min_eff"); -} - -// ---- Step 4.1: current_stake >= min_eff → no top-up ---- - -#[test] -fn test_adaptive_no_topup_when_stake_sufficient() { - // 50 participants with 300k TON. effective_min ~100k. - // current_stake = 650_000 TON >> effective_min. - // Expected: return 0 (no top-up). - let total_balance = 1_300_000 * NANO; - let free_balance = 0; // all staked or frozen - let current_stake = 650_000 * NANO; - - let elections_info = elections_info_with_participants(50, 300_000 * NANO); - - let result = calc_adaptive_stake( - "node-1", - total_balance, - free_balance, - current_stake, - FACTOR_3X, - &elections_info, - &default_cfg16(), - &default_cfg17(), - None, - ) - .unwrap(); - - assert_eq!(result, 0, "should return 0 when current_stake >= min_eff"); -} - -// ---- Step 3.5: insufficient funds guard ---- - -#[test] -fn test_adaptive_skip_when_insufficient_funds() { - // 50 participants with 300k TON. effective_min ~100k. - // total_balance is high (due to frozen), but free_balance < required. - // Expected: return 0 (skip). - let frozen = 900_000 * NANO; - let free_balance = 50_000 * NANO; // less than effective_min (~100k) - let current_stake = 0; - let total_balance = frozen + free_balance + current_stake; - - let elections_info = elections_info_with_participants(50, 300_000 * NANO); - - let result = calc_adaptive_stake( - "node-1", - total_balance, - free_balance, - current_stake, - FACTOR_3X, - &elections_info, - &default_cfg16(), - &default_cfg17(), - None, - ) - .unwrap(); - - assert_eq!(result, 0, "should skip when free_balance < min_eff_stake"); -} - -// ---- Cap to free_balance when half > free_balance ---- - -#[test] -fn test_adaptive_skip_when_half_exceeds_free_balance() { - // Use few participants (< min_validators) so emulation returns None. - // prev_min_eff = 50k controls the effective_min. - // total = 1_300_000, half = 650_000 > 50k → half branch. - // free_balance = 200_000 < half(650k) → skip (not enough to stake half). - // free_balance (200k) > prev_min_eff (50k) → passes insufficient funds guard. - let frozen = 1_100_000 * NANO; - let free_balance = 200_000 * NANO; - let current_stake = 0; - let total_balance = frozen + free_balance + current_stake; - let prev_min_eff = Some(50_000 * NANO); - - let elections_info = elections_info_with_participants(5, 300_000 * NANO); - - let result = calc_adaptive_stake( - "node-1", - total_balance, - free_balance, - current_stake, - FACTOR_3X, - &elections_info, - &default_cfg16(), - &default_cfg17(), - prev_min_eff, - ) - .unwrap(); - - assert_eq!(result, 0, "should skip when free_balance < half (operator should top up pool)"); -} - -// ---- min(curr, prev) selection ---- - -#[test] -fn test_adaptive_uses_min_of_curr_and_prev() { - // 50 participants with 300k TON. curr_min_eff ~100k. - // prev_min_eff = 80k < curr → should use prev (80k). - // total = 200k, half = 100k >= prev_min_eff (80k) → stake half = 100k. - let total_balance = 200_000 * NANO; - let free_balance = total_balance; - let current_stake = 0; - let prev_min_eff = Some(80_000 * NANO); - - let elections_info = elections_info_with_participants(50, 300_000 * NANO); - - let result = calc_adaptive_stake( - "node-1", - total_balance, - free_balance, - current_stake, - FACTOR_3X, - &elections_info, - &default_cfg16(), - &default_cfg17(), - prev_min_eff, - ) - .unwrap(); - - let half = total_balance / 2; - assert_eq!(result, half, "should use min(curr, prev) and stake half"); -} - -// ---- prev only (curr = None, fewer than min_validators) ---- - -#[test] -fn test_adaptive_fallback_to_prev_when_not_enough_participants() { - // Only 5 participants (< min_validators=13) → emulation returns None. - // prev_min_eff = 50k. - // total = 200k, half = 100k >= 50k → stake half. - let total_balance = 200_000 * NANO; - let free_balance = total_balance; - let current_stake = 0; - let prev_min_eff = Some(50_000 * NANO); - - let elections_info = elections_info_with_participants(5, 300_000 * NANO); - - let result = calc_adaptive_stake( - "node-1", - total_balance, - free_balance, - current_stake, - FACTOR_3X, - &elections_info, - &default_cfg16(), - &default_cfg17(), - prev_min_eff, - ) - .unwrap(); - - let half = total_balance / 2; - assert_eq!(result, half, "should fallback to prev_min_eff when not enough participants"); -} - -// ---- Both None → error ---- - -#[test] -fn test_adaptive_error_when_no_min_eff_available() { - // Fewer than min_validators (5 < 13) AND no prev_min_eff → error. - let total_balance = 200_000 * NANO; - let free_balance = total_balance; - let current_stake = 0; - - let elections_info = elections_info_with_participants(5, 300_000 * NANO); - - let result = calc_adaptive_stake( - "node-1", - total_balance, - free_balance, - current_stake, - FACTOR_3X, - &elections_info, - &default_cfg16(), - &default_cfg17(), - None, - ); - - assert!(result.is_err(), "should fail when both curr and prev min_eff are unavailable"); -} - -// ---- Top-up: half branch, partial top-up ---- - -#[test] -fn test_adaptive_topup_to_half() { - // Use few participants so emulation returns None; prev_min_eff controls effective. - // prev_min_eff = 600k. current_stake = 500k < 600k → need top-up. - // total = 1_300_000, half = 650_000 > 600k → half branch. - // stake = half - current = 650k - 500k = 150k. - let total_balance = 1_300_000 * NANO; - let free_balance = 200_000 * NANO; - let current_stake = 500_000 * NANO; - let prev_min_eff = Some(600_000 * NANO); - - // current_stake > 0 → emulation uses our_stake = 0 (already in list). - // With < min_validators participants, emulation returns None → uses prev_min_eff. - let elections_info = elections_info_with_participants(5, 300_000 * NANO); - - let result = calc_adaptive_stake( - "node-1", - total_balance, - free_balance, - current_stake, - FACTOR_3X, - &elections_info, - &default_cfg16(), - &default_cfg17(), - prev_min_eff, - ) - .unwrap(); - - let expected = total_balance / 2 - current_stake; - assert_eq!(result, expected, "should top up to half"); -} - // ===================================================== // AdaptiveSplit50: wait/sleep integration tests // ===================================================== From 32849f268e7461984dd4ea84d4770410c0a921c7 Mon Sep 17 00:00:00 2001 From: NikitaM <21960067+Keshoid@users.noreply.github.com> Date: Tue, 31 Mar 2026 11:04:13 +0300 Subject: [PATCH 05/11] refactor(elections): move common code to the beginning of `participate` func --- src/node-control/elections/src/runner.rs | 79 ++++++++++-------------- 1 file changed, 33 insertions(+), 46 deletions(-) diff --git a/src/node-control/elections/src/runner.rs b/src/node-control/elections/src/runner.rs index f6fcd10..5db05d2 100644 --- a/src/node-control/elections/src/runner.rs +++ b/src/node-control/elections/src/runner.rs @@ -20,7 +20,7 @@ use common::{ }, task_cancellation::CancellationCtx, time_format, - ton_utils::{nanotons_to_dec_string, nanotons_to_tons_f64}, + ton_utils::{display_tons, nanotons_to_dec_string, nanotons_to_tons_f64}, }; use contracts::{ ElectionsInfo, ElectorWrapper, NominatorWrapper, Participant, TonWallet, @@ -51,7 +51,7 @@ const NPOOL_COMPUTE_FEE: u64 = 200_000_000; /// Gas fee consumed by validator wallet const WALLET_COMPUTE_FEE: u64 = 200_000_000; /// Reserved minimum balance on the wallet (or pool) balance for stake calculations -const MIN_NANOTON_FOR_STORAGE: u64 = 1_000_000_000; +const MIN_NANOTON_FOR_STORAGE: u64 = 1_100_000_000; type OnStatusChange = Arc) + Send + Sync>; @@ -562,10 +562,38 @@ impl ElectionRunner { .find(|p| p.pub_key == entry.public_key) .cloned() }); + + // Refresh participant if missing or stale (different election cycle) + let needs_refresh = match node.participant.as_ref() { + Some(existing) => existing.election_id != election_id, + None => true, + }; + if needs_refresh { + // Reset participation-related state for the new election cycle + node.stake_accepted = false; + node.accepted_stake_amount = None; + node.submission_time = None; + node.stake_submissions.clear(); + node.participant = participant.clone(); + node.key_id = validator_key.as_ref().map(|entry| entry.key_id.clone()).unwrap_or_default(); + } // If the elector already has our stake, mark it accepted early - // so that calc_stake uses the correct current_stake (not 0). - if participant.is_some() { + // so that `calc_stake` uses the correct current_stake (not 0). + if let Some(participant) = participant.as_ref() { + tracing::info!( + "node [{}] stake found in elector: stake={} TON, sender_addr=-1:{}, pubkey={}, adnl={}, election_id={}", + node_id, + display_tons(participant.stake), + hex::encode(&participant.wallet_addr), + hex::encode(participant.pub_key.as_slice()), + base64::Engine::encode( + &base64::engine::general_purpose::STANDARD, + participant.adnl_addr.as_slice(), + ), + participant.election_id + ); node.stake_accepted = true; + node.accepted_stake_amount = Some(participant.stake); } let elections_stake = participant.as_ref().map(|p| p.stake).unwrap_or(0); let stake = Self::calc_stake(node, node_id, elections_stake, params, &stake_ctx) @@ -587,7 +615,6 @@ impl ElectionRunner { match validator_key { None => { - node.reset_participation(); tracing::warn!( "node [{}] validator key not found: election_id={}", node_id, @@ -648,22 +675,7 @@ impl ElectionRunner { hex::encode(entry.public_key.as_slice()) ); match participant { - Some(participant) => { - tracing::info!( - "node [{}] stake found in elector: stake={} TON, sender_addr=-1:{}, pubkey={}, adnl={}, election_id={}", - node_id, - participant.stake as f64 / 1_000_000_000.0, - hex::encode(&participant.wallet_addr), - hex::encode(participant.pub_key.as_slice()), - base64::Engine::encode( - &base64::engine::general_purpose::STANDARD, - participant.adnl_addr.as_slice(), - ), - participant.election_id - ); - node.accepted_stake_amount = Some(participant.stake); - node.participant = Some(participant.clone()); - node.stake_accepted = true; + Some(_) => { if matches!(node.stake_policy, StakePolicy::AdaptiveSplit50) && stake > 0 { let old_stake = node.participant.as_ref().map(|p| p.stake).unwrap_or(0); tracing::info!( @@ -679,30 +691,6 @@ impl ElectionRunner { } None => { tracing::warn!("node [{}] stake not found in elector", node_id); - // Refresh participant if missing or stale (different election cycle) - let needs_refresh = match node.participant.as_ref() { - Some(existing) => existing.election_id != election_id, - None => true, - }; - if needs_refresh { - // Reset participation-related state for the new election cycle - node.stake_accepted = false; - node.accepted_stake_amount = None; - node.submission_time = None; - node.stake_submissions.clear(); - node.participant = Some(Participant { - stake_message_boc: None, - adnl_addr: entry - .adnl_addr() - .ok_or_else(|| anyhow::anyhow!("no adnl address"))?, - pub_key: entry.public_key, - election_id, - wallet_addr: node.wallet_addr(), - stake, - max_factor, - }); - node.key_id = entry.key_id; - } if let Some(p) = node.participant.as_mut() { p.stake = stake; } @@ -954,7 +942,6 @@ impl ElectionRunner { StakePolicy::AdaptiveSplit50 => { if !adaptive_strategy::is_adaptive_split50_ready( node_id, - node.stake_accepted, configs.elections_info, configs.cfg15.elections_start_before, configs.cfg15.elections_end_before, From 1d8f0e6a837ac89fc35347241b618dc405c129bb Mon Sep 17 00:00:00 2001 From: NikitaM <21960067+Keshoid@users.noreply.github.com> Date: Tue, 31 Mar 2026 11:11:49 +0300 Subject: [PATCH 06/11] fmt: make fmt --- src/node-control/elections/src/adaptive_strategy.rs | 6 ++++-- src/node-control/elections/src/runner.rs | 3 ++- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/src/node-control/elections/src/adaptive_strategy.rs b/src/node-control/elections/src/adaptive_strategy.rs index 3396b0c..c6daa88 100644 --- a/src/node-control/elections/src/adaptive_strategy.rs +++ b/src/node-control/elections/src/adaptive_strategy.rs @@ -25,7 +25,6 @@ pub(crate) fn is_adaptive_split50_ready( adaptive_sleep_pct: f64, adaptive_waiting_pct: f64, ) -> bool { - let min_validators = cfg16.min_validators.as_u16() as usize; let participants_count = elections_info.participants.len(); let election_duration = cfg15_start_before.saturating_sub(cfg15_end_before) as u64; @@ -514,7 +513,10 @@ mod tests { ) .unwrap(); - assert_eq!(result, free_balance, "should use curr_min_eff (not prev) and stake all when half < curr"); + assert_eq!( + result, free_balance, + "should use curr_min_eff (not prev) and stake all when half < curr" + ); } // ---- prev only (curr = None, fewer than min_validators) ---- diff --git a/src/node-control/elections/src/runner.rs b/src/node-control/elections/src/runner.rs index 5db05d2..d4b1cef 100644 --- a/src/node-control/elections/src/runner.rs +++ b/src/node-control/elections/src/runner.rs @@ -575,7 +575,8 @@ impl ElectionRunner { node.submission_time = None; node.stake_submissions.clear(); node.participant = participant.clone(); - node.key_id = validator_key.as_ref().map(|entry| entry.key_id.clone()).unwrap_or_default(); + node.key_id = + validator_key.as_ref().map(|entry| entry.key_id.clone()).unwrap_or_default(); } // If the elector already has our stake, mark it accepted early // so that `calc_stake` uses the correct current_stake (not 0). From ef73f471e06f4162221ba9112e83d5ba3e967c24 Mon Sep 17 00:00:00 2001 From: NikitaM <21960067+Keshoid@users.noreply.github.com> Date: Tue, 31 Mar 2026 16:56:26 +0300 Subject: [PATCH 07/11] docs: rewrite adaptive staking strategy --- src/node-control/docs/staking-strategies.md | 148 ++++++++------------ 1 file changed, 56 insertions(+), 92 deletions(-) diff --git a/src/node-control/docs/staking-strategies.md b/src/node-control/docs/staking-strategies.md index 2ca1df9..5e045b2 100644 --- a/src/node-control/docs/staking-strategies.md +++ b/src/node-control/docs/staking-strategies.md @@ -4,117 +4,82 @@ ### Overview -AdaptiveSplit50 is a staking strategy designed to maximize capital efficiency across validation rounds. The core idea is simple: split all available funds in half and stake each half into alternating election rounds, so that capital is always working. However, this only makes sense when each half is large enough to be selected by the Elector — otherwise the funds sit idle and earn nothing. - -**Key principle:** If half of the available funds exceeds the minimum effective stake (the threshold below which the Elector will not select a validator), the strategy stakes half per round. If not, it stakes everything into a single round to avoid leaving idle capital. +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. --- -### Definitions +### Key Terms | Term | Description | |---|---| -| **Elector** | The TON smart contract that runs validator elections and selects which stakes participate in validation. | -| **Election round** | A time-bounded period during which validators submit stakes. At the end, the Elector selects the validator set. | -| **min_eff_stake** | The minimum effective stake — the lowest stake that the Elector would accept into the validator set. Stakes below this threshold are not selected and earn no rewards. | -| **frozen_stake** | A stake submitted in a previous election that is currently locked (frozen) for the duration of the active validation round. | -| **free_pool_balance** | Uncommitted funds sitting on the nominator pool balance, available for staking. | -| **current_stake** | The stake already submitted to the current election round (0 if nothing has been submitted yet). | -| **available** | Total capital the strategy can work with: `frozen_stake + free_pool_balance + current_stake`. | -| **half** | Half of the available capital: `available / 2`. | -| **config16** | TON blockchain configuration parameter that defines validator set constraints, including `min_validators` — the minimum number of validators required to form a set. | -| **config15** | TON blockchain configuration parameter that defines election timing, including the total election duration. | -| **sleep_period** | A configurable minimum delay (from the start of elections) before the strategy takes any action. Expressed as a fraction of the total election duration. | -| **waiting_period** | A configurable maximum time the strategy will wait for enough participants to appear before falling back to historical data. Expressed as a fraction of the total election duration. `sleep_period <= waiting_period`. | -| **tick** | One iteration of the strategy's main loop. The strategy runs periodically and re-evaluates its position on each tick. | +| **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`. | --- -### Algorithm - -The algorithm executes in four steps each time a new election round begins. - -#### Step 1 — Estimate min_eff_stake from the current election - -**Goal:** Determine the minimum effective stake for the current election by emulating the Elector's selection algorithm on the participants who have already submitted their stakes. - -1. When a new election starts, the strategy begins monitoring the list of participants who have submitted stakes to the Elector. - -2. The strategy waits until **both** of the following conditions are met: - - At least `config16.min_validators` participants have submitted their stakes (the minimum needed to emulate a meaningful election). - - The `sleep_period` has elapsed since the start of the election. - -3. **Timeout:** If the `waiting_period` elapses and fewer than `min_validators` participants have appeared, the strategy stops waiting and proceeds to Step 2 without a current-election estimate. This prevents the strategy from stalling indefinitely. +### How It Works -4. Once both conditions are satisfied, the strategy emulates an election: it takes the current list of participants, adds its own potential stake (`half = available / 2`) to the list, and runs the Elector's selection algorithm. The result is `curr_min_eff_stake` — the estimated minimum effective stake for this election. +The strategy runs on every tick (periodic check) during an election. -#### Step 2 — Estimate min_eff_stake from the previous election +#### 1. Wait for the right moment -**Goal:** Obtain a baseline minimum effective stake from historical data, independent of the current election's progress. +Before doing anything, the strategy waits until: -1. The strategy calls the Elector's `past_elections` get-method to retrieve the participant map from the most recent completed election. +- The **sleep_period** has passed since the election started, **and** +- At least `min_validators` participants have submitted stakes. -2. It emulates the Elector's selection algorithm on that historical participant list to compute `prev_min_eff_stake`. +If the **waiting_period** expires and there still aren't enough participants, the strategy stops waiting and proceeds with whatever data is available. -3. This value is **cached** so it does not need to be recomputed on every tick (it does not change within a single election round). +#### 2. Estimate min_eff_stake -> **Note:** This step always produces a result, unlike Step 1 which may time out. This ensures the strategy always has at least one min_eff_stake estimate to work with. +The strategy needs to know the minimum stake required to be selected. It uses two sources: -#### Step 3 — Decide the stake amount and submit +- **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. -**Goal:** Choose the optimal stake amount and submit it to the Elector. +**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. -1. **Pick the conservative estimate.** If both `curr_min_eff_stake` (from Step 1) and `prev_min_eff_stake` (from Step 2) are available, the strategy uses the **smaller** of the two. If only `prev_min_eff_stake` is available (Step 1 timed out), it uses that. This conservative approach reduces the risk of submitting a stake that is too low. +#### 3. Decide how much to stake -2. **Calculate available funds:** - ``` - available = frozen_stake + free_pool_balance + current_stake - ``` - -3. **Calculate half:** - ``` - half = available / 2 - ``` - -4. **Submit the stake:** - - If `half >= min_eff_stake` → submit `half` to the Elector. The expectation is that the remaining half will be sufficient for the next round. However, this is **not guaranteed** — the stake distribution may change in the next election, shifting the min_eff_stake up or down. Since the future state is unpredictable, the strategy uses the current estimate as the best available approximation. - - If `half < min_eff_stake` → submit **all available free funds** (`free_pool_balance`) to the Elector. Since `half < min_eff_stake` implies `available < 2 × min_eff_stake`, the remainder after staking any amount would be less than `min_eff_stake` — not enough to participate in the next round. Rather than leaving idle capital that cannot earn rewards, the strategy commits everything to the current round. - -5. **Insufficient funds guard:** Before submitting, the strategy checks whether `free_pool_balance >= min_eff_stake`. If the pool does not have enough free funds to cover the required stake, the strategy **skips the election entirely** and logs an error indicating that the election will be missed due to insufficient funds. No stake is submitted in this case. +``` +available = frozen_stake + free_pool_balance + current_stake +half = available / 2 +``` -#### Step 4 — Continuously adjust the stake +- **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. -**Goal:** After the initial submission, keep monitoring the election and top up the stake if conditions change. +**Guards:** -On every subsequent tick during the election: +- 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. -1. **Re-emulate the election** using the full current participant list to get an updated `min_eff_stake`. +#### 4. Top up on subsequent ticks -2. **Top-up if outbid:** If `min_eff_stake > current_stake`, the strategy sends an additional stake equal to the difference: - ``` - topup = min_eff_stake - current_stake - current_stake += topup - ``` - This ensures the node remains above the selection threshold even as new, larger stakes arrive. +On every tick after the initial submission, the strategy re-evaluates: -3. **Go all-in if next round is unviable:** The strategy checks whether the remaining funds (not staked in this round) would be enough to participate in the next election: - ``` - remaining = (frozen_stake + free_pool_balance) - current_stake - ``` - If `remaining < min_eff_stake`, it means the leftover funds won't be sufficient to enter the next validator set anyway. In this case, the strategy stakes the entire remaining balance into the current election to maximize returns from this round rather than leaving funds idle. +- 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 Parameters +### Configuration | Parameter | Type | Description | |---|---|---| -| `sleep_period` | float (0.0–1.0) | Minimum fraction of the election duration to wait before acting, even if enough participants are present. | -| `waiting_period` | float (0.0–1.0) | Maximum fraction of the election duration to wait for `min_validators` participants. Must be >= `sleep_period`. | +| `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. | --- -### Summary: Decision Flowchart +### Decision Flowchart ``` Election starts @@ -122,33 +87,32 @@ Election starts ▼ Wait for sleep_period AND min_validators participants │ - ├─ Both met ──► Emulate election → curr_min_eff_stake + ├─ Both met ──► Emulate election → curr_min_eff │ - └─ Timeout ───► curr_min_eff_stake = None + └─ Timeout ───► curr_min_eff = None │ ▼ - Fetch past_elections → prev_min_eff_stake (cached) + Fetch prev_min_stake from past elections (cached) │ ▼ - min_eff_stake = min(curr_min_eff_stake, prev_min_eff_stake) + min_eff_stake = curr_min_eff ?? prev_min_stake + │ + ├─ Neither available ──► Skip election (error) │ ▼ - available = frozen_stake + free_pool_balance + current_stake half = available / 2 │ ├─ half >= min_eff_stake ──► Stake half │ - └─ half < min_eff_stake ──► Stake all (next round unviable anyway) + └─ half < min_eff_stake ──► Stake all │ ▼ - ┌─ On every tick: ──────────────────────────────────┐ - │ │ - │ Re-emulate election → updated min_eff_stake │ - │ │ - │ If min_eff_stake > current_stake: │ - │ top up by (min_eff_stake - current_stake) │ - │ │ - │ If remaining funds < min_eff_stake: │ - │ stake all remaining into current round │ - └────────────────────────────────────────────────────┘ + ┌─ On every tick: ──────────────────────────────┐ + │ │ + │ Re-estimate min_eff_stake │ + │ │ + │ If min_eff_stake > current_stake → top up │ + │ │ + │ Apply same half-vs-all logic │ + └────────────────────────────────────────────────┘ ``` From c4341dd3ed63f0c5a32ed3490d364115b9285615 Mon Sep 17 00:00:00 2001 From: NikitaM <21960067+Keshoid@users.noreply.github.com> Date: Tue, 31 Mar 2026 19:13:18 +0300 Subject: [PATCH 08/11] feat(elections): remove our stake for participants before emulation - added tests --- src/node-control/common/src/app_config.rs | 36 +-- .../elections/src/adaptive_strategy.rs | 123 +++------ src/node-control/elections/src/runner.rs | 39 ++- .../elections/src/runner_tests.rs | 251 +++++++++++------- 4 files changed, 239 insertions(+), 210 deletions(-) diff --git a/src/node-control/common/src/app_config.rs b/src/node-control/common/src/app_config.rs index 260044a..21d38ba 100644 --- a/src/node-control/common/src/app_config.rs +++ b/src/node-control/common/src/app_config.rs @@ -467,8 +467,12 @@ fn default_tick_interval() -> u64 { 40 } -fn default_adaptive_waiting_pct() -> f64 { - 0.3 +fn default_waiting_pct() -> f64 { + 0.4 +} + +fn default_sleep_pct() -> f64 { + 0.2 } #[derive(serde::Serialize, serde::Deserialize, Clone)] @@ -485,14 +489,14 @@ pub struct ElectionsConfig { /// Interval for elections runner in seconds #[serde(default = "default_tick_interval")] pub tick_interval: u64, - /// AdaptiveSplit50: minimum wait time as fraction of election duration (0.0 - 1.0). + /// 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)] - pub adaptive_sleep_period_pct: f64, - /// AdaptiveSplit50: maximum wait time as fraction of election duration (0.0 - 1.0). + #[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_adaptive_waiting_pct")] - pub adaptive_waiting_period_pct: f64, + #[serde(default = "default_waiting_pct")] + pub waiting_period_pct: f64, } impl ElectionsConfig { @@ -506,14 +510,14 @@ 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.adaptive_sleep_period_pct) { - anyhow::bail!("adaptive_sleep_period_pct must be in range [0.0..1.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.adaptive_waiting_period_pct) { - anyhow::bail!("adaptive_waiting_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.adaptive_sleep_period_pct > self.adaptive_waiting_period_pct { - anyhow::bail!("adaptive_sleep_period_pct must be <= adaptive_waiting_period_pct"); + if self.sleep_period_pct > self.waiting_period_pct { + anyhow::bail!("sleep_period_pct must be <= waiting_period_pct"); } Ok(()) } @@ -526,8 +530,8 @@ impl Default for ElectionsConfig { policy_overrides: HashMap::new(), max_factor: default_max_factor(), tick_interval: default_tick_interval(), - adaptive_sleep_period_pct: 0.0, - adaptive_waiting_period_pct: default_adaptive_waiting_pct(), + sleep_period_pct: default_sleep_pct(), + waiting_period_pct: default_waiting_pct(), } } } diff --git a/src/node-control/elections/src/adaptive_strategy.rs b/src/node-control/elections/src/adaptive_strategy.rs index c6daa88..0da0027 100644 --- a/src/node-control/elections/src/adaptive_strategy.rs +++ b/src/node-control/elections/src/adaptive_strategy.rs @@ -7,7 +7,7 @@ * This software is provided "AS IS", WITHOUT WARRANTY OF ANY KIND. */ -use crate::election_emulator::{self, ElectionContext}; +use crate::election_emulator::{self, ElectionContext, ParticipantStake}; use common::ton_utils::nanotons_to_tons_f64; use contracts::ElectionsInfo; use ton_block::config_params::{ConfigParam16, ConfigParam17}; @@ -22,8 +22,8 @@ pub(crate) fn is_adaptive_split50_ready( cfg15_start_before: u32, cfg15_end_before: u32, cfg16: &ConfigParam16, - adaptive_sleep_pct: f64, - adaptive_waiting_pct: f64, + sleep_pct: f64, + waiting_pct: f64, ) -> bool { let min_validators = cfg16.min_validators.as_u16() as usize; let participants_count = elections_info.participants.len(); @@ -38,8 +38,8 @@ pub(crate) fn is_adaptive_split50_ready( } let election_start = elections_info.elect_close.saturating_sub(election_duration); - let sleep_deadline = election_start + (election_duration as f64 * adaptive_sleep_pct) as u64; - let wait_deadline = election_start + (election_duration as f64 * adaptive_waiting_pct) as u64; + let sleep_deadline = election_start + (election_duration as f64 * sleep_pct) as u64; + let wait_deadline = election_start + (election_duration as f64 * waiting_pct) as u64; let now = common::time_format::now(); // Wait if sleep period hasn't passed yet @@ -80,7 +80,7 @@ pub(crate) fn calc_adaptive_stake( free_balance: u64, current_stake: u64, our_max_factor: u32, - elections_info: &ElectionsInfo, + stakes: Vec, cfg16: &ConfigParam16, cfg17: &ConfigParam17, prev_min_stake: Option, @@ -96,16 +96,11 @@ pub(crate) fn calc_adaptive_stake( tracing::info!( "node [{}] adaptive_split50: emulate elections on {} participants", node_id, - elections_info.participants.len() + stakes.len() ); - let participants = elections_info - .participants - .iter() - .map(|p| election_emulator::ParticipantStake { stake: p.stake, max_factor: p.max_factor }) - .collect(); let ctx = ElectionContext { - participants, + participants: stakes, max_validators, min_validators, global_max_factor: max_stake_factor, @@ -136,10 +131,10 @@ pub(crate) fn calc_adaptive_stake( } (None, Some(prev)) => { tracing::info!( - "node [{}] adaptive_split50: prev_min={} TON (not enough current participants < {})", + "node [{}] adaptive_split50: not enough current participants < {}, use prev_min={} TON", node_id, + min_validators, nanotons_to_tons_f64(prev), - min_validators ); prev } @@ -222,7 +217,6 @@ pub(crate) fn calc_adaptive_stake( #[cfg(test)] mod tests { use super::*; - use contracts::{ElectionsInfo, Participant}; use ton_block::{ Coins, Number16, config_params::{ConfigParam16, ConfigParam17}, @@ -230,8 +224,6 @@ mod tests { const NANO: u64 = 1_000_000_000; const FACTOR_3X: u32 = 3 * 65536; - const ELECTION_ID: u64 = 1_700_000_000; - const MIN_STAKE: u64 = 10_000_000_000_000; // 10_000 TON fn default_cfg16() -> ConfigParam16 { ConfigParam16 { @@ -250,33 +242,9 @@ mod tests { } } - /// Build an ElectionsInfo with `n` participants each staking `stake_per` nanotons. - fn elections_info_with_participants(n: usize, stake_per: u64) -> ElectionsInfo { - let participants = (0..n) - .map(|i| { - let mut pubkey = [0u8; 32]; - pubkey[0] = i as u8; - pubkey[1] = (i >> 8) as u8; - Participant { - pub_key: pubkey.to_vec(), - adnl_addr: [0xEE; 32].to_vec(), - wallet_addr: pubkey.to_vec(), - stake: stake_per, - max_factor: FACTOR_3X, - election_id: ELECTION_ID, - stake_message_boc: None, - } - }) - .collect(); - ElectionsInfo { - election_id: ELECTION_ID, - elect_close: ELECTION_ID + 600, - min_stake: MIN_STAKE, - total_stake: n as u64 * stake_per, - failed: false, - finished: false, - participants, - } + /// Build `n` participant stakes each with `stake_per` nanotons. + fn participant_stakes(n: usize, stake_per: u64) -> Vec { + vec![ParticipantStake { stake: stake_per, max_factor: FACTOR_3X }; n] } // ---- half >= min_eff → stake half ---- @@ -291,7 +259,7 @@ mod tests { let free_balance = total_balance; // no frozen, no current let current_stake = 0; - let elections_info = elections_info_with_participants(50, 300_000 * NANO); + let stakes = participant_stakes(50, 300_000 * NANO); let result = calc_adaptive_stake( "node-1", @@ -299,7 +267,7 @@ mod tests { free_balance, current_stake, FACTOR_3X, - &elections_info, + stakes.clone(), &default_cfg16(), &default_cfg17(), None, @@ -321,31 +289,12 @@ mod tests { let free_balance = total_balance; let current_stake = 0; - let stakes: Vec = (0..400) - .map(|i| { - let mut pubkey = [0u8; 32]; - pubkey[0] = i as u8; - pubkey[1] = (i >> 8) as u8; - Participant { - pub_key: pubkey.to_vec(), - adnl_addr: [0xEE; 32].to_vec(), - wallet_addr: pubkey.to_vec(), - stake: (700_000 + i as u64 * 100) * NANO, - max_factor: FACTOR_3X, - election_id: ELECTION_ID, - stake_message_boc: None, - } + let stakes: Vec = (0..400) + .map(|i| ParticipantStake { + stake: (700_000 + i as u64 * 100) * NANO, + max_factor: FACTOR_3X, }) .collect(); - let elections_info = ElectionsInfo { - election_id: ELECTION_ID, - elect_close: ELECTION_ID + 600, - min_stake: MIN_STAKE, - total_stake: stakes.iter().map(|p| p.stake).sum(), - failed: false, - finished: false, - participants: stakes, - }; let result = calc_adaptive_stake( "node-1", @@ -353,7 +302,7 @@ mod tests { free_balance, current_stake, FACTOR_3X, - &elections_info, + stakes, &default_cfg16(), &default_cfg17(), None, @@ -374,7 +323,7 @@ mod tests { let free_balance = 0; // all staked or frozen let current_stake = 650_000 * NANO; - let elections_info = elections_info_with_participants(50, 300_000 * NANO); + let stakes = participant_stakes(50, 300_000 * NANO); let result = calc_adaptive_stake( "node-1", @@ -382,7 +331,7 @@ mod tests { free_balance, current_stake, FACTOR_3X, - &elections_info, + stakes.clone(), &default_cfg16(), &default_cfg17(), None, @@ -404,7 +353,7 @@ mod tests { let current_stake = 0; let total_balance = frozen + free_balance + current_stake; - let elections_info = elections_info_with_participants(50, 300_000 * NANO); + let stakes = participant_stakes(50, 300_000 * NANO); let result = calc_adaptive_stake( "node-1", @@ -412,7 +361,7 @@ mod tests { free_balance, current_stake, FACTOR_3X, - &elections_info, + stakes.clone(), &default_cfg16(), &default_cfg17(), None, @@ -437,7 +386,7 @@ mod tests { let total_balance = frozen + free_balance + current_stake; let prev_min_eff = Some(50_000 * NANO); - let elections_info = elections_info_with_participants(5, 300_000 * NANO); + let stakes = participant_stakes(5, 300_000 * NANO); let result = calc_adaptive_stake( "node-1", @@ -445,7 +394,7 @@ mod tests { free_balance, current_stake, FACTOR_3X, - &elections_info, + stakes.clone(), &default_cfg16(), &default_cfg17(), prev_min_eff, @@ -467,7 +416,7 @@ mod tests { let current_stake = 0; let prev_min_eff = Some(80_000 * NANO); - let elections_info = elections_info_with_participants(50, 300_000 * NANO); + let stakes = participant_stakes(50, 300_000 * NANO); let result = calc_adaptive_stake( "node-1", @@ -475,7 +424,7 @@ mod tests { free_balance, current_stake, FACTOR_3X, - &elections_info, + stakes.clone(), &default_cfg16(), &default_cfg17(), prev_min_eff, @@ -498,7 +447,7 @@ mod tests { let current_stake = 0; let prev_min_eff = Some(80_000 * NANO); - let elections_info = elections_info_with_participants(50, 300_000 * NANO); + let stakes = participant_stakes(50, 300_000 * NANO); let result = calc_adaptive_stake( "node-1", @@ -506,7 +455,7 @@ mod tests { free_balance, current_stake, FACTOR_3X, - &elections_info, + stakes.clone(), &default_cfg16(), &default_cfg17(), prev_min_eff, @@ -531,7 +480,7 @@ mod tests { let current_stake = 0; let prev_min_eff = Some(50_000 * NANO); - let elections_info = elections_info_with_participants(5, 300_000 * NANO); + let stakes = participant_stakes(5, 300_000 * NANO); let result = calc_adaptive_stake( "node-1", @@ -539,7 +488,7 @@ mod tests { free_balance, current_stake, FACTOR_3X, - &elections_info, + stakes.clone(), &default_cfg16(), &default_cfg17(), prev_min_eff, @@ -559,7 +508,7 @@ mod tests { let free_balance = total_balance; let current_stake = 0; - let elections_info = elections_info_with_participants(5, 300_000 * NANO); + let stakes = participant_stakes(5, 300_000 * NANO); let result = calc_adaptive_stake( "node-1", @@ -567,7 +516,7 @@ mod tests { free_balance, current_stake, FACTOR_3X, - &elections_info, + stakes.clone(), &default_cfg16(), &default_cfg17(), None, @@ -591,7 +540,7 @@ mod tests { // current_stake > 0 → emulation uses our_stake = 0 (already in list). // With < min_validators participants, emulation returns None → uses prev_min_eff. - let elections_info = elections_info_with_participants(5, 300_000 * NANO); + let stakes = participant_stakes(5, 300_000 * NANO); let result = calc_adaptive_stake( "node-1", @@ -599,7 +548,7 @@ mod tests { free_balance, current_stake, FACTOR_3X, - &elections_info, + stakes.clone(), &default_cfg16(), &default_cfg17(), prev_min_eff, diff --git a/src/node-control/elections/src/runner.rs b/src/node-control/elections/src/runner.rs index d4b1cef..1cf0756 100644 --- a/src/node-control/elections/src/runner.rs +++ b/src/node-control/elections/src/runner.rs @@ -8,6 +8,7 @@ */ use crate::{ adaptive_strategy, + election_emulator::ParticipantStake, providers::{ElectionsProvider, ValidatorConfig, ValidatorEntry}, }; use anyhow::Context as _; @@ -51,7 +52,7 @@ const NPOOL_COMPUTE_FEE: u64 = 200_000_000; /// Gas fee consumed by validator wallet const WALLET_COMPUTE_FEE: u64 = 200_000_000; /// Reserved minimum balance on the wallet (or pool) balance for stake calculations -const MIN_NANOTON_FOR_STORAGE: u64 = 1_100_000_000; +const MIN_NANOTON_FOR_STORAGE: u64 = 1_050_000_000; type OnStatusChange = Arc) + Send + Sync>; @@ -172,9 +173,9 @@ pub(crate) struct ElectionRunner { // Snapshot cache updated during tick execution and published to SnapshotStore in run_loop(). snapshot_cache: SnapshotCache, /// AdaptiveSplit50: minimum wait fraction of election duration. - adaptive_sleep_pct: f64, + sleep_pct: f64, /// AdaptiveSplit50: maximum wait fraction of election duration. - adaptive_waiting_pct: f64, + waiting_pct: f64, } #[derive(Default)] @@ -225,8 +226,8 @@ struct ConfigParams<'a> { struct StakeContext<'a> { past_elections: &'a [PastElections], our_max_factor: u32, - adaptive_sleep_pct: f64, - adaptive_waiting_pct: f64, + sleep_pct: f64, + waiting_pct: f64, prev_min_eff_stake: Option, } @@ -290,8 +291,8 @@ impl ElectionRunner { past_elections: vec![], past_elections_cache_id: 0, cached_prev_min_eff: None, - adaptive_sleep_pct: elections_config.adaptive_sleep_period_pct, - adaptive_waiting_pct: elections_config.adaptive_waiting_period_pct, + sleep_pct: elections_config.sleep_period_pct, + waiting_pct: elections_config.waiting_period_pct, } } @@ -546,8 +547,8 @@ impl ElectionRunner { let stake_ctx = StakeContext { past_elections: &self.past_elections, our_max_factor: max_factor, - adaptive_sleep_pct: self.adaptive_sleep_pct, - adaptive_waiting_pct: self.adaptive_waiting_pct, + sleep_pct: self.sleep_pct, + waiting_pct: self.waiting_pct, prev_min_eff_stake: self.cached_prev_min_eff, }; let node = self.nodes.get_mut(node_id).expect("node not found"); @@ -947,19 +948,33 @@ impl ElectionRunner { configs.cfg15.elections_start_before, configs.cfg15.elections_end_before, configs.cfg16, - ctx.adaptive_sleep_pct, - ctx.adaptive_waiting_pct, + ctx.sleep_pct, + ctx.waiting_pct, ) { return Ok(0); } let current_stake = if node.stake_accepted { elections_stake } else { 0 }; + let stakes: Vec<_> = configs + .elections_info + .participants + .iter() + .filter(|p| { + p.pub_key.as_slice() + != node + .participant + .as_ref() + .map(|p| p.pub_key.as_slice()) + .unwrap_or_default() + }) + .map(|p| ParticipantStake { stake: p.stake, max_factor: p.max_factor }) + .collect(); adaptive_strategy::calc_adaptive_stake( node_id, total_balance, pool_free_balance, current_stake, ctx.our_max_factor, - configs.elections_info, + stakes, configs.cfg16, configs.cfg17, ctx.prev_min_eff_stake, diff --git a/src/node-control/elections/src/runner_tests.rs b/src/node-control/elections/src/runner_tests.rs index 7508bd5..dc9d59d 100644 --- a/src/node-control/elections/src/runner_tests.rs +++ b/src/node-control/elections/src/runner_tests.rs @@ -358,8 +358,8 @@ impl TestHarness { policy_overrides: HashMap::new(), max_factor: 3.0, tick_interval: 10, - adaptive_sleep_period_pct: 0.0, - adaptive_waiting_period_pct: 0.3, + sleep_period_pct: 0.0, + waiting_period_pct: 0.3, }, bindings: HashMap::new(), } @@ -1190,8 +1190,8 @@ async fn test_multiple_nodes_one_excluded() { policy_overrides: HashMap::new(), max_factor: 3.0, tick_interval: 10, - adaptive_sleep_period_pct: 0.0, - adaptive_waiting_period_pct: 0.3, + sleep_period_pct: 0.0, + waiting_period_pct: 0.3, }; let mut bindings = HashMap::new(); @@ -1628,8 +1628,8 @@ async fn test_node_without_wallet_skipped() { policy_overrides: HashMap::new(), max_factor: 3.0, tick_interval: 10, - adaptive_sleep_period_pct: 0.0, - adaptive_waiting_period_pct: 0.3, + sleep_period_pct: 0.0, + waiting_period_pct: 0.3, }; let mut bindings = HashMap::new(); @@ -1974,8 +1974,8 @@ async fn test_adaptive_wait_for_participants() { let node_id = "node-1"; let mut harness = TestHarness::new(); harness.elections_config.policy = StakePolicy::AdaptiveSplit50; - harness.elections_config.adaptive_sleep_period_pct = 0.0; - harness.elections_config.adaptive_waiting_period_pct = 0.3; + harness.elections_config.sleep_period_pct = 0.0; + harness.elections_config.waiting_period_pct = 0.3; // elect_close far in the future (now + 10_000s) so we're early in the election. let now = common::time_format::now(); @@ -2020,8 +2020,8 @@ async fn test_adaptive_proceed_after_wait_timeout() { let node_id = "node-1"; let mut harness = TestHarness::new(); harness.elections_config.policy = StakePolicy::AdaptiveSplit50; - harness.elections_config.adaptive_sleep_period_pct = 0.0; - harness.elections_config.adaptive_waiting_period_pct = 0.3; + harness.elections_config.sleep_period_pct = 0.0; + harness.elections_config.waiting_period_pct = 0.3; let now = common::time_format::now(); let elect_close = now + 10; // almost closed @@ -2087,8 +2087,8 @@ async fn test_adaptive_sleep_period_delays_even_with_enough_participants() { let node_id = "node-1"; let mut harness = TestHarness::new(); harness.elections_config.policy = StakePolicy::AdaptiveSplit50; - harness.elections_config.adaptive_sleep_period_pct = 0.99; - harness.elections_config.adaptive_waiting_period_pct = 0.99; + harness.elections_config.sleep_period_pct = 0.99; + harness.elections_config.waiting_period_pct = 0.99; let now = common::time_format::now(); let elect_close = now + 10_000; // election just started @@ -2136,85 +2136,137 @@ async fn test_adaptive_sleep_period_delays_even_with_enough_participants() { #[tokio::test] async fn test_adaptive_topup_three_ticks() { - // Tick 1: No participants, prev_min_eff=20k → stakes half (~25k). - // Tick 2: Our stake in elector → stake_accepted=true, re-stakes (current_stake=0 - // because stake_accepted was false at calc_stake time). - // Tick 3: stake_accepted=true from tick 2. prev_min_eff rises to 40k - // which is > our current_stake. Triggers actual top-up with current_stake > 0. + // Full adaptive cycle with election emulation and top-up. + // Uses 13 other participants (= min_validators, boundary value) so emulation works. + // + // Tick 1: 13 participants at 100k TON → emulate → curr_min_eff ≈ 32.5k. + // wallet = 100k, half ≈ 50k >= min_eff → stake half. + // + // Tick 2: Elector sees our stake (~50k) → stake_accepted=true. + // current_stake (~50k) >= min_eff (~32.5k) → no top-up. + // + // Tick 3: One participant raises stake to 240k → curr_min_eff jumps to ≈ 60k. + // Our entry in elector is filtered out before emulation. + // current_stake (~50k) < min_eff (~60k) → sends additional stake to elector. + // remaining ≈ 50k, total ≈ 100k, half ≈ 50k < min_eff → stake all remaining. let node_id = "node-1"; + let wallet_balance = 100_000 * NANO; let mut harness = TestHarness::new(); harness.elections_config.policy = StakePolicy::AdaptiveSplit50; let wallet_addr = addr_bytes(&wallet_address()); let fee = ELECTOR_STAKE_FEE + NPOOL_COMPUTE_FEE; - let pool_free_balance = WALLET_BALANCE - fee - MIN_NANOTON_FOR_STORAGE; - // Tick 1: prev_min_eff = 30k. half = pool_free/2 ≈ 25k < 30k → stake all. - let initial_stake = pool_free_balance; // stake all free_balance + let pool_free_balance = wallet_balance - fee - MIN_NANOTON_FOR_STORAGE; + let initial_stake = pool_free_balance / 2; // stake half on tick 1 + + // --- Helper: build participants with given stakes --- + fn make_participants(stakes: &[(u64, u8)]) -> Vec { + stakes + .iter() + .map(|(stake, id)| { + let mut pubkey = [0u8; 32]; + pubkey[0] = 0x10 + id; // distinct from PUB_KEY + Participant { + pub_key: pubkey.to_vec(), + adnl_addr: vec![0xEE; 32], + wallet_addr: pubkey.to_vec(), + stake: *stake, + max_factor: FACTOR_3X, + election_id: ELECTION_ID, + stake_message_boc: None, + } + }) + .collect() + } - // Tick 2: stake_accepted set early, current_stake = initial_stake. - // total = pool_free + initial, half = total / 2. - // prev_min_eff = 30k (cached from tick 1). - // current_stake (≈50k) >= min_eff (30k) → no top-up. stake unchanged. - let stake_after_tick2 = initial_stake; + // 13 participants at 100k each + let base_stakes: Vec<(u64, u8)> = (0..13u8).map(|i| (100_000 * NANO, i)).collect(); + // Participant #0 raises to 240k on tick 3 + let raised_stakes: Vec<(u64, u8)> = + (0..13u8).map(|i| (if i == 0 { 240_000 * NANO } else { 100_000 * NANO }, i)).collect(); // --- Elector: elections_info varies per tick --- let ei_count = std::sync::Arc::new(std::sync::atomic::AtomicU32::new(0)); let ei_cc = ei_count.clone(); let wallet_addr_clone = wallet_addr.clone(); + let base_stakes_clone = base_stakes.clone(); harness.elector_mock.expect_elections_info().times(3).returning(move || { let n = ei_cc.fetch_add(1, std::sync::atomic::Ordering::SeqCst); - if n == 0 { - // Tick 1: no participants - Ok(ElectionsInfo { - election_id: ELECTION_ID, - elect_close: ELECTION_ID - 300, - min_stake: MIN_STAKE, - total_stake: 0, - failed: false, - finished: false, - participants: vec![], - }) - } else { - // Tick 2 & 3: our stake in elector. On tick 3 the elector - // reports the updated stake (after tick-2 re-stake). - let stake = if n == 1 { initial_stake } else { stake_after_tick2 }; - Ok(ElectionsInfo { - election_id: ELECTION_ID, - elect_close: ELECTION_ID - 300, - min_stake: MIN_STAKE, - total_stake: stake, - failed: false, - finished: false, - participants: vec![Participant { + match n { + 0 => { + // Tick 1: 13 participants at 100k, our node not yet in elector + let participants = make_participants(&base_stakes_clone); + let total: u64 = participants.iter().map(|p| p.stake).sum(); + Ok(ElectionsInfo { + election_id: ELECTION_ID, + elect_close: ELECTION_ID - 300, + min_stake: MIN_STAKE, + total_stake: total, + failed: false, + finished: false, + participants, + }) + } + 1 => { + // Tick 2: same 13 at 100k + our node in elector + let mut participants = make_participants(&base_stakes_clone); + participants.push(Participant { pub_key: PUB_KEY.to_vec(), adnl_addr: ADNL_ADDR.to_vec(), wallet_addr: wallet_addr_clone.clone(), - stake, + stake: initial_stake, max_factor: FACTOR_3X, election_id: ELECTION_ID, stake_message_boc: None, - }], - }) + }); + let total: u64 = participants.iter().map(|p| p.stake).sum(); + Ok(ElectionsInfo { + election_id: ELECTION_ID, + elect_close: ELECTION_ID - 300, + min_stake: MIN_STAKE, + total_stake: total, + failed: false, + finished: false, + participants, + }) + } + _ => { + // Tick 3: participant #0 doubled to 200k, rest at 100k + our node + let mut participants = make_participants(&raised_stakes); + participants.push(Participant { + pub_key: PUB_KEY.to_vec(), + adnl_addr: ADNL_ADDR.to_vec(), + wallet_addr: wallet_addr_clone.clone(), + stake: initial_stake, + max_factor: FACTOR_3X, + election_id: ELECTION_ID, + stake_message_boc: None, + }); + let total: u64 = participants.iter().map(|p| p.stake).sum(); + Ok(ElectionsInfo { + election_id: ELECTION_ID, + elect_close: ELECTION_ID - 300, + min_stake: MIN_STAKE, + total_stake: total, + failed: false, + finished: false, + participants, + }) + } } }); - // --- Elector: past_elections fetched twice (tick 1, tick 3; tick 2 uses cache) --- - // prev_min_eff on tick 1 must be > initial_stake so tick 2 triggers top-up. - // initial_stake ≈ 25k, so we use 30k. - let pe_count = std::sync::Arc::new(std::sync::atomic::AtomicU32::new(0)); - let pe_cc = pe_count.clone(); - harness.elector_mock.expect_past_elections().times(2).returning(move || { - let n = pe_cc.fetch_add(1, std::sync::atomic::Ordering::SeqCst); - // Call 0 (tick 1): 30k (> initial_stake ~25k). Call 1 (tick 3): raised above stake_after_tick2. - let prev_min = if n == 0 { 30_000 * NANO } else { stake_after_tick2 + 5_000 * NANO }; + // --- Elector: past_elections (fetched once, cached for same election_id) --- + // prev_min = 30k — only used as fallback if emulation fails. + harness.elector_mock.expect_past_elections().times(1).returning(|| { let mut frozen_map = HashMap::new(); frozen_map.insert( [0xAA; 32], FrozenParticipant { wallet_addr: [0xBB; 32], weight: 1, - stake: prev_min, + stake: 30_000 * NANO, banned: false, }, ); @@ -2224,7 +2276,7 @@ async fn test_adaptive_topup_three_ticks() { stake_held: 7200, vset_hash: vec![], frozen_map, - total_stake: prev_min, + total_stake: 30_000 * NANO, bonuses: 0, }]) }); @@ -2235,14 +2287,16 @@ async fn test_adaptive_topup_three_ticks() { harness.provider_mock.expect_validator_config().times(3).returning(move || { let n = vcc.fetch_add(1, std::sync::atomic::Ordering::SeqCst); if n == 0 { + // Tick 1: no existing key → runner generates new key Ok(ValidatorConfig::new()) } else { + // Tick 2-3: key available let mut keys = HashMap::new(); keys.insert( ELECTION_ID, ValidatorEntry { key_id: KEY_ID.to_vec(), - public_key: vec![], + public_key: PUB_KEY.to_vec(), adnl_addrs: vec![(ADNL_ADDR.to_vec(), ELECTION_ID + 7200)], expired_at: ELECTION_ID + 7200, }, @@ -2251,14 +2305,22 @@ async fn test_adaptive_topup_three_ticks() { } }); + // --- Provider: dynamic account balance (decreases after staking) --- + let account_bal = std::sync::Arc::new(std::sync::atomic::AtomicU64::new(wallet_balance)); + let ab = account_bal.clone(); + harness + .provider_mock + .expect_account() + .returning(move |_| Ok(fake_account(ab.load(std::sync::atomic::Ordering::SeqCst)))); + // --- Rest of elector/provider/wallet setup --- setup_default_elector(&mut harness.elector_mock, ELECTION_ID, 0); - setup_default_provider(&mut harness.provider_mock, WALLET_BALANCE, None); + setup_default_provider(&mut harness.provider_mock, wallet_balance, None); setup_wallet(&mut harness.wallet_mock); let mut runner = harness.build(node_id); - // === Tick 1: initial stake (half) === + // === Tick 1: emulate election → stake half === runner.refresh_validator_configs().await; runner.refresh_validator_set().await; let r1 = runner.run().await; @@ -2266,10 +2328,15 @@ async fn test_adaptive_topup_three_ticks() { let node = runner.nodes.get(node_id).unwrap(); assert!(node.participant.is_some(), "should participate after tick 1"); assert!(!node.stake_accepted, "stake not yet accepted after tick 1"); - assert_eq!(node.participant.as_ref().unwrap().stake, initial_stake); + assert_eq!( + node.participant.as_ref().unwrap().stake, + initial_stake, + "tick 1: should stake half of pool_free" + ); + // Simulate wallet balance decrease after staking + account_bal.store(wallet_balance - initial_stake - fee, std::sync::atomic::Ordering::SeqCst); - // === Tick 2: elector recognizes our stake, stake_accepted → true === - // current_stake = initial_stake (~50k) >= prev_min_eff (30k) → no top-up. + // === Tick 2: elector accepted, no top-up (current_stake >= min_eff) === runner.refresh_validator_configs().await; runner.refresh_validator_set().await; let r2 = runner.run().await; @@ -2277,12 +2344,12 @@ async fn test_adaptive_topup_three_ticks() { let node = runner.nodes.get(node_id).unwrap(); assert!(node.stake_accepted, "stake should be accepted on tick 2"); let tick2_stake = node.participant.as_ref().unwrap().stake; - assert_eq!(tick2_stake, initial_stake, "tick 2: no top-up needed (current_stake >= min_eff)"); + assert_eq!( + tick2_stake, initial_stake, + "tick 2: no top-up needed (current_stake >= curr_min_eff)" + ); - // === Tick 3: actual top-up with current_stake > 0 === - // Invalidate past_elections cache so tick 3 re-fetches with raised prev_min_eff. - // prev_min_eff now = stake_after_tick2 + 5k > current_stake → triggers top-up. - runner.past_elections_cache_id = 0; + // === Tick 3: one participant raised stake → top-up === runner.refresh_validator_configs().await; runner.refresh_validator_set().await; let r3 = runner.run().await; @@ -2291,34 +2358,28 @@ async fn test_adaptive_topup_three_ticks() { assert!(node.stake_accepted, "stake should still be accepted on tick 3"); let tick3_stake = node.participant.as_ref().unwrap().stake; - // On tick 3: stake_accepted=true → current_stake = stake_after_tick2. - // prev_min_eff = stake_after_tick2 + 5k → current_stake < min_eff → top-up. - // total = pool_free + stake_after_tick2, half = total/2. - let min_eff_tick3 = stake_after_tick2 + 5_000 * NANO; - let total_tick3 = pool_free_balance + stake_after_tick2; - let half_tick3 = total_tick3 / 2; + // Remaining wallet ≈ 50k, current_stake ≈ 50k in elector. + // total ≈ 100k, half ≈ 50k < min_eff (~60k) → stake all remaining. + let remaining_balance = wallet_balance - initial_stake - fee; + let pool_free_tick3 = remaining_balance - fee - MIN_NANOTON_FOR_STORAGE; assert!( tick3_stake > tick2_stake, "tick 3: stake should increase via top-up: tick2={}, tick3={}", tick2_stake, tick3_stake ); - if half_tick3 >= min_eff_tick3 { - // half branch: topup = half - current_stake - let topup = half_tick3 - stake_after_tick2; - assert_eq!( - tick3_stake, - tick2_stake + topup, - "tick 3: participant.stake = old + (half - current_stake)" - ); - } + assert_eq!( + tick3_stake, + tick2_stake + pool_free_tick3, + "tick 3: should stake all remaining (half < min_eff)" + ); } #[test] fn test_elections_config_validate_sleep_gt_waiting() { let config = ElectionsConfig { - adaptive_sleep_period_pct: 0.5, - adaptive_waiting_period_pct: 0.3, // sleep > waiting → invalid + sleep_period_pct: 0.5, + waiting_period_pct: 0.3, // sleep > waiting → invalid ..ElectionsConfig::default() }; assert!(config.validate().is_err()); @@ -2327,7 +2388,7 @@ fn test_elections_config_validate_sleep_gt_waiting() { #[test] fn test_elections_config_validate_sleep_out_of_range() { let config = ElectionsConfig { - adaptive_sleep_period_pct: 1.5, // > 1.0 → invalid + sleep_period_pct: 1.5, // > 1.0 → invalid ..ElectionsConfig::default() }; assert!(config.validate().is_err()); @@ -2336,8 +2397,8 @@ fn test_elections_config_validate_sleep_out_of_range() { #[test] fn test_elections_config_validate_valid() { let config = ElectionsConfig { - adaptive_sleep_period_pct: 0.1, - adaptive_waiting_period_pct: 0.5, + sleep_period_pct: 0.1, + waiting_period_pct: 0.5, ..ElectionsConfig::default() }; assert!(config.validate().is_ok()); @@ -2346,8 +2407,8 @@ fn test_elections_config_validate_valid() { #[test] fn test_elections_config_defaults() { let config = ElectionsConfig::default(); - assert_eq!(config.adaptive_sleep_period_pct, 0.0); - assert_eq!(config.adaptive_waiting_period_pct, 0.3); + assert_eq!(config.sleep_period_pct, 0.0); + assert_eq!(config.waiting_period_pct, 0.3); } // Participation status transitions across election lifecycle // Simulates: Idle → Participating → Submitted → Accepted → Elected → Validating From 77f30d7ca75f9a4c1b5fa2248be7131600552f8f Mon Sep 17 00:00:00 2001 From: NikitaM <21960067+Keshoid@users.noreply.github.com> Date: Wed, 1 Apr 2026 16:30:33 +0300 Subject: [PATCH 09/11] fix: after review --- src/node-control/elections/src/runner.rs | 10 ++++++++-- src/node-control/elections/src/runner_tests.rs | 4 ++-- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/src/node-control/elections/src/runner.rs b/src/node-control/elections/src/runner.rs index 1cf0756..0b56307 100644 --- a/src/node-control/elections/src/runner.rs +++ b/src/node-control/elections/src/runner.rs @@ -51,8 +51,14 @@ const RECOVER_FEE: u64 = 200_000_000; const NPOOL_COMPUTE_FEE: u64 = 200_000_000; /// Gas fee consumed by validator wallet const WALLET_COMPUTE_FEE: u64 = 200_000_000; -/// Reserved minimum balance on the wallet (or pool) balance for stake calculations -const MIN_NANOTON_FOR_STORAGE: u64 = 1_050_000_000; +/// Reserved minimum balance on the pool (or wallet) to correctly calculate free +/// funds for staking. Includes: +/// 1) 1 TON required by SNP (MIN_TON_FOR_STORAGE); +/// 2) 0.05 TON to cover storage fees accumulated after last pool transaction. +/// It's an approximation to avoid error when staking all available funds: +/// throw_unless(ERROR::INSUFFICIENT_BALANCE, stake_amount <= my_balance - msg_value - MIN_TON_FOR_STORAGE); +/// where `my_balance` is already decreased by storage fees which we want to cover. +const MIN_NANOTON_FOR_STORAGE: u64 = 1_005_000_000; type OnStatusChange = Arc) + Send + Sync>; diff --git a/src/node-control/elections/src/runner_tests.rs b/src/node-control/elections/src/runner_tests.rs index dc9d59d..702534e 100644 --- a/src/node-control/elections/src/runner_tests.rs +++ b/src/node-control/elections/src/runner_tests.rs @@ -2407,8 +2407,8 @@ fn test_elections_config_validate_valid() { #[test] fn test_elections_config_defaults() { let config = ElectionsConfig::default(); - assert_eq!(config.sleep_period_pct, 0.0); - assert_eq!(config.waiting_period_pct, 0.3); + assert_eq!(config.sleep_period_pct, 0.2); + assert_eq!(config.waiting_period_pct, 0.4); } // Participation status transitions across election lifecycle // Simulates: Idle → Participating → Submitted → Accepted → Elected → Validating From 4c6c1c3715139cb414cd33ec7fde7e0401c1546e Mon Sep 17 00:00:00 2001 From: NikitaM <21960067+Keshoid@users.noreply.github.com> Date: Wed, 1 Apr 2026 17:59:16 +0300 Subject: [PATCH 10/11] refactor(elections): info logs for adaptive strategy --- src/node-control/elections/src/adaptive_strategy.rs | 10 +++++----- src/node-control/elections/src/runner.rs | 2 +- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/node-control/elections/src/adaptive_strategy.rs b/src/node-control/elections/src/adaptive_strategy.rs index 0da0027..9f62d22 100644 --- a/src/node-control/elections/src/adaptive_strategy.rs +++ b/src/node-control/elections/src/adaptive_strategy.rs @@ -45,7 +45,7 @@ pub(crate) fn is_adaptive_split50_ready( // Wait if sleep period hasn't passed yet if now < sleep_deadline { tracing::info!( - "node [{}] adaptive_split50 - sleep period: now < sleep_deadline={}", + "node [{}] adaptive_split50: sleep period, now < sleep_deadline={}", node_id, common::time_format::format_ts(sleep_deadline) ); @@ -55,7 +55,7 @@ pub(crate) fn is_adaptive_split50_ready( // Wait if not enough participants and waiting period hasn't expired if participants_count < min_validators && now < wait_deadline { tracing::info!( - "node [{}] adaptive_split50 - waiting for participants: ({}/{}), deadline={}", + "node [{}] adaptive_split50: waiting for participants ({}/{}), deadline={}", node_id, participants_count, min_validators, @@ -179,7 +179,7 @@ pub(crate) fn calc_adaptive_stake( // half is enough — stake half. let stake = half.saturating_sub(current_stake); tracing::info!( - "node [{}] adaptive_split50 - stake half: current_stake={} TON, left_to_stake={} TON, half={} TON >= min_eff={} TON", + "node [{}] adaptive_split50: stake half, current_stake={} TON, left_to_stake={} TON, half={} TON >= min_eff={} TON", node_id, nanotons_to_tons_f64(current_stake), nanotons_to_tons_f64(stake), @@ -189,7 +189,7 @@ pub(crate) fn calc_adaptive_stake( if stake > free_balance { // Not enough free funds to stake half. Skip and let the operator top up. tracing::error!( - "node [{}] adaptive_split50 - insufficient free balance: need {} TON to stake half, \ + "node [{}] adaptive_split50: insufficient free balance, need {} TON to stake half, \ but only {} TON available. Consider topping up the pool.", node_id, nanotons_to_tons_f64(stake), @@ -204,7 +204,7 @@ pub(crate) fn calc_adaptive_stake( // so the remainder after staking min_eff would also be < min_eff. // The next round won't have enough funds anyway — stake everything. tracing::info!( - "node [{}] adaptive_split50 - stake all: half={} TON < min_eff={} TON, staking all free_balance={} TON", + "node [{}] adaptive_split50: stake all, half={} TON < min_eff={} TON, staking all free_balance={} TON", node_id, nanotons_to_tons_f64(half), nanotons_to_tons_f64(min_eff_stake), diff --git a/src/node-control/elections/src/runner.rs b/src/node-control/elections/src/runner.rs index 0b56307..0463149 100644 --- a/src/node-control/elections/src/runner.rs +++ b/src/node-control/elections/src/runner.rs @@ -687,7 +687,7 @@ impl ElectionRunner { if matches!(node.stake_policy, StakePolicy::AdaptiveSplit50) && stake > 0 { let old_stake = node.participant.as_ref().map(|p| p.stake).unwrap_or(0); tracing::info!( - "node [{}] adaptive top-up: {} TON → {} TON (delta={} TON)", + "node [{}] adaptive_split50: top-up {} TON → {} TON (delta={} TON)", node_id, nanotons_to_tons_f64(old_stake), nanotons_to_tons_f64(old_stake + stake), From 93956a3df5bcec139db7444da240fd2ebe60992c Mon Sep 17 00:00:00 2001 From: NikitaM <21960067+Keshoid@users.noreply.github.com> Date: Fri, 3 Apr 2026 17:00:34 +0300 Subject: [PATCH 11/11] fix(elections): restore participant from validator_config on restart --- src/node-control/elections/src/runner.rs | 26 +++++++++++++++++++++++- 1 file changed, 25 insertions(+), 1 deletion(-) diff --git a/src/node-control/elections/src/runner.rs b/src/node-control/elections/src/runner.rs index 0463149..c1efaee 100644 --- a/src/node-control/elections/src/runner.rs +++ b/src/node-control/elections/src/runner.rs @@ -581,7 +581,31 @@ impl ElectionRunner { node.accepted_stake_amount = None; node.submission_time = None; node.stake_submissions.clear(); - node.participant = participant.clone(); + let wallet_addr = node.wallet_addr(); + node.participant = match (participant.as_ref(), validator_key.as_ref()) { + (Some(p), _) => Some(p.clone()), + (None, Some(v)) => { + let adnl_addr = v + .adnl_addr() + .ok_or_else(|| anyhow::anyhow!("validator has no adnl address"))?; + if adnl_addr.is_empty() { + anyhow::bail!("validator adnl address is empty"); + } + if v.public_key.is_empty() { + anyhow::bail!("validator public key is empty"); + } + Some(Participant { + stake_message_boc: None, + pub_key: v.public_key.clone(), + adnl_addr, + election_id, + wallet_addr, + stake: 0, + max_factor, + }) + } + (None, None) => None + }; node.key_id = validator_key.as_ref().map(|entry| entry.key_id.clone()).unwrap_or_default(); }