Merge bitcoin/bitcoin#35813: wallet, rpc: Add listrawtransactions RPC
What changed, and why it matters
This commit adds a new wallet RPC called listrawtransactions to Bitcoin Core. It is a feature addition that lets users list every transaction their wallet knows about, including internal transfers and consolidations that the existing listtransactions RPC hides. The change is mostly a refactor of existing code into shared helpers plus a new RPC endpoint. There is no direct evidence in the commit of a security vulnerability, bug fix, or exploit.
No security action required. Treat as a normal feature addition. Reviewers may want to confirm that the new RPC respects existing wallet authentication and that the refactored helpers preserve prior gettransaction behavior.
Security signals we found
No security-relevant bug fix or vulnerability patch is present in the diff.
New RPC exposes additional wallet transaction metadata, but only to callers already authorized for wallet RPCs.
Code is a refactor of existing gettransaction logic into shared helpers; no new cryptographic, network, or consensus code.
Functional tests cover parameter validation, pagination, and verbose output.
No mention of CVE, security advisory, bug bounty, or independent researcher attribution in commit or PR description.
Evidence from the diff
The commit introduces listrawtransactions, a wallet RPC that returns each wallet transaction once with net balance change (amount) and fee when the wallet funded it. It reuses/refactors existing logic by extracting PushTxAmountAndFee and PushTxDecoded helpers from gettransaction, and makes TransactionDescriptionString’s parent_descs field optional so it can be omitted for this RPC. The new RPC is registered in the wallet RPC table and has a functional test. It operates under the wallet lock and uses the existing wtxOrdered index.
Changed components
src/wallet/rpc/transactions.cppsrc/wallet/rpc/wallet.cppsrc/rpc/client.cpptest/functional/wallet_listrawtransactions.pyInspect captured patch +334 / −27
### doc/release-notes-35813.md
@@ -0,0 +1,12 @@
+New RPCs
+--------
+
+- A new `listrawtransactions` RPC has been added to the wallet. Unlike
+ `listtransactions`, which only lists transactions with a logical economic
+ category (sends to external addresses, receives from external addresses), this
+ RPC returns every transaction the wallet knows about, including consolidations
+ and self-transfers that would otherwise be invisible. Each transaction appears
+ exactly once with its net wallet balance change excluding fee (`amount`) and,
+ when the wallet funded the transaction, the (negative) fee paid (`fee`). The
+ results support `count` and `skip` pagination, and a `verbose` flag that adds
+ a `decoded` field with the full decoded transaction data. (#35813)
### src/rpc/client.cpp
@@ -108,6 +108,9 @@ static const CRPCConvertParam vRPCConvertParams[] =
{ "listtransactions", 1, "count" },
{ "listtransactions", 2, "skip" },
{ "listtransactions", 3, "include_watchonly" },
+ { "listrawtransactions", 0, "count" },
+ { "listrawtransactions", 1, "skip" },
+ { "listrawtransactions", 2, "verbose" },
{ "walletpassphrase", 0, "passphrase", ParamFormat::STRING },
{ "walletpassphrase", 1, "timeout" },
{ "getblocktemplate", 0, "template_request" },
### src/wallet/rpc/transactions.cpp
@@ -393,10 +393,66 @@ static void ListTransactions(const CWallet& wallet, const CWalletTx& wtx, int nM
}
}
+static void PushTxAmountAndFee(const CWallet& wallet, const CWalletTx& wtx, UniValue& entry)
+ EXCLUSIVE_LOCKS_REQUIRED(wallet.cs_wallet)
+{
+ CAmount credit = CachedTxGetCredit(wallet, wtx, /*avoid_reuse=*/false);
+ CAmount debit = CachedTxGetDebit(wallet, wtx, /*avoid_reuse=*/false);
+ CAmount net = credit - debit;
+ bool is_from_me = CachedTxIsFromMe(wallet, wtx);
+ CAmount fee = (is_from_me ? wtx.GetTx()->GetValueOut() - debit : 0);
+
+ entry.pushKV("amount", ValueFromAmount(net - fee));
+ if (is_from_me)
+ entry.pushKV("fee", ValueFromAmount(fee));
+}
-static std::vector<RPCResult> TransactionDescriptionString()
+static void PushTxDecoded(const CWallet& wallet, const CWalletTx& wtx, UniValue& entry)
+ EXCLUSIVE_LOCKS_REQUIRED(wallet.cs_wallet)
{
- return{{RPCResult::Type::NUM, "confirmations", "The number of confirmations for the transaction. Negative confirmations means the\n"
+ UniValue decoded(UniValue::VOBJ);
+ TxToUniv(*wtx.GetTx(),
+ /*block_hash=*/uint256(),
+ /*entry=*/decoded,
+ /*include_hex=*/false,
+ /*txundo=*/nullptr,
+ /*verbosity=*/TxVerbosity::SHOW_DETAILS,
+ /*is_change_func=*/[&wallet](const CTxOut& txout) EXCLUSIVE_LOCKS_REQUIRED(wallet.cs_wallet) {
+ AssertLockHeld(wallet.cs_wallet);
+ return OutputIsChange(wallet, txout);
+ });
+ entry.pushKV("decoded", std::move(decoded));
+}
+
+/**
+ * Append a raw transaction entry for the given wallet transaction to ret.
+ *
+ * @param wallet The wallet.
+ * @param wtx The wallet transaction.
+ * @param ret Output vector to append the entry to.
+ * @param verbose If true, include a decoded transaction object.
+ */
+static void ListRawTransaction(const CWallet& wallet, const CWalletTx& wtx, std::vector<UniValue>& ret, bool verbose)
+ EXCLUSIVE_LOCKS_REQUIRED(wallet.cs_wallet)
+{
+ UniValue entry(UniValue::VOBJ);
+
+ PushTxAmountAndFee(wallet, wtx, entry);
+
+ WalletTxToJSON(wallet, wtx, entry);
+ entry.pushKV("abandoned", wtx.isAbandoned());
+ entry.pushKV("hex", EncodeHexTx(*wtx.GetTx()));
+
+ if (verbose) {
+ PushTxDecoded(wallet, wtx, entry);
+ }
+
+ ret.push_back(std::move(entry));
+}
+
+static std::vector<RPCResult> TransactionDescriptionString(bool include_parent_descs = true)
+{
+ std::vector<RPCResult> result{{RPCResult::Type::NUM, "confirmations", "The number of confirmations for the transaction. Negative confirmations means the\n"
"transaction conflicted that many blocks ago."},
{RPCResult::Type::BOOL, "generated", /*optional=*/true, "Only present if the transaction's only input is a coinbase one."},
{RPCResult::Type::BOOL, "trusted", /*optional=*/true, "Whether we consider the transaction to be trusted and safe to spend from.\n"
@@ -415,8 +471,8 @@ static std::vector<RPCResult> TransactionDescriptionString()
{
{RPCResult::Type::STR_HEX, "txid", "The transaction id."},
}},
- {RPCResult::Type::STR_HEX, "replaced_by_txid", /*optional=*/true, "Only if 'category' is 'send'. The txid if this tx was replaced."},
- {RPCResult::Type::STR_HEX, "replaces_txid", /*optional=*/true, "Only if 'category' is 'send'. The txid if this tx replaces another."},
+ {RPCResult::Type::STR_HEX, "replaced_by_txid", /*optional=*/true, "The txid of the transaction that replaced this one."},
+ {RPCResult::Type::STR_HEX, "replaces_txid", /*optional=*/true, "The txid of the transaction that this one replaces."},
{RPCResult::Type::ARR, "mempoolconflicts", "Transactions in the mempool that directly conflict with either this transaction or an ancestor transaction",
{
{RPCResult::Type::STR_HEX, "txid", "The transaction id."},
@@ -427,10 +483,13 @@ static std::vector<RPCResult> TransactionDescriptionString()
{RPCResult::Type::STR, "comment", /*optional=*/true, "If a comment is associated with the transaction, only present if not empty."},
{RPCResult::Type::STR, "bip125-replaceable", /*optional=*/true, "(\"yes|no|unknown\") (DEPRECATED) Whether this transaction signals BIP125 replaceability or has an unconfirmed ancestor signaling BIP125 replaceability.\n"
"May be unknown for unconfirmed transactions not in the mempool because their unconfirmed ancestors are unknown."},
- {RPCResult::Type::ARR, "parent_descs", /*optional=*/true, "Only if 'category' is 'receive'. List of parent descriptors for the output script of this coin.", {
- {RPCResult::Type::STR, "desc", "The descriptor string."},
- }},
};
+ if (include_parent_descs) {
+ result.push_back({RPCResult::Type::ARR, "parent_descs", /*optional=*/true, "Only if 'category' is 'receive'. List of parent descriptors for the output script of this coin.", {
+ {RPCResult::Type::STR, "desc", "The descriptor string."},
+ }});
+ }
+ return result;
}
RPCMethod listtransactions()
@@ -541,6 +600,90 @@ RPCMethod listtransactions()
};
}
+RPCMethod listrawtransactions()
+{
+ return RPCMethod{
+ "listrawtransactions",
+ "Returns up to 'count' most recent wallet transactions ordered from oldest to newest, "
+ "skipping the first 'skip' transactions. Unlike `listtransactions`, each wallet transaction "
+ "appears exactly once with its net wallet balance change excluding fee, without logical "
+ "interpretation (no category assignment, no change suppression). This means consolidation "
+ "and self-transfer transactions that are invisible in `listtransactions` are included here.\n",
+ {
+ {"count", RPCArg::Type::NUM, RPCArg::Default{10}, "The number of transactions to return."},
+ {"skip", RPCArg::Type::NUM, RPCArg::Default{0}, "The number of transactions to skip."},
+ {"verbose", RPCArg::Type::BOOL, RPCArg::Default{false}, "Whether to include a `decoded` field containing the decoded transaction (equivalent to RPC decoderawtransaction)"},
+ },
+ RPCResult{
+ RPCResult::Type::ARR, "", "",
+ {
+ {RPCResult::Type::OBJ, "", "", Cat<std::vector<RPCResult>>(
+ {
+ {RPCResult::Type::STR_AMOUNT, "amount", "The net change to the wallet balance caused by this transaction "
+ "(excluding fee). Positive means the wallet gained funds, negative means it lost funds, "
+ "zero means a pure self-transfer (e.g. consolidation)."},
+ {RPCResult::Type::STR_AMOUNT, "fee", /*optional=*/true, "The fee paid in " + CURRENCY_UNIT + ". "
+ "This is negative and only present when the wallet funded the transaction."},
+ },
+ Cat(TransactionDescriptionString(/*include_parent_descs=*/false),
+ {
+ {RPCResult::Type::BOOL, "abandoned", "'true' if the transaction has been abandoned (inputs are respendable)."},
+ {RPCResult::Type::STR_HEX, "hex", "Raw data for transaction"},
+ {RPCResult::Type::OBJ, "decoded", /*optional=*/true, "The decoded transaction (only present when `verbose` is passed)",
+ {
+ TxDoc({.wallet = true}),
+ }},
+ }))},
+ }
+ },
+ RPCExamples{
+ "\nList the most recent 10 transactions\n"
+ + HelpExampleCli("listrawtransactions", "") +
+ "\nList transactions 100 to 120\n"
+ + HelpExampleCli("listrawtransactions", "20 100") +
+ "\nList the most recent 10 transactions with decoded transaction data\n"
+ + HelpExampleCli("listrawtransactions", "10 0 true") +
+ "\nAs a JSON-RPC call\n"
+ + HelpExampleRpc("listrawtransactions", "20, 100")
+ },
+ [](const RPCMethod& self, const JSONRPCRequest& request) -> UniValue
+{
+ const std::shared_ptr<const CWallet> pwallet = GetWalletForJSONRPCRequest(request);
+ if (!pwallet) return UniValue::VNULL;
+
+ pwallet->BlockUntilSyncedToCurrentChain();
+
+ int count = self.Arg<int>("count");
+ int skip = self.Arg<int>("skip");
+ bool verbose = self.Arg<bool>("verbose");
+
+ if (count < 0)
+ throw JSONRPCError(RPC_INVALID_PARAMETER, "Negative count");
+ if (skip < 0)
+ throw JSONRPCError(RPC_INVALID_PARAMETER, "Negative skip");
+
+ std::vector<UniValue> ret;
+ {
+ LOCK(pwallet->cs_wallet);
+
+ const CWallet::TxItems& tx_ordered = pwallet->wtxOrdered;
+
+ int skipped = 0;
+ for (CWallet::TxItems::const_reverse_iterator it = tx_ordered.rbegin(); it != tx_ordered.rend(); ++it) {
+ if ((int)ret.size() >= count) break;
+ if (skipped++ < skip) continue;
+ CWalletTx* const pwtx = (*it).second;
+ ListRawTransaction(*pwallet, *pwtx, ret, verbose);
+ }
+ }
+
+ UniValue result{UniValue::VARR};
+ result.push_backV(std::make_move_iterator(ret.rbegin()), std::make_move_iterator(ret.rend()));
+ return result;
+},
+ };
+}
+
static std::vector<RPCResult> ListSinceBlockTxFields()
{
return Cat<std::vector<RPCResult>>(
@@ -768,14 +911,7 @@ RPCMethod gettransaction()
}
const CWalletTx& wtx = it->second;
- CAmount nCredit = CachedTxGetCredit(*pwallet, wtx, /*avoid_reuse=*/false);
- CAmount nDebit = CachedTxGetDebit(*pwallet, wtx, /*avoid_reuse=*/false);
- CAmount nNet = nCredit - nDebit;
- CAmount nFee = (CachedTxIsFromMe(*pwallet, wtx) ? wtx.GetTx()->GetValueOut() - nDebit : 0);
-
- entry.pushKV("amount", ValueFromAmount(nNet - nFee));
- if (CachedTxIsFromMe(*pwallet, wtx))
- entry.pushKV("fee", ValueFromAmount(nFee));
+ PushTxAmountAndFee(*pwallet, wtx, entry);
WalletTxToJSON(*pwallet, wtx, entry);
@@ -786,18 +922,7 @@ RPCMethod gettransaction()
entry.pushKV("hex", EncodeHexTx(*wtx.GetTx()));
if (verbose) {
- UniValue decoded(UniValue::VOBJ);
- TxToUniv(*wtx.GetTx(),
- /*block_hash=*/uint256(),
- /*entry=*/decoded,
- /*include_hex=*/false,
- /*txundo=*/nullptr,
- /*verbosity=*/TxVerbosity::SHOW_DETAILS,
- /*is_change_func=*/[&pwallet](const CTxOut& txout) EXCLUSIVE_LOCKS_REQUIRED(pwallet->cs_wallet) {
- AssertLockHeld(pwallet->cs_wallet);
- return OutputIsChange(*pwallet, txout);
- });
- entry.pushKV("decoded", std::move(decoded));
+ PushTxDecoded(*pwallet, wtx, entry);
}
AppendLastProcessedBlock(entry, *pwallet);
### src/wallet/rpc/wallet.cpp
@@ -1109,6 +1109,7 @@ RPCMethod signmessage();
RPCMethod listreceivedbyaddress();
RPCMethod listreceivedbylabel();
RPCMethod listtransactions();
+RPCMethod listrawtransactions();
RPCMethod listsinceblock();
RPCMethod gettransaction();
RPCMethod abandontransaction();
@@ -1153,6 +1154,7 @@ std::span<const CRPCCommand> GetWalletRPCCommands()
{"wallet", &listreceivedbylabel},
{"wallet", &listsinceblock},
{"wallet", &listtransactions},
+ {"wallet", &listrawtransactions},
{"wallet", &listunspent},
{"wallet", &listwalletdir},
{"wallet", &listwallets},
### test/functional/test_runner.py
@@ -151,6 +151,7 @@
'p2p_sendheaders.py',
'feature_config_args.py',
'wallet_listtransactions.py',
+ 'wallet_listrawtransactions.py',
'wallet_miniscript.py',
# vv Tests less than 30s vv
'wallet_deprecated_rbf.py',
### test/functional/wallet_listrawtransactions.py
@@ -0,0 +1,164 @@
+#!/usr/bin/env python3
+# Copyright (c) 2026-present The Bitcoin Core developers
+# Distributed under the MIT software license, see the accompanying
+# file COPYING or http://www.opensource.org/licenses/mit-license.php.
+"""Test the listrawtransactions RPC."""
+
+from decimal import Decimal
+
+from test_framework.test_framework import BitcoinTestFramework
+from test_framework.util import (
+ assert_equal,
+ assert_greater_than,
+ assert_greater_than_or_equal,
+ assert_raises_rpc_error,
+)
+
+
+class ListRawTransactionsTest(BitcoinTestFramework):
+ def set_test_params(self):
+ self.num_nodes = 1
+
+ def skip_test_if_missing_module(self):
+ self.skip_if_no_wallet()
+
+ def run_test(self):
+ self.test_invalid_parameters()
+ self.test_one_entry_per_tx()
+ self.test_coinbase_tx()
+ self.test_consolidation_tx_visible()
+ self.test_count_and_skip()
+ self.test_verbose()
+
+ def test_invalid_parameters(self):
+ self.log.info("Test listrawtransactions RPC parameter validity")
+ assert_raises_rpc_error(-8, "Negative count", self.nodes[0].listrawtransactions, -1)
+ assert_raises_rpc_error(-8, "Negative skip", self.nodes[0].listrawtransactions, 10, -1)
+
+ def test_one_entry_per_tx(self):
+ self.log.info("Test that each tx appears exactly once, unlike listtransactions which shows one entry per output")
+ self.generate(self.nodes[0], 101)
+
+ # Send-to-self to a wallet receiving address (not change). listtransactions
+ # produces two entries for this txid — one "send", one "receive" — because
+ # it works per-output. listrawtransactions must produce exactly one.
+ addr = self.nodes[0].getnewaddress()
+ txid = self.nodes[0].sendtoaddress(addr, 0.5)
+
+ lrt_matching = [tx for tx in self.nodes[0].listrawtransactions(20) if tx["txid"] == txid]
+ assert_equal(len(lrt_matching), 1)
+
+ def test_coinbase_tx(self):
+ self.log.info("Test that coinbase transactions appear in listrawtransactions with generated=True and no category")
+ node = self.nodes[0]
+
+ # Block 1's coinbase is mature (101 blocks generated in test_one_entry_per_tx).
+ coinbase_txid = node.getblock(node.getblockhash(1))["tx"][0]
+
+ results = node.listrawtransactions(9999)
+ matching = [tx for tx in results if tx["txid"] == coinbase_txid]
+
+ # Appears exactly once — same as any other tx.
+ assert_equal(len(matching), 1)
+ entry = matching[0]
+
+ # Coinbase is flagged via "generated", not via a "category" field —
+ # listrawtransactions intentionally omits category assignment.
+ assert_equal(entry["generated"], True)
+ assert "category" not in entry
+
+ # Wallet received the block reward (positive amount, no fee).
+ assert_greater_than(entry["amount"], 0)
+ assert "fee" not in entry
+
+ def test_consolidation_tx_visible(self):
+ self.log.info("Test that consolidation tx (all inputs/outputs owned by wallet) appears in listrawtransactions")
+
+ # Fund wallet with two UTXOs at distinct fresh addresses so listunspent
+ # can retrieve each one unambiguously.
+ addr1 = self.nodes[0].getnewaddress()
+ addr2 = self.nodes[0].getnewaddress()
+ self.nodes[0].sendtoaddress(addr1, 1.0)
+ self.nodes[0].sendtoaddress(addr2, 1.0)
+ self.generate(self.nodes[0], 1)
+
+ utxo1 = self.nodes[0].listunspent(1, 9999, [addr1])[0]
+ utxo2 = self.nodes[0].listunspent(1, 9999, [addr2])[0]
+
+ # Build consolidation tx: two inputs → one output, all wallet-owned.
+ # The output must go to a change-keychain address so that listtransactions
+ # omits it (outputs on the receiving keychain would appear as "receive").
+ inputs = [{"txid": utxo1["txid"], "vout": utxo1["vout"]},
+ {"txid": utxo2["txid"], "vout": utxo2["vout"]}]
+ total = utxo1["amount"] + utxo2["amount"]
+ fee = Decimal("0.0001")
+ outputs = {self.nodes[0].getrawchangeaddress(): total - fee}
+
+ raw_hex = self.nodes[0].createrawtransaction(inputs, outputs)
+ signed = self.nodes[0].signrawtransactionwithwallet(raw_hex)
+ assert_equal(signed["complete"], True)
+ consolidation_txid = self.nodes[0].sendrawtransaction(signed["hex"])
+ self.generate(self.nodes[0], 1)
+
+ # listtransactions omits it — no logical category (neither send to an
+ # external address nor receive from outside).
+ # listrawtransactions shows it exactly once.
+ lrt_results = self.nodes[0].listrawtransactions(100)
+ matching = [tx for tx in lrt_results if tx["txid"] == consolidation_txid]
+ assert_equal(len(matching), 1)
+
+ # amount = 0: no external flow (all funds stayed in the wallet).
+ # fee is negative: reflects the miner fee paid.
+ entry = matching[0]
+ assert_equal(entry["amount"], Decimal("0"))
+ assert_equal(entry["fee"], -fee)
+
+ def test_count_and_skip(self):
+ self.log.info("Test count and skip")
+ node = self.nodes[0]
+
+ all_txs = node.listrawtransactions(9999)
+ total = len(all_txs)
+ assert_greater_than_or_equal(total, 3) # Need at least 3 txs for pagination test
+
+ # count=0 returns an empty page.
+ assert_equal(node.listrawtransactions(0), [])
+
+ # count=2 returns the 2 newest txs ordered oldest-first within the result.
+ page = node.listrawtransactions(2)
+ assert_equal(len(page), 2)
+ assert_equal(page[0]["txid"], all_txs[-2]["txid"])
+ assert_equal(page[1]["txid"], all_txs[-1]["txid"])
+
+ # skip=1 skips the newest tx; the next 2 are returned.
+ page_skipped = node.listrawtransactions(2, 1)
+ assert_equal(len(page_skipped), 2)
+ assert_equal(page_skipped[0]["txid"], all_txs[-3]["txid"])
+ assert_equal(page_skipped[1]["txid"], all_txs[-2]["txid"])
+
+ # skip beyond total returns empty.
+ assert_equal(len(node.listrawtransactions(10, total + 10)), 0)
+
+ def test_verbose(self):
+ self.log.info("Test verbose=True includes hex and decoded fields")
+ node = self.nodes[0]
+
+ # Without verbose: hex present, decoded absent.
+ results = node.listrawtransactions(1)
+ assert_equal(len(results), 1)
+ entry = results[0]
+ assert "hex" in entry
+ assert "decoded" not in entry
+
+ # With verbose: both hex and decoded present.
+ results_verbose = node.listrawtransactions(1, 0, True)
+ assert_equal(len(results_verbose), 1)
+ entry_verbose = results_verbose[0]
+ assert "hex" in entry_verbose
+ assert "decoded" in entry_verbose
+ assert "txid" in entry_verbose["decoded"]
+ assert_equal(entry_verbose["txid"], entry_verbose["decoded"]["txid"])
+
+
+if __name__ == "__main__":
+ ListRawTransactionsTest(__file__).main()Why this scored 20/100
Community notes
Notes can correct, qualify, or add evidence to the AI analysis. Every note shown here has been validated by a human moderator.
The AI analysis stands alone for now. Submit a note if you can add evidence or important context.