iuna

iuna

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

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 }