Merge #99: command: add listtransactions
c39cb07360e5d3a6f528d4b17f1e34fae0da550b qa: functional test for transaction listing commands (Antoine Poinsot)
1f06c4d4dc4a6c611231c2e95a1e464e038b2570 db: fix the list_txids query (Antoine Poinsot)
774f9695941757253ba34c9a61c09e70d4a3149f Add listtransaction command (edouard)
3cd709697b4c0f098b9938dc122ddf471659dbe9 Add listconfirmed command (edouard)
Pull request description:
ACKs for top commit:
darosior:
ACK c39cb07360e5d3a6f528d4b17f1e34fae0da550b
Tree-SHA512: b0e158bb10d0c6fa3fc6beb6e12ea081a8078116733f2407902a9433f1df1fc67b2ee2d73c15d2d5c3bd49ff797ae0aa0b5c68f0684ed22c87f94c318fa39673
This commit is contained in:
commit
dc23f3667a
67
doc/API.md
67
doc/API.md
@ -5,16 +5,18 @@ interface over a Unix Domain socket.
|
||||
|
||||
Commands must be sent as valid JSONRPC 2.0 requests, ending with a `\n`.
|
||||
|
||||
| Command | Description |
|
||||
| ----------------------------------------------------------- | ---------------------------------------------------- |
|
||||
| [`stop`](#stop) | Stops the minisafe daemon |
|
||||
| [`getinfo`](#getinfo) | Get general information about the daemon |
|
||||
| [`getnewaddress`](#getnewaddress) | Get a new receiving address |
|
||||
| [`listcoins`](#listcoins) | List all wallet transaction outputs. |
|
||||
| [`listspendtxs`](#listspendtxs) | List all stored Spend transactions |
|
||||
| [`delspendtx`](#delspendtx) | Delete a stored Spend transaction |
|
||||
| [`broadcastspend`](#broadcastspend) | Finalize a stored Spend PSBT, and broadcast it |
|
||||
| [`startrescan`](#startrescan) | Start rescanning the block chain from a given date |
|
||||
| Command | Description |
|
||||
| ----------------------------------------------------------- | ---------------------------------------------------- |
|
||||
| [`stop`](#stop) | Stops the minisafe daemon |
|
||||
| [`getinfo`](#getinfo) | Get general information about the daemon |
|
||||
| [`getnewaddress`](#getnewaddress) | Get a new receiving address |
|
||||
| [`listcoins`](#listcoins) | List all wallet transaction outputs. |
|
||||
| [`listspendtxs`](#listspendtxs) | List all stored Spend transactions |
|
||||
| [`delspendtx`](#delspendtx) | Delete a stored Spend transaction |
|
||||
| [`broadcastspend`](#broadcastspend) | Finalize a stored Spend PSBT, and broadcast it |
|
||||
| [`startrescan`](#startrescan) | Start rescanning the block chain from a given date |
|
||||
| [`listconfirmed`](#listconfirmed) | List of confirmed transactions of incoming and outgoing funds |
|
||||
| [`listtransactions`](#listtransactions) | List of transactions with the given txids |
|
||||
|
||||
# Reference
|
||||
|
||||
@ -187,7 +189,6 @@ This command does not return anything for now.
|
||||
| Field | Type | Description |
|
||||
| -------------- | --------- | ---------------------------------------------------- |
|
||||
|
||||
|
||||
### `broadcastspend`
|
||||
|
||||
#### Request
|
||||
@ -203,7 +204,6 @@ This command does not return anything for now.
|
||||
| Field | Type | Description |
|
||||
| -------------- | --------- | ---------------------------------------------------- |
|
||||
|
||||
|
||||
### `startrescan`
|
||||
|
||||
#### Request
|
||||
@ -218,3 +218,46 @@ This command does not return anything for now.
|
||||
|
||||
| Field | Type | Description |
|
||||
| -------------- | --------- | ---------------------------------------------------- |
|
||||
|
||||
### `listconfirmed`
|
||||
|
||||
`listconfirmed` retrieves a paginated and ordered list of transactions that were confirmed within a given time window.
|
||||
Confirmation time is based on the timestamp of blocks.
|
||||
|
||||
#### Request
|
||||
|
||||
| Field | Type | Description |
|
||||
| ------------- | ------------ | ------------------------------------------ |
|
||||
| `start` | int | Inclusive lower bound of the time window |
|
||||
| `end` | int | Inclusive upper bound of the time window |
|
||||
| `limit` | int | Maximum number of transactions to retrieve |
|
||||
|
||||
#### Response
|
||||
|
||||
| Field | Type | Description |
|
||||
| -------------- | ------ | ------------------------------------------------------ |
|
||||
| `transactions` | array | Array of [Transaction resource](#transaction-resource) |
|
||||
|
||||
##### Transaction Resource
|
||||
|
||||
| Field | Type | Description |
|
||||
| -------- | ------------- | ------------------------------------------------------------------------- |
|
||||
| `height` | int or `null` | Block height of the transaction, `null` if the transaction is unconfirmed |
|
||||
| `time` | int or `null` | Block time of the transaction, `null` if the transaction is unconfirmed |
|
||||
| `tx` | string | hex encoded bitcoin transaction |
|
||||
|
||||
### `listtransactions`
|
||||
|
||||
`listtransactions` retrieves the transactions with the given txids.
|
||||
|
||||
#### Request
|
||||
|
||||
| Field | Type | Description |
|
||||
| ------------- | --------------- | ------------------------------------- |
|
||||
| `txids` | array of string | Ids of the transactions to retrieve |
|
||||
|
||||
#### Response
|
||||
|
||||
| Field | Type | Description |
|
||||
| -------------- | ------ | ------------------------------------------------------ |
|
||||
| `transactions` | array | Array of [Transaction resource](#transaction-resource) |
|
||||
|
||||
@ -2,7 +2,11 @@
|
||||
///!
|
||||
///! We use the RPC interface and a watchonly descriptor wallet.
|
||||
mod utils;
|
||||
use crate::{bitcoin::BlockChainTip, config, descriptors::MultipathDescriptor};
|
||||
use crate::{
|
||||
bitcoin::{Block, BlockChainTip},
|
||||
config,
|
||||
descriptors::MultipathDescriptor,
|
||||
};
|
||||
use utils::block_before_date;
|
||||
|
||||
use std::{cmp, collections::HashSet, convert::TryInto, fs, io, str::FromStr, time::Duration};
|
||||
@ -12,7 +16,11 @@ use jsonrpc::{
|
||||
client::Client,
|
||||
simple_http::{self, SimpleHttpTransport},
|
||||
};
|
||||
use miniscript::{bitcoin, descriptor};
|
||||
|
||||
use miniscript::{
|
||||
bitcoin::{self, hashes::hex::FromHex},
|
||||
descriptor,
|
||||
};
|
||||
|
||||
use serde_json::Value as Json;
|
||||
|
||||
@ -1035,12 +1043,15 @@ impl From<Json> for LSBlockRes {
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct GetTxRes {
|
||||
pub conflicting_txs: Vec<bitcoin::Txid>,
|
||||
pub block_height: Option<i32>,
|
||||
pub block_time: Option<u32>,
|
||||
pub block: Option<Block>,
|
||||
pub tx: bitcoin::Transaction,
|
||||
}
|
||||
|
||||
impl From<Json> for GetTxRes {
|
||||
fn from(json: Json) -> GetTxRes {
|
||||
let block_hash = json.get("blockhash").and_then(Json::as_str).map(|s| {
|
||||
bitcoin::BlockHash::from_str(s).expect("Invalid blockhash in `gettransaction` response")
|
||||
});
|
||||
let block_height = json
|
||||
.get("blockheight")
|
||||
.and_then(Json::as_i64)
|
||||
@ -1060,11 +1071,21 @@ impl From<Json> for GetTxRes {
|
||||
})
|
||||
.collect()
|
||||
});
|
||||
|
||||
let block = match (block_hash, block_height, block_time) {
|
||||
(Some(hash), Some(height), Some(time)) => Some(Block { hash, time, height }),
|
||||
_ => None,
|
||||
};
|
||||
let hex = json
|
||||
.get("hex")
|
||||
.and_then(Json::as_str)
|
||||
.expect("Must be present in bitcoind response");
|
||||
let bytes = Vec::from_hex(hex).expect("bitcoind returned a wrong transaction format");
|
||||
let tx: bitcoin::Transaction = bitcoin::consensus::encode::deserialize(&bytes)
|
||||
.expect("bitcoind returned a wrong transaction format");
|
||||
GetTxRes {
|
||||
conflicting_txs: conflicting_txs.unwrap_or_default(),
|
||||
block_height,
|
||||
block_time,
|
||||
block,
|
||||
tx,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -13,6 +13,14 @@ use std::{collections::HashMap, fmt, sync};
|
||||
|
||||
use miniscript::bitcoin;
|
||||
|
||||
/// Information about a block
|
||||
#[derive(Debug, Clone, Eq, PartialEq, Copy)]
|
||||
pub struct Block {
|
||||
pub hash: bitcoin::BlockHash,
|
||||
pub height: i32,
|
||||
pub time: u32,
|
||||
}
|
||||
|
||||
/// Information about the best block in the chain
|
||||
#[derive(Debug, Clone, Eq, PartialEq, Copy)]
|
||||
pub struct BlockChainTip {
|
||||
@ -66,7 +74,7 @@ pub trait BitcoinInterface: Send {
|
||||
fn spent_coins(
|
||||
&self,
|
||||
outpoints: &[(bitcoin::OutPoint, bitcoin::Txid)],
|
||||
) -> Vec<(bitcoin::OutPoint, bitcoin::Txid, i32, u32)>;
|
||||
) -> Vec<(bitcoin::OutPoint, bitcoin::Txid, Block)>;
|
||||
|
||||
/// Get the common ancestor between the Bitcoin backend's tip and the given tip.
|
||||
fn common_ancestor(&self, tip: &BlockChainTip) -> Option<BlockChainTip>;
|
||||
@ -88,6 +96,12 @@ pub trait BitcoinInterface: Send {
|
||||
/// Get the last block chain tip with a timestamp below this. Timestamp must be a valid block
|
||||
/// timestamp.
|
||||
fn block_before_date(&self, timestamp: u32) -> Option<BlockChainTip>;
|
||||
|
||||
/// Get a transaction related to the wallet along with potential confirmation info.
|
||||
fn wallet_transaction(
|
||||
&self,
|
||||
txid: &bitcoin::Txid,
|
||||
) -> Option<(bitcoin::Transaction, Option<Block>)>;
|
||||
}
|
||||
|
||||
impl BitcoinInterface for d::BitcoinD {
|
||||
@ -158,10 +172,8 @@ impl BitcoinInterface for d::BitcoinD {
|
||||
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 {
|
||||
if let Some(t) = res.block_time {
|
||||
confirmed.push((*op, h, t));
|
||||
}
|
||||
if let Some(block) = res.block {
|
||||
confirmed.push((*op, block.height, block.time));
|
||||
}
|
||||
} else {
|
||||
log::error!("Transaction not in wallet for coin '{}'.", op);
|
||||
@ -200,7 +212,7 @@ impl BitcoinInterface for d::BitcoinD {
|
||||
fn spent_coins(
|
||||
&self,
|
||||
outpoints: &[(bitcoin::OutPoint, bitcoin::Txid)],
|
||||
) -> Vec<(bitcoin::OutPoint, bitcoin::Txid, i32, u32)> {
|
||||
) -> Vec<(bitcoin::OutPoint, bitcoin::Txid, Block)> {
|
||||
let mut spent = Vec::with_capacity(outpoints.len());
|
||||
|
||||
let mut cache: HashMap<bitcoin::Txid, Option<d::GetTxRes>> = HashMap::new();
|
||||
@ -219,15 +231,8 @@ 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_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_height,
|
||||
tx.block_time.expect("Confirmed tx."),
|
||||
));
|
||||
if let Some(block) = tx.block {
|
||||
spent.push((*op, *txid, block));
|
||||
} else if !tx.conflicting_txs.is_empty() {
|
||||
for txid in &tx.conflicting_txs {
|
||||
let tx: Option<&d::GetTxRes> = match cache.get(txid) {
|
||||
@ -239,13 +244,8 @@ impl BitcoinInterface for d::BitcoinD {
|
||||
}
|
||||
};
|
||||
if let Some(tx) = tx {
|
||||
if let Some(block_height) = tx.block_height {
|
||||
spent.push((
|
||||
*op,
|
||||
*txid,
|
||||
block_height,
|
||||
tx.block_time.expect("Spend is confirmed"),
|
||||
))
|
||||
if let Some(block) = tx.block {
|
||||
spent.push((*op, *txid, block))
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -309,6 +309,13 @@ impl BitcoinInterface for d::BitcoinD {
|
||||
let tip = self.chain_tip();
|
||||
self.get_block_stats(tip.hash).time
|
||||
}
|
||||
|
||||
fn wallet_transaction(
|
||||
&self,
|
||||
txid: &bitcoin::Txid,
|
||||
) -> Option<(bitcoin::Transaction, Option<Block>)> {
|
||||
self.get_transaction(txid).map(|res| (res.tx, res.block))
|
||||
}
|
||||
}
|
||||
|
||||
// FIXME: do we need to repeat the entire trait implemenation? Isn't there a nicer way?
|
||||
@ -354,7 +361,7 @@ impl BitcoinInterface for sync::Arc<sync::Mutex<dyn BitcoinInterface + 'static>>
|
||||
fn spent_coins(
|
||||
&self,
|
||||
outpoints: &[(bitcoin::OutPoint, bitcoin::Txid)],
|
||||
) -> Vec<(bitcoin::OutPoint, bitcoin::Txid, i32, u32)> {
|
||||
) -> Vec<(bitcoin::OutPoint, bitcoin::Txid, Block)> {
|
||||
self.lock().unwrap().spent_coins(outpoints)
|
||||
}
|
||||
|
||||
@ -385,6 +392,13 @@ impl BitcoinInterface for sync::Arc<sync::Mutex<dyn BitcoinInterface + 'static>>
|
||||
fn tip_time(&self) -> u32 {
|
||||
self.lock().unwrap().tip_time()
|
||||
}
|
||||
|
||||
fn wallet_transaction(
|
||||
&self,
|
||||
txid: &bitcoin::Txid,
|
||||
) -> Option<(bitcoin::Transaction, Option<Block>)> {
|
||||
self.lock().unwrap().wallet_transaction(txid)
|
||||
}
|
||||
}
|
||||
|
||||
// FIXME: We could avoid this type (and all the conversions entailing allocations) if bitcoind
|
||||
|
||||
@ -121,7 +121,11 @@ fn update_coins(
|
||||
.map(|coin| (coin.outpoint, coin.spend_txid.expect("Coin is spending")))
|
||||
.chain(spending.iter().cloned())
|
||||
.collect();
|
||||
let spent = bit.spent_coins(spending_coins.as_slice());
|
||||
let spent = bit
|
||||
.spent_coins(spending_coins.as_slice())
|
||||
.into_iter()
|
||||
.map(|(oupoint, txid, block)| (oupoint, txid, block.height, block.time))
|
||||
.collect();
|
||||
log::debug!("Newly spent coins: {:?}", spent);
|
||||
|
||||
UpdatedCoins {
|
||||
|
||||
@ -9,7 +9,10 @@ use crate::{
|
||||
database::{Coin, DatabaseInterface},
|
||||
descriptors, DaemonControl, VERSION,
|
||||
};
|
||||
use utils::{change_index, deser_amount_from_sats, deser_psbt_base64, ser_amount, ser_base64};
|
||||
|
||||
use utils::{
|
||||
change_index, deser_amount_from_sats, deser_base64, deser_hex, ser_amount, ser_base64, ser_hex,
|
||||
};
|
||||
|
||||
use std::{
|
||||
collections::{BTreeMap, HashMap},
|
||||
@ -555,6 +558,51 @@ impl DaemonControl {
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// list_confirmed_transactions retrieves a limited list of transactions which occured between two given dates.
|
||||
pub fn list_confirmed_transactions(
|
||||
&self,
|
||||
start: u32,
|
||||
end: u32,
|
||||
limit: u64,
|
||||
) -> ListTransactionsResult {
|
||||
let mut db_conn = self.db.connection();
|
||||
let txids = db_conn.list_txids(start, end, limit);
|
||||
let transactions = txids
|
||||
.iter()
|
||||
.filter_map(|txid| {
|
||||
// TODO: batch batch those calls to the Bitcoin backend
|
||||
// so it can in turn optimize its queries.
|
||||
self.bitcoin
|
||||
.wallet_transaction(txid)
|
||||
.map(|(tx, block)| TransactionInfo {
|
||||
tx,
|
||||
height: block.map(|b| b.height),
|
||||
time: block.map(|b| b.time),
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
ListTransactionsResult { transactions }
|
||||
}
|
||||
|
||||
/// list_transactions retrieves the transactions with the given txids.
|
||||
pub fn list_transactions(&self, txids: &[bitcoin::Txid]) -> ListTransactionsResult {
|
||||
let transactions = txids
|
||||
.iter()
|
||||
.filter_map(|txid| {
|
||||
// TODO: batch batch those calls to the Bitcoin backend
|
||||
// so it can in turn optimize its queries.
|
||||
self.bitcoin
|
||||
.wallet_transaction(txid)
|
||||
.map(|(tx, block)| TransactionInfo {
|
||||
tx,
|
||||
height: block.map(|b| b.height),
|
||||
time: block.map(|b| b.time),
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
ListTransactionsResult { transactions }
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
@ -606,13 +654,13 @@ pub struct ListCoinsResult {
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct CreateSpendResult {
|
||||
#[serde(serialize_with = "ser_base64", deserialize_with = "deser_psbt_base64")]
|
||||
#[serde(serialize_with = "ser_base64", deserialize_with = "deser_base64")]
|
||||
pub psbt: Psbt,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ListSpendEntry {
|
||||
#[serde(serialize_with = "ser_base64", deserialize_with = "deser_psbt_base64")]
|
||||
#[serde(serialize_with = "ser_base64", deserialize_with = "deser_base64")]
|
||||
pub psbt: Psbt,
|
||||
pub change_index: Option<u32>,
|
||||
}
|
||||
@ -622,17 +670,36 @@ pub struct ListSpendResult {
|
||||
pub spend_txs: Vec<ListSpendEntry>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ListTransactionsResult {
|
||||
pub transactions: Vec<TransactionInfo>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct TransactionInfo {
|
||||
#[serde(serialize_with = "ser_hex", deserialize_with = "deser_hex")]
|
||||
pub tx: bitcoin::Transaction,
|
||||
pub height: Option<i32>,
|
||||
pub time: Option<u32>,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::testutils::*;
|
||||
use crate::{bitcoin::Block, database::SpendBlock, testutils::*};
|
||||
|
||||
use bitcoin::{
|
||||
blockdata::transaction::{TxIn, TxOut},
|
||||
util::bip32::ChildNumber,
|
||||
OutPoint, PackedLockTime, Script, Sequence, Transaction, Txid, Witness,
|
||||
};
|
||||
use std::str::FromStr;
|
||||
|
||||
use bitcoin::util::bip32;
|
||||
|
||||
#[test]
|
||||
fn getinfo() {
|
||||
let ms = DummyLiana::new();
|
||||
let ms = DummyLiana::new(DummyBitcoind::new(), DummyDatabase::new());
|
||||
// We can query getinfo
|
||||
ms.handle.control.get_info();
|
||||
ms.shutdown();
|
||||
@ -640,7 +707,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn getnewaddress() {
|
||||
let ms = DummyLiana::new();
|
||||
let ms = DummyLiana::new(DummyBitcoind::new(), DummyDatabase::new());
|
||||
|
||||
let control = &ms.handle.control;
|
||||
// We can get an address
|
||||
@ -661,7 +728,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn create_spend() {
|
||||
let ms = DummyLiana::new();
|
||||
let ms = DummyLiana::new(DummyBitcoind::new(), DummyDatabase::new());
|
||||
let control = &ms.handle.control;
|
||||
|
||||
// Arguments sanity checking
|
||||
@ -777,7 +844,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn update_spend() {
|
||||
let ms = DummyLiana::new();
|
||||
let ms = DummyLiana::new(DummyBitcoind::new(), DummyDatabase::new());
|
||||
let control = &ms.handle.control;
|
||||
let mut db_conn = control.db().lock().unwrap().connection();
|
||||
|
||||
@ -888,4 +955,353 @@ mod tests {
|
||||
|
||||
ms.shutdown();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn list_confirmed_transactions() {
|
||||
let outpoint = OutPoint::new(
|
||||
Txid::from_str("617eab1fc0b03ee7f82ba70166725291783461f1a0e7975eaf8b5f8f674234f3")
|
||||
.unwrap(),
|
||||
0,
|
||||
);
|
||||
|
||||
let deposit1: Transaction = Transaction {
|
||||
version: 1,
|
||||
lock_time: PackedLockTime(1),
|
||||
input: vec![TxIn {
|
||||
witness: Witness::new(),
|
||||
previous_output: outpoint,
|
||||
script_sig: Script::new(),
|
||||
sequence: Sequence(0),
|
||||
}],
|
||||
output: vec![TxOut {
|
||||
script_pubkey: Script::new(),
|
||||
value: 100_000_000,
|
||||
}],
|
||||
};
|
||||
|
||||
let deposit2: Transaction = Transaction {
|
||||
version: 1,
|
||||
lock_time: PackedLockTime(1),
|
||||
input: vec![TxIn {
|
||||
witness: Witness::new(),
|
||||
previous_output: outpoint,
|
||||
script_sig: Script::new(),
|
||||
sequence: Sequence(0),
|
||||
}],
|
||||
output: vec![TxOut {
|
||||
script_pubkey: Script::new(),
|
||||
value: 2000,
|
||||
}],
|
||||
};
|
||||
|
||||
let deposit3: Transaction = Transaction {
|
||||
version: 1,
|
||||
lock_time: PackedLockTime(1),
|
||||
input: vec![TxIn {
|
||||
witness: Witness::new(),
|
||||
previous_output: outpoint,
|
||||
script_sig: Script::new(),
|
||||
sequence: Sequence(0),
|
||||
}],
|
||||
output: vec![TxOut {
|
||||
script_pubkey: Script::new(),
|
||||
value: 3000,
|
||||
}],
|
||||
};
|
||||
|
||||
let spend_tx: Transaction = Transaction {
|
||||
version: 1,
|
||||
lock_time: PackedLockTime(1),
|
||||
input: vec![TxIn {
|
||||
witness: Witness::new(),
|
||||
previous_output: OutPoint {
|
||||
txid: deposit1.txid(),
|
||||
vout: 0,
|
||||
},
|
||||
script_sig: Script::new(),
|
||||
sequence: Sequence(0),
|
||||
}],
|
||||
output: vec![
|
||||
TxOut {
|
||||
script_pubkey: Script::new(),
|
||||
value: 4000,
|
||||
},
|
||||
TxOut {
|
||||
script_pubkey: Script::new(),
|
||||
value: 100_000_000 - 4000 - 1000,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
let mut db = DummyDatabase::new();
|
||||
db.insert_coins(vec![
|
||||
// Deposit 1
|
||||
Coin {
|
||||
is_change: false,
|
||||
outpoint: OutPoint {
|
||||
txid: deposit1.txid(),
|
||||
vout: 0,
|
||||
},
|
||||
block_time: Some(1),
|
||||
block_height: Some(1),
|
||||
spend_block: Some(SpendBlock { time: 3, height: 3 }),
|
||||
derivation_index: ChildNumber::from(0),
|
||||
amount: bitcoin::Amount::from_sat(100_000_000),
|
||||
spend_txid: Some(spend_tx.txid()),
|
||||
},
|
||||
// Deposit 2
|
||||
Coin {
|
||||
is_change: false,
|
||||
outpoint: OutPoint {
|
||||
txid: deposit2.txid(),
|
||||
vout: 0,
|
||||
},
|
||||
block_time: Some(2),
|
||||
block_height: Some(2),
|
||||
spend_block: None,
|
||||
derivation_index: ChildNumber::from(1),
|
||||
amount: bitcoin::Amount::from_sat(2000),
|
||||
spend_txid: None,
|
||||
},
|
||||
// This coin is a change output.
|
||||
Coin {
|
||||
is_change: true,
|
||||
outpoint: OutPoint::new(spend_tx.txid(), 1),
|
||||
block_time: Some(3),
|
||||
block_height: Some(3),
|
||||
spend_block: None,
|
||||
derivation_index: ChildNumber::from(2),
|
||||
amount: bitcoin::Amount::from_sat(100_000_000 - 4000 - 1000),
|
||||
spend_txid: None,
|
||||
},
|
||||
// Deposit 3
|
||||
Coin {
|
||||
is_change: false,
|
||||
outpoint: OutPoint {
|
||||
txid: deposit3.txid(),
|
||||
vout: 0,
|
||||
},
|
||||
block_time: Some(4),
|
||||
block_height: Some(4),
|
||||
spend_block: None,
|
||||
derivation_index: ChildNumber::from(3),
|
||||
amount: bitcoin::Amount::from_sat(3000),
|
||||
spend_txid: None,
|
||||
},
|
||||
]);
|
||||
|
||||
let mut btc = DummyBitcoind::new();
|
||||
btc.txs.insert(
|
||||
deposit1.txid(),
|
||||
(
|
||||
deposit1.clone(),
|
||||
Some(Block {
|
||||
hash: bitcoin::BlockHash::from_str(
|
||||
"0000000000000000000326b8fca8d3f820647c97ea33ef722096b3c7b2c8ee94",
|
||||
)
|
||||
.unwrap(),
|
||||
time: 1,
|
||||
height: 1,
|
||||
}),
|
||||
),
|
||||
);
|
||||
btc.txs.insert(
|
||||
deposit2.txid(),
|
||||
(
|
||||
deposit2.clone(),
|
||||
Some(Block {
|
||||
hash: bitcoin::BlockHash::from_str(
|
||||
"0000000000000000000326b8fca8d3f820647c97ea33ef722096b3c7b2c8ee94",
|
||||
)
|
||||
.unwrap(),
|
||||
time: 2,
|
||||
height: 2,
|
||||
}),
|
||||
),
|
||||
);
|
||||
btc.txs.insert(
|
||||
spend_tx.txid(),
|
||||
(
|
||||
spend_tx.clone(),
|
||||
Some(Block {
|
||||
hash: bitcoin::BlockHash::from_str(
|
||||
"0000000000000000000326b8fca8d3f820647c97ea33ef722096b3c7b2c8ee94",
|
||||
)
|
||||
.unwrap(),
|
||||
time: 3,
|
||||
height: 3,
|
||||
}),
|
||||
),
|
||||
);
|
||||
btc.txs.insert(
|
||||
deposit3.txid(),
|
||||
(
|
||||
deposit3.clone(),
|
||||
Some(Block {
|
||||
hash: bitcoin::BlockHash::from_str(
|
||||
"0000000000000000000326b8fca8d3f820647c97ea33ef722096b3c7b2c8ee94",
|
||||
)
|
||||
.unwrap(),
|
||||
time: 4,
|
||||
height: 4,
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
let ms = DummyLiana::new(btc, db);
|
||||
|
||||
let control = &ms.handle.control;
|
||||
|
||||
let transactions = control.list_confirmed_transactions(0, 4, 10).transactions;
|
||||
assert_eq!(transactions.len(), 4);
|
||||
|
||||
assert_eq!(transactions[0].time, Some(4));
|
||||
assert_eq!(transactions[0].tx, deposit3);
|
||||
|
||||
assert_eq!(transactions[1].time, Some(3));
|
||||
assert_eq!(transactions[1].tx, spend_tx);
|
||||
|
||||
assert_eq!(transactions[2].time, Some(2));
|
||||
assert_eq!(transactions[2].tx, deposit2);
|
||||
|
||||
assert_eq!(transactions[3].time, Some(1));
|
||||
assert_eq!(transactions[3].tx, deposit1);
|
||||
|
||||
let transactions = control.list_confirmed_transactions(2, 3, 10).transactions;
|
||||
assert_eq!(transactions.len(), 2);
|
||||
|
||||
assert_eq!(transactions[0].time, Some(3));
|
||||
assert_eq!(transactions[1].time, Some(2));
|
||||
assert_eq!(transactions[1].tx, deposit2);
|
||||
|
||||
let transactions = control.list_confirmed_transactions(2, 3, 1).transactions;
|
||||
assert_eq!(transactions.len(), 1);
|
||||
|
||||
assert_eq!(transactions[0].time, Some(3));
|
||||
assert_eq!(transactions[0].tx, spend_tx);
|
||||
|
||||
ms.shutdown();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn list_transactions() {
|
||||
let outpoint = OutPoint::new(
|
||||
Txid::from_str("617eab1fc0b03ee7f82ba70166725291783461f1a0e7975eaf8b5f8f674234f3")
|
||||
.unwrap(),
|
||||
0,
|
||||
);
|
||||
|
||||
let tx1: Transaction = Transaction {
|
||||
version: 1,
|
||||
lock_time: PackedLockTime(1),
|
||||
input: vec![TxIn {
|
||||
witness: Witness::new(),
|
||||
previous_output: outpoint,
|
||||
script_sig: Script::new(),
|
||||
sequence: Sequence(0),
|
||||
}],
|
||||
output: vec![TxOut {
|
||||
script_pubkey: Script::new(),
|
||||
value: 100_000_000,
|
||||
}],
|
||||
};
|
||||
|
||||
let tx2: Transaction = Transaction {
|
||||
version: 1,
|
||||
lock_time: PackedLockTime(1),
|
||||
input: vec![TxIn {
|
||||
witness: Witness::new(),
|
||||
previous_output: outpoint,
|
||||
script_sig: Script::new(),
|
||||
sequence: Sequence(0),
|
||||
}],
|
||||
output: vec![TxOut {
|
||||
script_pubkey: Script::new(),
|
||||
value: 2000,
|
||||
}],
|
||||
};
|
||||
|
||||
let tx3: Transaction = Transaction {
|
||||
version: 1,
|
||||
lock_time: PackedLockTime(1),
|
||||
input: vec![TxIn {
|
||||
witness: Witness::new(),
|
||||
previous_output: outpoint,
|
||||
script_sig: Script::new(),
|
||||
sequence: Sequence(0),
|
||||
}],
|
||||
output: vec![TxOut {
|
||||
script_pubkey: Script::new(),
|
||||
value: 3000,
|
||||
}],
|
||||
};
|
||||
|
||||
let mut btc = DummyBitcoind::new();
|
||||
btc.txs.insert(
|
||||
tx1.txid(),
|
||||
(
|
||||
tx1.clone(),
|
||||
Some(Block {
|
||||
hash: bitcoin::BlockHash::from_str(
|
||||
"0000000000000000000326b8fca8d3f820647c97ea33ef722096b3c7b2c8ee94",
|
||||
)
|
||||
.unwrap(),
|
||||
time: 1,
|
||||
height: 1,
|
||||
}),
|
||||
),
|
||||
);
|
||||
btc.txs.insert(
|
||||
tx2.txid(),
|
||||
(
|
||||
tx2.clone(),
|
||||
Some(Block {
|
||||
hash: bitcoin::BlockHash::from_str(
|
||||
"0000000000000000000326b8fca8d3f820647c97ea33ef722096b3c7b2c8ee94",
|
||||
)
|
||||
.unwrap(),
|
||||
time: 2,
|
||||
height: 2,
|
||||
}),
|
||||
),
|
||||
);
|
||||
btc.txs.insert(
|
||||
tx3.txid(),
|
||||
(
|
||||
tx3.clone(),
|
||||
Some(Block {
|
||||
hash: bitcoin::BlockHash::from_str(
|
||||
"0000000000000000000326b8fca8d3f820647c97ea33ef722096b3c7b2c8ee94",
|
||||
)
|
||||
.unwrap(),
|
||||
time: 4,
|
||||
height: 4,
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
let ms = DummyLiana::new(btc, DummyDatabase::new());
|
||||
|
||||
let control = &ms.handle.control;
|
||||
|
||||
let transactions = control.list_transactions(&[tx1.txid()]).transactions;
|
||||
assert_eq!(transactions.len(), 1);
|
||||
assert_eq!(transactions[0].tx, tx1);
|
||||
|
||||
let transactions = control
|
||||
.list_transactions(&[tx1.txid(), tx2.txid(), tx3.txid()])
|
||||
.transactions;
|
||||
assert_eq!(transactions.len(), 3);
|
||||
|
||||
let txs: Vec<Transaction> = transactions
|
||||
.iter()
|
||||
.map(|transaction| transaction.tx.clone())
|
||||
.collect();
|
||||
|
||||
assert!(txs.contains(&tx1));
|
||||
assert!(txs.contains(&tx2));
|
||||
assert!(txs.contains(&tx3));
|
||||
|
||||
ms.shutdown();
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,6 +1,8 @@
|
||||
use crate::database::DatabaseConnection;
|
||||
|
||||
use miniscript::bitcoin::{self, consensus, util::psbt::PartiallySignedTransaction as Psbt};
|
||||
use miniscript::bitcoin::{
|
||||
self, consensus, hashes::hex::FromHex, util::psbt::PartiallySignedTransaction as Psbt,
|
||||
};
|
||||
use serde::{de, Deserialize, Deserializer, Serializer};
|
||||
|
||||
/// Serialize an amount as sats
|
||||
@ -25,14 +27,32 @@ where
|
||||
s.serialize_str(&base64::encode(consensus::serialize(&t)))
|
||||
}
|
||||
|
||||
pub fn deser_psbt_base64<'de, D>(d: D) -> Result<Psbt, D::Error>
|
||||
pub fn deser_base64<'de, D, T>(d: D) -> Result<T, D::Error>
|
||||
where
|
||||
D: Deserializer<'de>,
|
||||
T: consensus::Decodable,
|
||||
{
|
||||
let s = String::deserialize(d)?;
|
||||
let s = base64::decode(&s).map_err(de::Error::custom)?;
|
||||
let psbt = consensus::deserialize(&s).map_err(de::Error::custom)?;
|
||||
Ok(psbt)
|
||||
consensus::deserialize(&s).map_err(de::Error::custom)
|
||||
}
|
||||
|
||||
pub fn ser_hex<S, T>(t: T, s: S) -> Result<S::Ok, S::Error>
|
||||
where
|
||||
S: Serializer,
|
||||
T: consensus::Encodable,
|
||||
{
|
||||
s.serialize_str(&consensus::encode::serialize_hex(&t))
|
||||
}
|
||||
|
||||
pub fn deser_hex<'de, D, T>(d: D) -> Result<T, D::Error>
|
||||
where
|
||||
D: Deserializer<'de>,
|
||||
T: consensus::Decodable,
|
||||
{
|
||||
let s = String::deserialize(d)?;
|
||||
let s = Vec::from_hex(&s).map_err(de::Error::custom)?;
|
||||
consensus::deserialize(&s).map_err(de::Error::custom)
|
||||
}
|
||||
|
||||
// Utility to gather the index of a change output in a Psbt, if there is one.
|
||||
|
||||
@ -117,6 +117,9 @@ pub trait DatabaseConnection {
|
||||
|
||||
/// Mark the given tip as the new best seen block. Update stored data accordingly.
|
||||
fn rollback_tip(&mut self, new_tip: &BlockChainTip);
|
||||
|
||||
/// Retrieve a limited list of txids that where deposited or spent between the start and end timestamps (inclusive bounds)
|
||||
fn list_txids(&mut self, start: u32, end: u32, limit: u64) -> Vec<bitcoin::Txid>;
|
||||
}
|
||||
|
||||
impl DatabaseConnection for SqliteConn {
|
||||
@ -245,6 +248,10 @@ impl DatabaseConnection for SqliteConn {
|
||||
fn rollback_tip(&mut self, new_tip: &BlockChainTip) {
|
||||
self.rollback_tip(new_tip)
|
||||
}
|
||||
|
||||
fn list_txids(&mut self, start: u32, end: u32, limit: u64) -> Vec<bitcoin::Txid> {
|
||||
self.db_list_txids(start, end, limit)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
|
||||
@ -490,6 +490,38 @@ impl SqliteConn {
|
||||
.expect("Db must not fail")
|
||||
}
|
||||
|
||||
/// Retrieves a limited and ordered list of transactions ids that happened during the given
|
||||
/// range.
|
||||
pub fn db_list_txids(&mut self, start: u32, end: u32, limit: u64) -> Vec<bitcoin::Txid> {
|
||||
db_query(
|
||||
&mut self.conn,
|
||||
"SELECT DISTINCT(txid) FROM ( \
|
||||
SELECT * from ( \
|
||||
SELECT txid, blocktime AS date FROM coins \
|
||||
WHERE blocktime >= (?1) \
|
||||
AND blocktime <= (?2) \
|
||||
ORDER BY blocktime \
|
||||
) \
|
||||
UNION \
|
||||
SELECT * FROM (
|
||||
SELECT spend_txid AS txid, spend_block_time AS date FROM coins \
|
||||
WHERE spend_block_time >= (?1) \
|
||||
AND spend_block_time <= (?2) \
|
||||
ORDER BY spend_block_time \
|
||||
) \
|
||||
ORDER BY date DESC LIMIT (?3) \
|
||||
)",
|
||||
rusqlite::params![start, end, limit],
|
||||
|row| {
|
||||
let txid: Vec<u8> = row.get(0)?;
|
||||
let txid: bitcoin::Txid =
|
||||
encode::deserialize(&txid).expect("We only store valid txids");
|
||||
Ok(txid)
|
||||
},
|
||||
)
|
||||
.expect("Db must not fail")
|
||||
}
|
||||
|
||||
pub fn delete_spend(&mut self, txid: &bitcoin::Txid) {
|
||||
db_exec(&mut self.conn, |db_tx| {
|
||||
db_tx.execute(
|
||||
@ -1089,4 +1121,157 @@ mod tests {
|
||||
|
||||
fs::remove_dir_all(&tmp_dir).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sqlite_list_txids() {
|
||||
let (tmp_dir, _, _, db) = dummy_db();
|
||||
|
||||
{
|
||||
let mut conn = db.connection().unwrap();
|
||||
|
||||
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(),
|
||||
is_change: false,
|
||||
spend_txid: None,
|
||||
spend_block: None,
|
||||
},
|
||||
Coin {
|
||||
outpoint: bitcoin::OutPoint::from_str(
|
||||
"c449539458c60bee6c0d8905ba1dadb20b9187b82045d306a408b894cea492b0:2",
|
||||
)
|
||||
.unwrap(),
|
||||
block_height: Some(101_095),
|
||||
block_time: Some(1_121_000),
|
||||
amount: bitcoin::Amount::from_sat(98765),
|
||||
derivation_index: bip32::ChildNumber::from_normal_idx(100).unwrap(),
|
||||
is_change: false,
|
||||
spend_txid: None,
|
||||
spend_block: None,
|
||||
},
|
||||
Coin {
|
||||
outpoint: bitcoin::OutPoint::from_str(
|
||||
"f0801fd9ca8bca0624c230ab422b2e2c4c8dc995e4e1dbc6412510959cce1e4f:3",
|
||||
)
|
||||
.unwrap(),
|
||||
block_height: Some(101_099),
|
||||
block_time: Some(1_122_000),
|
||||
amount: bitcoin::Amount::from_sat(98765),
|
||||
derivation_index: bip32::ChildNumber::from_normal_idx(1000).unwrap(),
|
||||
is_change: false,
|
||||
spend_txid: Some(
|
||||
bitcoin::Txid::from_str(
|
||||
"0c62a990d20d54429e70859292e82374ba6b1b951a3ab60f26bb65fee5724ff7",
|
||||
)
|
||||
.unwrap(),
|
||||
),
|
||||
spend_block: Some(SpendBlock {
|
||||
height: 101_199,
|
||||
time: 1_123_000,
|
||||
}),
|
||||
},
|
||||
Coin {
|
||||
outpoint: bitcoin::OutPoint::from_str(
|
||||
"19f56e65069f0a7a3bfb00c6a7085cc0669e03e91befeca1ee9891c9e737b2fb:4",
|
||||
)
|
||||
.unwrap(),
|
||||
block_height: Some(101_100),
|
||||
block_time: Some(1_124_000),
|
||||
amount: bitcoin::Amount::from_sat(98765),
|
||||
derivation_index: bip32::ChildNumber::from_normal_idx(10000).unwrap(),
|
||||
is_change: false,
|
||||
spend_txid: None,
|
||||
spend_block: None,
|
||||
},
|
||||
Coin {
|
||||
outpoint: bitcoin::OutPoint::from_str(
|
||||
"ed6c8f1af9325f84de521e785e7ddfd33dc28c9ada4d687dcd3850100bde54e9:5",
|
||||
)
|
||||
.unwrap(),
|
||||
block_height: Some(101_102),
|
||||
block_time: Some(1_125_000),
|
||||
amount: bitcoin::Amount::from_sat(98765),
|
||||
derivation_index: bip32::ChildNumber::from_normal_idx(100000).unwrap(),
|
||||
is_change: false,
|
||||
spend_txid: Some(
|
||||
bitcoin::Txid::from_str(
|
||||
"7477017f992cdc7ba08acafb77cb3b5bc0f42ac340d3e1e1da0785bdda20d5f6",
|
||||
)
|
||||
.unwrap(),
|
||||
),
|
||||
spend_block: Some(SpendBlock {
|
||||
height: 101_105,
|
||||
time: 1_126_000,
|
||||
}),
|
||||
},
|
||||
];
|
||||
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 db_txids = conn.db_list_txids(1_123_000, 1_127_000, 10);
|
||||
assert_eq!(
|
||||
&db_txids[..],
|
||||
&[
|
||||
bitcoin::Txid::from_str(
|
||||
"7477017f992cdc7ba08acafb77cb3b5bc0f42ac340d3e1e1da0785bdda20d5f6"
|
||||
)
|
||||
.unwrap(),
|
||||
bitcoin::Txid::from_str(
|
||||
"ed6c8f1af9325f84de521e785e7ddfd33dc28c9ada4d687dcd3850100bde54e9"
|
||||
)
|
||||
.unwrap(),
|
||||
bitcoin::Txid::from_str(
|
||||
"19f56e65069f0a7a3bfb00c6a7085cc0669e03e91befeca1ee9891c9e737b2fb"
|
||||
)
|
||||
.unwrap(),
|
||||
bitcoin::Txid::from_str(
|
||||
"0c62a990d20d54429e70859292e82374ba6b1b951a3ab60f26bb65fee5724ff7"
|
||||
)
|
||||
.unwrap()
|
||||
]
|
||||
);
|
||||
|
||||
let db_txids = conn.db_list_txids(1_123_000, 1_127_000, 2);
|
||||
assert_eq!(
|
||||
&db_txids[..],
|
||||
&[
|
||||
bitcoin::Txid::from_str(
|
||||
"7477017f992cdc7ba08acafb77cb3b5bc0f42ac340d3e1e1da0785bdda20d5f6"
|
||||
)
|
||||
.unwrap(),
|
||||
bitcoin::Txid::from_str(
|
||||
"ed6c8f1af9325f84de521e785e7ddfd33dc28c9ada4d687dcd3850100bde54e9"
|
||||
)
|
||||
.unwrap(),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
fs::remove_dir_all(&tmp_dir).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
@ -84,6 +84,49 @@ fn broadcast_spend(control: &DaemonControl, params: Params) -> Result<serde_json
|
||||
Ok(serde_json::json!({}))
|
||||
}
|
||||
|
||||
fn list_confirmed(control: &DaemonControl, params: Params) -> Result<serde_json::Value, Error> {
|
||||
let start: u32 = params
|
||||
.get(0, "start")
|
||||
.ok_or_else(|| Error::invalid_params("Missing 'start' parameter."))?
|
||||
.as_i64()
|
||||
.and_then(|i| i.try_into().ok())
|
||||
.ok_or_else(|| Error::invalid_params("Invalid 'start' parameter."))?;
|
||||
|
||||
let end: u32 = params
|
||||
.get(1, "end")
|
||||
.ok_or_else(|| Error::invalid_params("Missing 'end' parameter."))?
|
||||
.as_i64()
|
||||
.and_then(|i| i.try_into().ok())
|
||||
.ok_or_else(|| Error::invalid_params("Invalid 'end' parameter."))?;
|
||||
|
||||
let limit: u64 = params
|
||||
.get(2, "limit")
|
||||
.ok_or_else(|| Error::invalid_params("Missing 'limit' parameter."))?
|
||||
.as_i64()
|
||||
.and_then(|i| i.try_into().ok())
|
||||
.ok_or_else(|| Error::invalid_params("Invalid 'limit' parameter."))?;
|
||||
|
||||
Ok(serde_json::json!(&control.list_confirmed_transactions(
|
||||
start as u32,
|
||||
end as u32,
|
||||
limit
|
||||
)))
|
||||
}
|
||||
|
||||
fn list_transactions(control: &DaemonControl, params: Params) -> Result<serde_json::Value, Error> {
|
||||
let txids: Vec<bitcoin::Txid> = params
|
||||
.get(0, "txids")
|
||||
.ok_or_else(|| Error::invalid_params("Missing 'txids' parameter."))?
|
||||
.as_array()
|
||||
.and_then(|arr| {
|
||||
arr.iter()
|
||||
.map(|entry| entry.as_str().and_then(|e| bitcoin::Txid::from_str(e).ok()))
|
||||
.collect()
|
||||
})
|
||||
.ok_or_else(|| Error::invalid_params("Invalid 'txids' parameter."))?;
|
||||
Ok(serde_json::json!(&control.list_transactions(&txids)))
|
||||
}
|
||||
|
||||
fn start_rescan(control: &DaemonControl, params: Params) -> Result<serde_json::Value, Error> {
|
||||
let timestamp: u32 = params
|
||||
.get(0, "timestamp")
|
||||
@ -136,6 +179,22 @@ pub fn handle_request(control: &DaemonControl, req: Request) -> Result<Response,
|
||||
.ok_or_else(|| Error::invalid_params("Missing 'psbt' parameter."))?;
|
||||
update_spend(control, params)?
|
||||
}
|
||||
"listconfirmed" => {
|
||||
let params = req.params.ok_or_else(|| {
|
||||
Error::invalid_params(
|
||||
"The 'listconfirmed' command requires 3 parameters: 'start', 'end' and 'limit'",
|
||||
)
|
||||
})?;
|
||||
list_confirmed(control, params)?
|
||||
}
|
||||
"listtransactions" => {
|
||||
let params = req.params.ok_or_else(|| {
|
||||
Error::invalid_params(
|
||||
"The 'listtransactions' command requires 1 parameter: 'txids'",
|
||||
)
|
||||
})?;
|
||||
list_transactions(control, params)?
|
||||
}
|
||||
_ => {
|
||||
return Err(Error::method_not_found());
|
||||
}
|
||||
|
||||
@ -400,7 +400,7 @@ mod tests {
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
#[test]
|
||||
fn server_sanity_check() {
|
||||
let ms = DummyLiana::new();
|
||||
let ms = DummyLiana::new(DummyBitcoind::new(), DummyDatabase::new());
|
||||
let socket_path: path::PathBuf = [
|
||||
ms.tmp_dir.as_path(),
|
||||
path::Path::new("d"),
|
||||
|
||||
120
src/testutils.rs
120
src/testutils.rs
@ -1,5 +1,5 @@
|
||||
use crate::{
|
||||
bitcoin::{BitcoinInterface, BlockChainTip, UTxO},
|
||||
bitcoin::{BitcoinInterface, Block, BlockChainTip, UTxO},
|
||||
config::{BitcoinConfig, Config},
|
||||
database::{Coin, DatabaseConnection, DatabaseInterface, SpendBlock},
|
||||
descriptors, DaemonHandle,
|
||||
@ -11,11 +11,24 @@ use miniscript::{
|
||||
bitcoin::{
|
||||
self, secp256k1,
|
||||
util::{bip32, psbt::PartiallySignedTransaction as Psbt},
|
||||
Transaction, Txid,
|
||||
},
|
||||
descriptor,
|
||||
};
|
||||
|
||||
pub struct DummyBitcoind {}
|
||||
pub struct DummyBitcoind {
|
||||
pub txs: HashMap<Txid, (Transaction, Option<Block>)>,
|
||||
}
|
||||
|
||||
impl DummyBitcoind {}
|
||||
|
||||
impl DummyBitcoind {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
txs: HashMap::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl BitcoinInterface for DummyBitcoind {
|
||||
fn genesis_block(&self) -> BlockChainTip {
|
||||
@ -63,7 +76,7 @@ impl BitcoinInterface for DummyBitcoind {
|
||||
fn spent_coins(
|
||||
&self,
|
||||
_: &[(bitcoin::OutPoint, bitcoin::Txid)],
|
||||
) -> Vec<(bitcoin::OutPoint, bitcoin::Txid, i32, u32)> {
|
||||
) -> Vec<(bitcoin::OutPoint, bitcoin::Txid, Block)> {
|
||||
Vec::new()
|
||||
}
|
||||
|
||||
@ -90,9 +103,16 @@ impl BitcoinInterface for DummyBitcoind {
|
||||
fn tip_time(&self) -> u32 {
|
||||
todo!()
|
||||
}
|
||||
|
||||
fn wallet_transaction(
|
||||
&self,
|
||||
txid: &bitcoin::Txid,
|
||||
) -> Option<(bitcoin::Transaction, Option<Block>)> {
|
||||
self.txs.get(txid).cloned()
|
||||
}
|
||||
}
|
||||
|
||||
pub struct DummyDb {
|
||||
struct DummyDbState {
|
||||
deposit_index: bip32::ChildNumber,
|
||||
change_index: bip32::ChildNumber,
|
||||
curr_tip: Option<BlockChainTip>,
|
||||
@ -100,35 +120,39 @@ pub struct DummyDb {
|
||||
spend_txs: HashMap<bitcoin::Txid, Psbt>,
|
||||
}
|
||||
|
||||
impl DummyDb {
|
||||
pub fn new() -> DummyDb {
|
||||
DummyDb {
|
||||
deposit_index: 0.into(),
|
||||
change_index: 0.into(),
|
||||
curr_tip: None,
|
||||
coins: HashMap::new(),
|
||||
spend_txs: HashMap::new(),
|
||||
pub struct DummyDatabase {
|
||||
db: sync::Arc<sync::RwLock<DummyDbState>>,
|
||||
}
|
||||
|
||||
impl DatabaseInterface for DummyDatabase {
|
||||
fn connection(&self) -> Box<dyn DatabaseConnection> {
|
||||
Box::new(DummyDatabase {
|
||||
db: self.db.clone(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl DummyDatabase {
|
||||
pub fn new() -> DummyDatabase {
|
||||
DummyDatabase {
|
||||
db: sync::Arc::new(sync::RwLock::new(DummyDbState {
|
||||
deposit_index: 0.into(),
|
||||
change_index: 0.into(),
|
||||
curr_tip: None,
|
||||
coins: HashMap::new(),
|
||||
spend_txs: HashMap::new(),
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn insert_coins(&mut self, coins: Vec<Coin>) {
|
||||
for coin in coins {
|
||||
self.db.write().unwrap().coins.insert(coin.outpoint, coin);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for DummyDb {
|
||||
fn default() -> DummyDb {
|
||||
DummyDb::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl DatabaseInterface for sync::Arc<sync::RwLock<DummyDb>> {
|
||||
fn connection(&self) -> Box<dyn DatabaseConnection> {
|
||||
Box::new(DummyDbConn { db: self.clone() })
|
||||
}
|
||||
}
|
||||
|
||||
pub struct DummyDbConn {
|
||||
db: sync::Arc<sync::RwLock<DummyDb>>,
|
||||
}
|
||||
|
||||
impl DatabaseConnection for DummyDbConn {
|
||||
impl DatabaseConnection for DummyDatabase {
|
||||
fn network(&mut self) -> bitcoin::Network {
|
||||
bitcoin::Network::Bitcoin
|
||||
}
|
||||
@ -284,6 +308,35 @@ impl DatabaseConnection for DummyDbConn {
|
||||
fn complete_rescan(&mut self) {
|
||||
todo!()
|
||||
}
|
||||
|
||||
fn list_txids(&mut self, start: u32, end: u32, limit: u64) -> Vec<bitcoin::Txid> {
|
||||
let mut txids_and_time = Vec::new();
|
||||
let coins = &self.db.read().unwrap().coins;
|
||||
// Get txid and block time of every transactions that happened between start and end
|
||||
// timestamps.
|
||||
for coin in coins.values() {
|
||||
if let Some(time) = coin.block_time {
|
||||
if time >= start && time <= end {
|
||||
let row = (coin.outpoint.txid, time);
|
||||
if !txids_and_time.contains(&row) {
|
||||
txids_and_time.push(row);
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(time) = coin.spend_block.map(|b| b.time) {
|
||||
if time >= start && time <= end {
|
||||
let row = (coin.spend_txid.expect("spent_at is not none"), time);
|
||||
if !txids_and_time.contains(&row) {
|
||||
txids_and_time.push(row);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Apply order and limit
|
||||
txids_and_time.sort_by(|(_, t1), (_, t2)| t2.cmp(t1));
|
||||
txids_and_time.truncate(limit as usize);
|
||||
txids_and_time.into_iter().map(|(txid, _)| txid).collect()
|
||||
}
|
||||
}
|
||||
|
||||
pub struct DummyLiana {
|
||||
@ -310,7 +363,11 @@ pub fn tmp_dir() -> path::PathBuf {
|
||||
}
|
||||
|
||||
impl DummyLiana {
|
||||
pub fn new() -> DummyLiana {
|
||||
/// Creates a new DummyLiana interface
|
||||
pub fn new(
|
||||
bitcoin_interface: impl BitcoinInterface + 'static,
|
||||
database: impl DatabaseInterface + 'static,
|
||||
) -> DummyLiana {
|
||||
let tmp_dir = tmp_dir();
|
||||
fs::create_dir_all(&tmp_dir).unwrap();
|
||||
// Use a shorthand for 'datadir', to avoid overflowing SUN_LEN on MacOS.
|
||||
@ -336,8 +393,7 @@ impl DummyLiana {
|
||||
main_descriptor: desc,
|
||||
};
|
||||
|
||||
let db = sync::Arc::from(sync::RwLock::from(DummyDb::new()));
|
||||
let handle = DaemonHandle::start(config, Some(DummyBitcoind {}), Some(db)).unwrap();
|
||||
let handle = DaemonHandle::start(config, Some(bitcoin_interface), Some(database)).unwrap();
|
||||
DummyLiana { tmp_dir, handle }
|
||||
}
|
||||
|
||||
|
||||
@ -1,7 +1,5 @@
|
||||
import os
|
||||
import pytest
|
||||
import random
|
||||
import shutil
|
||||
import time
|
||||
|
||||
from fixtures import *
|
||||
@ -47,9 +45,7 @@ def test_listcoins(lianad, bitcoind):
|
||||
# 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: lianad.rpc.listcoins()["coins"][0]["block_height"] == block_height
|
||||
)
|
||||
wait_for(lambda: lianad.rpc.listcoins()["coins"][0]["block_height"] == block_height)
|
||||
|
||||
# Same if the coin gets spent.
|
||||
spend_tx = spend_coins(lianad, bitcoind, (res[0],))
|
||||
@ -356,3 +352,173 @@ def test_start_rescan(lianad, bitcoind):
|
||||
# Now that it caught up it noticed which one were used onchain, so it won't reuse
|
||||
# this derivation indexes anymore.
|
||||
assert lianad.rpc.getnewaddress() not in (first_address, second_address)
|
||||
|
||||
|
||||
def test_listtransactions(lianad, bitcoind):
|
||||
"""Test listing of transactions by txid and timespan"""
|
||||
|
||||
def sign_and_broadcast(psbt):
|
||||
txid = psbt.tx.txid().hex()
|
||||
psbt = lianad.sign_psbt(psbt)
|
||||
lianad.rpc.updatespend(psbt.to_base64())
|
||||
lianad.rpc.broadcastspend(txid)
|
||||
return txid
|
||||
|
||||
def wait_synced():
|
||||
wait_for(
|
||||
lambda: lianad.rpc.getinfo()["blockheight"] == bitcoind.rpc.getblockcount()
|
||||
)
|
||||
|
||||
best_block = bitcoind.rpc.getbestblockhash()
|
||||
initial_timestamp = bitcoind.rpc.getblockheader(best_block)["time"]
|
||||
wait_synced()
|
||||
|
||||
# Deposit multiple coins in a single transaction
|
||||
destinations = {
|
||||
lianad.rpc.getnewaddress()["address"]: 0.0123456,
|
||||
lianad.rpc.getnewaddress()["address"]: 0.0123457,
|
||||
lianad.rpc.getnewaddress()["address"]: 0.0123458,
|
||||
}
|
||||
txid = bitcoind.rpc.sendmany("", destinations)
|
||||
wait_for(lambda: len(lianad.rpc.listcoins()["coins"]) == 3)
|
||||
bitcoind.generate_block(1, wait_for_mempool=txid)
|
||||
|
||||
# Mine 12 blocks to force the blocktime to increase
|
||||
bitcoind.generate_block(12)
|
||||
wait_synced()
|
||||
best_block = bitcoind.rpc.getbestblockhash()
|
||||
second_timestamp = bitcoind.rpc.getblockheader(best_block)["time"]
|
||||
assert second_timestamp > initial_timestamp
|
||||
|
||||
# Deposit a coin that will be unspent
|
||||
addr = lianad.rpc.getnewaddress()["address"]
|
||||
txid = bitcoind.rpc.sendtoaddress(addr, 0.123456)
|
||||
wait_for(lambda: len(lianad.rpc.listcoins()["coins"]) == 4)
|
||||
bitcoind.generate_block(1, wait_for_mempool=txid)
|
||||
|
||||
# Deposit a coin that will be spent with a change output
|
||||
addr = lianad.rpc.getnewaddress()["address"]
|
||||
txid = bitcoind.rpc.sendtoaddress(addr, 0.23456)
|
||||
wait_for(lambda: len(lianad.rpc.listcoins()["coins"]) == 5)
|
||||
bitcoind.generate_block(1, wait_for_mempool=txid)
|
||||
outpoint = next(
|
||||
c["outpoint"] for c in lianad.rpc.listcoins()["coins"] if txid in c["outpoint"]
|
||||
)
|
||||
destinations = {
|
||||
bitcoind.rpc.getnewaddress(): 100_000,
|
||||
}
|
||||
res = lianad.rpc.createspend(destinations, [outpoint], 6)
|
||||
psbt = PSBT.from_base64(res["psbt"])
|
||||
txid = sign_and_broadcast(psbt)
|
||||
bitcoind.generate_block(1, wait_for_mempool=txid)
|
||||
|
||||
# Mine 12 blocks to force the blocktime to increase
|
||||
bitcoind.generate_block(12)
|
||||
wait_synced()
|
||||
best_block = bitcoind.rpc.getbestblockhash()
|
||||
third_timestamp = bitcoind.rpc.getblockheader(best_block)["time"]
|
||||
assert third_timestamp > second_timestamp
|
||||
bitcoind.generate_block(12)
|
||||
wait_synced()
|
||||
|
||||
# Deposit a coin that will be spent with a change output and also two new deposits
|
||||
addr = lianad.rpc.getnewaddress()["address"]
|
||||
txid = bitcoind.rpc.sendtoaddress(addr, 0.3456)
|
||||
wait_for(lambda: len(lianad.rpc.listcoins()["coins"]) == 7)
|
||||
bitcoind.generate_block(1, wait_for_mempool=txid)
|
||||
outpoint = next(
|
||||
c["outpoint"] for c in lianad.rpc.listcoins()["coins"] if txid in c["outpoint"]
|
||||
)
|
||||
destinations = {
|
||||
bitcoind.rpc.getnewaddress(): 11_000,
|
||||
addr: 12_000, # Even with address reuse! Booooh
|
||||
lianad.rpc.getnewaddress()["address"]: 13_000,
|
||||
}
|
||||
res = lianad.rpc.createspend(destinations, [outpoint], 6)
|
||||
psbt = PSBT.from_base64(res["psbt"])
|
||||
txid = sign_and_broadcast(psbt)
|
||||
bitcoind.generate_block(1, wait_for_mempool=txid)
|
||||
|
||||
# Deposit a coin that will be spending (unconfirmed spend transaction)
|
||||
addr = lianad.rpc.getnewaddress()["address"]
|
||||
txid = bitcoind.rpc.sendtoaddress(addr, 0.456)
|
||||
wait_for(lambda: len(lianad.rpc.listcoins()["coins"]) == 11)
|
||||
bitcoind.generate_block(1, wait_for_mempool=txid)
|
||||
outpoint = next(
|
||||
c["outpoint"] for c in lianad.rpc.listcoins()["coins"] if txid in c["outpoint"]
|
||||
)
|
||||
destinations = {
|
||||
bitcoind.rpc.getnewaddress(): 11_000,
|
||||
}
|
||||
res = lianad.rpc.createspend(destinations, [outpoint], 6)
|
||||
psbt = PSBT.from_base64(res["psbt"])
|
||||
txid = sign_and_broadcast(psbt)
|
||||
|
||||
# At this point we have 12 spent and unspent coins, one of them is unconfirmed.
|
||||
wait_for(lambda: len(lianad.rpc.listcoins()["coins"]) == 12)
|
||||
|
||||
# However some of them share the same txid! This is the case of the 3 first coins
|
||||
# for instance, or the Spend transactions with multiple outputs at one of our addresses.
|
||||
# In total, that's 8 transactions.
|
||||
txids = set(c["outpoint"][:-2] for c in lianad.rpc.listcoins()["coins"])
|
||||
assert len(txids) == 8
|
||||
|
||||
# We can query all of them at once using listtransactions. The result contains all
|
||||
# the correct transactions as hex, with no duplicate.
|
||||
all_txs = lianad.rpc.listtransactions(list(txids))["transactions"]
|
||||
assert len(all_txs) == 8
|
||||
for tx in all_txs:
|
||||
txid = bitcoind.rpc.decoderawtransaction(tx["tx"])["txid"]
|
||||
txids.remove(txid) # This will raise an error if it isn't there
|
||||
|
||||
# We can also query them one by one.
|
||||
txids = set(c["outpoint"][:-2] for c in lianad.rpc.listcoins()["coins"])
|
||||
for txid in txids:
|
||||
txs = lianad.rpc.listtransactions([txid])["transactions"]
|
||||
bit_txid = bitcoind.rpc.decoderawtransaction(txs[0]["tx"])["txid"]
|
||||
assert bit_txid == txid
|
||||
|
||||
# We can query all confirmed transactions
|
||||
best_block = bitcoind.rpc.getbestblockhash()
|
||||
final_timestamp = bitcoind.rpc.getblockheader(best_block)["time"]
|
||||
txs = lianad.rpc.listconfirmed(initial_timestamp, final_timestamp, 10)[
|
||||
"transactions"
|
||||
]
|
||||
assert len(txs) == 7, "The last spend tx is unconfirmed"
|
||||
for tx in txs:
|
||||
txid = bitcoind.rpc.decoderawtransaction(tx["tx"])["txid"]
|
||||
txids.remove(txid) # This will raise an error if it isn't there
|
||||
|
||||
# We can limit the size of the result
|
||||
txs = lianad.rpc.listconfirmed(initial_timestamp, final_timestamp, 5)[
|
||||
"transactions"
|
||||
]
|
||||
assert len(txs) == 5
|
||||
|
||||
# We can restrict the query to a certain time window.
|
||||
# First get the txid of all the transactions that happened during this timespan.
|
||||
txids = set()
|
||||
for coin in lianad.rpc.listcoins()["coins"]:
|
||||
if coin["block_height"] is None:
|
||||
continue
|
||||
block_hash = bitcoind.rpc.getblockhash(coin["block_height"])
|
||||
block_time = bitcoind.rpc.getblockheader(block_hash)["time"]
|
||||
spend_time = None
|
||||
if coin["spend_info"] is not None and coin["spend_info"]["height"] is not None:
|
||||
spend_bhash = bitcoind.rpc.getblockhash(coin["spend_info"]["height"])
|
||||
spend_time = bitcoind.rpc.getblockheader(spend_bhash)["time"]
|
||||
if (block_time >= second_timestamp and block_time <= third_timestamp) or (
|
||||
spend_time is not None
|
||||
and spend_time >= second_timestamp
|
||||
and spend_time <= third_timestamp
|
||||
):
|
||||
txids.add(coin["outpoint"][:-2])
|
||||
# It's all 7 minus the first deposit and the last confirmed spend. So that's 5 of them.
|
||||
assert len(txids) == 3
|
||||
# Now let's compare with what lianad is giving us.
|
||||
txs = lianad.rpc.listconfirmed(second_timestamp, third_timestamp, 10)[
|
||||
"transactions"
|
||||
]
|
||||
assert len(txs) == 3
|
||||
bit_txids = set(bitcoind.rpc.decoderawtransaction(tx["tx"])["txid"] for tx in txs)
|
||||
assert bit_txids == txids
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user