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