diff --git a/src/node-control/README.md b/src/node-control/README.md index 364d127..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,8 +1349,19 @@ Nominator pool configurations. Two pool types are supported: **TONCore Pool:** - `kind` — `"core"` -- `addresses` — array of exactly 2 pool addresses -- `validator_share` — validator share percentage +- `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` 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 3e50df8..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 @@ -9,20 +9,37 @@ 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_pool, resolve_toncore_router, ton_core_nominator::messages as pool_messages, }; -use contracts::{NOMINATOR_POOL_WORKCHAIN, NominatorWrapperImpl}; 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)] +enum PoolAddKind { + /// Single Nominator Pool (`kind: "snp"` in config) + #[default] + Snp, + /// Nominator Pool (`kind: "core"` / `PoolConfig::TONCore`) + Core, + /// Two-pool router (`kind: "core_router"` / `PoolConfig::TONCoreRouter`) + Router, +} + #[derive(clap::Args, Clone)] #[command(about = "Manage pools in the configuration")] pub struct PoolCmd { @@ -38,6 +55,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)] @@ -45,18 +66,41 @@ 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, core, or router")] + kind: PoolAddKind, #[arg( short = 'a', long = "address", - help = "Pool contract address, raw or base64url (if already deployed)" + 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", - help = "Owner address, raw or base64url (for deployment/verification)" + help = "SNP: owner address, raw or base64url (for deployment/verification)" )] owner: Option, + #[arg( + long = "validator-share", + help = "Core/Router: validator reward share (basis points, 0–65535; e.g. 5000 ≈ 50%)" + )] + validator_share: Option, + #[arg(long = "max-nominators", help = "Core/Router: max nominators (default: 40)")] + max_nominators: Option, + #[arg( + long = "min-validator-stake-nano", + help = "Core/Router: min validator stake in nanotons (embedded at deploy)" + )] + min_validator_stake_nano: Option, + #[arg( + long = "min-nominator-stake-nano", + help = "Core/Router: min nominator stake in nanotons (embedded at deploy)" + )] + min_nominator_stake_nano: Option, } #[derive(clap::Args, Clone)] @@ -73,30 +117,42 @@ 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, + #[arg(long = "pool-index", default_value_t = 0, help = "Router: pool index (0 or 1)")] + pool_index: usize, +} + +#[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, + #[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) -> 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, } } } 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 +162,133 @@ 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 share = self + .validator_share + .ok_or_else(|| anyhow::anyhow!("For core: --validator-share is required"))?; + + let normalized_address = self + .address + .as_deref() + .map(|a| normalize_ton_address(a, "address")) + .transpose()?; + + 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 validator_share={}, address={}, deploy_params: max_nominators={}, min_validator_stake={}, min_nominator_stake={}", + share, + normalized_address.as_deref().unwrap_or(""), + mx, + mv, + mn + ); + + ( + PoolConfig::TONCore { + validator_share: share, + address: normalized_address, + max_nominators: self.max_nominators, + min_validator_stake: self.min_validator_stake_nano, + min_nominator_stake: self.min_nominator_stake_nano, + }, + 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); 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(()) } @@ -134,7 +304,9 @@ struct PoolView { #[serde(skip_serializing_if = "Option::is_none")] addresses: Option>, #[serde(skip_serializing_if = "Option::is_none")] - validator_share: Option, + balances: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + validator_share: Option, } impl PoolLsCmd { @@ -222,17 +394,89 @@ async fn collect_pool_views( address: addr_result.ok(), owner: display_owner, addresses: None, + balances: None, validator_share: None, }); } - PoolConfig::TONCore { addresses, validator_share } => { + 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: balance_result, + address: resolved_addr, + owner: None, + addresses: None, + balances: None, + validator_share: Some(*validator_share), + }); + } + PoolConfig::TONCoreRouter { validator_share, addresses, .. } => { + 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: Some(addresses.to_vec()), + addresses: resolved_addrs, + balances: pool_balances, validator_share: Some(*validator_share), }); } @@ -274,13 +518,24 @@ fn print_pools_table(views: &[PoolView]) { ); } "Core" => { - let addrs = v.addresses.as_deref().map(|a| a.join(", ")).unwrap_or_default(); - let share = v.validator_share.map(|s| s.to_string()).unwrap_or_default(); + let display_addr = v.address.as_deref().unwrap_or(""); + let display_balance = + v.balance.as_deref().map(|s| s.white()).unwrap_or_else(|| "-".red()); println!( - " {:<15} {:<6} {:<14} {:<50} share={}", - v.name, "Core", "-", addrs, share, + " {:<15} {:<6} {:<14} {:<50} {}", + v.name, "Core", display_balance, display_addr, "-", ); } + "Router" => { + let addrs = v + .addresses + .as_deref() + .map(|a| a.join(", ")) + .unwrap_or_else(|| "".into()); + 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,); + } _ => {} } } @@ -369,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() { @@ -413,6 +784,245 @@ 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, + max_nominators, + min_validator_stake, + min_nominator_stake, + } => { + let resolved = resolve_toncore_pool( + wallet_address, + *validator_share, + address.as_deref(), + max_nominators.as_ref().copied(), + min_validator_stake.as_ref().copied(), + min_nominator_stake.as_ref().copied(), + )?; + 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"); + } + } +} + +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, self.pool_index)?; + + 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, self.pool_index)?; + + 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 2d57bc2..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, + 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,47 @@ 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 { .. } if pool_index != 0 => { + anyhow::bail!( + "--pool-index is only valid for Router pools (TONCore has a single pool)" + ); + } + PoolConfig::TONCore { + validator_share, + address, + max_nominators, + min_validator_stake, + min_nominator_stake, + } => { + let resolved = resolve_toncore_pool( + validator_addr, + *validator_share, + address.as_deref(), + max_nominators.as_ref().copied(), + min_validator_stake.as_ref().copied(), + min_nominator_stake.as_ref().copied(), + )?; + 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 a1efe21..36ce83c 100644 --- a/src/node-control/commands/src/commands/nodectl/deploy_cmd.rs +++ b/src/node-control/commands/src/commands/nodectl/deploy_cmd.rs @@ -12,10 +12,11 @@ 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 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; @@ -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")] @@ -290,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 { @@ -306,84 +317,151 @@ 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 deploy_targets: Vec<(MsgAddressInt, ton_block::StateInit)> = match pool_cfg_opt { + Some(PoolConfig::TONCore { + validator_share, + address, + max_nominators, + min_validator_stake, + min_nominator_stake, + }) => { + let resolved = resolve_toncore_pool( + &wallet_address, + *validator_share, + address.as_deref(), + max_nominators.as_ref().copied(), + min_validator_stake.as_ref().copied(), + min_nominator_stake.as_ref().copied(), + ) + .map_err(set_err)?; + 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)?; - res.borrow_mut().address = pool_address.to_string(); + resolved.into_iter().map(|r| (r.address, r.state_init)).collect() + } + 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, state_init) = + NominatorWrapperImpl::calculate_address_with_state_init( + -1, + owner, + &wallet_address, + ) + .map_err(set_err)?; + vec![(pool_address, state_init)] + } + }; - 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))); - } + 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 cancellation_ctx.is_cancelled() { - return Err(set_err(anyhow::anyhow!("Task cancelled"))); - } + 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 self.verbose { - println!( - "Deploy Single Nominator Pool: owner={}, wallet={} ...", - self.owner, wallet_address - ); - } + if cancellation_ctx.is_cancelled() { + return Err(set_err(anyhow::anyhow!("Task cancelled"))); + } - if wallet_info.account_state != AccountState::Active { - res.borrow_mut().error = - Some(format!("Wallet '{}' state {}", wallet_address, wallet_info.account_state)); - return Ok(()); - } + 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) + ))); + } - 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) - ))); - } + if self.verbose { + println!( + "Deploy pool [{}/{}]: address={} ...", + i + 1, + deploy_targets.len(), + pool_address + ); + } - // Deploy - let wallet = make_wallet(rpc_client.clone(), wallet_cfg, secret, &self.node) + 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(NominatorWrapperImpl::build_state_init(&self.owner, &wallet_address)?), - ) - .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 4fe7c2e..262e350 100644 --- a/src/node-control/common/src/app_config.rs +++ b/src/node-control/common/src/app_config.rs @@ -338,7 +338,44 @@ pub enum PoolConfig { owner: Option, }, #[serde(rename = "core")] - TONCore { addresses: [String; 2], validator_share: u64 }, + TONCore { + validator_share: u16, + /// Pool contract address. `None` = not deployed yet (will be derived from validator wallet). + #[serde(default)] + #[serde(skip_serializing_if = "Option::is_none")] + 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")] + 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, + }, + /// 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)] @@ -812,26 +849,121 @@ mod tests { } #[test] - fn test_pool_config_serde_core() { - let addr1 = ADDR; - let addr2 = OWNER; + fn test_pool_config_serde_core_no_address() { 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, + address: 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"); assert_eq!(json["validator_share"], 50); + assert!(json.get("address").is_none()); + } + + #[test] + fn test_pool_config_serde_core_with_address() { + let addr1 = ADDR; + let value = serde_json::json!({ + "kind": "core", + "validator_share": 100, + "address": 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::TONCore { + validator_share: 100, + 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), + } + ); + } + + #[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] diff --git a/src/node-control/contracts/src/lib.rs b/src/node-control/contracts/src/lib.rs index 99dd503..7949d6c 100644 --- a/src/node-control/contracts/src/lib.rs +++ b/src/node-control/contracts/src/lib.rs @@ -12,13 +12,18 @@ pub mod nominator; pub mod provider; pub mod smart_contract; mod stack_utils; +pub mod ton_core_nominator; pub mod wallet; 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_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 4a851f6..67a97ea 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,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 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 = { @@ -130,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, @@ -142,7 +153,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..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 @@ -42,7 +43,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)] @@ -68,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 new file mode 100644 index 0000000..285e101 --- /dev/null +++ b/src/node-control/contracts/src/ton_core_nominator.rs @@ -0,0 +1,17 @@ +/* + * 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, ResolvedTonCorePool, resolve_deploy_pool_params, + resolve_toncore_pool, resolve_toncore_router, +}; 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..096b39b --- /dev/null +++ b/src/node-control/contracts/src/ton_core_nominator/messages.rs @@ -0,0 +1,212 @@ +/* + * 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); + } +} 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 new file mode 100644 index 0000000..6232db4 --- /dev/null +++ b/src/node-control/contracts/src/ton_core_nominator/ton_core_nominator.rs @@ -0,0 +1,459 @@ +/* + * 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::{ + ContractProvider, SmartContract, + nominator::{NominatorRoles, NominatorWrapper, PoolConfig, PoolData}, +}; +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; + +/// 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), + ) +} + +/// 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 address: MsgAddressInt, + pub state_init: StateInit, +} + +/// Validate and resolve the pool address from TONCore config fields. +/// +/// 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, + 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 (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::() + .context(format!("invalid TONCore pool address: {addr}"))?; + anyhow::ensure!( + explicit == address, + "TONCore pool address ({explicit}) does not match derived address ({address})" + ); + } + + Ok(ResolvedTonCorePool { + reward_share: validator_share, + max_nominators: max_n, + min_validator_stake: min_v, + min_nominator_stake: min_n, + address, + state_init, + }) +} + +/// 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: +/// +/// 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 } + } + + /// 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`. + 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 (pool_addr, state_init) = Self::calculate_address_with_state_init( + validator_address, + validator_reward_share, + max_nominators_count, + min_validator_stake, + min_nominator_stake, + )?; + 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 { + 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, + max_nominators_count, + min_validator_stake, + min_nominator_stake, + )?; + let addr = Self::address_from_state_init(&state_init)?; + Ok((addr, 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; + 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, + nominator_stake_threshold: 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); + } +} 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..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. - pool: Option>, - /// Address to which to send commands: stake & recover. - /// It can be an elector address or a nominator pool address. + /// 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).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 +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); @@ -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 = @@ -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,12 +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?; - // 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 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", @@ -649,11 +690,8 @@ 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 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?; @@ -721,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<()> { @@ -799,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; @@ -817,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", @@ -952,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)| { @@ -1096,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( @@ -1130,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)) @@ -1139,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 cc219b8..51f040c 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::{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, @@ -44,7 +47,7 @@ pub(crate) async fn run( struct ContractsMonitor { master_wallet: Arc, - pools: Arc>>, + pools: Arc>, wallets: Arc>>, rpc_client: Arc, _store: 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(()) } @@ -254,18 +260,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) } @@ -326,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(); @@ -400,6 +411,76 @@ 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`. + /// 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!( + 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)] @@ -407,7 +488,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 +663,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 45be384..704bc2e 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, + 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}, @@ -49,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. @@ -75,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>; @@ -324,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 { @@ -336,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().to_string() - ); - 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)) @@ -390,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) } @@ -467,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) { @@ -512,8 +511,58 @@ 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, + address, + max_nominators, + min_validator_stake, + min_nominator_stake, + } => { + let resolved = resolve_toncore_pool( + validator_addr, + *validator_share, + address.as_deref(), + max_nominators.as_ref().copied(), + min_validator_stake.as_ref().copied(), + min_nominator_stake.as_ref().copied(), + )?; + 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])) } - _ => anyhow::bail!("unsupported pool kind"), } } 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!()