Merge #700: commands: optionally filter listcoins by status and outpoint

36e04edc8207df2b3b3c9fac78f11700514ee2bd db: update docstring for spend_coins (jp1ac4)
c492c51f26b55cc2fd1558bba2ea8a0ab2638d3f db: use coins() with filter in list_spending_coins() (jp1ac4)
eeaf90e5223049ee89832b5aa4a28a85d6c0f38e commands: optionally filter `listcoins` by status and/or outpoint (jp1ac4)

Pull request description:

  This is to resolve #676.

  I've renamed `CoinType` to `CoinStatus` and updated its values.

  The `listcoins` command and related functions can be optionally filtered by coin status and/or outpoint.

  Opening this as draft to check that I'm on the right track.

  Remaining tasks:
  - [x] Update tests
  - [x] Update API doc
  - [x] Possibly use the updated functions elsewhere, e.g. `list_spending_coins()`

ACKs for top commit:
  darosior:
    ACK 36e04edc8207df2b3b3c9fac78f11700514ee2bd

Tree-SHA512: 3f5692cd92c2b2011f845adb09523e86b5ea7b13a8bcf42452241b2e6229441c1d46efceb6138446cd7ddb083b3a8c9c2043b058f5caf691768f2f4b472e0fd0
This commit is contained in:
Antoine Poinsot 2023-09-13 10:05:32 +02:00
commit 3a59790530
No known key found for this signature in database
GPG Key ID: E13FC145CD3F4304
8 changed files with 818 additions and 101 deletions

View File

@ -81,14 +81,20 @@ This command does not take any parameter for now.
### `listcoins`
List all our transaction outputs, regardless of their state (unspent or not).
List all our transaction outputs, optionally filtered by status and/or outpoint.
#### Request
This command does not take any parameter for now.
| Field | Type | Description |
| -------------- | ----------------- | ----------------------------------------------------------------- |
| `statuses` | list of string | List of statuses to filter coins by (see below). |
| `outpoints` | list of string | List of outpoints to filter coins by, as `txid:vout`. |
| Field | Type | Description |
| ------------- | ----------------- | ----------------------------------------------------------- |
A coin may have one of the following four statuses:
- `unconfirmed`: deposit transaction has not yet been included in a block and coin has not been included in a spend transaction
- `confirmed`: deposit transaction has been included in a block and coin has not been included in a spend transaction
- `spending`: coin (whose deposit transaction may not yet have been confirmed) has been included in an unconfirmed spend transaction
- `spent`: coin has been included in a confirmed spend transaction
#### Response

View File

@ -1,6 +1,6 @@
use crate::{
bitcoin::{BitcoinInterface, BlockChainTip, UTxO},
database::{Coin, CoinType, DatabaseConnection, DatabaseInterface},
database::{Coin, DatabaseConnection, DatabaseInterface},
descriptors,
};
@ -34,7 +34,7 @@ fn update_coins(
secp: &secp256k1::Secp256k1<secp256k1::VerifyOnly>,
) -> UpdatedCoins {
let network = db_conn.network();
let curr_coins = db_conn.coins(CoinType::All);
let curr_coins = db_conn.coins(&[], &[]);
log::debug!("Current coins: {:?}", curr_coins);
// Start by fetching newly received coins.

View File

@ -6,11 +6,11 @@ mod utils;
use crate::{
bitcoin::BitcoinInterface,
database::{Coin, CoinType, DatabaseInterface},
database::{Coin, DatabaseInterface},
descriptors, DaemonControl, VERSION,
};
pub use crate::database::LabelItem;
pub use crate::database::{CoinStatus, LabelItem};
use utils::{
deser_addr_assume_checked, deser_amount_from_sats, deser_fromstr, deser_hex, ser_amount,
@ -289,11 +289,15 @@ impl DaemonControl {
GetAddressResult::new(address)
}
/// Get a list of all known coins.
pub fn list_coins(&self) -> ListCoinsResult {
/// Get a list of all known coins, optionally by status and/or outpoint.
pub fn list_coins(
&self,
statuses: &[CoinStatus],
outpoints: &[bitcoin::OutPoint],
) -> ListCoinsResult {
let mut db_conn = self.db.connection();
let coins: Vec<ListCoinsEntry> = db_conn
.coins(CoinType::All)
.coins(statuses, outpoints)
.into_values()
.map(|coin| {
let Coin {
@ -747,12 +751,15 @@ impl DaemonControl {
let timelock =
timelock.unwrap_or_else(|| self.config.main_descriptor.first_timelock_value());
let height_delta: i32 = timelock.try_into().expect("Must fit, it's a u16");
let sweepable_coins = db_conn.coins(CoinType::Unspent).into_values().filter(|c| {
// We are interested in coins available at the *next* block
c.block_info
.map(|b| current_height + 1 >= b.height + height_delta)
.unwrap_or(false)
});
let sweepable_coins = db_conn
.coins(&[CoinStatus::Unconfirmed, CoinStatus::Confirmed], &[])
.into_values()
.filter(|c| {
// We are interested in coins available at the *next* block
c.block_info
.map(|b| current_height + 1 >= b.height + height_delta)
.unwrap_or(false)
});
// Fill-in the transaction inputs and PSBT inputs information. Record the value
// that is fed to the transaction while doing so, to compute the fees afterward.

View File

@ -85,7 +85,11 @@ pub trait DatabaseConnection {
) -> Option<(bip32::ChildNumber, bool)>;
/// Get all our coins, past or present, spent or not.
fn coins(&mut self, coin_type: CoinType) -> HashMap<bitcoin::OutPoint, Coin>;
fn coins(
&mut self,
statuses: &[CoinStatus],
outpoints: &[bitcoin::OutPoint],
) -> 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>;
@ -192,8 +196,12 @@ impl DatabaseConnection for SqliteConn {
self.complete_wallet_rescan()
}
fn coins(&mut self, coin_type: CoinType) -> HashMap<bitcoin::OutPoint, Coin> {
self.coins(coin_type)
fn coins(
&mut self,
statuses: &[CoinStatus],
outpoints: &[bitcoin::OutPoint],
) -> HashMap<bitcoin::OutPoint, Coin> {
self.coins(statuses, outpoints)
.into_iter()
.map(|db_coin| (db_coin.outpoint, db_coin.into()))
.collect()
@ -348,13 +356,31 @@ impl Coin {
}
}
/// Possible (mutually exclusive) status of a coin.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum CoinType {
All,
Unspent,
pub enum CoinStatus {
/// Has not yet been included in a block and has no spend transaction.
Unconfirmed,
/// Has been included in a block and has no spend transaction.
Confirmed,
/// Has an unconfirmed spend transaction, but coin itself may not yet have been included in a block.
Spending,
/// Has a confirmed spend transaction.
Spent,
}
impl CoinStatus {
pub fn from_arg(s: &str) -> Option<CoinStatus> {
match s {
"unconfirmed" => Some(CoinStatus::Unconfirmed),
"confirmed" => Some(CoinStatus::Confirmed),
"spending" => Some(CoinStatus::Spending),
"spent" => Some(CoinStatus::Spent),
_ => None,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub enum LabelItem {
Address(bitcoin::Address),

View File

@ -23,7 +23,7 @@ use crate::{
maybe_apply_migration, LOOK_AHEAD_LIMIT,
},
},
Coin, CoinType, LabelItem,
Coin, CoinStatus, LabelItem,
},
descriptors::LianaDescriptor,
};
@ -357,30 +357,72 @@ impl SqliteConn {
.expect("Database must be available");
}
/// Get all the coins from DB.
pub fn coins(&mut self, coin_type: CoinType) -> Vec<DbCoin> {
db_query(
&mut self.conn,
match coin_type {
CoinType::All => "SELECT * FROM coins",
CoinType::Unspent => "SELECT * FROM coins WHERE spend_txid IS NULL",
CoinType::Spent => "SELECT * FROM coins WHERE spend_txid IS NOT NULL",
},
rusqlite::params![],
|row| row.try_into(),
)
/// Get all the coins from DB, optionally filtered by coin status and/or outpoint.
pub fn coins(
&mut self,
statuses: &[CoinStatus],
outpoints: &[bitcoin::OutPoint],
) -> Vec<DbCoin> {
let status_condition = statuses
.iter()
.map(|c| {
format!(
"({})",
match c {
CoinStatus::Unconfirmed => {
"blocktime IS NULL AND spend_txid IS NULL"
}
CoinStatus::Confirmed => {
"blocktime IS NOT NULL AND spend_txid IS NULL"
}
CoinStatus::Spending => {
"spend_txid IS NOT NULL AND spend_block_time IS NULL"
}
CoinStatus::Spent => "spend_block_time IS NOT NULL",
}
)
})
.collect::<Vec<String>>()
.join(" OR ");
// SELECT * FROM coins WHERE (txid, vout) IN ((txidA, voutA), (txidB, voutB));
let op_condition = if !outpoints.is_empty() {
let mut cond = "(txid, vout) IN (VALUES ".to_string();
for (i, outpoint) in outpoints.iter().enumerate() {
// NOTE: SQLite doesn't know Satoshi decided txids would be displayed as little-endian
// hex.
cond += &format!(
"(x'{}', {})",
FrontwardHexTxid(outpoint.txid),
outpoint.vout
);
if i != outpoints.len() - 1 {
cond += ", ";
}
}
cond += ")";
cond
} else {
String::new()
};
let where_clause = if !status_condition.is_empty() && !op_condition.is_empty() {
format!(" WHERE ({}) AND ({})", status_condition, op_condition)
} else if status_condition.is_empty() && !op_condition.is_empty() {
format!(" WHERE {}", op_condition)
} else if !status_condition.is_empty() && op_condition.is_empty() {
format!(" WHERE {}", status_condition)
} else {
String::new()
};
let query = format!("SELECT * FROM coins{}", where_clause);
db_query(&mut self.conn, &query, rusqlite::params![], |row| {
row.try_into()
})
.expect("Db must not fail")
}
/// List coins that are being spent and whose spending transaction is still unconfirmed.
pub fn list_spending_coins(&mut self) -> Vec<DbCoin> {
db_query(
&mut self.conn,
"SELECT * FROM coins WHERE spend_txid IS NOT NULL AND spend_block_time IS NULL",
rusqlite::params![],
|row| row.try_into(),
)
.expect("Db must not fail")
self.coins(&[CoinStatus::Spending], &[])
}
// FIXME: don't take the whole coin, we don't need it.
@ -445,7 +487,7 @@ impl SqliteConn {
.expect("Database must be available")
}
/// Mark a set of coins as spent.
/// Mark a set of coins as spending.
pub fn spend_coins<'a>(
&mut self,
outpoints: impl IntoIterator<Item = &'a (bitcoin::OutPoint, bitcoin::Txid)>,
@ -504,26 +546,7 @@ impl SqliteConn {
}
pub fn db_coins(&mut self, outpoints: &[bitcoin::OutPoint]) -> Vec<DbCoin> {
// SELECT * FROM coins WHERE (txid, vout) IN ((txidA, voutA), (txidB, voutB));
let mut query = "SELECT * FROM coins WHERE (txid, vout) IN (VALUES ".to_string();
for (i, outpoint) in outpoints.iter().enumerate() {
// NOTE: SQLite doesn't know Satoshi decided txids would be displayed as little-endian
// hex.
query += &format!(
"(x'{}', {})",
FrontwardHexTxid(outpoint.txid),
outpoint.vout
);
if i != outpoints.len() - 1 {
query += ", ";
}
}
query += ")";
db_query(&mut self.conn, &query, rusqlite::params![], |row| {
row.try_into()
})
.expect("Db must not fail")
self.coins(&[], outpoints)
}
pub fn db_spend(&mut self, txid: &bitcoin::Txid) -> Option<DbSpendTransaction> {
@ -889,6 +912,315 @@ CREATE TABLE spend_transactions (
fs::remove_dir_all(tmp_dir).unwrap();
}
#[test]
fn db_coins() {
let (tmp_dir, _, _, db) = dummy_db();
{
let mut conn = db.connection().unwrap();
// Necessarily empty at first.
assert!(conn.coins(&[], &[]).is_empty());
// Add one unconfirmed coin.
let outpoint_a = bitcoin::OutPoint::from_str(
"6f0dc85a369b44458eba3a1f0ea5b5935d563afb6994f70f5b0094e05be1676c:1",
)
.unwrap();
let coin_a = Coin {
outpoint: outpoint_a,
is_immature: false,
block_info: None,
amount: bitcoin::Amount::from_sat(10000),
derivation_index: bip32::ChildNumber::from_normal_idx(10).unwrap(),
is_change: false,
spend_txid: None,
spend_block: None,
};
conn.new_unspent_coins(&[coin_a]);
// We can query by status and/or outpoint.
assert!(vec![
conn.coins(&[], &[]),
conn.coins(&[CoinStatus::Unconfirmed], &[]),
conn.coins(&[CoinStatus::Unconfirmed], &[outpoint_a]),
conn.coins(&[], &[outpoint_a]),
conn.db_coins(&[outpoint_a]),
]
.iter()
.all(|res| res.len() == 1 && res[0].outpoint == coin_a.outpoint));
// It will not be returned if we filter for other statuses.
assert!(conn
.coins(
&[
CoinStatus::Confirmed,
CoinStatus::Spending,
CoinStatus::Spent
],
&[]
)
.is_empty());
// Filtering also for its outpoint will still not return it if status does not match.
assert!(conn
.coins(
&[
CoinStatus::Confirmed,
CoinStatus::Spending,
CoinStatus::Spent
],
&[outpoint_a]
)
.is_empty());
// Add a second coin.
let outpoint_b = bitcoin::OutPoint::from_str(
"61db3e276b095e5b05f1849dd6bfffb4e7e5ec1c4a4210099b98fce01571936f:12",
)
.unwrap();
let coin_b = Coin {
outpoint: outpoint_b,
is_immature: false,
block_info: None,
amount: bitcoin::Amount::from_sat(1111),
derivation_index: bip32::ChildNumber::from_normal_idx(103).unwrap(),
is_change: true,
spend_txid: None,
spend_block: None,
};
conn.new_unspent_coins(&[coin_b]);
// Both coins are unconfirmed.
assert!(vec![
conn.coins(&[], &[]),
conn.coins(&[CoinStatus::Unconfirmed], &[]),
conn.coins(&[CoinStatus::Unconfirmed], &[outpoint_a, outpoint_b]),
conn.coins(&[], &[outpoint_a, outpoint_b]),
conn.db_coins(&[outpoint_a, outpoint_b]),
]
.iter()
.all(|c| c.len() == 2
&& c[0].outpoint == coin_a.outpoint
&& c[1].outpoint == coin_b.outpoint));
// We can filter for just the first coin.
assert!(vec![
conn.coins(&[CoinStatus::Unconfirmed], &[outpoint_a]),
conn.coins(&[], &[outpoint_a]),
conn.db_coins(&[outpoint_a])
]
.iter()
.all(|res| res.len() == 1 && res[0].outpoint == coin_a.outpoint));
// Or we can filter for just the second coin.
assert!(vec![
conn.coins(&[CoinStatus::Unconfirmed], &[outpoint_b]),
conn.coins(&[], &[outpoint_b]),
conn.db_coins(&[outpoint_b])
]
.iter()
.all(|res| res.len() == 1 && res[0].outpoint == coin_b.outpoint));
// There are no coins with other statuses.
assert!(conn
.coins(
&[
CoinStatus::Confirmed,
CoinStatus::Spending,
CoinStatus::Spent
],
&[]
)
.is_empty());
// Now if we confirm one, it'll be marked as such.
conn.confirm_coins(&[(coin_a.outpoint, 174500, 174500)]);
assert!(vec![
conn.coins(&[CoinStatus::Confirmed], &[]),
conn.coins(&[CoinStatus::Confirmed], &[outpoint_a]),
conn.coins(&[], &[outpoint_a]),
conn.db_coins(&[outpoint_a]),
]
.iter()
.all(|res| res.len() == 1 && res[0].outpoint == coin_a.outpoint));
// We can get both confirmed and unconfirmed.
assert!(vec![
conn.coins(&[], &[]),
conn.coins(&[CoinStatus::Unconfirmed, CoinStatus::Confirmed], &[]),
conn.coins(
&[CoinStatus::Unconfirmed, CoinStatus::Confirmed],
&[outpoint_a, outpoint_b]
),
conn.coins(&[], &[outpoint_a, outpoint_b]),
conn.db_coins(&[outpoint_a, outpoint_b]),
]
.iter()
.all(|c| c.len() == 2
&& c[0].outpoint == coin_a.outpoint
&& c[1].outpoint == coin_b.outpoint));
// Now if we spend one, it'll be marked as such.
conn.spend_coins(&[(
coin_a.outpoint,
bitcoin::Txid::from_slice(&[0; 32][..]).unwrap(),
)]);
assert!(vec![
conn.coins(&[CoinStatus::Spending], &[]),
conn.coins(&[CoinStatus::Spending], &[outpoint_a]),
conn.coins(&[], &[outpoint_a]),
conn.list_spending_coins(),
conn.db_coins(&[outpoint_a])
]
.iter()
.all(|res| res.len() == 1 && res[0].outpoint == coin_a.outpoint));
// The second coin is still unconfirmed.
assert!(vec![
conn.coins(&[CoinStatus::Unconfirmed], &[]),
conn.coins(&[CoinStatus::Unconfirmed], &[outpoint_b]),
conn.coins(&[], &[outpoint_b]),
conn.db_coins(&[outpoint_b])
]
.iter()
.all(|res| res.len() == 1 && res[0].outpoint == coin_b.outpoint));
// Now we confirm the spend.
conn.confirm_spend(&[(
coin_a.outpoint,
bitcoin::Txid::from_slice(&[0; 32][..]).unwrap(),
128_097,
3_000_000,
)]);
// The coin no longer has spending status.
assert!(vec![
conn.coins(&[CoinStatus::Spending], &[]),
conn.coins(&[CoinStatus::Spending], &[outpoint_a]),
conn.list_spending_coins(),
]
.iter()
.all(|res| res.is_empty()));
// Both coins are still in DB.
assert!(vec![
conn.coins(&[], &[]),
conn.coins(&[CoinStatus::Unconfirmed, CoinStatus::Spent], &[]),
conn.coins(
&[CoinStatus::Unconfirmed, CoinStatus::Spent],
&[outpoint_a, outpoint_b]
),
conn.coins(&[], &[outpoint_a, outpoint_b]),
conn.db_coins(&[outpoint_a, outpoint_b]),
]
.iter()
.all(|c| c.len() == 2
&& c[0].outpoint == coin_a.outpoint
&& c[1].outpoint == coin_b.outpoint));
// Add a third and fourth coin.
let outpoint_c = bitcoin::OutPoint::from_str(
"61db3e276b095e5b05f1849dd6bfffb4e7e5ec1c4a4210099b98fce01571937a:42",
)
.unwrap();
let coin_c = Coin {
outpoint: outpoint_c,
is_immature: false,
block_info: None,
amount: bitcoin::Amount::from_sat(30000),
derivation_index: bip32::ChildNumber::from_normal_idx(4103).unwrap(),
is_change: false,
spend_txid: None,
spend_block: None,
};
let outpoint_d = bitcoin::OutPoint::from_str(
"61db3e276b095e5b05f1849dd6bfffb4e7e5ec1c4a4210099b98fce01571937a:43",
)
.unwrap();
let coin_d = Coin {
outpoint: outpoint_d,
is_immature: false,
block_info: None,
amount: bitcoin::Amount::from_sat(40000),
derivation_index: bip32::ChildNumber::from_normal_idx(4104).unwrap(),
is_change: false,
spend_txid: None,
spend_block: None,
};
conn.new_unspent_coins(&[coin_c, coin_d]);
// We can get all three unconfirmed coins with different status/outpoint filters.
assert!(vec![
conn.coins(&[CoinStatus::Unconfirmed], &[]),
conn.coins(
&[CoinStatus::Unconfirmed],
&[outpoint_b, outpoint_c, outpoint_d]
),
conn.coins(&[], &[outpoint_b, outpoint_c, outpoint_d]),
conn.db_coins(&[outpoint_b, outpoint_c, outpoint_d]),
]
.iter()
.all(|coin| coin.len() == 3
&& coin[0].outpoint == coin_b.outpoint
&& coin[1].outpoint == coin_c.outpoint
&& coin[2].outpoint == coin_d.outpoint));
// We can also get two of the three unconfirmed coins by filtering for their outpoints.
assert!(vec![
conn.coins(&[CoinStatus::Unconfirmed], &[outpoint_b, outpoint_c]),
conn.coins(&[], &[outpoint_b, outpoint_c]),
conn.db_coins(&[outpoint_b, outpoint_c]),
]
.iter()
.all(|coin| coin.len() == 2
&& coin[0].outpoint == coin_b.outpoint
&& coin[1].outpoint == coin_c.outpoint));
// Now spend second coin, even though it is still unconfirmed.
conn.spend_coins(&[(
coin_b.outpoint,
bitcoin::Txid::from_slice(&[1; 32][..]).unwrap(),
)]);
// The coin shows as spending.
assert!(vec![
conn.coins(&[CoinStatus::Spending], &[]),
conn.coins(&[CoinStatus::Spending], &[outpoint_b]),
conn.coins(&[], &[outpoint_b]),
conn.list_spending_coins(),
conn.db_coins(&[outpoint_b])
]
.iter()
.all(|res| res.len() == 1 && res[0].outpoint == coin_b.outpoint));
// Now confirm the third coin.
conn.confirm_coins(&[(coin_c.outpoint, 175500, 175500)]);
// We now only have one unconfirmed coin.
assert!(vec![
conn.coins(&[CoinStatus::Unconfirmed], &[]),
conn.coins(
&[CoinStatus::Unconfirmed],
&[outpoint_a, outpoint_b, outpoint_c, outpoint_d]
),
conn.coins(&[], &[outpoint_d]),
conn.db_coins(&[outpoint_d]),
]
.iter()
.all(|c| c.len() == 1 && c[0].outpoint == coin_d.outpoint));
// There is now one coin for each status.
assert!(vec![
conn.coins(&[CoinStatus::Unconfirmed], &[]),
conn.coins(&[CoinStatus::Unconfirmed], &[outpoint_d]),
conn.coins(&[CoinStatus::Confirmed], &[]),
conn.coins(&[CoinStatus::Confirmed], &[outpoint_c]),
conn.coins(&[CoinStatus::Spending], &[]),
conn.coins(&[CoinStatus::Spending], &[outpoint_b]),
conn.coins(&[CoinStatus::Spent], &[]),
conn.coins(&[CoinStatus::Spent], &[outpoint_a]),
conn.coins(&[], &[outpoint_a]),
conn.coins(&[], &[outpoint_b]),
conn.coins(&[], &[outpoint_c]),
conn.coins(&[], &[outpoint_d]),
]
.iter()
.map(|c| c.len())
.all(|length| length == 1));
}
fs::remove_dir_all(tmp_dir).unwrap();
}
#[test]
fn db_coins_update() {
let (tmp_dir, _, _, db) = dummy_db();
@ -897,7 +1229,7 @@ CREATE TABLE spend_transactions (
let mut conn = db.connection().unwrap();
// Necessarily empty at first.
assert!(conn.coins(CoinType::All).is_empty());
assert!(conn.coins(&[], &[]).is_empty());
// Add one, we'll get it.
let coin_a = Coin {
@ -914,11 +1246,11 @@ CREATE TABLE spend_transactions (
spend_block: None,
};
conn.new_unspent_coins(&[coin_a]);
assert_eq!(conn.coins(CoinType::All)[0].outpoint, coin_a.outpoint);
assert_eq!(conn.coins(&[], &[])[0].outpoint, coin_a.outpoint);
// We can also remove it. Say the unconfirmed tx that created it got replaced.
conn.remove_coins(&[coin_a.outpoint]);
assert!(conn.coins(CoinType::All).is_empty());
assert!(conn.coins(&[], &[]).is_empty());
// Add it back for the rest of the test.
conn.new_unspent_coins(&[coin_a]);
@ -928,9 +1260,21 @@ CREATE TABLE spend_transactions (
assert_eq!(coins.len(), 1);
assert_eq!(coins[0].outpoint, coin_a.outpoint);
// It is unspent.
assert_eq!(conn.coins(CoinType::Unspent)[0].outpoint, coin_a.outpoint);
assert!(conn.coins(CoinType::Spent).is_empty());
// It is unconfirmed.
assert_eq!(
conn.coins(&[CoinStatus::Unconfirmed], &[])[0].outpoint,
coin_a.outpoint
);
assert!(conn
.coins(
&[
CoinStatus::Confirmed,
CoinStatus::Spending,
CoinStatus::Spent
],
&[]
)
.is_empty());
// Add a second one (this one is change), we'll get both.
let coin_b = Coin {
@ -948,7 +1292,7 @@ CREATE TABLE spend_transactions (
};
conn.new_unspent_coins(&[coin_b]);
let outpoints: HashSet<bitcoin::OutPoint> = conn
.coins(CoinType::All)
.coins(&[], &[])
.into_iter()
.map(|c| c.outpoint)
.collect();
@ -967,15 +1311,24 @@ CREATE TABLE spend_transactions (
assert!(coins.iter().any(|c| c.outpoint == coin_a.outpoint));
assert!(coins.iter().any(|c| c.outpoint == coin_b.outpoint));
// They are both unspent
assert_eq!(conn.coins(CoinType::Unspent).len(), 2);
assert!(conn.coins(CoinType::Spent).is_empty());
// They are both unconfirmed.
assert_eq!(conn.coins(&[CoinStatus::Unconfirmed], &[]).len(), 2);
assert!(conn
.coins(
&[
CoinStatus::Confirmed,
CoinStatus::Spending,
CoinStatus::Spent
],
&[]
)
.is_empty());
// Now if we confirm one, it'll be marked as such.
let height = 174500;
let time = 174500;
conn.confirm_coins(&[(coin_a.outpoint, height, time)]);
let coins = conn.coins(CoinType::All);
let coins = conn.coins(&[], &[]);
assert_eq!(coins[0].block_info, Some(DbBlockInfo { height, time }));
assert!(coins[1].block_info.is_none());
@ -985,7 +1338,7 @@ CREATE TABLE spend_transactions (
bitcoin::Txid::from_slice(&[0; 32][..]).unwrap(),
)]);
let coins_map: HashMap<bitcoin::OutPoint, DbCoin> = conn
.coins(CoinType::All)
.coins(&[], &[])
.into_iter()
.map(|c| (c.outpoint, c))
.collect();
@ -1003,9 +1356,15 @@ CREATE TABLE spend_transactions (
.collect();
assert!(outpoints.contains(&coin_a.outpoint));
// The first one is spent, not the second one.
assert_eq!(conn.coins(CoinType::Spent)[0].outpoint, coin_a.outpoint);
assert_eq!(conn.coins(CoinType::Unspent)[0].outpoint, coin_b.outpoint);
// The first one is spending, not the second one.
assert_eq!(
conn.coins(&[CoinStatus::Spending], &[])[0].outpoint,
coin_a.outpoint
);
assert_eq!(
conn.coins(&[CoinStatus::Unconfirmed], &[])[0].outpoint,
coin_b.outpoint
);
// Now if we confirm the spend.
let height = 128_097;
@ -1051,7 +1410,7 @@ CREATE TABLE spend_transactions (
};
conn.new_unspent_coins(&[coin_imma]);
let outpoints: HashSet<bitcoin::OutPoint> = conn
.coins(CoinType::All)
.coins(&[], &[])
.into_iter()
.map(|c| c.outpoint)
.collect();
@ -1709,7 +2068,7 @@ CREATE TABLE spend_transactions (
spend_txid: None,
spend_block: None,
}]);
let coins = conn.coins(CoinType::All);
let coins = conn.coins(&[], &[]);
assert_eq!(coins.len(), 3);
assert_eq!(coins.iter().filter(|c| !c.is_immature).count(), 2);
}

View File

@ -1,5 +1,5 @@
use crate::{
commands::LabelItem,
commands::{CoinStatus, LabelItem},
jsonrpc::{Error, Params, Request, Response},
DaemonControl,
};
@ -87,6 +87,55 @@ fn broadcast_spend(control: &DaemonControl, params: Params) -> Result<serde_json
Ok(serde_json::json!({}))
}
fn list_coins(control: &DaemonControl, params: Option<Params>) -> Result<serde_json::Value, Error> {
let statuses_arg = params
.as_ref()
.and_then(|p| p.get(0, "statuses"))
.and_then(|statuses| statuses.as_array());
let statuses: Vec<CoinStatus> = if let Some(statuses_arg) = statuses_arg {
statuses_arg
.iter()
.map(|status_arg| {
status_arg
.as_str()
.and_then(CoinStatus::from_arg)
.ok_or_else(|| {
Error::invalid_params(format!(
"Invalid value {} in 'statuses' parameter.",
status_arg
))
})
})
.collect::<Result<Vec<CoinStatus>, Error>>()?
} else {
Vec::new()
};
let outpoints_arg = params
.as_ref()
.and_then(|p| p.get(1, "outpoints"))
.and_then(|op| op.as_array());
let outpoints: Vec<bitcoin::OutPoint> = if let Some(outpoints_arg) = outpoints_arg {
outpoints_arg
.iter()
.map(|op_arg| {
op_arg
.as_str()
.and_then(|op| bitcoin::OutPoint::from_str(op).map_or_else(|_| None, Some))
.ok_or_else(|| {
Error::invalid_params(format!(
"Invalid value {} in 'outpoints' parameter.",
op_arg
))
})
})
.collect::<Result<Vec<bitcoin::OutPoint>, Error>>()?
} else {
Vec::new()
};
let res = control.list_coins(&statuses, &outpoints);
Ok(serde_json::json!(&res))
}
fn list_confirmed(control: &DaemonControl, params: Params) -> Result<serde_json::Value, Error> {
let start: u32 = params
.get(0, "start")
@ -258,7 +307,10 @@ pub fn handle_request(control: &DaemonControl, req: Request) -> Result<Response,
}
"getinfo" => serde_json::json!(&control.get_info()),
"getnewaddress" => serde_json::json!(&control.get_new_address()),
"listcoins" => serde_json::json!(&control.list_coins()),
"listcoins" => {
let params = req.params;
list_coins(control, params)?
}
"listconfirmed" => {
let params = req.params.ok_or_else(|| {
Error::invalid_params(

View File

@ -1,7 +1,7 @@
use crate::{
bitcoin::{BitcoinInterface, Block, BlockChainTip, UTxO},
config::{BitcoinConfig, Config},
database::{BlockInfo, Coin, CoinType, DatabaseConnection, DatabaseInterface, LabelItem},
database::{BlockInfo, Coin, CoinStatus, DatabaseConnection, DatabaseInterface, LabelItem},
descriptors, DaemonHandle,
};
@ -195,19 +195,43 @@ impl DatabaseConnection for DummyDatabase {
self.db.write().unwrap().change_index = index;
}
fn coins(&mut self, coin_type: CoinType) -> HashMap<bitcoin::OutPoint, Coin> {
let coins = self.db.read().unwrap().coins.clone();
match coin_type {
CoinType::All => coins,
CoinType::Unspent => coins
.into_iter()
.filter(|(_, c)| c.spend_txid.is_none())
.collect(),
CoinType::Spent => coins
.into_iter()
.filter(|(_, c)| c.spend_txid.is_some())
.collect(),
}
fn coins(
&mut self,
statuses: &[CoinStatus],
outpoints: &[bitcoin::OutPoint],
) -> HashMap<bitcoin::OutPoint, Coin> {
self.db
.read()
.unwrap()
.coins
.clone()
.into_iter()
.filter_map(|(op, c)| {
if (c.block_info.is_none()
&& c.spend_txid.is_none()
&& statuses.contains(&CoinStatus::Unconfirmed))
|| (c.block_info.is_some()
&& c.spend_txid.is_none()
&& statuses.contains(&CoinStatus::Confirmed))
|| (c.spend_txid.is_some()
&& c.spend_block.is_none()
&& statuses.contains(&CoinStatus::Spending))
|| (c.spend_block.is_some() && statuses.contains(&CoinStatus::Spent))
|| statuses.is_empty()
{
Some((op, c))
} else {
None
}
})
.filter_map(|(op, c)| {
if outpoints.contains(&op) || outpoints.is_empty() {
Some((op, c))
} else {
None
}
})
.collect()
}
fn list_spending_coins(&mut self) -> HashMap<bitcoin::OutPoint, Coin> {

View File

@ -1,5 +1,6 @@
import pytest
import random
import re
import time
from fixtures import *
@ -44,20 +45,46 @@ def test_listcoins(lianad, bitcoind):
# If we send a coin, we'll get a new entry. Note we monitor for unconfirmed
# funds as well.
addr = lianad.rpc.getnewaddress()["address"]
txid = bitcoind.rpc.sendtoaddress(addr, 1)
addr_a = lianad.rpc.getnewaddress()["address"]
txid_a = bitcoind.rpc.sendtoaddress(addr_a, 1)
wait_for(lambda: len(lianad.rpc.listcoins()["coins"]) == 1)
res = lianad.rpc.listcoins()["coins"]
assert txid == res[0]["outpoint"][:64]
outpoint_a = res[0]["outpoint"]
assert txid_a == outpoint_a[:64]
assert res[0]["amount"] == 1 * COIN
assert res[0]["block_height"] is None
assert res[0]["spend_info"] is None
assert len(lianad.rpc.listcoins(["confirmed", "spent", "spending"])["coins"]) == 0
assert (
lianad.rpc.listcoins()
== lianad.rpc.listcoins([], [outpoint_a])
== lianad.rpc.listcoins(["unconfirmed"])
== lianad.rpc.listcoins(["unconfirmed"], [outpoint_a])
== lianad.rpc.listcoins(["unconfirmed", "confirmed"])
== lianad.rpc.listcoins(["spent", "unconfirmed", "confirmed"])
== lianad.rpc.listcoins(["spent", "unconfirmed", "confirmed"], [outpoint_a])
)
# If the coin gets confirmed, it'll be marked as such.
bitcoind.generate_block(1, wait_for_mempool=txid)
bitcoind.generate_block(1, wait_for_mempool=txid_a)
block_height = bitcoind.rpc.getblockcount()
wait_for(lambda: lianad.rpc.listcoins()["coins"][0]["block_height"] == block_height)
assert (
len(lianad.rpc.listcoins())
== len(lianad.rpc.listcoins(["confirmed"])["coins"])
== 1
)
assert (
lianad.rpc.listcoins()
== lianad.rpc.listcoins([], [outpoint_a])
== lianad.rpc.listcoins(["confirmed"])
== lianad.rpc.listcoins(["confirmed"], [outpoint_a])
== lianad.rpc.listcoins(["unconfirmed", "confirmed"])
== lianad.rpc.listcoins(["spent", "unconfirmed", "confirmed"])
== lianad.rpc.listcoins(["spent", "unconfirmed", "confirmed"], [outpoint_a])
)
# Same if the coin gets spent.
spend_tx = spend_coins(lianad, bitcoind, (res[0],))
spend_txid = get_txid(spend_tx)
@ -65,6 +92,8 @@ def test_listcoins(lianad, bitcoind):
spend_info = lianad.rpc.listcoins()["coins"][0]["spend_info"]
assert spend_info["txid"] == spend_txid
assert spend_info["height"] is None
assert len(lianad.rpc.listcoins(["spent"])["coins"]) == 0
assert len(lianad.rpc.listcoins(["spending"])["coins"]) == 1
# And if this spending tx gets confirmed.
bitcoind.generate_block(1, wait_for_mempool=spend_txid)
@ -73,6 +102,220 @@ def test_listcoins(lianad, bitcoind):
spend_info = lianad.rpc.listcoins()["coins"][0]["spend_info"]
assert spend_info["txid"] == spend_txid
assert spend_info["height"] == curr_height
assert len(lianad.rpc.listcoins(["unconfirmed", "confirmed"])["coins"]) == 0
assert (
lianad.rpc.listcoins()
== lianad.rpc.listcoins(["spent"])
== lianad.rpc.listcoins(["spent", "unconfirmed", "confirmed"])
)
# Add a second coin.
addr_b = lianad.rpc.getnewaddress()["address"]
txid_b = bitcoind.rpc.sendtoaddress(addr_b, 2)
wait_for(lambda: len(lianad.rpc.listcoins()["coins"]) == 2)
res = lianad.rpc.listcoins(["unconfirmed"], [])["coins"]
outpoint_b = res[0]["outpoint"]
# We have one unconfirmed coin and one spent coin.
assert (
len(lianad.rpc.listcoins()["coins"])
== len(lianad.rpc.listcoins([], [outpoint_a, outpoint_b])["coins"])
== len(lianad.rpc.listcoins(["unconfirmed", "spent"])["coins"])
== len(
lianad.rpc.listcoins(["unconfirmed", "spent"], [outpoint_a, outpoint_b])[
"coins"
]
)
== 2
)
assert (
lianad.rpc.listcoins([], [outpoint_b])
== lianad.rpc.listcoins(["unconfirmed"])
== lianad.rpc.listcoins(["unconfirmed"], [outpoint_b])
== lianad.rpc.listcoins(["unconfirmed", "confirmed"])
== lianad.rpc.listcoins(["spending", "unconfirmed", "confirmed"])
== lianad.rpc.listcoins(["spending", "unconfirmed", "confirmed"], [outpoint_b])
)
# Now confirm the second coin.
bitcoind.generate_block(1, wait_for_mempool=txid_b)
block_height = bitcoind.rpc.getblockcount()
wait_for(
lambda: lianad.rpc.listcoins([], [outpoint_b])["coins"][0]["block_height"]
== block_height
)
# We have one confirmed coin and one spent coin.
assert (
len(lianad.rpc.listcoins()["coins"])
== len(lianad.rpc.listcoins([], [outpoint_a, outpoint_b])["coins"])
== len(lianad.rpc.listcoins(["confirmed", "spent"])["coins"])
== len(
lianad.rpc.listcoins(["confirmed", "spent"], [outpoint_a, outpoint_b])[
"coins"
]
)
== 2
)
assert (
lianad.rpc.listcoins([], [outpoint_b])
== lianad.rpc.listcoins(["confirmed"])
== lianad.rpc.listcoins(["confirmed"], [outpoint_b])
== lianad.rpc.listcoins(["unconfirmed", "confirmed"])
== lianad.rpc.listcoins(["unconfirmed", "confirmed", "spending"])
== lianad.rpc.listcoins(["unconfirmed", "confirmed", "spending"], [outpoint_b])
)
# Add a third coin.
addr_c = lianad.rpc.getnewaddress()["address"]
txid_c = bitcoind.rpc.sendtoaddress(addr_c, 3)
wait_for(lambda: len(lianad.rpc.listcoins()["coins"]) == 3)
res = lianad.rpc.listcoins(["unconfirmed"], [])["coins"]
outpoint_c = res[0]["outpoint"]
# We have three different statuses: unconfirmed, confirmed and spent.
assert (
len(lianad.rpc.listcoins()["coins"])
== len(lianad.rpc.listcoins([], [outpoint_a, outpoint_b, outpoint_c])["coins"])
== len(lianad.rpc.listcoins(["unconfirmed", "confirmed", "spent"])["coins"])
== len(
lianad.rpc.listcoins(
["unconfirmed", "confirmed", "spent"],
[outpoint_a, outpoint_b, outpoint_c],
)["coins"]
)
== 3
)
assert (
lianad.rpc.listcoins([], [outpoint_c])
== lianad.rpc.listcoins(["unconfirmed"])
== lianad.rpc.listcoins(["unconfirmed"], [outpoint_c])
== lianad.rpc.listcoins(["unconfirmed", "spending"])
== lianad.rpc.listcoins(["spending", "unconfirmed"])
== lianad.rpc.listcoins(["spending", "unconfirmed", "confirmed"], [outpoint_c])
)
# Spend third coin, even though it is still unconfirmed.
spend_tx = spend_coins(lianad, bitcoind, (res[0],))
spend_txid = get_txid(spend_tx)
wait_for(
lambda: lianad.rpc.listcoins([], [outpoint_c])["coins"][0]["spend_info"]
is not None
)
assert len(lianad.rpc.listcoins(["unconfirmed"])["coins"]) == 0
assert (
len(lianad.rpc.listcoins()["coins"])
== len(lianad.rpc.listcoins([], [outpoint_a, outpoint_b, outpoint_c])["coins"])
== len(lianad.rpc.listcoins(["confirmed", "spending", "spent"])["coins"])
== len(
lianad.rpc.listcoins(
["confirmed", "spending", "spent"], [outpoint_a, outpoint_b, outpoint_c]
)["coins"]
)
== 3
)
# The unconfirmed coin now has spending status.
assert (
lianad.rpc.listcoins([], [outpoint_c])
== lianad.rpc.listcoins(["spending"])
== lianad.rpc.listcoins(["spending"], [outpoint_c])
== lianad.rpc.listcoins(["spending", "unconfirmed"])
== lianad.rpc.listcoins(["spending", "unconfirmed"], [outpoint_c])
)
# Add a fourth coin.
addr_d = lianad.rpc.getnewaddress()["address"]
txid_d = bitcoind.rpc.sendtoaddress(addr_d, 4)
wait_for(lambda: len(lianad.rpc.listcoins()["coins"]) == 4)
res = lianad.rpc.listcoins(["unconfirmed"], [])["coins"]
outpoint_d = res[0]["outpoint"]
# We now have all four statuses.
assert (
len(lianad.rpc.listcoins(["unconfirmed"])["coins"])
== len(lianad.rpc.listcoins(["confirmed"])["coins"])
== len(lianad.rpc.listcoins(["spending"])["coins"])
== len(lianad.rpc.listcoins(["spent"])["coins"])
== 1
)
assert (
len(lianad.rpc.listcoins()["coins"])
== len(
lianad.rpc.listcoins([], [outpoint_a, outpoint_b, outpoint_c, outpoint_d])[
"coins"
]
)
== len(
lianad.rpc.listcoins(["unconfirmed", "confirmed", "spending", "spent"])[
"coins"
]
)
== len(
lianad.rpc.listcoins(
["unconfirmed", "confirmed", "spending", "spent"],
[outpoint_a, outpoint_b, outpoint_c, outpoint_d],
)["coins"]
)
== 4
)
# We can filter for specific statuses/outpoints.
assert (
sorted(
lianad.rpc.listcoins(["spending", "spent"])["coins"],
key=lambda c: c["outpoint"],
)
== sorted(
lianad.rpc.listcoins(["spending", "spent"], [outpoint_a, outpoint_c])[
"coins"
],
key=lambda c: c["outpoint"],
)
== sorted(
lianad.rpc.listcoins(
["unconfirmed", "confirmed", "spending", "spent"],
[outpoint_a, outpoint_c],
)["coins"],
key=lambda c: c["outpoint"],
)
== sorted(
lianad.rpc.listcoins(
["spending", "spent"], [outpoint_a, outpoint_b, outpoint_c, outpoint_d]
)["coins"],
key=lambda c: c["outpoint"],
)
)
# Finally, check that we return errors for invalid parameter values.
for statuses, outpoints in [
(["fake_status"], []),
(["spent", "fake_status"], []),
(["fake_status", "fake_status_2"], []),
(["confirmed", "spending", "fake_status"], ["fake_outpoint"]),
(["fake_status"], [outpoint_a, outpoint_b]),
]:
with pytest.raises(
RpcError,
match=re.escape(
"Invalid params: Invalid value \"fake_status\" in \\'statuses\\' parameter."
),
):
lianad.rpc.listcoins(statuses, outpoints)
for statuses, outpoints in [
([], ["fake_outpoint"]),
([], [outpoint_a, "fake_outpoint", outpoint_b]),
([], [outpoint_a, "fake_outpoint", "fake_outpoint_2"]),
([], [outpoint_a, outpoint_b, "fake_outpoint"]),
]:
with pytest.raises(
RpcError,
match=re.escape(
"Invalid params: Invalid value \"fake_outpoint\" in \\'outpoints\\' parameter."
),
):
lianad.rpc.listcoins(statuses, outpoints)
def test_jsonrpc_server(lianad, bitcoind):