Merge #1483: commands: indicate in listcoins response whether coin is from self

1c0338610f76419287157c14a6647a362160f9e4 commands: add is_from_self to listcoins response (Michael Mallan)
c1e55716e619249c264193776d36c640df9bc946 database: add is_from_self to Coin (Michael Mallan)
6dd92052af0afc37bddb67493a64af396e7964c9 database: track whether coin is from self (Michael Mallan)
da185361f99eb319554ec78daa2fccdcd72eb298 sqlite: add helper to query single row (Michael Mallan)
4f6dcbfdfd7eaf471a38badc0c9ce48aefac814c sqlite: add columns to transactions and coins tables (Michael Mallan)
f2c910f6eb9f2b74329fa026c93f7e813f3baa53 sqlite: refactor test to not depend on order of coins (Michael Mallan)
bde3299db1c38eb6b0b09d7da2a73c43e40181b9 sqlite: refactor migration tests (Michael Mallan)

Pull request description:

  ⚠️ This PR upgrades and migrates DB version so a wallet opened against this PR will no longer work on Liana v8.

  This is a first step towards #1375.

  Following the approach from #1391, this PR adds a new `is_from_self` field to the response of the `listcoins` command.

  The underlying information is stored in a new `is_from_self` column in the coins database table. This column could instead have been added to the transactions table, but for consistency with other transaction-related columns, I added it to the coins table. It's also important to note that being from self is wallet-dependent, so adding it to the transactions table would not work if multiple wallets were supported in the same DB (h/t edouardparis).

  A subsequent PR will then use this field in the GUI to determine which unconfirmed coins, if any, can be included in the confirmed balance. It also needs to be added to the corresponding Liana Connect API call. Another use of this field will be to determine which unconfirmed coins to include in coin selection (https://github.com/wizardsardine/liana/issues/1484).

  The first commit in this PR refactors some existing DB migration tests so that they will not be affected by future changes to the coins table schema.

ACKs for top commit:
  pythcoiner:
    utACK 1c033861
  edouardparis:
    ACK 1c0338610f76419287157c14a6647a362160f9e4

Tree-SHA512: cdadb1f887989d40a08370a479310d80087b9075ff766dd518167baed4630ccbcbb0dc8f49b7fb31ad9aebddfb4ca459d83aeed3c47158bdb046a91352f98fc7
This commit is contained in:
edouardparis 2024-12-04 13:30:00 +01:00
commit 68799d56ff
No known key found for this signature in database
GPG Key ID: E65F7A089C20DC8F
14 changed files with 1029 additions and 177 deletions

View File

@ -135,6 +135,7 @@ A coin may have one of the following four statuses:
| `spend_info` | object | Information about the transaction spending this coin. See [Spending transaction info](#spending_transaction_info). |
| `is_immature` | bool | Whether this coin was created by a coinbase transaction that is still immature. |
| `is_change` | bool | Whether the coin deposit address was derived from the change descriptor. |
| `is_from_self` | bool | Whether the coin and all its unconfirmed ancestors, if any, are outputs of transactions from this wallet. |
##### Spending transaction info

View File

@ -226,6 +226,7 @@ mod tests {
address: dummy_address.clone(),
derivation_index: 0.into(),
is_change: false,
is_from_self: false,
},
Coin {
outpoint: bitcoin::OutPoint { txid, vout: 3 },
@ -236,6 +237,7 @@ mod tests {
address: dummy_address.clone(),
derivation_index: 1.into(),
is_change: false,
is_from_self: false,
},
Coin {
outpoint: bitcoin::OutPoint { txid, vout: 0 },
@ -246,6 +248,7 @@ mod tests {
address: dummy_address.clone(),
derivation_index: 2.into(),
is_change: false,
is_from_self: false,
},
Coin {
outpoint: bitcoin::OutPoint { txid, vout: 1 },
@ -256,6 +259,7 @@ mod tests {
address: dummy_address,
derivation_index: 3.into(),
is_change: false,
is_from_self: false,
},
]);

View File

@ -793,6 +793,7 @@ mod tests {
"derivation_index": 0,
"is_immature": false,
"is_change": false,
"is_from_self": false,
}]})),
),

View File

@ -649,6 +649,7 @@ impl Daemon for BackendWalletClient {
txid: info.txid,
height: info.height,
}),
is_from_self: false, // FIXME: use value from backend
})
.collect(),
})
@ -1133,6 +1134,7 @@ fn history_tx_from_api(value: api::Transaction, network: Network) -> HistoryTran
txid: info.txid,
height: info.height,
}),
is_from_self: false, // FIXME: use value from backend
});
}
}
@ -1188,6 +1190,7 @@ fn spend_tx_from_api(
txid: info.txid,
height: info.height,
}),
is_from_self: false, // FIXME: use value from backend
});
}
}

View File

@ -91,6 +91,7 @@ fn update_coins(
block_info: None,
spend_txid: None,
spend_block: None,
is_from_self: false,
};
received.push(coin);
}
@ -315,6 +316,9 @@ fn updates(
db_conn.unspend_coins(&updated_coins.expired_spending);
db_conn.spend_coins(&updated_coins.spending);
db_conn.confirm_spend(&updated_coins.spent);
// Update info about which coins are from self only after
// coins have been inserted & updated above.
db_conn.update_coins_from_self(current_tip.height);
if latest_tip != current_tip {
db_conn.update_tip(&latest_tip);
log::debug!("New tip: '{}'", latest_tip);

View File

@ -425,6 +425,7 @@ impl DaemonControl {
spend_block,
is_immature,
is_change,
is_from_self,
derivation_index,
..
} = coin;
@ -445,6 +446,7 @@ impl DaemonControl {
spend_info,
is_immature,
is_change,
is_from_self,
}
})
.collect();
@ -1229,6 +1231,10 @@ pub struct ListCoinsEntry {
pub is_immature: bool,
/// Whether the coin deposit address was derived from the change descriptor.
pub is_change: bool,
/// Whether the coin is the output of a transaction whose inputs are all from
/// this same wallet. If the coin is unconfirmed, it also means that all its
/// unconfirmed ancestors, if any, are also from self.
pub is_from_self: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
@ -1491,6 +1497,7 @@ mod tests {
is_change: false,
spend_txid: None,
spend_block: None,
is_from_self: false,
}]);
// If we try to use coin selection, the unconfirmed non-change coin will not be used
// as a candidate and so we get a coin selection error due to insufficient funds.
@ -1733,6 +1740,7 @@ mod tests {
is_change: false,
spend_txid: None,
spend_block: None,
is_from_self: false,
}]);
assert_eq!(
control.create_spend(&destinations, &[dummy_op_dup], 1_001, None),
@ -1755,6 +1763,7 @@ mod tests {
is_change: true,
spend_txid: None,
spend_block: None,
is_from_self: false,
};
db_conn.new_unspent_coins(&[unconfirmed_coin]);
// Coin selection error due to insufficient funds.
@ -1786,6 +1795,7 @@ mod tests {
is_change: false,
spend_txid: None,
spend_block: None,
is_from_self: false,
}]);
// First, create a transaction using auto coin selection.
let psbt = if let CreateSpendResult::Success { psbt, .. } =
@ -1921,6 +1931,7 @@ mod tests {
is_change: false,
spend_txid: None,
spend_block: None,
is_from_self: false,
}]);
let empty_dest = &HashMap::<bitcoin::Address<address::NetworkUnchecked>, u64>::new();
assert!(matches!(
@ -1960,6 +1971,7 @@ mod tests {
is_change: false,
spend_txid: None,
spend_block: None,
is_from_self: false,
}]);
assert_eq!(
control.create_spend(&destinations, &[imma_op], 1_001, None),
@ -2005,6 +2017,7 @@ mod tests {
is_change: false,
spend_txid: None,
spend_block: None,
is_from_self: false,
},
Coin {
outpoint: dummy_op_b,
@ -2015,6 +2028,7 @@ mod tests {
is_change: false,
spend_txid: None,
spend_block: None,
is_from_self: false,
},
]);
@ -2162,6 +2176,7 @@ mod tests {
height: 184500,
time: 184500,
}),
is_from_self: false,
}]);
// The coin is spent so we cannot RBF.
assert_eq!(
@ -2273,6 +2288,7 @@ mod tests {
derivation_index: ChildNumber::from(0),
amount: bitcoin::Amount::from_sat(100_000_000),
spend_txid: Some(spend_tx.txid()),
is_from_self: false,
},
// Deposit 2
Coin {
@ -2287,6 +2303,7 @@ mod tests {
derivation_index: ChildNumber::from(1),
amount: bitcoin::Amount::from_sat(2000),
spend_txid: None,
is_from_self: false,
},
// This coin is a change output.
Coin {
@ -2298,6 +2315,7 @@ mod tests {
derivation_index: ChildNumber::from(2),
amount: bitcoin::Amount::from_sat(100_000_000 - 4000 - 1000),
spend_txid: None,
is_from_self: false,
},
// Deposit 3
Coin {
@ -2312,6 +2330,7 @@ mod tests {
derivation_index: ChildNumber::from(3),
amount: bitcoin::Amount::from_sat(3000),
spend_txid: None,
is_from_self: false,
},
]);
@ -2532,6 +2551,7 @@ mod tests {
is_change: false,
spend_txid: None,
spend_block: None,
is_from_self: false,
}]);
}
}

View File

@ -181,6 +181,10 @@ pub trait DatabaseConnection {
/// Store transactions in database, ignoring any that already exist.
fn new_txs(&mut self, txs: &[bitcoin::Transaction]);
/// For all unconfirmed coins and those confirmed after `prev_tip_height`,
/// update whether the coin is from self or not.
fn update_coins_from_self(&mut self, prev_tip_height: i32);
/// Retrieve a list of transactions and their corresponding block heights and times.
fn list_wallet_transactions(
&mut self,
@ -379,6 +383,11 @@ impl DatabaseConnection for SqliteConn {
self.new_txs(txs)
}
fn update_coins_from_self(&mut self, prev_tip_height: i32) {
self.update_coins_from_self(prev_tip_height)
.expect("must not fail")
}
fn list_wallet_transactions(
&mut self,
txids: &[bitcoin::Txid],
@ -421,6 +430,7 @@ pub struct Coin {
pub is_change: bool,
pub spend_txid: Option<bitcoin::Txid>,
pub spend_block: Option<BlockInfo>,
pub is_from_self: bool,
}
impl std::convert::From<DbCoin> for Coin {
@ -434,6 +444,7 @@ impl std::convert::From<DbCoin> for Coin {
is_change,
spend_txid,
spend_block,
is_from_self,
..
} = db_coin;
Coin {
@ -445,6 +456,7 @@ impl std::convert::From<DbCoin> for Coin {
is_change,
spend_txid,
spend_block: spend_block.map(BlockInfo::from),
is_from_self,
}
}
}

File diff suppressed because it is too large Load Diff

View File

@ -4,6 +4,11 @@ use std::{convert::TryFrom, str::FromStr};
use miniscript::bitcoin::{self, address, bip32, consensus::encode, psbt::Psbt};
// Due to limitations of Sqlite's ALTER TABLE command and in order not to recreate
// tables during migration:
// - Columns `num_inputs` and `num_outputs` of the transactions table remain nullable.
// - There is no CHECK constraint to prevent both `is_immature` and `is_from_self`
// being true in the coins table.
pub const SCHEMA: &str = "\
CREATE TABLE version (
version INTEGER NOT NULL
@ -42,6 +47,10 @@ CREATE TABLE wallets (
* The 'is_immature' field is for coinbase deposits that are not yet buried under 100
* blocks. Note coinbase deposits can't technically be unconfirmed but we keep them
* as such until they become mature.
*
* The `is_from_self` field indicates if the coin is the output of a transaction whose
* inputs are all from the same wallet as the coin. For an unconfirmed coin, this also
* means that all unconfirmed ancestors, if any, are from self.
*/
CREATE TABLE coins (
id INTEGER PRIMARY KEY NOT NULL,
@ -57,6 +66,7 @@ CREATE TABLE coins (
spend_block_height INTEGER,
spend_block_time INTEGER,
is_immature BOOLEAN NOT NULL CHECK (is_immature IN (0,1)),
is_from_self BOOLEAN NOT NULL DEFAULT 0 CHECK (is_from_self IN (0,1)),
UNIQUE (txid, vout),
FOREIGN KEY (wallet_id) REFERENCES wallets (id)
ON UPDATE RESTRICT
@ -82,7 +92,10 @@ CREATE TABLE addresses (
CREATE TABLE transactions (
id INTEGER PRIMARY KEY NOT NULL,
txid BLOB UNIQUE NOT NULL,
tx BLOB UNIQUE NOT NULL
tx BLOB UNIQUE NOT NULL,
num_inputs INTEGER CHECK (num_inputs IS NULL OR num_inputs > 0),
num_outputs INTEGER CHECK (num_outputs IS NULL OR num_outputs > 0),
is_coinbase BOOLEAN NOT NULL DEFAULT 0 CHECK (is_coinbase IN (0,1))
);
/* Transactions we created that spend some of our coins. */
@ -195,6 +208,12 @@ pub struct DbCoin {
pub is_change: bool,
pub spend_txid: Option<bitcoin::Txid>,
pub spend_block: Option<DbBlockInfo>,
/// A coin is from self if it is the output of a transaction whose
/// inputs are all from this wallet. For unconfirmed coins, we
/// further require that all unconfirmed ancestors, if any, also
/// be from self, as otherwise they will depend on an unconfirmed
/// external transaction.
pub is_from_self: bool,
}
impl TryFrom<&rusqlite::Row<'_>> for DbCoin {
@ -234,6 +253,7 @@ impl TryFrom<&rusqlite::Row<'_>> for DbCoin {
});
let is_immature: bool = row.get(12)?;
let is_from_self: bool = row.get(13)?;
Ok(DbCoin {
id,
@ -246,6 +266,7 @@ impl TryFrom<&rusqlite::Row<'_>> for DbCoin {
is_change,
spend_txid,
spend_block,
is_from_self,
})
}
}

View File

@ -50,6 +50,21 @@ where
.collect::<rusqlite::Result<Vec<T>>>()
}
/// Internal helper for queries boilerplate
pub fn db_query_row<P, F, T>(
conn: &mut rusqlite::Connection,
stmt_str: &str,
params: P,
f: F,
) -> Result<T, rusqlite::Error>
where
P: IntoIterator + rusqlite::Params,
P::Item: rusqlite::ToSql,
F: FnMut(&rusqlite::Row<'_>) -> rusqlite::Result<T>,
{
conn.prepare(stmt_str)?.query_row(params, f)
}
/// The current time as the number of seconds since the UNIX epoch, truncated to u32 since SQLite
/// only supports i64 integers.
pub fn curr_timestamp() -> u32 {
@ -307,6 +322,166 @@ fn migrate_v5_to_v6(conn: &mut rusqlite::Connection) -> Result<(), SqliteDbError
Ok(())
}
fn migrate_v6_to_v7(conn: &mut rusqlite::Connection) -> Result<(), SqliteDbError> {
db_exec(conn, |db_tx| {
db_tx.execute_batch(
"
ALTER TABLE transactions ADD COLUMN num_inputs INTEGER CHECK (num_inputs IS NULL OR num_inputs > 0);
ALTER TABLE transactions ADD COLUMN num_outputs INTEGER CHECK (num_outputs IS NULL OR num_outputs > 0);
ALTER TABLE transactions ADD COLUMN is_coinbase BOOLEAN NOT NULL DEFAULT 0 CHECK (is_coinbase IN (0,1));
ALTER TABLE coins ADD COLUMN is_from_self BOOLEAN NOT NULL DEFAULT 0 CHECK (is_from_self IN (0,1));
UPDATE version SET version = 7;
",
)?;
Ok(())
})?;
Ok(())
}
fn migrate_v7_to_v8(conn: &mut rusqlite::Connection) -> Result<(), SqliteDbError> {
// This migration is done as several database transactions in order not to
// have a very large database transaction containing all rows from the
// transactions table.
const TXIDS_BATCH_SIZE: u32 = 100;
loop {
let txids = db_query(
conn,
"SELECT txid FROM transactions WHERE num_inputs IS NULL LIMIT ?1",
rusqlite::params![TXIDS_BATCH_SIZE],
|row| {
let txid: Vec<u8> = row.get(0)?;
let txid: bitcoin::Txid = bitcoin::consensus::encode::deserialize(&txid)
.expect("We only store valid txids");
Ok(txid)
},
)?;
if txids.is_empty() {
break;
}
for txid in &txids {
let tx = db_query_row(
conn,
"SELECT tx FROM transactions WHERE txid = ?1",
rusqlite::params![txid[..].to_vec()],
|row| {
let tx: Vec<u8> = row.get(0)?;
let tx: bitcoin::Transaction = bitcoin::consensus::encode::deserialize(&tx)
.expect("We only store valid transactions");
Ok(tx)
},
)?;
db_exec(conn, |db_tx| {
let updated = db_tx.execute(
"UPDATE transactions SET num_inputs = ?1, num_outputs = ?2, is_coinbase = ?3 WHERE txid = ?4",
rusqlite::params![tx.input.len(), tx.output.len(), tx.is_coinbase(), txid[..].to_vec()],
)?;
assert_eq!(updated, 1);
Ok(())
})?;
}
}
// Update the `is_from_self` column for all unconfirmed coins and those
// confirmed after height 0, i.e. this will act on all coins.
let prev_tip_height = 0;
// As part of the same db_tx, first make sure that all rows of the
// transactions table have been updated.
db_exec(conn, |db_tx| {
let num_txs_to_update: u32 = db_tx.query_row(
"SELECT count(txid) FROM transactions WHERE num_inputs IS NULL OR num_outputs IS NULL",
[],
|row| row.get(0),
)?;
assert_eq!(num_txs_to_update, 0);
// This is a copy of `SqliteConn::update_coins_from_self` as of
// the time of writing this migration. We don't use that method
// directly in case the schema changes in future and it no longer
// works on the V7 schema.
// Given the requirement for unconfirmed coins that all ancestors
// be from self, we perform the update in a loop until no further
// rows are updated in order to iterate over the unconfirmed coins.
// Although we don't expect any unconfirmed transaction to have
// more than 25 in-mempool descendants including itself, there
// could be more descendants in the DB following a reorg and a
// rollback of the tip. The max number of iterations would be
// one per unconfirmed coin not from self plus one for all
// confirmed coins.
// In any case, the query only sets `is_from_self` to 1 for
// those coins with value 0 and so the number of rows affected
// by each iteration must become 0.
let max_iterations = {
let num_unconfirmed: u64 = db_tx.query_row(
"SELECT COUNT(*) FROM coins
WHERE blockheight IS NULL AND is_from_self = 0",
[],
|row| row.get(0),
)?;
// Add 1 for the confirmed coins, which will all
// be updated in the first iteration, and another 1
// as a final check there's nothing left to update.
num_unconfirmed.checked_add(2).expect("must fit")
};
log::debug!(
"Updating is_from_self in up to {} iterations..",
max_iterations
);
let mut updated = 0;
for i in 0..max_iterations {
updated = db_tx.execute(
"
UPDATE coins
SET is_from_self = 1
FROM transactions t
INNER JOIN (
SELECT
spend_txid,
SUM(
CASE
WHEN blockheight IS NOT NULL THEN 1
-- If the spending coin is unconfirmed, only count
-- it as an input coin if it is from self.
WHEN blockheight IS NULL AND is_from_self = 1 THEN 1
ELSE 0
END
) AS cnt
FROM coins
WHERE spend_txid IS NOT NULL
-- We only need to consider spend transactions that are
-- unconfirmed or confirmed after `prev_tip_height
-- as only these transactions will affect the coins that
-- we are updating.
AND (spend_block_height IS NULL OR spend_block_height > ?1)
GROUP BY spend_txid
) spends
ON t.txid = spends.spend_txid AND t.num_inputs = spends.cnt
WHERE coins.txid = t.txid
AND (coins.blockheight IS NULL OR coins.blockheight > ?1)
AND coins.is_from_self = 0
",
[prev_tip_height],
)?;
if updated == 0 {
log::debug!("Finished updating is_from_self in {} iterations.", i + 1);
break;
}
}
assert_eq!(
updated, 0,
"no rows expected to be updated on final iteration while updating is_from_self",
);
// Finally update the DB version.
db_tx.execute("UPDATE version SET version = 8", [])?;
Ok(())
})?;
Ok(())
}
/// Check the database version and if necessary apply the migrations to upgrade it to the current
/// one. The `bitcoin_txs` parameter is here for the migration from versions 4 and earlier, which
/// did not store the Bitcoin transactions in database, to versions 5 and later, which do. For a
@ -360,6 +535,16 @@ pub fn maybe_apply_migration(
migrate_v5_to_v6(&mut conn)?;
log::warn!("Migration from database version 5 to version 6 successful.");
}
6 => {
log::warn!("Upgrading database from version 6 to version 7.");
migrate_v6_to_v7(&mut conn)?;
log::warn!("Migration from database version 6 to version 7 successful.");
}
7 => {
log::warn!("Upgrading database from version 7 to version 8.");
migrate_v7_to_v8(&mut conn)?;
log::warn!("Migration from database version 7 to version 8 successful.");
}
_ => return Err(SqliteDbError::UnsupportedVersion(version)),
}
}

View File

@ -484,6 +484,10 @@ impl DatabaseConnection for DummyDatabase {
}
}
fn update_coins_from_self(&mut self, _prev_tip_height: i32) {
// noop
}
fn list_wallet_transactions(
&mut self,
txids: &[bitcoin::Txid],

View File

@ -75,14 +75,36 @@ def test_reorg_exclusion(lianad, bitcoind):
coin_b = get_coin(lianad, txid)
b_spend_tx = spend_coins(lianad, bitcoind, [coin_b])
# These are external deposits so not from self.
assert coin_a["is_from_self"] is False
assert coin_b["is_from_self"] is False
# A confirmed and spent coin
addr = lianad.rpc.getnewaddress()["address"]
txid = bitcoind.rpc.sendtoaddress(addr, 3)
bitcoind.generate_block(1, wait_for_mempool=txid)
txid_c = bitcoind.rpc.sendtoaddress(addr, 3)
wait_for(lambda: len(lianad.rpc.listcoins()["coins"]) == 3)
coin_c = get_coin(lianad, txid)
c_spend_tx = spend_coins(lianad, bitcoind, [coin_c])
bitcoind.generate_block(1, wait_for_mempool=1)
# Now refresh this coin while it is unconfirmed.
res = lianad.rpc.createspend({}, [get_coin(lianad, txid_c)["outpoint"]], 1)
c_spend_psbt = PSBT.from_base64(res["psbt"])
txid_d = sign_and_broadcast_psbt(lianad, c_spend_psbt)
wait_for(lambda: len(lianad.rpc.listcoins()["coins"]) == 4)
coin_c = get_coin(lianad, txid_c)
coin_d = get_coin(lianad, txid_d)
assert coin_c["is_from_self"] is False
assert coin_c["block_height"] is None
# Even though coin_d is from a self-send, coin_c is still unconfirmed
# and is not from self. Therefore, coin_d is not from self either.
assert coin_d["is_from_self"] is False
bitcoind.generate_block(1)
# Wait for confirmation to be detected.
wait_for(lambda: get_coin(lianad, txid_d)["block_height"] is not None)
coin_c = get_coin(lianad, txid_c)
coin_d = get_coin(lianad, txid_d)
assert coin_c["is_from_self"] is False
assert coin_c["block_height"] is not None
assert coin_d["is_from_self"] is True
assert coin_d["block_height"] is not None
# Make sure the transaction were confirmed >10 blocks ago, so bitcoind won't update the
# mempool during the reorg to the initial height.
@ -108,7 +130,7 @@ def test_reorg_exclusion(lianad, bitcoind):
tx = bitcoind.rpc.gettransaction(txid)["hex"]
bitcoind.rpc.sendrawtransaction(tx)
bitcoind.rpc.sendrawtransaction(b_spend_tx)
bitcoind.rpc.sendrawtransaction(c_spend_tx)
sign_and_broadcast_psbt(lianad, c_spend_psbt)
bitcoind.generate_block(1, wait_for_mempool=5)
new_height = bitcoind.rpc.getblockcount()
wait_for(lambda: lianad.rpc.getinfo()["block_height"] == new_height)
@ -128,9 +150,11 @@ def test_reorg_exclusion(lianad, bitcoind):
for c in lianad.rpc.listcoins()["coins"]
if coin_c["outpoint"] == c["outpoint"]
)
c_spend_txid = get_txid(c_spend_tx)
assert new_coin_c["spend_info"]["txid"] == c_spend_txid
assert new_coin_c["spend_info"]["txid"] == txid_d
assert new_coin_c["spend_info"]["height"] == new_height
new_coin_d = get_coin(lianad, txid_d)
assert new_coin_d["is_from_self"] is True
assert new_coin_d["block_height"] == new_height
# TODO: maybe test with some malleation for the deposit and spending txs?
@ -162,17 +186,26 @@ def test_reorg_status_recovery(lianad, bitcoind):
assert initial_height > 100
wait_for(lambda: lianad.rpc.getinfo()["block_height"] == initial_height)
# Both coins are confirmed. Spend the second one then get their infos.
# Both coins are confirmed. Refresh the second one then get their infos.
wait_for(lambda: len(list_coins()) == 2)
wait_for(lambda: all(c["block_height"] is not None for c in list_coins()))
coin_b = get_coin(lianad, txids[1])
tx = spend_coins(lianad, bitcoind, [coin_b])
locktime = bitcoind.rpc.decoderawtransaction(tx)["locktime"]
# Refresh coin_b.
res = lianad.rpc.createspend({}, [coin_b["outpoint"]], 1)
b_spend_psbt = PSBT.from_base64(res["psbt"])
txid = sign_and_broadcast_psbt(lianad, b_spend_psbt)
coin_c = get_coin(lianad, txid)
# coin_c is unconfirmed and marked as from self as its parent is confirmed.
assert coin_c["block_height"] is None
assert coin_c["is_from_self"] is True
locktime = b_spend_psbt.tx.nLockTime
assert initial_height - 100 <= locktime <= initial_height
bitcoind.generate_block(1, wait_for_mempool=1)
wait_for(lambda: spend_confirmed_noticed(lianad, coin_b["outpoint"]))
coin_a = get_coin(lianad, txids[0])
coin_b = get_coin(lianad, txids[1])
coin_c = get_coin(lianad, txid)
# Reorg the chain down to the initial height without shifting nor malleating
# any transaction. The coin info should be identical (except the spend info
@ -187,9 +220,14 @@ def test_reorg_status_recovery(lianad, bitcoind):
if locktime == initial_height:
# Cannot be mined until next block (initial_height + 1).
coin_b["spend_info"] = None
# coin_c no longer exists.
with pytest.raises(StopIteration):
get_coin(lianad, coin_c["outpoint"])
else:
# Otherwise, the tx will be mined at the height the reorg happened.
coin_b["spend_info"]["height"] = initial_height
new_coin_c = get_coin(lianad, coin_c["outpoint"])
assert new_coin_c["is_from_self"] is True
assert new_coin_b == coin_b

View File

@ -213,14 +213,22 @@ def test_coinbase_deposit(lianad, bitcoind):
wait_for_sync()
coins = lianad.rpc.listcoins()["coins"]
assert (
len(coins) == 1 and coins[0]["is_immature"] and coins[0]["spend_info"] is None
len(coins) == 1
and coins[0]["is_immature"]
and coins[0]["spend_info"] is None
and not coins[0]["is_from_self"]
)
# Generate 100 blocks to make the coinbase mature. We should detect it as such.
# It remains as not from self.
bitcoind.generate_block(100)
wait_for_sync()
coin = lianad.rpc.listcoins()["coins"][0]
assert not coin["is_immature"] and coin["block_height"] is not None
assert (
not coin["is_immature"]
and coin["block_height"] is not None
and not coins[0]["is_from_self"]
)
# We must be able to spend the mature coin.
destinations = {bitcoind.rpc.getnewaddress(): int(0.999999 * COIN)}
@ -249,13 +257,17 @@ def test_coinbase_deposit(lianad, bitcoind):
bitcoind.rpc.generatetoaddress(1, change_addr)
wait_for(lambda: any(c["is_immature"] for c in lianad.rpc.listcoins()["coins"]))
coin = next(c for c in lianad.rpc.listcoins()["coins"] if c["is_immature"])
assert coin["is_change"]
assert coin["is_change"] and not coin["is_from_self"]
bitcoind.generate_block(100)
wait_for_sync()
coin = next(
c for c in lianad.rpc.listcoins()["coins"] if c["outpoint"] == coin["outpoint"]
)
assert not coin["is_immature"] and coin["block_height"] is not None
assert (
not coin["is_immature"]
and coin["block_height"] is not None
and not coin["is_from_self"]
)
@pytest.mark.skipif(

View File

@ -100,6 +100,7 @@ def test_listcoins(lianad, bitcoind):
assert res[0]["is_change"] == False
assert res[0]["block_height"] is None
assert res[0]["spend_info"] is None
assert res[0]["is_from_self"] is False
assert len(lianad.rpc.listcoins(["confirmed", "spent", "spending"])["coins"]) == 0
assert (
@ -131,6 +132,8 @@ def test_listcoins(lianad, bitcoind):
== lianad.rpc.listcoins(["spent", "unconfirmed", "confirmed"], [outpoint_a])
)
assert lianad.rpc.listcoins()["coins"][0]["is_from_self"] is False
# Same if the coin gets spent.
spend_tx = spend_coins(lianad, bitcoind, (res[0],))
spend_txid = get_txid(spend_tx)
@ -140,6 +143,7 @@ def test_listcoins(lianad, bitcoind):
assert spend_info["height"] is None
assert len(lianad.rpc.listcoins(["spent"])["coins"]) == 0
assert len(lianad.rpc.listcoins(["spending"])["coins"]) == 1
assert lianad.rpc.listcoins(["spending"])["coins"][0]["is_from_self"] is False
# And if this spending tx gets confirmed.
bitcoind.generate_block(1, wait_for_mempool=spend_txid)
@ -154,6 +158,8 @@ def test_listcoins(lianad, bitcoind):
== lianad.rpc.listcoins(["spent"])
== lianad.rpc.listcoins(["spent", "unconfirmed", "confirmed"])
)
assert len(lianad.rpc.listcoins()["coins"]) == 1
assert lianad.rpc.listcoins()["coins"][0]["is_from_self"] is False
# Add a second coin.
addr_b = lianad.rpc.getnewaddress()["address"]
@ -161,6 +167,7 @@ def test_listcoins(lianad, bitcoind):
wait_for(lambda: len(lianad.rpc.listcoins()["coins"]) == 2)
res = lianad.rpc.listcoins(["unconfirmed"], [])["coins"]
outpoint_b = res[0]["outpoint"]
assert res[0]["is_from_self"] is False
# We have one unconfirmed coin and one spent coin.
assert (
@ -182,6 +189,7 @@ def test_listcoins(lianad, bitcoind):
== lianad.rpc.listcoins(["spending", "unconfirmed", "confirmed"])
== lianad.rpc.listcoins(["spending", "unconfirmed", "confirmed"], [outpoint_b])
)
assert lianad.rpc.listcoins([], [outpoint_b])["coins"][0]["is_from_self"] is False
# Now confirm the second coin.
bitcoind.generate_block(1, wait_for_mempool=txid_b)
@ -190,6 +198,7 @@ def test_listcoins(lianad, bitcoind):
lambda: lianad.rpc.listcoins([], [outpoint_b])["coins"][0]["block_height"]
== block_height
)
assert lianad.rpc.listcoins([], [outpoint_b])["coins"][0]["is_from_self"] is False
# We have one confirmed coin and one spent coin.
assert (
@ -1079,6 +1088,7 @@ def test_rbfpsbt_bump_fee(lianad, bitcoind):
bitcoind.generate_block(1, wait_for_mempool=txid)
wait_for(lambda: len(lianad.rpc.listcoins(["confirmed"])["coins"]) == 3)
coins = lianad.rpc.listcoins(["confirmed"])["coins"]
assert all(c["is_from_self"] is False for c in coins)
# Create a spend that will later be replaced.
first_outpoints = [c["outpoint"] for c in coins[:2]]
@ -1113,6 +1123,13 @@ def test_rbfpsbt_bump_fee(lianad, bitcoind):
for c in lianad.rpc.listcoins([], first_outpoints)["coins"]
)
)
# The change output is from self as its parent is confirmed.
lc_res = lianad.rpc.listcoins(["unconfirmed"], [])["coins"]
assert (
len(lc_res) == 1
and first_txid in lc_res[0]["outpoint"]
and lc_res[0]["is_from_self"] is True
)
# We can now use RBF, but the feerate must be higher than that of the first transaction.
with pytest.raises(RpcError, match=f"Feerate 1 too low for minimum feerate 2."):
lianad.rpc.rbfpsbt(first_txid, False, 1)
@ -1145,6 +1162,13 @@ def test_rbfpsbt_bump_fee(lianad, bitcoind):
for c in lianad.rpc.listcoins([], first_outpoints)["coins"]
)
)
# The change output of the replacement is also from self as its parent is confirmed.
lc_res = lianad.rpc.listcoins(["unconfirmed"], [])["coins"]
assert (
len(lc_res) == 1
and rbf_1_txid in lc_res[0]["outpoint"]
and lc_res[0]["is_from_self"] is True
)
mempool_rbf_1 = bitcoind.rpc.getmempoolentry(rbf_1_txid)
# Note that in the mempool entry, "ancestor" includes rbf_1_txid itself.
rbf_1_feerate = (
@ -1178,6 +1202,12 @@ def test_rbfpsbt_bump_fee(lianad, bitcoind):
for c in lianad.rpc.listcoins([], desc_1_outpoints)["coins"]
)
)
lc_res = [c for c in lianad.rpc.listcoins(["unconfirmed"], [])["coins"]]
assert (
len(lc_res) == 1
and desc_1_txid in lc_res[0]["outpoint"]
and lc_res[0]["is_from_self"] is True
)
# Add a new transaction spending the change from the first descendant.
desc_2_destinations = {
bitcoind.rpc.getnewaddress(): 25_000,
@ -1194,6 +1224,12 @@ def test_rbfpsbt_bump_fee(lianad, bitcoind):
for c in lianad.rpc.listcoins([], desc_2_outpoints)["coins"]
)
)
lc_res = [c for c in lianad.rpc.listcoins(["unconfirmed"], [])["coins"]]
assert (
len(lc_res) == 1
and desc_2_txid in lc_res[0]["outpoint"]
and lc_res[0]["is_from_self"] is True
)
# Now replace the first RBF, which will also remove its descendants.
rbf_2_res = lianad.rpc.rbfpsbt(rbf_1_txid, False, feerate)
rbf_2_psbt = PSBT.from_base64(rbf_2_res["psbt"])
@ -1216,6 +1252,12 @@ def test_rbfpsbt_bump_fee(lianad, bitcoind):
for c in lianad.rpc.listcoins([], first_outpoints)["coins"]
)
)
lc_res = [c for c in lianad.rpc.listcoins(["unconfirmed"], [])["coins"]]
assert (
len(lc_res) == 1
and rbf_2_txid in lc_res[0]["outpoint"]
and lc_res[0]["is_from_self"] is True
)
# The unconfirmed coins used in the descendant transactions have been removed so that
# only one of the input coins remains, and its spend info has been wiped so that it is as before.
assert lianad.rpc.listcoins([], desc_1_outpoints + desc_2_outpoints)["coins"] == [
@ -1230,6 +1272,14 @@ def test_rbfpsbt_bump_fee(lianad, bitcoind):
for c in lianad.rpc.listcoins([], first_outpoints)["coins"]
)
)
final_coins = lianad.rpc.listcoins()["coins"]
# We have the three original coins plus the change output from the last RBF.
assert len(final_coins) == 4
assert len(coins + lc_res) == 4
for fc in final_coins:
assert fc["outpoint"] in [c["outpoint"] for c in coins + lc_res]
# Original coins are not from self, but RBF change output is.
assert fc["is_from_self"] is (fc["outpoint"] in [c["outpoint"] for c in lc_res])
def test_rbfpsbt_insufficient_funds(lianad, bitcoind):