Skip to content

Commit c336f81

Browse files
committed
Merge bitcoin#25504: RPC: allow to track coins by parent descriptors
a6b0c1f doc: add releases notes for 25504 (listsinceblock updates) (Antoine Poinsot) 0fd2d14 rpc: add an include_change parameter to listsinceblock (Antoine Poinsot) 55f98d0 rpc: output parent wallet descriptors for coins in listunspent (Antoine Poinsot) b724476 rpc: output wallet descriptors for received entries in listsinceblock (Antoine Poinsot) 55a82ea wallet: allow to fetch the wallet descriptors for a given Script (Antoine Poinsot) Pull request description: Wallet descriptors are useful for applications using the Bitcoin Core wallet as a backend for tracking coins, as they allow to track coins for multiple descriptors in a single wallet. However there is no information currently given for such applications to link a coin with an imported descriptor, severely limiting the possibilities for such applications of using multiple descriptors in a single wallet. This PR outputs the matching imported descriptor(s) for a given received coin in `listsinceblock` (and friends). It comes from a need for an application i'm working on, but i think it's something any software using `bitcoind` to track multiple descriptors in a single wallet would have eventually. For instance i'm thinking about the BDK project. Currently, the way to achieve this is to import raw addresses with labels and to have your application be responsible for wallet things like the gap limit. I'll add this to the output of `listunspent` too if this gets a few Concept ACKs. ACKs for top commit: instagibbs: ACK bitcoin@a6b0c1f achow101: re-ACK a6b0c1f Tree-SHA512: 7a5850e8de98b439ddede2cb72de0208944f8cda67272e8b8037678738d55b7a5272375be808b0f7d15def4904430e089dafdcc037436858ff3292c5f8b75e37
2 parents cf39913 + a6b0c1f commit c336f81

12 files changed

+158
-9
lines changed

doc/release-notes-25504.md

+6
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
Updated RPCs
2+
------------
3+
4+
- The `listsinceblock`, `listtransactions` and `gettransaction` output now contain a new
5+
`parent_descs` field for every "receive" entry.
6+
- A new optional `include_change` parameter was added to the `listsinceblock` command.

src/rpc/client.cpp

+1
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,7 @@ static const CRPCConvertParam vRPCConvertParams[] =
7474
{ "listsinceblock", 1, "target_confirmations" },
7575
{ "listsinceblock", 2, "include_watchonly" },
7676
{ "listsinceblock", 3, "include_removed" },
77+
{ "listsinceblock", 4, "include_change" },
7778
{ "sendmany", 1, "amounts" },
7879
{ "sendmany", 2, "minconf" },
7980
{ "sendmany", 4, "subtractfeefrom" },

src/wallet/receive.cpp

+3-3
Original file line numberDiff line numberDiff line change
@@ -193,7 +193,8 @@ CAmount CachedTxGetAvailableCredit(const CWallet& wallet, const CWalletTx& wtx,
193193

194194
void CachedTxGetAmounts(const CWallet& wallet, const CWalletTx& wtx,
195195
std::list<COutputEntry>& listReceived,
196-
std::list<COutputEntry>& listSent, CAmount& nFee, const isminefilter& filter)
196+
std::list<COutputEntry>& listSent, CAmount& nFee, const isminefilter& filter,
197+
bool include_change)
197198
{
198199
nFee = 0;
199200
listReceived.clear();
@@ -218,8 +219,7 @@ void CachedTxGetAmounts(const CWallet& wallet, const CWalletTx& wtx,
218219
// 2) the output is to us (received)
219220
if (nDebit > 0)
220221
{
221-
// Don't report 'change' txouts
222-
if (OutputIsChange(wallet, txout))
222+
if (!include_change && OutputIsChange(wallet, txout))
223223
continue;
224224
}
225225
else if (!(fIsMine & filter))

src/wallet/receive.h

+2-1
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,8 @@ struct COutputEntry
4242
void CachedTxGetAmounts(const CWallet& wallet, const CWalletTx& wtx,
4343
std::list<COutputEntry>& listReceived,
4444
std::list<COutputEntry>& listSent,
45-
CAmount& nFee, const isminefilter& filter);
45+
CAmount& nFee, const isminefilter& filter,
46+
bool include_change);
4647
bool CachedTxIsFromMe(const CWallet& wallet, const CWalletTx& wtx, const isminefilter& filter);
4748
bool CachedTxIsTrusted(const CWallet& wallet, const CWalletTx& wtx, std::set<uint256>& trusted_parents) EXCLUSIVE_LOCKS_REQUIRED(wallet.cs_wallet);
4849
bool CachedTxIsTrusted(const CWallet& wallet, const CWalletTx& wtx);

src/wallet/rpc/coins.cpp

+4
Original file line numberDiff line numberDiff line change
@@ -543,6 +543,9 @@ RPCHelpMan listunspent()
543543
{RPCResult::Type::BOOL, "solvable", "Whether we know how to spend this output, ignoring the lack of keys"},
544544
{RPCResult::Type::BOOL, "reused", /*optional=*/true, "(only present if avoid_reuse is set) Whether this output is reused/dirty (sent to an address that was previously spent from)"},
545545
{RPCResult::Type::STR, "desc", /*optional=*/true, "(only when solvable) A descriptor for spending this output"},
546+
{RPCResult::Type::ARR, "parent_descs", /*optional=*/false, "List of parent descriptors for the scriptPubKey of this coin.", {
547+
{RPCResult::Type::STR, "desc", "The descriptor string."},
548+
}},
546549
{RPCResult::Type::BOOL, "safe", "Whether this output is considered safe to spend. Unconfirmed transactions\n"
547550
"from outside keys and unconfirmed replacement transactions are considered unsafe\n"
548551
"and are not eligible for spending by fundrawtransaction and sendtoaddress."},
@@ -722,6 +725,7 @@ RPCHelpMan listunspent()
722725
entry.pushKV("desc", descriptor->ToString());
723726
}
724727
}
728+
PushParentDescriptors(*pwallet, scriptPubKey, entry);
725729
if (avoid_reuse) entry.pushKV("reused", reused);
726730
entry.pushKV("safe", out.safe);
727731
results.push_back(entry);

src/wallet/rpc/transactions.cpp

+18-5
Original file line numberDiff line numberDiff line change
@@ -315,13 +315,16 @@ static void MaybePushAddress(UniValue & entry, const CTxDestination &dest)
315315
* @param filter_label Optional label string to filter incoming transactions.
316316
*/
317317
template <class Vec>
318-
static void ListTransactions(const CWallet& wallet, const CWalletTx& wtx, int nMinDepth, bool fLong, Vec& ret, const isminefilter& filter_ismine, const std::string* filter_label) EXCLUSIVE_LOCKS_REQUIRED(wallet.cs_wallet)
318+
static void ListTransactions(const CWallet& wallet, const CWalletTx& wtx, int nMinDepth, bool fLong,
319+
Vec& ret, const isminefilter& filter_ismine, const std::string* filter_label,
320+
bool include_change = false)
321+
EXCLUSIVE_LOCKS_REQUIRED(wallet.cs_wallet)
319322
{
320323
CAmount nFee;
321324
std::list<COutputEntry> listReceived;
322325
std::list<COutputEntry> listSent;
323326

324-
CachedTxGetAmounts(wallet, wtx, listReceived, listSent, nFee, filter_ismine);
327+
CachedTxGetAmounts(wallet, wtx, listReceived, listSent, nFee, filter_ismine, include_change);
325328

326329
bool involvesWatchonly = CachedTxIsFromMe(wallet, wtx, ISMINE_WATCH_ONLY);
327330

@@ -367,6 +370,7 @@ static void ListTransactions(const CWallet& wallet, const CWalletTx& wtx, int nM
367370
entry.pushKV("involvesWatchonly", true);
368371
}
369372
MaybePushAddress(entry, r.destination);
373+
PushParentDescriptors(wallet, wtx.tx->vout.at(r.vout).scriptPubKey, entry);
370374
if (wtx.IsCoinBase())
371375
{
372376
if (wallet.GetTxDepthInMainChain(wtx) < 1)
@@ -418,7 +422,11 @@ static const std::vector<RPCResult> TransactionDescriptionString()
418422
{RPCResult::Type::NUM_TIME, "timereceived", "The time received expressed in " + UNIX_EPOCH_TIME + "."},
419423
{RPCResult::Type::STR, "comment", /*optional=*/true, "If a comment is associated with the transaction, only present if not empty."},
420424
{RPCResult::Type::STR, "bip125-replaceable", "(\"yes|no|unknown\") Whether this transaction could be replaced due to BIP125 (replace-by-fee);\n"
421-
"may be unknown for unconfirmed transactions not in the mempool."}};
425+
"may be unknown for unconfirmed transactions not in the mempool."},
426+
{RPCResult::Type::ARR, "parent_descs", /*optional=*/true, "Only if 'category' is 'received'. List of parent descriptors for the scriptPubKey of this coin.", {
427+
{RPCResult::Type::STR, "desc", "The descriptor string."},
428+
}},
429+
};
422430
}
423431

424432
RPCHelpMan listtransactions()
@@ -543,6 +551,7 @@ RPCHelpMan listsinceblock()
543551
{"include_watchonly", RPCArg::Type::BOOL, RPCArg::DefaultHint{"true for watch-only wallets, otherwise false"}, "Include transactions to watch-only addresses (see 'importaddress')"},
544552
{"include_removed", RPCArg::Type::BOOL, RPCArg::Default{true}, "Show transactions that were removed due to a reorg in the \"removed\" array\n"
545553
"(not guaranteed to work on pruned nodes)"},
554+
{"include_change", RPCArg::Type::BOOL, RPCArg::Default{false}, "Also add entries for change outputs.\n"},
546555
},
547556
RPCResult{
548557
RPCResult::Type::OBJ, "", "",
@@ -623,6 +632,7 @@ RPCHelpMan listsinceblock()
623632
}
624633

625634
bool include_removed = (request.params[3].isNull() || request.params[3].get_bool());
635+
bool include_change = (!request.params[4].isNull() && request.params[4].get_bool());
626636

627637
int depth = height ? wallet.GetLastBlockHeight() + 1 - *height : -1;
628638

@@ -632,7 +642,7 @@ RPCHelpMan listsinceblock()
632642
const CWalletTx& tx = pairWtx.second;
633643

634644
if (depth == -1 || abs(wallet.GetTxDepthInMainChain(tx)) < depth) {
635-
ListTransactions(wallet, tx, 0, true, transactions, filter, nullptr /* filter_label */);
645+
ListTransactions(wallet, tx, 0, true, transactions, filter, nullptr /* filter_label */, /*include_change=*/include_change);
636646
}
637647
}
638648

@@ -649,7 +659,7 @@ RPCHelpMan listsinceblock()
649659
if (it != wallet.mapWallet.end()) {
650660
// We want all transactions regardless of confirmation count to appear here,
651661
// even negative confirmation ones, hence the big negative.
652-
ListTransactions(wallet, it->second, -100000000, true, removed, filter, nullptr /* filter_label */);
662+
ListTransactions(wallet, it->second, -100000000, true, removed, filter, nullptr /* filter_label */, /*include_change=*/include_change);
653663
}
654664
}
655665
blockId = block.hashPrevBlock;
@@ -709,6 +719,9 @@ RPCHelpMan gettransaction()
709719
"'send' category of transactions."},
710720
{RPCResult::Type::BOOL, "abandoned", /*optional=*/true, "'true' if the transaction has been abandoned (inputs are respendable). Only available for the \n"
711721
"'send' category of transactions."},
722+
{RPCResult::Type::ARR, "parent_descs", /*optional=*/true, "Only if 'category' is 'received'. List of parent descriptors for the scriptPubKey of this coin.", {
723+
{RPCResult::Type::STR, "desc", "The descriptor string."},
724+
}},
712725
}},
713726
}},
714727
{RPCResult::Type::STR_HEX, "hex", "Raw data for transaction"},

src/wallet/rpc/util.cpp

+9
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,15 @@ std::string LabelFromValue(const UniValue& value)
123123
return label;
124124
}
125125

126+
void PushParentDescriptors(const CWallet& wallet, const CScript& script_pubkey, UniValue& entry)
127+
{
128+
UniValue parent_descs(UniValue::VARR);
129+
for (const auto& desc: wallet.GetWalletDescriptors(script_pubkey)) {
130+
parent_descs.push_back(desc.descriptor->ToString());
131+
}
132+
entry.pushKV("parent_descs", parent_descs);
133+
}
134+
126135
void HandleWalletError(const std::shared_ptr<CWallet> wallet, DatabaseStatus& status, bilingual_str& error)
127136
{
128137
if (!wallet) {

src/wallet/rpc/util.h

+4
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@
55
#ifndef BITCOIN_WALLET_RPC_UTIL_H
66
#define BITCOIN_WALLET_RPC_UTIL_H
77

8+
#include <script/script.h>
9+
810
#include <any>
911
#include <memory>
1012
#include <string>
@@ -39,6 +41,8 @@ const LegacyScriptPubKeyMan& EnsureConstLegacyScriptPubKeyMan(const CWallet& wal
3941
bool GetAvoidReuseFlag(const CWallet& wallet, const UniValue& param);
4042
bool ParseIncludeWatchonly(const UniValue& include_watchonly, const CWallet& wallet);
4143
std::string LabelFromValue(const UniValue& value);
44+
//! Fetch parent descriptors of this scriptPubKey.
45+
void PushParentDescriptors(const CWallet& wallet, const CScript& script_pubkey, UniValue& entry);
4246

4347
void HandleWalletError(const std::shared_ptr<CWallet> wallet, DatabaseStatus& status, bilingual_str& error);
4448
} // namespace wallet

src/wallet/wallet.cpp

+12
Original file line numberDiff line numberDiff line change
@@ -3336,6 +3336,18 @@ std::unique_ptr<SigningProvider> CWallet::GetSolvingProvider(const CScript& scri
33363336
return nullptr;
33373337
}
33383338

3339+
std::vector<WalletDescriptor> CWallet::GetWalletDescriptors(const CScript& script) const
3340+
{
3341+
std::vector<WalletDescriptor> descs;
3342+
for (const auto spk_man: GetScriptPubKeyMans(script)) {
3343+
if (const auto desc_spk_man = dynamic_cast<DescriptorScriptPubKeyMan*>(spk_man)) {
3344+
LOCK(desc_spk_man->cs_desc_man);
3345+
descs.push_back(desc_spk_man->GetWalletDescriptor());
3346+
}
3347+
}
3348+
return descs;
3349+
}
3350+
33393351
LegacyScriptPubKeyMan* CWallet::GetLegacyScriptPubKeyMan() const
33403352
{
33413353
if (IsWalletFlagSet(WALLET_FLAG_DESCRIPTORS)) {

src/wallet/wallet.h

+3
Original file line numberDiff line numberDiff line change
@@ -845,6 +845,9 @@ class CWallet final : public WalletStorage, public interfaces::Chain::Notificati
845845
std::unique_ptr<SigningProvider> GetSolvingProvider(const CScript& script) const;
846846
std::unique_ptr<SigningProvider> GetSolvingProvider(const CScript& script, SignatureData& sigdata) const;
847847

848+
//! Get the wallet descriptors for a script.
849+
std::vector<WalletDescriptor> GetWalletDescriptors(const CScript& script) const;
850+
848851
//! Get the LegacyScriptPubKeyMan which is used for all types, internal, and external.
849852
LegacyScriptPubKeyMan* GetLegacyScriptPubKeyMan() const;
850853
LegacyScriptPubKeyMan* GetOrCreateLegacyScriptPubKeyMan();

test/functional/wallet_basic.py

+33
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
from itertools import product
88

99
from test_framework.blocktools import COINBASE_MATURITY
10+
from test_framework.descriptors import descsum_create
1011
from test_framework.test_framework import BitcoinTestFramework
1112
from test_framework.util import (
1213
assert_array_result,
@@ -700,6 +701,38 @@ def run_test(self):
700701
txid_feeReason_four = self.nodes[2].sendmany(dummy='', amounts={address: 5}, verbose=False)
701702
assert_equal(self.nodes[2].gettransaction(txid_feeReason_four)['txid'], txid_feeReason_four)
702703

704+
self.log.info("Testing 'listunspent' outputs the parent descriptor(s) of coins")
705+
# Create two multisig descriptors, and send a UTxO each.
706+
multi_a = descsum_create("wsh(multi(1,tpubD6NzVbkrYhZ4YBNjUo96Jxd1u4XKWgnoc7LsA1jz3Yc2NiDbhtfBhaBtemB73n9V5vtJHwU6FVXwggTbeoJWQ1rzdz8ysDuQkpnaHyvnvzR/*,tpubD6NzVbkrYhZ4YHdDGMAYGaWxMSC1B6tPRTHuU5t3BcfcS3nrF523iFm5waFd1pP3ZvJt4Jr8XmCmsTBNx5suhcSgtzpGjGMASR3tau1hJz4/*))")
707+
multi_b = descsum_create("wsh(multi(1,tpubD6NzVbkrYhZ4YHdDGMAYGaWxMSC1B6tPRTHuU5t3BcfcS3nrF523iFm5waFd1pP3ZvJt4Jr8XmCmsTBNx5suhcSgtzpGjGMASR3tau1hJz4/*,tpubD6NzVbkrYhZ4Y2RLiuEzNQkntjmsLpPYDm3LTRBYynUQtDtpzeUKAcb9sYthSFL3YR74cdFgF5mW8yKxv2W2CWuZDFR2dUpE5PF9kbrVXNZ/*))")
708+
addr_a = self.nodes[0].deriveaddresses(multi_a, 0)[0]
709+
addr_b = self.nodes[0].deriveaddresses(multi_b, 0)[0]
710+
txid_a = self.nodes[0].sendtoaddress(addr_a, 0.01)
711+
txid_b = self.nodes[0].sendtoaddress(addr_b, 0.01)
712+
self.generate(self.nodes[0], 1, sync_fun=self.no_op)
713+
# Now import the descriptors, make sure we can identify on which descriptor each coin was received.
714+
self.nodes[0].createwallet(wallet_name="wo", descriptors=True, disable_private_keys=True)
715+
wo_wallet = self.nodes[0].get_wallet_rpc("wo")
716+
wo_wallet.importdescriptors([
717+
{
718+
"desc": multi_a,
719+
"active": False,
720+
"timestamp": "now",
721+
},
722+
{
723+
"desc": multi_b,
724+
"active": False,
725+
"timestamp": "now",
726+
},
727+
])
728+
coins = wo_wallet.listunspent(minconf=0)
729+
assert_equal(len(coins), 2)
730+
coin_a = next(c for c in coins if c["txid"] == txid_a)
731+
assert_equal(coin_a["parent_descs"][0], multi_a)
732+
coin_b = next(c for c in coins if c["txid"] == txid_b)
733+
assert_equal(coin_b["parent_descs"][0], multi_b)
734+
self.nodes[0].unloadwallet("wo")
735+
703736

704737
if __name__ == '__main__':
705738
WalletTest().main()

test/functional/wallet_listsinceblock.py

+63
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66

77
from test_framework.address import key_to_p2wpkh
88
from test_framework.blocktools import COINBASE_MATURITY
9+
from test_framework.descriptors import descsum_create
910
from test_framework.key import ECKey
1011
from test_framework.test_framework import BitcoinTestFramework
1112
from test_framework.messages import MAX_BIP125_RBF_SEQUENCE
@@ -39,6 +40,8 @@ def run_test(self):
3940
self.test_double_send()
4041
self.double_spends_filtered()
4142
self.test_targetconfirmations()
43+
self.test_desc()
44+
self.test_send_to_self()
4245

4346
def test_no_blockhash(self):
4447
self.log.info("Test no blockhash")
@@ -383,5 +386,65 @@ def double_spends_filtered(self):
383386
assert_equal(original_found, False)
384387
assert_equal(double_found, False)
385388

389+
def test_desc(self):
390+
"""Make sure we can track coins by descriptor."""
391+
self.log.info("Test descriptor lookup by scriptPubKey.")
392+
393+
# Create a watchonly wallet tracking two multisig descriptors.
394+
multi_a = descsum_create("wsh(multi(1,tpubD6NzVbkrYhZ4YBNjUo96Jxd1u4XKWgnoc7LsA1jz3Yc2NiDbhtfBhaBtemB73n9V5vtJHwU6FVXwggTbeoJWQ1rzdz8ysDuQkpnaHyvnvzR/*,tpubD6NzVbkrYhZ4YHdDGMAYGaWxMSC1B6tPRTHuU5t3BcfcS3nrF523iFm5waFd1pP3ZvJt4Jr8XmCmsTBNx5suhcSgtzpGjGMASR3tau1hJz4/*))")
395+
multi_b = descsum_create("wsh(multi(1,tpubD6NzVbkrYhZ4YHdDGMAYGaWxMSC1B6tPRTHuU5t3BcfcS3nrF523iFm5waFd1pP3ZvJt4Jr8XmCmsTBNx5suhcSgtzpGjGMASR3tau1hJz4/*,tpubD6NzVbkrYhZ4Y2RLiuEzNQkntjmsLpPYDm3LTRBYynUQtDtpzeUKAcb9sYthSFL3YR74cdFgF5mW8yKxv2W2CWuZDFR2dUpE5PF9kbrVXNZ/*))")
396+
self.nodes[0].createwallet(wallet_name="wo", descriptors=True, disable_private_keys=True)
397+
wo_wallet = self.nodes[0].get_wallet_rpc("wo")
398+
wo_wallet.importdescriptors([
399+
{
400+
"desc": multi_a,
401+
"active": False,
402+
"timestamp": "now",
403+
},
404+
{
405+
"desc": multi_b,
406+
"active": False,
407+
"timestamp": "now",
408+
},
409+
])
410+
411+
# Send a coin to each descriptor.
412+
assert_equal(len(wo_wallet.listsinceblock()["transactions"]), 0)
413+
addr_a = self.nodes[0].deriveaddresses(multi_a, 0)[0]
414+
addr_b = self.nodes[0].deriveaddresses(multi_b, 0)[0]
415+
self.nodes[2].sendtoaddress(addr_a, 1)
416+
self.nodes[2].sendtoaddress(addr_b, 2)
417+
self.generate(self.nodes[2], 1)
418+
419+
# We can identify on which descriptor each coin was received.
420+
coins = wo_wallet.listsinceblock()["transactions"]
421+
assert_equal(len(coins), 2)
422+
coin_a = next(c for c in coins if c["amount"] == 1)
423+
assert_equal(coin_a["parent_descs"][0], multi_a)
424+
coin_b = next(c for c in coins if c["amount"] == 2)
425+
assert_equal(coin_b["parent_descs"][0], multi_b)
426+
427+
def test_send_to_self(self):
428+
"""We can make listsinceblock output our change outputs."""
429+
self.log.info("Test the inclusion of change outputs in the output.")
430+
431+
# Create a UTxO paying to one of our change addresses.
432+
block_hash = self.nodes[2].getbestblockhash()
433+
addr = self.nodes[2].getrawchangeaddress()
434+
self.nodes[2].sendtoaddress(addr, 1)
435+
436+
# If we don't list change, we won't have an entry for it.
437+
coins = self.nodes[2].listsinceblock(blockhash=block_hash)["transactions"]
438+
assert not any(c["address"] == addr for c in coins)
439+
440+
# Now if we list change, we'll get both the send (to a change address) and
441+
# the actual change.
442+
res = self.nodes[2].listsinceblock(blockhash=block_hash, include_change=True)
443+
coins = [entry for entry in res["transactions"] if entry["category"] == "receive"]
444+
assert_equal(len(coins), 2)
445+
assert any(c["address"] == addr for c in coins)
446+
assert all(self.nodes[2].getaddressinfo(c["address"])["ischange"] for c in coins)
447+
448+
386449
if __name__ == '__main__':
387450
ListSinceBlockTest().main()

0 commit comments

Comments
 (0)