diff --git a/src/database/initialization.rs b/src/database/initialization.rs index 42805033d..4f3e2d265 100644 --- a/src/database/initialization.rs +++ b/src/database/initialization.rs @@ -4,6 +4,37 @@ use rusqlite::{Connection, params}; use std::fs; use std::path::Path; +/// Error during database migration with structured context. +#[derive(Debug, thiserror::Error)] +#[error("migration failed on {}{}: {source}", + table.as_deref().unwrap_or("(unknown table)"), + if details.is_empty() { String::new() } else { format!(" ({})", details) } +)] +pub struct MigrationError { + /// Table being operated on when the error occurred, if known. + pub table: Option, + /// Human-readable description of the operation that failed. + pub details: String, + /// Underlying SQLite error. + #[source] + pub source: rusqlite::Error, +} + +/// Extension trait for converting `rusqlite::Result` into `MigrationError` with table context. +trait MigrationResultExt { + fn migration_err(self, table: &str, details: &str) -> Result; +} + +impl MigrationResultExt for rusqlite::Result { + fn migration_err(self, table: &str, details: &str) -> Result { + self.map_err(|e| MigrationError { + table: Some(table.into()), + details: details.into(), + source: e, + }) + } +} + pub const DEFAULT_DB_VERSION: u16 = 33; pub const DEFAULT_NETWORK: &str = "mainnet"; @@ -24,7 +55,6 @@ impl Database { if settings_exists { self.ensure_settings_columns_exist(&conn)?; } - self.ensure_wallet_columns_exist(&conn)?; } // Check if this is the first time setup by looking for entries in the settings table. @@ -32,7 +62,8 @@ impl Database { self.create_tables()?; self.set_default_version()?; } else { - // If outdated, back up and either migrate or recreate the database. + self.run_consistency_checks(); + let current_version = self.db_schema_version()?; if current_version != DEFAULT_DB_VERSION { self.backup_db(db_file_path)?; @@ -49,7 +80,7 @@ impl Database { Ok(()) } - fn apply_version_changes(&self, version: u16, tx: &Connection) -> rusqlite::Result<()> { + fn apply_version_changes(&self, version: u16, tx: &Connection) -> Result<(), MigrationError> { match version { // Versions 28-32 were consolidated into v33 to resolve migration // numbering conflicts between the zk and v1.0-dev branches. @@ -60,100 +91,166 @@ impl Database { // Every sub-migration is idempotent (IF NOT EXISTS / column checks), // so this is safe to run on any DB that already applied some or all // of the individual steps. - self.add_core_wallet_name_column(tx)?; - self.init_contacts_tables(tx)?; - self.create_shielded_tables(tx)?; - self.create_shielded_wallet_meta_table(tx)?; - self.add_nullifier_sync_timestamp_column(tx)?; + self.clean_orphaned_fk_rows(tx)?; + self.add_core_wallet_name_column(tx) + .migration_err("wallet", "add core_wallet_name column")?; + self.init_contacts_tables(tx) + .migration_err("contact_private_info", "create contacts tables")?; + self.create_shielded_tables(tx) + .migration_err("shielded_notes", "create shielded tables")?; + self.create_shielded_wallet_meta_table(tx) + .migration_err("shielded_wallet_meta", "create shielded_wallet_meta table")?; + self.add_nullifier_sync_timestamp_column(tx).migration_err( + "shielded_wallet_meta", + "add last_nullifier_sync_timestamp column", + )?; + // Defer FK checks so parent->child rename order doesn't matter + // (contestant and token have composite FKs that include network). + tx.execute_batch("PRAGMA defer_foreign_keys = ON") + .map_err(|e| MigrationError { + table: None, + details: "defer FK checks for network rename".into(), + source: e, + })?; self.rename_network_dash_to_mainnet(tx)?; - self.add_wallet_transaction_status_column(tx)?; + self.add_wallet_transaction_status_column(tx) + .migration_err("wallet_transactions", "add status column")?; } 27 => { - self.add_network_indexes(tx)?; + self.add_network_indexes(tx).map_err(|e| MigrationError { + table: None, + details: "add network indexes".into(), + source: e, + })?; } 26 => { - self.add_last_full_sync_balance_column(tx)?; + self.add_last_full_sync_balance_column(tx).migration_err( + "platform_address_balances", + "add last_full_sync_balance column", + )?; } 25 => { - self.add_avatar_bytes_column(tx)?; + self.add_avatar_bytes_column(tx) + .migration_err("dashpay_profiles", "add avatar_bytes column")?; } 24 => { - self.add_selected_wallet_columns(tx)?; + self.add_selected_wallet_columns(tx) + .migration_err("settings", "add selected_wallet columns")?; } 23 => { - self.add_last_terminal_block_column(tx)?; + self.add_last_terminal_block_column(tx) + .migration_err("wallet", "add last_terminal_block column")?; } 22 => { - self.add_network_column_to_dashpay_contact_requests(tx)?; - self.add_network_column_to_dashpay_contacts(tx)?; + self.add_network_column_to_dashpay_contact_requests(tx) + .migration_err("dashpay_contact_requests", "add network column")?; + self.add_network_column_to_dashpay_contacts(tx) + .migration_err("dashpay_contacts", "add network column")?; } 21 => { - self.add_network_column_to_dashpay_profiles(tx)?; + self.add_network_column_to_dashpay_profiles(tx) + .migration_err("dashpay_profiles", "add network column")?; } 20 => { - self.add_platform_sync_columns(tx)?; + self.add_platform_sync_columns(tx) + .migration_err("wallet", "add platform sync columns")?; } 19 => { - self.initialize_platform_address_balances_table(tx)?; + self.initialize_platform_address_balances_table(tx) + .migration_err("platform_address_balances", "create table")?; } 18 => { - self.initialize_single_key_wallet_table(tx)?; + self.initialize_single_key_wallet_table(tx) + .migration_err("single_key_wallet", "create table")?; } 17 => { - self.add_address_total_received_column(tx)?; + self.add_address_total_received_column(tx) + .migration_err("wallet_addresses", "add total_received column")?; } 16 => { - self.add_wallet_balance_columns(tx)?; + self.add_wallet_balance_columns(tx) + .migration_err("wallet", "add balance columns")?; } 15 => { - self.add_core_backend_mode_column(tx)?; + self.add_core_backend_mode_column(tx) + .migration_err("settings", "add core_backend_mode column")?; } 14 => { - self.initialize_wallet_transactions_table(tx)?; + self.initialize_wallet_transactions_table(tx) + .migration_err("wallet_transactions", "create table")?; } 13 => { - // Add DashPay tables in version 12 - self.init_dashpay_tables_in_tx(tx)?; + self.init_dashpay_tables_in_tx(tx) + .migration_err("dashpay_profiles", "create DashPay tables")?; + } + 12 => { + self.add_disable_zmq_column(tx) + .migration_err("settings", "add disable_zmq column")?; + } + 11 => { + self.rename_identity_column_is_in_creation_to_status(tx) + .migration_err("identity", "rename is_in_creation to status")?; } - 12 => self.add_disable_zmq_column(tx)?, - 11 => self.rename_identity_column_is_in_creation_to_status(tx)?, 10 => { - self.add_theme_preference_column(tx)?; + self.add_theme_preference_column(tx) + .migration_err("settings", "add theme_preference column")?; } 9 => { - self.delete_all_identities_in_all_devnets_and_regtest(tx)?; - self.delete_all_local_tokens_in_all_devnets_and_regtest(tx)?; - self.remove_all_asset_locks_identity_id_for_all_devnets_and_regtest(tx)?; - self.remove_all_contracts_in_all_devnets_and_regtest(tx)?; - self.fix_identity_devnet_network_name(tx)?; + self.delete_all_identities_in_all_devnets_and_regtest(tx) + .migration_err("identity", "delete devnet/regtest identities")?; + self.delete_all_local_tokens_in_all_devnets_and_regtest(tx) + .migration_err("token", "delete devnet/regtest tokens")?; + self.remove_all_asset_locks_identity_id_for_all_devnets_and_regtest(tx) + .migration_err( + "asset_lock_transaction", + "clear devnet/regtest asset lock identity IDs", + )?; + self.remove_all_contracts_in_all_devnets_and_regtest(tx) + .migration_err("contract", "delete devnet/regtest contracts")?; + self.fix_identity_devnet_network_name(tx) + .migration_err("identity", "fix devnet network name")?; } 8 => { - self.change_contract_name_to_alias(tx)?; + self.change_contract_name_to_alias(tx) + .migration_err("contract", "rename name to alias")?; } 7 => { - self.migrate_asset_lock_fk_to_set_null(tx)?; + self.migrate_asset_lock_fk_to_set_null(tx) + .migration_err("asset_lock_transaction", "migrate FK to SET NULL")?; } 6 => { - self.update_scheduled_votes_table(tx)?; - self.initialize_token_table(tx)?; - self.drop_identity_token_balances_table(tx)?; - self.initialize_identity_token_balances_table(tx)?; - tx.execute("DROP TABLE IF EXISTS identity_order", [])?; - self.initialize_identity_order_table(tx)?; - tx.execute("DROP TABLE IF EXISTS token_order", [])?; - self.initialize_token_order_table(tx)?; + self.update_scheduled_votes_table(tx) + .migration_err("scheduled_votes", "update table schema")?; + self.initialize_token_table(tx) + .migration_err("token", "create table")?; + self.drop_identity_token_balances_table(tx) + .migration_err("identity_token_balances", "drop table")?; + self.initialize_identity_token_balances_table(tx) + .migration_err("identity_token_balances", "create table")?; + tx.execute("DROP TABLE IF EXISTS identity_order", []) + .migration_err("identity_order", "drop table")?; + self.initialize_identity_order_table(tx) + .migration_err("identity_order", "create table")?; + tx.execute("DROP TABLE IF EXISTS token_order", []) + .migration_err("token_order", "drop table")?; + self.initialize_token_order_table(tx) + .migration_err("token_order", "create table")?; } 5 => { - self.initialize_scheduled_votes_table(tx)?; + self.initialize_scheduled_votes_table(tx) + .migration_err("scheduled_votes", "create table")?; } 4 => { - self.initialize_top_up_table(tx)?; + self.initialize_top_up_table(tx) + .migration_err("top_up", "create table")?; } 3 => { - self.add_custom_dash_qt_columns(tx)?; + self.add_custom_dash_qt_columns(tx) + .migration_err("settings", "add custom dash_qt columns")?; } 2 => { - self.initialize_proof_log_table(tx)?; + self.initialize_proof_log_table(tx) + .migration_err("proof_log", "create table")?; } _ => { tracing::warn!("No database changes for version {}", version); @@ -179,7 +276,7 @@ impl Database { &self, original_version: u16, to_version: u16, - ) -> Result { + ) -> Result { match original_version.cmp(&to_version) { std::cmp::Ordering::Equal => { tracing::trace!( @@ -188,10 +285,14 @@ impl Database { ); Ok(false) } - std::cmp::Ordering::Greater => Err(format!( - "Database schema version {} is too new, max supported version: {}. Please update dash-evo-tool.", - original_version, to_version - )), + std::cmp::Ordering::Greater => Err(MigrationError { + table: None, + details: format!( + "database is at version {original_version} but this build \ + only supports up to version {to_version} — please update dash-evo-tool" + ), + source: rusqlite::Error::InvalidQuery, + }), std::cmp::Ordering::Less => { let mut conn = self .conn @@ -199,12 +300,37 @@ impl Database { .expect("Failed to lock database connection"); for version in (original_version + 1)..=to_version { - let tx = conn.transaction().map_err(|e| e.to_string())?; - self.apply_version_changes(version, &tx) - .map_err(|e| e.to_string())?; - self.update_database_version(version, &tx) - .map_err(|e| e.to_string())?; - tx.commit().map_err(|e| e.to_string())?; + tracing::debug!("Applying migration v{version}"); + let tx = conn.transaction().map_err(|e| MigrationError { + table: None, + details: format!("v{version}: begin transaction"), + source: e, + })?; + let result = self + .apply_version_changes(version, &tx) + .and_then(|()| { + self.update_database_version(version, &tx).migration_err( + "settings", + &format!("v{version}: update_database_version"), + ) + }) + .and_then(|()| { + tx.commit().map_err(|e| MigrationError { + table: None, + details: format!("v{version}: commit"), + source: e, + }) + }); + + if let Err(ref migration_err) = result { + if let rusqlite::Error::SqliteFailure(err, _) = &migration_err.source + && err.extended_code == 787 + { + // SQLITE_CONSTRAINT_FOREIGNKEY + Self::log_fk_violations(&conn); + } + return result.map(|()| true); + } } Ok(true) } @@ -939,12 +1065,248 @@ impl Database { // Shielded table helpers (create_shielded_tables, create_shielded_wallet_meta_table, // add_nullifier_sync_timestamp_column) are implemented in database/shielded.rs. + /// Remove orphaned child rows left behind when parent rows were deleted + /// while FK enforcement was off (system SQLite before bundled build). + /// Bundled SQLite enables FK checks by default, so any subsequent UPDATE + /// on these rows triggers re-validation and fails. Covers all FK + /// relationships in the schema: wallet→children, identity→children, + /// token→children, contract→children, contested_name→children. + fn clean_orphaned_fk_rows(&self, conn: &Connection) -> Result<(), MigrationError> { + // --- CASCADE children of wallet(seed_hash) --- + let wallet_fk_delete: &[(&str, &str)] = &[ + ("wallet_addresses", "seed_hash"), + ("wallet_transactions", "seed_hash"), + ("platform_address_balances", "seed_hash"), + ("shielded_notes", "wallet_seed_hash"), + ("shielded_wallet_meta", "wallet_seed_hash"), + ("asset_lock_transaction", "wallet"), + ]; + for (table, fk_col) in wallet_fk_delete { + if self + .table_exists(conn, table) + .migration_err(table, "check table existence")? + { + let deleted = conn + .execute( + &format!( + "DELETE FROM {table} WHERE {fk_col} NOT IN (SELECT seed_hash FROM wallet)" + ), + [], + ) + .migration_err(table, "delete orphaned wallet FK rows")?; + if deleted > 0 { + tracing::info!( + "Cleaned {deleted} orphaned row(s) from {table} (missing wallet)" + ); + } + } + } + + // identity.wallet is nullable with ON DELETE CASCADE — delete orphaned + // identities whose wallet no longer exists (but skip NULL wallet). + if self + .table_exists(conn, "identity") + .migration_err("identity", "check table existence")? + { + let deleted = conn + .execute( + "DELETE FROM identity WHERE wallet IS NOT NULL + AND wallet NOT IN (SELECT seed_hash FROM wallet)", + [], + ) + .migration_err("identity", "delete orphaned identity rows")?; + if deleted > 0 { + tracing::info!("Cleaned {deleted} orphaned identity row(s) (missing wallet)"); + } + } + + // --- CASCADE children of identity(id) --- + let identity_fk_delete: &[(&str, &str)] = &[ + ("top_up", "identity_id"), + ("scheduled_votes", "identity_id"), + ("identity_order", "identity_id"), + ("identity_token_balances", "identity_id"), + ("token_order", "identity_id"), + ]; + for (table, fk_col) in identity_fk_delete { + if self + .table_exists(conn, table) + .migration_err(table, "check table existence")? + { + let deleted = conn + .execute( + &format!( + "DELETE FROM {table} WHERE {fk_col} NOT IN (SELECT id FROM identity)" + ), + [], + ) + .migration_err(table, "delete orphaned identity FK rows")?; + if deleted > 0 { + tracing::info!( + "Cleaned {deleted} orphaned row(s) from {table} (missing identity)" + ); + } + } + } + + // --- SET NULL children of identity(id) --- + if self + .table_exists(conn, "asset_lock_transaction") + .migration_err("asset_lock_transaction", "check table existence")? + { + conn.execute( + "UPDATE asset_lock_transaction SET identity_id = NULL + WHERE identity_id IS NOT NULL + AND identity_id NOT IN (SELECT id FROM identity)", + [], + ) + .migration_err("asset_lock_transaction", "nullify orphaned identity_id")?; + conn.execute( + "UPDATE asset_lock_transaction SET identity_id_potentially_in_creation = NULL + WHERE identity_id_potentially_in_creation IS NOT NULL + AND identity_id_potentially_in_creation NOT IN (SELECT id FROM identity)", + [], + ) + .migration_err( + "asset_lock_transaction", + "nullify orphaned identity_id_potentially_in_creation", + )?; + } + + // --- CASCADE children of token(id) --- + if self + .table_exists(conn, "identity_token_balances") + .migration_err("identity_token_balances", "check table existence")? + && self + .table_exists(conn, "token") + .migration_err("token", "check table existence")? + { + conn.execute( + "DELETE FROM identity_token_balances + WHERE token_id NOT IN (SELECT id FROM token)", + [], + ) + .migration_err("identity_token_balances", "delete orphaned token FK rows")?; + } + if self + .table_exists(conn, "token_order") + .migration_err("token_order", "check table existence")? + && self + .table_exists(conn, "token") + .migration_err("token", "check table existence")? + { + conn.execute( + "DELETE FROM token_order WHERE token_id NOT IN (SELECT id FROM token)", + [], + ) + .migration_err("token_order", "delete orphaned token FK rows")?; + } + + // --- CASCADE children of contract --- + if self + .table_exists(conn, "token") + .migration_err("token", "check table existence")? + && self + .table_exists(conn, "contract") + .migration_err("contract", "check table existence")? + { + conn.execute( + "DELETE FROM token WHERE (data_contract_id, network) + NOT IN (SELECT contract_id, network FROM contract)", + [], + ) + .migration_err("token", "delete orphaned contract FK rows")?; + } + + // --- CASCADE children of contested_name --- + if self + .table_exists(conn, "contestant") + .migration_err("contestant", "check table existence")? + && self + .table_exists(conn, "contested_name") + .migration_err("contested_name", "check table existence")? + { + conn.execute( + "DELETE FROM contestant + WHERE (normalized_contested_name, network) + NOT IN (SELECT normalized_contested_name, network FROM contested_name)", + [], + ) + .migration_err("contestant", "delete orphaned contested_name FK rows")?; + } + + Ok(()) + } + + /// Log all FK violations to help diagnose SQLITE_CONSTRAINT_FOREIGNKEY errors. + fn log_fk_violations(conn: &Connection) { + const MAX_VIOLATIONS_TO_LOG: usize = 50; + + tracing::error!( + "FK constraint failure detected — running PRAGMA foreign_key_check for diagnostics:" + ); + let Ok(mut stmt) = conn.prepare("PRAGMA foreign_key_check") else { + tracing::error!(" failed to prepare PRAGMA foreign_key_check"); + return; + }; + let Ok(rows) = stmt.query_map([], |row| { + Ok(( + row.get::<_, String>(0)?, + row.get::<_, i64>(1)?, + row.get::<_, String>(2)?, + row.get::<_, i64>(3)?, + )) + }) else { + tracing::error!(" failed to execute PRAGMA foreign_key_check"); + return; + }; + + let mut count = 0usize; + let mut errors = 0usize; + for row in rows { + match row { + Ok((table, rowid, parent, fk_idx)) => { + count += 1; + if count <= MAX_VIOLATIONS_TO_LOG { + tracing::error!( + " FK violation: {table} rowid={rowid} -> {parent} (fk_index={fk_idx})" + ); + } + } + Err(e) => { + errors += 1; + if errors <= 3 { + tracing::error!(" FK check row decode error: {e}"); + } + } + } + } + if count > MAX_VIOLATIONS_TO_LOG { + tracing::error!( + " ... and {} more violation(s) not shown", + count - MAX_VIOLATIONS_TO_LOG + ); + } + if count == 0 && errors == 0 { + tracing::error!(" no violations found (failure may be from deferred FK check)"); + } + } + + /// Check if a table exists in the database. + fn table_exists(&self, conn: &Connection, table: &str) -> rusqlite::Result { + conn.query_row( + "SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type='table' AND name=?1)", + [table], + |row| row.get(0), + ) + } + /// Migration 29: rename network value `"dash"` to `"mainnet"` in all tables. /// /// Upstream `dashcore` renamed `Network::Dash` to `Network::Mainnet`, /// changing the `Display`/`FromStr` representation. This migration updates /// every table that stores the network as a string column. - fn rename_network_dash_to_mainnet(&self, conn: &Connection) -> rusqlite::Result<()> { + fn rename_network_dash_to_mainnet(&self, conn: &Connection) -> Result<(), MigrationError> { let tables = [ "settings", "wallet", @@ -967,13 +1329,122 @@ impl Database { "shielded_wallet_meta", ]; for table in tables { + tracing::debug!(" rename_network: updating {table}"); conn.execute( &format!("UPDATE {table} SET network = 'mainnet' WHERE network = 'dash'"), [], - )?; + ) + .migration_err(table, "rename network dash -> mainnet")?; } Ok(()) } + + /// Run database consistency checks on startup. + /// Non-fatal: logs warnings for any issues found but does not fail. + fn run_consistency_checks(&self) { + const MAX_ISSUES_TO_LOG: usize = 20; + + let conn = self.conn.lock().unwrap(); + + // PRAGMA quick_check can return multiple rows (one per issue). + match conn.prepare("PRAGMA quick_check") { + Ok(mut stmt) => match stmt + .query_map([], |row| row.get::<_, String>(0)) + .and_then(|rows| rows.collect::>>()) + { + Ok(results) if results.len() == 1 && results[0] == "ok" => { + tracing::debug!("Database quick_check passed"); + } + Ok(results) if results.is_empty() => { + tracing::warn!("Database quick_check returned no results"); + } + Ok(results) => { + tracing::warn!("Database quick_check found {} issue(s):", results.len()); + for issue in results.iter().take(MAX_ISSUES_TO_LOG) { + tracing::warn!(" {issue}"); + } + if results.len() > MAX_ISSUES_TO_LOG { + tracing::warn!( + " ... and {} more issue(s) not shown", + results.len() - MAX_ISSUES_TO_LOG + ); + } + } + Err(e) => { + tracing::warn!("Database quick_check failed: {e}"); + } + }, + Err(e) => { + tracing::warn!("Database quick_check failed to prepare: {e}"); + } + } + + // PRAGMA foreign_key_check returns one row per FK violation. + match conn.prepare("PRAGMA foreign_key_check") { + Ok(mut stmt) => { + match stmt.query_map([], |row| { + Ok(( + row.get::<_, String>(0)?, + row.get::<_, i64>(1)?, + row.get::<_, String>(2)?, + row.get::<_, i64>(3)?, + )) + }) { + Ok(rows) => { + let mut violations = Vec::new(); + let mut row_errors = 0usize; + for row in rows { + match row { + Ok(v) => violations.push(v), + Err(e) => { + row_errors += 1; + if row_errors <= 3 { + tracing::warn!( + "Database foreign_key_check row decode error: {e}" + ); + } + } + } + } + if violations.is_empty() && row_errors == 0 { + tracing::debug!("Database foreign_key_check passed — no violations"); + } else { + if !violations.is_empty() { + tracing::warn!( + "Database foreign_key_check found {} violation(s):", + violations.len() + ); + for (table, rowid, parent, fk_idx) in + violations.iter().take(MAX_ISSUES_TO_LOG) + { + tracing::warn!( + " FK violation: {table} rowid={rowid} -> {parent} (fk_index={fk_idx})" + ); + } + if violations.len() > MAX_ISSUES_TO_LOG { + tracing::warn!( + " ... and {} more violation(s) not shown", + violations.len() - MAX_ISSUES_TO_LOG + ); + } + } + if row_errors > 0 { + tracing::warn!( + "Database foreign_key_check had {row_errors} row decode error(s)" + ); + } + } + } + Err(e) => { + tracing::warn!("Database foreign_key_check query failed: {e}"); + } + } + } + Err(e) => { + tracing::warn!("Database foreign_key_check failed to prepare: {e}"); + } + } + } } #[cfg(test)] @@ -1267,4 +1738,656 @@ mod test { let conn = db.conn.lock().unwrap(); assert_v33_schema(&conn); } + + #[test] + fn test_v33_migration_with_orphaned_fk_rows() { + let temp_dir = tempfile::tempdir().unwrap(); + let db_file_path = temp_dir.path().join("orphans.db"); + let db = super::Database::new(&db_file_path).unwrap(); + + // Build full schema at current version + db.create_tables().unwrap(); + db.set_default_version().unwrap(); + + let valid_seed_hash = vec![0xAAu8; 32]; + let orphan_seed_hash = vec![0xBBu8; 32]; + + { + let conn = db.conn.lock().unwrap(); + + // Insert a real wallet with the old network name + conn.execute( + "INSERT INTO wallet ( + seed_hash, encrypted_seed, salt, nonce, + master_ecdsa_bip44_account_0_epk, uses_password, network + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)", + params![ + valid_seed_hash, + vec![1u8; 16], + vec![2u8; 16], + vec![3u8; 12], + vec![4u8; 33], + 0, + "dash" + ], + ) + .unwrap(); + + // Disable FK enforcement to simulate legacy system SQLite + conn.execute_batch("PRAGMA foreign_keys = OFF").unwrap(); + + // Insert orphaned wallet_transactions row (seed_hash not in wallet table). + // Shielded table orphans are not needed: those tables get dropped to + // simulate v27, then recreated empty by the migration. + conn.execute( + "INSERT INTO wallet_transactions ( + seed_hash, txid, network, timestamp, net_amount, + is_ours, raw_transaction, status + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)", + params![ + orphan_seed_hash, + vec![0xCCu8; 32], + "dash", + 1000, + -50000, + 1, + vec![0u8; 100], + 0 + ], + ) + .unwrap(); + + // Insert valid wallet_transactions row for the real wallet + conn.execute( + "INSERT INTO wallet_transactions ( + seed_hash, txid, network, timestamp, net_amount, + is_ours, raw_transaction, status + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)", + params![ + valid_seed_hash, + vec![0xDDu8; 32], + "dash", + 2000, + 100000, + 1, + vec![1u8; 100], + 0 + ], + ) + .unwrap(); + + // Insert orphaned wallet_addresses row + conn.execute( + "INSERT INTO wallet_addresses ( + seed_hash, address, derivation_path, balance, + path_reference, path_type + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6)", + params![orphan_seed_hash, "yOrphanAddr1", "m/44'/1'/0'/0/0", 0, 0, 0], + ) + .unwrap(); + + // Insert valid wallet_addresses row + conn.execute( + "INSERT INTO wallet_addresses ( + seed_hash, address, derivation_path, balance, + path_reference, path_type + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6)", + params![ + valid_seed_hash, + "yValidAddr1", + "m/44'/1'/0'/0/0", + 1000, + 0, + 0 + ], + ) + .unwrap(); + + // Insert a real identity for the valid wallet + let valid_identity_id = vec![0xEEu8; 32]; + let orphan_identity_id = vec![0xFFu8; 32]; + conn.execute( + "INSERT INTO identity (id, is_local, identity_type, alias, network) + VALUES (?1, 1, 'user', 'test', 'dash')", + params![valid_identity_id], + ) + .unwrap(); + + // Insert asset_lock_transaction referencing a deleted identity + conn.execute( + "INSERT INTO asset_lock_transaction ( + tx_id, transaction_data, amount, identity_id, wallet, network + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6)", + params![ + vec![0xA1u8; 32], + vec![0u8; 50], + 100_000, + orphan_identity_id, + valid_seed_hash, + "dash" + ], + ) + .unwrap(); + + // Insert asset_lock_transaction referencing a valid identity + conn.execute( + "INSERT INTO asset_lock_transaction ( + tx_id, transaction_data, amount, identity_id, wallet, network + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6)", + params![ + vec![0xA2u8; 32], + vec![1u8; 50], + 200_000, + valid_identity_id, + valid_seed_hash, + "dash" + ], + ) + .unwrap(); + + // Strip v28+ additions to simulate v27 state (same as test_v33_migration_from_v27) + // Remove shielded tables — they'll be recreated by migration + conn.execute("DROP TABLE IF EXISTS shielded_notes", []) + .unwrap(); + conn.execute("DROP TABLE IF EXISTS shielded_wallet_meta", []) + .unwrap(); + conn.execute("DROP TABLE IF EXISTS contact_private_info", []) + .unwrap(); + + // Recreate wallet without core_wallet_name + conn.execute_batch( + "CREATE TABLE wallet_old AS SELECT + seed_hash, encrypted_seed, salt, nonce, + master_ecdsa_bip44_account_0_epk, alias, is_main, + uses_password, password_hint, network, + confirmed_balance, unconfirmed_balance, total_balance, + last_platform_full_sync, last_platform_sync_checkpoint, + last_terminal_block + FROM wallet; + DROP TABLE wallet; + CREATE TABLE wallet ( + seed_hash BLOB NOT NULL PRIMARY KEY, + encrypted_seed BLOB NOT NULL, + salt BLOB NOT NULL, + nonce BLOB NOT NULL, + master_ecdsa_bip44_account_0_epk BLOB NOT NULL, + alias TEXT, + is_main INTEGER, + uses_password INTEGER NOT NULL, + password_hint TEXT, + network TEXT NOT NULL, + confirmed_balance INTEGER DEFAULT 0, + unconfirmed_balance INTEGER DEFAULT 0, + total_balance INTEGER DEFAULT 0, + last_platform_full_sync INTEGER DEFAULT 0, + last_platform_sync_checkpoint INTEGER DEFAULT 0, + last_terminal_block INTEGER DEFAULT 0 + ); + INSERT INTO wallet SELECT * FROM wallet_old; + DROP TABLE wallet_old;", + ) + .unwrap(); + + // Recreate wallet_transactions without status but WITH FK constraint, + // preserving orphaned rows (FK enforcement is still OFF). + conn.execute_batch( + "CREATE TABLE wallet_transactions_old AS SELECT + seed_hash, txid, network, timestamp, height, block_hash, + net_amount, fee, label, is_ours, raw_transaction + FROM wallet_transactions; + DROP TABLE wallet_transactions; + CREATE TABLE wallet_transactions ( + seed_hash BLOB NOT NULL, + txid BLOB NOT NULL, + network TEXT NOT NULL, + timestamp INTEGER NOT NULL, + height INTEGER, + block_hash BLOB, + net_amount INTEGER NOT NULL, + fee INTEGER, + label TEXT, + is_ours INTEGER NOT NULL, + raw_transaction BLOB NOT NULL, + PRIMARY KEY (seed_hash, txid, network), + FOREIGN KEY (seed_hash) REFERENCES wallet(seed_hash) ON DELETE CASCADE + ); + INSERT INTO wallet_transactions SELECT * FROM wallet_transactions_old; + DROP TABLE wallet_transactions_old;", + ) + .unwrap(); + + // Recreate single_key_wallet without core_wallet_name + conn.execute_batch( + "DROP TABLE IF EXISTS single_key_wallet; + CREATE TABLE single_key_wallet ( + key_hash BLOB NOT NULL PRIMARY KEY, + encrypted_private_key BLOB NOT NULL, + salt BLOB NOT NULL, + nonce BLOB NOT NULL, + public_key BLOB NOT NULL, + address TEXT NOT NULL, + alias TEXT, + uses_password INTEGER NOT NULL, + network TEXT NOT NULL, + confirmed_balance INTEGER DEFAULT 0, + unconfirmed_balance INTEGER DEFAULT 0, + total_balance INTEGER DEFAULT 0 + );", + ) + .unwrap(); + + // Re-enable FK enforcement + conn.execute_batch("PRAGMA foreign_keys = ON").unwrap(); + + // Set version to 27 + conn.execute("UPDATE settings SET database_version = 27 WHERE id = 1", []) + .unwrap(); + } + + assert_eq!(db.db_schema_version().unwrap(), 27); + + // Run migration with orphaned FK rows present + let result = db.try_perform_migration(27, DEFAULT_DB_VERSION); + assert!( + result.is_ok(), + "migration with orphaned FK rows failed: {:?}", + result.err() + ); + + assert_eq!(db.db_schema_version().unwrap(), DEFAULT_DB_VERSION); + + let conn = db.conn.lock().unwrap(); + assert_v33_schema(&conn); + + // Orphaned wallet_transactions should be gone + let orphan_txs: i64 = conn + .query_row( + "SELECT COUNT(*) FROM wallet_transactions WHERE seed_hash = ?1", + params![orphan_seed_hash], + |row| row.get(0), + ) + .unwrap(); + assert_eq!( + orphan_txs, 0, + "orphaned wallet_transactions should be deleted" + ); + + // Shielded tables should exist but be empty (recreated fresh by migration; + // the cleanup handles them gracefully even when just-created) + assert_table_exists(&conn, "shielded_notes"); + assert_table_exists(&conn, "shielded_wallet_meta"); + + // Valid wallet_transactions should survive with network renamed to mainnet + let valid_txs: i64 = conn + .query_row( + "SELECT COUNT(*) FROM wallet_transactions WHERE seed_hash = ?1 AND network = 'mainnet'", + params![valid_seed_hash], + |row| row.get(0), + ) + .unwrap(); + assert_eq!( + valid_txs, 1, + "valid wallet_transactions should survive with network=mainnet" + ); + + // Wallet itself should have mainnet + let wallet_network: String = conn + .query_row( + "SELECT network FROM wallet WHERE seed_hash = ?1", + params![valid_seed_hash], + |row| row.get(0), + ) + .unwrap(); + assert_eq!(wallet_network, "mainnet"); + + // Orphaned wallet_addresses should be gone, valid ones survive + let orphan_addrs: i64 = conn + .query_row( + "SELECT COUNT(*) FROM wallet_addresses WHERE seed_hash = ?1", + params![orphan_seed_hash], + |row| row.get(0), + ) + .unwrap(); + assert_eq!( + orphan_addrs, 0, + "orphaned wallet_addresses should be deleted" + ); + + let valid_addrs: i64 = conn + .query_row( + "SELECT COUNT(*) FROM wallet_addresses WHERE seed_hash = ?1", + params![valid_seed_hash], + |row| row.get(0), + ) + .unwrap(); + assert_eq!( + valid_addrs, 1, + "valid wallet_addresses should survive migration" + ); + + // asset_lock_transaction with orphaned identity_id should be SET NULL + let valid_identity_id = vec![0xEEu8; 32]; + + let orphan_lock_identity: Option> = conn + .query_row( + "SELECT identity_id FROM asset_lock_transaction WHERE tx_id = ?1", + params![vec![0xA1u8; 32]], + |row| row.get(0), + ) + .unwrap(); + assert!( + orphan_lock_identity.is_none(), + "orphaned asset_lock identity_id should be NULL, got {:?}", + orphan_lock_identity + ); + + // asset_lock_transaction with valid identity_id should keep it + let valid_lock_identity: Option> = conn + .query_row( + "SELECT identity_id FROM asset_lock_transaction WHERE tx_id = ?1", + params![vec![0xA2u8; 32]], + |row| row.get(0), + ) + .unwrap(); + assert_eq!( + valid_lock_identity, + Some(valid_identity_id), + "valid asset_lock identity_id should be preserved" + ); + } + + /// Test migration from v0.9.0 schema (DB version 5) all the way to current. + /// This is the exact schema shipped in the v0.9.0 release, with realistic + /// data including wallets, addresses, identities, and asset locks. + #[test] + fn test_migration_from_v090_to_current() { + let temp_dir = tempfile::tempdir().unwrap(); + let db_file_path = temp_dir.path().join("v090.db"); + let db = super::Database::new(&db_file_path).unwrap(); + + { + let conn = db.conn.lock().unwrap(); + + // Exact v0.9.0 schema — copied from git show v0.9.0:src/database/initialization.rs + conn.execute_batch( + "CREATE TABLE IF NOT EXISTS settings ( + id INTEGER PRIMARY KEY CHECK (id = 1), + password_check BLOB, + main_password_salt BLOB, + main_password_nonce BLOB, + network TEXT NOT NULL, + start_root_screen INTEGER NOT NULL, + custom_dash_qt_path TEXT, + overwrite_dash_conf INTEGER, + database_version INTEGER NOT NULL + ); + + CREATE TABLE IF NOT EXISTS wallet ( + seed_hash BLOB NOT NULL PRIMARY KEY, + encrypted_seed BLOB NOT NULL, + salt BLOB NOT NULL, + nonce BLOB NOT NULL, + master_ecdsa_bip44_account_0_epk BLOB NOT NULL, + alias TEXT, + is_main INTEGER, + uses_password INTEGER NOT NULL, + password_hint TEXT, + network TEXT NOT NULL + ); + + CREATE TABLE IF NOT EXISTS wallet_addresses ( + seed_hash BLOB NOT NULL, + address TEXT NOT NULL, + derivation_path TEXT NOT NULL, + balance INTEGER, + path_reference INTEGER NOT NULL, + path_type INTEGER NOT NULL, + PRIMARY KEY (seed_hash, address), + FOREIGN KEY (seed_hash) REFERENCES wallet(seed_hash) ON DELETE CASCADE + ); + + CREATE INDEX IF NOT EXISTS idx_wallet_addresses_path_reference + ON wallet_addresses (path_reference); + CREATE INDEX IF NOT EXISTS idx_wallet_addresses_path_type + ON wallet_addresses (path_type); + + CREATE TABLE IF NOT EXISTS utxos ( + txid BLOB NOT NULL, + vout INTEGER NOT NULL, + address TEXT NOT NULL, + value INTEGER NOT NULL, + script_pubkey BLOB NOT NULL, + network TEXT NOT NULL, + PRIMARY KEY (txid, vout, network) + ); + + CREATE INDEX IF NOT EXISTS idx_utxos_address ON utxos (address); + CREATE INDEX IF NOT EXISTS idx_utxos_network ON utxos (network); + + CREATE TABLE IF NOT EXISTS asset_lock_transaction ( + tx_id BLOB PRIMARY KEY, + transaction_data BLOB NOT NULL, + amount INTEGER, + instant_lock_data BLOB, + chain_locked_height INTEGER, + identity_id BLOB, + identity_id_potentially_in_creation BLOB, + wallet BLOB NOT NULL, + network TEXT NOT NULL, + FOREIGN KEY (identity_id) REFERENCES identity(id) ON DELETE CASCADE, + FOREIGN KEY (identity_id_potentially_in_creation) REFERENCES identity(id), + FOREIGN KEY (wallet) REFERENCES wallet(seed_hash) ON DELETE CASCADE + ); + + CREATE TABLE IF NOT EXISTS identity ( + id BLOB PRIMARY KEY, + data BLOB, + is_in_creation INTEGER NOT NULL DEFAULT 0, + is_local INTEGER NOT NULL, + alias TEXT, + info TEXT, + wallet BLOB, + wallet_index INTEGER, + identity_type TEXT, + network TEXT NOT NULL, + CHECK ((wallet IS NOT NULL AND wallet_index IS NOT NULL) + OR (wallet IS NULL AND wallet_index IS NULL)), + FOREIGN KEY (wallet) REFERENCES wallet(seed_hash) ON DELETE CASCADE + ); + + CREATE INDEX IF NOT EXISTS idx_identity_local_network_type + ON identity (is_local, network, identity_type); + + CREATE TABLE IF NOT EXISTS contested_name ( + normalized_contested_name TEXT NOT NULL, + locked_votes INTEGER, + abstain_votes INTEGER, + awarded_to BLOB, + end_time INTEGER, + locked INTEGER NOT NULL DEFAULT 0, + last_updated INTEGER, + network TEXT NOT NULL, + PRIMARY KEY (normalized_contested_name, network) + ); + + CREATE TABLE IF NOT EXISTS contestant ( + normalized_contested_name TEXT NOT NULL, + identity_id BLOB NOT NULL, + name TEXT, + votes INTEGER, + created_at INTEGER, + created_at_block_height INTEGER, + created_at_core_block_height INTEGER, + document_id BLOB, + network TEXT NOT NULL, + PRIMARY KEY (normalized_contested_name, identity_id, network), + FOREIGN KEY (normalized_contested_name, network) + REFERENCES contested_name(normalized_contested_name, network) + ON DELETE CASCADE + ); + + CREATE TABLE IF NOT EXISTS contract ( + contract_id BLOB, + contract BLOB, + name TEXT, + network TEXT NOT NULL, + PRIMARY KEY (contract_id, network) + ); + + CREATE INDEX IF NOT EXISTS idx_name_network ON contract (name, network);", + ) + .unwrap(); + + // v0.9.0 also created these via separate functions + // proof_log (v2) + conn.execute_batch( + "CREATE TABLE IF NOT EXISTS proof_log ( + proof_log_id INTEGER PRIMARY KEY AUTOINCREMENT, + proof_log BLOB NOT NULL, + proof_log_timestamp INTEGER NOT NULL + );", + ) + .unwrap(); + + // top_up (v4) + conn.execute_batch( + "CREATE TABLE IF NOT EXISTS top_up ( + identity_id BLOB NOT NULL, + top_up_index INTEGER NOT NULL, + amount INTEGER NOT NULL, + PRIMARY KEY (identity_id, top_up_index), + FOREIGN KEY (identity_id) REFERENCES identity(id) ON DELETE CASCADE + );", + ) + .unwrap(); + + // scheduled_votes (v5) — v0.9.0 schema had NO network column + // and NO FK to identity. The v6 migration handles both. + conn.execute_batch( + "CREATE TABLE IF NOT EXISTS scheduled_votes ( + identity_id BLOB NOT NULL, + contested_name TEXT NOT NULL, + vote_choice TEXT NOT NULL, + time INTEGER NOT NULL, + executed INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY (identity_id, contested_name) + );", + ) + .unwrap(); + + // Insert settings at version 5 + conn.execute( + "INSERT INTO settings (id, network, start_root_screen, database_version) + VALUES (1, 'dash', 0, 5)", + [], + ) + .unwrap(); + + // Insert a wallet with some addresses and an identity + let seed_hash = vec![0xAAu8; 32]; + conn.execute( + "INSERT INTO wallet (seed_hash, encrypted_seed, salt, nonce, + master_ecdsa_bip44_account_0_epk, alias, is_main, uses_password, network) + VALUES (?1, ?2, ?3, ?4, ?5, 'test-wallet', 1, 0, 'dash')", + params![ + seed_hash, + vec![1u8; 64], + vec![2u8; 16], + vec![3u8; 12], + vec![4u8; 33] + ], + ) + .unwrap(); + + conn.execute( + "INSERT INTO wallet_addresses (seed_hash, address, derivation_path, + balance, path_reference, path_type) + VALUES (?1, 'yTestAddr1', 'm/44''/1''/0''/0/0', 50000, 0, 0)", + params![seed_hash], + ) + .unwrap(); + + let identity_id = vec![0xBBu8; 32]; + conn.execute( + "INSERT INTO identity (id, is_local, alias, wallet, wallet_index, + identity_type, network) + VALUES (?1, 1, 'my-identity', ?2, 0, 'user', 'dash')", + params![identity_id, seed_hash], + ) + .unwrap(); + + conn.execute( + "INSERT INTO asset_lock_transaction (tx_id, transaction_data, amount, + identity_id, wallet, network) + VALUES (?1, ?2, 100000, ?3, ?4, 'dash')", + params![vec![0xCCu8; 32], vec![0u8; 50], identity_id, seed_hash], + ) + .unwrap(); + + conn.execute( + "INSERT INTO contract (contract_id, contract, name, network) + VALUES (?1, ?2, 'dpns', 'dash')", + params![vec![0xDDu8; 32], vec![0u8; 100]], + ) + .unwrap(); + } + + assert_eq!(db.db_schema_version().unwrap(), 5); + + // Run full migration from v5 to current + let result = db.try_perform_migration(5, DEFAULT_DB_VERSION); + assert!( + result.is_ok(), + "migration from v0.9.0 (v5) to v{DEFAULT_DB_VERSION} failed: {:?}", + result.err() + ); + + assert_eq!(db.db_schema_version().unwrap(), DEFAULT_DB_VERSION); + + let conn = db.conn.lock().unwrap(); + assert_v33_schema(&conn); + + // Verify data survived migration + let wallet_network: String = conn + .query_row( + "SELECT network FROM wallet WHERE seed_hash = ?1", + params![vec![0xAAu8; 32]], + |row| row.get(0), + ) + .unwrap(); + assert_eq!( + wallet_network, "mainnet", + "wallet network should be renamed" + ); + + // wallet_addresses should have total_received column (added by v17) + assert_column_exists(&conn, "wallet_addresses", "total_received"); + + // wallet should have balance columns (added by v16) + assert_column_exists(&conn, "wallet", "confirmed_balance"); + assert_column_exists(&conn, "wallet", "total_balance"); + + // wallet should have core_wallet_name (added by v33) + assert_column_exists(&conn, "wallet", "core_wallet_name"); + + // Identity should survive with network renamed + let id_network: String = conn + .query_row( + "SELECT network FROM identity WHERE id = ?1", + params![vec![0xBBu8; 32]], + |row| row.get(0), + ) + .unwrap(); + assert_eq!(id_network, "mainnet"); + + // Asset lock should survive with identity_id intact + let lock_identity: Option> = conn + .query_row( + "SELECT identity_id FROM asset_lock_transaction WHERE tx_id = ?1", + params![vec![0xCCu8; 32]], + |row| row.get(0), + ) + .unwrap(); + assert_eq!(lock_identity, Some(vec![0xBBu8; 32])); + } } diff --git a/src/database/scheduled_votes.rs b/src/database/scheduled_votes.rs index 3bbfd0336..461709ead 100644 --- a/src/database/scheduled_votes.rs +++ b/src/database/scheduled_votes.rs @@ -79,13 +79,28 @@ impl Database { [], )?; - // Copy data from old to new table - conn.execute( - "INSERT INTO scheduled_votes (identity_id, contested_name, vote_choice, time, executed, network) - SELECT identity_id, contested_name, vote_choice, time, executed, network - FROM scheduled_votes_old", + // Copy data from old to new table. The v0.9.0 schema created + // scheduled_votes without a network column, so handle both cases. + let has_network: bool = conn.query_row( + "SELECT COUNT(*) FROM pragma_table_info('scheduled_votes_old') WHERE name='network'", [], + |row| row.get::<_, i32>(0).map(|count| count > 0), )?; + if has_network { + conn.execute( + "INSERT INTO scheduled_votes (identity_id, contested_name, vote_choice, time, executed, network) + SELECT identity_id, contested_name, vote_choice, time, executed, network + FROM scheduled_votes_old", + [], + )?; + } else { + conn.execute( + "INSERT INTO scheduled_votes (identity_id, contested_name, vote_choice, time, executed, network) + SELECT identity_id, contested_name, vote_choice, time, executed, 'dash' + FROM scheduled_votes_old", + [], + )?; + } // Drop the old table conn.execute("DROP TABLE scheduled_votes_old", [])?;