Merge #13: Coin tracking

9d0c68dae34f96a96d318f75cbbb5dbefffdd3d8 commands: add a 'list_coins' command. (Antoine Poinsot)
99a9cbf0f83fda6f98892c23ac89324f04b650ff poller: query derivation index by address from DB (Antoine Poinsot)
3f17e9f0c32db9724b384b9bd58c097653e9d04f database: change interface from update_der_index to increment_der_index (Antoine Poinsot)
e74ea4c2d36d17d0aa05044991d2cfff311d9f37 sqlite: a table for a mapping from address to derivation index. (Antoine Poinsot)
c9ef068fa5cb0ec39a9a88ec581434f6abb84048 poller: update our coins on each poll (Antoine Poinsot)
c6a25adfcdd371a1a2581cc210fb4dc03dfced2d bitcoin: interface for coins discovery and updates (Antoine Poinsot)
05b3af1b5a9faaa54106e11f87e38b391ebff744 database: interface and implementation for coins storage and update (Antoine Poinsot)

Pull request description:

ACKs for top commit:
  edouardparis:
    ACK 9d0c68dae34f96a96d318f75cbbb5dbefffdd3d8

Tree-SHA512: 0087ff04939326ae7affbb4c0defbe5573fc58d15f4b2fb47a71a03880187c4105742cc9eda868c3fe0ee5890931dc57eb3c63b4ef79335f434771d5cc089483
This commit is contained in:
Antoine Poinsot 2022-08-25 10:29:46 +02:00
commit 3d427713e1
No known key found for this signature in database
GPG Key ID: E13FC145CD3F4304
14 changed files with 1218 additions and 67 deletions

View File

@ -64,3 +64,23 @@ This command does not take any parameter for now.
| Field | Type | Description |
| ------------- | ------ | ------------------ |
| `address` | string | A Bitcoin address |
### `listcoins`
List our current Unspent Transaction Outputs.
#### Request
This command does not take any parameter for now.
| Field | Type | Description |
| ------------- | ----------------- | ----------------------------------------------------------- |
#### Response
| Field | Type | Description |
| -------------- | ------------- | ---------------------------------------------------------------- |
| `amount` | int | Value of the UTxO in satoshis |
| `outpoint` | string | Transaction id and output index of this coin |
| `block_height` | int or null | Blockheight the transaction was confirmed at, or `null` |

View File

@ -3,7 +3,7 @@
///! We use the RPC interface and a watchonly descriptor wallet.
use crate::{bitcoin::BlockChainTip, config};
use std::{convert::TryInto, fs, io, str::FromStr, time::Duration};
use std::{collections::HashSet, convert::TryInto, fs, io, str::FromStr, time::Duration};
use jsonrpc::{
arg,
@ -278,6 +278,14 @@ impl BitcoinD {
.expect("We must not fail to make a request for more than a minute")
}
fn make_faillible_wallet_request(
&self,
method: &str,
params: &[Box<serde_json::value::RawValue>],
) -> Result<Json, BitcoindError> {
self.make_request(&self.watchonly_client, method, params)
}
fn get_bitcoind_version(&self) -> u64 {
self.make_node_request("getnetworkinfo", &[])
.get("version")
@ -521,8 +529,134 @@ impl BitcoinD {
.expect("bitcoind must send valid block hashes"),
)
}
}
pub fn list_since_block(&self, block_hash: &bitcoin::BlockHash) -> LSBlockRes {
self.make_wallet_request(
"listsinceblock",
&params!(Json::String(block_hash.to_string()),),
)
.into()
}
pub fn get_transaction(&self, txid: &bitcoin::Txid) -> Option<GetTxRes> {
// TODO: Maybe assert we got a -5 error, and not any other kind of error?
self.make_faillible_wallet_request(
"gettransaction",
&params!(Json::String(txid.to_string())),
)
.ok()
.map(|res| res.into())
}
/// Efficient check that a coin is spent.
pub fn is_spent(&self, op: &bitcoin::OutPoint) -> bool {
// The result of gettxout is empty if the outpoint is spent.
self.make_node_request(
"gettxout",
&params!(
Json::String(op.txid.to_string()),
Json::Number(op.vout.into())
),
)
.get("bestblock")
.is_none()
}
/// So, bitcoind has no API for getting the transaction spending a wallet UTXO. Instead we are
/// therefore using a rather convoluted way to get it the other way around, since the spending
/// transaction is actually *part of the wallet transactions*.
/// So, what we do there is listing all outgoing transactions of the wallet since the last poll
/// and iterating through each of those to check if it spends the transaction we are interested
/// in (requiring an other RPC call for each!!).
pub fn get_spender_txid(&self, spent_outpoint: &bitcoin::OutPoint) -> Option<bitcoin::Txid> {
// Get the hash of the block parent of the spent transaction's block.
let req = self.make_wallet_request(
"gettransaction",
&params!(Json::String(spent_outpoint.txid.to_string())),
);
let spent_tx_height = match req.get("blockheight").and_then(Json::as_i64) {
Some(h) => h,
// FIXME: we assume it's confirmed. If we were to change the logic in the poller, we'd
// need to handle it here.
None => return None,
};
let block_hash = if let Ok(res) = self.make_fallible_node_request(
"getblockhash",
&params!(Json::Number((spent_tx_height - 1).into())),
) {
res.as_str()
.expect("'getblockhash' result isn't a string")
.to_string()
} else {
// Possibly a race.
return None;
};
// Now we can get all transactions related to us since the spent transaction confirmed.
// We'll use it to locate the spender.
let lsb_res =
self.make_wallet_request("listsinceblock", &params!(Json::String(block_hash)));
let transactions = lsb_res
.get("transactions")
.and_then(Json::as_array)
.expect("tx array must be there");
// Get the spent txid to ignore the entries about this transaction
let spent_txid = spent_outpoint.txid.to_string();
// We use a cache to avoid needless iterations, since listsinceblock returns an entry
// per transaction output, not per transaction.
let mut visited_txs = HashSet::with_capacity(transactions.len());
for transaction in transactions {
if transaction.get("category").and_then(Json::as_str) != Some("send") {
continue;
}
let spending_txid = transaction
.get("txid")
.and_then(Json::as_str)
.expect("A valid txid must be present");
if visited_txs.contains(&spending_txid) || &spent_txid == spending_txid {
continue;
} else {
visited_txs.insert(spending_txid);
}
let gettx_res = self.make_wallet_request(
"gettransaction",
&params!(
Json::String(spending_txid.to_string()),
Json::Bool(true), // watchonly
Json::Bool(true) // verbose
),
);
let vin = gettx_res
.get("decoded")
.and_then(|d| d.get("vin").and_then(Json::as_array))
.expect("A valid vin array must be present");
for input in vin {
let txid = input
.get("txid")
.and_then(Json::as_str)
.and_then(|t| bitcoin::Txid::from_str(t).ok())
.expect("A valid txid must be present");
let vout = input
.get("vout")
.and_then(Json::as_u64)
.expect("A valid vout must be present") as u32;
let input_outpoint = bitcoin::OutPoint { txid, vout };
if spent_outpoint == &input_outpoint {
return bitcoin::Txid::from_str(spending_txid)
.map(Some)
.expect("Must be a valid txid");
}
}
}
None
}
}
// Bitcoind uses a guess for the value of verificationprogress. It will eventually get to
// be 1, and we want to be less conservative.
fn roundup_progress(progress: f64) -> f64 {
@ -535,3 +669,96 @@ fn roundup_progress(progress: f64) -> f64 {
(progress_rounded as f64 / precision) as f64
}
}
/// A 'received' entry in the 'listsinceblock' result.
#[derive(Debug, Clone)]
pub struct LSBlockEntry {
pub outpoint: bitcoin::OutPoint,
pub amount: bitcoin::Amount,
pub block_height: Option<i32>,
pub address: bitcoin::Address,
}
impl From<&Json> for LSBlockEntry {
fn from(json: &Json) -> LSBlockEntry {
let txid = json
.get("txid")
.and_then(Json::as_str)
.and_then(|s| bitcoin::Txid::from_str(s).ok())
.expect("bitcoind can't give a bad block hash");
let vout = json
.get("vout")
.and_then(Json::as_u64)
.expect("bitcoind can't give a bad vout") as u32;
let outpoint = bitcoin::OutPoint { txid, vout };
// Must be a received entry, hence not negative.
let amount = json
.get("amount")
.and_then(Json::as_f64)
.and_then(|a| bitcoin::Amount::from_btc(a).ok())
.expect("bitcoind won't give us a bad amount");
let block_height = json
.get("blockheight")
.and_then(Json::as_i64)
.map(|bh| bh as i32);
let address = json
.get("address")
.and_then(Json::as_str)
.and_then(|s| bitcoin::Address::from_str(s).ok())
.expect("bitcoind can't give a bad address");
LSBlockEntry {
outpoint,
amount,
block_height,
address,
}
}
}
#[derive(Debug, Clone)]
pub struct LSBlockRes {
pub received_coins: Vec<LSBlockEntry>,
}
impl From<Json> for LSBlockRes {
fn from(json: Json) -> LSBlockRes {
let received_coins = json
.get("transactions")
.and_then(Json::as_array)
.expect("Array must be present")
.into_iter()
.filter_map(|j| {
if j.get("category")
.and_then(Json::as_str)
.expect("must be present")
== "receive"
{
let lsb_entry: LSBlockEntry = j.into();
Some(lsb_entry)
} else {
None
}
})
.collect();
LSBlockRes { received_coins }
}
}
#[derive(Debug, Clone, Copy)]
pub struct GetTxRes {
pub block_height: Option<i32>,
}
impl From<Json> for GetTxRes {
fn from(json: Json) -> GetTxRes {
let block_height = json
.get("blockheight")
.and_then(Json::as_i64)
.map(|bh| bh as i32);
GetTxRes { block_height }
}
}

View File

@ -4,9 +4,11 @@
pub mod d;
pub mod poller;
use d::LSBlockEntry;
use std::sync;
use miniscript::bitcoin;
use miniscript::bitcoin::{self, hashes::Hash};
/// Information about the best block in the chain
#[derive(Debug, Clone, Eq, PartialEq, Copy)]
@ -17,6 +19,8 @@ pub struct BlockChainTip {
/// Our Bitcoin backend.
pub trait BitcoinInterface: Send {
fn genesis_block(&self) -> BlockChainTip;
/// Get the progress of the block chain synchronization.
/// Returns a percentage between 0 and 1.
fn sync_progress(&self) -> f64;
@ -26,9 +30,29 @@ pub trait BitcoinInterface: Send {
/// Check whether this former tip is part of the current best chain.
fn is_in_chain(&self, tip: &BlockChainTip) -> bool;
/// Get coins received since the specified tip.
fn received_coins(&self, tip: &BlockChainTip) -> Vec<UTxO>;
/// Get all coins that were confirmed, and at what height.
fn confirmed_coins(&self, outpoints: &[bitcoin::OutPoint]) -> Vec<(bitcoin::OutPoint, i32)>;
/// Get all coins that were spent, and the spending txid.
fn spent_coins(
&self,
outpoints: &[bitcoin::OutPoint],
) -> Vec<(bitcoin::OutPoint, bitcoin::Txid)>;
}
impl BitcoinInterface for d::BitcoinD {
fn genesis_block(&self) -> BlockChainTip {
let height = 0;
let hash = self
.get_block_hash(height)
.expect("Genesis block hash must always be there");
BlockChainTip { hash, height }
}
fn sync_progress(&self) -> f64 {
self.sync_progress()
}
@ -42,10 +66,80 @@ impl BitcoinInterface for d::BitcoinD {
.map(|bh| bh == tip.hash)
.unwrap_or(false)
}
fn received_coins(&self, tip: &BlockChainTip) -> Vec<UTxO> {
// TODO: don't assume only a single descriptor is loaded on the wo wallet
let lsb_res = self.list_since_block(&tip.hash);
lsb_res
.received_coins
.into_iter()
.map(|entry| {
let LSBlockEntry {
outpoint,
amount,
block_height,
address,
} = entry;
UTxO {
outpoint,
amount,
block_height,
address,
}
})
.collect()
}
fn confirmed_coins(&self, outpoints: &[bitcoin::OutPoint]) -> Vec<(bitcoin::OutPoint, i32)> {
let mut confirmed = Vec::with_capacity(outpoints.len());
for op in outpoints {
// TODO: batch those calls to gettransaction
if let Some(res) = self.get_transaction(&op.txid) {
if let Some(h) = res.block_height {
confirmed.push((*op, h));
}
} else {
log::error!("Transaction not in wallet for coin '{}'.", op);
}
}
confirmed
}
fn spent_coins(
&self,
outpoints: &[bitcoin::OutPoint],
) -> Vec<(bitcoin::OutPoint, bitcoin::Txid)> {
let mut spent = Vec::with_capacity(outpoints.len());
for op in outpoints {
if self.is_spent(&op) {
let spending_txid = if let Some(txid) = self.get_spender_txid(&op) {
txid
} else {
// TODO: better handling of this edge case.
log::error!(
"Could not get spender of '{}'. Using a dummy spending txid.",
op
);
bitcoin::Txid::from_slice(&[0; 32][..]).unwrap()
};
spent.push((*op, spending_txid));
}
}
spent
}
}
// FIXME: do we need to repeat the entire trait implemenation? Isn't there a nicer way?
impl BitcoinInterface for sync::Arc<sync::Mutex<dyn BitcoinInterface + 'static>> {
fn genesis_block(&self) -> BlockChainTip {
self.lock().unwrap().genesis_block()
}
fn sync_progress(&self) -> f64 {
self.lock().unwrap().sync_progress()
}
@ -57,4 +151,29 @@ impl BitcoinInterface for sync::Arc<sync::Mutex<dyn BitcoinInterface + 'static>>
fn is_in_chain(&self, tip: &BlockChainTip) -> bool {
self.lock().unwrap().is_in_chain(tip)
}
fn received_coins(&self, tip: &BlockChainTip) -> Vec<UTxO> {
self.lock().unwrap().received_coins(tip)
}
fn confirmed_coins(&self, outpoints: &[bitcoin::OutPoint]) -> Vec<(bitcoin::OutPoint, i32)> {
self.lock().unwrap().confirmed_coins(outpoints)
}
fn spent_coins(
&self,
outpoints: &[bitcoin::OutPoint],
) -> Vec<(bitcoin::OutPoint, bitcoin::Txid)> {
self.lock().unwrap().spent_coins(outpoints)
}
}
// FIXME: We could avoid this type (and all the conversions entailing allocations) if bitcoind
// exposed the derivation index from the parent descriptor in the LSB result.
#[derive(Debug, Clone)]
pub struct UTxO {
pub outpoint: bitcoin::OutPoint,
pub amount: bitcoin::Amount,
pub block_height: Option<i32>,
pub address: bitcoin::Address,
}

View File

@ -1,6 +1,6 @@
use crate::{
bitcoin::BitcoinInterface,
database::{DatabaseConnection, DatabaseInterface},
bitcoin::{BitcoinInterface, BlockChainTip, UTxO},
database::{Coin, DatabaseConnection, DatabaseInterface},
};
use std::{
@ -8,32 +8,148 @@ use std::{
thread, time,
};
fn update_tip(bit: &impl BitcoinInterface, db_conn: &mut Box<dyn DatabaseConnection>) {
use miniscript::bitcoin;
#[derive(Debug, Clone)]
struct UpdatedCoins {
pub received: Vec<Coin>,
pub confirmed: Vec<(bitcoin::OutPoint, i32)>,
pub spent: Vec<(bitcoin::OutPoint, bitcoin::Txid)>,
}
// Update the state of our coins. There may be new unspent, and existing ones may become confirmed
// or spent.
// NOTE: A coin may be updated multiple times at once. That is, a coin may be received, confirmed,
// and spent in a single poll.
fn update_coins(
bit: &impl BitcoinInterface,
db_conn: &mut Box<dyn DatabaseConnection>,
previous_tip: &BlockChainTip,
) -> UpdatedCoins {
// Start by fetching newly received coins.
let curr_coins = db_conn.unspent_coins();
let mut received = Vec::new();
for utxo in bit.received_coins(&previous_tip) {
if let Some(derivation_index) = db_conn.derivation_index_by_address(&utxo.address) {
if !curr_coins.contains_key(&utxo.outpoint) {
let UTxO {
outpoint, amount, ..
} = utxo;
let coin = Coin {
outpoint,
amount,
derivation_index,
block_height: None,
spend_txid: None,
};
received.push(coin);
}
} else {
log::error!(
"Could not get derivation index for coin '{}' (address: '{}')",
&utxo.outpoint,
&utxo.address
);
}
}
// We need to take the newly received ones into account as well, as they may have been
// confirmed within the previous tip and the current one, and we may not poll this chunk of the
// chain anymore.
let to_be_confirmed: Vec<bitcoin::OutPoint> = curr_coins
.values()
.chain(received.iter())
.filter_map(|coin| {
if coin.block_height.is_none() {
Some(coin.outpoint)
} else {
None
}
})
.collect();
let confirmed = bit.confirmed_coins(&to_be_confirmed);
// We need to take the newly received ones into account as well, as they may have been
// spent within the previous tip and the current one, and we may not poll this chunk of the
// chain anymore.
let to_be_spent: Vec<bitcoin::OutPoint> = curr_coins
.values()
.chain(received.iter())
.filter_map(|coin| {
if coin.spend_txid.is_none() {
Some(coin.outpoint)
} else {
None
}
})
.collect();
let spent = bit.spent_coins(&to_be_spent);
UpdatedCoins {
received,
confirmed,
spent,
}
}
// Returns the new block chain tip, if it changed.
fn new_tip(bit: &impl BitcoinInterface, current_tip: &BlockChainTip) -> Option<BlockChainTip> {
let bitcoin_tip = bit.chain_tip();
let current_tip = match db_conn.chain_tip() {
Some(tip) => tip,
None => {
db_conn.update_tip(&bitcoin_tip);
return;
}
};
// If the tip didn't change, there is nothing to update.
if current_tip == bitcoin_tip {
return;
if current_tip == &bitcoin_tip {
return None;
}
if bitcoin_tip.height > current_tip.height {
// Make sure we are on the same chain.
if bit.is_in_chain(&current_tip) {
// All good, we just moved forward. Record the new tip.
db_conn.update_tip(&bitcoin_tip);
return;
// All good, we just moved forward.
return Some(bitcoin_tip);
}
}
// TODO: reorg handling.
None
}
fn updates(bit: &impl BitcoinInterface, db: &impl DatabaseInterface) {
let mut db_conn = db.connection();
// Check if there was a new block before updating ourselves.
let current_tip = db_conn.chain_tip().expect("Always set at first startup");
let new_tip = new_tip(bit, &current_tip);
let latest_tip = new_tip.unwrap_or(current_tip);
// Then check the state of our coins. Do it even if the tip did not change since last poll, as
// we may have unconfirmed transactions.
let updated_coins = update_coins(bit, &mut db_conn, &current_tip);
// If the tip changed while we were polling our Bitcoin interface, start over.
if bit.chain_tip() != latest_tip {
log::info!("Chain tip changed while we were updating our state. Starting over.");
return updates(bit, db);
}
// The chain tip did not change since we started our updates. Record them and the latest tip.
// Having the tip in database means that, as far as the chain is concerned, we've got all
// updates up to this block. But not more.
db_conn.new_unspent_coins(&updated_coins.received);
db_conn.confirm_coins(&updated_coins.confirmed);
db_conn.spend_coins(&updated_coins.spent);
if let Some(tip) = new_tip {
db_conn.update_tip(&tip);
}
}
// If the database chain tip is NULL (first startup), initialize it.
fn maybe_initialize_tip(bit: &impl BitcoinInterface, db: &impl DatabaseInterface) {
let mut db_conn = db.connection();
if db_conn.chain_tip().is_none() {
// TODO: be smarter. We can use the timestamp of the descriptor to get a newer block hash.
db_conn.update_tip(&bit.genesis_block());
}
}
/// Main event loop. Repeatedly polls the Bitcoin interface until told to stop through the
@ -47,6 +163,8 @@ pub fn looper(
let mut last_poll = None;
let mut synced = false;
maybe_initialize_tip(&bit, &db);
while !shutdown.load(atomic::Ordering::Relaxed) || last_poll.is_none() {
let now = time::Instant::now();
@ -75,7 +193,6 @@ pub fn looper(
}
}
let mut db_conn = db.connection();
update_tip(&bit, &mut db_conn);
updates(&bit, &db);
}
}

View File

@ -2,7 +2,14 @@
//!
//! External interface to the Minisafe daemon.
use crate::{bitcoin::BitcoinInterface, database::DatabaseInterface, DaemonControl, VERSION};
mod utils;
use crate::{
bitcoin::BitcoinInterface,
database::{Coin, DatabaseInterface},
DaemonControl, VERSION,
};
use utils::{deser_amount_from_sats, ser_amount};
use miniscript::{
bitcoin,
@ -31,7 +38,7 @@ impl DaemonControl {
let mut db_conn = self.db.connection();
let index = db_conn.derivation_index();
// TODO: handle should we wrap around instead of failing?
db_conn.update_derivation_index(index.increment().expect("TODO: handle wraparound"));
db_conn.increment_derivation_index(&self.secp);
let address = self
.config
.main_descriptor
@ -43,6 +50,30 @@ impl DaemonControl {
.expect("It's a wsh() descriptor");
GetAddressResult { address }
}
/// Get a list of all currently unspent coins.
pub fn list_coins(&self) -> ListCoinsResult {
let mut db_conn = self.db.connection();
let coins: Vec<ListCoinsEntry> = db_conn
.unspent_coins()
// Can't use into_values as of Rust 1.48
.into_iter()
.map(|(_, coin)| {
let Coin {
amount,
outpoint,
block_height,
..
} = coin;
ListCoinsEntry {
amount,
outpoint,
block_height,
}
})
.collect();
ListCoinsResult { coins }
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
@ -65,6 +96,22 @@ pub struct GetAddressResult {
pub address: bitcoin::Address,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ListCoinsEntry {
#[serde(
serialize_with = "ser_amount",
deserialize_with = "deser_amount_from_sats"
)]
pub amount: bitcoin::Amount,
pub outpoint: bitcoin::OutPoint,
pub block_height: Option<i32>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ListCoinsResult {
pub coins: Vec<ListCoinsEntry>,
}
#[cfg(test)]
mod tests {
use super::*;

16
src/commands/utils.rs Normal file
View File

@ -0,0 +1,16 @@
use miniscript::bitcoin;
use serde::{Deserialize, Deserializer, Serializer};
/// Serialize an amount as sats
pub fn ser_amount<S: Serializer>(amount: &bitcoin::Amount, s: S) -> Result<S::Ok, S::Error> {
s.serialize_u64(amount.as_sat())
}
/// Deserialize an amount from sats
pub fn deser_amount_from_sats<'de, D>(deserializer: D) -> Result<bitcoin::Amount, D::Error>
where
D: Deserializer<'de>,
{
let a = u64::deserialize(deserializer)?;
Ok(bitcoin::Amount::from_sat(a))
}

View File

@ -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, secp256k1, util::bip32};
pub trait DatabaseInterface: Send {
fn connection(&self) -> Box<dyn DatabaseConnection>;
@ -33,12 +36,32 @@ 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);
fn increment_derivation_index(&mut self, secp: &secp256k1::Secp256k1<secp256k1::VerifyOnly>);
fn derivation_index_by_address(
&mut self,
address: &bitcoin::Address,
) -> Option<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 +76,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)
}
@ -61,7 +88,79 @@ impl DatabaseConnection for SqliteConn {
self.db_wallet().deposit_derivation_index
}
fn update_derivation_index(&mut self, index: bip32::ChildNumber) {
self.update_derivation_index(index)
fn increment_derivation_index(&mut self, secp: &secp256k1::Secp256k1<secp256k1::VerifyOnly>) {
self.increment_derivation_index(secp)
}
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)
}
fn derivation_index_by_address(
&mut self,
address: &bitcoin::Address,
) -> Option<bip32::ChildNumber> {
self.db_address(address)
.map(|db_addr| db_addr.derivation_index)
}
}
#[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,17 +11,20 @@ mod utils;
use crate::{
bitcoin::BlockChainTip,
database::sqlite::{
schema::{DbTip, DbWallet},
utils::{create_fresh_db, db_exec, db_query},
database::{
sqlite::{
schema::{DbAddress, DbCoin, DbTip, DbWallet},
utils::{create_fresh_db, db_exec, db_query, db_tx_query, LOOK_AHEAD_LIMIT},
},
Coin,
},
};
use std::{convert::TryInto, fmt, io, path};
use miniscript::{
bitcoin::{self, util::bip32},
Descriptor, DescriptorPublicKey,
bitcoin::{self, secp256k1},
Descriptor, DescriptorPublicKey, DescriptorTrait, TranslatePk2,
};
const DB_VERSION: i64 = 0;
@ -90,10 +93,11 @@ impl SqliteDb {
pub fn new(
db_path: path::PathBuf,
fresh_options: Option<FreshDbOptions>,
secp: &secp256k1::Secp256k1<secp256k1::VerifyOnly>,
) -> Result<SqliteDb, SqliteDbError> {
// Create the database if needed, and make sure the db file exists.
if let Some(options) = fresh_options {
create_fresh_db(&db_path, options)?;
create_fresh_db(&db_path, options, secp)?;
log::info!("Created a fresh database at {}.", db_path.display());
}
if !db_path.exists() {
@ -141,6 +145,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,
}
@ -200,30 +207,147 @@ impl SqliteConn {
.expect("Database must be available")
}
/// Update the deposit derivation index.
pub fn update_derivation_index(&mut self, index: bip32::ChildNumber) {
let new_index: u32 = index.into();
pub fn increment_derivation_index(
&mut self,
secp: &secp256k1::Secp256k1<secp256k1::VerifyOnly>,
) {
let network = self.db_tip().network;
db_exec(&mut self.conn, |db_tx| {
let db_wallet: DbWallet = db_tx_query(
&db_tx,
"SELECT * FROM wallets",
rusqlite::params![],
|row| row.try_into(),
)
.expect("Db must not fail")
.pop()
.expect("There is always a row in the wallet table");
let next_index: u32 = db_wallet
.deposit_derivation_index
.increment()
.expect("Must not get in hardened territory")
.into();
// NOTE: should be updated if we ever have multi-wallet support
db_tx.execute(
"UPDATE wallets SET deposit_derivation_index = (?1)",
rusqlite::params![next_index],
)?;
// Update the address to derivation index mapping.
// TODO: have this as a helper in descriptors.rs
let next_la_index = next_index + LOOK_AHEAD_LIMIT - 1;
let next_la_address = db_wallet
.main_descriptor
.derive(next_la_index)
.translate_pk2(|xpk| xpk.derive_public_key(secp))
.expect("All pubkeys were derived, no wildcard.")
.address(network)
.expect("It's a wsh() descriptor");
db_tx
.execute(
"UPDATE wallets SET deposit_derivation_index = (?1)",
rusqlite::params![new_index],
"INSERT INTO addresses (address, derivation_index) VALUES (?1, ?2)",
rusqlite::params![next_la_address.to_string(), next_la_index],
)
.map(|_| ())
})
.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")
}
pub fn db_address(&mut self, address: &bitcoin::Address) -> Option<DbAddress> {
db_query(
&mut self.conn,
"SELECT * FROM addresses WHERE address = ?1",
rusqlite::params![address.to_string()],
|row| row.try_into(),
)
.expect("Db must not fail")
.pop()
}
}
#[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, util::bip32};
use miniscript::{DescriptorTrait, TranslatePk2};
fn dummy_options() -> FreshDbOptions {
let desc_str = "wsh(andor(pk(03b506a1dbe57b4bf48c95e0c7d417b87dd3b4349d290d2e7e9ba72c912652d80a),older(10000),pk(0295e7f5d12a2061f1fd2286cefec592dff656a19f55f4f01305d6aa56630880ce)))";
let desc_str = "wsh(andor(pk(tpubDEN9WSToTyy9ZQfaYqSKfmVqmq1VVLNtYfj3Vkqh67et57eJ5sTKZQBkHqSwPUsoSskJeaYnPttHe2VrkCsKA27kUaN9SDc5zhqeLzKa1rr/*),older(10000),pk(tpubD8LYfn6njiA2inCoxwM7EuN3cuLVcaHAwLYeups13dpevd3nHLRdK9NdQksWXrhLQVxcUZRpnp5CkJ1FhE61WRAsHxDNAkvGkoQkAeWDYjV/*)))#y5wcna2d";
let main_descriptor = Descriptor::<DescriptorPublicKey>::from_str(desc_str).unwrap();
FreshDbOptions {
bitcoind_network: bitcoin::Network::Bitcoin,
@ -231,22 +355,42 @@ mod tests {
}
}
#[test]
fn db_startup_sanity_checks() {
fn dummy_db() -> (
path::PathBuf,
FreshDbOptions,
secp256k1::Secp256k1<secp256k1::VerifyOnly>,
SqliteDb,
) {
let tmp_dir = tmp_dir();
fs::create_dir_all(&tmp_dir).unwrap();
let secp = secp256k1::Secp256k1::verification_only();
let db_path: path::PathBuf = [tmp_dir.as_path(), path::Path::new("minisafed.sqlite3")]
.iter()
.collect();
assert!(SqliteDb::new(db_path.clone(), None)
let options = dummy_options();
let db = SqliteDb::new(db_path.clone(), Some(options.clone()), &secp).unwrap();
(tmp_dir, options, secp, db)
}
#[test]
fn db_startup_sanity_checks() {
let tmp_dir = tmp_dir();
fs::create_dir_all(&tmp_dir).unwrap();
let secp = secp256k1::Secp256k1::verification_only();
let db_path: path::PathBuf = [tmp_dir.as_path(), path::Path::new("minisafed.sqlite3")]
.iter()
.collect();
assert!(SqliteDb::new(db_path.clone(), None, &secp)
.unwrap_err()
.to_string()
.contains("database file not found"));
let options = dummy_options();
let db = SqliteDb::new(db_path.clone(), Some(options.clone())).unwrap();
let db = SqliteDb::new(db_path.clone(), Some(options.clone()), &secp).unwrap();
db.sanity_check(bitcoin::Network::Testnet, &options.main_descriptor)
.unwrap_err()
.to_string()
@ -254,7 +398,7 @@ mod tests {
fs::remove_file(&db_path).unwrap();
let other_desc_str = "wsh(andor(pk(037a27a76ebf33594c785e4fa41607860a960bb5aa3039654297b05bff57e4f9a9),older(10000),pk(0295e7f5d12a2061f1fd2286cefec592dff656a19f55f4f01305d6aa56630880ce)))";
let other_desc = Descriptor::<DescriptorPublicKey>::from_str(other_desc_str).unwrap();
let db = SqliteDb::new(db_path.clone(), Some(options.clone())).unwrap();
let db = SqliteDb::new(db_path.clone(), Some(options.clone()), &secp).unwrap();
db.sanity_check(bitcoin::Network::Bitcoin, &other_desc)
.unwrap_err()
.to_string()
@ -262,10 +406,10 @@ mod tests {
fs::remove_file(&db_path).unwrap();
// TODO: version check
let db = SqliteDb::new(db_path.clone(), Some(options.clone())).unwrap();
let db = SqliteDb::new(db_path.clone(), Some(options.clone()), &secp).unwrap();
db.sanity_check(bitcoin::Network::Bitcoin, &options.main_descriptor)
.unwrap();
let db = SqliteDb::new(db_path.clone(), None).unwrap();
let db = SqliteDb::new(db_path.clone(), None, &secp).unwrap();
db.sanity_check(bitcoin::Network::Bitcoin, &options.main_descriptor)
.unwrap();
@ -274,14 +418,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 +443,120 @@ 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();
}
#[test]
fn sqlite_addresses_cache() {
let (tmp_dir, options, secp, db) = dummy_db();
{
let mut conn = db.connection().unwrap();
// There is the index for the first index
let addr = options
.main_descriptor
.derive(0)
.translate_pk2(|xpk| xpk.derive_public_key(&secp))
.expect("All pubkeys were derived, no wildcard.")
.address(options.bitcoind_network)
.expect("Always a P2WSH address");
let db_addr = conn.db_address(&addr).unwrap();
assert_eq!(db_addr.derivation_index, 0.into());
// There is the index for the 199th index (look-ahead limit)
let addr = options
.main_descriptor
.derive(199)
.translate_pk2(|xpk| xpk.derive_public_key(&secp))
.expect("All pubkeys were derived, no wildcard.")
.address(options.bitcoind_network)
.expect("Always a P2WSH address");
let db_addr = conn.db_address(&addr).unwrap();
assert_eq!(db_addr.derivation_index, 199.into());
// And not for the 200th one.
let addr = options
.main_descriptor
.derive(200)
.translate_pk2(|xpk| xpk.derive_public_key(&secp))
.expect("All pubkeys were derived, no wildcard.")
.address(options.bitcoind_network)
.expect("Always a P2WSH address");
assert!(conn.db_address(&addr).is_none());
// But if we increment the deposit derivation index, the 200th one will be there.
conn.increment_derivation_index(&secp);
let db_addr = conn.db_address(&addr).unwrap();
assert_eq!(db_addr.derivation_index, 200.into());
}
fs::remove_dir_all(&tmp_dir).unwrap();
}
}

View File

@ -26,6 +26,30 @@ 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 mapping from descriptor address to derivation index. Necessary until
* we can get the derivation index from the parent descriptor from bitcoind.
*/
CREATE TABLE addresses (
address TEXT NOT NULL UNIQUE,
derivation_index INTEGER NOT NULL UNIQUE
);
";
/// A row in the "tip" table.
@ -88,3 +112,78 @@ 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,
})
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DbAddress {
pub address: bitcoin::Address,
pub derivation_index: bip32::ChildNumber,
}
impl TryFrom<&rusqlite::Row<'_>> for DbAddress {
type Error = rusqlite::Error;
fn try_from(row: &rusqlite::Row) -> Result<Self, Self::Error> {
let address: String = row.get(0)?;
let address = bitcoin::Address::from_str(&address).expect("We only store valid addresses");
let derivation_index: u32 = row.get(1)?;
let derivation_index = bip32::ChildNumber::from(derivation_index);
assert!(derivation_index.is_normal());
Ok(DbAddress {
address,
derivation_index,
})
}
}

View File

@ -2,6 +2,10 @@ use crate::database::sqlite::{schema::SCHEMA, FreshDbOptions, SqliteDbError, DB_
use std::{convert::TryInto, fs, path, time};
use miniscript::{bitcoin::secp256k1, DescriptorTrait, TranslatePk2};
pub const LOOK_AHEAD_LIMIT: u32 = 200;
/// Perform a set of modifications to the database inside a single transaction
pub fn db_exec<F>(conn: &mut rusqlite::Connection, modifications: F) -> Result<(), rusqlite::Error>
where
@ -12,6 +16,27 @@ where
tx.commit()
}
/// Internal helper for queries boilerplate
pub fn db_tx_query<P, F, T>(
tx: &rusqlite::Transaction,
stmt_str: &str,
params: P,
f: F,
) -> Result<Vec<T>, rusqlite::Error>
where
P: IntoIterator + rusqlite::Params,
P::Item: rusqlite::ToSql,
F: FnMut(&rusqlite::Row<'_>) -> rusqlite::Result<T>,
{
// rustc says 'borrowed value does not live long enough'
let x = tx
.prepare(stmt_str)?
.query_map(params, f)?
.collect::<rusqlite::Result<Vec<T>>>();
x
}
/// Internal helper for queries boilerplate
pub fn db_query<P, F, T>(
conn: &mut rusqlite::Connection,
@ -62,7 +87,11 @@ pub fn create_db_file(db_path: &path::Path) -> Result<(), std::io::Error> {
};
}
pub fn create_fresh_db(db_path: &path::Path, options: FreshDbOptions) -> Result<(), SqliteDbError> {
pub fn create_fresh_db(
db_path: &path::Path,
options: FreshDbOptions,
secp: &secp256k1::Secp256k1<secp256k1::VerifyOnly>,
) -> Result<(), SqliteDbError> {
create_db_file(db_path)?;
let timestamp = time::SystemTime::now()
@ -70,6 +99,24 @@ pub fn create_fresh_db(db_path: &path::Path, options: FreshDbOptions) -> Result<
.map(|dur| timestamp_to_u32(dur.as_secs()))
.expect("System clock went backward the epoch?");
// Fill the initial addresses. On a fresh database, the deposit_derivation_index is
// necessarily 0.
let mut query = String::with_capacity(100 * LOOK_AHEAD_LIMIT as usize);
for index in 0..LOOK_AHEAD_LIMIT {
// TODO: have this as a helper in descriptors.rs
let address = options
.main_descriptor
.derive(index)
.translate_pk2(|xpk| xpk.derive_public_key(secp))
.expect("All pubkeys were derived, no wildcard.")
.address(options.bitcoind_network)
.expect("Always a P2WSH address");
query += &format!(
"INSERT INTO addresses (address, derivation_index) VALUES (\"{}\", {});\n",
address, index
);
}
let mut conn = rusqlite::Connection::open(db_path)?;
db_exec(&mut conn, |tx| {
tx.execute_batch(SCHEMA)?;
@ -86,6 +133,7 @@ pub fn create_fresh_db(db_path: &path::Path, options: FreshDbOptions) -> Result<
VALUES (?1, ?2, ?3)",
rusqlite::params![timestamp, options.main_descriptor.to_string(), 0,],
)?;
tx.execute_batch(&query)?;
Ok(())
})?;

View File

@ -8,6 +8,7 @@ pub fn handle_request(control: &DaemonControl, req: Request) -> Result<Response,
let result = match req.method.as_str() {
"getinfo" => serde_json::json!(&control.get_info()),
"getnewaddress" => serde_json::json!(&control.get_new_address()),
"listcoins" => serde_json::json!(&control.list_coins()),
"stop" => serde_json::json!({}),
_ => {
return Err(Error::method_not_found());

View File

@ -161,6 +161,7 @@ fn setup_sqlite(
config: &Config,
data_dir: &path::Path,
fresh_data_dir: bool,
secp: &secp256k1::Secp256k1<secp256k1::VerifyOnly>,
) -> Result<SqliteDb, StartupError> {
let db_path: path::PathBuf = [data_dir, path::Path::new("minisafed.sqlite3")]
.iter()
@ -173,7 +174,7 @@ fn setup_sqlite(
} else {
None
};
let sqlite = SqliteDb::new(db_path, options)?;
let sqlite = SqliteDb::new(db_path, options, secp)?;
sqlite.sanity_check(config.bitcoin_config.network, &config.main_descriptor)?;
log::info!("Database initialized and checked.");
@ -223,8 +224,8 @@ impl DaemonControl {
config: Config,
bitcoin: sync::Arc<sync::Mutex<dyn BitcoinInterface>>,
db: sync::Arc<sync::Mutex<dyn DatabaseInterface>>,
secp: secp256k1::Secp256k1<secp256k1::VerifyOnly>,
) -> DaemonControl {
let secp = secp256k1::Secp256k1::verification_only();
DaemonControl {
config,
bitcoin,
@ -257,6 +258,8 @@ impl DaemonHandle {
#[cfg(not(test))]
setup_panic_hook();
let secp = secp256k1::Secp256k1::verification_only();
// First, check the data directory
let mut data_dir = config
.data_dir()
@ -275,6 +278,7 @@ impl DaemonHandle {
&config,
&data_dir,
fresh_data_dir,
&secp,
)?)) as sync::Arc<sync::Mutex<dyn DatabaseInterface>>,
};
@ -310,7 +314,7 @@ impl DaemonHandle {
);
// Finally, set up the API.
let control = DaemonControl::new(config, bit, db);
let control = DaemonControl::new(config, bit, db, secp);
Ok(Self {
control,
@ -513,6 +517,18 @@ mod tests {
stream.flush().unwrap();
}
// Send them a response to 'getblockhash' with the genesis block hash
fn complete_tip_init<'a>(server: &net::TcpListener) {
let net_resp = [
"HTTP/1.1 200\n\r\n{\"jsonrpc\":\"2.0\",\"id\":1,\"result\":\"000000000019d6689c085ae165831e934ff763ae46a2a6c172b3f1b60a8ce26f\"}\n".as_bytes(),
]
.concat();
let (mut stream, _) = server.accept().unwrap();
read_til_json_end(&mut stream);
stream.write_all(&net_resp).unwrap();
stream.flush().unwrap();
}
// Send them a response to 'getblockchaininfo' saying we are far from being synced
fn complete_sync_check<'a>(server: &net::TcpListener) {
let net_resp = [
@ -598,6 +614,7 @@ mod tests {
complete_network_check(&server);
complete_wallet_check(&server, &wo_path);
complete_desc_check(&server, desc_str);
complete_tip_init(&server);
complete_sync_check(&server);
daemon_thread.join().unwrap();

View File

@ -1,20 +1,28 @@
use crate::{
bitcoin::{BitcoinInterface, BlockChainTip},
bitcoin::{BitcoinInterface, BlockChainTip, UTxO},
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},
bitcoin::{self, secp256k1, util::bip32},
descriptor,
};
pub struct DummyBitcoind {}
impl BitcoinInterface for DummyBitcoind {
fn genesis_block(&self) -> BlockChainTip {
let hash = bitcoin::BlockHash::from_str(
"000000000019d6689c085ae165831e934ff763ae46a2a6c172b3f1b60a8ce26f",
)
.unwrap();
BlockChainTip { hash, height: 0 }
}
fn sync_progress(&self) -> f64 {
1.0
}
@ -32,11 +40,24 @@ impl BitcoinInterface for DummyBitcoind {
// No reorg
true
}
fn received_coins(&self, _: &BlockChainTip) -> Vec<UTxO> {
Vec::new()
}
fn confirmed_coins(&self, _: &[bitcoin::OutPoint]) -> Vec<(bitcoin::OutPoint, i32)> {
Vec::new()
}
fn spent_coins(&self, _: &[bitcoin::OutPoint]) -> Vec<(bitcoin::OutPoint, bitcoin::Txid)> {
Vec::new()
}
}
pub struct DummyDb {
curr_index: bip32::ChildNumber,
curr_tip: Option<BlockChainTip>,
coins: HashMap<bitcoin::OutPoint, Coin>,
}
impl DummyDb {
@ -44,6 +65,7 @@ impl DummyDb {
DummyDb {
curr_index: 0.into(),
curr_tip: None,
coins: HashMap::new(),
}
}
}
@ -59,6 +81,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
}
@ -71,8 +97,45 @@ impl DatabaseConnection for DummyDbConn {
self.db.read().unwrap().curr_index
}
fn update_derivation_index(&mut self, index: bip32::ChildNumber) {
self.db.write().unwrap().curr_index = index;
fn increment_derivation_index(&mut self, _: &secp256k1::Secp256k1<secp256k1::VerifyOnly>) {
let next_index = self.db.write().unwrap().curr_index.increment().unwrap();
self.db.write().unwrap().curr_index = next_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);
}
}
fn derivation_index_by_address(&mut self, _: &bitcoin::Address) -> Option<bip32::ChildNumber> {
None
}
}

View File

@ -1,4 +1,5 @@
from fixtures import *
from test_framework.utils import wait_for, COIN
def test_getinfo(minisafed):
@ -15,3 +16,27 @@ def test_getaddress(minisafed):
assert "address" in res
# We'll get a new one at every call
assert res["address"] != minisafed.rpc.getnewaddress()["address"]
def test_listcoins(minisafed, bitcoind):
# Initially empty
res = minisafed.rpc.listcoins()
assert "coins" in res
assert len(res["coins"]) == 0
# If we send a coin, we'll get a new entry. Note we monitor for unconfirmed
# funds as well.
addr = minisafed.rpc.getnewaddress()["address"]
txid = bitcoind.rpc.sendtoaddress(addr, 1)
wait_for(lambda: len(minisafed.rpc.listcoins()["coins"]) == 1)
res = minisafed.rpc.listcoins()["coins"]
assert txid == res[0]["outpoint"][:64]
assert res[0]["amount"] == 1 * COIN
assert res[0]["block_height"] is None
# If the coin gets confirmed, it'll be marked as such.
bitcoind.generate_block(1, wait_for_mempool=txid)
block_height = bitcoind.rpc.getblockcount()
wait_for(
lambda: minisafed.rpc.listcoins()["coins"][0]["block_height"] == block_height
)