iuna

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

ui_data_store.rs (54433B)


      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},
     15     domain::{
     16         Amount, BLINDED_COMMITTER_FEE_BPS, BLINDED_FEE_BPS_DENOMINATOR,
     17         BLINDED_REVEAL_BUNDLE_SIGNER_FEE_BPS, BlindedTransaction, Block, BurnLeaderRank,
     18         ChainSnapshot, Ledger, MINE_REWARD, OutPoint, REVEAL_COMMITTEE_SIZE,
     19         RevealedBlindedTransaction, Transaction, TxInput, TxOutput, blinded_reveal_finalizer_fee,
     20         hex_hash, reveal_committee_slot_count, revealed_blinded_transactions,
     21     },
     22 };
     23 
     24 const SCHEMA: &str = r#"
     25 CREATE TABLE IF NOT EXISTS block_metrics (
     26     height INTEGER PRIMARY KEY,
     27     block_hash TEXT NOT NULL,
     28     timestamp_ms INTEGER NOT NULL,
     29     block_time_ms INTEGER,
     30     mine_difficulty_bits INTEGER NOT NULL,
     31     circulating_supply INTEGER NOT NULL,
     32     known_wallet_addresses 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 ui_cache_meta (
     46     id INTEGER PRIMARY KEY CHECK (id = 1),
     47     schema_version INTEGER NOT NULL,
     48     tip_hash TEXT NOT NULL,
     49     updated_at_ms INTEGER NOT NULL
     50 );
     51 
     52 CREATE TABLE IF NOT EXISTS ui_output_index (
     53     txid TEXT NOT NULL,
     54     output_index INTEGER NOT NULL,
     55     address TEXT NOT NULL,
     56     amount INTEGER NOT NULL,
     57     PRIMARY KEY (txid, output_index)
     58 );
     59 
     60 CREATE TABLE IF NOT EXISTS ui_utxos (
     61     txid TEXT NOT NULL,
     62     output_index INTEGER NOT NULL,
     63     address TEXT NOT NULL,
     64     amount INTEGER NOT NULL,
     65     PRIMARY KEY (txid, output_index)
     66 );
     67 
     68 CREATE INDEX IF NOT EXISTS idx_ui_utxos_address
     69 ON ui_utxos(address);
     70 
     71 CREATE TABLE IF NOT EXISTS ui_wallet_transactions (
     72     address TEXT NOT NULL,
     73     sort_key INTEGER NOT NULL,
     74     kind TEXT NOT NULL,
     75     signature TEXT NOT NULL,
     76     block_height INTEGER NOT NULL,
     77     timestamp_ms INTEGER NOT NULL,
     78     block_finalizer TEXT NOT NULL,
     79     blinded INTEGER NOT NULL,
     80     transaction_json BLOB NOT NULL,
     81     PRIMARY KEY (address, signature)
     82 );
     83 
     84 CREATE INDEX IF NOT EXISTS idx_ui_wallet_transactions_address_kind_sort
     85 ON ui_wallet_transactions(address, kind, sort_key DESC);
     86 
     87 CREATE INDEX IF NOT EXISTS idx_ui_wallet_transactions_address_sort
     88 ON ui_wallet_transactions(address, sort_key DESC);
     89 
     90 CREATE TABLE IF NOT EXISTS ui_revealed_transactions (
     91     height INTEGER NOT NULL,
     92     commitment TEXT PRIMARY KEY,
     93     included_by TEXT NOT NULL,
     94     transaction_json BLOB NOT NULL
     95 );
     96 
     97 CREATE INDEX IF NOT EXISTS idx_ui_revealed_transactions_height
     98 ON ui_revealed_transactions(height);
     99 
    100 CREATE TABLE IF NOT EXISTS ui_burn_leader_ranks (
    101     block_hash TEXT NOT NULL,
    102     rank INTEGER NOT NULL,
    103     ticket_id TEXT NOT NULL,
    104     owner TEXT NOT NULL,
    105     amount INTEGER NOT NULL,
    106     eligible_from_height INTEGER NOT NULL,
    107     eligible_until_height INTEGER NOT NULL,
    108     PRIMARY KEY (block_hash, rank)
    109 );
    110 
    111 CREATE TABLE IF NOT EXISTS ui_burn_leader_rank_blocks (
    112     block_hash TEXT PRIMARY KEY
    113 );
    114 "#;
    115 
    116 const UI_CACHE_SCHEMA_VERSION: u32 = 1;
    117 
    118 #[derive(Clone, Debug, Eq, PartialEq, Serialize)]
    119 #[serde(rename_all = "camelCase")]
    120 pub struct BlockMetricRow {
    121     pub height: u64,
    122     pub block_hash: String,
    123     pub timestamp_ms: u64,
    124     pub block_time_ms: Option<u64>,
    125     pub mine_difficulty_bits: u32,
    126     pub circulating_supply: Amount,
    127     pub known_wallet_addresses: u64,
    128     pub transaction_count: u64,
    129     pub transfer_count: u64,
    130     pub burn_count: u64,
    131     pub mine_count: u64,
    132     pub burned_amount: Amount,
    133     pub total_burned_amount: Amount,
    134     pub fees_amount: Amount,
    135     pub reward_amount: Amount,
    136     pub vdf_rounds: u64,
    137     pub finalizer_rank: u32,
    138 }
    139 
    140 #[derive(Clone, Debug, Eq, PartialEq)]
    141 pub struct WalletTransactionProjection {
    142     pub sort_key: u64,
    143     pub kind: String,
    144     pub block_height: u64,
    145     pub timestamp_ms: u64,
    146     pub block_finalizer: String,
    147     pub blinded: bool,
    148     pub transaction: Transaction,
    149 }
    150 
    151 #[derive(Clone, Debug)]
    152 pub struct SqliteUiDataStore {
    153     path: PathBuf,
    154 }
    155 
    156 impl SqliteUiDataStore {
    157     pub fn open(path: impl AsRef<Path>) -> Result<Self> {
    158         let path = path.as_ref().to_path_buf();
    159         if let Some(parent) = path.parent() {
    160             fs::create_dir_all(parent).with_context(|| {
    161                 format!(
    162                     "failed to create chain database directory {}",
    163                     parent.display()
    164                 )
    165             })?;
    166         }
    167 
    168         let store = Self { path };
    169         store.with_connection_mut(|connection| {
    170             connection
    171                 .execute_batch(SCHEMA)
    172                 .context("failed to initialize UI data database schema")?;
    173             ensure_block_metrics_column(
    174                 connection,
    175                 "known_wallet_addresses",
    176                 "INTEGER NOT NULL DEFAULT 0",
    177             )?;
    178             Ok(())
    179         })?;
    180         Ok(store)
    181     }
    182 
    183     pub fn path(&self) -> &Path {
    184         &self.path
    185     }
    186 
    187     pub(crate) fn load_ui_chain_index(&self, tip_hash: &str) -> Result<Option<UiChainIndex>> {
    188         self.with_connection(|connection| {
    189             let meta = connection
    190                 .query_row(
    191                     "SELECT schema_version, tip_hash FROM ui_cache_meta WHERE id = 1",
    192                     [],
    193                     |row| Ok((row.get::<_, u32>(0)?, row.get::<_, String>(1)?)),
    194                 )
    195                 .optional()
    196                 .context("failed to load UI chain index metadata")?;
    197             let Some((schema_version, stored_tip_hash)) = meta else {
    198                 return Ok(None);
    199             };
    200             if schema_version != UI_CACHE_SCHEMA_VERSION || stored_tip_hash != tip_hash {
    201                 return Ok(None);
    202             }
    203 
    204             Ok(Some(UiChainIndex {
    205                 tip_hash: Some(stored_tip_hash),
    206                 outputs: load_ui_output_index(connection)?,
    207                 revealed_by_height: load_ui_revealed_transactions(connection)?,
    208                 burn_leader_ranks_by_hash: load_ui_burn_leader_ranks(connection)?,
    209             }))
    210         })
    211     }
    212 
    213     pub(crate) fn is_projected_to(&self, tip_hash: &str) -> Result<bool> {
    214         self.with_connection(|connection| {
    215             let projected = connection
    216                 .query_row(
    217                     "SELECT schema_version, tip_hash FROM ui_cache_meta WHERE id = 1",
    218                     [],
    219                     |row| Ok((row.get::<_, u32>(0)?, row.get::<_, String>(1)?)),
    220                 )
    221                 .optional()
    222                 .context("failed to load UI data projection metadata")?
    223                 .is_some_and(|(schema_version, stored_tip_hash)| {
    224                     schema_version == UI_CACHE_SCHEMA_VERSION && stored_tip_hash == tip_hash
    225                 });
    226             Ok(projected)
    227         })
    228     }
    229 
    230     pub fn project_snapshot(&self, snapshot: &ChainSnapshot, keep_metrics: bool) -> Result<()> {
    231         let updated_at_ms = unix_ms();
    232         let ui_index = build_ui_chain_index(snapshot);
    233         let utxos = Ledger::from_persisted_snapshot(snapshot.clone())
    234             .context("failed to rebuild ledger for UI UTXO projection")?
    235             .all_utxos();
    236         let wallet_transactions = wallet_transactions_from_snapshot(snapshot);
    237         let metrics = if keep_metrics {
    238             Some(metrics_from_snapshot(snapshot)?)
    239         } else {
    240             None
    241         };
    242 
    243         self.with_connection_mut(|connection| {
    244             let transaction = connection
    245                 .transaction()
    246                 .context("failed to start UI data projection transaction")?;
    247             match metrics {
    248                 Some(metrics) => replace_metrics(&transaction, &metrics)?,
    249                 None => clear_metrics_in_transaction(&transaction)?,
    250             }
    251             replace_ui_chain_index(&transaction, &ui_index, updated_at_ms)?;
    252             replace_ui_utxos(&transaction, &utxos)?;
    253             replace_ui_wallet_transactions(&transaction, &wallet_transactions)?;
    254             transaction
    255                 .commit()
    256                 .context("failed to commit UI data projection transaction")?;
    257             Ok(())
    258         })
    259     }
    260 
    261     pub fn replace_metrics_for_snapshot(&self, snapshot: &ChainSnapshot) -> Result<()> {
    262         let metrics = metrics_from_snapshot(snapshot)?;
    263         self.with_connection_mut(|connection| {
    264             let transaction = connection
    265                 .transaction()
    266                 .context("failed to start metrics transaction")?;
    267             replace_metrics(&transaction, &metrics)?;
    268             transaction
    269                 .commit()
    270                 .context("failed to commit metrics transaction")?;
    271             Ok(())
    272         })
    273     }
    274 
    275     pub fn clear_metrics(&self) -> Result<()> {
    276         self.with_connection_mut(|connection| {
    277             connection
    278                 .execute("DELETE FROM block_metrics", [])
    279                 .context("failed to delete block metrics")?;
    280             Ok(())
    281         })
    282     }
    283 
    284     pub fn clear_all(&self) -> Result<()> {
    285         self.with_connection_mut(|connection| {
    286             let transaction = connection
    287                 .transaction()
    288                 .context("failed to start UI data reset transaction")?;
    289             clear_metrics_in_transaction(&transaction)?;
    290             clear_ui_chain_index_in_transaction(&transaction)?;
    291             transaction
    292                 .commit()
    293                 .context("failed to commit UI data reset transaction")?;
    294             Ok(())
    295         })
    296     }
    297 
    298     pub fn load_metrics(&self) -> Result<Vec<BlockMetricRow>> {
    299         self.with_connection(|connection| {
    300             let mut statement = connection
    301                 .prepare(
    302                     r#"
    303 SELECT height, block_hash, timestamp_ms, block_time_ms, mine_difficulty_bits,
    304        circulating_supply, known_wallet_addresses, transaction_count, transfer_count, burn_count,
    305        mine_count, burned_amount, total_burned_amount, fees_amount, reward_amount,
    306        vdf_rounds, finalizer_rank
    307 FROM block_metrics
    308 ORDER BY height ASC
    309 "#,
    310                 )
    311                 .context("failed to prepare block metrics query")?;
    312             let rows = statement
    313                 .query_map([], |row| {
    314                     Ok(BlockMetricRow {
    315                         height: row.get(0)?,
    316                         block_hash: row.get(1)?,
    317                         timestamp_ms: row.get(2)?,
    318                         block_time_ms: row.get(3)?,
    319                         mine_difficulty_bits: row.get(4)?,
    320                         circulating_supply: row.get(5)?,
    321                         known_wallet_addresses: row.get(6)?,
    322                         transaction_count: row.get(7)?,
    323                         transfer_count: row.get(8)?,
    324                         burn_count: row.get(9)?,
    325                         mine_count: row.get(10)?,
    326                         burned_amount: row.get(11)?,
    327                         total_burned_amount: row.get(12)?,
    328                         fees_amount: row.get(13)?,
    329                         reward_amount: row.get(14)?,
    330                         vdf_rounds: row.get(15)?,
    331                         finalizer_rank: row.get(16)?,
    332                     })
    333                 })
    334                 .context("failed to load block metrics")?;
    335             rows.collect::<std::result::Result<Vec<_>, _>>()
    336                 .context("failed to read block metrics rows")
    337         })
    338     }
    339 
    340     pub fn load_recent_metrics(&self, limit: usize) -> Result<Vec<BlockMetricRow>> {
    341         self.with_connection(|connection| {
    342             let mut statement = connection
    343                 .prepare(
    344                     r#"
    345 SELECT height, block_hash, timestamp_ms, block_time_ms, mine_difficulty_bits,
    346        circulating_supply, known_wallet_addresses, transaction_count, transfer_count, burn_count,
    347        mine_count, burned_amount, total_burned_amount, fees_amount, reward_amount,
    348        vdf_rounds, finalizer_rank
    349 FROM block_metrics
    350 ORDER BY height DESC
    351 LIMIT ?1
    352 "#,
    353                 )
    354                 .context("failed to prepare recent block metrics query")?;
    355             let rows = statement
    356                 .query_map([limit as u64], |row| {
    357                     Ok(BlockMetricRow {
    358                         height: row.get(0)?,
    359                         block_hash: row.get(1)?,
    360                         timestamp_ms: row.get(2)?,
    361                         block_time_ms: row.get(3)?,
    362                         mine_difficulty_bits: row.get(4)?,
    363                         circulating_supply: row.get(5)?,
    364                         known_wallet_addresses: row.get(6)?,
    365                         transaction_count: row.get(7)?,
    366                         transfer_count: row.get(8)?,
    367                         burn_count: row.get(9)?,
    368                         mine_count: row.get(10)?,
    369                         burned_amount: row.get(11)?,
    370                         total_burned_amount: row.get(12)?,
    371                         fees_amount: row.get(13)?,
    372                         reward_amount: row.get(14)?,
    373                         vdf_rounds: row.get(15)?,
    374                         finalizer_rank: row.get(16)?,
    375                     })
    376                 })
    377                 .context("failed to load recent block metrics")?;
    378             let mut rows = rows
    379                 .collect::<std::result::Result<Vec<_>, _>>()
    380                 .context("failed to read recent block metrics rows")?;
    381             rows.reverse();
    382             Ok(rows)
    383         })
    384     }
    385 
    386     pub fn load_wallet_utxos(&self, address: &str) -> Result<Vec<(OutPoint, TxOutput)>> {
    387         self.with_connection(|connection| load_wallet_utxos(connection, address))
    388     }
    389 
    390     pub fn load_outputs(
    391         &self,
    392         outpoints: &BTreeSet<OutPoint>,
    393     ) -> Result<BTreeMap<OutPoint, TxOutput>> {
    394         self.with_connection(|connection| load_outputs(connection, outpoints))
    395     }
    396 
    397     pub fn load_wallet_transactions(
    398         &self,
    399         address: &str,
    400         kinds: &[&str],
    401         offset: usize,
    402         limit: usize,
    403     ) -> Result<(Vec<WalletTransactionProjection>, usize)> {
    404         self.with_connection(|connection| {
    405             load_wallet_transactions(connection, address, kinds, offset, limit)
    406         })
    407     }
    408 
    409     fn with_connection<T>(&self, work: impl FnOnce(&Connection) -> Result<T>) -> Result<T> {
    410         let connection = self.open_connection()?;
    411         connection
    412             .execute_batch(
    413                 r#"
    414 PRAGMA busy_timeout = 5000;
    415 PRAGMA synchronous = NORMAL;
    416 "#,
    417             )
    418             .context("failed to configure UI data database connection")?;
    419         work(&connection)
    420     }
    421 
    422     fn with_connection_mut<T>(&self, work: impl FnOnce(&mut Connection) -> Result<T>) -> Result<T> {
    423         let mut connection = self.open_connection()?;
    424         connection
    425             .execute_batch(
    426                 r#"
    427 PRAGMA journal_mode = WAL;
    428 PRAGMA busy_timeout = 5000;
    429 PRAGMA synchronous = NORMAL;
    430 "#,
    431             )
    432             .context("failed to configure UI data database connection")?;
    433         work(&mut connection)
    434     }
    435 
    436     fn open_connection(&self) -> Result<Connection> {
    437         Connection::open(&self.path)
    438             .with_context(|| format!("failed to open UI data database {}", self.path.display()))
    439     }
    440 }
    441 
    442 fn replace_metrics(
    443     transaction: &rusqlite::Transaction<'_>,
    444     metrics: &[BlockMetricRow],
    445 ) -> Result<()> {
    446     clear_metrics_in_transaction(transaction)?;
    447     for metric in metrics {
    448         transaction
    449             .execute(
    450                 r#"
    451 INSERT INTO block_metrics (
    452     height, block_hash, timestamp_ms, block_time_ms, mine_difficulty_bits,
    453     circulating_supply, known_wallet_addresses, transaction_count, transfer_count, burn_count,
    454     mine_count, burned_amount, total_burned_amount, fees_amount, reward_amount, vdf_rounds,
    455     finalizer_rank
    456 ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17)
    457 "#,
    458                 params![
    459                     metric.height,
    460                     metric.block_hash,
    461                     metric.timestamp_ms,
    462                     metric.block_time_ms,
    463                     metric.mine_difficulty_bits,
    464                     metric.circulating_supply,
    465                     metric.known_wallet_addresses,
    466                     metric.transaction_count,
    467                     metric.transfer_count,
    468                     metric.burn_count,
    469                     metric.mine_count,
    470                     metric.burned_amount,
    471                     metric.total_burned_amount,
    472                     metric.fees_amount,
    473                     metric.reward_amount,
    474                     metric.vdf_rounds,
    475                     metric.finalizer_rank,
    476                 ],
    477             )
    478             .with_context(|| format!("failed to insert metrics for block {}", metric.height))?;
    479     }
    480     Ok(())
    481 }
    482 
    483 fn ensure_block_metrics_column(
    484     connection: &Connection,
    485     name: &str,
    486     definition: &str,
    487 ) -> Result<()> {
    488     let mut statement = connection
    489         .prepare("PRAGMA table_info(block_metrics)")
    490         .context("failed to inspect block_metrics schema")?;
    491     let columns = statement
    492         .query_map([], |row| row.get::<_, String>(1))
    493         .context("failed to query block_metrics columns")?
    494         .collect::<std::result::Result<Vec<_>, _>>()
    495         .context("failed to read block_metrics columns")?;
    496     if columns.iter().any(|column| column == name) {
    497         return Ok(());
    498     }
    499     connection
    500         .execute(
    501             &format!("ALTER TABLE block_metrics ADD COLUMN {name} {definition}"),
    502             [],
    503         )
    504         .with_context(|| format!("failed to add block_metrics.{name} column"))?;
    505     Ok(())
    506 }
    507 
    508 fn clear_metrics_in_transaction(transaction: &rusqlite::Transaction<'_>) -> Result<()> {
    509     transaction
    510         .execute("DELETE FROM block_metrics", [])
    511         .context("failed to clear old block metrics")?;
    512     Ok(())
    513 }
    514 
    515 fn replace_ui_chain_index(
    516     transaction: &rusqlite::Transaction<'_>,
    517     index: &UiChainIndex,
    518     updated_at_ms: u64,
    519 ) -> Result<()> {
    520     clear_ui_chain_index_in_transaction(transaction)?;
    521     let Some(tip_hash) = &index.tip_hash else {
    522         return Ok(());
    523     };
    524     transaction
    525         .execute(
    526             r#"
    527 INSERT INTO ui_cache_meta (id, schema_version, tip_hash, updated_at_ms)
    528 VALUES (1, ?1, ?2, ?3)
    529 "#,
    530             params![UI_CACHE_SCHEMA_VERSION, tip_hash, updated_at_ms],
    531         )
    532         .context("failed to persist UI chain index metadata")?;
    533     for (outpoint, output) in &index.outputs {
    534         transaction
    535             .execute(
    536                 r#"
    537 INSERT INTO ui_output_index (txid, output_index, address, amount)
    538 VALUES (?1, ?2, ?3, ?4)
    539 "#,
    540                 params![outpoint.txid, outpoint.index, output.address, output.amount],
    541             )
    542             .with_context(|| {
    543                 format!(
    544                     "failed to persist UI output index row {}:{}",
    545                     outpoint.txid, outpoint.index
    546                 )
    547             })?;
    548     }
    549     for (height, revealed_transactions) in &index.revealed_by_height {
    550         for revealed in revealed_transactions {
    551             let transaction_json = serde_json::to_vec(&revealed.transaction)
    552                 .context("failed to serialize UI revealed transaction")?;
    553             transaction
    554                 .execute(
    555                     r#"
    556 INSERT INTO ui_revealed_transactions (height, commitment, included_by, transaction_json)
    557 VALUES (?1, ?2, ?3, ?4)
    558 "#,
    559                     params![
    560                         height,
    561                         revealed.commitment,
    562                         revealed.included_by,
    563                         transaction_json
    564                     ],
    565                 )
    566                 .with_context(|| {
    567                     format!(
    568                         "failed to persist UI revealed transaction {}",
    569                         revealed.commitment
    570                     )
    571                 })?;
    572         }
    573     }
    574     for (block_hash, ranks) in &index.burn_leader_ranks_by_hash {
    575         transaction
    576             .execute(
    577                 "INSERT INTO ui_burn_leader_rank_blocks (block_hash) VALUES (?1)",
    578                 params![block_hash],
    579             )
    580             .with_context(|| format!("failed to persist UI burn leader rank block {block_hash}"))?;
    581         for rank in ranks {
    582             transaction
    583                 .execute(
    584                     r#"
    585 INSERT INTO ui_burn_leader_ranks (
    586     block_hash, rank, ticket_id, owner, amount, eligible_from_height, eligible_until_height
    587 ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)
    588 "#,
    589                     params![
    590                         block_hash,
    591                         rank.rank,
    592                         rank.ticket_id,
    593                         rank.owner,
    594                         rank.amount,
    595                         rank.eligible_from_height,
    596                         rank.eligible_until_height,
    597                     ],
    598                 )
    599                 .with_context(|| {
    600                     format!(
    601                         "failed to persist UI burn leader rank {} for block {}",
    602                         rank.rank, block_hash
    603                     )
    604                 })?;
    605         }
    606     }
    607     Ok(())
    608 }
    609 
    610 fn replace_ui_utxos(
    611     transaction: &rusqlite::Transaction<'_>,
    612     utxos: &[(OutPoint, TxOutput)],
    613 ) -> Result<()> {
    614     transaction
    615         .execute("DELETE FROM ui_utxos", [])
    616         .context("failed to clear old UI UTXO index")?;
    617     for (outpoint, output) in utxos {
    618         transaction
    619             .execute(
    620                 r#"
    621 INSERT INTO ui_utxos (txid, output_index, address, amount)
    622 VALUES (?1, ?2, ?3, ?4)
    623 "#,
    624                 params![outpoint.txid, outpoint.index, output.address, output.amount],
    625             )
    626             .with_context(|| {
    627                 format!(
    628                     "failed to persist UI UTXO row {}:{}",
    629                     outpoint.txid, outpoint.index
    630                 )
    631             })?;
    632     }
    633     Ok(())
    634 }
    635 
    636 fn replace_ui_wallet_transactions(
    637     transaction: &rusqlite::Transaction<'_>,
    638     rows: &[(String, WalletTransactionProjection)],
    639 ) -> Result<()> {
    640     transaction
    641         .execute("DELETE FROM ui_wallet_transactions", [])
    642         .context("failed to clear old UI wallet transaction index")?;
    643     for (address, row) in rows {
    644         let transaction_json = serde_json::to_vec(&row.transaction)
    645             .context("failed to serialize UI wallet transaction")?;
    646         transaction
    647             .execute(
    648                 r#"
    649 INSERT INTO ui_wallet_transactions (
    650     address, sort_key, kind, signature, block_height, timestamp_ms, block_finalizer, blinded,
    651     transaction_json
    652 ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)
    653 "#,
    654                 params![
    655                     address,
    656                     row.sort_key,
    657                     row.kind,
    658                     row.transaction.signature(),
    659                     row.block_height,
    660                     row.timestamp_ms,
    661                     row.block_finalizer,
    662                     row.blinded,
    663                     transaction_json,
    664                 ],
    665             )
    666             .with_context(|| {
    667                 format!(
    668                     "failed to persist UI wallet transaction {} for {}",
    669                     row.transaction.signature(),
    670                     address
    671                 )
    672             })?;
    673     }
    674     Ok(())
    675 }
    676 
    677 fn clear_ui_chain_index_in_transaction(transaction: &rusqlite::Transaction<'_>) -> Result<()> {
    678     transaction
    679         .execute("DELETE FROM ui_cache_meta", [])
    680         .context("failed to clear old UI cache metadata")?;
    681     transaction
    682         .execute("DELETE FROM ui_output_index", [])
    683         .context("failed to clear old UI output index")?;
    684     transaction
    685         .execute("DELETE FROM ui_utxos", [])
    686         .context("failed to clear old UI UTXO index")?;
    687     transaction
    688         .execute("DELETE FROM ui_wallet_transactions", [])
    689         .context("failed to clear old UI wallet transaction index")?;
    690     transaction
    691         .execute("DELETE FROM ui_revealed_transactions", [])
    692         .context("failed to clear old UI revealed transaction index")?;
    693     transaction
    694         .execute("DELETE FROM ui_burn_leader_ranks", [])
    695         .context("failed to clear old UI burn leader rank index")?;
    696     transaction
    697         .execute("DELETE FROM ui_burn_leader_rank_blocks", [])
    698         .context("failed to clear old UI burn leader rank block index")?;
    699     Ok(())
    700 }
    701 
    702 fn load_ui_output_index(connection: &Connection) -> Result<BTreeMap<OutPoint, TxOutput>> {
    703     let mut statement = connection
    704         .prepare(
    705             r#"
    706 SELECT txid, output_index, address, amount
    707 FROM ui_output_index
    708 ORDER BY txid, output_index
    709 "#,
    710         )
    711         .context("failed to prepare UI output index query")?;
    712     let rows = statement
    713         .query_map([], |row| {
    714             Ok((
    715                 OutPoint {
    716                     txid: row.get(0)?,
    717                     index: row.get(1)?,
    718                 },
    719                 TxOutput {
    720                     address: row.get(2)?,
    721                     amount: row.get(3)?,
    722                 },
    723             ))
    724         })
    725         .context("failed to load UI output index")?;
    726     rows.collect::<std::result::Result<BTreeMap<_, _>, _>>()
    727         .context("failed to read UI output index rows")
    728 }
    729 
    730 fn load_outputs(
    731     connection: &Connection,
    732     outpoints: &BTreeSet<OutPoint>,
    733 ) -> Result<BTreeMap<OutPoint, TxOutput>> {
    734     if outpoints.is_empty() {
    735         return Ok(BTreeMap::new());
    736     }
    737     let mut outputs = BTreeMap::new();
    738     let mut statement = connection
    739         .prepare(
    740             r#"
    741 SELECT address, amount
    742 FROM ui_output_index
    743 WHERE txid = ?1 AND output_index = ?2
    744 "#,
    745         )
    746         .context("failed to prepare narrow UI output lookup")?;
    747     for outpoint in outpoints {
    748         let output = statement
    749             .query_row(params![outpoint.txid, outpoint.index], |row| {
    750                 Ok(TxOutput {
    751                     address: row.get(0)?,
    752                     amount: row.get(1)?,
    753                 })
    754             })
    755             .optional()
    756             .with_context(|| {
    757                 format!(
    758                     "failed to load UI output {}:{}",
    759                     outpoint.txid, outpoint.index
    760                 )
    761             })?;
    762         if let Some(output) = output {
    763             outputs.insert(outpoint.clone(), output);
    764         }
    765     }
    766     Ok(outputs)
    767 }
    768 
    769 fn load_wallet_utxos(connection: &Connection, address: &str) -> Result<Vec<(OutPoint, TxOutput)>> {
    770     let mut statement = connection
    771         .prepare(
    772             r#"
    773 SELECT txid, output_index, address, amount
    774 FROM ui_utxos
    775 WHERE address = ?1
    776 ORDER BY amount DESC, txid ASC, output_index ASC
    777 "#,
    778         )
    779         .context("failed to prepare UI wallet UTXO query")?;
    780     let rows = statement
    781         .query_map([address], |row| {
    782             Ok((
    783                 OutPoint {
    784                     txid: row.get(0)?,
    785                     index: row.get(1)?,
    786                 },
    787                 TxOutput {
    788                     address: row.get(2)?,
    789                     amount: row.get(3)?,
    790                 },
    791             ))
    792         })
    793         .context("failed to load UI wallet UTXOs")?;
    794     rows.collect::<std::result::Result<Vec<_>, _>>()
    795         .context("failed to read UI wallet UTXO rows")
    796 }
    797 
    798 fn load_wallet_transactions(
    799     connection: &Connection,
    800     address: &str,
    801     kinds: &[&str],
    802     offset: usize,
    803     limit: usize,
    804 ) -> Result<(Vec<WalletTransactionProjection>, usize)> {
    805     if kinds.is_empty() {
    806         return Ok((Vec::new(), 0));
    807     }
    808     if wallet_transaction_kinds_cover_all(kinds) {
    809         let total = connection
    810             .query_row(
    811                 "SELECT COUNT(*) FROM ui_wallet_transactions WHERE address = ?1",
    812                 params![address],
    813                 |row| row.get::<_, u64>(0),
    814             )
    815             .context("failed to count UI wallet transactions")? as usize;
    816         let mut statement = connection
    817             .prepare(
    818                 r#"
    819 SELECT sort_key, kind, block_height, timestamp_ms, block_finalizer, blinded, transaction_json
    820 FROM ui_wallet_transactions
    821 WHERE address = ?1
    822 ORDER BY sort_key DESC
    823 LIMIT ?2 OFFSET ?3
    824 "#,
    825             )
    826             .context("failed to prepare UI wallet transactions query")?;
    827         let rows = read_wallet_transaction_rows(
    828             statement.query_map(params![address, limit as i64, offset as i64], |row| {
    829                 wallet_transaction_projection_from_row(row)
    830             })?,
    831         )?;
    832         return Ok((rows, total));
    833     }
    834     let placeholders = std::iter::repeat_n("?", kinds.len())
    835         .collect::<Vec<_>>()
    836         .join(", ");
    837     let count_sql = format!(
    838         "SELECT COUNT(*) FROM ui_wallet_transactions WHERE address = ? AND kind IN ({placeholders})"
    839     );
    840     let mut count_params = Vec::<Value>::with_capacity(kinds.len() + 1);
    841     count_params.push(Value::Text(address.to_string()));
    842     for kind in kinds {
    843         count_params.push(Value::Text((*kind).to_string()));
    844     }
    845     let total = connection
    846         .query_row(&count_sql, params_from_iter(count_params.iter()), |row| {
    847             row.get::<_, u64>(0)
    848         })
    849         .context("failed to count UI wallet transactions")? as usize;
    850 
    851     let query_sql = format!(
    852         r#"
    853 SELECT sort_key, kind, block_height, timestamp_ms, block_finalizer, blinded, transaction_json
    854 FROM ui_wallet_transactions
    855 WHERE address = ? AND kind IN ({placeholders})
    856 ORDER BY sort_key DESC
    857 LIMIT ? OFFSET ?
    858 "#
    859     );
    860     let mut query_params = Vec::<Value>::with_capacity(kinds.len() + 3);
    861     query_params.push(Value::Text(address.to_string()));
    862     for kind in kinds {
    863         query_params.push(Value::Text((*kind).to_string()));
    864     }
    865     query_params.push(Value::Integer(limit as i64));
    866     query_params.push(Value::Integer(offset as i64));
    867     let mut statement = connection
    868         .prepare(&query_sql)
    869         .context("failed to prepare UI wallet transactions query")?;
    870     let rows = read_wallet_transaction_rows(
    871         statement
    872             .query_map(params_from_iter(query_params.iter()), |row| {
    873                 wallet_transaction_projection_from_row(row)
    874             })
    875             .context("failed to load UI wallet transactions")?,
    876     )?;
    877     Ok((rows, total))
    878 }
    879 
    880 fn wallet_transaction_kinds_cover_all(kinds: &[&str]) -> bool {
    881     ["transfer", "mine", "burn"]
    882         .into_iter()
    883         .all(|kind| kinds.contains(&kind))
    884 }
    885 
    886 fn wallet_transaction_projection_from_row(
    887     row: &rusqlite::Row<'_>,
    888 ) -> rusqlite::Result<WalletTransactionProjection> {
    889     let transaction_json = row.get::<_, Vec<u8>>(6)?;
    890     let transaction =
    891         serde_json::from_slice::<Transaction>(&transaction_json).map_err(|error| {
    892             rusqlite::Error::FromSqlConversionFailure(
    893                 transaction_json.len(),
    894                 rusqlite::types::Type::Blob,
    895                 Box::new(error),
    896             )
    897         })?;
    898     Ok(WalletTransactionProjection {
    899         sort_key: row.get(0)?,
    900         kind: row.get(1)?,
    901         block_height: row.get(2)?,
    902         timestamp_ms: row.get(3)?,
    903         block_finalizer: row.get(4)?,
    904         blinded: row.get::<_, u64>(5)? != 0,
    905         transaction,
    906     })
    907 }
    908 
    909 fn read_wallet_transaction_rows(
    910     rows: rusqlite::MappedRows<
    911         '_,
    912         impl FnMut(&rusqlite::Row<'_>) -> rusqlite::Result<WalletTransactionProjection>,
    913     >,
    914 ) -> Result<Vec<WalletTransactionProjection>> {
    915     rows.collect::<std::result::Result<Vec<_>, _>>()
    916         .context("failed to read UI wallet transaction rows")
    917 }
    918 
    919 fn load_ui_revealed_transactions(
    920     connection: &Connection,
    921 ) -> Result<BTreeMap<u64, Vec<RevealedBlindedTransaction>>> {
    922     let mut statement = connection
    923         .prepare(
    924             r#"
    925 SELECT height, commitment, included_by, transaction_json
    926 FROM ui_revealed_transactions
    927 ORDER BY height, commitment
    928 "#,
    929         )
    930         .context("failed to prepare UI revealed transaction query")?;
    931     let rows = statement
    932         .query_map([], |row| {
    933             let transaction_json = row.get::<_, Vec<u8>>(3)?;
    934             let transaction =
    935                 serde_json::from_slice::<Transaction>(&transaction_json).map_err(|error| {
    936                     rusqlite::Error::FromSqlConversionFailure(
    937                         transaction_json.len(),
    938                         rusqlite::types::Type::Blob,
    939                         Box::new(error),
    940                     )
    941                 })?;
    942             Ok(RevealedBlindedTransaction {
    943                 height: row.get(0)?,
    944                 commitment: row.get(1)?,
    945                 included_by: row.get(2)?,
    946                 transaction,
    947             })
    948         })
    949         .context("failed to load UI revealed transactions")?;
    950     let mut by_height = BTreeMap::<u64, Vec<RevealedBlindedTransaction>>::new();
    951     for revealed in rows {
    952         let revealed = revealed.context("failed to read UI revealed transaction row")?;
    953         by_height.entry(revealed.height).or_default().push(revealed);
    954     }
    955     Ok(by_height)
    956 }
    957 
    958 fn load_ui_burn_leader_ranks(
    959     connection: &Connection,
    960 ) -> Result<BTreeMap<String, Vec<BurnLeaderRank>>> {
    961     let mut blocks_statement = connection
    962         .prepare("SELECT block_hash FROM ui_burn_leader_rank_blocks ORDER BY block_hash")
    963         .context("failed to prepare UI burn leader rank block query")?;
    964     let blocks = blocks_statement
    965         .query_map([], |row| row.get::<_, String>(0))
    966         .context("failed to load UI burn leader rank blocks")?;
    967     let mut by_block_hash = BTreeMap::<String, Vec<BurnLeaderRank>>::new();
    968     for block_hash in blocks {
    969         by_block_hash.insert(
    970             block_hash.context("failed to read UI burn leader rank block row")?,
    971             Vec::new(),
    972         );
    973     }
    974 
    975     let mut statement = connection
    976         .prepare(
    977             r#"
    978 SELECT block_hash, rank, ticket_id, owner, amount, eligible_from_height, eligible_until_height
    979 FROM ui_burn_leader_ranks
    980 ORDER BY block_hash, rank
    981 "#,
    982         )
    983         .context("failed to prepare UI burn leader rank query")?;
    984     let rows = statement
    985         .query_map([], |row| {
    986             Ok((
    987                 row.get::<_, String>(0)?,
    988                 BurnLeaderRank {
    989                     rank: row.get(1)?,
    990                     ticket_id: row.get(2)?,
    991                     owner: row.get(3)?,
    992                     amount: row.get(4)?,
    993                     eligible_from_height: row.get(5)?,
    994                     eligible_until_height: row.get(6)?,
    995                 },
    996             ))
    997         })
    998         .context("failed to load UI burn leader ranks")?;
    999     for row in rows {
   1000         let (block_hash, rank) = row.context("failed to read UI burn leader rank row")?;
   1001         by_block_hash.entry(block_hash).or_default().push(rank);
   1002     }
   1003     Ok(by_block_hash)
   1004 }
   1005 
   1006 fn wallet_transactions_from_snapshot(
   1007     snapshot: &ChainSnapshot,
   1008 ) -> Vec<(String, WalletTransactionProjection)> {
   1009     let revealed_by_height = revealed_blinded_transactions(snapshot)
   1010         .unwrap_or_default()
   1011         .into_iter()
   1012         .fold(
   1013             BTreeMap::<u64, Vec<RevealedBlindedTransaction>>::new(),
   1014             |mut by_height, revealed| {
   1015                 by_height.entry(revealed.height).or_default().push(revealed);
   1016                 by_height
   1017             },
   1018         );
   1019     let mut rows = Vec::new();
   1020     for block in &snapshot.blocks {
   1021         for (index, transaction) in block.transactions.iter().rev().enumerate() {
   1022             push_wallet_transaction_projection(
   1023                 &mut rows,
   1024                 transaction,
   1025                 block,
   1026                 block.height as u128 * 10_000 + index as u128,
   1027                 false,
   1028             );
   1029         }
   1030         if let Some(revealed) = revealed_by_height.get(&block.height) {
   1031             for (index, revealed) in revealed.iter().rev().enumerate() {
   1032                 push_wallet_transaction_projection(
   1033                     &mut rows,
   1034                     &revealed.transaction,
   1035                     block,
   1036                     block.height as u128 * 10_000 + 5_000 + index as u128,
   1037                     false,
   1038                 );
   1039             }
   1040         }
   1041     }
   1042     rows
   1043 }
   1044 
   1045 fn push_wallet_transaction_projection(
   1046     rows: &mut Vec<(String, WalletTransactionProjection)>,
   1047     transaction: &Transaction,
   1048     block: &Block,
   1049     sort_key: u128,
   1050     blinded: bool,
   1051 ) {
   1052     let kind = transaction_kind(transaction).to_string();
   1053     let projection = WalletTransactionProjection {
   1054         sort_key: sort_key.min(u128::from(u64::MAX)) as u64,
   1055         kind,
   1056         block_height: block.height,
   1057         timestamp_ms: block.timestamp_ms,
   1058         block_finalizer: block.miner.clone(),
   1059         blinded,
   1060         transaction: transaction.clone(),
   1061     };
   1062     for address in wallet_transaction_addresses(transaction) {
   1063         rows.push((address, projection.clone()));
   1064     }
   1065 }
   1066 
   1067 fn wallet_transaction_addresses(transaction: &Transaction) -> Vec<String> {
   1068     match transaction {
   1069         Transaction::Transfer { .. } => {
   1070             let mut addresses = vec![transaction.sender().to_string()];
   1071             if let Some(to) = transaction.to() {
   1072                 if to != transaction.sender() {
   1073                     addresses.push(to.to_string());
   1074                 }
   1075             }
   1076             addresses
   1077         }
   1078         Transaction::Burn { .. } => vec![transaction.sender().to_string()],
   1079         Transaction::Mine { recipient, .. } => vec![recipient.clone()],
   1080     }
   1081 }
   1082 
   1083 fn transaction_kind(transaction: &Transaction) -> &'static str {
   1084     match transaction {
   1085         Transaction::Transfer { .. } => "transfer",
   1086         Transaction::Burn { .. } => "burn",
   1087         Transaction::Mine { .. } => "mine",
   1088     }
   1089 }
   1090 
   1091 fn metrics_from_snapshot(snapshot: &ChainSnapshot) -> Result<Vec<BlockMetricRow>> {
   1092     let ledger = Ledger::from_persisted_snapshot(snapshot.clone())
   1093         .context("failed to rebuild ledger for metrics")?;
   1094     let genesis = snapshot
   1095         .blocks
   1096         .first()
   1097         .cloned()
   1098         .context("cannot compute metrics for empty chain snapshot")?;
   1099     let mut running_ledger = Ledger::from_persisted_snapshot(ChainSnapshot {
   1100         genesis_allocations: snapshot.genesis_allocations.clone(),
   1101         vdf_rounds: snapshot.vdf_rounds,
   1102         launch_profile: snapshot.launch_profile.clone(),
   1103         blocks: vec![genesis],
   1104     })
   1105     .context("failed to rebuild genesis ledger for metrics")?;
   1106     let revealed = revealed_blinded_transactions(snapshot)?.into_iter().fold(
   1107         BTreeMap::<u64, Vec<crate::domain::RevealedBlindedTransaction>>::new(),
   1108         |mut by_height, revealed| {
   1109             by_height.entry(revealed.height).or_default().push(revealed);
   1110             by_height
   1111         },
   1112     );
   1113     let mut known_wallet_addresses = snapshot
   1114         .genesis_allocations
   1115         .keys()
   1116         .cloned()
   1117         .collect::<BTreeSet<_>>();
   1118     let mut total_burned_amount = 0_u64;
   1119     let mut rows = Vec::with_capacity(snapshot.blocks.len());
   1120     let mut previous_timestamp_ms = None;
   1121     let mut active_blinded = BTreeMap::<String, BlindedTransaction>::new();
   1122     let mut metric_utxos = metric_genesis_utxos(snapshot);
   1123     let mut metric_locked_blinded_inputs = BTreeMap::<String, Amount>::new();
   1124     let reveal_bundle_slots_by_height = ledger
   1125         .burn_leader_ranks_for_blocks(snapshot.blocks.iter().map(|block| block.height))
   1126         .map(|ranks_by_height| {
   1127             ranks_by_height
   1128                 .into_iter()
   1129                 .map(|(height, ranks)| (height, reveal_committee_slot_count(ranks.len())))
   1130                 .collect::<BTreeMap<_, _>>()
   1131         })
   1132         .unwrap_or_default();
   1133 
   1134     for block in &snapshot.blocks {
   1135         let revealed_transactions = revealed.get(&block.height).cloned().unwrap_or_default();
   1136         let mut transfer_count = 0_u64;
   1137         let mut burn_count = 0_u64;
   1138         let mut mine_count = 0_u64;
   1139         let mut burned_amount = 0_u64;
   1140         let mut burned_fee_amount = 0_u64;
   1141         let mut fees_amount = 0_u64;
   1142 
   1143         known_wallet_addresses.insert(block.miner.clone());
   1144         for signature in &block.reveal_bundle_section.signatures {
   1145             known_wallet_addresses.insert(signature.member.clone());
   1146         }
   1147         for transaction in &block.transactions {
   1148             collect_transaction_addresses(transaction, &mut known_wallet_addresses);
   1149             metric_apply_public_transaction(transaction, &mut metric_utxos)?;
   1150             fees_amount = fees_amount
   1151                 .checked_add(transaction.fee())
   1152                 .context("block metric fees overflow")?;
   1153             match transaction {
   1154                 Transaction::Transfer { .. } => transfer_count += 1,
   1155                 Transaction::Burn { amount, .. } => {
   1156                     burn_count += 1;
   1157                     burned_amount = burned_amount
   1158                         .checked_add(*amount)
   1159                         .context("block metric burns overflow")?;
   1160                 }
   1161                 Transaction::Mine { .. } => {
   1162                     mine_count += 1;
   1163                 }
   1164             }
   1165         }
   1166         for revealed in &revealed_transactions {
   1167             let transaction = &revealed.transaction;
   1168             known_wallet_addresses.insert(revealed.included_by.clone());
   1169             collect_transaction_addresses(transaction, &mut known_wallet_addresses);
   1170             metric_index_transaction_outputs(&mut metric_utxos, transaction);
   1171             metric_index_blinded_fee_outputs(
   1172                 &mut metric_utxos,
   1173                 &revealed.commitment,
   1174                 &revealed.included_by,
   1175                 block,
   1176                 transaction.fee(),
   1177             );
   1178             fees_amount = fees_amount
   1179                 .checked_add(transaction.fee())
   1180                 .context("block metric fees overflow")?;
   1181             let committer_fee = blinded_fee_share(transaction.fee(), BLINDED_COMMITTER_FEE_BPS);
   1182             let included_reveal_bundle_count = block.included_reveal_bundle_count();
   1183             let available_reveal_bundle_slots = reveal_bundle_slots_by_height
   1184                 .get(&block.height)
   1185                 .copied()
   1186                 .unwrap_or(REVEAL_COMMITTEE_SIZE);
   1187             let reveal_finalizer_fee = blinded_reveal_finalizer_fee(
   1188                 transaction.fee(),
   1189                 included_reveal_bundle_count,
   1190                 available_reveal_bundle_slots,
   1191             );
   1192             let reveal_bundle_signer_fees =
   1193                 blinded_fee_share(transaction.fee(), BLINDED_REVEAL_BUNDLE_SIGNER_FEE_BPS)
   1194                     .saturating_mul(included_reveal_bundle_count as u64);
   1195             let distributed_fee = committer_fee
   1196                 .saturating_add(reveal_finalizer_fee)
   1197                 .saturating_add(reveal_bundle_signer_fees);
   1198             burned_fee_amount = burned_fee_amount
   1199                 .checked_add(transaction.fee().saturating_sub(distributed_fee))
   1200                 .context("block metric burned fees overflow")?;
   1201             match transaction {
   1202                 Transaction::Transfer { .. } => transfer_count += 1,
   1203                 Transaction::Burn { amount, .. } => {
   1204                     burn_count += 1;
   1205                     burned_amount = burned_amount
   1206                         .checked_add(*amount)
   1207                         .context("block metric burns overflow")?;
   1208                 }
   1209                 Transaction::Mine { .. } => {
   1210                     mine_count += 1;
   1211                 }
   1212             }
   1213         }
   1214         let revealed_commitments = block
   1215             .all_blinded_reveals()
   1216             .into_iter()
   1217             .map(|reveal| reveal.commitment.clone())
   1218             .collect::<std::collections::BTreeSet<_>>();
   1219         let mut expired_blinded_fee_values = Vec::new();
   1220         active_blinded.retain(|commitment, transaction| {
   1221             if revealed_commitments.contains(commitment) {
   1222                 metric_locked_blinded_inputs.remove(commitment);
   1223                 return false;
   1224             }
   1225             if block.height >= transaction.expires_at_height {
   1226                 if !transaction.inputs.is_empty() {
   1227                     expired_blinded_fee_values.push(transaction.fee);
   1228                     metric_index_expired_blinded_change(
   1229                         &mut metric_utxos,
   1230                         commitment,
   1231                         transaction,
   1232                         metric_locked_blinded_inputs
   1233                             .remove(commitment)
   1234                             .unwrap_or_default(),
   1235                     );
   1236                 }
   1237                 return false;
   1238             }
   1239             true
   1240         });
   1241         let expired_blinded_fees =
   1242             expired_blinded_fee_values
   1243                 .into_iter()
   1244                 .try_fold(0_u64, |total, fee| {
   1245                     total
   1246                         .checked_add(fee)
   1247                         .context("block metric expiry fees overflow")
   1248                 })?;
   1249         fees_amount = fees_amount
   1250             .checked_add(expired_blinded_fees)
   1251             .context("block metric expiry fees overflow")?;
   1252         burned_fee_amount = burned_fee_amount
   1253             .checked_add(expired_blinded_fees)
   1254             .context("block metric expired burned fees overflow")?;
   1255         for transaction in &block.blinded_transactions {
   1256             for input in &transaction.inputs {
   1257                 known_wallet_addresses.insert(input.owner.clone());
   1258             }
   1259             let locked_total = metric_spend_blinded_inputs(transaction, &mut metric_utxos)?;
   1260             metric_locked_blinded_inputs.insert(transaction.commitment.clone(), locked_total);
   1261             active_blinded.insert(transaction.commitment.clone(), transaction.clone());
   1262         }
   1263         total_burned_amount = total_burned_amount
   1264             .checked_add(burned_amount)
   1265             .and_then(|amount| amount.checked_add(burned_fee_amount))
   1266             .context("total burned metric overflows")?;
   1267 
   1268         if block.height > 0 {
   1269             running_ledger
   1270                 .apply_preverified_block_at(block.clone(), u64::MAX)
   1271                 .with_context(|| format!("failed to replay block {} for metrics", block.height))?;
   1272         }
   1273         metric_index_block_reward(&mut metric_utxos, block);
   1274         let circulating_supply = ledger_circulating_supply(&running_ledger)?
   1275             .checked_add(metric_locked_supply(&metric_locked_blinded_inputs)?)
   1276             .context("circulating supply metric overflows")?;
   1277         let block_time_ms =
   1278             previous_timestamp_ms.map(|previous| block.timestamp_ms.saturating_sub(previous));
   1279         previous_timestamp_ms = Some(block.timestamp_ms);
   1280         rows.push(BlockMetricRow {
   1281             height: block.height,
   1282             block_hash: block.hash.clone(),
   1283             timestamp_ms: block.timestamp_ms,
   1284             block_time_ms,
   1285             mine_difficulty_bits: ledger.mine_difficulty_bits_at_height(block.height),
   1286             circulating_supply,
   1287             known_wallet_addresses: known_wallet_addresses.len() as u64,
   1288             transaction_count: (block.transactions.len() + revealed_transactions.len()) as u64,
   1289             transfer_count,
   1290             burn_count,
   1291             mine_count,
   1292             burned_amount,
   1293             total_burned_amount,
   1294             fees_amount,
   1295             reward_amount: block.reward,
   1296             vdf_rounds: block.vdf_rounds,
   1297             finalizer_rank: block.finalizer_rank,
   1298         });
   1299     }
   1300     Ok(rows)
   1301 }
   1302 
   1303 fn ledger_circulating_supply(ledger: &Ledger) -> Result<Amount> {
   1304     ledger
   1305         .status()
   1306         .balances
   1307         .values()
   1308         .try_fold(0_u64, |total, amount| {
   1309             total
   1310                 .checked_add(*amount)
   1311                 .context("circulating supply metric overflows")
   1312         })
   1313 }
   1314 
   1315 fn metric_locked_supply(locked: &BTreeMap<String, Amount>) -> Result<Amount> {
   1316     locked.values().try_fold(0_u64, |total, amount| {
   1317         total
   1318             .checked_add(*amount)
   1319             .context("circulating supply metric overflows")
   1320     })
   1321 }
   1322 
   1323 fn metric_genesis_utxos(snapshot: &ChainSnapshot) -> BTreeMap<OutPoint, TxOutput> {
   1324     snapshot
   1325         .genesis_allocations
   1326         .iter()
   1327         .filter(|(_, amount)| **amount > 0)
   1328         .map(|(address, amount)| {
   1329             (
   1330                 metric_genesis_allocation_outpoint(address),
   1331                 TxOutput {
   1332                     address: address.clone(),
   1333                     amount: *amount,
   1334                 },
   1335             )
   1336         })
   1337         .collect()
   1338 }
   1339 
   1340 fn blinded_fee_share(fee: Amount, bps: u64) -> Amount {
   1341     ((fee as u128 * bps as u128) / BLINDED_FEE_BPS_DENOMINATOR as u128) as Amount
   1342 }
   1343 
   1344 fn metric_apply_public_transaction(
   1345     transaction: &Transaction,
   1346     utxos: &mut BTreeMap<OutPoint, TxOutput>,
   1347 ) -> Result<()> {
   1348     metric_spend_transaction_inputs(transaction, utxos)?;
   1349     metric_index_transaction_outputs(utxos, transaction);
   1350     Ok(())
   1351 }
   1352 
   1353 fn metric_spend_transaction_inputs(
   1354     transaction: &Transaction,
   1355     utxos: &mut BTreeMap<OutPoint, TxOutput>,
   1356 ) -> Result<Amount> {
   1357     let inputs = match transaction {
   1358         Transaction::Transfer { inputs, .. } | Transaction::Burn { inputs, .. } => inputs,
   1359         Transaction::Mine { .. } => return Ok(0),
   1360     };
   1361     metric_spend_inputs(inputs, utxos)
   1362 }
   1363 
   1364 fn metric_spend_blinded_inputs(
   1365     transaction: &BlindedTransaction,
   1366     utxos: &mut BTreeMap<OutPoint, TxOutput>,
   1367 ) -> Result<Amount> {
   1368     metric_spend_inputs(&transaction.inputs, utxos)
   1369 }
   1370 
   1371 fn metric_spend_inputs(
   1372     inputs: &[TxInput],
   1373     utxos: &mut BTreeMap<OutPoint, TxOutput>,
   1374 ) -> Result<Amount> {
   1375     inputs.iter().try_fold(0_u64, |total, input| {
   1376         let output = utxos.remove(&input.outpoint).with_context(|| {
   1377             format!(
   1378                 "metric replay spends missing output {}:{}",
   1379                 input.outpoint.txid, input.outpoint.index
   1380             )
   1381         })?;
   1382         total
   1383             .checked_add(output.amount)
   1384             .context("metric replay input total overflows")
   1385     })
   1386 }
   1387 
   1388 fn metric_index_transaction_outputs(
   1389     utxos: &mut BTreeMap<OutPoint, TxOutput>,
   1390     transaction: &Transaction,
   1391 ) {
   1392     let outputs = match transaction {
   1393         Transaction::Transfer { outputs, .. } => outputs.clone(),
   1394         Transaction::Burn { change, .. } => change.clone(),
   1395         Transaction::Mine { recipient, .. } => vec![TxOutput {
   1396             address: recipient.clone(),
   1397             amount: MINE_REWARD,
   1398         }],
   1399     };
   1400     for (index, output) in outputs.iter().enumerate() {
   1401         utxos.insert(
   1402             OutPoint {
   1403                 txid: transaction.signature().to_string(),
   1404                 index: index as u32,
   1405             },
   1406             output.clone(),
   1407         );
   1408     }
   1409 }
   1410 
   1411 fn metric_index_blinded_fee_outputs(
   1412     utxos: &mut BTreeMap<OutPoint, TxOutput>,
   1413     commitment: &str,
   1414     included_by: &str,
   1415     block: &Block,
   1416     fee: Amount,
   1417 ) {
   1418     if fee == 0 {
   1419         return;
   1420     }
   1421     let committer_fee = blinded_fee_share(fee, BLINDED_COMMITTER_FEE_BPS);
   1422     if committer_fee > 0 {
   1423         utxos.insert(
   1424             metric_blinded_committer_fee_outpoint(commitment),
   1425             TxOutput {
   1426                 address: included_by.to_string(),
   1427                 amount: committer_fee,
   1428             },
   1429         );
   1430     }
   1431     let reveal_bundle_signer_fee = blinded_fee_share(fee, BLINDED_REVEAL_BUNDLE_SIGNER_FEE_BPS);
   1432     if reveal_bundle_signer_fee > 0 {
   1433         for signature in &block.reveal_bundle_section.signatures {
   1434             utxos.insert(
   1435                 metric_blinded_reveal_bundle_signer_fee_outpoint(commitment, signature.slot),
   1436                 TxOutput {
   1437                     address: signature.member.clone(),
   1438                     amount: reveal_bundle_signer_fee,
   1439                 },
   1440             );
   1441         }
   1442     }
   1443 }
   1444 
   1445 fn metric_index_expired_blinded_change(
   1446     utxos: &mut BTreeMap<OutPoint, TxOutput>,
   1447     commitment: &str,
   1448     transaction: &BlindedTransaction,
   1449     locked_total: Amount,
   1450 ) {
   1451     let Some(first_input) = transaction.inputs.first() else {
   1452         return;
   1453     };
   1454     let change = locked_total.saturating_sub(transaction.fee);
   1455     if change == 0 {
   1456         return;
   1457     }
   1458     utxos.insert(
   1459         metric_blinded_expiry_change_outpoint(commitment),
   1460         TxOutput {
   1461             address: first_input.owner.clone(),
   1462             amount: change,
   1463         },
   1464     );
   1465 }
   1466 
   1467 fn metric_index_block_reward(utxos: &mut BTreeMap<OutPoint, TxOutput>, block: &Block) {
   1468     if block.reward == 0 {
   1469         return;
   1470     }
   1471     utxos.insert(
   1472         metric_reward_outpoint(&block.hash),
   1473         TxOutput {
   1474             address: block.miner.clone(),
   1475             amount: block.reward,
   1476         },
   1477     );
   1478 }
   1479 
   1480 fn metric_genesis_allocation_outpoint(address: &str) -> OutPoint {
   1481     OutPoint {
   1482         txid: hex_hash(format!("iuna-genesis-allocation:{address}")),
   1483         index: 0,
   1484     }
   1485 }
   1486 
   1487 fn metric_reward_outpoint(block_hash: &str) -> OutPoint {
   1488     OutPoint {
   1489         txid: block_hash.to_string(),
   1490         index: u32::MAX,
   1491     }
   1492 }
   1493 
   1494 fn metric_blinded_committer_fee_outpoint(commitment: &str) -> OutPoint {
   1495     OutPoint {
   1496         txid: commitment.to_string(),
   1497         index: u32::MAX - 1,
   1498     }
   1499 }
   1500 
   1501 fn metric_blinded_reveal_bundle_signer_fee_outpoint(commitment: &str, slot: u8) -> OutPoint {
   1502     OutPoint {
   1503         txid: commitment.to_string(),
   1504         index: u32::MAX - 3 - u32::from(slot),
   1505     }
   1506 }
   1507 
   1508 fn metric_blinded_expiry_change_outpoint(commitment: &str) -> OutPoint {
   1509     OutPoint {
   1510         txid: commitment.to_string(),
   1511         index: 0,
   1512     }
   1513 }
   1514 
   1515 fn collect_transaction_addresses(transaction: &Transaction, addresses: &mut BTreeSet<String>) {
   1516     match transaction {
   1517         Transaction::Transfer {
   1518             inputs, outputs, ..
   1519         } => {
   1520             collect_input_addresses(inputs, addresses);
   1521             collect_output_addresses(outputs, addresses);
   1522         }
   1523         Transaction::Burn { inputs, change, .. } => {
   1524             collect_input_addresses(inputs, addresses);
   1525             collect_output_addresses(change, addresses);
   1526         }
   1527         Transaction::Mine { recipient, .. } => {
   1528             addresses.insert(recipient.clone());
   1529         }
   1530     }
   1531 }
   1532 
   1533 fn collect_input_addresses(inputs: &[TxInput], addresses: &mut BTreeSet<String>) {
   1534     for input in inputs {
   1535         addresses.insert(input.owner.clone());
   1536     }
   1537 }
   1538 
   1539 fn collect_output_addresses(outputs: &[TxOutput], addresses: &mut BTreeSet<String>) {
   1540     for output in outputs {
   1541         addresses.insert(output.address.clone());
   1542     }
   1543 }
   1544 
   1545 fn unix_ms() -> u64 {
   1546     SystemTime::now()
   1547         .duration_since(UNIX_EPOCH)
   1548         .unwrap_or_default()
   1549         .as_millis() as u64
   1550 }
   1551 
   1552 #[cfg(test)]
   1553 mod tests;