From e76c2f7a4111f87080e31539f83c21390fcd8f3b Mon Sep 17 00:00:00 2001 From: Ava Chow Date: Mon, 1 Sep 2025 13:38:28 -0700 Subject: [PATCH 1/5] test: Test wallet 'from me' status change If something is imported into the wallet, it can change the 'from me' status of a transaction. This status is only visible through gettransaction's "fee" field which is only shown for transactions that are 'from me'. --- test/functional/wallet_listtransactions.py | 49 ++++++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/test/functional/wallet_listtransactions.py b/test/functional/wallet_listtransactions.py index e4b54388338..714e6594657 100755 --- a/test/functional/wallet_listtransactions.py +++ b/test/functional/wallet_listtransactions.py @@ -5,9 +5,12 @@ """Test the listtransactions API.""" from decimal import Decimal +import time import os import shutil +from test_framework.blocktools import MAX_FUTURE_BLOCK_TIME +from test_framework.descriptors import descsum_create from test_framework.messages import ( COIN, tx_from_hex, @@ -18,7 +21,9 @@ from test_framework.util import ( assert_array_result, assert_equal, assert_raises_rpc_error, + find_vout_for_address, ) +from test_framework.wallet_util import get_generate_key class ListTransactionsTest(BitcoinTestFramework): @@ -97,6 +102,7 @@ class ListTransactionsTest(BitcoinTestFramework): self.run_coinjoin_test() self.run_invalid_parameters_test() self.test_op_return() + self.test_from_me_status_change() def run_rbf_opt_in_test(self): """Test the opt-in-rbf flag for sent and received transactions.""" @@ -311,6 +317,49 @@ class ListTransactionsTest(BitcoinTestFramework): assert 'address' not in op_ret_tx + def test_from_me_status_change(self): + self.log.info("Test gettransaction after changing a transaction's 'from me' status") + self.nodes[0].createwallet("fromme") + default_wallet = self.nodes[0].get_wallet_rpc(self.default_wallet_name) + wallet = self.nodes[0].get_wallet_rpc("fromme") + + # The 'fee' field of gettransaction is only added when the transaction is 'from me' + # Run twice, once for a transaction in the mempool, again when it confirms + for confirm in [False, True]: + key = get_generate_key() + descriptor = descsum_create(f"wpkh({key.privkey})") + default_wallet.importdescriptors([{"desc": descriptor, "timestamp": "now"}]) + + send_res = default_wallet.send(outputs=[{key.p2wpkh_addr: 1}, {wallet.getnewaddress(): 1}]) + assert_equal(send_res["complete"], True) + vout = find_vout_for_address(self.nodes[0], send_res["txid"], key.p2wpkh_addr) + utxos = [{"txid": send_res["txid"], "vout": vout}] + self.generate(self.nodes[0], 1, sync_fun=self.no_op) + + # Send to the test wallet, ensuring that one input is for the descriptor we will import, + # and that there are other inputs belonging to only the sending wallet + send_res = default_wallet.send(outputs=[{wallet.getnewaddress(): 1.5}], inputs=utxos, add_inputs=True) + assert_equal(send_res["complete"], True) + txid = send_res["txid"] + self.nodes[0].syncwithvalidationinterfacequeue() + tx_info = wallet.gettransaction(txid) + assert "fee" not in tx_info + assert_equal(any(detail["category"] == "send" for detail in tx_info["details"]), False) + + if confirm: + self.generate(self.nodes[0], 1, sync_fun=self.no_op) + # Mock time forward and generate blocks so that the import does not rescan the transaction + self.nodes[0].setmocktime(int(time.time()) + MAX_FUTURE_BLOCK_TIME + 1) + self.generate(self.nodes[0], 10, sync_fun=self.no_op) + + import_res = wallet.importdescriptors([{"desc": descriptor, "timestamp": "now"}]) + assert_equal(import_res[0]["success"], True) + # TODO: We should check that the fee matches, but since the transaction spends inputs + # not known to the wallet, it is incorrectly calculating the fee. + # assert_equal(wallet.gettransaction(txid)["fee"], fee) + tx_info = wallet.gettransaction(txid) + assert "fee" in tx_info + assert_equal(any(detail["category"] == "send" for detail in tx_info["details"]), True) if __name__ == '__main__': ListTransactionsTest(__file__).main() From 39a7dbdd277d1dea9a70314d8cc5ae057999ee88 Mon Sep 17 00:00:00 2001 From: Ava Chow Date: Thu, 28 Aug 2025 13:39:46 -0700 Subject: [PATCH 2/5] wallet: Determine IsFromMe by checking for TXOs of inputs Instead of checking whether the total amount of inputs known by the wallet is greater than 0, we should be checking for whether the input is known by the wallet. This enables us to determine whether a transaction spends an of output with an amount of 0, which is necessary for marking 0-value dust outputs as spent. --- src/wallet/wallet.cpp | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/wallet/wallet.cpp b/src/wallet/wallet.cpp index 6df74e848b8..b51e7d1109b 100644 --- a/src/wallet/wallet.cpp +++ b/src/wallet/wallet.cpp @@ -1634,7 +1634,11 @@ bool CWallet::IsMine(const COutPoint& outpoint) const bool CWallet::IsFromMe(const CTransaction& tx) const { - return (GetDebit(tx) > 0); + LOCK(cs_wallet); + for (const CTxIn& txin : tx.vin) { + if (GetTXO(txin.prevout)) return true; + } + return false; } CAmount CWallet::GetDebit(const CTransaction& tx) const From c40dc822d74aea46e4a21774ca282e008f609c2a Mon Sep 17 00:00:00 2001 From: Ava Chow Date: Thu, 28 Aug 2025 15:13:36 -0700 Subject: [PATCH 3/5] wallet: Throw an error in sendall if the tx size cannot be calculated --- src/wallet/rpc/spend.cpp | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/wallet/rpc/spend.cpp b/src/wallet/rpc/spend.cpp index c333cb54c99..d00547a8975 100644 --- a/src/wallet/rpc/spend.cpp +++ b/src/wallet/rpc/spend.cpp @@ -1521,7 +1521,6 @@ RPCHelpMan sendall() CoinFilterParams coins_params; coins_params.min_amount = 0; for (const COutput& output : AvailableCoins(*pwallet, &coin_control, fee_rate, coins_params).All()) { - CHECK_NONFATAL(output.input_bytes > 0); if (send_max && fee_rate.GetFee(output.input_bytes) > output.txout.nValue) { continue; } @@ -1544,6 +1543,9 @@ RPCHelpMan sendall() // estimate final size of tx const TxSize tx_size{CalculateMaximumSignedTxSize(CTransaction(rawTx), pwallet.get())}; + if (tx_size.vsize == -1) { + throw JSONRPCError(RPC_WALLET_ERROR, "Unable to determine the size of the transaction, the wallet contains unsolvable descriptors"); + } const CAmount fee_from_size{fee_rate.GetFee(tx_size.vsize)}; const std::optional total_bump_fees{pwallet->chain().calculateCombinedBumpFee(outpoints_spent, fee_rate)}; CAmount effective_value = total_input_value - fee_from_size - total_bump_fees.value_or(0); From 609d265ebc51abfe9a9ce570da647b6839dc1214 Mon Sep 17 00:00:00 2001 From: Ava Chow Date: Thu, 28 Aug 2025 15:13:23 -0700 Subject: [PATCH 4/5] test: Add a test for anchor outputs in the wallet --- test/functional/test_framework/script_util.py | 1 + test/functional/test_runner.py | 1 + test/functional/wallet_anchor.py | 116 ++++++++++++++++++ 3 files changed, 118 insertions(+) create mode 100755 test/functional/wallet_anchor.py diff --git a/test/functional/test_framework/script_util.py b/test/functional/test_framework/script_util.py index 5736eb6bc49..3b9d1957311 100755 --- a/test/functional/test_framework/script_util.py +++ b/test/functional/test_framework/script_util.py @@ -66,6 +66,7 @@ DUMMY_MIN_OP_RETURN_SCRIPT = CScript([OP_RETURN] + ([OP_0] * (MIN_PADDING - 1))) assert len(DUMMY_MIN_OP_RETURN_SCRIPT) == MIN_PADDING PAY_TO_ANCHOR = CScript([OP_1, bytes.fromhex("4e73")]) +ANCHOR_ADDRESS = "bcrt1pfeesnyr2tx" def key_to_p2pk_script(key): key = check_key(key) diff --git a/test/functional/test_runner.py b/test/functional/test_runner.py index f2e514a7a46..ad1837d1cdb 100755 --- a/test/functional/test_runner.py +++ b/test/functional/test_runner.py @@ -151,6 +151,7 @@ BASE_SCRIPTS = [ 'rpc_orphans.py', 'wallet_listreceivedby.py', 'wallet_abandonconflict.py', + 'wallet_anchor.py', 'feature_reindex.py', 'feature_reindex_readonly.py', 'wallet_labels.py', diff --git a/test/functional/wallet_anchor.py b/test/functional/wallet_anchor.py new file mode 100755 index 00000000000..9943abe1592 --- /dev/null +++ b/test/functional/wallet_anchor.py @@ -0,0 +1,116 @@ +#!/usr/bin/env python3 +# Copyright (c) 2025-present The Bitcoin Core developers +# Distributed under the MIT software license, see the accompanying +# file COPYING or https://www.opensource.org/licenses/mit-license.php. + +import time + +from test_framework.blocktools import MAX_FUTURE_BLOCK_TIME +from test_framework.descriptors import descsum_create +from test_framework.messages import ( + COutPoint, + CTxIn, + CTxInWitness, + CTxOut, +) +from test_framework.script_util import ( + ANCHOR_ADDRESS, + PAY_TO_ANCHOR, +) +from test_framework.test_framework import BitcoinTestFramework +from test_framework.util import ( + assert_equal, + assert_raises_rpc_error, +) +from test_framework.wallet import MiniWallet + +class WalletAnchorTest(BitcoinTestFramework): + def set_test_params(self): + self.num_nodes = 1 + + def skip_test_if_missing_module(self): + self.skip_if_no_wallet() + + def test_0_value_anchor_listunspent(self): + self.log.info("Test that 0-value anchor outputs are detected as UTXOs") + + # Create an anchor output, and spend it + sender = MiniWallet(self.nodes[0]) + anchor_tx = sender.create_self_transfer(fee_rate=0, version=3)["tx"] + anchor_tx.vout.append(CTxOut(0, PAY_TO_ANCHOR)) + anchor_spend = sender.create_self_transfer(version=3)["tx"] + anchor_spend.vin.append(CTxIn(COutPoint(anchor_tx.txid_int, 1), b"")) + anchor_spend.wit.vtxinwit.append(CTxInWitness()) + submit_res = self.nodes[0].submitpackage([anchor_tx.serialize().hex(), anchor_spend.serialize().hex()]) + assert_equal(submit_res["package_msg"], "success") + anchor_txid = anchor_tx.txid_hex + anchor_spend_txid = anchor_spend.txid_hex + + # Mine each tx in separate blocks + self.generateblock(self.nodes[0], sender.get_address(), [anchor_tx.serialize().hex()]) + anchor_tx_height = self.nodes[0].getblockcount() + self.generateblock(self.nodes[0], sender.get_address(), [anchor_spend.serialize().hex()]) + + # Mock time forward and generate some blocks to avoid rescanning of latest blocks + self.nodes[0].setmocktime(int(time.time()) + MAX_FUTURE_BLOCK_TIME + 1) + self.generate(self.nodes[0], 10) + + self.nodes[0].createwallet(wallet_name="anchor", disable_private_keys=True) + wallet = self.nodes[0].get_wallet_rpc("anchor") + import_res = wallet.importdescriptors([{"desc": descsum_create(f"addr({ANCHOR_ADDRESS})"), "timestamp": "now"}]) + assert_equal(import_res[0]["success"], True) + + # The wallet should have no UTXOs, and not know of the anchor tx or its spend + assert_equal(wallet.listunspent(), []) + assert_raises_rpc_error(-5, "Invalid or non-wallet transaction id", wallet.gettransaction, anchor_txid) + assert_raises_rpc_error(-5, "Invalid or non-wallet transaction id", wallet.gettransaction, anchor_spend_txid) + + # Rescanning the block containing the anchor so that listunspent will list the output + wallet.rescanblockchain(0, anchor_tx_height) + utxos = wallet.listunspent() + assert_equal(len(utxos), 1) + assert_equal(utxos[0]["txid"], anchor_txid) + assert_equal(utxos[0]["address"], ANCHOR_ADDRESS) + assert_equal(utxos[0]["amount"], 0) + wallet.gettransaction(anchor_txid) + assert_raises_rpc_error(-5, "Invalid or non-wallet transaction id", wallet.gettransaction, anchor_spend_txid) + + # Rescan the rest of the blockchain to see the anchor was spent + wallet.rescanblockchain() + assert_equal(wallet.listunspent(), []) + wallet.gettransaction(anchor_spend_txid) + + def test_cannot_sign_anchors(self): + self.log.info("Test that the wallet cannot spend anchor outputs") + for disable_privkeys in [False, True]: + self.nodes[0].createwallet(wallet_name=f"anchor_spend_{disable_privkeys}", disable_private_keys=disable_privkeys) + wallet = self.nodes[0].get_wallet_rpc(f"anchor_spend_{disable_privkeys}") + import_res = wallet.importdescriptors([ + {"desc": descsum_create(f"addr({ANCHOR_ADDRESS})"), "timestamp": "now"}, + {"desc": descsum_create(f"raw({PAY_TO_ANCHOR.hex()})"), "timestamp": "now"} + ]) + assert_equal(import_res[0]["success"], disable_privkeys) + assert_equal(import_res[1]["success"], disable_privkeys) + + anchor_txid = self.default_wallet.sendtoaddress(ANCHOR_ADDRESS, 1) + self.generate(self.nodes[0], 1) + + wallet = self.nodes[0].get_wallet_rpc("anchor_spend_True") + utxos = wallet.listunspent() + assert_equal(len(utxos), 1) + assert_equal(utxos[0]["txid"], anchor_txid) + assert_equal(utxos[0]["address"], ANCHOR_ADDRESS) + assert_equal(utxos[0]["amount"], 1) + + assert_raises_rpc_error(-4, "Missing solving data for estimating transaction size", wallet.send, [{self.default_wallet.getnewaddress(): 0.9999}]) + assert_raises_rpc_error(-4, "Error: Private keys are disabled for this wallet", wallet.sendtoaddress, self.default_wallet.getnewaddress(), 0.9999) + assert_raises_rpc_error(-4, "Unable to determine the size of the transaction, the wallet contains unsolvable descriptors", wallet.sendall, recipients=[self.default_wallet.getnewaddress()], inputs=utxos) + assert_raises_rpc_error(-4, "Unable to determine the size of the transaction, the wallet contains unsolvable descriptors", wallet.sendall, recipients=[self.default_wallet.getnewaddress()]) + + def run_test(self): + self.default_wallet = self.nodes[0].get_wallet_rpc(self.default_wallet_name) + self.test_0_value_anchor_listunspent() + self.test_cannot_sign_anchors() + +if __name__ == '__main__': + WalletAnchorTest(__file__).main() From 113a4228229baedda2a730e097f2d59ad58a4b0d Mon Sep 17 00:00:00 2001 From: Ava Chow Date: Tue, 20 Feb 2024 11:54:35 -0500 Subject: [PATCH 5/5] wallet: Add m_cached_from_me to cache "from me" status m_cached_from_me is used to track whether a transaction is "from me", i.e. has any inputs which belong to the wallet. This is held in memory only in the same way that a transaction's balances are. --- src/wallet/receive.cpp | 5 ++++- src/wallet/transaction.h | 3 +++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/src/wallet/receive.cpp b/src/wallet/receive.cpp index df3fbc0877d..13a25b36326 100644 --- a/src/wallet/receive.cpp +++ b/src/wallet/receive.cpp @@ -195,7 +195,10 @@ void CachedTxGetAmounts(const CWallet& wallet, const CWalletTx& wtx, bool CachedTxIsFromMe(const CWallet& wallet, const CWalletTx& wtx) { - return (CachedTxGetDebit(wallet, wtx, /*avoid_reuse=*/false) > 0); + if (!wtx.m_cached_from_me.has_value()) { + wtx.m_cached_from_me = wallet.IsFromMe(*wtx.tx); + } + return wtx.m_cached_from_me.value(); } // NOLINTNEXTLINE(misc-no-recursion) diff --git a/src/wallet/transaction.h b/src/wallet/transaction.h index bc6f0ef8450..1dbcdd2d921 100644 --- a/src/wallet/transaction.h +++ b/src/wallet/transaction.h @@ -232,6 +232,8 @@ public: * CWallet::ComputeTimeSmart(). */ unsigned int nTimeSmart; + // Cached value for whether the transaction spends any inputs known to the wallet + mutable std::optional m_cached_from_me{std::nullopt}; int64_t nOrderPos; //!< position in ordered transaction list std::multimap::const_iterator m_it_wtxOrdered; @@ -339,6 +341,7 @@ public: m_amounts[CREDIT].Reset(); fChangeCached = false; m_is_cache_empty = true; + m_cached_from_me = std::nullopt; } /** True if only scriptSigs are different */