iuna

iuna - experimental devnet protocol
git clone https://iuna.jhx.app/git/iuna.git
Log | Files | Refs | README | LICENSE

commit 9ab32fa995f6768b68c729a88b2ad7ba58d5cc0f
parent 7b998f3949f793b7201dfb9dca71337f476d4cab
Author: Joris Hartog <jorishartog@hotmail.com>
Date:   Tue, 11 Aug 2026 07:24:47 +0200

Improve UI polling performance

Diffstat:
Msrc/adapters/chain_store.rs | 24+++++++++++++++++-------
Msrc/adapters/http.rs | 19++++++++++---------
Msrc/adapters/http/api.rs | 55++++++++++++++++++++++++++++---------------------------
Msrc/adapters/http/metrics.rs | 14+++++++-------
Msrc/adapters/http/state.rs | 2+-
Msrc/adapters/http/tests.rs | 36++++++++++++++++++++++++++++++------
Msrc/adapters/http/types.rs | 14++++++++++++++
Msrc/adapters/http/ui.rs | 102+++++++++++++++++++++++++++++++++++++++++++++++++++++++------------------------
Msrc/app/automatic_mining.rs | 7+++----
Msrc/app/gossip.rs | 10++++------
Msrc/app/status.rs | 20+++++++++++++++++++-
Msrc/domain/ledger_queries.rs | 187+++++++++++++++++++++++++++++++++++++++++++++++++++----------------------------
Msrc/domain/tests.rs | 4++++
13 files changed, 330 insertions(+), 164 deletions(-)

diff --git a/src/adapters/chain_store.rs b/src/adapters/chain_store.rs @@ -14,7 +14,8 @@ use crate::domain::{ AGGREGATE_FINALIZER_FEE_ACTIVATION_HEIGHT, Amount, BLINDED_COMMITTER_FEE_BPS, BLINDED_REVEAL_BUNDLE_SIGNER_FEE_BPS, BlindedTransaction, Block, ChainSnapshot, Ledger, MINE_REWARD, OutPoint, REVEAL_COMMITTEE_SIZE, Transaction, TxInput, TxOutput, - blinded_reveal_finalizer_fee, hex_hash, revealed_blinded_transactions, + blinded_reveal_finalizer_fee, hex_hash, reveal_committee_slot_count, + revealed_blinded_transactions, }; mod compact; @@ -418,6 +419,15 @@ fn metrics_from_snapshot(snapshot: &ChainSnapshot) -> Result<Vec<BlockMetricRow> let mut active_blinded = BTreeMap::<String, BlindedTransaction>::new(); let mut metric_utxos = metric_genesis_utxos(snapshot); let mut metric_locked_blinded_inputs = BTreeMap::<String, Amount>::new(); + let reveal_bundle_slots_by_height = ledger + .burn_leader_ranks_for_blocks(snapshot.blocks.iter().map(|block| block.height)) + .map(|ranks_by_height| { + ranks_by_height + .into_iter() + .map(|(height, ranks)| (height, reveal_committee_slot_count(ranks.len()))) + .collect::<BTreeMap<_, _>>() + }) + .unwrap_or_default(); for block in &snapshot.blocks { let revealed_transactions = revealed.get(&block.height).cloned().unwrap_or_default(); @@ -462,9 +472,9 @@ fn metrics_from_snapshot(snapshot: &ChainSnapshot) -> Result<Vec<BlockMetricRow> &revealed.included_by, block, transaction.fee(), - ledger - .burn_leader_ranks_for_block(block.height) - .map(|ranks| ranks.len()) + reveal_bundle_slots_by_height + .get(&block.height) + .copied() .unwrap_or(REVEAL_COMMITTEE_SIZE), ); fees_amount = fees_amount @@ -472,9 +482,9 @@ fn metrics_from_snapshot(snapshot: &ChainSnapshot) -> Result<Vec<BlockMetricRow> .context("block metric fees overflow")?; let committer_fee = blinded_fee_share(transaction.fee(), BLINDED_COMMITTER_FEE_BPS); let included_reveal_bundle_count = block.included_reveal_bundle_count(); - let available_reveal_bundle_slots = ledger - .burn_leader_ranks_for_block(block.height) - .map(|ranks| ranks.len()) + let available_reveal_bundle_slots = reveal_bundle_slots_by_height + .get(&block.height) + .copied() .unwrap_or(REVEAL_COMMITTEE_SIZE); let reveal_finalizer_fee = blinded_reveal_finalizer_fee( transaction.fee(), diff --git a/src/adapters/http.rs b/src/adapters/http.rs @@ -69,9 +69,9 @@ pub use state::ServeOptions; use state::{AuthClientKey, AuthSession, HttpState, UiChainCache, UiChainView}; use static_assets::{alpine_js, app_js, favicon, index}; use ui::{ - add_pending_outputs, cached_chain_view, ui_blinded_reveal, ui_blinded_transaction, - ui_blocks_from_indexes, ui_pending_revealed_transaction, ui_transaction, - wallet_transaction_rows, + add_pending_outputs, burn_leader_ranks_for_blocks, cached_chain_view, ui_blinded_reveal, + ui_blinded_transaction, ui_blocks_from_indexes, ui_pending_revealed_transaction, + ui_transaction, wallet_transaction_rows, }; #[cfg(test)] use ui::{known_output_index, revealed_transactions_by_height, ui_block, ui_blocks}; @@ -98,9 +98,9 @@ const PEER_STALE_AFTER_MS: u64 = 20 * 60 * 1_000; mod types; use types::{ ActionResponse, AuthForm, AuthStatusResponse, BlocksQuery, ChangePasswordForm, ConfigForm, - ConfigResponse, MempoolCounts, MetricsQuery, MetricsResponse, NetworkHealthResponse, Page, - PageQuery, UiBlock, UiTransaction, WalletTransactionFilters, WalletTransactionRow, - WalletTransactionsQuery, WalletUtxoRow, + ConfigResponse, MempoolCounts, MetricsQuery, MetricsResponse, NetworkHealthLocalState, + NetworkHealthResponse, Page, PageQuery, UiBlock, UiTransaction, WalletTransactionFilters, + WalletTransactionRow, WalletTransactionsQuery, WalletUtxoRow, }; #[cfg(test)] use types::{BurnSettingsForm, TransferForm}; @@ -126,7 +126,7 @@ pub async fn serve( auth_backoff: Arc::new(Mutex::new(BTreeMap::new())), ui_cache: Arc::new(Mutex::new(UiChainCache::default())), }; - tokio::spawn(prewarm_chain_view_cache(state.clone())); + prewarm_chain_view_cache(state.clone()).await?; tokio::spawn(run_owned_blinded_outbox_persistence(state.clone())); let app = Router::new() .route("/", get(index)) @@ -206,12 +206,13 @@ pub async fn serve( .context("serving HTTP management UI") } -async fn prewarm_chain_view_cache(state: HttpState) { +async fn prewarm_chain_view_cache(state: HttpState) -> Result<()> { let snapshot = { let node = state.node.lock().await; node.chain_snapshot() }; - let _ = cached_chain_view(&state, &snapshot).await; + let _ = cached_chain_view(&state, &snapshot).await?; + Ok(()) } async fn api_config_form( diff --git a/src/adapters/http/api.rs b/src/adapters/http/api.rs @@ -14,14 +14,14 @@ use crate::{ use super::{ BlocksQuery, ConfigResponse, MempoolCounts, MetricsQuery, MetricsResponse, - NetworkHealthResponse, Page, PageQuery, UiBlock, UiTransaction, WalletTransactionFilters, - WalletTransactionRow, WalletTransactionsQuery, WalletUtxoRow, + NetworkHealthLocalState, NetworkHealthResponse, Page, PageQuery, UiBlock, UiTransaction, + WalletTransactionFilters, WalletTransactionRow, WalletTransactionsQuery, WalletUtxoRow, }; use super::{ DATASET_LIMIT, DATASET_PAGE_LIMIT, EXPLORER_LIMIT, EXPLORER_PAGE_LIMIT, HttpState, - add_pending_outputs, cached_chain_view, metrics_response, network_health, ui_blinded_reveal, - ui_blinded_transaction, ui_blocks_from_indexes, ui_pending_revealed_transaction, - ui_transaction, wallet_transaction_rows, + add_pending_outputs, burn_leader_ranks_for_blocks, cached_chain_view, metrics_response, + network_health, ui_blinded_reveal, ui_blinded_transaction, ui_blocks_from_indexes, + ui_pending_revealed_transaction, ui_transaction, wallet_transaction_rows, }; pub(super) async fn api_status(State(state): State<HttpState>) -> Json<NodeStatus> { @@ -38,7 +38,7 @@ pub(super) async fn api_blocks( .limit .unwrap_or(EXPLORER_PAGE_LIMIT) .min(EXPLORER_LIMIT); - let (snapshot, pending, blocks, burn_leader_ranks) = { + let (snapshot, pending, blocks) = { let node = state.node.lock().await; let snapshot = node.chain_snapshot(); let pending = node.pending_transactions(); @@ -46,19 +46,12 @@ pub(super) async fn api_blocks( Some(before_height) => node.blocks_before(before_height, limit), None => node.recent_blocks(limit), }; - let burn_leader_ranks = blocks - .iter() - .map(|block| { - ( - block.hash.clone(), - node.burn_leader_ranks_for_block(block.height) - .unwrap_or_default(), - ) - }) - .collect::<BTreeMap<_, _>>(); - (snapshot, pending, blocks, burn_leader_ranks) + (snapshot, pending, blocks) }; - let view = cached_chain_view(&state, &snapshot).await; + let view = cached_chain_view(&state, &snapshot) + .await + .unwrap_or_default(); + let burn_leader_ranks = burn_leader_ranks_for_blocks(&snapshot, &blocks); let mut outputs = view.outputs; add_pending_outputs(&mut outputs, &pending); Json(ui_blocks_from_indexes( @@ -100,7 +93,9 @@ pub(super) async fn api_mempool( pending_revealed, ) }; - let view = cached_chain_view(&state, &snapshot).await; + let view = cached_chain_view(&state, &snapshot) + .await + .unwrap_or_default(); let mut outputs = view.outputs; add_pending_outputs(&mut outputs, &pending); let mut items = pending @@ -135,7 +130,9 @@ pub(super) async fn api_wallet_transactions( node.owned_blinded_payloads(), ) }; - let view = cached_chain_view(&state, &snapshot).await; + let view = cached_chain_view(&state, &snapshot) + .await + .unwrap_or_default(); let mut outputs = view.outputs; add_pending_outputs(&mut outputs, &pending); let page_query = query.page(); @@ -281,17 +278,21 @@ pub(super) async fn api_metrics( pub(super) async fn api_network_health( State(state): State<HttpState>, ) -> Json<NetworkHealthResponse> { - let (status, mempool) = { + let (local, mempool) = { let node = state.node.lock().await; + let mempool = MempoolCounts { + plain_transactions: node.pending_transactions().len(), + blinded_transactions: node.pending_blinded_transactions().len(), + blinded_reveals: node.pending_blinded_reveals().len(), + }; ( - node.status(), - MempoolCounts { - plain_transactions: node.pending_transactions().len(), - blinded_transactions: node.pending_blinded_transactions().len(), - blinded_reveals: node.pending_blinded_reveals().len(), + NetworkHealthLocalState { + height: node.chain_height(), + pending_transactions: mempool.total(), }, + mempool, ) }; let peers = state.peers.lock().await.list(); - Json(network_health(&status, &peers, mempool)) + Json(network_health(local, &peers, mempool)) } diff --git a/src/adapters/http/metrics.rs b/src/adapters/http/metrics.rs @@ -1,6 +1,6 @@ use crate::{ adapters::chain_store::BlockMetricRow, - app::{NodeStatus, PeerDirection, PeerInfo}, + app::{PeerDirection, PeerInfo}, domain::Amount, }; @@ -8,16 +8,16 @@ use super::{ PEER_STALE_AFTER_MS, now_ms, types::{ MempoolCounts, MetricsChart, MetricsPoint, MetricsResponse, MetricsValueKind, - NetworkHealthResponse, + NetworkHealthLocalState, NetworkHealthResponse, }, }; pub(super) fn network_health( - status: &NodeStatus, + local: NetworkHealthLocalState, peers: &[PeerInfo], mempool: MempoolCounts, ) -> NetworkHealthResponse { - network_health_at(status, peers, mempool, now_ms()) + network_health_at(local, peers, mempool, now_ms()) } pub(super) fn metrics_response(enabled: bool, rows: Vec<BlockMetricRow>) -> MetricsResponse { @@ -152,12 +152,12 @@ fn micro_iuna_as_iuna(amount: Amount) -> f64 { } pub(super) fn network_health_at( - status: &NodeStatus, + local: NetworkHealthLocalState, peers: &[PeerInfo], mempool: MempoolCounts, now_ms: u64, ) -> NetworkHealthResponse { - let local_height = status.chain.height; + let local_height = local.height; let remote_best_height = peers.iter().filter_map(|peer| peer.last_known_height).max(); let best_known_height = remote_best_height.unwrap_or(local_height).max(local_height); let healthy_heights = peers @@ -246,7 +246,7 @@ pub(super) fn network_health_at( failed_peers, stale_peers, banned_peers, - pending_transactions: status.chain.pending_transactions, + pending_transactions: local.pending_transactions, pending_plain_transactions: mempool.plain_transactions, pending_blinded_transactions: mempool.blinded_transactions, pending_blinded_reveals: mempool.blinded_reveals, diff --git a/src/adapters/http/state.rs b/src/adapters/http/state.rs @@ -45,7 +45,7 @@ pub(super) struct UiChainCache { pub(super) revealed_by_height: BTreeMap<u64, Vec<RevealedBlindedTransaction>>, } -#[derive(Clone, Debug)] +#[derive(Clone, Debug, Default)] pub(super) struct UiChainView { pub(super) outputs: BTreeMap<OutPoint, TxOutput>, pub(super) revealed_by_height: BTreeMap<u64, Vec<RevealedBlindedTransaction>>, diff --git a/src/adapters/http/tests.rs b/src/adapters/http/tests.rs @@ -865,13 +865,17 @@ async fn network_health_summarizes_sync_and_peer_errors() { let dir = tempfile::tempdir().unwrap(); let state = auth_test_state(dir.path().join("config.json"), UiConfig::default()).await; let status = state.node.lock().await.status(); + let local = super::NetworkHealthLocalState { + height: status.chain.height, + pending_transactions: status.chain.pending_transactions, + }; let mempool = super::MempoolCounts { plain_transactions: 1, blinded_transactions: 2, blinded_reveals: 3, }; - let isolated = super::network_health(&status, &[], mempool); + let isolated = super::network_health(local, &[], mempool); assert!(!isolated.ok); assert_eq!(isolated.state, "isolated"); assert_eq!(isolated.local_height, 0); @@ -893,12 +897,12 @@ async fn network_health_summarizes_sync_and_peer_errors() { 11 * 60 * 1_000, 10_000, ); - let clock_health = super::network_health_at(&status, &clock_peers.list(), mempool, 10_000); + let clock_health = super::network_health_at(local, &clock_peers.list(), mempool, 10_000); assert_eq!(clock_health.network_time_offset_ms, Some(500)); assert_eq!(clock_health.bad_clock_peers, 1); let syncing = super::network_health( - &status, + local, &[PeerInfo { address: "127.0.0.1:9445".to_string(), direction: PeerDirection::Outbound, @@ -925,7 +929,7 @@ async fn network_health_summarizes_sync_and_peer_errors() { assert_eq!(syncing.lag_blocks, 3); let peer_errors = super::network_health( - &status, + local, &[PeerInfo { address: "127.0.0.1:9446".to_string(), direction: PeerDirection::Outbound, @@ -954,7 +958,7 @@ async fn network_health_summarizes_sync_and_peer_errors() { ); let stale = super::network_health_at( - &status, + local, &[PeerInfo { address: "127.0.0.1:9447".to_string(), direction: PeerDirection::Outbound, @@ -981,7 +985,7 @@ async fn network_health_summarizes_sync_and_peer_errors() { assert_eq!(stale.stale_peers, 1); let banned = super::network_health_at( - &status, + local, &[PeerInfo { address: "127.0.0.1:9448".to_string(), direction: PeerDirection::Outbound, @@ -1676,6 +1680,26 @@ fn block_detail_finalizer_opens_burn_leader_ranks_modal() { assert!(super::INDEX_HTML.contains("rank.ticket_id ?? rank.ticketId")); } +#[tokio::test] +async fn startup_prewarm_populates_chain_view_cache_before_first_request() { + let dir = tempfile::tempdir().unwrap(); + let state = auth_test_state(dir.path().join("config.json"), UiConfig::default()).await; + let expected_tip = { + let node = state.node.lock().await; + node.chain_snapshot() + .blocks + .last() + .map(|block| block.hash.clone()) + }; + assert!(state.ui_cache.lock().await.tip_hash.is_none()); + + super::prewarm_chain_view_cache(state.clone()) + .await + .unwrap(); + + assert_eq!(state.ui_cache.lock().await.tip_hash, expected_tip); +} + async fn auth_test_state(config_path: std::path::PathBuf, config: UiConfig) -> HttpState { config_store::save(&config_path, &config).unwrap(); let wallet_path = config_path.with_file_name("wallet.json"); diff --git a/src/adapters/http/types.rs b/src/adapters/http/types.rs @@ -52,6 +52,20 @@ pub(super) struct MempoolCounts { pub(super) blinded_reveals: usize, } +impl MempoolCounts { + pub(super) fn total(&self) -> usize { + self.plain_transactions + .saturating_add(self.blinded_transactions) + .saturating_add(self.blinded_reveals) + } +} + +#[derive(Clone, Copy, Debug)] +pub(super) struct NetworkHealthLocalState { + pub(super) height: u64, + pub(super) pending_transactions: usize, +} + #[derive(Debug, Deserialize)] pub(super) struct BurnSettingsForm { pub(super) enabled: Option<bool>, diff --git a/src/adapters/http/ui.rs b/src/adapters/http/ui.rs @@ -5,7 +5,7 @@ use crate::domain::{ BLINDED_REVEAL_BUNDLE_SIGNER_FEE_BPS, BlindedReveal, BlindedTransaction, Block, BurnLeaderRank, ChainSnapshot, Ledger, MINE_REWARD, OutPoint, REVEAL_COMMITTEE_SIZE, RevealedBlindedTransaction, Transaction, TxInput, TxOutput, blinded_reveal_finalizer_fee, - hex_hash, revealed_blinded_transactions, + hex_hash, reveal_committee_slot_count, revealed_blinded_transactions, }; use super::{ @@ -240,6 +240,32 @@ pub(super) fn ui_blocks_from_indexes( .collect() } +pub(super) fn burn_leader_ranks_for_blocks( + snapshot: &ChainSnapshot, + blocks: &[Block], +) -> BTreeMap<String, Vec<BurnLeaderRank>> { + let Some(ranks_by_height) = Ledger::from_persisted_snapshot(snapshot.clone()) + .ok() + .and_then(|ledger| { + ledger + .burn_leader_ranks_for_blocks(blocks.iter().map(|block| block.height)) + .ok() + }) + else { + return BTreeMap::new(); + }; + + blocks + .iter() + .filter_map(|block| { + ranks_by_height + .get(&block.height) + .cloned() + .map(|ranks| (block.hash.clone(), ranks)) + }) + .collect() +} + pub(super) fn ui_block( block: Block, outputs: &BTreeMap<OutPoint, TxOutput>, @@ -566,40 +592,52 @@ pub(super) fn known_output_index( outputs } -pub(super) async fn cached_chain_view(state: &HttpState, snapshot: &ChainSnapshot) -> UiChainView { +pub(super) async fn cached_chain_view( + state: &HttpState, + snapshot: &ChainSnapshot, +) -> anyhow::Result<UiChainView> { let tip_hash = snapshot.blocks.last().map(|block| block.hash.clone()); { let cache = state.ui_cache.lock().await; if cache.tip_hash == tip_hash { - return UiChainView { + return Ok(UiChainView { outputs: cache.outputs.clone(), revealed_by_height: cache.revealed_by_height.clone(), - }; + }); } } - let outputs = known_chain_output_index(snapshot); - let revealed_by_height = revealed_transactions_by_height(snapshot); + let (computed_tip_hash, view) = tokio::task::spawn_blocking({ + let snapshot = snapshot.clone(); + move || build_chain_view(&snapshot) + }) + .await?; let mut cache = state.ui_cache.lock().await; if cache.tip_hash == tip_hash { - return UiChainView { + return Ok(UiChainView { outputs: cache.outputs.clone(), revealed_by_height: cache.revealed_by_height.clone(), - }; + }); } - let view = UiChainView { - outputs, - revealed_by_height, - }; - cache.tip_hash = tip_hash; + cache.tip_hash = computed_tip_hash; cache.outputs = view.outputs.clone(); cache.revealed_by_height = view.revealed_by_height.clone(); - UiChainView { + Ok(UiChainView { outputs: view.outputs, revealed_by_height: view.revealed_by_height, - } + }) +} + +fn build_chain_view(snapshot: &ChainSnapshot) -> (Option<String>, UiChainView) { + ( + snapshot.blocks.last().map(|block| block.hash.clone()), + UiChainView { + outputs: known_chain_output_index(snapshot), + revealed_by_height: revealed_transactions_by_height(snapshot), + }, + ) } fn known_chain_output_index(snapshot: &ChainSnapshot) -> BTreeMap<OutPoint, TxOutput> { @@ -622,22 +660,7 @@ fn known_chain_output_index(snapshot: &ChainSnapshot) -> BTreeMap<OutPoint, TxOu .iter() .map(|block| (block.height, block)) .collect::<BTreeMap<_, _>>(); - let reveal_bundle_slots_by_height = Ledger::from_persisted_snapshot(snapshot.clone()) - .ok() - .map(|ledger| { - snapshot - .blocks - .iter() - .map(|block| { - let slots = ledger - .burn_leader_ranks_for_block(block.height) - .map(|ranks| ranks.len()) - .unwrap_or(REVEAL_COMMITTEE_SIZE); - (block.height, slots) - }) - .collect::<BTreeMap<_, _>>() - }) - .unwrap_or_default(); + let reveal_bundle_slots_by_height = reveal_bundle_slots_by_height(snapshot); let blinded_by_commitment = snapshot .blocks .iter() @@ -718,6 +741,23 @@ fn known_chain_output_index(snapshot: &ChainSnapshot) -> BTreeMap<OutPoint, TxOu outputs } +fn reveal_bundle_slots_by_height(snapshot: &ChainSnapshot) -> BTreeMap<u64, usize> { + Ledger::from_persisted_snapshot(snapshot.clone()) + .ok() + .and_then(|ledger| { + ledger + .burn_leader_ranks_for_blocks(snapshot.blocks.iter().map(|block| block.height)) + .ok() + }) + .map(|ranks_by_height| { + ranks_by_height + .into_iter() + .map(|(height, ranks)| (height, reveal_committee_slot_count(ranks.len()))) + .collect() + }) + .unwrap_or_default() +} + pub(super) fn add_pending_outputs( outputs: &mut BTreeMap<OutPoint, TxOutput>, pending: &[Transaction], diff --git a/src/app/automatic_mining.rs b/src/app/automatic_mining.rs @@ -212,7 +212,7 @@ impl NodeCore { &mut self, timestamp_ms: u64, ) -> Result<Option<Transaction>> { - let current_height = self.ledger.status().height; + let current_height = self.ledger.height(); if !self.automatic_mining_enabled { return Ok(None); } @@ -240,7 +240,7 @@ impl NodeCore { } fn prepare_automatic_anchor_burn(&mut self, timestamp_ms: u64) -> Result<Option<Transaction>> { - let current_height = self.ledger.status().height; + let current_height = self.ledger.height(); if !self.automatic_burn_needs_plaintext_anchor(timestamp_ms) { return Ok(None); } @@ -377,8 +377,7 @@ impl NodeCore { if self.ledger.finalizer_rank_count_for_next_block() > 0 { return false; } - let tip_hash = self.ledger.status().tip_hash; - recovery_vdf_sample_percent(self.wallet.address(), tip_hash.as_str()) + recovery_vdf_sample_percent(self.wallet.address(), self.ledger.tip_hash()) < self.recovery_vdf_top_rank_percent } diff --git a/src/app/gossip.rs b/src/app/gossip.rs @@ -52,24 +52,22 @@ impl NodeCore { } pub fn hello(&self, listen_addr: Option<String>, node_id: Option<String>) -> GossipEnvelope { - let status = self.ledger.status(); GossipEnvelope::Hello(ProtocolHello { protocol_version: PROTOCOL_VERSION, network_id: NETWORK_ID.to_string(), genesis_hash: self.ledger.genesis_hash().to_string(), listen_addr, node_id, - height: status.height, - tip_hash: status.tip_hash, + height: self.ledger.height(), + tip_hash: self.ledger.tip_hash().to_string(), time_ms: now_ms(), }) } pub fn peer_status(&self) -> GossipEnvelope { - let status = self.ledger.status(); GossipEnvelope::PeerStatus { - height: status.height, - tip_hash: status.tip_hash, + height: self.ledger.height(), + tip_hash: self.ledger.tip_hash().to_string(), time_ms: now_ms(), } } diff --git a/src/app/status.rs b/src/app/status.rs @@ -15,7 +15,7 @@ use super::{ impl NodeCore { pub fn status(&self) -> NodeStatus { - let chain = self.ledger.status(); + let chain = self.ledger.light_status(); let launch_profile = self.ledger.launch_profile(); let current_leader = self.ledger.expected_leader_for_next_block(); let wallet_is_current_leader = current_leader @@ -195,6 +195,24 @@ mod tests { } #[test] + fn status_omits_full_balance_map_for_ui_polling() { + let wallet = Wallet::from_seed("status-light-wallet"); + let mut allocations = BTreeMap::new(); + allocations.insert(wallet.address().to_string(), 10); + allocations.insert( + Wallet::from_seed("status-light-peer").address().to_string(), + 5, + ); + let ledger = Ledger::new(allocations, 1); + let node = NodeCore::from_ledger(wallet, ledger, 0); + + let status = node.status(); + + assert_eq!(status.wallet_balance, 10); + assert!(status.chain.balances.is_empty()); + } + + #[test] fn automatic_pow_status_reports_setup_placeholder_wait() { let wallet = Wallet::from_seed("automatic-pow-setup-placeholder-wallet"); let ledger = Ledger::new(BTreeMap::new(), 1); diff --git a/src/domain/ledger_queries.rs b/src/domain/ledger_queries.rs @@ -8,7 +8,7 @@ use super::blinded::{ use super::genesis::balances_from_utxos; use super::mine_policy::mine_anchor; use super::ticket::{ - apply_finalizer_ticket_effects, genesis_tickets, ranked_tickets_for_height, + BurnTicket, apply_finalizer_ticket_effects, genesis_tickets, ranked_tickets_for_height, tickets_created_by_block, tickets_created_by_transactions, }; use super::{ @@ -17,6 +17,55 @@ use super::{ Transaction, TxOutput, reveal_committee_slot_count, }; +fn apply_historical_ticket_block( + block: &Block, + launch_profile: &LaunchProfile, + tickets: &mut Vec<BurnTicket>, + active_blinded: &mut BTreeMap<String, ActiveBlindedTransaction>, +) -> Result<()> { + apply_finalizer_ticket_effects(block, tickets)?; + tickets.extend(tickets_created_by_block(block, launch_profile)?); + let mut revealed_transactions = Vec::new(); + for reveal in block.all_blinded_reveals() { + let active = active_blinded.get(&reveal.commitment).with_context(|| { + format!( + "block {} reveals unknown blinded transaction {}", + block.height, reveal.commitment + ) + })?; + let transaction = decrypt_blinded_transaction(&active.transaction, reveal)?; + if matches!(transaction, Transaction::Mine { .. }) { + bail!("mine actions are public and cannot be blinded"); + } + if blinded_envelope_fee_for_transaction(&transaction) != active.transaction.fee { + bail!( + "block {} blinded reveal fee does not match envelope", + block.height + ); + } + revealed_transactions.push(transaction); + active_blinded.remove(&reveal.commitment); + } + tickets.extend(tickets_created_by_transactions( + block.height, + &revealed_transactions, + launch_profile, + )?); + active_blinded.retain(|_, active| block.height < active.transaction.expires_at_height); + for transaction in &block.blinded_transactions { + active_blinded.insert( + transaction.commitment.clone(), + ActiveBlindedTransaction { + transaction: transaction.clone(), + locked_outputs: Vec::new(), + included_height: block.height, + included_by: block.miner.clone(), + }, + ); + } + Ok(()) +} + impl Ledger { pub fn snapshot(&self) -> ChainSnapshot { ChainSnapshot { @@ -28,6 +77,14 @@ impl Ledger { } pub fn status(&self) -> ChainStatus { + self.status_with_balances(true) + } + + pub fn light_status(&self) -> ChainStatus { + self.status_with_balances(false) + } + + fn status_with_balances(&self, include_balances: bool) -> ChainStatus { ChainStatus { height: self.tip().height, tip_hash: self.tip().hash.clone(), @@ -35,91 +92,91 @@ impl Ledger { launch_profile_hash: self.launch_profile.hash(), mine_reward: self.mine_reward, current_mine_difficulty_bits: self.current_mine_difficulty_bits(), - balances: balances_from_utxos(&self.utxos), + balances: include_balances + .then(|| balances_from_utxos(&self.utxos)) + .unwrap_or_default(), pending_transactions: self.pending.len() + self.pending_blinded.len() + self.pending_reveals.len(), } } + pub fn tip_hash(&self) -> &str { + &self.tip().hash + } + pub fn chain(&self) -> &[Block] { &self.chain } pub fn burn_leader_ranks_for_block(&self, height: u64) -> Result<Vec<BurnLeaderRank>> { - if height == 0 { - return Ok(Vec::new()); + Ok(self + .burn_leader_ranks_for_blocks([height])? + .remove(&height) + .unwrap_or_default()) + } + + pub fn burn_leader_ranks_for_blocks<I>( + &self, + heights: I, + ) -> Result<BTreeMap<u64, Vec<BurnLeaderRank>>> + where + I: IntoIterator<Item = u64>, + { + let mut requested = heights.into_iter().collect::<BTreeSet<_>>(); + let mut ranks_by_height = BTreeMap::new(); + if requested.remove(&0) { + ranks_by_height.insert(0, Vec::new()); } - let parent_index = height.checked_sub(1).context("block height underflows")? as usize; - let parent = self - .chain - .get(parent_index) - .with_context(|| format!("missing parent block for height {height}"))?; + if requested.is_empty() { + return Ok(ranks_by_height); + } + let mut tickets = genesis_tickets( &self.genesis_allocations, &self.chain[0], &self.launch_profile, )?; let mut active_blinded = BTreeMap::<String, ActiveBlindedTransaction>::new(); - for block in self - .chain - .iter() - .skip(1) - .take_while(|block| block.height < height) - { - apply_finalizer_ticket_effects(block, &mut tickets)?; - tickets.extend(tickets_created_by_block(block, &self.launch_profile)?); - let mut revealed_transactions = Vec::new(); - for reveal in block.all_blinded_reveals() { - let active = active_blinded.get(&reveal.commitment).with_context(|| { - format!( - "block {} reveals unknown blinded transaction {}", - block.height, reveal.commitment - ) - })?; - let transaction = decrypt_blinded_transaction(&active.transaction, reveal)?; - if matches!(transaction, Transaction::Mine { .. }) { - bail!("mine actions are public and cannot be blinded"); + let mut next_block_index = 1; + + for height in requested { + let parent_index = height.checked_sub(1).context("block height underflows")? as usize; + let parent = self + .chain + .get(parent_index) + .with_context(|| format!("missing parent block for height {height}"))?; + while let Some(block) = self.chain.get(next_block_index) { + if block.height >= height { + break; } - if blinded_envelope_fee_for_transaction(&transaction) != active.transaction.fee { - bail!( - "block {} blinded reveal fee does not match envelope", - block.height - ); - } - revealed_transactions.push(transaction); - active_blinded.remove(&reveal.commitment); - } - tickets.extend(tickets_created_by_transactions( - block.height, - &revealed_transactions, - &self.launch_profile, - )?); - active_blinded.retain(|_, active| block.height < active.transaction.expires_at_height); - for transaction in &block.blinded_transactions { - active_blinded.insert( - transaction.commitment.clone(), - ActiveBlindedTransaction { - transaction: transaction.clone(), - locked_outputs: Vec::new(), - included_height: block.height, - included_by: block.miner.clone(), - }, - ); + apply_historical_ticket_block( + block, + &self.launch_profile, + &mut tickets, + &mut active_blinded, + )?; + next_block_index += 1; } + + ranks_by_height.insert( + height, + ranked_tickets_for_height(parent, height, &tickets) + .into_iter() + .enumerate() + .map(|(rank, ticket)| BurnLeaderRank { + rank: rank as u32, + ticket_id: ticket.id, + owner: ticket.owner, + amount: ticket.amount, + eligible_from_height: ticket.eligible_from_height, + eligible_until_height: ticket.eligible_until_height, + }) + .collect(), + ); } - Ok(ranked_tickets_for_height(parent, height, &tickets) - .into_iter() - .enumerate() - .map(|(rank, ticket)| BurnLeaderRank { - rank: rank as u32, - ticket_id: ticket.id, - owner: ticket.owner, - amount: ticket.amount, - eligible_from_height: ticket.eligible_from_height, - eligible_until_height: ticket.eligible_until_height, - }) - .collect()) + + Ok(ranks_by_height) } pub fn reveal_committee_for_next_block(&self) -> Vec<RevealCommitteeMember> { diff --git a/src/domain/tests.rs b/src/domain/tests.rs @@ -1412,6 +1412,10 @@ fn burn_leader_ranks_for_block_reconstructs_historical_ticket_order() { assert_eq!(ranks[0].owner, leader); assert!(ranks.iter().all(|rank| rank.amount == MICRO_IUNA)); assert_eq!(ledger.burn_leader_ranks_for_block(0).unwrap(), Vec::new()); + + let batch = ledger.burn_leader_ranks_for_blocks([0, 1]).unwrap(); + assert!(batch.get(&0).unwrap().is_empty()); + assert_eq!(batch.get(&1), Some(&ranks)); } #[test]