Merge #63: Chain reorganization handling
e75637d3629df901b1aa453b131c1c780bfd8724 jsonrpc: fixup two typos in error messages (Antoine Poinsot) a9b0e5e559b0ddddbd8e612771a95182f131e0f0 qa: functional tests for block chain reorganization (Antoine Poinsot) e88bbbe65b34cf7af645fc3f07a90266d4a66792 poller: block chain reorganization handling (Antoine Poinsot) d6f24e1c6a20d961128aa9c95367bd62aa8cb229 bitcoind: don't return spent coins with unfetchable spending tx as spent (Antoine Poinsot) 99ab0d7add5f9f3f924a6bd61263135c09e2a4cc commands: add a 'spend_info' field to the 'listcoins' entries (Antoine Poinsot) 57add1d86bb734b0f5291b2f266ef5652c93bc8d commands: return the DB's block height in 'getinfo' (Antoine Poinsot) 92f7ef12251309a30d100108ad82cd6790b5f4ac commands: make listcoins return all coins by default (Antoine Poinsot) e9e4acd69de55e0c32c7e275e3b5318b5e161c46 db: database interface to rollback to a previous best block (Antoine Poinsot) 972c8dac86976723f4c417142999d4850c2fb9b6 db: require the spend block height from the DB interface (Antoine Poinsot) 6038843d33621c098d958d343c8b6d038922f72f database: rename coins' spent_at in spend_block_time (Antoine Poinsot) cce227f80fbcbb32c5fca6fe78142131d7f35a53 bitcoin: interface to get the common block in our and the backend's chains (Antoine Poinsot) Pull request description: This finally implements our reorganization handling. Like in revaultd, upon noticing a tip change that indicates a reorg happened in our Bitcoin backend we rollback our state to the common ancestor between our state and the new chain, then start rescanning from there. The logic is much more straightforward than in revaultd though, as there is no presigned transactions to care about. The PR grew a bit large as this needed a bit of preparatory work in order to be reasonably tested (and i noticed a few bugs and cleanups that slipped through review in #29). Please let me know if reviewers prefer that i split the prep work on the commands in another PR. Fixes #15. ACKs for top commit: darosior: ACK e75637d3629df901b1aa453b131c1c780bfd8724 Tree-SHA512: 1ebb2a3e10b462b739e1d5cb831de946177436c8fad4dcb20eb575fd0f58bef98a86e25c5fe0ed07d946975f982a420940607a69e74f24a02ef16271c92eceba
This commit is contained in:
commit
9bb20303e7
@ -16,6 +16,9 @@ task:
|
||||
- name: 'RPC functional tests'
|
||||
env:
|
||||
TEST_GROUP: tests/test_rpc.py
|
||||
- name: 'Chain functional tests'
|
||||
env:
|
||||
TEST_GROUP: tests/test_chain.py
|
||||
|
||||
cargo_registry_cache:
|
||||
folders: $CARGO_HOME/registry
|
||||
|
||||
23
doc/API.md
23
doc/API.md
@ -45,7 +45,7 @@ This command does not take any parameter for now.
|
||||
| -------------------- | ------- | -------------------------------------------------------------------------------------------- |
|
||||
| `version` | string | Version following the [SimVer](http://www.simver.org/) format |
|
||||
| `network` | string | Answer can be `mainnet`, `testnet`, `regtest` |
|
||||
| `blockheight` | integer | Current block height |
|
||||
| `blockheight` | integer | The block height we are synced at. |
|
||||
| `sync` | float | The synchronization progress as percentage (`0 < sync < 1`) |
|
||||
| `descriptors` | object | Object with the name of the descriptor as key and the descriptor string as value |
|
||||
|
||||
@ -70,7 +70,7 @@ This command does not take any parameter for now.
|
||||
|
||||
### `listcoins`
|
||||
|
||||
List our current Unspent Transaction Outputs.
|
||||
List all our transaction outputs, regardless of their state (unspent or not).
|
||||
|
||||
#### Request
|
||||
|
||||
@ -81,11 +81,20 @@ This command does not take any parameter for now.
|
||||
|
||||
#### 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` |
|
||||
| Field | Type | Description |
|
||||
| -------------- | ------------- | ------------------------------------------------------------------------------------------------------------------ |
|
||||
| `amount` | int | Value of the TxO 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`. |
|
||||
| `spend_info` | object | Information about the transaction spending this coin. See [Spending transaction info](#spending_transaction_info). |
|
||||
|
||||
|
||||
##### Spending transaction info
|
||||
|
||||
| Field | Type | Description |
|
||||
| ---------- | ----------- | -------------------------------------------------------------- |
|
||||
| `txid` | str | Spending transaction's id. |
|
||||
| `height` | int or null | Block height the spending tx was included at, if confirmed. |
|
||||
|
||||
|
||||
### `createspend`
|
||||
|
||||
@ -651,6 +651,34 @@ impl BitcoinD {
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
pub fn get_block_stats(&self, blockhash: bitcoin::BlockHash) -> BlockStats {
|
||||
let res = self.make_node_request(
|
||||
"getblockheader",
|
||||
¶ms!(Json::String(blockhash.to_string()),),
|
||||
);
|
||||
let confirmations = res
|
||||
.get("confirmations")
|
||||
.and_then(Json::as_i64)
|
||||
.expect("Invalid confirmations in `getblockheader` response: not an i64")
|
||||
as i32;
|
||||
let previous_blockhash = res
|
||||
.get("previousblockhash")
|
||||
.and_then(Json::as_str)
|
||||
.and_then(|s| bitcoin::BlockHash::from_str(s).ok())
|
||||
.expect("Invalid previousblockhash in `getblockheader` response");
|
||||
let height = res
|
||||
.get("height")
|
||||
.and_then(Json::as_i64)
|
||||
.expect("Invalid height in `getblockheader` response: not an u32")
|
||||
as i32;
|
||||
BlockStats {
|
||||
confirmations,
|
||||
previous_blockhash,
|
||||
height,
|
||||
blockhash,
|
||||
}
|
||||
}
|
||||
}
|
||||
// Bitcoind uses a guess for the value of verificationprogress. It will eventually get to
|
||||
// be 1, and we want to be less conservative.
|
||||
@ -779,3 +807,11 @@ impl From<Json> for GetTxRes {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct BlockStats {
|
||||
pub confirmations: i32,
|
||||
pub previous_blockhash: bitcoin::BlockHash,
|
||||
pub blockhash: bitcoin::BlockHash,
|
||||
pub height: i32,
|
||||
}
|
||||
|
||||
@ -6,10 +6,9 @@ pub mod poller;
|
||||
|
||||
use d::LSBlockEntry;
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync;
|
||||
use std::{collections::HashMap, fmt, sync};
|
||||
|
||||
use miniscript::bitcoin::{self, hashes::Hash};
|
||||
use miniscript::bitcoin;
|
||||
|
||||
/// Information about the best block in the chain
|
||||
#[derive(Debug, Clone, Eq, PartialEq, Copy)]
|
||||
@ -18,6 +17,12 @@ pub struct BlockChainTip {
|
||||
pub height: i32,
|
||||
}
|
||||
|
||||
impl fmt::Display for BlockChainTip {
|
||||
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
|
||||
write!(f, "({},{})", self.height, self.hash)
|
||||
}
|
||||
}
|
||||
|
||||
/// Our Bitcoin backend.
|
||||
pub trait BitcoinInterface: Send {
|
||||
fn genesis_block(&self) -> BlockChainTip;
|
||||
@ -51,7 +56,10 @@ pub trait BitcoinInterface: Send {
|
||||
fn spent_coins(
|
||||
&self,
|
||||
outpoints: &[(bitcoin::OutPoint, bitcoin::Txid)],
|
||||
) -> Vec<(bitcoin::OutPoint, bitcoin::Txid, u32)>;
|
||||
) -> Vec<(bitcoin::OutPoint, bitcoin::Txid, i32, u32)>;
|
||||
|
||||
/// Get the common ancestor between the Bitcoin backend's tip and the given tip.
|
||||
fn common_ancestor(&self, tip: &BlockChainTip) -> BlockChainTip;
|
||||
}
|
||||
|
||||
impl BitcoinInterface for d::BitcoinD {
|
||||
@ -136,10 +144,10 @@ impl BitcoinInterface for d::BitcoinD {
|
||||
} else {
|
||||
// TODO: better handling of this edge case.
|
||||
log::error!(
|
||||
"Could not get spender of '{}'. Using a dummy spending txid.",
|
||||
"Could not get spender of '{}'. Not reporting it as spending.",
|
||||
op
|
||||
);
|
||||
bitcoin::Txid::from_slice(&[0; 32][..]).unwrap()
|
||||
continue;
|
||||
};
|
||||
|
||||
spent.push((*op, spending_txid));
|
||||
@ -152,7 +160,7 @@ impl BitcoinInterface for d::BitcoinD {
|
||||
fn spent_coins(
|
||||
&self,
|
||||
outpoints: &[(bitcoin::OutPoint, bitcoin::Txid)],
|
||||
) -> Vec<(bitcoin::OutPoint, bitcoin::Txid, u32)> {
|
||||
) -> Vec<(bitcoin::OutPoint, bitcoin::Txid, i32, u32)> {
|
||||
let mut spent = Vec::with_capacity(outpoints.len());
|
||||
|
||||
let mut cache: HashMap<bitcoin::Txid, Option<d::GetTxRes>> = HashMap::new();
|
||||
@ -171,10 +179,15 @@ impl BitcoinInterface for d::BitcoinD {
|
||||
let mut txs_to_cache: Vec<(bitcoin::Txid, Option<d::GetTxRes>)> = Vec::new();
|
||||
|
||||
if let Some(tx) = tx {
|
||||
if let Some(block_time) = tx.block_time {
|
||||
if let Some(block_height) = tx.block_height {
|
||||
// TODO: make both block time and height under the same Option.
|
||||
assert!(tx.block_height.is_some());
|
||||
spent.push((*op, *txid, block_time))
|
||||
spent.push((
|
||||
*op,
|
||||
*txid,
|
||||
block_height,
|
||||
tx.block_time.expect("Confirmed tx."),
|
||||
));
|
||||
} else if !tx.conflicting_txs.is_empty() {
|
||||
for txid in &tx.conflicting_txs {
|
||||
let tx: Option<&d::GetTxRes> = match cache.get(txid) {
|
||||
@ -187,13 +200,12 @@ impl BitcoinInterface for d::BitcoinD {
|
||||
};
|
||||
if let Some(tx) = tx {
|
||||
if let Some(block_height) = tx.block_height {
|
||||
if block_height > 1 {
|
||||
spent.push((
|
||||
*op,
|
||||
*txid,
|
||||
tx.block_time.expect("Spend is confirmed"),
|
||||
))
|
||||
}
|
||||
spent.push((
|
||||
*op,
|
||||
*txid,
|
||||
block_height,
|
||||
tx.block_time.expect("Spend is confirmed"),
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -207,6 +219,21 @@ impl BitcoinInterface for d::BitcoinD {
|
||||
|
||||
spent
|
||||
}
|
||||
|
||||
fn common_ancestor(&self, tip: &BlockChainTip) -> BlockChainTip {
|
||||
let mut stats = self.get_block_stats(tip.hash);
|
||||
let mut ancestor = *tip;
|
||||
|
||||
while stats.confirmations == -1 {
|
||||
stats = self.get_block_stats(stats.previous_blockhash);
|
||||
ancestor = BlockChainTip {
|
||||
hash: stats.blockhash,
|
||||
height: stats.height,
|
||||
};
|
||||
}
|
||||
|
||||
ancestor
|
||||
}
|
||||
}
|
||||
|
||||
// FIXME: do we need to repeat the entire trait implemenation? Isn't there a nicer way?
|
||||
@ -248,9 +275,13 @@ impl BitcoinInterface for sync::Arc<sync::Mutex<dyn BitcoinInterface + 'static>>
|
||||
fn spent_coins(
|
||||
&self,
|
||||
outpoints: &[(bitcoin::OutPoint, bitcoin::Txid)],
|
||||
) -> Vec<(bitcoin::OutPoint, bitcoin::Txid, u32)> {
|
||||
) -> Vec<(bitcoin::OutPoint, bitcoin::Txid, i32, u32)> {
|
||||
self.lock().unwrap().spent_coins(outpoints)
|
||||
}
|
||||
|
||||
fn common_ancestor(&self, tip: &BlockChainTip) -> BlockChainTip {
|
||||
self.lock().unwrap().common_ancestor(tip)
|
||||
}
|
||||
}
|
||||
|
||||
// FIXME: We could avoid this type (and all the conversions entailing allocations) if bitcoind
|
||||
|
||||
@ -15,7 +15,7 @@ struct UpdatedCoins {
|
||||
pub received: Vec<Coin>,
|
||||
pub confirmed: Vec<(bitcoin::OutPoint, i32, u32)>,
|
||||
pub spending: Vec<(bitcoin::OutPoint, bitcoin::Txid)>,
|
||||
pub spent: Vec<(bitcoin::OutPoint, bitcoin::Txid, u32)>,
|
||||
pub spent: Vec<(bitcoin::OutPoint, bitcoin::Txid, i32, u32)>,
|
||||
}
|
||||
|
||||
// Update the state of our coins. There may be new unspent, and existing ones may become confirmed
|
||||
@ -27,8 +27,10 @@ fn update_coins(
|
||||
db_conn: &mut Box<dyn DatabaseConnection>,
|
||||
previous_tip: &BlockChainTip,
|
||||
) -> UpdatedCoins {
|
||||
let curr_coins = db_conn.coins();
|
||||
log::debug!("Current coins: {:?}", curr_coins);
|
||||
|
||||
// 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) {
|
||||
@ -43,7 +45,7 @@ fn update_coins(
|
||||
block_height: None,
|
||||
block_time: None,
|
||||
spend_txid: None,
|
||||
spent_at: None,
|
||||
spend_block: None,
|
||||
};
|
||||
received.push(coin);
|
||||
}
|
||||
@ -55,6 +57,7 @@ fn update_coins(
|
||||
);
|
||||
}
|
||||
}
|
||||
log::debug!("Newly received coins: {:?}", received);
|
||||
|
||||
// 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
|
||||
@ -71,6 +74,7 @@ fn update_coins(
|
||||
})
|
||||
.collect();
|
||||
let confirmed = bit.confirmed_coins(&to_be_confirmed);
|
||||
log::debug!("Newly confirmed coins: {:?}", 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
|
||||
@ -87,6 +91,7 @@ fn update_coins(
|
||||
})
|
||||
.collect();
|
||||
let spending = bit.spending_coins(&to_be_spent);
|
||||
log::debug!("Newly spending coins: {:?}", spending);
|
||||
|
||||
// Mark coins in a spending state whose Spend transaction was confirmed as such. Note we
|
||||
// need to take into account the freshly marked as spending coins as well, as their spend
|
||||
@ -99,6 +104,7 @@ fn update_coins(
|
||||
.chain(spending.iter().cloned())
|
||||
.collect();
|
||||
let spent = bit.spent_coins(spending_coins.as_slice());
|
||||
log::debug!("Newly spent coins: {:?}", spent);
|
||||
|
||||
UpdatedCoins {
|
||||
received,
|
||||
@ -108,25 +114,44 @@ fn update_coins(
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
enum TipUpdate {
|
||||
// The best block is still the same as in the previous poll.
|
||||
Same,
|
||||
// There is a new best block that extends the same chain.
|
||||
Progress(BlockChainTip),
|
||||
// There is a new best block that extends a chain which does not contain our former tip.
|
||||
Reorged(BlockChainTip),
|
||||
}
|
||||
|
||||
// Returns the new block chain tip, if it changed.
|
||||
fn new_tip(bit: &impl BitcoinInterface, current_tip: &BlockChainTip) -> Option<BlockChainTip> {
|
||||
fn new_tip(bit: &impl BitcoinInterface, current_tip: &BlockChainTip) -> TipUpdate {
|
||||
let bitcoin_tip = bit.chain_tip();
|
||||
|
||||
// If the tip didn't change, there is nothing to update.
|
||||
if current_tip == &bitcoin_tip {
|
||||
return None;
|
||||
return TipUpdate::Same;
|
||||
}
|
||||
|
||||
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.
|
||||
return Some(bitcoin_tip);
|
||||
return TipUpdate::Progress(bitcoin_tip);
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: reorg handling.
|
||||
None
|
||||
// Either the new height is lower or the same but the block hash differs. There was a
|
||||
// block chain re-organisation. Find the common ancestor between our current chain and
|
||||
// the new chain and return that. The caller will take care of rewinding our state.
|
||||
log::info!("Block chain reorganization detected. Looking for common ancestor.");
|
||||
let common_ancestor = bit.common_ancestor(current_tip);
|
||||
log::info!(
|
||||
"Common ancestor found: '{}'. Starting rescan from there. Old tip was '{}'.",
|
||||
common_ancestor,
|
||||
current_tip
|
||||
);
|
||||
TipUpdate::Reorged(common_ancestor)
|
||||
}
|
||||
|
||||
fn updates(bit: &impl BitcoinInterface, db: &impl DatabaseInterface) {
|
||||
@ -134,8 +159,17 @@ fn updates(bit: &impl BitcoinInterface, db: &impl DatabaseInterface) {
|
||||
|
||||
// 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);
|
||||
let latest_tip = match new_tip(bit, ¤t_tip) {
|
||||
TipUpdate::Same => current_tip,
|
||||
TipUpdate::Progress(new_tip) => new_tip,
|
||||
TipUpdate::Reorged(new_tip) => {
|
||||
// The block chain was reorganized. Rollback our state down to the common ancestor
|
||||
// between our former chain and the new one, then restart fresh.
|
||||
db_conn.rollback_tip(&new_tip);
|
||||
log::info!("Tip was rolled back to '{}'.", new_tip);
|
||||
return updates(bit, db);
|
||||
}
|
||||
};
|
||||
|
||||
// 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.
|
||||
@ -154,9 +188,12 @@ fn updates(bit: &impl BitcoinInterface, db: &impl DatabaseInterface) {
|
||||
db_conn.confirm_coins(&updated_coins.confirmed);
|
||||
db_conn.spend_coins(&updated_coins.spending);
|
||||
db_conn.confirm_spend(&updated_coins.spent);
|
||||
if let Some(tip) = new_tip {
|
||||
db_conn.update_tip(&tip);
|
||||
if latest_tip != current_tip {
|
||||
db_conn.update_tip(&latest_tip);
|
||||
log::debug!("New tip: '{}'", latest_tip);
|
||||
}
|
||||
|
||||
log::debug!("Updates done.");
|
||||
}
|
||||
|
||||
// If the database chain tip is NULL (first startup), initialize it.
|
||||
|
||||
@ -167,10 +167,13 @@ impl DaemonControl {
|
||||
impl DaemonControl {
|
||||
/// Get information about the current state of the daemon
|
||||
pub fn get_info(&self) -> GetInfoResult {
|
||||
let mut db_conn = self.db.connection();
|
||||
|
||||
let blockheight = db_conn.chain_tip().map(|tip| tip.height).unwrap_or(0);
|
||||
GetInfoResult {
|
||||
version: VERSION.to_string(),
|
||||
network: self.config.bitcoin_config.network,
|
||||
blockheight: self.bitcoin.chain_tip().height,
|
||||
blockheight,
|
||||
sync: self.bitcoin.sync_progress(),
|
||||
descriptors: GetInfoDescriptors {
|
||||
main: self.config.main_descriptor.clone(),
|
||||
@ -193,11 +196,11 @@ impl DaemonControl {
|
||||
GetAddressResult { address }
|
||||
}
|
||||
|
||||
/// Get a list of all currently unspent coins.
|
||||
/// Get a list of all known coins.
|
||||
pub fn list_coins(&self) -> ListCoinsResult {
|
||||
let mut db_conn = self.db.connection();
|
||||
let coins: Vec<ListCoinsEntry> = db_conn
|
||||
.unspent_coins()
|
||||
.coins()
|
||||
// Can't use into_values as of Rust 1.48
|
||||
.into_iter()
|
||||
.map(|(_, coin)| {
|
||||
@ -205,12 +208,19 @@ impl DaemonControl {
|
||||
amount,
|
||||
outpoint,
|
||||
block_height,
|
||||
spend_txid,
|
||||
spend_block,
|
||||
..
|
||||
} = coin;
|
||||
let spend_info = spend_txid.map(|txid| LCSpendInfo {
|
||||
txid,
|
||||
height: spend_block.map(|b| b.height),
|
||||
});
|
||||
ListCoinsEntry {
|
||||
amount,
|
||||
outpoint,
|
||||
block_height,
|
||||
spend_info,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
@ -458,7 +468,14 @@ pub struct GetAddressResult {
|
||||
pub address: bitcoin::Address,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
|
||||
pub struct LCSpendInfo {
|
||||
pub txid: bitcoin::Txid,
|
||||
/// The block height this spending transaction was confirmed at.
|
||||
pub height: Option<i32>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
|
||||
pub struct ListCoinsEntry {
|
||||
#[serde(
|
||||
serialize_with = "ser_amount",
|
||||
@ -467,6 +484,8 @@ pub struct ListCoinsEntry {
|
||||
pub amount: bitcoin::Amount,
|
||||
pub outpoint: bitcoin::OutPoint,
|
||||
pub block_height: Option<i32>,
|
||||
/// Information about the transaction spending this coin.
|
||||
pub spend_info: Option<LCSpendInfo>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
@ -572,7 +591,7 @@ mod tests {
|
||||
amount: bitcoin::Amount::from_sat(100_000),
|
||||
derivation_index: bip32::ChildNumber::from(13),
|
||||
spend_txid: None,
|
||||
spent_at: None,
|
||||
spend_block: None,
|
||||
}]);
|
||||
let res = control.create_spend(&[dummy_op], &destinations, 1).unwrap();
|
||||
let tx = res.psbt.global.unsigned_tx;
|
||||
@ -666,7 +685,7 @@ mod tests {
|
||||
amount: bitcoin::Amount::from_sat(100_000),
|
||||
derivation_index: bip32::ChildNumber::from(13),
|
||||
spend_txid: None,
|
||||
spent_at: None,
|
||||
spend_block: None,
|
||||
},
|
||||
Coin {
|
||||
outpoint: dummy_op_b,
|
||||
@ -675,7 +694,7 @@ mod tests {
|
||||
amount: bitcoin::Amount::from_sat(115_680),
|
||||
derivation_index: bip32::ChildNumber::from(34),
|
||||
spend_txid: None,
|
||||
spent_at: None,
|
||||
spend_block: None,
|
||||
},
|
||||
]);
|
||||
|
||||
|
||||
@ -6,7 +6,7 @@ pub mod sqlite;
|
||||
use crate::{
|
||||
bitcoin::BlockChainTip,
|
||||
database::sqlite::{
|
||||
schema::{DbCoin, DbTip},
|
||||
schema::{DbCoin, DbSpendBlock, DbTip},
|
||||
SqliteConn, SqliteDb,
|
||||
},
|
||||
};
|
||||
@ -54,8 +54,8 @@ pub trait DatabaseConnection {
|
||||
address: &bitcoin::Address,
|
||||
) -> Option<bip32::ChildNumber>;
|
||||
|
||||
/// Get all UTxOs.
|
||||
fn unspent_coins(&mut self) -> HashMap<bitcoin::OutPoint, Coin>;
|
||||
/// Get all our coins, past or present, spent or not.
|
||||
fn coins(&mut self) -> HashMap<bitcoin::OutPoint, Coin>;
|
||||
|
||||
/// List coins that are being spent and whose spending transaction is still unconfirmed.
|
||||
fn list_spending_coins(&mut self) -> HashMap<bitcoin::OutPoint, Coin>;
|
||||
@ -70,7 +70,7 @@ pub trait DatabaseConnection {
|
||||
fn spend_coins(&mut self, outpoints: &[(bitcoin::OutPoint, bitcoin::Txid)]);
|
||||
|
||||
/// Mark a set of coins as spent by a specified txid at a specified block time.
|
||||
fn confirm_spend(&mut self, outpoints: &[(bitcoin::OutPoint, bitcoin::Txid, u32)]);
|
||||
fn confirm_spend(&mut self, outpoints: &[(bitcoin::OutPoint, bitcoin::Txid, i32, u32)]);
|
||||
|
||||
/// Get specific coins from the database.
|
||||
fn coins_by_outpoints(
|
||||
@ -88,6 +88,9 @@ pub trait DatabaseConnection {
|
||||
|
||||
/// Delete a Spend transaction from database.
|
||||
fn delete_spend(&mut self, txid: &bitcoin::Txid);
|
||||
|
||||
/// Mark the given tip as the new best seen block. Update stored data accordingly.
|
||||
fn rollback_tip(&mut self, new_tip: &BlockChainTip);
|
||||
}
|
||||
|
||||
impl DatabaseConnection for SqliteConn {
|
||||
@ -118,8 +121,8 @@ impl DatabaseConnection for SqliteConn {
|
||||
self.increment_derivation_index(secp)
|
||||
}
|
||||
|
||||
fn unspent_coins(&mut self) -> HashMap<bitcoin::OutPoint, Coin> {
|
||||
self.unspent_coins()
|
||||
fn coins(&mut self) -> HashMap<bitcoin::OutPoint, Coin> {
|
||||
self.coins()
|
||||
.into_iter()
|
||||
.map(|db_coin| (db_coin.outpoint, db_coin.into()))
|
||||
.collect()
|
||||
@ -144,7 +147,7 @@ impl DatabaseConnection for SqliteConn {
|
||||
self.spend_coins(outpoints)
|
||||
}
|
||||
|
||||
fn confirm_spend<'a>(&mut self, outpoints: &[(bitcoin::OutPoint, bitcoin::Txid, u32)]) {
|
||||
fn confirm_spend<'a>(&mut self, outpoints: &[(bitcoin::OutPoint, bitcoin::Txid, i32, u32)]) {
|
||||
self.confirm_spend(outpoints)
|
||||
}
|
||||
|
||||
@ -184,9 +187,28 @@ impl DatabaseConnection for SqliteConn {
|
||||
fn delete_spend(&mut self, txid: &bitcoin::Txid) {
|
||||
self.delete_spend(txid)
|
||||
}
|
||||
|
||||
fn rollback_tip(&mut self, new_tip: &BlockChainTip) {
|
||||
self.rollback_tip(new_tip)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub struct SpendBlock {
|
||||
pub height: i32,
|
||||
pub time: u32,
|
||||
}
|
||||
|
||||
impl From<DbSpendBlock> for SpendBlock {
|
||||
fn from(b: DbSpendBlock) -> SpendBlock {
|
||||
SpendBlock {
|
||||
height: b.height,
|
||||
time: b.time,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub struct Coin {
|
||||
pub outpoint: bitcoin::OutPoint,
|
||||
pub block_height: Option<i32>,
|
||||
@ -194,7 +216,7 @@ pub struct Coin {
|
||||
pub amount: bitcoin::Amount,
|
||||
pub derivation_index: bip32::ChildNumber,
|
||||
pub spend_txid: Option<bitcoin::Txid>,
|
||||
pub spent_at: Option<u32>,
|
||||
pub spend_block: Option<SpendBlock>,
|
||||
}
|
||||
|
||||
impl std::convert::From<DbCoin> for Coin {
|
||||
@ -206,7 +228,7 @@ impl std::convert::From<DbCoin> for Coin {
|
||||
amount,
|
||||
derivation_index,
|
||||
spend_txid,
|
||||
spent_at,
|
||||
spend_block,
|
||||
..
|
||||
} = db_coin;
|
||||
Coin {
|
||||
@ -216,7 +238,7 @@ impl std::convert::From<DbCoin> for Coin {
|
||||
amount,
|
||||
derivation_index,
|
||||
spend_txid,
|
||||
spent_at,
|
||||
spend_block: spend_block.map(SpendBlock::from),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -252,11 +252,11 @@ impl SqliteConn {
|
||||
.expect("Database must be available")
|
||||
}
|
||||
|
||||
/// Get all UTxOs.
|
||||
pub fn unspent_coins(&mut self) -> Vec<DbCoin> {
|
||||
/// Get all the coins from DB.
|
||||
pub fn coins(&mut self) -> Vec<DbCoin> {
|
||||
db_query(
|
||||
&mut self.conn,
|
||||
"SELECT * FROM coins WHERE spend_txid is NULL",
|
||||
"SELECT * FROM coins",
|
||||
rusqlite::params![],
|
||||
|row| row.try_into(),
|
||||
)
|
||||
@ -267,7 +267,7 @@ impl SqliteConn {
|
||||
pub fn list_spending_coins(&mut self) -> Vec<DbCoin> {
|
||||
db_query(
|
||||
&mut self.conn,
|
||||
"SELECT * FROM coins WHERE spend_txid IS NOT NULL AND spent_at IS NULL",
|
||||
"SELECT * FROM coins WHERE spend_txid IS NOT NULL AND spend_block_time IS NULL",
|
||||
rusqlite::params![],
|
||||
|row| row.try_into(),
|
||||
)
|
||||
@ -334,17 +334,19 @@ impl SqliteConn {
|
||||
.expect("Database must be available")
|
||||
}
|
||||
|
||||
/// Mark a set of coins as spent.
|
||||
/// Mark the Spend transaction of a given set of coins as being confirmed at a given
|
||||
/// block.
|
||||
pub fn confirm_spend<'a>(
|
||||
&mut self,
|
||||
outpoints: impl IntoIterator<Item = &'a (bitcoin::OutPoint, bitcoin::Txid, u32)>,
|
||||
outpoints: impl IntoIterator<Item = &'a (bitcoin::OutPoint, bitcoin::Txid, i32, u32)>,
|
||||
) {
|
||||
db_exec(&mut self.conn, |db_tx| {
|
||||
for (outpoint, spend_txid, time) in outpoints {
|
||||
for (outpoint, spend_txid, height, time) in outpoints {
|
||||
db_tx.execute(
|
||||
"UPDATE coins SET spend_txid = ?1, spent_at = ?2 WHERE txid = ?3 AND vout = ?4",
|
||||
"UPDATE coins SET spend_txid = ?1, spend_block_height = ?2, spend_block_time = ?3 WHERE txid = ?4 AND vout = ?5",
|
||||
rusqlite::params![
|
||||
spend_txid.to_vec(),
|
||||
height,
|
||||
time,
|
||||
outpoint.txid.to_vec(),
|
||||
outpoint.vout,
|
||||
@ -437,13 +439,47 @@ impl SqliteConn {
|
||||
})
|
||||
.expect("Db must not fail");
|
||||
}
|
||||
|
||||
/// Unconfirm all data that was marked as being confirmed *after* the given chain
|
||||
/// tip, and set it as our new best block seen.
|
||||
///
|
||||
/// This includes:
|
||||
/// - Coins
|
||||
/// - Spending transactions confirmation
|
||||
/// - Tip
|
||||
///
|
||||
/// This will have to be updated if we are to add new fields based on block data
|
||||
/// in the database eventually.
|
||||
pub fn rollback_tip(&mut self, new_tip: &BlockChainTip) {
|
||||
db_exec(&mut self.conn, |db_tx| {
|
||||
db_tx.execute(
|
||||
"UPDATE coins SET blockheight = NULL, blocktime = NULL, spend_block_height = NULL, spend_block_time = NULL WHERE blockheight > ?1",
|
||||
rusqlite::params![new_tip.height],
|
||||
)?;
|
||||
db_tx.execute(
|
||||
"UPDATE coins SET spend_block_height = NULL, spend_block_time = NULL WHERE spend_block_height > ?1",
|
||||
rusqlite::params![new_tip.height],
|
||||
)?;
|
||||
db_tx.execute(
|
||||
"UPDATE tip SET blockheight = (?1), blockhash = (?2)",
|
||||
rusqlite::params![new_tip.height, new_tip.hash.to_vec()],
|
||||
)?;
|
||||
Ok(())
|
||||
})
|
||||
.expect("Db must not fail");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::database::SpendBlock;
|
||||
use crate::testutils::*;
|
||||
use std::{collections::HashSet, fs, path, str::FromStr};
|
||||
use std::{
|
||||
collections::{HashMap, HashSet},
|
||||
fs, path,
|
||||
str::FromStr,
|
||||
};
|
||||
|
||||
use bitcoin::{hashes::Hash, util::bip32};
|
||||
|
||||
@ -553,7 +589,7 @@ mod tests {
|
||||
let mut conn = db.connection().unwrap();
|
||||
|
||||
// Necessarily empty at first.
|
||||
assert!(conn.unspent_coins().is_empty());
|
||||
assert!(conn.coins().is_empty());
|
||||
|
||||
// Add one, we'll get it.
|
||||
let coin_a = Coin {
|
||||
@ -566,10 +602,10 @@ mod tests {
|
||||
amount: bitcoin::Amount::from_sat(98765),
|
||||
derivation_index: bip32::ChildNumber::from_normal_idx(10).unwrap(),
|
||||
spend_txid: None,
|
||||
spent_at: None,
|
||||
spend_block: 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);
|
||||
conn.new_unspent_coins(&[coin_a]);
|
||||
assert_eq!(conn.coins()[0].outpoint, coin_a.outpoint);
|
||||
|
||||
// We can query it by its outpoint
|
||||
let coins = conn.db_coins(&[coin_a.outpoint]);
|
||||
@ -587,14 +623,11 @@ mod tests {
|
||||
amount: bitcoin::Amount::from_sat(1111),
|
||||
derivation_index: bip32::ChildNumber::from_normal_idx(103).unwrap(),
|
||||
spend_txid: None,
|
||||
spent_at: None,
|
||||
spend_block: None,
|
||||
};
|
||||
conn.new_unspent_coins(&[coin_b.clone()]);
|
||||
let outpoints: HashSet<bitcoin::OutPoint> = conn
|
||||
.unspent_coins()
|
||||
.into_iter()
|
||||
.map(|c| c.outpoint)
|
||||
.collect();
|
||||
conn.new_unspent_coins(&[coin_b]);
|
||||
let outpoints: HashSet<bitcoin::OutPoint> =
|
||||
conn.coins().into_iter().map(|c| c.outpoint).collect();
|
||||
assert!(outpoints.contains(&coin_a.outpoint));
|
||||
assert!(outpoints.contains(&coin_b.outpoint));
|
||||
|
||||
@ -614,24 +647,24 @@ mod tests {
|
||||
let height = 174500;
|
||||
let time = 174500;
|
||||
conn.confirm_coins(&[(coin_a.outpoint, height, time)]);
|
||||
let coins = conn.unspent_coins();
|
||||
let coins = conn.coins();
|
||||
assert_eq!(coins[0].block_height, Some(height));
|
||||
assert_eq!(coins[0].block_time, Some(time));
|
||||
assert!(coins[1].block_height.is_none());
|
||||
assert!(coins[1].block_time.is_none());
|
||||
|
||||
// Now if we spend one, we'll only get the other one.
|
||||
// Now if we spend one, it'll be marked as such.
|
||||
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));
|
||||
let coins_map: HashMap<bitcoin::OutPoint, DbCoin> =
|
||||
conn.coins().into_iter().map(|c| (c.outpoint, c)).collect();
|
||||
assert!(coins_map
|
||||
.get(&coin_a.outpoint)
|
||||
.unwrap()
|
||||
.spend_txid
|
||||
.is_some());
|
||||
|
||||
let outpoints: HashSet<bitcoin::OutPoint> = conn
|
||||
.list_spending_coins()
|
||||
@ -641,10 +674,13 @@ mod tests {
|
||||
assert!(outpoints.contains(&coin_a.outpoint));
|
||||
|
||||
// Now if we confirm the spend.
|
||||
let height = 128_097;
|
||||
let time = 3_000_000;
|
||||
conn.confirm_spend(&[(
|
||||
coin_a.outpoint,
|
||||
bitcoin::Txid::from_slice(&[0; 32][..]).unwrap(),
|
||||
3,
|
||||
height,
|
||||
time,
|
||||
)]);
|
||||
// the coin is not in a spending state.
|
||||
let outpoints: HashSet<bitcoin::OutPoint> = conn
|
||||
@ -657,6 +693,12 @@ mod tests {
|
||||
// Both are still in DB
|
||||
let coins = conn.db_coins(&[coin_a.outpoint, coin_b.outpoint]);
|
||||
assert_eq!(coins.len(), 2);
|
||||
|
||||
// The confirmed one contains the right time and block height
|
||||
let coin = conn.db_coins(&[coin_a.outpoint]).pop().unwrap();
|
||||
assert!(coin.spend_block.is_some());
|
||||
assert_eq!(coin.spend_block.as_ref().unwrap().time, time);
|
||||
assert_eq!(coin.spend_block.unwrap().height, height);
|
||||
}
|
||||
|
||||
fs::remove_dir_all(&tmp_dir).unwrap();
|
||||
@ -700,4 +742,187 @@ mod tests {
|
||||
|
||||
fs::remove_dir_all(&tmp_dir).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sqlite_tip_rollback() {
|
||||
let (tmp_dir, _, _, db) = dummy_db();
|
||||
|
||||
{
|
||||
let mut conn = db.connection().unwrap();
|
||||
|
||||
let old_tip = BlockChainTip {
|
||||
hash: bitcoin::BlockHash::from_str(
|
||||
"00000000000000000004f43b5e743757939082170673d27a5a5130e0eb238832",
|
||||
)
|
||||
.unwrap(),
|
||||
height: 200_000,
|
||||
};
|
||||
conn.update_tip(&old_tip);
|
||||
|
||||
// 5 coins:
|
||||
// - One unconfirmed
|
||||
// - One confirmed before the rollback height
|
||||
// - One confirmed before the rollback height but spent after
|
||||
// - One confirmed after the rollback height
|
||||
// - One spent after the rollback height
|
||||
let coins = [
|
||||
Coin {
|
||||
outpoint: bitcoin::OutPoint::from_str(
|
||||
"6f0dc85a369b44458eba3a1f0ea5b5935d563afb6994f70f5b0094e05be1676c:1",
|
||||
)
|
||||
.unwrap(),
|
||||
block_height: None,
|
||||
block_time: None,
|
||||
amount: bitcoin::Amount::from_sat(98765),
|
||||
derivation_index: bip32::ChildNumber::from_normal_idx(10).unwrap(),
|
||||
spend_txid: None,
|
||||
spend_block: None,
|
||||
},
|
||||
Coin {
|
||||
outpoint: bitcoin::OutPoint::from_str(
|
||||
"c449539458c60bee6c0d8905ba1dadb20b9187b82045d306a408b894cea492b0:2",
|
||||
)
|
||||
.unwrap(),
|
||||
block_height: Some(101_095),
|
||||
block_time: Some(1_111_899),
|
||||
amount: bitcoin::Amount::from_sat(98765),
|
||||
derivation_index: bip32::ChildNumber::from_normal_idx(100).unwrap(),
|
||||
spend_txid: None,
|
||||
spend_block: None,
|
||||
},
|
||||
Coin {
|
||||
outpoint: bitcoin::OutPoint::from_str(
|
||||
"f0801fd9ca8bca0624c230ab422b2e2c4c8dc995e4e1dbc6412510959cce1e4f:3",
|
||||
)
|
||||
.unwrap(),
|
||||
block_height: Some(101_099),
|
||||
block_time: Some(1_121_899),
|
||||
amount: bitcoin::Amount::from_sat(98765),
|
||||
derivation_index: bip32::ChildNumber::from_normal_idx(1000).unwrap(),
|
||||
spend_txid: Some(
|
||||
bitcoin::Txid::from_str(
|
||||
"0c62a990d20d54429e70859292e82374ba6b1b951a3ab60f26bb65fee5724ff7",
|
||||
)
|
||||
.unwrap(),
|
||||
),
|
||||
spend_block: Some(SpendBlock {
|
||||
height: 101_199,
|
||||
time: 1_231_678,
|
||||
}),
|
||||
},
|
||||
Coin {
|
||||
outpoint: bitcoin::OutPoint::from_str(
|
||||
"19f56e65069f0a7a3bfb00c6a7085cc0669e03e91befeca1ee9891c9e737b2fb:4",
|
||||
)
|
||||
.unwrap(),
|
||||
block_height: Some(101_100),
|
||||
block_time: Some(1_131_899),
|
||||
amount: bitcoin::Amount::from_sat(98765),
|
||||
derivation_index: bip32::ChildNumber::from_normal_idx(10000).unwrap(),
|
||||
spend_txid: None,
|
||||
spend_block: None,
|
||||
},
|
||||
Coin {
|
||||
outpoint: bitcoin::OutPoint::from_str(
|
||||
"ed6c8f1af9325f84de521e785e7ddfd33dc28c9ada4d687dcd3850100bde54e9:5",
|
||||
)
|
||||
.unwrap(),
|
||||
block_height: Some(101_102),
|
||||
block_time: Some(1_134_899),
|
||||
amount: bitcoin::Amount::from_sat(98765),
|
||||
derivation_index: bip32::ChildNumber::from_normal_idx(100000).unwrap(),
|
||||
spend_txid: Some(
|
||||
bitcoin::Txid::from_str(
|
||||
"7477017f992cdc7ba08acafb77cb3b5bc0f42ac340d3e1e1da0785bdda20d5f6",
|
||||
)
|
||||
.unwrap(),
|
||||
),
|
||||
spend_block: Some(SpendBlock {
|
||||
height: 101_105,
|
||||
time: 1_201_678,
|
||||
}),
|
||||
},
|
||||
];
|
||||
conn.new_unspent_coins(&coins);
|
||||
conn.confirm_coins(
|
||||
&coins
|
||||
.iter()
|
||||
.filter_map(|c| {
|
||||
c.block_height
|
||||
.map(|b| (c.outpoint, b, c.block_time.unwrap()))
|
||||
})
|
||||
.collect::<Vec<_>>(),
|
||||
);
|
||||
conn.confirm_spend(
|
||||
&coins
|
||||
.iter()
|
||||
.filter_map(|c| {
|
||||
c.spend_block
|
||||
.as_ref()
|
||||
.map(|b| (c.outpoint, c.spend_txid.unwrap(), b.height, b.time))
|
||||
})
|
||||
.collect::<Vec<_>>(),
|
||||
);
|
||||
let mut db_coins = conn
|
||||
.db_coins(
|
||||
&coins
|
||||
.iter()
|
||||
.map(|c| c.outpoint)
|
||||
.collect::<Vec<bitcoin::OutPoint>>(),
|
||||
)
|
||||
.into_iter()
|
||||
.map(Coin::from)
|
||||
.collect::<Vec<_>>();
|
||||
db_coins.sort_by(|c1, c2| c1.outpoint.vout.cmp(&c2.outpoint.vout));
|
||||
assert_eq!(&db_coins[..], &coins[..]);
|
||||
|
||||
// Now that everything is settled, reorg to a previous height.
|
||||
let new_tip = BlockChainTip {
|
||||
hash: bitcoin::BlockHash::from_str(
|
||||
"000000000000000000016440c591da27679abfa53ef44d45b016640dbd04e126",
|
||||
)
|
||||
.unwrap(),
|
||||
height: 101_099,
|
||||
};
|
||||
conn.rollback_tip(&new_tip);
|
||||
|
||||
// The tip got updated
|
||||
let new_db_tip = conn.db_tip();
|
||||
assert_eq!(new_db_tip.block_height.unwrap(), new_tip.height);
|
||||
assert_eq!(new_db_tip.block_hash.unwrap(), new_tip.hash);
|
||||
|
||||
// And so were the coins
|
||||
let db_coins = conn
|
||||
.db_coins(
|
||||
&coins
|
||||
.iter()
|
||||
.map(|c| c.outpoint)
|
||||
.collect::<Vec<bitcoin::OutPoint>>(),
|
||||
)
|
||||
.into_iter()
|
||||
.map(|c| (c.outpoint, Coin::from(c)))
|
||||
.collect::<HashMap<_, _>>();
|
||||
// The first coin is unchanged
|
||||
assert_eq!(db_coins[&coins[0].outpoint], coins[0]);
|
||||
// Same for the second one
|
||||
assert_eq!(db_coins[&coins[1].outpoint], coins[1]);
|
||||
// The third one got its spend confirmation info wiped, but only that
|
||||
let mut coin = coins[2];
|
||||
coin.spend_block = None;
|
||||
assert_eq!(db_coins[&coins[2].outpoint], coin);
|
||||
// The fourth one got its own confirmation info wiped
|
||||
let mut coin = coins[3];
|
||||
coin.block_height = None;
|
||||
coin.block_time = None;
|
||||
assert_eq!(db_coins[&coins[3].outpoint], coin);
|
||||
// The fourth one got both is own confirmation and spend confirmation info wiped
|
||||
let mut coin = coins[4];
|
||||
coin.block_height = None;
|
||||
coin.block_time = None;
|
||||
coin.spend_block = None;
|
||||
assert_eq!(db_coins[&coins[4].outpoint], coin);
|
||||
}
|
||||
|
||||
fs::remove_dir_all(&tmp_dir).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
@ -30,7 +30,11 @@ CREATE TABLE wallets (
|
||||
deposit_derivation_index INTEGER NOT NULL
|
||||
);
|
||||
|
||||
/* Our (U)TxOs. */
|
||||
/* Our (U)TxOs.
|
||||
*
|
||||
* The 'spend_block_height' and 'spend_block.time' are only present if the spending
|
||||
* transaction for this coin exists and was confirmed.
|
||||
*/
|
||||
CREATE TABLE coins (
|
||||
id INTEGER PRIMARY KEY NOT NULL,
|
||||
wallet_id INTEGER NOT NULL,
|
||||
@ -41,8 +45,8 @@ CREATE TABLE coins (
|
||||
amount_sat INTEGER NOT NULL,
|
||||
derivation_index INTEGER NOT NULL,
|
||||
spend_txid BLOB,
|
||||
/* Time of the block containing the transaction spending the coin, NULL if not confirmed */
|
||||
spent_at INTEGER,
|
||||
spend_block_height INTEGER,
|
||||
spend_block_time INTEGER,
|
||||
UNIQUE (txid, vout),
|
||||
FOREIGN KEY (wallet_id) REFERENCES wallets (id)
|
||||
ON UPDATE RESTRICT
|
||||
@ -126,7 +130,13 @@ impl TryFrom<&rusqlite::Row<'_>> for DbWallet {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub struct DbSpendBlock {
|
||||
pub height: i32,
|
||||
pub time: u32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub struct DbCoin {
|
||||
pub id: i64,
|
||||
pub wallet_id: i64,
|
||||
@ -136,7 +146,7 @@ pub struct DbCoin {
|
||||
pub amount: bitcoin::Amount,
|
||||
pub derivation_index: bip32::ChildNumber,
|
||||
pub spend_txid: Option<bitcoin::Txid>,
|
||||
pub spent_at: Option<u32>,
|
||||
pub spend_block: Option<DbSpendBlock>,
|
||||
}
|
||||
|
||||
impl TryFrom<&rusqlite::Row<'_>> for DbCoin {
|
||||
@ -161,7 +171,13 @@ impl TryFrom<&rusqlite::Row<'_>> for DbCoin {
|
||||
let spend_txid: Option<Vec<u8>> = row.get(8)?;
|
||||
let spend_txid =
|
||||
spend_txid.map(|txid| encode::deserialize(&txid).expect("We only store valid txids"));
|
||||
let spent_at = row.get(9)?;
|
||||
let spend_height: Option<i32> = row.get(9)?;
|
||||
let spend_time: Option<u32> = row.get(10)?;
|
||||
assert_eq!(spend_height.is_none(), spend_time.is_none());
|
||||
let spend_block = spend_height.map(|height| DbSpendBlock {
|
||||
height,
|
||||
time: spend_time.expect("Must be there if height is"),
|
||||
});
|
||||
|
||||
Ok(DbCoin {
|
||||
id,
|
||||
@ -172,7 +188,7 @@ impl TryFrom<&rusqlite::Row<'_>> for DbCoin {
|
||||
amount,
|
||||
derivation_index,
|
||||
spend_txid,
|
||||
spent_at,
|
||||
spend_block,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@ -54,7 +54,7 @@ fn update_spend(control: &DaemonControl, params: Params) -> Result<serde_json::V
|
||||
.as_str()
|
||||
.and_then(|s| base64::decode(&s).ok())
|
||||
.and_then(|bytes| consensus::deserialize(&bytes).ok())
|
||||
.ok_or_else(|| Error::invalid_params("Invalid 'feerate' parameter."))?;
|
||||
.ok_or_else(|| Error::invalid_params("Invalid 'psbt' parameter."))?;
|
||||
control.update_spend(psbt)?;
|
||||
|
||||
Ok(serde_json::json!({}))
|
||||
@ -66,7 +66,7 @@ fn delete_spend(control: &DaemonControl, params: Params) -> Result<serde_json::V
|
||||
.ok_or_else(|| Error::invalid_params("Missing 'txid' parameter."))?
|
||||
.as_str()
|
||||
.and_then(|s| bitcoin::Txid::from_str(s).ok())
|
||||
.ok_or_else(|| Error::invalid_params("Invalid 'feerate' parameter."))?;
|
||||
.ok_or_else(|| Error::invalid_params("Invalid 'txid' parameter."))?;
|
||||
control.delete_spend(&txid);
|
||||
|
||||
Ok(serde_json::json!({}))
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
use crate::{
|
||||
bitcoin::{BitcoinInterface, BlockChainTip, UTxO},
|
||||
config::{BitcoinConfig, Config},
|
||||
database::{Coin, DatabaseConnection, DatabaseInterface},
|
||||
database::{Coin, DatabaseConnection, DatabaseInterface, SpendBlock},
|
||||
DaemonHandle,
|
||||
};
|
||||
|
||||
@ -59,9 +59,13 @@ impl BitcoinInterface for DummyBitcoind {
|
||||
fn spent_coins(
|
||||
&self,
|
||||
_: &[(bitcoin::OutPoint, bitcoin::Txid)],
|
||||
) -> Vec<(bitcoin::OutPoint, bitcoin::Txid, u32)> {
|
||||
) -> Vec<(bitcoin::OutPoint, bitcoin::Txid, i32, u32)> {
|
||||
Vec::new()
|
||||
}
|
||||
|
||||
fn common_ancestor(&self, _: &BlockChainTip) -> BlockChainTip {
|
||||
todo!()
|
||||
}
|
||||
}
|
||||
|
||||
pub struct DummyDb {
|
||||
@ -120,7 +124,7 @@ impl DatabaseConnection for DummyDbConn {
|
||||
self.db.write().unwrap().curr_index = next_index;
|
||||
}
|
||||
|
||||
fn unspent_coins(&mut self) -> HashMap<bitcoin::OutPoint, Coin> {
|
||||
fn coins(&mut self) -> HashMap<bitcoin::OutPoint, Coin> {
|
||||
self.db.read().unwrap().coins.clone()
|
||||
}
|
||||
|
||||
@ -128,7 +132,7 @@ impl DatabaseConnection for DummyDbConn {
|
||||
let mut result = HashMap::new();
|
||||
for (k, v) in self.db.read().unwrap().coins.iter() {
|
||||
if v.spend_txid.is_some() {
|
||||
result.insert(*k, v.clone());
|
||||
result.insert(*k, *v);
|
||||
}
|
||||
}
|
||||
result
|
||||
@ -136,11 +140,7 @@ impl DatabaseConnection for DummyDbConn {
|
||||
|
||||
fn new_unspent_coins<'a>(&mut self, coins: &[Coin]) {
|
||||
for coin in coins {
|
||||
self.db
|
||||
.write()
|
||||
.unwrap()
|
||||
.coins
|
||||
.insert(coin.outpoint, coin.clone());
|
||||
self.db.write().unwrap().coins.insert(coin.outpoint, *coin);
|
||||
}
|
||||
}
|
||||
|
||||
@ -160,19 +160,22 @@ impl DatabaseConnection for DummyDbConn {
|
||||
let mut db = self.db.write().unwrap();
|
||||
let spent = &mut db.coins.get_mut(op).unwrap();
|
||||
assert!(spent.spend_txid.is_none());
|
||||
assert!(spent.spent_at.is_none());
|
||||
assert!(spent.spend_block.is_none());
|
||||
spent.spend_txid = Some(*spend_txid);
|
||||
}
|
||||
}
|
||||
|
||||
fn confirm_spend<'a>(&mut self, outpoints: &[(bitcoin::OutPoint, bitcoin::Txid, u32)]) {
|
||||
for (op, spend_txid, time) in outpoints {
|
||||
fn confirm_spend<'a>(&mut self, outpoints: &[(bitcoin::OutPoint, bitcoin::Txid, i32, u32)]) {
|
||||
for (op, spend_txid, height, time) in outpoints {
|
||||
let mut db = self.db.write().unwrap();
|
||||
let spent = &mut db.coins.get_mut(op).unwrap();
|
||||
assert!(spent.spend_txid.is_some());
|
||||
assert!(spent.spent_at.is_none());
|
||||
assert!(spent.spend_block.is_none());
|
||||
spent.spend_txid = Some(*spend_txid);
|
||||
spent.spent_at = Some(*time);
|
||||
spent.spend_block = Some(SpendBlock {
|
||||
height: *height,
|
||||
time: *time,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@ -221,6 +224,10 @@ impl DatabaseConnection for DummyDbConn {
|
||||
fn delete_spend(&mut self, txid: &bitcoin::Txid) {
|
||||
self.db.write().unwrap().spend_txs.remove(txid);
|
||||
}
|
||||
|
||||
fn rollback_tip(&mut self, _: &BlockChainTip) {
|
||||
todo!()
|
||||
}
|
||||
}
|
||||
|
||||
pub struct DummyMinisafe {
|
||||
|
||||
168
tests/test_chain.py
Normal file
168
tests/test_chain.py
Normal file
@ -0,0 +1,168 @@
|
||||
from fixtures import *
|
||||
from test_framework.utils import wait_for, get_txid, spend_coins
|
||||
|
||||
|
||||
def get_coin(minisafed, outpoint_or_txid):
|
||||
return next(
|
||||
c
|
||||
for c in minisafed.rpc.listcoins()["coins"]
|
||||
if outpoint_or_txid in c["outpoint"]
|
||||
)
|
||||
|
||||
|
||||
def test_reorg_detection(minisafed, bitcoind):
|
||||
"""Test we detect block chain reorganization under various conditions."""
|
||||
initial_height = bitcoind.rpc.getblockcount()
|
||||
wait_for(lambda: minisafed.rpc.getinfo()["blockheight"] == initial_height)
|
||||
|
||||
# Re-mine the last block. We should detect it as a reorg.
|
||||
bitcoind.invalidate_remine(initial_height)
|
||||
minisafed.wait_for_logs(
|
||||
["Block chain reorganization detected.", "Tip was rolled back."]
|
||||
)
|
||||
wait_for(lambda: minisafed.rpc.getinfo()["blockheight"] == initial_height)
|
||||
|
||||
# Same if we re-mine the next-to-last block.
|
||||
bitcoind.invalidate_remine(initial_height - 1)
|
||||
minisafed.wait_for_logs(
|
||||
["Block chain reorganization detected.", "Tip was rolled back."]
|
||||
)
|
||||
wait_for(lambda: minisafed.rpc.getinfo()["blockheight"] == initial_height)
|
||||
|
||||
# Same if we re-mine a deep block.
|
||||
bitcoind.invalidate_remine(initial_height - 50)
|
||||
minisafed.wait_for_logs(
|
||||
["Block chain reorganization detected.", "Tip was rolled back."]
|
||||
)
|
||||
wait_for(lambda: minisafed.rpc.getinfo()["blockheight"] == initial_height)
|
||||
|
||||
# Same if the new chain is longer.
|
||||
bitcoind.simple_reorg(initial_height - 10, shift=20)
|
||||
minisafed.wait_for_logs(
|
||||
["Block chain reorganization detected.", "Tip was rolled back."]
|
||||
)
|
||||
wait_for(lambda: minisafed.rpc.getinfo()["blockheight"] == initial_height + 10)
|
||||
|
||||
|
||||
def test_reorg_exclusion(minisafed, bitcoind):
|
||||
"""Test the unconfirmation by a reorg of a coin in various states."""
|
||||
initial_height = bitcoind.rpc.getblockcount()
|
||||
wait_for(lambda: minisafed.rpc.getinfo()["blockheight"] == initial_height)
|
||||
|
||||
# A confirmed received coin
|
||||
addr = minisafed.rpc.getnewaddress()["address"]
|
||||
txid = bitcoind.rpc.sendtoaddress(addr, 1)
|
||||
bitcoind.generate_block(1, wait_for_mempool=txid)
|
||||
wait_for(lambda: len(minisafed.rpc.listcoins()["coins"]) == 1)
|
||||
coin_a = minisafed.rpc.listcoins()["coins"][0]
|
||||
|
||||
# A confirmed and 'spending' (unconfirmed spend) coin
|
||||
addr = minisafed.rpc.getnewaddress()["address"]
|
||||
txid = bitcoind.rpc.sendtoaddress(addr, 2)
|
||||
bitcoind.generate_block(1, wait_for_mempool=txid)
|
||||
wait_for(lambda: len(minisafed.rpc.listcoins()["coins"]) == 2)
|
||||
coin_b = get_coin(minisafed, txid)
|
||||
b_spend_tx = spend_coins(minisafed, bitcoind, [coin_b])
|
||||
|
||||
# A confirmed and spent coin
|
||||
addr = minisafed.rpc.getnewaddress()["address"]
|
||||
txid = bitcoind.rpc.sendtoaddress(addr, 3)
|
||||
bitcoind.generate_block(1, wait_for_mempool=txid)
|
||||
wait_for(lambda: len(minisafed.rpc.listcoins()["coins"]) == 3)
|
||||
coin_c = get_coin(minisafed, txid)
|
||||
c_spend_tx = spend_coins(minisafed, bitcoind, [coin_c])
|
||||
bitcoind.generate_block(1, wait_for_mempool=1)
|
||||
|
||||
# Reorg the chain down to the initial height, excluding all transactions.
|
||||
current_height = bitcoind.rpc.getblockcount()
|
||||
bitcoind.simple_reorg(initial_height, shift=-1)
|
||||
wait_for(lambda: minisafed.rpc.getinfo()["blockheight"] == current_height + 1)
|
||||
|
||||
# They must all be marked as unconfirmed.
|
||||
new_coin_a = get_coin(minisafed, coin_a["outpoint"])
|
||||
assert new_coin_a["block_height"] is None
|
||||
new_coin_b = get_coin(minisafed, coin_b["outpoint"])
|
||||
assert new_coin_b["block_height"] is None
|
||||
new_coin_c = get_coin(minisafed, coin_c["outpoint"])
|
||||
assert new_coin_c["block_height"] is None
|
||||
|
||||
# And if we now confirm everything, they'll be marked as such. The one that was 'spending'
|
||||
# will now be spent (its spending transaction will be confirmed) and the one that was spent
|
||||
# will be marked as such.
|
||||
deposit_txids = [c["outpoint"][:-2] for c in (coin_a, coin_b, coin_c)]
|
||||
for txid in deposit_txids:
|
||||
tx = bitcoind.rpc.gettransaction(txid)["hex"]
|
||||
bitcoind.rpc.sendrawtransaction(tx)
|
||||
bitcoind.rpc.sendrawtransaction(b_spend_tx)
|
||||
bitcoind.rpc.sendrawtransaction(c_spend_tx)
|
||||
bitcoind.generate_block(1, wait_for_mempool=5)
|
||||
new_height = bitcoind.rpc.getblockcount()
|
||||
wait_for(lambda: minisafed.rpc.getinfo()["blockheight"] == new_height)
|
||||
assert all(
|
||||
c["block_height"] == new_height for c in minisafed.rpc.listcoins()["coins"]
|
||||
), (minisafed.rpc.listcoins()["coins"], new_height)
|
||||
new_coin_b = next(
|
||||
c
|
||||
for c in minisafed.rpc.listcoins()["coins"]
|
||||
if coin_b["outpoint"] == c["outpoint"]
|
||||
)
|
||||
b_spend_txid = get_txid(b_spend_tx)
|
||||
assert new_coin_b["spend_info"]["txid"] == b_spend_txid
|
||||
assert new_coin_b["spend_info"]["height"] == new_height
|
||||
new_coin_c = next(
|
||||
c
|
||||
for c in minisafed.rpc.listcoins()["coins"]
|
||||
if coin_c["outpoint"] == c["outpoint"]
|
||||
)
|
||||
c_spend_txid = get_txid(c_spend_tx)
|
||||
assert new_coin_c["spend_info"]["txid"] == c_spend_txid
|
||||
assert new_coin_c["spend_info"]["height"] == new_height
|
||||
|
||||
# TODO: maybe test with some malleation for the deposit and spending txs?
|
||||
|
||||
|
||||
def spend_confirmed_noticed(minisafed, outpoint):
|
||||
c = get_coin(minisafed, outpoint)
|
||||
if c["spend_info"] is None:
|
||||
return False
|
||||
if c["spend_info"]["height"] is None:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def test_reorg_status_recovery(minisafed, bitcoind):
|
||||
"""
|
||||
Test the coins that were not unconfirmed recover their initial state after a reorg.
|
||||
"""
|
||||
list_coins = lambda: minisafed.rpc.listcoins()["coins"]
|
||||
|
||||
# Create two confirmed coins. Note how we take the initial_height after having
|
||||
# mined them, as we'll reorg back to this height and due to anti fee-sniping
|
||||
# these deposit transactions might not be valid anymore!
|
||||
addresses = (minisafed.rpc.getnewaddress()["address"] for _ in range(2))
|
||||
txids = [bitcoind.rpc.sendtoaddress(addr, 0.5670) for addr in addresses]
|
||||
bitcoind.generate_block(1, wait_for_mempool=txids)
|
||||
initial_height = bitcoind.rpc.getblockcount()
|
||||
wait_for(lambda: minisafed.rpc.getinfo()["blockheight"] == initial_height)
|
||||
|
||||
# Both coins are confirmed. Spend the second one then get their infos.
|
||||
wait_for(lambda: len(list_coins()) == 2)
|
||||
wait_for(lambda: all(c["block_height"] is not None for c in list_coins()))
|
||||
coin_b = get_coin(minisafed, txids[1])
|
||||
spend_coins(minisafed, bitcoind, [coin_b])
|
||||
bitcoind.generate_block(1, wait_for_mempool=1)
|
||||
wait_for(lambda: spend_confirmed_noticed(minisafed, coin_b["outpoint"]))
|
||||
coin_a = get_coin(minisafed, txids[0])
|
||||
coin_b = get_coin(minisafed, txids[1])
|
||||
|
||||
# Reorg the chain down to the initial height without shifting nor malleating
|
||||
# any transaction. The coin info should be identical (except the transaction
|
||||
# spending the second coin will be mined at the height the reorg happened).
|
||||
bitcoind.simple_reorg(initial_height, shift=0)
|
||||
new_height = bitcoind.rpc.getblockcount()
|
||||
wait_for(lambda: minisafed.rpc.getinfo()["blockheight"] == new_height)
|
||||
new_coin_a = get_coin(minisafed, coin_a["outpoint"])
|
||||
assert coin_a == new_coin_a
|
||||
new_coin_b = get_coin(minisafed, coin_b["outpoint"])
|
||||
coin_b["spend_info"]["height"] = initial_height
|
||||
assert new_coin_b == coin_b
|
||||
@ -126,12 +126,17 @@ class Bitcoind(TailableProc):
|
||||
for _ in range(n):
|
||||
self.rpc.generateblock(addr, [])
|
||||
|
||||
def invalidate_remine(self, height):
|
||||
delta = self.rpc.getblockcount() - height + 1
|
||||
h = self.rpc.getblockhash(height)
|
||||
self.rpc.invalidateblock(h)
|
||||
self.generate_empty_blocks(delta)
|
||||
|
||||
def simple_reorg(self, height, shift=0):
|
||||
"""
|
||||
Reorganize chain by creating a fork at height={height} and:
|
||||
- If shift >=0:
|
||||
- re-mine all mempool transactions into {height} + shift
|
||||
(with shift floored at 1)
|
||||
- Else:
|
||||
- don't re-mine the mempool transactions
|
||||
|
||||
|
||||
@ -8,6 +8,9 @@ import subprocess
|
||||
import threading
|
||||
import time
|
||||
|
||||
from io import BytesIO
|
||||
from .serializations import CTransaction, PSBT
|
||||
|
||||
TIMEOUT = int(os.getenv("TIMEOUT", 20))
|
||||
EXECUTOR_WORKERS = int(os.getenv("EXECUTOR_WORKERS", 20))
|
||||
VERBOSE = os.getenv("VERBOSE", "0") == "1"
|
||||
@ -43,6 +46,35 @@ def wait_for(success, timeout=TIMEOUT, debug_fn=None):
|
||||
raise ValueError("Error waiting for {}", success)
|
||||
|
||||
|
||||
def get_txid(hex_tx):
|
||||
"""Get the txid (as hex) of the given (as hex) transaction."""
|
||||
tx = CTransaction()
|
||||
tx.deserialize(BytesIO(bytes.fromhex(hex_tx)))
|
||||
return tx.txid().hex()
|
||||
|
||||
|
||||
def spend_coins(minisafed, bitcoind, coins):
|
||||
"""Spend these coins, no matter how.
|
||||
This will create a single transaction spending them all at once at the minimum
|
||||
feerate. This will broadcast but not confirm the transaction.
|
||||
|
||||
:param coins: a list of dict as returned by listcoins. The coins must all exist.
|
||||
:returns: the broadcasted transaction, as hex.
|
||||
"""
|
||||
total_value = sum(c["amount"] for c in coins)
|
||||
destinations = {
|
||||
bitcoind.rpc.getnewaddress(): total_value - 11 - 31 - 300 * len(coins)
|
||||
}
|
||||
res = minisafed.rpc.createspend([c["outpoint"] for c in coins], destinations, 1)
|
||||
|
||||
psbt = PSBT()
|
||||
psbt.deserialize(res["psbt"])
|
||||
tx = minisafed.sign_psbt(psbt)
|
||||
bitcoind.rpc.sendrawtransaction(tx)
|
||||
|
||||
return tx
|
||||
|
||||
|
||||
class RpcError(ValueError):
|
||||
def __init__(self, method: str, params: dict, error: str):
|
||||
super(ValueError, self).__init__(
|
||||
|
||||
@ -1,13 +1,13 @@
|
||||
from fixtures import *
|
||||
from test_framework.serializations import PSBT
|
||||
from test_framework.utils import wait_for, COIN
|
||||
from test_framework.utils import wait_for, COIN, get_txid, spend_coins
|
||||
|
||||
|
||||
def test_getinfo(minisafed):
|
||||
res = minisafed.rpc.getinfo()
|
||||
assert res["version"] == "0.1"
|
||||
assert res["network"] == "regtest"
|
||||
assert res["blockheight"] == 101
|
||||
wait_for(lambda: res["blockheight"] == 101)
|
||||
assert res["sync"] == 1.0
|
||||
assert "main" in res["descriptors"]
|
||||
|
||||
@ -34,6 +34,7 @@ def test_listcoins(minisafed, bitcoind):
|
||||
assert txid == res[0]["outpoint"][:64]
|
||||
assert res[0]["amount"] == 1 * COIN
|
||||
assert res[0]["block_height"] is None
|
||||
assert res[0]["spend_info"] is None
|
||||
|
||||
# If the coin gets confirmed, it'll be marked as such.
|
||||
bitcoind.generate_block(1, wait_for_mempool=txid)
|
||||
@ -42,6 +43,22 @@ def test_listcoins(minisafed, bitcoind):
|
||||
lambda: minisafed.rpc.listcoins()["coins"][0]["block_height"] == block_height
|
||||
)
|
||||
|
||||
# Same if the coin gets spent.
|
||||
spend_tx = spend_coins(minisafed, bitcoind, (res[0],))
|
||||
spend_txid = get_txid(spend_tx)
|
||||
wait_for(lambda: minisafed.rpc.listcoins()["coins"][0]["spend_info"] is not None)
|
||||
spend_info = minisafed.rpc.listcoins()["coins"][0]["spend_info"]
|
||||
assert spend_info["txid"] == spend_txid
|
||||
assert spend_info["height"] is None
|
||||
|
||||
# And if this spending tx gets confirmed.
|
||||
bitcoind.generate_block(1, wait_for_mempool=spend_txid)
|
||||
curr_height = bitcoind.rpc.getblockcount()
|
||||
wait_for(lambda: minisafed.rpc.getinfo()["blockheight"] == curr_height)
|
||||
spend_info = minisafed.rpc.listcoins()["coins"][0]["spend_info"]
|
||||
assert spend_info["txid"] == spend_txid
|
||||
assert spend_info["height"] == curr_height
|
||||
|
||||
|
||||
def test_jsonrpc_server(minisafed, bitcoind):
|
||||
"""Test passing parameters as a list or a mapping."""
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user