database: interface and implementation for coins storage and update

This commit is contained in:
Antoine Poinsot 2022-08-15 14:02:39 +02:00
parent b548451292
commit 05b3af1b5a
No known key found for this signature in database
GPG Key ID: E13FC145CD3F4304
4 changed files with 361 additions and 17 deletions

View File

@ -5,12 +5,15 @@ pub mod sqlite;
use crate::{ use crate::{
bitcoin::BlockChainTip, 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 { pub trait DatabaseInterface: Send {
fn connection(&self) -> Box<dyn DatabaseConnection>; fn connection(&self) -> Box<dyn DatabaseConnection>;
@ -33,12 +36,27 @@ pub trait DatabaseConnection {
/// Get the tip of the best chain we've seen. /// Get the tip of the best chain we've seen.
fn chain_tip(&mut self) -> Option<BlockChainTip>; fn chain_tip(&mut self) -> Option<BlockChainTip>;
/// The network we are operating on.
fn network(&mut self) -> bitcoin::Network;
/// Update our best chain seen. /// Update our best chain seen.
fn update_tip(&mut self, tip: &BlockChainTip); fn update_tip(&mut self, tip: &BlockChainTip);
fn derivation_index(&mut self) -> bip32::ChildNumber; fn derivation_index(&mut self) -> bip32::ChildNumber;
fn update_derivation_index(&mut self, index: bip32::ChildNumber); fn update_derivation_index(&mut self, index: bip32::ChildNumber);
/// Get all UTxOs.
fn unspent_coins(&mut self) -> HashMap<bitcoin::OutPoint, Coin>;
/// 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 { 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) { fn update_tip(&mut self, tip: &BlockChainTip) {
self.update_tip(&tip) self.update_tip(&tip)
} }
@ -64,4 +86,68 @@ impl DatabaseConnection for SqliteConn {
fn update_derivation_index(&mut self, index: bip32::ChildNumber) { fn update_derivation_index(&mut self, index: bip32::ChildNumber) {
self.update_derivation_index(index) self.update_derivation_index(index)
} }
fn unspent_coins(&mut self) -> HashMap<bitcoin::OutPoint, Coin> {
// 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<i32>,
pub amount: bitcoin::Amount,
pub derivation_index: bip32::ChildNumber,
pub spend_txid: Option<bitcoin::Txid>,
}
impl std::hash::Hash for Coin {
fn hash<H: std::hash::Hasher>(&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()
}
} }

View File

@ -11,9 +11,12 @@ mod utils;
use crate::{ use crate::{
bitcoin::BlockChainTip, bitcoin::BlockChainTip,
database::sqlite::{ database::{
schema::{DbTip, DbWallet}, sqlite::{
utils::{create_fresh_db, db_exec, db_query}, 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 { pub struct SqliteConn {
conn: rusqlite::Connection, conn: rusqlite::Connection,
} }
@ -214,13 +220,86 @@ impl SqliteConn {
}) })
.expect("Database must be available") .expect("Database must be available")
} }
/// Get all UTxOs.
pub fn unspent_coins(&mut self) -> Vec<DbCoin> {
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<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) \
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<Item = &'a (bitcoin::OutPoint, i32)>,
) {
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<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")
}
} }
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use crate::testutils::*; 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 { fn dummy_options() -> FreshDbOptions {
let desc_str = "wsh(andor(pk(03b506a1dbe57b4bf48c95e0c7d417b87dd3b4349d290d2e7e9ba72c912652d80a),older(10000),pk(0295e7f5d12a2061f1fd2286cefec592dff656a19f55f4f01305d6aa56630880ce)))"; 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] #[test]
fn db_startup_sanity_checks() { fn db_startup_sanity_checks() {
let tmp_dir = tmp_dir(); let tmp_dir = tmp_dir();
@ -274,14 +366,7 @@ mod tests {
#[test] #[test]
fn db_tip_update() { fn db_tip_update() {
let tmp_dir = tmp_dir(); let (tmp_dir, options, db) = dummy_db();
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 mut conn = db.connection().unwrap(); let mut conn = db.connection().unwrap();
@ -306,4 +391,72 @@ mod tests {
fs::remove_dir_all(&tmp_dir).unwrap(); 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<bitcoin::OutPoint> = 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<bitcoin::OutPoint> = 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();
}
} }

View File

@ -26,6 +26,22 @@ CREATE TABLE wallets (
main_descriptor TEXT NOT NULL, main_descriptor TEXT NOT NULL,
deposit_derivation_index INTEGER 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. /// 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<i32>,
pub amount: bitcoin::Amount,
pub derivation_index: bip32::ChildNumber,
pub spend_txid: Option<bitcoin::Txid>,
}
impl std::hash::Hash for DbCoin {
fn hash<H: std::hash::Hasher>(&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<Self, Self::Error> {
let id = row.get(0)?;
let wallet_id = row.get(1)?;
let block_height = row.get(2)?;
let txid: Vec<u8> = 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<Vec<u8>> = 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,
})
}
}

View File

@ -1,11 +1,11 @@
use crate::{ use crate::{
bitcoin::{BitcoinInterface, BlockChainTip}, bitcoin::{BitcoinInterface, BlockChainTip},
config::{BitcoinConfig, Config}, config::{BitcoinConfig, Config},
database::{DatabaseConnection, DatabaseInterface}, database::{Coin, DatabaseConnection, DatabaseInterface},
DaemonHandle, 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::{ use miniscript::{
bitcoin::{self, util::bip32}, bitcoin::{self, util::bip32},
@ -37,6 +37,7 @@ impl BitcoinInterface for DummyBitcoind {
pub struct DummyDb { pub struct DummyDb {
curr_index: bip32::ChildNumber, curr_index: bip32::ChildNumber,
curr_tip: Option<BlockChainTip>, curr_tip: Option<BlockChainTip>,
coins: HashMap<bitcoin::OutPoint, Coin>,
} }
impl DummyDb { impl DummyDb {
@ -44,6 +45,7 @@ impl DummyDb {
DummyDb { DummyDb {
curr_index: 0.into(), curr_index: 0.into(),
curr_tip: None, curr_tip: None,
coins: HashMap::new(),
} }
} }
} }
@ -59,6 +61,10 @@ pub struct DummyDbConn {
} }
impl DatabaseConnection for DummyDbConn { impl DatabaseConnection for DummyDbConn {
fn network(&mut self) -> bitcoin::Network {
bitcoin::Network::Bitcoin
}
fn chain_tip(&mut self) -> Option<BlockChainTip> { fn chain_tip(&mut self) -> Option<BlockChainTip> {
self.db.read().unwrap().curr_tip self.db.read().unwrap().curr_tip
} }
@ -74,6 +80,38 @@ impl DatabaseConnection for DummyDbConn {
fn update_derivation_index(&mut self, index: bip32::ChildNumber) { fn update_derivation_index(&mut self, index: bip32::ChildNumber) {
self.db.write().unwrap().curr_index = index; self.db.write().unwrap().curr_index = index;
} }
fn unspent_coins(&mut self) -> HashMap<bitcoin::OutPoint, Coin> {
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 { pub struct DummyMinisafe {