iuna

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

commit 81e4e34090872bd7d361d0583adef3c552396e6f
parent 69cbfa488b0ed867a426b6c0e27cd75a2b548ab6
Author: Joris Hartog <jorishartog@hotmail.com>
Date:   Tue, 11 Aug 2026 09:14:02 +0200

Persist UI chain indexes

Diffstat:
Msrc/adapters/chain_store.rs | 314+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
Msrc/adapters/chain_store/tests.rs | 31++++++++++++++++++++++++++++++-
Msrc/adapters/http.rs | 25++++++++++++++++++++++++-
Msrc/adapters/http/tests.rs | 36++++++++++++++++++++++++++++++++++++
Msrc/adapters/http/ui.rs | 303++++---------------------------------------------------------------------------
Msrc/adapters/mod.rs | 1+
Asrc/adapters/ui_index.rs | 333+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/domain/transaction.rs | 3++-
8 files changed, 747 insertions(+), 299 deletions(-)

diff --git a/src/adapters/chain_store.rs b/src/adapters/chain_store.rs @@ -10,12 +10,15 @@ use rusqlite::{Connection, OptionalExtension, params}; use serde::Serialize; -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, reveal_committee_slot_count, - revealed_blinded_transactions, +use crate::{ + adapters::ui_index::{UiChainIndex, build_ui_chain_index}, + domain::{ + AGGREGATE_FINALIZER_FEE_ACTIVATION_HEIGHT, Amount, BLINDED_COMMITTER_FEE_BPS, + BLINDED_REVEAL_BUNDLE_SIGNER_FEE_BPS, BlindedTransaction, Block, BurnLeaderRank, + ChainSnapshot, Ledger, MINE_REWARD, OutPoint, REVEAL_COMMITTEE_SIZE, + RevealedBlindedTransaction, Transaction, TxInput, TxOutput, blinded_reveal_finalizer_fee, + hex_hash, reveal_committee_slot_count, revealed_blinded_transactions, + }, }; mod compact; @@ -49,8 +52,50 @@ CREATE TABLE IF NOT EXISTS block_metrics ( vdf_rounds INTEGER NOT NULL, finalizer_rank INTEGER NOT NULL ); + +CREATE TABLE IF NOT EXISTS ui_cache_meta ( + id INTEGER PRIMARY KEY CHECK (id = 1), + schema_version INTEGER NOT NULL, + tip_hash TEXT NOT NULL, + updated_at_ms INTEGER NOT NULL +); + +CREATE TABLE IF NOT EXISTS ui_output_index ( + txid TEXT NOT NULL, + output_index INTEGER NOT NULL, + address TEXT NOT NULL, + amount INTEGER NOT NULL, + PRIMARY KEY (txid, output_index) +); + +CREATE TABLE IF NOT EXISTS ui_revealed_transactions ( + height INTEGER NOT NULL, + commitment TEXT PRIMARY KEY, + included_by TEXT NOT NULL, + transaction_json BLOB NOT NULL +); + +CREATE INDEX IF NOT EXISTS idx_ui_revealed_transactions_height +ON ui_revealed_transactions(height); + +CREATE TABLE IF NOT EXISTS ui_burn_leader_ranks ( + block_hash TEXT NOT NULL, + rank INTEGER NOT NULL, + ticket_id TEXT NOT NULL, + owner TEXT NOT NULL, + amount INTEGER NOT NULL, + eligible_from_height INTEGER NOT NULL, + eligible_until_height INTEGER NOT NULL, + PRIMARY KEY (block_hash, rank) +); + +CREATE TABLE IF NOT EXISTS ui_burn_leader_rank_blocks ( + block_hash TEXT PRIMARY KEY +); "#; +const UI_CACHE_SCHEMA_VERSION: u32 = 1; + #[derive(Clone, Debug, Eq, PartialEq, Serialize)] #[serde(rename_all = "camelCase")] pub struct BlockMetricRow { @@ -129,6 +174,32 @@ impl SqliteChainStore { }) } + pub(crate) fn load_ui_chain_index(&self, tip_hash: &str) -> Result<Option<UiChainIndex>> { + self.with_connection(|connection| { + let meta = connection + .query_row( + "SELECT schema_version, tip_hash FROM ui_cache_meta WHERE id = 1", + [], + |row| Ok((row.get::<_, u32>(0)?, row.get::<_, String>(1)?)), + ) + .optional() + .context("failed to load UI chain index metadata")?; + let Some((schema_version, stored_tip_hash)) = meta else { + return Ok(None); + }; + if schema_version != UI_CACHE_SCHEMA_VERSION || stored_tip_hash != tip_hash { + return Ok(None); + } + + Ok(Some(UiChainIndex { + tip_hash: Some(stored_tip_hash), + outputs: load_ui_output_index(connection)?, + revealed_by_height: load_ui_revealed_transactions(connection)?, + burn_leader_ranks_by_hash: load_ui_burn_leader_ranks(connection)?, + })) + }) + } + pub fn save(&self, snapshot: &ChainSnapshot) -> Result<()> { self.save_with_metrics(snapshot, false) } @@ -138,6 +209,7 @@ impl SqliteChainStore { let snapshot_blob = encode_compact_snapshot(snapshot).context("failed to encode compact chain snapshot")?; let updated_at_ms = unix_ms(); + let ui_index = build_ui_chain_index(snapshot); let metrics = if keep_metrics { Some(metrics_from_snapshot(snapshot)?) } else { @@ -166,6 +238,7 @@ ON CONFLICT(id) DO UPDATE SET Some(metrics) => replace_metrics(&transaction, &metrics)?, None => clear_metrics_in_transaction(&transaction)?, } + replace_ui_chain_index(&transaction, &ui_index, updated_at_ms)?; transaction .commit() .context("failed to commit chain persistence transaction")?; @@ -386,6 +459,235 @@ fn clear_metrics_in_transaction(transaction: &rusqlite::Transaction<'_>) -> Resu Ok(()) } +fn replace_ui_chain_index( + transaction: &rusqlite::Transaction<'_>, + index: &UiChainIndex, + updated_at_ms: u64, +) -> Result<()> { + clear_ui_chain_index_in_transaction(transaction)?; + let Some(tip_hash) = &index.tip_hash else { + return Ok(()); + }; + transaction + .execute( + r#" +INSERT INTO ui_cache_meta (id, schema_version, tip_hash, updated_at_ms) +VALUES (1, ?1, ?2, ?3) +"#, + params![UI_CACHE_SCHEMA_VERSION, tip_hash, updated_at_ms], + ) + .context("failed to persist UI chain index metadata")?; + for (outpoint, output) in &index.outputs { + transaction + .execute( + r#" +INSERT INTO ui_output_index (txid, output_index, address, amount) +VALUES (?1, ?2, ?3, ?4) +"#, + params![outpoint.txid, outpoint.index, output.address, output.amount], + ) + .with_context(|| { + format!( + "failed to persist UI output index row {}:{}", + outpoint.txid, outpoint.index + ) + })?; + } + for (height, revealed_transactions) in &index.revealed_by_height { + for revealed in revealed_transactions { + let transaction_json = serde_json::to_vec(&revealed.transaction) + .context("failed to serialize UI revealed transaction")?; + transaction + .execute( + r#" +INSERT INTO ui_revealed_transactions (height, commitment, included_by, transaction_json) +VALUES (?1, ?2, ?3, ?4) +"#, + params![ + height, + revealed.commitment, + revealed.included_by, + transaction_json + ], + ) + .with_context(|| { + format!( + "failed to persist UI revealed transaction {}", + revealed.commitment + ) + })?; + } + } + for (block_hash, ranks) in &index.burn_leader_ranks_by_hash { + transaction + .execute( + "INSERT INTO ui_burn_leader_rank_blocks (block_hash) VALUES (?1)", + params![block_hash], + ) + .with_context(|| format!("failed to persist UI burn leader rank block {block_hash}"))?; + for rank in ranks { + transaction + .execute( + r#" +INSERT INTO ui_burn_leader_ranks ( + block_hash, rank, ticket_id, owner, amount, eligible_from_height, eligible_until_height +) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7) +"#, + params![ + block_hash, + rank.rank, + rank.ticket_id, + rank.owner, + rank.amount, + rank.eligible_from_height, + rank.eligible_until_height, + ], + ) + .with_context(|| { + format!( + "failed to persist UI burn leader rank {} for block {}", + rank.rank, block_hash + ) + })?; + } + } + Ok(()) +} + +fn clear_ui_chain_index_in_transaction(transaction: &rusqlite::Transaction<'_>) -> Result<()> { + transaction + .execute("DELETE FROM ui_cache_meta", []) + .context("failed to clear old UI cache metadata")?; + transaction + .execute("DELETE FROM ui_output_index", []) + .context("failed to clear old UI output index")?; + transaction + .execute("DELETE FROM ui_revealed_transactions", []) + .context("failed to clear old UI revealed transaction index")?; + transaction + .execute("DELETE FROM ui_burn_leader_ranks", []) + .context("failed to clear old UI burn leader rank index")?; + transaction + .execute("DELETE FROM ui_burn_leader_rank_blocks", []) + .context("failed to clear old UI burn leader rank block index")?; + Ok(()) +} + +fn load_ui_output_index(connection: &Connection) -> Result<BTreeMap<OutPoint, TxOutput>> { + let mut statement = connection + .prepare( + r#" +SELECT txid, output_index, address, amount +FROM ui_output_index +ORDER BY txid, output_index +"#, + ) + .context("failed to prepare UI output index query")?; + let rows = statement + .query_map([], |row| { + Ok(( + OutPoint { + txid: row.get(0)?, + index: row.get(1)?, + }, + TxOutput { + address: row.get(2)?, + amount: row.get(3)?, + }, + )) + }) + .context("failed to load UI output index")?; + rows.collect::<std::result::Result<BTreeMap<_, _>, _>>() + .context("failed to read UI output index rows") +} + +fn load_ui_revealed_transactions( + connection: &Connection, +) -> Result<BTreeMap<u64, Vec<RevealedBlindedTransaction>>> { + let mut statement = connection + .prepare( + r#" +SELECT height, commitment, included_by, transaction_json +FROM ui_revealed_transactions +ORDER BY height, commitment +"#, + ) + .context("failed to prepare UI revealed transaction query")?; + let rows = statement + .query_map([], |row| { + let transaction_json = row.get::<_, Vec<u8>>(3)?; + let transaction = + serde_json::from_slice::<Transaction>(&transaction_json).map_err(|error| { + rusqlite::Error::FromSqlConversionFailure( + transaction_json.len(), + rusqlite::types::Type::Blob, + Box::new(error), + ) + })?; + Ok(RevealedBlindedTransaction { + height: row.get(0)?, + commitment: row.get(1)?, + included_by: row.get(2)?, + transaction, + }) + }) + .context("failed to load UI revealed transactions")?; + let mut by_height = BTreeMap::<u64, Vec<RevealedBlindedTransaction>>::new(); + for revealed in rows { + let revealed = revealed.context("failed to read UI revealed transaction row")?; + by_height.entry(revealed.height).or_default().push(revealed); + } + Ok(by_height) +} + +fn load_ui_burn_leader_ranks( + connection: &Connection, +) -> Result<BTreeMap<String, Vec<BurnLeaderRank>>> { + let mut blocks_statement = connection + .prepare("SELECT block_hash FROM ui_burn_leader_rank_blocks ORDER BY block_hash") + .context("failed to prepare UI burn leader rank block query")?; + let blocks = blocks_statement + .query_map([], |row| row.get::<_, String>(0)) + .context("failed to load UI burn leader rank blocks")?; + let mut by_block_hash = BTreeMap::<String, Vec<BurnLeaderRank>>::new(); + for block_hash in blocks { + by_block_hash.insert( + block_hash.context("failed to read UI burn leader rank block row")?, + Vec::new(), + ); + } + + let mut statement = connection + .prepare( + r#" +SELECT block_hash, rank, ticket_id, owner, amount, eligible_from_height, eligible_until_height +FROM ui_burn_leader_ranks +ORDER BY block_hash, rank +"#, + ) + .context("failed to prepare UI burn leader rank query")?; + let rows = statement + .query_map([], |row| { + Ok(( + row.get::<_, String>(0)?, + BurnLeaderRank { + rank: row.get(1)?, + ticket_id: row.get(2)?, + owner: row.get(3)?, + amount: row.get(4)?, + eligible_from_height: row.get(5)?, + eligible_until_height: row.get(6)?, + }, + )) + }) + .context("failed to load UI burn leader ranks")?; + for row in rows { + let (block_hash, rank) = row.context("failed to read UI burn leader rank row")?; + by_block_hash.entry(block_hash).or_default().push(rank); + } + Ok(by_block_hash) +} + fn metrics_from_snapshot(snapshot: &ChainSnapshot) -> Result<Vec<BlockMetricRow>> { let ledger = Ledger::from_persisted_snapshot(snapshot.clone()) .context("failed to rebuild ledger for metrics")?; diff --git a/src/adapters/chain_store/tests.rs b/src/adapters/chain_store/tests.rs @@ -3,7 +3,10 @@ use std::collections::BTreeMap; use rusqlite::Connection; use tempfile::tempdir; -use crate::domain::{BLOCK_REWARD, GenesisBurn, Ledger, Wallet, run_vdf}; +use crate::{ + adapters::ui_index::build_ui_chain_index, + domain::{BLOCK_REWARD, GenesisBurn, Ledger, Wallet, run_vdf}, +}; use super::{ BlockMetricRow, SqliteChainStore, decode_compact_snapshot, encode_compact_snapshot, @@ -38,6 +41,32 @@ fn sqlite_chain_store_roundtrips_snapshot() { } #[test] +fn sqlite_chain_store_persists_ui_chain_index_for_latest_tip() { + let dir = tempdir().unwrap(); + let store = SqliteChainStore::open(dir.path().join("chain.sqlite3")).unwrap(); + let wallet = Wallet::from_seed("ui-index-alice"); + let mut genesis = BTreeMap::new(); + genesis.insert(wallet.address().to_string(), 10); + let ledger = + Ledger::new_with_genesis_burns(genesis, vec![GenesisBurn::new(wallet.address(), 1)], 1) + .unwrap(); + let snapshot = ledger.snapshot(); + let expected = build_ui_chain_index(&snapshot); + let tip_hash = expected.tip_hash.as_deref().unwrap(); + + store.save(&snapshot).unwrap(); + + let loaded = store.load_ui_chain_index(tip_hash).unwrap().unwrap(); + assert_eq!(loaded, expected); + assert!( + store + .load_ui_chain_index("different-tip") + .unwrap() + .is_none() + ); +} + +#[test] fn compact_snapshot_roundtrips_and_is_smaller_than_json() { let alice = Wallet::from_seed("compact-alice"); let bob = Wallet::from_seed("compact-bob"); diff --git a/src/adapters/http.rs b/src/adapters/http.rs @@ -15,7 +15,7 @@ use axum::{ use tokio::{net::TcpListener, sync::Mutex}; use crate::{ - adapters::{config_store, config_store::UiConfig, p2p::GossipNetwork}, + adapters::{config_store, config_store::UiConfig, p2p::GossipNetwork, ui_index::UiChainIndex}, app::{SharedNode, SharedPeerBook}, domain::validate_address, }; @@ -207,6 +207,19 @@ pub async fn serve( } async fn prewarm_chain_view_cache(state: HttpState) -> Result<()> { + let tip_hash = { + let node = state.node.lock().await; + node.chain_tip_hash() + }; + if let Some(index) = load_persisted_ui_chain_index(&state, tip_hash.clone()).await? { + let mut cache = state.ui_cache.lock().await; + cache.tip_hash = index.tip_hash; + cache.outputs = index.outputs; + cache.revealed_by_height = index.revealed_by_height; + cache.burn_leader_ranks_by_hash = index.burn_leader_ranks_by_hash; + return Ok(()); + } + let snapshot = { let node = state.node.lock().await; node.chain_snapshot() @@ -215,6 +228,16 @@ async fn prewarm_chain_view_cache(state: HttpState) -> Result<()> { Ok(()) } +async fn load_persisted_ui_chain_index( + state: &HttpState, + tip_hash: String, +) -> Result<Option<UiChainIndex>> { + let store = state.chain_store.clone(); + tokio::task::spawn_blocking(move || store.load_ui_chain_index(&tip_hash)) + .await + .context("UI chain index loader failed")? +} + async fn api_config_form( State(state): State<HttpState>, Form(form): Form<ConfigForm>, diff --git a/src/adapters/http/tests.rs b/src/adapters/http/tests.rs @@ -1700,6 +1700,35 @@ async fn startup_prewarm_populates_chain_view_cache_before_first_request() { .last() .map(|block| block.hash.clone()) }; + let sentinel = OutPoint { + txid: "persisted-ui-index-sentinel".to_string(), + index: 7, + }; + let connection = rusqlite::Connection::open(state.chain_store.path()).unwrap(); + connection + .execute( + r#" +INSERT INTO ui_cache_meta (id, schema_version, tip_hash, updated_at_ms) +VALUES (1, 1, ?1, 0) +"#, + rusqlite::params![expected_tip.as_ref().unwrap()], + ) + .unwrap(); + connection + .execute( + "INSERT INTO ui_burn_leader_rank_blocks (block_hash) VALUES (?1)", + rusqlite::params![expected_tip.as_ref().unwrap()], + ) + .unwrap(); + connection + .execute( + r#" +INSERT INTO ui_output_index (txid, output_index, address, amount) +VALUES (?1, ?2, ?3, ?4) +"#, + rusqlite::params![&sentinel.txid, sentinel.index, "cached-address", 123_u64], + ) + .unwrap(); assert!(state.ui_cache.lock().await.tip_hash.is_none()); super::prewarm_chain_view_cache(state.clone()) @@ -1714,6 +1743,13 @@ async fn startup_prewarm_populates_chain_view_cache_before_first_request() { .as_ref() .is_some_and(|tip_hash| cache.burn_leader_ranks_by_hash.contains_key(tip_hash)) ); + assert_eq!( + cache.outputs.get(&sentinel), + Some(&TxOutput { + address: "cached-address".to_string(), + amount: 123, + }) + ); } async fn auth_test_state(config_path: std::path::PathBuf, config: UiConfig) -> HttpState { diff --git a/src/adapters/http/ui.rs b/src/adapters/http/ui.rs @@ -1,13 +1,12 @@ -use std::collections::{BTreeMap, BTreeSet}; +use std::collections::BTreeMap; use crate::domain::{ - Amount, BLINDED_COMMITTER_FEE_BPS, BLINDED_FEE_BPS_DENOMINATOR, - 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, reveal_committee_slot_count, revealed_blinded_transactions, + BlindedReveal, BlindedTransaction, Block, BurnLeaderRank, ChainSnapshot, MINE_REWARD, OutPoint, + RevealedBlindedTransaction, Transaction, TxInput, TxOutput, }; +use crate::adapters::ui_index::build_ui_chain_index; + use super::{ HttpState, UiChainView, types::{ @@ -198,19 +197,11 @@ fn wallet_transaction_row( } } +#[cfg(test)] pub(super) fn revealed_transactions_by_height( snapshot: &ChainSnapshot, ) -> BTreeMap<u64, Vec<RevealedBlindedTransaction>> { - revealed_blinded_transactions(snapshot) - .unwrap_or_default() - .into_iter() - .fold( - BTreeMap::<u64, Vec<RevealedBlindedTransaction>>::new(), - |mut by_height, revealed| { - by_height.entry(revealed.height).or_default().push(revealed); - by_height - }, - ) + crate::adapters::ui_index::revealed_transactions_by_height(snapshot) } #[cfg(test)] @@ -240,32 +231,6 @@ 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>, @@ -587,7 +552,7 @@ pub(super) fn known_output_index( snapshot: &ChainSnapshot, pending: &[Transaction], ) -> BTreeMap<OutPoint, TxOutput> { - let mut outputs = known_chain_output_index(snapshot); + let mut outputs = build_ui_chain_index(snapshot).outputs; add_pending_outputs(&mut outputs, pending); outputs } @@ -643,134 +608,17 @@ fn ui_chain_view_from_cache(cache: &super::UiChainCache) -> UiChainView { } fn build_chain_view(snapshot: &ChainSnapshot) -> (Option<String>, UiChainView) { + let index = build_ui_chain_index(snapshot); ( - snapshot.blocks.last().map(|block| block.hash.clone()), + index.tip_hash.clone(), UiChainView { - outputs: known_chain_output_index(snapshot), - revealed_by_height: revealed_transactions_by_height(snapshot), - burn_leader_ranks_by_hash: burn_leader_ranks_for_blocks(snapshot, &snapshot.blocks), + outputs: index.outputs, + revealed_by_height: index.revealed_by_height, + burn_leader_ranks_by_hash: index.burn_leader_ranks_by_hash, }, ) } -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 { - continue; - } - outputs.insert( - genesis_allocation_outpoint(address), - TxOutput { - address: address.clone(), - amount: *amount, - }, - ); - } - let revealed = revealed_blinded_transactions(snapshot).unwrap_or_default(); - let blocks_by_height = snapshot - .blocks - .iter() - .map(|block| (block.height, block)) - .collect::<BTreeMap<_, _>>(); - let reveal_bundle_slots_by_height = reveal_bundle_slots_by_height(snapshot); - let blinded_by_commitment = snapshot - .blocks - .iter() - .flat_map(|block| block.blinded_transactions.iter()) - .map(|transaction| (transaction.commitment.clone(), transaction.clone())) - .collect::<BTreeMap<_, _>>(); - for block in &snapshot.blocks { - for transaction in &block.transactions { - index_transaction_outputs(&mut outputs, transaction); - } - if block.reward > 0 { - outputs.insert( - reward_outpoint(&block.hash), - TxOutput { - address: block.miner.clone(), - amount: block.reward, - }, - ); - } - } - for revealed in revealed { - index_transaction_outputs(&mut outputs, &revealed.transaction); - let fee = revealed.transaction.fee(); - if matches!(revealed.transaction, Transaction::Mine { .. }) { - if let Some(commit) = blinded_by_commitment.get(&revealed.commitment) { - index_blinded_collateral_change(&mut outputs, commit, fee); - } - } - if fee > 0 { - let committer_fee = blinded_fee_share(fee, BLINDED_COMMITTER_FEE_BPS); - if committer_fee > 0 { - outputs.insert( - blinded_committer_fee_outpoint(&revealed.commitment), - TxOutput { - address: revealed.included_by, - amount: committer_fee, - }, - ); - } - if let Some(block) = blocks_by_height.get(&revealed.height) { - let reveal_finalizer_fee = blinded_reveal_finalizer_fee( - fee, - block.included_reveal_bundle_count(), - reveal_bundle_slots_by_height - .get(&revealed.height) - .copied() - .unwrap_or(REVEAL_COMMITTEE_SIZE), - ); - if reveal_finalizer_fee > 0 { - outputs.insert( - blinded_executor_fee_outpoint(&revealed.commitment), - TxOutput { - address: block.miner.clone(), - amount: reveal_finalizer_fee, - }, - ); - } - let reveal_bundle_signer_fee = - blinded_fee_share(fee, BLINDED_REVEAL_BUNDLE_SIGNER_FEE_BPS); - if reveal_bundle_signer_fee > 0 { - for signature in &block.reveal_bundle_section.signatures { - outputs.insert( - blinded_reveal_bundle_signer_fee_outpoint( - &revealed.commitment, - signature.slot, - ), - TxOutput { - address: signature.member.clone(), - amount: reveal_bundle_signer_fee, - }, - ); - } - } - } - } - } - index_expired_blinded_outputs(&mut outputs, snapshot); - 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], @@ -780,85 +628,6 @@ pub(super) fn add_pending_outputs( } } -fn index_blinded_collateral_change( - outputs: &mut BTreeMap<OutPoint, TxOutput>, - transaction: &BlindedTransaction, - fee: Amount, -) { - let Some(first_input) = transaction.inputs.first() else { - return; - }; - let locked_total = transaction.inputs.iter().fold(0_u64, |total, input| { - total.saturating_add( - outputs - .get(&input.outpoint) - .map(|output| output.amount) - .unwrap_or_default(), - ) - }); - if fee >= locked_total { - return; - } - outputs.insert( - blinded_expiry_change_outpoint(&transaction.commitment), - TxOutput { - address: first_input.owner.clone(), - amount: locked_total - fee, - }, - ); -} - -fn index_expired_blinded_outputs( - outputs: &mut BTreeMap<OutPoint, TxOutput>, - snapshot: &ChainSnapshot, -) { - let mut active = BTreeMap::<String, (BlindedTransaction, Amount)>::new(); - for block in &snapshot.blocks { - let revealed = block - .all_blinded_reveals() - .into_iter() - .map(|reveal| reveal.commitment.clone()) - .collect::<BTreeSet<_>>(); - active.retain(|commitment, (transaction, locked_total)| { - if revealed.contains(commitment) { - return false; - } - if block.height >= transaction.expires_at_height { - if let Some(first_input) = transaction.inputs.first() { - if transaction.fee <= *locked_total { - let change = *locked_total - transaction.fee; - if change > 0 { - outputs.insert( - blinded_expiry_change_outpoint(commitment), - TxOutput { - address: first_input.owner.clone(), - amount: change, - }, - ); - } - } - } - return false; - } - true - }); - for transaction in &block.blinded_transactions { - let locked_total = transaction.inputs.iter().fold(0_u64, |total, input| { - total.saturating_add( - outputs - .get(&input.outpoint) - .map(|output| output.amount) - .unwrap_or_default(), - ) - }); - active.insert( - transaction.commitment.clone(), - (transaction.clone(), locked_total), - ); - } - } -} - fn index_transaction_outputs( outputs: &mut BTreeMap<OutPoint, TxOutput>, transaction: &Transaction, @@ -881,49 +650,3 @@ fn index_transaction_outputs( ); } } - -fn genesis_allocation_outpoint(address: &str) -> OutPoint { - OutPoint { - txid: hex_hash(format!("iuna-genesis-allocation:{address}")), - index: 0, - } -} - -fn reward_outpoint(block_hash: &str) -> OutPoint { - OutPoint { - txid: block_hash.to_string(), - index: u32::MAX, - } -} - -fn blinded_committer_fee_outpoint(commitment: &str) -> OutPoint { - OutPoint { - txid: commitment.to_string(), - index: u32::MAX - 1, - } -} - -fn blinded_executor_fee_outpoint(commitment: &str) -> OutPoint { - OutPoint { - txid: commitment.to_string(), - index: u32::MAX - 2, - } -} - -fn blinded_reveal_bundle_signer_fee_outpoint(commitment: &str, slot: u8) -> OutPoint { - OutPoint { - txid: commitment.to_string(), - index: u32::MAX - 3 - u32::from(slot), - } -} - -fn blinded_expiry_change_outpoint(commitment: &str) -> OutPoint { - OutPoint { - txid: commitment.to_string(), - index: 0, - } -} - -fn blinded_fee_share(fee: Amount, bps: u64) -> Amount { - ((fee as u128 * bps as u128) / BLINDED_FEE_BPS_DENOMINATOR as u128) as Amount -} diff --git a/src/adapters/mod.rs b/src/adapters/mod.rs @@ -3,4 +3,5 @@ pub mod config_store; pub mod http; pub mod p2p; pub mod stratum; +pub(crate) mod ui_index; pub mod wallet_store; diff --git a/src/adapters/ui_index.rs b/src/adapters/ui_index.rs @@ -0,0 +1,333 @@ +use std::collections::{BTreeMap, BTreeSet}; + +use crate::domain::{ + Amount, BLINDED_COMMITTER_FEE_BPS, BLINDED_FEE_BPS_DENOMINATOR, + BLINDED_REVEAL_BUNDLE_SIGNER_FEE_BPS, BlindedTransaction, Block, BurnLeaderRank, ChainSnapshot, + Ledger, MINE_REWARD, OutPoint, REVEAL_COMMITTEE_SIZE, RevealedBlindedTransaction, Transaction, + TxOutput, blinded_reveal_finalizer_fee, hex_hash, reveal_committee_slot_count, + revealed_blinded_transactions, +}; + +#[derive(Clone, Debug, Default, Eq, PartialEq)] +pub(crate) struct UiChainIndex { + pub(crate) tip_hash: Option<String>, + pub(crate) outputs: BTreeMap<OutPoint, TxOutput>, + pub(crate) revealed_by_height: BTreeMap<u64, Vec<RevealedBlindedTransaction>>, + pub(crate) burn_leader_ranks_by_hash: BTreeMap<String, Vec<BurnLeaderRank>>, +} + +pub(crate) fn build_ui_chain_index(snapshot: &ChainSnapshot) -> UiChainIndex { + UiChainIndex { + tip_hash: snapshot.blocks.last().map(|block| block.hash.clone()), + outputs: known_chain_output_index(snapshot), + revealed_by_height: revealed_transactions_by_height(snapshot), + burn_leader_ranks_by_hash: burn_leader_ranks_for_blocks(snapshot, &snapshot.blocks), + } +} + +pub(crate) fn revealed_transactions_by_height( + snapshot: &ChainSnapshot, +) -> BTreeMap<u64, Vec<RevealedBlindedTransaction>> { + revealed_blinded_transactions(snapshot) + .unwrap_or_default() + .into_iter() + .fold( + BTreeMap::<u64, Vec<RevealedBlindedTransaction>>::new(), + |mut by_height, revealed| { + by_height.entry(revealed.height).or_default().push(revealed); + by_height + }, + ) +} + +pub(crate) 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() +} + +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 { + continue; + } + outputs.insert( + genesis_allocation_outpoint(address), + TxOutput { + address: address.clone(), + amount: *amount, + }, + ); + } + let revealed = revealed_blinded_transactions(snapshot).unwrap_or_default(); + let blocks_by_height = snapshot + .blocks + .iter() + .map(|block| (block.height, block)) + .collect::<BTreeMap<_, _>>(); + let reveal_bundle_slots_by_height = reveal_bundle_slots_by_height(snapshot); + let blinded_by_commitment = snapshot + .blocks + .iter() + .flat_map(|block| block.blinded_transactions.iter()) + .map(|transaction| (transaction.commitment.clone(), transaction.clone())) + .collect::<BTreeMap<_, _>>(); + for block in &snapshot.blocks { + for transaction in &block.transactions { + index_transaction_outputs(&mut outputs, transaction); + } + if block.reward > 0 { + outputs.insert( + reward_outpoint(&block.hash), + TxOutput { + address: block.miner.clone(), + amount: block.reward, + }, + ); + } + } + for revealed in revealed { + index_transaction_outputs(&mut outputs, &revealed.transaction); + let fee = revealed.transaction.fee(); + if matches!(revealed.transaction, Transaction::Mine { .. }) { + if let Some(commit) = blinded_by_commitment.get(&revealed.commitment) { + index_blinded_collateral_change(&mut outputs, commit, fee); + } + } + if fee > 0 { + let committer_fee = blinded_fee_share(fee, BLINDED_COMMITTER_FEE_BPS); + if committer_fee > 0 { + outputs.insert( + blinded_committer_fee_outpoint(&revealed.commitment), + TxOutput { + address: revealed.included_by, + amount: committer_fee, + }, + ); + } + if let Some(block) = blocks_by_height.get(&revealed.height) { + let reveal_finalizer_fee = blinded_reveal_finalizer_fee( + fee, + block.included_reveal_bundle_count(), + reveal_bundle_slots_by_height + .get(&revealed.height) + .copied() + .unwrap_or(REVEAL_COMMITTEE_SIZE), + ); + if reveal_finalizer_fee > 0 { + outputs.insert( + blinded_executor_fee_outpoint(&revealed.commitment), + TxOutput { + address: block.miner.clone(), + amount: reveal_finalizer_fee, + }, + ); + } + let reveal_bundle_signer_fee = + blinded_fee_share(fee, BLINDED_REVEAL_BUNDLE_SIGNER_FEE_BPS); + if reveal_bundle_signer_fee > 0 { + for signature in &block.reveal_bundle_section.signatures { + outputs.insert( + blinded_reveal_bundle_signer_fee_outpoint( + &revealed.commitment, + signature.slot, + ), + TxOutput { + address: signature.member.clone(), + amount: reveal_bundle_signer_fee, + }, + ); + } + } + } + } + } + index_expired_blinded_outputs(&mut outputs, snapshot); + 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() +} + +fn index_blinded_collateral_change( + outputs: &mut BTreeMap<OutPoint, TxOutput>, + transaction: &BlindedTransaction, + fee: Amount, +) { + let Some(first_input) = transaction.inputs.first() else { + return; + }; + let locked_total = transaction.inputs.iter().fold(0_u64, |total, input| { + total.saturating_add( + outputs + .get(&input.outpoint) + .map(|output| output.amount) + .unwrap_or_default(), + ) + }); + if fee >= locked_total { + return; + } + outputs.insert( + blinded_expiry_change_outpoint(&transaction.commitment), + TxOutput { + address: first_input.owner.clone(), + amount: locked_total - fee, + }, + ); +} + +fn index_expired_blinded_outputs( + outputs: &mut BTreeMap<OutPoint, TxOutput>, + snapshot: &ChainSnapshot, +) { + let mut active = BTreeMap::<String, (BlindedTransaction, Amount)>::new(); + for block in &snapshot.blocks { + let revealed = block + .all_blinded_reveals() + .into_iter() + .map(|reveal| reveal.commitment.clone()) + .collect::<BTreeSet<_>>(); + active.retain(|commitment, (transaction, locked_total)| { + if revealed.contains(commitment) { + return false; + } + if block.height >= transaction.expires_at_height { + if let Some(first_input) = transaction.inputs.first() { + if transaction.fee <= *locked_total { + let change = *locked_total - transaction.fee; + if change > 0 { + outputs.insert( + blinded_expiry_change_outpoint(commitment), + TxOutput { + address: first_input.owner.clone(), + amount: change, + }, + ); + } + } + } + return false; + } + true + }); + for transaction in &block.blinded_transactions { + let locked_total = transaction.inputs.iter().fold(0_u64, |total, input| { + total.saturating_add( + outputs + .get(&input.outpoint) + .map(|output| output.amount) + .unwrap_or_default(), + ) + }); + active.insert( + transaction.commitment.clone(), + (transaction.clone(), locked_total), + ); + } + } +} + +fn index_transaction_outputs( + outputs: &mut BTreeMap<OutPoint, TxOutput>, + transaction: &Transaction, +) { + let created_outputs = match transaction { + Transaction::Transfer { outputs, .. } => outputs.clone(), + Transaction::Burn { change, .. } => change.clone(), + Transaction::Mine { recipient, .. } => vec![TxOutput { + address: recipient.clone(), + amount: MINE_REWARD, + }], + }; + for (index, output) in created_outputs.iter().enumerate() { + outputs.insert( + OutPoint { + txid: transaction.signature().to_string(), + index: index as u32, + }, + output.clone(), + ); + } +} + +fn genesis_allocation_outpoint(address: &str) -> OutPoint { + OutPoint { + txid: hex_hash(format!("iuna-genesis-allocation:{address}")), + index: 0, + } +} + +fn reward_outpoint(block_hash: &str) -> OutPoint { + OutPoint { + txid: block_hash.to_string(), + index: u32::MAX, + } +} + +fn blinded_committer_fee_outpoint(commitment: &str) -> OutPoint { + OutPoint { + txid: commitment.to_string(), + index: u32::MAX - 1, + } +} + +fn blinded_executor_fee_outpoint(commitment: &str) -> OutPoint { + OutPoint { + txid: commitment.to_string(), + index: u32::MAX - 2, + } +} + +fn blinded_reveal_bundle_signer_fee_outpoint(commitment: &str, slot: u8) -> OutPoint { + OutPoint { + txid: commitment.to_string(), + index: u32::MAX - 3 - u32::from(slot), + } +} + +fn blinded_expiry_change_outpoint(commitment: &str) -> OutPoint { + OutPoint { + txid: commitment.to_string(), + index: 0, + } +} + +fn blinded_fee_share(fee: Amount, bps: u64) -> Amount { + ((fee as u128 * bps as u128) / BLINDED_FEE_BPS_DENOMINATOR as u128) as Amount +} diff --git a/src/domain/transaction.rs b/src/domain/transaction.rs @@ -111,7 +111,8 @@ pub struct OwnedBlindedTransaction { pub reveal: BlindedReveal, } -#[derive(Clone, Debug, Eq, PartialEq)] +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] pub struct RevealedBlindedTransaction { pub height: u64, pub commitment: String,