commit 81e4e34090872bd7d361d0583adef3c552396e6f
parent 69cbfa488b0ed867a426b6c0e27cd75a2b548ab6
Author: Joris Hartog <jorishartog@hotmail.com>
Date: Tue, 11 Aug 2026 09:14:02 +0200
Persist UI chain indexes
Diffstat:
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,