diff --git a/src/node-control/CHANGELOG.md b/src/node-control/CHANGELOG.md index 2f3e7d78..56ac1fcc 100644 --- a/src/node-control/CHANGELOG.md +++ b/src/node-control/CHANGELOG.md @@ -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 --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 `) 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 diff --git a/src/node-control/README.md b/src/node-control/README.md index 3e949deb..dfa33acb 100644 --- a/src/node-control/README.md +++ b/src/node-control/README.md @@ -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 ` | `-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 ``` --- @@ -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 | @@ -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:** @@ -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 } +``` --- @@ -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": { "": "" } + "static_adnls": { "": "" }, + "static_adnl_disabled": [""] }, // optional "voting": { @@ -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 --disable` or `DELETE /v1/elections/static-adnl/{node}`. Example: `["node1"]` #### `automation` (optional) 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 3cd22782..ffca3914 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 @@ -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::{ @@ -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 { @@ -196,6 +201,8 @@ struct BindingElectionView { stake_policy: StakePolicy, #[serde(default)] static_adnl: Option, + #[serde(default)] + static_adnl_disabled: bool, } fn print_elections_settings_table(view: &ElectionsSettingsView) { @@ -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 {:, ) -> 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", diff --git a/src/node-control/common/src/app_config.rs b/src/node-control/common/src/app_config.rs index 8e90a733..ec4bd32a 100644 --- a/src/node-control/common/src/app_config.rs +++ b/src/node-control/common/src/app_config.rs @@ -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, + /// 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, } impl ElectionsConfig { @@ -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(), } } } diff --git a/src/node-control/service/src/elections/election_task.rs b/src/node-control/service/src/elections/election_task.rs index 9fd5064c..c40aeba0 100644 --- a/src/node-control/service/src/elections/election_task.rs +++ b/src/node-control/service/src/elections/election_task.rs @@ -8,18 +8,17 @@ */ 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) + Send + Sync>; @@ -27,30 +26,23 @@ pub type BindingStatusCallback = Arc) + Se pub async fn run( cancellation_ctx: CancellationCtx, app_config: Arc, - rpc_client: Arc, - wallets: Arc>>, - pools: Arc>>, + runtime_cfg: Arc, store: Arc, - vault: Option>, on_status_change: Option, ) -> 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::>(); - + 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> = set @@ -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| { + 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), diff --git a/src/node-control/service/src/elections/runner.rs b/src/node-control/service/src/elections/runner.rs index 8e5bacc7..b9a9e354 100644 --- a/src/node-control/service/src/elections/runner.rs +++ b/src/node-control/service/src/elections/runner.rs @@ -68,6 +68,11 @@ const EXTRA_STORAGE_FEES: u64 = 5_000_000; type OnStatusChange = Arc) + Send + Sync>; +/// Persists a batch of freshly generated static ADNL addresses (node_id → base64) into the +/// runtime config under `elections.static_adnls`. Called at most once per tick. +pub(crate) type PersistStaticAdnls = + Arc) -> anyhow::Result<()> + Send + Sync>; + /// Record of a single stake submission (internal). #[derive(Clone, Debug)] struct StakeSubmissionRecord { @@ -112,6 +117,9 @@ struct Node { /// Pre-generated static ADNL address (32-byte key hash). /// When set, this address is re-registered each election instead of generating a fresh one. static_adnl_addr: Option>, + /// Opt-out: when true, the runner generates a fresh ephemeral ADNL address every cycle + /// for this node instead of using a static one. + static_adnl_disabled: bool, /// Excluded from elections (enable = false). excluded: bool, /// Effective stake policy for this node. @@ -135,19 +143,26 @@ impl Node { } /// Resolve the ADNL address for this election cycle. - /// If a static ADNL is configured, re-register it with the validator key. - /// Otherwise, generate a fresh ephemeral ADNL address. + /// Precedence: + /// 1. Opt-out (`static_adnl_disabled = true`) → fresh ephemeral ADNL. + /// 2. Static ADNL is set (either pre-configured or auto-generated by the + /// runner's ensure-step earlier this tick) → re-register it. + /// 3. Neither — the ensure-step failed to generate one this tick. Abort: + /// the next tick will retry generation. async fn resolve_adnl_addr( &mut self, perm_key_id: Vec, until: u64, ) -> anyhow::Result> { + if self.static_adnl_disabled { + return self.api.new_adnl_addr(perm_key_id, until).await; + } match &self.static_adnl_addr { Some(key_id) => { self.api.register_adnl_addr(key_id.clone(), perm_key_id, until).await?; Ok(key_id.clone()) } - None => self.api.new_adnl_addr(perm_key_id, until).await, + None => anyhow::bail!("static ADNL address not yet generated; will retry next tick"), } } @@ -216,6 +231,9 @@ pub(crate) struct ElectionRunner { sleep_pct: f64, /// AdaptiveSplit50: maximum wait fraction of election duration. waiting_pct: f64, + /// Callback to persist freshly generated static ADNL addresses into runtime config. + /// `None` in tests that don't care about persistence. + persist_static_adnls: Option, } #[derive(Default)] @@ -286,6 +304,7 @@ impl ElectionRunner { providers: HashMap>, wallets: Arc>>, pools: Arc>>, + persist_static_adnls: Option, ) -> Self { let mut nodes = HashMap::new(); for (node_id, provider) in providers { @@ -301,6 +320,7 @@ impl ElectionRunner { let excluded = !binding.map(|b| b.enable).unwrap_or(false); let binding_status = binding.map(|b| b.status).unwrap_or(BindingStatus::Idle); let stake_policy = elections_config.stake_policy(&node_id).clone(); + let static_adnl_disabled = elections_config.static_adnl_disabled.contains(&node_id); let static_adnl = elections_config.static_adnls.get(&node_id).and_then(|b64| { base64::Engine::decode(&base64::engine::general_purpose::STANDARD, b64) .map_err(|e| { @@ -316,6 +336,7 @@ impl ElectionRunner { pool: node_pools, pool_addr_cache: None, static_adnl_addr: static_adnl, + static_adnl_disabled, excluded, stake_policy, key_id: vec![], @@ -344,6 +365,7 @@ impl ElectionRunner { cached_prev_min_eff: None, sleep_pct: elections_config.sleep_period_pct, waiting_pct: elections_config.waiting_period_pct, + persist_static_adnls, } } @@ -399,6 +421,7 @@ impl ElectionRunner { } pub(crate) async fn run(&mut self) -> anyhow::Result<()> { + self.ensure_static_adnls().await; let cfg15 = self.election_parameters().await?; self.snapshot_cache.update_next_elections_range(&cfg15); @@ -772,11 +795,7 @@ impl ElectionRunner { tracing::info!( "node [{}] {} adnl address: {}", node_id, - if node.static_adnl_addr.is_some() { - "registered static" - } else { - "generated new" - }, + if node.static_adnl_disabled { "generated new" } else { "registered static" }, base64::Engine::encode( &base64::engine::general_purpose::STANDARD, adnl_addr.as_slice() @@ -1231,6 +1250,66 @@ impl ElectionRunner { } } + /// Generate and persist static ADNL addresses for any node that is missing one + /// and not explicitly opted out. After the first successful tick this is a no-op. + /// + /// Per-node generation failures are logged and retried on the next tick. The + /// in-memory `Node.static_adnl_addr` is only committed once `persist` succeeds, + /// so a failed persist leaves the node looking "missing" on the next tick and it + /// retries cleanly. + async fn ensure_static_adnls(&mut self) { + let mut generated: HashMap> = HashMap::new(); + for (node_id, node) in self.nodes.iter_mut() { + if node.static_adnl_disabled || node.static_adnl_addr.is_some() { + continue; + } + match node.api.generate_adnl_addr().await { + Ok(key_id) => { + tracing::info!( + "node [{}] static adnl address generated: {}", + node_id, + base64::Engine::encode(&base64::engine::general_purpose::STANDARD, &key_id,) + ); + generated.insert(node_id.clone(), key_id); + } + Err(e) => { + tracing::error!( + "node [{}] failed to generate static adnl address (will retry next tick): {:#}", + node_id, + e + ); + } + } + } + if generated.is_empty() { + return; + } + // Persist first; only commit in-memory state if the disk write succeeded. + if let Some(persist) = &self.persist_static_adnls { + let payload = generated + .iter() + .map(|(id, key)| { + ( + id.clone(), + base64::Engine::encode(&base64::engine::general_purpose::STANDARD, key), + ) + }) + .collect(); + if let Err(e) = persist(payload) { + tracing::error!( + "static-adnl: persist failed, dropping generated addresses (will retry next tick): {:#}", + e + ); + return; + } + } + for (node_id, key_id) in generated { + if let Some(node) = self.nodes.get_mut(&node_id) { + node.static_adnl_addr = Some(key_id); + } + } + } + async fn build_validators_snapshot(&mut self) -> ValidatorsSnapshot { let mut node_ids = self.nodes.keys().cloned().collect::>(); node_ids.sort(); diff --git a/src/node-control/service/src/elections/runner_tests.rs b/src/node-control/service/src/elections/runner_tests.rs index 441c2f1c..f9652088 100644 --- a/src/node-control/service/src/elections/runner_tests.rs +++ b/src/node-control/service/src/elections/runner_tests.rs @@ -20,7 +20,11 @@ use contracts::{ nominator::{NominatorRoles, PoolData, SNP_STORAGE_RESERVE, TONCORE_STORAGE_RESERVE, opcodes}, }; use mockall::mock; -use std::{collections::HashMap, sync::Arc, time::Duration}; +use std::{ + collections::{HashMap, HashSet}, + sync::Arc, + time::Duration, +}; use ton_block::{ BuilderData, Cell, Coins, ConfigParam15, Deserializable, MsgAddressInt, Number16, SigPubKey, SliceData, UInt256, ValidatorDescr, ValidatorSet, @@ -362,6 +366,9 @@ struct TestHarness { toncore_nominator_mocks: Option<(MockSingleNominatorWrapper, MockSingleNominatorWrapper)>, elections_config: ElectionsConfig, bindings: HashMap, + /// Captures static ADNL addresses persisted by `ensure_static_adnls`. When set, + /// `build()` installs a callback that writes generated entries here. + persisted_static_adnls: Option>>>, } impl TestHarness { @@ -380,11 +387,19 @@ impl TestHarness { sleep_period_pct: 0.0, waiting_period_pct: 0.3, static_adnls: HashMap::new(), + static_adnl_disabled: HashSet::new(), }, bindings: HashMap::new(), + persisted_static_adnls: None, } } + fn with_persist_capture(mut self) -> (Self, Arc>>) { + let store = Arc::new(std::sync::Mutex::new(HashMap::new())); + self.persisted_static_adnls = Some(store.clone()); + (self, store) + } + fn with_pool(mut self) -> Self { self.pool_mock = Some(MockSingleNominatorWrapper::new()); self @@ -399,6 +414,15 @@ impl TestHarness { async fn build(mut self, node_id: &str) -> ElectionRunner { self.bindings.entry(node_id.to_string()).or_insert_with(|| default_binding(true)); + // Default to ephemeral ADNL so existing tests keep using `new_adnl_addr`. Tests that + // exercise the auto-generate path call `with_persist_capture` to opt back in. + if !self.elections_config.static_adnls.contains_key(node_id) + && !self.elections_config.static_adnl_disabled.contains(node_id) + && self.persisted_static_adnls.is_none() + { + self.elections_config.static_adnl_disabled.insert(node_id.to_string()); + } + let wallet: Arc = Arc::new(self.wallet_mock); let mut wallets: HashMap> = HashMap::new(); wallets.insert(node_id.to_string(), wallet); @@ -420,6 +444,19 @@ impl TestHarness { let elector: Arc = Arc::new(self.elector_mock); + let persist: Option = + self.persisted_static_adnls.clone().map(|store| { + let store = store.clone(); + let cb: PersistStaticAdnls = Arc::new(move |generated: HashMap| { + let mut g = store.lock().unwrap(); + for (k, v) in generated { + g.insert(k, v); + } + Ok(()) + }); + cb + }); + ElectionRunner::new( &self.elections_config, &self.bindings, @@ -427,6 +464,7 @@ impl TestHarness { providers, Arc::new(wallets), Arc::new(pools), + persist, ) } } @@ -1265,6 +1303,7 @@ async fn test_multiple_nodes_one_excluded() { sleep_period_pct: 0.0, waiting_period_pct: 0.3, static_adnls: HashMap::new(), + static_adnl_disabled: HashSet::from(["node-1".to_string(), "node-2".to_string()]), }; let mut bindings = HashMap::new(); @@ -1326,6 +1365,7 @@ async fn test_multiple_nodes_one_excluded() { providers, Arc::new(wallets), Arc::new(pools), + None, ); let result = runner.run().await; @@ -1703,6 +1743,7 @@ async fn test_node_without_wallet_skipped() { sleep_period_pct: 0.0, waiting_period_pct: 0.3, static_adnls: HashMap::new(), + static_adnl_disabled: HashSet::new(), }; let mut bindings = HashMap::new(); @@ -1725,6 +1766,7 @@ async fn test_node_without_wallet_skipped() { providers, Arc::new(wallets), Arc::new(pools), + None, ); assert!( @@ -3177,3 +3219,150 @@ async fn test_static_adnl_uses_register_instead_of_new() { let participant = node.participant.as_ref().unwrap(); assert_eq!(participant.adnl_addr, static_adnl.to_vec(), "ADNL must be the static one"); } + +// ===================================================== +// TEST: auto-generate static ADNL when missing +// ===================================================== + +#[tokio::test] +async fn test_static_adnl_auto_generated_when_missing() { + let node_id = "node-1"; + let generated_adnl = [0xCC_u8; 32]; + let generated_b64 = + base64::Engine::encode(&base64::engine::general_purpose::STANDARD, &generated_adnl); + + let (mut harness, persisted) = TestHarness::new().with_persist_capture(); + // static_adnls map is empty and node is NOT in static_adnl_disabled -> auto-generate path + setup_default_elector(&mut harness.elector_mock, ELECTION_ID, 0); + + // Default provider expectations, but replace new_adnl_addr with register_adnl_addr expectation + // since the runner should use the auto-generated static address. + harness.provider_mock.expect_election_parameters().returning(|| Ok(default_cfg15())); + harness.provider_mock.expect_validator_config().returning(|| Ok(ValidatorConfig::new())); + harness.provider_mock.expect_get_current_vset().returning(|| Err(anyhow::anyhow!("no vset"))); + harness.provider_mock.expect_get_next_vset().returning(|| Ok(None)); + harness + .provider_mock + .expect_new_validator_key() + .returning(|_since, _until| Ok((KEY_ID.to_vec(), PUB_KEY.to_vec()))); + harness.provider_mock.expect_export_public_key().returning(|_key_id| Ok(PUB_KEY.to_vec())); + harness.provider_mock.expect_sign().returning(|_key, _data| Ok(SIGNATURE.to_vec())); + harness.provider_mock.expect_account().returning(move |_addr| Ok(fake_account(WALLET_BALANCE))); + harness.provider_mock.expect_send_boc().returning(|_boc| Ok(())); + harness.provider_mock.expect_config_param_16().returning(|| Ok(default_cfg16())); + harness.provider_mock.expect_config_param_17().returning(|| Ok(default_cfg17())); + harness.provider_mock.expect_shutdown().returning(|| Ok(())); + + // generate_adnl_addr: exactly 1 call across the run + harness + .provider_mock + .expect_generate_adnl_addr() + .times(1) + .returning(move || Ok(generated_adnl.to_vec())); + + // register_adnl_addr: exactly 1 call with the generated key + let expected = generated_adnl.to_vec(); + harness + .provider_mock + .expect_register_adnl_addr() + .withf(move |adnl_key_id, _perm_key_id, _until| adnl_key_id == expected.as_slice()) + .times(1) + .returning(|_adnl, _perm, _until| Ok(())); + + // new_adnl_addr must NOT be called (no expectation set). + + setup_wallet(&mut harness.wallet_mock); + harness.wallet_mock.expect_message().returning(|_dest, _value, _payload| Ok(dummy_cell())); + + let mut runner = harness.build(node_id).await; + let result = runner.run().await; + assert!(result.is_ok(), "run() failed: {:?}", result.err()); + + let node = runner.nodes.get(node_id).unwrap(); + assert_eq!( + node.static_adnl_addr.as_deref(), + Some(generated_adnl.as_slice()), + "in-memory static_adnl_addr should be populated" + ); + let participant = node.participant.as_ref().expect("participant should be set"); + assert_eq!(participant.adnl_addr, generated_adnl.to_vec()); + + let saved = persisted.lock().unwrap(); + assert_eq!(saved.get(node_id), Some(&generated_b64), "address must be persisted via callback"); +} + +// ===================================================== +// TEST: static_adnl_disabled -> ephemeral path (new_adnl_addr) (SMA-87) +// ===================================================== + +#[tokio::test] +async fn test_static_adnl_disabled_uses_ephemeral() { + let node_id = "node-1"; + let mut harness = TestHarness::new(); + harness.elections_config.static_adnl_disabled.insert(node_id.to_string()); + + setup_default_elector(&mut harness.elector_mock, ELECTION_ID, 0); + // setup_default_provider already sets up new_adnl_addr returning ADNL_ADDR. + setup_default_provider(&mut harness.provider_mock, WALLET_BALANCE, None); + + // generate_adnl_addr must NOT be called for disabled nodes (no expectation). + // register_adnl_addr must NOT be called either (no expectation). + + setup_wallet(&mut harness.wallet_mock); + harness.wallet_mock.expect_message().returning(|_dest, _value, _payload| Ok(dummy_cell())); + + let mut runner = harness.build(node_id).await; + let result = runner.run().await; + assert!(result.is_ok(), "run() failed: {:?}", result.err()); + + let node = runner.nodes.get(node_id).unwrap(); + assert!(node.static_adnl_addr.is_none(), "disabled node must have no static_adnl_addr"); + let participant = node.participant.as_ref().expect("participant should be set"); + assert_eq!(participant.adnl_addr, ADNL_ADDR.to_vec(), "must use ephemeral ADNL"); +} + +// ===================================================== +// TEST: failed generation aborts node's tick but other nodes proceed (SMA-87) +// ===================================================== + +#[tokio::test] +async fn test_static_adnl_generation_failure_aborts_node_only() { + // One node fails to generate static ADNL; its run should fail-soft (no participant set), + // and nothing should be persisted via callback. + let node_id = "node-1"; + let (mut harness, persisted) = TestHarness::new().with_persist_capture(); + + setup_default_elector(&mut harness.elector_mock, ELECTION_ID, 0); + + harness.provider_mock.expect_election_parameters().returning(|| Ok(default_cfg15())); + harness.provider_mock.expect_validator_config().returning(|| Ok(ValidatorConfig::new())); + harness.provider_mock.expect_get_current_vset().returning(|| Err(anyhow::anyhow!("no vset"))); + harness.provider_mock.expect_get_next_vset().returning(|| Ok(None)); + harness + .provider_mock + .expect_new_validator_key() + .returning(|_since, _until| Ok((KEY_ID.to_vec(), PUB_KEY.to_vec()))); + harness.provider_mock.expect_export_public_key().returning(|_key_id| Ok(PUB_KEY.to_vec())); + harness.provider_mock.expect_account().returning(move |_addr| Ok(fake_account(WALLET_BALANCE))); + harness.provider_mock.expect_config_param_16().returning(|| Ok(default_cfg16())); + harness.provider_mock.expect_config_param_17().returning(|| Ok(default_cfg17())); + harness.provider_mock.expect_shutdown().returning(|| Ok(())); + + // generate_adnl_addr fails: ensure step logs and skips persistence. + harness + .provider_mock + .expect_generate_adnl_addr() + .times(1) + .returning(|| Err(anyhow::anyhow!("control server unreachable"))); + + setup_wallet(&mut harness.wallet_mock); + + let mut runner = harness.build(node_id).await; + let _ = runner.run().await; // node-level participation may fail; tick must not panic. + + let node = runner.nodes.get(node_id).unwrap(); + assert!(node.static_adnl_addr.is_none(), "no static_adnl after failed generation"); + assert!(node.participant.is_none(), "no participant when ADNL generation failed"); + let saved = persisted.lock().unwrap(); + assert!(saved.is_empty(), "nothing should be persisted on failure"); +} diff --git a/src/node-control/service/src/http/config_handlers.rs b/src/node-control/service/src/http/config_handlers.rs index 37dafe1b..cc5ed0e5 100644 --- a/src/node-control/service/src/http/config_handlers.rs +++ b/src/node-control/service/src/http/config_handlers.rs @@ -195,6 +195,10 @@ pub struct BindingElectionStatusDto { pub stake_policy: StakePolicy, #[serde(skip_serializing_if = "Option::is_none")] pub static_adnl: Option, + /// When true, the node opts out of the static ADNL default and gets a fresh + /// ephemeral ADNL address every election cycle. + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub static_adnl_disabled: bool, } #[derive(Clone, serde::Serialize, serde::Deserialize, utoipa::ToSchema)] @@ -1076,6 +1080,7 @@ fn build_binding_election_status( status: b.status, stake_policy: elections.stake_policy(name).clone(), static_adnl: elections.static_adnls.get(name).cloned(), + static_adnl_disabled: elections.static_adnl_disabled.contains(name), }) .collect(); result.sort_by(|a, b| a.name.cmp(&b.name)); @@ -2385,7 +2390,9 @@ pub async fn v1_elections_static_adnl_handler( .runtime_cfg .update_and_save(|cfg| { let elections = cfg.elections.get_or_insert_with(ElectionsConfig::default); - elections.static_adnls.insert(node, b64); + elections.static_adnls.insert(node.clone(), b64); + // Rotation re-enables the static ADNL default if it was previously opted out. + elections.static_adnl_disabled.remove(&node); }) .map_err(|e| AppError::internal(e.to_string()))?; state.config_changed.notify_one(); @@ -2393,3 +2400,43 @@ pub async fn v1_elections_static_adnl_handler( tracing::info!("node [{}] static ADNL address set: {}", node_name, adnl_b64); Ok(axum::Json(StaticAdnlResponse { ok: true, result: StaticAdnlDto { adnl_addr: adnl_b64 } })) } + +#[utoipa::path( + delete, + path = "/v1/elections/static-adnl/{node}", + params(("node" = String, Path, description = "Node name to opt out of static ADNL")), + responses( + (status = 200, description = "Static ADNL disabled for node", body = OkResponse), + (status = 400, description = "Invalid request", body = ApiErrorResponse), + (status = 401, description = "Not authenticated", body = ApiErrorResponse), + (status = 500, description = "Internal error", body = ApiErrorResponse) + ), + security(("bearerAuth" = [])) +)] +pub async fn v1_elections_static_adnl_disable_handler( + state: axum::extract::State, + axum::extract::Path(node_name): axum::extract::Path, +) -> Result, AppError> { + let cfg = state.runtime_cfg.get(); + if cfg.elections.is_none() { + return Err(AppError::bad_request("elections are not configured")); + } + if !cfg.nodes.contains_key(&node_name) { + return Err(AppError::bad_request(format!("node '{}' not found", node_name))); + } + drop(cfg); + + let node = node_name.clone(); + state + .runtime_cfg + .update_and_save(move |cfg| { + let elections = cfg.elections.get_or_insert_with(ElectionsConfig::default); + elections.static_adnls.remove(&node); + elections.static_adnl_disabled.insert(node); + }) + .map_err(|e| AppError::internal(e.to_string()))?; + state.config_changed.notify_one(); + + tracing::info!("node [{}] static ADNL disabled (ephemeral per cycle)", node_name); + Ok(axum::Json(OkResponse { ok: true })) +} diff --git a/src/node-control/service/src/http/http_server_task.rs b/src/node-control/service/src/http/http_server_task.rs index bc18fb27..89bfd078 100644 --- a/src/node-control/service/src/http/http_server_task.rs +++ b/src/node-control/service/src/http/http_server_task.rs @@ -201,6 +201,10 @@ pub(crate) fn routes(enable_swagger: bool, state: AppState) -> axum::Router { "/v1/elections/static-adnl", axum::routing::post(super::config_handlers::v1_elections_static_adnl_handler), ) + .route( + "/v1/elections/static-adnl/{node}", + axum::routing::delete(super::config_handlers::v1_elections_static_adnl_disable_handler), + ) .route("/v1/voting/proposals", axum::routing::post(v1_voting_proposals_add_handler)) .route("/v1/voting/proposals/{hash}", axum::routing::delete(v1_voting_proposals_rm_handler)) .route("/v1/task/elections", axum::routing::post(v1_task_elections_handler)) @@ -865,6 +869,7 @@ impl utoipa::Modify for BearerAuthAddon { super::config_handlers::v1_contracts_automation_settings_handler, super::config_handlers::v1_contracts_automation_settings_update_handler, super::config_handlers::v1_elections_static_adnl_handler, + super::config_handlers::v1_elections_static_adnl_disable_handler, // It won't compile without full names super::config_handlers::v1_nodes_handler, super::config_handlers::v1_nodes_add_handler, diff --git a/src/node-control/service/src/task/mod.rs b/src/node-control/service/src/task/mod.rs index 329d3a97..d12c1f4b 100644 --- a/src/node-control/service/src/task/mod.rs +++ b/src/node-control/service/src/task/mod.rs @@ -9,11 +9,8 @@ pub mod task_macro; pub mod task_manager; -use crate::{ - elections::election_task::BindingStatusCallback, runtime_config::RuntimeConfig, task, - task::task_manager::ServiceTask, -}; -use common::{app_config::AppConfig, snapshot::SnapshotStore, task_cancellation::CancellationCtx}; +use crate::{elections::election_task::BindingStatusCallback, runtime_config::RuntimeConfig, task}; +use common::snapshot::SnapshotStore; use std::sync::Arc; task!(VotingTask, crate::voting::voting_task::run { @@ -24,39 +21,8 @@ task!(ContractsTask, crate::contracts::contracts_task::run { runtime_cfg: Arc, }); -pub struct ElectionsTask { +task!(ElectionsTask, crate::elections::election_task::run { runtime_cfg: Arc, store: Arc, on_status_change: Option, -} - -impl ElectionsTask { - pub fn new( - runtime_cfg: Arc, - store: Arc, - on_status_change: Option, - ) -> Self { - Self { runtime_cfg, store, on_status_change } - } -} - -#[async_trait::async_trait] -impl ServiceTask for ElectionsTask { - async fn run( - &self, - cancellation_ctx: CancellationCtx, - _app_config: Arc, - ) -> anyhow::Result<()> { - crate::elections::election_task::run( - cancellation_ctx, - self.runtime_cfg.get(), - self.runtime_cfg.rpc_client(), - self.runtime_cfg.wallets(), - self.runtime_cfg.pools(), - self.store.clone(), - self.runtime_cfg.vault(), - self.on_status_change.clone(), - ) - .await - } -} +});