2024-08-02 10:49:33 +01:00

2887 lines
116 KiB
Rust

//! 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<LianaDescriptor>),
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<io::Error> for SqliteDbError {
fn from(e: io::Error) -> Self {
SqliteDbError::FileCreation(e)
}
}
impl From<rusqlite::Error> 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<FreshDbOptions>,
secp: &secp256k1::Secp256k1<secp256k1::VerifyOnly>,
) -> Result<SqliteDb, SqliteDbError> {
// 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<SqliteConn, SqliteDbError> {
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<secp256k1::VerifyOnly>,
) {
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<DbCoin> {
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::<Vec<String>>()
.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<DbCoin> {
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<Item = &'a Coin>) {
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<Item = &'a (bitcoin::OutPoint, i32, u32)>,
) {
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<Item = &'a (bitcoin::OutPoint, bitcoin::Txid)>,
) {
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<Item = &'a bitcoin::OutPoint>,
) {
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<Item = &'a (bitcoin::OutPoint, bitcoin::Txid, i32, u32)>,
) {
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<DbAddress> {
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<DbCoin> {
self.coins(&[], outpoints)
}
pub fn db_spend(&mut self, txid: &bitcoin::Txid) -> Option<DbSpendTransaction> {
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<DbSpendTransaction> {
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<LabelItem, Option<String>>) {
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<LabelItem>) -> Vec<DbLabel> {
let query = format!(
"SELECT * FROM labels where item in ({})",
items
.iter()
.map(|a| format!("'{}'", a))
.collect::<Vec<String>>()
.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<bitcoin::Txid> {
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<u8> = 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<bitcoin::Txid> {
db_query(
&mut self.conn,
"SELECT txid FROM transactions",
rusqlite::params![],
|row| {
let txid: Vec<u8> = 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<DbWalletTransaction> {
// 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::<Vec<_>>()
.join(",")
);
let w_txs: Vec<DbWalletTransaction> =
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::<HashSet<_>>()
.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<secp256k1::VerifyOnly>,
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<bitcoin::OutPoint> = 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<bitcoin::OutPoint> = 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<bitcoin::OutPoint> = 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<bitcoin::OutPoint> = 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::<Vec<_>>(),
);
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::<Vec<_>>(),
);
let mut db_coins = conn
.db_coins(
&coins
.iter()
.map(|c| c.outpoint)
.collect::<Vec<bitcoin::OutPoint>>(),
)
.into_iter()
.map(Coin::from)
.collect::<Vec<_>>();
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::<Vec<bitcoin::OutPoint>>(),
)
.into_iter()
.map(|c| (c.outpoint, Coin::from(c)))
.collect::<HashMap<_, _>>();
// 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::<Vec<_>>(),
);
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::<Vec<_>>(),
);
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<Coin> = 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::<HashSet<_>>() // 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::<Vec<_>>()
.len(),
1
);
assert_eq!(
coins_pre
.iter()
.filter(|c| c.is_change)
.collect::<Vec<_>>()
.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<Coin> = 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();
}
}