diff --git a/src/database/mod.rs b/src/database/mod.rs index 6820661b..f395c364 100644 --- a/src/database/mod.rs +++ b/src/database/mod.rs @@ -5,12 +5,15 @@ pub mod sqlite; use crate::{ bitcoin::BlockChainTip, - database::sqlite::{schema::DbTip, SqliteConn, SqliteDb}, + database::sqlite::{ + schema::{DbCoin, DbTip}, + SqliteConn, SqliteDb, + }, }; -use std::sync; +use std::{collections::HashMap, sync}; -use miniscript::bitcoin::util::bip32; +use miniscript::bitcoin::{self, util::bip32}; pub trait DatabaseInterface: Send { fn connection(&self) -> Box; @@ -33,12 +36,27 @@ pub trait DatabaseConnection { /// Get the tip of the best chain we've seen. fn chain_tip(&mut self) -> Option; + /// The network we are operating on. + fn network(&mut self) -> bitcoin::Network; + /// Update our best chain seen. fn update_tip(&mut self, tip: &BlockChainTip); fn derivation_index(&mut self) -> bip32::ChildNumber; fn update_derivation_index(&mut self, index: bip32::ChildNumber); + + /// Get all UTxOs. + fn unspent_coins(&mut self) -> HashMap; + + /// Store new UTxOs. Coins must not already be in database. + fn new_unspent_coins<'a>(&mut self, coins: &[Coin]); + + /// Mark a set of coins as being confirmed at a specified height. + fn confirm_coins<'a>(&mut self, outpoints: &[(bitcoin::OutPoint, i32)]); + + /// Mark a set of coins as being spent by a specified txid. + fn spend_coins<'a>(&mut self, outpoints: &[(bitcoin::OutPoint, bitcoin::Txid)]); } impl DatabaseConnection for SqliteConn { @@ -53,6 +71,10 @@ impl DatabaseConnection for SqliteConn { } } + fn network(&mut self) -> bitcoin::Network { + self.db_tip().network + } + fn update_tip(&mut self, tip: &BlockChainTip) { self.update_tip(&tip) } @@ -64,4 +86,68 @@ impl DatabaseConnection for SqliteConn { fn update_derivation_index(&mut self, index: bip32::ChildNumber) { self.update_derivation_index(index) } + + fn unspent_coins(&mut self) -> HashMap { + // FIXME: if possible, avoid reallocating. + self.unspent_coins() + .into_iter() + .map(|db_coin| { + let DbCoin { + outpoint, + block_height, + amount, + derivation_index, + spend_txid, + .. + } = db_coin; + ( + outpoint, + Coin { + outpoint, + block_height, + amount, + derivation_index, + spend_txid, + }, + ) + }) + .collect() + } + + fn new_unspent_coins<'a>(&mut self, coins: &[Coin]) { + self.new_unspent_coins(coins) + } + + fn confirm_coins<'a>(&mut self, outpoints: &[(bitcoin::OutPoint, i32)]) { + self.confirm_coins(outpoints) + } + + fn spend_coins<'a>(&mut self, outpoints: &[(bitcoin::OutPoint, bitcoin::Txid)]) { + self.spend_coins(outpoints) + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Coin { + pub outpoint: bitcoin::OutPoint, + pub block_height: Option, + pub amount: bitcoin::Amount, + pub derivation_index: bip32::ChildNumber, + pub spend_txid: Option, +} + +impl std::hash::Hash for Coin { + fn hash(&self, h: &mut H) { + self.outpoint.hash(h) + } +} + +impl Coin { + pub fn is_confirmed(&self) -> bool { + self.block_height.is_some() + } + + pub fn is_spent(&self) -> bool { + self.spend_txid.is_some() + } } diff --git a/src/database/sqlite/mod.rs b/src/database/sqlite/mod.rs index 2e81b54b..a425fd42 100644 --- a/src/database/sqlite/mod.rs +++ b/src/database/sqlite/mod.rs @@ -11,9 +11,12 @@ mod utils; use crate::{ bitcoin::BlockChainTip, - database::sqlite::{ - schema::{DbTip, DbWallet}, - utils::{create_fresh_db, db_exec, db_query}, + database::{ + sqlite::{ + schema::{DbCoin, DbTip, DbWallet}, + utils::{create_fresh_db, db_exec, db_query}, + }, + Coin, }, }; @@ -141,6 +144,9 @@ impl SqliteDb { } } +// 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, } @@ -214,13 +220,86 @@ impl SqliteConn { }) .expect("Database must be available") } + + /// Get all UTxOs. + pub fn unspent_coins(&mut self) -> Vec { + db_query( + &mut self.conn, + "SELECT * FROM coins WHERE spend_txid is NULL", + rusqlite::params![], + |row| row.try_into(), + ) + .expect("Db must not fail") + } + + // FIXME: don't take the whole coin, we don't need it. + /// Store new, unconfirmed and unspent, coins. + /// Will panic if given a coin that is already in DB. + pub fn new_unspent_coins<'a>(&mut self, coins: impl IntoIterator) { + db_exec(&mut self.conn, |db_tx| { + for coin in coins { + let deriv_index: u32 = coin.derivation_index.into(); + db_tx.execute( + "INSERT INTO coins (wallet_id, txid, vout, amount_sat, derivation_index) \ + VALUES (?1, ?2, ?3, ?4, ?5)", + rusqlite::params![ + WALLET_ID, + coin.outpoint.txid.to_vec(), + coin.outpoint.vout, + coin.amount.as_sat(), + deriv_index, + ], + )?; + } + Ok(()) + }) + .expect("Database must be available") + } + + /// Mark a set of coins as confirmed. + pub fn confirm_coins<'a>( + &mut self, + outpoints: impl IntoIterator, + ) { + db_exec(&mut self.conn, |db_tx| { + for (outpoint, height) in outpoints { + db_tx.execute( + "UPDATE coins SET blockheight = ?1 WHERE txid = ?2 AND vout = ?3", + rusqlite::params![height, outpoint.txid.to_vec(), outpoint.vout,], + )?; + } + + Ok(()) + }) + .expect("Database must be available") + } + + /// Mark a set of coins as spent. + pub fn spend_coins<'a>( + &mut self, + outpoints: impl IntoIterator, + ) { + db_exec(&mut self.conn, |db_tx| { + for (outpoint, spend_txid) in outpoints { + db_tx.execute( + "UPDATE coins SET spend_txid = ?1 WHERE txid = ?2 AND vout = ?3", + rusqlite::params![spend_txid.to_vec(), outpoint.txid.to_vec(), outpoint.vout,], + )?; + } + + Ok(()) + }) + .expect("Database must be available") + } } #[cfg(test)] mod tests { use super::*; use crate::testutils::*; - use std::{fs, path, str::FromStr}; + use std::{collections::HashSet, fs, path, str::FromStr}; + + use bitcoin::hashes::Hash; fn dummy_options() -> FreshDbOptions { let desc_str = "wsh(andor(pk(03b506a1dbe57b4bf48c95e0c7d417b87dd3b4349d290d2e7e9ba72c912652d80a),older(10000),pk(0295e7f5d12a2061f1fd2286cefec592dff656a19f55f4f01305d6aa56630880ce)))"; @@ -231,6 +310,19 @@ mod tests { } } + fn dummy_db() -> (path::PathBuf, FreshDbOptions, SqliteDb) { + let tmp_dir = tmp_dir(); + fs::create_dir_all(&tmp_dir).unwrap(); + + let db_path: path::PathBuf = [tmp_dir.as_path(), path::Path::new("minisafed.sqlite3")] + .iter() + .collect(); + let options = dummy_options(); + let db = SqliteDb::new(db_path.clone(), Some(options.clone())).unwrap(); + + (tmp_dir, options, db) + } + #[test] fn db_startup_sanity_checks() { let tmp_dir = tmp_dir(); @@ -274,14 +366,7 @@ mod tests { #[test] fn db_tip_update() { - let tmp_dir = tmp_dir(); - fs::create_dir_all(&tmp_dir).unwrap(); - - let db_path: path::PathBuf = [tmp_dir.as_path(), path::Path::new("minisafed.sqlite3")] - .iter() - .collect(); - let options = dummy_options(); - let db = SqliteDb::new(db_path.clone(), Some(options.clone())).unwrap(); + let (tmp_dir, options, db) = dummy_db(); { let mut conn = db.connection().unwrap(); @@ -306,4 +391,72 @@ mod tests { fs::remove_dir_all(&tmp_dir).unwrap(); } + + #[test] + fn db_coins_update() { + let (tmp_dir, _, db) = dummy_db(); + + { + let mut conn = db.connection().unwrap(); + + // Necessarily empty at first. + assert!(conn.unspent_coins().is_empty()); + + // Add one, we'll get it. + let coin_a = Coin { + outpoint: bitcoin::OutPoint::from_str( + "6f0dc85a369b44458eba3a1f0ea5b5935d563afb6994f70f5b0094e05be1676c:1", + ) + .unwrap(), + block_height: None, + amount: bitcoin::Amount::from_sat(98765), + derivation_index: bip32::ChildNumber::from_normal_idx(10).unwrap(), + spend_txid: None, + }; + conn.new_unspent_coins(&[coin_a.clone()]); // On 1.48, arrays aren't IntoIterator + assert_eq!(conn.unspent_coins()[0].outpoint, coin_a.outpoint); + + // Add a second one, we'll get both. + let coin_b = Coin { + outpoint: bitcoin::OutPoint::from_str( + "61db3e276b095e5b05f1849dd6bfffb4e7e5ec1c4a4210099b98fce01571936f:12", + ) + .unwrap(), + block_height: None, + amount: bitcoin::Amount::from_sat(1111), + derivation_index: bip32::ChildNumber::from_normal_idx(103).unwrap(), + spend_txid: None, + }; + conn.new_unspent_coins(&[coin_b.clone()]); + let outpoints: HashSet = conn + .unspent_coins() + .into_iter() + .map(|c| c.outpoint) + .collect(); + assert!(outpoints.contains(&coin_a.outpoint)); + assert!(outpoints.contains(&coin_b.outpoint)); + + // Now if we confirm one, it'll be marked as such. + let height = 174500; + conn.confirm_coins(&[(coin_a.outpoint, height)]); + let coins = conn.unspent_coins(); + assert_eq!(coins[0].block_height, Some(height)); + assert!(coins[1].block_height.is_none()); + + // Now if we spend one, we'll only get the other one. + conn.spend_coins(&[( + coin_a.outpoint, + bitcoin::Txid::from_slice(&[0; 32][..]).unwrap(), + )]); + let outpoints: HashSet = conn + .unspent_coins() + .into_iter() + .map(|c| c.outpoint) + .collect(); + assert!(!outpoints.contains(&coin_a.outpoint)); + assert!(outpoints.contains(&coin_b.outpoint)); + } + + fs::remove_dir_all(&tmp_dir).unwrap(); + } } diff --git a/src/database/sqlite/schema.rs b/src/database/sqlite/schema.rs index d58fb06b..3d386415 100644 --- a/src/database/sqlite/schema.rs +++ b/src/database/sqlite/schema.rs @@ -26,6 +26,22 @@ CREATE TABLE wallets ( main_descriptor TEXT NOT NULL, deposit_derivation_index INTEGER NOT NULL ); + +/* Our (U)TxOs. */ +CREATE TABLE coins ( + id INTEGER PRIMARY KEY NOT NULL, + wallet_id INTEGER NOT NULL, + blockheight INTEGER, + txid BLOB NOT NULL, + vout INTEGER NOT NULL, + amount_sat INTEGER NOT NULL, + derivation_index INTEGER NOT NULL, + spend_txid BLOB, + UNIQUE (txid, vout), + FOREIGN KEY (wallet_id) REFERENCES wallets (id) + ON UPDATE RESTRICT + ON DELETE RESTRICT +); "; /// A row in the "tip" table. @@ -88,3 +104,54 @@ impl TryFrom<&rusqlite::Row<'_>> for DbWallet { }) } } + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DbCoin { + pub id: i64, + pub wallet_id: i64, + pub outpoint: bitcoin::OutPoint, + pub block_height: Option, + pub amount: bitcoin::Amount, + pub derivation_index: bip32::ChildNumber, + pub spend_txid: Option, +} + +impl std::hash::Hash for DbCoin { + fn hash(&self, h: &mut H) { + self.outpoint.hash(h) + } +} + +impl TryFrom<&rusqlite::Row<'_>> for DbCoin { + type Error = rusqlite::Error; + + fn try_from(row: &rusqlite::Row) -> Result { + let id = row.get(0)?; + let wallet_id = row.get(1)?; + + let block_height = row.get(2)?; + let txid: Vec = row.get(3)?; + let txid: bitcoin::Txid = encode::deserialize(&txid).expect("We only store valid txids"); + let vout = row.get(4)?; + let outpoint = bitcoin::OutPoint { txid, vout }; + + let amount = row.get(5)?; + let amount = bitcoin::Amount::from_sat(amount); + let der_idx: u32 = row.get(6)?; + let derivation_index = bip32::ChildNumber::from(der_idx); + + let spend_txid: Option> = row.get(7)?; + let spend_txid = + spend_txid.map(|txid| encode::deserialize(&txid).expect("We only store valid txids")); + + Ok(DbCoin { + id, + wallet_id, + outpoint, + block_height, + amount, + derivation_index, + spend_txid, + }) + } +} diff --git a/src/testutils.rs b/src/testutils.rs index f38d73b3..c28fc6cb 100644 --- a/src/testutils.rs +++ b/src/testutils.rs @@ -1,11 +1,11 @@ use crate::{ bitcoin::{BitcoinInterface, BlockChainTip}, config::{BitcoinConfig, Config}, - database::{DatabaseConnection, DatabaseInterface}, + database::{Coin, DatabaseConnection, DatabaseInterface}, DaemonHandle, }; -use std::{env, fs, io, path, process, str::FromStr, sync, thread, time}; +use std::{collections::HashMap, env, fs, io, path, process, str::FromStr, sync, thread, time}; use miniscript::{ bitcoin::{self, util::bip32}, @@ -37,6 +37,7 @@ impl BitcoinInterface for DummyBitcoind { pub struct DummyDb { curr_index: bip32::ChildNumber, curr_tip: Option, + coins: HashMap, } impl DummyDb { @@ -44,6 +45,7 @@ impl DummyDb { DummyDb { curr_index: 0.into(), curr_tip: None, + coins: HashMap::new(), } } } @@ -59,6 +61,10 @@ pub struct DummyDbConn { } impl DatabaseConnection for DummyDbConn { + fn network(&mut self) -> bitcoin::Network { + bitcoin::Network::Bitcoin + } + fn chain_tip(&mut self) -> Option { self.db.read().unwrap().curr_tip } @@ -74,6 +80,38 @@ impl DatabaseConnection for DummyDbConn { fn update_derivation_index(&mut self, index: bip32::ChildNumber) { self.db.write().unwrap().curr_index = index; } + + fn unspent_coins(&mut self) -> HashMap { + self.db.read().unwrap().coins.clone() + } + + fn new_unspent_coins<'a>(&mut self, coins: &[Coin]) { + for coin in coins { + self.db + .write() + .unwrap() + .coins + .insert(coin.outpoint, coin.clone()); + } + } + + fn confirm_coins<'a>(&mut self, outpoints: &[(bitcoin::OutPoint, i32)]) { + for (op, height) in outpoints { + let mut db = self.db.write().unwrap(); + let h = &mut db.coins.get_mut(op).unwrap().block_height; + assert!(h.is_none()); + *h = Some(*height); + } + } + + fn spend_coins<'a>(&mut self, outpoints: &[(bitcoin::OutPoint, bitcoin::Txid)]) { + for (op, spend_txid) in outpoints { + let mut db = self.db.write().unwrap(); + let spender = &mut db.coins.get_mut(op).unwrap().spend_txid; + assert!(spender.is_none()); + *spender = Some(*spend_txid); + } + } } pub struct DummyMinisafe {