//! Implementation of the database interface using SQLite. //! //! We use a bundled SQLite that is compiled with SQLITE_THREADSAFE. Sqlite.org states: //! > Multi-thread. In this mode, SQLite can be safely used by multiple threads provided that //! > no single database connection is used simultaneously in two or more threads. //! //! We leverage SQLite's `unlock_notify` feature to synchronize writes accross connection. More //! about it at https://sqlite.org/unlock_notify.html. pub mod schema; mod utils; use crate::{ bitcoin::BlockChainTip, database::{ sqlite::{ schema::{ DbAddress, DbCoin, DbLabel, DbLabelledKind, DbSpendTransaction, DbTip, DbWallet, DbWalletTransaction, SCHEMA, }, utils::{ create_fresh_db, curr_timestamp, db_exec, db_query, db_tx_query, db_version, maybe_apply_migration, LOOK_AHEAD_LIMIT, }, }, Coin, CoinStatus, LabelItem, }, descriptors::LianaDescriptor, }; use std::{ cmp, collections::{HashMap, HashSet}, convert::TryInto, fmt, io, path, }; use miniscript::bitcoin::{ self, bip32, consensus::encode, hashes::{sha256, Hash}, psbt::Psbt, secp256k1, }; const DB_VERSION: i64 = 5; /// Last database version for which Bitcoin transactions were not stored in database. In practice /// this meant we relied on the bitcoind watchonly wallet to store them for us. pub const MAX_DB_VERSION_NO_TX_DB: i64 = 4; #[derive(Debug)] pub enum SqliteDbError { FileCreation(io::Error), FileNotFound(path::PathBuf), UnsupportedVersion(i64), InvalidNetwork(bitcoin::Network), DescriptorMismatch(Box), Rusqlite(rusqlite::Error), } impl std::fmt::Display for SqliteDbError { fn fmt(&self, f: &mut fmt::Formatter) -> std::fmt::Result { match self { SqliteDbError::FileCreation(e) => { write!(f, "Error when create SQLite database file: '{}'", e) } SqliteDbError::FileNotFound(p) => { write!(f, "SQLite database file not found at '{}'.", p.display()) } SqliteDbError::UnsupportedVersion(v) => { write!(f, "Unsupported database version '{}'.", v) } SqliteDbError::InvalidNetwork(net) => { write!(f, "Database was created for network '{}'.", net) } SqliteDbError::DescriptorMismatch(desc) => { write!(f, "Database descriptor mismatch: '{}'.", desc) } SqliteDbError::Rusqlite(e) => write!(f, "SQLite error: '{}'", e), } } } impl std::error::Error for SqliteDbError {} impl From for SqliteDbError { fn from(e: io::Error) -> Self { SqliteDbError::FileCreation(e) } } impl From for SqliteDbError { fn from(e: rusqlite::Error) -> Self { SqliteDbError::Rusqlite(e) } } // In Bitcoin land, txids are usually displayed in reverse byte order. This is what rust-bitcoin // implements as `fmt::Display` for `bitcoin::Txid`. However, we store them as raw bytes in the // database and it so happens we sometimes have to look for a txid in hex, in which case we want // the "frontward" hex serialization. This is a hack to implement it. #[derive(Debug, Clone, Copy)] struct FrontwardHexTxid(bitcoin::Txid); impl fmt::Display for FrontwardHexTxid { fn fmt(&self, f: &mut fmt::Formatter) -> std::fmt::Result { write!( f, "{:x}", // sha256 isn't displayed in reverse byte order (contrary to sha256d). sha256::Hash::from_byte_array(self.0.to_byte_array()) ) } } #[derive(Debug, Clone)] pub struct FreshDbOptions { pub(self) bitcoind_network: bitcoin::Network, pub(self) main_descriptor: LianaDescriptor, pub(self) schema: &'static str, pub(self) version: i64, } impl FreshDbOptions { pub fn new( bitcoind_network: bitcoin::Network, main_descriptor: LianaDescriptor, ) -> FreshDbOptions { FreshDbOptions { bitcoind_network, main_descriptor, schema: SCHEMA, version: DB_VERSION, } } } #[derive(Debug, Clone)] pub struct SqliteDb { db_path: path::PathBuf, } impl SqliteDb { /// Instanciate an SQLite database either from an existing database file or by creating a fresh /// one. /// NOTE: don't forget to apply any migration with `maybe_apply_migration` if necessary. pub fn new( db_path: path::PathBuf, fresh_options: Option, secp: &secp256k1::Secp256k1, ) -> Result { // Create the database if needed, and make sure the db file exists. if let Some(options) = fresh_options { create_fresh_db(&db_path, options, secp)?; log::info!("Created a fresh database at {}.", db_path.display()); } if !db_path.exists() { return Err(SqliteDbError::FileNotFound(db_path)); } log::info!("Checking if the database needs upgrading."); Ok(SqliteDb { db_path }) } /// If the database version is older than expected, migrate it to the current version. If /// migrating from a database version 4 or earlier, all the wallet Bitcoin transactions must be /// passed through the `bitcoin_txs` parameter otherwise the migration will fail. pub fn maybe_apply_migrations( &self, bitcoin_txs: &[bitcoin::Transaction], ) -> Result<(), SqliteDbError> { maybe_apply_migration(&self.db_path, bitcoin_txs) } /// Get a new connection to the database. pub fn connection(&self) -> Result { let conn = rusqlite::Connection::open(&self.db_path)?; conn.busy_timeout(std::time::Duration::from_secs(60))?; Ok(SqliteConn { conn }) } /// Perform startup sanity checks. pub fn sanity_check( &self, bitcoind_network: bitcoin::Network, main_descriptor: &LianaDescriptor, ) -> Result<(), SqliteDbError> { let mut conn = self.connection()?; // At this point any migration must have been applied. let db_version = conn.db_version(); if db_version != DB_VERSION { return Err(SqliteDbError::UnsupportedVersion(db_version)); } // The config and the db should be on the same network. let db_tip = conn.db_tip(); if db_tip.network != bitcoind_network { return Err(SqliteDbError::InvalidNetwork(db_tip.network)); } // The config and db descriptors must match! let db_wallet = conn.db_wallet(); if &db_wallet.main_descriptor != main_descriptor { return Err(SqliteDbError::DescriptorMismatch( db_wallet.main_descriptor.into(), )); } Ok(()) } } // We only support single wallet. The id of the wallet row is always 1. const WALLET_ID: i64 = 1; pub struct SqliteConn { conn: rusqlite::Connection, } impl SqliteConn { pub fn db_version(&mut self) -> i64 { db_version(&mut self.conn).expect("db must not fail") } /// Get the network tip. pub fn db_tip(&mut self) -> DbTip { db_query( &mut self.conn, "SELECT * FROM tip", rusqlite::params![], |row| row.try_into(), ) .expect("Db must not fail") .pop() .expect("There is always a row in the tip table") } /// Get the information about the wallet. pub fn db_wallet(&mut self) -> DbWallet { db_query( &mut self.conn, "SELECT * FROM wallets", rusqlite::params![], |row| row.try_into(), ) .expect("Db must not fail") .pop() .expect("There is always a row in the wallet table") } /// Update the network tip. pub fn update_tip(&mut self, tip: &BlockChainTip) { db_exec(&mut self.conn, |db_tx| { db_tx .execute( "UPDATE tip SET blockheight = (?1), blockhash = (?2)", rusqlite::params![tip.height, tip.hash[..].to_vec()], ) .map(|_| ()) }) .expect("Database must be available") } /// Set the derivation index for receiving or change addresses. /// /// This will populate the address->deriv_index mapping with all the new entries between the /// former and new gap limit indexes. pub fn set_derivation_index( &mut self, index: bip32::ChildNumber, change: bool, secp: &secp256k1::Secp256k1, ) { let network = self.db_tip().network; db_exec(&mut self.conn, |db_tx| { let db_wallet: DbWallet = db_tx_query(db_tx, "SELECT * FROM wallets", rusqlite::params![], |row| { row.try_into() })? .pop() .expect("There is always a row in the wallet table"); // Make sure we don't set a lower derivation index. This can happen since the // derivation is set outside the atomic transaction. So there may be a race between say // the Bitcoin poller thread and the JSONRPC commands thread. if (change && index <= db_wallet.change_derivation_index) || (!change && index <= db_wallet.deposit_derivation_index) { // It was already set at a higher index. return Ok(()); } // First of all set the derivation index let index_u32: u32 = index.into(); if change { db_tx.execute( "UPDATE wallets SET change_derivation_index = (?1)", rusqlite::params![index_u32], )?; } else { db_tx.execute( "UPDATE wallets SET deposit_derivation_index = (?1)", rusqlite::params![index_u32], )?; } // Now if this new index is higher than the highest of our current derivation indexes, // populate the addresses mapping for derivation indexes between our previous "gap // limit index" and the new one. let curr_highest_index = cmp::max( db_wallet.deposit_derivation_index, db_wallet.change_derivation_index, ).into(); if index_u32 > curr_highest_index { let receive_desc = db_wallet.main_descriptor.receive_descriptor(); let change_desc = db_wallet.main_descriptor.change_descriptor(); for index in curr_highest_index + 1..=index_u32 { let la_index = index + LOOK_AHEAD_LIMIT - 1; let receive_addr = receive_desc.derive(la_index.into(), secp).address(network); let change_addr = change_desc.derive(la_index.into(), secp).address(network); db_tx.execute( "INSERT INTO addresses (receive_address, change_address, derivation_index) VALUES (?1, ?2, ?3)", rusqlite::params![receive_addr.to_string(), change_addr.to_string(), la_index], )?; } } Ok(()) }) .expect("Database must be available") } pub fn set_wallet_rescan_timestamp(&mut self, timestamp: u32) { db_exec(&mut self.conn, |db_tx| { // NOTE: this will need to be updated if we ever implement multi-wallet support db_tx .execute( "UPDATE wallets SET rescan_timestamp = (?1)", rusqlite::params![timestamp], ) .map(|_| ()) }) .expect("Database must be available") } /// Drop the rescan timestamp, and set it as the wallet creation timestamp if it /// predates it. /// /// # Panics /// - If called while rescan_timestamp is not set pub fn complete_wallet_rescan(&mut self) { let db_wallet = self.db_wallet(); let new_timestamp = cmp::min( db_wallet.rescan_timestamp.expect("Must be set"), db_wallet.timestamp, ); db_exec(&mut self.conn, |db_tx| { // NOTE: this will need to be updated if we ever implement multi-wallet support db_tx .execute( "UPDATE wallets SET timestamp = (?1), rescan_timestamp = NULL", rusqlite::params![new_timestamp], ) .map(|_| ()) }) .expect("Database must be available"); } /// Get all the coins from DB, optionally filtered by coin status and/or outpoint. pub fn coins( &mut self, statuses: &[CoinStatus], outpoints: &[bitcoin::OutPoint], ) -> Vec { let status_condition = statuses .iter() .map(|c| { format!( "({})", match c { CoinStatus::Unconfirmed => { "blocktime IS NULL AND spend_txid IS NULL" } CoinStatus::Confirmed => { "blocktime IS NOT NULL AND spend_txid IS NULL" } CoinStatus::Spending => { "spend_txid IS NOT NULL AND spend_block_time IS NULL" } CoinStatus::Spent => "spend_block_time IS NOT NULL", } ) }) .collect::>() .join(" OR "); // SELECT * FROM coins WHERE (txid, vout) IN ((txidA, voutA), (txidB, voutB)); let op_condition = if !outpoints.is_empty() { let mut cond = "(txid, vout) IN (VALUES ".to_string(); for (i, outpoint) in outpoints.iter().enumerate() { // NOTE: SQLite doesn't know Satoshi decided txids would be displayed as little-endian // hex. cond += &format!( "(x'{}', {})", FrontwardHexTxid(outpoint.txid), outpoint.vout ); if i != outpoints.len() - 1 { cond += ", "; } } cond += ")"; cond } else { String::new() }; let where_clause = if !status_condition.is_empty() && !op_condition.is_empty() { format!(" WHERE ({}) AND ({})", status_condition, op_condition) } else if status_condition.is_empty() && !op_condition.is_empty() { format!(" WHERE {}", op_condition) } else if !status_condition.is_empty() && op_condition.is_empty() { format!(" WHERE {}", status_condition) } else { String::new() }; let query = format!("SELECT * FROM coins{}", where_clause); db_query(&mut self.conn, &query, rusqlite::params![], |row| { row.try_into() }) .expect("Db must not fail") } /// List coins that are being spent and whose spending transaction is still unconfirmed. pub fn list_spending_coins(&mut self) -> Vec { self.coins(&[CoinStatus::Spending], &[]) } // FIXME: don't take the whole coin, we don't need it. /// Store new, unconfirmed and unspent, coins. /// Will panic if given a coin that is already in DB. pub fn new_unspent_coins<'a>(&mut self, coins: impl IntoIterator) { db_exec(&mut self.conn, |db_tx| { for coin in coins { let deriv_index: u32 = coin.derivation_index.into(); db_tx.execute( "INSERT INTO coins (wallet_id, txid, vout, amount_sat, derivation_index, is_change, is_immature) \ VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)", rusqlite::params![ WALLET_ID, coin.outpoint.txid[..].to_vec(), coin.outpoint.vout, coin.amount.to_sat(), deriv_index, coin.is_change, coin.is_immature, ], )?; } Ok(()) }) .expect("Database must be available") } /// Remove a set of coins from the database. pub fn remove_coins(&mut self, outpoints: &[bitcoin::OutPoint]) { db_exec(&mut self.conn, |db_tx| { for outpoint in outpoints { db_tx.execute( "DELETE FROM coins WHERE txid = ?1 AND vout = ?2", rusqlite::params![outpoint.txid[..].to_vec(), outpoint.vout,], )?; } Ok(()) }) .expect("Database must be available") } /// Mark a set of coins as confirmed. /// /// NOTE: this will also mark the coin as mature if it originates from an immature coinbase /// deposit. pub fn confirm_coins<'a>( &mut self, outpoints: impl IntoIterator, ) { db_exec(&mut self.conn, |db_tx| { for (outpoint, height, time) in outpoints { db_tx.execute( "UPDATE coins SET blockheight = ?1, blocktime = ?2, is_immature = 0 WHERE txid = ?3 AND vout = ?4", rusqlite::params![height, time, outpoint.txid[..].to_vec(), outpoint.vout,], )?; } Ok(()) }) .expect("Database must be available") } /// Mark a set of coins as spending. pub fn spend_coins<'a>( &mut self, outpoints: impl IntoIterator, ) { db_exec(&mut self.conn, |db_tx| { for (outpoint, spend_txid) in outpoints { db_tx.execute( "UPDATE coins SET spend_txid = ?1 WHERE txid = ?2 AND vout = ?3", rusqlite::params![ spend_txid[..].to_vec(), outpoint.txid[..].to_vec(), outpoint.vout, ], )?; } Ok(()) }) .expect("Database must be available") } /// Mark a set of coins as not being spent. pub fn unspend_coins<'a>( &mut self, outpoints: impl IntoIterator, ) { db_exec(&mut self.conn, |db_tx| { for outpoint in outpoints { db_tx.execute( "UPDATE coins SET spend_txid = NULL, spend_block_height = NULL, spend_block_time = NULL WHERE txid = ?1 AND vout = ?2", rusqlite::params![ outpoint.txid[..].to_vec(), outpoint.vout, ], )?; } Ok(()) }) .expect("Database must be available") } /// Mark the Spend transaction of a given set of coins as being confirmed at a given /// block. pub fn confirm_spend<'a>( &mut self, outpoints: impl IntoIterator, ) { db_exec(&mut self.conn, |db_tx| { for (outpoint, spend_txid, height, time) in outpoints { db_tx.execute( "UPDATE coins SET spend_txid = ?1, spend_block_height = ?2, spend_block_time = ?3 WHERE txid = ?4 AND vout = ?5", rusqlite::params![ spend_txid[..].to_vec(), height, time, outpoint.txid[..].to_vec(), outpoint.vout, ], )?; } Ok(()) }) .expect("Database must be available") } pub fn db_address(&mut self, address: &bitcoin::Address) -> Option { db_query( &mut self.conn, "SELECT * FROM addresses WHERE receive_address = ?1 OR change_address = ?1", rusqlite::params![address.to_string()], |row| row.try_into(), ) .expect("Db must not fail") .pop() } pub fn db_coins(&mut self, outpoints: &[bitcoin::OutPoint]) -> Vec { self.coins(&[], outpoints) } pub fn db_spend(&mut self, txid: &bitcoin::Txid) -> Option { db_query( &mut self.conn, "SELECT * FROM spend_transactions WHERE txid = ?1", rusqlite::params![txid[..].to_vec()], |row| row.try_into(), ) .expect("Db must not fail") .pop() } /// Insert a new Spend transaction or replace an existing one. pub fn store_spend(&mut self, psbt: &Psbt) { let txid = &psbt.unsigned_tx.txid()[..].to_vec(); db_exec(&mut self.conn, |db_tx| { db_tx.execute( "INSERT into spend_transactions (psbt, txid, updated_at) VALUES (?1, ?2, ?3) \ ON CONFLICT DO UPDATE SET psbt=excluded.psbt", rusqlite::params![psbt.serialize(), txid, curr_timestamp()], )?; Ok(()) }) .expect("Db must not fail"); } pub fn list_spend(&mut self) -> Vec { db_query( &mut self.conn, "SELECT * FROM spend_transactions", rusqlite::params![], |row| row.try_into(), ) .expect("Db must not fail") } pub fn update_labels(&mut self, items: &HashMap>) { db_exec(&mut self.conn, |db_tx| { for (labelled, kind, value) in items .iter() .map(|(a, v)| { match a { LabelItem::Address(a) =>(a.to_string(), DbLabelledKind::Address, v), LabelItem::Txid(a) =>(a.to_string(), DbLabelledKind::Txid, v), LabelItem::OutPoint(a) =>(a.to_string(), DbLabelledKind::OutPoint, v), } }) { if let Some(value) = value { db_tx.execute( "INSERT INTO labels (wallet_id, item, item_kind, value) VALUES (?1, ?2, ?3, ?4) \ ON CONFLICT DO UPDATE SET value=excluded.value", rusqlite::params![WALLET_ID, labelled, kind as i64, value], )?; } else { db_tx.execute( "DELETE FROM labels WHERE wallet_id = ?1 AND item = ?2", rusqlite::params![WALLET_ID, labelled], )?; } } Ok(()) }) .expect("Db must not fail") } pub fn db_labels(&mut self, items: &HashSet) -> Vec { let query = format!( "SELECT * FROM labels where item in ({})", items .iter() .map(|a| format!("'{}'", a)) .collect::>() .join(",") ); db_query(&mut self.conn, &query, rusqlite::params![], |row| { row.try_into() }) .expect("Db must not fail") } /// Retrieves a limited and ordered list of transactions ids that happened during the given /// range. pub fn db_list_txids(&mut self, start: u32, end: u32, limit: u64) -> Vec { db_query( &mut self.conn, "SELECT DISTINCT(txid) FROM ( \ SELECT * from ( \ SELECT txid, blocktime AS date FROM coins \ WHERE blocktime >= (?1) \ AND blocktime <= (?2) \ ORDER BY blocktime \ ) \ UNION \ SELECT * FROM ( SELECT spend_txid AS txid, spend_block_time AS date FROM coins \ WHERE spend_block_time >= (?1) \ AND spend_block_time <= (?2) \ ORDER BY spend_block_time \ ) \ ORDER BY date DESC LIMIT (?3) \ )", rusqlite::params![start, end, limit], |row| { let txid: Vec = row.get(0)?; let txid: bitcoin::Txid = encode::deserialize(&txid).expect("We only store valid txids"); Ok(txid) }, ) .expect("Db must not fail") } /// Retrieves all txids from the transactions table whether or not they are referenced by a coin. pub fn db_list_saved_txids(&mut self) -> Vec { db_query( &mut self.conn, "SELECT txid FROM transactions", rusqlite::params![], |row| { let txid: Vec = row.get(0)?; let txid: bitcoin::Txid = encode::deserialize(&txid).expect("We only store valid txids"); Ok(txid) }, ) .expect("Db must not fail") } /// Store transactions in database, ignoring any that already exist. pub fn new_txs(&mut self, txs: &[bitcoin::Transaction]) { db_exec(&mut self.conn, |db_tx| { for tx in txs { let txid = &tx.txid()[..].to_vec(); let tx_ser = bitcoin::consensus::serialize(tx); db_tx.execute( "INSERT INTO transactions (txid, tx) VALUES (?1, ?2) \ ON CONFLICT DO NOTHING", rusqlite::params![txid, tx_ser,], )?; } Ok(()) }) .expect("Database must be available") } pub fn list_wallet_transactions( &mut self, txids: &[bitcoin::Txid], ) -> Vec { // The UNION will remove duplicates. // We assume that a transaction's block info is the same in every coins row // it appears in. let query = format!( "SELECT t.tx, c.blockheight, c.blocktime \ FROM transactions t \ INNER JOIN ( \ SELECT txid, blockheight, blocktime \ FROM coins \ WHERE wallet_id = {WALLET_ID} \ UNION \ SELECT spend_txid, spend_block_height, spend_block_time \ FROM coins \ WHERE wallet_id = {WALLET_ID} \ AND spend_txid IS NOT NULL \ ) c ON t.txid = c.txid \ WHERE t.txid in ({})", txids .iter() .map(|txid| format!("x'{}'", FrontwardHexTxid(*txid))) .collect::>() .join(",") ); let w_txs: Vec = db_query(&mut self.conn, &query, rusqlite::params![], |row| { row.try_into() }) .expect("Db must not fail"); debug_assert_eq!( w_txs.len(), w_txs .iter() .map(|t| t.transaction.txid()) .collect::>() .len(), "database must not contain inconsistent block info for the same txid" ); w_txs } pub fn delete_spend(&mut self, txid: &bitcoin::Txid) { db_exec(&mut self.conn, |db_tx| { db_tx.execute( "DELETE FROM spend_transactions WHERE txid = ?1", rusqlite::params![txid[..].to_vec()], )?; Ok(()) }) .expect("Db must not fail"); } // TODO: mark coinbase deposits that were mature and became immature as such. /// Unconfirm all data that was marked as being confirmed *after* the given chain /// tip, and set it as our new best block seen. /// /// This includes: /// - Coins (coinbase deposits that became immature isn't currently implemented) /// - Spending transactions confirmation /// - Tip /// /// This will have to be updated if we are to add new fields based on block data /// in the database eventually. pub fn rollback_tip(&mut self, new_tip: &BlockChainTip) { db_exec(&mut self.conn, |db_tx| { db_tx.execute( "UPDATE coins SET blockheight = NULL, blocktime = NULL, spend_block_height = NULL, spend_block_time = NULL WHERE blockheight > ?1", rusqlite::params![new_tip.height], )?; db_tx.execute( "UPDATE coins SET spend_block_height = NULL, spend_block_time = NULL WHERE spend_block_height > ?1", rusqlite::params![new_tip.height], )?; db_tx.execute( "UPDATE tip SET blockheight = (?1), blockhash = (?2)", rusqlite::params![new_tip.height, new_tip.hash[..].to_vec()], )?; Ok(()) }) .expect("Db must not fail"); } } #[cfg(test)] mod tests { use super::*; use crate::database::{BlockInfo, DbBlockInfo}; use crate::testutils::*; use std::{ collections::{HashMap, HashSet}, fs, path, str::FromStr, }; use bitcoin::bip32; // The database schema used by the first versions of Liana (database version 0). Used to test // migrations starting from the first version. const V0_SCHEMA: &str = "\ CREATE TABLE version ( version INTEGER NOT NULL ); /* About the Bitcoin network. */ CREATE TABLE tip ( network TEXT NOT NULL, blockheight INTEGER, blockhash BLOB ); /* This stores metadata about our wallet. We only support single wallet for * now (and the foreseeable future). * * The 'timestamp' field is the creation date of the wallet. We guarantee to have seen all * information related to our descriptor(s) that occured after this date. * The optional 'rescan_timestamp' field is a the timestamp we need to rescan the chain * for events related to our descriptor(s) from. */ CREATE TABLE wallets ( id INTEGER PRIMARY KEY NOT NULL, timestamp INTEGER NOT NULL, main_descriptor TEXT NOT NULL, deposit_derivation_index INTEGER NOT NULL, change_derivation_index INTEGER NOT NULL, rescan_timestamp INTEGER ); /* Our (U)TxOs. * * The 'spend_block_height' and 'spend_block.time' are only present if the spending * transaction for this coin exists and was confirmed. */ CREATE TABLE coins ( id INTEGER PRIMARY KEY NOT NULL, wallet_id INTEGER NOT NULL, blockheight INTEGER, blocktime INTEGER, txid BLOB NOT NULL, vout INTEGER NOT NULL, amount_sat INTEGER NOT NULL, derivation_index INTEGER NOT NULL, is_change BOOLEAN NOT NULL CHECK (is_change IN (0,1)), spend_txid BLOB, spend_block_height INTEGER, spend_block_time INTEGER, UNIQUE (txid, vout), FOREIGN KEY (wallet_id) REFERENCES wallets (id) ON UPDATE RESTRICT ON DELETE RESTRICT ); /* A mapping from descriptor address to derivation index. Necessary until * we can get the derivation index from the parent descriptor from bitcoind. */ CREATE TABLE addresses ( receive_address TEXT NOT NULL UNIQUE, change_address TEXT NOT NULL UNIQUE, derivation_index INTEGER NOT NULL UNIQUE ); /* Transactions we created that spend some of our coins. */ CREATE TABLE spend_transactions ( id INTEGER PRIMARY KEY NOT NULL, psbt BLOB UNIQUE NOT NULL, txid BLOB UNIQUE NOT NULL ); "; const V3_SCHEMA: &str = "\ CREATE TABLE version ( version INTEGER NOT NULL ); /* About the Bitcoin network. */ CREATE TABLE tip ( network TEXT NOT NULL, blockheight INTEGER, blockhash BLOB ); /* This stores metadata about our wallet. We only support single wallet for * now (and the foreseeable future). * * The 'timestamp' field is the creation date of the wallet. We guarantee to have seen all * information related to our descriptor(s) that occured after this date. * The optional 'rescan_timestamp' field is a the timestamp we need to rescan the chain * for events related to our descriptor(s) from. */ CREATE TABLE wallets ( id INTEGER PRIMARY KEY NOT NULL, timestamp INTEGER NOT NULL, main_descriptor TEXT NOT NULL, deposit_derivation_index INTEGER NOT NULL, change_derivation_index INTEGER NOT NULL, rescan_timestamp INTEGER ); /* Our (U)TxOs. * * The 'spend_block_height' and 'spend_block.time' are only present if the spending * transaction for this coin exists and was confirmed. * * The 'is_immature' field is for coinbase deposits that are not yet buried under 100 * blocks. Note coinbase deposits can't be change. They also technically can't be * unconfirmed but we keep them as such until they become mature. */ CREATE TABLE coins ( id INTEGER PRIMARY KEY NOT NULL, wallet_id INTEGER NOT NULL, blockheight INTEGER, blocktime INTEGER, txid BLOB NOT NULL, vout INTEGER NOT NULL, amount_sat INTEGER NOT NULL, derivation_index INTEGER NOT NULL, is_change BOOLEAN NOT NULL CHECK (is_change IN (0,1)), spend_txid BLOB, spend_block_height INTEGER, spend_block_time INTEGER, is_immature BOOLEAN NOT NULL CHECK (is_immature IN (0,1)), CHECK (is_change IS 0 OR is_immature IS 0), UNIQUE (txid, vout), FOREIGN KEY (wallet_id) REFERENCES wallets (id) ON UPDATE RESTRICT ON DELETE RESTRICT ); /* A mapping from descriptor address to derivation index. Necessary until * we can get the derivation index from the parent descriptor from bitcoind. */ CREATE TABLE addresses ( receive_address TEXT NOT NULL UNIQUE, change_address TEXT NOT NULL UNIQUE, derivation_index INTEGER NOT NULL UNIQUE ); /* Transactions we created that spend some of our coins. */ CREATE TABLE spend_transactions ( id INTEGER PRIMARY KEY NOT NULL, psbt BLOB UNIQUE NOT NULL, txid BLOB UNIQUE NOT NULL, updated_at INTEGER ); /* Labels applied on addresses (0), outpoints (1), txids (2) */ CREATE TABLE labels ( id INTEGER PRIMARY KEY NOT NULL, wallet_id INTEGER NOT NULL, item_kind INTEGER NOT NULL CHECK (item_kind IN (0,1,2)), item TEXT UNIQUE NOT NULL, value TEXT NOT NULL ); "; const V4_SCHEMA: &str = " CREATE TABLE version ( version INTEGER NOT NULL ); /* About the Bitcoin network. */ CREATE TABLE tip ( network TEXT NOT NULL, blockheight INTEGER, blockhash BLOB ); /* This stores metadata about our wallet. We only support single wallet for * now (and the foreseeable future). * * The 'timestamp' field is the creation date of the wallet. We guarantee to have seen all * information related to our descriptor(s) that occured after this date. * The optional 'rescan_timestamp' field is a the timestamp we need to rescan the chain * for events related to our descriptor(s) from. */ CREATE TABLE wallets ( id INTEGER PRIMARY KEY NOT NULL, timestamp INTEGER NOT NULL, main_descriptor TEXT NOT NULL, deposit_derivation_index INTEGER NOT NULL, change_derivation_index INTEGER NOT NULL, rescan_timestamp INTEGER ); /* Our (U)TxOs. * * The 'spend_block_height' and 'spend_block.time' are only present if the spending * transaction for this coin exists and was confirmed. * * The 'is_immature' field is for coinbase deposits that are not yet buried under 100 * blocks. Note coinbase deposits can't technically be unconfirmed but we keep them * as such until they become mature. */ CREATE TABLE coins ( id INTEGER PRIMARY KEY NOT NULL, wallet_id INTEGER NOT NULL, blockheight INTEGER, blocktime INTEGER, txid BLOB NOT NULL, vout INTEGER NOT NULL, amount_sat INTEGER NOT NULL, derivation_index INTEGER NOT NULL, is_change BOOLEAN NOT NULL CHECK (is_change IN (0,1)), spend_txid BLOB, spend_block_height INTEGER, spend_block_time INTEGER, is_immature BOOLEAN NOT NULL CHECK (is_immature IN (0,1)), UNIQUE (txid, vout), FOREIGN KEY (wallet_id) REFERENCES wallets (id) ON UPDATE RESTRICT ON DELETE RESTRICT ); /* A mapping from descriptor address to derivation index. Necessary until * we can get the derivation index from the parent descriptor from bitcoind. */ CREATE TABLE addresses ( receive_address TEXT NOT NULL UNIQUE, change_address TEXT NOT NULL UNIQUE, derivation_index INTEGER NOT NULL UNIQUE ); /* Transactions we created that spend some of our coins. */ CREATE TABLE spend_transactions ( id INTEGER PRIMARY KEY NOT NULL, psbt BLOB UNIQUE NOT NULL, txid BLOB UNIQUE NOT NULL, updated_at INTEGER ); /* Labels applied on addresses (0), outpoints (1), txids (2) */ CREATE TABLE labels ( id INTEGER PRIMARY KEY NOT NULL, wallet_id INTEGER NOT NULL, item_kind INTEGER NOT NULL CHECK (item_kind IN (0,1,2)), item TEXT UNIQUE NOT NULL, value TEXT NOT NULL ); "; fn psbt_from_str(psbt_str: &str) -> Psbt { Psbt::from_str(psbt_str).unwrap() } fn dummy_options() -> FreshDbOptions { let desc_str = "wsh(andor(pk([aabbccdd]tpubDEN9WSToTyy9ZQfaYqSKfmVqmq1VVLNtYfj3Vkqh67et57eJ5sTKZQBkHqSwPUsoSskJeaYnPttHe2VrkCsKA27kUaN9SDc5zhqeLzKa1rr/<0;1>/*),older(10000),pk([aabbccdd]tpubD8LYfn6njiA2inCoxwM7EuN3cuLVcaHAwLYeups13dpevd3nHLRdK9NdQksWXrhLQVxcUZRpnp5CkJ1FhE61WRAsHxDNAkvGkoQkAeWDYjV/<0;1>/*)))#dw4ulnrs"; let main_descriptor = LianaDescriptor::from_str(desc_str).unwrap(); FreshDbOptions::new(bitcoin::Network::Bitcoin, main_descriptor) } fn dummy_db() -> ( path::PathBuf, FreshDbOptions, secp256k1::Secp256k1, SqliteDb, ) { let tmp_dir = tmp_dir(); fs::create_dir_all(&tmp_dir).unwrap(); let secp = secp256k1::Secp256k1::verification_only(); let db_path: path::PathBuf = [tmp_dir.as_path(), path::Path::new("lianad.sqlite3")] .iter() .collect(); let options = dummy_options(); let db = SqliteDb::new(db_path, Some(options.clone()), &secp).unwrap(); (tmp_dir, options, secp, db) } #[test] fn db_startup_sanity_checks() { let tmp_dir = tmp_dir(); fs::create_dir_all(&tmp_dir).unwrap(); let secp = secp256k1::Secp256k1::verification_only(); let db_path: path::PathBuf = [tmp_dir.as_path(), path::Path::new("lianad.sqlite3")] .iter() .collect(); assert!(SqliteDb::new(db_path.clone(), None, &secp) .unwrap_err() .to_string() .contains("database file not found")); let options = dummy_options(); let db = SqliteDb::new(db_path.clone(), Some(options.clone()), &secp).unwrap(); db.sanity_check(bitcoin::Network::Testnet, &options.main_descriptor) .unwrap_err() .to_string() .contains("Database was created for network"); fs::remove_file(&db_path).unwrap(); let other_desc_str = "wsh(andor(pk([aabbccdd]tpubDExU4YLJkyQ9RRbVScQq2brFxWWha7WmAUByPWyaWYwmcTv3Shx8aHp6mVwuE5n4TeM4z5DTWGf2YhNPmXtfvyr8cUDVvA3txdrFnFgNdF7/<0;1>/*),older(10000),pk([aabbccdd]tpubD8LYfn6njiA2inCoxwM7EuN3cuLVcaHAwLYeups13dpevd3nHLRdK9NdQksWXrhLQVxcUZRpnp5CkJ1FhE61WRAsHxDNAkvGkoQkAeWDYjV/<0;1>/*)))"; let other_desc = LianaDescriptor::from_str(other_desc_str).unwrap(); let db = SqliteDb::new(db_path.clone(), Some(options.clone()), &secp).unwrap(); db.sanity_check(bitcoin::Network::Bitcoin, &other_desc) .unwrap_err() .to_string() .contains("Database descriptor mismatch"); fs::remove_file(&db_path).unwrap(); // TODO: version check let db = SqliteDb::new(db_path.clone(), Some(options.clone()), &secp).unwrap(); db.sanity_check(bitcoin::Network::Bitcoin, &options.main_descriptor) .unwrap(); let db = SqliteDb::new(db_path.clone(), None, &secp).unwrap(); db.sanity_check(bitcoin::Network::Bitcoin, &options.main_descriptor) .unwrap(); let db = SqliteDb::new(db_path, None, &secp).unwrap(); db.maybe_apply_migrations(&[]).unwrap(); db.sanity_check(bitcoin::Network::Bitcoin, &options.main_descriptor) .unwrap(); fs::remove_dir_all(tmp_dir).unwrap(); } #[test] fn db_tip_update() { let (tmp_dir, options, _, db) = dummy_db(); { let mut conn = db.connection().unwrap(); let db_tip = conn.db_tip(); assert!( db_tip.block_hash.is_none() && db_tip.block_height.is_none() && db_tip.network == options.bitcoind_network ); let new_tip = BlockChainTip { height: 746756, hash: bitcoin::BlockHash::from_str( "00000000000000000006d50e4c9fd269ddf690c94f422dff85e96f1a84b3a615", ) .unwrap(), }; conn.update_tip(&new_tip); let db_tip = conn.db_tip(); assert_eq!(db_tip.block_height.unwrap(), new_tip.height); assert_eq!(db_tip.block_hash.unwrap(), new_tip.hash); } fs::remove_dir_all(tmp_dir).unwrap(); } #[test] fn db_labels_update() { let (tmp_dir, _, _, db) = dummy_db(); { let txid_str = "0c62a990d20d54429e70859292e82374ba6b1b951a3ab60f26bb65fee5724ff7"; let txid = LabelItem::from_str(txid_str, bitcoin::Network::Bitcoin).unwrap(); let mut items = HashSet::new(); items.insert(txid.clone()); let mut conn = db.connection().unwrap(); let db_labels = conn.db_labels(&items); assert!(db_labels.is_empty()); let mut txids_labels = HashMap::new(); txids_labels.insert(txid.clone(), Some("hello".to_string())); conn.update_labels(&txids_labels); let db_labels = conn.db_labels(&items); assert_eq!(db_labels[0].value, "hello"); txids_labels.insert(txid.clone(), Some("hello again".to_string())); conn.update_labels(&txids_labels); let db_labels = conn.db_labels(&items); assert_eq!(db_labels[0].value, "hello again"); // Now delete the label by passing a None value. *txids_labels.get_mut(&txid).unwrap() = None; conn.update_labels(&txids_labels); let db_labels = conn.db_labels(&items); assert!(db_labels.is_empty()); } fs::remove_dir_all(tmp_dir).unwrap(); } #[test] fn db_coins() { let (tmp_dir, _, _, db) = dummy_db(); { let mut conn = db.connection().unwrap(); // Necessarily empty at first. assert!(conn.coins(&[], &[]).is_empty()); let txs: Vec<_> = (0..6) .map(|i| bitcoin::Transaction { version: bitcoin::transaction::Version::TWO, lock_time: bitcoin::absolute::LockTime::from_height(i).unwrap(), input: Vec::new(), output: Vec::new(), }) .collect(); conn.new_txs(&txs); // Add one unconfirmed coin. let outpoint_a = bitcoin::OutPoint::new(txs.first().unwrap().txid(), 1); let coin_a = Coin { outpoint: outpoint_a, is_immature: false, block_info: None, amount: bitcoin::Amount::from_sat(10000), derivation_index: bip32::ChildNumber::from_normal_idx(10).unwrap(), is_change: false, spend_txid: None, spend_block: None, }; conn.new_unspent_coins(&[coin_a]); // We can query by status and/or outpoint. assert!([ conn.coins(&[], &[]), conn.coins(&[CoinStatus::Unconfirmed], &[]), conn.coins(&[CoinStatus::Unconfirmed], &[outpoint_a]), conn.coins(&[], &[outpoint_a]), conn.db_coins(&[outpoint_a]), ] .iter() .all(|res| res.len() == 1 && res[0].outpoint == coin_a.outpoint)); // It will not be returned if we filter for other statuses. assert!(conn .coins( &[ CoinStatus::Confirmed, CoinStatus::Spending, CoinStatus::Spent ], &[] ) .is_empty()); // Filtering also for its outpoint will still not return it if status does not match. assert!(conn .coins( &[ CoinStatus::Confirmed, CoinStatus::Spending, CoinStatus::Spent ], &[outpoint_a] ) .is_empty()); // Add a second coin. let outpoint_b = bitcoin::OutPoint::new(txs.get(1).unwrap().txid(), 12); let coin_b = Coin { outpoint: outpoint_b, is_immature: false, block_info: None, amount: bitcoin::Amount::from_sat(1111), derivation_index: bip32::ChildNumber::from_normal_idx(103).unwrap(), is_change: true, spend_txid: None, spend_block: None, }; conn.new_unspent_coins(&[coin_b]); // Both coins are unconfirmed. assert!([ conn.coins(&[], &[]), conn.coins(&[CoinStatus::Unconfirmed], &[]), conn.coins(&[CoinStatus::Unconfirmed], &[outpoint_a, outpoint_b]), conn.coins(&[], &[outpoint_a, outpoint_b]), conn.db_coins(&[outpoint_a, outpoint_b]), ] .iter() .all(|c| c.len() == 2 && c[0].outpoint == coin_a.outpoint && c[1].outpoint == coin_b.outpoint)); // We can filter for just the first coin. assert!([ conn.coins(&[CoinStatus::Unconfirmed], &[outpoint_a]), conn.coins(&[], &[outpoint_a]), conn.db_coins(&[outpoint_a]) ] .iter() .all(|res| res.len() == 1 && res[0].outpoint == coin_a.outpoint)); // Or we can filter for just the second coin. assert!([ conn.coins(&[CoinStatus::Unconfirmed], &[outpoint_b]), conn.coins(&[], &[outpoint_b]), conn.db_coins(&[outpoint_b]) ] .iter() .all(|res| res.len() == 1 && res[0].outpoint == coin_b.outpoint)); // There are no coins with other statuses. assert!(conn .coins( &[ CoinStatus::Confirmed, CoinStatus::Spending, CoinStatus::Spent ], &[] ) .is_empty()); // Now if we confirm one, it'll be marked as such. conn.confirm_coins(&[(coin_a.outpoint, 174500, 174500)]); assert!([ conn.coins(&[CoinStatus::Confirmed], &[]), conn.coins(&[CoinStatus::Confirmed], &[outpoint_a]), conn.coins(&[], &[outpoint_a]), conn.db_coins(&[outpoint_a]), ] .iter() .all(|res| res.len() == 1 && res[0].outpoint == coin_a.outpoint)); // We can get both confirmed and unconfirmed. assert!([ conn.coins(&[], &[]), conn.coins(&[CoinStatus::Unconfirmed, CoinStatus::Confirmed], &[]), conn.coins( &[CoinStatus::Unconfirmed, CoinStatus::Confirmed], &[outpoint_a, outpoint_b] ), conn.coins(&[], &[outpoint_a, outpoint_b]), conn.db_coins(&[outpoint_a, outpoint_b]), ] .iter() .all(|c| c.len() == 2 && c[0].outpoint == coin_a.outpoint && c[1].outpoint == coin_b.outpoint)); // Now if we spend one, it'll be marked as such. conn.spend_coins(&[(coin_a.outpoint, txs.get(2).unwrap().txid())]); assert!([ conn.coins(&[CoinStatus::Spending], &[]), conn.coins(&[CoinStatus::Spending], &[outpoint_a]), conn.coins(&[], &[outpoint_a]), conn.list_spending_coins(), conn.db_coins(&[outpoint_a]) ] .iter() .all(|res| res.len() == 1 && res[0].outpoint == coin_a.outpoint)); // The second coin is still unconfirmed. assert!([ conn.coins(&[CoinStatus::Unconfirmed], &[]), conn.coins(&[CoinStatus::Unconfirmed], &[outpoint_b]), conn.coins(&[], &[outpoint_b]), conn.db_coins(&[outpoint_b]) ] .iter() .all(|res| res.len() == 1 && res[0].outpoint == coin_b.outpoint)); // Now we confirm the spend. conn.confirm_spend(&[( coin_a.outpoint, txs.get(2).unwrap().txid(), 128_097, 3_000_000, )]); // The coin no longer has spending status. assert!([ conn.coins(&[CoinStatus::Spending], &[]), conn.coins(&[CoinStatus::Spending], &[outpoint_a]), conn.list_spending_coins(), ] .iter() .all(|res| res.is_empty())); // Both coins are still in DB. assert!([ conn.coins(&[], &[]), conn.coins(&[CoinStatus::Unconfirmed, CoinStatus::Spent], &[]), conn.coins( &[CoinStatus::Unconfirmed, CoinStatus::Spent], &[outpoint_a, outpoint_b] ), conn.coins(&[], &[outpoint_a, outpoint_b]), conn.db_coins(&[outpoint_a, outpoint_b]), ] .iter() .all(|c| c.len() == 2 && c[0].outpoint == coin_a.outpoint && c[1].outpoint == coin_b.outpoint)); // Add a third and fourth coin. let outpoint_c = bitcoin::OutPoint::new(txs.get(3).unwrap().txid(), 42); let coin_c = Coin { outpoint: outpoint_c, is_immature: false, block_info: None, amount: bitcoin::Amount::from_sat(30000), derivation_index: bip32::ChildNumber::from_normal_idx(4103).unwrap(), is_change: false, spend_txid: None, spend_block: None, }; let outpoint_d = bitcoin::OutPoint::new(txs.get(4).unwrap().txid(), 43); let coin_d = Coin { outpoint: outpoint_d, is_immature: false, block_info: None, amount: bitcoin::Amount::from_sat(40000), derivation_index: bip32::ChildNumber::from_normal_idx(4104).unwrap(), is_change: false, spend_txid: None, spend_block: None, }; conn.new_unspent_coins(&[coin_c, coin_d]); // We can get all three unconfirmed coins with different status/outpoint filters. assert!([ conn.coins(&[CoinStatus::Unconfirmed], &[]), conn.coins( &[CoinStatus::Unconfirmed], &[outpoint_b, outpoint_c, outpoint_d] ), conn.coins(&[], &[outpoint_b, outpoint_c, outpoint_d]), conn.db_coins(&[outpoint_b, outpoint_c, outpoint_d]), ] .iter() .all(|coin| coin.len() == 3 && coin[0].outpoint == coin_b.outpoint && coin[1].outpoint == coin_c.outpoint && coin[2].outpoint == coin_d.outpoint)); // We can also get two of the three unconfirmed coins by filtering for their outpoints. assert!([ conn.coins(&[CoinStatus::Unconfirmed], &[outpoint_b, outpoint_c]), conn.coins(&[], &[outpoint_b, outpoint_c]), conn.db_coins(&[outpoint_b, outpoint_c]), ] .iter() .all(|coin| coin.len() == 2 && coin[0].outpoint == coin_b.outpoint && coin[1].outpoint == coin_c.outpoint)); // Now spend second coin, even though it is still unconfirmed. conn.spend_coins(&[(coin_b.outpoint, txs.get(5).unwrap().txid())]); // The coin shows as spending. assert!([ conn.coins(&[CoinStatus::Spending], &[]), conn.coins(&[CoinStatus::Spending], &[outpoint_b]), conn.coins(&[], &[outpoint_b]), conn.list_spending_coins(), conn.db_coins(&[outpoint_b]) ] .iter() .all(|res| res.len() == 1 && res[0].outpoint == coin_b.outpoint)); // Now confirm the third coin. conn.confirm_coins(&[(coin_c.outpoint, 175500, 175500)]); // We now only have one unconfirmed coin. assert!([ conn.coins(&[CoinStatus::Unconfirmed], &[]), conn.coins( &[CoinStatus::Unconfirmed], &[outpoint_a, outpoint_b, outpoint_c, outpoint_d] ), conn.coins(&[], &[outpoint_d]), conn.db_coins(&[outpoint_d]), ] .iter() .all(|c| c.len() == 1 && c[0].outpoint == coin_d.outpoint)); // There is now one coin for each status. assert!([ conn.coins(&[CoinStatus::Unconfirmed], &[]), conn.coins(&[CoinStatus::Unconfirmed], &[outpoint_d]), conn.coins(&[CoinStatus::Confirmed], &[]), conn.coins(&[CoinStatus::Confirmed], &[outpoint_c]), conn.coins(&[CoinStatus::Spending], &[]), conn.coins(&[CoinStatus::Spending], &[outpoint_b]), conn.coins(&[CoinStatus::Spent], &[]), conn.coins(&[CoinStatus::Spent], &[outpoint_a]), conn.coins(&[], &[outpoint_a]), conn.coins(&[], &[outpoint_b]), conn.coins(&[], &[outpoint_c]), conn.coins(&[], &[outpoint_d]), ] .iter() .map(|c| c.len()) .all(|length| length == 1)); } fs::remove_dir_all(tmp_dir).unwrap(); } #[test] fn db_coins_update() { let (tmp_dir, _, _, db) = dummy_db(); { let mut conn = db.connection().unwrap(); let txs: Vec<_> = (0..4) .map(|i| bitcoin::Transaction { version: bitcoin::transaction::Version::TWO, lock_time: bitcoin::absolute::LockTime::from_height(i).unwrap(), input: Vec::new(), output: Vec::new(), }) .collect(); conn.new_txs(&txs); // Necessarily empty at first. assert!(conn.coins(&[], &[]).is_empty()); // Add one, we'll get it. let coin_a = Coin { outpoint: bitcoin::OutPoint::new(txs.first().unwrap().txid(), 1), is_immature: false, block_info: None, amount: bitcoin::Amount::from_sat(98765), derivation_index: bip32::ChildNumber::from_normal_idx(10).unwrap(), is_change: false, spend_txid: None, spend_block: None, }; conn.new_unspent_coins(&[coin_a]); assert_eq!(conn.coins(&[], &[])[0].outpoint, coin_a.outpoint); // We can also remove it. Say the unconfirmed tx that created it got replaced. conn.remove_coins(&[coin_a.outpoint]); assert!(conn.coins(&[], &[]).is_empty()); // Add it back for the rest of the test. conn.new_unspent_coins(&[coin_a]); // We can query it by its outpoint let coins = conn.db_coins(&[coin_a.outpoint]); assert_eq!(coins.len(), 1); assert_eq!(coins[0].outpoint, coin_a.outpoint); // It is unconfirmed. assert_eq!( conn.coins(&[CoinStatus::Unconfirmed], &[])[0].outpoint, coin_a.outpoint ); assert!(conn .coins( &[ CoinStatus::Confirmed, CoinStatus::Spending, CoinStatus::Spent ], &[] ) .is_empty()); // Add a second one (this one is change), we'll get both. let coin_b = Coin { outpoint: bitcoin::OutPoint::new(txs.get(1).unwrap().txid(), 12), is_immature: false, block_info: None, amount: bitcoin::Amount::from_sat(1111), derivation_index: bip32::ChildNumber::from_normal_idx(103).unwrap(), is_change: true, spend_txid: None, spend_block: None, }; conn.new_unspent_coins(&[coin_b]); let outpoints: HashSet = conn .coins(&[], &[]) .into_iter() .map(|c| c.outpoint) .collect(); assert!(outpoints.contains(&coin_a.outpoint)); assert!(outpoints.contains(&coin_b.outpoint)); // We can query both by their outpoints let coins = conn.db_coins(&[coin_a.outpoint]); assert_eq!(coins.len(), 1); assert_eq!(coins[0].outpoint, coin_a.outpoint); let coins = conn.db_coins(&[coin_b.outpoint]); assert_eq!(coins.len(), 1); assert_eq!(coins[0].outpoint, coin_b.outpoint); let coins = conn.db_coins(&[coin_a.outpoint, coin_b.outpoint]); assert_eq!(coins.len(), 2); assert!(coins.iter().any(|c| c.outpoint == coin_a.outpoint)); assert!(coins.iter().any(|c| c.outpoint == coin_b.outpoint)); // They are both unconfirmed. assert_eq!(conn.coins(&[CoinStatus::Unconfirmed], &[]).len(), 2); assert!(conn .coins( &[ CoinStatus::Confirmed, CoinStatus::Spending, CoinStatus::Spent ], &[] ) .is_empty()); // Now if we confirm one, it'll be marked as such. let height = 174500; let time = 174500; conn.confirm_coins(&[(coin_a.outpoint, height, time)]); let coins = conn.coins(&[], &[]); assert_eq!(coins[0].block_info, Some(DbBlockInfo { height, time })); assert!(coins[1].block_info.is_none()); // Now if we spend one, it'll be marked as such. conn.spend_coins(&[(coin_a.outpoint, txs.get(2).unwrap().txid())]); let coin = conn .coins(&[], &[coin_a.outpoint]) .into_iter() .next() .unwrap(); assert!(coin.spend_txid.is_some()); // We can unspend it, if the spend transaction gets double spent. conn.unspend_coins(&[coin_a.outpoint]); let coin = conn .coins(&[], &[coin_a.outpoint]) .into_iter() .next() .unwrap(); assert!(coin.spend_txid.is_none()); // Spend it back. We will see it as 'spending' conn.spend_coins(&[(coin_a.outpoint, txs.get(2).unwrap().txid())]); let outpoints: HashSet = conn .list_spending_coins() .into_iter() .map(|c| c.outpoint) .collect(); assert!(outpoints.contains(&coin_a.outpoint)); // The first one is spending, not the second one. assert_eq!( conn.coins(&[CoinStatus::Spending], &[])[0].outpoint, coin_a.outpoint ); assert_eq!( conn.coins(&[CoinStatus::Unconfirmed], &[])[0].outpoint, coin_b.outpoint ); // Now if we confirm the spend. let height = 128_097; let time = 3_000_000; conn.confirm_spend(&[(coin_a.outpoint, txs.get(2).unwrap().txid(), height, time)]); // the coin is not in a spending state. let outpoints: HashSet = conn .list_spending_coins() .into_iter() .map(|c| c.outpoint) .collect(); assert!(outpoints.is_empty()); // Both are still in DB let coins = conn.db_coins(&[coin_a.outpoint, coin_b.outpoint]); assert_eq!(coins.len(), 2); // The confirmed one contains the right time and block height let coin = conn.db_coins(&[coin_a.outpoint]).pop().unwrap(); assert!(coin.spend_block.is_some()); assert_eq!(coin.spend_block.as_ref().unwrap().time, time); assert_eq!(coin.spend_block.unwrap().height, height); // If we unspend it all spend info will be wiped. conn.unspend_coins(&[coin_a.outpoint]); let coin = conn .coins(&[], &[coin_a.outpoint]) .into_iter() .next() .unwrap(); assert!(coin.spend_txid.is_none()); assert!(coin.spend_block.is_none()); // Add an immature coin. As all coins it's first registered as unconfirmed (even though // it's not). let coin_imma = Coin { outpoint: bitcoin::OutPoint::new(txs.get(3).unwrap().txid(), 42), is_immature: true, block_info: None, amount: bitcoin::Amount::from_sat(424242), derivation_index: bip32::ChildNumber::from_normal_idx(4103).unwrap(), is_change: false, spend_txid: None, spend_block: None, }; conn.new_unspent_coins(&[coin_imma]); let outpoints: HashSet = conn .coins(&[], &[]) .into_iter() .map(|c| c.outpoint) .collect(); assert!(outpoints.contains(&coin_imma.outpoint)); let coin = conn.db_coins(&[coin_imma.outpoint]).pop().unwrap(); assert!(coin.is_immature && !coin.is_change); // Confirming an immature coin marks it as mature. let (height, time) = (424242, 424241); conn.confirm_coins(&[(coin_imma.outpoint, height, time)]); let coin = conn.db_coins(&[coin_imma.outpoint]).pop().unwrap(); assert!(!coin.is_immature); } fs::remove_dir_all(tmp_dir).unwrap(); } #[test] fn sqlite_addresses_cache() { let (tmp_dir, options, secp, db) = dummy_db(); { let mut conn = db.connection().unwrap(); // There is the index for the first index let addr = options .main_descriptor .receive_descriptor() .derive(0.into(), &secp) .address(options.bitcoind_network); let db_addr = conn.db_address(&addr).unwrap(); assert_eq!(db_addr.derivation_index, 0.into()); // And also for the change address let addr = options .main_descriptor .change_descriptor() .derive(0.into(), &secp) .address(options.bitcoind_network); let db_addr = conn.db_address(&addr).unwrap(); assert_eq!(db_addr.derivation_index, 0.into()); // There is the index for the 199th index (look-ahead limit) let addr = options .main_descriptor .receive_descriptor() .derive(199.into(), &secp) .address(options.bitcoind_network); let db_addr = conn.db_address(&addr).unwrap(); assert_eq!(db_addr.derivation_index, 199.into()); // And not for the 200th one. let addr = options .main_descriptor .receive_descriptor() .derive(200.into(), &secp) .address(options.bitcoind_network); assert!(conn.db_address(&addr).is_none()); // But if we increment the deposit derivation index, the 200th one will be there. conn.set_derivation_index(1.into(), false, &secp); let db_addr = conn.db_address(&addr).unwrap(); assert_eq!(db_addr.derivation_index, 200.into()); // It will also be there for the change descriptor. let addr = options .main_descriptor .change_descriptor() .derive(200.into(), &secp) .address(options.bitcoind_network); let db_addr = conn.db_address(&addr).unwrap(); assert_eq!(db_addr.derivation_index, 200.into()); // But not for the 201th. let addr = options .main_descriptor .change_descriptor() .derive(201.into(), &secp) .address(options.bitcoind_network); assert!(conn.db_address(&addr).is_none()); // If we increment the *change* derivation index to 1, it will still not be there. conn.set_derivation_index(1.into(), true, &secp); assert!(conn.db_address(&addr).is_none()); // But incrementing it once again it will be there for both change and receive. conn.set_derivation_index(2.into(), true, &secp); let db_addr = conn.db_address(&addr).unwrap(); assert_eq!(db_addr.derivation_index, 201.into()); let addr = options .main_descriptor .receive_descriptor() .derive(201.into(), &secp) .address(options.bitcoind_network); let db_addr = conn.db_address(&addr).unwrap(); assert_eq!(db_addr.derivation_index, 201.into()); // Now setting it to a much higher will fill all the addresses within the gap conn.set_derivation_index(52.into(), true, &secp); for index in 2..52 { let look_ahead_index = 200 + index; let addr = options .main_descriptor .receive_descriptor() .derive(look_ahead_index.into(), &secp) .address(options.bitcoind_network); let db_addr = conn.db_address(&addr).unwrap(); assert_eq!(db_addr.derivation_index, look_ahead_index.into()); } // Suppose the latest change derivation index was set to 52 above by the commands // thread. Suppose concurrently the Bitcoin poller thread queried the DB for the // latest derivation just before it happened, got 2 as a response, and then increased // the derivation index to -say- 7 after noticing a new change output paying to the // address at derivation index 6. It's absolutely possible and the only way to prevent // this is to make sure *within* the atomic DB transaction that we will never decrease // the derivation index. Make sure we actually perform this check (note it would only // crash during the second call). conn.set_derivation_index(7.into(), true, &secp); conn.set_derivation_index(8.into(), true, &secp); } fs::remove_dir_all(tmp_dir).unwrap(); } #[test] fn sqlite_tip_rollback() { let (tmp_dir, _, _, db) = dummy_db(); { let mut conn = db.connection().unwrap(); let old_tip = BlockChainTip { hash: bitcoin::BlockHash::from_str( "00000000000000000004f43b5e743757939082170673d27a5a5130e0eb238832", ) .unwrap(), height: 200_000, }; conn.update_tip(&old_tip); let txs: Vec<_> = (0..7) .map(|i| bitcoin::Transaction { version: bitcoin::transaction::Version::TWO, lock_time: bitcoin::absolute::LockTime::from_height(i).unwrap(), input: Vec::new(), output: Vec::new(), }) .collect(); conn.new_txs(&txs); // 5 coins: // - One unconfirmed // - One confirmed before the rollback height // - One confirmed before the rollback height but spent after // - One confirmed after the rollback height // - One spent after the rollback height // TODO: immature deposits let coins = [ Coin { outpoint: bitcoin::OutPoint::new(txs.first().unwrap().txid(), 1), is_immature: false, block_info: None, amount: bitcoin::Amount::from_sat(98765), derivation_index: bip32::ChildNumber::from_normal_idx(10).unwrap(), is_change: false, spend_txid: None, spend_block: None, }, Coin { outpoint: bitcoin::OutPoint::new(txs.get(1).unwrap().txid(), 2), is_immature: false, block_info: Some(BlockInfo { height: 101_095, time: 1_111_899, }), amount: bitcoin::Amount::from_sat(98765), derivation_index: bip32::ChildNumber::from_normal_idx(100).unwrap(), is_change: false, spend_txid: None, spend_block: None, }, Coin { outpoint: bitcoin::OutPoint::new(txs.get(2).unwrap().txid(), 3), is_immature: false, block_info: Some(BlockInfo { height: 101_099, time: 1_121_899, }), amount: bitcoin::Amount::from_sat(98765), derivation_index: bip32::ChildNumber::from_normal_idx(1000).unwrap(), is_change: false, spend_txid: Some(txs.get(3).unwrap().txid()), spend_block: Some(BlockInfo { height: 101_199, time: 1_231_678, }), }, Coin { outpoint: bitcoin::OutPoint::new(txs.get(4).unwrap().txid(), 4), is_immature: false, block_info: Some(BlockInfo { height: 101_100, time: 1_131_899, }), amount: bitcoin::Amount::from_sat(98765), derivation_index: bip32::ChildNumber::from_normal_idx(10000).unwrap(), is_change: false, spend_txid: None, spend_block: None, }, Coin { outpoint: bitcoin::OutPoint::new(txs.get(5).unwrap().txid(), 5), is_immature: false, block_info: Some(BlockInfo { height: 101_102, time: 1_134_899, }), amount: bitcoin::Amount::from_sat(98765), derivation_index: bip32::ChildNumber::from_normal_idx(100000).unwrap(), is_change: false, spend_txid: Some(txs.get(6).unwrap().txid()), spend_block: Some(BlockInfo { height: 101_105, time: 1_201_678, }), }, ]; conn.new_unspent_coins(&coins); conn.confirm_coins( &coins .iter() .filter_map(|c| c.block_info.map(|b| (c.outpoint, b.height, b.time))) .collect::>(), ); conn.confirm_spend( &coins .iter() .filter_map(|c| { c.spend_block .as_ref() .map(|b| (c.outpoint, c.spend_txid.unwrap(), b.height, b.time)) }) .collect::>(), ); let mut db_coins = conn .db_coins( &coins .iter() .map(|c| c.outpoint) .collect::>(), ) .into_iter() .map(Coin::from) .collect::>(); db_coins.sort_by(|c1, c2| c1.outpoint.vout.cmp(&c2.outpoint.vout)); assert_eq!(&db_coins[..], &coins[..]); // Now that everything is settled, reorg to a previous height. let new_tip = BlockChainTip { hash: bitcoin::BlockHash::from_str( "000000000000000000016440c591da27679abfa53ef44d45b016640dbd04e126", ) .unwrap(), height: 101_099, }; conn.rollback_tip(&new_tip); // The tip got updated let new_db_tip = conn.db_tip(); assert_eq!(new_db_tip.block_height.unwrap(), new_tip.height); assert_eq!(new_db_tip.block_hash.unwrap(), new_tip.hash); // And so were the coins let db_coins = conn .db_coins( &coins .iter() .map(|c| c.outpoint) .collect::>(), ) .into_iter() .map(|c| (c.outpoint, Coin::from(c))) .collect::>(); // The first coin is unchanged assert_eq!(db_coins[&coins[0].outpoint], coins[0]); // Same for the second one assert_eq!(db_coins[&coins[1].outpoint], coins[1]); // The third one got its spend confirmation info wiped, but only that let mut coin = coins[2]; coin.spend_block = None; assert_eq!(db_coins[&coins[2].outpoint], coin); // The fourth one got its own confirmation info wiped let mut coin = coins[3]; coin.block_info = None; assert_eq!(db_coins[&coins[3].outpoint], coin); // The fourth one got both is own confirmation and spend confirmation info wiped let mut coin = coins[4]; coin.block_info = None; coin.spend_block = None; assert_eq!(db_coins[&coins[4].outpoint], coin); } fs::remove_dir_all(tmp_dir).unwrap(); } #[test] fn db_rescan() { let (tmp_dir, _, _, db) = dummy_db(); { let mut conn = db.connection().unwrap(); // At first no rescan is ongoing let dummy_timestamp = 1_001; let db_wallet = conn.db_wallet(); assert!(db_wallet.rescan_timestamp.is_none()); assert!(db_wallet.timestamp > dummy_timestamp); // But if we set one there'll be conn.set_wallet_rescan_timestamp(dummy_timestamp); assert_eq!(conn.db_wallet().rescan_timestamp, Some(dummy_timestamp)); // Once it's done the rescan timestamp will be erased, and the // wallet timestamp will be set to the dummy timestamp since it's // lower. conn.complete_wallet_rescan(); let db_wallet = conn.db_wallet(); assert!(db_wallet.rescan_timestamp.is_none()); assert_eq!(db_wallet.timestamp, dummy_timestamp); // If we rescan from a later timestamp, we'll keep the existing // wallet timestamp afterward. conn.set_wallet_rescan_timestamp(dummy_timestamp + 1); assert_eq!(conn.db_wallet().rescan_timestamp, Some(dummy_timestamp + 1)); conn.complete_wallet_rescan(); let db_wallet = conn.db_wallet(); assert!(db_wallet.rescan_timestamp.is_none()); assert_eq!(db_wallet.timestamp, dummy_timestamp); } fs::remove_dir_all(tmp_dir).unwrap(); } #[test] fn sqlite_list_txids() { let (tmp_dir, _, _, db) = dummy_db(); { let mut conn = db.connection().unwrap(); let txs: Vec<_> = (0..7) .map(|i| bitcoin::Transaction { version: bitcoin::transaction::Version::TWO, lock_time: bitcoin::absolute::LockTime::from_height(i).unwrap(), input: Vec::new(), output: Vec::new(), }) .collect(); conn.new_txs(&txs); let coins = [ Coin { outpoint: bitcoin::OutPoint::new(txs.first().unwrap().txid(), 1), is_immature: false, block_info: None, amount: bitcoin::Amount::from_sat(98765), derivation_index: bip32::ChildNumber::from_normal_idx(10).unwrap(), is_change: false, spend_txid: None, spend_block: None, }, Coin { outpoint: bitcoin::OutPoint::new(txs.get(1).unwrap().txid(), 2), is_immature: false, block_info: Some(BlockInfo { height: 101_095, time: 1_121_000, }), amount: bitcoin::Amount::from_sat(98765), derivation_index: bip32::ChildNumber::from_normal_idx(100).unwrap(), is_change: false, spend_txid: None, spend_block: None, }, Coin { outpoint: bitcoin::OutPoint::new(txs.get(2).unwrap().txid(), 3), is_immature: false, block_info: Some(BlockInfo { height: 101_099, time: 1_122_000, }), amount: bitcoin::Amount::from_sat(98765), derivation_index: bip32::ChildNumber::from_normal_idx(1000).unwrap(), is_change: false, spend_txid: Some(txs.get(3).unwrap().txid()), spend_block: Some(BlockInfo { height: 101_199, time: 1_123_000, }), }, Coin { outpoint: bitcoin::OutPoint::new(txs.get(4).unwrap().txid(), 4), is_immature: true, block_info: Some(BlockInfo { height: 101_100, time: 1_124_000, }), amount: bitcoin::Amount::from_sat(98765), derivation_index: bip32::ChildNumber::from_normal_idx(10000).unwrap(), is_change: false, spend_txid: None, spend_block: None, }, Coin { outpoint: bitcoin::OutPoint::new(txs.get(5).unwrap().txid(), 5), is_immature: false, block_info: Some(BlockInfo { height: 101_102, time: 1_125_000, }), amount: bitcoin::Amount::from_sat(98765), derivation_index: bip32::ChildNumber::from_normal_idx(100000).unwrap(), is_change: false, spend_txid: Some(txs.get(6).unwrap().txid()), spend_block: Some(BlockInfo { height: 101_105, time: 1_126_000, }), }, ]; conn.new_unspent_coins(&coins); conn.confirm_coins( &coins .iter() .filter_map(|c| c.block_info.map(|b| (c.outpoint, b.height, b.time))) .collect::>(), ); conn.confirm_spend( &coins .iter() .filter_map(|c| { c.spend_block .as_ref() .map(|b| (c.outpoint, c.spend_txid.unwrap(), b.height, b.time)) }) .collect::>(), ); let db_txids = conn.db_list_txids(1_123_000, 1_127_000, 10); // Ordered by desc block time. let expected_txids = [6, 5, 4, 3].map(|i| txs.get(i).unwrap().txid()); assert_eq!(&db_txids[..], &expected_txids,); let db_txids = conn.db_list_txids(1_123_000, 1_127_000, 2); // Ordered by desc block time. let expected_txids = [6, 5].map(|i| txs.get(i).unwrap().txid()); assert_eq!(&db_txids[..], &expected_txids,); } fs::remove_dir_all(tmp_dir).unwrap(); } #[test] fn sqlite_list_saved_txids() { let (tmp_dir, _, _, db) = dummy_db(); { let mut conn = db.connection().unwrap(); let txs: Vec<_> = (0..7) .map(|i| bitcoin::Transaction { version: bitcoin::transaction::Version::TWO, lock_time: bitcoin::absolute::LockTime::from_height(i).unwrap(), input: Vec::new(), output: Vec::new(), }) .collect(); conn.new_txs(&txs); let mut db_txids = conn.db_list_saved_txids(); db_txids.sort(); let mut expected_txids: Vec<_> = txs.iter().map(|tx| tx.txid()).collect(); expected_txids.sort(); assert_eq!(&db_txids[..], &expected_txids,); } fs::remove_dir_all(tmp_dir).unwrap(); } #[test] fn sqlite_list_wallet_transactions() { let (tmp_dir, _, _, db) = dummy_db(); { let mut conn = db.connection().unwrap(); // The following is based on the `v4_to_v5_migration` test. let mut bitcoin_txs: Vec<_> = (0..100) .map(|i| bitcoin::Transaction { version: bitcoin::transaction::Version::TWO, lock_time: bitcoin::absolute::LockTime::from_height(i).unwrap(), input: Vec::new(), output: Vec::new(), }) .collect(); let spend_txs: Vec<_> = (0..10) .map(|i| { ( bitcoin::Transaction { version: bitcoin::transaction::Version::TWO, lock_time: bitcoin::absolute::LockTime::from_height(1_234 + i).unwrap(), input: Vec::new(), output: Vec::new(), }, if i % 2 == 0 { Some(BlockInfo { height: (i % 5) as i32 * 2_000, time: 1722488619 + (i % 5) * 84_999, }) } else { None }, ) }) .collect(); let coins: Vec = bitcoin_txs .iter() .chain(bitcoin_txs.iter()) // We do this to have coins which originate from the same tx. .enumerate() .map(|(i, tx)| Coin { outpoint: bitcoin::OutPoint { txid: tx.txid(), vout: i as u32, }, is_immature: (i % 10) == 0, amount: bitcoin::Amount::from_sat(i as u64 * 3473), derivation_index: bip32::ChildNumber::from_normal_idx(i as u32 * 100).unwrap(), is_change: (i % 4) == 0, block_info: if i & 2 == 0 { Some(BlockInfo { height: (i % 100) as i32 * 1_000, time: 1722408619 + (i % 100) as u32 * 42_000, }) } else { None }, spend_txid: if i % 20 == 0 { Some(spend_txs[i / 20].0.txid()) } else { None }, spend_block: if i % 20 == 0 { spend_txs[i / 20].1 } else { None }, }) .collect(); bitcoin_txs.extend(spend_txs.into_iter().map(|(tx, _)| tx)); conn.new_txs(&bitcoin_txs); // Insert all these coins into database. conn.new_unspent_coins(&coins); // Confirm those which are supposed to be. let confirmed_coins: Vec<_> = coins .iter() .filter_map(|coin| { coin.block_info .map(|blk| (coin.outpoint, blk.height, blk.time)) }) .collect(); conn.confirm_coins(&confirmed_coins); // Spend those which are supposed to be. let spent_coins: Vec<_> = coins .iter() .filter_map(|coin| coin.spend_txid.map(|txid| (coin.outpoint, txid))) .collect(); conn.spend_coins(&spent_coins); // Mark the spend as confirmed for those which are supposed to be. let confirmed_spent_coins: Vec<_> = coins .iter() .filter_map(|coin| { coin.spend_block.map(|blk| { ( coin.outpoint, coin.spend_txid.expect("always set when spend block is"), blk.height, blk.time, ) }) }) .collect(); conn.confirm_spend(&confirmed_spent_coins); // For easy lookup, map each tx to its txid. let bitcoin_txs: HashMap<_, _> = bitcoin_txs.into_iter().map(|tx| (tx.txid(), tx)).collect(); let block_info_from_coins: HashSet<_> = coins .iter() .map(|c| (c.outpoint.txid, c.block_info)) .chain( coins .iter() .filter_map(|c| c.spend_txid.map(|txid| (txid, c.spend_block))), ) .collect(); // Make sure each txid only has one block info. assert_eq!(bitcoin_txs.len(), block_info_from_coins.len()); // For each txid, determine its wallet transaction based on the coins defined above. let wallet_txs_from_coins: HashMap<_, _> = block_info_from_coins .into_iter() .map(|(txid, block_info)| { let tx = bitcoin_txs.get(&txid).unwrap(); ( txid, DbWalletTransaction { transaction: tx.clone(), block_info: block_info.map(|info| DbBlockInfo { height: info.height, time: info.time, }), }, ) }) .collect(); let all_txids: Vec<_> = bitcoin_txs.keys().cloned().collect(); for indices in [ (0..all_txids.len()).collect(), (0..all_txids.len() / 2).collect(), (all_txids.len() / 5..all_txids.len() / 2).collect(), vec![3, 4, 5, 6], vec![4, 5], vec![1, 3, 5], vec![1], vec![1, 1, 3, 4, 3], // we can pass duplicate txids vec![], // can pass empty slice ] { let txids: Vec<_> = indices .iter() .map(|i| *all_txids.get(*i).unwrap()) .collect(); // Make sure we have the expected number of txids. assert_eq!(txids.len(), indices.len()); let mut db_txs = conn.list_wallet_transactions(&txids); db_txs.sort_by(|a, b| a.transaction.txid().cmp(&b.transaction.txid())); let mut expected_txs: Vec<_> = txids .iter() .collect::>() // remove duplicates .into_iter() .map(|txid| wallet_txs_from_coins.get(txid).unwrap().clone()) .collect(); expected_txs.sort_by(|a, b| a.transaction.txid().cmp(&b.transaction.txid())); assert_eq!(&db_txs[..], &expected_txs[..],); } } fs::remove_dir_all(tmp_dir).unwrap(); } #[test] fn v0_to_v5_migration() { let secp = secp256k1::Secp256k1::verification_only(); // Create a database with version 0, using the old schema. let tmp_dir = tmp_dir(); fs::create_dir_all(&tmp_dir).unwrap(); let db_path: path::PathBuf = [tmp_dir.as_path(), path::Path::new("lianad_v0.sqlite3")] .iter() .collect(); let mut options = dummy_options(); options.schema = V0_SCHEMA; options.version = 0; create_fresh_db(&db_path, options, &secp).unwrap(); // Two PSBTs we'll insert in the DB before and after the migration. Note they are random // PSBTs taken from the descriptor unit tests, it doesn't matter. let first_psbt = psbt_from_str("cHNidP8BAIkCAAAAAWi3OFgkj1CqCDT3Swm8kbxZS9lxz4L3i4W2v9KGC7nqAQAAAAD9////AkANAwAAAAAAIgAg27lNc1rog+dOq80ohRuds4Hgg/RcpxVun2XwgpuLSrFYMwwAAAAAACIAIDyWveqaElWmFGkTbFojg1zXWHODtiipSNjfgi2DqBy9AAAAAAABAOoCAAAAAAEBsRWl70USoAFFozxc86pC7Dovttdg4kvja//3WMEJskEBAAAAAP7///8CWKmCIk4GAAAWABRKBWYWkCNS46jgF0r69Ehdnq+7T0BCDwAAAAAAIgAgTt5fs+CiB+FRzNC8lHcgWLH205sNjz1pT59ghXlG5tQCRzBEAiBXK9MF8z3bX/VnY2aefgBBmiAHPL4tyDbUOe7+KpYA4AIgL5kU0DFG8szKd+szRzz/OTUWJ0tZqij41h2eU9rSe1IBIQNBB1hy+jKsg1TihMT0dXw7etpu9TkO3NuvhBDFJlBj1cP2AQABAStAQg8AAAAAACIAIE7eX7PgogfhUczQvJR3IFix9tObDY89aU+fYIV5RubUIgICSKJsNs0zFJN58yd2aYQ+C3vhMbi0x7k0FV3wBhR4THlIMEUCIQCPWWWOhs2lThxOq/G8X2fYBRvM9MXSm7qPH+dRVYQZEwIgfut2vx3RvwZWcgEj4ohQJD5lNJlwOkA4PAiN1fjx6dABIgID3mvj1zerZKohOVhKCiskYk+3qrCum6PIwDhQ16ePACpHMEQCICZNR+0/1hPkrDQwPFmg5VjUHkh6aK9cXUu3kPbM8hirAiAyE/5NUXKfmFKij30isuyysJbq8HrURjivd+S9vdRGKQEBBZNSIQJIomw2zTMUk3nzJ3ZphD4Le+ExuLTHuTQVXfAGFHhMeSEC9OfCXl+sJOrxUFLBuMV4ZUlJYjuzNGZSld5ioY14y8FSrnNkUSED3mvj1zerZKohOVhKCiskYk+3qrCum6PIwDhQ16ePACohA+ECH+HlR+8Sf3pumaXH3IwSsoqSLCH7H1THiBP93z3ZUq9SsmgiBgJIomw2zTMUk3nzJ3ZphD4Le+ExuLTHuTQVXfAGFHhMeRxjat8/MAAAgAEAAIAAAACAAgAAgAAAAAABAAAAIgYC9OfCXl+sJOrxUFLBuMV4ZUlJYjuzNGZSld5ioY14y8Ec/9Y8jTAAAIABAACAAAAAgAIAAIAAAAAAAQAAACIGA95r49c3q2SqITlYSgorJGJPt6qwrpujyMA4UNenjwAqHGNq3z8wAACAAQAAgAEAAIACAACAAAAAAAEAAAAiBgPhAh/h5UfvEn96bpmlx9yMErKKkiwh+x9Ux4gT/d892Rz/1jyNMAAAgAEAAIABAACAAgAAgAAAAAABAAAAACICAlBQ7gGocg7eF3sXrCio+zusAC9+xfoyIV95AeR69DWvHGNq3z8wAACAAQAAgAEAAIACAACAAAAAAAMAAAAiAgMvVy984eg8Kgvj058PBHetFayWbRGb7L0DMnS9KHSJzBxjat8/MAAAgAEAAIAAAACAAgAAgAAAAAADAAAAIgIDSRIG1dn6njdjsDXenHa2lUvQHWGPLKBVrSzbQOhiIxgc/9Y8jTAAAIABAACAAAAAgAIAAIAAAAAAAwAAACICA0/epE59sVEj7Et0I4R9qJQNuX23RNvDZKCRL7eUps9FHP/WPI0wAACAAQAAgAEAAIACAACAAAAAAAMAAAAAIgICgldCOK6iHscv//2NipgaMABLV5TICU/zlP7HlQmlg08cY2rfPzAAAIABAACAAQAAgAIAAIABAAAAAQAAACICApb0p9rfpJshB3J186PGWrvzQdixcwQZWmebOUMdkquZHP/WPI0wAACAAQAAgAAAAIACAACAAQAAAAEAAAAiAgLY5q+unoDxC/HI5BaNiPq12ei1REZIcUAN304JfKXUwxz/1jyNMAAAgAEAAIABAACAAgAAgAEAAAABAAAAIgIDg6cUVCJB79cMcofiURHojxFARWyS4YEhJNRixuOZZRgcY2rfPzAAAIABAACAAAAAgAIAAIABAAAAAQAAAAA="); let second_psbt = psbt_from_str("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"); let bitcoin_txs: Vec<_> = (0..2) .map(|i| bitcoin::Transaction { version: bitcoin::transaction::Version::TWO, lock_time: bitcoin::absolute::LockTime::from_height(i).unwrap(), input: Vec::new(), output: Vec::new(), }) .collect(); // The helper that was used to store Spend transaction in previous versions of the software // when there was no associated timestamp. fn store_spend_old(conn: &mut rusqlite::Connection, psbt: &Psbt) { let txid = &psbt.unsigned_tx.txid()[..].to_vec(); db_exec(conn, |db_tx| { db_tx.execute( "INSERT into spend_transactions (psbt, txid) VALUES (?1, ?2) \ ON CONFLICT DO UPDATE SET psbt=excluded.psbt", rusqlite::params![psbt.serialize(), txid], )?; Ok(()) }) .expect("Db must not fail"); } // Store a PSBT before the migration. { let mut conn = rusqlite::Connection::open(&db_path).unwrap(); store_spend_old(&mut conn, &first_psbt); } // The helper that was used to store coins in previous versions of the software, stripped // down to a single coin. fn store_coin_old( conn: &mut rusqlite::Connection, outpoint: &bitcoin::OutPoint, amount: bitcoin::Amount, derivation_index: bip32::ChildNumber, is_change: bool, ) { db_exec(conn, |db_tx| { let deriv_index: u32 = derivation_index.into(); db_tx.execute( "INSERT INTO coins (wallet_id, txid, vout, amount_sat, derivation_index, is_change) \ VALUES (?1, ?2, ?3, ?4, ?5, ?6)", rusqlite::params![ WALLET_ID, outpoint.txid[..].to_vec(), outpoint.vout, amount.to_sat(), deriv_index, is_change, ], )?; Ok(()) }) .expect("Database must be available") } // Store a couple coins before the migration. { let mut conn = rusqlite::Connection::open(&db_path).unwrap(); store_coin_old( &mut conn, &bitcoin::OutPoint::new(bitcoin_txs.first().unwrap().txid(), 5), bitcoin::Amount::from_sat(14_000), 24.into(), true, ); store_coin_old( &mut conn, &bitcoin::OutPoint::new(bitcoin_txs.get(1).unwrap().txid(), 2), bitcoin::Amount::from_sat(392_093_123), 24_567.into(), false, ); } // Migrate the DB. maybe_apply_migration(&db_path, &bitcoin_txs).unwrap(); // Migrating twice will be a no-op. No need to pass `bitcoin_txs` second time. maybe_apply_migration(&db_path, &[]).unwrap(); let db = SqliteDb::new(db_path, None, &secp).unwrap(); // The DB version has been updated. { let mut conn = db.connection().unwrap(); let version = conn.db_version(); assert_eq!(version, 5); } // We should now be able to insert another PSBT, to query both, and the first PSBT must // have no associated timestamp. { let mut conn = db.connection().unwrap(); conn.store_spend(&second_psbt); let db_spends = conn.list_spend(); let first_spend = db_spends .iter() .find(|db_spend| db_spend.psbt == first_psbt) .unwrap(); assert!(first_spend.updated_at.is_none()); let second_spend = db_spends .iter() .find(|db_spend| db_spend.psbt == second_psbt) .unwrap(); assert!(second_spend.updated_at.is_some()); } // We should now be able to store an immature coin, query all of them, and the first two // should not be immature. { let mut conn = db.connection().unwrap(); let tx = bitcoin::Transaction { version: bitcoin::transaction::Version::TWO, lock_time: bitcoin::absolute::LockTime::from_height(2).unwrap(), input: Vec::new(), output: Vec::new(), }; conn.new_txs(&[tx.clone()]); conn.new_unspent_coins(&[Coin { outpoint: bitcoin::OutPoint::new(tx.txid(), 1), is_immature: true, block_info: None, amount: bitcoin::Amount::from_sat(98765), derivation_index: bip32::ChildNumber::from_normal_idx(10).unwrap(), is_change: false, spend_txid: None, spend_block: None, }]); let coins = conn.coins(&[], &[]); assert_eq!(coins.len(), 3); assert_eq!(coins.iter().filter(|c| !c.is_immature).count(), 2); } // We can insert labels. { let mut conn = db.connection().unwrap(); let txid_str = "0c62a990d20d54429e70859292e82374ba6b1b951a3ab60f26bb65fee5724ff7"; let txid = LabelItem::from_str(txid_str, bitcoin::Network::Bitcoin).unwrap(); let mut txids_labels = HashMap::new(); txids_labels.insert(txid.clone(), Some("hello".to_string())); conn.update_labels(&txids_labels); let mut items = HashSet::new(); items.insert(txid); let db_labels = conn.db_labels(&items); assert_eq!(db_labels[0].value, "hello"); } fs::remove_dir_all(tmp_dir).unwrap(); } #[test] fn v3_to_v5_migration() { let secp = secp256k1::Secp256k1::verification_only(); // Create a database with version 3, using the old schema. let tmp_dir = tmp_dir(); fs::create_dir_all(&tmp_dir).unwrap(); let db_path: path::PathBuf = [tmp_dir.as_path(), path::Path::new("lianad_v3.sqlite3")] .iter() .collect(); let mut options = dummy_options(); options.schema = V3_SCHEMA; options.version = 3; create_fresh_db(&db_path, options, &secp).unwrap(); { let db = SqliteDb::new(db_path.clone(), None, &secp).unwrap(); let mut conn = db.connection().unwrap(); assert!(conn.db_version() == 3); let bitcoin_txs: Vec<_> = (0..8) .map(|i| bitcoin::Transaction { version: bitcoin::transaction::Version::TWO, lock_time: bitcoin::absolute::LockTime::from_height(i).unwrap(), input: Vec::new(), output: Vec::new(), }) .collect(); // The following coins will be inserted into the DB as unconfirmed and then // some of them will be subsequently confirmed and spent. // Note that `block_info`, `spend_txid` and `spend_block` will all be set to // NULL in the DB by the `new_unspent_coins` method, but are set to `None` // here anyway. let coin_a = Coin { outpoint: bitcoin::OutPoint::new(bitcoin_txs.first().unwrap().txid(), 1), is_immature: false, amount: bitcoin::Amount::from_sat(1231001), derivation_index: bip32::ChildNumber::from_normal_idx(101).unwrap(), is_change: false, block_info: None, spend_txid: None, spend_block: None, }; let coin_b = Coin { outpoint: bitcoin::OutPoint::new(bitcoin_txs.get(1).unwrap().txid(), 19234), is_immature: false, amount: bitcoin::Amount::from_sat(23145), derivation_index: bip32::ChildNumber::from_normal_idx(10).unwrap(), is_change: false, block_info: None, spend_txid: None, spend_block: None, }; let coin_c = Coin { outpoint: bitcoin::OutPoint::new(bitcoin_txs.get(2).unwrap().txid(), 932), is_immature: false, amount: bitcoin::Amount::from_sat(354764), derivation_index: bip32::ChildNumber::from_normal_idx(3401).unwrap(), is_change: true, block_info: None, spend_txid: None, spend_block: None, }; let coin_d = Coin { outpoint: bitcoin::OutPoint::new(bitcoin_txs.get(3).unwrap().txid(), 1456), is_immature: false, amount: bitcoin::Amount::from_sat(23200), derivation_index: bip32::ChildNumber::from_normal_idx(4793235).unwrap(), is_change: true, block_info: None, spend_txid: None, spend_block: None, }; let coin_e = Coin { outpoint: bitcoin::OutPoint::new(bitcoin_txs.get(4).unwrap().txid(), 4633), is_immature: false, amount: bitcoin::Amount::from_sat(675000), derivation_index: bip32::ChildNumber::from_normal_idx(3).unwrap(), is_change: false, block_info: None, spend_txid: None, spend_block: None, }; let coin_imma_a = Coin { outpoint: bitcoin::OutPoint::new(bitcoin_txs.get(5).unwrap().txid(), 5), is_immature: true, amount: bitcoin::Amount::from_sat(4564347), derivation_index: bip32::ChildNumber::from_normal_idx(453).unwrap(), is_change: false, block_info: None, spend_txid: None, spend_block: None, }; let coin_imma_b = Coin { outpoint: bitcoin::OutPoint::new(bitcoin_txs.get(6).unwrap().txid(), 19234), is_immature: true, amount: bitcoin::Amount::from_sat(731453), derivation_index: bip32::ChildNumber::from_normal_idx(98).unwrap(), is_change: false, block_info: None, spend_txid: None, spend_block: None, }; // After the following operations, the state of the coins will be: // - coin_a is spent. // - coin_b is confirmed and spending. // - coin_c is confirmed. // - coin_d is the unconfirmed output of coin_b's spend and is spending. // - coin_e is the unconfirmed output of coin_d's spend. // - coin_imma_a is confirmed. // - coin_imma_b is still immature. conn.new_unspent_coins(&[ coin_a, coin_b, coin_c, coin_d, coin_e, coin_imma_a, coin_imma_b, ]); conn.confirm_coins(&[ (coin_a.outpoint, 175500, 1755001001), (coin_b.outpoint, 175502, 1755001032), (coin_c.outpoint, 175504, 1755005032), (coin_imma_a.outpoint, 176001, 1755001004), ]); conn.spend_coins(&[ (coin_a.outpoint, bitcoin_txs.get(7).unwrap().txid()), (coin_b.outpoint, coin_d.outpoint.txid), (coin_d.outpoint, coin_e.outpoint.txid), ]); conn.confirm_spend(&[( coin_a.outpoint, bitcoin_txs.get(7).unwrap().txid(), 245500, 1755003000, )]); assert_eq!(conn.coins(&[CoinStatus::Unconfirmed], &[]).len(), 2); assert_eq!(conn.coins(&[CoinStatus::Confirmed], &[]).len(), 2); assert_eq!(conn.coins(&[CoinStatus::Spending], &[]).len(), 2); assert_eq!(conn.coins(&[CoinStatus::Spent], &[]).len(), 1); let coins_pre = conn.coins(&[], &[]); assert_eq!(coins_pre.len(), 7); assert_eq!( coins_pre .iter() .filter(|c| c.is_immature) .collect::>() .len(), 1 ); assert_eq!( coins_pre .iter() .filter(|c| c.is_change) .collect::>() .len(), 2 ); // Migrate the DB. maybe_apply_migration(&db_path, &bitcoin_txs).unwrap(); assert_eq!(conn.db_version(), 5); // Migrating twice will be a no-op. No need to pass `bitcoin_txs` second time. maybe_apply_migration(&db_path, &[]).unwrap(); assert!(conn.db_version() == 5); let coins_post = conn.coins(&[], &[]); assert_eq!(coins_pre, coins_post); } fs::remove_dir_all(tmp_dir).unwrap(); } #[test] fn v4_to_v5_migration() { let secp = secp256k1::Secp256k1::verification_only(); // Create a database with version 3, using the old schema. let tmp_dir = tmp_dir(); fs::create_dir_all(&tmp_dir).unwrap(); let db_path: path::PathBuf = [tmp_dir.as_path(), path::Path::new("lianad_v4.sqlite3")] .iter() .collect(); let mut options = dummy_options(); options.schema = V4_SCHEMA; options.version = 4; // Create a hundred different transactions, from which originate two hundred // pseudo-random coins. let mut bitcoin_txs: Vec<_> = (0..100) .map(|i| bitcoin::Transaction { version: bitcoin::transaction::Version::TWO, lock_time: bitcoin::absolute::LockTime::from_height(i).unwrap(), input: Vec::new(), output: Vec::new(), }) .collect(); let spend_txs: Vec<_> = (0..10) .map(|i| { ( bitcoin::Transaction { version: bitcoin::transaction::Version::TWO, lock_time: bitcoin::absolute::LockTime::from_height(1_234 + i).unwrap(), input: Vec::new(), output: Vec::new(), }, if i % 2 == 0 { Some(BlockInfo { height: (i % 5) as i32 * 2_000, time: 1722488619 + (i % 5) * 84_999, }) } else { None }, ) }) .collect(); let coins: Vec = bitcoin_txs .iter() .chain(bitcoin_txs.iter()) // We do this to have coins which originate from the same tx. .enumerate() .map(|(i, tx)| Coin { outpoint: bitcoin::OutPoint { txid: tx.txid(), vout: i as u32, }, is_immature: (i % 10) == 0, amount: bitcoin::Amount::from_sat(i as u64 * 3473), derivation_index: bip32::ChildNumber::from_normal_idx(i as u32 * 100).unwrap(), is_change: (i % 4) == 0, block_info: if i & 2 == 0 { Some(BlockInfo { height: (i % 100) as i32 * 1_000, time: 1722408619 + (i % 100) as u32 * 42_000, }) } else { None }, spend_txid: if i % 20 == 0 { Some(spend_txs[i / 20].0.txid()) } else { None }, spend_block: if i % 20 == 0 { spend_txs[i / 20].1 } else { None }, }) .collect(); { let db = SqliteDb::new(db_path.clone(), Some(options), &secp).unwrap(); let mut conn = db.connection().unwrap(); // Insert all these coins into database. conn.new_unspent_coins(&coins); // Confirm those which are supposed to be. let confirmed_coins: Vec<_> = coins .iter() .filter_map(|coin| { coin.block_info .map(|blk| (coin.outpoint, blk.height, blk.time)) }) .collect(); conn.confirm_coins(&confirmed_coins); // Spend those which are supposed to be. let spent_coins: Vec<_> = coins .iter() .filter_map(|coin| coin.spend_txid.map(|txid| (coin.outpoint, txid))) .collect(); conn.spend_coins(&spent_coins); // Mark the spend as confirmed for those which are supposed to be. let confirmed_spent_coins: Vec<_> = coins .iter() .filter_map(|coin| { coin.spend_block.map(|blk| { ( coin.outpoint, coin.spend_txid.expect("always set when spend block is"), blk.height, blk.time, ) }) }) .collect(); conn.confirm_spend(&confirmed_spent_coins); } // Trying to migrate without specifying the transactions will fail. assert!(maybe_apply_migration(&db_path, &[]) .unwrap_err() .to_string() .contains("FOREIGN KEY constraint failed")); // Trying to migrate without specifying ALL the transactions will fail. (Missing the spend // tx here.) assert!(maybe_apply_migration(&db_path, &[]) .unwrap_err() .to_string() .contains("FOREIGN KEY constraint failed")); // Migration with all txs will succeed. bitcoin_txs.extend(spend_txs.iter().map(|(tx, _)| tx.clone())); maybe_apply_migration(&db_path, &bitcoin_txs).unwrap(); // Make sure all the transactions are indeed in DB. { let db = SqliteDb::new(db_path.clone(), None, &secp).unwrap(); let mut conn = db.connection().unwrap(); let txids: Vec<_> = bitcoin_txs.iter().map(|tx| tx.txid()).collect(); let bitcoin_txs_in_db: HashSet<_> = conn .list_wallet_transactions(&txids) .into_iter() .map(|tx| tx.transaction) .collect(); let bitcoin_txs: HashSet<_> = bitcoin_txs.into_iter().collect(); assert_eq!(bitcoin_txs.len(), bitcoin_txs_in_db.len()); assert_eq!(bitcoin_txs, bitcoin_txs_in_db); } fs::remove_dir_all(tmp_dir).unwrap(); } }