Merge bitcoin/bitcoin#33268: wallet: Identify transactions spending 0-value outputs, and add tests for anchor outputs in a wallet

113a422822 wallet: Add m_cached_from_me to cache "from me" status (Ava Chow)
609d265ebc test: Add a test for anchor outputs in the wallet (Ava Chow)
c40dc822d7 wallet: Throw an error in sendall if the tx size cannot be calculated (Ava Chow)
39a7dbdd27 wallet: Determine IsFromMe by checking for TXOs of inputs (Ava Chow)
e76c2f7a41 test: Test wallet 'from me' status change (Ava Chow)

Pull request description:

  One of the ways that the wallet would determine if a transaction was sent from the wallet was by checking if the total amount being spent by a transaction from outputs known to the wallet was greater than 0. This has worked fine until recently since there was no reason for 0-value outputs to be created. However, with ephemeral dust and P2A, it is possible to create standard 0-value outputs, and the wallet was not correctly identifying the spends of such outputs. This PR updates `IsFromMe` to only check whether the wallet knows any of the inputs, rather than checking the debit amount of a transaction.

  Additionally, a new functional test is added to test for this case, as well as a few other anchor output related scenarios. This also revealed a bug in `sendall` which would cause an assertion error when trying to spend all of the outputs in a wallet that has anchor outputs.

  Fixes #33265

ACKs for top commit:
  rkrux:
    lgtm ACK 113a422822
  enirox001:
    Tested ACK 113a422. Ran the full functional test suite including `wallet_anchor.py`; all tests passed. Fix for 0 value anchor detection and sendall size errors looks good. LGTM.
  furszy:
    ACK 113a422822

Tree-SHA512: df2ce4b258d1875ad0b4f27a5b9b4437137a5889a7d5ed7fbca65f904615e9572d232a8b8d070760f75ac168c1a49b7981f6b5052308575866dc610d191ca964
This commit is contained in:
merge-script 2025-09-12 14:42:08 +01:00
commit d20f10affb
No known key found for this signature in database
GPG Key ID: 2EEB9F5CC09526C1
8 changed files with 182 additions and 3 deletions

View File

@ -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)

View File

@ -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<CAmount> 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);

View File

@ -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<bool> m_cached_from_me{std::nullopt};
int64_t nOrderPos; //!< position in ordered transaction list
std::multimap<int64_t, CWalletTx*>::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 */

View File

@ -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

View File

@ -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)

View File

@ -152,6 +152,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',

116
test/functional/wallet_anchor.py Executable file
View File

@ -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()

View File

@ -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()