diff --git a/Cargo.lock b/Cargo.lock index 83340a4..d2bf6a5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3998,10 +3998,13 @@ dependencies = [ "anyhow", "argon2 0.6.0-rc.8", "axum 0.8.6", + "blake2 0.10.6", + "borsh", "chacha20poly1305 0.11.0-rc.3", "chrono", "clap 4.5.49", "config", + "digest 0.10.7", "dirs-next 2.0.0", "hex", "hmac", @@ -4039,6 +4042,7 @@ dependencies = [ "utoipa-swagger-ui", "uuid", "wiremock", + "zeroize", ] [[package]] diff --git a/minotari/Cargo.toml b/minotari/Cargo.toml index 3b7f700..1b0f81f 100644 --- a/minotari/Cargo.toml +++ b/minotari/Cargo.toml @@ -18,10 +18,13 @@ path = "src/bin/generate_openapi.rs" [dependencies] anyhow = "1.0.99" argon2 = { version = "0.6.0-rc.8", features = ["alloc"] } +blake2 = "0.10" +digest = "0.10" phc = "0.6.1" axum = { version = "0.8.6", features = ["default", "http2", "macros"] } chacha20poly1305 = { version = "0.11.0-rc.3", features = ["rand_core"] } chrono = "0.4.42" +zeroize = "1.8" clap = { version = "4.5.47", features = ["derive"] } dirs-next = "2" config = { version = "0.14.0", default-features = false, features = ["toml"] } diff --git a/minotari/src/cli.rs b/minotari/src/cli.rs index 211d38a..f4e3d24 100644 --- a/minotari/src/cli.rs +++ b/minotari/src/cli.rs @@ -696,6 +696,53 @@ pub enum Commands { #[arg(long, default_value_t = 86400)] seconds_to_lock: u64, }, + + /// Migrate an already-synced legacy console wallet database into this + /// wallet's database format. + /// + /// Reads the legacy console wallet's SQLite file (read-only), recovers the + /// master cipher seed using the provided source passphrase, and copies the + /// outputs and transaction history into a new account in the destination + /// database. The migrated account preserves the legacy random transaction + /// IDs as the user-facing display IDs, the same balance, and the same set + /// of unspent outputs - and sets the scan tip to the source's last + /// scanned block so the next scan does not re-process the entire chain. + /// + /// # Example + /// + /// ```bash + /// tari migrate-from-console-wallet \ + /// --source-db /path/to/console_wallet/console_wallet.sqlite3 \ + /// --source-password "old wallet password" \ + /// --account-name imported \ + /// --password "new wallet password" + /// ``` + MigrateFromConsoleWallet { + /// Path to the legacy console wallet's SQLite database. + #[arg(long, help = "Path to the source console wallet SQLite database")] + source_db: PathBuf, + + /// Passphrase that unlocks the source console wallet. + #[arg(long, help = "Source console wallet passphrase")] + source_password: String, + + #[command(flatten)] + security: SecurityArgs, + #[command(flatten)] + db: DatabaseArgs, + + /// Friendly name to give the new account in this wallet. + #[arg(short = 'a', long, help = "Account name for the migrated wallet")] + account_name: String, + + /// Run the migration against an in-flight transaction but roll it + /// back instead of committing. Useful for checking the source DB + /// would migrate cleanly (balance match, no schema conflicts, no + /// tx_id collisions that need fallback ids) before touching the + /// destination wallet. + #[arg(long, help = "Validate the migration without writing the destination DB")] + dry_run: bool, + }, } #[derive(Args, Debug)] diff --git a/minotari/src/lib.rs b/minotari/src/lib.rs index 6ed14d0..00f3807 100644 --- a/minotari/src/lib.rs +++ b/minotari/src/lib.rs @@ -126,6 +126,7 @@ pub mod daemon; pub mod db; pub mod http; pub mod log; +pub mod migrate; pub mod models; pub mod scan; pub mod tasks; diff --git a/minotari/src/main.rs b/minotari/src/main.rs index 038a218..2253311 100644 --- a/minotari/src/main.rs +++ b/minotari/src/main.rs @@ -63,6 +63,7 @@ use minotari::{ daemon, db::{self, WalletDbError, get_accounts, get_balance, init_db}, log::{init_logging, mask_string}, + migrate::{MigrationOptions, run_migration}, models::WalletEvent, scan::{self, reorg::rollback_from_height}, transactions::{ @@ -565,6 +566,76 @@ async fn main() -> Result<(), anyhow::Error> { Ok(()) }, + Commands::MigrateFromConsoleWallet { + source_db, + source_password, + security, + db, + account_name, + dry_run, + } => { + info!( + target: "audit", + source = source_db.display().to_string().as_str(), + account = account_name.as_str(), + dry_run = dry_run; + "Migrating from console wallet" + ); + + wallet_config.apply_database(&db); + + let report = tokio::task::spawn_blocking(move || { + run_migration(MigrationOptions { + source_db_path: source_db, + source_passphrase: source_password, + destination_db_path: wallet_config.database_path, + destination_passphrase: security.password, + account_name, + dry_run, + }) + }) + .await + .map_err(|e| anyhow!("Migration task join error: {}", e))??; + + println!("---------------------------------------------------------"); + if report.dry_run { + println!("Migration DRY-RUN complete (nothing was written):"); + } else { + println!("Migration complete:"); + } + println!(" Account name : {}", report.account_name); + println!(" Outputs migrated : {}", report.outputs_migrated); + println!(" Unspent : {}", report.unspent_outputs_count); + println!(" Spent : {}", report.spent_outputs_count); + println!(" Outputs skipped : {}", report.outputs_skipped); + println!(" Transactions migrated : {}", report.displayed_transactions_migrated); + println!( + " With matched outputs : {}", + report.displayed_transactions_with_matched_outputs + ); + println!(" Source balance : {} uT", report.source_balance.as_u64()); + println!(" Imported balance : {} uT", report.net_balance().as_u64()); + println!( + " Balance match : {}", + if report.balance_match { "YES" } else { "NO" } + ); + if let Some(h) = report.scan_tip_height { + println!(" Resumed scan tip : block {}", h); + } else { + println!(" Resumed scan tip : none (full scan will be required)"); + } + println!("---------------------------------------------------------"); + if !report.balance_match && !report.dry_run { + return Err(anyhow!( + "Migration balance mismatch: source = {} uT, imported = {} uT. \ + Re-run with --dry-run to investigate.", + report.source_balance.as_u64(), + report.net_balance().as_u64() + )); + } + Ok(()) + }, + Commands::BurnFunds { security, db, diff --git a/minotari/src/migrate/console_db.rs b/minotari/src/migrate/console_db.rs new file mode 100644 index 0000000..34bb3b5 --- /dev/null +++ b/minotari/src/migrate/console_db.rs @@ -0,0 +1,402 @@ +//! Read-only access to a legacy Tari console wallet SQLite database. +//! +//! The console wallet (`tari_wallet` crate) uses Diesel and a particular schema +//! evolution. The migration only needs a small slice of that schema, so this +//! module re-implements the few read paths it requires using `rusqlite` +//! directly. We intentionally avoid pulling in the full `tari_wallet` crate as +//! a dependency; the workspace minotari-cli is built against does not include +//! it and adding it would balloon the dependency footprint substantially. +//! +//! The trickiest piece is the cipher derivation. The console wallet stores the +//! master `CipherSeed` encrypted with a key derived from the user's passphrase +//! through this chain: +//! +//! ```text +//! passphrase + salt --[Argon2id 46 MiB, 1 iter, 1 par, 32 byte]--> secondary_derivation_key +//! secondary_derivation_key --[Blake2b-256, domain "com.tari.base_layer.wallet.secondary_key"]--> secondary_key +//! XChaCha20Poly1305(secondary_key).decrypt(encrypted_main_key, +//! AAD = b"wallet_main_key_encryption_v" + version_byte) -> main_key +//! XChaCha20Poly1305(main_key).decrypt(encrypted_master_seed, +//! AAD = b"wallet_setting_master_seed") -> CipherSeed bytes +//! ``` +//! +//! The stored `secondary_key_hash` is itself the Blake2b-256 of the +//! Argon2id-derived material under the same domain; it doubles as a stored +//! "expected key" for password verification. +//! +//! Only Argon2 v1 (id = 1) is supported; the console wallet has had no other +//! versions to date. + +use std::path::Path; + +use anyhow::{Context, anyhow}; +use argon2::{Algorithm, Argon2, Params, Version}; +use blake2::{Blake2b, Digest}; +use chacha20poly1305::{Key, KeyInit, XChaCha20Poly1305, XNonce, aead::Aead}; +use rusqlite::{Connection, OpenFlags, OptionalExtension, params}; +use tari_common_types::seeds::cipher_seed::CipherSeed; +use tari_crypto::{hash_domain, hashing::DomainSeparatedHasher}; +use tari_utilities::hex::from_hex; +use zeroize::Zeroizing; + +// Domain separator for the secondary key derivation. Must match the console +// wallet's `SecondaryKeyDomain` byte-for-byte. +hash_domain!(SecondaryKeyDomain, "com.tari.base_layer.wallet.secondary_key", 0); + +// Argon2 parameters used by the console wallet's `Argon2Parameters::from_version(Some(1))`. +// These values are consensus-level — changing them breaks decryption of every +// existing console wallet. +const ARGON2_MEMORY_KIB: u32 = 46 * 1024; +const ARGON2_ITERATIONS: u32 = 1; +const ARGON2_PARALLELISM: u32 = 1; +const ARGON2_OUTPUT_LEN: usize = 32; +const SUPPORTED_ARGON2_VERSION: u8 = 1; + +const MAIN_KEY_AAD_PREFIX: &[u8] = b"wallet_main_key_encryption_v"; +const MASTER_SEED_AAD: &[u8] = b"wallet_setting_master_seed"; + +const XNONCE_SIZE: usize = 24; + +/// Result of opening and authenticating against a legacy console wallet. +pub struct ConsoleWalletReader { + conn: Connection, +} + +/// One row from the source `outputs` table, as raw column bytes. Conversion +/// to a `WalletOutput` happens in `output_converter`. +#[derive(Debug, Clone)] +pub struct ConsoleOutputRow { + pub commitment: Vec, + pub spending_key: String, + pub value: i64, + pub output_type: i32, + pub maturity: i64, + pub status: i32, + pub hash: Vec, + pub script: Vec, + pub input_data: Vec, + pub script_private_key: String, + pub script_lock_height: i64, + pub sender_offset_public_key: Vec, + pub metadata_signature_ephemeral_commitment: Vec, + pub metadata_signature_ephemeral_pubkey: Vec, + pub metadata_signature_u_a: Vec, + pub metadata_signature_u_x: Vec, + pub metadata_signature_u_y: Vec, + pub mined_height: Option, + pub mined_in_block: Option>, + pub received_in_tx_id: Option, + pub spent_in_tx_id: Option, + pub features_json: String, + pub covenant: Vec, + pub mined_timestamp: Option, + pub encrypted_data: Vec, + pub minimum_value_promise: i64, + pub payment_id: Option>, + /// Canonical-bytes serialised `RangeProof`. Stored as `rangeproof` (one + /// word) BLOB NULL on the source schema as of the 2023-06-20 migration. + /// `None` is a legitimate value for outputs whose proof never made it + /// into the source row (older imports, scanned-from-genesis under an + /// earlier schema, etc.). + pub rangeproof: Option>, +} + +/// One row from the source `completed_transactions` table. +#[derive(Debug, Clone)] +pub struct ConsoleCompletedTxRow { + pub tx_id: i64, + pub source_address: Vec, + pub destination_address: Vec, + pub amount: i64, + pub fee: i64, + pub status: i32, + pub timestamp: chrono::NaiveDateTime, + pub cancelled: Option, + pub direction: Option, + pub mined_height: Option, + pub mined_in_block: Option>, + pub mined_timestamp: Option, + pub payment_id: Option>, + pub user_payment_id: Option>, + pub sent_output_hashes: Option>, + pub received_output_hashes: Option>, + pub change_output_hashes: Option>, +} + +/// Latest scanned block, for setting the new wallet's scan tip. +#[derive(Debug, Clone)] +pub struct ConsoleScannedTip { + pub height: u64, + pub header_hash: Vec, +} + +impl ConsoleWalletReader { + /// Opens the source database read-only and verifies the supplied passphrase + /// by attempting to derive and authenticate the master key. + pub fn open(path: &Path, passphrase: &str) -> Result<(Self, CipherSeed), anyhow::Error> { + if !path.exists() { + return Err(anyhow!("Console wallet database not found: {}", path.display())); + } + + let conn = + Connection::open_with_flags(path, OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_NO_MUTEX) + .with_context(|| format!("Failed to open console wallet at {} (read-only)", path.display()))?; + + let reader = Self { conn }; + let cipher_seed = reader.derive_cipher_seed(passphrase)?; + Ok((reader, cipher_seed)) + } + + fn read_setting(&self, key: &str) -> Result, anyhow::Error> { + let result: Option = self + .conn + .query_row( + "SELECT value FROM wallet_settings WHERE key = ?1", + params![key], + |row| row.get(0), + ) + .optional()?; + Ok(result) + } + + /// Reproduces the console wallet's secondary-key derivation and master-key + /// decryption to produce the `XChaCha20Poly1305` cipher used to encrypt the + /// master seed. + fn derive_master_cipher(&self, passphrase: &str) -> Result { + let secondary_key_version = self + .read_setting("SecondaryKeyVersion")? + .ok_or_else(|| anyhow!("Console wallet is missing SecondaryKeyVersion (encryption never set up?)"))?; + let secondary_key_salt = self + .read_setting("SecondaryKeySalt")? + .ok_or_else(|| anyhow!("Console wallet is missing SecondaryKeySalt"))?; + let secondary_key_hash_hex = self + .read_setting("SecondaryKeyHash")? + .ok_or_else(|| anyhow!("Console wallet is missing SecondaryKeyHash"))?; + let encrypted_main_key_hex = self + .read_setting("EncryptedMainKey")? + .ok_or_else(|| anyhow!("Console wallet is missing EncryptedMainKey"))?; + + let version: u8 = secondary_key_version + .parse() + .map_err(|e| anyhow!("Invalid SecondaryKeyVersion '{secondary_key_version}': {e}"))?; + if version != SUPPORTED_ARGON2_VERSION { + return Err(anyhow!( + "Unsupported console wallet encryption version {version}; only version {SUPPORTED_ARGON2_VERSION} is supported" + )); + } + + let secondary_key_hash = + from_hex(&secondary_key_hash_hex).map_err(|e| anyhow!("SecondaryKeyHash is not valid hex: {e}"))?; + let encrypted_main_key = + from_hex(&encrypted_main_key_hex).map_err(|e| anyhow!("EncryptedMainKey is not valid hex: {e}"))?; + + // 1. Argon2id over (passphrase, salt) — exactly the parameters the + // console wallet uses. Anything else fails the hash-commitment check + // on the next line. + let mut secondary_derivation_key = Zeroizing::new([0u8; ARGON2_OUTPUT_LEN]); + let argon2_params = Params::new( + ARGON2_MEMORY_KIB, + ARGON2_ITERATIONS, + ARGON2_PARALLELISM, + Some(ARGON2_OUTPUT_LEN), + ) + .map_err(|e| anyhow!("Failed to construct Argon2 parameters: {e}"))?; + let argon2 = Argon2::new(Algorithm::Argon2id, Version::V0x13, argon2_params); + argon2 + .hash_password_into( + passphrase.as_bytes(), + secondary_key_salt.as_bytes(), + secondary_derivation_key.as_mut(), + ) + .map_err(|e| anyhow!("Argon2 derivation failed: {e}"))?; + + // 2. Blake2b-256 with the SecondaryKeyDomain to produce the secondary + // encryption key. The console wallet uses the *same* Blake2b output + // as both the secondary_key and the secondary_key_hash, so we can + // verify the password by comparing the derived value against the + // stored hash before attempting AEAD decryption. + let secondary_key_bytes = DomainSeparatedHasher::, SecondaryKeyDomain>::new() + .chain_update(secondary_derivation_key.as_ref()) + .finalize(); + let secondary_key_bytes = secondary_key_bytes.as_ref(); + + if secondary_key_bytes != secondary_key_hash.as_slice() { + return Err(anyhow!("Console wallet password is incorrect")); + } + + // 3. AEAD-decrypt the main key under the secondary key. AAD is the + // domain prefix concatenated with the version byte. + let mut aad = MAIN_KEY_AAD_PREFIX.to_vec(); + aad.push(version); + let secondary_key_array: [u8; 32] = secondary_key_bytes + .try_into() + .map_err(|e: std::array::TryFromSliceError| anyhow!("Secondary key has unexpected length: {e}"))?; + let secondary_cipher = XChaCha20Poly1305::new(&Key::from(secondary_key_array)); + let main_key_bytes = decrypt_integral_nonce(&secondary_cipher, &aad, &encrypted_main_key) + .map_err(|e| anyhow!("Main-key decryption failed: {e}"))?; + if main_key_bytes.len() != 32 { + return Err(anyhow!( + "Decrypted main key has unexpected length {}", + main_key_bytes.len() + )); + } + + // 4. The main key drives the cipher for everything else stored under + // `wallet_settings` (master seed, etc.). + let main_key_array: [u8; 32] = main_key_bytes.as_slice().try_into().expect("length checked above"); + Ok(XChaCha20Poly1305::new(&Key::from(main_key_array))) + } + + fn derive_cipher_seed(&self, passphrase: &str) -> Result { + let cipher = self.derive_master_cipher(passphrase)?; + + let seed_hex = self + .read_setting("MasterSeed")? + .ok_or_else(|| anyhow!("Console wallet has no MasterSeed setting"))?; + let seed_ciphertext = from_hex(&seed_hex).map_err(|e| anyhow!("MasterSeed is not valid hex: {e}"))?; + + let seed_bytes = decrypt_integral_nonce(&cipher, MASTER_SEED_AAD, &seed_ciphertext) + .map_err(|e| anyhow!("Master seed decryption failed: {e}"))?; + + CipherSeed::from_enciphered_bytes(&seed_bytes, None) + .map_err(|e| anyhow!("Failed to reconstruct CipherSeed from decrypted bytes: {e}")) + } + + /// Returns every row from the source `outputs` table, in insertion order. + pub fn read_outputs(&self) -> Result, anyhow::Error> { + let mut stmt = self.conn.prepare( + "SELECT commitment, spending_key, value, output_type, maturity, status, hash, \ + script, input_data, script_private_key, script_lock_height, sender_offset_public_key, \ + metadata_signature_ephemeral_commitment, metadata_signature_ephemeral_pubkey, \ + metadata_signature_u_a, metadata_signature_u_x, metadata_signature_u_y, \ + mined_height, mined_in_block, received_in_tx_id, spent_in_tx_id, \ + features_json, covenant, mined_timestamp, encrypted_data, minimum_value_promise, \ + payment_id, rangeproof \ + FROM outputs \ + ORDER BY id ASC", + )?; + + let rows = stmt + .query_map([], |row| { + Ok(ConsoleOutputRow { + commitment: row.get(0)?, + spending_key: row.get(1)?, + value: row.get(2)?, + output_type: row.get(3)?, + maturity: row.get(4)?, + status: row.get(5)?, + hash: row.get(6)?, + script: row.get(7)?, + input_data: row.get(8)?, + script_private_key: row.get(9)?, + script_lock_height: row.get(10)?, + sender_offset_public_key: row.get(11)?, + metadata_signature_ephemeral_commitment: row.get(12)?, + metadata_signature_ephemeral_pubkey: row.get(13)?, + metadata_signature_u_a: row.get(14)?, + metadata_signature_u_x: row.get(15)?, + metadata_signature_u_y: row.get(16)?, + mined_height: row.get(17)?, + mined_in_block: row.get(18)?, + received_in_tx_id: row.get(19)?, + spent_in_tx_id: row.get(20)?, + features_json: row.get(21)?, + covenant: row.get(22)?, + mined_timestamp: row.get(23)?, + encrypted_data: row.get(24)?, + minimum_value_promise: row.get(25)?, + payment_id: row.get(26)?, + rangeproof: row.get(27)?, + }) + })? + .collect::, _>>()?; + Ok(rows) + } + + /// Returns every row from the source `completed_transactions` table. + /// Cancelled rows are excluded — they would only confuse the new wallet's + /// transaction history view. + pub fn read_completed_transactions(&self) -> Result, anyhow::Error> { + let mut stmt = self.conn.prepare( + "SELECT tx_id, source_address, destination_address, amount, fee, \ + status, timestamp, cancelled, direction, mined_height, mined_in_block, \ + mined_timestamp, payment_id, user_payment_id, \ + sent_output_hashes, received_output_hashes, change_output_hashes \ + FROM completed_transactions \ + WHERE cancelled IS NULL OR cancelled = 0 \ + ORDER BY tx_id ASC", + )?; + + let rows = stmt + .query_map([], |row| { + Ok(ConsoleCompletedTxRow { + tx_id: row.get(0)?, + source_address: row.get(1)?, + destination_address: row.get(2)?, + amount: row.get(3)?, + fee: row.get(4)?, + status: row.get(5)?, + timestamp: row.get(6)?, + cancelled: row.get(7)?, + direction: row.get(8)?, + mined_height: row.get(9)?, + mined_in_block: row.get(10)?, + mined_timestamp: row.get(11)?, + payment_id: row.get(12)?, + user_payment_id: row.get(13)?, + sent_output_hashes: row.get(14)?, + received_output_hashes: row.get(15)?, + change_output_hashes: row.get(16)?, + }) + })? + .collect::, _>>()?; + Ok(rows) + } + + /// Returns the highest-height entry from `scanned_blocks`, used to set the + /// new wallet's scan tip so it does not repeat work the console wallet + /// already did. + pub fn read_latest_scanned_block(&self) -> Result, anyhow::Error> { + let mut stmt = self + .conn + .prepare("SELECT header_hash, height FROM scanned_blocks ORDER BY height DESC LIMIT 1")?; + + let result = stmt + .query_row([], |row| { + let header_hash: Vec = row.get(0)?; + let height: i64 = row.get(1)?; + Ok(ConsoleScannedTip { + height: u64::try_from(height).unwrap_or(0), + header_hash, + }) + }) + .optional()?; + Ok(result) + } +} + +/// Decrypts a `nonce || ciphertext || tag` blob (the layout +/// `tari_common_types::encryption::encrypt_bytes_integral_nonce` produces). +/// +/// We re-implement this rather than calling into `tari_common_types` because +/// minotari-cli pulls in `chacha20poly1305 0.11.0-rc.3` while the published +/// `tari_common_types` is built against `0.10.x` — the two versions of +/// `XChaCha20Poly1305` are distinct types and cannot be passed across the +/// crate boundary. +fn decrypt_integral_nonce(cipher: &XChaCha20Poly1305, aad: &[u8], blob: &[u8]) -> Result, String> { + if blob.len() < XNONCE_SIZE { + return Err(format!( + "ciphertext too short: got {} bytes, need at least {}", + blob.len(), + XNONCE_SIZE + )); + } + let (nonce_bytes, ciphertext) = blob.split_at(XNONCE_SIZE); + let nonce_array: [u8; XNONCE_SIZE] = nonce_bytes + .try_into() + .map_err(|e: std::array::TryFromSliceError| format!("nonce slice conversion failed: {e}"))?; + let nonce = XNonce::from(nonce_array); + cipher + .decrypt(&nonce, chacha20poly1305::aead::Payload { msg: ciphertext, aad }) + .map_err(|e| format!("AEAD decryption failed: {e}")) +} diff --git a/minotari/src/migrate/migrator.rs b/minotari/src/migrate/migrator.rs new file mode 100644 index 0000000..430cf9e --- /dev/null +++ b/minotari/src/migrate/migrator.rs @@ -0,0 +1,666 @@ +//! Top-level orchestrator for the console-wallet -> minotari-cli migration. +//! +//! ```text +//! ConsoleWalletReader +-> derive cipher seed from password +//! +-> reads outputs, completed_transactions, scanned_blocks +//! v +//! output_converter reconstructs WalletOutput per row +//! v +//! migrator (this file) writes: +//! 1. account + key manager (and derives the view key once for tx_id +//! derivation, matching what the scan path does) +//! 2. every migratable output, plus a per-output balance_change +//! 3. for each completed_transactions row, a displayed_transaction +//! enriched with the matching outputs (received + spent) by tx_id +//! 4. the scan tip marker so subsequent scans resume from there +//! ``` +//! +//! `outputs` are the source of truth for value: the displayed-transaction +//! totals are computed from the matched outputs, and the legacy +//! `completed_transactions.amount` column is used only as a fallback when a +//! transaction has no matched outputs (orphan metadata). +//! +//! `completed_transactions` is the source of truth for transaction identity: +//! the legacy `tx_id` is the user-facing display id, and the row's status, +//! direction, fee, and counterparty fields drive the displayed transaction's +//! metadata. This avoids having to reconstruct transaction grouping from +//! output scripts the way the runtime scanner does. +//! +//! All inserts happen inside a single SQLite transaction. If any step fails +//! the whole thing rolls back, leaving the destination wallet untouched. + +use std::collections::HashMap; +use std::path::PathBuf; + +use anyhow::{Context, anyhow}; +use chrono::{DateTime, Utc}; +use log::info; +use rusqlite::{Connection, named_params}; +use tari_common_types::{ + seeds::cipher_seed::CipherSeed, tari_address::TariAddress, transaction::TxId, types::FixedHash, +}; +use tari_transaction_components::MicroMinotari; +use tari_transaction_components::key_manager::wallet_types::{SeedWordsWallet, WalletType}; +use tari_transaction_components::key_manager::{KeyManager, TransactionKeyManagerInterface}; +use tari_transaction_components::transaction_components::memo_field::MemoField; +use tari_utilities::ByteArray; + +use crate::db::{self, init_db}; +use crate::models::{BalanceChange, Id, OutputStatus}; + +use super::console_db::{ConsoleCompletedTxRow, ConsoleScannedTip, ConsoleWalletReader}; +use super::output_converter::{ConvertedOutput, LegacyOutputStatus, convert_output}; +use super::tx_converter::{MatchedOutput, MatchedOutputs, convert_transaction}; + +/// Inputs to a migration run. +#[derive(Clone, Debug)] +pub struct MigrationOptions { + /// Path to the legacy console wallet's SQLite file. + pub source_db_path: PathBuf, + /// Passphrase that unlocks the legacy wallet. + pub source_passphrase: String, + /// Path to the new minotari-cli SQLite file. Created if missing; the + /// account is added alongside any existing accounts. + pub destination_db_path: PathBuf, + /// Passphrase used to encrypt the new account's wallet blob. + pub destination_passphrase: String, + /// Friendly name to give the new account. + pub account_name: String, + /// When true, the migration runs through the same transaction but rolls + /// back instead of committing. Lets the caller validate the migration is + /// possible (balance match, no schema violations) without touching the + /// destination wallet. + pub dry_run: bool, +} + +impl Default for MigrationOptions { + fn default() -> Self { + Self { + source_db_path: PathBuf::new(), + source_passphrase: String::new(), + destination_db_path: PathBuf::new(), + destination_passphrase: String::new(), + account_name: String::new(), + dry_run: false, + } + } +} + +/// Summary returned to the caller for display / testing. +#[derive(Debug, Default)] +pub struct MigrationReport { + pub account_name: String, + pub outputs_migrated: usize, + pub outputs_skipped: usize, + pub unspent_outputs_count: usize, + pub spent_outputs_count: usize, + pub balance_credit: MicroMinotari, + pub balance_debit: MicroMinotari, + pub displayed_transactions_migrated: usize, + /// Number of `displayed_transactions` rows that had at least one output + /// in either the received or spent list pulled from the source `outputs` + /// table. The remainder are orphan completed-transaction rows whose + /// values fall back to the legacy `amount` column. + pub displayed_transactions_with_matched_outputs: usize, + pub scan_tip_height: Option, + /// Sum of unspent values read from the source wallet. Computed before + /// any writes; lets the caller cross-check `net_balance()` matches. + pub source_balance: MicroMinotari, + /// True iff `net_balance() == source_balance`. Useful as a single + /// migration health check, especially in `--dry-run` mode. + pub balance_match: bool, + /// True iff the migration was a `dry_run` and was rolled back. + pub dry_run: bool, +} + +impl MigrationReport { + pub fn net_balance(&self) -> MicroMinotari { + self.balance_credit.saturating_sub(self.balance_debit) + } +} + +/// Run the migration end-to-end. Returns a report on success. +/// +/// Steps: +/// 1. Open the source DB and decrypt the cipher seed using the source passphrase. +/// 2. Open / create the destination DB and run its migrations. +/// 3. Open a write transaction on the destination DB. +/// 4. Create the destination account, then derive its view key (so output +/// `tx_id`s match what the scan path would compute). +/// 5. Migrate each output and emit a per-output balance_change. Index every +/// output by its legacy `received_in_tx_id` / `spent_in_tx_id` so the +/// next step can join them with completed-transactions. +/// 6. For each completed_transactions row, build a `displayed_transactions` +/// row enriched with the matching outputs and inputs. +/// 7. Set the scan tip so the new wallet does not re-scan ground the console +/// wallet already covered. +/// 8. Commit, or roll back on any error along the way. +pub fn run_migration(options: MigrationOptions) -> Result { + if options.source_db_path == options.destination_db_path { + return Err(anyhow!("Source and destination database paths cannot be the same")); + } + + info!( + target: "audit", + source = options.source_db_path.display().to_string().as_str(), + dest = options.destination_db_path.display().to_string().as_str(), + account = options.account_name.as_str(); + "Starting console-wallet -> minotari-cli migration" + ); + + // 1. Open source DB and authenticate. + let (reader, cipher_seed) = ConsoleWalletReader::open(&options.source_db_path, &options.source_passphrase) + .context("Failed to open and authenticate the source console wallet")?; + + // 2. Read all source data eagerly. The dataset is bounded by the user's + // own UTXO set / transaction history and easily fits in memory; this + // keeps the destination write transaction short-lived. + let outputs = reader.read_outputs().context("Failed to read source outputs")?; + let transactions = reader + .read_completed_transactions() + .context("Failed to read source completed_transactions")?; + let scan_tip = reader + .read_latest_scanned_block() + .context("Failed to read source scanned_blocks")?; + drop(reader); // close source DB before we touch the destination + + // 3. Initialise destination DB and run migrations. + let pool = init_db(options.destination_db_path.clone()) + .map_err(|e| anyhow!("Failed to initialise destination database: {e}"))?; + let mut conn = pool.get().context("Failed to get destination DB connection")?; + + // 4. Single transaction for the whole migration. We use IMMEDIATE so the + // write lock is acquired up-front, making the duplicate-name check + // below atomic vs any concurrent writer. + let tx = conn + .transaction_with_behavior(rusqlite::TransactionBehavior::Immediate) + .context("Failed to start migration transaction")?; + + // Reject duplicate account names; checked inside the transaction so a + // concurrent writer can't race in between the check and create_account. + if db::get_account_by_name(&tx, &options.account_name) + .map_err(|e| anyhow!("Lookup of existing account failed: {e}"))? + .is_some() + { + return Err(anyhow!( + "Destination already has an account named '{}'; refusing to overwrite", + options.account_name + )); + } + + let mut report = migrate_in_transaction(&tx, &cipher_seed, &options, &outputs, &transactions, &scan_tip)?; + report.balance_match = report.net_balance() == report.source_balance; + report.dry_run = options.dry_run; + + if options.dry_run { + // Drop the transaction without committing. The destination DB is + // unchanged; the caller has a populated report for validation. + drop(tx); + info!( + target: "audit", + outputs = report.outputs_migrated, + skipped = report.outputs_skipped, + balance = report.net_balance().as_u64(), + balance_match = report.balance_match; + "Dry-run migration rolled back" + ); + } else { + tx.commit().context("Failed to commit migration transaction")?; + info!( + target: "audit", + outputs = report.outputs_migrated, + skipped = report.outputs_skipped, + unspent = report.unspent_outputs_count, + displayed = report.displayed_transactions_migrated, + with_outputs = report.displayed_transactions_with_matched_outputs, + balance = report.net_balance().as_u64(), + balance_match = report.balance_match; + "Migration committed" + ); + } + + Ok(report) +} + +fn migrate_in_transaction( + tx: &rusqlite::Transaction<'_>, + cipher_seed: &CipherSeed, + options: &MigrationOptions, + outputs: &[super::console_db::ConsoleOutputRow], + transactions: &[ConsoleCompletedTxRow], + scan_tip: &Option, +) -> Result { + let mut report = MigrationReport { + account_name: options.account_name.clone(), + ..Default::default() + }; + + // 4a. Create the new account from the recovered seed. + let seed_wallet = SeedWordsWallet::construct_new(cipher_seed.clone()) + .map_err(|e| anyhow!("Failed to construct SeedWordsWallet from migrated seed: {e}"))?; + let wallet_type = WalletType::SeedWords(seed_wallet); + db::create_account(tx, &options.account_name, &wallet_type, &options.destination_passphrase) + .map_err(|e| anyhow!("Failed to create destination account: {e}"))?; + + let account_id: i64 = tx + .query_row( + "SELECT id FROM accounts WHERE friendly_name = ?1", + [&options.account_name], + |r| r.get(0), + ) + .context("Failed to look up newly-created account id")?; + + // 4b. Derive the view key once for output tx_id derivation. Using the + // same `TxId::new_deterministic(view_key, output_hash)` formula the + // scan path uses means a freshly-scanned wallet and a migrated wallet + // end up with identical `outputs.tx_id` values for the same outputs. + let key_manager = KeyManager::new(wallet_type) + .map_err(|e| anyhow!("Failed to construct KeyManager for the migrated account: {e}"))?; + let view_key = key_manager.get_private_view_key(); + let view_key_bytes = view_key.as_bytes().to_vec(); + + // Indexes used by step 6. Built incrementally during step 5 so we touch + // each output exactly once. + let mut received_outputs_by_tx_id: HashMap> = HashMap::new(); + let mut spent_outputs_by_tx_id: HashMap> = HashMap::new(); + + // 5. Outputs. + for raw in outputs { + let converted = match convert_output(raw)? { + None => { + report.outputs_skipped += 1; + continue; + }, + Some(c) => c, + }; + + // Pre-track the unspent value as the source-of-truth balance BEFORE + // any insert, so we can cross-check the imported balance below + // regardless of any insert-side accounting bug. + if converted.legacy_status.is_unspent() { + report.source_balance = report.source_balance.saturating_add(converted.value); + } + + let inserted_id = insert_converted_output(tx, account_id, &converted, &view_key_bytes)?; + report.outputs_migrated += 1; + + // Index for the displayed-transactions join in step 6. + let matched_for_indexes = MatchedOutput { + hash: converted.output_hash, + value: converted.value, + mined_height: converted.mined_height, + mined_block_hash: converted.mined_block_hash, + destination_output_id: inserted_id, + // Read from the source `outputs.output_type` column so the + // displayed-transaction view can distinguish coinbase / burn / + // standard outputs correctly. Previously hardcoded to Standard. + output_type: converted.output_type, + }; + if let Some(rx_id) = converted.received_in_tx_id { + received_outputs_by_tx_id + .entry(rx_id) + .or_default() + .push(matched_for_indexes.clone()); + } + if let Some(sx_id) = converted.spent_in_tx_id { + spent_outputs_by_tx_id + .entry(sx_id) + .or_default() + .push(matched_for_indexes); + } + + // Pull the recipient / sender / fee / memo out of the output's + // MemoField once and reuse the same claimed-fields struct for both + // the credit and (if applicable) the debit balance_change. Saves + // a re-decode and keeps the two sides consistent. + let memo_claims = ClaimedMemoFields::extract(converted.wallet_output.payment_id()); + + // Balance changes: credit on receive, debit on spend. Keeping these + // per-output (rather than per-transaction) matches what the runtime + // ledger expects: each balance_change is linked to a specific + // output or input id. + if converted.legacy_status.is_unspent() { + report.unspent_outputs_count += 1; + insert_credit_balance_change(tx, account_id, &converted, inserted_id, &memo_claims)?; + report.balance_credit = report.balance_credit.saturating_add(converted.value); + } else if converted.legacy_status.is_spent() { + report.spent_outputs_count += 1; + // For spent outputs we need both a credit (the receive) and a + // debit (the spend). The pair keeps the balance arithmetic + // consistent and lets the user see a historical trail. + insert_credit_balance_change(tx, account_id, &converted, inserted_id, &memo_claims)?; + report.balance_credit = report.balance_credit.saturating_add(converted.value); + insert_input_for_spent_output(tx, account_id, &converted, inserted_id)?; + let input_id = tx.last_insert_rowid(); + insert_debit_balance_change(tx, account_id, &converted, input_id, &memo_claims)?; + report.balance_debit = report.balance_debit.saturating_add(converted.value); + } + } + + // 6. Completed transactions -> displayed_transactions, joined with the + // outputs indexed in step 5. Preserves the console wallet's random + // tx_id values as the user-facing ID. + for raw_tx in transactions { + let tx_id = raw_tx.tx_id as u64; + let received = received_outputs_by_tx_id.remove(&tx_id).unwrap_or_default(); + let spent = spent_outputs_by_tx_id.remove(&tx_id).unwrap_or_default(); + let matched = MatchedOutputs { received, spent }; + let had_matched = !matched.is_empty(); + + let converted = convert_transaction(raw_tx, account_id, &matched)?; + // Hard-fail rather than skip: a partial transaction history is worse + // than aborting and letting the user re-attempt. + db::insert_displayed_transaction(tx, &converted.display).with_context(|| { + format!( + "Failed to migrate displayed transaction with legacy tx_id {}", + raw_tx.tx_id + ) + })?; + report.displayed_transactions_migrated += 1; + if had_matched { + report.displayed_transactions_with_matched_outputs += 1; + } + } + + // 7. Scan tip. Avoids re-scanning the chain from genesis on the next + // `tari scan` invocation. + if let Some(tip) = scan_tip { + let height = i64::try_from(tip.height).unwrap_or(i64::MAX); + tx.execute( + "INSERT INTO scanned_tip_blocks (account_id, height, hash) VALUES (:account_id, :height, :hash)", + named_params! { + ":account_id": account_id, + ":height": height, + ":hash": tip.header_hash, + }, + ) + .context("Failed to insert scanned_tip_blocks marker")?; + report.scan_tip_height = Some(tip.height); + } + + Ok(report) +} + +/// Derive the destination `outputs.tx_id` from the account view key and the +/// output hash, matching the scan path's +/// `TxId::new_deterministic(view_key, output_hash)` exactly. This ensures a +/// migrated wallet and a scan-built wallet store the same `tx_id` for the +/// same output, so cross-references between them stay consistent. +fn deterministic_tx_id(view_key_bytes: &[u8], output_hash: &FixedHash) -> Id { + TxId::new_deterministic(view_key_bytes, output_hash).as_i64_wrapped() +} + +fn insert_converted_output( + tx: &Connection, + account_id: i64, + converted: &ConvertedOutput, + view_key_bytes: &[u8], +) -> Result { + let output_json = + serde_json::to_string(&converted.wallet_output).context("Failed to serialise migrated WalletOutput as JSON")?; + let value_i64 = converted.value.as_u64() as i64; + let height_i64 = i64::try_from(converted.mined_height).unwrap_or(i64::MAX); + let mined_dt = DateTime::::from_naive_utc_and_offset(converted.mined_timestamp, Utc); + let tx_id = deterministic_tx_id(view_key_bytes, &converted.output_hash); + + // Per maintainer feedback (round 2) on PR #121: + // * Spent / SpentMinedUnconfirmed are both mined-in-block spends - + // the source wallet has them as spent on chain, so the destination + // row is Spent in either case. + // * Encumbered{ToBeSpent,ShortTerm*} are pre-broadcast intents with + // no transaction actually linked to them, so they should NOT carry + // over as Locked - that caused issues in the destination wallet's + // accounting. Mark them Unspent and let the new wallet's normal + // reservation flow re-lock them if the user actually spends them. + let status_label = match converted.legacy_status { + LegacyOutputStatus::Spent | LegacyOutputStatus::SpentMinedUnconfirmed => OutputStatus::Spent.to_string(), + _ => OutputStatus::Unspent.to_string(), + }; + + tx.execute( + r#" + INSERT INTO outputs ( + account_id, tx_id, output_hash, mined_in_block_height, + mined_in_block_hash, value, mined_timestamp, wallet_output_json, + status + ) VALUES ( + :account_id, :tx_id, :hash, :height, :block_hash, :value, + :ts, :json, :status + ) + "#, + named_params! { + ":account_id": account_id, + ":tx_id": tx_id, + ":hash": converted.output_hash.as_slice(), + ":height": height_i64, + ":block_hash": converted.mined_block_hash.as_slice(), + ":value": value_i64, + ":ts": mined_dt, + ":json": output_json, + ":status": status_label, + }, + ) + .context("Failed to insert migrated output")?; + + Ok(tx.last_insert_rowid()) +} + +/// Memo-derived claim fields shared between the credit and debit +/// `balance_change` rows for one output. Built once from the output's +/// `MemoField` and reused, so the two halves of a spent-output's +/// ledger trail show the same counterparty / memo / fee. +struct ClaimedMemoFields { + recipient_address: Option, + sender_address: Option, + memo_parsed: Option, + memo_hex: Option, + fee: Option, +} + +impl ClaimedMemoFields { + fn extract(memo: &MemoField) -> Self { + // Gate on the raw payment-id bytes rather than the parsed string: + // `MemoField::Empty::payment_id_as_string()` returns the variant's + // Display rendering ("Empty"-style), not "". The bytes-level check + // is the only thing that distinguishes "no user memo" from "memo + // with content". + let raw = memo.payment_id_as_bytes(); + let (memo_parsed, memo_hex) = if raw.is_empty() { + (None, None) + } else { + (Some(memo.payment_id_as_string()), Some(hex::encode(&raw))) + }; + + Self { + // Only the `AddressAndData` MemoField variant carries a + // sender; the others return None. Same story for recipient + // on `TransactionInfo`. + recipient_address: memo.get_recipient_address(), + sender_address: memo.get_sender_address(), + memo_parsed, + memo_hex, + fee: memo.get_fee(), + } + } +} + +fn insert_credit_balance_change( + tx: &Connection, + account_id: i64, + converted: &ConvertedOutput, + output_id: i64, + claims: &ClaimedMemoFields, +) -> Result<(), anyhow::Error> { + let change = BalanceChange { + account_id, + caused_by_output_id: Some(output_id), + caused_by_input_id: None, + description: "migrated from console wallet".to_string(), + balance_credit: converted.value, + balance_debit: MicroMinotari::from(0), + effective_date: converted.mined_timestamp, + effective_height: converted.mined_height, + claimed_recipient_address: claims.recipient_address.clone(), + claimed_sender_address: claims.sender_address.clone(), + memo_parsed: claims.memo_parsed.clone(), + memo_hex: claims.memo_hex.clone(), + claimed_fee: claims.fee, + claimed_amount: Some(converted.value), + is_reversal: false, + reversal_of_balance_change_id: None, + is_reversed: false, + }; + db::insert_balance_change(tx, &change).map_err(|e| anyhow!("Failed to insert credit balance_change: {e}"))?; + Ok(()) +} + +fn insert_input_for_spent_output( + tx: &Connection, + account_id: i64, + converted: &ConvertedOutput, + output_id: i64, +) -> Result<(), anyhow::Error> { + // We use the *spent* event's block info if we had it, otherwise we fall + // back to the original mined block. The console wallet doesn't track the + // exact spent-block separately on the output row, so this is the best we + // have without re-scanning. + let mined_dt = DateTime::::from_naive_utc_and_offset(converted.mined_timestamp, Utc); + tx.execute( + r#" + INSERT INTO inputs ( + account_id, output_id, mined_in_block_height, + mined_in_block_hash, mined_timestamp + ) VALUES ( + :account_id, :output_id, :height, :block_hash, :ts + ) + "#, + named_params! { + ":account_id": account_id, + ":output_id": output_id, + ":height": i64::try_from(converted.mined_height).unwrap_or(i64::MAX), + ":block_hash": converted.mined_block_hash.as_slice(), + ":ts": mined_dt, + }, + ) + .context("Failed to insert input row for spent output")?; + Ok(()) +} + +fn insert_debit_balance_change( + tx: &Connection, + account_id: i64, + converted: &ConvertedOutput, + input_id: i64, + claims: &ClaimedMemoFields, +) -> Result<(), anyhow::Error> { + let change = BalanceChange { + account_id, + caused_by_output_id: None, + caused_by_input_id: Some(input_id), + description: "migrated spent (debit) from console wallet".to_string(), + balance_credit: MicroMinotari::from(0), + balance_debit: converted.value, + effective_date: converted.mined_timestamp, + effective_height: converted.mined_height, + claimed_recipient_address: claims.recipient_address.clone(), + claimed_sender_address: claims.sender_address.clone(), + memo_parsed: claims.memo_parsed.clone(), + memo_hex: claims.memo_hex.clone(), + claimed_fee: claims.fee, + claimed_amount: Some(converted.value), + is_reversal: false, + reversal_of_balance_change_id: None, + is_reversed: false, + }; + db::insert_balance_change(tx, &change).map_err(|e| anyhow!("Failed to insert debit balance_change: {e}"))?; + Ok(()) +} + +#[cfg(test)] +mod deterministic_tx_id_tests { + //! Direct unit tests for the new view-key-aware tx_id derivation. The + //! key correctness invariant: same (view_key, output_hash) always + //! produces the same tx_id, and different output hashes produce + //! different ids. + use tari_common_types::types::FixedHash; + + use super::deterministic_tx_id; + + fn hash(seed: u8) -> FixedHash { + FixedHash::from([seed; 32]) + } + + #[test] + fn deterministic_across_calls() { + let view_key = [0xCDu8; 32]; + let h = hash(0x7F); + let a = deterministic_tx_id(&view_key, &h); + let b = deterministic_tx_id(&view_key, &h); + assert_eq!(a, b, "same inputs must produce the same tx_id"); + } + + #[test] + fn different_hashes_produce_different_ids() { + let view_key = [0xABu8; 32]; + let a = deterministic_tx_id(&view_key, &hash(0x01)); + let b = deterministic_tx_id(&view_key, &hash(0x02)); + assert_ne!(a, b, "distinct output hashes must produce distinct tx_ids"); + } + + #[test] + fn different_view_keys_produce_different_ids_for_same_hash() { + let h = hash(0xAA); + let a = deterministic_tx_id(&[0x11u8; 32], &h); + let b = deterministic_tx_id(&[0x22u8; 32], &h); + assert_ne!( + a, b, + "the same output hash under different view keys must produce different tx_ids", + ); + } +} + +#[cfg(test)] +mod claimed_memo_fields_tests { + //! Verify the MemoField -> balance_change claim-field extraction is + //! covering the variants the migrator actually encounters and zeroing + //! the audit fields cleanly on empty memos. + + use super::ClaimedMemoFields; + use tari_transaction_components::transaction_components::memo_field::MemoField; + + #[test] + fn empty_memo_yields_all_none_claims() { + let memo = MemoField::new_empty(); + let claims = ClaimedMemoFields::extract(&memo); + assert!(claims.recipient_address.is_none()); + assert!(claims.sender_address.is_none()); + assert!( + claims.memo_parsed.is_none(), + "empty memo must surface as None, not Some(\"\")" + ); + assert!( + claims.memo_hex.is_none(), + "empty memo bytes must surface as None, not Some(\"\")" + ); + assert!(claims.fee.is_none()); + } + + #[test] + fn raw_memo_surfaces_in_parsed_and_hex_fields() { + // Raw is the most permissive variant; it forces the migrator's + // empty-vs-non-empty filtering to do real work. + let memo = MemoField::new_raw(b"trade fill 42".to_vec()).expect("memo fits"); + let claims = ClaimedMemoFields::extract(&memo); + assert!(claims.memo_parsed.is_some()); + assert!( + claims.memo_parsed.as_deref().unwrap().contains("trade fill 42") + || !claims.memo_parsed.as_deref().unwrap().is_empty(), + "raw memo must produce a non-empty parsed string" + ); + assert!(claims.memo_hex.is_some(), "raw memo bytes must produce a hex string"); + // Raw variant has no address or fee fields. + assert!(claims.recipient_address.is_none()); + assert!(claims.sender_address.is_none()); + assert!(claims.fee.is_none()); + } +} diff --git a/minotari/src/migrate/mod.rs b/minotari/src/migrate/mod.rs new file mode 100644 index 0000000..8d17933 --- /dev/null +++ b/minotari/src/migrate/mod.rs @@ -0,0 +1,52 @@ +//! Migration of an already-synced console wallet (legacy `tari` wallet) into +//! the minotari-cli database format. +//! +//! Closes [issue #119](https://github.com/tari-project/minotari-cli/issues/119). +//! +//! The motivation is that re-scanning the chain from the genesis block can +//! take hours on a busy node. Users coming from the legacy console wallet +//! already have a fully scanned local SQLite database; this module imports +//! that data directly so the migrated wallet can be used immediately, with +//! the same balance, the same UTXO set, and the same display transaction IDs +//! the user is accustomed to. +//! +//! # Pipeline +//! +//! 1. Open the source console wallet SQLite read-only. +//! 2. Derive the wallet cipher from the user-supplied password (Argon2id -> +//! secondary key -> XChaCha20-Poly1305 main key). +//! 3. Decrypt the master `CipherSeed` from the `wallet_settings` table. +//! 4. Construct a fresh `SeedWordsWallet` from the seed and create a new +//! minotari-cli account encrypted with the user-supplied minotari-cli +//! password. +//! 5. Stream rows from the source `outputs` table, reconstruct each +//! `WalletOutput` from its decomposed columns, and insert it into the +//! minotari-cli `outputs` table preserving spent / unspent status. +//! 6. Stream rows from the source `completed_transactions` table, build the +//! corresponding `DisplayedTransaction`, and insert it preserving the +//! original `tx_id` value as the user-facing display id. +//! 7. Copy the source's last scanned block height/hash into the new +//! `scanned_tip_blocks` table so the scanner resumes from there rather +//! than re-scanning from genesis. +//! +//! # What the migration does NOT preserve +//! +//! * Pending / inbound / outbound transactions that never reached the +//! `Completed` state in the console wallet. By design — the bounty +//! acceptance criteria only requires completed transactions. +//! * Per-row encrypted blob entries (the console wallet does not encrypt +//! output columns at rest, only the master seed setting). +//! * Cancelled transactions — they would only confuse the new wallet. + +pub mod console_db; +pub mod migrator; +pub mod output_converter; +pub mod tx_converter; + +#[cfg(test)] +mod test_fixture; + +#[cfg(test)] +mod tests; + +pub use migrator::{MigrationOptions, MigrationReport, run_migration}; diff --git a/minotari/src/migrate/output_converter.rs b/minotari/src/migrate/output_converter.rs new file mode 100644 index 0000000..27c3114 --- /dev/null +++ b/minotari/src/migrate/output_converter.rs @@ -0,0 +1,365 @@ +//! Convert a console wallet `outputs` row into the type the new wallet stores +//! (`WalletOutput` JSON blob plus surrounding metadata). +//! +//! The console wallet decomposes a `WalletOutput` across about 20 columns; +//! the new wallet keeps the whole thing as a single serde_json string with +//! a few indexed fields alongside (commitment hash, value, status, etc.). +//! +//! Mirrors the logic of +//! `tari_wallet::output_manager_service::storage::sqlite_db::output_sql::OutputSql::to_db_wallet_output`, +//! but driven directly off the raw column bytes rather than the Diesel +//! `OutputSql` struct, so that minotari-cli does not need to depend on the +//! `tari_wallet` crate. + +use std::str::FromStr; + +use anyhow::anyhow; +use tari_common_types::types::{ + ComAndPubSignature, CompressedCommitment, CompressedPublicKey, FixedHash, PrivateKey, RangeProof, +}; +use tari_script::{ExecutionStack, TariScript}; +use tari_transaction_components::{ + MicroMinotari, + key_manager::TariKeyId, + transaction_components::{ + EncryptedData, OutputFeatures, OutputType, TransactionOutputVersion, WalletOutput, covenants::Covenant, + memo_field::MemoField, + }, +}; +use tari_utilities::ByteArray; + +use super::console_db::ConsoleOutputRow; + +/// What the migration produces for one output: the reconstructed `WalletOutput` +/// itself plus the metadata the new wallet's `outputs` table needs alongside. +pub struct ConvertedOutput { + pub wallet_output: WalletOutput, + pub output_hash: FixedHash, + pub commitment: CompressedCommitment, + pub value: MicroMinotari, + pub mined_height: u64, + pub mined_block_hash: FixedHash, + pub mined_timestamp: chrono::NaiveDateTime, + pub received_in_tx_id: Option, + pub spent_in_tx_id: Option, + pub legacy_status: LegacyOutputStatus, + /// Decoded from the source `outputs.output_type` i32 column. Used by + /// the displayed-transactions builder so coinbase / burn / etc. outputs + /// render with the correct icon and accounting. + pub output_type: OutputType, +} + +/// The console wallet's `OutputStatus` enum, by integer value. We keep this +/// local because the published `tari_common_types` does not expose it. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum LegacyOutputStatus { + Unspent, + Spent, + EncumberedToBeReceived, + EncumberedToBeSpent, + Invalid, + CancelledInbound, + UnspentMinedUnconfirmed, + ShortTermEncumberedToBeReceived, + ShortTermEncumberedToBeSpent, + SpentMinedUnconfirmed, + NotStored, +} + +impl LegacyOutputStatus { + pub fn from_i32(value: i32) -> Result { + let status = match value { + 0 => Self::Unspent, + 1 => Self::Spent, + 2 => Self::EncumberedToBeReceived, + 3 => Self::EncumberedToBeSpent, + 4 => Self::Invalid, + 5 => Self::CancelledInbound, + 6 => Self::UnspentMinedUnconfirmed, + 7 => Self::ShortTermEncumberedToBeReceived, + 8 => Self::ShortTermEncumberedToBeSpent, + 9 => Self::SpentMinedUnconfirmed, + 10 => Self::NotStored, + _ => return Err(anyhow!("Unknown OutputStatus integer value {value}")), + }; + Ok(status) + } + + /// Returns true for outputs that should be carried over to the new wallet. + /// Cancelled / not-stored / invalid outputs are intentionally dropped — they + /// add no value and would clutter the new wallet's view. + pub fn is_migratable(self) -> bool { + matches!( + self, + Self::Unspent + | Self::Spent + | Self::UnspentMinedUnconfirmed + | Self::SpentMinedUnconfirmed + | Self::EncumberedToBeReceived + | Self::EncumberedToBeSpent + | Self::ShortTermEncumberedToBeReceived + | Self::ShortTermEncumberedToBeSpent + ) + } + + /// True if this output still represents claimable value in the wallet. + /// + /// Per maintainer feedback on PR #121 (round 2): + /// * `Spent` and `SpentMinedUnconfirmed` are the two "mined spend" + /// states: in both cases the spend is in a block according to the + /// source wallet, so the value is no longer attributable to the + /// user. These are `is_spent()`. + /// * `EncumberedToBeSpent` / `ShortTermEncumberedToBeSpent` are + /// pre-broadcast intents — the wallet locked an output to spend + /// it but no transaction has been mined yet, so the value still + /// belongs to the user. These fall through to `is_unspent()`. + /// * The remaining migratable states (Unspent / UnspentMinedUnconfirmed / + /// EncumberedToBeReceived / ShortTermEncumberedToBeReceived) are + /// trivially unspent. + pub fn is_unspent(self) -> bool { + self.is_migratable() && !self.is_spent() + } + + pub fn is_spent(self) -> bool { + matches!(self, Self::Spent | Self::SpentMinedUnconfirmed) + } +} + +/// Reconstruct a `WalletOutput` from the source row's raw column bytes. +/// +/// Returns `None` if the output should be skipped (e.g. cancelled, not stored, +/// invalid). Returns `Err` if the row's bytes are corrupt — those should fail +/// the migration loudly rather than silently dropping data. +pub fn convert_output(row: &ConsoleOutputRow) -> Result, anyhow::Error> { + let legacy_status = LegacyOutputStatus::from_i32(row.status)?; + if !legacy_status.is_migratable() { + return Ok(None); + } + + // Mined-block info is required: an output we never saw on chain has no + // place in a "ready to use" migrated wallet. The console wallet sets these + // fields together when an output is mined, so requiring all three is safe. + let mined_height = row + .mined_height + .ok_or_else(|| anyhow!("Output {} has no mined_height; cannot migrate", row_label(row)))?; + let mined_block_bytes = row + .mined_in_block + .as_ref() + .ok_or_else(|| anyhow!("Output {} has no mined_in_block hash", row_label(row)))?; + let mined_block_hash = FixedHash::try_from(mined_block_bytes.as_slice()) + .map_err(|e| anyhow!("Output {}: invalid mined_in_block hash: {e}", row_label(row)))?; + let mined_timestamp = row + .mined_timestamp + .ok_or_else(|| anyhow!("Output {} has no mined_timestamp", row_label(row)))?; + + // `WalletOutput` field reconstruction, mirroring + // `OutputSql::to_db_wallet_output` from the console wallet exactly. + let features: OutputFeatures = serde_json::from_str(&row.features_json) + .map_err(|e| anyhow!("Output {}: invalid features_json: {e}", row_label(row)))?; + + let covenant = Covenant::from_bytes(&mut row.covenant.as_slice()) + .map_err(|e| anyhow!("Output {}: bad covenant bytes: {e}", row_label(row)))?; + + let encrypted_data = EncryptedData::from_bytes(&row.encrypted_data) + .map_err(|e| anyhow!("Output {}: bad encrypted_data: {e}", row_label(row)))?; + + let payment_id = match &row.payment_id { + Some(bytes) => MemoField::from_bytes(bytes), + None => MemoField::new_empty(), + }; + + let commitment = CompressedCommitment::from_canonical_bytes(&row.commitment) + .map_err(|e| anyhow!("Output {}: bad commitment bytes: {e}", row_label(row)))?; + + let output_hash = FixedHash::try_from(row.hash.as_slice()) + .map_err(|e| anyhow!("Output {}: bad hash bytes: {e}", row_label(row)))?; + + // The console wallet supports falling back to a `LegacyTariKeyId` parser if + // the modern `TariKeyId::from_str` fails. The legacy types live in + // `tari_transaction_key_manager`, which is not on minotari-cli's dep tree. + // Rather than pull that in, we surface a clear error and ask the user to + // run the latest console wallet binary first; that wallet auto-converts + // legacy key IDs to modern ones on startup. + let commitment_mask_key_id = TariKeyId::from_str(&row.spending_key).map_err(|e| { + anyhow!( + "Output {}: spending_key '{}' is not a recognised TariKeyId ({e}). \ + If this is a very old wallet, open it once with the latest console wallet binary \ + so the on-disk key IDs are upgraded, then retry migration.", + row_label(row), + row.spending_key + ) + })?; + let script_key_id = TariKeyId::from_str(&row.script_private_key).map_err(|e| { + anyhow!( + "Output {}: script_private_key '{}' is not a recognised TariKeyId ({e}).", + row_label(row), + row.script_private_key + ) + })?; + + let metadata_signature = ComAndPubSignature::new( + CompressedCommitment::from_canonical_bytes(&row.metadata_signature_ephemeral_commitment) + .map_err(|e| anyhow!("Output {}: bad metadata ephemeral commitment: {e}", row_label(row)))?, + CompressedPublicKey::from_canonical_bytes(&row.metadata_signature_ephemeral_pubkey) + .map_err(|e| anyhow!("Output {}: bad metadata ephemeral pubkey: {e}", row_label(row)))?, + PrivateKey::from_canonical_bytes(&row.metadata_signature_u_a) + .map_err(|e| anyhow!("Output {}: bad metadata u_a: {e}", row_label(row)))?, + PrivateKey::from_canonical_bytes(&row.metadata_signature_u_x) + .map_err(|e| anyhow!("Output {}: bad metadata u_x: {e}", row_label(row)))?, + PrivateKey::from_canonical_bytes(&row.metadata_signature_u_y) + .map_err(|e| anyhow!("Output {}: bad metadata u_y: {e}", row_label(row)))?, + ); + + let sender_offset_public_key = CompressedPublicKey::from_canonical_bytes(&row.sender_offset_public_key) + .map_err(|e| anyhow!("Output {}: bad sender_offset_public_key: {e}", row_label(row)))?; + + let script = + TariScript::from_bytes(&row.script).map_err(|e| anyhow!("Output {}: bad script bytes: {e}", row_label(row)))?; + let input_data = ExecutionStack::from_bytes(&row.input_data) + .map_err(|e| anyhow!("Output {}: bad input_data bytes: {e}", row_label(row)))?; + + let value = MicroMinotari::from(u64::try_from(row.value).unwrap_or(0)); + let minimum_value_promise = MicroMinotari::from(u64::try_from(row.minimum_value_promise).unwrap_or(0)); + + // The console wallet stores the canonical-bytes serialisation of the + // output's `RangeProof` (when it has one) in the `rangeproof` BLOB NULL + // column, added by the 2023-06-20 schema migration. We decode it here + // so the migrated wallet does not have to reconstruct the proof on the + // first spend. `None` is preserved when the column is NULL (older imports, + // pre-rangeproof rows, or BulletProofPlus-only flows that don't carry one). + let rangeproof: Option = match row.rangeproof.as_ref() { + Some(bytes) => Some( + RangeProof::from_canonical_bytes(bytes) + .map_err(|e| anyhow!("Output {}: bad rangeproof bytes: {e}", row_label(row)))?, + ), + None => None, + }; + + let wallet_output = WalletOutput::new_from_parts( + TransactionOutputVersion::get_current_version(), + value, + commitment_mask_key_id, + features, + script, + input_data, + script_key_id, + sender_offset_public_key, + metadata_signature, + u64::try_from(row.script_lock_height).unwrap_or(0), + covenant, + encrypted_data, + minimum_value_promise, + rangeproof, + payment_id, + output_hash, + commitment.clone(), + ); + + Ok(Some(ConvertedOutput { + wallet_output, + output_hash, + commitment, + value, + mined_height: u64::try_from(mined_height).unwrap_or(0), + mined_block_hash, + mined_timestamp, + received_in_tx_id: row.received_in_tx_id.map(|v| v as u64), + spent_in_tx_id: row.spent_in_tx_id.map(|v| v as u64), + legacy_status, + output_type: decode_output_type(row.output_type), + })) +} + +/// Decode the console wallet's `outputs.output_type` i32 column into a +/// canonical `OutputType`. The i32 encoding is stable across versions of +/// the console wallet (it maps directly to `OutputType as i32`). Unknown +/// values fall back to `Standard` so a future protocol revision adding +/// new variants does not crash the migration on an in-flight wallet. +fn decode_output_type(value: i32) -> OutputType { + match value { + 0 => OutputType::Standard, + 1 => OutputType::Coinbase, + 2 => OutputType::Burn, + 3 => OutputType::ValidatorNodeRegistration, + 4 => OutputType::CodeTemplateRegistration, + _ => OutputType::Standard, + } +} + +fn row_label(row: &ConsoleOutputRow) -> String { + match row.received_in_tx_id { + Some(tx) => format!("(received_in_tx_id={tx})"), + None => "(unknown tx_id)".to_string(), + } +} + +#[cfg(test)] +mod tests { + //! Tests for the classification helpers and the i32 -> OutputType + //! decoder. These pin behaviour the migrator depends on so a future + //! reshuffle of the legacy enum or the on-disk encoding can't silently + //! change which outputs end up in the balance / get input rows. + + use super::*; + + #[test] + fn mined_spend_variants_count_as_spent_others_as_unspent() { + // Per maintainer feedback (round 2) on PR #121: + // * Spent / SpentMinedUnconfirmed are mined-in-block spends; the + // output's value has already left the wallet on chain. + // * Encumbered* variants are pre-broadcast intent states; nothing + // has been mined and the value still belongs to the user. + for spent in [LegacyOutputStatus::Spent, LegacyOutputStatus::SpentMinedUnconfirmed] { + assert!(spent.is_spent(), "{:?} must count as spent (spend is mined)", spent); + assert!(!spent.is_unspent(), "{:?} must NOT also count as unspent", spent); + } + + for unspent in [ + LegacyOutputStatus::Unspent, + LegacyOutputStatus::UnspentMinedUnconfirmed, + LegacyOutputStatus::EncumberedToBeReceived, + LegacyOutputStatus::EncumberedToBeSpent, + LegacyOutputStatus::ShortTermEncumberedToBeReceived, + LegacyOutputStatus::ShortTermEncumberedToBeSpent, + ] { + assert!(!unspent.is_spent(), "{:?} must not count as actually spent", unspent); + assert!( + unspent.is_unspent(), + "{:?} must count as unspent (value still in balance, no mined spend)", + unspent + ); + } + } + + #[test] + fn non_migratable_variants_are_neither_spent_nor_unspent() { + for s in [ + LegacyOutputStatus::Invalid, + LegacyOutputStatus::CancelledInbound, + LegacyOutputStatus::NotStored, + ] { + assert!(!s.is_migratable()); + assert!(!s.is_spent()); + assert!(!s.is_unspent(), "{:?} must not be classified as unspent", s); + } + } + + #[test] + fn output_type_decode_maps_each_known_i32_variant() { + assert!(matches!(decode_output_type(0), OutputType::Standard)); + assert!(matches!(decode_output_type(1), OutputType::Coinbase)); + assert!(matches!(decode_output_type(2), OutputType::Burn)); + assert!(matches!(decode_output_type(3), OutputType::ValidatorNodeRegistration)); + assert!(matches!(decode_output_type(4), OutputType::CodeTemplateRegistration)); + } + + #[test] + fn output_type_decode_falls_back_to_standard_for_unknown_variants() { + // An on-disk wallet built against a newer console version might + // store an output_type the migrator doesn't recognise yet. We + // prefer "best-effort import as Standard" over hard-failing the + // whole migration on a row we don't recognise. + assert!(matches!(decode_output_type(99), OutputType::Standard)); + assert!(matches!(decode_output_type(-1), OutputType::Standard)); + } +} diff --git a/minotari/src/migrate/test_fixture.rs b/minotari/src/migrate/test_fixture.rs new file mode 100644 index 0000000..17ad8c7 --- /dev/null +++ b/minotari/src/migrate/test_fixture.rs @@ -0,0 +1,287 @@ +//! Test-only helpers that build a synthetic console wallet SQLite database +//! shaped exactly like the one the legacy `tari_wallet` crate produces, so the +//! migration code can be exercised end-to-end without depending on the +//! console wallet binary. +//! +//! The encryption parameters and storage layout exactly mirror the live +//! console wallet at the time of writing — if the upstream wallet ever +//! changes its on-disk encryption format, these helpers must be updated in +//! lockstep with `console_db.rs`. + +use std::path::Path; + +use anyhow::{Context, anyhow}; +use argon2::{Algorithm, Argon2, Params, Version}; +use blake2::{Blake2b, Digest}; +use chacha20poly1305::{ + Key, KeyInit, XChaCha20Poly1305, XNonce, + aead::{Aead, Payload}, +}; +use rand::RngCore; +use rusqlite::{Connection, params}; +use tari_common_types::seeds::cipher_seed::CipherSeed; +use tari_crypto::{hash_domain, hashing::DomainSeparatedHasher}; +use tari_utilities::hex::Hex; + +// Same domain identifier the runtime decrypt path uses; redeclared here so +// this module is self-contained for tests that don't import the live one. +hash_domain!(SecondaryKeyDomain, "com.tari.base_layer.wallet.secondary_key", 0); + +const ARGON2_OUTPUT_LEN: usize = 32; +const SUPPORTED_ARGON2_VERSION: u8 = 1; +const MAIN_KEY_AAD_PREFIX: &[u8] = b"wallet_main_key_encryption_v"; +const MASTER_SEED_AAD: &[u8] = b"wallet_setting_master_seed"; + +pub struct ConsoleFixtureBuilder { + pub seed: CipherSeed, + pub passphrase: String, +} + +pub struct ConsoleFixture { + pub db_path: std::path::PathBuf, + pub passphrase: String, +} + +impl ConsoleFixtureBuilder { + pub fn new(passphrase: &str) -> Self { + Self { + seed: CipherSeed::random(), + passphrase: passphrase.to_string(), + } + } + + pub fn write(self, dir: &Path) -> Result { + let db_path = dir.join("console_wallet.sqlite3"); + let conn = Connection::open(&db_path).context("open synthetic console DB")?; + + create_console_schema(&conn)?; + seed_encryption_settings(&conn, &self.seed, &self.passphrase)?; + + Ok(ConsoleFixture { + db_path, + passphrase: self.passphrase, + }) + } +} + +/// Insert a stub `completed_transactions` row with a known `tx_id` so the +/// migration can be observed to copy the ID through. +pub fn insert_test_completed_transaction( + db_path: &Path, + tx_id: u64, + amount: u64, + fee: u64, + direction: i32, + status: i32, + mined_height: Option, +) -> Result<(), anyhow::Error> { + let conn = Connection::open(db_path).context("reopen synthetic console DB")?; + let now = chrono::Utc::now().naive_utc(); + conn.execute( + "INSERT INTO completed_transactions ( + tx_id, source_address, destination_address, amount, fee, + transaction_protocol, status, timestamp, cancelled, direction, + send_count, last_send_timestamp, confirmations, mined_height, + mined_in_block, mined_timestamp, transaction_signature_nonce, + transaction_signature_key, payment_id, sent_output_hashes, + received_output_hashes, change_output_hashes, user_payment_id, + lock_height + ) VALUES ( + ?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, + 0, NULL, NULL, ?11, NULL, ?12, ?13, ?14, NULL, NULL, NULL, NULL, NULL, 0 + )", + params![ + tx_id as i64, + vec![0u8; 35], // dummy source_address bytes (the migrator handles parse failures gracefully) + vec![0u8; 35], // dummy destination_address + amount as i64, + fee as i64, + Vec::::new(), // transaction_protocol blob + status, + now, + None::, // cancelled + direction, + mined_height.map(|h| h as i64), + now, // mined_timestamp + vec![0u8; 32], // transaction_signature_nonce + vec![0u8; 32], // transaction_signature_key + ], + )?; + Ok(()) +} + +/// Insert a stub `scanned_blocks` row. +pub fn insert_test_scanned_block(db_path: &Path, height: u64, hash: &[u8]) -> Result<(), anyhow::Error> { + let conn = Connection::open(db_path)?; + let now = chrono::Utc::now().naive_utc(); + conn.execute( + "INSERT INTO scanned_blocks (header_hash, height, num_outputs, amount, timestamp) + VALUES (?1, ?2, NULL, NULL, ?3)", + params![hash, height as i64, now], + )?; + Ok(()) +} + +fn create_console_schema(conn: &Connection) -> Result<(), anyhow::Error> { + // We only need the subset of tables our migration touches. Everything + // else the console wallet has (e.g. inbound_transactions) is irrelevant. + conn.execute_batch( + r#" + CREATE TABLE wallet_settings (key TEXT PRIMARY KEY NOT NULL, value TEXT NOT NULL); + + CREATE TABLE outputs ( + id INTEGER PRIMARY KEY NOT NULL, + commitment BLOB NOT NULL, + rangeproof BLOB NULL, + spending_key TEXT NOT NULL, + value BIGINT NOT NULL, + output_type INTEGER NOT NULL, + maturity BIGINT NOT NULL, + status INTEGER NOT NULL, + hash BLOB NOT NULL, + script BLOB NOT NULL, + input_data BLOB NOT NULL, + script_private_key TEXT NOT NULL, + script_lock_height UNSIGNED BIGINT NOT NULL DEFAULT 0, + sender_offset_public_key BLOB NOT NULL, + metadata_signature_ephemeral_commitment BLOB NOT NULL, + metadata_signature_ephemeral_pubkey BLOB NOT NULL, + metadata_signature_u_a BLOB NOT NULL, + metadata_signature_u_x BLOB NOT NULL, + metadata_signature_u_y BLOB NOT NULL, + mined_height UNSIGNED BIGINT NULL, + mined_in_block BLOB NULL, + mined_mmr_position BIGINT NULL, + marked_deleted_at_height BIGINT NULL, + marked_deleted_in_block BLOB NULL, + received_in_tx_id BIGINT NULL, + spent_in_tx_id BIGINT NULL, + coinbase_block_height UNSIGNED BIGINT NULL, + coinbase_extra BLOB NULL, + features_json TEXT NOT NULL DEFAULT '{}', + spending_priority UNSIGNED INTEGER NOT NULL DEFAULT 500, + covenant BLOB NOT NULL, + mined_timestamp DATETIME NULL, + encrypted_data BLOB NOT NULL, + minimum_value_promise BIGINT NOT NULL, + source INTEGER NOT NULL DEFAULT 0, + last_validation_timestamp DATETIME NULL, + payment_id BLOB NULL, + user_payment_id BLOB NULL, + CONSTRAINT unique_commitment UNIQUE (commitment) + ); + + CREATE TABLE completed_transactions ( + tx_id BIGINT PRIMARY KEY NOT NULL, + source_address BLOB NOT NULL, + destination_address BLOB NOT NULL, + amount BIGINT NOT NULL, + fee BIGINT NOT NULL, + transaction_protocol BLOB NOT NULL, + status INTEGER NOT NULL, + timestamp DATETIME NOT NULL, + cancelled INTEGER NULL, + direction INTEGER NULL, + send_count INTEGER DEFAULT 0 NOT NULL, + last_send_timestamp DATETIME NULL, + confirmations BIGINT NULL, + mined_height BIGINT NULL, + mined_in_block BLOB NULL, + mined_timestamp DATETIME NULL, + transaction_signature_nonce BLOB NOT NULL DEFAULT 0, + transaction_signature_key BLOB NOT NULL DEFAULT 0, + payment_id BLOB NULL, + sent_output_hashes BLOB NULL, + received_output_hashes BLOB NULL, + change_output_hashes BLOB NULL, + user_payment_id BLOB NULL, + lock_height BIGINT NULL DEFAULT 0 + ); + + CREATE TABLE scanned_blocks ( + header_hash BLOB PRIMARY KEY NOT NULL, + height BIGINT NOT NULL, + num_outputs BIGINT NULL, + amount BIGINT NULL, + timestamp DATETIME NOT NULL + ); + "#, + )?; + Ok(()) +} + +fn seed_encryption_settings(conn: &Connection, seed: &CipherSeed, passphrase: &str) -> Result<(), anyhow::Error> { + // 1. Derive Argon2id-output, then secondary key/hash from it. + let salt = generate_salt_string(); + let mut secondary_derivation_key = [0u8; ARGON2_OUTPUT_LEN]; + let argon2_params = + Params::new(46 * 1024, 1, 1, Some(ARGON2_OUTPUT_LEN)).map_err(|e| anyhow!("argon2 params: {e}"))?; + let argon2 = Argon2::new(Algorithm::Argon2id, Version::V0x13, argon2_params); + argon2 + .hash_password_into(passphrase.as_bytes(), salt.as_bytes(), &mut secondary_derivation_key) + .map_err(|e| anyhow!("argon2 derive: {e}"))?; + + let secondary_key_bytes = DomainSeparatedHasher::, SecondaryKeyDomain>::new() + .chain_update(secondary_derivation_key) + .finalize(); + let secondary_key_array: [u8; 32] = secondary_key_bytes + .as_ref() + .try_into() + .map_err(|_| anyhow!("secondary key length"))?; + let secondary_key_hash_hex = hex::encode(secondary_key_array); + + // 2. Generate a fresh main key, encrypt under the secondary key. + let mut main_key = [0u8; 32]; + rand::thread_rng().fill_bytes(&mut main_key); + + let mut aad = MAIN_KEY_AAD_PREFIX.to_vec(); + aad.push(SUPPORTED_ARGON2_VERSION); + + let secondary_cipher = XChaCha20Poly1305::new(&Key::from(secondary_key_array)); + let encrypted_main_key = encrypt_integral_nonce(&secondary_cipher, &aad, &main_key)?; + + // 3. Encrypt the master seed bytes under the main key. + let main_cipher = XChaCha20Poly1305::new(&Key::from(main_key)); + let seed_bytes = seed + .encipher(None) + .map_err(|e| anyhow!("CipherSeed::encipher failed: {e}"))?; + let encrypted_master_seed = encrypt_integral_nonce(&main_cipher, MASTER_SEED_AAD, &seed_bytes)?; + + // 4. Persist the four encryption settings + the encrypted seed. + let entries: Vec<(&str, String)> = vec![ + ("SecondaryKeyVersion", SUPPORTED_ARGON2_VERSION.to_string()), + ("SecondaryKeySalt", salt), + ("SecondaryKeyHash", secondary_key_hash_hex), + ("EncryptedMainKey", encrypted_main_key.to_hex()), + ("MasterSeed", encrypted_master_seed.to_hex()), + ("WalletBirthday", seed.birthday().to_string()), + ]; + for (key, value) in entries { + conn.execute( + "INSERT OR REPLACE INTO wallet_settings (key, value) VALUES (?1, ?2)", + params![key, value], + )?; + } + Ok(()) +} + +fn encrypt_integral_nonce(cipher: &XChaCha20Poly1305, aad: &[u8], plaintext: &[u8]) -> Result, anyhow::Error> { + let mut nonce_bytes = [0u8; 24]; + rand::thread_rng().fill_bytes(&mut nonce_bytes); + let nonce = XNonce::from(nonce_bytes); + let mut out = nonce_bytes.to_vec(); + let mut ciphertext = cipher + .encrypt(&nonce, Payload { msg: plaintext, aad }) + .map_err(|e| anyhow!("aead encrypt: {e}"))?; + out.append(&mut ciphertext); + Ok(out) +} + +/// The console wallet stores the salt as the textual rendering of a +/// `SaltString` (PHC base64). The Argon2 derivation only ever sees `salt.as_bytes()`, +/// so any random text is fine here as long as it round-trips byte-for-byte. +fn generate_salt_string() -> String { + let mut bytes = [0u8; 16]; + rand::thread_rng().fill_bytes(&mut bytes); + hex::encode(bytes) +} diff --git a/minotari/src/migrate/tests.rs b/minotari/src/migrate/tests.rs new file mode 100644 index 0000000..3c19550 --- /dev/null +++ b/minotari/src/migrate/tests.rs @@ -0,0 +1,323 @@ +//! End-to-end migration tests that build a synthetic console wallet, run the +//! migrator, and assert the destination minotari-cli database contains the +//! expected data. +//! +//! Output reconstruction is exercised by a separate, narrower test that +//! generates a real `WalletOutput` via the in-process key manager and then +//! serialises it back into the legacy column form so we can prove the +//! converter round-trips without depending on real chain state. + +#![allow(clippy::indexing_slicing)] + +use std::path::PathBuf; + +use rusqlite::{Connection, OptionalExtension, params}; +use tempfile::tempdir; + +use super::test_fixture::{ConsoleFixtureBuilder, insert_test_completed_transaction, insert_test_scanned_block}; +use super::{MigrationOptions, run_migration}; + +const SOURCE_PASSPHRASE: &str = "old-console-wallet-pw"; +const DEST_PASSPHRASE: &str = "new-wallet-pw"; + +#[test] +fn migration_creates_account_with_seed_words_recovered_from_source() { + // Validates the cipher seed round-trip: given a console wallet sealed with a + // known passphrase, the migrator can decrypt the master seed, build a + // SeedWordsWallet from it, and persist the resulting account into the + // destination DB. + let temp = tempdir().expect("temp dir"); + let fixture = ConsoleFixtureBuilder::new(SOURCE_PASSPHRASE) + .write(temp.path()) + .expect("write console fixture"); + let dest_db = temp.path().join("destination_wallet.sqlite3"); + + let report = run_migration(MigrationOptions { + source_db_path: fixture.db_path.clone(), + source_passphrase: fixture.passphrase.clone(), + destination_db_path: dest_db.clone(), + destination_passphrase: DEST_PASSPHRASE.to_string(), + account_name: "imported".to_string(), + dry_run: false, + }) + .expect("migration succeeds"); + + assert_eq!(report.account_name, "imported"); + // We didn't seed any outputs into the fixture so the migrator must + // produce zero migrated outputs, and a zero net balance, without panicking. + assert_eq!(report.outputs_migrated, 0); + assert_eq!(report.unspent_outputs_count, 0); + // Source balance = imported balance = 0; balance check must pass. + assert_eq!(report.source_balance.as_u64(), 0); + assert!( + report.balance_match, + "empty source must produce a matching imported balance" + ); + assert!( + !report.dry_run, + "non-dry-run option must surface as dry_run = false in the report" + ); + + // The destination DB should now contain exactly one account row, named + // "imported". The exact view/spend keys come from the seed we generated + // in the fixture; we check the row exists rather than re-deriving them. + let conn = Connection::open(&dest_db).expect("open destination"); + let count: i64 = conn + .query_row( + "SELECT COUNT(*) FROM accounts WHERE friendly_name = 'imported'", + [], + |r| r.get(0), + ) + .expect("count query"); + assert_eq!(count, 1, "exactly one account named 'imported' should exist"); +} + +#[test] +fn migration_dry_run_writes_nothing_but_returns_report() { + // `--dry-run` must execute the full migration code path so the user can + // validate the source DB without committing. The destination DB must be + // left untouched (no account row) but the report must be fully populated. + let temp = tempdir().expect("temp dir"); + let fixture = ConsoleFixtureBuilder::new(SOURCE_PASSPHRASE) + .write(temp.path()) + .expect("write console fixture"); + let dest_db = temp.path().join("destination_wallet.sqlite3"); + + let report = run_migration(MigrationOptions { + source_db_path: fixture.db_path.clone(), + source_passphrase: fixture.passphrase.clone(), + destination_db_path: dest_db.clone(), + destination_passphrase: DEST_PASSPHRASE.to_string(), + account_name: "dry_run_test".to_string(), + dry_run: true, + }) + .expect("dry-run migration succeeds"); + + assert!(report.dry_run, "dry_run flag must propagate into the report"); + assert!(report.balance_match); + + // Destination DB exists (we ran init_db on it), but the migration + // transaction was rolled back so the account row never landed. + if dest_db.exists() { + let conn = Connection::open(&dest_db).expect("open destination"); + let count: i64 = conn + .query_row( + "SELECT COUNT(*) FROM accounts WHERE friendly_name = 'dry_run_test'", + [], + |r| r.get(0), + ) + .unwrap_or(0); + assert_eq!(count, 0, "dry-run must NOT persist the account row"); + } + + // Sanity: a follow-up live migration with the same account name must + // still succeed (no leftover state from the dry-run blocking it). + let live = run_migration(MigrationOptions { + source_db_path: fixture.db_path, + source_passphrase: fixture.passphrase, + destination_db_path: dest_db, + destination_passphrase: DEST_PASSPHRASE.to_string(), + account_name: "dry_run_test".to_string(), + dry_run: false, + }) + .expect("subsequent live migration succeeds"); + assert!(!live.dry_run); +} + +#[test] +fn migration_rejects_wrong_source_passphrase() { + let temp = tempdir().expect("temp dir"); + let fixture = ConsoleFixtureBuilder::new(SOURCE_PASSPHRASE) + .write(temp.path()) + .expect("write console fixture"); + let dest_db = temp.path().join("destination_wallet.sqlite3"); + + let result = run_migration(MigrationOptions { + source_db_path: fixture.db_path, + source_passphrase: "this-is-not-the-right-password".to_string(), + destination_db_path: dest_db.clone(), + destination_passphrase: DEST_PASSPHRASE.to_string(), + account_name: "imported".to_string(), + dry_run: false, + }); + + let err = result.expect_err("wrong source passphrase must fail"); + // The underlying root cause is "Console wallet password is incorrect"; the + // outer wrapper just says "Failed to open and authenticate the source + // console wallet". Walk the error chain so the test is robust to either + // being shown. + let chain = std::iter::successors(Some(err.as_ref() as &(dyn std::error::Error + 'static)), |e| e.source()) + .map(|e| e.to_string()) + .collect::>() + .join(" | "); + assert!( + chain.contains("password is incorrect") || chain.contains("authenticate") || chain.contains("Password"), + "expected an authentication / password error, got chain: {chain}" + ); + + // Destination DB must not contain a half-built account. + if dest_db.exists() { + let conn = Connection::open(&dest_db).expect("open destination"); + let count: i64 = conn + .query_row("SELECT COUNT(*) FROM accounts", [], |r| r.get(0)) + .unwrap_or(0); + assert_eq!( + count, 0, + "destination must not contain any accounts after a failed migration" + ); + } +} + +#[test] +fn migration_preserves_completed_transaction_ids() { + // The bounty's primary acceptance criterion: legacy completed_transactions + // round-trip into the destination's displayed_transactions table with the + // SAME tx_id values the user was used to seeing. + let temp = tempdir().expect("temp dir"); + let fixture = ConsoleFixtureBuilder::new(SOURCE_PASSPHRASE) + .write(temp.path()) + .expect("write console fixture"); + + // Seed a handful of distinct, recognisable IDs spanning incoming/outgoing + // and confirmed/unconfirmed states — the test passes only if every one + // appears in the destination's displayed_transactions table. + let test_txs: &[(u64, u64, u64, i32, i32)] = &[ + // (tx_id, amount, fee, direction (0=in,1=out), legacy_status (6=mined_confirmed, 9=onesided_confirmed)) + (1_111_111_111u64, 5_000, 0, 0, 9), // incoming, one-sided confirmed + (2_222_222_222u64, 12_345, 0, 0, 12), // incoming, coinbase confirmed + (3_333_333_333u64, 50_000, 250, 1, 6), // outgoing, mined confirmed + ]; + for &(tx_id, amount, fee, dir, status) in test_txs { + insert_test_completed_transaction(&fixture.db_path, tx_id, amount, fee, dir, status, Some(123_456)) + .expect("seed tx"); + } + + let dest_db = temp.path().join("destination_wallet.sqlite3"); + let report = run_migration(MigrationOptions { + source_db_path: fixture.db_path, + source_passphrase: fixture.passphrase, + destination_db_path: dest_db.clone(), + destination_passphrase: DEST_PASSPHRASE.to_string(), + account_name: "imported".to_string(), + dry_run: false, + }) + .expect("migration succeeds"); + + assert_eq!(report.displayed_transactions_migrated, test_txs.len()); + // The fixture seeds no outputs, so every migrated transaction is in the + // "orphan" path (no matched outputs) and must fall back to legacy amount. + assert_eq!( + report.displayed_transactions_with_matched_outputs, 0, + "fixture seeds no outputs, so no displayed transactions should have matched outputs" + ); + + let conn = Connection::open(&dest_db).expect("open destination"); + for &(tx_id, amount, _, _, _) in test_txs { + let stored_id: Option = conn + .query_row( + "SELECT id FROM displayed_transactions WHERE id = ?1", + params![tx_id.to_string()], + |r| r.get(0), + ) + .optional() + .expect("query"); + assert_eq!( + stored_id.as_deref(), + Some(tx_id.to_string().as_str()), + "expected tx_id {tx_id} in destination displayed_transactions" + ); + // Orphan path fallback: with no matched outputs, the legacy + // completed_transactions.amount value must surface as the displayed + // transaction's amount so the user-facing total is preserved. + let stored_amount: Option = conn + .query_row( + "SELECT amount FROM displayed_transactions WHERE id = ?1", + params![tx_id.to_string()], + |r| r.get(0), + ) + .optional() + .expect("query amount"); + assert_eq!( + stored_amount, + Some(amount as i64), + "orphan completed_transactions amount must propagate to displayed_transactions", + ); + } +} + +#[test] +fn migration_sets_scan_tip_from_source() { + let temp = tempdir().expect("temp dir"); + let fixture = ConsoleFixtureBuilder::new(SOURCE_PASSPHRASE) + .write(temp.path()) + .expect("write console fixture"); + + // Insert a few scan tip rows, then expect the migrator to copy ONLY the + // highest-height one into the destination's `scanned_tip_blocks` table. + let tip_hash = [0xAB; 32]; + insert_test_scanned_block(&fixture.db_path, 100, &[0x11; 32]).expect("seed block 1"); + insert_test_scanned_block(&fixture.db_path, 500, &[0x22; 32]).expect("seed block 2"); + insert_test_scanned_block(&fixture.db_path, 999, &tip_hash).expect("seed block 3"); + + let dest_db = temp.path().join("destination_wallet.sqlite3"); + let report = run_migration(MigrationOptions { + source_db_path: fixture.db_path, + source_passphrase: fixture.passphrase, + destination_db_path: dest_db.clone(), + destination_passphrase: DEST_PASSPHRASE.to_string(), + account_name: "imported".to_string(), + dry_run: false, + }) + .expect("migration succeeds"); + + assert_eq!(report.scan_tip_height, Some(999)); + + let conn = Connection::open(&dest_db).expect("open destination"); + let (height, hash): (i64, Vec) = conn + .query_row( + "SELECT height, hash FROM scanned_tip_blocks ORDER BY height DESC LIMIT 1", + [], + |r| Ok((r.get(0)?, r.get(1)?)), + ) + .expect("scan tip row"); + assert_eq!(height, 999); + assert_eq!(hash, tip_hash.to_vec()); +} + +#[test] +fn migration_rejects_duplicate_account_name() { + // Sanity: two migrations targeting the same destination DB and the same + // account name must not silently overwrite. The second run errors out and + // the destination remains in the post-first-run state. + let temp = tempdir().expect("temp dir"); + let fixture = ConsoleFixtureBuilder::new(SOURCE_PASSPHRASE) + .write(temp.path()) + .expect("write console fixture"); + let dest_db: PathBuf = temp.path().join("destination_wallet.sqlite3"); + + run_migration(MigrationOptions { + source_db_path: fixture.db_path.clone(), + source_passphrase: fixture.passphrase.clone(), + destination_db_path: dest_db.clone(), + destination_passphrase: DEST_PASSPHRASE.to_string(), + account_name: "imported".to_string(), + dry_run: false, + }) + .expect("first migration succeeds"); + + let err = run_migration(MigrationOptions { + source_db_path: fixture.db_path, + source_passphrase: fixture.passphrase, + destination_db_path: dest_db, + destination_passphrase: DEST_PASSPHRASE.to_string(), + account_name: "imported".to_string(), + dry_run: false, + }) + .expect_err("second migration with same account name must fail"); + + let msg = err.to_string(); + assert!( + msg.contains("already") || msg.contains("imported"), + "expected duplicate-account error, got: {msg}" + ); +} diff --git a/minotari/src/migrate/tx_converter.rs b/minotari/src/migrate/tx_converter.rs new file mode 100644 index 0000000..0702787 --- /dev/null +++ b/minotari/src/migrate/tx_converter.rs @@ -0,0 +1,519 @@ +//! Convert a console wallet `completed_transactions` row into the new +//! wallet's `DisplayedTransaction` shape, joining in any outputs the source +//! wallet associated with that transaction. +//! +//! The most important property: the `tx_id` value the console wallet stored +//! (a random `u64` generated when the user first sent or received the +//! transaction) is preserved as the `DisplayedTransaction::id`. Without that +//! the user would see a fresh, unfamiliar set of IDs after migration, +//! which is exactly what the bounty's primary acceptance criterion +//! ("identical display transaction IDs") forbids. +//! +//! New wallet's normal scan path computes a deterministic `TxId` from +//! `(view_key, output_hash)`. We do not use that here for the displayed +//! transaction id; we keep the legacy random `tx_id` instead. The two ID +//! conventions co-exist in the `displayed_transactions` table without +//! conflict (PRIMARY KEY is just the string form of whatever u64 was +//! supplied). +//! +//! Outputs flow: +//! * The migrator builds two maps keyed by legacy `tx_id`: one for +//! outputs received in that transaction, one for outputs spent in that +//! transaction. +//! * For each completed-transaction row, the migrator looks up the +//! matching outputs and passes them to `convert_transaction` via +//! [`MatchedOutputs`]. The converter then builds the per-transaction +//! `outputs` / `inputs` lists with the real amounts and computes the +//! transaction-level total credit and debit from those outputs. +//! * Source rows that have no matching outputs (orphan transaction +//! metadata from a partial sync, for example) still produce a +//! displayed-transaction row using the legacy `amount` field as a +//! fallback so user-facing IDs do not disappear in the migration. + +use anyhow::anyhow; +use tari_common_types::{ + tari_address::TariAddress, + transaction::{LegacyTransactionStatus, TxId}, + types::FixedHash, +}; +use tari_transaction_components::MicroMinotari; +use tari_transaction_components::transaction_components::{CoinBaseExtra, OutputType, memo_field::MemoField}; + +use super::console_db::ConsoleCompletedTxRow; +use crate::models::{Id, OutputStatus}; +use crate::transactions::displayed_transaction_processor::{ + BlockchainInfo, DisplayedTransaction, FeeInfo, TransactionDetails, TransactionDirection, TransactionDisplayStatus, + TransactionInput, TransactionOutput, TransactionSource, +}; + +/// One output the source wallet associated with a transaction (either as a +/// receive or a spend), enriched with the data the displayed-transaction +/// builder needs. +#[derive(Debug, Clone)] +pub struct MatchedOutput { + pub hash: FixedHash, + pub value: MicroMinotari, + pub mined_height: u64, + pub mined_block_hash: FixedHash, + /// Destination `outputs.id` for the row we just inserted. Used so the + /// `inputs` half of the displayed transaction can carry + /// `matched_output_id` exactly the way the scan path does. + pub destination_output_id: Id, + pub output_type: OutputType, +} + +/// All outputs the source wallet linked to one legacy `tx_id`. Empty vectors +/// are fine: the converter falls back to legacy `completed_transactions.amount` +/// when neither side has any matched outputs. +#[derive(Debug, Clone, Default)] +pub struct MatchedOutputs { + pub received: Vec, + pub spent: Vec, +} + +impl MatchedOutputs { + pub fn is_empty(&self) -> bool { + self.received.is_empty() && self.spent.is_empty() + } + + fn total_credit(&self) -> MicroMinotari { + self.received + .iter() + .fold(MicroMinotari::from(0), |acc, m| acc.saturating_add(m.value)) + } + + fn total_debit(&self) -> MicroMinotari { + self.spent + .iter() + .fold(MicroMinotari::from(0), |acc, m| acc.saturating_add(m.value)) + } +} + +/// What the migrator emits per source transaction row. The orchestrator picks +/// these apart and writes them into the new wallet's `displayed_transactions` +/// table. +/// +/// Note we deliberately do NOT produce a `completed_transactions` row from +/// a migrated source. The runtime `TransactionMonitor` reads +/// `completed_transactions` to attempt rebroadcast / status refresh of +/// outbound transactions whose serialised blob is still around; populating +/// it with historical, already-mined rows that have no recoverable +/// broadcast blob would queue up bogus rebroadcasts. Historical context +/// lives in `displayed_transactions` (which the UI reads) and +/// `balance_changes` (which the ledger reads); the broadcast log stays +/// empty. +pub struct ConvertedTransaction { + pub display: DisplayedTransaction, +} + +pub fn convert_transaction( + row: &ConsoleCompletedTxRow, + account_id: i64, + matched: &MatchedOutputs, +) -> Result { + let tx_id = row.tx_id as u64; + let legacy_fee = MicroMinotari::from(u64::try_from(row.fee).unwrap_or(0)); + let legacy_amount = MicroMinotari::from(u64::try_from(row.amount).unwrap_or(0)); + + let direction = match row.direction { + Some(0) => TransactionDirection::Incoming, // legacy 0 = Inbound + Some(1) => TransactionDirection::Outgoing, // legacy 1 = Outbound + Some(_) | None => infer_direction_from_amount_or_outputs(row, matched), + }; + + let legacy_status = LegacyTransactionStatus::try_from(row.status).unwrap_or(LegacyTransactionStatus::Completed); + let status = map_status(legacy_status); + let source = map_source(legacy_status); + + let counterparty = match direction { + TransactionDirection::Incoming => parse_address(&row.source_address).ok(), + TransactionDirection::Outgoing => parse_address(&row.destination_address).ok(), + }; + + // Prefer the block info recorded on the matched outputs (they are the + // authoritative on-chain record). Fall back to whatever the legacy + // completed_transactions row carries. + let (block_height, block_hash) = + first_matched_block(matched).unwrap_or_else(|| match (row.mined_height, &row.mined_in_block) { + (Some(h), Some(hash_bytes)) if h >= 0 => { + (h as u64, FixedHash::try_from(hash_bytes.as_slice()).unwrap_or_default()) + }, + _ => (0, FixedHash::default()), + }); + + let timestamp = row.mined_timestamp.unwrap_or(row.timestamp); + + // Totals: derive from matched outputs where possible (they are the + // source of truth for value), fall back to legacy row.amount when this + // transaction has no matched outputs (orphan metadata case). + let (total_credit, total_debit) = if matched.is_empty() { + match direction { + TransactionDirection::Outgoing => (MicroMinotari::from(0), legacy_amount.saturating_add(legacy_fee)), + TransactionDirection::Incoming => (legacy_amount, MicroMinotari::from(0)), + } + } else { + (matched.total_credit(), matched.total_debit()) + }; + + // User-facing `amount` field on the displayed transaction: + // * Outgoing: the net amount the user paid out (matches what console + // wallet showed as the amount column for outgoing rows). + // * Incoming: the net amount received. + // For mixed transactions (both received and spent outputs in the same + // tx_id, which only happens for outgoing transactions with a change + // output), `amount` is the net debit (debit - credit), which is what the + // user saw on the console wallet. + let amount = if matched.is_empty() { + legacy_amount + } else { + match direction { + TransactionDirection::Outgoing => total_debit.saturating_sub(total_credit), + TransactionDirection::Incoming => total_credit, + } + }; + + // Outputs list: built from the matched receives so the user sees the + // real per-output amounts rather than the zeroed placeholders the + // previous implementation produced. + let outputs: Vec = matched + .received + .iter() + .map(|m| TransactionOutput { + hash: m.hash, + amount: m.value, + status: OutputStatus::Unspent, + mined_in_block_height: m.mined_height, + mined_in_block_hash: m.mined_block_hash, + output_type: m.output_type, + is_change: false, + }) + .collect(); + + // Inputs list: matched spends, including the destination output id for + // cross-reference (mirrors what `displayed_transaction_processor` would + // populate on a live scan). + let inputs: Vec = matched + .spent + .iter() + .map(|m| TransactionInput { + output_hash: m.hash, + amount: m.value, + matched_output_id: m.destination_output_id, + mined_in_block_hash: m.mined_block_hash, + }) + .collect(); + + let coinbase_extra = if matches!(source, TransactionSource::Coinbase) { + Some(CoinBaseExtra::default()) + } else { + None + }; + + let sent_output_hashes: Vec = matched.spent.iter().map(|m| m.hash).collect(); + + // Pull the user-visible message from the source transaction's memo + // (payment_id). Gate on the decoded MemoField's payment-id BYTES, not + // on the parsed string: `MemoField::Empty::payment_id_as_string()` + // returns the variant's Display rendering rather than "", so a bare + // `s.is_empty()` filter would not catch it and the destination would + // render the variant tag as a fake user memo. + let message = row.payment_id.as_ref().and_then(|bytes| { + let memo = MemoField::from_bytes(bytes); + if memo.payment_id_as_bytes().is_empty() { + None + } else { + Some(memo.payment_id_as_string()) + } + }); + + let display = DisplayedTransaction { + id: TxId::from(tx_id), + direction, + source, + status, + amount, + message, + counterparty, + blockchain: BlockchainInfo { + block_height, + timestamp, + confirmations: 0, + block_hash, + }, + fee: match direction { + TransactionDirection::Outgoing => Some(FeeInfo { amount: legacy_fee }), + TransactionDirection::Incoming => None, + }, + details: TransactionDetails { + account_id, + total_credit, + total_debit, + inputs, + outputs, + output_type: Some(OutputType::Standard), + coinbase_extra, + memo_hex: row.payment_id.as_ref().map(hex::encode), + sent_output_hashes, + sent_payrefs: Vec::new(), + }, + }; + + Ok(ConvertedTransaction { display }) +} + +fn parse_address(bytes: &[u8]) -> Result { + TariAddress::from_bytes(bytes).map_err(|e| anyhow!("Invalid address bytes: {e}")) +} + +fn infer_direction_from_amount_or_outputs( + row: &ConsoleCompletedTxRow, + matched: &MatchedOutputs, +) -> TransactionDirection { + // If we have matched outputs, use them as the source of truth: a tx + // with any spent outputs is outgoing; otherwise incoming. + if !matched.spent.is_empty() { + return TransactionDirection::Outgoing; + } + if !matched.received.is_empty() { + return TransactionDirection::Incoming; + } + // Fallback when neither side has matched outputs: legacy completed_transactions.amount + // sign was the original heuristic the console wallet used too. + if row.amount > 0 { + TransactionDirection::Incoming + } else { + TransactionDirection::Outgoing + } +} + +fn first_matched_block(matched: &MatchedOutputs) -> Option<(u64, FixedHash)> { + // Prefer the receive side when present (matches what a scanner would + // record). For pure outbound transactions, fall back to the spend side. + matched + .received + .first() + .or_else(|| matched.spent.first()) + .map(|m| (m.mined_height, m.mined_block_hash)) +} + +fn map_status(status: LegacyTransactionStatus) -> TransactionDisplayStatus { + use LegacyTransactionStatus::*; + match status { + Pending | Queued => TransactionDisplayStatus::Pending, + Completed | Broadcast => TransactionDisplayStatus::Pending, + MinedUnconfirmed | OneSidedUnconfirmed | CoinbaseUnconfirmed => TransactionDisplayStatus::Unconfirmed, + MinedConfirmed + | MinedConfirmedLocked + | OneSidedConfirmed + | OneSidedConfirmedLocked + | CoinbaseConfirmed + | CoinbaseConfirmedLocked => TransactionDisplayStatus::Confirmed, + Rejected => TransactionDisplayStatus::Rejected, + Imported | CoinbaseNotInBlockChain | Coinbase => TransactionDisplayStatus::Confirmed, + } +} + +fn map_source(status: LegacyTransactionStatus) -> TransactionSource { + use LegacyTransactionStatus::*; + match status { + Coinbase | CoinbaseUnconfirmed | CoinbaseConfirmed | CoinbaseNotInBlockChain | CoinbaseConfirmedLocked => { + TransactionSource::Coinbase + }, + OneSidedUnconfirmed | OneSidedConfirmed | OneSidedConfirmedLocked => TransactionSource::OneSided, + Imported => TransactionSource::Transfer, + _ => TransactionSource::Transfer, + } +} + +#[cfg(test)] +mod tests { + use chrono::NaiveDateTime; + + use super::*; + use crate::transactions::displayed_transaction_processor::TransactionDirection; + + fn make_row(tx_id: u64, amount_signed: i64, direction: Option) -> ConsoleCompletedTxRow { + ConsoleCompletedTxRow { + tx_id: tx_id as i64, + source_address: vec![0u8; 35], + destination_address: vec![0u8; 35], + amount: amount_signed, + fee: 50, + status: 6, // MinedConfirmed + timestamp: NaiveDateTime::default(), + cancelled: None, + direction, + mined_height: Some(123), + mined_in_block: Some(vec![0xAA; 32]), + mined_timestamp: Some(NaiveDateTime::default()), + payment_id: None, + user_payment_id: None, + sent_output_hashes: None, + received_output_hashes: None, + change_output_hashes: None, + } + } + + fn make_matched(hash_seed: u8, value: u64, height: u64) -> MatchedOutput { + MatchedOutput { + hash: FixedHash::from([hash_seed; 32]), + value: MicroMinotari::from(value), + mined_height: height, + mined_block_hash: FixedHash::from([0xBB; 32]), + destination_output_id: hash_seed as i64, + output_type: OutputType::Standard, + } + } + + #[test] + fn empty_matched_outputs_falls_back_to_legacy_amount_field() { + // Orphan completed_transactions case: no source outputs link to this + // tx_id. The displayed transaction must still appear (so the user's + // legacy tx_id is preserved) but it relies on the legacy `amount` + // field for its visible totals. + let row = make_row(7, 10_000, Some(0)); + let converted = convert_transaction(&row, 1, &MatchedOutputs::default()).expect("convert"); + assert_eq!(converted.display.amount, MicroMinotari::from(10_000)); + assert_eq!(converted.display.details.total_credit, MicroMinotari::from(10_000)); + assert_eq!(converted.display.details.total_debit, MicroMinotari::from(0)); + assert!(converted.display.details.outputs.is_empty()); + assert!(converted.display.details.inputs.is_empty()); + } + + #[test] + fn matched_receives_drive_total_credit_and_outputs_list() { + let row = make_row(8, 999_999, Some(0)); // legacy amount intentionally wrong + let matched = MatchedOutputs { + received: vec![make_matched(1, 500, 100), make_matched(2, 700, 100)], + spent: vec![], + }; + let converted = convert_transaction(&row, 1, &matched).expect("convert"); + + // Total credit is summed from matched outputs, not from row.amount. + assert_eq!(converted.display.details.total_credit, MicroMinotari::from(1_200)); + assert_eq!(converted.display.details.total_debit, MicroMinotari::from(0)); + assert_eq!(converted.display.amount, MicroMinotari::from(1_200)); + assert_eq!(converted.display.details.outputs.len(), 2); + assert_eq!(converted.display.details.outputs[0].amount, MicroMinotari::from(500)); + assert_eq!(converted.display.details.outputs[1].amount, MicroMinotari::from(700)); + } + + #[test] + fn outgoing_with_change_nets_to_actual_amount_paid() { + // Outbound transaction: spent two outputs worth 1000 total, kept a + // change output of 200. Net amount paid = 800. The user's console + // wallet would have shown this as an 800-amount outbound tx. + let row = make_row(9, 0, Some(1)); + let matched = MatchedOutputs { + received: vec![make_matched(1, 200, 50)], // change + spent: vec![make_matched(2, 600, 50), make_matched(3, 400, 50)], + }; + let converted = convert_transaction(&row, 1, &matched).expect("convert"); + + assert_eq!(converted.display.direction, TransactionDirection::Outgoing); + assert_eq!(converted.display.details.total_credit, MicroMinotari::from(200)); + assert_eq!(converted.display.details.total_debit, MicroMinotari::from(1_000)); + // amount visible to the user = debit - credit = 800 + assert_eq!(converted.display.amount, MicroMinotari::from(800)); + assert_eq!(converted.display.details.outputs.len(), 1); + assert_eq!(converted.display.details.inputs.len(), 2); + } + + #[test] + fn direction_falls_back_to_outputs_when_legacy_column_null() { + // Console wallet sometimes leaves direction NULL for coinbase / + // imported rows. Migrator must infer it from the outputs first + // (outputs are authoritative), and only then fall back to the legacy + // amount sign. + let row = make_row(10, 0, None); // direction null, amount 0 + let receive_only = MatchedOutputs { + received: vec![make_matched(1, 999, 5)], + spent: vec![], + }; + let converted = convert_transaction(&row, 1, &receive_only).expect("convert"); + assert_eq!(converted.display.direction, TransactionDirection::Incoming); + + let spend_only = MatchedOutputs { + received: vec![], + spent: vec![make_matched(2, 100, 5)], + }; + let converted = convert_transaction(&row, 1, &spend_only).expect("convert"); + assert_eq!(converted.display.direction, TransactionDirection::Outgoing); + } + + #[test] + fn matched_outputs_drive_blockchain_info_when_present() { + // A spent tx where the legacy `mined_height` column was NULL but the + // outputs themselves record the spend block. The displayed tx must + // take the block info from the matched outputs. + let mut row = make_row(11, 0, Some(1)); + row.mined_height = None; + row.mined_in_block = None; + + let matched = MatchedOutputs { + received: vec![], + spent: vec![make_matched(1, 50, 777)], + }; + let converted = convert_transaction(&row, 1, &matched).expect("convert"); + assert_eq!(converted.display.blockchain.block_height, 777); + assert_eq!(converted.display.blockchain.block_hash, FixedHash::from([0xBB; 32])); + } + + #[test] + fn payment_id_memo_field_populates_message_when_non_empty() { + // The console wallet stores the payment id as the raw MemoField + // encoding. The migrator must decode it and surface the + // human-readable string as `DisplayedTransaction::message` so the + // user sees the same memo content after migration as they saw + // before. We use the Raw variant in this test because its + // round-trip from bytes through `payment_id_as_string` does not + // depend on construction-time TariAddress / MicroMinotari values. + let mut row = make_row(12, 100, Some(0)); + let memo = MemoField::new_raw(b"hello".to_vec()).expect("memo fits"); + row.payment_id = Some(memo.to_bytes()); + + let converted = convert_transaction(&row, 1, &MatchedOutputs::default()).expect("convert"); + let message = converted + .display + .message + .as_deref() + .expect("message must be set when memo is present"); + assert!( + !message.is_empty(), + "Raw memo bytes must surface as a non-empty message" + ); + } + + #[test] + fn missing_payment_id_leaves_message_unset() { + // No memo on the source row -> no message rendered by the new + // wallet; the field must stay `None` rather than render an empty + // banner. + let mut row = make_row(13, 0, Some(0)); + row.payment_id = None; + let converted = convert_transaction(&row, 1, &MatchedOutputs::default()).expect("convert"); + assert!(converted.display.message.is_none()); + } + + #[test] + fn matched_output_type_propagates_to_displayed_outputs_list() { + // Coinbase outputs in the source must surface in the displayed + // transaction's outputs list with `output_type = Coinbase`, not + // the previous hardcoded Standard. This is what lets the new + // wallet's UI render coinbase rewards with the right icon / + // maturity treatment. + let row = make_row(14, 0, Some(0)); + let mut coinbase = make_matched(1, 5_000, 100); + coinbase.output_type = OutputType::Coinbase; + let matched = MatchedOutputs { + received: vec![coinbase], + spent: vec![], + }; + let converted = convert_transaction(&row, 1, &matched).expect("convert"); + assert_eq!(converted.display.details.outputs.len(), 1); + assert!(matches!( + converted.display.details.outputs[0].output_type, + OutputType::Coinbase + )); + } +}