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:
commit
3d427713e1
20
doc/API.md
20
doc/API.md
@ -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` |
|
||||
|
||||
@ -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",
|
||||
¶ms!(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",
|
||||
¶ms!(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",
|
||||
¶ms!(
|
||||
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",
|
||||
¶ms!(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",
|
||||
¶ms!(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", ¶ms!(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",
|
||||
¶ms!(
|
||||
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 }
|
||||
}
|
||||
}
|
||||
|
||||
@ -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,
|
||||
}
|
||||
|
||||
@ -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(¤t_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, ¤t_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, ¤t_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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
16
src/commands/utils.rs
Normal 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))
|
||||
}
|
||||
@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@ -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,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@ -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(())
|
||||
})?;
|
||||
|
||||
@ -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());
|
||||
|
||||
23
src/lib.rs
23
src/lib.rs
@ -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();
|
||||
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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
|
||||
)
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user