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;