iuna

iuna

iuna - experimental mainnet-candidate protocol
git clone https://getiuna.org/git/iuna.git
Log | Files | Refs | README | LICENSE

ui_data_store.rs (96584B)


      1 use std::{
      2     collections::{BTreeMap, BTreeSet},
      3     fs,
      4     path::{Path, PathBuf},
      5     time::{SystemTime, UNIX_EPOCH},
      6 };
      7 
      8 use anyhow::{Context, Result};
      9 use rusqlite::{Connection, OptionalExtension, params, params_from_iter, types::Value};
     10 
     11 use serde::Serialize;
     12 
     13 use crate::{
     14     adapters::ui_index::{UiChainIndex, build_ui_chain_index_for_blocks, projected_reward_outputs},
     15     domain::{
     16         AddressNetwork, Amount, Block, BurnLeaderRank, ChainSnapshot, Ledger,
     17         MINE_RETARGET_WINDOW_BLOCKS, MINE_REWARD, OutPoint, Transaction, TransactionV2,
     18         TransactionV2Domain, TxInput, TxOutput, decode_hex, encode_versioned_address, hex_encode,
     19         retarget_mine_difficulty_bits,
     20     },
     21 };
     22 
     23 const SCHEMA: &str = r#"
     24 CREATE TABLE IF NOT EXISTS block_metrics (
     25     height INTEGER PRIMARY KEY,
     26     block_hash TEXT NOT NULL,
     27     timestamp_ms INTEGER NOT NULL,
     28     block_time_ms INTEGER,
     29     mine_difficulty_bits INTEGER NOT NULL,
     30     circulating_supply INTEGER NOT NULL,
     31     known_wallet_addresses INTEGER NOT NULL DEFAULT 0,
     32     utxo_count INTEGER NOT NULL DEFAULT 0,
     33     transaction_count INTEGER NOT NULL,
     34     transfer_count INTEGER NOT NULL,
     35     burn_count INTEGER NOT NULL,
     36     mine_count INTEGER NOT NULL,
     37     burned_amount INTEGER NOT NULL,
     38     total_burned_amount INTEGER NOT NULL,
     39     fees_amount INTEGER NOT NULL,
     40     reward_amount INTEGER NOT NULL,
     41     vdf_rounds INTEGER NOT NULL,
     42     finalizer_rank INTEGER NOT NULL
     43 );
     44 
     45 CREATE TABLE IF NOT EXISTS metrics_cache_meta (
     46     id INTEGER PRIMARY KEY CHECK (id = 1),
     47     schema_version INTEGER NOT NULL,
     48     height INTEGER NOT NULL,
     49     tip_hash TEXT NOT NULL,
     50     updated_at_ms INTEGER NOT NULL
     51 );
     52 
     53 CREATE TABLE IF NOT EXISTS metric_known_addresses (
     54     address TEXT PRIMARY KEY,
     55     first_seen_height INTEGER NOT NULL
     56 );
     57 
     58 CREATE TABLE IF NOT EXISTS ui_leaderboards (
     59     kind TEXT NOT NULL,
     60     address TEXT NOT NULL,
     61     amount INTEGER NOT NULL,
     62     count INTEGER NOT NULL,
     63     PRIMARY KEY (kind, address)
     64 );
     65 
     66 CREATE INDEX IF NOT EXISTS idx_ui_leaderboards_rank
     67 ON ui_leaderboards(kind, amount DESC, count DESC, address ASC);
     68 
     69 CREATE TABLE IF NOT EXISTS ui_cache_meta (
     70     id INTEGER PRIMARY KEY CHECK (id = 1),
     71     schema_version INTEGER NOT NULL,
     72     tip_hash TEXT NOT NULL,
     73     updated_at_ms INTEGER NOT NULL
     74 );
     75 
     76 CREATE TABLE IF NOT EXISTS ui_output_index (
     77     txid TEXT NOT NULL,
     78     output_index INTEGER NOT NULL,
     79     address TEXT NOT NULL,
     80     amount INTEGER NOT NULL,
     81     PRIMARY KEY (txid, output_index)
     82 );
     83 
     84 CREATE TABLE IF NOT EXISTS ui_utxos (
     85     txid TEXT NOT NULL,
     86     output_index INTEGER NOT NULL,
     87     address TEXT NOT NULL,
     88     amount INTEGER NOT NULL,
     89     PRIMARY KEY (txid, output_index)
     90 );
     91 
     92 CREATE INDEX IF NOT EXISTS idx_ui_utxos_address
     93 ON ui_utxos(address);
     94 
     95 CREATE TABLE IF NOT EXISTS ui_wallet_transactions (
     96     address TEXT NOT NULL,
     97     sort_key INTEGER NOT NULL,
     98     kind TEXT NOT NULL,
     99     signature TEXT NOT NULL,
    100     block_height INTEGER NOT NULL,
    101     timestamp_ms INTEGER NOT NULL,
    102     block_finalizer TEXT NOT NULL,
    103     transaction_json BLOB NOT NULL,
    104     PRIMARY KEY (address, signature)
    105 );
    106 
    107 CREATE INDEX IF NOT EXISTS idx_ui_wallet_transactions_address_kind_sort
    108 ON ui_wallet_transactions(address, kind, sort_key DESC);
    109 
    110 CREATE INDEX IF NOT EXISTS idx_ui_wallet_transactions_address_sort
    111 ON ui_wallet_transactions(address, sort_key DESC);
    112 
    113 CREATE INDEX IF NOT EXISTS idx_ui_wallet_transactions_signature
    114 ON ui_wallet_transactions(signature);
    115 
    116 CREATE TABLE IF NOT EXISTS ui_wallet_transactions_v2 (
    117     address TEXT NOT NULL,
    118     sort_key INTEGER NOT NULL,
    119     kind TEXT NOT NULL,
    120     transaction_id TEXT NOT NULL,
    121     block_height INTEGER NOT NULL,
    122     timestamp_ms INTEGER NOT NULL,
    123     block_finalizer TEXT NOT NULL,
    124     envelope TEXT NOT NULL,
    125     PRIMARY KEY (address, transaction_id)
    126 );
    127 
    128 CREATE INDEX IF NOT EXISTS idx_ui_wallet_transactions_v2_address_kind_sort
    129 ON ui_wallet_transactions_v2(address, kind, sort_key DESC);
    130 
    131 CREATE INDEX IF NOT EXISTS idx_ui_wallet_transactions_v2_transaction_id
    132 ON ui_wallet_transactions_v2(transaction_id);
    133 
    134 CREATE TABLE IF NOT EXISTS ui_burn_leader_ranks (
    135     block_hash TEXT NOT NULL,
    136     rank INTEGER NOT NULL,
    137     ticket_id TEXT NOT NULL,
    138     owner TEXT NOT NULL,
    139     amount INTEGER NOT NULL,
    140     eligible_from_height INTEGER NOT NULL,
    141     eligible_until_height INTEGER NOT NULL,
    142     PRIMARY KEY (block_hash, rank)
    143 );
    144 
    145 CREATE TABLE IF NOT EXISTS ui_burn_leader_rank_blocks (
    146     block_hash TEXT PRIMARY KEY
    147 );
    148 "#;
    149 
    150 const RESET_SCHEMA: &str = r#"
    151 DROP TABLE IF EXISTS block_metrics;
    152 DROP TABLE IF EXISTS metrics_cache_meta;
    153 DROP TABLE IF EXISTS metric_known_addresses;
    154 DROP TABLE IF EXISTS ui_leaderboards;
    155 DROP TABLE IF EXISTS ui_cache_meta;
    156 DROP TABLE IF EXISTS ui_output_index;
    157 DROP TABLE IF EXISTS ui_utxos;
    158 DROP TABLE IF EXISTS ui_wallet_transactions;
    159 DROP TABLE IF EXISTS ui_wallet_transactions_v2;
    160 DROP TABLE IF EXISTS ui_revealed_transactions;
    161 DROP TABLE IF EXISTS ui_burn_leader_ranks;
    162 DROP TABLE IF EXISTS ui_burn_leader_rank_blocks;
    163 "#;
    164 
    165 const UI_DATA_SCHEMA_VERSION: u32 = 2;
    166 const UI_CACHE_SCHEMA_VERSION: u32 = 5;
    167 const METRICS_CACHE_SCHEMA_VERSION: u32 = 2;
    168 
    169 #[derive(Clone, Debug, Eq, PartialEq, Serialize)]
    170 #[serde(rename_all = "camelCase")]
    171 pub struct BlockMetricRow {
    172     pub height: u64,
    173     pub block_hash: String,
    174     pub timestamp_ms: u64,
    175     pub block_time_ms: Option<u64>,
    176     pub mine_difficulty_bits: u32,
    177     pub circulating_supply: Amount,
    178     pub known_wallet_addresses: u64,
    179     pub utxo_count: u64,
    180     pub transaction_count: u64,
    181     pub transfer_count: u64,
    182     pub burn_count: u64,
    183     pub mine_count: u64,
    184     pub burned_amount: Amount,
    185     pub total_burned_amount: Amount,
    186     pub fees_amount: Amount,
    187     pub reward_amount: Amount,
    188     pub vdf_rounds: u64,
    189     pub finalizer_rank: u32,
    190 }
    191 
    192 #[derive(Clone, Debug, Eq, PartialEq)]
    193 pub struct UiLeaderboardEntry {
    194     pub address: String,
    195     pub amount: Amount,
    196     pub count: u64,
    197 }
    198 
    199 #[derive(Clone, Debug, Eq, PartialEq)]
    200 pub struct WalletTransactionProjection {
    201     pub sort_key: u64,
    202     pub kind: String,
    203     pub block_height: u64,
    204     pub timestamp_ms: u64,
    205     pub block_finalizer: String,
    206     pub transaction: Transaction,
    207 }
    208 
    209 #[derive(Clone, Debug, Eq, PartialEq)]
    210 pub struct WalletTransactionV2Projection {
    211     pub sort_key: u64,
    212     pub kind: String,
    213     pub transaction_id: String,
    214     pub block_height: u64,
    215     pub timestamp_ms: u64,
    216     pub block_finalizer: String,
    217     pub envelope: String,
    218 }
    219 
    220 #[derive(Clone, Debug)]
    221 pub struct SqliteUiDataStore {
    222     path: PathBuf,
    223 }
    224 
    225 impl SqliteUiDataStore {
    226     pub fn open(path: impl AsRef<Path>) -> Result<Self> {
    227         let path = path.as_ref().to_path_buf();
    228         if let Some(parent) = path.parent() {
    229             fs::create_dir_all(parent).with_context(|| {
    230                 format!(
    231                     "failed to create chain database directory {}",
    232                     parent.display()
    233                 )
    234             })?;
    235         }
    236 
    237         let store = Self { path };
    238         store.with_connection_mut(|connection| {
    239             initialize_ui_data_schema(connection, store.path())
    240         })?;
    241         Ok(store)
    242     }
    243 
    244     pub fn path(&self) -> &Path {
    245         &self.path
    246     }
    247 
    248     pub(crate) fn load_ui_chain_index(&self, tip_hash: &str) -> Result<Option<UiChainIndex>> {
    249         self.with_connection(|connection| {
    250             let meta = connection
    251                 .query_row(
    252                     "SELECT schema_version, tip_hash FROM ui_cache_meta WHERE id = 1",
    253                     [],
    254                     |row| Ok((row.get::<_, u32>(0)?, row.get::<_, String>(1)?)),
    255                 )
    256                 .optional()
    257                 .context("failed to load UI chain index metadata")?;
    258             let Some((schema_version, stored_tip_hash)) = meta else {
    259                 return Ok(None);
    260             };
    261             if schema_version != UI_CACHE_SCHEMA_VERSION || stored_tip_hash != tip_hash {
    262                 return Ok(None);
    263             }
    264 
    265             Ok(Some(UiChainIndex {
    266                 tip_hash: Some(stored_tip_hash),
    267                 outputs: load_ui_output_index(connection)?,
    268                 burn_leader_ranks_by_hash: load_ui_burn_leader_ranks(connection)?,
    269             }))
    270         })
    271     }
    272 
    273     pub fn is_projected_to(&self, tip_hash: &str) -> Result<bool> {
    274         self.with_connection(|connection| {
    275             let projected = connection
    276                 .query_row(
    277                     "SELECT schema_version, tip_hash FROM ui_cache_meta WHERE id = 1",
    278                     [],
    279                     |row| Ok((row.get::<_, u32>(0)?, row.get::<_, String>(1)?)),
    280                 )
    281                 .optional()
    282                 .context("failed to load UI data projection metadata")?
    283                 .is_some_and(|(schema_version, stored_tip_hash)| {
    284                     schema_version == UI_CACHE_SCHEMA_VERSION && stored_tip_hash == tip_hash
    285                 });
    286             Ok(projected)
    287         })
    288     }
    289 
    290     pub fn metrics_are_projected_to(&self, tip_hash: &str) -> Result<bool> {
    291         self.with_connection(|connection| {
    292             let projected = connection
    293                 .query_row(
    294                     "SELECT schema_version, tip_hash FROM metrics_cache_meta WHERE id = 1",
    295                     [],
    296                     |row| Ok((row.get::<_, u32>(0)?, row.get::<_, String>(1)?)),
    297                 )
    298                 .optional()
    299                 .context("failed to inspect metrics projection")?
    300                 .is_some_and(|(schema_version, stored_tip_hash)| {
    301                     schema_version == METRICS_CACHE_SCHEMA_VERSION && stored_tip_hash == tip_hash
    302                 });
    303             Ok(projected)
    304         })
    305     }
    306 
    307     pub fn project_snapshot(&self, snapshot: &ChainSnapshot, keep_metrics: bool) -> Result<()> {
    308         let updated_at_ms = unix_ms();
    309         validate_projection_snapshot_structure(snapshot)?;
    310         let incremental_start = self.incremental_projection_start(snapshot)?;
    311 
    312         if keep_metrics {
    313             self.project_metrics_snapshot(snapshot, updated_at_ms)?;
    314         } else {
    315             self.clear_metrics()?;
    316         }
    317 
    318         if incremental_start == Some(snapshot.blocks.len()) {
    319             return Ok(());
    320         }
    321 
    322         let ledger = Ledger::from_preverified_snapshot(snapshot.clone())
    323             .context("failed to rebuild ledger for UI UTXO projection")?;
    324         let start = incremental_start.unwrap_or(0);
    325         let blocks = &snapshot.blocks[start..];
    326         let ui_index =
    327             build_ui_chain_index_for_blocks(snapshot, &ledger, blocks, incremental_start.is_none());
    328         let utxos = ledger.all_utxos();
    329         let all_wallet_transactions = wallet_transactions_from_snapshot(snapshot);
    330         let wallet_transactions = wallet_transactions_from_blocks(blocks);
    331         let wallet_transactions_v2 = wallet_transactions_v2_from_blocks(
    332             blocks,
    333             &ledger.transaction_v2_domain()?,
    334             AddressNetwork::from_profile_id(&snapshot.launch_profile.profile_id),
    335         );
    336         let leaderboards = build_ui_leaderboards(&utxos, &all_wallet_transactions)?;
    337 
    338         self.with_connection_mut(|connection| {
    339             let transaction = connection
    340                 .transaction()
    341                 .context("failed to start UI data projection transaction")?;
    342             if incremental_start.is_some() {
    343                 append_ui_chain_index(&transaction, &ui_index, updated_at_ms)?;
    344                 replace_ui_utxos(&transaction, &utxos)?;
    345                 append_ui_wallet_transactions(&transaction, &wallet_transactions)?;
    346                 append_ui_wallet_transactions_v2(&transaction, &wallet_transactions_v2)?;
    347             } else {
    348                 replace_ui_chain_index(&transaction, &ui_index, updated_at_ms)?;
    349                 replace_ui_utxos(&transaction, &utxos)?;
    350                 replace_ui_wallet_transactions(&transaction, &wallet_transactions)?;
    351                 replace_ui_wallet_transactions_v2(&transaction, &wallet_transactions_v2)?;
    352             }
    353             replace_ui_leaderboards(&transaction, &leaderboards)?;
    354             transaction
    355                 .commit()
    356                 .context("failed to commit UI data projection transaction")?;
    357             Ok(())
    358         })
    359     }
    360 
    361     fn incremental_projection_start(&self, snapshot: &ChainSnapshot) -> Result<Option<usize>> {
    362         self.with_connection(|connection| {
    363             let meta = connection
    364                 .query_row(
    365                     "SELECT schema_version, tip_hash FROM ui_cache_meta WHERE id = 1",
    366                     [],
    367                     |row| Ok((row.get::<_, u32>(0)?, row.get::<_, String>(1)?)),
    368                 )
    369                 .optional()
    370                 .context("failed to load UI projection metadata")?;
    371             let Some((schema_version, tip_hash)) = meta else {
    372                 return Ok(None);
    373             };
    374             if schema_version != UI_CACHE_SCHEMA_VERSION {
    375                 return Ok(None);
    376             }
    377             Ok(snapshot
    378                 .blocks
    379                 .iter()
    380                 .position(|block| block.hash == tip_hash)
    381                 .map(|height| height + 1))
    382         })
    383     }
    384 
    385     pub fn replace_metrics_for_snapshot(&self, snapshot: &ChainSnapshot) -> Result<()> {
    386         validate_projection_snapshot_structure(snapshot)?;
    387         self.project_metrics_snapshot(snapshot, unix_ms())
    388     }
    389 
    390     pub fn clear_metrics(&self) -> Result<()> {
    391         self.with_connection_mut(clear_metrics_state)
    392     }
    393 
    394     pub fn clear_all(&self) -> Result<()> {
    395         self.with_connection_mut(|connection| {
    396             let transaction = connection
    397                 .transaction()
    398                 .context("failed to start UI data reset transaction")?;
    399             clear_metrics_in_transaction(&transaction)?;
    400             clear_ui_chain_index_in_transaction(&transaction)?;
    401             transaction
    402                 .commit()
    403                 .context("failed to commit UI data reset transaction")?;
    404             Ok(())
    405         })
    406     }
    407 
    408     fn project_metrics_snapshot(&self, snapshot: &ChainSnapshot, updated_at_ms: u64) -> Result<()> {
    409         self.with_connection_mut(|connection| {
    410             let transaction = connection
    411                 .transaction()
    412                 .context("failed to start incremental metrics transaction")?;
    413             project_metrics_in_transaction(&transaction, snapshot, updated_at_ms)?;
    414             transaction
    415                 .commit()
    416                 .context("failed to commit incremental metrics transaction")?;
    417             Ok(())
    418         })
    419     }
    420 
    421     pub fn load_metrics(&self) -> Result<Vec<BlockMetricRow>> {
    422         self.with_connection(|connection| {
    423             let mut statement = connection
    424                 .prepare(
    425                     r#"
    426 SELECT height, block_hash, timestamp_ms, block_time_ms, mine_difficulty_bits,
    427        circulating_supply, known_wallet_addresses, utxo_count, transaction_count, transfer_count, burn_count,
    428        mine_count, burned_amount, total_burned_amount, fees_amount, reward_amount,
    429        vdf_rounds, finalizer_rank
    430 FROM block_metrics
    431 ORDER BY height ASC
    432 "#,
    433                 )
    434                 .context("failed to prepare block metrics query")?;
    435             let rows = statement
    436                 .query_map([], |row| {
    437                     Ok(BlockMetricRow {
    438                         height: row.get(0)?,
    439                         block_hash: row.get(1)?,
    440                         timestamp_ms: row.get(2)?,
    441                         block_time_ms: row.get(3)?,
    442                         mine_difficulty_bits: row.get(4)?,
    443                         circulating_supply: row.get(5)?,
    444                         known_wallet_addresses: row.get(6)?,
    445                         utxo_count: row.get(7)?,
    446                         transaction_count: row.get(8)?,
    447                         transfer_count: row.get(9)?,
    448                         burn_count: row.get(10)?,
    449                         mine_count: row.get(11)?,
    450                         burned_amount: row.get(12)?,
    451                         total_burned_amount: row.get(13)?,
    452                         fees_amount: row.get(14)?,
    453                         reward_amount: row.get(15)?,
    454                         vdf_rounds: row.get(16)?,
    455                         finalizer_rank: row.get(17)?,
    456                     })
    457                 })
    458                 .context("failed to load block metrics")?;
    459             rows.collect::<std::result::Result<Vec<_>, _>>()
    460                 .context("failed to read block metrics rows")
    461         })
    462     }
    463 
    464     pub fn load_recent_metrics(&self, limit: usize) -> Result<Vec<BlockMetricRow>> {
    465         self.with_connection(|connection| {
    466             let mut statement = connection
    467                 .prepare(
    468                     r#"
    469 SELECT height, block_hash, timestamp_ms, block_time_ms, mine_difficulty_bits,
    470        circulating_supply, known_wallet_addresses, utxo_count, transaction_count, transfer_count, burn_count,
    471        mine_count, burned_amount, total_burned_amount, fees_amount, reward_amount,
    472        vdf_rounds, finalizer_rank
    473 FROM block_metrics
    474 ORDER BY height DESC
    475 LIMIT ?1
    476 "#,
    477                 )
    478                 .context("failed to prepare recent block metrics query")?;
    479             let rows = statement
    480                 .query_map([limit as u64], |row| {
    481                     Ok(BlockMetricRow {
    482                         height: row.get(0)?,
    483                         block_hash: row.get(1)?,
    484                         timestamp_ms: row.get(2)?,
    485                         block_time_ms: row.get(3)?,
    486                         mine_difficulty_bits: row.get(4)?,
    487                         circulating_supply: row.get(5)?,
    488                         known_wallet_addresses: row.get(6)?,
    489                         utxo_count: row.get(7)?,
    490                         transaction_count: row.get(8)?,
    491                         transfer_count: row.get(9)?,
    492                         burn_count: row.get(10)?,
    493                         mine_count: row.get(11)?,
    494                         burned_amount: row.get(12)?,
    495                         total_burned_amount: row.get(13)?,
    496                         fees_amount: row.get(14)?,
    497                         reward_amount: row.get(15)?,
    498                         vdf_rounds: row.get(16)?,
    499                         finalizer_rank: row.get(17)?,
    500                     })
    501                 })
    502                 .context("failed to load recent block metrics")?;
    503             let mut rows = rows
    504                 .collect::<std::result::Result<Vec<_>, _>>()
    505                 .context("failed to read recent block metrics rows")?;
    506             rows.reverse();
    507             Ok(rows)
    508         })
    509     }
    510 
    511     pub fn load_leaderboards(&self, limit: usize) -> Result<UiLeaderboards> {
    512         self.with_connection(|connection| {
    513             Ok(UiLeaderboards {
    514                 balances: load_balance_leaderboard(connection, limit)?,
    515                 miners: load_transaction_leaderboard(connection, "mine", limit)?,
    516                 burners: load_transaction_leaderboard(connection, "burn", limit)?,
    517             })
    518         })
    519     }
    520 
    521     pub fn load_wallet_utxos(&self, address: &str) -> Result<Vec<(OutPoint, TxOutput)>> {
    522         self.with_connection(|connection| load_wallet_utxos(connection, address))
    523     }
    524 
    525     pub fn load_outputs(
    526         &self,
    527         outpoints: &BTreeSet<OutPoint>,
    528     ) -> Result<BTreeMap<OutPoint, TxOutput>> {
    529         self.with_connection(|connection| load_outputs(connection, outpoints))
    530     }
    531 
    532     pub fn load_wallet_transactions(
    533         &self,
    534         address: &str,
    535         kinds: &[&str],
    536         offset: usize,
    537         limit: usize,
    538     ) -> Result<(Vec<WalletTransactionProjection>, usize)> {
    539         self.with_connection(|connection| {
    540             load_wallet_transactions(connection, address, kinds, offset, limit)
    541         })
    542     }
    543 
    544     pub fn load_wallet_transactions_for_addresses(
    545         &self,
    546         addresses: &[String],
    547         kinds: &[&str],
    548         offset: usize,
    549         limit: usize,
    550     ) -> Result<(Vec<WalletTransactionProjection>, usize)> {
    551         self.with_connection(|connection| {
    552             load_wallet_transactions_for_addresses(connection, addresses, kinds, offset, limit)
    553         })
    554     }
    555 
    556     pub fn load_wallet_transactions_v2(
    557         &self,
    558         addresses: &[String],
    559         kinds: &[&str],
    560         offset: usize,
    561         limit: usize,
    562     ) -> Result<(Vec<WalletTransactionV2Projection>, usize)> {
    563         self.with_connection(|connection| {
    564             load_wallet_transactions_v2(connection, addresses, kinds, offset, limit)
    565         })
    566     }
    567 
    568     pub fn load_wallet_transaction_by_signature(
    569         &self,
    570         signature: &str,
    571     ) -> Result<Option<WalletTransactionProjection>> {
    572         self.with_connection(|connection| {
    573             connection
    574                 .query_row(
    575                     r#"
    576 SELECT sort_key, kind, block_height, timestamp_ms, block_finalizer, transaction_json
    577 FROM ui_wallet_transactions
    578 WHERE signature = ?1
    579 LIMIT 1
    580 "#,
    581                     [signature],
    582                     wallet_transaction_projection_from_row,
    583                 )
    584                 .optional()
    585                 .context("failed to load UI wallet transaction by signature")
    586         })
    587     }
    588 
    589     fn with_connection<T>(&self, work: impl FnOnce(&Connection) -> Result<T>) -> Result<T> {
    590         let connection = self.open_connection()?;
    591         connection
    592             .execute_batch(
    593                 r#"
    594 PRAGMA busy_timeout = 5000;
    595 PRAGMA synchronous = NORMAL;
    596 "#,
    597             )
    598             .context("failed to configure UI data database connection")?;
    599         work(&connection)
    600     }
    601 
    602     fn with_connection_mut<T>(&self, work: impl FnOnce(&mut Connection) -> Result<T>) -> Result<T> {
    603         let mut connection = self.open_connection()?;
    604         connection
    605             .execute_batch(
    606                 r#"
    607 PRAGMA journal_mode = WAL;
    608 PRAGMA busy_timeout = 5000;
    609 PRAGMA synchronous = NORMAL;
    610 "#,
    611             )
    612             .context("failed to configure UI data database connection")?;
    613         work(&mut connection)
    614     }
    615 
    616     fn open_connection(&self) -> Result<Connection> {
    617         Connection::open(&self.path)
    618             .with_context(|| format!("failed to open UI data database {}", self.path.display()))
    619     }
    620 }
    621 
    622 fn initialize_ui_data_schema(connection: &mut Connection, path: &Path) -> Result<()> {
    623     let stored_version = connection
    624         .pragma_query_value(None, "user_version", |row| row.get::<_, u32>(0))
    625         .context("failed to inspect UI data database schema version")?;
    626     let has_tables = connection
    627         .query_row(
    628             "SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%')",
    629             [],
    630             |row| row.get::<_, bool>(0),
    631         )
    632         .context("failed to inspect UI data database tables")?;
    633 
    634     if has_tables && stored_version != UI_DATA_SCHEMA_VERSION {
    635         println!(
    636             "rebuilding incompatible UI cache schema at {} (version {stored_version}, expected {UI_DATA_SCHEMA_VERSION})",
    637             path.display()
    638         );
    639         let transaction = connection
    640             .transaction()
    641             .context("failed to start UI data schema rebuild transaction")?;
    642         transaction
    643             .execute_batch(RESET_SCHEMA)
    644             .context("failed to clear incompatible UI data database schema")?;
    645         transaction
    646             .execute_batch(SCHEMA)
    647             .context("failed to recreate UI data database schema")?;
    648         transaction
    649             .pragma_update(None, "user_version", UI_DATA_SCHEMA_VERSION)
    650             .context("failed to record UI data database schema version")?;
    651         transaction
    652             .commit()
    653             .context("failed to commit UI data database schema rebuild")?;
    654         return Ok(());
    655     }
    656 
    657     connection
    658         .execute_batch(SCHEMA)
    659         .context("failed to initialize UI data database schema")?;
    660     ensure_block_metrics_column(
    661         connection,
    662         "known_wallet_addresses",
    663         "INTEGER NOT NULL DEFAULT 0",
    664     )?;
    665     ensure_block_metrics_column(connection, "utxo_count", "INTEGER NOT NULL DEFAULT 0")?;
    666     connection
    667         .pragma_update(None, "user_version", UI_DATA_SCHEMA_VERSION)
    668         .context("failed to record UI data database schema version")?;
    669     Ok(())
    670 }
    671 
    672 #[derive(Clone, Debug, Default, Eq, PartialEq)]
    673 pub struct UiLeaderboards {
    674     pub balances: Vec<UiLeaderboardEntry>,
    675     pub miners: Vec<UiLeaderboardEntry>,
    676     pub burners: Vec<UiLeaderboardEntry>,
    677 }
    678 
    679 fn load_balance_leaderboard(
    680     connection: &Connection,
    681     limit: usize,
    682 ) -> Result<Vec<UiLeaderboardEntry>> {
    683     load_materialized_leaderboard(connection, "balances", limit, false)
    684 }
    685 
    686 fn load_transaction_leaderboard(
    687     connection: &Connection,
    688     kind: &str,
    689     limit: usize,
    690 ) -> Result<Vec<UiLeaderboardEntry>> {
    691     load_materialized_leaderboard(connection, kind, limit, true)
    692 }
    693 
    694 fn load_materialized_leaderboard(
    695     connection: &Connection,
    696     kind: &str,
    697     limit: usize,
    698     rank_by_count: bool,
    699 ) -> Result<Vec<UiLeaderboardEntry>> {
    700     let order = if rank_by_count {
    701         "amount DESC, count DESC, address ASC"
    702     } else {
    703         "amount DESC, address ASC"
    704     };
    705     let mut statement = connection
    706         .prepare(&format!(
    707             r#"
    708 SELECT address, amount, count
    709 FROM ui_leaderboards
    710 WHERE kind = ?1
    711 ORDER BY {order}
    712 LIMIT ?2
    713 "#
    714         ))
    715         .with_context(|| format!("failed to prepare {kind} leaderboard query"))?;
    716     let rows = statement
    717         .query_map(params![kind, limit as u64], |row| {
    718             Ok(UiLeaderboardEntry {
    719                 address: row.get(0)?,
    720                 amount: row.get(1)?,
    721                 count: row.get(2)?,
    722             })
    723         })
    724         .with_context(|| format!("failed to load {kind} leaderboard"))?;
    725     rows.collect::<std::result::Result<Vec<_>, _>>()
    726         .with_context(|| format!("failed to read {kind} leaderboard rows"))
    727 }
    728 
    729 fn project_metrics_in_transaction(
    730     transaction: &rusqlite::Transaction<'_>,
    731     snapshot: &ChainSnapshot,
    732     updated_at_ms: u64,
    733 ) -> Result<()> {
    734     let tip = snapshot
    735         .blocks
    736         .last()
    737         .context("cannot project metrics for empty chain snapshot")?;
    738     let mut common_height = metrics_common_height(transaction, snapshot)?;
    739     let mut previous = common_height
    740         .map(|height| load_metric_at_height(transaction, height))
    741         .transpose()?
    742         .flatten();
    743     if common_height.is_some() && previous.is_none() {
    744         common_height = None;
    745     }
    746 
    747     match common_height {
    748         Some(height) => {
    749             transaction
    750                 .execute("DELETE FROM block_metrics WHERE height > ?1", [height])
    751                 .context("failed to truncate reorged block metrics")?;
    752             transaction
    753                 .execute(
    754                     "DELETE FROM metric_known_addresses WHERE first_seen_height > ?1",
    755                     [height],
    756                 )
    757                 .context("failed to truncate reorged metric addresses")?;
    758         }
    759         None => clear_metrics_in_transaction(transaction)?,
    760     }
    761 
    762     let mut known_wallet_addresses = previous
    763         .as_ref()
    764         .map(|metric| metric.known_wallet_addresses)
    765         .unwrap_or_default();
    766     if previous.is_none() {
    767         for address in snapshot.genesis_allocations.keys() {
    768             known_wallet_addresses = known_wallet_addresses
    769                 .checked_add(insert_metric_address(transaction, address, 0)?)
    770                 .context("known metric address count overflows")?;
    771         }
    772     }
    773 
    774     let start_height = common_height.map_or(0, |height| height.saturating_add(1));
    775     for block in snapshot
    776         .blocks
    777         .iter()
    778         .filter(|block| block.height >= start_height)
    779     {
    780         known_wallet_addresses = known_wallet_addresses
    781             .checked_add(index_metric_addresses(transaction, block)?)
    782             .context("known metric address count overflows")?;
    783         let mut metric = incremental_metric_for_block(snapshot, block, previous.as_ref())?;
    784         metric.known_wallet_addresses = known_wallet_addresses;
    785         insert_metric(transaction, &metric)?;
    786         previous = Some(metric);
    787     }
    788 
    789     transaction
    790         .execute(
    791             r#"
    792 INSERT INTO metrics_cache_meta (id, schema_version, height, tip_hash, updated_at_ms)
    793 VALUES (1, ?1, ?2, ?3, ?4)
    794 ON CONFLICT(id) DO UPDATE SET
    795     schema_version = excluded.schema_version,
    796     height = excluded.height,
    797     tip_hash = excluded.tip_hash,
    798     updated_at_ms = excluded.updated_at_ms
    799 "#,
    800             params![
    801                 METRICS_CACHE_SCHEMA_VERSION,
    802                 tip.height,
    803                 tip.hash,
    804                 updated_at_ms
    805             ],
    806         )
    807         .context("failed to update metrics cache metadata")?;
    808     Ok(())
    809 }
    810 
    811 fn metrics_common_height(
    812     transaction: &rusqlite::Transaction<'_>,
    813     snapshot: &ChainSnapshot,
    814 ) -> Result<Option<u64>> {
    815     let meta = transaction
    816         .query_row(
    817             "SELECT schema_version, height, tip_hash FROM metrics_cache_meta WHERE id = 1",
    818             [],
    819             |row| {
    820                 Ok((
    821                     row.get::<_, u32>(0)?,
    822                     row.get::<_, u64>(1)?,
    823                     row.get::<_, String>(2)?,
    824                 ))
    825             },
    826         )
    827         .optional()
    828         .context("failed to load metrics cache metadata")?;
    829     let Some((schema_version, stored_height, stored_hash)) = meta else {
    830         return Ok(None);
    831     };
    832     if schema_version != METRICS_CACHE_SCHEMA_VERSION {
    833         return Ok(None);
    834     }
    835 
    836     let candidate_height = stored_height.min(snapshot.blocks.len().saturating_sub(1) as u64);
    837     if candidate_height == stored_height
    838         && snapshot.blocks[candidate_height as usize].hash == stored_hash
    839     {
    840         return Ok(Some(candidate_height));
    841     }
    842 
    843     let mut statement = transaction
    844         .prepare(
    845             "SELECT height, block_hash FROM block_metrics WHERE height <= ?1 ORDER BY height DESC",
    846         )
    847         .context("failed to prepare metrics common-ancestor query")?;
    848     let rows = statement
    849         .query_map([candidate_height], |row| {
    850             Ok((row.get::<_, u64>(0)?, row.get::<_, String>(1)?))
    851         })
    852         .context("failed to query metrics common ancestor")?;
    853     for row in rows {
    854         let (height, hash) = row.context("failed to read metrics common ancestor")?;
    855         if snapshot
    856             .blocks
    857             .get(height as usize)
    858             .is_some_and(|block| block.hash == hash)
    859         {
    860             return Ok(Some(height));
    861         }
    862     }
    863     Ok(None)
    864 }
    865 
    866 fn load_metric_at_height(
    867     transaction: &rusqlite::Transaction<'_>,
    868     height: u64,
    869 ) -> Result<Option<BlockMetricRow>> {
    870     transaction
    871         .query_row(
    872             r#"
    873 SELECT height, block_hash, timestamp_ms, block_time_ms, mine_difficulty_bits,
    874        circulating_supply, known_wallet_addresses, utxo_count, transaction_count, transfer_count, burn_count,
    875        mine_count, burned_amount, total_burned_amount, fees_amount, reward_amount,
    876        vdf_rounds, finalizer_rank
    877 FROM block_metrics
    878 WHERE height = ?1
    879 "#,
    880             [height],
    881             |row| {
    882                 Ok(BlockMetricRow {
    883                     height: row.get(0)?,
    884                     block_hash: row.get(1)?,
    885                     timestamp_ms: row.get(2)?,
    886                     block_time_ms: row.get(3)?,
    887                     mine_difficulty_bits: row.get(4)?,
    888                     circulating_supply: row.get(5)?,
    889                     known_wallet_addresses: row.get(6)?,
    890                     utxo_count: row.get(7)?,
    891                     transaction_count: row.get(8)?,
    892                     transfer_count: row.get(9)?,
    893                     burn_count: row.get(10)?,
    894                     mine_count: row.get(11)?,
    895                     burned_amount: row.get(12)?,
    896                     total_burned_amount: row.get(13)?,
    897                     fees_amount: row.get(14)?,
    898                     reward_amount: row.get(15)?,
    899                     vdf_rounds: row.get(16)?,
    900                     finalizer_rank: row.get(17)?,
    901                 })
    902             },
    903         )
    904         .optional()
    905         .context("failed to load previous block metric")
    906 }
    907 
    908 fn insert_metric(transaction: &rusqlite::Transaction<'_>, metric: &BlockMetricRow) -> Result<()> {
    909     transaction
    910         .execute(
    911             r#"
    912 INSERT INTO block_metrics (
    913     height, block_hash, timestamp_ms, block_time_ms, mine_difficulty_bits,
    914     circulating_supply, known_wallet_addresses, utxo_count, transaction_count, transfer_count, burn_count,
    915     mine_count, burned_amount, total_burned_amount, fees_amount, reward_amount, vdf_rounds,
    916     finalizer_rank
    917 ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18)
    918 "#,
    919             params![
    920                 metric.height,
    921                 metric.block_hash,
    922                 metric.timestamp_ms,
    923                 metric.block_time_ms,
    924                 metric.mine_difficulty_bits,
    925                 metric.circulating_supply,
    926                 metric.known_wallet_addresses,
    927                 metric.utxo_count,
    928                 metric.transaction_count,
    929                 metric.transfer_count,
    930                 metric.burn_count,
    931                 metric.mine_count,
    932                 metric.burned_amount,
    933                 metric.total_burned_amount,
    934                 metric.fees_amount,
    935                 metric.reward_amount,
    936                 metric.vdf_rounds,
    937                 metric.finalizer_rank,
    938             ],
    939         )
    940         .with_context(|| format!("failed to insert metrics for block {}", metric.height))?;
    941     Ok(())
    942 }
    943 
    944 fn ensure_block_metrics_column(
    945     connection: &Connection,
    946     name: &str,
    947     definition: &str,
    948 ) -> Result<()> {
    949     let mut statement = connection
    950         .prepare("PRAGMA table_info(block_metrics)")
    951         .context("failed to inspect block_metrics schema")?;
    952     let columns = statement
    953         .query_map([], |row| row.get::<_, String>(1))
    954         .context("failed to query block_metrics columns")?
    955         .collect::<std::result::Result<Vec<_>, _>>()
    956         .context("failed to read block_metrics columns")?;
    957     if columns.iter().any(|column| column == name) {
    958         return Ok(());
    959     }
    960     connection
    961         .execute(
    962             &format!("ALTER TABLE block_metrics ADD COLUMN {name} {definition}"),
    963             [],
    964         )
    965         .with_context(|| format!("failed to add block_metrics.{name} column"))?;
    966     Ok(())
    967 }
    968 
    969 fn clear_metrics_in_transaction(transaction: &rusqlite::Transaction<'_>) -> Result<()> {
    970     transaction
    971         .execute("DELETE FROM block_metrics", [])
    972         .context("failed to clear old block metrics")?;
    973     transaction
    974         .execute("DELETE FROM metrics_cache_meta", [])
    975         .context("failed to clear old metrics cache metadata")?;
    976     transaction
    977         .execute("DELETE FROM metric_known_addresses", [])
    978         .context("failed to clear old metric addresses")?;
    979     Ok(())
    980 }
    981 
    982 fn clear_metrics_state(connection: &mut Connection) -> Result<()> {
    983     let transaction = connection
    984         .transaction()
    985         .context("failed to start metrics cleanup transaction")?;
    986     clear_metrics_in_transaction(&transaction)?;
    987     transaction
    988         .commit()
    989         .context("failed to commit metrics cleanup transaction")?;
    990     Ok(())
    991 }
    992 
    993 fn replace_ui_chain_index(
    994     transaction: &rusqlite::Transaction<'_>,
    995     index: &UiChainIndex,
    996     updated_at_ms: u64,
    997 ) -> Result<()> {
    998     clear_ui_chain_index_in_transaction(transaction)?;
    999     let Some(tip_hash) = &index.tip_hash else {
   1000         return Ok(());
   1001     };
   1002     transaction
   1003         .execute(
   1004             r#"
   1005 INSERT INTO ui_cache_meta (id, schema_version, tip_hash, updated_at_ms)
   1006 VALUES (1, ?1, ?2, ?3)
   1007 "#,
   1008             params![UI_CACHE_SCHEMA_VERSION, tip_hash, updated_at_ms],
   1009         )
   1010         .context("failed to persist UI chain index metadata")?;
   1011     for (outpoint, output) in &index.outputs {
   1012         transaction
   1013             .execute(
   1014                 r#"
   1015 INSERT INTO ui_output_index (txid, output_index, address, amount)
   1016 VALUES (?1, ?2, ?3, ?4)
   1017 "#,
   1018                 params![outpoint.txid, outpoint.index, output.address, output.amount],
   1019             )
   1020             .with_context(|| {
   1021                 format!(
   1022                     "failed to persist UI output index row {}:{}",
   1023                     outpoint.txid, outpoint.index
   1024                 )
   1025             })?;
   1026     }
   1027     for (block_hash, ranks) in &index.burn_leader_ranks_by_hash {
   1028         transaction
   1029             .execute(
   1030                 "INSERT INTO ui_burn_leader_rank_blocks (block_hash) VALUES (?1)",
   1031                 params![block_hash],
   1032             )
   1033             .with_context(|| format!("failed to persist UI burn leader rank block {block_hash}"))?;
   1034         for rank in ranks {
   1035             transaction
   1036                 .execute(
   1037                     r#"
   1038 INSERT INTO ui_burn_leader_ranks (
   1039     block_hash, rank, ticket_id, owner, amount, eligible_from_height, eligible_until_height
   1040 ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)
   1041 "#,
   1042                     params![
   1043                         block_hash,
   1044                         rank.rank,
   1045                         rank.ticket_id,
   1046                         rank.owner,
   1047                         rank.amount,
   1048                         rank.eligible_from_height,
   1049                         rank.eligible_until_height,
   1050                     ],
   1051                 )
   1052                 .with_context(|| {
   1053                     format!(
   1054                         "failed to persist UI burn leader rank {} for block {}",
   1055                         rank.rank, block_hash
   1056                     )
   1057                 })?;
   1058         }
   1059     }
   1060     Ok(())
   1061 }
   1062 
   1063 fn append_ui_chain_index(
   1064     transaction: &rusqlite::Transaction<'_>,
   1065     index: &UiChainIndex,
   1066     updated_at_ms: u64,
   1067 ) -> Result<()> {
   1068     let Some(tip_hash) = &index.tip_hash else {
   1069         return Ok(());
   1070     };
   1071     for (outpoint, output) in &index.outputs {
   1072         transaction
   1073             .execute(
   1074                 r#"
   1075 INSERT INTO ui_output_index (txid, output_index, address, amount)
   1076 VALUES (?1, ?2, ?3, ?4)
   1077 "#,
   1078                 params![outpoint.txid, outpoint.index, output.address, output.amount],
   1079             )
   1080             .with_context(|| {
   1081                 format!(
   1082                     "failed to append UI output index row {}:{}",
   1083                     outpoint.txid, outpoint.index
   1084                 )
   1085             })?;
   1086     }
   1087     for (block_hash, ranks) in &index.burn_leader_ranks_by_hash {
   1088         transaction
   1089             .execute(
   1090                 "INSERT INTO ui_burn_leader_rank_blocks (block_hash) VALUES (?1)",
   1091                 params![block_hash],
   1092             )
   1093             .with_context(|| format!("failed to append UI burn leader rank block {block_hash}"))?;
   1094         for rank in ranks {
   1095             transaction
   1096                 .execute(
   1097                     r#"
   1098 INSERT INTO ui_burn_leader_ranks (
   1099     block_hash, rank, ticket_id, owner, amount, eligible_from_height, eligible_until_height
   1100 ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)
   1101 "#,
   1102                     params![
   1103                         block_hash,
   1104                         rank.rank,
   1105                         rank.ticket_id,
   1106                         rank.owner,
   1107                         rank.amount,
   1108                         rank.eligible_from_height,
   1109                         rank.eligible_until_height,
   1110                     ],
   1111                 )
   1112                 .with_context(|| {
   1113                     format!(
   1114                         "failed to append UI burn leader rank {} for block {}",
   1115                         rank.rank, block_hash
   1116                     )
   1117                 })?;
   1118         }
   1119     }
   1120     transaction
   1121         .execute(
   1122             r#"
   1123 UPDATE ui_cache_meta
   1124 SET schema_version = ?1, tip_hash = ?2, updated_at_ms = ?3
   1125 WHERE id = 1
   1126 "#,
   1127             params![UI_CACHE_SCHEMA_VERSION, tip_hash, updated_at_ms],
   1128         )
   1129         .context("failed to advance UI chain index metadata")?;
   1130     Ok(())
   1131 }
   1132 
   1133 fn replace_ui_utxos(
   1134     transaction: &rusqlite::Transaction<'_>,
   1135     utxos: &[(OutPoint, TxOutput)],
   1136 ) -> Result<()> {
   1137     transaction
   1138         .execute("DELETE FROM ui_utxos", [])
   1139         .context("failed to clear old UI UTXO index")?;
   1140     for (outpoint, output) in utxos {
   1141         transaction
   1142             .execute(
   1143                 r#"
   1144 INSERT INTO ui_utxos (txid, output_index, address, amount)
   1145 VALUES (?1, ?2, ?3, ?4)
   1146 "#,
   1147                 params![outpoint.txid, outpoint.index, output.address, output.amount],
   1148             )
   1149             .with_context(|| {
   1150                 format!(
   1151                     "failed to persist UI UTXO row {}:{}",
   1152                     outpoint.txid, outpoint.index
   1153                 )
   1154             })?;
   1155     }
   1156     Ok(())
   1157 }
   1158 
   1159 fn replace_ui_wallet_transactions(
   1160     transaction: &rusqlite::Transaction<'_>,
   1161     rows: &[(String, WalletTransactionProjection)],
   1162 ) -> Result<()> {
   1163     transaction
   1164         .execute("DELETE FROM ui_wallet_transactions", [])
   1165         .context("failed to clear old UI wallet transaction index")?;
   1166     for (address, row) in rows {
   1167         let transaction_json = serde_json::to_vec(&row.transaction)
   1168             .context("failed to serialize UI wallet transaction")?;
   1169         transaction
   1170             .execute(
   1171                 r#"
   1172 INSERT INTO ui_wallet_transactions (
   1173     address, sort_key, kind, signature, block_height, timestamp_ms, block_finalizer,
   1174     transaction_json
   1175 ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)
   1176 "#,
   1177                 params![
   1178                     address,
   1179                     row.sort_key,
   1180                     row.kind,
   1181                     row.transaction.signature(),
   1182                     row.block_height,
   1183                     row.timestamp_ms,
   1184                     row.block_finalizer,
   1185                     transaction_json,
   1186                 ],
   1187             )
   1188             .with_context(|| {
   1189                 format!(
   1190                     "failed to persist UI wallet transaction {} for {}",
   1191                     row.transaction.signature(),
   1192                     address
   1193                 )
   1194             })?;
   1195     }
   1196     Ok(())
   1197 }
   1198 
   1199 fn append_ui_wallet_transactions(
   1200     transaction: &rusqlite::Transaction<'_>,
   1201     rows: &[(String, WalletTransactionProjection)],
   1202 ) -> Result<()> {
   1203     for (address, row) in rows {
   1204         let transaction_json = serde_json::to_vec(&row.transaction)
   1205             .context("failed to serialize UI wallet transaction")?;
   1206         transaction
   1207             .execute(
   1208                 r#"
   1209 INSERT INTO ui_wallet_transactions (
   1210     address, sort_key, kind, signature, block_height, timestamp_ms, block_finalizer,
   1211     transaction_json
   1212 ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)
   1213 "#,
   1214                 params![
   1215                     address,
   1216                     row.sort_key,
   1217                     row.kind,
   1218                     row.transaction.signature(),
   1219                     row.block_height,
   1220                     row.timestamp_ms,
   1221                     row.block_finalizer,
   1222                     transaction_json,
   1223                 ],
   1224             )
   1225             .with_context(|| {
   1226                 format!(
   1227                     "failed to append UI wallet transaction {} for {}",
   1228                     row.transaction.signature(),
   1229                     address
   1230                 )
   1231             })?;
   1232     }
   1233     Ok(())
   1234 }
   1235 
   1236 fn replace_ui_wallet_transactions_v2(
   1237     transaction: &rusqlite::Transaction<'_>,
   1238     rows: &[(String, WalletTransactionV2Projection)],
   1239 ) -> Result<()> {
   1240     transaction
   1241         .execute("DELETE FROM ui_wallet_transactions_v2", [])
   1242         .context("failed to clear old UI wallet transaction v2 index")?;
   1243     for (address, row) in rows {
   1244         transaction
   1245             .execute(
   1246                 r#"
   1247 INSERT INTO ui_wallet_transactions_v2 (
   1248     address, sort_key, kind, transaction_id, block_height, timestamp_ms, block_finalizer,
   1249     envelope
   1250 ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)
   1251 "#,
   1252                 params![
   1253                     address,
   1254                     row.sort_key,
   1255                     row.kind,
   1256                     row.transaction_id,
   1257                     row.block_height,
   1258                     row.timestamp_ms,
   1259                     row.block_finalizer,
   1260                     row.envelope,
   1261                 ],
   1262             )
   1263             .with_context(|| {
   1264                 format!(
   1265                     "failed to persist UI wallet transaction v2 {} for {}",
   1266                     row.transaction_id, address
   1267                 )
   1268             })?;
   1269     }
   1270     Ok(())
   1271 }
   1272 
   1273 fn append_ui_wallet_transactions_v2(
   1274     transaction: &rusqlite::Transaction<'_>,
   1275     rows: &[(String, WalletTransactionV2Projection)],
   1276 ) -> Result<()> {
   1277     for (address, row) in rows {
   1278         transaction
   1279             .execute(
   1280                 r#"
   1281 INSERT INTO ui_wallet_transactions_v2 (
   1282     address, sort_key, kind, transaction_id, block_height, timestamp_ms, block_finalizer,
   1283     envelope
   1284 ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)
   1285 "#,
   1286                 params![
   1287                     address,
   1288                     row.sort_key,
   1289                     row.kind,
   1290                     row.transaction_id,
   1291                     row.block_height,
   1292                     row.timestamp_ms,
   1293                     row.block_finalizer,
   1294                     row.envelope,
   1295                 ],
   1296             )
   1297             .with_context(|| {
   1298                 format!(
   1299                     "failed to append UI wallet transaction v2 {} for {}",
   1300                     row.transaction_id, address
   1301                 )
   1302             })?;
   1303     }
   1304     Ok(())
   1305 }
   1306 
   1307 fn replace_ui_leaderboards(
   1308     transaction: &rusqlite::Transaction<'_>,
   1309     rows: &[(String, UiLeaderboardEntry)],
   1310 ) -> Result<()> {
   1311     transaction
   1312         .execute("DELETE FROM ui_leaderboards", [])
   1313         .context("failed to clear old UI leaderboards")?;
   1314     for (kind, row) in rows {
   1315         transaction
   1316             .execute(
   1317                 r#"
   1318 INSERT INTO ui_leaderboards (kind, address, amount, count)
   1319 VALUES (?1, ?2, ?3, ?4)
   1320 "#,
   1321                 params![kind, row.address, row.amount, row.count],
   1322             )
   1323             .with_context(|| {
   1324                 format!(
   1325                     "failed to persist {kind} leaderboard row for {}",
   1326                     row.address
   1327                 )
   1328             })?;
   1329     }
   1330     Ok(())
   1331 }
   1332 
   1333 fn clear_ui_chain_index_in_transaction(transaction: &rusqlite::Transaction<'_>) -> Result<()> {
   1334     transaction
   1335         .execute("DELETE FROM ui_cache_meta", [])
   1336         .context("failed to clear old UI cache metadata")?;
   1337     transaction
   1338         .execute("DELETE FROM ui_output_index", [])
   1339         .context("failed to clear old UI output index")?;
   1340     transaction
   1341         .execute("DELETE FROM ui_utxos", [])
   1342         .context("failed to clear old UI UTXO index")?;
   1343     transaction
   1344         .execute("DELETE FROM ui_wallet_transactions", [])
   1345         .context("failed to clear old UI wallet transaction index")?;
   1346     transaction
   1347         .execute("DELETE FROM ui_wallet_transactions_v2", [])
   1348         .context("failed to clear old UI wallet transaction v2 index")?;
   1349     transaction
   1350         .execute("DELETE FROM ui_leaderboards", [])
   1351         .context("failed to clear old UI leaderboards")?;
   1352     transaction
   1353         .execute("DELETE FROM ui_burn_leader_ranks", [])
   1354         .context("failed to clear old UI burn leader rank index")?;
   1355     transaction
   1356         .execute("DELETE FROM ui_burn_leader_rank_blocks", [])
   1357         .context("failed to clear old UI burn leader rank block index")?;
   1358     Ok(())
   1359 }
   1360 
   1361 fn load_ui_output_index(connection: &Connection) -> Result<BTreeMap<OutPoint, TxOutput>> {
   1362     let mut statement = connection
   1363         .prepare(
   1364             r#"
   1365 SELECT txid, output_index, address, amount
   1366 FROM ui_output_index
   1367 ORDER BY txid, output_index
   1368 "#,
   1369         )
   1370         .context("failed to prepare UI output index query")?;
   1371     let rows = statement
   1372         .query_map([], |row| {
   1373             Ok((
   1374                 OutPoint {
   1375                     txid: row.get(0)?,
   1376                     index: row.get(1)?,
   1377                 },
   1378                 TxOutput {
   1379                     address: row.get(2)?,
   1380                     amount: row.get(3)?,
   1381                 },
   1382             ))
   1383         })
   1384         .context("failed to load UI output index")?;
   1385     rows.collect::<std::result::Result<BTreeMap<_, _>, _>>()
   1386         .context("failed to read UI output index rows")
   1387 }
   1388 
   1389 fn load_outputs(
   1390     connection: &Connection,
   1391     outpoints: &BTreeSet<OutPoint>,
   1392 ) -> Result<BTreeMap<OutPoint, TxOutput>> {
   1393     if outpoints.is_empty() {
   1394         return Ok(BTreeMap::new());
   1395     }
   1396     let mut outputs = BTreeMap::new();
   1397     let mut statement = connection
   1398         .prepare(
   1399             r#"
   1400 SELECT address, amount
   1401 FROM ui_output_index
   1402 WHERE txid = ?1 AND output_index = ?2
   1403 "#,
   1404         )
   1405         .context("failed to prepare narrow UI output lookup")?;
   1406     for outpoint in outpoints {
   1407         let output = statement
   1408             .query_row(params![outpoint.txid, outpoint.index], |row| {
   1409                 Ok(TxOutput {
   1410                     address: row.get(0)?,
   1411                     amount: row.get(1)?,
   1412                 })
   1413             })
   1414             .optional()
   1415             .with_context(|| {
   1416                 format!(
   1417                     "failed to load UI output {}:{}",
   1418                     outpoint.txid, outpoint.index
   1419                 )
   1420             })?;
   1421         if let Some(output) = output {
   1422             outputs.insert(outpoint.clone(), output);
   1423         }
   1424     }
   1425     Ok(outputs)
   1426 }
   1427 
   1428 fn load_wallet_utxos(connection: &Connection, address: &str) -> Result<Vec<(OutPoint, TxOutput)>> {
   1429     let mut statement = connection
   1430         .prepare(
   1431             r#"
   1432 SELECT txid, output_index, address, amount
   1433 FROM ui_utxos
   1434 WHERE address = ?1
   1435 ORDER BY amount DESC, txid ASC, output_index ASC
   1436 "#,
   1437         )
   1438         .context("failed to prepare UI wallet UTXO query")?;
   1439     let rows = statement
   1440         .query_map([address], |row| {
   1441             Ok((
   1442                 OutPoint {
   1443                     txid: row.get(0)?,
   1444                     index: row.get(1)?,
   1445                 },
   1446                 TxOutput {
   1447                     address: row.get(2)?,
   1448                     amount: row.get(3)?,
   1449                 },
   1450             ))
   1451         })
   1452         .context("failed to load UI wallet UTXOs")?;
   1453     rows.collect::<std::result::Result<Vec<_>, _>>()
   1454         .context("failed to read UI wallet UTXO rows")
   1455 }
   1456 
   1457 fn load_wallet_transactions(
   1458     connection: &Connection,
   1459     address: &str,
   1460     kinds: &[&str],
   1461     offset: usize,
   1462     limit: usize,
   1463 ) -> Result<(Vec<WalletTransactionProjection>, usize)> {
   1464     if kinds.is_empty() {
   1465         return Ok((Vec::new(), 0));
   1466     }
   1467     if wallet_transaction_kinds_cover_all(kinds) {
   1468         let total = connection
   1469             .query_row(
   1470                 "SELECT COUNT(*) FROM ui_wallet_transactions WHERE address = ?1",
   1471                 params![address],
   1472                 |row| row.get::<_, u64>(0),
   1473             )
   1474             .context("failed to count UI wallet transactions")? as usize;
   1475         let mut statement = connection
   1476             .prepare(
   1477                 r#"
   1478 SELECT sort_key, kind, block_height, timestamp_ms, block_finalizer, transaction_json
   1479 FROM ui_wallet_transactions
   1480 WHERE address = ?1
   1481 ORDER BY sort_key DESC
   1482 LIMIT ?2 OFFSET ?3
   1483 "#,
   1484             )
   1485             .context("failed to prepare UI wallet transactions query")?;
   1486         let rows = read_wallet_transaction_rows(
   1487             statement.query_map(params![address, limit as i64, offset as i64], |row| {
   1488                 wallet_transaction_projection_from_row(row)
   1489             })?,
   1490         )?;
   1491         return Ok((rows, total));
   1492     }
   1493     let placeholders = std::iter::repeat_n("?", kinds.len())
   1494         .collect::<Vec<_>>()
   1495         .join(", ");
   1496     let count_sql = format!(
   1497         "SELECT COUNT(*) FROM ui_wallet_transactions WHERE address = ? AND kind IN ({placeholders})"
   1498     );
   1499     let mut count_params = Vec::<Value>::with_capacity(kinds.len() + 1);
   1500     count_params.push(Value::Text(address.to_string()));
   1501     for kind in kinds {
   1502         count_params.push(Value::Text((*kind).to_string()));
   1503     }
   1504     let total = connection
   1505         .query_row(&count_sql, params_from_iter(count_params.iter()), |row| {
   1506             row.get::<_, u64>(0)
   1507         })
   1508         .context("failed to count UI wallet transactions")? as usize;
   1509 
   1510     let query_sql = format!(
   1511         r#"
   1512 SELECT sort_key, kind, block_height, timestamp_ms, block_finalizer, transaction_json
   1513 FROM ui_wallet_transactions
   1514 WHERE address = ? AND kind IN ({placeholders})
   1515 ORDER BY sort_key DESC
   1516 LIMIT ? OFFSET ?
   1517 "#
   1518     );
   1519     let mut query_params = Vec::<Value>::with_capacity(kinds.len() + 3);
   1520     query_params.push(Value::Text(address.to_string()));
   1521     for kind in kinds {
   1522         query_params.push(Value::Text((*kind).to_string()));
   1523     }
   1524     query_params.push(Value::Integer(limit as i64));
   1525     query_params.push(Value::Integer(offset as i64));
   1526     let mut statement = connection
   1527         .prepare(&query_sql)
   1528         .context("failed to prepare UI wallet transactions query")?;
   1529     let rows = read_wallet_transaction_rows(
   1530         statement
   1531             .query_map(params_from_iter(query_params.iter()), |row| {
   1532                 wallet_transaction_projection_from_row(row)
   1533             })
   1534             .context("failed to load UI wallet transactions")?,
   1535     )?;
   1536     Ok((rows, total))
   1537 }
   1538 
   1539 fn load_wallet_transactions_for_addresses(
   1540     connection: &Connection,
   1541     addresses: &[String],
   1542     kinds: &[&str],
   1543     offset: usize,
   1544     limit: usize,
   1545 ) -> Result<(Vec<WalletTransactionProjection>, usize)> {
   1546     if addresses.is_empty() || kinds.is_empty() {
   1547         return Ok((Vec::new(), 0));
   1548     }
   1549     let address_placeholders = std::iter::repeat_n("?", addresses.len())
   1550         .collect::<Vec<_>>()
   1551         .join(", ");
   1552     let kind_placeholders = std::iter::repeat_n("?", kinds.len())
   1553         .collect::<Vec<_>>()
   1554         .join(", ");
   1555     let predicate =
   1556         format!("address IN ({address_placeholders}) AND kind IN ({kind_placeholders})");
   1557     let mut filter_params = Vec::<Value>::with_capacity(addresses.len() + kinds.len());
   1558     filter_params.extend(addresses.iter().cloned().map(Value::Text));
   1559     filter_params.extend(kinds.iter().map(|kind| Value::Text((*kind).to_string())));
   1560 
   1561     let count_sql =
   1562         format!("SELECT COUNT(DISTINCT signature) FROM ui_wallet_transactions WHERE {predicate}");
   1563     let total = connection
   1564         .query_row(&count_sql, params_from_iter(filter_params.iter()), |row| {
   1565             row.get::<_, u64>(0)
   1566         })
   1567         .context("failed to count UI wallet transactions")? as usize;
   1568 
   1569     let query_sql = format!(
   1570         r#"
   1571 SELECT DISTINCT sort_key, kind, block_height, timestamp_ms, block_finalizer, transaction_json,
   1572        signature
   1573 FROM ui_wallet_transactions
   1574 WHERE {predicate}
   1575 ORDER BY sort_key DESC, signature ASC
   1576 LIMIT ? OFFSET ?
   1577 "#
   1578     );
   1579     let mut query_params = filter_params;
   1580     query_params.push(Value::Integer(limit as i64));
   1581     query_params.push(Value::Integer(offset as i64));
   1582     let mut statement = connection
   1583         .prepare(&query_sql)
   1584         .context("failed to prepare UI wallet transactions query")?;
   1585     let rows = read_wallet_transaction_rows(
   1586         statement
   1587             .query_map(params_from_iter(query_params.iter()), |row| {
   1588                 wallet_transaction_projection_from_row(row)
   1589             })
   1590             .context("failed to load UI wallet transactions")?,
   1591     )?;
   1592     Ok((rows, total))
   1593 }
   1594 
   1595 fn load_wallet_transactions_v2(
   1596     connection: &Connection,
   1597     addresses: &[String],
   1598     kinds: &[&str],
   1599     offset: usize,
   1600     limit: usize,
   1601 ) -> Result<(Vec<WalletTransactionV2Projection>, usize)> {
   1602     if addresses.is_empty() || kinds.is_empty() {
   1603         return Ok((Vec::new(), 0));
   1604     }
   1605     let address_placeholders = std::iter::repeat_n("?", addresses.len())
   1606         .collect::<Vec<_>>()
   1607         .join(", ");
   1608     let kind_placeholders = std::iter::repeat_n("?", kinds.len())
   1609         .collect::<Vec<_>>()
   1610         .join(", ");
   1611     let predicate =
   1612         format!("address IN ({address_placeholders}) AND kind IN ({kind_placeholders})");
   1613     let mut filter_params = Vec::<Value>::with_capacity(addresses.len() + kinds.len());
   1614     filter_params.extend(addresses.iter().cloned().map(Value::Text));
   1615     filter_params.extend(kinds.iter().map(|kind| Value::Text((*kind).to_string())));
   1616 
   1617     let count_sql = format!(
   1618         "SELECT COUNT(DISTINCT transaction_id) FROM ui_wallet_transactions_v2 WHERE {predicate}"
   1619     );
   1620     let total = connection
   1621         .query_row(&count_sql, params_from_iter(filter_params.iter()), |row| {
   1622             row.get::<_, u64>(0)
   1623         })
   1624         .context("failed to count UI wallet transactions v2")? as usize;
   1625 
   1626     let query_sql = format!(
   1627         r#"
   1628 SELECT DISTINCT sort_key, kind, block_height, timestamp_ms, block_finalizer, envelope,
   1629        transaction_id
   1630 FROM ui_wallet_transactions_v2
   1631 WHERE {predicate}
   1632 ORDER BY sort_key DESC, transaction_id ASC
   1633 LIMIT ? OFFSET ?
   1634 "#
   1635     );
   1636     let mut query_params = filter_params;
   1637     query_params.push(Value::Integer(limit as i64));
   1638     query_params.push(Value::Integer(offset as i64));
   1639     let mut statement = connection
   1640         .prepare(&query_sql)
   1641         .context("failed to prepare UI wallet transactions v2 query")?;
   1642     let rows = statement
   1643         .query_map(params_from_iter(query_params.iter()), |row| {
   1644             Ok(WalletTransactionV2Projection {
   1645                 sort_key: row.get(0)?,
   1646                 kind: row.get(1)?,
   1647                 block_height: row.get(2)?,
   1648                 timestamp_ms: row.get(3)?,
   1649                 block_finalizer: row.get(4)?,
   1650                 envelope: row.get(5)?,
   1651                 transaction_id: row.get(6)?,
   1652             })
   1653         })
   1654         .context("failed to load UI wallet transactions v2")?
   1655         .collect::<std::result::Result<Vec<_>, _>>()
   1656         .context("failed to read UI wallet transaction v2 rows")?;
   1657     Ok((rows, total))
   1658 }
   1659 
   1660 fn wallet_transaction_kinds_cover_all(kinds: &[&str]) -> bool {
   1661     ["transfer", "mine", "burn", "reward"]
   1662         .into_iter()
   1663         .all(|kind| kinds.contains(&kind))
   1664 }
   1665 
   1666 fn wallet_transaction_projection_from_row(
   1667     row: &rusqlite::Row<'_>,
   1668 ) -> rusqlite::Result<WalletTransactionProjection> {
   1669     let transaction_json = row.get::<_, Vec<u8>>(5)?;
   1670     let transaction =
   1671         serde_json::from_slice::<Transaction>(&transaction_json).map_err(|error| {
   1672             rusqlite::Error::FromSqlConversionFailure(
   1673                 transaction_json.len(),
   1674                 rusqlite::types::Type::Blob,
   1675                 Box::new(error),
   1676             )
   1677         })?;
   1678     Ok(WalletTransactionProjection {
   1679         sort_key: row.get(0)?,
   1680         kind: row.get(1)?,
   1681         block_height: row.get(2)?,
   1682         timestamp_ms: row.get(3)?,
   1683         block_finalizer: row.get(4)?,
   1684         transaction,
   1685     })
   1686 }
   1687 
   1688 fn read_wallet_transaction_rows(
   1689     rows: rusqlite::MappedRows<
   1690         '_,
   1691         impl FnMut(&rusqlite::Row<'_>) -> rusqlite::Result<WalletTransactionProjection>,
   1692     >,
   1693 ) -> Result<Vec<WalletTransactionProjection>> {
   1694     rows.collect::<std::result::Result<Vec<_>, _>>()
   1695         .context("failed to read UI wallet transaction rows")
   1696 }
   1697 
   1698 fn load_ui_burn_leader_ranks(
   1699     connection: &Connection,
   1700 ) -> Result<BTreeMap<String, Vec<BurnLeaderRank>>> {
   1701     let mut blocks_statement = connection
   1702         .prepare("SELECT block_hash FROM ui_burn_leader_rank_blocks ORDER BY block_hash")
   1703         .context("failed to prepare UI burn leader rank block query")?;
   1704     let blocks = blocks_statement
   1705         .query_map([], |row| row.get::<_, String>(0))
   1706         .context("failed to load UI burn leader rank blocks")?;
   1707     let mut by_block_hash = BTreeMap::<String, Vec<BurnLeaderRank>>::new();
   1708     for block_hash in blocks {
   1709         by_block_hash.insert(
   1710             block_hash.context("failed to read UI burn leader rank block row")?,
   1711             Vec::new(),
   1712         );
   1713     }
   1714 
   1715     let mut statement = connection
   1716         .prepare(
   1717             r#"
   1718 SELECT block_hash, rank, ticket_id, owner, amount, eligible_from_height, eligible_until_height
   1719 FROM ui_burn_leader_ranks
   1720 ORDER BY block_hash, rank
   1721 "#,
   1722         )
   1723         .context("failed to prepare UI burn leader rank query")?;
   1724     let rows = statement
   1725         .query_map([], |row| {
   1726             Ok((
   1727                 row.get::<_, String>(0)?,
   1728                 BurnLeaderRank {
   1729                     rank: row.get(1)?,
   1730                     ticket_id: row.get(2)?,
   1731                     owner: row.get(3)?,
   1732                     amount: row.get(4)?,
   1733                     eligible_from_height: row.get(5)?,
   1734                     eligible_until_height: row.get(6)?,
   1735                 },
   1736             ))
   1737         })
   1738         .context("failed to load UI burn leader ranks")?;
   1739     for row in rows {
   1740         let (block_hash, rank) = row.context("failed to read UI burn leader rank row")?;
   1741         by_block_hash.entry(block_hash).or_default().push(rank);
   1742     }
   1743     Ok(by_block_hash)
   1744 }
   1745 
   1746 fn build_ui_leaderboards(
   1747     utxos: &[(OutPoint, TxOutput)],
   1748     wallet_transactions: &[(String, WalletTransactionProjection)],
   1749 ) -> Result<Vec<(String, UiLeaderboardEntry)>> {
   1750     let mut by_kind = BTreeMap::<(String, String), UiLeaderboardEntry>::new();
   1751     for (_, output) in utxos {
   1752         if output.amount == 0 {
   1753             continue;
   1754         }
   1755         let key = ("balances".to_string(), output.address.clone());
   1756         let entry = by_kind.entry(key).or_insert_with(|| UiLeaderboardEntry {
   1757             address: output.address.clone(),
   1758             amount: 0,
   1759             count: 0,
   1760         });
   1761         entry.amount = entry
   1762             .amount
   1763             .checked_add(output.amount)
   1764             .context("balance leaderboard amount overflows")?;
   1765         entry.count = entry
   1766             .count
   1767             .checked_add(1)
   1768             .context("balance leaderboard count overflows")?;
   1769     }
   1770     for (address, projection) in wallet_transactions {
   1771         let (kind, amount) = match &projection.transaction {
   1772             Transaction::Mine { .. } => ("mine", MINE_REWARD),
   1773             Transaction::Burn { amount, .. } => ("burn", *amount),
   1774             Transaction::Transfer { .. } => continue,
   1775         };
   1776         let key = (kind.to_string(), address.clone());
   1777         let entry = by_kind.entry(key).or_insert_with(|| UiLeaderboardEntry {
   1778             address: address.clone(),
   1779             amount: 0,
   1780             count: 0,
   1781         });
   1782         entry.amount = entry
   1783             .amount
   1784             .checked_add(amount)
   1785             .with_context(|| format!("{kind} leaderboard amount overflows"))?;
   1786         entry.count = entry
   1787             .count
   1788             .checked_add(1)
   1789             .with_context(|| format!("{kind} leaderboard count overflows"))?;
   1790     }
   1791     Ok(by_kind
   1792         .into_iter()
   1793         .map(|((kind, _), entry)| (kind, entry))
   1794         .collect())
   1795 }
   1796 
   1797 fn wallet_transactions_from_snapshot(
   1798     snapshot: &ChainSnapshot,
   1799 ) -> Vec<(String, WalletTransactionProjection)> {
   1800     wallet_transactions_from_blocks(&snapshot.blocks)
   1801 }
   1802 
   1803 fn wallet_transactions_from_blocks(blocks: &[Block]) -> Vec<(String, WalletTransactionProjection)> {
   1804     let mut rows = Vec::new();
   1805     for block in blocks {
   1806         for (index, transaction) in block.transactions.iter().rev().enumerate() {
   1807             push_wallet_transaction_projection(
   1808                 &mut rows,
   1809                 transaction,
   1810                 block,
   1811                 block.height as u128 * 10_000 + index as u128,
   1812             );
   1813         }
   1814         let reward_outputs = if block.height > 0 {
   1815             projected_reward_outputs(block)
   1816         } else {
   1817             Vec::new()
   1818         };
   1819         let all_reward_outputs = reward_outputs
   1820             .iter()
   1821             .map(|(_, output)| output.clone())
   1822             .collect::<Vec<_>>();
   1823         for (index, (outpoint, output)) in reward_outputs.into_iter().enumerate() {
   1824             let signature = format!("reward:{}:{}", outpoint.txid, outpoint.index);
   1825             rows.push((
   1826                 output.address.clone(),
   1827                 WalletTransactionProjection {
   1828                     sort_key: (block.height as u128 * 10_000 + 9_999 - index as u128)
   1829                         .min(u128::from(u64::MAX)) as u64,
   1830                     kind: "reward".to_string(),
   1831                     block_height: block.height,
   1832                     timestamp_ms: block.timestamp_ms,
   1833                     block_finalizer: block.miner.clone(),
   1834                     transaction: Transaction::Transfer {
   1835                         inputs: Vec::new(),
   1836                         outputs: all_reward_outputs.clone(),
   1837                         fee: 0,
   1838                         signature,
   1839                     },
   1840                 },
   1841             ));
   1842         }
   1843     }
   1844     rows
   1845 }
   1846 
   1847 #[cfg(test)]
   1848 fn wallet_transactions_v2_from_snapshot(
   1849     snapshot: &ChainSnapshot,
   1850     expected_domain: &TransactionV2Domain,
   1851 ) -> Vec<(String, WalletTransactionV2Projection)> {
   1852     let network = AddressNetwork::from_profile_id(&snapshot.launch_profile.profile_id);
   1853     wallet_transactions_v2_from_blocks(&snapshot.blocks, expected_domain, network)
   1854 }
   1855 
   1856 fn wallet_transactions_v2_from_blocks(
   1857     blocks: &[Block],
   1858     expected_domain: &TransactionV2Domain,
   1859     network: AddressNetwork,
   1860 ) -> Vec<(String, WalletTransactionV2Projection)> {
   1861     let mut rows = Vec::new();
   1862     for block in blocks {
   1863         for (index, envelope) in block.transactions_v2.iter().rev().enumerate() {
   1864             let Some(transaction) = decode_projected_transaction_v2(envelope, expected_domain)
   1865             else {
   1866                 continue;
   1867             };
   1868             let Ok(transaction_id) = transaction.transaction_id(expected_domain).map(hex_encode)
   1869             else {
   1870                 continue;
   1871             };
   1872             let projection = WalletTransactionV2Projection {
   1873                 sort_key: (block.height as u128 * 10_000 + 5_000 + index as u128)
   1874                     .min(u128::from(u64::MAX)) as u64,
   1875                 kind: transaction_v2_filter_kind(&transaction).to_string(),
   1876                 transaction_id,
   1877                 block_height: block.height,
   1878                 timestamp_ms: block.timestamp_ms,
   1879                 block_finalizer: block.miner.clone(),
   1880                 envelope: envelope.clone(),
   1881             };
   1882             for address in wallet_transaction_v2_addresses(&transaction, network) {
   1883                 rows.push((address, projection.clone()));
   1884             }
   1885         }
   1886     }
   1887     rows
   1888 }
   1889 
   1890 fn decode_projected_transaction_v2(
   1891     envelope: &str,
   1892     expected_domain: &TransactionV2Domain,
   1893 ) -> Option<TransactionV2> {
   1894     let bytes = decode_hex(envelope).ok()?;
   1895     let (domain, transaction) = TransactionV2::decode(&bytes).ok()?;
   1896     (domain == *expected_domain).then_some(transaction)
   1897 }
   1898 
   1899 fn wallet_transaction_v2_addresses(
   1900     transaction: &TransactionV2,
   1901     network: AddressNetwork,
   1902 ) -> BTreeSet<String> {
   1903     let mut addresses = BTreeSet::new();
   1904     let mut insert = |address| {
   1905         if let Ok(address) = encode_versioned_address(address, network) {
   1906             addresses.insert(address);
   1907         }
   1908     };
   1909     match transaction {
   1910         TransactionV2::Migration {
   1911             inputs, outputs, ..
   1912         } => {
   1913             inputs.iter().for_each(|input| insert(input.owner));
   1914             outputs.iter().for_each(|output| insert(output.address));
   1915         }
   1916         TransactionV2::Transfer {
   1917             inputs, outputs, ..
   1918         } => {
   1919             inputs.iter().for_each(|input| insert(input.owner));
   1920             outputs.iter().for_each(|output| insert(output.address));
   1921         }
   1922         TransactionV2::Burn { inputs, change, .. } => {
   1923             inputs.iter().for_each(|input| insert(input.owner));
   1924             change.iter().for_each(|output| insert(output.address));
   1925         }
   1926         TransactionV2::Mine { recipient, .. } => insert(*recipient),
   1927     }
   1928     addresses
   1929 }
   1930 
   1931 fn transaction_v2_filter_kind(transaction: &TransactionV2) -> &'static str {
   1932     match transaction {
   1933         TransactionV2::Migration { .. } | TransactionV2::Transfer { .. } => "transfer",
   1934         TransactionV2::Burn { .. } => "burn",
   1935         TransactionV2::Mine { .. } => "mine",
   1936     }
   1937 }
   1938 
   1939 fn push_wallet_transaction_projection(
   1940     rows: &mut Vec<(String, WalletTransactionProjection)>,
   1941     transaction: &Transaction,
   1942     block: &Block,
   1943     sort_key: u128,
   1944 ) {
   1945     let kind = transaction_kind(transaction).to_string();
   1946     let projection = WalletTransactionProjection {
   1947         sort_key: sort_key.min(u128::from(u64::MAX)) as u64,
   1948         kind,
   1949         block_height: block.height,
   1950         timestamp_ms: block.timestamp_ms,
   1951         block_finalizer: block.miner.clone(),
   1952         transaction: transaction.clone(),
   1953     };
   1954     for address in wallet_transaction_addresses(transaction) {
   1955         rows.push((address, projection.clone()));
   1956     }
   1957 }
   1958 
   1959 fn wallet_transaction_addresses(transaction: &Transaction) -> Vec<String> {
   1960     match transaction {
   1961         Transaction::Transfer { .. } => {
   1962             let mut addresses = vec![transaction.sender().to_string()];
   1963             if let Some(to) = transaction.to() {
   1964                 if to != transaction.sender() {
   1965                     addresses.push(to.to_string());
   1966                 }
   1967             }
   1968             addresses
   1969         }
   1970         Transaction::Burn { .. } => vec![transaction.sender().to_string()],
   1971         Transaction::Mine { recipient, .. } => vec![recipient.clone()],
   1972     }
   1973 }
   1974 
   1975 fn transaction_kind(transaction: &Transaction) -> &'static str {
   1976     match transaction {
   1977         Transaction::Transfer { .. } => "transfer",
   1978         Transaction::Burn { .. } => "burn",
   1979         Transaction::Mine { .. } => "mine",
   1980     }
   1981 }
   1982 
   1983 fn validate_projection_snapshot_structure(snapshot: &ChainSnapshot) -> Result<()> {
   1984     if snapshot.blocks.is_empty() {
   1985         anyhow::bail!("chain snapshot is empty");
   1986     }
   1987     for (index, block) in snapshot.blocks.iter().enumerate() {
   1988         if block.height != index as u64 {
   1989             anyhow::bail!("chain snapshot block heights are not contiguous");
   1990         }
   1991         if index > 0 && block.prev_hash != snapshot.blocks[index - 1].hash {
   1992             anyhow::bail!("chain snapshot parent hashes are not contiguous");
   1993         }
   1994     }
   1995     Ok(())
   1996 }
   1997 
   1998 fn incremental_metric_for_block(
   1999     snapshot: &ChainSnapshot,
   2000     block: &Block,
   2001     previous: Option<&BlockMetricRow>,
   2002 ) -> Result<BlockMetricRow> {
   2003     let mut circulating_supply = match previous {
   2004         Some(previous) => previous.circulating_supply,
   2005         None => snapshot
   2006             .genesis_allocations
   2007             .values()
   2008             .try_fold(0_u64, |total, amount| {
   2009                 total
   2010                     .checked_add(*amount)
   2011                     .context("genesis circulating supply overflows")
   2012             })?,
   2013     };
   2014     let mut transfer_count = 0_u64;
   2015     let mut burn_count = 0_u64;
   2016     let mut mine_count = 0_u64;
   2017     let mut burned_amount = 0_u64;
   2018     let mut fees_amount = 0_u64;
   2019     let mut utxo_count = previous.map(|row| row.utxo_count).unwrap_or_else(|| {
   2020         snapshot
   2021             .genesis_allocations
   2022             .values()
   2023             .filter(|amount| **amount > 0)
   2024             .count() as u64
   2025     });
   2026 
   2027     for transaction in &block.transactions {
   2028         fees_amount = fees_amount
   2029             .checked_add(transaction.fee())
   2030             .context("block metric fees overflow")?;
   2031         match transaction {
   2032             Transaction::Transfer {
   2033                 inputs,
   2034                 outputs,
   2035                 fee,
   2036                 ..
   2037             } => {
   2038                 transfer_count += 1;
   2039                 apply_utxo_count_delta(&mut utxo_count, inputs.len(), outputs.len())?;
   2040                 circulating_supply = circulating_supply
   2041                     .checked_sub(*fee)
   2042                     .context("transfer fee exceeds circulating supply")?;
   2043             }
   2044             Transaction::Burn {
   2045                 inputs,
   2046                 change,
   2047                 amount,
   2048                 fee,
   2049                 ..
   2050             } => {
   2051                 burn_count += 1;
   2052                 apply_utxo_count_delta(&mut utxo_count, inputs.len(), change.len())?;
   2053                 burned_amount = burned_amount
   2054                     .checked_add(*amount)
   2055                     .context("block metric burns overflow")?;
   2056                 circulating_supply = circulating_supply
   2057                     .checked_sub(*amount)
   2058                     .and_then(|supply| supply.checked_sub(*fee))
   2059                     .context("burn exceeds circulating supply")?;
   2060             }
   2061             Transaction::Mine { .. } => {
   2062                 mine_count += 1;
   2063                 utxo_count = utxo_count.checked_add(1).context("UTXO count overflows")?;
   2064                 circulating_supply = circulating_supply
   2065                     .checked_add(MINE_REWARD)
   2066                     .context("mine reward circulating supply overflows")?;
   2067             }
   2068         }
   2069     }
   2070     for envelope in &block.transactions_v2 {
   2071         let encoded = decode_hex(envelope).context("block transaction v2 is not hexadecimal")?;
   2072         let (_, transaction) = TransactionV2::decode(&encoded)?;
   2073         fees_amount = fees_amount
   2074             .checked_add(transaction.fee())
   2075             .context("block metric transaction v2 fees overflow")?;
   2076         circulating_supply = circulating_supply
   2077             .checked_sub(transaction.fee())
   2078             .context("transaction v2 fee exceeds circulating supply")?;
   2079         match transaction {
   2080             TransactionV2::Migration {
   2081                 inputs, outputs, ..
   2082             } => {
   2083                 transfer_count += 1;
   2084                 apply_utxo_count_delta(&mut utxo_count, inputs.len(), outputs.len())?;
   2085             }
   2086             TransactionV2::Transfer {
   2087                 inputs, outputs, ..
   2088             } => {
   2089                 transfer_count += 1;
   2090                 apply_utxo_count_delta(&mut utxo_count, inputs.len(), outputs.len())?;
   2091             }
   2092             TransactionV2::Burn {
   2093                 inputs,
   2094                 change,
   2095                 amount,
   2096                 ..
   2097             } => {
   2098                 burn_count += 1;
   2099                 apply_utxo_count_delta(&mut utxo_count, inputs.len(), change.len())?;
   2100                 burned_amount = burned_amount
   2101                     .checked_add(amount)
   2102                     .context("block metric transaction v2 burns overflow")?;
   2103                 circulating_supply = circulating_supply
   2104                     .checked_sub(amount)
   2105                     .context("transaction v2 burn exceeds circulating supply")?;
   2106             }
   2107             TransactionV2::Mine { .. } => {
   2108                 mine_count += 1;
   2109             }
   2110         }
   2111     }
   2112     circulating_supply = circulating_supply
   2113         .checked_add(block.reward)
   2114         .context("block reward circulating supply overflows")?;
   2115     utxo_count = utxo_count
   2116         .checked_add(projected_reward_outputs(block).len() as u64)
   2117         .context("UTXO count overflows")?;
   2118     let total_burned_amount = previous
   2119         .map(|row| row.total_burned_amount)
   2120         .unwrap_or_default()
   2121         .checked_add(burned_amount)
   2122         .context("total burned metric overflows")?;
   2123 
   2124     Ok(BlockMetricRow {
   2125         height: block.height,
   2126         block_hash: block.hash.clone(),
   2127         timestamp_ms: block.timestamp_ms,
   2128         block_time_ms: previous.map(|row| block.timestamp_ms.saturating_sub(row.timestamp_ms)),
   2129         mine_difficulty_bits: metric_difficulty_for_block(snapshot, block, previous),
   2130         circulating_supply,
   2131         known_wallet_addresses: 0,
   2132         utxo_count,
   2133         transaction_count: block
   2134             .transactions
   2135             .len()
   2136             .saturating_add(block.transactions_v2.len()) as u64,
   2137         transfer_count,
   2138         burn_count,
   2139         mine_count,
   2140         burned_amount,
   2141         total_burned_amount,
   2142         fees_amount,
   2143         reward_amount: block.reward,
   2144         vdf_rounds: block.vdf_rounds,
   2145         finalizer_rank: block.finalizer_rank,
   2146     })
   2147 }
   2148 
   2149 fn apply_utxo_count_delta(count: &mut u64, spent: usize, created: usize) -> Result<()> {
   2150     *count = count
   2151         .checked_sub(spent as u64)
   2152         .context("transaction spends more UTXOs than exist")?
   2153         .checked_add(created as u64)
   2154         .context("UTXO count overflows")?;
   2155     Ok(())
   2156 }
   2157 
   2158 fn metric_difficulty_for_block(
   2159     snapshot: &ChainSnapshot,
   2160     block: &Block,
   2161     previous: Option<&BlockMetricRow>,
   2162 ) -> u32 {
   2163     let previous_difficulty = previous
   2164         .map(|row| row.mine_difficulty_bits)
   2165         .unwrap_or(snapshot.launch_profile.mine_difficulty_bits);
   2166     if block.height == 0 || block.height % MINE_RETARGET_WINDOW_BLOCKS != 0 {
   2167         return previous_difficulty;
   2168     }
   2169     let window_start = block.height + 1 - MINE_RETARGET_WINDOW_BLOCKS;
   2170     let mine_actions = snapshot.blocks[window_start as usize..=block.height as usize]
   2171         .iter()
   2172         .flat_map(|candidate| candidate.transactions.iter())
   2173         .filter(|transaction| matches!(transaction, Transaction::Mine { .. }))
   2174         .count() as u64;
   2175     retarget_mine_difficulty_bits(previous_difficulty, mine_actions)
   2176 }
   2177 
   2178 fn index_metric_addresses(transaction: &rusqlite::Transaction<'_>, block: &Block) -> Result<u64> {
   2179     let mut inserted = insert_metric_address(transaction, &block.miner, block.height)?;
   2180     for signature in &block.burn_bundle_section.signatures {
   2181         inserted = inserted
   2182             .checked_add(insert_metric_address(
   2183                 transaction,
   2184                 &signature.member,
   2185                 block.height,
   2186             )?)
   2187             .context("known metric address count overflows")?;
   2188     }
   2189     let mut addresses = BTreeSet::new();
   2190     for public_transaction in &block.transactions {
   2191         collect_transaction_addresses(public_transaction, &mut addresses);
   2192     }
   2193     for address in addresses {
   2194         inserted = inserted
   2195             .checked_add(insert_metric_address(transaction, &address, block.height)?)
   2196             .context("known metric address count overflows")?;
   2197     }
   2198     Ok(inserted)
   2199 }
   2200 
   2201 fn insert_metric_address(
   2202     transaction: &rusqlite::Transaction<'_>,
   2203     address: &str,
   2204     first_seen_height: u64,
   2205 ) -> Result<u64> {
   2206     let inserted = transaction
   2207         .execute(
   2208             "INSERT OR IGNORE INTO metric_known_addresses (address, first_seen_height) VALUES (?1, ?2)",
   2209             params![address, first_seen_height],
   2210         )
   2211         .with_context(|| format!("failed to index metric address {address}"))?;
   2212     Ok(inserted as u64)
   2213 }
   2214 
   2215 fn collect_transaction_addresses(transaction: &Transaction, addresses: &mut BTreeSet<String>) {
   2216     match transaction {
   2217         Transaction::Transfer {
   2218             inputs, outputs, ..
   2219         } => {
   2220             collect_input_addresses(inputs, addresses);
   2221             collect_output_addresses(outputs, addresses);
   2222         }
   2223         Transaction::Burn { inputs, change, .. } => {
   2224             collect_input_addresses(inputs, addresses);
   2225             collect_output_addresses(change, addresses);
   2226         }
   2227         Transaction::Mine { recipient, .. } => {
   2228             addresses.insert(recipient.clone());
   2229         }
   2230     }
   2231 }
   2232 
   2233 fn collect_input_addresses(inputs: &[TxInput], addresses: &mut BTreeSet<String>) {
   2234     for input in inputs {
   2235         addresses.insert(input.owner.clone());
   2236     }
   2237 }
   2238 
   2239 fn collect_output_addresses(outputs: &[TxOutput], addresses: &mut BTreeSet<String>) {
   2240     for output in outputs {
   2241         addresses.insert(output.address.clone());
   2242     }
   2243 }
   2244 
   2245 fn unix_ms() -> u64 {
   2246     SystemTime::now()
   2247         .duration_since(UNIX_EPOCH)
   2248         .unwrap_or_default()
   2249         .as_millis() as u64
   2250 }
   2251 
   2252 #[cfg(test)]
   2253 mod tests {
   2254     use std::collections::BTreeMap;
   2255 
   2256     use rusqlite::Connection;
   2257     use tempfile::tempdir;
   2258 
   2259     use crate::domain::{AddressNetwork, ChainSnapshot, GenesisBurn, Ledger, Wallet, hex_encode};
   2260 
   2261     use super::{
   2262         SqliteUiDataStore, replace_ui_wallet_transactions, replace_ui_wallet_transactions_v2,
   2263         wallet_transactions_from_snapshot, wallet_transactions_v2_from_snapshot,
   2264     };
   2265 
   2266     fn test_snapshot(seed: &str) -> ChainSnapshot {
   2267         let wallet = Wallet::from_seed(seed);
   2268         let mut allocations = BTreeMap::new();
   2269         allocations.insert(wallet.address().to_string(), 1);
   2270         Ledger::new_with_genesis_burns(allocations, vec![GenesisBurn::new(wallet.address(), 1)], 1)
   2271             .unwrap()
   2272             .snapshot()
   2273     }
   2274 
   2275     fn test_ledger(seed: &str) -> (Ledger, Wallet) {
   2276         let wallet = Wallet::from_seed(seed);
   2277         let mut allocations = BTreeMap::new();
   2278         allocations.insert(wallet.address().to_string(), 1_000);
   2279         let ledger = Ledger::new_with_genesis_burns(
   2280             allocations,
   2281             vec![GenesisBurn::new(wallet.address(), 10)],
   2282             1,
   2283         )
   2284         .unwrap();
   2285         (ledger, wallet)
   2286     }
   2287 
   2288     fn append_test_block(ledger: &mut Ledger, wallet: &Wallet, timestamp_ms: u64) {
   2289         let burn = ledger.build_burn(wallet, 1, 1).unwrap();
   2290         ledger.submit_transaction(burn).unwrap();
   2291         let block = ledger.mine_next_block(wallet, timestamp_ms).unwrap();
   2292         ledger.apply_locally_mined_block(block).unwrap();
   2293     }
   2294 
   2295     #[test]
   2296     fn wallet_projection_includes_block_rewards() {
   2297         let dir = tempdir().unwrap();
   2298         let store = SqliteUiDataStore::open(dir.path().join("ui_data.sqlite3")).unwrap();
   2299         let (mut ledger, wallet) = test_ledger("wallet-reward-history");
   2300         append_test_block(&mut ledger, &wallet, 1_234);
   2301 
   2302         store.project_snapshot(&ledger.snapshot(), false).unwrap();
   2303         let (rows, total) = store
   2304             .load_wallet_transactions(wallet.address(), &["reward"], 0, 10)
   2305             .unwrap();
   2306 
   2307         assert_eq!(total, 1);
   2308         assert_eq!(rows[0].kind, "reward");
   2309         assert_eq!(rows[0].block_height, 1);
   2310         assert_eq!(rows[0].timestamp_ms, 1_234);
   2311         assert_eq!(rows[0].transaction.to(), Some(wallet.address()));
   2312         assert!(rows[0].transaction.amount() > 0);
   2313     }
   2314 
   2315     #[test]
   2316     fn wallet_projection_queries_hybrid_reward_addresses() {
   2317         let dir = tempdir().unwrap();
   2318         let store = SqliteUiDataStore::open(dir.path().join("ui_data.sqlite3")).unwrap();
   2319         let (mut ledger, wallet) = test_ledger("wallet-hybrid-reward-history");
   2320         append_test_block(&mut ledger, &wallet, 1_234);
   2321         let hybrid_address = wallet.hybrid_address(AddressNetwork::Mainnet);
   2322         let mut snapshot = ledger.snapshot();
   2323         snapshot.blocks[1].reward_address = Some(hybrid_address.clone());
   2324 
   2325         let projection = wallet_transactions_from_snapshot(&snapshot);
   2326         store
   2327             .with_connection_mut(|connection| {
   2328                 let transaction = connection.transaction()?;
   2329                 replace_ui_wallet_transactions(&transaction, &projection)?;
   2330                 transaction.commit()?;
   2331                 Ok(())
   2332             })
   2333             .unwrap();
   2334         let (rows, total) = store
   2335             .load_wallet_transactions_for_addresses(
   2336                 &[wallet.address().to_string(), hybrid_address.clone()],
   2337                 &["reward"],
   2338                 0,
   2339                 10,
   2340             )
   2341             .unwrap();
   2342 
   2343         assert_eq!(total, 1);
   2344         assert_eq!(rows[0].transaction.to(), Some(hybrid_address.as_str()));
   2345     }
   2346 
   2347     #[test]
   2348     fn confirmed_v2_wallet_projection_is_replaced_on_reorg() {
   2349         let dir = tempdir().unwrap();
   2350         let store = SqliteUiDataStore::open(dir.path().join("ui_data.sqlite3")).unwrap();
   2351         let wallet = Wallet::from_seed("confirmed-v2-wallet-history");
   2352         let ledger = Ledger::new(BTreeMap::from([(wallet.address().to_string(), 100_000)]), 1);
   2353         let domain = ledger.transaction_v2_domain().unwrap();
   2354         let transaction = ledger.build_v2_migration_batch(&wallet, 100).unwrap();
   2355         let mut snapshot = ledger.snapshot();
   2356         snapshot.blocks[0].transactions_v2 = vec![hex_encode(transaction.encode(&domain).unwrap())];
   2357         let rows = wallet_transactions_v2_from_snapshot(&snapshot, &domain);
   2358         let network = AddressNetwork::from_profile_id(&snapshot.launch_profile.profile_id);
   2359         let hybrid_address = wallet.hybrid_address(network);
   2360 
   2361         assert!(rows.iter().any(|(address, _)| address == &hybrid_address));
   2362         store
   2363             .with_connection_mut(|connection| {
   2364                 let transaction = connection.transaction()?;
   2365                 replace_ui_wallet_transactions_v2(&transaction, &rows)?;
   2366                 transaction.commit()?;
   2367                 Ok(())
   2368             })
   2369             .unwrap();
   2370         let (projected, total) = store
   2371             .load_wallet_transactions_v2(
   2372                 std::slice::from_ref(&hybrid_address),
   2373                 &["transfer"],
   2374                 0,
   2375                 10,
   2376             )
   2377             .unwrap();
   2378         assert_eq!(total, 1);
   2379         assert_eq!(projected[0].transaction_id.len(), 64);
   2380         assert_eq!(projected[0].block_height, 0);
   2381 
   2382         store
   2383             .with_connection_mut(|connection| {
   2384                 let transaction = connection.transaction()?;
   2385                 replace_ui_wallet_transactions_v2(&transaction, &[])?;
   2386                 transaction.commit()?;
   2387                 Ok(())
   2388             })
   2389             .unwrap();
   2390         let (_, total) = store
   2391             .load_wallet_transactions_v2(&[hybrid_address], &["transfer"], 0, 10)
   2392             .unwrap();
   2393         assert_eq!(total, 0);
   2394     }
   2395 
   2396     #[test]
   2397     fn opening_legacy_ui_schema_rebuilds_the_derived_cache() {
   2398         let dir = tempdir().unwrap();
   2399         let path = dir.path().join("ui_data.sqlite3");
   2400         let connection = Connection::open(&path).unwrap();
   2401         connection
   2402             .execute_batch(
   2403                 r#"
   2404 CREATE TABLE ui_wallet_transactions (
   2405     address TEXT NOT NULL,
   2406     sort_key INTEGER NOT NULL,
   2407     kind TEXT NOT NULL,
   2408     signature TEXT NOT NULL,
   2409     block_height INTEGER NOT NULL,
   2410     timestamp_ms INTEGER NOT NULL,
   2411     block_finalizer TEXT NOT NULL,
   2412     blinded INTEGER NOT NULL,
   2413     transaction_json BLOB NOT NULL,
   2414     PRIMARY KEY (address, signature)
   2415 );
   2416 "#,
   2417             )
   2418             .unwrap();
   2419         drop(connection);
   2420 
   2421         let store = SqliteUiDataStore::open(&path).unwrap();
   2422         let connection = Connection::open(&path).unwrap();
   2423         let columns = connection
   2424             .prepare("PRAGMA table_info(ui_wallet_transactions)")
   2425             .unwrap()
   2426             .query_map([], |row| row.get::<_, String>(1))
   2427             .unwrap()
   2428             .collect::<rusqlite::Result<Vec<_>>>()
   2429             .unwrap();
   2430         let schema_version = connection
   2431             .pragma_query_value(None, "user_version", |row| row.get::<_, u32>(0))
   2432             .unwrap();
   2433         assert!(!columns.iter().any(|column| column == "blinded"));
   2434         assert_eq!(schema_version, super::UI_DATA_SCHEMA_VERSION);
   2435         drop(connection);
   2436 
   2437         let seed = "legacy-ui-schema";
   2438         let snapshot = test_snapshot(seed);
   2439         let tip = snapshot.blocks.last().unwrap().hash.clone();
   2440         let wallet = Wallet::from_seed(seed);
   2441         store.project_snapshot(&snapshot, true).unwrap();
   2442         let (_, total) = store
   2443             .load_wallet_transactions(wallet.address(), &["burn"], 0, 10)
   2444             .unwrap();
   2445         assert!(total > 0);
   2446 
   2447         drop(store);
   2448         let reopened = SqliteUiDataStore::open(&path).unwrap();
   2449         assert!(reopened.is_projected_to(&tip).unwrap());
   2450         assert!(reopened.metrics_are_projected_to(&tip).unwrap());
   2451     }
   2452 
   2453     #[test]
   2454     fn failed_ui_projection_keeps_last_committed_projection() {
   2455         let dir = tempdir().unwrap();
   2456         let store = SqliteUiDataStore::open(dir.path().join("ui_data.sqlite3")).unwrap();
   2457         let snapshot = test_snapshot("ui-data-rollback");
   2458         let tip = snapshot.blocks.last().unwrap().hash.clone();
   2459         store.project_snapshot(&snapshot, true).unwrap();
   2460         assert!(store.is_projected_to(&tip).unwrap());
   2461         assert!(store.metrics_are_projected_to(&tip).unwrap());
   2462         assert!(!store.load_metrics().unwrap().is_empty());
   2463 
   2464         let mut invalid = snapshot.clone();
   2465         invalid.blocks.clear();
   2466         let error = store.project_snapshot(&invalid, true).unwrap_err();
   2467 
   2468         assert!(
   2469             format!("{error:#}").contains("chain snapshot is empty"),
   2470             "{error:#}"
   2471         );
   2472         assert!(store.is_projected_to(&tip).unwrap());
   2473         assert!(store.metrics_are_projected_to(&tip).unwrap());
   2474         assert!(!store.load_metrics().unwrap().is_empty());
   2475     }
   2476 
   2477     #[test]
   2478     fn metrics_projection_appends_without_rewriting_the_consistent_prefix() {
   2479         let dir = tempdir().unwrap();
   2480         let store = SqliteUiDataStore::open(dir.path().join("ui_data.sqlite3")).unwrap();
   2481         let (mut ledger, wallet) = test_ledger("incremental-metrics");
   2482         append_test_block(&mut ledger, &wallet, 1_000);
   2483         append_test_block(&mut ledger, &wallet, 2_000);
   2484         store.project_snapshot(&ledger.snapshot(), true).unwrap();
   2485         let prefix = store.load_metrics().unwrap();
   2486 
   2487         let connection = Connection::open(store.path()).unwrap();
   2488         connection
   2489             .execute_batch(
   2490                 r#"
   2491 CREATE TRIGGER protect_metric_prefix
   2492 BEFORE DELETE ON block_metrics
   2493 WHEN OLD.height <= 2
   2494 BEGIN
   2495     SELECT RAISE(FAIL, 'consistent metric prefix was rewritten');
   2496 END;
   2497 "#,
   2498             )
   2499             .unwrap();
   2500         drop(connection);
   2501 
   2502         append_test_block(&mut ledger, &wallet, 3_000);
   2503         let snapshot = ledger.snapshot();
   2504         store.project_snapshot(&snapshot, true).unwrap();
   2505         let metrics = store.load_metrics().unwrap();
   2506 
   2507         assert_eq!(&metrics[..prefix.len()], prefix.as_slice());
   2508         assert_eq!(metrics.len(), snapshot.blocks.len());
   2509         assert_eq!(metrics.last().unwrap().block_hash, ledger.tip_hash());
   2510         assert_eq!(
   2511             metrics.last().unwrap().utxo_count,
   2512             ledger.all_utxos().len() as u64
   2513         );
   2514         assert_eq!(
   2515             metrics.last().unwrap().circulating_supply,
   2516             ledger
   2517                 .all_utxos()
   2518                 .iter()
   2519                 .map(|(_, output)| output.amount)
   2520                 .sum::<u64>()
   2521         );
   2522         assert_eq!(
   2523             metrics.last().unwrap().mine_difficulty_bits,
   2524             ledger.mine_difficulty_bits_at_height(ledger.height())
   2525         );
   2526     }
   2527 
   2528     #[test]
   2529     fn metrics_projection_replaces_only_the_reorged_suffix() {
   2530         let dir = tempdir().unwrap();
   2531         let store = SqliteUiDataStore::open(dir.path().join("ui_data.sqlite3")).unwrap();
   2532         let (mut base, wallet) = test_ledger("reorg-metrics");
   2533         append_test_block(&mut base, &wallet, 1_000);
   2534         let mut first_branch = base.clone();
   2535         let mut second_branch = base;
   2536         append_test_block(&mut first_branch, &wallet, 2_000);
   2537         append_test_block(&mut second_branch, &wallet, 3_000);
   2538         store
   2539             .project_snapshot(&first_branch.snapshot(), true)
   2540             .unwrap();
   2541 
   2542         let connection = Connection::open(store.path()).unwrap();
   2543         connection
   2544             .execute_batch(
   2545                 r#"
   2546 CREATE TRIGGER protect_metric_common_ancestor
   2547 BEFORE DELETE ON block_metrics
   2548 WHEN OLD.height <= 1
   2549 BEGIN
   2550     SELECT RAISE(FAIL, 'metric common ancestor was rewritten');
   2551 END;
   2552 "#,
   2553             )
   2554             .unwrap();
   2555         drop(connection);
   2556 
   2557         store
   2558             .project_snapshot(&second_branch.snapshot(), true)
   2559             .unwrap();
   2560         let metrics = store.load_metrics().unwrap();
   2561 
   2562         assert_eq!(metrics.len(), second_branch.chain().len());
   2563         assert_eq!(metrics.last().unwrap().block_hash, second_branch.tip_hash());
   2564         assert!(
   2565             store
   2566                 .metrics_are_projected_to(second_branch.tip_hash())
   2567                 .unwrap()
   2568         );
   2569         assert!(
   2570             !store
   2571                 .metrics_are_projected_to(first_branch.tip_hash())
   2572                 .unwrap()
   2573         );
   2574     }
   2575 
   2576     #[test]
   2577     fn ui_projection_appends_without_rewriting_the_consistent_history() {
   2578         let dir = tempdir().unwrap();
   2579         let store = SqliteUiDataStore::open(dir.path().join("ui_data.sqlite3")).unwrap();
   2580         let (mut ledger, wallet) = test_ledger("incremental-ui-data");
   2581         append_test_block(&mut ledger, &wallet, 1_000);
   2582         store.project_snapshot(&ledger.snapshot(), true).unwrap();
   2583         let first_tip = ledger.tip_hash().to_string();
   2584 
   2585         let connection = Connection::open(store.path()).unwrap();
   2586         connection
   2587             .execute_batch(
   2588                 r#"
   2589 CREATE TRIGGER protect_ui_output_prefix
   2590 BEFORE DELETE ON ui_output_index
   2591 BEGIN
   2592     SELECT RAISE(FAIL, 'consistent UI output history was rewritten');
   2593 END;
   2594 CREATE TRIGGER protect_ui_wallet_prefix
   2595 BEFORE DELETE ON ui_wallet_transactions
   2596 WHEN OLD.block_height <= 1
   2597 BEGIN
   2598     SELECT RAISE(FAIL, 'consistent UI wallet history was rewritten');
   2599 END;
   2600 CREATE TRIGGER protect_ui_rank_prefix
   2601 BEFORE DELETE ON ui_burn_leader_rank_blocks
   2602 BEGIN
   2603     SELECT RAISE(FAIL, 'consistent UI rank history was rewritten');
   2604 END;
   2605 "#,
   2606             )
   2607             .unwrap();
   2608         drop(connection);
   2609 
   2610         append_test_block(&mut ledger, &wallet, 2_000);
   2611         store.project_snapshot(&ledger.snapshot(), true).unwrap();
   2612 
   2613         assert!(store.is_projected_to(ledger.tip_hash()).unwrap());
   2614         let connection = Connection::open(store.path()).unwrap();
   2615         let first_reward_is_retained = connection
   2616             .query_row(
   2617                 "SELECT EXISTS(SELECT 1 FROM ui_output_index WHERE txid = ?1)",
   2618                 [&first_tip],
   2619                 |row| row.get::<_, bool>(0),
   2620             )
   2621             .unwrap();
   2622         let latest_reward_is_present = connection
   2623             .query_row(
   2624                 "SELECT EXISTS(SELECT 1 FROM ui_output_index WHERE txid = ?1)",
   2625                 [ledger.tip_hash()],
   2626                 |row| row.get::<_, bool>(0),
   2627             )
   2628             .unwrap();
   2629         assert!(first_reward_is_retained);
   2630         assert!(latest_reward_is_present);
   2631     }
   2632 
   2633     #[test]
   2634     fn ui_projection_falls_back_to_full_rebuild_for_a_reorg() {
   2635         let dir = tempdir().unwrap();
   2636         let store = SqliteUiDataStore::open(dir.path().join("ui_data.sqlite3")).unwrap();
   2637         let (mut base, wallet) = test_ledger("reorg-ui-data");
   2638         append_test_block(&mut base, &wallet, 1_000);
   2639         let mut first_branch = base.clone();
   2640         let mut second_branch = base;
   2641         append_test_block(&mut first_branch, &wallet, 2_000);
   2642         append_test_block(&mut second_branch, &wallet, 3_000);
   2643         let old_tip = first_branch.tip_hash().to_string();
   2644 
   2645         store
   2646             .project_snapshot(&first_branch.snapshot(), true)
   2647             .unwrap();
   2648         store
   2649             .project_snapshot(&second_branch.snapshot(), true)
   2650             .unwrap();
   2651 
   2652         assert!(store.is_projected_to(second_branch.tip_hash()).unwrap());
   2653         let connection = Connection::open(store.path()).unwrap();
   2654         let old_branch_output_remains = connection
   2655             .query_row(
   2656                 "SELECT EXISTS(SELECT 1 FROM ui_output_index WHERE txid = ?1)",
   2657                 [&old_tip],
   2658                 |row| row.get::<_, bool>(0),
   2659             )
   2660             .unwrap();
   2661         let new_branch_output_is_present = connection
   2662             .query_row(
   2663                 "SELECT EXISTS(SELECT 1 FROM ui_output_index WHERE txid = ?1)",
   2664                 [second_branch.tip_hash()],
   2665                 |row| row.get::<_, bool>(0),
   2666             )
   2667             .unwrap();
   2668         assert!(!old_branch_output_remains);
   2669         assert!(new_branch_output_is_present);
   2670     }
   2671 
   2672     #[test]
   2673     fn incremental_metrics_match_the_ledger_across_a_difficulty_retarget() {
   2674         let dir = tempdir().unwrap();
   2675         let store = SqliteUiDataStore::open(dir.path().join("ui_data.sqlite3")).unwrap();
   2676         let (mut ledger, wallet) = test_ledger("retarget-metrics");
   2677 
   2678         store.project_snapshot(&ledger.snapshot(), true).unwrap();
   2679         for height in 1..=super::MINE_RETARGET_WINDOW_BLOCKS + 2 {
   2680             append_test_block(&mut ledger, &wallet, height * 1_000);
   2681             store.project_snapshot(&ledger.snapshot(), true).unwrap();
   2682             let latest = store.load_metrics().unwrap().pop().unwrap();
   2683 
   2684             assert_eq!(latest.height, height);
   2685             assert_eq!(
   2686                 latest.mine_difficulty_bits,
   2687                 ledger.mine_difficulty_bits_at_height(height)
   2688             );
   2689             assert_eq!(
   2690                 latest.circulating_supply,
   2691                 ledger
   2692                     .all_utxos()
   2693                     .iter()
   2694                     .map(|(_, output)| output.amount)
   2695                     .sum::<u64>()
   2696             );
   2697         }
   2698     }
   2699 
   2700     #[test]
   2701     fn leaderboards_are_served_from_their_materialized_projection() {
   2702         let dir = tempdir().unwrap();
   2703         let store = SqliteUiDataStore::open(dir.path().join("ui_data.sqlite3")).unwrap();
   2704         let (mut ledger, wallet) = test_ledger("materialized-leaderboards");
   2705         append_test_block(&mut ledger, &wallet, 1_000);
   2706         store.project_snapshot(&ledger.snapshot(), true).unwrap();
   2707         let expected = store.load_leaderboards(10).unwrap();
   2708         assert!(!expected.balances.is_empty());
   2709         assert!(!expected.burners.is_empty());
   2710 
   2711         let connection = Connection::open(store.path()).unwrap();
   2712         connection.execute("DELETE FROM ui_utxos", []).unwrap();
   2713         connection
   2714             .execute("DELETE FROM ui_wallet_transactions", [])
   2715             .unwrap();
   2716 
   2717         assert_eq!(store.load_leaderboards(10).unwrap(), expected);
   2718     }
   2719 }