chain_store.rs (28752B)
1 use std::{ 2 fs, 3 path::{Path, PathBuf}, 4 time::{SystemTime, UNIX_EPOCH}, 5 }; 6 7 use anyhow::{Context, Result}; 8 use rusqlite::{Connection, OptionalExtension, params}; 9 10 use crate::{ 11 compact::{decode_compact_snapshot, encode_compact_snapshot, legacy_compact_snapshot_version}, 12 domain::ChainSnapshot, 13 }; 14 15 #[cfg(feature = "fuzzing")] 16 pub fn fuzz_decode_compact_snapshot(bytes: &[u8]) -> Result<ChainSnapshot> { 17 decode_compact_snapshot(bytes) 18 } 19 20 const SCHEMA: &str = r#" 21 CREATE TABLE IF NOT EXISTS chain_snapshots ( 22 id INTEGER PRIMARY KEY CHECK (id = 1), 23 height INTEGER NOT NULL, 24 tip_hash TEXT NOT NULL, 25 snapshot_blob BLOB NOT NULL, 26 updated_at_ms INTEGER NOT NULL 27 ); 28 CREATE TABLE IF NOT EXISTS chain_verification ( 29 id INTEGER PRIMARY KEY CHECK (id = 1), 30 tip_hash TEXT NOT NULL, 31 verifier_version TEXT NOT NULL, 32 verified_at_ms INTEGER NOT NULL 33 ); 34 CREATE TABLE IF NOT EXISTS pending_transactions_v2 ( 35 transaction_id TEXT PRIMARY KEY, 36 envelope TEXT NOT NULL, 37 position INTEGER NOT NULL, 38 updated_at_ms INTEGER NOT NULL 39 ); 40 "#; 41 42 // This identifies the consensus rules, not the application release. UI, packaging, and 43 // other non-consensus releases must not invalidate a chain that this node already verified. 44 // Bump this value only when historical validation semantics change, and add the old ruleset to 45 // `revalidation_from_height` with the first affected block height. 46 const CURRENT_CONSENSUS_RULESET: &str = "iuna-consensus-v1"; 47 48 // v0.4.10 introduced the verification marker and wrote the package version into it. Its 49 // validator is identical to the first stable consensus ruleset, so it can be migrated safely. 50 const LEGACY_EQUIVALENT_VERIFIER_VERSIONS: &[&str] = &["0.4.10"]; 51 52 struct ConsensusRulesetMigration { 53 from_ruleset: &'static str, 54 revalidate_from_height: u64, 55 } 56 57 // When a future release changes consensus validation, bump CURRENT_CONSENSUS_RULESET and add a 58 // direct migration for every still-supported older ruleset. The height is the first block whose 59 // validity can differ under the new rules. 60 const CONSENSUS_RULESET_MIGRATIONS: &[ConsensusRulesetMigration] = &[]; 61 62 #[derive(Clone, Debug)] 63 pub struct SqliteChainStore { 64 path: PathBuf, 65 } 66 67 #[derive(Debug)] 68 pub struct LoadedChainSnapshot { 69 pub snapshot: ChainSnapshot, 70 /// First block that must be validated again. `None` means the entire persisted chain is 71 /// already trusted under the current consensus ruleset. 72 pub revalidation_from_height: Option<u64>, 73 } 74 75 impl SqliteChainStore { 76 pub fn open(path: impl AsRef<Path>) -> Result<Self> { 77 let path = path.as_ref().to_path_buf(); 78 if let Some(parent) = path.parent() { 79 fs::create_dir_all(parent).with_context(|| { 80 format!( 81 "failed to create chain database directory {}", 82 parent.display() 83 ) 84 })?; 85 } 86 87 if let Some(reason) = legacy_chain_reason(&path)? { 88 let archive_path = archive_legacy_chain(&path)?; 89 println!( 90 "archived incompatible chain database ({reason}): {} -> {}", 91 path.display(), 92 archive_path.display() 93 ); 94 } 95 96 let store = Self { path }; 97 store.with_connection_mut(|connection| { 98 connection 99 .execute_batch(SCHEMA) 100 .context("failed to initialize chain database schema")?; 101 Ok(()) 102 })?; 103 Ok(store) 104 } 105 106 pub fn path(&self) -> &Path { 107 &self.path 108 } 109 110 pub fn load(&self) -> Result<Option<ChainSnapshot>> { 111 Ok(self 112 .load_with_verification_status()? 113 .map(|loaded| loaded.snapshot)) 114 } 115 116 pub fn contains_chain(&self) -> Result<bool> { 117 self.with_connection(|connection| { 118 connection 119 .query_row( 120 "SELECT EXISTS(SELECT 1 FROM chain_snapshots WHERE id = 1)", 121 [], 122 |row| row.get(0), 123 ) 124 .context("failed to inspect chain database") 125 }) 126 } 127 128 pub fn load_pending_transactions_v2(&self) -> Result<Vec<(String, String)>> { 129 self.with_connection(|connection| { 130 let mut statement = connection 131 .prepare( 132 r#" 133 SELECT transaction_id, envelope 134 FROM pending_transactions_v2 135 ORDER BY position ASC 136 "#, 137 ) 138 .context("failed to prepare pending transaction v2 query")?; 139 let rows = statement 140 .query_map([], |row| Ok((row.get(0)?, row.get(1)?))) 141 .context("failed to load pending transactions v2")?; 142 rows.collect::<std::result::Result<Vec<_>, _>>() 143 .context("failed to read pending transaction v2 rows") 144 }) 145 } 146 147 pub fn save_pending_transaction_v2(&self, transaction_id: &str, envelope: &str) -> Result<()> { 148 self.with_connection_mut(|connection| { 149 connection 150 .execute( 151 r#" 152 INSERT INTO pending_transactions_v2 (transaction_id, envelope, position, updated_at_ms) 153 VALUES ( 154 ?1, 155 ?2, 156 COALESCE((SELECT MAX(position) + 1 FROM pending_transactions_v2), 0), 157 ?3 158 ) 159 ON CONFLICT(transaction_id) DO UPDATE SET 160 envelope = excluded.envelope, 161 updated_at_ms = excluded.updated_at_ms 162 "#, 163 params![transaction_id, envelope, unix_ms()], 164 ) 165 .context("failed to persist pending transaction v2")?; 166 Ok(()) 167 }) 168 } 169 170 pub fn replace_pending_transactions_v2(&self, rows: &[(String, String)]) -> Result<()> { 171 let updated_at_ms = unix_ms(); 172 self.with_connection_mut(|connection| { 173 let transaction = connection 174 .transaction() 175 .context("failed to start pending transaction v2 persistence transaction")?; 176 transaction 177 .execute("DELETE FROM pending_transactions_v2", []) 178 .context("failed to clear pending transactions v2")?; 179 for (position, (transaction_id, envelope)) in rows.iter().enumerate() { 180 transaction 181 .execute( 182 r#" 183 INSERT INTO pending_transactions_v2 ( 184 transaction_id, envelope, position, updated_at_ms 185 ) 186 VALUES (?1, ?2, ?3, ?4) 187 "#, 188 params![transaction_id, envelope, position, updated_at_ms], 189 ) 190 .with_context(|| { 191 format!("failed to persist pending transaction v2 {transaction_id}") 192 })?; 193 } 194 transaction 195 .commit() 196 .context("failed to commit pending transaction v2 persistence transaction")?; 197 Ok(()) 198 }) 199 } 200 201 pub fn load_with_verification_status(&self) -> Result<Option<LoadedChainSnapshot>> { 202 self.with_connection(|connection| { 203 let stored = connection 204 .query_row( 205 "SELECT height, tip_hash, snapshot_blob FROM chain_snapshots WHERE id = 1", 206 [], 207 |row| { 208 Ok(( 209 row.get::<_, u64>(0)?, 210 row.get::<_, String>(1)?, 211 row.get::<_, Vec<u8>>(2)?, 212 )) 213 }, 214 ) 215 .optional() 216 .context("failed to load chain snapshot from database")?; 217 218 let snapshot = stored 219 .map(|(stored_height, stored_tip_hash, blob)| { 220 let snapshot = decode_compact_snapshot(&blob) 221 .context("failed to parse compact chain snapshot from database")?; 222 let (height, tip_hash) = snapshot_tip(&snapshot) 223 .context("compact chain snapshot contains no blocks")?; 224 if height != stored_height || tip_hash != stored_tip_hash { 225 anyhow::bail!( 226 "compact chain snapshot tip does not match database metadata" 227 ); 228 } 229 Ok((snapshot, tip_hash)) 230 }) 231 .transpose()?; 232 let Some((snapshot, tip_hash)) = snapshot else { 233 return Ok(None); 234 }; 235 let stored_ruleset = connection 236 .query_row( 237 r#" 238 SELECT verifier_version FROM chain_verification 239 WHERE id = 1 AND tip_hash = ?1 240 "#, 241 params![tip_hash], 242 |row| row.get::<_, String>(0), 243 ) 244 .optional() 245 .context("failed to inspect chain verification status")?; 246 Ok(Some(LoadedChainSnapshot { 247 snapshot, 248 revalidation_from_height: revalidation_from_height(stored_ruleset.as_deref()), 249 })) 250 }) 251 } 252 253 pub fn save(&self, snapshot: &ChainSnapshot) -> Result<()> { 254 self.save_with_verification_status(snapshot, false) 255 } 256 257 /// Persist a snapshot that has already passed consensus validation in this binary. 258 pub fn save_verified(&self, snapshot: &ChainSnapshot) -> Result<()> { 259 self.save_with_verification_status(snapshot, true) 260 } 261 262 fn save_with_verification_status( 263 &self, 264 snapshot: &ChainSnapshot, 265 verified: bool, 266 ) -> Result<()> { 267 let (height, tip_hash) = snapshot_tip(snapshot).context("cannot persist empty chain")?; 268 let snapshot_blob = 269 encode_compact_snapshot(snapshot).context("failed to encode compact chain snapshot")?; 270 let updated_at_ms = unix_ms(); 271 272 self.with_connection_mut(|connection| { 273 let transaction = connection 274 .transaction() 275 .context("failed to start chain persistence transaction")?; 276 transaction 277 .execute( 278 r#" 279 INSERT INTO chain_snapshots (id, height, tip_hash, snapshot_blob, updated_at_ms) 280 VALUES (1, ?1, ?2, ?3, ?4) 281 ON CONFLICT(id) DO UPDATE SET 282 height = excluded.height, 283 tip_hash = excluded.tip_hash, 284 snapshot_blob = excluded.snapshot_blob, 285 updated_at_ms = excluded.updated_at_ms 286 "#, 287 params![height, tip_hash, snapshot_blob, updated_at_ms], 288 ) 289 .context("failed to persist chain snapshot")?; 290 if verified { 291 transaction 292 .execute( 293 r#" 294 INSERT INTO chain_verification (id, tip_hash, verifier_version, verified_at_ms) 295 VALUES (1, ?1, ?2, ?3) 296 ON CONFLICT(id) DO UPDATE SET 297 tip_hash = excluded.tip_hash, 298 verifier_version = excluded.verifier_version, 299 verified_at_ms = excluded.verified_at_ms 300 "#, 301 params![tip_hash, CURRENT_CONSENSUS_RULESET, updated_at_ms], 302 ) 303 .context("failed to persist chain verification status")?; 304 } else { 305 transaction 306 .execute("DELETE FROM chain_verification", []) 307 .context("failed to clear chain verification status")?; 308 } 309 transaction 310 .commit() 311 .context("failed to commit chain persistence transaction")?; 312 Ok(()) 313 }) 314 } 315 316 pub fn clear_chain(&self) -> Result<()> { 317 self.with_connection_mut(|connection| { 318 let transaction = connection 319 .transaction() 320 .context("failed to start chain reset transaction")?; 321 transaction 322 .execute("DELETE FROM chain_snapshots", []) 323 .context("failed to delete chain snapshot")?; 324 transaction 325 .execute("DELETE FROM chain_verification", []) 326 .context("failed to delete chain verification status")?; 327 transaction 328 .execute("DELETE FROM pending_transactions_v2", []) 329 .context("failed to delete pending transactions v2")?; 330 transaction 331 .commit() 332 .context("failed to commit chain reset transaction")?; 333 Ok(()) 334 }) 335 } 336 337 fn with_connection<T>(&self, work: impl FnOnce(&Connection) -> Result<T>) -> Result<T> { 338 let connection = self.open_connection()?; 339 connection 340 .execute_batch( 341 r#" 342 PRAGMA busy_timeout = 5000; 343 PRAGMA synchronous = NORMAL; 344 "#, 345 ) 346 .context("failed to configure chain database connection")?; 347 work(&connection) 348 } 349 350 fn with_connection_mut<T>(&self, work: impl FnOnce(&mut Connection) -> Result<T>) -> Result<T> { 351 let mut connection = self.open_connection()?; 352 connection 353 .execute_batch( 354 r#" 355 PRAGMA journal_mode = WAL; 356 PRAGMA busy_timeout = 5000; 357 PRAGMA synchronous = NORMAL; 358 "#, 359 ) 360 .context("failed to configure chain database connection")?; 361 work(&mut connection) 362 } 363 364 fn open_connection(&self) -> Result<Connection> { 365 Connection::open(&self.path) 366 .with_context(|| format!("failed to open chain database {}", self.path.display())) 367 } 368 } 369 370 fn legacy_chain_reason(path: &Path) -> Result<Option<String>> { 371 if !path.exists() { 372 return Ok(None); 373 } 374 375 let connection = Connection::open(path) 376 .with_context(|| format!("failed to inspect chain database {}", path.display()))?; 377 connection 378 .execute_batch("PRAGMA busy_timeout = 5000;") 379 .context("failed to configure chain database inspection")?; 380 381 let table_exists = connection 382 .query_row( 383 "SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'chain_snapshots'", 384 [], 385 |_| Ok(()), 386 ) 387 .optional() 388 .context("failed to inspect chain database schema")? 389 .is_some(); 390 if !table_exists { 391 return Ok(None); 392 } 393 394 let mut columns = connection 395 .prepare("SELECT name FROM pragma_table_info('chain_snapshots')") 396 .context("failed to inspect chain snapshot columns")?; 397 let columns = columns 398 .query_map([], |row| row.get::<_, String>(0)) 399 .context("failed to read chain snapshot columns")? 400 .collect::<rusqlite::Result<Vec<_>>>() 401 .context("failed to read chain snapshot columns")?; 402 let has_snapshot_blob = columns.iter().any(|column| column == "snapshot_blob"); 403 if !has_snapshot_blob && columns.iter().any(|column| column == "snapshot_json") { 404 return Ok(Some("legacy JSON snapshot schema".to_string())); 405 } 406 if !has_snapshot_blob { 407 return Ok(None); 408 } 409 410 let snapshot_blob = connection 411 .query_row( 412 "SELECT snapshot_blob FROM chain_snapshots WHERE id = 1", 413 [], 414 |row| row.get::<_, Vec<u8>>(0), 415 ) 416 .optional() 417 .context("failed to inspect compact chain snapshot version")?; 418 Ok(snapshot_blob 419 .as_deref() 420 .and_then(legacy_compact_snapshot_version) 421 .map(|version| format!("compact snapshot version {version}"))) 422 } 423 424 fn archive_legacy_chain(path: &Path) -> Result<PathBuf> { 425 // Fold committed WAL contents into the database before moving the archive. 426 // Renaming any remaining sidecars first prevents them from being attached to 427 // the fresh database if startup is interrupted between the moves. 428 let connection = Connection::open(path) 429 .with_context(|| format!("failed to prepare legacy chain database {}", path.display()))?; 430 connection 431 .execute_batch("PRAGMA busy_timeout = 5000; PRAGMA wal_checkpoint(TRUNCATE);") 432 .context("failed to checkpoint legacy chain database before archiving")?; 433 drop(connection); 434 435 let archive_path = available_archive_path(path); 436 for suffix in ["-wal", "-shm"] { 437 let source = path_with_suffix(path, suffix); 438 if source.exists() { 439 let destination = path_with_suffix(&archive_path, suffix); 440 fs::rename(&source, &destination).with_context(|| { 441 format!( 442 "failed to archive legacy chain sidecar {} as {}", 443 source.display(), 444 destination.display() 445 ) 446 })?; 447 } 448 } 449 fs::rename(path, &archive_path).with_context(|| { 450 format!( 451 "failed to archive legacy chain database {} as {}", 452 path.display(), 453 archive_path.display() 454 ) 455 })?; 456 Ok(archive_path) 457 } 458 459 fn available_archive_path(path: &Path) -> PathBuf { 460 for index in 0_u64.. { 461 let suffix = if index == 0 { 462 ".pre-v6".to_string() 463 } else { 464 format!(".pre-v6.{index}") 465 }; 466 let candidate = path_with_suffix(path, &suffix); 467 if !candidate.exists() 468 && !path_with_suffix(&candidate, "-wal").exists() 469 && !path_with_suffix(&candidate, "-shm").exists() 470 { 471 return candidate; 472 } 473 } 474 unreachable!("archive suffix counter exhausted") 475 } 476 477 fn path_with_suffix(path: &Path, suffix: &str) -> PathBuf { 478 let mut value = path.as_os_str().to_os_string(); 479 value.push(suffix); 480 PathBuf::from(value) 481 } 482 483 fn snapshot_tip(snapshot: &ChainSnapshot) -> Option<(u64, String)> { 484 snapshot 485 .blocks 486 .last() 487 .map(|block| (block.height, block.hash.clone())) 488 } 489 490 fn revalidation_from_height(stored_ruleset: Option<&str>) -> Option<u64> { 491 match stored_ruleset { 492 Some(CURRENT_CONSENSUS_RULESET) => None, 493 Some(version) if LEGACY_EQUIVALENT_VERIFIER_VERSIONS.contains(&version) => None, 494 Some(ruleset) => CONSENSUS_RULESET_MIGRATIONS 495 .iter() 496 .find(|migration| migration.from_ruleset == ruleset) 497 .map(|migration| migration.revalidate_from_height) 498 // Unknown markers are untrusted. 499 .or(Some(1)), 500 // A missing marker means the snapshot was not persisted as validated. 501 None => Some(1), 502 } 503 } 504 505 fn unix_ms() -> u64 { 506 SystemTime::now() 507 .duration_since(UNIX_EPOCH) 508 .unwrap_or_default() 509 .as_millis() as u64 510 } 511 512 #[cfg(test)] 513 mod tests { 514 use std::{collections::BTreeMap, fs}; 515 516 use rusqlite::Connection; 517 use tempfile::tempdir; 518 519 use crate::domain::{ChainSnapshot, GenesisBurn, Ledger, Wallet}; 520 521 use super::{SCHEMA, SqliteChainStore}; 522 523 const LEGACY_JSON_SCHEMA: &str = r#" 524 CREATE TABLE chain_snapshots ( 525 id INTEGER PRIMARY KEY CHECK (id = 1), 526 height INTEGER NOT NULL, 527 tip_hash TEXT NOT NULL, 528 snapshot_json TEXT NOT NULL, 529 updated_at_ms INTEGER NOT NULL 530 ); 531 INSERT INTO chain_snapshots (id, height, tip_hash, snapshot_json, updated_at_ms) 532 VALUES (1, 0, 'legacy-tip', '{}', 0); 533 "#; 534 535 fn test_snapshot(seed: &str) -> ChainSnapshot { 536 let wallet = Wallet::from_seed(seed); 537 let mut allocations = BTreeMap::new(); 538 allocations.insert(wallet.address().to_string(), 1); 539 Ledger::new_with_genesis_burns(allocations, vec![GenesisBurn::new(wallet.address(), 1)], 1) 540 .unwrap() 541 .snapshot() 542 } 543 544 #[test] 545 fn open_archives_legacy_json_schema_and_creates_fresh_database() { 546 let dir = tempdir().unwrap(); 547 let path = dir.path().join("chain.sqlite3"); 548 Connection::open(&path) 549 .unwrap() 550 .execute_batch(LEGACY_JSON_SCHEMA) 551 .unwrap(); 552 553 let store = SqliteChainStore::open(&path).unwrap(); 554 555 assert!(store.load().unwrap().is_none()); 556 let archive = dir.path().join("chain.sqlite3.pre-v6"); 557 assert!(archive.exists()); 558 let legacy_json: String = Connection::open(archive) 559 .unwrap() 560 .query_row( 561 "SELECT snapshot_json FROM chain_snapshots WHERE id = 1", 562 [], 563 |row| row.get(0), 564 ) 565 .unwrap(); 566 assert_eq!(legacy_json, "{}"); 567 } 568 569 #[test] 570 fn open_archives_legacy_compact_snapshot_and_uses_unique_name() { 571 let dir = tempdir().unwrap(); 572 let path = dir.path().join("chain.sqlite3"); 573 let existing_archive = dir.path().join("chain.sqlite3.pre-v6"); 574 fs::write(&existing_archive, b"existing archive").unwrap(); 575 let connection = Connection::open(&path).unwrap(); 576 connection.execute_batch(SCHEMA).unwrap(); 577 let mut legacy_blob = b"IUNA-SNAPSHOT".to_vec(); 578 legacy_blob.push(5); 579 connection 580 .execute( 581 r#" 582 INSERT INTO chain_snapshots (id, height, tip_hash, snapshot_blob, updated_at_ms) 583 VALUES (1, 0, 'legacy-tip', ?1, 0) 584 "#, 585 [&legacy_blob], 586 ) 587 .unwrap(); 588 drop(connection); 589 590 let store = SqliteChainStore::open(&path).unwrap(); 591 592 assert!(store.load().unwrap().is_none()); 593 assert_eq!(fs::read(existing_archive).unwrap(), b"existing archive"); 594 let archived_blob: Vec<u8> = Connection::open(dir.path().join("chain.sqlite3.pre-v6.1")) 595 .unwrap() 596 .query_row( 597 "SELECT snapshot_blob FROM chain_snapshots WHERE id = 1", 598 [], 599 |row| row.get(0), 600 ) 601 .unwrap(); 602 assert_eq!(archived_blob, legacy_blob); 603 } 604 605 #[test] 606 fn open_does_not_archive_corrupt_or_future_compact_snapshots() { 607 for (name, blob) in [ 608 ("corrupt", vec![0, 1, 2, 3]), 609 ("future", [b"IUNA-SNAPSHOT".as_slice(), &[8]].concat()), 610 ] { 611 let dir = tempdir().unwrap(); 612 let path = dir.path().join(format!("{name}.sqlite3")); 613 let connection = Connection::open(&path).unwrap(); 614 connection.execute_batch(SCHEMA).unwrap(); 615 connection 616 .execute( 617 r#" 618 INSERT INTO chain_snapshots (id, height, tip_hash, snapshot_blob, updated_at_ms) 619 VALUES (1, 0, 'bad-tip', ?1, 0) 620 "#, 621 [&blob], 622 ) 623 .unwrap(); 624 drop(connection); 625 626 let store = SqliteChainStore::open(&path).unwrap(); 627 628 assert!(store.load().is_err()); 629 assert!(!dir.path().join(format!("{name}.sqlite3.pre-v6")).exists()); 630 } 631 } 632 633 #[test] 634 fn failed_snapshot_save_keeps_last_committed_chain() { 635 let dir = tempdir().unwrap(); 636 let store = SqliteChainStore::open(dir.path().join("chain.sqlite3")).unwrap(); 637 let snapshot = test_snapshot("chain-store-rollback"); 638 let tip = snapshot.blocks.last().unwrap().hash.clone(); 639 store.save(&snapshot).unwrap(); 640 641 let mut invalid = snapshot.clone(); 642 invalid.blocks.clear(); 643 let error = store.save(&invalid).unwrap_err(); 644 645 assert!(error.to_string().contains("cannot persist empty chain")); 646 let restored = store.load().unwrap().unwrap(); 647 assert_eq!(restored.blocks.last().unwrap().hash, tip); 648 } 649 650 #[test] 651 fn pending_transaction_v2_journal_preserves_order_and_clears_with_chain() { 652 let dir = tempdir().unwrap(); 653 let store = SqliteChainStore::open(dir.path().join("chain.sqlite3")).unwrap(); 654 store 655 .save_pending_transaction_v2("tx-b", "envelope-b") 656 .unwrap(); 657 store 658 .save_pending_transaction_v2("tx-a", "envelope-a") 659 .unwrap(); 660 661 assert_eq!( 662 store.load_pending_transactions_v2().unwrap(), 663 vec![ 664 ("tx-b".to_string(), "envelope-b".to_string()), 665 ("tx-a".to_string(), "envelope-a".to_string()), 666 ] 667 ); 668 669 store 670 .replace_pending_transactions_v2(&[("tx-a".to_string(), "replacement-a".to_string())]) 671 .unwrap(); 672 assert_eq!( 673 store.load_pending_transactions_v2().unwrap(), 674 vec![("tx-a".to_string(), "replacement-a".to_string())] 675 ); 676 677 store.clear_chain().unwrap(); 678 assert!(store.load_pending_transactions_v2().unwrap().is_empty()); 679 } 680 681 #[test] 682 fn load_rejects_snapshot_that_does_not_match_stored_tip_metadata() { 683 let dir = tempdir().unwrap(); 684 let store = SqliteChainStore::open(dir.path().join("chain.sqlite3")).unwrap(); 685 store 686 .save(&test_snapshot("chain-store-tip-integrity")) 687 .unwrap(); 688 store 689 .with_connection_mut(|connection| { 690 connection.execute( 691 "UPDATE chain_snapshots SET tip_hash = ?1 WHERE id = 1", 692 ["0".repeat(64)], 693 )?; 694 Ok(()) 695 }) 696 .unwrap(); 697 698 let error = store.load().unwrap_err(); 699 assert!( 700 error 701 .to_string() 702 .contains("snapshot tip does not match database metadata") 703 ); 704 } 705 706 #[test] 707 fn verified_snapshot_is_trusted_only_for_current_ruleset_and_tip() { 708 let dir = tempdir().unwrap(); 709 let store = SqliteChainStore::open(dir.path().join("chain.sqlite3")).unwrap(); 710 let snapshot = test_snapshot("chain-store-verification-status"); 711 store.save_verified(&snapshot).unwrap(); 712 713 let loaded = store.load_with_verification_status().unwrap().unwrap(); 714 assert_eq!(loaded.revalidation_from_height, None); 715 716 store 717 .with_connection_mut(|connection| { 718 connection.execute( 719 "UPDATE chain_verification SET verifier_version = 'previous-version'", 720 [], 721 )?; 722 Ok(()) 723 }) 724 .unwrap(); 725 let loaded = store.load_with_verification_status().unwrap().unwrap(); 726 assert_eq!(loaded.revalidation_from_height, Some(1)); 727 728 store 729 .with_connection_mut(|connection| { 730 connection.execute( 731 "UPDATE chain_verification SET verifier_version = ?1, tip_hash = 'other-tip'", 732 [super::CURRENT_CONSENSUS_RULESET], 733 )?; 734 Ok(()) 735 }) 736 .unwrap(); 737 let loaded = store.load_with_verification_status().unwrap().unwrap(); 738 assert_eq!(loaded.revalidation_from_height, Some(1)); 739 } 740 741 #[test] 742 fn ordinary_save_invalidates_previous_verification_status() { 743 let dir = tempdir().unwrap(); 744 let store = SqliteChainStore::open(dir.path().join("chain.sqlite3")).unwrap(); 745 let snapshot = test_snapshot("chain-store-unverified-save"); 746 store.save_verified(&snapshot).unwrap(); 747 assert!( 748 store 749 .load_with_verification_status() 750 .unwrap() 751 .unwrap() 752 .revalidation_from_height 753 .is_none() 754 ); 755 756 store.save(&snapshot).unwrap(); 757 758 assert!(store.contains_chain().unwrap()); 759 assert!( 760 store 761 .load_with_verification_status() 762 .unwrap() 763 .unwrap() 764 .revalidation_from_height 765 .is_some() 766 ); 767 } 768 769 #[test] 770 fn opening_database_without_verification_table_migrates_as_untrusted() { 771 let dir = tempdir().unwrap(); 772 let path = dir.path().join("chain.sqlite3"); 773 let store = SqliteChainStore::open(&path).unwrap(); 774 let snapshot = test_snapshot("chain-store-verification-migration"); 775 store.save_verified(&snapshot).unwrap(); 776 drop(store); 777 Connection::open(&path) 778 .unwrap() 779 .execute("DROP TABLE chain_verification", []) 780 .unwrap(); 781 782 let reopened = SqliteChainStore::open(&path).unwrap(); 783 let loaded = reopened.load_with_verification_status().unwrap().unwrap(); 784 785 assert_eq!(loaded.snapshot, snapshot); 786 assert_eq!(loaded.revalidation_from_height, Some(1)); 787 } 788 789 #[test] 790 fn v0410_verification_marker_migrates_without_historical_revalidation() { 791 let dir = tempdir().unwrap(); 792 let store = SqliteChainStore::open(dir.path().join("chain.sqlite3")).unwrap(); 793 let snapshot = test_snapshot("chain-store-legacy-ruleset-marker"); 794 store.save_verified(&snapshot).unwrap(); 795 store 796 .with_connection_mut(|connection| { 797 connection.execute( 798 "UPDATE chain_verification SET verifier_version = '0.4.10'", 799 [], 800 )?; 801 Ok(()) 802 }) 803 .unwrap(); 804 805 let loaded = store.load_with_verification_status().unwrap().unwrap(); 806 807 assert_eq!(loaded.revalidation_from_height, None); 808 } 809 }