commands: get wallet transactions from db

This commit is contained in:
jp1ac4 2024-07-08 12:54:33 +01:00
parent afa6a51601
commit a86d12d629
No known key found for this signature in database
GPG Key ID: C61FA2110D7DC407
5 changed files with 359 additions and 43 deletions

View File

@ -990,39 +990,19 @@ impl DaemonControl {
limit: u64,
) -> ListTransactionsResult {
let mut db_conn = self.db.connection();
// Note the result could in principle be retrieved in a single database query.
let txids = db_conn.list_txids(start, end, limit);
let transactions = txids
.iter()
.filter_map(|txid| {
// TODO: 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 }
self.list_transactions(&txids)
}
/// 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 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),
})
})
let transactions = self
.db
.connection()
.list_wallet_transactions(txids)
.into_iter()
.map(|(tx, height, time)| TransactionInfo { tx, height, time })
.collect();
ListTransactionsResult { transactions }
}
@ -2278,8 +2258,8 @@ mod tests {
},
]);
let mut btc = DummyBitcoind::new();
btc.txs.insert(
let mut txs_map = HashMap::new();
txs_map.insert(
deposit1.txid(),
(
deposit1.clone(),
@ -2293,7 +2273,7 @@ mod tests {
}),
),
);
btc.txs.insert(
txs_map.insert(
deposit2.txid(),
(
deposit2.clone(),
@ -2307,7 +2287,7 @@ mod tests {
}),
),
);
btc.txs.insert(
txs_map.insert(
spend_tx.txid(),
(
spend_tx.clone(),
@ -2321,7 +2301,7 @@ mod tests {
}),
),
);
btc.txs.insert(
txs_map.insert(
deposit3.txid(),
(
deposit3.clone(),
@ -2336,11 +2316,15 @@ mod tests {
),
);
let ms = DummyLiana::new(btc, db);
let ms = DummyLiana::new(DummyBitcoind::new(), db);
let control = &ms.control();
let mut db_conn = control.db.connection();
let txs: Vec<_> = txs_map.values().map(|(tx, _)| tx.clone()).collect();
db_conn.new_txs(&txs);
let transactions = control.list_confirmed_transactions(0, 4, 10).transactions;
let mut transactions = control.list_confirmed_transactions(0, 4, 10).transactions;
transactions.sort_by(|tx1, tx2| tx2.height.cmp(&tx1.height));
assert_eq!(transactions.len(), 4);
assert_eq!(transactions[0].time, Some(4));
@ -2355,7 +2339,8 @@ mod tests {
assert_eq!(transactions[3].time, Some(1));
assert_eq!(transactions[3].tx, deposit1);
let transactions = control.list_confirmed_transactions(2, 3, 10).transactions;
let mut transactions = control.list_confirmed_transactions(2, 3, 10).transactions;
transactions.sort_by(|tx1, tx2| tx2.height.cmp(&tx1.height));
assert_eq!(transactions.len(), 2);
assert_eq!(transactions[0].time, Some(3));
@ -2424,8 +2409,8 @@ mod tests {
}],
};
let mut btc = DummyBitcoind::new();
btc.txs.insert(
let mut txs_map = HashMap::new();
txs_map.insert(
tx1.txid(),
(
tx1.clone(),
@ -2439,7 +2424,7 @@ mod tests {
}),
),
);
btc.txs.insert(
txs_map.insert(
tx2.txid(),
(
tx2.clone(),
@ -2453,7 +2438,7 @@ mod tests {
}),
),
);
btc.txs.insert(
txs_map.insert(
tx3.txid(),
(
tx3.clone(),
@ -2468,9 +2453,31 @@ mod tests {
),
);
let ms = DummyLiana::new(btc, DummyDatabase::new());
let ms = DummyLiana::new(DummyBitcoind::new(), DummyDatabase::new());
let control = &ms.control();
let mut db_conn = control.db.connection();
let txs: Vec<_> = txs_map.values().map(|(tx, _)| tx.clone()).collect();
db_conn.new_txs(&txs);
// We need coins in the DB in order to get the block info for the transactions.
for (txid, (_tx, block)) in txs_map {
// Insert more than one coin per transaction to check that the command does not
// return duplicate transactions.
for vout in 0..4 {
db_conn.new_unspent_coins(&[Coin {
outpoint: bitcoin::OutPoint::new(txid, vout),
is_immature: false,
block_info: block.map(|b| BlockInfo {
height: b.height,
time: b.time,
}),
amount: bitcoin::Amount::from_sat(100_000),
derivation_index: bip32::ChildNumber::from(13),
is_change: false,
spend_txid: None,
spend_block: None,
}]);
}
}
let transactions = control.list_transactions(&[tx1.txid()]).transactions;
assert_eq!(transactions.len(), 1);

View File

@ -152,6 +152,12 @@ pub trait DatabaseConnection {
/// Store transactions in database, ignoring any that already exist.
fn new_txs(&mut self, txs: &[bitcoin::Transaction]);
/// Retrieve a list of transactions and their corresponding block heights and times.
fn list_wallet_transactions(
&mut self,
txids: &[bitcoin::Txid],
) -> Vec<(bitcoin::Transaction, Option<i32>, Option<u32>)>;
}
impl DatabaseConnection for SqliteConn {
@ -324,6 +330,22 @@ impl DatabaseConnection for SqliteConn {
fn new_txs<'a>(&mut self, txs: &[bitcoin::Transaction]) {
self.new_txs(txs)
}
fn list_wallet_transactions(
&mut self,
txids: &[bitcoin::Txid],
) -> Vec<(bitcoin::Transaction, Option<i32>, Option<u32>)> {
self.list_wallet_transactions(txids)
.into_iter()
.map(|wtx| {
(
wtx.transaction,
wtx.block_info.map(|b| b.height),
wtx.block_info.map(|b| b.time),
)
})
.collect()
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]

View File

@ -16,7 +16,7 @@ use crate::{
sqlite::{
schema::{
DbAddress, DbCoin, DbLabel, DbLabelledKind, DbSpendTransaction, DbTip, DbWallet,
SCHEMA,
DbWalletTransaction, SCHEMA,
},
utils::{
create_fresh_db, curr_timestamp, db_exec, db_query, db_tx_query, db_version,
@ -729,6 +729,50 @@ impl SqliteConn {
.expect("Database must be available")
}
pub fn list_wallet_transactions(
&mut self,
txids: &[bitcoin::Txid],
) -> Vec<DbWalletTransaction> {
// The UNION will remove duplicates.
// We assume that a transaction's block info is the same in every coins row
// it appears in.
let query = format!(
"SELECT t.tx, c.blockheight, c.blocktime \
FROM transactions t \
INNER JOIN ( \
SELECT txid, blockheight, blocktime \
FROM coins \
WHERE wallet_id = {WALLET_ID} \
UNION \
SELECT spend_txid, spend_block_height, spend_block_time \
FROM coins \
WHERE wallet_id = {WALLET_ID} \
AND spend_txid IS NOT NULL \
) c ON t.txid = c.txid \
WHERE t.txid in ({})",
txids
.iter()
.map(|txid| format!("x'{}'", FrontwardHexTxid(*txid)))
.collect::<Vec<_>>()
.join(",")
);
let w_txs: Vec<DbWalletTransaction> =
db_query(&mut self.conn, &query, rusqlite::params![], |row| {
row.try_into()
})
.expect("Db must not fail");
debug_assert_eq!(
w_txs.len(),
w_txs
.iter()
.map(|t| t.transaction.txid())
.collect::<HashSet<_>>()
.len(),
"database must not contain inconsistent block info for the same txid"
);
w_txs
}
pub fn delete_spend(&mut self, txid: &bitcoin::Txid) {
db_exec(&mut self.conn, |db_tx| {
db_tx.execute(
@ -2072,6 +2116,188 @@ CREATE TABLE labels (
fs::remove_dir_all(tmp_dir).unwrap();
}
#[test]
fn sqlite_list_wallet_transactions() {
let (tmp_dir, _, _, db) = dummy_db();
{
let mut conn = db.connection().unwrap();
// The following is based on the `v4_to_v5_migration` test.
let mut bitcoin_txs: Vec<_> = (0..100)
.map(|i| bitcoin::Transaction {
version: bitcoin::transaction::Version::TWO,
lock_time: bitcoin::absolute::LockTime::from_height(i).unwrap(),
input: Vec::new(),
output: Vec::new(),
})
.collect();
let spend_txs: Vec<_> = (0..10)
.map(|i| {
(
bitcoin::Transaction {
version: bitcoin::transaction::Version::TWO,
lock_time: bitcoin::absolute::LockTime::from_height(1_234 + i).unwrap(),
input: Vec::new(),
output: Vec::new(),
},
if i % 2 == 0 {
Some(BlockInfo {
height: (i % 5) as i32 * 2_000,
time: 1722488619 + (i % 5) * 84_999,
})
} else {
None
},
)
})
.collect();
let coins: Vec<Coin> = bitcoin_txs
.iter()
.chain(bitcoin_txs.iter()) // We do this to have coins which originate from the same tx.
.enumerate()
.map(|(i, tx)| Coin {
outpoint: bitcoin::OutPoint {
txid: tx.txid(),
vout: i as u32,
},
is_immature: (i % 10) == 0,
amount: bitcoin::Amount::from_sat(i as u64 * 3473),
derivation_index: bip32::ChildNumber::from_normal_idx(i as u32 * 100).unwrap(),
is_change: (i % 4) == 0,
block_info: if i & 2 == 0 {
Some(BlockInfo {
height: (i % 100) as i32 * 1_000,
time: 1722408619 + (i % 100) as u32 * 42_000,
})
} else {
None
},
spend_txid: if i % 20 == 0 {
Some(spend_txs[i / 20].0.txid())
} else {
None
},
spend_block: if i % 20 == 0 {
spend_txs[i / 20].1
} else {
None
},
})
.collect();
bitcoin_txs.extend(spend_txs.into_iter().map(|(tx, _)| tx));
conn.new_txs(&bitcoin_txs);
// Insert all these coins into database.
conn.new_unspent_coins(&coins);
// Confirm those which are supposed to be.
let confirmed_coins: Vec<_> = coins
.iter()
.filter_map(|coin| {
coin.block_info
.map(|blk| (coin.outpoint, blk.height, blk.time))
})
.collect();
conn.confirm_coins(&confirmed_coins);
// Spend those which are supposed to be.
let spent_coins: Vec<_> = coins
.iter()
.filter_map(|coin| coin.spend_txid.map(|txid| (coin.outpoint, txid)))
.collect();
conn.spend_coins(&spent_coins);
// Mark the spend as confirmed for those which are supposed to be.
let confirmed_spent_coins: Vec<_> = coins
.iter()
.filter_map(|coin| {
coin.spend_block.map(|blk| {
(
coin.outpoint,
coin.spend_txid.expect("always set when spend block is"),
blk.height,
blk.time,
)
})
})
.collect();
conn.confirm_spend(&confirmed_spent_coins);
// For easy lookup, map each tx to its txid.
let bitcoin_txs: HashMap<_, _> =
bitcoin_txs.into_iter().map(|tx| (tx.txid(), tx)).collect();
let block_info_from_coins: HashSet<_> = coins
.iter()
.map(|c| (c.outpoint.txid, c.block_info))
.chain(
coins
.iter()
.filter_map(|c| c.spend_txid.map(|txid| (txid, c.spend_block))),
)
.collect();
// Make sure each txid only has one block info.
assert_eq!(bitcoin_txs.len(), block_info_from_coins.len());
// For each txid, determine its wallet transaction based on the coins defined above.
let wallet_txs_from_coins: HashMap<_, _> = block_info_from_coins
.into_iter()
.map(|(txid, block_info)| {
let tx = bitcoin_txs.get(&txid).unwrap();
(
txid,
DbWalletTransaction {
transaction: tx.clone(),
block_info: block_info.map(|info| DbBlockInfo {
height: info.height,
time: info.time,
}),
},
)
})
.collect();
let all_txids: Vec<_> = bitcoin_txs.keys().cloned().collect();
for indices in [
(0..all_txids.len()).collect(),
(0..all_txids.len() / 2).collect(),
(all_txids.len() / 5..all_txids.len() / 2).collect(),
vec![3, 4, 5, 6],
vec![4, 5],
vec![1, 3, 5],
vec![1],
vec![1, 1, 3, 4, 3], // we can pass duplicate txids
vec![], // can pass empty slice
] {
let txids: Vec<_> = indices
.iter()
.map(|i| *all_txids.get(*i).unwrap())
.collect();
// Make sure we have the expected number of txids.
assert_eq!(txids.len(), indices.len());
let mut db_txs = conn.list_wallet_transactions(&txids);
db_txs.sort_by(|a, b| a.transaction.txid().cmp(&b.transaction.txid()));
let mut expected_txs: Vec<_> = txids
.iter()
.collect::<HashSet<_>>() // remove duplicates
.into_iter()
.map(|txid| wallet_txs_from_coins.get(txid).unwrap().clone())
.collect();
expected_txs.sort_by(|a, b| a.transaction.txid().cmp(&b.transaction.txid()));
assert_eq!(&db_txs[..], &expected_txs[..],);
}
}
fs::remove_dir_all(tmp_dir).unwrap();
}
#[test]
fn v0_to_v2_migration() {
let secp = secp256k1::Secp256k1::verification_only();

View File

@ -359,3 +359,32 @@ impl TryFrom<&rusqlite::Row<'_>> for DbLabel {
})
}
}
/// A transaction together with its block info.
#[derive(Clone, Debug, PartialEq)]
pub struct DbWalletTransaction {
pub transaction: bitcoin::Transaction,
pub block_info: Option<DbBlockInfo>,
}
impl TryFrom<&rusqlite::Row<'_>> for DbWalletTransaction {
type Error = rusqlite::Error;
fn try_from(row: &rusqlite::Row) -> Result<Self, Self::Error> {
let transaction: Vec<u8> = row.get(0)?;
let transaction: bitcoin::Transaction =
bitcoin::consensus::deserialize(&transaction).expect("We only store valid txs");
let block_height: Option<i32> = row.get(1)?;
let block_time: Option<u32> = row.get(2)?;
assert_eq!(block_height.is_none(), block_time.is_none());
let block_info = block_height.map(|height| DbBlockInfo {
height,
time: block_time.expect("Must be there if height is"),
});
Ok(DbWalletTransaction {
transaction,
block_info,
})
}
}

View File

@ -449,6 +449,38 @@ impl DatabaseConnection for DummyDatabase {
self.db.write().unwrap().txs.insert(tx.txid(), tx.clone());
}
}
fn list_wallet_transactions(
&mut self,
txids: &[bitcoin::Txid],
) -> Vec<(bitcoin::Transaction, Option<i32>, Option<u32>)> {
let txs: HashMap<_, _> = self
.db
.read()
.unwrap()
.txs
.clone()
.into_iter()
.filter(|(txid, _tx)| txids.contains(txid))
.collect();
let coins = self.coins(&[], &[]);
let mut wallet_txs = Vec::with_capacity(txs.len());
for (txid, tx) in txs {
let first_block_info = coins.values().find_map(|c| {
if c.outpoint.txid == txid {
Some(c.block_info)
} else if c.spend_txid == Some(txid) {
Some(c.spend_block)
} else {
None
}
});
if let Some(block_info) = first_block_info {
wallet_txs.push((tx, block_info.map(|b| b.height), block_info.map(|b| b.time)));
}
}
wallet_txs
}
}
pub struct DummyLiana {