diff --git a/doc/release-notes.md b/doc/release-notes.md --- a/doc/release-notes.md +++ b/doc/release-notes.md @@ -12,3 +12,10 @@ followed by `generatetoaddress`, can generate blocks for command line testing purposes. This is a client-side version of the former `generate` RPC. See the help for details. + +## Notification changes + +`-walletnotify` notifications are now sent for wallet transactions that are +removed from the mempool because they conflict with a new block. These +notifications were sent previously before the v0.21.13 release, but had been +broken since that release. diff --git a/src/interfaces/chain.h b/src/interfaces/chain.h --- a/src/interfaces/chain.h +++ b/src/interfaces/chain.h @@ -25,6 +25,8 @@ class CScheduler; class TxValidationState; +enum class MemPoolRemovalReason; + struct BlockHash; struct bilingual_str; struct CBlockLocator; @@ -271,8 +273,9 @@ public: virtual ~Notifications() {} virtual void transactionAddedToMempool(const CTransactionRef &tx) {} - virtual void transactionRemovedFromMempool(const CTransactionRef &ptx) { - } + virtual void + transactionRemovedFromMempool(const CTransactionRef &ptx, + MemPoolRemovalReason reason) {} virtual void blockConnected(const CBlock &block, int height) {} virtual void blockDisconnected(const CBlock &block, int height) {} virtual void updatedBlockTip() {} diff --git a/src/interfaces/chain.cpp b/src/interfaces/chain.cpp --- a/src/interfaces/chain.cpp +++ b/src/interfaces/chain.cpp @@ -75,8 +75,10 @@ void TransactionAddedToMempool(const CTransactionRef &tx) override { m_notifications->transactionAddedToMempool(tx); } - void TransactionRemovedFromMempool(const CTransactionRef &tx) override { - m_notifications->transactionRemovedFromMempool(tx); + void + TransactionRemovedFromMempool(const CTransactionRef &tx, + MemPoolRemovalReason reason) override { + m_notifications->transactionRemovedFromMempool(tx, reason); } void BlockConnected(const std::shared_ptr<const CBlock> &block, const CBlockIndex *index) override { diff --git a/src/txmempool.cpp b/src/txmempool.cpp --- a/src/txmempool.cpp +++ b/src/txmempool.cpp @@ -469,7 +469,8 @@ // for any reason except being included in a block. Clients interested // in transactions included in blocks can subscribe to the // BlockConnected notification. - GetMainSignals().TransactionRemovedFromMempool(it->GetSharedTx()); + GetMainSignals().TransactionRemovedFromMempool(it->GetSharedTx(), + reason); } for (const CTxIn &txin : it->GetTx().vin) { diff --git a/src/validationinterface.h b/src/validationinterface.h --- a/src/validationinterface.h +++ b/src/validationinterface.h @@ -21,6 +21,7 @@ class CValidationInterface; class uint256; class CScheduler; +enum class MemPoolRemovalReason; /** Register subscriber */ void RegisterValidationInterface(CValidationInterface *callbacks); @@ -137,7 +138,8 @@ * * Called on a background thread. */ - virtual void TransactionRemovedFromMempool(const CTransactionRef &ptx) {} + virtual void TransactionRemovedFromMempool(const CTransactionRef &ptx, + MemPoolRemovalReason reason) {} /** * Notifies listeners of a block being connected. * Provides a vector of transactions evicted from the mempool as a result. @@ -218,7 +220,8 @@ void UpdatedBlockTip(const CBlockIndex *, const CBlockIndex *, bool fInitialDownload); void TransactionAddedToMempool(const CTransactionRef &); - void TransactionRemovedFromMempool(const CTransactionRef &); + void TransactionRemovedFromMempool(const CTransactionRef &, + MemPoolRemovalReason); void BlockConnected(const std::shared_ptr<const CBlock> &, const CBlockIndex *pindex); void BlockDisconnected(const std::shared_ptr<const CBlock> &, diff --git a/src/validationinterface.cpp b/src/validationinterface.cpp --- a/src/validationinterface.cpp +++ b/src/validationinterface.cpp @@ -213,10 +213,11 @@ ptx->GetHash().ToString()); } -void CMainSignals::TransactionRemovedFromMempool(const CTransactionRef &ptx) { - auto event = [ptx, this] { +void CMainSignals::TransactionRemovedFromMempool(const CTransactionRef &ptx, + MemPoolRemovalReason reason) { + auto event = [ptx, reason, this] { m_internals->Iterate([&](CValidationInterface &callbacks) { - callbacks.TransactionRemovedFromMempool(ptx); + callbacks.TransactionRemovedFromMempool(ptx, reason); }); }; ENQUEUE_AND_LOG_EVENT(event, "%s: txid=%s", __func__, diff --git a/src/wallet/wallet.h b/src/wallet/wallet.h --- a/src/wallet/wallet.h +++ b/src/wallet/wallet.h @@ -1089,7 +1089,8 @@ std::optional<int> max_height, const WalletRescanReserver &reserver, bool fUpdate); - void transactionRemovedFromMempool(const CTransactionRef &ptx) override; + void transactionRemovedFromMempool(const CTransactionRef &ptx, + MemPoolRemovalReason reason) override; void ReacceptWalletTransactions() EXCLUSIVE_LOCKS_REQUIRED(cs_wallet); void ResendWalletTransactions(); struct Balance { diff --git a/src/wallet/wallet.cpp b/src/wallet/wallet.cpp --- a/src/wallet/wallet.cpp +++ b/src/wallet/wallet.cpp @@ -23,6 +23,7 @@ #include <script/sighashtype.h> #include <script/sign.h> #include <script/signingprovider.h> +#include <txmempool.h> #include <util/bip32.h> #include <util/check.h> #include <util/error.h> @@ -1202,12 +1203,45 @@ } } -void CWallet::transactionRemovedFromMempool(const CTransactionRef &ptx) { +void CWallet::transactionRemovedFromMempool(const CTransactionRef &ptx, + MemPoolRemovalReason reason) { LOCK(cs_wallet); auto it = mapWallet.find(ptx->GetId()); if (it != mapWallet.end()) { it->second.fInMempool = false; } + // Handle transactions that were removed from the mempool because they + // conflict with transactions in a newly connected block. + if (reason == MemPoolRemovalReason::CONFLICT) { + // Call SyncNotifications, so external -walletnotify notifications will + // be triggered for these transactions. Set Status::UNCONFIRMED instead + // of Status::CONFLICTED for a few reasons: + // + // 1. The transactionRemovedFromMempool callback does not currently + // provide the conflicting block's hash and height, and for backwards + // compatibility reasons it may not be not safe to store conflicted + // wallet transactions with a null block hash. See + // https://github.com/bitcoin/bitcoin/pull/18600#discussion_r420195993. + // 2. For most of these transactions, the wallet's internal conflict + // detection in the blockConnected handler will subsequently call + // MarkConflicted and update them with CONFLICTED status anyway. This + // applies to any wallet transaction that has inputs spent in the + // block, or that has ancestors in the wallet with inputs spent by + // the block. + // 3. Longstanding behavior since the sync implementation in + // https://github.com/bitcoin/bitcoin/pull/9371 and the prior sync + // implementation before that was to mark these transactions + // unconfirmed rather than conflicted. + // + // Nothing described above should be seen as an unchangeable requirement + // when improving this code in the future. The wallet's heuristics for + // distinguishing between conflicted and unconfirmed transactions are + // imperfect, and could be improved in general, see + // https://github.com/bitcoin-core/bitcoin-devwiki/wiki/Wallet-Transaction-Conflict-Tracking + SyncTransaction(ptx, + {CWalletTx::Status::UNCONFIRMED, /* block height */ 0, + BlockHash(), /* index */ 0}); + } } void CWallet::blockConnected(const CBlock &block, int height) { @@ -1220,7 +1254,8 @@ CWalletTx::Confirmation confirm(CWalletTx::Status::CONFIRMED, height, block_hash, index); SyncTransaction(block.vtx[index], confirm); - transactionRemovedFromMempool(block.vtx[index]); + transactionRemovedFromMempool(block.vtx[index], + MemPoolRemovalReason::BLOCK); } } diff --git a/test/functional/feature_notifications.py b/test/functional/feature_notifications.py --- a/test/functional/feature_notifications.py +++ b/test/functional/feature_notifications.py @@ -12,6 +12,7 @@ assert_equal, connect_nodes, wait_until, + disconnect_nodes, hex_str_to_bytes, ) @@ -118,7 +119,7 @@ for tx_file in os.listdir(self.walletnotify_dir): os.remove(os.path.join(self.walletnotify_dir, tx_file)) - # Transactions tests. Give node 0 same wallet seed as + # Conflicting transactions tests. Give node 0 same wallet seed as # node 1, generate spends from node 0, and check notifications # triggered by node 1 self.log.info("test -walletnotify with conflicting transactions") @@ -145,6 +146,34 @@ self.expect_wallet_notify([tx1]) assert_equal(self.nodes[1].gettransaction(tx1)["confirmations"], 1) + # Generate conflicting transactions with the nodes disconnected. + # Sending almost the entire available balance on each node, but + # with a slightly different amount, ensures that there will be + # a conflict. + balance = self.nodes[0].getbalance() + disconnect_nodes(self.nodes[0], self.nodes[1]) + tx2_node0 = self.nodes[0].sendtoaddress( + address=ADDRESS_ECREG_UNSPENDABLE, amount=balance - 20) + tx2_node1 = self.nodes[1].sendtoaddress( + address=ADDRESS_ECREG_UNSPENDABLE, amount=balance - 21) + assert tx2_node0 != tx2_node1 + self.expect_wallet_notify([tx2_node1]) + # So far tx2_node1 has no conflicting tx + assert not self.nodes[1].gettransaction( + tx2_node1)['walletconflicts'] + + # Mine a block on node0, reconnect the nodes, check that tx2_node1 + # has a conflicting tx after syncing with node0. + self.nodes[0].generatetoaddress(1, ADDRESS_ECREG_UNSPENDABLE) + connect_nodes(self.nodes[0], self.nodes[1]) + self.sync_blocks() + assert tx2_node0 in self.nodes[1].gettransaction(tx2_node1)[ + 'walletconflicts'] + + # node1's wallet will notify of the new confirmed transaction tx2_0 + # and about the conflicted transaction tx2_1. + self.expect_wallet_notify([tx2_node0, tx2_node1]) + # Create an invalid chain and ensure the node warns. self.log.info("test -alertnotify for forked chain") fork_block = self.nodes[0].getbestblockhash()