commit 4d92bc1360e4978790227101c69f2490a59bc030
parent 13f545b80710df2ced156b7c8b321f58dc157d06
Author: Joris Hartog <jorishartog@hotmail.com>
Date: Tue, 11 Aug 2026 13:57:56 +0200
Improve UI data cache consistency
Diffstat:
30 files changed, 3269 insertions(+), 1683 deletions(-)
diff --git a/Cargo.lock b/Cargo.lock
@@ -537,7 +537,7 @@ checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]]
name = "iuna"
-version = "0.2.46"
+version = "0.2.47"
dependencies = [
"anyhow",
"axum",
diff --git a/Cargo.toml b/Cargo.toml
@@ -1,6 +1,6 @@
[package]
name = "iuna"
-version = "0.2.46"
+version = "0.2.47"
edition = "2024"
license = "Apache-2.0"
diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock
@@ -1511,7 +1511,7 @@ checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]]
name = "iuna-desktop"
-version = "0.2.46"
+version = "0.2.47"
dependencies = [
"tauri",
"tauri-build",
diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml
@@ -1,6 +1,6 @@
[package]
name = "iuna-desktop"
-version = "0.2.46"
+version = "0.2.47"
edition = "2024"
publish = false
diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "iuna",
- "version": "0.2.46",
+ "version": "0.2.47",
"identifier": "labs.iuna.desktop",
"build": {
"beforeDevCommand": "",
diff --git a/src/adapters/chain_store.rs b/src/adapters/chain_store.rs
@@ -1,5 +1,4 @@
use std::{
- collections::{BTreeMap, BTreeSet},
fs,
path::{Path, PathBuf},
time::{SystemTime, UNIX_EPOCH},
@@ -8,21 +7,10 @@ use std::{
use anyhow::{Context, Result};
use rusqlite::{Connection, OptionalExtension, params};
-use serde::Serialize;
-
-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,
- },
-};
+use crate::domain::ChainSnapshot;
mod compact;
-use compact::{blinded_fee_share, decode_compact_snapshot, encode_compact_snapshot};
+use compact::{decode_compact_snapshot, encode_compact_snapshot};
const SCHEMA: &str = r#"
CREATE TABLE IF NOT EXISTS chain_snapshots (
@@ -32,92 +20,8 @@ CREATE TABLE IF NOT EXISTS chain_snapshots (
snapshot_blob BLOB NOT NULL,
updated_at_ms INTEGER NOT NULL
);
-
-CREATE TABLE IF NOT EXISTS block_metrics (
- height INTEGER PRIMARY KEY,
- block_hash TEXT NOT NULL,
- timestamp_ms INTEGER NOT NULL,
- block_time_ms INTEGER,
- mine_difficulty_bits INTEGER NOT NULL,
- circulating_supply INTEGER NOT NULL,
- known_wallet_addresses INTEGER NOT NULL DEFAULT 0,
- transaction_count INTEGER NOT NULL,
- transfer_count INTEGER NOT NULL,
- burn_count INTEGER NOT NULL,
- mine_count INTEGER NOT NULL,
- burned_amount INTEGER NOT NULL,
- total_burned_amount INTEGER NOT NULL,
- fees_amount INTEGER NOT NULL,
- reward_amount INTEGER NOT NULL,
- 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 {
- pub height: u64,
- pub block_hash: String,
- pub timestamp_ms: u64,
- pub block_time_ms: Option<u64>,
- pub mine_difficulty_bits: u32,
- pub circulating_supply: Amount,
- pub known_wallet_addresses: u64,
- pub transaction_count: u64,
- pub transfer_count: u64,
- pub burn_count: u64,
- pub mine_count: u64,
- pub burned_amount: Amount,
- pub total_burned_amount: Amount,
- pub fees_amount: Amount,
- pub reward_amount: Amount,
- pub vdf_rounds: u64,
- pub finalizer_rank: u32,
-}
-
#[derive(Clone, Debug)]
pub struct SqliteChainStore {
path: PathBuf,
@@ -136,15 +40,10 @@ impl SqliteChainStore {
}
let store = Self { path };
- store.with_connection(|connection| {
+ store.with_connection_mut(|connection| {
connection
.execute_batch(SCHEMA)
.context("failed to initialize chain database schema")?;
- ensure_block_metrics_column(
- connection,
- "known_wallet_addresses",
- "INTEGER NOT NULL DEFAULT 0",
- )?;
Ok(())
})?;
Ok(store)
@@ -174,47 +73,11 @@ 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)
- }
-
- pub fn save_with_metrics(&self, snapshot: &ChainSnapshot, keep_metrics: bool) -> Result<()> {
let (height, tip_hash) = snapshot_tip(snapshot).context("cannot persist empty chain")?;
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 {
- None
- };
self.with_connection_mut(|connection| {
let transaction = connection
@@ -234,11 +97,6 @@ ON CONFLICT(id) DO UPDATE SET
params![height, tip_hash, snapshot_blob, updated_at_ms],
)
.context("failed to persist chain snapshot")?;
- match metrics {
- 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")?;
@@ -246,29 +104,6 @@ ON CONFLICT(id) DO UPDATE SET
})
}
- pub fn replace_metrics_for_snapshot(&self, snapshot: &ChainSnapshot) -> Result<()> {
- let metrics = metrics_from_snapshot(snapshot)?;
- self.with_connection_mut(|connection| {
- let transaction = connection
- .transaction()
- .context("failed to start metrics transaction")?;
- replace_metrics(&transaction, &metrics)?;
- transaction
- .commit()
- .context("failed to commit metrics transaction")?;
- Ok(())
- })
- }
-
- pub fn clear_metrics(&self) -> Result<()> {
- self.with_connection(|connection| {
- connection
- .execute("DELETE FROM block_metrics", [])
- .context("failed to delete block metrics")?;
- Ok(())
- })
- }
-
pub fn clear_chain(&self) -> Result<()> {
self.with_connection_mut(|connection| {
let transaction = connection
@@ -277,8 +112,6 @@ ON CONFLICT(id) DO UPDATE SET
transaction
.execute("DELETE FROM chain_snapshots", [])
.context("failed to delete chain snapshot")?;
- clear_metrics_in_transaction(&transaction)?;
- clear_ui_chain_index_in_transaction(&transaction)?;
transaction
.commit()
.context("failed to commit chain reset transaction")?;
@@ -286,898 +119,36 @@ ON CONFLICT(id) DO UPDATE SET
})
}
- pub fn load_metrics(&self) -> Result<Vec<BlockMetricRow>> {
- self.with_connection(|connection| {
- let mut statement = connection
- .prepare(
- r#"
-SELECT height, block_hash, timestamp_ms, block_time_ms, mine_difficulty_bits,
- circulating_supply, known_wallet_addresses, transaction_count, transfer_count, burn_count,
- mine_count, burned_amount, total_burned_amount, fees_amount, reward_amount,
- vdf_rounds, finalizer_rank
-FROM block_metrics
-ORDER BY height ASC
-"#,
- )
- .context("failed to prepare block metrics query")?;
- let rows = statement
- .query_map([], |row| {
- Ok(BlockMetricRow {
- height: row.get(0)?,
- block_hash: row.get(1)?,
- timestamp_ms: row.get(2)?,
- block_time_ms: row.get(3)?,
- mine_difficulty_bits: row.get(4)?,
- circulating_supply: row.get(5)?,
- known_wallet_addresses: row.get(6)?,
- transaction_count: row.get(7)?,
- transfer_count: row.get(8)?,
- burn_count: row.get(9)?,
- mine_count: row.get(10)?,
- burned_amount: row.get(11)?,
- total_burned_amount: row.get(12)?,
- fees_amount: row.get(13)?,
- reward_amount: row.get(14)?,
- vdf_rounds: row.get(15)?,
- finalizer_rank: row.get(16)?,
- })
- })
- .context("failed to load block metrics")?;
- rows.collect::<std::result::Result<Vec<_>, _>>()
- .context("failed to read block metrics rows")
- })
- }
-
- pub fn load_recent_metrics(&self, limit: usize) -> Result<Vec<BlockMetricRow>> {
- self.with_connection(|connection| {
- let mut statement = connection
- .prepare(
- r#"
-SELECT height, block_hash, timestamp_ms, block_time_ms, mine_difficulty_bits,
- circulating_supply, known_wallet_addresses, transaction_count, transfer_count, burn_count,
- mine_count, burned_amount, total_burned_amount, fees_amount, reward_amount,
- vdf_rounds, finalizer_rank
-FROM block_metrics
-ORDER BY height DESC
-LIMIT ?1
-"#,
- )
- .context("failed to prepare recent block metrics query")?;
- let rows = statement
- .query_map([limit as u64], |row| {
- Ok(BlockMetricRow {
- height: row.get(0)?,
- block_hash: row.get(1)?,
- timestamp_ms: row.get(2)?,
- block_time_ms: row.get(3)?,
- mine_difficulty_bits: row.get(4)?,
- circulating_supply: row.get(5)?,
- known_wallet_addresses: row.get(6)?,
- transaction_count: row.get(7)?,
- transfer_count: row.get(8)?,
- burn_count: row.get(9)?,
- mine_count: row.get(10)?,
- burned_amount: row.get(11)?,
- total_burned_amount: row.get(12)?,
- fees_amount: row.get(13)?,
- reward_amount: row.get(14)?,
- vdf_rounds: row.get(15)?,
- finalizer_rank: row.get(16)?,
- })
- })
- .context("failed to load recent block metrics")?;
- let mut rows = rows
- .collect::<std::result::Result<Vec<_>, _>>()
- .context("failed to read recent block metrics rows")?;
- rows.reverse();
- Ok(rows)
- })
- }
-
fn with_connection<T>(&self, work: impl FnOnce(&Connection) -> Result<T>) -> Result<T> {
- let connection = Connection::open(&self.path)
- .with_context(|| format!("failed to open chain database {}", self.path.display()))?;
+ let connection = self.open_connection()?;
connection
.execute_batch(
r#"
-PRAGMA journal_mode = WAL;
+PRAGMA busy_timeout = 5000;
PRAGMA synchronous = NORMAL;
"#,
)
- .context("failed to configure chain database")?;
+ .context("failed to configure chain database connection")?;
work(&connection)
}
fn with_connection_mut<T>(&self, work: impl FnOnce(&mut Connection) -> Result<T>) -> Result<T> {
- let mut connection = Connection::open(&self.path)
- .with_context(|| format!("failed to open chain database {}", self.path.display()))?;
+ let mut connection = self.open_connection()?;
connection
.execute_batch(
r#"
PRAGMA journal_mode = WAL;
+PRAGMA busy_timeout = 5000;
PRAGMA synchronous = NORMAL;
"#,
)
- .context("failed to configure chain database")?;
+ .context("failed to configure chain database connection")?;
work(&mut connection)
}
-}
-
-fn replace_metrics(
- transaction: &rusqlite::Transaction<'_>,
- metrics: &[BlockMetricRow],
-) -> Result<()> {
- clear_metrics_in_transaction(transaction)?;
- for metric in metrics {
- transaction
- .execute(
- r#"
-INSERT INTO block_metrics (
- height, block_hash, timestamp_ms, block_time_ms, mine_difficulty_bits,
- circulating_supply, known_wallet_addresses, transaction_count, transfer_count, burn_count,
- mine_count, burned_amount, total_burned_amount, fees_amount, reward_amount, vdf_rounds,
- finalizer_rank
-) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17)
-"#,
- params![
- metric.height,
- metric.block_hash,
- metric.timestamp_ms,
- metric.block_time_ms,
- metric.mine_difficulty_bits,
- metric.circulating_supply,
- metric.known_wallet_addresses,
- metric.transaction_count,
- metric.transfer_count,
- metric.burn_count,
- metric.mine_count,
- metric.burned_amount,
- metric.total_burned_amount,
- metric.fees_amount,
- metric.reward_amount,
- metric.vdf_rounds,
- metric.finalizer_rank,
- ],
- )
- .with_context(|| format!("failed to insert metrics for block {}", metric.height))?;
- }
- Ok(())
-}
-
-fn ensure_block_metrics_column(
- connection: &Connection,
- name: &str,
- definition: &str,
-) -> Result<()> {
- let mut statement = connection
- .prepare("PRAGMA table_info(block_metrics)")
- .context("failed to inspect block_metrics schema")?;
- let columns = statement
- .query_map([], |row| row.get::<_, String>(1))
- .context("failed to query block_metrics columns")?
- .collect::<std::result::Result<Vec<_>, _>>()
- .context("failed to read block_metrics columns")?;
- if columns.iter().any(|column| column == name) {
- return Ok(());
- }
- connection
- .execute(
- &format!("ALTER TABLE block_metrics ADD COLUMN {name} {definition}"),
- [],
- )
- .with_context(|| format!("failed to add block_metrics.{name} column"))?;
- Ok(())
-}
-
-fn clear_metrics_in_transaction(transaction: &rusqlite::Transaction<'_>) -> Result<()> {
- transaction
- .execute("DELETE FROM block_metrics", [])
- .context("failed to clear old block metrics")?;
- 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")?;
- let genesis = snapshot
- .blocks
- .first()
- .cloned()
- .context("cannot compute metrics for empty chain snapshot")?;
- let mut running_ledger = Ledger::from_persisted_snapshot(ChainSnapshot {
- genesis_allocations: snapshot.genesis_allocations.clone(),
- vdf_rounds: snapshot.vdf_rounds,
- launch_profile: snapshot.launch_profile.clone(),
- blocks: vec![genesis],
- })
- .context("failed to rebuild genesis ledger for metrics")?;
- let revealed = revealed_blinded_transactions(snapshot)?.into_iter().fold(
- BTreeMap::<u64, Vec<crate::domain::RevealedBlindedTransaction>>::new(),
- |mut by_height, revealed| {
- by_height.entry(revealed.height).or_default().push(revealed);
- by_height
- },
- );
- let mut known_wallet_addresses = snapshot
- .genesis_allocations
- .keys()
- .cloned()
- .collect::<BTreeSet<_>>();
- let mut total_burned_amount = 0_u64;
- let mut rows = Vec::with_capacity(snapshot.blocks.len());
- let mut previous_timestamp_ms = None;
- 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();
- let mut transfer_count = 0_u64;
- let mut burn_count = 0_u64;
- let mut mine_count = 0_u64;
- let mut burned_amount = 0_u64;
- let mut burned_fee_amount = 0_u64;
- let mut fees_amount = 0_u64;
-
- known_wallet_addresses.insert(block.miner.clone());
- for signature in &block.reveal_bundle_section.signatures {
- known_wallet_addresses.insert(signature.member.clone());
- }
- for transaction in &block.transactions {
- collect_transaction_addresses(transaction, &mut known_wallet_addresses);
- metric_apply_public_transaction(transaction, &mut metric_utxos)?;
- fees_amount = fees_amount
- .checked_add(transaction.fee())
- .context("block metric fees overflow")?;
- match transaction {
- Transaction::Transfer { .. } => transfer_count += 1,
- Transaction::Burn { amount, .. } => {
- burn_count += 1;
- burned_amount = burned_amount
- .checked_add(*amount)
- .context("block metric burns overflow")?;
- }
- Transaction::Mine { .. } => {
- mine_count += 1;
- }
- }
- }
- for revealed in &revealed_transactions {
- let transaction = &revealed.transaction;
- known_wallet_addresses.insert(revealed.included_by.clone());
- collect_transaction_addresses(transaction, &mut known_wallet_addresses);
- metric_index_transaction_outputs(&mut metric_utxos, transaction);
- metric_index_blinded_fee_outputs(
- &mut metric_utxos,
- &revealed.commitment,
- &revealed.included_by,
- block,
- transaction.fee(),
- reveal_bundle_slots_by_height
- .get(&block.height)
- .copied()
- .unwrap_or(REVEAL_COMMITTEE_SIZE),
- );
- fees_amount = fees_amount
- .checked_add(transaction.fee())
- .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 = reveal_bundle_slots_by_height
- .get(&block.height)
- .copied()
- .unwrap_or(REVEAL_COMMITTEE_SIZE);
- let reveal_finalizer_fee = blinded_reveal_finalizer_fee(
- transaction.fee(),
- included_reveal_bundle_count,
- available_reveal_bundle_slots,
- );
- let reveal_bundle_signer_fees =
- blinded_fee_share(transaction.fee(), BLINDED_REVEAL_BUNDLE_SIGNER_FEE_BPS)
- .saturating_mul(included_reveal_bundle_count as u64);
- let distributed_fee = committer_fee
- .saturating_add(reveal_finalizer_fee)
- .saturating_add(reveal_bundle_signer_fees);
- burned_fee_amount = burned_fee_amount
- .checked_add(transaction.fee().saturating_sub(distributed_fee))
- .context("block metric burned fees overflow")?;
- match transaction {
- Transaction::Transfer { .. } => transfer_count += 1,
- Transaction::Burn { amount, .. } => {
- burn_count += 1;
- burned_amount = burned_amount
- .checked_add(*amount)
- .context("block metric burns overflow")?;
- }
- Transaction::Mine { .. } => {
- mine_count += 1;
- }
- }
- }
- let revealed_commitments = block
- .all_blinded_reveals()
- .into_iter()
- .map(|reveal| reveal.commitment.clone())
- .collect::<std::collections::BTreeSet<_>>();
- let mut expired_blinded_fee_values = Vec::new();
- active_blinded.retain(|commitment, transaction| {
- if revealed_commitments.contains(commitment) {
- metric_locked_blinded_inputs.remove(commitment);
- return false;
- }
- if block.height >= transaction.expires_at_height {
- if !transaction.inputs.is_empty() {
- expired_blinded_fee_values.push(transaction.fee);
- metric_index_expired_blinded_change(
- &mut metric_utxos,
- commitment,
- transaction,
- metric_locked_blinded_inputs
- .remove(commitment)
- .unwrap_or_default(),
- );
- }
- return false;
- }
- true
- });
- let expired_blinded_fees =
- expired_blinded_fee_values
- .into_iter()
- .try_fold(0_u64, |total, fee| {
- total
- .checked_add(fee)
- .context("block metric expiry fees overflow")
- })?;
- fees_amount = fees_amount
- .checked_add(expired_blinded_fees)
- .context("block metric expiry fees overflow")?;
- burned_fee_amount = burned_fee_amount
- .checked_add(expired_blinded_fees)
- .context("block metric expired burned fees overflow")?;
- for transaction in &block.blinded_transactions {
- for input in &transaction.inputs {
- known_wallet_addresses.insert(input.owner.clone());
- }
- let locked_total = metric_spend_blinded_inputs(transaction, &mut metric_utxos)?;
- metric_locked_blinded_inputs.insert(transaction.commitment.clone(), locked_total);
- active_blinded.insert(transaction.commitment.clone(), transaction.clone());
- }
- total_burned_amount = total_burned_amount
- .checked_add(burned_amount)
- .and_then(|amount| amount.checked_add(burned_fee_amount))
- .context("total burned metric overflows")?;
-
- if block.height > 0 {
- running_ledger
- .apply_preverified_block_at(block.clone(), u64::MAX)
- .with_context(|| format!("failed to replay block {} for metrics", block.height))?;
- }
- metric_index_block_reward(&mut metric_utxos, block);
- let circulating_supply = ledger_circulating_supply(&running_ledger)?
- .checked_add(metric_locked_supply(&metric_locked_blinded_inputs)?)
- .context("circulating supply metric overflows")?;
- let block_time_ms =
- previous_timestamp_ms.map(|previous| block.timestamp_ms.saturating_sub(previous));
- previous_timestamp_ms = Some(block.timestamp_ms);
- rows.push(BlockMetricRow {
- height: block.height,
- block_hash: block.hash.clone(),
- timestamp_ms: block.timestamp_ms,
- block_time_ms,
- mine_difficulty_bits: ledger.mine_difficulty_bits_at_height(block.height),
- circulating_supply,
- known_wallet_addresses: known_wallet_addresses.len() as u64,
- transaction_count: (block.transactions.len() + revealed_transactions.len()) as u64,
- transfer_count,
- burn_count,
- mine_count,
- burned_amount,
- total_burned_amount,
- fees_amount,
- reward_amount: block.reward,
- vdf_rounds: block.vdf_rounds,
- finalizer_rank: block.finalizer_rank,
- });
- }
- Ok(rows)
-}
-
-fn ledger_circulating_supply(ledger: &Ledger) -> Result<Amount> {
- ledger
- .status()
- .balances
- .values()
- .try_fold(0_u64, |total, amount| {
- total
- .checked_add(*amount)
- .context("circulating supply metric overflows")
- })
-}
-
-fn metric_locked_supply(locked: &BTreeMap<String, Amount>) -> Result<Amount> {
- locked.values().try_fold(0_u64, |total, amount| {
- total
- .checked_add(*amount)
- .context("circulating supply metric overflows")
- })
-}
-
-fn metric_genesis_utxos(snapshot: &ChainSnapshot) -> BTreeMap<OutPoint, TxOutput> {
- snapshot
- .genesis_allocations
- .iter()
- .filter(|(_, amount)| **amount > 0)
- .map(|(address, amount)| {
- (
- metric_genesis_allocation_outpoint(address),
- TxOutput {
- address: address.clone(),
- amount: *amount,
- },
- )
- })
- .collect()
-}
-
-fn metric_apply_public_transaction(
- transaction: &Transaction,
- utxos: &mut BTreeMap<OutPoint, TxOutput>,
-) -> Result<()> {
- metric_spend_transaction_inputs(transaction, utxos)?;
- metric_index_transaction_outputs(utxos, transaction);
- Ok(())
-}
-
-fn metric_spend_transaction_inputs(
- transaction: &Transaction,
- utxos: &mut BTreeMap<OutPoint, TxOutput>,
-) -> Result<Amount> {
- let inputs = match transaction {
- Transaction::Transfer { inputs, .. } | Transaction::Burn { inputs, .. } => inputs,
- Transaction::Mine { .. } => return Ok(0),
- };
- metric_spend_inputs(inputs, utxos)
-}
-
-fn metric_spend_blinded_inputs(
- transaction: &BlindedTransaction,
- utxos: &mut BTreeMap<OutPoint, TxOutput>,
-) -> Result<Amount> {
- metric_spend_inputs(&transaction.inputs, utxos)
-}
-
-fn metric_spend_inputs(
- inputs: &[TxInput],
- utxos: &mut BTreeMap<OutPoint, TxOutput>,
-) -> Result<Amount> {
- inputs.iter().try_fold(0_u64, |total, input| {
- let output = utxos.remove(&input.outpoint).with_context(|| {
- format!(
- "metric replay spends missing output {}:{}",
- input.outpoint.txid, input.outpoint.index
- )
- })?;
- total
- .checked_add(output.amount)
- .context("metric replay input total overflows")
- })
-}
-
-fn metric_index_transaction_outputs(
- utxos: &mut BTreeMap<OutPoint, TxOutput>,
- transaction: &Transaction,
-) {
- let 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 outputs.iter().enumerate() {
- utxos.insert(
- OutPoint {
- txid: transaction.signature().to_string(),
- index: index as u32,
- },
- output.clone(),
- );
- }
-}
-
-fn metric_index_blinded_fee_outputs(
- utxos: &mut BTreeMap<OutPoint, TxOutput>,
- commitment: &str,
- included_by: &str,
- block: &Block,
- fee: Amount,
- available_reveal_bundle_slots: usize,
-) {
- if fee == 0 {
- return;
- }
- let committer_fee = blinded_fee_share(fee, BLINDED_COMMITTER_FEE_BPS);
- if committer_fee > 0 {
- utxos.insert(
- metric_blinded_committer_fee_outpoint(commitment),
- TxOutput {
- address: included_by.to_string(),
- amount: committer_fee,
- },
- );
- }
- let reveal_finalizer_fee = blinded_reveal_finalizer_fee(
- fee,
- block.included_reveal_bundle_count(),
- available_reveal_bundle_slots,
- );
- if reveal_finalizer_fee > 0 && block.height < AGGREGATE_FINALIZER_FEE_ACTIVATION_HEIGHT {
- utxos.insert(
- metric_blinded_executor_fee_outpoint(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 {
- utxos.insert(
- metric_blinded_reveal_bundle_signer_fee_outpoint(commitment, signature.slot),
- TxOutput {
- address: signature.member.clone(),
- amount: reveal_bundle_signer_fee,
- },
- );
- }
- }
-}
-
-fn metric_index_expired_blinded_change(
- utxos: &mut BTreeMap<OutPoint, TxOutput>,
- commitment: &str,
- transaction: &BlindedTransaction,
- locked_total: Amount,
-) {
- let Some(first_input) = transaction.inputs.first() else {
- return;
- };
- let change = locked_total.saturating_sub(transaction.fee);
- if change == 0 {
- return;
- }
- utxos.insert(
- metric_blinded_expiry_change_outpoint(commitment),
- TxOutput {
- address: first_input.owner.clone(),
- amount: change,
- },
- );
-}
-
-fn metric_index_block_reward(utxos: &mut BTreeMap<OutPoint, TxOutput>, block: &Block) {
- if block.reward == 0 {
- return;
- }
- utxos.insert(
- metric_reward_outpoint(&block.hash),
- TxOutput {
- address: block.miner.clone(),
- amount: block.reward,
- },
- );
-}
-
-fn metric_genesis_allocation_outpoint(address: &str) -> OutPoint {
- OutPoint {
- txid: hex_hash(format!("iuna-genesis-allocation:{address}")),
- index: 0,
- }
-}
-
-fn metric_reward_outpoint(block_hash: &str) -> OutPoint {
- OutPoint {
- txid: block_hash.to_string(),
- index: u32::MAX,
- }
-}
-
-fn metric_blinded_committer_fee_outpoint(commitment: &str) -> OutPoint {
- OutPoint {
- txid: commitment.to_string(),
- index: u32::MAX - 1,
- }
-}
-
-fn metric_blinded_executor_fee_outpoint(commitment: &str) -> OutPoint {
- OutPoint {
- txid: commitment.to_string(),
- index: u32::MAX - 2,
- }
-}
-
-fn metric_blinded_reveal_bundle_signer_fee_outpoint(commitment: &str, slot: u8) -> OutPoint {
- OutPoint {
- txid: commitment.to_string(),
- index: u32::MAX - 3 - u32::from(slot),
- }
-}
-
-fn metric_blinded_expiry_change_outpoint(commitment: &str) -> OutPoint {
- OutPoint {
- txid: commitment.to_string(),
- index: 0,
- }
-}
-
-fn collect_transaction_addresses(transaction: &Transaction, addresses: &mut BTreeSet<String>) {
- match transaction {
- Transaction::Transfer {
- inputs, outputs, ..
- } => {
- collect_input_addresses(inputs, addresses);
- collect_output_addresses(outputs, addresses);
- }
- Transaction::Burn { inputs, change, .. } => {
- collect_input_addresses(inputs, addresses);
- collect_output_addresses(change, addresses);
- }
- Transaction::Mine { recipient, .. } => {
- addresses.insert(recipient.clone());
- }
- }
-}
-
-fn collect_input_addresses(inputs: &[TxInput], addresses: &mut BTreeSet<String>) {
- for input in inputs {
- addresses.insert(input.owner.clone());
- }
-}
-fn collect_output_addresses(outputs: &[TxOutput], addresses: &mut BTreeSet<String>) {
- for output in outputs {
- addresses.insert(output.address.clone());
+ fn open_connection(&self) -> Result<Connection> {
+ Connection::open(&self.path)
+ .with_context(|| format!("failed to open chain database {}", self.path.display()))
}
}
diff --git a/src/adapters/chain_store/compact.rs b/src/adapters/chain_store/compact.rs
@@ -1,9 +1,9 @@
use anyhow::{Context, Result, bail};
use crate::domain::{
- Amount, BLINDED_FEE_BPS_DENOMINATOR, BlindedReveal, BlindedTransaction, Block, ChainSnapshot,
- FinalizerMode, LaunchProfile, LeaderProof, MaskedBlindedReveal, OutPoint, RevealBundleSection,
- RevealBundleSignature, Transaction, TxInput, TxOutput,
+ BlindedReveal, BlindedTransaction, Block, ChainSnapshot, FinalizerMode, LaunchProfile,
+ LeaderProof, MaskedBlindedReveal, OutPoint, RevealBundleSection, RevealBundleSignature,
+ Transaction, TxInput, TxOutput,
};
const COMPACT_SNAPSHOT_MAGIC: &[u8] = b"IUNA-SNAPSHOT";
@@ -215,10 +215,6 @@ fn decode_blinded_reveal(reader: &mut CompactReader<'_>) -> Result<BlindedReveal
})
}
-pub(super) fn blinded_fee_share(fee: Amount, bps: u64) -> Amount {
- ((fee as u128 * bps as u128) / BLINDED_FEE_BPS_DENOMINATOR as u128) as Amount
-}
-
fn encode_reveal_bundle_section(
writer: &mut CompactWriter,
section: &RevealBundleSection,
diff --git a/src/adapters/chain_store/tests.rs b/src/adapters/chain_store/tests.rs
@@ -1,17 +1,10 @@
use std::collections::BTreeMap;
-use rusqlite::Connection;
use tempfile::tempdir;
-use crate::{
- adapters::ui_index::build_ui_chain_index,
- domain::{BLOCK_REWARD, GenesisBurn, Ledger, Wallet, run_vdf},
-};
+use crate::domain::{GenesisBurn, Ledger, Wallet, run_vdf};
-use super::{
- BlockMetricRow, SqliteChainStore, decode_compact_snapshot, encode_compact_snapshot,
- replace_metrics,
-};
+use super::{SqliteChainStore, decode_compact_snapshot, encode_compact_snapshot};
#[test]
fn sqlite_chain_store_roundtrips_snapshot() {
@@ -41,33 +34,34 @@ fn sqlite_chain_store_roundtrips_snapshot() {
}
#[test]
-fn sqlite_chain_store_persists_ui_chain_index_for_latest_tip() {
+fn sqlite_chain_store_does_not_create_ui_projection_tables() {
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()
- );
+ store
+ .with_connection(|connection| {
+ for table in [
+ "block_metrics",
+ "ui_cache_meta",
+ "ui_output_index",
+ "ui_revealed_transactions",
+ "ui_burn_leader_ranks",
+ "ui_burn_leader_rank_blocks",
+ ] {
+ let count = connection.query_row(
+ "SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = ?1",
+ [table],
+ |row| row.get::<_, u64>(0),
+ )?;
+ assert_eq!(count, 0, "{table} should live in ui_data.sqlite3");
+ }
+ Ok(())
+ })
+ .unwrap();
}
#[test]
-fn sqlite_chain_store_clear_chain_removes_snapshot_metrics_and_ui_indexes() {
+fn sqlite_chain_store_clear_chain_removes_snapshot() {
let dir = tempdir().unwrap();
let store = SqliteChainStore::open(dir.path().join("chain.sqlite3")).unwrap();
let wallet = Wallet::from_seed("clear-chain-alice");
@@ -76,19 +70,13 @@ fn sqlite_chain_store_clear_chain_removes_snapshot_metrics_and_ui_indexes() {
let ledger =
Ledger::new_with_genesis_burns(genesis, vec![GenesisBurn::new(wallet.address(), 1)], 1)
.unwrap();
- let expected = build_ui_chain_index(&ledger.snapshot());
- let tip_hash = expected.tip_hash.as_deref().unwrap();
- store.save_with_metrics(&ledger.snapshot(), true).unwrap();
+ store.save(&ledger.snapshot()).unwrap();
assert!(store.load().unwrap().is_some());
- assert!(!store.load_metrics().unwrap().is_empty());
- assert!(store.load_ui_chain_index(tip_hash).unwrap().is_some());
store.clear_chain().unwrap();
assert!(store.load().unwrap().is_none());
- assert!(store.load_metrics().unwrap().is_empty());
- assert!(store.load_ui_chain_index(tip_hash).unwrap().is_none());
}
#[test]
@@ -194,333 +182,6 @@ fn sqlite_chain_store_overwrites_latest_snapshot() {
}
#[test]
-fn sqlite_chain_store_saves_and_clears_block_metrics() {
- let dir = tempdir().unwrap();
- let store = SqliteChainStore::open(dir.path().join("chain.sqlite3")).unwrap();
- let wallet = Wallet::from_seed("metrics-alice");
- let mut genesis = BTreeMap::new();
- genesis.insert(wallet.address().to_string(), 10);
- let mut ledger =
- Ledger::new_with_genesis_burns(genesis, vec![GenesisBurn::new(wallet.address(), 1)], 1)
- .unwrap();
- let burn = ledger.build_burn(&wallet, 2, 1).unwrap();
- ledger.submit_transaction(burn).unwrap();
- let block = ledger.mine_next_block(&wallet, 1_000).unwrap();
- ledger.apply_locally_mined_block(block).unwrap();
-
- store.save_with_metrics(&ledger.snapshot(), true).unwrap();
- let metrics = store.load_metrics().unwrap();
-
- assert_eq!(metrics.last().unwrap().height, 1);
- assert_eq!(metrics.last().unwrap().burn_count, 1);
- assert_eq!(metrics.last().unwrap().burned_amount, 2);
- assert_eq!(metrics.last().unwrap().fees_amount, 1);
- assert_eq!(
- metrics.last().unwrap().circulating_supply,
- ledger.status().balances.values().copied().sum::<u64>()
- );
- assert_eq!(metrics.last().unwrap().known_wallet_addresses, 1);
-
- store.clear_metrics().unwrap();
- assert!(store.load_metrics().unwrap().is_empty());
-}
-
-#[test]
-fn sqlite_chain_store_loads_recent_metrics_in_height_order() {
- let dir = tempdir().unwrap();
- let store = SqliteChainStore::open(dir.path().join("chain.sqlite3")).unwrap();
- let wallet = Wallet::from_seed("recent-metrics-alice");
- let mut genesis = BTreeMap::new();
- genesis.insert(wallet.address().to_string(), 10);
- let mut ledger =
- Ledger::new_with_genesis_burns(genesis, vec![GenesisBurn::new(wallet.address(), 1)], 1)
- .unwrap();
-
- for timestamp_ms in [1_000, 2_000, 3_000] {
- let burn = ledger.build_burn(&wallet, 1, 0).unwrap();
- ledger.submit_transaction(burn).unwrap();
- let block = ledger.mine_next_block(&wallet, timestamp_ms).unwrap();
- ledger.apply_locally_mined_block(block).unwrap();
- }
- store.save_with_metrics(&ledger.snapshot(), true).unwrap();
-
- let metrics = store.load_recent_metrics(2).unwrap();
-
- assert_eq!(
- metrics.iter().map(|row| row.height).collect::<Vec<_>>(),
- vec![2, 3]
- );
-}
-
-#[test]
-fn sqlite_chain_store_migrates_known_wallet_address_metrics_column() {
- let dir = tempdir().unwrap();
- let path = dir.path().join("chain.sqlite3");
- let connection = Connection::open(&path).unwrap();
- connection
- .execute_batch(
- r#"
-CREATE TABLE block_metrics (
- height INTEGER PRIMARY KEY,
- block_hash TEXT NOT NULL,
- timestamp_ms INTEGER NOT NULL,
- block_time_ms INTEGER,
- mine_difficulty_bits INTEGER NOT NULL,
- circulating_supply INTEGER NOT NULL,
- transaction_count INTEGER NOT NULL,
- transfer_count INTEGER NOT NULL,
- burn_count INTEGER NOT NULL,
- mine_count INTEGER NOT NULL,
- burned_amount INTEGER NOT NULL,
- total_burned_amount INTEGER NOT NULL,
- fees_amount INTEGER NOT NULL,
- reward_amount INTEGER NOT NULL,
- vdf_rounds INTEGER NOT NULL,
- finalizer_rank INTEGER NOT NULL
-);
-"#,
- )
- .unwrap();
- drop(connection);
-
- let store = SqliteChainStore::open(&path).unwrap();
-
- store
- .with_connection(|connection| {
- let count = connection
- .query_row(
- "SELECT COUNT(*) FROM pragma_table_info('block_metrics') WHERE name = 'known_wallet_addresses'",
- [],
- |row| row.get::<_, u64>(0),
- )
- .unwrap();
- assert_eq!(count, 1);
- Ok(())
- })
- .unwrap();
-}
-
-#[test]
-fn sqlite_chain_store_metrics_supply_matches_wallet_balances_after_block_rewards() {
- let dir = tempdir().unwrap();
- let store = SqliteChainStore::open(dir.path().join("chain.sqlite3")).unwrap();
- let alice = Wallet::from_seed("metrics-supply-alice");
- let mut genesis = BTreeMap::new();
- genesis.insert(alice.address().to_string(), 100);
- let mut ledger =
- Ledger::new_with_genesis_burns(genesis, vec![GenesisBurn::new(alice.address(), 1)], 1)
- .unwrap();
-
- for timestamp_ms in [1_000, 2_000, 3_000] {
- let burn = ledger.build_burn(&alice, 1, 1).unwrap();
- ledger.submit_transaction(burn).unwrap();
- let block = ledger.mine_next_block(&alice, timestamp_ms).unwrap();
- ledger.apply_locally_mined_block(block).unwrap();
- }
-
- store.save_with_metrics(&ledger.snapshot(), true).unwrap();
- let metrics = store.load_metrics().unwrap();
- let supply_from_balances = ledger.status().balances.values().copied().sum::<u64>();
-
- assert_eq!(metrics.last().unwrap().height, 3);
- assert_eq!(
- metrics.last().unwrap().circulating_supply,
- supply_from_balances
- );
-}
-
-#[test]
-fn sqlite_chain_store_metrics_count_known_wallet_addresses_seen_on_chain() {
- let dir = tempdir().unwrap();
- let store = SqliteChainStore::open(dir.path().join("chain.sqlite3")).unwrap();
- let alice = Wallet::from_seed("metrics-address-alice");
- let bob = Wallet::from_seed("metrics-address-bob");
- let carol = Wallet::from_seed("metrics-address-carol");
- let mut genesis = BTreeMap::new();
- genesis.insert(alice.address().to_string(), 100);
- let mut ledger =
- Ledger::new_with_genesis_burns(genesis, vec![GenesisBurn::new(alice.address(), 1)], 1)
- .unwrap();
-
- let burn = ledger.build_burn(&alice, 1, 1).unwrap();
- ledger.submit_transaction(burn).unwrap();
- let transfer = ledger.build_transfer(&alice, bob.address(), 10, 1).unwrap();
- ledger.submit_transaction(transfer).unwrap();
- let mine = ledger.build_mine(carol.address()).unwrap();
- ledger.submit_transaction(mine).unwrap();
- let block = ledger.mine_next_block(&alice, 1_000).unwrap();
- ledger.apply_locally_mined_block(block).unwrap();
-
- store.save_with_metrics(&ledger.snapshot(), true).unwrap();
- let metrics = store.load_metrics().unwrap();
-
- assert_eq!(metrics[0].known_wallet_addresses, 1);
- assert_eq!(metrics.last().unwrap().known_wallet_addresses, 3);
-}
-
-#[test]
-fn sqlite_chain_store_metrics_include_revealed_blinded_burns() {
- let dir = tempdir().unwrap();
- let store = SqliteChainStore::open(dir.path().join("chain.sqlite3")).unwrap();
- let alice = Wallet::from_seed("metrics-blinded-alice");
- let bob = Wallet::from_seed("metrics-blinded-bob");
- let carol = Wallet::from_seed("metrics-blinded-carol");
- let wallets = [alice.clone(), bob.clone()];
- let mut genesis = BTreeMap::new();
- genesis.insert(alice.address().to_string(), 10_000_000);
- genesis.insert(bob.address().to_string(), 10_000_000);
- genesis.insert(carol.address().to_string(), 10_000_000);
- let mut ledger = Ledger::new_with_genesis_burns(
- genesis,
- vec![
- GenesisBurn::new(alice.address(), 1_000_000),
- GenesisBurn::new(bob.address(), 1_000_000),
- ],
- 1,
- )
- .unwrap();
- let blinded = ledger
- .build_blinded_burn(&carol, 3, 7, ledger.height() + 4)
- .unwrap();
- ledger
- .submit_blinded_transaction(blinded.transaction)
- .unwrap();
- let leader = ledger.expected_leader_for_next_block().unwrap();
- let wallet = wallets
- .iter()
- .find(|wallet| wallet.address() == leader)
- .unwrap();
- let burn = ledger.build_burn(wallet, 1, 0).unwrap();
- ledger.submit_transaction(burn).unwrap();
- let block = ledger.mine_next_block(wallet, 1).unwrap();
- ledger.apply_locally_mined_block(block).unwrap();
- let supply_after_commit = ledger.status().balances.values().copied().sum::<u64>();
- ledger.submit_blinded_reveal(blinded.reveal).unwrap();
- let leader = ledger.expected_leader_for_next_block().unwrap();
- let wallet = wallets
- .iter()
- .find(|wallet| wallet.address() == leader)
- .unwrap();
- let burn = ledger.build_burn(wallet, 1, 0).unwrap();
- ledger.submit_transaction(burn).unwrap();
- let bundles = ledger
- .reveal_committee_for_next_block()
- .into_iter()
- .filter_map(|member| {
- let wallet = wallets
- .iter()
- .find(|wallet| wallet.address() == member.owner)
- .unwrap();
- ledger.build_reveal_bundle(wallet).unwrap()
- })
- .collect::<Vec<_>>();
- assert!(!bundles.is_empty());
- let prepared = ledger
- .prepare_next_block_with_reveal_bundles(wallet.address(), 2, bundles)
- .unwrap();
- let vdf_output = run_vdf(prepared.vdf_seed(), prepared.vdf_rounds());
- let block = prepared.finish(wallet, vdf_output);
- assert_eq!(block.all_blinded_reveals().len(), 1);
- ledger.apply_locally_mined_block(block).unwrap();
-
- store.save_with_metrics(&ledger.snapshot(), true).unwrap();
- let metrics = store.load_metrics().unwrap();
- let commit = metrics
- .iter()
- .find(|metric| metric.height == 1)
- .expect("commit block metrics should exist");
- let last = metrics.last().unwrap();
- let supply_from_balances = ledger.status().balances.values().copied().sum::<u64>();
-
- assert_eq!(commit.circulating_supply, supply_after_commit + 10_000_000);
- assert_eq!(last.burn_count, 2);
- assert_eq!(last.burned_amount, 4);
- assert_eq!(last.fees_amount, 7);
- assert_eq!(last.circulating_supply, supply_from_balances);
- assert_eq!(last.known_wallet_addresses, 3);
-}
-
-#[test]
-fn sqlite_chain_store_roundtrips_vdf_round_metrics_above_legacy_u32_limit() {
- let dir = tempdir().unwrap();
- let store = SqliteChainStore::open(dir.path().join("chain.sqlite3")).unwrap();
- let vdf_rounds = u64::from(u32::MAX) + 42;
-
- store
- .with_connection_mut(|connection| {
- let transaction = connection.transaction().unwrap();
- replace_metrics(
- &transaction,
- &[BlockMetricRow {
- height: 1,
- block_hash: "hash".to_string(),
- timestamp_ms: 1_000,
- block_time_ms: Some(415_000),
- mine_difficulty_bits: 12,
- circulating_supply: 100,
- known_wallet_addresses: 1,
- transaction_count: 0,
- transfer_count: 0,
- burn_count: 0,
- mine_count: 0,
- burned_amount: 0,
- total_burned_amount: 0,
- fees_amount: 0,
- reward_amount: 0,
- vdf_rounds,
- finalizer_rank: 0,
- }],
- )?;
- transaction.commit().unwrap();
- Ok(())
- })
- .unwrap();
-
- let metrics = store.load_metrics().unwrap();
- assert_eq!(metrics.len(), 1);
- assert_eq!(metrics[0].vdf_rounds, vdf_rounds);
-}
-
-#[test]
-fn sqlite_chain_store_metrics_include_genesis_reward_when_burn_consumes_allocation() {
- let dir = tempdir().unwrap();
- let store = SqliteChainStore::open(dir.path().join("chain.sqlite3")).unwrap();
- let wallet = Wallet::from_seed("metrics-genesis-reward");
- let mut genesis = BTreeMap::new();
- genesis.insert(wallet.address().to_string(), 1);
- let ledger =
- Ledger::new_with_genesis_burns(genesis, vec![GenesisBurn::new(wallet.address(), 1)], 1)
- .unwrap();
-
- store.save_with_metrics(&ledger.snapshot(), true).unwrap();
- let metrics = store.load_metrics().unwrap();
-
- assert_eq!(metrics.len(), 1);
- assert_eq!(metrics[0].height, 0);
- assert_eq!(metrics[0].burned_amount, 1);
- assert_eq!(metrics[0].reward_amount, BLOCK_REWARD);
- assert_eq!(metrics[0].circulating_supply, BLOCK_REWARD);
-}
-
-#[test]
-fn sqlite_chain_store_disabled_metrics_save_deletes_old_metrics() {
- let dir = tempdir().unwrap();
- let store = SqliteChainStore::open(dir.path().join("chain.sqlite3")).unwrap();
- let wallet = Wallet::from_seed("metrics-cleanup");
- 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();
-
- store.save_with_metrics(&ledger.snapshot(), true).unwrap();
- assert!(!store.load_metrics().unwrap().is_empty());
-
- store.save_with_metrics(&ledger.snapshot(), false).unwrap();
- assert!(store.load_metrics().unwrap().is_empty());
-}
-
-#[test]
fn sqlite_chain_store_reports_invalid_compact_snapshot() {
let dir = tempdir().unwrap();
let store = SqliteChainStore::open(dir.path().join("chain.sqlite3")).unwrap();
diff --git a/src/adapters/http.rs b/src/adapters/http.rs
@@ -2,14 +2,18 @@ use std::{
collections::BTreeMap,
net::SocketAddr,
sync::Arc,
- time::{SystemTime, UNIX_EPOCH},
+ time::{Instant, SystemTime, UNIX_EPOCH},
};
use anyhow::{Context, Result};
use axum::{
Form, Json, Router,
+ body::Body,
extract::State,
+ http::Request,
middleware,
+ middleware::Next,
+ response::Response,
routing::{get, post},
};
use tokio::{net::TcpListener, sync::Mutex};
@@ -69,9 +73,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, cached_chain_view_for_tip, ui_blinded_reveal,
+ add_pending_outputs, cached_chain_view, cached_ui_blocks_for_tip, ui_blinded_reveal,
ui_blinded_transaction, ui_blocks_from_indexes, ui_pending_revealed_transaction,
- ui_transaction, wallet_transaction_rows,
+ ui_transaction, wallet_transaction_row, wallet_transaction_rows,
};
#[cfg(test)]
use ui::{known_output_index, revealed_transactions_by_height, ui_block, ui_blocks};
@@ -94,13 +98,14 @@ const AUTH_MAX_FAILED_ATTEMPTS: u32 = 5;
const AUTH_LOCKOUT_MS: u64 = 60 * 1_000;
const UNKNOWN_CLIENT_KEY: &str = "unknown";
const PEER_STALE_AFTER_MS: u64 = 20 * 60 * 1_000;
+const SLOW_UI_REQUEST_LOG_MS: u128 = 250;
mod types;
use types::{
ActionResponse, AuthForm, AuthStatusResponse, BlocksQuery, ChangePasswordForm, ConfigForm,
ConfigResponse, MempoolCounts, MetricsQuery, MetricsResponse, NetworkHealthLocalState,
- NetworkHealthResponse, Page, PageQuery, UiBlock, UiTransaction, WalletTransactionFilters,
- WalletTransactionRow, WalletTransactionsQuery, WalletUtxoRow,
+ NetworkHealthResponse, Page, PageQuery, UiBlock, UiTransaction, WalletTransactionContext,
+ WalletTransactionFilters, WalletTransactionRow, WalletTransactionsQuery, WalletUtxoRow,
};
#[cfg(test)]
use types::{BurnSettingsForm, TransferForm};
@@ -120,13 +125,30 @@ pub async fn serve(
ui_config,
config_path: options.config_path,
chain_store: options.chain_store,
+ ui_data_store: options.ui_data_store,
wallet_path: options.wallet_path,
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())),
+ ui_data_refresh: Arc::new(Mutex::new(())),
};
+ println!(
+ "warming UI data cache from {}...",
+ state.ui_data_store.path().display()
+ );
+ let ui_data_started = Instant::now();
prewarm_chain_view_cache(state.clone()).await?;
+ {
+ let cache = state.ui_cache.lock().await;
+ println!(
+ "UI data cache ready in {:.2}s (outputs: {}, revealed heights: {}, burn-rank blocks: {})",
+ ui_data_started.elapsed().as_secs_f64(),
+ cache.outputs.len(),
+ cache.revealed_by_height.len(),
+ cache.burn_leader_ranks_by_hash.len()
+ );
+ }
tokio::spawn(run_owned_blinded_outbox_persistence(state.clone()));
let app = Router::new()
.route("/", get(index))
@@ -194,6 +216,7 @@ pub async fn serve(
state.clone(),
require_auth_middleware,
))
+ .layer(middleware::from_fn(log_slow_api_request))
.with_state(state);
let listener = TcpListener::bind(addr)
@@ -207,6 +230,22 @@ pub async fn serve(
.context("serving HTTP management UI")
}
+async fn log_slow_api_request(request: Request<Body>, next: Next) -> Response {
+ let method = request.method().clone();
+ let path = request.uri().path().to_string();
+ let started = Instant::now();
+ let response = next.run(request).await;
+ let elapsed = started.elapsed();
+ if path.starts_with("/api/") && elapsed.as_millis() >= SLOW_UI_REQUEST_LOG_MS {
+ println!(
+ "slow UI API request: {method} {path} -> {} in {:.2}s",
+ response.status().as_u16(),
+ elapsed.as_secs_f64()
+ );
+ }
+ response
+}
+
async fn prewarm_chain_view_cache(state: HttpState) -> Result<()> {
let tip_hash = {
let node = state.node.lock().await;
@@ -233,7 +272,7 @@ async fn load_persisted_ui_chain_index(
state: &HttpState,
tip_hash: String,
) -> Result<Option<UiChainIndex>> {
- let store = state.chain_store.clone();
+ let store = state.ui_data_store.clone();
tokio::task::spawn_blocking(move || store.load_ui_chain_index(&tip_hash))
.await
.context("UI chain index loader failed")?
diff --git a/src/adapters/http/actions.rs b/src/adapters/http/actions.rs
@@ -21,7 +21,9 @@ use super::{
validate_address, wallet_setup_json,
};
use crate::{
- adapters::{chain_store::SqliteChainStore, config_store::UiConfig},
+ adapters::{
+ chain_store::SqliteChainStore, config_store::UiConfig, ui_data_store::SqliteUiDataStore,
+ },
app::GossipEnvelope,
domain::Amount,
};
@@ -303,12 +305,12 @@ pub(super) async fn set_keep_track_of_metrics(state: &HttpState, enabled: bool)
node.has_real_chain().then(|| node.chain_snapshot())
};
if let Some(snapshot) = snapshot {
- replace_metrics_for_snapshot(&state.chain_store, snapshot).await?;
+ replace_metrics_for_snapshot(&state.ui_data_store, snapshot).await?;
} else {
- clear_metrics(&state.chain_store).await?;
+ clear_metrics(&state.ui_data_store).await?;
}
} else {
- clear_metrics(&state.chain_store).await?;
+ clear_metrics(&state.ui_data_store).await?;
}
let mut config = state.ui_config.lock().await;
@@ -330,6 +332,7 @@ pub(super) async fn reset_local_chain(state: &HttpState, confirmation: &str) ->
*cache = super::UiChainCache::default();
}
clear_chain(&state.chain_store).await?;
+ clear_ui_data(&state.ui_data_store).await?;
state
.gossip
.broadcast(vec![GossipEnvelope::ChainSnapshotRequest])
@@ -392,7 +395,7 @@ pub(super) async fn set_p2p_accept_inbound(
}
async fn replace_metrics_for_snapshot(
- store: &SqliteChainStore,
+ store: &SqliteUiDataStore,
snapshot: crate::domain::ChainSnapshot,
) -> Result<()> {
let store = store.clone();
@@ -402,7 +405,7 @@ async fn replace_metrics_for_snapshot(
Ok(())
}
-async fn clear_metrics(store: &SqliteChainStore) -> Result<()> {
+async fn clear_metrics(store: &SqliteUiDataStore) -> Result<()> {
let store = store.clone();
tokio::task::spawn_blocking(move || store.clear_metrics())
.await
@@ -410,6 +413,14 @@ async fn clear_metrics(store: &SqliteChainStore) -> Result<()> {
Ok(())
}
+async fn clear_ui_data(store: &SqliteUiDataStore) -> Result<()> {
+ let store = store.clone();
+ tokio::task::spawn_blocking(move || store.clear_all())
+ .await
+ .context("UI data cleanup worker failed")??;
+ Ok(())
+}
+
async fn clear_chain(store: &SqliteChainStore) -> Result<()> {
let store = store.clone();
tokio::task::spawn_blocking(move || store.clear_chain())
diff --git a/src/adapters/http/api.rs b/src/adapters/http/api.rs
@@ -1,27 +1,31 @@
use std::collections::{BTreeMap, BTreeSet};
-use anyhow::Result;
+use anyhow::{Context, Result};
use axum::{
Json,
extract::{Query, State},
};
+#[cfg(test)]
+use crate::domain::Ledger;
use crate::{
adapters::p2p::P2pMetrics,
app::{NodeStatus, PeerInfo},
- domain::Ledger,
+ domain::{BlindedTransaction, OutPoint, Transaction, TxOutput},
};
use super::{
BlocksQuery, ConfigResponse, MempoolCounts, MetricsQuery, MetricsResponse,
NetworkHealthLocalState, NetworkHealthResponse, Page, PageQuery, UiBlock, UiTransaction,
- WalletTransactionFilters, WalletTransactionRow, WalletTransactionsQuery, WalletUtxoRow,
+ WalletTransactionContext, WalletTransactionFilters, WalletTransactionRow,
+ WalletTransactionsQuery, WalletUtxoRow,
};
use super::{
DATASET_LIMIT, DATASET_PAGE_LIMIT, EXPLORER_LIMIT, EXPLORER_PAGE_LIMIT, HttpState,
- add_pending_outputs, cached_chain_view, cached_chain_view_for_tip, metrics_response,
+ add_pending_outputs, cached_chain_view, cached_ui_blocks_for_tip, metrics_response,
network_health, ui_blinded_reveal, ui_blinded_transaction, ui_blocks_from_indexes,
- ui_pending_revealed_transaction, ui_transaction, wallet_transaction_rows,
+ ui_pending_revealed_transaction, ui_transaction, wallet_transaction_row,
+ wallet_transaction_rows,
};
pub(super) async fn api_status(State(state): State<HttpState>) -> Json<NodeStatus> {
@@ -38,40 +42,33 @@ pub(super) async fn api_blocks(
.limit
.unwrap_or(EXPLORER_PAGE_LIMIT)
.min(EXPLORER_LIMIT);
- let (tip_hash, pending, blocks) = {
+ let (tip_hash, blocks) = {
let node = state.node.lock().await;
- 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),
};
- (node.chain_tip_hash(), pending, blocks)
+ (node.chain_tip_hash(), blocks)
};
- let (view, pending, blocks) =
- match cached_chain_view_for_tip(&state, Some(tip_hash.as_str())).await {
- Some(view) => (view, pending, blocks),
- None => {
- let (snapshot, pending, blocks) = {
- 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),
- };
- (snapshot, pending, blocks)
- };
- let view = cached_chain_view(&state, &snapshot)
- .await
- .unwrap_or_default();
- (view, pending, blocks)
- }
+ if let Some(blocks) = cached_ui_blocks_for_tip(&state, Some(tip_hash.as_str()), blocks).await {
+ return Json(blocks);
+ }
+
+ let (snapshot, blocks) = {
+ let node = state.node.lock().await;
+ let snapshot = node.chain_snapshot();
+ let blocks = match query.before_height {
+ Some(before_height) => node.blocks_before(before_height, limit),
+ None => node.recent_blocks(limit),
};
- let mut outputs = view.outputs;
- add_pending_outputs(&mut outputs, &pending);
+ (snapshot, blocks)
+ };
+ let view = cached_chain_view(&state, &snapshot)
+ .await
+ .unwrap_or_default();
Json(ui_blocks_from_indexes(
blocks,
- &outputs,
+ &view.outputs,
&view.revealed_by_height,
&view.burn_leader_ranks_by_hash,
))
@@ -89,7 +86,8 @@ pub(super) async fn api_mempool(
State(state): State<HttpState>,
Query(query): Query<PageQuery>,
) -> Json<Page<UiTransaction>> {
- let (tip_hash, pending, pending_blinded, pending_reveals, pending_revealed) = {
+ let ui_data_ready = ensure_ui_data_current(&state).await.is_ok();
+ let (pending, pending_blinded, pending_reveals, pending_revealed) = {
let node = state.node.lock().await;
let pending = node.pending_transactions();
let pending_blinded = node.pending_blinded_transactions();
@@ -99,56 +97,24 @@ pub(super) async fn api_mempool(
.into_iter()
.map(|revealed| (revealed.commitment.clone(), revealed))
.collect::<BTreeMap<_, _>>();
- (
- node.chain_tip_hash(),
- pending,
- pending_blinded,
- pending_reveals,
- pending_revealed,
- )
+ (pending, pending_blinded, pending_reveals, pending_revealed)
+ };
+ let mut required_outputs = BTreeSet::new();
+ collect_transaction_input_outpoints(pending.iter(), &mut required_outputs);
+ collect_blinded_input_outpoints(pending_blinded.iter(), &mut required_outputs);
+ collect_transaction_input_outpoints(
+ pending_revealed
+ .values()
+ .map(|revealed| &revealed.transaction),
+ &mut required_outputs,
+ );
+ let mut outputs = if ui_data_ready {
+ load_outputs_for_outpoints(&state, required_outputs)
+ .await
+ .unwrap_or_default()
+ } else {
+ BTreeMap::new()
};
- let (view, pending, pending_blinded, pending_reveals, pending_revealed) =
- match cached_chain_view_for_tip(&state, Some(tip_hash.as_str())).await {
- Some(view) => (
- view,
- pending,
- pending_blinded,
- pending_reveals,
- pending_revealed,
- ),
- None => {
- 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
- .unwrap_or_default();
- (
- view,
- pending,
- pending_blinded,
- pending_reveals,
- pending_revealed,
- )
- }
- };
- let mut outputs = view.outputs;
add_pending_outputs(&mut outputs, &pending);
let mut items = pending
.iter()
@@ -173,63 +139,257 @@ pub(super) async fn api_wallet_transactions(
State(state): State<HttpState>,
Query(query): Query<WalletTransactionsQuery>,
) -> Json<Page<WalletTransactionRow>> {
- let (wallet, snapshot, pending, owned_blinded) = {
+ let page_query = query.page();
+ let offset = page_query.offset.unwrap_or(0);
+ let limit = page_query
+ .limit
+ .unwrap_or(DATASET_PAGE_LIMIT)
+ .clamp(1, DATASET_LIMIT);
+ let filters = WalletTransactionFilters::from_query(query);
+ if ensure_ui_data_current(&state).await.is_err() {
+ return Json(Page {
+ items: Vec::new(),
+ offset,
+ limit,
+ total: 0,
+ has_more: false,
+ next_offset: None,
+ });
+ }
+ let (wallet, 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)
+ let mut pending_required_outputs = BTreeSet::new();
+ collect_transaction_input_outpoints(pending.iter(), &mut pending_required_outputs);
+ collect_transaction_input_outpoints(owned_blinded.iter(), &mut pending_required_outputs);
+ let mut pending_outputs = load_outputs_for_outpoints(&state, pending_required_outputs)
.await
.unwrap_or_default();
- 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(
+ add_pending_outputs(&mut pending_outputs, &pending);
+ let pending_rows = wallet_transaction_rows(
+ &wallet,
+ pending.clone(),
+ owned_blinded.clone(),
+ &[],
+ &BTreeMap::new(),
+ &pending_outputs,
+ filters,
+ );
+ let pending_total = pending_rows.len();
+ let mut items = pending_rows
+ .into_iter()
+ .skip(offset.min(pending_total))
+ .take(limit)
+ .collect::<Vec<_>>();
+
+ let confirmed_offset = offset.saturating_sub(pending_total);
+ let remaining_limit = limit.saturating_sub(items.len());
+ let kinds = wallet_transaction_filter_kinds(filters);
+ let store = state.ui_data_store.clone();
+ let wallet_for_query = wallet.clone();
+ let (confirmed_rows, confirmed_total) = if remaining_limit == 0 {
+ (Vec::new(), 0)
+ } else {
+ tokio::task::spawn_blocking(move || {
+ store.load_wallet_transactions(
+ &wallet_for_query,
+ &kinds,
+ confirmed_offset,
+ remaining_limit,
+ )
+ })
+ .await
+ .ok()
+ .and_then(Result::ok)
+ .unwrap_or_default()
+ };
+ let mut confirmed_required_outputs = BTreeSet::new();
+ collect_transaction_input_outpoints(
+ confirmed_rows.iter().map(|row| &row.transaction),
+ &mut confirmed_required_outputs,
+ );
+ let confirmed_outputs = load_outputs_for_outpoints(&state, confirmed_required_outputs)
+ .await
+ .unwrap_or_default();
+ items.extend(confirmed_rows.into_iter().filter_map(|row| {
+ wallet_transaction_row(
&wallet,
- pending,
- owned_blinded,
- &snapshot.blocks,
- &view.revealed_by_height,
- &outputs,
- filters,
- ),
- page_query,
- ))
+ &row.transaction,
+ &confirmed_outputs,
+ &WalletTransactionContext {
+ status: "confirmed",
+ block_height: Some(row.block_height),
+ timestamp_ms: Some(row.timestamp_ms),
+ block_finalizer: Some(row.block_finalizer),
+ blinded: row.blinded,
+ },
+ )
+ }));
+ let total = pending_total + confirmed_total;
+ let next_offset = offset + items.len();
+ Json(Page {
+ items,
+ offset: offset.min(total),
+ limit,
+ total,
+ has_more: next_offset < total,
+ next_offset: (next_offset < total).then_some(next_offset),
+ })
+}
+
+async fn load_outputs_for_outpoints(
+ state: &HttpState,
+ outpoints: BTreeSet<OutPoint>,
+) -> Result<BTreeMap<OutPoint, TxOutput>> {
+ if outpoints.is_empty() {
+ return Ok(BTreeMap::new());
+ }
+ let store = state.ui_data_store.clone();
+ tokio::task::spawn_blocking(move || store.load_outputs(&outpoints))
+ .await
+ .unwrap_or_else(|_| Ok(BTreeMap::new()))
+}
+
+async fn ensure_ui_data_current(state: &HttpState) -> Result<()> {
+ let Some(tip_hash) = current_real_chain_tip(state).await else {
+ return Ok(());
+ };
+ if ui_data_matches_tip(state, tip_hash).await? {
+ return Ok(());
+ }
+
+ let _refresh_guard = state.ui_data_refresh.lock().await;
+ let Some(tip_hash) = current_real_chain_tip(state).await else {
+ return Ok(());
+ };
+ if ui_data_matches_tip(state, tip_hash).await? {
+ return Ok(());
+ }
+
+ let (snapshot, tip_hash) = {
+ let node = state.node.lock().await;
+ if !node.has_real_chain() {
+ return Ok(());
+ }
+ (node.chain_snapshot(), node.chain_tip_hash())
+ };
+ let keep_metrics = state.ui_config.lock().await.keep_track_of_metrics;
+ let chain_store = state.chain_store.clone();
+ let ui_data_store = state.ui_data_store.clone();
+ tokio::task::spawn_blocking(move || {
+ chain_store
+ .save(&snapshot)
+ .context("failed to persist chain before UI data catch-up")?;
+ ui_data_store
+ .project_snapshot(&snapshot, keep_metrics)
+ .context("failed to project UI data catch-up")?;
+ Ok::<(), anyhow::Error>(())
+ })
+ .await
+ .context("UI data catch-up worker failed")??;
+
+ ui_data_matches_tip(state, tip_hash)
+ .await?
+ .then_some(())
+ .context("UI data catch-up completed but projection tip does not match the chain tip")
+}
+
+async fn current_real_chain_tip(state: &HttpState) -> Option<String> {
+ let node = state.node.lock().await;
+ node.has_real_chain().then(|| node.chain_tip_hash())
+}
+
+async fn ui_data_matches_tip(state: &HttpState, tip_hash: String) -> Result<bool> {
+ let store = state.ui_data_store.clone();
+ tokio::task::spawn_blocking(move || store.is_projected_to(&tip_hash))
+ .await
+ .context("UI data projection metadata worker failed")?
+}
+
+fn collect_transaction_input_outpoints<'a>(
+ transactions: impl IntoIterator<Item = &'a Transaction>,
+ outpoints: &mut BTreeSet<OutPoint>,
+) {
+ for transaction in transactions {
+ match transaction {
+ Transaction::Transfer { inputs, .. } | Transaction::Burn { inputs, .. } => {
+ outpoints.extend(inputs.iter().map(|input| input.outpoint.clone()));
+ }
+ Transaction::Mine { .. } => {}
+ }
+ }
+}
+
+fn collect_blinded_input_outpoints<'a>(
+ transactions: impl IntoIterator<Item = &'a BlindedTransaction>,
+ outpoints: &mut BTreeSet<OutPoint>,
+) {
+ for transaction in transactions {
+ outpoints.extend(
+ transaction
+ .inputs
+ .iter()
+ .map(|input| input.outpoint.clone()),
+ );
+ }
}
pub(super) async fn api_wallet_utxos(
State(state): State<HttpState>,
Query(query): Query<PageQuery>,
) -> Json<Page<WalletUtxoRow>> {
- let (ledger, wallet) = {
+ if ensure_ui_data_current(&state).await.is_err() {
+ return Json(page_items(Vec::new(), query));
+ }
+ let (wallet, pending_spent) = {
let node = state.node.lock().await;
(
- node.wallet_view_ledger()
- .unwrap_or_else(|_| node.clone_ledger()),
node.wallet_address().to_string(),
+ node.wallet_pending_spent_outpoints(),
)
};
- Json(page_items(wallet_utxo_rows(&ledger, &wallet), query))
+ let store = state.ui_data_store.clone();
+ let utxos = tokio::task::spawn_blocking(move || store.load_wallet_utxos(&wallet))
+ .await
+ .ok()
+ .and_then(Result::ok)
+ .unwrap_or_default();
+ Json(page_items(
+ wallet_utxo_rows_from_ui_data(utxos, &pending_spent),
+ query,
+ ))
}
pub(super) async fn api_wallet_selectable_utxos(
State(state): State<HttpState>,
) -> Json<Vec<WalletUtxoRow>> {
- let (ledger, wallet) = {
+ if ensure_ui_data_current(&state).await.is_err() {
+ return Json(Vec::new());
+ }
+ let (wallet, pending_spent) = {
let node = state.node.lock().await;
(
- node.wallet_view_ledger()
- .unwrap_or_else(|_| node.clone_ledger()),
node.wallet_address().to_string(),
+ node.wallet_pending_spent_outpoints(),
)
};
- Json(selectable_wallet_utxo_rows(&ledger, &wallet))
+ let store = state.ui_data_store.clone();
+ let utxos = tokio::task::spawn_blocking(move || store.load_wallet_utxos(&wallet))
+ .await
+ .ok()
+ .and_then(Result::ok)
+ .unwrap_or_default();
+ Json(
+ wallet_utxo_rows_from_ui_data(utxos, &pending_spent)
+ .into_iter()
+ .filter(|utxo| utxo.spendable)
+ .collect(),
+ )
}
pub(super) fn page_items<T>(items: Vec<T>, query: PageQuery) -> Page<T> {
@@ -255,6 +415,21 @@ pub(super) fn page_items<T>(items: Vec<T>, query: PageQuery) -> Page<T> {
}
}
+fn wallet_transaction_filter_kinds(filters: WalletTransactionFilters) -> Vec<&'static str> {
+ let mut kinds = Vec::new();
+ if filters.transfer {
+ kinds.push("transfer");
+ }
+ if filters.mine {
+ kinds.push("mine");
+ }
+ if filters.burn {
+ kinds.push("burn");
+ }
+ kinds
+}
+
+#[cfg(test)]
pub(super) fn wallet_utxo_rows(ledger: &Ledger, wallet: &str) -> Vec<WalletUtxoRow> {
let spendable_outpoints = ledger
.available_utxos_for_address(wallet)
@@ -285,6 +460,25 @@ pub(super) fn wallet_utxo_rows(ledger: &Ledger, wallet: &str) -> Vec<WalletUtxoR
utxos
}
+fn wallet_utxo_rows_from_ui_data(
+ utxos: Vec<(crate::domain::OutPoint, crate::domain::TxOutput)>,
+ pending_spent: &BTreeSet<crate::domain::OutPoint>,
+) -> Vec<WalletUtxoRow> {
+ utxos
+ .into_iter()
+ .map(|(outpoint, output)| {
+ let spendable = !pending_spent.contains(&outpoint);
+ WalletUtxoRow {
+ outpoint,
+ address: output.address,
+ amount: output.amount,
+ spendable,
+ }
+ })
+ .collect()
+}
+
+#[cfg(test)]
pub(super) fn selectable_wallet_utxo_rows(ledger: &Ledger, wallet: &str) -> Vec<WalletUtxoRow> {
wallet_utxo_rows(ledger, wallet)
.into_iter()
@@ -315,7 +509,14 @@ pub(super) async fn api_metrics(
charts: Vec::new(),
});
}
- let store = state.chain_store.clone();
+ if ensure_ui_data_current(&state).await.is_err() {
+ return Json(MetricsResponse {
+ enabled,
+ latest: None,
+ charts: Vec::new(),
+ });
+ }
+ let store = state.ui_data_store.clone();
let rows = tokio::task::spawn_blocking(move || match query.limit {
Some(limit) => store.load_recent_metrics(limit.clamp(1, DATASET_LIMIT)),
None => store.load_metrics(),
diff --git a/src/adapters/http/index_html.rs b/src/adapters/http/index_html.rs
@@ -167,10 +167,7 @@ pub(super) const INDEX_HTML: &str = r#"<!doctype html>
.metric-chart-x-axis { position: relative; grid-column: 2; min-width: 0; overflow: visible; }
.metric-chart-x-axis .metric-chart-axis-label { position: absolute; top: 0; transform: translateX(-50%); }
.metric-chart-line { fill: none; stroke: #d5f55f; stroke-width: 2.2; stroke-linejoin: round; stroke-linecap: round; }
- .metric-chart-points { position: absolute; inset: 0; }
- .metric-chart-point-hit { position: absolute; width: 18px; height: 18px; border: 0; border-radius: 50%; padding: 0; background: transparent; cursor: crosshair; transform: translate(-50%, -50%); }
- .metric-chart-point-hit::after { content: ""; position: absolute; left: 50%; top: 50%; width: 5px; height: 5px; border-radius: 50%; background: #d5f55f; opacity: .2; transform: translate(-50%, -50%); }
- .metric-chart-point-hit:hover::after, .metric-chart-point-hit:focus-visible::after, .metric-chart-point-hit.is-active::after { width: 8px; height: 8px; opacity: 1; }
+ .metric-chart-hover-point { position: absolute; width: 8px; height: 8px; border-radius: 50%; background: #d5f55f; pointer-events: none; transform: translate(-50%, -50%); box-shadow: 0 0 0 4px rgba(213, 245, 95, .18); }
.metric-chart-tooltip { position: absolute; z-index: 1; max-width: min(180px, 80%); border: 1px solid #566d25; border-radius: 6px; padding: 5px 7px; background: #202615; color: #e8edf0; font-size: 11px; font-weight: 850; font-variant-numeric: tabular-nums; line-height: 1.25; pointer-events: none; box-shadow: 0 8px 20px rgba(0, 0, 0, .28); white-space: nowrap; }
.metrics-empty { border: 1px dashed #3a4248; border-radius: 8px; padding: 14px; color: #8d989f; background: #111316; }
.wallet-grid { width: 100%; display: grid; grid-template-columns: minmax(0, 1fr) minmax(300px, .8fr); gap: 12px; align-items: start; }
@@ -1079,11 +1076,9 @@ pub(super) const INDEX_HTML: &str = r#"<!doctype html>
<line class="metric-chart-axis" x1="4" y1="132" x2="296" y2="132"></line>
<polyline class="metric-chart-line" :points="metricChartPoints(chart)"></polyline>
</svg>
- <div class="metric-chart-points">
- <template x-for="marker in metricChartPointMarkers(chart)" :key="`${chart.id}-point-${marker.height}`">
- <button class="metric-chart-point-hit" type="button" :class="{ 'is-active': metricHover?.chartId === chart.id && metricHover?.height === marker.height }" :style="metricPointStyle(marker)" :title="marker.label" @focus="setMetricHover(chart, marker)" @blur="clearMetricHover(chart)" :aria-label="marker.label"></button>
- </template>
- </div>
+ <template x-if="metricHover?.chartId === chart.id">
+ <div class="metric-chart-hover-point" :style="metricHoverPointStyle(chart)" :title="metricTooltipLabel(chart)"></div>
+ </template>
<template x-if="metricHover?.chartId === chart.id">
<div class="metric-chart-tooltip" :style="metricTooltipStyle(chart)" x-text="metricTooltipLabel(chart)"></div>
</template>
diff --git a/src/adapters/http/metrics.rs b/src/adapters/http/metrics.rs
@@ -1,5 +1,5 @@
use crate::{
- adapters::chain_store::BlockMetricRow,
+ adapters::ui_data_store::BlockMetricRow,
app::{PeerDirection, PeerInfo},
domain::Amount,
};
diff --git a/src/adapters/http/state.rs b/src/adapters/http/state.rs
@@ -3,7 +3,10 @@ use std::{collections::BTreeMap, net::SocketAddr, path::PathBuf, sync::Arc};
use tokio::sync::Mutex;
use crate::{
- adapters::{chain_store::SqliteChainStore, config_store::UiConfig, p2p::GossipNetwork},
+ adapters::{
+ chain_store::SqliteChainStore, config_store::UiConfig, p2p::GossipNetwork,
+ ui_data_store::SqliteUiDataStore,
+ },
app::{SharedNode, SharedPeerBook, StratumStatus},
domain::{BurnLeaderRank, OutPoint, RevealedBlindedTransaction, TxOutput},
};
@@ -16,11 +19,13 @@ pub(super) struct HttpState {
pub(super) ui_config: Arc<Mutex<UiConfig>>,
pub(super) config_path: PathBuf,
pub(super) chain_store: SqliteChainStore,
+ pub(super) ui_data_store: SqliteUiDataStore,
pub(super) wallet_path: PathBuf,
pub(super) stratum: StratumStatus,
pub(super) auth_sessions: Arc<Mutex<BTreeMap<String, AuthSession>>>,
pub(super) auth_backoff: Arc<Mutex<BTreeMap<String, AuthBackoff>>>,
pub(super) ui_cache: Arc<Mutex<UiChainCache>>,
+ pub(super) ui_data_refresh: Arc<Mutex<()>>,
}
#[derive(Clone)]
@@ -56,6 +61,7 @@ pub(super) struct UiChainView {
pub struct ServeOptions {
pub config_path: PathBuf,
pub chain_store: SqliteChainStore,
+ pub ui_data_store: SqliteUiDataStore,
pub wallet_path: PathBuf,
pub stratum: StratumStatus,
pub addr: SocketAddr,
diff --git a/src/adapters/http/tests.rs b/src/adapters/http/tests.rs
@@ -1,8 +1,9 @@
use std::{collections::BTreeMap, sync::Arc};
use axum::{
- Router,
+ Json, Router,
body::{Body, to_bytes},
+ extract::{Query, State},
http::{HeaderMap, Method, Request, StatusCode, header},
middleware,
routing::{get, post},
@@ -13,7 +14,7 @@ use tower::ServiceExt;
use crate::{
adapters::{
chain_store::SqliteChainStore, config_store, config_store::UiConfig, p2p::GossipNetwork,
- wallet_store,
+ ui_data_store::SqliteUiDataStore, wallet_store,
},
app::{GossipEnvelope, NodeCore, PeerBook, PeerDirection, PeerInfo, StratumStatus},
domain::{
@@ -1386,8 +1387,8 @@ fn metric_row(
height: u64,
block_time_ms: Option<u64>,
vdf_rounds: u64,
-) -> crate::adapters::chain_store::BlockMetricRow {
- crate::adapters::chain_store::BlockMetricRow {
+) -> crate::adapters::ui_data_store::BlockMetricRow {
+ crate::adapters::ui_data_store::BlockMetricRow {
height,
block_hash: format!("hash-{height}"),
timestamp_ms: height,
@@ -1472,6 +1473,67 @@ fn metrics_screen_includes_block_range_filter() {
assert!(super::INDEX_HTML.contains("setMetricsRange('all')"));
assert!(super::INDEX_HTML.contains("Known addresses"));
assert!(super::INDEX_HTML.contains("knownWalletAddresses"));
+ assert!(super::INDEX_HTML.contains("metric-chart-hover-point"));
+ assert!(!super::INDEX_HTML.contains("metric-chart-point-hit"));
+ assert!(!super::INDEX_HTML.contains("metric-chart-hover-point\" x-show"));
+ assert!(super::INDEX_HTML.contains("<template x-if=\"metricHover?.chartId === chart.id\">"));
+ let app_js = include_str!("../../../www/assets/iuna-ui.js");
+ assert!(app_js.contains("shellRefreshPromise: null"));
+ assert!(app_js.contains("metricsRequestSeq: 0"));
+ assert!(app_js.contains("await this.refreshMetrics(options);"));
+ assert!(app_js.contains("this.refreshShellState({ addressBookVersion, silent: true });"));
+ assert!(app_js.contains("async refreshShellState(options = {})"));
+ assert!(app_js.contains("this.refreshMetrics();"));
+ assert!(!app_js.contains("this.blockchainMetrics = { enabled: this.blockchainMetrics?.enabled ?? true, latest: this.blockchainMetrics?.latest ?? null, charts: [] };\n this.refresh({ force: true });"));
+ assert!(app_js.contains("async fetchMetricsResponse(range = this.metricsRange)"));
+ assert!(app_js.contains("prepareMetricsResponse(metrics)"));
+ assert!(app_js.contains("_linePoints: linePoints"));
+ assert!(app_js.contains("_gridPath: gridPath"));
+ assert!(!app_js.contains("label: this.metricPointLabel(chart, point)"));
+ assert!(app_js.contains("label: this.metricPointLabel(chart, marker)"));
+ assert!(app_js.contains("metricHoverPointStyle(chart)"));
+}
+
+#[test]
+fn blocks_endpoint_uses_cached_block_projection_without_cloning_full_ui_index() {
+ let api_rs = include_str!("api.rs");
+ assert!(api_rs.contains("cached_ui_blocks_for_tip(&state, Some(tip_hash.as_str()), blocks)"));
+ assert!(!api_rs.contains("let mut outputs = view.outputs;\n add_pending_outputs(&mut outputs, &pending);\n Json(ui_blocks_from_indexes(\n blocks,\n &outputs,"));
+}
+
+#[test]
+fn mempool_and_wallet_transaction_endpoints_use_narrow_output_lookups() {
+ let api_rs = include_str!("api.rs");
+ assert!(api_rs.contains("load_outputs_for_outpoints(&state, required_outputs)"));
+ assert!(api_rs.contains("load_outputs_for_outpoints(&state, pending_required_outputs)"));
+ assert!(api_rs.contains("load_outputs_for_outpoints(&state, confirmed_required_outputs)"));
+ assert!(!api_rs.contains("let mut outputs = view.outputs;"));
+}
+
+#[test]
+fn http_server_logs_slow_api_requests() {
+ let http_rs = include_str!("../http.rs");
+ assert!(http_rs.contains("const SLOW_UI_REQUEST_LOG_MS: u128 = 250;"));
+ assert!(http_rs.contains("async fn log_slow_api_request"));
+ assert!(http_rs.contains("slow UI API request: {method} {path}"));
+}
+
+#[test]
+fn fee_estimate_polling_is_tab_scoped() {
+ let app_js = include_str!("../../../www/assets/iuna-ui.js");
+ let refresh = app_js
+ .split("async refreshFeeEstimates()")
+ .nth(1)
+ .expect("refreshFeeEstimates should exist")
+ .split("async refreshBurnFeeEstimate()")
+ .next()
+ .expect("refreshFeeEstimates body should precede burn fee estimate");
+
+ assert!(refresh.contains("if (this.tab === \"wallet\")"));
+ assert!(refresh.contains("await this.refreshTransferFeeEstimate();"));
+ assert!(refresh.contains("if (this.tab === \"mining\")"));
+ assert!(refresh.contains("this.refreshBurnFeeEstimate()"));
+ assert!(refresh.contains("this.refreshMineFeeEstimate()"));
}
#[test]
@@ -1641,9 +1703,10 @@ async fn chain_reset_deletes_local_chain_and_returns_to_placeholder() {
let ledger =
Ledger::new_with_genesis_burns(genesis, vec![GenesisBurn::new(wallet.address(), 1)], 1)
.unwrap();
+ state.chain_store.save(&ledger.snapshot()).unwrap();
state
- .chain_store
- .save_with_metrics(&ledger.snapshot(), true)
+ .ui_data_store
+ .project_snapshot(&ledger.snapshot(), true)
.unwrap();
{
let mut node = state.node.lock().await;
@@ -1666,7 +1729,7 @@ async fn chain_reset_deletes_local_chain_and_returns_to_placeholder() {
assert_eq!(node.status().wallet_address, wallet.address());
drop(node);
assert!(state.chain_store.load().unwrap().is_none());
- assert!(state.chain_store.load_metrics().unwrap().is_empty());
+ assert!(state.ui_data_store.load_metrics().unwrap().is_empty());
assert!(state.ui_cache.lock().await.tip_hash.is_none());
}
@@ -1683,6 +1746,7 @@ fn polling_refreshes_paged_datasets_without_visible_loaders() {
"async refreshNow(options = {}) {\n if (!this.canUseProtectedApi()) return;"
));
assert!(app_js.contains("refreshPromise: null"));
+ assert!(app_js.contains("networkHealthPromise: null"));
assert!(app_js.contains("if (this.refreshPromise)"));
assert!(app_js.contains("return this.refreshPromise;"));
assert!(app_js.contains("options.force === true"));
@@ -1696,6 +1760,16 @@ fn polling_refreshes_paged_datasets_without_visible_loaders() {
);
assert!(app_js.contains("if (tab === \"chain\") pagedDatasets.push(\"mempool\");"));
assert!(app_js.contains("if (tab === \"p2p\") pagedDatasets.push(\"peer\");"));
+ let merge_blocks_index = app_js
+ .find("if (blocks) this.mergeFreshBlocks(blocks, { animateHead: true });")
+ .expect("fresh blocks should be merged during refresh");
+ let paged_dataset_index = app_js
+ .find("this.refreshPagedDataset(kind, { silent: options.silent === true })")
+ .expect("paged datasets should refresh during refresh");
+ assert!(merge_blocks_index < paged_dataset_index);
+ assert!(app_js.contains("this.refreshNetworkHealth({ silent: options.silent === true });"));
+ assert!(app_js.contains("async refreshNetworkHealth(options = {})"));
+ assert!(app_js.contains("if (this.networkHealthPromise) return this.networkHealthPromise;"));
assert!(app_js.contains("cache: \"no-store\""));
assert!(app_js.contains("async fetchWithTimeout(path, options = {})"));
assert!(app_js.contains("controller.abort()"));
@@ -1798,7 +1872,7 @@ async fn startup_prewarm_populates_chain_view_cache_before_first_request() {
txid: "persisted-ui-index-sentinel".to_string(),
index: 7,
};
- let connection = rusqlite::Connection::open(state.chain_store.path()).unwrap();
+ let connection = rusqlite::Connection::open(state.ui_data_store.path()).unwrap();
connection
.execute(
r#"
@@ -1846,6 +1920,46 @@ VALUES (?1, ?2, ?3, ?4)
);
}
+#[tokio::test]
+async fn ui_data_endpoint_catches_up_stale_projection_before_reading() {
+ let dir = tempfile::tempdir().unwrap();
+ let state = auth_test_state(dir.path().join("config.json"), UiConfig::default()).await;
+ let wallet = Wallet::from_seed("http-ui-data-catch-up-wallet");
+ let mut genesis = BTreeMap::new();
+ genesis.insert(wallet.address().to_string(), 10);
+ let mut ledger =
+ Ledger::new_with_genesis_burns(genesis, vec![GenesisBurn::new(wallet.address(), 1)], 1)
+ .unwrap();
+ let burn = ledger.build_burn(&wallet, 1, 0).unwrap();
+ ledger.submit_transaction(burn).unwrap();
+ let block = ledger.mine_next_block(&wallet, 1_000).unwrap();
+ ledger.apply_locally_mined_block(block).unwrap();
+ let expected_tip = ledger.status().tip_hash;
+ {
+ let mut node = state.node.lock().await;
+ *node = NodeCore::from_ledger(wallet.clone(), ledger, 0);
+ }
+
+ assert!(state.chain_store.load().unwrap().is_none());
+ assert!(!state.ui_data_store.is_projected_to(&expected_tip).unwrap());
+
+ let Json(page) =
+ super::api_wallet_utxos(State(state.clone()), Query(super::PageQuery::default())).await;
+
+ assert!(
+ page.items
+ .iter()
+ .any(|utxo| utxo.address == wallet.address())
+ );
+ let persisted_tip = state
+ .chain_store
+ .load()
+ .unwrap()
+ .and_then(|snapshot| snapshot.blocks.last().map(|block| block.hash.clone()));
+ assert_eq!(persisted_tip.as_deref(), Some(expected_tip.as_str()));
+ assert!(state.ui_data_store.is_projected_to(&expected_tip).unwrap());
+}
+
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");
@@ -1856,6 +1970,8 @@ async fn auth_test_state(config_path: std::path::PathBuf, config: UiConfig) -> H
let gossip = GossipNetwork::new_for_tests(node.clone(), peers.clone());
let chain_store = SqliteChainStore::open(config_path.with_file_name("chain.sqlite3"))
.expect("test chain store should open");
+ let ui_data_store = SqliteUiDataStore::open(config_path.with_file_name("ui_data.sqlite3"))
+ .expect("test UI data store should open");
HttpState {
node,
peers,
@@ -1865,6 +1981,7 @@ async fn auth_test_state(config_path: std::path::PathBuf, config: UiConfig) -> H
)),
config_path,
chain_store,
+ ui_data_store,
wallet_path,
stratum: StratumStatus {
enabled: false,
@@ -1873,6 +1990,7 @@ async fn auth_test_state(config_path: std::path::PathBuf, config: UiConfig) -> H
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())),
+ ui_data_refresh: Arc::new(Mutex::new(())),
}
}
@@ -2025,14 +2143,14 @@ async fn metrics_setting_persists_config_and_clears_rows_when_disabled() {
.unwrap();
let config = config_store::load_or_create(&config_path).unwrap();
assert!(config.keep_track_of_metrics);
- assert!(!state.chain_store.load_metrics().unwrap().is_empty());
+ assert!(!state.ui_data_store.load_metrics().unwrap().is_empty());
super::set_keep_track_of_metrics(&state, false)
.await
.unwrap();
let config = config_store::load_or_create(&config_path).unwrap();
assert!(!config.keep_track_of_metrics);
- assert!(state.chain_store.load_metrics().unwrap().is_empty());
+ assert!(state.ui_data_store.load_metrics().unwrap().is_empty());
}
#[tokio::test]
diff --git a/src/adapters/http/types.rs b/src/adapters/http/types.rs
@@ -1,7 +1,7 @@
use serde::{Deserialize, Serialize};
use crate::{
- adapters::{chain_store::BlockMetricRow, config_store::UiConfig},
+ adapters::{config_store::UiConfig, ui_data_store::BlockMetricRow},
domain::{Amount, BurnLeaderRank, OutPoint, Transaction, TxOutput},
};
diff --git a/src/adapters/http/ui.rs b/src/adapters/http/ui.rs
@@ -103,7 +103,7 @@ pub(super) fn wallet_transaction_rows(
rows.into_iter().map(|(_, row)| row).collect()
}
-fn wallet_transaction_row(
+pub(super) fn wallet_transaction_row(
wallet: &str,
tx: &Transaction,
outputs_by_outpoint: &BTreeMap<OutPoint, TxOutput>,
@@ -591,12 +591,20 @@ pub(super) async fn cached_chain_view(
})
}
-pub(super) async fn cached_chain_view_for_tip(
+pub(super) async fn cached_ui_blocks_for_tip(
state: &HttpState,
tip_hash: Option<&str>,
-) -> Option<UiChainView> {
+ blocks: Vec<Block>,
+) -> Option<Vec<UiBlock>> {
let cache = state.ui_cache.lock().await;
- (cache.tip_hash.as_deref() == tip_hash).then(|| ui_chain_view_from_cache(&cache))
+ (cache.tip_hash.as_deref() == tip_hash).then(|| {
+ ui_blocks_from_indexes(
+ blocks,
+ &cache.outputs,
+ &cache.revealed_by_height,
+ &cache.burn_leader_ranks_by_hash,
+ )
+ })
}
fn ui_chain_view_from_cache(cache: &super::UiChainCache) -> UiChainView {
diff --git a/src/adapters/mod.rs b/src/adapters/mod.rs
@@ -3,5 +3,6 @@ pub mod config_store;
pub mod http;
pub mod p2p;
pub mod stratum;
+pub mod ui_data_store;
pub(crate) mod ui_index;
pub mod wallet_store;
diff --git a/src/adapters/ui_data_store.rs b/src/adapters/ui_data_store.rs
@@ -0,0 +1,1579 @@
+use std::{
+ collections::{BTreeMap, BTreeSet},
+ fs,
+ path::{Path, PathBuf},
+ time::{SystemTime, UNIX_EPOCH},
+};
+
+use anyhow::{Context, Result};
+use rusqlite::{Connection, OptionalExtension, params, params_from_iter, types::Value};
+
+use serde::Serialize;
+
+use crate::{
+ adapters::ui_index::{UiChainIndex, build_ui_chain_index},
+ domain::{
+ AGGREGATE_FINALIZER_FEE_ACTIVATION_HEIGHT, 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, TxInput, TxOutput, blinded_reveal_finalizer_fee,
+ hex_hash, reveal_committee_slot_count, revealed_blinded_transactions,
+ },
+};
+
+const SCHEMA: &str = r#"
+CREATE TABLE IF NOT EXISTS block_metrics (
+ height INTEGER PRIMARY KEY,
+ block_hash TEXT NOT NULL,
+ timestamp_ms INTEGER NOT NULL,
+ block_time_ms INTEGER,
+ mine_difficulty_bits INTEGER NOT NULL,
+ circulating_supply INTEGER NOT NULL,
+ known_wallet_addresses INTEGER NOT NULL DEFAULT 0,
+ transaction_count INTEGER NOT NULL,
+ transfer_count INTEGER NOT NULL,
+ burn_count INTEGER NOT NULL,
+ mine_count INTEGER NOT NULL,
+ burned_amount INTEGER NOT NULL,
+ total_burned_amount INTEGER NOT NULL,
+ fees_amount INTEGER NOT NULL,
+ reward_amount INTEGER NOT NULL,
+ 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_utxos (
+ txid TEXT NOT NULL,
+ output_index INTEGER NOT NULL,
+ address TEXT NOT NULL,
+ amount INTEGER NOT NULL,
+ PRIMARY KEY (txid, output_index)
+);
+
+CREATE INDEX IF NOT EXISTS idx_ui_utxos_address
+ON ui_utxos(address);
+
+CREATE TABLE IF NOT EXISTS ui_wallet_transactions (
+ address TEXT NOT NULL,
+ sort_key INTEGER NOT NULL,
+ kind TEXT NOT NULL,
+ signature TEXT NOT NULL,
+ block_height INTEGER NOT NULL,
+ timestamp_ms INTEGER NOT NULL,
+ block_finalizer TEXT NOT NULL,
+ blinded INTEGER NOT NULL,
+ transaction_json BLOB NOT NULL,
+ PRIMARY KEY (address, signature)
+);
+
+CREATE INDEX IF NOT EXISTS idx_ui_wallet_transactions_address_kind_sort
+ON ui_wallet_transactions(address, kind, sort_key DESC);
+
+CREATE INDEX IF NOT EXISTS idx_ui_wallet_transactions_address_sort
+ON ui_wallet_transactions(address, sort_key DESC);
+
+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 {
+ pub height: u64,
+ pub block_hash: String,
+ pub timestamp_ms: u64,
+ pub block_time_ms: Option<u64>,
+ pub mine_difficulty_bits: u32,
+ pub circulating_supply: Amount,
+ pub known_wallet_addresses: u64,
+ pub transaction_count: u64,
+ pub transfer_count: u64,
+ pub burn_count: u64,
+ pub mine_count: u64,
+ pub burned_amount: Amount,
+ pub total_burned_amount: Amount,
+ pub fees_amount: Amount,
+ pub reward_amount: Amount,
+ pub vdf_rounds: u64,
+ pub finalizer_rank: u32,
+}
+
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub struct WalletTransactionProjection {
+ pub sort_key: u64,
+ pub kind: String,
+ pub block_height: u64,
+ pub timestamp_ms: u64,
+ pub block_finalizer: String,
+ pub blinded: bool,
+ pub transaction: Transaction,
+}
+
+#[derive(Clone, Debug)]
+pub struct SqliteUiDataStore {
+ path: PathBuf,
+}
+
+impl SqliteUiDataStore {
+ pub fn open(path: impl AsRef<Path>) -> Result<Self> {
+ let path = path.as_ref().to_path_buf();
+ if let Some(parent) = path.parent() {
+ fs::create_dir_all(parent).with_context(|| {
+ format!(
+ "failed to create chain database directory {}",
+ parent.display()
+ )
+ })?;
+ }
+
+ let store = Self { path };
+ store.with_connection_mut(|connection| {
+ connection
+ .execute_batch(SCHEMA)
+ .context("failed to initialize UI data database schema")?;
+ ensure_block_metrics_column(
+ connection,
+ "known_wallet_addresses",
+ "INTEGER NOT NULL DEFAULT 0",
+ )?;
+ Ok(())
+ })?;
+ Ok(store)
+ }
+
+ pub fn path(&self) -> &Path {
+ &self.path
+ }
+
+ 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(crate) fn is_projected_to(&self, tip_hash: &str) -> Result<bool> {
+ self.with_connection(|connection| {
+ let projected = 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 data projection metadata")?
+ .is_some_and(|(schema_version, stored_tip_hash)| {
+ schema_version == UI_CACHE_SCHEMA_VERSION && stored_tip_hash == tip_hash
+ });
+ Ok(projected)
+ })
+ }
+
+ pub fn project_snapshot(&self, snapshot: &ChainSnapshot, keep_metrics: bool) -> Result<()> {
+ let updated_at_ms = unix_ms();
+ let ui_index = build_ui_chain_index(snapshot);
+ let utxos = Ledger::from_persisted_snapshot(snapshot.clone())
+ .context("failed to rebuild ledger for UI UTXO projection")?
+ .all_utxos();
+ let wallet_transactions = wallet_transactions_from_snapshot(snapshot);
+ let metrics = if keep_metrics {
+ Some(metrics_from_snapshot(snapshot)?)
+ } else {
+ None
+ };
+
+ self.with_connection_mut(|connection| {
+ let transaction = connection
+ .transaction()
+ .context("failed to start UI data projection transaction")?;
+ match metrics {
+ Some(metrics) => replace_metrics(&transaction, &metrics)?,
+ None => clear_metrics_in_transaction(&transaction)?,
+ }
+ replace_ui_chain_index(&transaction, &ui_index, updated_at_ms)?;
+ replace_ui_utxos(&transaction, &utxos)?;
+ replace_ui_wallet_transactions(&transaction, &wallet_transactions)?;
+ transaction
+ .commit()
+ .context("failed to commit UI data projection transaction")?;
+ Ok(())
+ })
+ }
+
+ pub fn replace_metrics_for_snapshot(&self, snapshot: &ChainSnapshot) -> Result<()> {
+ let metrics = metrics_from_snapshot(snapshot)?;
+ self.with_connection_mut(|connection| {
+ let transaction = connection
+ .transaction()
+ .context("failed to start metrics transaction")?;
+ replace_metrics(&transaction, &metrics)?;
+ transaction
+ .commit()
+ .context("failed to commit metrics transaction")?;
+ Ok(())
+ })
+ }
+
+ pub fn clear_metrics(&self) -> Result<()> {
+ self.with_connection_mut(|connection| {
+ connection
+ .execute("DELETE FROM block_metrics", [])
+ .context("failed to delete block metrics")?;
+ Ok(())
+ })
+ }
+
+ pub fn clear_all(&self) -> Result<()> {
+ self.with_connection_mut(|connection| {
+ let transaction = connection
+ .transaction()
+ .context("failed to start UI data reset transaction")?;
+ clear_metrics_in_transaction(&transaction)?;
+ clear_ui_chain_index_in_transaction(&transaction)?;
+ transaction
+ .commit()
+ .context("failed to commit UI data reset transaction")?;
+ Ok(())
+ })
+ }
+
+ pub fn load_metrics(&self) -> Result<Vec<BlockMetricRow>> {
+ self.with_connection(|connection| {
+ let mut statement = connection
+ .prepare(
+ r#"
+SELECT height, block_hash, timestamp_ms, block_time_ms, mine_difficulty_bits,
+ circulating_supply, known_wallet_addresses, transaction_count, transfer_count, burn_count,
+ mine_count, burned_amount, total_burned_amount, fees_amount, reward_amount,
+ vdf_rounds, finalizer_rank
+FROM block_metrics
+ORDER BY height ASC
+"#,
+ )
+ .context("failed to prepare block metrics query")?;
+ let rows = statement
+ .query_map([], |row| {
+ Ok(BlockMetricRow {
+ height: row.get(0)?,
+ block_hash: row.get(1)?,
+ timestamp_ms: row.get(2)?,
+ block_time_ms: row.get(3)?,
+ mine_difficulty_bits: row.get(4)?,
+ circulating_supply: row.get(5)?,
+ known_wallet_addresses: row.get(6)?,
+ transaction_count: row.get(7)?,
+ transfer_count: row.get(8)?,
+ burn_count: row.get(9)?,
+ mine_count: row.get(10)?,
+ burned_amount: row.get(11)?,
+ total_burned_amount: row.get(12)?,
+ fees_amount: row.get(13)?,
+ reward_amount: row.get(14)?,
+ vdf_rounds: row.get(15)?,
+ finalizer_rank: row.get(16)?,
+ })
+ })
+ .context("failed to load block metrics")?;
+ rows.collect::<std::result::Result<Vec<_>, _>>()
+ .context("failed to read block metrics rows")
+ })
+ }
+
+ pub fn load_recent_metrics(&self, limit: usize) -> Result<Vec<BlockMetricRow>> {
+ self.with_connection(|connection| {
+ let mut statement = connection
+ .prepare(
+ r#"
+SELECT height, block_hash, timestamp_ms, block_time_ms, mine_difficulty_bits,
+ circulating_supply, known_wallet_addresses, transaction_count, transfer_count, burn_count,
+ mine_count, burned_amount, total_burned_amount, fees_amount, reward_amount,
+ vdf_rounds, finalizer_rank
+FROM block_metrics
+ORDER BY height DESC
+LIMIT ?1
+"#,
+ )
+ .context("failed to prepare recent block metrics query")?;
+ let rows = statement
+ .query_map([limit as u64], |row| {
+ Ok(BlockMetricRow {
+ height: row.get(0)?,
+ block_hash: row.get(1)?,
+ timestamp_ms: row.get(2)?,
+ block_time_ms: row.get(3)?,
+ mine_difficulty_bits: row.get(4)?,
+ circulating_supply: row.get(5)?,
+ known_wallet_addresses: row.get(6)?,
+ transaction_count: row.get(7)?,
+ transfer_count: row.get(8)?,
+ burn_count: row.get(9)?,
+ mine_count: row.get(10)?,
+ burned_amount: row.get(11)?,
+ total_burned_amount: row.get(12)?,
+ fees_amount: row.get(13)?,
+ reward_amount: row.get(14)?,
+ vdf_rounds: row.get(15)?,
+ finalizer_rank: row.get(16)?,
+ })
+ })
+ .context("failed to load recent block metrics")?;
+ let mut rows = rows
+ .collect::<std::result::Result<Vec<_>, _>>()
+ .context("failed to read recent block metrics rows")?;
+ rows.reverse();
+ Ok(rows)
+ })
+ }
+
+ pub fn load_wallet_utxos(&self, address: &str) -> Result<Vec<(OutPoint, TxOutput)>> {
+ self.with_connection(|connection| load_wallet_utxos(connection, address))
+ }
+
+ pub fn load_outputs(
+ &self,
+ outpoints: &BTreeSet<OutPoint>,
+ ) -> Result<BTreeMap<OutPoint, TxOutput>> {
+ self.with_connection(|connection| load_outputs(connection, outpoints))
+ }
+
+ pub fn load_wallet_transactions(
+ &self,
+ address: &str,
+ kinds: &[&str],
+ offset: usize,
+ limit: usize,
+ ) -> Result<(Vec<WalletTransactionProjection>, usize)> {
+ self.with_connection(|connection| {
+ load_wallet_transactions(connection, address, kinds, offset, limit)
+ })
+ }
+
+ fn with_connection<T>(&self, work: impl FnOnce(&Connection) -> Result<T>) -> Result<T> {
+ let connection = self.open_connection()?;
+ connection
+ .execute_batch(
+ r#"
+PRAGMA busy_timeout = 5000;
+PRAGMA synchronous = NORMAL;
+"#,
+ )
+ .context("failed to configure UI data database connection")?;
+ work(&connection)
+ }
+
+ fn with_connection_mut<T>(&self, work: impl FnOnce(&mut Connection) -> Result<T>) -> Result<T> {
+ let mut connection = self.open_connection()?;
+ connection
+ .execute_batch(
+ r#"
+PRAGMA journal_mode = WAL;
+PRAGMA busy_timeout = 5000;
+PRAGMA synchronous = NORMAL;
+"#,
+ )
+ .context("failed to configure UI data database connection")?;
+ work(&mut connection)
+ }
+
+ fn open_connection(&self) -> Result<Connection> {
+ Connection::open(&self.path)
+ .with_context(|| format!("failed to open UI data database {}", self.path.display()))
+ }
+}
+
+fn replace_metrics(
+ transaction: &rusqlite::Transaction<'_>,
+ metrics: &[BlockMetricRow],
+) -> Result<()> {
+ clear_metrics_in_transaction(transaction)?;
+ for metric in metrics {
+ transaction
+ .execute(
+ r#"
+INSERT INTO block_metrics (
+ height, block_hash, timestamp_ms, block_time_ms, mine_difficulty_bits,
+ circulating_supply, known_wallet_addresses, transaction_count, transfer_count, burn_count,
+ mine_count, burned_amount, total_burned_amount, fees_amount, reward_amount, vdf_rounds,
+ finalizer_rank
+) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17)
+"#,
+ params![
+ metric.height,
+ metric.block_hash,
+ metric.timestamp_ms,
+ metric.block_time_ms,
+ metric.mine_difficulty_bits,
+ metric.circulating_supply,
+ metric.known_wallet_addresses,
+ metric.transaction_count,
+ metric.transfer_count,
+ metric.burn_count,
+ metric.mine_count,
+ metric.burned_amount,
+ metric.total_burned_amount,
+ metric.fees_amount,
+ metric.reward_amount,
+ metric.vdf_rounds,
+ metric.finalizer_rank,
+ ],
+ )
+ .with_context(|| format!("failed to insert metrics for block {}", metric.height))?;
+ }
+ Ok(())
+}
+
+fn ensure_block_metrics_column(
+ connection: &Connection,
+ name: &str,
+ definition: &str,
+) -> Result<()> {
+ let mut statement = connection
+ .prepare("PRAGMA table_info(block_metrics)")
+ .context("failed to inspect block_metrics schema")?;
+ let columns = statement
+ .query_map([], |row| row.get::<_, String>(1))
+ .context("failed to query block_metrics columns")?
+ .collect::<std::result::Result<Vec<_>, _>>()
+ .context("failed to read block_metrics columns")?;
+ if columns.iter().any(|column| column == name) {
+ return Ok(());
+ }
+ connection
+ .execute(
+ &format!("ALTER TABLE block_metrics ADD COLUMN {name} {definition}"),
+ [],
+ )
+ .with_context(|| format!("failed to add block_metrics.{name} column"))?;
+ Ok(())
+}
+
+fn clear_metrics_in_transaction(transaction: &rusqlite::Transaction<'_>) -> Result<()> {
+ transaction
+ .execute("DELETE FROM block_metrics", [])
+ .context("failed to clear old block metrics")?;
+ 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 replace_ui_utxos(
+ transaction: &rusqlite::Transaction<'_>,
+ utxos: &[(OutPoint, TxOutput)],
+) -> Result<()> {
+ transaction
+ .execute("DELETE FROM ui_utxos", [])
+ .context("failed to clear old UI UTXO index")?;
+ for (outpoint, output) in utxos {
+ transaction
+ .execute(
+ r#"
+INSERT INTO ui_utxos (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 UTXO row {}:{}",
+ outpoint.txid, outpoint.index
+ )
+ })?;
+ }
+ Ok(())
+}
+
+fn replace_ui_wallet_transactions(
+ transaction: &rusqlite::Transaction<'_>,
+ rows: &[(String, WalletTransactionProjection)],
+) -> Result<()> {
+ transaction
+ .execute("DELETE FROM ui_wallet_transactions", [])
+ .context("failed to clear old UI wallet transaction index")?;
+ for (address, row) in rows {
+ let transaction_json = serde_json::to_vec(&row.transaction)
+ .context("failed to serialize UI wallet transaction")?;
+ transaction
+ .execute(
+ r#"
+INSERT INTO ui_wallet_transactions (
+ address, sort_key, kind, signature, block_height, timestamp_ms, block_finalizer, blinded,
+ transaction_json
+) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)
+"#,
+ params![
+ address,
+ row.sort_key,
+ row.kind,
+ row.transaction.signature(),
+ row.block_height,
+ row.timestamp_ms,
+ row.block_finalizer,
+ row.blinded,
+ transaction_json,
+ ],
+ )
+ .with_context(|| {
+ format!(
+ "failed to persist UI wallet transaction {} for {}",
+ row.transaction.signature(),
+ address
+ )
+ })?;
+ }
+ 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_utxos", [])
+ .context("failed to clear old UI UTXO index")?;
+ transaction
+ .execute("DELETE FROM ui_wallet_transactions", [])
+ .context("failed to clear old UI wallet transaction 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_outputs(
+ connection: &Connection,
+ outpoints: &BTreeSet<OutPoint>,
+) -> Result<BTreeMap<OutPoint, TxOutput>> {
+ if outpoints.is_empty() {
+ return Ok(BTreeMap::new());
+ }
+ let mut outputs = BTreeMap::new();
+ let mut statement = connection
+ .prepare(
+ r#"
+SELECT address, amount
+FROM ui_output_index
+WHERE txid = ?1 AND output_index = ?2
+"#,
+ )
+ .context("failed to prepare narrow UI output lookup")?;
+ for outpoint in outpoints {
+ let output = statement
+ .query_row(params![outpoint.txid, outpoint.index], |row| {
+ Ok(TxOutput {
+ address: row.get(0)?,
+ amount: row.get(1)?,
+ })
+ })
+ .optional()
+ .with_context(|| {
+ format!(
+ "failed to load UI output {}:{}",
+ outpoint.txid, outpoint.index
+ )
+ })?;
+ if let Some(output) = output {
+ outputs.insert(outpoint.clone(), output);
+ }
+ }
+ Ok(outputs)
+}
+
+fn load_wallet_utxos(connection: &Connection, address: &str) -> Result<Vec<(OutPoint, TxOutput)>> {
+ let mut statement = connection
+ .prepare(
+ r#"
+SELECT txid, output_index, address, amount
+FROM ui_utxos
+WHERE address = ?1
+ORDER BY amount DESC, txid ASC, output_index ASC
+"#,
+ )
+ .context("failed to prepare UI wallet UTXO query")?;
+ let rows = statement
+ .query_map([address], |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 wallet UTXOs")?;
+ rows.collect::<std::result::Result<Vec<_>, _>>()
+ .context("failed to read UI wallet UTXO rows")
+}
+
+fn load_wallet_transactions(
+ connection: &Connection,
+ address: &str,
+ kinds: &[&str],
+ offset: usize,
+ limit: usize,
+) -> Result<(Vec<WalletTransactionProjection>, usize)> {
+ if kinds.is_empty() {
+ return Ok((Vec::new(), 0));
+ }
+ if wallet_transaction_kinds_cover_all(kinds) {
+ let total = connection
+ .query_row(
+ "SELECT COUNT(*) FROM ui_wallet_transactions WHERE address = ?1",
+ params![address],
+ |row| row.get::<_, u64>(0),
+ )
+ .context("failed to count UI wallet transactions")? as usize;
+ let mut statement = connection
+ .prepare(
+ r#"
+SELECT sort_key, kind, block_height, timestamp_ms, block_finalizer, blinded, transaction_json
+FROM ui_wallet_transactions
+WHERE address = ?1
+ORDER BY sort_key DESC
+LIMIT ?2 OFFSET ?3
+"#,
+ )
+ .context("failed to prepare UI wallet transactions query")?;
+ let rows = read_wallet_transaction_rows(
+ statement.query_map(params![address, limit as i64, offset as i64], |row| {
+ wallet_transaction_projection_from_row(row)
+ })?,
+ )?;
+ return Ok((rows, total));
+ }
+ let placeholders = std::iter::repeat_n("?", kinds.len())
+ .collect::<Vec<_>>()
+ .join(", ");
+ let count_sql = format!(
+ "SELECT COUNT(*) FROM ui_wallet_transactions WHERE address = ? AND kind IN ({placeholders})"
+ );
+ let mut count_params = Vec::<Value>::with_capacity(kinds.len() + 1);
+ count_params.push(Value::Text(address.to_string()));
+ for kind in kinds {
+ count_params.push(Value::Text((*kind).to_string()));
+ }
+ let total = connection
+ .query_row(&count_sql, params_from_iter(count_params.iter()), |row| {
+ row.get::<_, u64>(0)
+ })
+ .context("failed to count UI wallet transactions")? as usize;
+
+ let query_sql = format!(
+ r#"
+SELECT sort_key, kind, block_height, timestamp_ms, block_finalizer, blinded, transaction_json
+FROM ui_wallet_transactions
+WHERE address = ? AND kind IN ({placeholders})
+ORDER BY sort_key DESC
+LIMIT ? OFFSET ?
+"#
+ );
+ let mut query_params = Vec::<Value>::with_capacity(kinds.len() + 3);
+ query_params.push(Value::Text(address.to_string()));
+ for kind in kinds {
+ query_params.push(Value::Text((*kind).to_string()));
+ }
+ query_params.push(Value::Integer(limit as i64));
+ query_params.push(Value::Integer(offset as i64));
+ let mut statement = connection
+ .prepare(&query_sql)
+ .context("failed to prepare UI wallet transactions query")?;
+ let rows = read_wallet_transaction_rows(
+ statement
+ .query_map(params_from_iter(query_params.iter()), |row| {
+ wallet_transaction_projection_from_row(row)
+ })
+ .context("failed to load UI wallet transactions")?,
+ )?;
+ Ok((rows, total))
+}
+
+fn wallet_transaction_kinds_cover_all(kinds: &[&str]) -> bool {
+ ["transfer", "mine", "burn"]
+ .into_iter()
+ .all(|kind| kinds.contains(&kind))
+}
+
+fn wallet_transaction_projection_from_row(
+ row: &rusqlite::Row<'_>,
+) -> rusqlite::Result<WalletTransactionProjection> {
+ let transaction_json = row.get::<_, Vec<u8>>(6)?;
+ 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(WalletTransactionProjection {
+ sort_key: row.get(0)?,
+ kind: row.get(1)?,
+ block_height: row.get(2)?,
+ timestamp_ms: row.get(3)?,
+ block_finalizer: row.get(4)?,
+ blinded: row.get::<_, u64>(5)? != 0,
+ transaction,
+ })
+}
+
+fn read_wallet_transaction_rows(
+ rows: rusqlite::MappedRows<
+ '_,
+ impl FnMut(&rusqlite::Row<'_>) -> rusqlite::Result<WalletTransactionProjection>,
+ >,
+) -> Result<Vec<WalletTransactionProjection>> {
+ rows.collect::<std::result::Result<Vec<_>, _>>()
+ .context("failed to read UI wallet transaction 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 wallet_transactions_from_snapshot(
+ snapshot: &ChainSnapshot,
+) -> Vec<(String, WalletTransactionProjection)> {
+ let revealed_by_height = 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
+ },
+ );
+ let mut rows = Vec::new();
+ for block in &snapshot.blocks {
+ for (index, transaction) in block.transactions.iter().rev().enumerate() {
+ push_wallet_transaction_projection(
+ &mut rows,
+ transaction,
+ block,
+ block.height as u128 * 10_000 + index as u128,
+ false,
+ );
+ }
+ if let Some(revealed) = revealed_by_height.get(&block.height) {
+ for (index, revealed) in revealed.iter().rev().enumerate() {
+ push_wallet_transaction_projection(
+ &mut rows,
+ &revealed.transaction,
+ block,
+ block.height as u128 * 10_000 + 5_000 + index as u128,
+ false,
+ );
+ }
+ }
+ }
+ rows
+}
+
+fn push_wallet_transaction_projection(
+ rows: &mut Vec<(String, WalletTransactionProjection)>,
+ transaction: &Transaction,
+ block: &Block,
+ sort_key: u128,
+ blinded: bool,
+) {
+ let kind = transaction_kind(transaction).to_string();
+ let projection = WalletTransactionProjection {
+ sort_key: sort_key.min(u128::from(u64::MAX)) as u64,
+ kind,
+ block_height: block.height,
+ timestamp_ms: block.timestamp_ms,
+ block_finalizer: block.miner.clone(),
+ blinded,
+ transaction: transaction.clone(),
+ };
+ for address in wallet_transaction_addresses(transaction) {
+ rows.push((address, projection.clone()));
+ }
+}
+
+fn wallet_transaction_addresses(transaction: &Transaction) -> Vec<String> {
+ match transaction {
+ Transaction::Transfer { .. } => {
+ let mut addresses = vec![transaction.sender().to_string()];
+ if let Some(to) = transaction.to() {
+ if to != transaction.sender() {
+ addresses.push(to.to_string());
+ }
+ }
+ addresses
+ }
+ Transaction::Burn { .. } => vec![transaction.sender().to_string()],
+ Transaction::Mine { recipient, .. } => vec![recipient.clone()],
+ }
+}
+
+fn transaction_kind(transaction: &Transaction) -> &'static str {
+ match transaction {
+ Transaction::Transfer { .. } => "transfer",
+ Transaction::Burn { .. } => "burn",
+ Transaction::Mine { .. } => "mine",
+ }
+}
+
+fn metrics_from_snapshot(snapshot: &ChainSnapshot) -> Result<Vec<BlockMetricRow>> {
+ let ledger = Ledger::from_persisted_snapshot(snapshot.clone())
+ .context("failed to rebuild ledger for metrics")?;
+ let genesis = snapshot
+ .blocks
+ .first()
+ .cloned()
+ .context("cannot compute metrics for empty chain snapshot")?;
+ let mut running_ledger = Ledger::from_persisted_snapshot(ChainSnapshot {
+ genesis_allocations: snapshot.genesis_allocations.clone(),
+ vdf_rounds: snapshot.vdf_rounds,
+ launch_profile: snapshot.launch_profile.clone(),
+ blocks: vec![genesis],
+ })
+ .context("failed to rebuild genesis ledger for metrics")?;
+ let revealed = revealed_blinded_transactions(snapshot)?.into_iter().fold(
+ BTreeMap::<u64, Vec<crate::domain::RevealedBlindedTransaction>>::new(),
+ |mut by_height, revealed| {
+ by_height.entry(revealed.height).or_default().push(revealed);
+ by_height
+ },
+ );
+ let mut known_wallet_addresses = snapshot
+ .genesis_allocations
+ .keys()
+ .cloned()
+ .collect::<BTreeSet<_>>();
+ let mut total_burned_amount = 0_u64;
+ let mut rows = Vec::with_capacity(snapshot.blocks.len());
+ let mut previous_timestamp_ms = None;
+ 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();
+ let mut transfer_count = 0_u64;
+ let mut burn_count = 0_u64;
+ let mut mine_count = 0_u64;
+ let mut burned_amount = 0_u64;
+ let mut burned_fee_amount = 0_u64;
+ let mut fees_amount = 0_u64;
+
+ known_wallet_addresses.insert(block.miner.clone());
+ for signature in &block.reveal_bundle_section.signatures {
+ known_wallet_addresses.insert(signature.member.clone());
+ }
+ for transaction in &block.transactions {
+ collect_transaction_addresses(transaction, &mut known_wallet_addresses);
+ metric_apply_public_transaction(transaction, &mut metric_utxos)?;
+ fees_amount = fees_amount
+ .checked_add(transaction.fee())
+ .context("block metric fees overflow")?;
+ match transaction {
+ Transaction::Transfer { .. } => transfer_count += 1,
+ Transaction::Burn { amount, .. } => {
+ burn_count += 1;
+ burned_amount = burned_amount
+ .checked_add(*amount)
+ .context("block metric burns overflow")?;
+ }
+ Transaction::Mine { .. } => {
+ mine_count += 1;
+ }
+ }
+ }
+ for revealed in &revealed_transactions {
+ let transaction = &revealed.transaction;
+ known_wallet_addresses.insert(revealed.included_by.clone());
+ collect_transaction_addresses(transaction, &mut known_wallet_addresses);
+ metric_index_transaction_outputs(&mut metric_utxos, transaction);
+ metric_index_blinded_fee_outputs(
+ &mut metric_utxos,
+ &revealed.commitment,
+ &revealed.included_by,
+ block,
+ transaction.fee(),
+ reveal_bundle_slots_by_height
+ .get(&block.height)
+ .copied()
+ .unwrap_or(REVEAL_COMMITTEE_SIZE),
+ );
+ fees_amount = fees_amount
+ .checked_add(transaction.fee())
+ .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 = reveal_bundle_slots_by_height
+ .get(&block.height)
+ .copied()
+ .unwrap_or(REVEAL_COMMITTEE_SIZE);
+ let reveal_finalizer_fee = blinded_reveal_finalizer_fee(
+ transaction.fee(),
+ included_reveal_bundle_count,
+ available_reveal_bundle_slots,
+ );
+ let reveal_bundle_signer_fees =
+ blinded_fee_share(transaction.fee(), BLINDED_REVEAL_BUNDLE_SIGNER_FEE_BPS)
+ .saturating_mul(included_reveal_bundle_count as u64);
+ let distributed_fee = committer_fee
+ .saturating_add(reveal_finalizer_fee)
+ .saturating_add(reveal_bundle_signer_fees);
+ burned_fee_amount = burned_fee_amount
+ .checked_add(transaction.fee().saturating_sub(distributed_fee))
+ .context("block metric burned fees overflow")?;
+ match transaction {
+ Transaction::Transfer { .. } => transfer_count += 1,
+ Transaction::Burn { amount, .. } => {
+ burn_count += 1;
+ burned_amount = burned_amount
+ .checked_add(*amount)
+ .context("block metric burns overflow")?;
+ }
+ Transaction::Mine { .. } => {
+ mine_count += 1;
+ }
+ }
+ }
+ let revealed_commitments = block
+ .all_blinded_reveals()
+ .into_iter()
+ .map(|reveal| reveal.commitment.clone())
+ .collect::<std::collections::BTreeSet<_>>();
+ let mut expired_blinded_fee_values = Vec::new();
+ active_blinded.retain(|commitment, transaction| {
+ if revealed_commitments.contains(commitment) {
+ metric_locked_blinded_inputs.remove(commitment);
+ return false;
+ }
+ if block.height >= transaction.expires_at_height {
+ if !transaction.inputs.is_empty() {
+ expired_blinded_fee_values.push(transaction.fee);
+ metric_index_expired_blinded_change(
+ &mut metric_utxos,
+ commitment,
+ transaction,
+ metric_locked_blinded_inputs
+ .remove(commitment)
+ .unwrap_or_default(),
+ );
+ }
+ return false;
+ }
+ true
+ });
+ let expired_blinded_fees =
+ expired_blinded_fee_values
+ .into_iter()
+ .try_fold(0_u64, |total, fee| {
+ total
+ .checked_add(fee)
+ .context("block metric expiry fees overflow")
+ })?;
+ fees_amount = fees_amount
+ .checked_add(expired_blinded_fees)
+ .context("block metric expiry fees overflow")?;
+ burned_fee_amount = burned_fee_amount
+ .checked_add(expired_blinded_fees)
+ .context("block metric expired burned fees overflow")?;
+ for transaction in &block.blinded_transactions {
+ for input in &transaction.inputs {
+ known_wallet_addresses.insert(input.owner.clone());
+ }
+ let locked_total = metric_spend_blinded_inputs(transaction, &mut metric_utxos)?;
+ metric_locked_blinded_inputs.insert(transaction.commitment.clone(), locked_total);
+ active_blinded.insert(transaction.commitment.clone(), transaction.clone());
+ }
+ total_burned_amount = total_burned_amount
+ .checked_add(burned_amount)
+ .and_then(|amount| amount.checked_add(burned_fee_amount))
+ .context("total burned metric overflows")?;
+
+ if block.height > 0 {
+ running_ledger
+ .apply_preverified_block_at(block.clone(), u64::MAX)
+ .with_context(|| format!("failed to replay block {} for metrics", block.height))?;
+ }
+ metric_index_block_reward(&mut metric_utxos, block);
+ let circulating_supply = ledger_circulating_supply(&running_ledger)?
+ .checked_add(metric_locked_supply(&metric_locked_blinded_inputs)?)
+ .context("circulating supply metric overflows")?;
+ let block_time_ms =
+ previous_timestamp_ms.map(|previous| block.timestamp_ms.saturating_sub(previous));
+ previous_timestamp_ms = Some(block.timestamp_ms);
+ rows.push(BlockMetricRow {
+ height: block.height,
+ block_hash: block.hash.clone(),
+ timestamp_ms: block.timestamp_ms,
+ block_time_ms,
+ mine_difficulty_bits: ledger.mine_difficulty_bits_at_height(block.height),
+ circulating_supply,
+ known_wallet_addresses: known_wallet_addresses.len() as u64,
+ transaction_count: (block.transactions.len() + revealed_transactions.len()) as u64,
+ transfer_count,
+ burn_count,
+ mine_count,
+ burned_amount,
+ total_burned_amount,
+ fees_amount,
+ reward_amount: block.reward,
+ vdf_rounds: block.vdf_rounds,
+ finalizer_rank: block.finalizer_rank,
+ });
+ }
+ Ok(rows)
+}
+
+fn ledger_circulating_supply(ledger: &Ledger) -> Result<Amount> {
+ ledger
+ .status()
+ .balances
+ .values()
+ .try_fold(0_u64, |total, amount| {
+ total
+ .checked_add(*amount)
+ .context("circulating supply metric overflows")
+ })
+}
+
+fn metric_locked_supply(locked: &BTreeMap<String, Amount>) -> Result<Amount> {
+ locked.values().try_fold(0_u64, |total, amount| {
+ total
+ .checked_add(*amount)
+ .context("circulating supply metric overflows")
+ })
+}
+
+fn metric_genesis_utxos(snapshot: &ChainSnapshot) -> BTreeMap<OutPoint, TxOutput> {
+ snapshot
+ .genesis_allocations
+ .iter()
+ .filter(|(_, amount)| **amount > 0)
+ .map(|(address, amount)| {
+ (
+ metric_genesis_allocation_outpoint(address),
+ TxOutput {
+ address: address.clone(),
+ amount: *amount,
+ },
+ )
+ })
+ .collect()
+}
+
+fn blinded_fee_share(fee: Amount, bps: u64) -> Amount {
+ ((fee as u128 * bps as u128) / BLINDED_FEE_BPS_DENOMINATOR as u128) as Amount
+}
+
+fn metric_apply_public_transaction(
+ transaction: &Transaction,
+ utxos: &mut BTreeMap<OutPoint, TxOutput>,
+) -> Result<()> {
+ metric_spend_transaction_inputs(transaction, utxos)?;
+ metric_index_transaction_outputs(utxos, transaction);
+ Ok(())
+}
+
+fn metric_spend_transaction_inputs(
+ transaction: &Transaction,
+ utxos: &mut BTreeMap<OutPoint, TxOutput>,
+) -> Result<Amount> {
+ let inputs = match transaction {
+ Transaction::Transfer { inputs, .. } | Transaction::Burn { inputs, .. } => inputs,
+ Transaction::Mine { .. } => return Ok(0),
+ };
+ metric_spend_inputs(inputs, utxos)
+}
+
+fn metric_spend_blinded_inputs(
+ transaction: &BlindedTransaction,
+ utxos: &mut BTreeMap<OutPoint, TxOutput>,
+) -> Result<Amount> {
+ metric_spend_inputs(&transaction.inputs, utxos)
+}
+
+fn metric_spend_inputs(
+ inputs: &[TxInput],
+ utxos: &mut BTreeMap<OutPoint, TxOutput>,
+) -> Result<Amount> {
+ inputs.iter().try_fold(0_u64, |total, input| {
+ let output = utxos.remove(&input.outpoint).with_context(|| {
+ format!(
+ "metric replay spends missing output {}:{}",
+ input.outpoint.txid, input.outpoint.index
+ )
+ })?;
+ total
+ .checked_add(output.amount)
+ .context("metric replay input total overflows")
+ })
+}
+
+fn metric_index_transaction_outputs(
+ utxos: &mut BTreeMap<OutPoint, TxOutput>,
+ transaction: &Transaction,
+) {
+ let 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 outputs.iter().enumerate() {
+ utxos.insert(
+ OutPoint {
+ txid: transaction.signature().to_string(),
+ index: index as u32,
+ },
+ output.clone(),
+ );
+ }
+}
+
+fn metric_index_blinded_fee_outputs(
+ utxos: &mut BTreeMap<OutPoint, TxOutput>,
+ commitment: &str,
+ included_by: &str,
+ block: &Block,
+ fee: Amount,
+ available_reveal_bundle_slots: usize,
+) {
+ if fee == 0 {
+ return;
+ }
+ let committer_fee = blinded_fee_share(fee, BLINDED_COMMITTER_FEE_BPS);
+ if committer_fee > 0 {
+ utxos.insert(
+ metric_blinded_committer_fee_outpoint(commitment),
+ TxOutput {
+ address: included_by.to_string(),
+ amount: committer_fee,
+ },
+ );
+ }
+ let reveal_finalizer_fee = blinded_reveal_finalizer_fee(
+ fee,
+ block.included_reveal_bundle_count(),
+ available_reveal_bundle_slots,
+ );
+ if reveal_finalizer_fee > 0 && block.height < AGGREGATE_FINALIZER_FEE_ACTIVATION_HEIGHT {
+ utxos.insert(
+ metric_blinded_executor_fee_outpoint(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 {
+ utxos.insert(
+ metric_blinded_reveal_bundle_signer_fee_outpoint(commitment, signature.slot),
+ TxOutput {
+ address: signature.member.clone(),
+ amount: reveal_bundle_signer_fee,
+ },
+ );
+ }
+ }
+}
+
+fn metric_index_expired_blinded_change(
+ utxos: &mut BTreeMap<OutPoint, TxOutput>,
+ commitment: &str,
+ transaction: &BlindedTransaction,
+ locked_total: Amount,
+) {
+ let Some(first_input) = transaction.inputs.first() else {
+ return;
+ };
+ let change = locked_total.saturating_sub(transaction.fee);
+ if change == 0 {
+ return;
+ }
+ utxos.insert(
+ metric_blinded_expiry_change_outpoint(commitment),
+ TxOutput {
+ address: first_input.owner.clone(),
+ amount: change,
+ },
+ );
+}
+
+fn metric_index_block_reward(utxos: &mut BTreeMap<OutPoint, TxOutput>, block: &Block) {
+ if block.reward == 0 {
+ return;
+ }
+ utxos.insert(
+ metric_reward_outpoint(&block.hash),
+ TxOutput {
+ address: block.miner.clone(),
+ amount: block.reward,
+ },
+ );
+}
+
+fn metric_genesis_allocation_outpoint(address: &str) -> OutPoint {
+ OutPoint {
+ txid: hex_hash(format!("iuna-genesis-allocation:{address}")),
+ index: 0,
+ }
+}
+
+fn metric_reward_outpoint(block_hash: &str) -> OutPoint {
+ OutPoint {
+ txid: block_hash.to_string(),
+ index: u32::MAX,
+ }
+}
+
+fn metric_blinded_committer_fee_outpoint(commitment: &str) -> OutPoint {
+ OutPoint {
+ txid: commitment.to_string(),
+ index: u32::MAX - 1,
+ }
+}
+
+fn metric_blinded_executor_fee_outpoint(commitment: &str) -> OutPoint {
+ OutPoint {
+ txid: commitment.to_string(),
+ index: u32::MAX - 2,
+ }
+}
+
+fn metric_blinded_reveal_bundle_signer_fee_outpoint(commitment: &str, slot: u8) -> OutPoint {
+ OutPoint {
+ txid: commitment.to_string(),
+ index: u32::MAX - 3 - u32::from(slot),
+ }
+}
+
+fn metric_blinded_expiry_change_outpoint(commitment: &str) -> OutPoint {
+ OutPoint {
+ txid: commitment.to_string(),
+ index: 0,
+ }
+}
+
+fn collect_transaction_addresses(transaction: &Transaction, addresses: &mut BTreeSet<String>) {
+ match transaction {
+ Transaction::Transfer {
+ inputs, outputs, ..
+ } => {
+ collect_input_addresses(inputs, addresses);
+ collect_output_addresses(outputs, addresses);
+ }
+ Transaction::Burn { inputs, change, .. } => {
+ collect_input_addresses(inputs, addresses);
+ collect_output_addresses(change, addresses);
+ }
+ Transaction::Mine { recipient, .. } => {
+ addresses.insert(recipient.clone());
+ }
+ }
+}
+
+fn collect_input_addresses(inputs: &[TxInput], addresses: &mut BTreeSet<String>) {
+ for input in inputs {
+ addresses.insert(input.owner.clone());
+ }
+}
+
+fn collect_output_addresses(outputs: &[TxOutput], addresses: &mut BTreeSet<String>) {
+ for output in outputs {
+ addresses.insert(output.address.clone());
+ }
+}
+
+fn unix_ms() -> u64 {
+ SystemTime::now()
+ .duration_since(UNIX_EPOCH)
+ .unwrap_or_default()
+ .as_millis() as u64
+}
+
+#[cfg(test)]
+mod tests;
diff --git a/src/adapters/ui_data_store/tests.rs b/src/adapters/ui_data_store/tests.rs
@@ -0,0 +1,499 @@
+use std::collections::BTreeMap;
+
+use rusqlite::Connection;
+use tempfile::tempdir;
+
+use crate::{
+ adapters::ui_index::build_ui_chain_index,
+ domain::{BLOCK_REWARD, GenesisBurn, Ledger, OutPoint, Wallet, run_vdf},
+};
+
+use super::{BlockMetricRow, SqliteUiDataStore, replace_metrics};
+
+#[test]
+fn sqlite_ui_data_store_does_not_create_chain_snapshot_table() {
+ let dir = tempdir().unwrap();
+ let store = SqliteUiDataStore::open(dir.path().join("ui_data.sqlite3")).unwrap();
+
+ store
+ .with_connection(|connection| {
+ let count = connection.query_row(
+ "SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = 'chain_snapshots'",
+ [],
+ |row| row.get::<_, u64>(0),
+ )?;
+ assert_eq!(count, 0);
+ Ok(())
+ })
+ .unwrap();
+}
+
+#[test]
+fn sqlite_ui_data_store_persists_ui_chain_index_for_latest_tip() {
+ let dir = tempdir().unwrap();
+ let store = SqliteUiDataStore::open(dir.path().join("ui_data.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.project_snapshot(&snapshot, false).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 sqlite_ui_data_store_clear_all_removes_metrics_and_ui_indexes() {
+ let dir = tempdir().unwrap();
+ let store = SqliteUiDataStore::open(dir.path().join("ui_data.sqlite3")).unwrap();
+ let wallet = Wallet::from_seed("clear-ui-data-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 expected = build_ui_chain_index(&ledger.snapshot());
+ let tip_hash = expected.tip_hash.as_deref().unwrap();
+
+ store.project_snapshot(&ledger.snapshot(), true).unwrap();
+ assert!(!store.load_metrics().unwrap().is_empty());
+ assert!(store.load_ui_chain_index(tip_hash).unwrap().is_some());
+
+ store.clear_all().unwrap();
+
+ assert!(store.load_metrics().unwrap().is_empty());
+ assert!(store.load_ui_chain_index(tip_hash).unwrap().is_none());
+}
+
+#[test]
+fn sqlite_ui_data_store_saves_and_clears_block_metrics() {
+ let dir = tempdir().unwrap();
+ let store = SqliteUiDataStore::open(dir.path().join("ui_data.sqlite3")).unwrap();
+ let wallet = Wallet::from_seed("metrics-alice");
+ let mut genesis = BTreeMap::new();
+ genesis.insert(wallet.address().to_string(), 10);
+ let mut ledger =
+ Ledger::new_with_genesis_burns(genesis, vec![GenesisBurn::new(wallet.address(), 1)], 1)
+ .unwrap();
+ let burn = ledger.build_burn(&wallet, 2, 1).unwrap();
+ ledger.submit_transaction(burn).unwrap();
+ let block = ledger.mine_next_block(&wallet, 1_000).unwrap();
+ ledger.apply_locally_mined_block(block).unwrap();
+
+ store.project_snapshot(&ledger.snapshot(), true).unwrap();
+ let metrics = store.load_metrics().unwrap();
+
+ assert_eq!(metrics.last().unwrap().height, 1);
+ assert_eq!(metrics.last().unwrap().burn_count, 1);
+ assert_eq!(metrics.last().unwrap().burned_amount, 2);
+ assert_eq!(metrics.last().unwrap().fees_amount, 1);
+ assert_eq!(
+ metrics.last().unwrap().circulating_supply,
+ ledger.status().balances.values().copied().sum::<u64>()
+ );
+ assert_eq!(metrics.last().unwrap().known_wallet_addresses, 1);
+
+ store.clear_metrics().unwrap();
+ assert!(store.load_metrics().unwrap().is_empty());
+}
+
+#[test]
+fn sqlite_ui_data_store_projects_wallet_utxos() {
+ let dir = tempdir().unwrap();
+ let store = SqliteUiDataStore::open(dir.path().join("ui_data.sqlite3")).unwrap();
+ let alice = Wallet::from_seed("ui-utxo-alice");
+ let bob = Wallet::from_seed("ui-utxo-bob");
+ let mut genesis = BTreeMap::new();
+ genesis.insert(alice.address().to_string(), 100);
+ let mut ledger =
+ Ledger::new_with_genesis_burns(genesis, vec![GenesisBurn::new(alice.address(), 1)], 1)
+ .unwrap();
+ let burn = ledger.build_burn(&alice, 1, 0).unwrap();
+ ledger.submit_transaction(burn).unwrap();
+ let transfer = ledger.build_transfer(&alice, bob.address(), 10, 1).unwrap();
+ ledger.submit_transaction(transfer).unwrap();
+ let block = ledger.mine_next_block(&alice, 1_000).unwrap();
+ ledger.apply_locally_mined_block(block).unwrap();
+
+ store.project_snapshot(&ledger.snapshot(), false).unwrap();
+
+ let bob_utxos = store.load_wallet_utxos(bob.address()).unwrap();
+ assert_eq!(bob_utxos.len(), 1);
+ assert_eq!(bob_utxos[0].1.amount, 10);
+}
+
+#[test]
+fn sqlite_ui_data_store_loads_only_requested_outputs() {
+ let dir = tempdir().unwrap();
+ let store = SqliteUiDataStore::open(dir.path().join("ui_data.sqlite3")).unwrap();
+ let alice = Wallet::from_seed("ui-output-lookup-alice");
+ let bob = Wallet::from_seed("ui-output-lookup-bob");
+ let mut genesis = BTreeMap::new();
+ genesis.insert(alice.address().to_string(), 100);
+ let mut ledger =
+ Ledger::new_with_genesis_burns(genesis, vec![GenesisBurn::new(alice.address(), 1)], 1)
+ .unwrap();
+ let burn = ledger.build_burn(&alice, 1, 0).unwrap();
+ ledger.submit_transaction(burn).unwrap();
+ let transfer = ledger.build_transfer(&alice, bob.address(), 10, 1).unwrap();
+ let output = OutPoint {
+ txid: transfer.signature().to_string(),
+ index: 0,
+ };
+ ledger.submit_transaction(transfer).unwrap();
+ let block = ledger.mine_next_block(&alice, 1_000).unwrap();
+ ledger.apply_locally_mined_block(block).unwrap();
+ store.project_snapshot(&ledger.snapshot(), false).unwrap();
+
+ let requested = [
+ output.clone(),
+ OutPoint {
+ txid: "missing".to_string(),
+ index: 99,
+ },
+ ]
+ .into_iter()
+ .collect();
+ let outputs = store.load_outputs(&requested).unwrap();
+
+ assert_eq!(outputs.len(), 1);
+ assert_eq!(outputs.get(&output).unwrap().amount, 10);
+ assert_eq!(outputs.get(&output).unwrap().address, bob.address());
+}
+
+#[test]
+fn sqlite_ui_data_store_projects_wallet_transactions() {
+ let dir = tempdir().unwrap();
+ let store = SqliteUiDataStore::open(dir.path().join("ui_data.sqlite3")).unwrap();
+ let alice = Wallet::from_seed("ui-wallet-tx-alice");
+ let bob = Wallet::from_seed("ui-wallet-tx-bob");
+ let mut genesis = BTreeMap::new();
+ genesis.insert(alice.address().to_string(), 100);
+ let mut ledger =
+ Ledger::new_with_genesis_burns(genesis, vec![GenesisBurn::new(alice.address(), 1)], 1)
+ .unwrap();
+ let burn = ledger.build_burn(&alice, 1, 0).unwrap();
+ ledger.submit_transaction(burn).unwrap();
+ let transfer = ledger.build_transfer(&alice, bob.address(), 10, 1).unwrap();
+ let signature = transfer.signature().to_string();
+ ledger.submit_transaction(transfer).unwrap();
+ let block = ledger.mine_next_block(&alice, 1_000).unwrap();
+ ledger.apply_locally_mined_block(block).unwrap();
+
+ store.project_snapshot(&ledger.snapshot(), false).unwrap();
+
+ let (rows, total) = store
+ .load_wallet_transactions(bob.address(), &["transfer"], 0, 25)
+ .unwrap();
+ assert_eq!(total, 1);
+ assert_eq!(rows.len(), 1);
+ assert_eq!(rows[0].transaction.signature(), signature);
+ assert_eq!(rows[0].kind, "transfer");
+}
+
+#[test]
+fn sqlite_ui_data_store_loads_recent_metrics_in_height_order() {
+ let dir = tempdir().unwrap();
+ let store = SqliteUiDataStore::open(dir.path().join("ui_data.sqlite3")).unwrap();
+ let wallet = Wallet::from_seed("recent-metrics-alice");
+ let mut genesis = BTreeMap::new();
+ genesis.insert(wallet.address().to_string(), 10);
+ let mut ledger =
+ Ledger::new_with_genesis_burns(genesis, vec![GenesisBurn::new(wallet.address(), 1)], 1)
+ .unwrap();
+
+ for timestamp_ms in [1_000, 2_000, 3_000] {
+ let burn = ledger.build_burn(&wallet, 1, 0).unwrap();
+ ledger.submit_transaction(burn).unwrap();
+ let block = ledger.mine_next_block(&wallet, timestamp_ms).unwrap();
+ ledger.apply_locally_mined_block(block).unwrap();
+ }
+ store.project_snapshot(&ledger.snapshot(), true).unwrap();
+
+ let metrics = store.load_recent_metrics(2).unwrap();
+
+ assert_eq!(
+ metrics.iter().map(|row| row.height).collect::<Vec<_>>(),
+ vec![2, 3]
+ );
+}
+
+#[test]
+fn sqlite_ui_data_store_migrates_known_wallet_address_metrics_column() {
+ let dir = tempdir().unwrap();
+ let path = dir.path().join("ui_data.sqlite3");
+ let connection = Connection::open(&path).unwrap();
+ connection
+ .execute_batch(
+ r#"
+CREATE TABLE block_metrics (
+ height INTEGER PRIMARY KEY,
+ block_hash TEXT NOT NULL,
+ timestamp_ms INTEGER NOT NULL,
+ block_time_ms INTEGER,
+ mine_difficulty_bits INTEGER NOT NULL,
+ circulating_supply INTEGER NOT NULL,
+ transaction_count INTEGER NOT NULL,
+ transfer_count INTEGER NOT NULL,
+ burn_count INTEGER NOT NULL,
+ mine_count INTEGER NOT NULL,
+ burned_amount INTEGER NOT NULL,
+ total_burned_amount INTEGER NOT NULL,
+ fees_amount INTEGER NOT NULL,
+ reward_amount INTEGER NOT NULL,
+ vdf_rounds INTEGER NOT NULL,
+ finalizer_rank INTEGER NOT NULL
+);
+"#,
+ )
+ .unwrap();
+ drop(connection);
+
+ let store = SqliteUiDataStore::open(&path).unwrap();
+
+ store
+ .with_connection(|connection| {
+ let count = connection
+ .query_row(
+ "SELECT COUNT(*) FROM pragma_table_info('block_metrics') WHERE name = 'known_wallet_addresses'",
+ [],
+ |row| row.get::<_, u64>(0),
+ )
+ .unwrap();
+ assert_eq!(count, 1);
+ Ok(())
+ })
+ .unwrap();
+}
+
+#[test]
+fn sqlite_ui_data_store_metrics_supply_matches_wallet_balances_after_block_rewards() {
+ let dir = tempdir().unwrap();
+ let store = SqliteUiDataStore::open(dir.path().join("ui_data.sqlite3")).unwrap();
+ let alice = Wallet::from_seed("metrics-supply-alice");
+ let mut genesis = BTreeMap::new();
+ genesis.insert(alice.address().to_string(), 100);
+ let mut ledger =
+ Ledger::new_with_genesis_burns(genesis, vec![GenesisBurn::new(alice.address(), 1)], 1)
+ .unwrap();
+
+ for timestamp_ms in [1_000, 2_000, 3_000] {
+ let burn = ledger.build_burn(&alice, 1, 1).unwrap();
+ ledger.submit_transaction(burn).unwrap();
+ let block = ledger.mine_next_block(&alice, timestamp_ms).unwrap();
+ ledger.apply_locally_mined_block(block).unwrap();
+ }
+
+ store.project_snapshot(&ledger.snapshot(), true).unwrap();
+ let metrics = store.load_metrics().unwrap();
+ let supply_from_balances = ledger.status().balances.values().copied().sum::<u64>();
+
+ assert_eq!(metrics.last().unwrap().height, 3);
+ assert_eq!(
+ metrics.last().unwrap().circulating_supply,
+ supply_from_balances
+ );
+}
+
+#[test]
+fn sqlite_ui_data_store_metrics_count_known_wallet_addresses_seen_on_chain() {
+ let dir = tempdir().unwrap();
+ let store = SqliteUiDataStore::open(dir.path().join("ui_data.sqlite3")).unwrap();
+ let alice = Wallet::from_seed("metrics-address-alice");
+ let bob = Wallet::from_seed("metrics-address-bob");
+ let carol = Wallet::from_seed("metrics-address-carol");
+ let mut genesis = BTreeMap::new();
+ genesis.insert(alice.address().to_string(), 100);
+ let mut ledger =
+ Ledger::new_with_genesis_burns(genesis, vec![GenesisBurn::new(alice.address(), 1)], 1)
+ .unwrap();
+
+ let burn = ledger.build_burn(&alice, 1, 1).unwrap();
+ ledger.submit_transaction(burn).unwrap();
+ let transfer = ledger.build_transfer(&alice, bob.address(), 10, 1).unwrap();
+ ledger.submit_transaction(transfer).unwrap();
+ let mine = ledger.build_mine(carol.address()).unwrap();
+ ledger.submit_transaction(mine).unwrap();
+ let block = ledger.mine_next_block(&alice, 1_000).unwrap();
+ ledger.apply_locally_mined_block(block).unwrap();
+
+ store.project_snapshot(&ledger.snapshot(), true).unwrap();
+ let metrics = store.load_metrics().unwrap();
+
+ assert_eq!(metrics[0].known_wallet_addresses, 1);
+ assert_eq!(metrics.last().unwrap().known_wallet_addresses, 3);
+}
+
+#[test]
+fn sqlite_ui_data_store_metrics_include_revealed_blinded_burns() {
+ let dir = tempdir().unwrap();
+ let store = SqliteUiDataStore::open(dir.path().join("ui_data.sqlite3")).unwrap();
+ let alice = Wallet::from_seed("metrics-blinded-alice");
+ let bob = Wallet::from_seed("metrics-blinded-bob");
+ let carol = Wallet::from_seed("metrics-blinded-carol");
+ let wallets = [alice.clone(), bob.clone()];
+ let mut genesis = BTreeMap::new();
+ genesis.insert(alice.address().to_string(), 10_000_000);
+ genesis.insert(bob.address().to_string(), 10_000_000);
+ genesis.insert(carol.address().to_string(), 10_000_000);
+ let mut ledger = Ledger::new_with_genesis_burns(
+ genesis,
+ vec![
+ GenesisBurn::new(alice.address(), 1_000_000),
+ GenesisBurn::new(bob.address(), 1_000_000),
+ ],
+ 1,
+ )
+ .unwrap();
+ let blinded = ledger
+ .build_blinded_burn(&carol, 3, 7, ledger.height() + 4)
+ .unwrap();
+ ledger
+ .submit_blinded_transaction(blinded.transaction)
+ .unwrap();
+ let leader = ledger.expected_leader_for_next_block().unwrap();
+ let wallet = wallets
+ .iter()
+ .find(|wallet| wallet.address() == leader)
+ .unwrap();
+ let burn = ledger.build_burn(wallet, 1, 0).unwrap();
+ ledger.submit_transaction(burn).unwrap();
+ let block = ledger.mine_next_block(wallet, 1).unwrap();
+ ledger.apply_locally_mined_block(block).unwrap();
+ let supply_after_commit = ledger.status().balances.values().copied().sum::<u64>();
+ ledger.submit_blinded_reveal(blinded.reveal).unwrap();
+ let leader = ledger.expected_leader_for_next_block().unwrap();
+ let wallet = wallets
+ .iter()
+ .find(|wallet| wallet.address() == leader)
+ .unwrap();
+ let burn = ledger.build_burn(wallet, 1, 0).unwrap();
+ ledger.submit_transaction(burn).unwrap();
+ let bundles = ledger
+ .reveal_committee_for_next_block()
+ .into_iter()
+ .filter_map(|member| {
+ let wallet = wallets
+ .iter()
+ .find(|wallet| wallet.address() == member.owner)
+ .unwrap();
+ ledger.build_reveal_bundle(wallet).unwrap()
+ })
+ .collect::<Vec<_>>();
+ assert!(!bundles.is_empty());
+ let prepared = ledger
+ .prepare_next_block_with_reveal_bundles(wallet.address(), 2, bundles)
+ .unwrap();
+ let vdf_output = run_vdf(prepared.vdf_seed(), prepared.vdf_rounds());
+ let block = prepared.finish(wallet, vdf_output);
+ assert_eq!(block.all_blinded_reveals().len(), 1);
+ ledger.apply_locally_mined_block(block).unwrap();
+
+ store.project_snapshot(&ledger.snapshot(), true).unwrap();
+ let metrics = store.load_metrics().unwrap();
+ let commit = metrics
+ .iter()
+ .find(|metric| metric.height == 1)
+ .expect("commit block metrics should exist");
+ let last = metrics.last().unwrap();
+ let supply_from_balances = ledger.status().balances.values().copied().sum::<u64>();
+
+ assert_eq!(commit.circulating_supply, supply_after_commit + 10_000_000);
+ assert_eq!(last.burn_count, 2);
+ assert_eq!(last.burned_amount, 4);
+ assert_eq!(last.fees_amount, 7);
+ assert_eq!(last.circulating_supply, supply_from_balances);
+ assert_eq!(last.known_wallet_addresses, 3);
+}
+
+#[test]
+fn sqlite_ui_data_store_roundtrips_vdf_round_metrics_above_legacy_u32_limit() {
+ let dir = tempdir().unwrap();
+ let store = SqliteUiDataStore::open(dir.path().join("ui_data.sqlite3")).unwrap();
+ let vdf_rounds = u64::from(u32::MAX) + 42;
+
+ store
+ .with_connection_mut(|connection| {
+ let transaction = connection.transaction().unwrap();
+ replace_metrics(
+ &transaction,
+ &[BlockMetricRow {
+ height: 1,
+ block_hash: "hash".to_string(),
+ timestamp_ms: 1_000,
+ block_time_ms: Some(415_000),
+ mine_difficulty_bits: 12,
+ circulating_supply: 100,
+ known_wallet_addresses: 1,
+ transaction_count: 0,
+ transfer_count: 0,
+ burn_count: 0,
+ mine_count: 0,
+ burned_amount: 0,
+ total_burned_amount: 0,
+ fees_amount: 0,
+ reward_amount: 0,
+ vdf_rounds,
+ finalizer_rank: 0,
+ }],
+ )?;
+ transaction.commit().unwrap();
+ Ok(())
+ })
+ .unwrap();
+
+ let metrics = store.load_metrics().unwrap();
+ assert_eq!(metrics.len(), 1);
+ assert_eq!(metrics[0].vdf_rounds, vdf_rounds);
+}
+
+#[test]
+fn sqlite_ui_data_store_metrics_include_genesis_reward_when_burn_consumes_allocation() {
+ let dir = tempdir().unwrap();
+ let store = SqliteUiDataStore::open(dir.path().join("ui_data.sqlite3")).unwrap();
+ let wallet = Wallet::from_seed("metrics-genesis-reward");
+ let mut genesis = BTreeMap::new();
+ genesis.insert(wallet.address().to_string(), 1);
+ let ledger =
+ Ledger::new_with_genesis_burns(genesis, vec![GenesisBurn::new(wallet.address(), 1)], 1)
+ .unwrap();
+
+ store.project_snapshot(&ledger.snapshot(), true).unwrap();
+ let metrics = store.load_metrics().unwrap();
+
+ assert_eq!(metrics.len(), 1);
+ assert_eq!(metrics[0].height, 0);
+ assert_eq!(metrics[0].burned_amount, 1);
+ assert_eq!(metrics[0].reward_amount, BLOCK_REWARD);
+ assert_eq!(metrics[0].circulating_supply, BLOCK_REWARD);
+}
+
+#[test]
+fn sqlite_ui_data_store_disabled_metrics_projection_deletes_old_metrics() {
+ let dir = tempdir().unwrap();
+ let store = SqliteUiDataStore::open(dir.path().join("ui_data.sqlite3")).unwrap();
+ let wallet = Wallet::from_seed("metrics-cleanup");
+ 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();
+
+ store.project_snapshot(&ledger.snapshot(), true).unwrap();
+ assert!(!store.load_metrics().unwrap().is_empty());
+
+ store.project_snapshot(&ledger.snapshot(), false).unwrap();
+ assert!(store.load_metrics().unwrap().is_empty());
+}
diff --git a/src/app.rs b/src/app.rs
@@ -7,6 +7,7 @@ use std::{
time::{SystemTime, UNIX_EPOCH},
};
+use anyhow::Result;
use tokio::sync::Mutex;
use crate::domain::{
@@ -72,6 +73,32 @@ struct AutoPowMineCursor {
}
#[derive(Clone, Debug)]
+pub struct AutoPowMineJob {
+ ledger: Ledger,
+ recipient: String,
+ anchor: String,
+ salt: u64,
+ start_nonce: u64,
+ max_attempts: u64,
+}
+
+impl AutoPowMineJob {
+ pub fn anchor(&self) -> &str {
+ &self.anchor
+ }
+
+ pub fn search(self) -> Result<(Self, crate::domain::MineSearchOutcome)> {
+ let outcome = self.ledger.search_mine(
+ self.recipient.clone(),
+ self.salt,
+ self.start_nonce,
+ self.max_attempts,
+ )?;
+ Ok((self, outcome))
+ }
+}
+
+#[derive(Clone, Debug)]
pub struct NodeCore {
wallet: NodeWallet,
ledger: Ledger,
diff --git a/src/app/automatic_mining.rs b/src/app/automatic_mining.rs
@@ -57,7 +57,7 @@ impl NodeCore {
};
}
- let pow_error = match self.prepare_automatic_pow_mine() {
+ let pow_error = match self.prepare_automatic_pow_mining() {
Ok(tx) => {
plan.pow_mined = tx;
None
diff --git a/src/app/automatic_mining/pow.rs b/src/app/automatic_mining/pow.rs
@@ -2,12 +2,21 @@ use anyhow::{Context, Result};
use super::super::helpers::auto_pow_salt;
use super::super::{
- AUTO_POW_NONCE_ATTEMPTS_PER_WORKER_TICK, AutoPowMineCursor, MINE_ACTIONS_PER_ANCHOR_LIMIT,
- NodeCore, Transaction,
+ AUTO_POW_NONCE_ATTEMPTS_PER_WORKER_TICK, AutoPowMineCursor, AutoPowMineJob,
+ MINE_ACTIONS_PER_ANCHOR_LIMIT, NodeCore, Transaction,
};
+use crate::domain::MineSearchOutcome;
impl NodeCore {
pub fn prepare_automatic_pow_mining(&mut self) -> Result<Option<Transaction>> {
+ let Some(job) = self.prepare_automatic_pow_mining_job()? else {
+ return Ok(None);
+ };
+ let (job, outcome) = job.search()?;
+ self.finish_automatic_pow_mining_job(job, outcome)
+ }
+
+ pub fn prepare_automatic_pow_mining_job(&mut self) -> Result<Option<AutoPowMineJob>> {
if self.wallet.is_locked() {
if self.pow_mining_enabled {
self.last_auto_pow_mine_status = Some("wallet is locked".to_string());
@@ -24,11 +33,62 @@ impl NodeCore {
self.prepare_automatic_pow_mine()
}
+ pub fn finish_automatic_pow_mining_job(
+ &mut self,
+ job: AutoPowMineJob,
+ outcome: MineSearchOutcome,
+ ) -> Result<Option<Transaction>> {
+ if !self.pow_mining_enabled || !self.has_real_chain() {
+ return Ok(None);
+ }
+ let current_anchor = self
+ .ledger
+ .chain()
+ .last()
+ .map(|block| block.hash.clone())
+ .context("ledger has no anchor block")?;
+ if current_anchor != job.anchor() {
+ self.auto_pow_mine_cursor = None;
+ self.last_auto_pow_mine_status =
+ Some("discarded stale PoW search result after chain tip changed".to_string());
+ return Ok(None);
+ }
+ let mut searched = outcome.attempts;
+ if let Some(cursor) = &mut self.auto_pow_mine_cursor {
+ if cursor.anchor == current_anchor {
+ cursor.next_nonce = outcome.next_nonce;
+ cursor.searched = cursor.searched.saturating_add(outcome.attempts);
+ searched = cursor.searched;
+ }
+ }
+ let Some(tx) = outcome.transaction else {
+ self.last_auto_pow_mine_status = Some(format!(
+ "searched {searched} PoW nonces for the current tip; no proof yet"
+ ));
+ return Ok(None);
+ };
+ if self.ledger.pending_mine_count_for_anchor(¤t_anchor)
+ >= MINE_ACTIONS_PER_ANCHOR_LIMIT
+ {
+ self.last_auto_pow_mine_anchor = Some(current_anchor);
+ self.last_auto_pow_mine_status =
+ Some("waiting for next chain tip after queued mine actions".to_string());
+ self.auto_pow_mine_cursor = None;
+ return Ok(None);
+ }
+ self.submit_public_mine_action(tx.clone())?;
+ self.last_auto_pow_mine_anchor = Some(current_anchor);
+ self.last_auto_pow_mine_status = Some(format!(
+ "queued mine action after {searched} PoW nonce attempts for the current tip"
+ ));
+ Ok(Some(tx))
+ }
+
pub fn record_automatic_pow_mining_error(&mut self, message: String) {
self.last_auto_pow_mine_status = Some(message);
}
- pub(super) fn prepare_automatic_pow_mine(&mut self) -> Result<Option<Transaction>> {
+ pub(super) fn prepare_automatic_pow_mine(&mut self) -> Result<Option<AutoPowMineJob>> {
if !self.pow_mining_enabled {
self.last_auto_pow_mine_status = None;
self.auto_pow_mine_cursor = None;
@@ -65,32 +125,18 @@ impl NodeCore {
.as_ref()
.context("automatic PoW cursor was not initialized")?
.clone();
- let outcome = self.wallet_build_ledger()?.search_mine(
- wallet_address,
- cursor.salt,
- cursor.next_nonce,
- AUTO_POW_NONCE_ATTEMPTS_PER_WORKER_TICK
- .saturating_mul(u64::from(self.pow_mining_workers)),
- )?;
- let mut searched = outcome.attempts;
- if let Some(cursor) = &mut self.auto_pow_mine_cursor {
- if cursor.anchor == anchor {
- cursor.next_nonce = outcome.next_nonce;
- cursor.searched = cursor.searched.saturating_add(outcome.attempts);
- searched = cursor.searched;
- }
- }
- let Some(tx) = outcome.transaction else {
- self.last_auto_pow_mine_status = Some(format!(
- "searched {searched} PoW nonces for the current tip; no proof yet"
- ));
- return Ok(None);
- };
- self.submit_public_mine_action(tx.clone())?;
- self.last_auto_pow_mine_anchor = Some(anchor);
self.last_auto_pow_mine_status = Some(format!(
- "queued mine action after {searched} PoW nonce attempts for the current tip"
+ "searching PoW nonces for the current tip; searched {} so far",
+ cursor.searched
));
- Ok(Some(tx))
+ Ok(Some(AutoPowMineJob {
+ ledger: self.wallet_build_ledger()?,
+ recipient: wallet_address,
+ anchor,
+ salt: cursor.salt,
+ start_nonce: cursor.next_nonce,
+ max_attempts: AUTO_POW_NONCE_ATTEMPTS_PER_WORKER_TICK
+ .saturating_mul(u64::from(self.pow_mining_workers)),
+ }))
}
}
diff --git a/src/app/automatic_mining/tests.rs b/src/app/automatic_mining/tests.rs
@@ -503,15 +503,13 @@ fn automatic_leader_prepares_anchor_and_blinded_burn() {
#[test]
fn automatic_pow_mining_uses_protocol_finalizer_fee() {
let wallet = Wallet::from_seed("automatic-pow-mining-fee-wallet");
- let mut node = NodeCore::new(NodeConfig {
- wallet,
- genesis_allocations: BTreeMap::new(),
- vdf_rounds: 10,
- burn_per_block: 0,
- burn_fee: 0,
- pow_mining_workers: 1,
- recovery_vdf_top_rank_percent: 100,
- });
+ let ledger = Ledger::new_with_genesis_burns(
+ BTreeMap::from([(wallet.address().to_string(), MICRO_IUNA)]),
+ vec![GenesisBurn::new(wallet.address(), 1)],
+ 10,
+ )
+ .unwrap();
+ let mut node = NodeCore::from_ledger(wallet, ledger, 0);
node.set_pow_mining_enabled(true);
let plan = (1..10_000)
diff --git a/src/app/ledger_view.rs b/src/app/ledger_view.rs
@@ -1,10 +1,12 @@
use anyhow::Result;
+use std::collections::BTreeSet;
+
use crate::domain::{
- BlindedReveal, BlindedTransaction, Block, BurnLeaderRank, Ledger, Transaction,
+ BlindedReveal, BlindedTransaction, Block, BurnLeaderRank, Ledger, OutPoint, Transaction,
};
-use super::NodeCore;
+use super::{NodeCore, helpers::transaction_input_outpoints};
impl NodeCore {
pub fn ledger(&self) -> &Ledger {
@@ -22,6 +24,32 @@ impl NodeCore {
Ok(ledger)
}
+ pub fn wallet_pending_spent_outpoints(&self) -> BTreeSet<OutPoint> {
+ let mut spent = self
+ .ledger
+ .pending()
+ .iter()
+ .flat_map(transaction_input_outpoints)
+ .chain(
+ self.ledger
+ .pending_blinded_transactions()
+ .iter()
+ .flat_map(|transaction| {
+ transaction
+ .inputs
+ .iter()
+ .map(|input| input.outpoint.clone())
+ }),
+ )
+ .collect::<BTreeSet<_>>();
+ if let Some((height, burn)) = &self.local_block_anchor_burn {
+ if *height == self.ledger.height() && !self.ledger.has_transaction(burn.signature()) {
+ spent.extend(transaction_input_outpoints(burn));
+ }
+ }
+ spent
+ }
+
pub fn chain(&self) -> &[Block] {
self.ledger.chain()
}
diff --git a/src/app/wallet.rs b/src/app/wallet.rs
@@ -160,7 +160,19 @@ impl NodeCore {
}
pub fn estimate_mine_fee(&self, _fee_per_byte: Amount) -> Result<FeeEstimate> {
- self.build_mine_estimate().map(|(_, estimate)| estimate)
+ let tx = Transaction::Mine {
+ recipient: self.wallet.address().to_string(),
+ anchor: self.ledger.tip_hash().to_string(),
+ salt: 0,
+ nonce: 0,
+ difficulty_bits: self.ledger.current_mine_difficulty_bits(),
+ proof_header: None,
+ signature: "0".repeat(64),
+ };
+ Ok(FeeEstimate {
+ bytes: tx.economic_size_bytes(),
+ fee: tx.fee(),
+ })
}
pub fn external_mine_job(
@@ -425,9 +437,10 @@ mod tests {
use std::collections::BTreeMap;
use crate::{
- app::NodeCore,
+ app::{FeeEstimate, NodeCore},
domain::{
- GenesisBurn, Ledger, MICRO_IUNA, Transaction, VDF_TARGET_BLOCK_MS, Wallet, run_vdf,
+ GenesisBurn, Ledger, MICRO_IUNA, MINE_FINALIZER_FEE, Transaction, VDF_TARGET_BLOCK_MS,
+ Wallet, run_vdf,
},
};
@@ -480,6 +493,39 @@ mod tests {
}
#[test]
+ fn mine_fee_estimate_uses_template_without_searching_pow() {
+ let wallet = Wallet::from_seed("mine-fee-template-wallet");
+ let mut genesis = BTreeMap::new();
+ genesis.insert(wallet.address().to_string(), MICRO_IUNA);
+ let ledger = Ledger::new_with_genesis_burns(
+ genesis,
+ vec![GenesisBurn::new(wallet.address(), MICRO_IUNA)],
+ 1,
+ )
+ .unwrap();
+ let node = NodeCore::from_ledger(wallet, ledger, 0);
+
+ let estimate = node.estimate_mine_fee(MINE_FINALIZER_FEE).unwrap();
+
+ assert_eq!(
+ estimate,
+ FeeEstimate {
+ bytes: Transaction::Mine {
+ recipient: node.wallet_address().to_string(),
+ anchor: node.ledger().tip_hash().to_string(),
+ salt: 0,
+ nonce: 0,
+ difficulty_bits: node.ledger().current_mine_difficulty_bits(),
+ proof_header: None,
+ signature: "0".repeat(64),
+ }
+ .economic_size_bytes(),
+ fee: MINE_FINALIZER_FEE,
+ }
+ );
+ }
+
+ #[test]
fn wallet_building_reserves_local_anchor_burn_inputs() {
let alice = Wallet::from_seed("local-anchor-reserve-alice");
let finalizers = [alice.clone()];
diff --git a/src/domain/ledger_queries.rs b/src/domain/ledger_queries.rs
@@ -411,6 +411,13 @@ impl Ledger {
.collect()
}
+ pub fn all_utxos(&self) -> Vec<(OutPoint, TxOutput)> {
+ self.utxos
+ .iter()
+ .map(|(outpoint, output)| (outpoint.clone(), output.clone()))
+ .collect()
+ }
+
pub fn available_utxos_for_address(&self, address: &str) -> Result<Vec<(OutPoint, TxOutput)>> {
Ok(self
.utxos_after_spendable_pending()?
diff --git a/src/main.rs b/src/main.rs
@@ -1,14 +1,17 @@
use std::{
collections::BTreeMap,
net::SocketAddr,
- path::Path,
+ path::{Path, PathBuf},
sync::Arc,
time::{Duration, Instant},
};
use anyhow::{Context, Result, bail};
use iuna::{
- adapters::{chain_store::SqliteChainStore, config_store, http, p2p, stratum, wallet_store},
+ adapters::{
+ chain_store::SqliteChainStore, config_store, http, p2p, stratum,
+ ui_data_store::SqliteUiDataStore, wallet_store,
+ },
app::{
NodeCore, PeerBook, SharedNode, SharedPeerBook, StratumStatus, debug_logging_enabled,
now_ms, set_debug_logging,
@@ -46,7 +49,10 @@ async fn main() -> Result<()> {
let config_path = opts.config_path();
let wallet_file_exists = wallet_path.exists();
validate_wallet_for_mode(&opts, &wallet_path, wallet_file_exists)?;
- let chain_store = SqliteChainStore::open(opts.chain_db_path())?;
+ let chain_db_path = opts.chain_db_path();
+ let ui_data_db_path = ui_data_db_path(&chain_db_path);
+ let chain_store = SqliteChainStore::open(&chain_db_path)?;
+ let ui_data_store = SqliteUiDataStore::open(&ui_data_db_path)?;
let persisted_chain_exists = chain_store.load()?.is_some();
if opts.chain_mode == ChainMode::Genesis && persisted_chain_exists {
bail!(
@@ -111,12 +117,11 @@ async fn main() -> Result<()> {
let peers: SharedPeerBook = Arc::new(Mutex::new(PeerBook::from_addresses(peers)));
if has_chain {
let initial_snapshot = { node.lock().await.chain_snapshot() };
- persist_chain_snapshot(
- &chain_store,
- initial_snapshot,
- ui_config.lock().await.keep_track_of_metrics,
- )
- .await?;
+ let keep_metrics = ui_config.lock().await.keep_track_of_metrics;
+ persist_chain_snapshot(&chain_store, initial_snapshot.clone()).await?;
+ warm_ui_data_store(&ui_data_store, initial_snapshot, keep_metrics).await?;
+ } else {
+ clear_ui_data_store(&ui_data_store).await?;
}
println!("iuna wallet: {}", node.lock().await.wallet_address());
@@ -126,6 +131,7 @@ async fn main() -> Result<()> {
println!("wallet file: {}", wallet_path.display());
println!("config file: {}", config_path.display());
println!("chain database: {}", chain_store.path().display());
+ println!("UI data database: {}", ui_data_store.path().display());
println!("management UI: http://{}", opts.http_addr);
if p2p_accept_inbound {
println!("p2p listener: {}", configured_p2p_addr);
@@ -167,9 +173,25 @@ async fn main() -> Result<()> {
let persistence_node = Arc::clone(&node);
let persistence_store = chain_store.clone();
+ let persistence_ui_data_store = ui_data_store.clone();
let persistence_config = Arc::clone(&ui_config);
+ let persistence_initial_tip = {
+ let node = node.lock().await;
+ if node.has_real_chain() {
+ Some(node.chain_tip_hash())
+ } else {
+ None
+ }
+ };
tokio::spawn(async move {
- run_chain_persistence(persistence_node, persistence_store, persistence_config).await;
+ run_chain_persistence(
+ persistence_node,
+ persistence_store,
+ persistence_ui_data_store,
+ persistence_config,
+ persistence_initial_tip,
+ )
+ .await;
});
let finalizer_node = Arc::clone(&node);
@@ -202,6 +224,7 @@ async fn main() -> Result<()> {
http::ServeOptions {
config_path,
chain_store,
+ ui_data_store,
wallet_path,
stratum: stratum_status,
addr: opts.http_addr,
@@ -268,6 +291,10 @@ fn format_iuna(amount: Amount) -> String {
}
}
+fn ui_data_db_path(chain_db_path: &Path) -> PathBuf {
+ chain_db_path.with_file_name("ui_data.sqlite3")
+}
+
async fn initialize_ledger(
opts: &CliOptions,
wallet_address: &str,
@@ -504,7 +531,7 @@ async fn run_automatic_finalizer(node: SharedNode, gossip: p2p::GossipNetwork, d
async fn run_automatic_pow_miner(node: SharedNode, gossip: p2p::GossipNetwork, debug: bool) {
loop {
tokio::time::sleep(std::time::Duration::from_secs(1)).await;
- let (height, pow_mined, outbox) = {
+ let (height, job) = {
let mut node = node.lock().await;
if !node.pow_mining_enabled() {
continue;
@@ -513,8 +540,8 @@ async fn run_automatic_pow_miner(node: SharedNode, gossip: p2p::GossipNetwork, d
continue;
}
let height = node.chain_height();
- let pow_mined = match node.prepare_automatic_pow_mining() {
- Ok(tx) => tx,
+ let job = match node.prepare_automatic_pow_mining_job() {
+ Ok(job) => job,
Err(error) => {
node.record_automatic_pow_mining_error(format!(
"automatic PoW mining failed: {error:#}"
@@ -522,8 +549,42 @@ async fn run_automatic_pow_miner(node: SharedNode, gossip: p2p::GossipNetwork, d
None
}
};
+ (height, job)
+ };
+ let Some(job) = job else {
+ continue;
+ };
+
+ let search = tokio::task::spawn_blocking(move || job.search()).await;
+ let (pow_mined, outbox) = {
+ let mut node = node.lock().await;
+ let pow_mined = match search {
+ Ok(Ok((job, outcome))) => {
+ match node.finish_automatic_pow_mining_job(job, outcome) {
+ Ok(tx) => tx,
+ Err(error) => {
+ node.record_automatic_pow_mining_error(format!(
+ "automatic PoW mining failed: {error:#}"
+ ));
+ None
+ }
+ }
+ }
+ Ok(Err(error)) => {
+ node.record_automatic_pow_mining_error(format!(
+ "automatic PoW mining failed: {error:#}"
+ ));
+ None
+ }
+ Err(error) => {
+ node.record_automatic_pow_mining_error(format!(
+ "automatic PoW mining task failed: {error:#}"
+ ));
+ None
+ }
+ };
let outbox = node.drain_outbox();
- (height, pow_mined, outbox)
+ (pow_mined, outbox)
};
if let Err(error) = gossip.broadcast(outbox).await {
@@ -567,18 +628,30 @@ async fn run_peer_sync(node: SharedNode, gossip: p2p::GossipNetwork, debug: bool
async fn run_chain_persistence(
node: SharedNode,
store: SqliteChainStore,
+ ui_data_store: SqliteUiDataStore,
ui_config: Arc<Mutex<config_store::UiConfig>>,
+ initial_saved_tip: Option<String>,
) {
- run_chain_persistence_with_interval(node, store, ui_config, Duration::from_secs(2)).await;
+ run_chain_persistence_with_interval(
+ node,
+ store,
+ ui_data_store,
+ ui_config,
+ Duration::from_secs(2),
+ initial_saved_tip,
+ )
+ .await;
}
async fn run_chain_persistence_with_interval(
node: SharedNode,
store: SqliteChainStore,
+ ui_data_store: SqliteUiDataStore,
ui_config: Arc<Mutex<config_store::UiConfig>>,
interval: Duration,
+ initial_saved_tip: Option<String>,
) {
- let mut last_saved_tip: Option<String> = None;
+ let mut last_saved_tip = initial_saved_tip;
loop {
tokio::time::sleep(interval).await;
let snapshot = {
@@ -596,7 +669,9 @@ async fn run_chain_persistence_with_interval(
}
let keep_metrics = ui_config.lock().await.keep_track_of_metrics;
- match persist_chain_snapshot(&store, snapshot, keep_metrics).await {
+ match persist_chain_and_project_ui_data(&store, &ui_data_store, snapshot, keep_metrics)
+ .await
+ {
Ok(()) => last_saved_tip = Some(tip_hash),
Err(error) if debug_logging_enabled() => {
eprintln!("chain persistence failed: {error:#}")
@@ -606,18 +681,65 @@ async fn run_chain_persistence_with_interval(
}
}
-async fn persist_chain_snapshot(
+async fn persist_chain_and_project_ui_data(
store: &SqliteChainStore,
+ ui_data_store: &SqliteUiDataStore,
snapshot: ChainSnapshot,
keep_metrics: bool,
) -> Result<()> {
+ persist_chain_snapshot(store, snapshot.clone()).await?;
+ project_ui_data_store(ui_data_store, snapshot, keep_metrics).await
+}
+
+async fn persist_chain_snapshot(store: &SqliteChainStore, snapshot: ChainSnapshot) -> Result<()> {
let store = store.clone();
- tokio::task::spawn_blocking(move || store.save_with_metrics(&snapshot, keep_metrics))
+ tokio::task::spawn_blocking(move || store.save(&snapshot))
.await
.context("chain persistence worker failed")??;
Ok(())
}
+async fn warm_ui_data_store(
+ store: &SqliteUiDataStore,
+ snapshot: ChainSnapshot,
+ keep_metrics: bool,
+) -> Result<()> {
+ println!("warming UI data database...");
+ let started = Instant::now();
+ project_ui_data_store(store, snapshot, keep_metrics).await?;
+ println!(
+ "UI data database ready in {:.2}s",
+ started.elapsed().as_secs_f64()
+ );
+ Ok(())
+}
+
+async fn project_ui_data_store(
+ store: &SqliteUiDataStore,
+ snapshot: ChainSnapshot,
+ keep_metrics: bool,
+) -> Result<()> {
+ let store = store.clone();
+ tokio::task::spawn_blocking(move || store.project_snapshot(&snapshot, keep_metrics))
+ .await
+ .context("UI data projection worker failed")??;
+ Ok(())
+}
+
+async fn clear_ui_data_store(store: &SqliteUiDataStore) -> Result<()> {
+ println!("clearing UI data database...");
+ let started = Instant::now();
+ let store = store.clone();
+ tokio::task::spawn_blocking(move || store.clear_all())
+ .await
+ .context("UI data cleanup worker failed")??;
+ println!(
+ "UI data database ready in {:.2}s",
+ started.elapsed().as_secs_f64()
+ );
+ Ok(())
+}
+
#[cfg(test)]
#[path = "main_tests.rs"]
mod tests;
diff --git a/src/main_tests.rs b/src/main_tests.rs
@@ -1,7 +1,10 @@
use std::{collections::BTreeMap, sync::Arc, time::Duration};
use iuna::{
- adapters::{chain_store::SqliteChainStore, config_store::UiConfig, wallet_store},
+ adapters::{
+ chain_store::SqliteChainStore, config_store::UiConfig, ui_data_store::SqliteUiDataStore,
+ wallet_store,
+ },
app::{DEFAULT_BURN_PER_BLOCK, NodeCore},
domain::{BLOCK_REWARD, GenesisBurn, Ledger, MICRO_IUNA, VDF_TARGET_BLOCK_MS, Wallet},
};
@@ -13,7 +16,7 @@ use super::{
ChainMode, CliOptions, GENESIS_INITIAL_BURN_FEE, GENESIS_INITIAL_BURN_PER_BLOCK, StartupWallet,
apply_cli_p2p_config_overrides, configured_p2p_announce_addr, configured_p2p_bind_addr,
extrapolate_vdf_rounds, help_text, initial_burn_fee, initial_burn_per_block, initialize_ledger,
- load_startup_wallet, measure_vdf_rounds, persist_chain_snapshot,
+ load_startup_wallet, measure_vdf_rounds, persist_chain_snapshot, project_ui_data_store,
run_chain_persistence_with_interval, validate_wallet_for_mode,
};
@@ -80,6 +83,20 @@ fn debug_logging_can_be_enabled() {
}
#[test]
+fn automatic_pow_worker_searches_outside_node_lock() {
+ let main_rs = include_str!("main.rs");
+ let worker = main_rs
+ .split("async fn run_automatic_pow_miner")
+ .nth(1)
+ .expect("automatic PoW worker should exist");
+
+ assert!(worker.contains("prepare_automatic_pow_mining_job"));
+ assert!(worker.contains("tokio::task::spawn_blocking"));
+ assert!(worker.contains("finish_automatic_pow_mining_job"));
+ assert!(!worker.contains("prepare_automatic_pow_mining()"));
+}
+
+#[test]
fn removed_wallet_seed_is_rejected() {
let error = parse(&["--wallet-seed", "alice", "--genesis"]).unwrap_err();
assert!(error.to_string().contains("--wallet-seed was removed"));
@@ -592,16 +609,23 @@ async fn persistence_loop_saves_new_tip_after_node_changes() {
DEFAULT_BURN_PER_BLOCK,
)));
let initial_snapshot = { node.lock().await.chain_snapshot() };
- persist_chain_snapshot(&store, initial_snapshot, false)
+ let initial_tip = initial_snapshot
+ .blocks
+ .last()
+ .map(|block| block.hash.clone());
+ persist_chain_snapshot(&store, initial_snapshot)
.await
.unwrap();
let ui_config = Arc::new(Mutex::new(UiConfig::default()));
+ let ui_data_store = SqliteUiDataStore::open(dir.path().join("ui_data.sqlite3")).unwrap();
let persistence_task = tokio::spawn(run_chain_persistence_with_interval(
Arc::clone(&node),
store.clone(),
+ ui_data_store.clone(),
ui_config,
Duration::from_millis(10),
+ initial_tip,
));
{
let mut node = node.lock().await;
@@ -624,12 +648,78 @@ async fn persistence_loop_saves_new_tip_after_node_changes() {
persistence_task.abort();
assert_eq!(restored_tip.as_deref(), Some(expected_tip.as_str()));
+ assert!(ui_data_store.load_metrics().unwrap().is_empty());
+ let ui_data_connection = Connection::open(ui_data_store.path()).unwrap();
+ let projected_tip: String = ui_data_connection
+ .query_row(
+ "SELECT tip_hash FROM ui_cache_meta WHERE id = 1",
+ [],
+ |row| row.get(0),
+ )
+ .unwrap();
+ assert_eq!(projected_tip, expected_tip);
+}
+
+#[tokio::test]
+async fn persistence_loop_skips_tip_already_projected_at_startup() {
+ let dir = tempdir().unwrap();
+ let store = SqliteChainStore::open(dir.path().join("chain.sqlite3")).unwrap();
+ let ui_data_store = SqliteUiDataStore::open(dir.path().join("ui_data.sqlite3")).unwrap();
+ let wallet = Wallet::from_seed("background-persistence-warmed");
+ let ledger = ledger_with_one_spendable_iuna(&wallet);
+ let node = Arc::new(Mutex::new(NodeCore::from_ledger(
+ wallet,
+ ledger,
+ DEFAULT_BURN_PER_BLOCK,
+ )));
+ let initial_snapshot = { node.lock().await.chain_snapshot() };
+ let initial_tip = initial_snapshot
+ .blocks
+ .last()
+ .map(|block| block.hash.clone());
+ persist_chain_snapshot(&store, initial_snapshot.clone())
+ .await
+ .unwrap();
+ project_ui_data_store(&ui_data_store, initial_snapshot, false)
+ .await
+ .unwrap();
+ let ui_data_connection = Connection::open(ui_data_store.path()).unwrap();
+ ui_data_connection
+ .execute(
+ "UPDATE ui_cache_meta SET updated_at_ms = 123 WHERE id = 1",
+ [],
+ )
+ .unwrap();
+ drop(ui_data_connection);
+ let ui_config = Arc::new(Mutex::new(UiConfig::default()));
+
+ let persistence_task = tokio::spawn(run_chain_persistence_with_interval(
+ Arc::clone(&node),
+ store,
+ ui_data_store.clone(),
+ ui_config,
+ Duration::from_millis(10),
+ initial_tip,
+ ));
+ tokio::time::sleep(Duration::from_millis(50)).await;
+ persistence_task.abort();
+
+ let ui_data_connection = Connection::open(ui_data_store.path()).unwrap();
+ let updated_at_ms: u64 = ui_data_connection
+ .query_row(
+ "SELECT updated_at_ms FROM ui_cache_meta WHERE id = 1",
+ [],
+ |row| row.get(0),
+ )
+ .unwrap();
+ assert_eq!(updated_at_ms, 123);
}
#[tokio::test]
async fn persistence_loop_skips_setup_placeholder_chain() {
let dir = tempdir().unwrap();
let store = SqliteChainStore::open(dir.path().join("chain.sqlite3")).unwrap();
+ let ui_data_store = SqliteUiDataStore::open(dir.path().join("ui_data.sqlite3")).unwrap();
let wallet = Wallet::from_seed("background-persistence-setup");
let ledger = Ledger::new(BTreeMap::new(), 1);
let node = Arc::new(Mutex::new(NodeCore::from_ledger(wallet, ledger, 0)));
@@ -638,11 +728,14 @@ async fn persistence_loop_skips_setup_placeholder_chain() {
let persistence_task = tokio::spawn(run_chain_persistence_with_interval(
Arc::clone(&node),
store.clone(),
+ ui_data_store.clone(),
ui_config,
Duration::from_millis(10),
+ None,
));
tokio::time::sleep(Duration::from_millis(50)).await;
persistence_task.abort();
assert!(store.load().unwrap().is_none());
+ assert!(ui_data_store.load_metrics().unwrap().is_empty());
}
diff --git a/www/assets/iuna-ui.js b/www/assets/iuna-ui.js
@@ -17,6 +17,7 @@ window.iunaApp = function iunaApp() {
p2pMetrics: {},
blockchainMetrics: { enabled: false, latest: null, charts: [] },
loadingMetrics: false,
+ metricsRequestSeq: 0,
metricHover: null,
metricsRange: (() => {
try {
@@ -109,6 +110,8 @@ window.iunaApp = function iunaApp() {
lastUpdated: null,
pollHandle: null,
refreshPromise: null,
+ shellRefreshPromise: null,
+ networkHealthPromise: null,
requestTimeoutMs: 12000,
hashListenerInstalled: false,
newBlockHashes: new Set(),
@@ -170,10 +173,12 @@ window.iunaApp = function iunaApp() {
setTab(tab) {
if (!this.allowedTabs().includes(tab)) return;
+ const alreadyActive = this.tab === tab;
this.tab = tab;
if (window.location.hash !== `#${tab}`) {
window.location.hash = tab;
}
+ if (alreadyActive) return;
this.refresh({ silent: true });
},
@@ -629,28 +634,26 @@ window.iunaApp = function iunaApp() {
const shouldLoadBlocks = tab === "chain" || tab === "mining";
const shouldLoadP2pMetrics = tab === "p2p";
const shouldLoadMetrics = tab === "metrics";
+ if (shouldLoadMetrics) {
+ await this.refreshMetrics(options);
+ this.refreshShellState({ addressBookVersion, silent: true });
+ return;
+ }
if (shouldLoadBlocks && this.blocks.length === 0) this.loadingInitialBlocks = true;
- if (shouldLoadMetrics && this.metricsCharts().length === 0) this.loadingMetrics = true;
const pagedDatasets = [];
if (tab === "wallet") pagedDatasets.push("walletTx", "walletUtxo");
if (tab === "chain") pagedDatasets.push("mempool");
if (tab === "p2p") pagedDatasets.push("peer");
try {
- const [config, status, blocks, p2pMetrics, blockchainMetrics, networkHealth] = await Promise.all([
+ const [config, status, blocks, p2pMetrics, blockchainMetrics] = await Promise.all([
this.fetchJson("/api/config"),
this.fetchJson("/api/status"),
shouldLoadBlocks ? this.fetchJson("/api/blocks?limit=30") : Promise.resolve(null),
shouldLoadP2pMetrics ? this.fetchJson("/api/p2p/metrics") : Promise.resolve(this.p2pMetrics),
- shouldLoadMetrics ? this.fetchJson(this.metricsPath()) : Promise.resolve(this.blockchainMetrics),
- this.fetchJson("/api/network/health"),
+ Promise.resolve(this.blockchainMetrics),
]);
const previousChainHeight = this.status.chain?.height;
this.status = status;
- await Promise.all(
- pagedDatasets.map((kind) =>
- this.refreshPagedDataset(kind, { silent: options.silent === true })
- )
- );
this.config = config;
this.syncConfigState({ addressBookVersion });
if (!this.allowedTabs().includes(this.tab)) {
@@ -664,7 +667,6 @@ window.iunaApp = function iunaApp() {
this.pruneSelectedTransferUtxos();
this.p2pMetrics = p2pMetrics;
this.blockchainMetrics = blockchainMetrics;
- this.networkHealth = networkHealth;
this.burnAmount = status.mining?.burn_per_block ?? this.burnAmount;
this.burnFee = status.mining?.automatic_burn_fee ?? this.burnFee;
this.miningEnabled = status.mining?.automatic ?? this.miningEnabled;
@@ -679,6 +681,12 @@ window.iunaApp = function iunaApp() {
this.lastUpdated = new Date();
this.syncMiningEvents({ status, blocks });
this.scheduleFeeEstimates();
+ this.refreshNetworkHealth({ silent: options.silent === true });
+ await Promise.all(
+ pagedDatasets.map((kind) =>
+ this.refreshPagedDataset(kind, { silent: options.silent === true })
+ )
+ );
} catch (error) {
if (String(error.message || "").includes("401")) {
this.stopPolling();
@@ -688,10 +696,72 @@ window.iunaApp = function iunaApp() {
this.showFlash(error.message, "error");
} finally {
if (shouldLoadBlocks) this.loadingInitialBlocks = false;
- if (shouldLoadMetrics) this.loadingMetrics = false;
}
},
+ async refreshShellState(options = {}) {
+ if (!this.canUseProtectedApi()) return;
+ if (this.shellRefreshPromise) return this.shellRefreshPromise;
+ const addressBookVersion = options.addressBookVersion ?? this.addressBookVersion;
+ this.shellRefreshPromise = Promise.all([
+ this.fetchJson("/api/config"),
+ this.fetchJson("/api/status"),
+ ])
+ .then(async ([config, status]) => {
+ const previousChainHeight = this.status.chain?.height;
+ this.status = status;
+ this.config = config;
+ this.syncConfigState({ addressBookVersion });
+ if (!this.allowedTabs().includes(this.tab)) {
+ this.setTab("wallet");
+ }
+ if (!this.config.setup_complete) {
+ await this.refreshWalletSetup();
+ }
+ this.syncMempoolBlockMarker(previousChainHeight, status.chain?.height);
+ this.burnAmount = status.mining?.burn_per_block ?? this.burnAmount;
+ this.burnFee = status.mining?.automatic_burn_fee ?? this.burnFee;
+ this.miningEnabled = status.mining?.automatic ?? this.miningEnabled;
+ this.powMiningEnabled = status.mining?.pow_mining_enabled ?? this.powMiningEnabled;
+ this.powMiningWorkers = status.mining?.pow_mining_workers ?? this.powMiningWorkers;
+ this.maxPowMiningWorkers =
+ status.mining?.max_pow_mining_workers ?? this.maxPowMiningWorkers;
+ if (!this.burnAmountDirty) {
+ this.burnAmountDraft = this.amountLabel(this.burnAmount);
+ this.burnFeeDraft = this.amountLabel(this.burnFee);
+ }
+ this.lastUpdated = new Date();
+ this.syncMiningEvents({ status, blocks: null });
+ this.scheduleFeeEstimates();
+ this.refreshNetworkHealth({ silent: true });
+ })
+ .catch((error) => {
+ if (options.silent !== true) this.showFlash(error.message, "error");
+ })
+ .finally(() => {
+ this.shellRefreshPromise = null;
+ });
+ return this.shellRefreshPromise;
+ },
+
+ async refreshNetworkHealth(options = {}) {
+ if (!this.canUseProtectedApi()) return;
+ if (this.networkHealthPromise) return this.networkHealthPromise;
+ this.networkHealthPromise = this.fetchJson("/api/network/health")
+ .then((networkHealth) => {
+ this.networkHealth = networkHealth;
+ return networkHealth;
+ })
+ .catch((error) => {
+ if (options.silent !== true) this.showFlash(error.message, "error");
+ return null;
+ })
+ .finally(() => {
+ this.networkHealthPromise = null;
+ });
+ return this.networkHealthPromise;
+ },
+
async fetchJson(path) {
const response = await this.fetchWithTimeout(path, {
headers: { Accept: "application/json" },
@@ -1257,11 +1327,16 @@ window.iunaApp = function iunaApp() {
async refreshFeeEstimates() {
if (this.showingAuth()) return;
- await Promise.all([
- this.refreshBurnFeeEstimate(),
- this.refreshMineFeeEstimate(),
- this.refreshTransferFeeEstimate(),
- ]);
+ if (this.tab === "wallet") {
+ await this.refreshTransferFeeEstimate();
+ return;
+ }
+ if (this.tab === "mining") {
+ await Promise.all([
+ this.refreshBurnFeeEstimate(),
+ this.refreshMineFeeEstimate(),
+ ]);
+ }
},
async refreshBurnFeeEstimate() {
@@ -1730,8 +1805,8 @@ window.iunaApp = function iunaApp() {
return this.blockchainMetrics?.latest || {};
},
- metricsPath() {
- return this.metricsRange === "all" ? "/api/metrics" : `/api/metrics?limit=${this.metricsRange}`;
+ metricsPath(range = this.metricsRange) {
+ return range === "all" ? "/api/metrics" : `/api/metrics?limit=${range}`;
},
setMetricsRange(range) {
@@ -1743,29 +1818,57 @@ window.iunaApp = function iunaApp() {
// Non-persistent filtering is fine when storage is unavailable.
}
if (this.tab === "metrics") {
- this.blockchainMetrics = { enabled: this.blockchainMetrics?.enabled ?? true, latest: this.blockchainMetrics?.latest ?? null, charts: [] };
- this.refresh({ force: true });
+ this.refreshMetrics();
}
},
- metricChartPoints(chart) {
- const points = this.metricVisiblePoints(chart);
- if (points.length === 0) return "";
- const bounds = this.metricChartBounds(chart);
- return points
+ async fetchMetricsResponse(range = this.metricsRange) {
+ return this.prepareMetricsResponse(await this.fetchJson(this.metricsPath(range)));
+ },
+
+ async refreshMetrics(options = {}) {
+ if (!this.canUseProtectedApi()) return this.blockchainMetrics;
+ const requestId = ++this.metricsRequestSeq;
+ const range = this.metricsRange;
+ if (this.metricsCharts().length === 0 && options.silent !== true) {
+ this.loadingMetrics = true;
+ }
+ try {
+ const metrics = await this.fetchMetricsResponse(range);
+ if (requestId === this.metricsRequestSeq && this.metricsRange === range) {
+ this.blockchainMetrics = metrics;
+ }
+ return metrics;
+ } catch (error) {
+ if (options.silent !== true) this.showFlash(error.message, "error");
+ return this.blockchainMetrics;
+ } finally {
+ if (requestId === this.metricsRequestSeq) {
+ this.loadingMetrics = false;
+ }
+ }
+ },
+
+ prepareMetricsResponse(metrics) {
+ const charts = Array.isArray(metrics?.charts)
+ ? metrics.charts.map((chart) => this.prepareMetricChart(chart))
+ : [];
+ return { ...(metrics || {}), charts };
+ },
+
+ prepareMetricChart(chart) {
+ const points = this.metricValidPoints(chart);
+ const bounds = this.metricChartBoundsForPoints(points);
+ const yTicks = this.metricYAxisTicksForPoints(points);
+ const xTicks = this.metricXAxisTicksForPoints(points);
+ const linePoints = points
.map((point) => {
const x = this.metricXAxisPositionFromBounds(bounds, Number(point.height));
const y = this.metricYAxisPositionFromBounds(bounds, Number(point.value));
return `${x.toFixed(1)},${y.toFixed(1)}`;
})
.join(" ");
- },
-
- metricChartPointMarkers(chart) {
- const points = this.metricVisiblePoints(chart);
- if (points.length === 0) return [];
- const bounds = this.metricChartBounds(chart);
- return points.map((point) => {
+ const markers = points.map((point) => {
const height = Number(point.height);
const value = Number(point.value);
return {
@@ -1773,32 +1876,49 @@ window.iunaApp = function iunaApp() {
value,
x: this.metricXAxisPositionFromBounds(bounds, height),
y: this.metricYAxisPositionFromBounds(bounds, value),
- label: this.metricPointLabel(chart, point),
};
});
+ const gridPath = [
+ ...yTicks.map((tick) => {
+ const y = this.metricYAxisPositionFromBounds(bounds, Number(tick)).toFixed(1);
+ return `M4 ${y} H296`;
+ }),
+ ...xTicks.map((tick) => {
+ const x = this.metricXAxisPositionFromBounds(bounds, Number(tick)).toFixed(1);
+ return `M${x} 8 V132`;
+ }),
+ ].join(" ");
+ return {
+ ...chart,
+ _visiblePoints: points,
+ _bounds: bounds,
+ _yTicks: yTicks,
+ _xTicks: xTicks,
+ _linePoints: linePoints,
+ _markers: markers,
+ _gridPath: gridPath,
+ };
+ },
+
+ metricChartPoints(chart) {
+ return chart?._linePoints || "";
+ },
+
+ metricChartPointMarkers(chart) {
+ return chart?._markers || [];
},
metricGridPath(chart) {
- const yLines = this.metricYAxisTicks(chart).map((tick) => {
- const y = this.metricYAxisPositionFromBounds(this.metricChartBounds(chart), Number(tick)).toFixed(1);
- return `M4 ${y} H296`;
- });
- const xLines = this.metricXAxisTicks(chart).map((tick) => {
- const x = this.metricXAxisPositionFromBounds(this.metricChartBounds(chart), Number(tick)).toFixed(1);
- return `M${x} 8 V132`;
- });
- return [...yLines, ...xLines].join(" ");
+ return chart?._gridPath || "";
},
- metricVisiblePoints(chart) {
+ metricValidPoints(chart) {
const points = Array.isArray(chart?.points) ? chart.points : [];
- const validPoints = points.filter((point) => Number.isFinite(Number(point.value)));
- const limit = this.metricsRange;
- if (limit === "all") return validPoints;
- const latestHeight = Number(this.metricsLatest().height);
- if (!Number.isFinite(latestHeight)) return validPoints.slice(-limit);
- const minHeight = Math.max(0, latestHeight - limit + 1);
- return validPoints.filter((point) => Number(point.height) >= minHeight);
+ return points.filter((point) => Number.isFinite(Number(point.value)));
+ },
+
+ metricVisiblePoints(chart) {
+ return chart?._visiblePoints || this.metricValidPoints(chart);
},
metricLatestValueLabel(chart) {
@@ -1808,7 +1928,13 @@ window.iunaApp = function iunaApp() {
},
metricChartBounds(chart) {
- const points = this.metricVisiblePoints(chart);
+ return chart?._bounds || this.metricChartBoundsForPoints(this.metricVisiblePoints(chart));
+ },
+
+ metricChartBoundsForPoints(points) {
+ if (points.length === 0) {
+ return { minHeight: 0, maxHeight: 1, minValue: 0, maxValue: 1 };
+ }
const heights = points.map((point) => Number(point.height));
const values = points.map((point) => Number(point.value));
const valueTicks = this.niceTicks(Math.min(...values), Math.max(...values), 5);
@@ -1821,14 +1947,20 @@ window.iunaApp = function iunaApp() {
},
metricYAxisTicks(chart) {
- const points = this.metricVisiblePoints(chart);
+ return chart?._yTicks || this.metricYAxisTicksForPoints(this.metricVisiblePoints(chart));
+ },
+
+ metricYAxisTicksForPoints(points) {
if (points.length === 0) return [];
const values = points.map((point) => Number(point.value));
return this.niceTicks(Math.min(...values), Math.max(...values), 5).reverse();
},
metricXAxisTicks(chart) {
- const points = this.metricVisiblePoints(chart);
+ return chart?._xTicks || this.metricXAxisTicksForPoints(this.metricVisiblePoints(chart));
+ },
+
+ metricXAxisTicksForPoints(points) {
if (points.length === 0) return [];
const heights = points.map((point) => Number(point.height));
const minHeight = Math.min(...heights);
@@ -1896,8 +2028,10 @@ window.iunaApp = function iunaApp() {
return `left: ${(x / 300) * 100}%`;
},
- metricPointStyle(marker) {
- return `left: ${(marker.x / 300) * 100}%; top: ${(marker.y / 148) * 100}%;`;
+ metricHoverPointStyle(chart) {
+ const hover = this.metricHover;
+ if (!hover || hover.chartId !== chart.id) return "";
+ return `left: ${(hover.x / 300) * 100}%; top: ${(hover.y / 148) * 100}%;`;
},
setMetricHover(chart, marker) {
@@ -1907,7 +2041,7 @@ window.iunaApp = function iunaApp() {
value: marker.value,
x: marker.x,
y: marker.y,
- label: marker.label,
+ label: this.metricPointLabel(chart, marker),
};
},