diff --git a/lianad/src/database/sqlite/mod.rs b/lianad/src/database/sqlite/mod.rs index a25a9c13..59ba5c9a 100644 --- a/lianad/src/database/sqlite/mod.rs +++ b/lianad/src/database/sqlite/mod.rs @@ -43,7 +43,7 @@ use miniscript::bitcoin::{ secp256k1, }; -const DB_VERSION: i64 = 6; +const DB_VERSION: i64 = 7; /// 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. @@ -733,9 +733,16 @@ impl SqliteConn { let txid = &tx.txid()[..].to_vec(); let tx_ser = bitcoin::consensus::serialize(tx); db_tx.execute( - "INSERT INTO transactions (txid, tx) VALUES (?1, ?2) \ + "INSERT INTO transactions (txid, tx, num_inputs, num_outputs, is_coinbase) \ + VALUES (?1, ?2, ?3, ?4, ?5) \ ON CONFLICT DO NOTHING", - rusqlite::params![txid, tx_ser,], + rusqlite::params![ + txid, + tx_ser, + tx.input.len(), + tx.output.len(), + tx.is_coinbase() + ], )?; } Ok(()) @@ -840,7 +847,7 @@ mod tests { str::FromStr, }; - use bitcoin::bip32; + use bitcoin::{bip32, ScriptBuf}; // The database schema used by the first versions of Liana (database version 0). Used to test // migrations starting from the first version. @@ -1299,8 +1306,8 @@ CREATE TABLE labels ( .map(|i| bitcoin::Transaction { version: bitcoin::transaction::Version::TWO, lock_time: bitcoin::absolute::LockTime::from_height(i).unwrap(), - input: Vec::new(), - output: Vec::new(), + input: vec![bitcoin::TxIn::default()], // a single input + output: vec![bitcoin::TxOut::minimal_non_dust(ScriptBuf::default())], // a single output, }) .collect(); conn.new_txs(&txs); @@ -1602,8 +1609,8 @@ CREATE TABLE labels ( .map(|i| bitcoin::Transaction { version: bitcoin::transaction::Version::TWO, lock_time: bitcoin::absolute::LockTime::from_height(i).unwrap(), - input: Vec::new(), - output: Vec::new(), + input: vec![bitcoin::TxIn::default()], // a single input + output: vec![bitcoin::TxOut::minimal_non_dust(ScriptBuf::default())], // a single output, }) .collect(); conn.new_txs(&txs); @@ -1935,8 +1942,8 @@ CREATE TABLE labels ( .map(|i| bitcoin::Transaction { version: bitcoin::transaction::Version::TWO, lock_time: bitcoin::absolute::LockTime::from_height(i).unwrap(), - input: Vec::new(), - output: Vec::new(), + input: vec![bitcoin::TxIn::default()], // a single input + output: vec![bitcoin::TxOut::minimal_non_dust(ScriptBuf::default())], // a single output, }) .collect(); conn.new_txs(&txs); @@ -2144,8 +2151,8 @@ CREATE TABLE labels ( .map(|i| bitcoin::Transaction { version: bitcoin::transaction::Version::TWO, lock_time: bitcoin::absolute::LockTime::from_height(i).unwrap(), - input: Vec::new(), - output: Vec::new(), + input: vec![bitcoin::TxIn::default()], // a single input + output: vec![bitcoin::TxOut::minimal_non_dust(ScriptBuf::default())], // a single output, }) .collect(); conn.new_txs(&txs); @@ -2263,8 +2270,8 @@ CREATE TABLE labels ( .map(|i| bitcoin::Transaction { version: bitcoin::transaction::Version::TWO, lock_time: bitcoin::absolute::LockTime::from_height(i).unwrap(), - input: Vec::new(), - output: Vec::new(), + input: vec![bitcoin::TxIn::default()], // a single input + output: vec![bitcoin::TxOut::minimal_non_dust(ScriptBuf::default())], // a single output, }) .collect(); conn.new_txs(&txs); @@ -2291,8 +2298,8 @@ CREATE TABLE labels ( .map(|i| bitcoin::Transaction { version: bitcoin::transaction::Version::TWO, lock_time: bitcoin::absolute::LockTime::from_height(i).unwrap(), - input: Vec::new(), - output: Vec::new(), + input: vec![bitcoin::TxIn::default()], // a single input + output: vec![bitcoin::TxOut::minimal_non_dust(ScriptBuf::default())], // a single output, }) .collect(); let spend_txs: Vec<_> = (0..10) @@ -2301,8 +2308,8 @@ CREATE TABLE labels ( bitcoin::Transaction { version: bitcoin::transaction::Version::TWO, lock_time: bitcoin::absolute::LockTime::from_height(1_234 + i).unwrap(), - input: Vec::new(), - output: Vec::new(), + input: vec![bitcoin::TxIn::default()], // a single input + output: vec![bitcoin::TxOut::minimal_non_dust(ScriptBuf::default())], // a single output, }, if i % 2 == 0 { Some(BlockInfo { @@ -2462,7 +2469,7 @@ CREATE TABLE labels ( } #[test] - fn v0_to_v6_migration() { + fn v0_to_v7_migration() { let secp = secp256k1::Secp256k1::verification_only(); // Create a database with version 0, using the old schema. @@ -2568,7 +2575,7 @@ CREATE TABLE labels ( { let mut conn = db.connection().unwrap(); let version = conn.db_version(); - assert_eq!(version, 6); + assert_eq!(version, 7); } // We should now be able to insert another PSBT, to query both, and the first PSBT must // have no associated timestamp. @@ -2595,8 +2602,8 @@ CREATE TABLE labels ( let tx = bitcoin::Transaction { version: bitcoin::transaction::Version::TWO, lock_time: bitcoin::absolute::LockTime::from_height(2).unwrap(), - input: Vec::new(), - output: Vec::new(), + input: vec![bitcoin::TxIn::default()], // a single input + output: vec![bitcoin::TxOut::minimal_non_dust(ScriptBuf::default())], // a single output, }; conn.new_txs(&[tx.clone()]); conn.new_unspent_coins(&[Coin { @@ -2642,7 +2649,7 @@ CREATE TABLE labels ( } #[test] - fn v3_to_v6_migration() { + fn v3_to_v7_migration() { let secp = secp256k1::Secp256k1::verification_only(); // Create a database with version 3, using the old schema. @@ -2792,10 +2799,10 @@ CREATE TABLE labels ( // Migrate the DB. maybe_apply_migration(&db_path, &bitcoin_txs).unwrap(); - assert_eq!(conn.db_version(), 6); + assert_eq!(conn.db_version(), 7); // 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() == 6); + assert!(conn.db_version() == 7); // Compare the `DbCoin`s with the expected values. let coins_post = conn.coins(&[], &[]); diff --git a/lianad/src/database/sqlite/schema.rs b/lianad/src/database/sqlite/schema.rs index 52a83a0b..0f4ec7a8 100644 --- a/lianad/src/database/sqlite/schema.rs +++ b/lianad/src/database/sqlite/schema.rs @@ -4,6 +4,11 @@ use std::{convert::TryFrom, str::FromStr}; use miniscript::bitcoin::{self, address, bip32, consensus::encode, psbt::Psbt}; +// Due to limitations of Sqlite's ALTER TABLE command and in order not to recreate +// tables during migration: +// - Columns `num_inputs` and `num_outputs` of the transactions table remain nullable. +// - There is no CHECK constraint to prevent both `is_immature` and `is_from_self` +// being true in the coins table. pub const SCHEMA: &str = "\ CREATE TABLE version ( version INTEGER NOT NULL @@ -42,6 +47,10 @@ CREATE TABLE wallets ( * 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. + * + * The `is_from_self` field indicates if the coin is the output of a transaction whose + * inputs are all from the same wallet as the coin. For an unconfirmed coin, this also + * means that all unconfirmed ancestors, if any, are from self. */ CREATE TABLE coins ( id INTEGER PRIMARY KEY NOT NULL, @@ -57,6 +66,7 @@ CREATE TABLE coins ( spend_block_height INTEGER, spend_block_time INTEGER, is_immature BOOLEAN NOT NULL CHECK (is_immature IN (0,1)), + is_from_self BOOLEAN NOT NULL DEFAULT 0 CHECK (is_from_self IN (0,1)), UNIQUE (txid, vout), FOREIGN KEY (wallet_id) REFERENCES wallets (id) ON UPDATE RESTRICT @@ -82,7 +92,10 @@ CREATE TABLE addresses ( CREATE TABLE transactions ( id INTEGER PRIMARY KEY NOT NULL, txid BLOB UNIQUE NOT NULL, - tx BLOB UNIQUE NOT NULL + tx BLOB UNIQUE NOT NULL, + num_inputs INTEGER CHECK (num_inputs IS NULL OR num_inputs > 0), + num_outputs INTEGER CHECK (num_outputs IS NULL OR num_outputs > 0), + is_coinbase BOOLEAN NOT NULL DEFAULT 0 CHECK (is_coinbase IN (0,1)) ); /* Transactions we created that spend some of our coins. */ @@ -195,6 +208,12 @@ pub struct DbCoin { pub is_change: bool, pub spend_txid: Option, pub spend_block: Option, + /// A coin is from self if it is the output of a transaction whose + /// inputs are all from this wallet. For unconfirmed coins, we + /// further require that all unconfirmed ancestors, if any, also + /// be from self, as otherwise they will depend on an unconfirmed + /// external transaction. + pub is_from_self: bool, } impl TryFrom<&rusqlite::Row<'_>> for DbCoin { @@ -234,6 +253,7 @@ impl TryFrom<&rusqlite::Row<'_>> for DbCoin { }); let is_immature: bool = row.get(12)?; + let is_from_self: bool = row.get(13)?; Ok(DbCoin { id, @@ -246,6 +266,7 @@ impl TryFrom<&rusqlite::Row<'_>> for DbCoin { is_change, spend_txid, spend_block, + is_from_self, }) } } diff --git a/lianad/src/database/sqlite/utils.rs b/lianad/src/database/sqlite/utils.rs index 95fc984b..5847a6c6 100644 --- a/lianad/src/database/sqlite/utils.rs +++ b/lianad/src/database/sqlite/utils.rs @@ -307,6 +307,24 @@ fn migrate_v5_to_v6(conn: &mut rusqlite::Connection) -> Result<(), SqliteDbError Ok(()) } +fn migrate_v6_to_v7(conn: &mut rusqlite::Connection) -> Result<(), SqliteDbError> { + db_exec(conn, |db_tx| { + db_tx.execute_batch( + " + ALTER TABLE transactions ADD COLUMN num_inputs INTEGER CHECK (num_inputs IS NULL OR num_inputs > 0); + ALTER TABLE transactions ADD COLUMN num_outputs INTEGER CHECK (num_outputs IS NULL OR num_outputs > 0); + ALTER TABLE transactions ADD COLUMN is_coinbase BOOLEAN NOT NULL DEFAULT 0 CHECK (is_coinbase IN (0,1)); + + ALTER TABLE coins ADD COLUMN is_from_self BOOLEAN NOT NULL DEFAULT 0 CHECK (is_from_self IN (0,1)); + + UPDATE version SET version = 7; + ", + )?; + Ok(()) + })?; + Ok(()) +} + /// Check the database version and if necessary apply the migrations to upgrade it to the current /// one. The `bitcoin_txs` parameter is here for the migration from versions 4 and earlier, which /// did not store the Bitcoin transactions in database, to versions 5 and later, which do. For a @@ -360,6 +378,11 @@ pub fn maybe_apply_migration( migrate_v5_to_v6(&mut conn)?; log::warn!("Migration from database version 5 to version 6 successful."); } + 6 => { + log::warn!("Upgrading database from version 6 to version 7."); + migrate_v6_to_v7(&mut conn)?; + log::warn!("Migration from database version 6 to version 7 successful."); + } _ => return Err(SqliteDbError::UnsupportedVersion(version)), } }