iuna

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

commit 759fffac788a7fe47e8acb691e367426b8b089df
parent 3800ffbeebb24991b3031db2d201bf04d6a62794
Author: Joris Hartog <jorishartog@hotmail.com>
Date:   Mon, 10 Aug 2026 20:27:50 +0200

Improve UI chain data performance

Diffstat:
Msrc/adapters/http.rs | 209+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------------------
Msrc/domain.rs | 123+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----
2 files changed, 274 insertions(+), 58 deletions(-)

diff --git a/src/adapters/http.rs b/src/adapters/http.rs @@ -66,6 +66,7 @@ struct HttpState { stratum: StratumStatus, auth_sessions: Arc<Mutex<BTreeMap<String, AuthSession>>>, auth_backoff: Arc<Mutex<BTreeMap<String, AuthBackoff>>>, + ui_cache: Arc<Mutex<UiChainCache>>, } #[derive(Clone)] @@ -83,6 +84,19 @@ struct AuthBackoff { locked_until_ms: Option<u64>, } +#[derive(Clone, Debug, Default)] +struct UiChainCache { + tip_hash: Option<String>, + outputs: BTreeMap<OutPoint, TxOutput>, + revealed_by_height: BTreeMap<u64, Vec<RevealedBlindedTransaction>>, +} + +#[derive(Clone, Debug)] +struct UiChainView { + outputs: BTreeMap<OutPoint, TxOutput>, + revealed_by_height: BTreeMap<u64, Vec<RevealedBlindedTransaction>>, +} + pub struct ServeOptions { pub config_path: PathBuf, pub chain_store: SqliteChainStore, @@ -482,6 +496,7 @@ pub async fn serve( stratum: options.stratum, auth_sessions: Arc::new(Mutex::new(BTreeMap::new())), auth_backoff: Arc::new(Mutex::new(BTreeMap::new())), + ui_cache: Arc::new(Mutex::new(UiChainCache::default())), }; tokio::spawn(run_owned_blinded_outbox_persistence(state.clone())); let app = Router::new() @@ -840,24 +855,35 @@ async fn api_blocks( .limit .unwrap_or(EXPLORER_PAGE_LIMIT) .min(EXPLORER_LIMIT); - let node = state.node.lock().await; - let snapshot = node.chain_snapshot(); - let pending = node.pending_transactions(); - let blocks = match query.before_height { - Some(before_height) => node.blocks_before(before_height, limit), - None => node.recent_blocks(limit), + let (snapshot, pending, blocks, burn_leader_ranks) = { + let node = state.node.lock().await; + let snapshot = node.chain_snapshot(); + let pending = node.pending_transactions(); + let blocks = match query.before_height { + 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) }; - let burn_leader_ranks = blocks - .iter() - .map(|block| { - ( - block.hash.clone(), - node.burn_leader_ranks_for_block(block.height) - .unwrap_or_default(), - ) - }) - .collect::<BTreeMap<_, _>>(); - Json(ui_blocks(blocks, &snapshot, &pending, &burn_leader_ranks)) + let view = cached_chain_view(&state, &snapshot).await; + let mut outputs = view.outputs; + add_pending_outputs(&mut outputs, &pending); + Json(ui_blocks_from_indexes( + blocks, + &outputs, + &view.revealed_by_height, + &burn_leader_ranks, + )) } async fn api_config(State(state): State<HttpState>) -> Json<ConfigResponse> { @@ -879,17 +905,28 @@ async fn api_mempool( State(state): State<HttpState>, Query(query): Query<PageQuery>, ) -> Json<Page<UiTransaction>> { - let node = state.node.lock().await; - let snapshot = node.chain_snapshot(); - let pending = node.pending_transactions(); - let pending_blinded = node.pending_blinded_transactions(); - let pending_reveals = node.pending_blinded_reveals(); - let pending_revealed = node - .pending_revealed_blinded_transactions() - .into_iter() - .map(|revealed| (revealed.commitment.clone(), revealed)) - .collect::<BTreeMap<_, _>>(); - let outputs = known_output_index(&snapshot, &pending); + let (snapshot, pending, pending_blinded, pending_reveals, pending_revealed) = { + let node = state.node.lock().await; + let snapshot = node.chain_snapshot(); + let pending = node.pending_transactions(); + let pending_blinded = node.pending_blinded_transactions(); + let pending_reveals = node.pending_blinded_reveals(); + let pending_revealed = node + .pending_revealed_blinded_transactions() + .into_iter() + .map(|revealed| (revealed.commitment.clone(), revealed)) + .collect::<BTreeMap<_, _>>(); + ( + snapshot, + pending, + pending_blinded, + pending_reveals, + pending_revealed, + ) + }; + let view = cached_chain_view(&state, &snapshot).await; + let mut outputs = view.outputs; + add_pending_outputs(&mut outputs, &pending); let mut items = pending .iter() .map(|tx| ui_transaction(tx, &outputs)) @@ -913,21 +950,27 @@ async fn api_wallet_transactions( State(state): State<HttpState>, Query(query): Query<WalletTransactionsQuery>, ) -> Json<Page<WalletTransactionRow>> { - let node = state.node.lock().await; - let snapshot = node.chain_snapshot(); - let pending = node.pending_transactions(); - let owned_blinded = node.owned_blinded_payloads(); - let revealed_by_height = revealed_transactions_by_height(&snapshot); - let outputs = known_output_index(&snapshot, &pending); + let (wallet, snapshot, pending, owned_blinded) = { + let node = state.node.lock().await; + ( + node.wallet_address().to_string(), + node.chain_snapshot(), + node.pending_transactions(), + node.owned_blinded_payloads(), + ) + }; + let view = cached_chain_view(&state, &snapshot).await; + let mut outputs = view.outputs; + add_pending_outputs(&mut outputs, &pending); let page_query = query.page(); let filters = WalletTransactionFilters::from_query(query); Json(page_items( wallet_transaction_rows( - node.wallet_address(), + &wallet, pending, owned_blinded, &snapshot.blocks, - &revealed_by_height, + &view.revealed_by_height, &outputs, filters, ), @@ -939,22 +982,27 @@ async fn api_wallet_utxos( State(state): State<HttpState>, Query(query): Query<PageQuery>, ) -> Json<Page<WalletUtxoRow>> { - let node = state.node.lock().await; - let ledger = node - .wallet_view_ledger() - .unwrap_or_else(|_| node.clone_ledger()); - Json(page_items( - wallet_utxo_rows(&ledger, node.wallet_address()), - query, - )) + let (ledger, wallet) = { + let node = state.node.lock().await; + ( + node.wallet_view_ledger() + .unwrap_or_else(|_| node.clone_ledger()), + node.wallet_address().to_string(), + ) + }; + Json(page_items(wallet_utxo_rows(&ledger, &wallet), query)) } async fn api_wallet_selectable_utxos(State(state): State<HttpState>) -> Json<Vec<WalletUtxoRow>> { - let node = state.node.lock().await; - let ledger = node - .wallet_view_ledger() - .unwrap_or_else(|_| node.clone_ledger()); - Json(selectable_wallet_utxo_rows(&ledger, node.wallet_address())) + let (ledger, wallet) = { + let node = state.node.lock().await; + ( + node.wallet_view_ledger() + .unwrap_or_else(|_| node.clone_ledger()), + node.wallet_address().to_string(), + ) + }; + Json(selectable_wallet_utxo_rows(&ledger, &wallet)) } fn page_items<T>(items: Vec<T>, query: PageQuery) -> Page<T> { @@ -1979,6 +2027,7 @@ fn revealed_transactions_by_height( ) } +#[cfg(test)] fn ui_blocks( blocks: Vec<Block>, snapshot: &ChainSnapshot, @@ -1987,11 +2036,20 @@ fn ui_blocks( ) -> Vec<UiBlock> { let outputs = known_output_index(snapshot, pending); let revealed = revealed_transactions_by_height(snapshot); + ui_blocks_from_indexes(blocks, &outputs, &revealed, burn_leader_ranks) +} + +fn ui_blocks_from_indexes( + blocks: Vec<Block>, + outputs: &BTreeMap<OutPoint, TxOutput>, + revealed: &BTreeMap<u64, Vec<RevealedBlindedTransaction>>, + burn_leader_ranks: &BTreeMap<String, Vec<BurnLeaderRank>>, +) -> Vec<UiBlock> { blocks .into_iter() .map(|block| { let revealed_transactions = revealed.get(&block.height).cloned().unwrap_or_default(); - ui_block(block, &outputs, burn_leader_ranks, &revealed_transactions) + ui_block(block, outputs, burn_leader_ranks, &revealed_transactions) }) .collect() } @@ -2312,10 +2370,53 @@ fn hex_nibble(byte: u8) -> Option<u8> { } } +#[cfg(test)] fn known_output_index( snapshot: &ChainSnapshot, pending: &[Transaction], ) -> BTreeMap<OutPoint, TxOutput> { + let mut outputs = known_chain_output_index(snapshot); + add_pending_outputs(&mut outputs, pending); + outputs +} + +async fn cached_chain_view(state: &HttpState, snapshot: &ChainSnapshot) -> 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 { + 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 mut cache = state.ui_cache.lock().await; + if cache.tip_hash == tip_hash { + return 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.outputs = view.outputs.clone(); + cache.revealed_by_height = view.revealed_by_height.clone(); + UiChainView { + outputs: view.outputs, + revealed_by_height: view.revealed_by_height, + } +} + +fn known_chain_output_index(snapshot: &ChainSnapshot) -> BTreeMap<OutPoint, TxOutput> { let mut outputs = BTreeMap::new(); for (address, amount) in &snapshot.genesis_allocations { if *amount == 0 { @@ -2428,10 +2529,13 @@ fn known_output_index( } } index_expired_blinded_outputs(&mut outputs, snapshot); + outputs +} + +fn add_pending_outputs(outputs: &mut BTreeMap<OutPoint, TxOutput>, pending: &[Transaction]) { for transaction in pending { - index_transaction_outputs(&mut outputs, transaction); + index_transaction_outputs(outputs, transaction); } - outputs } fn index_blinded_collateral_change( @@ -6359,6 +6463,7 @@ mod tests { }, auth_sessions: Arc::new(Mutex::new(BTreeMap::new())), auth_backoff: Arc::new(Mutex::new(BTreeMap::new())), + ui_cache: Arc::new(Mutex::new(super::UiChainCache::default())), } } diff --git a/src/domain.rs b/src/domain.rs @@ -4125,15 +4125,13 @@ impl Ledger { if matches!(pending, Transaction::Mine { .. }) { continue; } - let mut candidate = utxos.clone(); - if apply_transaction(&pending, &mut candidate).is_ok() { - utxos = candidate; + if apply_spendable_pending_transaction(&pending, &mut utxos).is_err() { + continue; } } for pending in self.valid_pending_blinded_transactions() { - let mut candidate = utxos.clone(); - if spend_blinded_inputs(&pending, &mut candidate).is_ok() { - utxos = candidate; + if spend_spendable_blinded_inputs(&pending, &mut utxos).is_err() { + continue; } } Ok(utxos) @@ -5414,6 +5412,72 @@ fn spend_inputs( Ok(total) } +fn apply_spendable_pending_transaction( + transaction: &Transaction, + utxos: &mut BTreeMap<OutPoint, TxOutput>, +) -> Result<()> { + if matches!(transaction, Transaction::Mine { .. }) { + bail!("pending mine outputs are not spendable"); + } + transaction.verify_signature()?; + ensure_single_input_owner(transaction)?; + let input_total = transaction_input_total(transaction, utxos)?; + let outputs = transaction.outputs(); + let output_total = outputs.iter().try_fold(0_u64, |total, output| { + total + .checked_add(output.amount) + .context("transaction outputs overflow") + })?; + let required = output_total + .checked_add(transaction.fee()) + .context("transaction outputs plus fee overflow")? + .checked_add(match transaction { + Transaction::Burn { amount, .. } => *amount, + Transaction::Transfer { .. } | Transaction::Mine { .. } => 0, + }) + .context("transaction outputs plus burn overflow")?; + if input_total != required { + bail!("transaction inputs do not balance outputs, burn, and fee"); + } + ensure_outputs_do_not_overflow(utxos, &outputs)?; + for input in transaction.inputs() { + utxos.remove(&input.outpoint); + } + for (index, output) in outputs.iter().enumerate() { + utxos.insert( + OutPoint { + txid: transaction.signature().to_string(), + index: index as u32, + }, + output.clone(), + ); + } + Ok(()) +} + +fn transaction_input_total( + transaction: &Transaction, + utxos: &BTreeMap<OutPoint, TxOutput>, +) -> Result<Amount> { + let mut seen = BTreeSet::new(); + let mut total = 0_u64; + for input in transaction.inputs() { + if !seen.insert(input.outpoint.clone()) { + bail!("duplicate input in transaction"); + } + let output = utxos.get(&input.outpoint).with_context(|| { + format!("transaction spends missing output {}", input.outpoint.id()) + })?; + if output.address != input.owner { + bail!("transaction input owner does not match spent output"); + } + total = total + .checked_add(output.amount) + .context("transaction input total overflows")?; + } + Ok(total) +} + fn spend_blinded_inputs( transaction: &BlindedTransaction, utxos: &mut BTreeMap<OutPoint, TxOutput>, @@ -5450,6 +5514,53 @@ fn spend_blinded_inputs( Ok(locked) } +fn spend_spendable_blinded_inputs( + transaction: &BlindedTransaction, + utxos: &mut BTreeMap<OutPoint, TxOutput>, +) -> Result<Vec<TxOutput>> { + let locked = blinded_input_outputs(transaction, utxos)?; + for input in &transaction.inputs { + utxos.remove(&input.outpoint); + } + Ok(locked) +} + +fn blinded_input_outputs( + transaction: &BlindedTransaction, + utxos: &BTreeMap<OutPoint, TxOutput>, +) -> Result<Vec<TxOutput>> { + verify_blinded_input_signatures(transaction)?; + if transaction.inputs.is_empty() { + return Ok(Vec::new()); + } + let mut seen = BTreeSet::new(); + let mut locked = Vec::new(); + for input in &transaction.inputs { + if !seen.insert(input.outpoint.clone()) { + bail!("duplicate input in blinded transaction"); + } + let output = utxos.get(&input.outpoint).with_context(|| { + format!( + "blinded transaction spends missing output {}", + input.outpoint.id() + ) + })?; + if output.address != input.owner { + bail!("blinded transaction input owner does not match spent output"); + } + locked.push(output.clone()); + } + let locked_total = locked.iter().try_fold(0_u64, |total, output| { + total + .checked_add(output.amount) + .context("blinded transaction locked input total overflows") + })?; + if transaction.fee > locked_total { + bail!("blinded transaction fee exceeds locked inputs"); + } + Ok(locked) +} + fn blinded_locked_output_total(active: &ActiveBlindedTransaction) -> Result<Amount> { active .locked_outputs