iuna

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

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:
MCargo.lock | 2+-
MCargo.toml | 2+-
Msrc-tauri/Cargo.lock | 2+-
Msrc-tauri/Cargo.toml | 2+-
Msrc-tauri/tauri.conf.json | 2+-
Msrc/adapters/chain_store.rs | 1053+------------------------------------------------------------------------------
Msrc/adapters/chain_store/compact.rs | 10+++-------
Msrc/adapters/chain_store/tests.rs | 389++++++-------------------------------------------------------------------------
Msrc/adapters/http.rs | 51+++++++++++++++++++++++++++++++++++++++++++++------
Msrc/adapters/http/actions.rs | 23+++++++++++++++++------
Msrc/adapters/http/api.rs | 415++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---------------------
Msrc/adapters/http/index_html.rs | 13++++---------
Msrc/adapters/http/metrics.rs | 2+-
Msrc/adapters/http/state.rs | 8+++++++-
Msrc/adapters/http/tests.rs | 138+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------
Msrc/adapters/http/types.rs | 2+-
Msrc/adapters/http/ui.rs | 16++++++++++++----
Msrc/adapters/mod.rs | 1+
Asrc/adapters/ui_data_store.rs | 1579+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/adapters/ui_data_store/tests.rs | 499+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/app.rs | 27+++++++++++++++++++++++++++
Msrc/app/automatic_mining.rs | 2+-
Msrc/app/automatic_mining/pow.rs | 102+++++++++++++++++++++++++++++++++++++++++++++++++++++++++----------------------
Msrc/app/automatic_mining/tests.rs | 16+++++++---------
Msrc/app/ledger_view.rs | 32++++++++++++++++++++++++++++++--
Msrc/app/wallet.rs | 52+++++++++++++++++++++++++++++++++++++++++++++++++---
Msrc/domain/ledger_queries.rs | 7+++++++
Msrc/main.rs | 160+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----------
Msrc/main_tests.rs | 99++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---
Mwww/assets/iuna-ui.js | 246+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------------------
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(&current_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), }; },