status.rs (15434B)
1 use anyhow::Result; 2 3 use crate::{ 4 adapters::config_store::{MAX_POW_MINING_WORKERS, clamp_pow_mining_workers}, 5 domain::{ 6 AddressNetwork, Amount, MINE_FINALIZER_FEE, Transaction, TransactionV2, 7 VDF_TARGET_BLOCK_MS, transaction_v2_is_active, 8 }, 9 }; 10 11 use super::{ 12 FundedWalletAddressStatus, LaunchProfileStatus, MiningStatus, NETWORK_ID, 13 NetworkMigrationStatus, NodeCore, NodeStatus, QuantumMigrationStatus, StratumStatus, 14 helpers::{transaction_input_total_from_outputs, transaction_output_total_for_address}, 15 now_ms, 16 }; 17 18 impl NodeCore { 19 pub fn status(&self) -> NodeStatus { 20 let chain = self.ledger.light_status(); 21 let launch_profile = self.ledger.launch_profile(); 22 let current_leader = self.ledger.expected_leader_for_next_block(); 23 let wallet_is_current_leader = current_leader 24 .as_deref() 25 .is_none_or(|leader| leader == self.wallet.address()); 26 let legacy_address = self.wallet.address(); 27 let legacy_balance = self.ledger.balance_of(legacy_address); 28 let legacy_utxos = self.ledger.utxos_for_address(legacy_address).len(); 29 let hybrid_address = self.wallet.unlocked().ok().map(|wallet| { 30 wallet.hybrid_address(AddressNetwork::from_profile_id( 31 &self.ledger.launch_profile().profile_id, 32 )) 33 }); 34 let hybrid_addresses = self 35 .wallet 36 .unlocked() 37 .ok() 38 .and_then(|wallet| { 39 self.ledger 40 .wallet_owned_hybrid_encoded_addresses(wallet) 41 .ok() 42 }) 43 .unwrap_or_default(); 44 let hybrid_balance = hybrid_addresses.iter().fold(0_u64, |total, address| { 45 total.saturating_add(self.ledger.balance_of(address)) 46 }); 47 let pending_spent = self.wallet_pending_spent_outpoints(); 48 let mut owned_addresses = vec![legacy_address.to_string()]; 49 owned_addresses.extend(hybrid_addresses); 50 owned_addresses.sort(); 51 owned_addresses.dedup(); 52 let mut funded_wallet_addresses = owned_addresses 53 .into_iter() 54 .filter_map(|address| { 55 let utxos = self.ledger.utxos_for_address(&address); 56 if utxos.is_empty() { 57 return None; 58 } 59 Some(FundedWalletAddressStatus { 60 legacy: address == legacy_address, 61 address, 62 balance: utxos.iter().fold(0_u64, |total, (_, output)| { 63 total.saturating_add(output.amount) 64 }), 65 utxos: utxos.len(), 66 spendable_utxos: utxos 67 .iter() 68 .filter(|(outpoint, _)| !pending_spent.contains(outpoint)) 69 .count(), 70 }) 71 }) 72 .collect::<Vec<_>>(); 73 funded_wallet_addresses.sort_by(|left, right| { 74 right 75 .balance 76 .cmp(&left.balance) 77 .then_with(|| left.address.cmp(&right.address)) 78 }); 79 let address_network = 80 AddressNetwork::from_profile_id(&self.ledger.launch_profile().profile_id); 81 let transaction_v2_domain = self.ledger.transaction_v2_domain().ok(); 82 let pending_transaction_id = hybrid_address.as_deref().and_then(|address| { 83 self.ledger.pending_v2().iter().find_map(|transaction| { 84 let TransactionV2::Migration { outputs, .. } = transaction else { 85 return None; 86 }; 87 let belongs_to_wallet = outputs.iter().any(|output| { 88 crate::domain::encode_versioned_address(output.address, address_network) 89 .is_ok_and(|output_address| output_address == address) 90 }); 91 belongs_to_wallet.then(|| { 92 transaction_v2_domain 93 .as_ref() 94 .and_then(|domain| transaction.transaction_id(domain).ok()) 95 .map(crate::domain::hex_encode) 96 })? 97 }) 98 }); 99 100 NodeStatus { 101 app_version: env!("CARGO_PKG_VERSION").to_string(), 102 wallet_address: self.wallet.address().to_string(), 103 wallet_receive_address: self.wallet_receive_address().unwrap_or_default(), 104 funded_wallet_addresses, 105 wallet_balance: self.wallet_projected_balance(), 106 wallet_locked: self.wallet.is_locked(), 107 quantum_migration: QuantumMigrationStatus { 108 active: transaction_v2_is_active(self.ledger.height()), 109 hybrid_address, 110 legacy_balance, 111 hybrid_balance, 112 legacy_utxos, 113 migration_pending: pending_transaction_id.is_some(), 114 pending_transaction_id, 115 }, 116 launch_profile: LaunchProfileStatus { 117 profile_id: launch_profile.profile_id.clone(), 118 profile_hash: chain.launch_profile_hash.clone(), 119 ticket_maturity_delay_heights: launch_profile.ticket_maturity_delay_heights, 120 ticket_expiry_window_heights: launch_profile.ticket_expiry_window_heights, 121 mine_difficulty_bits: launch_profile.mine_difficulty_bits, 122 burn_lineage_maturity_heights: launch_profile.burn_lineage_maturity_heights, 123 }, 124 mining: MiningStatus { 125 automatic: self.automatic_mining_enabled, 126 pow_mining_enabled: self.pow_mining_enabled, 127 pow_mining_workers: self.pow_mining_workers, 128 max_pow_mining_workers: MAX_POW_MINING_WORKERS, 129 burn_per_block: self.burn_per_block, 130 automatic_burn_fee: self.burn_fee, 131 automatic_pow_mine_fee: MINE_FINALIZER_FEE, 132 last_auto_finalization_status: self.last_auto_finalization_status.clone(), 133 last_auto_pow_mine_anchor: self.last_auto_pow_mine_anchor.clone(), 134 last_auto_pow_mine_status: if self.pow_mining_enabled && !self.has_real_chain() { 135 Some("waiting for a real chain before PoW mining can start".to_string()) 136 } else { 137 self.last_auto_pow_mine_status.clone() 138 }, 139 vdf_rounds: self.ledger.vdf_rounds(), 140 vdf_target_block_ms: VDF_TARGET_BLOCK_MS, 141 current_leader, 142 wallet_is_current_leader, 143 last_auto_burn_height: self.last_auto_burn_height, 144 recovery_vdf_top_rank_percent: self.recovery_vdf_top_rank_percent, 145 }, 146 stratum: StratumStatus { 147 enabled: false, 148 listen_addr: None, 149 }, 150 chain, 151 network_migration: NetworkMigrationStatus { 152 required: self.network_migration_from().is_some(), 153 from_network: self.network_migration_from().map(str::to_string), 154 to_network: NETWORK_ID.to_string(), 155 }, 156 } 157 } 158 159 fn wallet_projected_balance(&self) -> Amount { 160 let address = self.wallet.address(); 161 let mut balance = self.ledger.balance_of(address); 162 if let Ok(wallet) = self.wallet.unlocked() { 163 if let Ok(addresses) = self.ledger.wallet_owned_hybrid_encoded_addresses(wallet) { 164 for hybrid_address in addresses { 165 balance = balance.saturating_add(self.ledger.balance_of(&hybrid_address)); 166 } 167 } 168 } 169 let confirmed_outputs = self 170 .ledger 171 .utxos_for_address(address) 172 .into_iter() 173 .map(|(outpoint, output)| (outpoint, output.amount)) 174 .collect::<std::collections::BTreeMap<_, _>>(); 175 if let Some((height, burn)) = &self.local_block_anchor_burn { 176 if *height == self.ledger.height() && !self.ledger.has_transaction(burn.signature()) { 177 let output_total = transaction_output_total_for_address(burn, address); 178 let input_total = 179 transaction_input_total_from_outputs(burn, address, &confirmed_outputs); 180 balance = balance 181 .saturating_sub(input_total) 182 .saturating_add(output_total); 183 } 184 } 185 186 balance 187 } 188 189 pub fn set_burn_per_block(&mut self, amount: Amount) -> Result<Option<Transaction>> { 190 self.set_automatic_burn(amount, self.burn_fee) 191 } 192 193 pub fn set_automatic_burn( 194 &mut self, 195 amount: Amount, 196 fee: Amount, 197 ) -> Result<Option<Transaction>> { 198 self.set_automatic_burn_settings(amount > 0, amount, fee) 199 } 200 201 pub fn set_automatic_burn_settings( 202 &mut self, 203 enabled: bool, 204 amount: Amount, 205 fee: Amount, 206 ) -> Result<Option<Transaction>> { 207 let was_disabled = !self.automatic_mining_enabled || self.burn_per_block == 0; 208 self.automatic_mining_enabled = enabled; 209 self.burn_per_block = amount; 210 self.burn_fee = fee; 211 if was_disabled && enabled && amount > 0 { 212 self.last_auto_burn_height = None; 213 self.last_auto_anchor_burn_height = None; 214 } 215 self.prepare_automatic_burn(now_ms()) 216 } 217 218 pub fn automatic_mining_enabled(&self) -> bool { 219 self.automatic_mining_enabled 220 } 221 222 pub fn set_pow_mining_enabled(&mut self, enabled: bool) { 223 self.pow_mining_enabled = enabled; 224 self.auto_pow_mine_cursor = None; 225 if !enabled { 226 self.last_auto_pow_mine_anchor = None; 227 self.last_auto_pow_mine_status = None; 228 } else { 229 self.last_auto_pow_mine_status = 230 Some("waiting for next automatic PoW mining tick".to_string()); 231 } 232 } 233 234 pub fn pow_mining_enabled(&self) -> bool { 235 self.pow_mining_enabled 236 } 237 238 pub fn set_pow_mining_workers(&mut self, workers: u8) { 239 let workers = clamp_pow_mining_workers(workers); 240 if self.pow_mining_workers != workers { 241 self.pow_mining_workers = workers; 242 self.auto_pow_mine_cursor = None; 243 if self.pow_mining_enabled { 244 self.last_auto_pow_mine_status = 245 Some("waiting for next automatic PoW mining tick".to_string()); 246 } 247 } 248 } 249 250 pub fn pow_mining_workers(&self) -> u8 { 251 self.pow_mining_workers 252 } 253 254 pub fn record_automatic_finalization_status(&mut self, message: impl Into<String>) { 255 self.last_auto_finalization_status = Some(message.into()); 256 } 257 258 pub fn set_recovery_vdf_top_rank_percent(&mut self, percent: u8) { 259 self.recovery_vdf_top_rank_percent = percent.min(100); 260 } 261 } 262 263 #[cfg(test)] 264 mod tests { 265 use std::collections::BTreeMap; 266 267 use crate::{ 268 app::{NodeConfig, NodeCore}, 269 domain::{LaunchProfile, Ledger, Wallet}, 270 }; 271 272 #[test] 273 fn status_reports_package_version() { 274 let wallet = Wallet::from_seed("status-version-wallet"); 275 let node = NodeCore::new(NodeConfig { 276 wallet, 277 genesis_allocations: BTreeMap::new(), 278 vdf_rounds: 1, 279 burn_per_block: 0, 280 burn_fee: 0, 281 pow_mining_workers: 1, 282 recovery_vdf_top_rank_percent: 100, 283 }); 284 285 let status = node.status(); 286 assert_eq!(status.app_version, env!("CARGO_PKG_VERSION")); 287 assert!(status.wallet_receive_address.starts_with("iuna1q")); 288 } 289 290 #[test] 291 fn local_testnet_status_uses_a_distinct_receive_address_prefix() { 292 let wallet = Wallet::from_seed("status-testnet-wallet"); 293 let ledger = Ledger::new_with_genesis_burns_and_profile( 294 BTreeMap::new(), 295 Vec::new(), 296 1, 297 LaunchProfile::local_testnet(), 298 ) 299 .unwrap(); 300 let node = NodeCore::from_ledger(wallet, ledger, 0); 301 let status = node.status(); 302 303 assert!(status.wallet_receive_address.starts_with("tiuna1q")); 304 assert!( 305 node.normalize_user_address(&status.wallet_receive_address) 306 .is_ok() 307 ); 308 let wrong_network = status.wallet_receive_address.replacen("tiuna", "iuna", 1); 309 assert!(node.normalize_user_address(&wrong_network).is_err()); 310 311 let receive_address = status.wallet_receive_address; 312 let mut reset_node = node; 313 reset_node.reset_chain_to_setup_placeholder(); 314 assert_eq!( 315 reset_node.wallet_receive_address().unwrap(), 316 receive_address 317 ); 318 } 319 320 #[test] 321 fn status_does_not_panic_on_invalid_locked_wallet_metadata() { 322 let node = NodeCore::from_locked_wallet_address( 323 "corrupt-wallet-address", 324 Ledger::new(BTreeMap::new(), 1), 325 false, 326 0, 327 0, 328 ); 329 330 let status = node.status(); 331 332 assert_eq!(status.wallet_address, "corrupt-wallet-address"); 333 assert!(status.wallet_receive_address.is_empty()); 334 } 335 336 #[test] 337 fn status_omits_full_balance_map_for_ui_polling() { 338 let wallet = Wallet::from_seed("status-light-wallet"); 339 let wallet_address = wallet.address().to_string(); 340 let mut allocations = BTreeMap::new(); 341 allocations.insert(wallet_address.clone(), 10); 342 allocations.insert( 343 Wallet::from_seed("status-light-peer").address().to_string(), 344 5, 345 ); 346 let ledger = Ledger::new(allocations, 1); 347 let node = NodeCore::from_ledger(wallet, ledger, 0); 348 349 let status = node.status(); 350 351 assert_eq!(status.wallet_balance, 10); 352 assert_eq!(status.funded_wallet_addresses.len(), 1); 353 assert_eq!(status.funded_wallet_addresses[0].address, wallet_address); 354 assert_eq!(status.funded_wallet_addresses[0].balance, 10); 355 assert_eq!(status.funded_wallet_addresses[0].utxos, 1); 356 assert_eq!(status.funded_wallet_addresses[0].spendable_utxos, 1); 357 assert!(status.funded_wallet_addresses[0].legacy); 358 assert!(status.chain.balances.is_empty()); 359 } 360 361 #[test] 362 fn automatic_pow_status_reports_setup_placeholder_wait() { 363 let wallet = Wallet::from_seed("automatic-pow-setup-placeholder-wallet"); 364 let ledger = Ledger::new(BTreeMap::new(), 1); 365 let mut node = NodeCore::from_ledger(wallet, ledger, 0); 366 367 node.set_pow_mining_enabled(true); 368 369 assert_eq!( 370 node.status().mining.last_auto_pow_mine_status.as_deref(), 371 Some("waiting for a real chain before PoW mining can start") 372 ); 373 } 374 375 #[test] 376 fn status_exposes_required_network_migration() { 377 let wallet = Wallet::from_seed("network-migration-status-wallet"); 378 let ledger = Ledger::new(BTreeMap::new(), 1); 379 let mut node = NodeCore::from_ledger(wallet, ledger, 0); 380 381 node.require_network_migration("iuna-devnet-v5"); 382 let status = node.status(); 383 384 assert!(status.network_migration.required); 385 assert_eq!( 386 status.network_migration.from_network.as_deref(), 387 Some("iuna-devnet-v5") 388 ); 389 assert_eq!(status.network_migration.to_network, crate::app::NETWORK_ID); 390 } 391 }