iuna

iuna

iuna - experimental mainnet-candidate protocol
git clone https://getiuna.org/git/iuna.git
Log | Files | Refs | README | LICENSE

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 }