database: interface and implementation for coins storage and update
This commit is contained in:
parent
b548451292
commit
05b3af1b5a
@ -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<dyn DatabaseConnection>;
|
||||
@ -33,12 +36,27 @@ pub trait DatabaseConnection {
|
||||
/// Get the tip of the best chain we've seen.
|
||||
fn chain_tip(&mut self) -> Option<BlockChainTip>;
|
||||
|
||||
/// 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<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 {
|
||||
@ -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<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()
|
||||
}
|
||||
}
|
||||
|
||||
@ -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<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)]
|
||||
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<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();
|
||||
}
|
||||
}
|
||||
|
||||
@ -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<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,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@ -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<BlockChainTip>,
|
||||
coins: HashMap<bitcoin::OutPoint, Coin>,
|
||||
}
|
||||
|
||||
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<BlockChainTip> {
|
||||
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<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 {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user