From 2d760d3cdea1aa8568bc4c4af344ae6ddbfd0261 Mon Sep 17 00:00:00 2001 From: mrnkslv Date: Mon, 30 Mar 2026 02:26:41 +0300 Subject: [PATCH 01/18] feat: added messages for ton_core_nominator --- .../src/ton_core_nominator/messages.rs | 220 ++++++++++++++++++ 1 file changed, 220 insertions(+) create mode 100644 src/node-control/contracts/src/ton_core_nominator/messages.rs diff --git a/src/node-control/contracts/src/ton_core_nominator/messages.rs b/src/node-control/contracts/src/ton_core_nominator/messages.rs new file mode 100644 index 0000000..bbcb37d --- /dev/null +++ b/src/node-control/contracts/src/ton_core_nominator/messages.rs @@ -0,0 +1,220 @@ +/* + * 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 ton_block::{BuilderData, Cell, Coins, IBitstring, Serializable}; + +/// Opcodes for nominator pool contract messages. +/// +/// `NEW_STAKE` and `RECOVER_STAKE` share the same opcodes and message format +/// as the single-nominator contract. Reuse `crate::nominator::new_stake` and +/// `crate::nominator::recover_stake` builders for those messages. +pub mod opcodes { + /// Send new stake to the elector (same as elector/SNP) + pub const NEW_STAKE: u32 = 0x4e73744b; + /// Recover stake from the elector (same as elector/SNP) + pub const RECOVER_STAKE: u32 = 0x47657424; + + // Pool-specific operations (sent as internal messages with query_id) + /// Accept coins (op = 1) + pub const ACCEPT_COINS: u32 = 1; + /// Process pending withdrawal requests (op = 2) + pub const PROCESS_WITHDRAW_REQUESTS: u32 = 2; + /// Emergency: process a single withdraw request (op = 3) + pub const EMERGENCY_WITHDRAW: u32 = 3; + /// Deposit validator funds (op = 4) + pub const DEPOSIT_VALIDATOR: u32 = 4; + /// Withdraw validator funds (op = 5) + pub const WITHDRAW_VALIDATOR: u32 = 5; + /// Update current validator set hash (op = 6, anyone can call) + pub const UPDATE_VALIDATOR_SET: u32 = 6; + /// Clean up outdated config proposal votings (op = 7) + pub const CLEANUP_VOTINGS: u32 = 7; +} + +/// Build "accept coins" message body (op = 1). +/// +/// Credits the attached message value to the pool balance. The contract only checks +/// opcode and `query_id`; TON amount is carried in the message, not in the body. +pub fn accept_coins(query_id: u64) -> anyhow::Result { + let mut builder = BuilderData::new(); + builder.append_u32(opcodes::ACCEPT_COINS)?.append_u64(query_id)?; + builder.into_cell() +} + +/// Build "process withdraw requests" message body. +/// +/// Tells the pool to process up to `limit` pending withdrawal requests. +pub fn process_withdraw_requests(query_id: u64, limit: u8) -> anyhow::Result { + let mut builder = BuilderData::new(); + builder + .append_u32(opcodes::PROCESS_WITHDRAW_REQUESTS)? + .append_u64(query_id)? + .append_u8(limit)?; + builder.into_cell() +} + +/// Build "emergency process withdraw request" message body (op = 3). +/// +/// Forces processing of a single nominator's withdraw request if the pool balance allows. +/// `request_address` is the nominator account id in basechain: 32 bytes (256 bits), same as +/// in `get_nominator_data` / `list_nominators` (without workchain prefix). +pub fn emergency_withdraw(query_id: u64, request_address: &[u8; 32]) -> anyhow::Result { + let mut builder = BuilderData::new(); + builder + .append_u32(opcodes::EMERGENCY_WITHDRAW)? + .append_u64(query_id)? + .append_raw(request_address, 256)?; + builder.into_cell() +} + +/// Build "update validator set" message body. +/// +/// Updates the saved validator set hash in the pool. +/// Can be sent by anyone; the pool checks config param 34 on-chain. +pub fn update_validator_set(query_id: u64) -> anyhow::Result { + let mut builder = BuilderData::new(); + builder + .append_u32(opcodes::UPDATE_VALIDATOR_SET)? + .append_u64(query_id)?; + builder.into_cell() +} + +/// Build "cleanup votings" message body. +/// +/// Removes config proposal votings older than 30 days. +pub fn cleanup_votings(query_id: u64) -> anyhow::Result { + let mut builder = BuilderData::new(); + builder + .append_u32(opcodes::CLEANUP_VOTINGS)? + .append_u64(query_id)?; + builder.into_cell() +} + +/// Build "deposit validator" message body. +/// +/// Validator sends coins to increase their own stake in the pool. +/// Attach the desired amount of TON to the message; 1 TON is deducted as a processing fee. +pub fn deposit_validator(query_id: u64) -> anyhow::Result { + let mut builder = BuilderData::new(); + builder + .append_u32(opcodes::DEPOSIT_VALIDATOR)? + .append_u64(query_id)?; + builder.into_cell() +} + +/// Build "withdraw validator" message body. +/// +/// Validator withdraws funds that do not belong to nominators. +/// Can only be called when pool state == 0 (not participating in validation). +pub fn withdraw_validator(query_id: u64, amount: u64) -> anyhow::Result { + let mut builder = BuilderData::new(); + builder + .append_u32(opcodes::WITHDRAW_VALIDATOR)? + .append_u64(query_id)?; + Coins::new(amount).write_to(&mut builder)?; + builder.into_cell() +} + +#[cfg(test)] +mod tests { + use super::*; + use ton_block::{Coins, Deserializable, SliceData}; + + #[test] + fn test_accept_coins() { + let query_id = 42u64; + + let cell = accept_coins(query_id).unwrap(); + let mut slice = SliceData::load_cell(cell).unwrap(); + + assert_eq!(slice.get_next_u32().unwrap(), opcodes::ACCEPT_COINS); + assert_eq!(slice.get_next_u64().unwrap(), query_id); + assert_eq!(slice.remaining_bits(), 0); + } + + #[test] + fn test_process_withdraw_requests() { + let query_id = 111u64; + let limit = 40u8; + + let cell = process_withdraw_requests(query_id, limit).unwrap(); + let mut slice = SliceData::load_cell(cell).unwrap(); + + assert_eq!(slice.get_next_u32().unwrap(), opcodes::PROCESS_WITHDRAW_REQUESTS); + assert_eq!(slice.get_next_u64().unwrap(), query_id); + assert_eq!(slice.get_next_byte().unwrap(), limit); + assert_eq!(slice.remaining_bits(), 0); + } + + #[test] + fn test_emergency_withdraw() { + let query_id = 99u64; + let addr = [0xABu8; 32]; + + let cell = emergency_withdraw(query_id, &addr).unwrap(); + let mut slice = SliceData::load_cell(cell).unwrap(); + + assert_eq!(slice.get_next_u32().unwrap(), opcodes::EMERGENCY_WITHDRAW); + assert_eq!(slice.get_next_u64().unwrap(), query_id); + let got = slice.get_next_bits(256).unwrap(); + assert_eq!(got.len(), 32); + assert_eq!(got, addr.to_vec()); + assert_eq!(slice.remaining_bits(), 0); + } + + #[test] + fn test_update_validator_set() { + let query_id = 222u64; + + let cell = update_validator_set(query_id).unwrap(); + let mut slice = SliceData::load_cell(cell).unwrap(); + + assert_eq!(slice.get_next_u32().unwrap(), opcodes::UPDATE_VALIDATOR_SET); + assert_eq!(slice.get_next_u64().unwrap(), query_id); + assert_eq!(slice.remaining_bits(), 0); + } + + #[test] + fn test_deposit_validator() { + let query_id = 333u64; + + let cell = deposit_validator(query_id).unwrap(); + let mut slice = SliceData::load_cell(cell).unwrap(); + + assert_eq!(slice.get_next_u32().unwrap(), opcodes::DEPOSIT_VALIDATOR); + assert_eq!(slice.get_next_u64().unwrap(), query_id); + assert_eq!(slice.remaining_bits(), 0); + } + + #[test] + fn test_withdraw_validator() { + let query_id = 444u64; + let amount = 5_000_000_000u64; + + let cell = withdraw_validator(query_id, amount).unwrap(); + let mut slice = SliceData::load_cell(cell).unwrap(); + + assert_eq!(slice.get_next_u32().unwrap(), opcodes::WITHDRAW_VALIDATOR); + assert_eq!(slice.get_next_u64().unwrap(), query_id); + let coins = Coins::construct_from(&mut slice).unwrap(); + assert_eq!(coins.as_u128(), amount as u128); + assert_eq!(slice.remaining_bits(), 0); + } + + #[test] + fn test_cleanup_votings() { + let query_id = 555u64; + + let cell = cleanup_votings(query_id).unwrap(); + let mut slice = SliceData::load_cell(cell).unwrap(); + + assert_eq!(slice.get_next_u32().unwrap(), opcodes::CLEANUP_VOTINGS); + assert_eq!(slice.get_next_u64().unwrap(), query_id); + assert_eq!(slice.remaining_bits(), 0); + } +} From 341f407402ca59e6ea271ce03ef89a7b87e5f1f7 Mon Sep 17 00:00:00 2001 From: mrnkslv Date: Tue, 31 Mar 2026 09:02:24 +0300 Subject: [PATCH 02/18] feat(core np): added NominatorPoolWrapperImpl --- .../src/ton_core_nominator/nominator_pool.rs | 294 ++++++++++++++++++ 1 file changed, 294 insertions(+) create mode 100644 src/node-control/contracts/src/ton_core_nominator/nominator_pool.rs diff --git a/src/node-control/contracts/src/ton_core_nominator/nominator_pool.rs b/src/node-control/contracts/src/ton_core_nominator/nominator_pool.rs new file mode 100644 index 0000000..f55837a --- /dev/null +++ b/src/node-control/contracts/src/ton_core_nominator/nominator_pool.rs @@ -0,0 +1,294 @@ +/* + * 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::nominator::{NominatorRoles, NominatorWrapper, PoolConfig, PoolData}; +use crate::{ContractProvider, SmartContract}; +use anyhow::Context; +use std::sync::Arc; +use ton_block::{ + BuilderData, Coins, IBitstring, MsgAddressInt, Serializable, + StateInit, read_single_root_boc, +}; + +/// Compiled code of the nominator-pool contract. +/// +/// Obtain by compiling the FunC source with `func` + `fift`: +/// + + +const CODE: &str = "b5ee9c7201023a010009c2000114ff00f4a413f4bcf2c80b0102016202030202ce0405020120131402012006070065421d749ab02705203aa008e23aa0303f00114a002a45301ba8e1323d74ac0019c5b01d430d020d749ab021270dede02e46c218047f3e09dbc400b434c0fe900c083e9100dc6c23c88c4cccc835d2708fe3c5200835c874c7cc2084139cdd12ee80b6cf2c38c02497c0f8b800f4c7f6cf1584b0002021081f09004f34c1c069b40830bffcb852483042b729be4830bffcb8524830443729b80830bfc870442c3cb852600330db3c5610c00193705711de104c103b4a98db3c085533db3c1f0c12042ce30f5540db3c105c104b103a497810561045103440330a0b0c0d03a257121110d30721c07922c06eb122c06423c077b121b1f2e04020b39e21d15616c000f2bd56152ebdf2bede22c064e30022c077925717e30d11168e1330041115040311140302111302571157115f03e30d0e0f1003341111d33f56165616db3ce30f0b11100b10bf10be10bd10bc10ab2122230028c88101001026cf0113cb0fcb0f01fa0201fa02c90104db3c1202d8810100561652a2f40e6fa120b3951112a41112de56122ebbf2e04182103b9aca0001111b01a120c200f2e042111a8e82db3c93307020e25613c0009401561aa094561aa001e25301a02cbef2e0432ad765755614b603aa00b609b9f2e04401db3c81010012561740bbf443082f2503a45611c0008f2156150410391028011118011111db3c015618a18212540be400be8e845613db3cde8ea3571781010056155292f40e6fa131f2e045c88101001256164099f4435613db3c4f0702e24f1f50770629303002fe5614c0ff56142dbab0b38e9d1114c000f2e07981010056135272f40e6fa1f2e07adb3c30c200f2e07b925714e211148020f00201d11113c079561356118307f40e6fa120b38e1982103b9aca005613d76595800f7aa984e401111801bef2e07b925717e2561695f404d31f3094306df823e25614228307f40e6fa131f2d07c2f11016cf82303c8ca0013cb1f021114018307f443c8f40001111201cb1f02011112010f8307f44311128e830ddb3c913de20c11100c10bf10bc30004a0cc8cb071bcb0f5009fa025007fa0215cc13f400f400cb1fcbffcb07cb1fcb1ff400c9ed540201201516020120191a0109bbf19db3c81f02016217180175af3bed9e2b882f87b6acc183fa0737d0f97042fa02183fc70fc0808029107a3e37d2904f816900698f98112cb781a802378101c8997100d9f32dc01f0109ac8b6d9e403302016e1b1c015dbbd05db3c57105f0f6d7f8e1f228307f47c6fa5208e1002f40431d31f3052106f0250036f02029132e201b3e6303181f0201201d1e0117ae3eed9e0837af8798b759c01f0276aa39db3c5f06509a5f096d7f8ea98101005230f47c6fa5208e9802db3c810100546380f40e6fa1312355206f0450036f02029132e201b3e6135f031f2f0244ab59db3c5f06509a5f098101002359f40e6fa1f2e056db3c8101004430f40e6fa1311f2f0154ed44d0d307d30ffa00fa00d401d0db3c05f404f404d31fd3ffd307d31fd31ff4043010bc10ab109a108920001c810100d701d30fd30ffa00fa0030001e01c0ff71f833d0810100d70358bab001e85b5712571257125712f8008210f96f732452e0ba8eb93b11117009a15380c1019a5088a020c100923727de8e16305305a8812710a9045301bc923020de5188a008a107e25077db3c270a11110a080a925712e22ac0018e198210ee6f454c52d0ba92703bde8210f374484c1dba92723ade913ce22404b85613c2005614c108b0821047657424561501bab182104e73744b561501bab1f2e0465613c001305613c0028f24d3071039102856180201111201db3c5619a18212540be400be8e845614db3cde11104870de5613c003e3005613c0062630272803ba707f8e988101005230f47c6fa5208e8702db3c3013a0029132e201b3e6306d7f8f378101005240f47c6fa5208f2602db3c25c2009f547715a98412a020c100923070de01dea070db3c8101005412015055f443029132e201b3e6145f042f2f25000ec858fa0201fa020172707f218eb0810100542270f47c6fa532218e9c3254411348705266db3c5217ba05a45304be927f36de103847634550de01b322b112e65f0401290268810100d7018101005462a0f40e6fa131f2e0474930185618011112db3c015619a18212540be400be8e845614db3cde1110487012293004d68f2024c103f2e071db3c6c21f9005360bd99343503a44413f823039130e25614db3cde5613c0078eb7f8237f8e2c56148307f47c6fa5208e1c02f40431d31f305230a18208278d00bc9a2011168307f45b301115de9132e201b3e65b5614db3cde821047657424561401ba3430302a03b2810100546550f40e6fa1f2bcdb3ca08212540be4005230a15210bc93306c14e0810100544666f45b30810100544655f45b3001a55124a182103b9aca005250be8f11705006db3c6d80101023102670db3c1023923434e243302f393804e08f3024c201f2e06f24c202f82325a124a63cbcb1f2e070821047657424c8cb1f5220cb3fc9db3c708018804010341023db3cde5613c0048e235616c0ff56162fbab0f2e04982103b9aca0001111901a120c200f2e04a51eea00e1118de5613c005925714e30d82104e73744b561301ba37382b2c04a85611c000f2e04a5616c0ff56162fbab0f2e04bfa0021c200f2e04e29db3c8212540be400561a01a101a15220bbf2e04c51f1a120c100923070de7f2fdb3c6d8010245970db3c561858a15619a18212540be400be2d39382e014e8e173005111605041115040311140302111302571157115f04e30d0f11100f10ef10de10cd10bc31013e707f8e988101005230f47c6fa5208e8702db3ca013a0029132e201b3e630312f011c8e841114db3c925714e20d11130d30000afa00fa00300114706d8010804072a0db3c3804d63e5f050fc0ff51e6ba1eb0f2e04e08c000f2e04f25f2e05082103b9aca001fbef2e05609fa0020db3c82103b9aca005230a18218746a5288005240bef2e0518212540be40001111001a15230bbf2e052535fbef2e0532edb3c5260bef2e0542d6ef2e05571db3c31f9007032333435001cd3ff31d31fd31f31d3ff31d431d100848028f833206e985b8218178411b200e0d0d30731fa00d31fd30fd30fd30f31d30f31d30fd30f305053a8ab075033a8ab075023a8ab0759a8ab075220a9b41fa0b60800268022f83320d0d30701c012f289d31fd31f3058035cdb3cdb3c1110c8cb1f1ccb3f5006cf16c9801871041110041038db3c0e11100e1f103e102d10bc107b50990743133637380022800ff833d0d31f31d31f31d31f31d70b1f011a71f833d0810100d7037f01db3c390048226eb32091719170e203c8cb055006cf165004fa02cb6a039358cc019130e201c901fb00001c74c8cb0212ca07810100cf01c9d0"; + +/// Pool is always deployed in the masterchain. +pub const POOL_WORKCHAIN: i32 = -1; + +/// Wrapper for the TON Nominator Pool contract. +/// +/// See: +/// +/// Unlike the single-nominator contract, this pool supports up to 40 nominators, +/// each depositing independently. The validator controls the pool via operational +/// messages (`new_stake`, `recover_stake`, `update_validator_set`, etc.). +/// +/// The `new_stake` / `recover_stake` message format is identical to the +/// single-nominator contract, so `crate::nominator::new_stake` and +/// `crate::nominator::recover_stake` builders can be reused as-is. +pub struct NominatorPoolWrapperImpl { + provider: Arc, + pool_addr: MsgAddressInt, + state_init: Option, +} + +impl NominatorPoolWrapperImpl { + /// Wrap an already-deployed pool at the given address. + pub fn new(provider: Arc, pool_addr: MsgAddressInt) -> Self { + Self { provider, pool_addr, state_init: None } + } + + /// Create a wrapper with deployment data (for pools that are not yet deployed). + /// + /// The pool address is derived deterministically from the `StateInit`. + pub fn from_init_data( + provider: Arc, + validator_address: &MsgAddressInt, + validator_reward_share: u16, + max_nominators_count: u16, + min_validator_stake: u64, + min_nominator_stake: u64, + ) -> anyhow::Result { + let state_init = Self::build_state_init( + validator_address, + validator_reward_share, + max_nominators_count, + min_validator_stake, + min_nominator_stake, + )?; + let pool_addr = Self::address_from_state_init(&state_init)?; + Ok(Self { provider, pool_addr, state_init: Some(state_init) }) + } + + /// Calculate the pool address from deployment parameters (without creating a wrapper). + pub fn calculate_address( + validator_address: &MsgAddressInt, + validator_reward_share: u16, + max_nominators_count: u16, + min_validator_stake: u64, + min_nominator_stake: u64, + ) -> anyhow::Result { + let state_init = Self::build_state_init( + validator_address, + validator_reward_share, + max_nominators_count, + min_validator_stake, + min_nominator_stake, + )?; + Self::address_from_state_init(&state_init) + } + + fn address_from_state_init(state_init: &StateInit) -> anyhow::Result { + let cell = state_init.write_to_new_cell()?.into_cell()?; + MsgAddressInt::with_params(POOL_WORKCHAIN, cell.hash(0)) + } + + /// Build the `StateInit` for deploying a new nominator pool. + /// + /// Data layout follows `save_data` / `load_data` in pool.fc: + /// ```text + /// state:8 nominators_count:16 stake_amount_sent:coins validator_amount:coins + /// config:^Cell nominators:dict withdraw_requests:dict + /// stake_at:32 saved_validator_set_hash:256 validator_set_changes_count:8 + /// validator_set_change_time:32 stake_held_for:32 config_proposal_votings:dict + /// ``` + pub fn build_state_init( + validator_address: &MsgAddressInt, + validator_reward_share: u16, + max_nominators_count: u16, + min_validator_stake: u64, + min_nominator_stake: u64, + ) -> anyhow::Result { + // --- config ref cell --- + // pack_config(validator_address, validator_reward_share, + // max_nominators_count, min_validator_stake, min_nominator_stake) + let mut config = BuilderData::new(); + let validator_hash = validator_address.address().get_bytestring(0); + anyhow::ensure!(validator_hash.len() == 32, "validator address must be 256 bits"); + config.append_raw(&validator_hash, 256)?; + config.append_raw(&validator_reward_share.to_be_bytes(), 16)?; + config.append_raw(&max_nominators_count.to_be_bytes(), 16)?; + Coins::new(min_validator_stake).write_to(&mut config)?; + Coins::new(min_nominator_stake).write_to(&mut config)?; + let config_cell = config.into_cell()?; + + // --- data cell --- + let mut data = BuilderData::new(); + data.append_u8(0)?; // state = 0 + data.append_raw(&0u16.to_be_bytes(), 16)?; // nominators_count = 0 + Coins::new(0u64).write_to(&mut data)?; // stake_amount_sent = 0 + Coins::new(0u64).write_to(&mut data)?; // validator_amount = 0 + data.checked_append_reference(config_cell)?; // config ref + data.append_bit_zero()?; // nominators = empty dict + data.append_bit_zero()?; // withdraw_requests = empty dict + data.append_u32(0)?; // stake_at + data.append_raw(&[0u8; 32], 256)?; // saved_validator_set_hash + data.append_u8(0)?; // validator_set_changes_count + data.append_u32(0)?; // validator_set_change_time + data.append_u32(0)?; // stake_held_for + data.append_bit_zero()?; // config_proposal_votings = empty dict + + let code = + read_single_root_boc(hex::decode(CODE).expect("nominator pool code hex is invalid"))?; + Ok(StateInit::with_code_and_data(code, data.into_cell()?)) + } +} + +#[async_trait::async_trait] +impl SmartContract for NominatorPoolWrapperImpl { + async fn balance(&self) -> anyhow::Result { + self.provider.balance(&self.pool_addr).await + } + + fn address(&self) -> MsgAddressInt { + self.pool_addr.clone() + } +} + +#[async_trait::async_trait] +impl NominatorWrapper for NominatorPoolWrapperImpl { + fn state_init(&self) -> Option { + self.state_init.clone() + } + + /// For the nominator pool there is no single "owner" — use the validator address + /// for both fields. The validator is the operational controller of the pool. + async fn get_roles(&self) -> anyhow::Result { + let pool_data = self.get_pool_data().await?; + let validator_address = MsgAddressInt::with_standart( + None, + POOL_WORKCHAIN as i8, + pool_data.pool_config.validator_addr.into(), + )?; + Ok(NominatorRoles { + owner_address: validator_address.clone(), + validator_address, + }) + } + + /// Parse the result of `get_pool_data` (17 flat values from `load_data`). + /// + /// Index mapping (0-based): + /// 0 state 8 min_nominator_stake + /// 1 nominators_count 9 nominators (cell, skip) + /// 2 stake_amount_sent 10 withdraw_requests (cell, skip) + /// 3 validator_amount 11 stake_at + /// 4 validator_address 12 saved_validator_set_hash + /// 5 validator_reward_share 13 validator_set_changes_count + /// 6 max_nominators_count 14 validator_set_change_time + /// 7 min_validator_stake 15 stake_held_for + /// 16 config_proposal_votings (cell, skip) + async fn get_pool_data(&self) -> anyhow::Result { + let stack = self + .provider + .get_method(self.pool_addr.to_string(), "get_pool_data", vec![]) + .await?; + + let state = stack.i64(0).context("parse state")? as i32; + let nominators_count = stack.i64(1).context("parse nominators_count")? as u32; + let stake_amount_sent = stack.i64(2).context("parse stake_amount_sent")? as u64; + let validator_amount = stack.i64(3).context("parse validator_amount")? as u64; + + let validator_addr = { + let mut array = [0u8; 32]; + array.copy_from_slice(&stack.number_bytes(4, 32).context("parse validator_addr")?); + array + }; + let validator_reward_share = stack.i64(5).context("parse validator_reward_share")? as u16; + let max_nominators_count = stack.i64(6).context("parse max_nominators_count")? as u16; + let min_validator_stake = stack.i64(7).context("parse min_validator_stake")? as u64; + // In the shared PoolConfig struct this field is named `max_nominators_stake` + // for SNP compatibility; for the nominator pool it represents `min_nominator_stake`. + let min_nominator_stake = stack.i64(8).context("parse min_nominator_stake")? as u64; + + // skip indices 9-10 (nominators, withdraw_requests) + + let stake_at = stack.i64(11).context("parse stake_at")? as u32; + let saved_validator_set_hash = { + let bytes = stack.number_bytes(12, 32).context("parse saved_validator_set_hash")?; + let mut array = [0u8; 32]; + array.copy_from_slice(&bytes); + array + }; + let validator_set_changes_count = + stack.i64(13).context("parse validator_set_changes_count")? as i32; + let validator_set_change_time = + stack.i64(14).context("parse validator_set_change_time")? as u64; + let stake_held_for = stack.i64(15).context("parse stake_held_for")? as u64; + + // skip index 16 (config_proposal_votings) + + Ok(PoolData { + state, + nominators_count, + stake_amount_sent, + validator_amount, + pool_config: PoolConfig { + validator_addr, + validator_reward_share, + max_nominators_count, + min_validator_stake, + max_nominators_stake: min_nominator_stake, + }, + stake_at, + saved_validator_set_hash, + validator_set_changes_count, + validator_set_change_time, + stake_held_for, + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::contract_provider; + use std::str::FromStr; + use ton_block::MsgAddressInt; + use ton_http_api_client::v2::client_json_rpc::ClientJsonRpc; + + fn open_pool() -> Option { + let pool_addr = + MsgAddressInt::from_str("kf-d42Dwn_dzfdwlV_aEeX7WWnJ-bBU_eZp6CfKoMb4vQ3t0") + .expect("Failed to parse pool address"); + let url = match std::env::var("TON_HTTP_API_URL") { + Ok(url) => url, + Err(_) => { + eprintln!("Skipping test: TON_HTTP_API_URL env variable not set"); + return None; + } + }; + + let client = ClientJsonRpc::connect(url, None).expect("Failed to connect to TON network"); + Some(NominatorPoolWrapperImpl::new(contract_provider!(Arc::new(client)), pool_addr)) + } + + #[tokio::test] + async fn test_get_pool_data() { + let Some(pool) = open_pool() else { + return; + }; + let data = pool.get_pool_data().await.expect("Failed to get pool data"); + assert!(data.pool_config.max_nominators_count <= 40); + } + + #[tokio::test] + async fn test_get_roles() { + let Some(pool) = open_pool() else { + return; + }; + let roles = pool.get_roles().await.expect("Failed to get roles"); + assert_eq!(roles.owner_address, roles.validator_address); + } +} From 113d706e9eec5da7528b31afeda245b7fd197415 Mon Sep 17 00:00:00 2001 From: mrnkslv Date: Tue, 31 Mar 2026 09:14:28 +0300 Subject: [PATCH 03/18] feat(core np): created mod ton_core_nominator --- src/node-control/contracts/src/lib.rs | 2 ++ .../contracts/src/ton_core_nominator.rs | 14 ++++++++++++++ .../{nominator_pool.rs => ton_core_nominator.rs} | 0 3 files changed, 16 insertions(+) create mode 100644 src/node-control/contracts/src/ton_core_nominator.rs rename src/node-control/contracts/src/ton_core_nominator/{nominator_pool.rs => ton_core_nominator.rs} (100%) diff --git a/src/node-control/contracts/src/lib.rs b/src/node-control/contracts/src/lib.rs index 99dd503..5daf242 100644 --- a/src/node-control/contracts/src/lib.rs +++ b/src/node-control/contracts/src/lib.rs @@ -9,6 +9,7 @@ pub mod config_contract; pub mod elector; pub mod nominator; +pub mod ton_core_nominator; pub mod provider; pub mod smart_contract; mod stack_utils; @@ -19,6 +20,7 @@ pub use config_contract::{ }; pub use elector::{ElectionsInfo, ElectorWrapper, ElectorWrapperImpl, Participant}; pub use nominator::{NOMINATOR_POOL_WORKCHAIN, NominatorWrapper, NominatorWrapperImpl}; +pub use ton_core_nominator::NominatorPoolWrapperImpl; pub use provider::ContractProvider; pub use smart_contract::SmartContract; pub use wallet::{TonWallet, WalletContract}; diff --git a/src/node-control/contracts/src/ton_core_nominator.rs b/src/node-control/contracts/src/ton_core_nominator.rs new file mode 100644 index 0000000..677a061 --- /dev/null +++ b/src/node-control/contracts/src/ton_core_nominator.rs @@ -0,0 +1,14 @@ +/* + * 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. + */ +/// Internal messages for nominator pool contract +pub mod messages; +/// Nominator pool contract implementation (wrapper, deploy state init, RPC). +mod ton_core_nominator; + +pub use ton_core_nominator::NominatorPoolWrapperImpl; diff --git a/src/node-control/contracts/src/ton_core_nominator/nominator_pool.rs b/src/node-control/contracts/src/ton_core_nominator/ton_core_nominator.rs similarity index 100% rename from src/node-control/contracts/src/ton_core_nominator/nominator_pool.rs rename to src/node-control/contracts/src/ton_core_nominator/ton_core_nominator.rs From 175b02fce8380a7332cdb551f2282ed601ce9019 Mon Sep 17 00:00:00 2001 From: mrnkslv Date: Tue, 31 Mar 2026 10:46:13 +0300 Subject: [PATCH 04/18] feat(core np): configs done --- src/node-control/README.md | 7 +- .../src/commands/nodectl/config_pool_cmd.rs | 126 ++++++++++++++---- .../src/commands/nodectl/config_wallet_cmd.rs | 49 ++++++- .../src/commands/nodectl/deploy_cmd.rs | 102 ++++++++++++-- src/node-control/common/src/app_config.rs | 43 +++++- src/node-control/contracts/src/lib.rs | 2 +- .../contracts/src/ton_core_nominator.rs | 4 +- .../ton_core_nominator/ton_core_nominator.rs | 19 +++ .../service/src/runtime_config.rs | 53 +++++++- 9 files changed, 358 insertions(+), 47 deletions(-) diff --git a/src/node-control/README.md b/src/node-control/README.md index 364d127..5a19cee 100644 --- a/src/node-control/README.md +++ b/src/node-control/README.md @@ -1349,8 +1349,11 @@ Nominator pool configurations. Two pool types are supported: **TONCore Pool:** - `kind` — `"core"` -- `addresses` — array of exactly 2 pool addresses -- `validator_share` — validator share percentage +- `addresses` — two addresses: validator wallet (`[0]`) and pool contract (`[1]`, must match the address derived from the parameters below) +- `validator_share` — validator reward share (basis points; stored as `u16` on-chain) +- `max_nominators` — optional; if omitted, `contracts` `resolve_deploy_pool_params` uses the default next to the pool contract +- `min_validator_stake` — optional (nanotons); same +- `min_nominator_stake` — optional (nanotons); same #### `bindings` diff --git a/src/node-control/commands/src/commands/nodectl/config_pool_cmd.rs b/src/node-control/commands/src/commands/nodectl/config_pool_cmd.rs index 3e50df8..6dc437c 100644 --- a/src/node-control/commands/src/commands/nodectl/config_pool_cmd.rs +++ b/src/node-control/commands/src/commands/nodectl/config_pool_cmd.rs @@ -17,12 +17,21 @@ use common::{ app_config::{AppConfig, PoolConfig}, ton_utils::display_tons, }; -use contracts::{NOMINATOR_POOL_WORKCHAIN, NominatorWrapperImpl}; +use contracts::{NOMINATOR_POOL_WORKCHAIN, NominatorWrapperImpl, resolve_deploy_pool_params}; use secrets_vault::{vault::SecretVault, vault_builder::SecretVaultBuilder}; use std::{path::Path, str::FromStr, sync::Arc}; use ton_block::{ADDR_FORMAT_BOUNCE, ADDR_FORMAT_URL_SAFE, MsgAddressInt}; use ton_http_api_client::v2::client_json_rpc::ClientJsonRpc; +#[derive(Clone, Copy, Debug, Default, clap::ValueEnum)] +enum PoolAddKind { + /// Single Nominator Pool (`kind: "snp"` in config) + #[default] + Snp, + /// Nominator Pool (`kind: "core"` / `PoolConfig::TONCore`) + Core, +} + #[derive(clap::Args, Clone)] #[command(about = "Manage pools in the configuration")] pub struct PoolCmd { @@ -45,18 +54,38 @@ pub enum PoolAction { pub struct PoolAddCmd { #[arg(short = 'n', long = "name", help = "Pool name (unique identifier)")] name: String, + #[arg(long = "kind", value_enum, default_value_t = PoolAddKind::Snp, help = "snp or core")] + kind: PoolAddKind, #[arg( short = 'a', long = "address", - help = "Pool contract address, raw or base64url (if already deployed)" + help = "SNP: pool contract address, raw or base64url (if already deployed)" )] address: Option, #[arg( short = 'o', long = "owner", - help = "Owner address, raw or base64url (for deployment/verification)" + help = "SNP: owner address, raw or base64url (for deployment/verification)" )] owner: Option, + #[arg(long = "addr1", help = "Core: addresses[0] (with --kind core)")] + addr1: Option, + #[arg(long = "addr2", help = "Core: addresses[1] (with --kind core)")] + addr2: Option, + #[arg(long = "validator-share", help = "Core: validator_share, basis points (with --kind core)")] + validator_share: Option, + #[arg(long = "max-nominators", help = "Core: max nominators (default: 40)")] + max_nominators: Option, + #[arg( + long = "min-validator-stake-nano", + help = "Core: min validator stake in nanotons (embedded at deploy)" + )] + min_validator_stake_nano: Option, + #[arg( + long = "min-nominator-stake-nano", + help = "Core: min nominator stake in nanotons (embedded at deploy)" + )] + min_nominator_stake_nano: Option, } #[derive(clap::Args, Clone)] @@ -85,18 +114,6 @@ impl PoolCmd { impl PoolAddCmd { pub async fn run(&self, path: &Path) -> anyhow::Result<()> { - if self.address.is_none() && self.owner.is_none() { - anyhow::bail!("At least one of --address or --owner must be specified"); - } - - let normalized_address = self - .address - .as_deref() - .map(|addr| normalize_ton_address(addr, "address")) - .transpose()?; - let normalized_owner = - self.owner.as_deref().map(|owner| normalize_ton_address(owner, "owner")).transpose()?; - let mut config = AppConfig::load(path)?; if config.pools.contains_key(&self.name) { @@ -106,19 +123,76 @@ impl PoolAddCmd { ); } - let pool_config = PoolConfig::SNP { - address: normalized_address.clone(), - owner: normalized_owner.clone(), + let (pool_config, info) = match self.kind { + PoolAddKind::Snp => { + if self.address.is_none() && self.owner.is_none() { + anyhow::bail!("For SNP: at least one of --address or --owner must be specified"); + } + + let normalized_address = self + .address + .as_deref() + .map(|addr| normalize_ton_address(addr, "address")) + .transpose()?; + let normalized_owner = + self.owner.as_deref().map(|owner| normalize_ton_address(owner, "owner")).transpose()?; + + let info = match (&normalized_address, &normalized_owner) { + (Some(a), Some(o)) => format!("kind=snp address='{}', owner='{}'", a, o), + (Some(a), None) => format!("kind=snp address='{}'", a), + (None, Some(o)) => format!("kind=snp owner='{}' (address will be calculated on bind)", o), + _ => unreachable!(), + }; + + ( + PoolConfig::SNP { + address: normalized_address.clone(), + owner: normalized_owner.clone(), + }, + info, + ) + } + PoolAddKind::Core => { + let addr1 = self + .addr1 + .as_deref() + .ok_or_else(|| anyhow::anyhow!("For core: --addr1 is required"))?; + let addr2 = self + .addr2 + .as_deref() + .ok_or_else(|| anyhow::anyhow!("For core: --addr2 is required"))?; + let share = self + .validator_share + .ok_or_else(|| anyhow::anyhow!("For core: --validator-share is required"))?; + + let a1 = normalize_ton_address(addr1, "addr1")?; + let a2 = normalize_ton_address(addr2, "addr2")?; + + let (mx, mv, mn) = resolve_deploy_pool_params( + self.max_nominators, + self.min_validator_stake_nano, + self.min_nominator_stake_nano, + ); + let info = format!( + "kind=core addresses=['{}', '{}'], validator_share={} (basis points), deploy_params_resolved: max_nominators={}, min_validator_stake_nano={}, min_nominator_stake_nano={} (omitted fields use contract defaults)", + a1, a2, share, mx, mv, mn + ); + + ( + PoolConfig::TONCore { + addresses: [a1, a2], + validator_share: share, + max_nominators: self.max_nominators, + min_validator_stake: self.min_validator_stake_nano, + min_nominator_stake: self.min_nominator_stake_nano, + }, + info, + ) + } }; + config.pools.insert(self.name.clone(), pool_config); save_config(&config, path)?; - - let info = match (&normalized_address, &normalized_owner) { - (Some(a), Some(o)) => format!("address='{}', owner='{}'", a, o), - (Some(a), None) => format!("address='{}'", a), - (None, Some(o)) => format!("owner='{}' (address will be calculated on bind)", o), - _ => unreachable!(), - }; println!("\n{} Pool '{}' added ({})\n", "OK".green().bold(), self.name, info); Ok(()) } @@ -225,7 +299,7 @@ async fn collect_pool_views( validator_share: None, }); } - PoolConfig::TONCore { addresses, validator_share } => { + PoolConfig::TONCore { addresses, validator_share, .. } => { views.push(PoolView { name: name.clone(), kind: "Core".to_string(), diff --git a/src/node-control/commands/src/commands/nodectl/config_wallet_cmd.rs b/src/node-control/commands/src/commands/nodectl/config_wallet_cmd.rs index 2d57bc2..c4bd39c 100644 --- a/src/node-control/commands/src/commands/nodectl/config_wallet_cmd.rs +++ b/src/node-control/commands/src/commands/nodectl/config_wallet_cmd.rs @@ -24,8 +24,8 @@ use common::{ ton_utils::{display_tons, tons_f64_to_nanotons}, }; use contracts::{ - ElectorWrapper, ElectorWrapperImpl, NominatorWrapperImpl, TonWallet, contract_provider, - nominator, + ElectorWrapper, ElectorWrapperImpl, NominatorPoolWrapperImpl, NominatorWrapperImpl, TonWallet, + contract_provider, nominator, resolve_deploy_pool_params, }; use elections::providers::{DefaultElectionsProvider, ElectionsProvider}; use secrets_vault::{errors::error::VaultError, vault::SecretVault}; @@ -705,6 +705,49 @@ fn resolve_pool_address( } (None, None) => anyhow::bail!("Pool has neither address nor owner configured"), }, - _ => anyhow::bail!("Unsupported pool kind for manual stake"), + PoolConfig::TONCore { + addresses, + validator_share, + max_nominators, + min_validator_stake, + min_nominator_stake, + } => { + let configured_validator = addresses[0] + .parse::() + .context("invalid TONCore addresses[0]")?; + if configured_validator != *validator_addr { + anyhow::bail!( + "TONCore addresses[0] must match validator wallet for manual stake (expected {}, got {})", + validator_addr, + addresses[0] + ); + } + let reward_share = u16::try_from(*validator_share) + .map_err(|_| anyhow::anyhow!("validator_share must fit in u16 (0..=65535)"))?; + let (max_n, min_v, min_n) = resolve_deploy_pool_params( + max_nominators.as_ref().copied(), + min_validator_stake.as_ref().copied(), + min_nominator_stake.as_ref().copied(), + ); + let calculated = NominatorPoolWrapperImpl::calculate_address( + &configured_validator, + reward_share, + max_n, + min_v, + min_n, + )?; + // addresses[1] must be the pool contract (same derivation as `nodectl deploy pool`). + let explicit = addresses[1] + .parse::() + .context("invalid TONCore pool contract address (addresses[1])")?; + if explicit != calculated { + anyhow::bail!( + "TONCore addresses[1] ({}) does not match pool address derived from addresses[0] and validator_share ({})", + explicit, + calculated + ); + } + Ok(explicit) + } } } diff --git a/src/node-control/commands/src/commands/nodectl/deploy_cmd.rs b/src/node-control/commands/src/commands/nodectl/deploy_cmd.rs index a1efe21..8e49063 100644 --- a/src/node-control/commands/src/commands/nodectl/deploy_cmd.rs +++ b/src/node-control/commands/src/commands/nodectl/deploy_cmd.rs @@ -12,11 +12,12 @@ use crate::commands::nodectl::utils::{ use colored::Colorize; use common::{ TonWalletVersion, + app_config::PoolConfig, task_cancellation::CancellationCtx, ton_utils::{nanotons_to_tons_f64, tons_f64_to_nanotons}, }; -use contracts::{NominatorWrapperImpl, TonWallet}; -use std::{cell::RefCell, collections::HashMap, path::Path, rc::Rc, sync::Arc}; +use contracts::{NominatorPoolWrapperImpl, NominatorWrapperImpl, TonWallet, resolve_deploy_pool_params}; +use std::{cell::RefCell, collections::HashMap, path::Path, rc::Rc, str::FromStr, sync::Arc}; use ton_block::{Cell, MsgAddressInt, write_boc}; use ton_http_api_client::v2::data_models::AccountState; @@ -70,8 +71,16 @@ struct DeployPoolCmd { #[arg(long = "verbose", help = "Print progress", required = false)] verbose: bool, - #[arg(long = "owner", help = "Address of the pool owner")] - owner: MsgAddressInt, + #[arg( + long = "owner", + help = "SNP: pool owner address (required unless deploying a `kind: core` pool)" + )] + owner: Option, + #[arg( + long = "pool", + help = "Pool name in config (defaults to the `pool` field of this node's binding)" + )] + pool: Option, #[arg(long = "amount", help = "Amount of TONs to transfer")] amount: f64, #[arg(long = "node", help = "Node ID")] @@ -306,9 +315,70 @@ impl DeployPoolCmd { anyhow::bail!("Task cancelled"); } - let pool_address = - NominatorWrapperImpl::calculate_address(-1, &self.owner, &wallet_address) + let pool_cfg_opt = self + .pool + .as_ref() + .or_else(|| config.bindings.get(&self.node).and_then(|b| b.pool.as_ref())) + .and_then(|name| config.pools.get(name)); + + let (pool_address, state_init) = match pool_cfg_opt { + Some(PoolConfig::TONCore { + addresses, + validator_share, + max_nominators, + min_validator_stake, + min_nominator_stake, + }) => { + let validator_addr = + MsgAddressInt::from_str(addresses[0].as_str()).map_err(set_err)?; + if validator_addr != wallet_address { + return Err(set_err(anyhow::anyhow!( + "TONCore addresses[0] must match this node's wallet (expected {}, config has {})", + wallet_address, + addresses[0] + ))); + } + let reward_share = u16::try_from(*validator_share).map_err(|e| { + set_err(anyhow::anyhow!("validator_share must fit in u16: {}", e)) + })?; + let (max_n, min_v, min_n) = resolve_deploy_pool_params( + max_nominators.as_ref().copied(), + min_validator_stake.as_ref().copied(), + min_nominator_stake.as_ref().copied(), + ); + let pool_address = NominatorPoolWrapperImpl::calculate_address( + &validator_addr, + reward_share, + max_n, + min_v, + min_n, + ) + .map_err(set_err)?; + let state_init = NominatorPoolWrapperImpl::build_state_init( + &validator_addr, + reward_share, + max_n, + min_v, + min_n, + ) .map_err(set_err)?; + (pool_address, state_init) + } + Some(PoolConfig::SNP { .. }) | None => { + let owner = self.owner.as_ref().ok_or_else(|| { + set_err(anyhow::anyhow!( + "SNP deploy requires --owner (set `pool` to a `kind: core` entry for TON Nominator Pool deploy)" + )) + })?; + let pool_address = + NominatorWrapperImpl::calculate_address(-1, owner, &wallet_address) + .map_err(set_err)?; + let state_init = + NominatorWrapperImpl::build_state_init(owner, &wallet_address).map_err(set_err)?; + (pool_address, state_init) + } + }; + res.borrow_mut().address = pool_address.to_string(); if self.verbose { @@ -333,10 +403,20 @@ impl DeployPoolCmd { } if self.verbose { - println!( - "Deploy Single Nominator Pool: owner={}, wallet={} ...", - self.owner, wallet_address - ); + match pool_cfg_opt { + Some(PoolConfig::TONCore { addresses, validator_share, .. }) => println!( + "Deploy TON Nominator Pool (core): validator={}, validator_share={} (addresses[1]={}) ...", + addresses[0], validator_share, addresses[1] + ), + _ => { + if let Some(owner) = self.owner.as_ref() { + println!( + "Deploy Single Nominator Pool: owner={}, wallet={} ...", + owner, wallet_address + ); + } + } + } } if wallet_info.account_state != AccountState::Active { @@ -367,7 +447,7 @@ impl DeployPoolCmd { false, wallet_info.seqno, None, - Some(NominatorWrapperImpl::build_state_init(&self.owner, &wallet_address)?), + Some(state_init), ) .await .map_err(set_err)?, diff --git a/src/node-control/common/src/app_config.rs b/src/node-control/common/src/app_config.rs index 4fe7c2e..278662a 100644 --- a/src/node-control/common/src/app_config.rs +++ b/src/node-control/common/src/app_config.rs @@ -338,7 +338,20 @@ pub enum PoolConfig { owner: Option, }, #[serde(rename = "core")] - TONCore { addresses: [String; 2], validator_share: u64 }, + TONCore { + addresses: [String; 2], + validator_share: u64, + /// Deploy-time pool parameters; if omitted, defaults are applied in `contracts` (`resolve_deploy_pool_params`). + #[serde(default)] + #[serde(skip_serializing_if = "Option::is_none")] + max_nominators: Option, + #[serde(default)] + #[serde(skip_serializing_if = "Option::is_none")] + min_validator_stake: Option, + #[serde(default)] + #[serde(skip_serializing_if = "Option::is_none")] + min_nominator_stake: Option, + }, } #[derive(serde::Serialize, serde::Deserialize, Clone)] @@ -826,6 +839,9 @@ mod tests { PoolConfig::TONCore { addresses: [addr1.to_string(), addr2.to_string()], validator_share: 50, + max_nominators: None, + min_validator_stake: None, + min_nominator_stake: None, } ); @@ -834,6 +850,31 @@ mod tests { assert_eq!(json["validator_share"], 50); } + #[test] + fn test_pool_config_serde_core_explicit_deploy_params() { + let addr1 = ADDR; + let addr2 = OWNER; + let value = serde_json::json!({ + "kind": "core", + "addresses": [addr1.to_string(), addr2.to_string()], + "validator_share": 100, + "max_nominators": 10, + "min_validator_stake": 5_000_000_000_000u64, + "min_nominator_stake": 1_000_000_000_000u64, + }); + let cfg: PoolConfig = serde_json::from_value(value).unwrap(); + assert_eq!( + cfg, + PoolConfig::TONCore { + addresses: [addr1.to_string(), addr2.to_string()], + validator_share: 100, + max_nominators: Some(10), + min_validator_stake: Some(5_000_000_000_000), + min_nominator_stake: Some(1_000_000_000_000), + } + ); + } + #[test] fn test_binding_status_serde_roundtrip() { for status in [ diff --git a/src/node-control/contracts/src/lib.rs b/src/node-control/contracts/src/lib.rs index 5daf242..8d17cc2 100644 --- a/src/node-control/contracts/src/lib.rs +++ b/src/node-control/contracts/src/lib.rs @@ -20,7 +20,7 @@ pub use config_contract::{ }; pub use elector::{ElectionsInfo, ElectorWrapper, ElectorWrapperImpl, Participant}; pub use nominator::{NOMINATOR_POOL_WORKCHAIN, NominatorWrapper, NominatorWrapperImpl}; -pub use ton_core_nominator::NominatorPoolWrapperImpl; +pub use ton_core_nominator::{NominatorPoolWrapperImpl, resolve_deploy_pool_params}; pub use provider::ContractProvider; pub use smart_contract::SmartContract; pub use wallet::{TonWallet, WalletContract}; diff --git a/src/node-control/contracts/src/ton_core_nominator.rs b/src/node-control/contracts/src/ton_core_nominator.rs index 677a061..23b6ea1 100644 --- a/src/node-control/contracts/src/ton_core_nominator.rs +++ b/src/node-control/contracts/src/ton_core_nominator.rs @@ -11,4 +11,6 @@ pub mod messages; /// Nominator pool contract implementation (wrapper, deploy state init, RPC). mod ton_core_nominator; -pub use ton_core_nominator::NominatorPoolWrapperImpl; +pub use ton_core_nominator::{ + NominatorPoolWrapperImpl, resolve_deploy_pool_params, +}; diff --git a/src/node-control/contracts/src/ton_core_nominator/ton_core_nominator.rs b/src/node-control/contracts/src/ton_core_nominator/ton_core_nominator.rs index f55837a..c440386 100644 --- a/src/node-control/contracts/src/ton_core_nominator/ton_core_nominator.rs +++ b/src/node-control/contracts/src/ton_core_nominator/ton_core_nominator.rs @@ -26,6 +26,25 @@ const CODE: &str = "b5ee9c7201023a010009c2000114ff00f4a413f4bcf2c80b010201620203 /// Pool is always deployed in the masterchain. pub const POOL_WORKCHAIN: i32 = -1; +/// Deploy-time parameters for `build_state_init` when the app config omits them. +pub const DEFAULT_DEPLOY_MAX_NOMINATORS: u16 = 40; +pub const DEFAULT_DEPLOY_MIN_VALIDATOR_STAKE_NANOTONS: u64 = 100_000_000_000_000; +pub const DEFAULT_DEPLOY_MIN_NOMINATOR_STAKE_NANOTONS: u64 = 10_000_000_000_000; + +/// Resolve deploy parameters for address derivation and `StateInit` (defaults from this module). +#[must_use] +pub fn resolve_deploy_pool_params( + max_nominators: Option, + min_validator_stake: Option, + min_nominator_stake: Option, +) -> (u16, u64, u64) { + ( + max_nominators.unwrap_or(DEFAULT_DEPLOY_MAX_NOMINATORS), + min_validator_stake.unwrap_or(DEFAULT_DEPLOY_MIN_VALIDATOR_STAKE_NANOTONS), + min_nominator_stake.unwrap_or(DEFAULT_DEPLOY_MIN_NOMINATOR_STAKE_NANOTONS), + ) +} + /// Wrapper for the TON Nominator Pool contract. /// /// See: diff --git a/src/node-control/service/src/runtime_config.rs b/src/node-control/service/src/runtime_config.rs index 45be384..81775e1 100644 --- a/src/node-control/service/src/runtime_config.rs +++ b/src/node-control/service/src/runtime_config.rs @@ -13,7 +13,8 @@ use common::{ vault_signer::VaultSigner, }; use contracts::{ - NominatorWrapper, NominatorWrapperImpl, TonWallet, WalletContract, contract_provider, + NominatorPoolWrapperImpl, NominatorWrapper, NominatorWrapperImpl, TonWallet, WalletContract, + contract_provider, resolve_deploy_pool_params, }; use secrets_vault::{ types::{algorithm::Algorithm, secret_id::SecretId, secret_spec::SecretSpec}, @@ -514,6 +515,54 @@ fn open_nominator_pool( }; Ok(Arc::new(pool)) } - _ => anyhow::bail!("unsupported pool kind"), + PoolConfig::TONCore { + addresses, + validator_share, + max_nominators, + min_validator_stake, + min_nominator_stake, + } => { + let configured_validator = MsgAddressInt::from_str(addresses[0].as_str()) + .context(format!("invalid TONCore addresses[0]: {}", addresses[0]))?; + if configured_validator != *validator_addr { + anyhow::bail!( + "TONCore addresses[0] must match validator wallet (expected {}, got {})", + validator_addr, + addresses[0] + ); + } + let reward_share = u16::try_from(*validator_share) + .map_err(|_| anyhow::anyhow!("validator_share must fit in u16 (0..=65535)"))?; + let (max_n, min_v, min_n) = resolve_deploy_pool_params( + max_nominators.as_ref().copied(), + min_validator_stake.as_ref().copied(), + min_nominator_stake.as_ref().copied(), + ); + let calculated = NominatorPoolWrapperImpl::calculate_address( + &configured_validator, + reward_share, + max_n, + min_v, + min_n, + )?; + let explicit = MsgAddressInt::from_str(addresses[1].as_str()) + .context(format!("invalid TONCore addresses[1]: {}", addresses[1]))?; + if explicit != calculated { + anyhow::bail!( + "TONCore addresses[1] ({}) does not match pool address derived from addresses[0] and validator_share ({})", + explicit, + calculated + ); + } + let pool = NominatorPoolWrapperImpl::from_init_data( + contract_provider!(rpc_client.clone()), + &configured_validator, + reward_share, + max_n, + min_v, + min_n, + )?; + Ok(Arc::new(pool)) + } } } From 6396945523227a161a4f53b05b576c3f49ef9737 Mon Sep 17 00:00:00 2001 From: mrnkslv Date: Tue, 31 Mar 2026 10:53:47 +0300 Subject: [PATCH 05/18] feat: formatting --- .../src/commands/nodectl/config_pool_cmd.rs | 20 ++++++++++++++----- .../src/commands/nodectl/config_wallet_cmd.rs | 5 ++--- .../src/commands/nodectl/deploy_cmd.rs | 8 +++++--- src/node-control/contracts/src/lib.rs | 4 ++-- .../contracts/src/ton_core_nominator.rs | 4 +--- .../src/ton_core_nominator/messages.rs | 16 ++++----------- .../ton_core_nominator/ton_core_nominator.rs | 20 ++++++------------- 7 files changed, 35 insertions(+), 42 deletions(-) diff --git a/src/node-control/commands/src/commands/nodectl/config_pool_cmd.rs b/src/node-control/commands/src/commands/nodectl/config_pool_cmd.rs index 6dc437c..7539094 100644 --- a/src/node-control/commands/src/commands/nodectl/config_pool_cmd.rs +++ b/src/node-control/commands/src/commands/nodectl/config_pool_cmd.rs @@ -72,7 +72,10 @@ pub struct PoolAddCmd { addr1: Option, #[arg(long = "addr2", help = "Core: addresses[1] (with --kind core)")] addr2: Option, - #[arg(long = "validator-share", help = "Core: validator_share, basis points (with --kind core)")] + #[arg( + long = "validator-share", + help = "Core: validator_share, basis points (with --kind core)" + )] validator_share: Option, #[arg(long = "max-nominators", help = "Core: max nominators (default: 40)")] max_nominators: Option, @@ -126,7 +129,9 @@ impl PoolAddCmd { let (pool_config, info) = match self.kind { PoolAddKind::Snp => { if self.address.is_none() && self.owner.is_none() { - anyhow::bail!("For SNP: at least one of --address or --owner must be specified"); + anyhow::bail!( + "For SNP: at least one of --address or --owner must be specified" + ); } let normalized_address = self @@ -134,13 +139,18 @@ impl PoolAddCmd { .as_deref() .map(|addr| normalize_ton_address(addr, "address")) .transpose()?; - let normalized_owner = - self.owner.as_deref().map(|owner| normalize_ton_address(owner, "owner")).transpose()?; + let normalized_owner = self + .owner + .as_deref() + .map(|owner| normalize_ton_address(owner, "owner")) + .transpose()?; let info = match (&normalized_address, &normalized_owner) { (Some(a), Some(o)) => format!("kind=snp address='{}', owner='{}'", a, o), (Some(a), None) => format!("kind=snp address='{}'", a), - (None, Some(o)) => format!("kind=snp owner='{}' (address will be calculated on bind)", o), + (None, Some(o)) => { + format!("kind=snp owner='{}' (address will be calculated on bind)", o) + } _ => unreachable!(), }; diff --git a/src/node-control/commands/src/commands/nodectl/config_wallet_cmd.rs b/src/node-control/commands/src/commands/nodectl/config_wallet_cmd.rs index c4bd39c..e239863 100644 --- a/src/node-control/commands/src/commands/nodectl/config_wallet_cmd.rs +++ b/src/node-control/commands/src/commands/nodectl/config_wallet_cmd.rs @@ -712,9 +712,8 @@ fn resolve_pool_address( min_validator_stake, min_nominator_stake, } => { - let configured_validator = addresses[0] - .parse::() - .context("invalid TONCore addresses[0]")?; + let configured_validator = + addresses[0].parse::().context("invalid TONCore addresses[0]")?; if configured_validator != *validator_addr { anyhow::bail!( "TONCore addresses[0] must match validator wallet for manual stake (expected {}, got {})", diff --git a/src/node-control/commands/src/commands/nodectl/deploy_cmd.rs b/src/node-control/commands/src/commands/nodectl/deploy_cmd.rs index 8e49063..b46b346 100644 --- a/src/node-control/commands/src/commands/nodectl/deploy_cmd.rs +++ b/src/node-control/commands/src/commands/nodectl/deploy_cmd.rs @@ -16,7 +16,9 @@ use common::{ task_cancellation::CancellationCtx, ton_utils::{nanotons_to_tons_f64, tons_f64_to_nanotons}, }; -use contracts::{NominatorPoolWrapperImpl, NominatorWrapperImpl, TonWallet, resolve_deploy_pool_params}; +use contracts::{ + NominatorPoolWrapperImpl, NominatorWrapperImpl, TonWallet, resolve_deploy_pool_params, +}; use std::{cell::RefCell, collections::HashMap, path::Path, rc::Rc, str::FromStr, sync::Arc}; use ton_block::{Cell, MsgAddressInt, write_boc}; use ton_http_api_client::v2::data_models::AccountState; @@ -373,8 +375,8 @@ impl DeployPoolCmd { let pool_address = NominatorWrapperImpl::calculate_address(-1, owner, &wallet_address) .map_err(set_err)?; - let state_init = - NominatorWrapperImpl::build_state_init(owner, &wallet_address).map_err(set_err)?; + let state_init = NominatorWrapperImpl::build_state_init(owner, &wallet_address) + .map_err(set_err)?; (pool_address, state_init) } }; diff --git a/src/node-control/contracts/src/lib.rs b/src/node-control/contracts/src/lib.rs index 8d17cc2..4e3a449 100644 --- a/src/node-control/contracts/src/lib.rs +++ b/src/node-control/contracts/src/lib.rs @@ -9,10 +9,10 @@ pub mod config_contract; pub mod elector; pub mod nominator; -pub mod ton_core_nominator; pub mod provider; pub mod smart_contract; mod stack_utils; +pub mod ton_core_nominator; pub mod wallet; pub use config_contract::{ @@ -20,7 +20,7 @@ pub use config_contract::{ }; pub use elector::{ElectionsInfo, ElectorWrapper, ElectorWrapperImpl, Participant}; pub use nominator::{NOMINATOR_POOL_WORKCHAIN, NominatorWrapper, NominatorWrapperImpl}; -pub use ton_core_nominator::{NominatorPoolWrapperImpl, resolve_deploy_pool_params}; pub use provider::ContractProvider; pub use smart_contract::SmartContract; +pub use ton_core_nominator::{NominatorPoolWrapperImpl, resolve_deploy_pool_params}; pub use wallet::{TonWallet, WalletContract}; diff --git a/src/node-control/contracts/src/ton_core_nominator.rs b/src/node-control/contracts/src/ton_core_nominator.rs index 23b6ea1..35c88e0 100644 --- a/src/node-control/contracts/src/ton_core_nominator.rs +++ b/src/node-control/contracts/src/ton_core_nominator.rs @@ -11,6 +11,4 @@ pub mod messages; /// Nominator pool contract implementation (wrapper, deploy state init, RPC). mod ton_core_nominator; -pub use ton_core_nominator::{ - NominatorPoolWrapperImpl, resolve_deploy_pool_params, -}; +pub use ton_core_nominator::{NominatorPoolWrapperImpl, resolve_deploy_pool_params}; diff --git a/src/node-control/contracts/src/ton_core_nominator/messages.rs b/src/node-control/contracts/src/ton_core_nominator/messages.rs index bbcb37d..096b39b 100644 --- a/src/node-control/contracts/src/ton_core_nominator/messages.rs +++ b/src/node-control/contracts/src/ton_core_nominator/messages.rs @@ -78,9 +78,7 @@ pub fn emergency_withdraw(query_id: u64, request_address: &[u8; 32]) -> anyhow:: /// Can be sent by anyone; the pool checks config param 34 on-chain. pub fn update_validator_set(query_id: u64) -> anyhow::Result { let mut builder = BuilderData::new(); - builder - .append_u32(opcodes::UPDATE_VALIDATOR_SET)? - .append_u64(query_id)?; + builder.append_u32(opcodes::UPDATE_VALIDATOR_SET)?.append_u64(query_id)?; builder.into_cell() } @@ -89,9 +87,7 @@ pub fn update_validator_set(query_id: u64) -> anyhow::Result { /// Removes config proposal votings older than 30 days. pub fn cleanup_votings(query_id: u64) -> anyhow::Result { let mut builder = BuilderData::new(); - builder - .append_u32(opcodes::CLEANUP_VOTINGS)? - .append_u64(query_id)?; + builder.append_u32(opcodes::CLEANUP_VOTINGS)?.append_u64(query_id)?; builder.into_cell() } @@ -101,9 +97,7 @@ pub fn cleanup_votings(query_id: u64) -> anyhow::Result { /// Attach the desired amount of TON to the message; 1 TON is deducted as a processing fee. pub fn deposit_validator(query_id: u64) -> anyhow::Result { let mut builder = BuilderData::new(); - builder - .append_u32(opcodes::DEPOSIT_VALIDATOR)? - .append_u64(query_id)?; + builder.append_u32(opcodes::DEPOSIT_VALIDATOR)?.append_u64(query_id)?; builder.into_cell() } @@ -113,9 +107,7 @@ pub fn deposit_validator(query_id: u64) -> anyhow::Result { /// Can only be called when pool state == 0 (not participating in validation). pub fn withdraw_validator(query_id: u64, amount: u64) -> anyhow::Result { let mut builder = BuilderData::new(); - builder - .append_u32(opcodes::WITHDRAW_VALIDATOR)? - .append_u64(query_id)?; + builder.append_u32(opcodes::WITHDRAW_VALIDATOR)?.append_u64(query_id)?; Coins::new(amount).write_to(&mut builder)?; builder.into_cell() } diff --git a/src/node-control/contracts/src/ton_core_nominator/ton_core_nominator.rs b/src/node-control/contracts/src/ton_core_nominator/ton_core_nominator.rs index c440386..1261ded 100644 --- a/src/node-control/contracts/src/ton_core_nominator/ton_core_nominator.rs +++ b/src/node-control/contracts/src/ton_core_nominator/ton_core_nominator.rs @@ -11,8 +11,7 @@ use crate::{ContractProvider, SmartContract}; use anyhow::Context; use std::sync::Arc; use ton_block::{ - BuilderData, Coins, IBitstring, MsgAddressInt, Serializable, - StateInit, read_single_root_boc, + BuilderData, Coins, IBitstring, MsgAddressInt, Serializable, StateInit, read_single_root_boc, }; /// Compiled code of the nominator-pool contract. @@ -20,7 +19,6 @@ use ton_block::{ /// Obtain by compiling the FunC source with `func` + `fift`: /// - const CODE: &str = "b5ee9c7201023a010009c2000114ff00f4a413f4bcf2c80b0102016202030202ce0405020120131402012006070065421d749ab02705203aa008e23aa0303f00114a002a45301ba8e1323d74ac0019c5b01d430d020d749ab021270dede02e46c218047f3e09dbc400b434c0fe900c083e9100dc6c23c88c4cccc835d2708fe3c5200835c874c7cc2084139cdd12ee80b6cf2c38c02497c0f8b800f4c7f6cf1584b0002021081f09004f34c1c069b40830bffcb852483042b729be4830bffcb8524830443729b80830bfc870442c3cb852600330db3c5610c00193705711de104c103b4a98db3c085533db3c1f0c12042ce30f5540db3c105c104b103a497810561045103440330a0b0c0d03a257121110d30721c07922c06eb122c06423c077b121b1f2e04020b39e21d15616c000f2bd56152ebdf2bede22c064e30022c077925717e30d11168e1330041115040311140302111302571157115f03e30d0e0f1003341111d33f56165616db3ce30f0b11100b10bf10be10bd10bc10ab2122230028c88101001026cf0113cb0fcb0f01fa0201fa02c90104db3c1202d8810100561652a2f40e6fa120b3951112a41112de56122ebbf2e04182103b9aca0001111b01a120c200f2e042111a8e82db3c93307020e25613c0009401561aa094561aa001e25301a02cbef2e0432ad765755614b603aa00b609b9f2e04401db3c81010012561740bbf443082f2503a45611c0008f2156150410391028011118011111db3c015618a18212540be400be8e845613db3cde8ea3571781010056155292f40e6fa131f2e045c88101001256164099f4435613db3c4f0702e24f1f50770629303002fe5614c0ff56142dbab0b38e9d1114c000f2e07981010056135272f40e6fa1f2e07adb3c30c200f2e07b925714e211148020f00201d11113c079561356118307f40e6fa120b38e1982103b9aca005613d76595800f7aa984e401111801bef2e07b925717e2561695f404d31f3094306df823e25614228307f40e6fa131f2d07c2f11016cf82303c8ca0013cb1f021114018307f443c8f40001111201cb1f02011112010f8307f44311128e830ddb3c913de20c11100c10bf10bc30004a0cc8cb071bcb0f5009fa025007fa0215cc13f400f400cb1fcbffcb07cb1fcb1ff400c9ed540201201516020120191a0109bbf19db3c81f02016217180175af3bed9e2b882f87b6acc183fa0737d0f97042fa02183fc70fc0808029107a3e37d2904f816900698f98112cb781a802378101c8997100d9f32dc01f0109ac8b6d9e403302016e1b1c015dbbd05db3c57105f0f6d7f8e1f228307f47c6fa5208e1002f40431d31f3052106f0250036f02029132e201b3e6303181f0201201d1e0117ae3eed9e0837af8798b759c01f0276aa39db3c5f06509a5f096d7f8ea98101005230f47c6fa5208e9802db3c810100546380f40e6fa1312355206f0450036f02029132e201b3e6135f031f2f0244ab59db3c5f06509a5f098101002359f40e6fa1f2e056db3c8101004430f40e6fa1311f2f0154ed44d0d307d30ffa00fa00d401d0db3c05f404f404d31fd3ffd307d31fd31ff4043010bc10ab109a108920001c810100d701d30fd30ffa00fa0030001e01c0ff71f833d0810100d70358bab001e85b5712571257125712f8008210f96f732452e0ba8eb93b11117009a15380c1019a5088a020c100923727de8e16305305a8812710a9045301bc923020de5188a008a107e25077db3c270a11110a080a925712e22ac0018e198210ee6f454c52d0ba92703bde8210f374484c1dba92723ade913ce22404b85613c2005614c108b0821047657424561501bab182104e73744b561501bab1f2e0465613c001305613c0028f24d3071039102856180201111201db3c5619a18212540be400be8e845614db3cde11104870de5613c003e3005613c0062630272803ba707f8e988101005230f47c6fa5208e8702db3c3013a0029132e201b3e6306d7f8f378101005240f47c6fa5208f2602db3c25c2009f547715a98412a020c100923070de01dea070db3c8101005412015055f443029132e201b3e6145f042f2f25000ec858fa0201fa020172707f218eb0810100542270f47c6fa532218e9c3254411348705266db3c5217ba05a45304be927f36de103847634550de01b322b112e65f0401290268810100d7018101005462a0f40e6fa131f2e0474930185618011112db3c015619a18212540be400be8e845614db3cde1110487012293004d68f2024c103f2e071db3c6c21f9005360bd99343503a44413f823039130e25614db3cde5613c0078eb7f8237f8e2c56148307f47c6fa5208e1c02f40431d31f305230a18208278d00bc9a2011168307f45b301115de9132e201b3e65b5614db3cde821047657424561401ba3430302a03b2810100546550f40e6fa1f2bcdb3ca08212540be4005230a15210bc93306c14e0810100544666f45b30810100544655f45b3001a55124a182103b9aca005250be8f11705006db3c6d80101023102670db3c1023923434e243302f393804e08f3024c201f2e06f24c202f82325a124a63cbcb1f2e070821047657424c8cb1f5220cb3fc9db3c708018804010341023db3cde5613c0048e235616c0ff56162fbab0f2e04982103b9aca0001111901a120c200f2e04a51eea00e1118de5613c005925714e30d82104e73744b561301ba37382b2c04a85611c000f2e04a5616c0ff56162fbab0f2e04bfa0021c200f2e04e29db3c8212540be400561a01a101a15220bbf2e04c51f1a120c100923070de7f2fdb3c6d8010245970db3c561858a15619a18212540be400be2d39382e014e8e173005111605041115040311140302111302571157115f04e30d0f11100f10ef10de10cd10bc31013e707f8e988101005230f47c6fa5208e8702db3ca013a0029132e201b3e630312f011c8e841114db3c925714e20d11130d30000afa00fa00300114706d8010804072a0db3c3804d63e5f050fc0ff51e6ba1eb0f2e04e08c000f2e04f25f2e05082103b9aca001fbef2e05609fa0020db3c82103b9aca005230a18218746a5288005240bef2e0518212540be40001111001a15230bbf2e052535fbef2e0532edb3c5260bef2e0542d6ef2e05571db3c31f9007032333435001cd3ff31d31fd31f31d3ff31d431d100848028f833206e985b8218178411b200e0d0d30731fa00d31fd30fd30fd30f31d30f31d30fd30f305053a8ab075033a8ab075023a8ab0759a8ab075220a9b41fa0b60800268022f83320d0d30701c012f289d31fd31f3058035cdb3cdb3c1110c8cb1f1ccb3f5006cf16c9801871041110041038db3c0e11100e1f103e102d10bc107b50990743133637380022800ff833d0d31f31d31f31d31f31d70b1f011a71f833d0810100d7037f01db3c390048226eb32091719170e203c8cb055006cf165004fa02cb6a039358cc019130e201c901fb00001c74c8cb0212ca07810100cf01c9d0"; /// Pool is always deployed in the masterchain. @@ -190,10 +188,7 @@ impl NominatorWrapper for NominatorPoolWrapperImpl { POOL_WORKCHAIN as i8, pool_data.pool_config.validator_addr.into(), )?; - Ok(NominatorRoles { - owner_address: validator_address.clone(), - validator_address, - }) + Ok(NominatorRoles { owner_address: validator_address.clone(), validator_address }) } /// Parse the result of `get_pool_data` (17 flat values from `load_data`). @@ -209,10 +204,8 @@ impl NominatorWrapper for NominatorPoolWrapperImpl { /// 7 min_validator_stake 15 stake_held_for /// 16 config_proposal_votings (cell, skip) async fn get_pool_data(&self) -> anyhow::Result { - let stack = self - .provider - .get_method(self.pool_addr.to_string(), "get_pool_data", vec![]) - .await?; + let stack = + self.provider.get_method(self.pool_addr.to_string(), "get_pool_data", vec![]).await?; let state = stack.i64(0).context("parse state")? as i32; let nominators_count = stack.i64(1).context("parse nominators_count")? as u32; @@ -278,9 +271,8 @@ mod tests { use ton_http_api_client::v2::client_json_rpc::ClientJsonRpc; fn open_pool() -> Option { - let pool_addr = - MsgAddressInt::from_str("kf-d42Dwn_dzfdwlV_aEeX7WWnJ-bBU_eZp6CfKoMb4vQ3t0") - .expect("Failed to parse pool address"); + let pool_addr = MsgAddressInt::from_str("kf-d42Dwn_dzfdwlV_aEeX7WWnJ-bBU_eZp6CfKoMb4vQ3t0") + .expect("Failed to parse pool address"); let url = match std::env::var("TON_HTTP_API_URL") { Ok(url) => url, Err(_) => { From 88aa686bf8f163345de405378eec45468af1ac0d Mon Sep 17 00:00:00 2001 From: mrnkslv Date: Tue, 31 Mar 2026 11:02:50 +0300 Subject: [PATCH 06/18] feat: crates format updated Made-with: Cursor --- .../contracts/src/ton_core_nominator/ton_core_nominator.rs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/node-control/contracts/src/ton_core_nominator/ton_core_nominator.rs b/src/node-control/contracts/src/ton_core_nominator/ton_core_nominator.rs index 1261ded..3d73411 100644 --- a/src/node-control/contracts/src/ton_core_nominator/ton_core_nominator.rs +++ b/src/node-control/contracts/src/ton_core_nominator/ton_core_nominator.rs @@ -6,8 +6,10 @@ * * This software is provided "AS IS", WITHOUT WARRANTY OF ANY KIND. */ -use crate::nominator::{NominatorRoles, NominatorWrapper, PoolConfig, PoolData}; -use crate::{ContractProvider, SmartContract}; +use crate::{ + ContractProvider, SmartContract, + nominator::{NominatorRoles, NominatorWrapper, PoolConfig, PoolData}, +}; use anyhow::Context; use std::sync::Arc; use ton_block::{ From b52f03dc16fb07ffed52d2a37664fee1d8ce22f4 Mon Sep 17 00:00:00 2001 From: mrnkslv <112663764+mrnkslv@users.noreply.github.com> Date: Tue, 31 Mar 2026 11:34:19 +0300 Subject: [PATCH 07/18] Update src/node-control/README.md Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/node-control/README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/node-control/README.md b/src/node-control/README.md index 5a19cee..c58540a 100644 --- a/src/node-control/README.md +++ b/src/node-control/README.md @@ -1351,9 +1351,9 @@ Nominator pool configurations. Two pool types are supported: - `kind` — `"core"` - `addresses` — two addresses: validator wallet (`[0]`) and pool contract (`[1]`, must match the address derived from the parameters below) - `validator_share` — validator reward share (basis points; stored as `u16` on-chain) -- `max_nominators` — optional; if omitted, `contracts` `resolve_deploy_pool_params` uses the default next to the pool contract -- `min_validator_stake` — optional (nanotons); same -- `min_nominator_stake` — optional (nanotons); same +- `max_nominators` — optional; if omitted, defaults from the contracts module are used (40 nominators) +- `min_validator_stake` — optional (nanotons); if omitted, defaults from the contracts module are used (100 TON) +- `min_nominator_stake` — optional (nanotons); if omitted, defaults from the contracts module are used (10 TON) #### `bindings` From a4f01233af657fa239d534a7bf3d05fb6ee0ca03 Mon Sep 17 00:00:00 2001 From: mrnkslv <112663764+mrnkslv@users.noreply.github.com> Date: Tue, 31 Mar 2026 11:35:00 +0300 Subject: [PATCH 08/18] Update src/node-control/common/src/app_config.rs Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/node-control/common/src/app_config.rs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/node-control/common/src/app_config.rs b/src/node-control/common/src/app_config.rs index 278662a..555dbc4 100644 --- a/src/node-control/common/src/app_config.rs +++ b/src/node-control/common/src/app_config.rs @@ -848,6 +848,9 @@ mod tests { let json = serde_json::to_value(&cfg).unwrap(); assert_eq!(json["kind"], "core"); assert_eq!(json["validator_share"], 50); + assert!(json.get("max_nominators").is_none()); + assert!(json.get("min_validator_stake").is_none()); + assert!(json.get("min_nominator_stake").is_none()); } #[test] From 9bdb1650651a9fae6d9b7487e9b7ab684b3f94d3 Mon Sep 17 00:00:00 2001 From: mrnkslv Date: Thu, 2 Apr 2026 14:54:05 +0300 Subject: [PATCH 09/18] fix: fixes according to comments --- .../src/commands/nodectl/config_cmd.rs | 2 +- .../src/commands/nodectl/config_pool_cmd.rs | 317 ++++++++++++++++-- .../src/commands/nodectl/config_wallet_cmd.rs | 44 +-- .../src/commands/nodectl/deploy_cmd.rs | 56 +--- src/node-control/common/src/app_config.rs | 32 +- src/node-control/contracts/src/lib.rs | 5 +- .../src/nominator/single_nominator.rs | 28 +- .../contracts/src/nominator/wrapper.rs | 3 +- .../contracts/src/ton_core_nominator.rs | 5 +- .../ton_core_nominator/ton_core_nominator.rs | 93 ++++- .../service/src/runtime_config.rs | 51 +-- 11 files changed, 463 insertions(+), 173 deletions(-) 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..e5220de 100644 --- a/src/node-control/commands/src/commands/nodectl/config_cmd.rs +++ b/src/node-control/commands/src/commands/nodectl/config_cmd.rs @@ -102,7 +102,7 @@ impl ConfigCmd { ConfigAction::Generate(cmd) => cmd.run().await, ConfigAction::Node(cmd) => cmd.run(path).await, ConfigAction::Wallet(cmd) => cmd.run(path, cancellation_ctx).await, - ConfigAction::Pool(cmd) => cmd.run(path).await, + ConfigAction::Pool(cmd) => cmd.run(path, cancellation_ctx).await, ConfigAction::Bind(cmd) => cmd.run(path).await, ConfigAction::TonHttpApi(cmd) => cmd.run(path).await, ConfigAction::MasterWallet(cmd) => cmd.run(path).await, diff --git a/src/node-control/commands/src/commands/nodectl/config_pool_cmd.rs b/src/node-control/commands/src/commands/nodectl/config_pool_cmd.rs index 7539094..c5b5598 100644 --- a/src/node-control/commands/src/commands/nodectl/config_pool_cmd.rs +++ b/src/node-control/commands/src/commands/nodectl/config_pool_cmd.rs @@ -9,18 +9,24 @@ use crate::commands::nodectl::{ output_format::OutputFormat, utils::{ - calculate_wallet_address, save_config, try_create_rpc_client, warn_ton_api_unavailable, + SEND_TIMEOUT, calculate_wallet_address, get_wallet_config, load_config_vault_rpc_client, + make_wallet, save_config, try_create_rpc_client, wait_for_seqno_change, wallet_info, + warn_ton_api_unavailable, }, }; use colored::Colorize; use common::{ app_config::{AppConfig, PoolConfig}, - ton_utils::display_tons, + task_cancellation::CancellationCtx, + ton_utils::{display_tons, nanotons_to_tons_f64, tons_f64_to_nanotons}, +}; +use contracts::{ + NOMINATOR_POOL_WORKCHAIN, NominatorWrapperImpl, TonWallet, resolve_deploy_pool_params, + resolve_toncore_pools, ton_core_nominator::messages as pool_messages, }; -use contracts::{NOMINATOR_POOL_WORKCHAIN, NominatorWrapperImpl, resolve_deploy_pool_params}; use secrets_vault::{vault::SecretVault, vault_builder::SecretVaultBuilder}; -use std::{path::Path, str::FromStr, sync::Arc}; -use ton_block::{ADDR_FORMAT_BOUNCE, ADDR_FORMAT_URL_SAFE, MsgAddressInt}; +use std::{io::Write, path::Path, str::FromStr, sync::Arc}; +use ton_block::{ADDR_FORMAT_BOUNCE, ADDR_FORMAT_URL_SAFE, MsgAddressInt, write_boc}; use ton_http_api_client::v2::client_json_rpc::ClientJsonRpc; #[derive(Clone, Copy, Debug, Default, clap::ValueEnum)] @@ -47,6 +53,10 @@ pub enum PoolAction { Ls(PoolLsCmd), /// Remove a pool from the configuration Rm(PoolRmCmd), + /// Deposit validator funds into a TONCore nominator pool + DepositValidator(PoolDepositValidatorCmd), + /// Withdraw validator funds from a TONCore nominator pool + WithdrawValidator(PoolWithdrawValidatorCmd), } #[derive(clap::Args, Clone)] @@ -68,15 +78,21 @@ pub struct PoolAddCmd { help = "SNP: owner address, raw or base64url (for deployment/verification)" )] owner: Option, - #[arg(long = "addr1", help = "Core: addresses[0] (with --kind core)")] - addr1: Option, - #[arg(long = "addr2", help = "Core: addresses[1] (with --kind core)")] - addr2: Option, + #[arg( + long = "pool-addr-even", + help = "Core: even-round pool contract address (optional; derived from validator wallet if omitted)" + )] + pool_addr_even: Option, + #[arg( + long = "pool-addr-odd", + help = "Core: odd-round pool contract address (derived with min_validator_stake+1; optional)" + )] + pool_addr_odd: Option, #[arg( long = "validator-share", - help = "Core: validator_share, basis points (with --kind core)" + help = "Core: validator reward share encoded on-chain (basis points, 0–65535; e.g. 5000 ≈ 50%)" )] - validator_share: Option, + validator_share: Option, #[arg(long = "max-nominators", help = "Core: max nominators (default: 40)")] max_nominators: Option, #[arg( @@ -105,12 +121,36 @@ pub struct PoolRmCmd { name: String, } +#[derive(clap::Args, Clone)] +#[command(about = "Deposit validator funds into a TONCore nominator pool")] +pub struct PoolDepositValidatorCmd { + #[arg(short = 'b', long = "binding", help = "Binding name (resolves wallet and pool)")] + binding: String, + #[arg(short = 'a', long = "amount", help = "Amount in TON to deposit")] + amount: f64, +} + +#[derive(clap::Args, Clone)] +#[command(about = "Withdraw validator funds from a TONCore nominator pool")] +pub struct PoolWithdrawValidatorCmd { + #[arg(short = 'b', long = "binding", help = "Binding name (resolves wallet and pool)")] + binding: String, + #[arg(short = 'a', long = "amount", help = "Amount in TON to withdraw")] + amount: f64, +} + impl PoolCmd { - pub async fn run(&self, path: &Path) -> anyhow::Result<()> { + pub async fn run( + &self, + path: &Path, + cancellation_ctx: CancellationCtx, + ) -> anyhow::Result<()> { match &self.action { PoolAction::Add(cmd) => cmd.run(path).await, PoolAction::Ls(cmd) => cmd.run(path).await, PoolAction::Rm(cmd) => cmd.run(path).await, + PoolAction::DepositValidator(cmd) => cmd.run(path, cancellation_ctx).await, + PoolAction::WithdrawValidator(cmd) => cmd.run(path, cancellation_ctx).await, } } } @@ -163,20 +203,16 @@ impl PoolAddCmd { ) } PoolAddKind::Core => { - let addr1 = self - .addr1 - .as_deref() - .ok_or_else(|| anyhow::anyhow!("For core: --addr1 is required"))?; - let addr2 = self - .addr2 - .as_deref() - .ok_or_else(|| anyhow::anyhow!("For core: --addr2 is required"))?; let share = self .validator_share .ok_or_else(|| anyhow::anyhow!("For core: --validator-share is required"))?; - let a1 = normalize_ton_address(addr1, "addr1")?; - let a2 = normalize_ton_address(addr2, "addr2")?; + let a1 = self.pool_addr_even.as_deref() + .map(|a| normalize_ton_address(a, "pool-addr-even")) + .transpose()?; + let a2 = self.pool_addr_odd.as_deref() + .map(|a| normalize_ton_address(a, "pool-addr-odd")) + .transpose()?; let (mx, mv, mn) = resolve_deploy_pool_params( self.max_nominators, @@ -184,14 +220,18 @@ impl PoolAddCmd { self.min_nominator_stake_nano, ); let info = format!( - "kind=core addresses=['{}', '{}'], validator_share={} (basis points), deploy_params_resolved: max_nominators={}, min_validator_stake_nano={}, min_nominator_stake_nano={} (omitted fields use contract defaults)", - a1, a2, share, mx, mv, mn + "kind=core validator_share={}, even={}, odd={}, deploy_params: max_nominators={}, min_validator_stake={}, min_nominator_stake={}", + share, + a1.as_deref().unwrap_or(""), + a2.as_deref().unwrap_or(""), + mx, mv, mn ); ( PoolConfig::TONCore { - addresses: [a1, a2], validator_share: share, + even_pool_address: a1, + odd_pool_address: a2, max_nominators: self.max_nominators, min_validator_stake: self.min_validator_stake_nano, min_nominator_stake: self.min_nominator_stake_nano, @@ -218,7 +258,7 @@ struct PoolView { #[serde(skip_serializing_if = "Option::is_none")] addresses: Option>, #[serde(skip_serializing_if = "Option::is_none")] - validator_share: Option, + validator_share: Option, } impl PoolLsCmd { @@ -309,14 +349,17 @@ async fn collect_pool_views( validator_share: None, }); } - PoolConfig::TONCore { addresses, validator_share, .. } => { + PoolConfig::TONCore { validator_share, even_pool_address, odd_pool_address, .. } => { + let mut addrs = Vec::new(); + addrs.push(even_pool_address.clone().unwrap_or_else(|| "".into())); + addrs.push(odd_pool_address.clone().unwrap_or_else(|| "".into())); views.push(PoolView { name: name.clone(), kind: "Core".to_string(), balance: None, address: None, owner: None, - addresses: Some(addresses.to_vec()), + addresses: Some(addrs), validator_share: Some(*validator_share), }); } @@ -497,6 +540,224 @@ impl PoolRmCmd { } } +fn resolve_toncore_pool_address( + pool_cfg: &PoolConfig, + wallet_address: &MsgAddressInt, +) -> anyhow::Result { + match pool_cfg { + PoolConfig::TONCore { + validator_share, + even_pool_address, + odd_pool_address, + max_nominators, + min_validator_stake, + min_nominator_stake, + } => { + let resolved = resolve_toncore_pools( + wallet_address, + *validator_share, + even_pool_address.as_deref(), + odd_pool_address.as_deref(), + max_nominators.as_ref().copied(), + min_validator_stake.as_ref().copied(), + min_nominator_stake.as_ref().copied(), + )?; + Ok(resolved.even_address) + } + PoolConfig::SNP { .. } => { + anyhow::bail!("This command is only supported for TONCore pools, not SNP"); + } + } +} + +fn confirm_action(prompt: &str) -> anyhow::Result { + print!("{prompt} [y/N]: "); + std::io::stdout().flush()?; + let mut answer = String::new(); + std::io::stdin().read_line(&mut answer)?; + Ok(matches!(answer.trim(), "y" | "Y" | "yes" | "Yes")) +} + +impl PoolDepositValidatorCmd { + pub async fn run( + &self, + path: &Path, + cancellation_ctx: CancellationCtx, + ) -> anyhow::Result<()> { + let (config, vault, rpc_client) = load_config_vault_rpc_client(path).await?; + + let binding = config + .bindings + .get(&self.binding) + .ok_or_else(|| anyhow::anyhow!("Binding '{}' not found", self.binding))?; + + let wallet_cfg = + get_wallet_config(&binding.wallet, &config.wallets, config.master_wallet.as_ref())?; + + let pool_name = binding + .pool + .as_ref() + .ok_or_else(|| anyhow::anyhow!("Binding '{}' has no pool configured", self.binding))?; + let pool_cfg = config + .pools + .get(pool_name) + .ok_or_else(|| anyhow::anyhow!("Pool '{}' not found", pool_name))?; + + let (wallet_address, wallet_info_data, wallet_secret) = + wallet_info(rpc_client.clone(), wallet_cfg, vault.clone()).await?; + + if wallet_info_data.account_state != ton_http_api_client::v2::data_models::AccountState::Active { + anyhow::bail!("Wallet '{}' is {}", binding.wallet, wallet_info_data.account_state); + } + + let pool_address = resolve_toncore_pool_address(pool_cfg, &wallet_address)?; + + let deposit_nanotons = tons_f64_to_nanotons(self.amount); + if deposit_nanotons == 0 { + anyhow::bail!("Amount must be greater than 0"); + } + + let gas_reserve: u64 = 2_000_000_000; + if wallet_info_data.balance < deposit_nanotons.saturating_add(gas_reserve) { + anyhow::bail!( + "Insufficient wallet balance: {} TON (need {} TON + gas)", + nanotons_to_tons_f64(wallet_info_data.balance), + self.amount, + ); + } + + println!( + "\n{}\n Binding: {}\n Wallet: {} ({})\n Pool: {}\n Amount: {:.9} TON\n", + "Deposit validator summary:".cyan().bold(), + self.binding, + binding.wallet, + wallet_address, + pool_address, + self.amount, + ); + + if !confirm_action("Confirm deposit?")? { + println!("{}", "Deposit cancelled".yellow()); + return Ok(()); + } + + let wallet = + make_wallet(rpc_client.clone(), wallet_cfg, wallet_secret, &binding.wallet).await?; + + let pool_addr_display = pool_address.to_string(); + let body = pool_messages::deposit_validator(0)?; + let msg = wallet + .build_message(pool_address, deposit_nanotons, body, true, None, None, None) + .await?; + + let msg_boc = write_boc(&msg)?; + rpc_client.send_boc(&msg_boc).await?; + + wait_for_seqno_change( + rpc_client.clone(), + &wallet_address, + wallet_info_data.seqno, + &cancellation_ctx, + SEND_TIMEOUT, + ) + .await?; + + println!( + "{} Deposited {:.9} TON to pool {}", + "OK".green().bold(), + self.amount, + pool_addr_display + ); + Ok(()) + } +} + +impl PoolWithdrawValidatorCmd { + pub async fn run( + &self, + path: &Path, + cancellation_ctx: CancellationCtx, + ) -> anyhow::Result<()> { + let (config, vault, rpc_client) = load_config_vault_rpc_client(path).await?; + + let binding = config + .bindings + .get(&self.binding) + .ok_or_else(|| anyhow::anyhow!("Binding '{}' not found", self.binding))?; + + let wallet_cfg = + get_wallet_config(&binding.wallet, &config.wallets, config.master_wallet.as_ref())?; + + let pool_name = binding + .pool + .as_ref() + .ok_or_else(|| anyhow::anyhow!("Binding '{}' has no pool configured", self.binding))?; + let pool_cfg = config + .pools + .get(pool_name) + .ok_or_else(|| anyhow::anyhow!("Pool '{}' not found", pool_name))?; + + let (wallet_address, wallet_info_data, wallet_secret) = + wallet_info(rpc_client.clone(), wallet_cfg, vault.clone()).await?; + + if wallet_info_data.account_state != ton_http_api_client::v2::data_models::AccountState::Active { + anyhow::bail!("Wallet '{}' is {}", binding.wallet, wallet_info_data.account_state); + } + + let pool_address = resolve_toncore_pool_address(pool_cfg, &wallet_address)?; + + let withdraw_nanotons = tons_f64_to_nanotons(self.amount); + if withdraw_nanotons == 0 { + anyhow::bail!("Amount must be greater than 0"); + } + + println!( + "\n{}\n Binding: {}\n Wallet: {} ({})\n Pool: {}\n Amount: {:.9} TON\n", + "Withdraw validator summary:".cyan().bold(), + self.binding, + binding.wallet, + wallet_address, + pool_address, + self.amount, + ); + + if !confirm_action("Confirm withdrawal?")? { + println!("{}", "Withdrawal cancelled".yellow()); + return Ok(()); + } + + let wallet = + make_wallet(rpc_client.clone(), wallet_cfg, wallet_secret, &binding.wallet).await?; + + let pool_addr_display = pool_address.to_string(); + let gas_amount: u64 = 1_000_000_000; + let body = pool_messages::withdraw_validator(0, withdraw_nanotons)?; + let msg = wallet + .build_message(pool_address, gas_amount, body, true, None, None, None) + .await?; + + let msg_boc = write_boc(&msg)?; + rpc_client.send_boc(&msg_boc).await?; + + wait_for_seqno_change( + rpc_client.clone(), + &wallet_address, + wallet_info_data.seqno, + &cancellation_ctx, + SEND_TIMEOUT, + ) + .await?; + + println!( + "{} Withdrawal of {:.9} TON requested from pool {}", + "OK".green().bold(), + self.amount, + pool_addr_display + ); + Ok(()) + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/src/node-control/commands/src/commands/nodectl/config_wallet_cmd.rs b/src/node-control/commands/src/commands/nodectl/config_wallet_cmd.rs index e239863..d8ec4f9 100644 --- a/src/node-control/commands/src/commands/nodectl/config_wallet_cmd.rs +++ b/src/node-control/commands/src/commands/nodectl/config_wallet_cmd.rs @@ -24,8 +24,8 @@ use common::{ ton_utils::{display_tons, tons_f64_to_nanotons}, }; use contracts::{ - ElectorWrapper, ElectorWrapperImpl, NominatorPoolWrapperImpl, NominatorWrapperImpl, TonWallet, - contract_provider, nominator, resolve_deploy_pool_params, + ElectorWrapper, ElectorWrapperImpl, NominatorWrapperImpl, TonWallet, contract_provider, + nominator, resolve_toncore_pools, }; use elections::providers::{DefaultElectionsProvider, ElectionsProvider}; use secrets_vault::{errors::error::VaultError, vault::SecretVault}; @@ -706,47 +706,23 @@ fn resolve_pool_address( (None, None) => anyhow::bail!("Pool has neither address nor owner configured"), }, PoolConfig::TONCore { - addresses, validator_share, + even_pool_address, + odd_pool_address, max_nominators, min_validator_stake, min_nominator_stake, } => { - let configured_validator = - addresses[0].parse::().context("invalid TONCore addresses[0]")?; - if configured_validator != *validator_addr { - anyhow::bail!( - "TONCore addresses[0] must match validator wallet for manual stake (expected {}, got {})", - validator_addr, - addresses[0] - ); - } - let reward_share = u16::try_from(*validator_share) - .map_err(|_| anyhow::anyhow!("validator_share must fit in u16 (0..=65535)"))?; - let (max_n, min_v, min_n) = resolve_deploy_pool_params( + let resolved = resolve_toncore_pools( + validator_addr, + *validator_share, + even_pool_address.as_deref(), + odd_pool_address.as_deref(), max_nominators.as_ref().copied(), min_validator_stake.as_ref().copied(), min_nominator_stake.as_ref().copied(), - ); - let calculated = NominatorPoolWrapperImpl::calculate_address( - &configured_validator, - reward_share, - max_n, - min_v, - min_n, )?; - // addresses[1] must be the pool contract (same derivation as `nodectl deploy pool`). - let explicit = addresses[1] - .parse::() - .context("invalid TONCore pool contract address (addresses[1])")?; - if explicit != calculated { - anyhow::bail!( - "TONCore addresses[1] ({}) does not match pool address derived from addresses[0] and validator_share ({})", - explicit, - calculated - ); - } - Ok(explicit) + Ok(resolved.even_address) } } } diff --git a/src/node-control/commands/src/commands/nodectl/deploy_cmd.rs b/src/node-control/commands/src/commands/nodectl/deploy_cmd.rs index b46b346..0bdc892 100644 --- a/src/node-control/commands/src/commands/nodectl/deploy_cmd.rs +++ b/src/node-control/commands/src/commands/nodectl/deploy_cmd.rs @@ -16,10 +16,8 @@ use common::{ task_cancellation::CancellationCtx, ton_utils::{nanotons_to_tons_f64, tons_f64_to_nanotons}, }; -use contracts::{ - NominatorPoolWrapperImpl, NominatorWrapperImpl, TonWallet, resolve_deploy_pool_params, -}; -use std::{cell::RefCell, collections::HashMap, path::Path, rc::Rc, str::FromStr, sync::Arc}; +use contracts::{NominatorWrapperImpl, TonWallet, resolve_toncore_pools}; +use std::{cell::RefCell, collections::HashMap, path::Path, rc::Rc, sync::Arc}; use ton_block::{Cell, MsgAddressInt, write_boc}; use ton_http_api_client::v2::data_models::AccountState; @@ -325,46 +323,24 @@ impl DeployPoolCmd { let (pool_address, state_init) = match pool_cfg_opt { Some(PoolConfig::TONCore { - addresses, validator_share, + even_pool_address, + odd_pool_address, max_nominators, min_validator_stake, min_nominator_stake, }) => { - let validator_addr = - MsgAddressInt::from_str(addresses[0].as_str()).map_err(set_err)?; - if validator_addr != wallet_address { - return Err(set_err(anyhow::anyhow!( - "TONCore addresses[0] must match this node's wallet (expected {}, config has {})", - wallet_address, - addresses[0] - ))); - } - let reward_share = u16::try_from(*validator_share).map_err(|e| { - set_err(anyhow::anyhow!("validator_share must fit in u16: {}", e)) - })?; - let (max_n, min_v, min_n) = resolve_deploy_pool_params( + let resolved = resolve_toncore_pools( + &wallet_address, + *validator_share, + even_pool_address.as_deref(), + odd_pool_address.as_deref(), max_nominators.as_ref().copied(), min_validator_stake.as_ref().copied(), min_nominator_stake.as_ref().copied(), - ); - let pool_address = NominatorPoolWrapperImpl::calculate_address( - &validator_addr, - reward_share, - max_n, - min_v, - min_n, ) .map_err(set_err)?; - let state_init = NominatorPoolWrapperImpl::build_state_init( - &validator_addr, - reward_share, - max_n, - min_v, - min_n, - ) - .map_err(set_err)?; - (pool_address, state_init) + (resolved.even_address, resolved.even_state_init) } Some(PoolConfig::SNP { .. }) | None => { let owner = self.owner.as_ref().ok_or_else(|| { @@ -372,11 +348,9 @@ impl DeployPoolCmd { "SNP deploy requires --owner (set `pool` to a `kind: core` entry for TON Nominator Pool deploy)" )) })?; - let pool_address = - NominatorWrapperImpl::calculate_address(-1, owner, &wallet_address) + let (pool_address, state_init) = + NominatorWrapperImpl::calculate_address_with_state_init(-1, owner, &wallet_address) .map_err(set_err)?; - let state_init = NominatorWrapperImpl::build_state_init(owner, &wallet_address) - .map_err(set_err)?; (pool_address, state_init) } }; @@ -406,9 +380,9 @@ impl DeployPoolCmd { if self.verbose { match pool_cfg_opt { - Some(PoolConfig::TONCore { addresses, validator_share, .. }) => println!( - "Deploy TON Nominator Pool (core): validator={}, validator_share={} (addresses[1]={}) ...", - addresses[0], validator_share, addresses[1] + Some(PoolConfig::TONCore { validator_share, .. }) => println!( + "Deploy TON Nominator Pool (core): validator={}, validator_share={}, pool={} ...", + wallet_address, validator_share, pool_address ), _ => { if let Some(owner) = self.owner.as_ref() { diff --git a/src/node-control/common/src/app_config.rs b/src/node-control/common/src/app_config.rs index 555dbc4..a6c8d41 100644 --- a/src/node-control/common/src/app_config.rs +++ b/src/node-control/common/src/app_config.rs @@ -339,8 +339,15 @@ pub enum PoolConfig { }, #[serde(rename = "core")] TONCore { - addresses: [String; 2], - validator_share: u64, + validator_share: u16, + /// Even-round pool address. `None` = not deployed yet (will be derived from validator wallet). + #[serde(default)] + #[serde(skip_serializing_if = "Option::is_none")] + even_pool_address: Option, + /// Odd-round pool address (same params but `min_validator_stake + 1`). `None` = not deployed. + #[serde(default)] + #[serde(skip_serializing_if = "Option::is_none")] + odd_pool_address: Option, /// Deploy-time pool parameters; if omitted, defaults are applied in `contracts` (`resolve_deploy_pool_params`). #[serde(default)] #[serde(skip_serializing_if = "Option::is_none")] @@ -825,20 +832,18 @@ mod tests { } #[test] - fn test_pool_config_serde_core() { - let addr1 = ADDR; - let addr2 = OWNER; + fn test_pool_config_serde_core_no_addresses() { let value = serde_json::json!({ "kind": "core", - "addresses": [addr1.to_string(), addr2.to_string()], "validator_share": 50, }); let cfg: PoolConfig = serde_json::from_value(value).unwrap(); assert_eq!( cfg, PoolConfig::TONCore { - addresses: [addr1.to_string(), addr2.to_string()], validator_share: 50, + even_pool_address: None, + odd_pool_address: None, max_nominators: None, min_validator_stake: None, min_nominator_stake: None, @@ -848,19 +853,19 @@ mod tests { let json = serde_json::to_value(&cfg).unwrap(); assert_eq!(json["kind"], "core"); assert_eq!(json["validator_share"], 50); - assert!(json.get("max_nominators").is_none()); - assert!(json.get("min_validator_stake").is_none()); - assert!(json.get("min_nominator_stake").is_none()); + assert!(json.get("even_pool_address").is_none()); + assert!(json.get("odd_pool_address").is_none()); } #[test] - fn test_pool_config_serde_core_explicit_deploy_params() { + fn test_pool_config_serde_core_with_addresses() { let addr1 = ADDR; let addr2 = OWNER; let value = serde_json::json!({ "kind": "core", - "addresses": [addr1.to_string(), addr2.to_string()], "validator_share": 100, + "even_pool_address": addr1.to_string(), + "odd_pool_address": addr2.to_string(), "max_nominators": 10, "min_validator_stake": 5_000_000_000_000u64, "min_nominator_stake": 1_000_000_000_000u64, @@ -869,8 +874,9 @@ mod tests { assert_eq!( cfg, PoolConfig::TONCore { - addresses: [addr1.to_string(), addr2.to_string()], validator_share: 100, + even_pool_address: Some(addr1.to_string()), + odd_pool_address: Some(addr2.to_string()), max_nominators: Some(10), min_validator_stake: Some(5_000_000_000_000), min_nominator_stake: Some(1_000_000_000_000), diff --git a/src/node-control/contracts/src/lib.rs b/src/node-control/contracts/src/lib.rs index 4e3a449..2239ffc 100644 --- a/src/node-control/contracts/src/lib.rs +++ b/src/node-control/contracts/src/lib.rs @@ -22,5 +22,8 @@ pub use elector::{ElectionsInfo, ElectorWrapper, ElectorWrapperImpl, Participant pub use nominator::{NOMINATOR_POOL_WORKCHAIN, NominatorWrapper, NominatorWrapperImpl}; pub use provider::ContractProvider; pub use smart_contract::SmartContract; -pub use ton_core_nominator::{NominatorPoolWrapperImpl, resolve_deploy_pool_params}; +pub use ton_core_nominator::{ + NominatorPoolWrapperImpl, ResolvedTonCorePools, resolve_deploy_pool_params, + resolve_toncore_pools, +}; pub use wallet::{TonWallet, WalletContract}; diff --git a/src/node-control/contracts/src/nominator/single_nominator.rs b/src/node-control/contracts/src/nominator/single_nominator.rs index 4a851f6..1b5f803 100644 --- a/src/node-control/contracts/src/nominator/single_nominator.rs +++ b/src/node-control/contracts/src/nominator/single_nominator.rs @@ -38,9 +38,9 @@ impl NominatorWrapperImpl { validator_address: &MsgAddressInt, workchain: i32, ) -> anyhow::Result { - let state_init = Some(Self::build_state_init(owner_address, validator_address)?); - let nominator_addr = Self::calculate_address(workchain, owner_address, validator_address)?; - Ok(Self { provider, nominator_addr, state_init }) + let (nominator_addr, state_init) = + Self::calculate_address_with_state_init(workchain, owner_address, validator_address)?; + Ok(Self { provider, nominator_addr, state_init: Some(state_init) }) } pub fn calculate_address( @@ -48,10 +48,20 @@ impl NominatorWrapperImpl { owner_address: &MsgAddressInt, validator_address: &MsgAddressInt, ) -> anyhow::Result { - let state_init = Self::build_state_init(owner_address, validator_address)? - .write_to_new_cell()? - .into_cell()?; - MsgAddressInt::with_params(wc, state_init.hash(0)) + Self::calculate_address_with_state_init(wc, owner_address, validator_address) + .map(|(addr, _)| addr) + } + + /// Calculate both the pool address and `StateInit` in a single pass. + pub fn calculate_address_with_state_init( + wc: i32, + owner_address: &MsgAddressInt, + validator_address: &MsgAddressInt, + ) -> anyhow::Result<(MsgAddressInt, StateInit)> { + let state_init = Self::build_state_init(owner_address, validator_address)?; + let cell = state_init.write_to_new_cell()?.into_cell()?; + let addr = MsgAddressInt::with_params(wc, cell.hash(0))?; + Ok((addr, state_init)) } pub fn build_state_init( @@ -117,7 +127,7 @@ impl NominatorWrapper for NominatorWrapperImpl { let validator_reward_share = stack.i64(5).context("parse validator_reward_share")? as u16; let max_nominators_count = stack.i64(6).context("parse max_nominators_count")? as u16; let min_validator_stake = stack.i64(7).context("parse min_validator_stake")? as u64; - let max_nominators_stake = stack.i64(8).context("parse max_nominators_stake")? as u64; + let nominator_stake_threshold = stack.i64(8).context("parse nominator_stake_threshold")? as u64; // skip indices 9-10 (nominators, withdraw_requests) let stake_at = stack.i64(11).context("parse stake_at")? as u32; let saved_validator_set_hash = { @@ -142,7 +152,7 @@ impl NominatorWrapper for NominatorWrapperImpl { validator_reward_share, max_nominators_count, min_validator_stake, - max_nominators_stake, + nominator_stake_threshold, }, stake_at, saved_validator_set_hash, diff --git a/src/node-control/contracts/src/nominator/wrapper.rs b/src/node-control/contracts/src/nominator/wrapper.rs index 2c6268a..8fde7d6 100644 --- a/src/node-control/contracts/src/nominator/wrapper.rs +++ b/src/node-control/contracts/src/nominator/wrapper.rs @@ -42,7 +42,8 @@ pub struct PoolConfig { pub validator_reward_share: u16, pub max_nominators_count: u16, pub min_validator_stake: u64, - pub max_nominators_stake: u64, + /// SNP: max nominator stake; TONCore: min nominator stake. + pub nominator_stake_threshold: u64, } /// Pool data returned by get_pool_data() #[derive(Debug, Clone, Default, PartialEq)] diff --git a/src/node-control/contracts/src/ton_core_nominator.rs b/src/node-control/contracts/src/ton_core_nominator.rs index 35c88e0..ae911b5 100644 --- a/src/node-control/contracts/src/ton_core_nominator.rs +++ b/src/node-control/contracts/src/ton_core_nominator.rs @@ -11,4 +11,7 @@ pub mod messages; /// Nominator pool contract implementation (wrapper, deploy state init, RPC). mod ton_core_nominator; -pub use ton_core_nominator::{NominatorPoolWrapperImpl, resolve_deploy_pool_params}; +pub use ton_core_nominator::{ + NominatorPoolWrapperImpl, ResolvedTonCorePools, resolve_deploy_pool_params, + resolve_toncore_pools, +}; diff --git a/src/node-control/contracts/src/ton_core_nominator/ton_core_nominator.rs b/src/node-control/contracts/src/ton_core_nominator/ton_core_nominator.rs index 3d73411..751cc0b 100644 --- a/src/node-control/contracts/src/ton_core_nominator/ton_core_nominator.rs +++ b/src/node-control/contracts/src/ton_core_nominator/ton_core_nominator.rs @@ -45,6 +45,75 @@ pub fn resolve_deploy_pool_params( ) } +/// Resolved even/odd pool addresses (and optionally `StateInit`) for a TONCore config. +pub struct ResolvedTonCorePools { + pub reward_share: u16, + pub max_nominators: u16, + pub min_validator_stake: u64, + pub min_nominator_stake: u64, + pub even_address: MsgAddressInt, + pub even_state_init: StateInit, + pub odd_address: MsgAddressInt, + pub odd_state_init: StateInit, +} + +/// Validate and resolve both even/odd pool addresses from TONCore config fields. +/// +/// Resolves deploy-time defaults, calculates deterministic addresses, and — if explicit +/// addresses are provided — verifies they match the derived ones. +pub fn resolve_toncore_pools( + validator_addr: &MsgAddressInt, + validator_share: u16, + even_pool_address: Option<&str>, + odd_pool_address: Option<&str>, + max_nominators: Option, + min_validator_stake: Option, + min_nominator_stake: Option, +) -> anyhow::Result { + let (max_n, min_v, min_n) = + resolve_deploy_pool_params(max_nominators, min_validator_stake, min_nominator_stake); + + let (even_address, even_state_init) = + NominatorPoolWrapperImpl::calculate_address_with_state_init( + validator_addr, validator_share, max_n, min_v, min_n, + )?; + if let Some(addr) = even_pool_address { + let explicit = addr + .parse::() + .context(format!("invalid TONCore even_pool_address: {addr}"))?; + anyhow::ensure!( + explicit == even_address, + "TONCore even_pool_address ({explicit}) does not match derived address ({even_address})" + ); + } + + let min_v_odd = min_v.saturating_add(1); + let (odd_address, odd_state_init) = + NominatorPoolWrapperImpl::calculate_address_with_state_init( + validator_addr, validator_share, max_n, min_v_odd, min_n, + )?; + if let Some(addr) = odd_pool_address { + let explicit = addr + .parse::() + .context(format!("invalid TONCore odd_pool_address: {addr}"))?; + anyhow::ensure!( + explicit == odd_address, + "TONCore odd_pool_address ({explicit}) does not match derived address ({odd_address})" + ); + } + + Ok(ResolvedTonCorePools { + reward_share: validator_share, + max_nominators: max_n, + min_validator_stake: min_v, + min_nominator_stake: min_n, + even_address, + even_state_init, + odd_address, + odd_state_init, + }) +} + /// Wrapper for the TON Nominator Pool contract. /// /// See: @@ -79,14 +148,13 @@ impl NominatorPoolWrapperImpl { min_validator_stake: u64, min_nominator_stake: u64, ) -> anyhow::Result { - let state_init = Self::build_state_init( + let (pool_addr, state_init) = Self::calculate_address_with_state_init( validator_address, validator_reward_share, max_nominators_count, min_validator_stake, min_nominator_stake, )?; - let pool_addr = Self::address_from_state_init(&state_init)?; Ok(Self { provider, pool_addr, state_init: Some(state_init) }) } @@ -98,6 +166,20 @@ impl NominatorPoolWrapperImpl { min_validator_stake: u64, min_nominator_stake: u64, ) -> anyhow::Result { + Self::calculate_address_with_state_init( + validator_address, validator_reward_share, + max_nominators_count, min_validator_stake, min_nominator_stake, + ).map(|(addr, _)| addr) + } + + /// Calculate both the pool address and `StateInit` in a single pass. + pub fn calculate_address_with_state_init( + validator_address: &MsgAddressInt, + validator_reward_share: u16, + max_nominators_count: u16, + min_validator_stake: u64, + min_nominator_stake: u64, + ) -> anyhow::Result<(MsgAddressInt, StateInit)> { let state_init = Self::build_state_init( validator_address, validator_reward_share, @@ -105,7 +187,8 @@ impl NominatorPoolWrapperImpl { min_validator_stake, min_nominator_stake, )?; - Self::address_from_state_init(&state_init) + let addr = Self::address_from_state_init(&state_init)?; + Ok((addr, state_init)) } fn address_from_state_init(state_init: &StateInit) -> anyhow::Result { @@ -222,8 +305,6 @@ impl NominatorWrapper for NominatorPoolWrapperImpl { let validator_reward_share = stack.i64(5).context("parse validator_reward_share")? as u16; let max_nominators_count = stack.i64(6).context("parse max_nominators_count")? as u16; let min_validator_stake = stack.i64(7).context("parse min_validator_stake")? as u64; - // In the shared PoolConfig struct this field is named `max_nominators_stake` - // for SNP compatibility; for the nominator pool it represents `min_nominator_stake`. let min_nominator_stake = stack.i64(8).context("parse min_nominator_stake")? as u64; // skip indices 9-10 (nominators, withdraw_requests) @@ -253,7 +334,7 @@ impl NominatorWrapper for NominatorPoolWrapperImpl { validator_reward_share, max_nominators_count, min_validator_stake, - max_nominators_stake: min_nominator_stake, + nominator_stake_threshold: min_nominator_stake, }, stake_at, saved_validator_set_hash, diff --git a/src/node-control/service/src/runtime_config.rs b/src/node-control/service/src/runtime_config.rs index 81775e1..57c19fb 100644 --- a/src/node-control/service/src/runtime_config.rs +++ b/src/node-control/service/src/runtime_config.rs @@ -14,7 +14,7 @@ use common::{ }; use contracts::{ NominatorPoolWrapperImpl, NominatorWrapper, NominatorWrapperImpl, TonWallet, WalletContract, - contract_provider, resolve_deploy_pool_params, + contract_provider, resolve_toncore_pools, }; use secrets_vault::{ types::{algorithm::Algorithm, secret_id::SecretId, secret_spec::SecretSpec}, @@ -516,53 +516,28 @@ fn open_nominator_pool( Ok(Arc::new(pool)) } PoolConfig::TONCore { - addresses, validator_share, + even_pool_address, + odd_pool_address, max_nominators, min_validator_stake, min_nominator_stake, } => { - let configured_validator = MsgAddressInt::from_str(addresses[0].as_str()) - .context(format!("invalid TONCore addresses[0]: {}", addresses[0]))?; - if configured_validator != *validator_addr { - anyhow::bail!( - "TONCore addresses[0] must match validator wallet (expected {}, got {})", - validator_addr, - addresses[0] - ); - } - let reward_share = u16::try_from(*validator_share) - .map_err(|_| anyhow::anyhow!("validator_share must fit in u16 (0..=65535)"))?; - let (max_n, min_v, min_n) = resolve_deploy_pool_params( + let resolved = resolve_toncore_pools( + validator_addr, + *validator_share, + even_pool_address.as_deref(), + odd_pool_address.as_deref(), max_nominators.as_ref().copied(), min_validator_stake.as_ref().copied(), min_nominator_stake.as_ref().copied(), - ); - let calculated = NominatorPoolWrapperImpl::calculate_address( - &configured_validator, - reward_share, - max_n, - min_v, - min_n, )?; - let explicit = MsgAddressInt::from_str(addresses[1].as_str()) - .context(format!("invalid TONCore addresses[1]: {}", addresses[1]))?; - if explicit != calculated { - anyhow::bail!( - "TONCore addresses[1] ({}) does not match pool address derived from addresses[0] and validator_share ({})", - explicit, - calculated - ); - } - let pool = NominatorPoolWrapperImpl::from_init_data( + // TODO: return both even/odd wrappers when elections runner supports round-based selection + let even = NominatorPoolWrapperImpl::new( contract_provider!(rpc_client.clone()), - &configured_validator, - reward_share, - max_n, - min_v, - min_n, - )?; - Ok(Arc::new(pool)) + resolved.even_address, + ); + Ok(Arc::new(even)) } } } From 670c6657e3b7fcb74d0929b5400728b43b68fc5e Mon Sep 17 00:00:00 2001 From: mrnkslv Date: Thu, 2 Apr 2026 15:13:39 +0300 Subject: [PATCH 10/18] feat(core np):add opportunity to use 2 pools --- src/node-control/contracts/src/lib.rs | 2 +- .../contracts/src/nominator/wrapper.rs | 28 ++++ .../elections/src/election_task.rs | 4 +- src/node-control/elections/src/runner.rs | 144 ++++++++++++------ .../elections/src/runner_tests.rs | 11 +- .../service/src/auth/user_store.rs | 4 +- .../service/src/contracts/contracts_task.rs | 34 +++-- .../service/src/runtime_config.rs | 38 ++--- .../service/src/task/task_manager.rs | 4 +- 9 files changed, 179 insertions(+), 90 deletions(-) diff --git a/src/node-control/contracts/src/lib.rs b/src/node-control/contracts/src/lib.rs index 2239ffc..dffc523 100644 --- a/src/node-control/contracts/src/lib.rs +++ b/src/node-control/contracts/src/lib.rs @@ -19,7 +19,7 @@ pub use config_contract::{ ConfigContractImpl, ConfigContractWrapper, ConfigProposal, ProposedParam, }; pub use elector::{ElectionsInfo, ElectorWrapper, ElectorWrapperImpl, Participant}; -pub use nominator::{NOMINATOR_POOL_WORKCHAIN, NominatorWrapper, NominatorWrapperImpl}; +pub use nominator::{NOMINATOR_POOL_WORKCHAIN, NodePools, NominatorWrapper, NominatorWrapperImpl}; pub use provider::ContractProvider; pub use smart_contract::SmartContract; pub use ton_core_nominator::{ diff --git a/src/node-control/contracts/src/nominator/wrapper.rs b/src/node-control/contracts/src/nominator/wrapper.rs index 8fde7d6..37e5f9b 100644 --- a/src/node-control/contracts/src/nominator/wrapper.rs +++ b/src/node-control/contracts/src/nominator/wrapper.rs @@ -7,6 +7,7 @@ * This software is provided "AS IS", WITHOUT WARRANTY OF ANY KIND. */ use crate::SmartContract; +use std::sync::Arc; use ton_block::{MsgAddressInt, StateInit}; /// Trait for interacting with single-nominator smart contract @@ -69,3 +70,30 @@ pub struct PoolData { /// Stake held for duration pub stake_held_for: u64, } + +/// Pool wrappers for a single node binding. +/// +/// - **SNP**: single pool in `even`, `odd` is `None`. +/// - **TONCore**: `even` for even-round elections, `odd` for odd-round elections. +#[derive(Clone)] +pub struct NodePools { + pub even: Arc, + pub odd: Option>, +} + +impl NodePools { + /// Returns the pool that should be used for the given election round. + /// For SNP (odd == None) always returns `even`. + /// For TONCore selects by `election_id % 2`. + pub fn for_election(&self, election_id: u64) -> &Arc { + match &self.odd { + Some(odd) if election_id % 2 != 0 => odd, + _ => &self.even, + } + } + + /// Iterates over all pools (1 for SNP, 2 for TONCore). + pub fn iter(&self) -> impl Iterator> { + std::iter::once(&self.even).chain(self.odd.iter()) + } +} diff --git a/src/node-control/elections/src/election_task.rs b/src/node-control/elections/src/election_task.rs index ee117ff..7e7cf06 100644 --- a/src/node-control/elections/src/election_task.rs +++ b/src/node-control/elections/src/election_task.rs @@ -16,7 +16,7 @@ use common::{ snapshot::SnapshotStore, task_cancellation::CancellationCtx, }; -use contracts::{ElectorWrapperImpl, NominatorWrapper, TonWallet, contract_provider}; +use contracts::{ElectorWrapperImpl, NodePools, TonWallet, contract_provider}; use secrets_vault::vault::SecretVault; use std::{collections::HashMap, sync::Arc, time::Duration}; use ton_http_api_client::v2::client_json_rpc::ClientJsonRpc; @@ -29,7 +29,7 @@ pub async fn run( app_config: Arc, rpc_client: Arc, wallets: Arc>>, - pools: Arc>>, + pools: Arc>, store: Arc, vault: Option>, on_status_change: Option, diff --git a/src/node-control/elections/src/runner.rs b/src/node-control/elections/src/runner.rs index 67fae96..3ad9e3b 100644 --- a/src/node-control/elections/src/runner.rs +++ b/src/node-control/elections/src/runner.rs @@ -20,7 +20,7 @@ use common::{ ton_utils::nanotons_to_dec_string, }; use contracts::{ - ElectionsInfo, ElectorWrapper, NominatorWrapper, Participant, TonWallet, + ElectionsInfo, ElectorWrapper, NodePools, NominatorWrapper, Participant, TonWallet, elector::PastElections, nominator, }; use std::{ @@ -84,11 +84,10 @@ struct Node { /// Computed in build_validators_snapshot, used by build_our_participants_snapshot. is_next_validator: bool, wallet: Arc, - /// Nominator pool instance. Optional. - pool: Option>, - /// Address to which to send commands: stake & recover. - /// It can be an elector address or a nominator pool address. - elections_address: MsgAddressInt, + /// Nominator pools for this node. `None` = direct staking (no pool). + pools: Option, + /// Elector address (used as elections target when no pool is configured). + elector_address: MsgAddressInt, /// Last error observed for this node during the current/previous tick (stringified). last_error: Option, /// Excluded from elections (enable = false). @@ -104,9 +103,15 @@ struct Node { } impl Node { - fn wallet_addr(&self) -> Vec { - self.pool - .as_ref() + /// Returns the active pool for the given election round, or `None` for direct staking. + fn active_pool(&self, election_id: u64) -> Option<&Arc> { + self.pools.as_ref().map(|p| p.for_election(election_id)) + } + + /// Address used as the "sender" in the elector's participant list. + /// Pool address for pool-based staking, wallet address for direct staking. + fn wallet_addr(&self, election_id: u64) -> Vec { + self.active_pool(election_id) .map(|p| p.address()) .unwrap_or_else(|| self.wallet.address()) .address() @@ -114,9 +119,25 @@ impl Node { .storage() .to_vec() } - fn elections_addr(&self) -> MsgAddressInt { - self.elections_address.clone() + + /// All addresses that may have stakes at the elector (for recovery and snapshot matching). + fn all_staking_addresses(&self) -> Vec> { + match &self.pools { + Some(pools) => pools + .iter() + .map(|p| p.address().address().clone().storage().to_vec()) + .collect(), + None => vec![self.wallet.address().address().clone().storage().to_vec()], + } + } + + /// Target address for stake/recover messages for the given election round. + fn elections_addr(&self, election_id: u64) -> MsgAddressInt { + self.active_pool(election_id) + .map(|p| p.address()) + .unwrap_or_else(|| self.elector_address.clone()) } + fn reset_participation(&mut self) { self.participant = None; self.submission_time = None; @@ -124,8 +145,9 @@ impl Node { self.accepted_stake_amount = None; self.stake_submissions.clear(); } - async fn stake_balance(&mut self, gas_fee: u64) -> anyhow::Result { - match self.pool.as_ref() { + + async fn stake_balance(&mut self, gas_fee: u64, election_id: u64) -> anyhow::Result { + match self.active_pool(election_id) { Some(pool) => self.api.account(&pool.address().to_string()).await.map(|x| x.balance()), None => self .api @@ -135,9 +157,11 @@ impl Node { } .map(|b| b.saturating_sub(MIN_NANOTON_FOR_STORAGE)) } + async fn wallet_balance(&mut self) -> anyhow::Result { self.api.account(&self.wallet.address().to_string()).await.map(|x| x.balance()) } + async fn find_election_key(&mut self, election_id: u64) -> Option { let mut validator_entry = self.validator_config.find(election_id); if let Some(entry) = validator_entry.as_mut() { @@ -206,8 +230,9 @@ impl ElectionRunner { elector: Arc, providers: HashMap>, wallets: Arc>>, - pools: Arc>>, + pools: Arc>, ) -> Self { + let elector_address = elector.address(); Self { default_max_factor: elections_config.max_factor, default_stake_policy: elections_config.policy.clone(), @@ -221,7 +246,7 @@ impl ElectionRunner { return None; } }; - let pool = pools.get(&node_id).map(|p| p.clone()); + let node_pools = 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); @@ -230,12 +255,9 @@ impl ElectionRunner { node_id, Node { api: provider, - elections_address: pool - .as_ref() - .map(|p| p.address()) - .unwrap_or_else(|| elector.address()), + elector_address: elector_address.clone(), wallet, - pool, + pools: node_pools, excluded, stake_policy, key_id: vec![], @@ -360,7 +382,7 @@ impl ElectionRunner { node.stake_accepted = false; node.accepted_stake_amount = None; if let Some(p) = - elections_info.participants.iter().find(|p| p.wallet_addr == node.wallet_addr()) + elections_info.participants.iter().find(|p| p.wallet_addr == node.wallet_addr(election_id)) { node.stake_accepted = true; node.accepted_stake_amount = Some(p.stake); @@ -424,9 +446,9 @@ impl ElectionRunner { ) { self.snapshot_cache.last_max_factor = Some(self.calc_max_factor()); - // It can be a validator wallet or nominator pool address. + // Include all pool addresses (even + odd for TONCore) so we can match any participant. let wallet_addrs: HashSet> = - self.nodes.values().map(|node| node.wallet_addr()).collect(); + self.nodes.values().flat_map(|node| node.all_staking_addresses()).collect(); let participants = Self::build_participants_snapshot(elections_info, &wallet_addrs); let participant_min_stake = @@ -498,6 +520,7 @@ impl ElectionRunner { &self.past_elections, participant.as_ref().map(|p| p.stake).unwrap_or(0), elections_info.min_stake, + election_id, ) .await .context("stake calculation error")?; @@ -552,12 +575,12 @@ impl ElectionRunner { pub_key, adnl_addr, election_id, - wallet_addr: node.wallet_addr(), + wallet_addr: node.wallet_addr(election_id), stake, max_factor, }); node.key_id = key_id; - Self::send_stake(node_id, node, stake).await?; + Self::send_stake(node_id, node, stake, election_id).await?; Ok(()) } Some(entry) => { @@ -610,7 +633,7 @@ impl ElectionRunner { .ok_or_else(|| anyhow::anyhow!("no adnl address"))?, pub_key: entry.public_key, election_id, - wallet_addr: node.wallet_addr(), + wallet_addr: node.wallet_addr(election_id), stake, max_factor, }); @@ -619,7 +642,7 @@ impl ElectionRunner { if let Some(p) = node.participant.as_mut() { p.stake = stake; } - Self::send_stake(node_id, node, stake).await?; + Self::send_stake(node_id, node, stake, election_id).await?; } } Ok(()) @@ -627,12 +650,16 @@ impl ElectionRunner { } } - async fn send_stake(node_id: &str, node: &mut Node, stake: u64) -> anyhow::Result<()> { + async fn send_stake( + node_id: &str, + node: &mut Node, + stake: u64, + election_id: u64, + ) -> anyhow::Result<()> { tracing::info!("node [{}] build stake message", node_id); let payload = Self::build_new_stake_payload(node_id, node).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?; + let stake_balance = node.stake_balance(fee, election_id).await?; if stake_balance < stake { anyhow::bail!( "low stake balance: required={} TON, available={} TON", @@ -649,11 +676,10 @@ impl ElectionRunner { ); } - // if node has nominator pool, the wallet should send only gas fee, - // otherwise the wallet should send stake + gas fee - let send_value = node.pool.as_ref().map(|_| fee).unwrap_or(stake + fee); + let send_value = node.active_pool(election_id).map(|_| fee).unwrap_or(stake + fee); + let elections_addr = node.elections_addr(election_id); let msg_boc = - write_boc(&node.wallet.message(node.elections_addr(), send_value, payload).await?)?; + write_boc(&node.wallet.message(elections_addr, send_value, payload).await?)?; tracing::debug!("wallet external message: boc={}", hex::encode(&msg_boc)); tracing::info!("node [{}] send stake", node_id); node.api.send_boc(&msg_boc).await?; @@ -721,12 +747,31 @@ impl ElectionRunner { async fn recover_stake(&mut self, node_id: &str) -> anyhow::Result { let node = self.nodes.get_mut(node_id).expect("node not found"); - let amount = self.elector.compute_returned_stake(&node.wallet_addr()).await?; - node.last_recover_amount = amount; - if amount > 0 { + + // Collect (staking_address_bytes, message_target) pairs for each pool. + // For pools: check pool address at elector, send recover TO the pool. + // For direct staking: check wallet address at elector, send recover TO the elector. + let recover_targets: Vec<(Vec, MsgAddressInt)> = match &node.pools { + Some(pools) => pools + .iter() + .map(|p| (p.address().address().clone().storage().to_vec(), p.address())) + .collect(), + None => { + let addr = node.wallet.address(); + vec![(addr.address().clone().storage().to_vec(), node.elector_address.clone())] + } + }; + + let mut total_amount = 0u64; + for (staking_addr, target_addr) in recover_targets { + let amount = self.elector.compute_returned_stake(&staking_addr).await?; + if amount == 0 { + continue; + } tracing::info!( - "node [{}] send recover stake: amount={} TON", + "node [{}] send recover stake: target={}, amount={} TON", node_id, + target_addr, amount as f64 / 1_000_000_000.0 ); let fee = RECOVER_FEE + WALLET_COMPUTE_FEE; @@ -743,15 +788,18 @@ impl ElectionRunner { &node .wallet .message( - node.elections_addr(), + target_addr, RECOVER_FEE, Self::build_recover_stake_payload().await?, ) .await?, )?; node.api.send_boc(&msg_boc).await?; + total_amount += amount; } - Ok(amount) + + node.last_recover_amount = total_amount; + Ok(total_amount) } pub(crate) async fn shutdown(&mut self) -> anyhow::Result<()> { @@ -799,6 +847,7 @@ impl ElectionRunner { past_elections: &[PastElections], elections_stake: u64, // stake sent to the elections but not yet accepted by the elector min_stake: u64, + election_id: u64, ) -> anyhow::Result { tracing::info!("node [{}] calc stake", node_id); let fee = ELECTOR_STAKE_FEE + NPOOL_COMPUTE_FEE; @@ -817,7 +866,7 @@ impl ElectionRunner { } // Get pool free balance - let pool_free_balance = node.stake_balance(fee).await?; + let pool_free_balance = node.stake_balance(fee, election_id).await?; let total_balance = frozen_stake + pool_free_balance + elections_stake; tracing::info!( "node [{}] frozen_stake={} TON, pool_balance={} TON, elections_stake={} TON, total_balance={} TON", @@ -952,7 +1001,10 @@ impl ElectionRunner { let participant = node.participant.as_ref(); let wallet_addr = Some(node.wallet.address().to_string()); - let pool_addr = node.pool.as_ref().map(|p| p.address().to_string()); + let pool_addr = node + .pools + .as_ref() + .map(|p| p.iter().map(|w| w.address().to_string()).collect::>().join(", ")); let pubkey = validator_entry .as_ref() .map(|(_, entry)| { @@ -1096,7 +1148,10 @@ impl ElectionRunner { let node = self.nodes.get(&node_id).expect("node not found"); let participant = node.participant.as_ref(); let wallet_addr = Some(node.wallet.address().to_string()); - let pool_addr = node.pool.as_ref().map(|p| p.address().to_string()); + let pool_addr = node + .pools + .as_ref() + .map(|p| p.iter().map(|w| w.address().to_string()).collect::>().join(", ")); let pubkey = participant.map(|p| { base64::Engine::encode( @@ -1130,7 +1185,8 @@ impl ElectionRunner { }) .collect(); - let fallback_sender_addr = format!("-1:{}", hex::encode(node.wallet_addr())); + let election_id_for_addr = participant.map(|p| p.election_id).unwrap_or(0); + let fallback_sender_addr = format!("-1:{}", hex::encode(node.wallet_addr(election_id_for_addr))); let accepted_stake = if node.stake_accepted { node.accepted_stake_amount.map(nanotons_to_dec_string).or_else(|| { node.stake_submissions.last().map(|s| nanotons_to_dec_string(s.stake)) diff --git a/src/node-control/elections/src/runner_tests.rs b/src/node-control/elections/src/runner_tests.rs index 415dc07..7a7bc0f 100644 --- a/src/node-control/elections/src/runner_tests.rs +++ b/src/node-control/elections/src/runner_tests.rs @@ -14,7 +14,7 @@ use common::{ time_format, }; use contracts::{ - ElectionsInfo, ElectorWrapper, NominatorWrapper, Participant, TonWallet, + ElectionsInfo, ElectorWrapper, NodePools, NominatorWrapper, Participant, TonWallet, elector::{FrozenParticipant, PastElections}, nominator::{NominatorRoles, PoolData, opcodes}, }; @@ -357,9 +357,12 @@ impl TestHarness { let mut providers: HashMap> = HashMap::new(); providers.insert(node_id.to_string(), Box::new(self.provider_mock)); - let mut pools: HashMap> = HashMap::new(); + let mut pools: HashMap = HashMap::new(); if let Some(pool) = self.pool_mock { - pools.insert(node_id.to_string(), Arc::new(pool)); + pools.insert( + node_id.to_string(), + NodePools { even: Arc::new(pool), odd: None }, + ); } let elector: Arc = Arc::new(self.elector_mock); @@ -1594,7 +1597,7 @@ async fn test_node_without_wallet_skipped() { providers.insert("node-1".to_string(), Box::new(provider1)); let wallets: HashMap> = HashMap::new(); // empty! - let pools: HashMap> = HashMap::new(); + let pools: HashMap = HashMap::new(); let runner = ElectionRunner::new( &elections_config, diff --git a/src/node-control/service/src/auth/user_store.rs b/src/node-control/service/src/auth/user_store.rs index 6ee6b0a..b38d79c 100644 --- a/src/node-control/service/src/auth/user_store.rs +++ b/src/node-control/service/src/auth/user_store.rs @@ -271,7 +271,7 @@ mod tests { use super::*; use crate::runtime_config::RuntimeConfig; use common::app_config::{AppConfig, AuthConfig, UserEntry}; - use contracts::{NominatorWrapper, TonWallet}; + use contracts::{NodePools, TonWallet}; use secrets_vault::{ crypto::{key_material::KeyMaterial, master_key::MasterKey}, storage::file_json::FileJsonStorage, @@ -370,7 +370,7 @@ mod tests { Arc::new(NoopWallet) } - fn pools(&self) -> Arc>> { + fn pools(&self) -> Arc> { Arc::new(HashMap::new()) } diff --git a/src/node-control/service/src/contracts/contracts_task.rs b/src/node-control/service/src/contracts/contracts_task.rs index cc219b8..cb33c18 100644 --- a/src/node-control/service/src/contracts/contracts_task.rs +++ b/src/node-control/service/src/contracts/contracts_task.rs @@ -9,7 +9,7 @@ use crate::runtime_config::RuntimeConfig; use anyhow::Context; use common::{app_config::AppConfig, snapshot::SnapshotStore, task_cancellation::CancellationCtx}; -use contracts::{NominatorWrapper, TonWallet, contract_provider}; +use contracts::{NodePools, NominatorWrapper, TonWallet, contract_provider}; use std::{ collections::{HashMap, HashSet}, sync::Arc, @@ -44,7 +44,7 @@ pub(crate) async fn run( struct ContractsMonitor { master_wallet: Arc, - pools: Arc>>, + pools: Arc>, wallets: Arc>>, rpc_client: Arc, _store: Arc, @@ -254,18 +254,20 @@ impl ContractsMonitor { /// Returns `false` if master balance is insufficient (caller should sleep). async fn ensure_pools_deployed(&self, seqno: &mut i64) -> anyhow::Result { let mut all_deployed = true; - for (node_id, pool) in self.pools.iter() { - match self.deploy_pool(&node_id, pool.clone(), *seqno).await { - Ok(true) => (), - Ok(false) => { - all_deployed = false; - *seqno += 1; - } - Err(e) => { - all_deployed = false; - tracing::error!(target: "contracts", "[{}] deploy pool error: {:#}", node_id, e); - } - }; + for (node_id, node_pools) in self.pools.iter() { + for pool in node_pools.iter() { + match self.deploy_pool(node_id, pool.clone(), *seqno).await { + Ok(true) => (), + Ok(false) => { + all_deployed = false; + *seqno += 1; + } + Err(e) => { + all_deployed = false; + tracing::error!(target: "contracts", "[{}] deploy pool error: {:#}", node_id, e); + } + }; + } } Ok(all_deployed) } @@ -407,7 +409,7 @@ mod tests { use super::ContractsMonitor; use axum::{Json, Router, extract::State, routing::post}; use common::snapshot::SnapshotStore; - use contracts::{NominatorWrapper, SmartContract, TonWallet}; + use contracts::{NodePools, SmartContract, TonWallet}; use std::{ collections::HashMap, sync::{ @@ -582,7 +584,7 @@ mod tests { let rpc_client = Arc::new(ClientJsonRpc::connect(rpc_url, None).unwrap()); ContractsMonitor { master_wallet, - pools: Arc::>>::default(), + pools: Arc::>::default(), wallets, rpc_client, _store: Arc::new(SnapshotStore::new()), diff --git a/src/node-control/service/src/runtime_config.rs b/src/node-control/service/src/runtime_config.rs index 57c19fb..cb7152b 100644 --- a/src/node-control/service/src/runtime_config.rs +++ b/src/node-control/service/src/runtime_config.rs @@ -13,8 +13,8 @@ use common::{ vault_signer::VaultSigner, }; use contracts::{ - NominatorPoolWrapperImpl, NominatorWrapper, NominatorWrapperImpl, TonWallet, WalletContract, - contract_provider, resolve_toncore_pools, + NodePools, NominatorPoolWrapperImpl, NominatorWrapper, NominatorWrapperImpl, TonWallet, + WalletContract, contract_provider, resolve_toncore_pools, }; use secrets_vault::{ types::{algorithm::Algorithm, secret_id::SecretId, secret_spec::SecretSpec}, @@ -50,7 +50,7 @@ struct RuntimeState { /// Optional secrets vault for key management. vault: Option>, /// Lazily-loaded nominator pools, rebuilt when config changes. - pools: Arc>>, + pools: Arc>, /// Lazily-loaded wallets, rebuilt when config changes. wallets: Arc>>, /// Shared TON HTTP API JSON-RPC client. @@ -76,7 +76,7 @@ impl std::error::Error for RuntimeConfigError {} pub trait RuntimeConfig: Send + Sync { fn get(&self) -> Arc; fn master_wallet(&self) -> Arc; - fn pools(&self) -> Arc>>; + fn pools(&self) -> Arc>; fn wallets(&self) -> Arc>>; fn rpc_client(&self) -> Arc; fn vault(&self) -> Option>; @@ -325,7 +325,7 @@ impl RuntimeConfigStore { app_config: &AppConfig, rpc_client: Arc, wallets: &HashMap>, - ) -> anyhow::Result>>> { + ) -> anyhow::Result>> { let mut map = HashMap::new(); for (node_name, binding) in app_config.bindings.iter() { if let Some(pool_name) = &binding.pool { @@ -337,16 +337,13 @@ impl RuntimeConfigStore { .get(node_name) .context(format!("validator wallet not found: {}", node_name))? .address(); - let pool = open_nominator_pool(cfg, rpc_client.clone(), &validator_address) + let pools = open_nominator_pool(cfg, rpc_client.clone(), &validator_address) .map_err(|e| { anyhow::anyhow!("node [{}] open nominator pool error: {:#}", node_name, e) })?; - tracing::info!( - "[{}] opened nominator pool: address={}", - node_name, - pool.address().to_string() - ); - map.insert(node_name.to_owned(), pool); + let addrs: Vec = pools.iter().map(|p| p.address().to_string()).collect(); + tracing::info!("[{}] opened nominator pool(s): {}", node_name, addrs.join(", ")); + map.insert(node_name.to_owned(), pools); } } Ok(Arc::new(map)) @@ -391,7 +388,7 @@ impl RuntimeConfig for RuntimeConfigStore { Arc::clone(&state.master_wallet) } - fn pools(&self) -> Arc>> { + fn pools(&self) -> Arc> { let state = self.state.read().expect("Runtime state poisoned (read)"); Arc::clone(&state.pools) } @@ -468,7 +465,7 @@ fn open_nominator_pool( config: &PoolConfig, rpc_client: Arc, validator_addr: &MsgAddressInt, -) -> anyhow::Result> { +) -> anyhow::Result { match config { PoolConfig::SNP { address, owner } => { let pool = match (address, owner) { @@ -513,7 +510,7 @@ fn open_nominator_pool( anyhow::bail!("pool has neither address nor owner configured"); } }; - Ok(Arc::new(pool)) + Ok(NodePools { even: Arc::new(pool), odd: None }) } PoolConfig::TONCore { validator_share, @@ -532,12 +529,15 @@ fn open_nominator_pool( min_validator_stake.as_ref().copied(), min_nominator_stake.as_ref().copied(), )?; - // TODO: return both even/odd wrappers when elections runner supports round-based selection - let even = NominatorPoolWrapperImpl::new( + let even: Arc = Arc::new(NominatorPoolWrapperImpl::new( contract_provider!(rpc_client.clone()), resolved.even_address, - ); - Ok(Arc::new(even)) + )); + let odd: Arc = Arc::new(NominatorPoolWrapperImpl::new( + contract_provider!(rpc_client.clone()), + resolved.odd_address, + )); + Ok(NodePools { even, odd: Some(odd) }) } } } diff --git a/src/node-control/service/src/task/task_manager.rs b/src/node-control/service/src/task/task_manager.rs index ff87136..24b3a44 100644 --- a/src/node-control/service/src/task/task_manager.rs +++ b/src/node-control/service/src/task/task_manager.rs @@ -175,7 +175,7 @@ impl TaskController { mod tests { use super::*; use common::app_config::{HttpConfig, TonHttpApiConfig}; - use contracts::{NominatorWrapper, TonWallet}; + use contracts::{NodePools, TonWallet}; use secrets_vault::vault::SecretVault; use std::{ collections::HashMap, @@ -194,7 +194,7 @@ mod tests { fn master_wallet(&self) -> Arc { unimplemented!() } - fn pools(&self) -> Arc>> { + fn pools(&self) -> Arc> { unimplemented!() } fn wallets(&self) -> Arc>> { From 296c9f0721ff3d8bd694f040f6cb47844ec696c6 Mon Sep 17 00:00:00 2001 From: mrnkslv Date: Fri, 3 Apr 2026 10:18:42 +0300 Subject: [PATCH 11/18] feat: implement logic ton core nominator with 1 pool --- src/node-control/README.md | 4 +- .../src/commands/nodectl/config_pool_cmd.rs | 52 +++---- .../src/commands/nodectl/config_wallet_cmd.rs | 12 +- .../src/commands/nodectl/deploy_cmd.rs | 12 +- src/node-control/common/src/app_config.rs | 25 +--- src/node-control/contracts/src/lib.rs | 6 +- .../contracts/src/nominator/wrapper.rs | 28 ---- .../contracts/src/ton_core_nominator.rs | 4 +- .../ton_core_nominator/ton_core_nominator.rs | 65 ++++---- .../elections/src/election_task.rs | 4 +- src/node-control/elections/src/runner.rs | 139 +++++------------- .../elections/src/runner_tests.rs | 11 +- .../service/src/auth/user_store.rs | 4 +- .../service/src/contracts/contracts_task.rs | 34 ++--- .../service/src/runtime_config.rs | 47 +++--- .../service/src/task/task_manager.rs | 4 +- 16 files changed, 157 insertions(+), 294 deletions(-) diff --git a/src/node-control/README.md b/src/node-control/README.md index c58540a..bda81c0 100644 --- a/src/node-control/README.md +++ b/src/node-control/README.md @@ -1352,8 +1352,8 @@ Nominator pool configurations. Two pool types are supported: - `addresses` — two addresses: validator wallet (`[0]`) and pool contract (`[1]`, must match the address derived from the parameters below) - `validator_share` — validator reward share (basis points; stored as `u16` on-chain) - `max_nominators` — optional; if omitted, defaults from the contracts module are used (40 nominators) -- `min_validator_stake` — optional (nanotons); if omitted, defaults from the contracts module are used (100 TON) -- `min_nominator_stake` — optional (nanotons); if omitted, defaults from the contracts module are used (10 TON) +- `min_validator_stake` — optional (nanotons); if omitted, defaults from the contracts module are used (100,000 TON) +- `min_nominator_stake` — optional (nanotons); if omitted, defaults from the contracts module are used (10,000 TON) #### `bindings` diff --git a/src/node-control/commands/src/commands/nodectl/config_pool_cmd.rs b/src/node-control/commands/src/commands/nodectl/config_pool_cmd.rs index c5b5598..dad9f11 100644 --- a/src/node-control/commands/src/commands/nodectl/config_pool_cmd.rs +++ b/src/node-control/commands/src/commands/nodectl/config_pool_cmd.rs @@ -22,7 +22,7 @@ use common::{ }; use contracts::{ NOMINATOR_POOL_WORKCHAIN, NominatorWrapperImpl, TonWallet, resolve_deploy_pool_params, - resolve_toncore_pools, ton_core_nominator::messages as pool_messages, + resolve_toncore_pool, ton_core_nominator::messages as pool_messages, }; use secrets_vault::{vault::SecretVault, vault_builder::SecretVaultBuilder}; use std::{io::Write, path::Path, str::FromStr, sync::Arc}; @@ -69,7 +69,7 @@ pub struct PoolAddCmd { #[arg( short = 'a', long = "address", - help = "SNP: pool contract address, raw or base64url (if already deployed)" + help = "Pool contract address, raw or base64url (optional; derived on deploy if omitted)" )] address: Option, #[arg( @@ -78,19 +78,9 @@ pub struct PoolAddCmd { help = "SNP: owner address, raw or base64url (for deployment/verification)" )] owner: Option, - #[arg( - long = "pool-addr-even", - help = "Core: even-round pool contract address (optional; derived from validator wallet if omitted)" - )] - pool_addr_even: Option, - #[arg( - long = "pool-addr-odd", - help = "Core: odd-round pool contract address (derived with min_validator_stake+1; optional)" - )] - pool_addr_odd: Option, #[arg( long = "validator-share", - help = "Core: validator reward share encoded on-chain (basis points, 0–65535; e.g. 5000 ≈ 50%)" + help = "Core: validator reward share (basis points, 0–65535; e.g. 5000 ≈ 50%)" )] validator_share: Option, #[arg(long = "max-nominators", help = "Core: max nominators (default: 40)")] @@ -207,11 +197,10 @@ impl PoolAddCmd { .validator_share .ok_or_else(|| anyhow::anyhow!("For core: --validator-share is required"))?; - let a1 = self.pool_addr_even.as_deref() - .map(|a| normalize_ton_address(a, "pool-addr-even")) - .transpose()?; - let a2 = self.pool_addr_odd.as_deref() - .map(|a| normalize_ton_address(a, "pool-addr-odd")) + let normalized_address = self + .address + .as_deref() + .map(|a| normalize_ton_address(a, "address")) .transpose()?; let (mx, mv, mn) = resolve_deploy_pool_params( @@ -220,18 +209,16 @@ impl PoolAddCmd { self.min_nominator_stake_nano, ); let info = format!( - "kind=core validator_share={}, even={}, odd={}, deploy_params: max_nominators={}, min_validator_stake={}, min_nominator_stake={}", + "kind=core validator_share={}, address={}, deploy_params: max_nominators={}, min_validator_stake={}, min_nominator_stake={}", share, - a1.as_deref().unwrap_or(""), - a2.as_deref().unwrap_or(""), + normalized_address.as_deref().unwrap_or(""), mx, mv, mn ); ( PoolConfig::TONCore { validator_share: share, - even_pool_address: a1, - odd_pool_address: a2, + address: normalized_address, max_nominators: self.max_nominators, min_validator_stake: self.min_validator_stake_nano, min_nominator_stake: self.min_nominator_stake_nano, @@ -349,17 +336,14 @@ async fn collect_pool_views( validator_share: None, }); } - PoolConfig::TONCore { validator_share, even_pool_address, odd_pool_address, .. } => { - let mut addrs = Vec::new(); - addrs.push(even_pool_address.clone().unwrap_or_else(|| "".into())); - addrs.push(odd_pool_address.clone().unwrap_or_else(|| "".into())); + PoolConfig::TONCore { validator_share, address, .. } => { views.push(PoolView { name: name.clone(), kind: "Core".to_string(), balance: None, - address: None, + address: address.clone(), owner: None, - addresses: Some(addrs), + addresses: None, validator_share: Some(*validator_share), }); } @@ -547,22 +531,20 @@ fn resolve_toncore_pool_address( match pool_cfg { PoolConfig::TONCore { validator_share, - even_pool_address, - odd_pool_address, + address, max_nominators, min_validator_stake, min_nominator_stake, } => { - let resolved = resolve_toncore_pools( + let resolved = resolve_toncore_pool( wallet_address, *validator_share, - even_pool_address.as_deref(), - odd_pool_address.as_deref(), + address.as_deref(), max_nominators.as_ref().copied(), min_validator_stake.as_ref().copied(), min_nominator_stake.as_ref().copied(), )?; - Ok(resolved.even_address) + Ok(resolved.address) } PoolConfig::SNP { .. } => { anyhow::bail!("This command is only supported for TONCore pools, not SNP"); diff --git a/src/node-control/commands/src/commands/nodectl/config_wallet_cmd.rs b/src/node-control/commands/src/commands/nodectl/config_wallet_cmd.rs index d8ec4f9..a627868 100644 --- a/src/node-control/commands/src/commands/nodectl/config_wallet_cmd.rs +++ b/src/node-control/commands/src/commands/nodectl/config_wallet_cmd.rs @@ -25,7 +25,7 @@ use common::{ }; use contracts::{ ElectorWrapper, ElectorWrapperImpl, NominatorWrapperImpl, TonWallet, contract_provider, - nominator, resolve_toncore_pools, + nominator, resolve_toncore_pool, }; use elections::providers::{DefaultElectionsProvider, ElectionsProvider}; use secrets_vault::{errors::error::VaultError, vault::SecretVault}; @@ -707,22 +707,20 @@ fn resolve_pool_address( }, PoolConfig::TONCore { validator_share, - even_pool_address, - odd_pool_address, + address, max_nominators, min_validator_stake, min_nominator_stake, } => { - let resolved = resolve_toncore_pools( + let resolved = resolve_toncore_pool( validator_addr, *validator_share, - even_pool_address.as_deref(), - odd_pool_address.as_deref(), + address.as_deref(), max_nominators.as_ref().copied(), min_validator_stake.as_ref().copied(), min_nominator_stake.as_ref().copied(), )?; - Ok(resolved.even_address) + Ok(resolved.address) } } } diff --git a/src/node-control/commands/src/commands/nodectl/deploy_cmd.rs b/src/node-control/commands/src/commands/nodectl/deploy_cmd.rs index 0bdc892..8d8951b 100644 --- a/src/node-control/commands/src/commands/nodectl/deploy_cmd.rs +++ b/src/node-control/commands/src/commands/nodectl/deploy_cmd.rs @@ -16,7 +16,7 @@ use common::{ task_cancellation::CancellationCtx, ton_utils::{nanotons_to_tons_f64, tons_f64_to_nanotons}, }; -use contracts::{NominatorWrapperImpl, TonWallet, resolve_toncore_pools}; +use contracts::{NominatorWrapperImpl, TonWallet, resolve_toncore_pool}; use std::{cell::RefCell, collections::HashMap, path::Path, rc::Rc, sync::Arc}; use ton_block::{Cell, MsgAddressInt, write_boc}; use ton_http_api_client::v2::data_models::AccountState; @@ -324,23 +324,21 @@ impl DeployPoolCmd { let (pool_address, state_init) = match pool_cfg_opt { Some(PoolConfig::TONCore { validator_share, - even_pool_address, - odd_pool_address, + address, max_nominators, min_validator_stake, min_nominator_stake, }) => { - let resolved = resolve_toncore_pools( + let resolved = resolve_toncore_pool( &wallet_address, *validator_share, - even_pool_address.as_deref(), - odd_pool_address.as_deref(), + address.as_deref(), max_nominators.as_ref().copied(), min_validator_stake.as_ref().copied(), min_nominator_stake.as_ref().copied(), ) .map_err(set_err)?; - (resolved.even_address, resolved.even_state_init) + (resolved.address, resolved.state_init) } Some(PoolConfig::SNP { .. }) | None => { let owner = self.owner.as_ref().ok_or_else(|| { diff --git a/src/node-control/common/src/app_config.rs b/src/node-control/common/src/app_config.rs index a6c8d41..4d27479 100644 --- a/src/node-control/common/src/app_config.rs +++ b/src/node-control/common/src/app_config.rs @@ -340,14 +340,10 @@ pub enum PoolConfig { #[serde(rename = "core")] TONCore { validator_share: u16, - /// Even-round pool address. `None` = not deployed yet (will be derived from validator wallet). + /// Pool contract address. `None` = not deployed yet (will be derived from validator wallet). #[serde(default)] #[serde(skip_serializing_if = "Option::is_none")] - even_pool_address: Option, - /// Odd-round pool address (same params but `min_validator_stake + 1`). `None` = not deployed. - #[serde(default)] - #[serde(skip_serializing_if = "Option::is_none")] - odd_pool_address: Option, + address: Option, /// Deploy-time pool parameters; if omitted, defaults are applied in `contracts` (`resolve_deploy_pool_params`). #[serde(default)] #[serde(skip_serializing_if = "Option::is_none")] @@ -832,7 +828,7 @@ mod tests { } #[test] - fn test_pool_config_serde_core_no_addresses() { + fn test_pool_config_serde_core_no_address() { let value = serde_json::json!({ "kind": "core", "validator_share": 50, @@ -842,8 +838,7 @@ mod tests { cfg, PoolConfig::TONCore { validator_share: 50, - even_pool_address: None, - odd_pool_address: None, + address: None, max_nominators: None, min_validator_stake: None, min_nominator_stake: None, @@ -853,19 +848,16 @@ mod tests { let json = serde_json::to_value(&cfg).unwrap(); assert_eq!(json["kind"], "core"); assert_eq!(json["validator_share"], 50); - assert!(json.get("even_pool_address").is_none()); - assert!(json.get("odd_pool_address").is_none()); + assert!(json.get("address").is_none()); } #[test] - fn test_pool_config_serde_core_with_addresses() { + fn test_pool_config_serde_core_with_address() { let addr1 = ADDR; - let addr2 = OWNER; let value = serde_json::json!({ "kind": "core", "validator_share": 100, - "even_pool_address": addr1.to_string(), - "odd_pool_address": addr2.to_string(), + "address": addr1.to_string(), "max_nominators": 10, "min_validator_stake": 5_000_000_000_000u64, "min_nominator_stake": 1_000_000_000_000u64, @@ -875,8 +867,7 @@ mod tests { cfg, PoolConfig::TONCore { validator_share: 100, - even_pool_address: Some(addr1.to_string()), - odd_pool_address: Some(addr2.to_string()), + address: Some(addr1.to_string()), max_nominators: Some(10), min_validator_stake: Some(5_000_000_000_000), min_nominator_stake: Some(1_000_000_000_000), diff --git a/src/node-control/contracts/src/lib.rs b/src/node-control/contracts/src/lib.rs index dffc523..a436865 100644 --- a/src/node-control/contracts/src/lib.rs +++ b/src/node-control/contracts/src/lib.rs @@ -19,11 +19,11 @@ pub use config_contract::{ ConfigContractImpl, ConfigContractWrapper, ConfigProposal, ProposedParam, }; pub use elector::{ElectionsInfo, ElectorWrapper, ElectorWrapperImpl, Participant}; -pub use nominator::{NOMINATOR_POOL_WORKCHAIN, NodePools, NominatorWrapper, NominatorWrapperImpl}; +pub use nominator::{NOMINATOR_POOL_WORKCHAIN, NominatorWrapper, NominatorWrapperImpl}; pub use provider::ContractProvider; pub use smart_contract::SmartContract; pub use ton_core_nominator::{ - NominatorPoolWrapperImpl, ResolvedTonCorePools, resolve_deploy_pool_params, - resolve_toncore_pools, + NominatorPoolWrapperImpl, ResolvedTonCorePool, resolve_deploy_pool_params, + resolve_toncore_pool, }; pub use wallet::{TonWallet, WalletContract}; diff --git a/src/node-control/contracts/src/nominator/wrapper.rs b/src/node-control/contracts/src/nominator/wrapper.rs index 37e5f9b..8fde7d6 100644 --- a/src/node-control/contracts/src/nominator/wrapper.rs +++ b/src/node-control/contracts/src/nominator/wrapper.rs @@ -7,7 +7,6 @@ * This software is provided "AS IS", WITHOUT WARRANTY OF ANY KIND. */ use crate::SmartContract; -use std::sync::Arc; use ton_block::{MsgAddressInt, StateInit}; /// Trait for interacting with single-nominator smart contract @@ -70,30 +69,3 @@ pub struct PoolData { /// Stake held for duration pub stake_held_for: u64, } - -/// Pool wrappers for a single node binding. -/// -/// - **SNP**: single pool in `even`, `odd` is `None`. -/// - **TONCore**: `even` for even-round elections, `odd` for odd-round elections. -#[derive(Clone)] -pub struct NodePools { - pub even: Arc, - pub odd: Option>, -} - -impl NodePools { - /// Returns the pool that should be used for the given election round. - /// For SNP (odd == None) always returns `even`. - /// For TONCore selects by `election_id % 2`. - pub fn for_election(&self, election_id: u64) -> &Arc { - match &self.odd { - Some(odd) if election_id % 2 != 0 => odd, - _ => &self.even, - } - } - - /// Iterates over all pools (1 for SNP, 2 for TONCore). - pub fn iter(&self) -> impl Iterator> { - std::iter::once(&self.even).chain(self.odd.iter()) - } -} diff --git a/src/node-control/contracts/src/ton_core_nominator.rs b/src/node-control/contracts/src/ton_core_nominator.rs index ae911b5..b3ef0b2 100644 --- a/src/node-control/contracts/src/ton_core_nominator.rs +++ b/src/node-control/contracts/src/ton_core_nominator.rs @@ -12,6 +12,6 @@ pub mod messages; mod ton_core_nominator; pub use ton_core_nominator::{ - NominatorPoolWrapperImpl, ResolvedTonCorePools, resolve_deploy_pool_params, - resolve_toncore_pools, + NominatorPoolWrapperImpl, ResolvedTonCorePool, resolve_deploy_pool_params, + resolve_toncore_pool, }; diff --git a/src/node-control/contracts/src/ton_core_nominator/ton_core_nominator.rs b/src/node-control/contracts/src/ton_core_nominator/ton_core_nominator.rs index 751cc0b..ddd2032 100644 --- a/src/node-control/contracts/src/ton_core_nominator/ton_core_nominator.rs +++ b/src/node-control/contracts/src/ton_core_nominator/ton_core_nominator.rs @@ -45,72 +45,52 @@ pub fn resolve_deploy_pool_params( ) } -/// Resolved even/odd pool addresses (and optionally `StateInit`) for a TONCore config. -pub struct ResolvedTonCorePools { +/// Resolved pool address and `StateInit` for a TONCore config. +pub struct ResolvedTonCorePool { pub reward_share: u16, pub max_nominators: u16, pub min_validator_stake: u64, pub min_nominator_stake: u64, - pub even_address: MsgAddressInt, - pub even_state_init: StateInit, - pub odd_address: MsgAddressInt, - pub odd_state_init: StateInit, + pub address: MsgAddressInt, + pub state_init: StateInit, } -/// Validate and resolve both even/odd pool addresses from TONCore config fields. +/// Validate and resolve the pool address from TONCore config fields. /// -/// Resolves deploy-time defaults, calculates deterministic addresses, and — if explicit -/// addresses are provided — verifies they match the derived ones. -pub fn resolve_toncore_pools( +/// Resolves deploy-time defaults, calculates the deterministic address, and — if an explicit +/// address is provided — verifies it matches the derived one. +pub fn resolve_toncore_pool( validator_addr: &MsgAddressInt, validator_share: u16, - even_pool_address: Option<&str>, - odd_pool_address: Option<&str>, + pool_address: Option<&str>, max_nominators: Option, min_validator_stake: Option, min_nominator_stake: Option, -) -> anyhow::Result { +) -> anyhow::Result { let (max_n, min_v, min_n) = resolve_deploy_pool_params(max_nominators, min_validator_stake, min_nominator_stake); - let (even_address, even_state_init) = + let (address, state_init) = NominatorPoolWrapperImpl::calculate_address_with_state_init( validator_addr, validator_share, max_n, min_v, min_n, )?; - if let Some(addr) = even_pool_address { + if let Some(addr) = pool_address { let explicit = addr .parse::() - .context(format!("invalid TONCore even_pool_address: {addr}"))?; + .context(format!("invalid TONCore pool address: {addr}"))?; anyhow::ensure!( - explicit == even_address, - "TONCore even_pool_address ({explicit}) does not match derived address ({even_address})" + explicit == address, + "TONCore pool address ({explicit}) does not match derived address ({address})" ); } - let min_v_odd = min_v.saturating_add(1); - let (odd_address, odd_state_init) = - NominatorPoolWrapperImpl::calculate_address_with_state_init( - validator_addr, validator_share, max_n, min_v_odd, min_n, - )?; - if let Some(addr) = odd_pool_address { - let explicit = addr - .parse::() - .context(format!("invalid TONCore odd_pool_address: {addr}"))?; - anyhow::ensure!( - explicit == odd_address, - "TONCore odd_pool_address ({explicit}) does not match derived address ({odd_address})" - ); - } - - Ok(ResolvedTonCorePools { + Ok(ResolvedTonCorePool { reward_share: validator_share, max_nominators: max_n, min_validator_stake: min_v, min_nominator_stake: min_n, - even_address, - even_state_init, - odd_address, - odd_state_init, + address, + state_init, }) } @@ -137,6 +117,15 @@ impl NominatorPoolWrapperImpl { Self { provider, pool_addr, state_init: None } } + /// Wrap a pool at a known address with a pre-computed `StateInit` (for deployment). + pub fn new_with_state_init( + provider: Arc, + pool_addr: MsgAddressInt, + state_init: StateInit, + ) -> Self { + Self { provider, pool_addr, state_init: Some(state_init) } + } + /// Create a wrapper with deployment data (for pools that are not yet deployed). /// /// The pool address is derived deterministically from the `StateInit`. diff --git a/src/node-control/elections/src/election_task.rs b/src/node-control/elections/src/election_task.rs index 7e7cf06..ee117ff 100644 --- a/src/node-control/elections/src/election_task.rs +++ b/src/node-control/elections/src/election_task.rs @@ -16,7 +16,7 @@ use common::{ snapshot::SnapshotStore, task_cancellation::CancellationCtx, }; -use contracts::{ElectorWrapperImpl, NodePools, TonWallet, contract_provider}; +use contracts::{ElectorWrapperImpl, NominatorWrapper, TonWallet, contract_provider}; use secrets_vault::vault::SecretVault; use std::{collections::HashMap, sync::Arc, time::Duration}; use ton_http_api_client::v2::client_json_rpc::ClientJsonRpc; @@ -29,7 +29,7 @@ pub async fn run( app_config: Arc, rpc_client: Arc, wallets: Arc>>, - pools: Arc>, + pools: Arc>>, store: Arc, vault: Option>, on_status_change: Option, diff --git a/src/node-control/elections/src/runner.rs b/src/node-control/elections/src/runner.rs index 3ad9e3b..0290a05 100644 --- a/src/node-control/elections/src/runner.rs +++ b/src/node-control/elections/src/runner.rs @@ -20,7 +20,7 @@ use common::{ ton_utils::nanotons_to_dec_string, }; use contracts::{ - ElectionsInfo, ElectorWrapper, NodePools, NominatorWrapper, Participant, TonWallet, + ElectionsInfo, ElectorWrapper, NominatorWrapper, Participant, TonWallet, elector::PastElections, nominator, }; use std::{ @@ -84,10 +84,11 @@ struct Node { /// Computed in build_validators_snapshot, used by build_our_participants_snapshot. is_next_validator: bool, wallet: Arc, - /// Nominator pools for this node. `None` = direct staking (no pool). - pools: Option, - /// Elector address (used as elections target when no pool is configured). - elector_address: MsgAddressInt, + /// Nominator pool instance. Optional — `None` means direct staking. + pool: Option>, + /// Address to which to send commands: stake & recover. + /// Pool address when a pool is configured, elector address otherwise. + elections_address: MsgAddressInt, /// Last error observed for this node during the current/previous tick (stringified). last_error: Option, /// Excluded from elections (enable = false). @@ -103,15 +104,9 @@ struct Node { } impl Node { - /// Returns the active pool for the given election round, or `None` for direct staking. - fn active_pool(&self, election_id: u64) -> Option<&Arc> { - self.pools.as_ref().map(|p| p.for_election(election_id)) - } - - /// Address used as the "sender" in the elector's participant list. - /// Pool address for pool-based staking, wallet address for direct staking. - fn wallet_addr(&self, election_id: u64) -> Vec { - self.active_pool(election_id) + fn wallet_addr(&self) -> Vec { + self.pool + .as_ref() .map(|p| p.address()) .unwrap_or_else(|| self.wallet.address()) .address() @@ -119,25 +114,9 @@ impl Node { .storage() .to_vec() } - - /// All addresses that may have stakes at the elector (for recovery and snapshot matching). - fn all_staking_addresses(&self) -> Vec> { - match &self.pools { - Some(pools) => pools - .iter() - .map(|p| p.address().address().clone().storage().to_vec()) - .collect(), - None => vec![self.wallet.address().address().clone().storage().to_vec()], - } - } - - /// Target address for stake/recover messages for the given election round. - fn elections_addr(&self, election_id: u64) -> MsgAddressInt { - self.active_pool(election_id) - .map(|p| p.address()) - .unwrap_or_else(|| self.elector_address.clone()) + fn elections_addr(&self) -> MsgAddressInt { + self.elections_address.clone() } - fn reset_participation(&mut self) { self.participant = None; self.submission_time = None; @@ -145,9 +124,8 @@ impl Node { self.accepted_stake_amount = None; self.stake_submissions.clear(); } - - async fn stake_balance(&mut self, gas_fee: u64, election_id: u64) -> anyhow::Result { - match self.active_pool(election_id) { + async fn stake_balance(&mut self, gas_fee: u64) -> anyhow::Result { + match self.pool.as_ref() { Some(pool) => self.api.account(&pool.address().to_string()).await.map(|x| x.balance()), None => self .api @@ -157,11 +135,9 @@ impl Node { } .map(|b| b.saturating_sub(MIN_NANOTON_FOR_STORAGE)) } - async fn wallet_balance(&mut self) -> anyhow::Result { self.api.account(&self.wallet.address().to_string()).await.map(|x| x.balance()) } - async fn find_election_key(&mut self, election_id: u64) -> Option { let mut validator_entry = self.validator_config.find(election_id); if let Some(entry) = validator_entry.as_mut() { @@ -230,9 +206,8 @@ impl ElectionRunner { elector: Arc, providers: HashMap>, wallets: Arc>>, - pools: Arc>, + pools: Arc>>, ) -> Self { - let elector_address = elector.address(); Self { default_max_factor: elections_config.max_factor, default_stake_policy: elections_config.policy.clone(), @@ -246,7 +221,7 @@ impl ElectionRunner { return None; } }; - let node_pools = pools.get(&node_id).cloned(); + 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); @@ -255,9 +230,12 @@ impl ElectionRunner { node_id, Node { api: provider, - elector_address: elector_address.clone(), + elections_address: pool + .as_ref() + .map(|p| p.address()) + .unwrap_or_else(|| elector.address()), wallet, - pools: node_pools, + pool, excluded, stake_policy, key_id: vec![], @@ -382,7 +360,7 @@ impl ElectionRunner { node.stake_accepted = false; node.accepted_stake_amount = None; if let Some(p) = - elections_info.participants.iter().find(|p| p.wallet_addr == node.wallet_addr(election_id)) + elections_info.participants.iter().find(|p| p.wallet_addr == node.wallet_addr()) { node.stake_accepted = true; node.accepted_stake_amount = Some(p.stake); @@ -448,7 +426,7 @@ impl ElectionRunner { // Include all pool addresses (even + odd for TONCore) so we can match any participant. let wallet_addrs: HashSet> = - self.nodes.values().flat_map(|node| node.all_staking_addresses()).collect(); + self.nodes.values().map(|node| node.wallet_addr()).collect(); let participants = Self::build_participants_snapshot(elections_info, &wallet_addrs); let participant_min_stake = @@ -520,7 +498,6 @@ impl ElectionRunner { &self.past_elections, participant.as_ref().map(|p| p.stake).unwrap_or(0), elections_info.min_stake, - election_id, ) .await .context("stake calculation error")?; @@ -575,12 +552,12 @@ impl ElectionRunner { pub_key, adnl_addr, election_id, - wallet_addr: node.wallet_addr(election_id), + wallet_addr: node.wallet_addr(), stake, max_factor, }); node.key_id = key_id; - Self::send_stake(node_id, node, stake, election_id).await?; + Self::send_stake(node_id, node, stake).await?; Ok(()) } Some(entry) => { @@ -633,7 +610,7 @@ impl ElectionRunner { .ok_or_else(|| anyhow::anyhow!("no adnl address"))?, pub_key: entry.public_key, election_id, - wallet_addr: node.wallet_addr(election_id), + wallet_addr: node.wallet_addr(), stake, max_factor, }); @@ -642,7 +619,7 @@ impl ElectionRunner { if let Some(p) = node.participant.as_mut() { p.stake = stake; } - Self::send_stake(node_id, node, stake, election_id).await?; + Self::send_stake(node_id, node, stake).await?; } } Ok(()) @@ -650,16 +627,11 @@ impl ElectionRunner { } } - async fn send_stake( - node_id: &str, - node: &mut Node, - stake: u64, - election_id: u64, - ) -> anyhow::Result<()> { + 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 fee = ELECTOR_STAKE_FEE + NPOOL_COMPUTE_FEE; - let stake_balance = node.stake_balance(fee, election_id).await?; + let stake_balance = node.stake_balance(fee).await?; if stake_balance < stake { anyhow::bail!( "low stake balance: required={} TON, available={} TON", @@ -676,10 +648,9 @@ impl ElectionRunner { ); } - let send_value = node.active_pool(election_id).map(|_| fee).unwrap_or(stake + fee); - let elections_addr = node.elections_addr(election_id); + let send_value = node.pool.as_ref().map(|_| fee).unwrap_or(stake + fee); let msg_boc = - write_boc(&node.wallet.message(elections_addr, send_value, payload).await?)?; + write_boc(&node.wallet.message(node.elections_addr(), send_value, payload).await?)?; tracing::debug!("wallet external message: boc={}", hex::encode(&msg_boc)); tracing::info!("node [{}] send stake", node_id); node.api.send_boc(&msg_boc).await?; @@ -747,31 +718,12 @@ impl ElectionRunner { async fn recover_stake(&mut self, node_id: &str) -> anyhow::Result { let node = self.nodes.get_mut(node_id).expect("node not found"); - - // Collect (staking_address_bytes, message_target) pairs for each pool. - // For pools: check pool address at elector, send recover TO the pool. - // For direct staking: check wallet address at elector, send recover TO the elector. - let recover_targets: Vec<(Vec, MsgAddressInt)> = match &node.pools { - Some(pools) => pools - .iter() - .map(|p| (p.address().address().clone().storage().to_vec(), p.address())) - .collect(), - None => { - let addr = node.wallet.address(); - vec![(addr.address().clone().storage().to_vec(), node.elector_address.clone())] - } - }; - - let mut total_amount = 0u64; - for (staking_addr, target_addr) in recover_targets { - let amount = self.elector.compute_returned_stake(&staking_addr).await?; - if amount == 0 { - continue; - } + let amount = self.elector.compute_returned_stake(&node.wallet_addr()).await?; + node.last_recover_amount = amount; + if amount > 0 { tracing::info!( - "node [{}] send recover stake: target={}, amount={} TON", + "node [{}] send recover stake: amount={} TON", node_id, - target_addr, amount as f64 / 1_000_000_000.0 ); let fee = RECOVER_FEE + WALLET_COMPUTE_FEE; @@ -788,18 +740,15 @@ impl ElectionRunner { &node .wallet .message( - target_addr, + node.elections_addr(), RECOVER_FEE, Self::build_recover_stake_payload().await?, ) .await?, )?; node.api.send_boc(&msg_boc).await?; - total_amount += amount; } - - node.last_recover_amount = total_amount; - Ok(total_amount) + Ok(amount) } pub(crate) async fn shutdown(&mut self) -> anyhow::Result<()> { @@ -847,7 +796,6 @@ impl ElectionRunner { past_elections: &[PastElections], elections_stake: u64, // stake sent to the elections but not yet accepted by the elector min_stake: u64, - election_id: u64, ) -> anyhow::Result { tracing::info!("node [{}] calc stake", node_id); let fee = ELECTOR_STAKE_FEE + NPOOL_COMPUTE_FEE; @@ -866,7 +814,7 @@ impl ElectionRunner { } // Get pool free balance - let pool_free_balance = node.stake_balance(fee, election_id).await?; + let pool_free_balance = node.stake_balance(fee).await?; let total_balance = frozen_stake + pool_free_balance + elections_stake; tracing::info!( "node [{}] frozen_stake={} TON, pool_balance={} TON, elections_stake={} TON, total_balance={} TON", @@ -1001,10 +949,7 @@ impl ElectionRunner { let participant = node.participant.as_ref(); let wallet_addr = Some(node.wallet.address().to_string()); - let pool_addr = node - .pools - .as_ref() - .map(|p| p.iter().map(|w| w.address().to_string()).collect::>().join(", ")); + let pool_addr = node.pool.as_ref().map(|p| p.address().to_string()); let pubkey = validator_entry .as_ref() .map(|(_, entry)| { @@ -1148,10 +1093,7 @@ impl ElectionRunner { let node = self.nodes.get(&node_id).expect("node not found"); let participant = node.participant.as_ref(); let wallet_addr = Some(node.wallet.address().to_string()); - let pool_addr = node - .pools - .as_ref() - .map(|p| p.iter().map(|w| w.address().to_string()).collect::>().join(", ")); + let pool_addr = node.pool.as_ref().map(|p| p.address().to_string()); let pubkey = participant.map(|p| { base64::Engine::encode( @@ -1185,8 +1127,7 @@ impl ElectionRunner { }) .collect(); - let election_id_for_addr = participant.map(|p| p.election_id).unwrap_or(0); - let fallback_sender_addr = format!("-1:{}", hex::encode(node.wallet_addr(election_id_for_addr))); + let fallback_sender_addr = format!("-1:{}", hex::encode(node.wallet_addr())); let accepted_stake = if node.stake_accepted { node.accepted_stake_amount.map(nanotons_to_dec_string).or_else(|| { node.stake_submissions.last().map(|s| nanotons_to_dec_string(s.stake)) diff --git a/src/node-control/elections/src/runner_tests.rs b/src/node-control/elections/src/runner_tests.rs index 7a7bc0f..415dc07 100644 --- a/src/node-control/elections/src/runner_tests.rs +++ b/src/node-control/elections/src/runner_tests.rs @@ -14,7 +14,7 @@ use common::{ time_format, }; use contracts::{ - ElectionsInfo, ElectorWrapper, NodePools, NominatorWrapper, Participant, TonWallet, + ElectionsInfo, ElectorWrapper, NominatorWrapper, Participant, TonWallet, elector::{FrozenParticipant, PastElections}, nominator::{NominatorRoles, PoolData, opcodes}, }; @@ -357,12 +357,9 @@ impl TestHarness { let mut providers: HashMap> = HashMap::new(); providers.insert(node_id.to_string(), Box::new(self.provider_mock)); - let mut pools: HashMap = HashMap::new(); + let mut pools: HashMap> = HashMap::new(); if let Some(pool) = self.pool_mock { - pools.insert( - node_id.to_string(), - NodePools { even: Arc::new(pool), odd: None }, - ); + pools.insert(node_id.to_string(), Arc::new(pool)); } let elector: Arc = Arc::new(self.elector_mock); @@ -1597,7 +1594,7 @@ async fn test_node_without_wallet_skipped() { providers.insert("node-1".to_string(), Box::new(provider1)); let wallets: HashMap> = HashMap::new(); // empty! - let pools: HashMap = HashMap::new(); + let pools: HashMap> = HashMap::new(); let runner = ElectionRunner::new( &elections_config, diff --git a/src/node-control/service/src/auth/user_store.rs b/src/node-control/service/src/auth/user_store.rs index b38d79c..6ee6b0a 100644 --- a/src/node-control/service/src/auth/user_store.rs +++ b/src/node-control/service/src/auth/user_store.rs @@ -271,7 +271,7 @@ mod tests { use super::*; use crate::runtime_config::RuntimeConfig; use common::app_config::{AppConfig, AuthConfig, UserEntry}; - use contracts::{NodePools, TonWallet}; + use contracts::{NominatorWrapper, TonWallet}; use secrets_vault::{ crypto::{key_material::KeyMaterial, master_key::MasterKey}, storage::file_json::FileJsonStorage, @@ -370,7 +370,7 @@ mod tests { Arc::new(NoopWallet) } - fn pools(&self) -> Arc> { + fn pools(&self) -> Arc>> { Arc::new(HashMap::new()) } diff --git a/src/node-control/service/src/contracts/contracts_task.rs b/src/node-control/service/src/contracts/contracts_task.rs index cb33c18..3fbaee7 100644 --- a/src/node-control/service/src/contracts/contracts_task.rs +++ b/src/node-control/service/src/contracts/contracts_task.rs @@ -9,7 +9,7 @@ use crate::runtime_config::RuntimeConfig; use anyhow::Context; use common::{app_config::AppConfig, snapshot::SnapshotStore, task_cancellation::CancellationCtx}; -use contracts::{NodePools, NominatorWrapper, TonWallet, contract_provider}; +use contracts::{NominatorWrapper, TonWallet, contract_provider}; use std::{ collections::{HashMap, HashSet}, sync::Arc, @@ -44,7 +44,7 @@ pub(crate) async fn run( struct ContractsMonitor { master_wallet: Arc, - pools: Arc>, + pools: Arc>>, wallets: Arc>>, rpc_client: Arc, _store: Arc, @@ -254,20 +254,18 @@ impl ContractsMonitor { /// Returns `false` if master balance is insufficient (caller should sleep). async fn ensure_pools_deployed(&self, seqno: &mut i64) -> anyhow::Result { let mut all_deployed = true; - for (node_id, node_pools) in self.pools.iter() { - for pool in node_pools.iter() { - match self.deploy_pool(node_id, pool.clone(), *seqno).await { - Ok(true) => (), - Ok(false) => { - all_deployed = false; - *seqno += 1; - } - Err(e) => { - all_deployed = false; - tracing::error!(target: "contracts", "[{}] deploy pool error: {:#}", node_id, e); - } - }; - } + for (node_id, pool) in self.pools.iter() { + match self.deploy_pool(node_id, pool.clone(), *seqno).await { + Ok(true) => (), + Ok(false) => { + all_deployed = false; + *seqno += 1; + } + Err(e) => { + all_deployed = false; + tracing::error!(target: "contracts", "[{}] deploy pool error: {:#}", node_id, e); + } + }; } Ok(all_deployed) } @@ -409,7 +407,7 @@ mod tests { use super::ContractsMonitor; use axum::{Json, Router, extract::State, routing::post}; use common::snapshot::SnapshotStore; - use contracts::{NodePools, SmartContract, TonWallet}; + use contracts::{NominatorWrapper, SmartContract, TonWallet}; use std::{ collections::HashMap, sync::{ @@ -584,7 +582,7 @@ mod tests { let rpc_client = Arc::new(ClientJsonRpc::connect(rpc_url, None).unwrap()); ContractsMonitor { master_wallet, - pools: Arc::>::default(), + pools: Arc::>>::default(), wallets, rpc_client, _store: Arc::new(SnapshotStore::new()), diff --git a/src/node-control/service/src/runtime_config.rs b/src/node-control/service/src/runtime_config.rs index cb7152b..eae09f8 100644 --- a/src/node-control/service/src/runtime_config.rs +++ b/src/node-control/service/src/runtime_config.rs @@ -13,8 +13,8 @@ use common::{ vault_signer::VaultSigner, }; use contracts::{ - NodePools, NominatorPoolWrapperImpl, NominatorWrapper, NominatorWrapperImpl, TonWallet, - WalletContract, contract_provider, resolve_toncore_pools, + NominatorPoolWrapperImpl, NominatorWrapper, NominatorWrapperImpl, TonWallet, WalletContract, + contract_provider, resolve_toncore_pool, }; use secrets_vault::{ types::{algorithm::Algorithm, secret_id::SecretId, secret_spec::SecretSpec}, @@ -50,7 +50,7 @@ struct RuntimeState { /// Optional secrets vault for key management. vault: Option>, /// Lazily-loaded nominator pools, rebuilt when config changes. - pools: Arc>, + pools: Arc>>, /// Lazily-loaded wallets, rebuilt when config changes. wallets: Arc>>, /// Shared TON HTTP API JSON-RPC client. @@ -76,7 +76,7 @@ impl std::error::Error for RuntimeConfigError {} pub trait RuntimeConfig: Send + Sync { fn get(&self) -> Arc; fn master_wallet(&self) -> Arc; - fn pools(&self) -> Arc>; + fn pools(&self) -> Arc>>; fn wallets(&self) -> Arc>>; fn rpc_client(&self) -> Arc; fn vault(&self) -> Option>; @@ -325,7 +325,7 @@ impl RuntimeConfigStore { app_config: &AppConfig, rpc_client: Arc, wallets: &HashMap>, - ) -> anyhow::Result>> { + ) -> anyhow::Result>>> { let mut map = HashMap::new(); for (node_name, binding) in app_config.bindings.iter() { if let Some(pool_name) = &binding.pool { @@ -337,13 +337,16 @@ impl RuntimeConfigStore { .get(node_name) .context(format!("validator wallet not found: {}", node_name))? .address(); - let pools = open_nominator_pool(cfg, rpc_client.clone(), &validator_address) + let pool = open_nominator_pool(cfg, rpc_client.clone(), &validator_address) .map_err(|e| { anyhow::anyhow!("node [{}] open nominator pool error: {:#}", node_name, e) })?; - let addrs: Vec = pools.iter().map(|p| p.address().to_string()).collect(); - tracing::info!("[{}] opened nominator pool(s): {}", node_name, addrs.join(", ")); - map.insert(node_name.to_owned(), pools); + tracing::info!( + "[{}] opened nominator pool: address={}", + node_name, + pool.address() + ); + map.insert(node_name.to_owned(), pool); } } Ok(Arc::new(map)) @@ -388,7 +391,7 @@ impl RuntimeConfig for RuntimeConfigStore { Arc::clone(&state.master_wallet) } - fn pools(&self) -> Arc> { + fn pools(&self) -> Arc>> { let state = self.state.read().expect("Runtime state poisoned (read)"); Arc::clone(&state.pools) } @@ -465,7 +468,7 @@ fn open_nominator_pool( config: &PoolConfig, rpc_client: Arc, validator_addr: &MsgAddressInt, -) -> anyhow::Result { +) -> anyhow::Result> { match config { PoolConfig::SNP { address, owner } => { let pool = match (address, owner) { @@ -510,34 +513,28 @@ fn open_nominator_pool( anyhow::bail!("pool has neither address nor owner configured"); } }; - Ok(NodePools { even: Arc::new(pool), odd: None }) + Ok(Arc::new(pool)) } PoolConfig::TONCore { validator_share, - even_pool_address, - odd_pool_address, + address, max_nominators, min_validator_stake, min_nominator_stake, } => { - let resolved = resolve_toncore_pools( + let resolved = resolve_toncore_pool( validator_addr, *validator_share, - even_pool_address.as_deref(), - odd_pool_address.as_deref(), + address.as_deref(), max_nominators.as_ref().copied(), min_validator_stake.as_ref().copied(), min_nominator_stake.as_ref().copied(), )?; - let even: Arc = Arc::new(NominatorPoolWrapperImpl::new( + Ok(Arc::new(NominatorPoolWrapperImpl::new_with_state_init( contract_provider!(rpc_client.clone()), - resolved.even_address, - )); - let odd: Arc = Arc::new(NominatorPoolWrapperImpl::new( - contract_provider!(rpc_client.clone()), - resolved.odd_address, - )); - Ok(NodePools { even, odd: Some(odd) }) + resolved.address, + resolved.state_init, + ))) } } } diff --git a/src/node-control/service/src/task/task_manager.rs b/src/node-control/service/src/task/task_manager.rs index 24b3a44..ff87136 100644 --- a/src/node-control/service/src/task/task_manager.rs +++ b/src/node-control/service/src/task/task_manager.rs @@ -175,7 +175,7 @@ impl TaskController { mod tests { use super::*; use common::app_config::{HttpConfig, TonHttpApiConfig}; - use contracts::{NodePools, TonWallet}; + use contracts::{NominatorWrapper, TonWallet}; use secrets_vault::vault::SecretVault; use std::{ collections::HashMap, @@ -194,7 +194,7 @@ mod tests { fn master_wallet(&self) -> Arc { unimplemented!() } - fn pools(&self) -> Arc> { + fn pools(&self) -> Arc>> { unimplemented!() } fn wallets(&self) -> Arc>> { From 380dd3936326f0eb50e1fa9dd5b53389d4bd0be3 Mon Sep 17 00:00:00 2001 From: mrnkslv Date: Fri, 3 Apr 2026 12:45:07 +0300 Subject: [PATCH 12/18] feat: ton_core_router implementation --- .../src/commands/nodectl/config_pool_cmd.rs | 172 ++++++++++--- .../src/commands/nodectl/config_wallet_cmd.rs | 35 ++- .../src/commands/nodectl/deploy_cmd.rs | 176 +++++++------ src/node-control/common/src/app_config.rs | 91 +++++++ src/node-control/contracts/src/lib.rs | 4 +- .../src/nominator/single_nominator.rs | 3 +- .../contracts/src/nominator/wrapper.rs | 46 ++++ .../contracts/src/ton_core_nominator.rs | 2 +- .../ton_core_nominator/ton_core_nominator.rs | 96 ++++++- .../elections/src/election_task.rs | 4 +- src/node-control/elections/src/runner.rs | 184 +++++++++----- .../elections/src/runner_tests.rs | 237 +++++++++++++++++- .../service/src/auth/user_store.rs | 4 +- .../service/src/contracts/contracts_task.rs | 34 +-- .../service/src/runtime_config.rs | 66 +++-- .../service/src/task/task_manager.rs | 8 +- 16 files changed, 935 insertions(+), 227 deletions(-) diff --git a/src/node-control/commands/src/commands/nodectl/config_pool_cmd.rs b/src/node-control/commands/src/commands/nodectl/config_pool_cmd.rs index dad9f11..999d108 100644 --- a/src/node-control/commands/src/commands/nodectl/config_pool_cmd.rs +++ b/src/node-control/commands/src/commands/nodectl/config_pool_cmd.rs @@ -22,7 +22,7 @@ use common::{ }; use contracts::{ NOMINATOR_POOL_WORKCHAIN, NominatorWrapperImpl, TonWallet, resolve_deploy_pool_params, - resolve_toncore_pool, ton_core_nominator::messages as pool_messages, + resolve_toncore_pool, resolve_toncore_router, ton_core_nominator::messages as pool_messages, }; use secrets_vault::{vault::SecretVault, vault_builder::SecretVaultBuilder}; use std::{io::Write, path::Path, str::FromStr, sync::Arc}; @@ -36,6 +36,8 @@ enum PoolAddKind { Snp, /// Nominator Pool (`kind: "core"` / `PoolConfig::TONCore`) Core, + /// Two-pool router (`kind: "core_router"` / `PoolConfig::TONCoreRouter`) + Router, } #[derive(clap::Args, Clone)] @@ -64,14 +66,18 @@ pub enum PoolAction { pub struct PoolAddCmd { #[arg(short = 'n', long = "name", help = "Pool name (unique identifier)")] name: String, - #[arg(long = "kind", value_enum, default_value_t = PoolAddKind::Snp, help = "snp or core")] + #[arg(long = "kind", value_enum, default_value_t = PoolAddKind::Snp, help = "snp, core, or router")] kind: PoolAddKind, #[arg( short = 'a', long = "address", - help = "Pool contract address, raw or base64url (optional; derived on deploy if omitted)" + help = "Pool contract address (SNP/Core; optional, derived on deploy if omitted)" )] address: Option, + #[arg(long = "address-0", help = "Router: pool[0] address (optional; derived if omitted)")] + address_0: Option, + #[arg(long = "address-1", help = "Router: pool[1] address (optional; derived if omitted)")] + address_1: Option, #[arg( short = 'o', long = "owner", @@ -80,19 +86,19 @@ pub struct PoolAddCmd { owner: Option, #[arg( long = "validator-share", - help = "Core: validator reward share (basis points, 0–65535; e.g. 5000 ≈ 50%)" + help = "Core/Router: validator reward share (basis points, 0–65535; e.g. 5000 ≈ 50%)" )] validator_share: Option, - #[arg(long = "max-nominators", help = "Core: max nominators (default: 40)")] + #[arg(long = "max-nominators", help = "Core/Router: max nominators (default: 40)")] max_nominators: Option, #[arg( long = "min-validator-stake-nano", - help = "Core: min validator stake in nanotons (embedded at deploy)" + help = "Core/Router: min validator stake in nanotons (embedded at deploy)" )] min_validator_stake_nano: Option, #[arg( long = "min-nominator-stake-nano", - help = "Core: min nominator stake in nanotons (embedded at deploy)" + help = "Core/Router: min nominator stake in nanotons (embedded at deploy)" )] min_nominator_stake_nano: Option, } @@ -118,6 +124,8 @@ pub struct PoolDepositValidatorCmd { binding: String, #[arg(short = 'a', long = "amount", help = "Amount in TON to deposit")] amount: f64, + #[arg(long = "pool-index", default_value_t = 0, help = "Router: pool index (0 or 1)")] + pool_index: usize, } #[derive(clap::Args, Clone)] @@ -127,14 +135,12 @@ pub struct PoolWithdrawValidatorCmd { binding: String, #[arg(short = 'a', long = "amount", help = "Amount in TON to withdraw")] amount: f64, + #[arg(long = "pool-index", default_value_t = 0, help = "Router: pool index (0 or 1)")] + pool_index: usize, } impl PoolCmd { - pub async fn run( - &self, - path: &Path, - cancellation_ctx: CancellationCtx, - ) -> anyhow::Result<()> { + pub async fn run(&self, path: &Path, cancellation_ctx: CancellationCtx) -> anyhow::Result<()> { match &self.action { PoolAction::Add(cmd) => cmd.run(path).await, PoolAction::Ls(cmd) => cmd.run(path).await, @@ -212,7 +218,9 @@ impl PoolAddCmd { "kind=core validator_share={}, address={}, deploy_params: max_nominators={}, min_validator_stake={}, min_nominator_stake={}", share, normalized_address.as_deref().unwrap_or(""), - mx, mv, mn + mx, + mv, + mn ); ( @@ -226,6 +234,57 @@ impl PoolAddCmd { info, ) } + PoolAddKind::Router => { + if self.address.is_some() { + anyhow::bail!("For router: use --address-0 / --address-1 instead of --address"); + } + let share = self + .validator_share + .ok_or_else(|| anyhow::anyhow!("For router: --validator-share is required"))?; + + let a0 = self + .address_0 + .as_deref() + .map(|a| normalize_ton_address(a, "address-0")) + .transpose()?; + let a1 = self + .address_1 + .as_deref() + .map(|a| normalize_ton_address(a, "address-1")) + .transpose()?; + + let addresses = if a0.is_some() || a1.is_some() { + Some([a0.clone(), a1.clone()]) + } else { + None + }; + + let (mx, mv, mn) = resolve_deploy_pool_params( + self.max_nominators, + self.min_validator_stake_nano, + self.min_nominator_stake_nano, + ); + let info = format!( + "kind=core_router validator_share={}, addr[0]={}, addr[1]={}, deploy_params: max_nominators={}, min_validator_stake={}, min_nominator_stake={}", + share, + a0.as_deref().unwrap_or(""), + a1.as_deref().unwrap_or(""), + mx, + mv, + mn + ); + + ( + PoolConfig::TONCoreRouter { + validator_share: share, + addresses, + max_nominators: self.max_nominators, + min_validator_stake: self.min_validator_stake_nano, + min_nominator_stake: self.min_nominator_stake_nano, + }, + info, + ) + } }; config.pools.insert(self.name.clone(), pool_config); @@ -347,6 +406,20 @@ async fn collect_pool_views( validator_share: Some(*validator_share), }); } + PoolConfig::TONCoreRouter { validator_share, addresses, .. } => { + let addrs = addresses.as_ref().map(|a| { + a.iter().map(|o| o.clone().unwrap_or_else(|| "".into())).collect() + }); + views.push(PoolView { + name: name.clone(), + kind: "Router".to_string(), + balance: None, + address: None, + owner: None, + addresses: addrs, + validator_share: Some(*validator_share), + }); + } } } views @@ -385,11 +458,23 @@ fn print_pools_table(views: &[PoolView]) { ); } "Core" => { - let addrs = v.addresses.as_deref().map(|a| a.join(", ")).unwrap_or_default(); + let display_addr = v.address.as_deref().unwrap_or(""); let share = v.validator_share.map(|s| s.to_string()).unwrap_or_default(); println!( " {:<15} {:<6} {:<14} {:<50} share={}", - v.name, "Core", "-", addrs, share, + v.name, "Core", "-", display_addr, share, + ); + } + "Router" => { + let addrs = v + .addresses + .as_deref() + .map(|a| a.join(", ")) + .unwrap_or_else(|| "".into()); + let share = v.validator_share.map(|s| s.to_string()).unwrap_or_default(); + println!( + " {:<15} {:<6} {:<14} {:<50} share={}", + v.name, "Router", "-", addrs, share, ); } _ => {} @@ -527,8 +612,14 @@ impl PoolRmCmd { fn resolve_toncore_pool_address( pool_cfg: &PoolConfig, wallet_address: &MsgAddressInt, + pool_index: usize, ) -> anyhow::Result { match pool_cfg { + PoolConfig::TONCore { .. } if pool_index != 0 => { + anyhow::bail!( + "--pool-index is only valid for Router pools (TONCore has a single pool)" + ); + } PoolConfig::TONCore { validator_share, address, @@ -546,6 +637,26 @@ fn resolve_toncore_pool_address( )?; Ok(resolved.address) } + PoolConfig::TONCoreRouter { .. } if pool_index > 1 => { + anyhow::bail!("--pool-index must be 0 or 1 for Router pools"); + } + PoolConfig::TONCoreRouter { + validator_share, + addresses, + max_nominators, + min_validator_stake, + min_nominator_stake, + } => { + let resolved = resolve_toncore_router( + wallet_address, + *validator_share, + addresses.as_ref(), + max_nominators.as_ref().copied(), + min_validator_stake.as_ref().copied(), + min_nominator_stake.as_ref().copied(), + )?; + Ok(resolved[pool_index].address.clone()) + } PoolConfig::SNP { .. } => { anyhow::bail!("This command is only supported for TONCore pools, not SNP"); } @@ -561,11 +672,7 @@ fn confirm_action(prompt: &str) -> anyhow::Result { } impl PoolDepositValidatorCmd { - pub async fn run( - &self, - path: &Path, - cancellation_ctx: CancellationCtx, - ) -> anyhow::Result<()> { + pub async fn run(&self, path: &Path, cancellation_ctx: CancellationCtx) -> anyhow::Result<()> { let (config, vault, rpc_client) = load_config_vault_rpc_client(path).await?; let binding = config @@ -588,11 +695,14 @@ impl PoolDepositValidatorCmd { let (wallet_address, wallet_info_data, wallet_secret) = wallet_info(rpc_client.clone(), wallet_cfg, vault.clone()).await?; - if wallet_info_data.account_state != ton_http_api_client::v2::data_models::AccountState::Active { + if wallet_info_data.account_state + != ton_http_api_client::v2::data_models::AccountState::Active + { anyhow::bail!("Wallet '{}' is {}", binding.wallet, wallet_info_data.account_state); } - let pool_address = resolve_toncore_pool_address(pool_cfg, &wallet_address)?; + let pool_address = + resolve_toncore_pool_address(pool_cfg, &wallet_address, self.pool_index)?; let deposit_nanotons = tons_f64_to_nanotons(self.amount); if deposit_nanotons == 0 { @@ -655,11 +765,7 @@ impl PoolDepositValidatorCmd { } impl PoolWithdrawValidatorCmd { - pub async fn run( - &self, - path: &Path, - cancellation_ctx: CancellationCtx, - ) -> anyhow::Result<()> { + pub async fn run(&self, path: &Path, cancellation_ctx: CancellationCtx) -> anyhow::Result<()> { let (config, vault, rpc_client) = load_config_vault_rpc_client(path).await?; let binding = config @@ -682,11 +788,14 @@ impl PoolWithdrawValidatorCmd { let (wallet_address, wallet_info_data, wallet_secret) = wallet_info(rpc_client.clone(), wallet_cfg, vault.clone()).await?; - if wallet_info_data.account_state != ton_http_api_client::v2::data_models::AccountState::Active { + if wallet_info_data.account_state + != ton_http_api_client::v2::data_models::AccountState::Active + { anyhow::bail!("Wallet '{}' is {}", binding.wallet, wallet_info_data.account_state); } - let pool_address = resolve_toncore_pool_address(pool_cfg, &wallet_address)?; + let pool_address = + resolve_toncore_pool_address(pool_cfg, &wallet_address, self.pool_index)?; let withdraw_nanotons = tons_f64_to_nanotons(self.amount); if withdraw_nanotons == 0 { @@ -714,9 +823,8 @@ impl PoolWithdrawValidatorCmd { let pool_addr_display = pool_address.to_string(); let gas_amount: u64 = 1_000_000_000; let body = pool_messages::withdraw_validator(0, withdraw_nanotons)?; - let msg = wallet - .build_message(pool_address, gas_amount, body, true, None, None, None) - .await?; + let msg = + wallet.build_message(pool_address, gas_amount, body, true, None, None, None).await?; let msg_boc = write_boc(&msg)?; rpc_client.send_boc(&msg_boc).await?; diff --git a/src/node-control/commands/src/commands/nodectl/config_wallet_cmd.rs b/src/node-control/commands/src/commands/nodectl/config_wallet_cmd.rs index a627868..4c2beeb 100644 --- a/src/node-control/commands/src/commands/nodectl/config_wallet_cmd.rs +++ b/src/node-control/commands/src/commands/nodectl/config_wallet_cmd.rs @@ -25,7 +25,7 @@ use common::{ }; use contracts::{ ElectorWrapper, ElectorWrapperImpl, NominatorWrapperImpl, TonWallet, contract_provider, - nominator, resolve_toncore_pool, + nominator, resolve_toncore_pool, resolve_toncore_router, }; use elections::providers::{DefaultElectionsProvider, ElectionsProvider}; use secrets_vault::{errors::error::VaultError, vault::SecretVault}; @@ -122,6 +122,8 @@ pub struct WalletStakeCmd { amount: f64, #[arg(short = 'm', long = "max-factor", default_value = "3.0", help = "Max factor (1.0..3.0)")] max_factor: f32, + #[arg(long = "pool-index", default_value_t = 0, help = "Router: pool index (0 or 1)")] + pool_index: usize, } impl WalletCmd { @@ -465,7 +467,7 @@ impl WalletStakeCmd { if wallet_info_res.account_state != AccountState::Active { anyhow::bail!("Wallet '{}' is {}", binding.wallet, wallet_info_res.account_state); } - let pool_address = resolve_pool_address(pool_cfg, &wallet_address)?; + let pool_address = resolve_pool_address(pool_cfg, &wallet_address, self.pool_index)?; let pool_addr_bytes = pool_address.address().clone().storage().to_vec(); // Connect to validator node via control protocol @@ -694,8 +696,12 @@ fn confirm(prompt: &str) -> anyhow::Result { fn resolve_pool_address( pool_cfg: &PoolConfig, validator_addr: &MsgAddressInt, + pool_index: usize, ) -> anyhow::Result { match pool_cfg { + PoolConfig::SNP { .. } if pool_index != 0 => { + anyhow::bail!("--pool-index is not applicable for SNP pools"); + } PoolConfig::SNP { address, owner } => match (address, owner) { (Some(addr), _) => addr.parse::().context("invalid pool address"), (None, Some(owner)) => { @@ -705,6 +711,11 @@ fn resolve_pool_address( } (None, None) => anyhow::bail!("Pool has neither address nor owner configured"), }, + PoolConfig::TONCore { .. } if pool_index != 0 => { + anyhow::bail!( + "--pool-index is only valid for Router pools (TONCore has a single pool)" + ); + } PoolConfig::TONCore { validator_share, address, @@ -722,5 +733,25 @@ fn resolve_pool_address( )?; Ok(resolved.address) } + PoolConfig::TONCoreRouter { .. } if pool_index > 1 => { + anyhow::bail!("--pool-index must be 0 or 1 for Router pools"); + } + PoolConfig::TONCoreRouter { + validator_share, + addresses, + max_nominators, + min_validator_stake, + min_nominator_stake, + } => { + let resolved = resolve_toncore_router( + validator_addr, + *validator_share, + addresses.as_ref(), + max_nominators.as_ref().copied(), + min_validator_stake.as_ref().copied(), + min_nominator_stake.as_ref().copied(), + )?; + Ok(resolved[pool_index].address.clone()) + } } } diff --git a/src/node-control/commands/src/commands/nodectl/deploy_cmd.rs b/src/node-control/commands/src/commands/nodectl/deploy_cmd.rs index 8d8951b..24aef06 100644 --- a/src/node-control/commands/src/commands/nodectl/deploy_cmd.rs +++ b/src/node-control/commands/src/commands/nodectl/deploy_cmd.rs @@ -16,7 +16,7 @@ use common::{ task_cancellation::CancellationCtx, ton_utils::{nanotons_to_tons_f64, tons_f64_to_nanotons}, }; -use contracts::{NominatorWrapperImpl, TonWallet, resolve_toncore_pool}; +use contracts::{NominatorWrapperImpl, TonWallet, resolve_toncore_pool, resolve_toncore_router}; use std::{cell::RefCell, collections::HashMap, path::Path, rc::Rc, sync::Arc}; use ton_block::{Cell, MsgAddressInt, write_boc}; use ton_http_api_client::v2::data_models::AccountState; @@ -321,7 +321,7 @@ impl DeployPoolCmd { .or_else(|| config.bindings.get(&self.node).and_then(|b| b.pool.as_ref())) .and_then(|name| config.pools.get(name)); - let (pool_address, state_init) = match pool_cfg_opt { + let deploy_targets: Vec<(MsgAddressInt, ton_block::StateInit)> = match pool_cfg_opt { Some(PoolConfig::TONCore { validator_share, address, @@ -338,7 +338,25 @@ impl DeployPoolCmd { min_nominator_stake.as_ref().copied(), ) .map_err(set_err)?; - (resolved.address, resolved.state_init) + vec![(resolved.address, resolved.state_init)] + } + Some(PoolConfig::TONCoreRouter { + validator_share, + addresses, + max_nominators, + min_validator_stake, + min_nominator_stake, + }) => { + let resolved = resolve_toncore_router( + &wallet_address, + *validator_share, + addresses.as_ref(), + max_nominators.as_ref().copied(), + min_validator_stake.as_ref().copied(), + min_nominator_stake.as_ref().copied(), + ) + .map_err(set_err)?; + resolved.into_iter().map(|r| (r.address, r.state_init)).collect() } Some(PoolConfig::SNP { .. }) | None => { let owner = self.owner.as_ref().ok_or_else(|| { @@ -347,97 +365,101 @@ impl DeployPoolCmd { )) })?; let (pool_address, state_init) = - NominatorWrapperImpl::calculate_address_with_state_init(-1, owner, &wallet_address) - .map_err(set_err)?; - (pool_address, state_init) + NominatorWrapperImpl::calculate_address_with_state_init( + -1, + owner, + &wallet_address, + ) + .map_err(set_err)?; + vec![(pool_address, state_init)] } }; - res.borrow_mut().address = pool_address.to_string(); - - if self.verbose { - println!("Update pool info ..."); + if wallet_info.account_state != AccountState::Active { + res.borrow_mut().error = + Some(format!("Wallet '{}' state {}", wallet_address, wallet_info.account_state)); + return Ok(()); } - let pool_info = rpc_client.get_address_information(&pool_address).await.map_err(set_err)?; - res.borrow_mut().account_state = pool_info.state.clone(); + let amount_to_send_nano = tons_f64_to_nanotons(self.amount); + + let wallet = make_wallet(rpc_client.clone(), wallet_cfg, secret, &self.node) + .await + .map_err(set_err)?; + let mut seqno = wallet_info.seqno; + + for (i, (pool_address, state_init)) in deploy_targets.iter().enumerate() { + res.borrow_mut().address = pool_address.to_string(); - if pool_info.state == AccountState::Active { if self.verbose { - println!("The pool '{}' is already deployed", &pool_address); + println!("Update pool info [{}/{}] ...", i + 1, deploy_targets.len()); } - return Ok(()); - } else if pool_info.state == AccountState::Frozen { - return Err(set_err(anyhow::anyhow!("The pool '{}' is frozen", &pool_address))); - } - - if cancellation_ctx.is_cancelled() { - return Err(set_err(anyhow::anyhow!("Task cancelled"))); - } + let pool_info = + rpc_client.get_address_information(pool_address).await.map_err(set_err)?; + res.borrow_mut().account_state = pool_info.state.clone(); - if self.verbose { - match pool_cfg_opt { - Some(PoolConfig::TONCore { validator_share, .. }) => println!( - "Deploy TON Nominator Pool (core): validator={}, validator_share={}, pool={} ...", - wallet_address, validator_share, pool_address - ), - _ => { - if let Some(owner) = self.owner.as_ref() { - println!( - "Deploy Single Nominator Pool: owner={}, wallet={} ...", - owner, wallet_address - ); - } + if pool_info.state == AccountState::Active { + if self.verbose { + println!("The pool '{}' is already deployed", pool_address); } + continue; + } else if pool_info.state == AccountState::Frozen { + return Err(set_err(anyhow::anyhow!("The pool '{}' is frozen", pool_address))); } - } - if wallet_info.account_state != AccountState::Active { - res.borrow_mut().error = - Some(format!("Wallet '{}' state {}", wallet_address, wallet_info.account_state)); - return Ok(()); - } + if cancellation_ctx.is_cancelled() { + return Err(set_err(anyhow::anyhow!("Task cancelled"))); + } - let amount_to_send_nano = tons_f64_to_nanotons(self.amount); - if wallet_info.balance < amount_to_send_nano { - return Err(set_err(anyhow::anyhow!( - "Wallet '{}' balance {:.4}_TON is too low", - wallet_address, - nanotons_to_tons_f64(wallet_info.balance) - ))); - } + let current_balance = + rpc_client.get_address_information(&wallet_address).await.map_err(set_err)?.balance; + if current_balance < amount_to_send_nano { + return Err(set_err(anyhow::anyhow!( + "Wallet '{}' balance {:.4}_TON is too low", + wallet_address, + nanotons_to_tons_f64(current_balance) + ))); + } - // Deploy - let wallet = make_wallet(rpc_client.clone(), wallet_cfg, secret, &self.node) + if self.verbose { + println!( + "Deploy pool [{}/{}]: address={} ...", + i + 1, + deploy_targets.len(), + pool_address + ); + } + + let msg_boc = write_boc( + &wallet + .build_message( + pool_address.clone(), + amount_to_send_nano, + Cell::default(), + false, + seqno, + None, + Some(state_init.clone()), + ) + .await + .map_err(set_err)?, + ) + .map_err(set_err)?; + + rpc_client.send_boc(&msg_boc).await.map_err(set_err)?; + wait_for_deploy( + rpc_client.clone(), + pool_address, + &cancellation_ctx, + self.verbose, + DEPLOY_TIMEOUT, + ) .await .map_err(set_err)?; - let msg_boc = write_boc( - &wallet - .build_message( - pool_address.clone(), - amount_to_send_nano, - Cell::default(), - false, - wallet_info.seqno, - None, - Some(state_init), - ) - .await - .map_err(set_err)?, - ) - .map_err(set_err)?; - - rpc_client.send_boc(&msg_boc).await.map_err(set_err)?; - wait_for_deploy( - rpc_client.clone(), - &pool_address, - &cancellation_ctx, - self.verbose, - DEPLOY_TIMEOUT, - ) - .await - .map_err(set_err)?; + + seqno = seqno.map(|s| s + 1); + } res.borrow_mut().deployed = true; res.borrow_mut().account_state = AccountState::Active; diff --git a/src/node-control/common/src/app_config.rs b/src/node-control/common/src/app_config.rs index 4d27479..262e350 100644 --- a/src/node-control/common/src/app_config.rs +++ b/src/node-control/common/src/app_config.rs @@ -355,6 +355,27 @@ pub enum PoolConfig { #[serde(skip_serializing_if = "Option::is_none")] min_nominator_stake: Option, }, + /// Two TONCore pools with automatic routing: pool[0] uses `min_validator_stake`, + /// pool[1] uses `min_validator_stake + 1`. The runner picks the first pool with `state == 0`. + #[serde(rename = "core_router")] + TONCoreRouter { + validator_share: u16, + /// Two pool contract addresses `[pool_0, pool_1]`. Each element is optional — + /// `None` means "not deployed yet, derive deterministically". + /// The outer `Option` itself defaults to `None` (both derived). + #[serde(default)] + #[serde(skip_serializing_if = "Option::is_none")] + addresses: Option<[Option; 2]>, + #[serde(default)] + #[serde(skip_serializing_if = "Option::is_none")] + max_nominators: Option, + #[serde(default)] + #[serde(skip_serializing_if = "Option::is_none")] + min_validator_stake: Option, + #[serde(default)] + #[serde(skip_serializing_if = "Option::is_none")] + min_nominator_stake: Option, + }, } #[derive(serde::Serialize, serde::Deserialize, Clone)] @@ -875,6 +896,76 @@ mod tests { ); } + #[test] + fn test_pool_config_serde_core_router_no_addresses() { + let value = serde_json::json!({ + "kind": "core_router", + "validator_share": 50, + }); + let cfg: PoolConfig = serde_json::from_value(value).unwrap(); + assert_eq!( + cfg, + PoolConfig::TONCoreRouter { + validator_share: 50, + addresses: None, + max_nominators: None, + min_validator_stake: None, + min_nominator_stake: None, + } + ); + + let json = serde_json::to_value(&cfg).unwrap(); + assert_eq!(json["kind"], "core_router"); + assert_eq!(json["validator_share"], 50); + assert!(json.get("addresses").is_none()); + } + + #[test] + fn test_pool_config_serde_core_router_with_addresses() { + let addr0 = ADDR; + let addr1 = OWNER; + let value = serde_json::json!({ + "kind": "core_router", + "validator_share": 100, + "addresses": [addr0.to_string(), addr1.to_string()], + "max_nominators": 10, + "min_validator_stake": 5_000_000_000_000u64, + "min_nominator_stake": 1_000_000_000_000u64, + }); + let cfg: PoolConfig = serde_json::from_value(value).unwrap(); + assert_eq!( + cfg, + PoolConfig::TONCoreRouter { + validator_share: 100, + addresses: Some([Some(addr0.to_string()), Some(addr1.to_string())]), + max_nominators: Some(10), + min_validator_stake: Some(5_000_000_000_000), + min_nominator_stake: Some(1_000_000_000_000), + } + ); + } + + #[test] + fn test_pool_config_serde_core_router_partial_addresses() { + let addr0 = ADDR; + let value = serde_json::json!({ + "kind": "core_router", + "validator_share": 50, + "addresses": [addr0.to_string(), null], + }); + let cfg: PoolConfig = serde_json::from_value(value).unwrap(); + assert_eq!( + cfg, + PoolConfig::TONCoreRouter { + validator_share: 50, + addresses: Some([Some(addr0.to_string()), None]), + max_nominators: None, + min_validator_stake: None, + min_nominator_stake: None, + } + ); + } + #[test] fn test_binding_status_serde_roundtrip() { for status in [ diff --git a/src/node-control/contracts/src/lib.rs b/src/node-control/contracts/src/lib.rs index a436865..7949d6c 100644 --- a/src/node-control/contracts/src/lib.rs +++ b/src/node-control/contracts/src/lib.rs @@ -19,11 +19,11 @@ pub use config_contract::{ ConfigContractImpl, ConfigContractWrapper, ConfigProposal, ProposedParam, }; pub use elector::{ElectionsInfo, ElectorWrapper, ElectorWrapperImpl, Participant}; -pub use nominator::{NOMINATOR_POOL_WORKCHAIN, NominatorWrapper, NominatorWrapperImpl}; +pub use nominator::{NOMINATOR_POOL_WORKCHAIN, NodePools, NominatorWrapper, NominatorWrapperImpl}; pub use provider::ContractProvider; pub use smart_contract::SmartContract; pub use ton_core_nominator::{ NominatorPoolWrapperImpl, ResolvedTonCorePool, resolve_deploy_pool_params, - resolve_toncore_pool, + resolve_toncore_pool, resolve_toncore_router, }; pub use wallet::{TonWallet, WalletContract}; diff --git a/src/node-control/contracts/src/nominator/single_nominator.rs b/src/node-control/contracts/src/nominator/single_nominator.rs index 1b5f803..42e8757 100644 --- a/src/node-control/contracts/src/nominator/single_nominator.rs +++ b/src/node-control/contracts/src/nominator/single_nominator.rs @@ -127,7 +127,8 @@ impl NominatorWrapper for NominatorWrapperImpl { let validator_reward_share = stack.i64(5).context("parse validator_reward_share")? as u16; let max_nominators_count = stack.i64(6).context("parse max_nominators_count")? as u16; let min_validator_stake = stack.i64(7).context("parse min_validator_stake")? as u64; - let nominator_stake_threshold = stack.i64(8).context("parse nominator_stake_threshold")? as u64; + let nominator_stake_threshold = + stack.i64(8).context("parse nominator_stake_threshold")? as u64; // skip indices 9-10 (nominators, withdraw_requests) let stake_at = stack.i64(11).context("parse stake_at")? as u32; let saved_validator_set_hash = { diff --git a/src/node-control/contracts/src/nominator/wrapper.rs b/src/node-control/contracts/src/nominator/wrapper.rs index 8fde7d6..8fe01a4 100644 --- a/src/node-control/contracts/src/nominator/wrapper.rs +++ b/src/node-control/contracts/src/nominator/wrapper.rs @@ -7,6 +7,7 @@ * This software is provided "AS IS", WITHOUT WARRANTY OF ANY KIND. */ use crate::SmartContract; +use std::sync::Arc; use ton_block::{MsgAddressInt, StateInit}; /// Trait for interacting with single-nominator smart contract @@ -69,3 +70,48 @@ pub struct PoolData { /// Stake held for duration pub stake_held_for: u64, } + +/// Pool binding for a single node: either one pool or two with routing. +#[derive(Clone)] +pub enum NodePools { + /// SNP or TONCore — a single nominator pool. + Single(Arc), + /// TONCoreRouter — two pools; the runner picks the free one via `get_pool_data().state`. + Router([Arc; 2]), +} + +impl NodePools { + /// Primary pool (pool[0]). Used for address display and as the default staking address. + pub fn primary(&self) -> &Arc { + match self { + NodePools::Single(p) => p, + NodePools::Router([p, _]) => p, + } + } + + /// All pools (1 for Single, 2 for Router). + pub fn all(&self) -> Vec<&Arc> { + match self { + NodePools::Single(p) => vec![p], + NodePools::Router([a, b]) => vec![a, b], + } + } + + /// Select the pool that is ready for validation (`state == 0`). + /// For `Single` — always returns the only pool. + /// For `Router` — queries `get_pool_data()` on each pool, returns the first with `state == 0`. + pub async fn select_free(&self) -> anyhow::Result<&Arc> { + match self { + NodePools::Single(p) => Ok(p), + NodePools::Router(pools) => { + for pool in pools { + let data = pool.get_pool_data().await?; + if data.state == 0 { + return Ok(pool); + } + } + anyhow::bail!("all router pools are busy (state != 0)") + } + } + } +} diff --git a/src/node-control/contracts/src/ton_core_nominator.rs b/src/node-control/contracts/src/ton_core_nominator.rs index b3ef0b2..285e101 100644 --- a/src/node-control/contracts/src/ton_core_nominator.rs +++ b/src/node-control/contracts/src/ton_core_nominator.rs @@ -13,5 +13,5 @@ mod ton_core_nominator; pub use ton_core_nominator::{ NominatorPoolWrapperImpl, ResolvedTonCorePool, resolve_deploy_pool_params, - resolve_toncore_pool, + resolve_toncore_pool, resolve_toncore_router, }; diff --git a/src/node-control/contracts/src/ton_core_nominator/ton_core_nominator.rs b/src/node-control/contracts/src/ton_core_nominator/ton_core_nominator.rs index ddd2032..6232db4 100644 --- a/src/node-control/contracts/src/ton_core_nominator/ton_core_nominator.rs +++ b/src/node-control/contracts/src/ton_core_nominator/ton_core_nominator.rs @@ -70,10 +70,13 @@ pub fn resolve_toncore_pool( let (max_n, min_v, min_n) = resolve_deploy_pool_params(max_nominators, min_validator_stake, min_nominator_stake); - let (address, state_init) = - NominatorPoolWrapperImpl::calculate_address_with_state_init( - validator_addr, validator_share, max_n, min_v, min_n, - )?; + let (address, state_init) = NominatorPoolWrapperImpl::calculate_address_with_state_init( + validator_addr, + validator_share, + max_n, + min_v, + min_n, + )?; if let Some(addr) = pool_address { let explicit = addr .parse::() @@ -94,6 +97,81 @@ pub fn resolve_toncore_pool( }) } +/// Resolve two TONCore pool addresses for the router configuration. +/// +/// `pool[0]` uses `min_validator_stake`, `pool[1]` uses `min_validator_stake + 1`. +/// If explicit addresses are provided, they are validated against the derived ones. +pub fn resolve_toncore_router( + validator_addr: &MsgAddressInt, + validator_share: u16, + addresses: Option<&[Option; 2]>, + max_nominators: Option, + min_validator_stake: Option, + min_nominator_stake: Option, +) -> anyhow::Result<[ResolvedTonCorePool; 2]> { + let (max_n, min_v, min_n) = + resolve_deploy_pool_params(max_nominators, min_validator_stake, min_nominator_stake); + + let explicit = |idx: usize| -> Option<&str> { addresses.and_then(|a| a[idx].as_deref()) }; + + let pool0 = { + let (address, state_init) = NominatorPoolWrapperImpl::calculate_address_with_state_init( + validator_addr, + validator_share, + max_n, + min_v, + min_n, + )?; + if let Some(addr) = explicit(0) { + let parsed = addr + .parse::() + .context(format!("invalid TONCoreRouter addresses[0]: {addr}"))?; + anyhow::ensure!( + parsed == address, + "TONCoreRouter addresses[0] ({parsed}) does not match derived address ({address})" + ); + } + ResolvedTonCorePool { + reward_share: validator_share, + max_nominators: max_n, + min_validator_stake: min_v, + min_nominator_stake: min_n, + address, + state_init, + } + }; + + let min_v_1 = min_v.saturating_add(1); + let pool1 = { + let (address, state_init) = NominatorPoolWrapperImpl::calculate_address_with_state_init( + validator_addr, + validator_share, + max_n, + min_v_1, + min_n, + )?; + if let Some(addr) = explicit(1) { + let parsed = addr + .parse::() + .context(format!("invalid TONCoreRouter addresses[1]: {addr}"))?; + anyhow::ensure!( + parsed == address, + "TONCoreRouter addresses[1] ({parsed}) does not match derived address ({address})" + ); + } + ResolvedTonCorePool { + reward_share: validator_share, + max_nominators: max_n, + min_validator_stake: min_v_1, + min_nominator_stake: min_n, + address, + state_init, + } + }; + + Ok([pool0, pool1]) +} + /// Wrapper for the TON Nominator Pool contract. /// /// See: @@ -156,9 +234,13 @@ impl NominatorPoolWrapperImpl { min_nominator_stake: u64, ) -> anyhow::Result { Self::calculate_address_with_state_init( - validator_address, validator_reward_share, - max_nominators_count, min_validator_stake, min_nominator_stake, - ).map(|(addr, _)| addr) + validator_address, + validator_reward_share, + max_nominators_count, + min_validator_stake, + min_nominator_stake, + ) + .map(|(addr, _)| addr) } /// Calculate both the pool address and `StateInit` in a single pass. diff --git a/src/node-control/elections/src/election_task.rs b/src/node-control/elections/src/election_task.rs index ee117ff..7e7cf06 100644 --- a/src/node-control/elections/src/election_task.rs +++ b/src/node-control/elections/src/election_task.rs @@ -16,7 +16,7 @@ use common::{ snapshot::SnapshotStore, task_cancellation::CancellationCtx, }; -use contracts::{ElectorWrapperImpl, NominatorWrapper, TonWallet, contract_provider}; +use contracts::{ElectorWrapperImpl, NodePools, TonWallet, contract_provider}; use secrets_vault::vault::SecretVault; use std::{collections::HashMap, sync::Arc, time::Duration}; use ton_http_api_client::v2::client_json_rpc::ClientJsonRpc; @@ -29,7 +29,7 @@ pub async fn run( app_config: Arc, rpc_client: Arc, wallets: Arc>>, - pools: Arc>>, + pools: Arc>, store: Arc, vault: Option>, on_status_change: Option, diff --git a/src/node-control/elections/src/runner.rs b/src/node-control/elections/src/runner.rs index 0290a05..56b2d29 100644 --- a/src/node-control/elections/src/runner.rs +++ b/src/node-control/elections/src/runner.rs @@ -20,8 +20,8 @@ use common::{ ton_utils::nanotons_to_dec_string, }; use contracts::{ - ElectionsInfo, ElectorWrapper, NominatorWrapper, Participant, TonWallet, - elector::PastElections, nominator, + ElectionsInfo, ElectorWrapper, NodePools, Participant, TonWallet, elector::PastElections, + nominator, }; use std::{ collections::{HashMap, HashSet}, @@ -84,10 +84,9 @@ struct Node { /// Computed in build_validators_snapshot, used by build_our_participants_snapshot. is_next_validator: bool, wallet: Arc, - /// Nominator pool instance. Optional — `None` means direct staking. - pool: Option>, - /// Address to which to send commands: stake & recover. - /// Pool address when a pool is configured, elector address otherwise. + /// Nominator pool(s) for this node. `None` = direct staking (no pool). + pools: Option, + /// Default address to send commands: primary pool address or elector address. elections_address: MsgAddressInt, /// Last error observed for this node during the current/previous tick (stringified). last_error: Option, @@ -104,19 +103,33 @@ struct Node { } impl Node { - fn wallet_addr(&self) -> Vec { - self.pool - .as_ref() - .map(|p| p.address()) - .unwrap_or_else(|| self.wallet.address()) - .address() - .clone() - .storage() - .to_vec() + /// All addresses that may have stakes at the elector (for recovery and snapshot matching). + fn all_staking_addresses(&self) -> Vec> { + match &self.pools { + Some(pools) => pools + .all() + .iter() + .map(|p| p.address().address().clone().storage().to_vec()) + .collect(), + None => vec![self.wallet.address().address().clone().storage().to_vec()], + } } + fn elections_addr(&self) -> MsgAddressInt { self.elections_address.clone() } + + /// Resolve the pool/elector address to use for the current staking operation. + /// For Router pools, queries `get_pool_data` to pick the free pool. + /// For Single pools, returns the pool address. + /// For direct staking (no pool), returns the elector address. + async fn resolve_staking_target(&self) -> anyhow::Result { + match &self.pools { + Some(pools) => Ok(pools.select_free().await?.address()), + None => Ok(self.elections_address.clone()), + } + } + fn reset_participation(&mut self) { self.participant = None; self.submission_time = None; @@ -124,20 +137,28 @@ impl Node { self.accepted_stake_amount = None; self.stake_submissions.clear(); } - async fn stake_balance(&mut self, gas_fee: u64) -> anyhow::Result { - match self.pool.as_ref() { - Some(pool) => self.api.account(&pool.address().to_string()).await.map(|x| x.balance()), - None => self + + async fn stake_balance( + &mut self, + gas_fee: u64, + active_pool_addr: Option<&MsgAddressInt>, + ) -> anyhow::Result { + match active_pool_addr { + Some(addr) => self .api - .account(&self.wallet.address().to_string()) + .account(&addr.to_string()) .await - .map(|x| x.balance().saturating_sub(gas_fee)), + .map(|x| x.balance().saturating_sub(MIN_NANOTON_FOR_STORAGE)), + None => self.api.account(&self.wallet.address().to_string()).await.map(|x| { + x.balance().saturating_sub(gas_fee).saturating_sub(MIN_NANOTON_FOR_STORAGE) + }), } - .map(|b| b.saturating_sub(MIN_NANOTON_FOR_STORAGE)) } + async fn wallet_balance(&mut self) -> anyhow::Result { self.api.account(&self.wallet.address().to_string()).await.map(|x| x.balance()) } + async fn find_election_key(&mut self, election_id: u64) -> Option { let mut validator_entry = self.validator_config.find(election_id); if let Some(entry) = validator_entry.as_mut() { @@ -206,7 +227,7 @@ impl ElectionRunner { elector: Arc, providers: HashMap>, wallets: Arc>>, - pools: Arc>>, + pools: Arc>, ) -> Self { Self { default_max_factor: elections_config.max_factor, @@ -221,7 +242,7 @@ impl ElectionRunner { return None; } }; - let pool = pools.get(&node_id).cloned(); + let node_pools = 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); @@ -230,12 +251,12 @@ impl ElectionRunner { node_id, Node { api: provider, - elections_address: pool + elections_address: node_pools .as_ref() - .map(|p| p.address()) + .map(|p| p.primary().address()) .unwrap_or_else(|| elector.address()), wallet, - pool, + pools: node_pools, excluded, stake_policy, key_id: vec![], @@ -359,8 +380,9 @@ impl ElectionRunner { // Reset previous state; only mark as accepted if present in current participants node.stake_accepted = false; node.accepted_stake_amount = None; + let addrs = node.all_staking_addresses(); if let Some(p) = - elections_info.participants.iter().find(|p| p.wallet_addr == node.wallet_addr()) + elections_info.participants.iter().find(|p| addrs.contains(&p.wallet_addr)) { node.stake_accepted = true; node.accepted_stake_amount = Some(p.stake); @@ -426,7 +448,7 @@ impl ElectionRunner { // Include all pool addresses (even + odd for TONCore) so we can match any participant. let wallet_addrs: HashSet> = - self.nodes.values().map(|node| node.wallet_addr()).collect(); + self.nodes.values().flat_map(|node| node.all_staking_addresses()).collect(); let participants = Self::build_participants_snapshot(elections_info, &wallet_addrs); let participant_min_stake = @@ -482,6 +504,18 @@ impl ElectionRunner { ) -> 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"); + + // Resolve the target address and wallet_addr once per tick so that + // calc_stake, send_stake, and the elector signed payload all use the + // same pool (critical for Router where select_free() picks one of two). + let staking_target = + node.resolve_staking_target().await.context("resolve staking target")?; + let active_pool_addr = node.pools.as_ref().map(|_| &staking_target); + let staking_wallet_addr = match &node.pools { + Some(_) => staking_target.address().clone().storage().to_vec(), + None => node.wallet.address().address().clone().storage().to_vec(), + }; + // 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 @@ -498,6 +532,7 @@ impl ElectionRunner { &self.past_elections, participant.as_ref().map(|p| p.stake).unwrap_or(0), elections_info.min_stake, + active_pool_addr, ) .await .context("stake calculation error")?; @@ -552,12 +587,12 @@ impl ElectionRunner { pub_key, adnl_addr, election_id, - wallet_addr: node.wallet_addr(), + wallet_addr: staking_wallet_addr.clone(), stake, max_factor, }); node.key_id = key_id; - Self::send_stake(node_id, node, stake).await?; + Self::send_stake(node_id, node, stake, staking_target).await?; Ok(()) } Some(entry) => { @@ -610,7 +645,7 @@ impl ElectionRunner { .ok_or_else(|| anyhow::anyhow!("no adnl address"))?, pub_key: entry.public_key, election_id, - wallet_addr: node.wallet_addr(), + wallet_addr: staking_wallet_addr.clone(), stake, max_factor, }); @@ -618,8 +653,9 @@ impl ElectionRunner { } if let Some(p) = node.participant.as_mut() { p.stake = stake; + p.wallet_addr = staking_wallet_addr; } - Self::send_stake(node_id, node, stake).await?; + Self::send_stake(node_id, node, stake, staking_target).await?; } } Ok(()) @@ -627,11 +663,17 @@ impl ElectionRunner { } } - async fn send_stake(node_id: &str, node: &mut Node, stake: u64) -> anyhow::Result<()> { + async fn send_stake( + node_id: &str, + node: &mut Node, + stake: u64, + target_addr: MsgAddressInt, + ) -> anyhow::Result<()> { tracing::info!("node [{}] build stake message", node_id); let payload = Self::build_new_stake_payload(node_id, node).await?; let fee = ELECTOR_STAKE_FEE + NPOOL_COMPUTE_FEE; - let stake_balance = node.stake_balance(fee).await?; + let active_pool = node.pools.as_ref().map(|_| &target_addr); + let stake_balance = node.stake_balance(fee, active_pool).await?; if stake_balance < stake { anyhow::bail!( "low stake balance: required={} TON, available={} TON", @@ -648,9 +690,8 @@ impl ElectionRunner { ); } - let send_value = node.pool.as_ref().map(|_| fee).unwrap_or(stake + fee); - let msg_boc = - write_boc(&node.wallet.message(node.elections_addr(), send_value, payload).await?)?; + let send_value = node.pools.as_ref().map(|_| fee).unwrap_or(stake + fee); + let msg_boc = write_boc(&node.wallet.message(target_addr, send_value, payload).await?)?; tracing::debug!("wallet external message: boc={}", hex::encode(&msg_boc)); tracing::info!("node [{}] send stake", node_id); node.api.send_boc(&msg_boc).await?; @@ -718,37 +759,55 @@ impl ElectionRunner { async fn recover_stake(&mut self, node_id: &str) -> anyhow::Result { let node = self.nodes.get_mut(node_id).expect("node not found"); - let amount = self.elector.compute_returned_stake(&node.wallet_addr()).await?; - node.last_recover_amount = amount; - if amount > 0 { + + let recover_targets: Vec<(Vec, MsgAddressInt)> = match &node.pools { + Some(pools) => pools + .all() + .iter() + .map(|p| (p.address().address().clone().storage().to_vec(), p.address())) + .collect(), + None => { + let addr = node.wallet.address(); + vec![(addr.address().clone().storage().to_vec(), node.elections_addr())] + } + }; + + let fee_per_recover = RECOVER_FEE + WALLET_COMPUTE_FEE; + let mut wallet_balance = node.wallet_balance().await?; + let mut total_amount = 0u64; + + for (staking_addr, target_addr) in recover_targets { + let amount = self.elector.compute_returned_stake(&staking_addr).await?; + if amount == 0 { + continue; + } tracing::info!( - "node [{}] send recover stake: amount={} TON", + "node [{}] send recover stake: target={}, amount={} TON", node_id, + target_addr, amount as f64 / 1_000_000_000.0 ); - let fee = RECOVER_FEE + WALLET_COMPUTE_FEE; - let wallet_balance = node.wallet_balance().await?; - if wallet_balance < fee { + if wallet_balance < fee_per_recover { anyhow::bail!( "node [{}] low wallet balance: required={} TON, available={} TON", node_id, - fee as f64 / 1_000_000_000.0, + fee_per_recover as f64 / 1_000_000_000.0, wallet_balance as f64 / 1_000_000_000.0 ); } let msg_boc = write_boc( &node .wallet - .message( - node.elections_addr(), - RECOVER_FEE, - Self::build_recover_stake_payload().await?, - ) + .message(target_addr, RECOVER_FEE, Self::build_recover_stake_payload().await?) .await?, )?; node.api.send_boc(&msg_boc).await?; + wallet_balance = wallet_balance.saturating_sub(fee_per_recover); + total_amount += amount; } - Ok(amount) + + node.last_recover_amount = total_amount; + Ok(total_amount) } pub(crate) async fn shutdown(&mut self) -> anyhow::Result<()> { @@ -796,6 +855,7 @@ impl ElectionRunner { past_elections: &[PastElections], elections_stake: u64, // stake sent to the elections but not yet accepted by the elector min_stake: u64, + active_pool_addr: Option<&MsgAddressInt>, ) -> anyhow::Result { tracing::info!("node [{}] calc stake", node_id); let fee = ELECTOR_STAKE_FEE + NPOOL_COMPUTE_FEE; @@ -814,7 +874,7 @@ impl ElectionRunner { } // Get pool free balance - let pool_free_balance = node.stake_balance(fee).await?; + let pool_free_balance = node.stake_balance(fee, active_pool_addr).await?; let total_balance = frozen_stake + pool_free_balance + elections_stake; tracing::info!( "node [{}] frozen_stake={} TON, pool_balance={} TON, elections_stake={} TON, total_balance={} TON", @@ -949,7 +1009,9 @@ impl ElectionRunner { let participant = node.participant.as_ref(); let wallet_addr = Some(node.wallet.address().to_string()); - let pool_addr = node.pool.as_ref().map(|p| p.address().to_string()); + let pool_addr = node.pools.as_ref().map(|p| { + p.all().iter().map(|w| w.address().to_string()).collect::>().join(", ") + }); let pubkey = validator_entry .as_ref() .map(|(_, entry)| { @@ -1093,7 +1155,9 @@ impl ElectionRunner { let node = self.nodes.get(&node_id).expect("node not found"); let participant = node.participant.as_ref(); let wallet_addr = Some(node.wallet.address().to_string()); - let pool_addr = node.pool.as_ref().map(|p| p.address().to_string()); + let pool_addr = node.pools.as_ref().map(|p| { + p.all().iter().map(|w| w.address().to_string()).collect::>().join(", ") + }); let pubkey = participant.map(|p| { base64::Engine::encode( @@ -1127,7 +1191,11 @@ impl ElectionRunner { }) .collect(); - let fallback_sender_addr = format!("-1:{}", hex::encode(node.wallet_addr())); + let staking_addrs: Vec = node + .all_staking_addresses() + .iter() + .map(|a| format!("-1:{}", hex::encode(a))) + .collect(); let accepted_stake = if node.stake_accepted { node.accepted_stake_amount.map(nanotons_to_dec_string).or_else(|| { node.stake_submissions.last().map(|s| nanotons_to_dec_string(s.stake)) @@ -1136,10 +1204,10 @@ impl ElectionRunner { None }; - // Find position in ranked list (1-based) + // Find position in ranked list (1-based); for Router check both pool addresses. let position = ranked_participants .iter() - .position(|p| p.sender_addr == fallback_sender_addr) + .position(|p| staking_addrs.contains(&p.sender_addr)) .map(|pos| (pos + 1) as u32); let elections_running = matches!( diff --git a/src/node-control/elections/src/runner_tests.rs b/src/node-control/elections/src/runner_tests.rs index 415dc07..9a258a5 100644 --- a/src/node-control/elections/src/runner_tests.rs +++ b/src/node-control/elections/src/runner_tests.rs @@ -14,7 +14,7 @@ use common::{ time_format, }; use contracts::{ - ElectionsInfo, ElectorWrapper, NominatorWrapper, Participant, TonWallet, + ElectionsInfo, ElectorWrapper, NodePools, NominatorWrapper, Participant, TonWallet, elector::{FrozenParticipant, PastElections}, nominator::{NominatorRoles, PoolData, opcodes}, }; @@ -28,6 +28,7 @@ use ton_block::{ // ---- Address helpers ---- const POOL_ADDR: [u8; 32] = [0xBBu8; 32]; +const POOL_ADDR_1: [u8; 32] = [0xCCu8; 32]; fn wallet_address() -> MsgAddressInt { MsgAddressInt::standard(-1, [0xAAu8; 32]) @@ -37,6 +38,10 @@ fn pool_address() -> MsgAddressInt { MsgAddressInt::standard(-1, POOL_ADDR) } +fn pool_address_1() -> MsgAddressInt { + MsgAddressInt::standard(-1, POOL_ADDR_1) +} + fn elector_address() -> MsgAddressInt { MsgAddressInt::standard(-1, [0x33u8; 32]) } @@ -321,6 +326,7 @@ struct TestHarness { provider_mock: MockElectionsProviderImpl, wallet_mock: MockTonWalletImpl, pool_mock: Option, + router_mocks: Option<(MockNominatorWrapperImpl, MockNominatorWrapperImpl)>, elections_config: ElectionsConfig, bindings: HashMap, } @@ -332,6 +338,7 @@ impl TestHarness { provider_mock: MockElectionsProviderImpl::new(), wallet_mock: MockTonWalletImpl::new(), pool_mock: None, + router_mocks: None, elections_config: ElectionsConfig { policy: StakePolicy::Split50, policy_overrides: HashMap::new(), @@ -347,6 +354,12 @@ impl TestHarness { self } + fn with_router(mut self) -> Self { + self.router_mocks = + Some((MockNominatorWrapperImpl::new(), MockNominatorWrapperImpl::new())); + self + } + fn build(mut self, node_id: &str) -> ElectionRunner { self.bindings.entry(node_id.to_string()).or_insert_with(|| default_binding(true)); @@ -357,9 +370,11 @@ impl TestHarness { let mut providers: HashMap> = HashMap::new(); providers.insert(node_id.to_string(), Box::new(self.provider_mock)); - let mut pools: HashMap> = HashMap::new(); + let mut pools: HashMap = HashMap::new(); if let Some(pool) = self.pool_mock { - pools.insert(node_id.to_string(), Arc::new(pool)); + pools.insert(node_id.to_string(), NodePools::Single(Arc::new(pool))); + } else if let Some((p0, p1)) = self.router_mocks { + pools.insert(node_id.to_string(), NodePools::Router([Arc::new(p0), Arc::new(p1)])); } let elector: Arc = Arc::new(self.elector_mock); @@ -471,10 +486,38 @@ fn setup_wallet(wallet: &mut MockTonWalletImpl) { wallet.expect_message().returning(|_dest, _value, _payload| Ok(dummy_cell())); } +/// Like `setup_default_provider` but without `expect_account` — caller sets up account mock separately. +fn setup_default_provider_without_account( + provider: &mut MockElectionsProviderImpl, + _wallet_balance: u64, +) { + provider.expect_election_parameters().returning(|| Ok(default_cfg15())); + provider.expect_validator_config().returning(|| Ok(ValidatorConfig::new())); + provider.expect_get_current_vset().returning(|| Err(anyhow::anyhow!("no vset"))); + provider.expect_get_next_vset().returning(|| Ok(None)); + provider + .expect_new_validator_key() + .returning(|_since, _until| Ok((KEY_ID.to_vec(), PUB_KEY.to_vec()))); + provider.expect_new_adnl_addr().returning(|_key_id, _until| Ok(ADNL_ADDR.to_vec())); + provider.expect_export_public_key().returning(|_key_id| Ok(PUB_KEY.to_vec())); + provider.expect_sign().returning(|_key, _data| Ok(SIGNATURE.to_vec())); + provider.expect_send_boc().returning(|_boc| Ok(())); + provider.expect_shutdown().returning(|| Ok(())); +} + fn setup_pool(pool: &mut MockNominatorWrapperImpl) { pool.expect_address().returning(|| pool_address()); } +fn pool_data_with_state(state: i32) -> PoolData { + PoolData { state, ..Default::default() } +} + +fn setup_router_pool(pool: &mut MockNominatorWrapperImpl, addr: MsgAddressInt, state: i32) { + pool.expect_address().returning(move || addr.clone()); + pool.expect_get_pool_data().returning(move || Ok(pool_data_with_state(state))); +} + // ===================================================== // TEST: participate in elections (new key, no pool) // ===================================================== @@ -1594,7 +1637,7 @@ async fn test_node_without_wallet_skipped() { providers.insert("node-1".to_string(), Box::new(provider1)); let wallets: HashMap> = HashMap::new(); // empty! - let pools: HashMap> = HashMap::new(); + let pools: HashMap = HashMap::new(); let runner = ElectionRunner::new( &elections_config, @@ -1995,3 +2038,189 @@ async fn test_participation_status_lifecycle() { runner.snapshot_cache.last_elections_status = ElectionsStatus::Closed; assert_eq!(get_status(&runner), ParticipationStatus::Idle); } + +// ===================================================== +// Router-specific tests +// ===================================================== + +#[tokio::test] +async fn test_router_selects_free_pool() { + let node_id = "node-1"; + let mut harness = TestHarness::new().with_router(); + + setup_default_elector(&mut harness.elector_mock, ELECTION_ID, 0); + setup_wallet(&mut harness.wallet_mock); + + let (p0, p1) = harness.router_mocks.as_mut().unwrap(); + // pool[0] busy (state=2), pool[1] free (state=0) + setup_router_pool(p0, pool_address(), 2); + setup_router_pool(p1, pool_address_1(), 0); + + // Provider returns pool[1] balance when asked for that address + let pool1_hex = hex::encode(POOL_ADDR_1); + harness.provider_mock.expect_account().returning(move |address| { + if address.contains(&pool1_hex) { + Ok(fake_account(POOL_BALANCE)) + } else { + Ok(fake_account(WALLET_BALANCE)) + } + }); + + setup_default_provider_without_account(&mut harness.provider_mock, WALLET_BALANCE); + + let expected_stake = (POOL_BALANCE - MIN_NANOTON_FOR_STORAGE) / 2; + harness.wallet_mock.expect_message().returning(|_dest, _value, _payload| Ok(dummy_cell())); + + 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_some()); + let participant = node.participant.as_ref().unwrap(); + // wallet_addr should be pool[1] (the free one), not pool[0] + assert_eq!(participant.wallet_addr, addr_bytes(&pool_address_1())); + assert_eq!(participant.stake, expected_stake); +} + +#[tokio::test] +async fn test_router_both_pools_busy_skips_elections() { + let node_id = "node-1"; + let mut harness = TestHarness::new().with_router(); + + setup_default_elector(&mut harness.elector_mock, ELECTION_ID, 0); + setup_wallet(&mut harness.wallet_mock); + + let (p0, p1) = harness.router_mocks.as_mut().unwrap(); + setup_router_pool(p0, pool_address(), 2); + setup_router_pool(p1, pool_address_1(), 2); + + harness + .provider_mock + .expect_account() + .returning(move |_address| Ok(fake_account(WALLET_BALANCE))); + + setup_default_provider_without_account(&mut harness.provider_mock, WALLET_BALANCE); + + let mut runner = harness.build(node_id); + let result = runner.run().await; + // Should fail because both pools are busy + assert!( + result.is_err() || { + let node = runner.nodes.get(node_id).unwrap(); + node.last_error.is_some() + } + ); +} + +#[tokio::test] +async fn test_router_recover_stake_both_pools() { + let node_id = "node-1"; + let mut harness = TestHarness::new().with_router(); + + let returned_per_pool = 50_000_000_000_000u64; + + // Elector returns active elections so run() proceeds to recover + harness.elector_mock.expect_address().returning(|| elector_address()); + harness.elector_mock.expect_get_active_election_id().returning(|| Ok(ELECTION_ID)); + harness.elector_mock.expect_elections_info().returning(move || { + Ok(ElectionsInfo { + election_id: ELECTION_ID, + elect_close: ELECTION_ID - 300, + min_stake: MIN_STAKE, + total_stake: 0, + failed: false, + finished: false, + participants: vec![], + }) + }); + harness.elector_mock.expect_past_elections().returning(|| Ok(vec![])); + // Both pools have returnable stake + harness + .elector_mock + .expect_compute_returned_stake() + .returning(move |_addr| Ok(returned_per_pool)); + + setup_wallet(&mut harness.wallet_mock); + harness.wallet_mock.expect_message().returning(|_dest, _value, _payload| Ok(dummy_cell())); + + let (p0, p1) = harness.router_mocks.as_mut().unwrap(); + setup_router_pool(p0, pool_address(), 0); + setup_router_pool(p1, pool_address_1(), 0); + + harness.provider_mock.expect_election_parameters().returning(|| Ok(default_cfg15())); + 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_validator_config().returning(|| Ok(ValidatorConfig::new())); + harness + .provider_mock + .expect_account() + .returning(move |_address| Ok(fake_account(WALLET_BALANCE))); + harness.provider_mock.expect_send_boc().returning(|_boc| Ok(())); + harness.provider_mock.expect_shutdown().returning(|| Ok(())); + + 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(); + // Both pools had returnables, so total recovered = 2 * returned_per_pool + assert_eq!(node.last_recover_amount, returned_per_pool * 2); +} + +#[tokio::test] +async fn test_router_elections_finished_matches_any_pool() { + let node_id = "node-1"; + let mut harness = TestHarness::new().with_router(); + + // Elections are finished with a participant from pool[1] + harness.elector_mock.expect_address().returning(|| elector_address()); + harness.elector_mock.expect_get_active_election_id().returning(|| Ok(ELECTION_ID)); + let pool1_addr_bytes = addr_bytes(&pool_address_1()); + harness.elector_mock.expect_elections_info().returning(move || { + Ok(ElectionsInfo { + election_id: ELECTION_ID, + elect_close: ELECTION_ID - 300, + min_stake: MIN_STAKE, + total_stake: 100_000_000_000_000, + failed: false, + finished: true, + participants: vec![Participant { + pub_key: PUB_KEY.to_vec(), + adnl_addr: ADNL_ADDR.to_vec(), + election_id: ELECTION_ID, + wallet_addr: pool1_addr_bytes.clone(), + stake: 50_000_000_000_000, + max_factor: 196608, + stake_message_boc: None, + }], + }) + }); + harness.elector_mock.expect_past_elections().returning(|| Ok(vec![])); + harness.elector_mock.expect_compute_returned_stake().returning(|_| Ok(0)); + + setup_wallet(&mut harness.wallet_mock); + + let (p0, p1) = harness.router_mocks.as_mut().unwrap(); + setup_router_pool(p0, pool_address(), 0); + setup_router_pool(p1, pool_address_1(), 0); + + harness.provider_mock.expect_election_parameters().returning(|| Ok(default_cfg15())); + 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_validator_config().returning(|| Ok(ValidatorConfig::new())); + harness + .provider_mock + .expect_account() + .returning(move |_address| Ok(fake_account(WALLET_BALANCE))); + harness.provider_mock.expect_shutdown().returning(|| Ok(())); + + 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(); + // Stake from pool[1] should be matched even though primary is pool[0] + assert!(node.stake_accepted, "stake_accepted should be true for Router pool[1]"); + assert_eq!(node.accepted_stake_amount, Some(50_000_000_000_000)); +} diff --git a/src/node-control/service/src/auth/user_store.rs b/src/node-control/service/src/auth/user_store.rs index 6ee6b0a..b38d79c 100644 --- a/src/node-control/service/src/auth/user_store.rs +++ b/src/node-control/service/src/auth/user_store.rs @@ -271,7 +271,7 @@ mod tests { use super::*; use crate::runtime_config::RuntimeConfig; use common::app_config::{AppConfig, AuthConfig, UserEntry}; - use contracts::{NominatorWrapper, TonWallet}; + use contracts::{NodePools, TonWallet}; use secrets_vault::{ crypto::{key_material::KeyMaterial, master_key::MasterKey}, storage::file_json::FileJsonStorage, @@ -370,7 +370,7 @@ mod tests { Arc::new(NoopWallet) } - fn pools(&self) -> Arc>> { + fn pools(&self) -> Arc> { Arc::new(HashMap::new()) } diff --git a/src/node-control/service/src/contracts/contracts_task.rs b/src/node-control/service/src/contracts/contracts_task.rs index 3fbaee7..47f799f 100644 --- a/src/node-control/service/src/contracts/contracts_task.rs +++ b/src/node-control/service/src/contracts/contracts_task.rs @@ -9,7 +9,7 @@ use crate::runtime_config::RuntimeConfig; use anyhow::Context; use common::{app_config::AppConfig, snapshot::SnapshotStore, task_cancellation::CancellationCtx}; -use contracts::{NominatorWrapper, TonWallet, contract_provider}; +use contracts::{NodePools, NominatorWrapper, TonWallet, contract_provider}; use std::{ collections::{HashMap, HashSet}, sync::Arc, @@ -44,7 +44,7 @@ pub(crate) async fn run( struct ContractsMonitor { master_wallet: Arc, - pools: Arc>>, + pools: Arc>, wallets: Arc>>, rpc_client: Arc, _store: Arc, @@ -254,18 +254,20 @@ impl ContractsMonitor { /// Returns `false` if master balance is insufficient (caller should sleep). async fn ensure_pools_deployed(&self, seqno: &mut i64) -> anyhow::Result { let mut all_deployed = true; - for (node_id, pool) in self.pools.iter() { - match self.deploy_pool(node_id, pool.clone(), *seqno).await { - Ok(true) => (), - Ok(false) => { - all_deployed = false; - *seqno += 1; - } - Err(e) => { - all_deployed = false; - tracing::error!(target: "contracts", "[{}] deploy pool error: {:#}", node_id, e); - } - }; + for (node_id, node_pools) in self.pools.iter() { + for pool in node_pools.all() { + match self.deploy_pool(node_id, pool.clone(), *seqno).await { + Ok(true) => (), + Ok(false) => { + all_deployed = false; + *seqno += 1; + } + Err(e) => { + all_deployed = false; + tracing::error!(target: "contracts", "[{}] deploy pool error: {:#}", node_id, e); + } + }; + } } Ok(all_deployed) } @@ -407,7 +409,7 @@ mod tests { use super::ContractsMonitor; use axum::{Json, Router, extract::State, routing::post}; use common::snapshot::SnapshotStore; - use contracts::{NominatorWrapper, SmartContract, TonWallet}; + use contracts::{NodePools, SmartContract, TonWallet}; use std::{ collections::HashMap, sync::{ @@ -582,7 +584,7 @@ mod tests { let rpc_client = Arc::new(ClientJsonRpc::connect(rpc_url, None).unwrap()); ContractsMonitor { master_wallet, - pools: Arc::>>::default(), + pools: Arc::>::default(), wallets, rpc_client, _store: Arc::new(SnapshotStore::new()), diff --git a/src/node-control/service/src/runtime_config.rs b/src/node-control/service/src/runtime_config.rs index eae09f8..704bc2e 100644 --- a/src/node-control/service/src/runtime_config.rs +++ b/src/node-control/service/src/runtime_config.rs @@ -13,8 +13,8 @@ use common::{ vault_signer::VaultSigner, }; use contracts::{ - NominatorPoolWrapperImpl, NominatorWrapper, NominatorWrapperImpl, TonWallet, WalletContract, - contract_provider, resolve_toncore_pool, + NodePools, NominatorPoolWrapperImpl, NominatorWrapper, NominatorWrapperImpl, TonWallet, + WalletContract, contract_provider, resolve_toncore_pool, resolve_toncore_router, }; use secrets_vault::{ types::{algorithm::Algorithm, secret_id::SecretId, secret_spec::SecretSpec}, @@ -50,7 +50,7 @@ struct RuntimeState { /// Optional secrets vault for key management. vault: Option>, /// Lazily-loaded nominator pools, rebuilt when config changes. - pools: Arc>>, + pools: Arc>, /// Lazily-loaded wallets, rebuilt when config changes. wallets: Arc>>, /// Shared TON HTTP API JSON-RPC client. @@ -76,7 +76,7 @@ impl std::error::Error for RuntimeConfigError {} pub trait RuntimeConfig: Send + Sync { fn get(&self) -> Arc; fn master_wallet(&self) -> Arc; - fn pools(&self) -> Arc>>; + fn pools(&self) -> Arc>; fn wallets(&self) -> Arc>>; fn rpc_client(&self) -> Arc; fn vault(&self) -> Option>; @@ -325,7 +325,7 @@ impl RuntimeConfigStore { app_config: &AppConfig, rpc_client: Arc, wallets: &HashMap>, - ) -> anyhow::Result>>> { + ) -> anyhow::Result>> { let mut map = HashMap::new(); for (node_name, binding) in app_config.bindings.iter() { if let Some(pool_name) = &binding.pool { @@ -337,16 +337,14 @@ impl RuntimeConfigStore { .get(node_name) .context(format!("validator wallet not found: {}", node_name))? .address(); - let pool = open_nominator_pool(cfg, rpc_client.clone(), &validator_address) + let node_pools = open_nominator_pool(cfg, rpc_client.clone(), &validator_address) .map_err(|e| { - anyhow::anyhow!("node [{}] open nominator pool error: {:#}", node_name, e) - })?; - tracing::info!( - "[{}] opened nominator pool: address={}", - node_name, - pool.address() - ); - map.insert(node_name.to_owned(), pool); + anyhow::anyhow!("node [{}] open nominator pool error: {:#}", node_name, e) + })?; + let addrs: Vec = + node_pools.all().iter().map(|p| p.address().to_string()).collect(); + tracing::info!("[{}] opened nominator pool(s): {}", node_name, addrs.join(", ")); + map.insert(node_name.to_owned(), node_pools); } } Ok(Arc::new(map)) @@ -391,7 +389,7 @@ impl RuntimeConfig for RuntimeConfigStore { Arc::clone(&state.master_wallet) } - fn pools(&self) -> Arc>> { + fn pools(&self) -> Arc> { let state = self.state.read().expect("Runtime state poisoned (read)"); Arc::clone(&state.pools) } @@ -468,7 +466,7 @@ fn open_nominator_pool( config: &PoolConfig, rpc_client: Arc, validator_addr: &MsgAddressInt, -) -> anyhow::Result> { +) -> anyhow::Result { match config { PoolConfig::SNP { address, owner } => { let pool = match (address, owner) { @@ -513,7 +511,7 @@ fn open_nominator_pool( anyhow::bail!("pool has neither address nor owner configured"); } }; - Ok(Arc::new(pool)) + Ok(NodePools::Single(Arc::new(pool))) } PoolConfig::TONCore { validator_share, @@ -530,11 +528,41 @@ fn open_nominator_pool( min_validator_stake.as_ref().copied(), min_nominator_stake.as_ref().copied(), )?; - Ok(Arc::new(NominatorPoolWrapperImpl::new_with_state_init( + Ok(NodePools::Single(Arc::new(NominatorPoolWrapperImpl::new_with_state_init( contract_provider!(rpc_client.clone()), resolved.address, resolved.state_init, - ))) + )))) + } + PoolConfig::TONCoreRouter { + validator_share, + addresses, + max_nominators, + min_validator_stake, + min_nominator_stake, + } => { + let resolved = resolve_toncore_router( + validator_addr, + *validator_share, + addresses.as_ref(), + max_nominators.as_ref().copied(), + min_validator_stake.as_ref().copied(), + min_nominator_stake.as_ref().copied(), + )?; + let [r0, r1] = resolved; + let p0: Arc = + Arc::new(NominatorPoolWrapperImpl::new_with_state_init( + contract_provider!(rpc_client.clone()), + r0.address, + r0.state_init, + )); + let p1: Arc = + Arc::new(NominatorPoolWrapperImpl::new_with_state_init( + contract_provider!(rpc_client.clone()), + r1.address, + r1.state_init, + )); + Ok(NodePools::Router([p0, p1])) } } } diff --git a/src/node-control/service/src/task/task_manager.rs b/src/node-control/service/src/task/task_manager.rs index ff87136..0dc6325 100644 --- a/src/node-control/service/src/task/task_manager.rs +++ b/src/node-control/service/src/task/task_manager.rs @@ -175,7 +175,7 @@ impl TaskController { mod tests { use super::*; use common::app_config::{HttpConfig, TonHttpApiConfig}; - use contracts::{NominatorWrapper, TonWallet}; + use contracts::{NodePools, TonWallet}; use secrets_vault::vault::SecretVault; use std::{ collections::HashMap, @@ -194,11 +194,11 @@ mod tests { fn master_wallet(&self) -> Arc { unimplemented!() } - fn pools(&self) -> Arc>> { - unimplemented!() + fn pools(&self) -> Arc> { + Arc::new(HashMap::new()) } fn wallets(&self) -> Arc>> { - unimplemented!() + Arc::new(HashMap::new()) } fn rpc_client(&self) -> Arc { unimplemented!() From 4b760de68c0dd40764f371bf0868ac0f571ddb58 Mon Sep 17 00:00:00 2001 From: mrnkslv Date: Fri, 3 Apr 2026 18:08:17 +0300 Subject: [PATCH 13/18] feat: upd binding options --- .../src/commands/nodectl/config_pool_cmd.rs | 203 ++++++++++++++++-- .../src/commands/nodectl/deploy_cmd.rs | 6 +- .../service/src/contracts/contracts_task.rs | 80 ++++++- 3 files changed, 272 insertions(+), 17 deletions(-) diff --git a/src/node-control/commands/src/commands/nodectl/config_pool_cmd.rs b/src/node-control/commands/src/commands/nodectl/config_pool_cmd.rs index 999d108..abc1113 100644 --- a/src/node-control/commands/src/commands/nodectl/config_pool_cmd.rs +++ b/src/node-control/commands/src/commands/nodectl/config_pool_cmd.rs @@ -304,6 +304,8 @@ struct PoolView { #[serde(skip_serializing_if = "Option::is_none")] addresses: Option>, #[serde(skip_serializing_if = "Option::is_none")] + balances: Option>, + #[serde(skip_serializing_if = "Option::is_none")] validator_share: Option, } @@ -392,31 +394,89 @@ async fn collect_pool_views( address: addr_result.ok(), owner: display_owner, addresses: None, + balances: None, validator_share: None, }); } PoolConfig::TONCore { validator_share, address, .. } => { + let resolved_addr = if address.is_some() { + address.clone() + } else { + resolve_toncore_pool_address_via_binding( + name, + pool, + config, + &mut vault, + warn_on_error, + ) + .await + .ok() + }; + + let balance_result = if let Some(ref addr_str) = resolved_addr { + resolve_pool_balance(&Ok(addr_str.clone()), rpc_client).await.ok() + } else { + None + }; + views.push(PoolView { name: name.clone(), kind: "Core".to_string(), - balance: None, - address: address.clone(), + balance: balance_result, + address: resolved_addr, owner: None, addresses: None, + balances: None, validator_share: Some(*validator_share), }); } PoolConfig::TONCoreRouter { validator_share, addresses, .. } => { - let addrs = addresses.as_ref().map(|a| { - a.iter().map(|o| o.clone().unwrap_or_else(|| "".into())).collect() - }); + let has_stored = addresses.as_ref().is_some_and(|a| a.iter().any(|o| o.is_some())); + + let resolved_addrs = if has_stored { + addresses.as_ref().map(|a| { + a.iter() + .map(|o| o.clone().unwrap_or_else(|| "".into())) + .collect() + }) + } else { + resolve_toncore_router_addresses_via_binding( + name, + pool, + config, + &mut vault, + warn_on_error, + ) + .await + .ok() + }; + + let pool_balances = + if let (Some(addrs), Some(client)) = (&resolved_addrs, rpc_client) { + let mut bals = Vec::new(); + for addr_str in addrs { + if let Ok(addr) = MsgAddressInt::from_str(addr_str) { + match client.get_address_information(&addr).await { + Ok(info) => bals.push(display_tons(info.balance)), + Err(_) => bals.push("-".to_string()), + } + } else { + bals.push("-".to_string()); + } + } + Some(bals) + } else { + None + }; + views.push(PoolView { name: name.clone(), kind: "Router".to_string(), balance: None, address: None, owner: None, - addresses: addrs, + addresses: resolved_addrs, + balances: pool_balances, validator_share: Some(*validator_share), }); } @@ -459,10 +519,11 @@ fn print_pools_table(views: &[PoolView]) { } "Core" => { let display_addr = v.address.as_deref().unwrap_or(""); - let share = v.validator_share.map(|s| s.to_string()).unwrap_or_default(); + let display_balance = + v.balance.as_deref().map(|s| s.white()).unwrap_or_else(|| "-".red()); println!( - " {:<15} {:<6} {:<14} {:<50} share={}", - v.name, "Core", "-", display_addr, share, + " {:<15} {:<6} {:<14} {:<50} {}", + v.name, "Core", display_balance, display_addr, "-", ); } "Router" => { @@ -471,11 +532,9 @@ fn print_pools_table(views: &[PoolView]) { .as_deref() .map(|a| a.join(", ")) .unwrap_or_else(|| "".into()); - let share = v.validator_share.map(|s| s.to_string()).unwrap_or_default(); - println!( - " {:<15} {:<6} {:<14} {:<50} share={}", - v.name, "Router", "-", addrs, share, - ); + let display_balance = + v.balances.as_deref().map(|b| b.join(" | ")).unwrap_or_else(|| "-".into()); + println!(" {:<15} {:<8} {:<28} {}", v.name, "Router", display_balance, addrs,); } _ => {} } @@ -565,6 +624,122 @@ async fn resolve_pool_address( Ok(addr_str) } +async fn resolve_validator_addr_via_binding( + pool_name: &str, + config: &AppConfig, + vault: &mut Option>>, + warn_on_error: bool, +) -> Result { + if vault.is_none() { + *vault = Some(match SecretVaultBuilder::from_env().await { + Ok(v) => Some(v), + Err(e) => { + if warn_on_error { + println!( + "{}: {}", + "Warning: failed to initialize secret vault".yellow(), + e.to_string().yellow() + ); + } + None + } + }); + } + let vault_arc = vault.as_ref().and_then(|v| v.clone()).ok_or("vault unavailable")?; + + let mut matching: Vec<_> = + config.bindings.iter().filter(|(_, b)| b.pool.as_deref() == Some(pool_name)).collect(); + if matching.is_empty() { + return Err("no binding found".to_string()); + } + matching.sort_by_key(|(k, b)| (!b.enable, k.as_str())); + let (_, binding) = matching[0]; + + let wallet_cfg = config.wallets.get(&binding.wallet).ok_or("wallet not configured")?; + let secret = + wallet_cfg.key.read_secret(Some(vault_arc)).await.map_err(|_| "get wallet key error")?; + let keypair = secret.as_keypair().map_err(|_| "wallet key is not a keypair")?; + let pub_key = keypair + .public_key() + .await + .map_err(|_| "get public key error")? + .ok_or("empty public key")?; + calculate_wallet_address(wallet_cfg, &pub_key) + .map_err(|_| "address calculation error".to_string()) +} + +async fn resolve_toncore_pool_address_via_binding( + pool_name: &str, + pool: &PoolConfig, + config: &AppConfig, + vault: &mut Option>>, + warn_on_error: bool, +) -> Result { + let PoolConfig::TONCore { + validator_share, + max_nominators, + min_validator_stake, + min_nominator_stake, + .. + } = pool + else { + return Err("not a TONCore pool".into()); + }; + let wallet_addr = + resolve_validator_addr_via_binding(pool_name, config, vault, warn_on_error).await?; + let resolved = resolve_toncore_pool( + &wallet_addr, + *validator_share, + None, + max_nominators.as_ref().copied(), + min_validator_stake.as_ref().copied(), + min_nominator_stake.as_ref().copied(), + ) + .map_err(|e| format!("resolve error: {e}"))?; + resolved + .address + .to_string_custom(ADDR_FORMAT_BOUNCE | ADDR_FORMAT_URL_SAFE) + .map_err(|_| "address conversion error".into()) +} + +async fn resolve_toncore_router_addresses_via_binding( + pool_name: &str, + pool: &PoolConfig, + config: &AppConfig, + vault: &mut Option>>, + warn_on_error: bool, +) -> Result, String> { + let PoolConfig::TONCoreRouter { + validator_share, + max_nominators, + min_validator_stake, + min_nominator_stake, + .. + } = pool + else { + return Err("not a TONCoreRouter pool".into()); + }; + let wallet_addr = + resolve_validator_addr_via_binding(pool_name, config, vault, warn_on_error).await?; + let resolved = resolve_toncore_router( + &wallet_addr, + *validator_share, + None, + max_nominators.as_ref().copied(), + min_validator_stake.as_ref().copied(), + min_nominator_stake.as_ref().copied(), + ) + .map_err(|e| format!("resolve error: {e}"))?; + resolved + .iter() + .map(|r| { + r.address + .to_string_custom(ADDR_FORMAT_BOUNCE | ADDR_FORMAT_URL_SAFE) + .map_err(|_| "address conversion error".to_string()) + }) + .collect() +} + fn normalize_ton_address(addr: &str, flag_name: &str) -> anyhow::Result { let trimmed = addr.trim(); if trimmed.is_empty() { diff --git a/src/node-control/commands/src/commands/nodectl/deploy_cmd.rs b/src/node-control/commands/src/commands/nodectl/deploy_cmd.rs index 24aef06..36ce83c 100644 --- a/src/node-control/commands/src/commands/nodectl/deploy_cmd.rs +++ b/src/node-control/commands/src/commands/nodectl/deploy_cmd.rs @@ -299,10 +299,12 @@ impl DeployPoolCmd { let (config, vault, rpc_client) = load_config_vault_rpc_client(Path::new(&self.config)).await.map_err(set_err)?; + let wallet_name = + config.bindings.get(&self.node).map(|b| b.wallet.as_str()).unwrap_or(&self.node); let wallet_cfg = config .wallets - .get(&self.node) - .ok_or_else(|| anyhow::anyhow!("Wallet '{}' not found", &self.node)) + .get(wallet_name) + .ok_or_else(|| anyhow::anyhow!("Wallet '{}' not found", wallet_name)) .map_err(set_err)?; if self.verbose { diff --git a/src/node-control/service/src/contracts/contracts_task.rs b/src/node-control/service/src/contracts/contracts_task.rs index 47f799f..0edc788 100644 --- a/src/node-control/service/src/contracts/contracts_task.rs +++ b/src/node-control/service/src/contracts/contracts_task.rs @@ -9,7 +9,10 @@ use crate::runtime_config::RuntimeConfig; use anyhow::Context; use common::{app_config::AppConfig, snapshot::SnapshotStore, task_cancellation::CancellationCtx}; -use contracts::{NodePools, NominatorWrapper, TonWallet, contract_provider}; +use contracts::{ + NodePools, NominatorWrapper, TonWallet, contract_provider, + ton_core_nominator::messages as tc_messages, +}; use std::{ collections::{HashMap, HashSet}, sync::Arc, @@ -94,6 +97,9 @@ impl ContractsMonitor { if !self.ensure_wallet_balances(&mut seqno).await? { return Ok(()); } + if !self.ensure_pool_validator_sets_updated(&mut seqno).await? { + return Ok(()); + } tracing::info!(target: "contracts", "all contracts are ready"); Ok(()) } @@ -328,6 +334,9 @@ impl ContractsMonitor { } /// Step 4: Top up active wallets whose balance is below the minimum threshold. + /// + /// Step 5 (`ensure_pool_validator_sets_updated`) depends on the pools being + /// deployed and wallets funded, so this step runs first. async fn ensure_wallet_balances(&self, seqno: &mut i64) -> anyhow::Result { let mut all_topped_up = true; let mut processed_wallets = HashSet::new(); @@ -402,6 +411,75 @@ impl ContractsMonitor { } Ok(all_topped_up) } + + /// Step 5: Send `update_validator_set` (opcode 6) to TonCore pool controllers + /// that are in staking state (state == 2) but haven't detected enough validator + /// set changes for recovery. + /// + /// The TonCore pool contract tracks the on-chain validator set hash + /// (config param 34) and increments an internal counter each time it changes. + /// Recovery is only allowed once `validator_set_changes_count >= 2`. + /// Without this step, the counter stays at 0 and recovery is permanently blocked. + async fn ensure_pool_validator_sets_updated(&self, seqno: &mut i64) -> anyhow::Result { + let mut all_updated = true; + tracing::info!( + target: "contracts", + "ensure_pool_validator_sets_updated: checking {} nodes", + self.pools.len() + ); + for (node_id, node_pools) in self.pools.iter() { + for pool in node_pools.all() { + let pool_data = match pool.get_pool_data().await { + Ok(d) => d, + Err(e) => { + tracing::warn!( + target: "contracts", + "[{}] get_pool_data error (skipping update_validator_set): pool={} {:#}", + node_id, pool.address(), e + ); + continue; + } + }; + + tracing::info!( + target: "contracts", + "[{}] pool={} state={} vsc_count={}", + node_id, pool.address(), pool_data.state, pool_data.validator_set_changes_count + ); + + if pool_data.state != 2 || pool_data.validator_set_changes_count >= 2 { + continue; + } + + tracing::info!( + target: "contracts", + "[{}] update_validator_set: pool={}, state={}, vsc_count={}", + node_id, + pool.address(), + pool_data.state, + pool_data.validator_set_changes_count, + ); + + let body = tc_messages::update_validator_set(0)?; + let msg = self + .master_wallet + .build_message( + pool.address(), + WALLET_GAS, + body, + true, + Some(u32::try_from(*seqno)?), + None, + None, + ) + .await?; + self.broadcast(&msg).await?; + *seqno += 1; + all_updated = false; + } + } + Ok(all_updated) + } } #[cfg(test)] From 4ed3c75f2e95a318c266914f7c1bb9440fdfb807 Mon Sep 17 00:00:00 2001 From: mrnkslv Date: Fri, 3 Apr 2026 18:27:56 +0300 Subject: [PATCH 14/18] feat(documentation): readme updated --- src/node-control/README.md | 12 ++++++++++-- .../contracts/src/nominator/single_nominator.rs | 2 +- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/src/node-control/README.md b/src/node-control/README.md index bda81c0..b5e14f7 100644 --- a/src/node-control/README.md +++ b/src/node-control/README.md @@ -1338,7 +1338,7 @@ Validator wallets for election submissions and TON transfers: #### `pools` -Nominator pool configurations. Two pool types are supported: +Nominator pool configurations. Three pool types are supported: **Single Nominator Pool (SNP):** @@ -1349,12 +1349,20 @@ Nominator pool configurations. Two pool types are supported: **TONCore Pool:** - `kind` — `"core"` -- `addresses` — two addresses: validator wallet (`[0]`) and pool contract (`[1]`, must match the address derived from the parameters below) +- `address` — optional deployed pool contract address. When omitted, the address is derived from the validator wallet and deploy parameters (see `resolve_deploy_pool_params` / `resolve_toncore_pool` in the contracts module). If set, it must match the derived address. - `validator_share` — validator reward share (basis points; stored as `u16` on-chain) - `max_nominators` — optional; if omitted, defaults from the contracts module are used (40 nominators) - `min_validator_stake` — optional (nanotons); if omitted, defaults from the contracts module are used (100,000 TON) - `min_nominator_stake` — optional (nanotons); if omitted, defaults from the contracts module are used (10,000 TON) +**TONCore Router (two pools):** + +- `kind` — `"core_router"` +- `addresses` — optional pair `[pool_0, pool_1]`, each entry an optional string (pool contract address). Serialized as `addresses: [ "", "" ]` when both are known, or with `null` entries for slots not yet deployed. When the whole field is omitted or entries are `null`, addresses are derived deterministically for each controller (see `resolve_toncore_router`). Each explicit address must match the corresponding derived address. +- `validator_share` — validator reward share (basis points; same meaning as TONCore) +- `max_nominators`, `min_validator_stake`, `min_nominator_stake` — same optional semantics as TONCore. Deploy resolution uses `min_validator_stake` for pool index `0` and `min_validator_stake + 1` (nanoton) for pool index `1` so the two contracts differ. +- **Routing:** the election runner and pool helpers treat this as two separate TONCore controller contracts. When staking or recovering, the implementation selects a **free** pool: the first controller for which `get_pool_data()` reports `state == 0` (idle / ready). If both are busy (`state != 0`), operations fail until one round finishes and a controller returns to idle. This allows overlapping validation rounds using two pools under one binding. + #### `bindings` Bindings link nodes to wallets and pools for elections participation: diff --git a/src/node-control/contracts/src/nominator/single_nominator.rs b/src/node-control/contracts/src/nominator/single_nominator.rs index 42e8757..67a97ea 100644 --- a/src/node-control/contracts/src/nominator/single_nominator.rs +++ b/src/node-control/contracts/src/nominator/single_nominator.rs @@ -141,7 +141,7 @@ impl NominatorWrapper for NominatorWrapperImpl { stack.i64(13).context("parse validator_set_changes_count")? as i32; let validator_set_change_time = stack.i64(14).context("parse validator_set_change_time")? as u64; - let stake_held_for = stack.i64(11).context("parse stake_held_for")? as u64; + let stake_held_for = stack.i64(15).context("parse stake_held_for")? as u64; Ok(PoolData { state, From 3cbbc81c5cc34b151f4f5728e8ce70b7f3fb7197 Mon Sep 17 00:00:00 2001 From: mrnkslv Date: Fri, 3 Apr 2026 19:10:14 +0300 Subject: [PATCH 15/18] fix: return correct index for snp --- src/node-control/service/src/contracts/contracts_task.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/node-control/service/src/contracts/contracts_task.rs b/src/node-control/service/src/contracts/contracts_task.rs index 0edc788..51f040c 100644 --- a/src/node-control/service/src/contracts/contracts_task.rs +++ b/src/node-control/service/src/contracts/contracts_task.rs @@ -419,7 +419,8 @@ impl ContractsMonitor { /// The TonCore pool contract tracks the on-chain validator set hash /// (config param 34) and increments an internal counter each time it changes. /// Recovery is only allowed once `validator_set_changes_count >= 2`. - /// Without this step, the counter stays at 0 and recovery is permanently blocked. + /// Unlike the SNP contract, the TonCore pool does not update this counter + /// automatically — opcode 6 must be sent explicitly (by anyone). async fn ensure_pool_validator_sets_updated(&self, seqno: &mut i64) -> anyhow::Result { let mut all_updated = true; tracing::info!( From 17a4ce9b4a21e17fcaf63e6b87eb85dacf1aae17 Mon Sep 17 00:00:00 2001 From: mrnkslv Date: Tue, 7 Apr 2026 08:08:16 +0300 Subject: [PATCH 16/18] feat: split50 for ton_core_adaptor --- src/node-control/elections/src/runner.rs | 22 +++++++++++++++++++ .../elections/src/runner_tests.rs | 3 ++- 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/src/node-control/elections/src/runner.rs b/src/node-control/elections/src/runner.rs index 56b2d29..0949064 100644 --- a/src/node-control/elections/src/runner.rs +++ b/src/node-control/elections/src/runner.rs @@ -891,6 +891,28 @@ impl ElectionRunner { min_stake as f64 / 1_000_000_000.0 ); } + + // TONCore router: two pools alternate rounds — reserve is the *other* pool. + // Split50 would halve (frozen + pool + elections) and under-stake the active pool; + // instead stake the full liquid balance of the selected pool (still >= min_stake). + if matches!( + (&node.pools, &node.stake_policy), + (Some(NodePools::Router(_)), StakePolicy::Split50) + ) { + if pool_free_balance < min_stake { + anyhow::bail!( + "not enough funds: pool_available={} TON, min_stake={} TON", + pool_free_balance as f64 / 1_000_000_000.0, + min_stake as f64 / 1_000_000_000.0 + ); + } + tracing::info!( + "node [{}] split50 on router: use full active pool balance (not half of total_balance)", + node_id + ); + return Ok(pool_free_balance); + } + node.stake_policy.calculate_stake(min_stake, total_balance) } diff --git a/src/node-control/elections/src/runner_tests.rs b/src/node-control/elections/src/runner_tests.rs index 9a258a5..357fb2d 100644 --- a/src/node-control/elections/src/runner_tests.rs +++ b/src/node-control/elections/src/runner_tests.rs @@ -2068,7 +2068,8 @@ async fn test_router_selects_free_pool() { setup_default_provider_without_account(&mut harness.provider_mock, WALLET_BALANCE); - let expected_stake = (POOL_BALANCE - MIN_NANOTON_FOR_STORAGE) / 2; + // Router + split50: stake entire liquid balance of the active (free) pool. + let expected_stake = POOL_BALANCE - MIN_NANOTON_FOR_STORAGE; harness.wallet_mock.expect_message().returning(|_dest, _value, _payload| Ok(dummy_cell())); let mut runner = harness.build(node_id); From 7c7e4ed86c98ff7d202d134652e3aa764ee6741d Mon Sep 17 00:00:00 2001 From: mrnkslv Date: Tue, 7 Apr 2026 14:00:57 +0300 Subject: [PATCH 17/18] fix: staking amount for ton core adaptor --- .../commands/src/commands/nodectl/deploy_cmd.rs | 14 ++++++++++++-- src/node-control/elections/src/runner.rs | 14 +++++++------- src/node-control/elections/src/runner_tests.rs | 4 ++++ 3 files changed, 23 insertions(+), 9 deletions(-) diff --git a/src/node-control/commands/src/commands/nodectl/deploy_cmd.rs b/src/node-control/commands/src/commands/nodectl/deploy_cmd.rs index 36ce83c..3397883 100644 --- a/src/node-control/commands/src/commands/nodectl/deploy_cmd.rs +++ b/src/node-control/commands/src/commands/nodectl/deploy_cmd.rs @@ -16,7 +16,10 @@ use common::{ task_cancellation::CancellationCtx, ton_utils::{nanotons_to_tons_f64, tons_f64_to_nanotons}, }; -use contracts::{NominatorWrapperImpl, TonWallet, resolve_toncore_pool, resolve_toncore_router}; +use contracts::{ + NominatorWrapperImpl, TonWallet, resolve_toncore_pool, resolve_toncore_router, + ton_core_nominator::messages as pool_messages, +}; use std::{cell::RefCell, collections::HashMap, path::Path, rc::Rc, sync::Arc}; use ton_block::{Cell, MsgAddressInt, write_boc}; use ton_http_api_client::v2::data_models::AccountState; @@ -385,6 +388,13 @@ impl DeployPoolCmd { let amount_to_send_nano = tons_f64_to_nanotons(self.amount); + let deploy_body = match pool_cfg_opt { + Some(PoolConfig::TONCore { .. }) | Some(PoolConfig::TONCoreRouter { .. }) => { + pool_messages::deposit_validator(0).map_err(set_err)? + } + _ => Cell::default(), + }; + let wallet = make_wallet(rpc_client.clone(), wallet_cfg, secret, &self.node) .await .map_err(set_err)?; @@ -438,7 +448,7 @@ impl DeployPoolCmd { .build_message( pool_address.clone(), amount_to_send_nano, - Cell::default(), + deploy_body.clone(), false, seqno, None, diff --git a/src/node-control/elections/src/runner.rs b/src/node-control/elections/src/runner.rs index 31e16e2..8ac0305 100644 --- a/src/node-control/elections/src/runner.rs +++ b/src/node-control/elections/src/runner.rs @@ -52,13 +52,13 @@ 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 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; +/// funds for staking. +/// +/// TONCore nominator-pool contract reserves 10 TON (`MIN_TONS_FOR_STORAGE` in pool.fc) +/// and checks `throw_unless(82, value <= balance - MIN_TONS_FOR_STORAGE())`. +/// The SNP contract uses only 1 TON, but over-reserving by 9 TON is negligible +/// compared to typical validator stakes (100k+ TON). +const MIN_NANOTON_FOR_STORAGE: u64 = 10_000_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 1137d6b..3bbc034 100644 --- a/src/node-control/elections/src/runner_tests.rs +++ b/src/node-control/elections/src/runner_tests.rs @@ -531,6 +531,8 @@ fn setup_default_provider_without_account( provider.expect_export_public_key().returning(|_key_id| Ok(PUB_KEY.to_vec())); provider.expect_sign().returning(|_key, _data| Ok(SIGNATURE.to_vec())); provider.expect_send_boc().returning(|_boc| Ok(())); + provider.expect_config_param_16().returning(|| Ok(default_cfg16())); + provider.expect_config_param_17().returning(|| Ok(default_cfg17())); provider.expect_shutdown().returning(|| Ok(())); } @@ -2687,6 +2689,8 @@ async fn test_router_recover_stake_both_pools() { .expect_account() .returning(move |_address| 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(())); let mut runner = harness.build(node_id); From 75474218110ea3ba31e1eda19ebd949f7e339a88 Mon Sep 17 00:00:00 2001 From: mrnkslv Date: Tue, 7 Apr 2026 16:14:39 +0300 Subject: [PATCH 18/18] fix: update value amount for update validator set message --- .../service/src/contracts/contracts_task.rs | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/src/node-control/service/src/contracts/contracts_task.rs b/src/node-control/service/src/contracts/contracts_task.rs index 51f040c..7c990df 100644 --- a/src/node-control/service/src/contracts/contracts_task.rs +++ b/src/node-control/service/src/contracts/contracts_task.rs @@ -26,8 +26,12 @@ use ton_http_api_client::v2::{client_json_rpc::ClientJsonRpc, data_models::Accou const DEPLOY_AMOUNT: u64 = 1_100_000_000; // 1.1 TON /// Minimal required balance for a wallet before it will be topped up. const MIN_WALLET_BALANCE: u64 = 5_000_000_000; // 5 TON -/// Gas cost for sending message from a wallet. +/// Gas cost for sending a simple message from a wallet (deploy, top-up). const WALLET_GAS: u64 = 100_000_000; // 0.1 TON +/// Gas for masterchain pool operations (update_validator_set, etc.) +/// Masterchain gas prices are ~25x basechain; 0.1 TON is not enough +/// for load_data + get_current_validator_set + cell_hash + save_data. +const POOL_OP_GAS: u64 = 500_000_000; // 0.5 TON /// Amount to top up a wallet if its balance is below the minimum threshold. const TOP_UP_AMOUNT: u64 = 10_000_000_000; // 10 TON @@ -444,7 +448,7 @@ impl ContractsMonitor { tracing::info!( target: "contracts", - "[{}] pool={} state={} vsc_count={}", + "[{}] pool={} state={} ={}", node_id, pool.address(), pool_data.state, pool_data.validator_set_changes_count ); @@ -466,7 +470,7 @@ impl ContractsMonitor { .master_wallet .build_message( pool.address(), - WALLET_GAS, + POOL_OP_GAS, body, true, Some(u32::try_from(*seqno)?),