Skip to content
7 changes: 7 additions & 0 deletions src/node-control/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Changed

- **Static ADNL address is now the default across elections.** Previously nodectl generated a fresh ADNL key every election cycle, which broke Rust-node fastsync for peers that still knew the validator by its previous ADNL. Now nodectl auto-generates and persists a static ADNL per node on the first election cycle and reuses it thereafter. Existing `elections.static_adnls` entries are honored unchanged. To opt out for a node and revert to per-cycle ephemeral ADNL, run `nodectl config elections static-adnl --node <name> --disable` (or `DELETE /v1/elections/static-adnl/{node}`). The opt-out is stored in the new `elections.static_adnl_disabled` set. Running the existing rotate command (`nodectl config elections static-adnl --node <name>`) re-enables the static default if it was previously disabled.

### Added

- **`DELETE /v1/elections/static-adnl/{node}`** — opt the node out of static ADNL; the runner will generate a fresh ephemeral ADNL each cycle.
- **`--disable` flag on `nodectl config elections static-adnl`** — CLI counterpart of the DELETE endpoint.
- **`static_adnl_disabled` field on `BindingElectionStatusDto`** — surfaced by `GET /v1/elections/settings` and the `elections show` CLI output ("disabled" marker in the Static ADNL column).
- **TONCore pool deploy mode (`deploy_layout`)** — per-slot string (JSON field name unchanged). Canonical values: **`legacy`** (full pool bytecode in `StateInit.code`; same addresses as pools created with older nodectl) and **`tonscan`** (alias: `tonscan_compatible`, `tonscan-compatible` — bootstrap `StateInit.code` + `SETCODE` on first execution; explorers such as Tonscan recognise the contract). **Use `tonscan` for new pools.** Already-deployed slots must stay on the mode they were created with — address derivation differs between modes. Wired through REST (`POST /v1/pools/core`, pool slot views), JSON config, and CLI (`nodectl config pool add core --deploy-mode`, alias `--deploy-layout`). Defaults: **missing `deploy_layout` in persisted config** stays **`legacy`** so derived addresses are preserved; **`POST /v1/pools/core`** / **`nodectl config pool add core`** omitting deploy mode default to **`tonscan`**.

## [0.4.0] - 2026-04-21
Expand Down
41 changes: 35 additions & 6 deletions src/node-control/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -712,14 +712,26 @@ nodectl config elections disable node0

##### `config elections static-adnl`

Generate a persistent ADNL address for a node and save it to the `elections.static_adnls` config. The ADNL key is created on the validator node via its control server. Once set, the election runner reuses this address every cycle instead of generating a fresh one.
Manage the persistent ADNL address for a node.

By default (since v0.5.0) nodectl generates and persists a static ADNL per node automatically on the first election cycle, so peers keep finding the validator across rotations. Use this command to:

- **Rotate** the static ADNL (replace the stored key with a fresh one): run without `--disable`.
- **Opt out** for a node (revert to per-cycle ephemeral ADNL): run with `--disable`.

Rotating again on a previously disabled node re-enables the static default.

| Flag | Short form | Description |
|------|------------|-------------|
| `--node <NAME>` | `-n` | Node name (must exist in `nodes`) |
| `--disable` | | Opt out of static ADNL for this node |

```bash
# Rotate (or initialize) the static ADNL for node0
nodectl config elections static-adnl --node node0

# Opt out — fresh ADNL each election cycle (legacy behavior)
nodectl config elections static-adnl --node node0 --disable
```

---
Expand Down Expand Up @@ -1205,7 +1217,8 @@ Role columns use the following shorthand: **P** = public (no token), **N** = `no
| POST | `/v1/elections/settings` | O | Update elections settings (policy, per-node override, tick, max-factor, `sleep_period_pct`, `waiting_period_pct`) |
| GET | `/v1/automation/settings` | N | Contracts task settings (auto-deploy, auto-topup, amounts, tick) |
| POST | `/v1/automation/settings` | O | Update contracts task settings (partial JSON: tick/toggles and nested `wallet` / `pool`, nanotons) |
| POST | `/v1/elections/static-adnl` | O | Generate and assign a persistent ADNL address for a node |
| POST | `/v1/elections/static-adnl` | O | Generate and assign (or rotate) a persistent ADNL address for a node |
| DELETE | `/v1/elections/static-adnl/{node}` | O | Opt out of static ADNL for a node (fresh ADNL each cycle) |
| GET | `/v1/validators` | N | Validators snapshot for controlled nodes |
| POST | `/v1/task/elections` | O | Enable / disable / restart the elections background task |
| GET | `/v1/nodes` | N | List configured nodes with control-server status |
Expand Down Expand Up @@ -1463,7 +1476,9 @@ Returns the **`automation`** settings as JSON: `tick_interval_sec`, `auto_deploy

#### `POST /v1/elections/static-adnl`

Generate a persistent ADNL address on the validator node and save it to the `elections.static_adnls` config map. The election runner will reuse this address every cycle instead of generating a fresh ephemeral one.
Generate (or rotate) a persistent ADNL address on the validator node and save it to the `elections.static_adnls` config map. The election runner reuses this address every cycle instead of generating a fresh ephemeral one.

Since v0.5.0 nodectl auto-generates a static ADNL for each node on its first election cycle, so this endpoint is only needed to rotate an existing address or re-enable a previously disabled node.

**Request:**

Expand All @@ -1482,7 +1497,19 @@ Generate a persistent ADNL address on the validator node and save it to the `ele
}
```

Calling this endpoint again for the same node generates a **new** key and overwrites the previous one.
Calling this endpoint again for the same node generates a **new** key, overwrites the previous one, and clears any opt-out state.

---

#### `DELETE /v1/elections/static-adnl/{node}`

Opt the node out of the static ADNL default: removes the entry from `elections.static_adnls` and adds the node to `elections.static_adnl_disabled`. The runner will generate a fresh ephemeral ADNL each election cycle (pre-v0.5 behavior). To re-enable, call `POST /v1/elections/static-adnl` again.

**Response:**

```json
{ "ok": true }
```

---

Expand Down Expand Up @@ -1853,7 +1880,8 @@ Configuration is specified in JSON format.
"tick_interval": 40,
"sleep_period_pct": 0.2,
"waiting_period_pct": 0.4,
"static_adnls": { "<node_name>": "<base64_adnl_key_hash>" }
"static_adnls": { "<node_name>": "<base64_adnl_key_hash>" },
"static_adnl_disabled": ["<node_name>"]
},
// optional
"voting": {
Expand Down Expand Up @@ -1970,7 +1998,8 @@ Automatic elections task configuration:
- `tick_interval` — interval between election checks in seconds (default: `40`)
- `sleep_period_pct` — AdaptiveSplit50 minimum wait as a fraction of election duration. Default `0.2`. Must be in `[0.0, 1.0]` and ≤ `waiting_period_pct`.
- `waiting_period_pct` — AdaptiveSplit50 maximum wait for enough participants as a fraction of election duration. Default `0.4`. Must be in `[0.0, 1.0]` and ≥ `sleep_period_pct`.
- `static_adnls` — pre-generated persistent ADNL addresses keyed by node name (base64-encoded). When a node has an entry here, the runner reuses this ADNL address each election cycle instead of generating a fresh one. Managed via `config elections static-adnl` or `POST /v1/elections/static-adnl`. Example: `{ "node0": "oRvD1E5F..." }`
- `static_adnls` — pre-generated persistent ADNL addresses keyed by node name (base64-encoded). When a node has an entry here, the runner reuses this ADNL address each election cycle instead of generating a fresh one. Since v0.5.0 nodectl auto-populates this map on the first election cycle for every node that is not in `static_adnl_disabled`. Managed via `config elections static-adnl` or `POST /v1/elections/static-adnl`. Example: `{ "node0": "oRvD1E5F..." }`
- `static_adnl_disabled` — set of node names that opt out of the static ADNL default; the runner generates a fresh ephemeral ADNL address each cycle for these nodes (pre-v0.5 behavior). Managed via `config elections static-adnl --node <name> --disable` or `DELETE /v1/elections/static-adnl/{node}`. Example: `["node1"]`

#### `automation` (optional)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
*/
use crate::commands::nodectl::{
output_format::OutputFormat,
utils::{api_get, api_post, resolve_service_url},
utils::{api_delete, api_get, api_post, resolve_service_url},
};
use colored::Colorize;
use common::{
Expand Down Expand Up @@ -127,6 +127,11 @@ pub struct DisableCmd {
pub struct StaticAdnlCmd {
#[arg(short = 'n', long = "node", required = true, help = "Node name")]
node: String,
/// Opt out of the static ADNL default for this node: the runner will generate a fresh
/// ephemeral ADNL address every cycle (pre-v0.5 behavior). Run without this flag again
/// to rotate and re-enable the static address.
#[arg(long = "disable", default_value_t = false)]
disable: bool,
}

impl ElectionsCfgCmd {
Expand Down Expand Up @@ -196,6 +201,8 @@ struct BindingElectionView {
stake_policy: StakePolicy,
#[serde(default)]
static_adnl: Option<String>,
#[serde(default)]
static_adnl_disabled: bool,
}

fn print_elections_settings_table(view: &ElectionsSettingsView) {
Expand All @@ -214,7 +221,8 @@ fn print_elections_settings_table(view: &ElectionsSettingsView) {
}

if !view.bindings.is_empty() {
let has_static_adnl = view.bindings.iter().any(|b| b.static_adnl.is_some());
let has_static_adnl =
view.bindings.iter().any(|b| b.static_adnl.is_some() || b.static_adnl_disabled);
// Column widths: stake policy strings can be long (e.g. adaptive_split50 (50% or 100%)).
// Headers/data must pad plain text before coloring — ANSI must not count toward {:<N}.
const W_NODE: usize = 20;
Expand Down Expand Up @@ -255,7 +263,11 @@ fn print_elections_settings_table(view: &ElectionsSettingsView) {
let status_cell = format!("{:<w_st$}", b.status.to_string(), w_st = W_STATUS);
let stake_cell = format!("{:<w_sk$}", b.stake_policy.to_string(), w_sk = W_STAKE);
if has_static_adnl {
let adnl = b.static_adnl.as_deref().unwrap_or("—");
let adnl = if b.static_adnl_disabled {
"disabled"
} else {
b.static_adnl.as_deref().unwrap_or("—")
};
let adnl_cell = format!("{:<w_ad$}", adnl, w_ad = W_ADNL);
println!(
" {:<w$}{}{}{}{}",
Expand Down Expand Up @@ -490,6 +502,16 @@ impl StaticAdnlCmd {
config_path: Option<&str>,
) -> anyhow::Result<()> {
let base_url = resolve_service_url(url, config_path)?;
if self.disable {
let path = format!("/v1/elections/static-adnl/{}", self.node);
api_delete(&base_url, &path, token).await?;
println!(
"{} Static ADNL disabled for '{}' (fresh ADNL each cycle)",
"OK".green().bold(),
self.node
);
return Ok(());
}
let resp = api_post(
&base_url,
"/v1/elections/static-adnl",
Expand Down
6 changes: 6 additions & 0 deletions src/node-control/common/src/app_config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -616,8 +616,13 @@ pub struct ElectionsConfig {
/// Pre-generated ADNL addresses, keyed by node name (base64-encoded).
/// When a node has an entry here, the runner attaches this existing ADNL address
/// to the validator key each election instead of generating a fresh one.
/// Auto-populated on first election for nodes not in `static_adnl_disabled`.
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
pub static_adnls: HashMap<String, String>,
/// Nodes that opt out of the static ADNL default: the runner generates a fresh
/// ephemeral ADNL address every cycle for them (pre-v0.5 behavior).
#[serde(default, skip_serializing_if = "HashSet::is_empty")]
pub static_adnl_disabled: HashSet<String>,
}

impl ElectionsConfig {
Expand Down Expand Up @@ -668,6 +673,7 @@ impl Default for ElectionsConfig {
sleep_period_pct: default_sleep_pct(),
waiting_period_pct: default_waiting_pct(),
static_adnls: HashMap::new(),
static_adnl_disabled: HashSet::new(),
}
}
}
Expand Down
53 changes: 32 additions & 21 deletions src/node-control/service/src/elections/election_task.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,49 +8,41 @@
*/
use super::{
providers::{DefaultElectionsProvider, ElectionsProvider},
runner::ElectionRunner,
runner::{ElectionRunner, PersistStaticAdnls},
};
use crate::runtime_config::RuntimeConfig;
use anyhow::Context;
use common::{
app_config::{AppConfig, BindingStatus},
app_config::{AppConfig, BindingStatus, ElectionsConfig},
snapshot::SnapshotStore,
task_cancellation::CancellationCtx,
};
use contracts::{ElectorWrapperImpl, NominatorWrapper, TonWallet, contract_provider};
use secrets_vault::vault::SecretVault;
use contracts::{ElectorWrapperImpl, contract_provider};
use std::{collections::HashMap, sync::Arc, time::Duration};
use ton_http_api_client::v2::client_json_rpc::ClientJsonRpc;

/// Callback invoked after each tick with updated binding statuses.
pub type BindingStatusCallback = Arc<dyn Fn(HashMap<String, BindingStatus>) + Send + Sync>;

pub async fn run(
cancellation_ctx: CancellationCtx,
app_config: Arc<AppConfig>,
rpc_client: Arc<ClientJsonRpc>,
wallets: Arc<HashMap<String, Arc<dyn TonWallet>>>,
pools: Arc<HashMap<String, Arc<dyn NominatorWrapper>>>,
runtime_cfg: Arc<dyn RuntimeConfig>,
store: Arc<SnapshotStore>,
vault: Option<Arc<SecretVault>>,
on_status_change: Option<BindingStatusCallback>,
) -> anyhow::Result<()> {
let Some(config) = app_config.elections.as_ref() else {
anyhow::bail!("elections config is empty");
};

let adnl_configs = app_config
.nodes
.iter()
.map(|(node_name, cfg)| (node_name.clone(), cfg.clone()))
.collect::<HashMap<_, _>>();

let vault = runtime_cfg.vault();
let mut set = tokio::task::JoinSet::new();
let mut sorted_nodes: Vec<_> = adnl_configs.into_iter().collect();
let mut sorted_nodes: Vec<_> =
app_config.nodes.iter().map(|(node_name, cfg)| (node_name.clone(), cfg.clone())).collect();
sorted_nodes.sort_by(|(a, _), (b, _)| a.cmp(b));

for (node_id, config) in sorted_nodes.into_iter() {
for (node_id, node_cfg) in sorted_nodes.into_iter() {
let vault = vault.clone();
set.spawn(async move { (node_id, config.to_node_adnl_config(vault).await) });
set.spawn(async move { (node_id, node_cfg.to_node_adnl_config(vault).await) });
}

let providers: HashMap<String, Box<dyn ElectionsProvider>> = set
Expand All @@ -75,10 +67,29 @@ pub async fn run(
anyhow::bail!("cannot proceed: some nodes have invalid configs");
}

let elector = Arc::new(ElectorWrapperImpl::new(contract_provider!(rpc_client)));
let elector = Arc::new(ElectorWrapperImpl::new(contract_provider!(runtime_cfg.rpc_client())));

let persist_static_adnls: PersistStaticAdnls = {
let runtime_cfg = runtime_cfg.clone();
Arc::new(move |generated: HashMap<String, String>| {
runtime_cfg.update_and_save(Box::new(move |cfg| {
let elections = cfg.elections.get_or_insert_with(ElectionsConfig::default);
for (node_id, b64) in generated {
elections.static_adnls.insert(node_id, b64);
}
}))
})
};

let mut runner =
ElectionRunner::new(config, &app_config.bindings, elector, providers, wallets, pools);
let mut runner = ElectionRunner::new(
config,
&app_config.bindings,
elector,
providers,
runtime_cfg.wallets(),
runtime_cfg.pools(),
Some(persist_static_adnls),
);
runner
.run_loop(
Duration::from_secs(config.tick_interval),
Expand Down
Loading
Loading