diff --git a/src/bitcoin/poller/looper.rs b/src/bitcoin/poller/looper.rs index c3f246d8..8944d92c 100644 --- a/src/bitcoin/poller/looper.rs +++ b/src/bitcoin/poller/looper.rs @@ -66,6 +66,7 @@ fn update_coins( if !curr_coins.contains_key(&utxo.outpoint) { let coin = Coin { outpoint, + is_immature: false, amount, derivation_index, is_change, diff --git a/src/commands/mod.rs b/src/commands/mod.rs index e5ca6322..8e36ecca 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -976,6 +976,7 @@ mod tests { let mut db_conn = control.db().lock().unwrap().connection(); db_conn.new_unspent_coins(&[Coin { outpoint: dummy_op, + is_immature: false, block_info: None, amount: bitcoin::Amount::from_sat(100_000), derivation_index: bip32::ChildNumber::from(13), @@ -1081,6 +1082,7 @@ mod tests { }; db_conn.new_unspent_coins(&[Coin { outpoint: dummy_op_dup, + is_immature: false, block_info: None, amount: bitcoin::Amount::from_sat(400_000), derivation_index: bip32::ChildNumber::from(42), @@ -1127,6 +1129,7 @@ mod tests { db_conn.new_unspent_coins(&[ Coin { outpoint: dummy_op_a, + is_immature: false, block_info: None, amount: bitcoin::Amount::from_sat(100_000), derivation_index: bip32::ChildNumber::from(13), @@ -1136,6 +1139,7 @@ mod tests { }, Coin { outpoint: dummy_op_b, + is_immature: false, block_info: None, amount: bitcoin::Amount::from_sat(115_680), derivation_index: bip32::ChildNumber::from(34), @@ -1303,6 +1307,7 @@ mod tests { // Deposit 1 Coin { is_change: false, + is_immature: false, outpoint: OutPoint { txid: deposit1.txid(), vout: 0, @@ -1316,6 +1321,7 @@ mod tests { // Deposit 2 Coin { is_change: false, + is_immature: false, outpoint: OutPoint { txid: deposit2.txid(), vout: 0, @@ -1329,6 +1335,7 @@ mod tests { // This coin is a change output. Coin { is_change: true, + is_immature: false, outpoint: OutPoint::new(spend_tx.txid(), 1), block_info: Some(BlockInfo { height: 3, time: 3 }), spend_block: None, @@ -1339,6 +1346,7 @@ mod tests { // Deposit 3 Coin { is_change: false, + is_immature: false, outpoint: OutPoint { txid: deposit3.txid(), vout: 0, diff --git a/src/database/mod.rs b/src/database/mod.rs index 23daf8c2..e25acb92 100644 --- a/src/database/mod.rs +++ b/src/database/mod.rs @@ -281,6 +281,7 @@ impl From for BlockInfo { #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub struct Coin { pub outpoint: bitcoin::OutPoint, + pub is_immature: bool, pub block_info: Option, pub amount: bitcoin::Amount, pub derivation_index: bip32::ChildNumber, @@ -293,6 +294,7 @@ impl std::convert::From for Coin { fn from(db_coin: DbCoin) -> Coin { let DbCoin { outpoint, + is_immature, block_info, amount, derivation_index, @@ -303,6 +305,7 @@ impl std::convert::From for Coin { } = db_coin; Coin { outpoint, + is_immature, block_info: block_info.map(BlockInfo::from), amount, derivation_index, diff --git a/src/database/sqlite/mod.rs b/src/database/sqlite/mod.rs index 2e63ec12..2cf3aa7b 100644 --- a/src/database/sqlite/mod.rs +++ b/src/database/sqlite/mod.rs @@ -35,7 +35,7 @@ use miniscript::bitcoin::{ secp256k1, }; -const DB_VERSION: i64 = 1; +const DB_VERSION: i64 = 2; #[derive(Debug)] pub enum SqliteDbError { @@ -383,8 +383,8 @@ impl SqliteConn { 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) \ - VALUES (?1, ?2, ?3, ?4, ?5, ?6)", + "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(), @@ -392,6 +392,7 @@ impl SqliteConn { coin.amount.to_sat(), deriv_index, coin.is_change, + coin.is_immature, ], )?; } @@ -593,11 +594,12 @@ impl SqliteConn { .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 + /// - Coins (coinbase deposits that became immature isn't currently implemented) /// - Spending transactions confirmation /// - Tip /// @@ -823,6 +825,7 @@ CREATE TABLE spend_transactions ( "6f0dc85a369b44458eba3a1f0ea5b5935d563afb6994f70f5b0094e05be1676c:1", ) .unwrap(), + is_immature: false, block_info: None, amount: bitcoin::Amount::from_sat(98765), derivation_index: bip32::ChildNumber::from_normal_idx(10).unwrap(), @@ -855,6 +858,7 @@ CREATE TABLE spend_transactions ( "61db3e276b095e5b05f1849dd6bfffb4e7e5ec1c4a4210099b98fce01571936f:12", ) .unwrap(), + is_immature: false, block_info: None, amount: bitcoin::Amount::from_sat(1111), derivation_index: bip32::ChildNumber::from_normal_idx(103).unwrap(), @@ -949,6 +953,31 @@ CREATE TABLE spend_transactions ( assert!(coin.spend_block.is_some()); assert_eq!(coin.spend_block.as_ref().unwrap().time, time); assert_eq!(coin.spend_block.unwrap().height, height); + + // 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::from_str( + "61db3e276b095e5b05f1849dd6bfffb4e7e5ec1c4a4210099b98fce01571937a:42", + ) + .unwrap(), + is_immature: true, + block_info: None, + amount: bitcoin::Amount::from_sat(424242), + derivation_index: bip32::ChildNumber::from_normal_idx(4103).unwrap(), + is_change: false, // Cannot be both a coinbase deposit and change. + spend_txid: None, + spend_block: None, + }; + conn.new_unspent_coins(&[coin_imma]); + let outpoints: HashSet = conn + .coins(CoinType::All) + .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); } fs::remove_dir_all(tmp_dir).unwrap(); @@ -1084,12 +1113,14 @@ CREATE TABLE spend_transactions ( // - 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::from_str( "6f0dc85a369b44458eba3a1f0ea5b5935d563afb6994f70f5b0094e05be1676c:1", ) .unwrap(), + is_immature: false, block_info: None, amount: bitcoin::Amount::from_sat(98765), derivation_index: bip32::ChildNumber::from_normal_idx(10).unwrap(), @@ -1102,6 +1133,7 @@ CREATE TABLE spend_transactions ( "c449539458c60bee6c0d8905ba1dadb20b9187b82045d306a408b894cea492b0:2", ) .unwrap(), + is_immature: false, block_info: Some(BlockInfo { height: 101_095, time: 1_111_899, @@ -1117,6 +1149,7 @@ CREATE TABLE spend_transactions ( "f0801fd9ca8bca0624c230ab422b2e2c4c8dc995e4e1dbc6412510959cce1e4f:3", ) .unwrap(), + is_immature: false, block_info: Some(BlockInfo { height: 101_099, time: 1_121_899, @@ -1140,6 +1173,7 @@ CREATE TABLE spend_transactions ( "19f56e65069f0a7a3bfb00c6a7085cc0669e03e91befeca1ee9891c9e737b2fb:4", ) .unwrap(), + is_immature: false, block_info: Some(BlockInfo { height: 101_100, time: 1_131_899, @@ -1155,6 +1189,7 @@ CREATE TABLE spend_transactions ( "ed6c8f1af9325f84de521e785e7ddfd33dc28c9ada4d687dcd3850100bde54e9:5", ) .unwrap(), + is_immature: false, block_info: Some(BlockInfo { height: 101_102, time: 1_134_899, @@ -1303,6 +1338,7 @@ CREATE TABLE spend_transactions ( "6f0dc85a369b44458eba3a1f0ea5b5935d563afb6994f70f5b0094e05be1676c:1", ) .unwrap(), + is_immature: false, block_info: None, amount: bitcoin::Amount::from_sat(98765), derivation_index: bip32::ChildNumber::from_normal_idx(10).unwrap(), @@ -1315,6 +1351,7 @@ CREATE TABLE spend_transactions ( "c449539458c60bee6c0d8905ba1dadb20b9187b82045d306a408b894cea492b0:2", ) .unwrap(), + is_immature: false, block_info: Some(BlockInfo { height: 101_095, time: 1_121_000, @@ -1330,6 +1367,7 @@ CREATE TABLE spend_transactions ( "f0801fd9ca8bca0624c230ab422b2e2c4c8dc995e4e1dbc6412510959cce1e4f:3", ) .unwrap(), + is_immature: false, block_info: Some(BlockInfo { height: 101_099, time: 1_122_000, @@ -1353,6 +1391,7 @@ CREATE TABLE spend_transactions ( "19f56e65069f0a7a3bfb00c6a7085cc0669e03e91befeca1ee9891c9e737b2fb:4", ) .unwrap(), + is_immature: true, block_info: Some(BlockInfo { height: 101_100, time: 1_124_000, @@ -1368,6 +1407,7 @@ CREATE TABLE spend_transactions ( "ed6c8f1af9325f84de521e785e7ddfd33dc28c9ada4d687dcd3850100bde54e9:5", ) .unwrap(), + is_immature: false, block_info: Some(BlockInfo { height: 101_102, time: 1_125_000, @@ -1448,7 +1488,7 @@ CREATE TABLE spend_transactions ( } #[test] - fn v0_to_v1_migration() { + fn v0_to_v2_migration() { let secp = secp256k1::Secp256k1::verification_only(); // Create a database with version 0, using the old schema. @@ -1490,11 +1530,66 @@ CREATE TABLE spend_transactions ( store_spend_old(&mut conn, &first_psbt); } - // Migrate the DB. We should be able to insert another PSBT, to query both, and the first - // PSBT must have no associated timestamp. + // 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::from_str( + "ed6c8f1af9325f84de521e785e7ddfd33dc28c9ada4d687dcd3850100bde54e9:5", + ) + .unwrap(), + bitcoin::Amount::from_sat(14_000), + 24.into(), + true, + ); + store_coin_old( + &mut conn, + &bitcoin::OutPoint::from_str( + "81b2f327d4c1fd67afd039374f8798fd9ff37932c6f5c221c1c569350eac5ac8:2", + ) + .unwrap(), + bitcoin::Amount::from_sat(392_093_123), + 24_567.into(), + false, + ); + } + + // Migrate the DB. maybe_apply_migration(&db_path).unwrap(); maybe_apply_migration(&db_path).unwrap(); // Migrating twice will be a no-op. let db = SqliteDb::new(db_path, None, &secp).unwrap(); + + // 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); @@ -1511,6 +1606,28 @@ CREATE TABLE spend_transactions ( 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(); + conn.new_unspent_coins(&[Coin { + outpoint: bitcoin::OutPoint::from_str( + "6f0dc85a369b44458eba3a1f0ea5b5935d563afb6994f70f5b0094e05be1676c:1", + ) + .unwrap(), + 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(CoinType::All); + assert_eq!(coins.len(), 3); + assert_eq!(coins.iter().filter(|c| !c.is_immature).count(), 2); + } + fs::remove_dir_all(tmp_dir).unwrap(); } } diff --git a/src/database/sqlite/schema.rs b/src/database/sqlite/schema.rs index 93e101e7..b4b4b7b7 100644 --- a/src/database/sqlite/schema.rs +++ b/src/database/sqlite/schema.rs @@ -39,6 +39,10 @@ CREATE TABLE wallets ( * * 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, @@ -53,6 +57,8 @@ CREATE TABLE coins ( 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 @@ -156,6 +162,8 @@ pub struct DbBlockInfo { pub struct DbCoin { pub id: i64, pub wallet_id: i64, + /// Whether this coin was created by a yet-to-be-mature coinbase transaction. + pub is_immature: bool, pub outpoint: bitcoin::OutPoint, pub block_info: Option, pub amount: bitcoin::Amount, @@ -201,9 +209,16 @@ impl TryFrom<&rusqlite::Row<'_>> for DbCoin { time: spend_time.expect("Must be there if height is"), }); + let is_immature: bool = row.get(12)?; + assert!( + !is_immature || !is_change, + "A coin cannot be both created in a coinbase and be change" + ); + Ok(DbCoin { id, wallet_id, + is_immature, outpoint, block_info, amount, diff --git a/src/database/sqlite/utils.rs b/src/database/sqlite/utils.rs index 073fbe7f..a782036f 100644 --- a/src/database/sqlite/utils.rs +++ b/src/database/sqlite/utils.rs @@ -165,6 +165,25 @@ fn migrate_v0_to_v1(conn: &mut rusqlite::Connection) -> Result<(), SqliteDbError Ok(()) } +// After Liana 1.0 we upgraded the schema to record whether a coin originated from an immature +// coinbase transaction. +fn migrate_v1_to_v2(conn: &mut rusqlite::Connection) -> Result<(), SqliteDbError> { + db_exec(conn, |tx| { + tx.execute( + "ALTER TABLE coins ADD COLUMN is_immature", + rusqlite::params![], + )?; + tx.execute( + "UPDATE coins SET is_immature = 0 WHERE is_immature IS NULL", + rusqlite::params![], + )?; + tx.execute("UPDATE version SET version = 2", rusqlite::params![])?; + Ok(()) + })?; + + Ok(()) +} + /// Check the database version and if necessary apply the migrations to upgrade it to the current /// one. pub fn maybe_apply_migration(db_path: &path::Path) -> Result<(), SqliteDbError> { @@ -183,6 +202,11 @@ pub fn maybe_apply_migration(db_path: &path::Path) -> Result<(), SqliteDbError> migrate_v0_to_v1(&mut conn)?; log::warn!("Migration from database version 0 to version 1 successful."); } + 1 => { + log::warn!("Upgrading database from version 1 to version 2."); + migrate_v1_to_v2(&mut conn)?; + log::warn!("Migration from database version 1 to version 2 successful."); + } _ => return Err(SqliteDbError::UnsupportedVersion(version)), } }